bank-server 코드 읽기 (1)

ClassBinu·2024년 7월 11일

F-lab

목록 보기
59/65

좋은 코드를 보는 경험이 필요하다는 멘토님 피드백에 따라 선정한 교보재 코드

https://github.com/pietrzakadrian/bank-server

결론: 이 코드는 뜯고 씹고 즐기고 맛볼 가치가 있었다.

1. index.ts

모듈안에 공통적으로 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에서 계층적으로 라우터 처리할 때 이런 느낌으로 한 것 같음.

2. 파일명

띄어쓰기가 필요한 부분은 -로 표현함.

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

3. 폴더 구조

기능별 모듈 안에 guard, interfaces 등이 각각 있는 게 아니라 nest의 life cycle을 기준으로 폴더가 나눠져 있음.

  • common
  • decorators
  • exceptions
  • filters
  • guards
  • interceptors
  • interfaces
  • middlewares
  • migrations
  • modules
  • providers
  • utils
    main.ts

나는 기존에 module이 모든 구조의 시작점이었는데, 여기서는 module도 하나의 기능 처럼 취급. 모듈의 기능은 컨트롤러와 서비스에 집중된 느낌이고 이 컨트롤러와 서비스를 운영하기 위한 의존성은 해당 모듈 안에서 구현

4. DTO

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를 클래스로 만들고, 생성자 주입으로 인스턴스를 생성하고 그걸 반환하면 훨씬 안정적인 코드가 된다!!

5. _authService

숨겨진 서비스는 뭐지?

  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 쓰기!

6. 추상 클래스

  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);
  }
}

이건 좀 나중에 자세하게 알아보자.

7. 로그아웃이 왜 patch?

  @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();
  }

최근 로그아웃 시간을 기록하고 있다.
(클라이언트에서 토큰 같은 거 파기하는 식으로 처리하겠지?)

8. 쿼리 빌더

서비스레이어에서 typeORM repository를 쓰는 게 아니라 servcie에서 쿼리빌더를 호출하고 있다.

    return queryBuilder
      .update()
      .set({ lastFailedLoggedDate: new Date() })
      .where('id = :id', { id: userAuth.id })
      .execute();
  }

오호, 이제 보인다. 이거 빌더 패턴으로 배운 거 같은데 그래서 이름도 쿼리 '빌더'였다. ORM에서는 쿼리를 객체와 매핑시키는건데, 이 객체를 한번에 쫘좌좌작 생성하는 게 아니라, 빌더로 순차적으로 차곡차곡 . 메서드로 단계적으로 쌓아가는 것(빌딩, 체이닝)
만약 이걸 빌더 패턴으로 안 하면 객체 생성 시 매개변수가 엄청 길어져서 가독성 떨어지고, 또 순서나 개수 떄문에 오류 발생 가능성이 높아질 것 같음!

9. 내부에서의 메서드

      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()

10. 트랜잭션

배신.. 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 값이 사용됨. 거의 수동 설정하지 않을 것 같지만, 정합성 중요도 여부에 따라서 결정해야 할 듯.

11. Promise.all([])

await Promise.all([
  this._updateLastSuccessfulLoggedDate(userAuth),
  this._userConfigService.updateLastPresentLoggedDate(userConfig),
      ]);
  1. 프로미스를 병렬로 실행
  2. 모두 성공하면 결과를 배열로 반환
  3. 하나라도 실패하면 에러 반환하고, 전체가 실패하 것으로 간주(reject)

자, 노드는 싱글 스레드인데 promise가 병렬로 실행될 수 있나?
ㅇㅇ
왜냐면 우리에겐 libuv가 있기 때문이지.
I/O 작업은 노드 메인 싱글 스레드가 담당하는 게 아님.
I/O가 필요한 시점에 노드의 스레드는 이 작업을 libuv에 위임시키고,
자기는 다른 작업(콜 스택 작업)을 실행함.
libuv를 이걸 열심히 작업해서 이게 끝나면, 큐에다가 돌려주고,
콜 스택이 비면 이벤트 루프가 큐에 있는 걸 콜 스택에 넣어서 그때 비로소 완료되는 것!

근데 promise가 병렬로 처리되다가, 하나가 reject되면 libuv에서 도는 다른 스레드 작업은? -> 결론: 다른 작업이 중지되는 건 아님.

reject되면 Promise.all([])이 오류를 반환하고 해당 프로미스 체인이 reject 상태가 됨. 그래서 그 동안 libuv 스레드 작업은 진행되고, 성공하면 이벤트 큐에 쌓임. 근데 이미 체인 내에서 reject된게 있으니까 결과는 무시되거나 별도 로직으로 처리 가능.

0개의 댓글