주문을 만드는 Controller는 이렇게 생겼다.
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand());
return HttpApiResponse.success(OrderResponse.from(result));
}
그런데 CreateOrderCommand에는 이 필드가 있어야 한다.
private final Long userId; // 누가 주문했는가
userId는 요청 바디에 없다. 로그인 토큰에서 온다. 그래서 request.toCommand()가 그걸 채울 방법이 없다.
예외의 종류 표준에서 인증 실패를 AuthenticationEntryPoint로 처리하기로 정했고, common/security에는 그 핸들러들이 있다. 인증이 실패했을 때는 정했는데, 성공했을 때 그 주체를 어떻게 쓰는지는 정한 적이 없다.
먼저 확실히 아닌 것
// ❌ 절대 안 된다
public class CreateOrderRequest {
private final Long userId; // 클라이언트가 보낸다
private final String productName;
}
클라이언트가 userId를 적어 보내면 남의 ID로 남 대신 주문할 수 있다. 인증을 한 의미가 사라진다. 이건 선택지가 아니라 취약점이다.
주체는 우리가 검증한 것에서만 와야 한다. 즉 SecurityContext다.
원칙 1. LoginUser는 api에서 죽는다
Entity가 infra에서 죽고 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);
}
}
// order/api/OrderController.java
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> createOrder(
LoginUser loginUser, // 주입된다
@Valid @RequestBody CreateOrderRequest request) {
ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand(loginUser));
return HttpApiResponse.success(OrderResponse.from(result));
}
// 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()));
}
}
// 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);
}
}
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)
계층별 검증 표준이 세운 축과 같다.
DB에서 조회한 상태를 근거로 하는 검증은
Domain의validateXxx()메서드가 판단한다. 조회는 Service가 Repository로 수행하고, 판단만Domain에 위임한다.
“이 주문이 내 것인가”가 정확히 그것이다.
// ❌ 규칙이 Service로 샌다
OrderDomain order = orderRepository.findById(command.getOrderId()).orElseThrow(...);
if (!order.getUserId().equals(command.getUserId())) {
throw BusinessException.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()));
}
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를 읽는 안도 버렸다. 가장 간단해 보였는데, 그러면 application이 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에서 죽는다.application으로는Long userId만 간다request.toCommand(loginUser)로 변환한다. 변환 책임은Request가 그대로 진다LoginUserArgumentResolver가 Security를 아는 유일한 자리다. 인증 방식이 바뀌어도 Controller는 안 바뀐다- 인증 없이 Resolver에 도달하면 500이다. 필터 설정 실수이지 사용자 잘못이 아니다
- 권한 판단의 축은 이미 있었다. 데이터 없이 판단되면
@PreAuthorize, DB를 봐야 하면Domain - 감사 필드는
AuditorAware가 따로 읽는다.Command의userId와 섞지 않는다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/authenticated-user-standard/SKILL.md
---
name: authenticated-user-standard
description: 인증된 사용자가 Controller에서 Domain까지 내려가는 경로 규칙. LoginUser, ArgumentResolver, Command의 userId, 본인 확인을 다룰 때 반드시 적용한다.
---
# 인증 주체 전달 표준
인증 주체는 요청 바디에 없다. `SecurityContext`에서 꺼내 `api` 경계에서 `LoginUser`로 받고, `application`으로는 `Long userId`만 넘긴다.
## LoginUser
- `common/security`에 `LoginUser` 값 객체를 두고 `Long userId`만 담는다.
- Spring Security 타입을 참조하지 않는다. private 생성자 + `public` 정적 팩토리로 만들고 필드는 `final`이다.
- 역할·권한을 담지 않는다. 권한 판단은 Security 계층에서 이미 끝났다.
## Controller
- 인증이 필요한 엔드포인트는 `LoginUser`를 파라미터로 받는다. 어노테이션을 붙이지 않는다 — `LoginUserArgumentResolver`가 타입으로 판별한다.
- `XxxRequest`에 `userId`를 두지 않는다. 클라이언트가 보낸 값을 주체로 쓰면 남의 자원을 조작할 수 있다.
- 인증이 필요 없는 엔드포인트는 파라미터를 받지 않는다. `LoginUser`를 `null`로 넘기지 않는다.
## Command / Query 조립
- `request.toCommand(loginUser)` / `request.toQuery(loginUser)`로 변환한다. 변환 책임은 `Request`가 그대로 진다.
- `Request` 안에서 `loginUser.getUserId()`를 꺼내 담는다. `LoginUser`는 `api` 밖으로 나가지 않는다.
- `XxxCommand`/`XxxQuery`는 `Long userId`를 갖는다. `LoginUser`를 필드로 두지 않는다.
- Controller가 `XxxCommand.of(...)`를 직접 조립하지 않는다.
- 본인 것만 조회하는 API는 `XxxQuery`에도 `userId`를 담는다. 빠뜨리면 남의 데이터가 보인다.
## LoginUserArgumentResolver
- `common/security`에 두고 `WebMvcConfigurer`의 `addArgumentResolvers`로 등록한다.
- `supportsParameter`는 파라미터 타입이 `LoginUser`인지만 본다.
- `SecurityContextHolder`에서 `Authentication`을 꺼내 `LoginUser`를 만든다. **이 클래스가 Spring Security를 아는 유일한 자리다.**
- 인증 정보가 없거나 익명이면 `IllegalStateException`을 던진다. 필터가 이미 401로 막았어야 하므로 여기 도달한 것은 설정 실수이고, 사용자에게 보이는 실패가 아니라 500이다.
- 인증 방식이 바뀌면 이 클래스만 고친다. Controller는 바뀌지 않는다.
## 권한 판단이 갈리는 자리
- 데이터를 조회하지 않고 판단되는 것(관리자인가)은 Security 계층에서 `@PreAuthorize`로 막는다.
- DB에서 조회한 상태를 근거로 하는 것(내 주문인가)은 `Domain`이 판단한다. `order.validateOwnedBy(userId)`.
- 이 축은 validation-standard의 "도메인 지식이 필요한가"와 같다. 새 기준을 만들지 않는다.
- 본인 확인을 Controller나 Service의 `if`문으로 처리하지 않는다. 규칙이 `Domain` 밖으로 샌다.
## 감사 필드
- `@CreatedBy`/`@LastModifiedBy`는 `Command`의 `userId`와 다른 경로다. `AuditorAware`가 `SecurityContext`에서 직접 읽는다.
- `Domain`은 감사 필드를 나르지 않는다. 두 경로를 섞지 않는다. domain-persistence-standard를 따른다.
GitHub Copilot — .github/instructions/authenticated-user-standard.instructions.md
---
description: 인증 주체를 Controller에서 Domain까지 전달하는 규칙
applyTo: "**/api/**/*.java, **/common/**/*.java"
---
- `common/security`에 `LoginUser` 값 객체를 두고 `Long userId`만 담는다. Spring Security 타입을 참조하지 않는다.
- `LoginUser`에 역할·권한을 담지 않는다.
- 인증이 필요한 Controller 메서드는 `LoginUser`를 파라미터로 받는다. 어노테이션을 붙이지 않는다.
- `XxxRequest`에 `userId` 필드를 두지 않는다. 클라이언트가 보낸 주체를 신뢰하지 않는다.
- 인증이 필요 없는 엔드포인트는 `LoginUser` 파라미터를 받지 않는다. `null`로 넘기지 않는다.
- `request.toCommand(loginUser)` / `request.toQuery(loginUser)`로 변환하고 Controller가 `XxxCommand.of(...)`를 직접 조립하지 않는다.
- `XxxCommand`/`XxxQuery`는 `Long userId`를 갖는다. `LoginUser`를 필드로 두지 않는다.
- 본인 것만 조회하는 API는 `XxxQuery`에도 `userId`를 담는다.
- `LoginUserArgumentResolver`를 `common/security`에 두고 `WebMvcConfigurer`로 등록한다.
- `supportsParameter`는 파라미터 타입이 `LoginUser`인지만 본다.
- 인증 정보가 없거나 익명이면 `IllegalStateException`을 던진다. 설정 실수이므로 500으로 다룬다.
- 데이터 없이 판단되는 권한(관리자인가)은 `@PreAuthorize`로 막는다.
- DB 조회가 필요한 판단(내 주문인가)은 `Domain`의 `validateOwnedBy(userId)`가 한다. Controller나 Service의 `if`문으로 처리하지 않는다.
- `@CreatedBy`/`@LastModifiedBy`는 `AuditorAware`가 `SecurityContext`에서 직접 읽는다. `Domain`이 감사 필드를 나르지 않는다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기