회사에서 주문 관련(결제, 취소, 환불 등) 알림톡 기능을 개발했다.
별 문제 없이 상용환경에 배포했고, 잘 동작했다.
그리고 추가 알림톡 기능을 개발하면서 개발환경에서 테스트 해보던 중,
알림톡 관련 로직에서 NPE가 발생했고 주문까지 롤백되어 취소됐다.
주문과 알림톡 발송이 한 서비스 로직안에 하나의 트랜잭션으로 묶여있었기 때문인데
알림톡이 발송되지 않아도 사용자의 편의성이 떨어질 뿐, 주문하는데 문제는 없다.
그러므로 알림톡이 주문에 영향을 미쳐서는 안된다.
이를 해결하기 위해 마침 YAPP 프로젝트에서 비슷한 요소가 있어서
YAPP 프로젝트에 먼저 적용해보고, 회사 프로젝트에 적용해 보고자 한다.
위에서도 이야기 했지만, 부가적인 기능의 실패로 핵심 기능에 영향을 미쳐서는 안된다.
하지만 반대로 핵심 기능이 실패하면, 부가적인 기능도 같이 실패해야 한다.
핵심 기능(주문)이 실패했는데, 부가적인 기능(알림톡 발송)이 성공하면 안된다.
또한 단순 코드레벨에서의 에러뿐만 아니라, 알림톡을 발송하는 서버(외부 요인)에서 장애가
발생한다면, 알림톡 발송이 실패할 것이고 이 영향으로 쇼핑몰(본래의 서비스)에서
주문까지 실패하게 된다. (알림톡때문에 주문이 실패해서는 안된다.)
예로 주문 로직을 처리하는 시간이 5초, 알림톡을 발송하는 로직이 10초 걸렸다고 해보자.
사용자가 주문하는데 총 15초를 기다려야 하는 셈이다.
핵심 기능(주문)은, 부가적인 기능(알림톡 발송)을 기다릴 필요가 없다.
위에서 주문과 알림톡을 예로 이야기 했지만, 적용해볼 프로젝트의 상황은 아래와 같다.
구현해야 하는 기능
핵심 기능
부가적인 기능이자 외부 요소
정책(요구사항)
회원가입 진행 시, 사용자는 자신의 프로필 이미지를 등록할 수 있다.
이 때 이미지 등록이 실패하더라도, 회원 정보 등록은 성공해야 한다. (알림톡 전송이 실패해도, 주문은 완료되어야 하기 때문에)
반대로 회원 정보 등록이 실패하면, 이미지 등록도 실패해야 한다.
(주문이 실패했는데 주문 완료 알림톡이 전송되는 상황은 피해야 하기 때문에)
이런것들은 기획에 따라 달라질 수 있는 내용이지만, 이런 정책이 있다고 가정했다.
회원 정보 등록 실패시, 이미지 저장도 실패해야 한다.(필수)
- 지금은 한 트랜잭션에 묶여있기 때문에 1번 조건은 만족한다.
- 이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다. (필수)
- 회원 가입 완료 이후에 이미지를 저장한다. (선택)
@RestController
public class AccountController {
...
@PostMapping("회원가입")
public void signUp(){
응답 = 회원 정보 등록();
if(응답 성공하면){
이미지저장();
}
}
}
성능문제는 해결하지 못하지만 필수 조건 1,2번을 만족한다.
회원 정보 등록 실패시, 이미지 저장도 실패해야 한다.(필수)이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다.(필수)- 회원 가입 완료 이후에 이미지를 저장한다. (선택)
두 번째로 떠오른 생각은, 한 트랜잭션으로 묶여있는게 문제니까 트랜잭션만
분리해주면 되지 않을까? 라는 생각이 들었다.
스프링의 @Transactional
은 여러 전파 옵션을 제공해주는데
요구사항을 만족시킬 수 있는 옵션이 있는지 살펴보자
부모 트랜잭션이 있어도 이를 무시하고, 무조건 새 트랜잭션을 생성한다.
호출한 곳(부모), 호출된 곳(자식) 두 트랜잭션이 독립적으로 동작한다.
따라서 롤백도 전파되지 않는다.
현재는 구조가 간단해서 별 문제 없어 보이지만, 회원 정보 등록 이후
별도의 과정이 더 있다고 생각해보면, 새 트랜잭션B가 생성된 이후에
트랜잭션 A에서 에러가 발생해도, 독립적인 트랜잭션 B를 제어할 수 없다.
즉 회원 정보 등록 실패시, 이미지 저장도 실패해야 한다
는 조건을 만족시킬 수 없다.
부모 트랜잭션이 존재하면, 중첩 트랜잭션을 시작한다.
REQUIRES_NEW
옵션 처럼 독립적인 새 트랜잭션을 만드는 것과는 다르다.REQUIRES_NEW
과 다른점은 중첩된 트랜잭션은 부모 트랜잭션의 커밋과 롤백에는 영향을
받지만 중첩된 트랜잭션의 커밋과 롤백은 부모 트랜잭션에게 영향을 미치지 않는다.
(부모 → 자식 단방향)
따라서 회원 정보 등록 실패시, 이미지 저장도 실패해야 한다
, 이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다
는 두 필수 조건을 만족시킬 수 있다.
단 주의점은 SAVEPOINT를 지정한 시점까지만 롤백이 가능하다.
즉 SAVEPOINT 기능을 지원하는 DBMS만 사용 가능한 기능이다.
그러나 문제는 문서에 따르면 hibernate는 중첩된 트랜잭션을 지원하지 않는다.
중첩된 트랜잭션을 위해 JDBC 3.0과 SAVEPOINT를 지원하는 DBMS를 이용해야 한다.
JDBC 3.0 사용시 성능문제는 해결하지 못하지만 필수 조건 1,2번을 만족한다.
회원 정보 등록 실패시, 이미지 저장도 실패해야 한다.(필수)이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다.(필수)- 회원 가입 완료 이후에 이미지를 저장한다. (선택)
ApplicationEventPublisher (Spring Framework 5.3.22 API)
Spring에서 어노테이션을 기반으로 이벤트 처리를 지원해준다.
직접 Publisher와 Listener를 커스텀해서 기능을 구현할 수도 있다.
자세한 내용은 구현하면서 살펴보자.
세 방법 중에 2-3(이벤트 리스너)번 방법을 사용하고자 한다.
이유는 2-1번은 굳이 해보지 않아도 가능하다라는걸 알 수 있고
2-1, 2-2 모두 성능문제를 해결할 수 없기 때문이다.
그리고 여기서 이야기한 방법이 전부는 아니며
메시징 큐를 이용하는 방법도 있다. 이부분은 마지막에 다시 이야기 한다.
이벤트를 발행하면 ApplicationEventPublisher
가 Event를 받는다.
Event를 EventMultiCaster
에게 전달한다.
EventMultiCaster
는 각 Linster
들에게 브로드캐스팅하듯이 Event를 뿌린다.
각 Linster
들은 자신의 파라미터 이벤트 타입과 맞지 않으면 무시하고, 맞으면 실행합니다.
그러면 우리는 전달할 Event와, Listener를 만들어주면 된다.
자세한 과정은 해당 블로그를 참고하자.
@Getter
@NoArgsConstructor
public class SavedImageEvent{
private MultipartFile imageFile;
private Account account;
public SavedImageEvent(MultipartFile imageFile, Account account) {
this.imageFile = imageFile;
this.account = account;
}
public static SavedImageEvent of(MultipartFile imageFile, Account account){
return new SavedImageEvent(imageFile, account);
}
}
@Component
@RequiredArgsConstructor
public class AccountEventHandler {
private final AccountImageService accountImageService;
@EventListener
public void saveImage(SavedImageEvent event){
accountImageService.create(event.getImageFile(), event.getAccount());
}
}
발행한 이벤트를 핸들링할 이벤트 핸들러를 만들었다.
빈으로 등록해주고, @EventListener
어노테이션을 이용하면
해당 메서드가 Listener 역할을 수행하게 된다.
파라미터에 있는 이벤트로 해당 리스너에게 전달된 이벤트인지 아닌지 구분한다.
public class AccountService {
...
private final AccountImageService accountImageService;
...
public Long signUp(Account account, AccountSignUpRequest request) {
Account updateAccount = accountMapper.toEntity(request);
account.signUp(updateAccount);
if (hasImageFile(signUpRequest.getImageFile())) {
AccountImage accountImage = accountImageService.create(signUpRequest.getImageFile());
account.addImage(accountImage);
}
return account.getId();
}
private boolean hasImageFile(MultipartFile imageFile){
return imageFile != null && !imageFile.isEmpty();
}
...
}
AccountImageService
를 주입받아서, 이미지를 처리하고 있다.
AccountImageService
에서 S3에 이미지를 및 DB에 이미지를 저장한다.
public class AccountService {
...
private final ApplicationEventPublisher eventPublisher;
...
public Long signUp(Account account, AccountSignUpRequest request) {
Account updateAccount = accountMapper.toEntity(request);
account.signUp(updateAccount);
System.out.println("회원 정보 등록"); // 순서를 확인하기 위해 임시로 추가
if (hasImageFile(request.getImageFile())) {
eventPublisher.publishEvent(
SavedImageEvent.of(request.getImageFile(), account)
);
}
System.out.println("회원 가입 완료"); // 순서를 확인하기 위해 임시로 추가
return account.getId();
}
...
}
ApplicationEventPublisher
를 주입받아서 사용하면 된다.
그리고 publishEvent()
를 통해 원하는 시점(여기서는 회원가입 이후)에 이벤트를 발행하면 된다.
중요한점은 AccountImageService
를 더 이상 주입받지 않는다는 점이다. (코드레벨 의존성 분리)
여전히 필수 조건을 만족하지 못한다.
회원 정보 등록 실패시, 이미지 저장도 실패해야 한다.(필수)- 이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다. (필수)
- 회원 가입 완료 이후에 이미지를 저장한다. (선택)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
@Component
@RequiredArgsConstructor
public class SignedUpEventHandler {
private final AccountImageService accountImageService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void saveImage(SignedUpEvent event){
accountImageService.create(event.getImageFile(), event.getAccount());
}
}
회원가입 성공시(트랜잭션 성공시) 이미지 이벤트를 생성하도록 디폴트 phase를 사용했다.
기존 코드에서 바뀐부분은 핸들러에서의 @TransactionalEventListener
어노테이션 하나이다.
테스트코드 실행시 이벤트가 정상적으로 발행되지 않아서, 직접 애플리케이션을 실행하고
API를 호출해서 테스트했다..
어쨋든 회원도 잘 저장되고, S3에 이미지도 잘 저장됐다.
그런데 문제는 DB에 이미지 정보가 저장 되지 않았다. (insert 쿼리가 발생하지 않았다.)
트랜잭션이 commit되면 당연히 사라지는줄 알았다.
그래서 4-2 그림처럼 동작할 것을 예상했지만, 실제로는 위 그림처럼 기존 트랜잭션에 참여하게 된다.
commit된 트랜잭션을 다시 commit해서 update, insert, delete등 행위를 하는것은 불가능하다.
트랜잭션이 commit되어야 DB로 쿼리를 날릴텐데, 이미 commit을 수행했기 때문이다.
하지만 `commit된 트랜잭션`에 참여하여 select 행위는 가능하다.
그래서 회원 정보 등록하는 과정에서 트랜잭션을 commit했고,
이후 commit
된 트랜잭션`에 참여하여 이미지를 저장 하려고 하기 때문에 DB에 저장이 되지 않는 것이다.
해결 방법은 간단하다.
새 트랜잭션을 만들어서 이벤트(이미지 저장)를 처리할 수 있도록
@Transactional(propagation = Propagation.REQUIRES_NEW)
를 추가해주면 된다.
@Test
@DisplayName("회원가입 - 추가 정보 입력 (이미지 포함)")
void signUpAddInfoWithImage() {
//given
AccountSignUpRequest req = new AccountSignUpRequest();
req.setAge(null);
req.setCity(null);
req.setDetail(null);
req.setNickname(null);
req.setSex(null);
//when
Long accountId = accountService.signUp(accountWithoutToken, req);
...
}
---
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveImage(SignedUpEvent event){
System.out.println("==이미지 저장 이벤트 실행==");
accountImageService.create(event.getImageFile(), event.getAccount());
}
요청값에 null 넣어서, 회원가입 로직에서 NPE를 발생시킬 것이다.
이 때 이미지도 등록이 실패하는지, 즉 이벤트를 실행시키는지 확인해보자.
==이미지 저장 이벤트 실행==
이 찍히면 안된다.public void create(MultipartFile imageFile, Account account) {
System.out.println(2/0);
// s3에 이미지 등록
// DB에 이미지 정보 저장
}
이미지를 저장하는 로직에서 고의로 예외를 발생시켰다. (숫자를 0으로 나누기..)
회원정보는 이미 저장하고, 별도의 트랜잭션에서 이미지를 등록하면서 실패하기 때문에
회원정보는 잘 저장되야 한다.
회원 정보를 저장하는 update 쿼리가 날라가서, 잘 저장 됐다.
그리고 맨 아래 보면 이벤트는 실행 됐지만, 예외가 발생해 rollback
된걸 볼 수 있다.
의도대로 S3와 DB에 모두 이미지는 저장되지 않았다.
성능 문제는 해결하지 못하지만 필수 조건을 모두 만족한다.
회원 정보 등록 실패시, 이미지 저장도 실패해야 한다.(필수)이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다.(필수)- 회원 가입 완료 이후에 이미지를 저장한다. (선택)
의존성을 제거해 조건을 모두 만족했지만, 처음에 이야기한 성능문제는 여전히 남아있다.
@TransactionalEventListener
를 사용하면 트랜잭션이 분리돼서 따로 처리되니까
성능문제가 해결된거 아닌가? 라고 착각할 수 있다. (내가 그랬다..)
하지만 스프링에서 하나의 요청은 하나의 쓰레드가 처리한다. 중간에 새 트랜잭션이 생성되어도
새 쓰레드가 처리하는것이 아닌, 기존 쓰레드가 계속 처리한다. 즉 동기적으로 처리되고 있다는
이야기다. 사용자는 여전히 회원가입하는데 이미지를 저장하는 시간까지 같이 기다려야 한다.
...
@EnableAsync // 추가
public class PetApplication {
public static void main(String[] args) {
SpringApplication.run(PetApplication.class, args);
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Async
public void saveImage(SignedUpEvent event){
System.out.println("==이벤트 실행==");
accountImageService.create(event.getImageFile(), event.getAccount());
System.out.println("==이벤트 완료==");
}
역시 스프링은 매우 간단하게 어노테이션으로 지원해준다.
비동기 처리를 위한 @EnableAsync
어노테이션을 메인 클래스에 추가하고
비동기로 동작시키고자 하는 메서드에 @Async
어노테이션만 추가해주면 된다.
단 @Async
사용시 주의할점이 있다.
메서드는 반환값을 가지면 안된다.
private 메서드이면 안된다.
내부 호출이 발생하면 안된다.
별도로 설정하지 않는다면 매번 새로운 Thread를 생성한다
@Configuration
@EnableAsync
public class AsyncThreadConfiguration {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // thread-pool에 항상 살아있는 thread 최소 개수
executor.setMaxPoolSize(5); // thread-pool에서 사용 가능한 최대 thread 개수
executor.setQueueCapacity(500); // thread-pool에서 사용할 최대 queue 크기
executor.setThreadNamePrefix("ToGaether");
executor.initialize();
return executor;
}
}
Spring 가이드에 나온 코드를 거의 그대로 가져왔다.
비동기로 처리할 때 마다, 새로운 Thread를 생성하지 않고 Thread-pool에 있는 Thread를 가져와서 사용하고, 처리하고 나면 다시 Thread-pool에 반납하게 된다.
내부적으로 어떻게 돌아가는지에 대한 글은 해당 블로그에 자세히 정리되어있다.
spring:
task:
execution:
pool:
core-size: 5
max-size: 5
원했던 모든 조건을 만족했다.
회원 정보 등록 실패시, 이미지 저장도 실패해야 한다.(필수)이미지 저장이 실패해도, 회원 정보 등록은 성공해야 한다.(필수)회원 가입 완료 이후에 이미지를 저장한다. (선택)
스프링이 제공해주는 이벤트 기능으로, 스프링 Bean으로 동작한다.
그렇다는 것은 외부 시스템과의 연동은 불가능하다는 점이다.
또한 다른 서버로 이벤트를 전달 할 수 없으므로
MSA 구조처럼 각 도메인 서버가 분리되어 있는 경우에도 당연히 사용 불가능하다.
이런 경우에는 Kafka, RabbitMQ와 같은 메시징 큐 소프트웨어나, AWS SQS 같은 것들을 이용해야 한다.
스프링이 제공해주는 @Async
어노테이션 이외에 여러 방법들이 존재한다.
이벤트를 비동기로 처리할 수 있는 상황은 A하면 이어서 B를 해라
의 요구사항을
A하면 최대 언제까지 B를 해라
로 바꿀 수 있다면, 비동기로 처리할 수 있다.
비동기 이벤트 처리 방법은 다양하다.
DDD Start! 라는 책에 나오는 내용으로, 자세히 알고싶다면 해당 책을 참고하자.
퇴근하고 조금씩 하다보니 흐름도 끊기고, 글을 쓰면서 하다보니 며칠이 걸렸다..
마침 오전에 일이 있어서 연차 사용한김에 시간이 남아서 마무리 했다.
강의를 보며 RabbitMQ를 간단하게 따라해본적은 있었지만,
나름 제대로(?) 이벤트 처리를 처음 해보았다.
다음에는 AWS SQS, Kafka, RabbitMQ 등과 같은 것들을 이용해 이벤트를 처리해보는게 목표이다.
안녕하세요 글 잘봤습니다. 비동기로 수행하면 쓰레드가 분리되고 트랜잭션 세션도 동일하지 않아서 트랜잭션 이벤트 리스너의 AFTER_COMMIT 트리거가 동작하지 않았을거 같은데, 어떻게 하셨는지 궁금합니다.