부분 실패
고친 사람 github-actions[bot]
부분 실패는 여러 곳에 걸친 한 번의 작업에서 일부만 성공하고 나머지는 실패하는 고장입니다. 결제만 되고 배송 접수는 안 된 주문이 그렇습니다. 전부 되거나 전혀 안 되거나 둘 중 하나라고 믿고 짠 코드가 이때 어긋납니다. 실패한 쪽이 정말 안 된 것인지도 부르는 쪽에서는 가릴 수 없습니다.
쉽고 빠른 이해
한 건을 처리하다가 절반만 되고 끝나는 고장입니다. 결제는 됐는데 배송 접수는 안 된 주문이 그런 경우입니다.
한 대의 기계 안에서는 프로그램이 멈추면 다 같이 멈춥니다. 일이 여러 기계에 나뉘면 한쪽만 멈출 수 있고, 멈춘 쪽이 멈췄다는 것을 남에게 알려 주지 않습니다.
이렇게 터집니다:
- 한 건을 처리하려고 여러 곳에 차례로 요청을 보냅니다
- 앞의 몇 곳은 처리했는데 어느 곳에서 실패하거나 답이 안 옵니다
- 앞에서 처리한 것은 남아 있고, 부르는 쪽은 어디까지 됐는지 모릅니다
견디려면 손이 늘어납니다. 같은 요청이 두 번 와도 한 번으로 치는 장치를 붙이고, 어디까지 갔는지 따로 기록하고, 이미 된 것을 되돌리는 길을 따로 만들어야 합니다.
상세
한쪽만 죽는 고장
부분 실패는 한 대의 기계에서 나는 고장과 성질이 다릅니다.
이삿짐을 트럭 셋에 나눠 싣고 새집으로 보냈다고 해 봅시다. 셋 다 도착하면 이사가 끝나고, 셋 다 못 떠나면 이사를 안 한 것입니다. 곤란한 것은 둘만 도착한 경우입니다. 남은 짐이 길에서 엎어진 것인지 아직 오는 중인지 새집에서는 알 수 없습니다.
한 대의 기계 안에서는 이런 어중간한 결과가 잘 안 납니다. 프로그램이 죽으면 그 프로그램이 쥐고 있던 것이 함께 사라지고, 부르는 쪽은 아무 일도 안 일어났다고 보면 대체로 맞습니다.
일이 여러 기계나 여러 저장소에 나뉘면 사정이 달라집니다. 한쪽 기계는 멀쩡히 돌고 다른 쪽만 멈출 수 있습니다. 게다가 멈춘 쪽이 멈췄다는 것을 남에게 알려 주지 않습니다.
그래서 한 건의 작업이 여러 곳에 걸쳐 있을 때 일부만 성공하고 나머지는 실패한 상태가 생깁니다. 이것이 부분 실패입니다.
이 글에서 「곳」은 자기 저장소를 따로 쥐고 있는 서버나 저장소 하나를 가리킵니다. 한 데이터베이스 안의 두 테이블은 한 곳입니다.
분산 시스템이 어렵다고 말할 때 가리키는 어려움의 뿌리가 이것입니다. 분산 시스템은 여러 대의 기계가 네트워크로 이어져 하나처럼 일하는 구조를 말합니다. 기계가 여럿이면 한쪽만 죽는 일이 늘 생깁니다.
성공도 실패도 아닌 셋째 결과
한 곳에 요청을 보내고 답을 기다리는 코드는 결과를 둘로 나눕니다. 성공이거나 실패입니다. 사이에 네트워크가 끼면 셋째가 생깁니다. 답이 안 와서 모르는 경우입니다.
모른다는 것이 왜 곤란한지는 실패와 견주면 드러납니다. 실패라면 다시 보내면 되고 성공이라면 넘어가면 됩니다. 모르는 경우에는 어느 쪽이냐에 따라 해야 할 일이 반대입니다. 그런데 어느 쪽인지 알 방법이 없습니다.
답이 안 왔을 때 상대 쪽에서 있었던 일은 셋 중 하나입니다.
| 상대 쪽에서 있었던 일 | 다시 보내면 |
|---|---|
| 요청이 닿지도 않았다 | 한 번 처리된다 |
| 닿아서 처리됐는데 답이 오다 끊겼다 | 두 번 처리된다 |
| 닿았지만 처리 도중 상대가 죽었다 | 어중간하게 남은 것 위에 또 처리된다 |
세 줄 모두 부르는 쪽에는 똑같이 「답이 없음」으로 보입니다. 타임아웃도 이 셋을 안 가려 줍니다. 타임아웃은 정해 둔 시간 안에 답이 없으면 기다리기를 그만두는 장치일 뿐이고, 상대가 무엇을 했는지는 알려 주지 않습니다.
터지는 조건
셋이 모두 맞을 때 터집니다. 하나라도 빠지면 일부만 된 상태가 안 생깁니다.
| 조건 | 무슨 뜻인가 |
|---|---|
| 걸침 | 한 건의 작업이 두 곳 이상을 고친다 |
| 따로 확정 | 그 두 곳이 한 번의 커밋으로 같이 확정되지 않는다 |
| 중간 실패 | 첫 곳을 고친 뒤 다음 곳에서 실패하거나 답이 안 온다 |
아래 그림은 둘째 조건을 그린 것입니다. 안쪽 상자 하나가 한 번의 커밋입니다.
flowchart TD
subgraph G1["두 곳에 나눠 쓴다"]
A["주문 한 건"]
subgraph C1["결제 서버의 커밋"]
A1["결제 행"]
end
subgraph C2["배송 서버의 커밋"]
A2["배송 행"]
end
A --> A1
A --> A2
end
subgraph G2["한 저장소에 모은다"]
B["주문 한 건"]
subgraph C3["한 번의 커밋"]
B1["주문 행"]
B2["배송 행"]
end
B --> B1
B --> B2
end
위쪽은 한 건이 커밋 경계 둘을 가로지르는 모습이고, 아래쪽은 같은 일이 커밋 하나 안에 들어온 모습입니다. 경계가 둘이냐 하나냐가 둘째 조건이 갈리는 곳입니다.
둘째 조건을 뒤집어 말하면 그 작업이 원자성을 못 지킨다는 뜻입니다. 원자성은 여러 단계로 된 일이 전부 된 것이나 전혀 안 된 것 중 하나로만 보이게 하는 성질입니다.
조건은 이 정도로 좁혀야 재현이 됩니다. 「서버가 불안정할 때 터집니다」는 조건이 아닙니다. 「결제 서버에 먼저 쓰고 배송 서버에 쓰는 코드에서 배송 서버를 내린 채 주문을 한 건 넣으면 결제만 남는다」가 조건입니다.
최소 재현
주문을 받는 쪽이 결제와 배송 두 곳에 나눠 기록하는 코드가 제일 작은 재현입니다. 아래에서 「주문 서버」라고 부르는 것이 그 부르는 쪽입니다.
결제.승인(주문); // 승인됨
배송.접수(주문); // 타임아웃
두 줄 사이에 실패가 들어가면 결제만 남은 주문이 생깁니다. 이 주문은 어느 쪽에서 봐도 어중간합니다. 결제 쪽 기록만 보면 팔린 주문이고, 배송 쪽 기록만 보면 없는 주문입니다.
아래는 이때 오간 것을 순서대로 늘어놓은 그림입니다.
sequenceDiagram
participant 주문 as 주문 서버
participant 결제 as 결제 서버
participant 배송 as 배송 서버
주문->>결제: 승인 요청
결제-->>주문: 승인됨
주문->>배송: 접수 요청
Note over 배송: 답이 오지 않는다
Note over 주문: 접수됐는지 모른 채 끝난다
마지막 화살표에는 돌아오는 짝이 없습니다. 보낸 것은 확실합니다. 처리됐는지는 모릅니다.
되돌리기가 어려운 까닭
어중간한 상태를 봤으면 앞으로 되돌리면 될 것 같습니다. 한 대의 기계 안이라면 그렇습니다. 데이터베이스는 롤백으로 아직 확정하지 않은 변경을 지웁니다.
곳이 여럿이면 되돌리기도 요청입니다. 결제를 취소하려면 결제 서버에 취소 요청을 보내야 합니다. 그 요청도 닿지 않거나 답이 안 올 수 있습니다. 되돌리는 일 자체가 또 부분 실패합니다.
sequenceDiagram
participant 주문 as 주문 서버
participant 결제 as 결제 서버
participant 배송 as 배송 서버
주문->>결제: 승인 요청
결제-->>주문: 승인됨
주문->>배송: 접수 요청
Note over 배송: 답이 오지 않는다
주문->>결제: 승인 취소 요청
Note over 결제: 이 답도 오지 않는다
배송 접수의 답을 못 받아 결제를 되돌리려 합니다. 그런데 그 취소 요청도 답이 오지 않습니다. 되돌리기가 같은 고장을 한 겹 더 만듭니다.
이미 확정된 것을 못 지우는 경우도 있습니다. 보낸 메일은 회수되지 않습니다. 외부 결제망에 올라간 승인은 지우는 것이 아니라 반대 거래를 하나 더 만들어 갚습니다. 이렇게 되돌리기 대신 반대 일을 해서 앞의 결과를 상쇄하는 것을 보상 트랜잭션이라고 합니다.
견디는 쪽으로 짜는 법
없애는 것이 아니라 견디는 쪽으로 짭니다. 곳이 여럿인 한 이 고장은 계속 납니다. 목표는 났을 때 사람이 안 들어가도 양쪽이 도로 맞아떨어지게 만드는 것입니다.
수단은 앞의 조건 셋 중 어느 것을 깨뜨리느냐로 갈립니다.
| 깨뜨릴 조건 | 방법 | 이름 |
|---|---|---|
| 걸침 | 두 곳에 쓰던 것을 한 저장소 안의 한 번의 쓰기로 모은다 | 트랜잭셔널 아웃박스 |
| 따로 확정 | 여러 곳을 한 번에 확정하는 절차를 둔다 | 2단계 커밋 |
| 중간 실패 | 못 없앤다. 다시 해도 안전하게 만들어 끝날 때까지 보낸다 | 멱등성 · 체크포인트 · 대사 |
셋 중 제일 자주 쓰는 것은 마지막 줄입니다. 그것부터 풉니다.
먼저 코드가 결과를 셋으로 나눠 들게 합니다. 성공과 실패만 있는 코드는 모르는 경우를 실패로 몰아넣습니다. 그러면 이미 된 일을 안 된 것으로 치게 됩니다.
모르는 경우를 따로 두고 나면 할 일은 하나입니다. 다시 보내는 것입니다. 그러려면 두 번 보내도 안전해야 합니다.
같은 요청이 두 번 와도 한 번 온 것과 결과가 같은 성질을 멱등성이라고 합니다. 요청마다 키를 하나 붙여 보내면 됩니다. 받는 쪽은 그 키를 이미 처리했으면 다시 하지 않고 앞서 준 답을 돌려줍니다.
배송.접수(주문, "ord-7-1"); // 접수됨
배송.접수(주문, "ord-7-1"); // 접수됨
두 줄이 같은 답을 줍니다. 받는 쪽은 두 번째 요청에서 아무것도 하지 않았습니다.
아래 그림은 이 키가 보내는 쪽과 받는 쪽에서 각각 어떻게 쓰이는지를 보인 것입니다.
flowchart TD
A["보내는 쪽: 같은 키를 붙여 요청을 보낸다"] --> D{"받는 쪽: 이 키를 전에 봤나"}
D -->|"처음"| E["받는 쪽: 처리하고 답을 적어 둔다"]
D -->|"이미 봤다"| F["받는 쪽: 적어 둔 답을 돌려준다"]
E --> B{"보내는 쪽: 답이 닿았나"}
F --> B
B -->|"닿았다"| C["보내는 쪽: 이 요청은 끝났다"]
B -->|"안 닿았다"| A
되돌아가는 화살표를 몇 바퀴 돌아도 처리는 한 번만 됩니다.
다시 보내는 것만으로는 부족합니다. 곳이 여럿이면 어느 곳까지 끝냈는지를 부르는 쪽에 적어 두고, 다시 시작할 때 그다음부터 갑니다. 여러 건을 줄줄이 처리하는 배치 처리에서는 이 기록을 체크포인트라고 부릅니다.
그 기록이 또 하나의 곳이 되면 헛일입니다. 부르는 쪽 자기 저장소에 업무 데이터와 같은 확정 안에서 적으면 곳이 안 늘어납니다.
그래도 어긋난 것은 남습니다. 그래서 주기적으로 양쪽 기록을 견주는 작업을 따로 돌립니다. 한쪽에만 있는 건을 찾아 걷어 냅니다. 양쪽을 맞춰 보는 이 작업을 대사라고 부릅니다.
표 첫째 줄은 곳을 줄이는 쪽입니다. 두 곳에 나눠 쓰던 것을 한 저장소 안의 한 번의 쓰기로 모으면 걸침이 사라집니다.
최소 재현으로 돌아가 봅시다. 배송 접수를 지금 부르는 대신, 주문 행을 쓰는 같은 트랜잭션 안에서 「배송에 접수할 것」을 한 테이블에 한 줄로 적어 둡니다. 트랜잭션은 여러 번의 쓰기를 한 번에 확정하거나 한 번에 무르는 묶음입니다. 주문 행과 그 한 줄은 같은 저장소에 있으니 함께 확정됩니다.
그 줄은 따로 도는 일꾼이 읽어 배송 서버로 보냅니다. 보내다 실패해도 줄이 남아 있으니 다시 보내면 됩니다. 이렇게 짜는 것을 트랜잭셔널 아웃박스라고 합니다.
flowchart TD
subgraph 커밋["한 번의 커밋 · 주문 저장소"]
A["주문 행"]
B["아웃박스 행"]
end
B --> W["따로 도는 일꾼"]
W --> S["배송 서버"]
배송 서버가 저절로 갱신되지는 않습니다. 다만 한 건의 작업이 두 곳을 고치던 대목이 한 곳으로 줄었고, 남은 것은 끝날 때까지 다시 보내면 되는 일입니다.
표 둘째 줄은 여러 곳을 한 번에 확정하려는 2단계 커밋입니다. 조율하는 쪽이 먼저 「준비됐나」를 묻고, 모두가 준비됐다고 답하면 그때 확정을 알립니다. 답하는 쪽을 참여자라고 부릅니다.
대가는 그 조율하는 쪽입니다. 조율하는 쪽이 확정을 알리기 직전에 죽으면 참여자들은 확정도 취소도 못 한 채 잠긴 채로 기다립니다.
이 고장과 가르는 선
곳이 여럿이라고 다 부분 실패는 아닙니다. 한 데이터베이스 안에서 행 여럿을 고치는 것은 한 번의 커밋으로 같이 확정되므로 둘째 조건이 안 맞습니다. 중간에 죽으면 그 커밋은 없던 것이 됩니다.
다만 그 커밋의 답을 못 받으면 다시 모르는 경우로 돌아갑니다. 저장소 안에서는 전부 되거나 전혀 안 된 것이 맞습니다. 다만 부르는 쪽에서는 어느 쪽인지 모릅니다. 부분 실패의 절반은 상태가 어중간한 것이고 나머지 절반은 상태를 모르는 것입니다.
한쪽만 느려지는 것도 이 고장과 가릅니다. 답이 늦을 뿐 오기는 오면 일부만 성공한 상태가 아닙니다. 다만 부르는 쪽이 기다리다 포기하면 그때부터는 모르는 경우가 되어 같은 문제로 넘어갑니다.
전부 죽는 것도 이 고장이 아닙니다. 다 죽으면 아무것도 안 된 것이라 재시도 한 번으로 끝납니다. 어중간한 상태가 안 남는 쪽이 다루기 쉽습니다.
관련 항목
부분 실패가 생기는 실행 구조
분산 시스템 · 마이크로서비스 · 네트워크 분단 · 비동기 네트워크 · 원격 프로시저 호출 · 메시지 큐 · 배치 처리 · 데이터 파이프라인 · 복제
부분 실패를 견디게 하는 성질과 장치
멱등성 · 재시도 · 지수 백오프 · 체크포인트 · 대사 · 보상 트랜잭션 · 사가 · 트랜잭셔널 아웃박스 · 2단계 커밋 · 최종 일관성 · 서킷 브레이커
부분 실패가 남기는 어긋난 상태
중복 적재 · 중복 결제 · 고아 레코드 · 데이터 불일치 · 메시지 유실 · 좀비 트랜잭션
부분 실패를 알아채는 수단
타임아웃 · 헬스 체크 · 분산 추적 · 모니터링 · 경보 · 로그 집계
부분 실패와 맞세워지는 성질
원자성 · 트랜잭션 · 커밋 · 롤백 · 분산 트랜잭션 · 선형화 가능성
부분 실패와 헷갈리는 이웃 고장
경쟁 상태 · 갱신 손실 · 데드락 · 스플릿 브레인 · 연쇄 장애 · 일시적 장애 · 영구적 장애
부분 실패가 못 없어지는 까닭을 밝히는 이론
다른 이름: partial failure · 부분 장애 · 부분적 실패