Search
moon
sun

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=1 → select ... where id=2
◦
B: select ... where id=2 → select ... 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 채택.
•
개인/팀 경험: 애그리거트 수준만 가져가도 상당한 효과.