들어가며
운영 중인 EKS 서비스에서 S3 파일 조회, 이미지 미리보기, 파일 업로드가 동시에 먹통이 되는 장애가 발생했다. Pod 로그를 확인하니 이런 경고가 반복되고 있었다:
@smithy/node-http-handler:WARN - socket usage at capacity=50 and 190 additional requests are enqueued.
소켓 50개가 전부 점유된 상태에서 190개 요청이 대기열에 갇혀 있었다. 원인은 단 하나의 메서드 — S3 파일 존재 여부를 확인하는 fileExists() 함수였다.
이전에 Java로 같은 작업을 했을 때는 이런 문제를 겪은 적이 없었다. 왜 Node.js에서만 이 문제가 발생하는 걸까?
문제의 코드
// Node.js AWS SDK v3 — 소켓 누수가 발생하는 코드
async fileExists(filePath: string): Promise<boolean> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key
});
try {
await this.s3Client.send(command); // 응답의 Body 스트림을 소비하지 않음
return true;
} catch (error: any) {
if (error.name === 'NoSuchKey') return false;
throw error;
}
}
파일이 존재하는지 확인하기 위해 GetObjectCommand를 사용했다. 파일 내용이 필요 없으니 response.Body를 읽지 않았다. 바로 이것이 소켓을 영구적으로 점유시키는 원인이었다.
핵심 차이: 스트림 미소비 시 소켓 처리
Node.js AWS SDK v3
Node.js SDK v3는 @smithy/node-http-handler를 통해 Node.js의 네이티브 HTTP Agent를 사용한다.
GetObjectCommand의 응답에는 Body 필드가 ReadableStream으로 포함된다. 이 스트림을 끝까지 읽거나(consume) 명시적으로 destroy()를 호출해야 소켓이 반환된다.
요청 → S3 응답 (Body 스트림 포함) → 스트림 미소비 → 소켓 반환 안 됨 → 누적 → 고갈
AWS SDK 메인테이너가 GitHub Issue #6691에서 명확히 확인한 내용:
"If you acquire a streaming response, such as S3::getObject's Body field, you must read the stream to completion in order for the socket to close naturally."
스트림을 소비하지 않으면:
- HTTP 소켓이 영원히 점유 상태로 남음
- GC(가비지 컬렉터)가 정리해주지 않음
- 타임아웃 기반 자동 회수 메커니즘이 없음
- 기본 maxSockets=50에 도달하면 모든 S3 요청이 무한 대기
Java AWS SDK
Java SDK는 Apache HttpClient 기반의 커넥션 풀을 사용한다. 같은 실수를 해도 여러 겹의 안전장치가 동작한다.
SDK v1 (ClientConfiguration) 기본값:
| 설정 | 값 | 역할 |
| maxConnections | 50 | 최대 커넥션 수 (Node.js와 동일) |
| connectionMaxIdleMillis | 60,000ms | 유휴 커넥션 60초 후 자동 회수 |
| validateAfterInactivityMillis | 5,000ms | 비활성 커넥션 재사용 전 검증 |
| connectionTimeout | 10,000ms | 커넥션 획득 타임아웃 |
| socketTimeout | 50,000ms | 소켓 읽기 타임아웃 |
SDK v2 (SdkHttpConfigurationOption) 기본값:
| 설정 | 값 | 역할 |
| MAX_CONNECTIONS | 50 | 최대 커넥션 수 |
| CONNECTION_MAX_IDLE_TIMEOUT | 60초 | 유휴 커넥션 자동 회수 |
| REAP_IDLE_CONNECTIONS | true | 유휴 커넥션 리퍼 기본 활성화 |
| CONNECTION_ACQUIRE_TIMEOUT | 10초 | 커넥션 풀 대기 타임아웃 (초과 시 예외 발생) |
핵심은 REAP_IDLE_CONNECTIONS=true 와 CONNECTION_MAX_IDLE_TIMEOUT=60초 설정이다. S3Object의 응답 스트림을 닫지 않아도, 60초 동안 데이터 전송이 없으면 커넥션 리퍼(reaper)가 해당 커넥션을 강제 회수한다.
또한 CONNECTION_ACQUIRE_TIMEOUT=10초 덕분에, 커넥션 풀이 고갈되더라도 Node.js처럼 무한 대기하지 않고 10초 후 예외를 던진다. 개발자가 문제를 즉시 인지할 수 있다.
단, Java SDK에서도 스트림을 닫지 않으면 커넥션 leak이 발생한다. AWS 공식 트러블슈팅 문서에서도 이를 경고한다: "A common cause of a leak is because a streaming operation — such as a S3 getObject method — is not closed." 다만 Java는 안전장치가 있어 장애로 이어지기 전에 자동 복구되는 경우가 많을 뿐이다.
doesObjectExist() — API 설계의 차이
이 문제가 발생하는 근본적인 이유 중 하나는 SDK가 제공하는 API의 차이다.
Java SDK v1: 내장 메서드 제공
// Java SDK v1 — 올바른 방법이 기본 제공됨
boolean exists = s3Client.doesObjectExist("my-bucket", "my-key");
이 메서드는 내부적으로 getObjectMetadata()를 호출하며, 이는 HTTP HEAD 요청을 사용한다. 파일 본문을 다운로드하지 않으므로 소켓 leak이 원천적으로 불가능하다.
// doesObjectExist() 내부 구현 (AmazonS3Client.java)
public boolean doesObjectExist(String bucketName, String objectName) {
try {
getObjectMetadata(bucketName, objectName); // HEAD 요청
return true;
} catch (AmazonS3Exception e) {
if (e.getStatusCode() == 404) return false;
throw e;
}
}
Java SDK v2: 내장 메서드 없음, 하지만 headObject() 사용이 자연스러움
SDK v2에서는 doesObjectExist()가 제거되었다 (Issue #392). 하지만 headObject()가 명확하게 제공되어 개발자가 자연스럽게 올바른 방법을 선택한다.
// Java SDK v2
try {
s3Client.headObject(HeadObjectRequest.builder()
.bucket("my-bucket").key("my-key").build());
return true;
} catch (NoSuchKeyException e) {
return false;
}
Node.js SDK v3: 내장 메서드 없음
doesObjectExist() 같은 편의 메서드가 없다. 개발자가 직접 구현해야 하는데, S3 경험이 적으면 익숙한 GetObjectCommand를 사용하게 된다 — 그리고 소켓 leak이 시작된다.
// Node.js SDK v3 — 잘못된 구현 (하지만 흔히 발생)
async fileExists(key: string): Promise<boolean> {
try {
await s3Client.send(new GetObjectCommand({ Bucket, Key })); // 소켓 leak!
return true;
} catch { return false; }
}
비교 요약
| Java SDK v1 | Java SDK v2 | Node.js SDK v3 | |
| doesObjectExist() 내장 | ✅ HEAD 요청 사용 | ❌ (headObject 직접 호출) | ❌ (직접 구현 필요) |
| 스트림 미소비 시 | 60초 후 리퍼가 회수 | 60초 후 리퍼가 회수 | 영구 점유 |
| 유휴 커넥션 자동 회수 | ✅ (60초) | ✅ (60초, 리퍼 기본 활성) | ❌ 없음 |
| 커넥션 풀 고갈 시 | 10초 후 예외 발생 | 10초 후 예외 발생 | 무한 대기 |
| GC 기반 정리 | 부분적 (finalizer) | 부분적 | ❌ 없음 |
| 기본 maxSockets | 50 | 50 | 50 |
올바른 구현
파일 존재 확인 — HeadObjectCommand 사용
import { HeadObjectCommand } from '@aws-sdk/client-s3';
async fileExists(filePath: string): Promise<boolean> {
const key = filePath.replace(`s3://${this.bucketName}/`, '');
try {
await this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucketName,
Key: key
}));
return true;
} catch (error: any) {
// HeadObjectCommand는 404 시 'NotFound' 에러를 던짐
// (GetObjectCommand의 'NoSuchKey'와 다름에 주의)
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
return false;
}
throw error;
}
}
HeadObjectCommand는 응답에 Body가 포함되지 않으므로 소켓 leak이 원천적으로 불가능하다.
주의: HeadObjectCommand의 404 에러 이름은 "NotFound"이다. GetObjectCommand의 "NoSuchKey"와 다르다. HEAD 응답에는 XML 에러 바디가 없어서 S3 에러 코드를 파싱할 수 없기 때문이다.
GetObjectCommand 사용 시 — 반드시 스트림 처리
GetObjectCommand를 사용해야 한다면, 반드시 Body 스트림을 소비하거나 파괴해야 한다:
// 방법 1: 스트림을 끝까지 읽기
const response = await s3Client.send(new GetObjectCommand({ Bucket, Key }));
const data = await response.Body?.transformToByteArray();
// 방법 2: 스트림을 명시적으로 파괴
const response = await s3Client.send(new GetObjectCommand({ Bucket, Key }));
response.Body?.destroy(); // 소켓 즉시 반환
S3Client maxSockets 증가
기본값 50은 동시 요청이 많은 서비스에서 병목이 될 수 있다:
import { S3Client } from '@aws-sdk/client-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import https from 'https';
const s3Client = new S3Client({
region: 'ap-northeast-2',
requestHandler: new NodeHttpHandler({
httpsAgent: new https.Agent({ maxSockets: 200 }),
requestTimeout: 5000, // 5초 타임아웃 (무한 대기 방지)
}),
});
실제 장애 사례
환경
- AWS EKS (prod), Node.js + Express + TypeScript
- S3 스토리지를 사용하는 백오피스 서비스 (Pod 2개)
증상
- PDF 조회 API 응답 무한 대기
- 이미지 미리보기 불가
- 파일 업로드 불가
- 세 기능이 동시에 먹통
원인
fileExists()에서 GetObjectCommand 사용 후 response.Body를 소비하지 않음. 매 요청마다 소켓 1개씩 누수되어 결국 50개 전부 고갈.
Pod 로그
@smithy/node-http-handler:WARN - socket usage at capacity=50 and 190 additional requests are enqueued.
Pod 1: 190개 요청 대기, Pod 2: 177개 요청 대기.
조치
- 긴급: Pod 재시작으로 소켓 풀 초기화
- 근본: fileExists()를 HeadObjectCommand로 변경
- 보강: passport deserializeUser 성공 로그 제거 (매 요청마다 출력되어 경고 로그를 묻히게 하는 원인)
결론
Java AWS SDK에서 이 문제가 안 보이는 건 "문제가 없어서"가 아니라 안전장치가 있어서다.
- 유휴 커넥션 리퍼가 60초마다 방치된 커넥션을 회수
- 커넥션 풀 고갈 시 10초 후 예외를 던져 즉각 인지 가능
- doesObjectExist() 같은 올바른 API를 기본 제공
Node.js SDK v3에는 이런 안전장치가 없다. 스트림을 소비하지 않으면 소켓이 영구적으로 점유되고, 풀이 고갈되면 무한 대기에 빠진다. 에러도 던지지 않으니 장애가 발생할 때까지 아무도 모른다.
핵심 원칙: Node.js AWS SDK v3에서 S3를 사용할 때는
- 파일 존재 확인에는 HeadObjectCommand를 사용한다
- GetObjectCommand의 Body는 반드시 소비하거나 destroy()한다
- maxSockets와 requestTimeout을 명시적으로 설정한다
참고 자료
- AWS SDK for JavaScript v3 — Configure maxSockets
- NodeHttpHandlerOptions API Reference
- S3 GetObjectCommand 소켓 leak — GitHub Issue #6691
- HeadObjectCommand NotFound 에러 — GitHub Issue #1596
- Java SDK v2 doesObjectExist 미제공 — GitHub Issue #392
- AWS SDK Java v2 Troubleshooting — Connection Leaks
- AWS SDK Java v2 Apache HTTP Client Configuration
'CLOUD > AWS' 카테고리의 다른 글
| AWS EKS 버전 유지 시 확장지원 비용 리스크 (2024년 이후 변경) (0) | 2026.01.12 |
|---|---|
| AWS CloudFront 캐시 정책과 CORS 에러 (0) | 2026.01.02 |
| AWS CloudFront 요금 정액제 출시 (2025. 11 업데이트) (0) | 2025.12.23 |
| ASG + 스팟 인스턴스 + 사설 DNS 자동등록 잘 안쓰이는이유 (2) | 2025.09.24 |
| Spot 인스턴스를 안정적으로 쓰는 방법: ASG + Capacity-Optimized + Capacity Rebalancing 2편(단점, 무중단, 장점) (0) | 2025.09.22 |
댓글