(번역) 타입스크립트를 활용한 5가지 리팩터링 팁

February 01, 2022

  • TypeScript를 이용한 리팩터링 아이디어를 얻고 싶어서 아래 글을 읽어보다가 번역했다.

    Top 5 techniques in TypeScript to bring your code to the next level.

  • 글을 읽으면서 JS에도 적용 가능한 내용이라 생각했고 댓글에도 나처럼 생각한 사람들이 있다.
  • 예시들이 전부 TypeScript 문법을 쓰고 있기 때문에 TypeScript를 활용했다고 하는 것이며, 문법을 빼거나 대체할 수 있다면 JavaScript 프로젝트에도 적용할 수 있는 패턴들이다.

우리 모두는 더 좋은 코드 퀄리티를 제공하기 위해 시간과 열정을 들이지만, 언제나 개선의 여지가 존재한다. 내가 반년 전 작성한 코드를 들여보고 있노라면, 개선시킬 방법이 생각나지 않는다. 두가지 이유가 있다고 생각한다. 내가 성장하지 못했거나, 이미 잘 짜여 있거나. 당신이 나와 비슷한 상황이거나 코드 품질을 중시한다면 공감할 것이다. 그동안 당신이 몰랐던, 주기적으로 사용할 수 있는 몇가지 새로운 테크닉에 대해 탐구해보자.

1. ‘includes’와 selector function을 활용한 validation 개선

문제

하나의 property를 같은 Type을 가진 여러개의 값들과 비교하고, 그 중 하나라도 일치하면 true를 return하는 함수를 만든다고 하자.

enum UserStatus {
  Administrator = 1,
  Author = 2,
  Contributor = 3,
  Editor = 4,
  Subscriber = 5,
}

interface User {
  firstName: string;
  lastName: string;
  status: UserStatus;
}

function isEditActionAvailable(user: User): boolean {
  return (
    user.status === UserStatus.Administrator ||
    user.status === UserStatus.Author ||
    user.status === UserStatus.Editor
  );

개선 방법

배열 메서드 중 includes를 사용해보자. 아래처럼 접근하면 조건문이 더욱 깔끔해진다.

const EDIT_ROLES = [
  UserStatus.Administrator,
  UserStatus.Author,
  UserStatus.Editor,
];

function isEditActionAvailable(user: User): boolean {
  return EDIT_ROLES.includes(user.status);
}

그러나, 여기에도 개선점이 있다.

첫 째로, 함수 내에 하드코딩된 데이터를 포함해야 한다. 아니면 다른 방식으로 상수값을 넣어줘야한다. (EDIT_ROLES)

둘 째는, 위에서 정의한 수정 외에 다른 권한(role)을 추가로 체크하는 함수를 또 만들고 싶다면?

selector 함수 및 role list 데이터를 받는 factory 함수를 만들어보자.

이 함수는 또 다른 함수를 반환한다. 반환하는 함수는 user 데이터를 받아서 이 user의 status(property)와 상위에서 인자로 받은 role list를 비교한다.

function roleCheck<D, T>(
  selector: (data: D) => T,
  roles: T[]
): (value: D) => boolean {
  return (value: D) => roles.includes(selector(value));
}

const isEditActionAvailable = roleCheck(
  (user: User) => user.status,
  EDIT_ROLES
);

위 방식으로, 함수 내 데이터들을 쪼개 기능적 측면에서 개선했고, 재사용이 가능해졌다.

얼마나 쉽게 다른 role을 체크하는 함수를 추가할 수 있게 되었는지 확인해보자.

const ADD_ROLES = [
  UserStatus.Administrator,
  UserStatus.Author
];

const isAddActionAvailable = roleCheck(
  (user: User) => user.status,
  ADD_ROLES
);

잠깐 생각해보자. 여기서 우리는 selector 함수의 필요성에 대해서 의문을 느끼고 있을 것이다.

selector 함수의 도움으로 다른 필드를 선택하는 것이 가능하다.

User가 리드, 매니저 등 직책(team status)을 가지고 있고, 이를 체크해야한다고 가정하자. 이런 요구사항을 반영하는 가드함수를 더 추가할 필요가 없다. 만들어둔 roleCheck 함수를 재활용하면 되기 때문이다.

enum TeamStatus {
    Lead = 1,
    Manager = 2,
    Developer = 3
}

const MANAGER_OR_LEAD = [
    TeamStatus.Lead,
    TeamStatus.Manager
];

const isManagerOrLead = roleCheck(
  (user: User) => user.teamStatus,
  MANAGER_OR_LEAD
);

2. 캡슐화한 분기 코드에 콜백 사용하기

문제

작은 차이 외엔 거의 유사한 함수들을 많이 만들어 봤을 것이다. 중복되는 코드들을 제거해보자.

async function createUser(user: User): Promise<void> {
  LoadingService.startLoading();
  await userHttpClient.createUser(user);
  LoadingService.stopLoading();
  UserGrid.reloadData();
}

async function updateUser(user: User): Promise<void> {
  LoadingService.startLoading();
  await userHttpClient.updateUser(user); // 이부분만 다르다.
  LoadingService.stopLoading();
  UserGrid.reloadData();
}

해결 방법

달라지는 부분의 코드를 추출해서 콜백으로 넘겨주는 방식으로 중복을 제거할 수 있다.

async function makeUserAction(fn: Function): Promise<void> {
  LoadingService.startLoading();
  await fn();
  LoadingService.stopLoading();
  UserGrid.reloadData();
}

async function createUser2(user: User): Promise<void> {
  makeUserAction(() => userHttpClient.createUser(user));
}

async function updateUser2(user: User): Promise<void> {
  makeUserAction(() => userHttpClient.updateUser(user));
}

3. 조건 술부 컴비네이터

문제

아래의 함수들은 확인해야할 조건이 많고, 책임질 게 많다.

enum UserRole {
  Administrator = 1,
  Editor = 2,
  Subscriber = 3,
  Writer = 4,
}

interface User {
  username: string;
  age: number;
  role: UserRole;
}

const users = [
  { username: "John", age: 25, role: UserRole.Administrator },
  { username: "Jane", age: 7, role: UserRole.Subscriber },
  { username: "Liza", age: 18, role: UserRole.Writer },
  { username: "Jim", age: 16, role: UserRole.Editor },
  { username: "Bill", age: 32, role: UserRole.Editor },
];

const greaterThan17AndWriterOrEditor = users.filter(
  (user: User2) =>
    user.age > 17 &&
    (user.role === UserRole.Writer || user.role === UserRole.Editor)
);

const greaterThan5AndSubscriberOrWriter = users.filter(
  (user: User2) => user.age > 5 && user.role === UserRole.Writer
);

해결 방법

조건 술부들을 결합하는 컴비네이터를 만들어서 코드 가독성과 재사용성을 제고해보자.

type PredicateFn = (value: any, index?: number) => boolean;
type ProjectionFn = (value: any, index?: number) => any;

function or(...predicates: PredicateFn[]): PredicateFn {
  return (value) => predicates.some((predicate) => predicate(value));
}

function and(...predicates: PredicateFn[]): PredicateFn {
  return (value) => predicates.every((predicate) => predicate(value));
}

function not(...predicates: PredicateFn[]): PredicateFn {
  return (value) => predicates.every((predicate) => !predicate(value));
}

컴비네이터를 적용해보자.

const isWriter = (user: User) => user.role === UserRole.Writer;
const isEditor = (user: User) => user.role === UserRole.Editor;
const isGreaterThan17 = (user: User) => user.age > 17;
const isGreaterThan5 = (user: User) => user.age > 5;

const greaterThan17AndWriterOrEditor = users.filter(
  and(isGreaterThan17, or(isWriter, isEditor))
);

const greaterThan5AndSubscriberOrWriter = users.filter(
  and(isGreaterThan5, isWriter)
);

4. 컴비네이터를 factory로 개선하기

문제

술부 컴비네이터가 너무 많은 변수들을 생성하게 되면 함수들 사이에서 길을 잃게된다. 단 한 번만 컴비네이터 함수를 사용한다면 더욱 범용적일 것이다.

해결 방법

컴비네이터 factory를 만들어볼 것이다. 몇 부분만 추가해보자.

const isRole = (role: UserRole) => 
  (user: User) => user.role === role;

const isGreaterThan = (age: number) =>
  (user: User) => user.age > age;

const greaterThan17AndWriterOrEditor = users.filter(
  and(isGreaterThan(17), or(isRole(UserRole.writer), isRole(UserRole.Editor)))
);

const greaterThan5AndSubscriberOrWriter = users.filter(
  and(isGreaterThan(5), isRole(UserRole.Writer)
);

반복도 줄이고, 코드를 깔끔하게 유지할 수 있다.

5. class 캡슐화 알고리즘

문제

아래의 클래스는 너무 많은 책임을 가지고 있다. 알고리즘 로직과 관련이 있어야하는데 그러지 않고 있다.

class User {
  constructor(
    public firstName: string,
    public lastName: string,
    public signUpDate: Date
  ) {}

  getFormattedUserDetails(): string {
    const formattedSignUpDate = `${this.signUpDate.getFullYear()}-${this.signUpDate.getMonth() + 1}-${this.signUpDate.getDate()}`;
    const username = `${this.firstName.charAt(0)}${this.lastName}`.toLowerCase();

    return `
        First name: ${this.firstName},
        Last name: ${this.lastName},
        Sign up date: ${formattedSignUpDate},
        Username: ${username}
    `;
  }
}

const user = new User("John", "Doe", new Date());
console.log(user.getFormattedUserDetails());

개선

User의 data model 내부에 저 method(getFormattedUserDetails)가 있으면 안된다.

따라서, 우리의 임무는 책임을 분산하는 것이다. 알고리즘을 분리해서 class내부에 캡슐화해야 한다.

interface User {
    firstName: string,
    lastName: string,
    signUpDate: Date
}

class UserDetailsFormatter {
  constructor(private user: User) {}

  format(): string {
    const { firstName, lastName } = this.user;

    return `
        First name: ${firstName},
        Last name: ${lastName},
        Sign up date: ${this.getFormattedSignUpDate()},
        Username: ${this.getUsername()}
    `;
  }

  private getUsername(): string {
    const { firstName, lastName } = this.user;

    return `${firstName.charAt(0)}${lastName}`.toLowerCase();
  }

  private getFormattedSignUpDate(): string {
    const signUpDate = this.user.signUpDate;

    return [
      signUpDate.getFullYear(),
      signUpDate.getMonth() + 1,
      signUpDate.getDate(),
    ].join("-");
  }
}

const user = { firstName: "John", lastName: "Doe", signUpDate: new Date() };
const userFormatter = new UserDetailsFormatter(user);
console.log(userFormatter.format());

번역하면서 느낀점

  • 1번 방식처럼 확장 가능성을 고려하면서 함수 설계를 하면 유지 보수에 아주 좋을 것 같다!
  • 어쩔 수 없이 복잡한 조건문을 써야한다면… 3,4번은 util화해서 사용하면 코드가 훨씬 깔끔해질 것 같다.

Written by Rita Ahn - frontend / UX

© 2023, Built with Gatsby and Leonids theme.