23년 2월 14일 TIL & 개발노트 (깔끔한 코드에 대한 고민)

thumbnail

23년 2월 13일 TIL

금일 흑우집합소를 개발하며 배운 내용 및 겪은 과정을 정리하여 올려본다.
오늘은 깔끔한 코드 작성에 대해 고민한 부분을 정리하여 올려본다.

어제 작업하던 JWT 부분에 대해 매듭을 지려고 했다.
일부분은 Ports And Adapters Architecture 방식으로 작성해보려고 노력중이다.

근데 확실히 레이어드 아키텍쳐보다 더 나은 것 같다.
이유는 크게 세 가지다.

  1. 하나의 코드 파일의 가독성 증가
  2. 레이어드 보다 더 세분화 가능
  3. 깔끔한 파일 관리

개인적 사견이 많이 녹여 있어서 다른 사람은 공감 못할 수 있다.
레이어드 아키텍쳐로도 충분히 구현 가능하지만, 이 아키텍쳐로 인해 명확하게 각 역할이 나눠지는 것을 채감했다.

이것은 차후에 다루고…
오늘 했던 고민은 아래와 같다.


고민 포인트

먼저 내가 jwt를 검증하는 Inbound 영역의 서비스 로직이 있다.
코드는 아래와 같다.

export class VerifyJwtService implements VerifyJwtOutboundPort {
  constructor(
    private readonly jwtService: JwtService,
    @Inject(DECODE_JWT_OUTBOUND_PORT)
    private readonly decodJwtOutboundPort: DecodeJwtOutboundPort,
    @Inject(FIND_ACCOUNT_EMAIL_JOIN_TYPE_OUTBOUND_PORT)
    private readonly findAccountEmailJoinTypeOutboundPort: FindAccountEmainJoinTypeOutboundPort,
    @Inject(LOGOUT_ACCOUNT_INBOUND_PORT)
    private readonly logoutAccountInboundPort: LogoutAccountInboundPort,
    @Inject(CREATE_JWT_OUTBOUND_PORT)
    private readonly createJwtOutboundPort: CreateJwtOutboundPort
  ) {}

  async excute(params: VerifyJwtInputDto) {
    try {
      params.tokenType === JWT_TYPE.ACCESS_TOKEN
        ? await this.verifyAccessJwt(params.jwt)
        : await this.verifyRefreshJwt(params.jwt)
    } catch (e) {
      throw e
    }
  }

  private async verifyAccessJwt(jwt: string) {
    try {
      this.jwtService.verify(jwt, {
        secret: process.env.JWT_ACCESS_SECRET_KEY,
        audience: getJwtAudience(),
        issuer: getJwtIssuer(JWT_TYPE.ACCESS_TOKEN),
      } as JwtVerifyOptions)
    } catch (e) {
      const validResult = this.tokenExceptionHandler(
        e,
        jwt,
        JWT_TYPE.ACCESS_TOKEN
      )

      if (validResult instanceof AuthException) {
        throw validResult
      }

      //고민 포인트 01
      //액세스 토큰에서 만료가 되면 리프래시 토큰을 검증한다.
      //큰 문제 없다면 AccessToken을 다시 전달한다.
      //이 부분에는 거의 10~20줄 짜리 코드 로직이 들어갔었다.
    }
  }

  /**
   * 리프레시 토큰 검증  고민포인트 02
   * @param jwt
   */
  private async verifyRefreshJwt(jwt: string) {
    try {
      this.jwtService.verify(jwt, {
        secret: process.env.JWT_REFRESH_SECRET_KEY,
        audience: getJwtAudience(),
        issuer: getJwtIssuer(JWT_TYPE.REFRESH_TOKEN),
      } as JwtVerifyOptions)
    } catch (e) {
      //고민 포인트 03
      //리프레시 만료시에 대한 처리 로직
    }
  }

  /**
   * 토큰 예외 공통 처리 핸들러
   * @param e
   * @param jwt
   * @param tokenType
   * @returns
   */
  private tokenExceptionHandler(
    e: Error,
    jwt: string,
    tokenType: JwtType
  ): boolean | AuthException {
    //토큰 만료 시
    if (e instanceof TokenExpiredError) {
      console.log('[tokenExceptionHandler] : TokenExpiredError')
      // return true;
      return new AuthException(AuthJwtErrorCode.jwtExpired(jwt, tokenType))
    }
    // payload가 잘 못 되었을 때 (base64 decode가 안되는 경우 등)
    else if (e instanceof SyntaxError) {
      console.log('[tokenExceptionHandler] : Syntax')
      // throw new AuthException(AuthJwtErrorCode.jwtInvalid(jwt, tokenType));
      return new AuthException(AuthJwtErrorCode.jwtInvalid(jwt, tokenType))
    }

    // JwtWebTokenError should be later than TokenExpiredError
    //보통 토큰의 시크릿 코드가 변경되었는데 구형 키 값을 사용하여 만들어진 JWT를 사용할 경우 발생
    else if (e instanceof JsonWebTokenError) {
      console.log('[tokenExceptionHandler] : JsonWebTokenError')
      // throw new AuthException(AuthJwtErrorCode.invalidTokenOrInvalidSignature(jwt));
      return new AuthException(
        AuthJwtErrorCode.invalidTokenOrInvalidSignature(jwt, tokenType)
      )
    }

    //그 외 기타 에러
    //기타 에러
    return new AuthException(AuthJwtErrorCode.jwtEtcError(jwt, tokenType))
  }
}

딱 봐도 코드의 문제가 보인다. (고민 포인트는 아래서 집어볼 예정)
일단 가장 큰 문제는 코드의 복잡도가 높다는 것이다.

내가 최근 연습하고 있는 방법은 하나의 서비스는 하나의 기능만 담당한다 이다.
이게 생각보다 어렵다.

명확하게 나눠지는건 쉽게 자를 수 있지만 위처럼 Jwt를 구현할 때 Access와 Refresh를 같이 쓴다.
이 경우 아래와 같이 생각할 수 있다.


  1. 먼저 한 메서드를 통해 JWT와 타입을 받고 내부 프라이빗 메서드에서 나눠서 처리하는 방법
  2. 검증 자체 목적에 집중하는 서비스 로직과 Access와 Refresh 각각을 담당하는 서비스로 나눠서 처리하는 방법
  3. 검증이라는 목적에 집중하여 Access와 Refresh 검증만 하고 그 외 예외 발생 시 호출 영역으로 전가시키기

맨 처음 내가 구현한 방법은 1번 방식이었고…
결국 위 코드처럼 복잡도가 매우 많이 상승하게 되었다.

그럼 2번으로 할 것인가…
이 부분도 잘 생각해야 하는게 나누고 자르는게 좋지만 너무 나눌 경우 그것 자체로 인해 기능이 너무 산재되어 결국 코드 팔로우가 힘들 것이다.

그래서 3번의 방법을 선택하게 되었다.
이렇게 하면서 각 고민 포인트를 고민하고 해결하였다.


고민 포인트 01

Access JWT를 검증하고 catch 하는 부분에서 예외에 대하여 직접 처리하려 했더니 catct 부분이 복잡해졌다.
이로 인해 Refresh JWT 부분의 catch도 같은 문제가 생기게 되었다.

이 부분은 위에서 언급한 3번 방법으로 해결하게 되었다.

JWT를 검증하는 로직에서 AuthException 이라는 예외를 직접 핸들링하고,
이 부분에서 내가 의도한 예외(Access의 Expired 같은)는 따로 정상 처리를 하고,
그 외의 예외는 호출 지점으로 올려서 Controller 단의 ExceptionHandler로 흐르게끔 처리했다.

그래서 이 부분은 해결할 수 있었다.


고민 포인트 02

검증 로직에서 Access 와 Refresh를 각각 나눠서 별도의 파일로 관리할 것인가를 고민했었다.
근데 이것도 위에서 언급한 바와 같이 정답이 아니었다.

혹시 별도의 JWT가 추가되면 계속 코드 파일이 늘어나기 때문이다.
그래서 이 부분은 1번 포인트와 같이 검증 목적에 집중하기로 했다.

결국 신경 쓸 부분은 Refresh가 만료된 시점에 사용자 로그아웃 처리였다.
이 부분은 검증 로직에서 신경 쓸 것이 아닌 호출 지점에서 처리할 문제였다.


@Injectable()
export class AccessJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    @Inject(VERIFY_JWT_OUTBOUND_PORT)
    private readonly verifyjwtOutboundPort: VerifyJwtOutboundPort,
    @Inject(DECODE_JWT_OUTBOUND_PORT)
    private readonly decodJwtOutboundPort: DecodeJwtOutboundPort,
    @Inject(CREATE_JWT_OUTBOUND_PORT)
    private readonly createJwtOutboundPort: CreateJwtOutboundPort,
    @Inject(FIND_ACCOUNT_EMAIL_JOIN_TYPE_OUTBOUND_PORT)
    private readonly findAccountEmailJoinTypeOutboundPort: FindAccountEmainJoinTypeOutboundPort,
    @Inject(LOGOUT_ACCOUNT_INBOUND_PORT)
    private readonly logoutAccountInboundPort: LogoutAccountInboundPort
  ) {
    super()
  }

  async validate(req: Request, payload: any) {
    const jwt = req.header('testAuth')

    if (!jwt) {
      throw new AuthException(AuthJwtErrorCode.jwtNotFound())
    }

    try {
      await this.verifyjwtOutboundPort.excute({
        jwt,
        tokenType: JWT_TYPE.ACCESS_TOKEN,
      })
    } catch (e) {
      if (e instanceof AuthException) {
        //Access Jwt 만료된 경우
        if (e.errorCode === ERROR_CODE_JWT_EXPIRED) {
          const validResult: boolean = await this.validRefreshJwt(account)
          //이후 로직 처리
        }
      } else {
        throw e
      }
    }
    return payload
  }
}

위 코드는 Strategy 파일이다.
Nest.js에서 사용하는 전략 파일의 일종이고, 이건 Access Jwt의 처리를 담당하는 Strategy다.

이 부분에서 토큰 검증 이후 만료된 토큰에 대한 처리, 갱신 가능하면 갱신 처리, 각종 Jwt의 예외처리를 담당했다.
위 코드는 샘플용이라 정확하지 않지만…

내가 지금 확인해보니 이 Strategy는 import와 몇 주석을 빼면 100줄도 안되는 코드로 변했다.
기존에 verify에 몰아서 짤 때는 약 300줄이 넘어갔다 -_-;;

그래서 목적에 집중한 코드 덕분에 한결 나아진 코드를 작성할 수 있었다.


고민 포인트 03

이 부분도 01, 02와 연계되어 있는데, 결국 Refresh가 만료되었을 때에 대한 처리 빼곤 Access와 동일했다.
그래서 별도 분리를 하지 않고, verify에서 예외를 호출단으로 넘기고 처리는 호출점에서 담당하는 방식으로 해결했다.


정리

오늘도 코드를 작성하면서 좀 더 깔끔하고, 유지보수가 쉬운 코드로 작성하는 연습을 하면서 하니…
사실 시간은 좀 배로 걸리는 것 같다.

실행 목적에 집중하여 막 짜면 결과는 사실 금방 나온다.
그리고 이렇게 블로그로 남길거 생각 안하면 더 빠르고…

근데 이렇게 하여 완성하고 나면 결국 그 뒤가 문제다.
지금 내가 만든 MyMe.Link가 그렇다.

정말 코드가 판타스틱하다.
사실 유지보수 해야 하는데 정말 큰 일이 된거 같아서 큰일이다…
차라리 다시 짜는게 나을 것 같기도 할 정도니 말이다.

이번 흑우집합소는 그런 전철을 안밟게 하려고 하는데 이게 맞는건지도 사실 잘 모르겠다.
정답은 없기에…

일단 이게 최선의 방법인 것 같고 하면서 더 나은 코드를 짜는 방법을 찾는게 목적이 되었다.

중간에 개발업 내려놨다 왔지만 나름 5년차 백엔드 개발자다.
물론 약 3년 휴식 + 전에 약간 섞인 물경력이 문제긴 하지만…

나름 쌓여있는 경험치 덕분에 혼자서도 이렇게 잘(?) 해낼 수 있던 것 같다.

이제는 다른 사람과 함께 일해보고 싶다.
근데 일단 매듭은 짓고 다시…


Written by@MHLab
로또는 흑우집합소 🎲
와인관리, 시음노트, 셀러관리는 마와셀 🥂

🫥 My Service|  📜 Contact|  💻 GitHub