4주차 - 다리 건너기 깃허브 링크
[클래스 분리]
이번 과제에는 기본적으로 주어진 클래스들이 존재했고 이를 이용하여 프로젝트를 진행했어야 했다. 주어진 클래스들을 보니 어떤 구조로 프로젝트를 진행해야 된다는 가이드 라인을 제시하는 것 같아 구현 자체에 큰 어려움은 없었던 것 같다. 하지만 많이 시간을 소비했던 부분은 BridgeGame클래스를 domain으로 분류할 것인가 service로 분류할 것인가 고민했던 것이었다. 처음에는 BridgeGame 클래스를 domain 클래스로 분류하고 실제 게임 로직을 Service 클래스로 분리해서 작성하려고 했다.
/**
* 다리 건너기 게임을 관리하는 클래스
*/
public class BridgeGame {
/**
* 사용자가 칸을 이동할 때 사용하는 메서드
* <p>
* 이동을 위해 필요한 메서드의 반환 타입(return type), 인자(parameter)는 자유롭게 추가하거나 변경할 수 있다.
*/
public void move() {
}
/**
* 사용자가 게임을 다시 시도할 때 사용하는 메서드
* <p>
* 재시작을 위해 필요한 메서드의 반환 타입(return type), 인자(parameter)는 자유롭게 추가하거나 변경할 수 있다.
*/
public void retry() {
}
}
하지만 발생했던 문제점은 BridgeGame 내 선언된 move, retry 메서드였다. 해당 메서드들은 수정이 불가능하다는 요구사항이 있었고 이 메서드들은 다리 건너기 게임을 진행하는데 필수적으로 필요한 비즈니스(도메인) 로직에 해당했다. 단순 데이터를 저장하고 조작하는 클래스는 domain으로 분리하고, 비즈니스 로직이 구현된 클래스는 service로 분리하는 기준을 가진 입장에서는 BridgeGame 클래스를 다시 service 클래스로 분리할 수 밖에 없었다.
문제를 일단 해결하고 나니 갖게된 의문은 왜 내가 가진 domain과 service 분리에 대한 기준으로 이번 과제에서 다리에 대한 domain 클래스를 만들기 어려운가였다. 스스로 내린 결론은 BridgeGame 클래스에서 사용하는 bridge가 BridgeMaker 클래스를 이용해 List 데이터로 생성하여 사용되었기 때문이라는 것이었다. 여태까지 코드를 언제 domain 클래스로 분리해서 사용했었는지를 생각해보면 primitive 데이터들을 service단에서 계속 저장하고 이용해야 하는 경우였다. 하지만 BridgeMaker에서 생성한 다리를 Collection 중 하나인 List의 객체로 반환했기 때문에 데이터를 유지하는데 문제가 없었고 오히려 domain 클래스를 생성해서 관리하는 것이 비효율적이라는 판단이 들었다. 결과적으로 Bridge를 별도의 domain 클래스로 분리하지 않고 BridgeGame 클래스를 service 패키지로 분류해서 과제를 구현하게 되었다.
// 제출 당시 Bridge는 다리와 관련된 상수를 가지는 Enum 클래스였다.
public enum Bridge {
MIN_LENGTH(3),
MAX_LENGTH(20),
START_INDEX(0);
final private int value;
Bridge(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
}
(이후 수정한 내용)
위 생각들은 프리코스 과제를 제출했던 당시에 들었던 것들이다. 하지만 이후 왜 이 때 당시 가졌던 프로젝트 패키지 구조가 바뀔 수 밖에 없었는지 생각이 바뀌게 되어 다시 정리하고자 한다.
먼저 프리코스 미션 진행 당시에 가지고 있던 domain과 service에 대한 생각을 먼저 정리한다.
- Domain : 프로그램에서 객체라고 분리될 수 있는, 혹은 데이터를 저장해야 하는 기능이 필요한 경우 도메인 객체로 이를 유지하도록 한다.
- Service : 도메인 / 비즈니스 로직은 이곳에서 구현한다.
3주차에서 프로젝트 패키지 구조에 대해 고민하고 내렸던 domain과 service에 대한 주관적인 결론이다. 과거 회고 내용을 다시 보면 BridgeGame 클래스를 다리 건너기 게임에 필요한 데이터들을 단순 저장하고 검증하기 위한 용도, 즉 도메인으로 사용하려고 했었다는 걸 알 수 있다. 하지만 데이터를 저장하고 검증만 해야 하는 과거 기준에서는 BridgeGame 내에 제시된 move와 retry 메서드를 묵인할 수 없었다. 해당 기능들은 누가 봐도 게임에 핵심적인 도메인/비즈니스 로직에 해당했기에 Service 단에서 구현되어야 했기 때문이다.
하지만 여기에서 생각해봐야 할 부분은 왜 미션에서 게임과 관련된 데이터들을 검증 및 저장해야 할 것 같은 BridgeGame에 도메인/비즈니스 로직 메서드들을 구현하라고 강제했을까? 라고 생각한다. 실제로 프리코스 종료 후 기존 제출 코드들을 리팩토링하면서 다시 한 번 프로젝트 패키지 구조에 대해 생각을 바꾸게 되었다.
결론부터 얘기하자면 게임 진행과 관련된 데이터들과 게임을 진행하는 도메인/비즈니스 로직이 분리될 필요가 없었기 때문이다. 먼저 과거의 내가 결론지은 domain과 service의 역할들은 일반적으로 생각하는 MVC 구조와 다르다. 일단 기본적으로 MVC 구조에서는 서비스 단이 나오지 않으며 모든 핵심 도메인/비즈니스 로직은 Model에 구현되어야 한다. 그렇다면 왜 과거의 나는 패키지 구조를 왜 그렇게 나누려고 했을까?
먼저 프리코스 이전 잠깐 Spring 프레임워크에 대해 공부할 때 접했던 웹 어플리케이션 구조에 대한 내용이 영향을 주었던 것 같다.
위와 같은 웹 어플리케이션의 구조에서 각 계층의 역할은 다음과 같다.
- Controller : client의 명령을 받아 내부에서 명령을 처리하도록 한 뒤 그 결과를 view(혹은 client)에게 다시 반환하는 역할
- Service : 핵심 도메인 / 비즈니스 로직 구현
- Repository : 데이터베이스에 접근하여 도메인 객체를 DB에 저장하고 관리한다.
- Domain : 비즈니스 도메인 객체 (ex - 회원, 주문, 쿠폰 등 DB에 저장되어 관리되는 데이터)
과거 결론지었던 Domain과 Service의 개념과 거의 비슷하다. 얕게나마 Spring 프레임워크에 대해 배우면서 습득한 지식이 있던 상태에서 해당 개념들이 옳은 구조라는 생각이 있었던 것 같다. 또한 실제로 프리코스 과제를 진행했던 후기 / 회고들을 살펴보면 화려하게 repository, service, domain 모두 나눠가며 개발한 케이스들이 눈에 가장 먼저 들어왔었고 이것이 가장 크게 과거의 내 생각을 정하게 한 요인이 아닐까 싶다.
다시 본론으로 돌아가서 게임 진행과 관련된 데이터들과 게임을 진행하는 도메인/비즈니스 로직이 분리될 필요가 없다고 생각하는 이유를 얘기하고자 한다. 왜 웹 어플리케이션 구조에서 기존 MVC 구조에 등장하지 않았던 Service, Repository 가 등장하고 Domain은 Repository와 DB사이에 껴있는 것일까? 그 이유는 계층형 아키텍처를 통해 Controller, Service, Repository 각 계층 간 결합성을 낮추고 수행 기능의 응집성을 올리기 위함이다. (자세한 설명은 해당 레퍼런스의 계층형 아키텍처 부분을 참고하면 좋을 것 같다.) 그렇다면 우리가 프리코스 미션에서 진행하는 수준에서 이러한 구조가 정말 필요할까를 다시 고민하게 된다.
프리코스 미션에서는 DB를 연동하여 프로그램을 개발해야 하는 것이 아니다. 그렇기에 DB에 접근할 수 있는 Repository 계층을 따로 둘 필요가 없으며 Domain 계층이 DAO 역할을 수행할 필요도 없다. 이는 곧 Controller와 Repository 사이에서 독립적으로 도메인/비즈니스 로직을 수행하는 Service 계층의 역할 또한 다시 생각해봐야 한다는 흐름으로 이어진다. 결국 Service, Repository, Domain 모두 역할이 애매해지는 것이다.
많은 시간을 고민한 끝에 내린 결론은 아직 내가 개발하는 정도의 프로그램 단계에서는 전통적인 MVC 구조만 따라도 충분하다는 것이었다. 그리하여 도메인/비즈니스 로직에 해당하는 기능과 핵심 데이터를 담는 변수들을 모두 Domain(혹은 Model)에서 관리하도록 하자고 결정했고 이후 미션 리팩토링 스터디에서부터 최종 코딩테스트에 이르기까지 단순한 MVC 구조를 사용했다.
이미 지난 회고에 이렇게까지 장황하게 글을 추가하는 이유는 이후에 나와 같은 고민을 하고 있을 누군가에게 꼭 얘기해주고 싶어서이다. Model은 뭐고 Service는 뭐지? Repository를 꼭 써야만 하는건가? 라는 고민을 하며 정작 집중해야 할 구현에 신경쓰지 못하고 있다면 아직은 고민하지 않아도 될 단계라고 얘기해주고 싶다.
(물론 이러한 생각들이 다 정립된 상태에서 미션에 해당 구조를 사용하고자 하는 이라면 사용하는 것이 맞다. 이 부분에 대해서는 정답이 없다고 생각한다.)
[try-catch를 통한 예외처리]
지난 주차까지 과제를 진행하면서 모든 예외 처리를 Validator 클래스 메서드로 검증이 필요한 값을 넘기고 메서드 내부에서 Exception을 throw하는 방식으로 예외처리를 진행해왔다. 이번 주차 요구사항에서 ‘예외가 발생하면 사용자의 값을 재입력 받는다’라는 요구사항을 처리하기 위해서는 검증 메서드로 값을 넘기는게 아니라 사용자의 입력을 받는 부분에서 직접 예외가 발생하는 것을 잡고 조치를 취할 수 있어야만 했다.
먼저 알아야 했던 것은 정확히 어떤 예외상황이 발생하는지였다. 대부분 메서드에 적합하지 않은 값을 전달했을 때 발생하는 IllegalArgumentException이 발생했다. 딱 하나 다르게 발생한 Exception 종류는 입력 값의 형식이 숫자 형식이 아닐 때 발생하는 NumberFormatException이었다. 사용자의 입력값을 받아서 Integer로 변환하는 과정에서 발생하는 Exception이었는데 사용자의 입력값이 정수가 아니라 다른 문자가 섞여 있을 때 발생했다. 처음에는 하던대로 검증 메서드를 작성해서 예외처리를 하려고 생각했었다. 하지만 발생한 Exception 클래스에 따라 정확하게 처리를 하라는 요구사항을 지키기 위해 try-catch문을 사용하게 되었다.
public int readBridgeSize() throws NumberFormatException {
System.out.println("\n다리의 길이를 입력해주세요.");
int size;
try {
size = Integer.parseInt(readLine().trim());
} catch (NumberFormatException error) {
System.out.println("[ERROR] 올바른 형식의 입력값이 아닙니다. 다시 입력해 주십시오.");
return readBridgeSize();
}
return size;
}
여기서 놀랐던 부분은 잘못된 형식을 입력받았을 때 발생하는 Exception 클래스 하나만 catch하니 모든 값을 일일이 검증할 필요가 없어졌다는 것이다. 예전 방식대로 검증 메서드를 구현했다면 최소 하나 이상의 메서드를 구현하고 모든 입력 값의 케이스를 직접 고려했어야 했다. 하지만 어떤 Exception 클래스가 발생할 지 알고 이를 catch해서 예외 처리 코드를 작성하니 불필요한 검증 메서드 작성 과정을 생략할 수 있게 되었다. 검증 메서드를 일일이 작성하는 것보다 try-catch문을 통한 예외처리가 훨씬 더 효율적이라는 것을 확실하게 알게된 계기가 되었다.
[객체를 객체스럽게 사용해야 한다.]
BridgeGame 클래스 내부에 선언된 userBridge라는 Collection 값을 외부에 알려줘야 하는 기능이 필요했다. 예전에는 아무런 생각없이 getter 메서드를 이용해 Collection 값을 넘겨 주었는데 이렇게 되면 클래스 외부에서 Collection 값의 변경이 생길 수 있다는 문제가 발생한다는 것을 알게 되었다. 공통 피드백을 받고 최대한 인스턴스 변수를 클래스 외부로 반환하는 getter 메서드의 사용을 최대한 지양해야 하는 이유를 알게 되었다. 하지만 부득이하게 getter처럼 클래스 내부 인스턴스 변수들을 외부에 전달해야 하는 경우에는 어떻게 해결해야 하는지 고민이었다. 공통 피드백에 첨부된 레퍼런스를 통해 Collection 값에 unmodifiableeList를 적용하여 반환하는 것이 하나의 해결책이 될 수 있음을 알게되어 문제를 해결할 수 있었다. 앞으로 객체를 설계하고 서비스단 메서드를 작성할 때 객체와 관련된 작업은 최대한 해당 객체의 내부에서 처리하고 그 결과만 외부로 넘겨주는 방식으로 진행해야겠다.
public List<List<String>> getRoundMaps() {
List<List<String>> maps = new ArrayList<>();
maps.add(Collections.unmodifiableList(userBridge)); //외부에서 userBridge에 대한 제어 차단
maps.add(getBridgeLog());
return maps;
}
공부하며 알게된 내용 중 하나는 객체들을 설계할 때 객체들 간 주고 받는 메시지를 먼저 정하고 해당 메시지를 적절하게 주고 받을 수 있는 객체들을 작성하는 방식으로 진행하는 것이 좋다는 것이다. 항상 객체를 먼저 작성한 이후에 객체간 메시지를 어떻게 주고 받을지 고민하며 service단 코드를 작성했었다. 그렇다 보니 막상 service단에서 객체를 생성해 사용하려고 하면 객체 속성값의 타입을 바꿔야한다던가 메서드를 추가로 작성해야 하는 등의 과정이 추가로 필요했다. 이런 과정을 최소화하기 위해서는 객체를 먼저 설계할 것이 아니라 객체가 주고받는 메시지를 먼저 정하고 여기에 맞춰서 객체를 설계해야 한다는 것을 알게 되었다.
[Enum을 통한 상수 정리]
이번 주차 과제를 수행하다보니 선언한 상수가 너무 많아져서 과연 가독성이 이대로 괜찮은가에 대한 의문이 들었다. 이를 해결하기 위해 지난 주차에 배운 Enum 클래스를 활용하여 기능 구현 클래스 내부에 선언된 상수들을 줄여보려 하였다. 어떤 부분에서는 처음보다 코드 길이가 오히려 길어져서 불편했던 부분도 일부 있었다. 단순히 상수값의 변수명을 사용하던 부분이 Enum클래스명.인스턴스객체명.getValue() 와 같은 것으로 대체되니 한 라인의 코드가 너무 길어지게 된 것이다. 그래서 그 다음은 메서드의 매개변수들을 기존 타입에서 Enum 클래스 타입으로 바꾸는 리팩토링 작업을 진행하기로 결정했다.
//리팩토링 전
public static void validateIsStringCommand(String string, String command1, String command2)
throws IllegalArgumentException {
if (!string.equals(command1) && !string.equals(command2)) {
throw new IllegalArgumentException();
}
}
public void move(String nextMove) throws IllegalArgumentException {
Validator.validateIsStringCommand(nextMove, Command.MOVE_UP.getValue(), Command.MOVE_DOWN.getValue());
userBridge.add(nextMove);
}
// 리팩토링 이후
public static void validateIsStringCommand(String string, Command command1, Command command2)
throws IllegalArgumentException {
if (!string.equals(command1.getValue()) && !string.equals(command2.getValue())) {
throw new IllegalArgumentException();
}
}
public void move(String nextMove) throws IllegalArgumentException {
Validator.validateIsStringCommand(nextMove, Command.MOVE_UP, Command.MOVE_DOWN);
userBridge.add(nextMove);
}
리팩토링 작업을 마치고 나니 매개변수로 주고받는 타입을 아예 Enum 클래스로 변경되었고 코드들이 깔끔해져 가독성이 향상되는 더 큰 효과를 얻게 되었다. 또한 관련 있는 상수들을 Enum 클래스에 모아서 선언해두고 나니 나중에 상수 값을 변경해야 하는 상황이 생겼을 때 굉장히 편할 것이라는 생각이 들었다. 일일이 모든 클래스를 돌아다니며 상수값들을 수정할 필요 없이 Enum클래스 내 상수 값 하나만 변경해주면 되는 효과를 얻을 수 있기 때문이었다. 프로젝트의 규모가 커지면 커질수록 이런 상수값들을 관리하는 것도 매우 중요하게 여겨야 겠다는 것을 배웠다.
[테스트 코드 리팩토링]
프리코스 과정을 시작하고 테스트 코드를 쭉 작성해오면서 어떤 케이스를 테스트 코드로 작성해야 할까에만 집중했었다. 하지만 이번 주차에는 테스트 코드도 코드다 라는 공통 피드백 사항을 받고 테스트 코드 리팩토링을 꼭 해봐야 겠다고 결심했다.
이전까지는 여러 테스트 케이스를 하드코딩으로 직접 값을 생성해 줬었기에 하나의 테스트 메서드의 길이가 매우 길어졌고 한 눈에 메서드 내용을 알아보기 힘들었다. 하지만 JUnit의 @ParameterizedTest 어노테이션을 이용하니 테스트 케이스 값들을 전부 테스트 메서드 외부로 분리해낼 수 있었다. 이렇게 리팩토링을 하고나니 스스로 봐도 놀라울 정도로 테스트 메서드의 길이가 단축되었다. 또한 테스트 케이스를 작성하는 부분도 분리해냈기에 어떤 테스트 케이스들이 있는지 확인하고 수정하는 것도 직관적으로 행할 수 있게 되었다.
@DisplayName("미리 설정된 다리를 유지하면서 게임을 재시작한다.")
@ParameterizedTest
@MethodSource("MoveCommandsAndResultSignMap")
void 미리_설정된_다리를_유지하며_게임을_재시작하는_기능_테스트(List<String> moveCommands, List<String> resultSignMap) {
//given
bridgeGameCase.retry();
for(String command : moveCommands){
bridgeGameCase.move(command);
}
//when
List<String> result = bridgeGameCase.getRoundMaps().get(ResultMap.SIGN_MAP.index());
//then
assertThat(result).isEqualTo(resultSignMap);
}
static Stream<Arguments> MoveCommandsAndResultSignMap(){
return Stream.of(
Arguments.of(Arrays.asList("D", "U", "U"), Arrays.asList("O", "O", "O")),
Arguments.of(Arrays.asList("D", "U", "D"), Arrays.asList("O", "O", "X"))
);
}
그리고 BridgeGame 클래스에 대한 테스트 코드를 작성하다 보니 메서드마다 새로운 BridgeGame을 생성하여 테스트하고 있다는 사실을 발견하게 되었다. 테스트 메서드마다 중복되는 코드가 발생하였기에 이를 리팩토링하기 위해 BridgeGame 객체를 인스턴스 변수로 빼내서 생성하였다. 그리고 @BeforeEach 어노테이션을 이용하여 매번 테스트를 시작하기 전에 BridgeGame 인스턴스 객체 변수를 초기화할 수 있도록 하였다.
@BeforeEach
void beforeEach(){
bridgeGameCase = new BridgeGame(new TestNumberGenerator(newArrayList(0,1,1)),3);
}
테스트 코드 또한 기능 구현 코드와 똑같이 리팩토링의 대상으로 보고 정성을 들여야 한다는 것을 알게 되었다. 개인적으로 이번 주차 과제에서 가장 수행하고 나서 뿌듯함을 느꼈던 부분이었다고 생각한다.
[테스트하기 어려운 코드]
2주차부터 마지막 주차까지 프리코스를 진행하면서 항상 존재했던 요구사항 중 하나는 랜덤한 값을 생성하는 메서드를 사용하는 것이었다. 자연스럽게 들었던 의문은 랜덤하게 값을 생성하는 메서드를 사용하는 기능을 어떻게 테스트할 수 있을까였다. 이번 마지막 주차 공통 피드백을 공부하니 인터페이스를 사용하여 랜덤 값 생성 부분을 구현하면 테스트하기 쉽게 구현할 수 있다는 것을 알게 되었다. 인터페이스를 오버라이드해서 기능을 구현하게 되면 테스트 시에는 내가 의도한 값을 도출하는 객체로 재정의해서 원하는 테스트 케이스로 테스트를 진행할 수 있게 되는 것이다.
@Test
void 다리_생성_테스트() {
BridgeNumberGenerator numberGenerator = new TestNumberGenerator(newArrayList(1, 0, 0));
BridgeMaker bridgeMaker = new BridgeMaker(numberGenerator); // 기존 코드에서는 랜덤값 생성기가 주입된다.
List<String> bridge = bridgeMaker.makeBridge(3);
assertThat(bridge).containsExactly("U", "D", "D");
}
static class TestNumberGenerator implements BridgeNumberGenerator {
private final List<Integer> numbers;
TestNumberGenerator(List<Integer> numbers) {
this.numbers = numbers;
}
@Override
public int generate() {
return numbers.remove(0);
}
}
테스트 하기 어려운 기능을 구현할 때는 인터페이스를 사용해 실제 비즈니스 로직에서 사용되는 코드와 의도된 테스트 케이스를 도출하는 코드를 원하는 대로 구현해서 사용할 수 있도록 해야 한다는 것을 알게 되었다.
'우아한테크코스 > 우테코 프리코스' 카테고리의 다른 글
[우아한테크코스 5기] 백엔드 지원, 합격 과정 회고 (합격 후기) (20) | 2023.02.06 |
---|---|
[우아한테크코스 5기] 프리코스 3주차 회고 (0) | 2022.12.22 |
[우아한테크코스 5기] 프리코스 2주차 회고 (0) | 2022.11.08 |
[우아한테크코스 5기] 프리코스 1주차 회고 (1) | 2022.11.02 |