[Clean Code] XIV. 점진적 개선(SUCCESSIVE REFINEMENT)

Clean Code 3판을 읽고 정리한 글입니다

XIV. 점진적 개선(SUCCESSIVE REFINEMENT)

SUCCESSIVE REFINEMENT : 연속적인 정제

  • 해당 챕터는 점진적인 개선을 보여주는 사례 연구
  • 우선, 출발은 좋았으나 확장성이 부족했던 모듈을 소개
  • 그런 다음, 모듈을 개선하고 정리하는 단계를 살펴본다

예시

  • 프로그램을 짜다 보면 종종 명령행 인수의 구문을 분석할 필요가 생긴다
  • 편리한 유틸리티가 없다면 main 함수로 넘어오는 문자열 배열을 직접 분석하게 된다
  • 내 사정에 딱 맞는 유틸리티가 없다면? 물론 직접 짜겠다고 결심한다. 새로 짠 유틸리티를 Args라 부르겠다
간단한 Args 사용법
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
Args arg = new Args("l,p#,d*", args);
boolean logging = arg.getBoolean('l');
int port = arg.getInt('p');
String directory = arg.getString('d');
executeApplication(logging, port, directory);
} catch (ArgsException e) {
System.out.print("Argument error: %s\n", e.errorMessage());
}
}

설명

  • 명령어 인수 구문 분석기
  • Args는 사용법이 간단하다
  • Args 생성자에 (입력 파라미터로 들어온) 인수 문자열과 형식 문자열을 넘겨 Args 인스턴스를 생성
    • 첫 파라미터 : 형식 또는 스키마를 지정, 예시에서는 부울인수, 정수 인수, 문자열인수
    • 두번째 파라미터 : main으로 넘어온 명령행 인수배열 자체
  • 명령행 인수 구문 분석 시도
    • 성공? → 정상적으로 인스턴스 생성 → 그 후 Args 인스턴스에다 인수 값을 질의(getter메서드)
    • 실패? → ArgsException 발생 → 형식 문자열이나 명령행 인수 자체에 문제

Args 구현

14-2 Args.java 대체로 깔끔한 구조에 잘짜인
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
package com.objectmentor.utilities.args;

import static com.objectmentor.utilities.args.ArgsException.ErrorCode.*;
import java.util.*;

public class Args {
private Map<Character, ArgumentMarshaler> marshalers;
private Set<Character> argsFound;
private ListIterator<String> currentArgument;

public Args(String schema, String[] args) throws ArgsException {
marshalers = new HashMap<Character, ArgumentMarshaler>();
argsFound = new HashSet<Character>();

parseSchema(schema);
parseArgumentStrings(Arrays.asList(args));
}

private void parseSchema(String schema) throws ArgsException {
for (String element : schema.split(","))
if (element.length() > 0)
parseSchemaElement(element.trim());
}

private void parseSchemaElement(String element) throws ArgsException {
char elementId = element.charAt(0);
String elementTail = element.substring(1);
validateSchemaElementId(elementId);
if (elementTail.length() == 0)
marshalers.put(elementId, new BooleanArgumentMarshaler());
else if (elementTail.equals("*"))
marshalers.put(elementId, new StringArgumentMarshaler());
else if (elementTail.equals("#"))
marshalers.put(elementId, new IntegerArgumentMarshaler());
else if (elementTail.equals("##"))
marshalers.put(elementId, new DoubleArgumentMarshaler());
else if (elementTail.equals("[*]"))
marshalers.put(elementId, new StringArrayArgumentMarshaler());
else
throw new ArgsException(INVALID_ARGUMENT_FORMAT, elementId, elementTail);
}

private void validateSchemaElementId(char elementId) throws ArgsException {
if (!Character.isLetter(elementId))
throw new ArgsException(INVALID_ARGUMENT_NAME, elementId, null);
}

private void parseArgumentStrings(List<String> argsList) throws ArgsException {
for (currentArgument = argsList.listIterator(); currentArgument.hasNext();) {
String argString = currentArgument.next();
if (argString.startsWith("-")) {
parseArgumentCharacters(argString.substring(1));
} else {
currentArgument.previous();
break;
}
}
}

private void parseArgumentCharacters(String argChars) throws ArgsException {
for (int i = 0; i < argChars.length(); i++)
parseArgumentCharacter(argChars.charAt(i));
}

private void parseArgumentCharacter(char argChar) throws ArgsException {
ArgumentMarshaler m = marshalers.get(argChar);
if (m == null) {
throw new ArgsException(UNEXPECTED_ARGUMENT, argChar, null);
} else {
argsFound.add(argChar);
try {
m.set(currentArgument);
} catch (ArgsException e) {
e.setErrorArgumentId(argChar);
throw e;
}
}
}

public boolean has(char arg) {
return argsFound.contains(arg);
}

public int nextArgument() {
return currentArgument.nextIndex();
}

public boolean getBoolean(char arg) {
return BooleanArgumentMarshaler.getValue(marshalers.get(arg));
}

public String getString(char arg) {
return StringArgumentMarshaler.getValue(marshalers.get(arg));
}

public int getInt(char arg) {
return IntegerArgumentMarshaler.getValue(marshalers.get(arg));
}

public double getDouble(char arg) {
return DoubleArgumentMarshaler.getValue(marshalers.get(arg));
}

public String[] getStringArray(char arg) {
return StringArrayArgumentMarshaler.getValue(marshalers.get(arg));
}
}

특징 : 코드가 아래로 읽힌다

  • 잘 읽었으면 ArgumentMarshaler 인터페이스와 파생 클래스의 기능을 이해했을 것
14-3 ArgumentMarshaler.java
1
2
3
public interface ArgumentMarshaler {
void set(Iterator<String> currentArgument) throws ArgsException;
}
14-4 BooleanArgumentMarshaler.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BooleanArgumentMarshaler implements ArgumentMarshaler { 
private boolean booleanValue = false;

public void set(Iterator<String> currentArgument) throws ArgsException {
booleanValue = true;
}

public static boolean getValue(ArgumentMarshaler am) {
if (am != null && am instanceof BooleanArgumentMarshaler)
return ((BooleanArgumentMarshaler) am).booleanValue;
else
return false;
}
}
StringArgumentMarshaler.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import static com.objectmentor.utilities.args.ArgsException.ErrorCode.*;

public class StringArgumentMarshaler implements ArgumentMarshaler {
private String stringValue = "";

public void set(Iterator<String> currentArgument) throws ArgsException {
try {
stringValue = currentArgument.next();
} catch (NoSuchElementException e) {
throw new ArgsException(MISSING_STRING);
}
}

public static String getValue(ArgumentMarshaler am) {
if (am != null && am instanceof StringArgumentMarshaler)
return ((StringArgumentMarshaler) am).stringValue;
else
return "";
}
}
IntegerArgumentMarshaler.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import static com.objectmentor.utilities.args.ArgsException.ErrorCode.*;

public class IntegerArgumentMarshaler implements ArgumentMarshaler {
private int intValue = 0;

public void set(Iterator<String> currentArgument) throws ArgsException {
String parameter = null;
try {
parameter = currentArgument.next();
intValue = Integer.parseInt(parameter);
} catch (NoSuchElementException e) {
throw new ArgsException(MISSING_INTEGER);
} catch (NumberFormatException e) {
throw new ArgsException(INVALID_INTEGER, parameter);
}
}

public static int getValue(ArgumentMarshaler am) {
if (am != null && am instanceof IntegerArgumentMarshaler)
return ((IntegerArgumentMarshaler) am).intValue;
else
return 0;
}
}
  • DoubleArgumentMarshaler, StringArrayArgumentMarshaler는 생략, 연습문제
ArgsException.java 오류 코드 상수 정의
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
74
75
76
77
78
79
import static com.objectmentor.utilities.args.ArgsException.ErrorCode.*;

public class ArgsException extends Exception {
private char errorArgumentId = '\0';
private String errorParameter = null;
private ErrorCode errorCode = OK;

public ArgsException() {}

public ArgsException(String message) {super(message);}

public ArgsException(ErrorCode errorCode) {
this.errorCode = errorCode;
}

public ArgsException(ErrorCode errorCode, String errorParameter) {
this.errorCode = errorCode;
this.errorParameter = errorParameter;
}

public ArgsException(ErrorCode errorCode, char errorArgumentId, String errorParameter) {
this.errorCode = errorCode;
this.errorParameter = errorParameter;
this.errorArgumentId = errorArgumentId;
}

public char getErrorArgumentId() {
return errorArgumentId;
}

public void setErrorArgumentId(char errorArgumentId) {
this.errorArgumentId = errorArgumentId;
}

public String getErrorParameter() {
return errorParameter;
}

public void setErrorParameter(String errorParameter) {
this.errorParameter = errorParameter;
}

public ErrorCode getErrorCode() {
return errorCode;
}

public void setErrorCode(ErrorCode errorCode) {
this.errorCode = errorCode;
}

public String errorMessage() {
switch (errorCode) {
case OK:
return "TILT: Should not get here.";
case UNEXPECTED_ARGUMENT:
return String.format("Argument -%c unexpected.", errorArgumentId);
case MISSING_STRING:
return String.format("Could not find string parameter for -%c.", errorArgumentId);
case INVALID_INTEGER:
return String.format("Argument -%c expects an integer but was '%s'.", errorArgumentId, errorParameter);
case MISSING_INTEGER:
return String.format("Could not find integer parameter for -%c.", errorArgumentId);
case INVALID_DOUBLE:
return String.format("Argument -%c expects a double but was '%s'.", errorArgumentId, errorParameter);
case MISSING_DOUBLE:
return String.format("Could not find double parameter for -%c.", errorArgumentId);
case INVALID_ARGUMENT_NAME:
return String.format("'%c' is not a valid argument name.", errorArgumentId);
case INVALID_ARGUMENT_FORMAT:
return String.format("'%s' is not a valid argument format.", errorParameter);
}
return "";
}

public enum ErrorCode {
OK, INVALID_ARGUMENT_FORMAT, UNEXPECTED_ARGUMENT, INVALID_ARGUMENT_NAME,
MISSING_STRING, MISSING_INTEGER, INVALID_INTEGER, MISSING_DOUBLE, INVALID_DOUBLE
}
}

단순 개념 구현에 너무 많은 코드

  • 장환한 언어 자바 → 정적 타입 언어 → 타입 시스템 만족을 위해 많은 단어 필요
  • 네이밍, 함수 크기, 형식에 주목 → 전반적으로 깔끔하고 잘짜인 프로그램

예) 날짜 인수, 복소수 인수 등 새로운 인스타입 추가

  • ArgumentMarshaler에서 새 클래스를 파생해 getXXX 함수를 추가
  • parseSchemaElement 함수에 새 case 문만 추가하면 끝
  • 필요시 ArgsException.ErrorCode를 만들고 새 오류 메시지를 추가

어떻게 짰느냐고?

한번에 뚝딱 나온 것이 아님

  • 수십 년의 교훈 : 프로그래밍은 과학보다 공예(craft)에 가깝다
  • 깨끗한 코드를 짜려면 먼저 지저분한 코드를 짠 뒤에 정리해야 한다는 의미
  • 작문도 1차 초안, 2차 초안 최종안을 만든다
  • 대부분 신참은 대다수의 초딩처럼 이 충고를 따르지않음
    • 무조건 돌아가는 프로그램 목표 → 일단 ‘돌아가면’ 다음 업무 → ‘돌아가는’ 프로그램은 상태가 어떻든 LET IT BE → 자살 행위

Args: 1차 초안

Args.java 1차 초안
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
import java.text.ParseException; 
import java.util.*;

public class Args {
private String schema;
private String[] args;
private boolean valid = true;
private Set<Character> unexpectedArguments = new TreeSet<Character>();
private Map<Character, Boolean> booleanArgs = new HashMap<Character, Boolean>();
private Map<Character, String> stringArgs = new HashMap<Character, String>();
private Map<Character, Integer> intArgs = new HashMap<Character, Integer>();
private Set<Character> argsFound = new HashSet<Character>();
private int currentArgument;
private char errorArgumentId = '\0';
private String errorParameter = "TILT";
private ErrorCode errorCode = ErrorCode.OK;

private enum ErrorCode {
OK, MISSING_STRING, MISSING_INTEGER, INVALID_INTEGER, UNEXPECTED_ARGUMENT}

public Args(String schema, String[] args) throws ParseException {
this.schema = schema;
this.args = args;
valid = parse();
}

private boolean parse() throws ParseException {
if (schema.length() == 0 && args.length == 0)
return true;
parseSchema();
try {
parseArguments();
} catch (ArgsException e) {
}
return valid;
}

private boolean parseSchema() throws ParseException {
for (String element : schema.split(",")) {
if (element.length() > 0) {
String trimmedElement = element.trim();
parseSchemaElement(trimmedElement);
}
}
return true;
}

private void parseSchemaElement(String element) throws ParseException {
char elementId = element.charAt(0);
String elementTail = element.substring(1);
validateSchemaElementId(elementId);
if (isBooleanSchemaElement(elementTail))
parseBooleanSchemaElement(elementId);
else if (isStringSchemaElement(elementTail))
parseStringSchemaElement(elementId);
else if (isIntegerSchemaElement(elementTail))
parseIntegerSchemaElement(elementId);
else
throw new ParseException(String.format("Argument: %c has invalid format: %s.",
elementId, elementTail), 0);
}
}

private void validateSchemaElementId(char elementId) throws ParseException {
if (!Character.isLetter(elementId)) {
throw new ParseException("Bad character:" + elementId + "in Args format: " + schema, 0);
}
}

private void parseBooleanSchemaElement(char elementId) {
booleanArgs.put(elementId, false);
}

private void parseIntegerSchemaElement(char elementId) {
intArgs.put(elementId, 0);
}

private void parseStringSchemaElement(char elementId) {
stringArgs.put(elementId, "");
}

private boolean isStringSchemaElement(String elementTail) {
return elementTail.equals("*");
}

private boolean isBooleanSchemaElement(String elementTail) {
return elementTail.length() == 0;
}

private boolean isIntegerSchemaElement(String elementTail) {
return elementTail.equals("#");
}

private boolean parseArguments() throws ArgsException {
for (currentArgument = 0; currentArgument < args.length; currentArgument++) {
String arg = args[currentArgument];
parseArgument(arg);
}
return true;
}

private void parseArgument(String arg) throws ArgsException {
if (arg.startsWith("-"))
parseElements(arg);
}

private void parseElements(String arg) throws ArgsException {
for (int i = 1; i < arg.length(); i++)
parseElement(arg.charAt(i));
}

private void parseElement(char argChar) throws ArgsException {
if (setArgument(argChar))
argsFound.add(argChar);
else
unexpectedArguments.add(argChar);
errorCode = ErrorCode.UNEXPECTED_ARGUMENT;
valid = false;
}

private boolean setArgument(char argChar) throws ArgsException {
if (isBooleanArg(argChar))
setBooleanArg(argChar, true);
else if (isStringArg(argChar))
setStringArg(argChar);
else if (isIntArg(argChar))
setIntArg(argChar);
else
return false;

return true;
}

private boolean isIntArg(char argChar) {
return intArgs.containsKey(argChar);
}

private void setIntArg(char argChar) throws ArgsException {
currentArgument++;
String parameter = null;
try {
parameter = args[currentArgument];
intArgs.put(argChar, new Integer(parameter));
} catch (ArrayIndexOutOfBoundsException e) {
valid = false;
errorArgumentId = argChar;
errorCode = ErrorCode.MISSING_INTEGER;
throw new ArgsException();
} catch (NumberFormatException e) {
valid = false;
errorArgumentId = argChar;
errorParameter = parameter;
errorCode = ErrorCode.INVALID_INTEGER;
throw new ArgsException();
}
}

private void setStringArg(char argChar) throws ArgsException {
currentArgument++;
try {
stringArgs.put(argChar, args[currentArgument]);
} catch (ArrayIndexOutOfBoundsException e) {
valid = false;
errorArgumentId = argChar;
errorCode = ErrorCode.MISSING_STRING;
throw new ArgsException();
}
}

private boolean isStringArg(char argChar) {
return stringArgs.containsKey(argChar);
}

private void setBooleanArg(char argChar, boolean value) {
booleanArgs.put(argChar, value);
}

private boolean isBooleanArg(char argChar) {
return booleanArgs.containsKey(argChar);
}

public int cardinality() {
return argsFound.size();
}

public String usage() {
if (schema.length() > 0)
return "-[" + schema + "]";
else
return "";
}

public String errorMessage() throws Exception {
switch (errorCode) {
case OK:
throw new Exception("TILT: Should not get here.");
case UNEXPECTED_ARGUMENT:
return unexpectedArgumentMessage();
case MISSING_STRING:
return String.format("Could not find string parameter for -%c.", errorArgumentId);
case INVALID_INTEGER:
return String.format("Argument -%c expects an integer but was '%s'.", errorArgumentId, errorParameter);
case MISSING_INTEGER:
return String.format("Could not find integer parameter for -%c.", errorArgumentId);
}
return "";
}

private String unexpectedArgumentMessage() {
StringBuffer message = new StringBuffer("Argument(s) -");
for (char c : unexpectedArguments) {
message.append(c);
}
message.append(" unexpected.");

return message.toString();
}

private boolean falseIfNull(Boolean b) {
return b != null && b;
}

private int zeroIfNull(Integer i) {
return i == null ? 0 : i;
}

private String blankIfNull(String s) {
return s == null ? "" : s;
}

public String getString(char arg) {
return blankIfNull(stringArgs.get(arg));
}

public int getInt(char arg) {
return zeroIfNull(intArgs.get(arg));
}

public boolean getBoolean(char arg) {
return falseIfNull(booleanArgs.get(arg));
}

public boolean has(char arg) {
return argsFound.contains(arg);
}

public boolean isValid() {
return valid;
}

private class ArgsException extends Exception {
}
}

사실상 초안도 아닌 미완성

  • 함수 이름, 변수이름, 나름의 구조 → 나름의 노력의 증거
  • 하지만 결과는 엉망

    책에서는 초안 이전의 그나마 나았던 코드에서 엉망이 되어가는 부분을 보여주고 있음

그래서 멈췄다

완성을 멈추다

  • 추가할 인수 유형이 적어도 2개 → 더 나쁜 코드가 되는 자명한 사실
  • 코드 리팩터링의 적기를 지금으로 판단

전체 리팩터링 진행

결론

  • 나쁜 일정은 다시 짜면 된다. 나쁜 요구사항은 다시 정의하면 된다. 나쁜 팀 역학은 복구하면 된다.
  • 하지만 나쁜 코드는 썩어 문드러진다. 점점 무게가 늘어나 팀의 발목을 잡는다
  • 서두르다 영원히 자신의 운명을 지배할 악성 코드라는 굴레를 짊어진다
  • 그저 돌아가는 코드만으로는 부족하다. 돌아가는 코드가 심하게 망가지는 사례는 흔하다. 단순히 돌아가는 코드에 만족하는 프로그래머는 전문가 정신이 부족하다.
  • 나쁜 코드보다 더 오랫동안 더 심각하게 개발 프로젝트에 악영향을 미치는 요인도 없다
  • 물론 나쁜 코드도 깨끗한 코드로 개선할 수 있다. 하지만 비용이 엄청나게 많이 든다
    • 코드가 썩어가며 모듈은 서로서로 얽히고설켜 뒤엉키고 숨겨진 의존성이 수도 없이 생긴다
    • 오래된 의존성을 찾아내 깨려면 상당한 시간과 인내심이 필요하다
  • 반면 처음부터 코드를 깨끗하게 유지하기란 상대적으로 쉽다
    • 아침에 엉망으로 만든 코드를 오후에 정리하기는 어렵지 않다
    • 더욱이 5분 전에 엉망으로 만든 코드는 지금 당장 정리하기 아주 쉽다
  • 그러므로 코드는 언제나 최대한 깔끔하고 단순하게 정리하자. 절대로 썩어가게 방치하면 안 된다.
  • 진짜 테스트 코드는 여기 있었네;; 반성
  • 프로그램을 망치는 가장 좋은 방법 중 하나 ? 개선이라는 이름 아래 구조를 크게 뒤집는 행위

Related POST

공유하기