환율 계산기 제작 with TDD 였던 것

김기영·2022년 1월 26일
1

원티드프리온보딩

목록 보기
1/4
post-thumbnail

🎉원티드 프리 온보딩 시작

얼마 전, 지인으로부터 원티드 프리 온보딩 프론트엔드 과정이 있다는 얘기를 들었고, 곧바로 사전과제를 완료하고 제출했었는데 최종 합격했다고 연락이 왔다! 지난 24일 월요일에 OT가 시작되었고, OT라서 대충 넘어갈 줄 알았지만 그 날 팀 편성이 완료되고 수요일까지 해야될 과제도 주어졌다.

🎉간단한 환율 계산기

첫 번째 과제는 실시간 환율 데이터를 받아와서 송금액(USD)을 입력하면 선택한 나라의 화폐로 얼마인지 알려주는 간단한 환율 계산기를 제작하는 것이었다. 구현해야 될 화면은 이렇다.


여기에 필요한 기능은 다음과 같다.

  1. 실시간 환율 데이터 받아오기
  2. select box로 수취 국가 변경 (한국, 일본, 필리핀)
  3. 환율, 수취 금액 세자리 수 마다 comma 찍기, 소수점 2자리까지 포함시키기
  4. 송금액 (입력 값)에 따른 수취 금액 유효성 검사

첫 번째 과제라 그런지 비교적 간단한 기능 구현과제였고, unit test 하기 적절한 과제라고 생각하여 react-testing-libraryjest를 이용해보기로 했다. yarn 으로 create react-app 하였다면, jest 가 자동으로 설치된다.

✨실시간 환율 데이터 받아오기

api 데이터 받기

환율정보는 https://currencylayer.com/ 의 무료 서비스를 이용해서 실시간으로 가져와야 되었고, axios로 데이터를 받아왔다. 해당하는 코드를 다른 팀원도 사용할 수 있게 utils 폴더에 작성했다.

// utils/api.js
export const getExchangeRate = async () => {
  const response = await axios.get(EXCHANGE_RATE_API_ENDPOINT);

  return response.data;
};

api test

데이터를 잘 받아오는지 test하기 위해 utils 안에 __test__ 폴더를 생성하고, api.test.js 파일을 만들었다. 일단 GET 요청이 잘 되는지 확인해보자.

// utils/__test__/api.test.js
test("GET 성공 시 Status 는 200 을 반환한다.", async () => {
  const response = await fetch(EXCHANGE_RATE_API_ENDPOINT, { method: "GET" });
  expect(response.status).toBe(200);
});

test 글로벌 함수의 첫 번째 인자는 테스트 설명으로 해당 테스트를 누구나 알아 볼 수 있도록 자세하게 작성하는 것이 원칙이다. 두 번째 인자로는 테스트를 실행하는 함수를 받는다. it으로 하는 동작과 완벽히 같은 일을 수행하며, it 보다 더 직관적으로 알아보기 쉽도록 하기위해 test 명령어를 따로 만들어 주었다고 한다.

두 번째로 볼 코드는 expect 이다. 기대한 결과가 성공인지 실패인지 확인해준다. 작성한 코드를 예로 들면, expect 안의 값(response.status)과 toBe의 값(200)이 같으면 passed, 다르면 failed가 출력된다. toBe 는 matcher 메소드로 이외에도 toEqual, toBeNull, toHaveTextContent, toHaveStyle 등 아주 많은 종류의 method가 있다. jest는 css도 체크가능한 이유가 toHaveStyle 같은 메소드를 지원하기 때문이다.

더 자세한 내용은 https://jestjs.io/docs/expect 에서 확인할 수 있다.

코드 작성 후 터미널에 yarn test 를 타이핑하면 jest가 watch모드로 동작하고 passed !라는 문구가 나오면서 GET 요청이 정상적으로 되는 것을 알수 있다. watch모드는 파일에 수정 사항이 감지될 경우 자동으로 테스트를 실행해주는 상태를 말한다. 여기서 status는 서버 응답에 대한 HTTP 상태 코드를 의미하며 간단하게 나타내면 다음과 같다.

  • 200 ~ 299 : 성공
  • 400 ~ 499 : client 에러
  • 500 ~ 599 : server 에러

흔히 접하는 404 Not Found 페이지도 client 에러인 것. 자세한 정보는 다음 링크를 참고하면 좋을 것 같다.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Status

이제 요청은 잘 되는걸 확인했으니, mock 데이터 파일을 생성해서 받아온 데이터가 mock 데이터 파일과 일치하는지 확인해보자. mock 데이터 파일은 위의 getExchangeRate 함수의 response를 json 파일로 생성했다.

// utils/__test__/api.test.js
test("mock데이터와 실제 api에서 받아온 데이터가 비슷하니?", async () => {
  const response = await getExchangeRate();
  expect(response).toBeCloseTo(mockData);
});

api에서 받아온 데이터가 매 초마다 바뀌기 때문에, 하드코딩된 mock 데이터의 값과 실제 받아온 데이터가 일치할 확률이 매우 낮았다. 따라서 toBeCloseTo를 이용해 근사치인지 확인해 보려 했으나, 해당 메소드는 number만 처리할 수 있어서 object인 데이터를 비교할 수 없다고 failed를 출력해주었다. object를 비교해주는 toMatchObject는 정확한 값일 때만 성공이기 때문에 마찬가지로 제대로 동작하지 않았다. 시간이 부족해 결국 해결하지 못했고, mockData.json에 필요한 데이터만 넣고, 받아온 데이터도 필요한 데이터만 찾아내서 하나씩 toBeCloseTo로 비교하면 될 것 같다 정도로만 생각하고 다음으로 넘어가기로 했다.

✨select box 국가 변경 (한국, 일본, 필리핀)

select box

필요한 정보를 가진 object를 constants.js 파일에 선언하고, select box를 만들어보자.

// utils/constants.js
export const FIRST_CALCULATOR_OPTIONS = [
  {
    key: "KRW",
    value: "KRW",
    name: "한국(KRW)",
  },
  { key: "JPY",
    value: "JPY",
    name: "일본(JPY)",
  },
  {
    key: "PHP",
    value: "PHP",
    name: "필리핀(PHP)",
  },
];

//components/FirstCalculator.js
  ...
  const [exchangeRate, setExchangeRate] = useState({
    USDKRW: 0,
    USDJPY: 0,
    USDPHP: 0,
  });
  const [selectedExchangeRate, setSelectedExchangeRate] = useState({
    rate: 0,
    nation: "",
  });

  const getData = async () => {
    try {
      const { quotes } = await getExchangeRate();
      const { USDKRW, USDJPY, USDPHP } = quotes;
      setExchangeRate({ USDKRW, USDJPY, USDPHP });
      setSelectedExchangeRate({
        rate: USDKRW.toFixed(2),
        nation: "KRW",
      });
    } catch (e) {
      console.error(e)
    } 
  };

  useEffect(() => {
    getData();
  }, []);

  const onChangeOption = (e) => {
    const rate = exchangeRate[`USD${e.target.value}`].toFixed(2);
    const nation = e.target.value;
    setSelectedExchangeRate({
      rate,
      nation,
    });
  };
  ...
  return (
  ...
    <CalculatorBlock>
  	<CalculatorText>수취국가: </CalculatorText>
  	<select onChange={onChangeOption}>
  	{FIRST_CALCULATOR_OPTIONS.map((option) => {
    	const { key, value, name } = option;
    	return (
     		<option key={`option-${key}`} value={value}>
			{name}
		</option>
		);
	})}
	</select>
    </CalculatorBlock>
    <CalculatorBlock>
	<CalculatorText>환율: </CalculatorText>
	{selectedExchangeRate.rate} {selectedExchangeRate.nation}/USD
    </CalculatorBlock>
  ...

useEffect로 처음 렌더링될 때, 데이터를 받아와서 exchangeRate에 저장해주었다. option이 변경되면(클릭) selectedExchangeRate에 변경된 국가명과 환율을 저장하고, 그 값을 보여준다.

select box test

일단 처음 렌더링될 때, 보여주는 국가명이 한국인지 확인해보기로 했다.

// components/__test__/FirstCalculator.test.js
import { render, screen } from "@testing-library/react";
import FirstCalculator from "../FirstCalculator";

test("수취국가의 기본 값은 한국인가?", async () => {
  render(<FirstCalculator />);

  const option = await screen.findByRole("option", { selected: true });
  expect(option).toHaveTextContent("한국(KRW)");
});

renderscreen이 import된 것을 볼 수 있다. 여기서 render는 인자로 받는 JSX의 가상돔을 생성하는 함수이다. 앞서 작성한 FirstCalculator 컴포넌트를 전달받는다. screen은 생성된 가상돔에 접근하기 위한 전역 객체이다.
findByRolerole에 해당하는 요소를 찾아주는 메소드이다. role은 aria에서 사용하는 요소의 역할을 의미하는 속성이며 tag name과는 다르다. <button> <option> <a> 처럼 기본적으로 role을 가진 요소도 있다. 이들의 기본 role은 각각 button option link 이다. 더 자세한 정보는 아래 링크에서 확인할 수 있다.

https://www.w3.org/TR/html-aria/#docconformance
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles

결과적으로 해당 코드는 FirstCalculator 가상돔을 생성하고, 거기서 roleoption인 것들 중 선택된 것은 '한국(KRW)' 텍스트를 가지고 있나? 라는 의미이다. 결과는 passed

다음 테스트로 option이 잘 바뀌는지 확인해야하는데 거기까지 하진 못하고 다음으로 넘어갔다.

✨콤마, 소수점 처리

콤마 처리 함수

환율과 송금액은 소수점 둘째 자리까지 나와야하고, 3자리 마다 콤마(,)를 나타내주어야 한다. 먼저 콤마 찍는 함수부터 만들어 보자.

// utils/comma.js
export const comma = (x) => x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");

해석하면,
\B : 앞에 경계문자가 있다. (여기서는 숫자)
(?=regex) : positive lookahead. 뒤에 오는 것들 중에 해당하는 패턴이 있다.
(\d{3})+ : 숫자가 세개씩 있는데 이런 문자열이 1개 이상 반복된다.
(?!regex) : negative lookahead. 뒤에 오는 것들 중에 해당하는 패턴이 없다.
(?!\d) : 뒤에 숫자가 없다.

결과적으로 앞에는 숫자가 있으며, 뒤에는 세 자리 숫자로 구성된 하나 이상의 그룹이 있고, 그것들은 모두 뒤에 다른 숫자가 있어서는 안된다는 뜻이다. 해당하는 것들을 전부 찾아서 ','로 변경한다.

콤마 test

콤마가 제대로 찍히는지 test 해보자.

// utils/__test__/comma.test.js
import { comma } from "../comma";

test("정수 입력했을 때 콤마 잘 찍히나?", () => {
  expect(comma(123456789)).toBe("123,456,789");
});

passed.

소수도 잘 찍히는지 확인해보자.

// utils/__test__/comma.test.js
...
test("정수만 입력해도 소수점 잘 나오나?", () => {
  const num = 123456789;
  expect(comma(num.toFixed(2))).toBe("123,456,789.00");
});

test("소수 입력했을 때 콤마 잘 찍히나?", () => {
  expect(comma(123456.78)).toBe("123,456.78");
});

마찬가지로 passed. 이제 이 함수를 활용해서 결과값을 출력함으로써 마무리 해보자.

✨송금액 (입력 값)에 따른 수취 금액 유효성 검사

수취금액 유효성 검사

입력 값이 없거나 숫자가 아닐경우, 0이하, 10000 초과 일 경우에 송금액이 유효하지 않는다는 문구를 나타내주어야 한다. 정상 범위일 경우는 수취 금액은 얼마 입니다 라는 문구가 나타나야한다.

// components/FirstCalculator.js
 const [remittance, setRemittance] = useState(0); // 송금액
 const [receviedAmount, setReceviedAmount] = useState(0); // 수취금액
 const [isResult, setIsResult] = useState(false); // 결과값이 있는지
 const [isValidAmount, setIsValidAmount] = useState(true); // 입력이 정상 범위인지
 ...
 const onSubmit = () => {
    const parsedRemittance = parseFloat(remittance);
    setIsResult(true);
    if (
      parsedRemittance <= 0 ||
      parsedRemittance > 10000 ||
      isNaN(parsedRemittance)
    ) {
      setIsValidAmount(false);
      return;
    }
    setIsValidAmount(true);
    setReceviedAmount(
      (parseFloat(remittance) * selectedExchangeRate.rate).toFixed(2)
      (parsedRemittance * selectedExchangeRate.rate).toFixed(2)
    );
};
const amountMessage = (isValidAmount) => {
    return isValidAmount
      ? `수취금액은 ${comma(receviedAmount)} ${selectedExchangeRate.nation}
    입니다.`
      : "송금액이 바르지 않습니다";
};
...
return (
  ...
          {isResult && (
          <CalculatorBlock>
            <CalculatorText error={!isValidAmount}>
              {amountMessage(isValidAmount)}
            </CalculatorText>
          </CalculatorBlock>
  ...
 )

submit 버튼을 클릭하면, isResulttrue로 바뀌고, amountMessage가 동작하는데, 입력 값의 valid 여부에 따라서 amountMessage의 값이 바뀌도록 코드를 작성했다. 처음 작성한 코드는 valid 에 따라서 다른 결과값을 보여 주려면 submit 정상 동작을 한 번 시켜주었어야 했어서 처리하는 데 애 좀 먹었다.

수취금액 유효성 검사 test

먼저 송금액이 0일 경우 테스트 해보자.

// components/__test__/FirstCalculator.test.js
import { render, screen, fireEvent } from "@testing-library/react";

test("송금액 입력이 0일 경우 Submit 클릭 했을 때 수취금액 유효성 검사", async () => {
  render(<FirstCalculator />);
  const submitButton = await screen.findByRole("button");
  const input = screen.getByPlaceholderText("송금액을 입력해주세요");

  fireEvent.change(input, {
    target: {
      value: "0",
    },
  });

  fireEvent.click(submitButton);

  const text = screen.getByTestId("amountText");
  expect(text).toHaveTextContent("송금액이 바르지 않습니다");

앞에서 확인했던 render, screen 외에 fireEvent가 추가되었다. 이것은 가상돔과의 상호작용이 가능하도록 하는 객체로 이벤트를 발생시킬 수 있다.
fireEvent로 찾은 input의 value를 0으로 변경시키고, 버튼을 클릭하게 하여 가상돔에 결과 문구가 출력이 되도록 만들어주었다. 출력된 결과 문구를 가져오기 위해 data-testid 라는 속성을 주었다.

...
<CalculatorText error={!isValidAmount} data-testid="amountText">
  {amountMessage(isValidAmount)}
</CalculatorText>
...

이제 getByTestId로 해당 문구를 가져와서 toHaveTextContent로 기대값이 맞는지 확인해보면 passed 가 잘 나온다.

마찬가지로 10001 일때도 test 해보자

test("송금액 입력이 10001일 경우 Submit 클릭 했을 때 수취금액 유효성 검사", async () => {
  render(<FirstCalculator />);
  const submitButton = await screen.findByRole("button");
  const input = screen.getByPlaceholderText("송금액을 입력해주세요");

  fireEvent.change(input, {
    target: {
      value: "10001",
    },
  });

  fireEvent.click(submitButton);

  const text = screen.getByTestId("amountText");
  expect(text).toHaveTextContent("송금액이 바르지 않습니다");
});

결과는 passed

이제 정상 입력 범위일 때를 test 해보자.

test("송금액 입력이 0초과 10000 이하일 경우 Submit 클릭 했을 때 수취금액은 어떻게 되지?", async () => {
  render(<FirstCalculator />);
  const submitButton = await screen.findByRole("button");
  const input = screen.getByPlaceholderText("송금액을 입력해주세요");
  const exchangeRate = screen.getByTestId("exchangeRateText");
  fireEvent.change(input, {
    target: {
      value: "100",
    },
  });

  fireEvent.click(submitButton);

  const text = screen.getByTestId("amountText");

  expect(text).toHaveTextContent(
    `수취금액은 ${comma(
      (+input.value * +exchangeRate.textContent).toFixed(2)
    )} KRW 입니다.`
  );
});

마찬가지로 passed. 이로써 첫 번째 과제를 끝냈고, 리팩토링과 배포만 남았다.

♻️리팩토링

api 데이터 로드 custom hooks

// hooks/useExchangeRateLoad.js
import { useState, useEffect } from "react";
import { getExchangeRate } from "../utils/api";

export default function useExchangeRateLoad() {
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  const [data, setData] = useState(null);

  const getData = async () => {
    try {
      setIsLoading(true);
      const { quotes } = await getExchangeRate();
      setData(quotes);
    } catch (e) {
      setIsError(true);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    getData();
  }, []);

  return {
    isLoading,
    isError,
    data,
  };
}

getData 함수를 custom hooks로 만들고 데이터와 로딩 중인지 확인하는 state, 에러 발생시 state를 리턴해주었다.

// components/FirstCalculator.js
import useExchangeRateLoad from "../hooks/useExchangeRateLoad";

...
const { isLoading, isError, data } = useExchangeRateLoad();
...
if (isError) return <div>로딩 중 에러가 발생 했습니다.</div>;
if (isLoading) return <div>환율 정보 로딩중...</div>;
return (
  ...
)

hooks를 import 해서 에러와 로딩 중을 처리하고, 데이터를 사용했다.

useCallback으로 캐싱 및 리렌더 최소화

기존에 작성했던 JSX 내부 함수는 state가 변경되었을 때, 새로 생성되었다. 이를 useCallback으로 막아주자.

 const onChangeOption = useCallback((e) => {
   ...
 });

 const onSubmit = useCallback(() => {
   ...
 }, [remittance, rate]);

 const amountMessage = useCallback((isValidAmount) => {
   ...
 },[receviedAmount, nation]);
   

컴포넌트 분리는 굳이 안해도 될 것 같아서 리팩토링은 여기서 끝.

🚀배포

netlify에 손쉽게 배포했다. 근데 데이터가 받아지지 않는다...? 찾아보니 api 서버는 http인데, 배포 서버는 https 라서 안됐던 것. 이를 해결하기 위해 index.html에 메타태그를 추가해 주었다.

<meta
      http-equiv="Content-Security-Policy"
      content="upgrade-insecure-requests"
/>

하지만 api 서버에서 free plan은 http 요청이 안되도록 설정이 되어있어 상황은 그대로였다. 방법은 http로 배포하거나, 유로 plan을 구매하는 것이었고 우리는 http로 배포하기로 했다.

결과는 ? heroku로 http 배포 성공 !

http://wanted-infinity.herokuapp.com/

📝후기

좋은 팀원을 만나서 과제를 잘 마무리 할 수 있었다. 페어프로그래밍으로 진행했었는데, React 코드 작성, 리팩토링, 배포에 대해서 정말 많은 것을 배웠다. 테스트 코드도 배우기만 했고 직접 사용한 적은 이번이 처음이었는데 테스트 주도 개발이 아닌 개발 주도 테스트(?)가 된 것 같아 아쉽다. 사용 해본 거로 만족하자. 다음 과제부턴 더 어려워 질 것이고, 테스트 코드 작성하기도 빠듯할 것 같다.

아쉬운 점은 과제 api free plan에서 http 요청이 안되는 것, 그리고 월간 request가 최대 250회라 개발 중간에 자주 막혀서 새로 가입해야 했던 것 정도가 있겠다. 과제 제출 직전에 또 새로 가입했다.

heroku 배포 전에 AWS S3로 배포했었는데, 잘 안되어서 유료 plan을 사야되나 싶었는데 띠용? 자고 일어나니 킹갓 팀원 분께서 heroku로 http 배포 성공해주셨다. 만쉐이 !

profile
FE Developer

1개의 댓글

comment-user-thumbnail
2022년 2월 5일

킹갓기영님 파이팅입니다

답글 달기