일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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
- Spring Security
- 클린아키텍처
- Garbage Collection
- EntityGraph
- JPA
- TDD
- 배낭문제
- spring
- EffectiveJava
- 타임리프
- 멱등성
- 자바
- effective java
- cache
- 이펙티브자바
- 파이썬
- Transactional
- 동시성처리
- 캐시
- thymeleaf
- collapse
- @Transactional
- AOP
- JVM
- lombok
- 코딩테스트
- interceptor
- 알고리즘
- BFS
- Today
- Total
Jinnie devlog
@Transactional 남용 줄이기 도전기 - 두 번의 삽질 본문
커머스 시스템의 주문 로직에 쿠폰 기능을 추가하면서, 주문 과정에 필요한 트랜잭션이 점점 많아졌다.
그동안은 거의 모든 서비스 클래스에 @Transactional을 붙여 사용했고, 특별한 문제는 없었다.
그런데 찾아보니 무분별한 트랜잭션 사용은 성능 저하와 의도치 않은 트랜잭션 경계를 만들 수 있다는 사실을 알게 됐다.
그래서 “가능한 한 @Transactional을 빼보자”는 마음으로 무작정 제거 후 테스트를 돌렸다가, 두 번의 힘든 경험을 했다.
첫번 째 경우 - 좋아요 기능의 TOCTOU(체크-사용 사이 레이스)
public LikeInfo like(LikeCommand.Create likeCommand) {
final LikeEntity likeEntity = LikeEntity.of(likeCommand.userId(), likeCommand.productId());
return likeRepository.find(likeEntity.getUserId(), likeEntity.getProductId())
.map(LikeInfo::from)
.orElseGet(() -> {
LikeEntity saved = likeRepository.save(likeEntity);
return LikeInfo.from(saved);
});
}
테스트에서 좋아요 함수를 호출 시, 500 오류가 발생했다.
원인은 두 스레드가 동시에 같은 (userId, productId)로 좋아요 요청을 보내면, 둘 다 find()에서 “없음”을 확인하고 동시에 save()를 실행 → 유니크 키 제약 위반이 발생하는 것이었다.
@Transactional이 빠져 있어서 예외 발생 시 롤백 처리가 안 되거나, 세션이 분리돼서 동시성 체크가 깨지면서 500 오류가 났다.
- 원인 1: find → save 패턴에서 발생하는 동시성 레이스
- 원인 2: 트랜잭션 누락으로 인한 예외/세션 처리 불안정
- 결과: 유니크 제약 위반이 멱등하게 처리되지 않고 500 반환
이런 상황을 TOCTOU(Time Of Check to Time Of Use)라고 한다고 한다.
즉, “조건을 확인(check)”한 시점과 “실제로 사용(use)”하는 시점 사이에 다른 쓰레드가 개입해 상태가 바뀌어 버리는 문제다.
개선 - 유니크키 지정 / 저장 먼저, 예외로 멱등 처리
@Table(
name = "likes",
uniqueConstraints = @UniqueConstraint(name = "uk_like_user_product", columnNames = {"user_id", "product_id"})
)
@Transactional
public LikeInfo like(LikeCommand.Create cmd) {
LikeEntity entity = LikeEntity.of(cmd.userId(), cmd.productId());
try {
LikeEntity saved = likeRepository.save(entity); // 먼저 저장 시도
return LikeInfo.from(saved);
} catch (DataIntegrityViolationException e) {
// 이미 존재 → 멱등 처리
return likeRepository.find(cmd.userId(), cmd.productId())
.map(LikeInfo::from)
.orElseGet(() -> LikeInfo.liked(cmd.userId(), cmd.productId()));
}
}
왜 find → save는 멱등하지 않을까?
- 두 요청이 동시에 “없음”을 보고 save() → 한쪽은 성공, 다른 쪽은 예외 발생. 예외를 처리하지 않으면 500 응답.
왜 save 먼저 하면 멱등이 되는가?
- DB의 유니크 제약이 원자적으로 보장.
- 첫 요청만 성공적으로 INSERT, 나머지는 예외 발생.
- 예외를 잡아 “이미 좋아요 상태”로 응답하면, 결과 상태와 응답 의미가 항상 동일.
비유
- find → save: “문에 자물쇠 있나?” 동시에 보고 “없네” → 같이 달다 한 명은 성공, 한 명은 충돌.
- save 먼저: 그냥 자물쇠 달기 시도. 첫 사람만 성공, 나머지는 “이미 잠겨 있음”이라고 응답. 결과는 항상 ‘잠김’ 상태 하나.
두번 째 경우 - LazyInitializationException
OrderInfo orderInfoTest= orderService.getOrder(orderId);
//OrderService
public List<OrderInfo> getOrders(Long userId) {
var orders = orderRepository.findByUserId(userId);
return orders.stream()
.map(OrderInfo::from)
.toList();
}
테스트 코드 작성 중, 위에 코드에서 아래와 같은 오류가 났다.
org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role:
com.loopers.domain.order.OrderEntity.orderItems:
could not initialize proxy - no Session
왜 쓰기 함수도 아닌 읽기 함수에???
찾아보니, 서비스 메서드에 @Transactional이 없어서 트랜잭션(=영속성 컨텍스트/세션) 경계가 너무 빨리 닫혀서 생기는 문제였다.
- @Transactional이 없으면, 리포지토리 메서드가 끝나는 순간 세션이 닫힌다.
- 이후 LAZY 필드를 접근하면 영속성 컨텍스트가 없어 예외 발생.
왜 @Transactional이 없으면 LazyInitializationException이 발생할까?
- @Transactional이 붙은 메서드는 메서드 시작 시 트랜잭션을 시작하고, 영속성 컨텍스트(=Hibernate Session)를 현재 쓰레드에 바인딩한다.
- 메서드가 정상 종료되면 커밋/flush, 예외면 롤백하며, 그 동안 같은 메서드 실행 중에는 동일한 영속성 컨텍스트를 유지한다.
- 이 덕분에 LAZY 로딩이 필요한 순간 세션이 살아 있어 엔티티를 불러올 수 있다.
반대로 @Transactional이 없으면:
- orderRepository.findByUserId()를 호출하는 순간 잠깐 세션이 열렸다가, 리포지토리 메서드가 끝나면 바로 닫힌다.
- 이후 map(OrderInfo::from)에서 order.getOrderItems()처럼 LAZY 컬렉션을 접근하면, 이미 세션이 없으므로 LazyInitializationException이 발생한다.
@Transactional(readOnly = true)
public List<OrderInfo> getOrders(Long userId) {
var orders = orderRepository.findByUserId(userId);
return orders.stream()
.map(OrderInfo::from)
.toList();
}
읽기 메서드에도 @Transactional(readOnly = true)를 붙이니 정상 동작했다.
우선은 깊이 있게 알지 못해서 클래스 전체에 @Transactional을 붙이지 않고, 읽기 메서드: @Transactional(readOnly = true) / 쓰기 메서드: @Transactional (기본, readOnly=false) 이렇게 설정하긴했는데 이렇게 단순하게 설정해서 되는게 아닌 것 같다.
또 실무에서는 성능문제 등 여러가지 문제로 사용을 지양하라고 하니 어떤 문제가 있는지 찾아보았다.
@Transactional의 단점 — 무분별 사용을 지양해야 하는 이유
- 트랜잭션 범위 확대
- 읽기 전용 로직까지 트랜잭션에 포함 → DB 커넥션 장시간 점유, 락 대기 증가.
- Lazy 로딩 과도 허용
- 트랜잭션이 열려 있으니 N+1 쿼리 문제를 감지하기 어려워짐.
- 불필요한 쓰기 가능 모드
- 단순 조회에도 flush 검사 수행 → 성능 손실.
- 전파 옵션 실수
- REQUIRED/REQUIRES_NEW 설정 실수 시 의도치 않은 롤백/커밋.
- Self-invocation 미적용
- 같은 클래스 내부 메서드 호출 시 프록시를 거치지 않아 @Transactional이 적용 안 됨.
- 불필요한 동시성 대기
- 긴 트랜잭션 유지 시 다른 스레드 접근 차단, 특히 비관적 락과 결합 시 영향 큼.
마무리
이번 경험을 통해 @Transactional은 붙이기만 하면 끝이 아니라, 꼭 필요한 경계에만 적용해야 한다는 걸 배웠다.
또, 동시성 처리에서는 DB 제약 조건과 예외 처리 흐름까지 고려해야 멱등성을 지킬 수 있다는 점도 깨달았다.
더 공부해야지...
JPA Transactional 잘 알고 쓰고 계신가요? | 카카오페이 기술 블로그
JPA Transactional 잘 알고 쓰고 계신가요? | 카카오페이 기술 블로그
JPA Transactional과 그에 따른 DB 쿼리 성능과의 관계에 대해서 설명합니다.
tech.kakaopay.com
'교육' 카테고리의 다른 글
외부 시스템 연동 지연, 장애, 실패 대응 (Failure-Ready Systems) (0) | 2025.08.22 |
---|---|
10만건 조회 성능 개선 시도 (인덱스, 캐싱) (4) | 2025.08.15 |
WIL - 3주차 (Domain Modeling) (1) | 2025.08.03 |
WIL - 2주차 (Software Design) (0) | 2025.07.25 |
좋아요 토글 API, REST스럽게 만들기 (3) | 2025.07.25 |