티스토리 뷰
본 포스팅은 프로젝트 회고를 목적으로 작성되었습니다.
프로젝트 결정 계기
본 프로젝트는 학교에서 진행하는 진로탐색학점제의 일환으로, [실전 TypeScript와 고급 웹 개발]이란 주제로 진행된 프로젝트에 대한 내용을 다뤘습니다. 해당 과제는 JavaScript의 슈퍼셋인 TypeScript의 정적 타입 시스템을 통해 코드의 안정성과 가독성을 향상시키는 방법을 탐구하는 데 중점을 두었습니다.
첫 번째 토이 프로젝트를 고려하던 도중, 학과에서 팀 과제를 진행하며 아이폰에 내장된 기본 계산기 앱을 사용하게 되었고, 이를 계기로 아이폰 기본 계산기를 모델로 한 순차적 계산기 프로그램을 제작하게 되었습니다.
초기 프로젝트였던 만큼, 타입 추론을 고려하지 않고 모든 변수에 명시적으로 타입을 기재하는 방식으로 진행되었습니다😋
선행 연구
프로젝트 진행 초기에는 TypeScript로 진행하였으나, 구현 실력의 미숙함으로 인해 JavaScript로 제작 후 TypeScript로 마이그레이션하는 쪽으로 방향을 틀었습니다.
[React] 리액트로 계산기 만들기
CSS를 사용할때 주로 sass를 썼었다, styled-component도 연습하면서 css grid에 대해서도 공부해보고 싶었다. 뭐가 좋을지 고민하다가 계산기 UI가 떠올랐고 내친김에 구현까지 해보았다.
velog.io
React로 계산기(calculator) 만들기
전에도 상세하게 올려보겠다던 계산기 코드를 이제야 올려본다 처음부터 로직을 생각하고 구현 해보았기에 의미 있고 실용성 있는 계산기가 되길 개인적으로 바란다 리액트로 계산기를 만드는
rec8730.tistory.com
리액트로 순차적 계산기를 구현했던 분들의 포스팅에 많은 도움을 받았습니다. 다만, eval을 사용하는 것은 해커에게 먹잇감을 주는 위험한 발상이며 이를 피하고자 다중 if문을 사용하는 것도 꺼려지게 되어 다른 방식을 연구하게 되었습니다.
eval() - JavaScript | MDN
eval() 은 문자로 표현된 JavaScript 코드를 실행하는 함수입니다.
developer.mozilla.org
프로젝트 진행
Calculator without eval()
how to calculate the value from one input without eval() in react
How can i calculate the value of a single input without using eval() ? in the code below it just does that but I used the eval function which is said to be harmful and a bad decision, is it possibl...
stackoverflow.com
Calculate string value in javascript, not using eval
Is there a way to calculate a formula stored in a string in JavaScript without using eval()? Normally I would do something like var apa = "12/5*9+9.4*2"; console.log(eval(apa)); So, does ...
stackoverflow.com
그렇게 사용하게 된 방식이 토큰화입니다.
해당 프로젝트에는, tokenize와 calculate라는 유틸리티 함수가 사용됩니다. tokenize는 버튼 이벤트가 발생해 들어오는 문자열 형식의 표현식을 토큰화시키는 역할을 합니다.
tokenize.js
const Operator = {};
export default function tokenize(expr) {
const num = [];
const oper = [];
const split = expr.split('');
for (const char of split) {
if ('/*-+'.includes(char)) {
oper.push(char);
} else {
num.push(char);
}
}
return { num, oper };
}
예를 들어,
다음과 같은 버튼 이벤트가 나타났다고 가정해 tokenize 함수의 입력값이 된다면
해당 결과가 출력됩니다.
프로젝트 입출력 값의 착오가 생기면 바로잡기 어려워질 것이라 생각해, Jest를 이용해 테스트코드를 도입했습니다.
describe.skip('tokenize.js', () => {
test('test', () => {
const expression = '123+24*32';
const result = tokenize(expression);
expect(result).toStrictEqual([123, '+', 24, '*', 32]);
});
calculate.js
그렇게 토큰화된 출력값은 calculate 함수의 인자값으로 전달되어 사칙연산 우선순위에 맞추어 계산이 됩니다. calOper 함수는 calculate의 연산 과정을 모듈화하는 도우미 함수로 사용되었습니다.
const priority = [
{
'*': (a, b) => a * b,
'/': (a, b) => a / b,
},
{
'+': (a, b) => a + b,
'-': (a, b) => a - b,
},
];
export function calOper(expr, oper) {
const result = [];
let operator = null;
for (const token of expr) {
if (token in oper) {
operator = oper[token];
} else if (operator) {
result[result.length - 1] = operator(result[result.length - 1], token);
operator = null;
} else {
result.push(token);
}
}
return result;
}
export function calculate(tokens) {
for (let i = 0; i < priority.length; i++) {
while (tokens.length > 1) {
const newToken = calOper(tokens, priority[i]);
tokens = newToken;
// 무한루프
}
}
return tokens[0];
}
해당 파일의 동작 방식은 다음과 같습니다. tokenize 함수에서 출력된 [123,'+',24,'*',32]를 예시로 들겠습니다. priority 배열을 통해 우선순위대로 연산자를 반복하며, calOper에 넘겨집니다.
calOper는 oper 인자에 넘겨진 연산자가 있다면 다음 조건문을 통해 해당 부분만 먼저 계산합니다. 해당 결과값을 return하고, 계산이 완료될 때까지 calcualte 함수를 반복해주면 완료입니다.
for (const token of expr) {
if (token in oper) {
operator = oper[token]; // 연산자를 포획
} else if (operator) {
// 두번째 피연산자면 계산
result[result.length - 1] = operator(result[result.length - 1], token);
operator = null;
} else {
result.push(token); // 첫 번째 피연산자면 result 배열에 삽입
}
}
따라서, 첫 번째 priority[1]에서 calOper는 [123,'+',768]을 반환합니다. 두 번째 priority[2]에서 calOper는 891이라는 결과값을 도출할 수 있습니다.
무한루프 문제
하지만 이런 경우, priority[i]에서 아무런 연산자를 찾아낼 수 없는 경우 무한루프에 빠지게 됩니다. 다시 말해 우선순위가 서로 같은 연산자끼리만 구성된 표현식입니다. 이를 해결하기 위해 calculate 함수에 조건식을 달아 무한루프를 빠져나올 조건을 걸어줍니다.
if (tokens.length === newToken.length) break;
TypeScript 마이그레이션
TypeScript의 마이그레이션은 비교적 간단하게 진행되는 줄 알았으나, calOper에서 사용된 단 한 줄의 코드로 인해 엄청난 트러블 슈팅 과정을 겪게 되었습니다.
operator = oper[token];
바로 Object[key] 형태의 사용이 문제였습니다. key값에 변수를 도입하기 위해서는, 점 연산자가 아닌 Object[key] 형식을 사용해야 했는데 그러면 타입 추론에 에러가 생기는 것이 문제였습니다.
에러가 생길 때 가장 무서운 점은 그 에러가 왜 생기는지 모를 때라고 생각합니다. 타입스크립트 사용의미숙함으로 검색어를 알아낼 수 없었고, 거의 한 주동안 공을 들여 문제의 원인을 파악해 해결할 수 있었습니다.
타입 좁히기
해당 상황에서 사용된 oper[token]처럼 동적인 키 값을 사용하게 된다면 TypeScript는 모든 문자열을 받을 수 있다고 간주합니다. 따라서 이 경우 타입 좁히기를 사용해 타입을 구체적으로 명시해주어야합니다.
이를 해결하기 위해 token이 PrimaryOpcode나 SecondaryOpcode 중 하나라는 것을 확인할 수 있도록 만들어주어야 했습니다.
따라서, 타입 가드를 이용해 token이 정확한 연산자 타입인지 확인 시켜주어야 했고 이를 통해 token의 타입을 좁히는 해결 방식을 사용했습니다.
function isPrimaryOpcode(token: Token): token is PrimaryOpcode {
return token === '*' || token === '/';
}
function isSecondaryOpcode(token: Token): token is SecondaryOpcode {
return token === '+' || token === '-';
}
이러한 TypeScript의 작동 방식을 알게 되어 타입 좁히기와 넓히기에 대한 개념을 배웠고, 타입 가드를 적절히 활용해 코드의 안정성을 높이는 방법을 학습할 수 있었습니다.
다만, 해당 개념에 대한 부재로 생각보다 코드가 많이 길어지게 되어 조금 더 학습에 필요성을 느끼게 되었습니다.
type PrimaryOpcode = '*' | '/';
type SecondaryOpcode = '+' | '-';
type Operator = (a: number, b: number) => number;
type Token = number | string;
type PrimaryOperator = {
[key in PrimaryOpcode]: Operator;
};
type SecondaryOperator = {
[key in SecondaryOpcode]: Operator;
};
const priority: [PrimaryOperator, SecondaryOperator] = [
{
'*': (a: number, b: number) => a * b,
'/': (a: number, b: number) => a / b,
},
{
'+': (a: number, b: number) => a + b,
'-': (a: number, b: number) => a - b,
},
];
export function calOper(
expr: Token[],
oper: PrimaryOperator | SecondaryOperator
): Token[] {
const result: Token[] = [];
let operator: Operator | null = null;
for (const token of expr) {
if (isPrimaryOperator(oper)) {
if (isPrimaryOpcode(token) && typeof token === 'string') {
operator = oper[token];
} else if (typeof token === 'number' && operator) {
result[result.length - 1] = operator(result.at(-1) as number, token);
operator = null;
} else {
result.push(token);
}
} else {
if (isSecondaryOpcode(token) && typeof token === 'string') {
operator = oper[token];
} else if (typeof token === 'number' && operator) {
// 사칙연산자의 우선순위가 낮은 연산자의 경우 null을 사용하지 않아도 됨
result[result.length - 1] = operator(result.at(-1) as number, token);
} else {
result.push(token);
}
}
}
return result;
}
function isPrimaryOperator(
oper: PrimaryOperator | SecondaryOperator
): oper is PrimaryOperator {
return Reflect.has(oper, '*') || Reflect.has(oper, '/');
}
function isPrimaryOpcode(token: Token): token is PrimaryOpcode {
return typeof token === 'string' && ['*', '/'].includes(token);
}
function isSecondaryOpcode(token: Token): token is SecondaryOpcode {
return typeof token === 'string' && ['+', '-'].includes(token);
}
export function calculate(tokens: (number | string)[]): number {
// for...of 쓰는 것을 추천함
for (let i = 0; i < priority.length; i++) {
while (tokens.length) {
const newToken = calOper(tokens, priority[i]);
if (tokens.length === newToken.length) break;
tokens = newToken;
}
}
if (typeof tokens[0] === 'number') {
return tokens[0];
}
throw new Error('result cannot be string');
}
해당 프로젝트 진행 과정이 담긴 깃허브 링크입니다.
GitHub - mindaaaa/react-ts-playground: A collection of mini projects built with React and TypeScript.
A collection of mini projects built with React and TypeScript. - mindaaaa/react-ts-playground
github.com
'Oops, All Code! > 📝 Study Notes' 카테고리의 다른 글
[React+TS] 투두리스트와 다크모드 결합을 통해 알아본 타입 좁히기 (0) | 2024.11.04 |
---|---|
React 다크모드 구현과 CSS Variables (0) | 2024.09.22 |
소프트웨어공학 과제로 역할 분담하기 (0) | 2024.09.19 |
[Conventional Commits] 커밋 메시지 작성 가이드 (0) | 2024.09.02 |
iterm으로 Vite 프로젝트 스캐폴딩(Scaffolding)하기 (1) | 2024.08.31 |
- Total
- Today
- Yesterday
- typescript
- 소사벌맛집
- 우아한테크코스
- 타입좁히기
- js
- 프론트엔드
- 카드뉴스
- 어휘력
- 서평
- 도서리뷰
- 안성스타필드
- 일급객체
- 소사벌
- react
- 책추천
- 어른의어휘공부
- 대학생플리마켓
- 프리코스
- 트러블슈팅
- 도서추천
- 프로토타입
- 플리마켓후기
- 코딩테스트
- 비즈플리마켓
- 회고
- 카페추천
- 경험플리마켓
- javascript
- 대학생팝업스토어
- 플리마켓운영
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |