🔖Contents
🎯학습 목표
1. 모임 참가 신청에서 초과 참석(overbooking) 이 왜 발생하는지(레이스 컨디션) 설명할 수 있다.
2. “정원 체크 + 참석 처리”를 직렬화(serialization) 해야 하는 이유를 설명할 수 있다.
3. Pessimistic Lock(`SELECT ... FOR UPDATE`) 으로 groupId 단위 동시성을 제어하는 설계를 설명할 수 있다.
4. FREE, APPROVAL_REQUIRED 정책에서 정원 체크 시점이 달라지고, attend, approve, left에 락이 필요한 이유를 설명할 수 있다.
모임 서비스에서 “참여하기”는 가장 빈번하고, 동시에 가장 민감한 API 중 하나입니다. 특히 참여 정책이 FREE(즉시 참석) 인 모임은 사용자가 버튼을 누르는 순간 곧바로 ATTEND 상태로 바뀌기 때문에, 동시에 여러 요청이 들어오면 정원 제한이 깨질 가능성이 존재합니다. 제가 테스트 중에 겪었던 실제 문제이면서 흔하게 발생될 여지가 있는 문제는 딱 이겁니다. 모임 최대 인원은 12명인데, 동시에 참여 요청이 몰리면 13명 이상이 ATTEND 되는 초과 참석(overbooking) 이 발생할 수 있습니다.
이 문제는 단순히 “count를 체크하면 되지 않나?” 수준에서 끝나지 않습니다. 왜냐하면 “count 체크” 자체가 여러 요청에서 동시에 실행되면, 그 순간부터는 시간 차가 아니라 ‘트랜잭션 경합’ 문제가 되기 때문입니다. 이번 편에서는 구현 얘기보다 먼저, 왜 이런 일이 발생하는지 를 제대로 정리해보겠습니다.
1. 동시성 문제는 왜 ‘정원 체크’에서 가장 자주 터질까?
스프링 부트 애플리케이션은 멀티 쓰레드(Multi-Thread) 방식으로 동작합니다. 여러 요청이 들어오면 각각의 쓰레드가 독립적으로 일을 처리하죠. 하지만 이 쓰레드들이 공유 자원(DB 데이터, 전역 변수 등)에 접근할 때 문제가 생깁니다. 이 사실을 기억하고, 정원 체크 로직을 살펴보겠습니다.

겉으로 보기엔 완벽합니다. 하지만 이 로직은 두 개 이상의 모임 참여 요청이 같은 시점에 들어오면 깨질 수 있습니다. 핵심은 이 구간이 사실상 검증(check) + 변경(write) 이 결합된 “임계 구간(critical section)”이라는 점입니다.
- 검증: 지금 모임 인원이 꽉 찼나?
- 변경: 참석자를 하나 늘린다
이 두 작업은 “같은 기준”을 공유해야 의미가 있습니다. 그런데 DB에서 일반적인 조회는 “남들이 지금 바꾸고 있는 중인지”를 고려하지 않습니다. 그래서 동시에 들어온 요청들이 각자 같은 count를 읽고, 각자 통과해버리는 문제가 생깁니다. 예를 들어 다음과 같이 동시성 문제를 가장 단순하게 표현하면 아래 시나리오입니다.

동시성 문제를 가장 단순하게 표현하면 아래 시나리오입니다.
- 현재 참석자 수: 11명
- 정원: 12명
- 요청 A, 요청 B가 거의 동시에 들어옴
두 요청이 다음처럼 실행될 수 있습니다.
- 요청 A:
count = 11조회 → 통과 - 요청 B:
count = 11조회 → 통과 - 요청 A: 참석 저장 → 12명
- 요청 B: 참석 저장 → 13명(문제 발생)
여기서 중요한 건 “A가 먼저 들어왔으니 A만 성공해야 한다” 같은 직관이 동시성 환경에서는 보장되지 않는다는 점입니다. 정말 특별한 경우가 아니라면 서버는 거의 대부분 멀티스레드로 동작하고, DB도 동시에 여러 트랜잭션을 처리합니다. 따라서 실행 순서는 우리가 생각하는 ‘시간 순서’와 다를 수 있고, 실제로는 “겹친다(overlap)”가 기본입니다. 이게 바로 경쟁 조건(race condition)이고, 정원 체크는 대표적인 레이스 조건 발생 지점입니다.
- 동시성 문제: 둘 이상의 프로세스나 스레드가 공유 자원(메모리, 변수, 파일 등)에 동시에 접근하여 발생하는 문제 상황 전체를 의미
- 레이스 컨디션: 여러 스레드가 공유 자원에 접근할 때, 실행 순서나 타이밍에 따라 결과가 달라지는 현상
1.1 if문으로 막는다면?
많이 하는 오해가 있습니다.
- “그럼 synchronized 걸면 되지 않나요?”
- “한 서버 안에서는 막을 수 있지 않나요?”
여기서 짚고 넘어가야 할 현실이 있습니다. 서버 인스턴스가 1대면 synchronized로 “프로세스 내부”는 잠글 수 있습니다. 지금 모놀리식 구조로 충분히 synchronized 만으로 해결할 수 있는 상황입니다. 하지만 서버가 2대 이상이면? 인스턴스 A와 B는 서로를 모릅니다. 그리고 대부분의 서비스는 결국 scale-out을 고려해야 합니다. 따라서 “정원 제한” 같은 규칙은 애플리케이션 메모리에서 보장할 수 있는 성질이 아닙니다. 정원 제한은 데이터 자체의 정합성이므로, 결국 단일 진실의 근원(SSoT, Single Source of Truth) 인 DB 트랜잭션에서 보장되어야 합니다. 그래서 이 문제는 자연스럽게 다음 질문으로 이어집니다. “DB 트랜잭션 관점에서 이 문제를 어떻게 막을 것인가?”
1.2 트랜잭션 격리 수준과 이 문제의 연결점
정원 체크가 깨지는 이유를 요약하면, 한 트랜잭션이 읽은 값(count=11)이 다른 트랜잭션의 쓰기 작업에 의해 “내가 쓰기 전에” 이미 의미가 바뀌어 버릴 수 있다. 결국, 읽기(read)와 쓰기(write)가 원자적(atomic)으로 묶이지 않았기 때문입니다. 격리 수준(Isolation level)은 바로 이런 상황에서 “읽기와 쓰기의 상호 간섭을 어느 정도 허용할지”를 정하는 정책입니다. 다만 여기서 중요한 포인트는 단순히 격리 수준을 올린다고 해결되는 게 아니라 “정원 체크 구간을 직렬화(serialization)할 수 있느냐”가 핵심이라는 점입니다. 결국 우리가 원하는 성질은 이것입니다. 같은 groupId에 대한 ‘정원 체크 + 참석 처리’는 한 번에 한 트랜잭션만 실행되어야 한다. 이걸 보장하려면 DB가 제공하는 락(lock) 개념으로 들어가야 합니다.
동시성 제어에서 직렬화(serialization)란 여러 개의 트랜잭션이 동시에(Parallel) 실행되더라도, 그 결과가 마치 하나씩 순서대로(Serial) 실행된 것과 같아야 한다는 성질을 말합니다. 예를 들어 "정원 체크 + 참석 처리"라는 로직을 여러 명이 동시에 달려들지 못하게 막고, 한 번에 딱 한 명씩만 통과시키는 터널처럼 만드는 것을 "직렬화한다"고 표현합니다.
- 동시 실행 (Parallel): A와 B가 동시에 정원을 체크하고 예약 버튼을 누름
- 직렬 실행 (Serial): A가 예약 완료 후 나간 뒤에, B가 들어와서 남은 정원을 확인하고 예약
2. 락(Lock)으로 레이스 컨디션 해결
앞에서 정리한 것처럼, 우리가 원하는 건 단순합니다. 같은 groupId에 대한 “정원 체크 + 참석 처리” 구간은 한 번에 한 트랜잭션만 실행되게 만들고 싶다는 것입니다. 한마디로 동시에 들어오는 참여 요청들이 “겹치지 않도록” 만들어야 합니다. 가장 전통적이고 확실한 방법이 바로 Lock(잠금) 입니다. 락을 아주 직관적으로 표현하면, DB에게 “지금 이 모임 데이터는 내가 처리하는 동안 다른 트랜잭션이 건드리지 못하게 막아줘.” 말하는 것과 같습니다. 여기서 중요한 건 “락은 코드 레벨의 if문이 아니라, DB가 제공하는 동시성 제어 장치”라는 점입니다. 핵심은 애플리케이션이 아니라 DB가 책임지고 순서를 보장해주는 방법입니다.
2.1 락이 해결하려는 핵심: ‘검증과 변경’을 하나로 묶기
정원 초과가 발생하는 근본 원인은 딱 하나였습니다. A 트랜잭션이 count=11을 읽고, B 트랜잭션도 count=11을 읽고, 둘 다 통과해버리는 것 입니다. 바로 “검증(check)”이 여러 트랜잭션에서 동시에 수행되는 순간, 검증의 의미가 깨집니다. 그래서 락의 목적은 단순합니다. 검증과 변경이 수행되는 구간 전체를 임계 구간(critical section)으로 만들고, 그 구간에는 한 번에 한 트랜잭션만 들어오게 한다! 이렇게 되면 흐름이 바뀝니다.

- 요청 A가 터널에 들어가서 정원 체크 + 참석 처리 + 저장까지 하고 나옵니다.
- 요청 B는 그동안 “대기(wait)”합니다.
- A가 나오면 그때 B가 들어가서 “최신 상태”를 기준으로 다시 정원 체크를 수행합니다.
즉, B는 더 이상 count=11을 못 읽습니다. A가 처리한 후의 값(count=12)을 읽게 됩니다.
그래서 B는 자연스럽게 “정원 초과”로 실패합니다.
2.2 락의 종류: 낙관적 락 vs 비관적 락
동시성 제어를 이야기하면 락은 보통 낙관적 락과 비관적 락 두 가지 흐름으로 분기됩니다.
1. 낙관적 락(Optimistic Lock)
낙관적 락은 말 그대로 “충돌이 자주 일어나지 않을 것”이라고 낙관하는 방식입니다.
- 일단 락을 걸지 않고 실행합니다.
- 저장하는 순간에 버전(version)이나 조건을 확인합니다.
- 누군가 먼저 수정해버렸다면 그때 실패(충돌 감지)하고 롤백합니다.
- 실패한 쪽은 다시 시도(retry)합니다.
즉, 충돌을 ‘사전에 막는’ 게 아니라 ‘나중에 감지’ 하는 방식입니다. 이 방식은 충돌이 드물 때 효율적입니다. 하지만 “모임 참여 버튼”은 현실적으로 충돌이 꽤 빈번하게 발생할 수 있는 영역입니다. 특히 정원 막바지(예: 11, 12명) 상황에서는 “충돌이 매우 자주 발생”합니다.
그리고 낙관적 락을 쓰면 서비스 로직은 다음 부담을 떠안습니다.
- 실패한 요청을 재시도할지?
- 몇 번까지 재시도할지?
- 재시도 중에 동일 사용자의 상태가 바뀌면 어떻게 할지?
- 재시도로 인해 응답 시간이 길어지는 것을 어떻게 감당할지?
낙관적 락은 “충돌이 잦은 구간”에서는 오히려 운영 난이도가 크게 올라갑니다.
2. 비관적 락(Pessimistic Lock)
비관적 락은 반대입니다. “충돌이 자주 일어날 것”이라고 비관하고 시작합니다.
- 특정 데이터에 접근하기 전에 먼저 락을 잡습니다.
- 락을 잡은 트랜잭션만 임계 구간을 실행할 수 있습니다.
- 다른 요청은 락이 풀릴 때까지 대기합니다.
따라서 충돌을 아예 “사전에 막아버리는 방식”입니다. “정원 체크 + 참석 처리”는 정합성이 깨지면 서비스 룰 자체가 무너지는 영역이고, 정원 막바지에서는 충돌이 꽤 빈번할 수 있습니다. 그래서 이 상황에서는 비관적 락이 훨씬 직관적이고 안전한 선택이 됩니다.
2.3 그런데 ‘무엇을’ 락 걸어야 할까?
여기서 한 번 더 중요한 질문이 나옵니다. “정원 체크는 GroupUserV2(참석 테이블)를 count로 조회하는데, 락은 어디에 걸어야 하지?” 정원 체크는 결국 GroupUserV2를 count해서 판단하지만, “임계 구간을 직렬화해야 하는 기준”은 모임(groupId) 입니다.결국 같은 모임에 대한 참여 요청끼리만 서로 경쟁합니다. 다른 모임끼리는 경쟁하면 안 됩니다. 따라서 락은 “groupId 단위로 직렬화” 되어야 합니다. 이 지점에서 가장 깔끔한 선택이 바로, GroupV2(모임) row 자체를 잠궈서, 해당 모임에 대한 참여 요청 흐름을 한 줄로 세운다는 설계입니다. 이 방식의 장점은 명확합니다.
- “A 모임” 참여 요청은 A 모임 row를 기준으로 직렬화
- “B 모임” 참여 요청은 B 모임 row를 기준으로 직렬화
- 서로 다른 groupId는 서로 영향을 주지 않음
lock granularity(락 단위)가 정확히 우리가 원하는 단위와 일치합니다.
3. Pessimistic Lock의 핵심 개념: SELECT ... FOR UPDATE
비관적 락의 대표적인 구현 방식이 바로 SELECT ... FOR UPDATE 입니다. 이 쿼리는 단순한 조회가 아닙니다. “조회하면서” “해당 row를 쓰기 잠금(Write Lock)으로 점유”합니다. 그래서 같은 row에 대해 다른 트랜잭션이 접근하려고 하면, 보통은 아래 중 하나로 동작합니다.
- 대기(wait)
- 타임아웃(timeout)
- 데드락 상황이면 롤백
FOR UPDATE는 DB 레벨에서 임계 구간에 대한 ‘입장권’을 하나만 발급하는 느낌입니다.
3.1 락이 걸리면 실제로 어떤 일이 벌어질까?
GroupV2 row를 FOR UPDATE로 잡았다고 가정해보겠습니다.
- 요청 A가 groupId=10에 대해
FOR UPDATE로 row를 잡습니다. - 요청 B도 같은 groupId=10에 대해 들어옵니다.
이때 요청 B는 “바로 count를 읽는 것”조차 못 하게 됩니다. 왜냐하면 B는 “임계 구간”에 들어가기 전에 이미 입구에서 막히기 때문입니다. 결과적으로 같은 groupId 요청은 DB에서 자동으로 줄을 서게 되고, A가 commit, rollback 해서 락을 풀어야 B가 들어옵니다. 그리고 그 순간 B는 이미 A가 반영한 최신 상태(ATTEND 증가, status 변경 등)를 기반으로 로직을 수행합니다. 덕분에 정원 체크의 핵심이었던 “같은 count를 동시에 읽는 상황” 자체가 사라집니다.
4. “정원 체크 구간”을 어떻게 직렬화 기준
앞에서 정한 목표는 같은 groupId에 대한 ‘정원 체크 + 참석 처리’는 반드시 한 번에 하나의 트랜잭션만 실행되어야 한다 입니다.
이 목표를 만족시키려면 다음 조건이 필요합니다.
- 같은 모임(groupId)에 대한 요청만 서로 영향을 주고
- 다른 모임의 요청과는 독립적으로 처리되며
- DB 트랜잭션 수준에서 순서가 보장되어야 한다
즉, 락의 범위(lock granularity)가 정확히 groupId 단위여야 합니다. 여기서 중요한 선택 포인트가 하나 나옵니다. “참석자 수는 GroupUserV2 테이블에 있는데, 왜 GroupV2(모임) 엔티티를 잠그는가?!” 정원 체크는 실제로는 다음 쿼리로 수행됩니다.

GroupUserV2에서status = ATTEND인 row 개수 count
하지만 락을 걸 기준은 “count 대상 테이블”이 아니라 “동시성 제어의 기준이 되는 개념”이어야 합니다.
모임 참여 서비스에서 동시성 제어의 기준은 명확합니다.
- 참석 요청은 “모임 단위”로 경쟁한다
- 같은 모임이면 서로 경쟁
- 다른 모임이면 서로 독립
따라서 경쟁의 단위는 GroupUser가 아니라 Group입니다. 만약 GroupUserV2 쪽에 락을 걸면 어떤 문제가 생길까요?
- count 쿼리는 여러 row를 스캔함
- 아직 생성되지 않은 row(새 참석자)는 잠글 대상이 없음
- “다음에 들어올 row”를 미리 잠글 수 없음
결과적으로 “count + insert” 조합은 row-level lock만으로는 완전히 보호하기 어렵습니다. 반면 GroupV2는 어떨까요?
- 모임은 항상 하나의 row로 존재
- 모든 참석, 취소, 승인은 결국 “이 모임의 상태”를 바꾼다
- 이 row 하나만 잠그면, 해당 모임에 대한 모든 행동을 직렬화 가능
그래서 이 구조에서는 GroupV2 row를 임계 구간의 기준점 으로 삼는 것이 가장 안정적인 선택이 됩니다.
4.1 모임을 잠근다는 의미
SELECT ... FOR UPDATE 로 GroupV2 row를 가져온다는 것은, DB에게 “이 모임에 대한 상태 변경은 내가 끝날 때까지 아무도 못 하게 막아줘.” 요청하는 것입니다. 여기서 말하는 “상태 변경”은 단순히 GroupV2.status만이 아닙니다.
- 참석자 수 증가
- 참석자 수 감소
- FULL ↔ RECRUITING 상태 전환
- 승인, 강퇴, 차단 등 참석 상태 변경
이 모든 흐름이 같은 groupId를 기준으로 직렬화됩니다. GroupV2 row는 이 모임에 대한 모든 참여 관련 트랜잭션의 ‘입구 게이트’ 역할을 하게 됩니다. 구체적으로, FREE(즉시 참석) 정책에서의 흐름을 다시 정리해보면 다음과 같습니다.
- 트랜잭션 시작
GroupV2를FOR UPDATE로 조회 → 락 획득- 현재 참석자 수(count) 조회
- 정원 체크
- 참석 처리(ATTEND)
- 필요 시 상태 변경(RECRUITING → FULL)
- 트랜잭션 종료 → 락 해제
이 흐름에서 중요한 점은 딱 하나입니다. 정원 체크는 항상 락을 잡은 이후에 수행된다.
그래서 어떤 요청도 “과거의 count”를 기준으로 판단할 수 없게 됩니다.
4.2 Pessimistic Lock 적용 구조
겉보기에는 단순한 findById 와 거의 같습니다. 하지만 이 쿼리는 의미적으로 완전히 다른 요청입니다.

일반 조회 (findById)
- 그냥 데이터를 읽는다
- 다른 트랜잭션과 상관없이 동시에 실행 가능
PESSIMISTIC_WRITE
- 데이터를 읽으면서, 해당 row에 쓰기 락을 건다
- 같은 row에 대해 다른 트랜잭션은 대기 상태로 들어감
따라서 PESSIMISTIC_WRITE 메서드를 호출하는 순간부터 “이 모임에 대한 동시 처리”는 DB가 직접 제어합니다. 트랜잭션 관점에서 실제로 일어나는 일을 attend() 메서드를 기준으로 트랜잭션 흐름을 다시 그려보겠습니다.

여기서 중요한 점은 요청 B는 “count 조회”조차 락을 잡기 전에는 실행되지 않는다. 에요.
정리하자면, “같은 count를 동시에 읽는 상황”이 구조적으로 제거됩니다.
4.3 count 쿼리는 락이 없어도 안전할까?
이쯤 되면 이런 의문이 생길 수 있습니다.“count 쿼리에는 락을 안 거는데, 괜찮은가?” 결론부터 말하면 괜찮습니다.
이유는 정말 단순합니다. count 쿼리는 GroupUserV2를 조회하지만 그 쿼리를 실행하는 시점에는 이미 GroupV2 row에 대한 락을 획득한 상태이기 때문입니다. 즉, count 자체를 잠그는 게 아니라 count의 기준이 되는 ‘모임 상태 변화’를 잠그는 것입니다. 이렇게 되면 다음이 보장됩니다.
- 다른 트랜잭션은 같은 모임에 대해 참석/취소/승인을 수행할 수 없음
- 따라서 count 결과는 “이 트랜잭션이 끝날 때까지” 안정적
이게 바로 GroupV2 row를 임계 구간의 기준점을 잠그는 전략의 핵심입니다.
4.4 비관적 락의 단점
안타깝게도, 비관적 락은 만능이 아닙니다. 대표적인 단점은 대기 시간 증가입니다. 동시에 많은 요청이 들어오면 뒤에 온 요청은 DB에서 대기합니다. 하지만 모임 서비스의 특성상, 한 모임에 동시에 수십, 수백 명이 몰리는 경우는 극히 드문 상황입니다. 설령 몰리더라도 “정원 막바지”에서만 발생하고, 이 구간에서 정합성 > 응답 속도라는 합리적인 판단을 했습니다. 그리고 데드락 가능성은 현재 구조에서는 매우 낮습니다. 항상 같은 순서로 단 하나의 row(GroupV2)만 잠그기 떄문에 중첩 락 없습니다. 따라서 락 순서가 단순하고 일관적입니다.
데드락이란, 여러 트랜잭션이 서로가 점유하고 있는 리소스(데이터)의 잠금 해제를 무한정 기다리면서 아무 작업도 완료하지 못하는 상태를 말합니다. 즉, 트랜잭션 A가 리소스 X를 잠그고 리소스 Y를 기다리고, 동시에 트랜잭션 B가 리소스 Y를 잠그고 리소스 X를 기다리는 것처럼, 자원 요청이 얽혀 멈춰버리는 현상입니다.
5. APPROVAL_REQUIRED 정책에서도 락이 필요한 이유
여기서 한 가지 더 짚고 넘어가야 할 부분이 있습니다. “승인제 모임은 즉시 참석이 아닌데, 굳이 락까지 걸 필요가 있나?” 결론부터 말하면 있습니다. 다만 락이 필요한 시점이 다를 뿐입니다.
FREE 정책
- 참석 요청 시점에 정원 체크
- 따라서
attend()에서 락 필요
APPROVAL_REQUIRED 정책
- 신청(PENDING) 단계에서는 정원과 무관
- 승인(approve) 시점에 정원 체크
- 따라서
approve()에서 락 필요
즉, 두 정책의 차이는 “락이 필요하냐, 아니냐”가 아니라 “정원 체크가 어느 시점에서 발생하느냐” 입니다. 그래서 실제 코드에서도,
attend()→findByIdForUpdate()approve()→findByIdForUpdate()left(),kick(),ban()모두 동일하게findByIdForUpdate()
처럼 모임의 참석 인원이 변할 수 있는 모든 지점에서 동일한 락 전략을 사용합니다.
한 마디로 “정원 제한은 애플리케이션 로직이 아니라, DB 트랜잭션이 보장해야 할 규칙이다.” 입니다.






5.1 왜 leave(나가기)도 같은 락 전략이 필요한가?
처음 보면 이렇게 생각할 수 있습니다. “나가기는 인원을 줄이는 거니까, 초과 참석 문제랑 상관없지 않나?”

하지만 이 역시 같은 임계 구간 문제입니다. 한쪽에서는 attend()가 들어오고, 다른 쪽에서는 left()가 동시에 들어오면 FULL ↔ RECRUITING 상태 전환이 엇갈릴 수 있습니다. 따라서 참석 증가와 감소가 동시에 발생할 수 있는 구간 역시 “모임 상태 + 참석자 수”라는 동일한 공유 자원을 다룹니다. 그래서 이 구현에서는 단순히 “참석만 락”이 아니라, 모임의 참석 인원에 영향을 주는 모든 행위를 동일한 직렬화 규칙 안에 넣는다 라는 방향을 선택했습니다.
'💭Retrospective' 카테고리의 다른 글
| 이미지 로딩 속도와 크기 70% 단축: WebP 변환부터 Redis 프리업로드로 URL을 보증하기 (0) | 2026.01.10 |
|---|---|
| 서평단: 그림으로 이해하는 도커와 쿠버네티스 (0) | 2026.01.09 |
| 서평단: Do it! HTML+CSS 웹 표준의 정석 탄탄한 웹 기본기를 위한 교과서 | 개정판 3 판 후기 (0) | 2025.12.17 |
| 모임 목록 조회 API 트러블슈팅: 커서 기반 페이징과 N+1을 설계 구조로 해결하기 (0) | 2025.12.16 |
| 마틴 파울러가 소개하는 소프트웨어 아키텍처 (1) | 2025.11.25 |