9장 까지는 코드 레벨에서 클린하게 작성하는데 초점을 두고 공부했어요. 10장에서는 코드 레벨보다 차원이 더 높은 단계인 클래스 레벨을 더 깨끗하게 만드는 방법을 알아볼거에요.
프로그램은 신문 기사처럼 읽혀요. 왜냐하면 보통 클래스에서는 [정적 공개 상수 - 공개 변수 - 정적 비공개 변수 - 비공개 인스턴스 변수] 순으로 변수 목록이 먼저 선언되고 [각 공개 함수 + 각 공개 함수에서 사용되는 비공개함수]로 공개 함수 직후에 사용되는 비공개 함수가 함께 선언되요. 추상화 수준에 맞춰 순차적으로 선언된다고 보면되요.
변수와 유틸리티 함수는 가능한 공개하지 않는 편이 좋아요. 물론 그렇다고 반드시 숨길 필요는 없어요. 예를 들면 같은 패키지 안에서 테스트 코드에 접근을 허용하기 위해 protected로 선언할 수 있고, 패키지 전체에 공개할 수 있어요. 하지만 항상 비공개 상태를 유지할 방법을 강구해본 뒤에 캡슐화를 풀어주는 것은 최후의 수단으로 생각해서요.
클래스를 만들 때 중요한 규칙은 작아야 한다는 것이에요. 함수와 마찬가지로 작고 명료하게 만들어야 해요. 함수는 물리적인 행 수로 크기를 측정할 수 있어요. 하지만 클래스에서는 다른 척도로 계산해요. 바로 맡은 책임을 세요.
다음의 엄청 많은 메서드를 가진 SuperDashboard 클래스를 예시로 작은 클래스가 무엇인지 공부해봐요.
70개 메서드 가진 SuperDashboard | 5개 메서드 가진 SuperDashboard |
|---|---|
| |
위 70개 메서드 가진 SuperDashboard 클래스는 누구나 엄청 크다는 것에 동의할거에요. 그러면 5개의 메서드를 가진 SuperDashboard는 괜찮을까요? 정답은 아니에요. 메서드 수가 작지만 여전히 책임이 너무 많기 때문이에요.
클래스 이름은 해당 클래스 책임을 기술해야해요. 작명은 클래스 크기를 줄이는 첫 번째 관문이에요. 만약 간결한 이름이 떠오르지 않는다면 클래스 크기가 클 가능성이 커요. 대표적인 예로 클래스 이름에 Processor, Manager, Super 같은 모호한 단어가 있으면 여러 책임을 갖는 증거가 되요. 또한 클래스는 if,and,or,but을 사용하지 않고 25단어 내외로 하는게 좋아요.
단일 책임 원칙(SRP)는 클래스나 모듈을 변경할 이유가 하나뿐이어야 한다는 원칙이에요. 즉, 클래스는 책임(변경할 이유)가 하나여야 한다는 것이에요. 예를 들면 위의 5개 메서드를 가진 SuperDashboard가 변경할 이유는 2가지에요.
메서드가 5개인 SuperDashboard 책임
SuperDashboard 소프트웨어 버전 정보 추적
자바 스윙 컴포넌트 관리
물론 이 코드는 스윙 코드가 달라지면 버전 번호도 달라질 거에요. 하지만 책임(변경할 이유)를 파악하면 코드를 추상화하기 쉬워지고 추상화가 더 쉽게 떠올라요.
SuperDashboard에서 버전 정보를 다루는 메서드를 따로 빼서 Version 클래스를 만들어봐요. 이러면 이제 Version 클래스는 다른 애플리케이션에서 재사용하기 아주 쉬운 구조가되요.
public class Version { public int getMajorVersionNumber() public int getMinorVersionNumber() public int getBuildNumber() }SRP는 객체 지향 설계에서 중요한 개념이고 이해하고 지키기 수월한 개념이에요. 하지만 설계자가 가장 무시하는 규칙 중에 하나이기도 해요. 왜그럴까요?
소프트웨어를 돌아가게 만드는 것과 깨끗하게 만드는 것은 별개에요. 우리는 두뇌 용량에 한계가 있기 때문에 '깨끗하고 체계적'보다 '돌아가는' 소프트웨어에 더 초점을 맞춰요. 그리고 이런 태도는 100% 맞는 태도에요. 문제는 대다수가 프로그램이 돌아가면 일이 끝났다고 여기는 것에 있어요. "깨끗하고 체계적인 소프트웨어'라는 다음 관심사로 전환하지 않아요. 프로그램으로 되돌아가 만능 클래스를 단일 책임 클래스로 분리하지 않고 그냥 넘어가버려요.
그리고 많은 개발자가 자잘한 단일 책임 클래스가 많아지면 큰 그림을 이해하기 어려워진다고 우려해요. 왜냐하면 코드 파악을 위해 클래스를 넘나들어야하기 때문이에요.
하지만 작은 클래스가 많은 시스템이든 큰 클래스가 몇 개뿐인 시스템이든 돌아가는 부품 수는 비슷해요. 그래서 고민할 질문은 "어떻게 명확하게 분리할 것인가?" 이에요. 규모가 커지면 시스템은 논리가 많고 복잡해져요. 그렇기 때문에 복잡성을 다루려면 체계적인 정리가 필수에요. 그래야 개발자가 무엇이 어디에 있는지 쉽게 찾아요.
큼직한 다목적 클래스 몇 개로 이뤄진 시스템은 변경을 할 때 당장 알 필요가 없는 사실까지 들이밀어 방해해요. 하지만 작은 클래스들로 이뤄진 시스템은 변경을 가할 때 직접 영향이 미치는 컴포넌트만 이해해도 충분해요.
클래스는 인스턴스 변수 수가 작아야해요. 각 클래스 메서드는 클래스 인스턴스 변수를 하나 이상 사용해야 해요. 일반적으로 메서드가 변수를 더 많이 사용할수록 메서드와 클래스는 응집도가 더 높아요. 모든 인스턴스 변수를 메서드마다 사용하는 클래스는 응집도가 가장 높고요.
그런데 이렇게 응집도가 가장 높은 클래스는 가능하지도 바람직하지도 않아요! 그렇지만 우리는 응집도가 높은 클래스를 선호해요. 응집도가 높다는 말은 클래스에 속한 메서드와 변수가 서로 의존하며 논리적인 단위로 묶인다는 의미에요.
아래 Stack을 구현한 코드는 응집도가 높은 클래스 예시에요 . size()를 제외한 다른 두 메서드는 두 변수를 모두 사용해요.
public class Stack { private int topOfStack = 0; List<Integer> elements = new LinkedList<Integer>(); public int size() { return topOfStack; } public void push(int element) { topOfStack++; elements.add(element); } public int pop() throws PoppedWhenEmpty { if (topOfStack == 0) throw new PoppedWhenEmpty(); int element = elements.get(--topOfStack); elements.remove(topOfStack); return element; }}'함수를 작게, 매개변수 목록을 짧게'라는 전략을 따르다 보면 때때로 몇몇 메서드만이 사용하는 인스턴스 변수가 많아져요. 이는 대부분 새로운 클래스로 쪼개야 한다는 신호에요. 응집도가 높아지도록 변수와 메서드를 적절히 분리해 새로운 클래스 두세 개로 쪼개주세요.
큰 함수를 작은 함수 여러개로 나누면 클래스 수가 많아져요. 예를 들면 변수가 아주 많은 큰 함수가 있다고 해요. 이 함수의 일부를 작은 함수 하나로 빼고 싶은데, 빼내려는 코드가 큰 함수에 정의된 변수 넷을 사용해요. 그러면 변수 4개를 새 함수의 인자로 넣어야 할까요?
아니에요! 만약 네 변수를 클래스 인스턴스 변수로 승격하면 새 함수는 인수가 필요없어요. 그만큼 함수 쪼개기가 쉬워져요. 하지만 이렇게 하면 클래스가 응집력을 잃어요(몇몇 함수에서만 사용하는 변수를 클래스 변수로 승격했기 때문). 그래서 몇몇 함수가 몇몇 변수만 사용한다면 해당 부분을 독자적인 부분으로 분리해요. 그러면 응집력을 잃지 않게되요.
다음 코드 예시를 통해 어떻게 쪼개는지 알아봐요.
리팩터링 전 | 리팩터링 후 (3개로 분리됨) |
|---|---|
| |
| |
|
리팩터링 전에는 변수가 엄청 많고 구조가 빽빽해요. 이를 이제 리팩터링해서 여러 함수나 클래스로 나눠봐요. PrimePrinter, RowColumnPagePrinter, PrimeGenerator 이 3가지 클래스로 나눴어요.
가장 먼저 눈에 띄는 변화는 프로그램이 길어졌어요. 1쪽을 조금 넘기던 프로그램이 3쪽 정도로 늘어났어요. 길이가 늘어난 이유는 크게 3가지에요.
리팩터링 후 길이가 늘어난 이유
더 길고 서술적인 변수명 사용
주석의 역할을 해주는 함수 선언과 클래스 선언 활용.
가독성을 위한 공백 추가 및 형식 맞추기
그리고 변경해야할 이유(책임)도 3가지로 나눠졌어요.
변경할 책임
PrimePrinter : main 함수만 포함하며 실행 환경을 책임. => 호출 방식이 달라지면 이 클래스 변경
RowColumnPagePrinter : 숫자 목록을 주어진 행과 열에 맞춰 페이지에 출력. => 출력 모양을 바꾸려면 이 클래스를 변경
PrimeGenerator : 소수 목록을 생성. => 소수를 계산하는 알고리즘 바꾸려면 이 클래스를 변경
위와 같은 변경은 재구현이 아니에요. 리팩터링 전과 후의 알고리즘은 같아요. 위 코드는 정확한 동작을 검증하기 위한 테스트 코드가 먼저 작성된 후 한 번에 하나씩 수 차례에 걸쳐 코드가 변경되었어요.
대부분의 시스템은 지속적인 변경이 일어나요. 그리고 변경할 때마다 시스템이 의도대로 동작하지 않을 위험이 커요. 깨끗한 시스템은 클래스를 체계적으로 정리해 변경에 수반하는 위험을 낮춰요.
다음의 SQL 문자열을 만드는 Sql 클래스를 예시로 봐요.
public class Sql { public Sql(String table, Column[] columns) public String create() public String insert(Object[] fields) public String selectAll() public String findByKey(String keyColumn, String keyValue) public String select(Column column, String pattern) public String select(Criteria criteria) public String preparedInsert() private String columnList(Column[] columns) private String valuesList(Object[] fields, final Column[] columns) private String selectWithCriteria(String criteria) private String placeholderList(Column[] columns)}이 클래스는 새로운 SQL 문을 지원하려면 반드시 Sql 클래스에 손대야해요(예를 들면 update). 또한 기존 SQL문 하나를 수정할 때도 Sql 클래스에 손대야 해요. 예를 들면 select문에 내장된 select 문을 지원하려면 Sql 클래스를 고쳐야해요. 이렇게 변경할 이유가 두가지라서 Sql 클래스는 SRP를 위반해요.
또한 구조적인 관점에서도 selectWithCriteria라는 비공개 메서드는 select 문을 처리할 때만 사용하기 때문에 SRP를 위반해요.
클래스 일부에서만 사용되는 비공개 메서드는 코드를 개선할 잠재적인 여지를 시사해요. 하지만 실제로 개선에 뛰어드는 계기는 시스템이 변해서라야 해요. Sql 클래스를 논리적으로 완성으로 여긴다면 책임을 분리할 필요가 없어요. 만약 update 문이 필요하지 않다면 Sql 클래스를 그냥 그대로 둬도 좋아요. 하지만 클래스에 손대는 순간 설계를 개선하려는 고민과 시도가 필요해요.
그래서 만약 위 예시를 개선하면 어떤식이 좋을까요?
abstract public class Sql { public Sql(String table, Column[] columns) abstract public String generate();}public class CreateSql extends Sql { public CreateSql(String table, Column[] columns) @Override public String generate()}public class SelectSql extends Sql { public SelectSql(String table, Column[] columns) @Override public String generate()}public class InsertSql extends Sql { public InsertSql(String table, Column[] columns, Object[] fields) @Override public String generate() private String valuesList(Object[] fields, final Column[] columns)}public class SelectWithCriteriaSql extends Sql { public SelectWithCriteriaSql( String table, Column[] columns, Criteria criteria) @Override public String generate()}public class SelectWithMatchSql extends Sql { public SelectWithMatchSql( String table, Column[] columns, Column column, String pattern) @Override public String generate()}public class FindByKeySql extends Sql public FindByKeySql( String table, Column[] columns, String keyColumn, String keyValue) @Override public String generate()}public class PreparedInsertSql extends Sql { public PreparedInsertSql(String table, Column[] columns) @Override public String generate() { private String placeholderList(Column[] columns)}public class Where { public Where(String criteria) public String generate()}public class ColumnList { public ColumnList(Column[] columns) public String generate()}공개 메서드(인터페이스, api)들을 Sql 클래스에서 파생하는 클래스로 만들었어요. 그리고 일부 메서드에서만 사용하는 비공개 메서드는 해당하는 파생 클래스로 옮겼고요. 또한 모든 파생 클래스가 공통으로 사용하는 Where, ColumnList라는 메서드는 두 유틸리티 클래스에 넣었어요. 이렇게 만들면 클래스가 단순해지고 코드가 이해하기 쉬워져요. 그리고 함수 하나를 수정했다고 다른 함수가 망가질 위험도 사라졌어요. 그리고 테스트 관점에서도 모든 논리를 구석구석 증명하기도 쉬워졌고요.
그리고 update 문을 추가할 때 기존 클래스 변경도 필요가 없어요. 단순히 Sql 클래스에서 파생되는 UpdateSql 클래스를 만들면 끝이에요.
위 리팩터링을 함으로써 SRP와 OCP 원칙을 모두 지켜요. 그래서 새 기능을 추가하기 쉽고, 확장할 때 기존 코드를 변경할 필요가 없어요.
요구사항은 변하고 이에따라 코드도 변해요. 그래서 상세한 구현에 의존하는 클라이언트 클래스는 구현이 바뀌면 위험에 빠져요. 그래서 우리는 상세한 코드 구현을 포함하는 구상 클래스(concrete class)와 개념을 포함하는 추상 클래스(abstract class)를 사용해서 구현이 미치는 영향을 격리해요.
상세한 구현에 의존하는 코드는 테스트가 어려워요. 예를 들면 Portfolio 클래스를 만든다고 해봐요. 그리고 이 클래스는 TokyoStockExchange API를 사용해 포트폴리오 값을 계산해요. 그런데 이렇게 직접 API를 사용하면 5분마다 값이 달라져서 테스트 코드 짜기가 힘들어요.
그래서 StockExchange라는 인터페이스를 만들고 TokyoStockExchange를 모방하는 TestStockExchange를 사용하여 고정된 값을 반환하도록 만들 수 있어요. 이렇게 만들면 테스트 코드를 작성할 수 있게되요.
위와 같이 테스트가 가능할 정도로 시스템의 결합도를 낮추면 유연성과 재사용성이 높아져요. 결합도가 낮다는 소리는 각 시스템 요소가 다른 요소로부터 그리고 변경으로부터 잘 격리되어 있다는 의미에요. 각 요소가 잘 격리되면 각 요소를 이해하기 쉬워져요.
그리고 이렇게 결합도를 줄이면 자연스럽게 구현이아닌 추상화에 의존하는 의존성역전(DIP) 원칙를 따르는 클래스가 나와요.
이제 클레스 레벨에서 클린하게 만드는 방법을 알아봤어요. 결국에는 계속 같은 소리라고 느끼지 않나요? 핵심은 결국 "분리"에요. 의존성을 없애기 위한 분리. 연관된 것을 모아두기 위한 분리. 쉽게 수정 가능하게 하기 위한 분리. 쉽게 확장 가능하게 하기 위한 분리.