사다리 타기 미션이 종료되었다..!
물론 끝난지 10일 정도 되가지만.. 미루고 미루다가 정리한다 ㅎ
나는 내가 학습한 것들을 나만의 언어로 작성하면 못배기는 성격이기에, 사다리 타기를 통해 배운 것들을 정리하고자 한다.
기능목록도 도메인 영역별로
이전까지는 요구사항을 조금 불친절하게 작성했던 것 같다.
문서는 나를 위해서만 존재하는 것이 아니다.
기능 목록을 도메인 영역별로, 상세히 작성하는 경우 다른 개발자가 시스템을 이해하기가 쉬워지며,
더불어 커밋 로그에 대한 가이드라인이 되기도 한다.
따라서 이 이후로는, 기능목록에 도메인 로직을 상세히 작성하기 시작했다.
현재는 다음과 같이 도메인 모델에 따라 기능을 분류하고 있다.
내려가기 규칙
내려가기 규칙에 대해서는 내가 프롤로그에 따로 작성해 둔 것이 있어, 링크만 첨부하겠다!
쉽게 말해 내려가기 규칙은 추상화 레벨이 점차 낮아지도록 메소드 선언 순서를 조정하는 것을 의미한다.
예측 가능하게 짜라
객체의 행동은 예측 가능해야 한다
저번 미션 회고에서도 정리한 내용이지만, 모듈은 놀람 최소화 원칙을 준수해야 하기 때문이다.
사다리 타기 미션에서 내가 실수한 부분은 이 원칙을 어겼다는 것이다 😅
public enum Block {
EMPTY(0),
EXIST(1);
private final int status;
...
}
클라이언트가 위 코드에서 status가 무엇인지 예측이 가능할까?
전혀 예측 불가능할 것이다.
status는 랜덤하게 블록을 만들어내기 위한 역할이었기 때문이다.
랜덤 모듈 자체는 객체를 반환하지 않으므로, 0과 1 중 하나를 반환하게 하고, 그것에 Block 객체를 대입하는 방식이었다.
모듈 자체를 하나의 객체로 볼 수 있듯, 객체 자체도 하나의 모듈로 볼 수 있다.
따라서 객체를 설계할 때도, 이 객체를 사용하게 될 클라이언트에게 서비스를 제공한다는 관점에서 설계하자.
핵심 도메인 객체를 특정 인터페이스에 영구 결합시킬 것인가
사다리 타기에서 핵심 도메인 객체는 당근 사다리다.
그렇다면 사다리를 구성하는 Line, Block도 핵심 도메인 객체가 된다.
나는 초기에 사다리를 생성하는 로직과 저장하는 분리했다.
즉, Ladder와 LadderMaker 객체를 분리했다.
왜 그렇게 분리했냐고 묻는다면, 랜덤의 개념이 포함되어 있기 때문이다.
사다리는 랜덤으로 생성되어야 하고, 생성의 책임을 갖는 객체는 필연적으로 랜덤 값을 다루게 된다.
이 말인 즉슨, 핵심 도메인 객체에게 생성 책임을 부여하면 핵심 도메인 객체가 랜덤 로직을 가지게 된다는 소리다.
엥, 그러면 전략 패턴으로 주입해주면 끝나는 문제가 아닌가요?
라고 물을 수 있지만 전략 패턴을 사용한다는 건, 포함 관계 혹은 연관 관계를 가진다는 의미다.
즉, 핵심 도메인 객체가 항상 전략 패턴을 사용한 인터페이스와 결합되어 있게 된다.
나는 이것이 문제라고 생각했기 때문에 Maker 객체를 분리해 랜덤 값을 다루게 했다.
그런데, 그러자 핵심 도메인 객체인 Ladder, Line, Block이 빈약해졌다(빈약한 도메인 모델).
즉, 스스로 상태를 관리하지 못하게 되었고 언제든지 사다리 게임의 규약을 어길 수 있게 되었다.
이 부분에 대해 당연히 지적 받게 되었고, 나는 두 가지 해결책을 떠올렸다.
- 구조는 그대로 두고, 핵심 도메인 객체의 인스턴스를 Maker 객체에서만 반환하게 한다.
- Maker객체와 핵심 도메인 객체를 합친다.
리뷰어분과 이야기를 나눈 결과, 보다 응집도도 높고 도메인 집약적인 2번을 선택했다.
그렇지만 2번 방식이 완전하게 좋다는 말은 못하겠다.
테스트를 위한 전략 패턴의 인터페이스가 결합되기 때문이다.
정답은 역시나 없다. 이미 장단점은 파악하고 있기에 상황에 맞춰, 올바른 설계를 하도록 하자.
예외 메세지는 View의 영역인가
자동차 경주 미션부터 고민했던게, 예외 메세지는 View의 영역인지 아닌지다.
현재 구현하고 있는 미션에서는 예외 메세지가 사용자에게 직접적으로 보여진다.
그렇기에 View가 수정되면 예외 메세지가 수정되고, 이는 도메인의 변경을 야기한다.
즉, Domain이 View의 존재에 대해 알게 된다.
Domain은 순수 비즈니스 로직을 담아야 하는 모듈이다.
따라서 View의 변경이 영향을 준다는 것은 문제가 된다.
그렇기에 나는 줄곧 예외 메세지를 도메인에서 분리하려는 메커니즘을 만드려고 했다.
그러다가 리뷰어분에게 들은 말씀이 다음과 같다.
예외 메세지로부터 도메인을 보호하는 것 자체가 어색하다는 것이다.
확실히 맞다.
예외 메세지는 디버깅적 성향이 강하고, 예외 자체도 '예외적인 상황에 대한 문맥 전달'의 느낌이 강하다.
그렇다면 이런 고민을 하게 된 이유가 무엇일까?
콘솔 어플리케이션이면서, 예외 메세지를 사용자에게 직접 드러내기 때문이다.
그렇기에 예외 메세지에 따른 domain 영역의 변경이 발생하는 것이다.
나중에 웹서비스로 넘어간다면 이런 문제가 발생하지 않을 것이다.
도메인 영역에서는 예외 발생만 정의하고, 사용자에게 보여지는 예외는 백엔드의 영역이 아니다.
즉, 요약하자면 백엔드와 프론트엔드의 개념이 혼합되어 있기에 발생한 문제라는 소리다.
원래 예외 메세지 자체는 디버깅용으로 작성되는게 맞다.
아무튼, 미션의 구조가 그러하므로 별 수 있겠는가..? 나는 다음과 같이 사용자에게 보여질 예외 메세지를 정의하는 컨트롤러를 작성했다.
public class FrontExceptionController {
private static final Map<Class<? extends RuntimeException>, String> messageSelector = new HashMap<>();
private final OutputView outputView;
public FrontExceptionController(OutputView outputView) {
this.outputView = outputView;
init();
}
private void init() {
addMessageWithDomain();
addMessageWithView();
}
private void addMessageWithDomain() {
messageSelector.put(LadderLengthException.class, "사다리의 길이는 <플레이어 수 - 1> 이상이어야 합니다.");
messageSelector.put(PlayerNameLengthException.class, "플레이어 이름의 길이는 1이상 5이하 입니다.");
messageSelector.put(PlayerNumberException.class, "플레이어 수는 두 명 이상이어야 합니다.");
messageSelector.put(NoSuchPlayerException.class, "해당하는 플레이어를 찾을 수 없습니다.");
}
private void addMessageWithView() {
messageSelector.put(NumberFormatException.class, "입력된 값은 정수가 아닙니다.");
}
public void handle(RuntimeException e) {
outputView.printExceptionMessage(messageSelector.get(e.getClass()));
}
}
이를 통해 도메인에서는 예외 발생만 정의한다.
즉, 시스템이 어떤 흐름에 있는지 상태만 나타내고, UI로 보여질 메세지를 부여하는 역할은 컨트롤러가 가진다.
이를 통해 개발자에게 보여질 예외 메세지, 사용자에게 보여질 예외 메세지를 나눌 수 있었다.
진행하고 있는 미션 특성상, 도메인 영역을 순수하게 보존하려면 위와 같은 설계가 맞다고 생각한다.
요약하자면..
- 예외 메세지는 본래 View의 영역이 아니라, 디버깅용이다.
- 미션 특성상, 예외 메세지가 사용자에게 직접 드러난다.
- 따라서 콘솔 어플리케이션 미션에 한해 사용자에게 보여질 예외 메세지, 디버깅용 예외 메세지를 나누어야 한다.
위 세 줄이 약 3주 간 예외처리에 관해 고민한 결과물이다.
도메인 - 뷰 의존성 줄이기
뷰가 도메인에 의존해도 될까?
의존을 안할 수는 없다. 뷰가 도메인을 모르면 어떻게 렌더링을 하겠는가.
그렇다면 의존성을 줄여야 할까?
그렇다.
특정 뷰를 100만명이 사용한다고 해보자.
해당 뷰에서 도메인 객체를 직접적으로 의존하고 있다면, 도메인 객체를 변경하는 것이 가능할까?
도메인 변경이 100만명이 사용하는 뷰의 변경으로 이어진다.
그렇기에 직접적인 의존성을 제거하는 편이 낫다.
따라서 MVC에서 View에게는 String 타입과 원시 타입만 제공해야 한다.
그렇다면 반대로 도메인 객체를 View에게 직접 보내야 하는 상황이 있을지 궁금해서 리뷰어분에게 여쭤봤는데,
웹서비스든 앱서비스든 그렇게 하는 경우 단점이 너무 많아 사용하지 않는다라고 말씀해주셨다.
하지만 이론 상은 이해하겠으나, 실제로 구현하려고 하니 모든 객체를 일일이 String이나 원시 타입으로 풀어서 View에 보내는 건 여간 쉬운 일이 아니다.
따라서 뷰에서 도메인 영역에 덜 의존하는 방향에 대해서는 앞으로 조금 더 고민해봐야겠다.
보다 좋은 방법이 있을까?
setter 사용은 무조건 금기?
맞다. 무분별한 setter 사용은 죄악이다.
그럼 다음과 같은 경우는?
public void recordGameResult(List<String> gameRecord) {
for (int i = 0; i < size(); i++) {
gameRecords.put(players.get(i), gameRecord.get(i));
}
}
게임 결과를 player 각각에게 매핑해주는 역할을 한다.
위 코드는 setter일까, 아닐까? 일단 setter가 맞다.
그럼 문제가 되는 것이 아닐까?
이에 대해 궁금해져서 여쭤봤는데, 리뷰어분이 위 메소드는 도메인적 행위를 나타내기에 문제가 없다고 하셨다.
예를 들어, DB에 접근할 때 updateUser라는 메소드가 있다고 해보자. 이 역시도 당연히 setter이긴 하다.
무분별한 setter를 통해 캡슐화를 저해하고 내부 구조에 접근하는 것이 문제다.
도메인적 흐름이나 비즈니스 로직 자체가 setter라면 문제가 되지 않는다.
그렇지만 위와 같이 setter의 성질을 띠는 메소드의 경우, 주의해서 다뤄야겠다.
본질은 setter이니까!
마치며
사다리 미션이 종료되었는데, 좋은 리뷰어와 좋은 페어 디투를 만나 정말 많이 배웠다!
좋은 사람, 좋은 미션과 함께.^^ #우테코
이번에 학습한 것들이 추후 좋은 밑거름이 되었으면 좋겠다..!
'우테코 5기' 카테고리의 다른 글
getter는 금기가 아니다 (3) | 2023.03.25 |
---|---|
[레벨 1 미션] 블랙잭 게임 미션 회고 (7) | 2023.03.22 |
[레벨 1 강의] 좋은 코드 (0) | 2023.03.01 |
[레벨 1 미션] 자동차 경주 미션 회고 (0) | 2023.02.18 |
[레벨 1 미션] 자동차 경주 학습 기록 (0) | 2023.02.18 |