우테코 2차 과정 - 3. 지하철 노선도

Posted by : at

Category : wua-techcourse


드디어 대망의 프리코스의 마지막 과제! 지하철 노선도 과제이다. 역시 마지막 과제는 요구조건이 굉장히 많았다. 요구조건의 대부분은 클린코드를 향해!! 기능 요구사항은 다음과 같다.

(깃 주소 : https://github.com/qhals321/java-subway-map-precourse/blob/bomin/README.md)

  • 기능 요구사항

🧭 기능 개발 (백엔드)


기능 요구사항을 보면 엄청 자세히 나와있다. 예를 들어 역 관련 기능, 노선 관련 기능 이런 식으로 각 도메인 별 요구사항이 자세히 나와있다. 그러다보니 도메인을 나누는 데에 많은 도움이 되었고 나중에 다른 프로젝트를 개발할 때 이런식으로 도메인 별 요구사항을 잘 정리하면 코드를 작성하는 데에 있어 많은 도움이 될 것 같다.

기능부터 개발을 시작했다. 클린한 코드를 위해서는 리팩토링이 필수이니 기능 확인 작업을 위한 튼튼한 테스트 코드도 필수였다. (최대한 모든 상황의 수를 테스트 코드로 짜려다 보니 거의 시간의 80%는 테스트 코드를 작성하는 데에 사용이 되었다. 총 31가지 상황의 테스트를 짜게 되었다.)

구조는 다음과 같다.

무언가 기능 구현할 것도 많고 뷰도 많은 내용들이 있다보니 자연스레 MVC 패턴과 같이 코드가 구현되었다. 그리고 복잡한 서비스 로직이 없어서 서비스 레이어는 없이 repository - controller 흐름으로 완성하게 되었다. 이번 과제에서 기능구현은 최대한 유연한 코드가 될 수 있고 안전한 코드가 될 수 있도록 노력했다.

  • 외부 데이터베이스를 사용한다면 어떻게 바로 연결할 수 있을까?

    = RepositoryInterface로 선언해 구현 클래스만 만든다면 바로 연결이 가능하게끔 만들었다.

  • 뷰를 어떻게 완벽히 분리할까?

    = 뷰는 컨트롤러를 통해서만 접근이 가능하게끔 만들었고 도메인의 불변성을 위해 외부(View)에 내보낼 때는 DTO로 다시 매핑해서 내보내어 도메인의 불변성을 유지하려 노력했다.

도메인 개발

  • Station (역)

    Station 도메인을 개발할 때 가장 중요했던 건 Line 구간에 등록되어 있는 Station은 삭제가 불가하다는 전제조건이다. Station 입장에서는 Line의 모든 것을 알 필요 없이 Line 구간에 등록이 되어 있느냐만 알면 되니 int lineCount를 선언해 등록되어 있는 구간의 수를 카운트해주었다. lineCount가 0이 될 때만 삭제를 허가하면 Station의 가장 큰 이슈를 해결이 되는 것이다. (여러 구간에 등록이 가능하다, 어찌보면 N : N 관계)

    • 구현 코드

      
        public class Station {
            private static final int ZERO = 0;
            private String name;
            private int lineCount = 0;
      
            private Station(String name) {
                this.name = name;
            }
      
            public static Station from(String name){
                return new Station(name);
            }
      
            public String getName() {
                return name;
            }
      
            public void addLine() {
                lineCount++;
            }
      
            public void removeLine() {
                lineCount--;
            }
      
            public boolean isRemovable() {
                return lineCount == ZERO;
            }
      
            @Override
            public boolean equals(Object o) {
                if (this == o) return true;
                if (o == null || getClass() != o.getClass()) return false;
                Station station = (Station) o;
                return getName().equals(station.getName());
            }
      
            @Override
            public int hashCode() {
                return Objects.hash(getName());
            }
        }
      
  • Line (노선)

    Line 도메인 같은 경우 구간까지 관리하게끔 구현했다. 즉, 같은 노선에 있는 역의 정보를 모두 관리하게끔 했다. 구간은 역을 삽입하고 제거하는 기능이 자주 사용이 되니 LinkedList 자료구조를 사용했다. 구간에 역을 삽입하게 되면 StationlineCount를 올려주는 메소드를 불러주고, 제거한다면 lineCount를 내려주는 메소드를 불러오게 되었다. 즉, Line에서 StationlineCount에 영향을 주는 방식으로 개발했다.

    상품 구매 도메인 개발 방식과 매우 유사했다. 고객이 상품을 구매하면 재고 상품의 수는 줄여주고, 구매 취소를 한다면 재고 상품의 수는 다시 늘려준다.

    • 구현 코드

        public class Line {
            private static final int MIN_STATION_SIZE = 2;
            private String name;
            private List<Station> stations;
      
            private Line(String name, Station start, Station end) {
                this.name = name;
                stations = new LinkedList<>();
                stations.add(start);
                stations.add(end);
                start.addLine();
                end.addLine();
            }
      
            public static Line of(String name, Station start, Station end) {
                return new Line(name, start, end);
            }
      
            public String getName() {
                return name;
            }
      
            public List<Station> stations(){
                return Collections.unmodifiableList(stations);
            }
      
            public void joinStation(Station station, int index) {
                int newIndex = index - 1;   //1을 입력하면 0 번째 인덱스를 가르킨다.
                stations.add(newIndex, station);
                station.addLine();
            }
      
            public boolean isCorrectIndex(int index) {
                int minIndex = 1;
                int maxIndex = stations.size() + 1;
                return index >= minIndex && index <= maxIndex;
            }
      
            public void removeStation(Station station) {
                stations.remove(station);
                station.removeLine();
            }
      
            public boolean isStationRemovable() {
                return stations.size() > MIN_STATION_SIZE;
            }
      
            public int stationSize(){
                return stations.size();
            }
      
            public boolean containsStation(Station station) {
                return stations.contains(station);
            }
      
            public void removeAllStation(){
                stations.forEach(Station::removeLine);
            }
      
            @Override
            public boolean equals(Object o) {
                if (this == o) return true;
                if (o == null || getClass() != o.getClass()) return false;
                Line line = (Line) o;
                return getName().equals(line.getName());
            }
      
            @Override
            public int hashCode() {
                return Objects.hash(getName());
            }
        }
      
      

Repository 개발

도메인의 상태관리를 해준다. 이번 과제들을 통해 repository에 대한 정의가 정확히 세워진 것 같다.

과제에서 기본 뼈대가 있었는데 흥미로웠던 건 Collections.unmodifiableList(lines) 라는 메소드였다. 이런식으로 배열의 불변성을 유지할 수 있구나..!! 하지만 테스트 코드를 짤 때 저장되어 있는 모든 도메인을 삭제해야했기 때문에 새로운 배열에 옮겨 담아줬다. new ArrayList(lines)<>.

만일 자바 내부에서 데이터를 관리하는 것이 아니라 외부 데이터베이스를 사용해 관리한다면, Repository가 바뀌어야했기에 Repository는 인터페이스로 선언하고 RepositoryJava로 코드를 구현하게 되었다.

Controller 개발

유효성 검사에 대해 고민이 많았다. 도메인 모델에서 검사를 하는 것이 맞을까 뷰에 가까운 컨트롤러에서 검사를 하는 것이 적합할까? 어디서 얼핏 봤던 건 ‘뷰에서 들어오자마자 유효성부터 검사를 하는 것이 좋다’해서 컨트롤러에서 검사하는 방법을 선택했다. 컨트롤러가 하는 역할은 유효성 검사와 요청에 맞는 데이터 반환이다.

데이터 반환 같은 경우 최대한 뷰와 도메인의 의존을 줄이기 위해 새로운 DTO를 만들어 제공해주었다. 도메인의 불변성을 위해…!

🧭 View 개발


가장 공을 들였던 시간이다. ‘if else 또는 switch 사용이 불가하다’라는 요구사항이 있어, ‘어떤 방식으로 사용자에게 입력에 맞는 출력을 내뱉을 수 있을까?’ 고민을 했다. 방법은 Map을 이용해 사용자가 입력하는 글자를 key로 만들고 그에 맞는 화면을 value로 넣었다.

사용자가 입력해 그에 맞는 화면으로 이동하는 뷰를 navigationView 폴더를 만들어 관리했다.

총 4가지의 화면이 존재했다. 이 화면들은 전부 보기를 주어서 선택하면 그에 맞는 서비스를 제공해주는 뷰라 navigationView 라는 관심으로 묶었다. 디테일한 부분만 다르고 기능은 모두 같기 때문에 템플릿 메소드 패턴을 이용했다. (enum으로도 처리할까 고민하다가 이번에는 템플릿 메소드 패턴을 사용해보고 싶다는 마음에…)

NavigationViewTemplate이라는 추상 클래스를 만들어 디테일한 부분은 상속받은 클래스에서 구현하게끔 만들어 코드를 동작하게 만든다.

public abstract class NavigationViewTemplate {
    protected final Scanner scanner;
    private final ViewsContainer views;
    protected ViewStrategy viewStrategy;

    public NavigationViewTemplate(Scanner scanner) {
        this.scanner = scanner;
        this.views = setViewsContainer();
    }

    //선택 사항의 뷰를 담는 컨테이너를 구현하게끔 한다.
    protected abstract ViewsContainer setViewsContainer();

    public void show() {
        selectView();
        viewStrategy.execute();
        OutputView.enter();
    }

    private void selectView() {
        OutputView.selectView(navViewName());
        OutputView.printMessage(views.navigate());
        String viewKey = scanner.nextLine();
        viewStrategy = views.getView(viewKey);
        if (viewStrategy == null) {
            OutputView.notExist();
            selectView();
        }
    }

    //화면의 이름을 리턴하게 한다. (예 : 메인화면)
    protected abstract String navViewName();
}

상속을 받는다면 뷰 컨테이너 완성과 화면의 이름만 구현하면 된다.

뷰 컨테이너란 메인화면을 예로 든다면

  1. 역 관리
  2. 노선 관리
  3. 구간 관리
  4. 지하철 노선도 출력

Q. 종료

위의 각 뷰를 담은 컨테이너다. ({key : 1, value : '역 관리 뷰'})

다음이 메인화면의 구현 코드이다.

public class MainView extends NavigationViewTemplate {
    private static final String NAV_VIEW_NAME = "메인 화면";

    public MainView(Scanner scanner) {
        super(scanner);
    }

    @Override
    public void show() {
        super.show();
        //종료를 선택하지 않는다면 다시 메인화면을 띄워줄 수 있게 한다.
        if(!(viewStrategy instanceof ExitView)) {
            show();
        }
    }

    @Override
    protected ViewsContainer setViewsContainer() {
        ViewsContainer views = ViewsContainer.newInstance();
        views.add("1", new StationManageView(scanner));
        views.add("2", new LineManageView(scanner));
        views.add("3", new LineStationManageView(scanner));
        views.add("4", new LineMapView());
        views.add("Q", new ExitView());
        return views;
    }

    @Override
    protected String navViewName() {
        return NAV_VIEW_NAME;
    }
}

메인화면은 exit을 누르지 않는 이상 계속 반복을 시켜줘야해서 show()를 오버라이딩을 해 위 코드를 더했다.

  • enum 버전

    enum 이 더 깔끔한 것 같기도 하고…?

      public enum MainView {
          ONE("1", "역 관리", new StationManageStr()),
          TWO("2", "노선 관리", new LineManageStr()),
          THREE("3", "구간 관리", new StationLineManageStr()),
          FOUR("4", "지하철 노선도 출력", new LinePrinterStr()),
          QUIT("Q", "종료", () -> System.exit(0));
    
          private static final String MENU_FORM = "%s. %s \n";
          private String inputNum;
          private String viewName;
          private View2Strategy view2Strategy;
    
          MainView(String inputNum, String viewName, View2Strategy view2Strategy) {
              this.inputNum = inputNum;
              this.viewName = viewName;
              this.view2Strategy = view2Strategy;
          }
    
          public static String menuList() {
              StringBuilder menu = new StringBuilder();
              for (MainView value : values()) {
                  menu.append(String.format(MENU_FORM, value.inputNum, value.viewName));
              }
              return menu.toString();
          }
    
          public static void run(String inputNum) {
              Arrays.stream(values())
                      .filter(value -> value.inputNum.equals(inputNum))
                      .findFirst()
                      .orElseThrow(() -> new IllegalStateException(""))
                      .view2Strategy.run();
          }
      }
    

Execute Views

실제로 기능을 실행하는 뷰들이다. View Container에 들어가는 value들이기도 하다. 위 navigation view에서 입력하는 key에 따라 각자 다르게 실행할 수 있게 만들어야 했기 때문에 Strategy pattern을 이용했다.

public interface ViewStrategy {
    void execute();
    String viewName();
}

View Container에서 value로 들어가는 인터페이스다. execute()navigation view에서 클릭을 했을 시 실행될 구간이다.

(NavigationViewTemplate.class)
public void show() {
        selectView();
        viewStrategy.execute();
        OutputView.enter();
    }

위를 보면 show()라는 함수가 실행이 되면 execute() 함수가 실행이 되는 것을 볼 수 있다. 즉, 선택한 키에 맞는 뷰의 execute() 함수가 실행되게끔 만들었다.

역을 만드는 기능을 갖고 있는 화면을 실행하는 코드이다.

public class CreateStationView implements ViewStrategy {
    private static final String VIEW_NAME = "역 등록";
    private static final String CREATE_MESSAGE = "등록할 역 이름을 입력하세요.";
    private static final String CREATE_COMPLETE = "지하철 역이 등록되었습니다.";
    private final StationController stationController;
    private final Scanner scanner;

    public CreateStationView(StationController stationController, Scanner scanner) {
        this.stationController = stationController;
        this.scanner = scanner;
    }

    @Override
    public void execute() {
        try {
            OutputView.selectView(CREATE_MESSAGE);
            String stationName = scanner.nextLine();
            OutputView.enter();
            stationController.createStation(stationName);
            OutputView.infoView(CREATE_COMPLETE);
        } catch (IllegalStateException e) {
            OutputView.errorView(e.getMessage());
        }
    }

    @Override
    public String viewName() {
        return VIEW_NAME;
    }
}

🧭 이번 과제를 끝내며…

이번 과제에서는 저번 과제에서 힘썼던 ‘역할 분담’ 이외에도 ‘유지보수’를 고려하며 코드를 작성하려 노력했다.

  • 만약 외부 데이터베이스와 연동하고 싶다면?
  • 기능을 추가해야되는데 다른 코드를 건들지 않고 개발을 어떻게 할까?
  • 만약 외부에서 데이터 조작이 생긴다면? (불변성 유지)

이런 고민을 하며 코드를 작성하다보니 자연스레 디자인 패턴을 사용하면서 추상 클래스와 인터페이스가 작성이 되었다.

또한 switch문과 else문 없이 다양한 요청을 어떻게 처리할까가 가장 고민이었다. 원래대로라면 switch문을 쓰겠지만 다른 방법으로 접근해보니 고민스러웠지만 너무 재미있던 시간이었다. 자연스레 코드가 조금 더 객체지향적인 코드가 되었고 유지보수 측면에서도 더 좋은 코드가 나오게 되었다.

확실히 뷰와 기능개발을 정확히 나누어 개발을 하다보니 정말 편했다. 기능 개발을 할 때에는 뷰에 내보낼 데이터만 생각하고 그에 맞는 테스트 코드를 작성하니 테스트 코드만 잘 돌아간다면 어떤 리팩토링을 해도 뷰에는 영향을 주지 않아 독립적인 개발이 가능했다. 이렇게 프론트앤드와 백앤드가 나눠졌구나..!

이번 과제에도 정말 많은 것을 배웠다. 기능 구현 부분에서는 백앤드 웹 개발과 비슷했지만 콘솔 뷰 구현 부분은 새로운 도전이라 많은 고민을 던져주었고 그 속에서 다시 한 번 성장한 것 같다. 이번 과제가 프리코스 마지막이라니 조금은 아쉽긴하다…

🧭 아쉬운 점 (2020.12.17 추가)

오브젝트 책을 과제가 끝나고 다 읽어봤다. 내가 낸 과제에는 아쉬운 점들이 몇몇 있었다.

  1. 예외처리

    예외를 던지는 곳을 컨트롤러 부분에서 다 던지게 되었다. 하지만 객체지향(책임주도개발)은 한 객체가 행동을 했을 때 생기는 모든 것은 그 객체만 알아야 하는데 외부에서 예외까지 던져주면 그 외부는 그 객체가 어떻게 흘러가야되는 지 알아야 되므로 SRP 법칙에 어긋나는 것 같다.

    예외는 생기는 곳에서 바로 throw!!!

  2. 상속 보다는 합성

    뷰 템플릿이라는 추상 클래스를 만들어 그것을 상속받아 뷰를 확장시켰다(템플릿 메소드 패턴). 그러면서 super의 메서드를 호출했는데 그렇게되면 상위 클래스와 의존성이 깊어지는 것이다. 과연 유지보수에 알맞은 코드일까?? 차라리 상속보다는 자식 객체마다 바뀌어지는 부분을 클래스로 만들어 합성 방식을 사용했으면 어떠했을까?

  3. 너무 MVC 패턴의 의존

    추상화가 부족했던 것 같다. 1,2 차 과제는 최대한 추상화를 하려고 노력했는데 이번 과제에는 MVC만을 신경쓴 것 같다. 네이밍같은 경우 편했지만 객체들끼리의 연결을 잘 만들어주지 못 한 것 같다. 도메인끼리 연결해서 만들려고 노력하자.

이 책을 조금 더 빨리 발견했더라면 객체지향의 베이스를 조금 더 탄탄히 했을텐데 아쉬웠다. 그래도 파이널 테스트 이전에 발견하고 고칠 수 있어서 정말 다행이다.


About Dani
Dani

많은 것을 배우고 싶은 사람

Email : bomdani9302@gmail.com

Website : https://qhals321.github.io

About 다니

많은 것을 배우고 나누고싶은 사람

Useful Links