2주차 미션을 시작하기 전 진행했던 코수타와 과제 이메일에서 언급된 미션의 설계 의도와 목적을 최대한 따라가기 위해 노력했던 한 주였다. 내가 파악한 이번 주차 미션의 목표는 함수를 분리하고, 각 함수별로 테스트를 작성하는 것에 익숙해지는 것이었다.
함수를 분리하자
함수, 메서드를 정확하게 분리하기 위해서 필요한 것은 요구사항을 수행하기 위한 기능들을 최대한 작은 기능으로 나누는 것이다. 단일 기능 별로 메서드화를 하게 되면 자연스럽게 메서드가 한 가지 기능만을 수행해야 한다는 목표를 달성할 수 있으며 추후 리팩토링 과정에서 다시 번거롭게 일부분을 분리해내야 하는 수고를 방지할 수 있게 된다. 따라서 이번 미션에서도 숫자 야구게임이라는 하나의 개념을 점차 쪼개나가는 방식으로 기능 구현 리스트를 작성하는데 많은 시간을 투자했다.
먼저 숫자 야구 게임을 진행하기 위한 기본적인 규칙들을 정리했다. 그 다음 이 규칙들을 수행하는데 필요한 기능들로 / 스트라이크 개수 체크 / 볼 개수 체크 / 사용자 입력 / 메시지 출력 / 등을 정리했다. 그리고 각 기능들을 쪼갤 수 있는 한 최대한 쪼개기 위해 가장 많이 시간을 투자했다. 예를 들어 strike 개수 count하기, 두 수의 특정 자릿수가 동일한 값인지 확인하는 기능 메서드와 같이 스트라이크 개수 체크 기능을 세분화했고, 또 두 수의 특정 자릿수가 동일한 값인지 확인하는 기능 메서드기능을 쪼개 한자리 숫자 2개가 같은 숫자인지 판별하는 메서드와 같은 작은 기능으로 세분화하며 추상적인 기능을 구체화 시켜갔다.
자연스럽게 들었던 의문은 과연 어느 정도까지 기능을 쪼개서 구체화 시켜야 하는 것인가였다. 미션을 수행하며 스스로 내려본 결론은 추상적인 기능을 ‘테스트할 수 있는 단위’의 기능이 될 때까지 구체화시켜야 한다는 것이었다. 추상적인 기능은 무엇을 테스트해야 할 지 파악하기 어려워 테스트 코드를 구현하는 것이 힘들다. 반면 구체적으로 쪼개진 기능은 수행해야 할 것이 명확하기 때문에 테스트 코드를 구현하는 것도 수월해진다. 테스트 주도 개발(TDD)에서 중요한 시작점은 결국 요구사항을 세분화해 기능을 구체적으로 정리하는 데서 시작해야 한다는 점을 깨닫게 되었다.
각 기능들을 세분화가 더 이상 불가능할 때까지 쪼개다 보니 느꼈던 점은 다른 상위 기능을 쪼개서 도출된 하위 기능에 서로 공통된 부분이 존재한다는 것이었다. 공통된 하위 기능들을 메서드화 한다면 코드의 재사용성을 많이 향상시킬 수 있겠다고 생각하게된 부분이었다. 실제로 이번 미션을 진행하면서 공통된 하위 기능들을 분리해 구현한 메서드들은 흔히 말하는 유틸적인 메서드들이 많았다. 이 메서드들을 Utils 클래스에 모아 구현했고 추후 다른 프로젝트를 수행할 때에도 사용할 수 있도록 하였다.
함수별로 테스트 코드 작성
TDD 방식에 익숙해지기 위해 요구사항을 분석해 도출한 기능들에 대한 테스트 코드를 작성하고나서 개발을 시작하고자 했다. 처음에는 정리한 모든 기능에 대해서 테스트 코드를 작성하려고 했다. 하지만 단순한 게임 진행 멘트 출력 까지도 테스트 기능을 만들어야 하는 것인지, 만들어야 한다면 어떻게 만들어야 할지 고민하다 보니 너무 많은 시간이 소요되고 진행은 더뎌 졌다. 결국 기능 명세를 한 항목들 중 ‘메서드화가 확실하게 된 기능들’을 중심으로 테스트코드를 작성했다. 과제를 정리하고 다시 생각해보니 테스트 코드를 작성할 때 어려움을 겪었던 것은 명세한 기능들 중에 확실하게 단일 메서드로 구현하기 어려운 것들이 존재해서 그랬던 것 같다.
기능 중 메서드로 명확하게 분리하기 힘들었던 것들은 대체로 controller 부분에 해당하는 기능들이 많았다. 반면 구현 기능 리스트를 작성할 때 service 부분에 들어갈 비즈니스 로직에 해당하는 부분들과 유틸성 메서드들은 명확하게 정리하기가 비교적 수월했다. controller는 프로그램 자체의 흐름을 제어하는 역할을 수행한다. 지금 생각해보면 구체적인 입력과 반환값이 존재하는 단일 기능이 아닌 전체 프로그램의 흐름을 구분하여 메서드화 하고 테스트 코드를 작성하려 하니 당연히 어려웠던 것이라고 생각한다. 앞으로 요구사항을 분석해 기능을 명세할 때는 해당 기능이 프로그램의 어느 단에서 이루어져야 하는지 파악을 먼저 해야 겠다고 생각했다. service 단에서 비즈니스 로직을 처리하는 기능인지, controller 단에서 service 단에 구현된 비즈니스 로직 처리 메서드를 가지고 흐름을 명시해야 하는 기능인지, 아니면 view에서 단순히 메시지를 던져주면 되는 것인지 등을 구분 한다면 어떻게 테스트 코드를 작성해야 하는지를 명확하게 알 수 있다고 결론을 내렸다.
테스트 코드를 작성하며 했던 고민은 어떤 스타일로 테스트 코드를 작성하는 것이 직관적일까에 대한 것이었다. 고민하며 알게된 것은 BDD의 given / when / then 구조였다. 보는 사람으로 하여금 / 특정 값이 주어지고 / 어떤 상황이 발생했을 때 / 그에 대한 결과가 보장되야 한다 / 라는 직관적인 의미를 전달할 수 있게 해준다. 그래서 테스트 코드 작성 스타일은 given / when/ then 구조를 따르기로 결정했다.
두 번째 고민은 매개변수를 받지 않고 사용자의 입력을 받거나 출력을 하는 void 메서드는 어떻게 테스트 코드를 작성하는가에 대한 것이었다. 찾아낸 방법은 outputstream, inputstream을 따로 생성해 system.setOut(), system.setIn() 메서드의 인자로 넣어 콘솔에 입출력되던 설정을 바꿔주면 된다. 이젠 테스트 코드를 작성할 때 사용자의 입력에 대한 부분과 출력에 대한 부분도 문제없이 테스트를 진행할 수 있게 되었다.
(추가 사항 - 아래 코드는 같은 기능을 수행하는 중복 코드에 대한 리팩토링이 되어 있지 않음을 감안하고 보면 좋겠다.)
@Test
void printGameResult_테스트() {
//given
final Core T = new Core();
final List<Integer> case1 = List.of(3, 0); // 3 strikes
final List<Integer> case2 = List.of(0, 0); // 0 strikes 0 balls
final List<Integer> case3 = List.of(2, 1); // 2 strikes 1 balls
final List<Integer> case4 = List.of(1, 2); // 1 strikes 2 balls
final List<Integer> case5 = List.of(2, 0); // 2 strikes 0 balls
final List<Integer> case6 = List.of(0, 2); // 0 strikes 1 balls
//when
final OutputStream result1 = new ByteArrayOutputStream();
System.setOut(new PrintStream(result1));
T.printGameResult(case1.get(0), case1.get(1));
final OutputStream result2 = new ByteArrayOutputStream();
System.setOut(new PrintStream(result2));
T.printGameResult(case2.get(0), case2.get(1));
final OutputStream result3 = new ByteArrayOutputStream();
System.setOut(new PrintStream(result3));
T.printGameResult(case3.get(0), case3.get(1));
final OutputStream result4 = new ByteArrayOutputStream();
System.setOut(new PrintStream(result4));
T.printGameResult(case4.get(0), case4.get(1));
final OutputStream result5 = new ByteArrayOutputStream();
System.setOut(new PrintStream(result5));
T.printGameResult(case5.get(0), case5.get(1));
final OutputStream result6 = new ByteArrayOutputStream();
System.setOut(new PrintStream(result6));
T.printGameResult(case6.get(0), case6.get(1));
//then
assertThat(result1.toString().strip()).as("printGameResult 테스트").isEqualTo("3스트라이크");
assertThat(result2.toString().strip()).as("printGameResult 테스트").isEqualTo("낫싱");
assertThat(result3.toString().strip()).as("printGameResult 테스트").isEqualTo("1볼 2스트라이크");
assertThat(result4.toString().strip()).as("printGameResult 테스트").isEqualTo("2볼 1스트라이크");
assertThat(result5.toString().strip()).as("printGameResult 테스트").isEqualTo("2스트라이크");
assertThat(result6.toString().strip()).as("printGameResult 테스트").isEqualTo("2볼");
}
테스트 코드를 왜 “의도치 않은 유용한 부산물”이라고 표현하는지에 대한 이유는 리팩토링을 진행하며 알게되었다. 리팩토링을 하다보면 코드를 옮기거나 삭제하는 작업도 많이 하기 때문에 실수하기 마련이다. 그리고 프로그램의 로직을 의도치 않게 바꿔버리는 실수도 종종 하게 된다. 이 때 이러한 실수들을 방지하고 엄청난 시간적 정신적 자원 소모를 막아주는 것이 테스트 코드였다. 일련의 리팩토링 과정을 끝낼 때마다 미리 작성해둔 테스트 코드를 실행시켜 볼 수 있다는 건 방금 수행한 과정에 대한 확실한 피드백을 가장 빠르게 받아볼 수 있다는 걸 몸소 체험하며 알 수 있었다. 그리고 혹여나 실수를 놓치진 않을까 걱정하지 않고 리팩토링에 집중할 수 있는 경험도 얻으며 테스트 코드의 중요성을 또 한 번 느낄 수 있었다.
프로젝트 디렉토리 구조 설계
이번 과제를 진행하며 초반에는 모든 메서드의 작성을 Core 클래스를 생성하여 내부에 전부 구현하는 방식으로 진행했다. 그리고 나중에 게임 진행에 핵심적인 기능을 수행하는 메서드만 남겨두고 공통적으로 사용되는 유틸적인 메서드들은 Utils 클래스를 생성하여 분리하였다. 리팩토링 과정에서 이렇게 메서드를 용도에 따라 분리하게 되면서 든 생각은 프로젝트의 패키지 구조를 미리 정해두고 메서드를 구현할 때 수행 기능에 따라 분리해서 개발한다면 추후 프로젝트 구조와 코드 정리에 들게되는 비용을 줄일 수 있겠다는 것이었다.
이에 따라 패키지 구조에도 정해진 컨벤션이 있는지 찾아 보았으나 뚜렷하게 이것이 정답이다라고 제시된 개념은 없었다. 내가 이번에 진행했던 방식대로 처음부터 모든 세세한 디렉토리 구조를 설계하고 개발을 해나가는 것이 아니라 파일을 먼저 나눠서 개발을 진행하다가 사이즈가 커지면 추후 별도 폴더로 구분하는 것이 좋다는 입장도 있었다. 하지만 더 보기 좋은 패키지 구조를 적용해보고 싶었기에 계속 찾아본 결과 예전부터 많이 들어봤던 MVC 구조가 눈에 들어왔다. Spring을 학습하며 배웠던 domain, controller, service, repository, view 등의 구조도 볼 수 있었다. 이렇게 패키지 구조가 수행하는 역할 별로 잘 나눠져 있다면 기능을 구분하고 정리하는데 느낀 어려움을 해소할 수 있겠다고 생각했다. 이번에는 시간 상 문제로 완벽하게 원하는 대로 패키지 구조를 생성했다고 말하긴 어려웠고 그 만큼 기능을 구분하고 정리하는데에도 어려움을 느꼈었다 . 다음 주차 미션에는 패키지 구조를 미리 정해두고 이에 맞춰 기능을 분리하고 정리하며 개발할 수 있도록 해야겠다.
Commit Message Format
Commit Message Format은 지난 주차 부터 지켜오던 Convention과 같은 것이었다. 하나 고민이 되었던 점은 Commit Message 제목 부분에 scope를 언제, 어떻게 명시할 것인가였다. 알아보니 이 scope는 필수적인 것은 아니지만 어떤 부분에 이루어진 Commit인지 다른사람이 볼 때 바로 알 수 있겠다는 장점을 취하고 싶었다. 모든 태그에 scope를 명시하고 싶었으나 test, style과 같은 태그들에는 붙이지 못하고 feat과 refactor 태그에만 scope를 명시했다. test 태그는 테스트 코드를 작성할 당시 테스트하는 대상 메서드가 어떤 클래스에 속하게 될 지 알 수 없었기에 명시하지 못했다. style태그는 공백문자 추가/제거, 줄바꿈 수정 등 여러 메서드에 적용되는 사항들이 있었기에 명시하지 못했다.
scope는 선택사항이며 프로젝트를 진행하는 팀 내에서 scope를 따로 정의해서 사용하기도 한다는 사실을 알게 되었다. 그래서 다음 미션때는 Commit Message 제목에 패키지 구조, 즉 domain, controller, service, view 등을 scope로 명시하여 작업한 영역을 바로 알 수 있도록 하기로 결정했다
Java Convention
이번 주차 과제에서 새롭게 고려한 Java Convention에는 import문 순서 지키기, 열 제한 120자, 의미있는 공백라인 사용, 줄바꿈 규칙 등이 있었다. 생각보다 코드 자체의 스타일에 대한 Convention이 많다고 느꼈는데 남은 2주간 습관화해서 내 코딩 스타일로 만들어야겠다. 또 하나 기억나는 다른 Convention은 변수 선언이 최대한 변수가 사용되는 위치의 최대한 근처에서 이뤄져야 한다는 것이었다. 그냥 본능적으로 지키고 있던 Convention이었는데 변수의 scope를 최대한 줄이기 위함이라는 것을 이번 기회에 알게되었다.
Two level of indentation per method
지난 주 과제 때는 indentation level을 1로 유지하면서 개발을 진행했었다. 메서드의 재사용성 향상과 단일 기능 별로 메서드를 분리하는 리팩토링을 경험할 수 있었는데 이번 주 과제 요구사항으로는 indentation level을 2로 유지하라는 항목이 있었다. 단지 level이 1 늘었을 뿐이었는데 개발을 하면서 느껴지는 체감은 굉장히 컸었다. 특히 반복문 내부의 조건문들을 처리하는 부분이 많았었는데 indentation level을 2로 유지하니 훨씬 편하게 개발할 수 있었다. 하지만 indentation 1을 유지할 때보다는 가독성이 조금 떨어지는 것과 메서드의 단일 기능 수행을 고려하는 측면에서 손해를 보는 것 같았다. 편리함과 가독성, 기능분리에서 trade-off를 잘 정해야 하는 부분이라고 생각했다.
마무리
프로그램의 본 기능 구현보다 요구사항 분석과 테스트 코드 구현에 더 많은 시간과 노력을 투자했던 미션이었다. 덕분에 JUnit과 AssertJ를 사용하는데 많이 익숙해지긴 했지만 과연 실제 개발을 진행할 때도 이렇게 기능 개발보다 테스트 코드에 더 많이 집중할 수 있는 여건이 될까를 고민하게 되었다. 하지만 몸소 체험했던 것처럼 테스트 코드가 가져다 주는 이점 또한 확실하기에 기능 개발이 테스트 코드 작성에 많이 밀리지 않도록 테스트 코드를 작성하는 기준을 스스로 명확하게 정해둬야 겠다고 결론을 내렸다.
'우아한테크코스 > 우테코 프리코스' 카테고리의 다른 글
[우아한테크코스 5기] 백엔드 지원, 합격 과정 회고 (합격 후기) (20) | 2023.02.06 |
---|---|
[우아한테크코스 5기] 프리코스 4주차 회고 (0) | 2022.12.22 |
[우아한테크코스 5기] 프리코스 3주차 회고 (0) | 2022.12.22 |
[우아한테크코스 5기] 프리코스 1주차 회고 (1) | 2022.11.02 |