객제지향, TDD, 클린코드/NextStep

'단위테스트'와 'TDD'에 대하여

ummchicken 2023. 3. 13. 19:44

객제지향, TDD, 클린코드에 대한 학습을 하기 위해 

'NEXTSTEP 플레이그라운드' 과정을 진행하고 있다.

총 4번의 단계별 미션이 있다.

 

 

4~5개월 전에 우테코 프리코스를 약 4주간 참여한 경험이 있다.

이번 '자바 플레이그라운드' 코스도 우테코 프리코스와 비슷한 것 같다.

 

단계에 따라 구체적인 요구사항이 추가되는 형식.

 

우테코 프리코스 때는 단순 회고만 올렸다.

하지만 이번엔 회고뿐만 아니라, 

나의 코드를 분석해보는 과정까지 블로그에 담아보려 한다.

 

 

'NEXTSTEP 플레이그라운드' 과정은 총 4회의 단계별 미션이 있다.

1회차는 [숫자 야구 게임]이다.

그것을 학습하며 깨달은 것들을 기록해 볼 예정이다.

 

 

 


 

📌 목표

  1. 매일 미션 진행하기
    • 한 번에 모두 구현하기 보다, 매일 일정한 시간을 투자한다.
  2. 가진 것을 비우기
    • 구체적인 요구사항을 회피하지 않고, 적용하기 전과 후의 코드를 분석한다.
    • 내가 가진 것을 비울 때, 가장 많은 것을 배울 수 있다.
  3. 정답을 찾기 위해 집착하지 않는다.
    • 미션을 진행하는데 정답은 없다.
    • 정답을 찾으려는 노력이 오히려 학습을 방해한다.
    • 즉, 현재 상황에서 최선의 답을 끊임없이 찾으려고 노력한다.

 

 


 

✔️ 학습 목표 - 숫자 야구 게임

  • 자바 code convention을 지키면서 프로그래밍하는 경험
  • JUnit 사용법을 익혀, 단위 테스트하는 경험
  • 학습테스트를 하면서 JUnit 사용법을 익히는 경험
  • 메소드를 분리하는 리팩터링 경험

 

 

 

✔️ 객체지향 연습 원칙

  1. 규칙 1: 한 메서드에 오직 한 단계의 들여쓰기만 한다.
  2. 규칙 2: else 예약어를 쓰지 않는다.

 

즉, 메소드를 분리해 메소드가 한 가지 작업만 담당하도록 구현하는 연습이 목표이다.

메소드 라인 수를 15라인이 넘지 않도록 구현한다.

 

 

 

✔️ Clean Code 가이드 - 함수(메소드)

  1. 작게 만든다.
  2. 한 가지만 한다.
  3. 함수 당 추상화 수준은 하나로 한다.
    • 함수가 확실히 '한 가지' 작업만 하려면, 함수 내 모든 문장이 동일한 추상화 수준에 있어야 한다.
    • 코드는 위에서 아래로 이야기처럼 일해야 좋다.
  4. 서술적인 이름을 사용한다.
    • 길어도 괜찮다.
    • 일관성이 있어야 한다.
  5. 함수 인수
    • 함수에서 이상적인 인수 개수는 0개(무항)이다. 다음은 1개, 2개이다.
    • 3개는 가능한 피하는 편이 좋고, 4개 이상은 특별한 이유가 있어도 사용하면 안 된다.
    • 만약 인수가 2~3개 필요한 경우가 생긴다면, 인수를 독자적인 클래스를 생성할 수 있는지 검토해 본다.
  6. side effect를 만들지 않는다.
  7. 명령과 조회를 분리한다.
    • 개체 상태를 변경하거나, 아니면 개체 정보를 반환하거나 둘 중 하나이다.
  8. 오류 코드보다 예외를 사용한다.
    • 오류 처리도 한 가지 작업이다.
      • 함수는 '한 가지' 작업만 해야 한다. 오류 처리도 '한 가지' 작업에 속한다.
      • 그러므로 오류를 처리하는 함수는 오류만 처리해야 마땅하다.
    • try/catch 블록을 별도 함수로 뽑아내는 게 낫다.
  9. 반복하지 않는다.

 

 


 

✔️ 프로그래밍 요구사항

  1. 자바 코드 컨벤션을 지키면서 프로그래밍한다.
    • 들여쓰기는 '4 spaces'로 한다.
  2. indent(인덴트, 들여쓰기) depth를 2가 넘지 않도록 구현한다. 1까지만 허용한다.
    • 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
    • indent depth를 줄이는 좋은 방법은, 함수(또는 메소드)를 분리하면 된다.
  3. else 예약어를 쓰지 않는다.
    • 예를 들어, if 조건절에서 값을 return하는 방식으로 구현하면, else를 사용하지 않아도 된다.
    • switch/case도 허용하지 않는다.
  4. 모든 로직에 단위 테스트를 구현한다. 단, UI(System.out, System.in) 로직은 제외
    • 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.
    • UI 로직을 InputView, ResultView와 같은 클래스를 추가해 분리한다.
  5. 3항 연산자를 쓰지 않는다.
  6. 함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라.

 

 

(우테코 프리코스 할 때랑 같은 내용이다.)

 

 

 


 

✔️ 단위테스트에 대하여

  1. 단위테스트를 기반으로 개발하려면, to-do list가 잘 정리되어 있어야 한다.
    • 즉, 요구사항 분석을 잘 해야한다.

 

 

 

✔️ TDD를 하는 이유

  1. 디버깅 시간을 줄여준다.
  2. 동작하는 문서 역할을 한다.
  3. 변화에 대한 두려움을 줄여준다.

 

 

✔️ TDD 사이클

TDD 사이클

  1. 실패하는 테스트를 구현한다.
  2. 테스트가 성공하도록 프로덕션 코드를 구현한다.
  3. 프로덕션 코드테스트 코드를 리팩토링한다.
    • 둘다 해야 한다.
      • 테스트코드의 중복이 제거되기 때문.
      • 기능이 변경이 되었을 때, 빠르게 개선할 수 있다.

 

 

✔️ TDD 원칙

  1. 원칙 1 - 실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.
  2. 원칙 2 - 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 원칙 3 - 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

 

💡 즉, 너무 많은 부분을 예측해서 개발하지 말고, 
현재 테스트 케이스를 만족할 수 있는 수준으로만 프로덕션 코드를 개발해라.

미래를 예측해서 오버 엔지니어링을 하는 경우를 막기 위해 
딱 필요한 정도로만 프로덕션 코드를 구현하라는 것이다.

 

 

 


 

그럼 TDD는 어떻게 해야할까?

 

✔️ 도메인 지식, 객체 설계 경험이 있는 경우

  • 요구사항 분석을 통해 대략적인 설계 - 객체 추출
  • UI, DB 등과 의존관계를 가지지 않는 핵심 도메인 영역을 집중 설계

 

  • 1차적으로는 도메인 로직을 테스트하는 것에 집중한다.

  • 가능한 MVC 기반으로 영역을 분리한 다음에, 도메인 영역 클래스 설계를 한다.

 

 


 

✔️ 숫자 야구 게임에 대한 도메인 설계

  • 대략적인 도메인 객체 설계

 

 

 

 

처음이라 낯설고, 너무 어려울 땐...

✔️ 구현할 기능 목록 작성하기

  • 구현할 기능 목록을 작성한 후에 TDD로 도전
  • 기능 목록을 작성하는 것도 역량이 필요한 것 아닌가?
  • 역량도 중요하지만, 연습이 필요하다.
    • to-do list를 잘 쪼갠다는 것도, 요구사항 분석을 잘 한 것이다.
    • 그러면, 요구사항에 대한 도메인 지식도 쌓이는 것이다.
  • 어떤 단위로 쪼갤 것인가에 대해 고민을 해야 한다.
    • 그러면 자연스럽게 클래스 설계가 나올 수 있다.

 

(처음부터 잘 하지 않고, 어려워하는 것에 대한 격려를 해주시는 포비님...)

 

💡 아무 것도 없는 상태에서 새롭게 구현하는 것보다, 
레거시 코드가 있는 상태에서 리팩토링하는 것은 몇 배 더 어렵다.

(이건 내가 조금이나마 경험해 봤다. 

내가 짠 코드 리팩토링하려니 머리 아파서 뒤집어 엎은 경우 있음.

근데 첨부터 다시 하려니까 그것도 쉽지 않아서 

결국 다시 내가 짠 코드 리팩토링 함.

그 과정에서 배운 게 많았다.)

 

 

다음은 피드백 기능 목록이다.

피드백 기능 목록

  • to-do list는 계속해서 수정하거나 업데이트 가능하다.

 

 

 

그럼 난 기능 목록을 어떻게 작성했을까?

내가 작성한 기능 목록

  • 예외 처리에 대한 부분이 전혀 없다. (반성...)

 

 


 

그럼 이제 TDD를 적용해보자.

✔️ 1 단계

Util 성격의 기능이 TDD로 도전하기 좋다.

 

  • 사용자로부터 입력 받는 3개 숫자 예외 처리를 해보자.
    • 1 ~ 9의 숫자인가?
    • 중복 값이 있는가?
    • 3자리인가?

 

1. Test 코드 작성

@Test
void 야구_숫자_1_9_검증() {
    assertThat(ValidationUtils.validNo(9)).isTrue();
    assertThat(ValidationUtils.validNo(1)).isTrue();
    assertThat(ValidationUtils.validNo(0)).isFalse();
    assertThat(ValidationUtils.validNo(10)).isFalse();
}
  • input과 output이 있는 것을 테스트 하는 게 명확하게 보기 쉽다.
  • 경계값을 테스트하면, 적은 자료로도 테스트를 할 수 있다.

 

 

2. 클래스 생성

public class ValidationUtils {

    public static final int MIN_NO = 1;
    public static final int MAX_NO = 9;

    public static boolean validNo(int no) {
        return no >= MIN_NO && no <= MAX_NO;
    }
}
  • 하드코딩을 하지 않기 위해 상수를 선언한다.

 

 

 

✔️ 2 단계

테스트가 가능한 부분에 대해 TDD로 도전해보자.

(랜덤 숫자나 날짜 같은 것들은 테스트 하기 어렵다.)

 

  • 위치와 숫자 값이 같은 경우 - 스트라이크
  • 위치는 다른데 숫자 값이 같은 경우 - 볼
  • 숫자 값이 다른 경우 - 낫싱

 

💡 한 번에 모든 기능을 테스트하는 코드를 만들기 보단, 
작은 단위로 쪼개서 구현해라.

∵ 문제를 너무 큰 단위로 구현하려면 어렵다.

 

 

  • 스트라이크, 볼, 낫싱 판별

피드백 코드

public class Ball {
    private final int position;
    private final int ballNo;

    public Ball(int position, int ballNo) {
        this.position = position;
        this.ballNo = ballNo;
    }

    public BallStatus play(Ball ball) {
        if (this.equals(ball)) {
            return BallStatus.STRIKE;
        }

        if (ball.matchBallNo(ballNo)) {
            return BallStatus.BALL;
        }

        return BallStatus.NOTHING;
    }

    private boolean matchBallNo(int ballNo) {
        return this.ballNo == ballNo;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Ball ball = (Ball) o;
        return position == ball.position && ballNo == ball.ballNo;
    }

    @Override
    public int hashCode() {
        return Objects.hash(position, ballNo);
    }
}

(이것이 객체지향이구나... 난 여태까지 절차지향 코드를 짰던 것인가ㄷㄷ)

  • equals를 재정의 했다.
    • position과 number가 같으면 return 되고, 
    • 자연스럽게 그 밑의 if는 number만 같은지 판별하게 된다.

 

 

 

내 코드 (정말 민망해지는 코드다ㅋ;;)

public static boolean judgeNumber(String numStr, ArrayList<Integer> computerNum) {
    int countStrike = 0;
    int countBall = 0;

    for(int i = 0; i < 3; i++) {
        if(computerNum.contains(numStr.charAt(i) - '0')) countBall++;
        if(computerNum.get(i) == numStr.charAt(i) - '0') countStrike++;
    }
    countBall = countBall - countStrike;
    printResult(countStrike, countBall);

    if(countStrike == 3) return true;

    return false;
}
  • 자리와 숫자를 판별하는 메소드를 따로 만들지 않고, 일치하는 갯수만 카운트 해 판별했다.
    • 쪼갤 수 있을 때까지 쪼개는 연습을 하자.
    • 그러면 자연스럽게 depth가 1이 되어야 하는 원칙을 지킬 수 있다.

 

 


 

✔️ depth

while (true) {
    System.out.print("숫자를 입력해 주세요 : ");
    String numStr = br.readLine();
    boolean judge = judgeNumber(numStr, computerNum);

    if(judge) {
        replay = replay();
        if (replay) return;
        computerNum = generateNumber();
        System.out.println(computerNum);
    }
}
  • depth가 3이 나와버렸다.
    • 요구 사항은 depth가 2를 넘지 않은, 즉 1까지만 허용하는 것이다.

 

 

 

리팩토링을 해보자.

💡 indent depth를 줄이는 좋은 방법은, 함수(또는 메소드)를 분리하면 된다.

 

 

 

boolean replay = false;
while (replay != true) {
    System.out.print("숫자를 입력해 주세요 : ");
    String numStr = br.readLine();
    boolean judge = judgeNumber(numStr, computerNum);

    if(judge) {
        replay = replay();
        computerNum = generateNumber();
        System.out.println(computerNum);
    }
}

나름 임시방편으로 바꿔봤는데... 

depth는 2가 됐지만, 좀 별로다.

억지부려서 한 느낌.

 

한 메소드는 하나의 기능만 하도록 작게 쪼개야 하는데, 

내 메소드들은 한 번에 너무 많은 기능들을 담당한다.

 

어려운 객체지향의 세계...

 

 

 

 

 

 

객체지향은 파면 팔 수록 어렵다.

계속 공부를 해야한다.

 

 

 

 

 

 

 

 

 

 

 

'객제지향, TDD, 클린코드 > NextStep' 카테고리의 다른 글

'TDD'와 '리팩토링'에 대하여  (0) 2023.04.03