[우아한테크코스 5기 프리코스]4주차 후기

96프로지망생·2022년 11월 22일
0

오징어게임 중 다리 건너기 게임을 구현해보자.

https://github.com/ilgon0110/javascript-bridge

추가된 요구 사항

  • 함수(또는 메서드)의 길이가 10라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메서드)가 한 가지 일만 잘하도록 구현한다.
  • 메서드의 파라미터 개수는 최대 3개까지만 허용한다.
  • 아래 있는 InputViewOutputViewBridgeGameBridgeMaker 클래스(또는 객체)의 요구사항을 참고하여 구현한다.
    • 각 클래스(또는 객체)의 제약 사항은 아래 클래스별 세부 설명을 참고한다.
    • 이외 필요한 클래스(또는 객체)와 메서드는 자유롭게 구현할 수 있다.
    • InputView 에서만 MissionUtils의 Console.readLine() 을 이용해 사용자의 입력을 받을 수 있다.
    • BridgeGame 클래스에서 InputViewOutputView 를 사용하지 않는다.

추상 클래스, 인터페이스 ?

저번 기수 합격자들의 회고록과 이번 기수 잘하는 지원자들이 3주차에 시도한 것들 중 시도해보고 싶은 것들이다. 4주차때는 반드시 시도해 보고자 추상 클래스와 인터페이스에 대해 공부하고 정리해보도록 하자.

추상 메서드, 추상 클래스

추상 메서드란 부모 클래스에서 정의하며, 자식 클래스에서 반드시 오버라이딩해야만 사용할 수 있는 메소드를 말한다. 그러므로 추상 메서드는 구현부가 없고, 선언부만 있다.

추상 클래스는 추상 메서드를 하나 이상 포함한 클래스이며, 정의되지 않은 추상 메서드를 포함하고 있음으로 인스턴스를 생성할 수 없다. 그러므로 상속만이 가능하다.

https://happysisyphe.tistory.com/m/26

코드를 보며 이해해보도록 하자.

class Animal {
  constructor(name) {
    this.name = name;
  }

  move() {
    console.log('동물이 움직입니다');
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name);
  }
}

Animal이 추상 클래스이고, 자식 클래스인 Dogmove 메서드를 반드시 사용하도록 설계했다. 하지만 자바스크립트에선 자바와 달리 abstract 이나 interface같은 선언자가 없기 때문에 직접 구현하여야 한다.

class Animal {
  constructor(name) {
    this.name = name;
    if (this.constructor === Animal) {
      throw new Error("추상 클래스로 인스턴스를 생성하였습니다.");
    }
  }

  move() {
    console.log('동물이 움직입니다');
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name);
  }
}

const animal = new Animal("동물"); // this.constructor === [class Animal], 이래선 안됨
const dog = new Dog("개"); // this.constructor === [class Dog extends Animal], 이렇게 써야함

//추상 메서드 오버라이딩
class Animal {
  constructor(name) {
    this.name = name;
    if (this.constructor === Animal) {
      throw new Error("추상 클래스로 인스턴스를 생성하였습니다.");
    }
  }

  move() {
    throw new Error("추상 메소드는 꼭 오버라이딩 되어야 합니다.");
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name);
  }

  move() {
    console.log("웕 웕");
  }
}

const dog = new Dog("개");
dog.move();

자바스크립트에서 추상 클래스와 인터페이스를 어떻게 활용해야되는지 더 공부하던 중 좋은 블로그를 발견했다.

https://myjamong.tistory.com/150

  • 추상 클래스 : 상속을 통해서 자손 클래스에서 완성하도록 유도하는 클래스. 미완성 설계도라고도 표현한다. 상속을 위한 클래스이기때문에 따로 객체를 생성할 수 없다.

  • 인터페이스 : 추상 클래스가 미완성 설계도라면 인터페이스는 기본 설계도라고 할 수 있다.

  • 추상클래스는 _ is A (~이다)

  • 인터페이스는 _ be able to A(~할 수 있다.)


개인적으로 굉장히 이해가 잘되도록 그린 그림이다.

하지만 이 글은 자바를 기반으로 설명하고 있다. 개념이야 똑같지만 자바는 abstract , interface선언자와 다중 상속을 지원하기 때문에 추상 클래스와 인터페이스를 사용 용도에 따라 분리할 수 있지만 자바스크립트에선 코드로써 직접 클래스가 인터페이스인지 추상 클래스인지 드러나도록 설계해야 한다.


이제 지원자들 중 추상 메서드와 인터페이스를 사용한 지원자의 코드를 분석해보자. 개인적으로 잘하면서 많은 걸 시도한다고 생각하는 지원자이다.

https://github.com/dev-redo/javascript-lotto

인터페이스 -> 추상 클래스 -> 자식 클래스 3단 구조로 MVC 패턴을 구조화했다. 그 중 Controller 구조를 보도록 하자.

//1. 인터페이스
const IGameCtrl = class {
  start() {
    throw Error('메서드구현필요');
  }

  gameProcess() {
    throw Error('메서드구현필요');
  }

  end() {
    throw Error('메서드구현필요');
  }
};

module.exports = IGameCtrl;

//2. 추상 클래스
const IGameCtrl = require('./IGameCtrl');

const GameCtrl = class extends IGameCtrl {
  constructor(view, model) {
    super();
    this.view = view;
    this.model = model;
    if (this.constructor === GameCtrl) {
      throw new Error('추상 클래스로 인스턴스를 생성하였습니다.');
    }
  }

  start() {
    this.gameProcess();
  }

  gameProcess() {}

  end() {}
};

module.exports = GameCtrl;

//자식 클래스. 실제 메서드를 구현하는 클래스
const LottoCtrl = class extends GameCtrl {
  constructor(view, model) {
    super(view, model);
  }

  gameProcess() {
    this.inputLottoBudget();
  }
//...Controller에 필요한 메서드들 구현
	end() {
	//대충 종료하는 내용
	}
}

인터페이스에서 추상 메서드를 선언하고, 추상 클래스에서 상속 후 추상 메서드를 오버라이딩하여 start()는 추상 클래스 단에서 구현하여 사용하고 있다. 그 다음으로 자식 클래스를 사용한다. 이렇게 한 의도가 뭘까? 필자는 인터페이스와 추상 클래스를 나눈 이유가 잘 와닿지 않는다. start() 를 자식 클래스에서 숨기고 싶은 의도라고만 이해하였다. 한 개만 만들어도 되는 것 아닌가? 추상 클래스와 인터페이스는 모두 변경에 유연하게 대처하기 위해 사용되는 패턴이다.

답은 GameCtrl이라는 네이밍에 있었다. start(), game(), end() 추상 메서드를 인터페이스에 선언하여 LottoCtrl은 Lotto 관련 메서드만 사용하게 만들었고, GameCtrl 추상 클래스에서 start() 를 구현해 자식 클래스는 오바라이딩 없이 사용할 수 있게 하면서 변경하기 어렵게 만들었다. 그래도 이것만으로 인터페이스와 추상 클래스를 나눌 필요가 싶긴 하지만 작성자의 의도는 이해하게 되었다. 다음은 View Components를 보도록 하자.

//인터페이스
const IGameView = class {
  input(message, callback) {
    throw Error('메서드구현필요');
  }

  output(message) {
    throw Error('메서드구현필요');
  }

  close() {
    throw Error('메서드구현필요');
  }
};

module.exports = IGameView;

// 추상 클래스
const { Console } = require('@woowacourse/mission-utils');
const IGameView = require('./IGameView');

const GameView = class extends IGameView {
  constructor() {
    super();
    if (this.constructor === GameView) {
      throw new Error('추상 클래스로 인스턴스를 생성하였습니다.');
    }
  }

  input(message, callback) {
    Console.readLine(message, callback);
  }

  output(message) {
    Console.print(message);
  }

  close() {
    Console.close();
  }
};

module.exports = GameView;

//자식 클래스
const GameView = require('./GameView');

const LottoView = class extends GameView {};

module.exports = LottoView;

인터페이스 -> 추상 클래스 -> 자식 클래스 구조를 맞추기 위해 자식 클래스가 상속만 받고 있긴 하지만, Controller에서 지저분한 코드 없이 view.input() view.output() 만으로 구현한 것을 보니 대단하다.

필자는 단순히 UI 로직 → view, 비즈니스 로직 → Controller, 데이터 → Model 로 생각하고 구현했는데.. 그리고 Console.readline을 View에서 처리할 수 있는 방법을 떠올리지 못하고 Controller에 구현했다. Controller는 View와 Model에 의존해도 되기 때문이다. 확실히 내 방식보다 좋은 방식이다.

추상 클래스와 인터페이스를 공부하다 보니 오히려 자바스크립트에서, 또 그렇게 볼륨이 크지 않은 프리코스 과제에서 인터페이스와 추상 클래스를 사용용도에 맞게 명확히 분리해서 사용할 수 있을까? 라는 의문이 들었다. 물론 관련 지식의 밀도가 낮은 탓도 클 것이다. 이번 과제에선 일단 추상 클래스와 인터페이스를 합쳐 MVC 패턴의 구조를 짜고, 리팩토링 단계에서 분리할 수 있으면 분리해 볼 생각이다. 그냥 잘하는 사람이 분리했으니까 따라하는 것은 맞지 않아 보인다.

또다시 Jest

미치겠다. Cannot log after tests are done. 이라는 메시지와 함께 Console.readLine이 원래 하던데로 작동하지 않는다. 추상화고 뭐고 다 빼고 그냥 Console 두개 찍는 테스트를 진행했는데도 안된다. 월요일까지 모든 로직을 완료하려고 했는데 시작부터 말썽이다. 왜이러니..? 원래 안 이랬잖아.

mockQuestion() 함수를 쓰니까 된다. readLine은 비동기로 작동하는 함수인데, 테스트 환경에선 동기적로 작동함으로 readLine 안에 있는 콜백함수가 실행되기 전에 테스트가 끝나버린다. 그래서 에러가 나는 것 같다.(테스트상으론 에러가 아니다. 정상적으로 진행된 것) 그래서 mockQuestion() 을 통해 콜백함수의 argument를 바로 넣어 주어 비동기 함수를 동기적으로 실행해도 원래 함수의 의도처럼 동작하도록 만들어 주는 것이 mocking 함수들의 역할이다.(뇌피셜) 계속 공부해보자.

3주차까지와 달리 추가된 mocking 함수들이 많다. 하나하나 뜯어가며 사용 용도를 파악했고, 용도를 다 파악했다고 생각하고 리팩토링에 들어가려는 찰나 마지막 runException 테스트가 통과되지 않았다. 그 이유로는

start() -> getBridgeSize() -> 잘못된 값 입력 시 throw로 에러 발생 후 getBridgeSize() 다시 시작
		   gameStart() -> getBridgeSize()가 완벽히 끝나고 this.model.bridge가 생성되야 함수 호출

이런 흐름을 생각했는데, 내 예상과 달리 getBridgeSize()에서 Error가 발생하면 throw가 작동되어 지정한 [ERROR]문을 출력하지만, try catch 구문에서 만들어논 재귀를 돌지 않고 gameStart()를 호출해버린다.

이렇게 되면 잘못된 입력값이나 없는 입력값으로 bridge를 생성하려하고, 당연히 오류가 발생한다. 테스트 코드에 있는 expectLogContains(getOutput(logSpy), ['[ERROR]']); 는 호출이 되지 않고 게임 내에서 undefiend property 참조 오류가 나는 상황. 사실 자바스크립트 언어의 구동 방식을 생각하면 당연한 오류다. stack 자료구조로 함수가 도중에 다른 함수를 호출하면 마지막 위치를 callstack에 저장했다가 다시 실행될때 마지막 위치부터 실행하기 때문이다.


해결. 그리고 엄청난 실수 자각

해결했다. 코드 흐름을 하나하나 따져가며 비동기적으로 실행되야 되는 함수들을 try catch 구문에 배치하여 해결했지만, 이 과정에서 지금까지 놓치고 있는 치명적 실수를 발견했다.

Jest만으론 디버깅이 어려워 nodeJS를 직접 실행하려고 콘솔에서 node App.js를 입력하는 순간, 아무것도 실행되지 않고 커서만 blink되고 있었다. 그 순간 깨달았다. 인스턴스를 선언하고 app.play()를 선언해야 하는데, 지금까지 진행한 baseball과 lotto 과제에서는 인스턴스를 선언하지 않았다. 당연히 nodeJS 환경에서 실행되지 않고 커서만 깜빡였을 것이다. 요구사항을 잘못 이해했고, Jest와 NodeJS, CLI에 모두 익숙하지 않아 발생한 실력 문제이기도 하다. 아쉽다... 마지막 과제 마지막 날이 되서야 깨닫고 말았다.

📢 Node.js 14 버전에서 실행 가능해야 한다.
Node.js 14에서 정상적으로 동작하지 않을 경우 0점 처리한다.
프로그램 실행의 시작점은 App.js의 play 메서드이다. 아래와 같이 프로그램을 실행시킬 수 있어야 한다.

예시

const app = new App();
app.play();

나는 이 예시를 Jest의 Application.test 에서 app 인스턴스를 선언하고 app.play()로 작동하는 걸 보고 ‘최종 테스트를 말하는 것이구나. 근데 이미 선언을 다 해놨네? 이 요구사항은 node 버전을 주의하라는 것이군' 으로 이해했다.

이 오해 하나로 지금까지 한 노력이 물거품이 된 기분이다. 굵은 글씨로 0점 처리된다고 써놔서 뭐라 할 말도 없고 온전히 내가 착각한 것이기 때문에 누굴 탓 할수도 없다. 나처럼 생각한 사람도 몇 없을 것 같다.

예상치 못한 오류로 실행에 실패했습니다.

요구사항 오해로 멘탈이 나가버린 채 제출 버튼을 눌렀을 때 지금까지 한 번도 발생한 적이 없는 제출오류가 발생했다. 아직 최종 시간까지는 2시간정도 남아있는 상황이라 '침착하자. 고칠 수 있다. 이거라도 제대로 내자'라고 심호흡하고 오류 해결에 돌입했지만, 한시간 동안 어디서 오류가 나는 건지 찾을 수 없었다. 당연히 로컬에선 모두 정상으로 작동했기 때문이다. 그때 지원자들이 모여있는 슬랙에서 제출 날마다 '예상치 못한 오류' 키워드로 조언을 구하는 지원자들이 많았었고, 혹시 회고록에서 이 오류를 해결한 사람이 있지 않을까? 라는 생각에 다른 지원자들의 회고록을 빠른 속도로 스캔했고, 한 지원자의 회고록에서 'Constants 파일을 import하는 과정에서 오류가 났다. 다행히 원인을 파악하고 무사히 제출할 수 있었다' 라는 글귀를 보았다. 바로 Constants 파일을 삭제하고 하드코딩 한 다음 제출 버튼을 누르니
됐다! 정말 다행이다... Constants 파일에 있는 상수들을 너무 많이 export하다보니 오류가 생긴 것 같다. 어쩐지 어느 순간부터 Constants파일에 있는 상수들을 작성할 때 VS Code의 자동 완성 기능이 작동하지 않았다.

느낀 점

이번 과제에선 추상 클래스와 인터페이스를 MVC 구조에 맞게 적용하여 객체지향 프로그래밍을 추구했다. 개념을 다잡기 위해 다양한 블로그와 다른 지원자들의 코드를 살펴보았고, 실제 사용 예시를 보며 추상 클래스와 인터페이스에 대해 이해했다. 다만 자바스크립트는 자바와 달리 abstract이나 interface 선언자가 없기 때문에, 설계자가 사용 용도를 잘 이해하고 인터페이스와 추상 클래스를 코드 자체로 나타내야 했다. 그래서 무분별하게 인터페이스 → 추상 클래스 → 자손 클래스 3단 구조를 설정하지 않고 사용 용도에 집중했다. 실제로 모든 클래스에 인터페이스나 추상 클래스를 적용하려고 하니 보기에도 헷갈리고 예상치 못한 오류를 발생시키는 경우가 있었다. 최종적으론 View Components에만 3단 구조를 적용하고, 나머지 Components는 일부만 적용하거나 아예 하지 않았다.

다음은 Jest였다. 3주 차와는 다르게 다양한 mock 함수들이 최종 테스트 코드에 추가돼 이 함수들이 어떻게 작동되는지 이해해야 했다. 공식 문서를 먼저 읽어봤지만, 아직 공식 문서가 어려워서 하나하나 console.log() 하며 함수들이 어떻게 작동되는지 이해했다. 다행히 함수명에서 대략 어떤 결과가 return 되는지 예상되어 어렵지 않게 이해하고 단위 테스트 코드를 작성할 수 있었다. 완성된 테스트 코드가 제공되는 것이 얼마나 큰 도움이 되는지 프리코스 기간 동안 체감할 수 있었다. 완성된 테스트 코드는 무조건 정답이어야 해서 이 테스트의 출력값과 흐름을 따라가면서 mock 함수들의 기능과 출력값을 이해할 수 있었다.

가장 아쉬운 것은 마지막 날인 오늘이 되어서야 요구사항을 제대로 이해한 것이다. ‘NodeJS 14버전에서 실행 가능해야 한다’는 요구사항을 최종 테스트 코드에서 app.play()하여 테스트를 진행하기 때문에, 나는 ‘App Class를 실행시키지 않아도 되는구나. 이 요구사항은 node 버전을 주의하라는 뜻이군’ 이라고 이해하여 app.play() 메서드를 실행시키지 않고 제출했다. 이번 과제에 있는 ‘예외 처리 시 그 부분부터 다시 입력을 받는다.’ 를 디버깅하기 위해 Node에서 직접 실행시켰을 때 앞선 요구사항의 참뜻을 이해할 수 있었다. 너무 아쉽다.

한 달 동안 열심히 프리코스를 달려오면서 가장 많이 배운 것은 ‘코드도 결국 사람이 읽고 쓰는 것’이라는 것이다. 사람이 보고 이해하기 쉽게 코드를 작성해야 하고, 그게 좋은 코드라는 걸 직접 과제를 진행해가면서 마음에 와닿게 하는 것이 프리코스의 면모였다. 어떻게 하면 읽기 편할까 생각하다 보니 변수와 메서드 네이밍에 신경 쓰게 되고, 큰 메서드를 분리하게 되고, 클래스를 분리하게 되고, 객체지향 프로그래밍에 대해 더 공부하게 됬다. 앞으로도 계속 프로그래밍 학습을 할 텐데, 프리코스에서 성장한 것들을 잊지 말고 꾸준히 달려나가야겠다.

0개의 댓글