동시성 문제란?
동일한 데이터에 대해 여러 트랜잭션이 동시에 접근하여 수정하려고 할 때, 실행 순서에 따라 결과가 달라져 데이터의 일관성이 깨지는 현상을 말한다. 이를 경쟁 상태(Race Condition)이라고 한다.
- 원인 : 멀티스레드, 프로세스 환경에서 데이터베이스나 메모리의 공유 자원에 대한 동시 접근
- 증상 : 데이터 부정합, 더티 리드, 유령 읽기(Phantom Read)
예시는 아래와 같다.
동시성 문제 예시: 재고 관리
- 현재 재고가 1개 남은 상태
- 사용자 A와 사용자 B가 동시에 '구매하기' 버튼을 누른다.
- 두 스레드 모두 현재 재고를 1로 읽는다.
- 두 스레드 모두 재고 - 1 연산을 수행하여 재고를 0으로 업데이트한다.
- 결과적으로 남은 물건은 1개인데 2명에게 팔리는 사고가 발생한다. (Race Condition)
동시성 문제 발생 원인
- Lost Update: 두 트랜잭션이 동시에 같은 데이터를 수정하여 한 쪽의 변경이 사라짐
- Dirty Read: 커밋되지 않은 데이터를 읽음
- Non-Repeatable Read: 같은 조회를 반복했는데 결과가 다름
해결 방법 : 애플리케이션 레벨 (Synchronized)
- 동작: Java의 synchronized 키워드를 사용해 한 번에 하나의 스레드만 메서드에 진입하게 한다.
- 특징:
- 자바의 예약어로, 해당 메서드나 블록에 대해 하나의 스레드만 접근하도록 제한한다. (현재 실행 중인 해당 JVM 프로세스 내에서 특정 메서드나 블록에 단일 스레드만 접근하도록 제한)
- 단일 서버에서 간단한 공유 자원 관리에 사용
- JVM 기반으로 동기화된다. (Java/Kotlin 기본 제공)
- 객체의 모니터 락(Monitor Lock)을 획득한 스레드만 실행 권한을 갖는다.
- 한계: 서버가 여러 대(분산 환경=Scale-out)일 경우 무용지물이다. 각 서버(JVM) 인스턴스 내부에서만 유효하기 때문이다. (1번 서버의 스레드와 2번 서버의 스레드는 서로 다른 JVM 위에서 돌기 때문이다!)
- 한계2: @Transactional과 함께 사용할 때 프록시 문제로 동시성 제어가 제대로 안 될 수 있는 함정이 있다.
Transactional 어노테이션은 프록시 기반이기에 트랜잭션이 synchronized 블록 안에서 시작되도록 구성해야 안전하다.
해결 방법 : 락(Lock)
락(Lock) 매커니즘은 위와 같은 동시성 문제를 해결하기 위한 대표적인 방법 중 하나이다. 특정 자원을 한 번에 하나의 스레드만 사용할 수 있도록 "잠금"을 거는 장치라고 보면 된다. 이를 통해 공유 자원에 대한 접근을 제어하여 데이터의 일관성을 유지할 수 있다.
- 공유 락 (Shared Lock, S-Lock): 다른 사람이 읽는 것은 허용하지만, 수정하는 것은 방지합니다. (Read Lock)
- 배타 락 (Exclusive Lock, X-Lock): 내가 사용하는 동안 다른 사람이 읽지도 쓰지도 못하게 완전히 점유합니다. (Write Lock)
데이터베이스와 JPA는 이러한 동시성 문제를 해결하기 위해 락(Locking)을 제공한다. JPA 환경에서 동시성을 제어하는 가장 대표적인 두 가지 전략인 낙관락과 비관락에 대해 정리해보았다.
비관적 락(Pessimistic Lock)
'데이터에 충돌이 반드시 생길 것이다' 라고 비관적으로 가정하고 미리 문을 잠가버리는 방식이다.
- 동작: 데이터베이스가 제공하는 배타적 잠금(Exclusive Lock) 기능을 사용한다 (예: SELECT ... FOR UPDATE)
- 특징:
- 데이터를 읽는 시점에 즉시 락을 건다
- 다른 트랜잭션은 락이 풀릴 때까지 대기해야 한다
- 장점: 데이터 정합성이 강력하게 보장됨
- 단점: 성능 저하가 발생할 수 있고, 서로 락이 풀리길 기다리는 데드락에 빠질 위험이 있다.
- 비관락이 적합한 상황: 결제, 송금, 재고관리, 티켓팅 (충돌 발생 시 금전적 손실이 발생하며, 실패하면 안되는 도메인일 경우)
낙관적 락(Optimistic Lock)
"동시 수정이 발생하지 않을 것이라고 낙관적으로 가정" 하는 방식이다. DB의 락 기능을 사용하는 것이 아니라 애플리케이션 레벨에서 버전 관리를 통해 해결한다.
동작: 엔티티에 @Version 컬럼을 추가하여 관리
- 데이터를 읽을 때 버전을 함께 읽음
- 수정할 때 내가 읽은 버전 == 현재 DB 버전인지 확인
- 같다면 버전을 1 올리고 수정. 다르면 누군가 그새 수정한 것이므로 예외(OptimisticLockException)를 던짐
- 특징:
- 트랜잭션이 커밋되는 시점에 충돌 여부를 알 수 있따
- 장점: 실제 DB 락을 걸지 않으므로 비관적 락보다 성능상 이점이 있다.
- 단점: 충돌이 발생했을 때 롤백 처리를 개발자가 직접 코드로 구현해야 함.
- 낙관락이 적합한 상황: 닉네임, 프로필 사진 변경, 게시글 수정/삭제 (동시에 수정할 가능성 매우 낮음)
| 구분 | 비관적 락 (Pessimistic) | 낙관적 락 (Optimistic) |
| 가정 | 충돌이 자주 일어날 것이다. | 충돌이 거의 없을 것이다. |
| 제어 위치 | 데이터베이스 (Physical) | 애플리케이션 (Logical) |
| 성능 | 상대적으로 느림 (대기 시간 발생) | 상대적으로 빠름 |
| 충돌 시점 | 데이터를 읽을 때 (선제적) | 커밋할 때 (사후 확인) |
| 적합한 상황 | 재고 관리, 금융 거래 등 정합성이 최우선일 때 | 게시글 수정 등 충돌 빈도가 낮을 때 |
Redis를 활용한 분산 락(Distributed Lock)
- 동작: Redis나 Zookeeper 같은 별도의 외부 공유 저장소를 이용하여 여러 서버가 공통된 락을 관리한다.
- 필요한 경우: 여러 프로세스(서버, 인스턴스)가 동시에 같은 자원을 수정할 가능성이 있고, 정합성이 깨지면 치명적일 경우
- 종류: Redisson (Pub/Sub 방식), Lettuce (스핀 락 방식) 등
- 동작 방식 (Redisson 기준):
- 스레드가 Redis에 "락 좀 빌려줘"라고 요청
- 락을 선점한 스레드가 있다면 다른 서버의 스레드는 대기(Wait)
- 락을 가진 스레드가 해제하면 Redis가 대기 중인 다른 서버 스레드에게 신호를 보냄
- 장점: 마이크로서비스 아키텍처(MSA)나 다중 서버 환경에서 완벽한 동시성 제어가 가능해진다.
- 적합한 상황: 서버가 2대 이상인 분산 환경, DB 락/트랜잭션으로 막을 수 없는 경우
| 해결 방법 | 적용 범위 | 장점 | 단점 |
| synchronized | 단일 서버 | 구현이 매우 간단함 | 분산 환경(다중 서버) 적용 불가 |
| 낙관적 락 | DB | 성능상 이점 (락 대기 없음) | 충돌 발생 시 재시도 로직 직접 구현 필요 |
| 비관적 락 | DB | 강력한 정합성 보장 | 성능 저하 및 데드락 발생 가능성 |
| 분산 락 (Redis) | 분산 환경 | 다중 서버 환경에서 완벽 대응 | 외부 인프라 구축 비용 및 복잡도 증가 |
그 외의 해결 방법들
- DB 원자적 쿼리 (Atomic Query):
- UPDATE post SET like_count = like_count + 1 WHERE id = 1
- 애플리케이션에서 읽어오지 않고 DB가 직접 계산하게 한다. 가장 성능이 좋고 간단한 방법
- 메시지 큐 (Message Queue):
- Kafka나 RabbitMQ를 이용해 요청을 순차적으로 큐에 쌓고, 컨슈머가 하나씩 처리하게 하여 동시성 발생 여지를 없앱니다. (순차 처리)
트랜잭션 격리 수준이란?
격리 수준: 트랜잭션이 다른 트랜잭션의 변경을 얼마나 볼 수 있는지 제어 (DB 레벨)
락: 특정 데이터에 대한 동시 접근을 제어하는 메커니즘 (애플리케이션/DB 레벨)
격리 수준은 전역 설정이고, 락은 특정 엔티티나 테이블 단위로 적용된다.
동시성 문제 질문 예시
같이 면스하는 팀원 분이 주신 질문인데, 현재 포스팅에 정리해두는게 나을 것 같아서 기록해두려고 한다.
DB에 있는 A 데이터를 트랜잭션 1에서 영속성 조회를 하는데 트랜잭션 2가 그 해당 DB에 있는 A 데이터를 수정해버린 상황이다. 이때의 트랜잭션 상황에서는 어떻게 되는지?
이에 대한 답변은 아래와 같다.
트랜잭션 1은 트랜잭션 2의 변경 사항을 알지 못하고,
최초 조회 시점의 값을 그대로 사용한다. 즉, T1의 값을 그대로 사용하게 된다.
이유는:
- JPA는 트랜잭션 단위로 영속성 컨텍스트를 유지 (트랜잭션마다 영속성 컨텍스트는 분리됨)
- 한 트랜잭션의 영속성 컨텍스트는 다른 트랜잭션의 변경을 자동으로 알지 못함
- 한 번 조회된 엔티티는 1차 캐시에 저장
- 이후 동일 엔티티 조회 시 DB를 다시 보지 않음 (JPA 조회 순서는 영속성 컨텍스트의 1차 캐시가 우선됨)
자세한 동작 흐름은 아래와 같다.
일단 첫번째 상황은 다음과 같다.
[DB] a = 100
트랜잭션 1(T1)에서 find로 조회 → 영속성 컨텍스트에 적재
트랜잭션 2(T2)가 같은 a 데이터를 200으로 수정 후 commit
이때 T1에서는 어떻게 되느냐?
- DB 값은 200이지만, T1에서는 이전 값인 100을 계속 보고 있다.
동작 흐름
[Transaction 1 시작]
|
v
em.find(A)
|
v
+-------------------------------+
| Persistence Context (T1) |
| |
| A (value = 100) |
| 1차 캐시 + 스냅샷 저장 |
+-------------------------------+
------------------ 같은 시점 ------------------
[Transaction 2 시작]
|
v
UPDATE A SET value = 200 (수정)
|
v
COMMIT → DB 반영 완료
------------------ 이후 ------------------
🟦 다시 트랜잭션 1로 돌아오면?
T1 내부에서 다시 em.find(a)
[Transaction 1 계속 진행]
|
v
em.find(A)
|
v
1차 캐시에서 반환 (value = 100)
문제 해결 방식
그렇다면 위와 같이 동시에 같은 시점에 데이터를 조회/갱신을 완료한 상황에서 T1을 조회했을 경우, 이전 조회값이 계속 나오는 문제를 어떻게 해야할까?
트랜잭션 1에서 DB 최신 값을 보고 싶으면?
방법 1. em.refresh()
em.refresh(a);
1차 캐시 무시
↓
DB 재조회
↓
영속 엔티티 값 덮어쓰기
방법 2. 영속성 컨텍스트 초기화
em.clear();
- 모든 영속 엔티티 제거
- 이후 조회 시 DB 재조회
낙관적 락
@Version
private Long version;
- 동작 방식: UPDATE 시 version 조건 포함
- 트랜잭션 1, 2가 동시에 수정 시 후행 커밋에서 예외 발생 (다른 트랜잭션이 먼저 수정하면 → OptimisticLockException 발생)
UPDATE A
SET value = ?, version = version + 1
WHERE id = ? AND version = ?
비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
- SELECT 시점에 DB 락
- 다른 트랜잭션 접근 차단
- 동시성은 안전하지만 성능 부담 큼
추가 꼬리 질문 대비
- ❓ 그럼 refresh() 하면 어떻게 되나요?
→ 영속성 컨텍스트에 있는 엔티티 상태를 버리고, DB 값으로 다시 동기화함. - ❓ 트랜잭션 격리 수준이 READ_COMMITTED면 달라지나요?
→ JPA 1차 캐시가 우선이라 여전히 동일 - ❓ OSIV 환경에서는 더 위험하지 않나요?
→ OSIV 환경에서는 영속성 컨텍스트 수명이 길어지기 때문에 최신 데이터 불일치나 예상치 못한 Lazy Loading이 발생할 수 있다. - ❓ 이 상황에서 트랜잭션 1도 수정하면 어떻게 되나요?
→ 마지막 커밋이 이전 변경을 덮어쓰는 “Lost Update”가 발생할 수 있다.
T1: A = "old"
T2: A = "new" → commit
T1: A = "t1_value" → commit
JPA는 DB 변경 여부를 자동으로 알지 못함
Dirty Checking은 자기 스냅샷 기준
결국 T1의 UPDATE가 T2 변경을 덮어씀
=> 결과 : T2의 변경 = 소실됨
[동시성 제어 선택 가이드]
|
|--- 서버가 1대인가? ---> YES: [synchronized] (단, 성능 저하 주의)
| |
| NO (서버가 여러 대)
| |
| +--- 충돌이 잦은가?
| |
| +---> YES: [비관적 락 (Pessimistic Lock)]
| |
| +---> NO : [낙관적 락 (Optimistic Lock)]
|
|--- 대규모 분산 환경인가? ---> YES: [Redis 분산 락 (Redisson)]
https://f-lab.kr/insight/understanding-concurrency-issues-20240625
동시성 이슈와 락 메커니즘 이해하기
이 글에서는 동시성 이슈와 이를 해결하기 위한 락 메커니즘에 대해 다룹니다. 동시성 이슈의 개념과 락의 종류, 사용 사례를 통해 어떻게 데이터를 일관성 있게 처리할 수 있는지 설명합니다.
f-lab.kr
https://jaeseo0519.tistory.com/399
[Spring Boot] Java에서 동시성 문제를 해결하는 다양한 기법과 성능 평가
📕 목차1. Introduction2. synchronized method/block3. ReentrantLock4. CAS(Compare-And-Swap) Algorithm (feat. Atomic)5. Optimistic Lock6. Pessimistic Lock7. Distributed Lock8. Redis: Sorted Set9. Redis: Pipeline10. Messaging Queue11. Performance1. Introd
jaeseo0519.tistory.com
https://dev-yujji.tistory.com/77
[Backend] 동시성 이슈와 해결 방법 알아보기
1. 동시성 문제란?📒 개념여러 작업이 동시에 실행될 때 발생할 수 있는 예상치 못한 오류나 데이터 충돌 현상공유 자원에 여러 스레드, 혹은 사용자 요청이 동시에 접근하면서 문제가 생기는
dev-yujji.tistory.com
https://tao-tech.tistory.com/23
[SpringBoot] 다양한 동시성 제어 방법
동시성 제어는 여러 `스레드`나 `프로세스`가 공유 자원에 접근할 때 발생할 수 있는 문제를 방지하고, 데이터의 무결성을 유지하기 위한 방법입니다. 이를 해결하기 위한 여러 가지 접근법이 있
tao-tech.tistory.com
'TIL' 카테고리의 다른 글
| [TIL] DDD 개념 & 설계 사고방식 (0) | 2026.02.20 |
|---|---|
| [TIL] 블로킹/논블로킹과 동기/비동기 (0) | 2026.02.10 |
| [TIL] JPA 엔티티 생명주기 (0) | 2026.01.28 |
| [TIL] JPA 동작 원리 (0) | 2026.01.27 |
| [TIL] ORM (0) | 2026.01.26 |