프리코스 1주차 미션을 진행하며 신경 쓴 부분, 그리고 그 과정에서 배우고 느낀 점들을 작성해보려 한다.
구현할 기능 설계 및 정리
우선 나는 프리코스 목표로 '구현에 앞서 문제에 대한 적절한 해결 방안을 설계하는 연습하기'를 다짐하였기 때문에 냅다 생각나는 대로 코드를 짜는게 아니라 문제를 요구사항과 함께 충분히 분석하고 설계하는 과정에 투자를 많이 하려고 하였다.
💡 설계
- 입력 받은 문자열에 대해 인덱스를 증가시키며 한 자씩 읽으면서, case별로 함수를 만들어 처리한다.
/
가 나올 경우,+4자리
까지 읽어서 커스텀 구분자의 지정 형식에 맞는지 확인한다.- 숫자가 나오면 배열에 넣어놓고, 그 다음 자리를 확인하여 숫자면 다시 배열에 넣는다.
- 숫자가 아닌 문자가 나오면 구분자인지 확인 후 오류를 발생시키거나 합계에 더하고 숫자를 저장하던 배열을 비운다.
🚨 에러 처리 기준 정의
사용자의 입력에 자유도가 높고, 기능 요구 사항을 보다 명확히 분석하기 위해 에러가 발생하는 case를 먼저 정리하는 것이 좋겠다고 생각하였다.
- 양수가 아닌 숫자를 포함하는 경우 (ex.
0:1,2
) - "//"과 "\n" 사이에 숫자가 오는 경우
-> 숫자는 구분자가 될 수 없는 것으로 정한다. 예로//1\n112111
입력 시1 + 21 + 1 = 23
또는1 + 2 + 11 = 14
로 답이 갈릴 수 있기 때문 - 커스텀으로 지정한 구분자의 길이가 2 이상의 문자열인 경우
-> 구분자로는 문자 1개를 허용하는 것으로 정한다. - 구분자(기본 구분자 + 커스텀 구분자) 외의 문자가 구분자로서 사용된 경우 (ex.
//;\n1!2
,1.1.1
) - 그 외 입력 형식이 잘못된 경우 (ex.
//&\n3&
,4,
,:1:2
,!
)
커스텀 구분자를 지정해준 경우, 기본 구분자와 혼합하여 사용하는 경우도 허용하는 것으로 정한다. (ex. //;\n1,2;3 => 6)
✅ 구현할 기능 목록
필요한 변수 초기화
시작에 앞서, 구상한 내용을 바탕으로 구현에 필요한 변수를 선언한다.
- 전체 문자열 배열
- 배열에서 index 역할을 할 커서
- 숫자를 담을 배열
- 합계를 저장할 변수
문자열 입력받기
사용자로부터 문자열을 입력 받고, 입력 받은 문자열을 한 글자씩 읽을 수 있도록 문자열 배열을 생성한다.
문자열 종류 판단하기
문자열 배열의 요소를 하나씩 읽으면서 "/"인지 문자인지 숫자인지에 따라 분기 처리 해준다. 각 분기에 대해 함수를 통해 커스텀 구분자 추가, 구분자 처리, 숫자 합산 및 에러 핸들링을 해준다.
커스텀 구분자 추가
입력값이 `/`로 시작하는 경우 커스텀 구분자의 지정 형식에 맞는지 확인 후, 구분자로 추가한다.
구분자 처리
문자열이 문자인 경우, 해당 문자가 구분자에 해당하는지 확인 후 맞다면 저장해놓았던 숫자 배열을 `join()`으로 합쳐서 숫자로 변환 후 합계에 더한다.
숫자 처리
문자열이 숫자인 경우, 배열에 추가하여 다음 구분자를 만날 때까지 저장해둔다. 혹은 맨 끝자리에 오는 숫자일 경우 값을 합계에 더해준다.
에러 핸들링
사용자가 입력을 잘못한 경우, "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시키고 애플리케이션을 종료시킨다.
결과 출력
구분자를 기준으로 추출된 숫자의 총 합을 결과로 출력한다.
미션을 진행하며 고민한 부분
비동기 입력 처리
Javascript는 기본적으로 싱글 스레드로 동작하지만 이벤트 루프를 통해 병렬적인 일 처리를 가능하게 한다. 이 비동기 함수는 Promise를 반환한다.
비동기로 사용자 입력을 처리하는 이유
async, await를 사용해서 사용자 입력을 받도록 처리하였다. 비동기 처리를 하지 않고 입력을 받으면 사용자가 입력을 받아 처리할 때까지 다른 작업을 수행하지 못하고 기다려야 한다. 따라서 async 키워드를 사용하여 이벤트 루프가 해당 함수를 병렬적으로 처리할 수 있도록 하고, await 을 통해 입력을 기다리도록 하였다.
async run() {
const input = await this.ioHandler.input();
// ...
}
class IOHandler {
async input() {
const input = await Console.readLineAsync(
"덧셈할 문자열을 입력해 주세요.\n"
);
const strArr = input.trim().split("");
return strArr;
}
}
input()
함수의 비동기 처리
일반적으로 입출력 작업은 사용자가 언제 입력을 완료할 수 없으므로 비동기로 처리하는 것이 효율적이다. 입력 함수를 비동기적으로 처리하게 되면 입력을 기다리는 동안 다른 작업이 블로킹되는 현상을 방지할 수 있고, 입력 대기 중에도 다른 작업을 처리할 수 있기 때문에 응답성을 향상시킬 수 있다. 입력이 완료되면 Promise를 반환하여input
변수에 할당된다.
run()
함수의 비동기 처리
비동기 함수인input()
을 호출하기 위해 비동기 함수로 선언되어야 한다. (비동기 함수는 비동기 함수 안에서만 await으로 호출할 수 있다.)
run() 함수는 사용자로부터 입력을 받아 그에 따른 작업을 수행하는 흐름이므로,await
을 통해 사용자의 입력을 기다릴 필요성이 있다. 비동기 처리 없이 동기적으로 입력을 기다리면 프로그램 전체가 중단되기 때문에 비동기로 대기 중에도 다른 작업을 수행할 수 있도록 하는 것이다.
이 부분은 궁금하면서도 가장 이해하기가 힘들었던 부분이었다. 이해가 되는 것 같으면서도 정확한 동작 방식이 헷갈려서 자료를 많이 찾아보았다. async와 await을 위주로 원리를 정리하였지만 제대로 이해하기 위해서는 비동기 프로그래밍뿐 아니라 이벤트 루프와 Promise 등 함께 알아야 할 내용이 많아서 그런 것도 큰 것 같다. 아직 깊이 있게 이해하진 못했지만 의문을 품고 얕고 넓게나마 알아본게 도움이 되었다.
[참고] 자바스크립트 비동기에 대한 이해
[참고] async, await 예제로 이해하기
[참고] 자바스크립트 이벤트 루프의 동작 원리
[참고] 자바스크립트 Promise의 개념
클래스 사용
자바스크립트는 애초에 객체 지향 언어가 아니지만, ES6부터 Class 문법이 추가되어 객체 지향적인 프로그래밍이 가능해졌다. 아무래도 리액트를 하면서는 클래스 문법을 사용할 일이 없었어서, 이 부분 때문에 첫 과제부터 순수 자바스크립트만 사용하는게 좀 낯설게 느껴졌던 것 같다.
클래스 문법을 통해 객체 지향에서 사용하는 다양한 기능을 활용할 수 있었지만 이번에는 생성자 함수(constructor)와 this의 용법 정도만 사용해서 구현하였다.
그래서 일단은 첫 주차이기도 하니 클래스 관련해서는 리팩토링 시 전체적인 구조를 어떻게 할지와 그에 따라 어떻게 클래스를 분리시킬지를 주로 고민하였다. 이 부분은 아래에서 다루도록 하겠다.
에러 처리
프로그래밍에서 예외 처리는 기본이면서도 매우 중요한 부분이다. 특히 이 문제에서는 아주 다양한 경우의 수로 에러가 날 수 있는 문제였기 때문에 뭉뚱그려서 그냥 에러라고 띄우는 것보다 어떤 종류의 에러인지 알려줄 필요성이 있어 보였다. 가장 좋은 에러 처리는 애초에 '발생하지 않는 것'일 것이다. 하지만 코테이토 플젝을 운영하면서 느낀 건, 사용자는 언제나 의도한 것과 다른 신박한 행동을 하기 마련이라는 것이다.. 따라서 설계보다 이전에 에러를 처리하는 기준을 먼저 정하였다. 특히 미션 요구사항에서 알려주지 않은 부분은 알아서 처리해야 했기에 문제가 될 상황을 미리 정확히 분석하는게 일순위라고 생각했다.
어느 정도로 세세하게 에러 종류를 나눌 것인지도 고민을 좀 했었다. 입력에서 가장 비중이 높은 기능적 요구사항이 '구분자'와 '양수'로 문자열이 이루어져야 한다는 것, 그리고 안내한 형식에 맞추어 '커스텀 구분자'를 지정할 수 있다는 것이었기 때문에 다음과 같이 네 가지로 에러를 구분하였다.
- 구분자 외의 문자가 포함된 경우
- 양수가 아닌 숫자를 입력한 경우
- 커스텀 구분자의 지정 형식이 틀린 경우
- 그 외 입력 형식이 잘못된 경우
각 case에 대한 에러 메시지는 아래와같은 점을 고려하며 작성하였다.
✅ 좋은 에러 메시지의 조건
- 사용자가 에러를 쉽게 이해할 수 있어야 하고, (이를테면 발생하게 된 원인을 파악할 수 있어야 함)
- 발생한 에러에 대해 스스로 해결할 수 있도록 해야 하고,
- 에러 메시지를 보고 부정적인 감정이 들지 않게끔 해야 한다
[참고] 좋은 에러를 만드는 6가지 원칙을 참고하여 작성하였다.
정규표현식 사용
처음 미션을 보고 떠올렸던 방식은 구분자를 구하고, 그 구분자를 기준으로 split()
을 써서 숫자를 추출하여 합하는 방식이었다. 그리고 입력이 아주 다양하게 있을 수 있기 때문에 발생할 수 있는 에러 케이스를 정리하고, 범위를 정하여 분류하였다. 분류된 각 케이스에 대해 형식을 검사하고, 관련 오류를 발생시킬 계획이었다. 따라서 정규표현식을 사용하고자 하였다.
입력에 자유도가 높기 때문에 각 케이스마다 입력을 검사하기 위한 정규표현식을 만드는 것이 꽤나 번거롭게 느껴졌다. 결과적으로 구현할 기능 목록도 갈아엎고 새로운 방식을 설계하였지만... 적어도 커스텀 구분자를 지정하는 형식을 검사할 때 한 번은 정규표현식을 사용하는 것이 편리했다.
정규표현식의 캡처 기능
커스텀 구분자를 지정하려면 문자열 맨 앞에 //
와 \n
사이에 '한 자리의 숫자가 아닌 문자'가 들어가도록 입력하면 된다. 이 형식을 match()
로 검사해서 일치한다면 해당 문자는 구분자로 추가하도록 하였다. 이때 캡처 기능을 사용하였는데, 아래와 같이 괄호()
로 문자열에서 가져오고 싶은 부분을 얻어낼 수 있다. 괄호는 정규표현식에서 그룹핑에도 사용하지만 동시에 캡처의 용도로도 사용이 가능하다.
const customSeparatorReg = new RegExp(/\/\/(\D{1})\\n/);
const customSeparator = customSeparatorPlaceholder.match(customSeparatorReg)[1];
match()
는 매칭되는 문자열을 첫번째 요소로 하는 배열을 반환하는데, ()
와 같은 캡처링 그룹을 사용하면 두번째 요소로 캡처한 부분을 담아서 반환한다. customSeparator
를 출력해보면 이를 알 수 있다.
["//;\n1:1;1", ";", index: 0, ...]
네이밍
클래스, 메서드, 변수의 이름을 지을 때 이름만 보고 그 의도를 파악할 수 있게 하고자 고민을 많이 하였다. 아직 이름 짓는 센스가 부족해서 항상 고민하는 부분 중 하나인데, 길이가 조금 길어지더라도 의미가 흐려지지 않도록 지나치게 축약하지 않으려고 하였다.
리팩토링
코드 분리
처음에는 App 클래스에 모든 인스턴스 변수와 함수들을 모아놨었다. 기능 구현을 마치고 보니 프로그램을 하나의 파일만으로 구성한 것이 너무 좋지 않아보여서 클래스 분리를 꼭 시켜야겠구나 생각했다. 문제는 막상 분리를 시키려니 좋은 생각이 잘 떠오르지 않았다.
객체 지향적으로 생각한다는건 어떤걸까?
클래스는 어떤 기준으로 정의해야 할까?
와 같은 고민을 했다.
백엔드 하는 플젝 동료에게도 첫번째 질문을 던져보았는데, 결국엔 '역할'을 잘 생각해야 하는 것 같았다.
그리고 다른 사람들 레포를 여럿 구경해보니까, 잘 읽히는 코드를 작성하신 분들의 공통점은 클래스를 역할에 맞게 생성하고, 구조적으로 분리를 충분히 시킨 것을 알 수 있었다. 조금 참고해서 리팩토링을 해보았다.
- App Class
메인이 되는 클래스
전체적으로 어떤 구조로 설계되었으며 어떤 흐름으로 실행되는지가 한 눈에 이해하기 쉬워야 한다. 따라서 입력과 출력은 별도의 클래스로 분리시켜 한 줄로 만들었고, 입력 문자열에 따라 다른 핸들링 함수로 각각 다르게 처리하도록 하는 가장 큰 로직만 남겨 두었다.
async run() {
const input = await this.ioHandler.input();
while (this.stringParser.cursor < input.length) {
const char = input[this.stringParser.cursor];
if (char === '/') {
this.stringParser.handleCustomSeparator(char, input);
} else if (isNaN(Number(char))) {
this.stringParser.handleNotNumber(char, input);
} else {
this.stringParser.handleNumber(char, input);
}
}
this.ioHandler.print(this.stringParser.sum);
- IOHandler Class
입출력을 담당하는 클래스
입력과 출력을 처리하는 코드가 매우 간단했기 때문에 따로 생성하지 않고 하나의 입출력 클래스 안에서 메소드를 정의하였다.
async input() {
const input = await Console.readLineAsync('덧셈할 문자열을 입력해 주세요.\n');
const strArr = input.trim().split('');
return strArr;
}
async print(result) {
Console.print(`결과 : ${result}`);
}
- StringParser Class
한 자씩 읽어내는 문자열에 대한 처리를 담당하는 클래스
App Class에서 입력값을 처리하는 큰 틀을 잡았다면 각 입력 케이스에 대해 처리해주는 함수는 여기에 두었다. 각 함수들을 util 디렉토리를 생성해서 총 세 개의 유틸 함수로 빼버릴지 아니면 역시 각각의 함수의 역할이 다른 만큼 세 개의 클래스를 만들지도 고민했었다.
각 함수의 역할이 다르긴 하지만, 큰 틀에서 다른 분기에 쓰이며 처리하는 '대상'에 차이가 있을 뿐 궁극적인 용도는 같다고 판단하여 하나의 클래스 안에 세 개의 메서드를 생성해 구성하였다. 그리고 이 구현 방식이 마치 컴파일러의 동작 방식과 유사해서, 문자열 하나 하나를 분석하고 처리한다는 의미에서 StingParser
라고 네이밍하였다.
- errorMsg
상수화하여 따로 관리하기 위해 constant 디렉토리 생성 후 하위에 두었다. key 이름은 upper snake case를 컨벤션으로 지켜, 내용에 잘 매치되는 이름으로 지었다.
const ERROR_MESSAGE = {
INVALID_INPUT: "[ERROR] 입력 형식을 다시 확인해 주세요.",
NOT_SEPARATOR: "[ERROR] 구분자 외의 문자가 포함되어 있습니다.",
INVALID_NUMBER: "[ERROR] 숫자는 양수만 입력할 수 있어요.",
INVALID_CUSTOM_SEPARATOR_FORMAT:
'[ERROR] 커스텀 구분자를 지정하려면 "//{1자리의 숫자가 아닌 문자}\\n" 형식으로 입력해 주세요. (ex. "//;\\n1;2;3")',
};
회고
공통 피드백에 대한 성찰
- 요구사항 준수하기
과제 진행 요구사항, 기능 요구사항, 프로그래밍 요구사항 이렇게 3가지의 요구사항이 있기 때문에 유의해야 한다. 미션에서 가장 기본인 부분이기 때문에 몇 번씩 꼼꼼히 읽어보고 확인하였기 때문에 잘 충족했다고 생각한다.
- 커밋 메시지 의미 있게 작성하기
AngularJS Git Commit Message Conventions를 반영하여 작성하였다. 메소드명 같이 너무 특정되는 내용을 제외하고, 그렇다고 너무 포괄적이지도 않게 '어떤 작업을 했는지'와 '무얼 위해 이 행동을 했는지'가 드러나도록 하였다.
또한 커밋 빈도는 최소 함수 단위로, 하나의 의미 있는 행동을 할 때마다 커밋을 하였다.
사실 커밋은 평소 프로젝트 작업하던 때와 거의 똑같이 했는데, 피드백으로 제공해주신 좋은 git 커밋 메시지를 작성하기 위한 7가지 약속을 읽어보니 커밋 관련해서는 그래도 잘 하고 있었나보다:)
- 디버깅 시에는 출력 대신 디버거 사용하기
나는 디버깅 시에 거의 console.log()
를 사용해서 확인을 하는데, 그래서 이 피드백을 보고 상당히 헉 했다. 사실 console.log()
를 매번 코드 중간에 삽입했다가 다시 지우고 하는게 비효율적이라는건 이번 과제를 구현하면서도 느꼈던 점이다. 거기다 프로젝트 작업하면서도 많이 느낀 거지만 console.log()
를 사용하다보면 불필요한 흔적이 남기 마련이라서 코드가 더럽혀지기도 한다. 따라서 이 부분은 다음 미션 때 개선해볼 또 다른 과제로 설정하기로 했다.
- 이름을 통해 의도 드러내기
이름이 조금 길어지더라도 이름만 보고도 어떤 역할을 하는지 유추할 수 있도록 시간을 꽤 투자했다.
- 공백도 컨벤션에 포함시키기
공백을 통일성 있게 사용하였고, 의미 없이 사용하지 않고 가독성 향상 목적에서 문맥을 끊어내는 부분 등에서 사용하였으므로 잘했다고 볼 수 있겠다.
- 의미 없는 주석 달지 않기
개발자는 주석을 통해 설명하기보다 코드를 통해 의도를 전달해야 한다고 생각한다. '이름을 통해 의도 드러내기'도 이와 같은 맥락이었기에 이 부분도 충족한 것 같다. 물론, 아직까지 코드만으로 어떤 개발자든 이해시킬만한 능력은 많이 부족하기에 최대한 클린코드를 작성하려고 노력했으나 한 눈에 읽히지 않는 부분이 많다고 생각한다.(ㅠ) 필요하다고 생각하는 부분에는 주석을 달아주었다.
- 코드 포맷팅 사용하기
원래 airbnb 플러그인을 설치하여 ESLint와 Prettier를 사용하려다 말았는데, 의존성을 새로 추가할 수 없다는 조건 탓에 직접 airbnb 스타일에 맞추어 코드를 작성하였다. 하지만 피드백을 보니 아마 ESLint와 Prettier를 사용하는 건 좋은 생각이었던 것 같다. 사용 후 git에만 반영하지 않으면 됐던 것을... 나중에 깨달아버려서 아쉽다..ㅎ 다음 주에는 원래 하려던 대로 해 볼 생각이다.
- 자바스크립트 내장 함수 적극 활용하기
평소 알고리즘 문제를 풀 때에도 프로젝트 개발 할 때에도 자바스크립트에서 기본으로 제공하는 API를 야무지게 활용하는 것을 선호하는 편인데, 구현 방식 자체가 약간은 빡코딩에 가까운 것같기도 해서 이 부분은 잘 모르겠다. 내장 함수를 활용할 수 있는 만큼 하려고 노력해야겠다.
추가로 개인적인 아쉬움
테스트 코드 작성을 해보고 싶었는데... 시간 상의 이유로 하지 못했다.
자바스크립트의 경우 Jest를 사용해서 TDD를 하는 것으로 아는데, 사실 TDD를 경험해본 적이 딱히 없기 때문에 부끄럽지만 그 중요성조차 잘 모르는 상태이다. 하지만 일단 이번 과제에서 기본으로 작성된 테스트 케이스는 두 가지밖에 없었어서, 여러 가지 케이스를 검증하려면 테스트 코드를 추가하는 것이 확실히 좋았을 것 같다. 그래서 2주차에는 이 부분도 공부해보고 테스트 코드를 직접 작성해보고 싶다.
마무리
1주차는 개발 환경과 javascript 언어만을 사용하여 프로그래밍 하는 것에 익숙해지는 것이 목표였다. 앞으로 주차를 거듭하면서 배우고 느낀 점을 기반으로 더욱 나은 방향을 고민해보며 성장하고 싶다.