1. OTP 2차 인증(2FA)이란?
아이디와 비밀번호를 입력한 후, 앱이나 문자로 제공되는 일정 시간 유효한 임의의 숫자를 추가로 입력해 계정을 보호하는 강력한 보안 방식입니다.
기존에는 ID와 PW를 통한 단순 로그인만 존재했으나, 유저들에게 보다 강력한 보안 옵션을 제공하기 위해 마이페이지에서 2차 인증(ON/OFF)을 설정할 수 있는 OTP 기능을 새롭게 추가하게 되었습니다.
2. 처음 작업할 때 놓쳤던 점 (AS-IS)
기능을 처음 구현할 때는 구현 자체에 급급해 Spring Security가 제공하는 강력한 인프라를 제대로 활용하지 못했습니다.
대표적으로 아래와 같은 세 가지 설계를 간과하고 있었습니다.
- Spring Security 인증 흐름 밖에서 처리된 OTP 검증
- ID/PW 인증은 Security가 처리하고, OTP 검증은 Security 필터 체인 밖에서 별도로 처리하다 보니 인증 프로세스가 파편화되었습니다.
- Controller의 역할 과중 및 SuccessHandler의 변질
- Controller가 직접 검증 로직과 인증 처리를 담당하게 되었습니다.
이로 인해 AuthenticationSuccessHandler는 성공 처리를 하지 못하고, 단순히 2차 인증 여부에 따른 분기 처리만 수행하는 어색한 구조가 되었습니다.
- Controller가 직접 검증 로직과 인증 처리를 담당하게 되었습니다.
- 불안정한 인증 대기 상태 관리
- 1차 인증과 2차 인증 사이의 '중간 대기 상태'를 단순 세션 속성(HttpSession.setAttribute)으로만 관리했습니다.
Spring Security의 전용 Token 메커니즘을 활용하지 못해 보안적으로나 구조적으로나 아쉬움이 남았습니다.
- 1차 인증과 2차 인증 사이의 '중간 대기 상태'를 단순 세션 속성(HttpSession.setAttribute)으로만 관리했습니다.
3. 구조 재설계 및 리팩토링 (TO-BE)
코드 리뷰를 거치며 Spring Security의 핵심 아키텍처인 Filter → Token → Provider → Handler 흐름 안으로 OTP 인증을 완벽하게 편입시키기로 결정했습니다.
[사용자 요청]
↓
Filter : 요청을 가로채서 상황에 맞는 인증 토큰(Authentication)을 생성
↓
Token : 현재 인증 상태(미인증, 대기, 인증 완료)를 세밀하게 표현
↓
Provider : 토큰을 넘겨받아 DB 조회 및 실제 OTP 번호 일치 여부를 검증
↓
Handler : 검증 결과에 따라 성공 및 실패 후처리를 자동 수행
4. 구체적인 구현 코드
4-1. 인증 상태를 표현할 Custom Token 구현
인증의 단계를 unauthenticated와 authenticated로 명확히 분리하여, OTP 번호 입력 전 대기 상태와 검증 완료 상태를 하나의 토큰 객체 안에서 안전하게 관리할 수 있도록 설계했습니다.
public class OtpAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 1L;
private final Object principal; // 사용자 식별자 (username 또는 UserDetails)
private final String otpId; // OTP 레코드 PK (DB 조회용)
private final String otpCode; // 사용자 입력 OTP 코드
/**
* 미인증 토큰 - OTP 발송 후 대기 상태 (세션 저장용)
* otpCode = null (아직 미입력 상태)
*/
public static OtpAuthenticationToken unauthenticated(Object principal, String otpId) {
return new OtpAuthenticationToken(principal, otpId, null);
}
/**
* 미인증 토큰 - OTP 코드 입력 후 검증 요청 (Provider 전달용)
* otpId로 DB 조회 후 otpCode 검증 진행
*/
public static OtpAuthenticationToken unauthenticated(Object principal, String otpId, String otpCode) {
return new OtpAuthenticationToken(principal, otpId, otpCode);
}
/**
* 인증 완료 토큰 - OTP 검증 성공 후 SecurityContext 저장용
* otpId, otpCode = null (검증 완료 후에는 불필요한 정보이므로 제거)
*/
public static OtpAuthenticationToken authenticated(Object principal,
Collection<? extends GrantedAuthority> authorities) {
return new OtpAuthenticationToken(principal, authorities);
}
private OtpAuthenticationToken(Object principal, String otpId, String otpCode) {
super(null);
this.principal = principal;
this.otpId = otpId;
this.otpCode = otpCode;
setAuthenticated(false);
}
private OtpAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.otpId = null;
this.otpCode = null;
super.setAuthenticated(true);
}
public String getOtpId() { return otpId; }
@Override
public Object getCredentials() { return otpCode; }
@Override
public Object getPrincipal() { return principal; }
}
4-2. [1단계] ID/PW 인증 및 OTP 발송 필터 (POST /login)
UsernamePasswordAuthenticationFilter를 확장하여 1차 인증 성공 시 OTP를 생성·발송하고, 대기 토큰을 세션에 얹어준 뒤 화면을 리다이렉트합니다.
/**
* 1단계 필터 - ID/PW 인증 성공 시 OTP 발송 후 대기 토큰을 세션에 저장
* 엔드포인트: POST /login
*/
public class OtpLoginFilter extends UsernamePasswordAuthenticationFilter {
private final OtpService otpService;
public OtpLoginFilter(AuthenticationManager authenticationManager, OtpService otpService) {
super(authenticationManager);
this.otpService = otpService;
setFilterProcessesUrl("/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) {
String username = request.getParameter("username");
String password = request.getParameter("password");
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(username, password);
return getAuthenticationManager().authenticate(token);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException {
String username = authResult.getName();
// OTP 생성 후 SMS 발송, DB INSERT → otpId(레코드 PK) 반환
String otpId = otpService.sendOtp(username);
// pending 토큰 생성 - otpId 보관, otpCode = null (미입력)
OtpAuthenticationToken pendingToken =
OtpAuthenticationToken.unauthenticated(username, otpId);
// 세션에 대기 토큰 저장 → 2단계 필터에서 꺼내어 사용
request.getSession().setAttribute("OTP_PENDING", pendingToken);
response.sendRedirect("/otp/form");
}
}
🏷️ 내부적인 성공/실패 핸들러 연결은 부모 클래스인 AbstractAuthenticationProcessingFilter.doFilter()에서 깔끔하게 처리되므로, 비즈니스 로직에만 집중할 수 있습니다.
4-3. [2단계] OTP 코드 입력 및 검증 위임 필터 (POST /otp/verify)
유저가 입력한 OTP 코드를 받아 1단계에서 세션에 저장해 둔 OTP_PENDING 토큰의 otpId와 조합한 후, AuthenticationManager에게 실제 검증을 위임합니다.
/**
* 2단계 필터 - OTP 코드 입력 시 세션의 대기 토큰과 함께 Provider로 검증 위임
* 엔드포인트: POST /otp/verify
*/
public class OtpVerifyFilter extends AbstractAuthenticationProcessingFilter {
public OtpVerifyFilter(AuthenticationManager authenticationManager) {
super(new AntPathRequestMatcher("/otp/verify", "POST"));
setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) {
// 세션에서 pending 토큰 조회
OtpAuthenticationToken pendingToken =
(OtpAuthenticationToken) request.getSession().getAttribute("OTP_PENDING");
if (pendingToken == null) {
throw new AuthenticationServiceException("OTP session expired");
}
String otpCode = request.getParameter("otpCode");
// pending 토큰의 otpId + 사용자 입력 OTP 코드로 검증 요청 토큰 생성
OtpAuthenticationToken tokenWithOtp = OtpAuthenticationToken.unauthenticated(
pendingToken.getPrincipal(),
pendingToken.getOtpId(), // DB 조회용 PK
otpCode // 사용자 입력값 (credentials)
);
return getAuthenticationManager().authenticate(tokenWithOtp);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException {
// 검증 완료 후 세션의 pending 토큰 제거
request.getSession().removeAttribute("OTP_PENDING");
// 최종 인증 완료 토큰을 SecurityContext에 안전하게 저장
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
response.sendRedirect("/home");
}
}
4-4. 실질적인 검증을 담당하는 Custom Provider
AuthenticationManager로부터 토큰을 전달받아 유저 계정 상태, OTP 유효 시간, DB 저장 값과의 일치 여부 등 꼼꼼한 실질 검증을 수행하는 핵심 계층입니다.
/**
* OTP 검증 Provider
* OtpAuthenticationToken(검증 요청)을 받아 DB에서 OTP 코드 확인 후 인증 완료 토큰 반환
*/
@Component
@RequiredArgsConstructor
public class OtpAuthenticationProvider implements AuthenticationProvider {
private final OtpService otpService;
private final UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) {
OtpAuthenticationToken token = (OtpAuthenticationToken) authentication;
String username = (String) token.getPrincipal();
String otpId = token.getOtpId(); // DB 조회용 PK
String otpCode = (String) token.getCredentials(); // 사용자 입력 OTP
// DB에서 otpId로 레코드 조회 후 otpCode 검증 (실패 시 여기서 예외 발생 -> unsuccessfulAuthentication 호출됨)
otpService.verify(otpId, otpCode);
UserDetails user = userDetailsService.loadUserByUsername(username);
// 인증 완료 토큰 반환 (보안을 위해 otpId, otpCode는 null 처리)
return OtpAuthenticationToken.authenticated(user, user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
// 이 Provider는 OtpAuthenticationToken 타입만 전담하여 처리함
return OtpAuthenticationToken.class.isAssignableFrom(authentication);
}
}
4-5. Spring Security Config 설정
위에서 구현한 두 필터의 순서를 명확히 지정하고, 각각의 Provider가 제 역할을 할 수 있도록 ProviderManager에 등록해 줍니다.
/**
* Security 설정 및 필터 등록
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OtpService otpService;
private final UserDetailsService userDetailsService;
private final OtpAuthenticationProvider otpAuthenticationProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = authenticationManager();
OtpLoginFilter otpLoginFilter =
new OtpLoginFilter(authenticationManager, otpService);
OtpVerifyFilter otpVerifyFilter =
new OtpVerifyFilter(authenticationManager);
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/otp/form", "/otp/verify").permitAll()
.anyRequest().authenticated()
)
// 기본 UsernamePasswordAuthenticationFilter 자리에 OtpLoginFilter 대체 등록
.addFilterAt(otpLoginFilter, UsernamePasswordAuthenticationFilter.class)
// OtpVerifyFilter는 OtpLoginFilter 이후에 실행되도록 등록
.addFilterAfter(otpVerifyFilter, OtpLoginFilter.class)
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager() {
// 1차 ID/PW 검증용 Provider
DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
daoProvider.setUserDetailsService(userDetailsService);
daoProvider.setPasswordEncoder(passwordEncoder());
// 2차 OTP 검증용 Provider와 함께 매니저 구성
return new ProviderManager(daoProvider, otpAuthenticationProvider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5. 아키텍처 변경으로 얻은 장점
프레임워크의 흐름에 맞춰 코드를 리팩토링하면서 얻은 이점은 생각보다 훨씬 강력했습니다.
- 인증 관리 자동화 및 보안성 향상
- 인증 로직이 완전히 Spring Security 생명주기 내부로 들어오면서 SecurityContext 관리가 안전하게 자동화되었습니다. 중간 대기 토큰 구조를 사용하여 세션 탈취 등의 취약점도 예방할 수 있게 되었습니다.
- 깔끔한 관심사 분리
- Controller는 이제 데이터를 검증하는 무거운 짐을 내려놓고, 단순한 페이지 렌더링/리다이렉트 역할만 담당하게 되었습니다.
- 유연한 확장성 확보
- 향후 생체 인증(FIdO)이나 이메일 인증 등 새로운 2차 인증 방식이 추가되더라도, 기존 비즈니스 로직을 건드릴 필요 없이 Configurer 단위나 Provider 체인에 폼만 바꾸어 끼우면 되는 구조적 유연함을 얻었습니다.
마치며 💭
기능이 올바르게 동작한다고 해서 무조건 좋은 코드는 아니라는 점을 다시금 뼈저리게 느낀 작업이었습니다.
처음 프레임워크의 흐름을 무시하고 Controller와 세션에 의존해 비즈니스 로직을 구현했을 때는 어쨌든 돌아가니까 괜찮다고 타협할 뻔했습니다.
하지만 코드 리뷰를 통해 Spring Security 내부 흐름을 마주했고 프레임워크의 철학에 맞춰 리팩토링하는 과정에서 코드가 얼마나 견고하고 유연해질 수 있는지 깊이 체감할 수 있었습니다.
혹시 저처럼 Spring Security 환경에서 다중 인증(MFA)이나 커스텀 인증 흐름을 고민하고 계신 분이 있다면 이 글이 조금이라도 도움이 되었으면 좋겠습니다 :)
'Case Study > Modernization' 카테고리의 다른 글
| 익일 쿠폰 발급 자동화를 위해 Spring Scheduler를 적용한 경험 (0) | 2026.07.07 |
|---|---|
| FTP 배포에서 CI/CD 자동화로 넘어가며 얻은 안정성 (1) | 2026.03.19 |
| PHP 레거시 시스템을 Spring Boot로 전환하며 고민했던 기록들 (0) | 2026.03.12 |