웹 애플리케이션 개발 시 백엔드는 단순히 비즈니스 로직을 수행하는 것을 넘어, 전면 프론트엔드로 명확하고 구조화된 오류 정보를 전달해야 한다. 특히 사용자 인증과 같은 복잡한 시나리오에서는 정적 메시지로는 부족하며, 남은 시도 횟수나 잠금 시간과 같이 실시간으로 계산되는 동적 정보가 필요하다. 이러한 요구사항을 충족하면서도 코드의 유지보수성과 일관성을 해치지 않도록 설계하는 것이 핵심 과제다.
본문은 Spring Boot 기반 관리자 인증 시스템에서 발생하는 동적 오류 메시지 전달 문제를 중심으로, 점진적인 리팩터링을 통해 깔끔하고 확장 가능한 솔루션을 제시한다.
사례: 관리자 로그인 실패 카운트 및 계정 잠금
시스템 요구사항은 다음과 같다:
- 일반 관리자는 연속으로 3회 비밀번호를 잘못 입력하면 15분간 계정이 잠긴다.
- 각 실패 시도마다 "비밀번호가 틀렸습니다. 남은 시도 횟수: N번"과 같은 피드백을 제공해야 한다.
- 잠금 상태일 경우 "계정이 잠겼습니다. XX분 후 재시도 가능"이라는 메시지를 반환해야 한다.
기존 서비스 로직은 비밀번호 검증 실패 시 정적 예외를 던지는 방식으로 구현되어 있다.
if (!passwordEncoder.matches(inputPassword, storedPassword)) {
int newFailCount = adminUser.getLoginFailCount() + 1;
adminUser.setLoginFailCount(newFailCount);
if (newFailCount >= 3) {
adminUser.setStatus(LOCKED);
redisTemplate.opsForValue().set(
"admin:block:" + adminUser.getId(),
"blocked",
Duration.ofMinutes(15)
);
}
userRepository.save(adminUser);
throw new InvalidCredentialsException(AuthError.WRONG_PASSWORD);
}
여기서 문제는 AuthError.WRONG_PASSWORD가 미리 정의된 정적 메시지만 포함하므로, 남은 시도 횟수와 같은 동적 정보를 전달할 수 없다는 점이다.
기존 오류 처리 아키텍처의 한계
현재 시스템은 다음 구성 요소들로 오류 응답을 표준화하고 있다:
- Result<T>: JSON 응답 포맷을 정의하며,
code,message,data필드를 가짐. - AuthError: 오류 코드와 기본 메시지를 상수로 정의한 enum.
- InvalidCredentialsException: 비즈니스 예외 클래스.
- RestExceptionHandler: 전역에서 예외를 캐치해
Result객체로 변환.
전역 핸들러는 아래와 같이 작동한다.
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<Result<Void>> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity.badRequest()
.body(Result.error(ex.getError()));
}
}
이 구조는 유연성이 부족하여, 동적 메시지를 처리하기 어렵다.
최적의 해결책: 예외 클래스의 확장
기존 아키텍처를 최대한 보존하면서 동적 메시지를 지원하기 위해, 예외 클래스에 새로운 생성자를 추가하는 방식을 채택한다.
1단계: 예외 클래스 강화
예외 클래스가 동적 메시지를 수용할 수 있도록 수정한다. 기존의 enum 기반 생성자는 유지하면서, 문자열 기반 생성자를 추가한다.
public class InvalidCredentialsException extends RuntimeException {
private final AuthError error;
// 기존 생성자 - 정적 메시지 사용
public InvalidCredentialsException(AuthError error) {
super(error.getMessage());
this.error = error;
}
// 새 생성자 - 동적 메시지 수용
public InvalidCredentialsException(String dynamicMessage) {
super(dynamicMessage);
this.error = AuthError.WRONG_PASSWORD; // 기본 오류 코드 연결
}
public AuthError getError() {
return error;
}
}
2단계: 전역 핸들러 업데이트
핸들러는 예외 객체의 실제 메시지를 우선적으로 사용하도록 조정한다. 이렇게 하면 오류 코드는 유지되며, 메시지만 동적으로 교체된다.
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<Result<Void>> handleInvalidCredentials(InvalidCredentialsException ex) {
log.warn("로그인 실패: {}", ex.getMessage());
Result<Void> response = Result.error(ex.getError());
response.setMessage(ex.getMessage()); // 동적 메시지로 덮어씀
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
3단계: 서비스 로직 적용
서비스 계층에서는 조건에 따라 적절한 동적 메시지를 생성하고, 이를 예외에 담아 던진다.
try {
authenticateUser(loginDto);
} catch (AuthenticationException e) {
String detailMsg;
if (user != null && user.getRole() != ADMIN_ROLE) {
int remaining = MAX_ATTEMPTS - user.getFailCount();
if (remaining <= 0) {
detailMsg = String.format("계정이 잠겼습니다. %d분 후 다시 시도하세요.", LOCK_DURATION_MINUTES);
} else {
detailMsg = String.format("비밀번호가 틀렸습니다. 남은 시도 횟수: %d번", remaining);
}
} else {
detailMsg = "비밀번호가 올바르지 않습니다.";
}
auditLogService.logFailure(user, loginDto.getUsername(), detailMsg);
throw new InvalidCredentialsException(detailMsg); // 동적 메시지 전달
}
결과
이렇게 설계하면 프론트엔드는 아래와 같은 일관된 형식의 응답을 받게 된다.
// 시도 가능 시
{
"code": 1006,
"message": "비밀번호가 틀렸습니다. 남은 시도 횟수: 2번",
"data": null
}
// 계정 잠김 시
{
"code": 1006,
"message": "계정이 잠겼습니다. 15분 후 다시 시도하세요.",
"data": null
}
이 접근법은 기존 구조를 파괴하지 않으면서도 동적 정보를 자연스럽게 통합할 수 있어, 유지보수성과 확장성을 모두 만족하는 실용적인 솔루션이다.