Skip to Content
BackendSQS visibility timeout과 receiptHandle의 함정
💻 Backend2025년 6월 15일

SQS visibility timeout과 receiptHandle의 함정

#aws#sqs#troubleshooting#async

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을 늘릴 수 있다. 대용량 파일 내보내기, 배치 데이터 처리 같은 작업에서 주로 사용한다.

java
// 개념 예시 — visibility timeout 연장 sqsClient.changeMessageVisibility(ChangeMessageVisibilityRequest.builder() .queueUrl(queueUrl) .receiptHandle(receiptHandle) // ReceiveMessage에서 받은 핸들 .visibilityTimeout(300) // 5분으로 연장 .build());

AWS 공식 문서에서 권장하는 패턴은 heartbeat 방식이다. 주기적으로 timeout을 연장하면서 처리 완료 시 메시지를 삭제한다.

java
// 개념 예시 — 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을 캡처하는 대신, 항상 최신 핸들을 참조하도록 변경했다.

java
// 개념 예시 — AS-IS: 고정 핸들 캡처 String capturedHandle = message.receiptHandle(); // 수신 시점에 고정 scheduler.scheduleAtFixedRate(() -> { sqsClient.changeMessageVisibility(/* capturedHandle 사용 */); }, ...);
java
// 개념 예시 — 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 기반 중복 처리 감지를 추가했다.

java
// 개념 예시 — 멱등성 체크 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 QueuemaxReceiveCount 초과 메시지를 격리. 실패 원인 분석용
12시간 한도이를 초과할 수 있는 작업은 Step Functions 또는 작업 분할 검토
모니터링ApproximateNumberOfMessagesNotVisible CloudWatch 지표로 in-flight 메시지 감시

교훈

API가 성공 응답을 반환한다고 해서 의도한 효과가 발생했다고 가정하면 안 된다. 특히 SQS처럼 분산 시스템의 API는 “요청을 받았다”와 “의도한 효과가 적용됐다” 사이에 간극이 있을 수 있다.
  • 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의 세부 동작에 숨어 있었던 사례로, 비슷한 구조를 운영할 때 참고가 될 수 있다.


참고 자료

Last updated on