TDD, Cleancode with JavaScript 카테고리의 4번째 글이다.
지난 글에서, 자동차 경주 게임이라는 과제를 진행중이라고 작성했는데 어제 1차 과제 PR을 올리고 리뷰를 받을 수 있었다.
개인적으로 어떤 태스크를 진행할 때 이렇게 글로 정리하고, 기록을 남기면서 했던 경험이 좋았어서, 이번 리팩토링 과정을 이렇게 기록해보려고 한다. (셀프 대화..?) 내 사고의 흐름을 추적할 수도 있고, 어디서부터 문제가 시작되었는지 알 수 있다는 부분이 좋다. 그리고 좀 더 집중도 되고, 나중에 아.. 이랬었구나 기억할 수도 있고, 블로그에 적는거니 나름 구조화도 시킬 수 있고.. 주절주절
사이드 이펙트 관리
이번 과제에서 내가 가장 헤맸던 부분은 1. 구현을 어떻게 해야하는가 2. 어떤 범위로 테스트를 해야하는가 였다.
요구사항 자체는 어렵지 않은데, 이걸 TDD 스럽게, 잘게 쪼개가면서 코드를 작성해야 한다고 생각하니 T.T 그 부분이 가장 어려웠다.
이런 고민을 PR 에 담아 질문을 드렸고, 아래와 같이 답변을 받을 수 있었다.
Q1. 요구사항에 대한 설계는 경험의 영역인가?
- 경험이 조금 필요하다.
- 경험을 떠나서는 '사이드 이펙트 관리'에 초점을 맞출 수 있다.
이번에 관심사 분리, 가독성, 테스트 용이성을 위해 MVC 패턴을 도입했다. 처음부터 바로 이 패턴을 도입해 객체지향적으로 설계해야겠다는 생각이 들지 않아 한참을 헤맸는데, 그렇다면 다른 사람들은 어떻게 처음부터 그런 방법을 떠올릴 수 있느냐? 가 궁금해서 드린 질문이었다. ㅋㅋ 경험 + 기본 지식이 당연한거긴 한데... 그 외에 '사이드 이펙트 관리' 라는 부분에도 포커스를 맞출 수 있다는게 다시 깨닫게 된 인사이트였다. 결국 외부 의존이 어떻게 변경될 것인지 모르기 때문에 그 의존을 최소화하고, 예측이 어려운 사이드이펙트를 방지한다는 생각으로 설계를 해나가면 도움이 될 것이라는게 답변의 요지.
Q2. TC 를 작성할 때 유기적인 동작을 위주로? 아니면 각 함수가 제대로 동작하는지?
- e2e 테스트가 아닌 이상 각 함수의 동작을 테스트하는 단위 테스트를 기반으로.
- 또한 모든 함수, 메소드가 사이드 이펙트가 없도록 설계하는 것보단, 사이드 이펙트가 있는 것들만 분리해서 관리한다고 보면 좋다.
이건 전에 함수형 코딩 책을 읽었을 때에도 나왔던 말인데, 프로그램의 주요 기능이 결국 사이드 이펙트 발생이기 때문에 사이드 이펙트 없이 프로그램을 만들 수는 없다. 어쨌든 외부와 통신을 하며 외부의 상태값을 바꾸는 것도 사이드 이펙트니까..
결론적으로는 사이드 이펙트를 어떻게 관리할 것인가에 초점을 맞추면 프로그램 설계에 도움이 된다는 내용의 답변이었다.
코드 리뷰 살펴보기
1. 상수화
- 객체 내에서관리하는 상수는 객체 안에서 선언해주자.
- 요구사항에 따라 변화할 요지가 있는 값에 대해 상수화를 하자
2. 유틸보다는 도메인 같은 유틸이 있음
- 특정 도메인에서만 사용되는 유틸 - 도메인에 속해야 할 메서드가 유틸 함수로 분리되어 있음.
- 이름을 테스트하는 validate 함수가 특정한 위치에서만 호출이 되기 때문에 이를 유틸이라고 볼 수 있을까? 의문
3. 일부 Model 이 View 의 역할을 수행한다.
- 게임 실행 결과를 출력하는 부분이 Model 에 속해있다.
- 우승자를 출력하는 부분도 마찬가지.
- 각 모델에 해당하는 값(state) 만을 리턴하고, 값을 정제하는 부분은 바깥쪽 - 이를 가져다 쓰는 view 쪽에서 수행하도록!
4. 예측 불가능한 테스트가 있다.
- 어떤 값을 입력했을 때, 예측되지 않는 값이 나올 거라는 예상으로 테스트를 짠 구간이 있다.
- 이 때는 예측을 할 수 있도록 테스트코드와 코드를 조금 수정해주는게 좋을 것 같다.
- 예측 불가능한 테스트를 예측 가능한 테스트로 만들려면, 특정한 input 에 대해 언제나 특정한 output 을 출력하도록 해야한다.
- 이렇게 만드려면, 사이드 이펙트를 발생하지 않는 방향으로 메소드를 수정하는게 좋을 것 같다.
자잘한 리뷰를 제외하면 크게 위와 같다.
---
위 리뷰를 보고 특히 3번, 4번에서 책임이 불분명하게 나눠져있다는 생각을 하게 되어서 객체 설계부터 다시 진행했다.
게임의 흐름과 요구사항을 확인하며 어떤 역할이 필요한지, 그 역할을 담당하는 객체는 무엇이 있을지 위와 같이 도출했다.
나는 경주 게임에 필요한 데이터를 저장하는 객체를 크게 4가지로 구분했고, 게임을 담당하는 모델과 뷰와 모델사이에서 각 데이터를 전달하는 역할을 하는 컨트롤러 객체를 만들었다.
각 객체와 역할은 아래와 같이 구분했다.
- [x] Car: 한 대의 자동차
- [x] Cars: 경주에 참여하는 자동차들
- [x] RaceRecords: 자동차 경주 기록
- [x] RaceWinners: 자동차 경주 우승자
- [x] Game: 자동차 경주 게임 모델
- [x] GameController: 자동차 경주 실행 담당
- [x] View : I/O 를 담당
다른 분들이 작성한 코드와 살짝 비교해봤는데, 나처럼 이렇게까지 세분화해서 역할을 나눈 분은 많지 않은 것 같다. 객체의 역할을 이렇게 나누고 코드를 작성하니 나 스스로는 전반적인 흐름도 더 이해하기 쉬웠고, 유효성 검사 같은 경우에도 캡슐화를 통해 각 객체에서 담당해야할 부분에 대해서만 진행하는 방향으로 작성할 수 있었다.
그런데 한편으로는 코드의 양이 너무 많아진 것 같고, 더 적은 코드로도 효율적으로 작성할 수 있을텐데... 라는 아쉬움이 든다. 사실 이번에 `객체의 소리 듣기` 라는 번외 세션을 진행하며 두 번째 미션인 로또 게임의 요구사항을 분석하며 객체와 관계를 설계해보는 시간을 가졌는데 그 때도 역할을 분리하고 분리하다보니 다른 누구보다 많은 객체를 만들게 되었다. 도대체 역할과 책임을 어디까지 분리해야하는가? 에 대한 고민이 든다.
객체 하나에 대한 예시 코드를 가져와 보겠다. (코드가 좀 기니까 주의하시길.........)
좀 부끄럽긴 한데.. 혹시 이 글을 읽는 분들 중 피드백을 주고 싶은 분이 있다면 언제든 환영입니다..
1.
아래 객체는 각 역할별로 분리된 모든 객체를 가져다가 게임을 실행시킬 수 있도록 만드는 Game 이라는 객체다.
처음 이 Game 객체를 만들 때, 유저로 부터 입력을 받은 자동차 이름과, 게임을 진행할 횟수를 인자로 받아오도록 한다.
그리고 여기서 이 round 를 가지고 게임을 진행하므로, 각 라운드에 해당하는 record 도 멤버변수로 가지고 있도록 한다.
export class Game {
static DEFAULT_ROUNDS = 5
static DEFAULT_CURRENT_ROUND = 1
static MOVE_THRESHOLD = 4
#cars
#entries
#rounds
#currentRound
#currentRecord
#records
#winners
constructor(carNames, rounds = Game.DEFAULT_ROUNDS) {
this.#validateCarName(carNames)
this.#cars = new Cars(carNames)
this.#entries = this.#cars.entries
this.#rounds = rounds
this.#currentRound = Game.DEFAULT_CURRENT_ROUND
this.#currentRecord = {}
this.#records = new RaceRecords()
}
...
}
위 코드를 작성하면서.. 좋았던 점은 이 객체 안에서 게임의 전반적인 흐름을 제어할 수 있다고 느껴지는 것? 자동차 객체나, 경주 기록, 우승자 목록은 알맞게 가공된 데이터를 받아와 역할에 맞는 동작을 수행하고 값을 주기 때문에 이 객체에서 다시 그 값을 받아와 전체 게임에 대한 기록을 갖게 된다.
문제는... 테스트하기 쉬우라고 여러 멤버변수를 만들고 이를 거의 몽땅 getter 로 내보내고 있는데 살짝 테스트를 위한 코드같다는 생각도 들고... 이에 대한 리뷰가 있을거라는 기대가 든다.
2.
실제로 게임을 실행시키는 로직이다. 여기서 만약 자동차가 앞으로 나아가는 조건이 변경될 수도 있으니 인자를 통해 함수를 받아오도록 한다. round 마다 게임을 진행하면서 자동차들을 이동시키고, 기록을 저장하고, 그리고 저장된 기록을 토대로 우승자를 선출한다.
run(moveCondition = this.#determineShouldMove) {
for (let round = this.#currentRound; round <= this.#rounds; round++) {
this.#currentRound = round
// 조건을 넘겨 자동차를 전진시킴
this.#cars.move(moveCondition)
// 각 라운드별 기록을 저장함
const record = this.#convertStatusToRecord(this.#cars.status)
this.#records.add(record)
}
// 모든 라운드 종료 후 우승자를 선정함
this.#setWinners(this.#records.records)
}
일단 이 코드에서 보완되어야 할 점은.. 지금 쓰면서 생각났는데 moveCondition 이 boolean을 리턴하는 함수임을 강제해야 한다는 점임. 외부에서는 여기에 전달해야하는 함수가 무엇인지 모르기 때문에 아무거나 전달했다가 프로그램이 망가지는 현상이 발생할 수도 있을 것 같다. 일단은 어떤 조건이 올지 미처 생각을 못해서 저대로 두었음... 으 어렵다.
3.
그리고 나머지 private methods.
각 객체에서의 유효성 검사는, 받아오는 값이 현재 객체 내에서 사용되기에 필요한 범위 만큼을 커버하도록 했다. 말이 좀 어려운데 그러니까 여기서 carNames 라는 값을 받아와서 Cars 객체에 넘겨 자동차들을 만들어 낸다면, 값이 존재하는지, 배열 형태인지, 그 배열안에 값은 올바른 타입인지 까지를 검사한다는 것. 그리고 그 이외에 중복된 값이라던지 유효하지 않은 자동차 이름 등에 대해서는 또 각 객체가 담당하는 역할이 있으니 그에 맞게 진행하도록 했다.
#validateCarName(carNames) {
if (
!carNames ||
!Array.isArray(carNames) ||
carNames.some((name) => !name || typeof name !== 'string')
) {
throw new Error(ERROR_MESSAGE.NAMES_TO_BE_VALID)
}
}
#determineShouldMove(limit = 9) {
const randomNumber = Math.ceil(Math.random() * limit)
return randomNumber > Game.MOVE_THRESHOLD
}
#convertStatusToRecord(statuses) {
const record = {}
statuses.forEach((status) => (record[status.name] = status.position))
return record
}
#setWinners(records) {
this.#winners = new RaceWinners(records).winners
}
이렇게 유효성 검사를 진행하면서... 캡슐화를 통해 각 객체에서 책임져야 하는 부분까지 커버하도록 만들었다고 했지만 과연 이 기준이 어디까지인지 정하는 것은 또 별개의 문제인 것 같다.
아무튼 또 리뷰가 달리면 그에 맞게 리팩터링을 하며 확장된 다음 스텝의 미션을 진행해야한다. 다음 리팩토링 글로 또 돌아오겠음... ㅋ
--- 여기서부턴 사담.(겸 푸념 ㅜ)
지금 이 과정을 시작한지 벌써 3주 정도 되었는데 과제 진도가 정말 더디다. 처음에 감을 잘못 잡아서 구현에 오랜 시간을 쏟기도 했고 또.. 회사 적응 + 매일 운동 등등... 사실 이거 다 변명이고 뭔가 더 진득하게 하고싶은데 몸이 잘 따라주지를 않는 것 같다. 다시는 오지 않을 시간과 기회이니 적극적으로 활용해야지 싶으면서도 여러가지 이유로 에너지를 100% 쏟지 못한다는 느낌을 받는다.
여러 이유가 있는데...
1. 더딘 코딩 속도
원래도 코드 짤 때 생각을 정말 많이 하고 짜는 편이라 스스로 속도가 느리다고 생각하는 편. 이전에는 하루종일 시간이 있으니 새벽까지 붙잡아도 되고 천천히 정리하면서 해도 되서 괜찮았는데, 요즘은 회사 일, 운동 등등 시간 분배를 잘 해야하다보니 한두시간안에 끝나지 않는 일이 많음.
2. 약간 멍청해진 것 같음
뭔가 이전처럼(?) 그러니까 한참 부트캠프를 다니고 스터디를 운영하고 할 때만큼 학습 관련된 머리가(?) 돌아가지 않는 것 같다. 뭔가 이건 내가 좀 멍청해진 것 같다는 표현이 가장 적합한듯... 예전엔 어떤 개념이나 이론에 대해서 좀 더 진득하게 파고들고 이건 왜그러지 저건 왜그러지 이런 궁금증도 들고 했는데, 지금은 그런 궁금증이 들어도 찾아보고 정리하는데 시간이 걸리니 대충 슥 읽고 아 이렇게 적용하면 되겠군 하고 적용했다가, 깊게 파지 않아서 다 까먹고 뭐 그거의 반복인 것 같다. 회사 일도 그렇고 개인 공부도 그런 것 같음...
3. 체력
2번이 되는 이유가 체력 때문인 것 같기도 하다. 진짜 개발하는게 일이 되니까 매일 동료 개발자의 코드를 읽고 리뷰하고, 회사 코드 읽고 생각하고 코드짜고 이거의 반복이니 여기서 에너지를 다 쏟고 좀 흐리멍텅해진게 아닐까 추측이 됨...
4. 실력에 대한 자신감 부족
다른 분들이 짠 코드를 보면 와 이런 디자인 패턴도 공부하셨구나, 이런 방식을 생각해내셨군! 하면서 감탄 투성이인데 나는 흐리멍텅 + 실력도 안되니까 아... 이거 어떻게 짜냐... 이러고 멍때리는 경우가 많이 있음. 과제 첫 일주일간 거의 그랬다 ㅠ ㅋㅋ
후... 이겨내고 정진해야지. 기술블로그인데 이런 사담을 막 써도 되는건지 모르겠다. ㅋ
'TDD, Cleancode with JavaScript' 카테고리의 다른 글
# 5 / 객체간의 결합도 / 객체를 객체답게? (2) | 2023.08.17 |
---|---|
# 3 / 일급 컬렉션이 머에영? (우적우적 🍿) (3) | 2023.08.01 |
# 2 / 어떤 과정으로 문제를 해결해 나갈 수 있을까. (4) | 2023.07.27 |
# 1 / OT. TDD 란 무엇인가. (0) | 2023.07.19 |