SQS visibility timeout과 receiptHandle의 함정
SQS 메시지 처리 기본 구조
Amazon SQS에서 메시지는 세 가지 상태를 거친다.
- Stored — Producer가 큐에 보냈지만 아직 소비자가 수신하지 않은 상태.
- In-Flight — 소비자가 수신했지만 아직 삭제하지 않은 상태. 이 동안 다른 소비자에게는 보이지 않는다.
- Deleted — 처리 완료 후 소비자가 삭제한 상태.
“A message is considered to be in flight after it is received from a queue by a consumer, but not yet deleted from the queue.” — AWS SQS Developer Guide
Visibility Timeout이란
소비자가 메시지를 수신하면, SQS는 해당 메시지를 지정된 시간 동안 다른 소비자에게 보이지 않게 한다. 이 시간이 visibility timeout이다.
| 속성 | 값 |
|---|---|
| 기본값 | 30초 |
| 최솟값 | 0초 |
| 최댓값 | 12시간 (43,200초) |
| Standard 큐 In-Flight 한도 | ~120,000 메시지 |
Standard 큐는 at-least-once 전달을 보장한다. Visibility timeout 내라도 동일 메시지가 두 번 이상 전달될 수 있으므로, 소비자 쪽에서 멱등성(idempotency) 설계가 필수다.
changeMessageVisibility — 처리 시간 연장
처리가 오래 걸릴 때, ChangeMessageVisibility API로 timeout을 늘릴 수 있다. 대용량 파일 내보내기, 배치 데이터 처리 같은 작업에서 주로 사용한다.
// 개념 예시 — visibility timeout 연장
sqsClient.changeMessageVisibility(ChangeMessageVisibilityRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(receiptHandle) // ReceiveMessage에서 받은 핸들
.visibilityTimeout(300) // 5분으로 연장
.build());AWS 공식 문서에서 권장하는 패턴은 heartbeat 방식이다. 주기적으로 timeout을 연장하면서 처리 완료 시 메시지를 삭제한다.
// 개념 예시 — heartbeat 기반 visibility timeout 연장
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
sqsClient.changeMessageVisibility(ChangeMessageVisibilityRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(latestReceiptHandle.get()) // 최신 핸들 사용
.visibilityTimeout(120)
.build());
} catch (Exception e) {
log.warn("Visibility timeout 연장 실패", e);
}
}, 60, 60, TimeUnit.SECONDS); // 60초마다 120초로 연장12시간 절대 한도: ChangeMessageVisibility로 아무리 연장해도, 메시지를 최초 수신한 시점부터 최대 12시간까지만 가능하다. 12시간을 초과하면 에러가 반환된다. 이 한도를 넘는 작업은 AWS Step Functions로 분리하거나 작업 단위를 나눠야 한다.
증상: 대용량 작업의 중복 실행
프로덕션에서 대용량 내보내기 작업이 중복 실행되는 현상이 발생했다. 사용자가 하나의 내보내기를 요청했는데, S3에 동일한 파일이 2개 생성됐다. 로그를 확인하니 같은 SQS 메시지가 두 번 처리된 것을 확인했다.
코드에는 changeMessageVisibility를 호출하는 heartbeat 로직이 이미 구현되어 있었다. 처리 시간이 길어질 것으로 예상되면, 주기적으로 visibility timeout을 연장하는 타이머가 설정되어 있었다. 그런데 왜 동작하지 않았는가?
원인: receiptHandle의 유효성 문제
디버깅 결과, changeMessageVisibility API 호출 자체는 에러 없이 성공했지만, 실제로 visibility timeout이 연장되지 않았다.
원인은 receiptHandle에 있었다. SQS에서 메시지를 수신하면 receiptHandle이 발급되는데, 이 핸들은 수신 시점에만 유효하다. SQS 리스너가 내부적으로 관리하는 receiptHandle과 비동기 타이머에서 캡처해 둔 receiptHandle 사이에 불일치가 발생했다.
AWS 공식 문서에서도 이 점을 명시한다:
“Unlike with a queue, when you change the visibility timeout for a specific message the timeout value is applied immediately but isn’t saved in memory for that message. If you don’t delete a message after it is received, the visibility timeout for the message reverts to the original timeout value (not to the value you set using the ChangeMessageVisibility action) the next time the message is received.” — AWS API Reference: ChangeMessageVisibility
| 시점 | receiptHandle 상태 | API 호출 결과 |
|---|---|---|
| 메시지 수신 직후 | 유효 | 정상 동작 |
| 일정 시간 경과 | 내부 갱신됨 | 성공 응답이지만 효과 없음 |
| timeout 만료 후 재수신 | 새 핸들 발급 | 이전 핸들 완전 무효 |
수정 내용
최신 receiptHandle 참조 구조로 변경
타이머가 고정된 receiptHandle을 캡처하는 대신, 항상 최신 핸들을 참조하도록 변경했다.
// 개념 예시 — AS-IS: 고정 핸들 캡처
String capturedHandle = message.receiptHandle(); // 수신 시점에 고정
scheduler.scheduleAtFixedRate(() -> {
sqsClient.changeMessageVisibility(/* capturedHandle 사용 */);
}, ...);// 개념 예시 — TO-BE: AtomicReference로 최신 핸들 유지
AtomicReference<String> latestHandle = new AtomicReference<>(message.receiptHandle());
scheduler.scheduleAtFixedRate(() -> {
sqsClient.changeMessageVisibility(ChangeMessageVisibilityRequest.builder()
.queueUrl(queueUrl)
.receiptHandle(latestHandle.get())
.visibilityTimeout(120)
.build());
}, 60, 60, TimeUnit.SECONDS);검증 로직 추가
API 호출 후 실제로 timeout이 연장되었는지를 확인하는 검증을 추가했다. changeMessageVisibility가 200 OK를 반환해도 실제 효과가 없을 수 있으므로, 메시지 속성을 다시 조회해서 남은 timeout을 확인한다.
멱등성 보강
Visibility timeout 연장이 실패하더라도 서비스가 안전하게 동작하도록, 요청 ID 기반 중복 처리 감지를 추가했다.
// 개념 예시 — 멱등성 체크
public void processExport(ExportRequest request) {
String requestId = request.getRequestId();
if (processedRequestStore.exists(requestId)) {
log.info("이미 처리된 요청, 스킵: {}", requestId);
return;
}
try {
doExport(request);
processedRequestStore.markCompleted(requestId);
} finally {
deleteMessage(message);
}
}SQS 메시지 처리 설계 체크리스트
다음은 SQS 기반 비동기 워크로드를 설계할 때 확인해야 할 항목이다.
| 항목 | 설명 |
|---|---|
| Visibility timeout 설정 | 예상 처리 시간보다 넉넉히. 확실하지 않으면 짧게 시작 후 heartbeat으로 연장 |
| receiptHandle 관리 | 비동기 타이머에서 사용 시 AtomicReference 등으로 최신 핸들 유지 |
| 멱등성 | SQS는 at-least-once 전달. 중복 수신을 전제하고 요청 ID 기반 중복 감지 필수 |
| Dead-Letter Queue | maxReceiveCount 초과 메시지를 격리. 실패 원인 분석용 |
| 12시간 한도 | 이를 초과할 수 있는 작업은 Step Functions 또는 작업 분할 검토 |
| 모니터링 | ApproximateNumberOfMessagesNotVisible CloudWatch 지표로 in-flight 메시지 감시 |
교훈
changeMessageVisibility를 쓸 때는receiptHandle의 최신성을 반드시 확인한다.- 긴 처리 시간이 예상되는 메시지 소비자는 멱등성을 기본 설계에 포함해야 한다.
- Visibility timeout 연장은 보호막이지 보장이 아니다. 실패 시 대안 경로(DLQ, 멱등성)가 있어야 한다.
- AWS 공식 Best Practice: “Implement a heartbeat mechanism to periodically extend the visibility timeout, ensuring the message remains invisible until processing is complete.”
이 트러블슈팅은 SQS를 쓰는 비동기 워크로드에서 반복적으로 만날 수 있는 패턴이다. “왜 성공했는데 안 되지?”라는 질문의 답이 API의 세부 동작에 숨어 있었던 사례로, 비슷한 구조를 운영할 때 참고가 될 수 있다.
참고 자료