가드가 모두 있어도 먼저 읽으면 값이 없다

AI Chat / Tutor의 S2S 튜터 라우트는 JWT 인증과 튜터 봇 해석을 서로 다른 가드가 맡는다. JWT 가드가 검증한 로그인 값을 request.s2s.userLogin에 넣고, resolver가 그 값으로 과목과 봇을 찾는다. resolver가 먼저 실행되면 로그인 값이 비어 있어 서버 구성 오류를 반환한다.

수정 전에는 두 데코레이터가 모두 붙어 있었지만 resolver가 먼저 등록됐다. 이 저장소의 TypeScript 데코레이터는 아래에서 위로 적용되고 Nest의 UseGuards가 메타데이터 배열에 가드를 더한다. 수정은 POST·GET 학습요소 라우트에서 S2sAuth를 핸들러에 더 가까이 배치했다. 다음은 데코레이터 배치만 추린 코드다.

@ResolveTutorBot('courseId')
@S2sAuth('aitutor')
@Post('courses/:courseId/module-items/:moduleItemId')

이 배치에서는 JWT가 먼저 등록돼 검증한 값을 채우고 resolver가 뒤에서 읽는다. resolver는 앞서 검증된 신원을 이용하는 역할이며, 그 자체가 JWT 검증을 대신하지 않는다.

항상 통과하는 가드는 이 의존을 숨겼다

기존 컨트롤러 테스트는 두 가드를 모두 canActivate: () => true로 바꾼다. 컨트롤러의 서비스 호출과 응답만 검사할 때는 유용하지만, 두 가드 사이의 요청 상태 전달은 실행하지 않는다. 잘못된 등록 순서여도 모킹한 가드는 모두 통과한다.

추가된 회귀 검사는 핸들러에서 실제 GUARDS_METADATA를 읽는다. 두 클래스가 모두 등록됐는지 확인한 뒤 JWT의 인덱스가 더 작은지 검사한다. 가드 배열을 얻은 뒤의 assertion은 다음과 같다.

const jwtIdx = guards.indexOf(S2sJwtGuard);
const resolverIdx = guards.indexOf(S2sTutorBotResolverGuard);
expect(jwtIdx).toBeGreaterThanOrEqual(0);
expect(resolverIdx).toBeGreaterThanOrEqual(0);
expect(jwtIdx).toBeLessThan(resolverIdx);

컨트롤러 단위 테스트를 무겁게 만들지 않고, 모킹이 가렸던 구성 조건을 직접 검사한 방식이다. 인덱스 비교 전에 존재 검사도 해야 한다. 없는 클래스의 -1을 잘못된 순서 비교의 성공으로 해석하지 않기 위해서다.

순서 의존을 유지한다면 새 호출부도 같은 규칙을 지켜야 한다

하나의 합성 데코레이터로 두 가드를 묶으면 호출부가 순서를 덜 신경 쓸 수 있다. 두 책임을 한 가드에 넣는 방법도 있지만 조합의 자유가 줄어든다. 실제 수정은 가드를 분리한 채 데코레이터 주석과 두 라우트의 등록 순서 검사로 의존을 드러냈다. 새 라우트는 같은 규칙을 따로 지켜야 한다.

메타데이터 검사는 두 가드가 올바른 순서로 구성됐는지 확인하지만, 실제 HTTP 요청에서 JWT 검증·과목 허용·봇 조회가 모두 성공하는지까지 보여 주지는 않는다. 각 가드의 단위 검사, 두 가드를 연결한 구성 검사, 실제 요청 검증은 서로 다른 질문에 답한다.