
멘토님의 제안으로 데브코스 4기를 수료한 인원과 함께 클린 코드 스터디를 진행하면서 얻은 인사이트를 정리했다.
1~2주간은 바닐라 자바스크립트 간단한 과제를 만들면서 스터디원끼리 코드 리뷰를 진행했다. React만 사용하다가 오랜만에 바닐라 자바스크립트로, 그것도 클래스를 기반으로 코드를 작성하다 보니 코드를 적절히 분리하는 게 어려웠다. 규모가 크지 않은 프로젝트라 MVC 패턴으로 폴더 구조를 관리했는데 처음엔 Model-View-Controller의 관계를 제대로 이해하지 못하고 있다가 차츰 Model에서 View를 분리하면서 도메인 로직을 View에서 분리할 수 있게 되었다. 우연찮게도 대부분의 스터디원들이 MVC 패턴을 사용하면서 도메인 로직을 분리할 때의 인사이트를 얻을 수 있었고, 다들 이 부분을 1~2주차의 핵심 목표로 잡은 듯 했다. 스터디를 진행하며 처음부터 다른 사람의 코드를 보며 아이디어를 베끼기(?)보다는 시행착오를 거치며 얻는 인사이트였기에 더 의미가 있고 기억에 잘 남았다고 생각한다.
아래는 1~2주 바닐라 자바스크립트로 과제를 구현하면서 얻은 인사이트를 정리해보았다.
처음에 나는 유효성 검사를 if문을 사용해서 관리했다. 그게 Model이든 View이든 간에...
setValue(value) {
if (typeof value !== 'string') {
throw new Error('type Error');
}
if (value.trim().length === 0) {
throw new Error('length Error');
}
this.value = value;
}
이런 식으로 유효성 검사를 했을 때 다음과 같은 단점이 있었다.
- 중복 로직이 많아진다.
- 함수의 길이가 길어진다.
- 하나의 유효성 검사 로직이 다른 유효성 검사 로직과 연관되며 특정 함수에 종속된다.
그러다 스터디원이 createValidator 를 만든 걸 보고 나도 나만의 createValidator를 만들고자 했다.
createValidator 에 어떤 도메인과 관련된 validate 객체를 넣어주면 validate 객체 내부에 있는 키값을 모조리 돌면서 검사하는 방식이다.
validate 객체 내부에서 검사하고자 하는 key값과 value 값을 넘겨줌으로써 사용할 수 있다.
1. 사용방법
this.validator = createValidator(userValidator);
setUserName(userName) {
this.validator({
'userName': userName
});
}
2. validate 구성요소
userValidate 는 다음과 같은 형식으로 구성된다. userName이라는 키값을 검사하면 그 아래에 있는 type과 length에 대해 검사할 수 있다.
const userValidator = {
userName: {
type: (value) => typeof value === 'string' || 'TypeError',
length: (value) => value.length > 0 || 'LengthError'
}
}
유저라는 도메인에는 userName 외에도 Gender, Age 등 여러 요소가 들어가고, 키를 통해 접근하면 될 수 있어 확장하기도 편하고, 중첩 객체를 넣어줄 수도 있다.
3. createValidator
createValidator는 초기화 시 넘겨준 validate 를 기억해놓은 채로, validator 함수만을 반환한다.
validator 함수는 상위에서 넘겨준 객체를 순회하면서 key와 value 값을 validateKeyValue로 넘겨주는 역할을 한다.
const createValidator = (validation) => {
const validateKeyValue = (keyType, value) => {
// key와 value에 대해 검사한다
};
// 클로저 방식으로 validator 가 반환된다
return (values) => {
Object.entries(values).map(([keyType, value]) => {
if (validation[keyType] === undefined) {
throw new Error(
`${keyType} 가 초기화 시 전달한 validation 내부에 존재하지 않습니다.`,
);
}
// key와 value에 대한 자세한 검사는 validateKeyValue에 위임한다.
validateKeyValue(keyType, value);
});
};
};
validateKeyValue 함수는 keyType 내부에 있는 함수들을 모두 순회한다음 string 타입이 반환되면 에러를 발생시킨다. 이를 통해 상위에서 if 문을 작성해서 validate 타입이 true인지 false인지 검사하는 로직을 작성할 필요 없이 에러 생성 부분을 내부로 숨길 수 있다.
const validateKeyValue = (keyType, value) => {
Object.keys(validation[keyType]).forEach((validationKey) => {
const validate = validation[keyType][validationKey](value);
if (validate !== true) {
throw new Error(validate);
}
});
};
const createValidator = (validation) => {
const validateKeyValue = (keyType, value) => {
Object.keys(validation[keyType]).forEach((validationKey) => {
const validate = validation[keyType][validationKey](value);
if (validate !== true) {
throw new Error(validate);
}
});
};
return (values) => {
Object.entries(values).map(([keyType, value]) => {
if (validation[keyType] === undefined) {
throw new Error(
`${keyType} 가 초기화 시 전달한 validation 내부에 존재하지 않습니다.`,
);
}
validateKeyValue(keyType, value);
});
};
};
export default createValidator;
이렇게 createValidator 를 만듦으로써 얻은 효과는 다음과 같다.
- 유효성 검사 로직 테스트시 다른 유효성 검사에 의존하지 않는다.
- error 발생을 createValidator 내부에 숨김으로써 외부에서 일일이 코드를 반복작성할 필요가 없다.
- 자세한 기능을 숨기면서도 유효성 검사를 진행한다는 사실을 상위 레이어에서 확인할 수 있다.
위에서 사용한 createValidator를 사용하면서 View에서 들어오는 입력값 검사를 할 때도 유용한 부분이 있었다.
올바르지 않은 Input값이 들어오게 되면 에러메세지를 보여주고 처음부터 입력을 다시 받아야 한다. 브라우저 화면은 에러만 보여주면 사용자가 다시 입력받는 부분을 신경쓰지 않아도 됐지만 콘솔로 입출력을 받다보니 구현 전에 생각할 부분이 많았다.
나는 처음에 main 파일 내부에서 반복문을 처리해주었다.
do {
const input = await inputName();
} while(validate({inputName: input}));
하지만 유효성 검사를 if 문으로 매번 검사하는 것과 마찬가지로 while문을 매번 작성해야한다는 불편함, 에러 메세지를 출력하기 어렵다는 불편함이 있었다.
이 문제를 해결하기 위해 while 과 관련된 처리를 숨겨줄 필요가 있었다. 그래서 콘솔 입력을 받고 출력하는 View 클래스와 별개로, Ouput과 Input 클래스를 따로 만들어 View 폴더에서 관리했다.

원래 View 클래스에서 readline을 호출했지만 그 역할을 Input 클래스가 담당하게 되었다. View 클래스에선 input 클래스의 메서드를 호출해서 그대로 반환하는 역할만을 담당하게 되었다.
// View
constructor() {
this.inputView = new Input();
}
inputName() {
return this.inputView.input('이름을 입력해주세요');
}
1. Input의 input 메서드
해당 메서드에 while의 자세한 구현을 숨긴다. Input의 input은 에러가 발생하지 않을 때까지 콘솔로 입력을 받아야 한다.
async input(message) {
let value = '';
let isValid = undefined;
do {
value = await this.#rl(message); // readline 함수
isValid = validate();
} while(typeof isValid === 'string');
return value;
}
여기서 validate에 들어갈 값은 매번 바뀌게 된다. 이름과 나이, 성별 등등 각각의 입력값에 대해 검사하는 타입이 다를 것이다. 이를 위해 외부에서 에러 검사를 받아오도록 한다.
input(message, {validate}) {
let value = '';
do {
value = await this.#rl(message);
validate && valdiate(value);
} while (typeof valiate(value) === 'string')
return value;
}
validate 는 최상단에서 다음과 같이 전달하게 된다.
// 최상단
const inputValidator = createValidator(inputValidate);
const name = await inputName({
validate: (value) => inputValidator({ name: value })
})
그리고 View가 message와 함계 validate가 든 options 객체를 inputView로 전달하면 된다.
// View
async inputName(options) {
return this.inputView.input('이름을 입력해주세요', options);
}
2. while을 try-catch로 변경하기
createValidator를 사용해서 validate 판별을 하게 되면 에러가 발생할 경우 Error객체를 던진다. 이 때문에 while은 Error를 처리하기 적합하지 않다.
그대신 try-catch를 사용하여 Error를 처리하면 더 간단하게 처리할 수 있다.
async input(
message,
{ validate },
) {
try {
const value = await this.#rl(message);
validate && validate(value);
return value;
} catch (error) {
console.log(error);
return this.readInput(message, {validate});
}
}
3. 후처리하기
console로 받아오는 값들은 모두 문자열이기 때문에 배열로 변경하고 싶거나 숫자로 변경하고 싶은 경우 후처리를 해줘야 한다.
InputView에 이미 validate를 객체 안에 넣어 전달하고 있으므로 후처리함수를 함께 넣어줄 수도 있다.
// 최상단
const inputValidator = createValidator(inputValidate);
const name = await inputName({
validate: (value) => inputValidator({ name: value }),
postProcessFn: (value) => value.trim(),
})
이렇게 최상단에서 postProcessFn 가 값을 변경하도록 하면 Input 클래스가 postProcessFn을 거친 값을 validate 에 넣어줄 수 있다.
// Input 클래스
try {
const { postProcessFn, validate } = options;
this.validator({ message, options, postProcessFn, validate });
const input = await this.#rl(message);
const postInput = postProcessFn ? postProcessFn(input) : input;
validate && validate(postInput);
return postInput;
} catch (error) {
console.log(error);
return this.input(message, options);
}
클래스형으로 구현을 하면서 super 키워드로 상속을 할 때 어떤 일이 발생하는지 공부하게 되었다.
예를 들어 Animal이라는 부모 클래스가 있고 Cat 클래스가 Animal 클래스를 상속받는다고 하자.
class Animal {
constructor(name) {
this.name = name;
setInit();
}
setInit() {
console.log('I am an Animal');
}
}
class Cat extends Animal {
costructor(name) {
super(name);
}
setInit() {
console.log('I am a cat');
}
}
그럼 이때 Cat 클래스는 Animal 내부에 있는 메서드들을 사용하기 위해 super()를 호출한다.
이 때 일어나는 동작은 다음과 같다.
- Animal의 constructor가 실행된다.
- Animal의 constructor가 setInit을 발견한다.
- Cat에서 setInit은 오버라이딩 되어있으므로 Cat의 setInit을 호출한다.
따라서 Cat에서 super를 호출하게 되면 I am a cat이라는 문자열이 콘솔에 나오게 된다. 이 부분의 동작이 잘 이해가가지 않아서 매번 헷갈려 했는데 이번에 개념을 확실히 잡게 되었다.
이전에 한 차례 포스트에서 다뤘던 주제였는데 과제를 하면서 다시 접했다. 다시 공부를 하면서 도메인 로직은 서비스가 제공하는 도메인과 관련된 로직을 의미하고 어플리케이션은 도메인 로직을 제외한 다른 로직을 의미한다는 것으로 이해했다.
이번 과제에선 도메인 로직은 어플리케이션이나 뷰 로직의 존재를 알지 않고서도 독자적으로 존재한다는 피드백을 얻었다.
즉 Car 라는 도메인 로직이 있는데 여기서 입, 출력에 관련한 (웹이라면 렌더링) 로직을 몰라야 한다는 의미였다.
class Car {
move() {
console.log('이동해요~'); // 즉 이러면 안된다!
}
}
이 부분에 대해서도 멘토님께서 '지금은 콘솔을 사용하여 입출력을 받고 자바스크립트 클래스만으로 개발하는 로직을 React 를 사용해 웹에서 보여주는 로직이라면 과연 어떻게 했을까'에 대해 생각해보라고 하셨다.
도메인 로직에서 뷰 로직을 끼워넣기는 해야한다. 예를 들어 자동차가 이동할 때마다 지금의 거리가 얼마인지 사용자에게 보여줘야 한다면 move 메서드를 호출할 때마다 콘솔에 입력을 해야한다.
이 부분을 어떻게 할지 고민하다가 React 컴포넌트에서 onClick, onBlur 등으로 함수로 전달한 것이 생각났다. 이 방법은 on~ 내부에 메서드를 숨김으로써 도메인 로직은 단순히 메서드를 실행시키기만 하면 되기 때문에 뷰의 존재를 숨기면서도 뷰와 관련된 함수를 호출할 수 있는 방법이라고 생각했다.
class Car {
move(onMove) {
onMove();
}
}
추상화의 개념을 제대로 접할 수 있었던 사례라고 생각하며, 이론을 직접 응용해보니 신기하기도하고 재미있기도 했다. 더불어 React에서 아무 생각 없이 on 키워드를 사용해서 컴포넌트를 설계했는데 비로소 그 의미를 알 수 있게 된 의미있는 고민이었다!
이번 클린 코드 스터디에선 테스트 프레임워크인 Vitest를 사용해서 테스트 코드를 작성했다. 이전에 우테코 프리코스에 참여하며 4주간 Jest 프레임워크를 공부한 적이 있는데 문서도 별로 없고 메서드도 어려워서 테스트에 대한 막연한 두려움이 있었다. (코드를 더럽게 작성해서 테스트가 어려웠을지도 모른다.)
개인적으로 사용해본 Vitest는 더 가볍고 사용하기 편리하다는 느낌이 있었다. 그래서 부담없이 테스트 코드를 작성했고, 테스트에 대한 두려움을 덜 수 있었다. 테스트 코드를 작성하고 TDD 설계를 해보며 나름대로 얻은 장점은 다음과 같다.
1. 프로젝트 안정화
코드를 작성하는 도중엔 내가 아무리 유효성 검사를 열심히 작성했다고 해도 빠진 부분을 체크하기 어렵다. 이렇게 빠진 부분을 테스트 코드를 작성하며 발견하게 된다는 게 내가 찾은 첫번째 장점이었다.
처음엔 매개변수가 들어올 경우 당연히 특정 타입일거라 생각하고 내용을 작성하는 실수를 많이 저질렀다. 그래서 string 타입이라고 생각했는데 string 타입이 아니어서 관련 메서드를 사용할 수 없어 나는 에러를 여럿 마주했다.
테스트 코드는 여러 케이스를 넣어보면서 내가 당연하게 생각했던 실수를 바로 잡아줬다. 이번 과제는 완성된 프로덕트가 아니고 리팩토링을 한 번 할 때마다 폴더 구조를 뒤엎는 바람에 유지보수 측면에서의 장점을 알 수 없었지만 이미 완성된 프로덕트를 유지보수하고, 거기서 함수 하나의 동작을 변경하는 정도라면 테스트 코드의 의미가 더 커질 것이라 생각한다.
2. 함수 역할 분리
나는 유닛 테스트를 위주로 테스트 코드를 작성했다. 그리고 TDD를 기반으로 테스트 코드에 기반해 함수를 작성하며, 함수 하나가 하나의 역할만을 담당하는 데 도움이 되었다.
그 예시로 원래는 setValue와 같은 메서드에서 들어온 value를 적절한 포맷으로 변경해주는 로직을 함께 넣어놓는 코드 습관이 있었다.
setValue(value) {
// value 후처리하기
this.value = value;
}
하지만 TDD 기반의 설계를 하다 보니 setValue는 새 값을 균일한 타입으로 변경한 뒤 현재 value의 값을 변경한다...? 라는 문장이 어색하게 보였다.
그래서 setValue에서 후처리를 해주는 부분을 함수로 따로 분리하고, 해당 부분은 따로 테스트를 작성해주었다.
setValue(value) {
const normalizedValue = normalize(value);
this.value = normalizeValue;
}
// test
test('normalize는 파라미터를($value)모두 1차원 배열로 만든다.', (value) => {
const normalizedValue = nomalize(value);
expect(value).toBeArray(normalizedValue);
})
test('setValue는 value의 값을 변경한다.', () => {
// ...
})
이렇게 TDD를 기반으로 구현하면서 함수가 하나의 역할만을 담당하는 단일 책임 원칙을 최대한 지킬 수 있었다.
1. 시간이 많이 소요된다.
테스트 코드를 작성하면 프로젝트의 에러를 방지하는 측면에선 좋은 점 밖에 없다고 생각은 하지만 초반에 테스트 코드를 작성하며 거의 구현만큼의 노력을 기울이게 되었다.
테스트 코드를 작성하는 일이 절대 가볍지 않고, 코드를 작성하면서 리팩토링도 겸사겸사 진행하기 때문이었다. 만약 일정이 촉박한 프로젝트를 한다면 테스트를 필수적으로 작성해야 한다는 의견에 대해 의의를 제기해볼 것 같다.
하지만 이 부분도 최근 AI의 발전으로 많이 커버할 수 있는 부분이라고 생각하기도 한다. 반복되는 코드를 작성하는 게 가장 불편했는데 AI가 작성해준 테스트 코드가 은근 쓸만했다. 물론 놓치는 부분이 없게 신경써야 하지만...
2. 러닝 커브
describe, test, expect 등 기본 메서드는 누가봐도 이렇게 사용하는거구나~, 할 정도로 직관적이다. 하지만 아직 mocking과 spy등의 심화 개념은 학습하지 못했다. 함수가 실행되었는지, 몇번 호출되었는지 등등의 간단한 코드는 작성할 수 있지만 함수 내부의 내용을 바꿔치기 한다거나, 테스트 코드에서 의존성을 주입해야하는 부분에선 꽤 러닝 커브가 있다고 생각한다.
물론 이건 어느 정도 내가 모킹을 편법을 사용해서 구현했기 때문에 내가 학습을 해야할 부분이기도 하다. 그래도 describe, expect 처럼 선녀같은 친구들을 보다 보면 어려운 개념이다...
위에서 살짝 언급했지만 normalize 함수를 만든 이유는 바로 다형성을 처리하기 위해서다. 멘토님께서 피드백해주신 부분이었는데 다형성 개념을 적용해서 사용하는 쪽에서 편리하게 어플리케이션을 만들 수 있다.
예를 들어 숫자로 이뤄진 배열을 state로 가진다고 생각해보자.
this.state = [1, 2, 3];
위와 같이 숫자로만 값이 들어오면 편하겠지만 프론트엔드에선 (하물며 코드에서도) 문자열로 값이 들어오는 경우가 허다하다. 그러니 다음과 같이 전달하는 경우도 있을 것이다.
setState(1, 2, 3);
setState('1', '2', '3');
setState('1, 2, 3');
setState(['1', '2', '3']);
하나의 객체가 처음 들어올 땐 여러 타입을 가질 수 있지만 내부에서 normalize를 통해 [1, 2, 3] 으로 변경해 사용한다면 더 편하게 코드를 사용할 수 있을 것이다.
내가 작성한 normalize 함수는 다음과 같다.
static normalize(...value) {
// spread 연산자 때문에 [[1, 2, 3]] 으로 들어오는 경우
if (Array.isArray(value[0])) {
value = value.flat();
}
// ['1, 2, 3'] 처럼 하나의 문자열로 들어오는 경우
if (value.length === 1) {
value = value[0].split(',').map((number) => number.trim());
}
return [...value];
}
이건 스터디원이 이런 방법도 있군요! 라고 말해서 별 것 아니지만 추가했다. test 코드에서 매번 프로퍼티를 전달하는 건 힘들어서 미리 제공된 값을 사용할 수 있었으면 하고, 어느 정도는 내가 원하는 값으로 변경할 수 있었으면 해서 다음과 같이 코드를 작성했다.
function makeUser(userProperty) {
const user = new User({
name: 'suyeon',
gender: 'w',
email: 'asdf@asdf.com',
phone: '1234-4321'
...userProperty,
});
return user;
}
실제로 이런 방식을 사용하는진 모르겠지만 반복코드를 줄일 수 있다는 데선 꽤 유용하다고 생각한다!