HooneyLog

마틴파울러의 리팩터링 개정 2판과 함께 클린코드 잘짜는법 배워보자!!(1/2)

프로필 이미지

Seunghoon Shin

2022년 5월 7일 05:50
마틴파울러의 리팩터링 2판

안녕하세요!! 이번시간에는 마틴파울러의 리팩터링 개정 2판 ( 자바스크립트 편 )을 보고 예시코드를 보면서 공부한걸 기록하는 시간을 가져보려고 합니다~!

해당 게시글은 하나의 글에서 모든걸 다 담기에는 너무 길어질거같아 총 2편으로 나누어 글을 올리겠습니다!

원래 책으로 공부하는 것 보다 인터넷 영상이나 프로젝트를 진행하면서 공부하는것을 좋아하는데 처음엔 직장 동료가 추천을 해주었고 또한 유명한 많은 개발자들이 추천하는 책이라서 큰 마음 먹고 결국 구매를 하였습니다! 저는 제가 좋아하는 프론트엔드 개발자 유튜버인 드림코딩님의 추천을 받아 구매를 하였습니다! ㅎㅎㅎ

실력있는 개발자란?

이 책에서는 실력있는 개발자란 무엇인가 라는 이야기에 대해서도 나오게 됩니다. 컴퓨터가 이해하는 언어를 짜는것은 바보라도 가능하다고 말합니다. 하지만 사람이 보고 바로 이해할 수 있는 코드를 짜는 것은 그렇지 않다고 말합니다.

컴퓨터가 이해하는 코드는 바보도 작성할 수 있다. 사람이 이해하도록 작성하는 프로그래머가 진정한 실력자다.

저는 위 문구를 보고 참 많은 공감을 하였습니다. 다른 사람과 협업을 하거나 아니면 이전 프로젝트를 인수받아서 개발을 이어나가야하는 상황이 오게되면 코드 분석에 많은 시간이 할애됩니다. 하지만 많은 레거시가 있거나, 너무나 이해하기 어렵게 짜여져있는 코드때문에 골머리를 썩히게 되는 경우도 종종 있는것 같습니다. 때문에 원활한 유지보수를 위해서 클린코드가 매우 중요하다고 생각했고, 저는 이 클린코드를 짜기 위해서는 어떻게 해야할까라는 많은 고민을 했습니다. 또 저같은 주니어는 미리 이러한 습관을 들여놔야 미래가 매우 편할것이라고 생각하였는데, 마침 이 책을 알게되어 너무 기쁜 마음으로 공부를 하고 있습니다!


리팩토링하기전 코드 준비 해놓기

책에서는 자바스크립트로 나와있지만 저는 타입스크립트를 사용하여 진행해보려고 합니다!

코드는 책에 있는 코드를 사용합니다~!

그럼 바로 리팩토링 할 코드를 살펴보겠습니다

우선 타입스크립트 답게 매개변수의 타입을 만들었습니다

Plays는 mapped type 기법을 사용하여 타입을 짰습니다.


// src/ts/index.ts

export type PlayName = "hamlet" | "as_like" | "othello";
export interface Play {
  name: string;
  type: string;
}
export interface Performance {
  playId: PlayName;
  audience: number;
}

export type Invocie = {
  customer: string;
  performances: Performance[];
};

export type Plays = {
  [propName in PlayName]: Play;
};

아래의 코드는 다양한 연극을 바탕으로 공연 요청이 들어오면 장르와 관객 규모로 비용을 측정해주는 프로그램입니다.

// src/index.ts

import { Invocie, Plays } from "./ts";

const statement = (invoice: Invocie, plays: Plays) => {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `청구 내역 (고객명 : ${invoice.customer})\n`;
  const format = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format;

  for (let perf of invoice.performances) {
    const play = plays[perf.playId];
    let thisAmount = 0;

    switch (play.type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) {
          thisAmount += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) {
          thisAmount += 10000 + 500 * (perf.audience - 20);
        }
        thisAmount += 300 * perf.audience;
        break;
      default:
        throw new Error(`알수없는 장르 : ${play.type}`);
    }
    volumeCredits += Math.max(perf.audience - 30, 0);

    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);

    result += `${play.name} : ${format(thisAmount / 100)} (${
      perf.audience
    }석)\n`;
    totalAmount += thisAmount;
  }

  result += `총액 : ${format(totalAmount / 100)}\n`;
  result += `적립 포인트 : ${volumeCredits}점\n`;
  return result;
};
export default statement;

위 코드를 실행시키면 아래와 같은 결과값을 냅니다.

청구 내역 (고객명 : Seunghoon)
Hamlet : $650.00 (55)
As You Like It : $580.00 (35)
Othello : $500.00 (40)
총액 : $1,730.00
적립 포인트 : 47

위 코드를 보실때 무슨 생각이 드시나요? 코드가 이해하기 너무 힘들어서 리팩토링이 필요하다고 생각하시나요?

저거만 보면 리팩토링이 굳이 필요하지 않다고 생각할 수 도 있습니다! 왜냐하면 일단 코드의 양이 많지 않기 때문에 이해하는데는 큰 무리가 없을 것입니다!

하지만 저러한 구조가 수백 줄 수천 줄이 되어버린다면 어떨까요?? 그때는 코드 이해력이 굉장히 떨어질것입니다.

때문에 리팩토링하는 습관을 들이고 코드가 수백줄 수천줄이 되어버리기전에 미리 하는것이 좋다고 생각됩니다. 계속 안좋은 구조로 코드가 짜여지게되면 그 프로젝트는 손쓰기에도 무서운 상황이 되어버릴수도있습니다!

프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩토링하고 나서 원하는 기능을 추가한다.

결과값에 대한 테스트 코드 작성하기

이 책에서 강조하는것 중 하나가 리팩토링하기전에 테스트코드를 작성하는것이다!

테스트의 역할은 굉장히 중요한데, 사람이 작업을 하기때문에 언제든지 실수하거나 놓치는 경우가 있을 수 있기 때문이다! 하지만 테스트 코드를 작성해놓으면 이러한 사태를 막을 수 있다. 위의 statement 함수는 그럼 어떻게 테스트 코드를 구성하면 좋을까?

답은 간단합니다! 그냥 함수가 문자열을 반환하기때문에 그 반환된 문자열과 기대값이 일치하는지 체크하면 됩니다.

저는 그래서 아래와 같이 jest를 이용하여 테스트 코드를 작성하였습니다!

import statement from "./index";
import { Invocie, Plays } from "./ts";

const invoice: Invocie = {
  customer: "Seunghoon",
  performances: [
    { playId: "hamlet", audience: 55 },
    { playId: "as_like", audience: 35 },
    { playId: "othello", audience: 40 },
  ],
};

const plays: Plays = {
  hamlet: { name: "Hamlet", type: "tragedy" },
  as_like: { name: "As You Like It", type: "comedy" },
  othello: { name: "Othello", type: "tragedy" },
};

test("statment result test", () => {
  const result = `청구 내역 (고객명 : Seunghoon)
Hamlet : $650.00 (55석)
As You Like It : $580.00 (35석)
Othello : $500.00 (40석)
총액 : $1,730.00
적립 포인트 : 47점
`;
  expect(statement(invoice, plays)).toBe(result);
});

그리고 test 코드를 실행해보면 문제가 없을시 아래와 같이 출력이 될것입니다

PASS  src/index.test.ts
  ✓ statment result test (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.164 s, estimated 1 s
Ran all test suites.

Watch Usage: Press w to show more.

하지만 작업을 하다가 무언가의 데이터를 잘못 만지거나 놓친게 있다면 아래 처럼 fail에 대한 결과값이 나오게 될것입니다. 그리고 어느 부분이 잘못되었는지도 나오기때문에 빠르게 실수를 바로잡을 수 있습니다

FAIL  src/index.test.ts
  ✕ statment result test (5 ms)

  ● statment result test

    expect(received).toBe(expected) // Object.is equality

    - Expected  - 3
    + Received  + 3

      청구 내역 (고객명 : Seunghoon)
    - Hamlet : $650.00 (55)
    + Hamlet : $2,900.00 (55)
      As You Like It : $580.00 (35)
    - Othello : $500.00 (40)
    + Othello : $1,400.00 (40)
    - 총액 : $1,730.00
    + 총액 : $4,880.00
      적립 포인트 : 47
      25 | 적립 포인트 : 47      26 | `;
    > 27 |   expect(statement(invoice, plays)).toBe(result);
         |                                     ^
      28 | });
      29 |

      at Object.toBe (src/index.test.ts:27:37)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.481 s, estimated 1 s
Ran all test suites.

Watch Usage: Press w to show more.

분명히 리팩토링을 하다보면 무언가 빼먹고 실수하는 경우가 많이 발생합니다. 그래서 테스트 코드는 리팩토링을 위해서는 반드시 필요한 과정이고 리팩토링에 대한 자신감을 높여줍니다! 리팩토링에서의 테스트코드는 선택이 아닌 필수라고 생각됩니다!!

리팩토링을 본격적으로 해보자!

긴 함수를 리팩토링 할때는 전체 동작에서 각 부분을 나눌 수 있는 지점을 찾아보는것이 좋다!

switch (play.type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) {
          thisAmount += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) {
          thisAmount += 10000 + 500 * (perf.audience - 20);
        }
        thisAmount += 300 * perf.audience;
        break;
      default:
        throw new Error(`알수없는 장르 : ${play.type}`);
    }

우선 바로 눈에 보이는것이 switch 문 일것입니다.

해당 코드는 연극의 장르별로 요금을 계산해주는 함수이다. 이러한 기능 역할들은 함수로 따로 만들어 개발해주는 것이 중요한 방법이다!

그리고 네이밍도 굉장히 중요하다. 위는 연극 장르별로 amount(요금) 을 계산해주는 코드 이기때문에 amountFor 과 같은 직관적인 함수명으로 만들어주면 좋다!

코드를 잘 보면 알겠지만 필요한 매개변수가 있음을 알 수 있을것입니다! 그것은 바로 play와 perf입니다. 때문에 위의 매개변수를 받는 함수를 만들어 한번 함수 추출하기를 해보겠습니다. (함수 추출하기)

그럼 아래와 같이 amountFor 의 함수가 탄생했습니다.

import { Invocie, Play, Plays, Performance } from "./ts";

function amountFor(perf: Performance, play: Play) {
  let thisAmount = 0;
  switch (play.type) {
    case "tragedy":
      thisAmount = 40000;
      if (perf.audience > 30) {
        thisAmount += 1000 * (perf.audience - 30);
      }
      break;
    case "comedy":
      thisAmount = 30000;
      if (perf.audience > 20) {
        thisAmount += 10000 + 500 * (perf.audience - 20);
      }
      thisAmount += 300 * perf.audience;
      break;
    default:
      throw new Error(`알수없는 장르 : ${play.type}`);
  }

  return thisAmount;
};

함수반환값은 좀 더 명확하게 하기 위해서 thisAmount와 같은 이름을 사용하지 않고 result 라는 이름을 사용해주는 것이 좋습니다. 이 함수에서 최종으로 다뤄야하는 데이터는 “이것”이야 ! 라고 알려줄 수 있기때문이죠.

그러면 1차적으로는 아래와 같은 코드가 완성됩니다.

test code는 계속해서 실행시켜주셔야합니다!! 저는 jest를 watch모드로 하여 저장할때마다 실행이 되도록 하였습니다.

import { Invocie, Play, Plays, Performance } from "./ts";

const statement = (invoice: Invocie, plays: Plays) => {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `청구 내역 (고객명 : ${invoice.customer})\n`;
  const format = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format;

  for (let perf of invoice.performances) {
    const play = plays[perf.playId];
    let thisAmount = 0;

    thisAmount += amountFor(perf, play);

    volumeCredits += Math.max(perf.audience - 30, 0);

    if ("comedy" === play.type) volumeCredits += Math.floor(perf.audience / 5);

    result += `${play.name} : ${format(thisAmount / 100)} (${
      perf.audience
    }석)\n`;
    totalAmount += thisAmount;
  }

  result += `총액 : ${format(totalAmount / 100)}\n`;
  result += `적립 포인트 : ${volumeCredits}점\n`;
  return result;

  function amountFor(perf: Performance, play: Play) {
    let result = 0;
    switch (play.type) {
      case "tragedy":
        result = 40000;
        if (perf.audience > 30) {
          result += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        result = 30000;
        if (perf.audience > 20) {
          result += 10000 + 500 * (perf.audience - 20);
        }
        result += 300 * perf.audience;
        break;
      default:
        throw new Error(`알수없는 장르 : ${play.type}`);
    }

    return result;
  }
};
export default statement;

그 다음 할 것은 play라는 로컬변수를 함수로 만들어 그것을 이용해서 매개변수를 안넘기고 사용 할 것입니다.

당연한 이야기겠지만 굳이 쓸 필요가 없는 매개변수는 최소화하면서 로컬 범위에 있는 변수들을 최대한 안사용하는것이 좋다. 왜냐하면 저런 변수들이 많아지면 존재하는 이름이 많아져서 리팩토링에서 추출하는 작업이 굉장히 복잡해지기때문이다.

무슨 말인지 바로 코드로 살펴보자!

아래와 같이 playFor이라는 함수를 만들어 play 변수대신 사용할것이다.

function playFor(perf: Performance) {
    return plays[perf.playId];
  }

그러면 아래와 같이 더 간결하게 코드를 만들어 낼 수 있다

import { Invocie, Play, Plays, Performance } from "./ts";

const statement = (invoice: Invocie, plays: Plays) => {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `청구 내역 (고객명 : ${invoice.customer})\n`;

  const format = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 2,
  }).format;

  for (let perf of invoice.performances) {
    let thisAmount = 0;

    thisAmount += amountFor(perf);

    volumeCredits += Math.max(perf.audience - 30, 0);

    if ("comedy" === playFor(perf).type)
      volumeCredits += Math.floor(perf.audience / 5);

    result += `${playFor(perf).name} : ${format(thisAmount / 100)} (${
      perf.audience
    }석)\n`;
    totalAmount += thisAmount;
  }

  result += `총액 : ${format(totalAmount / 100)}\n`;
  result += `적립 포인트 : ${volumeCredits}점\n`;
  return result;

  function amountFor(perf: Performance) {
    let result = 0;
    switch (playFor(perf).type) {
      case "tragedy":
        result = 40000;
        if (perf.audience > 30) {
          result += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        result = 30000;
        if (perf.audience > 20) {
          result += 10000 + 500 * (perf.audience - 20);
        }
        result += 300 * perf.audience;
        break;
      default:
        throw new Error(`알수없는 장르 : ${playFor(perf).type}`);
    }

    return result;
  }

  function playFor(perf: Performance) {
    return plays[perf.playId];
  }
};
export default statement;

코드를 잘 보면 알겠지만 기존에 아래와 같이 play 변수로 사용하던것들을

playFor(perf) 로 대체하여 변수를 인라인하였다 (변수 인라인하기)

이렇게 함으로써 amountFor에 매개변수로 넘기던 play도 없애고 그 안에서도 변수 인라인을 사용 할 수 있기때문에 로컬변수에 대한 의존성도 줄어들어 코드를 훨씬 관리하기가 편해졌음을 알 수 있다!

 const play = plays[perf.playId];

그 다음은 적립 포인트를 계산하는 volumeCredits도 함수로 만들어 사용 할 수 있다!

function volumeCreditsFor(perf: Performance) {
    let result = 0;
    result += Math.max(perf.audience - 30, 0);

    if ("comedy" === playFor(perf).type)
      result += Math.floor(perf.audience / 5);
    return result;
  }

for (let perf of invoice.performances) {
    let thisAmount = 0;

    thisAmount += amountFor(perf);
    volumeCredits += volumeCreditsFor(perf);

    result += `${playFor(perf).name} : ${format(thisAmount / 100)} (${
      perf.audience
    }석)\n`;
    totalAmount += thisAmount;
  }

이렇게 함으로써 연극별 요금을 계산하는 함수에 이어 적립포인트도 계산하는 함수를 만들어 반복문안의 코드가 훨씬 간결해졌고 네이밍만 봐도 무슨 역할을 하는지 직관적으로 보이기때문에 사람이 훨씬 이해하기 쉬운 코드가 만들어졌다!

그리고 앞서 play 변수에 말했던것 처럼 변수는 나중에 문제를 일으킬수가 있다!

때문에 format 변수도 함수로 만들어 함수 호출로 대체가 가능하다

US 달러 형태로 포맷팅을 하기때문에 함수명은 usdFormat으로 지었고 매개변수를 받아 어떤 형태로 포맷을 할것인지 만들어 함수로 재구성을 하였다.

function usdFormat(currency: number) {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: 2,
    }).format(currency / 100);
  }

그리고 아래처럼 기존 format으로 되어있던것을 함수 호출로 대체를 하였다!

result += `${playFor(perf).name} : ${usdFormat(thisAmount)} (${
      perf.audience
    }석)\n`;

result += `총액 : ${usdFormat(totalAmount)}\n`;

그 다음은 어떤 변수를 빼서 대체가 가능할까??

totalAmount와 volumeCredits 가 보인다

for (let perf of invoice.performances) {
    let thisAmount = 0;

    thisAmount += amountFor(perf);
    volumeCredits += volumeCreditsFor(perf);

    result += `${playFor(perf).name} : ${usdFormat(thisAmount)} (${
      perf.audience
    }석)\n`;
    totalAmount += thisAmount;
  }

이렇게 반복문안에서 계속 더해서 저 변수를 결과값에 넣어주는 식으로 되어있다. 하지만 totalAmount와 totalVolumeCredit 이라는 함수를 만들어 그것으로 변수를 대체하여 사용한다면 더 가독성이 좋은 코드가 될것이다.

import { Invocie, Play, Plays, Performance } from "./ts";

const statement = (invoice: Invocie, plays: Plays) => {
  let result = `청구 내역 (고객명 : ${invoice.customer})\n`;

  for (let perf of invoice.performances) {
    result += `${playFor(perf).name} : ${usdFormat(amountFor(perf))} (${
      perf.audience
    }석)\n`;
  }

  result += `총액 : ${usdFormat(totalAmount())}\n`;
  result += `적립 포인트 : ${totalVolumeCredit()}점\n`;
  return result;

  // 총 적립포인트
  function totalVolumeCredit() {
    let result = 0;
    for (let perf of invoice.performances) {
      result += volumeCreditsFor(perf);
    }

    return result;
  }

  // 총 금액
  function totalAmount() {
    let result = 0;
    for (let perf of invoice.performances) {
      result += amountFor(perf);
    }

    return result;
  }

  // 연극당 적립포인트 계산
  function volumeCreditsFor(perf: Performance) {
    let result = 0;
    result += Math.max(perf.audience - 30, 0);

    if ("comedy" === playFor(perf).type)
      result += Math.floor(perf.audience / 5);
    return result;
  }

  // 연극당 요금 계산
  function amountFor(perf: Performance) {
    let result = 0;
    switch (playFor(perf).type) {
      case "tragedy":
        result = 40000;
        if (perf.audience > 30) {
          result += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        result = 30000;
        if (perf.audience > 20) {
          result += 10000 + 500 * (perf.audience - 20);
        }
        result += 300 * perf.audience;
        break;
      default:
        throw new Error(`알수없는 장르 : ${playFor(perf).type}`);
    }

    return result;
  }

  // US 달러 형식으로 포맷팅
  function usdFormat(currency: number) {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: 2,
    }).format(currency / 100);
  }

  // 연극별로 value값 뽑아내기
  function playFor(perf: Performance) {
    return plays[perf.playId];
  }
};
export default statement;

그러면 위와 같은 코드가 완성이 된다. 확실히 처음보다 훨씬 가독성이 좋은 코드로 만들어졌다. result를 제외한 변수가 다 사라졌고 기능별로 함수로 만들어 이해하기가 너무 쉬어졌다.

위 처럼 리팩토링 실습을 타입스크립트와 테스트코드를 함께 사용하니깐 굉장히 자신감있게 리팩토링이 가능던거같다. 잘못된 형식으로 접근하면 타입스크립트가 바로 잡아주고 잘못된 결과값을 내면 테스트코드가 잡아주니까 확실하게 나의 실수를 바로 잡아주는 느낌을 들었다..

이래서 타입스크립트가 계속해서 뜨고 있고 테스트코드가 중요하다고 말하는거구나 하고 다시 한번 새삼 느끼고있다..

추가적인 리팩토링은 다음 게시글에서 마저 작성하여 뵙도록 하겠습니다 :)

감사합니다!!