[동시성 제어] 회의실 예약 시스템에서 비관적 락 대신 네임드 락을 선택한 이유

Category
Backend
Tags
Concurrency
Transaction
RaceCondition
Lock
Published
January 14, 2026
Last updated
Last updated March 13, 2026
회의실 예약 서비스를 만든다고 하면, 보통은 검색 속도나 UI부터 떠올리기 쉽다.
그런데 실제로 가장 먼저 지켜야 하는 건 훨씬 단순하다.
바로 같은 시간에 같은 회의실이 두 번 예약되지 않는 것, 즉 데이터 정합성이다.
문제는 이 요구사항이 생각보다 만만하지 않다는 점이다.
사용자가 한 명씩만 요청하는 환경이라면 어렵지 않다. 하지만 실제 서비스에서는 여러 사용자가 거의 동시에 같은 회의실을 예약하려고 들어온다. 그 순간부터 이 문제는 단순한 CRUD가 아니라 동시성 제어의 영역으로 넘어간다.
이번 글에서는 회의실 예약 시스템을 설계하면서 왜 일반적인 비관적 락보다 네임드 락(Named Lock) 이 더 잘 맞는 선택지였는지 정리해보려 한다.

1. 가장 단순한 구현은 왜 깨질까?

처음엔 보통 이렇게 구현한다.
  1. 해당 시간대에 예약이 이미 있는지 조회한다.
  1. 없다면 예약을 저장한다.
말로만 보면 전혀 문제 없어 보인다.
SELECT COUNT(*) FROM reservation WHERE room_id = 101 AND start_time < :endTime AND end_time > :startTime; -- 결과가 0이면 INSERT
하지만 동시 요청이 들어오면 이야기가 달라진다.
예를 들어 User A와 User B가 거의 같은 순간에 예약을 시도했다고 해보자.
  • A가 조회한다 → 예약 없음
  • B가 조회한다 → 예약 없음
  • A가 INSERT 한다
  • B도 INSERT 한다
결과는 이중 예약(Double Booking) 이다.
이건 전형적인 Race Condition 이다.
즉, “조회 후 저장” 자체는 맞는 로직이지만, 그 사이에 다른 요청이 끼어들 수 있다는 점을 고려하지 못한 것이다.

2. 그럼 비관적 락을 쓰면 되지 않을까?

동시성 문제가 나오면 가장 먼저 떠오르는 방법이 보통 비관적 락(Pessimistic Lock) 이다.
대표적으로 SELECT ... FOR UPDATE 를 사용해, 조회한 데이터를 다른 트랜잭션이 건드리지 못하게 만드는 방식이다. 은행 계좌 잔액처럼 기존 데이터를 수정하는 문제에서는 아주 잘 맞는다.
예를 들어 이런 경우다.
  • 계좌 row가 이미 존재한다.
  • SELECT ... FOR UPDATE 로 해당 row를 잠근다.
  • 잔액을 수정한다.
문제는 회의실 예약은 조금 다르다.
예약은 대개 기존 row를 수정하는 작업이 아니라, 새로운 row를 추가하는 작업이다.
즉, 예약이 없는 시간대라면 아예 이런 상태다.
  • 조회 결과가 없음
  • 잠글 row도 없음
  • 결국 아무 것도 잠그지 못한 채 다음 단계로 넘어감
이게 회의실 예약에서 비관적 락이 애매해지는 이유다.
락을 걸고 싶어도, 아직 존재하지 않는 예약 row에는 락을 걸 수 없다.
결국 이 문제는 단순한 row lock 하나로 끝나지 않는다.
“없는 데이터를 어떻게 막을 것인가?”라는 질문이 남는다.

3. 낙관적 락은 정합성보다 UX에서 아쉽다

그렇다면 낙관적 락(Optimistic Lock) 은 어떨까?
버전 컬럼을 두고 충돌 시점에 실패시키는 방식은 분명 유효하다.
충돌을 감지하고 잘 막아낼 수 있으니 데이터 정합성 자체는 지킬 수 있다.
다만 예약 서비스에서는 사용자 경험이 썩 좋지 않다.
예를 들어 두 사용자가 동시에 예약 버튼을 눌렀을 때,
  • A는 성공한다
  • B는 마지막 단계에서 실패한다
  • B는 “다시 시도해주세요”를 본다
이 흐름은 시스템 입장에선 맞지만, 사용자 입장에선 꽤 불친절하다.
특히 일반적인 예약 서비스라면 사용자는 “먼저 들어온 요청부터 순서대로 처리되겠지”라고 기대하는 경우가 많다.
즉, 여기서 필요한 건 단순히 충돌을 감지하는 방식이 아니라,
애초에 같은 자원에 대한 요청을 줄 세워서 순차 처리하는 방식에 가깝다.

4. 관점을 바꾸면 해답이 보인다

여기서 한 번 생각을 바꿔볼 필요가 있다.
우리가 정말 잠그고 싶은 건 “reservation 테이블의 특정 row”일까?
사실 더 정확히 말하면 우리가 보호하고 싶은 대상은 회의실 자체다.
예약 row가 아직 없어서 잠글 수 없다면, “Room_101”이라는 자원 자체를 잠그면 되지 않을까?
이 관점에서 잘 맞는 것이 바로 네임드 락(Named Lock) 이다.
MySQL에서는 문자열 기반의 사용자 레벨 락을 제공한다.
SELECT GET_LOCK('ROOM_101', 10);
이 락은 특정 row에 거는 락이 아니라, 지정한 이름의 문자열 자원에 대해 획득하는 락이다.
즉, 같은 회의실에 대한 예약 요청이 동시에 들어오면 흐름이 이렇게 된다.
  • User A: ROOM_101 락 획득 성공
  • User B: ROOM_101 락 획득 시도 → 대기
  • A가 검증하고 저장한 뒤 락 해제
  • 그다음 B가 락을 획득하고 같은 검증 로직 수행
이 구조에서는 회의실 단위의 순차 처리가 보장된다.

5. 네임드 락을 적용한 예약 흐름

네임드 락을 적용하면 예약 로직은 다음처럼 정리된다.

1) 회의실 단위로 락을 획득한다

SELECT GET_LOCK('ROOM_101', 10);

2) 해당 시간대에 겹치는 예약이 있는지 확인한다

SELECT COUNT(*) FROM reservation WHERE room_id = 101 AND start_time < :endTime AND end_time > :startTime;

3) 예약이 없으면 INSERT 한다

INSERT INTO reservation (...);

4) 작업이 끝나면 락을 해제한다

SELECT RELEASE_LOCK('ROOM_101');
핵심은 간단하다.
“예약 데이터”를 잠그는 게 아니라, “회의실 자원”을 잠근다.
이렇게 하면 예약 row가 아직 존재하지 않아도 상관없다. 회의실이라는 자원 단위로 요청을 직렬화할 수 있기 때문이다.

6. 이 방식이 특히 잘 맞는 이유

회의실 예약 문제에서는 네임드 락이 생각보다 잘 맞는다.
이유는 세 가지다.

첫째, row의 존재 여부와 무관하다

비관적 락은 보통 “이미 존재하는 row”를 전제로 한다. 하지만 예약은 빈 시간대에 새로 생성되는 데이터라, 그 전제가 자주 깨진다.
네임드 락은 이런 제약이 없다. 잠글 대상이 데이터가 아니라 문자열 기반 자원이기 때문이다.

둘째, 사용자 경험이 자연스럽다

같은 회의실에 대한 요청이 동시에 들어오면 실패를 즉시 반환하는 대신, 먼저 들어온 요청이 끝날 때까지 뒤 요청이 기다린다.
즉, “운 좋으면 성공, 아니면 실패”가 아니라 “순서대로 처리”에 가까운 흐름을 만들 수 있다.

셋째, 별도 인프라 없이 MySQL만으로 해결 가능하다

규모가 아주 크지 않은 서비스라면 Redis나 ZooKeeper 같은 별도 락 인프라를 운영하는 건 부담이 될 수 있다.
이럴 때 MySQL의 네임드 락은 꽤 현실적인 선택지다. 이미 쓰고 있는 DB 안에서 해결되기 때문이다.

7. 다만, 이 방식에도 함정은 있다

네임드 락이 깔끔한 해법처럼 보이지만, 실무에서는 반드시 조심해야 할 부분이 있다.
바로 커넥션 풀 고갈(Connection Pool Exhaustion) 이다.
네임드 락은 세션 기반으로 동작한다. 즉, 락을 획득한 커넥션이 락을 쥔 상태로 유지된다. 대기 중인 요청들도 결국 커넥션을 점유한 채 기다리게 된다.
이 상태에서 락 처리를 일반 비즈니스 쿼리와 같은 커넥션 풀로 섞어버리면 어떤 일이 생길까?
  • 예약 요청이 몰린다
  • 락 대기 중인 요청들이 커넥션을 계속 잡고 있다
  • 풀의 커넥션이 점점 고갈된다
  • 예약과 무관한 로그인, 조회, 게시판 API까지 같이 영향을 받는다
결국 예약 기능 하나 때문에 전체 서비스가 느려지거나 죽을 수 있다.
그래서 실무에서는 이 부분을 거의 필수적으로 분리해서 본다.

권장 방식: 락 전용 DataSource 분리

예를 들어 다음처럼 운영할 수 있다.
  • 비즈니스 로직용 커넥션 풀: 50
  • 락 처리 전용 커넥션 풀: 10
이렇게 분리하면 예약 요청이 몰려도 락 대기 트래픽이 전체 DB 커넥션을 잠식하지 않는다. 즉, 장애 범위를 예약 기능 안으로 가둘 수 있다.
네임드 락 자체보다도, 사실 이 운영 포인트를 놓치지 않는 것이 더 중요하다.

8. 그래서 비관적 락보다 네임드 락이 더 맞았던 이유

정리하면 이 문제의 본질은 “예약 row를 어떻게 잠글까?”가 아니었다.
정확히는,
“아직 존재하지 않는 예약 데이터가 아니라, 회의실이라는 자원에 대해 어떻게 동시 접근을 직렬화할까?”
이 질문에 가까웠다.
그 기준으로 보면 선택은 꽤 명확해진다.
  • 기존 row를 수정하는 문제
    • → 비관적 락이 잘 맞는다
  • 존재하지 않는 row를 새로 생성하는 문제
    • → 네임드 락이 더 자연스럽다
즉, 락의 정답은 기술 자체에 있는 게 아니라 도메인의 성격에 있다.

9. 마무리

동시성 제어를 이야기하다 보면 종종 더 무거운 해법부터 떠올리게 된다. 분산 락, Redis, 메시지 큐 같은 선택지도 물론 필요할 수 있다.
하지만 모든 문제를 큰 도구로 해결할 필요는 없다.
소규모에서 중간 규모의 회의실 예약 시스템이라면, 그리고 이미 MySQL을 사용하고 있다면, Named Lock만으로도 충분히 실용적이고 견고한 설계를 만들 수 있다.
중요한 건 “유명한 기술을 썼는가”가 아니라, 지금 해결하려는 문제의 모양에 맞는 락을 골랐는가다.
회의실 예약에서는 적어도 내 기준에서, 비관적 락보다 네임드 락이 그 질문에 더 잘 맞는 답이었다.