← Posts

Architecture · Message Queue · Backend

메시지 큐 - 기다리지 않게 만든다

지금 안 해도 되는 일을 쌓아두고 나중에 처리한다. 대신 실패가 나중에 오고, 중복이 오고, 순서가 흔들린다.

목차
  1. 다 끝났는데 응답이 안 나간다
  2. 지금 안 해도 되는 일을 쌓아둔다
  3. 큐가 실제로 사는 이유
  4. 큐 길이가 신호다
  5. 대가 하나: 끝났다는 말의 의미가 달라진다
  6. 대가 둘: 실패가 나중에 온다
  7. 대가 셋: 같은 메시지가 두 번 온다
  8. 대가 넷: 순서가 흔들린다
  9. 넣었는데 안 넣어진 경우
  10. 큐를 넣기 전에 물어볼 것
  11. 정리

결제 승인은 진작 끝났는데 사용자는 아직 결과 화면을 못 보고 있다. 확인 메일이 안 나가서다.

다 끝났는데 응답이 안 나간다

주문 완료 처리를 하는 코드가 대개 이렇게 자란다.

java
@Transactional
public void complete(Long orderId) {
    order.complete();                        // 진짜 해야 하는 일
    mailClient.sendOrderConfirm(orderId);    // 확인 메일
    pointClient.grant(orderId);              // 적립금
    statsClient.record(orderId);             // 통계 집계
}

앞 편의 기준으로 보면 아래 세 줄은 사용자에게 줄 답을 바꾸지 않는다. 메일이 나가든 안 나가든 주문은 완료다. 그런데 지금은 셋을 다 기다린 다음에야 응답이 나간다.

문제는 느린 것만이 아니다. 메일 서버가 죽으면 주문 완료가 실패한다. 있어도 그만인 일이 있어야 하는 일을 무너뜨린다. 순서가 거꾸로다.

지금 안 해도 되는 일을 쌓아둔다

해법의 모양은 단순하다. 그 일을 직접 하지 말고 적어두고, 응답은 바로 내보낸다. 적힌 것은 다른 쪽에서 꺼내 처리한다.

diagramdiagram

이 “적어두는 곳”이 메시지 큐다. 구조는 셋으로 이뤄진다.

  • 보내는 쪽: 할 일을 메시지로 만들어 큐에 넣는다
  • : 메시지를 순서대로 보관한다. 꺼내 갈 때까지 사라지지 않는다
  • 받는 쪽: 큐에서 꺼내 처리하고, 다 됐다고 알린다

핵심은 보내는 쪽과 받는 쪽이 같은 시각에 살아 있을 필요가 없다는 것이다. 동기 호출은 상대가 지금 응답해야 성립한다. 큐는 메시지를 맡기는 것으로 끝난다.

큐가 실제로 사는 이유

빨라지는 건 눈에 띄는 효과일 뿐이고, 큐를 쓰는 이유는 셋에 가깝다.

받는 쪽이 죽어도 일이 안 사라진다. 메일 처리기가 멈춰 있어도 메시지는 큐에 쌓인다. 다시 뜨면 밀린 것부터 처리한다. 동기 호출이었다면 그 시간 동안의 메일은 그냥 유실됐다.

몰릴 때를 흡수한다. 이벤트 시작 순간에 주문이 평소보다 크게 몰려도, 넣는 건 빠르니 앞단은 버틴다. 처리는 자기 속도로 따라간다. 큐 길이가 늘었다 줄 뿐이다.

diagramdiagram

처리 속도를 따로 조절할 수 있다. 큐가 계속 길어지면 받는 쪽만 늘린다. 앞단은 그대로 둔다. 앞 편에서 본 “확장 단위가 갈린다”가 여기서 가장 선명하게 나타난다.

참고

그래서 큐는 받는 쪽이 앞단보다 느리거나 불안정할 때 값어치가 있다. 둘의 속도가 비슷하고 안정적이면 큐를 넣어도 얻는 게 별로 없고 구조만 복잡해진다.

큐 길이가 신호다

큐를 넣으면 새로 볼 게 생긴다. 쌓인 메시지 수와 가장 오래된 메시지의 나이다.

  • 길이가 늘었다 줄면 정상이다. 몰린 걸 흡수하는 중이다
  • 계속 늘기만 하면 받는 쪽 처리 속도가 들어오는 속도보다 느리다는 뜻이다. 처리기를 늘리거나 처리 자체를 빠르게 해야 한다
  • 줄지 않고 멈춰 있으면 받는 쪽이 죽었거나 특정 메시지에서 막혀 있다

이 값을 안 보면 큐는 문제를 숨기는 장치가 된다. 앞단은 멀쩡히 응답하는데 메일은 몇 시간째 안 나가고 있는 상태가, 아무도 모르는 채로 지속된다.

대가 하나: 끝났다는 말의 의미가 달라진다

사용자가 “완료됐습니다”를 봤을 때 실제로 끝난 건 주문 상태 변경뿐이다. 메일은 아직이다. 대개는 문제가 안 되지만, 문제가 되는 자리가 있다.

  • 완료 화면에서 바로 “적립금 내역”으로 갔는데 아직 반영이 안 돼 있다
  • 업로드가 끝났다고 했는데 목록에 안 보인다

그래서 화면이 이 시차를 알고 만들어져야 한다. 처리 중임을 그대로 보여주거나(접수됐습니다·처리 중), 조회 쪽에서 아직 반영 안 된 상태를 자연스럽게 다루거나. “완료”라고 말해놓고 결과가 없는 게 가장 나쁘다.

대가 둘: 실패가 나중에 온다

동기 호출은 실패하면 그 자리에서 안다. 예외를 잡아 사용자에게 알리든 롤백하든 선택할 수 있다.

큐로 넘긴 일이 실패하면 사용자는 이미 떠났고 요청 맥락도 없다. 그래서 실패 처리 절차를 미리 만들어 둬야 한다.

diagramdiagram

재시도는 여기서 훨씬 안전하다. 사용자가 기다리고 있지 않으니 간격을 넉넉히 두고 여러 번 시도해도 된다. 앞 편의 동기 재시도가 “지금 당장 몰아치는 것”이었다면 여기는 시간을 벌 수 있다.

그래도 계속 실패하는 메시지는 있다. 이걸 무한히 재시도하면 두 가지가 망가진다. 자원을 계속 먹고, 그 메시지 뒤에 줄 선 것들이 못 나간다. 그래서 정해진 횟수를 넘기면 따로 빼둔다. 이 보관함을 흔히 데드 레터 큐라고 부른다.

주의

실패 보관함을 만들어 놓고 안 보면 만들지 않은 것과 같다. 여기에 쌓이는 건 “자동으로는 처리 못 한 일”이다. 알림을 걸어 사람이 확인하고, 원인을 고친 뒤 다시 넣는 절차까지가 한 세트다.

대가 셋: 같은 메시지가 두 번 온다

큐는 대개 최소 한 번 전달을 보장한다. 메시지가 유실되지 않는다는 뜻이고, 뒤집으면 두 번 이상 올 수 있다는 뜻이다.

왜 그런가. 처리기가 메시지를 꺼내 일을 다 끝냈는데, “처리 완료” 신호를 보내기 직전에 죽었다고 하자. 큐 입장에서는 아무도 처리하지 않은 메시지다. 그래서 다른 처리기에게 다시 준다. 일은 두 번 실행된다.

diagramdiagram

“그럼 정확히 한 번 보내달라고 하면 되지 않나” 싶지만, 네트워크가 낀 이상 보낸 쪽은 상대가 처리했는지 알 수 없다. 처리는 됐는데 응답만 유실된 경우와 아예 못 받은 경우가 구별되지 않기 때문이다. 그래서 유실을 막으려면 중복을 감수해야 하고, 중복을 막으려면 유실을 감수해야 한다.

실무의 선택은 거의 항상 중복을 감수하고 받는 쪽에서 거른다이다. 메시지마다 식별자를 붙여 이미 처리한 것이면 건너뛴다. 이 방법은 멱등성 글에서 본 그대로다. 큐를 쓰기로 했으면 받는 쪽을 멱등하게 만드는 건 선택이 아니다.

대가 넷: 순서가 흔들린다

큐는 이름부터 줄인데 왜 순서가 흔들리나. 처리기를 여러 개 두는 순간 흔들린다.

diagramdiagram

꺼내는 순서는 맞아도 처리가 끝나는 순서는 다를 수 있다. 같은 주문에 대한 “수정”과 “취소”가 뒤집혀 반영되면 취소된 주문이 되살아난다.

방법은 둘이다.

  • 순서가 필요한 것끼리 한 줄로 세운다. 같은 주문 id의 메시지는 늘 같은 처리기로 가게 나눈다. 큐를 여러 갈래로 나누고 키로 갈래를 정하는 방식이 이것이다. 대신 그 갈래 안에서는 병렬 처리가 안 되니 처리량을 그만큼 포기한다
  • 순서에 안 기대게 만든다. 메시지에 시각이나 버전을 담아, 이미 더 새로운 상태가 반영돼 있으면 옛 메시지를 무시한다

먼저 물어야 할 건 정말 전역 순서가 필요한가다. 필요한 건 대개 “같은 주문 안에서의 순서”지 “모든 주문 사이의 순서”가 아니다. 범위를 좁힐수록 병렬 처리를 더 많이 남길 수 있다.

넣었는데 안 넣어진 경우

마지막으로 놓치기 쉬운 자리 하나. DB 저장과 큐에 넣기는 한 트랜잭션이 아니다.

java
@Transactional
public void complete(Long orderId) {
    order.complete();                 // DB
    queue.send(new MailRequest(...)); // 큐 (다른 시스템)
}

주문은 커밋됐는데 큐 전송이 실패하면 메일은 영영 안 나간다. 반대로 큐에 넣고 나서 트랜잭션이 롤백되면, 없는 주문에 대한 메일이 나간다.

이 문제는 Aggregate에서 이미 한 번 나왔다. 거기서 쓴 방식이 여기 그대로 적용된다. 보낼 메시지를 같은 DB 안에 같은 트랜잭션으로 기록해 두고, 별도 처리기가 그 기록을 읽어 큐로 보내는 것이다. 그러면 주문 저장과 “메일을 보내기로 했다”는 사실이 원자적으로 같이 남는다.

이 방식에서도 큐로 보낸 뒤 기록을 지우기 전에 죽으면 같은 메시지가 두 번 나간다. 그런데 그건 이미 위에서 받는 쪽을 멱등하게 만들어 해결한 문제다. 유실은 막고 중복은 거르는 것, 이 조합이 큐를 쓰는 시스템의 기본 자세다.

큐를 넣기 전에 물어볼 것

큐는 공짜가 아니다. 운영할 대상이 하나 늘고, 위의 대가 넷이 전부 따라온다. 그래서 넣기 전에 답해 둔다.

질문답이 없으면
이 일이 늦어져도 사용자에게 줄 답이 같은가큐로 뺄 대상이 아니다
늦어지는 걸 화면에서 어떻게 보여줄 것인가”완료”라 해놓고 결과가 없는 화면이 된다
받는 쪽이 같은 메시지를 두 번 받으면 어떻게 되나중복 발송·중복 적립이 난다
계속 실패하는 메시지는 누가 보나조용히 사라진다
순서가 뒤집히면 무엇이 깨지나취소된 주문이 되살아난다

답을 못 하겠는 항목이 있으면 그 항목이 먼저 할 일이다. 큐를 넣는 것보다.

정리

메시지 큐란할 일을 적어두고 나중에 꺼내 처리하는 장치
핵심 성질보내는 쪽과 받는 쪽이 같은 시각에 살아 있지 않아도 된다
사는 이유받는 쪽이 죽어도 일이 안 사라지고, 몰릴 때를 흡수하고, 처리 속도를 따로 조절한다
볼 것쌓인 수와 가장 오래된 메시지의 나이
대가완료의 의미가 달라지고, 실패가 나중에 오고, 중복이 오고, 순서가 흔들린다
필수 준비받는 쪽을 멱등하게. 실패 보관함을 만들고 실제로 보기
저장과 전송한 트랜잭션이 아니다. 보낼 메시지를 DB에 같이 기록한다

지금까지의 큐는 일을 넘기는 것이었다. 보내는 쪽이 “메일을 보내라”고 시켰고 받는 쪽이 하나 있었다. 그런데 주문 하나에 반응해야 할 곳이 다섯이라면 어떻게 되나. 다음 글에서 시키는 대신 알리는 방식을 본다.

관련 글