Skip to content

Spring 인증과 세션 관리 구현 전략

Connectrip 개발 중 사용자 인증과 세션 관리를 위한 커스텀 구현이 필요했다. Spring Security를 사용하지 않고 직접 구현해야 하는 상황에서 필터와 인터셉터를 활용한 두 가지 접근 방식을 비교 검토했다. 각 방식의 장단점과 실제 구현 과정에서 발견한 세션 관리의 핵심 포인트들을 정리했다.

인증 구현 접근법 비교

Filter 기반: 서블릿 레벨에서의 조기 차단

java
public class SessionAuthenticationFilter implements Filter {

    private static final Set<PermitPattern> PERMIT_PATTERNS = Set.of(
            new PermitPattern("/api/sign-in", Set.of(POST.name())),
            new PermitPattern("/api/posts", Set.of(GET.name())),
            new PermitPattern("/api/posts/\\d+", Set.of(GET.name()))
    );

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // 인증 불필요 경로 체크
        for (PermitPattern pattern : PERMIT_PATTERNS) {
            if (pattern.matches(httpRequest.getRequestURI(), httpRequest.getMethod())) {
                chain.doFilter(request, response);
                return;
            }
        }

        // 세션 기반 인증 체크
        HttpSession session = httpRequest.getSession(false);
        if (session != null && session.getAttribute("memberId") != null) {
            chain.doFilter(request, response);
            return;
        }

        httpResponse.setStatus(401);
    }
}

필터 방식은 서블릿 컨테이너 레벨에서 동작하기 때문에 SpringDispatcherServlet에 도달하기 전에 요청을 차단할 수 있다. 이는 인증되지 않은 요청이 Spring MVC의 복잡한 처리 과정(핸들러 매핑, 어댑터 선택, 인터셉터 체인 등)을 거치지 않아 리소스 절약에 도움이 된다.

반면 URL 패턴 기반의 매칭은 정규식이나 복잡한 경로 변수를 활용하기 어려워 유연성이 제한된다. 예를 들어 /api/users/{id}/posts/{postId} 같은 경로 패턴을 필터에서 정확히 매칭하려면 상당한 구현 복잡도가 필요하다.

Interceptor 기반: 어노테이션 중심의 유연한 제어

java
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!requiredAuthentication(handler)) {
            return true;
        }

        HttpSession session = request.getSession(false);
        if (session == null || session.getAttribute("memberId") == null) {
            throw new AuthenticationException();
        }

        return true;
    }

    private boolean requiredAuthentication(Object handler) {
        return handler instanceof HandlerMethod handlerMethod &&
                handlerMethod.getMethodAnnotation(AuthenticationRequired.class) != null;
    }
}

인터셉터는 @AuthenticationRequired 어노테이션을 활용해 메서드 레벨에서 세밀한 인증 제어가 가능하다. HandlerMethod를 통해 어노테이션 정보에 접근할 수 있어 역할 기반 권한 검증, 메서드별 다른 인증 규칙 적용 등 복잡한 권한 로직 구현이 수월하다.

하지만 DispatcherServlet 내부에서 핸들러 매핑 이후에 실행되므로, Spring의 리플렉션 기반 처리 과정을 거친다. 또한 존재하지 않는 URL은 HandlerMapping 단계에서 걸러지므로 인터셉터가 실행되지 않지만, 매핑은 되었지만 인증이 실패한 경우에는 이미 상당한 처리 과정을 거친 후가 된다.

세션 관리 메커니즘 심화

세션 획득 방법별 차이점

Spring Boot에서 세션을 다루는 방법은 3가지이며, 각각 다른 특성을 가진다. 실제 개발하면서 이 차이점을 명확히 이해하지 못해 의도치 않은 세션 생성으로 메모리 누수가 발생한 경험이 있어, 각 방식의 동작 원리를 정확히 파악하는 것이 중요하다.

1. 의존성 주입: Scoped Proxy의 동적 위임

java
@RestController
public class TestController {

    private final HttpSession httpSession;

    public TestController(HttpSession httpSession) {
        this.httpSession = httpSession; // 동적 프록시 주입
    }
}

Controllersingleton scope이고 HttpSessionsession scope이므로, SpringScoped Proxy를 생성해 런타임에 적절한 세션 인스턴스로 위임한다. 이 프록시는 실제로는 ThreadLocal 기반으로 현재 요청의 세션을 찾아 메서드 호출을 전달하는 방식으로 동작한다.

2. HttpServletRequest를 통한 제어

java
@GetMapping("/method")
public ResponseEntity<Void> method(HttpServletRequest request) {
    HttpSession session = request.getSession(false); // 세션 없으면 null 반환
    return ResponseEntity.ok().build();
}

getSession(false) 사용으로 불필요한 세션 생성을 방지할 수 있다. 이는 공개 API에서 세션이 필요하지 않은 상황에서 메모리 절약에 도움이 된다.

3. 매개변수 직접 주입: 항상 세션 생성되는 함정

java
@GetMapping("/method")
public ResponseEntity<Void> method(HttpSession session) {
    // 항상 세션이 생성됨 - 공개 API에서 사용하면 불필요한 메모리 사용
    return ResponseEntity.ok().build();
}

세션 생성과 JSESSIONID 쿠키 처리

HttpSession이 생성되는 시점과 실제 클라이언트에게 JSESSIONID 쿠키가 전송되는 시점은 다르다. 서블릿 스펙에 따르면 세션 생성 직후에는 서버 메모리에만 세션이 저장되고, JSESSIONID 쿠키는 응답이 커밋(response.commit())될 때 Set-Cookie 헤더로 전송된다.

java
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
    HttpServletRequest httpRequest = (HttpServletRequest) request;
    HttpServletResponse httpResponse = (HttpServletResponse) response;

    // 세션이 없으면 새로 생성
    HttpSession session = httpRequest.getSession(true);
    log.info("세션 ID: {}", session.getId());

    // 이 시점에서는 아직 쿠키가 실제로 전송되지 않음
    // response가 commit되기 전까지는 Set-Cookie 헤더만 준비됨

    chain.doFilter(request, response);

    // 응답 완료 후 JSESSIONID 쿠키가 클라이언트로 전송됨
}

실제로 getSession() 호출 시점에 서블릿 컨테이너는 Set-Cookie 헤더를 응답에 추가하지만, 클라이언트가 받는 것은 HTTP 응답이 완전히 처리된 후다. 따라서 세션 생성 직후 getHeader("Set-Cookie")로 확인하는 것은 구현체에 따라 다를 수 있으며, 정확한 테스트를 위해서는 실제 HTTP 클라이언트로 요청해야 한다.

세션 저장소의 내부 구조 분석

Spring Boot의 내장 톰캣에서 세션이 실제로 어떻게 저장되는지 소스를 확인해보니, Tomcat 내장 메모리의 ConcurrentHashMap을 사용한다.

java
// `ManagerBase` 클래스
protected Map<String,Session> sessions = new ConcurrentHashMap<>();

@Override
public Session findSession(String id) throws IOException {
    return sessions.get(id);
}

ConcurrentHashMap의 특성상 분할 잠금으로 높은 동시성을 제공하고, 읽기 작업은 무잠금으로 동작한다. 하지만 메모리 기반이므로 서버 재시작 시 모든 세션이 손실되는 문제가 있어, 분산 환경에서는 별도의 세션 스토어가 필요하다.

세션 커스터마이징

쿠키 이름과 설정 변경

yaml
# application.yml
server:
  servlet:
    session:
      cookie:
        name: CONNECTRIP_SESSION
        http-only: true
        secure: false # HTTPS 환경에서는 true

세션 ID 생성 패턴 커스터마이징

java
public class CustomSessionManager extends StandardManager {

    private final SecureRandom secureRandom = new SecureRandom();

    @Override
    public Session createSession(String sessionId) {
        String customSessionId = generateCustomSessionId();
        Session session = super.createSession(customSessionId);
        session.setId(customSessionId);
        return session;
    }

    private String generateCustomSessionId() {
        byte[] randomBytes = new byte[16];
        secureRandom.nextBytes(randomBytes);

        StringBuilder sb = new StringBuilder("CONNECTRIP:");
        for (byte b : randomBytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

커스텀 매니저 등록

java
@Configuration
public class SessionConfig {

    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> customSession() {
        return factory -> factory.addContextCustomizers(
            (Context context) -> context.setManager(new CustomSessionManager())
        );
    }
}

보안 공격 방지 구현

세션 고정 공격의 실질적 위협

java
@PostMapping("/api/sign-in")
public ResponseEntity<Void> signIn(HttpServletRequest request, @RequestBody SignInRequest signInRequest) {
    // 인증 로직...

    // 기존 세션 무효화 (세션 고정 공격 방지)
    HttpSession oldSession = request.getSession(false);
    if (oldSession != null) {
        oldSession.invalidate();
    }

    // 새 세션 생성
    HttpSession newSession = request.getSession(true);
    newSession.setAttribute("memberId", memberId);

    return ResponseEntity.ok().build();
}

세션 고정 공격은 공격자가 특정 세션 ID를 미리 확보한 후, 사용자가 해당 세션으로 로그인하도록 유도하는 공격이다. 로그인 시점에 기존 세션을 무효화하고 새로운 세션을 생성해야 이를 방지할 수 있다.

동시 세션 제한으로 계정 공유 방지

java
@Component
public class SessionRegistry {

    private final Map<String, Set<String>> userSessions = new ConcurrentHashMap<>();

    public void registerSession(String userId, String sessionId) {
        userSessions.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(sessionId);
    }

    public boolean isSessionLimitExceeded(String userId, int maxSessions) {
        Set<String> sessions = userSessions.get(userId);
        return sessions != null && sessions.size() >= maxSessions;
    }
}

ArgumentResolver를 통한 사용자 정보 주입

java
@Component
public class AuthenticationArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().equals(Long.class) &&
               parameter.hasParameterAnnotation(AuthenticatedMember.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                 NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        return Objects.requireNonNull(request).getSession().getAttribute("memberId");
    }
}
java
@AuthenticationRequired
@GetMapping("/api/profile")
public ResponseEntity<UserProfile> getProfile(@AuthenticatedMember Long memberId) {
    // `memberId`가 자동으로 주입됨
    return ResponseEntity.ok(userService.getProfile(memberId));
}

동시 세션 제한은 하나의 계정으로 여러 기기에서 동시 로그인을 제한하여 계정 공유를 방지한다. 사용자별 활성 세션을 추적하고 제한 개수를 초과하면 기존 세션을 무효화하거나 새 로그인을 차단할 수 있다.

성능 최적화와 메모리 관리

공개 API에서의 세션 생성 방지

java
// 잘못된 예시 - 항상 세션 생성
@GetMapping("/api/public-data")
public ResponseEntity<Data> getPublicData(HttpSession session) {
    return ResponseEntity.ok(publicDataService.getData());
}
java
// 올바른 예시 - 필요시에만 세션 생성
@GetMapping("/api/public-data")
public ResponseEntity<Data> getPublicData(HttpServletRequest request) {
    HttpSession session = request.getSession(false);
    return ResponseEntity.ok(publicDataService.getData());
}

공개 API에서 HttpSession을 매개변수로 받으면 불필요한 세션이 생성되어 메모리를 낭비한다. 인증이 필요한 API에서만 세션을 사용하고, 공개 API에서는 HttpServletRequest를 통해 필요시에만 세션을 가져오는 방식이 효율적이다.

Redis 기반 분산 세션 스토어

Spring Boot 단일 인스턴스에서는 내장 메모리로 충분하지만, 로드 밸런싱 환경에서는 Spring SessionRedis를 활용한 외부 세션 스토어가 필요하다.

java
@EnableRedisHttpSession
@Configuration
public class RedisSessionConfig {

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory(
            new RedisStandaloneConfiguration("localhost", 6379)
        );
    }
}

실무에서 얻은 교훈

Connectrip에서 커스텀 인증 시스템을 구현하면서 깨달은 것은 단순히 기능 구현을 넘어 성능과 보안을 균형 있게 고려해야 한다는 점이다. 필터와 인터셉터 각각의 특성을 정확히 이해하고 프로젝트 상황에 맞는 선택이 중요하다.

필터 방식은 서블릿 레벨에서 조기 차단하여 Spring MVC 처리 과정을 생략할 수 있지만 유연성이 제한되고, 인터셉터 방식은 Spring 생태계와의 완전한 통합으로 복잡한 인증 로직 구현에 적합하다. 세션 관리에서는 의도치 않은 세션 생성으로 인한 메모리 누수를 방지하고, 세션 고정 공격 같은 보안 위협에 대한 대비책을 구현하는 것이 실제 운영 환경에서 중요하다.

실제 프로덕션에서는 Spring Security를 사용하는 것이 일반적이지만, 이런 커스텀 구현 경험을 통해 Spring MVC의 요청 처리 구조와 세션 관리 메커니즘을 깊이 있게 이해할 수 있었다. 특히 프레임워크에서 제공하는 기본 기능들이 어떤 원리로 동작하는지 파악함으로써, 문제 상황에서 더 빠르게 원인을 찾고 해결할 수 있게 되었다.

Released under the MIT License.