본 글은 우아한테크코스 프리코스 1주차 과제 기반의 내용입니다.
PR : https://github.com/guesung/javascript-calculator-7/tree/guesung
들어가며
JavaScript는 객체 지향과 함수형을 모두 쓸 수 있는 언어라, 상황에 맞게 방식을 골라 쓸 수 있습니다.
JavaScript에서 함수는 일급 객체입니다. 변수에 담고, 다른 함수의 인자로 넘기고, 반환할 수도 있어서 고차 함수를 만들 수 있습니다. 이를 활용하여 DOM의 이벤트 처리, 배열 메서드의 콜백 함수, 클로저를 통한 상태 관리 등 다양한 프로그래밍 패턴을 구현할 수 있습니다. 특히 함수형 프로그래밍에서 중요시되는 순수 함수와 불변성의 원칙을 적용하면 예측 가능하고 유지보수가 용이한 코드를 작성할 수 있습니다.
ES6에서는 class문법이 도입되어 객체 지향 프로그래밍을 더욱 직관적으로 구현할 수 있게 되었습니다. 프로토타입 기반 상속을 더 친숙한 문법으로 쓸 수 있게 되면서, Java나 C++ 하던 개발자도 접근하기 쉬움지게 됐습니다. 또한 Promise, async/await와 같은 비동기 처리 문법의 도입으로 콜백 지옥을 피하고 더 깔끔한 비동기 코드를 작성할 수 있게 되었습니다.
브라우저부터 서버, 모바일까지 JavaScript를 쓰는 곳에서 객체 지향과 함수형을 상황에 맞게 섞어 씁니다.
객체 지향과 함수형은 각각 장단점이 있어서, 프로젝트 특성과 요구사항에 따라 골라 쓰거나 섞어 씁니다.
객체 지향은 관련된 데이터와 동작을 객체로 추상화해 코드를 구조화하고, 캡슐화로 내부 구현을 숨긴 채 인터페이스로 통신합니다. 상속과 다형성으로 코드 재사용성을 높이고, 상태를 가진 객체들의 상호작용으로 프로그램을 모델링합니다. 복잡한 도메인 모델링이나 UI 컴포넌트 설계, 게임 개발에 잘 맞습니다.
함수형은 부수 효과를 최소화하고 순수 함수의 조합으로 프로그램을 구성합니다. 불변성으로 예측 가능한 코드를 작성하고, 고차 함수로 추상화 수준을 높이며, 선언적으로 작성해 가독성을 향상시킵니다. 데이터 변환이나 병렬 처리, 상태 관리, 이벤트 처리에 적합합니다.
함수형 프로그래밍 적용하기
프로젝트에서 최근에 학습한 함수형 프로그래밍 기법을 적용하려 노력했습니다. 제가 가장 중점을 둔 부분은 다음과 같습니다.
- 부수 효과가 있는 함수와 없는 함수, 그리고 데이터를 명확히 구분한다.
- 부수 효과가 있는 함수는 최대한 순수 함수로 변환하고, 순수 함수는 가능한 한 데이터로 추출한다.
여기서 부수 효과가 없는 함수가 바로 순수 함수를 의미합니다. 순수 함수는 주어진 인자에 따라 결과가 결정되며 동일한 인자에 대해 항상 같은 결과를 반환한다는 특징이 있습니다. 그리고 데이터는 이벤트에 대한 사실을 나타냅니다.
부수 효과란 함수가 외부 상태를 변경하는 것을 의미하며 호출 횟수나 시점에 따라 다른 결과를 만들어낸다는 특징이 있습니다.
프로그램 개발은 본질적으로 부수 효과를 만들어내는 행위이기에 이를 완전히 제거하는 것은 불가능합니다. 하지만 부수 효과가 있는 함수를 순수 함수로 분리함으로써 부수 효과를 최소화할 수 있습니다. 저는 이러한 접근 방식에 집중하며 코드를 작성했습니다.
- 부수효과가 있는 함수
과제 1에서 액션은 세 가지입니다. 사용자의 입력을 전달받는 것과 결과값을 출력하는 것, 그리고 에러를 출력하는 것
export function printResult(str) { }
export async function readLineAsync() { }
export function validatePositiveNumberArray(array) { }
위 함수는 호출 횟수나 호출 시점에 따라 항상 결과값이 다릅니다. 즉 부수효과가 있는 함수입니다.
- 부수효과가 없는 함수, 즉 순수 함수
export function stringifyToJSON(str) { }
export function sumArray(numberArray) { }
다시말해 동일한 인자를 전달하면 항상 같은 결과값을 리턴하는 함수입니다.
- 데이터
class App {
#DEFAULT_DELIMITER = '..';
#CUSTOM_DELIMITER_REGEXP = /../;
// ..
}
export const INPUT_INFO_MESSAGE = '..';
export const ERROR_MESSAGE = '..';
데이터는 상수 혹은 클래스의 필드에 선언을 하였습니다.
또한 Console.readLineAsync로 읽은 사용자의 값 또한 부수 효과로 인해 만들어진 데이터라 할 수 있습니다.
const userInput = await readLineAsync();
const userInput; // 데이터
하나의 사례를 들어 설명하겠습니다.
- Before
function calculateSum(input) {
validatePositiveNumberArray(input);
const numberArray = convertNumberArray(input);
return sumArray(numberArray);
}
function validatePositiveNumberArray(array) {
if (!Array.isArray(array)) throw new Error(ERROR_MESSAGE);
for (const item of array) {
if (isNaN(item) || Number(item) <= 0) throw new Error(ERROR_MESSAGE);
}
}
위 함수에서 calculateSum은 부수효과가 있는 함수입니다. 왜냐하면 calculateSum 내부에 있는 validatePositiveNumberArray함수가 바로 에러를 출력하는, 즉 부수효과를 만드는 함수이기 때문입니다.
이처럼 함수 내에 부수효과가 있는 함수가 있으면 그 함수는 부수효과가 있는 함수가 됩니다.
- After
validatePositiveNumberArray(input);
// ..
function calculateSum(input) {
const numberArray = convertNumberArray(input);
return sumArray(numberArray);
}
위의 코드는 상당히 간단한 코드여서 validatePositiveNumberArray를 calculateSum바깥에서 호출하는 방법으로 단순하게 해결이 되었습니다.
위와 같은 방식으로 함수형 프로그래밍 기법을 적용함으로써 얻은 가장 큰 장점은 '테스트의 용이성'입니다. 각 함수를 분리하여 개별적으로 테스트를 작성할 수 있게 되었습니다. 부수 효과가 있는 함수와 없는 함수가 서로 밀접하게 의존하고 있다면, 오류의 발생 지점을 찾기가 어려워집니다.
또한 코드의 예측 가능성이 향상되었습니다. 부수 효과를 일으키는 하나의 큰 함수(run) 내에 부수 효과가 있는 함수와 없는 함수를 명확히 구분하여 작성했습니다.
async run() {
const userInput = await readLineAsync(); // 부수 효과가 있는 함수
validatePositiveNumberArray(input); // 부수 효과가 있는 함수
const processedInput = this.processInput(userInput); // 부수 효과가 없는 함수
const sum = this.calculateSum(processedInput); // 부수 효과가 없는 함수
printResult(String(sum)); // 부수 효과가 있는 함수
}
에러가 발생했을 때, 우리는 부수 효과가 있는 함수에서 오류가 발생했을 가능성이 높다고 쉽게 추측할 수 있습니다. 순수 함수들은 항상 동일한 입력에 대해 동일한 출력을 제공하기 때문입니다. 따라서 디버깅 과정에서 부수 효과가 있는 함수들을 중점적으로 살펴보면 효율적으로 문제를 해결할 수 있습니다.
객체 지향 프로그래밍 적용하기
과제에서 클래스 기반 해결을 요구했기에 자연스럽게 객체 지향적 접근을 택했습니다. 클래스와 관련된 프로퍼티와 메서드를 함께 작성하여 캡슐화하는 것이 객체 지향 프로그래밍의 핵심입니다. 제가 가장 고민한 부분은 "캡슐화의 범위"였습니다. 캡슐화란 유사한 기능을 하는 데이터와 동작을 하나의 클래스로 묶는 것을 의미합니다.
이를 위해 App 클래스의 역할에 직접 관련된 값과 함수는 모두 App의 프로퍼티 및 메서드로 구현했고, 그 외의 것들은 utils와 constants 파일로 분리했습니다.
App의 프로퍼티와 메서드는 다음과 같습니다.
class App {
#DEFAULT_DELIMITER = ',:';
#CUSTOM_DELIMITER_REGEXP = /\/\/(.+)\\n/;
async run() { }
processInput(input) { }
getDelimiter(str) { }
extractContent(str) { }
}
위의 모든 요소가 App의 주요 기능인 '계산기'와 직접적으로 연관되어 있음을 알 수 있습니다.
반면 계산기 외에도 다양한 상황에서 사용 가능한 함수들은 유틸리티 함수로 분리했습니다.
export async function readLineAsync() { }
export function printResult(str) { }
export function stringifyToJSON(str) { }
export function sumArray(numberArray) { }
export function validatePositiveNumberArray(array) { }
객체 지향적 설계를 더욱 강화하려면 input 가공 메서드(processInput, getDelimiter 등)를 InputParser 클래스로, 계산 관련 로직을 Calculator 클래스로 분리할 수 있습니다. 이는 더 명확한 책임 분리와 모듈화를 가능케 합니다.
그러나 이 작은 앱에서 3개의 클래스로 나누는 것은 오히려 과도한 복잡성을 야기할 수 있다고 판단했습니다. 따라서 현재는 App이라는 단일 클래스에 모든 기능을 구현하는 것이 적절하다고 보았습니다. 향후 앱의 규모가 커지고 복잡해진다면, 앞서 언급한 3개의 클래스로 구조를 개선하는 것도 고려해볼 만한 옵션이 될 것이라 생각합니다.
클린하게 코드 작성하기
가장 마지막으로 과제를 풀며 고려했던 것은 클린 코드입니다.
클린 코드의 핵심은 '읽기 좋은 코드'입니다. 읽기 좋은 코드를 위해 다음 세 가지 원칙을 적용했습니다.
- 명확한 변수명과 함수명으로 역할 표현하기
- 일관된 추상화 계층 유지하기
- 하나의 함수는 하나의 책임만 수행하기
이 원칙들을 적용하여 개선한 코드를 이전 버전과 비교하며 설명하겠습니다.
1) 명확한 변수명과 함수명으로 역할 표현하기
- Before
export function getOrRegExpFromString(separator) {
return new RegExp(`[${separator}]`);q
}
이 함수는 정규표현식의 문자 클래스([])를 사용하여 구분자를 정규표현식으로 변환합니다.OrRegExp라는 용어는 모호하고 저만 이해할 수 있는 표현입니다. 이를 더 명확하고 일반적인 용어로 변경했습니다.
- After
export function convertCharacterClassRegex(delimiter) {
return new RegExp(`[${delimiter}]`);
}
characterClass은 MDN문서에서 공식적으로 사용되는 용어입니다. 좀 더 명확한 용어를 사용함으로써 다른 방식(예를 들어 |)을 떠올리지 않을 수 있게 되었습니다.
2) 일관된 추상화 계층 유지하기
- Before
async run() {
const userInput = await readLineAsync();
const separator = this.getSeparator(userInput);
const separatorRegExp = this.getSeparatorRegExp(separator);
let separatedUserInput;
if (this.customSeparatorRegExr.test(userInput))
separatedUserInput = this.getSeparatedString(
userInput.split(this.customSeparatorRegExr)[1],
separatorRegExp
);
else
separatedUserInput = this.getSeparatedString(userInput, separatorRegExp);
for (const it of separatedUserInput) {
if (isNaN(it) || Number(it) <= 0) throw new Error("[ERROR]");
}
// ..
}
2, 3, 4줄은 함수로 추상화되어 있습니다. 반면 그 뒤의 코드는 if-else문을 사용해 상당히 명령적으로 작성된 것을 볼 수 있습니다.
- After
async run() {
const userInput = await readLineAsync();
const processedInput = this.processInput(userInput);
const sum = this.calculateSum(processedInput);
printResult(String(sum));
}
최종 코드를 보면 위에서부터 함수명을 차례로 읽으며 전체 과정을 쉽게 유추할 수 있습니다. "사용자의 입력을 받고, 이를 가공하고, 더해서, 출력하는구나"와 같이 단계별로 자연스럽게 이해가 됩니다.
추상화 계층을 일관되게 유지함으로써 코드가 마치 물 흐르듯 읽히게 되었습니다. 이는 코드의 가독성과 이해도를 크게 향상시켰습니다.
3) 하나의 함수는 하나의 책임만 수행하기
단일 책임 원칙을 잘 수행한 함수들입니다.
- sumArray
export function sumArray(numberArray) {
return numberArray.reduce((prev, cur) => prev + cur, 0);
}
- stringifyToJSON
export function stringifyToJSON(str) {
return JSON.stringify(str);
}
위 두 함수는 다른 책임을 수행하지 않고 ‘배열의 값을 더하는 책임’, 그리고 ‘JSON문자열로 직렬화하는 책임’을 수행합니다. 하나의 함수가 하나의 책임을 수행함으로써 이전보다 코드를 이해하고 유지보수가 용이합니다.
가장 어려웠던 부분은 단일 책임 원칙의 '책임'을 이해하는 것이었습니다. 처음에는 '하나의 함수는 하나의 책임만 수행한다'는 원칙에서 '책임'을 단순히 '동작'으로 해석했습니다. 그 결과 아래와 같이 읽기 힘든 코드를 작성하게 되었습니다.
async run() {
const userInput = await readLineAsync();
const stringifiedUserInput = this.getStringifiedString(userInput);
const { separator, newUserInput } = this.getSeparator(stringifiedUserInput);
const separatorRegExp = this.getSeparatorRegExp(separator);
const separatedUserInput = newUserInput.split(separatorRegExp);
// ..
}
위 함수들은 각각 하나의 역할만을 수행합니다. 즉 단일 책임 원칙을 준수하고 있습니다. 하지만 이로 인해 코드를 이해하기 위해서는 각 줄을 하나씩 해석해야 했고 결국 전체적인 가독성이 크게 저하되었습니다.
나중에야 단일 책임 원칙에서 말하는 '책임'이 단순한 동작 단위가 아니라 더 큰 개념적 단위라는 것을 깨달았습니다. 하나의 책임은 여러 개의 동작을 포함할 수 있으며 중요한 것은 이 동작들이 하나의 일관된 '책임'을 수행하는 것입니다.
이러한 깨달음을 바탕으로 코드를 다음과 같이 개선했습니다.
async run() {
const userInput = await readLineAsync();
const processedInput = this.processInput(userInput);
// ..
processInput(input) {
const stringified = stringifyToJSON(input);
const separator = this.getSeparator(stringified);
const content = this.extractContent(stringified);
return this.splitContent(content, separator);
}
}
이 개선된 코드에서 processInput 함수는 사용자 입력을 가공하는 하나의 '책임'을 담당합니다. 이전 코드의 3~6번째 줄에 해당하는 모든 동작을 이 함수 안에 포함시켰습니다.
이렇게 하나의 책임을 하나의 함수로 묶음으로써 코드의 흐름을 한눈에 파악하기가 훨씬 쉬워졌습니다. 이를 통해 단일 책임 원칙을 올바르게 적용하면 코드의 가독성과 유지보수성이 크게 향상된다는 것을 체감할 수 있었습니다.
맺으며
하나의 언어로 다양한 프로그래밍 기법을 활용할 수 있다는 점은 정말 흥미롭습니다. 상황에 맞게 원하는 방식을 선택할 수 있으니까요. 과거에는 클린 코드를 단순히 '깔끔하게 작성하면 되는 것 아닌가?'라고 생각했습니다. 하지만 당시의 저는 그 구체적인 방법을 전혀 알지 못했습니다. '도대체 어떤 코드가 클린한 거지?'라는 의문에 답할 수 없었죠. 그러나 최근 객체 지향 프로그래밍과 함수형 프로그래밍을 학습하고 실제 코드에 적용해보면서 '아, 이런 게 클린 코드구나'라는 깨달음을 얻었습니다. 프로그래밍 패러다임은 오랜 시간 동안 코드를 잘 작성해온 사람들의 지혜의 결정체입니다. 따라서 이를 적절히 활용하는 것이 개발자로서 '좋은' 코드를 작성하는 데 핵심이라고 느꼈습니다.
아직은 프로그래밍 패러다임을 맛보기 수준으로만 적용해봤지만 앞으로 프리코스를 진행하고 프로젝트를 개발하면서 이러한 방법들을 의식적으로 하나씩 적용해 나가려 합니다. 이를 통해 더욱 '클린'한 코드를 작성하는 개발자로 성장하고 싶습니다.