본문 바로가기

동시성 문제(1)

@정소민fan2025. 12. 21. 21:17

동시성 문제, 왜 해결해야 할까?

개발 로컬 환경(혼자 쓰는 환경)에서는 절대 발생하지 않지만, 운영 환경에 나가는 순간 지옥문이 열리는 이유가 바로 동시성(Concurrency) 때문이다.

스프링의 톰캣 스레드 풀이든, NestJS의 이벤트 루프와 워커 스레드든, 현대의 웹 서버는 기본적으로 여러 요청을 동시에 처리하도록 설계되어 있다. 즉, 여러 트랜잭션이 같은 데이터(행)를 동시에 건드리는 상황은 피할 수 없는 숙명이다.

이를 방치했을 때의 대가는 혹독하다.

  1. 정합성 붕괴와 금전 손실: "쿠폰도 돈이다." 오버셀(재고보다 많이 팔림), 잔액 음수 발생, 쿠폰 초과 발급 등은 회사의 직접적인 손실로 이어진다.
  2. TPS 급락 & 장애: 락 대기 시간이 길어지거나 데드락(교착 상태)에 빠지면 서버 스레드가 고갈되면서 503 Service Unavailable 타임아웃이 발생한다.
  3. 확장 한계: 아무리 샤딩을 하고 레플리카를 늘려도, 특정 핫스팟 데이터(예: 인기 상품 재고)에 락이 걸리면 병목이 해결되지 않는다.
  4. 운영 비용 폭발: 재현이 힘든 버그다. "어? 왜 이러지?" 하고 새벽에 핫픽스 배포를 반복하는 무한 루프에 빠지게 된다.

대표적인 동시성 문제 유형

  • 분실 갱신 (Lost Update): A와 B가 동시에 수정하다가, A의 수정 사항이 B에 덮여쓰여 사라지는 현상.
  • 더티 리드 (Dirty Read): 아직 커밋되지 않은 데이터를 읽어서 잘못된 로직을 수행하는 현상.
  • 불일치 (Inconsistency): 트랜잭션 중간에 다른 트랜잭션이 끼어들어, 계산 결과나 데이터의 앞뒤가 안 맞게 되는 현상.

왜 발생하는가?

대부분의 웹 서버 아키텍처 때문이다.

  • Tomcat/Netty: 요청마다 별도 스레드를 할당하거나 멀티 스레드로 동작한다.
  • NestJS: 싱글 스레드 기반이지만, 비동기 처리를 위한 이벤트 루프와 DB 작업을 위한 워커 스레드가 존재하므로 논리적인 동시성 이슈는 똑같이 발생한다.

이 과정에서 두 가지 상황이 주로 문제를 일으킨다.

1. 레이스 컨디션 (Race Condition)

여러 트랜잭션이 동시에 같은 데이터를 처리하려 할 때 발생한다. 가장 흔한 패턴은 Read-Modify-Write의 틈새다. 데이터를 읽고(Read), 값을 변경하기(Write) 직전의 짧은 틈 사이에 다른 요청이 들어와 값을 바꿔버리는 것이다.

  • 해결책:
    • 트랜잭션 경계를 명확히 설정 (@Transactional)
    • 낙관적 락 (Optimistic Lock)
    • DB 유니크 제약 조건 활용 (Insert 중복 방지)

2. 데드락 (Deadlock)

두 트랜잭션이 서로가 가진 자원의 락을 놓기만을 무한정 기다리는 상태다. 주로 여러 자원에 락을 걸 때 획득 순서가 꼬이거나, 트랜잭션이 너무 길어서 락을 오래 쥐고 있을 때 발생한다.

  • 해결책:
    • 락 획득 순서 고정: 항상 A테이블 → B테이블 순서로만 락을 걸도록 강제한다.
    • 재시도 로직: 데드락 감지 시 에러를 뱉고 재시도한다.
    • 빠른 처리: 트랜잭션 내 로직을 최대한 가볍게 유지한다.

필수 선행 지식

본격적인 해결책에 앞서, DB의 격리 수준과 락 전략에 대한 이해가 필요하다.


실무에서의 동시성 처리 레시피

이론은 알겠고, 실제로 어떻게 코드를 짜야 할까? 상황별로 전략이 다르다.

상황 1: 재고 차감 (오버셀 방지)

한정판 상품 판매나 선착순 쿠폰 발급 같은 상황이다.

  1. 비관적 락 (Pessimistic Lock / SELECT ... FOR UPDATE)
    • 장점: 조회 시점부터 락을 걸어버리므로 데이터 정합성이 확실하게 보장된다. 가장 마음 편한 방법.
    • 단점: TPS(초당 처리 건수)가 늘어나면 DB 병목이 심해져서 전체 서비스가 느려진다.
  2. 낙관적 락 (Optimistic Lock / @Version)
    • 장점: DB 락을 걸지 않으므로 성능상 이점이 크다.
    • 단점: 충돌이 발생하면 개발자가 직접 재시도(Retry) 로직을 구현해야 한다.
    • 주의: 타임세일처럼 순식간에 트랜잭션이 몰리는 경우(High Contention), 재시도 요청이 폭주하여 오히려 DB가 뻗을 수 있다. 충돌이 잦은 곳엔 비추천.
    • 전략: 재시도 횟수를 2~3회로 제한하고, 그래도 실패하면 사용자에게 "잠시 후 다시 시도해주세요"라는 메시지를 띄우는 것이 현실적이다.

상황 2: 포인트/잔액 차감 및 충전

"따닥"하고 결제 버튼을 두 번 누르는 경우를 막아야 한다.

  1. X-Lock (배타 락) 사용
    • Wallet 테이블의 해당 유저 로우에 락을 건다. 안정적이지만 역시 성능과 병렬 처리에 한계가 있다.
  2. 원장(Ledger) 방식 + 낙관적 락 (추천)
    • 잔액(balance) 컬럼 하나만 믿고 업데이트(UPDATE wallet SET balance = balance - 100)하는 방식은 위험하다.
    • 대신 히스토리 테이블을 이용한다.
    • 조회: SELECT SUM(amount) FROM wallet_history WHERE wallet_id = ? 방식으로 현재 잔액을 계산한다.
    • 기록: 입/출금 내역을 INSERT 할 때 버전을 비교하여 충돌을 감지한다.
    • 장점: 모든 변경 이력이 남으므로 추적이 쉽고 정합성이 높다.
    • 단점: 잔액 조회 시마다 SUM 연산 비용이 든다. (스냅샷을 주기적으로 생성해 보완 가능)
  3. 멱등성(Idempotency) 보장
    • 모바일 앱이나 프론트엔드에서 요청을 보낼 때 멱등 키(Idempotency Key, UUID 등)를 같이 보낸다.
    • 서버는 DB에 이 키를 같이 저장한다. (Unique Index 설정)
    • 같은 키로 두 번째 요청이 오면 DB단에서 중복 에러가 발생하므로, 자연스럽게 "이미 처리된 요청입니다"라고 응답할 수 있다.

이 글은 GEMINI 3의 블로그 포스터 젬을 활용하여 작성되었습니다. 초안은 제가 썼음

정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차