주문을 만드는 Controller는 이렇게 생겼다.

@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(
        @Valid @RequestBody CreateOrderRequest request) {
    ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand());
    OrderCreatedResponse data = OrderCreatedResponse.from(result);
    URI location = URI.create("/orders/" + data.getOrderId());
    return HttpApiResponses.created(location, data);
}

그런데 CreateOrderCommand에는 이 필드가 있어야 한다.

private final Long userId;      // 누가 주문했는가

한 줄씩 따라가기. @PostMapping("/orders")는 HTTP POST 방식으로 /orders에 온 요청을 이 메서드가 처리하게 하는 표시다. orderCommandUseCase는 UseCase 인터페이스로, Controller는 이 인터페이스만 보고 실제 주문 생성 절차는 그 뒤의 Service 클래스에 맡긴다.

userId는 요청 바디에 없다. 로그인 토큰에서 온다. 그래서 request.toCommand()가 그걸 채울 방법이 없다.

예외의 종류 표준에서 인증 실패를 AuthenticationEntryPoint로 처리하기로 정했고, common/security에는 그 핸들러들이 있다. 인증이 실패했을 때는 정했는데, 성공했을 때 그 주체를 어떻게 쓰는지는 정한 적이 없다.


먼저 확실히 아닌 것

// ❌ 절대 안 된다
public class CreateOrderRequest {
    private final Long userId;      // 클라이언트가 보낸다
    private final String productName;
}

클라이언트가 userId를 적어 보내면 남의 ID로 남 대신 주문할 수 있다. 인증을 한 의미가 사라진다. 이건 선택지가 아니라 취약점이다.

주체는 우리가 검증한 것에서만 와야 한다. 즉 SecurityContext다.


원칙 1. LoginUser는 api에서 죽는다

Entity가 Repository 구현체 안에서 죽고 Domain만 나가는 것과 같은 구조를 쓴다. 패키지 구조 표준이 정한 “Entity는 패키지 밖으로 나가지 않는다”는 규칙과 같은 모양이다 — Entity는 DB 테이블과 짝지어진 클래스라 Repository 구현체 파일 안에서만 만들어지고 쓰이며, 그 바깥(Service·Controller)으로는 Domain만 넘어간다.

// common/security/LoginUser.java — 프레임워크 의존이 없는 값 객체
@Getter
public class LoginUser {

    private final Long userId;

    private LoginUser(Long userId) {
        this.userId = Objects.requireNonNull(userId, "userId");
    }

    public static LoginUser of(Long userId) {
        return new LoginUser(userId);
    }
}

한 줄씩 따라가기. @Getter는 Lombok이 userId 필드를 읽는 getUserId() 메서드를 자동으로 만들어 준다는 표시다. 생성자 LoginUser(Long userId)는 private라 클래스 밖에서 직접 new LoginUser(...)를 부를 수 없고, 대신 아래 of() 정적 메서드로만 만들 수 있다. Objects.requireNonNull(userId, "userId")는 userId가 null이면 그 자리에서 바로 NullPointerException을 던지고, null이 아니면 값을 그대로 돌려주는 자바 표준 메서드다 — 두 번째 인자(“userId”)는 예외 메시지에 들어가 어떤 값이 null이었는지 바로 알려 준다.

// order/api/OrderController.java
@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(
        LoginUser loginUser,                                  // 주입된다
        @Valid @RequestBody CreateOrderRequest request) {

    ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand(loginUser));
    OrderCreatedResponse data = OrderCreatedResponse.from(result);
    URI location = URI.create("/orders/" + data.getOrderId());
    return HttpApiResponses.created(location, data);
}

코드 주석의 “주입된다”는 내가 직접 new LoginUser(...)를 만들어 넘기지 않아도 Spring이 이 메서드를 부르기 전에 LoginUser 값을 채워서 가져다준다는 뜻이다. @RequestBody가 요청 본문을 보고 자동으로 CreateOrderRequest를 채워 주는 것과 같은 자리이고, 그 채우는 방법은 원칙 2의 LoginUserArgumentResolver가 정한다.

// order/api/CreateOrderRequest.java
public CreateOrderCommand toCommand(LoginUser loginUser) {
    return CreateOrderCommand.of(loginUser.getUserId(), productName, quantity);
}

Command에는 Long userId만 들어간다. LoginUser는 api 경계를 넘지 않는다.

변환 책임은 계층별 DTO 네이밍 표준이 정한 대로 Request가 그대로 진다. 인자가 하나 늘었을 뿐이고, Controller는 여전히 얇다. Request 필드가 나중에 늘어도 Controller는 안 바뀐다.

조회도 대칭이다.

orderQueryUseCase.searchMyOrders(request.toQuery(loginUser));   // Query에도 userId가 들어간다

“내 주문만 조회”가 여기서 걸린다. XxxQuery에 userId가 없으면 남의 주문이 목록에 나온다.


원칙 2. Resolver가 Security를 아는 유일한 자리다

// common/security/LoginUserArgumentResolver.java
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return LoginUser.class.equals(parameter.getParameterType());   // 타입으로 판별한다
    }

    @Override
    public LoginUser resolveArgument(MethodParameter parameter, ModelAndViewContainer mav,
                                     NativeWebRequest request, WebDataBinderFactory binderFactory) {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !authentication.isAuthenticated()
                || authentication instanceof AnonymousAuthenticationToken) {
            throw new IllegalStateException(
                    "LoginUser 파라미터가 있는데 인증 정보가 없다. SecurityFilterChain 설정을 확인해야 한다.");
        }

        return LoginUser.of(Long.valueOf(authentication.getName()));
    }
}

한 줄씩 따라가기. HandlerMethodArgumentResolver는 Controller 메서드의 파라미터를 Spring이 기본 제공하는 방식이 아니라 내가 정한 방식으로 채우게 해 주는 인터페이스다. supportsParameter는 파라미터의 타입만 보고 “이 Resolver가 이 파라미터를 맡을지”를 판단한다 — 타입이 LoginUser일 때만 맡는다. resolveArgument가 실제로 그 값을 만드는 메서드다. SecurityContextHolder.getContext().getAuthentication()은 Security 필터가 로그인 확인을 마치고 넣어 둔 인증 결과(Authentication 객체)를 꺼내는 코드다. 그 값이 null이거나 인증되지 않았거나 AnonymousAuthenticationToken(로그인하지 않은 사람에게도 Spring Security가 기본으로 채워 주는 “비로그인” 표시용 Authentication 구현체)이면 로그인하지 않은 사람이 이 자리까지 온 것이므로 예외를 던진다. 마지막 줄의 authentication.getName()은 영어 뜻과 달리 사람 이름이 아니라 로그인할 때 principal 자리에 넣어 둔 값을 돌려준다 — Authentication 인터페이스가 그 값을 꺼내는 메서드 이름을 getName()으로 정해 둔 것뿐이고, 실제로 무엇이 들어 있는지는 로그인을 처리하는 필터가 거기에 무엇을 넣었느냐에 달려 있다. 이 저장소는 그 자리에 숫자 ID를 문자열로 넣어 두므로 Long.valueOf(...)로 다시 숫자로 바꿀 수 있다.

// common/security/WebMvcConfig.java
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}

한 줄씩 따라가기. @Configuration은 이 클래스 안의 @Bean 메서드가 Spring이 관리하는 빈을 만든다는 표시다. @RequiredArgsConstructor는 Lombok이 final 필드(loginUserArgumentResolver)만 받는 생성자를 자동으로 만들어 준다. addArgumentResolvers는 Spring이 시작할 때 한 번 불러 주는 메서드로, 여기서 방금 만든 Resolver를 등록해야 Controller 파라미터에 LoginUser를 쓸 수 있게 된다.

graph LR F["Security 필터
SecurityContext에 저장"] --> AR["ArgumentResolver
LoginUser 생성"] AR --> C["Controller
LoginUser"] C -->|"toCommand"| CMD["Command
Long userId"] CMD --> D["Domain
validateOwnedBy"] style F fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0 style AR fill:#1a202c,stroke:#f56565,stroke-width:3px,color:#e2e8f0 style C fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style CMD fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0

Long.valueOf(authentication.getName()) 이 한 줄이 필터가 principal에 무엇을 넣느냐에 맞춰 바뀐다. JWT를 세션으로 바꾸든 주체 표현을 바꾸든 여기만 고치면 되고 Controller는 하나도 안 바뀐다.

ErrorCode에 HttpStatus 대신 int를 담고, Domain이 JPA를 모르게 한 것과 같은 결이다 — 프레임워크를 한 파일에 가둔다.

인증 없이 도달하면 500이다

LoginUser 파라미터가 있다는 건 인증이 필요한 엔드포인트라는 뜻이다. Security 필터가 이미 401로 막았어야 한다. 그런데도 여기 왔다면 SecurityFilterChain 설정이 어긋난 것이다.

사용자 잘못이 아니므로 401이 아니라 500이다. 삭제 표준에서 FK 위반을 500으로 다룬 것과 같은 계열이고, assert 표준이 말한 “있을 수 없는 일”이다.


원칙 3. 권한 판단이 갈리는 축은 이미 있다

권한 검사를 어디서 하느냐가 걸릴 줄 알았는데, 기준이 이미 서 있었다.

"관리자인가"    → 데이터를 조회하지 않고 판단된다  → Security 계층 (@PreAuthorize)
"내 주문인가"   → DB를 조회해야 안다              → Domain (validateOwnedBy)

위 표의 @PreAuthorize는 메서드가 시작되기 전에 “이 사람이 이걸 호출할 권한이 있는가”를 검사하는 표시다 — 데이터를 조회하지 않고도 판단할 수 있는 검사(예: 역할이 관리자인가)를 여기서 막는다.

계층별 검증 표준이 세운 축과 같다.

DB에서 조회한 상태를 근거로 하는 검증은 Domain의 validateXxx() 메서드가 판단한다. 조회는 Service가 Repository로 수행하고, 판단만 Domain에 위임한다.

“이 주문이 내 것인가”가 정확히 그것이다.

// ❌ 규칙이 Service로 샌다
OrderDomain order = orderRepository.findById(command.getOrderId()).orElseThrow(...);
if (!order.getUserId().equals(command.getUserId())) {
    throw ErrorCodeException.of(ErrorCode.ACCESS_DENIED);
}

// ✅ 판단은 Domain이
OrderDomain order = orderRepository.findById(command.getOrderId()).orElseThrow(...);
order.validateOwnedBy(command.getUserId());
order.cancel();

그래서 LoginUser에는 userId만 담는다. 역할·권한은 Security 계층이 이미 판단을 끝냈으므로 아래로 내려갈 이유가 없다.


원칙 4. 감사 필드는 다른 경로다

“누가 만들었는가”를 기록해야 한다면 @CreatedBy를 쓰게 되는데, 이건 Command의 userId와 다른 길로 간다.

// common/config/AuditorConfig.java
@Bean
AuditorAware<Long> auditorAware() {
    return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
            .filter(Authentication::isAuthenticated)
            .map(auth -> Long.valueOf(auth.getName()));
}

한 줄씩 따라가기. AuditorAware는 JPA Auditing이 @CreatedBy 필드를 채울 때 “지금 누가 이 작업을 하는가”를 물어보는 인터페이스다. 이 Bean은 그 질문에 SecurityContext에서 꺼낸 인증 결과의 getName() 값(숫자 ID 문자열)을 숫자로 바꿔 돌려준다. Authentication이 없거나 인증되지 않았으면 빈 값을 돌려주고, 그러면 @CreatedBy 필드는 채워지지 않는다.

Domain 영속화 표준에서 감사 필드는 Entity에만 두고 Domain은 모른다고 정했다. 그래서 Domain이 나를 수가 없고, JPA Auditing이 SecurityContext에서 직접 읽는다.

두 경로를 섞으면 안 된다. Command의 userId를 감사용으로 쓰려고 Domain에 createdBy를 넣는 순간, applyTo가 그 필드를 덮게 되고 영속화 표준이 막아둔 문제가 되살아난다.


판단 기준 정리

질문 답 결론
주체는 어디서 오나? SecurityContext Request에 userId를 두지 않는다
Controller는 무엇으로 받나? LoginUser 파라미터 어노테이션 없이 타입으로 판별한다
LoginUser에 뭘 담나? Long userId만 역할·권한은 담지 않는다
Command에 어떻게 넣나? request.toCommand(loginUser) Controller가 직접 조립하지 않는다
Command가 갖는 타입은? Long userId LoginUser는 api 밖으로 안 나간다
인증이 필요 없는 API는? 파라미터를 안 받는다 null을 넘기지 않는다
Security를 아는 자리는? LoginUserArgumentResolver 인증 방식이 바뀌면 여기만 고친다
인증 없이 Resolver에 오면? 500 필터 설정 실수이지 사용자 잘못이 아니다
“관리자인가”는? @PreAuthorize 데이터 없이 판단된다
“내 주문인가”는? Domain.validateOwnedBy() DB 조회가 필요하다
조회 API는? XxxQuery에도 userId 빠뜨리면 남의 데이터가 보인다
감사 필드는? AuditorAware Command의 userId와 다른 경로다

이 표준을 정하기까지

시작은 요청 한 사이클을 훑다가 발견한 빈칸이었다. request.toCommand()가 Command를 만드는데 userId는 바디에 없다. 그런데 Command에는 있어야 한다. 그 사이를 잇는 코드가 어디에도 정의돼 있지 않았다.

common/security를 열어보고 성격을 알았다. 거기 있는 것은 AuthenticationEntryPoint와 AccessDeniedHandler뿐이었다. 둘 다 인증이 실패했을 때 응답을 만드는 물건이다. 실패는 정했는데 성공은 정하지 않은 상태였다.

클라이언트가 보내는 안은 즉시 버렸다. Request에 userId를 넣으면 남의 ID로 주문할 수 있다. 선택지가 아니라 취약점이라 비교할 것도 없었다.

Service가 SecurityContext를 읽는 안도 버렸다. 가장 간단해 보였는데, 그러면 Service가 Spring Security를 import한다. ErrorCode에 HttpStatus 대신 int를 담아 Domain이 웹을 모르게 한 결정과 정면으로 어긋난다.

남은 것은 Controller가 받아서 내려보내는 것뿐이었고, 거기서 두 가지가 갈렸다. @AuthenticationPrincipal을 그대로 쓸지 커스텀 Resolver를 만들지, 그리고 Command를 누가 조립할지. 전자는 프레임워크를 한 파일에 가두는 쪽으로, 후자는 변환 책임을 Request에 유지하는 쪽으로 정했다. 둘 다 이 저장소가 계속 써온 기준을 따라갔다.

코드를 실제로 짜보고서 제안 하나가 틀렸다는 걸 알았다. @LoginUser LoginUser loginUser로 쓰려고 했는데 자바에서 어노테이션도 타입이라 같은 패키지에 같은 이름을 둘 수 없다. 컴파일이 안 된다.

그런데 고치려고 보니 어노테이션이 애초에 필요 없었다. supportsParameter가 파라미터 타입만 보면 되고, 타입 이름이 LoginUser라 읽는 데도 지장이 없다. 오류를 고치다가 파일이 하나 줄었다 — 말로만 설계하고 넘어갔으면 못 만났을 지점이다.

권한 판단은 새로 정할 게 없었다. “관리자인가”와 “내 주문인가”를 어디서 막을지가 남은 줄 알았는데, 검증 표준의 “도메인 지식이 필요한가” 가 그대로 답이었다. 데이터를 안 봐도 판단되면 Security, DB를 봐야 하면 Domain이다. 같은 축이 세 번째 표준에서 또 나왔다.

마지막으로 감사 필드가 다른 길이라는 걸 짚었다. @CreatedBy를 Command의 userId로 채우고 싶어지는데, 그러면 Domain에 감사 필드가 들어가고 영속화 표준이 막아둔 “applyTo가 덮는” 문제가 되살아난다. 두 경로를 분리해 적어뒀다.


정리

  • 클라이언트가 보낸 주체를 믿지 않는다. Request에 userId를 두지 않는다
  • LoginUser는 api에서 죽는다. Service로는 Long userId만 간다
  • request.toCommand(loginUser)로 변환한다. 변환 책임은 Request가 그대로 진다
  • LoginUserArgumentResolver가 Security를 아는 유일한 자리다. 인증 방식이 바뀌어도 Controller는 안 바뀐다
  • 인증 없이 Resolver에 도달하면 500이다. 필터 설정 실수이지 사용자 잘못이 아니다
  • 권한 판단의 축은 이미 있었다. 데이터 없이 판단되면 @PreAuthorize, DB를 봐야 하면 Domain
  • 감사 필드는 AuditorAware가 따로 읽는다. Command의 userId와 섞지 않는다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기