Spring 프레임워크에서 @Transactional은 데이터베이스 트랜잭션을 관리하는 도구입니다. 하지만 이 어노테이션을 사용할 때 주의해야 할 함정 중 하나가 바로 자기 호출(Self-Invocation) 문제입니다. 이번 글에서는 이 이슈가 무엇인지, 왜 발생하는지, 그리고 어떻게 해결할 수 있는지 알아보겠습니다.
@Transactional의 동작 원리
Spring의 @Transactional은 AOP(Aspect-Oriented Programming)를 기반으로 동작합니다. Spring은 @Transactional이 붙은 메서드를 프록시 객체로 감싸서 트랜잭션 시작과 종료를 관리합니다. 즉, 메서드가 호출될 때 프록시가 개입하여 트랜잭션을 열고, 메서드 실행이 끝나면 커밋 또는 롤백을 처리합니다.
하지만 이 프록시 기반 메커니즘 때문에 한 가지 제약이 생깁니다: 프록시를 거치지 않는 호출은 @Transactional이 적용되지 않는다는 점입니다.
자기 호출(Self-Invocation)이란?
자기 호출은 같은 클래스 내에서 @Transactional이 붙은 메서드를 직접 호출하는 상황을 말합니다. 예를 들어, 아래 코드를 보겠습니다.
@Servic
@RequierdArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
public void importAllData() {
// 데이터 가져오기 로직
for (int i = 0; i < 10; i++) {
saveData(i); // 내부 메서드 호출
}
}
@Transactional
public void saveData(int data) {
storeRepository.save(new Store(data));
}
}
위 코드에서 importAllData()는 saveData()를 호출합니다. saveData()에 @Transactional이 붙어 있으니 트랜잭션이 적용될 것 같지만, 실제로는 그렇지 않습니다.
문제의 원인
- importAllData()가 saveData()를 호출할 때, Spring 프록시를 거치지 않고 클래스 내부에서 직접 호출합니다.
- 프록시는 외부에서 메서드를 호출할 때만 동작하므로, 내부 호출에서는 @Transactional이 무시됩니다.
- 결과적으로 saveData()는 트랜잭션 없이 실행되고, 예외 발생 시 롤백도 보장되지 않습니다.
문제 재현 예시
다음은 실제로 문제를 재현한 코드입니다.
@Service
@RequiredArgsConstructor
public class OpenApiService {
@Value("${openapi.seoul.serviceKey}")
private String serviceKey;
private final ShoppingMallRepository shoppingMallRepository;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public int importAllOpenApiData() {
String url = String.format("http://openapi.seoul.go.kr:8088/%s/json/ServiceInternetShopInfo/1/1", serviceKey);
String response = restTemplate.getForObject(url, String.class);
int totalCount = getTotalCount(response);
int pageSize = 1000;
int totalInserted = 0;
for (int start = 1; start <= totalCount; start += pageSize) {
int end = Math.min(start + pageSize - 1, totalCount);
totalInserted += importOpenApiData(start, end); // 자기 호출
}
return totalInserted;
}
@Transactional
public int importOpenApiData(int start, int end) {
String url = String.format("http://openapi.seoul.go.kr:8088/%s/json/ServiceInternetShopInfo/%d/%d", serviceKey, start, end);
String response = restTemplate.getForObject(url, String.class);
List<ShoppingMall> shoppingMalls = parseJson(response);
shoppingMallRepository.saveAll(shoppingMalls);
return shoppingMalls.size();
}
// parseJson, getTotalCount 메서드 생략
}
해결 방법
자기 호출 문제를 해결하는 방법은 몇 가지가 있습니다. 아래에서 주요 접근법을 소개합니다.
1. @Transactional을 상위 메서드로 이동
가장 간단한 해결책은 @Transactional을 호출하는 상위 메서드에 붙이는 것입니다.
@Service
@RequiredArgsConstructor
public class OpenApiService {
@Transactional
public int importAllOpenApiData() {
String url = String.format("http://openapi.seoul.go.kr:8088/%s/json/ServiceInternetShopInfo/1/1", serviceKey);
String response = restTemplate.getForObject(url, String.class);
int totalCount = getTotalCount(response);
int pageSize = 1000;
int totalInserted = 0;
for (int start = 1; start <= totalCount; start += pageSize) {
int end = Math.min(start + pageSize - 1, totalCount);
totalInserted += importOpenApiData(start, end);
}
return totalInserted;
}
public int importOpenApiData(int start, int end) {
String url = String.format("http://openapi.seoul.go.kr:8088/%s/json/ServiceInternetShopInfo/%d/%d", serviceKey, start, end);
String response = restTemplate.getForObject(url, String.class);
List<ShoppingMall> shoppingMalls = parseJson(response);
shoppingMallRepository.saveAll(shoppingMalls);
return shoppingMalls.size();
}
}
- 장점: 전체 작업이 하나의 트랜잭션으로 묶여 일관성이 보장됩니다.
- 단점: 데이터가 많으면 트랜잭션이 너무 커질 수 있습니다.
2. 별도의 서비스 클래스로 분리
메서드를 다른 클래스로 분리하면 프록시가 정상적으로 동작합니다.
@Service
@RequiredArgsConstructor
public class OpenApiService {
private final DataImportService dataImportService;
public int importAllOpenApiData() {
// ... totalCount 계산 로직
int totalInserted = 0;
for (int start = 1; start <= totalCount; start += pageSize) {
int end = Math.min(start + pageSize - 1, totalCount);
totalInserted += dataImportService.importOpenApiData(start, end);
}
return totalInserted;
}
}
@Service
@RequiredArgsConstructor
public class DataImportService {
@Transactional
public int importOpenApiData(int start, int end) {
// 데이터 가져와서 저장하는 로직
}
}
- 장점: 각 호출마다 독립적인 트랜잭션이 생성되어 대량 데이터 처리에 유리합니다.
- 단점: 코드가 분리되므로 관리 포인트가 늘어납니다.
결론
@Transactional의 자기 호출 문제는 Spring의 프록시 기반 동작 방식에서 비롯됩니다. 이를 해결하려면 트랜잭션 범위를 상위 메서드로 옮기거나, 로직을 별도 클래스로 분리하거나, 프로그래밍 방식으로 트랜잭션을 관리해야 합니다. 프로젝트의 요구사항(데이터 크기, 일관성 필요 여부 등)에 따라 적절한 방법을 선택하세요.
혹시 이 문제로 고민 중이라면, 위 예시를 참고해 테스트해보고 최적의 해결책을 찾아보시길 바랍니다!
'TIL' 카테고리의 다른 글
아웃 소싱 프로젝트를 진행하며 고민했던 것들 (0) | 2025.03.04 |
---|---|
오늘 한 일 (0) | 2025.02.26 |
Service의 의존성 관계 : Service간 의존이 좋은가? Repository 의존이 좋은가? (0) | 2025.02.24 |
HttpMessageConverter (0) | 2025.02.21 |
뉴스피드 프로젝트 후기 (0) | 2025.02.20 |