Original article: React Best Practices – Tips for Writing Better React Code in 2022

이 년 전, 리액트를 배우기 시작했다. 그리고 지금도 여전히 소프트웨어 개발자로서 직장에서 일을 할 때나 사이드 프로젝트를 할 때 여전히 리액트를 쓴다.

그때 당시에는 정말 많은 "전형적인" 문제들을 만났다. 그러다 보니 검색해 보며 여러 모범 사례를 찾아다녔고, 작업 흐름에 녹여보곤 했다. 그 덕에 나는 물론 내 팀원의 사정까지 나아질 수 있었다.

막상 당시에는 최고의 방법으로 해결해 낼 수 없었던 과제들도 있었는데, 나중에 더 나은 방법으로 접근하고 싶었다.

그래서 이 가이드가 탄생하게 되었다. 이 년 전 처음 시작했을 때 나 자신에게 주는 팁 모음인 셈이다.

목차

뭐니 뭐니 해도, 모든 리액트 개발자가 마주하는 세 가지 주요한 과제에 대해 알면 좋을 것 같다. 잠재적인 도전 과제에 대해 인지하고 있다면, 모범 사례를 더 깊이 이해할 수 있으니 중요한 부분이다. 시작할 때부터 이런 태도를 견지한다면, 컴포넌트를 설계하거나 자신만의 프로젝트를 준비할 때 도움 될 것이다.

그럼, 이제 중요한 첫 번째 단계로써, 코드 예제를 통한 이론과 실무가 혼합된 세 가지 모범 사례를 소개하고자 한다. hello world 문제를 최소화하고 실제 세계에서 만났던 코드 이야기를 더 해보려고 한다.

리액트 개발자가 마주하는 세 가지 주요한 도전 과제

image
메인 이미지

매일 같이 리액트를 사용하던 지난 이 년 간, 리액트 개발자가 앱을 제작하는 동안 마주하는 세 가지 중요 문제에 대해 알게 됐다. 이런 문제들을 무시한다면 앱이 더디게 성장한다거나 하는 힘든 시간을 겪을 수도 있다.

그러니 앱을 세심하게 조직하는 과정에서, 이 세 가지를 염두에 두고 시간과 에너지를 아낄 수 있다.

⚙️ 유지보수성

유지보수성은 재사용성과 관련된다. 초기에는 애플리케이션과 컴포넌트는 매우 경량화된 상태로, 유지보수가 쉽다. 하지만 요구사항이 증가하기 시작하면, 컴포넌트는 복잡해지고 유지보수성이 떨어진다.

종종 각자 결과물을 대표하는 여러 가지 상황에 대한 컴포넌트를 볼 일이 있다. JSX가 조건부 렌더링(삼항 연산자나 단순히 && 연산자를 사용해서)으로 넘치고, 조건에 따라 클래스 이름이 적용되어 있거나 컴포넌트가 거대한 switch문을 사용하고 있는 것이다. 잠재적인 prop과 상태 값들이 저마다 다른 결과물을 책임지는 형국이다.

여기에 쓰인 기술들이 그 자체로는 아무 문제가 없다고 생각한다. 하지만 모든 사람이 컴포넌트가 유지보수성이 떨어지기 시작하고 이런 기술이 남용되고 있을 때 대한 감각을 키워야 한다고 생각한다. 이 글에서 나중에 이런 경우를 어떻게 하면 더 잘 다룰 수 있을지에 대해 배워볼 것이다.

문제는 (나 역시도 죄책감을 느끼지만) 컴포넌트가 더 복잡해지고 더 다양해질수록(다형성), 더 유지하기가 까다롭다는 것이다.

솔직히 말해서, 근본적인 원인이 게으름이나 충분한 경험 부족인 경우가 종종 있고, 이따금 이것이 컴포넌트를 더 유지보수하기 쉽고 깨끗하게 만들기 위해 적절히 리팩토링해야 한다는 시간적 압박 때문이기도 하다.

목격했던 또 다른 핵심적인 요소는 테스팅을 아예 안 하거나 적게 한다는 것이다. 많은 개발자가 좋아하는 종류의 업무가 아닌 것은 알지만, 장기적으로 보면 테스팅은 정말로 도움이 된다. 테스팅 그 자체는 이 글에서 중요한 주제로 다루지는 않지만, 내가 작성한 다른 블로그 포스트에도 관심을 가져주길 바란다.

🧠 리액트를 확실히 이해하기

리액트 개발자들이 겪는 또 다른 근본적인 문제는 실제로 리액트가 어떻게 작동하는지에 대한 확실한 이해가 없다는 것이다. 나 역시 그랬다.

많은 사람이 기초를 탄탄하게 하지 않은 상태에서 너무 빨리 중간에서 고급 단계의 개념으로 넘어가는 것을 봤다. 하지만, 이건 리액트에 국한된 이야기는 아니다. 프로그래밍에서 일반적으로 일어나는 문제긴 하다.

리액트를 제대로 이해하지 않으면 개발자로서 여러 문제를 겪을 수 있다. 다양한 생명 주기의 컴포넌트를 사용하고 싶었을 때 이걸 어떻게 사용해야 하는지 알 수 없어서 머리가 지끈거렸던 것이 기억난다. 그래서 다시 뒤로 돌아가 해당 주제에 대해 더 깊게 알 필요가 있었다.

이건 정말 중요한 문제 중 하나여서, 아래 블로그 포스트에서 한 챕터를 할애해 두었다.

📈 확장성

확장성이라는 과제는 유지보수성과 연결이 된다. 리액트에만 적용되는 것은 아니고, 일반적으로 소프트웨어에 다 적용되는 말이다.

UX뿐만 아니라 깔끔한 코드 패턴과 현명한 설계 역시 훌륭한 소프트웨어 제작에 필요하다는 것을 알게 됐다. 나의 경우, 확장할 수 있는가에 따라 소프트웨어의 질이 결정됐다.

많은 요소가 동작하기 시작하면서 소프트웨어의 확장 가능성이 커졌다. 이 글에서 가장 중요한 팁에 관해 배울 수 있다.

컴포넌트를 조작하고 프로젝트 구조를 조직할 때 유지보수성확장가능성에 대해 유념한다면, 중대한 리팩토링이 필요할 정도로 코드가 지저분해지는 상황을 덜 겪을 것이다.

리액트를 배우는 방법

자 이제 리액트 학습을 위해 몇 가지 모범 사례를 깊게 들여다보자.

리액트의 구성 요소 배워보기

image-1
Foundation의 알파벳 철자가 하나씩 나무 블럭에 적혀 있다.

위에서 이미 간단하게 이야기했지만, 구성 요소의 특징을 살피는 것이 리액트 학습에만 국한된 것은 아니다. 다른 기술이나 프로그래밍 언어에도 적용된다. 모랫바닥에 바로 고층 건물을 쌓고는 무너지지 않을 것이라 기대할 수는 없다.

기초를 제대로 이해하지 않고 리액트의 중간/고급 개념에 바로 뛰어든 개발자를 겪었다는 내 말이 이제 꽤 분명하게 들릴지도 모르겠다.

JavaScript에 비춰보아도 대체로 사실이다. 바닐라 JavaScript에 대한 확실한 토대 없이는 리액트가 이해되지 않는다는 말을 절대적으로 믿는 편이다.

자 그럼 이런 이야기들이 익숙하게 들리고, 리액트를 배우고자 하는 생각은 있지만 바닐라 JavaScript는 그다지 편하게 느껴지지 않는다면, JavaScript를 먼저 튼튼하게 하는 데 시간을 써야 한다. 미래에 두통을 겪을 시간을 아껴줄 것이다.

한번 읽어보고 싶다면, 리액트를 하기 전에 알아야 할 JavaScript의 가장 중요한 컨셉이라는 가이드가 도움 될 것이다.

하지만 나한테는 이런 기본기를 아는 것만으로는 충분치 않았다. 리액트가 실제로 어떻게 돌아가는지 아는 것이 필수적이었다. 좋은 리액트 개발자가 되고자 한다면(이 글을 읽고 있으니, 물론 그렇겠다고 생각하겠지만), 자신이 쓰는 도구는 알아야 한다고 생각한다. 이는 개발자로서도 클라이언트에게도 모두 도움이 된다.

한편으로는 애플리케이션 디버깅에 쓰이는 시간을 절약할 수 있다. 또, 계속해서 기본만 다시 배워야 해서 뒤로 돌아가지 않아도 되니 훨씬 더 효율적이다. 무엇에 대해서 이야기하는지 기본적으로 알게 되는 것이다.

물론 모든 내용을 다 알 수는 없으니 이 주제에 너무 스트레스받지 않아도 된다. 실무적인 문제와 더 많은 프로젝트를 겪으면 겪을수록 더 많이 배우게 될 것이다. 시작부터 좋은 지식을 제대로 무장한 상태로 말이다.

자, 그럼 이해가 됐으리라 생각된다. 이제 리액트의 탄탄한 기초를 다지기 위해서는 무엇을 어떤 순서로 제대로 배워야 하는지 궁금할 것이다.

정말 최소한 리액트 공식 문서의 주요 컨셉에 대한 챕터의 모든 주제에 대해서는 이해할 필요가 있다.

Hook을 다룬 챕터에 대해서도 어느 정도는 익숙해질 필요가 있다. Hook은 이제 관습화되어서 모든 곳에, 특히 서드파티 리액트 패키지에서 사용되고 있다.

useStateuseEffect 같이 더 많이 사용하는 내용도 물론 좋지만, useMemo, useCallback, useRef 같은 다른 내용에 대한 이해도 필수적이다.

고급 가이드라 불리는 다른 챕터도 있는데, 초기에는 무조건 읽어야 한다고 생각하지 않지만, 리액트를 따라가는 여정에서 꼭 한 번 읽어보기를 추천한다.

늘 그렇지만 실무 경험이 어느 정도 있는 상태에서 고급 주제를 이해하기가 훨씬 쉽지만, 더 일찍 이 지식을 알게 된다면 더 나을 것이다.

당연하게도 리액트 공식 문서만 따라가는 것으로 제한하지 않아도 된다. 구성요소를 다루는 온라인 강의를 수강하고 튜토리얼을 시청하거나 블로그 포스트를 읽는 것도 기초 쌓기에 도움 되는 활동 중 하나다. 무엇이 가장 최선인지 시험해 보자.

최소한으로 알아야 할 가장 중요한 컨셉을 고르라 한다면, 아래의 목록을 추천한다.

  • "상태(state)"란?
  • 클래스와 함수형 컴포넌트의 흥망성쇠
  • 컴포넌트 리렌더링이랑 무엇이고 어떻게 동작하는가
  • 리렌더링을 하는 방법
  • 컴포넌트의 다양한 생명주기와 이것을 어떻게 다룰 것인가
  • 가상 DOM
  • CSR(클라이언트 사이드 렌더링)과 SSR(서버 사이드 렌더링)의 일반적인 혹은 리액트에서의 장점
  • 제어 컴포넌트 vs 비제어 컴포넌트
  • 상태 끌어올리기
  • 적어도 하나의 전역 상태 관리 기법에 대해서 (Context API, Redux/Toolkit, Recoil)
  • 컴포넌트 패턴 (특히 적절한 패턴을 고르는 것에 관하여)

깨끗하고 성능 좋은, 유지 보수가 쉬운 리액트 컴포넌트 만드는 법 배워보기

모든 프로그래머의, 적어도 나에게는 꿈같은 일이다. 나한테는 훌륭한 프로그래머와 좋은 프로그래머를 가르는 자질이기도 하다. 언제나 배워야 하고, 개선해야 할 점이 있기 때문에, 절대로 완벽해질 수 없다는 사실이 재미있는 지점이다.

아래 모범 사례를 따라가다 보면 팀원뿐만 아니라 자기 자신도 도움받을 수 있을 것이다. 스타일 가이드를 통해 어떻게 코드를 짜는지에 대한 중요한 내용을 정의해 둔 개발팀을 본 적이 있다. 어떻게 생각하느냐고 묻는다면 훌륭한 아이디어라고 말할 것이다.

그중 일부는 아래와 같았다.

  • 함수형 컴포넌트 사용하기 (arrow-function 같은)
  • 인라인 스타일을 사용하지 않기
  • 적절한 import 구조를 유지하기 (서드파티 import를 먼저 --> 내부에서 쓰는 import를 마지막으로)
  • 커밋(commit) 전에 코드 형식 맞추기

등등이 있었다.

찾아보면 더 자세한 내용을 알 수 있을 것이다. 팀에 따라 다르긴 하지만, 개인적으로는 숙련된 개발자라면 모종의 자유를 가질 법하고, 그렇게까지 제한될 필요가 없다고 생각하기 때문에 지나치게 자세한 스타일 가이드는 좋아하지 않는 편이다.

하지만, 대개 스타일 가이드는 개요를 짜고, 모범 사례를 좇으며 팀 모두가 중요한 지점에 대해서 같은 생각을 공유한다는 것을 확실히 해주는 좋은 방법이라고 생각한다. 이는 팀워크와 결과물의 질을 정말 많이 올려준다.

그럼, 이제 깨끗하며, 성능이 좋고, 유지 보수하기 좋은 컴포넌트를 생성할 수 있는 모범 사례란 무엇인지 살펴보도록 하자. 편안한 마음으로, 뭔가 받아 적을 노트를 준비한 상태에서 즐겨보자!

📁 좋은 폴더 구조 만들기

리액트 애플리케이션 내부에 파일과 폴더를 정리해 두는 것은 유지보수성과 확장성에 필수적이다.

좋은 폴더 구조란 애플리케이션의 규모와 팀에 따라 다르므로 일반적으로 적용되는 대답은 없다. 특히 의견이 분분한 주제이다 보니 각자의 선호에 따라서도 좌지우지된다.

그럼에도 시간이 좀 지나고 나선, 다양한 규모의 애플리케이션에 적용할 만한 몇 가지 좋은 사례들이 진화했다.

이 굉장한 블로그 포스트에서는 애플리케이션의 다섯 가지 규모를 다루며 어떻게 파일과 폴더를 정리할 수 있을지에 대한 좋은 아이디어를 소개한다. 애플리케이션을 기획하고 시작할 때 이 글을 유념한다면 장기적으로 보았을 때 큰 차이를 만들 수 있을 것이다.

너무 과한 기술을 사용하느니 현재의 애플리케이션과 팀 규모에 가장 잘 맞는 적절한 설계를 유지하는 데 최선을 다하자.

👇 구조적인 import 순서 유지하기

이미 리액트에 대한 경험이 어느 정도 있다면, import 문이 넘쳐나 비대해진 파일을 본 적 있을 것이다. 서드파티 패키지 같은 외부 import부터 다른 컴포넌트나 유틸 함수, 스타일 등 많은 내부 import문이 섞여 있었을 것이다.

아래는 실제 코드의 일부를 발췌한 예시이다.

import React, { useState, useEffect, useCallback } from "react";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import Title from "../components/Title";
import Navigation from "../components/Navigation";
import DialogActions from "@material-ui/core/DialogActions"
import { getServiceURL } from '../../utils/getServiceURL";
import Grid from "@material-ui/core/Grid";
import Paragraph from "../components/Paragprah";
import { sectionTitleEnum } from "../../constants";
import { useSelector, useDispatch } from "react-redux";
import Box from "@material-ui/core/Box";
import axios from 'axios';
import { DatePicker } from "@material-ui/pickers";
import { Formik } from "formik";
import CustomButton from "../components/CustomButton";
현실에서는 import문이 55줄이 넘어가기도 한다.

뭐가 문제인지 눈치챘을 것이다. 대체 뭐가 서드파티고, 뭐가 로컬(내부) import 문인지 알기 어렵다. 그룹으로 묶이지 않아서 엉망이다.

아까 예시를 개선해 보자.

import React, { useState, useEffect, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Formik } from "formik";
import axios from 'axios';
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import Box from "@material-ui/core/Box";
import DialogActions from "@material-ui/core/DialogActions";
import Grid from "@material-ui/core/Grid";
import { DatePicker } from "@material-ui/pickers";

import { getServiceURL } from '../../utils/getServiceURL";
import { sectionTitleEnum } from "../../constants";
import CustomButton from "../components/CustomButton";
import Title from "../components/Title";
import Navigation from "../components/Navigation";
import Paragraph from "../components/Paragraph";
내부 및 외부 import문을 분리한 코드 예제

구조가 훨씬 더 깨끗해졌고 외부와 내부의 import문을 구분하기 쉬워졌다. 물론 (가능하다면 :)) named import 같은 기법을 사용해 여기서 더 최적화를 시도해 보아도 좋다. 이를테면 material-ui의 모든 컴포넌트를 한 줄로 import 해볼 수도 있다.

어떤 개발자는 세 영역으로 나누어 import문을 정리하기도 한다.

('react' 같이) 내장 -> 외부 (서드파티 노트 모듈) -> 내부 순이다.

직접 매번 관리하거나 linter를 사용해 보라. 리액트 앱에서 적당한 import 구조를 유지하도록 linter를 설정하는 방법에 대해 이곳의 훌륭한 글을 통해 확인할 수 있다.

📔 다양한 컴포넌트 패턴 알아보기

유지보수와 확장이 불가능한 스파게티 코드로 끝나버리지 않으려면, 리액트 개발자로서 더 경험이 많아질수록 다양한 컴포넌트 패턴에 대해 학습하는 것이 필요하다.

이것이 끝은 아닌 것이, 여러 가지 패턴을 알아가는 것 자체가 좋은 기초가 된다. 이것의 가장 중요한 면은 어떤 문제에 대해 어떤 패턴을 적용할지 그때를 알게 된다는 점이다.

모든 패턴이 특정 목적을 위해 쓰인다. 예를 들어 compound component pattern은 컴포넌트의 레벨이 깊어질 때 발생하는 불필요한 prop-drilling을 피하고자 한다. 즉, 다섯 개 정도 되는 레벨을 지나야 이 prop이 필요한 컴포넌트에 도달하는 경우가 발생한다면, 여러 가지 방식으로 컴포넌트를 조직해 보자.

여담인데 예전에 props-drilling에 대한 정말 많은 논의가 있었던 걸로 안다. 이것이 좋은지 나쁜지에 대한 매우 의견이 분분했다. 나의 경우 컴포넌트 레벨이 두 개 이상을 거쳐 prop이 전달된다면 다른 방법이나 패턴을 생각해보려고 하는 편이다.

개발자로 일할 때 훨씬 효율적일 뿐만 아니라 작성하는 컴포넌트도 훨씬 더 유지보수 가능하며 확장가능해진다. 이런 패턴을 사용할 줄 안다면 다른 개발자와 차별화된 리액트 개발자로 보일 것이다. 직접 리서치해 보기를 정말 권하는데, 나의 경우 이 Udemy 강의가 매우 도움이 되었다.

🔒 linter를 사용해서 규칙을 지키기

linter는 의존성 패키지의 import 순서를 식별하기 쉽게 해주는 데만 필요한 것이 아니다. 일반적으로 더 나은 코드를 짜도록 도와준다.

create-react-app을 사용할 때 보면 이미 ESLint가 설정되어 있지만, 완전히 내 식대로 새로 작성하거나 기작성된 규칙을 확장할 수도 있다.

linter란 작성하고 있는 JavaScript 코드를 관측하고 코드를 실행할 때 발생할 가능성이 높은 에러를 미리 알려준다. linter를 정말로 잘 사용하게 되기까지 시간이 좀 걸렸지만, 지금은 linter 없이 일하는 상황을 상상하기 어렵다.

linter를 설치하는 것과 규칙을 준수하는 건 별개의 일이다. linter를 끌 수도 있다. 특정 코드 한 줄이나 파일 전체에 대해서 비활성화할 수 있다. 이렇게 하는 것이 이해되는 상황이 몇 군데 있을 수 있지만, 내 경험으로 비춰보았을 때는 드물었다.

또 다른 훌륭한 이점은 스타일 점검을 조정해 볼 수 있다는 점이다. 특히 팀 단위인 경우 더 도움이 된다. 일단 코드를 어떻게 작성할지, 양식화를 어떻게 할지에 대한 관습을 합의하고 나면 ESLint를 JSPrettify 같은 것과 통합해 사용하는 것도 쉽다.

🧪 코드 테스트해 보기

개발자로서 테스트가 제일 좋아하는 종류의 일이 아니란 것은 잘 안다. 나도 역시 그랬다. 처음에는 테스팅이 불필요한데다가 방해되는 일로 느껴졌다. 단기적으로는 맞는 말처럼 들릴 수도 있다. 하지만 앱이 성장한다거나 하는 장기적 관점에서는 테스팅은 필수다.

테스팅이야말로 내가 하는 일에 전문성을 더해주고 소프트웨어를 고품질로 만들어주었다.

기본적으로 사람 손으로 직접 하는 수행하는 테스팅에는 아무 문제도 없으며 완전히 이걸 하지 말아야 할 이유는 없다. 그렇지만 새로운 기능을 추가하려고 한다거나 뭔가가 망가지지 않기를 바란다고 생각해 보자. 시간이 소모되는 업무로 느껴지며 휴먼 에러를 발생시킬 가능성이 있다.

테스트 코드를 작성하는 시간에는 이미 테스트를 통과하기 위한 코드를 머릿속으로 조직화하는 과정을 거치고 있는 셈이다. 어떤 위험성이 따라올는지 인식함으로써 이를 주시할 수 있게 되는 식으로 늘 도움이 되었다.

바로 코드를 짜는 과정에 뛰어들지 않으면서도 (나라면 절대 권장하지 않을) 목표에 대해 먼저 생각하는 것이다.

예를 들어서 "이 특정한 컴포넌트는 무얼 수행해야 할까? 테스트한다면 어떤 중요한 엣지 케이스가 발생할까? 컴포넌트를 오직 한 기능만 수행하도록 좀 더 단순화할 수는 없을까? .."

작성하려고 하는 코드에 대한 관점을 가질 때 이 목표를 지킬 수 있는 날카로운 집중력이 유지된다.

테스트는 일종의 문서화 방법으로도 기능할 수 있는데, 새로운 개발자가 처음 코드를 접할 때 소프트웨어의 각기 다른 부분이 어떻게 동작할 것인지를 이해하는 데 매우 도움이 된다.

그러니 괜히 잔업이 는다는 생각에 테스트를 피하지 말자. 현실에서는 일단 프로젝트가 자리 잡고 나서 미래의 야근을 피하게 해 줄 테니 말이다.

리액트 공식 문서의 "테스팅" 챕터를 확인해보시라. 리액트에서 테스팅에 대한 몇 가지 튜토리얼을 확인해 보고 소규모 TDD 앱을 바로 작성해 보거나 현재 진행 중인 앱에 바로 테스팅을 도입해 보라.

🧰 Typescript를 도입하기 (아니라면 적어도 defaultProps와 prop type 사용해보기)

소프트웨어 개발자로서 처음 우리 팀이 맡게 된 리액트 프로젝트가 이미 다른 회사에서 기본적인 부분을 이미 개발해 두었었다. 그 위에서 고객사의 프로젝트를 진행해야 했고, TypeScript도 이미 추가되어 있었다.

그때까지는 나와 팀원 모두 바닐라 JavaScript만 사용하다가 TypeScript에 대한 경험이 별로 없을 때였다.

그 프로젝트를 몇 주 진행하면서 우리는 TypeScript의 이점보다는 업무 흐름을 방해하는 존재로 느꼈다. 실제로는 TypeScript 경고를 피하려고 모든 타입은 any로 지정해 버렸으니 TypeScript를 유용하게 사용하고 있지도 않았다.

이러고 나자 프로젝트에서 아예 TypeScript를 제거하고 우리가 잘 아는 영역인 바닐라 JavaScript를 사용하자는 결론이 났다. 처음에는 괜찮았지만, 프로젝트가 더 복잡해지자 더 타입 에러가 발생하기 시작했다. 그때야 우리는 TypeScript를 완전히 제거해 버린 결정을 엄청나게 후회했다. 이런 일은 얼마든지 발생할 수 있으므로 미래를 위한 귀중한 경험을 한 셈이었다.

이런 상황 덕에 TypeScript에 두 번째 기회를 줘보자 싶어 남는 시간에 TypeScript를 배웠다. TypeScript로 사이드 프로젝트 몇 개를 해보고 나니, 이것이 없는 경우를 상상하기 어려워졌다.

TypeScript를 사용할 때의 장점을 몇 가지 나열해 보자면, 정적 타입 검사, IDE(intellisense)에서 더 나은 코드 완성도, 개발자 경험의 향상, 코드 작성하는 동안의 타입 에러 검출 등이 있다.

반대로 몇 가지 문제점도 당연히 있다. (Java나 C# 같은) 강타입 언어를 사용하지 않았다면 처음에는 TypeScript를 이해하기가 너무 어렵기 때문이다.

하지만 그럴 만한 가치가 충분하니 TypeScript를 사용해 보고 이를 경험해 보라. 이곳에서 리액트 애플리케이션에서 TypeScript를 사용하는 것의 장단점 개요를 이해할 수 있는 좋은 글을 읽을 수 있다. 이 튜토리얼에서는 TypeScript 환경에서 리액트 앱을 작성하는 방법도 배울 수 있다.

리액트 앱 내에 TypeScript를 도입하고 싶지 않은 이유가 있을 수도 있다. 그럴 수 있다. 하지만 최소한 적어도 prop으로 엉망이 되고 싶지 않다면, 컴포넌트에 prop-typesdefault-props을 써보는 것을 추천한다.

💎 lazy-loading / 코드 분리 시도해 보기

JavaScript나 React 세계에서 시간을 좀 보내셨다면, 번들링(bundling) 때문에 꽤 우여곡절이 많았을 것이다. 이 용어를 처음 들어보았다면, 리액트 공식 문서에서 어떻게 이야기하는지 한 번 살펴보자.

대부분 리액트 앱은 Webpack, Rollup 또는 Browserify 같은 도구를 사용해 파일을 "번들한" 상태다. 번들링이란 파일을 가져와 이를 "번들"이라는 하나의 파일로 병합하는 과정을 말한다. 이런 번들은 한 번에 전체 앱을 로드하는 웹 페이지에 포함될 수 있다.

기본적으로는 훌륭한 기술이지만, 앱이 커지면 꽤 골치 아파지기도 한다. 번들도 함께 비대해지기 때문이다. three.js 같은 서드파티 라이브러리를 사용한다고 할 때 특히 그렇다.

한 가지 위험한 점은 사용자가 비록 코드의 한 조각만 필요하다고 하더라도 번들은 항상 로드가 끝까지 완료되어야 한다는 부분이다. 이는 앱을 로드하기까지 불필요하게 오랜 시간이 걸리기 때문에 성능 문제로 이어진다.

이를 피하려면, 코드 분리하기(code splitting)라는, 사용자의 필요에 맞게 코드를 조각 단위로 쪼개는 기술이 있다. Webpack, Rollup, Browserify 같은 많이 쓰이는 번들러는 모두 코드 분리 기능을 지원한다. 제일 큰 이점이라면 여러 번들을 생성하고 이를 동적으로 로드할 수 있다는 점이다.

번들을 쪼개는 것은 사용자가 오로지 필요로 할 때만 lazy load를 할 수 있도록 도와준다.

식료품점으로 가서 바나나, 사과, 빵을 좀 집어 오고 싶어 한다고 가정해 보자. 가게에 있는 모든 종류를 사는 것이 아니라 그중 바나나, 사과, 빵을 골라 오는 상황일 것이다. 당신은 그저 한 범위의 일부분에만 흥미가 있을 뿐이다. 그러니 전부를 살 이유가 없지 않은가? 더 오래 걸리며 심지어는 더 비쌀 텐데.

앱이 거대해질수록 잠재적으로 발생할 수 있는 문제들에 대해 인지하고 이런 이슈를 제거할 수 있는 기술을 바로 손에 익히는 것이 정말 중요하다. 더 자세히 알고 싶다면 리액트 공식 문서에서 더 읽어볼 수 있다.

🗄️ 재사용 가능한 로직을 커스텀 훅으로 분리하기

리액트 공식 문서에 따르면,

훅이란, 컴포넌트의 위계를 변경하지 않으면서도 state 변화가 들어 있는 로직(stateful logic)을 재사용하도록 해 준다.

이 말은 즉, 클래스 컴포넌트와의 조합으로 이전에 사용했던 기술에 대한 더 나은 해결법이라 할 수 있다. 코드를 작성해 온 지 좀 되었다면, 고차 컴포넌트(HOC, Higher Order Components)render props를 사용해 본 기억이 날 것이다.

이미 다른 함수형 컴포넌트에서 사용되었던, state의 변화를 포함한 로직을 동일하게 사용하는 상황이라면, 커스텀 훅을 만들어보기에 정말 좋은 기회이다. 커스텀 훅에는 이 로직이 캡슐화되어 있고, 컴포넌트 안에 함수처럼 이 훅을 호출하기만 하면 된다.

스크린 사이즈에 따라 UI가 변경되며, 브라우저의 창이 (사용자에 의해) 직접 변경될 때마다 현재 창 크기를 계속 추적하고 싶은 상황에 대한 예시를 빠르게 훑어보자.

const ScreenDimensions = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });
  
  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    handleResize();
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return (
  	<>
    	<p>Current screen width: {windowSize.width}</p>
        <p>Current screen height: {windowSize.height}</p>
    </>
  )
}
출처: https://usehooks.com/useWindowSize/

보시다시피 해결법이 꽤 직접적인 데다가 이렇게 정의한다고 해서 문제가 될 것은 없어 보인다.

까다로운 부분은 지금부터다. 정확히 같은 로직을 다른 컴포넌트에서도 사용하고 싶다고 가정해 보자. 이 경우에는 현재의 스크린 사이즈를 기준으로 (하나는 스마트폰, 하나는 데스크톱용으로) 다른 UI를 렌더하려고 한다.

물론 해당 로직을 복사-붙여넣기 해도 된다. 하지만, DRY 원칙(역자 주-Don't Repeat Yourself)으로부터 알 수 있듯이, 이건 그렇게 좋은 습관이 아니다.

이 로직을 조정이라도 하고 싶다면, 두 컴포넌트 모두에서 수정해야 한다. 더 많은 컴포넌트에 이 로직을 붙여 넣을 때면 더 유지보수하기 어려워지고 더 에러가 나기 쉬워진다.

그렇다면 보통 바닐라 JavaScript에서는 어떤 식으로 할까? 로직을 캡슐화한 함수를 정의해 둬서 다양한 부분들에서 사용될 수 있도록 할 것이다. 훅으로 하고자 하는 것도 바로 이것과 같다. JavaScript 함수에서 크게 더해지는 것은 없지만, 리액트 훅이기 때문에 몇 가지 리액트만의 특장점을 가질 뿐이다.

커스텀 훅은 아래처럼 보일 것이다.

const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });
  
  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    handleResize();
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return windowSize;
}

이제 ScreenDimensions라는 컴포넌트에 손쉽게 호출할 수 있다.

const ScreenDimensions = () => {
  const windowSize = useWindowSize()
  
  return (
  	<>
    	<p>Current screen width: {windowSize.width}</p>
        <p>Current screen height: {windowSize.height}</p>
    </>
  )
}

다른 어떤 컴포넌트에라도 커스텀 훅을 호출해 컴포넌트에서 사용하고자 하는 변수(여기에서는 현재의 window size)에 반환값을 저장할 수 있다.

const ResponsiveView = () => {
  const windowSize = useWindowSize()
  
  return (
  	<>
    	{windowSize.width <= 960 ? (
          <SmartphoneView />
        ) : (
          <DesktopView />	
        )}
    </>
  )
}

🖥️ 효과적으로 에러 처리하기

많은 개발자가 효과적으로 에러를 처리하기를 종종 간과하거나 과소평가하곤 한다. 다른 모범 사례들처럼 이것 역시 초기에는 나중에 생각할 것으로 여겨진다. 일단 코드가 동작하도록 만들고 싶고 에러에 대해서 생각하느라 시간을 "낭비"하고 싶지 않은 것이다.

하지만 일단 경험이 쌓이면서, 에러 처리를 더 잘해두었더라면 에너지를(당연하게도 귀중한 시간) 무척 절약할 수 있었을 법한 지저분한 상황을 겪고 나면, 장기적으로 보았을 때 애플리케이션 내에 견고하게 에러를 처리하는 것이 필수적이라는 사실을 알게 된다. 특히 애플리케이션이 프로덕션으로 배포되고 나면 더욱 그렇다.

하지만 정확히 에러 처리하기가 리액트 세계에서 의미하는 바가 무엇일까? 각자 수행하는 역할이 몇 가지가 있다. 하나는 catch 에러고, 또 하나는 그에 맞는 UI를 다루는 것이며 마지막으로 이를 적절히 로그하는 것이다.

React Error Boundary

React Error Boundary는 커스텀 클래스 컴포넌트로, 애플리케이션 전체를 감싸는 데 사용된다. 물론 예를 들자면 더 세부적인 UI를 렌더하기 위해서 컴포넌트 트리 깊게 내려간 컴포넌트 주위를 감싸는 ErrorBoundary 컴포넌트를 컨테이너 삼아도 된다. 기본적으로 에러가 날 법한 컴포넌트 주위로 ErrorBoundary로 에워싸는 것이 좋은 모범 사례로 본다.

수명주기 메소드인 componentDidCatch()를 통해 렌더링 단계나 다른 자식 컴포넌트의 수명 주기 동안 에러를 감지할 수 있다. 이 단계에서 에러가 발생한다면, 감지된 부분이 상위로 올라가 ErrorBoundary 컴포넌트에서도 걸린다.

로깅 서비스를 (무척 추천하는 방법이다) 사용하고 있다면, 이 서비스를 연결하기 좋은 지점이기도 하다.

getDerivedStateFromError()라는 정적 함수는 렌더 단계 동안 호출되며 ErrorBoundary 컴포넌트의 상태값을 업데이트하는 데 쓰인다. 상태값에 따라 조건적으로 에러 UI를 렌더할 수 있다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    //log the error to an error reporting service
    errorService.log({ error, errorInfo });
  }

  render() {
    if (this.state.hasError) {
      return <h1>Oops, something went wrong.</h1>;
    }
    return this.props.children; 
  }
}

이 접근법의 가장 큰 문제점은 서버 사이드 렌더링이나 이벤트 핸들러에서 발생하는 비동기 콜백 에러가 범위 바깥이라 처리할 수 없다는 점이다.

범위 밖의 에러 처리를 위한 try-catch 사용해보기

이 방법은 비동기 콜백 내부에서 발생하는 에러를 잡는 데에 효과적이다. API에서 사용자의 프로필 데이터를 가져오고 있고, Profile 컴포넌트에서 이를 보여주고자 한다고 가정하자.

const UserProfile = ({ userId }) => {
	const [isLoading, setIsLoading] = useState(true)
	const [profileData, setProfileData] = useState({})
    
    useEffect(() => {
        // 비동기 함수로 분리하기 
        const getUserDataAsync = async () => {
        	try {
            	// API에서 사용자 데이터 가져오기 
            	const userData = await axios.get(`/users/${userId}`)

                // 사용자의 데이터가 존재하지 않는다면 (catch 블럭에서 걸리도록) 에러 발생시키기
                if (!userData) {
                	throw new Error("No user data found")
                }

                // 사용자의 데이터가 존재한다면 상태를 업데이트 하기 
                setProfileData(userData.profile)
            } catch(error) {
                // 어떤 에러든 로깅 서비스를 통해 로그하기 
            	errorService.log({ error })
                // 상태 업데이트 
                setProfileData(null)
            } finally {
                // 어떤 경우라도 로딩 상태는 초기화해 주기 
                setIsLoading(false)
            }
        }
        
        getUserDataAsync()
    }, [])
    
    if (isLoading) {
    	return <div>Loading ...</div>
    }
    
    if (!profileData) {
    	return <ErrorUI />
    }
    
    return (
    	<div>
        	...User Profile
        </div>
    )
}

컴포넌트가 마운트되고 나면, props로부터 받은 userId에 해당하는 사용자의 데이터를 받기 위해 API로 GET 요청을 시작한다.

try-catch를 사용하면 API를 호출하는 동안 발생할 수 있는 에러를 모든 감지할 수 있다. 예를 들어 이 경우에는 API로부터 404 혹은 500을 응답받게 될 것이다.

에러가 발생하면, catch 블럭으로 오게 되는데, 파라미터로써 에러를 받게 될 것이다. 로깅 시스템을 통해 에러가 로그되며, 상태를 업데이트하고 그에 맞는 커스텀 에러 UI를 보여줄 것이다.

(개인적으로 추천하는) react-error-boundary 라이브러리 사용해보기

이 라이브러리는 위에 소개한 두 기법이 모두 녹아 있다고 보면 된다. 리액트에서 에러 처리를 단순화하고 앞서 언급한 ErrorBoundary 컴포넌트의 한계를 극복했다.

import { ErrorBoundary } from 'react-error-boundary'

const ErrorComponent = ({ error, resetErrorBoundary }) => {
  
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
    </div>
  )
}

const App = () => {
  const logError = (error, errorInfo) => {
  	errorService.log({ error, errorInfo })
  }
  

  return (
    <ErrorBoundary 
       FallbackComponent={ErrorComponent}
       onError={logError}
    >
       <MyErrorProneComponent />
    </ErrorBoundary>
  );
}

이 라이브러리는 이미 살펴본 바 있는 ErrorBoundary 기능에 새로운 의미를 더한 컴포넌트로 구성되어 있다. prop으로써 FallbackComponent을 가지며 에러가 발생하면 렌더가 될 컴포넌트를 전달할 수 있게 한다.

onError라는 prop은 에러가 발생하면 호출되는 콜백 함수도 제공한다. 로깅 서비스에 에러를 로그할 때 사용하기에 무척 좋다.

도움이 될 만한 다른 prop도 있다. 더 알고 싶다면, 이 문서를 읽어볼 수 있다.

이 라이브러리는 useErrorHandler()라는 훅을 제공하는데, 비동기 코드든 서버 사이드 렌더링이든 이벤트 핸들러 같은 범위 바깥의 에러도 감지할 수 있게 해준다.

에러 로깅하기

효과적으로 에러를 감지하고 처리했다면 이다음은 적절하게 로깅해보기가 있다. 애플리케이션 내에 에러 처리 방법이 구축되었다면, 일관적으로 이를 로그할 줄 알아야 한다.

가장 자주 사용되는 방법은 오래됐지만 여전히 좋은 console.log라는 방법이다. 개발 단계에서 빠르게 확인하고자 한다면 좋지만, 프로덕션으로 배포된 상황이라면 쓸모없어진다. 사용자의 브라우저 내에서만 에러가 확인되니 전혀 효과적이지 않다.

프로덕션일 때 에러를 로깅하고자 한다면, 개발자인 여러분은 이를 고치기 위해 특정한 장소에서 에러를 확인하고 싶을 것이다.

이런 이유로 직접 로깅 서비스를 만들거나 서드파티 라이브러리를 사용할 필요가 생긴다.

서드파티 로깅 서비스를 사용한다면 개인적으로는 단연코 Sentry를 추천한다. 한 번쯤은 꼭 확인하길 바란다.

☝️ 앱 전체에서 key prop을 고유하게 유지하기

데이터의 렌더를 위해 배열을 매핑할 때, 요소마다 항상 key 속성을 정의해주어야 한다. key prop으로 각 요소의 index를 사용하는 것이 나도 그렇고 다른 사람들도 간단하고 흔하게 사용하는 방법으로 보인다.

리액트가 정확히 어떤 요소가 바뀌는지, 추가되는지, 혹은 삭제되는지 알 수 있도록 하므로 key prop 사용은 중요하다. 컴포넌트의 상태가 변하면서 UI가 새로운 상태를 가지고 리렌더링 되는 상황을 상상해 보자. 리액트가 업데이트되려면 이전 UI와 새로운 UI 간의 차이를 알아야 한다.

"무슨 요소가 추가/삭제 혹은 변화되었나?"

그러므로 key prop은 고유해야 한다. 현재 요소의 index를 사용해 특정 map 함수에서 오직 고유한 key prop을 사용하도록 하는 것이다.

현 시즌의 풋볼팀의 점수 기록을 보여주고 싶다고 할 때, 아래처럼 해볼 수 있을 것이다.

const SeasonScores = ({ seasonScoresData }) => {
	
    return (
    	<>
        	<h3>Our scores in this season:<h3>
        	{seasonScoresData.map((score, index) => (
    			<div key={index}>
        			<p>{score.oponennt}</p>
        			<p>{score.value}</p>
        		</div>
    		))}
        </>
    )
}

만약에 이 map 함수에서만 고유하다고 한다면, 나중에는 문제가 발생할 수 있다. 리액트 애플리케이션이나 심지어 한 컴포넌트 안에서도 map 함수를 하나 이상 갖는 상황이 꽤 흔하기 때문이다.

현재 선수 명단을 보여주는 컴포넌트 내부에 또 다른 map 함수가 있다고 해보자.

const SeasonScores = ({ seasonScoresData, currentRoster }) => {
	
    return (
    	<>
        	<h3>Our scores in this season:<h3>
        	{seasonScoresData.map((score, index) => (
    			<div key={index}>
        			<p>{score.oponennt}</p>
        			<p>{score.value}</p>
        		</div>
    		))}
            </br>
			<h3>Our current roster:<h3>
        	{currentRoster.map((player, index) => (
            	<div key={index}>
                	<p>{player.name}</p>
                    <p>{player.position}</p>
                    <p>{player.jerseyNumber}</p>
                    <p>{player.totalGoals}</p>
                </div>
    		))}
        </>
    )
}

결국 컴포넌트 내부에 많은 key가 중복되어 쓰이는 상황에 처했다. seasonScoresData 내부에 14개의 요소가 있고, currentRoaster 내부에 30개의 요소가 있다고 해보자. 0-13 숫자가 두 번씩 key prop으로 쓰였다. 고유한 key prop을 가진다는 목적을 충족하지 못한다.

이는 리액트가 오직 한 아이템에 대해서만 리렌더를 수행하고, 다른 부분을 생략할 수 있기 때문에 문제 상황으로 이어질 가능성이 있다. 혹은 UI 트리를 업데이트하는 과정에 비효율이 발생할 수도 있다. 더 자세한 예시를 확인해 보려면 이 팁 아래에 추천하는 블로그 포스트 글을 한 번 확인해 보라.

이런 원치 않는 상황을 피하려면, 늘 애플리케이션 전체에서 고유한 key를 사용해야 한다. 이상적으로는 가능하다면 배열의 각 아이템이 각자의 고유한 id를 갖는 것이리라. 하지만, 늘 이럴 수만은 없기 때문에, uuidv4 같은 외부 라이브러리를 통해 고유한 id 값을 생성시킬 수 있다.

이를 유념해 두고 두 배열의 모든 아이템이 id 속성을 갖는다는 가정을 해보자. 컴포넌트는 아래와 같을 것이다.

const SeasonScores = ({ seasonScoresData, currentRoster }) => {
	
    return (
    	<>
        	<h3>Our scores in this season:<h3>
        	{seasonScoresData.map((score, index) => (
    			<div key={score.id}>
        			<p>{score.oponennt}</p>
        			<p>{score.value}</p>
        		</div>
    		))}
            </br>
			<h3>Our current roster:<h3>
        	{currentRoster.map((player, index) => (
            	<div key={player.id}>
                	<p>{player.name}</p>
                    <p>{player.position}</p>
                    <p>{player.jerseyNumber}</p>
                    <p>{player.totalGoals}</p>
                </div>
    		))}
        </>
    )
}

더 자세히 알고 싶다면, 해당 주제에 대해 이 블로그 포스트를 참고할 수 있다.

더 나은 리액트 코드 작성을 위한 팁 - 화룡점정

이미지

이 가이드를 집 짓기의 과정에 비교해보고 싶다. 첫 번째로 리액트의 구성 요소를 배우기가 애플리케이션을 짓는 견고한 토대다. 두 번째로 깨끗하고 성능 좋은, 유지 보수가 쉬운 리액트 컴포넌트 만드는 법 배워보기는 벽을 세우는 과정이다.

그렇다면 이제부터는 집을 완성하기 위해 꼭대기에 지붕을 세우는 것이라 할 수 있다. 화룡점정이라 부르는 이유다. 이번 팁은 더욱더 세세할 것이다.

대부분은 이전에 언급한 것보다는 강제성이 없지만, 그래도 이들을 제대로 사용할 줄 안다면 큰 차이를 만들어낼 수 있다.

🪄 더 이르게 useReducer hook을 사용해 보기

리액트에서 가장 자주 사용되는 훅 중 하나는 useState일 것이다. 컴포넌트를 생성하고 시간이 지나자 여러 상태로 가득해지곤 한다. 그러니 컴포넌트가 useState 훅으로 넘쳐나는 것도 당연하다.

const CustomersMap = () => {
  const [isDataLoading, setIsDataLoading] = useState(false)
  const [customersData, setCustomersData] = useState([])
  const [hasError, setHasError] = useState(false)
  const [isHovered, setIsHovered] = useState(false)
  const [hasMapLoaded, setHasMapLoaded] = useState(false)
  const [mapData, setMapData] = useState({})
  const [formData, setFormData] = useState({})
  const [isBtnDisabled, setIsBtnDisabled] = useState(false)
  
  ...
  
  return ( ... )
}

여러 가지 useState 훅이 생기는 것은 규모는 물론 컴포넌트의 복잡성까지 커지고 있다는 분명한 신호다.

더 작은 컴포넌트를 만들어 상태나 JSX를 옮겨둘 수 있다면, 이렇게 하는 것이 좋다. useState 혹과 JSX를 한 번에 깨끗하게 정리할 수 있다.

위 예시에서 아래 두 state를 별개의 컴포넌트에 담아 모든 상태와 JSX가 양식(form)에 대해서만 다루도록 할 수도 있다.

하지만 도무지 말이 안 돼서, 컴포넌트 안에 이렇게 많은 state를 가지고 있어야만 하는 상황이 있을 수도 있다. 컴포넌트의 가독성을 개선하려면 useReducer 훅을 사용해 보자.

공식 리액트 문서는 이렇게 설명한다.

useReducer는 보통 여러 부차적인 값을 포함하는 복잡한 상태 로직이 있는 경우나 다른 상태가 이전의 상태를 의존하는 상황에서 useState보다 선호된다. useReducer는 또한 콜백 대신 dispatch를 전달하면서 깊은 업데이트를 수행하는 컴포넌트를 위해 성능을 최적화할 수도 있다.

이 말을 생각해 보면, 컴포넌트는 useReducer를 사용하면 아래처럼 바꿔볼 수 있다.

// 초기 상태
const initialState = {
  isDataLoading : false
  customerData: [],
  hasError: false,
  isHovered: false,
  hasMapLoaded: false,
  mapData: {},
  formdata: {},
  isBtnDisabled: false
}

// REDUCER
const reducer = (state, action) => {
  switch (action.type) {
    case 'POPULATE_CUSTOMER_DATA':
      return {
        ...state,
        customerData: action.payload
      }
    case 'LOAD_MAP':
      return {
        ...state,
        hasMapLoaded: true
      }
    ...
    ...
    ...
    default: {
      return state
    }	
  }
}

// COMPONENT
const CustomersMap = () => {
    const [state, dispatch] = useReducer(reducer, initialState)
    ...
    
    return ( ... )
}

컴포넌트 자체가 매우 깨끗해졌고, 공식 문서에서 읽은 것처럼 몇 가지 이점이 함께 눈에 띈다. Redux에 익숙하다면 reducer의 개념이나 이를 사용하는 방법이 그다지 새롭지는 않을 것이다.

나는 예를 들어 컴포넌트에 사용한 useState 훅이 네 개가 넘어가거나, state 자체가 그냥 boolean 값 이상으로 복잡해진다면 useReducer를 사용하는 규칙을 세워두고 있다. 양식에 사용되는 객체 하나가 더 깊고 복잡해진 경우가 될 수도 있겠다.

🔌 boolean props에는 약어 사용해 보기

컴포넌트에 boolean 값을 전달하는 경우가 종종 있다. 많은 개발자들은 아래처럼 사용하곤 한다.

<RegistrationForm hasPadding={true} withError={true} />

그런데 prop 자체가 참인지 (prop이 전달된다면) 거짓인지 (prop이 빠져 있다면) 둘 중 하나인 상황이므로 굳이 이렇게 작성할 필요가 없다.

아래가 훨씬 깔끔한 접근법이다.

<RegistrationForm hasPadding withError />

👎 string props에는 중괄호를 피하기

방금 살펴본 팁은 string prop에도 적용해 볼 수 있다.

<Paragraph variant={"h5"} heading={"A new book"} />

prop에 바로 string을 직접 쓸 수 있기 때문에 이런 경우에는 중괄호를 사용하지 않아도 된다. JSX 요소에 className을 바로 사용하고 싶다고 해도 string 형태로 직접 사용할 수 있다.

string이 아니라 JavaScript 표현식을 사용하고 싶다면 중괄호를 사용해야 한다. 가령 숫자나 객체를 사용하고 싶을 때가 있을 것이다. template string도 마찬가지다. (내가 그랬던 것처럼 헷갈리지 마시길)

단순한 string이라면 예시에서처럼 그냥 이렇게 사용하면 된다.

<Paragraph variant="h5" heading="A new book" />

🧹 props를 전개(spread)할 때 non-html 속성을 삭제하기

빠르게 예제를 살펴보자.

const MainTitle = ({ isBold, children, ...restProps }) => {
	
  return (
    <h1 
      style={{ fontWeight: isBold ? 600 : 400 }}
      {...restProps}
    >
      {children}
    </h1>
  )
}

h1 태그를 렌더하고, prop 몇 가지를 추출하고, h1 태그에 삽입될 수 있는 다른 잠재적인 prop을 전개하는 컴포넌트를 하나 만들었다. 아직까지는 좋아 보인다.

이제, 다른 컴포넌트 안에 이를 사용해서 수동으로 h1을 굵게 할지 말지를 해볼 수도 있다.

// 굵은 제목
const IndexPage = () => {
	
  return (
    <>
      <MainTitle isBold>
        Welcome to our new site!
      </MainTitle>
      ...
    </>
  )
}
// 굵은 제목이 아닌
const AboutPage = () => {
	
  return (
    <>
      <MainTitle>
      	Some quick lines about us!
      </MainTitle>
      ...
    </>
  )
}

에러나 경고 메시지 없이 완벽하게 동작하는 것처럼 보인다. 흥미로운 부분은 이 h1 태그에 직접 전개된 다른 prop을 쓰면서부터다.

id, class 같은 적합한 HTML 속성을 사용한다면 에러 없이 모든 것이 잘 동작할 것이다. ("className"은 "class"가 되는 것을 명심하라)

const IndexPage = () => {
	
  return (
    <>
      <MainTitle isBold id="index-main-title" className="align-left">
        Welcome to our new site!
      </MainTitle>
      ...
    </>
  )
}

위의 모든 prop은 {...restProps}를 사용하고 있으니 h1의 속성으로써 추가될 것이다. 뭐가 됐든 여기에 추가한, 빼내지 않은 props는 h1 태그에 삽입될 것이다.

여러 상황에 훌륭하게 적용되지만, 동시에 문제가 있기도 하다.

// Page Component
const IndexPage = () => {
	
  return (
    <>
      <MainTitle isBold hasPadding>
        Welcome to our new site!
      </MainTitle>
      ...
    </>
  )
}

// MainTitle Component
const MainTitle = ({ isBold, children, ...restProps }) => {
	
  return (
    <h1 
      style={{ 
        fontWeight: isBold ? 600 : 400,
        padding: restProps.hasPadding ? 16 : 0
      }}
      {...restProps}
    >
      {children}
    </h1>
  )
}

위 코드에서 MainTitle 컴포넌트에 hasPadding 이라는 새로운 prop을 추가했고, 이는 필수 prop이 아니다. 컴포넌트 내부에는 props로부터 이를 추출하고 있지 않고, restProps.hasPadding을 통해 호출하고 있다.

코드는 동작하지만, 브라우저를 열어보면, h1 태그에 적용하려고 했던 hasPadding이 non-HTML 속성이라는 경고를 보게 될 것이다. h1 태그의 {...restProps}isBold처럼 추출되지 않는 hasPadding 때문이다.

이를 피하려면, non-HTML 요소부터 prop에서 늘 먼저 추출해, JSX 요소에 전개하려고 하는 restProps에 적합한 HTML 속성만 남도록 해야 한다.

예제는 이렇게 될 것이다.

// Page Component
const IndexPage = () => {
	
  return (
    <>
      <MainTitle isBold hasPadding>
        Welcome to our new site!
      </MainTitle>
      ...
    </>
  )
}

// MainTitle Component
const MainTitle = ({ isBold, children, hasPadding, ...restProps }) => {
	
  return (
    <h1 
      style={{ 
        fontWeight: isBold ? 600 : 400,
        padding: hasPadding ? 16 : 0
      }}
      {...restProps}
    >
      {children}
    </h1>
  )
}

브라우저의 콘솔에 불필요한 경고가 넘쳐난다면 너무 지저분할 것이다. 디버깅할 때라면 더더욱.

이 주제에 대해 더 정보를 알아보고 해결 방법이 궁금하다면, 리액트 문서의 이 주제를 살펴보시라.

🔥 snippet extensions 사용하기

비주얼 스튜디오 코드에서는 생산성을 많이 높여주는 특정 확장 프로그램이 있다. 이런 확장 프로그램 중 하나는 snippet이다.

그 많은 보일러플레이트 코드를 여러 번 작성하지 않도록 해준다는 점이 큰 장점이다. 새로운 컴포넌트를 계속 만드는데, 같은 걸 계속 타이핑한다고 생각해 보라.

import React from 'react'

const GoogleMap = () => {

}

export default GoogleMap

snippet을 사용한다면 예를 들어 rafce만 작성하고 탭을 누르면, 같은 보일러 플레이트 코드가 생성된다. 시간 절약은 물론 개발 속도도 빨라진다.

하지만 주의해서 사용할 것! 모든 개발자에게 권하지는 못하겠다. 내 생각에는 초보자들은 snippet을 쓰는 대신 보일러 플레이트를 일일이 손으로 타이핑해보아야 한다. 이렇게 하면 근육 기억이 생겨나 배운 것을 더욱 분명하게 해 줄 것이다.

너무 많이 타이핑해서 잠자면서도 할 수 있을 정도라면, 그래서 지겹다면 snippet을 사용하기에 알맞다.

추천하는 확장 프로그램 목록

ES7+ React/Redux/React-Native snippets
ES7+ React/Redux/React-Native snippets
JavaScript (ES6) code snippets
JavaScript (ES6) code snippets
Mithril Emmet
Mithrill Emmet

❌ div가 불필요할 때는 fragment 사용하기

리액트 컴포넌트는 최상위에서 오직 한 HTML 태그만 렌더할 수 있다. 나란히 두 요소를 렌더하고자 한다면, 잘 알려진 인접한 JSX 요소는 하나의 태그로 닫혀야 한다(Adjacent JSX elements must be wrapped in an enclosing tag.)라는 에러를 만나게 될 것이다.

const InfoText = () => {
	
  // 에러가 날 것이다.
  return (
    <h1>Welcome!</h1>
    <p>This our new page, we're glad you're are here!</p>
  )
}

뭘 할 수 있을까? fragment로 감싸면 된다. 리액트에서 허용되며, 브라우저에서도 추가적인 HTML 요소가 렌더되지 않는다.

const InfoText = () => {
	
  return (
  	<>
      <h1>Welcome!</h1>
      <p>This our new page, we're glad you're are here!</p>
    </>
  )
}

div 태그로도 물론 가능하다. 하지만 div 다음에 또 div를 사용하면 브라우저에서 소위 div 지옥이라고 말하는, div 태그가 중첩되는 말도 안 되는 상황을 만나게 될 것이다.

그러니 리액트에서 감싸는 역할을 할 태그가 필요하다면, 불필요하게 HTML 태그를 사용하는 대신, 간단하게 fragment를 사용하자.

👈 children이 불필요할 때 셀프 클로징 태그 사용하기

경험에서 비춰보자면, 이 팁은 종종 간과되지만, 적은 노력으로 코드가 정말 훨씬 깔끔해지게 해 준다.

리액트에서는 자식 요소를 컴포넌트에 전달할 수 있고, children 속성을 통해 여기에 접근할 수 있다. 이런 컴포넌트를 가리켜 composite component라 부른다.

이런 경우 당연히 여는 태그와 닫는 태그가 존재한다.

<NavigationBar>
  <p>Home</p>
  <p>About</p>
  <p>Projects</p>
  <p>Contact</p>
</NavigationBar>

하지만 자식 요소가 필요하지 않다면, 여는 태그와 닫는 태그를 사용하는 것이 이해가 안 될 것이다. 그렇지 않은가?

<NavigationBar></NavigationBar>

이렇게 하는 대신, HTML에서 마찬가지로 자식 요소를 갖지 않는 input 태그같이 셀프 클로징 요소처럼 컴포넌트를 사용해 보는 것을 추천한다.

<NavigationBar />

훨씬 보기에 깔끔해졌다. 안 그런가?

✅ 흔한 이름 표기법 따라 하기

이름 표기법의 진짜 의미는 다루고 있는 요소가 어떤 타입인지 쉽게 인지하고, 코드 안의 무언가들이 커뮤니티에서도 통용되도록 하기 위함이다.

내 관점에서는, 따라 하면 좋을 법한, 리액트와 JavaScript에 관련된 이름 표기법이 두 가지가 있다.

컴포넌트, interface, type 별칭에는 파스칼 케이스를 쓰기

// React component
const LeftGridPanel = () => {
  ...
}

// Typescript interface
interface AdminUser {
  name: string;
  id: number;
  email: string;
}

// Typescript Type Alias
type TodoList = {
	todos: string[];
    id: number;
    name: string;
}

변수, 배열, 객체, 함수 등 JavaScript 데이터 타입에는 카멜 케이스를 쓰기

const getLastDigit = () => { ... }

const userTypes = [ ... ]

파스칼 케이스로 리액트 컴포넌트 이름을 짓는 것은 특히 중요하다. 리액트에 맞게 linter를 설정해 둔 상태에서 카멜 케이스로 컴포넌트의 이름을 지었다면, 그리고 그 안에서 훅을 사용했다면 훅은 오직 컴포넌트 내부에서만 사용되어야 한다는 경고 메시지를 계속 만날 것이다. linter가 리액트 컴포넌트는 파스칼 케이스인지 아닌지로 인식되기 때문이다.

불편해도 이미 존재하는 이름 표기법을 잘 따라간다면 쉽게 고칠 수 있다.

🧨 XSS 공격 예방을 위해 코드를 깨끗하게 하기

리액트에서 특정 요소에 dangerouslySetInnerHTML 속성을 사용해야 하는 상황이 있을 수 있다. 본질적으로 JavaScript의 innerHTML에 상응한다.

그러므로 이를 사용한다는 것은, 리액트로부터 직접 HTML을 작성한다는 것이다.

div에 HTML string을 렌더하는 예시를 생각해 보자. string은 이미 HTML 문법이 지정된 rich 텍스트 에디터로부터 받아온다.

const Markup = () => {
  const htmlString = "<p>This is set via dangerouslySetInnerHTML</p>"
  
  return (
    <div dangerouslySetInnerHTML={{ __html: htmlString }} />
  )
}

dangerously라는 용어는 일부러 붙게 됐다. 이 속성을 쓸 때면 cross-site-scripting(XSS) 공격이 들어올 수도 있기 때문이다. 그러니 이 코드를 사용하려고 한다면, 먼저 깨끗하게 만드는 것이 필수다.

dompurify라는 좋은 라이브러리가 있으니, 도움이 될 것이다.

마지막

와, 정말 재미있지 않은가? 머릿속에서 지난 시간 축적된 모든 것들을 꺼내놓으려고 애썼다. 내 경험을 공유하면서, 리액트를 배우고 개발하는 누군가가 어려운 시간을 피할 수 있으면 하는 마음으로 가이드를 쓰게 됐다.

물론 여기서 놓친 더 많은 중요한 모범 사례들을 고려해 볼 수도 있다. 좋은 자세다. 이 가이드에 어떤 걸 추가하고 싶은지 듣고 싶다.

하나 기억할 것은, 늘 나에게 적합한 것을 적용하는 태도다. 무조건 다 수용하지 말고, 자신의 상황에 도움이 될 만한 것을 생각해 보라. 그런 다음 자신만의 모범 사례에 추가하는 것이다.

인스타그램에서 개발자로서의 삶에 대한 여정이나 유용한 통찰을 확인할 수 있다. 항상 여러분을 돕는 것과 내가 받을 수 있는 피드백에 대해 행복하게 지내고 있을 것이다. 그러니, 편히 방문해 주시길.