[TDD] TDD 해보니까 어때? (1)
- 12 mins페어코딩과 TDD 방식으로 개발했던 프로젝트를 통해 좋은 코드에 대해 고민한적 없던 초보 개발자가 무엇을 개선할 수 있었는지 알아보자.
초보 개발자 TDD를 만나다.
갈피를 잡지못하고 방황하던 작년 7월, 주니어 개발자들의 아이돌(?) 이동욱님의 글을 보고 ‘코드스쿼드 웹백엔드 코스‘를 알게 되었다. 그 무렵 나는 무기력에 빠져있었고, 해당 교육이 내게 좋은 전환점이 될 것 같았다. 그래서 그해 9월부터 코드스쿼드를 다니기 시작했다.
나는 코드스쿼드를 통해 존경하는 박재성(a.k.a. javajigi) 님을 만나 TDD, 객체 지향적 설계, 리팩토링을 통한 클린코드 작성법, 개발자의 학습방법 등을 배울 수 있었다.
6개월이라는 짧은 기간 동안 많은 것을 배웠지만, 그중 가장 인상 깊었던 것은 테스트 주도 개발 방식을 통해 코드의 설계를 개선했던 경험이다.
코드에도 품질이 있다.
스승님(박재성님)께서 강조하신 말씀이 있다.
"TDD를 잘 활용하면 설계를 개선하고 '품질 높은 코드'를 만들 수 있다."
처음엔 두 가지 이유 로 저 말씀을 완전히 이해하지 못했다.
- 코드의 품질이 무엇인지.
- TDD를 사용하면 무엇이 어떻게 개선되는지.
코드의 품질이란 무엇인가?
우선 나는 그 전까지 한 번도 코드 품질에 대한 고민을 해본 적이 없었다. 애초에 코드에 품질이 있다는 생각도 안 해봤다. 나는 그저 코드는 구현하고 싶은 기능만 작동되게 하면 되는 것으로 생각했었다.
무지함을 해소하고자, 내 나름대로 코드의 품질이 무엇인지 정리해보았다.
[좋은 코드의 조건]
- 잘 작동한다.
- 읽기(이해하기) 쉽다.
- 중복이 없다.
- 변경(확장)이 용의하다.
- 잘 작동한다
당연히 개발의 목적은 잘 작동하는 소프트웨어를 만드는 것이다. 기본적으로 작동하지 않으면 무슨 소용인가?
- 읽기(이해하기) 쉽다.
“코드의 가독성이 왜 중요한가? 잘 동작하면 그뿐이지 않은가?”라고 생각할 수 있겠지만, 개발은 혼자 하는 것이 아니라 여럿이서 한다.
읽기 힘든 코드는 다른 사람이 이해하기 힘들고, 파악하는데 많은 시간을 쏟게 한다. 이로 인해 의미파악을 위한 커뮤니케이션에 추가적인 비용이 들어간다. 또, 수정이나 확장이 필요할 때 이해하는 시간이 필요해진다.
프로그래밍은 프로그래밍 언어로 한다. 언어의 목적은 의미를 전달하는 데 있다. 의미를 파악하기 힘들면 소통하는데 어려움을 겪는다. 가독성 좋은 코드를 작성해야 이런 비효율성을 개선할 수 있다.
- 중복이 없다.
중복은 해악이다.
코드에 중복되는 내용이 많으면, 수정이 힘들다. 예를 들어 A라는 기능과 B라는 기능에 공통으로 들어가는 부분이 있다. 공통 부분을 수정해야 할 때 포함하고 있는 모든 코드를 수정해야 한다. 비효율적일 뿐만 아니라 하나라도 빼먹는다면 전혀 다른 실행을 초래하게 된다.
그 때문에 중복된 코드는 분리하여 재사용할 수 있어야 한다.
- 변경(확장)이 용의하다.
소프트웨어는 고여있으면 안 된다. 뒤처지지 않으려면 새로운 기능이 추가되고, 기존 기능을 개선할 수 있어야 한다. 이를 위해서는 기존 코드를 수정하고 확장할 수 있어야 한다.
쉽게 기능을 확장하고 수정하기 위해서 중복을 제거하고, 변화에 대하여 영향을 최소화하기 위해 기능 간 의존성을 낮추어야 한다.
그래서 TDD를 하면 뭐가 좋은데..?
[엉클밥(객체 지향 개발의 선구자 “로버트 마틴”)의 TDD 원칙]
1. 실패하는 테스트를 작성하기 전에는 절대로 프로덕션 코드를 작성하지 않는다.
2. 실패하는 테스트 코드를 한번에 하나이상 작성하지 않는다
3. 현재 실패하고 있는 테스트를 통과하기에 충분한 정도를 넘어서는 프로덕션 코드를 작성하지 않는다.
TDD(Test Driven Development)는 실제 동작할 프로덕션 코드를 만들기 전에 테스트 케이스를 작성하고, 테스트 케이스를 통과시킨 후 해당 코드를 리팩토링하는 과정을 반복하는 개발 방식이다.
기존의 내 개발방식은 원하는 기능을 테스트 없이 구현하는 것이었다. 혹, 테스트를 한다 해도 테스트 케이스를 만드는 게 아니라 콘솔에 원하는 값이 찍히는지 확인하는 게 전부였다.
내게 테스트는 완성된 기능이 잘 동작하는지 확인하는 작업이었고, 테스트를 만드는 자체가 귀찮은 일이었다. 그래서 처음 TDD를 접했을 때, 익숙하지 않아 거부감부터 들었다.
“이 불편하고 귀찮기까지 한 TDD를 하면 좋은 코드가 뚝딱 만들어지는 것일까?”
역시 원리만 알아선 알 수 없다. 나는 직접 체험해보기 위해 커리큘럼과 별개로 이전에 혼자 진행했던 프로젝트를 TDD를 통해 리팩토링해보기로 결심했다.
오.. 나의 &#$! 같은 코드
리팩토링할 프로젝트는 자바를 공부하며 만든 토이 프로젝트였다.
Legacy Code
주제는 콘솔 상에서 사용자와 컴퓨터가 인디언 포커를 할 수 있도록 만든 게임이었다.
- AutoGamer : Gamer 클래스를 상속받아, 사용자의 카드와 본인의 카드를 기억하여 자동으로 베팅하는 역할
- Dealer : 게임의 턴을 실행하고 턴의 승패를 판단하여 게이머들에게 칩을 분배하는 역할
- Gamer : 사용자의 입력을 받아 직접 베팅하는 하는 역할
- IndianPoker : 게임을 실행하고 종료조건에 따라 게임의 승패를 판단하는 역할
프로젝트는 총 네 가지 클래스로 구성되어 있다.
-
IndianPoker 객체는 게임의 시작과 종료를 제어하는 핵심 도메인이다. Dealer와 Gamer 객체와 의존관계를 맺고있다.
-
Dealer 객체는 턴을 제어하는 로직을 가진 도메인이고, 매 턴 마다 Dealer의 상태는 초기화된다.
-
Gamer 객체는 칩과 카드라는 상태를 지니며 베팅이라는 행위를 할 수 있는데, AutoGamer 와 Gamer는 같은 베팅이라는 행위를 해도 로직은 다르게 구현되었다. Gamer는 사용자의 입력을 받아 베팅하고, AutoGamer는 기억한 카드를 통해 베팅하는 방법과 베팅할 칩 수를 계산한다.
코드를 다시 살펴보기 전까진 “나름 객체 지향적으로 설계한 거 같은데?“라고 생각했다. 하지만 실제 코드를 보았을 때 정말 반성하게 되었다.
// gamer가 betting하는 메소드
public int betting(int opponentCard, int opponentChip, int myChip) {
System.out.println("\n-->" + this.getName() + "의 Turn");
System.out.println("\n상대의 카드:" + opponentCard);
System.out.println("상대가 배팅한 칩:" + opponentChip);
System.out.println("======배팅 방식======");
System.out.println("1.Call 2.Raise 3.Die");
int menu = Integer.parseInt(readFromKeyboard());
int chip = 0;
switch (menu) {
case 1: // call
while (true) {
System.out.println("++배팅할 칩 갯수++");
chip = Integer.parseInt(readFromKeyboard());
if (opponentChip == 1) { // 상대가 아직 betting 안한 경우
if (this.chipCount() > chip) {
System.out.println("Call하였습니다.");
return this.minusChip(chip);
} else {
System.out.println("#잘못 입력하였습니다. 다시입력해주세요.");
}
} else {
if (this.chipCount() > chip && (chip + myChip) == opponentChip) {
System.out.println("Call 하였습니다.");
return this.minusChip(chip);
} else if (this.chipCount() < chip) {
System.out.println("#Call할 수 없습니다. Die합니다.");
return 0;
} else {
System.out.println("#잘못 입력하였습니다. 다시입력해주세요.");
}
}
}
case 2: // raise
while (true) {
System.out.println("++배팅할 칩 갯수++");
chip = Integer.parseInt(readFromKeyboard());
if (opponentChip == 1) { // 상대가 아직 betting 안한 경우
if (this.chipCount() > chip) {
System.out.println("Raise 하였습니다.");
return this.minusChip(chip);
} else {
System.out.println("#잘못 입력하였습니다. 다시입력해주세요.");
}
} else {
if (this.chipCount() > chip && (chip + myChip) > opponentChip) {
System.out.println("Raise하였습니다.");
return this.minusChip(chip);
} else if (this.chipCount() < chip) {
System.out.println("#Raise할 수 없습니다. Die합니다.");
return 0;
} else {
System.out.println("#잘못 입력하였습니다. 다시입력해주세요.");
}
}
}
case 3: // die
return 0;
default:
System.out.println("@@잘못 입력하셨습니다.");
return this.betting(opponentCard, opponentChip, myChip);
}
}
이런게 %$#&같은 코드구나…
실제 코드를 까보았을 때, 정말 많은 문제가 있었다.
- 코딩컨벤션을 지키지 않고 있다. (통일되지 않은 이름들, 자간 같은 것들)
- 도메인의 핵심 로직이 직접 입출력을 담당하고 있다.
- 많은 분기처리로 코드의 복잡성이 높아져, 내가 짠 코드지만 이해할 수 없었다.
가장 큰 문제는 모든 클래스의 모든 메소드 가 이런 식이었다는 것이다. 나는 참담한 마음을 뒤로하고 결정했다. 이럴 바에는 처음부터 다시 만들자..!
우리 Pair Coding 할래?
다시 만들기로 했을 때, 혼자가 아니라 같이 코드스쿼드를 수강하고 있는 친구와 같이 개발하기로 했다. 그리고 기왕 협업하는 김에 색다른 방식을 사용하고 싶었다.
보통 팀을 이뤄 프로젝트를 개발할 때 Git을 이용해서 협업을 해왔지만, 파트너와 나는 교육을 수강하는 동안 한 공간에 있을 수 있기에 페어 코딩 방식으로 진행하기로 했다.
페어 코딩은 컴퓨터를 한 대 두고 한 명이 코드를 작성하면 나머지 한 명이 실시간으로 코드리뷰를 하는 것을 번갈아가며 주기적으로 반복하는 개발 방식이다.
나는 경영학도 출신으로 수많은 팀 프로젝트를 경험하였고, 동아리 대표와 대외활동 팀 대표로 활동하며 다양한 프로젝트를 경험했기에 협업에는 자신이 있었다. 하지만 언제나 그렇듯 모든 일은 내가 원하는 대로 흘러가지 않았다.
조율 한번 해주세요..
페어 코딩은 일주일에 두 번 4시간씩 하기로 했다. 하지만 페어 코딩의 특성상 둘이서 한 몸처럼 개발해야 했기에, 몇 가지 조율해야 하는 부분이 있었다.
일정 좀 맞추자.
일주일에 두 번씩 하기로 정했지만 각자 사정이 있거나, 급한 일이 생기면 페어 코딩이 우선순위에서 밀리게 되었다. 때문에 한 명이 가능하더라도 다른 사람이 안 되면 개발할 수 없어서 빼먹는 경우가 종종 생겼다.
둘이서 한 사람
각 기능을 개발하는데 두 사람 모두 같은 시간을 들인다고 가정하면 각 기능을 따로 개발하여 합치는 게 더 빠르다. 둘이서 한 기능을 개발하다 보니 둘이서 개발한다 해도 결국 한 명이 개발하는 것과 같은 속도로 개발하게 되었다.
너무나도 다른 우리
우리는 서로 자기주장이 강한 편이었다. 물론, 둘 다 남의 의견을 무시하는 타입은 아니어서 다행이었지만, 서로 납득하는 결과가 나올 때 까지 다음 단계로 넘어가지 못했다.
“이 메소드는 이 클래스에 있어야 한다.”
“이 클래스는 이 패키지로 묶어야해, 그리고 이 행위는 인터페이스로 분리해야 한다.”
우린 이런 식으로 의견이 달랐기에 조율이 필요하였고, 그래서 혼자할 때보다 시간이 두 배로 들었다.
그중 가장 기억에 남는 건 이름짓기 다.
둘 다 명칭에 민감한 편이라 이름 한 번 짓는데, 한 번에 오케이 한 경우가 없었다. 평균 30분 정도 각자 파파고와 구글번역기를 뒤져가며 어떤 단어가 이 변수, 행위, 클래스의 이름에 어울린다고 조율한 후에야 결정할 수 있었다.
[Turn, Round 논쟁]
인디언 포커는 플레이어 둘이 각 20장의 카드를 가지고 하는 게임이기에,
서로 베팅하며 각자의 승패를 판단하는 것을 최대 20번 반복할 수 있다.
우리는 전체 게임이 아니라 한 번 승패를 비교하는 것을 Turn으로 할지 Round로 할지 고민했었다.
처음에는 단위를 Round로 묶고 각자 베팅하는 순서를 Player의 차례.
즉, Turn으로 하자고 합의를 했었다.
둘 다 포커를 즐겼던 것도 아니고 영어로 된 용어를 잘 알지 못해서,
뭔가 어색하지만 그렇게 합의하기로 하였는데.
만날 때마다 거슬린다며 계속 이름을 바꾸었다.
결국, 콘솔게임을 다 완성할 때쯤 Turn으로 합의를 봤었다.
그래도 남는게 있었다.
페어 코딩은 효율로 따지자면 극악이었다. 하지만 얻은 점도 많았다.
감시자가 있다.
우리는 둘 다 TDD가 익숙하지 않은 상태였다. 그 때문에 습관처럼 프로덕션 코드부터 작성할 때가 있었는데, 그때마다 TDD 흐름으로 복귀하도록 지적해줄 수 있었다.
또, 잠시 까먹고 넘어갈 수 있는 부분도 코드를 작성하고 있지 않은 사람이 기억하고 있다가 말해줄 수 있었다.
서로가 서로의 버팀목이 되어준다.
개발하다 보면 집중력이 흐트러지고 이 정도에서 끝내고 쉬고 싶을 때가 많다. 극단적으로 아예 했던 과정을 무산시키고 싶은 때도 있었다. 그럴 때 서로가 이것만 좀 더 개선해보자고, 포기하지 않게 힘을 줄 수 있었다.
스승님이 "betting이라는 행위를 State Pattern을 적용해서 개선해볼래?"라고 피드백을 주셧을 때,
둘 다 해당 디자인 패턴을 어떻게 적용해야 할지 감이 안 와 멘붕이 왔던 적이 있다.
"그냥 설계를 개선안하고 이대로 구현할까?"라는 생각도 했지만,
서로에게 지금까지 했던 시간이 아깝다며 좀 더 개선해보자고 힘을 주었다.
타협은 없다.
앞서 단점이 되었던 의견 조율 과정이 깔끔한 코드를 작성하는 데 가장 큰 도움이 되었다.
혼자 개발하다 보면 빨리 다음으로 넘어가고 싶어서 이름짓기 및 패키지 구분 그리고 클래스의 역할과 책임을 분리하는 작업을 대충하고 넘어간다. 하지만 의견이 다르고 지적질을 좋아하는 사람끼리 개발을 하니, 서로 눈치를 보게 되었다.
이를 통해 의식적으로 다른 사람이 납득하도록 코드를 짜게 되었다.
페어코딩과 TDD의 시너지
페어 코딩을 해보니 TDD를 처음 학습하는 사람에게 큰 도움이 될 수 있다고 생각하게 되었다.
TDD는 그저 테스트 케이스먼저 작성하는 개발법이 아니다. 억지 테스트 케이스를 만들어 통과시키고 리팩토링하지 않는다면, 그냥 테스트해본 $!%같은 코드이다.
TDD는 모든 과정에 있어 의식적으로 좋은 설계를 하겠다는 목적을 가지고 행해야 하고, 이를 위한 리팩토링을 동반하고 있어야 한다. 그래야 테스트 코드를 통해 정제된 코드를 얻을 수 있다.
처음 TDD를 접하는 사람은 아직 이러한 개발방식에 대해 익숙하지 않기 때문에, 목적의식이 흐릿해질 수 있다. 이럴 때 페어 코딩은 지속적인 리뷰를 통해 목표를 잃지 않게 해줄 수 있는 개발 방식이다.