[Spring] Filter + @Transactional 조합, 과연 가능할까?
들어가기 전
이번 포스팅에서는 Filter에 @Transactional을 사용했을 때 트랜잭션이 정상적으로 수행이 되는지 알아보겠습니다.
개요
현업에서 개발된 코드 중 Filter 내부에서 @Transactional을 사용하는 로직을 보게 되었을 때,
"과연 이 조합이 정상적으로 트랜잭션을 처리할 수 있을까?"라는 의문이 생겼습니다.
Filter는 일반적으로 서블릿 컨테이너에서 실행되기 때문에 Spring의 AOP 기반 @Transactional이 정상적으로 작동하지 않을 수도 있다는 생각이 들었고, 이에 대한 정확한 동작 방식을 실험을 통해 확인해 보기로 했습니다.
이번 글에서는 해당 실험 과정을 예시 코드와 함께 살펴보며 Filter + @Transactional 조합이 어떤 조건에서 동작하는지 확인해 보겠습니다.
//import 생략..
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import study.mysqlnamedlock.example.getlock.TicketWriteService;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final TicketWriteService ticketWriteService;
@Bean
public FilterRegistrationBean<FilterTransaction> myFilter() {
FilterRegistrationBean<FilterTransaction> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new FilterTransaction(ticketWriteService));
registrationBean.addUrlPatterns("/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
//import 생략..
@RequiredArgsConstructor
public class FilterTransaction implements Filter {
private final TicketWriteService ticketWriteService;
@Transactional
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
ticketWriteService.decrease(1L);
filterChain.doFilter(servletRequest, servletResponse);
}
}
@Service
@RequiredArgsConstructor
public class TicketWriteService {
private final TicketJpaRepository ticketJpaRepository;
public void decrease(Long ticketId) {
System.out.println("Ticket 감소하는 Service 로직");
Ticket ticket = ticketJpaRepository.findById(ticketId)
.orElseThrow(IllegalArgumentException::new);
if(ticket.getAmount() == 0) {
throw new RuntimeException("티켓 모두 소진되었습니다.");
}
ticket.decrease();
}
public int getAmount(Long ticketId) {
Ticket ticket = ticketJpaRepository.findById(ticketId)
.orElseThrow(IllegalArgumentException::new);
return ticket.getAmount();
}
}
이 글을 읽는 분들도 아래 글을 읽기 전에 위 코드가 정상 동작할지, 안 할지 생각해 보고 "동작할 거다"라고 생각하시는 분은 왜 동작할 거라고 생각을 했는지, "동작 안 할 거다"라고 생각하신 분은 왜 동작을 안 할 거 같은지 생각한 후 글을 이어서 보시는 것을 추천드리겠습니다.
이제부터 위 예시가 정상적으로 동작 안 할 거 같다고 생각한 이유에 대해서 알아보겠습니다.
참고 : 이번 포스팅은 Filter, @Transaction에 대한 상세한 설명을 하는 글이 아니기 때문에 간략한 설명으로 진행할 예정입니다.
정상적으로 동작 안 할 거 같다고 생각 한 이유
정상적으로 동작 안 할 거 같다고 생각한 이유에 대해서 설명하기 전 Spring 프레임워크에서 클라이언트로부터 요청이 들어오고 응답이 나가는 동작 과정에 대해서 먼저 알아보겠습니다.
클라이언트로부터 요청이 오면 Filter -> Dispathcer Servlet -> Interceptor -> Controller 순으로 요청을 처리합니다.
클라이언트한테 응답을 해줄 때는 요청의 역순으로 처리한 후 응답을 해줍니다.
이때 위 코드에서 Filter에 @Transactional을 붙여서 트랜잭션이 정상적으로 동작하기를 기대하는 로직이 있었습니다.
그러나 저는 기대와 다르게 트랜잭션이 동작 안 할 거라고 생각했습니다.
이렇게 생각한 이유는 Filter와 @Transactional의 관리 및 실행 시점이 다르기 때문입니다.
Filter 인터페이스 내부의 패키지를 확인해 보면 스프링 컨테이너에서 관리하는 게 아닌 서블릿 컨테이너에서 관리하고 있습니다.
@Transactional은 스프링에서 관리하고 스프링 AOP 방식으로 동작합니다.
@Transactional이 붙은 메서드가 실행되기 전에 트랜잭션이 시작이 되고 실행이 완료되면 커밋 또는 롤백을 진행합니다.
이론상 클라이언트의 요청이 들어오면 서블릿에서 관리하고 있는 Filter가 제일 먼저 동작합니다.
그래서 서블릿 영역에서 스프링에서 관리하고 있는 @Transactional이 정상적으로 수행되지 않을 거라고 생각했습니다.
실험해 본 결과 제가 생각했던 결과대로 트랜잭션이 정상적으로 동작되지 않는 것을 확인할 수 있었습니다.
실험 결과
트랜잭션 수행되는 로그를 확인하기 위해 로그 설정을 하였습니다.
logging:
level:
org:
springframework:
transaction: debug
hibernate: debug
테스트를 위해 100개의 티켓 데이터를 구성하였습니다.
필터에서 수행되는 트랜잭션이 실행이 되는지 로그를 통해 확인하였습니다.
@RequiredArgsConstructor
public class FilterTransaction implements Filter {
private final TicketWriteService ticketWriteService;
@Transactional
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
System.out.println("============필터 진행============");
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("트랜잭션 여부 = " + actualTransactionActive);
ticketWriteService.decrease(1L);
System.out.println("============필터 끝============");
filterChain.doFilter(servletRequest, servletResponse);
}
}
- TransactionSynchronizationManager.isActualTransactionActive() : 현재 트랜잭션 활성화 여부
티켓을 하나씩 감소시키는 API
@RestController
@RequiredArgsConstructor
public class Controller {
private final TicketWriteService ticketWriteService;
@GetMapping
public int decrease() {
return ticketWriteService.getAmount(1L);
}
}
API 호출한 결과
티켓을 하나 감소하는 API를 호출하였는데 "100"을 반환하고 있습니다.
API 호출 후 로그
Filter에서 적용한 트랜잭션은 활성화되지 않은 것을 확인할 수 있습니다.
그러나 로그(빨간 박스)를 자세히 확인해 보면 트랜잭션이 "begin"이 되고 "committing"되는 것을 로그 내용을 통해 확인할 수 있습니다.
트랜잭션이 시작되고 커밋되는 이유는 아래 코드로 인해 발생하는 것입니다.
트랜잭션이 동작하는 이유는 "findById"로 인해 수행된 것입니다.
이유는 JPA의 구현체에서 클래스 단위에 @Transactional(readOnly = true)로 설정이 되어있어서 JPA에서 제공하는 메서드를 호출하면 호출한 메서드 범위까지만 트랜잭션이 정상적으로 실행이 됩니다.
JPA 구현체(SimpleJpaRepository)
위 실험 결과를 통해서 JPA에서 기본적으로 제공하는 메서드 호출을 제외하고 Filter에서는 트랜잭션이 동작 안 하는 것을 확인할 수 있었습니다.
그럼 Filter에는 @Transactional을 붙여도 절대 반영이 안 되는 건가??
그거에 대한 답변은 "No"입니다.
아래와 같은 방법으로 Filter에도 트랜잭션이 동작하도록 설정할 수 있습니다.
Filter에 @Transactional 적용하는 법
1. 트랜잭션이 필요한 비즈니스 로직에 @Transactional 사용
@RequiredArgsConstructor
public class FilterTransaction implements Filter {
private final TicketWriteService ticketWriteService;
@Transactional
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
System.out.println("============필터 진행============");
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("트랜잭션 여부 = " + actualTransactionActive);
ticketWriteService.decrease(1L);
System.out.println("============필터 끝============");
filterChain.doFilter(servletRequest, servletResponse);
}
}
@Service
@RequiredArgsConstructor
public class TicketWriteService {
private final TicketJpaRepository ticketJpaRepository;
@Transactional
public void decrease(Long ticketId) {
System.out.println("Ticket 감소하는 Service 로직");
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("TicketWriteService.decrease 트랜잭션 여부 = " + actualTransactionActive);
Ticket ticket = ticketJpaRepository.findById(ticketId)
.orElseThrow(IllegalArgumentException::new);
if(ticket.getAmount() == 0) {
throw new RuntimeException("티켓 모두 소진되었습니다.");
}
ticket.decrease();
}
public int getAmount(Long ticketId) {
Ticket ticket = ticketJpaRepository.findById(ticketId)
.orElseThrow(IllegalArgumentException::new);
return ticket.getAmount();
}
}
위 코드를 수행하면 아래와 같은 로그를 확인할 수 있습니다.
로그를 확인해 보면 Filter에 적용한 트랜잭션은 적용이 안된 것을 확인할 수 있습니다(트랜잭션 여부 = false)
그러나 TicketWriteService에 적용한 트랜잭션은 적용된 것을 확인할 수 있고 update쿼리가 수행이 되어 티켓이 감소된 것을 확인할 수 있습니다.
TicketWriteService에서 트랜잭션이 정상적으로 동작하는 이유는 해당 서비스가 스프링 컨테이너에서 관리하는 스프링 빈으로 등록이 되어 있기 때문에 AOP가 정상동작하여 트랜잭션을 적용해 주기 때문입니다.
2. Filter를 @Componet를 사용하여 스프링 빈으로 등록
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final FilterTransaction filterTransaction;
@Bean
public FilterRegistrationBean<FilterTransaction> myFilter() {
FilterRegistrationBean<FilterTransaction> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(filterTransaction);
registrationBean.addUrlPatterns("/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@Component
@RequiredArgsConstructor
public class FilterTransaction implements Filter {
private final TicketWriteService ticketWriteService;
@Transactional
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
System.out.println("============필터 진행============");
boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("트랜잭션 여부 = " + actualTransactionActive);
ticketWriteService.decrease(1L);
System.out.println("============필터 끝============");
filterChain.doFilter(servletRequest, servletResponse);
}
}
위 코드는 Filter 인터페이스를 구현한 구현체에 @Component를 사용하여 스프링 빈으로 등록하고 빈으로 등록된 Filter 구현체를 Filter로 등록해 줍니다.
트랜잭션이 어떻게 동작하는지 아래 로그를 통해 확인해 보겠습니다.
로그를 확인해 보면 Filter에 적용한 트랜잭션이 적용된 것을 확인할 수 있습니다.(트랜잭션 여부 = true)
그리고 서비스 로직에서의 트랜잭션도 활성화가 된 것을 확인할 수 있습니다.
update 쿼리가 수행되어 티켓이 감소가 된 것을 확인할 수 있습니다.
Filter에서도 트랜잭션이 적용되는 이유는 Filter 구현체를 스프링 빈으로 등록하여 서블릭 컨테이너가 아닌 스프링 컨테이너가 관리하여 스프링 AOP 정상적으로 동작하여 @Transactional이 적용된 것을 확인할 수 있습니다.
정리
- Filter를 구현한 구현체를 스프링 빈으로 등록하지 않으면 @Transactional 적용하여도 트랜잭션 동작 하지 않습니다.
- 스프링 빈 등록되지 않은 Filter 구현체에서 호출하는 비즈니스 로직에 @Transactional 적용하면 트랜잭션 정상 동작합니다.
- Filter 구현체를 스프링 빈으로 등록하면 트랜잭션 정상 동작합니다.