ALB 502 Bad Gateway 완전 분석: 인스턴스가 Healthy인데 왜 502가 발생하는가
ALB 액세스 로그에 502가 쏟아지는데 타겟 그룹 콘솔에는 인스턴스가 멀쩡히 Healthy로 표시되어 있다. 이 상황이 혼란스러운 이유는 ALB의 헬스체크와 실제 요청 처리가 완전히 별개의 레이어에서 동작하기 때문이다. 헬스체크는 통과했지만 실제 HTTP 응답이 ALB의 기대를 벗어나는 순간 502가 발생한다.
TL;DR — ALB 502 원인 분류
| 원인 카테고리 | 증상 | 핵심 확인 지점 |
|---|---|---|
| HTTP 프로토콜 위반 | 모든 요청에서 502 발생 | 응답 헤더 형식, Content-Length 불일치 |
| Keep-Alive 타임아웃 불일치 | 간헐적 502, 로드 증가 시 악화 | ALB idle timeout vs 앱 서버 keep-alive timeout |
| 응답 헤더 크기 초과 | 특정 요청에서만 502 | 응답 헤더 총 크기 |
| 타겟 연결 거부/타임아웃 | target_status_code가 비어 있음 | 보안 그룹, 포트 바인딩 |
| 청크 인코딩 오류 | 대용량 응답에서 502 | Transfer-Encoding 헤더 처리 |
ALB 502가 발생하는 메커니즘
ALB는 클라이언트와 타겟 사이에서 HTTP 레이어 7 프록시로 동작한다. 타겟이 Healthy 상태라는 것은 ALB가 헬스체크 엔드포인트에서 정상 응답을 받았다는 의미일 뿐, 실제 애플리케이션 요청에 대한 응답이 올바르다는 보장이 아니다. ALB는 타겟으로부터 받은 HTTP 응답이 RFC를 위반하거나, 연결이 예기치 않게 끊기거나, 응답 자체를 받지 못하면 클라이언트에게 502를 반환한다.
- 클라이언트 → ALB: HTTP 요청 수신
- ALB → 타겟: 기존 Keep-Alive 연결 또는 신규 연결로 요청 전달
- 타겟 응답 검증: ALB가 HTTP 응답 형식, 헤더, 인코딩을 검증
- 검증 실패 또는 연결 오류: ALB가 클라이언트에게 502 반환
- 헬스체크는 별도 경로: 헬스체크 성공 여부와 무관하게 502 발생 가능
헬스체크는 '타겟이 살아있는가'를 확인하고, ALB의 502는 '타겟의 응답이 올바른가'를 판단한다. 두 질문의 답은 독립적이다.
1단계: ALB 액세스 로그에서 502 패턴 파악
콘솔의 메트릭만 보면 502 발생 시점과 빈도는 알 수 있지만 원인은 알 수 없다. 액세스 로그의 elb_status_code와 target_status_code 필드 조합이 진단의 출발점이다. target_status_code가 비어 있으면 ALB가 타겟으로부터 응답 자체를 받지 못한 것이고, 값이 있으면 타겟은 응답했지만 그 내용에 문제가 있는 것이다.
먼저 S3에서 액세스 로그를 가져온다.
# ALB 액세스 로그 S3 경로 확인
aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/1234567890abcdef \
--query 'Attributes[?Key==`access_logs.s3.enabled` || Key==`access_logs.s3.bucket` || Key==`access_logs.s3.prefix`]'
# 로그 다운로드 (날짜와 계정 ID는 실제 값으로 교체)
aws s3 sync s3://my-log-bucket/AWSLogs/123456789012/elasticloadbalancing/us-east-1/2024/01/15/ ./alb-logs/
로그를 받았으면 502 발생 패턴을 분석한다. ALB 액세스 로그는 공백으로 구분된 필드를 사용하며, AWS 공식 문서 기준으로 target_processing_time은 7번째 필드, elb_status_code는 9번째 필드, target_status_code는 10번째 필드, 요청 URL은 13번째 필드다.
cd alb-logs
# 502 오류 전체 추출 — elb_status(9), target_status(10), target_proc_time(7), request(13)
zcat *.log.gz | awk '$9 == 502 {print "elb_status:"$9, "target_status:"$10, "target_proc_time:"$7, "request:"$13}' | head -50
# target_status_code가 비어 있는 경우 (타겟 연결 실패)
zcat *.log.gz | awk '$9 == 502 && $10 == "-" {print $0}' | wc -l
# target_processing_time이 30초 이상인 경우 (응답 지연)
zcat *.log.gz | awk '$7 > 30 {print "elb_status:"$9, "target_status:"$10, "target_proc_time:"$7, "request:"$13}' | sort -k3 -rn | head -20
이 분석에서 두 가지 시나리오로 분기된다. target_status_code가 -이면 연결 레이어 문제(3단계), 값이 있으면 HTTP 응답 내용 문제(2단계)로 진행한다.
2단계: HTTP 응답 형식 문제 진단 — ALB 502의 가장 흔한 원인
타겟이 응답은 했지만 ALB가 502를 반환하는 경우, 응답 자체의 형식이 문제다. 가장 자주 마주치는 패턴은 세 가지다.
Keep-Alive 타임아웃 불일치
이게 실제로 가장 많이 놓치는 원인이다. ALB의 idle timeout(기본 60초)보다 애플리케이션 서버의 keep-alive timeout이 짧게 설정되어 있으면, ALB는 살아있다고 판단한 연결로 요청을 보내지만 서버는 이미 그 연결을 닫은 상태다. 서버 입장에서는 정상적으로 연결을 닫은 것이고, ALB 입장에서는 요청을 보냈는데 연결이 끊긴 것이므로 502를 반환한다.
ALB idle timeout을 60초로 설정했다면, 애플리케이션 서버의 keep-alive timeout은 반드시 60초보다 길게 설정해야 한다. 같거나 짧으면 경쟁 조건이 발생한다.
# ALB idle timeout 확인
aws elbv2 describe-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/1234567890abcdef \
--query 'Attributes[?Key==`idle_timeout.timeout_seconds`]'
# ALB idle timeout 조정 (애플리케이션 서버 keep-alive보다 짧게 설정)
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/1234567890abcdef \
--attributes Key=idle_timeout.timeout_seconds,Value=50
Nginx를 사용한다면 keepalive_timeout을 ALB idle timeout보다 크게 설정한다. Node.js HTTP 서버라면 server.keepAliveTimeout과 server.headersTimeout을 확인한다. Node.js 10.x 이후 기본 keep-alive timeout이 5초로 변경된 이력이 있어 ALB 뒤에 배치하면 이 문제가 즉시 재현된다.
응답 헤더 크기 초과
특정 요청(주로 로그인 후 세션 쿠키가 많은 경우)에서만 502가 발생한다면 응답 헤더 크기를 의심한다. ALB가 처리할 수 있는 응답 헤더 크기에는 제한이 있다. 정확한 한도는 AWS 공식 문서를 확인하되, 실제 응답 헤더를 직접 측정하는 것이 빠르다.
# 타겟 인스턴스에 직접 접속해서 응답 헤더 크기 확인
curl -s -D - -o /dev/null http://<instance-private-ip>:<port>/<path> | head -50
# 헤더 크기만 측정
curl -s -D /tmp/headers.txt -o /dev/null http://<instance-private-ip>:<port>/<path>
wc -c /tmp/headers.txt
잘못된 Transfer-Encoding 또는 Content-Length
청크 인코딩 응답에서 청크 크기와 실제 데이터가 불일치하거나, Content-Length 헤더 값이 실제 바디 크기와 다르면 ALB가 응답 파싱 중 연결을 끊고 502를 반환한다. 이는 주로 커스텀 미들웨어나 프록시 레이어가 응답을 가공할 때 발생한다.
# 타겟에서 직접 응답 받아 Transfer-Encoding 확인
curl -v http://<instance-private-ip>:<port>/<path> 2>&1 | grep -E 'Transfer-Encoding|Content-Length|< HTTP'
3단계: 타겟 연결 실패 진단 — target_status_code가 '-'인 경우
액세스 로그에서 target_status_code가 -로 찍힌다면 ALB가 타겟과 연결 자체를 맺지 못했거나, 연결 후 응답을 받기 전에 연결이 끊긴 것이다. 헬스체크는 통과하는데 실제 요청에서 이 패턴이 나온다면 헬스체크 경로와 실제 요청 경로의 처리 방식 차이를 의심한다.
# 타겟 그룹 헬스 상태 상세 확인
aws elbv2 describe-target-health \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/1234567890abcdef
# 타겟 그룹 속성 확인 (deregistration_delay, slow_start 등)
aws elbv2 describe-target-group-attributes \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-tg/1234567890abcdef
보안 그룹이 ALB에서 타겟으로의 트래픽을 허용하는지 확인한다. ALB는 VPC 내부에서 타겟에 접근하므로 타겟 인스턴스의 보안 그룹 인바운드 규칙에 ALB 보안 그룹 ID가 명시되어 있어야 한다.
# ALB 보안 그룹 확인
aws elbv2 describe-load-balancers \
--load-balancer-arns arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/1234567890abcdef \
--query 'LoadBalancers[0].SecurityGroups'
# 타겟 인스턴스 보안 그룹 인바운드 규칙 확인
aws ec2 describe-security-groups \
--group-ids sg-0123456789abcdef0 \
--query 'SecurityGroups[0].IpPermissions'
4단계: CloudWatch 메트릭으로 타임라인 재구성
개별 로그 분석이 끝났으면 메트릭으로 502 발생 패턴을 시간축에 올려놓는다. 502가 특정 시간대에 집중되는지, 타겟 응답 시간 증가와 상관관계가 있는지 확인하면 원인 카테고리를 좁힐 수 있다.
# HTTPCode_ELB_5XX_Count 메트릭 조회 (최근 3시간, 1분 단위)
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_ELB_5XX_Count \
--dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef \
--start-time $(date -u -d '3 hours ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 \
--statistics Sum \
--query 'sort_by(Datapoints, &Timestamp)[*].[Timestamp,Sum]' \
--output table
# TargetResponseTime P99 확인
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name TargetResponseTime \
--dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef \
--start-time $(date -u -d '3 hours ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 \
--statistics p99 \
--query 'sort_by(Datapoints, &Timestamp)[*].[Timestamp,p99]' \
--output table
실제 장애 사례: Healthy인데 502가 간헐적으로 발생한 경우
Node.js Express 애플리케이션을 ALB 뒤에 배포한 직후부터 간헐적 502가 발생했다. 타겟은 Healthy였고, 502 발생 빈도는 트래픽이 증가할수록 높아졌다. 처음에는 애플리케이션 코드 문제로 판단해서 에러 핸들링을 강화했지만 개선이 없었다.
액세스 로그를 분석하니 502 케이스의 target_status_code가 모두 -였다. 타겟이 응답하지 않은 것이다. 그런데 애플리케이션 로그에는 해당 요청에 대한 기록이 없었다. ALB가 요청을 보냈는데 애플리케이션이 받지 못한 것이다.
원인은 Node.js HTTP 서버의 기본 keepAliveTimeout이 5초였던 것이다. ALB idle timeout은 기본값 60초. ALB는 최대 60초까지 연결을 재사용하려 하지만, Node.js 서버는 5초 후 연결을 닫는다. 트래픽이 적을 때는 새 연결이 자주 생성되어 문제가 드러나지 않다가, 트래픽이 증가해 연결 재사용이 활발해지면서 경쟁 조건이 빈번해졌다.
// Node.js 서버 설정 수정
const server = app.listen(3000);
// ALB idle timeout(60초)보다 길게 설정
server.keepAliveTimeout = 65000; // 65초
server.headersTimeout = 66000; // keepAliveTimeout보다 약간 길게
ALB idle timeout을 55초로 줄이는 방법도 있지만, 서버 측 timeout을 늘리는 것이 더 안전하다. ALB idle timeout 단축은 정상적인 장기 연결에도 영향을 준다.
진단에 필요한 IAM 권한
위 진단 명령어를 실행하려면 읽기 권한과 속성 수정 권한이 필요하다. 두 권한의 범위가 다르므로 역할을 분리하는 것이 원칙에 맞다.
🔽 IAM 정책 예시 펼치기
// 읽기 전용 진단 정책
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ALBDiagnosticReadOnly",
"Effect": "Allow",
"Action": [
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeLoadBalancerAttributes",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetGroupAttributes",
"elasticloadbalancing:DescribeTargetHealth",
"cloudwatch:GetMetricStatistics",
"ec2:DescribeSecurityGroups",
"s3:GetObject",
"s3:ListBucket"
],
"Resource": "*"
}
]
}
// 속성 수정 정책 (별도 역할로 분리 권장)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ALBAttributeModify",
"Effect": "Allow",
"Action": [
"elasticloadbalancing:ModifyLoadBalancerAttributes"
],
"Resource": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/1234567890abcdef"
}
]
}
ALB 502 진단 플로우
target_status_code 필드"] B --> C{"target_status_code 값?"} C -->|"'-' (없음)"| D["타겟 연결 실패"] C -->|"값 있음"| E["HTTP 응답 형식 문제"] D --> D1["보안 그룹 인바운드 규칙 확인"] D --> D2["Keep-Alive timeout 불일치 확인"] D --> D3["포트 바인딩 / 프로세스 상태 확인"] E --> E1["응답 헤더 크기 측정"] E --> E2["Transfer-Encoding 검증"] E --> E3["Content-Length 불일치 확인"] D1 --> F["수정 적용"] D2 --> F D3 --> F E1 --> F E2 --> F E3 --> F F --> G["CloudWatch HTTPCode_ELB_5XX_Count 감소 확인"]
- 액세스 로그 확인: 502 발생 여부와 target_status_code 값 확인
- target_status_code 분기: '-'이면 연결 실패, 값이 있으면 응답 형식 문제
- 연결 실패 경로: 보안 그룹 → 포트 바인딩 → Keep-Alive timeout 순서로 확인
- 응답 형식 문제 경로: 헤더 크기 → Transfer-Encoding → Content-Length 확인
- 수정 후 검증: CloudWatch 메트릭으로 502 감소 확인
ALB 502 진단 마무리 및 다음 단계
ALB 502는 타겟 헬스 상태와 무관하게 발생할 수 있다. 액세스 로그의 target_status_code 필드가 진단의 핵심 분기점이다. 이 값이 없으면 연결 레이어, 값이 있으면 HTTP 응답 레이어를 파고든다. Keep-Alive timeout 불일치는 가장 흔하면서도 가장 늦게 발견되는 원인이므로, 새 애플리케이션을 ALB 뒤에 배포할 때는 서버의 keep-alive 설정을 먼저 확인하는 습관을 들이는 것이 좋다.
핵심 용어 정리
| 용어 | 설명 |
|---|---|
| target_status_code | ALB 액세스 로그 필드. 타겟이 반환한 HTTP 상태 코드. '-'이면 타겟으로부터 응답을 받지 못한 것 |
| idle timeout | ALB가 클라이언트 또는 타겟과의 유휴 연결을 유지하는 최대 시간. 기본값 60초 |
| Keep-Alive timeout | HTTP 연결을 재사용하기 위해 서버가 연결을 열어두는 시간. ALB idle timeout과 정렬이 필요 |
| Transfer-Encoding: chunked | HTTP 응답 바디를 청크 단위로 전송하는 방식. 청크 크기 선언과 실제 데이터 불일치 시 502 유발 |
| HTTPCode_ELB_5XX_Count | ALB가 자체적으로 생성한 5xx 응답 수를 나타내는 CloudWatch 메트릭 |
댓글
댓글 쓰기