[Lock] 낙관적 락과 비관적 락은 언제 사용해야 할까? 유이한 해결책일까?
0. 서론
최근 프로젝트를 진행하면서 공유하는 리소스에 대해 동시성을 어떻게 제어할지 고민했다. 게시글의 좋아요 숫자를 늘리는 기능이었는데, 여러 사람이 공유하면서 충돌이 잦으니 당연히 비관적 락을 떠올렸다.
하지만, 좋아요의 숫자를 세는 기능이 비관적 락으로 정합성을 빠르고 정확하게 유지해야 할 만큼 중요한 비지니스 로직은 아니었다. 값은 결과적으로 맞기만 하면 충분했다.
그렇다면 어떻게 최소한의 비용을 지불하고, 어느정도 정합성도 지키면서 안전한 프로그램을 만들 수 있을까?
1. 비관적 락을 사용하는 경우
비관적 락은 데이터에 물리적 Lock을 걸어, 다른 트랜잭션이 해당 데이터를 사용할 수 없게 만든다. 직관적이다. 만약 다른 트랜잭션이 Lock이 걸린 데이터에 접근하려고 하면, 일정 시간 대기하거나 예외가 발생한다.
장단점
- 장점: 레이스 컨디션 상황을 차단하여 완벽한 데이터 정합성을 보장한다.
- 단점:
- 매 읽기쓰기마다 Lock을 걸어 성능이 떨어진다.
- 데드락의 위험이 있다.
사용처
통장 잔고를 A와 B가 동시에 업데이트 하는 경우
- [12:00:00] A의 트랜잭션 시작 (입금 요청)
- A가
findByIdForUpdate(1)을 호출 - DB에
SELECT * FROM account WHERE id = 1 FOR UPDATE쿼리가 날아간다. - [DB 상태] DB는 1번 계좌 데이터(Row)에 배타적 락을 건다.
- A는 잔고 10,000원을 읽어옵니다.
- A가
- [12:00:01] B의 트랜잭션 시작 (출금 요청)
- B도
findByIdForUpdate(1)을 호출 - DB에
SELECT * FROM account WHERE id = 1 FOR UPDATE쿼리가 날아간다. - [DB 상태 - 대기 발생] 현재 1번 계좌에는 A가 건 락이 걸려 있습니다. 따라서 B의 쿼리는 실행되지 못하고 A의 트랜잭션이 끝날 때까지 멈춰서 기다린다. B는 아직 잔고를 읽지 못했다. (이것이 갱신 손실을 막는 핵심)
- B도
- [12:00:02] A의 비즈니스 로직 수행
- A의 애플리케이션 메모리에서 연산이 일어난다:
10,000 + 5,000 = 15,000
- A의 애플리케이션 메모리에서 연산이 일어난다:
- [12:00:03] A의 트랜잭션 커밋 및 락 해제
- DB에
UPDATE account SET balance = 15,000 WHERE id = 1쿼리가 실행된다. - A의 트랜잭션이 종료되면서 1번 계좌에 걸려있던 배타적 락이 해제된다.
- [DB 잔고] 15,000원
- DB에
- [12:00:03] B의 트랜잭션 재개 (대기 해제)
- A의 락이 풀리자마자, 아까에 멈춰있던 B의
SELECT FOR UPDATE쿼리가 마저 실행된다. - B는 과거의 10,000원이 아닌, A가 방금 커밋한 최신 잔고 15,000원을 읽어온다.
- 동시에 B가 1번 계좌에 새로운 배타적 락을 쥔다.
- A의 락이 풀리자마자, 아까에 멈춰있던 B의
- [12:00:04] B의 비즈니스 로직 수행
- B의 애플리케이션 메모리에서 연산이 일어난다:
15,000 - 3,000 = 12,000
- B의 애플리케이션 메모리에서 연산이 일어난다:
- [12:00:05] B의 트랜잭션 커밋 및 락 해제
- DB에
UPDATE account SET balance = 12,000 WHERE id = 1쿼리가 실행된다. - B의 트랜잭션이 종료되고 락이 해제된다.
- 최종 상태: 12,000원 (정확히 정합성이 맞음)
- DB에
2. 낙관적 락을 사용하는 경우
낙관적 락은 DB 레벨에서 걸리는 물리적 제한이 없다. 대신 Version이 존재해, 처음 데이터를 읽었을 때의 Version과, 커밋 직전의 Version이 다르다면 롤백하는 식으로 작동한다.
장단점
- 장점: 애초에 Lock을 거는 방식이 아니라서 DB 자원을 소모하지 않는다.
- 단점: 만에 하나 충돌 발생 시, 롤백 비용을 지불해야 한다. (꽤 크다)
사용 예시
동일한 게시글 내용을 2명이 수정하는 경우 (User Think Time)
- [12:00] A가 수정 화면 열기
- A가 받은 데이터:
(제목: "기존 제목", 내용: "기존 내용", version: 1)
- A가 받은 데이터:
- [12:01] B가 수정 화면 열기
- B가 받은 데이터:
(제목: "기존 제목", 내용: "기존 내용", version: 1)
- B가 받은 데이터:
- [12:02] B가 저장
- B가 서버로 보낸 데이터:
(제목: "B가 수정한 제목", ..., version: 1) - JPA가 날리는 쿼리:
UPDATE board SET title='B가 수정한 제목', version=2 WHERE id=1 AND version=1 - DB에
version=1인 데이터가 있으므로 업데이트 성공. DB의 version은 2가 된다.
- B가 서버로 보낸 데이터:
- [12:05] A가 저장
- A가 서버로 보낸 데이터:
(제목: "기존 제목", ..., version: 1) - JPA가 날리는 쿼리:
UPDATE board SET title='기존 제목', version=2 WHERE id=1 AND version=1 - [해결] 현재 DB의 version은 2이다.
WHERE version=1조건에 맞는 데이터가 없으므로 업데이트되는 행(Row)의 수가 0개이다. - JPA는 이를 감지하고
OptimisticLockException을 발생시킨다. 서버는 A에게 “다른 사용자가 이미 글을 수정했습니다. 다시 확인해 주세요.”라고 예외 처리를 해줄 수 있다.
- A가 서버로 보낸 데이터:
이런 상황에서는 비관적 락을 사용해 데이터를 선 점유할 수 없으니, 낙관적 락이 유일한 해결책이 된다.
3. 그럼 충돌은 자주 발생할 것 같은데 완벽한 정합성은 필요 없으면 어떻게 해
세상 모든 데이터가 비관적 락이 필요한 경우 / 낙관적 락이 필요한 경우 두 가지로 나뉘는게 아니다. 도메인 특성은 꽤나 다양하다.
앞서 내가 설명한 경우가 딱 그렇다. 좋아요 수를 업데이트 하는 기능은 반드시 여러 유저들과 충돌하지만, 결과적으로만 값이 맞아도 된다. 실시간으로 데이터가 완벽할 필요가 없다. 좋아요 갯수가 한 두개 다르다고, 신경쓰는 유저는 존재하지 않는다.
해결책 1. Insert-Only + Batch Update
락을 걸지 않는다. 일정 기준(1~2분 등등)이 넘어가면 좋아요 Table을 조회해 한꺼번에 업데이트한다. 애초에 유저에게 좋아요 갯수를 업데이트할 권리를 주지 않는다.
해결책 2. DB레벨의 원자적 증가
DB 레벨의 락을 사용해 아주 짧은 순간만 락을 획득해 업데이트한다. 쿼리가 실행되는 수 밀리초의 락만 걸리게 된다. 락의 범위를 줄여 트랜잭션이 충돌할 확률을 낮춘다.
해결책3. Redis INCR 사용
Redis는 Spring서버와 달리 싱글 스레드로 작동하기 때문에, 데이터 증감을 원자적으로 관리 가능하다. 또한 메모리 기반이라 DB에 비해 속도가 매우 빠르다.
이곳에 쌓인 좋아요 카운트를 주기적으로 DB에 업데이트 해준다.
4. 동시성 문제를 해결하는 기준은?
이전까지는 단순히 충돌이 잦을 것 같으면? 비관락, 아니면? 낙관락 같은 이분법적인 단순한 사고를 했었는데, 한번 고민해보니 좀 더 복잡한 영역이었다. 어떤 것을 고려해봐야 할까?
도메인 특성: 완벽해야 할까? 흘러나가도 될까?
비지니스 요구사항에 따라 데이터의 엄격함을 생각해보자.
- 금융 데이터: 0.1원의 차이라도 있으면 안되는 도메인. 강력한 일관성을 가장 첫번째로 고려해야 한다. 따라서 비관적 락이나 분산락을 첫번째로 고려해야 한다.
- 숫자(좋아요, 조회수 등등): 최종 일관성만 맞으면 되는 도메인. 시스템이 죽지 않고, 시스템 가용성을 첫 번째로 생각하는게 우선이다. DB 원자적 연산이나, Redis를 고려해보자.
유저 임팩트: 기술적 선택이 사용자 경험(UX)을 결정한다
- 비관락의 UX: 충돌이 잦은 곳에 비관락을 걸면, 사용자는 무한 로딩을 보게 되거나 타임아웃 에러를 겪고, DB 커넥션이 부족해져 시스템 전체가 느려진다.
- 낙관락의 UX: 사용자 생각 시간(User Think Time)이 개입된 긴 트랜잭션에서, 누군가 내 글을 먼저 수정했다면 덮어쓰기(Lost Update)를 방지하고 “데이터가 변경되었습니다”라는 명확한 피드백을 줄 수 있다.
- 무(無)락의 UX: 좋아요 기능에서 락을 없애면, 사용자는 버벅임 없이 즉각적인 반응(쾌적함)을 경험한다.
동시성 문제는 단순히 “어떤 락을 고를까?”의 문제가 아닌, 이 비지니스에서 무엇을 취하고 무엇을 잃어도 괜찮을지를 가늠하는 문제다. 항상 도메인 특성을 고려해 가장 실용적이고 유연한 동시성 제어 방식을 생각해보자.
