14장 점진적인 개선
Args 구현
Args.class : 명령행 인수를 정의된 형식(schema)에 따라 파싱하고, 해당 값을 타입별로 쉽게 조회할 수 있게 해주는 유틸리티 클래스
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.printf("Argument error: %s\n", e.errorMessage());
}
}
매개변수 "l,p#,d*": 명령행 인수의 스키마. 각각의 문자는 인수의 이름과 타입을 나타낸다.
l→ boolean 값 (true/false)p#→ 정수형 인수d*→ 문자열 인수
두 번째 매개변수: 실제 명령행에서 받은 인수 배열. 파싱해서 각 타입에 맞게 저장한다.
파싱이 성공적으로 끝나면 메서드(getBoolean, getInt, getString)를 통해 인수 값을 조회할 수 있다.
Args 핵심
| 구성 요소 | 책임 |
|---|---|
Args | 스키마 해석, 인수 파싱 |
ArgumentMarshaler | 인수 유형 인터페이스 |
| 실제 파싱 로직 | |
ArgsException | 오류 처리 |
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<>();
argsFound = new HashSet<>();
parseSchema(schema);
parseArgumentStrings(Arrays.asList(args));
}
marshalers: 각 인수 이름(l,p,d)에 대응하는 처리 객체를 저장하는 MapargsFound: 실제 입력에서 발견된 인수 목록을 추적하는 SetcurrentArgument: 인수를 순회하며 파싱할 때 사용
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);
}
}
- 형식 문자열은 쉼표로 구분된 각 인수 정의(
l,p#,d*)로 나눈다. parseSchemaElement()는 각 정의를 파싱하여 해당 타입에 맞는ArgumentMarshaler를marshalers에 등록한다.
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;
}
}
실제 명령행 인수를 순회하면서 -로 시작하는 항목을 발견하면 해당 인수 문자를 분해해 파싱한다.
p 8080이라면p를 찾아서IntegerArgumentMarshaler에 값을 설정한다.
ArgumentMarshaler 인터페이스와 파생 클래스
public interface ArgumentMarshaler {
void set(Iterator<String> currentArgument) throws ArgsException;
}
모든 인수 타입은 이 인터페이스를 구현한다.
각 타입별 구현 클래스는 다음과 같다:
BooleanArgumentMarshaler: 단순히true로 설정StringArgumentMarshaler: 다음 인수를 문자열로 저장IntegerArgumentMarshaler: 다음 인수를 정수로 변환DoubleArgumentMarshaler: 다음 인수를 실수로 변환StringArrayArgumentMarshaler: 문자열 배열로 저장 ([*]형식)
각 클래스는 getValue()라는 static 메서드로 결과를 조회할 수 있도록 한다.
독자는 코드를 위에서 아래로 순차적으로 읽을 수 있다.
ArgsException : 에러 처리
public class ArgsException extends Exception {
// ...
public enum ErrorCode {
OK, INVALID_ARGUMENT_FORMAT, UNEXPECTED_ARGUMENT, INVALID_ARGUMENT_NAME,
MISSING_STRING, MISSING_INTEGER, INVALID_INTEGER, MISSING_DOUBLE, INVALID_DOUBLE
}
public String errorMessage() {
switch (errorCode) {
case OK: return "TILT: Should not get here.";
case UNEXPECTED_ARGUMENT: return String.format("Argument -%c unexpected.", errorArgumentId);
// 생략...
}
}
}
에러 상황을 명확히 구분하기 위해 열거형 ErrorCode를 사용하며, 사용자에게 전달할 수 있는 에러 메시지
설계 특징
- 가독성이 좋다: 위에서 아래로 순차적으로 읽히며 흐름이 자연스럽다.
- 확장에 유리하다: 새 인수 유형을 추가하려면
ArgumentMarshaler를 확장하고parseSchemaElement()에 한 줄만 추가하면 된다. - 책임 분리가 명확하다: 스키마 파싱, 인수 파싱, 타입별 처리, 예외 처리 등이 잘 나뉘어 있다.
어떻게 짰느냐고?
프로그래밍은 과학이라기보다는 공예에 가깝다.
처음부터 깔끔한 코드가 나오는 경우는 거의 없다. 일단 동작하는 지저분한 코드를 짠 뒤, 계속해서 다듬고 정리해서 최종 버전을 만든다.
대부분의 초보자는 코드가 “돌아가기만” 하면 만족하고 다음 단계로 넘어간다.
하지만 숙련된 개발자는 돌아가는 코드가 아니라, 읽기 좋고 고치기 좋은 코드가 진짜 코드라는 걸 안다.
Args: 1차 초안
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 {
}
}
그래서 멈췄다
- 타입별 처리 로직이 스키마 파싱, 인수 파싱, 값 조회의 세 곳에 중복되어 있음
- 새로운 타입을 추가하려면 세 군데 이상 수정해야 함
Map<Character, Type>이 여러 개 존재해 응집도가 낮고 중복됨ArgsException이 실질적인 정보 전달에 활용되지 않음
인수 유형은 다양하지만 모두가 유사한 메서드를 제공. 👉 인터페이스스 ArgumentMarshaler 탄생
점진적으로 개선하다
프로그램을 망치는 가장 빠른 방법 중 하나는 ‘개선’이라는 이름 아래 구조를 무리하게 뒤엎는 것이다. 그렇게 구조를 크게 바꾼 프로그램은 대개 다시는 정상으로 돌아오지 못한다. 변경 후에도 시스템이 이전과 정확히 같은 방식으로 동작하는 것이 정말 어려워지기 때문이다.
👉 테스트 주도 개발(TDD)
TDD는 항상 시스템이 정상적으로 동작해야 한다는 원칙을 기반으로 한다. 즉, 어떤 변경을 하든 간에 프로그램은 여전히 잘 돌아가야 한다. 변경 이후에도 프로그램이 이전과 동일한 결과를 보장해야만 한다.
이걸 보장하려면 언제든 실행 가능한 자동화 테스트가 필수다.
리팩토링을 할 때는 코드를 최소로 건드리는, 가장 단순한 변경 부터 시작한다. 구조 변경을 최소화하면서도 코드를 점점 더 좋은 방향으로 개선해 나가도 TDD 덕분에 언제든 테스트로 변경이 안전함을 검증할 수 있었고, 덕분에 과감한 구조 개선도 시도할 수 있다.
Map<Character, Boolean>→Map<Character, ArgumentMarshaler>로 변경
// 전: boolean 값을 직접 저장
private Map<Character, Boolean> booleanArgs = new HashMap<>();
// 후: ArgumentMarshaler 객체 저장
private Map<Character, ArgumentMarshaler> booleanArgs = new HashMap<>();
// 전: 스키마 요소 파싱 시 true로 저장
private void parseBooleanSchemaElement(char elementId) {
booleanArgs.put(elementId, false); // 기본값
}
// 후: BooleanArgumentMarshaler 객체 저장
private void parseBooleanSchemaElement(char elementId) {
booleanArgs.put(elementId, new BooleanArgumentMarshaler());
}
// 전: 값 설정
private void setBooleanArg(char argChar, boolean value) {
booleanArgs.put(argChar, value);
}
// 후: BooleanArgumentMarshaler 내부에 값 설정
private void setBooleanArg(char argChar, boolean value) {
booleanArgs.get(argChar).setBoolean(value);
}
// 전: 값 조회
public boolean getBoolean(char arg) {
Boolean value = booleanArgs.get(arg);
return value != null && value;
}
// 후: BooleanArgumentMarshaler에서 값 꺼내기
public boolean getBoolean(char arg) {
ArgumentMarshaler am = booleanArgs.get(arg);
return am != null && am.getBoolean();
}
String 인수
나머지 인수 추가 과정은 boolean 인수 처리 방식과 유사하다.
- 우선
HashMap에 관련 정보를 저장하도록 하고,parse,set,get함수를 수정 - 모든 인수 유형에 대한 처리를
ArgumentMarshaler에 넣고, - 파생 클래스로 분리
이렇게 하면 프로그램 구조를 점진적으로 개선하면서도 정상 동작을 유지할 수 있다.
기존 예외 코드의 문제
- 코드가 흉하고 읽기 어렵다.
- Args 클래스와 무관한 책임을 떠안고 있다.
ParseException을 던지지만, 이는Args모듈에 소속되지 않는다.
👉 모든 예외 처리를 ArgsException이라는 독립 클래스로
Args모듈과 예외/오류 처리 로직을 분리
public class ArgsException extends Exception {
private char errorArgumentId = '\0';
private String errorParameter = "TILT";
private ErrorCode 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() throws Exception {
switch (errorCode) {
case OK:
throw new Exception("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);
}
return "";
}
public enum ErrorCode {
OK, INVALID_FORMAT, UNEXPECTED_ARGUMENT, INVALID_ARGUMENT_NAME, MISSING_STRING,
MISSING_INTEGER, INVALID_INTEGER,
MISSING_DOUBLE, INVALID_DOUBLE
}
}
- 에러 코드(
ErrorCode)를 열거형으로 정의. - 인수 식별자, 파라미터 값을 저장.
- 오류 메시지를 생성하는
errorMessage()메서드 포함. - SRP(Single Responsibility Principle) 준수
결론
그저 돌아가기만 하는 코드는 부족하다.
나쁜 코드보다 개발 프로젝트에 더 심각한 악영향을 끼친다.
- 나쁜 일정은 다시 계획하면 된다.
- 나쁜 요구사항은 다시 정의하면 된다.
- 나쁜 팀 역학은 복구할 수 있다.
시간이 지날수록 무게가 늘어나고 팀의 발목을 잡는다.
물론 나쁜 코드도 깨끗한 코드로 바꿀 수는 있지만 그 비용은 크다.
- 코드가 썩어가면서 뒤엉키는 모듈들
- 수없이 생기는 숨겨진 의존성
- 그 의존성을 찾아 깨는 데는 막대한 시간과 인내
처음부터 깨끗하게 유지하는 것은 상대적으로 쉽다.
아침에 엉망으로 만든 코드를 오후에 정리하는 건 어렵지 않다. 더 나아가, 5분 전에 만든 엉망진창 코드는 바로 지금 정리하는 게 가장 쉽다.
코드는 언제나 최대한 깔끔하고 단순하게 유지해야 한다. 절대로 썩어가도록 방치해서는 안 된다.