Search

JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기

Tags
Study
JPA
Database
Last edited time
2025/09/19 11:51
2 more properties
Search
JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기
Study
JPA
Database
JPA의 사실과 오해 (9) 동시성 제어 / 애그리거트 완성하기
Study
JPA
Database

1. 동시성 제어

동시성 문제
하나의 데이터를 프로세스나 스레드와 같은 에이전트가 동시에 접근할 때 발생하는 문제

1.1. 종류

1.1.1. 손실된 업데이트

트랜잭션1에서 A 데이터 조회 (값 1000)
트랜잭션2에서 A 데이터 업데이트 (1000 → 1002)
트랜잭션1에서 A 데이터 업데이트 (1000 → 1001) 후 커밋
트랜잭션2에서 업데이트된 값 (1002) 커밋
트랜잭션 1에서 업데이트된 값(1001)은 유실됨

1.1.2. 더티 읽기

트랜잭션1에서 A 데이터 조회 (값 1000)
트랜잭션1에서 A 데이터 업데이트 (1000 → 3000)
트랜잭션2에서 A 데이터 조회 (3000)
트랜잭션1에서 롤백 (3000 → 1000)
트랜잭션2에서는 여전히 3000으로 남음

1.1.3. 반복 불가능한 읽기 (Unrepeatable Read)

트랜잭션1에서 A 데이터 조회 (값 1000)
트랜잭션2에서 A 데이터 업데이트 (값 1000 → 2000) 후 커밋
트랜잭션 1에서 A 데이터 다시 조회 → 2000으로 조회됨

1.1.4. 팬텀 읽기

트랜잭션1에서 A 데이터 갯수 조회 (1개)
트랜잭션2에서 A 데이터 신규 생성 후 커밋
트랜잭션1에서 A 데이터 갯수 재 조회 (1개 → 2개)

1.2. 동시성 문제 해결 방법

1.2.1. 불변 or 격리

불변 or 격리
불변 → 안바뀌게 만들면 됨
격리 → 작업공간은 나눠주는 것
트랜잭션
DB는 격리 수준을 제공함

1.2.2. 격리 수준 선택하기

트랜잭션 격리 수준과 발생 가능한 문제
격리 수준
팬텀 읽기
반복 불가능 읽기
더티 읽기
순실된 업데이트
READ_UNCOMMITTED
발생
발생
발생
방지
READ_COMMITED
발생
발생
방지
방지
REPEATABLE_READ
발생
방지
방지
방지
SERIALIZABLE
방지
방지
방지
방지
격리 수준 선택하기
정확성과 활동성 사이의 트레이드 오프
정확성이 낮은 READ_UNCOMMITED와 활동성이 낮은 SERIALIZABLE은 제외
JPA는 REPEATABLE_READ를 영속성 컨텍스트가 지원
따라서, DB는 READ_COMMITED를 써도 괜찮음

1.3. 낙관적 잠금

충돌 가능성이 낮은 경우 사용
커밋시점에 충돌 방지

1.3.1. 버전 번호를 활용한 버전 관리

버전을 이용하여 낙관적 잠금
@Entity @NoArgsConstructor public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Money fee; @Version private long version; }
Java
복사
예시
where 조건에 현재 버전을 명시하여 조건을 만족하는 경우에만 업데이트
@DataJpaTest(showSql = false) public class JpqlPersistenceContextTest { @Autowired private EntityManager em; @Test public void optimistic_lock_version() { Movie movie = new Movie("암살", 120, Money.wons(10000), null); em.persist(movie); em.flush(); em.clear(); Movie loadedMovie = em.find(Movie.class, movie.getId()); loadedMovie.changeFee(Money.wons(12000L)); em.flush(); } }
Java
복사
select m1_0.id, m1_0.policy_id, m1_0.fee, m1_0.title, m1_0.version from movie m1_0 where m1_0.id=1; update movie set discount_policy_id=1, fee=12000, title='암살', version=1 where id=1 and version=0;
SQL
복사
특정 컬럼이 업데이트하는 경우에 버전 번호를 증가시키지 않고 싶은 경우
@org.hibernate.annotations.OptimisticLock(excluded = true)
해당 컬럼 값만 업데이트 되는 경우 버전이 증가하지 않음

1.3.2. 수정시간을 이용한 버전 관리

버전 컬럼을 넣지 않고 수정 시간을 이용한 버전 관리
@Entity @NoArgsConstructor public class Movie { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Money fee; @Version @org.hibernate.annotations.UpdateTimestamp private LocalDateTime lastUpdate; }
Java
복사
예시
@DataJpaTest(showSql = false) public class JpqlPersistenceContextTest { @Autowired private EntityManager em; @Test public void optimistic_lock_version() { Movie movie = new Movie("암살", 120, Money.wons(10000), null); em.persist(movie); em.flush(); em.clear(); Movie loadedMovie = em.find(Movie.class, movie.getId()); loadedMovie.changeFee(Money.wons(12000L)); em.flush(); } }
Java
복사
select m1_0.id, m1_0.policy_id, m1_0.fee, m1_0.title, m1_0.last_update, m1_0.version from movie m1_0 where m1_0.id=1; update movie set discount_policy_id=1, fee=12000, title='암살', last_update='2024-11-26T10:30:36.612572' where id=1 and last_update='2024-11-26T10:30:36.569952';
SQL
복사
데이터베이스 수정 시간을 이용한 버전관리
source = SourceType.DB 사용
@org.hibernate.annotations.UpdateTimestamp(source = SourceType.DB)
select m1_0.id, m1_0.policy_id, m1_0.fee, m1_0.title, m1_0.last_update, m1_0.version from movie m1_0 where m1_0.id=1; update movie set discount_policy_id=1, fee=12000, title='암살', last_update=localtimestamp where id=1 and last_update='2024-11-26T10:30:36.569952';
SQL
복사

1.3.3. JPQL에 낙관적 락 설정하기

실제로는 거의 잘 쓰이지 않음
JPQL 조회 시 낙관적 락을 걸어서 동시성 문제를 예방할 수 있음
단순 조회로 합계를 계산하면 동시성 오류 발생 가능
setLockMode(LockModeType.OPTIMISTIC)을 사용하면,
조회한 순간의 version이 트랜잭션 끝날 때까지 유지되는지 확인
즉, 커밋 시점에 데이터가 변경되지 않았음을 보장
따라서 동시 수정/삭제에 안전한 조회 + 연산을 할 수 있음
예시)
JPQL로 Movie 목록 조회 후 총합 계산
이 시점에서는 단순 조회라서 동시성 문제가 발생할 수 있음
List<Movie> movies = em.createQuery("select m from Movie m", Movie.class) .getResultList(); long result = movies.stream() .mapToLong(m -> m.getFee().longValue()) .sum();
Java
복사
동시성 문제 발생 시나리오
누군가 id=3인 Movie를 삭제했다고 가정하면,
영속성 컨텍스트는 여전히 id=3 엔티티를 들고 있지만 DB에는 삭제되어 있음.
이 상태에서 합계를 구하면 총합 오류가 발생할 수 있습니다.
즉, 조회 시점과 실제 DB 상태가 달라져 불일치 문제가 발생합니다.
setLockMode()를 이용한 낙관적 락
락 모드를 OPTIMISTIC으로 지정하면,
트랜잭션 커밋 시점에 조회했던 엔티티들의 version 컬럼이 그대로인지 확인
만약 다른 트랜잭션에서 수정/삭제하여 version 값이 바뀌었다면 예외 발생
따라서 안전하게 합계를 계산할 수 있음
List<Movie> movies = em.createQuery("select m from Movie m", Movie.class) .setLockMode(LockModeType.OPTIMISTIC) .getResultList();
Java
복사
테스트 코드 예시
@Rollback(false) → 테스트 종료 후에도 DB 상태 유지 (버전 확인 가능).
JPQL 실행 후 SQL 로그를 보면, version 값을 재조회하는 쿼리가 실행되어, 트랜잭션 커밋 시점에 변경 여부를 확인합니다.
@DataJpaTest(showSql = false) public class JpqlPersistenceContextTest { @Autowired private EntityManager em; @Test @Rollback(value = false) // 테스트 끝나도 트랜잭션 롤백 안함 public void jpql_lock_mode() { em.persist(new Movie("영화1", 120, Money.wons(10000), null)); em.persist(new Movie("영화2", 120, Money.wons(10000), null)); em.flush(); em.clear(); List<Movie> movies = em.createQuery("select m from Movie m", Movie.class) .setLockMode(LockModeType.OPTIMISTIC) .getResultList(); long result = movies.stream() .mapToLong(m -> m.getFee().longValue()) .sum(); assertThat(result).isEqualTo(20000L); } }
Java
복사
현업에서 낙관적 락에 대한 의견
대부분의 사용자를 자기 데이터 1개만 업데이트하는 경우가 많음
동시성 이슈인데, 시스템상으로는 에러 처리처럼 보임
낙관적 락을 사용하기 보다는 상태를 넣어버리는게 나을수도 있음
사업적/기회적으로의 마인드 관점
동시성 꼭 처리안해도 된다.
가끔나오는 이슈를 보정 배치를 하는게 나을 수도 있다.
사업자의 관점에서 더 많이 팔리는게 좋을 수 도 있음.
마이너스 허용. 재고를 더넣자.

1.3.4. 애그리거트 단위로 낙관적 락

실제로는 거의 잘 쓰이지 않음
개별 클래스가 아닌 애그리거트 전체로 동시성 처리해야하는 경우
애그리거트 루트에 버전 추가
OPTIMISTIC FORCE INCREMENT
예시)
하위 엔티티가 변경될때도 루트의 버전이 변경됨
Movie movie = em.find(Movie.class, 1L, LockModeType.OPTIMISTIC_FORCE_INCREMENT); movie.getDiscountPolicy().changeName("새로운 할인 정책");
Java
복사

1.4. 비관적 쓰기 잠금

SELECT FOR UPDATE
성능 이슈 발생함
커밋될때까지 블락
LockModeType.PESSIMISTIC_WRITE
예시)
Movie movie = em.find(Movie.class, 1L, LockModeType.PESSIMISTIC_WRITE); movie.getDiscountPolicy().changeName("새로운 할인 정책");
Java
복사

1.5. 데드락 방지

1.5.1. 데드락 발생 가능성 예시

두 개의 트랜잭션이 동시에 다음과 같은 작업을 수행한다고 가정
// 트랜잭션 A Movie movie1 = em.find(Movie.class, 1L); Movie movie2 = em.find(Movie.class, 2L); movie1.changeFee(Money.wons(12000)); movie2.changeFee(Money.wons(15000));
Java
복사
// 트랜잭션 B Movie movie1 = em.find(Movie.class, 2L); Movie movie2 = em.find(Movie.class, 1L); movie1.changeFee(Money.wons(15000)); movie2.changeFee(Money.wons(12000));
Java
복사
SQL 실행 흐름
A: select ... where id=1select ... where id=2
B: select ... where id=2select ... where id=1
결과
A는 id=1 → id=2 순서로 update
B는 id=2 → id=1 순서로 update
이 경우 서로 서로가 가진 락을 기다리면서 데드락 발생

1.5.2. PK 기반 쿼리 정렬

Hibernate에는 업데이트 시 PK 순서대로 정렬해서 실행하는 기능 존재
application.yml에 다음을 설정
spring: jpa: properties: hibernate: order_updates: true
YAML
복사
효과
UPDATE 문이 항상 PK 기준 오름차순 정렬로 실행됨
따라서 모든 트랜잭션이 동일한 순서(id=1 → id=2)로 update를 실행
데드락이 아니라 단순한 락 대기(Blocking) 상황으로 전환됨
하나가 끝나면 다른 트랜잭션이 실행되므로 안전하게 처리 가능
정리
데드락은 트랜잭션들이 다른 순서로 락을 점유하려고 할 때 발생
Hibernate의 hibernate.order_updates=true 옵션을 사용하면,
모든 UPDATE 쿼리를 PK 순서대로 실행하여 데드락 대신 블로킹으로 해결
따라서 다중 트랜잭션 환경에서 안전하게 동시 업데이트 가능

2. 애그리거트 완성하기

2.1. 애그리거트와 레파지토리

트랜잭션과 데이터 저장/조회의 단위
레파지토리
애그리거트 단위로 세팅
비즈니스 로직과 연관
데이터 저장/조회를 위한 객체
복잡성 감소를 위해 ID를 이용한 참조
리드 모델
화면에 데이터를 말아서 화면에 주기위한 용도(리드 모델)는 리포지토리랑은 아무 상관은 없음

2.2. 연관관계와 탐색 가능성

연관관계
객체 참조 (강한 결합도)
제3의 객체를 통한 탐색 (약한 결합도)
연관관계는 한 객체를 통해 다른 객체에 다을 수 있음을 의미
애그리거트 외부의 관계는 끊고 Repository를 통해 조회하는게 나음
애그리거트는 캡슐화의 경계
애그리거트 외부 → 레파지토리를 통해 탐색
애그리거트 내부 → 객체 참조를 통해 탐색

2.3. 애그리거트 설정

애그리거트 루트에 엔티티 그래프 설정
애그리거트 전체를 로드하기 위해
패치 옵션은 LAZY로 설정
레포지토리 인터페이스에서 엔티티 그래프 사용
애그리거트를 로드하는 메서드에서는 엔티티 그래프 사용
Movie 애그리거트 전체 저장/삭제는?
영속성 전파 옵션 지정
cascade = CascadeType.ALL
orphanRemoval = true
동시성 처리는?
OPTIMISTIC_LOCK_MODE 잠금 모드 설정
근데 엔티티 그래프랑 같이 못씀
실제로는 잘안쓰임

2.4. 기타 내용 정리

애그리거트는 CUD(쓰기) 맥락에서만 다룬다. 조회는 UI/리포트 맥락으로 따로 분리.
도메인 모델(엔티티/밸류) 조회 모델(DTO/프로젝션) 을 의도적으로 분리하자. 중복은 의도되면 괜찮다.
DDD는 방법론·아키텍처가 아니라 마인드셋: 비즈니스 언어로 문제를 이해하고, 그 언어를 코드에 반영한다.

2.4.1. 조회 vs 애그리거트(쓰기) 분리

단순/복합 데이터 조회는 애그리거트와 무관하게 설계한다.
예: 어드민/리포트 화면은 애그리거트 규칙을 강제하지 않는다 → 읽기 전용 모델·SQL로 처리.
애그리거트는 불변식(invariant)을 지키며 상태를 변경(CUD)하는 경계다.
애그리거트 로직과 조회 로직을 섞지 않는다.

2.4.2. 안티패턴: 도메인 로직 클래스에 조회 참조 추가

도메인 객체(엔티티/밸류)가 “화면/리포트용 조회”를 위해 리포지토리나 DAO를 직접 참조하는 건 안티패턴.
조회는 트랜잭션 스크립트 패턴(SQL 자동화)으로, 앱/서비스 계층에서 처리
Spring Data JPA 프로젝션, 네이티브 SQL, QueryDSL, 뷰/머티리얼라이즈드 뷰 등을 사용.

2.4.3. 조직 내 JPA 표준 룰을 초기에 정하자

나중에 바꾸기 어렵다 → 초기에 합의할 항목 예시:
식별자 전략(예: Long + IDENTITY/SEQUENCE), 지연 로딩 기본(LAZY),
equals/hashCode 기준(주로 id), @Version 사용 여부,
연관관계 방향/영속성 전이 규칙, 컬렉션 사용 가이드,
엔티티명/컬럼명 네이밍, 엔티티 <-> DTO 매핑 정책,
읽기 전용 리포지토리/프로젝션 사용 원칙(네이티브 허용 범위 포함).

2.4.4. 조회용 모델 vs 도메인용 모델

등록/수정용 도메인 모델과 조회용 모델을 분리하라.
예: Product(도메인) vs ProductSummaryView, ProductDetailView(조회)
중복은 의도된 중복으로 허용(읽기 성능과 응집도↑, 도메인 보호).

2.4.5. 애그리거트 경계 설정 기준

같은 라이프사이클로 함께 생성/삭제되는가?
다른 루트로 별도의 불변식을 가지고 독립적으로 업데이트되는가?
그렇다면 별도 애그리거트로 분리.
한 트랜잭션에서 반드시 함께 일관성을 보장해야 한다면 같은 애그리거트.

2.4.6. “모델 주도 설계”는 어디까지?

리팩터링 가능한 단위까지 모델링하되 과도한 설계는 피한다.
DDD는 비즈니스를 중심으로 사고하고 대화하는 언어(유비쿼터스 언어)를 코드에 반영하는 마인드.
“패턴 전부 적용”이 목적이 아니라 문제 해결이 목적.

2.4.7. DDD 마인드 정리

요구사항을 가장 잘 만족하는 코드를 짠다. JPA는 도구일 뿐.
문제 정의가 우선: 도메인 전문가와 충분히 대화하고 용어 합의(유비쿼터스 언어).
변경이 왔을 때 어디를 바꿔야 하는지 빠르게 찾을 수 있게 모델/코드를 정렬한다.
기술에 집착하지 말고 비즈니스 규칙/불변식에 집중.

2.4.8. 언제 DDD를 얼마나?

방법론으로 접근? 아키텍처로 접근? → 풀 문제의 복잡도에 맞춰 “적정하게 끊어라.”
규칙/불변식이 단순, 보고서 위주의 시스템 → 트랜잭션 스크립트 + 조회 분리로 충분.
규칙/불변식이 복잡, 변경/확장이 잦음 → 애그리거트 중심의 DDD 채택.
개인/팀 경험: 애그리거트 수준만 가져가도 상당한 효과.