Post

number 타입의 쿼리 파라미터 | Repository API로 변경 | Request 타입 처리 | 복잡한 서비스 로직 책임 분리하기

social-feed-hub의 게시물 목록 조회, 상세 조회 API를 구현하며 코드 리뷰 받은 부분 반영하기

number 타입의 쿼리 파라미터 | Repository API로 변경 | Request 타입 처리 | 복잡한 서비스 로직 책임 분리하기

해당 PR 보러가기

1. number 타입의 쿼리 파라미터 형 변환하기

문제

  • Query Parameter든 Path Parameter든 들어올 땐 string 타입으로 들어온다.
  • 현재 프로젝트에서, number 타입의 쿼리 파라미터로 pageCount, page가 있다.
  • 저 변수들이 서비스 로직에선 산술 연산자와 함께 암시적 형변환이 이루어지고 있어 동작하는데 문제는 없었지만, 타입을 정확히 하고, NaN 처리를 해주지 않아 추가해야 한다.

시도

1
2
3
4
5
6
7
  @IsNotEmpty()
  @Transform(({ value }) => Number(value))
  readonly pageCount: number = 10;

  @IsNotEmpty()
  @Transform(({ value }) => Number(value))
  readonly page: number = 0;
  • class-transformerTransform 데코레이터를 활용해 number로 타입을 변환한다.
  • 형 변환에는 parseInt, Number, + 단항 연산자 붙이기 등 많은 방법이 있지만 명시적으로 Number를 사용했다.
1
2
3
4
5
6
7
  async findAll(hashtag: string, queries: PostingQueryDto): Promise<PostingResponseDto[]> {
    if (isNaN(queries.pageCount) || isNaN(queries.page)) {
      throw new BadRequestException();
    }
    
    const rawPostings = await this.getRawPostings(hashtag, queries);
  ...
  • 그리고 서비스 로직 안에서 NaN인 경우에 대한 예외 처리를 추가했다.

새로운 문제

  • 위 방법은 DTO에서 기본값을 설정하고 있는데, 해당 변수에 아무것도 입력되지 않아 기본값(number)가 들어갈 때 타입 문제가 있었다.
  • DTO에서의 기본값을 string 타입으로 ('10', '0' ) 바꾸는 방법도 있었지만 논리적으로 맞지 않는다고 생각했다.
  • NaN 처리를 서비스 로직에서 하고 있는데, 지금까지 데이터 유효성 검증은 컨트롤러단에서 모두 했던 것 같아 그다지 효율적인 방식이라는 생각은 들지 않았다.

시도 ✅

1
2
3
4
5
6
7
  @IsNotEmpty()
  @IsNumberString()
  readonly pageCount: number = 10;

  @IsNotEmpty()
  @IsNumberString()
  readonly page: number = 0;
  • DTO에서 해당하는 변수들에 @IsNumberString() 데코레이터를 사용하여 별다른 코드 추가 없이 간결하게 해결하였다!
  • DTO에서 모든 유효성 검증을 처리해 코드 일관성을 유지할 수 있다.
  • number로 실질적인 타입 변환까지 되진 않지만, 이게 코드를 쓰는데 크게 작용할 것 같진 않다. 아주 굿~

2. Repository API로 가능한 부분 변경하기

문제

1
2
3
4
5
6
7
  const posting = await this.postingRepository
    .createQueryBuilder('posting')
    .leftJoinAndSelect('posting.postingHashtags', 'postingHashtag')
    .leftJoinAndSelect('postingHashtag.hashtag', 'hashtag')
    .where('posting.id = :id', { id })
    .getOne();
  ...
  • queryBuilder를 사용한 원래 코드 (상세 조회 API)
  • 팀 컨벤션대로, Repository API로 구현할 수 있으면 queryBuilder 지양하기.

시도 ✅

1
2
3
4
  const posting = await this.postingRepository.findOne({
    where: { id },
    relations: ['postingHashtags', 'postingHashtags.hashtag'],
  });
  • Repository API relations 옵션을 사용해 수정했다.
  • 가독성 ㄹㅈㄷ로 개선
  • 조건별 쿼리 빌드가 필요한 목록 조회 API는 기존대로 queryBuilder를 사용하기로 결정했다.

3. user 객체가 담겨있는 Request 타입 처리

문제

1
2
3
4
5
6
  @UseGuards(AuthGuard('jwt'))
  @Get()
  async findAll(@Req() req: any, @Query() queries: PostingQueryDto): Promise<PostingResponseDto[]> {
    const hashtag = queries.hashtag || req.user.accountName;
    return await this.postingsService.findAll(hashtag, queries);
  }
  • 보통 컨트롤러에서 Request 객체에 접근이 필요하면, expressRequest를 import해 타입 명시를 해주었다.
  • 그러나 이 코드는, 인가 과정을 거치기 때문에 들어오는 req 객체에 User 객체도 포함되어 있을 것이다. 그래서 Request { ..., user: User {} } 이런 식의 커스텀 타입이 필요하다.
    • 우리가 궁극적으로 필요한 건 User 객체의 accountName이다.
    • 해당 과정은 src/auth/jwt.strategy.ts에서 수행한다.

첫 번째 시도

1
2
3
interface UserRequest extends Request {
  user: UserResponseDto;
}
  • 다른 팀원분께서 정의해놓으신 인터페이스를 갖다 쓰는 방법
  • 채택되지 않은 이유: export 키워드 붙여도 되나… 싶어서

두 번째 시도 ✅

1
2
3
4
5
6
  @Get()
  @ApiAcceptedResponse({ type: StatisticResponseDto })
  getStatistic(@Query() statisticQuery: StatisticQueryDto, @Req() req: Request): Promise<StatisticResponseDto> {
    const userName = (req.user as User).accountName;
    return this.statisticService.getStatistics(statisticQuery, userName);
  }
  • 지난 코드 리뷰 시간에 통계 쪽에서도 accountName이 필요했던 게 기억나서, 다른 팀원분의 코드를 훔쳐보았다ㅎㅎ
  • as 키워드를 사용해 타입을 우회하고 있다.
  • 시도 1과 비교해 뭐가 더 나은 방법인지, 스타일의 문제인지 모르겠으나 이 방법을 쓰면 현재 내 상황에서 다른 코드를 더 고치지 않아도 되어서 채택했다.
  • 지금 보니 만약 req.usernull이나 undefined 값이면 accountName에 접근하는 과정에서 에러가 날 것이다. 당시엔 별 이슈가 없었나 보다.

또 다른 방법

1
2
3
4
5
6
7
8
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const UserInfo = createParamDecorator(
  (data: any, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user || null;
  },
);

보통은 이렇게 커스텀 데코레이터를 만들어 변수에 붙이는 식으로 사용했다. 중복 코드를 최소화할 수 있어 좋은 방법인 것 같다.

4. 복잡한 서비스 로직 책임 분리하기

문제

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
  async findAll(hashtag: string, queries: PostingQueryDto): Promise<PostingResponseDto[]> {
    const { type, orderBy, sortOrder, searchBy, search, pageCount, page } = queries;
    const queryBuilder = this.postingRepository.createQueryBuilder('posting');

    // 1건의 hashtag와 정확히 일치하는 값 검색
    queryBuilder
      .innerJoinAndSelect('posting.postingHashtags', 'postingHashtag')
      .innerJoinAndSelect('postingHashtag.hashtag', 'hashtag')
      .where('hashtag.name = :hashtag', { hashtag });

    // 게시물의 type 별로 조회, 미입력 시 모든 type
    if (type) {
      queryBuilder.andWhere('posting.type = :type', { type });
    }

    // search 입력된 경우 searchBy에 따라 검색
    if (search) {
      if (searchBy.split(',').length === 2) {
        queryBuilder.andWhere('posting.title LIKE :search OR posting.content LIKE :search', { search: `%${search}%` });
      } else {
        queryBuilder.andWhere(`posting.${searchBy} LIKE :search`, { search: `%${search}%` });
      }
    }

    // 입력된 값대로 정렬해 페이지네이션
    const postings = await queryBuilder
      .orderBy(`posting.${orderBy}`, sortOrder)
      .skip(page * pageCount)
      .take(pageCount)
      .getMany();

    // hashtag depth 정리 & content 글자 수 제한 적용해 반환
    return postings.map((posting) => {
      const arrangedPosting = this.getPostingObjWithHashtags(posting);
      arrangedPosting.content = arrangedPosting.content.substring(0, 20);

      return arrangedPosting;
    });
  }
  • 컨트롤러에서 호출하는 서비스 함수 하나에서 모든 작업을 하고 있다.
  • 가독성 개선과, 책임 분리를 목적으로 함수로 나누기로 했다.

시도 ✅

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
  async findAll(hashtag: string, queries: PostingQueryDto): Promise<PostingResponseDto[]> {
    const rawPostings = await this.getRawPostings(hashtag, queries);

    // hashtag depth 정리 & content 글자 수 제한 적용해 반환
    return rawPostings.map((rawPosting) => {
      const posting = this.getPostingObjWithHashtags(rawPosting);
      posting.content = posting.content.substring(0, 20);

      return posting;
    });
  }

  // 입력된 쿼리 파라미터를 바탕으로 DB 쿼리를 빌드해 게시물 목록을 가져옵니다.
  private async getRawPostings(hashtag: string, queries: PostingQueryDto): Promise<Posting[]> {
    const { type, orderBy, sortOrder, searchBy, search, pageCount, page } = queries;

    const queryBuilder = this.postingRepository.createQueryBuilder('posting');

    ...

    // 입력된 값대로 정렬해 페이지네이션
    const rawPostings = await queryBuilder
      .orderBy(`posting.${orderBy}`, sortOrder)
      .skip(page * pageCount)
      .take(pageCount)
      .getMany();

    return rawPostings;
  }
  • 간단히 쿼리 빌더 부분만 따로 빼 함수를 만들었다.
This post is licensed under CC BY 4.0 by the author.