프로젝트/하루한냥

[트러블 슈팅] 자바스크립트의 배열은 왜 그럴까? (심리 테스트 유효값 골라내기)

알파카털파카 2023. 11. 16. 07:15
[트러블 슈팅]
자바스크립트의 배열은 왜 그럴까? (심리 테스트 유효값 골라내기)

 

 

 

 

프로젝트를 마무리 하기 직전, 스트레스 자가 테스트 페이지에서 오류가 펑펑 터졌다. 분명 괜찮아 보였는데 어떨 때는 제대로 되고, 어떻게 하면 또 '결과보기' 버튼이 활성화되지 않았다. 그 이전에도 마지막 문항만 선택했는데 '결과보기' 버튼이 활성화되는 오류로 머리를 싸맸던 적이 있기 때문에 또 한번 발생한 오류에 골치가 아팠다. 이럴 때는 어떤 순간에 오류가 발생했는지를 찾는 것부터 수수께끼가 시작된다. 그리고 발견한 자바스크립트 배열의 수상한 녀석 <비어 있음>... 이게 대체 뭔지 살펴보고, 오류를 해결해가는 과정을 적어보려 한다. 

 

 


 

 

1. 이슈 발생

10개의 테스트 문항에 모두 답을 하면 '결과보기' 버튼이 활성화 되어야 한다. 그런데 경우에 따라 모든 문항을 골랐는데도 버튼이 활성화되지 않는 이슈가 있었다. 이래저래 실험해보니 답변 중 '전혀 아님'이 하나라도 있으면 작동하지 않았다. 빈도를 나타내는 '전혀 아님'부터 '매일'까지가 0~4의 숫자값으로 이루어져 있었는데, '전혀 아님'을 가리키는 0이 답변 배열에 담기면서, '결과보기' 버튼의 validation 검사에 통과하지 못했기 때문이었다. 

 

const [answer, setAnswer] = useState<number[]>([]);

const validAnswer = answer.filter((el) => el);
const isDisabledSubmit = useMemo(() => validAnswer.length !== 10, [answer]);

 

모든 문항에 '전혀 아님'을 선택했을 때, validAnswer의 콘솔 결과

 

 

filter로 유효값 골라내기

filter 메소드를 이용하면, 각 요소에 동일한 콜백함수를 적용하고 truthy한 값만 모아 새로운 배열로 반환된다. 이 과정에서 유효한 값만 골라낼 수 있다. 기존의 코드는 아래와 같았는데, 문제는 0이 falsy한 값으로 처리된다는 점이었다. 

 

const validAnswer = answer.filter((el) => el);
const isDisabledSubmit = useMemo(() => validAnswer.length !== 10, [answer]);

 

 

 

2. 그 이전에는 어땠을까?

심리 테스트 파트는 기존에도 오류가 몇 번 있었다. 코드 리뷰를 주고받는 도중, 마지막 문항만 선택했을 때에도 결과보기 버튼이 활성된다는 것을 알게 되었다. filter로 유효값을 골라내는 로직이 추가된 이유였다. 

 

PR에서 주고받은 코드리뷰

 

3번 문항에 답변하면 답변 배열의 2번째 인덱스에 값이 저장되어야 한다. 이 부분은 기존의 answer 배열을 복사해 구현했다. 유저가 답을 선택하면 해당 문항의 인덱스에 값이 추가된다. 답변을 선택한 인덱스에만 값이 있고, 나머지는 '비어 있음'이다. 

 

const handleChangeAnswer = (e: ChangeEvent<HTMLInputElement>, index: number) => {
  const { value } = e.target;
  const parseNumberAnswer = Number(value);

  const updatedAnswers = [...answer];
  updatedAnswers[index] = parseNumberAnswer;
  setAnswer(updatedAnswers);
};

 

 

10번 문항만 선택했을 때 - 화면
10번 문항만 선택했을 때 - 콘솔

 

이 때는 '비어 있음'이 undefined 인 줄 알았다. 값이 아직 할당되지 않았기 때문에 null은 아니고, undefined 이겠거니 싶었는데, 이것이 자바스크립트 배열의 수상한 부분이었다. 이 파트는 아래에서 다시 언급하도록 하고 우선 문제 해결에 집중하자.

 

 

 

 

3. 답변 타입에 null 추가하기 

다시 돌아가서, 배열에 기본값으로 null을 넣어두기로 했다. useState를 사용할 때 answer의 타입으로 null을 추가했다. 그렇지만 API 호출할 때 타입과 맞지 않아 오류가 발생했다.

 

const [answer, setAnswer] = useState<(number | null)[]>([]);

 

에러 발생

 

import { http } from '../http';

type RequestPostAnswer = {
  type: 'stress';
  scores: number[];
};

export const apiPostAnswer = (type: 'stress', scores: number[]) => {
  const data = {
    type,
    scores,
  };

  return http.post<unknown, RequestPostAnswer>('/answer', data);
};

 

API 요청을 보낼 때 answer의 값은 모두 number이기 때문에, API 요청을 할 때 타입가드를 해주었다. 

 

const isNumber = (value: number | null): value is number => value !== null;
const numbersOnly: number[] = answer.filter(isNumber);
const responsePostAnswer = await apiPostAnswer('stress', numbersOnly);

 

 

is 타입가드 이용하기

is 키워드를 사용하면 타입가드를 할 수 있다. 함수가 호출된 범위 내에서만 타입을 특정 타입으로 좁힌다. as 키워드와 마찬가지로 컴파일 단계에서만 사용된다. 하루한냥 프로젝트에서는 null을 걸러내고 숫자만 가려내도록 할 수 있다. 

 

const answer = [];

// 기본 함수
const isNumber = (value: number | null) => value !== null;

// is 타입가드 함수
const isNumberTypeGuard = (value: number | null): value is number => value !== null;

// filter 안에 들어가는 함수와 동일한 로직
const numbersOnly1 = answer.filter((value: number | null) => value !== null);
const numbersOnly2 = answer.filter(isNumberTypeGuard);

 

 

 

 

4. Range 함수로 배열 만들기

jset 테스트는 통과하지만, 아직 0이 답변 배열에 담기지 않는 상태다. filter를 잘못 걸러내고 있기 때문이다. 처음에는 답변값을 0~4가 아니라 1~5로 전면 교체할까 싶었지만, 그러면 결과 페이지의 결과표도 다시 계산해야 하고, 문제를 직면하는 것이 아니라 속임수로 도망치는 것 같아 0~4를 유지하되 해결하는 방법을 찾기로 했다. 그러려면 배열의 길이가 무조건 10이 되어야 하고, 버튼이 활성화되는 로직도 새로 작성해야 했다. 

 

배열을 만드는 range 함수에 문항 갯수인 10을 집어넣고, 모든 요소가 null이 되도록 만든다. length가 10인 배열이 만들어진다. 이렇게 하면 3에서의 (number | null)[] 타입도 괜찮아진다. null을 걸러내는 타입가드 로직도 적절해졌다. useState에 넣을 초기값으로는 range 함수로 생성된 배열을 넣는다. 

 

const range = (n: number, initial = 0) => {
  Array(n)
    .fill(0)
    .map((el) => initial + index);
}

 

기존에 filter로 유효값을 걸러준 대신, includes 메소드를 사용해 배열에 null이 남아있는지 체크하도록 했다. answer의 길이를 10 미만으로 걸러, null이 남아있거나 답변을 선택하지 않은 문항이 남아있을 경우에 '결과보기' 버튼이 활성화되지 않도록 했다. 

 

const initialAnswer = range(10).map(() => null);

export function QuestionPage() {
  const [answer, setAnswer] = useState<(number | null)[]>(initialAnswer);

  const isInvalidAnswer = answer.includes(null);
  const isDisabledSubmit = useMemo(() => isInvalidAnswer || answer.length < 10, [answer]);
  
  // 생략
}

 

 

 

 

5. 자바스크립트 배열에서 emtpy란?

프로젝트가 정상 작동하므로 아까 보았던 '비어 있음'을 다시 살펴보자. 일반적으로 '없음'을 나타낼 때는 undefined와 null를 사용하는 것으로 알고있다. 이 두 가지와 '비어 있음(empty)'을 비교해 보았다.

 

undefined, null, empty 비교1
[]의 length는 0

 

 

null, undefined는 어쨌든 하나씩 자리를 차지하고 있다. 그렇지만 Array(n)로 생성하거나 array[n]로 임의의 자리에 값을 추가할 경우 '비어 있음'이 표시된다. 빈 배열을 할당한 arr1은 길이가 0인 배열이다. 

 

undefined, null, empty 비교2

 

이번에는 map을 돌면서 2를 곱해보았다. 1, 4의 결과는 이전과 동일하다. empty는 값이 없어서 연산이 적용되지 않는다. 한편, null과 undefined에 연산을 적용한 결과가 각각 0, NaN인 것이 흥미로웠다. 

 

NaN은 Not-A-Number로, 숫자가 아님을 의미한다. Number(undefined) 등 숫자로 변환에 실패했을 때 반환된다. null은 숫자 0으로 변환되어 0과 곱하면 0을, 2를 더하면 2를 반환했다. NaN인지 가려내는 isNaN 함수를 실행해봐도 null과 undefined는 결과가 다르다.

 

isNaN(1 + null); // false
isNaN(1 + undefined); // true

 

 

undefined

자바스크립트 원시값, 값을 할당하지 않았을 경우 부여됨, 숫자 연산 시 NaN을 반환

null

자바스크립트 원시값, 의도적인 비어 있음을 나타냄, 숫자 연산 시 0으로 변환됨

empty

몇 번째 인덱스인지 나타냄, 연산이 적용되지 않음, 아무 것도 없이 비어 있는 상태

 

 

 

 

마치며

스트레스 테스트를 구현하면서 처음 접해본 것들이 많았다. 라디오 버튼 구현하기도 그렇고, 유효값을 제대로 걸러내려면 어떻게 해야하는지 고민도 많이 했다. 타입 가드의 한 방법인 is 키워드를 사용하는 방법도 배웠다. 요새 <이펙티브 타입스크립트> 책을 읽는 중인데 간절할 때(필요할 때) 읽으니까 도움이 많이 된다. 새삼 타입스크립트가 간단해보이지만서도, 자유자재로 사용하는 레벨에 이르려면 꽤 험난한 길이 펼쳐져 있다고 느낀다. 이번 트러블 슈팅 과정에서는 '비어 있음'을 난생 처음 보았다. 세상에 자바스크립트에는 별 게 다 있다! 기본기를 다져야한다는 말이 와닿고 있는 요즘이다.