
TL;DR: 하나의 요청만 보냈다고 믿었던 결제 시스템이 왜 같은 거래를 두 번 처리하게 되었는지, 로그와 구조를 따라가며 끝까지 이해해 본 기록
이번 이슈를 다시 돌아보면서 가장 오래 남은 감정은, 나는 처음에 이 문제를 전혀 다른 방향으로 붙잡고 있었다는 약간의 당혹감이었다.
처음의 나는 네트워크를 먼저 의심했다. 앞단 장비가 요청을 다시 보낸 것은 아닐까, 중간 프록시가 재전송한 것은 아닐까, 상대 시스템이 같은 요청을 잘못 두 번 처리한 것은 아닐까 같은 생각을 한동안 반복했다. 그렇게 생각한 이유는 분명했다. 내가 보고 있던 결제 시스템 로그에는 외부 적립 요청이 한 번만 찍혀 있었기 때문이다.
그래서 처음에는 오히려 확신에 가까운 감정도 있었다. 적어도 내가 보고 있는 장면 안에서는 결제 시스템이 한 번만 호출한 것처럼 보였으니, 문제는 바깥 어딘가에 있다고 생각하기 쉬웠다.
그런데 결과적으로 이번 이슈의 핵심은 네트워크 재전송보다는, 결제 시스템 안에서 같은 거래를 두 실행 경로가 거의 동시에 접근 할 수 있었다는 쪽에 더 가까웠다. 겉으로 보면 같은 거래가 두 번 처리된 사건이지만, 조금 더 정확하게 말하면 같은 거래를 한 번만 처리되게 막아 주는 구조가 충분히 분명하지 않았던 사건이었다.
이번 글은 그 과정을 다시 따라가 본 기록이다. 무엇을 했는지를 차례대로 적기보다, 왜 처음에는 네트워크 문제처럼 보였는지, 어떤 순간부터 구조 문제로 보기 시작했는지, 그리고 이번 주에 공부하고 정리한 생각들이 왜 이 이슈를 이해하는 데 도움이 되었는지를 조금 더 솔직하게 남겨 두고 싶었다.
이번 글에서 중심에 두고 싶은 것
먼저 분명히 해 두고 싶은 것은, 이번 글의 주제가 이번 주 구현 내용 자체는 아니라는 점이다. 중심은 어디까지나 하나의 거래가 왜 두 번 처리되었는지에 대한 이슈다.
다만 이번 주에 공부하고 구현하며 정리한 생각들이, 이 이슈를 바라보는 방식에는 분명한 영향을 주었다. 예전 같았으면 나는 아마 끝까지 누가 요청을 두 번 보냈는지에 더 오래 머물렀을 것이다. 그런데 이번에는 그 질문만으로는 충분하지 않다는 것을 조금 더 빨리 받아들일 수 있었다.
그 이유는 이번 주 내내 붙잡고 있었던 질문이 결국 이런 것들이었기 때문이다.
- 어떤 값이 읽기 전에 이미 정리되어 있어야 하는가
- 어떤 책임을 쓰기 경로가 가져가야 하는가
- 어떤 결과가 여러 경로에서 동시에 다르게 보일 수 있는가
- 무엇이 코드와 구조 안에 분명하게 드러나 있어야 하는가
이 질문들은 원래 상품 조회 성능을 다루는 과정에서 더 선명해진 것들이었지만, 막상 이 중복 처리 이슈를 다시 보니 훨씬 더 직접적으로 연결되었다. 그래서 이번 글은 이번 주 구현을 소개하는 글이라기보다, 이번 주에 얻은 관점으로 이 이슈를 다시 이해해 본 글에 더 가깝다.
처음 마주했을 때 보였던 장면
이 사건은 처음에 아주 전형적인 네트워크 문제처럼 보였다.
내가 처음 받아 본 정보는 단순했다. 결제 시스템 로그에는 외부 적립 요청이 한 번만 보였고, 포인트 시스템에서는 같은 요청이 두 번 들어온 것처럼 보였고, 포인트 시스템 데이터베이스에는 같은 성격의 데이터가 두 건 있었다.
결제 시스템 로그.
13:14:01.002 [결제 시스템] --- 포인트 적립 요청 1회 ---> [포인트 시스템]
포인트 시스템 로그.
13:14:01.002 [결제 시스템] --- 포인트 적립 요청 1회 ---> [포인트 시스템]
13:14:01.003 [결제 시스템] --- 포인트 적립 요청 1회 ---> [포인트 시스템]
...
DB 저장 2회
이 정도 사실만 놓고 보면 대부분 같은 방향으로 생각하게 된다. 나 역시 그랬다. 결제 시스템은 한 번만 보냈는데, 중간 어딘가에서 같은 요청을 다시 보낸 것 아닐까 하는 생각이 가장 먼저 들었다.
이 가설은 정말 그럴듯했다.나 혼자 생각했을 때에는,, 내가 보고 있는 로그 안에서는 실제로 한 번만 호출한 것처럼 보였기 때문이다. 그래서 한동안은 앞단 장비, 프록시, 네트워크 재전송, 외부 시스템의 중복 처리 같은 가능성을 먼저 의심했다.
지금 돌아보면 그때의 나는 눈앞에 있는 로그가 보여 주는 장면을 거의 전체처럼 받아들이고 있었다. 이번 사건은 그 습관의 한계를 꽤 아프게 보여 줬다.
왜 그 생각이 그렇게 그럴듯했는가
내가 처음 네트워크를 의심한 이유는 단순히 경험 부족 때문만은 아니었다. 실제로 로그 흐름이 너무 정상적으로 보였기 때문이다.
지급 이력대상을 확인해서 데이터를 준비한 후, 포인트 시스템에 요청을 보내고, 성공 응답을 받고, 후속 처리까지 이어지는 흐름에는 눈에 띄는 실패도, 예외도, 다시 시도한 흔적도 없었다. 이 흐름만 놓고 보면 적어도 결제 시스템 한쪽에서는 한 번만 요청한 것처럼 보인다.
문제는 여기서 한 걸음 더 나가서, 그러면 시스템 전체도 한 번만 보냈겠구나 하고 믿어 버리면 안 된다는 점이었다.
이번 사건에서 내가 가장 크게 배운 것 중 하나는 이것이다. 하나의 로그 파일은 사건 전체가 아니라, 특정 위치에 설치된 카메라 한 대가 찍은 장면일 뿐이라는 사실이다. 머리로는 익숙한 말이었지만, 이번에는 그 문장이 훨씬 더 무겁게 다가왔다.
결정적인 단서는 생각보다 단순한 데 있었다
상황을 바꾸어 준 것은 포인트 시스템이 남긴 요청 정보였다. 같은 경로로 들어온 두 요청은 거의 같았다. 요청 경로가 같았고, 요청 본문이 같았고, 거래를 식별하는 거래 키가 같았고, 출발지 주소도 같았다.
그런데 요청 추적 식별자 header의 x-amzn-trace-id 는 달랐다.
이 차이는 아주 중요했다. 적어도 포인트 시스템은 이 두 건을 하나의 요청이 중간에서 복제된 흔적이 아니라, 서로 다른 두 요청으로 받아들이고 있었다고 보는 편이 더 자연스러웠다. 시간 차이도 거의 없었기 때문에, 질문은 자연스럽게 바뀌었다.
누가 이 두 개의 독립된 요청을 만들었는가.
이 질문으로 넘어간 순간부터 문제는 네트워크보다 구조에 더 가까워졌다. 같은 거래가 두 번 처리되었다는 현상보다, 그 두 요청이 어디에서 만들어졌는지를 보는 쪽이 훨씬 중요해졌다.
중간에 했던 오판들
이번 이슈를 이해하는 과정에서 나는 몇 번의 오판을 했다. 이 부분도 남겨 두고 싶다. 나중에 비슷한 문제를 다시 만났을 때, 같은 함정에 빠지지 않기 위해서다.
첫 번째 오판은, 같은 인스턴스에서 처리된 것처럼 보이니 요청도 하나였을 것이라고 생각한 점이다. 하지만 같은 인스턴스가 아주 짧은 시간 차이로 서로 다른 두 요청을 받을 수도 있다.
두 번째 오판은, 로그에 한 번 있으니 프로그램도 한 번만 보냈을 것이라고 추론한 점이다. 이 생각은 실행 흐름이 하나뿐인 구조에서는 어느 정도 맞을 수 있다. 하지만 역할이 나뉜 여러 실행 주체가 함께 돌아가는 구조에서는 위험한 추론이다.
세 번째 오판은, 조금만 더 혼자 들여다보면 답이 나올 것이라고 믿었던 점이다. 실제로 결정적인 실마리는 시스템 구조를 잘 아는 동료들과 함께 보면서 나왔다. 이번 경험을 지나고 보니, 오래 붙잡는 끈기도 중요하지만 구조적 맥락이 핵심인 문제는 함께 보는 편이 훨씬 빠르고 정확하다는 생각이 더 강해졌다.
개인적으로는 이 부분이 꽤 크게 남았다. 혼자 오래 보는 태도 자체가 나쁜 것은 아니지만, 어떤 문제는 더 깊이 파는 것보다 더 넓게 보는 것이 먼저라는 사실을 이번에 실감했다.
퍼즐이 맞춰진 순간
결정적인 사실은 처리 역할이 분산되어 있었다는 점이었다. 기본 처리 경로는 분산된 작업 인스턴스가 맡고 있었고, 재시도 경로는 별도의 특정 인스턴스가 맡고 있었다.
이 사실을 이해하고 나니 사건의 그림이 완전히 달라졌다. 이제 문제는 중간 어딘가가 같은 요청을 한 번 더 보냈는가가 아니라, 결제 시스템 안에서 같은 거래를 두 경로가 동시에 접근할 수 있었는가가 된다.
그리고 실제로는 거의 같은 시점에 이런 일이 벌어진 것이다. 본처리 경로가 같은 거래를 정상 처리 대상으로 읽어 왔고, 재시도 경로도 같은 거래를 재시도 대상으로 읽어 왔다. 이 거래가 아직 누구에게도 명확하게 선점되지 않은 상태였다면, 두 경로는 각각 이 거래는 내가 처리해도 된다고 판단할 수 있다.
그 순간부터 외부 시스템에는 같은 거래 키를 가진 요청이 두 건 들어가게 된다.
나는 이 지점에서 꽤 묘한 감각을 느꼈다. 처음에는 그렇게 멀게 느껴졌던 문제가, 구조를 이해하는 순간 너무 단순하게 보였기 때문이다. 그래서 오히려 더 아쉬웠다. 조금만 더 일찍 구조를 먼저 봤더라면, 나는 훨씬 덜 헤맸을지도 모른다.
왜 이것을 동시성 문제라고 보는가
동시성 문제라는 말을 너무 어렵게 이해할 필요는 없다고 느꼈다. 이번 사건에서는 설명이 오히려 단순했다. 같은 대상은 하나의 거래 키였고, 그 대상을 만진 주체는 본처리 경로와 재시도 경로였고, 문제가 된 순간은 누가 먼저 이 거래를 처리하기로 확정했는지가 분명하지 않았던 시점이었다.
즉 이번 사건의 본질은 같은 거래를 동시에 다룰 수 있도록 열어 둔 구조에 있었다.
이 문제는 한 프로그램 안의 여러 실행 흐름에서만 생기는 것도 아니다. 여러 프로세스 사이에서도 생기고, 여러 서버 사이에서도 생기고, 배치와 재시도 로직 사이에서도 생긴다. 이번 사건은 그 사실을 아주 직접적으로 보여 줬다. 그래서 이번에는 동시성이라는 말을 조금 더 현실적인 얼굴로 이해하게 되었다.
이번 주 공부가 이 장면을 다시 보게 만든 방식
이번 주에 내가 공부하고 구현한 내용은 주로 상품 조회 성능과 관련된 것이었다. 그런데 이상하게도, 그 공부는 이 결제 이슈를 이해하는 데도 적지 않은 도움을 줬다.
그 이유는 이번 주에 내가 단순히 쿼리를 고치는 것이 아니라 구조를 보는 연습을 조금 더 했기 때문이다. 이번 주 회고에서 내가 가장 오래 붙잡고 있던 생각은, 읽기 성능 문제는 쿼리 하나만 고친다고 끝나지 않고 읽기 경로 자체를 더 단순하게 만들어야 한다는 것이었다.
이 생각을 이번 이슈에 그대로 옮겨 보면 말이 조금 바뀐다. 중복 처리 문제는 재시도 조건 하나만 바꾼다고 끝나지 않는다. 같은 거래를 읽고 처리하는 구조 자체를 더 단순하고 더 분명하게 만들어야 한다.
이번 주 구현에서 특히 인상적이었던 것도 기술의 이름보다 책임의 위치였다. 캐시를 붙였다는 사실보다, 쓰기 이후 무효화 책임이 어디에 있는지가 코드에 드러난 점이 더 크게 남았다. 좋아요가 바뀌면 LikeFacade가 likesCount를 반영하고 캐시를 지운다. 상품 정보가 바뀌면ProductCommandFacade가 관련 캐시를 지운다. 읽기 경로는 단순해졌고, 그 단순함을 유지하는 책임은 쓰기 경로가 더 많이 가져간다.
이 감각으로 이번 결제 이슈를 다시 보면 질문도 자연스럽게 바뀐다. 누가 읽는가, 무엇을 읽는가, 어디서 처리 권한이 정해지는가, 그 결과를 누가 기록하는가를 먼저 보게 된다.
이번 주를 지나며 내 안에 남은 가장 큰 변화는, 이전보다 현상 그 자체보다 현상을 가능하게 만든 구조를 먼저 보게 되었다는 점이었다. 개인적으로는 이 변화가 꽤 의미 있게 느껴졌다.
왜 응급조치는 필요했지만 충분하지 않았는가
원인을 파악한 뒤 바로 한 일은 재시도 조회 조건에 시간 버퍼를 두는 것이었다. 방금 생성된 거래는 바로 재시도 경로가 읽지 않도록 만든 것이다.
이 조치는 분명 의미가 있었다. 재발 가능성을 빠르게 낮출 수 있었고, 운영 환경에 비교적 빨리 적용할 수 있었고, 장애 직후 위험을 줄이는 현실적인 조치이기도 했다. 장애를 다루는 현실에서는 이런 대응이 꼭 필요하다는 것도 다시 느꼈다.
하지만 시간이 지나고 보니, 이 조치를 근본 해결이라고 부를 수는 없다는 점도 분명했다. 시간 버퍼는 구조를 바꾸는 장치가 아니라 충돌 가능성을 줄이는 장치이기 때문이다. 본처리가 더 오래 걸리면 다시 겹칠 수 있고, 처리 지연이 커지면 다시 재시도 경로가 같은 거래를 집을 수 있다. 결국 문제의 본질인 같은 거래를 여러 경로가 동시에 읽을 수 있다는 구조 자체는 남아 있게 된다.
이 부분에서도 나는 이번 주 회고의 한 문장을 다시 떠올렸다. 잘못 붙인 캐시나 설명되지 않는 만료 시간은 문제를 해결하는 것처럼 보여도, 오히려 구조를 흐릴 수 있다는 문장이었다. 이번 사건에서도 설명되지 않는 시간 버퍼는 응급조치로는 유효하지만, 구조의 답은 아니었다.
그렇다면 무엇이 더 근본에 가까운가
이 질문에 대한 내 생각은 이번 사건을 겪으며 꽤 분명해졌다.
먼저 결제 시스템 안에서 같은 거래를 한 실행 주체만 처리할 수 있게 만들어야 한다. 가장 중요한 것은 선점이다. 아직 처리되지 않은 거래를 읽어 온 뒤 각자 판단하는 방식보다, 실제로 처리 중 상태로 바꾸는 데 성공한 쪽만 외부 호출을 보낼 수 있게 해야 한다. 읽고 나서 판단하는 구조보다, 상태를 바꾸는 데 성공했는지로 처리 권한을 정하는 구조가 더 안전하다.
또 외부 적립 시스템도 같은 거래 키에 대해 결과를 한 번만 인정하도록 만들어야 한다. 현실에서는 같은 요청이 다시 올 수 있다. 재시도 로직이 잘못될 수도 있고, 사람이 수동으로 다시 실행할 수도 있고, 장애 상황에서 같은 요청이 다시 흘러들어올 수도 있다. 그래서 받는 쪽도 같은 거래 키라면 이미 처리된 요청인지 확인할 수 있어야 한다.
여기에 더해 여러 실행 주체가 같은 상태를 함께 볼 수 있어야 한다. 이번 사건의 문제는 본처리 경로와 재시도 경로가 서로 다른 판단을 했다는 데 있었다. 그렇다면 해결도 결국 같은 사실을 함께 보게 만드는 방향으로 가야 한다. 중앙 저장소에 거래 키 기준의 처리 상태를 두고, 이미 처리 중인 거래라면 다른 경로는 멈추게 만드는 방식이 더 자연스럽다.
마지막으로 구조 자체도 더 단순해져야 한다. 본처리는 누가 하는지, 재시도는 누가 하는지, 같은 거래가 두 경로에 동시에 보일 수 있는지, 이미 처리 중이라는 사실을 누가 공유하는지가 복잡할수록 분석도 어려워지고 재발 가능성도 커진다. 이번 사건은 구조의 복잡함이 장애 분석 자체를 어렵게 만든다는 점도 함께 보여 줬다.
이번 주 공부를 이 문제에 섞어 보며 든 생각
이번 주 공부와 구현을 이 이슈에 억지로 끼워 넣고 싶지는 않았다. 다만 자연스럽게 섞이는 지점은 분명 있었다.
인덱스를 공부하면서는, 자주 쓰는 조회 패턴에 맞춰 읽기 경로를 정리하지 않으면 비용이 커진다는 점을 다시 확인했다. 캐시를 공부하면서는, 읽기를 빠르게 만드는 것보다 더 중요한 것이 무엇을 언제 지우고 누가 그 책임을 지는지 분명히 하는 일이라는 점을 더 강하게 느꼈다. likesCount 비정규화를 정리하면서는, 읽기를 단순하게 만든 대신 쓰기 경로가 더 무거운 책임을 가져간다는 사실이 특히 인상적이었다.
이 세 가지를 이번 결제 이슈에 겹쳐 보니 결론도 비슷한 방향으로 모였다. 중복 처리 문제도 결국 같은 거래를 읽는 경로, 처리 상태를 정하는 경로, 처리 후 결과를 기록하는 경로가 분명해야 한다. 읽기를 빠르게 만드는 기술 이름보다, 누가 같은 거래를 동시에 유효하다고 볼 수 있는지부터 정리해야 한다.
이번 주에 내가 가장 크게 느낀 감상은 이것이었다. 기술은 따로따로 존재하는 것처럼 보여도, 결국 계속 같은 질문으로 돌아온다. 무엇을 미리 정리할 것인가, 무엇을 한 번만 결정하게 만들 것인가, 그리고 그 책임을 어디에 둘 것인가 하는 질문 말이다.
이 생각이 개인적으로는 꽤 반가웠다. 공부한 것이 따로 놀지 않고 결국 하나의 시선으로 모이는 느낌이 있었기 때문이다.
이번 사건을 지나며 개인적으로 남은 것들
이번 이슈를 따라가며 개인적으로 남은 감상도 적어 두고 싶다.
하나는, 나는 생각보다 쉽게 눈앞의 로그를 전체로 착각한다는 점이다. 이번에는 그 한계를 꽤 분명하게 봤다. 로그는 분명 중요한 증거이지만, 배경 구조 없이 읽으면 오히려 잘못된 확신을 줄 수도 있다.
또 하나는, 혼자 오래 붙잡고 있는 태도만으로는 해결되지 않는 문제가 분명히 있다는 점이다. 구조를 잘 아는 사람과 함께 보는 것은 단순히 도움을 받는 행위라기보다, 더 정확한 해석을 위해 필요한 과정에 가깝다는 생각이 들었다.
마지막으로는, 좋은 문서는 무엇을 바꿨는가보다 왜 그렇게 판단했는가를 남겨야 한다는 점이 더 크게 남았다. 이번 사건에서 정말 중요했던 것은 재시도 조회 조건에 1분 버퍼를 두었다는 결과 자체가 아니었다. 왜 처음에는 네트워크로 보였는지, 왜 그 생각이 틀렸는지, 어떤 단서가 구조 문제를 드러냈는지, 왜 응급조치와 근본 해결을 구분해야 하는지가 더 오래 남아야 할 내용이었다.
정리하며
이번 이슈를 한 문장으로 줄이면 이렇다.
결제 시스템은 스스로 하나의 요청만 보냈다고 믿기 쉬운 모습이었지만, 실제로는 본처리 경로와 재시도 경로가 같은 거래를 거의 동시에 접근할 수 있는 구조였고, 그 결과 외부 시스템에는 같은 거래 키 요청이 두 번 들어갔다.
나는 이번 사건을 통해 두 가지를 더 분명하게 배우게 되었다. 하나는, 비슷한 문제를 다시 만나면 이제는 누가 두 번 호출했는가보다 먼저 누가 같은 거래를 동시에 접근할 수 있었는가를 보게 될 것 같다는 점이다. 다른 하나는, 이번 주에 공부한 인덱스, 캐시, 비정규화, 무효화 같은 주제들도 결국은 따로 떨어진 기술 목록이 아니라 무엇을 어디서 결정하고 누가 그 책임을 지는가를 더 또렷하게 보는 연습이었다는 점이다.
그래서 이번 주의 배움을 가장 짧게 정리하면 이렇게 말할 수 있을 것 같다.
문제의 이름이 성능이든 중복 처리든, 결국 중요한 것은 더 많은 기술을 덧붙이는 일이 아니라 같은 데이터를 누가 언제 읽고 누가 언제 확정할 수 있는지를 구조적으로 분명하게 만드는 일이라는 점이다.