원인 로직
/**
* 결제 이력으로 매출 저장
* @param sttHist
*/
private void registerSalesFromApi( SttHist sttHist, String couponId, Long contractNo, Frame frame, BigDecimal quantity, PriceDTO price ,String windowTime ) {
CouponUsageScope couponUsageScope = null;
String salesId = null;
while( true ) {
salesId = generateSalesId( SalesDivision.INCOME );
if( !salesRepository.existsById( salesId ) )
break;
}
...
salesRepository.save( sales );
}
해당 로직 돌면서 salesRepository.save( sales ); 에서 장애 발생
✅ 1. 데드락 발생 가능 구간 요약
데드락 발생 지점:
while (true) { salesId = generateSalesId(SalesDivision.INCOME); if (!salesRepository.existsById(salesId)) break; }
salesRepository.save(sales);
가능성 높은 원인:
- salesId를 직접 생성하고 중복 체크를 수동으로 수행하는 구조
- 이 부분은 동시성 이슈로 데드락이 발생할 가능성이 매우 높음
이 코드는 동시에 여러 스레드에서 진입하면 existsById 쿼리와 save 쿼리가 충돌하거나 경합 가능성 높음.
✅ 2. 해결 방향 요약
① ID 자동 생성으로 전환 (@GeneratedValue, UUID 등)
가능하다면 salesId를 DB나 애플리케이션 수준에서 자동 생성되도록 변경.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long salesId;
또는 UUID 기반:
sales.setSalesId(UUID.randomUUID().toString());
→ existsById() 루프를 없애면 락과 경합 자체가 사라져 데드락 위험이 급감.
② 트랜잭션 분리 또는 시간 최소화
registerSalesFromApi() 안에서 너무 많은 데이터를 다루고 있기 때문에:
- 모든 DB 조회 (ctrRepository, natRepository, xrtRepository, cpnRepository)를 save 이전에 다 마친 후, 트랜잭션을 새로 시작하거나 또는 이 전체 로직을 @Transactional(propagation = REQUIRES_NEW)처럼 독립 트랜잭션으로 분리하는 것도 방법.
🔁 ③ 데드락 재시도 로직 추가
일시적인 데드락은 재시도로 해결됩니다. Spring에서 다음처럼 처리 가능:
@Retryable(
value = { LockAcquisitionException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
@Transactional
private void registerSalesFromApi(...) {
...
}
단, 위 두 가지 조치를 먼저 하고, 최후의 안전망으로 적용.
✅ 3. 추가 개선 제안
항목 개선 포인트
| generateSalesId() | 동시 요청에 안전하지 않음. Redis/DB 시퀀스 기반으로 개선 필요 |
| salesRepository.existsById() | 병목 가능성 있음. 가능한 한 제거 |
| save() 전 데이터 가공 | 너무 많은 DB 호출이 한 트랜잭션 안에 있음. 분리 고려 |
| 트랜잭션 길이 | 로직이 길고 복잡하여 락 점유 시간 길어짐 |
결론: 실전 리팩토링 방향
- salesId 직접 생성 로직 제거 (UUID 등으로 자동화)
- existsById() 제거로 DB 경합 감소
- @Retryable로 예외에 대비
- 트랜잭션 내 DB 호출 최소화 / 분리
- salesRepository.save() 전후를 잘게 나누어 트랜잭션 범위 조절
- salesId 값을 가져오는 곳에서 발생하는것으로 파악 generateSalesId()
while( true ) {
salesId = generateSalesId( SalesDivision.INCOME );
if( !salesRepository.existsById( salesId ) )
break;
}
private String generateSalesId( SalesDivision salesDivision ) {
return String.format("%s%s%012d", salesDivision.getCode().charAt(0),
LocalDateTime.now().format(DateTimeFormatter.ofPattern(CommonConstants.DATE_TIME_FORMAT_LONG_DATE)),
RandomUtils.nextLong(0, 999999999999L));
}
🔍 현재 방식의 문제점
java
복사편집
String.format("%s%s%012d",
salesDivision.getCode().charAt(0),
LocalDateTime.now().format(...),
RandomUtils.nextLong(0, 999999999999L)
);
- 충돌 가능성 있음: 동일한 salesDivision, LocalDateTime.now() (같은 시/초)에 여러 요청이 들어오면, Random 숫자가 우연히 중복될 수 있음.
- 중복 체크(existsById)를 수동으로 하므로 데드락 유발: 동시 다발적으로 들어오면 서로 경합하면서 DB 락 발생.
- 랜덤 + 타임스탬프 방식은 유니크 보장이 없음
해결 방안 3가지
1. UUID 사용 + 접두사
간단하고 확실하게 유니크한 ID 보장:
private String generateSalesId(SalesDivision salesDivision) {
return salesDivision.getCode().charAt(0) + UUID.randomUUID().toString().replace("-", "");
}
예시 출력:
I38f12bcb1efc4c5ea7d8b0cf627d92b1
👉 UUID는 전 세계적으로 중복 확률이 거의 0에 수렴하므로 existsById() 제거 가능. 데드락도 사라짐.
2. DB 시퀀스 또는 자동 증가 키 사용
만약 RDBMS(MySQL/MariaDB/PostgreSQL 등)에서 ID를 숫자로 관리하고 싶다면
- salesId를 @GeneratedValue(strategy = GenerationType.IDENTITY) 등으로 위임
- 혹은 시퀀스를 만들어 @GeneratedValue(strategy = SEQUENCE, generator = "...") 방식 사용
3. Redis를 활용한 전역 시퀀스 생성기
만약 특정 포맷 유지가 꼭 필요하다면, Redis의 atomic increment를 사용하는 방법도 있음:
// Redis key: SALES_ID_SEQ:20250509
Long seq = redisTemplate.opsForValue().increment("SALES_ID_SEQ:" + LocalDate.now());
String salesId = String.format("I%s%06d", LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")), seq);
이 방식은 중복 없음 + 포맷 유지 가능 + 성능 빠름
단, Redis 연결 필요
📌 결론 및 적용 권장
방식 장점 단점 추천도
| UUID | 유니크 보장, 간단 | 가독성 떨어질 수 있음 | ⭐⭐⭐⭐⭐ |
| DB 시퀀스 | 자동 관리, 중복 없음 | ID 포맷 제한 | ⭐⭐⭐⭐ |
| Redis 시퀀스 | 포맷 유지 + 빠름 | Redis 도입 필요 | ⭐⭐⭐⭐ |
| 랜덤+타임 | 중복 위험, 데드락 위험 | 불안정 | ❌❌❌ |
결론
uuid 방식으로 변경
'Develop > Java(JPA)' 카테고리의 다른 글
| Cache#2 캐시에 대한 이해 (1) | 2025.02.24 |
|---|---|
| Cache#1 스프링의 캐시 추상화(@Cacheable) (0) | 2025.02.24 |
| [Java] Stream() 과 parallelStream() 차이점 (0) | 2025.02.18 |
| [JPA] JPA save() 반복 호출로 DB 커넥션 과부하 해결 (0) | 2025.02.18 |
| [JPA] for문으로 save() 할 경우 생기는 이슈 및 처리 (0) | 2025.02.18 |