number 타입의 쿼리 파라미터 | Repository API로 변경 | Request 타입 처리 | 복잡한 서비스 로직 책임 분리하기
social-feed-hub의 게시물 목록 조회, 상세 조회 API를 구현하며 코드 리뷰 받은 부분 반영하기
number 타입의 쿼리 파라미터 | Repository API로 변경 | Request 타입 처리 | 복잡한 서비스 로직 책임 분리하기
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-transformer
의Transform
데코레이터를 활용해 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 객체에 접근이 필요하면,
express
의Request
를 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.user
가null
이나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.