MVC 패턴이란 유지보수의 편리성을 위해 만든 코드 스타일이다.
모델은 데이터를 담고 있는 부분이다.
중요한점은, 모델은 컨트롤러와 뷰에 의존하면 안된다는 것이다. 즉 오직 데이터만을 위한 코드들만 담아두고 컨트롤러와 System.out 과 같은 뷰의 코드는 존재하면 안된다.
컨트롤러는 Model과 View의 사이를 중재하고 있는 부분이다.
주의할 점은 컨트롤러에는 비즈니스 로직을 최대한 배재하고, 단순히 뷰와 모델만을 이어주는 코드만 있도록 가볍게 구현해야 한다.
뷰는 사용자한테 보여지는 부분으로, 컨트롤러가 아닌 모델에만 의존해야 한다.
또한 모델로부터 받는 데이터는 사용자마다 다르게 보여주어야 하는, 즉 변하는 데이터만을 받아야 한다. 예를 들어 다리의 모양인 [ ] 에 관련된 영역은 모델이 아닌 아래와 같이 뷰에 있는 것이 옳다.
public class OutputView {
private static final String BRIDGE_FROM = "[ %s ]";
}
만약 여러 컨트롤러에서 하나의 도메인 비즈니스 로직을 사용하고자 할 때, 반드시 중복되는 코드가 발생하게 된다.
따라서 컨트롤러와 모델 사이에 비즈니스 로직을 수행하는 메서드를 모듈화한 Service 계층을 두어, 컨트롤러에서는 만들어진 여러 서비스들을 골라서 사용하면 되기 때문에 재사용성을 높일 수 있다.
더불어 서비스의 기능들을 조합만 해서 새로운 기능을 만들 수 있으므로 확장성이 증가하며, 서비스에서 다른 서비스를 의존성 참조하는 것도 가능하다.( 단, 의존성으로 서비스 레이어간의 순환 참조와 같은 문제가 발생했다면 다시 설계하는 것을 추천한다.)
스프링에서 왜 서비스 계층을 나눌까?
Service에서 Service를 의존할까 Repository(Dao)를 의존할까
스프링 순환 참조(Circular Reference)
DTO란, Data Transfer Object 의 줄임말로 계층(Layer)간 데이터를 교환하기 위한 객체를 뜻한다. 일종의 도메인 객체를 보호(캡슐화)하기 위한 방안이므로 주로 서비스 계층 내부에서는 도메인을 그대로 사용하고, 컨트롤러로 갈 때 DTO로 변환하는 과정이 일어난다.
📚 DTO의 특징
- DTO는 데이터 접근 메서드 외 기능을 가지고 있지 않는다. (getter, setter 메서드 외에 비즈니스 로직을 가지지 않는다.)
- 단, 정렬, 직렬화 등 데이터 표현을 위한 기능은 가질 수 있다.
- 값을 유연하게 변경할 수 있다. (가변성, mutable)
- 데이터 캡슐화를 통해 유연한 대응이 가능하다.
DTO를 다리 건너기 게임에 도입하게 이유는 다음과 같았다.
public class BridgeResult { // 도메인
...
public List<String> getUpBridgeResult() { // 게터메서드
return upBridgeResult
.stream()
.map(MoveResult::symbol)
.collect(Collectors.toList());
}
...
}
public class OutputView { // 뷰
// 무려 네개의 인자를 받고 있다.
public void printResult(List<String> upBridge, List<String> downBridge, String success, int retryCount) {
System.out.println(FINAL_RESULT);
printMap(upBridge, downBridge);
printGameSuccess(success);
printTotalRetryCount(retryCount);
}
}
위 코드의 네가지 정보는 뷰로 넘겨야할 가변 정보들인데 만약 출력해야할 데이터의 개수에 비례해 인자의 개수는 기하급수적으로 늘어날 것이다.
public class GameResultDto {
private final BridgeResultDto bridgeResultDto;
private final String success;
private final int retryCount;
...
public class BridgeResult {
public BridgeResultDto toDto() { // DTO로 변환하는 메서드
return BridgeResultDto.of(getResult());
}
}
public void printResult(GameResultDto gameResult) {
System.out.println(FINAL_RESULT);
printMap(gameResult.getBridgeResultDto());
printGameSuccess(gameResult.getSuccess());
printTotalRetryCount(gameResult.getRetryCount());
}
도메인에서는 게처 메서드 없이 필요한 데이터들만 가공해서 꺼낼 수 있게 되었고, 뷰로 전달할때도 인자 하나로 간단하게 전달할 수 있게 되었다.(DTO간 의존성을 제거한다면 인자 2개)
클래스간에 의존성이 있으면 무조건 좋지 않다고 생각해서 BridgeGame 클래스 안에 Bridge 를 필드로 가지고 있지 않고 아래와 같이 매번 함수의 인자로 전달하는 방식을 택했다.
따라서 자연스럽게 bridgeSize 정보가 객체 내부에만 존재하는 것이 아닌 프로그램 내부에 자유롭게 돌아다니게 되었고 현재 위치가 다리의 끝에 도달했는지 묻는 로직은 Bridge가 아닌 BridgeGame에서 이루어졌다. (게터 메서드를 지양하기 위해 객체에게 메시지를 보내지 못했다)
public class Bridge {
private final List<Square> bridge;
...
public getSize() { // 다리 길이 정보를 반환하는 게터 메서드
return bridge.size();
}
}
public class BridgeGame {
public void move(int bridgeSize) { // 인자로 전달
if (position < bridgeSize) {
position++;
}
}
public boolean isGameSuccess(int bridgeSize) { // 인자로 전달
return position == bridgeSize;
}
}
BridgeService 와 GameService 를 분리했고, position 정보는 BridgeGame 에 존재하고 있는 상황이다.
public class BridgeService {
// 다음 칸으로 이동이 가능한지 결과를 리턴하는 메서드
public SquareResult getOneSpaceMoveResult(String move, int position) {
Square userMove = Square.of(move);
boolean moveResult = bridge.canMoveForward(userMove, position);
return new SquareResult(userMove, MoveResult.of(movable));
}
}
public class GameService {
...
// 다리의 사이즈를 별도의 초기화 메서드를 통해 저장(객체지향적이지 못하다)
public void initGame(BridgeGame bridgeGame, int bridgeSize) {
this.bridgeGame = bridgeGame;
this.bridgeSize = bridgeSize;
}
// 이동이 가능하다면 현재 위치를 옮기는 메서드
public void isSuccessMoveBridge(SquareResult squareResult) {
if (squareResult.isMoveSuccess()) {
bridgeGame.move(bridgeSize);
}
}
...
}
✍️ 현재 코드의 문제점
getSize , getPosition BridgeService에 있는 Bridge에게 건너도 되냐고 물은 이후에 결과를 가져와서 또 GameService에서 결과가 성공이면 이동을 시키고 있다.GameService 내부에서 bridgeSize에 대한 필드를 가져야 하는데 생성자에서 한번에 초기화를 못하니 생성 시점이 아닌 Bridge 를 생성하고 난 이후에 따로 초기화 해준다.BridgeService와 GameService가 긴밀히 연결되어 있어서 서비스를 둘로 나눈 의미가 없어진다.move() 에서 다리의 길이를 인자로 받는다는게 약간 이상하다.결론적으로 코드가 너무 객체 지향과는 멀어져 지저분해진다. 더불어 서비스끼리 정보는 주고 받을 수 있지만, 만약 컨트롤러를 하나가 아닌 다리와 게임 컨트롤러 두개로 나누게 된다면 Application.main() 에서 컨트롤러 간에 정보 교환을 하거나 하나의 컨트롤러가 두개의 서비스 객체를 모두 지니고 있어야 할 상황이 올 것이다.
✍️ 해결 방법
이 모든 문제점들은 Bridge 객체 내부에서 size 값을 가지고 비즈니스 로직을 수행한다면 해결된다.
public class Bridge {
private final List<Square> bridge;
...
// 다리에 끝에 도달했는지 알려주는 메서드
public boolean isEndOfBridge(int position) {
return position == bridge.size();
}
}
또한 BridgeGame 내부에 Bridge 를 필드로 둔다면 BridgeService의 getOneSpaceMoveResult와 GameService의 isSuccessMoveBridge 기능을 합칠 수 있어 앞선 코드보다 더욱 깔끔하고 직관적인 코드가 만들어졌다.
public class BridgeGame {
private final Bridge bridge;
...
public BridgeGame(Bridge bridge) { // BridgeGame을 필드로 주입
this.bridge = bridge;
}
// 이동이 가능하다면 바로 현재 위치를 1 증가시켜 이동시키는 메서드
public SquareResult move(Square userMove) {
boolean movable = bridge.canMoveForward(userMove, position);
if (movable) {
position ++;
}
return new SquareResult(userMove, MoveResult.of(movable));
}
}
public class GameService {
// 이동 결과를 반환하는 메서드
public SquareResult moveBridge(String move) {
Square userSquare = Square.of(move);
return bridgeGame.move(userSquare);
}
}
만약 내가 처음부터 Bridge 내부에서 사이즈를 가지고 다리에 끝에 도달했는지 로 판별하는 메시지를 먼저 만들었다면, 사이즈 정보를 밖으로 꺼내서 일어난 복잡한 상황들을 해결하려고 무수한 리팩토링을 할 일은 없지 않았을까?
따라서 앞으로는 클래스를 설계할 때, 중요 메시지를 우선으로 만들고 그것을 바탕으로 외부 메소드들을 생성해야 겠다는 깨달음을 얻었다.
함수 내부의 try-catch 문에서 에러를 잡은 후, 뒤의 코드들은 정상적으로 실행되게 된다.
따라서 처리를 잘못해준다면 사용자가 입력한 유효하지 않은 값들 때문에 Exception 이 발생하는 등 부작용(side-effect)이 일어날 것이기 때문에 관련된 코드들은 모두 try 로직 안으로 넣어주는 것이 좋다.
💡 만약 사용자가 올바른 값을 입력할때까지 기다리고 싶다면?
재귀 함수 혹은 반복문를 사용하여 정상 처리될때까지 기다릴 수 있다.
private BridgeSize inputBridgeInfo() {
BridgeSize bridgeSize = null;
try {
int size = inputView.readBridgeSize();
bridgeSize = new BridgeSize(size);
} catch (IllegalArgumentException ex) {
System.out.println(ex.getMessage());
}
return bridgeSize;
}
이런 상황에서 올바르지 않은 값이 들어온다면 bridgeSize가 null인 상태로 함수가 종료된다. 따라서 그 다음 로직인 다리를 생성하는 함수에서 NullPointerException이 발생하게 된다.

private BridgeSize inputBridgeInfo() {
try {
int size = inputView.readBridgeSize();
return new BridgeSize(size);
} catch (IllegalArgumentException ex) {
System.out.println(ex.getMessage());
return inputBridgeInfo(); // 재귀 호출
}
}
재귀적으로 계속 인풋 값을 받아온 다음(사용자에게) 알맞은 값이 들어온다면, 정상적인 값의 BridgeSize를 리턴시키고 함수를 종료하면 된다.
즉, 재귀 호출을 이용해 올바른 값을 다음 로직으로 넘겨주면 부작용은 일어나지 않는다.