Skip to content
isdnetworks
Go back

단일 요청과 동시성이라는 두 축

(수정됨)

이미지 재생성 전에 마켓으로 전송돼서 오류가 난다는 주장이 있었고 나는 그것을 코드로 반박하려고 조사를 시작했다.

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()의 동기 보장이었고 그것으로 반박에 충분했으므로 거기서 끝냈다. 반문이 없었으면 틀린 결론이 그대로 나갔을 것이고, 주장을 검증할 때는 그 주장이 맞는 조건도 같이 찾아야 한다.

정리


Share this post on:

Previous Post
프로젝트마다 다른 방식과 표준
Next Post
읽기 패턴 재사용으로는 안 된다