010. (Clean Code) 10. 클래스 - Classes

10. 클래스 - Classes

지금까지는 코드를 깨끗하게 작성하는 방법을 위주로 알아보았다.

그리고 함수를 깨끗하게 구현하는 방법과 함수간의 관계를 맺는 방식도 알아보았다.

이제 더 높은 단계인 깨끗하게 클래스를 작성하는 방법에 대해서 알아보도록 하자.

10.1. 클래스 체계

클래스를 정의하는 표준 자바 관례에 따르면 문서에서 노출되는 순서는 아래와 같다.

  1. 정적 공개 상수
  2. 정적 비공개 변수
  3. 비공개 인스턴스 변수
  4. 공개 변수
  5. 공개 함수
  6. 비공개 함수

즉, 추상화 단계가 순차적으로 내려감을 알 수 있다.

캡슐화

변수와 유틸리티 함수는 가능한 공개하지않는 것이 좋다.

때로는 변수나 유틸리티 함수의 접근제어자를 protected로 선언해서 테스트 코드의 접근을 허용하는 케이스도 있다.

같은 패키지 안에서 테스트 코드가 함수를 호출하거나 변수를 사용해야 한다면 그 함수나 변수를 protected로 선언하거나 패키지 전체로 공개한다.

하지만 무작정 접근을 허용해선 안된다.

모든 방법을 고민해보고, 최후의 방법으로만 캡슐화를 깨야한다.

10.2. 클래스는 작아야 한다!

클래스 작성의 첫 번째 규칙은 바로 크기이다.

단언하자면 클래스는 작아야하고, 더 작아야한다.

그럼 작다 크다를 가를 수 있는 기준이 필요해진다.

클래스는 얼마나 작아야할까?

함수처럼 물리적인 코드의 행을 카운트하면 될까?

클래스 크기의 기준은 바로 책임이다.

아래 예제로 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class SuperDashboard extends JFrame implements MetaDataUser {
public String getCustomizerLanguagePath()
public void setSystemConfigPath(String systemConfigPath)
public String getSystemConfigDocument()
public void setSystemConfigDocument(String systemConfigDocument)
public boolean getGuruState()
public boolean getNoviceState()
public boolean getOpenSourceState()
public void showObject(MetaObject object)
public void showProgress(String s)
public boolean isMetadataDirty()
public void setIsMetadataDirty(boolean isMetadataDirty)
public Component getLastFocusedComponent()
public void setLastFocused(Component lastFocused)
public void setMouseSelectState(boolean isMouseSelected)
public boolean isMouseSelected()
public LanguageManager getLanguageManager()
public Project getProject()
public Project getFirstProject()
public Project getLastProject()
public String getNewProjectName()
public void setComponentSizes(Dimension dim)
public String getCurrentDir()
public void setCurrentDir(String newDir)
public void updateStatus(int dotPos, int markPos)
public Class[] getDataBaseClasses()
public MetadataFeeder getMetadataFeeder()
public void addProject(Project project)
public boolean setCurrentProject(Project project)
public boolean removeProject(Project project)
public MetaProjectHeader getProgramMetadata()
public void resetDashboard()
public Project loadProject(String fileName, String projectName)
public void setCanSaveMetadata(boolean canSave)
public MetaObject getSelectedObject()
public void deselectObjects()
public void setProject(Project project)
public void editorAction(String actionName, ActionEvent event)
public void setMode(int mode)
public FileManager getFileManager()
public void setFileManager(FileManager fileManager)
public ConfigManager getConfigManager()
public void setConfigManager(ConfigManager configManager)
public ClassLoader getClassLoader()
public void setClassLoader(ClassLoader classLoader)
public Properties getProps()
public String getUserHome()
public String getBaseDir()
public int getMajorVersionNumber()
public int getMinorVersionNumber()
public int getBuildNumber()
public MetaObject pasting(MetaObject target, MetaObject pasted, MetaProject project)
public void processMenuItems(MetaObject metaObject)
public void processMenuSeparators(MetaObject metaObject)
public void processTabPages(MetaObject metaObject)
public void processPlacement(MetaObject object)
public void processCreateLayout(MetaObject object)
public void updateDisplayLayer(MetaObject object, int layerIndex)
public void propertyEditedRepaint(MetaObject object)
public void processDeleteObject(MetaObject object)
public boolean getAttachedToDesigner()
public void processProjectChangedState(boolean hasProjectChanged)
public void processObjectNameChanged(MetaObject object)
public void runProject()
public void setAçowDragging(boolean allowDragging)
public boolean allowDragging()
public boolean isCustomizing()
public void setTitle(String title)
public IdeMenuBar getIdeMenuBar()
public void showHelper(MetaObject metaObject, String propertyName)

// ... 많은 비공개 메서드가 이어진다 ...
}

SuperDashboard 클래스를 보면 어떤 생각이 드는가?

공개된 메서드만 70여개에 달한다. 이러한 클래스를 만능 클래스(God Object)라고 부르기도 한다.

참고 001. (The Essence of Object-Orientation) 1. 협력과 역할 그리고 책임

만약 SuperDashboard 클래스가 아래와 같다면 어떨까?

1
2
3
4
5
6
7
public class SuperDashboard extends JFrame implements MetaDataUser {
public Component getLastFocusedComponent()
public void setLastFocused(Component lastFocused)
public int getMajorVersionNumber()
public int getMinorVersionNumber()
public int getBuildNumber()
}

좀 더 괜찮아보이긴 한다.

하지만 메서드의 개수와 상관없이 SuperDashboard 클래스가 가지고 있는 책임이 너무 많다.

가장 먼저 신경써야하는 것은 클래스의 이름을 짓는 것이다.

클래스의 이름을 해당 클래스가 가지고 있는 책임을 기술해야 하며, 클래스의 크기를 줄이는 제일 첫 관문이다.

적당한 이름이 안 떠오른다면 해당 클래스가 많은 책임을 가지고 있는 것이다.

단일 책임 원칙

단일 책임 원칙(SRP - Single Responsibility Principle)은 클래스나 모듈을 변경해야하는 이유가 단 하나뿐이어야한다는 원칙이다.

위의 예제를 다시 가져와보자.

1
2
3
4
5
6
7
public class SuperDashboard extends JFrame implements MetaDataUser {
public Component getLastFocusedComponent()
public void setLastFocused(Component lastFocused)
public int getMajorVersionNumber()
public int getMinorVersionNumber()
public int getBuildNumber()
}

SuperDashboard 클래스는 변경해야하는 이유가 몇 가지일까?

첫 번째, SuperDashboard 클래스는 소프트웨어의 버전을 출력해주는 책임을 가지고 있기에 버전이 변경될 때마다 계속 변경해야 한다.

두 번째, SuperDashboard 클래스는 Java의 Swing 컴포넌트인 JFrame을 구현하고 있다. 스윙 코드가 변경될 때마다 버전 번호가 달라질 수 있따.

결과적으로 SuperDashboard 클래스는 두 가지의 책임을 가지고 있다.

책임 즉, 변경할 이유를 파악하다보면 해당 코드를 추상화하기도 용이해진다.

아래와 같이 SuperDashboard 클래스에서 버전 정보를 다루는 메서드를 별도로 분리해서 책임을 분리해보자.

1
2
3
4
5
public class Version {
public int getMajorVersionNumber()
public int getMinorVersionNumber()
public int getBuildNumber()
}

Version이라는 독자적인 클래스를 만들었고, 다른 곳에서 재사용하기도 쉬워졌다.

위의 사례와 같이 단일 책임 원칙과 별도 분리에 대해 자세한 내용은 아래 포스팅을 참고하자.

참고
001. (The Essence of Object-Orientation) 1. 협력과 역할 그리고 책임
045. (Pragmatic Unit Testing in Kotlin with JUnit) 9. 리팩토링 - 단일 책임 원칙, 명령 질의 분리 원칙

응집도(Cohesion)

클래스는 가지고 있는 인스턴스 변수가 최소화되어야 한다.

각 클 래스 메서드는 클래스 인스턴스 변수를 하나 이상 사용해야한다.

위와 같이 메서드가 변수를 더 많이 사용할수록 메서드와 클래스의 응집도는 높다고 볼수 있다.

만약 모든 인스턴스 변수를 메서드마다 사용하는 클래스는 응집도가 가장 높을 것이다.

응집도가 높다는 것은 클래스에 속한 메서드와 변수가 서로 의존하며 논리적인 단위로 묶였다는 의미를 가지고 있다.

아래 Stack 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}
}

size() 메서드를 제외하고 push()pop() 메서드는 모든 변수를 접근한다.

즉 응집도가 높은 클래스라고 볼 수 있다.

함수를 작게, 파라미터 개수를 적게 작성하다보면 종종 몇몇 메서드만이 사용하는 인스턴스 변수가 늘어날 수 밖에 없다.

이렇게 응집도가 떨어지는 것은 클래스를 쪼개야한다는 일종의 시그널이다.

참고
011. (Objects) 4. 설계 품질과 트레이드오프
004. (The Essence of Object-Orientation) 4. 객체지향 설계 기법의 기초

응집도를 유지하면 작은 클래스 여럿이 나온다

큰 함수를 작은 함수 여럿으로 쪼개기만 해도 클래스의 수는 증가한다.

아래 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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;
}
}
}
}

메인 함수 뿐인 위 코드는 전형적인 지저분한 코드이다.

들여쓰기도 심하고, 이상한 변수도 많고, 구조도 복잡하게 결합되어있다.

하나씩 추출해서 깨끗한 코드로 바꿔보자.

먼저 페이지 관련 출력을 RowColumnPagePrint 클래스로 추출한다.

RowColumnPagePrint 클래스는 숫자 목록을 주어진 행과 열에 맞추어 페이지 출력하는 책임을 가지고 있다.

출력하는 포맷이 변경되면 RowColumnPagePrint 클래스만 수정하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 클래스로 추출한다.

PrimeGenerator 클래스는 소수 목록을 생성하는 책임을 가지고 있다.

소수를 계산하는 알고리즘이 변경되면 PrimeGenerator 클래스만 수정하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class PrimeGenerator {
private static int[] primes;
private static ArrayList<Integer> multiplesOfPrimeFactors;

protected static int[] generate(int n) {
primes = new int[n];
multiplesOfPrimeFactors = new ArrayList<Integer>();
set2AsFirstPrime();
checkOddNumbersForSubsequentPrimes();
return primes;
}

private static void set2AsFirstPrime() {
primes[0] = 2;
multiplesOfPrimeFactors.add(2);
}

private static void checkOddNumbersForSubsequentPrimes() {
int primeIndex = 1;
for (int candidate = 3 ; primeIndex < primes.length ; candidate += 2) {
if (isPrime(candidate))
primes[primeIndex++] = candidate;
}
}

private static boolean isPrime(int candidate) {
if (isLeastRelevantMultipleOfNextLargerPrimeFactor(candidate)) {
multiplesOfPrimeFactors.add(candidate);
return false;
}
return isNotMultipleOfAnyPreviousPrimeFactor(candidate);
}

private static boolean isLeastRelevantMultipleOfNextLargerPrimeFactor(int candidate) {
int nextLargerPrimeFactor = primes[multiplesOfPrimeFactors.size()];
int leastRelevantMultiple = nextLargerPrimeFactor * nextLargerPrimeFactor;
return candidate == leastRelevantMultiple;
}

private static boolean isNotMultipleOfAnyPreviousPrimeFactor(int candidate) {
for (int n = 1; n < multiplesOfPrimeFactors.size(); n++) {
if (isMultipleOfNthPrimeFactor(candidate, n))
return false;
}
return true;
}

private static boolean isMultipleOfNthPrimeFactor(int candidate, int n) {
return candidate == smallestOddNthMultipleNotLessThanCandidate(candidate, n);
}

private static int smallestOddNthMultipleNotLessThanCandidate(int candidate, int n) {
int multiple = multiplesOfPrimeFactors.get(n);
while (multiple < candidate)
multiple += 2 * primes[n];
multiplesOfPrimeFactors.set(n, multiple);
return multiple;
}
}

책임을 분리한 뒤 다시 PrintPrimes에 적용하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}
}

결과적으로 코드의 길이가 많이 늘어났다.

하지만 이는 잘못된 게 아니다.

변수와 함수에 의미있는 이름을 적용하였고, 가독성을 확보하기 위한 들여쓰기와 형식을 맞추었기 때문이다.

10.3. 변경하기 쉬운 클래스

대다수의 시스템은 지속적으로 변경이 발생한다.

변경은 필연적으로 시스템의 오동작을 발생할 여지가 잠재되어있기에 이 위험도를 낮추는 노력이 지속적으로 필요하다.

아래 Sql 예제는 주어진 메타 자료로 적절한 SQL 문자열을 생성하는 코드이다.

update() 기능은 아직 지원하지 않는 상태로, 추후 update() 기능을 지원하게 되면 어찌되었든 변경을 해야하는 상황이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 클래스를 수정해야하며, 기존 SQL문을 수정할때도 이는 마찬가지이다.

변경해야할 이유가 2개인 시점부터 Sql 클래스는 단일 책임 원칙을 위반하게 되었다.

아래와 같이 수정하는 건 어떨까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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()
}

Sql 클래스에 있는 모든 공개 인터페이스를 Sql 클래스의 서브 클래스로 치환하였다.

valueList와 같은 비공개 메서드는 해당하는 서브 클래스로 이전하여였다.

모든 서브 클래스가 공통적으로 사용하는 비공개 메서드는 WhereColumnList 유틸리티 클래스로 이관하였다.

다소 번거로워졌지만 코드의 이해 자체는 쉬워졌고, 함수의 수정이 다른 함수를 망가트릴 위험도 없어졌다.

update()문을 추가하더라도 기존 클래스또한 변경할 필요가 없어졌다.

결과적으로 단일 책임 원칙 뿐만 아니라 개방 폐쇄 원칙도한 준수할 수 있게 되었다.

참고 016. (Objects) 9. 유연한 설계

변경으로부터 격리

요구항은 계속해서 변하기 마련이며, 이는 코드 또한 계속해서 변한다는 것을 의미한다.

객체지향 프로그래밍의 세계에는 구현 클래스와 추상 클래스가 존재하는데

구현 클래스는 상세한 구현을, 추상 클래스는 개념만 포함한다고도 인지하고 있다.

헌데 상세한 구현에 의존하는 코드는 테스트를 어렵게 만든다.

이러한 경우 변경 포인트를 별도로 분리하여 테스트용 클래스로 분리하는 것이 좋다.

참고
031. (Unit Test Principles) 5. Mock과 테스트 취약성
035. (Unit Test Principles) 9. Mock 처리에 대한 모범 사례