Cross-cutting Concerns · Backend

요청 스코프 컨텍스트
req.user가 안티패턴인 이유

인증된 사용자를 request 객체에서 바로 꺼내 쓰는 코드는 처음 쓸 때는 아무 문제 없어 보인다. 그런데 비즈니스 로직을 "지금 HTTP 요청을 처리하는 중"이라는 사실에 슬그머니 묶어 버리는 데 이만큼 쉬운 방법도 없다.

계층형 아키텍처에서 인증이 있을 자리는 분명하다. Interface 계층 한 곳뿐이다. Domain 계층과 Application 계층은 인증 컨텍스트에 기대지 않는다. Command나 Query에는 userId 같은 평범한 문자열처럼 필요한 값만 들어가고, 그 ID를 어떻게 확인했는지는 담기지 않는다.

// forbidden — verifying the token directly in an Application Service
public async cancelOrder(token: string, command: CancelOrderCommand) {
  const user = await this.authService.verify(token)  // this is the Interface layer's job
  ...
}

맞는 계층 안에서도 생기는 실수

인증을 Interface 계층에 뒀다고 끝은 아니다. 그 계층 안에서도 사용자 정보를 request 객체에서 직접 읽는 건 피하는 게 좋다. 취향 문제로 넘길 일이 아니다.

// avoid — reads the user info directly off the request object
public async cancelOrder(
  @Req() req: { user: { userId: string } },
  @Body() body: CancelOrderRequestBody
): Promise<void> {
  return this.commandService.cancelOrder({ ...body, userId: req.user.userId })
}

request 객체에서 바로 읽으면 Handler, 그리고 Handler가 부르는 코드 전부가 넘겨받은 값 하나에 기대는 대신 "지금 HTTP 요청이 진행 중"이라는 사실에 묶인다. 인증된 사용자는 특히 그렇다. 이 값은 보통 여기저기서 필요하다. Handler에서도, 때로는 Application 계층의 Service에서도, 로깅이나 observability 인터셉터에서도 또 쓴다.

그 호출 지점마다 req를 줄줄이 넘기든, 프레임워크 전역의 "현재 요청"을 끌어다 쓰든 결과는 같다. HTTP의 사정을 평범한 애플리케이션 호출로 바꿔 주는 게 Interface 계층이 있는 이유인데, 그 이유가 사라진다.

고치는 방법은 Correlation ID에 이미 쓰고 있던 패턴과 같다. 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서 쓰던 패턴이다. 인증 단계에서 값을 요청 스코프 스토리지에 넣어 두고, 필요한 곳에서는 request 객체 없이 그 스토리지에서 꺼내 쓴다.

// Interface layer: read the userId from request-scoped storage, not the request object
public async cancelOrder(
  @Body() body: CancelOrderRequestBody
): Promise<void> {
  const userId = userContextStorage.getRequesterId()
  return this.commandService.cancelOrder({ ...body, userId })
}

디버깅에 한참 걸린 부분

막상 구현해 보니 문서의 한 단락 설명에는 안 담긴 함정이 있었다. 처음에는 Correlation ID 패턴을 그대로 따라 했다. Middleware가 AsyncLocalStorage 스코프를 열고, Auth Guard가 그 안에 사용자를 채우는 식이다. 타입 체크와 lint를 통과했고 unit test 117개도 다 통과했다. 그런데 e2e 테스트는 67개 중 58개가 500 에러로 실패했다.

원인은 서로 관계없는 두 가지였고, 둘 다 혼자서도 실패를 낼 만했다. 하나는 e2e 스펙이 테스트 모듈을 직접 만들어 쓰면서 앱의 부트스트랩 설정을 한 번도 부르지 않았다는 것이다. 그래서 테스트 환경에서는 Middleware가 아예 돌지 않았다.

다른 하나는 테스트 구성과 상관없이 어디서나 해당되는 문제다. Guard의 canActivate()는 boolean 하나를 돌려줄 뿐이다. "이제 파이프라인의 나머지를 이어서 처리하라"는 콜백을 받지 않는데, AsyncLocalStorage.run(value, callback)에 필요한 게 그 콜백이다. 테스트든 프로덕션이든 Guard 혼자서는 그 스코프를 열 수가 없다.

책임을 둘로 나눴다

Guard는 토큰을 검증하고, 결과를 request 객체의 내부 전달용 필드에 넣어 둔다. Controller는 이 필드를 읽지 않는다. 다음 단계로 넘기기 위한 자리일 뿐이다.

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly authService: AuthService,
    private readonly reflector: Reflector
  ) {}

  public async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()])
    if (isPublic) return true

    const request = context.switchToHttp().getRequest()
    const authorization = request.headers.authorization
    if (!authorization?.startsWith('Bearer ')) throw new UnauthorizedException()

    const token = authorization.replace('Bearer ', '')
    const user = await this.authService.verify(token)
    if (!user) throw new UnauthorizedException()

    // A Guard has no callback to wrap the rest of the pipeline, so it cannot itself open the
    // AsyncLocalStorage-based UserContextStore. This field is an internal-only handoff to
    // UserContextInterceptor — Controllers must never read it directly.
    request.__verifiedUser = user
    return true
  }
}

스토리지 스코프는 Interceptor가 연다. Interceptor는 감쌀 수 있는 next.handle()을 받기 때문에, 나머지 요청 처리를 통째로 그 안에 넣을 수 있다.

@Injectable()
export class UserContextInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest()
    const user = request.__verifiedUser

    if (!user) return next.handle()

    return new Observable((subscriber) => {
      UserContextStore.run(user, () => {
        next.handle().subscribe(subscriber)
      })
    })
  }
}

둘은 합성 데코레이터 하나로 늘 같이 붙인다. 그래서 Guard만 있고 Interceptor가 빠진 라우트는 생길 수 없다.

export const Authenticated = (): ReturnType<typeof applyDecorators> => applyDecorators(
  UseGuards(AuthGuard),
  UseInterceptors(UserContextInterceptor)
)

스토리지는 얇은 래퍼다. 문제가 있으면 소리 없이 넘어가지 않고 바로 터지도록 일부러 만들었다.

const storage = new AsyncLocalStorage<UserContext>()

export const UserContextStore = {
  run: (user: UserContext, fn: () => void) => storage.run(user, fn),
  getUser: (): UserContext | undefined => storage.getStore(),

  // Throws rather than returning undefined: a Controller method gated by @Authenticated()
  // should never reach this with no user set, so a thrown error surfaces a real wiring bug
  // immediately instead of silently propagating an empty requesterId into a Command/Query.
  getRequesterId: (): string => {
    const user = storage.getStore()
    if (!user) throw new Error('UserContextStore.getRequesterId() called outside an authenticated request context.')
    return user.userId
  }
}
테스트 통과로 끝내지 않기

동시성에 이만큼 민감한 장치는 테스트가 통과했다고 맞다고 볼 수 없었다. 앱을 직접 띄우고 사용자 두 명을 만든 다음, 요청을 차례로도 보내고 동시에 병렬로도 보냈다. 어느 쪽이든 응답 데이터는 늘 그 요청의 bearer 토큰 주인 것이었다. AsyncLocalStorage 방식에서 요청끼리 데이터가 새지 않는다는 걸 직접 확인한 셈이다. unit test만으로는 완전히 배제할 수 없는 부분이다.

다른 언어는 고치기 전에 먼저 확인했다

그러면 같은 설계를 구현한 다른 4개 언어에도 같은 함정이 있었을까. 없었다. 왜 없었는지가 더 쓸모 있는 얘기다. req.user = payload는 잘 알려진 함정이지만 Express나 NestJS 같은 프레임워크에서 주로 생긴다. 이 프레임워크들에는 "이 요청의 인증된 principal"이라는 개념이 자체적으로 없다.

요청 스코프 인증을 이미 프레임워크가 제공하는 쪽은 손대지 않아도 맞게 돼 있었다. Go는 context.Context와 context.WithValue를 쓴다. Spring Security는 Authentication을 메서드 파라미터로 명시해 받고 HttpServletRequest는 직접 만지지 않는다. FastAPI는 Depends(get_current_user)가 있다. 모든 언어를 똑같이 고쳐야 한다고 생각했다면 필요 없는 수정 4건을 더 했을 것이다. 언어별 코드를 먼저 열어 보니 5개 언어 중 고칠 곳은 하나뿐이었다.

더 볼 자료

docs/architecture/authentication.md(예제 프로젝트의 인증 흐름 전체와 계층 배치 원칙) · docs/architecture/cross-cutting-concerns.md(요청 파이프라인에서 인증이 놓이는 자리와, 이 글이 따라 한 Correlation ID 패턴) · common/user-context-store.ts(NestJS 구현 코드)