- 트랜잭션 내에 외부 API 호출(알림톡 발송 등)을 포함시키면, API 실패 시 전체 트랜잭션이 롤백되어 중요한 데이터(회원가입 정보 등)가 유실될 수 있다.
- 문제 해결을 위해서는 트랜잭션 범위를 적절하게 설정해야 하며, 핵심 데이터 저장과 외부 API 호출을 분리하는 것이 중요하다.
- 트랜잭션 사용 시 "이 모든 작업이 함께 성공/실패해야 하는지", "트랜잭션 내에 네트워크 I/O가 포함되어 있는지", "롤백 시 모든 작업이 안전하게 롤백 가능한지"를 고려해야 한다.
최근 진행중인 프로젝트에서 트랜잭션을 잘못 사용하여 생긴 문제를 실제 작성한 코드 케이스 별로 점검하고 개선한 과정을 정리해보겠습니다.
유저가 회원가입을 하고 웰컴메시지로 알림톡을 발송하는 기능을 만들어야 하는 요구사항이 있었다.
맨 처음 작성했던 코드를 대략적으로 복기해보면 다음과 같다.
@Service
@RequiredArgsConstructor
class UserService {
private final UserRepository userRepository;
private final AlimTalkManager alimTalkManager;
@Transactional
public void signUp(String userName, PhoneNumber phoneNumber, Age age) {
// 1. 입력값 유효성 검증 및 이미 존재하는 유저인지 확인
validateUserInput(userName, phoneNumber, age);
// 2. 유저 정보 저장
User user = new User(userName, phoneNumber, age);
userRepository.save(user);
// 3. 웰컴 메시지 발송
alimTalkManager.sendAlimTalk(phoneNumber);
}
}
언뜻보면 별 문제 없는 코드인것 같다. 유효성 검사를 수행하고, 사용자 정보를 저장한 후, 알림톡을 보내는 순서로 진행된다.
하지만 이 코드에는 심각한 문제가 숨어 있다. 만약 사용자 정보 저장은 성공했지만, 알림톡 발송 과정에서 외부 API 호출이 실패한다면 어떻게 될까? 놀랍게도, 정상적으로 처리된 사용자 정보 저장까지 롤백되어 회원가입 자체가 실패하게 된다. 이유는 무엇일까?
이 문제의 원인을 이해하려면 트랜잭션의 작동 방식과 범위에 대해 이해해야한다. 스프링의 @Transactional
어노테이션이 적용된 메서드가 호출되면, 해당 메서드의 시작부터 종료까지를 하나의 트랜잭션 단위로 처리하게 된다.
아래의 그림을 한번보면 더욱 직관적으로 이해가된다.
유저가 회원가입을 요청하면 userService.signUp()
메서드를 호출하게 되고,
signUp()
메서드에 걸려있는 @Transactional
에너테이션에 의해
메서드 시작과 동시에 트랜잭션이 시작되게 된다.
이때, DB에 저장하는 작업을 처리하기는 하지만 엄밀하게는 이때 DB에 유저 정보가 반영된 것은 아니다.
이유는 트랜잭션이 아직 끝나지 않아서 commit을 하지 않았기 때문이며,
엄밀하게는 회원의 정보는 Spring 내부 트랜잭션 컨텍스트에 캐싱되어 있는 상태로 존재하게 된다.
이후에 알림톡을 호출하는 작업을 하고 알림톡 발송에 성공하면 메서드가 종료되고,
트랜잭션도 commit되게 된다.
즉, 알림톡 발송 API 호출이 완료된 이후에야 회원의 정보가 저장되게 된다는 것이다.
외부 API 호출 같은 작업은 네트워크의 상태에 따라 언제든 실패할 수 있는 작업이며
만약, 실패하게 되면 userService.signUp()
메서드에 걸려있는 전체 트랜잭션이 롤백되게 되고 소중한 유저의 정보는 그대로 유실된다.
이는 다음과 같은 두 가지 심각한 문제를 초래한다.
- 중요한 비즈니스 로직(회원가입)의 성공이 외부 시스템(알림톡 서비스)의 상태에 의존하게 됨
- 사용자 경험 저하: 회원가입은 정상적으로 처리되었다고 생각했지만 실제로는 실패
트랜잭션은 데이터의 일관성을 보장하기 위한 메커니즘이지만, 과도하게 넓은 범위로 설정하면 오히려 시스템의 안정성을 해칠 수 있다. 특히 네트워크 I/O와 같은 불안정한 작업을 트랜잭션 범위에 포함시키는 것은 시스템의 신뢰성을 크게 저하시킬 수 있다.
어떻게 개선해야할까?
당장 단순한 해결책을 생각해보면 @Transactional
에너테이션을 제거하여
스프링 트랜잭션을 걸지 않고 처리를 하면 된다.
여기서 트랜잭션을 사용해야하는 이유에 대해 생각해보자.
트랜잭션이란 처리하는 데이터의 원자성을 보장하기 위한 하나의 논리적인 작업 단위를 의미한다.
이 메서드에서 일어나는 작업을 생각해보면 다음과 같다.
- 유저의 정보를 받는다.
- 이미 존재하는 유저의 정보인지 검증한다.
- 정보를 적절하게 변환한다.
- 유저의 정보를 저장한다.
- 알림을 발송한다.
여기서 일어나는 작업의 단위를 생각해보면
- 유저 회원가입 처리
- 알림 발송
이렇게 크게 두개의 단위로 나뉜다고 볼 수 있다.
그렇다면 1의 작업 단위와 2의 작업 단위를 각각 나누고 1의 작업 단위를 묶어주는게 필요하다고 생각하였다.
이유는, 회원의 정보를 추가할때 하나의 테이블에 인서트 할 뿐만 아니라
추가로 회원가입시 필요한 여러 추가적인 데이터를 다른 테이블에 같이 인서트 해야 했기 때문이다.
즉,
인서트 되는 데이터 쿼리 전체가 한번에 성공하거나 실패
해야 데이터 원자성이 보장되는 상황이다.
러프하게 예시를 들면, 회원가입시 아래의 데이터셋을 DB에 저장해야한다.
- Users 테이블에 핵심적인 회원 정보 저장
- UserProfile 테이블에 추가적인 정보 저장
- …
만약, 유저 정보를 저장하는 작업에 트랜잭션을 걸지 않으면 User 테이블에만 값이 들어가고
다른 테이블에는 값이 들어가지 않는 경우가 존재할 수 있다.
인서트하는 데이터가 하나의 튜플만 존재한다면 굳이 명시적 트랜잭션을 걸어서 관리할 필요는 없어 보이지만,
이 상황에서는 한번에 처리하는 유저의 데이터가 여러 테이블별로 나뉘어있기 때문에 하나의 단위로 묶는것이 필요하다.
따라서, 위의 코드를 다음과 같이 개선하였다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserManager userManager;
private final AlimTalkManager alimTalkManager;
public void signUp(String userName, PhoneNumber phoneNumber, Integer age) {
User user = userManager.saveSignUpUser(userName, phoneNumber, age);
// 3. 웰컴 메시지 발송
alimTalkManager.sendAlimTalk(user.getId());
}
}
@Component
@RequiredArgsConstructor
public class UserManager {
private UserRepository userRepository;
@Transactional
public User saveSignUpUser(String userName, PhoneNumber phoneNumber, Integer age) {
// 1. 입력값 유효성 검증
validateUserInput(userName, phoneNumber, age);
// 2. 유저 정보 저장
User user = User.builder()
.name(userName)
.age(age)
.phoneNumber(phoneNumber)
.build();
userRepository.save(user);
return user;
}
}
이 개선된 코드 구조는
- 유저 정보를 처리하는 부분을 하나의 단위로 묶고 메서드를 트랜잭션으로 묶어서 처리
- 알림 발송 메서드는 트랜잭션에서 분리
특히, UserService 내부에 메서드를 나눠서 만들지 않고 별도로 UserManager 를 만들어서 처리한 이유는
@Transactional
은 AOP 로 인해 프록시 객체를 만들어주게 된다.
같은 클래스 내에서 메서드를 직접 호출할 경우, 프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않는다. 따라서 트랜잭션 처리를 위해서는 별도의 클래스로 분리하여 스프링 빈 호출을 통해 프록시가 작동하도록 해야 한다.
(
스프링 트랜잭션 프록시
관련하여 자세한 내용은 하이퍼커넥트 기술블로그에 자세히 나와있다.)
개선된 코드의 I/O 장애 발생 시나리오를 그려보면 아래 그림과 같다.
해당 방식으로 트랜잭션과 I/O 작업을 분리하면 다음과 같이 I/O 작업이 실패하더라도 사용자 정보 저장은 트랜잭션을 통해 보장 해줄 수 있다.
앞서 살펴본 회원가입 사례 외에도, 다음과 같은 잘못 작성한 알림톡 발송 코드에서도 같은 문제가 발생 할 수 있다.
@Transactional
public AlimTalkLog sendAlimTalkAndSaveLog(UUID userId, AlimTalkFeignRequest alimTalkFeignRequest) {
SendFormMsgResponse sendFormMsgResponse = alimTalkApiClientManager.sendAlimTalk(alimTalkFeignRequest); // 알림톡 발송
AlimTalkLog alimTalkLog = createRequestLog(userId, alimTalkFeignRequest.getTemplateCode(), sendFormMsgResponse); // 알림톡 발송 api 호출 결과 저장
GetFormMsgResponse alimTalkMsgStatus = alimTalkApiClientManager.getSentAlimTalkMsgStatus(alimTalkLog.getMessageId()); // 알림톡 메시지 발송 결과 조회
return saveSentAlimTalkMsgStatus(alimTalkMsgStatus, alimTalkLog); // 알림톡 메시지 발송 결과 저장
}
이 코드는 다음과 같이 동작한다.
- 알림톡 발송 API 호출
- 알림톡 로그 엔티티 생성
- 알림톡 벤더사 서버에 해당 알림톡 발송 (성공/실패 여부)상태 조회 API 호출
- 로그에 상태 업데이트 후 DB 저장
이 코드의 실제 콜스택을 확인해보면 다음과 같다.
실제 콜스택을 분석해보면 다음과 같다.
트랜잭션 시작과 관리
: invokeWithinTransaction
메서드가 여러 번 호출되는 것을 볼 수 있다.
이는 스프링의 TransactionAspectSupport
클래스에서 @Transactional
어노테이션이 적용된 메서드를 실행할 때 호출되는 메서드로, 트랜잭션의 시작, 커밋, 롤백을 관리한다.
데이터베이스 연결 획득
: getConnection()
메서드가 HikariDataSource
에서 호출되는 것을 볼 수 있으며, 이는 트랜잭션이 시작될 때 데이터베이스 연결을 획득하는 과정임.
HTTP 통신 작업
: getOutputStream()
, getInputStream()
메서드들이 HttpsURLConnectionImpl
에서 호출되고 있으며, 이는 외부 API와의 HTTP 통신을 나타냄.
중요한 점은 이러한 HTTP 통신 메서드들이 invokeWithinTransaction
호출 이후, 즉 트랜잭션 내부에서 발생하고 있음
반복되는 트랜잭션 관리와 HTTP 통신
: 콜스택에서 invokeWithinTransaction
호출과 HTTP 통신 메서드 호출이 번갈아 나타나는 것을 볼 수 있으며, 이는 트랜잭션 내에서 여러 번의 외부 API 호출이 이루어지고 있음을 보여줌.
SQL 실행
: 마지막 부분에서 prepareStatement
와 executeUpdate
메서드가 호출되는 것을 볼 수 있으며, 이는 트랜잭션 내에서 데이터베이스 작업이 수행되고 있음을 나타냄
결국 이 코드에서도 하나의 트랜잭션에 외부 API I/O가 포함되어있는 좋지않는 패턴의 코드이다.
이 코드는 결국 하나의 튜플을 DB에 인서트하는 내용이므로 별도의 원자성을 보장해줄 필요가 없으므로 트랜잭션이 필요하지 않다고 생각하였다. (별도의 격리수준을 설정하기 위해서는 트랜잭션이 필요할수도 있으나 현재 상황에서는 필요하지 않았다.)
public AlimTalkLog sendAlimTalkAndSaveLog(UUID userId, AlimTalkFeignRequest alimTalkFeignRequest) {
SendFormMsgResponse sendFormMsgResponse = alimTalkApiClientManager.sendAlimTalk(alimTalkFeignRequest); // 알림톡 발송
AlimTalkLog alimTalkLog = createRequestLog(userId, alimTalkFeignRequest.getTemplateCode(), sendFormMsgResponse); // 알림톡 발송 api 호출 결과 저장
GetFormMsgResponse alimTalkMsgStatus = alimTalkApiClientManager.getSentAlimTalkMsgStatus(alimTalkLog.getMessageId()); // 알림톡 메시지 발송 결과 조회
return saveSentAlimTalkMsgStatus(alimTalkMsgStatus, alimTalkLog); // 알림톡 메시지 발송 결과 저장
}
따라서 다음과 같이 트랜잭션을 제거후 콜스택을 확인해보면
각각의 트랜잭션과 외부 API 호출 I/O 작업이 격리되었다. 하단의 그림을 보면 더욱 명확하다.
이제 중간에 API 호출에 실패하더라도 로그가 전체 날아가는 일이 없고 외부 API 호출 작업과 트랜잭션을 격리시킬수 있게 되었다.
평소 트랜잭션을 사용할때 트랜잭션의 원리와 사용 이유에 대해 정확히 알지 못하고 사용했던것 같고 이번 경험을 통해 트랜잭션에 대한 이해도가 조금은 올라가게 되었습니다.
특히, 앞으로 트랜잭션 사용시 다음과 같은 질문에 고민해보면 좋을것 같습니다.
Q : 이 트랜잭션이 포함하는 모든 작업이 함께 성공하거나 실패해야 하는가?
Q : 트랜잭션 내에 네트워크 I/O나 외부 시스템 호출이 포함되어 있는가?
Q : 트랜잭션의 롤백이 발생했을 때, 모든 작업이 안전하게 롤백 가능한가?