알 수 없는 불편함이 느껴지는 영화.
곡성
을 보았는가?
그렇다면, 수 년이 지난 언젠가.
유튜브 알고리즘에 올라온 영화의 해석을 본 적도 있을 것이다.
우리가 당연하게 생각하며 보았던, 영화의 모든 장면들은.
사실 굉장히 이질적이고 기괴한 것들이었다.
우리가 영화를 보며 느꼈던 모종의 이질감과 위화감은 우리의 본능이 보낸 신호가 아니었을까.
생각을 정리하기도 전, 많은 정보가 주어진다면.
우리는 의문은 갖지 못한다.
다만, 우리의 본능은 위화감이라는 신호를 계속해서 보낸다.
그것이 불편함이다.
스스로 작성한 코드를 곱씹다, 느낀 그 소름돋는 위화감을 함께 살펴보자.
코드는 간단한 야구게임을 구현하는 예제이다.
각 객체의 역할을 확인해보자.
// Model/Player.js
import { validateBaseballNumber } from '../utils/validation.js';
export class Player {
#number;
constructor(number) {
this.validation(number);
this.#number = number;
}
validation(number) {
validateBaseballNumber(number);
}
get _number() {
return this.#number;
}
}
// Model/Computer.js
import { Player } from './Player.js';
export class Computer extends Player {
compareNumber(userNumber) {
this.validation(userNumber);
return this.#checkResult(this._number, userNumber);
}
#checkResult(answer, userNumber) {
const defaultValue = { strike: 0, ball: 0 };
return answer.reduce((acc, current, index) => {
if (current === userNumber[index]) {
acc.strike += 1;
return acc;
}
if (userNumber.includes(current)) acc.ball += 1;
return acc;
}, defaultValue);
}
}
async #guessNumber() {
const userNumber = await this.#view.readUserNumber(); // 지점1
return this.#game.compareNumber(userNumber); // 지점2
}
유저에게 입력을 받아 이를 Model에서 비교하는 로직으로
구현 당시에는 큰 문제를 느끼지 못한 코드이다.
지점 1 : View에서 입력받은 숫자에 대한 검증을 진행한다.
지점 2 : Player를 상속받은 Computer가 메서드를 이용해 정답을 확인한다.
해당 메서드에서 다시 한 번 Model에서 숫자에 대한 검증을 진행하며, 탄탄한 안전성을 확보할 수 있었다.
문득, 보기엔 큰 문제가 없는 코드같다. 코드 작성당시 나 또한 나쁘지 않은 코드라 생각하며 어느정도 만족했다.
다만, 동일한 검증을 두 번 한다는 부분에서 알 수 없는.
그러나 무시하고 넘어가고싶도록 사소한 위화감이 신호를 보내기 시작했다.
원래의 코드이다.
export class Player {
#numberList;
constructor(numberList) {
this.validation(numberList);
this.#numberList = numberList;
}
validation(numberList) {
if (numberList.length !== BASEBALL_NUMBER.DIGIT)
throw new CustomError(MESSAGE.ERROR.INVALID_DIGITS);
if (new Set(numberList).size !== numberList.length)
throw new CustomError(MESSAGE.ERROR.DUPLICATE_NUMBERS);
if (!numberList.every(Number))
throw new CustomError(MESSAGE.ERROR.INVALID_TYPE);
if (!numberList.every(isBaseballNumber))
throw new CustomError(MESSAGE.ERROR.OUT_OF_RANGE);
}
get _number() {
return this.#number;
}
}
Player를 상속받는 Computer가 해당 validation을 사용하려면 해당 필드를 public field로 공개해야만 사용이 가능했다.
사실 이러한 것들을 변경하는 데, 당장 큰 cost가 들지는 않는다.
다만, 계속해서 알 수 없는 위화감과 불편한 기분이 드는 것은 사실이다.
이런 감정을 못본채 하며, 내가 한 선택은 다음과 같았다.
import { validateBaseballNumber } from '../utils/validation.js';
export class Player {
#number;
constructor(number) {
this.validation(number);
this.#number = number;
}
validation(number) {
validateBaseballNumber(number);
}
get _number() {
return this.#number;
}
}
- 해당 메서드를 public field로 전환하였다.
validation 로직을 Util함수로 분리
하였다.
그 당시에는 모르고 지나쳤지만, 지금에선 소름이 돋는 부분이다.
문제의 본질이 나타났다.
왜 야구 번호에 대한 validation 로직을 Util함수로 분리를 해야했는가?
위에서 언급한 "이중검증"과 같은 맥락이다.
현재 내 코드는 다음과 같은 문제를 가지고 있었다.
export class Player {
#number;
constructor(number) {
this.validation(number);
this.#number = number;
}
validation(number) {
validateBaseballNumber(number);
}
get _number() {
return this.#number;
}
}
아니다. Player는 BaseballNumber이다.
Computer은 Player. 아니 BaseballNumber를 상속받는다.
당연히 잘못됐다.
BaseballNumber가 번호 비교를한다?
복권 샀는데, 복권이 구매자에게 당첨여부를 알려주는 경우가 있던가?
number에 대한 검증 로직이 private에서 public으로 바뀐 이유는 Computer가 BaseballNumber가 되려고 하기 때문이다.
문제점의 본질이 나타났다.
이를 빠르게 해결해보자.
문제를 파악했으니 해결은 간단하다.
- Player를 BaseballNumber 객체로 바꾼다.
- validation 유틸함수는 다시 private 필드로 캡슐화한다.
- Computer는 BaseballNumber를 상속받는 것이 아니라 number Field로 사용한다.
- 유저가 정답을 입력할 때, BaseballNumber 객체의 인스턴스로 생성한다.
import { BaseballNumberError } from '../Model/Error.js';
import { BASEBALL_NUMBER, TYPE, NUMBER } from '../constants/baseballNumber.js';
import { ERROR } from '../constants/error.js';
import { isBaseballNumber } from '../utils/baseballNumberUtils.js';
export class BaseballNumber {
#numberList;
constructor(numberList) {
// 데이터 정규화
const normalizedInput = this.#normalizeInput(numberList);
// 검증
this.#validation(normalizedInput);
// 할당
this.#numberList = normalizedInput.map(Number);
}
#normalizeInput(input) {
//...codes
}
// 검증 로직을 다시 private화
#validation(numberList) {
if (numberList.length !== BASEBALL_NUMBER.DIGIT)
throw new BaseballNumberError(ERROR.MESSAGE.INVALID_DIGITS);
if (new Set(numberList).size !== numberList.length)
throw new BaseballNumberError(ERROR.MESSAGE.DUPLICATE_NUMBERS);
if (numberList.includes(NUMBER.ZERO))
throw new BaseballNumberError(ERROR.MESSAGE.OUT_OF_RANGE);
if (!numberList.every((value) => !isNaN(value)))
throw new BaseballNumberError(ERROR.MESSAGE.NOT_A_NUMBER);
if (!numberList.every(isBaseballNumber))
throw new BaseballNumberError(ERROR.MESSAGE.OUT_OF_RANGE);
}
get _numberList() {
return this.#numberList;
}
}
외부에 넓게 퍼져있던 validation을 다시 캡슐화하였다.
또한, 어느정도의 유연성을 제공하기 위해, 생성자에서 입력받는 answer는 '123', 123, [1, 2, 3] 등의 타입을 지원한다. 물론, 안정성을 위해 validation도 철저히!
import { SCORE } from '../constants/baseballGame.js';
import { NUMBER } from '../constants/baseballNumber.js';
import { BaseballNumber } from './BaseballNumber.js';
export class Computer {
#answerList;
constructor(number) {
this.#answerList = new BaseballNumber(number)._numberList;
}
compareNumber(userNumber) {
const userNumberList = new BaseballNumber(userNumber)._numberList;
return this.#checkResult(this.#answerList, userNumberList);
}
#checkResult(answer, userNumber) {
const defaultScore = { strike: NUMBER.ZERO, ball: NUMBER.ZERO };
return answer.reduce((score, current, index) => {
if (current === userNumber[index]) {
score.strike += SCORE.UNIT;
return score;
}
if (userNumber.includes(current)) score.ball += SCORE.UNIT;
return score;
}, defaultScore);
}
}
컴퓨터는 BaseballNumber를 field로 사용한다.
Computer가 BaseballNumber의 상위 Layer가 됨으로써 더 이상 BaseballNumber의 Validaiton 로직을 public으로 둘 필요가 없어졌다.
또한, 다른 사용처에서 BaseballNumber에 대한 검증이 필요한 경우 숫자를 생성자에 전달하여 인스턴스를 생성하면 모든 문제가 해결된다.
코드를 짤 때, 꾸준히 의심하자.
의심만으로는 보이지 않을 때,
휴식시간을 가짐으로써 나를 코드로부터 분리하자.
객체들의 관계가 정리되며 객관적인 시야가 생긴다.