1차 강의 - TDD

이번에 nextstep에서 열리는 TDD, CleanCode Java 14기에 참여하게 되었다.

좋은 교육기관에서 제대로 미션을 수행하며 TDD와 클린코드, 리팩토링을 찐하게 맛보고 성장하고 싶다..!

 

TDD에 집착하는 이유

TDD(Test Driven Development, 테스트 주도 개발) → Kent Beck(켄트 백)

 

TDD란 프로그래밍 의사결정과 피드백 사이의 간극을 의식하고 이를 제어하는 기술이다.

 

웹 어플리케이션을 기준으로 피드백을 받는 과정을 생각해보면 UI부터 데이터베이스 테이블까지 만들고 CRUD까지 만들고 피드백을 받기까지 엄청 오랜 시간이 걸린다. 하지만 TDD는 엄청 빠른 시간내에 피드백을 받을 수 있다.

 

TDD의 아이러니 중 하나는 테스트 기술이 아니라는 점이다. TDD는 분석 기술이며, 설계 기술이기도 하다.

 

요구사항 분석을 철저히 하지 않으면 TDD를 시작할때 시작 포인트를 찾지 못하고 막막함을 느끼게 된다.

요구사항 분석을 철저히 하여 어느 부분을 집중적으로 TDD 할것인지 정해야 한다.

 

TDD를 잘 하기 위해서는 In - Out 방식을 이용해야 한다. (객체의 안쪽 부분부터 만들며 큰것들을 만들어 나가는 방식)

이렇게 기능 목록을 만든 다음, 그 목록들을 독립적으로 분리할 수 있어야 한다. (Divide & Conquer) 그래야 나중에 이 기능 목록들을 모아서 더 큰 기능을 만들어 낼 수 있다. → 이는 상당한 노력이 필요하다.

 

TDD = Test First Development + Refactoring

테스트 우선 개발을 진행하며 리팩토링(네이밍, 메서드, 인터페이스 추출 등)을 한다.

즉, TDD는 한번에 설계를 많이한 뒤에 개발을 하는 것이 아니라 개발을 해나가면서 작은 설계를 계속해서 반복해나가는 것이다.

TDD Cycle

기능 개발의 조급함을 의식적으로 제어해야 한다. TDD 사이클을 지키면서 테스트를 성공시키면 작은 기능 구현이 완료된 것이고 그 이후 리팩터링 작업을 항상 할 수 있도록 의식적으로 지켜나가야 한다.

사이클을 지키면서 일정 수준의 품질의 코드를 지속적으로 유지하는 것이 TDD의 목표이다.

 

테스트 코드를 나중에 만들겠다..? ⇒ 테스트 코드를 안 만들겠다는 뜻..
먼 미래는 없다. 바로 지금 당장 right now 만들어야 한다.

 

우리는 지금까지 기능을 구현하고 + 완벽한 설계를 하기 위해 노력했다. 하지만 이는 굉장히 어렵다.

다시 위 TDD 사이클을 보자.

 

먼저 (1.) 실패하는 테스트를 만든다. 그리고 (2.) 테스트에 성공하기 위해 로직을 구현하는 것에 집중한다. 이때 테스트를 통과하기 위해 어떠한 행위도 허용한다. (설계의 고민은 하지 않아도 된다.) (3.) 테스트가 통과되면 그제서야 설계에 집중한다. (리팩토링 → 메소드, 클래스 설계, 클린코드 구현)

 

클린코드를 만들고, 설계를 하는 과정은 창의적인 과정 이다. 이러한 창의적인 과정을 하기 위해서는 Inner Peace가 굉장히 중요한데, 기능 구현에 대한 조급함과, 창의적인 과정이 동시에 진행되면 압박감 때문에 잘 안된다.

하지만 TDD는 테스트를 일단 성공시켜놓기 때문에 조급함은 사라진다. 그래서 이후의 리팩토링 작업에서 설계에 집중할 수 있게 된다.

머릿속의 복잡한 알고리즘들을 한방에 설계하는 것 보다 테스트 코드를 하나씩 만들어 나가는게 훨씬 수월하다. 이러한 테스트 코드들이 모이면 결국 기능 구현이 완료되게 된다. (물론 테스트 코드를 잘 설계하는 것도 중요하다.)


근데 이러한 TDD를 어떻게 연습하면 좋을까?

그냥 연습을 열심히한다? 클린 코드와 같은 책을 본다? 동영상을 본다?

무조건 연습을 많이 한다고 잘할 수 있을까?

 

TDD를 잘한다는 것은

테스트하기 쉬운 코드와 테스트하기 어려운 코드를 보는 눈이 생기는 것이다.

더 나아가, 테스트하기 어려운 코드를 테스트 하기 쉬운 코드로 설계할 수 있는 감(sense)이 생겨야 한다.

테스트 하기 쉬운 부분만 TDD로 개발한다는 것이다. (단위테스트는 테스트하기 어려운 것들은 테스트 하지 않아도 된다.)

근데 우리는 모든 걸 단위테스트로 잘 해야 한다는 강박관념이 있어서 어렵다고 느끼는 것이다. (테스트하기 어려운 걸 테스트 하려고 하니 당연하다.)

 

즉, 테스트하기 어려운건 테스트 하지말고 버려라. 거기에 핵심 로직이 있는게 아니다. 핵심로직이 있는 부분을 테스트하기 쉬운코드로 설계한 다음에 그 부분만 집중적으로 TDD를 계속 하는 것이다. 그러면 훨씬 안정적인 코드를 짤 수 있다.

 

의식적인 연습의 7가지 원칙

1만 시간의 재발견 책 참고

  1. 효과적인 훈련 기법이 수립되어 있는 기술을 연마한다.
  2. 개인의 컴포트 존을 벗어난 지점에서 진행해야 한다. 자신의 현재 능력을 살짝 넘어가는 작업을 지속적으로 시도해야 한다. (쉬운것만 하면 안된다.)
  3. 명확하고 구체적인 목표를 항상 인지하고 진행해야 한다. (ex : else 문을 쓰지 말것)
  4. 신중하고 계획적이어야 한다. 즉, 개인이 온전히 집중하고 의식적으로 행동해야 한다. (단순히 목표를 인지할 뿐만 아니라 개인이 그 목표를 이루기 위해 신중하고 계획적인 행동을 의식적으로 해야 한다.)
  5. 피드백과 피드백에 따른 행동의 변경을 수반해야한다. (피드백을 통한 개선점을 찾으려면 스스로에 대한 효과적인 심적 표상이 있어야 한다. ex : 코드 리뷰 → 코드리뷰를 통한 고민과 노력 → 심정 표상 상세)
  6. 효과적인 심적 표상을 만들어내는 한편으로 심적 표상에 의존해야 한다. (수행능력의 향상은 심적 표상의 발전과 밀접한 연관이 있다. → 개인의 수행능력이 향상되면 심적 표상이 한층 상세해지고 효과적이게되고 이는 다시 수행능력의 향상으로 이어진다.)
  7. 기존에 습득한 기술의 특정 부분을 집중적으로 개선함으로써 발전시키고, 수정하는 과정을 수반해야 한다.

참고: 심적 표상(mental representations) 이란?
물체, 문제, 일의 상태 등에 관한 지식이 마음에 저장되는 방식.

⇒ TDD를 위와 같은 의식적인 연습 원칙을 통해 효과적으로 연습하자!

객체 지향 생활 체조 원칙을 통한 의식적인 연습

OOP를 학습할때 효과적인 방식이다.

  • 규칙 1 : 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
  • 규칙 2 : else 예약어를 쓰지 않는다.
  • 규칙 3 : 모든 원시값과 문자열을 포장한다.
  • 규칙 4 : 한 줄에 점을 하나만 찍는다.
  • 규칙 5 : 줄여쓰지 않는다. (축약 금지)
  • 규칙 6 : 모든 엔티티를 작게 유지한다.
  • 규칙 7 : 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  • 규칙 8 : 일급 콜렉션을 쓴다.
  • 규칙 9 : 게터/세터/프로퍼티를 쓰지 않는다.

→ 거의 정량적인 기준이다. 정량적인 기준으로 연습하는게 OOP 연습에 도움이 된다.

정성적인 기준으로는 연습하기가 굉장히 힘들기 때문이다.

이제 연습을 해보자.

 

문자열 덧셈 계산기를 통한 TDD/리팩토링 실습

기능 요구사항

  • 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환 (예: “” => 0, "1,2" => 3, "1,2,3" => 6, “1,2:3” => 6)
  • 앞의 기본 구분자(쉼표, 콜론)외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 “//”와 “\n” 사이에 위치하는 문자를 커스텀 구분자로 사용한다. 예를 들어 “//;\n1;2;3”과 같이 값을 입력할 경우 커스텀 구분자는 세미콜론(;)이며, 결과 값은 6이 반환되어야 한다.
  • 문자열 계산기에 숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw한다.

프로그래밍 요구사항

  • 메소드가 너무 많은 일을 하지 않도록 분리하기 위해 노력해 본다.

 

의식적인 연습 1 - 기능 요구사항 분리

    1. 빈 문자열 또는 null 값을 입력할 경우 0을 반환해야 한다.(예 : “” => 0, null => 0)
    1. 숫자 하나를 문자열로 입력할 경우 해당 숫자를 반환한다.(예 : “1”)
    1. 숫자 두개를 컴마(,) 구분자로 입력할 경우 두 숫자의 합을 반환한다.(예 : “1,2”)
    1. 구분자를 컴마(,) 이외에 콜론(:)을 사용할 수 있다. (예 : “1,2:3” => 6)
    1. “//”와 “\n” 문자 사이에 커스텀 구분자를 지정할 수 있다. (예 : “//;\n1;2;3” => 6)
    1. 음수를 전달할 경우 RuntimeException 예외가 발생해야 한다. (예 : “-1,2,3”)

이렇게 기능 목록을 만든 다음, TDD로 구현이 가능할 것같은 것들을 모아서 걔부터 TDD를 한다. TDD 하기 어려운 것은 하지 않는다.

 

의식적인 연습 2 - 실패하는 테스트 먼저 구현하기

 

의식적인 연습 3 - TFD + 리팩터링

앞서 본 TFD (Test First Development)와 리팩터링을 진행한다!

TDD는 input과 output을 먼저 잘 설정하는게 중요하다.

1. 빈 문자열 또는 null 값을 입력할 경우 0을 반환해야 한다.(예 : “” => 0, null => 0)

input은 null, 테스트 메서드 이름은 아직 안정했으므로 대략 c라고 정한다.

당연히 메서드가 없기 때문에 컴파일 되지 않는다.

이 상태에서 main 모듈에 해당 클래스를 만든다.

이렇게 test 모듈에서 main 모듈로 클래스를 만드는것이 익숙해야 한다.

public class C {
    public static int c(String o) {
        return 0;
    }
}

메서드를 대략적으로 만든 뒤 테스트를 실행하면 성공한다. (우연히 return 0과 테스트에서의 기대값이 같아서 생긴 결과)

이제 리팩터링의 시간이다. 우선 이름이 마음에 안든다. 이름에 대한 리팩토링을 진행한다.

public class StringAddCalculator {
    public static int splitAndSum(String value) {
        return 0;
    }
}
public class StringAddCalculatorTest {
    @Test
    void 빈_문자열_공백문자_일때() {
        int result = StringAddCalculator.splitAndSum(null);
        assertThat(result).isEqualTo(0);

                result = StringAddCalculator.splitAndSum("");
        assertThat(result).isEqualTo(0);
    }
}

여기서 테스트 메서드를 한글로 썼는데, 팀의 정책에 따라가는게 맞다. 보통은 테스트 메서드의 의미를 전달하는데 집중하기 때문에 영어로 쓰고 전달력이 떨어지는거보다 차라리 한글로 쓰고 그 의도를 명확하게 표현하는게 더 낫다.

 

아니면 @DisplayName 을 이용하는 방법도 있다. 하지만 테스트 메서드 이름과 중복된 의미를 가질 수 있기 때문에 테스트 메서드 이름에 그 의도를 명확히 밝히는게 더 깔끔할 수 있다. (물론 테스트 메서드 이름에 언더바를 쓰는게 단점이긴 하지만..)

 

아래와 같이 불필요한 지역변수를 제거하여 사용해도 좋다.

public class StringAddCalculatorTest {
    @Test
    void 빈_문자열_공백문자_일때() {
        assertThat(StringAddCalculator.splitAndSum(null)).isEqualTo(0);
        assertThat(StringAddCalculator.splitAndSum("")).isEqualTo(0);
    }
}

given, when, then 방식으로 쓰는 경우도 있지만, 이렇게 되면 라인 단위로 구분해줘야 하기때문에 지역변수를 사용할 수 밖에 없다. 그렇게 되면 given부분이 없는 경우도 많고 지역변수의 네이밍도 고려해야하고 타이핑 횟수도 늘어나서 꼭 given, when, then 방식을 써야할 필요는 없다.

굳이 given when then 방식을 보여주고 싶다면 주석으로 보여주기보다는 공백라인을 통해 보여주는 방식도 있다.

2. 숫자 하나를 문자열로 입력할 경우 해당 숫자를 반환한다.(예 : “1”)

3. 숫자 두개를 컴마(,) 구분자로 입력할 경우 두 숫자의 합을 반환한다.(예 : “1,2”)

2, 3번 기능 목록에 대한 실패 테스트 코드를 작성한다.

1번 기능 목록에 대한 실질적인 구현과 함께 구현해본다. (1번 기능 목록이 우연히 성공했어서 구현을 안함..)

public class StringAddCalculator {
    public static int splitAndSum(String text) {
        int result = 0;
        if (text == null || text.isBlank()) {
            result = 0;
        } else {
            String[] values = text.split(",");
            for (String value : values) {
                result += Integer.parseInt(value);
            }
        }
        return result;
    }
}

TDD는 바로 이와같은 방식으로 진행된다.

나머지 기능 목록사항도 구현해보자.

public class StringAddCalculatorTest {
    @Test
    void 빈_문자열_공백문자_일때() {
        int result = StringAddCalculator.splitAndSum(null);
        assertThat(result).isEqualTo(0);

        result = StringAddCalculator.splitAndSum("");
        assertThat(result).isEqualTo(0);
    }

    @Test
    void 쉼표_구분자() {
        assertThat(StringAddCalculator.splitAndSum("1,2")).isEqualTo(3);
    }

    @Test
    void 콜론_구분자() {
        assertThat(StringAddCalculator.splitAndSum("1:2")).isEqualTo(3);
    }

    @Test
    void 커스텀_구분자() {
        assertThat(StringAddCalculator.splitAndSum("//;\n1;2")).isEqualTo(3);
    }

    @Test
    void 음수_기본구분자() {
        assertThatThrownBy(() -> {
            StringAddCalculator.splitAndSum("-1:2,3");
        }).isInstanceOf(RuntimeException.class);
    }

    @Test
    void 음수_커스텀구분자() {
        assertThatThrownBy(() -> {
            StringAddCalculator.splitAndSum("//;\n-1;2;3");
        }).isInstanceOf(RuntimeException.class);
    }
}
public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        int result = 0;
        if (text == null || text.isBlank()) {
            result = 0;
        } else {
            Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
            if (m.find()) {
                String customDelimiter = m.group(1);
                String[] values= m.group(2).split(customDelimiter);
                for (String value : values) {
                    result += Integer.parseInt(value);
                }
            } else {
                String[] values = text.split(DEFAULT_DELIMITER);
                for (String value : values) {
                    int number = Integer.parseInt(value);
                    if (number < 0) {
                        throw new RuntimeException("음수는 허용하지 않습니다.");
                    }
                    result += number;
                }
            }
        }
        return result;
    }
}

리팩토링을 어떻게 할지 집중하기 위해 프로덕션 코드를 쓰레기 코드로 만들었는데, 다음에는 TDD로 기능단위 구현하면서 바로바로 리팩토링 하도록 하자.

 

else 문을 사용하게 되면, 지역 변수를 선언하고, if - else 문을 타고 따라가며 사용된다. 이러한 로컬 변수가 메서드 전체에 사용되는것은 좋은 방식이 아니다. (버그 발생할 확률이 매우 높아짐) 그래서 else 문 없이 사용하려면 early - return 을 통해, if 문에서 바로 return 해버린다. 이것만으로 depth가 한단계 낮아지게 된다.

public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        if (text == null || text.isBlank()) {
            return 0; //early - return
        }
        int result = 0;
        Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
        if (m.find()) {
            String customDelimiter = m.group(1);
            String[] values= m.group(2).split(customDelimiter);
            for (String value : values) {
                result += Integer.parseInt(value);
            }
        } else {
            String[] values = text.split(DEFAULT_DELIMITER);
            for (String value : values) {
                int number = Integer.parseInt(value);
                if (number < 0) {
                    throw new RuntimeException("음수는 허용하지 않습니다."); // depth 2
                }
                result += number;
            }
        }
        return result;
    }
}

if문 안의 for문을 보면 depth가 한단계 더 들어가있다. 이를 메서드 추출의 힌트로 생각하자.

public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        if (text == null || text.isBlank()) {
            return 0;
        }
        int result = 0;
        Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
        if (m.find()) {
            String customDelimiter = m.group(1);
            String[] values= m.group(2).split(customDelimiter);
            result = sum(values, result);
        } else {
            String[] values = text.split(DEFAULT_DELIMITER); // else 사용
            result = sum(values, result);
        }
        return result;
    }

    private static int sum(String[] values, int result) {
        for (String value : values) {
            int number = Integer.parseInt(value);
            if (number < 0) {
                throw new RuntimeException("음수는 허용하지 않습니다.");
            }
            result += number;
        }
        return result;
    }
}

sum 메서드를 통해 중복 내용을 제거하고, 커스텀 문자의 음수허용 여부까지 동시에 처리가 가능하게 된다

남은 else문도 early - return으로 리팩토링 한다. 이때 지역변수 result가 사라진다.

public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        if (text == null || text.isBlank()) {
            return 0;
        }
        Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
        if (m.find()) {
            String customDelimiter = m.group(1);
            String[] values= m.group(2).split(customDelimiter);
            return sum(values);
        }
        String[] values = text.split(DEFAULT_DELIMITER);
        return sum(values);
    }

    private static int sum(String[] values) {
        int sum = 0;
        for (String value : values) {
            int number = Integer.parseInt(value);
            if (number < 0) {
                throw new RuntimeException("음수는 허용하지 않습니다."); // depth 2
            }
            sum += number;
        }
        return sum;
    }
}

리팩토링이 끝날때마다 항상 전체 테스트케이스를 돌려준다. 설계가 끝나자마자 바로 피드백을 받을 수 있다!

메서드로 분리한 sum 메서드에 indent가 if문으로 또 2 depth이므로 int로 바꿔주는 메서드로 다시 분리한다.

public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        if (text == null || text.isBlank()) {
            return 0;
        }
        Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
        if (m.find()) {
            String customDelimiter = m.group(1);
            String[] values= m.group(2).split(customDelimiter);
            return sum(values);
        }
        String[] values = text.split(DEFAULT_DELIMITER);
        return sum(values);
    }

    private static int sum(String[] values) {
        int sum = 0;
        for (String value : values) {
            sum += toInt(value); // 쓸모 없는 지역변수 삭제
        }
        return sum;
    }

    private static int toInt(String value) {
        int number = Integer.parseInt(value);
        if (number < 0) {
            throw new RuntimeException("음수는 허용하지 않습니다.");
        }
        return number;
    }
}

이렇게 indent를 depth 1로 만드는 리팩토링을 하다보면 계속해서 메서드를 분리할 수 밖에없다. 그리고 메서드는 가능한 작은 단위로 분리하는게 의미가 있다.

 

리팩토링을 하면서 쓸모 없는 지역변수들이 생기면 바로 없애주는 연습도 하자!

 

지금까지 리팩토링한 것을 봤을때, 사실 sum 메서드는 합 뿐만 아니라 String 배열을 int로 바꿔주는 2가지 역할을 한다. (메서드는 한가지 일만 하도록 시키는게 규칙이다.)

public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        if (text == null || text.isBlank()) {
            return 0;
        }
        Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
        if (m.find()) {
            String customDelimiter = m.group(1);
            String[] values= m.group(2).split(customDelimiter);
            return sum(toInts(values));
        }
        String[] values = text.split(DEFAULT_DELIMITER);
        return sum(toInts(values));
    }

    private static int[] toInts(String[] values) {
        int[] numbers = new int[values.length];
        for (int i = 0; i < values.length; i++) {
            numbers[i] = toInt(values[i]);
        }
        return numbers;
    }

    private static int sum(int[] values) {
        int sum = 0;
        for (int value : values) {
            sum += value;
        }
        return sum;
    }

    private static int toInt(String value) {
        int number = Integer.parseInt(value);
        if (number < 0) {
            throw new RuntimeException("음수는 허용하지 않습니다.");
        }
        return number;
    }
}

toInts 같은 부분은 자바의 stream으로 처리할 수 있지만, 처음에 OOP를 연습할때는 가능한 stream을 사용하지 않고 구현해보자.

sum(toInts(values))이런식의 메서드 체이닝 방식이 함수형 프로그래밍의 스타일이다.

 

참고 : 함수형 프로그래밍 스타일을 하게 되면 성능이 떨어지는 경우도 있고, 코드의 복잡도가 증가하는 경우, 유지보수 하기 힘들 수도 있어서, OOP(클래스들 간의 설계) 와 함수형 프로그래밍을 섞어서 작업하는게 유지보수하기 편한 코드가 될 수 있다.

 

위와 같이 toInts로 메서드는 하나의 일을 할 수 있도록 분리하였다. 그런데 이렇게 되면 for문을 2번 돌게 된다. 근데 사실 성능은 n번만 돌던것이 2n 도는 것으로 바뀔 뿐이다. 한번 더 돈다고 해서 어플리케이션에 엄청난 영향을 미치지는 않는다. (기껏해야 0.000x [ms] 정도 느려질 뿐이다.)

 

과거에는 하드웨어 성능이 떨어졌기 때문에 for문을 더 돌거, 클래스 분리를 하면 성능이 떨어진다. 하지만 현재는 하드웨어 성능이 굉장히 좋다. 오히려 인건비가 더 비싸기 때문에 코드를 구현할 때 성능도 물론 중요하지만 읽기 좋은 코드를 구현하고 재사용 가능한 코드를 만드는 게 더 중요하다.

 

이렇게 가독성 좋은 코드를 작성하다보니 위와 같이 for문을 한번 더 도는식의 성능 문제가 있다면 그때는 다시 합치면 된다. (하지만 거의 몇% 안된다.) 오히려 성능이 떨어지는건 데이터베이스와 인터페이스 작업 혹은, 외부 API 작업에서 빈번히 발생한다. 쿼리 튜닝, 인덱스 설계에 시간을 투자해야지 어플리케이션 코드는 유지보수하기 좋은, 클린코드에 집중해야한다. 만약 성능이 좀 크리티컬하다면 그때 그 부분만 합치면 된다. (근데 그럴일 거의 없다.)

public class StringAddCalculator {

    public static final String DEFAULT_DELIMITER = ",|:";
    public static final String CUSTOM_DELIMITER_REGEXP = "//(.)\n(.*)";

    public static int splitAndSum(String text) {
        if (isBlank(text)) {
            return 0;
        }
        Matcher m = Pattern.compile(CUSTOM_DELIMITER_REGEXP).matcher(text);
        if (m.find()) {
            String customDelimiter = m.group(1);
            String[] values= split(m.group(2), customDelimiter);
            return sum(toInts(values));
        }
        String[] values = split(text, DEFAULT_DELIMITER);
        return sum(toInts(values));
    }

    private static String[] split(String text, String customDelimiter) {
        return text.split(customDelimiter);
    }

    private static boolean isBlank(String text) {
        return text == null || text.isBlank();
    }

    private static int[] toInts(String[] values) {
        int[] numbers = new int[values.length];
        for (int i = 0; i < values.length; i++) {
            numbers[i] = toInt(values[i]);
        }
        return numbers;
    }

    private static int sum(int[] values) {
        int sum = 0;
        for (int value : values) {
            sum += value;
        }
        return sum;
    }

    private static int toInt(String value) {
        int number = Integer.parseInt(value);
        if (number < 0) {
            throw new RuntimeException("음수는 허용하지 않습니다.");
        }
        return number;
    }
}

남은 부분에 대한 로직들 또한 메서드로 분리하면 좋다. (메서드를 분리할때 compose method pattern이라고해서, 예를들어 toInts와 같이 메서드가 분리될때를 추상화 Level 1이라고 한다. 또 isBlank로 null과 empty 부분을 리팩토링 할 수도 있다.)

 

이런식의 메서드 분리에 익숙해지면, 클래스 분리를 하는 부분 또한 잘 고려하면 된다.

여기서 toInt 메서드를 보면, 음수값에 대한 예외처리도 있으면서 동시에 int 변환이 이루어지고 있어서 하나의 메서드가 두가지 역할을 하고 있는게 아닌가 라는 생각을 할 수 있다. 이럴때, 메서드 분리로 처리할 수도 있지만 클래스 분리로 처리할 수도 있다.

public class Positive {
    private int number;

    Positive(String value) {
        int number = Integer.parseInt(value);
        if (number < 0) {
            throw new RuntimeException("음수는 허용하지 않습니다.");
        }
    }
}

이렇게 number를 감싸는 Positive 객체를 만드는 것이다. 그러면 이 객체를 생성할때 생성자에서 음수에 대한 유효성 검사를 하기 때문에 항상 이 Positive 객체가 감싸는 number는 양수가 된다.

 

근데 생성자가 또 두가지 역할을 하는게 아닌가? 라고생각할 수 있다. 그럴때 생성자를 또 여러개 만들어서 처리하면 된다.

public class Positive {
    private int number;

    Positive(String value) {
        this(Integer.parseInt(value));
    }

    Positive(int number) {
        if (number < 0) {
            throw new RuntimeException("음수는 허용하지 않습니다.");
        }
        this.number = number;
    }
}

이런식으로 메서드 분리하듯이 생성자를 분리할 수 있다.

객체지향 설계를 하면서 생성자를 많이 생성하게 될 것이다.

 

참고 : CRUD는 테스트 하지 않는다. (시간낭비) 데이터베이스 쿼리가 복잡하면 사실 이건 통합테스트이다. 핵심 비즈니스로직에 집중하는게 시간투자 대비 효과가 가장 높다. CRUD 테스트는 테이블 부터 데이터까지 필요한게 많기때문에 CRUD 테스트는 하지 않는다. → 테스트도 비용이기 떄문에 어느부분에 투자하고 하지 말지를 알아야 한다.

참고 : 커밋로그는 가장 작은 단위로 하는게 좋고, 기능목록 단위로 하는게 좋다. (하나의 테스트케이스 단위) → 즉, TDD 사이클이 끝났을때 하는걸 추천 (리팩토링이 완료되는 시점)

 

참고 : private 메서드는 테스트 하지 않는다. 만약 private을 직접 테스트를 하고 싶다면, 설계를 다르게 해야하는 힌트로 생각하면 된다. (private은 public을 통해서 테스트 하는걸 원칙으로 한다.)

 

참고 : 자바의 다형성을 이용하면 if - else / switch 문을 없앨 수 있다. 삼항 연산자도 가독성을 낮추기 때문에 사용을 권하지 않는다.

 

느낀점

지금까지의 내용이 엄청 어려운 내용은 아니었지만, 실제 코드에 적용하다보면 모호한 경우가 많았다. 아직 갈길이 많이 남았지만, 처음에 하나씩 규칙들을 이해하고 적용해 나가보고 싶다.

 

자동차 경주 - 미션 피드백 링크 : 

step 1 :  https://github.com/next-step/java-racingcar/pull/3091

step 2 : https://github.com/next-step/java-racingcar/pull/3206

 

댓글

Designed by JB FACTORY