검색창에 react를 빠르게 친다. 글자마다 자동완성 요청이 나간다. 그런데 화면에는 re로 찾은 결과가 떠 있다. 입력창에는 react가 그대로 있는데 말이다. 새로고침하면 재현되지 않고, 개발할 때 로컬 서버에서는 한 번도 본 적이 없다.
이 버그에는 이름이 있다. 경쟁 조건(race condition)이다. 결과가 작업들이 끝나는 순서에 따라 달라지는 상황을 말한다. JavaScript는 메인 스레드 하나에서 돌지만, await나 .then으로 기다리는 동안 다른 코드가 먼저 실행될 수 있다. 실행 순서가 정해지는 방식은 이벤트 루프 노트에 있다. 스레드가 하나여도 기다림이 있으면 동시성 문제가 생긴다. 이 장은 동시성 문제의 두 가지 모양, 뒤바뀐 응답과 잃어버린 갱신(lost update)을 코드로 따라간다.
경쟁 조건: 늦게 보낸 요청이 먼저 도착한다
요청 세 개가 100ms 간격으로 나간다. 서버가 요청마다 다른 시간을 쓰거나, 네트워크가 요청마다 다른 경로로 지나가면 응답이 돌아오는 시각이 뒤섞인다.
r, re, rea를 0ms, 100ms, 200ms에 보냈다. 응답 지연이 90ms, 300ms, 120ms라면 마지막에 화면에 남는 결과는?
선택지와 해설 보기
실험 응답이 도착한 순서대로 화면을 그리면
아래 실험은 이 상황을 실행할 때마다 같은 결과가 나오는 모델로 만든 것이다. 요청 보냄과 응답 도착을 모두 이벤트로 적고, 일어난 시각 순서로 정렬한 뒤 하나씩 처리한다. 응답이 오면 그 결과로 화면을 바꾼다.
r, re, rea를 100ms 간격으로 한 글자씩 칩니다. 응답이 오면 그 결과로 화면을 바꿉니다. 실제 네트워크를 잰 것이 아니라 요청 보냄과 응답 도착을 일어난 시각 순서로 늘어놓고 하나씩 처리하는 모델입니다. 막대는 요청마다 보낸 시각부터 응답이 온 시각까지이고, 끝의 글자가 그 응답을 어떻게 했는지입니다. 덮어씀은 더 늦게 보낸 요청의 결과를 지운 응답입니다.
- 0ms요청 'r'
- 80ms응답 'r' → 화면에 그림
- 100ms요청 're'
- 180ms응답 're' → 화면에 그림
- 200ms요청 'rea'
- 280ms응답 'rea' → 화면에 그림
입력창 'rea', 화면 'rea'. 맞습니다.
- 해볼 것
- '지연이 고를 때' 화면이 입력창과 맞는지 본다
- '둘째 요청이 느릴 때' 400ms에
re가 '덮어씀'이 되는 것을 본다
'둘째 요청이 느릴 때'를 고르면 예측의 상황이 그대로 재생된다. 상태 줄은 입력창 'rea', 화면 're'다.
rea(200 + 120 = 320ms)가 먼저 도착해 그려지고, 400ms에 도착한 re가 그 위를 덮어 화면에 re가 남는다. 도착하면 그린다는 코드에는 틀린 줄이 없다. 그 규칙이 "도착 순서 = 보낸 순서"를 몰래 가정했을 뿐이다. 지연이 고르면 그 가정이 맞아서 버그가 보이지 않는다. 로컬 서버에서 재현되지 않던 이유가 이것이다.
브라우저는 응답이 보낸 순서대로 온다고 약속하지 않는다. React 문서도 Effect 안에서 데이터를 가져오는 예제에서 같은 문제를 짚는다. 네트워크 응답은 보낸 순서와 다른 순서로 도착할 수 있다(react.dev: Fetching data with Effects).
순번 가드: 가장 최근 요청의 응답만 그린다
고치는 방법은 단순하다. 요청을 보낼 때마다 번호를 붙여 "가장 최근에 보낸 요청"을 기억해 두고, 응답이 오면 자기 번호가 그 번호와 같을 때만 화면을 바꾼다. 이 장에서는 이것을 순번 가드라고 부른다.
let latest = -1function onInput(i: number, q: string) { latest = i // 보낼 때 번호를 적어 두고 search(q).then(items => { if (i !== latest) return // 최신 요청이 아니면 버린다 setItems(items) })}
실험 가장 최근 요청의 응답만 그리기
고정 입력은 r, re, rea 세 글자입니다. 다른 지연으로는 react를 다섯 글자 치고, 응답 지연을 50~450ms 중에서 정해진 규칙(시드 고정 의사난수. 같은 시드면 같은 수열이 나오는 가짜 난수)으로 뽑습니다. 누를 때마다 다음 시드로 넘어갑니다. 실제 네트워크를 잰 것이 아니라 요청 보냄과 응답 도착을 일어난 시각 순서로 늘어놓고 하나씩 처리하는 모델입니다. 막대는 요청마다 보낸 시각부터 응답이 온 시각까지이고, 끝의 글자가 그 응답을 어떻게 했는지입니다. 덮어씀은 더 늦게 보낸 요청의 결과를 지운 응답입니다. 버림은 순번 가드가 버린 응답입니다.
- 0ms요청 'r'
- 100ms요청 're'
- 150ms응답 're' → 화면에 그림
- 200ms요청 'rea'
- 250ms응답 'rea' → 화면에 그림
- 400ms응답 'r' → 화면에 그림
입력창 'rea', 화면 'r'. 어긋났습니다. 먼저 보낸 요청의 응답이 가장 늦게 도착했습니다.
- 해볼 것
- 순번 가드 없이, 입력창과 화면이 어긋나는 지연을 찾아본다
- 어긋난 그 지연 그대로 순번 가드를 켜서 바로잡는다
- '첫 요청이 가장 느릴 때'에서 가드를 켜
r의 응답이 버려지는 것을 본다
'첫 요청이 가장 느릴 때'에서 순번 가드를 켜면 r의 응답은 400ms에야 도착한다. 그때 latest는 이미 2(rea)라서 i !== latest가 참이고, 응답은 버려진다. 화면은 rea 결과로 남는다. 150ms에 도착한 re 결과는 그 시각에 최신이었으므로 그려진다.
순번 가드는 "지금 가장 최근인 요청"만 통과시킨다.
React에서는 이 가드를 Effect의 정리 함수(cleanup)로 짠다. Effect마다 let ignore = false를 두고, 의존 값이 바뀌어 정리 함수가 불리면 ignore = true로 바꾼다. 늦게 온 옛 응답은 자기 Effect의 ignore가 true라서 setState를 건너뛴다(react.dev). 번호를 비교하는 대신 Effect 실행마다 플래그를 따로 두는 것이다.
한발 더 나가 옛 요청을 아예 취소할 수도 있다. AbortController의 abort()는 아직 끝나지 않은 fetch 요청과 응답 본문 읽기를 중단한다(MDN: AbortController). 취소하면 서버와 네트워크의 일도 줄어든다. 다만 서버가 이미 받은 요청을 처리하고 있었다면 그 처리까지 되돌리지는 않는다. 화면을 지키는 것은 여전히 "최신 요청만 그린다"는 규칙이다.
잃어버린 갱신: 읽고, 고치고, 쓰기
두 번째 모양은 값을 고칠 때 나온다. 좋아요 버튼이 이렇게 짜여 있다고 하자. 서버에서 글을 읽어 오고(get), 좋아요 수에 1을 더해 통째로 저장한다(put). 읽기와 쓰기 사이에 await가 있으니, 그 사이에 다른 사용자의 같은 작업이 끼어들 수 있다.
좋아요가 10인 글에서 A와 B가 각자 "읽기 → 1 더해 저장"을 한다. 실행 순서가 A 읽기, B 읽기, A 저장, B 저장이면 최종 좋아요 수는?
선택지와 해설 보기
실험 좋아요 두 번이 한 번으로
작업 A와 B가 각자 좋아요 수를 읽고(read), 1을 더해 저장합니다(write). schedule은 두 작업의 단계가 실제로 실행된 순서입니다. 아래 칸은 schedule을 왼쪽부터 한 칸씩 그렸고, likes 줄은 그 단계 뒤의 저장된 값입니다.
let likes = 10const local: Record<string, number> = { A: 0, B: 0 }const schedule = [['A', 'read'], ['A', 'write'], ['B', 'read'], ['B', 'write']]for (const [task, op] of schedule) { if (op === 'read') { local[task] = likes console.log(`${task}: ${likes} 읽음`) } else { likes = local[task] + 1 console.log(`${task}: ${likes} 저장`) }}console.log(`좋아요 2번, 결과 ${likes}`)
- likes
- 10
- 해볼 것
- '엇갈려서' 입력을 끝까지 실행해 결과가 몇인지 본다
B가 자기가 읽은 10에 1을 더한 11을 저장해 A의 저장을 덮으니 결과는 11이다. 두 작업 모두 자기 눈에는 올바르게 동작했다. 문제는 B가 저장할 때 쓴 값이 "B가 읽었던 때의 값"이라는 데 있다. 그 사이 A가 값을 바꿨는데 B는 모른다. 이것을 잃어버린 갱신이라고 한다. 차례로 실행하면 12가 나오니, 이 버그도 실행 순서가 엇갈릴 때만 드러난다.
여기서 지켜야 할 성질이 원자성(atomicity)이다. "읽고 → 고치고 → 쓰기"(read-modify-write) 세 단계가 다른 작업에는 쪼갤 수 없는 한 단계처럼 보여야 한다. 중간 상태를 다른 작업이 보거나 끼어들 수 없어야 한다.
원자적 연산, 락, 버전
원자성을 얻는 방법은 크게 세 가지다.
하나, 고치는 일을 값이 있는 곳에서 한 번에 한다. 클라이언트가 읽고 더해서 쓰는 대신, 서버나 데이터베이스에 "1 올려라"를 한 번에 맡긴다. SQL로는 UPDATE posts SET likes = likes + 1 WHERE id = $1이다. PostgreSQL의 기본 격리 수준(isolation level)인 Read Committed에서 두 트랜잭션(transaction, 여러 SQL을 한 덩어리로 묶어 전부 되거나 전부 안 되게 하는 단위. 8장)이 같은 행을 동시에 고치면, 나중 쪽은 앞 트랜잭션이 끝나기를 기다린 뒤 갱신된 최신 행에 자기 연산을 적용한다(PostgreSQL 문서: 트랜잭션 격리). 그래서 두 번 모두 반영된다.
둘, 락(lock)으로 한 번에 한 작업만 들어가게 한다. 락은 상호 배제(mutual exclusion), 줄여서 뮤텍스(mutex)라고도 부른다. 한 작업이 락을 잡고 있으면 다른 작업은 풀릴 때까지 기다린다. Python의 asyncio.Lock은 asyncio 작업(task)들 사이에서 공유 자원에 한 작업만 접근하게 해 준다. async with lock: 안에 읽기·고치기·쓰기를 넣는다(Python 문서: asyncio 동기화). 이 락은 한 프로세스 안의 작업끼리만 막는다. 서버 프로세스가 여러 개(여러 대)라면 데이터베이스나 별도의 저장소가 락을 맡아야 한다.
셋, 버전을 비교해 먼저 고친 쪽만 이기게 한다. 이것을 낙관적 동시성 제어(optimistic concurrency control)라고 한다. 충돌이 드물다고 보고 락 없이 진행하되, 저장할 때 "내가 읽은 뒤로 아무도 안 고쳤나?"를 확인한다. 데이터에 버전 번호를 붙이고, 저장 요청에 읽었던 버전을 함께 보낸다. 서버는 버전이 같을 때만 저장하고 버전을 올린다. 다르면 거절하고, 클라이언트는 다시 읽어서 시도한다.
async function likePost(postId: string) { for (let attempt = 0; attempt < 3; attempt++) { const post = await api.get(`/posts/${postId}`) // 서버: version이 같을 때만 저장하고 version을 1 올린다 const ok = await api.put(`/posts/${postId}`, { likes: post.likes + 1, version: post.version }) if (ok) return } throw new Error('다른 사람이 계속 먼저 고쳤다')}
HTTP에는 이 방식이 헤더로 들어 있다. 읽을 때 받은 ETag 값을 PUT 요청의 If-Match 헤더에 담아 보내면, 서버는 그 사이 리소스가 바뀌었을 때 저장하지 않고 412 Precondition Failed를 돌려준다. MDN은 이것을 잃어버린 갱신을 막는 용도로 설명한다(MDN: If-Match). ETag 자체는 ETag 노트에 있다.
함정 찾기 AI가 짠 검색창과 좋아요, 동시에 일어나면 어디가 틀어질까?
“검색어가 바뀔 때마다 결과를 불러오는 검색창, 좋아요를 1 올리는 함수, 주문을 두 번 보내지 않게 막는 저장 함수를 짜 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
확인 문제 장을 덮기 전에 떠올려 보기
1. 자동완성에서 옛 검색어의 결과가 최신 결과를 덮는 원인은?
2. 좋아요 10에서 두 작업이 각자 "읽기 → 1 더해 저장"을 하는데, 둘 다 읽은 뒤에 저장했다. 결과는?
3. 저장할 때 읽었던 버전을 함께 보내 서버가 버전이 같을 때만 저장하게 하는 방식은?
더 풀어 보기(선택) · 동시에 최대 k개만 실행하는 작업 실행기
이미지 업로드 같은 작업이 여러 개 있다. 한꺼번에 다 보내면 서버와 브라우저에 부담이 크니, 동시에 최대 k개만 실행한다. 작업은 주어진 순서대로 시작하고, 실행 중인 작업 하나가 끝나면 그 즉시 다음 작업이 빈자리에 들어간다. 작업마다 걸리는 시간(초)이 durations일 때 작업이 끝나는 순서와 모두 끝나는 시각을 구하는 코드를 짜 보자.
durations = [3, 1, 4, 1, 5], k = 2 → 완료 순서 1, 0, 3, 2, 4, 총 9초