Skip to the content.

Security Guide — REST API 보안 아키텍처

목적: REST API Layer의 인증/인가 보안 아키텍처 및 구현 가이드

철학: 업계 표준 준수, 컴포넌트 분리, Zero-Trust 보안 모델


📌 아키텍처 패턴 선택

이 프로젝트는 두 가지 인증 아키텍처 패턴을 지원합니다:

패턴 설명 권장 환경 문서
JWT-per-Service 각 서비스가 JWT 직접 검증 단일 서비스, 개발 환경 이 문서
Gateway Only Gateway에서 JWT 검증, 서비스는 헤더만 읽음 마이크로서비스, 운영 환경 gateway-only-architecture.md

⚠️ 마이크로서비스 환경에서는 Gateway Only 패턴을 권장합니다.

Gateway Only 아키텍처 가이드 참조


1️⃣ 보안 아키텍처 개요 (JWT-per-Service)

전체 구조

┌─────────────────────────────────────────────────────────────────┐
│                        Security Layer                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐ │
│  │ MdcLogging  │ → │ JwtAuth     │ → │ Security            │ │
│  │ Filter      │    │ Filter      │    │ FilterChain         │ │
│  └─────────────┘    └─────────────┘    └─────────────────────┘ │
│         │                  │                                    │
│         ↓                  ↓                                    │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐ │
│  │ MdcContext  │    │ TokenCookie │    │ AuthenticationError │ │
│  │ Holder      │    │ Writer      │    │ Handler             │ │
│  └─────────────┘    └─────────────┘    └─────────────────────┘ │
│                            │                     │              │
│                            ↓                     ↓              │
│                     ┌─────────────┐    ┌─────────────────────┐ │
│                     │ Security    │    │ RFC 7807            │ │
│                     │ Context     │    │ ProblemDetail       │ │
│                     │ Authenticator│    │                     │ │
│                     └─────────────┘    └─────────────────────┘ │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│                     Configuration Layer                         │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐ │
│  │ ApiPaths    │    │ Security    │    │ SecurityConfig      │ │
│  │ (Constants) │    │ Paths       │    │ (@Configuration)    │ │
│  └─────────────┘    └─────────────┘    └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

컴포넌트 역할

컴포넌트 책임 패키지 위치
ApiPaths API 경로 상수 정의 auth/paths/
SecurityPaths 보안 정책별 경로 그룹화 auth/paths/
SecurityConfig Spring Security 설정 auth/config/
JwtAuthenticationFilter JWT 토큰 검증 + Silent Refresh auth/filter/
MdcLoggingFilter Request ID 추적 auth/filter/
SecurityContextAuthenticator SecurityContext 인증 설정 auth/component/
TokenCookieWriter HttpOnly 쿠키 관리 auth/component/
MdcContextHolder MDC 컨텍스트 관리 auth/component/
AuthenticationErrorHandler 인증/인가 에러 처리 auth/handler/

2️⃣ 패키지 구조

권장 구조

adapter-in/rest-api/src/main/java/com/company/adapter/in/rest/
├── auth/
│   ├── config/
│   │   └── SecurityConfig.java          # Spring Security 설정
│   ├── paths/
│   │   ├── ApiPaths.java                 # API 경로 상수
│   │   └── SecurityPaths.java            # 보안 정책별 경로 그룹
│   ├── filter/
│   │   ├── JwtAuthenticationFilter.java  # JWT 인증 필터
│   │   └── MdcLoggingFilter.java         # MDC 로깅 필터
│   ├── component/
│   │   ├── SecurityContextAuthenticator.java  # 인증 설정
│   │   ├── TokenCookieWriter.java        # 쿠키 관리
│   │   └── MdcContextHolder.java         # MDC 컨텍스트
│   ├── handler/
│   │   ├── AuthenticationErrorHandler.java    # 인증 에러 처리
│   │   └── OAuth2SuccessHandler.java     # OAuth2 성공 처리
│   └── utils/
│       └── CookieUtils.java              # 쿠키 유틸리티
└── ...

3️⃣ 핵심 컴포넌트 상세

3.1 ApiPaths (경로 상수)

상세 가이드: api-paths-guide.md

package com.company.adapter.in.rest.auth.paths;

/**
 * API 경로 상수 정의
 *
 * <p>모든 REST API 엔드포인트 경로를 상수로 관리합니다.
 *
 * <p>장점:
 * <ul>
 *   <li>컴파일 타임 검증 - 오타 방지</li>
 *   <li>IDE 자동완성/리팩토링 지원</li>
 *   <li>Controller와 Security 경로 동기화 보장</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
public final class ApiPaths {

    public static final String API_V1 = "/api/v1";

    private ApiPaths() {
        // 인스턴스화 방지
    }

    /**
     * Member 도메인 경로
     */
    public static final class Members {
        public static final String BASE = API_V1 + "/members";
        public static final String REGISTER = BASE;                    // POST
        public static final String BY_ID = BASE + "/{id}";            // GET, PUT, DELETE
        public static final String PASSWORD_RESET = BASE + "/password/reset";  // POST

        private Members() {}
    }

    /**
     * Auth 도메인 경로
     */
    public static final class Auth {
        public static final String BASE = API_V1 + "/auth";
        public static final String LOGIN = BASE + "/login";           // POST
        public static final String LOGOUT = BASE + "/logout";         // POST
        public static final String REFRESH = BASE + "/refresh";       // POST

        private Auth() {}
    }

    /**
     * Order 도메인 경로
     */
    public static final class Orders {
        public static final String BASE = API_V1 + "/orders";
        public static final String BY_ID = BASE + "/{id}";
        public static final String CANCEL = BASE + "/{id}/cancel";
        public static final String CONFIRM = BASE + "/{id}/confirm";

        private Orders() {}
    }
}

3.2 SecurityPaths (보안 정책 그룹)

package com.company.adapter.in.rest.auth.paths;

/**
 * 보안 정책별 경로 그룹화
 *
 * <p>인증/인가 정책에 따라 경로를 그룹화합니다.
 *
 * @author development-team
 * @since 1.0.0
 */
public final class SecurityPaths {

    private SecurityPaths() {}

    /**
     * 인증 불필요 엔드포인트 (Public)
     *
     * <p>로그인, 회원가입 등 인증 없이 접근 가능한 경로
     */
    public static final String[] PUBLIC_ENDPOINTS = {
        ApiPaths.Members.REGISTER,
        ApiPaths.Members.PASSWORD_RESET,
        ApiPaths.Auth.LOGIN,
        ApiPaths.Auth.REFRESH
    };

    /**
     * 인증 불필요 패턴 (Public Patterns)
     *
     * <p>와일드카드가 필요한 공개 경로
     */
    public static final String[] PUBLIC_PATTERNS = {
        "/oauth2/**",
        "/login/oauth2/**",
        "/swagger-ui/**",
        "/v3/api-docs/**",
        "/actuator/**",
        ApiPaths.API_V1 + "/health"
    };

    /**
     * 관리자 전용 엔드포인트 (ROLE_ADMIN)
     */
    public static final String[] ADMIN_ENDPOINTS = {
        ApiPaths.API_V1 + "/admin/**"
    };

    /**
     * 리소스 소유자 검증 필요 엔드포인트
     *
     * <p>Method Security로 추가 검증 필요
     */
    public static final String[] OWNER_VERIFICATION_REQUIRED = {
        ApiPaths.Members.BY_ID,
        ApiPaths.Orders.BY_ID,
        ApiPaths.Orders.CANCEL
    };
}

3.3 SecurityConfig (Spring Security 설정)

package com.company.adapter.in.rest.auth.config;

import com.company.adapter.in.rest.auth.filter.JwtAuthenticationFilter;
import com.company.adapter.in.rest.auth.handler.AuthenticationErrorHandler;
import com.company.adapter.in.rest.auth.paths.SecurityPaths;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * Spring Security 설정
 *
 * <p>JWT 기반 Stateless 인증 설정을 구성합니다.
 *
 * @author development-team
 * @since 1.0.0
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // @PreAuthorize 활성화
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final AuthenticationErrorHandler authenticationErrorHandler;

    public SecurityConfig(
            JwtAuthenticationFilter jwtAuthenticationFilter,
            AuthenticationErrorHandler authenticationErrorHandler) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.authenticationErrorHandler = authenticationErrorHandler;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            // CSRF 비활성화 (JWT 사용)
            .csrf(AbstractHttpConfigurer::disable)

            // Session Stateless
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // 인가 설정
            .authorizeHttpRequests(auth -> auth
                // Public Endpoints (인증 불필요)
                .requestMatchers(SecurityPaths.PUBLIC_ENDPOINTS).permitAll()
                .requestMatchers(SecurityPaths.PUBLIC_PATTERNS).permitAll()

                // Admin Endpoints (ROLE_ADMIN 필요)
                .requestMatchers(SecurityPaths.ADMIN_ENDPOINTS).hasRole("ADMIN")

                // 나머지는 인증 필요
                .anyRequest().authenticated()
            )

            // JWT 필터 추가
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class)

            // 에러 핸들러 설정
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint(authenticationErrorHandler)  // 401
                .accessDeniedHandler(authenticationErrorHandler)       // 403
            )

            .build();
    }
}

3.4 JwtAuthenticationFilter (JWT 인증 + Silent Refresh)

package com.company.adapter.in.rest.auth.filter;

import com.company.adapter.in.rest.auth.component.MdcContextHolder;
import com.company.adapter.in.rest.auth.component.SecurityContextAuthenticator;
import com.company.adapter.in.rest.auth.component.TokenCookieWriter;
import com.company.adapter.in.rest.auth.utils.CookieUtils;
import com.company.application.member.dto.response.TokenPairResponse;
import com.company.application.member.port.out.TokenProviderPort;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Optional;
import org.springframework.web.filter.OncePerRequestFilter;

/**
 * JWT Authentication Filter
 *
 * <p>쿠키에서 Access Token을 추출하여 인증을 처리하는 필터
 *
 * <p>동작 방식:
 * <ol>
 *   <li>쿠키에서 Access Token 추출</li>
 *   <li>Access Token 유효 → 인증 성공</li>
 *   <li>Access Token 만료 + Refresh Token 유효 → Silent Refresh (자동 갱신)</li>
 *   <li>둘 다 없거나 만료 → 인증 실패 (다음 필터로 전달)</li>
 * </ol>
 *
 * <p>Silent Refresh:
 * <ul>
 *   <li>Access Token이 만료되었지만 Refresh Token이 유효한 경우</li>
 *   <li>새로운 Access Token을 발급하여 쿠키에 설정</li>
 *   <li>사용자는 401 없이 계속 서비스 이용 가능</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    private final TokenProviderPort tokenProviderPort;
    private final TokenCookieWriter tokenCookieWriter;
    private final SecurityContextAuthenticator securityContextAuthenticator;
    private final MdcContextHolder mdcContextHolder;

    public JwtAuthenticationFilter(
            TokenProviderPort tokenProviderPort,
            TokenCookieWriter tokenCookieWriter,
            SecurityContextAuthenticator securityContextAuthenticator,
            MdcContextHolder mdcContextHolder) {
        this.tokenProviderPort = tokenProviderPort;
        this.tokenCookieWriter = tokenCookieWriter;
        this.securityContextAuthenticator = securityContextAuthenticator;
        this.mdcContextHolder = mdcContextHolder;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        Optional<String> accessToken = extractAccessToken(request);

        if (accessToken.isPresent()) {
            String token = accessToken.get();

            if (tokenProviderPort.validateAccessToken(token)) {
                // Access Token 유효 → 인증 성공
                authenticateAndSetMdc(request, token);
            } else if (tokenProviderPort.isAccessTokenExpired(token)) {
                // Access Token 만료 → Silent Refresh 시도
                trySilentRefresh(request, response);
            }
        } else {
            // Access Token 없음 → Refresh Token으로 Silent Refresh 시도
            trySilentRefresh(request, response);
        }

        filterChain.doFilter(request, response);
    }

    /**
     * SecurityContext 인증 및 MDC 설정
     */
    private void authenticateAndSetMdc(HttpServletRequest request, String accessToken) {
        String memberId = securityContextAuthenticator.authenticate(request, accessToken);
        mdcContextHolder.setMemberId(memberId);
    }

    /**
     * Silent Refresh 시도
     *
     * <p>Refresh Token이 유효한 경우 새로운 토큰 쌍을 발급하고 쿠키에 설정
     */
    private void trySilentRefresh(HttpServletRequest request, HttpServletResponse response) {
        CookieUtils.getRefreshToken(request)
            .filter(tokenProviderPort::validateRefreshToken)
            .ifPresent(refreshToken -> {
                String memberId = tokenProviderPort.extractMemberIdFromRefreshToken(refreshToken);
                TokenPairResponse newTokens = tokenProviderPort.generateTokenPair(memberId);

                // 새 토큰을 쿠키에 설정
                tokenCookieWriter.addTokenCookies(
                    response,
                    newTokens.accessToken(),
                    newTokens.refreshToken(),
                    newTokens.accessTokenExpiresIn(),
                    newTokens.refreshTokenExpiresIn());

                // 새 Access Token으로 인증 설정
                authenticateAndSetMdc(request, newTokens.accessToken());
            });
    }

    /**
     * Access Token 추출
     *
     * <p>우선순위:
     * <ol>
     *   <li>쿠키에서 추출 (access_token)</li>
     *   <li>Authorization 헤더에서 추출 (Bearer ...)</li>
     * </ol>
     */
    private Optional<String> extractAccessToken(HttpServletRequest request) {
        // 1. 쿠키에서 추출 시도
        Optional<String> cookieToken = CookieUtils.getAccessToken(request);
        if (cookieToken.isPresent()) {
            return cookieToken;
        }

        // 2. Authorization 헤더에서 추출 시도
        String authHeader = request.getHeader(AUTHORIZATION_HEADER);
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            return Optional.of(authHeader.substring(BEARER_PREFIX.length()));
        }

        return Optional.empty();
    }
}

3.5 AuthenticationErrorHandler (RFC 7807 에러 처리)

package com.company.adapter.in.rest.auth.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

/**
 * Authentication Error Handler
 *
 * <p>인증/인가 에러를 RFC 7807 ProblemDetail 형식으로 처리
 *
 * <p>역할:
 * <ul>
 *   <li>AuthenticationEntryPoint: 인증 실패 (401 Unauthorized)</li>
 *   <li>AccessDeniedHandler: 인가 실패 (403 Forbidden)</li>
 * </ul>
 *
 * <p>응답 형식:
 * <ul>
 *   <li>RFC 7807 ProblemDetail 표준 준수</li>
 *   <li>GlobalExceptionHandler와 동일한 형식 유지</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Component
public class AuthenticationErrorHandler
        implements AuthenticationEntryPoint, AccessDeniedHandler {

    private static final Logger log = LoggerFactory.getLogger(AuthenticationErrorHandler.class);

    private final ObjectMapper objectMapper;

    public AuthenticationErrorHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    /**
     * 인증 실패 처리 (401 Unauthorized)
     */
    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException) throws IOException {

        log.debug("Authentication failed: {}", authException.getMessage());

        ProblemDetail problemDetail = buildProblemDetail(
            request,
            HttpStatus.UNAUTHORIZED,
            "Unauthorized",
            "인증이 필요합니다. 로그인 후 다시 시도해주세요.",
            "AUTH_REQUIRED");

        writeResponse(response, HttpStatus.UNAUTHORIZED, problemDetail);
    }

    /**
     * 인가 실패 처리 (403 Forbidden)
     */
    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException {

        log.warn("Access denied: {}", accessDeniedException.getMessage());

        ProblemDetail problemDetail = buildProblemDetail(
            request,
            HttpStatus.FORBIDDEN,
            "Forbidden",
            "해당 리소스에 대한 접근 권한이 없습니다.",
            "ACCESS_DENIED");

        writeResponse(response, HttpStatus.FORBIDDEN, problemDetail);
    }

    /**
     * RFC 7807 ProblemDetail 생성
     */
    private ProblemDetail buildProblemDetail(
            HttpServletRequest request,
            HttpStatus status,
            String title,
            String detail,
            String code) {

        ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
        pd.setTitle(title);
        pd.setType(URI.create("about:blank"));

        // 표준 확장 필드
        pd.setProperty("timestamp", Instant.now().toString());
        pd.setProperty("code", code);

        // instance (요청 경로)
        String uri = request.getRequestURI();
        if (request.getQueryString() != null && !request.getQueryString().isBlank()) {
            uri = uri + "?" + request.getQueryString();
        }
        pd.setInstance(URI.create(uri));

        // Tracing 정보 (MDC에서)
        String traceId = MDC.get("traceId");
        String requestId = MDC.get("requestId");
        if (traceId != null) {
            pd.setProperty("traceId", traceId);
        }
        if (requestId != null) {
            pd.setProperty("requestId", requestId);
        }

        return pd;
    }

    /**
     * JSON 응답 작성
     */
    private void writeResponse(
            HttpServletResponse response,
            HttpStatus status,
            ProblemDetail problemDetail) throws IOException {
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());

        objectMapper.writeValue(response.getWriter(), problemDetail);
    }
}

3.6 TokenCookieWriter (HttpOnly 쿠키 관리)

package com.company.adapter.in.rest.auth.component;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;

/**
 * Token Cookie Writer
 *
 * <p>JWT 토큰을 HttpOnly 쿠키로 관리하는 컴포넌트
 *
 * <p>쿠키 보안 설정:
 * <ul>
 *   <li>HttpOnly: true - JavaScript 접근 차단 (XSS 방지)</li>
 *   <li>Secure: 환경 설정에 따름 - HTTPS 전용 (프로덕션)</li>
 *   <li>SameSite: Lax - CSRF 방지</li>
 * </ul>
 *
 * @author development-team
 * @since 1.0.0
 */
@Component
public class TokenCookieWriter {

    public static final String ACCESS_TOKEN_COOKIE = "access_token";
    public static final String REFRESH_TOKEN_COOKIE = "refresh_token";

    private static final String ROOT_PATH = "/";

    private final boolean secure;
    private final String domain;
    private final String sameSite;

    public TokenCookieWriter(SecurityProperties securityProperties) {
        this.secure = securityProperties.getCookie().isSecure();
        this.domain = securityProperties.getCookie().getDomain();
        this.sameSite = securityProperties.getCookie().getSameSite();
    }

    /**
     * 토큰 쿠키들을 Response에 추가
     */
    public void addTokenCookies(
            HttpServletResponse response,
            String accessToken,
            String refreshToken,
            long accessTokenExpiry,
            long refreshTokenExpiry) {
        addAccessTokenCookie(response, accessToken, accessTokenExpiry);
        addRefreshTokenCookie(response, refreshToken, refreshTokenExpiry);
    }

    /**
     * Access Token 쿠키 추가
     */
    public void addAccessTokenCookie(
            HttpServletResponse response,
            String accessToken,
            long accessTokenExpiry) {
        String cookieValue = buildCookieValue(
            ACCESS_TOKEN_COOKIE, accessToken, ROOT_PATH, accessTokenExpiry);
        response.addHeader("Set-Cookie", cookieValue);
    }

    /**
     * Refresh Token 쿠키 추가
     */
    public void addRefreshTokenCookie(
            HttpServletResponse response,
            String refreshToken,
            long refreshTokenExpiry) {
        String cookieValue = buildCookieValue(
            REFRESH_TOKEN_COOKIE, refreshToken, ROOT_PATH, refreshTokenExpiry);
        response.addHeader("Set-Cookie", cookieValue);
    }

    /**
     * 토큰 쿠키들 삭제 (로그아웃)
     */
    public void deleteTokenCookies(HttpServletResponse response) {
        String accessCookie = buildCookieValue(ACCESS_TOKEN_COOKIE, "", ROOT_PATH, 0);
        String refreshCookie = buildCookieValue(REFRESH_TOKEN_COOKIE, "", ROOT_PATH, 0);
        response.addHeader("Set-Cookie", accessCookie);
        response.addHeader("Set-Cookie", refreshCookie);
    }

    /**
     * Set-Cookie 헤더 값 생성
     */
    private String buildCookieValue(String name, String value, String path, long maxAge) {
        StringBuilder cookie = new StringBuilder();
        cookie.append(name).append("=").append(value);
        cookie.append("; Path=").append(path);
        cookie.append("; Max-Age=").append(maxAge);
        cookie.append("; HttpOnly");

        if (secure) {
            cookie.append("; Secure");
        }

        if (domain != null && !domain.isBlank() && !"localhost".equals(domain)) {
            cookie.append("; Domain=").append(domain);
        }

        cookie.append("; SameSite=").append(capitalize(sameSite));

        return cookie.toString();
    }

    private String capitalize(String str) {
        if (str == null || str.isEmpty()) {
            return str;
        }
        return str.substring(0, 1).toUpperCase(java.util.Locale.ROOT)
            + str.substring(1).toLowerCase(java.util.Locale.ROOT);
    }
}

3.7 MdcLoggingFilter (요청 추적)

package com.company.adapter.in.rest.auth.filter;

import com.company.adapter.in.rest.auth.component.MdcContextHolder;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/**
 * MDC Logging Filter
 *
 * <p>요청 추적을 위한 MDC(Mapped Diagnostic Context) 설정 필터
 *
 * <p>Gateway에서 전달된 X-Request-Id를 MDC에 설정하고, 없으면 새로 생성
 *
 * <p>설정되는 MDC 키:
 * <ul>
 *   <li>requestId: 요청 추적 ID (Gateway에서 전달 또는 자체 생성)</li>
 *   <li>memberId: 인증된 사용자 ID (JwtAuthenticationFilter에서 설정)</li>
 * </ul>
 *
 * <p>필터 순서: 가장 먼저 실행되어야 함 (Ordered.HIGHEST_PRECEDENCE)
 *
 * @author development-team
 * @since 1.0.0
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcLoggingFilter extends OncePerRequestFilter {

    public static final String REQUEST_ID_HEADER = "X-Request-Id";
    public static final String MDC_REQUEST_ID = "requestId";

    private final MdcContextHolder mdcContextHolder;

    public MdcLoggingFilter(MdcContextHolder mdcContextHolder) {
        this.mdcContextHolder = mdcContextHolder;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        try {
            // Gateway에서 전달된 Request ID 사용, 없으면 생성
            String requestId = request.getHeader(REQUEST_ID_HEADER);
            if (requestId == null || requestId.isBlank()) {
                requestId = generateRequestId();
            }

            // MDC에 requestId 설정
            MDC.put(MDC_REQUEST_ID, requestId);

            // Response Header에도 추가 (클라이언트 디버깅용)
            response.setHeader(REQUEST_ID_HEADER, requestId);

            filterChain.doFilter(request, response);
        } finally {
            // 요청 완료 후 MDC 정리
            MDC.remove(MDC_REQUEST_ID);
            mdcContextHolder.clearMemberId();
        }
    }

    /**
     * Request ID 생성 (UUID 앞 8자리)
     */
    private String generateRequestId() {
        return UUID.randomUUID().toString().substring(0, 8);
    }
}

4️⃣ Method Security (리소스 소유자 검증)

@PreAuthorize 사용

package com.company.adapter.in.rest.order.controller;

import com.company.adapter.in.rest.auth.paths.ApiPaths;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

/**
 * Order Command Controller
 */
@RestController
@RequestMapping(ApiPaths.Orders.BASE)
public class OrderCommandController {

    /**
     * 주문 취소 - 리소스 소유자만 가능
     */
    @PatchMapping("/{id}/cancel")
    @PreAuthorize("@orderSecurityChecker.isOwner(#id, authentication.principal.memberId)")
    public ResponseEntity<ApiResponse<Void>> cancelOrder(@PathVariable Long id) {
        // ...
    }

    /**
     * 관리자 전용 - 강제 취소
     */
    @PatchMapping("/{id}/force-cancel")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ApiResponse<Void>> forceCancelOrder(@PathVariable Long id) {
        // ...
    }
}

Security Checker Bean

package com.company.adapter.in.rest.auth.component;

import com.company.application.order.port.in.query.OrderQueryUseCase;
import org.springframework.stereotype.Component;

/**
 * Order 리소스 소유자 검증
 */
@Component("orderSecurityChecker")
public class OrderSecurityChecker {

    private final OrderQueryUseCase orderQueryUseCase;

    public OrderSecurityChecker(OrderQueryUseCase orderQueryUseCase) {
        this.orderQueryUseCase = orderQueryUseCase;
    }

    /**
     * 주문 소유자 검증
     */
    public boolean isOwner(Long orderId, String memberId) {
        return orderQueryUseCase.isOrderOwner(orderId, Long.parseLong(memberId));
    }
}

5️⃣ 환경별 설정

application.yml (공통)

security:
  jwt:
    secret: ${JWT_SECRET:must-be-changed-in-production}
    access-token-expiration: 3600      # 1 hour
    refresh-token-expiration: 604800   # 7 days

  cookie:
    domain: ${COOKIE_DOMAIN:localhost}
    secure: ${COOKIE_SECURE:false}
    same-site: lax

  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:8080
    allowed-methods:
      - GET
      - POST
      - PUT
      - PATCH
      - DELETE
      - OPTIONS
    allowed-headers:
      - "*"
    exposed-headers:
      - Authorization
      - Set-Cookie
      - X-Request-Id
    allow-credentials: true

application-prod.yml (운영)

security:
  cookie:
    secure: true   # HTTPS 전용
    domain: example.com

6️⃣ Do / Don’t

✅ Good Patterns

// ✅ 1. Constants로 경로 관리
@RequestMapping(ApiPaths.Orders.BASE)

// ✅ 2. SecurityPaths로 정책 그룹화
.requestMatchers(SecurityPaths.PUBLIC_ENDPOINTS).permitAll()

// ✅ 3. Method Security로 리소스 소유자 검증
@PreAuthorize("@orderSecurityChecker.isOwner(#id, authentication.principal.memberId)")

// ✅ 4. RFC 7807 ProblemDetail 사용
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, detail)

// ✅ 5. HttpOnly 쿠키로 토큰 저장
cookie.append("; HttpOnly");
cookie.append("; SameSite=Lax");

❌ Anti-Patterns

// ❌ 1. 하드코딩 경로
@RequestMapping("/api/v1/orders")

// ❌ 2. YAML에만 경로 정의 (SpEL 방식)
@RequestMapping("${api.endpoints.order.base}")

// ❌ 3. Controller에서 직접 인가 로직
if (!order.getMemberId().equals(currentUser.getId())) {
    throw new AccessDeniedException("...");
}

// ❌ 4. 일반 JSON 에러 응답 (RFC 7807 미준수)
response.getWriter().write("{\"error\": \"Unauthorized\"}");

// ❌ 5. Authorization 헤더로만 토큰 전달 (XSS 취약)
localStorage.setItem("token", accessToken);

7️⃣ 체크리스트


8️⃣ 관련 가이드


작성자: Development Team 최종 수정일: 2025-11-13 버전: 1.0.0