정리
항목 내용
| 문제 | 다중 스레드에서 최대 등록 수를 초과한 참치캔 등록 |
| 원인 | 트랜잭션 경계, 낙관적 락 미적용, 내부 호출로 인한 @Retryable 미동작 |
| 해결 | @Version을 통한 낙관적 락 + 외부 서비스에서 @Retryable 호출 + RetryListener로 로그 검증 |
0. 서론
현재 서비스 구조는 다음과 같다.
- 사용자는 사전에 전달받은 “시리얼 코드”를 통해 “참치캔” 등록 승인을 받는다.
- 등록 승인 시에는 “시리얼 코드”와 “기기 ID”를 사용한다.
- 하나의 “시리얼 코드”에는 최대 n개의 기기 ID가 등록될 수 있다.
- n개 이상일 경우 서버는 해당 “참치캔” 등록을 거부한다.
- 이미 등록된 기기 ID를 가진 참치캔만 다른 기기 ID를 삭제할 수 있다.
이런 상황에서 동시에 다수의 등록 요청이 들어오면 최대 수를 초과해서 기기 ID가 등록될 수도 있겠다는 의심이 들어, 검증을 진행해보기로 했다.
1. 테스트 환경 조성
멀티스레드 환경을 구성하기 위해 Executors, CountDownLatch 등을 사용했다.
@Test
@Transactional
@DisplayName("동시에 n개의 참치캔 등록 요청")
void testConcurrency() throws InterruptedException {
int n = 20;
final ExecutorService executorService = Executors.newFixedThreadPool(5);
final CountDownLatch latch = new CountDownLatch(n);
SerialCode serialCode = serialCodeService.create(); // 시리얼 코드 생성
for (int i = 0; i < n; i++) {
final int index = i;
executorService.execute(() -> {
try {
tunaCanService.registerTunaCan(
new TunaCanRegisterRequest(serialCode.getCode(), "device-" + index, "location"));
} finally {
latch.countDown();
}
});
}
latch.await();
SerialCode result = serialCodeRepository.findByCode(serialCode.getCode()).get();
assertFalse(result.getRegisteredDeviceCount() <= result.getMaxDeviceCount());
}
1-1. 트랜잭션과 스레드풀
@Transactional은 메인 스레드에만 적용되고, Executor로 실행한 내부 스레드에는 적용되지 않는다.
그래서 테스트 중 serialCode가 DB에 반영되지 않았다는 에러가 발생했다.
flush로 해결하려 했지만, flush는 SQL을 보내긴 해도 실제 커밋되진 않기 때문에 반영되지 않은 상태로 유지됨. 결국 실패.
(flush와 commit의 차이점)
1-2. Transactional 제거
그래서 테스트 메서드에서는 트랜잭션을 제거하고 단순한 객체 등록으로 진행.
@Test
@DisplayName("참치캔 등록 - 최대치 초과 시도")
void testRegisterOverMax() throws InterruptedException {
int n = 20;
final ExecutorService executorService = Executors.newFixedThreadPool(5);
final CountDownLatch latch = new CountDownLatch(n);
SerialCode serialCode = SerialCode.builder()
.maxDeviceCount(5)
.build();
for (int i = 0; i < n; i++) {
final int index = i;
executorService.execute(() -> {
try {
serialCode.registerDevice(new Device(serialCode, "device-" + index, "location"));
} finally {
latch.countDown();
}
});
}
latch.await();
assertEquals(5, serialCode.getDevices().size());
}
이제 예상했던 대로 동시성 문제로 인하여 초과등록을 확인할 수 있었다.

setup에서 새로운 ID를 생성하고 (Transactional) , 테스트 클래스의 로컬 변수로 해당 ID를 저장한다.
test에서는 해당 id를 기준으로 서비스레이어에 서비스를 요청하게되는데,
스레드풀에서는 해당 ID가 존재하지 않는다는 오류를 발생시킨다.
해당 부분은 내가 setup의 @BeforeEach 어노테이션에 대한 이해부족으로 발생한 것이라 생각된다.
BeforeEach 에서의 작업 또한 메인 테스트의 트랜잭션 범위에 같이 묶이게 되는 것이다.
Test와 관련된 몇가지 글을 찾아본 결과 “테스트 시에는 Transactional이 반드시 필요없다면 사용하지 않는 것이 좋다는 생각이 들었다.
현 상황에서는 각 서비스마다 트랜잭션 설정이 되어있으므로 해당 부분을 제거하도록 하였다.
1-3. 트랜잭션 + Lazy 로딩 문제
테스트에서는 @Transactional이 없기 때문에 Lazy 로딩된 관계를 조회할 수 없어서 오류 발생.
따라서 반환 타입을 DTO로 바꾸고 트랜잭션을 서비스 내부에서만 사용하도록 설계 변경
1-4. 결론
테스트 결과, 동시성 문제로 인해 최대치보다 많은 기기 ID가 등록될 수 있음을 확인함.
2. 동시성 해결 전략
선택한 방법: 낙관적 락
- @Version을 SerialCode 엔티티에 추가
- @Lock(OPTIMISTIC)으로 레포지토리 메서드에 락 적용
- 동시 등록 시 충돌이 발생하면 OptimisticLockException을 발생시킴
낙관적 락은 드문 충돌 상황에 적합하고, 우리 서비스 구조상 다중 서버/높은 트래픽 환경은 아님 → 적합함 판단.
3. 재시도 전략
충돌이 발생했지만 아직 최대 등록 가능 수를 초과하지 않은 경우엔 재시도를 하면 정상 등록될 수 있다.
그래서 재시도 전략을 추가.
의존성 추가
implementation 'org.springframework.retry:spring-retry'
재시도 어노테이션
@Retryable(
retryFor = {OptimisticLockException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000) // 1초 대기 후 재시도
)
public void registerTunaCan(...) {
...
}
3-1. 재시도 검증
최대 등록 가능 갯수를 20개로 설정하였고,
재시도 횟수를 늘리면 정상 등록될 것이라 기대했으나, 항상 6개 정도만 등록됨 → 이상함.
AOP 기반이기 때문에 내부 호출에서는 @Retryable이 적용되지 않음 → 외부에서 호출되도록 구조 수정.
→ TunaCanComponentService 등 외부 빈에서 호출하도록 변경 후 해결.
3-2. RetryListener 로깅
정상적으로 재시도가 일어나는지 로그로 확인하기 위해 RetryListener 구현:
@Component
@Slf4j
public class TunaCanRetryLogListener implements RetryListener {
@Override
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
log.warn("🔁 Retry 시작: {}", context.getRetryCount());
return true;
}
@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.error("❌ Retry 실패 ({}회차): {}", context.getRetryCount(), throwable.toString());
}
@Override
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.info("✅ Retry 종료");
}
}
로그로 확인한 결과, 재시도는 정상적으로 수행되고 있음을 확인.
ps. 코드를 직접 노출할 수 없어 일부 변형이 들어갔으며, 그로 인해 동작이 원할하지 않을 수 있다.
'날것 그대로의 공부기록 > 날 것 그대로 일지' 카테고리의 다른 글
| python에서 멀티스레드 적용하기 (1) | 2025.08.04 |
|---|---|
| 컨트롤러만 테스트하기 (2) | 2025.08.04 |
| github action 자동배포 (3) | 2025.08.04 |
| 23년 6월 18일 생각들 (0) | 2023.06.18 |