겉으로 들어나는 코드의 기능은 바꾸지 않으면서 내부 구조를 개선하는 걸 말한다.
설계가 나쁜 시스템은 수정하기 어렵다.
어플리케이션 크기가 몇백줄, 몇천줄 정도의 경우에는 수정하기 어렵지 않다.
하지만 그 이상의 엔터프라이즈 어플리케이션의 경우에는 잘못 설계되었다면 수정하기 엄청 어렵다.
그렇다고 한 번에 제대로 된 설계를 하기도 어렵다. 이후에 도메인 지식이 점점 늘어날 수도 있고, 요구사항이 변경될 수도 있기 때문이다.
그러므로 마인드 자체를 유연하게 가져가야한다. 현재 시점에서 최선의 코드를 작성하도록 하고 더 나은 구조가 보인다면 리팩터링 하는 식으로.
여기서 말하는 최선의 코드, 좋은 코드란 뭘까?
프로그래머마다 생각이 다를 순 있겠지만, 나는 이렇게 생각한다. "Small Change 를 만들어내기 쉬운가?"
수정하기 쉽다는 뜻은 많은 의미를 내포한다. 모듈화가 잘 되어있다는 뜻이고, 코드를 읽고 이해하기 쉽다는 뜻이므로.
리팩터링은 이 목표를 가지고 기능은 그대로 유지한 채 코드를 변경하는 작업을 말한다.
누군가 리팩터링 하다가 코드가 제대로 동작하지 않는다고 한다면 그는 리팩터링 한게 아니다.
리팩터링을 제대로 적용했다면 코드가 꺠지는 문제는 생기지 않는다.
리팩터링은 큰 목표사이에 단계를 아주 잘게 쪼개서 나아간다. 그래서 마틴이 하는 방법을 보면 작은 단계로 나눠서 (컴파일 - 테스트 - 커밋을) 진행한다.
이후에 나오는 본격적인 리팩터링 기법은 되게 많다. 근데 여기에 나와있는 기법들의 절차를 다 기억한다기 보다는 가이드라인을 가지고 쪼개서 나가면 된다고 생각한다.
가이드라인은 다음과 같다.
- 컴파일 에러가 안나도록 한번에 모두를 변경하지 않는다. (컴파일 에러가 나더라도 금방 고칠 수 있다는 걸 의미한다.)
- 테스트를 돌린다. (그래서 테스트에 굉장히 의존적이다.)
- 하나의 유의미한 리팩터링을 했다면 커밋한다. (문제가 생겼을 때 다시 돌아가기 위함이다.)
- 기능 추가와 리팩터링을 같이 하지 않는다. 한 번에 하나씩 한다. (마틴은 이걸 두 개의 모자라고 표현했다.)
이런 사이클을 자주 돌리면서 리팩터링을 하는데 누군가는 너무 시간이 많이 걸릴 거 같다. 비효율적일 것 같다 라고 생각할 수 있다.
리팩터링을 이렇게 잘게 나누는 근본적인 이유는 테스트가 통과되지 않아서 생기는 디버깅 시간을 가지지 않기 위해서다.
만약 리팩터링 기법의 보폭에 익숙하다면 그 보폭을 조금 더 크게 가져가면 된다.
리팩터링은 날잡고 하는게 아니라 수시로 하는 것이다.
리팩터링은 프로그래밍 과정에 자연스럽게 녹아있는 것이다. 개발하는 것과 별개의 것이 아니다.
(물론 TDD 의 리듬 (테스트 설계 - 개발 - 리팩터링 순으로 진행) 을 알고있다면 당연한 것일수도 있다.)
리팩터링 해야하는 시점은 그럼 언제일까?
1. 먼저 코드베이스에 기능을 새로 추가하기 직전에 할 수 있다.
- 이 시점에 구조를 한번 살펴보면서, 구조를 바꾸면 작업이 더 수월하고 코드가 명확해질 것 같은지 확인해보고 가능하면 리팩터링한다.
**2. 코드가 하는 일을 파악했을 때 해본다. **
- 이 때 그 코드의 의도를 더욱 명확하게 바꿀 수 있는지 확인해보는 것이다.
3. 쓰레기 줍기 리팩터링을 적용한다. (캠핑 규칙)
- 코드가 하는 일을 파악하던 중에 비효율적인 코드를 봤다면 그 쓰레기들을 줍는 것.
- 물론 여기서는 절충이 필요하다. 너무 많은 시간을 빼앗지 않도록 하는 것.
**그렇다면 리팩터링을 하지 말아야 하는 시점은 언제일까? **
- 리팩터링을 하면 안되는 상황도 물론 있다.
- 내부 동작을 분석할 필요가 없는 경우. 지저분한 코드를 만나도 코드를 완벽하게 알지 못하니까 섣부른 판단을 하지 않는다.
- 또 다른 경우는 그냥 새로운 코드를 작성하는게 더 나은 경우가 있다. (이건 경험에 많이 의존한다.)
냄새 나면 당장 갈아라.
리팩터링을 어느 시점에 해야하는지는 알았다.
그렇다면 언제 딱 시작하면 될까?
여기서는 악취라는 표현을 사용한다. 악취가 나는 코드를 만나면 리팩터링을 시작하라고 말한다.
그리고 리팩터링을 시작하는 시점은 알려 줄 수 있지만 언제 끝내야 하는지에 대한 기준은 제시하지 않는다.
그 끝은 사람마다 기준이 다르다. 이 부분은 개발자의 직관에 의존하는 부분이다.
여기선 악취들의 핵심만 소개하고 그 뒤에 이것들을 해결하는 기법을 소개하고 이 글을 마무리 하곘다.
코드는 추리소설과 다르다.
코드는 명확해야 한다. 이게 다른 사람과 협업 하기위한 코드의 목적이다.
그래서 함수, 모듈, 변수, 클래스 이름만 보고도 무슨 일을 하는지 어떻게 사용하는지를 명확히 알 수 있어야 한다
이름 짓기를 도와줄 수 있는 방법은 딱히 없지만 가이드는 있다.
- 명확한 이름이 떠오르지 않는다면 설계를 의심해보는 것.
중복 코드가 만드는 문제는 변경 포인트가 많다 라는 것이다. 그래서 업데이트가 안되는 코드가 생길 수 있다 라는 점이 있다.
중복 코드는 여러 곳에 있을 수 있다.
- 하나의 클래스 안에서 두 메소드가 유사할 수도 있고.
- 서브 클래스들 사이에서 중복된 메소드가 존재할 수도 있다.
- 유사한 동작을 하는 함수에서 중복 코드가 있을 수 있다.
이 문제를 해결하는 방법은 변경 포인트를 한 곳으로 모우는 것이다.
긴 함수는 대체로 자기가 직접 계산을 하는, 구체적인 일을 수행하기 때문에 재사용성이 낮다.
그리고 짧은 함수들로 구성된 코드베이스는 대체로 위임하는 구조로 이뤄지기 때문에 코드를 이해하기 쉽다.
그러므로 짧은 함수 + 좋은 이름으로 어떠한 동작을 하는지 명확하게 보여줘야한다.
매개변수의 목록이 길어지면 그 자체로 이해하기 어렵다.
그러므로 메개변수의 개수를 줄여야 한다. 이를 위한 방법은 많다.
- 클래스를 만들어서 필드를 통해 매개변수 줄일수도 있고
- 항상 함께 전달되는 매개변수들이 있다면 이들을 묶어서, 객채로 만들어서 전달하는 방법도 있다.
- 파라미터로 플래그 인수가 전달된다면 이것을 제거하고 각 특성에 맞는 함수를 만드는 방식도 있다.
전역 데이터는 악취 중에서도 특히 심하다.
그 이유는 코드 베이스 어디에서나 변경이 가능하니까 문제가 생겼을 때 이 변경 시점을 추적하는게 힘들다.
즉 버그는 끊임없이 발생하는데 이 원인이 되는 코드를 찾기가 힘들다.
전역 데이터를 사용하는 경우라면 그 데이터가 불변이라면 괜찮다. 그렇지 않다면 할 수 있는 방법은 캡슐화를 통해서 접근 범위를 제한하자.
가변 데이터도 전역 데이터와 비슷한 문제가 생길 수 있다.
데이터를 변경했더니 버그가 생기는 경우가 종종 있고 이를 찾기는 굉장히 어렵다 는 문제다.
이러한 이유로 함수형 프로그래밍에서는 데이터를 변경하지 않는다고 한다.
함수형 프로그래밍 자체를 지원하지 않는 언어도 많으니 핵심만 가져온다면 데이터에 불변성을 줄 수 있다면 주는게 좋다.
기변데이터의 악취는 문제가 생겼을 때 추적하기 쉽도록 하는 것이다.
이 방법은 다음과 같다.
- 가변 데이터를 다룰 땐 캡슐화를 통해서 정해놓은 함수에서만 접근을 허용하는 방법이 있다.
- 변수의 용도를 다시 한번 살펴보자. 갱신되는 부분만 따로 독립적으로 분리해서 갱신 로직 자체를 떼어내다 방법이 있다.
- 애초에 값을 항당하는 Setter 메소드를 지우는 방법도 있을 것 같다.
- 파생 변수를 애초에 질의 함수로 바꿔서, 변수를 가지고 있지 않고 필요할 때 계산하는 방식도 있다.
- (파생 변수란 말은 기존의 변수를 조합해서 새로운 변수를 추출하는 걸 파생변수 라고 한다.)
뒤엉킨 변경은 SOLID 의 SRP (Single Responsibility Principle) 가 제대로 지켜지지 않을 때 생기는 악취다.
소프트웨어는 변경하기 쉬운 구조로 이뤄져야 한다. 이 말은 변경 포인트를 한 곳으로 모우는 걸 말한다.
뒤엉킨 변경은 하나의 모듈이 여러 원인으로 변경될 수 있을 때 생기는 문제다.
주로 이런 문제는 하나의 모듈이 여러 맥락 (Context) 를 넘나드는 경우에 생긴다.
이를 해결하는 방법은 다음과 같다.
- 일련의 처리 흐름을 맥락 별로 단계를 분리시켜준다.
- 여러 맥락을 드나드는 함수가 있다면 이를 추출해서 분리시킨다.
- 모듈이 클래스 단위라면 각 클래스별로 맥락을 분리해주면 된다.
이 악취는 코드를 변경할 때마다 자잘하게 수정해야 하는 클래스가 많을 때 풍긴다.
산탄총 수술은 뒤엉킨 변경과 비슷하면서도 반대다.
뒤엉킨 변경이 여러 맥락을 드나드는 경우라면 산탄총 수술은 맥락 자체가 흩어진 경우를 말한다.
이 문제를 해결하는 방법은 흩어져 있는 코드를 모아서 적절한 모듈을 만들어 주는 일이다. (높은 응집도를 추구하는 것.)
프로그램을 모듈화 할 때 고려해야 하는 사항으로는 같은 모듈에서는 상호작용을 최대한 늘리고 모듈과 다른 모듈 사이의 상호 작용은 최대한 줄여야 한다.
이로인해 적절한 상호 작용만 노출시킴으로써 코드의 이해를 도와준다.
반면 기능 편애는 흔히 어떤 모듈의 함수가 자신이 속한 모듈의 함수나 데이터와의 상호 작용보다 다른 모듈과의 상호작용이 더 많을 때 풍기는 냄새다.
흔히 Getter 메소드를 여러번 호출하면서 다른 데이터에 접근해서 상호작용할 때 풍기는 냄새가 그 예이다.
이 문제를 해결하는 방법은 그 데이터 근처로 함수를 옮겨주면 된다. 즉 같은 데이터를 다루는 로직은 한 곳으로 모우는 것이다.
물론 이 경우를 거스르는 경우도 있다. 디자인 패턴에서 전략 패턴 (Strategy Pattern) 과 방문자 패턴 (Visitor Pattern).
(두 패턴의 경우 객체와 객체의 동작에 사용하는 알고리즘을 분리시키는 유형의 패턴이다. 즉 객체가 알고리즘을 가지고 있지 않는 것, 이렇게 하는 이유는 확장성 있는 설계를 가지기 위해서다.)
데이터는 서로 몰려다니는 경우가 많다.
그래서 이런 데이터들은 한 곳으로 모아놓으면 유용한 경우가 많다. (특정 동작을 줄 수 있다던지.)
데이터 뭉치인지 확인하는 방법은 한 값을 지운다면 이 값의 의미가 있을까? 를 물어보면 된다.
그랬을 때 나머지 값이 의미가 없다면 이들은 뭉쳐다니는 것이다.
대부분의 프로그래밍 언어에서는 기본형 타입을 지원한다. 정수, 부동소수점 수, 문자열과 같은 다양한 기본형을 제공해준다.
그래서인지 모르겠지만 프로그래머들은 이런 기본형들을 그냥 사용하는걸 선호하지 자신의 문제를 해결하기 위한 클래스로 만들어서 사용하는걸 꺼려한다. (예로 화폐, 전화번호, 좌표 등)
주로 이게 악취를 품는 코드는 기본형을 통해 그냥 if 절에서 계산하는 코드다.
이것 보다는 클래스의 메소드로 정의하는게 훨씬 좋다.
Switch 문이 진짜 나쁜 경우는 비슷한 Switch 문을 중복해서 사용하고 있을 경우다.
이 경우에 하나의 케이스가 추가되기만 하면 모든 Switch 문을 찾아서 넣어줘야 하는 작업이 필요해진다.
이렇게 중복이 발생하고 있다면 조건부 로직을 다형성으로 바꿔주자.
반복문은 프로그래밍 언어가 등장할 때부터 함께 한 핵심 프로그래밍 요소다.
예전에는 반복문의 대안이 없었지만 현재는 일급 함수(First-class Function) 을 지원하는 프로그래밍 언어가 많아지면서 반복문을 파이프라인으로 바꿀 수 있다.
파이프라인을 사용하는 이유는 반복문 보다 코드를 이해하기 더 쉽기 때문이다. (Filter 나 Map 같은 파이프라인 연산을 사용하면 원소들이 어떻게 처리되는지 쉽게 파악할 수 있다.)
우리는 코드의 구조를 잡을 때 프로그래밍적 요소를 활용한다. (요소는 여기서 클래스, 메소드, 인터페이스와 같은 걸 말한다.)
이렇게 프로그래밍 적 요소를 활용하면 재활용 할 수 있는 여건과 함께 의미있는 이름을 가질 수 있기 떄문이다.
그치만 이렇게 구조를 잡지 않아도 되는 경우가 있다.
- 재활용의 여지가 없는 메소드
- 이름을 가지지 않아도 충분히 이해할만한 동작들
- 죽은 객체인 경우
이런 경우에 구조를 분해해서 없애버리는 게 좋다.
추측성 일반화는 나중에 이게 필요할 것이라는 이유로 당장은 필요없는 코드로 인해 풍기는 악취다.
이는 YAGNI (You aren't gonna need it) 원칙과도 관련이 있다.
코드 설계는 유연해야한다. 그 시점이 올 때 코드를 리팩터링 하면서 작성하면 된다.
그러므로 당장 걸리적 거리는 코드는 모두 지워버리자.
간혹 클래스 필드에서 특정한 상황에서만 그 필드에 값이 들어가는 경우가 있다.
이런 필드는 대부분의 상황에서는 값이 들어가 있지 않기 때문에 다른 사람들이 그 클래스를 보면 이해하기가 어려워진다.
원래 클래스라는 것 자체가 모든 필드가 다 들어있다는 전제하에 객체를 만들어야 한다.
그러므로 이런 임시 필드가 있다면 이런 필드를 위한 클래스를 만들어 주는 방법이 있다.
또 기존 클래스에 임시 필드의 여부와 관련해서 동작하는 메소드가 있다면 특이 케이스 클래스를 추가해서 해결할 수 있다.
메시지 체인은 클라이언트가 한 객체를 통해 다른 객체를 얻은 후 연속적으로 객체를 찾아나가는 과정을 말한다.
가령 getSomething() 과 같은 메소드를 연속적으로 호출해서 객체를 찾아나가는 걸 말한다.
이는 클라이언트가 객체 내비게이션 구조에 종속됐음을 말한다. 중개자의 역할만 하는 객체는 딱히 의미는 없다.
이를 해결하는 방법은 결국에 찾는 최종 객체에게 어떠한 행동을 바라는지를 알아보고, 그 행동을 옮기는 일이다.
객체의 대표적인 기능 중 하나로 캡슐화 (encapsulation) 이라는 기능이 있다.
캡슐화를 통해서 객체는 다른 객체에게 작업을 위임하고 구현을 몰라도 된다.
예로 팀장과 미팅을 잡는다고 하면 팀장은 일정을 조율하고 답을 줄 것이다. 근데 일정을 잡는 과정에서 다이어리를 쓰든, 캘린더를 쓰든, 비서를 쓰든 그건 알바가 아니다.
이렇게 캡술화를 사용하면 구현을 몰라도 원하는 바를 얻을 수 있는 장점이 있다.
그치만 이게 지나치면 문제가 되는데 클래스의 메소드의 절반이 다른 객체에게 위임하고 있는 구조라면 그 객체는 Middle Man 이다.
아까도 얘기했다시피 중개자의 역할만 하는 객체는 딱히 의미가 없다.
소프트웨어 개발자는 모듈 사이에 벽을 두껍게 세우기를 좋아한다.
이 말은 모듈 사이의 필요없는 결합은 줄이고 싶어한다는 말을 의미한다.
만약 모듈끼리 짜잘하게 데이터를 주고 받는 일이 많다면 함수나 필드를 옮겨서 이 결합을 줄여야한다.
부모 클래스와 자식 클래스끼리 결합이 많다면 이들은 헤어지는게 맞다.
또 여러 모듈이 같은 관심사를 공유해서 결합하는 일이 많다면 제 3의 모듈을 만드는 기법도 있다.
한 클래스가 너무 많은 일을 하려다 보면 필드 수가 늘어난다.
필드 수가 많아지면 필드의 이해가 떨어져서 중복 코드가 발생할 확률이 높아진다.
이럴 땐 같은 필드끼리 묶어서 클래스를 만들어서 위임하자.
다른 방법으로는 상속 관계를 만들어서 공통의 필드는 슈퍼 클래스로 옮기는 방법도 있다.
클래스의 장점은 언제든 필요에 따라 클래스를 교체할 수 있다는 것이다.
이를 위해서는 클래스의 타입이 인터페이스와 같아야 한다.
유사한 동작을 하는 클래스를 본다면 변경할 클래스를 잡자. 그 다음 인터페이스에서 선언한 메소드와 같아지도록 변경하면 된다.
데이터 클래스란 클래스가 필드와 Getter/Setter 만 가지고 있는 클래스다.
이런 클래스는 다른 클래스에서 멋대로 변경하고 사용할 여지가 있으므로 캡슐화 하는게 좋다.
그러므로 public 필드가 있다면 캡슐화로 숨기고 변경하면 안되는 데이터가 있다면 Setter 를 제거하자.
상속을 포기하는 시점은 자식 클래스가 부모 클래스의 동작은 필요하지만 인터페이스를 따를 필요는 없다고 생각되는 시점이다.
이 경우에 서브 클래스를 위임으로 바꾸거나 기법을 이용하거나 슈퍼 클래스를 위임으로 바꾸는 것들을 활용해 상속 메커니즘에서 벗어날 수 있다.
주석을 달지 마라고 말하지는 않곘다. 올바른 주석은 향기를 불러일으킨다.
다만 주석을 탈취제의 목적처럼 사용하지는 말자.
주석이 너무 많다는건 악취를 만드는 코드가 많다는 뜻이다.
즉 주석을 남겨야겠다면 주석이 필요 없는 코드로 리팩터링을 먼저 해보자.
이 악취들을 어떻게 해결해 나가는지 구체적으로 궁금하다면 여기서 검색해서 찾아보자.
악취 | 탈취용 리팩터링 기법 |
---|---|
가변 데이터 (Mutuable Data) | 변수 캡슐화하기, 변수 쪼개기, 문장 슬라이드하기, 함수 추출하기, 질의 함수와 변경 함수 분리하기, 세터 제거하기, 파생 변수를 질의 함수로 바꾸기, 여러 함수를 클래스로 묶기, 여러 함수를 변환 함수로 묶기, 참조를 값으로 바꾸기 |
거대한 클래스 (Large Class) | 클래스 추출하기, 슈퍼 클래스 추출하기, 타입 코드를 서브 클래스로 바꾸기 |
기능 편애 (Feature Envy) | 함수 옮기기, 함수 추출하기 |
기본형 집착 (Primitive Obsession) | 기본형을 객체로 바꾸기, 타입 코드를 서브 클래스로 바꾸기, 조건부 로직을 다형성으로 바꾸기, 클래스 추출하기, 매개변수 객체 만들기 |
기이한 이름 (Mysterious Name) | 함수 선언 바꾸기, 변수 이름 바꾸기, 필드 이름 바꾸기 |
긴 매개변수 목록 (Long Parameter List) | 매개변수를 질의 함수로 바꾸기, 객체 통째로 넘기기, 매개변수 객체 만들기, 플래그 인수 제거하기, 여러 함수를 클래스로 묶기 |
긴 함수 (Long Function) | 함수 추출하기, 임시 변수를 질의 함수로 바꾸기, 매개변수 객체 만들기, 객체 통째로 넘기기, 함수를 명령으로 바꾸기, 조건문 분해하기, 조건부 로직을 다형성으로 바꾸기, 반복문 쪼개기 |
내부자 거래 (Insider Trading) | 함수 옮기기, 필드 옮기기, 위임 숨기기, 서브 클래스를 위임으로 바꾸기, 슈퍼 클래스를 위임으로 바꾸기 |
데이터 뭉치 (Data Clumps) | 클래스 추출하기, 매개변수 객체 만들기, 객체 통째로 넘기기 |
데이터 클래스 (Data Class) | 레코드 캡슐화 하기, 세터 제거하기, 함수 옮기기, 함수 추출하기, 단계 쪼개기 |
뒤엉킨 변경 (Divergent Change) | 단계 쪼개기, 함수 옮기기, 함수 추출하기, 클래스 추출하기 |
메시지 체인 (Message Chain) | 위임숨기기, 함수 추출하기, 함수 옮기기 |
반복되는 Switch 문 (Repeated Switches) | 조건부 로직을 다형성을 바꾸기 |
반복문 (Loops) | 반복문을 파이프라인으로 바꾸기 |
산탄총 수술 (Shotgun Surgery) | 함수 옮기기, 필드 옮기기, 여러 함수를 클래스로 묶기, 여러 함수를 변환 함수로 묶기, 단계 쪼개기, 함수 인라인 하기, 클래스 인라인 하기 |
상속 포기 (Refused Bequest) | 메소드 내리기, 필드 내리기, 서브 클래스를 위임으로 바꾸기, 슈퍼 클래스를 위임으로 바꾸기 |
서로 다른 인터페이스의 대안 클래스들 (Alternative Classes with Different) | 함수 선언 바꾸기, 함수 옮기기, 슈퍼 클래스 추출하기 |
성의 없는 요소 (Lazy Element) | 함수 인라인하기, 클래스 인라인하기, 계층 합치기 |
임시 필드 (Temporary Field) | 클래스 추출하기, 함수 옮기기, 특이 케이스 추가하기 |
전역 데이터 (Global Data) | 변수 캡슐화하기 |
주석 (Comments) | 함수 추출하기, 함수 선언 바꾸기, 어서션 추가하기 |
중개자 (Middle Man) | 중개자 제거하기, 함수 인라인하기, 서브 클래스를 위임으로 바꾸기, 슈퍼 클래스를 위임으로 바꾸기 |
중복 코드 (Duplicated Code) | 함수 추출하기, 문장 슬라이드하기, 메소드 올리기 |
추측성 일반화 (Speculative Generality) | 계층 합치기, 함수 인라인하기, 함수 선언바꾸기, 죽은 코드 제거하기 |
이제부터 리팩터링 기법들을 간략하게 소개하겠다.
여기에 나와있는 리팩터링 기법들은 이전에 말한 악취를 제거하기 위한 용도로 사용된다.
결국 코드를 탈취 하고 향기를 불러일으키는 방향은 다음과 같다고 생각한다.
- 코드를 명확하게 만들기 위해서
- 중복을 제거하기 위해서
- 좋은 설계의 디자인 원칙을 따르려고
- GRASP 원칙과 (특히 느슨한 결합도와 높은 응집도)
- SOLID 원칙
- 문제가 생겼을 때 해결하기 쉬워지도록.
여기에 있는 리팩터링의 기법이 이 방향대로 진행하고 있다는 걸 알면 더 재밌게 느껴지지 않을까 싶다.
이 기법은 가장 기본적이고 많이 사용하는 리팩터링이다.
가장 많이 사용하는 리팩터링 기법은 함수 추출하기 와 변수 추출하기 이다.
추출 한다는 건 결국 이름짓기를 말하는 거고 올바른 이름은 코드를 명확하게 해준다.
함수 선언 바꾸기는 함수의 이름을 변경할 때, 함수의 인수를 추가하거나 제거할 때 많이 쓰인다
바꿀 대상이 변수라면 변수 이름 바꾸기 기법을 사용하고 이는 변수 캡슐화 하기 와 ****관련이 깊다.
자주 함께 뭉쳐다니는 인수들은 매개변수 객체 만들기 기법을 적용해서 객체를 하나로 묶으면 편리할 때가 많다.
이렇게 이름을 짓거나 바꾸는 건 가장 기본적인 리팩터링이다.
이 다음으로는 함수를 만들면 함수들을 모듈로 묶는 여러 함수를 클래스로 묶기 을 이용할 수도 있다.
또 다른 함수를 묶는 방법으로는 여러 함수를 변환 함수로 묶기 도 있는데 이는 읽기 전용 데이터를 다룰 때 특히 좋다. (변환 함수는 가공된 데이터를 새로운 데이터에 저장하는데 이는 원본 데이터가 수정되면 일관성이 깨질 수 있기 때문에.)
그 다음 단계로는 모듈로 만들었으면 명확히 모듈끼리 단계를 구분짓는 단계 쪼개기 기법을 적용하는 것도 가능하다.
Before Refactoring
function prinOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
console.log('고객명: ${invoice.customer}');
console.log('채무액: ${outstanding}');
}
After Refactoring
function printOwing(invoice) {
printBanner();
let outstanding = calculateOutstanding();
printDetails(outstanding);
function printDetails(outstanding) {
console.log('고객명: ${invoice.customer}');
console.log('채무액: ${outstanding}');
}
}
함수 추출하기는 코드 조각을 찾아 무슨 일을 하는 지 파악한 다음 독립된 함수로 추출하고 목적에 맞는 이름을 붙이는 것이다.
(여기서는 함수라는 큰 의미의 용어를 사용했는데, 여기서 말하는 함수는 객체지향에서의 메소드나 절차형 언어의 프로시저 / 서브루틴 에서도 똑같이 적용이 되는 대상을 뜻한다.)
그렇다면 언제 함수 추출하기 기법을 사용할까?
- 길이를 기준으로 (6줄이 넘는 함수에서는 악취가 나기 시작한다.) 예로 절대 한 화면을 넘어가지 않도록 한다.
- 중복을 제거하기 위해서. 재사용성을 위해서
- 목적과 구현을 분리하는 방식 (함수 하나에 여러가지 일을 하면 구현이 여러개가 담긴다. 그러면 그때부터 이해하기가 어려워지기 시작하므로 이러한 구현들을 추출해서 무엇을 하는지 이름을 지어주자. 목적을 정해주자.)
추가로 알아둘 사항으로는 함수 추출하기는 늘 이름 짓기가 동반되므로 이름을 잘 지어야만 이 리팩터링의 효과가 발휘된다.
Before Refactoring
function getRating(driver) {
return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver) {
return driver.numberOfLateDeliveries > 5;
}
After Refactoring
function getRating(driver) {
return (driver.numberOfLateDeliveries > 5) ? 2 : 1;
}
함수 자체가 짧은걸 권장하지만 때로는 함수 본문이 함수 이름만큼 명확한 경우가 있다면 함수를 제거하는 게 좋다.
쓸데없는 간접 호출은 거슬릴 뿐이다.
또 적용할 시점은 리팩터링 과정에서 잘못 추출한 함수들이 있다면 이를 제거하기 위해 인라인 할 수도 있다.
그리고 간접 호출을 너무 과하게 쓰는 경우가 있다면 즉 가령 다른 함수들로 위임만 하는 구조가 있다면 이를 인라인 하기도 한다.
정리하자면 이 기법은 필요없는 함수 호출을 제거하기 위해서 있다.
Before refactoring
return order.quantity * order.itemPrice -
Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 +
Math.min(order.quantity * order.itemPrice * 0.1, 100);
After refactoring
const basePrice = order.quantity * order.itemPrice;
const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05;
const shipping = Math.min(basePrice * 0.1, 100);
return basePrice - quantityDiscount + shipping;
하나의 표현식이 너무 복잡하다면 표현식의 일부를 지역 변수로 추출해서 관리하면 이해하기가 더 쉽다.
복잡한 단계를 하나의 호흡으로 가져가기 보다 매 단계별 이름을 붙혀서 보다 코드의 목적을 드러내기 쉽기 때문이다.
변수를 추출하기로 결정했다면 그것이 적용할 문맥이 어디까지 필요한지 살펴봐야한다.
이 함수 안에서만 필요한 지 아니면 다른 함수에서도 이 표현식의 단계가 필요한 지 생각해봐야 한다.
이 함수 안에서만 필요하면 지역 변수로 쓰면 되지만 다른 함수에서도 필요하다면 이 표현식을 함수로 추출하는게 더 나을 수 있다.
이렇게 맥락을 고려해서 짜면 중복 작성이 줄어들 수 있다는 장점이 있다.
Before refactoring
let basePrice = anOrder.basePrice;
return (basePrice > 1000);
After refactoring
return anOrder.basePrice > 1000;
변수는 함수 안에서 특정한 의미를 가져서 코드의 이해를 도와준다.
하지만 변수가 많으면 코드를 리팩터링 하는데 방해를 주기도 하고 변수가 원래 표현식과 다를 바 없을 때도 있다.
즉 원래 표현식 만으로도 충분히 설명이 되는걸 말한다.
Before refactoring
function circum(radius) {...}
After refactoring
function circumference(radius) {...}
함수의 이름이 좋으면 이름만 보고도 무슨 일을 하는지 파악하는게 가능하지만 나쁜 이름은 혼란을 일으킨다.
이름이 잘못된 함수가 있다면 무조건 바꾸자.
그치만 좋은 이름을 짓기 위한 팁이 있는데 함수의 목적을 주석으로 설명해보는 것이다. 그러다 보면 주석이 멋진 이름으로 바뀌어 올 때가 있다.
또 다른 팁은 나는 실제 구현사항에 집중해서 함수의 이름을 짓기 보다는 해줘야하는 역할에 좀 더 집중하면 잘되더라.
함수의 이름 뿐 아니라 매개변수도 마찬가지다.
매개변수는 함수와 어울려서 함수의 문맥을 정해준다. 즉 (함수 이름 + 매개변수) 로 함수를 이해한단 뜻이다.
매개변수를 올바르게 선택하기는 단순히 규칙 몇 개로 표현할 수는 없다.
예를들어서 대여한 지 30일이 지났는지를 기준으로 지불 기한이 넘었는지 판단하는 함수가 있다고 생각해보자.
매개변수로 지불 객체가 적절할까? 마감일을 넘기는게 적합할까?
이런 문제는 정답이 없다.
마감일을 넘기면 날짜와만 결합하면 되므로 다른 모듈과 결합하지 않아도 된다. 즉 신경 쓸 요소가 적어진다.
지불 객체를 전달하면 지불이 제공하는 여러 속성을 전달받을 수 있다. 이로 인해 캡슐화 수준을 높일 수 있다.
그러므로 각각에 장단점이 있기 때문에 우리가 취해야 하는 건 고칠 수 있는 능력을 갖추는 것이다.
Before refactoring
let defaultOwner = {firstName: "마틴", lastName: "파울러"};
After refactoring
let defaultOwnerData = {firstName: "마틴", lastName: "파울러"};
export function defaultOwner() { return defaultOwnerData; }
export function setDefaultOwner(ars) { defaultOwnerData = arg; }
함수는 데이터보다 다루기 수월하다.
함수는 대체로 호출 하는식으로 동작되며 함수를 바꿀 때는 함수가 다른 함수를 호출하도록 변경만해주면 쉽게 바꾸는게 가능하다.
하지만 데이터는 데이터를 사용하는 모든 부분을 바꿔줘야한다.
짧은 함수 안의 임수 변수처럼 유효범위가 아주 좁은 데이터는 문제가 되지 않지만 이러한 이유로 전역 데이터는 골칫거리가 될 수 있다.
그래서 접근할 수 있는 넓은 유혀범위를 가진 데이터는 먼저 그 데이터의 접근을 독점하는 함수를 만드는게 가장 좋다.
즉 그냥 데이터를 바로 접근하는 것보다 함수를 통해서 접근하는게 통제성이 더 좋다.
데이터 재구성 보다 함수 재구성이 더 간단하기 때문이다.
이렇게 데이터 캡슐화를 하면 이점이 있는데 데이터 변경 전이나 변경 후 추가 로직을 쉽게 넣는게 가능하다.
나는 유효범위가 함수 하나보다 넓은 가변 데이터는 모두 이런식으로 캡슐화를 한다.
객체 지향에서 객체의 데이터를 항상 private 으로 유지해야 한다고 그토록 강조하는 이유가 여기에 있다.
Before refactoring
let a = height * width;
After refactoring
let area = height * width;
이름 짓기와 관련된 리팩터링 기법이다.
개발을 진행하다가 도메인 지식이 늘어서 그에 맞도록 이름을 변경하거나 사용자의 요구사항이 변경되서 그에 맞게 이름을 변경해야 하는 경우에 사용한다.
Before refactoring
function amountInvoiced(startDate, endDate) {...}
function amountReceived(startDate, endDate) {...}
function amountOverdue(startDate, endDate) {...}
After refactoring
function amountInvoiced(aDateRange) {...}
function amountReceived(aDateRange) {...}
function amountOverdue(aDateRange) {...}
데이터 항목 여러 개가 이 함수로 저 함수로 같이 몰려다니는 경우를 자주 볼 수 있다.
나는 이런 데이터 무리를 발견하면 하나의 데이터 구조로 만들어주는 걸 선호한다.
데이터 사이의 관계가 명확해져서 하나의 책임을 가질 수 있고, 또 파라미터 개수를 줄여줄 수 있으니까 함수의 이해가 쉬워지기 때문이다.
Before refactoring
function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}
After refactoring
class Reading {
base() {...}
taxableCharge() {...}
calculateBaseCharge() {...}
}
나는 함수 호출 시 공통 인수로 전달되는 공통 데이터를 사용하는 함수가 여럿 있다면 이들을 하나의 클래스로 묶는 걸 선호한다.
클래스로 묶으면 이 함수들이 공유하는 공통 환경을 더 명확하게 표현하는게 가능해지기 때문이다.
그리고 각 함수에 전달되는 인수를 줄여서 함수 호출이 더 간결하게 만들 수 있다.
Before refactoring
function base(aReading) {...}
function taxableCharge(aReading) {...}
After refactoring
function enrichReading(argReading) {
const aReading = _.cloneDeep(argReading);
aReading.baseCharge = base(aReading);
aReading.taxableCharge = taxableCharge(aReading);
return aReading;
}
소프트웨어는 데이터를 입력 받아서 여러가지 정보를 도출한다.
이렇게 도출된 정보를 바탕으로 비슷한 도출 로직이 또 일어나는 경우가 있다.
이 경우에는 이런 도출 작업들을 한 곳으로 모우는걸 추천한다.
모아두면 검색과 갱신을 일관적으로 적용하는게 가능해지기 때문이다. 즉 중복 제거와 관련이 깊다.
이 방법으로 변환 함수 (transform) 를 적용할 수 있다.
변환 함수는 원본 데이터를 받아서 필요한 정보를 도출하고 출력 데이터를 만들어서 이를 반환하는 방법이다.
이 변환 함수의 특징은 여러 곳에서 도출하는게 아니라 변환 함수만 바라보도록 하는 것이 특징이다.
이 방법 대신 여러 함수를 클래스로 묶기 로 처리해도 좋다.
다만 원본 데이터가 코드 안에서 갱신 되는 경우라면 클래스로 문제를 해결하는 것이 낫다.
그 데이터 변경은 자기가 담당하는게 나으니까. 데이터와 그 데이터를 다루는 메소드는 가까이 있는게 좋다.
변환 함수로 묶는다면 원본 데이터가 수정되면 일관성이 꺠질 수 있기 떄문이다.
Before refactoring
const orderData = orderString.split(/\s+/);
const productPrice = priceList[orderData[0].split("-")[1]];
const orderPrice = parseInt(orderData[1]) * productPrice;
After refactoring
const orderRecord = parseOrder(order);
const orderPrice = price(orderRecord, priceList);
function parseOrder(aString) {
const values = aString.split(/\s+/);
return ({
productID: values[0].split("-")[1],
quantity: parseInt(values[1]),
});
}
function price(order, priceList) {
return order.quantity * priceList[order.productID];
}
나는 서로 다른 두 대상을 한꺼번에 다루는 코드를 발견하면 각각을 별개의 모듈로 나누는 방법을 찾는다.
두 대상을 한번에 생각하는 것이 아니라 한 대상씩 생각하기 위함이다. (코드의 이해 측면에서 더 도와준다. 단계별로 모듈을 나누면)
모듈이 잘 분리되어 있다면 다른 모듈의 상세 내용은 전혀 기억하지 않아도 된다 라는 장점이 있다.
이렇게 하기 위해 가장 간편한 방법은 동작을 연이은 두 단계로 쪼개는 것이다. ****(한 모듈 진행 후 다른 모듈 진행 이런식으로 단계를 나누는 것)
이런 과정은 컴파일러의 예시로 이해하면 좋다.
컴파일러는 기본적으로 어떤 텍스트를 입력받아서 실행 가능한 형태로 변환한다.
컴파일러는 지속적으로 발전하면서 여러 단계로 구성되는게 좋다고 판단되었는데 과정은 다음과 같다.
- 텍스트를 토큰화 하기
- 토큰을 파싱해서 구문 트리 만들기
- 구문 트리 변환해서 목적 코드 만들기
각 단계는 자신의 목적만 집중하기 때문에 나머지 단계를 몰라도 된다.
즉 자신의 문제만 해결하면 된다.
이렇게 단계를 쪼개는 기법은 주로 덩치 큰 소프트웨어에 적용된다.
하지만 나는 규모에 관계없이 여러 단계로 분리하면 좋을만한 코드를 발견할 때마다 기본적으로 단계 쪼개기 리팩터링을 한다.
코드 영역들이 마침 서로 다른 데이터와 함수를 사용한다면 이는 단계 쪼개기에 적합하다는 뜻이다.
이렇게 별개의 모듈로 분리하면 코드를 훨씬 분명하게 드러내는게 가능하다.
모듈을 분리하는 가장 중요한 기준은 필요없는 정보는 드러내지 않는 것이다.
즉 모듈에서 노출되야 할 부분만 정확하게 노출하는 것이다.
이러한 방법은 캡슐화를 통해서 가능하다.
대표적인 방법으로는 레코드 캡슐화 하기 와 컬렉션 캡슐화하기 로 캡슐화해서 숨기는게 가능하다.
심지어 기본형 데이터 구조도 기본형을 객체로 바꾸기 로 캡슐화할 수 있다. (이때 얻을 수 있는 효과는 진짜로 놀랍다.)
리팩터링 할 때 임시 변수가 거슬리는 경우가 있는데 이런 경우는 임시 변수를 질의 함수로 바꾸기 가 상당히 도움된다. 특히 길이가 긴 함수를 쪼개는 경우에.
클래스는 본래 정보를 숨기는 용도로 설계되었다.
앞 장에선 여러 함수를 클래스로 묶기 를 통해 클래스를 만드는 방법을 소개했다. 이외에도 흔히 사용하는 추출하기/인라인하기 리팩터링 기법인 클래스 추출하기 와 클래스 인라인하기 도 활용할 수 있다.
클래스는 내부 정보 뿐 아니라 연결 관계를 숨기는 데도 유용한데 이를 위한 방법으로는 위임 숨기기 가 있다.
물롱 위임 숨기기를 통해 필요없는 중개자가 너무 많아지는 경우에는 중개자 제거하기 도 필요하다.
가장 큰 캡슐화 단위는 클래스와 모듈이지만 함수도 캡슐화가 가능하다. 때로는 함수 알고리즘을 통째로 바꿔야 할 때가 있는데 이는 함수 추출하기 로 알고리즘 전체를 하나의 함수에 담은 뒤 알고리즘 교체하기 를 적용하면 된다.
Before refactoring
organization = {name: "애크미 구스베리", country: "GB"}; // 레코드
After refactoring
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
get name() {return this._name;}
set name(arg) {this._name = arg;}
get country() {return this._country;}
set country(arg) {this._country = arg;}
}
대부분의 프로그래밍 언어에서는 데이터 레코드를 표현하는 구조를 제공한다. (자바에서는 클래스만 있지 레코드를 지원하지는 않는다.)
레코드를 다룰 때 주의할 점은 계산해서 얻을 수 있는 값과 그렇지 않은 값은 구별해야 한다는 점이다.
이러한 이유 때문에 가변 데이터에서는 레코드 대신 클래스를 이용하는 걸 선호한다.
클래스를 이용하면 어떠한 데이터를 제공해주는지 메소드를 보면 바로 알 수 있다.
데이터가 불변 데이터인 경우에는 값만 제공해주면 되므로 굳이 클래스를 쓰지는 않는다. 그냥 모든 필드를 레코드에서 모두 제공해주도록 한다.
Before refactoring
class Person {
...
get courses() {return this._courses;}
set courses(aList) {this._courses = aList;}
}
After refactoring
class Person {
...
get courses() {return this._courses.slice();}
addCourse(aCourse) {...}
removeCourse(aCourse) {...}
}
나는 가변 데이터는 모두 캡슐화를 하는 편이다.
이렇게 할 경우 데이터들이 언제 어떻게 수정되는지 추적하기 좋기 때문이다.
컬렉션 구조를 사용할 때 주의할 점이 있는데 컬렉션안의 요소를 변경하는 작업이 필요한건 클래스 메소드로 따로 만들어 두는 것이다. 예로 add() remove() 같은 메소드들 말이다.
이 말은 컬렉션 자체를 반환하는 것을 막는다는 건 아니다.
컬렉션 자체 반환을 막도록 하면 컬렉션에서 사용가능한 다채로운 인터페이스를 사용하는데 제한이 걸리기 때문이다.
그래서 컬렉션 변경과 같은 작업은 클래스 메소드를 통해서 이뤄지도록 하고
컬렉션을 반환하는 getter 함수는 컬렉션 자체를 반환하는 것이 아니라 복사본을 반환하도록 하자. 그리고 컬렉션을 통째로 변경할 수 있는 세터는 없애도록 하자. 없앨 수 없으면 인수로 전달받은 컬렉션을 복제본으로 가져와서 원본에 영향이 안가도록 하자.
물론 원본에 영향이 안가는게 이상하다고 느낄 순 있지만 대부분의 프로그래머는 이 패턴을 이용하니까 이상하게 느껴지지는 않을 것이다. 물론 성능적으로 크게 문제가 된다면 이를 적용하면 안되겠지만 그럴 일은 거의 없다.
이 방법외에도 컬렉션을 읽기 전용으로 해놓고 사용하는 방법도 있다. 그러면 문제가 되는 상황은 거의 없을 것이니.
Before refactoring
orders.filter(o => "high" === o.priority
|| "rush" === o.priority);
After refactoring
orders.filter(o => o.priority.higherThan(new Priority("normal")))
개발 초기에는 단순한 정보나 문자를 표현했던 데이터들이 프로그램 규모가 커질수록 간단해지지 않아진다.
예컨대 전화번호 같은 문자열 데이터가 나중에는 포매팅이나 지역 코드 추출과 같은 특별한 동작이 필요해질 수 있다.
나는 데이터가 단순히 출력 이상의 기능이 필요해진다면 클래스로 바꾼다.
이렇게 바꿈으로써 나중에 특별한 동작이 필요해지면 이 클래스에 추가하면 되므로 유용하다.
Before refactoring
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
After refactoring
get basePrice() {this._quantity * this._itemPrice;}
...
if (this.basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
함수 안에서 어떤 코드의 결과값을 뒤에서 다시 참조할 목적으로 임시 변수를 사용한다.
임시 변수는 계산된 결과를 반복적으로 계산하지 않기 위해서 사용하는데 이는 함수로 만들어두는게 유용한 경우가 있다.
예로 여러 곳에서 똑같은 방식으로 계산되는 변수를 보면 이를 함수로 추출해놓는다면 중복을 제거할 수 있다.
또 함수를 추출하는 작업을 할 때 임시 변수가 문제가 되는데 (파라미터로 전달해야 하니까) 함수의 파라미터 수를 줄이는데 기여를 한다.
이 기법을 사용할 때 주의할 점은 변수에 여러번의 대입을 하는 경우에는 사용하면 안된다는 점이다.
Before refactoring
class Person {
get officeAreaCode() {return this._officeAreaCode;}
get officeNumber() {return this._officeNumber;}
}
After refactoring
class Person {
get officeAreaCode() {return this._telephoneNumber.areaCode;}
get officeNumber() {return this._telephoneNumber.number;}
}
class TelephoneNumber {
get areaCode() {return this._areaCode;}
get number() {return this._number;}
}
클래스는 반드시 명확하게 추상화하고 주어진 소수의 역할만 수행해야한다.
하지만 실무에서는 주어진 클래스에 데이터와 동작이 계속해서 추가되면서 커지는 경우가 많다.
역할이 많아지고 데이터와 메소드가 많은 클래스는 이해하기 어렵다.
그러므로 메소드와 데이터를 따로 묶을 수 있다면 클래스를 분리해서 그 클래스에게 작업을 위임하도록 하는게 좋다.
Before refactoring
class Person {
get officeAreaCode() {return this._telephoneNumber.areaCode;}
get officeNumber() {return this._telephoneNumber.number;}
}
class TelephoneNumber {
get areaCode() {return this._areaCode;}
get number() {return this._number;}
}
After refactoring
class Person {
get officeAreaCode() {return this._officeAreaCode;}
get officeNumber() {return this._officeNumber;}
}
클래스 인라인 하기는 클래스 추출하기의 반대되는 리팩토링 기법이다.
나는 더 이상 제 역할을 하지 못하는 클래스가 있다면 인라인 해버린다.
주로 역할을 옮기는 리팩토링 이후 남은 역할이 거의 없을 때 이 클래스를 가장 많이 사용하는 클래스로 옮긴다.
두 클래스의 기능을 합치고 싶을 때 인라인 하는 기법을 사용한다.
애매한 역할을 하는 두 클래스가 있다면 그것들을 합쳐서 새로운 클래스를 추출 하는게 더 나을 수 있기 때문이다.
Before refactoring
manager = aPerson.department.manager;
After refactoring
manager = aPerson.manager;
class Person {
get manager() {return this.department.manager;}
모듈화 설계를 제대로 하는 핵심은 캡슐화다.
캡슐화는 모듈이 노출하는 요소롤 제한해서 꼭 필요한 부분을 위주로 협력하도록 해준다.
캡슐화가 잘 되어 있다면 무언가를 변경할 때 함께 고려해야 할 모듈 수가 적어져서 코드를 변경하기 쉬워진다.
예로 객체가 다른 객체의 메소드를 호출할려면 그 객체를 알아야 한다. 근데 호출 당하는 객체의 인터페이스가 변경되면 그 객체를 알고있는 모든 객체가 변경해야한다.
이런 경우가 발생할 수 있다면 그 객체를 노출시키지 않으면 된다. 숨기면 된다. 그러면 아무런 영향을 받지 않는다.
이렇게 객체가 다른 객체를 알면 안되는 경우 즉 객체와 다른 객체가 결합하면 안되는 경우에 이 기법을 쓰면 좋다.
Before refactoring
manager = aPerson.manager;
class Person {
get manager() {return this.department.manager;}
After refactoring
manager = aPerson.department.manager;
위임 숨기기의 반대되는 리팩토링이다.
위임 숨기기는 접근하려는 객체를 제한하는 캡슐화를 제공하는 이점으로 불필요한 결합이나 의존성을 제거해주는 이점이 있다.
근데 만약 클래스에 위임이 너무 많다면 그냥 접근을 허용하도록 하는게 더 나을 수도 있다.
즉 결합을 해야하는 구조라면 결합을 하는게 나을 수 있다.
객체가 단순히 중개자 (middle man) 역할만 해준다면 이 리팩토링 기법을 고려해보자.
Before refactoring
function foundPerson(people) {
for(let i = 0; i < people.length; i++) {
if (people[i] === "Don") {
return "Don";
}
if (people[i] === "John") {
return "John";
}
if (people[i] === "Kent") {
return "Kent";
}
}
return "";
}
After refactoring
function foundPerson(people) {
const candidates = ["Don", "John", "Kent"];
return people.find(p => candidates.includes(p)) || '';
}
어떤 목적을 달성하는 방법은 여러가지가 있다.
그 중에선 분명 더 나은 방법이 있을 것이다.
나는 이렇게 더 나은 방법을 찾아내면 복잡한 기존의 방법을 걷어내고 코드를 간명한 방식으로 고친다.
리팩토링하면 복잡한 대상을 단순한 단위로 나누는게 가능하지만 이렇게 때로는 알고리즘 전체를 걷어내고 훨씬 간결한 알고리즘으로 바꿔야 할 때가 있다.
알고리즘을 살짝 다르게 동작하도록 바꾸고 실을 때도 통쨰로 바꾼후에 처리하면 더 간단하게 할 수 있다.
이 방법을 하기전에는 만드시 메소드를 가능한 잘게 나눴는지 확인하자. 거대하고 복잡한 알고리즘은 교체하기 어려우므로.
여기에 있는 기법들은 코드를 다른 컨택스트로 옮겨서 적절한 모듈을 만들어주는 방향의 리팩토링을 진행한다
모듈화를 하는 이유는 뭘까?
- 코드에서 작은 변경 (Small change) 를 만들기 위해서. 이 기준은 다음과 같다.
- 변경 포인트를 쉽게 찾을 수 있는가?.
- 변경의 여파가 적은가?
- 코드가 더욱 명확해진다.
- 구조가 잘 짜여져있으므로 동작을 이해하기 쉽다.
- 캡슐화를 통해서 불 필요한 정보를 드러내지 않아도 된다.
그리고 여기서는 추가로 반복문을 다루는 리팩터링도 소개한다.
- 반복문이 단 하나의 일만 수행하도록 보장하는 변경인 반복문 쪼개기 를 사용하고
- 반복문을 더 이해하기 쉽게 파이프라인으로 바꾸는 방법인 반복문을 파이프라인으로 바꾸기 기법이 있다. ****
마지막으로 죽은 코드를 제거하는 리팩터링인 죽은 코드 제거하기 를 소개하고 이 장은 마무리된다.
Before refactoring
class Account {
get overdraftCharge() {
...
}
}
After refactoring
class AccountType {
get overdraftCharge() {
...
}
}
함수 옮기기 기법 은 모듈성을 적용하기 위한 리팩토링 기법이다.
함수 옮기기 기법 은 대상 함수의 현재 컨택스트와 후보 컨택스트를 둘러보면서 비교하면 된다. (어떤 데이터를 가지고 있는지, 어떤 함수를 가지고 있는지 등을 비교하면서)
Before refactoring
class Customer {
get plan() {return this._plan;}
get discountRate() {return this._discountRate;}
}
After refactoring
class Customer {
get plan() {return this._plan;}
get discountRate() {return this.plan.discountRate;}
}
프로그램은 동작을 구현하는 코드로 이뤄지지만 그 힘은 데이터 구조로부터 나온다.
잘 짜여진 데이터 구조는 직관적으로 어떠한 동작을 수행하는지 이해하기 쉽고 짜기 쉽다.
하지만 처음부터 데이터 구조를 올바르게 짜기가 어렵다.
설계를 열심히 해서 잘 짰다 하더라도 도메인 지식이 점점 쌓이면 더 적합한 데이터 구조가 보일수도 있다.
그러므로 더 올바른 구조가 보인다면 그떄그때 리팩토링을 적용하는게 중요하다.
Before refactoring
result.push('<p>제목: ${person.photo.title}</p>');
result.concat(photoData(person.photo));
function photoData(aPhoto) {
return [
'<p>위치: ${aPhoto.location}</p>',
'<p>날짜: ${aPhoto.date.toDateString()}</p>',
];
}
After refactoring
result.concat(photoData(person.photo));
function photoData(aPhoto) {
return [
'<p>제목: ${aPhoto.title}</p>',
'<p>위치: ${aPhoto.location}</p>',
'<p>날짜: ${aPhoto.date.toDateString()}</p>',
];
}
이 방법은 중복 코드를 제거하기 위해 하나의 함수로 합치는 리팩토링 기법이다.
만약 어떤 함수를 호출한 이후에 앞 뒤로 같은 함수를 호출하는 일이 반복된다면 이를 합치는게 좋다.
이렇게 함치기 위해 문장을 옮길려면 합쳐지는 함수 즉 피호출 함수와 옮겨지는 문장이 한 몸이라는 확신이 있어야 한다.
한 몸 정도까지는 아니고 그냥 단순히 합쳐지는 경우가 꽤 많다면 그냥 새로운 함수를 만드는게 낫다.
Before refactoring
emitPhotoData(outStream, person.photo);
function emitPhotoData(outStream, photo) {
outStream.write('<p>제목: ${photo.title}</p>\n');
outStream.write('<p>위치: ${photo.location}</p>\n');
}
After refactoring
emitPhotoData(outStream, person.photo);
outStream.write('<p>위치: ${photo.location}</p>\n');
function emitPhotoData(outStream, photo) {
outStream.write('<p>제목: ${photo.title}</p>\n');
}
함수는 프로그래머가 쌓아 올리는 추상화의 기본 빌딩 블록이다.
그런데 추상화라는 것이 항상 경계가 완벽하게 되는 것은 아니다.
코드베이스의 기능 범위가 달라진다먄 기존에 하나의 일만 수행하던 함수가 어느새 두 개 이상의 일을 수행하는 경우도 많다.
즉 여러 곳에서 사용하던 함수가 일부 호출자에게는 다른 기능을 적용하도록 해야한다면 이 코드의 일부를 호출자에게 전달하는게 좋다.
Before refactoring
let appliesToMass = false;
for(const s of states) {
if (s === "MA") appliesToMass = true;
}
After refactoring
appliesToMass = states.includes("MA");
함수는 여러 동작을 하나로 묶어준다.
그리고 함수의 이름이 코드의 동작보다 목적을 말해주기 때문에 똑같은 코드 대신, 함수를 활용하면 이해하기가 더 쉬워진다는 장점이 있다. 또 중복을 없앨수도 있다.
이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 만나면 이를 함수 호출로 바꾸자.
특히 라이브러리가 제공하는 함수로 대체할 수 있다면 특히 좋다. 함수 본문조차 작성하지 않아도 되기 때문에.
Before refactoring
const pricingPlan = retrievePricingPlan();
const order = retreiveOrder();
let charge;
const chargePerUnit = pricingPlan.unit;
After refactoring
const pricingPlan = retrievePricingPlan();
const chargePerUnit = pricingPlan.unit;
const order = retreiveOrder();
let charge;
관련된 코드들이 서로 가까이 모여 있다면 이해하기가 더 쉽다.
실제로 나는 문장 슬라이드하기 리팩토링으로 이런 코드들을 한 데 모아둔다.
가장 흔한 사례는 변수를 선언하고 사용할 때인데 모든 변수 선언을 함수 첫머리에 모아두는 사람도 있지만 나는 변수를 처음 사용할 때 선언하는 스타일을 선호한다.
문장 슬라이드로 코드를 옮길 때 주의할 점은 부수효과를 고려하는 것이다. (건너뛸 코드 조각이나 슬라이드할 코드 조각이 서로 값을 바꿀 가능성이 있다면 사이드 이펙트가 일어날 수 있으므로.)
부수효과가 없는 코드끼리는 마음 가는 대로 재배치할 수 있지만 부수효과가 있다면 그렇지 않다.
그러므로 애초에 이런 부수효과를 만들지 않기 위해서 명령-질의 분리 (Command-Query Separation) 원칙을 지키자. 이러면 코드의 이해가 더 쉬워지고 리팩토링이 더 쉽다.
Before refactoring
let averageAge = 0;
let totalSalary = 0;
for (const p of people) {
averageAge += p.age;
totalSalary += p.salary;
}
averageAge = averageAge / people.length;
After refactoring
let totalSalary = 0;
for (const p of people) {
totalSalary += p.salary;
}
let averageAge = 0;
for (const p of people) {
averageAge += p.age;
}
averageAge = averageAge / people.length;
종종 반복문 하나에서 두 가지 일을 수행하는 경우가 있다.
한번에 모두 처리하는게 성능상에 좋지 않을까? 라는 생각에 기안해서 말이다.
근데 이렇게 하면 코드를 고치기 어렵다. 반복문 안에서 어떠한 일을 수행하고 있는지 제대로 파악해야만 고칠 수 있기 때문이다. (그리고 몇번의 반복을 더한다고 그렇게 느려지지 않는다.)
반복문을 분리하면 사용하기도 쉬워진다.
한 가지 값만 계산하는 반복문이라면 그 값만 곧바로 반환하는게 가능하다.
반복문 쪼개기도 주의할 점이 있는데 이는 부수효과가 있는지 살펴보는 것이다. ****
Before refactoring
const names = [];
for (const i of input) {
if (i.job === "programmer")
names.push(i.name);
}
After refactoring
const names = input
.filter(i => i.job === "programmer")
.map(i => i.name)
;
프로그래머 대부분이 그렇듯 나도 객체 컬렉션을 순회할 때 반복문을 사용하라고 배웠다.
하지만 언어는 계속해서 발전하고 더 나은 구조를 제공해준다.
여기서 나오는 파이프라인 (Pipeline) 을 이용하면 처리 과정을 일련의 연산으로 표현할 수 있고 이를 통해 코드가 더욱 명확해진다.
Before refactoring
if(false) {
doSomethingThatUsedToMatter();
}
After refactoring
소프트웨어에서 사용되지 않은 코드가 있다면 그 소프트웨어의 동작을 이해하는 데 커다란 어려움을 줄 수 있다.
이 코드들은 절대 호출되지 않으니 무시해도 된다! 라는 신호를 주지 않기 때문이다. (호출이 되지 않더라도 다른 개발자가 죽은 코드를 볼 때 의도적으로 남겨놓았구나 라고 생각 할 수 있기 떄문에 사용되지 않는다면 삭제하자.)
코드가 더 이상 사용되지 않게 됐다면 지웡야한다. 혹시라도 다시 필요해질 날이 오지 않을까 싶다면 버전 관리 시스템을 이용하도록 하자.
데이터 구조는 프로그램에서 중요한 역할을 한다. 이 챕터는 이와 관련된 리팩터링을 한다.
하나의 값이 여러 목적으로 사용한다면 혼란과 버그를 야기할 수 있으므로 변수 쪼개기 을 활용해 적절하게 분리하자.
다른 프로그램 요소와 마찬가지로 변수 이름을 제대로 짓는것은 중요하므로 변수 이름 바꾸기 와는 친해져야한다.
변수 자체를 완전히 없애는게 더 나은 경우라면 파생 변수를 질의 함수로 바꾸기를 사용하자.
참조 (Reference) 인지 값 (Value) 인지에 따라 헷갈려서 문제가 되는 코드가 있을 수 있다. 이런 경우에는 둘 사이를 전환할 수 있는 참조를 값으로 바꾸기 와 값을 참조로 바꾸기 를 적용하자.
Before refactoring
let temp = 2 * (height + width);
console.log(temp);
temp = height * width;
console.log(temp);
After refactoring
const perimeter = 2 * (height + width);
console.log(perimeter);
const area = height * width;
console.log(area);
변수는 다양한 용도로 사용 될 수 있다.
그치만 변수를 사용할 땐 딱 하나의 역할만으로 사용해야 한다. 한 변수를 여러가지 목적으로 사용하면 코드를 이해하는데 혼란을 줄 수 있고 추후에 버그를 낼 가능성이 높다.
이 말은 변수에 한번 이상 대입을 하는 경우에는 문제가 있다는 뜻이다.
Before refactoring
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
get name() {return this._name;}
set name(aString) {this._name = aString;}
get country() {return this._country;}
set country(aCountryCode) {this._country = aCountryCode;}
}
After refactoring
class Organization {
constructor(data) {
this._title = data.title;
this._country = data.country;
}
get title() {return this._title;}
set title(aString) {this._title = aString;}
get country() {return this._country;}
set country(aCountryCode) {this._country = aCountryCode;}
}
이름이 중요하다는 사실은 다들 안다.
프로그램 곳곳에 쓰이는 레코드의 필드들의 이름은 특히 더 중요하다.
이런 이름들로 인해 데이터 구조를 이해하기 더 쉬워지고 결국에 프로그램을 이해하기 더 쉬워진다.
데이터 구조가 중요한 만큼 프로젝트를 진행하면서 더 깊은 이해를 바탕으로 더 적합한 이름이 보인다면 즉시 이를 반영하도록 하자.
Before refactoring
get production() { return this._production; }
applyAdjustment(anAudjustment) {
this._adjustments.push(anAdujustment);
this._production += anAdjustment.amount;
}
After refactoring
get production() {
return this._adjustments
.reduce((sum, a) => sum + a.amount, 0);
}
applyAdjustment(anAudjustment) {
this._adjustments.push(anAdujustment);
}
파생 변수란 말은 기존의 변수를 조합해서 새로운 변수를 추출하는 걸 말한다.
즉 기존의 변수들이 업데이트가 되면 이 파생 변수에도 영향이 갈 여지가 있다는 뜻이다.
가변 데이터는 소프트웨어에 문제를 일으키는 요소 중 하나다.
주로 일으키는 문제는 한 쪽에서 업데이트 되서 연결된 다른 쪽에서 연쇄효과를 일으켜서 문제를 찾기 어렵게 만든다.
그렇다고 가변 데이터를 사용하지 않을 수도 없기 때문에 일단 가변 데이터를 사용하는 경우라면 유효 범위를 제한시켜 놓는게 가장 기본적이다.
또 추가적인 방법으로 값을 계산해낼 수 있는 변수들은 모두 제거하고 함수로 만들어 두는 방법이 있다.
매번 값을 계산해서 변수에 저장하고 있는 것보다 함수 호출로 그 때가서 계산하는게 문제가 일어나지 않을 확률이 더 높다.
Before refactoring
class Product {
...
applyDiscount(arg) {this._price.amout -= arg;}
}
After refactoring
class Product {
...
applyDiscount(arg) {
this._price = new Money(this._price.amount - arg, this._price.currency);
}
}
객체를 다른 객체에서 사용한다고 하면 참조 또는 값으로 사용할 수 있다.
참조냐 값이냐에 따른 가장 극명한 차이는 변경 전파인데, 참조의 경우에는 내부의 객체의 값이 변경되면 전파되지만 값인 경우에는 새로운 객체가 전달되었으니 기존 객체에는 영향이 없다.
즉 정리하면 다음과 같다.
- 값을 쓰는 경우는 변경의 전파를 원하지 않을 때.
- 참조를 쓰는 경우는 특정 객체를 여러 객체에서 공유하고 있고 그 객체의 변경이 다른 객체도 모두 알아야 하는 경우에 사용한다.
Before refactoring
let customer = new Customer(customerData);
After refactoring
let customer = customerRepository.get(customerData.id);
하나의 데이터 구조 안에 논리적으로 똑같은 데이터 구조를 참조하는 레코드가 여러 개 있을 때가 있다.
예로 보면 주문 목록을 읽다 보면 같은 고객의 요청 주문이 여러 개 섞여 있을 수 있다.
이때 고객을 값으로도, 참조로도 다룰 수 있는데 둘의 차이는 변경의 유무다. ****물론 복사를 하면 더 많은 메모리를 사용한다는 점이 있지만 크게 문제가 되진 않는다.
이전에 얘기했듯이 논리적으로 같은 데이터를 물리적으로 복제할 때 가장 큰 문제점은 데이터를 갱신할 때 일관성의 문제다.
그리고 값을 참조로 바꿀때 엔터티 하나당 객체가 단 하나만 존재하게 되는데 그러면 보통 이런 객체들을 한데 모아놓고 클라이언트들의 접근을 관리해주는 일종의 저장소가 필요하다.
즉 엔터티를 표현하는 객체를 한 번만 만들고, 객체가 필요한 곳에서는 모두 이 저장소로부터 얻어쓰는 형태로 사용하면 된다.
Before refactoring
function potentialEnergy(mass, height) {
return mass * 9.81 * height;
}
After refactoring
const STANDARD_GRAVITY = 9.81;
function potentialEnergy(mass, height) {
return mass * STANDARD_GRAVITY * height;
}
매직 리터럴 (Magic Literal) 이란 소스 코드에 등장하는 일반적인 리터럴 값을 말한다.
예컨대 움직임을 계산하는 코드에서라면 9.806655 라는 중력을 뜻하는 숫자가 산재해 있다.
이런 숫자는 특별한 의미가 있어서 이런 의미를 알지 못한다면 코드를 읽는 사람은 이해하기 어렵다. 이런 코드를 매직 리터럴 이라고 한다.
이런 매직 리터럴 코드를 보면 코드 자체가 어떤 의므를 나타내는지 분명하게 드러내 주는게 좋다.
리팩토링 기법은 쉽다. 숫자대신 상수를 정의하고 상수를 사용하도록 바꾸면 된다.
상수로 바꾸는 것과 함께 매직 리터럴을 사용하는 또 다른 방법이 있는데 비교 로직이 있다면 고려해볼 수 있는 방법이다.
예를 들어 aValue == "M"
을 aValue == MALE_GENDER
로 바꾸기보다. isMale(aValue)
라는 함수 호출로 만드는 걸 선호한다.
그리고 리터럴이 함수 하나에서만 쓰이고 함수가 충분한 맥락을 제공해주고 있다면 상수를 사용하지 않아도 된다.
조건부 로직은 프로그래밍에서 필요한 존재지만 안타깝게도 프로그램을 복잡하게 만드는 주요 요인이다.
그래서 나는 조건부 로직을 이해하기 쉽게 바꾸는 리팩토링을 자주한다.
복잡한 조건문에는 조건문 분해하기 를 적용하고
논리적 조합을 명확하게 다듬는 데는 중복 조건식 통합하기 를 적용한다.
함수의 핵심 로직에 본격적으로 들어가기 전에 앞서 검사해야 할 때는 중첩 조건문을 보호 구문으로 바꾸기 을 적용하고
Switch 문이나 똑같은 분기문이 등장한다면 조건문 로직을 다형성으로 바꾸기 을 적용한다.
널 (Null) 과 같은 특이 케이스를 처리하는 데도 조건부 로직이 흔히 쓰인다면 특이 케이스 추가하기 를 적용해 코드의 중복을 상당히 줄일 수 있다.
한편 내가 조건문을 없애는 걸 상당히 좋아하지만 프로그램의 상태를 확인하고 그 결과에 따라 다르게 동작해야 하는 상황이라면 어서션 추가하기 를 사용한다.
마지막으로 제어 플래그를 이용해 코드 동작 흐름을 변경하는 코드는 대부분 제어 플래그를 탈출문으로 바꾸기 를 적용해 더 간소화 하는게 가능하다.
Before refactoring
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd))
charge = quantity * plan.summerRate;
else
charge = quantity * plan.regularRate + plan.regularServiceCharge;
After refactoring
if (summer())
charge = summerCharge();
else
charge = regularCharge();
복잡한 조건부 로직은 프로그램을 복잡하게 만드는 가장 흔한 원흉이다.
다양한 조건, 그에 따라 동작을 코드로 작성하면 꽤 긴 함수가 탄생한다.
긴 함수는 그 자체로 읽기가 어렵고 조건문은 이 어려움을 증가시킨다.
조건을 검사하고 그 결과에 따른 동작을 표현하는 코드는 무엇을 하는지는 나타내주지만 '왜' 일어나는지 제대로 설명해주지 않기 때문이다.
이러한 조건부 코드를 리팩터링 할 땐 코드를 부위별로 분해하고 코드 덩어리들을 의도를 살린 함수 호출로 바꿔주자. 그러면 전체적인 의도가 명확해진다.
Before refactoring
if (anEmployee.seniority < 2) return 0;
if (anEmployee.monthsDisabled > 12) return 0;
if (anEmployee.isPartTime) return 0;
After refactoring
if (isNotEligibleForDisability()) return 0;
function isNotEligibleForDisability() {
return ((anEmployee.seniority < 2)
|| (anEmployee.monthsDisabled > 12)
|| (anEmployee.isPartTime));
}
비교하는 조건은 다르지만 그 결과로 수행하는 동작은 똑같은 코드들이 있을 수 있다.
어짜피 동작할 로직이 같다면 조건식을 하나로 통합할 수 있다면 그러는게 낫다.
왜냐하면 하나로 통합하는 과정에서 함수 추출을 통해 코드의 의도를 더욱 명확하게 살릴 수 있기 떄문이다.
복잡한 조건식을 함수로 추출하면 코드의 의도가 훨씬 분명하게 드러나는 경우가 많다.
함수로 추출하기는 무엇을 하는지에 대한 코드를 왜 하는지로 표현할 수 있는 리팩토링 기법이다.
이 기법은 똑같은 동작이고, 여러 조건식들 사이에서 '왜' 검사를 하는지에 대한 목표가 같다면 하나로 묶는게 낫다라는 말이다.
그러므로 독립된 로직이라고 판단이 든다면 이 기법을 사용하지 않는게 낫다.
Before refactoring
function getPayAmount() {
let result;
if (isDead)
result = deadAmount();
else {
if (isSeparated)
result = separatedAmount();
else {
if (isRetired)
result = retiredAmount();
else
result = normalPayAmount();
}
}
return result;
}
After refactoring
function getPayAmount() {
if (isDead) return deadAmount();
if (isSeparated) return separatedAmount();
if (isRetired) return retiredAmount();
return normalPayAmount();
}
조건문이 사용되는 케이스는 흔히 두 개다.
- 참인 경로와 거짓인 경로 모두 정상적인 로직이 필요한 경우.
- 참과 거짓 경로 중 한쪽만 정상적인 로직이 필요한 경우.
중요한 건 정상적인 로직이 명확하게 보이도록 하는게 중요하다.
그래서 나는 참인 경로와 거짓인 경로 모두 정상적인 로직이라면 if - else 절로 확실하게 두 동작을 보여줄려고 한다.
그치만 한 쪽 경로만 정상적인 로직이라고 한다면 빨리 리턴하게 해서 정상적인 로직이 두드러지게 보이도록 한다.
예를 들면 비정상 조건을 if 절에서 검사한 후 조건이 참이면 (비정상이면) 함수에서 빠져나오도록 한다.
이렇게 if 절로 검사하는 형태를 흔히 보호 구문 (guard clause) 라고 한다.
중첩 조건문을 보호 구문을 바꾸면 코드가 명확해진다. (중첩 조건문은 눈에 잘 들어오지 않기 때문이다.)
Before refactoring
switch (bird.type) {
case '유럽 제비':
return "보통이다";
case '아프리카 제비':
return (bird.numberOfCoconuts > 2) ? "지쳤다" : "보통이다";
case '노르웨이 파랑 앵무':
return (bird.voltage > 100) ? "그을렸다" : "예쁘다";
default:
return "알 수 없다";
}
After refactoring
class EuropeanSwallow {
get plumage() {
return "보통이다";
}
...
class AfricanSwallow {
get plumage() {
return (this.numberOfCoconuts > 2) ? "지쳤다" : "보통이다";
}
...
class NorwegianBlueParrot {
get plumage() {
return (this.voltage > 100) ? "그을렸다" : "예쁘다";
}
...
복잡한 조건부 로직은 프로그래밍에서 해석하기 가장 난해하다.
이는 클래스와 다형성을 이용하면 확실하게 분리하는게 가능하다.
흔한 예로 타입을 여러 개 만들고 각 타입이 조건부 로직을 자신만의 방식으로 처리하도록 구성하는 방법이 있다.
각 타입을 기준으로 분기하는 Switch 문이 여러 개 보인다면 case 별로 클래스를 만들어서 Switch 문의 중복을 없앨 수 있다.
또 다른 예로 기본 동작을 위한 case 문과 그 변형 동작으로 구성된 로직을 떠올릴 수 있다.
기본 동작은 가장 일반적이거나 가장 직관적인 동작이다.
기본 동작과 변형 동작이 섞여 있다면 코드가 지저분해질 수 밖에 없다.
그러므로 가장 직관적이고 일반적인 코드는 슈퍼 클래스에 넣고, 기본 동작과 차이를 나타내는 변형 동작들은 서브 클래스에 넣도록 하는 방식이 있다.
Before refactoring
if (aCustomer === "미확인 고객")
customerName = "거주자";
After refactoring
class UnknownCustomer {
get name() {return "거주자";}
}
데이터 구조의 특정 값을 확인한 후 똑같은 동작을 반복하는 코드가 곳곳에 등장한다면 이를 해결해야 한다.
특수한 경우의 공통 동작을 하나에 모아서 사용하는 경우가 특이 케이스 패턴 (Special Case Pattern) 이라고 한다.
즉 이 같은 경우는 반복되는 코드들을 한 곳으로 모아야 한다.
이 패턴을 활용하면 특이 케이스를 확인하는 코드 대부분을 단순 함수 호출로 바꿀 수 있다.
특이 케이스를 사용할 때 단순히 데이터만 읽으면 된다면 리터럴 값만 준비하면 되고 그 이상의 어떠한 동작이 필요하다면 필요한 메소드를 담은 객체를 만들면 된다.
Before refactoring
if (this.discountRate)
base = base - (this.discountRate * base);
After refactoring
assert(this.discountRate >= 0);
if (this.discountRate)
base = base - (this.discountRate * base);
특정한 조건 내에서만 동작하는 코드가 있을 수 있다.
예로 제곱근 계산은 입력이 양수 일때만 동작할 수 있다.
객체로 접근을 하면 필드 값을 기반으로 동작을 할 수도 있다.
이런 조건이 코드에 명시적으로 드러나면 좋겠지만 그렇지 않은 경우도 있다. 알고리즘을 보고 연역적으로 알아내야 하는 경우도 있다.
주석으로라도 표현이 되어있으면 좋고 가장 좋은 방법은 assertion 을 이용해서 코드 자체에 삽입해놓는 것이 가장 좋다.
어서션의 본질적인 목적은 프로그램이 어떠한 상태여야 하는지 다른 개발자들에게 설명해주는 도구이다.
Before refactoring
for (const p of people) {
if (!found) {
if (p === "조커") {
sendAlert();
fount = true;
}
}
After refactoring
for (const p of people) {
if (p === "조커") {
sendAlert();
break;
}
}
제어 플래그는 코드의 동작을 바꾼다.
조건절에서는 제어 플래그를 통해 검사하고, 어떤 곳에서는 계산을 통해 제어 플래그의 값을 바꾸는 구조로 되어있다.
제어 플래그를 사용하면 코드를 이해하기 어려워지므로 이를 리팩토링해서 쉽게 바꾸자.
제어 플래그의 주 서식지는 반복문으로, 제어 플래그 대신 break 문이나 continue 문을 적절히 이용하도록 하고 함수에서 사용하는 제어 플래스는 return 문을 잘 쓰도록 해보자.
모듈과 함수는 소프트웨어를 구성하는 블록이고 API 는 이 블록들을 서로 연결시켜주는 요소다.
그러므로 API 를 이해하기 쉽고 사용하기 쉽게 만드는 일은 중요하다.
좋은 API 는 데이터를 갱신하는 함수와 그저 조회하는 함수를 명확히 구별한다.
두 기능이 섞여 있다면 질의 함수와 변경 함수 분리하기 를 적용하자.
값 하나 때문에 여러 개로 나뉜 함수들은 함수 매개변수화하기 를 적용해 하나로 합칠 수 있다.
한편 어떤 매개변수는 그저 함수의 동작 모드를 전환하는 용도로 쓰이는데 이 매개변수는 제거하자. 플래그 인수 제거하기 를 적용하면 좋다.
데이터 구조가 함수 사이를 건너다니면서 분해되서 사용되는 경우는 객체 통째로 넘기기 를 적용해 하나로 유지하면 깔끔하다.
함수에 매개변수를 건넬지 아니면 피호출 함수가 스스로 매개변수를 구할지 에 대한 진리는 없다. 그러므로 상황을 보면서 매개변수를 질의 함수로 바꾸기 와 질의 함수를 매개변수로 바꾸기 를 적절하게 사용하자.
클래스는 모듈이고 모듈이 불변으로 바뀌길 원한다면 기회가 될때 세터 제거하기 를 적용하자.
한편 호출자에 새로운 객체를 만들어 반환하려 할 때 일반적인 생성자의 능력만으로 부족하다면 생성자를 팩토리 함수로 바꾸기 를 적용하자.
마지막 두 리팩토링은 수 많은 데이터를 받는 복잡한 함수를 잘게 쪼개는 문제를 다룬다.
함수를 명령으로 바꾸기 를 적용하면 이런 함수를 객체로 변환할 수 있는데 그러면 해당 함수 본문에서 함수 추출하기를 적용하기 편해진다.
나중에 이 함수를 단순화 해서 명령 객체가 더 이상 필요 없어진다면 명령을 함수로 바꾸기 를 적용해 함수로 되돌리자.
Before refactoring
function getTotalOutstandingAndSendBill() {
const result = customer.invoices.reduce((total, each) => each.amount + total, 0);
sendBill();
return result;
}
After refactoring
function totalOutstanding() {
return customer.invoices.reduce((total, each) => each.amount + total, 0);
}
function sendBill() {
emailGateway.send(formatBill(customer));
}
질의 함수와 변경 함수를 분리한다면, 질의 함수가 어떠한 사이드 이펙트를 내지 않고 결과를 내주기만 한다면, 문제를 일으킬 걱정을 하지 않아도 된다.
그러므로 질의 함수는 부수 효과가 없어야 한다.
나는 값을 반화하면서 부수효과가 있는 함수를 발견하면 이를 분리시키려고 한다.
Before refactoring
function tenPercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.1);
}
function fivePercentRaise(aPerson) {
aPerson.salary = aPerson.salary.multiply(1.05);
}
After refactoring
function raise(aPerson, factor) {
aPerson.salary = aPerson.salary.multiply(1 + factor);
}
두 함수의 로직이 유사하고 리터럴 값만 다르다면 그 리터럴 값만 매개변수로 해서 코드의 중복을 제거할 수 있다.
이렇게하면 매개변수의 값만 바꿔서 여러 곳에 적용할 수 있으니 더욱 유용하다.
Before refactoring
function setDimension(name, value) {
if (name === "height") {
this._height = value;
return;
}
if (name === "width") {
this._width = value;
return;
}
}
After refactoring
function setHeight(value) {this._height = value;}
function setWidth(value) {this._width = value;}
플래그 인수란 (Flag Argument) 호출하는 함수가 호출되는 함수의 로직을 결정하기 위해 전달하는 매개변수다.
나는 플래그 인수를 사용하는 함수를 극도로 싫어하는데 함수 호출자의 입장에서 이 매개변수에 어떤 값을 전달해야하는지 이해하기 어렵기 때문이다.
즉 함수를 이해하기 어렵다. 그러므로 플래그 인수를 사용하지 말자.
Before refactoring
const low = aRoom.daysTempRange.low;
const high = aRoom.daysTempRange.high;
if (aPlan.withinRange(low, high))
After refactoring
if (aPlan.withinRange(aRoom.daysTempRange))
하나의 객체에서 값 두어개를 가져와서 인수로 넘기는 코드를 보면 그냥 객체를 통째로 넘기는게 낫지 않은가 라는 고민을 한다.
객체 자체를 통째로 넘기면 대응하기 쉽다.
객체에서 값을 뽑아서 다른 곳을 던진다는 것 자체가 의미없는 코드를 양산하는 것과 같다. 이는 코드의 명확성을 떨어뜨린다.
그리고 객체를 통째로 넘기면 매개변수 개수도 줄어들므로 함수를 이해하기가 더 쉽기도하다.
하지만 함수가 객체 자체에 의존하면 안되는 경우라면, 즉 서로 다른 모듈에 있는 관계라면 이 리팩토링의 기법을 사용하지 않는다.
한편 객체의 일부를 기반으로 같은 동작을 반복하는 코드가 있다면 그 일부가 클래스가 되어야 한다는 뜻이기도 하다. 그래서 따로 묶어서 클래스 추출하기 를 적용하자.
마지막으로 많은 사람이 놓치는 예제가 있는데 객체가 다른 객체의 메소드를 호출하는 과정에서 자신이 가지고 있는 메소드 여러개를 전달하는 경우라면, 그 객체 자체를 넘기는 경우, 즉 this 를 통해 넘기는 경우가 더 나을 수 있다. 이런 기법도 있다.
Before refactoring
availableVacation(anEmployee, anEmployee.grade);
function availableVacation(anEmployee, grade) {
// 연휴 계산...
After refactoring
availableVacation(anEmployee)
function availableVacation(anEmployee) {
const grade = anEmployee.grade;
// 연휴 계산...
매개변수 목록은 함수의 동작에 영향을 줄 수 있는 요소들이다.
매개변수의 목록은 중복이 없는게 좋고 짧을수록 좋다.
피호출 함수가 스스로 매개변수의 값을 알고있고 구할 수 있는 경우라면 매개변수는 없는게 더 코드를 이해하기가 쉽다.
이 경우에 해당 매개변수는 의미없는 코드일 뿐이기 때문이다.
매개변수가 있다면 호출자가 이런 의존성을 연결시켜주는 작업을 해야하고 매개변수가 없다면 피호출자가 주체가 되서 의존성 문제를 해결해야한다.
나는 습관적으로 피호출자가 주체가 되도록 하는데 그러면 함수의 사용이 훨씬 쉬워지기 때문이다.
피호출자가 주체가 될 때 고려해야하는 사항은 피호출자 함수에 의도치 않은 의존성이 생기는지의 여부다.
피호출자가 쉽게, 또는 다른 매개변수를 통해서 알 수 있는 경우라면 의존성이 생기지 않겠지만 다른 모듈에 있는 객체를 매개변수로 받지않고 스스로 알아낼려고 하면 불필요한 의존성이 생긴다.
즉 해당 함수가 알면 안되는 변수는 매개변수를 삭제하면 안된다.
그리고 주의사항으로 매개변수를 없애는 대신 전역변수 같은 걸 이용할려고는 하지말자. 함수는 참조 투명 (referential transparency) 해야한다.
함수에 똑같은 매개변수의 전달은 같은 결과를 반영해야 한다. 이를 기억하자.
Before refactoring
targetTemperature(aPlan)
function targetTemperature(aPlan) {
currentTemperature = thermostat.currentTemperature;
After refactoring
targetTemperature(aPlan, thermostat.currentTemperature)
function targetTemperature(aPlan, currentTemperature) {
코드를 읽다 보면 함수 안에 있기에는 적합하지 않은 참조들이 있다.
이 경우에는 해당 참조를 매개변수로 바꿈으로써 해결할 수 있다.
이로인해 함수를 호출하는 호출자에게 책임을 옮겨진다.
함수가 특정 대상에 대한 의존을 가지지 않기 위해 매개변수로 바꾸면 호출하는 쪽이 해당 의존성을 가지게 된다.
즉 어느 쪽에 의존성을 둘 것인지에 대한 문제로 여기에는 정답은 없다.
따라서 프로그램을 더 잘 이해하게 되서 적합한 쪽으로 의존성을 옮기면 된다.
이렇게 언제든지 리팩토링이 일어날 수 있게 기존의 코드를 바꾸기 쉽게 설계해두는 것이 중요하다.
그리고 함수를 설계할 땐 참조 투명성이 중요하다. 왜냐하면 참조 투명성이 있는 함수는 똑같은 매개변수에선 똑같은 동작을 하기 때문에 예측하기 쉽기 때문이다.
함수가 어떤 대상의 값에 의존하고 있고 이 값이 바뀔 여지가 많다면 참조 투명성을 가지는 함수로, 매개변수로 전달받도록 하면 함수를 다루기 쉽다.
Before refactoring
class Person {
get name() {...}
set name(aString) {...}
}
After refactoring
class Person {
get name() {...}
}
세터 메소드가 있는 것은 객체가 변경될 여지가 있다는 의미를 나타낸다.
객체 생성 후에 변경되지 않을 것이라고 설계한 불변 객체라면 세터 메소드를 없애도록 하는게 맞다.
즉 수정하지 않겠다 라는 의도를 드러내는 것이다.
Before refactoring
leadEngineer = new Employee(document.leadEngineer, 'E');
After refactoring
leadEngineer = createEngineer(document.leadEngineer);
많은 객체 지향 언어에서는 생성자를 사용한다.
생성자는 객체를 초기화하는 용도로 사용하지만 기능 제공에서 한계를 제공하기도 한다.
가령 생성자는 그 객체의 인스턴스를 반환한다는 점. 서브 클래스나 프록시를 반환하지는 못한다는 점이 있고
생성자 메소드의 이름보다 더 적절한 이름이 있다고 판단되더라도 그 이름을 사용할 수 없다.
팩토리 함수는 이런 제약이 없다. 팩토리 함수에선 생성자를 써도되고 다른 함수로 대체해도 되기 때문이다.
Before refactoring
function score(candidate, medicalExam, scoringGuide) {
let result = 0;
let healthLevel = 0;
...
}
After refactoring
class Scorer {
constructor(candidate, medicalExam, scoringGuide) {
this._candidate = candidate;
this._medicalExam = medicalExam;
this._scoringGuide = scoringGuide;
}
execute() {
this._result = 0;
this._healthLevel = 0;
...
}
}
함수는 프로그래밍의 가장 기본적인 빌딩 요소다.
그런데 함수는 그 함수만을 위한 객체로 캡슐화 되면 종종 유용해지는 상황이 생긴다.
이런 객체를 가리켜서 명령 객체, 함수를 명령 함수라고 한다. (디자인 패턴의 Command Pattern 과도 같으며 명령이란 말은 객체의 상태를 변경하는 메소드다.)
명령 객체 대부분은 메소드 하나로 구성되고 이 메소드를 요청해서 실행하는 것이 이 객체의 목적이다.
명령은 평범한 함수 매커니즘 보다 훨씬 유연함을 제공해줄 수 있다.
가령 Undo 연산을 제공해줄 수 있다던지. 라이프 사이클을 좀 더 세밀하게 제어가 가능하다라는 점.
그리고 메소드와 필드를 이용해서 복잡한 함수를 쪼갤 수 있다 라는 점 들을 제공해줄 수 있다.
Before refactoring
class ChargeCalculator {
constructor(customer, usage) {
this._customer = customer;
this._usage = usage;
}
execute() {
return this._customer.rate * this._usage;
}
}
After refactoring
function charge(customer, usage) {
return customer.rate * usage;
}
명령 객체의 장점은 복잡한 연산을 수행하도록 객체 안에서 캡슐화를 할 수 있다는 점이다.
복잡한 함수 자체가 객체가 가진 필드들로 인해서 쪼개질 수 있다라는 점이 가장 크다.
하지만 함수 자체가 복잡하지 않다라고 한다면 명령 객체를 사용한다는 점 자체가 더 복잡할 수 있다.
Before refactoring
let totalAscent = 0;
calculateAscent();
function calculateAscent() {
for (let i = 1; i < points.length; i++) {
const verticalChange = points[i].elevation - points[i-1].elevation;
totalAscent += (verticalChange > 0) ? verticalChange : 0;
}
}
After refactoring
const totalAscent = calculateAscent();
function calculateAscent() {
let result = 0;
for (let i = 1; i < points.length; i++) {
const verticalChange = points[i].elevation - points[i-1].elevation;
result += (verticalChange > 0) ? verticalChange : 0;
}
return result;
}
데이터가 어떻게 업데이트 되는지 코드에서 추적하는 일은 어렵다.
그것을 도와주는 방법 중 하나로 함수가 객체의 한 상태만을 변경 시킨다면, 그 값을 리턴 하도록 해서, 어떻게 수정되는지 추적하기 쉽게 하는 방법이 있다.
이 리팩토링의 방법은 값 여러 개를 변경시키는 경우에는 효과가 없다. 하지 말자.
Before refactoring
if (data)
return new ShippingRules(data);
else
return -23;
After refactoring
if (data)
return new ShippingRules(data);
else
throw new OrderProcessingError(-23);
오류 코드를 사용한다면 오류 코드를 일일히 검사해서 처리해줘야 한다.
하지만 예외는, 예외를 던지면 적절한 예외 핸들러를 찾을 때까지 콜스택을 타고 위로 전파된다.
그래서 프로그램에서 적절한 지점에서 예외를 처리하도록 해놨다면 프로그램은 예외가 생기는 것에 대해서 신경쓰지 않아도 된다.
다만 예외 처리는 정교해야한다. 즉 자신이 처리할 수 있는 예외라면 처리하고 그렇지 않다면 다시 던지는 구조로 하나도 빼먹으면 안된다.
예외를 사용할 때 주의할 점은 예외가 나서 프로그램이 종료되더라도 이후 프로그램이 정상적으로 동작할 것인지에 대한 물음을 해보면 된다.
그렇다면 예외를 사용해서 처리하면 되고 그렇지 않다면 오류를 잡아서 검출해야 한다는 뜻이다.
Before refactoring
double getValueForPeriod(int periodNumber) {
try {
return values[periodNumber];
} catch (ArrayIndexOutOfBoundsException e) {
return 0;
}
}
After refactoring
double getValueForPeriod(int periodNumber) {
return (periodNumber >= vaules.length) ? 0 : values[periodNumber];
}
예외를 던지기 전에 호출자가 예외가 일어날 상황을 미리 감지하고 사전에 대처할 수 있다면 그렇게 하는게 좀 더 직관적일 것이다.
즉 예외를 던지고 뒤늦게 catch 문에서 수습하는 코드보다 사전에 조건으로 적절한지 확인하고 그렇지 않다면 대비하는 코드를 작성하자.
이번 장에서는 객체 지향 프로그래밍에서 가장 유명한 상속을 다루겠다.
특정 기능을 상속 계층 구조에서 위나 아래로 옮기는 일은 꽤나 흔하다.
이와 관련된 리팩토링 기법으로는 메소드 올리기 와 필드 올리기, 생성자 본문 올리기, 메소드 내리기, 필드 내리기 가 있다.
계층 사이에 클래스를 추가하거나 제거하는 리팩토링 기법으로는 슈퍼 클래스 추출하기, 서브 클래스 제거하기 , 계층 합치기 가 있다.
때론 필드 값에 따라서 동작이 달라지는 클래스가 있는데 이런 필드를 서브 클래스로 대체할 수 있다. 이게 더 나은 것 같다면 적용하는 기법으로는 타입 코드를 서브 클래스로 바꾸기 를 이용한다.
상속은 아주 막강한 도구지만 잘못된 곳에서 사용되는 경우에는 문제가 생길 수 있다. 이런 경우에 사용하는 리팩토링 기법으로는 서브 클래스를 위임으로 바꾸기 와 슈퍼 클래스를 위임으로 바꾸기 를 활용할 수 있다.
이를 통해 상속을 위임으로 바꿀 수 있다
Before refactoring
class Employee {...}
class Salesperson extends Employee {
get name() {...}
}
class Engineer extends Employee {
get name() {...}
}
After refactoring
class Employee {
get name() {...}
}
class Salesperson extends Employee {...}
class Engineer extends Employee {...}
중복 코드 제거는 중요하다.
한쪽의 변경이 다른 쪽에는 업데이트 되지 않을 수도 있는, 즉 중복을 놓치는 문제가 생기기 쉽기 때문이다.
메소드 올리기를 적용하는 가장 쉬운 예는 클래스 계층에서 메소드들의 본문 코드가 똑같을 경우다.
이럴 땐 그냥 복사해서 슈퍼 클래스에 붙여 넣기만 하면 끝이다.
메소드 올리기를 적용하기에 복잡한 상황은 해당 메소드에서 참조하는 필드들이 서브 클래스에만 있는 경우다.
이런 경우에는 필드를 먼저 슈퍼 클래스로 옮기는 필드 올리기 를 먼저 적용하고 메소드에 올려야 한다.
마지막으로 두 메소드의 전체 흐름은 비슷하지만 세부 내용이 다르다면 디자인 패턴인 Template Method Pattern 을 고려하자.
Before refactoring
class Employee {...}
class Salesperson extends Employee {
private String name;
}
class Engineer extends Employee {
private String name;
}
After refactoring
class Employee {
protected String name;
}
class Salesperson extends Employee {...}
class Engineer extends Employee {...}
서브 클래스들이 독립적으로 시간을 두고 개발되었거나, 뒤늦게 클래스들이 하나의 계층 구조로 통합되어 있는 경우라면 필드가 중복되는 경우가 생길 수 있다.
항상 그런 것은 아니기 때문에 필드가 어떻게 사용되고 있는지 분석해야 한다.
분석 결과 필드들이 비슷한 방식으로 쓰인다고 판단되면 슈퍼 클래스로 올리자.
이렇게 하면 얻을 수 있는 것으로 데이터 선언 중복을 제거하는게 가능하고 그 선언을 바탕으로 하는 동작을 슈퍼 클래스로 올겨서 중복을 제거할 수 있다.
Before refactoring
class Party { }
class Employee extends Party {
constructor(name, id, monthlyCost) {
super();
this._id = id;
this._name = name;
this._monthlyCost = monthlyCost;
}
}
class Department extends Party {
constructor(name, staff) {
super();
this._name = name;
this._staff = staff;
}
}
After refactoring
class Party {
constructor(name) {
this._name = name;
}
}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super(name);
this._id = id;
this._monthlyCost = monthlyCost;
}
}
class Departement extends Party {
constructor(name, staff) {
super(name);
this._staff = staff;
}
생성자는 일반 메소드와 다르다.
생성자는 할 수 있는 일과 호출 순서에 제약이 있기 때문이다.
물론 간단한 필드를 올리는 거라면 생성자를 올리기 쉽다.
하지만 이제 호출 순서가 정해져서 꼬이는 경우가 있다.
예로 들면 일반적으로 super() 를 호출해서 공통 작업을 먼저 처리해주는데 아직 서브 클래스의 필드가 할당되지 않아서 초기화 로직을 실행하지 못하는 경우가 있다.
이런 경우는 공통 코드를 함수로 추출해놓고 슈퍼 클래스로 올려놓으면 된다. 그리고 그 함수를 호출하는 식으로.
Before refactoring
class Employee {
get quota {...}
}
class Engineer extends Employee {...}
class Salesperson extends Employee {...}
After refactoring
class Employee {...}
class Engineer extends Employee {...}
class Salesperson extends Employee {
get quota {...}
}
특정 서브 클래스 하나 혹은 소수와만 관련된 메소드는 슈퍼 클래스에서 제거하고 서브 클래스에 두는 것이 더 깔끔하다. (SOLID 의 LSP 원칙이라고 생각하면 된다.)
다만 이 리팩토링은 슈퍼 클래스에서 제공하는 기능이 특정 서브 클래스에서 사용하는게 명확할 때 사용해야 한다.
만약에 각 서브 클래스마다 다르게 동작해야 하는 부분이 있다면 이 기능의 많은 로직보다 다형성으로 바꾸는게 나을 경우가 있기 때문에.
Before refactoring
class Employee {
private String quota;
}
class Engineer extends Employee {...}
class Salesperson extends Employee {...}
After refactoring
class Employee {...}
class Engineer extends Employee {...}
class Salesperson extends Employee {
protected String quota;
}
서브 클래스 하나 혹은 소수에서만 사용하는 필드는 해당 서브 클래스로 옮기는게 낫다.
Before refactoring
function createEmployee(name, type) {
return new Employee(name, type);
}
After refactoring
function createEmployee(name, type) {
switch (type) {
case "engineer": return new Enginner(name);
case "salesperson": return new Salesperson(name);
case "manager": return new Manager(name);
}
}
소프트웨어 시스템에서는 비슷한 대상을 특정 특성에 따라 구분해야 할 때가 있다.
예로 직원을 담당 업무로 구분하거나 (엔지니어, 관리자, 영업자 등), 주문을 시급성으로 구분 하거나 (급함, 보통) 등
이런 일을 다루는 수단으로 타입 코드를 프로그래밍에서 자주 사용한다.
타입 코드만으로는 불편한 상황은 없지만 그 이상으로 무언가 필요할 때가 있다.
특성에 따라서 다르게 동작하도록 하거나, 특정 타입에 따라서 다른 동작이나 데이터가 필요하거나 할 때.
이런 방식은 서브 클래스를 사용하면 해결해줄 수 있다.
Before refactoring
class Person {
get genderCode() {return "X";}
}
class Male extends Person {
get genderCode() {return "M";}
}
class Female extends Person {
get genderCode() {return "F";}
}
After refactoring
class Person {
get genderCode() {return this._genderCode;}
}
서브 클래스는 원래 데이터 구조와는 다른 변종을 만들어서 동작을 달라지게 하는 유용한 수단이다.
하지만 소프트웨어가 커지면서 변종이 다른 모듈로 이동하거나 사라지기도 하면서 한 번도 활용되지 않기도 한다.
더 이상 쓰이지 않는 서브 클래스는 그냥 슈퍼 클래스가 대체하는 게 최선이다.
Before refactoring
class Department {
get totalAnnualCost() {...}
get name() {...}
get headCount() {...}
}
class Employee {
get annualCost() {...}
get name() {...}
get id() {...}
}
After refactoring
class Party {
get name() {...}
get annualCost() {...}
}
class Department extends Party {
get annualCost() {...}
get headCount() {...}
}
class Employee extends Party {
get annualCost() {...}
get id() {...}
}
비슷한 일을 수핼하는 두 클래스가 있다면 공통 부분을 슈퍼 클래스에 넣을 수 있다.
데이터라면 필드를, 동작이라면 메소드를 슈퍼 클래스에 올리면 된다.
객체 지향을 설명할 때 상속 구조는 현실 세계에서 활용하는 분류 체계에 기초해서 이용을 해야 한다고 말한다.
하지만 내 경험에 비추어보면 상속은 프로그램이 성장하게 되면서 깨우치게 되고 슈퍼 클래스로 끌어올리고 싶은 공통 요소를 찾았을 때 수행하게 되는 경우가 많다.
슈퍼 클래스 추출하기의 대안으로 클래스 추출하기 가 있다.
즉 상속으로 해결할 수도, 위임으로 해결할 수도 있다.
슈퍼 클래스 추출이 더 쉬우므로 이것을 하는걸 먼저 권장한다. 나중에라도 위임이 더 나은 구조라고 판단되면 언제든지 슈퍼 클래스를 위임으로 바꾸기 를 적용할 수 있다.
Before refactoring
class Employee {...}
class Salesperson extends Employee {...}
After refactoring
class Employee {...}
클래스 구조를 리팩토링 하다 보면 기능들을 위로 올리거나 아래로 내리는 일은 다반사다.
예컨대 계층구조도 진화하면서 어떤 클래스는 부모와 너무 비슷해져서 독립적으로 존재해야 할 이유가 사라지기도한다.
그때 바로 그들을 합치면 된다.
Before refactoring
class Order {
get daysToShip() {
return this._warehouse.daysToShip;
}
}
class PriorityOrder extends Order {
get daysToShip() {
return this._priorityPlan.daysToShip;
}
}
After refactoring
class Order {
get daysToShip() {
return (this._priorityDelegate) ? this._priorityDelegate.daysToShip : this._warehouse.daysToShip;
}
}
class PriorityOrderDelegate {
get daysToShip() {
return this._priorityPlan.daysToShip;
}
}
특성에 따라 동작이 달라지는 객체들은 상속으로 표현하는게 자연스럽다.
공통 데이터와 동작은 모두 슈퍼 클래스에 두고 서브 클래스는 자신에 맞게 기능을 추가하거나 오버라이드 하면 된다.
객체 지향에서 이러한 매키니즘은 자연스럽다.
하지만 상속은 단점이 있다.
가장 명확한 단점은 한 번만 쓸 수 있는 카드라는 것이다.
무언가가 달라져야 하는 이유가 여러 개여도 상속은 한 이뮤만 잡을 수 밖에 없다.
예로 사람 객체의 동작을 나이대와 소득 수준에 따라 달라지게 하고 싶다면 서브 클래스는 젊은이와 어르신이 되거나 혹은 부자와 서민이 되어야 한다.
둘 다는 안된다.
또 다른 문제로 상속은 클래스들의 관계를 아주 긴밀하게 결합한다.
부모를 바꾸면 자식들의 기능을 해치기가 쉽기 때문에 주의해야 한다.
그래서 자식들이 슈퍼 클래스를 어떻게 상속해 쓰는지를 이해해야 한다.
부모와 자식이 서로 다른 모듈에 속하거나 다른 팀에서 구현한다면 문제는 더욱 커진다.
위임 (Delegate) 는 위의 두 문제를 모두 해결해준다.
다양한 클래스에 서로 다른 이유로 위임할 수 있다.
위임은 상속보다 결합도가 낮다.
즉 상속은 위임보다 결합도가 높다. 자식 클래스는 부모 클래스를 이해하고 설계하기 떄문에 부모 클래스의 변경은 자식 클래스에게 많은 변화를 줄 수 있기 때문이다.
유명한 원칙이 있다.
"상속 보다는 컴포지션을 사용하라!"
여기서 컴포지션이 위임을 말하는 것이다.
이 말은 상속은 위험하다고 상속을 사용하지 말라고도 하는데 나는 상속을 자주 사용한다.
이렇게 하는 배경에는 나중에라도 필요하면 언제든 서브 클래스를 위임으로 바꿀 수 있기 때문이다.
그래서 처음에는 상속으로 접근한 다음, 문제가 생기면 위임으로 갈아탄다.
실제로 이 원칙을 주장한 디자인 패턴 책은 상속과 컴포지션을 함께 사용하는 방법을 설명해준다. (여기서는 상속의 과용을 설명해준 것이다.)
디자인 패턴에 익숙한 사람이라면 이 패턴을 State Pattern 이나 Strategy Pattern 이라고 생각해도 좋다.
구조적으로 보면 두 패턴은 위임 방식으로 계층 구조를 분리해준다.
Before refactoring
class List {...}
class Stack extends List {...}
After refactoring
class Stack {
constructor() {
this._storage = new List();
}
}
class List {...}
객체 지향 프로그래밍에서 상속은 기존 기능을 재활용하는 강력한 수단이다.
기존 클래스를 상속해 입맛에 맞게 오버라이드 하거나 새 기능을 추가하면 된다.
하지만 상속이 혼란과 복잡도를 키우는 방식으로 이뤄지기도 한다.
자바의 스택 클래스가 그 예다.
자바의 스택은 리스트를 상속하고 있다.
그 이유로 데이터를 저장하고 조회하는 리스트의 기능을 재활용하겠다는 생각이 초래한 결과다.
재활용 관점에서는 좋았지만 이 상속에는 문제가 있다.
리스트의 연산 중 스택에는 적용되지 않는게 많은데도 그 모든 연산이 스택 인터페이스에 그대로 노출되어 있다.
이 보다는 스택에 리스트 객체를 필드에 두고 필요한 기능만 위임하는 식으로 했다면 더 나을 것이다.
자바의 스택이 슈퍼 클래스를 위임으로 바꾸는 이번 리팩터링을 적용할 좋은 예다.
슈퍼 클래스의 기능들이 서브 클래스에 어울리지 않는다면 그 기능들을 상속을 통해 이용하면 안된다는 신호다.
제대로 된 상속이라면 서브 클래스가 슈퍼 클래스의 모든 기능을 사용해야 하고, 서브 클래스의 인스턴스를 슈퍼 클래스의 인스턴스로도 취급할 수 있어야한다.
이외에 서브 클래스 방식 모델링이 합리적일 때도 슈퍼 클래스를 위임으로 바꾸기도 한다.
이는 슈퍼 / 서브 클래스가 아주 강하게 결합되어 있어서 슈퍼 클래스를 수정하면 서브 클래스가 망가지기 쉬울 경우를 말한다.