디프만에서 진행한 프로젝트로 nest js 에 mongoDB를 적용하면서 고민한점에 대해서 이야기할려고한다.
우선 얻어갈수있는 부분에 대한 목차이다.
- 모델설정 , 다른 모델 populate 처리시 타입은?
- populate를 안한상태에서 ObjectId로 비교를 하고싶을때
- ClassSerializerInterceptor with mongoDB ...?
- 컨트롤러에서 밸리데이션과동시에 ObjectId형으로 변환하기
- class-validator , class-transform 의 밸리데이션시 실행시점 ( 매우중요 )
아래 예시코드들은 위 프로젝트에서 떼온것이다!
typeOrm을 쓰게되면 매우 잘 맞아떨어지는 느낌으로 돌아가지만 mongoDB를 쓰게되면 삐걱삐걱거린다.
가장큰이유로는 mongoose의 쿼리시 mongoose Document class 형으로 반환을하고,해당 반환값을
그대로 ClassSerializerInterceptor를 통해서 응답값을 내보낼때 인데 티키타카 내에선 어떻게 해결했는지 후에 설명하도록하고
우선 모델 설정부분에 대해서 말해보겠다!.
모델설정 , 다른 모델 populate 처리시 타입은?
jpa 나 typeOrm 이미 써보신분들은 안 헷갈리실수도 있지만 , 본인은 자바스크립트에서 타입스크립트를 적용할려니 처음에 좀 많이 했갈렸었다. 티키타카에서 쓰고있는 유저모델과 룸 중 중요 포인트만 살펴보자면,
import { Types } from 'mongoose';
export class User {
// 유저 고유 아이디
@TransformObjectIdToString({ toClassOnly: true })
@Expose()
_id: Types.ObjectId;
// 저장될땐 ObjectId 형태로 저장됨
// 잘못 타입을 선한하는경우 Types.ObjectId[] 처럼 하는경우임!
@Prop({ type: [{ type: Types.ObjectId, ref: 'Room' }] })
favoriteRoomList: Room[];
//룸정보간소화
@Type(() => ResShortCutRoomDto)
@Prop({ type: Types.ObjectId, ref: 'Room' })
@Expose()
myRoom: Room | null;
// 시간 +9 로 변환로직
@Transform(({ value }) => toKRTimeZone(value), { toClassOnly: true })
@Expose()
createdAt: Date;
}
user.model.ts
export class Room {
// 룸의 고유 아이디
@TransformObjectIdToString({ toPlainOnly: true })
@Type(() => Types.ObjectId)
@Expose()
_id: Types.ObjectId;
// 방안에들어있는 유저 리스트 정보
@Prop({
required: true,
type: [{ type: Types.ObjectId, ref: 'User' }]
})
@Type(() => UserProfileDto)
@Expose()
userList: UserProfileDto[];
// 위치정보 저장
@Prop({
type: Geometry,
index: '2dsphere'
})
@Type(() => Geometry)
@Expose({ toClassOnly: true })
@Exclude({ toPlainOnly: true })
geometry: Geometry;
}
room.model.ts
몽고디비 입장에서는 populate를 하기전까진 ObjectId 형식으로 도큐멘트들에 저장을 하지만 ref 되는 모델 형식으로 적어줘야만
populate를 해올때 타입스크립트에서 타입으로 접근 할 수가있다.
그리고 가끔 모델에
export class User extends Document
이런형식으로 기술하는 경우가 있는데 이렇게 구성하면 서비스레이어에서 타입가지고 뭘하기가 매우매우 힘들어진다. 값대입할때 Document형을 포함하고있지않다고 ide가 궁시렁거릴 확률이 매우매우높아진다.
절대적으로 빼주시는게 맞다.
document형 문서
사실은 타입상은 맞다. User.find() 처럼 메서드를 실행시키고나면 mongoose.document.class 형으로 결과값이 감싸져서 나오는데
변경감지나, 트래킹이나 , virtual로 설정한 모델 프로퍼티등 좀더 도움을주는 helper 객체라고 생각하시면 될것 같다.
하지만 레포지토리쓰는 패턴인데 서비스레이어 까지..? 디비를 수정할수있는 그런 헬퍼객체를 올리는게 맞지 않다고 생각은한다.
위와같은이유로 타입은 extends Document를 하는게 맞지만, 서비스레이어에서 Document 내부에있는 헬퍼 펑션들을 사용할 일 이 없기때문에 extends를 빼주시는게 맞다고 생각한다.
populate를 안한상태에서 ObjectId로 비교를 하고싶을때
하지만 populate를 안적어줬을때는 어떻게 하냐?
우리가 내가 들어가있는 방정보에서 iFavorite ( 내가 즐겨찾기를 했는지 표시해야하는 필드 ) 를 응답으로 줘야한다고 가정하자
이를 구현할라면 내가 즐겨찾기로 추가해놨던 방 목록을 가져와 내가 현재 들어가있는 방정보가 있는지 찾으면된다.
pk 격인 ObjectId 로 비교를 수행해야하는데 populate를 해서 굳이 방의 모든정보를 가져오지 않아도 _id값으로 구별이 가능하다.
매번 쿼리에 populate를 해서 방객체를 가져온뒤 _id를 꺼내와도되지만, _id값 가지고 비교만하는데 나머지 부가정보가 현재는 필요없기 때문이다.
이때 그러면 내가 컴파일 타임에선 Room 타입이라고 적어줬지만 실행 시점에선 실제론 ObjectId형이 값으로 들어가게된다 ( populate를 적어주지 않았기 때문에 ) 그러나. 내가 id값을 비교할려고 했지만 룸타입과 룸타입으로 == 비교를 할수는 없다.
이를 해결할 수 있는 트릭이있다.
아래 소스처럼 이미 userId 가 ObjectId 형인데도, userId._id로 또 ObjectId형인 아이디를 접근할수가있다.
즉 Room 타입이지만 populate를 안했을시에 room 필드에 ObjectId("67772999") 이런형태의 정보가 들어가있고
._id 필드가 필요하다면 room(실제론ObjectId)._id 형태로 원래의 아이디 값을 가져올수있는 것이다.
다시 설명하자면
ObjectId("67772999")._id = ObjectId("67772999")
위와 같은 공식이? 통한다.
따라서 서비스레이어에서 비교로직을 수행한다면 ObjectId 형의 헬퍼 펑션인 .equals의 도움을 받아 서비스레이어에서 다음과 같은 코드를 구성할 수있다.
ClassSerializerInterceptor with mongoDB ...?
mongoose 를 사용할때의 문제점은 mongoose.document.class 형태로 쿼리의 결과를 받는 다는것이다.
가령, User.find() 실행시에 결과가 mongoose.document.class 형태로 넘어와서 안에 헬퍼 펑션이 있는체로
시리얼라이저를 활용해@Expose , @Exclude 등으로 주고싶은 정보를 빼는것을 원하지만 실상은
이상한 메소드나 값들도 리턴값으로 전달을 해준다.
이런경우의 오류는 이유가 단한가지인데
위에서 설명했듯이 Room 타입이여도 실행시점에는 Documents 가 extended 된 타입이기때문에 헬퍼 펑션들이 같이 나가기 때문이다.
이러한 객체를 다시 Room 타입으로 변환을 해주던, 아니면
.lean 옵션을 통해서 리포지토리 단에서 아예 Document 형식을 덜어낸 리터럴 (플레인) 객체로 리턴을 해줘야한다.
이때 팁은 {default : true} 를 .lean의 인자로 넘겨준것인데 위문서에서 공식적인 패키지다.
모델선언시에 @Prop에 default 값을 .lean 시에도 유지 시켜준다.
주의할점은 .lean 사용시 plain한 객체라는 것인데.
시리얼라이저의 @expose 등의 효능의 참맛을 느끼고싶으면
서비스레이어에서라도 최소한으로 class 생성자를 가진 인스턴스로 변환해서 ( new Dto() 하라는 소리 ) 넘겨야한다.
그래야 class-transformer의 toPlain 이 제대로 먹히니깐.
정말 중요한거다 리터럴 객체로 넘기면 시리얼라이징 하는게 아니다 클래스 생성자를 활용해서 인스턴스를 만들어야한다.!!!!!
ObjectId 도 클래스 시리얼라이징시 동작하게 만들기
다 된줄 알았는데 날 또 힘들게 하는 무언가가있다.
분명 new 이용해서 클래스 인스턴스로 만들었고 @Expose 까지 적어줬는데도 이런다.
이는 _id 형식이 ObjectId 형이기 때문에 발생하는 오류인데
// 디티오
@TransformObjectIdToString({ toClassOnly: true })
@Expose()
_id: Types.ObjectId;
//TransformObjectIdToString
// Transform 을 활용한 커스텀 데코레이터 제작
import { ExposeOptions, Transform } from 'class-transformer';
export const TransformObjectIdToString =
(options?: ExposeOptions) => (target, propertyKey) => {
Transform(value => value.obj._id.toString(), options)(target, propertyKey);
};
위처럼 커스텀 데코레이터를 만들고 활용하면 쉬워진다.
toClassOnly 뭐 아시죠,,? plain -> Class로 변환시에만 동작하게 하는거에요!
Transform 을 활용해서 value => value.obj._id.toString() 즉 스트링형으로 인스턴스를 만들때 변환한다.
이렇게되면 최종적으로 응답이나갈때 시리얼라이저가 클래스 인스턴스를 객체로 변환해주면서 뺄정보 빼주고 그런식으로 동작하는것이다.
이렇게... 쿼리로 하는건 알겠는데.... 이건 쿼리로 꺼내올때이고...
사실 우린 클라이언트한테 _id값을 받을때가 있지 않은가?
json으로 통신하면 string형으로 넘어올텐데 넘어온값을
밸리데이션 ( ObjectId 형식인지 검증 ) 과함께 자연스럽게 컨트롤러 단에서 ObjectId형식으로 변환하는 방법을 알아보자.
컨트롤러에서 밸리데이션과동시에 ObjectId형으로 변환하기
서비스레이어에서 아주 신나게 비교로직을 수행하는데 만약
위와같은 api가 있다고 가정하자. url은 어차피 다 스트링형으로 넘어오니
/v1/rooms/626cf238b51596721c21289b/join
위처럼 클라이언트가 요청하면 우린뭐...
joinRoom(@Param("roomId") roomId, @ReqUser() user: User){
// roomId 를 ObjectId형으로 변환...? 언제...?
// roomId를 받고나서 변환해줘야하나?
// 그냥 저기서 ObjectId형식으로 변환될수있는지 밸리데이션하고
// ObjectId형으로 받고싶어...!!!
}
위와같은 방식으로 컨트롤러에서 받고 서비스레이어에 넘겨주기전에 왠만하면 밸리데이션과 함께 String 형을 ObjectId 형으로 변환시킨후에 넘겨주는게 맞다고 생각한다. 그래야 서비스 레이어에서는 동일하게 ObjectId형을 가지고 논다라고 생각할수 있기 때문이다.
따라서 밸리데이션과 함께 ObjectId 형으로 변환하기 위해선. 커스텀을 할필요가있다.
여기서 중요한점은 class-validator의 동작방식에 대한 이해이다.
class-validator , class-transform 의 밸리데이션시 실행시점
우리들이 @IsIn , @IsString , @IsNumber 처럼 클래스 안에 필드들에 적어주는 어노태이션들은
컴파일시점에서 실행될때 딱 한번 해당 필드들에대해 reflect-metadata (이하 리플렉션 ) 을 사용해서 우리 눈에는 안보이는 정보들을 심어준다. class 타입으로 메타데이터를 가지고있으면 해당클래스를 생성자로 이용하여 객체를 생성하였을때 , 안보이는 정보들을 이용해서 검사를 할수있는것이다.
메타데이터에대한건 클래스의 데코레이터 (4가지가있는데 ) 그중 필드부분 메타데이터 보면 validation관련부분이있다 함 보고오시라.
그러면 class-validator의 동작 시점은 언제인가? 라고 한다면
json > (express가 json을 자바스크립트 객체로 바꿔줌) > literal (plain) object > class로 만든이후 > validate(검증)
이라고 생각할 수있다. 왜냐면 죽이되든 밥이되는 클래스 형태로 ( 생성자가 있는 형태로 ) 만들어야지 메타데이터를 조회 ( 즉 @IsIn이라고 적었는지 @IsString 라고 적었는지 눈에 안보이는 정보를 빼내올수 있다는 소리 ) 이다.
정보를 빼내오면 class-validator의 공식문서에 적혀지있는 validate 메소드를 실행시켜 생성자로 만들어진 객체에서 metadata를 조회해서 해당필드가 맞게 값을 가지고 있는지에대한 검사를 진행한다는 뜻이다.
위 소스는 nest js 에서 제공하는ValidationPipe 에 ts타입말고 js 구현파일이다. 여기서 중요한점이 있는데.
https://github.com/nestjs/nest/blob/master/packages/common/pipes/validation.pipe.ts
literal (plain) object >(class-transformer) > class로 만든이후
저 클래스로 만드는 과정속에서 classTransformer가 사용된다는 점이다. 여기서 아차 싶은점이있다.
단순히 생각하기로는 위에서 @Transform으로 ObjectId를 스트링으로 만들었으니
씐나게~ 만들어보자 하면서 아래와같은 소스를 짤수 있다.
import { Types } from 'mongoose';
import { IsMongoId } from 'class-validator';
export class RoomIdDto {
@IsMongoId()
@Transform(value => new Types.ObjectId(value) , {toClassOnly : true });
roomId: Types.ObjectId;
}
@IsMongoId를 이용해서 몽고아이디 형식인지 확인했고!
이러면! 클라이언트가 갑자기 이상한 "123123123" 값을 보내도 몽고아이디가 아니니깐 밸리데이션이 되겠지!
@Transform 이용해서 클래스만들때~ string으로 받은걸 ObjectId 형식으로 만들어줘야지 !
할수있다.
이럼 오류난다
왤까? 당연하다
literal (plain) object >(class-transformer) > class로 만든이후
리터럴 오브젝트 에서 클래스 인스턴스로 변환할때 class-transformer을 사용한다.
그럼 저 @Transfrom이 먼저 실행되는것이다
이해가되는가? 밸리데이션이 먼저되야하는데 트랜스폼이 먼저되면서 new Types.ObjectId("똥값") 이 먼저 실행되는것이다.
그럼 당연히 바로그냥 500번대 오류난다.
이를 해결하기위해선
밸리데이션이 가장 나중에 실행된다는 점을 이용해야한다
다시 컨트롤러에서 밸리데이션과동시에 ObjectId형으로 변환하기
// 최종형태
export class RoomIdDto {
@MongoIdValidationTransfrom({ toClassOnly: true })
roomId: Types.ObjectId;
}
//사용방법
@ApiOperation({ summary: '유저가 채팅방에서 아예 나가버릴때' })
@Delete(':roomId/join')
outRoom(@Param() roomIdDto: RoomIdDto, @ReqUser() user: User) {
roomIdDto.roomId --> ObjectId 형태
}
import { applyDecorators } from '@nestjs/common';
import { ExposeOptions } from 'class-transformer';
import { NotEquals } from 'class-validator';
import { failToConvertMongoId, MongoIdTransform } from './MongoIdTransform';
// 밸리데이션기능을 수행할수있는 커스텀 데코레이터
export function MongoIdValidationTransfrom(options?: ExposeOptions) {
return applyDecorators(
MongoIdTransform(options), //MongIdTransform 실행시 ObjectId형태 또는 failToConvertMongoId
NotEquals(failToConvertMongoId, {
message: 'MongoId 형식 오류',
}),
);
}
import { ExposeOptions, Transform } from 'class-transformer';
import * as mongoose from 'mongoose';
import { Types } from 'mongoose';
export const failToConvertMongoId = 'mongoIdCheckFail';
export const MongoIdTransform =
(options?: ExposeOptions) => (target, propertyKey) => {
Transform((value) => {
// 몽고아이디 인지를 검사
if (!mongoose.isValidObjectId(value.obj[propertyKey]))
return failToConvertMongoId;
return new Types.ObjectId(value.obj[propertyKey]);
}, options)(target, propertyKey);
};
literal (plain) object >(class-transformer) > class로 만든이후 > 검증
위순서를 이용하여 transfrom이 먼저실행될때 ObjectId 형태가 맞으면 ObjectId형태로 바꿔서 반환하고
아니면 "mongoIdCheckFail"로 반환하고
클래스의 인스턴스로 만들어진뒤에
검증이란걸 했을때 roomId 필드가 "mongoIdCheckFail" 이라는값을 가지고 있다면 validation 오류를 반환하도록 하였다.
또한 (target, propertykey) => {} 형식은 타입스크립트 어노테이션 관련 공식문서에 나와있는 필드 어노테이션의 propertyKey ( 필드 이름 ) 을 접근할수 있는 방법이니 넘어가겠다.
이런 방식으로 구성하게되면 자연스럽게 컨트롤러에서 objectId형태인 _id값으로받아 서비스레이어에 전달해 줄 수 있다.
쓰다보니... 좀 어려운 내용들 ( 리플렉션 , 시리얼라이징 ) 등 내용이나왔는데 네스트 쓸꺼면 꼭 알아두셨으면 한다.
클래스의 인스턴스 형태여야지 시리얼라이징이 제대로 동작한다는점을 무엇보다도 염두해두고 코드를 작성하면 좋을것같다.
그리고... 티키타카 깔아주세요 ㅎㅎ
'nest.js' 카테고리의 다른 글
[고스락 티켓 2.0] nestjs db rollback repository 테스트 (0) | 2022.08.14 |
---|---|
[고스락 티켓 2.0] nestjs transaction with repository (0) | 2022.08.14 |
[고스락 티켓 2.0] nestjs redis forRootAsync 모듈 만들기 (0) | 2022.08.13 |
[고스락 티켓 2.0] nest js ValidationError custom (1) | 2022.08.13 |
[고스락 티켓 2.0] nest js 유저 role 기반 api 인가 (0) | 2022.08.13 |