← 모든 글
DevOps

웹 서버를 배포하면 지급이 멈췄다

배치가 발행한 지급 이벤트를 웹 서버가 처리하던 구조에서 배포마다 진행 중인 지급이 끊기던 문제를, 상태 기록과 프로세스 통합 두 단계로 나눠 없앤 과정을 적었다.

판매자에게 매출 대금을 먼저 지급하는 흐름이 있다. 배치가 대상을 뽑아 이벤트를 발행하고, 그 이벤트를 받아 실제로 돈을 보내는 처리는 웹 서버가 맡고 있었다.

문제는 그 웹 서버가 사용자 요청도 함께 받는 서버였다는 것이다. 기능을 배포하려면 그 서버를 내려야 하고, 내리는 순간 진행 중이던 지급도 함께 끊겼다.

배포가 지급을 건드리는 구조였다

발행과 처리가 다른 서비스에 나뉘어 있었다. 발행은 배치 서비스가 하고, 처리는 웹 서버가 한다. 배치 서비스는 종료 신호를 받으면 하던 일을 마치고 내려가지만, 웹 서버는 그런 준비가 돼 있지 않았다.

flowchart LR
  A["배치 서비스 · 대상 발행"] --> B[("이벤트 큐")]
  B --> C["웹 서버 · 지급 처리"]
  C --> D["지급 대행사 호출"]
  E["배포 · 재시작"] -.-> C

지급 한 건은 짧지 않다. 검증하고, 자격을 확인하고, 자금을 요청하고, 대행사에 보내고, 결과를 기다린다. 그 사이 어느 지점에서든 서버가 내려갈 수 있었다.

어디까지 됐는지 알 수 없었다

더 큰 문제는 끊긴 다음이었다. 처리 상태를 따로 저장하지 않았기 때문에, 서버가 다시 뜬 뒤에 어떤 건이 끝났고 어떤 건을 다시 해야 하는지 구분할 방법이 없었다.

이벤트가 큐에서 빠졌으니 다시 오지 않는다. 그렇다고 전부 다시 돌리면 이미 나간 지급이 또 나갈 수 있다. 결국 사람이 하나씩 조회해 확인했다.

배포는 자주 한다. 자주 하는 일이 매번 사람 손을 부르고 있었고, 확인하는 사람도 매번 같은 조회를 처음부터 다시 했다. 배포할 때마다 지급을 걱정해야 한다는 것 자체가 배포를 미루게 만드는 이유가 되기도 했다.

큐에서 꺼낸 다음이 더 위험했다

이 구조를 들여다보다가 더 조용한 경로를 발견했다. 이벤트를 큐에서 꺼내고 처리를 시작한 직후에 종료가 걸리는 경우다.

꺼내는 데 성공했으니 큐에는 없다. 그런데 처리 도중 Redis 클라이언트가 이미 종료된 상태가 되면 그다음 동작이 실패한다. 실패했을 때 다시 큐에 넣는 경로가 있었지만, 그 경로도 같은 클라이언트를 쓴다. 되돌릴 방법이 함께 죽는다.

take()로 꺼냄        -> 큐에서 사라짐
처리 시작
클라이언트 종료
재시도 시도          -> 같은 클라이언트라 실패
결과                 -> 이벤트 폐기

실제로 이력이 중간 단계에 멈춘 채 남아 있는 건을 확인했다. 그 건은 지급 자체는 정상 완료됐고 관찰용 이력만 갱신되지 않은 경우여서 피해가 없었다. 다만 같은 컨슈머가 다른 이벤트도 처리하고 있었고, 그쪽에서 같은 일이 나면 결과가 달랐을 것이다.

처음 설계는 절반만 옮기는 것이었다

처음에는 컨슈머만 배치 서비스로 옮기고 실제 처리 로직은 웹 서버에 두는 방식을 잡았다. 옮기는 코드가 적어 위험이 작아 보였다.

설계를 검토하다 이 방식이 목적을 달성하지 못한다는 것을 알았다. 컨슈머가 어디 있든 처리가 웹 서버에서 돌면 배포 영향은 그대로다. 가장 오래 걸리는 구간이 여전히 종료 준비가 없는 쪽에 남는다.

절반만 옮기는 안을 버리고 처리 자체를 옮기기로 방향을 바꿨다. 변경 범위는 커졌지만 그러지 않으면 작업의 이유가 사라졌다.

두 단계로 나눴다

그렇다고 한 번에 옮기지는 않았다. 지급은 틀리면 바로 돈 문제라, 먼저 안전망을 깔고 그다음에 구조를 바꾸기로 했다.

단계한 일노린 것
1차지급 건의 상태를 DB에 기록끊겨도 어디까지 됐는지 알 수 있게
2차처리를 배치 서비스로 이관배포가 지급을 건드리지 않게

1차만으로도 사람이 조회하던 일은 없어진다. 2차까지 가야 원인이 사라지지만, 1차가 먼저 들어가 있으면 2차 이관 중에 문제가 생겨도 복구할 근거가 남는다.

상태를 남기니 이어서 할 수 있었다

발행, 소비 시작, 완료를 각각 기록했다. 이 세 값만 있으면 서버가 언제 죽든 다음 실행이 판단할 수 있다.

발행만 있고 소비 시작이 없으면 아직 아무도 손대지 않은 건이다. 소비 시작은 있는데 완료가 없으면 처리 도중에 끊긴 건이고, 이건 대행사 쪽 결과를 확인한 뒤에 판단해야 한다. 둘 다 있으면 끝난 건이다.

전부 다시 돌리는 선택지가 사라진 것이 가장 큰 변화였다. 이어서 할 대상이 데이터로 정해지니 재처리가 판단이 아니라 조회가 됐다.

발행과 처리를 같은 프로세스에 뒀다

2차에서는 처리 로직을 배치 서비스로 옮겨 발행과 처리가 같은 프로세스 안에서 일어나게 했다.

flowchart LR
  A["배치 서비스"] --> B[("이벤트 큐")]
  B --> C["배치 서비스 · 지급 처리"]
  C --> D["지급 대행사 호출"]
  E["웹 서버 배포"] -. "영향 없음" .-> A

배치 서비스는 종료 신호를 받으면 처리 중인 건을 마치고 내려간다. 웹 서버 배포 주기와 지급이 완전히 분리됐다.

다만 전부 옮기지는 않았다. 신용과 계약 도메인에 강하게 묶인 검증 몇 단계는 웹 서버에 남기고 호출해서 쓰게 했다. 그 단계들은 다른 기능과 코드를 공유하고 있어서 떼어내면 중복이 생긴다. 가장 오래 걸리는 대행사 호출 구간을 옮기는 것이 목적의 본질이라 거기까지만 했다.

세 흐름이 한 지점에서 만났다

옮기면서 알게 된 것이 있다. 자동 지급과 운영자가 수동으로 실행하는 지급, 그리고 대기 상태에서 재개되는 지급이 전부 같은 처리 지점을 지나고 있었다.

그래서 그 지점을 옮기자 세 흐름이 함께 옮겨졌다. 각각을 따로 옮겼다면 세 번 나눠 검증해야 했을 일이 한 번에 끝났다. 반대로 말하면 그 지점 하나가 잘못되면 셋이 함께 잘못된다는 뜻이기도 해서, 검증은 세 경로를 모두 태워 확인했다.

옮긴 것은 지급 하나였다

웹 서버에 남은 컨슈머가 아직 있다. 지급은 옮겼지만 같은 컨슈머 구현을 쓰는 다른 이벤트가 그대로 남아 있고, 종료 시 이벤트를 잃는 경로도 그대로다. 배치 서비스에 적용한 종료 보강을 그쪽으로 옮기는 작업이 남았다.

중단된 건의 판단도 완전히 자동은 아니다. 소비 시작만 기록된 건은 대행사에 실제로 요청이 갔는지 확인해야 하고, 그 확인이 애매하면 사람이 본다. 상태를 남긴 것은 판단할 근거를 만든 것이지 판단을 대신한 것은 아니다.

중요했던 결정은 기술이 아니라 순서였다. 원인을 바로 없애는 대신 끊겼을 때 복구할 수 있게 먼저 만들었고, 그 안전망이 없었으면 이관 자체를 훨씬 조심스럽게, 그래서 훨씬 느리게 했을 것이다.