지금까지 우리는 조회(Read) 쿼리를 어떻게든 최적화하여 10만 명의 조회를 0.1초 만에 처리하는 극강의 성능 아키텍처를 만들어냈습니다. 하지만 데이터베이스 세계에서 가장 무섭고 까다로운 재앙은 조회할 때 터지는 것이 아니라, 여러 명의 유저가 "동시에 하나의 데이터를 수정(Write/Update)하려 달려들 때" 터집니다.
유명 가수의 콘서트 티켓팅이나 한정판 명품 선착순 판매(Draw) 이벤트가 열렸다고 상상해 봅시다. 남은 재고는 딱 1개입니다. 그런데 A 유저와 B 유저가 0.001초의 오차도 없이 동시에 결제 버튼을 누릅니다. 두 명의 톰캣 쓰레드는 DB에서 남은 재고가 1개인 것을 동시에 확인(SELECT)하고, 각자 재고를 0으로 수정(UPDATE)한 뒤 커밋해 버립니다. 어? 재고는 1개였는데 두 명 모두 결제가 성공해 버렸습니다! 이 끔찍한 현상을 전산학에서는 갱신 손실(Lost Update) 혹은 동시성 이슈(Concurrency Issue)라고 부릅니다. 이 문제를 해결하지 못하면 회사는 고객들에게 엄청난 금전적 배상을 해야 하고, 개발자는 짐을 싸야 합니다.
이번 60단계(Phase 12의 마지막) 포스팅에서는 이 무시무시한 동시성 문제를 물리적으로 멱살 잡고 해결해 버리는 데이터베이스 제어의 절대 반지, 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)의 아키텍처 철학, 그리고 실무 분산 환경에서의 Retry(재시도) 메커니즘에 대해 상세히 알아보겠습니다.

1. 인간을 믿지 마라: 비관적 락 (Pessimistic Lock)
"이 선착순 데이터는 무조건 100% 확률로 수만 명이 동시에 덤벼들어서 충돌이 터질 거야!"라고 시스템을 비관적으로 바라보는 방식입니다. 이 방식은 애플리케이션 단(Java)에서 뭘 어떻게 하는 것이 아니라, 데이터베이스 엔진(MySQL, Oracle)이 자체적으로 제공하는 물리적인 락(Lock) 메커니즘에 전적으로 의지합니다.

스프링 데이터 JPA의 Repository 인터페이스에서 조회 메서드 위에 @Lock(LockModeType.PESSIMISTIC_WRITE) 애노테이션을 붙여주면 끝납니다. 이 애노테이션이 붙은 메서드를 실행하면 JPA는 SELECT ... FOR UPDATE 구문을 만들어 DB로 던집니다. 이 쿼리를 맞은 DB는 즉시 해당 Row(행)에 배타적 락(Exclusive Lock)을 강하게 걸어버립니다. A 쓰레드가 락을 쥐고 트랜잭션을 끝낼(커밋할) 때까지, 0.001초 늦게 도착한 B 쓰레드는 해당 데이터를 아예 조회조차 하지 못하고 DB 커넥션을 쥔 채로 하염없이 대기(Wait) 상태에 빠지게 됩니다.
⚠️ 비관적 락의 치명적 한계 (성능 폭망)
데이터의 정합성은 은행 금고처럼 100% 완벽하게 보장됩니다. 절대로 초과 결제가 일어나지 않죠. 하지만 세상에 공짜는 없습니다. B 쓰레드, C 쓰레드, 그리고 1만 번째 쓰레드까지 전부 DB 락이 풀리기를 기다리며 줄을 서게 됩니다(병목 큐잉). DB 커넥션 풀은 순식간에 고갈되고, 톰캣의 쓰레드 풀도 요청을 처리하지 못한 채 말라붙어 서버 전체의 응답 속도(Latency)가 수십 초 단위로 폭증하며 결국 시스템 전체가 타임아웃(Timeout)으로 대폭발을 일으킵니다. 따라서 비관적 락은 '정말 치명적이고 빈도가 낮은 금융 결제' 등에서만 극히 제한적으로 사용해야 합니다.
2. 싸우지 말고 버전을 올려라: 낙관적 락 (Optimistic Lock)
"설마 유저 두 명이 완벽히 똑같은 0.001초에 수정을 누르겠어? 충돌이 별로 안 날 거야!"라고 낙관적으로 바라보는 방식입니다. 이 방식은 무식하게 DB에 물리적 락을 걸어 줄을 세우지 않습니다. 대신, 엔티티(Table)에 @Version 필드를 하나 추가하여 애플리케이션 레벨에서 충돌을 감지(Version Control)하는 똑똑한 우회술을 사용합니다.
동작 원리는 다음과 같습니다. DB에 재고(Quantity=10)와 버전(Version=1)이 적혀있습니다.
A 유저와 B 유저가 동시에 재고를 조회합니다. 둘 다 Version=1을 읽어옵니다.
A 유저가 재고를 9로 깎고 커밋을 요청합니다. JPA는 UPDATE item SET quantity = 9, version = 2 WHERE id = 1 AND version = 1 쿼리를 날립니다. 업데이트가 성공하면 버전은 2가 됩니다.
0.001초 뒤 B 유저가 재고를 9로 깎고 커밋을 요청합니다. JPA는 똑같이 UPDATE ... WHERE id = 1 AND version = 1 쿼리를 날립니다. 어? 그런데 방금 A 유저가 버전을 2로 바꿔버렸기 때문에 조건문(version=1)에 맞는 데이터가 DB에 없습니다! 업데이트된 Row 수가 0이라는 것을 확인한 JPA는 즉각적으로 ObjectOptimisticLockingFailureException(버전 충돌 에러)을 터뜨려 B 유저의 결제를 무자비하게 롤백(Rollback) 시켜버립니다.
낙관적 락은 DB에 물리적 락을 걸지 않으므로 성능 저하가 거의 없고 TPS가 어마어마하게 나옵니다. 충돌이 잘 나지 않는 일반적인 게시판 수정 기능 등에서 최고의 성능을 자랑합니다.
3. 실무 아키텍트의 고뇌: 충돌 시나리오와 Retry(재시도) 방어막
하지만 낙관적 락에도 거대한 문제점이 숨어 있습니다. B 유저의 결제가 버전 충돌로 인해 에러가 났을 때, 사용자 화면에 "죄송합니다, 충돌 났으니 다시 결제 버튼을 누르세요"라고 500 에러 페이지를 띄울 것입니까? 화가 난 유저들은 당장 서비스에서 이탈할 것입니다. 충돌이 발생하면 개발자가 백그라운드 코드로 '자동 재시도(Retry)'를 시켜주어야 합니다.
스프링 리트라이(Spring Retry)나 AOP를 활용하여, 예외가 터졌을 때 즉시 DB에서 최신 버전(Version=2)을 다시 읽어오고 재고 깎기 로직을 자동으로 재실행하는 코드를 짜야합니다. 여기서 치명적인 딜레마가 터집니다. 만약 티켓팅처럼 1만 명이 '확정적으로' 충돌하는 이벤트에 낙관적 락을 걸어버리면? 1만 명의 쓰레드가 미친 듯이 에러를 내며 재시도(Retry) 뺑뺑이를 돌게 되고, 서버의 CPU 점유율이 단숨에 100%를 돌파하며 스레드 웜홀(Thread Wormhole)에 빠져 서버가 불타버립니다.
💡 결론 및 실무 튜닝 가이드: 충돌 빈도가 낮다면 무조건 낙관적 락(Optimistic Lock) + Retry 아키텍처로 가서 성능을 극대화해야 합니다. 하지만 선착순 이벤트처럼 100% 확률로 피 터지는 충돌이 예상된다면 차라리 깔끔하게 비관적 락(Pessimistic Lock)을 걸거나, 아니면 다음 Phase에서 우리가 궁극적으로 배우게 될 메모리 기반의 Redis 분산 락(Distributed Lock, Redisson)이나 Kafka 메시지 큐를 통해 비동기로 처리하는 아키텍처로 반드시 우회해야 서버가 폭발하는 것을 막을 수 있습니다.
정리하며 (Phase 12 종료)
이번 60단계 포스팅을 마지막으로, 트래픽 폭주와 거대한 데이터 볼륨 앞에서도 무너지지 않는 데이터 정합성의 성벽을 쌓아 올린 Phase 12: Database Deep Dive & Concurrency 대장정이 화려하게 마무리되었습니다. Master-Slave 트래픽 분산과 JTA 글로벌 트랜잭션, JPA N+1 방어, QueryDSL의 한계 돌파, 그리고 낙관적/비관적 락을 통한 갱신 손실(Lost Update) 방어까지... 백엔드 시스템의 근간인 데이터베이스 아키텍처와 관련된 거의 모든 실무 트러블슈팅 지식을 머릿속에 장착하셨습니다. 자, 하지만 매번 이렇게 디스크 I/O를 일으키며 DB까지 내려가는 것은 너무나도 무겁고 느립니다. 조회 속도를 0.1초에서 0.01초 단위로 10배 이상 끌어올리기 위해서는 DB로 가기 전, 메모리 단에서 데이터를 낚아채는 고속도로 톨게이트를 지어야 합니다!
다음 61단계(Phase 13 시작) 포스팅에서는 "캐싱(Caching) 아키텍처와 성능 튜닝: Spring Cache의 @Cacheable 추상화와 Redis를 활용한 글로벌 인메모리(In-Memory) 캐시 전략 완벽 가이드"에 대해 아주 치밀하게 딥다이브 해보겠습니다!