😇 들어가기 앞서
로그인 실패 시 핸들링을 구현하면서 발생했던 트랜잭션으로 발생했던 이슈가 있어서 공유하고자 합니다.
트랜잭션에 대해서 추가적인 내용들은 따로 정리를 할 예정입니다.
🙋 트랜잭션이란?
하나의 작업의 단위를 트랜잭션이라고 합니다.
스프링에서는 트랜잭션을 편하게 사용할 수 있도록 AOP와 어노테이션 기반으로 동작합니다.
우리가 꼭 짚고 넘어가야 것은 트랜잭션은 기본적으로 상위 트랜잭션에 포함된다 입니다.
1️⃣ 로그인 실패
로그인 실패 핸들링은 다음과 같은 요구사항을 가집니다.
- 로그인 실패 시 로그인 실패 회수를 카운팅한다.
- 카운팅한 결과를 예외에 담아서 던진다.
코드는 다음과 같이 작성했습니다. 인증을 진행하다가 BadCredentialsException
가 발생하면 핸들링 로직을 실행합니다.
@Transactional
public ResponseDto login(String loginId, String password) {
// Do Something...
Authentication authentication = authenticate(loginId, password);
}
private Authentication authenticate(String loginId, String password) {
try {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginId, password);
return authenticationManagerBuilder.getObject().authenticate(authenticationToken);
} catch (BadCredentialsException e) {
int failCount = // Do failCount...
throw new CustomBadCredentialException(ErrorCode.PASSWORD_NOT_MATCH, failCount);
}
}
첫 번째로 간과했던 점은 앞서 말했던 부모 트랜잭션에 포함된다 입니다.
바로 문제점을 인지하고 로그인을 핸들링하는 로직을 분리하고,
Propagation.REQUIRES_NEW
옵션으로 새로운 트랜잭션을 생성해서 처리하도록 했습니다.
@Transactional
public ResponseDto login(String loginId, String password) {
// Do Something...
Authentication authentication = authenticate(loginId, password);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int handleLoginFail(String loginId) {
// Handle Failure...
}
private Authentication authenticate(String loginId, String password) {
try {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginId, password);
return authenticationManagerBuilder.getObject().authenticate(authenticationToken);
} catch (BadCredentialsException e) {
int failCount = handleLoginFail(loginId);
throw new CustomBadCredentialException(ErrorCode.PASSWORD_NOT_MATCH, failCount);
}
}
당연히 잘 동작할 거라고 생각했지만 정상적으로 동작하지 않았습니다 🥹🥹
이유는 AOP로 트랜잭션을 처리하면 트랜잭션이 클래스 레벨에서 관리되기 때문입니다.
따라서, 같은 클래스 내부에서 호출하는 경우 새로운 트랜잭션을 생성하지 못합니다.
즉, 설정한 옵션은 무시되고 기존의 트랜잭션에 합류됩니다.
핸들링 메서드를 다른 서비스에 위치시킴으로 이 문제를 해결할 수 있었습니다!
롤백되는 것을 막기 위해서 새로운 트랜잭션을 사용해야 할 때 같은 클래스 내부에서 위치시키지 않아야 합니다.
2️⃣ 테스트
구현한 기능을 테스트를 하던 중 두 번째 이슈를 발견했습니다.
포스트맨에서는 정상적으로 동작하는데 E2E 테스트에서는 이상하게 NotFound 예외가 발생합니다.
로직을 확인해봤지만 이상이 없었고 여러 테스트를 해본 결과 트랜잭션 문제였습니다.
문제는 테스트용 데이터를 넣어주는 과정에서 발생했습니다.
데이터를 저장하는 메서드들은 모두 내부적으로 트랜잭션이 선언되어있습니다.
따라서, 테스트용 데이터가 상위 트랜잭션(테스트)에 종속되어 커밋이 되지 않아 DB에 반영이 되지 않았던 것입니다.
이처럼 테스트에 트랜잭션을 선언하면 원하는대로 테스트가 동작하지 않을 수 있습니다.
추가로, 추후에 메서드에 선언된 트랜잭션이 운영 등의 이유로 없어질 수 있습니다.
제 코드에서는 발생하지 않지만 연관 관계가 존재하는 객체가 있다고 가정했을 때,
프록시 객체를 가져오려고 하면 LazyInitializationException
이 발생할 수 있습니다.
😋 정리
트랜잭션은 하나의 작업 단위를 의미합니다.
기본적으로 하위 트랜잭션은 부모 트랜잭션에 포함됩니다.
예외로 인해 트랜잭션 롤백 시 처리해야할 로직이 있다면 다른 클래스(Bean)에 분리해야 합니다.
그래야 정상적으로 Propagation.REQUIRES_NEW
옵션이 적용 수 있습니다.
테스트에 트랜잭션을 사용하면 분명한 장단점이 존재합니다.
트랜잭션을 사용하면 다음 테스트를 진행할 때 초기 상태에서 동작할 수 있어서 편리합니다.
하지만 테스트 코드로서 기능을 하지 못 할 수도 있습니다.
반면에, 트랜잭션을 사용하지 않으면 테스트 환경을 유지해주기 위한 추가적인 코드를 작성해야합니다.
이런 trade-off를 고려하셔서 테스트에 사용하시기 바랍니다. 😎😎
'Web > Spring' 카테고리의 다른 글
[Spring] @ModelAttribute (2) | 2023.04.19 |
---|---|
[Spring] @Valid, @Validated과 Custom Annotation (2) (0) | 2023.03.21 |
[Spring] @Valid, @Validated과 Custom Annotation (1) (0) | 2023.03.19 |
[토비의 스프링] 테스트 (2장) (0) | 2023.01.16 |
[토비의 스프링] 오브젝트와 의존관계 (1장) (0) | 2022.12.21 |
댓글