List
Search
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 채택.
•
개인/팀 경험: 애그리거트 수준만 가져가도 상당한 효과.