🔐 로그인 잠금(Lock) 처리 — 트랜잭션 문제
회원 로그인할 때, (최초 로그인 실패 시점으로부터) 30분 내에 5회 이상 실패하면 계정에 잠금(lock)을 걸어서 로그인을 제한하는 기능을 구현했다. 그런데 이 과정에서 트랜잭션 때문에 실패 로그가 DB에 반영되지 않는 문제가 발생했다.
❗ 문제 상황
login 메서드에서 회원이 비밀번호가 틀린 경우, 예외를 터뜨린다. 로그인 실패 로그를 DB에 저장하기 위해서
setLoginFailureHistory() 를 호출한다. 그러나...
❌ 예외가 발생하면서 부모 트랜잭션이 롤백 →
실패 기록(로그인 실패 카운트)이 DB에 커밋되지 않음
즉, 실패 로그를 남겨야하는데 예외가 터지면서 실패 로그 저장 로직이 롤백되버리는 문제가 발생했다.
시도 1 — REQUIRES_NEW 전파단계 적용 (실패)
로그인 메서드와 실패 로그 저장 메서드에 둘다 트랜잭션 전파 단계를 requires_new 로 설정하여 실패 로그가 새로운 트랜잭션에서 저장되도록 만든다.
(requires_new: 부모 메서드에서 자식 메서드를 호출하는 경우, 반드시 새로운 트랜잭션을 시작하는 전파 단계)
하지만, 결과는 실패..
❌ 이유: 같은 클래스 내부 호출은 AOP가 적용되지 않음
login 메서드가 같은 클래스의 setLoginFailureHistory()를 호출하면 setLoginFailureHistory 를 시작하는 순간, 프록시를 거치지 않기 때문에 @Transactional 자체가 적용되지 않는다.
그래서 @Transactional(REQUIRES_NEW)는 무효.
@Transactional(Transactional.TxType.REQUIRES_NEW)
시도 2 — TransactionTemplate (성공)
import org.springframework.transaction.support.TransactionTemplate;
TransactionTemplate 을 사용하면 새로운 트랜잭션을 직접 시작할 수 있다.
transactionTemplate.execute() 메서드 내부는 항상 독립된 트랜잭션으로 실행된다. 따라서 실패 로그 저장을 예외 처리 부분과 분리하여 DB에 커밋 가능하다.
⚠️ 주의점
TransactionTemplate를 쓰면 @Transactional 애터네이션은 제거해야한다. 같이 쓰면 트랜잭션이 이중으로 설정되므로 오류 가능성이 있기 때문이다.
✔ 최종 동작 원리
부모 메서드 login 에 트랜잭션 설정이 없음 → 부모 트랜잭션 없음
transactionTemplate.execute() 호출하는 순간 → 새로운 트랜잭션이 시작된다.
실패 로그는 예외와 관련 없이 따로 커밋된다.
적용 코드
transactionTemplate 안에는 DB에 저장하는 부분만 넣고 throw 하는 부분은 그 다음에 넣어야 한다.
만약 그 안에 throw 구문을 넣으면 하나의 트랜잭션 안에서 롤백이 일어나므로 실패로그는 저장 되지 않는다.
<hide/>
public Map<String, Object> login(HttpServletRequest request, LoginRequestVO loginRequestVO) throws BadRequestException, AccessDeniedException {
Map<String, Object> loginResult = new HashMap<>();
/** email, password 검증, 잠김 여부 확인 **/
/** 비밀번호 체크 */
Boolean checkResult = BCrypt.checkpw(loginRequestVO.getPassword(), entity.getPassword());
if (!checkResult) {
transactionTemplate.execute(status -> {
setLoginFailureHistory(entity);
userRepository.save(entity);
return null;
});
throw new AccessDeniedException("정보가 일치하지 않습니다. Password 를 확인하세요.");
}
return loginResult;
}
private void setLoginFailureHistory(UserEntity entity){
LocalDateTime now = LocalDateTime.now();
LocalDateTime failedLoginDt = entity.getFailedLoginDt(); // 최초 fail 시점 (이 시점 기준으로 30분 동안 카운트)
boolean isLockExpired
= (failedLoginDt == null) || now.isAfter(failedLoginDt.plusMinutes(LOCK_WINDOW_MINUTES)); // 30분 지나면 로그인 실패 정보 reset
/*** 로그인 최초 실패, 30분 경과한 경우 reset */
if( entity.getLoginTryCount() == null || entity.getLoginTryCount() == 0 || isLockExpired == true){
log.info("로그인 실패 정보를 초기화합니다.");
entity.setLoginTryCount(1);
entity.setFailedLoginDt(now);
/*** 30분 내에 로그인 연속 실패 ***/
} else if(isLockExpired == false){
int curLoginTryCnt = entity.getLoginTryCount() + 1;
entity.setLoginTryCount(curLoginTryCnt);
if(curLoginTryCnt >= MAX_LOGIN_TRY_COUNT){
log.info("로그인 실패 횟수 5회 이상입니다. 계정을 잠갔습니다.");
entity.setLockYn("Y");
}
}
}