Clean Code 10. Class


캡슐화 (Encapsulation)

변수와 유틸리티 함수는 공개하지 않는게 낫지만, 때로는 테스트 코드에서 접근하기 위해서 protected로 선언하기도 한다. 하지만 비공개 상태를 유지할 방법을 강구하고, 캡슐화를 풀어주는 결정은 최후의 수단이다.

클래스는 작아야 한다!

함수와 같이 물리적인 라인 수를 측정하는게 아니라 클래스가 맡은 책임을 측정한다.

이 클래스는 충분히 작을까 ?
public class SuperDashboard extends JFrame implements MetaDataUser {
	public Component getLastFocusedComponent()
	public void setLastFocused(Component lastFocused)
	public int getMajorVersionNumber()
	public int getMinorVersionNumber()
	public int getBuildNumber() 
}

작명은 클래스 크기를 줄이는 첫번째 관문이다. 클래스 이름에 Processor, Manager, Super와 같은 모호한 단어가 있으면 일단 여러 책임을 떠안겼다는 증거이다.
이 클래스의 책임은 무엇인가 ? 클래스 설명에 if, and, or, but을 사용하지 않고 25자 이내로 가능해야 한다.

단일 책임의 원칙 (Single Responsibility Principle)

클래스나 모듈을 변경할 이유가 단 하나뿐이어야 한다.

위 클래스는 크기는 작아도 변경할 이유가 두 가지다.

  1. 마지막으로 포커스를 받은 컴퍼넌트에 접근
  2. 버전/빌드 정보 추적

그래서 버전 정보만을 책임지는 클래스를 분리하는게 가능하다.

단일 책임 클래스
public class Version {
	public int getMajorVersionNumber() 
	public int getMinorVersionNumber() 
	public int getBuildNumber()
}

많은 개발자들이 자잘한 단일 책임 클래스가 많아지면 이해하기 어려워진다고 생각한다.

도구 상자를 어떻게 관리하고 싶은가? 작은 서랍을 많이 두고 기능과 이름이 명확한 컴포넌트를 나눠 넣고 싶은가? 아니면 큰 서랍 몇개를 두고 모두 던져 넣고 싶은가?

어찌 되었던 파악해야할 로직의 수는 비슷하다. 잘 정리되어 있냐 아니냐의 차이일 뿐이다.

응집도 (Cohesion)

클래스는 인스턴스 변수 수가 작아야 한다. 메서드가 변수를 더 많이 사용할수록 메서드와 클래스는 응집도가 높다. 아래의 Stack을 구현한 클래스는 응집도가 아주 높다. size()를 제외한 두 메서드는 두 변수를 다 사용한다.

Stack.java
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;
	}
}

함수를 작게, 매개변수를 짧게 라는 전략을 따르다 보면 몇몇 메서드만 사용하는 변수가 많아 질 수 있다. 이는 클래스를 쪼개야 한다는 신호다. 응집도가 높아지도록 변수와 메서드를 분리해 클래스를 쪼개준다.

응집도를 유지하면 작은 클래스 여럿이 나온다.
  1. 큰 함수를 작은 함수 여럿으로 나눈다.
  2. 함수로 빼내려는 함수가 인자를 4개 사용한다.
  3. 인자 길이를 줄이기 위해서 변수로 승격시킨다.
  4. 나머지 함수들은 해당 변수를 사용하지 않으므로 클래스가 응집력을 잃게된다.
  5. 쪼개라 ! 그러면 다시 클래스의 응집력이 올라간다.

아래는 커누스 교수가 쓴 책 Literate Programming의 예제로 큰 함수를 작은 함수/클래스로 쪼개는 훌륭한 예제이다. 소수를 구하고, 화면에 테이블로 출력하는 코드다.

PrimePrinter.java
package literatePrimes;

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;
			}
		}
	}
}

응집력을 높인 클래스로 분리

PrimePrinter.java (refactoring)
package literatePrimes;

public class PrimePrinter {
	public static void main(String[] args) {
		final int NUMBER_OF_PRIMES = 1000;
		int[] primes = PrimeGenerator.generate(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); 
	}
}
RowColumnPagePrinter.java
package literatePrimes;

import java.io.PrintStream;

public 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); 
			printStream.println("\f");
			pageNumber++;
		} 
	}
	
	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;
	} 
}
PrimeGenerator.java
package literatePrimes;

import java.io.PrintStream;

public 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); 
			printStream.println("\f");
			pageNumber++;
		} 
	}
	
	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;
	} 
}

재구현이 아니다! 두 프로그램은 알고리즘과 동작원리가 동일하다. 가장 먼저, 테스트 슈트를 작성한 다음, 한 번에 하나씩 코드를 변경한다.

변경하기 쉬운 클래스

대부분의 시스템은 지속적으로 변한다. 그때 의도대로 동작하지 않을 위험이 따른다. 깨끗한 시스템은 클래스를 체계적으로 정리해 변경에 수반하는 위험을 낮춘다.

변경이 필요한 클래스
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)
}

아직 update문을 지원하지 않는다. 여기에 update를 지원하기 위해서는 손대야 한다. 뿐만 아니라 select문에 변경 사항이 있더라도 손대야 한다. 이미 SRP를 위반한다. 구조적으로 보더라도 selectWithCriteria는 select에서만 사용한다.

정리한 클래스
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()
}

public method들을 Sql의 파생클래스로 만들었다. 모든 클래스가 사용하는 pravate method는 Where와 ColumnList라는 두 유틸리트 클래스에 넣었다. 이제는 update 기능을 추가하더라도 기존 클래스를 건드릴 필요가 없다.

변경으로부터 격리

구체적인(concrete) 클래스와 추상(abstract) 클래스가 있을 때 concrete 클래스에 의존하는 클라이언트 클래스는 구현이 바뀌면 위험에 빠진다. 그래서 interface와 abstract 클래스를 사용해서 구현에 미치는 영향을 격리한다.

상세 구현에 의존하는 코드는 테스트가 어렵다. interface를 이용하면 해당 클래스를 흉내내는 테스트 클래스를 만들 수 있다.

interface
public interface StockExchange { 
	Money currentPrice(String symbol);
}

클라이언트 클래스에서는 interface를 참조하도록 수정한다.

client class
public Portfolio {
	private StockExchange exchange;
	public Portfolio(StockExchange exchange) {
		this.exchange = exchange; 
	}
}

이제 테스트용 클래스를 만들 수 있다.

test code
public class PortfolioTest {
	private FixedStockExchangeStub exchange;
	private Portfolio portfolio;
	
	@Before
	protected void setUp() throws Exception {
		exchange = new FixedStockExchangeStub(); 
		exchange.fix("MSFT", 100);
		portfolio = new Portfolio(exchange);
	}

	@Test
	public void GivenFiveMSFTTotalShouldBe500() throws Exception {
		portfolio.add(5, "MSFT");
		Assert.assertEquals(500, portfolio.value()); 
	}
}

위와 같은 테스트가 가능할 정도로 시스템의 결합도를 낮추면 유연성과 재사용성도 높아진다. 결합도가 낮다는 소리는 각 시스템 요소가 다른 요소와 변경으로 부터 격리되었다는 의미다. 이렇게 결합도는 최소로 줄이면 DIP (Dependency Inversion Principle)를 따르는 클래스가 나온다. 상세구현이 아니라 추상화에 의존해야 한다는 원칙이다.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)