코드의 구린내
구린내라는 표현은 캔트 벡이 마틴 파울러에게 제안한 아이디어이다. 어딘가 구리다, 별로다 라는 표현을 '구린내'라고 빗대어 표현한 것이다. 리팩토링이 필요한 코드를 코드의 구린내가 난다고 표현한다.
중복 코드 (Duplicated Code)
중복 코드는 코드의 구린내 중 하나이다. 똑같은 코드 구조가 두 군에 이상에서 중복으로 사용될 때, 이를 하나로 통일하면 프로그램이 개선된다.
중복 코드의 예로 한 클래스의 두 개의 메서드에 동일한 코드 구조가 들어가 있는 경우이다. 이럴 때는 메서드 추출 기법을 사용해서 중복되는 코드를 빼내어 별도의 메서드로 구성하는 것이 좋다.
public void methodA() {
methodC();
}
public void methodB() {
methodC();
}
public void methodC() {
// 로직 구현
}
다른 예로는 두 하위 클래스에 같은 코드가 있는 경우이다. 이 때는 두 하위 클래스에 정의된 메서드 위치를 상위 클래스로 옮기는 메서드 상향 기법을 사용하면 된다.
서로 상관 없는 두 클래스에서 중복 코드가 있을 때는 중복 코드를 제 3의 클래스 또는 모듈로 떼어 낸 후, 그것을 다른 클래스에 호출하도록 하는 방법이 있다.
코드 구조가 동일한 중복 코드를 존재하지 않도록 개발해야 한다. 요구사항이 바뀌었을 때마다 코드를 수정할 때, 중복 코드가 존재하면 버그를 발생시킬 가능성이 높아지기 때문이다.
장황한 메서드 (Long Method)
메서드 이름은 해당 메서드의 기능을 대표할 수 있다. 따라서 메서드 이름을 통해 기능을 파악할 수 있다면, 코드 분석에 소요되는 시간을 줄일 수 있다. 되도록이면 메서드 길이는 짧아야 하며, 기능별로 과감하게 쪼개야 한다.
메서드 명은 기능 수행 방식이 아니라 목적(기능 자체)을 나타내는 이름으로 작성해야 한다. 메서드 이름이 길더라도 코드의 의도를 잘 반영하도록 정해야 한다.
메서드 길이를 줄이려면 대게 메서드 추출 기법을 사용한다. 메서드의 매개변수와 임시변수가 많아지면, 기능을 추출하는 것이 까다로워진다. 따라서 메서드에서 매개변수와 임시변수를 줄여야 한다.
메서드 매개변수 개수가 많아지면 이를 객체로 전환하는 기법과 객체를 통째로 전달하는 기법을 사용하면 간결해진다.
public void methodA(int a, int b, int c, int d) {
// 생략
}
class CustomObject {
int a;
int b;
int c;
int d;
}
public void methodA(CustomObject customObj) {
// 생략
}
임시변수는 메서드 호출로 전환하거나 임시변수를 메서드 체인으로 전환하는 기법을 사용하면 임시변수를 제거한다.
int totalResult = 0;
// 임시변수 사용
for (CustomObject customObj : customObjects) {
int tempResult = customObj.getResult();
if (customObj.getCode() == CustomCode.Event) {
totalResult += (tempResult * 0.7);
} else {
totalResult += (tempResult * 1.2);
}
}
// 임시변수 사용하지 않음
for (CustomObject customObj : customObjects) {
if (customObj.getCode() == CustomCode.Event) {
totalResult += (customObj.getResult() * 0.7);
} else {
totalResult += (customObj.getResult() * 1.2);
}
}
방대한 클래스 (Large Class)
기능이 많은 클래스에는 인스턴스 변수가 많이 들어 있다. 인스턴스 변수가 많으면 중복 코드가 반드시 존재한다.
클래스 추출을 적용하면 수많은 인스턴스 변수를 하나로 묶을 수 있다. 하위 클래스로 추출하는 것이 적합하면 하위 클래스로 추출을 하는 것이 더 간단하다. 추출할 클래스가 대리자로 부적절하면 모듈 추출을 적용하면 된다.
인스턴스 변수가 많은 것처럼 클래스의 코드 분량이 방대하면 중복 코드가 있을 가능성이 높아진다. 이를 해결하는 방법은 클래스 자체에서 중복 코드를 제거하고 하나의 공통 코드를 호출하도록 변경해야 한다. 또한, 클래스 추출, 모듈 추출, 하위 클래스 추출 중 하나를 사용해서 클래스를 쪼갤 수 있다.
과다한 매개변수 (Long Parameter LIst)
메서드에 필요한 모든 데이터를 전달하는 것보다 모든 데이터를 가져올 수 있는 객체를 전달하는 것이 효과적이다. 객체를 통해서 필요한 데이터를 가져오면 된다. 만약 여러 데이터 항목에 논리적인 객체가 없다면 매개변수 세트를 객체로 전환하는 기법을 사용하면 된다.
수정의 산발 (Divergent Change)
소프트웨어는 요구사항에 따라 달라지고 수정하기 쉽게 구성해야 한다. 수정의 산발은 한 클래스가 다양한 원인 때문에 다양한 방식으로 자주 수정될 때 일어난다.
특정 클래스에 기능을 추가할 때마다 여러 개의 메서드를 수정해야 한다면, 그 클래스를 여러 개의 변형 객체로 분리하는 것이 좋다. 그러면 각 객체는 한 종류의 수정에 의해서만 변경된다. 이러한 사실은 처음 클래스를 설계할 때 발견되지 않고, 여러 기능을 추가하면서 나중에 발견하게 된다. 특정 변경 사항이 있을 때는 하나의 클래스나 모듈만 변경해야 하며, 새 클래스나 모듈에는 변경사항을 표시해야 한다.
기능의 산재 (Shotgun Surgery)
기능의 산재는 수정의 산발과 비슷하지만 정 반대다. 수정할 때마다 여러 클래스에서 자잘한 부분을 모두 고쳐야 한다면 이 문제를 의심할 수 있다. 기능들이 여러 클래스에 산재되어 있다면 수정할 때 깜박하고 놓치기 쉬워진다.
이러한 경우에는 메서드 이동과 필드 이동을 사용해서 수정한 부분을 하나의 클래스 안에 넣어야 한다. 기존의 클래스에 넣기가 어려울 때는 새로운 클래스를 만들어야 할 수도 있다.
수정의 산발은 한 클래스에 여러 수정이 발생하는 문제이고, 기능의 산재는 하나의 수정으로 여러 클래스가 바뀌게 되는 문제이다.
잘못된 소속 (Feature Envy)
객체의 핵심은 데이터와 데이터에 사용되는 프로세스를 한 데 묶는 기술이라는 점이다. 어떤 메서드가 자신이 속하지 않는 클래스에 더 많이 접근한다면 이는 잘못된 소속이다.
소속이 잘못된 메서드는 더 많이 접근하는 클래스로 이동하는 것이 마땅하다. 메서드 이동을 해서 더 자주 접근하는 클래스로 옮겨야 한다. 메서드 중 일부분만 소속이 잘못된 경우에는 그 부분을 메서드 추출해서 메서드를 이동시켜야 한다.
데이터 뭉치 (Data Clumps)
동일한 여러 개의 데이터 항목들이 여러 클래스에 위치해 있는 경우가 있다. 이렇게 몰려 다니는 데이터 뭉치는 객체로 만들어야 한다.
강박적 기본 타입 사용 (Primitive Obsession)
데이터를 사용할 때 기본 타입 데이터만 사용하지말고 객체도 사용해야 한다. 자바에서는 기본 데이터 타입 클래스뿐만 아니라 문자열과 날짜를 나타내는 클래스가 존재한다.
돈, 전화번호, 이메일, 우편번호와 같은 특수 문자열 클래스 등의 사소한 작업에 작은 객체를 잘 사용하지 않으려는 경향이 있다. 이러한 습관은 좋지 않다.
데이터 값을 객체로 전환하는 것은 객체를 활용해서 다양한 이점을 취할 수 있다. 아래 코드는 프로그래머스 백엔드 수업에서 사용되는 Email 클래스이다. 이메일은 특수 문자열 값으로 사용해도 되지만, 값을 클래스로 전환했을 경우에 유효성 검증, 계정 이름만 가져오기 등 기능을 추가할 수 있다.
public class Email {
private String address;
public Email(String address) {
checkArgument(isNotEmpty(address), "address must be provided.");
checkArgument(checkAddress(address), "Invalid email address: " + address);
this.address = address;
}
private static boolean checkAddress(String address) {
return matches("[\\w~\\-.+]+@[\\w~\\-]+(\\.[\\w~\\-]+)+", address);
}
public String getName() {
String[] tokens = address.split("@");
if (tokens.length == 2)
return tokens[0];
return null;
}
public String getDomain() {
String[] tokens = address.split("@");
if (tokens.length == 2)
return tokens[1];
return null;
}
public String getAddress() {
return address;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Email email = (Email) o;
return Objects.equals(address, email.address);
}
@Override
public int hashCode() {
return Objects.hash(address);
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("address", address)
.toString();
}
}
switch 문 (Switch Statements)
객체지향 코드의 확연한 특징 중 하나는 switch-case 문이 비교적 적게 사용된다는 점이다. swtch문의 단점은 반드시 중복이 생긴다는 점이다. switch 문을 적게 사용하는 방법은 객체지향 개념 중 하나인 다형성, 즉 재정의를 이용하는 것이다.
SLiPP 위키에서 다형성을 사용해 if/else를 제거하는 리팩토링을 하라. 글을 참고해보자.
평행 상속 계층 (Parallel Inheritance Hierarchies)
평생 상속 계층은 기능의 산재의 특수한 상황이다. 이 문제점이 있으면 한 클래스의 하위 클래스를 만들 때마다 매번 다른 클래스의 하위 클래스도 생성해야 한다. 메서드 이동과 필드 이동을 해서 참조하는 클래스에 있는 계층을 제거한다.
직무유기 클래스 (Lazy Class)
하나의 클래스를 추가할 때마다 유지관리와 이해하기 위한 비용이 추가된다. 따라서 비용만큼의 기능을 수행하지 못하는 클래스가 있다면 없애야 한다.
리팩토링 또는 기능의 변화로 인해서 특정 클래스의 역할이 사라지는 경우도 있다. 이러한 클래스를 그대로 두는 것보다 하위 클래스 또는 모듈에 계층 병합을 적용한다.
막연한 범용 코드 (Speculative Generality)
지금은 당장 사용하지 않지만 미래에 사용할거라는 믿음을 갖고 추가하는 코드가 있을 수 있다. 이러한 코드는 시간이 흐르고 난 뒤, 다시 보면 이해하기 어렵고 유지보수 하기가 어려워진다. 별다른 기능이 없는 클래스나 모듈이 있으면 계층 병합을 하고, 불필요한 위임을 제거하려면 클래스에 직접 삽입한다. 메서드에 사용되지 않는 매개변수가 있다면 제거하며, 메서드명이 불분명 하다고 느껴지면 메서드명을 변경해야 한다.
임시 필드 (Temporary Field)
객체 안에 인스턴스 변수가 특정 상황에서만 할당되고 사용되는 경우가 있다. 개발자는 객체에 들어 있는 모든 변수를 사용할 것이라고 예상한다. 그래서 해당 변수가 특정 시점에만 사용되면, 코드를 파악하기가 어려워진다.
이러한 경우에는 클래스 추출 기법을 사용해서 새로운 클래스에 관련된 변수와 관련된 코드를 넣어야 한다.
메시지 체인 (Message Chains)
메시지 체인은 클라이언트가 특정 객체에 요청하면, 특정 객체는 다른 객체에 요청하고 이러한 방식으로 연쇄적 요청이 발생하는 문제점을 뜻한다. 이러한 로직은 긴 줄의 getThis 메서드나 임시변수 세트라고 볼 수 있다.
이러한 경우에는 대리 객체 은폐를 적용해야 한다. 객체가 사용되는 코드 부분을 메서드 추출을 통해 별도의 메서드로 빼낸 후 메서드 이동을 해서 체인 아래로 밀어낸다.
과잉 중개 메서드 (Middle Man)
객체의 주요 특징 중 하나가 캡슐화이다. 캡슐화란 내부의 세부적인 처리를 외부에서 볼 수 없게 하는 은폐하는 작업을 뜻한다. 자바에서 주요 필드를 private 접근 제한자로 숨기는 것도 그 중 하나이다.
캡슐화는 대개 위임이 수반된다. A가 B에게 요청 메시지를 보내면, B는 내부적으로 C에게 요청 메시지를 보내고 응답 메시지를 받는다. 그리고 B는 A에게 응답 메세지를 보낸다. A의 입장에서 B에게 요청만 보낸 것이지 C를 사용했는지 알 필요가 없다.
이러한 위임 기능을 많이 사용하는 클래스가 존재한다면, 과잉 중개 메서드 제거 기법을 사용해서 원리가 구현된 객체에 직접 접근하는 것이 좋다.
지나친 관여 (Inappropriate Intimacy)
서로 다른 두 클래스가 지나치게 관여한다면 문제가 있다고 볼 수 있다. 이러한 경우에는 메서드 이동과 필도 이동을 사용해서 각 클래스를 분리해서 지나친 관여를 줄여야 한다. 클래스 양방향 연결을 단방향으로 전환할 수 있는지 분석해야 한다. 그리고 두 클래스가 공통적으로 사용하는 기능이 있다면, 별도의 안전한 클래스로 빼내면 된다.
인터페이스가 다른 대용 클래스 (Alternative Classes with Different Interfaces)
기능은 같은데 시그니처가 다른 메서드에는 메서드명 변경을 해야 한다. 클래스에 충분한 기능이 구현되어 있지 않기 때문에 메서드명 변경만으로는 충분하지 않다. 프토콜이 같아질 때까지 메서드 이동을 해서 기능을 해당 클래스로 옮겨야 한다.
미흡한 라이브러리 클래스 (Incomplete Library Class)
라이브러리 클래스는 완벽하지 않을 수 있다. 문제는 라이브러리 클래스를 원하는 기능을 수행하게 수정하는 것은 보통 불가능하다. 이러한 경우에는 외래 클래스에 메서드 추가 기법 또는 국소적 상속화장 클래스 사용 기법을 사용한다.
데이터 클래스 (Data Class)
데이터 클래스는 필드와 읽기/쓰기 메서드만 들어 있는 클래스다. 이러한 클래스 역할은 데이터를 보관하는 역할을 담당하며, 데이터 조작은 다른 클래스가 수행한다. 각 필드는 캡슐화 기법을 적용해야 한다. 그리고 변경되지 말아야 하는 필드에는 쓰기 메서드(setter)를 제거해야 한다.
방치된 상속물 (Refused Bequest)
하위 클래스는 부모 클래스의 메서드와 데이터를 상속 받는다. 상속받는 메서드 중에서 사용하지 않는 것이 있다면, 하위 클래스는 그러한 메서드를 방치해버리는 문제가 생긴다.
전통적인 관점에서 이 문제의 원인은 잘못된 계층구조 때문이다. 이러한 경우에 새 대등 클래스를 작성하고 메서드 하향과 필드 하향을 실시해서 사용되지 않는 모든 메서드를 그 형제 클래스에 몰아 넣어야 한다. 이렇게 하면 상위 클래스에는 공통 코드만 들어 있게 된다.
불필요한 주석 (Comments)
주석을 작성하는 것은 좋은 습관이다. 다만, 주석의 사용 용도가 코드의 구린내를 가리기 위해 존재하는 경우가 많아서 문제이다. 특정 메서드에서 어떤 코드 간의 기능을 설명하는 주석이 길다면, 메서드 추출을 사용해야 하는 경우이다. 또한, 메서드 추출을 적용하고 난 뒤에도 기능을 설명할 주석이 필요하다면 메서드명 변경을 해야 한다.
주석을 추가하기 전에 코드를 리팩토링 할 수 없는지 먼저 살펴보자.
참고자료
- 리팩토링 코드 품질을 개선하는 객제지향 사고법, 마틴 파울러 지음, 김지원 옮김
'Refactoring' 카테고리의 다른 글
리팩토링 - 객체 간의 기능 이동 (0) | 2019.06.06 |
---|---|
리팩토링 - 메서드 정리 (0) | 2019.05.16 |
리팩토링 - 리팩토링 개론 (0) | 2019.05.04 |
댓글