이미지 재생성 전에 마켓으로 전송돼서 오류가 난다는 주장이 있었고 나는 그것을 코드로 반박하려고 조사를 시작했다.
Table of contents
Open Table of contents
배경 — 단일 요청 흐름의 안전
이미지 업로드 부분을 보니 Http::post()로 응답을 기다리는 블로킹 호출이었다. 이미지 서비스가 변환을 끝내고 200으로 응답할 때까지 기다리고 그 서비스도 동기로 답한다.
dispatch()로 전송 작업을 넣는 줄은 그다음에 오므로 한 요청 안에서는 순서가 보장된다. 변환이 끝나야 다음 줄이 실행되니 재생성 전에 전송될 수 없다고 보고 타이밍 창이 없다고 결론을 냈다.
반문이 바꾼 조건
그 결론에 반문이 하나 왔다. 여러 사람이 같은 상품을 동시에 수정해서 보낼 때는 발생할 수 있지 않겠느냐는 것이었다.
내가 생각하지 못한 조건이었다. 두 사용자가 같은 product_id를 동시에 편집하면 서로 다른 요청이므로 한쪽의 블로킹이 다른 쪽을 막지 않는다. 한쪽 변환이 다른 쪽 변환에 덮이거나 한쪽이 다른 쪽의 중간 상태를 보고 전송할 수 있다.
원인 — 락 부재와 워커의 무확인
그러면 그것을 막는 장치가 있는지를 확인해야 했다. 상품과 전송 관련 서비스 전체에서 Cache::lock과 lockForUpdate를 grep -rn으로 검색하니 0건이었고 product_id 단위 동시성 락이 하나도 없었다.
전송 워커도 함께 봤는데 Storage::exists() 없이 경로 문자열만 구성해서 보내고 있었다. 변환이 안 끝났으면 그 경로에 파일이 없고 마켓이 가져가려다 404로 실패한다. 처음 결론과 정반대로 그 주장은 다중 요청 조건에서 맞을 수 있었다.
처음 결론을 그대로 전달했으면 버튼 잠금 요구가 불필요하다는 부정확한 답을 줬을 것이다. 그 주장을 한 쪽은 실제로 증상을 봤을 것이고 근거 없이 말한 것이 아니었다.
판단 기준 — 두 차원의 구분
이 건에서 얻은 것이 가장 큰 지점이 여기다. 코드 조사에서 단일 요청 흐름과 다중 요청 동시성을 별개 차원으로 반드시 구분해야 한다는 것이다.
단일 요청에서는 순서가 보장되는지를 보고 다중 요청에서는 같은 product_id에 대한 동시 접근을 막는지를 본다. 앞의 것이 안전해도 뒤의 것은 별개이며 동기 호출은 그 요청 안의 순서만 보장할 뿐 다른 요청과의 관계는 아무것도 보장하지 않는다.
놓치기 쉬운 이유도 분명했다. 코드를 읽으면 한 흐름을 따라가게 되고 그 순서가 눈에 보이면 됐다고 느낀다. 다른 요청이 동시에 같은 흐름을 타는 것은 코드에 안 보이므로 상상해야 하는데 lock을 grep하는 일이 그 상상을 대신해 준다.
교훈 — 반박하려는 조사의 편향
해결에는 두 가지 방법이 있었는데 같은 상품에 대한 수정과 전송을 Cache::lock으로 직렬화하는 것과 전송 직전에 Storage::exists()로 결과물이 실제로 있는지 확인하는 게이트다. 뒤쪽을 골랐다. 이유는 락은 성능 부담과 데드락 위험이 따라오고 게이트는 한 줄이라서다. 대신 준비 안 된 상품의 전송이 실패로 남아 재시도에 맡겨지는 것을 감수했다.
이 조사가 반박에서 시작했다는 점도 기록해 둘 만하다. 반박하려고 조사하면 반박 근거만 찾게 되고 그것을 찾는 순간 멈춘다. 내가 처음 찾은 것이 Http::post()의 동기 보장이었고 그것으로 반박에 충분했으므로 거기서 끝냈다. 반문이 없었으면 틀린 결론이 그대로 나갔을 것이고, 주장을 검증할 때는 그 주장이 맞는 조건도 같이 찾아야 한다.
정리
- 단일 요청의 순서 보장과 다중 요청의 동시성은 별개 차원이다
Http::post()동기 호출은 그 요청 안의 순서만 보장한다- 다중 접근을 막는지는
lock검색으로 확인한다 - 워커가
Storage::exists()로 결과물 존재를 확인 안 하면 준비 안 된 것을 보낸다 - 반박하려고 조사하면 반박 근거만 찾는다
- 주장을 검증할 때 그 주장이 맞는 조건도 같이 찾는다
- 해결은
Cache::lock과 전송 직전 게이트 둘이고 후자가 가볍다 - 증상을 봤다는 주장은 근거 없이 나온 것이 아니다