Post

때로는 오버엔지니어링이 필요합니다 파헤치기

때로는 오버엔지니어링이 필요합니다 파헤치기

올리브영 메시지 시스템 전환기를 읽고 — 초당 1건도 안 되는 발송에 Kafka가 필요할까?

참고한 아티클: 때로는 오버엔지니어링이 필요합니다

1. 문제 정의

기존 레거시 발송 구조

기존 알림 발송은 테이블을 거쳐 처리되고 있었다. 각 서비스가 발송 대상 테이블에 메시지를 INSERT하면, 데몬 프로세스가 이 테이블을 주기적으로 읽어 발송한다. 발송이 끝나면 이력 테이블에 기록을 남기고 원본 행을 삭제한다.

1
서비스 → 발송 대상 테이블 INSERT → 데몬 폴링 → 발송 → 이력 저장 → 원본 삭제

가맹점 개점 알림, 미마감 매장 알림, 대용량 파일 다운로드 완료 통지처럼 성격이 다른 수십 개 서비스가 이 구조를 함께 쓰고 있었다. 그래서 데몬에 문제가 생기면 여러 서비스의 알림이 동시에 영향을 받는다.

데몬 프로세스의 문제점

  • WAS와 같은 장비에서 실행된다. CPU와 메모리를 나눠 쓰기 때문에, 대량 발송이 몰리면 서비스 성능에도 영향을 준다.
  • 표준 모니터링 바깥에 있다. 장애가 나도 어느 단계에서 실패했는지 추적하기 어렵다.
  • 운영 방식이 표준에서 벗어나 있다. 월별 이력 테이블을 데몬이 직접 만들고, 발송 계정은 수백 개로 흩어져 있다.
  • 인스턴스를 하나만 띄워야 한다. 두 개 이상 띄우면 같은 메시지가 중복 발송될 수 있는데, 이 규칙은 코드나 설정이 아니라 담당자 간 인수인계로만 전해지고 있었다.

즉, 중복 발송을 막는 장치가 구조가 아니라 사람 사이의 약속에 있었던 셈이다.

최종 목표: 통합 메시지 API 직접 호출

목표는 발송 대상 테이블을 없애고, 각 서비스가 통합 메시지 API를 직접 호출하는 구조로 바꾸는 것이다.

하지만 처음부터 그 구조로 갈 수는 없었다. 수십 개 서비스가 테이블 직접 INSERT나 발송용 저장 프로시저 호출에 묶여 있었고, 발송 배치도 여기저기 흩어져 있었다. 일부 발신번호는 하드코딩되어 있기도 했다.

이걸 한 번에 바꾸다가 하나라도 빠뜨리면, 그 서비스의 알림은 에러 없이 조용히 끊기고 한참 뒤에야 알게 된다. 그래서 모든 서비스가 API로 옮겨 갈 때까지, 기존 테이블 방식으로 들어오는 요청을 받아 줄 과도기 구조가 필요했다.

결국 해결하려는 문제는 무엇일까?

내가 이해한 이 아티클의 문제는 단순히 데몬을 새로 만드는 것이 아니다. 서비스 코드는 그대로 둔 채, 레거시 테이블로 들어오는 발송 요청을 안전하게 처리하는 것이다.

  • 서비스들이 지금처럼 테이블에 INSERT만 해도 발송이 이어져야 한다.
  • 전환 중에 알림이 누락되면 안 되고, 누락되더라도 바로 알 수 있어야 한다.
  • 발송은 되돌릴 수 없으니, 장애나 재시도 상황에서도 중복 발송이 없어야 한다.
  • WAS와 분리되어야 하고, 인스턴스를 하나만 띄워야 한다는 규칙을 사람이 기억하지 않아도 되어야 한다.
  • 전환이 끝나면 깔끔하게 걷어낼 수 있어야 한다.

참고로 평소 발송량은 초당 1건도 되지 않는다. 처리량 자체가 문제는 아니었던 것이다.

그래서 아티클의 해결책을 보기 전에 먼저 생각해 봤다.

과연 나라면 이 레거시 구조에서 발송을 어떻게 안전하게 처리할 수 있었을까?


2. 어떤 기준으로 방법을 골라야 할까?

해결 방법을 떠올리기 전에, 먼저 내가 택할 방법이 지켜야 할 요구사항을 정의해 봤다.

  1. 인프라를 늘리는 것을 최소화한다.
  2. 코드가 분리되어 있어야 한다.
  3. 비용을 최소화한다.
  4. 안전하게 처리되어야 한다.

인프라를 늘리는 것을 최소화

이 구조는 모든 서비스가 통합 메시지 API로 옮겨 가면 사라질 과도기 구조다. 새 인프라를 들이면 전환 기간 동안 운영해야 하고, 전환이 끝나면 다시 걷어내야 한다. 그래서 가능하면 이미 있는 인프라 안에서 해결하고 싶었다.

코드가 분리되어 있어야 함

기존 데몬은 WAS와 같은 장비에서 돌면서 서비스 성능에 영향을 줬다. 발송을 처리하는 코드가 서비스 코드와 분리되어 있어야 대량 발송이 몰려도 서비스에 영향을 덜 주고, 전환이 끝났을 때 그 부분만 걷어낼 수 있다.

비용을 최소화

평소 발송량은 초당 1건도 되지 않는다. 이 정도 처리량을 위해 비용이 많이 드는 구조를 택할 이유는 없다고 봤다.

안전하게 처리되어야 함

전환 중에 알림이 누락되면 안 되고, 발송은 되돌릴 수 없으니 중복 발송도 없어야 한다. 발송 → 이력 저장 → 원본 삭제 중 어느 단계에서 실패하더라도 다시 처리할 수 있어야 한다.

다만 앞의 세 가지와 마지막 안전은 서로 부딪칠 수 있다. 안전을 위해 장치를 더할수록 인프라와 비용도 같이 늘어나기 때문이다.


3. 내가 만약 이 문제를 맞닥뜨렸다면?

처음 떠올린 방법: CDC

앞의 기준을 두고 처음 떠올린 방법은 CDC였다. DB에서 일어난 변경을 잡아내는 방법이다.

CDC를 붙이는 방법은 여러 가지가 있지만, MySQL을 쓴다고 가정하면 binlog 기반으로 CDC를 붙일 것 같다.

CDC를 붙이면 테이블에 INSERT, UPDATE, DELETE가 일어날 때 그 변경을 이벤트로 가져올 수 있다. 서비스 코드를 고치지 않아도, 발송 대상 테이블에 데이터가 들어오는 순간을 잡을 수 있는 것이다.

CDC를 붙인다고 알아서 되는 건 아니다

여기서 헷갈리면 안 되는 부분이 있다.

binlog가 있다고 변경이 알아서 이벤트로 나가는 것은 아니다.

binlog는 원래 CDC를 위해 있던 게 아니다. Replica가 원본 DB의 변경을 따라 복사할 수 있도록 남겨 두던 기록이다. CDC는 이 기록을 Connector가 읽어 가는 방식이다. Connector가 Replica처럼 DB에 붙어서 binlog를 읽고, 그 내용을 이벤트로 바꿔 내보내는 것이다.

그래서 CDC를 쓰려면 binlog를 읽어 줄 Connector를 따로 띄워야 한다. binlog도 어떤 행이 어떻게 바뀌었는지 남도록 binlog_format=ROW여야 한다.

이벤트는 어디에 담아야 할까?

CDC를 붙이면 발송 대상 테이블의 변경을 잡아 이벤트로 내보낼 수 있다.

1
서비스 → 발송 대상 테이블 INSERT → binlog → CDC Connector → 이벤트 발행 → ?

그런데 여기서 다음 질문이 생긴다.

내보낸 이벤트를 받아서 담아 둘 공간이 필요하지 않을까?

인프라 구조를 모르니 가정이 필요했다

여기서 고민이 많이 생겼다. 내가 지금 올리브영의 인프라 구조를 모르기 때문이다.

그래서 여기서도 가정을 하나 넣었다. 복잡하게 생각하지 않고, 기존에 쓰고 있는 것이 없다고 가정하고 진행했다.

데몬 대신 워커, 중간에는 SQS

그러면 먼저 발송하는 주체가 바뀐다. 기존 데몬 프로세스 대신 워커라는 새로운 인스턴스가 필요하다. 그리고 CDC와 워커 사이에 이벤트를 담아 둘 공간으로 SQS를 둔다.

1
서비스 → 발송 대상 테이블 INSERT → binlog → CDC Connector → SQS → 워커 → 발송

워커가 처리하는 순서는 이렇게 생각했다.

  1. SQS에서 이벤트를 받아 발송한다.
  2. 발송이 끝나면 SQS에서 DeleteMessage로 메시지를 지운다.
  3. 발송 대상 테이블에서 해당 행을 삭제한다.
  4. 발송 이력 테이블로 옮긴다.

순서를 적어 놓고 보니 이런 생각이 들었다.

이렇게 되면 안전할까?

고민이 되기 시작한다. 뭔가 좀 이상해 보이기도 한다.

무엇이 이상했을까?

곰곰이 생각해 보니 이상해 보였던 이유는 발송에 있었다.

발송은 한 번 나가면 되돌릴 수 없고, DB 트랜잭션에 같이 묶을 수도 없다.

그런데 내가 정한 순서에서는 발송 뒤에 DeleteMessage, 원본 삭제, 이력 이관이 각각 따로 일어난다. 중간에 워커가 죽으면 어디서 죽었느냐에 따라 결과가 전부 달라진다. 그래서 하나씩 따라가 봤다.

1. 삭제하고 나서 이력으로 옮기면?

원본 행을 먼저 지우고 이력 테이블로 옮기는 순서다. 그런데 지운 직후에 워커가 죽으면, 이력으로 옮길 데이터가 이미 없다.

발송은 됐는데 기록은 어디에도 남지 않는 것이다. 그래서 이력 저장과 원본 삭제는 따로 하면 안 되고, 하나의 트랜잭션으로 묶어야 한다고 생각했다.

2. DeleteMessage를 먼저 하면?

발송하고 SQS에서 메시지를 지운 다음 워커가 죽었다고 해 보자. SQS에는 메시지가 없고, 발송 대상 테이블에는 행이 그대로 남는다.

그러면 이 행은 누가 다시 처리할까? CDC는 INSERT가 일어났을 때 이미 이벤트를 한 번 보냈다. 다시 처리해 줄 주체가 없다.

결국 발송은 됐는데 테이블에는 아직 안 보낸 것처럼 남고, 아무도 이 사실을 모른다. 앞에서 걱정했던 조용히 실패하는 상황이 여기서도 생기는 것이다.

3. 발송하고 DeleteMessage 전에 죽으면?

이번에는 반대다. 발송은 됐는데 메시지를 지우기 전에 워커가 죽으면, 일정 시간이 지난 뒤 메시지가 다시 큐에 보인다. 그러면 다른 워커가 받아서 같은 알림을 한 번 더 보낸다.

게다가 SQS 표준 큐는 메시지를 최소 한 번 전달하는 방식이라, 장애가 없어도 같은 메시지가 두 번 올 수 있다.

그래서 발송 전에 원본 행이 아직 있는지 먼저 확인해야 한다고 생각했다. 원본이 없으면 이미 처리된 것으로 보고 넘어가는 것이다.

4. 내가 지운 행도 CDC가 잡지 않을까?

CDC는 INSERT만 잡는 게 아니다. DELETE도 잡는다. 워커가 원본 행을 지우면, 그 삭제도 이벤트가 되어 다시 SQS로 들어온다.

그래서 워커는 INSERT 이벤트만 골라서 처리해야 한다. 그렇지 않으면 자기가 지운 삭제 이벤트를 다시 받게 된다.

5. 요구사항이랑은 맞을까?

이 구조를 위해 새로 생기는 게 CDC Connector, SQS, 워커 세 가지다. 앞에서 정한 요구사항 중 인프라를 늘리는 것을 최소화, 비용을 최소화와는 맞지 않는다.

2번에서 걱정했던 것처럼, 안전하게 만들려고 할수록 인프라와 비용도 같이 늘어나고 있었다.

순서를 다시 정해 봤다

1~4번을 정리해 보니, 순서를 이렇게 바꾸면 될 것 같았다.

  1. 원본 행이 아직 있는지 확인한다. 없으면 건너뛴다.
  2. 발송한다.
  3. 이력 저장과 원본 삭제를 하나의 트랜잭션으로 처리한다.
  4. SQS에서 DeleteMessage로 메시지를 지운다.
1
SQS 수신 → 원본 확인 → 발송 → [이력 저장 + 원본 삭제] → DeleteMessage

이렇게 하면 마지막 DeleteMessage 전에 죽어도 괜찮다. 메시지를 다시 받더라도 원본이 이미 없으니 건너뛰면 된다.

DB에서 원본을 지우는 것을 “처리 완료” 표시로 쓰는 셈이다.

그런데 아직 남는 구멍이 하나 있다. 발송 직후, 트랜잭션 전에 죽는 경우다. 원본이 그대로 남아 있으니 메시지를 다시 받으면 또 발송한다.

발송을 DB 트랜잭션에 넣을 수 없는 이상, 이 구간은 순서만 바꿔서는 막을 수 없다.

결국 중복으로 보내는 것과 아예 안 보내는 것 중, 무엇을 감수할지 골라야 했다.


4. 아티클과 달랐던 점

 내 설계아티클
큐SQSKafka
큐에 담는 것정하지 않았다메시지 ID만 담고, 원본은 DB에서 다시 읽는다
워커끼리 중복원본 확인으로만 막는다컨슈머 그룹으로 한 메시지를 한 워커만 가져간다
재시도생각하지 못했다큐 재시도는 없이, 네트워크 오류와 5xx만 한 번 다시 보내고 4xx는 바로 실패 처리한다
전환 방법생각하지 못했다기능 플래그를 끈 채 먼저 배포하고, 확인한 뒤 켜고, 레거시는 나중에 내린다

다시 정한 처리 순서는 아티클과 거의 같았다. 마지막에 남긴 질문에도 아티클은 답을 냈다.

알림이 조용히 빠지는 것보다 이력이 잠깐 비는 쪽이 복구할 수 있다고 보고, 발송을 먼저 했다.

나는 발송 순서 하나에 집중했는데, 아티클은 재시도와 전환 방법까지 챙기고 있었다.


5. 그냥 워커가 DB를 폴링하면 안 될까?

아티클과 비교하다가 문득 이런 생각이 들었다.

발송 대상 테이블이 이미 큐 역할을 하고 있는데, 워커가 그냥 이 테이블을 폴링하면 안 될까?

서비스가 INSERT로 넣고, 처리가 끝나면 지운다. 이미 큐처럼 쓰이고 있는 테이블 앞에 CDC와 Kafka를 붙이면, 큐를 하나 더 만드는 셈이다.

근거는 트래픽이 낮다는 것

평소 발송량은 초당 1건도 되지 않는다. 10분 사이 1만 건이 몰린 적이 있다고 해도 초당 17건 정도다.

이 정도면 워커가 1초마다 테이블을 읽어도 부담이 크지 않다. 대부분은 거의 빈 테이블을 인덱스로 한 번 읽고 끝나기 때문이다. 몰릴 때도 행이 테이블에 쌓여 있다가 순서대로 빠지면 된다. 테이블 자체가 순간적인 폭증을 받아 주는 완충 공간이 되는 것이다.

SKIP LOCKED로 여러 워커가 나눠 가져가기

데몬을 하나만 띄워야 했던 이유는 두 대가 같은 행을 같이 읽고 같이 보낼 수 있어서였다. 이건 FOR UPDATE SKIP LOCKED로 막을 수 있다.

1
2
3
4
5
6
SELECT id
FROM send_target
WHERE lease_until IS NULL OR lease_until < NOW()
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

다른 워커가 이미 잡고 있는 행은 기다리지 않고 건너뛴다. 그래서 워커를 여러 대 띄워도 같은 행을 두 워커가 동시에 잡지 않는다. 인스턴스를 하나만 띄워야 한다는 규칙을 사람이 기억하지 않아도 된다.

그런데 발송하는 동안 락을 계속 잡고 있어야 할까?

FOR UPDATE로 잡은 락은 트랜잭션이 끝날 때까지 유지된다. 그러면 외부 API로 발송하는 동안에도 트랜잭션을 열어 둬야 한다. 발송이 느려지면 DB 커넥션과 락도 그만큼 오래 잡혀 있게 된다.

그래서 락은 행을 고르는 짧은 순간에만 쓰고, 그 뒤로는 lease_until로 “이 행은 내가 처리 중”이라고 표시해 두기로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
-- 1. 짧은 트랜잭션으로 행을 고르고 lease를 건다
UPDATE send_target
SET lease_until = NOW() + INTERVAL 30 SECOND
WHERE id IN (:ids);
COMMIT;

-- 2. 트랜잭션 밖에서 발송한다

-- 3. 이력 저장과 원본 삭제를 하나의 트랜잭션으로 처리한다
INSERT INTO send_history SELECT ... FROM send_target WHERE id = :id;
DELETE FROM send_target WHERE id = :id;
COMMIT;

다른 워커는 lease_until이 지나지 않은 행을 고르지 않는다. 워커가 발송 도중 죽으면 lease_until이 지난 뒤 다른 워커가 그 행을 다시 가져간다. 죽은 워커가 잡고 있던 일을 따로 정리해 주지 않아도, 시간이 지나면 자연스럽게 다른 워커에게 넘어가는 것이다.

lease 시간은 발송에 걸리는 시간보다 넉넉하게 잡아야 한다. 발송이 lease보다 오래 걸리면, 아직 처리 중인 행을 다른 워커가 가져가서 한 번 더 보낼 수 있다.

요구사항과 다시 맞춰 보면

요구사항폴링 워커
인프라를 늘리는 것을 최소화새로 생기는 건 워커 하나뿐이다
코드가 분리되어 있어야 함워커가 WAS와 따로 뜬다
비용을 최소화큐도 CDC도 필요 없다
안전하게 처리되어야 함SKIP LOCKED와 lease로 같은 행을 같이 잡지 않고, 죽은 워커의 일은 lease가 지나면 넘어간다

3번에서 고민했던 문제도 대부분 사라진다. 큐가 없으니 DeleteMessage 순서를 걱정할 필요가 없고, 내가 지운 행이 다시 이벤트로 들어오는 일도 없다. 원본 행이 남아 있으면 아직 처리 전이고, 지워졌으면 처리 완료다.

다만 발송 직후, 이력 저장과 원본 삭제 트랜잭션 전에 죽으면 다시 보내게 되는 구간은 그대로 남는다. 발송은 큐가 있든 없든 트랜잭션에 넣을 수 없기 때문이다.

트래픽이 낮은 이 상황에서는, 이미 있는 테이블을 큐로 쓰고 워커만 따로 띄우는 것으로도 충분하지 않았을까?


6. 마무리

처음에는 초당 1건도 안 되는 발송에 Kafka까지 필요할까라는 질문으로 시작했다.

직접 설계해 보면서 CDC와 SQS를 붙였더니, 발송과 DeleteMessage, 원본 삭제, 이력 이관 순서 하나하나에서 문제가 생겼다. 순서를 다시 정하고 나니 아티클과 거의 같은 답에 도착했다.

그런데 아티클과 비교하다 보니 다시 처음 질문으로 돌아왔다. 트래픽이 낮다는 조건이라면, 이미 큐처럼 쓰이고 있는 테이블을 워커가 폴링하는 것만으로도 충분하지 않았을까?

물론 나는 올리브영의 인프라 구조를 모른다. 이미 CDC와 Kafka를 운영하고 있었다면 새로 늘어나는 부담이 거의 없었을 테니, 아티클의 선택이 더 자연스러웠을 수도 있다.

이번 글에서 가장 기억에 남는 것은 어떤 방법이 정답이냐보다, 내가 정한 요구사항을 기준으로 방법을 하나씩 따져 본 과정이었다. 아티클 제목처럼 때로는 오버엔지니어링이 필요할 수도 있다. 다만 그게 정말 필요한지는 결국 서비스의 조건을 보고 판단해야 한다고 느꼈다.

This post is licensed under CC BY 4.0 by the author.