개발 일지/TroubleShooting

Spring 로그인 실패 처리 롤백 문제 - TransactionTemplate로 해결

계란💕 2025. 11. 22. 02:02

🔐 로그인 잠금(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");
        }
    }
}