[우테코] 프리코스 1주차 - 숫자야구

짱수·2023년 1월 21일
0

우테코

목록 보기
1/1
post-thumbnail

모 기업 인턴으로 지내고 있는 친구의 제안으로 우아한 테크코스의 프리코스 과제들을 만들어보기로 했다. 친구는 인턴 과정에서 진행하는 코드 리뷰를 해주기로 했다.

1주차 과제는 숫자야구 이다.
하지만, 단순 숫자야구를 구현하는 것에서 그치는 과제가 아니었다.
다양한 요구사항과 컨벤션들을 지키고, 잘못된 입력에 대해 예외처리를 요구하며, 테스트 코드까지 통과해야 하는 무시무시한 프로젝트였다.
하지만 여기서 멈출수는 없지 않은가!
덤벼라, 우테코!

요구 명세

우테코에서 요구한 전체 요구 명세는 상당히 길기에 링크를 달아둔다.
아래는 이번 구현에서 내가 특별히 신경써야 했던 부분들이다.

기능

사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨 후 애플리케이션은 종료되어야 한다.

지금까지 자바를 이용해 알고리즘 문제는 많이 풀어봤지만, 예외 처리가 필요한 상황은 없었다. 예외처리에 대해선 개념과 간단한 사용법 정도만 알고 있었기에 구현 과정에서 잠깐 헤매게 되었다.

프로그래밍 요구사항

자바 코드 컨벤션을 지키면서 프로그래밍한다.

컨벤션을 신경쓰면서 프로그래밍을 하게 된 첫 프로젝트였다. 다행히 평소 컨벤션에 관심을 두기도 했고, Spring 강의를 들으면서도 자연스럽게 손에 익은 컨벤션들이 많아 크게 힘이 들지는 않았다.

indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다.하면 된다.

시간 복잡도와 가독성을 위한 요구사항 같았다.

함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라.

각 클래스는 단일 책임 원칙을 따르도록, 그리고 각 메소드 역시 많은 작업을 담당하지 않도록 구현하기 위해 신경썼다.

Randoms, Console

JDK에서 기본 제공하는 Random, Scanner API 대신 camp.nextstep.edu.missionutils에서 제공하는 Randoms, Console API를 활용해 구현해야 한다.
Random 값 추출은 camp.nextstep.edu.missionutils.Randoms의 pickNumberInRange()를 활용한다.
프로그램 구현을 완료했을 때 src/test/java 디렉터리의 ApplicationTest에 있는 모든 테스트 케이스가 성공해야 한다. 테스트가 실패할 경우 0점 처리한다.

나중에 알게 된 사실인데, 테스트 코드가 성공하기 위한 조건이었다. 요구 조건을 제대로 읽지 않고 pickNumberInRange()가 아닌 다른 메서드를 사용하였는데, 이 때문에 테스트코드가 실패하게 된다.

중간 평가 (~230114)

구현

숫자야구 프로그램 자체는 구현이 어렵지 않았다.
전체 게임을 진행시키는 Game 클래스를 사용하였고, run() 메서드로 전체 프로그램을 동작하게 했다. number 변수에는 컴퓨터가 선택한 값 3개가 차례로 입력된다.

public class Game {
	private List<Integer> number;

	public Game(){
	}
    
    public void run(){
    ...

아래는 주요 함수들이다.

run()

run() 메서드에선 restart() 메서드의 리턴값을 반복 조건으로, 각 한번의 게임을 진행하는 play()메서드를 반복적으로 호출한다.

public void run(){
	try{
		do {
			play();
		} while (reStrart() == true);
	}catch (IllegalArgumentException e){
		throw e;
	}
}

play()

play() 메서드는 호출이 되면 컴퓨터가 랜덤한 숫자를 고르는 init() 메서드를 호출 후, getList() 메서드를 반복 호출하여 사용자에게서 입력을 받는다. checkResult()의 결과값에 따라 반복 여부를 결정한다.

private void play(){
	String input;
	init();
	do{
		try{
			input = getList();
		}catch (IllegalArgumentException e){
			throw e;
        }
	}while(checkResult(input) == false);
}

restart()

restart() 메서드는 입력 값을 확인 후 재시작의 상황에선 true, 프로그램 종료의 상황에선 false 를 반환해준다.

private boolean reStrart() throws IllegalArgumentException{
	System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
	String restart= Console.readLine();
	if(restart.equals("1")){
		return true;
	}else if("2"){
		return false;
	} else{
		throw new IllegalArgumentException(
        	"["+this.toString()+".reStart()]잘못된 값이 입력되었습니다.\n"
			+ "expect : "+ Restart.restart+" " + Restart.end+"\ninput : " + restart);
	}
}

init()

init() 메서드에선 Randoms.pickUniqueNumbersInRange() 메서드로 숫자 3개를 선택한다. 결과 확인을 편하게 하기 위해 임시로 정답을 출력하게 했다.

private void init(){
	try{
		number = Randoms.pickUniqueNumbersInRange(1, 9, 3);
	}catch (IllegalArgumentException e){
		System.out.println(e.getMessage());
	}
    
	//결과 확인용 
	for (Integer integer : number) {
		System.out.println(integer);
	}
}

구현이 완료된 이후 게임을 진행해 봤고, 실행 결과 잘 동작했다.
그런데, 구현이 완료된 이후 테스트코드 하나가 계속 실패했다. 디버깅을 해 보니, init() 메서드의 실행 이후에도, number 변수에 아무런 값이 들어가있지 않아 프로그램이 진행되지 않았다.

평가

엔트리 포인트

프로젝트의 main() 함수는 Game 객체를 생성하고, 해당 객체의 run() 메서드를 호출하는 것이 전부이다.

public class Application {
    public static void main(String[] args) {
        Game baseball = new Game();
        baseball.run();
    }
}

이처럼 클래스에서의 시작 부분을 명확하게 구분한 것은 매우 좋은 습관이라고 피드백 받았다.

훗날 내가 배포한 코드를 읽는 사람들이 각 단계들을 손쉽게 따라가며 코드를 이해할 수 있다고 했다.

접근 제한자

Game 클래스의 경우 run() 메서드만 public으로 지정되어 있으며, 나머지 모든 메서드와 변수는 private으로 지정되어 숨겨져 있다. 이는 이후 누군가 Game 클래스를 사용하더라도 run() 메서드만 실행하면 되기에 다른 메서드는 필요하지 않으며, 필요하지 않은 메서드나 변수로의 접근이 프로젝트를 망칠 수 있기에 숨겨둔 것이다.

이처럼 필요한 메서드와 필요하지 않은 메서드를 구분한 것은 좋은 습관이라고 했다.

상수 관리

코드 내에 존재하는 매직넘버들에 대해 피드백을 받았다.

각 조건을 확인하는 과정에서
restart.equals("1")
같은 코드를 많이 사용하게 되었는데, 이 떄 사용하는 "1" 같은 값은 상황에 따라 변할 수 있는 값이기에 따로 관리를 해 주는것이 좋다.
따로 프로그램에 사용되는 상수를 관리하는 클래스를 생성해, 해당 클래스 내에서
public String RESTART_STRING = "1";
로 값을 지정하고, 값을 사용할 때는
restart.equals(RESTART_STRING) 으로 사용할 수있다.

이렇게 상수를 따로 모아 관리하게 되면 종료 조건이 바뀌는 등 코드의 수정이 필요할 때 RESTART_STRING의 값을 변경해 주는 것 만으로도 손쉽게 리팩토링이 가능하다.
또한, 이 코드를 읽는 사람 입장에서도 조금 더 명확하게 네이밍 된 변수의 이름을 보기에 코드의 진행을 이해하기 훨씬 편하다는 장점이 있다.

피드백

예외 처리

코드가 복잡한 이유를 발견했다!
프로젝트에서 요구한 예외처리 방법과 내가 구현한 방법이 조금 달랐다.
프로젝트에서는 예외가 발생한 경우, 프로그램이 종료되기를 요구했다. 그런데, 나는 각 예외 상황에 대해 Exception을 발생시킨 후, 해당 Exception이 던지는 메서드를 호출한 부모 메서드에서 다시 try-catchException을 잡고, catch문 안에서는 다시 잡은 Exception을 던졌다...

사실 그럴 필요 없이, Exception이 발생하면 던지고, 따로 처리해주지 않으면 자연스럽게 프로그램이 종료된다. try-catch를 사용해야 하는 경우는 말 그대로 프로그램이 종료되지 않도록 내가 처리 해 주어야 할 때 이다.

테스트코드 - mockito

테스트코드 하나가 문제가 생겼던 이유를 확인할 수 있었다!

우선 아래는 문제가 있었던 테스트코드이다.

class ApplicationTest extends NsTest {

    @Test
    void 게임종료_후_재시작() {
        assertRandomNumberInRangeTest(
                () -> {
                    run("246", "135", "1", "597", "589", "2");
                    assertThat(output()).contains(
                    "낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
                },
                1, 3, 5, 5, 8, 9
        );
    }
}

assertRandomNumberInRangeTest()를 포함한 테스트 코드는 아래 코드이다.

public class Assertions {
    private static final Duration RANDOM_TEST_TIMEOUT = Duration.ofSeconds(10L);
    
	public static void assertRandomNumberInRangeTest(
        final Executable executable,
        final Integer value,
        final Integer... values
    ) {
        assertRandomTest(
            () -> Randoms.pickNumberInRange(anyInt(), anyInt()),
            executable,
            value,
            values
        );
    }
    
    private static <T> void assertRandomTest(
        final Verification verification,
        final Executable executable,
        final T value,
        final T... values
    ) {
        assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> {
            try (final MockedStatic<Randoms> mock = mockStatic(Randoms.class)) {
                mock.when(verification).thenReturn(value, Arrays.stream(values).toArray());
                executable.execute();
            }
        });
    }
}

결과적으론, 테스트 코드를 작성하는데 사용된 Mockito의 사용법에 대한 무지와 요구사항을 제대로 읽지 않은 안일함의 조화가 불러온 문제였다.

최종 평가 (230118)

구현

문제점을 찾았으니 다시 구현을 하고자 했다. 하지만 테스트코드를 통과하지 못한 이유를 찾느라 점점 코드는 복잡해졌고, git을 사용하지 않았기에 원래 상태로 돌리기도 어려웠다. 코드가 무거워지면서 리팩토링이 힘들어졌다.

새로 프로젝트를 구현하기로 했다.
이번엔, git을 사용해 기능의 구현마다 버전을 관리하고, README.md 파일에 프로젝트를 설계하는 과정부터 차근차근 진행하기로 했다.
기능 구현은 이전 프로젝트와 크게 다르지 않기에 생략한다.
궁금한 사람은 Repository를 확인해 달라!

아래는 프로젝트의 디렉토리 트리이다.
📂main
┗ 📂java
┃ ┣ 📂baseball
┃ ┃ ┗ 📜Application.java
┃ ┣ 📂baseballgame
┃ ┃ ┗ 📜Game.java
┃ ┗ 📂constant
┃ ┃ ┣ 📜Constant.java
┃ ┃ ┗ 📜StringError.java

StringError.javaException에 사용할 에러 메시지를 관리하고, 나머지 상수들은 Constant.java로 관리하였다.

git 버전 관리는 특정 동작 단위로 구분하여 구현이 완료될 때 마다 commit을 하였다.
commit 메시지는 #001 - 구현된 기능 의 형식으로 통일하였다.

평가 및 피드백

객체 생성의 비용

내가 구현한 코드에서는 객체를 생성할 때 마다 컴퓨터가 정답을 선택하는 초기화의 과정을 진행했다. 때문에 각각의 게임이 끝나고 다시 게임을 시작하는 경우에는 객체를 새로 생성을 하는 방법을 선택했다. 이 방법은 객체를 생성하는 데 들어가는 비용이 클 수 있다고 고려해보라고 피드백을 받았다.
매 판 마다 객체를 새로 생성하는 대신, 정답을 선택하는 초기화 과정을 따로 함수로 만들어 한번 생성한 객체에서 초기화만 진행하는 방법과 비용 차이를 고민 해 보아야 겠다.

상수 네이밍

상수를 네이밍할 때는 SUCCESS_BOOLEAN처럼 예약어를 쓰는 경우는 적다고 했다. 그냥 SUCCESS라고만 네이밍 해도 좋을 것 같다고 피드백을 들었다.
네이밍은 여전히 어렵다...

함수 설계

사용자의 입력 값으로 정답을 확인하는 과정에서 사용자 입력의 길이를 확인하는 함수를 사용했다.

	void verifyInputStringLength(String inputString, int correctLength){
		if(inputString.length() != correctLength){
			throw new IllegalArgumentException(wrongLengthInputError + 
            "\nExpect : " + correctLength + "\nInput : " + inputString.length());
		}
	}

어짜피 정답을 확인할때만 사용자의 입력 길이를 확인할건데, 왜 굳이 corretLength 변수를 파라미터로 전달하는지 질문이 있었다.
지금은 정답 확인에서만 사용하게 되었지만, 사실 입력 String의 길이를 확인해야 하는 경우는 더 있을 수 있었기에 확장성을 고려해 파라미터로 설정했다고 대답했고, 그런 의도였으면 좋은 설계인 것 같다고 피드백을 받을 수 있었다.

후기

나는 지금까지 혼자 백준문제나 조금 풀 줄 아는 감자였는데, 나만의 작은 프로젝트를 하나 성공적으로 끝냈다. 물론, 이 프로젝트를 겪으면서 되려 공부해야 할 건 산더미처럼 늘었다. (테스트 코드 작성, Mockito, 네이밍 컨벤션, github,,,) 그래도, 즐거운 프로젝트였다.

코딩은 재밌다!!

profile
Zangsu

1개의 댓글

comment-user-thumbnail
2023년 4월 22일

안녕하세요!!
우테코 과제들은 코스를 밟지 않는 사람도 혹시 할수 있는지 궁금해서 여쭤봅니다!!

답글 달기