Post

JWT 토큰 파싱 변경 과정과 협업의 가치에 대한 고민

JWT 토큰 파싱 변경 과정과 협업의 가치에 대한 고민

문제 상황

저는 토큰 인증방식 을 이용하여서 구현했습니다. 토큰 방식으로 해서 세션을 저장할 필요가 없어서 확장성 있게 설계할 수 있어서 좋았습니다.

하지만 문제가 생겼습니다. 기능 확장을 하는데 있어서 문제가 생겼는데요, 문제 상황은 이렇습니다. 질문지 개발을 다른 캠퍼께서하고 계셨는데, 질문지 스크랩에 대한 내용을 리턴해야합니다.

만약 로그인한 유저가 질문지(대충 리소스) 리스트를 받아오는데 있어서 자신이 어떤 질문지를 스크랩했는지 보여주는 식으로 질문지 리스트를 리턴해야한다 하는 유즈 케이스가 있습니다. 문제는 이때, 로그인하지 않은 유저는 모든 질문지 리스트에 스크랩 여부를 false 로 하여 리턴하려고 했었는데요.

이때, AuthGuard(”jwt”) 를 통해 유저 정보를 받아오고 있었는데, 유저 정보가 없으면 무조건 401 에러를 내뱉기 때문에, 인증 모듈을 구현한 저에게 개선 요청을 하셨습니다. 아래는 실제 코드입니다.

해당 상황에서 핸들러에 진입하기도 전에 Guard 에서 반환을 시켜버리기 때문에 인증 전략을 수정해야하는 상황이었습니다. 토큰을 전달 받아 scrap 유무를 확인해서 질문지들을 조회하는 API를 하나씩 더 만들 수도 있겠지만, 카테고리에 따라 만드는 경우 등 다양한 유즈케이스가 있어서 확장성 있게 개선하는 방향이 아니라고 판단했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Post()
@UseGuards(AuthGuard("jwt"))
async createQuestionList(
    @JwtPayload() token: IJwtPayload,
    @Req() req,
    @Body()
    body: {
        title: string;
        contents: string[];
        categoryNames: string[];
        isPublic: boolean;
    }
) {
    try {
        const { title, contents, categoryNames, isPublic } = body;

        // 질문지 DTO 준비
        const createQuestionListDto: CreateQuestionListDto = {
            title,
            contents,
            categoryNames,
            isPublic,
            userId: token.userId,
        };

        // 질문지 생성
        const { createdQuestionList, createdQuestions } =
            await this.questionListService.createQuestionList(createQuestionListDto);
        return {
            success: true,
            message: "Question list created successfully.",
            data: {
                createdQuestionList,
                createdQuestions,
            },
        };
    } catch (error) {
        return {
            success: false,
            message: "Failed to create question list.",
            error: error.message,
        };
    }
}

해결 시도 과정

401 리턴을 저는 JWT 데코레이터 에서 하고 있었습니다. 위 개발일지 글에서 올린 데코레이터 코드와는 다른 이전 코드입니다. 그래서 해당 부분에서 타입 가드가 오류가 날 경우 null 을 리턴해주는 식으로 해결했습니다.

1
2
3
4
5
6
7
8
9
10
11
export const JwtPayload = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const payload = request.user.jwtToken;

    if (!isJwtTokenPayload(payload)) {
        throw new UnauthorizedException("Invalid jwt token payload");
    }

    return payload;
});

해결된 줄 알았으나,,

그럼에도 불구하고 여전히 401 에러를 리턴하고 있었습니다.

알고보니 passport-jwt 에서 직접적으로 토큰 인증에 실패하면 에러를 출력하고 있었습니다. 이 경우도 verify 함수를 설정해보는 등 여러 시도를 해보았지만,처음부터 토큰을 가져오지 못할 때 바로 401 에러를 자체적으로 리턴하고 있었습니다.

그래서 이경우도 jsonwebtoken 라이브러리와 passport-custom 으로 자체적으로 해결했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-jwt";
import { Request } from "express";
import "dotenv/config";

@Injectable()
export class AccessTokenStrategy extends PassportStrategy(Strategy, "jwt") {
    constructor() {
        super({
            jwtFromRequest: (req: Request) => {
                if (!req || !req.cookies) return null;
                return req.cookies["accessToken"];
            },
            secretOrKey: process.env.JWT_ACCESS_TOKEN_SECRET_KEY,
            passReqToCallback: true,
        });
    }

    async validate(req: Request, payload: any) {
        const { userId, username } = payload;

        return {
            jwtToken: {
                userId,
                username,
            },
        };
    }
}

생각해보니..

생각해보니, 컨트롤러의 책임은 API 응답에 있을 수 있겠다는 생각이 들었습니다. 이게 무슨 뜻이냐면, 어떤 요청에 대한 응답의 책임은 컨트롤러에 있다라는 생각입니다. 또한, 아까 사례처럼 오류 처리에 대한 철학을 조금 생각해봤습니다.

예외를 진짜 마구잡이로 던져서 컨트롤러 핸들러에서 어찌할 줄 모르는 경우를 생각해보면, 에러 핸들링을 하는 범위를 어디서부터 어떻게 해야할지 생각을 다시하게 되었습니다.

배울 점

이와 관련해서 Claude 에게도 물어보았습니다. 혼자만의 생각을 정리하지 않고, 무언가 자문을 구할 사람이 주변에 없을 경우 이용을 하고 있었는데, 이에 관한 답변도 공유를 하면서 저의 생각을 정리하며 마쳐봅니다.

질문

401 등 컨트롤러에서는 응답 코드를 리턴하는 핸들링에 대한 책임이 전적으로 있다고 생각하는데요. 어떻게 생각하시나요?

클로드의 답변

HTTP 상태 코드의 처리와 관련된 중요한 논점을 제기하셨네요. 컨트롤러의 책임 범위에 대해 제 의견을 말씀드리겠습니다.

컨트롤러가 상태 코드를 전적으로 처리하는 것보다는, 다음과 같은 계층적 접근이 더 효과적일 수 있습니다:

  1. Exception Filters 활용
1
2
3
4
5
6
7
8
9
10
11
12
13
@Catch(UnauthorizedException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: UnauthorizedException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();

    response.status(401).json({
      statusCode: 401,
      message: 'Unauthorized access'
    });
  }
}

  1. Guards에서의 인증/인가 처리
1
2
3
4
5
6
7
8
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    // 인증 실패 시 UnauthorizedException 발생
    throw new UnauthorizedException();
  }
}

  1. Interceptors를 통한 공통 응답 처리
1
2
3
4
5
6
7
8
9
10
11
12
@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      map(data => ({
        statusCode: context.switchToHttp().getResponse().statusCode,
        data
      }))
    );
  }
}

이렇게 계층화된 접근을 사용하면 다음과 같은 이점이 있습니다:

  1. 관심사의 분리: 컨트롤러는 비즈니스 로직에만 집중할 수 있습니다
  2. 코드 재사용: 공통된 에러 처리 로직을 여러 컨트롤러에서 재사용할 수 있습니다
  3. 일관성: 애플리케이션 전반에 걸쳐 일관된 에러 처리가 가능합니다

물론 특수한 경우에는 컨트롤러에서 직접 상태 코드를 다뤄야 할 수도 있습니다. 하지만 대부분의 경우, Nest.js가 제공하는 이러한 기능들을 활용하는 것이 더 효과적인 접근방식이 될 것 같습니다.

이러한 방식에 대해 어떻게 생각하시나요?

다시 개선한 협업 과정

가급적이면 Nest.js 에서 제공하는 철학을 지켜야하는게 맞다고 생각이 들지만, 우리 팀에서 협업하는 방식은 팀 전체적으로 원하는 컨벤션을 지키는게 좋다고 생각했습니다.

위에서 언급했듯, 저희 프로젝트를 백엔드를 맡고 계신 다른 개발자분께서 맡으신 부분이라 최대한 그부분에 편하게 맞춰주려고 노력했습니다. 이전 스터디 세션 리팩토링 과정 에서 배우기를 코드 구현의 철학보다는 팀의 가치를 지키는게 중요하다고 생각했고, 팀이 원하는 컨벤션을 어느정도 맞추는 것도 중요하다고 생각하게 되었습니다.

Pasted image 20241228225723.webp

Pasted image 20241228225742.webp

진정한 협업의 가치는 팀을 먼저 생각하기 가 아닐까 싶습니다. 무엇이든 정답이 없고, 진짜 협업의 가치를 쫓기 위해서는 저나 프레임워크의 원칙보다 팀원의 원칙을 따라가는게 맞지 않나 생각을 했고, 실제로 반영하여 Claude의 답변을 반영하진 않았고, 이대로 진행하려고 합니다!

This post is licensed under CC BY 4.0 by the author.