우테코 오리엔테이션에서 멘토님이 회고의 중요성을 말씀해주셨다. 프리코스를 합격의 관문이라고 생각하지 않고 프로그래밍에 몰입하고 성장할 수 있는 기회라고 여기고 지금까지 내가 고생하고 공부한 것을 회고와 학습정리에 남길 것이다.
프리코스에서는 처음에 기능 목록 작성을 요구한다.
이전에는 기능 목록을 작성하지 않고 프로젝트를 진행했었다. 프로젝트가 1주일 이상 진행되면 내가 뭘 개발해야 하는지 까먹는 상황이 발생하곤 했다.
기능 목록을 정리할 때 생각보다 오랜 시간이 걸렸다. 겉으로 보기에는 쉬워보이는 미션이어도 요구사항을 자세히 들여다보니까 생각보다 하나의 기능을 구현하는데도 그 아래에 다른 기능이 필요했다.
게다가 미션 진행 방식에 "기능 요구 사항에 기재되지 않은 내용은 스스로 판단하여 구현한다." 라는 내용이 있기에 더욱 고심했다.
하지만 기능 목록을 작성함으로써 내가 구현해야할 기능들이 체계적으로 잡혔다. 기능을 구현했다면 문서에도 갱신했다. 그러면 내가 놓친 부분이 없는지 쉽게 확인이 가능했다.
사실 이전까지는 커밋 메시지에 대해 많은 고려를 하지 않았다. 많은 코드를 짜고 나서 "많이 했는데 이제 그만 커밋할까?" 라는 생각이 들면 지금까지 한 내용을 훑어보고 추상적으로 커밋 메시지를 적었었다.
나는 많은 코드를 짜고 커밋한 것부터가 잘못 됬었다고 생각한다. 커밋의 주요한 목적은 세이브파일을 통해 되돌리는 것이다. 세이브 파일은 많을 수록 좋다. 게임을 할 때 틈만 나면 세이브파일을 찍는 것과 같다.
그리고 추상적으로 적었던 것도 잘못되었다. 세이브 파일은 많을 수록 좋지만 많아질수록 그 파일이 어떤 진행상태를 가진지 구분하기 어려워진다. 하지만 세이브 파일 제목에 자세하게 진행한 상황을 적어놓으면 어떨까? 내가 원하는 세이브파일을 찾기 쉬울 것이다.
좋은 깃 커밋 메시지를 작성하는 방법이 7가지 있다.
다른 작성 방법은 이해가 되었지만 딱 한가지 방법이 이해가 안됬다.
커밋을 과거형으로 적는게 맞는 것 같지만 명령문으로 쓰는 이유는 커밋메시지의 의미는 "이 커밋이 적용되면 어떤일이 이루어진다"라는 내용이 있기 때문이다. 커밋 메시지를 이렇게 읽으면 이해가 된다.
(if applied, this commit will ) 리드미 파일 생성 = 커밋이 적용된다면 이 커밋은 리드미 파일을 생성할 것.
다른 유저가 커밋 메시지를 보면 의도를 딱 알 수 있어야한다.
fix: fix foo to enable bar
This fixes the broken behavior of the component by doing xyz.
BREAKING CHANGE
Before this fix foo wasn't enabled at all, behavior changes from <old> to <new>
Closes D2IQ-12345
올바른 커밋 메시지의 예이다.
타입을 먼저 쓰고, 짧은 제목을 작성한다.
한칸을 띄고 본문을 무엇을, 왜 작성했는지 적는다.
추가로 이슈에 있는 내용을 해결하기 위해 커밋했다면 밑에 이슈 넘버를 넣는다.
커밋 메시지를 작성할 때 맨 처음에 위치해야할 타입 명이다. 커밋메시지를 읽을 때 가장 처음에 위치하므로 이 커밋이 어떤 행위를 하는 지 알 수 있다.
이를 적용해서 내 프로젝트에도 반영을 해보았다. 타입 옆에 스코프를 둬서 어떤 클래스에서 변화가 일어났는 지 명시했다. 그리고 *
로 본문의 내용을 구분해서 적었다.
지금은 기능 하나를 추가해도 다른 기능에 피해가 가지 않을 정도의 스케일이지만 나중에 가서는 하나 추가하면 여러곳에서 에러가 나올 수 있을 것 같다는 생각에 테스트 코드를 학습하고 직접 적용했다.
체크 표시가 보이면 안심이 된다.
테스트하기 위한 Assertion 도구. JUnit 테스트 도구가 있지만 assertThat을 통해 직관적으로 사용하기 위해 AssertJ를 자주 사용한다.
Assertions.assertThat(T)
를 입력하면 Assertion이 나온다. 이 assertion를 다른 오브젝트와 비교해서 같은지 확인하는 식으로 테스트를 한다.
boolean : assertThat(object).isFalse()
: false인지 검사
object : assertThat(object).isEqualTo(3)
: 3이 맞는지 검사. isNotEqualTo도 있다.
iterable : assertThat(iter)
- .filteredOn(student -> student.getName().contains("송")
: 필터를 통해 조건에 맞는 오브젝트만 남긴다.
- .isContain("a")
: 포함하는지 검사
- .isEmpty()
: 비어있는지 검사
@Test
void 판정테스트() {
Assertions.assertThat(result1[1]).isEqualTo(3);
}
// result1[1]이 3이라면 테스트 성공이다.
exception : 예외가 처리되었는지 테스트 할 수 있다.
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> {...})
: {...} 안에서 코드가 돌아갈 때 타깃 예외가 일어나는지 검사한다. assertThatNoException().isThrownBy(() -> {...})
: {...} 안에서 아무 예외가 일어나지 않는지 검사한다. @Test
void 재시작입력예외처리테스트(){
Display display = new Display();Assertions.assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> {
display.getSplitedUserInput(" 12");
});
display.getSplitedUserInput(" 12")
은 일부러 IllegalArgumentException을 발생시키는 명령이다. 이 명령으로 인해 예외가 발생하면 테스트 성공이다.
테스트 코드를 작성하던 중에 살짝 곤혹스러웠던 부분이 있었다. 테스트를 할 때 기능이 분리되어있지 않아 예외처리 테스트를 할 때 어떻게 콘솔 입력을 해야할 지 고민했었다. 하지만 기능을 분리하여 해결할 수 있었다.
public boolean getRestartInput(){
System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
String trimRestartInput = Console.readLine().trim();
validateOneOrTwo(trimRestartInput);
if(trimRestartInput.equals("1")){
return true;
}
printEndText();
return false;
}
이것이 이전 코드다. 입력을 받고 1인지 2인지 판별한 후에 리턴한다. 이 메서드는 두 기능을 수행한다.
하지만 여기서 테스트 코드에서 getRestartInput을 호출해서 에러처리테스트를 하고 싶다면 콘솔에 원하는 값을 입력하는 방법을 찾아야한다. 하지만 이것보다 쉬운 방법이 있다. 아래와 같이 메서드의 기능을 분리하는 것이다.
public boolean getRestartInput(){
System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
boolean oneOrTwo = getOneOrTwo(Console.readLine());
return oneOrTwo;
}
public boolean getOneOrTwo(String input){
String trimInput = input.trim();
validateOneOrTwo(trimInput);
if(trimInput.equals("1")){
return true;
}
printEndText();
return false;
}
입력이 올바른지 테스트하고 싶다면 getOneOrTwo 를 사용하면 된다.
Assertions.assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> {
display.getSplitedUserInput("12 ");
});
테스트 코드를 여러개 작성해봤는데 생각보다 귀찮은 작업이 아니었고 단위적으로 테스트 하기 좋은 도구라는 생각이 들었다.
궁금한 건 테스트 코드를 먼저 작성해야하는지 아니면 기능을 구현하고 테스트 코드를 먼저 작성해야하는지 궁금하다. 자연스레 TDD에 흥미가 생기는 것 같다.
테스트 코드를 작성할 때 고민했던게 테스트를 위해서 private 멤버변수, 또는 private 메서드를 public으로 돌려야할까 고민했었다. public으로 바꾸면 테스트가 굉장히 쉽다. 하지만 객체가 객체스럽게 존재하려면 자기 상태를 남에게 공개하지 말아야한다고 생각해 private 접근자로 되돌려놓았다. 대신 입출력을 시뮬레이트하는 방법을 찾아보고 테스트 코드에 반영하려고 했다. (이 문제는 설계의 부족함 때문일 수 있다. - 2주차 회고 TDD 참고)
물론 프리코스에서 제공하는 클래스를 사용하면 예외처리, 출력값 테스트가 가능하지만 찾아보고 어떻게 시뮬레이트가 가능한지 학습하고 싶었다.
방법은 Scanner의 동작 방식을 이용하는 것이었다. Scanner는 생성될 때 System.in에서 유저가 입력한 문자 스트림을 가져오는 형식인데 우리가 System.in에 넣고 싶은 문자를 넣어 Scanner가 가져가도록 하면 가능하다.
다음은 System.in 에 넣고 싶은 문자를 넣는 코드이다.
void inputStringToSystemIn(String data){
ByteArrayInputStream testIn = new ByteArrayInputStream(data.getBytes());
System.setIn(testIn);
}
이를 통해 사용자의 입력을 시뮬레이트하면서 테스트 할 수 있었다.
@Test
void 유저입력예외처리테스트_길이가3이아닌입력(){
inputStringToSystemIn("12 ");
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> {
inputView.getUserInput();
});
}
그러나 두 테스트를 연속으로 실행할 때는 java.util.NoSuchElementException
예외가 나왔다. 현재 스트림에 아무것도 없다는 뜻이다. 해결하기 위해 삽질을 했지만 답은 가까이에 있었다.
void inputStringToSystemIn(String data){
ByteArrayInputStream testIn = new ByteArrayInputStream(data.getBytes());
System.setIn(testIn);
display.close();
}
System.setIn
은 말 그대로 System.in을 다른 스트림으로 바꾸는 명령이다. display.getUserInput()
은 처음으로 Scanner를 사용하는 명령이기 때문에 Scanner는 바꿔준 ByteArrayInputStream
을 가지고 입력을 받는다. 그래서 첫번째 테스트는 성공했다. 하지만 Scanner가 close되지 않고 계속 사용된다면 아무리 System.setIn
을 해봤자 이전에 설정한 ByteArrayInputStream
을 가지고 입력을 받으니 아무 입력을 받지않아 java.util.NoSuchElementException
예외가 나오는 것이다.
때문에 display.close();
를 통해 Scanner를 종료해주고 바뀐 입력스트림을 가지고 다시 생성되도록 유도한 것이다.
Assertions.assertThat(result1.strikeCount).isEqualTo(3);
이전 테스트 코드 중 일부인데 strikeCount
접근자가 private로 바꾸었기 때문에 더이상 이 명령문은 작동하지 않는다. 그래서 실제 콘솔의 Output을 받아서 예상한 결과값이 나오는지 테스트하는 코드를 작성하려고 했다.
위에서 콘솔 Input을 우리가 입맛대로 조정하는 것처럼 콘솔 Output도 조정이 가능하다.
@Test
void 판정테스트(){
ByteArrayOutputStream testOut = new ByteArrayOutputStream();
System.setOut(new PrintStream(testOut));
이렇게 System.out
을 ByteArrayOutputStream
으로 변경함으로써 아래처럼 콘솔에 나오는 출력값을 가져올 수 있다.
result1.printResult();
Assertions.assertThat(testOut.toString()).isEqualTo("3스트라이크\n");
testOut.reset();
printResult()
는 결과를 출력하는 메서드다.
testOut.toString()
으로 결과를 문자열로 바꿔서 테스트를 했다.
result1.printResult();
Assertions.assertThat(testOut.toString()).isEqualTo("3스트라이크\n");
result2.printResult();
Assertions.assertThat(testOut.toString()).isEqualTo("3볼\n");
reset()
을 하는 이유는 위와 같이 두번 출력을 하게되었을 때 리셋을 하지 않으면 "3스트라이크\n 3볼\n"처럼 이어져서 나오기 떄문이다.
모든 기능을 구현하고 나서 클린코드로 리팩토링하려고 하니까 너무 힘들다. 이쪽저쪽에서 고쳐야할 것들이 하나 만지면 나온다. 리팩토링은 제때하자. 메서드가 비대해지기 시작하면 리팩토링하고 클래스가 커지면 리팩토링하자. 처음부터 확장성, 가독성을 염두하려고하면 코드에 운이 잘 안때지지만 기능 하나 만들고 리팩토링하면 나중엔 수정하기 쉬울 것이다. 실제로 이전에는 원시타입을 많이 사용했는데 이쪽 저쪽 원시타입을 사용하니깐 여기 저기서 수정해야하는 복잡한 상황이 나오더라.
기능이 오직 하나인 메서드, 책임이 하나인 클래스가 좋은 이유는
Display 클래스가 비대해진 것을 발견하고 분리를 하기로 결정했다.
public class Display {
public void printStartText(){...}
private void printEndText(){...}
public void printGameOverText(){
System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 종료");
}
public void printJudgeResult(int ballCount, int strikeCount){
...
}
public Numbers getUserNumbers() {
System.out.print("숫자를 입력해주세요 : ");
NumberInput numberInput = new NumberInput(Console.readLine().trim());
return numberInput.getNumbers();
}
public boolean getRestartInput(){
...
}
Display는 두가지 기능을 수행한다.
때문에 InputView 와 OutputView로 분리를 시켰다.
InputView
public class InputView {
public Numbers getUserNumbers() {
OutputView.printNumberInputText();
NumberInput numberInput = new NumberInput(Console.readLine().trim());
return numberInput.getNumbers();
}
...
}
OutputView
public class OutputView{
private static final String START_TEXT = "숫자 야구 게임을 시작합니다.";
...
public static void printStartText(){
System.out.println(START_TEXT);
}
...
이와같이 두가지 클래스로 나누니까 출력에 관해서 수정해야겠다 싶으면 OutputView에서 확인하는 등 가독성 측면에서 좋아졌다.
이전 코드를 먼저 보면
private void calculateCount(int[] userInput, int[] computerNumbers){
for(int userIndex = 0; userIndex < 3; userIndex++){
if(isStrike(userInput, computerNumbers, userIndex)) {
strikeCount++;
}
else if(isBall(userInput, computerNumbers, userIndex)){
ballCount++;
}
}
}
들여쓰기가 두번 되어 있는 것을 볼 수 있다. 아직은 코드가 복잡해지지 않아서 그렇지 스케일이 큰 프로젝트에서 들여쓰기가 두번된 코드를 보면 머리가 복잡해지고 가독성이 떨어진다.
메서드를 분리해서 이를 해결한 코드이다.
private void calculateCount(int[] userInput, int[] computerNumbers){
for(int userIndex = 0; userIndex < 3; userIndex++){
countStrike(userInput, computerNumbers, userIndex);
countBallOneByOne(userInput, computerNumbers, userIndex);
}
}
private void countStrike(int[] userInput, int[] computerNumbers, int index){
if(userInput[index] == computerNumbers[index]) strikeCount++;
}
calculateCount 코드를 보면 확실하게 이해하기 쉽다. "유저가 입력한 숫자 하나하나 스트라이크를 세고, 볼을 센다." 이처럼 메서드를 분리하니까 코드 가독성이 올라갔다.
그리고 메서드를 분리하면 기능이 한개가 되므로 여러개였을 때다 범용성이 높아 코드 재사용성이 높아진다.
가장 와닿지 않는 말인데 내가 프로젝트하면서 한번도 원시타입을 포장할 생각이 없었기 때문이다. 이 내용을 이해할 수 없어도 일단 따라보고 그 결과를 지켜보기로 했다.
원시타입을 포장하는 이유는
원시타입을 사용할 경우 상태를 관리하기 어렵지만 원시타입을 포장할 경우 상태를 관리할 수 있다.
다음은 교통카드 클래스이고 원시타입을 사용한다.
public class TransportationCard {
int amount;
TransportationCard(int amount){
this.amount = amount;
}
}
만약 교통카드 한도가 10만원이라면 원시타입으로는 10만원을 넘기는 값이 들어오면 예외 처리를 할 수 없다.
이럴 때 원시타입을 포장하는 것은 도움이 된다.
public class Amount {
private int amount;
Amount(int coin){
if(coin > 100000){
throw new IlligalArgumentException;
}
this.amount = coin;
}
}
public class TransportationCard {
Amount amount;
TransportationCard(int coin){
this.amount = new Amount(coin);
}
}
Amount라는 클래스를 생성해서 한도를 넘기면 예외처리를 하도록 변경했다.
이전에는 amount 변수를 우리의 비즈니스에 맞게할 수 없었지만 가능해졌다.
public class TransportationCard {
int amount;
TransportationCard(int amount){
if(amount > 100000){
throw new IlligalArgumentException;
}
this.amount = amount;
}
}
이렇게도 amount 예외처리를 할 수 있다만 중요한 것은 이 TransportationCard 클래스는 예외처리 하도록 만들어진 클래스는 아니라는 것이다.
원시타입을 사용하면 자연스레 포장을 했을 때 안에 있었던 기능들이 클래스들로 뿔뿔히 흩어지게 된다.
이를태면 TransportationCard에서 태그할 때돈을 계산하고 Account로 환전 될 수 있다.
public class TransportationCard {
int amount;
void tag(int price){
this.amount -= price;
}
void transfer(int amount, Account account){
this.amount -= amount;
account.plus(amount);
}
}
public class Account {
int amount;
void plus(int amount){
this.amount += amount;
}
}
하지만 여기서 환율이라는 기능이 추가되면 어떻게 될까.
두 클래스 전부 환율 기능을 추가하기 위해 수정되어야한다. 두 클래스라서 그렇지 3개 이상 수정하게 되면 꽤나 골치 아플 것이다. 이따 Amount라는 클래스로 원시타입을 포장하면 한 클래스에 이체 기능을 추가하는 것만으로도 끝난다.
이것보다 어떻게 분리하지? 라는 생각이 있었는데 원시 타입을 포장하니까 분리할 수 있는 영역들이 꽤나 많다는 것을 느꼈다.
자주 이 둘이 함께 소개되는 것을 볼 수 있다.
VO는 유효성 검사, 동등성 비교, 불변성을 가지는 객체이다. 값을 가리키는 객체라는 점에서 원시타입을 포장했다고도 볼 수 있다.
하지만 원시값을 포장했다고 무조건 VO가 되는 것은 아니다. 유효성 검사, 동등성 비교, 불변성을 포함해야하기 때문이다.
원시값 포장은 VO를 포함하는 개념이라고 볼 수 있다.
첫 주차기 때문에 긴장하면서 개발에 임했지만 나조차도 놀랄 만큼 개발과 학습에 집중했다. 그리고 기능 목록 정리, 깃 커밋 메시지, 클린 코드 등 중요하지 않다고 여긴 것들을 직접 학습하고 적용했던 한 주였는데 생각치도 못하게 내 개발 사이클에 영향을 끼치게 된다라는 것을 깨닫게 되었다. 남은 3주도 1주차처럼 개발에 빠져 큰 성장을 이뤄내는 일주일이 되었으면 좋겠다.