서버 간 통신, 이번엔 제대로 신뢰성 있게 해보자
목차
1. 들어가며
솔직히 그동안 서버끼리 연동할 때 깊게 고민해본 적이 별로 없었어요. 늘 하던 대로 HTTP로 호출하거나, 아니면 카프카로 이벤트 하나 던지고 끝냈죠. 그런데 그렇게 만들어둔 연동들이 종종 말썽이었습니다. 어느 날 보면 데이터가 누락돼 있어서 손으로 메꿔야 했고 어떤 기능은 연동이 못 미더워서 아예 접거나 동기 호출로 되돌리기도 했고요.
이제 여러 서비스를 본격적으로 이어붙이는 작업을 앞두고 있어서 이번엔 좀 제대로 해보고 싶었습니다. “서버가 다른 서버를 부른다”는 이 단순한 일이 왜 자꾸 깨지는지, 신뢰성 있게 만들려면 뭘 고민해야 하는지를 여러 방면으로 따져봤어요. 이 글은 그 고민의 기록입니다. 특정 기능 이야기라기보다는, 서버 간 통신을 설계할 때 제가 짚어야 한다고 느낀 것들을 개념 위주로 정리해보려고 해요.
시작하기 전에 한 가지를 먼저 깔고 가고 싶어요. 서버가 다른 서버를 HTTP로 부르는 순간, 둘 사이엔 네트워크라는 못 믿을 구간이 낍니다. 응답이 200으로 깔끔하게 오면 다행인데, 현실은 그렇게 친절하지 않죠. 4xx가 올 수도, 5xx가 올 수도, 아예 응답이 없을(timeout) 수도, 연결을 거부당할 수도 있어요. 그리고 제일 골치 아픈 경우가 있죠. 상대는 처리했는데 응답만 유실돼서 성공인지 실패인지 알 수 없는 상황도 있습니다.
그래서 저는 서버 간 통신 설계의 본체가 happy path가 아니라 **“실패를 어떻게 다룰까”**라고 생각하게 됐어요. 그리고 그 실패들을 들여다보다 보니, 전부 결국 세 가지 질문으로 좁혀지더라고요.
- 누구 책임인가? — 내가 잘못 보냈나, 상대가 아픈가?
- 재시도하면 되나? — 일시적인가, 영구적인가?
- 부작용이 적용됐을 수 있나? — 결과가 모호한가?
이 세 질문이 글 전체를 관통합니다. 하나씩 따라가 볼게요.
2. 에러를 그대로 흘려도 되나?
제일 먼저 막힌 건 의외로 단순한 데였어요. A 서버가 B 서버를 부르고 그 결과를 다시 A의 클라이언트에게 돌려주는 구조에서, B가 준 status를 그대로 클라이언트에게 흘려보내도 되나? 하는 거였죠.
처음엔 “그냥 패스스루하면 편하지 않나” 했는데 생각해보니 이게 추상화를 깨는 거더라고요. A의 클라이언트는 B라는 서버가 존재하는지조차 몰라요. 그런데 B가 뱉은 400을 그대로 받으면, 클라이언트는 “어? 내가 요청을 잘못 보냈나?” 하고 헛다리를 짚습니다. 사실 그 400은 A가 내부적으로 B를 부르다 난 거고 클라이언트가 어떻게 할 수 있는 게 아닌데도요.
그래서 A가 번역기 역할을 해줘야 한다고 정리했어요. “이 응답을 받은 내 클라이언트가 뭘 해야 하는가”를 기준으로 status를 다시 정하는 거죠. 정리해보면 이렇게 갈립니다.
| B의 상황 | A가 클라에 주는 status | 왜 |
|---|---|---|
| B가 400 (A가 잘못된 값으로 호출) | 500 | 클라가 고칠 게 없음 → 재시도 막기 |
| B 연결 거부 (down) | 503 | 일시적, 나중에 되면 재시도 가능 |
| B 응답 없음 (timeout) | 504 | upstream 지연 |
| B가 5xx | 502 | upstream이 깨진 응답을 줌 |
그런데 여기서 제가 한참 헷갈렸어요. 처음 직관은 “400은 재시도해도 안 되니까 500으로 주자”였거든요. 결론은 맞는데 근거가 틀렸더라고요.
곰곰이 보니 HTTP status code는 사실 직교하는 두 정보를 한 숫자에 욱여넣고 있었습니다.
| 축 | 질문 | 어디에 담나 |
|---|---|---|
| 책임 주체 | 누구 잘못? 클라가 고칠 수 있나? | 4xx ↔ 5xx (status) |
| 재시도 가능성 | 다시 보내면 되나? | status론 부정확 → 별도 신호 |
그러니까 “400은 재시도 안 되니까 500”이 아니라, **“이건 A 책임이니까 5xx”**가 정확한 근거였어요. 분기 기준은 재시도 가능성이 아니라 책임 주체여야 하더라고요. 재시도해도 되는지는 Retry-After 헤더나 body의 { code, retryable: false } 같은 걸로 따로 빼고요. 이렇게 나누고 나니 status는 모니터링이나 서킷브레이커가 쓰는 거친 신호로, body는 클라가 행동을 정하는 정밀한 신호로 역할이 깔끔하게 갈렸습니다.
참고로 502를 “nginx가 죽은 것”으로 오해하기 쉬운데, 502 Bad Gateway는 원래 “게이트웨이 역할 서버가 upstream에서 이상한 응답을 받았다”는 표준 정의예요. A가 B 앞단에서 그 역할을 하니 의미상 오히려 502가 맞습니다. 다만 인프라가 뱉은 502랑 앱이 의도적으로 던진 502가 모니터링에서 안 갈리는 게 현실 문제인데, 그건 body 에러코드나 헤더로 출처를 박아서 구분했어요.
3. 재시도, 지금 할까 이따 할까
상대가 5xx를 뱉었을 때 제일 먼저 떠오른 건 “바로 다시 부르자”였어요. 그런데 이게 함정이더라고요. 500이 난 서버는 1초 뒤에도 거의 또 500이에요. 그 자리에서 즉시 재시도하면 회복은 안 되고 부하만 더 줍니다. 이미 비틀거리는 서버를 제가 더 밀어버리는 셈이죠.
그래서 재시도는 “이따가”가 기본이어야 했어요. 그럼 그 “이따가”를 어디에 맡길까. 몇 가지를 놓고 비교해봤습니다.
| 방안 | 걸리는 점 |
|---|---|
setTimeout으로 인메모리 지연 | 프로세스가 죽으면 그 타이머도 같이 증발 → 재시도 유실 |
| 메시지 큐(Kafka)에 지연 흉내 | 오프셋 커밋 블로킹·HoL 블로킹·리밸런스 — 지연 재시도엔 안 맞음 |
| 지연 작업 큐(BullMQ류) | delay·attempts·backoff·DLQ가 내장 |
결론은 지연 작업 큐였어요. attempts: 4, exponential 30s처럼 두면 30초 → 1분 → 2분 → 4분으로 백오프하면서 알아서 재시도해줍니다. 상대가 몇 분 죽어도 큐가 메워주니 다음 정기 작업까지 기다려야 했던 큰 갭이 분 단위로 줄어요. 그리고 재시도를 다 써버리면 그냥 조용히 죽이는 게 아니라 DLQ로 보내서 알림을 띄우게 했습니다. “끝까지 재시도하거나, 안 되면 눈에 띄게 남긴다”까지 해야 비로소 신뢰성이라고 느꼈거든요.
flowchart TD
Call[B 호출] -->|성공| Done([완료])
Call -->|4xx 영구| Skip([즉시 알림 + skip])
Call -->|5xx/timeout 일시| Enq[지연 큐에 적재]
Enq --> Q[(지연 작업 큐)]
Q -->|백오프 후| Retry{재시도}
Retry -->|성공| Done
Retry -->|attempts 소진| DLQ([DLQ + 알림])
style Done fill:#ebfbee,stroke:#2f9e44
style Skip fill:#fff5f5,stroke:#e03131
style DLQ fill:#fff5f5,stroke:#e03131
style Enq fill:#fff9db,stroke:#e8590c
재시도엔 무서운 함정이 하나 더 있어요. 재시도 증폭이라는 건데요. 각 계층이 3번씩 재시도하면, 3계층을 거치는 호출은 가장 깊은 서비스에 27배 부하를 때립니다. 이미 아픈 서비스를 재시도가 오히려 더 죽이는 거죠. 그러니 재시도는 한 계층(보통 제일 바깥 엣지)에서만 두고 전체의 일정 비율로 제한하는 retry budget 같은 걸 같이 두는 게 맞다고 봤어요. 뒤에 나올 서킷브레이커의 “빠른 실패”가 이 폭주를 끊어주는 것도 같은 맥락이고요.
4. 재시도해도 괜찮은 걸까?
여기서 멈칫했어요. 재시도를 한다는 건 결국 같은 요청을 두 번 이상 보낼 수도 있다는 거잖아요. 그래도 괜찮은 게 맞나?
이걸 따지다 보니 exactly-once는 원리적으로 불가능하다는 결론에 닿더라고요. 우리가 현실에서 가질 수 있는 건 보통 at-least-once고 그건 곧 중복을 전제로 깔고 간다는 뜻이에요. 그러니 중복이 와도 결과가 같도록 만드는 멱등성은 선택이 아니라 그냥 전제였습니다.
그런데 “재시도해도 된다”고 다 같은 등급이 아니라는 걸 알게 됐어요. 앞 표에서 503이랑 504를 둘 다 “재시도 가능”으로 묶었는데 사실 둘은 안전도가 다릅니다.
- 503 (연결 거부) → 상대가 요청을 받지도 못했음이 확실해요. 부작용이 0이니 다시 보내도 안전합니다.
- 504 (timeout) → 상대가 처리했는지 안 했는지 내가 모릅니다. 처리는 됐는데 응답만 유실됐을 수도 있어요. 이게 분산 시스템에서 제일 까다로운, 결과가 모호한 케이스예요.
세 번째 질문(“부작용이 적용됐을 수 있나”)이 바로 여기서 갈리더라고요. GET 같은 멱등 읽기면 504든 5xx든 똑같이 취급해도 안전해요. 다시 읽어봐야 그만이니까요. 그런데 “1000원을 차감하라” 같은 비멱등 쓰기를 504에서 무심코 재시도하면 이중 차감이 납니다. 그래서 504 + 비멱등 연산은 함부로 재시도하면 안 되고 멱등 장치랑 반드시 세트여야 한다고 정리했어요.
5. 멱등은 누가 책임지나
그럼 그 멱등성을, 누가 어떻게 보장하느냐. 크게 두 갈래로 보였어요.
첫째, 연산 자체가 원래 멱등인 경우. GET·PUT·DELETE처럼요. 또는 “이 관계를 이 상태로 만들어라” 같은 선언형 연산이 여기 속합니다. 같은 요청이 두 번 와도 결과가 똑같으니 따로 장치가 필요 없어요. upsert가 대표적이죠.
둘째, 연산이 누적형이라 원래 멱등이 아닌 경우. “포인트를 더해라” 같은 거요. 두 번 오면 두 배가 빠지니까 이건 호출하는 쪽이 멱등키를 발급하고 받는 쪽이 그 키로 중복을 걸러내야 합니다. Stripe가 쓰는 방식이에요. 내부에서 재시도할 때도 같은 키를 계속 들고 가야 하고요.
결국 도메인의 성격이 전략을 정하는 것 같아요. “A와 B를 이 상태로 매핑해라”는 선언형이라 자연히 멱등이고 “포인트를 더해라”는 누적형이라 멱등키가 필수예요. 이 둘을 구분하는 게 첫 단추였습니다.
멱등키를 무엇으로 잡느냐도 은근히 중요했어요. 안정적인 비즈니스 정체성으로 잡아야 하더라고요. 매번 새로 생성되는 uuid 같은 걸 키로 쓰면, 재시도하거나 재발행할 때 키가 바뀌어서 같은 요청을 다른 요청으로 인식해버립니다. 그러면 멱등이 깨져요.
그리고 그 키를 어디에 저장해서 중복을 막느냐. 저는 진실의 원천은 unique index를 가진 멱등키 테이블이어야 한다고 봤어요.
// 중복 차단의 최종 방어선은 DB unique index
await session.withTransaction(async () => {
await processedKey.claim(idempotencyKey, { session }); // unique — 두 번째는 E11000
await applyEffect(payload, { session }); // 효과를 같은 트랜잭션에
});
// claim에서 중복(E11000)이 나면 "이미 처리됨"으로 조용히 흡수 — 이게 정상 흐름
여기서 제가 중요하게 생각한 건 도장(키 기록)과 효과(실제 처리)를 한 트랜잭션으로 묶는 것이에요. 둘을 따로 하면 “효과는 적용됐는데 도장은 안 찍힘”(중복 위험)이나 “도장만 찍힘”(유실 위험) 같은 틈이 생기거든요. 빠른 경로로 Redis를 1차 필터로 둘 수는 있지만 Redis는 부하를 덜어주는 보조일 뿐 단일 실패점이 되면 안 된다고 봤어요. Redis가 비어 있어도 DB unique가 중복을 최종적으로 막아줘야 하니까요.
6. 멱등은 방어가 아니라 자유다
이건 작업하면서 가장 크게 와닿은 부분이에요. 멱등성을 그냥 “중복이 와도 버틴다”는 방어로만 봤는데 써보니 그게 절반이었어요.
대량 데이터를 동기화하는 작업을 예로 들게요. 처음엔 재시도 단위를 잘게(항목 하나씩) 잡았어요. 격리도 깔끔하고 재시도도 정확하니까요. 그런데 호출이 항목 수만큼 나가서 부하가 좀 거슬렸습니다. 여러 항목을 한 번의 호출로 묶으면 호출 수가 확 줄어드는데 그러면 한 묶음이 중간에 실패했을 때 이미 성공한 항목까지 다시 처리하게 돼요. 처음엔 이게 데이터를 깨뜨리지 않을까 직관적으로 겁이 났어요.
그런데 가만 보니, 쓰기가 insert-only 멱등 upsert면 이미 있는 항목은 그냥 no-op이더라고요.
// 이미 있으면 안 건드림 ($setOnInsert). 다시 긁어도 무해하다.
{ updateOne: { filter: { key }, update: { $setOnInsert: { ...row } }, upsert: true } }
이미 성공한 항목을 또 처리해도 기존 행은 그대로(훼손 0), 없는 행만 채워져요(누락 0). 그러니까 재시도 단위를 키워도 정확성은 그대로고 커진 재시도가 만드는 건 “이미 한 일을 또 하는 헛수고”일 뿐이었어요. 그건 정확성의 문제가 아니라 효율의 문제더라고요.
| 잘게 재시도 | 묶어서 재시도 | |
|---|---|---|
| 정확성(누락·훼손) | 0 | 0 (멱등 덕분) |
| 호출 수 | 많음 | 적음 |
| 재시도 시 재처리량 | 그 항목만 | 묶음의 성공분까지(헛수고) |
이걸 깨닫고 나니 멱등성을 보는 눈이 좀 바뀌었어요. 멱등이 재시도 단위를 정확성에서 분리해준 거예요. 작업을 잘게 쪼개든 묶든, 백오프를 몇 번 더 돌리든, 멱등이면 정확성 걱정 없이 고를 수 있어요. 멱등이 없으면 이 선택지들이 전부 “중복 나면 어쩌지”라는 족쇄에 묶이는데 있으면 그게 풀립니다. 그래서 저는 신뢰성 설계의 첫 질문이 “어디까지 멱등하게 만들 수 있나”가 되어야 한다고 생각하게 됐어요.
7. 어디서부터 다시 하지?
재시도가 무해해졌으면, 다음 질문은 자연스럽게 “그래서 어디부터 다시 하지?”였어요. 대량 데이터를 페이지로 나눠 읽다가 3페이지에서 상대가 503을 뱉으면, 정확히 그 지점부터 이어가야 하잖아요.
여기서 offset(skip(2000).limit(200))은 위험하더라고요. 페이징 도중에 데이터가 삽입되거나 삭제되면 페이지가 통째로 밀려서 누락이나 중복이 납니다. 그래서 불변키 기준의 cursor(keyset) 페이지네이션을 썼어요. “직전 페이지의 마지막 키보다 큰 것”부터 다음 페이지를 가져오는 방식이요. 어디까지 읽었는지를 값으로 이어받기 때문에, 도중에 데이터가 늘어도 진행을 잃지 않습니다.
그리고 작업하다 보니 cursor랑 멱등이 사실 한 쌍이라는 게 보였어요.
- cursor는 “어디까지 읽었나”를 보장해줘요. → 진행을 잃지 않는다.
- 멱등은 “다시 해도 된다”를 보장해줘요. → 재시도가 두렵지 않다.
cursor만 있고 멱등이 없으면 재시도가 중복을 만들고 멱등만 있고 cursor가 없으면 매번 처음부터 다시 긁어야 해서 비효율적이에요. 둘이 같이 있어야 비로소 “안전하면서 효율적인 재개”가 되더라고요. 실패한 지점의 cursor를 재시도 작업에 담아두면, 다음에 그 지점부터 바로 이어갈 수 있고요.
8. 남의 장애가 왜 내 장애가 되나
마지막으로 고민한 건 장애 전파였어요. 상대 서버가 느려져서 호출마다 3초씩 타임아웃이 난다고 해볼게요. 그 3초 동안 제 커넥션이랑 자원이 묶이고 그런 요청이 쌓이다 보면 결국 남의 장애가 제 자원을 갉아먹어서 저까지 죽습니다. 이게 cascading failure인데, 처음엔 이게 왜 무서운지 잘 와닿지 않았어요.
서킷브레이커를 들여다보고 나서야 본질을 이해했는데 흔히 생각하는 “요청을 막는 것”이 핵심이 아니더라고요. 진짜는 실패하는 속도였어요.
- 서킷브레이커가 없으면: 요청 → 3초 대기 → 타임아웃. 그 3초 동안 자원이 묶입니다.
- 서킷브레이커가 OPEN이면: 요청 → 0ms 즉시 실패. 자원을 안 묶어요.
느린 실패(3초 점유)가 나를 죽이는 거고 서킷브레이커는 그걸 빠른 실패(0ms)로 바꿔주는 거예요. OPEN의 가치는 “차단”이 아니라 “즉시성”이라는 게, 저한텐 좀 새로운 관점이었어요. 폴백이 있으면 그동안 캐시 데이터라도 돌려주면 되고요.
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : 최근 N초·최소 M건 중 에러율 초과
OPEN --> HALF_OPEN : resetTimeout 경과
HALF_OPEN --> CLOSED : 테스트 요청 성공
HALF_OPEN --> OPEN : 테스트 요청 실패
OPEN으로 넘어가는 조건이 세 개(에러율·최소 요청 수·관측 윈도우)인 것도 다 이유가 있었어요. 최소 요청 수가 없으면 첫 1건 실패에 에러율 100%가 돼서 과민반응하고 관측 윈도우(최근 N초)가 없으면 누적 평균이라 지금 연속으로 죽어도 둔감하게 못 잡거든요. “최근 N초 동안 최소 M건은 와봤는데 그중 절반 넘게 실패”여야 진짜 죽은 거라고 보는 거죠.
여기서 환경별 디테일 하나를 짚고 싶어요. cascading failure로 죽는 자원이 늘 “스레드풀”인 건 아니에요. 이벤트 루프 기반 런타임(Node 같은)은 요청마다 스레드를 안 잡으니까, 실제로 죽는 건 커넥션/소켓 풀, 대기 중인 작업이 쌓이는 메모리, 그리고 펜딩이 쌓여 멀쩡한 요청까지 느려지는 이벤트 루프 지연이에요. 이거 모르고 “스레드풀 고갈”이라고 설명하면 사실이랑 안 맞더라고요.
그리고 서킷브레이커 상태가 보통 프로세스 로컬인 것도 처음엔 의아했는데 알고 보니 의도된 거였어요. 보호하려는 대상이 “내 프로세스의 로컬 자원”이니까 상태도 로컬인 게 자연스럽고 외부 호출을 줄이려고 둔 장치가 상태 보겠다고 매 요청마다 또 Redis를 호출하면 그게 자기모순이거든요. 진짜 전역 서킷브레이커가 필요하면 그건 앱 코드가 아니라 Istio/Envoy 같은 서비스 메시 레벨로 올리는 거고요. 결국 서킷브레이커가 하는 일은 외부 의존성의 장애가 내 SLO를 흔들지 않게 경계를 긋는 것이라고 이해했습니다.
9. 마치며
쭉 정리해보니, 서버 간 통신의 신뢰성은 어느 한 장치로 되는 게 아니었어요. 상대 응답을 책임 주체 기준으로 번역하고, 일시 실패는 지연 큐로 재시도하되 증폭은 막고, 그 재시도의 전제로 멱등성을 깔고. 거기에 멱등키를 안정적인 정체성으로 잡고, 재개 지점을 cursor로 잃지 않고, 서킷브레이커로 장애 전파를 끊고. 이것들이 다 맞물려야 비로소 “신뢰성 있다”는 느낌이 들었습니다.
그 밑바닥엔 처음에 깐 세 질문이 계속 흐르고 있었어요. 누구 책임인가, 재시도 가능한가, 부작용이 모호한가. 신기한 건 이 세 질문이 HTTP status든 gRPC 코드든 메시지 큐의 DLQ든 전송 수단과 상관없이 똑같이 답해야 하는 질문이라는 거예요. status code는 그냥 그 셋을 한 숫자에 욱여넣은 HTTP만의 어휘였던 거고요.
마지막으로, 동기 API랑 비동기 이벤트는 경쟁 관계가 아니라 같은 문제의 두 얼굴이라는 생각이 들어요. 즉시 응답이 필요하면 동기 호출에 이 글의 장치들을 얹고 즉시성이 덜 중요하면 이벤트로 빼서 발행을 보장하는 거죠. 어느 쪽이든 핵심은 결국 하나였어요. 네트워크 너머의 상대를 믿지 않는 것. 그 불신을 어떤 장치로 메우느냐가 설계의 거의 전부였던 것 같습니다. 물론 여기 적은 게 정답도 아니고 전부도 아닐 거예요. 실제로 여러 서비스를 이어붙이면서 분명 놓친 게 또 드러날 텐데, 그건 그때 또 정리해보려고 합니다.