LIKET - 리팩토링과 TDD

민경찬·2024년 2월 17일
2

백엔드

목록 보기
14/19
post-thumbnail

라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.

안녕하세요. 라이켓 프로젝트의 백엔드 개발을 담당하고 있는 민경찬입니다. 이번 글에서는 라이켓 개발 과정에서 TDD를 도입하는 과정에 대해서 소개해보려 합니다.


배경

라이켓 개발이 80%정도 끝났을 당시 기획적으로 조금의 수정 사항을 받게 되었습니다. 1-2개 정도의 도메인 모델에서 1-2개 정도의 서비스 메서드가 수정되거나 삭제되어야 했죠.

그러나 이 과정에서 다음과 같은 심리적인 문제를 가진다는 것을 깨달았습니다.

  1. 기존에 작성한 메서드 변경에 대한 두려움
  2. 배포에 대한 두려움

이 문제를 무시한다면 레거시 코드는 점점 쌓일 것입니다. 제품 업데이트에 대한 개발자의 태도 또한 수동적으로 바뀔테지요. 이 문제는 반드시 잡고 넘어가야 한다고 생각했습니다.

리팩토링으로써 말이죠.

❓ 문제의 근본은?

리팩토링을 한다는 것은 현 상황의 문제점을 해결하고 그것을 코드에 담아낸다는 것입니다.

그러기 위해서는 우선 문제점에 대한 근본적인 배경을 이해해야합니다.

🛑 메서드 변경이 왜 두려울까?

메서드 변경이 두려운 이유를 다음 두 가지 이유 때문이라고 생각했습니다.

  1. 계층끼리 데이터를 전달할 때 가공하는 주체가 없다.
  2. 서비스 메서드의 변경은 테스트 코드의 변경으로 이어진다.

데이터를 컨트롤러에서 사용하는 데이터로 변경하는 과정을 담은 코드입니다.

const liketList = await pipe(
  this.prismaService.liket.findMany({
    // Something...
  }),
  map((liket) => ({
    idx: liket.idx,
    // Something...
  })),
  toArray,
)

데이터를 가공한다는 것은 서비스 코드를 비대해지도록 만든 가장 큰 원인이 되었습니다. 비대해진 서비스 코드는 점점 더 리팩토링 하기 어려워지게 되었죠.

특히나 서비스에서 사용하는 객체의 타입 정의를 변경하는 일이 있다면 정말 어려워집니다.

IUser[]를 리턴하는 getUserAll 메서드가 있다고 가정해봅시다.

public getUserAll: () => Promise<IUser[]>

만약 IUser 프로퍼티에 age 프로퍼티를 추가하고 싶다면 어떻게 해야할까요?

export interface IUser {
	idx: number;
  	...
    age: number // 추가
}

위 코드처럼 추가할 수 있습니다.

그러나 Iuser를 반환하는 정말 많은 메서드에서 타입 에러를 띄우게 될 것입니다.

그러니 당연하게도 그 어떤 서비스 메서드도 건드리고 싶지 않게 됩니다.

당연하게도 테스트코드 또한 변경되는 것이죠

🛑 배포는 왜 두려운걸까?

코드의 양이 적다면 작동의 확신을 가지는 것이 크게 어렵지 않을 것입니다. 코드를 작성하기까지 개인이 생각헀던 다양한 근거와 세밀한 디테일이 아직 기억에 남아있기 때문이죠.

그러나 개발에 시간을 점점 투자하고 코드가 많아질 수록 이전에 작성했던 코드의 세밀한 부분까지 기억한다는 것은 쉽지 않습니다.

❓ 어떻게 리팩토링할까?

문제의 근본을 파악했다면 이제 리팩토링의 방향성을 정해야합니다. 비대해진 서비스 코드를 작성하고 신뢰성있는 테스트코드를 작성할 수 있도록 말이죠.

이를 위해서 다음 세 가지 스탭을 통해 코드를 리팩토링하기로 했습니다.

  1. 서비스에서 데이터를 가공하는 주체 만들기
  2. 필요한 서비스 메서드의 타입만을 정의하고 테스트 코드 만들기
  3. 만들어진 테스트 코드를 바탕으로 서비스 메서드 작성하기

가벼운 수준의 TDD를 도입하기로 한 것입니다.


TDD

TDD는 테스트를 기반으로 개발을 한다는 개발 방법입니다. 그러나 테스트 코드를 작성한다는 것이 핵심이 아닙니다.

코드의 설계를 테스트 코드에 담아낸 다는 것이 핵심입니다.

즉, TDD를 하기위해서는 테스트 코드를 작성하는 것이 우선이 되는 것이 아닌, 설계가 우선이 되어야합니다.

타입에 대한 컴파일 에러를 우선적으로 해결한 후 테스트 코드를 작성하려고합니다.

🔥 설계하기

설계 작업은 크게 두 가지로 구분해볼 수 있습니다.

  1. 가공 주체 분리하기
  2. 서비스 메서드 타입 설계하기

서비스 메서드는 무언가를 받고 작업을 하며 무언가를 뱉어주는 역할을 합니다. 그러기 위해서는 다음 두 가지가 필요했습니다.

  1. Request DTO
  2. Response BO

요청 DTO를 통해 작업에 대한 요청 정보를 받아 서비스에서만 사용하는 또 다른 객체로 변경해서 응답을 해줘야한다는 것이죠.

저는 Response BO를 Entity라고 하기로 했습니다.

Prisma는 Model이라는 명칭을 사용합니다. 이를 피하기 위해 Entity를 사용합니다.

기존 코드에서 서비스 레이어에서만 사용하는 객체를 class가 아닌 interface로 정의했습니다.

export interface IUser{
  idx: number;
  name: string;
  ...
}
public getUserAll: () => Promise<IUser[]>;

그러나 이는 추가 가공에 대한 주체를 관리하기 어렵다고 판단하였고 이를 class로 구현하기로 하였습니다.

export class UserEntity {
  idx: number;
  name: string;
  ...
  
  constructor(user: {
    idx: number;
    name: string;
    ...
  }) {
    this.idx = user.idx;
    this.name = user.name;
    ...
  }
    
  static createUserEntity(user: User): UserEntity {
  	return new UserEntity({
      idx: user.idx,
      name: user.name,
      ...
    });
  }
}

static 메서드에 보이는 User 타입은 Prisma에서 가져온 모델입니다. 이를 서비스 메서드에서 사용한다면 다음과 같이 작성할 수 있습니다.

public getUserByIdx(idx: number): Promise<UserEntity> {
  const user = await prisma.user.findByUnique(...);
  
  return UserEntity.createUserEntity(user);
}

이제 비대해진 서비스 코드를 해결할 수 있게 되었습니다. 가공에 대한 책임도 해당 엔티티로 넘길 수 있게되었죠.

이제 메서드를 설계해보겠습니다.

public signUp: (signUpDto: SignUpDto) => Promise<string>;

public getUserAll: (
  pagenation: UserListPagenationDto,
) => Promise<{ user: UserEntity<'my', 'admin'>[]; count: number }>;

...

이제 서비스 메서드는 이런식으로 타입 정의를 해둘 수 있습니다. 타입을 정의했다면 테스트 코드를 작성할 수 있습니다.

🔥 테스트 코드 작성하기

위에서 언급했듯이 테스트 코드를 작성한다는 것이 핵심이 아닙니다. 테스트 코드는 설계를 보여주는 청사진이 되어야하는 것이고 테스트 코드를 실행함으로써 메인 코드가 청사진대로 만들어졌는지를 확인하는 것입니다.

그러니 우선적으로 메서드를 설계하고 그것을 테스트 코드에 반영해보겠습니다.

signUp메서드는 다음의 프로세스를 통해 회원가입을 진행하는 메서드입니다.

  1. 이메일 인증 토큰 검사
  2. 이메일 중복 검사 확인
  3. 회원가입
  4. 로그인 토큰 발급

앞서 설계한 서비스 메서드를 통해 어떤 메서드를 주입받아 사용해야하는지 결정해야합니다.

  1. 이메일 인증 토큰 검사 -> authService.verifyEmailAuthToken
  2. 이메일 중복 검사 확인 -> prisma.user.findFirst
  3. 회원가입 -> prisma.user.create
  4. 로그인 토큰 발급 -> jwtService.sign

이를 테스트 코드로 다음 처럼 반영할 수 있습니다.

it('Sign Up success case', async () => {
  authServiceMock.verifyEmailAuthToken = jest.fn().mockReturnValue(true);
  prismaMock.user.findFirst = jest.fn().mockResolvedValue(null);
  jwtServiceMock.verify = jest.fn().mockReturnValue('this.is.token');

  const result = await service.signUp({
    emailToken: 'jwt token',
    pw: 'abc123',
    nickname: 'test',
  });

  expect(result).toBe('this.is.token');
});

아래는 이메일 미인증에 대한 테스트코드입니다.

it('Sign Up - Email Auth Fail', async () => {
  authServiceMock.verifyEmailAuthToken = jest.fn().mockReturnValue(false); // 토큰 검증 실패 시
  prismaMock.user.findFirst = jest.fn().mockResolvedValue(null);
  jwtServiceMock.verify = jest.fn().mockReturnValue('this.is.token');

  try {
    const result = await service.signUp({
      emailToken: 'jwt token',
      pw: 'abc123',
      nickname: 'test',
    });
  } catch(err: any) {
    expect(result).toBeInstanceOf(InvalidEmailAuthTokenException);
  }
});

자 이제 큰 그림은 끝났습니다.

위와 같이 머리속으로 그려놓은 설계를 전부 테스트코드에 반영하고 이를 토대로 메인 로직을 구현하면 됩니다


흐뭇.


결론

테스트 코드는 설계를 코드로써 반영합니다. 우리의 소프트웨어의 구조를 담고 있는 청사진으로써의 역할을 하는 것이죠. 이는 비대해진 코드의 구조를 정리하기에 도움을 줍니다.

배포에 대한 두려움도 많이 사라졌습니다.
테스트가 증명하니까요.

감사합니다.

0개의 댓글