코드
https://github.com/rkwhr0010/clean_code/tree/main/src
변경 사항은 git history 참고
클래스 체계
1 분류
변수, 메서드
2 분류
Static 인지 아닌지
3 분류
Public ….. Private 순서
추상화 수준이 점진적으로 내려가도록
공개 메서드에 종속된 비공개 메서드는 공개 메서드 바로 아래 위치
public class Exam01 { public static String EXAMPLE = "공개상수"; public String exam = "공개변수"; protected String exam2 = "보호변수"; private String exam3 = "비공개변수"; public static void mtd() {} public void mtd2() {} public void mtd3() { mtd3Inner(); } private void mtd3Inner() {} protected void mtd4() {} } |
캡슐화
변수나 유틸 함수는 가능한 공개하지 않는다.
반드시 숨겨야 한다는 법칙은 없다. 간혹 protected 로 선언해 테스트 코드에 접근을 허용하기도 한다. 그 만큼 테스트 코드가 중요하기 때문이다.
다만, 이 방법은 최후의 수단으로 private 상태에서 모든 방법을 찾아서 테스트하고, 여의치 않으면, 최후의 수단으로 캡슐화를 느슨하게 푸는 결정을 한다.
클래스는 작아야 한다
클래스를 만들 때 가장 첫 번째 규칙은 크기다. 둘 째도 세 째도…
함수에서 강조 했던 이야기다.
가이드 라인
public class SuperDashboard extends JFrame implements MetaDataUser{ public Component getLastFocusedComponent() {return null;} public void setlastFocused(Component component) {} public int getMajorVersionNumber() {return 0;} public int getMinorVersionNumber() {return 0;} public int getBuildNumber() {return 0;} } |
클래스 크기는 메서드 개 수만으로 정해지는 것이 아니다. 책임을 봐야한다.
위 클래스는 메서드 수는 적지만, 많은 책임을 지고있다.
클래스 이름은 클래스 책임을 나타내야 한다.
클래스 이름 작명이 클래스 크기 줄이기 시작이다.
만약 간결한 이름이 떠오르지 않는다면, 필히 많은 책임을 지고 있을 가능성이 높다
클래스 이름에 Processor, Manager, Super 같은 모호한 단어가 있다면, 클래스가 여러 책임을 가졌을 가능성이 높다.
클래스 이름에 if, and, or 같은 단어가 보이면 책임이 그만큼 많다는 것이다.
사용하면 안된다. 그리고 25단어 내외로 가능해야 한다.
클래스를 설명할 때, "~하며" 같은 단어가 들어가게 된다면, 책임이 많은 것이다.
SuperDashboard는 마지막으로 포커스를 얻었던 컴포넌트에 접근하는 방법을 제공하며, 버전과 빌드 번호를 추적한다.
단일 책임 원칙 (SRP: Single Responsibility Principle)
클래스나 모듈을 변경할 이유는 단, 하나이어야 한다.
SuperDashboard는 변경할 이유가 두 가지다.
- 소프트웨어 버전 정보를 추적하는데 버전 정보는 SW출시 마다 변경된다.
- 스윙 컴포넌트를 관리한다(JFrame)
스윙 코드가 변경되면, 버전 정보 추척도 영향을 받는다. 역으론 영향을 주진 않는다.
public class Version { public int getMajorVersionNumber() {return 0;} public int getMinorVersionNumber() {return 0;} public int getBuildNumber() {return 0;} } |
버전 정보를 관리하는 독자적인 단일 책임 클래스
이 클래스는 다른 애플리케이션에서 재사용하기 쉽다.
SRP는 지키기 쉬운 원칙이다. 하지만 가장 무시 받는 규칙 중 하나다.
SW를 돌아만 가게 하는 것과 SW를 깨끗하게 유지하는 것은 별개다.
대부분이 돌아만 가게 하면, 일이 끝났다고 여기는 게 문제다.
이후 깨끗하게 유지를 하지 않는다.
만능 클래스를 단일책임클래스 여럿으로 분리하는 작업을 의미
최우선은 당연히 돌아가는 것!
일반적으로 클래스 개수가 늘어나 이 클래스 저 클래스 움직이면 프로그램의 큰 그림을 보기 힘들다고 걱정한다.
하지만 결국 프로그램 알고리즘 총량을 똑같다. 어느 시스템이건 익힐 내용 양은 비슷하다.
그러므로 그 걱정은 접어두고, 우리가 고민해야할 것은 분류이다.
기능과 이름이 명확한 컴포넌트를 잘 분류해야 한다.
프로젝트 규모가 커질 수록 시스템 논리도 복잡해진다.
이런 복잡성을 제어하려면 체계적인 분류가 필요하다.
그래야 개발자들이 무엇이 어디에 존재하는 지 찾을 수 있다.
분류를 통해 변경 시 영향을 미치는 컴포넌트를 쉽게 식별하고, 불필요한 컴포넌트가 핵심 사항 파악을 방해하지 않는다.
큰 클래스 하나보다 작은 클래스 여럿이 바람직하다.
작은 클래스는 SRP를 준수하며, 다른 작은 클래스와 협력을 통해 시스템에 필요한 동작을 수행한다.
응집도(Cohesion)
클래스는 인스턴스 변수가 작아야 한다.
각 클래스 메서드는 클래스 인스턴스 변수를 하나 이상 사용해야 한다.
일반적으로 메서드가 변수를 더 많이 사용할 수록 메서드와 클래스는 응집도가 높다.
모든 인스턴스 변수를 메서드마다 사용하는 클래스는 응집도가 가장 높다.
가장 높은 응집도 클래스는 현실적으로 가능하지도 바람직하지도 않다.
개발자가 응집도 높은 클래스를 선호하는 이유는 클래스에 속한 메서드와 변수가 서로 의존하며 논리적인 단위로 묶이기 때문이다.
//높은 응집도 클래스 public class Stack { private int topOfStack = 0; List<Integer> elements = new LinkedList<>(); 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; } } |
topOfStack 인스턴스 변수를 모든 메서드가 사용하며,
elements 도 대부분 사용한다.
"함수를 작게, 매개변수 목록을 짧게" 전략을 따르다 보면
때때로 몇몇 메서드만이 사용하는 인스턴스 변수가 많아진다.
이는 새로운 클래스로 쪼개야 한다는 신호다.
응집도를 유지하면 작은 클래스 여럿이 나온다
큰 함수를 작은 함수 여럿으로 나누기만 해도 클래스 수가 많아진다.
예를 들어, 큰 함수 일부를 작은 함수로 빼고 싶은데,
추출하려는 코드가 큰 함수 로컬 변수를 사용한다.
로컬 변수를 작은 함수의 인자로 주면 안되고, 그 로컬 변수를 인스턴스 변수로 만들면, 인지가 필요없다.
다만, 이렇게 작업을 이어가면, 클래스가 응집력을 잃는다.
몇몇 함수만이 사용하는 인스턴스 변수가 늘어나기 때문이다.
즉, 새로운 클래스로 쪼개야 한다는 신호가 늘어난다.
결과적으로 큰 함수를 작은 함수 여럿으로 쪼개다 보면 종족 작은 클래스 여럿으로 쪼갤 기회가 생긴다.
이 과정에서 프로그램 구조가 점점 더 체계적으로 변한다.
public class PrintPrimes { public static void main(String[] args) { final int M = 1000; final int RR = 50; final int CC = 4; final int WW = 10; final int ORDMAX = 30; int P[] = new int[M + 1]; int PAGENUMBER; int PAGEOFFSET; int ROWOFFSET; int C; int J; int K; boolean JPRIME; int ORD; int SQUARE; int N; int MULT[] = new int[ORDMAX + 1]; J = 1; K = 1; P[1] = 2; ORD = 2; SQUARE = 9; while (K < M) { do { J = J + 2; if (J == SQUARE) { ORD = ORD + 1; SQUARE = P[ORD] * P[ORD]; MULT[ORD - 1] = J; } N = 2; JPRIME = true; while (N < ORD && JPRIME) { while (MULT[N] < J) MULT[N] = MULT[N] + P[N] + P[N]; if (MULT[N] == J) JPRIME = false; N = N + 1; } } while (!JPRIME); K = K + 1; P[K] = J; } { PAGENUMBER = 1; PAGEOFFSET = 1; while (PAGEOFFSET <= M) { System.out.println("The First " + M + " Prime Numbers --- Page " + PAGENUMBER); System.out.println(""); for (ROWOFFSET = PAGEOFFSET; ROWOFFSET < PAGEOFFSET + RR; ROWOFFSET++) { for (C = 0; C < CC; C++) if (ROWOFFSET + C * RR <= M) System.out.format("%10d", P[ROWOFFSET + C * RR]); System.out.println(""); } System.out.println("\f"); PAGENUMBER = PAGENUMBER + 1; PAGEOFFSET = PAGEOFFSET + RR * CC; } } } } |
여러 함수로 나누고, 클래스와 변수에 의미있는 이름을 부여하기
public class PrimePrinter { public static void main(String[] args) { final int NUMBER_OF_PRIMES = 1000; int[] primes = PrimeGenerator.generatePrimes(NUMBER_OF_PRIMES); final int ROWS_PER_PAGE = 50; final int COLUMNS_PER_PAGE = 4; RowColumnPagePrinter tablePrinter = new RowColumnPagePrinter(ROWS_PER_PAGE, COLUMNS_PER_PAGE, "The First " + NUMBER_OF_PRIMES + " Prime Numbers"); tablePrinter.print(primes); } } class RowColumnPagePrinter { private int rowsPerPage; private int columnsPerPage; private int numbersPerPage; private String pageHeader; private PrintStream printStream; public RowColumnPagePrinter(int rowsPerPage, int columnsPerPage, String pageHeader) { this.rowsPerPage = rowsPerPage; this.columnsPerPage = columnsPerPage; this.pageHeader = pageHeader; numbersPerPage = rowsPerPage * columnsPerPage; printStream = System.out; } public void print(int[] data) { int pageNumber = 1; for (int firstIndexOnPage = 0; firstIndexOnPage < data.length; firstIndexOnPage += numbersPerPage) { int lastIndexOnPage = Math.min(firstIndexOnPage + numbersPerPage - 1, data.length - 1); printPageHeader(pageHeader, pageNumber); printPage(firstIndexOnPage, lastIndexOnPage, data); } } private void printPage(int firstIndexOnPage, int lastIndexOnPage, int[] data) { int firstIndexOfLastRowOnPage = firstIndexOnPage + rowsPerPage - 1; for (int firstIndexInRow = firstIndexOnPage; firstIndexInRow <= firstIndexOfLastRowOnPage; firstIndexInRow++) { printRow(firstIndexInRow, lastIndexOnPage, data); printStream.println(""); } } private void printRow(int firstIndexInRow, int lastIndexOnPage, int[] data) { for (int column = 0; column < columnsPerPage; column++) { int index = firstIndexInRow + column * rowsPerPage; if (index <= lastIndexOnPage) { printStream.format("%10d", data[index]); } } } private void printPageHeader(String pageHeader, int pageNumber) { printStream.println(pageHeader + " --- Page " + pageNumber); printStream.println(""); } public void setOutput(PrintStream printStream) { this.printStream = printStream; } } class PrimeGenerator { private static boolean[] crossedOut; private static int[] result; public static int[] generatePrimes(int maxValue) { if (maxValue < 2) { return new int[0]; } else { uncrossIntegersUpTo(maxValue); crossOutMultiples(); putUncrossedIntegersIntoResult(); return result; } } private static void uncrossIntegersUpTo(int maxValue) { crossedOut = new boolean[maxValue + 1]; for (int i = 2; i < crossedOut.length; i++) { crossedOut[i] = false; } } private static void crossOutMultiples() { int limit = determineIterationLimit(); for (int i = 2; i <= limit; i++) { if (notCrossed(i)) { crossOutMultiplesOf(i); } } } private static int determineIterationLimit() { /* * 배열에 있는 모든 배수는 배열의 제곱근보다 작은 소수의 인수다. 따라서 이 제곱근보다 더 큰 숫자의 배수는 제거할 필요가 없다. */ double iterationLimit = Math.sqrt(crossedOut.length); return (int) iterationLimit; } private static void crossOutMultiplesOf(int i) { for (int multiple = 2 * i; multiple < crossedOut.length; multiple += i) { crossedOut[multiple] = true; } } private static boolean notCrossed(int i) { return crossedOut[i] == false; } private static void putUncrossedIntegersIntoResult() { result = new int[numberOfUncrossedIntegers()]; for (int j = 0, i = 2; i < crossedOut.length; i++) { if (notCrossed(i)) { result[j++] = i; } } } private static int numberOfUncrossedIntegers() { int count = 0; for (int i = 2; i < crossedOut.length; i++) { if (notCrossed(i)) { count++; } } return count; } } |
개선 결과로 프로그램이 길어졌다.
길이가 늘어난 이유는 첫째, 서술적인 변수 이름 사용
둘째, 주석 대신 클래스와 메서드 선언을 활용
셋째, 가독성을 위한 공백 추가 및 형식 맞춤
기존 프로그램에서 세 가지 책임을 각 클래스로 나눴다.
PrimePrinter 클래스는 실행 환경 및 호출 방식 책임
RowColumnPagePrinter 클래스는 숫자 목록을 주어진 행과 열에 맞춰 출력 책임
PrimeGenerator 클래스는 소수 목록 생성 책임
재구현이 아닌, 기존 프로그램 알고리즘과 동작원리를 그대로 사용
먼저 동작 검증을 위한 테스트 슈트 구축 후
조금씩 코드를 변경, 다시 테스트 를 반복해 완성
변경하기 쉬운 클래스
변경을 무조건 다가온다. 변경 후 시스템이 의도대로 도작하지 않을 가능성은 항상 존재한다.
깨끗한 시스템은 클래스를 체계적으로 정리해 변경에 따른 위험을 낮춘다.
주어진 메타정보로 SQL 문자열 생성 클래스, 아직 미완성으로 나중에 변경이 예정돼있다.
select 만 완성되었고, 나머지는 미구현이라 가정
public class Sql { public Sql(String table, Column[] columns) {} public String create() {return "";} public String insert(Object[] fields) {return "";} public String selectAll() {return "";} public String findByKey(String keyColumn, String keyValue) {return "";} public String select(Column column, String pattern) {return "";} public String select(Criteria criteria) {return "";} private String columnList(Column[] columns) {return "";} private String valuesList(Object[] fields, final Column[] columns) {return "";} private String selectWithCriteria(String criteria) {return "";} private String placeholderList(Column[] columns) {return "";} } |
문제점
새로운 SQL 구현 및 기존 SQL 수정 시 반드시 Sql 클래스를 손대야 한다.
SRP 위반
위 비공개 함수는 일반적으로 개선의 여지가 있는 경우가 많다.
닫힌 클래스 집합 (OCP)
abstract public class Sql { public Sql(String table, Column[] columns) {} abstract public String generate(); } class CreateSql extends Sql { public CreateSql(String table, Column[] columns) { super(table, columns); } @Override public String generate() {return null;} } class SelectSql extends Sql { public SelectSql(String table, Column[] columns) { super(table, columns); } @Override public String generate() {return null;} } class InsertSql extends Sql { public InsertSql(String table, Column[] columns, Object[] fields) { super(table, columns); } @Override public String generate() {return null;} private String valuesList(Object[] fields, final Column[] columns) {return "";} } class SelectWithCriteriaSql extends Sql { public SelectWithCriteriaSql(String table, Column[] columns, Criteria criteria) { super(table, columns); } @Override public String generate() {return null;} } class SelectWithMatchSql extends Sql { public SelectWithMatchSql(String table, Column[] columns, Column column, String pattern) { super(table, columns); } @Override public String generate() {return null;} } class FindByKeySql extends Sql { public FindByKeySql(String table, Column[] columns, String keyColumn, String keyValue) { super(table, columns); } @Override public String generate() {return null;} } class PreparedInsertSql extends Sql { public PreparedInsertSql(String table, Column[] columns, String keyColumn, String keyValue) { super(table, columns); } @Override public String generate() {return null;} private String placeholderList(Column[] columns) {return "";} } class Where { public Where(String criteria) { } public String generate() {return "";} } class ColumnList { public ColumnList(Column[] columns) { } public String generate() {return "";} } |
종속적인 비공개 함수는 그 클래스가 가져가게 하고,
일부 비공개 함수는 클래스로 분리했다.
각 클래스는 아주 단순하다.
메서드 하나를 수정한다고 다른 메서드가 망가질 위험이 완전히 제거됐다.
테스트하기도 쉬워졌다.
만약 Update를 구현해야 한다면, Sql 상속받아(OCP) UpdateSql 클래스를 만들면 된다.
다른 클래스가 전혀 영향받지 않는다.
재구성한 Sql 클래스는 SRP와 OCP를 준수한다.
OCP(Open-Closed Principle), 수정엔 폐쇄적, 확장엔 개방적
위와 대입하면, Sql클래스는 기능 추가엔 확장으로 대응하며, 이로 인한 전체 구조 변경엔 폐쇄적이다.
변경으로부터 격리
요구사항은 변한다. 따라서 코드도 변한다.
구체적인 클래스에 의존하면, 변경이 어렵다.
그래서 추상 클래스나 인터페이스를 사용해 구체적인 구현을 감추는 방식을 사용한다.
이는 구현이 미치는 영향을 격리한다.
구현에 의존하는 코드는 테스트도 어렵다.
예시
Portfolio 클래스는 TokyoStockExchange API를 사용해 포트폴리오 값을 계산한다.
테스트 코드는 시세(API)에 영향을 받으며, 시세는 5분 마다 값이 달라진다.
이런 값이 변하는 API를 테스트하기란 쉽지 않다.
TokyoStockExchange API를 직접 호출하는 대신(구체적인 것에 의존)
StockExchange 인터페이스를 정의 후 사용한다.
public interface StockExchange { Money currentPrice(String symbol); } |
TokyoStockExchange는 위 인터페이스를 구현하도록 한다.
포트폴리오 클래스는 구체적인 클래스(도쿄증권거래소API) 대신 인터페이스에 의존한다.
public class Portfolio { private StockExchange exchange; public Portfolio(StockExchange exchange) { this.exchange = exchange; } // ... } |
구제적인 구현에 의존하지 않기 때문에 이제 API를 테스트 할 수 있는 구조가 됐다.
도쿄증권거래소API 대신 고정 주가를 반환하는 테스트 클래스를 만들어 테스트를 하면 된다.
public class PortfolioTest { private FixedStockExchangeStub exchange; private Portfolio portfolio; @Before protected void setUp() throws Exception { exchange = new FixedStockExchangeStub(); //마소 종목은 시세가 100달라 exchange.fix("Microsoft", 100); portfolio = new Portfolio(exchange); } @Test public void 종목_5개_구매_총_500달러() throws Exception { portfolio.add(5, "Microsoft"); Assert.assertEquals(500L, portfolio.value()); } } |
테스트가 가능할 정도로 시스템 결합도를 낮추면 유연성과 재사용성도 높아진다.
결합도가 낮다는 것은 각 시스템 요소가 다른 요소로부터, 변경으로부터 잘 격리되어 있다는 뜻이다.
결합도를 최소로 줄이면 자연스럽게 DIP를 준수하는 클래스가 나온다.
DIP(Dependency Inversion Principle) 클래스가 상세한 구현이 아니라 추상화에 의존해야 한다는 원칙
Portfolio 클래스는 TokyoStockExchange 클래스가 아닌 StockExchange 를 의존한다.
추사 메서드는 주식 가격을 반환한다는 추상적 개념 제외한 "어떻게" 값을 가져오는 지를 숨긴다.