객제지향, TDD, 클린코드에 대한 학습을 하기 위해
'NEXTSTEP 플레이그라운드' 과정을 진행하고 있다.
총 4번의 단계별 미션이 있다.
4~5개월 전에 우테코 프리코스를 약 4주간 참여한 경험이 있다.
이번 '자바 플레이그라운드' 코스도 우테코 프리코스와 비슷한 것 같다.
단계에 따라 구체적인 요구사항이 추가되는 형식.
우테코 프리코스 때는 단순 회고만 올렸다.
하지만 이번엔 회고뿐만 아니라,
나의 코드를 분석해보는 과정까지 블로그에 담아보려 한다.
'NEXTSTEP 플레이그라운드' 과정은 총 4회의 단계별 미션이 있다.
1회차는 [숫자 야구 게임]이다.
그것을 학습하며 깨달은 것들을 기록해 볼 예정이다.
📌 목표
- 매일 미션 진행하기
- 한 번에 모두 구현하기 보다, 매일 일정한 시간을 투자한다.
- 가진 것을 비우기
- 구체적인 요구사항을 회피하지 않고, 적용하기 전과 후의 코드를 분석한다.
- 내가 가진 것을 비울 때, 가장 많은 것을 배울 수 있다.
- 정답을 찾기 위해 집착하지 않는다.
- 미션을 진행하는데 정답은 없다.
- 정답을 찾으려는 노력이 오히려 학습을 방해한다.
- 즉, 현재 상황에서 최선의 답을 끊임없이 찾으려고 노력한다.
✔️ 학습 목표 - 숫자 야구 게임
- 자바 code convention을 지키면서 프로그래밍하는 경험
- JUnit 사용법을 익혀, 단위 테스트하는 경험
- 학습테스트를 하면서 JUnit 사용법을 익히는 경험
- 메소드를 분리하는 리팩터링 경험
✔️ 객체지향 연습 원칙
- 규칙 1: 한 메서드에 오직 한 단계의 들여쓰기만 한다.
- 규칙 2: else 예약어를 쓰지 않는다.
즉, 메소드를 분리해 메소드가 한 가지 작업만 담당하도록 구현하는 연습이 목표이다.
메소드 라인 수를 15라인이 넘지 않도록 구현한다.
✔️ Clean Code 가이드 - 함수(메소드)
- 작게 만든다.
- 한 가지만 한다.
- 함수 당 추상화 수준은 하나로 한다.
- 함수가 확실히 '한 가지' 작업만 하려면, 함수 내 모든 문장이 동일한 추상화 수준에 있어야 한다.
- 코드는 위에서 아래로 이야기처럼 일해야 좋다.
- 서술적인 이름을 사용한다.
- 길어도 괜찮다.
- 일관성이 있어야 한다.
- 함수 인수
- 함수에서 이상적인 인수 개수는 0개(무항)이다. 다음은 1개, 2개이다.
- 3개는 가능한 피하는 편이 좋고, 4개 이상은 특별한 이유가 있어도 사용하면 안 된다.
- 만약 인수가 2~3개 필요한 경우가 생긴다면, 인수를 독자적인 클래스를 생성할 수 있는지 검토해 본다.
- side effect를 만들지 않는다.
- 명령과 조회를 분리한다.
- 개체 상태를 변경하거나, 아니면 개체 정보를 반환하거나 둘 중 하나이다.
- 오류 코드보다 예외를 사용한다.
- 오류 처리도 한 가지 작업이다.
- 함수는 '한 가지' 작업만 해야 한다. 오류 처리도 '한 가지' 작업에 속한다.
- 그러므로 오류를 처리하는 함수는 오류만 처리해야 마땅하다.
- try/catch 블록을 별도 함수로 뽑아내는 게 낫다.
- 오류 처리도 한 가지 작업이다.
- 반복하지 않는다.
✔️ 프로그래밍 요구사항
- 자바 코드 컨벤션을 지키면서 프로그래밍한다.
- 들여쓰기는 '4 spaces'로 한다.
- indent(인덴트, 들여쓰기) depth를 2가 넘지 않도록 구현한다. 1까지만 허용한다.
- 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
- indent depth를 줄이는 좋은 방법은, 함수(또는 메소드)를 분리하면 된다.
- else 예약어를 쓰지 않는다.
- 예를 들어, if 조건절에서 값을 return하는 방식으로 구현하면, else를 사용하지 않아도 된다.
- switch/case도 허용하지 않는다.
- 모든 로직에 단위 테스트를 구현한다. 단, UI(System.out, System.in) 로직은 제외
- 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.
- UI 로직을 InputView, ResultView와 같은 클래스를 추가해 분리한다.
- 3항 연산자를 쓰지 않는다.
- 함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라.
(우테코 프리코스 할 때랑 같은 내용이다.)
✔️ 단위테스트에 대하여
- 단위테스트를 기반으로 개발하려면, to-do list가 잘 정리되어 있어야 한다.
- 즉, 요구사항 분석을 잘 해야한다.
✔️ TDD를 하는 이유
- 디버깅 시간을 줄여준다.
- 동작하는 문서 역할을 한다.
- 변화에 대한 두려움을 줄여준다.
✔️ TDD 사이클
- 실패하는 테스트를 구현한다.
- 테스트가 성공하도록 프로덕션 코드를 구현한다.
- 프로덕션 코드와 테스트 코드를 리팩토링한다.
- 둘다 해야 한다.
- 테스트코드의 중복이 제거되기 때문.
- 기능이 변경이 되었을 때, 빠르게 개선할 수 있다.
- 둘다 해야 한다.
✔️ TDD 원칙
- 원칙 1 - 실패하는 단위 테스트를 작성할 때까지 프로덕션 코드(production code)를 작성하지 않는다.
- 원칙 2 - 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 원칙 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 |
---|