본문으로 건너뛰기

Spring Boot에서 Session 인증을 커스텀하는 이유와 실전 구현

"왜 Spring Security의 기본 Form Login을 쓰지 않고 직접 구현할까?"

이 글에서는 Admin/Customer 통합 환경에서 JSON 기반 로그인 + 중복 로그인 제어를 구현하며 겪었던 표준 방식으로는 안 되는 이유어떻게 우회했는지를 기록합니다.


1. 왜 커스텀 세션 인증?

프로젝트 요구사항

항목요구사항표준 FormLogin 가능 여부
JSON 기반 로그인{"email": "...", "password": "..."}복잡
중복 로그인 제어동일 계정 1명만 로그인 허용가능
계정 잠금 처리5회 실패 시 계정 잠김커스텀 필요
첫 로그인 체크첫 로그인 시 비밀번호 변경 강제커스텀 필요
타입별 응답Admin/Customer별 다른 응답커스텀 필요

표준 FormLogin의 한계

Spring Security의 기본 FormLogin 필터 체인:

UsernamePasswordAuthenticationFilter
→ AuthenticationManager
→ UserDetailsService (사용자 조회)
→ SuccessHandler / FailureHandler

문제점:

  1. 필터 단계에서는 비즈니스 로직 주입이 복잡
    • 필터 기반 인증은 커스터마이징이 어렵고, 특히 AuthenticationFailureHandler만으로는 DB 상태 변경(잠금 등)을 처리하기 직관적이지 않음.
  2. 실패 카운트를 어디서 관리할까?
    • 필터는 Stateless하므로 DB 업데이트 로직을 끼워넣기 애매함
  3. 첫 로그인 체크는 인증 성공 이후에 판단해야 함
    • 필터 체인에서는 순서상 어색함

해결 방안: Controller + SessionAuthenticationStrategy 조합

필터를 우회하여 Controller에서 비즈니스 로직을 선행 처리하되, 인증 결과는 Security의 표준 컴포넌트를 이용해 세션 시스템에 등록 수동으로 Authentication 객체를 생성하고, SecurityContextRepository를 통해 세션에 영속화하는 일련의 과정을 코드로 구현

[기존] Filter Chain 방식

Request → Filter → AuthenticationManager → Response

[개선] Controller 방식

Request → Controller → Service (비즈니스 로직)

→ 수동으로 AuthenticationManager 호출
→ SessionAuthenticationStrategy 호출
→ SecurityContext 저장 → Response

Spring Security의 세션 정책을 그대로 활용하는 방안


2. 인증 구현

전체 인증 플로우

1. [Client] Request

2. [Controller] 요청 수신

3. [LoginService] 검증
3.1 사용자 확인
3.2 계정 활성 여부
3.3 비밀번호 검증
3.4 첫 로그인 검증

4. [performSecurityAuthentication]
4.1 AuthenticationManager.authenticate() 호출
4.2 SessionAuthenticationStrategy.onAuthentication() 호출
4.2.1) 중복 세션 제어 | 세션 고정 공격 방지 |
4.2.2) SessionRegistry 등록
4.3 SecurityContext 생성 및 설정
4.4 HttpSession에 SecurityContext 저장

5. [Response]

단계별 설명

1단계: 사용자 요청 수신

@PostMapping("/login")
public ResponseEntity login(@RequestBody LoginDto req) {
// Controller에서 직접 처리
}

2단계: 비즈니스 검증 (LoginService)

if (user.isLocked()) {
throw new AccountLockedException("계정이 잠겼습니다.");
}

if (user.isFirstLogin()) {
return new FirstLoginResponse("비밀번호를 변경하세요.");
}

if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
user.incrementFailCount();
if (user.getFailCount() >= 5) {
user.lock();
}
userRepository.save(user);
throw new BadCredentialsException("아이디 또는 비밀번호 틀렸습니다.");
}

3단계: Spring Security 인증 실행

// AuthenticationManager를 수동으로 호출
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
req.getEmail(),
req.getPassword(),
user.getAuthorities()
);
Authentication authentication = authenticationManager.authenticate(authToken);

4단계: 세션 정책 적용

// SessionAuthenticationStrategy 수동 호출
sessionAuthenticationStrategy.onAuthentication(authentication, request, response);

5단계: SecurityContext 저장

SecurityContext context = securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response);

3. 핵심 컴포넌트 구현

각 컴포넌트의 역할

컴포넌트역할실무 의미
HttpSessionEventPublisher세션 생성/삭제 이벤트를 Spring Security에 전달로그아웃/타임아웃 시 SessionRegistry 자동 정리
SessionRegistry세션 및 사용자 상태 관리Admin UI에서 "현재 접속 중인 사용자" 목록 확인 가능
SessionAuthenticationStrategy세션 정책 체인중복 로그인 제어 + 세션 고정 공격 방지 + 세션 등록

1) User Entity

@Entity
@Table(name = "users")
@EqualsAndHashCode(of = "email", callSuper = false)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User implements UserDetails {
//...
}

equals/hashCode를 오버라이딩 하는 이유

  • Spring Security의 SessionRegistry동일 사용자의 세션을 추적하기 위해

  • 내부적으로 Map<Object, Set<SessionInformation>>을 사용하기 때문에 equals/hashCode를 오버라이딩해 해야 함

    // SessionRegistryImpl 내부
    private final ConcurrentMap> principals = new ConcurrentHashMap<>();

    public void registerNewSession(String sessionId, Object principal) {
    Set sessions = principals.get(principal);
    // 여기서 principal.equals()로 동일 사용자 판단
    }
  • equals를 오버라이딩하지 않으면: 객체 참조 비교로 동작

  • SessionRegistry에 같은 사용자가 여러 번 등록되어, 중복 로그인 제어 X


2) LoginService

1) 로그인 메인 로직

    @Transactional
public LoginResponse login(LoginDto req, HttpServletRequest request,
HttpServletResponse response) {

// 1. 사용자 조회
User user = userRepository.findByEmail(req.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

// 2. 계정 잠김 체크
if (user.isLocked()) {
throw new AccountLockedException("계정이 잠겼습니다. 관리자에게 문의하세요.");
}

// 3. 계정 활성화 체크
if (!user.isEnabled()) {
throw new DisabledException("비활성화된 계정입니다.");
}

// 4. 비밀번호 검증
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
user.incrementFailCount();
userRepository.save(user);

if (user.isLocked()) {
throw new AccountLockedException(
"5회 실패로 계정이 잠겼습니다. 관리자에게 문의하세요."
);
}

throw new BadCredentialsException(
String.format("아이디 또는 비밀번호가 틀렸습니다. (남은 시도: %d회)",
5 - user.getFailCount())
);
}

// 5. 첫 로그인 체크
if (user.isFirstLogin()) {
return LoginResponse.firstLogin("비밀번호를 변경해야 합니다.");
}

// 6. Spring Security 인증 실행
performSecurityAuthentication(req, user, request, response);

// 7. 로그인 성공 처리
user.resetFailCount();
userRepository.save(user);

return LoginResponse.success(user);
}

2) Spring Security 인증 수행

    private void performSecurityAuthentication(LoginDto req, User user,
HttpServletRequest request,
HttpServletResponse response) {

// 1. AuthenticationManager를 통한 인증
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
req.getEmail(),
req.getPassword(),
user.getAuthorities()
);

Authentication authentication = authenticationManager.authenticate(authToken);

// 2. SessionAuthenticationStrategy 호출 (세션 정책 적용) → SessionRegistry 등록
sessionAuthenticationStrategy.onAuthentication(authentication, request, response);

// 3. SecurityContext 생성 및 설정
SecurityContext context = securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

// 4. SecurityContextRepository에 저장 (HttpSession에 저장됨)
securityContextRepository.saveContext(context, request, response);
}

3) SessionConfig - 세션 정책 설정

1) HttpSessionEventPublisher

세션 생성/삭제 이벤트를 Spring Security에 전달

    public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher() {
@Override
public void sessionCreated(HttpSessionEvent event) {
super.sessionCreated(event);
}

@Override
public void sessionDestroyed(HttpSessionEvent event) {
super.sessionDestroyed(event);
}
};
}

2) 서블릿 리스너에 등록 (정상 동작 보장)

(1)HttpSessionEventPublisher을 ServletListener에 등록해야 로그아웃 시 SessionRegistry에서 세션이 제거됨 하지 않을 경우, 세션이 누적

    public ServletListenerRegistrationBean
httpSessionEventPublisherRegistration() {
return new ServletListenerRegistrationBean<>(httpSessionEventPublisher());
}

3) SessionRegistry

세션 및 사용자 상태 관리 핵심 컴포넌트

    public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl() {
@Override
public void registerNewSession(String sessionId, Object principal) {
super.registerNewSession(sessionId, principal);
}

@Override
public void removeSessionInformation(String sessionId) {
SessionInformation info = getSessionInformation(sessionId);
super.removeSessionInformation(sessionId);
}

private String extractPrincipalName(Object principal) {
if (principal instanceof User) {
return ((User) principal).getEmail();
}
return principal.toString();
}
};
}

4) SessionAuthenticationStrategy

인증 성공 시 작동하는 세션 정책

    public SessionAuthenticationStrategy sessionAuthenticationStrategy(
SessionRegistry sessionRegistry) {

// 3-1) 동시 로그인 개수 제한
ConcurrentSessionControlAuthenticationStrategy concurrentControl =
new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
concurrentControl.setMaximumSessions(1); // 동일 계정 1명만
concurrentControl.setExceptionIfMaximumExceeded(false); // 기존 세션 만료

// 3-2) 세션 고정 공격 방지 (세션 ID 재발급)
SessionFixationProtectionStrategy sessionFixation =
new SessionFixationProtectionStrategy();
sessionFixation.setMigrateSessionAttributes(true);

// 3-3) SessionRegistry에 세션 등록
RegisterSessionAuthenticationStrategy registerSession =
new RegisterSessionAuthenticationStrategy(sessionRegistry);

// 3가지 전략을 Composite로 통합
return new CompositeSessionAuthenticationStrategy(Arrays.asList(
concurrentControl,
sessionFixation,
registerSession
));
}

4) SecurityFilterChain 설정

컨트롤러는 "로그인 시 딱 한 번 실행"되어 "세션 생성 및 전략 수동 호출"함 필터는 "로그인 이후 API"에 "세션 유효성 검증 및 정책 담당"

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // REST API는 CSRF 불필요

// SecurityContext 저장 방식 설정
.securityContext(context -> context
.securityContextRepository(securityContextRepository())
.requireExplicitSave(true) // 명시적 저장 (Controller에서 수동 저장)
)

// 세션 관리 설정
.sessionManagement(session -> session
.sessionFixation().changeSessionId() // 세션 고정 공격 방지
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // 동일 계정 1명만
.maxSessionsPreventsLogin(false) // 기존 세션 만료 방식
.sessionRegistry(sessionRegistry())
.expiredSessionStrategy(customSessionExpiredStrategy()) // 만료 시 처리
)

// 예외 처리
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationDeniedHandler()) // 401
.accessDeniedHandler(authorizationDeniedHandler()) // 403
)

// 커스텀 필터 추가
.addFilterBefore(sessionExpiredFilter(), SecurityContextPersistenceFilter.class)
.addFilterBefore(improvedLogoutFilter(), LogoutFilter.class);

return http.build();
}

주요 설정 설명

  • requireExplicitSave

    • 기본값(false)은 SecurityContextPersistenceFilter가 자동 저장
    • true로 설정 시 Controller에서 수동으로 saveContext() 호출 필요
    • true로 변경하지 않으면, SecurityContext가 두 번 저장됨
  • sessionFixation().changeSessionId()

    • 세션 고정 공격(Session Fixation) 방지
    • 로그인 성공 시 세션 ID를 새로 발급하여 공격자가 탈취한 세션 ID 무효화

4. 동작 검증

1) 정상 로그인

POST /api/login
{
"email": "user@example.com",
"password": "password123"
}

→ 200 OK
{
"status": "SUCCESS",
"user": {
"email": "user@example.com",
"type": "CUSTOMER"
}
}

2) 5회 실패 → 계정 잠김

# 1~4회 실패
→ 400 Bad Request
{
"error": "아이디 또는 비밀번호가 틀렸습니다. (남은 시도: 4회)"
}

# 5회 실패
→ 423 Locked
{
"error": "5회 실패로 계정이 잠겼습니다."
}

3) 첫 로그인

POST /api/login
{
"email": "newuser@example.com",
"password": "temp123"
}

→ 200 OK
{
"status": "FIRST_LOGIN",
"message": "비밀번호를 변경해야 합니다."
}

4) 중복 로그인 제어

# 사용자 A: 로그인 성공 (Session ID: abc123)
# 사용자 B: 같은 계정으로 로그인 시도

→ 사용자 A의 세션이 만료됨
→ 사용자 A가 API 호출 시 401 Unauthorized 응답

5. 정리

핵심 요약

JSON 기반 로그인 + 계정 잠금 + 첫 로그인 체크 + ROLE별 응답
이 4가지를 FormLogin 필터에서 처리하기는 너무 복잡해습니다.

Controller 방식으로 전환한 후:

  • 비즈니스 로직이 Service 레이어에 명확히 분리됨
  • Spring Security의 세션 정책은 그대로 활용

트레이드오프

  • 장점:

    • 비즈니스 로직 자유도 높음
    • 코드 가독성 좋음
  • 단점:

    • Spring Security의 "자동화"를 포기
    • SessionAuthenticationStrategy 수동 호출 필요
    • SecurityContext 수동 저장 필요