| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 오라클
- java
- It
- 데이터베이스
- AOP
- 개발자
- 프로젝트관리
- 웹프론트
- redmine
- 스프링
- 이슈관리
- 일감관리
- db
- 인덱스
- Si
- 백엔드
- frontend
- 비전공개발자
- spring aop
- 레드마인
- 자바
- 엘라스틱서치
- 검색
- kibana
- Database
- backend
- PM
- elasticsearch
- spring
- PreparedStatement
- Today
- Total
리타의 저장소
Lock 과 트랜잭션 동시성 제어 #2 본문

JAVA 레이어에서 DB 레이어 까지
* 여기선 Spring JPA 기준으로 설명해본다.
[Java Thread]
↓
@Transactional
↓
Persistence Context (1차 캐시, Dirty Checking)
↓
flush → SQL 상태 반영
↓
JDBC SQL 전송
↓
Oracle Transaction / Undo / Row Lock / Commit
save()를 호출하면 DB에 반영되는 것인가?
많은 사람들이 save() 했으니 DB에 반영되었다고 생각할 수 있으나, 실제로는 영속성 컨텍스트에만 반영된 상태일 수 있고, flush가 나가도 commit 전이면 다른 세션에까지 확정되지 않는다.
JPA spec은 flush가 AUTO일 때 쿼리 실행 전이나 커밋 시점 등에 반영될 수 있고, active transaction이 없으면 flush하면 안 된다고 명시한다.
Spring은 논리적 트랜잭션과 물리적 트랜잭션을 구분해서 설명한다. Oracle은 기본적으로 read committed와 multiversion read consistency를 제공한다.
- 추가적으로 Spring Data JPA 에서
merge()는 1차 캐시나 DB에서 데이터를 다시 조회해오는 과정이 발생할 수 있으므로, 단순 업데이트 목적이라면 변경 감지(Dirty Checking)를 사용하는 것이 훨씬 효율적이다. save()를 호출했을 때 무조건적으로persist()가 호출 되는 것은 아니다. 내부적으로 해당 엔티티가 새로운 상태 (isNew())인지 확인한다. 새로운 객체면EntityManager.persist()를 호출하지만, 이미 존재하는 객체 (식별자가 있는 경우) 라면EntityManager.merge()를 호출한다.
@Transactional은 무엇을 시작하나
Spring의 @Transactional은 보통 서비스 메서드 경계에서 트랜잭션을 시작하고 종료한다. 이때 중요한 건 Spring이 말하는 논리적 트랜잭션과 실제 JDBC/DB의 물리적 트랜잭션은 다를 수 있다는 점이다.
PROPAGATION_REQUIRED는 기존 물리 트랜잭션에 참여하거나 없으면 새로 만든다.
Spring이 기본값으로 사용하는 propagation behavior는
PROPAGTION_REQUIRED이다. 이 옵션을 사용하면 어떤 트랜잭션 안에서TransactionTemplate을 통해 트랜잭션을 열려고 시도할 경우,AbstractPlatformTransactionManager.getTransaction()는 이미 열려있는 기존 트랜잭션을 반환한다. 즉, 새로운 트랜잭션이 열리는 게 아니라 기존 트랜잭션에 참여하게 된다. (기존 트랜잭션에 참여하거나 새로 생성)
완전히 새롭고 독립적인 트랜잭션을 열고 싶다면, 안쪽 트랜잭션에의 propagation behavior 를PROPACATION_REQUIRES_NEW로 지정해줘야함.
- 트랜잭션 롤백 조건Spring
@Transactional은
기본적으로 Unchecked Exception(RuntimeException 계열)과 Error가 발생하면 rollback을 수행하고, Checked Exception(Exception 계열)이 발생하면 기본적으로는 rollback 대상이 아니므로, 별도 설정이 없으면 rollback 하지 않고 commit 된다.
- Unchecked Exception (
RuntimeException하위) 발생 시 → rollback Error발생 시 → rollback- Checked Exception (
Exception하위이지만RuntimeException아님) 발생 시 → 기본적으로 rollback 안 함
→ 결과적으로 commit 쪽으로 간다
Fulsh ≠ Commit
JPA는 엔티티 변경을 즉시 DB에 반영하지 않고, Persistence Context 안에서 관리한다.
이 과정에서 dirty checking이 일어나고, 변경분이 SQL action으로 준비된다. flush는 이 변경을 DB SQL로 동기화하는 단계이다. JPA spec은 fluch mode AUTO 가 기본이며, 쿼리 결과에 영향을 줄 수 있으면 쿼리 실행 전에 flush가 일어날 수 있다고 한다. COMMIT 은 커밋 시 flush 가 일어나고, provider가 다른 시점에 flush할 수도 잇다.
persist()= 객체를 관리 대상으로 올림- 필드 변경 = dirty checking 대상
flush()= SQL을 DB로 보냄commit()= 트랜잭션 확정
flush는 DB에 SQL을 보낼 뿐이고, commit이 되어야 다른 트랜잭션 입장에서 확정된 데이터로 받아들여짐.
JAVA에서 동시성 제어 방식
Syncronized / atomic / concurrent / volitile
JAVA에서 왜 동시성 제어가 필요할까?
JAVA는 멀티스레드 환경이다. 멀티스레드 환경에서는 여러 스레드가 동시에 같은 자원에 접근한다.
예를들어,
count++;
이 한 줄은 사실 원자적 연산이 아니다. 실제로는 대충 이렇게 쪼개진다.
- count 읽기
- 1 더하기
- 다시 저장하기
그래서 두 스레드가 동시에 실행하면
- 둘 다 10을 읽고
- 둘 다 11을 만들고
- 둘 다 11을 저장
해서 결과가 12가 아니라 11이 될 수 있다.
이게 대표적인 race condition 이다. 동시성 제어는 이런 문제를 막기 위한 것이다.
Synchronized
Java의 가장 기본적인 모니터 락 기반 동기화 방식, 특정 코드 블록이나 메서드에 한 번에 하나의 스레드만 들어오게 한다.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
synchronized가 해결하는 것
- 상호배제 보장
- 원자성 보장(해당 임계영역 안에서)
- 가시성 보장 (한 스레드가 바꾼 값이 다른 스레드에게 바로 보이는가)
왜 가시성도 보장하는가 하면, synchronized 는 락을 획득하고 해제하는 과정에서 메모리 동기화가 일어난다.
쉽게 말하면
- 락 해제 전에 변경한 값은 메인 메모리에 반영되고
- 다른 스레드가 같은 락을 획득하면 최신값을 읽게 된다
Volatile
volatile 은 가시성 보장용 키워드다.
private volatile boolean running = true;
한 스레드가 값을 바꾸면 다른 스레드가 그 변경을 바로 볼 수 있게 한다.
volatile 은 원자성까지 보장하지는 않는다.
volatile int count = 0;
count++;
이건 여전히 안전하지 않다.
왜냐하면 count++ 자체가 read-modify-write 이기 때문.
언제 쓰냐
- 플래그 변수
- 상태 전파
- 한 번 쓰고 여러 스레드가 읽는 설정값
- double-checked locking에서 참조 변수
Atomic
보통 java.util.concurrent.atomic 패키지의 클래스들을 말한다.
예:
AtomicIntegerAtomicLongAtomicBooleanAtomicReference
락 없이 CAS(compare-and-set) 기반으로 원자적 연산을 수행한다.
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
count.getAndIncrement();
count.compareAndSet(3, 4);
CAS (Compare-And-Set) 란?
쉽게 말하면 이런 느낌이다.
"현재 값이 내가 예상한 값이면 새 값으로 바꿔라"
예)
count.compareAndSet(10, 11);
의미:
- 현재 값이 10이면 11로 바꿈
- 아니면 실패
이걸 반복해서 락 없이 안전한 갱신을 구현한다.
장점
- 락 없이 빠를 수 있음
- 단일 변수 연산에 매우 강함
- 경쟁이 아주 심하지 않으면 효율적
단점
- 여러 변수 묶음 상태 변경에는 약함
- CAS 재시도가 많아지면 오히려 비효율적
- 코드가 synchronized보다 덜 직관적일 수 있음
Atomic이 해결하는 것
- 원자성 보장
- 가시성 보장
하지만
- 여러 변수의 일관성을 한 번에 보장하는 건 어려움
Atomic과 ABA 문제
CAS는 "현재 메모리 값이 내가 예상한 값과 동일한가?"만 확인한다. 만약 스레드 1이 값을 A에서 B로 바꿨다가 다시 A로 돌려놓았을 때, 스레드 2가 접근하면 값은 여전히 A이므로 변경이 없었다고 착각하고 연산을 성공시킨다. 이를 해결하기 위해 Java에서는 값의 변경 횟수나 버전을 함께 기록하는 AtomicStampedReference를 제공한다.
언제 쓰냐?
- 카운터
- 상태값
- 플래그
- 참조 교체
- 락 없이 고성능 단일 변수 제어
Concurrent
이건 사실 키워드가 아니라 보통
java.util.concurrent 패키지 계열을 통칭해서 말한다. 즉, 동시성 문제를 다루기 위한 고수준 도구 모음이라고 보면 된다.
"java.util.concurrent는 고성능 멀티스레드 환경에서 동시성 유틸리티 클래스들을 제공한다. 이 클래스들의 최상위 클래스가 AbstractQueuedSynchronizer 다. 결국 AbstractQueuedSynchronizer 는 java.util.concurrent 에서 동기화 클래스의 핵심 기반 클래스다."
대표적으로:
ConcurrentHashMapCopyOnWriteArrayListBlockingQueueExecutorServiceReentrantLockSemaphoreCountDownLatchCyclicBarrierForkJoinPool
ConcurrentHashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
일반 HashMap 은 멀티스레드에서 안전하지 않다.
반면 ConcurrentHashMap 은 내부적으로 동시성을 고려해서 설계되어 있다.
- 성능 비결
- 일반
Hashtable이나Collections.synchronizedMap()은 메서드 전체에 락을 걸어 병목 현상이 심하다. 반면ConcurrentHashMap은 버킷(Bucket) 단위로 분할하여 락을 걸거나(Lock Stripping), 락 없이 CAS 연산을 활용하여 처리량을 극대화한다는 점에 성능의 비결이 있다.
ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> {
System.out.println(Thread.currentThread().getName());
});
스레드를 직접 만들고 관리하지 않고, 스레드 풀 기반으로 작업을 실행할 수 있다.
BlockingQueue
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
queue.put("task");
String value = queue.take();
생산자-소비자 패턴에 매우 유용하다.
concurrent 계열이 중요한 이유
실무에서는 synchronized 만으로 다 짜기보다 검증된 동시성 유틸리티를 쓰는 게 훨씬 안전하고 생산적이다.
→ synchronized 키워드로 이루어진 영역에 스레드가 접근하려면 모니터 락을 획득해야한다. 스레드는 모니터 락을 획득할 때까지 무한 대기한다. 이때 해당 스레드의 상태는 BLOCKED 이다. BLOCKED 상태는 interrupted()로 다시 RUNNABLE 상태로 전이시킬 수 없다. 락을 얻기 전까지 BLOCKED 상태로 머무르게 되고, ‘무한’ 대기 상태에 빠지게 되는 것.
lock 획득의 순서 또한 보장되지 않는다. 순서 보장 없이 lock을 부여받기를 오매불망 기다리는 것이 synchronized의 한계이다.
'Dev > Backend' 카테고리의 다른 글
| 트랜잭션 동시성 처리 #1 (feat. 오라클 vs MySQL vs PostgreSQL) (0) | 2026.04.03 |
|---|---|
| Lock과 트랜잭션 동시성제어 #3 (0) | 2026.04.03 |
| Lock 과 트랜잭션 동시성 제어 #1 (0) | 2026.04.03 |
| Presigned Url, Signed Url, Signed Cookie (1) | 2026.03.02 |
| GC의 종류 & 운영중인 시스템의 GC 확인하기 (0) | 2025.10.11 |