Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

저자가 신경 예제 코드로 구조를 면밀히 살핀다.

명령행 인수 구문을 분석하는 프로그램 Args

 

Args는 생성자로 인수 형식 문자열과 명령행 인수를 받는다.

Args인스턴스로 인수 값을 질의한다.

public class Main {
  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());
    }
  }
  private static void executeApplication(boolean logging, int port, String directory) {
    /* 예제 없음....*/
  }
}

 

Args 구현

 

package chap14.ex01;


import static chap14.ex01.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));
  }
}

 

구조가 위에서 아래로 읽힌다. 스크롤을 사용해 이리저리 이동하지 않는다.

import java.util.*;
public interface ArgumentMarshaler {
  void set(Iterator<String> currentArgument) throws ArgsException;
}
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;
  }
}
import static chap14.ex01.ArgsException.ErrorCode.*;
import java.util.*;


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 "";
  }
}
import static chap14.ex01.ArgsException.ErrorCode.*;
import java.util.*;


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;
  }
}
import static chap14.ex01.ArgsException.ErrorCode.*;
import java.util.*;


public class DoubleArgumentMarshaler implements ArgumentMarshaler {
  private double doubleValue = 0;
  public void set(Iterator<String> currentArgument) throws ArgsException {
    String parameter = null;
    try {
      parameter = currentArgument.next();
      doubleValue = Double.parseDouble(parameter);
    } catch (NoSuchElementException e) {
      throw new ArgsException(MISSING_DOUBLE);
    } catch (NumberFormatException e) {
      throw new ArgsException(INVALID_DOUBLE, parameter);
    }
  }
  public static double getValue(ArgumentMarshaler am) {
    if (am != null && am instanceof DoubleArgumentMarshaler)
      return ((DoubleArgumentMarshaler) am).doubleValue;
    else
      return 0.0;
  }
}
import static chap14.ex01.ArgsException.ErrorCode.*;
import java.util.*;


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

 

오류 코드

import static chap14.ex01.ArgsException.ErrorCode.*;
@SuppressWarnings("serial")
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
  }
}

단순한 개념이나 코드가 많은 이유는 자바는 정적 타입 언어이기 때문이다.

동적 타입 언어라면 코드량은 획기적으로 줄어든다.

 

저자가 공들인 예제로 천천히 꼼꼼히 보는 것이 좋다.

이름, 함수 크기, 코드 형식, 새로운 코드 추가 기존 코드에 거의 영향 없음(팩토리 같은 코드에만 영향)

 

어떻게 짰느냐고?

번에 위와 같은 깥끔한 코드를 구현하는 것은 전문가도 불가능하다.

깨끗한 코드를 짜려면 먼저 지저분한 코드를 정리해야 한다.

 

문서에 초안을 작성하고, 퇴고하는 것과 같다.

개발도, 일단 목적에 맞는 돌아가는 코드를 목표로 하고, 이후 정리를 한다.

대부분의 개발자는 돌아만 가는 코드만 구현하고 정리를 하지 않는다.

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<>();
  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))
      parseBooleanSchemaFlement(elementId);
    else if (isStringSchemaElement(elementTail))
      parseStringSchemaFlement(elementId);
    else if (isIntegerSchemaElement(elementTail)) {
      parseIntegerSchemaFlement(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 parseBooleanSchemaFlement(char elementId) {
    booleanArgs.put(elementId, false);
  }


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


  private void parseStringSchemaFlement(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 argCha) throws ArgsException {
    currentArgument++;
    try {
      stringArgs.put(argCha, args[currentArgument]);
    } catch (ArrayIndexOutOfBoundsException e) {
      valid = false;
      errorArgumentId = argCha;
      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 {
  }
}

1 초안은 말장난이다. 사실 미완성이다.

인스턴스 변수 수도 많고, 'TILT' 의미도 해석할 없다.

 

최초 Boolean 인수만 지원하던 초기 버전

package chap14.ex03;
import java.util.*;
public class Args {
  private String schema;
  private String[] args;
  private boolean valid;
  private Set<Character> unexpectedArguments = new TreeSet<>();
  private Map<Character, Boolean> booleanArgs = new HashMap<>();
  private int numberOfArguments = 0;


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


  public boolean isValid() {
    return valid;
  }
  private boolean parse() {
    if (schema.length() == 0 && args.length == 0)
      return true;
    parseSchema();
    parseArguments();
    return unexpectedArguments.size() == 0;
  }
  private boolean parseSchema() {
    for (String element : schema.split(",")) {
      parseSchemaElement(element);
    }
    return true;
  }
  private void parseSchemaElement(String element) {
    parseBooleanSchemaFlement(element);
  }
  private void parseBooleanSchemaFlement(String element) {
    char c = element.charAt(0);
    if (Character.isLetter(c)) {
      booleanArgs.put(c, false);
    }
  }
  private boolean parseArguments() {
    for (String arg : args)
      parseArgument(arg);
    return true;
  }
  private void parseArgument(String arg) {
    if (arg.startsWith("-"))
      parseElements(arg);
  }
  private void parseElements(String arg) {
    for (int i = 1; i < arg.length(); i++)
      parseElement(arg.charAt(i));
  }
  private void parseElement(char argChar) {
    if (isBoolean(argChar)) {
      numberOfArguments++;
      setBooleanArg(argChar, true);
    } else
      unexpectedArguments.add(argChar);
  }
  private boolean isBoolean(char argChar) {
    return booleanArgs.containsKey(argChar);
  }


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


  public int cardinality() {
    return numberOfArguments;
  }


  public String usage() {
    if (schema.length() > 0)
      return "-[" + schema + "]";
    else
      return "";
  }
  public String errorMessage() {
    if (unexpectedArguments.size() > 0) {
      return unexpectedArgumentMessage();
    } else
      return "";
  }
  private String unexpectedArgumentMessage() {
    StringBuffer message = new StringBuffer("Argument(s) -");
    for (char c : unexpectedArguments) {
      message.append(c);
    }
    message.append(" unexpected.");
    return message.toString();
  }


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

정도만 되어도 단순하고, 이해가 쉽다.

하지만 코드는 나중에 엉망으로 변할 여지가 숨겨져 있다.

이유로 코드가 점점 더러워진 것이다.

 

하나 String 기능 추가

import java.text.ParseException;
import java.util.*;
public class Args {
  private String schema;
  private String[] args;
  private boolean valid;
  private Set<Character> unexpectedArguments = new TreeSet<>();
  private Map<Character, Boolean> booleanArgs = new HashMap<>();
  private Map<Character, String> stringArgs = new HashMap<>();
  private Set<Character> argsFound = new HashSet<>();
  private int currentArgument;
  private char errorArgument = '\0';


  enum ErrorCode{
    OK, MISSING_STRING
  }


  private ErrorCode errorCode = ErrorCode.OK;
  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();
    parseArguments();
    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))
      parseBooleanSchemaFlement(elementId);
    else if (isStringSchemaElement(elementTail))
      parseStringSchemaFlement(elementId);
  }


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


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


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


  private void parseBooleanSchemaFlement(char elementId) {
    booleanArgs.put(elementId, false);
  }
  private boolean parseArguments() {
    for (currentArgument = 0; currentArgument < args.length; currentArgument++) {
      String arg = args[currentArgument];
      parseArgument(arg);
    }
    return true;
  }
  private void parseArgument(String arg) {
    if (arg.startsWith("-"))
      parseElements(arg);
  }
  private void parseElements(String arg) {
    for (int i = 1; i < arg.length(); i++)
      parseElement(arg.charAt(i));
  }
  private void parseElement(char argChar) {
    if (setArgument(argChar))
      argsFound.add(argChar);
    else {
      unexpectedArguments.add(argChar);
    }
  }
  private boolean setArgument(char argChar) {
    boolean set = true;
    if (isBoolean(argChar))
      setBooleanArg(argChar, true);
     
    else if (isString(argChar))
      setStringArg(argChar, "");
    else
      set = false;
   
    return set;
  }
  private void setStringArg(char argChar, String s) {
    currentArgument++;
    try {
      stringArgs.put(argChar, args[currentArgument]);
    } catch (ArrayIndexOutOfBoundsException e) {
      valid = false;
      errorArgument = argChar;
      errorCode = ErrorCode.MISSING_STRING;
    }
  }


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


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


  private boolean isBoolean(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{
    if (unexpectedArguments.size() > 0) {
      return unexpectedArgumentMessage();
    } else
      switch (errorCode) {
      case MISSING_STRING:
        return String.format("Could not find string parameter for -%c",
            errorArgument);
      case OK:
        throw new Exception("TILT: Should not get here.");
      }
    return "";
  }
  private String unexpectedArgumentMessage() {
    StringBuffer message = new StringBuffer("Argument(s) -");
    for (char c : unexpectedArguments) {
      message.append(c);
    }
    message.append(" unexpected.");
    return message.toString();
  }


  public boolean getBoolean(char arg) {
    return falseIfNull(booleanArgs.get(arg));
  }
  private boolean falseIfNull(Boolean b) {
    return b == null ? false : b;
  }


  public String getString(char arg) {
    return blankIfNull(stringArgs.get(arg));
  }
  private String blankIfNull(String s) {
    return s == null ? "" : s;
  }


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


  public boolean isValid() {
    return valid;
  }
}

코드가 통제를 벗어나기 시작한다.

코드가 엄청나게 지저분해져 버그가 숨어들지도 모르는 코드로 변했다.

그래서 멈췄다

인수 유형이 추가되면 더욱 엉망이 된다. 그러면 더욱 유지보수하기 힘들어 진다.

리팩터링이 필요하다.

분석

신규 인수 유형 추가 곳에 코드를 추가해야 한다.

  • 인수 유형 HashMap
  • 명령행 인수에서 인수 유형 분석
  • get*, set* 메서드

 

인수 유형은 다양하지만 모두가 유사한 메서드를 제공한다. 따라서 클래스 하나로 만든다. ArgumentMarchaler

 

점진적으로 개선하다

프로그램을 망치는 방법 하나는 개선이라는 이름에 아래 이전과 다르게 구조를 크게 뒤집는 행위다.

 

TDD(테스트 주도 개발) 기법을 사용해, 점진적으로 개선해야 한다.

TDD에선 언제라도 시스템이 돌아가야 한다.

, 변경 후에 변경 전과 똑같이 돌아가야 한다.

규칙을 준수하려면, 번에 크게 뜯어 고칠 수가 없다.

 

전과 같음을 보증하려면, 자동화된 테스트 슈트가 필요하다.

리팩터링을 하면서, 조금에 변경에도 테스트를 구동한다.

 

리팩터링을 하는 중요한 일이 생겨 그만 둬도 시스템은 전과 같이 동작할 것이다.

변경 마다 테스트를 돌렸으니까

 

ArgumentMarshaler 골격 추가

  /**  
   * ###### Args 내부 ######
   * 편의상 Args 내부 가장 끝 자리에 추출할 클래스를 정의했다.
   * 사용하지 않으니, 기존 시스템에 영향은 전혀 없다.        
   */
  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 {
   
  }
}

 

점진적 변경

  • 인수 유형 HashMap
  • 명령행 인수에서 인수 유형 분석
  • get*, set* 메서드

 

 

 

오류가 나는 곳을 수정한다.

 

 

나머지도 동일하게 알맞게 수정한다

 

 

이제 falseIfNull 메서드는 필요가 없다.

null 존재할 없기 때문이다. 따라서 제거한다.

 

 

String 인수

인수 추가 변경될

  • 인수 유형 HashMap
  • 명령행 인수에서 인수 유형 분석
  • get*, set* 메서드

 

 

 

똑같이 변경하다보면, 필요한 메서드가 생긴다.

IDE 기능을 이용해 메서드를 생성하고, 구현하면 된다.

여기서 코드가 자동 생성되는 위치가 ArgumentMarshaler 이유는 지네릭이 Map<Character, ArgumentMarshaler> 으로 get() 메서드로 반환되는 타입이 ArgumentMarshaler 이기 때문이다.

추가로 반대의 경우인 put()메서드로 객체를 저장할 서브타입은 ArgumentMarshaler로 형변환되어 저장된다.

 

노란색 경고는 아직 호출되는 곳이 없어서 그렇다.

 

전과 같은 이유로 blankIfNull 메서드는 불필요하므로 제거한다.

 

서브 클래스가 아닌 ArgumentMarshaler 슈퍼 클래스에 일단 전부 코드를 모은 이유는 모두 기능을 옮긴 다시 리팩터링해 파생 클래스로 코드를 분리하기 위함이다.

과정이 늘어나지만, 쉽고, 점진적이고, 안전하게 리팩터링할 있다.

 

Integer 똑같이 처리한다.

 

 

 

역시나 불필요한 체크 메서드를 제거했다.

String 제외하고 사실 기본형은 기본값으로 초기화되기 때문에 booleanValue = false 사실 필요없다.

 

로직을 집중시킨 ArgumentMarshaler

  private class ArgumentMarshaler {
    private boolean booleanValue = false;
    private String stringValue;
    private int integerValue;
   
    public void setBoolean(boolean value) {
      booleanValue = value;
    }
   
    public void setInteger(int i) {
      integerValue = i;
    }
   
    public int getInteger() {
      return integerValue;
    }
    public void setString(String s) {
      stringValue = s;
    }
   
    public String getString() {
      return stringValue == null ? "" : stringValue;
    }
    public boolean getBoolean() {
      return booleanValue;
    }
  }

 

이제 파생 클래스로 옮길 차례다.

boolean

 

추상 클래스로 만들고 다형성 활용을 위한 추상 메서드 set 정의한다.

그리고 옮길 대상 값을 파생 클래스에서 접근할 있게 protected 변경한다.

 

setBoolean() 메서드를 set() 대체하도록 한다.

 

 

 

이제 불필요한 ArgumentMarshaler.setBoolean() 메서드를 제거한다.

BooleanArgumentMarshaler.set() 메서드는 사실 인수가 필요가 없다.

기본으로 false , set() 호출할 경우는 true 바꿀때 밖에 없기 때문이다.

다른 파생 클래스는 인자가 필요하다.

 

 

불필요한 형변환이 늘어나지만, 이렇게 해도 된다.

 

이제 get()메서드를 구현해야 한다. get() 메서드는 구현하기 훨씬 까다롭다.

반환 타입이 여러 개라 Object 받환해야 한다.

문제는 호출하는 코드에서 적절한 형변환 코드가 필요하다. 이게 까다롭다.

 

ArgumentMarshaler

 

 

Args

 

ArgumentMarshaler

 

이제 마지막으로 boolean 필드를 파생 클래스로 내린다.

 

 

 

String

과정은 동일하다.

Args

 

 

 

 

Integer

Args

 

 

 

 

IntegerArgumentMarshaler 경우 파싱과정에서 예외가 발생할 있어 구현이 복잡했다.

 

사용자 정의 예외로 바꾼다.

 

일단 오류는 무시한다. 현재 try 구문에서 사용하는 메서드에서 절대로 ArgsException 던지지 않기 때문에 IDE 경고를 내보내는 것이다.

 

 

 

별도 Map 점진적 이관

Args

새로 추가한 맵에 동일하게 저장한 뿐이다. 기존 코드 동작에 영향은 없다는 점에 주목한다.

 

 

marshalers.get(argChar) 코드 중복이 보인다.

호출하는 곳을 추적해 중복을 제거한다.

Args

 

 

중복을 제거했다. 이후 코드를 보면 이상 "isXXXArg" 메서드를 굳이 호출할 필요가 없어 메서드를 인라인했다.

인라인 또한 IDE 도움을 받는다.

 

 

이제 개별 map 사용에서 공통 map 사용하도록 리팩터링한다. Boolean 부터 시작

 

나머지도 동일하게 변경한다.

 

 

Args.getBoolean() 리팩터링

 

이제 booleanArgs Map 사용되는 곳은 없다.

booleanArgs  코드를 제거한다.

 

 

 

나머지 인수유형도 리팩토링

 

 

 

marshalers 맵만 남았다.

 

parseXXXXSchemaFlement() 메서드도 굳이 선언할 필요 없어 인라인한다.

번째 리팩터링 완료

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, ArgumentMarshaler> marshalers = 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))
      marshalers.put(elementId, new BooleanArgumentMarshaler());
    else if (isStringSchemaElement(elementTail))
      marshalers.put(elementId, new StringArgumentMarshaler());
    else if (isIntegerSchemaElement(elementTail)) {
      marshalers.put(elementId, new IntegerArgumentMarshaler());
    } 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 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 {
    ArgumentMarshaler m = marshalers.get(argChar);
    try {
      if (m instanceof BooleanArgumentMarshaler)
        setBooleanArg(m);
      else if (m instanceof StringArgumentMarshaler)
        setStringArg(m);
      else if (m instanceof IntegerArgumentMarshaler)
        setIntArg(m);
      else
        return false;
    } catch (ArgsException e) {
      valid = false;
      errorArgumentId = argChar;
      throw e;
    }
   
    return true;
  }


  private void setIntArg(ArgumentMarshaler m) throws ArgsException {
    currentArgument++;
    String parameter = null;
    try {
      parameter = args[currentArgument];
      m.set(parameter);
    } catch (ArrayIndexOutOfBoundsException e) {
      errorCode = ErrorCode.MISSING_INTEGER;
      throw new ArgsException();
    } catch (ArgsException e) {
      errorParameter = parameter;
      errorCode = ErrorCode.INVALID_INTEGER;
      throw new ArgsException();
    }
  }


  private void setStringArg(ArgumentMarshaler m) throws ArgsException {
    currentArgument++;
    try {
      m.set(args[currentArgument]);
     
    } catch (ArrayIndexOutOfBoundsException e) {
      errorCode = ErrorCode.MISSING_STRING;
      throw new ArgsException();
    }
  }


  private void setBooleanArg(ArgumentMarshaler m) {
    try {
      m.set("true");
    } catch (ArgsException e) {
    }
  }


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


  public String getString(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    try {
      return am == null ? "" : (String) am.get();
    } catch (ClassCastException e) {
      return "";
    }
  }


  public int getInt(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    try {
      return am == null ? 0 : (Integer) am.get();
    } catch (Exception e) {
      return 0;
    }
  }


  public boolean getBoolean(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    boolean b = false;
    try {
      b = am != null && (Boolean) am.get();
    } catch (ClassCastException e) {
      b = false;
    }
    return b;
  }
  public boolean has(char arg) {
    return argsFound.contains(arg);
  }
  public boolean isValid() {
    return valid;
  }
  @SuppressWarnings("serial")
  private class ArgsException extends Exception {
  }




  private abstract class ArgumentMarshaler {
    public abstract void set(String s) throws ArgsException;
    public abstract Object get();
  }


  private class BooleanArgumentMarshaler extends ArgumentMarshaler {
    private boolean booleanValue = false;
    public void set(String s) {
      booleanValue = true;
    }
    public Object get() {
      return booleanValue;
    }
  }


  private class StringArgumentMarshaler extends ArgumentMarshaler {
    private String stringValue;
   
    public void set(String s) {
      stringValue = s;
    }
   
    public Object get() {
      return stringValue;
    }
   
  }


  private class IntegerArgumentMarshaler extends ArgumentMarshaler {
    private int integerValue;
    public void set(String s) throws ArgsException{
      try {
        integerValue = Integer.parseInt(s);
      } catch (NumberFormatException e) {
        throw new ArgsException();
      }
    }
    public Object get() {
      return integerValue;
    }
  }
}

 

구조는 개선됐지만, 여전히 미흡한 점이 많다.

특히 setXXX 메서드는 전부 흉하다. 유형을 일일이 확인하기 때문이다.

리팩터링 원칙에 "조건부 로직을 다형성으로 바꾸기" 원칙을 찾아보면 좋다.

 

ArgumentMarshaler.set() 호출해서 처리하려면 현재 로직을 파생 클래스로 옮겨야 한다.

함수의 인수는 적으면 적을 수록 좋다. 인수를 넘기지 말고

인수를 하나만 넘기도록 args, currentArgument 인스턴스 변수를 리팩터링한다.

 

 

 

 

 

 

 

 

Args.setArgument() 리팩터링

if-else 구분을 제거하기 위해 가장 먼저 예외 케이스(else) 밖으로 빼냈다.

 

Boolean 로직이 제일 쉬워 Boolean 부터 처리한다.

 

boolean 에선 currentArgument 필요없지만, String, Integer에선 필요하다.

메서드 리팩터링의 최종 목표인 추상화된 하나의 set() 메서드를 위해선, 선언부를 일치 시켜야 한다.

 

 

신규 추상 메서드 추가로 서브 클래스에서 구현하지 않으면 컴파일에 실패할 것이다.

추가 구현하도록 한다.

신규 메서드이므로 아직 세부 구현은 미뤄두고 컴파일만 있도록 공란으로 둔다.

그리고 본래 목적인 Boolean 리팩터링을 진행한다.

 


이제
Args.setBooleanArg() 제거해도 안전하다.

그리고 Args.setBooleanArg 호출하는 메서드를 리팩터링한다.

 

 

 

나머지도 동일하게 리팩터링을 진행한다.

 

 

 

 

 

불필요한 메서드 제거를 위해

IntegerArgumentMarshaler 클래스를 정리한다.

 

나머지 ArgumentMarshaler 서브 클래스는 set(String s)에서 아무것도 안했으므로 바로 제거한다.

 

이제 구조 개선으로 인해 신규 유형 추가가 얼마나 쉬운지 확인한다.

신규 유형 추가 전에 테스트 케이스부터 추가하고 진행한다.

 

먼저 불필요한 메서드를 인라인한다.

 

 

 

 

Args.getDouble 메서드를 추가한다.

신규 유형 작업이 끝났다.

 

신규 유형에 대한 예외 메시지 추가

 

신규 유형에 따른 테스트 케이스도 추가해야 하지만, 따로 예제로 작성하지 않았다.

모든 리팩터링은 사소한 수정 하나에도 계속 단위테스트를 실행에 코드에 깨짐이 없는지 확인해야 한다.

 

예외 리팩터링

예외 코드 사실 Args 직접적으로 속하지도 않는다.

ParseException 예외 또한 Args 클래스에 속하지도 않는다.

모든 예외를 ArgsException 클래스로 모은다.

 

 

ErrorCode 옮기면서 오류가 것을 위와 같이 수정한다.

 

 

 

오류 코드를 수정한 모든 ParseException 커스텀 ArgsException 변경한다.

 

 

 

 

나머지 모든 예외처리 로직을 옮긴다.

 

 

package chap14.ex05;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.TreeSet;
public class Args {
  private String schema;
  private List<String> argsList;
  private Iterator<String> currentArgument;
  private Map<Character, ArgumentMarshaler> marshalers = new HashMap<>();
  private Set<Character> argsFound = new HashSet<>();
//  private boolean valid = true;
//  private Set<Character> unexpectedArguments = new TreeSet<>();
//  private char errorArgumentId = '\0';
//  private String errorParameter = "TILT";
//  private ArgsException.ErrorCode errorCode = ArgsException.ErrorCode.OK;
  public Args(String schema, String[] args) throws ArgsException {
    this.schema = schema;
    this.argsList = Arrays.asList(args);
//    valid = parse();
    parse();
  }
//  private boolean parse() throws ArgsException {
//    if (schema.length() == 0 && argsList.size() == 0)
//      return true;
//    parseSchema();
//    try {
//      parseArguments();
//    } catch (ArgsException e) {
//    }
//    return valid;
//  }
  private void parse() throws ArgsException {
    parseSchema();
    parseArguments();
  }
  private boolean parseSchema() throws ArgsException {
    for (String element : schema.split(",")) {
      if (element.length() > 0) {
        String trimmedElement = element.trim();
        parseSchemaElement(trimmedElement);
      }
    }
    return true;
  }
  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
      throw new ArgsException(String.format("Argument: %c has invalid format: %s.", elementId, elementTail));
  }
  private void validateSchemaElementId(char elementId) throws ArgsException {
    if (!Character.isLetter(elementId)) {
      throw new ArgsException("Bad character:" + elementId + "in Args format: " + schema);
    }
  }
  private boolean parseArguments() throws ArgsException {
    for (currentArgument = argsList.iterator(); currentArgument.hasNext();) {
      String arg = currentArgument.next();
      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 = ArgsException.ErrorCode.UNEXPECTED_ARGUMENT;
//      valid = false;
      throw new ArgsException(ArgsException.ErrorCode.UNEXPECTED_ARGUMENT, argChar, null);
    }
  }
  private boolean setArgument(char argChar) throws ArgsException {
    ArgumentMarshaler m = marshalers.get(argChar);
    if (m == null) {
      return false;
    }
    try {
      m.set(currentArgument);
      return true;
    } catch (ArgsException e) {
//      valid = false;
//      errorArgumentId = argChar;
      e.setErrorArgumentId(argChar);
      throw e;
    }
  }
  public int cardinality() {
    return argsFound.size();
  }
  public String usage() {
    if (schema.length() > 0)
      return "-[" + schema + "]";
    else
      return "";
  }
//  ArgsException으로 옮김
//  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);
//    case INVALID_DOUBLE:
//      return String.format("Argument -%c expects an double but was '%s'.", errorArgumentId, errorParameter);
//    case MISSING_DOUBLE:
//      return String.format("Could not find double parameter for -%c.", errorArgumentId);
//    }
//    return "";
//  }
//  제거 errorMessage()을 옮기면서, 메서드 인라인 진행
//  private String unexpectedArgumentMessage() {
//    StringBuffer message = new StringBuffer("Argument(s) -");
//    for (char c : unexpectedArguments) {
//      message.append(c);
//    }
//    message.append(" unexpected.");
//
//    return message.toString();
//  }
  public String getString(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    try {
      return am == null ? "" : (String) am.get();
    } catch (ClassCastException e) {
      return "";
    }
  }
  public int getInt(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    try {
      return am == null ? 0 : (Integer) am.get();
    } catch (Exception e) {
      return 0;
    }
  }
  public double getDouble(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    try {
      return am == null ? 0 : (Double) am.get();
    } catch (Exception e) {
      return 0.0;
    }
  }
  public boolean getBoolean(char arg) {
    ArgumentMarshaler am = marshalers.get(arg);
    boolean b = false;
    try {
      b = am != null && (Boolean) am.get();
    } catch (ClassCastException e) {
      b = false;
    }
    return b;
  }
  public boolean has(char arg) {
    return argsFound.contains(arg);
  }


//  제거
//  public boolean isValid() {
//    return valid;
//  }
  @SuppressWarnings("all")
  private 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.errorArgumentId = errorArgumentId;
      this.errorParameter = errorParameter;
    }
    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 unecpected.", 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 an double but was '%s'.",
            errorArgumentId, errorParameter);
      case MISSING_DOUBLE:
        return String.format("Could not find double parameter for -%c.",
            errorArgumentId);
      }
      return "";
    }
    private enum ErrorCode {
      OK, MISSING_STRING, MISSING_INTEGER, INVALID_INTEGER,
      UNEXPECTED_ARGUMENT, MISSING_DOUBLE, INVALID_DOUBLE
    }
  }
  private interface ArgumentMarshaler {
    public abstract void set(Iterator<String> currentArgument) throws ArgsException;
    public abstract Object get();
  }
  private class BooleanArgumentMarshaler implements ArgumentMarshaler {
    private boolean booleanValue = false;
    public void set(Iterator<String> currentArgument) throws ArgsException {
      booleanValue = true;
    }
    public Object get() {
      return booleanValue;
    }
  }
  private class StringArgumentMarshaler implements ArgumentMarshaler {
    private String stringValue;
    public void set(Iterator<String> currentArgument) throws ArgsException {
      try {
        stringValue = currentArgument.next();
      } catch (NoSuchElementException e) {
//        errorCode = ArgsException.ErrorCode.MISSING_INTEGER;
        throw new ArgsException(ArgsException.ErrorCode.MISSING_INTEGER);
      }
    }
    public Object get() {
      return stringValue;
    }
  }
  private class IntegerArgumentMarshaler implements ArgumentMarshaler {
    private int integerValue = 0;
    public void set(Iterator<String> currentArgument) throws ArgsException {
      String parameter = null;
      try {
        parameter = currentArgument.next();
        integerValue = Integer.parseInt(parameter);
      } catch (NoSuchElementException e) {
//        errorCode = ArgsException.ErrorCode.MISSING_STRING;
        throw new ArgsException(ArgsException.ErrorCode.MISSING_STRING);
      } catch (NumberFormatException e) {
//        errorParameter = parameter;
//        errorCode = ArgsException.ErrorCode.INVALID_INTEGER;
        throw new ArgsException(ArgsException.ErrorCode.INVALID_INTEGER, parameter);
      }
    }
    public Object get() {
      return integerValue;
    }
  }
  private class DoubleArgumentMarshaler implements ArgumentMarshaler {
    private double doubleValue = 0;
    public void set(Iterator<String> currentArgument) throws ArgsException {
      String parameter = null;
      try {
        parameter = currentArgument.next();
        doubleValue = Double.parseDouble(parameter);
      } catch (NoSuchElementException e) {
//        errorCode = ArgsException.ErrorCode.MISSING_DOUBLE;
        throw new ArgsException(ArgsException.ErrorCode.MISSING_DOUBLE);
      } catch (NumberFormatException e) {
//        errorParameter = parameter;
//        errorCode = ArgsException.ErrorCode.INVALID_DOUBLE;
        throw new ArgsException(ArgsException.ErrorCode.INVALID_DOUBLE, parameter);
      }
    }
    public Object get() {
      return doubleValue;
    }
  }
}

 

이제 Args 던지는 예외는 ArgsException 뿐이다.

Args 예외/오류 처리 코드와 목적 코드가 완전히 분리됐다.

 

개별 클래스로 분리

이제 하나의 클래스 파일에 있던 내부 클래스를 각자 클래스로 분리한다.

 

IDE 도움을 받는다.

이클립스 기준 Alt + Shift + T

 

 

최종

 

소프트웨어 설계는 분할만 잘해도 품질이 크게 올라간다.

관심사를 분리하면 코드를 이해하고 보수하기 쉬워진다.

 

특히나 ArgsException.errorMessage() 메서드는 Args 있었을 명백히 SRP 위반한 사례다.

Args 클래스 책임은 인수를 처리하는 것인데, errorMessage() 추가로 오류 메시지 형식을 결정하는 책임을 가졌기 때문이다.

 

결론

돌아만 가는 코드만으로는 부족하다.

설계와 구조를 개선할 시간이 없다고 변명은 말이 안된다. 나쁜 코드로 인한 악영향기 크기 때문이다.

묵은 나쁜 코드일 수록 좋은 코드로 바꾸기 더욱 힘들어진다. 처음부터 깨끗한 코드를 유지하는 것이 훨씬 쉽다.

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

13장 동시성  (1) 2024.02.05
12장 창발성  (0) 2024.01.29
11장 시스템  (0) 2024.01.22
10장 클래스  (1) 2024.01.15
9장 단위 테스트  (1) 2024.01.08

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

객체는 처리의 추상화다. 스레드는 일정의 추상화다.

-제임스 O. 코플리엔

 

동시성과 깔끔한 코드는 양립하기 어렵다.

스레드 하나만 실행하는 코드는 짜기 쉽다.

겉으로 멀쩡 해보이는 실상은 문제가 숨겨진 다중 스레드 코드는 짜기 쉽다. 이런 코드의 심각한 문제는 시스템이 동작하다가 부하가 발생하거나 아니면 갑자기 문제가 발생한다.

 

동시성이 필요한 이유?

동시성은 결합(무엇과 언제) 없애는 전략

무엇과 언제를 분리하는 전략

 

스레드가 하나인 프로그램은 무엇과 언제가 밀접하다

콜스텍을 보면 쉽게 있다.

 

무엇과 언제를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다.

 

구조적인 관점에서 하나의 프로그램은 거대한 루프(단일 쓰레드) 아닌 작은 협력 프로그램 여럿으로 보인다.

따라서 시스템을 이해하기, 문제를 분리하기 쉽다

 

서블릿 모델, WAS 서블릿 컨테이너를 보면, 동시성을 부분적으로 관리한다.

요청 마다 서버는 비동기식으로 서블릿을 실행한다.

서블릿 스레드는 다른 서블릿 스레드와 무관하다.

 

컨테이너가 제공하는 결합분리 전략은 완벽하지 않아 주의가 필요하다.

그래도 서블릿 모델이 제공하는 구조점 이점이 더욱 크다.

 

구조점 이점을 제외하고도, 응답시간과 작업 처리량 개선도 많이된다.

단일 쓰레드 프로그램은 병목에 취약하다.

I/O 블락, 소켓 통신 대기하는 시간

 

미신과 오해

미신과 오해

  • 동시성은 항상 성능을 올려준다.
    대기시간이 길고, 프로세서를 여러 스레드가 공유 가능하거나
    여러
    프로세서가 동시에 처리할 독립적인 계산이 많은 경우에만 성능이 높아진다.
    경우 전부 일반적인 상황은 아니다.
     
  • 동시성을 구현해도 설계는 그대로다.
    단일 쓰레드와 멀티 쓰레드는 설계 자체가 다르다.
    무엇과 언제를 분리하면 시스템 구조가 크게 달라져야 한다.
     
  • 컨테이너를 사요하면 동시성을 이해할 필요가 없다.
    컨테이너가 어떻게 동작하는지 알아야 동시 수정, 데드락 등과 같은 문제를 회피할 있다.

사실

  • 동시성은 다소 부하를 유발한다.
    성능 측면에서 부하가 걸린다. 그리고 코드량도 늘어난다
  • 동시성은 복잡하다. 간단한 문제라도 복잡하다
  • 일반적으로 동시성 버그를 제현하기 어렵다.
    문제로 자주 발현되지 않은 동시성 버그를 버그로 생각하지 않고, 고치려는 시도를 하지 않는 경우가 있다.
  • 동시성을 구현하려면 근본적인 설계 전략을 고려해야 한다.

 

난관

동시성 구현이 어려운 이유

public class X {
  private int lastIdUsed;


  public void resetLastIdUsed() {
    lastIdUsed = 0;
  }
  public int getNextId() {
    return ++lastIdUsed; // lastIdUsed = lastIdUsed + 1
  }
}

 

JIT 컴파일러가 바이트 코드를 처리하는 방식과 자바 메모리 모델이 원자로 간주하는 최소 단위를 알아야 무엇이 문제인지 파악할 있다.

 

getNextId() 메서드를 동작을 피연산자스택 기준으로 추상화하여 한글로 풀면 다음과 같다.

  • lastIdUsed 읽는다.
  • 상수 1 읽는다.
  • lastIdUsed, 상수 1 한다.

단위 사이 마다 다른 쓰레드가 개입할 가능성이 있다.

 

 

원자적 연산

중간에 중단이 불가능한 연산을 원자적 연산으로 정의한다.

 

자바 메모리 모델에 의하면 32bit 메모리에 값을 할당하는 연산은 중단이 불가능하다.

 

int 에서 long으로 바꾼다면, 원자적 연산이 아니다.

JVM 명세에 따르면 64 bit 값을 할당하는 연산 개로 나눠진다. , 프로세서에 따라 원자적 연산으로 처리할 있다.

https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.6.1

, 32bit 할당 다음 32bit 할당 사이 다른 쓰레드가 끼어들 가능성이 존재한다.

다른 쓰레드가 32bit 하나를 수정할 있다.

 

resetLastIdUsed() 메서드는 원자적 연산이다.

전후 맥락과 관계 없이 상수 0 할당하고 있다.

피연산자 스택에서 값을 할당할 사용되는 정보(this, 0) 다른 스레드에서 간섭을 없다.

추가로 데이터 형을 int -> long으로 변경해도 원자적 연산이다. 중간에 다른 쓰레드가 간섭할 경우의 수는 의미가 없다. 결국 상수 0 할당하기 때문이다.

 

getNextId()는 원자적 연산이 아니다.

++ 연산자는 바이트 코드상으로 lastIdUsed = lastIdUsed + 1 연산과 같은다.

피연산자 스택에서 lastIdUsed 값을 가져오고 다른 쓰레드가 끼어들어 lastIdUsed + 1 수행을 하면, +1 소실된다.

public class Main {
  public static void main(String[] args) {
    X x = new X();
   
    for (int i = 0; i < 100_000; i++) {
      createThread(() -> System.out.println(x.getNextId()));
    }
  }


  static void createThread(Runnable run) {
    new Thread(run).start();
  }


}

 

100000 결과로 나와야 하지만, 동시성 문제로 최종 결과로 999981 출력됐다.

쓰레드가 코드 레벨 ++lastIdUsed 작업을 수행하던

바이트 코드 레벨 lastIdUsed 가져오고, 상수 1 가져오고, 둘을 더해야하는데

lastIdUsed 가져온 상태에서 다른 쓰레드가 끼어들어 일부 데이터가 손실된 것이다.

 

public synchronized int getNextId() {
   return ++lastIdUsed; // lastIdUsed = lastIdUsed + 1
}

 

 

synchronized 키워드를 사용해 임계영역을 지정하면 정상적으로 동작한다.

 

동시성을 위해 바이트 코드를 전부 이해할 필요는 없다.

공유 객체/값이 있는곳, 동시 읽기/수정 문제를 일으킬 있는 코드, 동시성 문제를 방지하는 방법은 알아야 한다.

 

 

동시성 방어 원칙

단일 책임 원칙

주어진 메서드, 클래스, 컴포넌트를 변경할 이유는 하나여야 한다는 원칙

동시성은 복잡성 하나만으로도 분리할 이유가 충분하다.

동시성 관련 코드는 다른 코드와 분리해야 한다.

 

동시성 구현 고려사항

  • 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다
  • 동시성 코드는 독자적인 난관이 있다. 훨씬 어렵다.
  • 잘못 구현된 동시성 코드는 추측하기 힘든 방식으로 실패한다.

 

동시성 관련 코드는 다른 코드와 분리해야 한다.

 

따름 정리: 자료 범위를 제한하라

객체 하나를 공유한 동일 필드를 수정하면, 쓰레드 간섭이 발생할 있다.

문제를 방지하려면, 공유 객체 내에 synchronized 키워드로 임계 영역으로 보호해야 한다.

임계영역은 성능에 악영향으로 사용 수를 최대한 줄여야 한다.

 

공유 자료를 수정하는 위치가 많을 수록 발생할 있는 경우의

  • 임계영역을 빼먹기, 하나라도 빼먹으면 전체에 영향
  • 모든 임계영역 체크로 고된 노력이 필요
  • 어려운 버그 사유를 찾기 어려워

 

자료를 캡슐화하고, 공유 자료를 최대한 줄여야 한다.

 

따름 정리: 자료 사본을 사용하라

객체를 복사해 읽기 전용으로 사용하는 방법

 

쓰레드가 객체를 복사해 사용한 쓰레드가 사본에서 결과를 가져오는 방법도 가능

 

일반적으로 사본을 생성하는 비용이 공유 자료 임계영역으로 인한 비용보다 적다.

  • 사본 생성 비용
    메모리
    , 메모리로 인한 GC 발생
  • 임계영역 비용
    동기화로
    인한

 

따름 정리: 쓰레드는 가능한 독립적으로 구현하라

스레드 하나 마다 싱글 스레드 어플리케이션으로 생각하고 개발한다.

이러면 다른 스레드와 자료 공유라는 생각을 못하게 된다. 단일 쓰레드 프로그램이니까

 

쓰레드는 클리이언트 요청 하나를 처리한다.

모든 정보는 비공유 출처에서 가져와 로컬 변수에 저장한다.

로컬 변수만 사용한다면, 동기화 문제를 일으킬 수가 없다.

이렇게 되면 스레드는 마치 서로 다른 어플리케이션 처럼 동작하게 된다.

DB 같은 공유하는 외부 자원을 사용하면 문제가 생길 있다.

 

예시는 WAS 동작 방식이다. Request 마다 하나의 쓰레드를 생성해 동작한다.

 

권장

독자적인 쓰레드로, 가능하면 다른 프로세서에서, 돌려도 갠찮도록 자료를 독립적인 단위로 분할하라

 

라이브러리를 이해하라

자바 5부터 동시성이 많이 개선됐다.

java.util.concurrent 패키지

참고로 자바 8부터 추가된 java.util.stream 패키지에선 동시성을 훨씬 쉽게 사용할 있다.

 

자바 5 이후 버전을 사용한다면 동시성 코드 구현 다음을 고려한다.

  • 스레드 환경에 안전한 컬렉션 사용
  • 서로 무관한 작업을 수행할 때는 executor 프레임 워크를 사용한다
  • 가능하면 스레드가 블락킹 되지 않는 방법을 사용한다.
  • 일부 클래스 라이브러리는 스레드에 안전하지 못하다.

 

스레드 환경에 안전한 컬렉션

java.util.concurrent 패키지는 다중 쓰레드 환경에서 안전하다. 심지어 성능도 좋다.

ConcurrentHashMap의 경우 HashMap보다 거의 모든 면에서 빠르다.

https://stackoverflow.com/questions/1378310/performance-concurrenthashmap-vs-hashmap

 

동시 읽기/쓰기를 지원한다. 그리고 자주 사용하는 복합 연산을 다중 쓰레드 상에서 안전하게 만든 메서드를 제공한다.

https://codepumpkin.com/hashtable-vs-synchronizedmap-vs-concurrenthashmap/

 

복잡한 동시성 설계를 위해 자바 5 추가된 클래스

  • ReentrantLock
    메서드에서 잠그고, 다른 메서드에서 푸른
  • Semaphore
    진입
    가능한 프로세스/스레드 카운트 제한이 존재하는
  • CountDownLatch
    지정한
    만큼 이벤트 발생하면 대기 중인 스레드를 모두 해제하는
    모든
    쓰레드는 동시에 공편하게 시작할 기회를 가진다.

권장

자바 네이티브 라이브러리 사용을 검토

 

실행 모델을 이해하라

기본 용어

  • 한정된 자원(Bound Resource)
    모든 컴퓨팅 자원이 아닌, 공유 자원을 의미한다.
    CPU,
    메모리 같은 나의 자원부터, 소켓, DB연결 외부 연결 ...
  • 상호 배제(Mutual Exclusion)
    번에 쓰레드만 공유 자료나 공유 자원을 사용할 있는 경우를 의미한다.
  • 기아(Stravation)
    우선순위같은 스케줄링 문제로 특정 쓰레드나 쓰레드들이 오랫동안 혹은 영원히 자원 할당을 못받는 상황을 의미한다.
  • 데드락(Deadlock)
    여러 쓰레드가 각기 자원을 들고 있으면서 서로 자원을 원해 계속 대기하는 상황
  • 라이브락(Livelock)
    락을 거는 단계에서 쓰레드가 서로를 방해
    획득과 헤제를 반복하면서, 오랫동안 혹은 영원히 실행하지 못하는 상황

 

생산자-소비자

스레드를 생산자/소비자 스레드로 분류한다.

역할 쓰레드는 하나일 수도 여러 쓰레드 수도 있다.

 

종류 쓰레드는 실행 대기열을 공유한다. 대기열을 한정된 자원

 

생산자 쓰레드는 대기열에 공간이 있을 정보를 만들어 채워 넣는다.

소비자 쓰레드는 대기열에 정보가 있어야 가져온다. 정보가 채워질 때까지 대기한다.

 

대기열을 효과적으로 사용하기 위해, 생산자/소비자 쓰레드는 서로 통신한다.

생산자 쓰레드는 대기열에 정보를 채웠으면, 정보가 있다는 사실을 소비자 쓰레드에게 알려준다.

소비자 쓰레드는 대기열에 정보를 가져오고, 대기열에 빈공간이 존재한다는 사실을 생산자 쓰레드에게 알려준다.

순환 구조로 잘못하면, 생산자/소비자 쓰레드는 진행할 있는 상황에서 서로의 신호를 대기할 수도 있다.

 

읽기-쓰기

상황

읽기 쓰레드가 공유 자원을 사용한다. 쓰기 쓰레드가 공유 자원을 가끔씩 갱신

구조는 읽기 쓰레드 처리율이 중요하다.

 

읽기 처리율만 강조하면 쓰기 쓰레드가 기아 상태에 빠져, 정보를 갱신하지 못할 수도 있다.

갱신을 허용하면, 처리율이 떨어진다.

공유 자원을 읽는 동안, 공유 자원을 쓰지 못하게, 반대로 공유 자원을 쓰는 동안, 공유 자원을 읽지 못하게, 사이 균형이 중요하다.

일반적으론 쓰기 작업을 대기하느라 읽기 쓰레드들이 대기하는 상황 때문에 처리율이 떨어진다.

 

식사하는 철학자들

둥근 식탁에 철학자들이 앉아 있다.

철학자는 왼쪽에 포크가 있다.

식탁 가운데에 스파게티 접시가 있다.

포크를 집어야 먹을 있다.

철학자들은 배고프지 않으면 생각을 하고, 배고프면 포크를 집어들고 스파게티를 먹는다.

배고픈 철학자는 왼쪽/오른쪽 철학자가 포크를 사용 중이라면, 대기한다.

스파게티를 먹고, 포크를 내려놓고 다시 생각에 잠긴다.

 

상황에서 철학자는 쓰레드, 포크는 자원이다.

여러 프로세스는 한정된 자원을 얻으려 경쟁한다.

경쟁을 효과적으로 제어하지 못하면, 기아, 데드락, 라이브락 등과 같은 상황이 발생한다.

 

권장

대부분의 다중 쓰레드 문제는 틀에서 보면 세가지 범주에 속한다.

알고리즘과 해법을 이해해야 한다.

 

동기화하는 메서드 사이에 존재하는 의존성을 이해하라

동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드에 찾기 어려운 버그가 생긴다.

자바는 synchronized 키워드로 임계영역을 만들어 개별 메서드를 보호할 있다.

공유 클래스 하나에 동기화된 메서드가 여럿이면 구현이 올바른지 확인해야 한다.

 

권장

공유 객체 하나에는 메서드 하나만 사용

 

공유 객체 하나에 메서드 이상 필요한 경우는 다음을 고려

  • 클라이언트에서 잠금
    클라이언트에서
    번째 메서드 호출 서버를 잠근다
    마지막
    메서드 호출까지 잠금을 유지
  • 서버에서 잠금
    클라이언트가
    호출한 별도 메서드를 하나 만든다.
    메서드는 서버를 잠그고 모든 메서드 호출 잠금을 해제한다.
  • 연결 서버
    잠금을
    수행하는 중간 단계를 생성
    서버에서
    잠금과 유사하나 원래 서버는 변경하지 않는다

 

메서드 사이에 존재하는 의존성을 조심하라

public class IntegerIterator implements Iterator<Integer>{
  private Integer nextValue = 0;
  @Override
  public synchronized boolean hasNext() {
    return nextValue < 100_000;
  }
  @Override
  public synchronized Integer next()  {
    if (nextValue >= 100_000) {
      return -1; //예외라고 가정, 비정상값
    }
    return nextValue++;
  }


  public synchronized Integer getNextValue() {
    return nextValue;
  }
}
public class Main {
  public static void main(String[] args) {
    IntegerIterator iterator = new IntegerIterator();
    while (iterator.hasNext()) {
      int nextValue = iterator.next();
     
      // nextValue 로 무언가를 계산
    }
  }
}

코드는 하나의 쓰레드가 실행하면 문제없지만,

이상 쓰레드가 IntegerIterator 객체를 공유하면, 문제가 생긴다.

 

거의 문제가 없지만, 지점쯤에 스레드 간섭으로 정상값을 가져오지 못할 있다.

 

nextValue 현재 99998

스레드 1, hasNext() 호출로 true 얻었다.

스레드 1, next() 호출 전에

스레드 2, 끼어들어 hasNext() 호출로 true 얻었다.

스레드 2, next() 호출한다. 99998 + 1 = 99999 반환한다. 스레드 2 종료

nextValue 현재 99999 이므로 false 이다.

하지만 스레드 1 이미 true 받아, hasNext() 호출한다. 비정상 값을 반환받는다(예외라고 가정)

 

상황은 실제 다중 스레드 환경에서 발생할 있는 문제다.

문제는 스레드 간섭할 때만 문제가 발생하기 때문에 훨씬 까다롭다.

 

해결 방안

  • 실패를 용인한다
  • 클라이언트를 바꿔 문제 해결
    클라이언트
    기반 잠금 구현
  • 서버를 바꿔 문제 해결
    서버
    기반 잠근 구현

 

실패를 용인한다

실패를 전부 허용한다는 뜻이 아니라, 때로는 실패해도 괜찮도록 한다.

예를들어 가끔 발생하는 실패를 예외처리로 재상태로 돌린다. 다만, 근본적인 문제해결이 아니므로 조잡한 방식이다. 근단적인 예시로, 메모리 누수를 못잡아서 달에 번씩 재부팅하는 것과 같다.

 

클라이언트-기반 잠금

다중 스레드 환경에서 클라이언트 코드를 모두 다음과 같이 변경

  static void threadSafe() {
    IntegerIterator it = new IntegerIterator();
    while (true) {
      int nextValue;
      synchronized (it) {
        if (!it.hasNext()) {
          break;
        }
        nextValue = it.next();
      }
      doSomethingWith(nextValue);
    }
  }

클라이언트는 IntegerIterator 객체에 락을 건다.

안전해 졌지만 ,DRY 원칙을 위배해 중복이 많아진다.

 

중복도 문제고. 다른 문제는 모든 프로그래머가 클라이언트 코드에서 IntegerIterator 객체를 사용할 synchronized 키워드로 락을 걸어야 한다고 인지를 하고, 빼먹으면 안된다.

 

문제는 아주 심각하고, 빼먹은 코드를 찾기도 힘들다.

, 절대 클라리언트-기반 잠금을 사용해선 안된다.

다중 쓰레드 환경에 안전하지 못한 라이브러리 사용해야 하는 상황에서도 절대 쓰지 않는다.

Adapter 패턴으로 해당 라이브러리를 감싸서 사용한다.

 

서버-기반 잠금

IntegerIterator 를 다음과 같이 변경한다.

public class IntegerIteratorServerLocked {
  private Integer nextValue = 0;


  public synchronized Integer getNextOrNull() {
    if (nextValue < 100_000) {
      return nextValue++;
    } else {
      return null;
    }
  }
}

클라이언트 코드

  private static void serverBaseThreadSafe() {
    IntegerIteratorServerLocked it = new IntegerIteratorServerLocked();
    while (true) {
      Integer nextValue = it.getNextOrNull();
      if (nextValue == null) {
        break;
      }
      // nextValue 로 무언가를 계산
    }
  }

API 변경했다.

클라리언트는 hasNext() 대신 null 체크가 필요해졌다.

그래도 일반적으로 서버기반 잠금이 옳다.

이유

  • 관련 코드 중복 제거
  • 성능 증가(멀티 쓰레드 환경 서버를 단일 쓰레드 환경으로 수정)
  • 오류 발생 가능성 감소( 관련 코드를 기억할 필요 없음)
  • 중앙 집중화된 코드
  • 공유 변수 범위 축소

 

서버 코드에 손을 대지 못할 경우 예시

public class IntegerIteratorAdapter {
  private IntegerIterator it = new IntegerIterator();


  public synchronized Integer getNextOrNull() {
    if (it.hasNext()) {
      return it.next();
    } else {
      return null;
    }
  }
}

 

동기화하는 부분을 작게 만들어라

synchronized 키워드로 감싼 코드 영역은 번에 쓰레드만 실행 가능하다.

락은 스레드 지연과 HW 부하를 유발한다.

필요한 곳만 임계영역을 설정해 수를 최대한 줄여야 한다.

단편적으로 임계영역 수만 집중하여 전체를 임계영역으로 만들면 안된다.

임계영역 크기는 스레드 경쟁을 촉발시켜 성능이 저하된다.

 

작업 처리량 높이기

예시

URL 목록을 받아 네트워크 연결 페이지를 읽어오는 코드

페이지를 분석해 통계를 구함

모든 페이지 분석 통계 보고서 출력

//URL 하나를 받아 해당 페이지 내용을 반환한다.
public class PageReader {
  // ...
  HttpClientImpl httpClient = new HttpClientImpl();


  public String getPageFor(String url) {
    HttpMethod method = new GetMethod(url);
   
    try {
      httpClient.executeMethod(method);
      String response = method.getResponseBodyAsString();
      return response;
    } catch (Exception e) {
      return handle(e);
    } finally {
      method.releaseConnection();
    }
  }
  private String handle(Exception e) {
    return "";
  }
}
public class PageIterator {
  private PageReader reader;
  private URLIterator urls;


  public PageIterator(PageReader reader, URLIterator urls) {
    this.reader = reader;
    this.urls = urls;
  }


  public synchronized String getNextPageOrNull() {
    if (urls.hasNext()) {
      return getPageFor(urls.next());
    } else {
      return null;
    }
  }
  private String getPageFor(String url) {
    return reader.getPageFor(url);
  }
}

 

PageIterator 인스턴스는 여러 스레드가 공유

스레드는 PageIterator 인스턴스를 공유하며, Iterator 사용한다.

 

코드에서 synchronized 키워드를 필요한 곳에 최대한 적은 범위로 적용한 것을 있다. 처럼 제한적으로 사용해야 한다

 

작업 처리량 계산 - 단일 스레드 환경

연산 속도 가정

  • 페이지 읽어오는 평균 I/O시간 : 1
  • 페이지 분석하는 평균 처리 시간 : 0.5
  • 처리 CPU 100% 사용, I/O CPU 0% 사용

 

쓰레드 하나가 N 페이지를 처리하는 시간은 1.5 * N

 

작업 처리량 계산 - 다중 스레드 환경

순서에 무관하게 페이지를 읽어, 독립적으로 처리해도 좋다면, 다중 스레드가 처리율을 높여줄 있다.

 

다중 쓰레드를 사용하면 페이지 분석과 I/O 동시에 처리할 있다.

스레드가 3, CPU 100% 활용한다고 가정하면,

페이지 읽기 번에 페이지 분석을 번을 겹칠 있다.

따라서 1 마다 단일 쓰레드와 비교해서 3배를 처리할 있다.

 

올바른 종료 코드는 구현하기 어렵다

영구 구동 시스템과 잠시 구동하다 종료하는 시스템을 구현하는 방법은 다르다.

 

깔끔하게 종료하는 코드는 구현하기 어렵다.

가장 흔한 문제는 데드락으로 스레드가 오지 못하는 종료 시그널을 계속 기다린다.

 

가정

부모 스레드가 자식 스레드를 여러 만든 모두 끝나기를 기다렸다 자원을 해제하고 종료하는 시스템

 

자식 쓰레드 하나라도 락이 걸리면, 부모 쓰레드는 영원히 대기상태로 시스템 종료를 못한다

 

유사한 시스템에서 사용자에게서 종료 명령을 받았다고 가정

부모 쓰레드는 자식 쓰레드에게 종료하라고 명령을 내린다.

이때 자식 쓰레드 개가 생산자/소비자 관계

생산자 자식 쓰레드는 명령을 받아 종료를 했다.

소비자 자식 쓰레드는 생상자 자식 쓰레드로부터 오지 못할 명령을 대기 중이라 부모 쓰레드 종료 명령을 수행하지 못하고 영원히 종료를 못한다.

 

이게 끝이 아니다. 반드시 시간을 들여 올바른 종료 코드를 구현해야 한다.

 

권장

종료 코드를 개발 초기부터 고민하고 구현하고, 좋은 알고리즘 찾아보고 있다면 것을 사용하라

 

데드락

가정

개수가 한정된 자원 개를 공유하는 어플리케이션

  • 로컬 임시 DB 연결
  • 중앙 저장소 MQ 연결

 

생성과 갱신 연산 개를 수행

  • 생성
    중앙
    저장소 연결 임시 DB 연결
    중앙
    저장소 통신 임시 DB 작업을 저장
  • 갱신
    임시
    DB 연결 중앙 저장소 연결
    임시
    DB에서 작업을 읽어 중앙 저장소로 전송

 

크기보다 사용자 수가 많은 상황, 크기는 10

  • 사용자 10명이 생성 시도
    중앙
    저장소 연결 10 전부 확보
    임시
    DB 연결 확보 전에 중단
  • 사용자 10명이 갱신 시도
    임시
    DB 연결 10 모두 확보
    중앙
    저장소 연결 확보 전에 중단
  • 생성 스레드 10개는 임시 DB 연결 확보를 위해 대기한다.
    갱신 스레드 10개는 중앙 저장소 연결 확보를 위해 대기한다.
  • 데드락 발생

 

이런 동시성 버그는 증상 재현이 어렵다. 증상이 어쩌다 발생하기도 하고, 원인 파악을 위해 디버깅 문을 추가하면, 디버깅 문이 추가 됨에 따라 데드락 양상이 바뀔 수도 있다.

 

데드락을 해결하려면 원인을 이해해야 한다.

  • 상호 배제 (Mutual exclusion)
  • 잠금 & 대기 (Lock & Wait)
  • 선점 불가 (No Preemption)
  • 순환 대기 (Circular Wait)

 

상호 배제 (Mutual exclusion)

여러 스레드가 자원을 공유하나 자원은 여러 스레드가 동시에 사용하지 못하며 개수가 제한적이라면 상호 배제 조건을 만족한다

예시

DB 연결, 쓰기용 파일 열기, 레코드 , 세마포어 같은 자원

 

잠금 & 대기 (Lock & Wait)

스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 점유한 자원을 내놓지 않는다.

 

선점 불가 (No Preemption)

스레드가 다른 스레드로부터 자원을 뺏지 못한다.

자원을 점유한 스레드가 자원을 내놓기 전까지 다른 스레드는 자원을 점유하지 못한다.

 

순환 대기 (Circular Wait, == 죽음의 포옹)

T1, T2 쓰레드 존재, R1, R2 자원 존재

 

T1 -> R1 점유

T2 -> R2 점유

T1 -> R2 필요

T2 -> R1 필요

 

 

가지 조건을 모두 충족하면, 데드락이 발생한다.

하나라도 해결하면, 발생하지 않는다.

 

상호 배제 조건 깨기

  • 동시에 사용해도 괜찮은 자원을 활용

  • 스레드 이상으로 자원 수를 늘린다.
  • 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.

 

실무에선 대다수 자원은 수가 제한적이다. 그리고 번째 자원을 사용하고 추가로 필요한 자원이 있을 수도 있다.

, 실제로 상호 배제 조건을 깨기는 쉽지 않다.

 

잠금 & 대기 조건 깨기

대기하지 않으면 데드락은 발생하지 않는다.

자원을 점유하기 전에 확인 자원 하나라도 점유를 못하면 지금까지 점유한 자원을 반환하고 처음부터 재시작한다.

 

문제점

  • 기아(Starvation)
    스레드가 필요한 자원을 점유하지 못한다.
    이유는 번에 점유하기 힘든 자원 조합
  • 라이브락(Livelock)
    여러 스레드가 한번에 잠금 단계로 진입을 시도한다. 때문에 쓰레드는 자원을 점유했다가 반환했다가를 반복한다.

 

상황 작업 처리량을 크게 저해한다.

기아는 CPU 효율을 저하시켜 작업 처리량을 저하시킨다.

라이브락은 CPU 자원만을 쓸데없이 많이 사용한다.

 

선점 불가 조건 깨기

다른 쓰레드로부터 자원을 뺏을 있으면, 데드락은 발생하지 않는다.

 

필요한 자원이 잠금 상태라면, 소유자 스레드에게 잠금 해제 요청을 한다.

소유자 스레드는 현재 상태가 다른 자원을 기다리는 중이라면, 자신이 점유한 자원을 모두 해제하고 처음부터 다시 시작한다

 

잠금 해제 요청이 없다면, 스레드가 자원을 기다려도 된다는 이점이 있다.

따라서 처음부터 다시 시작하는 수가 줄어든다.

다만 요청 관리하기 어렵다.

 

순환 대기 조건 깨기

가장 흔한 전략

대부분 시스템이 채용

 

T1, T2 똑같은 순서로 자원을 할당하게 만들면 순환 대기는 불가능하다.

 

기존

T1 -> R1 점유

T2 -> R2 점유

T1 -> R2 필요

T2 -> R1 필요

 

해결

모든 쓰레드가 자원 점유 순서 동일하게 가져가도록 한다.

R1 점유 R2 점유하도록 변경

 

문제점

  • 자원을 할당하는 순서와 사용하는 순서가 다를 있다.
    먼저 할당 받은 자원을 나중에 사용하게 되어 필요 이상으로 자원을 점유하게 있다
  • 때론 순서에 따라 자원 할당이 어렵다
    자원 사용 둘째 자원을 얻는다면 순서대로 할당하기는 불가능하다.

 

결론

데드락을 피하는 전략은 많다. 각기 장단이 존재하며 어떠한 것도 쉽지 않다.

따라서 쓰레드 관련 코드를 분리해 개발을 해야한다.

그래야 쓰레드 관련 문제를 파악하기 그나마 쉬워지고, 문제 해결 방법도 쉬워진다.

 

스레드 코드 테스트하기

동시성 코드는 올바르다고 증명하기는 불가능하다.

그럼에도 불구하고 충분한 테스트는 위험을 낮춰준다.

 

권장

문제를 노출하는 테스트 케이스 작성

각종 설정과 부하 정도를 바꿔가며 반복 테스트

실패하면, 반드시 원인을 추적, 다시 돌렸을 통과했다고 그냥 넘어가면 안된다.

 

고려사항 지침

  • 말이 되는 실패는 잠정적인 스레드 문제로 취급하라
  • 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 있도록 스레드 코드를 구현하라
  • 다중 스레드 쓰는 코드 부분을 상황에 맞춰 조정할 있게 작성하라
  • 프로세서 수보다 많은 스레드를 돌려보라
  • 다른 플랫폼에서 돌려보라
  • 코드에 보조 코드를 넣어 돌려, 강제로 실패를 일으키게 해보라

 

말이 되는 실패는 잠정적인 스레드 문제로 취급하라

다중 쓰레드 코드는 말이 안되는 오류를 일으킬 때도 있다.

문제는 이를 개발자가 직관적으로 원인을 이해할 없다.

100 번에 1 문제가 발생할 수도 있어, 문제를 재현하기가 어렵다

이런 문제를 일회성 문제로 취급해선 안된다.

 

권장

시스템 실패를 일회성이라 치부하지 말기

 

다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자

스레드 환경은 배제한 코드부터 테스트해야 한다.

스레드가 별개 코드로 분리되어 있다면, 코드가 호출하는 POJO 스레드를 모른다.

POJO 스레드 환경

 

권장

스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 말기

먼저 스레드 환경 밖에 코드를 테스트

 

다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 있도록 스레드 코드를 구현하라

  • 스레드도 실행, 여러 스레드로 실행, 실행 스레드 변경
  • 실제 환경과 테스트 환경에서 스레드 코드 실행
  • 빨리, 천천히 다양한 속도로 테스트
  • 반복 가능하도록 테스트 케이스 작성

 

다중 스레드 쓰는 코드 부분을 상황에 맞춰 조정할 있게 작성하라

적절한 스레드 개수를 번에 파악할 수는 없다.

스레드 개수를 조절하기 쉽게 코드를 구현한다.

런타임 스레드 수를 조절하는 방법도 고려한다.

처리율과 효율에 따라 스스로 스레드 개수를 조율하는 코드도 코민한다.

 

프로세서 수보다 많은 스레드를 돌려보라

강제로 스레드 스와핑을 유발하기 위해 프로세서 수보다 많은 스레드를 돌린다.

스와핑이 잦을 수록 임계영역을 빼먹은 코드나 데드락을 코드를 찾기 쉬워진다.

 

다른 플랫폼에서 돌려보라

다중 스레드 코드는 운영체제마다 스레드를 처리하는 정책에 따라 결과가 달라질 수도 있다.

 

코드에 보조 코드를 넣어 돌려, 강제로 실패를 일으키게 해보라

스레드 버그가 산발적이고 우발적이고 재현이 어려운 이유는 코드가 실행될 있는 수천, 수만 가지의 경로 하나에서 오류가 발생하기 때문이다.

때문에 버그를 발견해도 다시 찾기도, 재현하기도 힘들다.

 

드물게 발생하는 오류를 자주 일으키기 위해 보조 코드를 사용한다.

Object.wait(), sleep() 등과 같은 스레드를 조정하는 메서드를 추가해 다양한 순서로 실행한다.

 

코드에 보조 코드를 추가하는 방법 가지

  • 직접 구현
  • 자동화

 

직접 구현

  public synchronized String nextUrlOrNull() {
    if (hasNext()) {
      String url = urlGenerator.next();
      Thread.yield(); //보조 코드
      return url;
    }
    return null;
  }

yield() 메서드를 추가해 실행되는 경로에 변화를 줬다.

실패할 가능성을 높였다.

다만, 실패가 yield() 때문은 절대 아니다. 원래 실패했어야 것이 드러났을 뿐이다.

 

문제점

  • 직접 보조 코드 삽입할 위치 선정
  • 어떤 함수를 어디서 호출할
  • 배포 환경에 보조 코드를 계속 남겨두는 것은 성능이 저하됨
  • 보조 코드를 넣어도 실패했어야 코드가 실패하지 않을 확률일 훨씬 높다.

 

POJO 쓰레드를 제어하는 클래스를 분리하면, 보조 코드를 넣기 훨씬 쉽다.

 

자동화

보조 코드 자동화는 AOF(Aspect-Oriented Framework, CGLIB 등과 같은 도구가 필요하다.

public class ThreadJigglePoint {
  public static void jiggle() {
    //무작위로 sleep() yield() 등과 같은 쓰레드 제어 메서드를 랜덤으로 호출한다.
  }
}

 

  public synchronized String nextUrlOrNull() {
    if (hasNext()) {
      ThreadJigglePoint.jiggle();
      String url = urlGenerator.next();
      ThreadJigglePoint.jiggle();
      updateHasNext();
      ThreadJigglePoint.jiggle();
      return url;
    }
    return null;
  }

 

전용 도구를 사용하면 좋지만, 여의치 않으면 위와 같은 간단한 도구를 만들어 사용한다.

public class ThreadJigglePoint {
  private static Action action;


  static {
    //환경에 따라 다르게 사용하도록 구현
    if(isLocal()) {
      action = Action.Op;
    } else {
      action = Action.NoOp;
    }
  }
  private static boolean isLocal() {
    return true;
  }


  public static void jiggle() {
    action.jiggle();
  }




  enum Action {
    Op {
      void jiggle() {
        //무작위로 sleep() yield() 등과 같은 쓰레드 제어 메서드를 호출한다.
      }
    },
    NoOp {
      void jiggle() {
        //아무것도 하지 않는다.
      }
    };
   
    abstract void jiggle();
  }
}

위와 같이 배포환경 마다 구현을 따로 가지고 있으면 편리하다.

 

코드를 흔드는 이유는 스레드를 매번 다른 순서로 실행해 오류를 들어내게 하기 위함이다.

 

결론

다중 스레드 코드는 올바로 구현하기 어렵다.

간단한 코드도 다중 쓰레드와 공유 자료를 사용하는 순간 복잡해진다.

 

SRP 준수한다. 반드시 쓰레드 코드와 쓰레드를 모르는 POJO 코드를 분리한다.

쓰레드 코드를 테스트할 때는 쓰레드 코드만 테스트할 있게 된다.

 

동시성 오류를 일으키는 잠정적인 원인을 철저히 이해한다.

 

사용하는 라이브러리와 기본 알고리즘을 이해한다.

특정 라이브러리 기능이 기본 알고리즘과 유사한 문제를 어떻게 해결했는지 파악한다.

 

보호할 코드 영역을 찾는 방법과 코드 영역을 잠그는 방법을 이해한다.

잠긴 영역에서 다른 감긴 영역을 호출하지 않는다.

공유할 정보와 공유하지 말아야할 정보를 명확히 식별한다.

보호할 코드 영역의 수와 크기를 최대한 줄인다.

클라이언트에게 공유 상태를 관리할 책임을 넘기지 않는다.

어쩌다 발생한다고 일회성 문제라 취급하지 않는다.

보조 코드를 넣고, 무작위 설정으로 수천 돌려 최대한 많은 오류를 발견해야 한다.

 

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

14장 점진적인 개선  (0) 2024.02.12
12장 창발성  (0) 2024.01.29
11장 시스템  (0) 2024.01.22
10장 클래스  (1) 2024.01.15
9장 단위 테스트  (1) 2024.01.08

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

켄트 벡이 제시한 단순한 설계 규칙 네가지를 따르면, 소프트웨어 설계 품질을 크게 높일 있다.

 

중요도

  • 모든 테스트를 실행한다.
  • 중복을 제거한다.
  • 프로그래머 의도를 표현한다.
  • 클래스와 메서드 수를 최소화 한다.

 

단순한 설계 규칙 1: 모든 테스트를 실행하라

어떠한 가치보다 먼저, 설계는 의도한 대로 돌아가는 시스템을 내놓아야 한다.

시스템이 의도한 대로 돌아가는지 검증할 방법이 필요하다.

 

검증이 불가능한 시스템은 출시하면 안된다.

 

테스트가 가능한 시스템을 만들려고 애쓰면, 설계 품질이 올라간다.

이유

  • 크기가 작고 목적 하나만 수행하는 클래스가 나오기 때문이다. , SRP 준수하게 된다
  • 결합도가 높다면 테스트 작성이 어렵다. 따라서 DIP 준수하기 위해 DI, 추상화 등을 이용해 결합도를 낮추도록 하게 된다.

 

테스트 케이스를 만들고 계속 돌리라는 규칙을 따르면, 결합도는 낮고, 응집도는 높은 객체 지향 방법론의 목표가 달성된다.

 

단순한 설계 규칙 2~4: 리팩터링

테스트 케이스 작성 점진적으로 코드를 리팩터링한다.

리팩터링 과정에서 반복적으로 테스트를 구동해 정상적으로 동작함을 확인한다.

 

리팩터링 단계는 SW설계 품질을 높이기 위한 모든 기법을 적용해도 좋다.

단계는 다른 규칙인 중복제거, 의도표현, 클래스/메서드 최소화를 적용하는 단계이기도 하다.

 

중복을 없애라

중복은 여러 형태로 표출된다. 유사한 코드들은 유사하게 고쳐주면 리팩터링이 쉬워진다.

 

구현 중복

class List {
  private int size;


  public int size() {
    return size;
  }
  public boolean isEmpty() {
    return size == 0;
  }
}
class List {
  private int size;


  public int size() {
    return size;
  }
  public boolean isEmpty() {
    return size() == 0;
  }
}

isEmpty()에서 size() 사용했다.

지금은 단순한 예시라 와닿지 않을 있지만,

size()메서드가 꽤나 정교하다고 가정하면,

isEmpty()메서 size() 재사용한 것은 아주 효과적인 것이다.

  void scaleToOneDimension(float desiredDimension, float imageDimension) {
    if (Math.abs(desiredDimension - imageDimension) < errorThreshold) {
      return;
    }
    float scalingFactor = desiredDimension / imageDimension;
    scalingFactor = (float)(Math.floor(scalingFactor *100) * 0.00f);
   
    RenderedOp newImage = ImageUtilities.getScaledImaged(image, scalingFactor, scalingFactor);
    image.dispose();
    System.gc();
    image = newImage;
  }


  synchronized void rotate(int degrees) {
    RenderedOp newImage = ImageUtilities.getRotatedImage(image, degrees);
    image.dispose();
    System.gc();
    image = newImage;
  }
  void scaleToOneDimension(float desiredDimension, float imageDimension) {
    if (Math.abs(desiredDimension - imageDimension) < errorThreshold) {
      return;
    }
    float scalingFactor = desiredDimension / imageDimension;
    scalingFactor = (float)(Math.floor(scalingFactor *100) * 0.00f);
   
    replaceImage(ImageUtilities.getScaledImaged(image, scalingFactor, scalingFactor));
  }


  synchronized void rotate(int degrees) {
    replaceImage(ImageUtilities.getRotatedImage(image, degrees));
  }


  private void replaceImage(RenderedOp newImage) {
    image.dispose();
    System.gc();
    image = newImage;
  }

공통적인 코드를 메서드로 뽑았다.

메서드로 인해 클래스가 SRP 위반한다. 별도 클래스로 뽑아도 좋다.

별도 클래스로 뽑으면, 다른 팀원이 메서드를 다른 곳에서 재사용할 기회를 얻는다.

이런 소규모 재사용은 시스템 복잡도를 극적으로 줄여준다.

 

템플릿 메서드 패턴은 고차원 중복을 제거할 목적으로 자주 사용된다.

 

표현하라

자신이 이해하는 코드를 짜기는 쉽다. 자신이 짜는 동안 코드를 구석구석 이해하기 때문이다.

중요한 것은 나중에 유지보수할 사람이 코드를 보는 관점이다. 이는 미래의 까먹은 내가 수도 있다.

 

소프트웨어 생애 주기에서 대부분은 유지보수다.

버그의 싹을 심지 않으려면 유지보수 개발자가 시스템을 이해해야 한다.

깨끗한 시스템은 복잡도고, 무엇을 하는지 명확해 이해하기 쉬워 유지보수 비용이 낮아진다.

 

규칙

  • 좋은 이름 사용
  • 함수와 클래스 크기 가능한 최소화
    작은
    모듈은 이해하기 쉬워질 밖에 없다.
  • 표준 명칭 사용
    예시
    _디자인 패턴을 사용하면 접미로 붙여 준다
  • 단위 테스트 케이스 작성
  • 개선하고자하는 의지와 노력

 

클래스와 메서드 수를 최소로 줄여라

중복 제거, 의도 표현, SRP 준수라는 기본 개념을 극단적으로 지키면, 득보다 실이 많다.

그래서 "가능한"줄이는 것을 목표로 한다

 

예시

  • 클래스마다 무조건 인터페이스를 생성하라고 요구하는 표준
  • 자료 클래스와 동작 클래스 무조건 분리

 

가능한 독단적인 견해를 멀리하고 실용적인 방식을 택한다.

규칙은 다른 4 규칙 가장 우선순위가 낮다. 다른 중요한 우선순위를 먼저 챙겨야 한다.

 

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

14장 점진적인 개선  (0) 2024.02.12
13장 동시성  (1) 2024.02.05
11장 시스템  (0) 2024.01.22
10장 클래스  (1) 2024.01.15
9장 단위 테스트  (1) 2024.01.08

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

복잡서은 주근이다. 개발자에게서 생기를 앗아가며, 제품을 계획하고 제작하고 테스트하기 어렵게 만든다.

 

-레이 오지, 마이크로소프트 CTO

 

도시를 세운다면?

온갖 세부사항을 혼자 직접 관리하기란 불가능이다.

이미 가꿔진 도시라도 사람으론 불가능이다.

도시가 돌아가는 이유는 전기, 수도, 도로 분야의 팀이 있기 때문이다.

 

다른 이유로 적절한 추상화와 모듈화 덕분이다.

정부 조직을 예를 들면, 행정부(정부) 나라 전반의 일을 그림에서 담당할 것이다.

아래 ~, ~ 아래 ~ 이런식으로 점점 구체화된다.

부분을 추상화와 모듈화에 빗대면, 저수준 기관들은 전체 행정 그림을 몰라도 상관없다.

 

소프트웨어도 이와 같다.

 

시스템 제작과 시스템 사용을 분리하라

construction, use

호텔을 짓는 사람과 완공 이용하는 사람은 다르다.

자동차를 제조하는 사람과 자동차를 구매해 사용하는 사람은 다르다.

이처럼 제작과 사용은 전혀 다르다

 

소프트웨어 시스템은 애플리케이션 객체를 제작하고 의존성을 서로 연결하는 준비과정과 준비 과정 이후에 이어지는 런타임 로직을 분리해야 한다.

 

시작 단계(제작) 모든 애플리케이션이 풀어야할 관심사(concern).

관심사 분리는 가장 오래되고 중요한 설계 기법 하나다.

 

준비과정과 런타임 로직이 뒤섞인 예시

  //지연로딩 예시
  public Service getService() {
    if(service == null) {
      service = new ServiceImpl("1", "2", "3");
// 모든 상황에 적합한 객체인지 모르겠음...
    }
    return service;
  }

지연로딩 설명

  • 필요할 객체 생성한다. 무거운 객체를 필요한 시점에 생성하면, 어플리케이션 시작 시간이 짧아진다.
  • 어떠한 경우에도 null 리턴하지 않는다.

 

getService() 메서드는 ServiceImpl 생성자 인수에 명시적으로 의존한다.

프로그래밍에서 new 연산자 사용 자체가 의존성을 의미한다.

구체적인 클래스만이 인스턴스화가 가능하기 때문이다. (DIP)

런타임 로직에서 ServiceImpl 객체를 사용하지 않더라도 의존성을 해결하지 않으면 컴파일이 안된다.

 

테스트도 힘들다. ServiceImpl 무거운 객체라 getService()호출 전에

Mock객체를 할당하려 해도 런타임 로직과 객체 생성 로직이 섞인 탓에

service null 경우와 아닌 경우를 테스트 해야 한다. 책임이 개다. SRP 위반한다.

if문이 보이면 일단 SRP 위반하는지 살펴야 한다.

 

ServiceImpl 모든 상황에 적합한 객체인지 모른다는 것도 문제다.

타입은 인터페이스지만, new 연산자 사용으로 구체적인 클래스에 의존하고 있다.

코드를 테스트를 하려할 객체가 적합한 객체인지 파악할 있을까?

 

초기화 지연 기법을 적게 사용하면 심각한 문제는 아니지만,

많은 어플리케이션이 이런 좀스러운 설정 기법을 수시로 사용한다.

이런 방식은 어플리케이션 곳곳에 흩어져 모듈성은 저조하며 중복이 심하다.

 

설정 논리는 일반 실행 논리와 분리해야 모듈성이 높아진다.

 

스프링은 선언적인 코드로 객체 생성과 조립을 해줘 new 연산자에 의한 의존성이 사라진다.

과정에서 지연로딩도 알아서 처리해준다.

 

Main 분리

시스템 생성과 시스템 사용 분리하는 기법 하나로

생성 관련 코드는 모두 main이나 main 호출하는 모듈로 옮긴다.

나머지 시스템은 모든 객체가 생성되고 모든 의존성이 연결되었다고 가정한다.

 

제어 흐름을 따라가기 쉽다.

main함수에서 모든 객체를 생성한 애플리케이션에 넘기면,

어플리케이션은 그저 객체를 사용하면 그만이다.

 

이렇게 되면, 항상 의존 방향은 main함수에서 애플리케이션으로 간다.

따라서, 애플리케이션은 main함수가 객체를 어떻게 생성되는지 전혀 모르게 된다.

 

팩토리

객체가 생성되는 시점을 애플리케이션이 결정할 필요도 생길 있다.

추상 팩토리 패턴을 쓰면, 애플리케이션이 객체를 생성하는 시점을 결정하지만,

애플리케이션은 객체를 생성하는 코드를 모른다.(추상 팩토리가 책임)

모든 의존성이 main에서 OrderProcessing 애플리케이션으로 향한다.

OrderProcessing 애플리케이션은 LineItem 생성되는 구체적인 방법을 모든다.

그럼에도 , LineItem 생성되는 시점은 통제한다.

 

의존성 주입

사용과 제작을 분리하는 강력한 메커니즘

DI(Dependency Injection)

DI IoC 기법을 의존성 관리에 적용한 매커니즘

 

제어 역전에서는 객체가 맡은 보조 책임을 새로운 객체에게 위임한다.

새로운 객체는 위임받은 책임만 맡으므로 SRP 지키게 된다. (DI 컨테이너)

 

    // JNDI 검색은 의존성 주입을 부분적으로 구현한 기능
    // 디렉터리 서버에 이름을 제공하고 그 이름에 일치하는 서비스를 요청
    Service myService = (Service)JndiContext.lookup("이름");

진정한 의존성 주입은 반환되는 객체 유형을 제어하지 않는다. (클래스 의존성)

의존성을 주입하는 방법으로 설정자 메서드나 생성자 인수를 제공한다.

 

스프링 프레임워크는 자바 DI 컨테이너를 제공

객체 사이 의존성은 설정 파일로 관리한다 (XML, @Configuration)

 

대부분 DI 컨테이너는 프록시 같은 기법으로 지연 로딩을 지원한다.

 

확장

군락 -> 마을 -> 도시

위와 같이 성장을 하는데, 각종 인프라가 확장되고, 없던 인프라도 생긴다.

이러한 과정은 원활하게 진행되진 않는다.

예를들어, 도로의 경우 도시로 성장하면서 확장해야 하는 경우 " 처음부터 넓게 만들지 않았을까?"라는 의문이 생길 있다.

하지만, 많은 마을 어느 마을이 도시로 성장할까?

시골 마을에 왕복 10차선을 뚫는다고 하면 납득할 사람이 있을까?

처음부터 올바르게 시스템을 만들 있다는 믿음은 미신이다.

오늘 주어진 사용자 요구사항에 맞춰 시스템을 구현해야 한다.

매일 새로운 요구에 맞춰 반복적으로 점진적으로 확장하는 것이 애자일 방식의 핵심

이를 도와주는 것이 TDD(테스트주도개발), 리팩터링이다.

 

코드 레벨이 아닌 시스템 아키텍처 레벨에도 적용될까?

소프트웨어 시스템은 물리적인 시스템과 다르다. 관심사를 적절히 분리해 관리한다면 소프트웨어 아키텍처는 점진적으로 발전할 있다.

 

과거 EJB1, EJB2 아키텍처는 관심사를 적절히 분리하지 못했기에 유기적인 성장이 어려웠다.

 

횡단(cross-cutting)관심사

EJB2 아키텍처는 일부 영역에서 관심사를 완벽에 가깝게 분리한다.

트랜잭션, 보안, 일부 영속적인 동작은 배치 기술자에서 정의한다. (코드가 아님)

 

영속성 같은 관심사는 모든 객체가 전반적으로 동일한 방식을 이용해야 바람직하다.

그리고 실제로도 거의 같다.

 

AOP(관점 지향 프로그래밍) 횡단 괌심사에 대해 모듈성을 확보하는 일반적인 방법론이다.

 

AOP에서 관점이라는 모듈 구성 개념은

"특정 관심사를 지원하려면 시스템에서 특정 지점들이 동작하는 방식을 일관성 있게 바꿔야 한다." 라고 명시한다.

명시는 간결한 선언이나 프로그래밍 매커니즘으로 수행한다.

 

자바에서 사용하는 관점 혹은 유사한 매커니즘

  • 자바 프록시
  • 순수 자바 AOP 프레임워크
  • AspectJ 관점

 

자바 프록시

메서드 호출을 감싸는 경우와 같은 단순한 상황에 적합

JDK에서 제공하는 동적 프록시는 인터페이스만 지원한다.

클래스 프록시를 사용하려면 CGLIB, ASM, Javaassist 같은 바이트 코드 처리 라이브러리가 필요하다.

스프링은 CGLIB 사용

public interface Bank {
  Collection<Account> getAccounts();
  void setAccounts(Collection<Account> accounts);
}
//POJO 순수자바객체
public class BankImpl implements Bank{
  private Collection<Account> accounts;


  public Collection<Account> getAccounts() {
    return accounts;
  }
  public void setAccounts(Collection<Account> accounts) {
    //방어적 복사
    this.accounts = new ArrayList<>();
    for (Account a : accounts) {
      this.accounts.add(a);
    }
  }
}
public class BankProxyHandler implements InvocationHandler{
  private Bank bank;


  public BankProxyHandler(Bank bank) {
    this.bank = bank;
  }
  @SuppressWarnings("unchecked")
  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    String methodName = method.getName();
    if (methodName.equals("getAccounts")) {
      bank.setAccounts(getAccountsFromDatabase());
      return bank.getAccounts();
    } else if (methodName.equals("setAccounts")) {
      bank.setAccounts((Collection<Account>) args[0]);
      setAccountsToDatabase(bank.getAccounts());
      return null;
    } else {
      // 기타 메서드...
      return null;
    }
  }
  //세부사항
  protected void setAccountsToDatabase(Collection<Account> accounts) {
  }
  protected Collection<Account> getAccountsFromDatabase() {
    return null;
  }
}
    //사용하는 코드...
    Bank bank = (Bank) Proxy.newProxyInstance(Bank.class.getClassLoader(),
        new Class[] {Bank.class},
        new BankProxyHandler(new BankImpl()));

프록시로 감쌀 인터페이스 Bank

Bank 비즈니스 로직을 구현하는 준수자바클래스 BankImpl

 

단순한 예제지만, 코드가 상당히 복잡하다.

바이트 조작 라이브러리를 사용해도 만만치 않다.

 

프록시 사용의 단점은 코드양과 크기 때문에 깨끗한 코드를 작성하기 어렵다는 점이다.

또한, AOP 위한 시스템 단위 실행 지점 선정이 불가능하다. (클래스 메서드 별로 구현)

 

순수 자바 AOP 프레임워크

프록시 코드는 대부분 규격화되어 있어 도구로 자동화할 있다.

순수 자바 관점을 구현하는 스프링 AOP 내부적으로 프록시를 사용한다

순수 자바란 AspectJ 사용하지 않는다는

 

POJO 순수하게 도메인에 초점을 맞춘다.

프레임워크나 다른 도메인에도 의존하지 않는다. 따라서 테스트가 쉽고 간단하다.

이로 인해 코드를 보수하고 개선하기 편하다.

 

프로그래머는 설정 파일이나 API 사용해 애플리케이션 기반 구조를 구현한다.

설정에는 횡단 관심사도 포함된다.

 

프레임워크는 사용자가 모르게 프록시나 바이트코드 라이브러리를 사용해 이를 구현한다.

이런 선언들이 객체를 생성하고 서로 연결하는 DI 컨테이너의 구체적인 동작을 제어한다.

선언은 코드레벨의 조작이 아닌 설정 파일로 조작하는 것을 의미한다.

@Transactional 같은 것이 대표적

선언적 프로그래밍은 어노테이션 방식, 설정파일방식(대게 xml, 자바 config방식)

 

빈은 중첩된 러시아 인형의 일부분과 같다.

Bank 도메인 객체는 자료 접근자 객체(DAO) 프록시 되어있다.

DAO JDBC 드라이버 자료 소스로 프록시 되어있다.

 

클라이언트는 Bank 객체에 getAccounts() 호출한다고 생각하지만,

실제로는 Bank POJO 기본 동작을 확장한 중첩 데코레이터 가장 외곽과 통신한다.

원한다면, 트랜잭션, 캐싱과 같은 기능도 데코레이터로 추가할 있다.

 

애플리케이션에서 DI 컨테이너에게 객체를 요청하는 방법

스프링 관련 자바 코드가 거의 필요 없다. 따라서 사실상 애플리케이션은 스프링과 독립적이다.

프레임워크와 강하게 결합됐던 과거 EJB2 시스템 문제가 사라진다.

 

XML설정 방법은 읽기 어려웠지만, 겉으로 보이지 않는 자동 프록시 생성이나 관점 논리보다는 훨씬 단순하다.

 

현재는 Java5부터 추가된 애노테이션을 이용해 횡단 관심사를 선언적으로 분리하는 방식을 많이 사용한다.

 

원래 EJB2 코드보다 훨씬 깨끗하다.

일부 상세한 정보는 어노테이션 안에 존재하므로, 코드 자체는 깨끗하다.

 

애노테이션 정보는 필요하다면, XML 배치 기술자로 옮길 있다.

이렇게 되면 진짜 순수한 POJO 남게 된다.

 

오늘날 개발에는 어노테이션을 적극적으로 쓴다.

 

AspectJ 관점

관심사를 관점으로 분리하는 가장 강력한 도구

AspectJ 언어 차원에서 관점을 모듈화 구성으로 지원하는 자바 언어 확장이다.

스프링 AOP, Jboss AOP 같은 순수 자바 방식도 관점이 필요한 상황 대부분을 충족하므로 충분하다.

 

AspectJ 강력하지만, 언어 문법과 사용법을 익혀야 한다.

최근에 AspectJ 애노테이션으로 지원할 있게 되어 새로운 언어라는 부담을 완화시켰다.

 

테스트 주도 시스템 아키렉쳐 구축

관점으로 관심사를 분리하는 방식은 강력하다.

 

애플리케이션 도메인 논리를 여러 의존적인 것들을 제외한 순수한 POJO 작성할 있다면, 진정한 테스트 주도 아키텍처 구축이 가능하다.

 

BDUF(Big Design Up Front)

구현 전에 앞으로 벌어질 모든 사항을 설계하는 기법

 

건축가는 BDUF 방식을 취한다. 물리적 구조물을 짓기 시작하면, 변경이 불가능하기 때문이다.

 

소프트웨어는 다르다. 소프트웨어 구조가 관점을 효과적으로 분리한다면, 극적인 변화가 경제적으로 가능하다.

 

주의할 것은 아무 방향 없이 프로젝트를 진행해도 된다는 소리가 아니다.

 

설계가 최대한 분리되어 추상화 수준과 범위에서 코드가 적당히 단순하면, 나중에 기반 구조를 조금씩 추가하며 확장하기 용이하다. (캐싱, 보안…)

 

초창기 EJB 많은 기술에 초점을 제대로된 관심사 분리를 못했다.

설계가 좋은 API 필요하지 않으면 과유불급이다. 좋은 API 걸리적거리면 안된다.

 

좋은 시스템은 개발자가 아키텍처에 발목을 잡히지 않고, 오롯히 고객의 요구사항과 비즈니스 로직에만 집중할 있다.

 

의사 결정을 최적화하라

시스템 하나를 사람이 모든 결정을 내리긴 어렵다.

 

모듈을 나누고 관심사를 분리하면, 모듈별 관리와 결정이 가능해진다.

 

마지막 순간까지 결정을 미루는 것이 좋다.

성급한 결정은 불충분한 지식으로 내린 결정이다. 성급한 결정은 피드백을 모으고, 고민하는 탐구할 기회를 없앤다.

 

시스템은 도메인 특화 언어가 필요하다

소프트웨어 디자인 패턴은 건축 업계에서 많은 영감을 받아 만들어 졌다.

https://en.wikipedia.org/wiki/The_Timeless_Way_of_Building

건축 분야는 필수적인 정보를 명료하고 정확하게 전달하는 어휘, 관용구, 패턴이 풍부하다.

 

소프트웨어 분야에도 최근 DSL 새롭게 떠오르고 있다.

DSL 간단한 스크립트 언어나 표준 언어로 구현한 API이다.

 

좋은 DSL 도메인 개념과 개념을 구현한 코드 사이 의사소통 간극을 줄여준다.

DSL 수상화 수준을 코드 관용구나 디자인 패턴 이상으로 끌어올린다.

개발자가 적절한 추상화 수준에서 코드 의도를 표현할 있다.

결론

시스템도 깨끗해야 한다.

더러운 아키택처는 도메인 논리를 흐리고, 기민성을 떨어트린다.

 

도메인 논리가 흐려지면, 기민성, 구현, 버그 모든 면에서 악영향을 준다.

 

모든 추상화 단계에서 의도는 명확히 표현해야 한다.

그러기 위해서 POJO 작성하고 관점 혹은 관점과 유사한 메커니즘을 사용해 구현 관심사를 분리해야 한다.

 

시스템을 걸계하든 개별 모듈을 설계하든, 실제로 돌아가는 가장 단순한 수단을 사용해야 한다.

 

 

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

13장 동시성  (1) 2024.02.05
12장 창발성  (0) 2024.01.29
10장 클래스  (1) 2024.01.15
9장 단위 테스트  (1) 2024.01.08
8장 경계  (1) 2024.01.01

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

클래스 체계

1 분류

변수,  메서드

 

2 분류

Static 인지 아닌지

 

3 분류

Public …..  Private 순서

 

추상화 수준이 점진적으로 내려가도록

공개 메서드에 종속된 비공개 메서드는 공개 메서드 바로 아래 위치

 

public class Exam01 {
  public static String EXAMPLE = "공개상수";
  public String exam = "공개변수";
  protected String exam2 = "보호변수";
  private String exam3 = "비공개변수";


  public static void mtd() {}
  public void mtd2() {}
  public void mtd3() {
    mtd3Inner();
  }
  private void mtd3Inner() {}
  protected void mtd4() {}
}

 

캡슐화

변수나 유틸 함수는 가능한 공개하지 않는다.

반드시 숨겨야 한다는 법칙은 없다. 간혹 protected 선언해 테스트 코드에 접근을 허용하기도 한다. 만큼 테스트 코드가 중요하기 때문이다.

다만, 방법은 최후의 수단으로 private 상태에서 모든 방법을 찾아서 테스트하고, 여의치 않으면, 최후의 수단으로 캡슐화를 느슨하게 푸는 결정을 한다.

 

클래스는 작아야 한다

클래스를 만들 가장 번째 규칙은 크기다. 째도 세 째도…

함수에서 강조 했던 이야기다.

 

가이드 라인

 

public class SuperDashboard extends JFrame implements MetaDataUser{
  public Component getLastFocusedComponent() {return null;}
  public void setlastFocused(Component component) {}
  public int getMajorVersionNumber() {return 0;}
  public int getMinorVersionNumber() {return 0;}
  public int getBuildNumber() {return 0;}
}

 

클래스 크기는 메서드 수만으로 정해지는 것이 아니다. 책임을 봐야한다.

 

클래스는 메서드 수는 적지만, 많은 책임을 지고있다.

 

클래스 이름은 클래스 책임을 나타내야 한다.

 

클래스 이름 작명이 클래스 크기 줄이기 시작이다.

만약 간결한 이름이 떠오르지 않는다면, 필히 많은 책임을 지고 있을 가능성이 높다

 

클래스 이름에 Processor, Manager, Super 같은 모호한 단어가 있다면, 클래스가 여러 책임을 가졌을 가능성이 높다.

 

클래스 이름에 if, and, or 같은 단어가 보이면 책임이 그만큼 많다는 것이다.

사용하면 안된다. 그리고 25단어 내외로 가능해야 한다.

 

클래스를 설명할 , "~하며" 같은 단어가 들어가게 된다면, 책임이 많은 것이다.

SuperDashboard 마지막으로 포커스를 얻었던 컴포넌트에 접근하는 방법을 제공하며, 버전과 빌드 번호를 추적한다.

 

단일 책임 원칙 (SRP: Single Responsibility Principle)

클래스나 모듈을 변경할 이유는 , 하나이어야 한다.

SuperDashboard 변경할 이유가 가지다.

  • 소프트웨어 버전 정보를 추적하는데 버전 정보는 SW출시 마다 변경된다.
  • 스윙 컴포넌트를 관리한다(JFrame)

 

스윙 코드가 변경되면, 버전 정보 추척도 영향을 받는다. 역으론 영향을 주진 않는다.

 

public class Version {
  public int getMajorVersionNumber() {return 0;}
  public int getMinorVersionNumber() {return 0;}
  public int getBuildNumber() {return 0;}
}

버전 정보를 관리하는 독자적인 단일 책임 클래스

클래스는 다른 애플리케이션에서 재사용하기 쉽다.

 

SRP 지키기 쉬운 원칙이다. 하지만 가장 무시 받는 규칙 하나다.

SW 돌아만 가게 하는 것과 SW 깨끗하게 유지하는 것은 별개다.

대부분이 돌아만 가게 하면, 일이 끝났다고 여기는 문제다.

이후 깨끗하게 유지를 하지 않는다.

만능 클래스를 단일책임클래스 여럿으로 분리하는 작업을 의미

최우선은 당연히 돌아가는 !

 

일반적으로 클래스 개수가 늘어나 클래스 클래스 움직이면 프로그램의 그림을 보기 힘들다고 걱정한다.

하지만 결국 프로그램 알고리즘 총량을 똑같다. 어느 시스템이건 익힐 내용 양은 비슷하다.

그러므로 걱정은 접어두고, 우리가 고민해야할 것은 분류이다.

기능과 이름이 명확한 컴포넌트를 분류해야 한다.

 

프로젝트 규모가 커질 수록 시스템 논리도 복잡해진다.

이런 복잡성을 제어하려면 체계적인 분류가 필요하다.

그래야 개발자들이 무엇이 어디에 존재하는 찾을 있다.

 

분류를 통해 변경 영향을 미치는 컴포넌트를 쉽게 식별하고, 불필요한 컴포넌트가 핵심 사항 파악을 방해하지 않는다.

 

클래스 하나보다 작은 클래스 여럿이 바람직하다.

작은 클래스는 SRP 준수하며, 다른 작은 클래스와 협력을 통해 시스템에 필요한 동작을 수행한다.

 

응집도(Cohesion)

클래스는 인스턴스 변수가 작아야 한다.

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

일반적으로 메서드가 변수를 많이 사용할 수록 메서드와 클래스는 응집도가 높다.

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

 

가장 높은 응집도 클래스는 현실적으로 가능하지도 바람직하지도 않다.

개발자가 응집도 높은 클래스를 선호하는 이유는 클래스에 속한 메서드와 변수가 서로 의존하며 논리적인 단위로 묶이기 때문이다.

//높은 응집도 클래스
public class Stack {
  private int topOfStack = 0;
  List<Integer> elements = new LinkedList<>();


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

 

topOfStack 인스턴스 변수를 모든 메서드가 사용하며,

elements 대부분 사용한다.

 

"함수를 작게, 매개변수 목록을 짧게" 전략을 따르다 보면

때때로 몇몇 메서드만이 사용하는 인스턴스 변수가 많아진다.

이는 새로운 클래스로 쪼개야 한다는 신호다.

 

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

함수를 작은 함수 여럿으로 나누기만 해도 클래스 수가 많아진다.

예를 들어, 함수 일부를 작은 함수로 빼고 싶은데,

추출하려는 코드가 함수 로컬 변수를 사용한다.

로컬 변수를 작은 함수의 인자로 주면 안되고, 로컬 변수를 인스턴스 변수로 만들면, 인지가 필요없다.

다만, 이렇게 작업을 이어가면, 클래스가 응집력을 잃는다.

몇몇 함수만이 사용하는 인스턴스 변수가 늘어나기 때문이다.

, 새로운 클래스로 쪼개야 한다는 신호가 늘어난다.

결과적으로 함수를 작은 함수 여럿으로 쪼개다 보면 종족 작은 클래스 여럿으로 쪼갤 기회가 생긴다.

과정에서 프로그램 구조가 점점 체계적으로 변한다.

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

 

여러 함수로 나누고, 클래스와 변수에 의미있는 이름을 부여하기

public class PrimePrinter {
  public static void main(String[] args) {
    final int NUMBER_OF_PRIMES = 1000;
    int[] primes = PrimeGenerator.generatePrimes(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);
  }
}
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);
    }
  }
  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;
  }
}
class PrimeGenerator {
  private static boolean[] crossedOut;
  private static int[] result;
  public static int[] generatePrimes(int maxValue) {
    if (maxValue < 2) {
      return new int[0];
    } else {
      uncrossIntegersUpTo(maxValue);
      crossOutMultiples();
      putUncrossedIntegersIntoResult();
      return result;
    }
  }
  private static void uncrossIntegersUpTo(int maxValue) {
    crossedOut = new boolean[maxValue + 1];
    for (int i = 2; i < crossedOut.length; i++) {
      crossedOut[i] = false;
    }
  }
  private static void crossOutMultiples() {
    int limit = determineIterationLimit();
    for (int i = 2; i <= limit; i++) {
      if (notCrossed(i)) {
        crossOutMultiplesOf(i);
      }
    }
  }
  private static int determineIterationLimit() {
    /*
     * 배열에 있는 모든 배수는 배열의 제곱근보다 작은 소수의 인수다. 따라서 이 제곱근보다 더 큰 숫자의 배수는 제거할 필요가 없다.
     */
    double iterationLimit = Math.sqrt(crossedOut.length);
    return (int) iterationLimit;
  }
  private static void crossOutMultiplesOf(int i) {
    for (int multiple = 2 * i; multiple < crossedOut.length; multiple += i) {
      crossedOut[multiple] = true;
    }
  }
  private static boolean notCrossed(int i) {
    return crossedOut[i] == false;
  }
  private static void putUncrossedIntegersIntoResult() {
    result = new int[numberOfUncrossedIntegers()];
    for (int j = 0, i = 2; i < crossedOut.length; i++) {
      if (notCrossed(i)) {
        result[j++] = i;
      }
    }
  }
  private static int numberOfUncrossedIntegers() {
    int count = 0;
    for (int i = 2; i < crossedOut.length; i++) {
      if (notCrossed(i)) {
        count++;
      }
    }
    return count;
  }
}

개선 결과로 프로그램이 길어졌다.

길이가 늘어난 이유는 첫째, 서술적인 변수 이름 사용

둘째, 주석 대신 클래스와 메서드 선언을 활용

셋째, 가독성을 위한 공백 추가 형식 맞춤

 

기존 프로그램에서 가지 책임을 클래스로 나눴다.

PrimePrinter 클래스는 실행 환경 호출 방식 책임

RowColumnPagePrinter 클래스는 숫자 목록을 주어진 행과 열에 맞춰 출력 책임

PrimeGenerator 클래스는 소수 목록 생성 책임

 

재구현이 아닌, 기존 프로그램 알고리즘과 동작원리를 그대로 사용

 

먼저 동작 검증을 위한 테스트 슈트 구축

조금씩 코드를 변경, 다시 테스트 반복해 완성

 

변경하기 쉬운 클래스

변경을 무조건 다가온다. 변경 시스템이 의도대로 도작하지 않을 가능성은 항상 존재한다.

깨끗한 시스템은 클래스를 체계적으로 정리해 변경에 따른 위험을 낮춘다.

 

주어진 메타정보로 SQL 문자열 생성 클래스, 아직 미완성으로 나중에 변경이 예정돼있다.

 

select 완성되었고, 나머지는 미구현이라 가정

public class Sql {
  public Sql(String table, Column[] columns) {}
  public String create() {return "";}
  public String insert(Object[] fields) {return "";}
  public String selectAll() {return "";}
  public String findByKey(String keyColumn, String keyValue) {return "";}
  public String select(Column column, String pattern) {return "";}
  public String select(Criteria criteria) {return "";}
  private String columnList(Column[] columns) {return "";}
  private String valuesList(Object[] fields, final Column[] columns) {return "";}
  private String selectWithCriteria(String criteria) {return "";}
  private String placeholderList(Column[] columns) {return "";}
}

문제점

새로운 SQL 구현 기존 SQL 수정 반드시 Sql 클래스를 손대야 한다.

SRP 위반

비공개 함수는 일반적으로 개선의 여지가 있는 경우가 많다.

 

닫힌 클래스 집합 (OCP)

abstract public class Sql {
  public Sql(String table, Column[] columns) {}
  abstract public String generate();
}
class CreateSql extends Sql {
  public CreateSql(String table, Column[] columns) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
}
class SelectSql extends Sql {
  public SelectSql(String table, Column[] columns) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
}
class InsertSql extends Sql {
  public InsertSql(String table, Column[] columns, Object[] fields) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
  private String valuesList(Object[] fields, final Column[] columns) {return "";}
}
class SelectWithCriteriaSql extends Sql {
  public SelectWithCriteriaSql(String table, Column[] columns,
      Criteria criteria) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
}
class SelectWithMatchSql extends Sql {
  public SelectWithMatchSql(String table, Column[] columns,
      Column column, String pattern) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
}
class FindByKeySql extends Sql {
  public FindByKeySql(String table, Column[] columns,
      String keyColumn, String keyValue) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
}
class PreparedInsertSql extends Sql {
  public PreparedInsertSql(String table, Column[] columns,
      String keyColumn, String keyValue) {
    super(table, columns);
  }
  @Override public String generate() {return null;}
  private String placeholderList(Column[] columns) {return "";}
}
class Where {
  public Where(String criteria) { }
  public String generate() {return "";}
}
class ColumnList {
  public ColumnList(Column[] columns) { }
  public String generate() {return "";}
}

종속적인 비공개 함수는 클래스가 가져가게 하고,

일부 비공개 함수는 클래스로 분리했다.

 

클래스는 아주 단순하다.

메서드 하나를 수정한다고 다른 메서드가 망가질 위험이 완전히 제거됐다.

테스트하기도 쉬워졌다.

만약 Update 구현해야 한다면, Sql 상속받아(OCP)  UpdateSql 클래스를 만들면 된다.

다른 클래스가 전혀 영향받지 않는다.

 

재구성한 Sql 클래스는 SRP OCP 준수한다.

OCP(Open-Closed Principle), 수정엔 폐쇄적, 확장엔 개방적

위와 대입하면, Sql클래스는 기능 추가엔 확장으로 대응하며, 이로 인한 전체 구조 변경엔 폐쇄적이다.

 

변경으로부터 격리

 요구사항은 변한다. 따라서 코드도 변한다.

구체적인 클래스에 의존하면, 변경이 어렵다.

그래서 추상 클래스나 인터페이스를 사용해 구체적인 구현을 감추는 방식을 사용한다.

이는 구현이 미치는 영향을 격리한다.

 

구현에 의존하는 코드는 테스트도 어렵다.

 

예시

Portfolio 클래스는 TokyoStockExchange API 사용해 포트폴리오 값을 계산한다.

테스트 코드는 시세(API) 영향을 받으며, 시세는 5 마다 값이 달라진다.

이런 값이 변하는 API 테스트하기란 쉽지 않다.

 

TokyoStockExchange API 직접 호출하는 대신(구체적인 것에 의존)

StockExchange 인터페이스를 정의 사용한다.

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

TokyoStockExchange 인터페이스를 구현하도록 한다.

 

포트폴리오 클래스는 구체적인 클래스(도쿄증권거래소API) 대신 인터페이스에 의존한다.

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

구제적인 구현에 의존하지 않기 때문에 이제 API 테스트 있는 구조가 됐다.

도쿄증권거래소API 대신 고정 주가를 반환하는 테스트 클래스를 만들어 테스트를 하면 된다.

 

public class PortfolioTest {
  private FixedStockExchangeStub exchange;
  private Portfolio portfolio;


  @Before
  protected void setUp() throws Exception {
    exchange = new FixedStockExchangeStub();
    //마소 종목은 시세가 100달라
    exchange.fix("Microsoft", 100);
    portfolio = new Portfolio(exchange);
  }


  @Test
  public void 종목_5_구매__500달러() throws Exception {
    portfolio.add(5, "Microsoft");
    Assert.assertEquals(500L, portfolio.value());
  }
}

 

테스트가 가능할 정도로 시스템 결합도를 낮추면 유연성과 재사용성도 높아진다.

 

결합도가 낮다는 것은 시스템 요소가 다른 요소로부터, 변경으로부터 격리되어 있다는 뜻이다.

 

결합도를 최소로 줄이면 자연스럽게 DIP 준수하는 클래스가 나온다.

DIP(Dependency Inversion Principle) 클래스가 상세한 구현이 아니라 추상화에 의존해야 한다는 원칙

 

Portfolio 클래스는 TokyoStockExchange 클래스가 아닌 StockExchange 를 의존한다.

추사 메서드는 주식 가격을 반환한다는 추상적 개념 제외한 "어떻게" 값을 가져오는 지를 숨긴다.

 

 

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

12장 창발성  (0) 2024.01.29
11장 시스템  (0) 2024.01.22
9장 단위 테스트  (1) 2024.01.08
8장 경계  (1) 2024.01.01
7장 오류 처리  (0) 2023.12.25

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

테스트 코드는 일회용이 아니다. 단위 테스트는 자동화되어야 한다.

 

TDD 법칙 가지

실제 코드를 짜기 전에 단위 테스트부터 짜라

 

  • 첫재 법칙
    실패하는
    단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다
  • 둘째 법칙
    컴파일은
    성공하면서 실행이 실패하는 정도로만 단위 테스트를 작성한다
  • 셋째 법칙
    현재
    실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

 

법칙을 따르면 개발과 테스트가 대략 30 주기로 묶인다.

테스트 코드와 실제 코드가 함께 나온다.

특히, 테스트 코드가 실제 코드가 나오기 직전에 같이 나온다.

 

방식으로 일하면, 시간이 지남에 따라 실제 코드와 맞먹는 수준으로 무수한 테스트 케이스가 나온다.

너무 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.

 

 

깨끗한 테스트 코드 유지하기

지저분한 테스트 코드는 테스트를 하느니만 못하다.

실제 코드가 진화하면 테스트 코드도 변해야 한다.

 

테스트 코드가 지저분할수록 변경이 어려워, 실제 코드를 짜는 시간보다 테스트 케이스를 추가하는 시간이 걸린다.

실패 케이스가 발생하기 시작하면, 지저분한 코드로 인해, 실패하는 테스트 케이스를 점점 통과시키기 어려워진다. 이는 점점 부담으로 다가와

테스트 슈트는 결국 폐기를 밖에 없어진다.

하지만, 테스트 슈트가 없으면 개발자는 자신이 변경한 코드가 제대로 도는지 확인할 방법이 없다.

 

테스트 코드가 지저분함 => 변경이 어려워 => 실제 코드보다 테스트 케이스 추가가 시간이 걸림 => 실패 케이스 발생 => 지저분한 테스트 코드라 통과시키기 어려움 => 시간이 지남에 따라 지저분한 테스트 코드는 늘음 => 개발자에게 부담 => 테스트 슈트 폐기 => 테스트할 방법이 없음 => 결함율 증가 => 변경을 꺼기게 => 실제 코드가 망가지기 시작

 

결국 지저분하게 관리된 테스트 코드는 거기에 들어간 노력이 0 되버린다.

원인은 테스트 코드를 것이다.

 

테스트 코드는 일급 시민이다. 실제 코드와 동등한 수준으로 깔끔해야 한다.

아니면 결국 잃어버린다

 

테스트는 유연성, 유지보수성, 재사용성을 제공한다

단위 테스트 케이스는 실제 코드에 버팀목

테스트 케이스가 있어야 변경이 두렵지 않다.

개발자가 변경하기 두려운 근본적인 원인은 변경하다가 오류가 날까봐

 

테스트 케이스가 없다면, 모든 변경이 잠정적인 버그다.

 

테스트 커버리지가 높을 수록 공포는 줄어든다.

 

테스트 코드가 깨끗 => 테스트가 쉬움 => 실제 코드 변경이 쉬움

 

비즈니스 요구사항에 빠른 대처를 위해선 테스트 코드를 관리해야 하는 것은 필수

 

깨끗한 테스트 코드

깨끗한 테스트 조건 가독성

가독성 조건, 명료성, 단순성, 풍부한 표현력

 

테스트 코드는 최소 코드로 최대 표현력을 줘야한다.

 

예시

  public void testGetPageHieratchyAsXml() throws Exception {
    crawler.addPage(root, PathParser.parse("PageOne"));
    crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
    crawler.addPage(root, PathParser.parse("PageTwo"));
    request.setResource("root");
    request.addInput("type", "pages");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response = (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();
    assertEquals("text/xml", response.getContentType());
    assertSubString("<name>PageOne</name>", xml);
    assertSubString("<name>PageTwo</name>", xml);
    assertSubString("<name>ChildOne</name>", xml);
  }


  public void testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() throws Exception {
    WikiPage pageOne = crawler.addPage(root, PathParser.parse("PageOne"));
    crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
    crawler.addPage(root, PathParser.parse("PageTwo"));
    PageData data = pageOne.getData();
    WikiPageProperties properties = data.getProperties();
    WikiPageProperty symLinks = properties.set(SymbolicPage.PROPERTY_NAME);
    symLinks.set("SymPage", "PageTwo");
    pageOne.commit(data);
    request.setResource("root");
    request.addInput("type", "pages");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response = (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();
    assertEquals("text/xml", response.getContentType());
    assertSubString("<name>PageOne</name>", xml);
    assertSubString("<name>PageTwo</name>", xml);
    assertSubString("<name>ChildOne</name>", xml);
    assertNotSubString("SymPage", xml);
  }


  public void testGetDataAsHtml() throws Exception {
    crawler.addPage(root, PathParser.parse("TestPageOne"), "test page");
    request.setResource("TestPageOne");
    request.addInput("type", "data");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response = (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();
    assertEquals("text/xml", response.getContentType());
    assertSubString("test page", xml);
    assertSubString("<Test", xml);
  }

 

PathParser 문자열을 pagePath 인스턴스로 변환한다.

pagePath 크롤러가 사용하는 객체다.

테스트하려는 본질과는 무관한 코드다. 오하려 테스트 의도만 흐린다.

responder 또한 테스트 본질과는 무관한 코드다.

 

코드는 결정적으로 읽는 사람을 전혀 고려하지 않는 테스트 코드다.

독자(다른 개발자, 심지어 미래의 자신) 본질과 관계없는 잡다한 코드를 읽으며 간신히 테스트 코드를 이해하게 된다.

 

리팩터링

  public void testGetPageHieratchyAsXml() throws Exception {
    makePages("PageOne", "PageOne.ChildOne", "PageTwo");
    submitRequest("root", "type:pages");
   
    assertResponseIsXML();
    assertResponseContains("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
  }
  public void testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() throws Exception {
    WikiPage pageOne = crawler.addPage(root, PathParser.parse("PageOne"));
    makePages("PageOne.ChildOne", "PageTwo");
    addLinkTo(pageOne, "SymPage", "PageTwo");
    submitRequest("root", "type:pages");
   
    assertResponseIsXML();
    assertResponseContains("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
    assertResponseDoesNotContain("SymPage");
  }




  public void testGetDataAsHtml() throws Exception {
    makePageWithContent("TestPageOne", "test page");
    submitRequest("TestPageOne", "type:pages");
   
    assertResponseIsXML();
    assertResponseContains("test page", "<Test");
  }

BuildOperateCheck 패턴이 위와 같은 테스트 구조에 적합

http://butunclebob.com/FitNesse.BuildOperateCheck

테스트는 부분으로 나뉜다.

  • 부분 테스트 자료 생성
  • 번째 부분 테스트 자료 조작
  • 번째 부분 조작한 결과 정상인지 확인

 

잡다하고 세세한 코드를 제거하고, 테스트하고자 하는 본질을 노출한다.

이제 누가봐도 빠르게 테스트가 무엇을 하는지 있다.

 

도메인에 특화된 테스트 언어

직전 예시는 DSL 테스트 코드를 구현한 기법이다.

시스템 조작 API 대신 API 상단에 함수와 유틸리티를 구현 구현한 함수와 유틸리티를 사용한다.

테스트 코드를 짜기도, 읽기도 쉬워진다.

구현한 함수와 유틸리티는 테스트 코드에서 사용하는 특수 API 된다.

구현한 함수와 유틸리티는 테스트를 구현하는 당사자와 나중에 테스트를 읽어볼 독자를 도와주는 테스트 언어다.

 

이중 표준

  @Test
  public void turnOnLoTempAlarmAtThreashold() throws Exception {
    hw.setTemp(WAY_TOO_COLD);
    controller.tic();
    assertTrue(hw.heaterState());
    assertTrue(hw.blowerState());
    assertFalse(hw.coolerState());
    assertFalse(hw.hiTempAlarm());
    assertTrue(hw.loTempAlarm());
  }

tic() 메서드가 뭔지

상태 이름과 상태 값을 확인하느라 시선이 분산된다.

hw.headterState() 확인 assertTrue 보게 된다.

리팩터링

  @Test
  public void turnOnLoTempAlarmAtThreashold() throws Exception {
    wayTooCold();
    assertEquals("HBchL", hw.getState());
  }
  public String getState() {
    String state = "";
    state += heater ? "H" : "h";
    state += bowler ? "B" : "b";
    state += cooler ? "C" : "c";
    state += hiTempAlram ? "H" : "h";
    state += loTempAlram ? "L" : "l";
   
    return state;
  }

tic()함수는 wayTooCold()안으로 숨겼다.

그리고, 대문자가 on, 소문자가 off 표현하도록 했다.

순서는 heat, blower, cooler, hi-temp-alram, lo-temp-alram 이다.

 

방식이 그릇된 정보를 피하라 규칙을 위반하지만, 여기선 적절해 보인다.

 

getState() 메서드는 효율을 위해선 StringBuffer 좋으나

사용법이 보기가 흉하다.

코드는 테스트 환경이므로, 컴퓨팅 자원이 충분하다. 보기 좋은 방식으로 사용했다.

 

이것이 이중 표준의 본질이다. 실제 환경에서는 절대 안되지만 테스트 환경에선 문제가 없는 방식이 존재한다.

특히, CPU, 메모리 같은 자원의 경우 개발 코드와는 전혀 무관하다.

 

테스트 assert 하나

Junit 으로 테스트 코드를 때는 함수마다 assert 문을 하나만 사용해야 한다는 규칙은 테스트 코드를 이해하기 쉽게 만든다.

  public void testGetPageHierachyAsXml() throws Exception {
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
   
    whenRequestIsIssured("root", "type:pages");
   
    thenResponseShouldBeXML();
  }


  public void testGetPageHierarchyHasRightTags() throws Exception {
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
   
    whenRequestIsIssured("root", "type:pages");
   
    thenResponseShouldContain(
        "<name>PageOne</name>",
        "<name>PageTwo</name>",
        "<name>ChildOne</name>"
        );
  }

given - when - then 패턴

  • given
    테스트 상태를 설명
  • when
    구체화하고자
    하는 해동
  • then
    예상되는 변화

패턴을 사용하면 테스트를 읽기 쉬워진다.

다만, 테스트를 분리하면 중복이 많아진다.

 

중복은 템플릿 메서드 패턴으로 제거할 있다.

중복되는 given, when 부분을 부모로 두고, then 부분을 상속 구현하면 된다.

 

혹은 Junit 프레임워크 도움을 받아 @Before 어노테이션으로 처리해도 된다.

 

결국 이것저것 일이 많아진다. 단일 assert 훌륭한 지침이지만, 같은 개념을 테스트 하는 경우 함수 하나에 여러 assert 사용을 고려해도 좋다.

 

테스트 개념 하나

  public void testAddMonths() {
    SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
   
    SerialDate d2 = SerialDate.addMonths(1, d1);
    assertEquals(30, d2.getDayOfMonth());
    assertEquals(6, d2.getMonth());
    assertEquals(2004, d2.getYYYY());
   
    SerialDate d3 = SerialDate.addMonths(2, d1);
    assertEquals(31, d3.getDayOfMonth());
    assertEquals(7, d3.getMonth());
    assertEquals(2004, d3.getYYYY());
   
    SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
    assertEquals(30, d4.getDayOfMonth());
    assertEquals(7, d4.getMonth());
    assertE

개의 개념을 테스트를 하고 있다.

독자적인 테스트 개로 쪼개야 한다.

 

개념 assert 수를 치소로 줄여라 그리고 테스트 함수 하나는 개념 하나만 테스트하라

FIRST

Fast(속도)

테스트는 빨라야 한다. 그래야 자주 돌린다.

느리면, 자주 못돌려 테스트 돌리기를 주저하게 된다. 결국 코드 품질이 망가진다.

 

Indepencent(독립)

테스트 의존은 없어야 한다.

테스트 순서를 뒤섞어도 아무런 문제가 없어야 한다.

의존이 생기는 순간 하나 실패 줄줄이 실패한다. 더욱이 문제를 찾기 힘들어 진다. ( 뒤에 진짜 결함이 숨겨질 수도 있다)

 

Repeatable(반복)

어떤 환경에서도 반복적으로 테스트 가능해야 한다.

심지어 인터넷이 안되는 환경에도 가능해야 한다.

예외를 두기 시작하면, 테스트 실패 이유에 변명만 늘어난다

 

Self-Validating(자가검증)

테스트는 boolean값을 반환해야 한다.

성공, 실패 두가지, 사실을 알려고, 로그를 뒤지면 안된다

테스트 작성 명확히 실패, 성공 케이스를 판단해야 한다.(애매한게 있으면 안된다)

 

Timely (적시)

단위 테스트는 테스트 하려는 실제 코드 구현 직전에 구현한다.

실제 코드를 구현하고, 테스트 코드를 만들면, 실제 코드가 테스트하기 어려운 구조라는 것을 뒤늦게 발견할 수도 있다. 나아가 테스트가 불가능하도록 실제 코드를 설계할 수도 있다.

 

결론

  • 테스트는 실제 코드만큼 중요하다.
  • 좋은 테스트 코드는 실제 코드의 유연성, 유지보수성, 재사용성을 보증한다.
  • 표현력을 높이고 간결히 정리해야 한다. 테스트 하기 쉽도록 테스트 API 구현해 DSL 만든다.
  • 테스트 코드가 망가지면, 결국 실제 코드도 망가진다.

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

11장 시스템  (0) 2024.01.22
10장 클래스  (1) 2024.01.15
8장 경계  (1) 2024.01.01
7장 오류 처리  (0) 2023.12.25
6장 객체와 자료 구조  (1) 2023.12.18

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

시스템에 들어가는 모든 소프트웨어를 직접 개발하는 경우는 드물다.

어떤 식으로든 외부 코드(대표적인 것은 라이브러리) 사용한다.

외부 코드와 우리 코드 소프트웨어 경계를 깔끔이 처리하는 것은 대단히 중요하다.

 

외부 코드 사용하기

인터페이스 제공자/사용자 관점 괴리

제공자는 적용성을 최대한 넓히려 한다. 많은 환경에서 구동돼야 많이 구매하니까

사용자는 자신이 필요한 기능에 특화된 인터페이스를 원한다.

 

예시 java.util.Map

 

많은 기능을 제공한다.

clear() 모든 내용을 지우는 기능인데 누구나 호출 가능하다.

 

예시

    //지네릭스 없던 시절 예시
    Map sensors = new HashMap();
    Sensor s = (Sensor) sensors.get(sensorsId);

특정 객체 유형을 저장하고 싶지만, 강제할 방법이 없고, 특히 꺼냈을 타입이 Object 사용자에게 형변환 책임을 준다.

 

    Map<String, Sensor> sensors = new HashMap<>();
    Sensor s = sensors.get(sensorsId);

지네릭스 도입으로 형변환의 책임은 없어졌다.

 

여전히 clear() 같은 위험한 메서드를 제한할 없다.

 

프로그램에서 Map<String, Sensor> 인수로 여기저기 넘긴다면,

만약 Map인터페이스가 변경될 경우 영향받는 코드가 분명 많을 것이다.

 

//맵 레퍼 클래스
public class Sensors {
  private final Map sensors = new HashMap();
  public Sensor getById(String id) {
    return (Sensor) sensors.get(id);
  }
  //불필요한 기능을 제거할 수 있다. 이 경우는 인터페이스가 없다...
  //프록시로 구현해(대신 인터페이스 구현 필요)
  //불필요한 기능을 예외를 던지도록 구현할 수 있다.
}

 

경계 인터페이스은  Map Sensors 안으로 숨긴다.

 

경우 만약 Map 인터페이스가 변한다고 해도, 이를 사용하는 클라이언트 코드는 변경이 없다.

Sensors 인터페이스 처럼 완충 역할을 하기 때문이다.

또한, 불필요한 인터페이스를 감출 있다. 제어권은 Sensors에게 있다.

 

Map 같은 경계 인터페이스를 ㅣ요하는 클래스나 클래스 계열 밖으로 노출되지 않도록 한다.

 

경계 살피고 익히기

빠른 개발을 위해 외부 패키지를 사용한다.

외부 패키지 테스트는 우리 책임은 아니지만, 사용할 코드는 테스트해야 한다.

 

만약 타사 라이브러리 사용법이 분명치 않다고 가정

보통 API문서를 것이다. 그리고 코드를 작성해 라이브러리 동작을 확인한다.

버그가 발생하면, 우리 버그인지 라이브러리 버그인지 찾아내기도 한다.

 

처럼 외부 코드는 익히기 어렵다. 우리 코드와 통합도 어렵다.

 

곧바로 우리쪽 코드를 작성해 외부 코드를 호출하는 대신

먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 거슬 학습 테스트 한다.

 

학습 테스트는 공짜 이상이다

학습 테스트 비용은 없다. 어차피 API 익혀야 하기 때문이다.

오히려 학습 테스트로 얻는 성과가 크다.

 

학습 테스트는 외부 패키지가 예상대로 도는지 검증한다.

학습 테스트 이후 우리 코드와 통합을 했다.

 

시간이 지나 외부 패키기에 버전이 나왔다. 반드시 버전이 우리 코드랑 호환된다는 보장이 없어 변경할 수가 없다.

학습 테스트가 있다면, 호환이 되지 않아도 학습 테스트로 바로 있다.

 

중요한 것은 학습 테스트가 필요하든 말든, 어차피 실제 코드와 동일한 방식으로 인터페이스를 사용하는 테스트 케이스가 필요하다.

 

깨끗한 경계

늦던 빠르건 변경은 반드시 찾아온다.

경계 사이에서 변경이 발생했을 , 경계 사이 설계가 우수하면 변경에 많은 비용이 필요하지 않다.

통제하지 못하는 코드(주로 jar 묶인 외부 라이브러리) 바로 사용하면, 향후 변경 비용이 커진다.

 

경계에 위치하는 코드는 깔끔히 분리해야 한다.(주로 외부 라이브러리)

그리고 내가 원하는 동작에 대한 테스트 케이스를 작성한다.

이렇게 만들어진 테스트 케이스를 기반으로 우리가 통제할 있는 인터페이스를 정의한다.

외부 패키지에서 불필요한 기능은 감추고, 우리가 필요로 하는 기능만 인터페이스로 추상화한다.

 

외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리해야 한다.

외부 패키지를 새로운 클래스로 감싸거나

Adapter 패턴을 이용해 우리가 원하는 인터페이스로 변환해야 한다.

 

이렇게 되면, 경계 인터페이스는 우리가 통제할 있으며, 코드 모양이 표준화된다.

그리고 외부 패키지 변경 발생 , 경계 코드가 곳으로 모였기 때문에 변경할 코드도 줄어든다.

 

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

10장 클래스  (1) 2024.01.15
9장 단위 테스트  (1) 2024.01.08
7장 오류 처리  (0) 2023.12.25
6장 객체와 자료 구조  (1) 2023.12.18
5장 형식 맞추기  (0) 2023.12.11

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

오류 처리 코드는 나쁘다.

오류 처리 코드가 있다는 자체가 함수는 여러 가지 일을 하고 있다는 증거다.

더구나 오류 처리 코드가 함수 안에 여기저기 흩어져 있다면,

함수 본래 목적을 파악하기 어려워 진다.

 

오류 처리 자체는 매우 중요하다.

다만, 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워지면 안된다.

 

오류 코드보다 예외를 사용하라

옛날 언어는 예외를 지원하지 못했다.

이로 인해 오류 처리 방법이 제한적이 였다.

 

대게 플래그 값을 설정해 오류 코드를 반환하는 방법이 주류였다.

 

public class DeviceController {
  private static final String DEV1 = null;
  private static final boolean DEVICE_SUSPENDED = false;
  private Recode recode;
  private Logger logger;


  public void sendShutDown() {
    DeviceHandle handle = getHandle(DEV1);
    //디바이스 생태 점검
    if (handle != DeviceHandle.INVALID) {
      //레코드 필드에 디바이스 상태 저장
      retrieveDeviceRecord(handle);
      //디바이스가 일시정지 상태가 아니라면 종료
      if (recode.getStatus() != DEVICE_SUSPENDED) {
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        pauseDevice(handle);
      } else {
        logger.log(Level.INFO, "Device suspended. Unable to shutdown");
      }
    } else {
      logger.log(Level.INFO, "Invalid handle for: " + DEV1.toString());
    }
  }

 

public class DeviceController {
  private static final String DEV1 = null;
  private static final boolean DEVICE_SUSPENDED = false;
  private Recode recode;
  private Logger logger;
  public void sendShutDown() {
    try {
      tryToShutdown();
    } catch (DeviceShutDownError e) {
      logger.log(Level.INFO, e);
    }
  }
  private void tryToShutdown() throws DeviceShutDownError {
    DeviceHandle handle = getHandle(DEV1);
      retrieveDeviceRecord(handle);
      pauseDevice(handle);
      clearDeviceWorkQueue(handle);
      pauseDevice(handle);
  }
  private void clearDeviceWorkQueue(DeviceHandle handle)  throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
  }
  private void pauseDevice(DeviceHandle handle)  throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
  }
  private void retrieveDeviceRecord(DeviceHandle handle)  throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
  }
  private DeviceHandle getHandle(String dev12) throws DeviceShutDownError {
    //~~ 하다 안된 경우 가정
    if(true) {
      throw new DeviceShutDownError("Invalid handle for: " + DEV1.toString());
    }
    return null;
  }
}

핵심 로직과 오류 처리 로직이 완전히 분리되면서, 코드 품질이 좋아지고, 코드가 클린해졌다.

 

Try-Catch-Finally 문부터 작성하라

에외가 발생할 코드를 때는 try-catch-finally 문으로 시작한다.

그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태 정의를 하기 쉬워진다.

 

try 블록에 코드는 실행 어느 시점에서든 중단 catch 블록으로 넘어 있다.

catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.(예외 발생 프로그램을 정상 동작하도록)

 

예외를 기대하는 단위 테스트

  @Test(expected = StorageException.class)
  public void retrieveSectionShouldThrowOnInvalidFileName() {
    sectionStore.retrieveSection("invlid - file");
  }

 

예외를 던지지 않아 실패

  public List<RecordedGrip> retrieveSection(String sectionName) {
    //실제로 구현할 때까지 비어 있는 더미를 반환한다.
    return new ArrayList<RecordedGrip>();
  }

예외를 던지도록 코드 구성

  public List<RecordedGrip> retrieveSection(String sectionName)
      throws StorageException {
    try {//sectionName 잘못된 파일 경로 일부러 넣음
      FileInputStream stream = new FileInputStream(sectionName);
    } catch (Exception e) {
      throw new StorageException("retrieval error", e);
    }
    return new ArrayList<RecordedGrip>();
  }

 

미확인(unchecked)예외를 사용하라

확인된(Checked) 예외 특징

해당 메서드 호출 발생 가능한 모든 예외를 catch

, try-catch 강제한다.

 

가지 사실를 중심으로 장단점이 발생한다.

 

근본적으로 확인된 예외는 OCP 위반한다.

해당 메서드 호출 catch 현재 코드에서 3단계 위에 있다면,

현재 예외를 던지는 코드에서 최초 호출 메서드까지 모든 메서드는 throws 해당 예외를 선언해야 한다.

이는 캡슐화가 깨짐을 의미한다. 하위 메서드가 해당 예외를 던진 다는 사실을 상위 메서드가 알게 됐기 때문이다.

 

하위 단계 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다.

이는 영향 받은 모듈은 기능은 변경된 없으나, 메서드에 예외를 선언한 때문에 재컴파일이 필요해진다.(영향도 문제는 대규모 시스템만 해당 )

 

예외에 의미를 제공하라

예외발생 호출 스택을 제공하지만, 이로는 부족하다.

예외 메시지에 전후 사정을 명확히 기입해 예외가 발생했는지 입력한다.

로그, 예외 메시지

 

호출자를 고려해 예외 클래스를 정의하라

오류를 분류하는 방법은 많다.

오류 발생 위치 분류, 유형 분류(디바이스 실패, 네트워크 실패 …)

 

오류를 정의할 가장 중요한 것은 오류를 catch 하는 방법이 돼야 한다.

안좋은 예

외부 라이브러리라고 가정, 외부 라이브러리가 던지는 모든 예외를 catch한다

    ACMPort port = new ACMPort(12);
    try {
      port.open();      
    } catch (DeviceResponseException e) {
      reportPortError(e);
      logger.log(Level.INFO, "Device response exception");
    } catch (ATM1212UnlockedException e) {
      reportPortError(e);
      logger.log(Level.INFO, "Device response exception");
    } catch (GMXError e) {
      reportPortError(e);
      logger.log(Level.INFO, "Device response exception");
    } finally {
      //.............
    }

 

  void exam() {
    LocalPort port = new LocalPort(12);
    try {
      port.open();
    } catch (PortDeviceFailure e) {
      reportPortError(e);
      logger.log(Level.INFO, e.getMessage());
    } finally {
      //.............
    }
  }

호출하는 외부 라이브러리 API 감싸 예외 유형 하나로 통합했다.

 

public class LocalPort {
  private final ACMPort innerPort;
  public LocalPort(int i) {
    innerPort = new ACMPort(i);
  }
  public void open() {
    try {
      innerPort.open();      
    } catch (
        DeviceResponseException |
        ATM1212UnlockedException |
        GMXError e) {
      throw new PortDeviceFailure(e);
    }
  }
}

LocalPort 단순한 래퍼 클래스다.

만약 인터페이스 였다면, implements 구현해 다형성을 이용했을

 

실제로 외부 API 사용할 때는 감싸기 기법이 좋다

 

외부API 감싸면 외부 라이브러리와 우리 프로그램 사이 의존성이 크게 줄어든다.

나중에 다른 라이브러리로 갈아타도 비용이 적다.

 

또한 외부 API 호출하는 래퍼클래스의 코드에 테스트 코드를 넣어 주는 방법으로 테스트 용이성도 크게 증가한다.

 

특정한 외부 API 대한 사용 방법을 우리 프로그램이 사용하기 편하게 API 재구축 있다.

 

예외 클래스는 대부분 하나로 충분한 코드가 많다.

예외 클래스에 포함된 정보로 오류를 구분해도 충분한 경우(ex_Message에만 입력한 값만 으로 충분하다)

예외를 분류해서 catch 필요가 있는 경우만 여러 예외를 사용한다.

 

정상 흐름을 정의하라

외부 API 감싸고, 독자적인 예외를 던지고, 독자적인 예외를 잡아 처리하는 것은 대부분 좋은 방식이다.

때로는 중단이 적합지 않을 때도 있다.

 

    try {
      //청구한 식비가 있으면 총계에 더한다.
      MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
      m_total += expenses.getTotal();
    } catch (MealExpensesNotFound e) {
      //청구한 게 없다면, 기본형 식비를 더한다.
      m_total += getMealPerDiem();
    }

청구한 식비가 없다면, MealExpensesNotFound 예외가 발생해,

예외를 잡아 기본형 식비를 더한다.

 

괜찮긴 하지만, 불필요한 예외처리 흐름을 보이고 있다.

코드 흐름 : 식비 계산 => 예외 발생하면 기본형 식비 한다.

 

    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();

위와 같이 개선하면 코드 흐름이 간결해진다.

코드 흐름 : 식비 계산

 

public class PerDiemMealExpenses implements MealExpenses{
  @Override
  public int getTotal() {
    //기본형 식대
    return 0;
  }
}
public class ExpenseReportDAO {


  public MealExpenses getMeals(String id) {
    //청구한 식비가 없다면, 기본형 클래스를 반환한다.
    //이런 방식을 특수 사례 패턴 이라한다.
    MealExpenses meal = find(id);
    if(meal == null) {
      return new PerDiemMealExpenses();
    }
    return meal;
  }
  private MealExpenses find(String id) {
    return null;
  }
}

위와 같은 방법을 특수 사례 패턴이라 한다.

마틴 파울러 리팩터링에 자세히 소개되어 있다.

 

객체 지향에서 상속을 활용하는 일반적인 가지 방법,

하나, 기본형 동작을 정의 특수 사례를 상속으로 뺀다.

대부분이 기본형 동작으로 충분( 많이 사용)

, 기본형 동작없이(인터페이스 처럼) 모든 사례를 서브 클래스로 뺀다.

다양한 경우의

 

null 반환하지 마라

  //수 많은 null을 체크하는 더러운 코드
  void registerItem(Item item) {
    if(item != null) {
      ItemRegistry registry = peristenStore.getItemRegistry();
      if(registry != null) {
        Item existing = registry.getItem(item.getID());
        if(existing.getBillingPeriod().hasRetailOwner()) {
          existing.register(item);
        }
      }
    }
  }

null 반환하는 것은 호출자에게 null관련 문제를 떠넘기는 좋은 코드다.

메서드를 호출하는 모든 코드 중에서 하나라도 null 체크를 빼먹으면, 애플리케이션 오류가 발생할 있다.

코드 기준으로 peristenStore null 체크 로직을 빼먹었다.

그리고 null 체크가 중첩일 NullPointerException 발생하면, 찾기도 힘들다.

 

null 반환하는 것보다 예외를 던지거나 특수 사례 객체를 반환해야 한다.

 

외부 API null 반환한다면, 래퍼 클래스로 감싸 예외를 던지거나 특수 사례 객체를 반환한다.

 

일반적으론 특수 사례 객체가 좋은 대책이다.

   List<Employee> employees = getEmployees();
    if(employees != null) {
      for(Employee e : employees) {
        totalPay += e.getPay();
      }
    }

 

public class Exam02 {
  void exam() {
    int totalPay = 0;
    List<Employee> employees = getEmployees();
    //null을 체크할 필요가 없다.
    for(Employee e : employees) {
      totalPay += e.getPay();
    }
  }
  private List<Employee> getEmployees() {
    boolean result = false; //직원이 없다고 가정
    if(result) {
      return Collections.emptyList();
    }
    //정상적으로 반환했다고 가정
    return null;
  }
}

자바의 경우 읽기 전용 리스트라는 특수 사례 객체가 존재한다.

Collections.emptyList()

 

null 전달하지 마라

null반환 보다 나쁜 방식

//두 지점 사이 거리 계산
public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) {
    return (p2.x - p1.x) * 1.5;
  }
}
  void exam() {
    MetricsCalculator calculator = new MetricsCalculator();
    calculator.xProjection(null, new Point(12, 13));
  }

누군가 호출할 null 입력해, NullPointerException 발생할 있다.

 

public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) throws InvalidArgumentException {
    if(p1 == null || p1 == null) {
      throw new InvalidArgumentException("MetricsCalculator.xProjection에 비정상 인자");
    }
    return (p2.x - p1.x) * 1.5;
  }
}
    try {
      calculator.xProjection(null, new Point(12, 13));
    } catch (InvalidArgumentException e) {
      e.printStackTrace();
    }

NullPointerException 반환하는 대신 새로운 예외 유형을 만들어 던지고 있다.

이로 인해 호출부에서 try - catch 문이 필요해졌다.

 

//개선 시도2
public class MetricsCalculator {
  public double xProjection(Point p1, Point p2)  {
    assert p1 != null : "p1 null이면 안됩니다.";
    assert p2 != null : "p2 null이면 안됩니다.";
    return (p2.x - p1.x) * 1.5;
  }
}

assert 문을 사용하는 방법이 있다.

근데 실제 assert 문을 활성화해서 사용하는 경우가 있는지는 모르겠다.

 

안타깝게도 내가 null 반환하지는 않을 있지만, 누군가의 실수로 입력으로 들어올 있는 null 막을 방법은 없다. 프로젝트 정책적으로 강력하게 null인수를 사용하지 못하도록 해야 한다. (그래도...실수는…)

 

결론

깨끗한 코드는 읽기도 좋고, 무엇보다 안정성도 높아야 한다.

오류 처리 로직과 비즈니스 로직을 분리하면, 깨끗하고 튼튼한 코드를 작성할 있다.

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

9장 단위 테스트  (1) 2024.01.08
8장 경계  (1) 2024.01.01
6장 객체와 자료 구조  (1) 2023.12.18
5장 형식 맞추기  (0) 2023.12.11
4장 주석  (1) 2023.12.04

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

변수를 private으로 정의하는 이유는 남들이 변수에 의존하지 않게 만들고 싶기 때문이다.

 

이렇게 되면, 언제든 개발자가 변수 타입이나 구현을 변경할 있다. 의존하는 곳이 없으니까!

 

그런데 대부분 변수는 private으로 선언하고, getter, setter public으로 당연히 정의할 ?

 

자료 추상화

//클래스 구조를 외부로 노출하고 있다.
public class Point1 {
  public double x;
  public double y;
}
//구현을 완전히 숨기고 있다.
public interface Point2 {
 double getX();
 double getY();
 void setCartesian(double x, double y);
 double getR();
 double getTheta();
 void setPolar(double r, double theta);
}

 

Point2 자료 구조를 명백하게 표현하고 있다.

뿐만 아니라 메서드로 접근 정책을 강제한다.

x, y 번에 설정해야 한다. 따로 설정할 방법이 없다.

/*
 * 단순히 변수를 private으로 바꾸고
 * 그 사이  게터/세터 메서드를 넣는다고,구현이 감춰지지 않는다.
 * 구현을 감추려면 추상화가 필요하다.
 */
public class Point3 {
  //구현을 그대로 노출하는 것이랑 다를바 없다.
  private double x;
  private double y;
  public double getX() {
    return x;
  }
  public void setX(double x) {
    this.x = x;
  }
  public double getY() {
    return y;
  }
  public void setY(double y) {
    this.y = y;
  }
}

사용자가 구현을 모른 자료의 핵심을 조작할 있어야 한다.

 

 

public interface Vehicle1 {
  double getFuelTankCapacityInGallons();
  double getGallonsOfGasoline();
}

Vehicle1 "구체적인" 값을 알려준다.

단순히 게터/세터를 설정하는 것과 유사하다.

자료 출처를 명확히 있다.

 

public interface Vehicle2 {
 double getPercentFuelRemaining();
}

Vehicle2 남은 연료 퍼센트를 알려준다.

자료를 어디서 읽어와서 퍼센트를 산출하는지 드러나지 않는다.

 

 

자료를 세세하게 공개하기보다는 추상적인 개념으로 표현하는 편이 좋다.

아무 생각없이 게터/세터를 추가하는 것으로 추상화는 이뤄지지 않으며, 가장 나쁘다.

 

자료/객체 비대칭

객체와 자료 구조는 다르다.

예제에서 Point1 자료구조다.

 

객체는 추상화 뒤로 자료를 숨긴 자료를 다루는 함수만 공개한다.

자료 구조는 자료를 그대로 공개하며 별다른 함수는 제공하지 않는다.

 

//절차적 도형 예시
public class Exam01 {
  //자료구조
  public class Point {
    public double x;
    public double y;
  }
  //자료구조
  public class Square {
    public Point topLeft;
    public double side;
  }
  //자료구조
  public class Rectangle {
    public Point topLeft;
    public double height;
    public double width;
  }
  //자료구조
  public class Circle {
    public Point center;
    public double radius;
  }
  //동작 방식 정의
  public class Geometry {
    public final double PI = 3.14159265358979323846;
    public double area(Object shape) throws NoSuchShapeException {
      if(shape instanceof Square) {
        Square s = (Square)shape;
        return s.side * s.side;
      } else if (shape instanceof Rectangle) {
        Rectangle s = (Rectangle)shape;
        return s.height * s.width;
      } else if (shape instanceof Circle) {
        Circle s = (Circle)shape;
        return PI * s.radius * s.radius;
      } else {
        throw new NoSuchShapeException();
      }
    }
  }
  class NoSuchShapeException extends RuntimeException {}
}

 

위는 절차적 코드다. 객체 지향으로 설계하지 않았다.

 

무조건 객체지향으로 설계하는 것이 옳지만은 않다.

예를 들어, 코드에서 둘레 길이를 구하는 perimeter() 메서드를 추가한다고 가정하면,

다른 클래스는 전혀 영향을 받지 않는다.

 

반면에 다른 도형 클래스를 추가하고 싶다면, Geometry 클래스에 속한 메서드를 모두 고쳐야 한다.

 

//다형적인 도형
public class Exam02 {
  interface Shape {
    double area();
  }


  public class Point {
    public double x;
    public double y;
  }


  class Square implements Shape {
    private Point topLeft;
    private double side;
    public double area() {
      return side * side;
    }
  }


  class Rectangle implements Shape {
    private Point topLeft;
    private double height;
    private double width;
    public double area() {
      return height * width;
    }
  }


  class Circle implements Shape {
    private Point center;
    private double radius;
    public final double PI = 3.14159265358979323846;
    public double area() {
      return PI * radius * radius;
    }
  }
}

객제 지향으로 설계한 클래스다.

자료 + 기능 = 객체

 

여기서는 area() 다형 메서드고, 동작 방식을 위한 Geometry 클래스는 필요 없다.

그러므로 새로운 도형 클래스를 추가해도 기존 클래스엔 아무런 영향을 미치지 않는다.

 

 

반면에 Shape 새로운 메서드(기능) 추가 하면, 클래스 전부를 고쳐야 한다.

추가로 상속은 모든 하위 클래스에 영향을 준다. 그래서 상속보단 구성을 사용하는 경우가 많다.

 

자료구조, 객체지향 장단점

장점

자료구조를 사용하는 절차적인 코드는 기존 자료 구조를 변경하지 않으면서 함수를 추가하기 쉽다.

반면, 객체 지향 코드는 기존 함수를 변경하지 않으면서 클래스를 추가하기 쉽다.

 

단점

절차적인 코드는 새로운 자료 구조를 추가하기 어렵다. 그러려면 모든 함수를 고쳐야 한다.

객체 지향 코드는 새로운 함수를 추가하기 어렵다. 그러려면 모든 클래스를 고쳐야 한다.

 

특징\설계 절차지향 객체지향
장점 함수 추가(기존 자료구조 변경 없음) 클래스 추가(상속, 기존 클래스 변경없음)
단점 자료 구조 추가(모든 함수 영향) 함수 추가(모든 클래스 영향)

 

어느 것이 하나가 절대적인 참이 아니다.

잘차지향에서 변경이 쉬운 것은 객체지향에서 어렵다.

반대도 똑같다.

 

디미터 법칙

자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙

 

객체는 자료를 숨기고, 함수를 공개한다.

객체는 조회 함수(게터) 내부 구조를 공개하면 안된다.

 

  class C {
    List<?> list = new ArrayList<>();
    //디미터의 법칙, 다음과 같은 객체의 메서드만 호출해야 한다.
    List<?> f(List<?> list) {
      list.add(null);//인수로 넘어온 객체
      this.list.add(null);//인스턴스 변수에 저장된 객체
      new ArrayList<>().add(null);//메서드 내에서 생성된 객체
      //허용된 메서드가 반환하는 메서드는 호출하면 안된다.
      return new ArrayList<>();
    }
  }

 

 

기차 충돌(trainwreck)

디미터 법칙 어긴 예시

  void exam1() {
    String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
  }

아래와 같이 나누는 것이 차라리 좋다.

  void exam2() {
    Options opts = ctxt.getOptions();
    File scratchDir = opts.getScratchDir();
    String outputDir = scratchDir.getAbsolutePath();
  }

코드는 너무 많은 정보를 알고 있어야 한다.

메서드가 객체를 리턴하고, 객체는 객체를 리턴하는 등…

 

예시가 디미터 법칙을 위반했는지는 객체인지, 자료구조인지에 달렸다.

객체는 내부 구조를 숨겨야 하므로, 어긴 것이 된다.

 

자료 구조는 당연히 내부 구조를 노출해야 한다. (디미터 법칙 적용 대상이 아님)

명백한 자료 구조 표현

  void exam3() {
    String outputDir = ctxt.options.scratchDir.absolutePath;
  }

 

실제 현업에선 자바빈 규약이 있어, 변수로 접근하지 못하는 경우가 많다.

 

잡종 구조

객체와 자료 구조가 섞인 구조를 말한다.

일부 필드는 비공개 변수를 단순히 공개 게터/세터로 노출, 중요한 기능을 하는 메서드도 존재

 

구조체 감추기

ctxt, options, scratchDir 객체라면, 줄줄이 사탕 형식으로 엮여서는 된다.

내부 구조를 감춰야 하기 때문이다.

  void exam1(){
    //공개해야하는 메서드가 너무 많다.
    ctxt.getAbsolutePathOfScratchDirectoryOption();
    //getScratchDirectoryOption() 반환하는 값이 자료 구조라고 가정하게 된다.(노출했으니까)
    ctxt.getScratchDirectoryOption().getAbsolutePath();
  }

ctxt 객체라면, 뭔가를 하라고 말해야지 속을 드러내면 안된다. (명령)

결국 절대경로를 리턴하는데, 사용 목적이 없다.

 

  void exam2() {
    String classFileName = "";
    //코드를 분석해서 메서드 이름을 명시적으로 지었다.
    //그리고 무언가를 생성하라고 명령했다. (내부 구조 드러냄 없음, 디미터 법칙 준수)
    BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);
  }

 

자료 전달 객체(DTO, Data Transfer Object)

보편적인 구조는 public 변수만 존재하고, 메서드는 없는 구조

 

소켓 통신, DB 내외 시스템과 소통 수단으로 유용한 객체다.

 

가장 일반적인 구조는 자바빈(Bean) 규약을 지키는 구조다.

자바빈 규약 간단한 요약

  • 변수 private
  • 모든 변수 게터/세터 존재
  • 기본 생성자 필수

 

public class Address {
  private String street;
  private String streetExtra;
  private String city;
  private String state;
  private String zip;
  public Address(String street, String streetExtra, String city, String state, String zip) {
    this.street = street;
    this.streetExtra = streetExtra;
    this.city = city;
    this.state = state;
    this.zip = zip;
  }
  public String getStreet() {
    return street;
  }
  public String getStreetExtra() {
    return streetExtra;
  }
  public String getCity() {
    return city;
  }
  public String getState() {
    return state;
  }
  public String getZip() {
    return zip;
  }
}

구조는 세터는 없다. , 생성자로 생성과 동시에 즉시 사용할 있음을 보장한다.

 

활성 레코드

DTO 특수한 형태

DTO + 탐색 함수 제공 (find, save)

 

활성 레코드는 자료 구조 취급을 해야한다.

탐색 함수가 있다고, 활성 레코드에 비즈니스 로직을 구현하면 안된다.

 

결론

객체

동작을 공개하고 자료를 숨긴다.

 

이로 인해 기존 동작을 변경하지 않으면서,

객체 타입을 추가하기 쉬워진다.

 

반면에 기존 객체에 동작을 추가하는 것은 어렵다.

 

자료구조

동작이 없이 자료만 노출한다.

 

이로 인해 동작을 추가하기 쉬워진다.

 

반면에 기존 함수에 자료 구조를 추가하기는 어렵다.

 

용도

시스템을 구현할 새로운 자료 타입이 계속 늘어날 같으면, 객체지향이 적합하다

 

시스템을 구현할 새로운 동작을 계속 추가할 같으면, 절차지향이 적합하다.

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

8장 경계  (1) 2024.01.01
7장 오류 처리  (0) 2023.12.25
5장 형식 맞추기  (0) 2023.12.11
4장 주석  (1) 2023.12.04
3장 함수  (1) 2023.11.27

 

 

Clean Code(클린 코드) | 로버트 C. 마틴 | 인사이트- 교보ebook

애자일 소프트웨어 장인 정신, 나쁜 코드도 돌아는 간다. 하지만 코드가 깨끗하지 못하면 개발 조직은 기어간다. 매년 지저분한 코드로 수많은 시간과 상당한 자원이 낭비된다. 그래야 할 이유

ebook-product.kyobobook.co.kr

코드

https://github.com/rkwhr0010/clean_code/tree/main/src

변경 사항은 git history 참고

 

코든 코드가 깔끔하고, 일관적이며, 꼼꼼해야 한다.

 

누군가 코드를 봤을 전문가가 짰다는 인상을 줘야 한다.

 

프로그래머라면 조직에 코딩 컨벤션에 따라 코드를 작성해야 한다.

코딩 스타일을 맞추기 위해 툴을 사용할 있다면, 사용해야 한다. (예시_ESLint)

 

형식을 맞추는 목적

코드 형식은 의사소통의 일환

의사소통의 중요성은 말할 필요가 없다.

 

'돌아만 가능 코드' 변경에 취약하다.

깨진 창문 효과로 시간이 지남에 따라 더욱 더러워 것이다.

결과 의사소통이 되지 않는 코드가 된다.

 

형식을 엄격하게 맞춰 놓는다면, 시간이 지나도 유지보수 용이성이 그대로 유지된다.

 

개발자는 사라져도 개발자의 스타일과 규율은 사라지지 않는다.

 

적절한 길이를 유지하라

소스 파일 세로 길이

톰캣 같은 경우 500 줄이 넘는 경우도 많다.

Fitnesse 경우 대부분 30 ~ 100 사이에 위치한다.

 

Fitnesse 전체 코드 양은 5 줄이다.

 

이를 보면서 있는 것은, 200 미만은 작은 파일로도 커다란 시스템을 구축할 있다는 것이다.

 

코드가 작으면, 이해하기 쉽다.

 

신문 기사처럼 작성하라

기사는 기본적으로 위에서 아래로 읽게 된다.

그리고 하나의 기사가 면을 차지하는 경우는 거의 없다.

적절한 크기를 가진다.

 

문단은 표제다. 독자는 표제를 읽고, 읽을지 말지를 결정한다.

표제는 자세한 세부사항을 숨기고 커다란 그림을 보여준다.

 

위에서 아래로 읽을 수록 세부사항이 조금씩 드러난다.

 

이를 코드에 대입하면,

소스 파일 부분에는 고차원 개념과 알고리즘을 설명한다.

아래로 수록 세부적인 로직이 설명되며,

마지막엔 가장 저차원 함수가 나온다.

 

개념은 행으로 분리하라

패키지 선언부, import , 함수 사이 등에서 행으로 구분하는 것을 의미한다.

행으로 구분 안하고, 따닥따닥 붙어있다면, 코드 가독성이 현저히 떨어진다.

 

세로 밀집도

바꿈은 개념을 분리한다.

 

세로 밀집도는 연관성을 의미한다.

연관된 코드를 세로로 가까이 있어야 한다.

 

public class ReporterConfig {
  /**
   * 리포터 리스너 클래스 이름
   */
  private String m_className;
  /**
   * 리포터 리스너 속성
   */
  private List<Property> m_properties = new ArrayList<>();
  public void addProperty(Property property) {
    m_properties.add(property);
  }
}
public class ReporterConfig {
  private String m_className;
  private List<Property> m_properties = new ArrayList<>();


  public void addProperty(Property property) {
    m_properties.add(property);
  }
}

번째보다 번째가 눈에 들어온다.

스크롤 필요가 없다.

 

수직 거리

함수 연관 관계와 동작 방식을 이해하려고,

스크롤 하거나, 나아가 조각난 다른 파일로 왔다갔다 뺑뺑이를 있다면,

그것이 끔찍한 경험이라는 것을 것이다.

 

행위가 전부 시간과 노력을 소모한다.

 

서로 미접한 개념은 세로로 가까이 둬야 한다.

특별한 이유가 없다면, 밀접한 개념은 같은 파일에 위치해야 한다.

위와 같은 이유로 protected 접근자를 사용을 피해야 한다.

 

같은 파일 내에 코드는 연관성이 높을 수록 서로 붙어 있어야 한다.

아니면, 소스를 찾느라 스크롤을 많이 해야 한다.

 

변수 선언

변수 선언은 사용하는 위치에 최대한 가까이 선언해야 한다.

클린 코드 룰을 따르면, 함수는 매우 작아 지게 되므로,

함수 앞에 선언하게 된다.

 

 

  void something() {
    for (final Object obj : lists) {
      //좋은 예
      final Object someThing = createInstance();
    }
  }

 

인스턴스 변수

보통 클래스 처음에 선언한다.

변수 세로 거리는 두지 않는다.

 

메서드와 메서드 사이에 변수를 선언하면, 가독성이 떨어진다.

 

종속 함수

함수가 다른 함수를 호출하는 경우를 말한다.

경우 종속 함수는 바로 호출하는 함수 아래에 위치해야 한다.

 

개념적 유사성

종속 함수 같이 직접적인 유사성을 제외하고도

같은 변수를 사용하는 메서드들이나, 비슷한 동작을 수행하는 함수를 말한다.

 

Junit 좋은 예시

개념적 유사성이 매우 높다.

 

새로 순서

고차원 함수가 저차원 함수를 호출한다.

, 가장 고차원 함수가 가장 앞에, 가장 저차원 함수가 가장 아래 위치해야 한다.

 

가로 형식 맞추기

대부분 20 ~ 60 사이에 위치한다.

80 부턴 급격히 적어진다.

 

자료는 옛날 모니터가 좋았던 시절.

현재는 120 이내로 유지하는 것을 권장한다.

 

가로 공백과 밀집도

할당 연산자를 구분하기 위해 띄어쓰기

메서드 인수 구분을 위해 콤마 띄어쓰기

 

연산자 우선순위 강조를 위한 공백

package chap05.ex04;
public class Quadratic {
  public static double root1(double a, double b, double c) {
    double determinant = determinant(a, b, c);
   
    return (-b + Math.sqrt(determinant)) / (2*a);
  }
  public static double root2(int a, int b, int c) {
    double determinant = determinant(a, b, c);
    return (-b - Math.sqrt(determinant)) / (2*a);
  }
  private static double determinant(double a, double b, double c) {
    return b*b - 4*a*c;
  }
}

곱셈이 우선순위가 높아 붙이고, 뺄샘은 띄웠다.

 

IDE 자동 정렬 기능을 사용하면, 모든 연산자에 동일한 간격을 띄워 위와 같은 포멧이 나오진 않는다.

 

현실적으로 우선순위가 맞더라도 소괄호를 치는 것이 좋아보인다.

 

가로 정렬

이렇게 코드를 정렬하면, 좋지 않다.

이렇게 되면, 진짜 목적인 변수 타입을 먼저 읽지 않고, 시선이 변수명으로 먼저 간다.

 

들여쓰기

public class Quadratic { public static double root1(double a, double b, double c) {double determinant = determinant(a, b, c);    return (-b + Math.sqrt(determinant)) / (2*a);} public static double root2(int a, int b, int c) {double determinant = determinant(a, b, c); return (-b - Math.sqrt(determinant)) / (2*a); } private static double determinant(double a, double b, double c) { return b*b - 4*a*c; }}

코드는 기계는 정확히 동작하지만, 결국 사람이 때는 하나도 눈에 안들어 온다.

 

들여쓰기는 코드 구조를 눈에 들어오도록 도움을 준다.

기본 들여쓰기 룰은 지키는 것이 좋다.

 

규칙

중요한 규칙이다. 개인이 선호하는 규칙이 있겠지만, 규칙이 반드시 지켜야 한다.

그래야 소프트웨어가 일관적인 스타일을 유지하게 된다.

'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글

7장 오류 처리  (0) 2023.12.25
6장 객체와 자료 구조  (1) 2023.12.18
4장 주석  (1) 2023.12.04
3장 함수  (1) 2023.11.27
2장 의미 있는 이름  (1) 2023.11.20

+ Recent posts