BookReview [Refactoring]
Refactoring(리팩터링 제 2판)
코드 구조를 체계적으로 개선하여 효율적인 리팩터링 구현하기
브릿지라는 게임개발 동아리에서 개설하게 된 북클럽 2회차 책이다.
Clean Code 다음으로 읽게 된 책으로 싸이클은 다음과 같다.
550쪽 분량, 12챕터로 구분
격주로 130쪽 정도 분량을 읽고 토론
5회 싸이클, 약 10주 소요 예정
나름대로 마일스톤도 잡고 6명이서 시작했으나.. 생각보다 많이 퍽퍽한 책이여서 그런지 3회차부터 포기자가 나오기 시작했다.
이 글을 작성하고 있는 현재 시점에서는 혼자 진행중..
처음 1~4장은 리팩터링에 대한 원칙, 예시로 시작하여 코드에서 피해야 할 악취 그리고 테스트 코드의 필요성으로 구성되어 있다.
이 부분이 읽는 부분 중 가장 재밌던 부분이였으며 사람들과 의견 교류도 활발했다.
5장 이후로는 실제 리팩터링 기법에 대해 설명해주는데 간단한 예시, 배경, 절차, 세부 예시등 한가지 리팩터링에 대해 깊게 알려준다.
처음에는 딱딱해서 “언제 다 읽지..”라는 생각이였으나.. 나중에는 필요에 의해 찾아봐야 함을 깨달았다..
이 책을 5회독 한다고 해서 리팩터링을 잘하냐? 그런 문제가 아닌 다시 한번 이책을 펼쳐볼 용기가 있느냐에 문제인 것 같다.
자신이 리팩터링을 결심했다면 이 책을 꼭 읽어보길 추천한다.
Refactoring(preface)
리팩터링은 스몰토크 커뮤니티에서 처음 등장하여 차츰 프레임워크 개발에서 필수 요소가 되었다.
프레임워크 개발자들은 클래스의 상속 구조를 개선하거나 코드의 중복을 없애는 과정에서도 리팩터링은 빠지지 않는다.
이는 자신의 프레임워크가 처음부터 완벽하지 않다는 것을 잘 알고 경험을 쌓아가면서 개선되리라 확신하기 때문이다.
또한 프로그래머는 코드를 짜는 시간보다 수정하고, 읽는 시간이 더 많다는 것을 알기에 코드 자체를 이해하고 수정하기 쉽게 짜는 것이 매우 중요한데 그 열쇠가 바로 리팩터링이다.
리팩터링에도 위험이 따른다.
리팩터링이란 동작하는 코드를 수정하는 것이고, 그 과정에서 미묘한 버그가 생길 수 있다.
이를 위해 반드시 테스트 코드를 작성해야 한다.
읽으면서 되게 뻔한 내용이 있을 수 있지만 방심하지 말아야 한다.
각 기법의 절차를 이해해야지만 절제된 리팩터링을 할 수 있다.
책에서도 나오지만 책을 읽는 것만으론 아무런 의미가 없다.
실제로 사용해보고 몸으로 느껴야 한다.
잘 돌아가는 코드는 손대는 게 아니다 vs 리팩터링
물론 오래된 레거시 코드가 있을 때 시스템의 핵심적인 부분이라면 객체지향적으로 유리한 래퍼를 사용하여 건들지 않을 수 있다.
하지만 프로젝트성과 사용화되어 서비스될 소프트웨어의 본질이 조금 다르다고 생각한다.
게임잼, 6개월 R&D 프로젝트, 1년 프로젝트는 설계 자체에 대한 고민이 높은 레벨까지 가지 않는다고 생각한다.
반대로 몇십년간 서비스되는 소프트웨어 예를 들어 메이플의 경우는 자체엔진이긴 하지만 분명하게 리팩터링을 몇번 씩 진행했을 것이라 생각한다.
역 피라미드의 형태로 제작된 프로젝트는 잘 쌓는다고 해결되는 문제가 아니다.
기반이 탄탄하지 못하기 때문에 차라리 다시 만드는게 현명하다..
실제로 나도 그런 프로젝트에 참여해본 경험이 있고, 코드 자체가 너무 복잡해서 불가능에 가까웠다.
선택과 집중이 필요한 시기엔 결단이 필요한 법..
이런 고질적인 문제를 해결하기 위해선 리팩터링이 필요하다.
리팩터링이란?
리팩터링이란 한마디로 API는 변경하지 않되 세부 사항을 변경하는 것이다.
즉, 겉으로 드러나는 코드는 바꾸지 않으면서 내부 구조를 개선하는 것
역설적으로 리팩터링이 가능하려면 코드가 제대로 동작해야 한다는 것을 의미한다.
이런 생각때문인지 일단 동작하게 하려고 구조를 전혀 생각하지 않고 코딩하는 경우가 종종 있는데 나중에 몇배로 고생하는..
또한 클린 코드에서 말한 테스트 코드의 중요성이 여기서 또 등장하는데 코드가 제대로 동작하는지 체크할 수 있는 테스트 코드가 있다면 리팩터링에 대한 위험성을 많이 줄일 수 있다.
공학에 가깝던 코딩이 점점 해킹에 가까워진다.
읽기전에
프로그래머의 필독서로 평가 받는 리팩터링..
1장은 작은 프로그램을 직접 리팩터링하는 과정을 보여준다.
이 과정을 반드시 제대로 이해하고 넘어가야 한다.
2장은 일반 원칙, 정의, 당위성에 대해 설명한다.
그리고 리팩터링과정의 문제점을 몇 가지 소개한다.
3장은 코드 악취에 대해 말하고, 4장은 테스트를 작성하는 방법을 수록했다.
이어서 리팩터링 카탈로그가 이 책의 핵심으로 대부분의 개발자가 익혀야 할 필수 리팩터링을 담고있다.
책에 등장하는 코드는 자바 스크립트로 이뤄져 있지만 다른 언어로도 충분히 읽을 수 있다.
- 리팩터링이 뭔지 모른다면 1장을 읽자
- 리팩터링을 해야하는 이유를 모른다면 1장과 2장을 읽자
- 리팩터링해야 할 곳을 찾고 싶을 때는 3장을 읽자
- 리팩터링을 실습하고 싶다면 1장부터 4장까지 꼼꼼히 읽고 나머지는 훑어보자
1. 리팩터링: 첫 번째 예시
원칙은 지나치게 일반화되기 쉬워서 실제 적용 방법을 파악하기 어렵지만 예시가 있으면 모든 게 명확해진다.
1장은 앞 서 말한대로 리팩터링의 예시를 보여주는 장이다.
1.1 자, 시작해보자
- 다양한 연극을 외주로 받아서 공연하는 극단이 있다.
- 공연 요청이 들어오면 연극의 장르와 관객 규모를 기초로 비용을 책정한다.
- 현재 이 극단은 두 가지 장르, 비극과 희극만 공연한다.
- 공연료와 별개로 포인트를 지급해서 다음번 의뢰 시 공연료를 할인받을 수도 있다.(충성도 시스템)
극단은 공연할 연극 정보를 다음과 같이 간단한 JSON 파일에 저장한다.
// plays.json
{
"hamlet": {"name": "Hamlet", "type": "tragedy"},
"as-like": {"name": "As You Like It", "type": "comedy"},
"othello": {"name": "Othello", "type": "tragedy"}
}
공연료 청구성에 들어갈 데이터도 다음과 같이 JSON 파일에 저장한다.
// invoices.json
[
{
"customer": "BigCo",
"performances": [
{
"playID": "hamlet",
"audience": 55
},
{
"playID": "as-like",
"audience": 35
},
{
"playID": "othello",
"audience": 40
}
]
}
]
공연료 청구서를 출력하는 코드는 다음과 같다.
// function statement
function statement(invoice, 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);
// 희극 관객 5명마다 추가 포인트를 제공한다.
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;
}
자바 스크립트 코드도 어렵지 않게 이해가 되지만 좀 더 가독성 좋은 코드를 위해 구조를 수정하지 않고 C#
으로 바꿔서 작성하였다.
public class Play
{
public string name;
public string type;
public Play(string name, string type)
{
this.name = name;
this.type = type;
}
}
public class Performance
{
public string playID;
public int audience;
public Performance(string playID, int audience)
{
this.playID = playID;
this.audience = audience;
}
}
public class Invoice
{
public string customer;
public List<Performance> performances;
public Invoice(string customer, List<Performance> performances)
{
this.customer = customer;
this.performances = performances;
}
}
public class Account
{
public String Statment(Invoice invoice, List<Play> plays)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
var play = plays.FirstOrDefault(p => p.name == perf.playID);
int 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 Exception($"알 수 없는 장르:{play.type}");
}
// 포인트를 적립한다.
volumeCredits += Math.Max(perf.audience - 30, 0);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == play.type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{play.name}: ${thisAmount / 100} ({perf.audience}석)\n";
totalAmount += thisAmount;
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
}
public class Program
{
public static void Main()
{
List<Play> plays = new List<Play>();
plays.Add(new Play("Hamlet", "tragedy"));
plays.Add(new Play("As You Like It", "comedy"));
plays.Add(new Play("Othello", "tragedy"));
List<Performance> performances = new List<Performance>();
performances.Add(new Performance("Hamlet", 55));
performances.Add(new Performance("As You Like It", 35));
performances.Add(new Performance("Othello", 40));
Invoice invoices = new Invoice("BigCo", performances);
Account account = new Account();
Console.WriteLine(account.Statment(invoices, plays));
}
}
json utill까지 설치하고 직렬화 과정까진 하지 않고 객체를 생성하여 동작만 동일하게 만들었다.
정확한 동작을 실제로 보고 싶다면 CodeReview에서 확인할 수 있다.
뒤에서 등장하는 리팩터링 과정또한 해당 코드로 진행하며 진행과정 또한 커밋으로 기록할 예정
1.2 예시 프로그램을 본 소감
저자와 마찬가지로 코드가 짧아서 그런지 어렵지 않게 이해할 수 있었고,
다만 막히는 부분은 본문에 없던 도메인, 요구사항이 수식으로 나오는 부분을 제외하고 동작 자체는 크게 어려움 없었다.
과연 앞 장에서도 다룬 코드는 동작하기만 하면 문제가 없는 것일까?
컴파일러는 코드가 깔끔하든 지저분하든 상관없이 동작하며 대부분 사람이 문제이다.
사람은 코드를 눈으로 보고 풀이해야 하므로 가독성과 미적인 기준은 상당히 중요하기 때문에..
코드가 실행안될 때, 버그가 많을 때 위안이 되는 말 중 하나로 “하나는 컴퓨터는 거짓말을 하지 않는다”
저자는 수백 줄짜리 코드를 수정할 때면 먼저 프로그램의 작동 방식을 더 쉽게 파악할 수 있도록 코드를 여러 함수와 프로그램 요소로 재구성한다.
프로그램의 구조가 빈약하다면 대체로 구조부터 바로잡은 뒤에 기능을 수정하는 편이 작업하기가 훨씬 수월하다.
프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩터링하고 나서 원하는 기능을 추가한다.
이제 코드를 수정할 부분을 살펴보자
HTML 출력 기능 추가하기
사용자의 요청에 따라 HTML 출력 기능이 필요하다고 가정하자
그렇다면 HTML태크를 삽입해야 하니 문자열을 추가하는 각각을 조건문으로 감싸게 된다면, Statement함수의 복잡도가 크게 증가한다.
이런 상황이라면 대부분 이 함수의 복사본을 만들고 복사본에서 HTML을 출력하려고 할 수 있는데
이 때 복사하는 일 자체는 큰 부담이 없지만 나중에 문제를 야기할 수 있다.
청구서 작성 로직을 변경할 때 마다 기존 함수와 HTML버전 함수 모두를 수정해야 하고, 항시 일관된지 수정해야 한다.
즉, 중복에 의한 사이드 이펙트가 발생할 확률이 매우 높다.
이는 두 번째 요구사항과도 관련이 있다.
다양한 장르의 추가
이번엔 희극과 비극뿐만 아니라 다양한 장르의 연극을 공연하는 요구사항이 들어왔다.
장르가 늘어남에 따라 예측할 수 없을 때 조건문을 추가해야 한다면 이후에는 폭탄해체 작업을 해야할 수 있다.
새로운 요구사항은 수색 대원처럼 한두 명씩이 아니라, 한 부대씩 몰려오기 마련이다.
이처럼 연극 장르와 공연료 정책이 달라질 때마다 statement()함수를 수정해야 하므로 리팩터링이 필요하다.
1.3 리팩터링의 첫 단계
리팩터링의 첫 단계는 항상 똑같다.
리팩터링할 코드 영역을 꼼꼼하게 검사해줄 테스트 코드들부터 마련해야 한다.
리팩터링에서 테스트의 역할은 굉장히 중요하다.
리팩터링 기법들이 버그 발생 여지를 최소화하도록 구성됐다고는 하나 실제 작업은 사람이 수행하기 때문에 언제든 실수할 가능성이 농후..
프로그램이 클수록 수정 과정에서 예상치 못한 문제가 발생할 가능성이 크다.
‘디지털 시대의 연약한 자여, 그대 이름은 소프트웨어’
statement() 함수의 테스트는 어떻게 준비하면 될까?
이 함수가 문자열을 반환하므로, 다양한 장르의 공연들로 구성된 공연료 청구서 몇 개를 작성하여 문자열 형태로 준비한다.
그 다음 statement()가 반환한 문자열과 준비한 정답을 비교 후 테스트 프레임워크를 이용해 모든 테스트를 단축키 하나로 실행할 수 있도록 한다.
리팩터링하기 전에 제대로 된 테스트부터 마련한다.
테스트는 반드시 자가진단하도록 만든다.
1.4 statement() 함수 쪼개기
statement처럼 긴 함수를 리팩터링할 때는 먼저 동작을 부분으로 나눌 수 있는 지점을 찾는다.
중간 즘 분기문이 가장 먼저 눈에 띈다.
public String Statment(Invoice invoice, List<Play> plays)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
var play = plays.FirstOrDefault(p => p.name == perf.playID);
int 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 Exception($"알 수 없는 장르:{play.type}");
}
// 포인트를 적립한다.
volumeCredits += Math.Max(perf.audience - 30, 0);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == play.type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{play.name}: ${thisAmount / 100} ({perf.audience}석)\n";
totalAmount += thisAmount;
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
이 switch문은 한 번의 공연에 대한 요금을 계산하고 있다.
이러한 정보는 머릿속 STM에 저장되기 때문에 잊지 않으려면 재빨리 코드에 반영해야 한다.
즉, 이 함수에서 분리 가능한 부분인 것으로 함수 추출하기
과정을 거친다.
먼저 별도로 함수로 빼냈을 때 유효범위를 벗어나는 변수, 즉 새 함수에서 곧바로 사용할 수 없는 변수가 있는지 확인한다.
이번 예에서는 perf, play, thisAmount가 해당 되며, 추출한 새 함수에서도 필요하지만 값을 변경하지 않기 때문에 매개변수로 전달하면 된다.
한편 thisAmount는 함수 안에서 값이 바뀌는데, 이런 변수는 조심해서 다뤄야 한다.
이번 예제에서는 이런 변수가 하나이므로 반환하도록 작성했다.
private int AmountFor(Performance perf, Play play)
{
int 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 Exception($"알 수 없는 장르:{play.type}");
}
return thisAmount;
}
이제 원래 함수에서는 새로 만든 함수를 호출하도록 수정한다.
public String Statment(Invoice invoice, List<Play> plays)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
var play = plays.FirstOrDefault(p => p.name == perf.playID) ?? throw new Exception($"연극이름:{perf.playID}를 찾을 수 없습니다.");
int thisAmount = AmountFor(perf, play);
// 포인트를 적립한다.
volumeCredits += Math.Max(perf.audience - 30, 0);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == play.type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{play.name}: ${thisAmount / 100} ({perf.audience}석)\n";
totalAmount += thisAmount;
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
이렇게 수정하고 나면 반드시 컴파일 후 테스트를 진행하여 실수한 게 없는지 확인한다.
아무리 간단한 수정이라도 리팩터링 후에는 테스트하는 습관을 들이는 것이 바람직하다.
사람은 실수하기 마련이다.
Clean Code에서는 이런 의식적인 행위를 수술직전 손을 씻는 의사와 비유하였다.
또한 한 가지를 수정할 때마다 테스트하면, 오류가 생기더라도 변경 폭이 작기 때문에 살펴볼 범위도 좁아서 문제를 찾고 해결하기가 훨씬 쉽다.
이처럼 조금씩 변경하고 매번 테스트하는 것은 리팩터링 절차의 핵심이다.
리팩터링은 프로그램 수정을 작은 단계로 나눠 진행한다.
그래서 중간에 실수하더라도 버그를 쉽게 찾을 수 있다.
생각
리팩터링, 테스트코드에 대한 객체지향적 관점에서도 훌룡하다고 생각이 든다.
리팩터링과 테스트코드를 짜는 행위 자체가 클래스, 메서드를 더욱 세분화하여 작게 만들 수 있기 때문에 더 유리하다.
한 번에 너무 많이 수정하려다 실수를 저지르면 디버깅하기 어려워서 결과적으로 작업 시간이 늘어난다.
조금씩 수정하여 피드백 주기를 짧게 가져가는 습관이 재앙을 피하는 길이다.
애자일과 관련된 이야기 같다고 생각이 들며, 유능한 개발자는 몇 만줄을 수정한 개발자 보다 2~3줄의 수정으로 이뤄진 커밋이라 생각한다.
리팩터링은 프로그램 수정을 작은 단계로 나눠 진행한다.
그래서 중간에 실수하더라도 버그를 쉽게 찾을 수 있다.
방금 수정한 사항을 테스트했을 때 문제가 없다면 코드베이스로 병합을 진행한다.
함수를 추출하고 나면 추출된 함수 코드를 자세히 들여다보면서 지금보다 명확하게 표현할 수 있는 간단한 방법은 없는지 검토한다.
가장 먼저 변수의 이름을 더 명확하게 바꿔보자
thisAmount
를 result
로 변경
저자는 함수의 반환 값에는 항상 result라는 이름을 쓴다.
result라는 이름이 통일성을 가지게 되면 함수를 읽을 때 한가지는 명확해지게 되며 해석할 한 가지가 줄어든다.
STM측면에서도 저장될 필요 없이 LTM단위에서 작업 기억 공간에서 해결할 문제라고 생각한다.
두 번째는 Perf를 aPerformance로 변경한다.
이 또한 코드를 읽는 사람이 이해하기 쉽도록 이름을 바꾼 것이다.
컴퓨터가 이해하는 코드는 바보도 작성할 수 있다.
이해하도록 작성하는 프로그래머가 진정한 실력자다.
이렇게 이름을 바꾸는 행위가 가치가 있는가??
물론이다. 좋은 코드라면 하는 일이 명확히 드러나야 하며, 코드를 읽는 사람이 추리를 하게 해서는 안된다.
play 변수 제거하기
amountFor()의 매개변수를 살펴보면서 이 값들이 어디서 오는지 확인해보면 aPerformance는 루프 변수에서 오기 때문에 반복문을 한 번 돌 때마다 자연스레 값이 변경된다.
하지만 play는 개별 공연에서 얻기 때문에 애초에 매개변수로 전달할 필요가 없다.
긴 함수를 잘게 쪼갤 때마다 play 같은 변수를 최대한 제거한다.
이런 임시 변수들 때만에 로컬 볌위에 존재하는 이름이 늘어나서 추출 작업이 복잡해지기 때문이다.
이를 해결해주는 리팩터링으로는 임시 변수를 질의 함수로 바꾸기가 있다.
다음으로 컴파일-테스트-커밋한 다음 변수 인라인하기를 적용한다.
public class Account
{
public String Statment(Invoice invoice, List<Play> plays)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
int thisAmount = AmountFor(perf, Playfor(perf, plays));
// 포인트를 적립한다.
volumeCredits += Math.Max(perf.audience - 30, 0);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == Playfor(perf, plays).type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{Playfor(perf, plays).name}: ${thisAmount / 100} ({perf.audience}석)\n";
totalAmount += thisAmount;
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
private int AmountFor(Performance perf, Play play)
{
int 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 Exception($"알 수 없는 장르:{play.type}");
}
return result;
}
private Play Playfor(Performance perf, List<Play> plays)
{
return plays.FirstOrDefault(p => p.name == perf.playID) ?? throw new Exception($"연극이름:{perf.playID}를 찾을 수 없습니다.");
}
}
이후 amountFor()에 함수 선언 바꾸기를 적용해서 play 매개변수를 제거할 수 있게 되었다.
public class Account
{
private List<Play> plays;
public Account(List<Play> plays)
{
this.plays = plays;
}
public String Statment(Invoice invoice)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
int thisAmount = AmountFor(perf);
// 포인트를 적립한다.
volumeCredits += Math.Max(perf.audience - 30, 0);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == Playfor(perf).type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${thisAmount / 100} ({perf.audience}석)\n";
totalAmount += thisAmount;
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
private int AmountFor(Performance perf)
{
int 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 Exception($"알 수 없는 장르:{Playfor(perf).type}");
}
return result;
}
private Play Playfor(Performance perf)
{
return plays.FirstOrDefault(p => p.name == perf.playID) ?? throw new Exception($"연극이름:{perf.playID}를 찾을 수 없습니다.");
}
}
방금 수행한 Plays -> Playfor로 변경한 과정에서 따로 캐싱하지 않고 메서드를 바로 호출하기 때문에 반복에 의한 성능 저하가 있을 수 있다.
뒤에서 리팩터링과 성능 관계를 자세히 설명하겠지만, 일단 지금은 성능엔 큰 영향이 없다.
설사 심각하게 느려지더라도 제대로 리팩터링된 코드베이스는그렇지 않은 코드베이스보다 성능을 개선하기가 훨씬 수월하다.
변수를 제거해서 얻는 가장 큰 장점은 추출 작업이 훨씬 쉬워진다는 것이다.
유효범위를 신경써야 할 대상이 줄어들기 때문이다.
마찬가지로 amountFor함수도 변수를 제거하고 함수로 변경, 인라인하기를 적용한다.
public class Account
{
private List<Play> plays;
public Account(List<Play> plays)
{
this.plays = plays;
}
public String Statment(Invoice invoice)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 포인트를 적립한다.
volumeCredits += Math.Max(perf.audience - 30, 0);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == Playfor(perf).type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${AmountFor(perf) / 100} ({perf.audience}석)\n";
totalAmount += AmountFor(perf);
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
private int AmountFor(Performance perf)
{
int 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 Exception($"알 수 없는 장르:{Playfor(perf).type}");
}
return result;
}
private Play Playfor(Performance perf)
{
return plays.FirstOrDefault(p => p.name == perf.playID) ?? throw new Exception($"연극이름:{perf.playID}를 찾을 수 없습니다.");
}
}
적립 포인트 계산 코드 추출하기
앞에서 play변수를 제거한 결과 로컬 유효범위의 변수가 하나 줄어서 적립 포인트 계산 부분을 추출하기가 훨씬 쉬워졌다.
public class Account
{
private List<Play> plays;
public Account(List<Play> plays)
{
this.plays = plays;
}
public String Statment(Invoice invoice)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 포인트를 적립한다.
volumeCredits += VolumeCreditsFor(perf);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == Playfor(perf).type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${AmountFor(perf) / 100} ({perf.audience}석)\n";
totalAmount += AmountFor(perf);
}
result += $"총액: ${totalAmount / 100}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (Playfor(aPerformance).type)
{
case "tragedy": // 비극
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{Playfor(aPerformance).type}");
}
return result;
}
private Play Playfor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == Playfor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
}
format 변수 제거하기
앞에서 말했듯이 임시 변수는 나중에 문제를 일으킬 수 있다.
임수 변수는 자신이 속한 루틴에서만 의미가 있어서 루틴이 길고 복잡해지기 쉽다.
따라서 다음으로 할 리팩터링은 이런 변수들을 제거하는 것이다.
C# 달러 표시 포멧팅을 따로 몰라서 임시 Tostring 래퍼로 작성
public class Account
{
private List<Play> plays;
public Account(List<Play> plays)
{
this.plays = plays;
}
public String Statment(Invoice invoice)
{
int totalAmount = 0;
int volumeCredits = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 포인트를 적립한다.
volumeCredits += VolumeCreditsFor(perf);
// 희극 관객 5명마다 추가 포인트를 제공한다.
if ("comedy" == Playfor(perf).type) volumeCredits += (int) Math.Floor((decimal) perf.audience / 5);
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${Format(AmountFor(perf) / 100)} ({perf.audience}석)\n";
totalAmount += AmountFor(perf);
}
result += $"총액: ${Format(totalAmount / 100)}\n";
result += $"적립 포인트: {volumeCredits}점\n";
return result;
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (Playfor(aPerformance).type)
{
case "tragedy": // 비극
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{Playfor(aPerformance).type}");
}
return result;
}
private Play Playfor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == Playfor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
private string Format(int aNumber)
{
// usd로 변환 과정
return aNumber.ToString();
}
}
작성하고 보니 Format이라는 메서드가 하는 일이 너무 불분명하다.
또한 FormatAsUSD는 너무 정황하다(지금처럼 가시 범위가 좁다면 더더욱 맞지 않다.)
함수 선언 바꾸기를 적용한다.
private string USD(int aNumber)
{
// usd로 변환 과정
return aNumber.ToString();
}
이처럼 이름 짓기는 중요하면서도 어려운 작업이다.
긴 함수를 작게 쪼개는 리팩터링은 이름을 잘 지어야만 효과가 있다.
이름이 좋으면 본문을 읽지 않고도 무슨 일을 하는지 알 수 있다.
물론 단번에 좋은 이름을 짓는 것은 어렵기 때문에 처음에 당장 떠오른 이름으로 최선의 이름을 사용하다
더 좋은 이름이 떠오르면 바꾸는 것이 좋다.
흔히 코드를 두 번 이상 읽고 나서야 가장 적합한 이름이 떠오르곤 한다.
VolumeCredits 변수 제거하기
이 변수는 반복문을 한 바퀴 돌 때마다 값을 누적하기 때문에 리팩터링하기가 더 까다롭다.
따라서 먼저 반복문 쪼개기로 volumeCredits를 누적하는 부분을 분리한다.
foreach (var perf in invoice.performances)
{
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${USD(AmountFor(perf) / 100)} ({perf.audience}석)\n";
totalAmount += AmountFor(perf);
}
foreach (var perf in invoice.performances)
{
// 포인트를 적립한다.
volumeCredits += VolumeCreditsFor(perf);
}
이어서 문장 슬라이드하기를 적용해서 volumeCredits 변수를 선언하는 문장을 반복문 바로 앞으로 옯긴다.
int volumeCredits = 0;
foreach (var perf in invoice.performances)
{
// 포인트를 적립한다.
volumeCredits += VolumeCreditsFor(perf);
}
volumeCredits 값 갱신과 관련한 문장들을 한데 모아두면 임시 변수를 질의 함수로 바꾸기가 수월해진다.
이번에도 역시 volumeCredits 값 계산 코드를 함수로 추출하는 작업부터 한다.
public class Account
{
private List<Play> plays;
private Invoice invoice;
public Account(List<Play> plays, Invoice invoice)
{
this.plays = plays;
this.invoice = invoice;
}
public String Statment()
{
int totalAmount = 0;
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${USD(AmountFor(perf) / 100)} ({perf.audience}석)\n";
totalAmount += AmountFor(perf);
}
result += $"총액: ${USD(totalAmount / 100)}\n";
result += $"적립 포인트: {totalVolumeCredits()}점\n";
return result;
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (Playfor(aPerformance).type)
{
case "tragedy": // 비극
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{Playfor(aPerformance).type}");
}
return result;
}
private Play Playfor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == Playfor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
private string USD(int aNumber)
{
return aNumber.ToString();
}
private int totalVolumeCredits()
{
int result = 0;
foreach (var perf in invoice.performances)
{
result += VolumeCreditsFor(perf);
}
return result;
}
}
C#언어로 작성하게 되면서 멤버 변수로 올리게 되었는데 더 좋은 방법이 있을까?
여기서 생각할 수 있는 점은 반복문을 쪼개면서 성능이 느려지지 않을까 걱정할 수 있다.
이 정도 중복은 선응에 미치는 영향이 미미할 때가 많다.
다양한 경우가 있겠지만 저자의 조언은 특별한 경우가 아니라면 일단 무시하라
는 것이다.
리팩터링 때문에 성능이 떨어진다면, 하던 리팩터링을 마무리하고 나서 성능을 개선하자.
생각
게임 관련 최적화에 대한 이슈도 알아본적이 있다.
유니티 프로파일링으로 코드의 문제되는 부분을 볼 수 있어 공부한 적이 있는데
미친 코드(이상한 반복, 무한루프)가 아니고서야 기본적인 룰 풀링이나 최적화 패턴, 캐싱을 잘 지킨다면
그 이상의 최적화는 무의미하다는 결론이 나왔다.
실제로 업계에서도 최적화를 진행할 때 코드레벨이 가장 후순위로 이펙트 관련 리소스부터 줄인다고 한다.
다시 한번 알아보자면 volumeCredits 변수를 제거하는 작업의 단계는 아래와 같다.
- 반복문 쪼개기
- 문장 슬라이드하기
- 함수 추출하기
- 변수 인라인하기
마찬가지로 totalAmount 변수도 제거해보자.
public class Account
{
private List<Play> plays;
private Invoice invoice;
public Account(List<Play> plays, Invoice invoice)
{
this.plays = plays;
this.invoice = invoice;
}
public String Statment()
{
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${USD(AmountFor(perf) / 100)} ({perf.audience}석)\n";
}
result += $"총액: ${USD(TotalAmount() / 100)}\n";
result += $"적립 포인트: {totalVolumeCredits()}점\n";
return result;
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (Playfor(aPerformance).type)
{
case "tragedy": // 비극
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{Playfor(aPerformance).type}");
}
return result;
}
private Play Playfor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == Playfor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
private string USD(int aNumber)
{
return aNumber.ToString();
}
private int totalVolumeCredits()
{
int totalAmount = 0;
foreach (var perf in invoice.performances)
{
totalAmount += VolumeCreditsFor(perf);
}
return totalAmount;
}
private int TotalAmount()
{
int result = 0;
foreach (var perf in invoice.performances)
{
result += AmountFor(perf);
}
return result;
}
}
1.5 중간 점검: 난무하는 중첩 함수
지금까지의 리팩터링결과를 살펴보면 구조가 많이 좋아졌다.
public class Account
{
private List<Play> plays;
private Invoice invoice;
public Account(List<Play> plays, Invoice invoice)
{
this.plays = plays;
this.invoice = invoice;
}
public String Statment()
{
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${USD(AmountFor(perf) / 100)} ({perf.audience}석)\n";
}
result += $"총액: ${USD(TotalAmount() / 100)}\n";
result += $"적립 포인트: {totalVolumeCredits()}점\n";
return result;
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (Playfor(aPerformance).type)
{
case "tragedy": // 비극
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{Playfor(aPerformance).type}");
}
return result;
}
private Play Playfor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == Playfor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
private string USD(int aNumber)
{
return aNumber.ToString();
}
private int totalVolumeCredits()
{
int totalAmount = 0;
foreach (var perf in invoice.performances)
{
totalAmount += VolumeCreditsFor(perf);
}
return totalAmount;
}
private int TotalAmount()
{
int result = 0;
foreach (var perf in invoice.performances)
{
result += AmountFor(perf);
}
return result;
}
}
최상위 Statement함수는 이제 단 7줄로 표현되고 있으며 출력할 문장을 생성하는 일만 담당한다.
계산 로직은 전부 보조함수로 빼내었다.
1.6 계산 단계와 포맷팅 단계 분리하기
지금까지는 프로그램의 논리적인 요소를 파악하기 쉽도록 코드의 구조를 보강하는 데 주안점을 두고 리팩터링 했다.
골격은 충분히 개선되었으니 이제 원하던 기능 statement()의 HTML버전을 만드는 작업을 시작해보자.
C# 성격상 내가 작성한 코드는 이미 Statement()함수내에서 중첩함수로 설계하지 않았다.
우리는 텍스트 버전과 HTML버전을 모두 똑같은 계산 함수들을 사용하게 만들고 싶다.
저자가 다양한 해결책 중 가장 선호하는 방식은 단계 쪼개기다.
statement()의 로직을 두 단계로 나누는 것으로 statement에서 필요한 데이터를 처리하고, 다음 단계에서 텍스트나 HTML로 표현하도록 한다.
단계를 쪼개려면 먼저 두 번째 단계가 될 코드들을 함수 추출하기로 뽑아내야 한다.
이 과정에서 두 번째 단계는 청구 내역을 출력하는 코드인데, 현재는 본문에 해당한다.
public String Statment()
{
return renderPlainText();
}
private string renderPlainText()
{
string result = $"청구 내역 (고객명:{invoice.customer})\n";
foreach (var perf in invoice.performances)
{
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${USD(AmountFor(perf) / 100)} ({perf.audience}석)\n";
}
result += $"총액: ${USD(TotalAmount() / 100)}\n";
result += $"적립 포인트: {totalVolumeCredits()}점\n";
return result;
}
역시 컴파일-테스트-커밋 단계를 거치고 두 단계 사이의 중간 데이터 구조 역할을 할 객체를 만들어서 renderPlainText()함수에 전달한다.
좀 더 객체지향적, c#에 맞게 설계하려면 render쪽이 인터페이스처럼 제작되어 클래스 단위로 동작, 즉 협력 객체가 되어야 할 듯 하다.
public class Account
{
private List<Play> plays;
private Invoice invoice;
public Account(List<Play> plays, Invoice invoice)
{
this.plays = plays;
this.invoice = invoice;
}
public String Statment()
{
Invoice statementData = new Invoice(invoice.customer , invoice.performances);
return renderPlainText(statementData);
}
private string renderPlainText(Invoice statementData)
{
string result = $"청구 내역 (고객명:{statementData.customer})\n";
foreach (var perf in statementData.performances)
{
// 청구 내역을 출력한다.
result += $"{Playfor(perf).name}: ${USD(AmountFor(perf) / 100)} ({perf.audience}석)\n";
}
result += $"총액: ${USD(TotalAmount() / 100)}\n";
result += $"적립 포인트: {totalVolumeCredits()}점\n";
return result;
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (Playfor(aPerformance).type)
{
case "tragedy": // 비극
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{Playfor(aPerformance).type}");
}
return result;
}
private Play Playfor(Performance aPerformance)
{
// 메서드 내부 메서드 불가능하기 때문에 클래스 멤버로 처리
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == Playfor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
private string USD(int aNumber)
{
return aNumber.ToString();
}
private int totalVolumeCredits()
{
int totalAmount = 0;
foreach (var perf in invoice.performances)
{
totalAmount += VolumeCreditsFor(perf);
}
return totalAmount;
}
private int TotalAmount()
{
int result = 0;
foreach (var perf in invoice.performances)
{
result += AmountFor(perf);
}
return result;
}
}
사실 지금 구조는 매우 불편한 구조라고 할 수 있다.
추상화 레벨도 다르고, 전혀 다형성을 고려하지 않았기 때문에 올바르지 못한 구조라고 생각된다.
하지만 뒤에서 더 이어나가는 부분이 있기 때문에 일단 따라가고 이후에 리팩토링을 진행해보자.
언어의 차이 때문에 내부함수를 두지 못하기 때문에 단순하게 클래스로 구분하던가 인터페이스를 활용하여 이쁘게 가져가야 한다.
연극 제목의 중간 데이터 구조에서 가져오기 위해 얕은 복사를 수행하는 모습도 있다.
가변 데이터에 대한 위험성과 불변 데이터로 처리하는 이유에 대해선 Clean Code나 C#의 레코드라는 type을 알아보면 좀 더 쉽게 이해가 된다.
C# 코드로 재구성
최대한 구조를 생각하면서 만들었는데 js코드의 특성을 잘 이해하지 못해서 조금 엉터리 코드가 된 것 같기도 하다.
문제가 된 부분은 Performace가 동적으로 확장되는 부분인 것 같은데 C#의 튜플처럼 동작하는 건지..
C#코드로 나타내기 위해서 새로운 클래스를 생성하여 책임을 분리하였다.
또한 각각 Statement를 인터페이스로 분리하였고 Stateprinter라는 클래스로 DI통한 의존성 역전 및 단일 책임을 부여하였다.
// StatementData 클래스
public class StatementData
{
public readonly string customer;
public readonly List<EnrichPerformance> performances;
public readonly int totalAmount;
public readonly int totalVolumeCredits;
public StatementData(Invoice invoice, List<Play> plays)
{
this.customer = invoice.customer;
this.performances = new List <EnrichPerformance>();
foreach (var perf in invoice.performances)
{
this.performances.Add(new EnrichPerformance(perf, plays));
}
this.totalAmount = TotalAmount();
this.totalVolumeCredits = TotalVolumeCredits();
}
private int TotalAmount()
{
int result = 0;
foreach (var perf in performances)
{
result += perf.amount;
}
return result;
}
private int TotalVolumeCredits()
{
int result = 0;
foreach (var perf in performances)
{
result += perf.volumeCredits;
}
return result;
}
}
// EnrichPerformance 클래스
public class EnrichPerformance
{
private List<Play> plays;
public Play play;
public int amount;
public int volumeCredits;
public int audience;
public EnrichPerformance(Performance aPerformace, List<Play> plays)
{
this.plays = plays;
this.play = PlayFor(aPerformace);
this.amount = AmountFor(aPerformace);
this.volumeCredits = VolumeCreditsFor(aPerformace);
audience = aPerformace.audience;
}
private Play PlayFor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception ($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private int AmountFor(Performance aPerformance)
{
int result = 0;
switch (play.type)
{
case "tragedy":
result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
break;
case "comedy":
result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
break;
default:
throw new Exception($"알 수 없는 장르:{play.type}");
}
return result;
}
private int VolumeCreditsFor(Performance aPerformance)
{
int result = 0;
result += Math.Max(aPerformance.audience - 30, 0);
if ("comedy" == PlayFor(aPerformance).type) result += (int) Math.Floor((decimal) aPerformance.audience / 5);
return result;
}
}
// IStatement 인터페이스
public interface IStatement
{
public string Statement();
}
// TextStatement 클래스
public class TextStatement : IStatement
{
private readonly Invoice invoice;
private readonly List<Play> plays;
public TextStatement(Invoice invoice, List<Play> plays)
{
this.invoice = invoice;
this.plays = plays;
}
public string Statement()
{
return RenderPlainText(new StatementData(invoice, plays));
}
private string RenderPlainText(StatementData data)
{
string result = $"청구 내역 (고객명:{data.customer})\n";
foreach (var perf in data.performances)
{
result += $" {perf.play.name}: {Usd(perf.amount)} ({perf.audience}석)\n";
}
result += $"총액: {Usd(data.totalAmount)}\n";
result += $"적립 포인트: {data.totalVolumeCredits}점\n";
return result;
}
private string Usd(int aNumber)
{
return string.Format("${0:#,##0.00}", aNumber / 100);
}
}
// HtmlStatement 클래스
public class HtmlStatement : IStatement
{
private readonly Invoice invoice;
private readonly List<Play> plays;
public HtmlStatement(Invoice invoice, List<Play> plays)
{
this.invoice = invoice;
this.plays = plays;
}
public string Statement()
{
return RenderHtml(new StatementData(invoice, plays));
}
private string RenderHtml(StatementData data)
{
string result = $"<h1>청구 내역 (고객명:{data.customer})</h1>\n";
result += "<table>\n";
result += "<tr><th>연극</th><th>좌석 수</th><th>금액</th></tr>";
foreach (var perf in data.performances)
{
result += $"<tr><td>{perf.play.name}</td><td>{perf.audience}</td>";
result += $"<td>{Usd(perf.amount)}</td></tr>\n";
}
result += "</table>\n";
result += $"<p>총액: <em>{Usd(data.totalAmount)}</em></p>\n";
result += $"<p>적립 포인트: <em>{data.totalVolumeCredits}</em>점</p>\n";
return result;
}
private string Usd(int aNumber)
{
return string.Format("${0:#,##0.00}", aNumber / 100);
}
}
// StatementPrinter 클래스
public class StatementPrinter
{
private IStatement statement;
public StatementPrinter(IStatement statement)
{
this.statement = statement;
}
public void Print()
{
Console.WriteLine(statement.Statement());
}
}
public class Program
{
public static void Main()
{
List<Play> plays = new List<Play>();
plays.Add(new Play("Hamlet", "tragedy"));
plays.Add(new Play("As You Like It", "comedy"));
plays.Add(new Play("Othello", "tragedy"));
List<Performance> performances = new List<Performance>();
performances.Add(new Performance("Hamlet", 55));
performances.Add(new Performance("As You Like It", 35));
performances.Add(new Performance("Othello", 40));
Invoice invoices = new Invoice("BigCo", performances);
// Account account = new Account(plays, invoices);
// Console.WriteLine(account.Statment());
StatementPrinter statementPrinter = new StatementPrinter(new TextStatement(invoices, plays));
statementPrinter.Print();
statementPrinter = new StatementPrinter(new HtmlStatement(invoices, plays));
statementPrinter.Print();
}
}
문제가 되는 부분은 EnrichPerformance클래스의 확장으로 인한 Performance클래스의 중복값 발생이다.
나중에 사용자가 모르고 사용한다면 더블 체크나 사이드 이펙트가 발생할 가능성이 높다.
그래도 코드 자체의 줄수가 줄어들고 추상화 레벨에 맞게 분리되어 있게 리팩터링을 진행했다.
다 작성하고 나니 보이는 추가 수정사항은 public 말고 c#의 특성에 맞게 프로퍼티로 작성했으면 더 좋았을 것 같다.
readonly가 몇개 부실하거나 불변성을 보장하지 못하는 부분 몇개..
1.7 중간 점검: 두 파일로 분리됨
책에서는 data와 statement로 두개로 분리되었지만
내 코드에선 data 두가지, statment는 인터페이스로 두 가지로 분리되었다.
처음 코드에 비해 양이 매우 많이 늘었지만 전체 로직 자체는 더 뚜렷하게 분리되어 있다.
각 출력 형식 이전에 data를 해당 로직에서 생성 후 출력 형식은 각각 인터페이스에 맞게 제작하여 printer로 넘겨준다.
캠핑자들에게는 “도착했을 때보다 깔끔하게 정돈하고 떠난다.”라는 규칙이 있다.
프로그래밍도 마찬가지다.
항시 코드베이스를 작업 시작 전보다 건강하게 만들어놓고 떠나야 한다.
1.8 다형성을 활용해 계산 코드 재구성하기
현재 amoutFor()함수를 보면 연극 장르에 따라 계산 방식이 달라진 다는 것을 알 수 있다.
이런 조건부 형식의 코드는 수정횟수가 늘어남에 따라 버그도 덩달아 같이 늘어나게 된다.
명확한 구조를 보완하는 방법은 다양하지만 객체지향의 다형성을 활용하는 것이 바람직하다.
여기서 사용되는 리팩터링 기법이 조건부 로직을 다형성으로 바꾸기다.
간단하게 생성 디자인 패턴 중 팩토리 메서드 패턴을 생각하면 간단하다.
인터페이스 레벨에서 DI까지 말고 switch 정도로 구현해본다.
// PerformanceCalculator 클래스
public interface IPerformaceCalculator
{
int AmountFor();
int VolumeCreditsFor();
Play GetPlay();
}
// PerformanceCalculator 클래스
public class ComedyCalculator : IPerformaceCalculator
{
private Performance aPerformance;
private Play play;
public ComedyCalculator(Performance aPerformance, Play play)
{
this.aPerformance = aPerformance;
this.play = play;
}
public int AmountFor()
{
int result = 30000;
if (aPerformance.audience > 20)
{
result += 10000 + 500 * (aPerformance.audience - 20);
}
result += 300 * aPerformance.audience;
return result;
}
public int VolumeCreditsFor()
{
return (int) Math.Floor((decimal) aPerformance.audience / 5);
}
public Play GetPlay()
{
return play;
}
}
// PerformanceCalculator 클래스
public class TragedyCalculator : IPerformaceCalculator
{
private Performance aPerformance;
private Play play;
public TragedyCalculator(Performance aPerformance, Play play)
{
this.aPerformance = aPerformance;
this.play = play;
}
public int AmountFor()
{
int result = 40000;
if (aPerformance.audience > 30)
{
result += 1000 * (aPerformance.audience - 30);
}
return result;
}
public int VolumeCreditsFor()
{
return Math.Max(aPerformance.audience - 30, 0);
}
public Play GetPlay()
{
return play;
}
}
// PerformanceCalculator 클래스
public class EnrichPerformance
{
private List<Play> plays;
public Play play;
public int amount;
public int volumeCredits;
public int audience;
public EnrichPerformance(Performance aPerformace, List<Play> plays)
{
this.plays = plays;
IPerformaceCalculator calculator = CreatePerformanceCalculator(aPerformace, PlayFor(aPerformace));
this.play = calculator.GetPlay();
this.amount = calculator.AmountFor();
this.volumeCredits = calculator.VolumeCreditsFor();
audience = aPerformace.audience;
}
private Play PlayFor(Performance aPerformance)
{
return plays.FirstOrDefault(p => p.name == aPerformance.playID) ?? throw new Exception ($"연극이름:{aPerformance.playID}를 찾을 수 없습니다.");
}
private IPerformaceCalculator CreatePerformanceCalculator(Performance aPerformance, Play aPlay)
{
switch (aPlay.type)
{
case "tragedy":
return new TragedyCalculator(aPerformance, aPlay);
case "comedy":
return new ComedyCalculator(aPerformance, aPlay);
default:
throw new Exception($"알 수 없는 장르:{aPlay.type}");
}
}
}
EnrichPerformance부분에 Calculator 팩토리 메서드를 넣어뒀는데 사실 SRP를 위반하기에 따로 Factory 클래스를 만들어서 분리하는 것이 좋다.
그게 핵심이 아니니.. 자세한 내용은 블로그를 참고
1.9 상태 점검: 다형성을 활용하여 데이터 생성하기
앞서 함수를 추출했을 때와 같이 구조를 보강하면서 코드가 늘어났다.
이번 수정으로 나아진 점은 연극 장르별 계산 코드들이 함께 묶이면서 가독성이 올라갔다는 것
수정, 추가가 편리하다.
새로운 장르를 추가하기 위해선 해당 인터페이스를 상속받아서 추가하기만 하면 된다.
1.10 마치며
간단한 예로 리팩터링을 진행하였다.
- 함수 추출하기
- 변수 인라인하기
- 함수 옮기기
- 조건부 로직을 다형성으로 바꾸기
등 다양한 리팩터링 기법을 알아봤다.
이번 장에선 리팩터링을 크게 3 단계로 진행했다.
먼저 원본 함수를 중첩 함수 여러 개로 나눴다.
다음으로 단계 쪼개기를 적용하여 계산 코드와 출력코드를 구분했다.
마지막으로 계산로직을 다형성으로 표현했다.
책과 다른 점은 출력로직또한 인터페이스를 활용하여 다형성을 적용했다는 점이다.
이 외에도 코드에 적용해야할 리팩터링이 많이 남아있지만 이번 장에선 과정정도로 이해한다.
좋은 코드를 가늠하는 확실한 방법은 ‘얼마나 수정하기 쉬운가’디.
프로그래머 사이에서 좋은 코드에 대한 의견은 항상 분분하다.
저자가 선호하는 적절한 이름의 작은 함수들
로 만드는 방식에 대하는 사람도 분명 있을 것이다.
미적인 관점으로 접근하게 되면 좋고 나쁨이 명확하지 않아서 개인 취향 말고는 어떠한 지침도 세울 수 없다.
하지만 취향을 넘어선 관점이 분명하게 존재하며, 코드를 수정하기 쉬운 정도
야 말로 좋은 코드를 가늠하는 가장 확실한 방법이다.
코드는 명확해야 한다.
건강한 코드베이스는 생산성을 극대화하고, 고객에 필요한 기능을 더 빠르고 저렴한 비용으로 제공하도록 해준다.
이번 예시에서 배울 수 있는 가장 중요한 부분은 리팩터링하는 리듬이다.
각 단계를 마칠 때마다 테스트하고 동작을 확인한다.
이는 곧 단계를 작게 나누고, 더 빠르게 처리하고, 코드가 절대 망가지지 않게 해준다.
이러한 작은 단계들이 모여 상당히 큰 변화를 이룰 수 있다는 것을 알게 될 것이다.
느낀점
1장부터 예제로 리팩터링의 전반을 설명해줘서 좋은 책임을 직감했다.
앞서 읽은 클린코드가 코드를 작성하면서 지켜야 할 패턴이라면 리팩터링은 이미 작성된 코드를 개선하는 방법이다.(이미 동작하는)
그 과정이 크게 다르지 않을 것이라 생각했는데 읽어보니 깨닫게 되는 부분이 많았다.
우리는 처음부터 자동차를 만들 수 없기 때문에 리팩터링은 필수적이라는 사실..
가장 느리게 가는길이 가장 빠른 길이다.
읽으면서 1챕터는 여러번 다시 읽어야할 부분이라고 생각되며 1챕터를 제대로 읽어야지 뒷 부분을 공감하며, 이해할 수 있을 것 같다.
다른 책에서도 공통적으로 느끼는 점이지만 이런 패턴, 리듬을 빨리 파악하는게 중요한 것 같다.
전체적으로 다시 읽어보니 너무 다.
로 끝나는 다다체가 너무 많은 듯..
논의사항
스스로 리팩터링을 하지 않는(회피하는) 이유는 뭘까요?
저는 개인적으로 귀찮아서(귀찮다고 생각한 이유는 지금 생각해보면 해당 프로젝트에 애정이 없었던 것 같습니다.)가 가장 큽니다.
2. 리팩터링 원칙
2.1 리팩터링 정의
수 많은 다른 소프트웨어 개발용어와 같이 리팩터링(Refactioring)이라는 단어도 다소 두루뭉실하게 사용된다.
ex) 인터페이스, 도메인 등등
저자는 이 용어을 더 구체적으로 사용해야 한다고 주장하며, 명사와 동사로도 사용 가능하다고 한다.
- 명사: 소프트웨어의 겉보기 동작은 그대로 유지한 채, 코드를 이해하고 수정하기 쉽도록 내부 구조를 변경하는 기법
- 동사: 소프트웨어의 겉보기 동작은 그대로 유지한 채, 여러 가지 리팩터링 기법을 적용해서 소프트웨어를 재구성하다.
지금껏 수많은 사람이 코드를 정리하는 작업을 모조리 리팩터링
이라고 표현하고 있는데, 앞에서 제시한 정의에 따르면 특정한 방식에 따라 코드를 정리하는 것만이 리팩터링이다.
리팩터링은 결국 동작을 보존하는 작은 단계들을 거쳐 코드를 수정하고, 이러한 단계들을 순차적으로 연결하여 큰 변화를 만들어내는 일이다.
누군가 “리팩터링하다가 코드가 깨져서 며칠이나 고생했어”라고 한다면, 십중팔구 리팩터링한 것이 아니다.
코드베이스를 정리하거나 구조를 바꾸는 모든 작업을 재구성이라는 포괄적인 용어로 표현하고, 리팩터링은 재구성 중 한 형태이다.
한 번에 바꿀 수 있는 작업을 수 많은 단계로 잘게 나눠서 작업하는 모습을 처음 접하게 되면 리팩터링하는 것이 오히려 더 비효율적이라고 생각할 수 있는데, 잘게 나눌 수록 오히려 작업을 더 빨리 처리할 수 있다.
작업은 사람이 하기에
앞에서 리팩터링을 정의할 때 겉보기 동작이라는 표현을 사용했는데, 이는 리팩터링하기 전과 후의 코드가 똑같이 동작해야 한다는 뜻이다.
그렇다고 완전하게 똑같다는 말은 아니고 콜스택이나 동작 방식, 세부 사항이 변경되어도 목적하는 동작은 똑같이 수행되어야 한다는 뜻이다.
리팩터링은 성능 최적화와 비슷하게 둘 다 코드를 변경하지만 프로그램의 전반적인 기능은 그대로 유지한다.
단지 목적이 다를 뿐으로 리팩터링은 성능이 좋아질 수도 있고 나빠질 수도 있다.
2.2 두 개의 모자
소프트웨어 개발을 할 때 목적이 기능 추가냐 아니면 리팩터링이냐를 명확하게 구분해야 한다.
이를 켄트 벡
은 두 개의 모자에 비유 했다.
기능을 추가할 때는 기능 추가 모자를 쓴 다음 기존 코드는 건드리지 않고 새 기능을 추가하기만 한다.
반면, 리팩터링 모자를 쓴 다음 기능 추가는 절대 하지 않기로 다짐한 뒤 오로지 코드 재구성에만 전념한다.
테스트도 새로 만들지 않는다.
두 모자 모두 테스트는 필수적이다..!
소프트웨어개발을 하는동안 대부분의 프로그래머는 두 모자를 동시에 쓰고 있다.
두 모자는 한번에 존재할 수 없으니 잠깐 내려놓고 한 가지 모자만 선택해야 한다.
2.3 리팩터링하는 이유
리팩터링은 소프트웨어의 모든 문제점을 해결하는 만병통치약이 아니다.
하지만 코드를 건강하게 유지하도록 도와주는 약임은 분명하다.
리팩터링하면 소프트웨어 설계가 좋아진다
리팩터링을 무시할 경우 소프트웨어의 내부 설계(아키텍처)가 썩기 쉽다.
아키텍처를 충분히 이해하지 못한 채 단기 목표만을 위해 코드를 수정하다 보면 기반 구조가 무너지기 쉽다.
과거에 말한 역 피라미드 구조
정말 극단적인 경우지만 게임의 경우에는 설계부터가 잘못된 경우는 다시 만드는 게 더 빠를 정도로 엉키고 망가져 있다면.. 악순환이 반복되는 것
반면, 규칙적인 리팩터링은 코드의 구조를 지탱해줄 것이다.
같은 일을 하더라도 설계가 나쁘면 코드가 길어지기 십상이다. (예로 1챕터에서 다룬 분기문을 메서드내에서 처리할 경우 조건이 많아질 수록 코드 수정이 어려워짐)
그래서 중복 코드 제거는 설계 개선 작업의 중요한 한 축을 차지한다.
코드량을 줄인다고 시스템이 빨라지는 것은 아니다.. 하지만 코드를 수정하는데 드는 노력은 크게 달라진다.
비슷한 일을 하는 코드가 산재해 있다면 한 부분만 살짝 바꿔서는 시스템이 예상대로 동작하지 않을 수 있다.
반면, 중복 코드를 제거하면 모든 코드가 언제나 고유한 일을 수행함을 보장할 수 있으며, 이는 바람직한 설계의 핵심이다.
리팩터링하면 소프트웨어를 이해하기 쉬워진다
컴퓨터는 거짓말을 하지 않는다.
그래서 컴퓨터에게 시키려는 일과 이를 표현한 코드의 차이를 최대한 줄여야 한다.
프로그래밍은 결국 내가 원하는 바를 정확히 표현하는 일이다.
좋은 코드, 나쁜 코드의 책에서 코드는 다른 사람이 봐도 한번에 이해가 되도록 작성해야 한다.
여기서 다른 사람은 1달 뒤, 1년 뒤 자신도 포함하는 말이다.
스스로 1달동안 건들지 않은 유물코드를 보게 되면 왜 이렇게 짰는지도 못알아 보는 경우가 많다.
물론 소프트웨어 개발자는 프로그램의 동작에 신경을 써야하지만, 그 코드를 자신도 이해 못하고 허둥지둥하는 모습을 보인다면 분명히 몇 달 뒤에는 자신도 해당 코드를 수정하지 못할 것이다.
즉, 궁극적인 목표는 프로그램의 최적화가 아닌 자신 그리고 남이 해당 코드를 보고 바로 이해할 수 있도록 리팩터링하는 것
리팩터링하면 버그를 쉽게 찾을 수 있다
리팩터링하면 코드가 하는 일을 깊이 파악할 수 있게 되면서 새로 깨달은 것을 곧바로 코드에 반영하게 된다.
프로그램의 구조를 명확하게 다듬으면 그냥 ‘이럴 것이다’라고 가정하던 점들이 분명히 드러나는데, 버그를 지나치려야 지나칠 수 없을 정도로 명확해진다.
난 뛰어난 프로그래머가 아니에요, 단지 뛰어난 습관을 지닌 프로그래머일 뿐이에요
리팩터링하면 프로그래밍의 속도를 높일 수 있다
지금까지 제시한 장점을 한 마디로 요약하면 리팩터링하면 코드 개발 속도를 높일 수 있다.
그 반대라고 생각할 수 있지만, 리팩터링을 통해 품질을 높일 수 있다는 점은 결국 내부 설계와 가독성이 개선되고 버그가 줄어든다는 점은 모두 품질 향상에 직결된다.
품질 향상은 결국 뒤로 가게되면 속도 자체에 영향을 크게 주게 된다.
사실 다들 알고 있지만 간과하거나 무시하는 부분이기는 하다.
잘 개발되었다면 새로운 시스템을 도입할 때 전혀 문제가 없어야 하지만 초기 개발 속도보다 느려진다면 아슬아슬한 모형탑을 쌓고 있음을 직감해야 한다.
즉, 가장 느린 길이 가장 빠른 길이다.
2.4 언제 리팩터링해야 할까?
저자는 프로그래밍할 때 거의 한 시간 간격으로 리팩터링을 한다.
3의 법칙
- 처음에는 그냥 한다.
- 비슷한 일을 두 번째로 하게 되면(중복이 발생), 일단 계속 진행한다.
- 비슷한 일을 세 번째 하게 되면 리팩터링한다.
준비를 위한 리팩터링: 기능을 쉽게 추가하게 만들기
리팩터링하기 가장 좋은 시점은 코드베이스에 기능을 새로 추가하기 직전이다.
구조를 살짝 바꾸면 다른 작업을 하기가 훨씬 쉬워질 만한 부분을 찾는다.
비유하면 지금 위치에서 동쪽으로 100km를 이동하려는데 그 사이를 숲이 가로막고 있다면, 좀 둘러가더라도 20km 북쪽에 있는 고속도로를 타는 편이 세 배나 빠를 수 있다..
다들 “직진!”을 외치더라도, 때로는 “잠깐, 지도를 보고 가장 빠른 길을 찾아보자”고 말할 줄 알아야 한다.
준비를 위한 리팩터링이 바로 이런 역항을 한다.
버그를 잡을 때도 마찬가지로 여기 저기 흩어져 있는 버그보다 한 곳에 합쳐져 있는 버그를 잡는 것이 훨씬 쉽다.
이해를 위한 리팩터링: 코드를 이해하기 쉽게 만들기
코드를 수정하려면 먼저 그 코드가 하는 일을 파악해야 한다.
그 코드를 작성한 사람은 자신일 수도 있고, 다른 사람일 수도 있다.
코드가 더 명확하게 드러나도록 리팩터링할 여지는 없는지 찾아본다.
이런 리팩터링 과정에서 선수 작업으로 무조건 테스트 코드는 작성되어 있어야 한다.
쓰레기 줍기 리팩터링
코드를 파악하던 중에 일을 비효율적으로 처리하는 모습을 발견할 때가 있다.
이때, 약간의 타협점이 필요하다.
원래 하려던 작업과 관련 없는 일에 너무 많은 시간을 빼앗기긴 싫을 것이다.
좋은 방법은 간단한 수정은 바로바로 수정하고 시간이 좀 걸리는 일은 메모를 남겨놓고 이후에 처리한다.
이것이 쓰레기 줍기 리팩터링이다.
물론 수정하려면 몇 시간이나 걸리고 당장은 더 급한 일이 있을 수 있지만, 조금이나마 개선해두는 것이 좋다.
캠핑장 규칙처럼 조금씩 개선하다 보면 결국 문제를 해결할 수 있게 될 것이다.
리팩터링은 외부에 보이는 작업이 아니기 때문에 몇 달에 걸쳐 진행하더라도 코드가 깨지지 않는다.(테스트만 완벽하다면..)
계획된 리팩터링과 수시로 하는 리팩터링
앞에서 본 리팩터링 방법은 모두 기회가 될 때만 진행하고 개발에 들어가기 전 일정을 따로 잡지 않고 리팩터링을 진행한다.
기능을 추가하거나 버그를 잡을 때도 함께 한다.
보기 싫은 코드를 발견하면 리팩터링하자.
그런데 잘 작성된 코드 역시 수많은 리팩터링을 거쳐야 한다.
리팩터링을 과거에 저지를 실수를 바로잡거나 보기 싫은 코드를 정리하는 작업이라고 오해할 수 있다.
보기 싫은 코드는 리팩터링해야 함이 당연하지만, 잘 작성된 코드 또한 수많은 리팩터링을 거쳐야 한다.
무언가 수정하려 할 때는 먼저 수정하기 쉽게 정돈하고 그런 다음 쉽게 수정하자
오랫동안 사람들은 소프트웨어 개발이란 뭔가 ‘추가’하는 과정으로 여겼다.
하지만 뛰어난 개발자들은 새 기능을 추가하기 쉽도록 코드를 수정하는 것이 그 기능을 가장 빠르게 추가하는 길일 수 있음을 안다.
소프트웨어 개발을 끝이 있는 작업으로 보면 안 된다.
새 기능이 필요할 때마다 소프트웨어는 이를 반영하기 위해 수정된다.
생각
이런 리팩터링은 게임의 스킬과 같이 동작하며 갈고 닦을 수록 더욱 강해지는 도구이자 기술이다.
다른 언어와 개발 툴도 마찬가지라고 생각하며 가장 중요한 것은 마음가짐과 꾸준함정도라고 생각한다.
오래 걸리는 리팩터링
리팩터링은 대부분 몇 분 안에 끝난다.
길어야 몇 시간 정도이지만, 간혹 팀 전체가 달라붙어 몇 주가 걸리는 리팩터링도 있다.
라이브러리를 교체하는 작업일 수도 있고, 일부 코드를 다른 팀과 공유하기 위해 컴포넌트로 빼는 작업일 수 있다.
또는 작업하면서 쌓여온 골치 아픈 의존성을 정리하는 작업일 수도 있다.
이런 상황에서 팀 전체가 매달리는 리팩터링 보단 주어진 문제를 몇 주에 걸쳐 조금씩 해결해가는 편이 효과적일 때가 많다.
누구든지 리팩터링해야할 코드와 관련된 작업을 하게 될 때마다 원하는 방향으로 조금씩 개선하는 식이다.
코드 리뷰에 리팩터링 활용하기
코드 리뷰는 개발팀 전체에 지식을 전파하는 데 좋다.
경험이 많은 개발자의 노하우를 더 적은 개발자에게 전수할 수 있따.
이 외에도 코드 리뷰는 이점이 다양하다.
리팩터링은 다른 이의 코드를 리뷰하는 데도 도움이 된다.
리팩터링을 활용하기 전에는 코드를 읽고, 그럭저럭 이해한 뒤, 몇 가지 개선 사항을 제시했다.
지금은 새로운 아이디어가 떠오르면 리팩터링하여 쉽게 구현해 넣을 수 있는지부터 살펴본다.(쉽다면 리팩터링한다.)
이 과정을 몇 번 반복하면 내가 떠올린 아이디어를 실제로 적용했을 때의 모습을 더 명확하게 볼 수 있다.
머리로만 상상하는 것이 아니라 눈으로 직접 확인하는 것이다.
그러다 보면 리팩터링해보지 않고는 절대 떠올릴 수 없던 한 차원 높은 아이디어가 떠오르기도 한다.
가장 추천하는 리팩터링 코드 리뷰 방식은 짝 프로그래밍이다.
생각
과거 42서울에서 짝 프로그래밍을 경험해본적이 있는데 같은 코드라도 생각이 전혀 달라서 신기했던 경험이 있다.
지금 이 책으로 진행중인 북 클럽도 짝 프로그래밍의 일종이라고 생각한다.
각자 책을 읽고 느낀점과 생각을 토론하는 과정에서 내가 생각하지 못한 인사이트를 얻기 때문이다.
관리자에게는 뭐라고 말해야 할까?
게임으로 따지자면… 기획자, pm, 디렉터에게 뭐라고 해야할까?
기획자는 눈에 보이는 작업을 원하지만, 리팩터링은 전혀 눈에 보이지 않기 때문에 기획자입장에선 이해하기 어렵다.
몇 주를 잡고 리팩터링을 진행하다가 틀어져서 좀 더 시간이 필요하지만 프로젝트 마일스톤이 다가오면서 급하게 작업하다 보면 결국 어설픈 재구성에 빠지고 코드 베이스 전체에 악 영향을 미친다.
소프트웨어 개발자는 효과적인 소프트웨어를 빠르게 만들어 내는 것이다.
경험상 리팩터링하면 소프트웨어를 빠르게 만드는데 아주 효과적이다.
하지만 왜 하지 않을까??
역사가 증명하듯 이 질문에 대한 답은 그냥 “하지 않아서”이다.
프로 개발자에게 주어진 임무는 새로운 기능을 빠르게 구현하는 것이고, 가장 빠른 방법은 리팩터링이다.
리팩터링하지 말아야 할 때
리팩터링도 하지 말아야 할 때가 있다.
외부 API의 경우 지저분한 코드를 볼 수 있는데 이는 내부 동작을 이해하는 시점에서야 리팩터링 효과를 제대로 볼 수 있다.
리팩터링을 하는 것보다 처음부터 새로 작성하는 게 쉬울 때도 리팩터링하지 않는다.
경험해본적이 있다..
2.5 리팩터링 시 고려할 문제
특정한 기술, 도구, 아키텍처등이 등장할 때 마다 우리는 비판적인 시각으로 바라봐야 한다.
유니티를 사용중이라면 새로운 엔진과 장점,단점을 파악하고 이해해야 한다.
똑같이 리팩터링도 하나의 기법이기 때문에 이에 딸려오는 트레이트 오프는 당연하다.
새 기능 개발 속도 저하
리팩터링이 개발 속도를 저하시킨다고 하지만, 리팩터링의 궁극적인 목적은 개발 속도를 높이는 데 있다.
리팩터링의 궁극적인 목적은 개발 속도를 높여서, 더 적은 노력으로 더 많은 가치를 창출하는 것이다.
기능 개발과 당장 눈 앞에 보이는 리팩터링에서 균형을 잘 잡아야 한다.
저자는 준비를 위한 리팩터링을 하면 변경을 훨씬 쉽게 할 수 있다고 확신한다.
그래서 새 기능을 구현해넣기 편해지겠다 싶은 리팩터링이라면 주저하지 않고 리팩터링부터 한다.
리팩터링을 더 자주 하도록 노력해야 한다.
건강한 코드의 위력을 충분히 경험해보지 않고서는 코드베이스가 건강할 때와 허약할 때의 생산성 차이를 체감하기 어렵다.
코드베이스가 건강하면 기존 코드를 새로운 방식으로 조합하기 쉬워서 복잡한 새 기능을 더 빨리 추가할 수 있다.
가장 위함한 오류는 리팩터링을 클린 코드
나 바람직한 엔지니어링 습관
처럼 도덕적인 이유로 정당화하는 것이다.
리팩터링의 본질은 코드 베이스를 예쁘게 꾸미는 데 있지 않다.(부가적으로 따라오는 것)
오로지 경제적인 이유로 하는 것이다.
코드 소유권
리팩터링하다 보면 모듈의 내부뿐 아니라 시스템의 다른 부분과 연동하는 방식에도 영향을 주는 경우가 많다.
함수의 이름을 바꾸고 싶고 그 함수를 호출하는 곳을 모두 찾을 수 있다면, 간단히 함수 선언 바꾸기를 통해 변경할 수 있다.
함수를 호출하는 코드의 소유자가 다른 팀이라서 나에게는 쓰기 권한이 없을 수 있다.
또는 바꾸려는 함수가 고객에게 API로 제공된 것이라면 누가 얼마나 쓰고 있는지를 고사하고, 실제로 쓰이기나 하는지조차 모를 수 있다.
이런 함수는 인터페이스를 누가 선언했는지에 관계없이 클라이언트가 사용하는 공개된 인터페이스
에 속한다.
코드 소유권이 나뉘어 있으면 리팩터링에 방해가 된다.
클라이언트에 영향을 주지 않고서는 원하는 형태로 변경할 수 없기 때문이다.
이 상황에서 개선을 하기 위해선 약간의 제약이 따르게 된다.
함수 이름을 바꾸기 위해선 기존 함수는 그대로 유지하되 본문에서 새로운 함수를 호출하도록 변경하거나 기존함수를 래퍼로 감싸서 처리할 수 있다.
이처럼 복잡해지기 때문에 코드 소유권을 세분화하여 철저하게 나누는 것에는 그만큼 비용이 따른다.
브랜치
흔히 볼 수 있는 팀 단위 작업 방식은 버전 관리 시스템을 사용하여 팀원마다 코드베이스의 브랜치를 하나씩 맡아서 작업하다가, 결과물이 어느정도 쌓이면 마스터 브랜치에 통합해서 공유하는 것이다.
기능별로 브랜치를 두고 작업을 하다보면 독립 브랜치로 작업하는 기간이 길어질수록 작업 결과를 마스터로 통합하기가 어려워진다.
따라서 수시로 merge하거나 rebase하는데 여러 기능 브랜치에서 동시에 개발이 진행될 때는 이런 식으로 해결할 수 없다.
따라서 단방향 병합과 양방향 통합을 구분하여 작업하는 것이 좋다.
지속적 통합(CI)에 따라 하루에 최소 한 번은 마스터와 통합하여 머지의 복잡도를 낮출 수 있다.
궁극적으로 리팩터링과의 궁합도 매우 뛰어나다.
리팩터링을 하다 보면 코드베이스 전반에 걸쳐 자잘하게 수정하는 부분이 많을 때가 있다.
프로그램 전체에서 자주 사용하는 함수의 이름을 바꾸는 경우가 이러한 예이다.
해당 경우 머지 과정에서 충돌이 생기기 쉽고 특히 기능별 브랜치 방식에서는 리팩터링을 도저히 진행할 수 없을 정도로 심각한 머지 문제가 발생하기 쉽다.
CI와 리팩터링을 합쳐 익스트림 프로그래밍(XP)라고 부른다.
테스팅
리팩터링을 하기 위해선 자가 테스트 코드가 필요하다.
리팩터링에 관한 유용한 이유, 테스트 코드가 필요한 이유를 수도 없이 말하지만, 왜 실천하지 못하는 걸까?
저자 또한 테스트 코드의 유용성, 실제 사용 사례를 보여주며 효과에 대하 말하지만 쉽게 실천하지 못하는 이유는 객체지향에 대한 이해 부족도 한 원인이라고 본다.
많은 고전 개발책이나 뛰어난 프로젝트에서도 볼 수 있지만 실제 공부하는 학생 레벨의 코드에서는 테스트 코드를 보기 힘들다.
안해봐서 그렇고 몰랐기 때문이라고 생각한다.
레거시 코드
사람들은 물려 받는 것을 좋아하지만, 코드는 그렇지 않다.
레거시 코드는 대체로 복잡하고 제대로 갖춰지지 않은 것이 많다.
하지만 그 당시에 최선을 다한 코드이기 때문에 함부로 욕해선 안된다.
레거시 시스템을 파악할 때 리팩터링이 굉장히 도움된다.(단련하기에..)
이 문제의 정답 또한 테스트 코드이다.
테스트를 보강하고 레거시의 시스템을 파악하여 리팩터링을 진행한다.
하지만 테스트가 있다고 해서 레거시 코드가 한번에 깨끗해지는 것은 아니며, 그런 낙관적인 시선도 좋지 못하다.
앞서 말한 캠핑장 규칙과 같이 조금씩 꾸준하게 리팩터링을 진행해야 한다.
데이터베이스
리팩터링 1판에서는 데이터베이스는 리팩터링하기 까다롭다고 했었는데 2판이 출시되고 진화형 데이터베이스 설계, 데이터베이스 리팩터링기법이 등장하며 어렵지 않게 바뀌었다.
다른 리팩터링과 마찬가지로 이 기법도 전체 변경 과정을 작고 독립된 단계들로 쪼개는 것이 핵심이다.
그래야 마이그레이션 후에도 정상 작동할 수 있다.
데이터베이스 리팩터링은 프로덕션 환경에 여러 단계로 나눠서 릴리스하는 것이 대체로 좋다는 점에서 다른 리팩터링과 다르다.
2.6 리팩터링, 아키텍처, 애그니(YAGNI)
과거에는 코드를 작성한 뒤에는 아키텍처를 변경할 수 없다는 인식이 지배적이였는데, 리팩터링은 이런 관점을 크게 바꿔놓았다.
이 책의 부제처럼 리팩터링으로 기존 코드의 설계를 개선할 수 있다.
하지만 앞서 말한 레거시 코드는 변경하기 어려울 때가 많기 때문에 탄탄한 테스트 코드가 뒷받침해줘야 한다.
리팩터링이 아키텍처에 미치는 실질적인 효과는 요구사항 변화에 자연스럽게 대응하도록 코드베이스를 잘 설계해준다는 데 있다.
코딩 전에 아키텍처를 확정지으려 할 때의 대표적인 문제는 소프트웨어 요구사항을 사전에 모두 파악해야 한다는 것이다.
하지만 막상 해보면 실현할 수 없는 목표일 때가 많다..
우리는 소프트웨어를 실제로 사용해보고 업무에 미치는 영향을 직접 확인하고 나서야 정말로 원하는 바를 알게 되는 경우가 허다하다.
한 가지 방법으로 유연성 메커니즘을 소프트웨어에 심어두는 것이다.
가령 함수를 정의하다 보면 범용적으로 사용할 수 있겠다는 생각이 들 때가 있다.
Mathf나 다른 라이브러리와 같이 사용자가 사용할 함수를 미리 오버로딩, 선택적 매개변수 등을 통해 범용성을 늘리는 방법도 있지만, 다른 책에서 말하듯이 오용될 수 있는 부분은 제한하는 것이 좋다.
필요에 의해서만 만들어야 하고 모든 경우의 수를 생각해 만들면 캡슐화의 의미 자체가 변질된다.
리팩터링을 활용하면 다르게 접근할 수 있다.
유연성에 대응되는 부분을 생각하지 않고 현재까지 파악된 요구사항만을 해결하는 소프트웨어를 구축한다.
진행하면서 사용자의 요구사항을 더 잘 이해하게 되면 아키텍처도 그에 맞게 리팩터링해서 바꾼다.
이런 식으로 설계하는 방식을 간결한 설계, 점직적 설계, YAGNI(You Aren’t Gonna Need It)라고 한다.
문자 그대로 적용할 때도 있지만, 아키텍처를 전혀 고려하지 말라는 뜻은 아니다.
이를 받아들인다고 해서 아키텍처를 완전히 소홀해도 된다는 뜻은 아니다.
리팩터링으로는 변경하기 어려워서 미리 생각해두면 시간이 절약되는 경우도 얼마든지 있다.
2.7 리팩터링과 소프트웨어 개발 프로세스
XP, TDD와 같이 애자일의 원칙을 따르는 개발 방법론들이 있다.
생각
나는 개발을 진행할 때 애자일 비스무리하게 따라가는 편인데, 하루의 Todo를 정하고 해당 일정에 맞춰 움직인다.
점직적으로 스케줄을 늘려나가며 나의 가용시간을 판단하기도 하는데.. 이런 방식이 애자일과 비슷한 것 같다.
리팩터링의 첫 번째 토대는 자가 테스트 코드이다. (매우 매우 중요..)
팀으로 개발하면서 리팩터링을 하려면 각 팀원이 다른 사람의 작업을 방해하지 않으면서 언제든지 리팩터링을 할 수 있어야 한다.
지속적 통합을 강조하는 이유도 이 때문이다.
이러한 순환 구조? 핵심 실천법을 갖췄다면 애자일의 다른 요소가 주는 이점까지 취할 수 있는 토대를 마련한 셈이다.
2.8 리팩터링과 성능
직관적인 설계 vs 성능은 중요한 주제는 다양한 의견이 있지만 앞서 말한 것과 같이 리팩터링을 하면 느려질 수 있는 것은 사실이나 그와 동시에 성능을 튜닝하기는 더 쉬워진다.
빠른 소프트웨어를 작성하는 방법 세 가지
첫 번째는 예산 분배방식으로, 하드 리얼타임 시스템에서 많이 사용한다.
이 방식에 따르면 설계를 여러 컴포넌트로 나눠서 컴포넌트 마다 자원 예산을 할당한다.(초과할 수 없다.)
두 번째는 끊임없이 관심을 기울이는 것이다.
프로그래머라면 누구나 높은 성능을 유지하기 위해 무슨 일이든 한다.
직관적이어서 흔히 사용하는 방식이지만 실제 효과는 변변치 않다.
성능을 개선하기 위해 코드를 수정하다 보면 프로그램은 다루기 어려운 형태로 변하기 쉽고, 결국 개발이 더뎌진다.
아무것도 안 만드는 데도 시간이 걸린다.
이 이야기의 교훈은 시스템에 대해 잘 알더라도 섣불리 추측하지 말고 성능을 측정해봐야 한다.
그러면 새로운 사실을 배우게 되는데, 십중팔구 내가 잘못 알고 있었음을 깨닫게 된다.
2.9 리팩터링의 유래
리팩터링의 유래에 관한 이야기이다.
리팩터링이 어떻게 이 업계에 주류 개발 기법을 남게 되었는지
2.10 리팩터링 자동화
지금 내가 사용중인 IDE Rider역시 리팩터링을 지원한다.
메서드 이름을 변경하거나 문제가 되는 부분을 지적해주며 좀 더 좋은 구조로 변경할 수 있도록 도와준다.
자동화가 되었음에도 꼭 테스트는 해볼 것.
또한 자동화로는 따라 갈 수 없는 구조적인 리팩터링은 직접 해야 한다.
2.11 더 알고 싶다면
느낀점
클린코드도 그렇고, 좋은 코드 나쁜 코드, 객체지향 책 등등 모든 책에서 테스트 코드의 중요성을 매우 많이 언급한다.
아직도 배우고 있는 단계이지만 테스트 코드는 짜면 짤 수록 경험이 정말 중요하다는 것을 알게 되기도 하고 리팩터링이라는 과정을 따로 시간을 할애하는 것이 아닌 의식적으로 행해야 함을 배운 것 같다.
이번 장은 대체적으로 리팩터링에 대한 개념?(해야하는 이유, 방법, 효과 등등)에 대해 다뤘는데 기억에 남는 부분은 두 개의 모자 부분..
논의사항
따로 기획자분이나 PM에게 리팩터링을 해야함을 설명하여 시간을 할애할 때 어떤 식으로 말을 하실지 궁금합니다!
3. 코드에서 나는 악취
냄새 나면 당장 갈아라
켄트 벡 할머니의 육아 원칙
이번 장에서는 리팩터링의 타이밍을 결정하는 냄새를 살펴본다.
인스턴스 변수를 삭제하거나 상속 계층을 만든느 방법을 설명하기는 쉽다.
하지만 이런 일을 언제 해야하는 지는 명확하게 정립된 규칙이 없다.
결론부터 말하면 이런 결정은 경험과 직관에 의존한다.
즉, 코드 감각을 키워야 하는 영역이다.
따라서 많이 등장하는 코드 악취, 냄새를 예로 보여주며 냄새가 나는 부분을 파악하는 감을 키워보자.
이후 6~12장의 카탈로그로 악취를 없애는 과정을 준비하자
CleanCode에선 냄새의 연장선으로 좀 더 확장된 개념을 다룬 듯 하여 링크를 같이 첨부한다.
3.1 기이한 이름
추리 소설이나 로판소설.. “이번 생은 나혼자만 레벨업을 하도록 하겠습니다?” 등등.. 궁금증을 자아낼수록 좋지만, 코드는 아니다.
기인이나 예술적으로 보이고 싶더라도 참고 단순하고 명료하게 작성해야 한다.
이를 가장 직관적으로 먼저 드러내는 것이 이름이다.
하지만 소프트웨어 개발에서도 어렵다고 평가되는 이름 짓기.. 이후 등장하는 함수 선언 바꾸기, 변수 이름 바꾸기, 필드 이름 바꾸기ㅊ럼 이름을 바꾸는 리팩터링들이 있다.
3.2 중복 코드
똑같은 코드 구조가 여러 곳에서 반복된다면 냄새가 날 수 있다.
하나로 통합하여 더 나은 프로그램을 만들 수 있다.
가장 간단한 중복 코드의 예로, 한 클래스에 딸린 두 메서드가 똑같은 표현식을 사용하는 경우가 있다.
이럴 때는 함수 추출하기를 사용하여 추출된 메서드를 호출하게 바꾸면 된다.
코드가 비슷하긴 한데 완전히 똑같지는 않다면, 먼저 문장 슬라이드하기로 비슷한 부분을 한 곳에 모아 함수 추출하기를 더 쉽게 적용할 수 있는지 살펴본다.
같은 부모로부터 파생된 서브 클래스들에 코드가 중복되어 있다면, 각자 따로 호출되지 않도록 메서드 올리기를 적용해 부모로 옮긴다.
3.3 긴 함수
짧은 함수로 구성된 코드베이스를 얼핏 훑으면 연산하는 부분이 하나도 없어 보인다.
코드가 끝없이 위임하는 방식으로 작성되어 있기 때문이다.
하지만 이런 프로그램을 수십년간 다루다 보면 이 짧은 함수들이 얼마나 중요한지 깨닫게 된다.
간접 호출의 효과, 즉 코드를 이해하고, 공유하고, 선택하기 쉬워진다는 장점은 함수를 짧게 구성할 때 나오는 것이다.
과거에는 서브루틴을 호출하는 비용이 컸기 때문에 짧은 함수를 꺼렸지만 요즘 언어들은 프로세스 안에서의 함수 호출 비용을 거의 없앴기 때문에 짧은 함수를 만드는 데 부담이 없다.
물론 사람이 보기에 왔다 갔다 해야하기 때문에 여전히 부담이 된다.
그러나 클린코드에서도 말하듯 함수 단위가 작아지면 작아질 수록 이름을 통해 본문을 볼 필요가 없어지게 된다.
이름 자체로 해당 코드가 설명이 된다면, 테스트가 통과 한다면 굳이 본문을 볼 필요가 없기 때문이다.
반대로 함수 길이가 길면 해당 동작과정을 함수 이름으로 나타내기엔 무리가 있다.
이를 위해 Intention 의도
가 드러나게 이름을 짓는다.
함수를 짧게 만드는 과정은 99%가 함수 추출하기가 차지한다.
함수가 매개변수와 임시 변수를 많이 사용하면 추출작업에 방해가 된다.
그렇다면 임시 변수를 질의 함수로 바꾸기로 임시 변수의 수를, 매개변수 객체 만들기와 객체 통째로 넘기기로 매개변수의 수르르 줄일 수 있을 것이다.
이 리팩터링을 적용해도 여전히 임시 변수와 매개변수가 너무 많다면 더 큰 수술이라 할 수 있는 함수를 명령으로 바꾸기를 고려해보자
조건문이나 반복문도 추출 대상의 실마리를 제공한다.
조건문은 조건문 분해하기로 대응한다.
거대한 switch문을 구성하는 case문마다 함수 추출하기를 적용해서 각 case의 본문을 함수 호출문 하나로 바꾼다.
같은 조건을 기준으로 나뉘는 switch문이 여러 개 있다면 조건부 로직을 다형성으로 바꾸기를 적용한다.
반복문도 그 안의 코드와 함께 추출해서 독립된 함수로 만든다.
3.4 긴 매개변수 목록
매개변수는 최대 한개 없으면 더 좋다.
이런 매개변수는 매개변수를 질의 함수로 바꾸기로 제거할 수 있다.
사용 중인 데이터 구조에서 값을 뽑아 각각을 별개의 매개변수로 전달하는 코드라면 객체 통째로 넘기기를 적용해서 원본 데이터 구조를 그대로 전달한다.
항상 함께 전달되는 매게변수들은 매개변수 객체 만들기로 하나로 묶어버린다.
함수의 동작 방식을 정하는 플래그 역할의 매개변수는 플래그 인수 제거하기로 없애준다.
클래스는 매개변수를 줄이는 데 효과적인 수단이기도 하다.
특히 여러 개의 함수가 특정 매개변수들의 값을 공통으로 사용할 때 유용하다.
이럴 때는 여러 함수를 클래스로 묶기를 이용하여 공통 값들을 클래스의 필드로 정의한다.
3.5 전역 데이터
전역 데이터 사용에 주의해야 한다는 말을 정말 많이 들었다.
이를 함부로 사용하는 프로그래머에겐 지옥에 간다는..?
코드의 악취, 안티 패턴의 최고의 속하는 전역 데이터.
이것이 문제를 발생시키는 항목은 정말 많지만 일단 어디서든 접근 가능하다는 것 만으로도 매우 큰 취약점이다.
이를 방지하기 위해 우리가 사용하는 대표적인 리팩터링은 변수 캡슐화하기다.
다른 코드에서 오염시킬 가능성이 있는 데이터를 발견할 때마다 이 기법을 가장 먼저 적용한다.
역설적으로 데이터 자체를 private으로 두고 필요에 의해 개방하는 방법이 있을 듯 하다
3.6 가변 데이터
데이터를 변경했더니 예상하지 못한 버그가 나올 수 있다.
코드의 다른 곳에서는 다른 값을 기대한다는 사실을 인식하지 못한 채 수정해버리면 프로그램이 오작동한다.
특히 이 문제가 아주 드문 조건에만 발생한다면 알아내기가 매우 어렵다..
따라서 불변데이터를 기본으로 사용하기를 추천한다.
변수 캡슐화하기를 적용하여 함수를 거쳐야만 수정할 수 있도록 하거나 변수 쪼개기를 통해 용도별로 독립변수를 저장하게 하여 값 갱신이 문제를 일으킬 여지를 없앤다.
갱신 로직은 다른 코드와 떨어뜨려 놓는 것이 좋다. 그러기 위해 문장 슬라이드하기와 함수 추출하기를 이용해서 무언가를 갱신하는 코드를 분리한다.
가능한 세터 제거하기도 적용한다.
값을 다른 곳에서 설정할 수 있는 가변 데이터가 풍기는 악취는 특히 고약하므로 이럴 때는 파생 변수를 질의 함수로 바꾸기를 적용한다.
3.7 뒤엉킨 변경
소프트웨어는 자고로 소프트해야 마땅하다.
코드를 수정할 때는 시스템에서 고쳐 할 군데를 찾아서 그 부분만 수정하 수 있기를 바라기 때문
SRP
지원해야할 데이터베이스가 추가될 때마다 함수 세 개를 변경해야 한다면 SRP를 위반하고 뒤엉킨 변경이 발생했다는 뜻이다.
데이터베이스에서 데이터를 가져와서 금융 상품 로직에서 처리해야 하는 일처럼 순차적으로 실행되는 게 자연스러운 맥락이라면, 다음 맥락에 필요한 데이터를 특정한 데이터 구조에 담아 전달하는 식으로 구분한다.
- 단계 쪼개기
전체 처리 과정 곳곳에서 각기 다른 맥락의 함수를 호출하는 빈도가 높다면, 각 맥락에 해당하는 적당한 모듈들을 만들어서 관련 함수들을 모은다.
- 함수 옮기기
그러면 처리 과정이 맥락별로 구분된다.
이 때 맥락의 일에 관여하는 함수가 있다면 옮기기 전에 함수 추출하기부터 수행한다.
모듈이 클래스라면 클래스 추출하기가 맥락별 분리 방법을 잘 안내해줄 것이다.
3.8 산탄총 수술
이 냄새는 뒤엉킨 변경과 비슷하면서도 정반대다.
이 냄새는 코드를 변경할 때마다 자잘하게 수정해야 하는 클래스가 많을 때 풍긴다.
변경할 부분이 코드 전반에 퍼져 있다면 찾기도 어렵고 꼭 수정해야 할 곳을 지나치기 쉽다.
이럴 때는 함수 옮기기와 필드 옮기기로 한 모듈에 묶어버리면 좋다.
비슷한 데이터를 다루는 함수가 많다면 여러 함수를 클래스로 묶기를 적용한다.
데이터 구조를 변환하거나 보강하는 함수들에는 여러 함수를 변환 함수로 묶기를 적용한다.
이렇게 묶은 함수들의 출력 결과를 묶어서 다음 단계의 로직으로 전달할 수 있다면 단계 쪼개기를 적용한다.
어설프게 분리된 로직을 함수 인라인하기나 클래스 인라인하기같은 인라인 리팩터링으로 하나로 합치는 것도 산탄총 수술에 대처하는 좋은 방법이다.
메서드나 클래스가 비대해지지만, 나중에 추출하기 리팩터링으로 더 좋은 형태로 분리할 수 있다.
사실 우리는 작은 함수와 클래스에 지나칠 정도로 집착하지만 코드를 재구성하는 단계에서는 큰 덩어리에 집착하지 않는다.
3.9 기능 편애
프로그램을 모듈화할 때는 코드를 여러 영역으로 나눈 뒤 영역안에서 이뤄지는 상호작용은 최대한 늘리고 영역 사이에서 이뤄지는 상호작용은 최소로 줄이는 게 주력한다.
기능 편애는 흔히 어떤 함수가 자기가 속한 모듈의 함수나 데이터보다 다른 모듈의 함수나 데이터와 상호작용할 일이 더 많을 때 풍기는 냄새다.
한 객체가 게터 메서드 대 여섯개를 호출하도록 작성된 함수같은 경우는 함수가 데이터와 가까이 있고 싶어한다는 의중이 드러나므로 데이터 근처로 옮겨주면 된다. 함수 옮기기
때로는 기능을 편애할 수 있다.
이럴 때는 그 부분만 독립함수로 빼낸 다음 원하는 모듈로 보내준다.
물론 어디로 옮길지가 명확하게 드러나지 않을 때도 있다.
예컨대 함수가 사용하는 모듈이 다양하다면 가장 많은 데이터를 포함한 모듈로 옮긴다.
이런 규칙과 다르게 전략 패턴과 방문자 패턴은 함께 변경할 대상을 한데 모으는 것으로 데이터와 이를 활용하는 동작은 함께 변경해야 할 때가 많지만, 같은 데이터를 다르는 코드를 한 곳에서 변경할 수 있도록 옮긴다.
3.10 임시 필드
데이터 항목들은 서너 개가 여러 곳에서 항상 함께 뭉쳐 다니는 모습을 흔히 목격할 수 있다.
클래스 두어 개의 필드에서, 혹은 여러 메서드의 시그니처에서 함께 발견되기도 한다.
가장 먼저 필드 형태의 데이터 뭉치를 찾아서 클래스 추출하기로 하나의 객체로 묶는다.
다음은 메서드 시그니처에 있는 데이터 뭉치 차례이다.
먼저 매개변수 객체 만들기나 객체 통째로 넘기기를 적용해서 매개변수 수를 줄여본다.
그 즉시 메서드의 호출이 간결해질 것이다.
데이터 뭉치인지 확인하는 방법으로 값 하나를 삭제했을 때 나머지 데이터만으로는 의미가 없다면 객체로 환생하길 갈망하는 데이터 뭉치라는 뜻이다.
기능 편애를 없애는 과정에서 새로운 클래스를 만들었다면 그 클래스로 옮기면 좋을 동작은 없는지 확인해본다.
이러한 연계 과정은 상당한 중복을 없애고 향후 개발을 가속하는 유용한 클래스를 탄생시키는 결과로 이어지기도 한다.
3.11 기본형 집착
대부분의 프로그래밍 언어는 정수, 부동소수점 수, 문자열 같은 다양한 기본형을 제공한다.
라이브러리를 통해 날짜 같은 간단한 객체를 추가로 제공하기도 한다.
한편 프로그래머 중에는 자신에게 주어진 문제에 딱 맞는 기초 타입을 직접 정의하기를 몹시 꺼리는 사람이 많다.
그래서 금액을 그냥 숫자로 표현하거나, 날짜를 문자열로 표현하거나, 전화번호를 문자열로 표현하는 식이다.
이 냄새는 문자열을 다루는 코드에서 특히 흔하게 보인다.
기본형을 객체로 바꾸기를 적용하면 기본형만이 거주하는 구석기 동굴을 의미 있는 자료형들이 사는 최신식 코드로 탈바꿈할 수 있다.
3.12 반복되는 switch문
많은 사람들이 switch문은 모조리 조건부 로직을 다형성으로 바꾸기로 없애야 할 대상이라고 주장한다.
중복된 switch문이 문제가 되는 이유는 조건절을 하나 추가할 때마다 다른 switch문들도 모두 찾아서 함께 수정해야 하기 때문이다.
이럴 때 다형성은 반복된 switch문이 내뿜는 사악한 기운을 제압하여 코드베이스를 최신 스타일로 바꿔주는 세련된 무기인 셈이다.
3.13 반복문
반복문은 프로그래밍 언어가 등장할 때부터 함께한 프로그래밍 요소이다.
이제는 일급 함수를 지원하는 언어가 많아져서 반복문을 파이프라인으로 바꾸기를 적용해서 시대에 걸맞지 않은 반복문을 제거할 수 있게 되었다.
Rx프로그래밍
3.14 성의 없는 요소
우리는 코드의 구조를 잡을 때 프로그램 요소를 이용하는 걸 좋아한다.
그래야 그 구조를 변형하거나 재활용할 기회가 생기고, 혹은 단순히 더 의미 있는 이름을 가졌기 때문이다.
그렇지만 그 구조가 필요 없을 때도 있다.
본문 코드를 그대로 쓰는 것과 진배없는 함수도 있고, 실질적인 메서드가 하나인 클래스도 있다.
아마 범용성이나 미래를 예측하여 제작하였지만 사용되지 않아 남겨졌을 것이다.
이런 요소는 제거하는 것이 좋다
이 제거 작업은 함수 인라인하기나 클래스 인라인하기로 처리하며, 상속을 사용했다면 계층 합치기를 적용한다.
3.15 추측성 일반화
추측성 일반화는 우리가 민감하게 반응하는 냄새로 “나중에 필요할 거야”라는 생각으로 당장은 필요 없는 모든 종류의 후킹 포인트와 특이 케이스 처리 로직을 작성해둔 코드에서 풍긴다.
미래에 대비해 작성한 부분을 실제로 사용하게 되면 다행이지만, 그렇지 않는다면 쓸데없이 낭비일 뿐이다.
당장 걸리적거리는 코드는 눈앞에서 치워버려라
하는 일이 거의 없는 추상 클래스는 계층 합치기로 제거한다.
쓸데없이 위임하는 코드는 함수 인라인하기나 클래스 인라인하기로 삭제한다.
본문에서 사용되지 않는 매개변수는 함수 선언 바꾸기로 없앤다.
추측성 일반화는 테스트 코드 말고는 사용하는 곳이 없는 함수나 클래스에서 흔히 볼 수 있다.
이런 코드를 발견하면 테스트 케이스부터 삭제한 뒤에 죽은 코드 제거하기로 날려버린다.
3.16 임시 필드
간혹 특정 상황에서만 값이 설정되는 필드를 가진 클래스도 있다.
하지만 객체를 가져올 때는 당연히 모든 필드가 채워져 있으리라 기대하는 게 보통이다.
이렇게 임시 필드를 갖도록 작성하면 코드를 이해하기 어렵다.
그래서 사용자는 쓰이지 않는 것처럼 보이는 필드가 존재하는 이유를 파악하느라 머리를 싸메게 된다.
이렇게 덩그러니 떨어져 있는 필드르을 발견하면 클래스 추출하기로 제 살 곳을 찾아준다.
그런 다음 함수 옮기기로 임시 필드들과 관련된 코드를 모조리 새 클래스에 몰아넣는다.
또한, 임시 필드들이 유요한지 확인한 후 동작하는 조건부 로직이 있을 수 있는데, 특이 케이스 추가하기로 필드들이 유요하지 않을 때를 위한 대안 클래스를 만들어서 제거할 수 있다.
3.17 메시지 체인
메세지 체인은 클라이언트가 한 객체를 통해 다른 객체를 얻은 뒤 방금 얻은 객체에 또 다른 객체를 요청하는 식으로, 다른 객체를 요청하는 작업이 연쇄적으로 이어지는 코드를 말한다.
기차충돌
게터의 꼬리를 물고 임시 변수들이 줄줄이 나열되는 코드가 있는데, 이는 클라이언트가 객체 내비게이션에 종속됐음을 의미한다.
그래서 내비게이션 중간 단계를 수정하면 클라이언트도 수정해야 한다.
SRP위반
이 문제는 위임 숨기기로 해결한다.
이 리팩터링은 메시지 체인의 다양한 연결점에 적용할 수 있다.
원칙적으로 체인을 구성하는 모든 객체에 적용할 수 있지만, 그러다 보면 중간 객체들이 모두 중재자가 돼버리기 쉽다.
그러니 최종 결과 객체가 어떻게 쓰이는지부터 살펴보는 게 좋다.
직접 해당 객체에 접근하는 것이 아닌 해당 객체의 존재를 숨김으로 중재자가 되는 것이다.
3.18 중개자
객체의 대표적인 기능 중 하나로, 외부로부터 세부사항을 숨겨주는 캡슐화가 있다.
캡슐화의 과정에선 위임이 자주 활용되는데, 예를 들어 우리가 팀장에게 미팅을 요청한다고 하자면, 팀장은 자신의 일정을 확인한 후 답을 준다.
이후 팀장이 어떤 방법으로 일정을 기록하는지는 신경쓰지 않는다.
이런 중개자도 지나치면 문제가 된다.
클래스가 제공하는 메서드 중 절반이 다른 클래스에 구현을 위임하고 있다면 코드를 제대로 읽을 수 없게 된다.
이럴 때는 중개자 제거하기를 활용하여 실제로 일을 하는 객체와 직접 소통하게 하자.
3.19 내부자 거래
모듈 사이의 데이터 거래가 많으면 결합도가 높아진다고 말하는데, 일이 제대로 돌아가게 하려면 거래가 이뤄질 수 밖에 없지만, 그 양을 최소로 줄이고 모두 투명하게 처리해야 한다.
커피 자판기 옆에서 은밀히 데이터를 주고받는 모듈들이 있다면 함수 옮기기와 필드 옮기기 기법으로 떼어놓아서 사적으로 처리하는 부분을 줄인다.
여러 모듈이 같은 관심사를 공유한다면 공통 부분을 정식으로 처리하는 제 3자의 모듈을 새로 만들거나 위임 숨기기를 이용하여 다른 모듈이 중간자 역할을 하게 만든다.
3.20 거대한 클래스
한 클래스가 너무 많은 일을 하려다 보면 필드 수가 상당히 늘어난다.
그리고 클래스에 필드가 너무 많으면 중복 코드가 생기기 쉽다.
이럴 때는 클래스 추출하기로 필드들 일부를 따로 묶는다.
같은 컴포넌트에 모아두는 것이 합당해 보이는 필드들을 선택하면 된다.
더 일반적으로는 한 클래스 안에서 접두어나 접미어가 같은 필드들이 함께 추출할 후보들이다.
이렇게 분리할 컴포넌트를 원래 클래스와 상속 관계로 만드는 게 좋다면 슈퍼클래스 추출하기나 타입 코드를 서브클래스로 바꾸기를 적용하는 편이 더 쉬울 것이다.
클래스가 항시 모든 필드를 사용하지 않을 수도 있다.
이럴 때는 앞에서 언급한 추출 기법을 여러 차례 수행해야 할지도 모른다.
코드도 마찬가지로 너무 길다면 이는 중복 코드가 발생활 확률이 높다는 것을 의미한다.
3.21 서로 다른 인터페이스의 대안 클래스들
클래스를 사용할 때의 큰 장점은 필요에 따라 언제든 다른 클래스로 교체할 수 있다는 것이다.
단 교체하려면 인터페이스가 같아야 한다.
따라서 함수 선언 바꾸기로 메서드 시그니처를 일치시킨다.
때로는 이것만으로 부족한데, 이럴 때는 함수 옮기기를 이용하여 인터페이스가 같아질 때까지 필요한 동작들을 클래스 안으로 밀어 넣는다.
그러다 대안 클래스들 사이에 중복 코드가 생기면 슈퍼클래스 추출하기를 적용할지 고려해본다.
3.22 데이터 클래스
데이터 클래스란 데이터 필드와 게터/세터 메서드로만 구성된 클래스를 말한다.
그저 데이터 클래스가 너무 깊이까지 함부로 다룰 때가 많다.
이런 클래스에 public 필드가 있다면 레코드 캡슐화하기로 숨기자
변경하면 안되는 필드는 세터 제거하기로 접근을 봉쇄한다.
C#에서 record라는 타입이 있는데 9버전에서 도입된 나름 신기술이다.
class와 같이 참조형식이지만 구조체의 특징도 같이 가지고 있다.
불변 객체를 보장할 수 있기 때문에 공부해보는 것도 좋을 것 같다.
3.23 상속 포기
상속에 관한 논쟁은 아주 뜨겁지만 최근에는 상속을 피하는 것으로 많이 넘어갔다고 생각된다.
최근에 읽은 책 모두 상속을 피하고 합성과 구성에 집중하고 있다.
서브클래스가 부모로부터 메서드와 데이터를 물려받고 싶지 않아한다면 그것이 가능할까?
먼저 같은 계층에서 서브클래스를 하나 새로 만들고, 메서드 내리기와 필드 내리기를 통해서 물려받지 않을 부모 코드를 모조리 새로 만든 서브클래스로 넘긴다.
그러면 부모에는 공통된 부분만 남는다.
3.24 주석
주석을 달면 안된다고 말하려는 건 아니니 걱정하지 말자??
주석을 다는 것도 좋지만 좋은 코드로 보여주는 것이 더 바람직하다.
주석은 오히려 가독성을 떨어뜨리고 코드를 복잡하게 만든다.
일종의 변명에 불과하다고 말한다.
주석을 남겨야겠다는 생각이 들면, 가장 먼저 주석이 필요 없는 코드로 리팩터링해본다.
느낀점
정말 이 부분은 클린코드의 연장선이자 같은 내용이라 생각했다.
읽은지 얼마 되지도 않아서 지식을 정리하는데 큰 도움이 된 듯하다.
지금은 가볍게 읽고 뒤의 세부 카탈로그를 보고 다시 읽어보는 것도 좋을 둣 하다.
논의사항
읽으시면서 느끼거나 본 가장 심한 악취는 뭐였나요?
4. 테스트 구축하기
리팩터링은 가치있는 도구이지만, 제대로 활용하려면 실수를 잡아주는 견고한 테스트 스위트가 필요하다.
리팩터링을 꼭하지 않더라도 테스트 코드 작성은 필요하다. 전체적인 개발효율을 높여준다.
4.1 자가 테스트 코드의 가치
실상 프로그래머는 코드를 짜는 시간은 얼마 되지 않는다.
읽거나 고민하는 시간이 훨씬 더 많다.
사실 버그를 해결하는 것은 금방 해결되지만 가장 끔찍한 것은 버그를 찾는 과정이다.
이런 과정에서 사이드 이펙트가 발생하여 다른 버그를 양산할 수 있다..
테스트 코드를 직접 작성하고 이를 하나씩 확인하는 과정에서 지금의 Junits나 NUnit 같은 테스트 프레임워크가 탄생하게 되었다.
모든 테스트를 완전히 자동화하고 그 결과까기 스스로 검사하게 만들자
테스트를 작성하기 가장 좋은 시간은 프로그래밍을 시작하기 전이다.
예를 들어 기능을 추가하기 전에 테스트부터 작성한다면, 원하는 기능을 추가하기 위해 무엇이 필요한지 고민하게 된다.
구현보다 인터페이스에 집중하게 된다는 장점이 있다.
또한 테스트 코드가 작성되어 있다면 작업이 완료되는 시점을 정확하게 판단할 수 있다.(테스트가 다 통과한 시점)
이게 조금 더 극단적으로 가면 테스트 주도 개발(TDD)이다.
이후 TDD관련 책을 읽어도 좋을 것 같다는 생각
4.2 테스트할 샘플 코드
이 코드도 1장에서 내가 C#
으로 변경해서 작업한 것과 같이 내 입맛에 맞춰서 실습해 볼 예정이다.
비즈니스 로직 코드는 클래스 두 개로 구성되며, 하나는 생산자를 표현하는 Producer이고, 다른 하나는 지역 전체를 표현하는 Province이다.
마찬가지로 json import과정을 생략하고 main에서 객체로 넣는 방식을 채택
- 테스트할 샘플 코드는
C# windowforms으로 제작
- 자바스크립트에서 C#으로 변환이기에 어색한 부분이 있음
public class Producer
{
private Province province;
private string name;
private int cost;
private int production;
public Producer(Province province, string name, int cost, int production)
{
this.province = province;
this.name = name;
this.cost = cost;
this.production = production;
}
public string GetName()
{
return name;
}
public int GetCost()
{
return cost;
}
public void SetCost(int arg)
{
cost = arg;
}
public int GetProduction()
{
return production;
}
public void SetProduction(int arg)
{
production = arg;
// 수정
}
}
public class Province
{
private string name;
private List<Producer> producers = new List<Producer>();
private int totalProduction;
private int demand;
private int price;
public Province(string name, int demand, int price)
{
this.name = name;
this.demand = demand;
this.price = price;
this.totalProduction = 0;
}
public void AddProducer(Producer arg)
{
producers.Add(arg);
totalProduction += arg.GetProduction();
}
public string GetName()
{
return name;
}
public int GetTotalProduction()
{
return totalProduction;
}
public void SetTotalProduction(int arg)
{
totalProduction = arg;
}
public int GetDemand()
{
return demand;
}
public void SetDemand(int arg)
{
demand = arg;
}
public int GetPrice()
{
return price;
}
public void SetPrice(int arg)
{
price = arg;
}
public List<Producer> GetProducers()
{
return producers;
}
public int Shortfall()
{
return demand - totalProduction;
}
public int Profit()
{
return DemandValue() - DemandCost();
}
public int DemandValue()
{
return satisfiedDemand() * price;
}
public int DemandCost()
{
int remainingDemand = demand;
int result = 0;
producers.Sort((a, b) => a.GetCost() - b.GetCost());
foreach (Producer p in producers)
{
int contribution = Math.Min(remainingDemand, p.GetProduction());
remainingDemand -= contribution;
result += contribution * p.GetCost();
}
return result;
}
public int satisfiedDemand()
{
return Math.Min(demand, totalProduction);
}
}
namespace Chapter04;
public partial class Form1 : Form
{
private Province province;
public Form1()
{
InitializeData();
InitializeComponent();
}
private void InitializeData()
{
province = new Province("Asia", 30, 20);
province.AddProducer(new Producer(province, "Byzantium", 10, 9));
province.AddProducer(new Producer(province, "Attalia", 12, 10));
province.AddProducer(new Producer(province, "Sinope", 10, 6));
}
}
namespace Chapter04;
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
private System.Windows.Forms.Label titleLabel = null;
private System.Windows.Forms.Label header_1 = null;
private System.Windows.Forms.Label header_2 = null;
private System.Windows.Forms.Label header_3 = null;
private Font titleFont = null;
private Font bodyFont = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
titleFont = new Font("Arial", 16, FontStyle.Bold);
bodyFont = new Font("Arial", 10, FontStyle.Regular);
this.components = new System.ComponentModel.Container();
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(600, 350);
this.Text = "리팩터링 Chapter04";
this.titleLabel = new System.Windows.Forms.Label();
this.titleLabel.Location = new System.Drawing.Point(10, 10);
this.titleLabel.Size = new System.Drawing.Size(500, 30);
this.titleLabel.Text = "지역:" + province.GetName();
this.titleLabel.Font = titleFont;
this.Controls.Add(this.titleLabel);
this.header_1 = new System.Windows.Forms.Label();
this.header_1.Location = new System.Drawing.Point(30, 60);
this.header_1.Size = new System.Drawing.Size(200, 20);
this.header_1.Text = $"수요: {province.GetDemand()} 가격: {province.GetPrice()}";
this.header_1.Font = bodyFont;
this.Controls.Add(this.header_1);
this.header_2 = new System.Windows.Forms.Label();
this.header_2.Location = new System.Drawing.Point(10, 120);
this.header_2.Size = new System.Drawing.Size(100, 20);
this.header_2.Text = $"생산자 수: {province.GetProducers().Count}";
this.header_2.Font = bodyFont;
this.Controls.Add(this.header_2);
// FlowLayoutPanel 생성
FlowLayoutPanel flowLayoutPanel = new FlowLayoutPanel();
flowLayoutPanel.FlowDirection = FlowDirection.TopDown;
flowLayoutPanel.Location = new System.Drawing.Point(30, 150);
flowLayoutPanel.Size = new System.Drawing.Size(1000, 150);
// FlowLayoutPanel에 Button 컨트롤 추가
for (int i = 0; i < province.GetProducers().Count ; i++)
{
Label label = new Label();
label.Text = $"{province.GetProducers()[i].GetName()}: 비용: {province.GetProducers()[i].GetCost()} 생산량: {province.GetProducers()[i].GetProduction()} 수익: {province.GetProducers()[i].GetCost() * province.GetProducers()[i].GetProduction()}";
label.Font = bodyFont;
label.Size = new System.Drawing.Size(300, 40);
flowLayoutPanel.Controls.Add(label);
}
this.Controls.Add(flowLayoutPanel); // 폼에 FlowLayoutPanel 추가
this.header_3 = new System.Windows.Forms.Label();
this.header_3.Location = new System.Drawing.Point(10, 300);
this.header_3.Size = new System.Drawing.Size(200, 20);
this.header_3.Text = $"부족분: {province.Shortfall()} 총수익: {province.Profit()}";
this.header_3.Font = bodyFont;
this.Controls.Add(this.header_3);
this.ResumeLayout(false);
}
#endregion
}
namespace Chapter04;
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new Form1());
}
}
4.3 첫 번째 테스트
위에서 작성한 코드를 테스트하기 위해선 먼저 테스트 프레임워크를 마련해야 한다.
C#의 경우 NUnit을 사용하면 된다.
dotnet add package NUnit
테스트 코드 작성 예시
namespace Province_Test;
public class Tests
{
private Province asia;
[SetUp]
public void SetUp()
{
asia = Chapter04.Program.SampleProvinceData();
}
[Test]
public void Shortfall()
{
Assert.AreEqual(5, asia.Shortfall());
}
}
책에서 말한 픽스처(고정장치)는 SetUp, Init과정으로 위 처럼 테스트를 위해 사용할 객체를 초기화하는 과정이라 생각된다.
테스트 코드 실행 결과
1단계 픽스처(여기선 SetUp, 클래스쪽에 애트리뷰트로 다는 경우)
2단계 해당 픽스처의 속성들을 검증한다.
실패해야 할 상황에서는 반드시 실패하게 만들자
문제없이 통과했다는 것은 좋은 일이지만 테스트 코드의 정확성을 위해 오류를 주입해보는 것도 좋은 방법이다.
자주 테스트하라. 작성 중인 코드는 최소한 몇 분 간격으로 테스트하고, 적어도 하루에 한 번은 전체 테스트를 돌려보자
지금은 한개의 테스트이지만 실전은 수백, 수천개의 테스트 코드가 존재할 수 있다.
이런 경우에 내가 지금 사용하는 것과 같이 CLI의 과정은 좋지 못한 것 같다.
Extension에서 GUI테스트 코드 확장을 설치하여 NUnit의 기능을 좀 더 활용해보는 것도 좋을 것 같다.
ClI에서 GUI로 변경
위 GUI와 같이 테스트를 자주 실행하고 간편하게 확인한다면 무언가 실패했을 때 금방 확인할 수 있다.
간결한 피드백은 자가 테스트에서 매우 중요하다.
4.4 테스트 추가하기
이번에는 클래스가 하는 일을 모두 살펴보고 각각의 기능에서 오류가 생길 수 있는 조건을 하나씩 테스트하는 식으로 진행한다.
일부 프로그래머가 좋아하는 public메서드를 빠짐없이 테스트하는 방식과는 다르다.
테스트는 위험 요인을 중심으로 작성해야 하며, 테스트의 목적은 어디까지나 현재 혹은 향후에 발생하는 버그를 찾는 데 있다.
완벽하게 만드느라 테스트를 수행하지 못하느니, 불완전한 테스트라도 작성해 실행하는 편이 낫다.
저자는 테스트 코드의 기대값을 설정할 때 오류 주입을 통해 확인한다.
namespace Province_Test;
[TestFixture]
public class Tests
{
private Province asia;
[SetUp]
public void SetUp()
{
asia = Chapter04.Program.SampleProvinceData();
}
[Test]
public void Shortfall()
{
Assert.AreEqual(5, asia.Shortfall());
}
[Test]
public void Profit()
{
Assert.AreEqual(230, asia.Profit());
}
}
지금까지 작성된 두 테스트에는 문제점이 하나 있다.
같은 객체를 공유하기 때문에 테스트의 순서에 따라 결과가 달라지거나 불변을 보장하지 못하기에 문제가 될 수 있다는 점이다.
하지만 C# Nunit
의 경우 [SetUp] 애트리뷰트를 통해 테스트를 실행하기 전 매번 초기화를 진행한다.
1회만 하고 싶다면 [OneTimeSetUp]을 붙이면 된다.
불변이 정말로 보장된다면 공유 픽스처를 사용할 수 있지만 애초에 테스트에서 픽스처 값을 변경하지 못하도록 하는 것이 중요하다.
따라서 가장 쉬운 방법이 위와 같이 테스트 전에 한번 씩 픽스처를 만드는 것이다.
4.5 픽스처 수정하기
지금까지 작성한 테스트 코드를 통해 픽스처를 불러와 그 속성을 확인하는 방법을 알 수 있었다.
그런데 실전에서는 사용자가 값을 변경하면서 픽스처의 내용도 수정되는 경우가 흔하다.
이런 수정은 대부분 세터에서 이뤄지는데, 세터는 보통 아주 단순하여 버그가 생길 일도 없으니 잘 테스트하지 않는다.
하지만 복잡한 동작이 포함되어 있다면 테스트해볼 필요가 있다.
SetUp에서 설정
한 표준 픽스처를 취해서, 테스트를 수행
하고, 이 픽스처가 기대한 대로 처리했는지 검증
한다.
이를 준비(arrange), 실행(act), 단언(assert)이라고 부른다.
4.6 경계 조건 검사하기
지금까지 작성한 테스트는 모든 일이 순조롭고 사용자의 의도대로 사용하는, 일명 꽃길
상황에 집중하였다.
그런데 이 범위를 벗어나는 경계 지점에서 문제가 생기면 어떤 일이 벌어지는지 확인하는 테스트도 함께 작성하면 좋다.
생산자가 없거나 수요가 없거나 수요가 마이너스인 경우와 같이 경계 조건을 테스트하는 것이다.
여기서 드는 의문으로 수요가 음수일 때 수익이 음수가 나온다는 것이 이 프로그램을 사용하는 고객관점에서 말이 되는 소리일까?
이처럼 경계를 확인하는 테스트를 작성해보면 특이 상황, 예외적인 상황에 대해 더 잘 이해할 수 있다.
문제가 생길 가능성이 있는 부분을 생각해보고 그 부분을 집중적으로 테스트하자
실패
와 에러
의 차이를 명확하게 하는 것도 중요하다.
실패란 검증 단계에서 실제 값이 예상 범위를 벗어났다는 뜻이고, 에러는 성격이 조금 다르다.
검증보다 앞선 과정에서 발생한 예외 상황을 말한다.
그렇다면 테스트를 어느 수준까지 해야 할까?
아무리 꼼꼼한 테스트를 하더라도 버그 없는 완벽한 프로그램을 만들 수는 없다.
반면 테스트가 프로그래밍 속도를 높여준다는 사실에는 변함이 없다.
정말 많은 테스트 기법들이 있지만 너무 몰입할 필요는 없다..!
또한 테스트에 너무 집중하여 작성하다 보면 오히려 의욕이 떨어질 수 있기 때문에 위험한 부분에 집중하는 것이 좋다.
4.7 끝나지 않은 여정
이 책의 주제는 테스트가 아닌 리팩터링이다. (테스트 내용만 따로 책으로 읽는 걸 추천,)
테스트에 관한 내용은 방대하고 계속 변하가기에 처음부터 완벽한 테스트 코드를 작성하려고 하지 말자.
테스트 과정 또한, 배워가는 과정 모두 리팩터링의 일부라고 생각하자.
느낀점
논의사항으로 대체
논의사항
저번 논의사항에서도 말했지만 테스트 코드를 제대로 활용하기 위해선 객체지향적인 스킬이 먼저 선행되어야 한다고 생각합니다.
물론 테스트 코드를 작성하다 보면 객체지향적으로 코딩하게 되는 좋은 선순환이 있지만 그전에 제대로 된 이해가 되어야 할 것 같습니다.
좋은 코드, 나쁜 코드
에서 읽은 내용인데 테스트 코드를 짤 때 빠지는 함정이 스스로 짠 로직에서 통과할 수 있는 케이스만 만드는 것에 대한 내용이 나오는데 그 당시에는 이해가 되지 않았지만 작성하다 보니 무슨말인지 알겠더라구요
마찬가지로 스스로 테스트 코드를 작성하실 때 조심해야하는 부분, 포인트가 있을까요?
비슷한 내용으로 ‘실패해야 할 상황에서는 반드시 실패하게 만들자’가 있었습니다.
5. 리팩터링 카탈로그 보는 법
이 장을 기준으로 다양한 리팩터링 기법을 하나씩 소개한다.
내용이 워낙 많다보니 이 카탈로그를 보는 법을 활용하여 필요할 때마다 읽어보는 것을 추천.
5.1 리팩터링 설명 형식
카탈로그의 리팩터링 기법들은 일정한 형식을 갖춰 정리했다.
각 기법에 대한 설명은 다음 다섯 항목으로 구성된다.
- 가장 먼저 이름이 나온다. 이름은 리팩터링 용어를 구축하는 데 중요하다. 책 전반에서 리팩터링을 이 이름으로 지칭한다. 같은 기법을 다르게 부르는 경우도 있기 때문에 그중 흔한 이름도 함께 소개한다.
- 다음으로 리팩터링의 핵심 개념을 간략히 표현한 개요(개념도 + 코드 예시)가 나온다. 원하는 리팩터링을 찾을 때 도움될 것이다.
- 그 다음에 나오는 배경은 해당 리팩터링이 왜 필요한지와 그 기법을 적용하면 안 되는 상황을 설명한다.
- 이어서 나오는 절차는 리팩터링하는 과정을 단계별로 제시한다.
- 마지막의 예시는 해당 리팩터링 기법을 실제로 적용하는 간다한 예와 그 예시를 보여준다.
개요
개요는 리팩터링 전후로 코드가 어떻게 달라지는지 보여준다.
수행 과정을 설명하기 위해서가 아닌 나중에 다시 찾아볼 때 이 기법이 어떤 것인지 쉽게 떠올리기 위한 것이다.
시각 장치
절차
절차는 오랜만에 적용하는 리팩터링의 구체적인 단계를 잊지 않도록 개인 노트에 기록해둔 것이다.
그래서 압축된 표현이 많고 단계를 왜 그렇게 구성했는지 설명이 없을 때도 있다.
제대로된 설명은 예시에서 풀어준다.
예시
예시는 매우 간단한 사례들로 준비되어 있다.
주의를 분산시키지 않고 리팩터링에 집중할 수 있도록 하기 위해서다.
5.2 리팩터링 기법 선정 기준
이 카탈로그는 완벽하지 않다. 저자 기준 유용한 것들로 구성되어 있다.
따라서 읽는 사람의 판단이 가장 중요..!
느낀점
논의사항
상당히 짧은 챕터인데 리팩터링에 대한 카탈로그보다 사실 이런 문서화, 가이드가 되게 마음에 들었습니다.
정리된 형식에 맞게 이름, 개요, 배경, 절차, 예시로 보여주고 모든 내용이 통일된 형식이 마음에 들었습니다.
마찬가지로 저도 최대한 레포마다 규칙을 통일하고 해당 내용을 문서화하려고 노력하는데 다른 분들은 이런 가이드라인을 만드신 경험이 있을까요?
개인적으로 이런 C레벨 작업을 좋아합니다..!
6. 기본적인 리팩터링
카탈로그의 첫머리는 가장 기본적이고 많이 사용해서 제일 먼저 배워야 하는 리팩터링들로 시작한다.
6.1 함수 추출하기(Extract Function)
- 반대 리팩터링: 함수 인라인하기
void PrintOwing()
{
PrintBanner();
// 미해결 채무(outstanding)를 계산한다.
var outstanding = CalculateOutstanding();
// 세부 사항을 출력한다.
Console.WriteLine($"고객명: {name}");
Console.WriteLine($"채무액: {outstanding}");
}
void PrintOwing()
{
PrintBanner();
PrintDetails(GetOutstanding());
}
void PrintDetails(double outstanding)
{
Console.WriteLine($"고객명: {name}");
Console.WriteLine($"채무액: {outstanding}");
}
배경
함수 추출하기는 매우 많이 사용되는 리팩터링 중 하나로 코드 조각을 찾아 무슨 일을 하는지 파악한 다음, 독립된 함수로 추출하고 목적에 맞는 이름을 붙인다.
여기서 함수란, 객체지향 언어에서는 메서드, 절차형 언어의 프로시저/서브루틴에도 똑같이 해당되는 말이다.
코드를 언제 독립된 함수로 묶어야 할지에 관한 의견은 수없이 많다.(길이, 중복, 화면 기준 등등)
하지만 목적과 구현을 분리하는 방식이 가장 합리적인 기준으로 생각된다.
코드를 보고 무슨일을 하는지 한참 걸린다면 그 부분을 함수로 추출한 뒤 ‘무슨 일’에 걸맞는 이름을 짓는다.
이렇게 해두면 나중에 코드를 다시 읽을 때 함수의 목적이 눈에 확 들어오고, 본문 코드(그 함수가 목적을 이루기 위해 구체적으로 수행하는 일)에 대해서는 더 이상 신경 쓸 일이 거의 없다.
짧은 함수를 만드는 것에 두려움을 가지면 안된다.
이런 짧은 함수의 이점은 이름을 잘 지어야만 발휘되므로 이름 짓기에 특별히 신경 써야 한다.
이름을 잘 짓기까지는 어느 정도 훈련이 필요하다.
하지만 일단 요령을 터득한 후에는 별도 문서 없이 코드 자체만으로 내용을 충분히 설명되게 만들 수 있다.
절차
- 함수를 새로 만들고 목적을 잘 드러내는 이름을 붙인다. (‘어떻게’가 아닌 ‘무엇을’ 하는지가 드러나야 한다.)
- 대상 코드가 함수 호출문 하나처럼 매우 간단하더라도 함수로 뽑아서 목적이 더 잘 드러나는 이름을 붙일 수 있다면 추출한다.
- 이런 이름이 떠오르지 않는다면 함수로 추출하면 안된다는 신호다.
- 하지만 추출하는 과정에서 좋은 이름이 떠오를 수도 있으니 처음부터 최선의 이름부터 짓고 시작할 필요는 없다. 그 과정에서 조금이라도 깨달은 게 있다면 시간 낭비는 아니다.
- 추출할 코드를 원본 함수에서 복사하여 새 함수에 붙여넣는다.
- 추출할 코드 중 원본 함수의 지역 변수를 참조하거나 추출한 함수의 유효범위를 벗어나는 변수는 없는지 검사한다. 있다면 매개변수로 전달한다.
- 원본 함수의 중첩 함수로 추출할 때는 이런 문제가 생기지 않는다.
- 일반적으로 함수에는 지역 변수와 매개변수가 있기 마련이다. 가장 일반적인 처리 방법은 모두 인수로 전달하는 것(그렇게 매력적인 방법은 아니라고 판단된다.)
- 추출한 코드에서만 사용하는 변수가 추출한 함수 밖에 선언되어 있다면 추출한 함수 안에서 선언 하도록 수정한다.
- 변수를 다 처리했다면 컴파일한다.
- 컴파일되는 언어로 개발 중이라면 변수를 모두 처리하고 나서 한번 컴파일해보자. 제대로 처리하지 못한 변수를 찾는 데 도움될 때가 많다.
- 원본 함수에서 추출한 코드 부분을 새로 만든 함수를 호출하는 문장으로 바꾼다(즉, 추출한 함수로 일을 위임한다.)
- 테스트한다.
- 다른 코드에 방금 추출한 것과 비슷한 코드가 없는지 살핀다. 있다면 방금 추출한 새 함수를 호출하도록 바꾼다.
- 중복 혹은 비슷한 코드를 찾아주는 리팩터링 도구도 있다.
예시: 유효범위를 벗어나는 변수가 없을 때
아주 간단한 코드여서 함수 추출하기가 쉽다. (해당 로직에서 완전 분리가 가능한 형태로 출력 -> 목적과 구현이 분리되어 있다.)
예시: 지역 변수를 사용할 때
지역 변수와 관련하여 가장 간단한 경우는 변수를 사용하지만 다른 값을 다시 대입하지는 않을 때다.
이 경우에는 지역 변수들을 그냥 매개변수로 넘기면 된다.
예시: 지역 변수의 값을 변경할 때
지역 변수에 값을 대입하게 되면 문제가 복잡해진다.
지금은 임시 변수만을 취급하겠지만 만약 매개변수에 값을 대입하는 코드를 발견하면 곧바로 그 변수를 쪼개서 임시 변수를 새로 하나 만들어 그 변수에 대입하게 한다.
대입 대상이 되는 임시 변수는 크게 두 가지로 나눌 수 있다.
먼저 간단한 경우는 변수가 추출된 코드안에서만 사용될 때다.
즉, 이 변수는 추출된 코드 안에서만 존재한다.
이보다 특이한 경우는 변수가 추출한 함수 밖에서 사용될 때다.
이럴 때는 변수가 대입된 새 값을 반환해야 한다.
void PrintOwing()
{
PrintBanner();
var outstanding = GetOutstanding();
PrintDetails(outstanding);
}
void PrintBanner()
{
Console.WriteLine("*****************");
Console.WriteLine("**** 고객 채무 ****");
Console.WriteLine("*****************");
}
double GetOutstanding()
{
double result = 0;
foreach (var o in _orders)
{
result += o.Amount;
}
return result;
}
void PrintDetails(double outstanding)
{
Console.WriteLine($"고객명: {name}");
Console.WriteLine($"채무액: {outstanding}");
}
값을 반환할 변수가 여러 개라면?
이 부분은 함수가 값 하나만 반환하는 방식이 좋은 코드에 가깝기 때문에 코드 자체를 재구성하는 방향으로 가는 것이 좋다.
굳이 한 함수에서 여러 값을 반환해야 한다면 레코드나 튜플로 반환할 수 있지만 단계를 작게 나누고 작게 쪼개는 방식이 더 좋다.
6.2 함수 인라인하기(Inline Function)
- 반대 리팩터링: 함수 추출하기
double GetRating()
{
return (MoreThanFiveLateDeliveries()) ? 2 : 1;
}
bool MoreThanFiveLateDeliveries()
{
return _numberOfLateDeliveries > 5;
}
double GetRating()
{
return (_numberOfLateDeliveries > 5) ? 2 : 1;
}
배경
책에서는 계속해서 목적이 분명하게 드러나는 이름의 짤막한 함수를 이용하기를 권한다.
그래야 코드가 이해하기 쉬워지고 명료해지기 때문이다.
하지만 때로는 함수 본문이 이름만큼 명확한 경우도 있다.
또는 함수 본문 코드를 이름만큼 깔끔하게 리팩터링할 때도 있다.
이럴 때는 그 함수를 제거한다.
간접 호출은 유용할 수 있지만 쓸데없이 간접 호출은 거슬릴 뿐이다.
리팩터링 과정에서 잘못 추출된 함수들도 다시 인라인 한다.
간접 호출을 너무 과하게 쓰는 코드도 흔한 인라인 대상이다.
절차
- 다형 메서드인지 확인한다.
- 서브클래스에서 오버라이드하는 메서드는 인라인하면 안된다.
- 인라인할 함수를 호출하는 곳을 모두 찾는다.
- 각 호출문을 함수 본문으로 교체한다.
- 하나씩 교체할 때마다 테스트한다.
- 인라인 작업을 한 번에 처리할 필요는 없다. 인라인하기가 까다로운 부분이 있다면 일단 남겨두고 여유가 생길 때마다 틈틈히 처리한다.
- 함수 정의(원래 함수)를 삭제한다.
간단해 보일 수 있어도 다양한 예외상황이 있을 수 있다.
너무 복잡한 상황이라면 하지 않는 것이 바람직하다.
예시
double GetRating()
{
return (MoreThanFiveLateDeliveries()) ? 2 : 1;
}
bool MoreThanFiveLateDeliveries()
{
return _numberOfLateDeliveries > 5;
}
첫 예제와 같이 본문 그대로 복사하여 인라인 하는 방법이 가장 간단한 예이다.
다른 예제로는 변수를 매개변수로 전달받아 참조하고 있는 경우가 있는데 이런 경우는 한 단계식 처리하는 것이 좋다.
6.3 변수 추출하기(Extract Variable)
- 반대 리팩터링: 변수 인라인하기
double Price()
{
// 가격(price) = 기본 가격 - 수량 할인 + 배송비
return _quantity * _itemPrice -
Math.Max(0, _quantity - 500) * _itemPrice * 0.05 +
Math.Min(_quantity * _itemPrice * 0.1, 100.0);
}
double Price()
{
// 가격(price) = 기본 가격 - 수량 할인 + 배송비
var basePrice = _quantity * _itemPrice;
var quantityDiscount = Math.Max(0, _quantity - 500) * _itemPrice * 0.05;
var shipping = Math.Min(basePrice * 0.1, 100.0);
return basePrice - quantityDiscount + shipping;
}
배경
표현식이 너무 복잡해서 이해하기 어려울 때가 있다.
이럴 때 지역변수를 활용하여 표현식을 쪼개 관리하기 더 쉽게 만들 수 있다.
그러면 복잡한 로직을 구성하는 단계마다 이름을 붙일 수 있어서 코드의 목적을 훨씬 명확하게 드러낼 수 있다.
이 과정에서 추가한 변수는 디버겅에서도 도움이 된다. 계산과정이 원자적으로 이뤄지지 않고 단계로 쪼개져 있기 때문에 중단점을 설정하는데 도움이 됨
변수 추출을 고려한다고 한다면 표현식에 이름을 붙이고 싶다는 뜻이다.
이름을 붙이기로 했다면 그 이름이 들어갈 문맥도 살펴야 한다.
현재 함수 안에서만 의미가 있다면 변수로 추출하는 것이 좋다.
하지만 함수를 벗어난 넓은 문맥에서까지 의미가 된다면 그 넓은 범위에서 통용되는 이름을 생각해야 한다.
다시 말해 이 경우는 변수가 아닌 함수로 추출해야 함을 의미한다.
이름이 통용되는 문맥을 넓히면 다른 코드에서 사용할 수 있기 때문에 같은 표현식을 중복해서 작성하지 않아도 된다.
절차
- 추출하려는 표현식에 부작용은 없는지 확인한다.
- 불변 변수를 하나 선언하고 이름을 붙일 표현식의 복제본을 대입한다.
- 원본 표현식을 새로 만든 변수로 교체한다.
- 테스트한다.
- 표현식을 여러 곳에서 사용한다면 각각을 새로 만든 변수로 교체한다. 하나 교체할 때마다 테스트한다.
예시
double Price()
{
// 가격(price) = 기본 가격 - 수량 할인 + 배송비
return _quantity * _itemPrice -
Math.Max(0, _quantity - 500) * _itemPrice * 0.05 +
Math.Min(_quantity * _itemPrice * 0.1, 100.0);
}
이런 복잡한 표현식을 발견하면 먼저 표현식을 복제하고 변수로 추출한다.
double Price()
{
// 가격(price) = 기본 가격 - 수량 할인 + 배송비
var basePrice = _quantity * _itemPrice;
var quantityDiscount = Math.Max(0, _quantity - 500) * _itemPrice * 0.05;
var shipping = Math.Min(basePrice * 0.1, 100.0);
return basePrice - quantityDiscount + shipping;
}
- 먼저 도메인 즉 요구사항을 파악하고 기본 가격이 수량*단가라는 것을 알아낸다.
- 따라서 기본 가격을 계산하는 표현식을 복제하고 basePrice라는 이름의 변수에 대입한다.
- 그 다음으로 수량 할인을 계산하는 표현식을 복제하고 quantityDiscount라는 이름의 변수에 대입한다.
- 마지막으로 배송비를 계산하는 표현식을 복제하고 shipping이라는 이름의 변수에 대입한다.
- 이제 원래 표현식을 새로 만든 변수로 교체한다.
중간 중간 변수를 치환하는 과정에서 테스트를 수행한다.
예시: 클래스 안에서
class Order
{
private Data _data;
public Order(Data data)
{
_data = data;
}
public int Quantity => _data.Quantity;
public int ItemPrice => _data.ItemPrice;
public double Price()
{
// 가격(price) = 기본 가격 - 수량 할인 + 배송비
return Quantity * ItemPrice -
Math.Max(0, Quantity - 500) * ItemPrice * 0.05 +
Math.Min(Quantity * ItemPrice * 0.1, 100.0);
}
}
class Order
{
private Data _data;
public Order(Data data)
{
_data = data;
}
public int Quantity => _data.Quantity;
public int ItemPrice => _data.ItemPrice;
public double Price()
{
return BasePrice() - QuantityDiscount() + Shipping();
}
public double BasePrice() => Quantity * ItemPrice;
public double QuantityDiscount() => Math.Max(0, Quantity - 500) * ItemPrice * 0.05;
public double Shipping() => Math.Min(Quantity * ItemPrice * 0.1, 100.0);
}
이렇게 작성하게 되면 객체지향의 장점을 잘 살린 코드라고 할 수 있다.
객체는 특정 로직과 데이터를 외부와 공유하려 할 때 공유할 정보를 설명해주는 적당한 크기의 문맥이 되어준다.
이 예처럼 간단한 경우라면 효과가 크지 않지만, 덩치가 큰 클래스에서 공통 동작을 별도 이름으로 뽑아내서 추상화해두면 그 객체를 다룰 때 쉽게 활용할 수 있어서 매우 유용하다.
6.4 변수 인라인하기(Inline Variable)
- 반대 리팩터링: 변수 추출하기
int basePrice = anOrder.BasePrice;
return (basePrice > 1000);
return (anOrder.BasePrice > 1000);
배경
변수는 함수 안에서 표현식을 가리키는 이름으로 쓰이며, 대체로 긍정적인 효과를 준다.
하지만 그 이름이 원래 표현식과 다를 바 없을 때도 있다.
또 변수가 주변 코드를 리팩터링하는 데 방해가 되기도 한다.
이럴 때는 변수를 인라인하는 것이 좋다.
절차
- 대입문의 우변에서 부작용이 생기지는 않는지 확인한다.
- 변수가 불변으로 선언되지 않았다면 불변으로 만든 후 테스트한다.
- 이렇게 되어야 변수에 값이 단 한 번만 대입되는지 확인할 수 있다.
- 이 변수를 가장 처음 사용하는 코드를 찾아서 대입문 우번의 코드로 바꾼다.
- 테스트한다.
- 변수를 사용하는 부분을 모두 교체할 때까지 이 과정을 반복한다.
- 변수 선언문과 대입문을 지운다.
- 테스트한다.
6.5 함수 선언 바꾸기(Change Function Declaration)
- 다른 이름
- 함수 이름 바꾸기
- 시그니처 바꾸기
public double Circum(int radius) {...}
public double CircumFerence(int radius) {...}
배경
함수는 프로그램을 작은 부분으로 나누는 주된 수단이다.
함수 선언은 각 부분이 서로 맞물리는 방식을 표현하며, 실질적으로 소프트웨어 시스템의 구성 요소를 조립하는 연결부 역할을 한다.
건축과 마찬가지로 소프트웨어도 이러한 연결부에 상당히 의존적이다.
즉 인터페이스를 절 정의하면 시스템에 새로운 부분을 추가하기가 쉬워지는 반면, 잘못 정의하면 지속적인 방해 요인으로 작용한다.
이러한 연결부에서 가장 중요한 부분이 함수 이름이다.
이름이 좋으면 함수의 구현 코드를 살펴볼 필요 없이 호출문만 보고도 무슨 일을 하는지 파악할 수 있다.
하지만 좋은 이름을 떠올리기란 쉽지 않다. (가장 어려운 문제)
함수의 매개변수도 마찬가지다.
매개변수는 함수가 외부 세계와 어우러지는 방식을 정의한다.
메게변수는 함수를 사용하는 문맥을 설정한다.
이 리팩터링은 사실 가장 어려운 리팩터링이 아닐까 싶다.
내 기준에선 내가 한국인이고 외국에서 일할 생각이 크게 없기 때문에 이름에 대한 명명 규칙은 대부분 친숙한 단어 위주로 사용한다.
물론 서술적이고 간단한 이름이 좋다는 것을 알고, 그 수준을 목표로 하지만 따라오는 코스트가 객체지향에 대한 이해와 영어의 전반적인 이해가 필요하다고 생각된다.
절차
이 리팩터링은 약간 성격이 다르다.
먼저 변경 사항을 살펴보고 함수 선언과 호출문들을 단번에 고칠 수 있는지 가늠해본다.
가능할 것 같다면 간단한 절차를 따른다.
마이그레이션 절차를 적용하면 호출문들을 점진적으로 수정할 수 있다.
호출하는 곳이 많거나, 호출 과정이 복잡하거나, 호출 대상이 다형 메서드거나, 선언이 복잡할 때는 이렇게 해야한다.
간단한 절차
- 매개변수를 제거하려거든 먼저 함수 본문에서 제거 대상 매개변수를 참조하는 곳은 없는지 확인한다.
- 메서드 선언을 원하는 형태로 바꾼다.
- 기존 메서드 선언을 참조하는 부분을 모두 찾아서 바뀐 형태로 수정한다.
- 테스트한다.
변경할 게 둘 이상이면 나눠서 처리하는 편이 나을 때가 많다.
따라서 이름 변경과 매개변수 추가를 모두 하고 싶다면 각각 독립적으로 처리하자.
(그러다 문제가 생기면 작업을 되돌리고 마이그레이션 절차를 따른다.)
사람은 기본적으로 멀티에 약하고 하면 안된다. 각 단계를 작게 나누고 독립적으로 처리할 것
마이그레이션 절차
- 이어지는 추출 단계를 수월하게 만들어야 한다면 함수의 본문을 적절히 리팩터링한다.
- 함수 본문을 새로운 함수로 추출한다.
- 새로 만들 함수 이름이 기존 함수와 같다면 일단 검색하기 쉬운 이름을 임시로 붙여둔다.
- 추출한 함수에 매개변수를 추가해야 한다면 ‘간단한 절차’를 따라 추가한다.
- 테스트한다.
- 기존 함수를 인라인 한다.
- 이름을 임시로 붙여뒀다면 함수 선언 바꾸기를 통해 원래 이름으로 되돌린다.
- 테스트한다.
예시: 함수 이름 바꾸기(간단한 절차)
public double Circum(int radius) {...}
public double CircumFerence(int radius) {...}
다음으로 Circum을 호출한 부분을 모두 찾아서 변경한다.(요즘은 IDE가 좋아져서 일괄 변경을 사용)
예시: 함수 이름 바꾸기(마이그레이션 절차)
public double Circum(int radius) {...}
public double GetCircumFerence(int radius) {...}
public double Circum(int radius) => GetCircumFerence(radius);
API처럼 래퍼클래스와 같은 형태로 생각하면 좋다
좀 더 안전한 방식, 폐기 예정을 생각하고 작업하는 방식
예시: 매개변수 추가하기
public void AddReservation(Reservation reservation)
{
AddReservation(reservation, false);
}
public void AddReservation(Reservation reservation, bool isPriority)
{
if (isPriority)
{
// 우선순위 예약을 추가한다.
}
else
{
// 일반 예약을 추가한다.
}
}
이 예제에서 내 생각은 if 조건문으로 분기보다 각 함수 이름이 좀 더 명확한 형태가 좋을 것 같다.
우선순위 예약과 일반 예약의 함수를 따로 만들고 직접 호출하는 방식이 더 좋아보인다.
6.6 변수 캡슐화하기(Encapsulate Variable)
public string Name;
private string _name;
public string Name
{
get => _name;
set => _name = value;
}
배경
리팩터링은 결국 프로그램의 요소를 조작하는 일이다.
함수는 데이터보다 다루기가 수월하다.
함수를 사용한다는 건 대체로 호출한다는 뜻이고, 함수의 이름을 바꾸거나 다른 모듈로 옮기기는 어렵지 않다.
여차하면 기존 함수를 그대로 둔 채로 전달함수로 활용할 수도 있기 때문이다.
반대로 데이터는 함수보다 까다로운데, 그 이유는 이런 식으로 처리할 수 없기 때문이다.
데이터는 참조하는 모든 부분을 한 번에 바꿔야 코드가 제대로 작동한다.
짧은 함수 안의 임시 변수처럼 유효범위가 아주 좁은 데이터는 어려울 게 없지만, 유효 범위가 넓어질수록 다루기 어려워진다.
전역 데이터가 골칫거리인 이유도 바로 여기에 있다.
따라서 접근할 수 있는 데이터를 옯길 때는 먼저 그 데이터로의 접근을 독접하는 함수를 만드는 식으로 캡슐화하는 것이 가장 좋은 방법일 때가 많다.
객체지향에서 private을 항상 유지해야 한다고 하는 이유가 바로 여기에 있다.
절차
- 변수로의 접근과 갱신을 전담하는 캡슐화 함수들을 만든다. (게터와 세터 C#은 프로퍼티라는 강력한 기능이 있다)
- 정적 검사를 수행한다.
- 변수를 직접 참조하던 부분을 모두 적절한 캡슐화 함수 호출로 바꾼다. 하나씩 바꿀 때마다 테스트한다.
- 변수의 접근 범위를 제한한다.
- 변수로의 직접 접근을 막을 수 없을 때도 있다. 그럴 때는 변수 이름을 바꿔서 테스트해보면 해당 변수를 참조하는 곳을 쉽게 찾아낼 수 있다.
- 테스트한다.
- 변수 값이 레코드라면 레코드 캡슐화하기를 적용할지 고려해본다.
예시
이 부분은 C#의 좀 더 강력한 기능인 프로퍼티를 사용하면 된다.
public string Name;
private string _name;
public string Name
{
get => _name;
set => _name = value;
}
set이나 get을 제한하고 싶다면 아래와 같이 사용
public string Name { get; private set; }
6.7 변수 이름 바꾸기(Rename Variable)
int a = height * width;
int area = height * width;
배경
명확한 프로그래밍의 핵심은 이름짓기다.
변수는 프로그래머가 하려는 일에 관해 많은 것을 설명해준다.
단, 이름을 잘 지었을 때만 그렇다.
이런 이름에 관한 인지작용은 프로그래머의 뇌
라는 책을 참고하면 좋다.
이 부분에서 조금 인지적인 부분이 설명되어야 하는데 프로그래머가 알고 있는 당연한 문법 람다식의 경우 x에 대한 변수는 바로 해석이 가능하지만 스피드하게 푸는 알고리즘 문제의 변수들은 각각 무슨 역할을 하는지 단번에 파악하기 어렵다.
맥락과 배경지식의 영역이 필요한 부분
절차
- 폭넓게 쓰이는 변수라면 캡슐화를 고려한다.
- 이름을 바꿀 변수를 참조하는 곳을 모두 찾아서, 하나씩 변경한다.
- 다른 코드베이스에서 참조하는 변수는 외부에 공개된 변수이므로 리팩터링을 적용할 수 없다.
- 변수 값이 변하지 않는다면 다른 이름으로 복제본을 만들어서 하나씩 점진적으로 변경한다. 하나씩 바꿀 때마다 테스트한다.
- 테스트한다.
예시
변수 이름 바꾸기의 가장 간단한 예는 임시 변수나 인수처럼 유효범위가 하나로 국한된 변수다.
예시도 필요없고 단지 이름만 잘 지으면 된다.
6.8 매개변수 객체 만들기(Introduce Parameter Object)
private void AmountInvoicedIn(Date start, Date end) {...}
private void AmountReceivedIn(Date start, Date end) {...}
private void AmountOverdueIn(Date start, Date end) {...}
private void AmountInvoicedIn(DateRange range) {...}
private void AmountReceivedIn(DateRange range) {...}
private void AmountOverdueIn(DateRange range) {...}
배경
데이터 항목 여러 개가 이 함수에서 저 함수로 함께 몰려다니는 경우를 자주 본다.
이런 데이터 무리를 발견하면 데이터 구조를 하나로 모아주곤 한다.(매개변수 자체가 적은 것이 유리)
데이터 뭉치를 데이터 구조로 묶으면 데이터 사이의 관계가 명확해진다는 이점을 얻는다.
게다가 함수가 이 데이터 구조를 받게 하면 매개변수 수가 줄어든다.
같은 데이터 구조를 사용하는 모든 함수가 원소를 참조할 때 항상 똑같은 이름을 사용하기 때문에 일관성도 높여준다.
하지만 이 리팩터링의 진정한 힘은 코드를 더 근본적으로 바꿔준다는 데 있다.
이런 데이터구조를 발견하면 이 데이터 구조를 활용하는 형태로 프로그램 동작을 재구성한다.
데이터 구조에 담길 데이터에 공통으로 적용되는 동작을 추출해서 함수로 만드는 것이다.
이 과정에서 새로 만든 데이터 구조가 문제 영역을 훨씬 간결하게 표현하는 새로운 추상개념으로 격상되면서, 코드의 개념적인 그림을 다시 그릴 수도 있다.
절차
- 적당한 데이터 구조가 아직 마련되어 있지 않다면 새로 만든다.
- 개인적으로 클래스를 만드는 것을 선호한다. 나중에 동작까지 함께 묶기 좋기 때문이다. 주로 값 객체로 만든다.
- 테스트한다.
- 함수 선언 바꾸기로 새 데이터 구조를 매개변수로 추가한다.
- 테스트한다.
- 함수 호출 시 새 데이터 구조 인스턴스를 넘기도록 수정한다. 하나씩 수정할 때마다 테스트한다.
- 기존 매개변수를 사용하던 코드를 새 데이터 구조의 원소를 사용하도록 바꾼다.
- 다 바꿨다면 기존 매개변수를 제거한다.
예시
온도 측정값 배열에서 정상 작동 범위를 벗어난 것이 있는지 검사하는 코드다.
public class HeatingPlan
{
private TempRange _range;
public HeatingPlan(TempRange range)
{
_range = range;
}
public bool WithinRange(int low, int high)
{
return (low >= _range.GetLow() && high <= _range.GetHigh());
}
}
public class TempRange
{
private int _low;
private int _high;
public TempRange(int low, int high)
{
_low = low;
_high = high;
}
public int GetLow() => _low;
public int GetHigh() => _high;
}
public class Room
{
private HeatingPlan _plan;
public Room(HeatingPlan plan)
{
_plan = plan;
}
public bool WithinPlan(TempRange range)
{
return _plan.WithinRange(range.GetLow(), range.GetHigh());
}
}
이 코드는 매개변수가 2개인 함수를 2개 호출하고 있다.
이런 경우는 매개변수를 객체로 만들면 편리하다.
public class HeatingPlan
{
private TempRange _range;
public HeatingPlan(TempRange range)
{
_range = range;
}
public bool WithinRange(TempRange range)
{
return (range.GetLow() >= _range.GetLow() && range.GetHigh() <= _range.GetHigh());
}
}
public class TempRange
{
private int _low;
private int _high;
public TempRange(int low, int high)
{
_low = low;
_high = high;
}
public int GetLow() => _low;
public int GetHigh() => _high;
}
public class Room
{
private HeatingPlan _plan;
public Room(HeatingPlan plan)
{
_plan = plan;
}
public bool WithinPlan(TempRange range)
{
return _plan.WithinRange(range);
}
}
진정한 값 객체로 거듭나기
진정한 값 객체는 클래스로 만들어 두고 이후에 관련 동작들을 해당 클래스로 옮길 수 있다는 이점이 생긴다.
public class TempRange
{
private int _low;
private int _high;
public TempRange(int low, int high)
{
_low = low;
_high = high;
}
public int GetLow() => _low;
public int GetHigh() => _high;
public bool Includes(int arg)
{
return arg >= GetLow() && arg <= GetHigh();
}
}
6.9 여러 함수를 클래스로 묶기(Combine Functions into Class)
private void base(aReading) {...}
private void taxableCharge(aReading) {...}
private void calculateBaseCharge(aReading) {...}
class Reading
{
...
public int GetBase();
public int GetTaxableCharge();
public int GetCalculateBaseCharge();
}
배경
클래스는 대다수의 최신 언어가 지원하는 기본적인 빌딩 블록이다.
클래스는 데이터와 함수를 하나의 공유 환경으로 묶은 후, 다른 프로그램 요소와 어우러질 수 있도록 그중 일부를 외부에 제공한다.
클래스는 객체 지향 언어의 기본인 동시에 다른 패러다임 언어에도 유용하다.
이 리팩터링은 이미 만들어진 함수들을 재구성할 때는 물론, 새로 만든 클래스와 관련하여 놓친 연산자를 찾아서 새 클래스의 메서드로 뽑아내는 데도 좋다.
함수를 한데 묶는 또 다른 방법으로 여러 함수를 변환 함수로 묶기
도 있다.
둘 중 어느 방식으로 리팩터링을 진행할지는 프로그램의 문맥을 넓게 파악하고 정해야 한다.
클래스로 묶는 경우는 클라이언트가 객체의 핵심 데이터를 변경할 수 있고, 파생 객체들을 일관되게 관리할 수 있다.
이런 함수들은 중첩 함수들로 묶어도 되지만 클래스로 묶는 것이 더 좋다(확장성)
절차
- 함수들이 공유하는 공통 데이터 레코드를 캡슐화한다.
- 공통 데이터가 레코드 구조로 묶여 있지 않다면 먼저 매개변수 객체 만들기로 데이터를 하나로 묶는 레코드를 만든다.
- 공통 레코드를 사용하는 함수 각각을 새 클래스로 옮긴다.
- 공통 레코드의 멤버는 함수 호출문의 인수 목록에서 제거한다.
- 데이터를 조작하는 로직들은 함수로 추출해서 새 클래스로 옮긴다.
예시
public record Reading
{
public int CustomerId { get; init; }
public int Quantity { get; init; }
public DateTime Date { get; init; }
}
public class ReadingCalculator
{
private Reading _reading;
public ReadingCalculator(Reading reading)
{
_reading = reading;
}
public int Base()
{
return _reading.Quantity * 2;
}
public int TaxableCharge()
{
return Math.Max(0, _reading.Quantity - 100);
}
public int CalculateBaseCharge()
{
return _reading.Quantity * 2;
}
}
내가 만든 예제에서 활용된 record
는 C# 9.0에서 추가된 기능으로 불변 데이터를 쉽게 만들 수 있게 해준다.
자세한 내용은 검색해서 참고!
6.10 여러 함수를 변환 함수로 묶기(Combine Functions into Transform)
public int base(aReading) {...}
public int taxableCharge(aReading) {...}
public Reading enrichReading(aReading)
{
const aReading = deepCopy(aReading);
aReading.baseCharge = base(aReading);
aReading.taxableCharge = taxableCharge(aReading);
return aReading;
}
배경
소프트웨어는 데이터를 입력받아서 여러 가지 정보를 도출하곤 한다.
이렇게 도출된 정보는 여러 곳에서 사용될 수 있는데, 그러다 보면 이 정보가 사용되는 곳마다 같은 도출 로직이 반복되기도 한다.
이런 작업을 한 곳에 모아두게 되면 검색과 갱신을 일관된 장소에서 처리할 수 있고 로직 중복도 막을 수 있다.
이렇게 하기 위한 방법으로 변환 함수를 사용할 수 있다.
변환 함수는 원본 데이터를 입력받아서 필요한 정보를 모두 도출한 뒤, 각각의 출력 데이터의 필드에 넣어 반환한다.
이것의 클래스로 묶기와 차이점은 원본 데이터가 코드 안에서 갱신될 때는 클래스로 묶기를 사용하고, 원본 데이터가 갱신되지 않을 때는 변환 함수를 사용한다.
절차
- 변환할 레코드를 입력받아서 값을 그대로 반환하는 변환 함수를 만든다.
- 이 작업은 대체로 깊은 복사로 처리해야 한다. 반환 함수가 원본 레코드를 바꾸지 않는지 검사하는 테스트를 마련해두면 도움될 때가 많다.
- 묶을 함수 중 하나를 골라서 본문 코드를 변환 함수로 옮기고, 처리 결과를 레코드에 새 필드로 기록한다. 그런 다음 클라이언트 코드가 이 필드를 사용하도록 수정한다.
- 로직이 복잡하면 함수 추출하기 부터 한다.
- 테스트한다.
- 나머지 관련 함수도 위 과정에 따라 처리한다.
예시
9번 예제와 비슷하여 패스
6.11 단계 쪼개기(Split Phase)
const string[] orderData = orderString.Split(/\s+/);
const int productPrice = priceList[orderData[0].Split("-")[1]];
const int orderPrice = ParseInt(orderData[1]) * productPrice;
const Order order = new Order(orderString);
const int orderPrice = order.Price(priceList);
public class Order
{
private string _data;
public Order(string data)
{
_data = data;
}
public int Price(Dictionary<string, int> priceList)
{
const string[] orderData = _data.Split(/\s+/);
const int productPrice = priceList[orderData[0].Split("-")[1]];
return ParseInt(orderData[1]) * productPrice;
}
}
이 부분은 변환이 생각보다 어렵다
배경
서로 다른 두 대상을 한꺼번에 다루는 코드를 발견하면 각각의 별개의 모듈로 나누는 방법을 모색한다.
코드를 수정해야 할 때 두 대상을 동시에 생각할 필요 없이 하나에만 집중하기 위해서다.
분리하는 가장 간편한 방법은 동작을 연이은 두 단계로 쪼개는 것이다.
입력이 처리 로직에 적합하지 않은 형태로 들어오는 경우를 예로 생각해보자. 이럴 때는 본 작업에 들어가기 전에 입력값을 다루기 편한 형태로 가공한다.
아니면 순차적인 단계들로 분리해도 된다.
가장 대표적인 예로 컴파일러
다.
기본적으로 어떤 텍스트를 입력받아 실행 가능한 형태로 변환하는데 이 과정이 순차적이라는 것
각 단계는 자신만의 문제에 집중하기에 다른 단계에 관해서는 몰라도 이해할 수 있다.
이렇게 단계를 쪼개는 기법은 주로 덩치 큰 소프트웨어에 적용된다.
절차
- 두 번째 단계에 해당하는 코드를 독립 함수로 추출한다.
- 테스트한다.
- 중간 데이터 구조를 만들어서 앞에서 추출한 함수의 인자로 추가한다.
- 테스트한다.
- 추출한 두 번째 단계 함수의 매개변수를 하나씩 검토한다. 그중 첫 번째 단계에서 사용되는 것은 중간 데이터 구조로 옮긴다. 하나씩 옮길 때마다 테스트한다.
- 첫 번째 단계 코드를 함수로 추출하면서 중간 데이터 구조를 반환하도록 만든다.
예시
상품의 결제 금액을 계산하는 코드로 살펴본다.
public int PriceOrder(Product product, int quantity, ShippingMethod shippingMethod)
{
int basePrice = product.BasePrice * quantity;
int discount = Math.Max(quantity - product.DiscountThreshold, 0) * product.BasePrice * product.DiscountRate;
int shippingPerCase = (basePrice > shippingMethod.DiscountThreshold) ? shippingMethod.FeePerCase - 1 : shippingMethod.FeePerCase;
int shippingCost = quantity * shippingPerCase;
int price = basePrice - discount + shippingCost;
return price;
}
코드를 살펴보면 계산이 두 단계로 이뤄지는데 앞 두줄은 상품 가격을 계산하고, 나머지는 배송비를 계산한다.
- 먼저 배송비 계산부분을 함수로 추출한다.
public int PriceOrder(Product product, int quantity, ShippingMethod shippingMethod)
{
int basePrice = product.BasePrice * quantity;
int discount = Math.Max(quantity - product.DiscountThreshold, 0) * product.BasePrice * product.DiscountRate;
int price = basePrice - discount + ShippingCost(quantity, shippingMethod);
return price;
}
private int ShippingCost(int quantity, ShippingMethod shippingMethod)
{
int shippingPerCase = (quantity > shippingMethod.DiscountThreshold) ? shippingMethod.FeePerCase - 1 : shippingMethod.FeePerCase;
int shippingCost = quantity * shippingPerCase;
return shippingCost;
}
두 번째 단계에 필요한 데이터를 모두 개별 매개변수로 전달했다.
실전에서는 이런 데이터가 상당히 많을 수 있는데, 어차피 나중에 걸러내기 때문에 문제없다.
여기서 말하는 걸러내기 작업은 두 가지 이상 리팩터링을 동시에 하지말고 우선 한가지에 집중하라는 것
- 다음으로 첫 번째 단계와 두 번째 단계가 주고받을 중간 데이터 구조를 만든다.
뒤 두가지 예제는 따로 CodeReview에서 다룰 예정
느낀점
논의사항으로
논의사항
앞 3장에서 코드 스멜에 대해 이야기하면서 대부분 짧게 나왔던 내용을 다시 풀어서 예시와 자세한 설명이 있어서 좋았습니다..
마찬가지로 한번에 다 읽고 이해하기 보다 필요할 때 찾아보기 좋을 것 같은 내용이네요!
이런 부분도 읽고 넘기는게 아닌 실천이 가장 중요할 것 같습니다.
기본적인 리팩터링에서 가장 많이 사용하시는 리팩터링은 무엇인가요?
7. 캡슐화
7.1 레코드 캡슐화하기(Encapsulate Record)
public class Organization
{
public string Name { get; set; }
public string Country { get; set; }
}
public class Organization
{
public string Name { get; set; }
public Country Country { get; set; }
}
public class Country
{
public string Name { get; set; }
}
배경
대부분의 프로그래밍 언어는 데이터 레코드를 표현하는 구조를 제공한다.
레코드는 연관된 데이터를 직접적인 방식으로 묶을 수 있어서 각각을 따로 취급할 때보다 훨씬 의미있는 단어로 전달할 수 있게 해준다.
하지만 레코드는 계산하여 얻을 수 있는 값과 그렇지 않은 값을 명확하게 구분해 저장하는 점이 번거롭다.
때문에 가변 데이터를 저장하는 용도로는 레코드보다 객체가 좋을 수 있다.
객체를 사용하면 어떻게 저장했는지를 숨긴 채 세 가지 값을 각각의 메서드로 제공할 수 있다.
절차
- 레코드를 담은 변수를 캡슐화한다.
- 레코드를 감싼 단순한 클래스로 해당 변수의 내용을 교체한다. 이 클래스에 원본 레코드를 반환하는 접근자도 정의하고, 변수를 캡슐화하는 함수들이 이 접근자를 사용하도록 수정한다.
- 테스트한다.
- 원본 레코드 대신 새로 정의한 클래스 타입의 객체를 반환하는 함수들을 새로 만든다.
- 레코드를 반환하는 예전 함수를 사용하는 코드를 새 함수를 사용하도록 바꾼다.
- 클래스에서 원본 데이터를 반환하는 접근자와 원본 레크드를 반환하는 함수들을 제거한다.
- 테스트한다.
- 레코드의 필드도 데이터 구조인 중첩 구조라면 레코드 캡슐화하기와 컬렉션 캡슐화하기를 재귀적으로 적용한다.
예시: 간단한 레코드 캡슐화하기
public class Organization
{
public string Name { get; set; }
public string Country { get; set; }
}
public class Organization
{
public string Name { get; set; }
public Country Country { get; set; }
}
public class Country
{
public string Name { get; set; }
}
불변 데이터는 앞 장에서 다룬 것과 같이 C#
의 record를 사용하면 되고 가변 데이터에 대한 캡슐화는 프로퍼티로 처리할 수 있다.
7.2 컬렉션 캡슐화하기(Encapsulate Collection)
public class Person
{
public List<Course> Courses { get; set; }
}
public class Person
{
private List<Course> _courses = new List<Course>();
public List<Course> Courses
{
get => _courses;
set => _courses = value;
}
}
이 예제는 사실 틀린 예제이지만 자바스크립트와 최대한 비슷하게..
- 책 에서 원하는 것 처럼 컬렉션을 제어할 수 없게 만들려면
C#
에선IEnumerable
이나ReadOnlyCollection
을 이용해야 한다.
아니면 해당 프로퍼티에서 DeepCopy 객체를 반환하는 Get만 두고 Set을 삭제하는 방법이 있을 것 같다.
배경
가변 데이터 또한 캡슐화를 해 놓으면 데이터 구조가 언제 어떻게 수정되는지 추적하기 쉬워진다.
객체지향에서도 캡슐화는 적극 권장되며 컬렉션을 다룰 때 일어나는 실수를 방지할 수 있다.
사실 이보다 Add, Remove같은 메서드를 제공하는 것이 더 바람직한 방법으로 컬렉션 자체를 반환하게 되면 변경될 가능성이 항상 존재하기에 실수 자체를 차단하는 것이 좋다.
위에서 언급한 복제된 객체를 반환하거나 이터레이터를 써서 접근을 막는 방법..
게임에서는 이런 데이터를 매우 많이 만지기 때문에 불변 데이터를 보장하기 위해 복사를 사용한다면 부담이 될 것 같다는 생각도 든다.
절차
- 아직 컬레션을 캡슐화하지 않았다면 변수 캡슐화하기부터 한다.
- 컬렉션에 원소를 추가/제거하는 함수를 추가한다.
- 정적 검사를 수행한다.
- 컬렉션을 참조하는 부분을 모두 찾는다. 컬렉션의 변경자를 호출하는 코드가 모두 앞에서 추가한 추가/제거 함수를 호출하도록 수정한다. 하나씩 수정할 때마다 테스트한다.
- 컬렉션 게터를 수정해서 원본 내용을 수정할 수 없는 읽기전용 프락시나 복제본을 반환하게 한다.
- 테스트한다.
예시
public class Person
{
public List<Course> Courses { get; set; }
}
public class Person
{
private List<Course> _courses = new List<Course>();
public List<Course> Courses
{
get => _courses.DeepCopy();
}
public void AddCourse(Course course)
{
_courses.Add(course);
}
public void RemoveCourse(Course course)
{
// 삭제 예외처리
_courses.Remove(course);
}
}
or 접근 제한은 위 링크 참조
7.3 기본형을 객체로 바꾸기(Replace Primitive with Object)
public class Order
{
public string Customer { get; set; }
}
public class Order
{
public Customer Customer { get; set; }
}
public class Customer
{
public string Name { get; set; }
}
배경
개발 초기에는 단순한 정보를 문자나 숫자로 표현하는 경우가 많다.
이후 개발이 진행되면서 해당 정보들이 더이상 간단하지 않게 변하게 된다.
단순 출력 이상의 성능이 필요해지는 순간 데이터를 표현하는 전용 클래스로 정의하는 것이 좋다.
기본형 데이터를 감싼 형태라 효과가 미미하지만 이후에 특별한 동작을 추가하다 보면 유용한 도구가 된다.
C#
에서는 enum을 사용해서 Entity의 State를 정의하거나, 상태 관련을 열거형으로 다루는 경우가 많다.
하지만 상태끼리의 상호작용이나 상태에 따른 공통적인, 부가적인 행동이 필요할 때는 enum을 벗어나 클래스로 업그레이드 하는 것이 좋을 수 있을 것 같다.
절차
- 아직 변수를 캡슐화하지 않았다면 캡슐화한다.
- 단순한 값 클래스를 만든다. 생성자는 기존 값을 인수로 받아서 저장하고, 이 값을 반환하는 게터를 추가한다.
- 정적 검사를 수행한다.
- 값 클래스의 인스턴스를 새로 만들어서 필드에 저장하도록 세터를 수정한다. 이미 있다면 필드의 타입을 적절히 변경한다.
- 새로 만든 클래스의 게터를 호출한 결과를 반환하도록 게터를 수정한다.
- 테스트한다.
- 함수 이름을 바꾸면 원본 접근자의 동작을 더 잘 드러낼 수 있는지 검토한다.
예시
enum State
{
STATE_STOP,
STATE_START,
STATE_PAUSE,
STATE_RESUME
}
public class Player
{
public State State { get; set; }
}
public class Player
{
public PlayerState State { get; set; }
}
public class PlayerState
{
public State State { get; set; }
}
7.4 임시 변수를 질의 함수로 바꾸기
public class Order
{
public double GetPrice()
{
double basePrice = _quantity * _itemPrice;
if (basePrice > 1000)
{
return basePrice * 0.95;
}
else
{
return basePrice * 0.98;
}
}
}
public class Order
{
public double GetPrice()
{
if (BasePrice() > 1000)
{
return BasePrice() * 0.95;
}
else
{
return BasePrice() * 0.98;
}
}
private double BasePrice()
{
return _quantity * _itemPrice;
}
}
배경
함수 안에서 어떤 코드의 결과값을 뒤에서 다시 참조할 목적으로 임시 변수를 쓰기도 한다.
이런 임시 변수가 3진 아웃, 두번 이상 사용된다면 함수로 추출하는 것이 좋다.
이 리팩터링은 클래스 내부에서 적용할 때 효과가 크다.
쿨랴수눈 추출할 메서드들에 공유 컨텍스트를 제공하기 때문이다.
클래스 바깥의 최상위 함수로 추출하면 매개변수가 너무 많아져서 함수를 사용하는 장점이 줄어든다.
절차
- 변수가 사용되기 전에 값이 확실히 결정되는지, 변수를 사용할 때마다 계산 로직이 매번 다른 결과를 내는지 않는지 확인한다.
- 읽기전용으로 만들 수 있는 변수는 읽기전용으로 만든다.
- 테스트한다.
- 변수 대입문을 함수로 추출한다.
- 테스트한다.
- 변수 인라인하기로 변수를 제거한다.
예시
public class Order
{
public double GetPrice()
{
double basePrice = _quantity * _itemPrice;
if (basePrice > 1000)
{
return basePrice * 0.95;
}
else
{
return basePrice * 0.98;
}
}
}
public class Order
{
public double GetPrice()
{
if (BasePrice() > 1000)
{
return BasePrice() * 0.95;
}
else
{
return BasePrice() * 0.98;
}
}
private double BasePrice()
{
return _quantity * _itemPrice;
}
}
7.5 클래스 추출하기(Extract Class)
public class Person
{
public string OfficeAreaCode { get; set; }
public string OfficeNumber { get; set; }
}
public class Person
{
private TelephoneNumber _officeTelephone = new TelephoneNumber();
public string OfficeAreaCode
{
get => _officeTelephone.AreaCode;
set => _officeTelephone.AreaCode = value;
}
public string OfficeNumber
{
get => _officeTelephone.Number;
set => _officeTelephone.Number = value;
}
}
public class TelephoneNumber
{
public string AreaCode { get; set; }
public string Number { get; set; }
}
배경
클래스는 반드시 명확하고 추상화하고 소수의 주어진 역할만 처리해야 한다는 가이드라인을 따라야 한다.
하지만 실무에서는 연산을 추가하고 데이터를 추가하다 보면 클래스가 점점 비대하지곤 한다.
그러다 보면 나중엔 손쓸 틈도 없이 딱딱한 덩어리로 변해버린다.
특히 일부 데이터와 메서드를 따로 묶을 수 있다면 어서 분리하라는 신호다.
절차
- 클래스의 역할을 분리할 방법을 정한다.
- 분리될 역할을 담당할 클래스를 새로 만든다.
- 원래 클래스의 생성자에서 새로운 클래스의 인스턴스를 생성하여 필드에 저장해둔다.
- 분리될 역할에 필요할 필드들을 새 클래스로 옮긴다. 하나씩 옮길 때마다 테스트한다.
- 메서드들도 새 클래스로 옮긴다. 이때 저수준 메서드, 즉 다른 메서드를 호출하기보다는 호출당하는 일이 많은 메서드부터 옮긴다. 하나씩 옮길 때마다 테스트한다.
- 양쪽 클래스의 인터페이스를 살펴보면서 불필요한 메서드를 제거하고, 이름도 새로운 환경에 맞게 바꾼다.
- 새 클래스를 외부로 노출할지 정한다. 노출하려거든 새 클래스에 참조를 값으로 바꾸기를 적용할지 고려한다.
예시
public class Person
{
public string OfficeAreaCode { get; set; }
public string OfficeNumber { get; set; }
}
public class Person
{
private TelephoneNumber _officeTelephone = new TelephoneNumber();
public string OfficeAreaCode
{
get => _officeTelephone.AreaCode;
set => _officeTelephone.AreaCode = value;
}
public string OfficeNumber
{
get => _officeTelephone.Number;
set => _officeTelephone.Number = value;
}
}
7.6 클래스 인라인하기(Inline Class)
public class Person
{
private TelephoneNumber _officeTelephone = new TelephoneNumber();
public string OfficeAreaCode
{
get => _officeTelephone.AreaCode;
set => _officeTelephone.AreaCode = value;
}
public string OfficeNumber
{
get => _officeTelephone.Number;
set => _officeTelephone.Number = value;
}
}
public class Person
{
public string OfficeAreaCode { get; set; }
public string OfficeNumber { get; set; }
}
배경
클래스 인라인하기는 클래스 추출하기를 거꾸로 돌리는 리팩터링이다.
더 이상 제 역할을 못 해서 그대로 두면 안 되는 클래스는 인라인해버린다.
이런 역설적인 상황 때문에 아마 모든 경우를 설계하지 말고(처음부터 자동차를 만들려고 하지 말고) 필요할 때 설계해야 하는 것 같다.
절차
- 소스 클래스의 각 public 메서드에 대응하는 메서드들을 타깃 클래스에 생성한다. 이 메서드들은 단순히 작업을 소스 클래스로 위임해야 한다.
- 소스 클래스의 메서드를 사용하는 코드를 모두 타킷 클래스의 위임 메서드를 사용하도록 바꾼다. 하나씩 바꿀 때마다 테스트한다.
- 소스 클래스의 메서드와 필드를 모두 타깃 클래스로 옮긴다. 하나씩 옮길 때마다 테스트한다.
- 소스 클래스를 삭제한다. (조의를 표한다..ㅠ)
예시
public class Person
{
private TelephoneNumber _officeTelephone = new TelephoneNumber();
public string OfficeAreaCode
{
get => _officeTelephone.AreaCode;
set => _officeTelephone.AreaCode = value;
}
public string OfficeNumber
{
get => _officeTelephone.Number;
set => _officeTelephone.Number = value;
}
}
public class Person
{
public string OfficeAreaCode { get; set; }
public string OfficeNumber { get; set; }
}
7.7 위임 숨기기(Hide Delegate)
public class Person
{
public Department Department { get; set; }
}
public class Department
{
public Person Manager { get; set; }
}
public class Person
{
public Department Department { get; set; }
public Person GetManager()
{
return Department.Manager;
}
}
public class Department
{
public Person Manager { get; set; }
}
배경
모듈화 설계를 제대로 하는 핵심은 캡슐화다.
모듈들이 시스템의 다른 부분에 대해 알아야할 내 내용을 줄여준다.
캡슐화가 잘 되어 있다면 무언가를 변경해야 할 때 함께 고려해야 할 모듈 수가 적어져서 코드를 변경하기가 훨씬 쉬워진다.
객체 지향을 처음 배울 때는 캡슐화란 필드를 숨기는 것이라고 배운다.
그러다 경험이 쌓이면서 캡슐화의 역할이 그보다 많다는 사실을 깨닫는다.
서버 객체의 필드가 가리키는 객체의 메서드를 호출하려면 클라이언트는 이 위임 객체를 알아야 한다.
위임 객체의 인터페이스가 바뀌면 클라이언트가 코드를 수정해야 한다.
이러한 의존성을 없애려면 서버 자체에 위임 메서드를 만들어서 위임 객체의 존재를 숨기면 된다.
그러면 위임 객체가 수정되더라도 서버 코드만 고치면 되며, 클라이언트는 아무런 영향을 받지 않는다.
절차
- 위임 객체의 각 메서드에 해당하는 위임 메서드를 서버에 생성한다.
- 클라이언트가 위임 객체 대신 서버를 호출하도록 수정한다. 하나씩 수정할 때마다 테스트한다.
- 모두 수정했다면, 서버로부터 위임 객체를 얻는 접근자를 제거한다.
- 테스트한다.
예시
public class Person
{
public Department Department { get; set; }
}
public class Department
{
public Manager Manager { get; set; }
}
public class Manager
{
public string Name { get; set; }
}
public class Person
{
public Department Department { get; set; }
public string GetManagerName()
{
return Department.Manager.Name;
}
}
7.8 중개자 제거하기(Remove Middle Man)
public class Person
{
public Department Department { get; set; }
public string GetManagerName()
{
return Department.Manager.Name;
}
}
public class Person
{
public Department Department { get; set; }
public string GetManagerName()
{
return Department.GetManagerName();
}
}
public class Department
{
public Manager Manager { get; set; }
public string GetManagerName()
{
return Manager.Name;
}
}
배경
위임 숨기기와 반대되는 리팩터링으로 앞서 설명한 캡슐화의 이점이 또다른 방해요소로 작용하게 되면 적용할 수 있다.
클라이언트가 위임 객체의 또 다른 기능을 사용하고 싶을 때 마다 서버에 위임 메서드를 추가해야 하는데, 이렇게 기능을 추가하다 보면 단순히 전달만 하는 위임 메서드들이 성가셔진다.
그러면 서버 클래스는 그저 중개자 역할로 전락하여, 차라리 클라이언트가 위임 객체를 직접 호출하는 편이 낫다.
절차
- 위임 객체를 얻는 게터를 만든다.
- 위임 메서드를 호출하는 클라이언트가 모두 이 게터를 거치도록 수정한다. 하나씩 바꿀 때마다 테스트한다.
- 모두 수정했다면 위임 메서드를 삭제한다.
예시
public class Person
{
public Department Department { get; set; }
public string GetManagerName()
{
return Department.GetManagerName();
}
}
public class Department
{
public Manager Manager { get; set; }
public string GetManagerName()
{
return Manager.Name;
}
}
public class Person
{
public Department Department { get; set; }
public string GetManagerName()
{
return Department.Manager.Name;
}
}
7.9 알고리즘 교체하기(Substitute Algorithm)
public string FoundPerson(string[] people)
{
for (int i = 0; i < people.Length; i++)
{
if (people[i].Equals("Don"))
{
return "Don";
}
if (people[i].Equals("John"))
{
return "John";
}
if (people[i].Equals("Kent"))
{
return "Kent";
}
}
return "";
}
public string FoundPerson(string[] people)
{
string[] candidates = { "Don", "John", "Kent" };
for (int i = 0; i < people.Length; i++)
{
if (candidates.Contains(people[i]))
{
return people[i];
}
}
return "";
}
배경
어떤 목적을 달성하는 방법은 여러 가지가 있기 마련이다.
그중에서도 다른 방법보다 쉬운 방법이 있다.
알고리즘도 마찬가지다.
절차
- 교체할 코드를 함수 하나에 모은다.
- 이 함수만을 이용해 동작을 검증하는 테스트를 마련한다.
- 대체할 알고리즘을 준비한다.
- 정적 검사를 수행한다.
- 기존 알고리즘과 새 알고리즘의 결과를 비교하는 테스트를 수행한다. 두 결과가 같다면 리팩터링이 끝난다. 그렇지 않다면 기존 알고리즘을 참고해서 새 알고리즘을 테스트하고 디버깅한다.
느낀점
- 이번 챕터도 역시 당연하게 생각한 리팩터링도 있고 더 깊은 뜻이 있던 리팩터링도 있어서 얻어가는게 많았다.
- 계속해서 언어를
C#
으로 작성하다 보니 좀 더 유연해진 것 같은..?
논의사항
컬렉션 캡슐화하기
에 대한 논의가 항상 많았던 것 같은데 이 부분에 대해서 이야기 해보면 좋을 것 같습니다..!
실제 프로젝트에서 DeepCopy방식으로 반환하여 불변을 보장했던 것 같은데 다른 분들은 불변 데이터 객체에 대해서 어떻게 생각하시나요?
8. 기능 이동
지금까지는 프로그램 요소를 생성 혹은 제거하거나 이름을 변경하는 리팩터링을 다뤘다.
여기에 더해 요소를 다른 컨텍스트로 옮기는 일 역시 리팩터링의 중요한 축이다.
8.1 함수 옮기기(Move Function)
class Account
{
public GetDraftCharge() {...}
}
class AccountType
{
public GetDraftCharge() {...}
}
배경
모듈성이란 프로그램의 어딘가를 수정하려 할 때 해당 기능과 깊이 관련된 작은 일부만 이해해도 가능하게 해주는 능력이다.
모듈성을 높이려면 서로 연관된 요소들을 함께 묶고, 요소 사이의 연결 관계를 쉽게 찾고 이해할 수 있도록 해야 한다.
하지만 프로그램을 얼마나 잘 이해했느냐애 따라 구체적인 방법이 달라질 수 있다.
보통은 이해도가 높아질수록 소프트웨어 요소들을 더 잘 묶는 새로운 방법을 깨우치게 된다.
그래서 높아진 이해를 반여하려면 요소들을 이리저리 옮겨야 할 수 있다.
모든 함수는 컨텍스트 안에 존재한다.
객체 지향 프로그래밍의 핵심 모듈화 컨텍스트는 클래스다.
어떤 함수가 자신이 속한 모듈 A의 요소 보다 B모듈의 요소를 더 많이 참조한다면 모듈 B로 옮겨야 마땅하다.
이렇게 되면 캡슐화가 좋아져서 소프트웨어의 나머지 부분은 모듈 B의 세부사항에 덜 의존하게 된다.
절차
- 선택한 함수가 현재 컨텍스트에서 사용 중인 모든 프로그램 요소를 살펴본다. 이 요소들 중에도 함께 옮겨야 할 게 있는지 고민해본다.
- 선택함 함수가 다형 메서드인지 확인한다.
- 선택한 함수를 타깃 컨텍스트로 복사한다. 타깃 함수가 새로운 터전에 잘 자리 잠도록 다듬는다.
- 정적 분석을 수행한다.
- 소스 컨텍스트에서 타깃 함수를 참조할 방법을 찾아 반영한다.
- 소스 함수를 타깃 함수의 위임 함수가 되도록 수정한다.
- 테스트한다.
- 소스 함수를 인라인할지 고민해본다.
8.2 필드 옮기기(Move Field)
class Customer
{
private Plan plan;
private string discountRate;
public Plan GetPlan() { return plan; }
public string GetDiscountRate() { return discountRate; }
}
class Customer
{
private Plan plan;
public Plan GetPlan() { return plan; }
public string GetDiscountRate() { return plan.GetDiscountRate(); }
}
배경
프로그램의 상당 부분이 동작을 구현하는 코드로 이뤄지지만 프로그램의 진짜 힘은 데이터 구조에서 나온다.
주어진 문제를 적합한 데이터 구조를 활용하면 동작 코드는 자연스럽게 단순하고 직관적으로 짜여진다.
반면 데이터 구조를 잘못 선택하면 아귀가 맞지 않는 데이터를 다루기 위한 코드로 범벅이 된다.
이해하기 어려운 코드가 만들어지는 데서 끝나지 않고, 데이터 구조 자체도 그 프로그램이 어떤 일을 하는지 파악하기 어렵게 만든다.
그래서 데이터 구조가 매우 중요하지만, 훌룡한 프로그램이 갖춰야 할 다른 요인들과 마찬가지로, 제대로 하기가 어렵다.
현재 데이터 구조가 적절치 않다고 판단되면 바로 수정해야 한다. (이후에는 늦다.)
고치지 않고 데이터 구조에 남겨진 흠들은 우리 머릿속을 혼란스럽게 하고 훗날 작성하게 될 코드를 더욱 복잡하게 만든다.
필드 옮기기
리팩터링은 대체로 더 큰 변경의 일환으로 수행된다.
예컨대 필드 하나를 잘 옮기면, 그 필드를 사용하던 많은 코드가 원래 위치에서 사용하는 게 더 수월할 수 있다.
그렇다면 리팩타링을 마저 호출 진행하여 호출 코드들까지 모두 변경한다.
절차
- 소스 필드가 캡슐화되어 있지 않다면 캡슐화한다.
- 테스트한다.
- 타깃 객체에 필드를 생성한다.
- 정적 검사를 수행한다.
- 소스 객체에서 타깃 객체를 참조할 수 있는지 확인한다.
- 접근자들이 타깃 필드를 사용하도록 수정한다.
- 테스트한다.
- 소스 필드를 제거한다.
- 테스트한다.
예시
public class Customer
{
private string name;
private int discountRate;
private CustomerContract contract;
public Customer(string name, int discountRate)
{
this.name = name;
this.discountRate = discountRate;
this.contract = new CustomerContract(DateTime.Now);
}
public int GetDiscountRate()
{
return discountRate;
}
public void BecomePreferred()
{
...
}
public void ApplyDiscount(int discountRate)
{
...
}
}
public class CustomerContract
{
private DateTime startDate;
public CustomerContract(DateTime startDate)
{
this.startDate = startDate;
}
public DateTime GetStartDate()
{
return startDate;
}
}
여기서 할인율을 뜻하는 discountRate
필드를 Customer
에서 CustomerContract
로 옮기고 싶다.
public class Customer
{
private string name;
private CustomerContract contract;
public Customer(string name, int discountRate)
{
this.name = name;
this.contract = new CustomerContract(DateTime.Now, discountRate);
}
public int GetDiscountRate()
{
return contract.GetDiscountRate();
}
public void BecomePreferred()
{
...
}
public void ApplyDiscount(int discountRate)
{
...
}
}
public class CustomerContract
{
private DateTime startDate;
private int discountRate;
public CustomerContract(DateTime startDate, int discountRate)
{
this.startDate = startDate;
this.discountRate = discountRate;
}
public DateTime GetStartDate()
{
return startDate;
}
public int GetDiscountRate()
{
return discountRate;
}
}
8.3 문장을 함수로 옮기기(Move Statements into Function)
result.push("<p>제목: " + photo.title + "</p>");
result.concat(Format(photo));
public string Format(Photo photo)
{
return "<p>위치: " + photo.location + "</p>";
}
result.concat(FormatPhoto(photo));
public string FormatPhoto(Photo photo)
{
return "<p>제목: " + photo.title + "</p>"
+ "<p>위치: " + photo.location + "</p>";
}
배경
중복 재고는 코드를 건강하게 관리하는 가장 효과적인 방법 중 하나다.
예컨대 특정 함수를 호출하는 코드가 나올 때마다 그 앞이나 뒤에서 똑같은 코드가 추가로 실행되는 모습을 보면 중복 재고가 있다는 신호다.
이 부분은 함수로 추출하기
와 비슷하며 절차도 똑같다 (5,6번 제외)
절차
- 반복 코드가 함수 호출 부분과 멀리 떨어져 있다면 문장 슬라이드하기를 적용해 근처로 옮긴다.
- 타깃 함수를 호출하는 곳이 한 곳뿐이면, 단순히 소스 위치에서 해당 코드를 잘라내어 피호출 함수로 복사하고 테스트한다. 이 경우라면 나머지 단계를 스킵한다.
- 호출자가 둘 이상이면 호출자 중 하나에서 ‘타깃 함수 호출 부분과 그 함수로 옮기려는 문장들을 함께’다른 함수로 추출한다. 추출한 함수에 기억하기 쉬운 임시 이름을 붙인다.
- 다른 호출자 모두가 방금 추출한 함수를 사용하도록 수정한다. 하나씩 수정할 때마다 테스트한다.
- 모든 호출자가 새로운 함수를 사용하게 되면 원래 함수를 새로운 함수 안으로 인라인한 후 원래 함수를 제거한다.
- 새로운 함수의 이름을 원래 함수의 이름으로 바꿔준다.
8.4 문장을 호출한 곳으로 옮기기(Move Statements to Callers)
result.concat(FormatPhoto(photo));
public string FormatPhoto(Photo photo)
{
return "<p>제목: " + photo.title + "</p>"
+ "<p>위치: " + photo.location + "</p>";
}
result.push("<p>제목: " + photo.title + "</p>");
result.concat(Format(photo));
public string Format(Photo photo)
{
return "<p>위치: " + photo.location + "</p>";
}
배경
문장을 함수로 옮기기
와 반대 방향으로 적용하는 리팩터링이다.
함수는 프로그래머가 쌓아 올리는 추상화의 기본 빌딩 블록이다.
하지만 추상화라는 것이 그 경계를 항상 올바르게 긋기가 만만치 않다.
그래서 코드베이스의 기능 범위가 달라지면 추상화의 경계도 움직이게 된다.
함수 관점에서 생각해보면, 초기에는 응집도 높고 한 가지 일만 수행하던 함수가 어느새 둘 이상의 다른 일을 수행하게 바뀔 수 있다는 것이다.
절차
- 호출자가 한두 개뿐이고 피호출 함수도 간단한 단순한 상황이면, 피호출 함수의 처음 줄을 잘라내어 호출자로 복사해 넣는다. 테스트만 통과하면 리팩터링은 여기서 끝이다.
- 거 복잡한 상황에서는, 이동하지
않길
원하는 모든 문장을 함수로 추출한 다음 검색하기 쉬운 임시 이름을 지어준다. - 원래 함수를 인라인한다.
- 추출된 함수의 이름을 원래 함수의 이름으로 변경한다.
8.5 인라인 코드를 함수 호출로 바꾸기(Replace Inline Code with Function Call)
int basePrice = quantity * itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
int basePrice = quantity * itemPrice;
return DiscountedPrice(basePrice);
public int DiscountedPrice(int basePrice)
{
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
}
배경
함수는 여러 동작을 하나로 묶어준다. 그리고 함수의 이름이 코드의 동작 방식보다 목적을 말해주기 때문에 함수를 활용하면 코드를 이해하기가 쉬워진다.
이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 발견하면 보통은 해당 코드를 함수 호출로 대체하길 원할 것이다.
예외가 있다면, 순전히 우연히 비슷한 코드가 만들어졌을 때뿐이다.
즉, 기존 함수의 코드를 수정하더라도 인라인 코드의 동작은 바뀌지 않아야 할 때뿐이다.
이 경우인가를 판단하는 데는 함수의 이름이 힌트가 된다.
절차
- 인라인 코드를 함수 호출로 대체한다.
- 테스트한다.
함수 추출하기와 차이점은 대체할 함수가 이미 존재하는지에 대한 여부다.
아직 없어서 새로 만들어야 한다면 함수 추출하기를 적용하고, 이미 존재한다면 인라인 코드를 함수 호출로 바꾸기를 적용한다.
8.6 문장 슬라이드하기(Slide Statements)
var pricingPlan = RetrievePricingPlan();
var order = RetrieveOrder();
int charge;
var chargePerUnit = pricingPlan.GetUnitCharge();
var pricingPlan = RetrievePricingPlan();
var chargePerUnit = pricingPlan.GetUnitCharge();
var order = RetrieveOrder();
int charge;
배경
관련된 코드들이 가까이 모여 있다면 이해하기가 더 쉽다.
예컨대 하나의 데이터 구조를 이용하는 문장들은 한데 모여 있어야 좋다.(읽기도, 수정하기도)
관련 코드끼리 모으는 작업은 다른 리팩터링의 준비 단계로 자주 행해진다.
절차
- 코드 조각을 이동할 목표 위치를 찾는다. 코드 조각이 원래 위치와 목표 위치 사이의 코드들을 훑어보면서, 조각을 모으고 나면 동작이 달라지는 코드가 있는지 살핀다. 다음과 같은 간섭이 있다면 이 리팩터링을 포기한다.
- 코드 조각에서 참조하는 요소를 선헌하는 문장 앞으로는 이동할 수 없다.
- 코드 조각을 참조하는 요소의 뒤로는 이동할 수 없다.
- 코드 조각에서 참조하는 요소를 수정하는 문장을 건너뛰어 이동할 수 없다.
- 코드 조각이 수정하는 요소를 참조하는 요소를 건너뛰어 이동할 수 없다.
- 코드 조각을 원래 위치에서 잘라내어 목표 위치에 붙여 넣는다.
- 테스트한다.
만약 테스트가 실패한다면 더 작게 나눠서 시도해봐라.
8.7 반복문 쪼개기(Split Loop)
int averageAge = 0;
int totalSalary = 0;
foreach (var p in people)
{
averageAge += p.age;
totalSalary += p.salary;
}
averageAge = averageAge / people.Length;
int averageAge = 0;
foreach (var p in people)
{
averageAge += p.age;
}
int totalSalary = 0;
foreach (var p in people)
{
totalSalary += p.salary;
}
averageAge = averageAge / people.Length;
배경
종종 반복문 하나에서 두 가지 일을 수행하는 모습을 보게 된다.
그저 두 일을 한꺼번에 처리할 수 있다는 이유에서 말이다.
하지만 이렇게 반복문을 수정해야 할 때마다 두 가지 일 모두를 잘 이해하고 진행해야 한다.
반대로 각각의 바복문으로 분리해두면 수정할 동작 하나만 이해하면 된다.
반복문을 분리하면 사용하기도 쉬워진다. 한 가지 값만 계산하는 반복문이라면 그 값만 곧바로 반환할 수 있다.
반면 여러 일을 수행하는 반복문이라면 구조체를 반환하거나 지역 변수를 활용해야 한다.
참고로 반복문 쪼개기는 서로 다른 일들이 한 함수에서 이뤄지고 있다는 신호일 수 있고, 그래서 반복문 쪼개기와 함수 추출하기는 연이어 수행하는 일이 낮다.
리팩터링 != 최적화
절차
- 반복문을 복제해 두 개로 만든다.
- 반복문이 중복되어 생기는 부수효과를 제거한다.
- 테스트한다.
- 완료됐으면, 각 반복문을 함수로 추출할지 고민해본다.
예시
int averageAge = 0;
int totalSalary = 0;
foreach (var p in people)
{
averageAge += p.age;
totalSalary += p.salary;
}
averageAge = averageAge / people.Length;
int averageAge = 0;
foreach (var p in people)
{
averageAge += p.age;
}
int totalSalary = 0;
foreach (var p in people)
{
totalSalary += p.salary;
}
averageAge = averageAge / people.Length;
8.8 반복문을 파이프라인으로 바꾸기(Replace Loop with Pipeline)
List<string> names = new List<string>();
foreach (var person in input)
{
if (person.Job == "programmer")
{
names.Add(person.Name);
}
}
List<string> namesLinq = input
.Where(person => person.Job == "programmer")
.Select(person => person.Name)
.ToList();
배경
언어가 계속 발전함에 따라 더 나은 구조를 제공하는 쪽으로 발전해왔다.
반복문 대신에 논리를 파이프라인으로 표현하면 이해하기 쉬워진다.
C#은 LINQ를 사용해서 쿼리식을 표현하는데 초기에 컨벤션으로 사용하지 말자고 합의보는 경우도 종종 있다고 한다.
절차
- 반복문에서 사용하는 컬렉션을 가리키는 변수를 하나 만든다.
- 반복문의 첫 줄부터 시작해서, 각각의 단위 행위를 적절한 컬렉션 파이프라인 연산으로 대체한다.
- 반복문의 모든 동작을 대체했다면 반복문 자체를 지운다.
8.9 죽은 코드 제거하기(Remove Dead Code)
if (false)
{
Console.WriteLine("unreachable code");
}
...
배경
빌드전 Debug코드 제거하기 등..
코드가 더 이상 사용되지 않는다면 지워야 한다.
절차
- 죽은 코드를 외부에서 참조할 수 있는 경우라면 혹시라도 호출하는 곳이 있는지 확인한다.
- 없다면 죽은 코드를 제거한다.
- 테스트한다.
느낀점
- 이번 장은 이미 리팩터링 단계보다 그 전 단계에서 이뤄질 수 있는 작업 같다는 생각이 든다.
- 기능을 이동시키는 부분이 제대로 실행되려면 참조가 복잡하지 않고, 모듈화가 선행되어야 편할 것 같다는 느낌이 먼저 드는 것 같다. (엉킨 실타래)
논의사항
과거 죽은 코드를 제거한다고 되게 많이 정리했다가 직렬화로 참조된 코드까지 지워버러서 곤란했던 경험이 있습니다.
그때의 경험으로 게임잼이나 단기 프로젝트말고는 대부분 코드레벨에서 데이터를 참조하고 다룰려고 하는 편입니다..
혹시 죽은 코드를 제거하실 때 어떤 방식으로 접근하시는지 궁금합니다.
9. 데이터 조직화
데이터 구조는 프로그램의 중요한 역할을 수행하니 데이터 구조에 집중한 리팩터링만 모아놨다.
9.1 변수 쪼개기
int temp = 2 * (height + width);
Console.WriteLine(temp);
temp = height * width;
Console.WriteLine(temp);
const int perimeter = 2 * (height + width);
Console.WriteLine(perimeter);
const int area = height * width;
Console.WriteLine(area);
배경
변수는 다양한 용도로 쓰이는데, 가끔 변수에 여러 번 값을 대입할 수밖에 없는 상황도 있다.
예를 들어 반복문의 경우 i
라는 변수는 루프변수로 반복문을 반복할때 마다 값이바뀐다.
수집 변수의 경우는 메서드가 동작하는 중간중간 값을 저장한다.
그 외에도 변수는 긴 코드의 결과를 저장했다가 나중에 쉽게 참고하려는 목적으로 흔히 쓰인다.
이런 변수에는 값을 단 한 번만 대입해야 한다.
대입이 두 번 이상 이뤄진다면 여러 가지 역할을 수행한다는 신호다.
역할이 둘 이상인 변수가 있다면 쪼개야 한다.
예외는 없다.
역할 하나당 변수 하나다.
여러 용도로 쓴 변수는 코드를 읽는 이에게 커다란 혼란을 주기 때문이다.
절차
- 변수를 선언한 곳과 값을 처음 대입하는 곳에서 변수 이름을 바꾼다.
- 가능하면 이때 불변으로 선언한다.
- 이 변수에 두 번째로 값을 대입하는 곳 앞까지의 모든 참조를 새로운 변수 이름으로 바꾼다.
- 두 번째 대입 시 변수를 원래 이름으로 다시 선언한다.
- 테스트한다.
- 반복한다.
예시
이번 예는 해기스라는 음식이 다른 지역으로 전파된 거리를 구하는 코드를 살펴본다.
public void DistanceTravelled(Scenario scenario, int time)
{
int result;
int acc = scenario.primaryForce / scenario.mass; // 가속도 = 힘 / 질량
int primaryTime = Math.Min(time, scenario.delay);
result = 0.5 * acc * primaryTime * primaryTime; // 전파된 거리
int secondaryTime = time - scenario.delay;
if (secondaryTime > 0) // 두 번째 힘을 반영해 다시 계산
{
int primaryVelocity = acc * scenario.delay;
acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime * secondaryTime;
}
return result;
}
이 함수를 살펴보면 acc변수에 값이 두 번 대입되는 것을 알 수 있다.
즉, 역할이 두개라는 신호로 하나는 첫 번째 힘을 유발한 가속도를 저장하는 역할이고, 다른 하나는 두 번째 힘까지 반영된 후의 가속도를 저장하는 역할이다.
여기서 사실 최근의 IDE는 변수 이름을 일괄변경하는 고급진 기술을 가지고 있다.
public void DistanceTravelled(Scenario scenario, int time)
{
int result;
const int primaryAcceleration = scenario.primaryForce / scenario.mass; // 가속도 = 힘 / 질량
int primaryTime = Math.Min(time, scenario.delay);
result = 0.5 * primaryAcceleration * primaryTime * primaryTime; // 전파된 거리
int secondaryTime = time - scenario.delay;
if (secondaryTime > 0) // 두 번째 힘을 반영해 다시 계산
{
int primaryVelocity = primaryAcceleration * scenario.delay;
const int secondaryAcceleration = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
result += primaryVelocity * secondaryTime + 0.5 * secondaryAcceleration * secondaryTime * secondaryTime;
}
return result;
}
9.2 필드 이름 바꾸기
public class Organization
{
public string Name { get; set; }
}
public class Organization
{
public string Title { get; set; }
}
배경
다시 한번 강조하지만 이름은 중요하다.
그리고 특히 프로그램 곳곳에서 쓰이는 레코드 구조체의 필드 이름들은 더욱 중요하다.
데이터 구조가 중요하다는 이유는 다들 알테니 이러한 구조를 잘 관리하는 것도 중요하다.
절차
- 레코드의 유효 범위가 제한적이라면 필드에 접근하는 모든 코드를 수정한 후 테스트한다.
- 레코드가 캡슐화되지 않았다면 먼저 캡슐화를 한다.
- 캡슐화된 객체 안의 private 필드명을 변경하고, 그에 맞게 내부 메서드들을 수정한다.
- 테스트한다.
- 생성자의 매개변수 중 필드와 이름이 겹치는 게 있다면 함수 선언 바꾸기로 변경한다.
- 접근자들의 이름도 바꿔준다.
예시
public class Organization
{
public string Name { get; set; }
}
public class Organization
{
public string Title { get; set; }
}
여기서 Name
을 Title
로 바꾸는 것은 쉽다.
이 객체가 코드 베이스 곳곳에서 사용되어 Name
이라는 이름이 충돌이 날 수 있다면, 먼저 캡슐화를 한다.
public class Organization
{
private string _title;
public string Title
{
get => _title;
set => _title = value;
}
}
사실 이 부분에서 캡슐화만 진행해도 사용되는 부분이 전부 드러나고 해당 코드를 변경하는게 더 유리해 보인다.
백개라고 해서 내부 클래스에서 name과 title을 둘다 사용가능하게 하는 것은 비효율적이라고 판단된다.
9.3 파생 변수를 질의 함수로 바꾸기
get
{
return _quantity * _itemPrice -
Math.Max(0, _quantity - 500) * _itemPrice * 0.05 +
Math.Min(_quantity * _itemPrice * 0.1, 100.0);
}
get
{
return BasePrice - QuantityDiscount + Shipping;
}
배경
가변 데이터는 소프트웨어에 문제를 일으키는 경우 가장 큰 골칫거리에 속한다.
가변 데이터는 서로 다른 두 코드를 이상한 방식으로 결합하기도 하는데, 예컨대 한 쪽 코드에서 수정한 값이 연쇄 효과를 일으켜 다른 쪽 코드에 원인을 찾기 어려운 문제를 야기하기도 한다.(사이드 이펙트)
그렇다고 가변 데이터를 완전히 배제하기란 현실적으로 불가능할 때가 많지만, 가변 데이터의 유효 범위를 가능한 한 좁혀야 한다고 힘주어 주장해본다.
효과가 좋은 방법으로, 값을 쉽게 계산해낼 수 있는 변수들을 모두 제거할 수 있다.
계산 과정을 보여주는 코드 자체가 데이터의 의미를 더 분명히 드러내는 경우도 자주 있으며 변경된 값을 깜빡하고 결과 변수에 반영하지 않는 실수를 막아준다.
여기에는 합당한 예외가 존재하는데 피연산자 데이터가 불변이라면 계산 결과도 일정하므로 역시 불변으로 만들 수 있다. (보장된다.)
그래서 새로운 데이터 구조를 생성하는 변형 연산이라면 비록 계산 코드로 대체할 수 있더라도 그대로 두는 편이 좋다.
절차
- 변수 값이 갱신되는 지점을 모두 찾는다.
- 해당 변수값을 계산해주는 함수를 만든다.
- 해당 변수가 사용되는 모든 곳에 어서션을 추가하여 함수의 계산 결과가 변수의 값과 같은지 확인한다.
- 테스트한다.
- 변수를 읽는 코드를 모두 함수 호출로 대체한다.
- 테스트한다.
- 변수를 선언하고 갱신하는 코드를 죽은 코드 제거하기로 없앤다.
9.4 참조를 값으로 바꾸기
public class Product
{
public void ApplyDiscount(int discount)
{
this.price.amount -= discount;
}
}
public class Product
{
public void ApplyDiscount(int discount)
{
this.price = new Money(this.price.amount - discount);
}
}
배경
객체를 다른 객체에 중첩하면 내부 객체를 참조 혹은 값으로 취급할 수 있다.
참조냐 값이냐의 차이는 내부 객체에 속성을 갱신하는 방식에서 가장 극명하게 드러난다.
참조로 다루는 경우에는 내부 객체는 그대로 둔 채 그 객체의 속성만 갱신하며, 값으로 다루는 경우에는 새로운 속성을 담은 객체로 기존 내부 객체를 통째로 대체한다.
필드를 값으로 다룬다면 내부 객체의 클래스를 수정하여 값 객체로 만들 수 있다.
값 객체는 대체로 자유롭게 활용하기 좋은데, 특히 불변이기 때문이다.
불변 데이터 값은 프로그램 외부로 건네줘도 나중에 그 값이 바뀌거나 영향을 주지 않을까 걱정하지 않아도 된다.
값을 복제해 이곳저곳 활용하더라도 서로 간의 참조를 관리하지 않아도 된다.
값 객체는 분산 시스템과 동시성 시스템에서 특히 유용하다.
절차
- 후보 클래스가 불변인지, 혹은 불변이 될 수 있는지 확인한다.
- 각각의 세터를 하나씩 제거한다.
- 이 값 객체의 필드들을 사용하는 동치성 비교 메서드를 만든다.
예시
public class Person
{
private TelephoneNumber telephoneNumber;
pulbic Person()
{
this.telephoneNumber = new TelephoneNumber();
}
public string GetOfficeAreaCode()
{
return this.telephoneNumber.areaCode;
}
public void SetOfficeAreaCode(string arg)
{
this.telephoneNumber.areaCode = arg;
}
public string GetOfficeNumber()
{
return this.telephoneNumber.number;
}
public void SetOfficeNumber(string arg)
{
this.telephoneNumber.number = arg;
}
}
public class TelephoneNumber
{
public string areaCode;
public string number;
}
이 코드에서 참조를 값으로 변경하기 위해선 가장 먼저 TelephoneNumber
클래스를 불변으로 만들어야 한다.
public class TelephoneNumber
{
private string _areaCode;
private string _number;
public TelephoneNumber(string areaCode, string number)
{
this._areaCode = areaCode;
this._number = number;
}
public string GetAreaCode()
{
return this._areaCode;
}
public string GetNumber()
{
return this._number;
}
}
이후 세터를 호출하는 부분에서 전화번호를 매번 다시 대입하도록 바꿔야 한다. (Set에서 항상 새로운 객체를 할당한다면 이는 불변이 보장됨)
public class Person
{
private TelephoneNumber telephoneNumber;
pulbic Person()
{
this.telephoneNumber = new TelephoneNumber();
}
public string GetOfficeAreaCode()
{
return this.telephoneNumber.GetAreaCode();
}
public void SetOfficeAreaCode(string arg)
{
this.telephoneNumber = new TelephoneNumber(arg, this.telephoneNumber.GetNumber());
}
public string GetOfficeNumber()
{
return this.telephoneNumber.GetNumber();
}
public void SetOfficeNumber(string arg)
{
this.telephoneNumber = new TelephoneNumber(this.telephoneNumber.GetAreaCode(), arg);
}
}
9.5 값을 참조로 바꾸기
var customer = new Customer(customerData);
var customer = customerRepository.Get(customerData.id);
배경
하나의 데이터 구조 안에 논리적으로 똑같은 제3의 데이터 구조를 참조하는 레코드가 여러개 있을 때가 있다.
예컨대 주문 목록을 읽다 보면 같은 고객이 요청한 주문이 여러 개 섞여 있을 수 있다.
이때 고객을 값으로도, 혹은 참조로도 다룰 수 있다.
값으로 다루게 다룬다면 고객 데이터가 각 주문에 복사되고, 참조로 다룬다면 여러 주문이 단 하나의 데이터 구조를 참조하게 된다.
고객 데이터를 갱신할 일이 없다면 어느 방식이든 상관없다.
같은 데이터를 여러 벌 복사하는게 조금 꺼림칙할지 모르지만, 별달리 문제되는 경우는 많지 않아서 흔히 사용되는 방식이다.
복사본이 많이 생겨서 가끔은 메모리가 부족할 수도 있지만, 다른 성능 이슈와 마찬가지로 아주 드문 일이다.
논리적으로 같은 데이터를 물리적으로 복제해 사용할 때 가장 크게 문제되는 상황은 그 데이터를 갱신해야 할 때다.
모든 복제본을 찾아서 빠짐없이 갱신해야 하며, 하나라도 놓치면 데이터 일관성이 깨져버린다.
이런 상황이라면 데이터를 참조로 바꿔주는 것이 좋다. (데이터가 하나면 갱신된 내용이 해당 고객의 주문 모두에 곧바로 반영되기 때문이다.)
값을 참조로 바꾸면 엔티티 하나당 객체도 단 하나만 존재하게 되는데, 그러면 보통 이런 객체들을 한데 모아놓고 클라이언트의 접근을 관리해주는 일종의 저장소가 필요해진다.
각 엔티티를 표현하는 객체를 한 번만 만들고, 객체가 필요한 곳에서는 모두 이 저장소로부터 얻어 쓰는 방식이 된다.
절차
- 같은 부류에 속하는 객체들을 보관할 저장소를 만든다.
- 생성자에서 이 부류의 객체들 중 특정 객체를 찾아내는 방법이 있는지 확인한다. (key 등등)
- 호스트 객체의 생성자들을 수정하여 필요한 객체를 이 저장소에서 찾도록 한다.
예시
public class Order
{
public Order(Data data)
{
this.number = data.number;
this.customer = new Customer(data.customer);
}
public Customer GetCustomer()
{
return this.customer;
}
}
public class Customer
{
public Customer(Data data)
{
this.id = data.id;
}
}
이런 방식으로 생성한 고객 객체는 값이다.
고객 ID가 123인 주문을 다섯 개 생성한다면 독립된 고객 객체가 다섯 개 만들어진다.
이 중 하나를 수정하더라도 나머지 네 개에는 반영되지 않는다.
이 처럼 고객 객체의 중복이 발생할 수 있는 가능성이 있다면 고객 객체를 참조로 바꾸는 것이 좋다.
설상가상으로 불변이 보장되지 않기 때문에 스파게티 코드의 조짐이 보인다.
항상 물리적으로 똑같은 객체를 사용하고 싶다면 먼저 이 유일한 객체를 저장해둘 곳이 있어야 한다.
객체를 어디에 저장해야 할지는 애플리케이션에 따라 다르겠지만, 간단한 상황이라면 저장소 객체를 사용한다.
public class Order
{
public Order(Data data)
{
this.number = data.number;
this.customer = CustomerRepository.Get(data.customerId);
}
public Customer GetCustomer()
{
return this.customer;
}
}
public class Customer
{
public Customer(Data data)
{
this.id = data.id;
}
}
이렇게 특정 주문과 관련된 고객 정보를 갱신하면 같은 고객을 공유하는 주문 모두에서 갱신된 데이터를 사용하게 된다.
9.6 매직 리터럴 바꾸기
public int PotentialEnergy(double mass, double height)
{
return mass * 9.81 * height;
}
public const double Gravity = 9.81;
public int PotentialEnergy(double mass, double height)
{
return mass * Gravity * height;
}
배경
매직 리터럴이란 소스 코드에 등장하는 일반적인 리터럴 값을 말한다.
예컨대 움직임을 계산하는 코드에서라면 9.80665라는 숫자가 산재해 있는 모습을 목격할 수 있다.
이 의미를 이해하고 알고 있더라도 코드 자체에서 뜻을 명확하게 해주는 것이 좋다.
상수를 정의하고 숫자 대신 상수를 사용하도록 바꾸면 될 것이다.
느낀점
개인적으로 데이터 불변에 관련해서 9.4, 9.5가 되게 흥미로웠다.
생각하지 못했던 부분도 있어서 시간이 생긴다면 한번 더 보게될 것 같은 내용이었다.
논의사항
작성하신 코드 중에서 9.4, 9.5를 적용하면 좋아질 수 있는 구조가 보이시나요?
10. 조건부 로직 간소화
조건부 로직은 프로그램의 힘을 강화하는 데 크게 기여하지만, 반대로 프로그램을 복잡하게 만드는 주요 원흉이기도 하다.
10.1 조건문 분해하기
if (date.before(SUMMER_START) || date.after(SUMMER_END))
charge = quantity * winterRate + winterServiceCharge;
else
charge = quantity * summerRate;
if (Summer())
charge = winterCharge();
else
charge = summerCharge();
배경
다양한 조건, 그에 따라 동작도 다양한 코드를 작성하면 순식간에 꽤 긴 함수가 탄생한다.
긴 함수는 그 자체로 읽기가 어렵지만, 조건문은 그 어려움을 한층 가중시킨다.
조건을 검사하고 그 결과에 따른 동작을 표현한 코드는 무슨 일이 일어나는지는 이야기해주지만 왜
일어나는지는 제대로 말해주지 않을 때가 많은 것이 문제다.
거대한 코드 블록이 주어지면 코드를 부위별로 분해한 다음 해체된 코드 덩어리들을 각 덩어리의 의도를 살린 이름의 함수 호출로 바꿔주자.
그러면 전체적인 의도가 더 확실히 드러난다.
절차
- 조건식과 그 조건식에 딸린 조건절 각각을 함수로 추출한다.
10.2 조건식 통합하기
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return ((anEmployee.seniority < 2)
|| (anEmployee.monthsDisabled > 12)
|| (anEmployee.isPartTime));
}
배경
비교하는 조건은 다르지만 그 결과로 수행하는 동작은 똑같은 코드들이 더러 있는데, 어차피 같은 일을 할 거라면 조건 검사도 하나로 통합하는게 낫다.
이럴 때 and
연산자나 or
연산자를 사용하면 여러 개의 비교 로직을 하나로 합칠 수 있다.
조건부 코드를 통합하는 게 중요한 이유는 두 가지다.
첫째, 여러 조각으로 나뉜 조건들을 하나로 통합함으로써 내가 하려는 일이 더 명확해진다.
나눠서 순서대로 비교해도 결과는 같지만, 읽는 사람은 독립된 검사들이 우연히 함께 나열된 것으로 오해할 수 있다.
두 번째 이유는 이 작업이 함수 추출하기까지 이어질 가능성이 높기 때문이다.
복잡한 조건식을 함수로 추출하면 코드의 의도가 훨씬 분명하게 드러나는 경우가 많다.
함수 추출하기는 무엇
을 하는지를 기술하던 코드를 왜
하는지를 말해주는 코드로 바꿔주는 효과적인 도구임을 기억하자.
절차
- 해당 조건식들 모두에 부수효과가 없는지 확인한다.
- 조건문 두 개를 선택하여 두 조건문의 조건식을 논리 연산자로 결합한다.
- 테스트한다.
- 조건이 하나만 남을 때까지 과정을 반복한다.
- 하나로 합쳐진 조건식을 함수로 추출할지 고려해본다.
10.3 중첩 조건문을 보호 구문으로 바꾸기
public int GetPayAmount() {
int result;
if (isDead) result = deadAmount();
else {
if (isSeparated) result = separatedAmount();
else {
if (isRetired) result = retiredAmount();
else result = normalPayAmount();
}
}
return result;
}
public int GetPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}
배경
조건문은 주로 두가지 형태로 쓰인다.
참인 경로와 거짓인 경로 모두 정상 동작으로 이어지는 형태, 한쪽만 정상인 형태다.
두 형태는 의도하는 바가 서로 다르므로 그 의도가 코드에 드러나야 한다.
두 경로 모두 정상 동작이라면 if와 else절을 사용한다.
한쪽만 정상이라면 비정상 조건을 if에서 검사한 다음, 조건이 참(비정상이면) 함수에서 빠져나온다.
두 번째 검사 형태를 흔히 보호 구문이라고 한다.
중첩 조건문을 보호 구문으로 바꾸기 리팩터링의 핵심은 의도를 부각하는 데 있다.
절차
- 교체해야 할 조건 중 가장 바깥 것을 선택하여 보호 구문으로 바꾼다.
- 테스트한다.
- 위 과정을 필요한 만큼 반복한다.
- 모든 보호 구문이 같은 결과를 반환한다면 보호 구문들의 조건식을 통합한다.
10.4 조건부 로직을 다형성으로 바꾸기
switch (bird.Type) {
case '유럽제비':
return "보통이다"
case '아프리카 제비':
return (bird.NumberOfCoconuts > 2) ? "지쳤다" : "보통이다";
case '노르웨이 파랑 앵무':
return (bird.Voltage > 100) ? "그을렸다" : "예쁘다";
default:
return "알 수 없다";
}
class EuropeanSwallow {
GetPlumb() {
return "보통이다";
}
}
...
class NorwegianBlueParrot {
GetPlumb() {
return (bird.Voltage > 100) ? "그을렸다" : "예쁘다";
}
}
...
class AfricanSwallow {
GetPlumb() {
return (bird.NumberOfCoconuts > 2) ? "지쳤다" : "보통이다";
}
}
배경
복잡한 조건부 로직은 프로그래밍에서 해석하기 가장 난해한 대상에 속한다.
종종 더 높은 수준의 개념을 도입하여 이 조건들을 분리해낼 수 있다.
조건문 구조를 그대로 둔 채 해결될 때도 있지만, 클래스와 다형성을 이용하면 더 확실하게 분리할 수도 있다.
흔한 예로, 타입을 여러 개 만들고 각 타입이 조건부 로직을 자신만의 방식으로 처리하도록 구성하는 방법이 있다.
switch문을 구성해 case별로 클래스를 각각 만들어 공통된 로직으로 만들어 중복을 없앨 수 있다.
또 다른 예로, 기본 동작을 위한 case문과 그 변형 동작으로 구성된 로직을 떠올릴 수 있다.
기본 동작은 가장 일반적이거나 가장 직관적인 동작일 것이다.
먼저 이 로직을 슈퍼클래스로 넣어서 변형 동작에 신경 쓰지 않고 기본에 집중하게 한다.
그런 다음 변형 동작을 뜻하는 case들을 각각의 서브클래스로 만든다.
이 서브클래스들은 기본 동작과의 차이를 표현하는 코드로 채워질 것이다.
다형성은 객체 지향 프로그래밍의 핵심이다.
하지만 (유용한 기능들이 늘 그렇듯) 남용하기 쉽다. 실제로 모든 조건부 로직을 다형성으로 대체해야 한다고 주장하는 사람도 있지만, 그 견해에 동의하지 않는다..
조건부 로직 대부분의 기본문인 if, else, switch는 기본 도구로 이뤄지기 때문이다.
물론 앞서 이야기 한 방법대로 복잡한 조건부 로직을 발견한다면 다형성이 얼마나 막강한 도구인지 알게 될 수 있다.
개인적인 의견도 동의한다. 유용한 도구이긴 하지만 남발해선 안된다. 오히려 거버넌스나 엔트로피가 증가해 보기에 안좋을 수 있음
절차
- 다형적 동작을 표현하는 클래스들이 아직 없다면 만들어준다. 이왕이면 적합한 인스턴스를 알아서 만들어 반환하는 팩터리 함수도 함께 만든다.
- 호출하는 코드에서 팩터리 함수를 사용하게 한다.
- 조건부 로직 함수를 슈퍼클래스로 옮긴다.
- 서브클래스 중 하나를 선택한다. 서브클래스에서 슈퍼클래스의 조건부 로직 메서드를 오버라이드한다. 조건부 문장 중 선택된 서브 클래스에 해당하는 조건절을 서브클래스 메서드로 복사한 다음 적절히 수정한다.
- 같은 방식으로 각 조건절을 해당 서브클래스에서 메서드로 구현한다.
- 슈퍼클래스 메서드에는 기본 동작 부분만 남긴다. 혹은 슈퍼클래스가 추상 클래스여야 한다면, 이 메서드를 추상으로 선언하거나 서브 클래스로 처리해야 함을 알리는 에러를 던진다.
10.5 특이 케이스 추가하기
if (customer == "미확인 고객") customerName = "거주자";
class UnknownCustomer {
public string GetName {
return "거주자";
}
}
배경
데이터 구조의 특정 값을 확인한 후 똑같은 동작을 수행하는 코드가 곳곳에 등장하는 경우가 더러 있는데, 흔히 볼 수 있는 중복 코드 중 하나다.
이처럼 코드베이스에서 특정 값에 대해 똑같이 반응하는 코드가 여러 곳이라면 그 반응들을 한 데로 모으는 게 효율적이다.
특수한 경우의 공통 동작을 요소 하나에 모아서 사용하는 특이 케이스 패턴이라는 것이 있는데, 바로 이럴 때 적용하면 좋은 메커니즘이다.
이 패턴을 활용하면 특이 케이스를 확인하는 코드 대부분을 단순한 함수 호출로 바꿀 수 있다.
널은 특이 케이스로 처리해야할 때가 만다. 그래서 이 패턴을 넉 객체 패턴이라고도 한다.
과거 널 객체 패턴에 대한 정리를 한적이 있어서 해당 글을 첨부한다.
하지만 널 외의 다른 특이 케이스에도 같은 패턴을 적용할 수 있으니, 널 객체가 특이 케이스의 특수한 예라고 보는 것이 맞다.
절차
- 컨테이너에 특이 케이스인지를 검사하는 속성을 추가하고, false를 반환하게 한다.
- 특이 케이스 객체를 만든다. 이 객체는 특이 케이스인지를 검사하는 속성만 포함하며, 이 속성은 true를 반환하게 한다.
- 클라이언트에서 특이 케이스인지를 검사하는 코드를 함수로 추출한다. 모든 클라이언트가 값을 직접 비교하는 대신 방금 추출한 함수를 사용하도록 고친다.
- 코드에 새로운 특이 케이스 대상을 추가한다. 함수의 반환 값으로 받거나 변환 함수를 적용하면 된다.
- 특이 케이스를 검사하는 함수 본문을 수정하여 특이 케이스 객체의 속성을 사용하도록 한다.
- 테스트한다.
- 여러 함수를 클래스로 묶기나 여러 함수를 변환 함수로 묶기를 적용하여 특이 케이스를 처리하는 공통 동작을 새로운 요소로 옮긴다.
- 아직도 특이 케이스 검사를 이용하는 곳이 남아 있다면 검사 함수를 인라인 한다.
10.6 어서션 추가하기
if (this.discountRate) {
basePrice -= basePrice * this.discountRate;
}
Assert.IsTrue(this.discountRate >= 0);
if (this.discountRate) {
basePrice -= basePrice * this.discountRate;
}
배경
특정 조건이 참일 때만 제대로 동작하는 코드 영역이 있을 수 있다.
단순한 예로, 제곱근 계산은 입력이 양수일 때만 정상 동작한다.
객체로 눈을 돌리면 여러 필드 중 최소 하나에는 값이 들어 있어야 동작하는 경우를 생각할 수 있다.
이런 가정이 코드에 항상 명시적으로 기술되어 있지는 않아서 알고리즘을 보고 연역해서 알아내야 할 때도 있다.
좋은 방법으로 어서션을 이용해서 코드 자체에 삽입해놓는 것이다.
어서션은 항상 참이라고 가정하는 조건부 문장으로, 어서션에 실패했다는 건 프로그래머가 잘못했다는 뜻이다.
절차
- 참이라고 가정하는 조건이 보이면 그 조건을 명시하는 어서션을 추가한다.
10.7 제어 플래그를 탈출문으로 바꾸기
foreach (var p in people) {
if (!found) {
if (p == "조커") {
SendAlert();
found = true;
}
}
}
foreach (var p in people) {
if (p == "조커") {
SendAlert();
break;
}
}
배경
제어 플래그란 코드의 동작을 변경하는 데 사용되는 변수를 말하며, 어딘가에서 값을 계산해 제어 플래그에 설정한 후 다른 어딘가의 조건문에서 검사하는 형태로 쓰인다.
플래그 자체는 코드 악취로 볼 수 있다.
리팩터링으로 충분히 간소화가 가능할 수 있음에도 복잡하게 작성된 코드에서 흔히 나타나기 때문이다.
제어 플래그의 주 서식지는 반복문 안이다.
break문이나 continue문 활용에 익숙하지 않은 사람이 심어놓기도 하고 함수의 return을 하나로 유지하고자 노력하는 사람이 심기도 한다.
절차
- 제어 플래그를 사용하는 코드를 함수로 추출할지 고려한다.
- 제어 플래그를 갱신하는 코드를 각각을 적절한 제어문으로 바꾼다. 하나 바꿀 때마다 테스트한다.
- 모두 수정했다면 제어 플래그를 제거한다.
느낀점
이번 장이 제일 얻어가는게 많은 장이라고 생각된다.
객체지향의 적인 부분과 기본 조건식에서 자주 나오게 되는 문제점을 배경을 통해 알 수 있었다.
논의사항
- x
11. API 리팩터링
모듈과 함수는 소프트웨어를 구성하는 빌딩 블록이며, API는 이 블록들을 끼워 맞추는 연결부다.
이런 API를 이해하기 쉽고 사용하기 쉽게 만드는 일은 중요한 동시에 어렵기도 하다.
그래서 API를 개선하는 방법을 새로 깨달을 때마다 그에 맞게 리팩터링해야 한다.
11.1 질의 함수와 변경 함수 분리하기
public Customer GetTotalOutstandingAndSendBill() {
double result = 0;
Enumeration e = _orders.elements();
while (e.hasMoreElements()) {
Order each = (Order) e.nextElement();
result += each.GetAmount();
}
SendBill();
return result;
}
public void SendBill() {
// 구현부
}
배경
우리는 외부에서 관찰할 수 있는 부수효과가 전혀 없이 값을 반환해주는 함수를 추구해야 한다.
이런 함수는 어느 때건 원하는 만큼 호출해도 아무 문제가 없다.
호출하는 문장의 위치를 호출하는 함수 안 어디로든 옮겨도 되며 테스트하기도 쉽다.
한마디로, 이용할 때 신경 쓸 거리가 매우 적다.
부수효과가 있는 함수와 없는 함수는 명확히 구분하는 것이 좋다.
이를 위한 한 가지 방법은 질의 함수는 모두 부수효과가 없어야 한다.
는 규칙을 따르는 것이다.
이를 명령-질의 분리라 하는데, 이 규칙을 절대적으로 신봉하는 프로그래머도 있다.
필자는 100% 동의하지 않지만 그래도 되도록이면 따르려 노력하며 실제로 효과도 많이 봤다.
값을 반환하면서 부수효과가 있는 함수를 발견하면 상태를 변경하는 부분과 질의하는 부분을 분리하려 시도한다. (무조건)
절차
- 대상 함수를 복제하고 질의 목적에 충실한 이름을 짓는다.
- 새 질의 함수에서 부수효과를 모두 제거한다.
- 정적 검사를 수행한다.
- 원래 함수를 호출하는 곳을 모두 찾아낸다. 호출하는 곳에서 반환 값을 사용한다면 질의 함수를 호출하도록 바꾸고, 원래 함수를 호출하는 코드를 바로 아래 줄에 새로 추가한다. 하나 수정할 때마다 테스트한다.
- 원래 함수에서 질의 관련 코드를 제거한다.
- 테스트한다.
11.2 함수 매개변수화하기
public void TenPercentRaise() {
_salary *= 1.1;
}
public void FivePercentRaise() {
_salary *= 1.05;
}
public void Raise(double factor) {
_salary *= (1 + factor);
}
배경
구 함수의 로직이 아주 비슷하고 단지 리터럴 값만 다르다면, 그 다른 값만 매개변수로 받아 처리하는 함수 하나로 합쳐서 중복을 없앨 수 있다.
이렇게 하면 매개변수 값만 바꿔서 여러 곳에서 쓸 수 있으니 함수의 유용성이 커진다.
절차
- 비슷한 함수 중 하나를 선택한다.
- 함수 선언 바꾸기로 리터럴들을 매개변수로 추가한다.
- 이 함수를 호출하는 곳 모두에 적절한 리터럴 값을 추가한다.
- 테스트한다.
- 매개변수로 받은 값을 사용하도록 함수 본문을 수정한다. 하나 수정할 때마다 테스트한다.
- 비슷한 다른 함수를 호출하는 코드를 찾아 매개변수화된 함수를 호출하도록 하나씩 수정한다. 하나씩 수정할 때마다 테스트한다.
11.3 플래그 인수 제거하기
public void SetDimension(string name, int value)
{
if (name.Equals("height")) {
_height = value;
return;
}
if (name.Equals("width")) {
_width = value;
return;
}
}
public int Height {
set { _height = value; }
}
public int Width {
set { _width = value; }
}
C#은 프로퍼티로 더 유용하게 처리
배경
플래그 인수란 호출되는 함수가 실행할 로직을 호출하는 쪽에서 선택하기 위해 전달하는 인수다.
플래그 인수는 호출할 수 있는 함수들이 무엇이고 어떻게 호출해야 하는지를 이해하기가 어려워지기 때문에 사용하지 않는 편이 좋다.
문장 구조 자체가 읽는 사람이 해석하며 읽어야 하기에 부담이 큼
특정한 기능 하나만 수행하는 명시적인 함수를 제공하는 편이 훨씬 깔끔하다.
절차
- 매개변수로 주어질 수 있는 값 각각에 대응하는 명시적 함수들을 생성한다.
- 원래 함수를 호출하는 코드들을 모두 찾아서 각 리터럴 값에 대응되는 명시적 함수를 호출하도록 수정한다.
11.4 객체 통째로 넘기기
const int low = aRoom.DaysTempRange.Low;
const int high = aRoom.DaysTempRange.High;
if (aPlan.WithinRange(low, high))
if (aPlan.WithinRange(aRoom.DaysTempRange))
배경
하나의 레코드에서 값 두어 개를 가져와 인수로 넘기는 코드 보다 그 값들 대신에 레코드를 통째로 넘기고 함수 본문에서 필요한 값을 꺼내 쓰는 편이 더 나을 때가 많다.
레코드를 통째로 넘기면 변화에 대응하기 쉽다.(공감)
예컨대 그 함수가 더 다양한 데이터를 사용하도록 바뀌어도 매개변수 목록은 수정할 필요가 없다.
그리고 매개변수 목록이 짧아져서 일반적으로는 함수 사용법을 이해하기 쉬워진다.
레코드에 담긴 데이터 중 일부를 받는 함수가 여러 개라면 그 함수들끼리는 같은 데이터를 사용하는 부분이 있을 것이고, 그 부분의 로직이 중복될 가능성이 커진다.
레코드를 통째로 넘긴다면 이런 로직 중복도 없앨 수 있다.
하지만 함수가 레코드 자체에 의존하기를 원치 않을 때는 이 리팩터링을 수행하지 않는데, 레코드와 함수가 서로 다른 모듈에 속한 상황이면 특히 더 그렇다.
어떤 객체로부터 값 몇 개를 얻은 후 그 값들만으로 무언가를 하는 로직이 있다면 그 로직을 객체 안으로 집어넣어야 함을 알려주는 악취로 봐야 한다.
그래서 객체를 통째로 넘기기는 특히 매개변수 객체 만들기 후, 즉 산재한 수많은 데이터 더미를 새로운 객체로 묶은 후 적용하곤 한다.
한편, 한 객체가 제공하는 기능 중 항상 똑같은 일부만을 사용하는 코드가 많다면, 그 기능만 따로 묶어서 클래스로 추출하라는 신호일 수 있다.
절차
- 매개변수들을 원하는 형태로 받는 빈 함수를 만든다.
- 새 함수의 본문에서는 원래 함수를 호출하도록 하며, 새 매개변수와 원래 함수의 매개변수를 매핑한다.
- 정적 검사를 수행한다.
- 모든 호출자가 새 함수를 사용하게 수정한다. 하나씩 수정하며 테스트한다.
- 호출자를 모두 수정했다면 원래 함수를 인라인한다.
- 새 함수의 이름을 적절히 수정하고 모든 호출자에 반영한다.
11.5 매개변수를 질의 함수로 바꾸기
AvailableVacation(anEmployee, anEmployee.Grade);
public int AvailableVacation(Employee anEmployee, int grade) {
// ...
}
AvailableVacation(anEmployee);
public int AvailableVacation(Employee anEmployee) {
int grade = anEmployee.Grade;
// ...
}
배경
매개변수 목록은 함수의 변동 요인을 모아놓은 곳이다.
즉, 함수의 동작에 변화를 줄 수 있는 일차적인 수단이다.
다른 코드와 마찬가지로 이 목록에서도 중복은 피하는 게 좋으며 짧을수록 이해하기 쉽다.
피호출 함수가 스스로 쉽게 결정할 수 있는 값을 매개변수로 건네는 것도 일종의 중복이다.
이런 함수를 호출할 때 매개변수의 값은 호출자가 정하게 되는데, 이 결정은 사실 하지 않아도 되었을 일이니 의미 없는 코드만 복잡해질 뿐이다.
매개변수를 제거하면 값을 결정하는 책임의 주체가 달라진다.
매개변수가 있다면 결정 주체는 호출자가 되고, 매개변수가 없다면 피호출 함수가 된다.
습관적으로 호출하는 쪽을 간소화 하는 것이 좋다.
즉, 책임의 소재를 피호출 함수로 옮긴다는 뜻인데, 물론 피호출 함수가 그 역할을 수행하기에 적합할 때만 그렇게 한다.
매개변수를 질의 함수로 바꾸지 말아야 할 상황도 있다.
가장 흔한 예는 매개변수를 제거하면 피호출 함수에 원치 않는 의존성이 생길 때다.
즉, 해당 함수가 알지 못했으면 하는 프로그램 요소에 접근해야 하는 상황을 만들 때다.
새로운 의존성이 생기거나 제거하고 싶은 기존 의존성을 강화하는 경우라 할 수 있다.
이런 상황은 주로 함수 본문에서 문제의 외부 함수를 호출해야 하거나 나중에 함수 밖으로 빼내길 원하는 수용 객체에 담긴 데이터를 활용해야 할 때 일어난다.
만약 제거하려는 매개변수의 값을 다른 매개변수에 질의해서 얻을 수 있다면 안심하고 질의 함수로 바꿀 수 있다.
다른 매개변수에서 얻을 수 있는 값을 별도 매개변수로 전달하는 것은 아무 의미가 없다.
주의사항으로는 대상 함수가 참조 투명 해야 한다는 것이다.
참조 투명이란?
함수에 똑같은 값을 건네 호출하면 항상 똑같이 동작한다
라는 뜻으로 동작을 예측하고 테스트하기가 훨씬 쉬우니 이 특성이 사라지지 않도록 주의하자.
따라서 매개변수를 없애는 대신 가변 전역 변수를 이용하는 일을 하면 안 된다.
절차
- 칠요하다면 대상 매개변수의 값을 계산하는 코드를 별도 함수로 추출 해놓는다.
- 함수 본문에서 대상 매개변수로의 참조츨 모두 찾아서 그 매개변수의 값을 만들어주는 표현식을 참조하도록 바꾼다. (하나 수정할 때마다 테스트한다.)
- 함수 선언 바꾸기로 대상 매개변수를 없앤다.
11.6 질의 함수를 매개변수로 바꾸기
const int value = TargetTemperature(Plan aplan);
public int TargetTemperature(Plan aplan)
{
currentTemperature = thermostat.currentTempature;
// 생략
}
const int value = TargetTemperature(Plan aplan, thermostat.currentTemperature);
public int TargetTemperature(Plan aplan, int currentTemperature)
{
// 생략
}
배경
모드를 읽다 보면 함수 안에 두기엔 거북한 참조를 발견할 때가 있다.
싱글톤을 이용한 데이터를 받아오는 코드?
전역 변수를 참조한다거나 제거하길 원하는 원소를 참조하는 경우가 여기에 속한다.
이 문제는 해당 참조를 매개변수로 바꿔 해결할 수 있다.
참조를 풀어내는 책임을 호출자로 옮기는 것이다.
이런 상황의 대부분은 코드의 의존 관계를 바꾸려 할 때 벌어진다.
생각
극단적인 의존관계의 역전이 과연 리팩터링에 도움이 될까?
위 코드와 같이 의존성, 여기선 응집성을 제거하고 매개변수로 호출받아 사용하는 이 리팩터링 기법은 이해가 되지만,
리팩터링을 결심한 시기에는 이미 리팩터링이 늦었다는 말 처럼 의존관계를 바꾸기 시작한 시기에는 이미 의존성이 복잡하게 엉켜있을 것 같다는 생각..
책도 그 극단 사이에 균형을 잘 찾아야 한다고 말한다.
대다수의 까다로운 결정이 그렇듯, 이 역시 한 시점에 내린 결정이 영원히 옳다고 할 수는 없는 문제다.
참조 투명하지 않은 원소에 접근하는 모든 함수는 참조 투명성을 잃게 되는데, 이 문제는 해당 원소를 매개변수로 바꾸면 해결된다.
책임이 호출자로 옮겨진다는 점을 고려해야 하지만, 모듈은 참조 투명하게 만들어 얻는 장점은 대체로 아주 크다.
그래서 모듈을 개발할 때 순수 함수들을 따로 구분하고, 프로그램의 입출력과 기타 가변 원소들을 다루는 로직으로 순수함수들을 겉을 감싸는 패턴을 많이 활용한다.
전역클래스의 올바른 예
이 리팩터링의 단점은 질의 함수를 매개변수로 바꾸면 어떤 값을 제공할지를 호출자가 알아내야 한다.
결국 호출자가 복잡해지는데, 앞서 말한 것과 같이 호출자가 단순해지도록 설계하는 것이 유리하다.
절차
- 변수 추출하기로 질의 코드를 함수 본문의 나머지 코드와 분리한다.
- 함수 본문 중 해당 질의를 호출하지 않는 코드들을 별도 함수로 추출한다.
- 방금 만든 변수를 인라인하여 제거한다.
- 원래 함수도 인라인한다.
- 새 함수의 이름을 원래 함수의 이름으로 고쳐준다.
이 리팩터링을 수행하면 호출하는 쪽 코드는 전보다 다루기 어려워지는 게 보통이다. ‘의존성을 모듈 바깥으로 밀어낸다’함은 그 의존성을 처리하는 책임을 호출자에게 지운다는 뜻이기 때문이다.
11.7 세터 제거하기
public class Person
{
public string Name {get; set;}
}
public class Person
{
public string Name {get; private set;}
}
C#
의 경우는 좀 더 유연하게 세터를 제거 할 수 있다.
배경
세터 메서드가 있다고 함은 필드가 수정될 수 있다는 뜻이고 객체 생성 후에는 수정되지 않길 원하는 필드라면 세터를 제공하지 않았을 것이다.
(그래서 그 필드를 불변으로 만들었을 것이다.)
그러면 해당 필드는 오직 생성자에서만 설정되며, 수정하지 않겠다는 의도가 명명백백해지고, 변경될 가능성이 봉쇄된다.
세터 제거하기 리팩터링이 필요한 상황은 크게 두 가지다.
첫째, 사람들이 무조건 접근자 메서드를 통해서만 필드를 다루려 할 때다.
생성자에서도 세터를 사용하려 하는데, 이 논쟁에 대해 C#
은 readonly
라는 키워드를 통해 동적으로 값을 불변으로 만들 수 있다.
생성자 단위에서 초기화 되는 값들을 불변으로 만들어줘 실제로 const보다 유용하게 사용된다.
Effective C#에서도 해당 글을 다루고 있어서 같이 첨부한다.
두 번째 상황은 클라이언트에서 생성 스크립트를 사용해 객체를 생성할 때다.
생성 스크립트란?
생성자를 호출한 후 일련의 세터를 호출하여 객체를 완성하는 형태의 코드를 말한다.
설계자는 스크립트가 완료되면 그 뒤로는 객체가 변경되지 않을 것이라고 기대한다.
즉, 해당 세터들은 처음 생성될때만 호출되리라 가정한다.
이런 경우도 세터들을 제거하여 의도를 더 정확하게 전달하는게 좋다. (이거 만지면 안됨!)
절차
- 설정해야 할 값을 생성자에서 받지 않는다면 그 값을 받을 매개변수를 생성자에 추가한다. 그런 다음 생성자 안에서 적절한 세터를 호출한다.
- 생성자 밖에서 세터를 호출하는 곳을 찾아 제거하고, 대신 새로운 생성자를 사용하도록 한다. 하나 수정할 때마다 테스트한다.
- 세터 메서드를 인라인한다. 가능하다면 해당 필드를 불변으로 만든다.
- 테스트한다.
11.8 생성자를 팩터리 함수로 바꾸기
var leadEngineer = new Employee(document.leadEnginerr, 'E');
var leadEngineer = createEngineer(document.leadEngineer);
배경
많은 객체 지향 언어에서 제공하는 생성자는 객체를 초기화하는 특별한 용도의 함수이다.
실제로는 새로운 객체를 생성할 때면 주로 생성자를 호출한다.
하지만 생성자에는 일반 함수에는 없는 이상한 제약이 따라 붙기도 한다.
자바의 경우는 반드시 생성자를 정의한 클래스의 인스턴스를 반환하애 한다.
팩터리 함수는 이러한 제약 없이 다양한, 필요로 하는 기능을 추가하여 우아하게 작성가능하다.
개인적으로는 팩터리 함수 또한 생성자의 한 겹의 래퍼라는 생각이 든다.
모든 생성자를 팩토리 함수로 바꾸라는 것이 아닐 것이다.
모든 데이터를 레포 객체로 다루는 것이 아닌말과 같은..
많이 사용되고 불변 객체가 보장되어야 하며 앞 뒤로 다양한 로직이 필요할 때 적합하다.
절차
- 팩토리 함수를 만든다. 팩토리 함수의 본문에서는 원래의 생성자를 호출한다.
- 생성자를 호출하던 코드를 팩토리 함수 호출로 바꾼다.
- 하나씩 수정할 때마다 테스트한다.
- 생성자의 가시 범위가 최소가 되도록 제한한다.
11.9 함수를 명령으로 바꾸기
public void Score(int condidate, int medicalExam, int scoringGuide)
{
int result = 0;
int healthLevel = 0;
}
public class Scorer
{
private int result;
private int healthLevel;
private int condidate;
private int medicalExam;
private int scoringGuide;
public Scorer(int condidate, int medicalExam, int scoringGuide)
(
this.condidate = condidate;
this.medicalExam = medicalExam;
this.scoringGuide = scoringGuide;
)
Excute()
{
this.result = 0;
this.healthLevel = 0;
}
}
배경
함수를 그 함수만을 위한 객체 안으로 캡슐화하면 더 유용해지는 상황이 있다.
이런 객체를 가리켜 명령 객체
혹은 단순히 명령
이라 한다.
명령 객체 대부분은 메서드 하나로 구성되며, 이 메서드를 요청해 실행하는 것이 이 객체의 목적이다.
명령은 평범한 함수 메커니즘보다 훨씬 유연하게 함수를 제어하고 표현할 수 있다.
명령은 되돌리기 같은 보조 연산을 제공할 수 있으며, 수명주기를 더 정밀하게 제어하는 데 필요한 매개변수를 만들어주는 메서드를 제공할 수 있따.
상속과 훅을 이용해 사용자 맞춤형으로 만들 수도 있다.
소프트웨어 개발 용어 중에는 여러 가지 의므로 사용되는 게 많은데,
명령
도 마찬가지다.
지금 맥락에서의 명령은 요청을 캡슐화한 객체로, 디자인 패턴 중 명령 패턴을 말하는 것과 같다.
메서드 호출을 실체화한 것이다.
절차
- 대상 함수의 기능을 옮길 빈 클래스를 만든다. 클래스의 이름은 함수 이름에 기초해 짓는다.
- 방금 생성한 빈 클래스로 함수를 옮긴다.
- 명령관련 이름은 사용하는 프로그래밍 언어의 컨벤션을 따른다.(excute, call)
- 함수의 인수들 각각은 명령의 필드로 만들어 생성자를 통해 설정할지 고민해본다.
11.10 명령을 함수로 바꾸기
public class ChargeCalator
{
private Customer customer;
private Usage usage;
public ChargeCalator(Customer customer, Usage usage)
{
this.customer = customer;
this.usage = usage;
}
excute()
{
return this.customer.rate * this.usage;
}
}
public void Charge(Customer customer, Usage usage)
{
this.customer = customer;
this.usage = usage;
}
배경
명령 객체는 복잡한 연산을 다룰 수 있는 강력한 메커니즘을 제공한다.
구체적으로는, 큰 연산 하나를 여러 개의 작은 메서드로 쪼개고 필드를 이용해 쪼개진 메서드들끼리 정보를 공유할 수 있다.
또한 어떤 메서드를 호출하냐에 따라 다른 효과를 줄 수 있고 각 단계를 거치며 데이터를 조금씩 완성해갈 수도 있다.
명령의 이런 능력은 공짜가 아니다.
명령은 그저 함수를 하나 호출해 정해진 일을 수행하는 용도로 주로 쓰인다.
로직이 크게 복잡하지 않다면 명령 객체는 장점보다 단점이 크니 평범한 함수로 바꿔주는 게 낫다.
절차
- 명령을 생성하는 코드와 명령의 실행 메서드를 호출하는 코드를 함께 함수로 추출한다.
- 명령의 실행 함수가 호출하는 보조 메서드들 각각을 인라인 한다.
- 함수 선언 바꾸기를 적용하여 생성자의 매개변수 모두를 명령의 실행 메서드로 옮긴다.
- 명령의 실행 메서드에서 참조하는 필드들 대신 대응하는 매개변수를 사용하게끔 바꾼다. 하나씩 수정할 때마다 테스트한다.
- 생성자 호출과 명령의 실행 메서드 호출을 호출자 안으로 인라인 한다.
- 테스트한다.
- 죽은 코드 제거하기로 명령 클래스를 없앤다.
11.11 수정된 값 반환하기
int totalAscent = 0;
CalculateAscent();
public void CalculateAscent()
{
for (int i = 1; i < points.length; i++)
{
const verticalChange = points[i].elevation - points[i - 1].elevation;
totalAscent += (verticalChange > 0) ? verticalChange : 0;
}
}
const int totalAscent = CalculateAscent();
public int CalculateAscent()
{
int result = 0;
for (int i = 1; i < points.length; i++)
{
const verticalChange = points[i].elevation - points[i - 1].elevation;
result += (verticalChange > 0) ? verticalChange : 0;
}
return result;
}
배경
데이터가 어떻게 수정되는지를 추적하는 일은 코드에서 이해하기 가장 어려운 부분 중 하나다.
특히 같은 데이터 블록을 읽고 수정하는 코드가 여러 곳이라면 데이터가 수정되는 흐름과 코드의 흐름을 일치시키기가 상당히 어렵다.
그래서 데이터가 수정된다면 그 사실을 명확히 알려주어서, 어느 함수가 무슨일을 하는지 쉽게 알 수 있게 하는 일이 중요하다.
데이터가 수정됨을 알려주는 좋은 방법은 변수를 갱신하는 함수라면 수정된 값을 반환하여 호출자가 그 값을 변수에 담아두도록 하는 것이다.
이 방식으로 코딩하면 호출자 코드를 읽을 때 변수가 갱신될 것임을 분명히 인지하게 된다.
해당 변수의 값을 단 한 번만 정하면 될 때 특히 유용하다.
이 리팩터링은 값 하나를 계산한다는 분명한 목적이 있는 함수들에 가장 효과적이고, 반대로 값 여러 개를 갱신하는 함수에는 효과적이지 않다.
한편, 함수 옮기기의 준비 작업으로 적용하기에 좋은 리팩터링이다.
절차
- 함수가 수정된 값을 반환하게 하여 호출자가 그 값을 자신의 변수에 저장하게 한다.
- 테스트한다.
- 피호출 함수 안에 반환할 값을 가리키는 새로운 변수를 선언한다.
- 테스트한다.
- 계산이 선언과 동시에 이뤄지도록 통합한다. (즉, 선언 시점에 계산 로직을 바로 실행해 대입한다.)
- 테스트한다.
- 피호출 함수의 변수 이름을 새 역할에 어울리도록 바꿔준다.
- 테스트한다.
11.12 오류 코드를 예외로 바꾸기
if (data)
return new ShippingRules(data);
else
return -23;
if (data)
return new ShippingRules(data);
else
throw new OrderProcessingError(-23);
배경
과거에는 오류코드를 사용하는 것이 보편적이였다. 실제 C 표준 라이브러리 함수들을 보면 매직넘버 -1을 반환하는 함수가 존재한다.
예외는 프로그래밍 언어에서 제공하는 독립적인 오류 처리 메커니즘이다.
오류가 발견되면 예외를 던진다.
그러면 적절한 예외 핸들러를 찾을 때까지 콜스택을 타고 위로 전파된다.
예외를 사용하면 오류 코드를 일일이 검사하거나 오류를 식별해 콜스택 위로 던지는 일을 신경 쓰지 않아도 된다.
예외는 독자적인 흐름이 있어서 프로그램의 나머지에서는 오류 발생에 따른 복잡한 상황에 대처하는 코드를 작성하거나 읽을 일이 없게 해준다.
예외는 정교한 메커니즘이지만 대다수의 다른 정교한 메커니즘과 같이 정확하게 사용할 때만 최고의 효과를 낸다.
예외는 말 그대로 예상 밖의 동작일 때만 쓰여야 한다.
달리 말하면 프로그램의 정상 동작 범주에 들지 않는 오류를 나타낼 때만 쓰여야 한다.
괜찮은 경험 법칙이 하나 있다.
예외를 던지는 코드를 프로그램 종료 코드로 바꿔도 프로그램이 여전히 정상 동작할지를 따져보는 것이다.
정상 동작하지 않을 것 같다면 예외를 사용하지 말라는 신호다.
이때는 예외 대신 오류 검출하여 프로그램을 정상 흐름으로 되돌리게끔 처리해야 한다.
절차
- 콜스택 상위에 해당 예외를 처리할 예외 핸들러를 작성한다.
- 테스트한다.
- 해당 오류 코드를 대체할 예외와 그 밖의 예외를 구분할 식별 방법을 찾는다.
- 정적 검사를 수행한다.
- catch절을 수정하여 직접 처리할 수 있는 예외는 적절히 대처하고 그렇지 않은 예외는 다시 던진다.
- 오류 코드를 반환하는 곳에서 예외를 던지도록 수정한다. 하나씩 수정할 때마다 테스트한다.
- 모두 수정했다면 그 오류 코드를 콜스택 위로 전달하는 코드를 모두 제거한다. 하나씩 수정할 때마다 테스트한다.
11.13 예외를 사전확인으로 바꾸기
public double GetValueForPeriod(int periodNumber)
{
try
{
return values[periodNumber];
}
catch (ArrayIndexOutOfBoundsException e)
{
return 0;
}
}
public double GetValueForPeriod (int periodNumber)
{
return (periodNumber >= values.length) ? 0 : values[periodNumber];
}
배경
예외라는 개념은 프로그래밍 언어의 발전에 의미 있는 한걸음이었다.
오류 코드를 연쇄적으로 전파하던 긴 코드를 예외로 바꿔 깔끔히 제거할 수 있게 되었으니..
하지만 좋은 것들이 늘 그렇듯, 예외도 과용될 수 있다.
함수 수행 시 문제가 될 수 있는 조건을 함수 호출 전에 검사할 수 있다면, 예외를 던지는 대신 호출하는 곳에서 조건을 검사하도록 해야 한다.
절차
- 예외를 유발하는 상황을 검사할 수 있는 조건문을 추가한다. catch 블록의 코드를 조건문의 조건절중 하나로 옮기고, 낭믄 try 블록의 코드를 다른 조건절로 옮긴다.
- catch 블록에 어서션을 추가하고 테스트한다.
- try문과 catch블록을 제거한다.
- 테스트한다.
느낀점
이번 장을 읽으면서 대부분의 트레이트 오프가 있는 리팩터링들이 역이 되는 기법이 왜 있는지 생각하게 된 것 같습니다.
논의사항
- 이번 장에서 가장 적용하기 어렵다고 생각한 리팩터링이 있다면 그 리팩터링에 대해 같이 이야기해보면 좋을 것 같습니다.
12. 상속 다루기
마지막 장..!
이번 장에선 객체 지향 프로그래밍에서 가장 유명한 특성인 상속을 다룬다.
다른 강력한 메커니즘처럼 이 역시 유용한 동시에 오용하기 쉽다.
더욱이 상속은 발등에 불이 떨어져서야 잘못 사용했음을 인지하는 경우가 많다.
12.1 메서드 올리기
public class Employee {...}
public class Salesperson : Employee
{
public GetName() {...}
}
public class Engineer : Employee
{
public GetName() {...}
}
public class Employee
{
public GetName() {...}
}
public class Salesperson : Employee {...}
public class Engineer : Employee {...}
배경
중복 코드 제거는 중요하다.
중복된 두 메서드가 당장은 문제없이 동작할지라도 미래에는 벌레가 꼬이는 음쓰로 전락할 수 있다.
무언가 중복되었다는 것은 한쪽의 변경이 다른 쪽에는 반영되지 않을 수 있다는 위험을 항상 수반한다.
그런데 일반적으로 중복을 찾기가 그리 쉽지 않다는 게 문제다.
메서드 올리기를 적용하기 가장 쉬운 상황위 위와 같은 상황이다.
이럴 때는 그냥 복사해 붙여넣으면 끝나지만 실제로는 이렇게 단순한 경우는 적다.
테스트를 얼마나 잘 만들었느냐에 크게 의존하는 방법이다.
그래서 차이점을 찾는 방법이 효과가 좋다.
테스트에서 놓친 동작까지 알게 해주는 경우가 자주 있기 때문이다.
메서드 올리기 리팩터링을 적용하려면 선행 단계를 거쳐야 할 때가 많다.
예컨대 서로 다른 두 클래스의 두 메서드를 각각 매개변수화하면 궁극적으로 같은 메서드가 되기도 한다.
이런 경우에 가장 적은 단계를 거쳐 리팩터링하려면 각각의 함수를 매개변수화한 다음 메서드를 상속 계층의 위로 올리면 된다.
반면, 메서드 올리기를 적용하기에 가장 이상하고 복잡한 상황은 해당 메서드의 본문에서 참조하는 필드들이 서브클래스에만 있는 경우다.
이런 경우라면 필드들 먼저 슈퍼클래스로 올린 후에 메서드를 올려야 한다.
두 메서드의 전체 흐름은 비슷하지만 세부 내용이 다르다면 템플릿 메서드 만들기를 고려해보자.
절차
- 똑같이 동작하는 메서드인지 면밀히 살펴본다.
- 메서드 안에서 호출하는 다른 메서드와 참조하는 필드들을 슈퍼클래스에서도 호출하고 참조할 수 있는지 확인한다.
- 메서드 시그니처가 다르다면 함수 선언 바꾸기로 슈퍼클래스에서 사용하고 싶은 형태로 통일한다.
- 슈퍼클래스에 새로운 메서드를 생성하고, 대상 메서드의 코드를 붙여넣는다.
- 정적 검사를 수행한다.
- 서브클래스 중 하나의 메서드를 제거한다.
- 테스트한다.
- 모든 서브클래스의 메서드가 없어질 때까지 다른 서브클래스의 메서드를 하나씩 제거한다.
12.2 필드 올리기
public class Employee {...}
public class Salesperson : Employee
{
private string name;
}
public class Engineer : Employee
{
private string name;
}
public class Employee
{
private string name;
}
public class Salesperson : Employee {...}
public class Engineer : Employee {...}
배경
서브클래스들이 독립적으로 개발되었거나 뒤늦게 하나의 계층구조로 리팩터링된 경우라면 일부 기능이 중복되어 있을 때가 왕왕 있다.
특히 필드가 중복되기 쉽다.
이런 필드들은 이름이 비슷한 게 보통이지만, 항상 그런 것은 아니다.
그래서 어떤 일이 벌어지는지 알아내려면 필드들이 어떻게 이용되는지 분석해봐야 한다.
분석 결과 필드들이 비슷한 방식으로 쓰인다고 판단되면 슈퍼클래스로 끌어올린다.
이렇게 하면 두 가지 중복을 줄일 수 있다.
첫째, 데이터 중복 선언을 없앨 수 있다.
둘째, 해당 필드를 사용하는 동작을 서브클래스에서 슈퍼클래스로 옮길 수 있다.
동적 언어 중에는 필드를 클래스 정의에 포함시키지 않는 경우가 많다.
그 대신 필드에 가장 처음 값이 대입될 때 등장한다.
이런 경우라면 필드를 올리기 전에 반드시 생성자 본문부터 올려야 한다.
절차
- 후보 필드들을 사용하는 곳 모두가 그 필드들을 똑같은 방식으로 사용하는지 면밀히 살핀다.
- 필드들의 이름이 각기 다르다면 똑같은 이름으로 바꾼다.
- 슈퍼클래스에 새로운 필드를 생성한다.
- 서브클래스의 필드들을 제거한다.
- 테스트한다.
12.3 생성자 본문 올리기
public class Party {...}
public class Employee : Party
{
public Employee(string name, string id, int monthlyCost) : base()
{
this.name = name;
this.id = id;
this.monthlyCost = monthlyCost;
}
}
public class Party
{
public Party(string name)
{
this.name = name;
}
}
public class Employee : Party
{
public Employee(string name, string id, int monthlyCost) : base(name)
{
this.id = id;
this.monthlyCost = monthlyCost;
}
}
사실 C#은 기본적으로 생성자를 호출할 때 부모의 생성자를 호출하기에 매개변수가 없다면 부모의 오버로딩된 생성자가 아니라면 base를 사용할 이유가 없긴 하다.
배경
생성자는 다루기 까다롭다.
일반 메서드와 달라서 생성자가 하는 일에 제약을 두는 것이 좋다.
서브클래스들에서 기능이 같은 메서드들을 발견하면 함수 추출하기와 메서드 올리기를 차례로 적용하여 말끔히 슈퍼클래스로 옮기곤 한다.
그런데 메서드가 생성자라면 스텝이 꼬인다. 생성자는 할 수 있는 일과 호출 순서에 제약이 있기 때문에 조금 다른 식으로 접근해야 한다.
절차
- 슈퍼클래스에 생성자가 없다면 하나 정의한다. 서브클래스의 생성자들에서 이 생성자가 호출되는지 확인한다.
- 문장 슬라이드하기로 공통 문장을 모두를 super() 호출 직후로 옮긴다.
- 공통 코드를 슈퍼클래스에 추가하고 서브클래스들에서는 제거한다. 생성자 매개변수 중 공통 코드에서 참조하는 값들을 모두 super()로 건넨다.
- 테스트한다.
- 생성자 시작 부분으로 옮길 수 없는 공통 코드에는 함수 추출하기와 메서드 올리기를 차례로 적용한다.
12.4 메서드 내리기
public class Employee
{
public double GetQuota() {...}
}
public class Salesperson : Employee {...}
public class Engineer : Employee {...}
public class Employee {...}
public class Salesperson : Employee {...}
public class Engineer : Employee
{
public double GetQuota() {...}
}
배경
특정 서브클래스 하나와만 관련된 메서드는 슈퍼클래스에서 제거하고 해당 서브클래스에 추가하는 편이 깔끔하다.
다만 이 리팩터링은 해당 기능을 제공하는 서브클래스가 정확히 무엇인지를 호출자가 알고 있을 때만 적용할 수 있다.
그렇지 못한 상황이라면 서브클래스에 따라 다르게 동작하는 슈퍼클래스의 기만적인 조건부 로직을 다형성으로 바꿔야 한다.
절차
- 대상 메서드를 모든 서브클래스에 복사한다.
- 슈퍼클래스에서 그 메서드를 제거한다.
- 테스트한다.
- 이 메서드를 사용하지 않는 모든 서브클래스에서 제거한다.
- 테스트한다.
12.5 필드 내리기
public class Employee
{
private string quota;
}
public class Salesperson : Employee {...}
public class Engineer : Employee {...}
public class Employee {...}
public class Salesperson : Employee
{
private string quota;
}
public class Engineer : Employee {...}
배경
서브클래스 하나(혹은 소수)에서만 사용하는 필드는 해당 서브클래스로 옮긴다.
절차
- 대상 필드를 모든 서브클래스에 정의한다.
- 슈퍼클래스에서 그 필드를 제거한다.
- 테스트한다.
- 이 필드를 사용하지 않는 모든 서브클래스에서 제거한다.
- 테스트한다.
12.6 타입 코드를 서브클래스로 바꾸기
public Employee CreateEmployee(string name, Type type)
{
return new Employee(name, type);
}
public Employee CreateEmployee(string name, Type type)
{
switch (type)
{
case Type.Engineer:
return new Engineer(name);
case Type.Salesperson:
return new Salesperson(name);
case Type.Manager:
return new Manager(name);
default:
throw new Exception("Incorrect type code value");
}
}
배경
소프트웨어 시스템에서는 비슷한 대상들을 특정 특성에 따라 구분해야 할 때가 자주 있다.
예컨대 직원을 담당 업무로 구분하거나(엔지니어, 관리자, 영업자 등) 주문을 시급성으로 구분하기도 한다. (급함, 보통 등)
이런 일을 다루는 수단으로는 타입 코드 필드가 있다.
타입 코드는 프로그래밍 언어에 따라 열거형이나 심볼, 문자열, 숫자 등으로 표현하며, 외부 서비스가 제공하는 데이터를 다루려 할 때 딸려오는 일이 흔하다.
타입 코드만으로도 특별히 불편한 사항은 별로 없지만 그 이상의 무언가가 필요할 때가 있다.
여기서 ‘그 이상’이라 하면 바로 서브클래스를 가리킨다.
서브클래스는 두 가지 면에서 특히 매력적이다.
첫째, 조건에 따라 다르게 동작하도록 해주는 다형성을 제공한다.
타입 코드에 따라 동작이 달라져야 하는 함수가 여러 개일 때 특히 유용하다.
서브클래스를 이용하면 이런 함수들에 조건부 로직을 다형성으로 바꾸기를 적용할 수 있다.
둘째, 특정 타입에서만 의미가 있는 값을 사용하는 필드나 메서드가 있을 때 발현된다.
예컨대 판매 목표는 영업자 유형일 때만 의미가 있다.
이런 상황이라면 서브클래스를 만들고 필요한 서브클래스만 필드를 갖도록 정리하자.
물론 타입 코드를 사용할 때도 타입과 값이 올바르게 짝지어 사용되는지 검증하는 코드를 넣을 수 있지만, 서브 클래스 방식이 관계를 더 명확하게 드러내준다.
절차
- 타입 코드 필드를 자가 캡슐화한다.
- 타입 코드 값 하나를 선택하여 그 값에 해당하는 서브클래스를 만든다. 타입 코드 게터 메서드를 오버라이드하여 해당 타입 코드의 리터럴 값을 반환하게 한다.
- 매개변수로 받은 타입 코드와 방금 만든 서브클래스를 매핑하는 선택로직을 만든다.
- 테스트한다.
- 타입 코드 값 각각에 대해 서브클래스 생성과 선택 로직 추가를 반복한다. 클래스 하나가 완성될 때마다 테스트한다.
- 타입 코드 필드를 제거한다.
- 테스트한다.
- 타입 코드 접근자를 이용하는 메서드 모두 메서드 내리기와 조건부 로직을 다형성으로 바꾸기를 적용한다.
12.7 서브클래스 제거하기
public class Person
{
public string GetGenderCode()
{
return "X";
}
}
public class Male : Person
{
public string GetGenderCode()
{
return "M";
}
}
public class Female : Person
{
public string GetGenderCode()
{
return "F";
}
}
public class Person
{
public string GetGenderCode()
{
return this.genderCode;
}
}
배경
서브클래싱은 원래 데이터 구조와는 다른 변종을 만들거나 종류에 따라 동작이 달라지게 할 수 있는 유용한 메커니즘이다.
다름을 프로그래밍하는 멋진 수단인 것이다.
하지만 소프트웨어 시스템이 성장함에 따라 서브클래스로 만든 변종이 다른 모듈로 이동하거나 완전히 사라지기도 하면서 가치가 바래기도 한다.
서브클래스는 결국 한 번도 활용되지 않기도 하며, 때론 서브클래스를 필요로 하지 않는 방식으로 만들어진 기능에서만 쓰이기도 한다.
더 이상 쓰이지 않는 서브클래스와 마주하는 프로그래머는 가치없는 것을 이해하느라 에너지를 낭비할 것이다.
이런 정도까지 되면 서브클래스를 슈퍼클래스의 필드로 대체해 제거하는 게 최선이다.
절차
- 서브클래스의 생성자를 팩터리 함수로 바꾼다.
- 서브클래스의 타입을 검사하는 코드가 있다면 그 검사 코드에 함수 추출하기와 함수 옮기기를 차례로 적용하여 슈퍼클래스로 옮긴다. 테스트한다.
- 서브클래스의 타입을 나타내는 필드를 슈퍼클래스에 만든다.
- 서브클래스를 참조하는 메서드가 방금 만든 타입 필드를 이용하도록 수정한다.
- 서브클래스를 지운다.
- 테스트한다.
이 리팩터링은 다수의 서브클래스에 한꺼번에 적용할 때가 많다.
그럴 때는 팩터리 함수를 추가하고 타입 검사 코드를 옮기는 캡슐화 단계들을 먼저 실행한 다음 개별 서브클래스를 하나씩 슈퍼클래스로 흡수시킨다.
12.8 슈퍼클래스 추출하기
public class Department
{
public string GetTotalAnnualCost() {...}
public string GetName() {...}
public string GetHeadCount() {...}
}
public class Employee
{
public string GetAnnualCost() {...}
public string GetName() {...}
public string GetId() {...}
}
public class Party
{
public string GetName() {...}
}
public class Department : Party
{
public string GetTotalAnnualCost() {...}
public string GetHeadCount() {...}
}
public class Employee : Party
{
public string GetAnnualCost() {...}
public string GetId() {...}
}
배경
비슷한 일을 수행하는 두 클래스가 보이면 상속 메커니즘을 이용해서 비슷한 부분을 공통의 슈퍼클래스로 옮겨 담을 수 있다.
공통된 부분이 데이터라면 필드 올리기를 활용하고, 동작이라면 메서드 올리기를 활용한다.
객체 지향을 설명할 때 상속 구조는 ‘현실 세계에서 활용하는 어떤 분류 체계에 기초하여 구현에 들어가기 앞서 부모/자식 관계를 신중하게 설계해야 한다’라고 이야기하는 사람이 많다.
현실 세계의 이런 분류 체계는 상속을 적용하는 데 힌트가 될 수 있다.
절차
- 빈 슈퍼클래스를 만든다. 원래의 클래스들이 새 클래스를 상속하도록 한다.
- 테스트한다.
- 생성자 본문 올리기, 메서드 올리기, 필드 올리기를 차례로 적용하여 공통 원소를 슈퍼클래스로 옮긴다.
- 서브클래스에 남은 메서드들을 검토한다. 공통되는 부분이 있다면 함수로 추출한 다음 메서드 올리기를 적용한다.
- 원래 클래스들을 사용하는 코드를 검토하여 슈퍼클래스의 인터페이스를 사용하게 할지 고민해본다.
12.9 계층 합치기
public class Employee {...}
public class Salesperson : Employee {...}
public class Employee {...}
배경
클래스 계층구조를 리팩터링하다 보면 기능들을 위로 올리거나 아래로 내리는 일은 다반사로 벌어진다.
예컨대 계층구조도 진화하면서 어떤 클래스와 그 부모가 너무 비슷해져서 더는 독립적으로 존재해야 할 이유가 사라지는 경우가 생기기도 한다.
바로 그 둘을 하나로 합쳐야 할 시점이다.
절차
- 두 클래스 중 제거할 것을 고른다.
- 필드 올리기와 메서드 올리기 혹은 필드 내리기와 메서드 내리기를 적용하여 모든 요소를 하나의 클래스로 옮긴다.
- 제거할 클래스를 참조하던 모든 코드가 남겨질 클래스를 참조하도록 고친다.
- 빈 클래스를 제거한다.
- 테스트한다.
12.10 서브클래스를 위임으로 바꾸기
public class Order
{
public int GetDaysToShip()
{
return this.warehouse.GetDaysToShip();
}
}
public class PriorityOrder : Order
{
public int GetDaysToShip()
{
return this.priorityPlan.GetDaysToShip();
}
}
public class Order
{
public int GetDaysToShip()
{
return (this.priorityDelegate) ? this.priorityPlan.GetDaysToShip() : this.warehouse.GetDaysToShip();
}
}
public class PriorityOrderDelegate
{
public int GetDaysToShip()
{
return this.priorityPlan.GetDaysToShip();
}
}
배경
속한 갈래에 따라 동작이 달라지는 객체들은 상속으로 표현하는 게 자연스럽다.
공통 데이터와 동작은 모두 슈퍼클래스에 두고 서브클래스는 자신에 맞게 기능을 추가하거나 오버라이드하면 된다.
객체 지향 언어로는 이런 형태로 구현하기가 쉽기 때문에 흔히 활용되는 메커니즘이다.
하지만 상속에는 단점이 있다.
가장 명확한 단점은 한 번만 쓸 수 있는 카드라는 것이다.
무언가가 달라져야 하는 이유가 여러 개여도 상속에서는 그중 단 하나의 이유만 선택해 기준으로 삼을 수밖에 없다.
예컨대 사람 객체의 동작을 ‘나이대’와 ‘소득 수준’에 따라 달리 하고 싶다면 서브클래스는 젊은이와 어르신이 되거나, 혹은 부자와 서민이 되어야 한다.
둘 다는 안 된다.
또 다른 문제로, 상속은 클래스들의 관계를 아주 긴밀하게 결합한다.
부모를 수정하면 이미 존재하는 자식들의 기능을 해치기가 쉽기 때문에 각별히 주의해야 한다.
그래서 자식들이 슈퍼클래스를 어떻게 상속해 쓰는지를 이해해야 한다.
부모와 자식이 서로 다른 모듈에 속하거나 다른 팀에서 구현한다면 문제가 더 커진다.
위임은 이상의 두 문제를 모두 해결해준다. (여기서 위임은 아마 구성, 합성을 말하는 듯 하다.)
다양한 클래스에 서로 다른 이유로 위임할 수 있다.
위임은 객체 사이의 일반적인 관계이므로 상호작용에 필요한 인터페이스를 명확히 정의할 수 있다.
즉, 상속보다 결합도가 훨씬 약하다.
그래서 서브클래싱 관련 문제에 직면하게 되면 흔히 서브클래스를 위임으로 바꾸곤 한다.
유명한 원칙으로 상속보다는 컴포지션을 사용하라
이를 상속은 위험하다고 받아들여지기도 하는데 이는 과장된 말이다.
상속을 이해 해야지만 구성(위임)의 이점을 이해할 수 있고 상속도 활용할줄 알아야 한다.
절차
- 생성자를 호출하는 곳이 많다면 생성자를 팩터리 함수로 바꾼다.
- 위임으로 활용할 빈 클래스를 만든다. 이 클래스의 생성자는 서브클래스에 특화된 데이터를 전부 받아야 하며, 보통은 슈퍼클래스를 가리키는 역참조도 필요하다.
- 위임을 저장할 필드를 슈퍼클래스에 추가한다.
- 서브클래스 생성 코드를 수정하여 위임 인스턴스를 생성하고 위임 필드에 대입해 초기화한다.
- 서브클래스의 메서드 중 위임 클래스로 이동할 것을 고른다.
- 함수 옮기기를 적용해 위임 클래스로 옮긴다. 원래 메서드에서 위임하는 코드는 지우지 않는다.
- 서브클래스 외부에도 원래 메서드를 호출하는 코드가 있다면 서브클래스의 위임 코드를 슈퍼클래스로 옮긴다. 이때 위임이 존재하는지를 검사하는 보호 코드로 감싸야 한다. 호출하는 외부 코드가 없다면 원래 메서드는 죽은 코드가 되므로 제거한다.
- 테스트한다.
- 서브클래스의 모든 메서드가 옮겨질 때까지 과정을 반복한다.
- 서브클래스들의 생성자를 호출하는 코드를 찾아서 슈퍼클래스의 생성자를 사용하도록 수정한다.
- 테스트한다.
- 서브클래스를 삭제한다.
12.11 슈퍼클래스를 위임으로 바꾸기
public class List {...}
public class Stack : List {...}
public class Stack
{
public Stack()
{
this.storage = new List();
}
}
class List {...}
배경
객체 지향 프로그래밍에서 상속은 기존 기능을 재활용하는 강력하고 손쉬운 수단이다.
기존 클래스를 상속하여 입맛에 맞게 오버라이드하거나 새 기능을 추가하면 된다.
하지만 상속이 혼란과 복잡도를 키우는 방식으로 이뤄지기도 한다.
상속을 잘못 적용한 예로는 자바의 스택 클래스가 유명하다.
자바의 스택은 리스트를 상속하고 있는데, 데이터를 저장하고 조작하는 리스트의 기능을 재활용하겠다는 생각이 초래한 결과다.
재활용이란 관점에서는 좋았지만 이 상속에는 문제가 있다.
자바의 스택은 슈퍼클래스를 위임으로 바꾸는 이번 리팩터링을 적용해야 하는 좋은 예다.
슈퍼클래스의 기능들이 서브클래스에는 어울리지 않는다면 그 기능들을 상속을 통해 이용하면 안된다는 신호다.
제대로 된 상속이라면 서브클래스가 슈퍼클래스의 모든 기능을 사용함은 물론, 서브클래스의 인스턴스를 슈퍼클래스의 인스턴스로도 취급할 수 있어야 한다.
다시 말해, 슈퍼클래스가 사용되는 모든 곳에서 서브클래스의 인스턴스를 대신 사용해도 이상없이 동작해야 한다.
예컨대 이름과 엔진 크기 등을 속성으로 갖는 자동차 모델 클래스가 있다고 하자.
그러면 여기에 차량 식별 번호와 제조일자 메서드를 더하면 물리적인 자동차를 표현하는 데 재활용할 수 있을 거라 착각할 수 있다.
이는 흔하고 미묘한 모델링 실수로, 내가 타입-인스턴스 동형이의어라고 부르는 것이다.
이상은 모두 혼란과 오류를 일으키는 예인 동시에 상속을 버리고 위임으로 갈아타 객체를 분리하면 쉽게 피할 수 있는 예다.
위임을 이용하면 기능 일부만 빌려올 뿐인, 서로 별개인 개념임이 명확해진다.
서브클래스 방식 모델링이 합리적일 때라도 슈퍼클래스를 위임으로 바꾸기도 한다.
슈퍼/서브 클래스는 강하게 결합된 관계라서 슈퍼클래스를 수정하면 서브클래스가 망가지기 쉽기 때문이다.
위임에도 물론 단점이 존재한다.
위임의 기능을 이용할 호스트의 함수 모두를 전달 함수로 만들어야 한다는 점이다.
전달 함수를 작성하기란 지루한 일이다. 하지만 아주 단순해서 문제가 생길 가능성은 적다.
이상의 이유로 “상속은 절대 사용하지 말라”고 조언하는 사람도 있지만, 실상은 그렇지 않다. (반대한다.)
상위 타입의 모든 메서드가 하위 타입에도 적용되고, 하위 타입의 모든 인스턴스가 상위 타입의 인스턴스도 되는 등, 의미상 적합한 조건이라면 상속은 간단하고 효과적인 메커니즘이다.
생각
개인적인 생각이지만 그렇다고 3계층 이상으로 깊어지는 상속은 좋게 볼 수 없는 것 같다.
1~2 깊이 정도는 읽는 사람이 이해하는데 문제가 없지만 그 이상 깊어진다면 너무 단단한, 수정할 수 없는 비석느낌이 강하다.
절차
- 슈퍼클래스 객체를 참조하는 필드를 서브클래스에 만든다. 위임 참조를 새로운 슈퍼클래스 인스턴스로 초기화한다.
- 슈퍼클래스의 동작에 각각 대응하는 전달 함수를 서브클래스에 만든다.(위임 참조로 전달) 서로 관련된 함수끼리 그룹으로 묶어서 진행하며, 그룹을 하나씩 만들 때마다 테스트한다.
- 슈퍼클래스의 동작 모두가 전달 함수로 오버라이드되었다면 상속 관계를 끊는다.
느낀점
마지막 장이 상속, 객체지향에 관련된 내용이라 매우 도움이 된 것 같다.
중간 중간 클린코드의 내용이 나와 반갑기도 했고, 최근에 읽은 객체지향 사고 프로세스
에서 하고 싶었던 말도 있어서 개인적인 생각을 정리했다.
논의사항
- 이번 장에선 위임, 서브클래스, 슈퍼클래스에 경계에 대한 내용이 주를 이뤘는데 실제로 위에서 말한 리팩터링을 진행해보신 적이 있나요?
댓글남기기