좋은 코드를 보는 경험이 필요하다는 멘토님 피드백에 따라 선정한 교보재 코드
https://github.com/pietrzakadrian/bank-server
결론: 이 코드는 뜯고 씹고 즐기고 맛볼 가치가 있었다.
모듈안에 공통적으로 index.ts가 있음.
export * from './login-payload.dto';
export * from './token-payload.dto';
export * from './user-login.dto';
export * from './user-register.dto';
export * from './user-forgotten-password.dto';
export * from './user-reset-password.dto';
export * from './forgotten-password-payload.dto';
이런 구조는 모듈을 불러올 때 불러오는 위치가 하나로 통합된다는 장점이 있음.
이거 django에서 계층적으로 라우터 처리할 때 이런 느낌으로 한 것 같음.
띄어쓰기가 필요한 부분은 -로 표현함.
forgotten-password-payload.dto.ts
login-payload.dto.ts
token-payload.dto.ts
user-forgotten-password.dto.ts
user-login.dto.ts
user-register.dto.ts
user-reset-password.dto.ts
기능별 모듈 안에 guard, interfaces 등이 각각 있는 게 아니라 nest의 life cycle을 기준으로 폴더가 나눠져 있음.
나는 기존에 module이 모든 구조의 시작점이었는데, 여기서는 module도 하나의 기능 처럼 취급. 모듈의 기능은 컨트롤러와 서비스에 집중된 느낌이고 이 컨트롤러와 서비스를 운영하기 위한 의존성은 해당 모듈 안에서 구현
export class LoginPayloadDto {
@ApiProperty({ type: () => UserDto })
readonly user: UserDto;
@ApiProperty({ type: () => TokenPayloadDto })
readonly token: TokenPayloadDto;
constructor(user: UserDto, token: TokenPayloadDto) {
this.user = user;
this.token = token;
}
}
DTO에 readonly가 붙어 있음.
이건 한번 넘어온 DTO의 값을 변경을 막아서 더 안정적으로 DTO 운영.
근데 만약 DTO의 값을 변경해야 하는 로직이 필요할 때면 어떡하지?
DTO의 목적 자체가 계층 간 데이터 전송임.
즉, 그걸 비즈니스 로직에서 수정하는 건 목적에서 어긋남.
만약 데이터가 변경된 DTO가 필요하다면, 원본 DTO를 수정하는 게 아니라, 비즈니스 로직 수행 후 새로운 DTO를 생성하는 게 맞음.
constructor(user: UserDto, token: TokenPayloadDto) {
this.user = user;
this.token = token;
}
이런 방식에 대해서 멘토링 시간에 배운 적이 있음.
그럼 이건 이런식으로 사용 가능?
const user = new UserDto("John Doe", 30);
const token = new TokenPayloadDto("abcdef12345", new Date());
const loginPayload = new LoginPayloadDto(user, token);
의존성 주입
user, token 인스턴스를 클래스 내부에서 생성하는 게 아니라 생성자 주입 방식으로 주입 받음.
클래스 내에 생성자가 있으면 user, token이 없으면 LoginPayloadDto 데이터 정합성이 깨질 가능성이 적어 보임
실제 bank 코드에서 어떻게 쓰이는지 살펴보자.
async userLogin(
@Body() userLoginDto: UserLoginDto,
): Promise<LoginPayloadDto> {
const user = await this._authService.validateUser(userLoginDto);
const token = await this._authService.createToken(user);
return new LoginPayloadDto(user.toDto(), token);
}
DTO는 request에서만 쓰는 게 아니라 response에서도 쓴다??!!
그 전까지 객체 형태로 반환이 필용하면 return에 직접 객체를 하드코딩해서 반환했는데, 이렇게 DTO를 클래스로 만들고, 생성자 주입으로 인스턴스를 생성하고 그걸 반환하면 훨씬 안정적인 코드가 된다!!
숨겨진 서비스는 뭐지?
async userLogin(
@Body() userLoginDto: UserLoginDto,
): Promise<LoginPayloadDto> {
const user = await this._authService.validateUser(userLoginDto);
const token = await this._authService.createToken(user);
return new LoginPayloadDto(user.toDto(), token);
}
이렇게 주입된다.
constructor(
private readonly _userService: UserService,
private readonly _userAuthService: UserAuthService,
private readonly _authService: AuthService,
) {}
아, private 필드라서 관습적으로 그렇게 쓰는 거였음. 인스턴스에 _ 만 봐도 접근제어자를 알 수 있으니까 클래스 내부까지 뒤져볼 필요가 없음.
다른 모듈 컨트롤러를 살펴봐도 서비스를 다 private도 주입하고 있음.
객체 지향의 은닉화? 왜냐면 service를 외부에 공개하면, 외부에서 이 service에 접근할 수 있으니까. 외부에서는 내부의 service에 접근해서 조작하면 안 되고, 열려있는 controller에 접근해야 한다.
private과 readonly를 같이 쓰는 이유는?
목적이 다름
private는 캡슐화: 내부 구현을 외부로부터 숨김
readonly는 불변성: 객체 생성 후 객체 상태가 변하지 않음을 보장
결론: 객체 상태가 예측 가능해지고 안정적임. 객체의 전 생애 주기 동안 변경되지 않음을 보장. 앞으로 의존성 주입할 때는 private, readonly 쓰기!
async userRegister(
@Body() userRegisterDto: UserRegisterDto,
): Promise<UserDto> {
const user = await this._userService.createUser(userRegisterDto);
return user.toDto();
}
user.toDto() 는 뭐야?
User 클래스 내의 메서드인줄 알았는데 추상 클래스였다!!!
export abstract class AbstractEntity<T extends AbstractDto = AbstractDto> {
@PrimaryGeneratedColumn('increment')
id: number;
@Column()
@Generated('uuid')
uuid: string;
abstract dtoClass: new (entity: AbstractEntity, options?: any) => T;
toDto(options?: any): T {
return UtilsService.toDto(this.dtoClass, this, options);
}
}
이건 좀 나중에 자세하게 알아보자.
@Patch('logout')
// 데코레이터 생략
async userLogout(@AuthUser() user: UserEntity): Promise<void> {
await this._userAuthService.updateLastLogoutDate(user.userAuth);
왜 lotout이 patch인가?
서비스에서 호출 함수가 updateLastLogoutDate update?
세부 구현 들어가 보자.
return queryBuilder
.update()
.set({ lastLogoutDate: new Date() })
.where('id = :id', { id: userAuth.id })
.execute();
}
최근 로그아웃 시간을 기록하고 있다.
(클라이언트에서 토큰 같은 거 파기하는 식으로 처리하겠지?)
서비스레이어에서 typeORM repository를 쓰는 게 아니라 servcie에서 쿼리빌더를 호출하고 있다.
return queryBuilder
.update()
.set({ lastFailedLoggedDate: new Date() })
.where('id = :id', { id: userAuth.id })
.execute();
}
오호, 이제 보인다. 이거 빌더 패턴으로 배운 거 같은데 그래서 이름도 쿼리 '빌더'였다. ORM에서는 쿼리를 객체와 매핑시키는건데, 이 객체를 한번에 쫘좌좌작 생성하는 게 아니라, 빌더로 순차적으로 차곡차곡 . 메서드로 단계적으로 쌓아가는 것(빌딩, 체이닝)
만약 이걸 빌더 패턴으로 안 하면 객체 생성 시 매개변수가 엄청 길어져서 가독성 떨어지고, 또 순서나 개수 떄문에 오류 발생 가능성이 높아질 것 같음!
await Promise.all([
this._updateLastSuccessfulLoggedDate(userAuth),
this._userConfigService.updateLastPresentLoggedDate(userConfig),
]);
이런식으로 쓰는 게 있는데, 해당 클래스 안에서만 쓰는 메서드는 이런 식으로 구현됨.
private async _updateLastSuccessfulLoggedDate(
userAuth: UserAuthEntity,
lastPresentLoggedDate?: Date,
): Promise<UpdateResult> {
const queryBuilder = this._userAuthRepository.createQueryBuilder(
'userAuth',
);
return queryBuilder
.update()
.set({ lastSuccessfulLoggedDate: lastPresentLoggedDate ?? new Date() })
.where('id = :id', { id: userAuth.id })
.execute();
즉, 컨트롤러에서 호출이 필요한 거라면 이런 식으로 하고
public async createUserAuth(createdUser): Promise<UserAuthEntity[]>
서비스 레이어 안에서만 쓸 거라면 이런 식으로 함.
private async _updateLastSuccessfulLoggedDate()
배신.. typeORM에서 트랜잭션 매니저로 다 컨트롤 해야 하는 줄 알았는데, 데코레이터가 있었다.. 그래도 데코레이터만 썼으면 그냥 이해 못했을텐데, 매니저로 트랜잭션을 제어할 수 있다는 걸 아는 것과 모르는 건 차이가 있겠지.
https://www.npmjs.com/package/typeorm-transactional-cls-hooked
@Transactional()
public async updateLastLoggedDate(
user: UserEntity,
isSuccessiveLogged: boolean,
): Promise<UserEntity> {
const {
userAuth,
userConfig,
userAuth: { lastSuccessfulLoggedDate },
userConfig: { lastPresentLoggedDate },
} = user;
if (!isSuccessiveLogged) {
await this._updateLastFailedLoggedDate(userAuth);
} else if (isSuccessiveLogged && !lastSuccessfulLoggedDate) {
await Promise.all([
this._updateLastSuccessfulLoggedDate(userAuth),
this._userConfigService.updateLastPresentLoggedDate(userConfig),
]);
} else {
await Promise.all([
this._updateLastSuccessfulLoggedDate(userAuth, lastPresentLoggedDate),
this._userConfigService.updateLastPresentLoggedDate(userConfig),
]);
}
return this._userService.getUser({ uuid: user.uuid });
}
@Transactional()데코레이터를 통해 이러한 설정을 통해 메서드 내의 모든 데이터베이스 작업은 하나의 트랜잭션 단위로 관리 됨
그럼 트랜잭션 레벨 어떻게 설정하지?
typeorm-transactional-cls-hooked 문서에 있음.


이런 식으로 옵션 설정 가능
@Transactional({ isolationLevel: 'REPEATABLE_READ' })
근데 설정안하면 기본 DBMS 값이 사용됨. 거의 수동 설정하지 않을 것 같지만, 정합성 중요도 여부에 따라서 결정해야 할 듯.
await Promise.all([
this._updateLastSuccessfulLoggedDate(userAuth),
this._userConfigService.updateLastPresentLoggedDate(userConfig),
]);
자, 노드는 싱글 스레드인데 promise가 병렬로 실행될 수 있나?
ㅇㅇ
왜냐면 우리에겐 libuv가 있기 때문이지.
I/O 작업은 노드 메인 싱글 스레드가 담당하는 게 아님.
I/O가 필요한 시점에 노드의 스레드는 이 작업을 libuv에 위임시키고,
자기는 다른 작업(콜 스택 작업)을 실행함.
libuv를 이걸 열심히 작업해서 이게 끝나면, 큐에다가 돌려주고,
콜 스택이 비면 이벤트 루프가 큐에 있는 걸 콜 스택에 넣어서 그때 비로소 완료되는 것!
근데 promise가 병렬로 처리되다가, 하나가 reject되면 libuv에서 도는 다른 스레드 작업은? -> 결론: 다른 작업이 중지되는 건 아님.
reject되면 Promise.all([])이 오류를 반환하고 해당 프로미스 체인이 reject 상태가 됨. 그래서 그 동안 libuv 스레드 작업은 진행되고, 성공하면 이벤트 큐에 쌓임. 근데 이미 체인 내에서 reject된게 있으니까 결과는 무시되거나 별도 로직으로 처리 가능.