우아한 테크코스 레벨 1을 지나오면서 처음부터 지금까지 크루들 사이에서 회자되는 주제들은 여러가지가 있었다. 그 중 하나를 꼽아보자면 "Dto를 사용해야 하는가?" 였다. 꽤 많은 크루들이 Dto를 사용해 미션을 진행했다는 얘기도 많이 들었다. 반대로 그만큼 사용하지 않았다는 크루들도 많았기에 항상 뜨거운 감자였던 것 같다. 개인적으로 지금까지는 Dto를 사용하지 않는 입장을 고수해왔다. 하지만 최근 미션을 진행하며 생각이 많이 바뀌었기에 레벨 1이 끝나가는 이 시점에서 Dto 사용에 대한 개인적인 견해를 정리해 보고자 한다.
저번 블랙잭 미션을 진행하며 받았던 리뷰 중 다음과 같은 내용이 있었다.
unwarp을 컨트롤러에서 수행해야하는 이유가 있나요? View는 Domain을 알아선 안된다고 생각하시나요?
여기에서 unwrap은 원시타입의 데이터를 포장한 객체 혹은 값 객체(VO) 내부에 있는 상태 값을 getter를 사용해 외부로 꺼내는 기능을 의미한다. 블랙잭 미션을 진행하며 출력을 위해 필요한 원시타입 상태 값을 외부로 꺼내는 책임을 어디에서 질 것인지 페어와 많은 얘기를 나눴었다. 그 전까지 나는 미션의 요구사항 중 "view가 domain에 의존하도록 한다"는 내용을 보고 항상 도메인에서 wrapping 객체를 view에 그대로 넘겨주는 방식으로 구현했었다.
// domain의 Name 클래스
public class Name {
private final String name;
public Name(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
// OutputView 클래스
private List<String> unwrapNames(List<Name> names) {
return names.stream()
.map(Name::getName)
.collect(Collectors.toList());
}
사다리 게임을 구현한 미션을 보면 위와 같이 view에 도메인 객체를 그대로 넘겨서 view가 도메인에 의존하도록 했었다.
블랙잭 미션 페어였던 민트는 MVC 패턴에서 Model과 View가 상호 독립적으로 존재해야 한다고 주장했다. Model 과 View 어느 쪽도 한 쪽에 의존하는 양상은 지양해야 한다는 것이었다. 그러면 도대체 이 책임을 어디에서 져야 하는가에 대한 의문이 자연스레 따라왔다. 민트와 나 모두 지금껏 Dto를 사용하지 않았고 굳이 사용할 필요성을 못 느껴온 입장이었기에 Dto는 고려대상에서 제외됐다. 짧지 않은 논의를 통해 해당 책임을 domain이 값 객체를 unwrap해서 getter로 반환하는 것으로 결정하고 미션을 진행했었다.
// BlackjackGameController 클래스
private DrawCommand readDrawCommand(Player player) {
outputView.printAskOneMoreCardMessage(player.getName()); // domain 객체가 unwrap해서 controller를 통해 view에게 넘겨준다
return inputView.readDrawCommand();
}
// OutputView 클래스
public void printAskOneMoreCardMessage(String name) {
System.out.printf("%s는 한장의 카드를 더 받겠습니까?(예는 y, 아니오는 n)\n", name);
}
위와 같은 방식으로 미션을 진행하면서도 이렇게 하는 게 최선인지 확신하긴 어려웠다. 그 이유는 아래 코드를 보면 알 수 있다.
// BlackjackController 클래스, 게임의 최종 결과를 보여주는 메서드
private void showFinalResult(BlackJackGame blackJackGame) {
Map<Boolean, Integer> dealerWinningRecord = blackJackGame.getDealerWinningRecord();
Map<String, Boolean> userFinalResult = blackJackGame.getUserFinalResult();
outputView.printFinalResult(dealerWinningRecord, userFinalResult);
}
// OutputView 클래스, 딜러의 총 전적과 플레이어의 최종 승패 결과를 출력하는 메서드
public void printFinalResult(Map<Boolean, Integer> dealerRecord, Map<String, Boolean> userResult) {
System.out.println("## 최종 승패");
System.out.printf("딜러: %d승 %d패\n", dealerResult.get(true), dealerResult.get(false));
for (Entry<String, Boolean> userFinalResult : userResult.entrySet()) {
if (userFinalResult.getValue()) {
System.out.println(userFinalResult.getKey() + ": 승");
continue;
}
System.out.println(userFinalResult.getKey() + ": 패");
}
}
아까와는 다르게 출력 기능을 수행하는데 필요한 정보가 훨씬 더 많아졌다. 그 뿐만 아니라 정보의 단순 나열로 표현하지 못하는 값 사이의 연관 정보를 나타내기 위해 map 자료구조를 사용해서 전달하는 것을 볼 수 있다. 그런데 매개변수로 받는 map 객체의 타입이 도메인 값 객체가 아닌 기본 wrapper 클래스로 선언되어 있다. 외부에서 값 객체를 unwrap해서 넘겨주다 보니 발생한 현상이었다. 구현하고 보니 메서드 시그니처에서 매개변수에 담긴 데이터가 무엇인지 쉽게 알 수 없다는 아쉬움이 개인적으로 들었다.
// BlackjackGame 클래스, 플레이어 결과 정보를 unwrap한 뒤, map 객체에 담아 반환한다.
public Map<String, Boolean> getUserFinalResult() {
Map<String, Boolean> userFinalResult = new LinkedHashMap<>();
for (Player player : players) {
userFinalResult.put(player.getName(), player.isWinner());
}
return userFinalResult;
}
물론 코드를 따라가다 보면 전달되는 map 객체가 만들어진 부분을 찾을 수 있다. 그러나 BlackjackGame 클래스에는 해당 코드만 있는 것이 아니라 게임을 수행하는 다른 기능들 또한 존재한다. 만약 내가 처음 코드를 보는 개발자의 입장에서 이런 작업이 반복되어야 한다면 굉장히 부담스러울 것 같았다.
이러한 과정을 거치면서 여러 리뷰를 받고 다시 생각을 정리한 내용은 아래와 같다.
체스 게임 리뷰어 영이의 리뷰 中 :
... 예를 들어 회원아이디, 비밀번호, 나이 등등의 신상정보가 있는 user 도메인을 view로 그대로 넘겨버리면 개발자도구에서 비밀번호같은 중요정보를 볼 수 있어요. 그래서 view와 domain을 분리하는 이유라고 생각합니다
view에서 도메인을 알게 되면 외부로 드러나지 말아야 할 정보들이 같이 노출되는 위험이 발생하게 된다는 것이 영이의 의견이었다. 영이가 알려준 이유대로 view에게 domain 객체 자체를 넘기는 상황을 피하면서 controller가 해당 책임을 지지 않는 방법은 결국 domain에서 unwrap 책임을 지는 방법밖에 없었다.
이와는 별개로 이전까지는 도메인 로직 단에서 원시타입 상태 값이 이용되어야 하는 경우 값을 포장한 객체에서 내부 값을 꺼내는 것이 아니라 포장한 객체 자체로 사용되어야 한다고 생각했다. 예를 들어 게임 참여자의 이름이 필요한 domain 기능을 수행할 때 String 원시타입 값을 사용하는게 아니라 Name 값 객체 자체를 사용해 수행할 수 있어야 한다고 여겨왔다.
위에서 명시한 두 가지 이유는 상충한다. 그래서 참여자의 이름 정보를 얻어오기 위한 getter를 열 때 domain에서 사용하기 위한 Name 값 객체를 반환할 지 view에서 사용하기 위해 String을 반환해야 할 지 항상 고민해왔다. String을 반환하는 getter로 여는 경우 domain에서 원시타입 값을 이용해 비즈니스 로직을 수행한다는 생각이 들었고 개인적으로 이는 지양하고 싶었다. 그렇다고 domain 값 객체를 그대로 반환하자니 또 unwrap의 책임소재가 불분명했다.
지금까지는 domain 값 객체를 그대로 반환하도록 getter를 열고 unwrap의 책임질 곳을 결정해서 구현해왔다. view가 도메인에게 의존하도록 구현했던 적도 있었고 controller가 unwrap 책임을 지도록 구현했던 적도 있었다. 하지만 항상 어디에서 책임을 질 것인지 미루다가 결정되는 폭탄 돌리기와 같은 느낌을 항상 받았었다.
그러다 이번 블랙잭 미션 리팩토링을 진행하면서 dto의 사용을 고려하게 되었다.
출력을 위해 view에 전달해야 하는 정보가 많아지면서 값 객체를 unwrap 해주는 책임의 크기가 이전 미션에 비해 증가했음이 첫 번째 이유였다. 두 번째 이유는 여러 값을 한 번에 전달하기 위해 map 자료구조를 사용해 전달하는 방식을 채택하며 느낀 단점 때문이었다. 위에서도 언급했다시피 메서드 시그니처에서 전달받는 매개변수가 어떤 정보를 갖고 있는지 직관적으로 한 번에 알기 어렵다는 것이 단점이었다.
드디어 여러 값 객체를 unwrap 하여 한 번에 view에게 전달할 수 있는 역할을 수행할 객체의 필요성을 느꼈고 이러한 사고과정을 통해 dto를 도입해보기로 결정했다.
// 블랙잭 게임에서 사용자의 정보를 전달하는 Dto
public class UserGameDataDto {
private final String name;
private final int score;
private final List<String> cards;
public UserGameDataDto(Name name, Score score, List<Card> cards) {
this.name = name.getName();
this.score = score.getScore();
this.cards = cards.stream()
.map(Card::getSymbol)
.collect(Collectors.toList());
}
public String getName() {
return name;
}
public int getScore() {
return score;
}
public List<String> getCards() {
return cards;
}
}
// BlackjackGame 클래스, 플레이어의 게임 현황 정보를 Dto에 담아 반환한다.
public List<UserGameDataDto> getPlayerGameData() {
List<UserGameDataDto> playerGameData = new ArrayList<>();
players.forEach(player ->
playerGameData.add(new UserGameDataDto(player.getName(), player.getScore(), player.getCards())));
return playerGameData;
}
// BlackjackGameController, 플레이어의 카드 현황을 출력하는 메서드
private void showPlayerCardResults(BlackjackGame blackjackGame) {
List<UserGameDataDto> playerGameData = blackjackGame.getPlayerGameData();
playerGameData.forEach(outputView::printUserCardWithScore);
}
// OutputView 클래스, 블랙잭 게임에서 플레이어의 이름, 카드, 점수를 출력하는 메서드
public void printUserCardWithScore(UserGameDataDto userGameDataDto) {
String name = userGameDataDto.getName();
String cards = String.join(", ", userGameDataDto.getCards());
int score = userGameDataDto.getScore();
System.out.println(name + "카드 : " + cards + " - 결과: " + score);
}
결과적으로 Dto를 도입함으로써 얻을 수 있던 장점은 다음과 같이 정리할 수 있었다.
- 여러 데이터를 묶어서 map과 같은 자료구조로 전달하는 게 아니라 Dto를 통해 전달함으로써 읽는 사람으로 하여금 view 메서드의 시그니처에서 어떤 정보들이 전달되는지 비교적 훨씬 이해하기 쉬워졌다.
- 출력을 위한 값 객체의 unwrap 책임을 controller나 domain이 지는 것이 아니라 Dto가 수행하게 됨으로써 각자의 책임 소재가 더욱 명확해졌다.
- domain 입장에서는 자신의 상태 값을 unwrap하여 반환하지 않을 수 있게 되었다. 덕분에 외부로 건네준 데이터는 원시타입 데이터 쪼가리가 아닌 값 객체로써 domain적인 의미를 유지할 수 있게 되었다.
레벨 1을 지내오며 해결되지 않던 의문이었는데 개인적으로나마 정리할 수 있었던 시간이 되었다. 하지만 아직 Dto에 대한 정확한 개념이나 사용처를 공부해보지 않고 정리한 내용이기에 앞으로 더 수정될 여지도 분명 존재할 것이다. 이후 좀 더 정확한 Dto에 대한 개념적 정리를 해볼 필요를 느끼며 이번 포스팅을 마친다.
'우아한테크코스 > 학습 정리' 카테고리의 다른 글
[Level 2] Layered Architecture에 대한 개인적인 고찰 (0) | 2023.04.27 |
---|---|
[Level 2] Repository와 Dao를 분리하는 기준 (5) | 2023.04.21 |
[Level 2] 공식문서를 통한 프레임워크 학습 방법에 대한 고찰 (1) | 2023.04.16 |
[Level 1] 인터페이스와 추상클래스에 대한 개인적인 고찰 (0) | 2023.04.08 |
[Level 1] 좋은 객체의 7가지 덕목 (4) | 2023.03.11 |