프로그램을 짜다 보면 종종 명령행 인수의 구문을 분석할 필요가 생긴다. 편리한 유틸리티가 없다면 main 함수로 넘어오는 문자열 배열을 직접 분석하게 된다. 여러 가지 훌륭한 유틸리티가 있지만 내 사정에 딱 맞는 유틸리티가 없다면 직접 구현해야 한다.
간단한 예로 직접 구현할 유틸리티 Args를 작성해 보자
[14-1] Args 사용법
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 클래스의 인스턴스를 생성할 수 있다.
- 첫 번째 매개변수 “l,p#,d#”는 형식 또는 스키마를 지정한다.
- 첫 번째 -l은 부울 인수, 두 번째 -p는 정수 인수, 세 번째 인수 -d는 문자열 인수다.
- Args 생성자로 넘긴 둘째 매개변수는 main으로 넘어온 명령행 인수 배열 자체다.
- 형식 문자열이나 명령행 문자열 인수에 문제가 있다면 ArgsException이 발생한다.
Args 구현
다음은 저자가 직접 작성한 Args 클래스의 구현 코드로 스타일과 구조에 신경 썼으므로 주의 깊게 읽어보자.
[14-2] Args.java
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));
}
}
- Args 클래스에 대한 구현 코드다.
- 눈 여겨볼 부분은 ArgumentMarshaler를 정의하는 부분으로 ArgumentMarshaler 인터페이스와 구현체들이다. 이들을 정의하는 코드는 [14-3]부터 [14-8]까지 따로 분리하여 정의한다.
[14-3] ArgumentMarshaler.java
public interface ArgumentMarshaler {
void set(Iterator<String> currentArgument) throws ArgsException;
}
[14-4] BooleanArgumentMarshaler.java
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;
}
}
[14-5] StringArgumentMarshaler.java
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 "";
}
}
[14-6] IntegerArgumentMarshaler.java
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;
}
}
[14-7] DoubleArgumentMarshaler.java
public class DoubleArgumentMarshaler implements ArgumentMarshaler {
private int doubleValue = 0;
public void set(Iterator<String> currentArgument) throws ArgsException {
String parameter = null;
try {
parameter = currentArgument.next();
doubleValue = Integer.parseInt(parameter);
} catch (NoSuchElementException e) {
throw new ArgsException(MISSING_DOUBLE);
} catch (NumberFormatException e) {
throw new ArgsException(INVALID_DOUBLE, parameter);
}
}
public static int getValue(ArgumentMarshaler am) {
if (am != null && am instanceof IntegerArgumentMarshaler)
return ((DoubleArgumentMarshaler) am).doubleValue;
else
return 0;
}
}
[14-8] StringArrayArgumentMarshaler.java
public class StringArrayArgumentMarshaler implements ArgumentMarshaler {
private List<String> strings = new ArrayList<String>();
public void set(Iterator<String> currentArgument) throws ArgsException {
try {
strings.add(currentArgument.next());
} catch (NoSuchElementException e) {
throw new ArgsException(MISSING_STRING);
}
}
public static String[] getValue(ArgumentMarshaler am) {
if (am != null && am instanceof StringArrayArgumentMarshaler)
return ((StringArrayArgumentMarshaler) am).strings.toArray(new String[0]);
else
return new String[0];
}
}
[14-9] ArgsException.java
다음은 예외를 정의하는 부분이다.
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() {
return switch (errorCode) {
case OK -> "TILT: Should not get here.";
case UNEXPECTED_ARGUMENT -> String.format("Argument -%c unexpected.", errorArgumentId);
case MISSING_STRING -> String.format("Could not find string parameter for -%c.", errorArgumentId);
case INVALID_INTEGER -> String.format("Argument -%c expects an integer but was '%s'.", errorArgumentId, errorParameter);
case MISSING_INTEGER -> String.format("Could not find integer parameter for -%c.", errorArgumentId);
case INVALID_DOUBLE -> String.format("Argument -%c expects a double but was '%s'.", errorArgumentId, errorParameter);
case MISSING_DOUBLE -> String.format("Could not find double parameter for -%c.", errorArgumentId);
case INVALID_ARGUMENT_NAME -> String.format("'%c' is not a valid argument name.", errorArgumentId);
case INVALID_ARGUMENT_FORMAT -> String.format("'%s' is not a valid argument format.", errorParameter);
};
}
public enum ErrorCode {
OK, INVALID_ARGUMENT_FORMAT, UNEXPECTED_ARGUMENT, INVALID_ARGUMENT_NAME,
MISSING_STRING, MISSING_INTEGER, INVALID_INTEGER, MISSING_DOUBLE, INVALID_DOUBLE
}
}
- 위 코드들은 단순한 개념을 구현하는 코드지만 상당히 많은 코드로 인해 복잡해 보일 수 있다.
- 위 코드를 다시 확인하여 명명법, 함수 크기, 코드 형식 등에 주목하기 바란다.
- 전반적으로 깔끔한 구조에 잘 짜인 프로그램이다.
- 날짜 인수나 복소수 인수 등 새로운 인수 유형을 추가하는 방법이 명백하다.
- 고칠 코드가 별로 없다. (ArgumentMarshaler에서 새 클래스를 파생해 getter를 추가한 후 parseSchemaElement 함수에 새 case 문만 추가하면 끝이다.)
- 요구에 따라 새로운 ArgsException.ErrorCode를 만들고 새 오류 메시지를 추가할 수 있다.
어떻게 짰느냐고?
저자는 위 코드와 같은 구조는 처음부터 구현하지 않았다. 또한, 우리에게 깨끗하고 우아한 프로그램을 한 번에 작성하리라고 기대하지 않는다.
프로그래밍은 과학보다 공예(craft)에 가깝다. 깨끗한 코드를 짜려면 먼저 지저분한 코드를 짠 뒤에 정리해야 한다.
- 먼저 1차 초안을 쓰고, 그 초안을 고쳐 2차 초안을 만들고, 계속 고쳐 최종안을 만든다.
- 깔끔한 작품을 내놓으려면 단계적으로 개선해야 한다.
하지만, 대다수 개발자들은 먼저 돌아가는 프로그램을 목표로 잡고 개발 한 뒤에는 다음 업무로 넘어간다. 돌아가는 프로그램은 그 상태가 어떻든 그대로 버려둔다. 경험이 풍부한 개발자는 이런 행동이 전문가로서 자살 행위라는 사실을 잘 안다.
Args: 1차 초안
[14-10] Args.java
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<>();
private Map<Character, Boolean> booleanArgs = new HashMap<>();
private Map<Character, String> stringArgs = new HashMap<>();
private Map<Character, Integer> intArgs = new HashMap<>();
private Set<Character> argsFound = new HashSet<>();
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 {
}
}
- 처음 코드를 작성했을 때는 이처럼 지저분한 코드로 작성되었다.
- 인스턴스 변수 개수만 해도 상당히 많다.
- “TILT”와 같은 의미를 알기 어려운 문자열, HashSets와 TreeSets, try-catch-catch 블록 등 모두가 지저분한 코드에 기여하는 요인이다.
위 코드는 실제로 어느 정도 손보려 애쓴 흔적이 있다. 함수 이름이나 변수 이름을 선택한 방식, 나름대로 구조가 있다는 사실 등이다. Boolean 인수만 지원하던 초기 버전을 작성할 때는 이처럼 코드가 엉망이지는 않았다.
하지만 String과 Integer 등 여러 인수 유형이 추가되면서 코드는 지저분해졌다. 적당히 유지보수가 가능했던 코드는 버그와 결함이 숨어있을지도 모른다는 상당히 의심스러운 코드로 뒤바뀌어 버린 것이다.
그래서 멈췄다
추가할 인수 유형이 두 개는 더 있었으나 코드가 훨씬 더 나빠지리라는 사실이 자명했다. 코드 구조를 유지보수하기 좋은 상태로 만들려면 지금이 적기라 판단했다. 저자는 기능을 더 이상 추가하지 않기로 결정하고 리팩토링을 시작했다.
새로운 인수 유형을 추가하려면 주요 지점 세 곳에 코드를 추가해야 한다.
- HashMap을 선택하기 위해 스키마 요소의 구문을 분석한다.
- 명령행 인수에서 인수 유형을 분석해 진짜 유형으로 변환한다.
- getter 메서드를 구현해 호출자에게 진짜 유형을 반환한다.
인수 유형은 다양하지만 모두 유사한 메서드를 제공하므로 클래스 하나가 적합하다고 판단했다. 그렇게 추가된 개념이 ArgumentMarshaler이다.
점진적으로 개선하다
프로그램을 망치는 방법 중 하나는 개선이라는 이름 아래 구조를 크게 뒤집는 행위다. 어떤 프로그램은 그저 그런 ‘개선’에서 결코 회복하지 못한다. ‘개선’ 전과 똑같이 프로그램을 돌리기가 아주 어렵기 때문이다.
- 저자는 테스트 주도 개발(TDD) 기법을 사용했다.
- TDD는 언제 어느 때라도 시스템이 돌아가야 한다는 원칙을 따른다.
- TDD는 시스템을 망가뜨리는 변경을 허용하지 않는다.
- 변경을 한 후에도 시스템이 변경되기 전과 똑같이 돌아가야 한다.
변경 전후에 시스템이 똑같이 돌아간다는 사실을 확인하려면 언제든 실행이 가능한 자동화된 테스트 슈트가 필요하다. 저자는 앞서 Args 클래스를 구현하는 동안에 단위 테스트와 인수 테스트를 작성해 놓았었다. 따라서 다음과 같이 시스템에 자잘한 변경을 가하기 시작했다.
[14-11] Args.java에 추가한 ArgumentMarshaler
private class ArgumentMarshaler {
private boolean booleanValue = false;
public void setBoolean(boolean value) {
booleanValue = value;
}
public boolean getBoolean() {
return booleanValue;
}
}
private class BooleanArgumentMarshaler extends ArgumentMarshaler {
}
private class StringArgumentMarshaler extends ArgumentMarshaler {
}
private class IntegerArgumentMarshaler extends ArgumentMarshaler {
}
- 개선하기 전 코드에 ArgumentMarshaler 클래스를 추가하였다.
- 이후 코드를 최소로 건드리는, 가장 단순한 변경을 가했다.
Boolean 인수를 저장하는 HashMap에서 Boolean 타입을 ArgumentMarshaler 타입으로 변경했다.
public class Args {
...
private Map<Character, ArgumentMarshaler> booleanArgs = new HashMap<>();
...
private void parseBooleanSchemaElement(char elementId) {
booleanArgs.put(elementId, new BooleanArgumentMarshaler());
}
...
private void setBooleanArg(char argChar, boolean value) {
booleanArgs.get(argChar).setBoolean(value);
}
...
public boolean getBoolean(char arg) {
return falseIfNull(booleanArgs.get(arg).getBoolean());
}
...
}
이후 String 인수를 추가하는 과정은 boolean 인수와 매우 유사했다. HashMap을 변경한 후 parse, set, get 함수를 고친다. 각 인수 유형을 처리하는 코드를 모두 ArgumentMarshaler 클래스에 넣고, ArgumentMarshaler 파생 클래스를 만들어 코드를 분리했다.
소프트웨어 설계는 분할만 잘해도 품질이 크게 높아진다. 적절한 장소를 만들어 코드만 분리해도 설계가 좋아진다. 관심사를 분리하면 코드를 이해하고 보수하기 훨씬 더 쉬워진다.
결론
그저 돌아가는 코드만으로는 부족하다. 돌아가는 코드가 심하게 망가지는 사례는 흔하다. 단순히 돌아가는 코드에 만족하는 개발자는 전문가 정신이 부족하다. 설계와 구조를 개선할 시간이 없다는 것은 변명일 뿐이다. 나쁜 코드보다 더 오랫동안 더 심각하게 개발 프로젝트에 악영향을 미치는 요인은 없기 때문이다.
나쁜 코드도 깨끗한 코드로 개선할 수 있다. 하지만 비용이 엄청나게 많이 든다. 나쁜 코드를 깨끗한 코드로 개선하는 것은 상당한 시간과 인내심이 필요하다. 반면 처음부터 코드를 깨끗하게 유지하기란 상대적으로 쉽다. 아침에 엉망으로 만든 코드를 오후에 정리하기는 어렵지 않다.
그러므로 코드는 언제다 최대한 깔끔하고 단순하게 정리하자.
https://link.coupang.com/a/bfFqVj
https://link.coupang.com/a/bfFq3g
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."
'IT Book > 클린 코드(Clean Code)' 카테고리의 다른 글
[Clean Code] 15. Junit 들여다보기 - 클린 코드 정독하기 (1) | 2023.11.19 |
---|---|
[Clean Code] 13. 동시성 - 클린 코드 정독하기 (3) | 2023.10.23 |
[Clean Code] 12. 창발성 - 클린 코드 정독하기 (0) | 2023.09.12 |
[Clean Code] 11. 시스템 - 클린 코드 정독하기 (1) | 2023.09.11 |
[Clean Code] 10. 클래스 - 클린 코드 정독하기 (0) | 2023.08.29 |