Gatsby v3가 기존 v2에 비해 변경된 점이 많아서 마이그레이션을 계속 미루고 있었다. 기존 사이트는 모노리포 아래에 메인 웹사이트와 한국어 웹사이트를 별도 패키지로 구성하고 있었는데 공유하는 컴포넌트를 양쪽에서 사용할 수 있는건 좋긴 했지만 매번 글을 작성할 때마다 지나치게 많은 시간과 리소스를 사용했었다. 여름학기가 끝난 기념으로 재빨리 웹사이트를 정리했다.

계획

일단 마이그레이션 문서를 확인했다. 변경된 부분이 참 많았다. 하하하. 그냥 새 스타터를 받아서 하나씩 옮기는 쪽으로 전략을 바꿨다. 마이그레이션 과정에서 해야 할 일을 정리했다.

  • static 폴더 정리하기: 기존 워드프레스에서 옮겨올 때 대부분 리소스를 wp-content 폴더 안에 저장한 그대로 옮겨온 탓에 워드프레스도 아닌데 wp-content가 계속 존재했다. 각 리소스를 사용하는 포스트를 폴더 형태로 리소스까지 함께 보관하도록 변경한다.
  • mdx 적용
  • 분리된 사이트를 통합: 템플릿을 쉽게 적용할 수 있는 구조를 만든다.
  • 플러그인 마이그레이션: 직접 만든 플러그인, 설치해서 사용하는 플러그인 확인하기.
  • sitemap, rss를 영문과 국문 별도 설정
  • noindex 설정 추가
  • 답답하지 않은 디자인

과정

파일 정리

먼저 static 폴더를 정리하는 일을 가장 하고 싶었다. 그동안 사이트가 분리되어 있던 탓에 늘 어떤 방식으로든 경로 처리 문제가 있었다. 웹사이트 프리픽스를 지정할 수 있도록 gatsby에서 지원하고 있긴 하지만 플러그인마다 동작이 보장되어 있질 않았다. 그런 이유로 gatsby-remark-sharp 같은 플러그인은 제대로 동작하지 않았다. 이참에 wp-content 폴더도 정리하고 모든 정적 리소스도 함께 관리하도록 구조를 변경했다. sharp에서 지원 안하는 파일용으로 gatsby-remark-copy-linked-files도 설치했다.

스크립트를 작성해서 해당 리소스를 사용하는 포스트를 검색해서 리소스와 포스트를 폴더로 묶었다. 처음엔 쉘스크립트로 작성하다가 답답해서 js로 작성했고 생각보다 금방 할 수 있었다. (문자열 찾는 작업은 ripgrep-js를 활용했다.) 불필요한 크롭 이미지와 어디에도 연결되지 않은 리소스 모두 제거할 수 있었다.

Gatsby 관련

mdx는 가장 기대하고 얼른 설치해보고 싶은 것 중 하나였는데 생각보다 손봐야 할 부분이 많아서 일단은 remarkMarkdown을 유지하기로 했다. 다음에 기회가 된다면 0순위 작업이 될 것 같다.

Gatsby는 폴더 구조대로 url을 생성하는 것이 기본이지만 frontmatter 에서 주소를 직접 지정하는 플러그인을 쓰고 있다. 파일 구조는 언제든 바꿔도 url이 유지되었으면 해서 만든 플러그인이었는데 v3에서도 큰 문제 없이 동작했다.

lang과 type을 기준으로 템플릿을 찾아서 페이지를 만들도록 작성했다. 카테고리와 태그 페이지 생성도 기존 코드에서 아주 조금 바꾸는 것으로도 충분했다.

sitemap, rss는 동일한 플러그인을 여러 번 config에 넣는 것으로 여러 페이지를 만들 수 있었다. 다만 gatsby-plugin-sitemap은 생각과 다르게 filterPages가 동작하는 문제로 excludes만 적용했는데 해당 플러그인에서 사용하는 minimatch가 생각처럼 동작하지 않았다. 차라리 정규표현식으로 되어 있으면 편할 텐데. 일단은 sitemap이 크게 급한 상황은 아니니까 대략 숨겨야 하는 페이지만 숨기도록 수정했다. 이 과정에서 noindex 페이지도 몇 설정했다.

전 디자인도 크게 답답하지는 않았는데 너무 삭막한 것 같아서 조금 손봤다. styled-components로 작성했던 컴포넌트를 쉽게 옮겨 올 수 있었다. 다크 테마 설정은 추후에 하기로 하고 일단 뒀다.

대부분 플러그인은 문제 없이 동작했다.

  • gatsby-remark-attrremarkjs의 판올림으로 더이상 동작하지 않는다. 자주 쓰는 기능 중 하나였는데 아쉽다. common markdown의 directives를 지원하는 remark-directive를 설정했는데 attr처럼 동작하지는 않지만 그럭저럭 문제 없이 동작하긴 한다.
  • 그 사이에 netlify도 성능이 많이 좋아졌고 기능도 많아졌다. 빌드 이미지도 변경하고 캐시도 설치했다. 예전엔 gatsby-plugin-netlify-cache가 있었는데 deprecated 되었고 netlify에서 만든 플러그인, netlify-plugin-gatsby를 설치했다. 기존 플러그인과 동시에 설정하면 충돌이 있어서 둘 중 하나를 골라야 한다.

Netlify 관련

Netlify에서 사용중인 빌드 이미지가 오래되어서 해당 설정도 변경했다. 다만 빌드 중에 설치된 라이브러리를 제대로 인식하지 못하는 문제가 있었다. 내 경우에는 설치된 ruby 버전을 인식 못해서 새로 받는 일이 반복되었다. Site setting > build & deploy > build setting 에서 Edit setting 후 동일한 리포지터리를 다시 설정하고 저장하면 해결 된다.

Netlify Forms를 사용하지 않는다면 Site setting > build & deploy > form detection 설정을 끈다. 배포 속도가 향상된다.

결과

전체 배포 시간이 평균 4분~6분에서 1분 30초 선으로 줄어들었다.


워드프레스에서 Gatsby로 변경한 이후로 버전 업데이트는 좀 번거롭지만 보안과 서버 관리에 대한 걱정은 확실히 없어졌다. 다만 서드파티 플러그인이 버전을 따라가지 못하거나 하는 문제는 어디나 비슷한 것 같다. 그런 면에서 주요 플러그인을 모노리포에서 같이 관리하는 gatsby의 전략이 참 멋지다.

블로그를 처음 옮길 때와 달리 정적 호스팅 선택지가 많아졌다. 지금은 netlify를 계속 쓰고 있긴 하지만 Gatsby Cloud라든지 Azure 에서 제공하는 서비스도 확인해보고 싶다.

Remark에 흥미가 많이 생겼다. Rehype이나 mdx를 어떻게 지원하는지, 조만간 더 살펴보고 기능과 구조를 정리해보고 싶다.

늘 그렇듯 업데이트도 한 김에 좀 더 부지런히 글도 쓰고 생각도 정리하는 기회가 되었으면 좋겠다.

비주얼 타이머를 개발해서 2019년 6월에 출시했고 1년여 시간이 지나 1만 건 다운로드를 넘겼다. 현재는 목표로 했던 기능은 대부분 구현했고 성능 문제 개선, 사용자 피드백 적용을 고민하고 있다. 개발을 시작하면서 어렴풋이 정한 마일스톤인데 달성해서 기분 좋다.

비주얼 타이머는 남은 시간을 직관적으로 확인할 수 있도록 진행 상황을 표시해주는 시각화 타이머로, 소리, 색상, 문구 등 다양한 개인화 기능을 제공한다. 앱은 React native를 사용했고 공지 등 서버 자원은 Azure Function App, 이슈와 리퍼지터리는 Github에서 관리했다.

1만 달성 🙌

개발 동기

  • 생산성 도구를 만들어보고 싶다
  • 프로젝트로 실무 감각을 유지하고 싶다
  • 사이드 프로젝트로 작은 앱은 만들어 봤으니 조금 큰 앱을 만들고 싶다
  • 애플 개발자 등록 비용이라도 벌고 싶다

개발 전

이전부터 생산성 도구에 관심이 많아 만들어보고 싶었다. 타이머는 나도 자주 사용하는 앱 중 하나였고 앱스토어에서 새로운 앱은 없나 종종 검색하기도 했었다. 생산성 앱을 만들겠다는 결정을 하고 나서 다음 과정을 거쳤다.

이전에 시험 삼아 만들어본 팁 계산기 앱에서 얻은 경험을 바탕으로 무슨 앱을 만들지 결정했다.

  • 앱 이름에서 제공하는 기능을 명확하게 알 수 있도록: 별도로 마케팅할 가능성이 작으니까 나는 앱스토어 검색에 최적화하는 쪽으로 기울었다. 앱스토어에서 다양한 키워드로 검색해보니 검색 키워드가 앱 이름이면 유명한 앱보다 먼저 검색 결과에 노출되는 경우가 많았다.
  • 앱스토어에서 검색했을 때 경쟁 앱이 적당히 있어야: 큰 카테고리에서는 검색 순위에 밀릴 수 있어도 조금이라도 카테고리가 좁아지면 상위 순위에 나와야 한다. 검색했을 때 경쟁 앱이 전혀 없다면 사람들이 검색하지 않는 키워드일 가능성도 크다.
  • 기존 앱에서 개선할 만한 부분 확인하기: 다른 앱도 사용해보고 그 앱 리뷰도 보고 업데이트 로그를 보니 이런 앱을 만들며 겪을 피드백과 개선 과정을 이해하는 데 도움이 되었다. (물론 로그를 대충 적는 앱, 이해하기 힘든 리뷰도 많지만)

타이머 자체 기능도 당연히 중요하지만 사소한 개인화 기능을 요청하는 리뷰가 많았다. 그래서 다양한 설정을 할 수 있는 타이머를 만들어보기로 했고 가능한 한 대부분의 설정을 변경할 수 있도록 계획했다. 그 중에도 아동을 대상으로 한 개인화 기능은 유료로 제공해서 수익을 만들기로 했다.

개발 과정

프로젝트는 React native를 사용하기로 했다. 웹앱은 생각보다 느린 반응이 눈에 보여서 Flutter와 RN 사이에서 고민했는데 지금은 많이 개선되었지만 당시에 Flutter로 만들었다는 앱을 써보니 웹앱보다는 빠르지만 약간 느린 인상을 받아서 RN으로 결정하게 되었다.

Expo도 편의 라이브러리가 많아서 좋지만, 결정 당시에는 한국어 입력 문제가 있어서 바로 react native를 사용하기로 했다.

초기에 고려하지 않은 탓에 큰 작업이 되었던 분할 화면

처음에는 공통으로 사용하는 컴포넌트를 만들어서 다른 프로젝트를 하게 되더라도 활용할 수 있도록 계획을 세웠다. 설정 저장/불러오기나 국제화 지원과 같이 전역에서 사용되는 라이브러리는 큰 문제 없이 계획한 방식대로 만들 수 있었다. 다만 생각처럼 되지 않은 부분은 표현 컴포넌트였다. RN에서 기본적인 컴포넌트를 많이 지원하긴 하지만 iOS 스타일의 크고 작은 표현 컴포넌트는 다른 라이브러리를 사용하거나 직접 만들어야 했다. 공개된 라이브러리가 많이 있지만 필요한 기능은 극히 일부인 데다 스타일 일관성을 맞추는 노력이 생각보다 많이 필요로 해서 직접 만들게 되었다. 이렇게 부분적으로 기능하는 컴포넌트는 당장에는 잘 동작하지만, 특정 상황만 고려해서 만든 탓에 다른 프로젝트에서 쓰기엔 조금 부족했다.

디자인은 iOS 기본 컴포넌트를 거의 따라가서 큰 막힘은 없었다. 아직도 약간씩 어색한 부분이 있지만 대부분 컴포넌트가 나름 비슷하게 동작한다. 그 외는... 좀 대충 만든 부분도 있지만 그래도 동작은 하니까 일단 뒀다. (타이머 순서 바꾸는 부분이라든지 매우 안 이쁘다.)

기능 중 완료 이미지 모음 리소스를 만드는 데 가장 많은 시간이 걸렸다. 완료 이미지 모음은 동물 등 이미지와 함께 이미지에 맞는 효과음이 타이머 완료 시에 표시되는 기능이다. 적합한 저작권을 가진 리소스를 찾고 정리해서 넣는 과정에 많은 시간을 썼다. 직접 일러스트라든지 그릴 수 있었다면 시간을 좀 더 줄일 수 있었을까.

서버는 Azure Function App을 사용하고 있는데 공지를 그냥 JSON 형식으로 내려주는 수준이라 별도의 데이터베이스 없이 작성했다. 그런 덕분에 응답도 빠르고 비용도 안 들어서 지금까지 그대로 유지되고 있다.

개발은 출시까지 4개월 정도 걸렸는데 개발은 1달 반에서 2달 정도 걸렸고 나머지는 앱 리소스(소리 효과, 이미지)와 앱스토어 메타 정보(앱 소개, 정책 등)을 준비하는 데 시간을 썼다.

사소하지만 품이 많이 드는 작업. 아이폰, 아이패드, 언어별로.

개발이 완료된 후에는 이상한모임 분들에게 테스트를 부탁해서 진행했다. 이 과정에서 앱이 어떤 맥락과 흐름에서 동작해야 하는지 피드백을 많이 주셔서 큰 도움이 되었다.

마케팅

홍보는 페이스북 그룹, 이상한모임 채널, 클리앙, 내 소셜 계정에서 진행했다. 소개 글과 함께 프로모션 코드도 나눴다. 다운로드 수는 확실히 이런 홍보를 할 때마다 오르긴 했지만 액티브 유저 수 추세는 큰 변동이 없이 항상 일정하게 증가했다. 돈을 쓰지 않아서 그랬을까, 프로모션 코드도 돈이라고 생각한다면 업데이트마다 꽤 지출했다고 볼 수 있겠다.

가장 큰 도움이 되었던 Back to the Mac 그룹

페이스북 타깃 광고는 제공하는 도구로 확인해보니 적어도 광고 본 사람 중 30% 이상은 받고 결제해야 손익 분기인 비용이라 집행하지 않았다...

그래도 적은 수지만 앱스토어에서 검색해 받는 사람도 꾸준히 있다. 메타 정보를 작성하는 일도 생각보다 쉽지 않았다. 업데이트마다 조금씩 변경하고 있지만, 검색 지표가 명확하게 공개돼 있지 않아서 솔직히 잘 모르겠다. (어떤 키워드로 검색했는지 등)

검색 비중이 높다

개선과 피드백

앱 출시 이후 성능 문제를 계속 개선하고 있고 사용자 피드백도 그 과정에서 반영하고 있다. 초기에는 불안정하게 동작하는 부분이 있어서 못쓰겠다는 리뷰 받고 참담했는데 부지런히 안정화했다. 이 과정에서 Xcode의 프로파일링 도구가 큰 도움이 되었다.

피드백은 앱 내 이메일 보내기로도 오지만 대부분은 앱스토어 리뷰로 남긴다. 만족도를 물어보고 이메일로 보낼지 앱스토어 리뷰로 보낼지 구분하겠다고 백로그에 적어두고는 그동안 잊고 지냈... 그동안 작은 피드백도 많이 받았는데 이미 개인화에 중점을 두고 만든 덕분에 대부분 피드백은 간단하게 추가할 수 있었다.

좋은 리뷰와 탄탄한 피드백은 늘 힘이 된다

다만 구조적인 변화가 필요한 피드백은 백로그로 쌓고 있다. 가령 여러 타이머를 동시에 사용하거나 순서대로 동작하도록 하는 방식은 현재 구조에서는 처음에 염두에 두지 않았던 부분이라 구현이 어렵다. 좀 더 정리해서 차기 버전에서는 구현할 수 있는 구조로 가고 싶다.

아쉬운 점

사용자 추적

앱스토어커넥트에서 제공하는 분석 도구는 피상적인 수준이라 실제 데이터를 볼 수 없다는 점이 늘 불편하다. 실제 사용은 앱을 실행했을 때 버전 확인과 공지 데이터를 받기 때문에 애저 펑션앱 사용량으로 확인할 수 있지만, 여전히 부족하다. GA 등 추적 도구를 사용해서 어떤 기능을 주로 사용하는지 등 데이터를 모으면 개발 방향을 잡는 데 더 도움이 될 텐데 그러지 않았다. 처음부터 이 부분이 중요하다는 이야기를 들었는데도 지금까지 적용하지 않은 것은 정말 반성해야 한다.

약한 수익 모델

현재는 인앱 결제로 수익이 발생하고 있다.

  • 완료 시 나오는 이미지 모음
  • 개발 지원하기

처음 앱을 기획할 때는 아동용 타이머로 방향을 잡고 있어서 완료 이미지 모음을 인앱으로 제공했다. 앱을 공개한 이후에는 아동용 타이머 외에 일반 용도로 많이 사용하길래 커피 지원하기 같은 개발 지원 항목을 추가했다. 한국에서는 개발 지원이, 미국 스토어에서는 이미지 모음 결제가 많다. 내가 쓰는 시간을 생각하면 아직도 많이 부족하지만 그래도 앱개발자 등록 비용은 해결할 수 있게 되었다.

다음 버전에서는 인앱을 정리하고 유료로 전환하는 것을 고민하고 있는데 이런 결정은 어떻게 해야 하는 걸까, 사용자에게 무슨 가치를 주고 있는지, 그 비용은 어떻게 결정해야 할지 배우고 싶다.

안드로이드 지원

처음에는 RN이면 안드로이드 지원은 저절로 되겠지 생각했는데 실제로 돌려본 결과는 처참했다. iOS는 그래도 작게나마 해본 경험이 있어서 트러블슈팅도 큰 문제가 없었는데 안드로이드는 구동 자체도 쉽지 않았다. 차기 버전에서는 안드로이드도 같이 지원하고 싶다.

자동화와 테스트 부족

지금 앱은 일부 로직에 유닛테스트가 있지만, 아직 많이 부족하고 자동화도 없다. 앞으로 개선해야 하는 부분이다.

잘한 점

React Native

React로 많은 프로젝트를 해보지 않았는데 이 앱을 만들고 관리하면서 많이 배웠다. 정말 매달 새로운 기능과 라이브러리에 눈과 머리가 바쁘다. 출시 이후에도 react에 hook도 들어오고 많은 새 기능이 들어오고 있는데 업데이트 과정에서 조금씩 적용하고 개선하며 많이 배우고 있다.

확실히 js나 react에 경험이 있다면 큰 문제 없이 RN 프로젝트도 꾸릴 수 있다. 물론 타깃 플랫폼도 잘 알면 더 편하다. 특히 기능이나 용어는 각 타깃 플랫폼에서 제공하는 용어로 많이 설명되고 있어서 안드로이드와 iOS의 용어 차이를 알아두는 것도 도움이 됐다.

다국어 지원

처음부터 고려하지 않았다면 꽤 번거로운 작업이 되었을 텐데 지금 와서는 잘 한 결정 중 하나다. 현재 영어랑 한국어만 지원하지만 지금까지 지역 통계를 보면 확실히 도움이 되었다. 마케팅도, 프로모션도 대부분 한국에 했는데 다운로드 비율이나 수익 비중은 미국이 더 크다.

프로모션 코드는 한국에서 많이 뿌렸는데 조금 억울

우선순위와 시간 관리

혼자 하는 프로젝트는 내 마음대로 할 수 있다는 장점이 있지만, 한편으로는 정말로 제한적인 시간 자원을 어떻게 관리하는가 하는 점이 정말 큰 어려움이었다. 특히 내가 안 하면 아무것도 진행이 되지 않는다는 점이 당연하지만 피곤했다.

다행히 처음에 계획한 최소 기능은 대부분 만들어냈고 학업 하면서도 크고 작은 개선 작업을 진행했다. 출시 이후에는 적은 분량이라도 한 달에 한 번은 업데이트 할 수 있도록 노력했다. 지금까지 받은 피드백 중 적용된 것은 일부지만 개선 과정에서 좋은 리뷰도 많이 받을 수 있었다.


이 이전에도 앱을 만들어본 경험은 있지만, 하루 정도면 만들었던 수준의 앱과는 경험의 깊이가 달랐다. 특히 앱은 웹과는 확실히 다른 맥락과 인터페이스를 갖고 있었다. 앱을 출시하는 경험은 평소라면 별 생각 없이 쓰던 수많은 앱을 교과서처럼 보이게 했다. 아름답고 멋지고 편리한 기능을 모두 앱에 적용하고 싶었지만 작아도 정교한 기능은 보는 것과 다르게 훨씬 많은 시간을 필요로 했다. 쉽진 않겠지만 다음엔 그런 부분도 꼼꼼하게 구현해보고픈 욕심도 생겼다.

지금 이렇게 되돌아보면 잘한 점보다 부족한 점도 많고 배워야 할 부분도 산더미다. 그래도 부족한 부분이 많지만 유용하게 사용하고 있다는 리뷰 하나하나가 큰 힘이 됐다. 앞으로 개선될 타이머와 만들어갈 많은 프로젝트에서도 이런 경험이 거름이 되었으면 좋겠다.

애플 앱스토어에서 보기

웹페이지 다크 모드 지원은 prefers-color-scheme 미디어 쿼리를 사용해서 적용할 수 있다. 각 해상도에 따라 미디어 쿼리를 적용하는 방식과 크게 다르지 않다.

.title {
    color: #101010;
}

.desc {
    background-color: darkorange;
}

@media (prefers-color-scheme: dark) {
    .title {
        color: #EFEFEF;
    }
    .desc {
        background-color: peachpuff;
    }
}

이제 해당 페이지는 다크 모드로 지정했다면 해당 스타일로 적용된다. CSS 프로퍼티로 정의하면 자료와 구조를 분리할 수 있어 좀 더 깔끔해진다.

:root {
    --title-color: #101010;
    --desc-color: darkorange;
}

@media (prefers-color-scheme: dark) {
    :root {
        --title-color: #EFEFEF;
        --desc-color: peachpuff;
    }
}

.title {
    color: var(--title-color);
}

.desc {
    background-color: var(--desc-color);
}

사용자가 시스템에서 설정한 대로 보이긴 하지만 웹페이지에서 직접 변경할 수 있도록 옵션을 제공할 수도 있다. localStorage에 설정을 저장해서 다시 웹페이지에 접속할 때 마지막 설정을 불러올 수 있다.

:root {
    --title-color: #101010;
    --desc-color: darkorange;
}

@media (prefers-color-scheme: dark) {
    :root {
        --title-color: #EFEFEF;
        --desc-color: peachpuff;
    }
}

[data-theme="light"] {
    --title-color: #101010;
    --desc-color: darkorange;
}

[data-theme="dark"] {
    --title-color: #EFEFEF;
    --desc-color: peachpuff;
}

/* ... */

저장된 값이 있다면 페이지에 설정한다.

const theme = localStorage.getItem('theme');
if (theme) {
    document.documentElement.setAttribute('data-theme', theme);
}

시스템 설정을 확인하기 위해서 window.matchMedia() 함수를 사용할 수 있다. CSS의 미디어 쿼리가 현재 페이지에 해당하는지 확인하는 기능을 제공한다.

function toggleTheme() {
    // 저장된 값이 없다면 시스템 설정을 기준으로 함
    const currentTheme = localStorage.getItem('theme')
        || (
            window.matchMedia("(prefers-color-scheme: dark)").matches
            ? 'dark'
            : 'light'
        );
    const newTheme = currentTheme === 'dark' ? 'light' : 'dark';

    // 최상위 엘리먼트에 설정, 로컬 스토리지에 설정을 저장
    document.documentElement.setAttribute('data-theme', newTheme);
    localStorage.setItem('theme', newTheme);
}

만약 미디어 쿼리의 결과를 동적으로 반영해야 하는 경우에는 window.matchMedia()의 반환값인 MediaQueryList에 이벤트 리스너를 추가할 수 있다.

const mql = window.matchMedia("(prefers-color-scheme: dark)");

mql.addEventListener((e) => {
    if (e.matches) {
        // 해당 미디어 쿼리가 참인 경우
    } else {
        // 해당 미디어 쿼리가 거짓인 경우
    }
});

오랜만에 글을 쓴다고 자리에 앉았다. 작년에 미국으로 넘어 온 이후로 지금까지 많은 일이 있었는데 기록을 제대로 남기지 않았던 탓에 블로그도 휑하고 기억도 가물가물하다. 올해 7월도 조금밖에 남지 않았는데! 그래서 그동안 있던 일이라도 간단하게 정리해보고 싶었다.

왜 글을 잘 안 쓰게 되었나

미국으로 이사 온 이후로 안전에 대한 감각이 많이 달라졌다. 미국 밖에 있을 때는 총기 사건에 대한 뉴스를 드문드문 듣는 정도였지만 그렇게 외신으로 보도되는 사건은 매우 큰 사건에 한해서고 그보다 작은 사건도 꽤 빈번했다. 한국이나 호주라면 사소할 만한 사건에도 여기는 총기 문제가 늘 엮여서 사람 다치는 일이 흔했다. 바로 TV만 켜면 나오는 뉴스에도 그렇게 겁이 생기는데 오가면서 들은 뉴스나 사건에도 걱정이 앞설 수밖에 없었다.

결정적으로 집 앞에 멀쩡하게 세워져 있던 차량을 도난당하는 일이 있었다. 경찰에 신고했지만 끝내 찾을 수 없었다. 이 사건으로 동네 페이스북 커뮤니티와 Nextdoor에 가입하게 되었다. 이런 서비스를 통해 지역에서 일어나는 사건/사고를 알고 대응할 수 있다는 점은 분명 장점이지만, 한편으로는 이 지역에 산다면 내가 어디 사는지 명확히 알 수 있을 정도로 세세한 정보를 서비스에 제공하게 된다는 점이 걸렸다.

가장 큰 불안감은 내 온라인 활동이 사회공학(Social engineering)의 타겟이 될 수 있다는 점에서다. 예를 들어, 어딘가 여행을 가서 사진을 올린다면 집에 나 또는 가족이 없는 것을 알고 주거지에 침입할 수도 있다. 내 일상적인 동선을 파악해서 범죄 행위를 저지를 수도 있다. 내가 올린 사진에 걸린 태그를 보고 어느 지역에 사는지 알아낼 수도 있다. 소셜 네트워크의 사소한 요소조차도 내 불안함을 자극했다.

그래서 글도, 사진도 올리기 전에 여러 차례 고민할 수밖에 없었다. 정말 공유하고 싶을 때는 다녀온 지 한참 지나서야 올리기도 했다. 불안해서 어디인지 태그도 잘 하지 않았다. 이렇게 올린 글이나 사진은 적시성이 떨어져서 올리는 재미도 덜했다. 그래서 자연스럽게 아무것도 올리지 않는 쪽이 되었는데 늘 일상을 공유하던 습관 탓인지 고스란히 일상의 스트레스가 되었다.

최근 들어서 조금씩 올리긴 하지만 여전히 불안한 마음이 크다. 아마 이 글을 쓴 이후로는 다시 자제할 것이다. 내가 심은 꽃을 보세요! 하고 올리지만, 앞마당에 있는 꽃을 찍어 올린 것 보고 우리 집인걸 알면 어떡하지 걱정된다. 여전히 동네에 크고 작은 사건이 있는 것을 온갖 소셜 서비스를 통해 실시간으로 날아오기 때문이다. 그래서 아마 정말 기술적인 이야기 외에는 거의 쓰지 않을 것 같다.

코드, 코드, 코드

지난번에 팁 계산기 이후로도 여러 앱을 만들어서 올렸다. 지난 팁 계산기는 ionic을 사용했지만, 그 이후로는 모두 react-native를 사용했다. 리액트를 공부해도 실무에서 제대로 사용해본 적 없으니 리액트를 사용한 프로젝트를 해보고 싶었다. 그리고 팁 계산기 이후로 웹과는 다른 맥락에 재미를 느껴서 앱을 계속 만들고 싶었는데 리액트 네이티브가 좋은 선택지가 되었다.

현재 안드로이드 기기가 없어서 다 iOS로만 출시되어 있다. 매년 내는 애플세를 내고 나서 무얼 살 정도로 수익이 있으면 🤞 그때쯤 기기도 구입하고 안드로이드로도 낼 수 있지 않을까.

비주얼 타이머

비주얼 타이머는 리액트 네이티브로 가장 먼저 만들어본 앱이다. 남은 시간을 쉽게 확인할 수 있는 타이머로 다양한 설정을 통해 자신만의 타이머를 만들 수 있다.

  • 계획했던 기능을 5% 정도 빼고는 모두 구현했다.
  • 개발 기간 중간에 다른 많은 일정이 있었는데 끝을 냈다는 사실만으로도 감사하다.
  • 출시 전에 베타 테스트로 좋은 피드백을 많이 받아서 개선할 수 있었다.
  • 설정 할 수 있는 부분이 너무 많아서 혼란을 느끼는 사용자가 많았다. 다음엔 정말 필요한 기능만.
  • 어린이를 위한 종료 이미지와 효과음을 인앱으로 넣었는데 생각보다 인기가 있었다!
비주얼 타이머

팁 계산기

팁 계산기는 지난번에 만든 앱을 개선한 리액트 네이티브 앱이다. 한꺼번에 여러 팁 퍼센트를 확인하고 선택할 수 있다. 이전 버전과 다르게 계산 결과가 저장되며 메모를 남길 수 있다.

  • 기존 팁 계산기는 ionic에 angular였지만 중요 연산 로직은 대부분 재활용할 수 있었다.
  • JavaScriptCore의 처리 속도 지연을 처음 경험했다. 네이티브 코드로 넘겨서 연산하면 좋겠지만 memoize 하는 정도로 그쳤다.
  • 처음으로 admob을 달았는데 간단했다. 그런데 광고 붙인 이후로 앱이 영 안 이쁘다.

온도 변환 퀴즈

온도 변환 퀴즈는 화씨와 섭씨온도를 빠르게 바꿔서 선택하는 퀴즈 앱이다. 간단하게 하루 만에 끝내는 앱을 생각하다가 만들었다.

  • 늘 잘써보고 싶다고 했던 Animated를 가장 많이 썼다.
  • 간단한 게임은 리액트 네이티브도 정말 넘치는 수준이었다.
  • 광고 보면 체력을 하나 더 주는 리워드를 붙여봤는데... 광고 시청 바랍니다 😎

매일 투두

매일 투두는 시작 시각에 할 일을 적고 완료 시각에 할 일을 체크하는 앱이었다. 다른 앱과 다른 점이라면 하루가 지났을 때 모든 목록이 히스토리로 옮겨진다는 점이었다. 투두가 쌓이는 스트레스를 없애주는 신개념 투두앱🤔이었는데 만들어서 공유하니까 반응이 신통하지 않아서 침몰한 앱이다. (결국엔 나도 잘 안 썼고...)

블로그

내 블로그도 디자인을 변경했다. 전 디자인은 아이맥 기준처럼 만들어져서 작은 스크린에서는 답답하고 큰 스크린에서는 지나치게 글이 크게 보였다. 다시 마음을 다잡는 겸 블로그도 새로 정리했는데 깔끔해서 마음에 든다.

  • vw와 rem을 많이 사용했다. 그래도 픽셀 단위로 미디어쿼리 쓸 부분이 조금씩 있긴 했다.
  • 리액트 네이티브를 쓰며 익숙해진 flex를 많이 썼다.
  • 늘 제목의 계층을 서체 크기로 하는 게 불편하다고 생각했는데 css counter를 적용해서 책처럼 표시했다. 좀 더 명확한 것 같다.
  • 제목에 링크 붙여주는 플러그인을 넣었다.
  • 구조를 바꿔서 그리드 밖으로 나가는 것처럼 보이는 요소를 넣을 수 있게 되었고 (이 포스트에 이미지처럼) 이쁘게 잘 적용된 것 같다.
  • 배경색을 약간 어둡게 바꿔서 흰색을 강조로 사용할 수 있게 되었다.
  • 전체적으로 색을 부드럽게 조정했다. 너무 발색이 심한 웹페이지에 피로해서 내 자신도 블로그에 자주 안 들어왔나 싶다.

다녀온 곳

그동안 길고 짧게 다녀온 곳이 많았다. 아내와 가족과 좋은 시간을 보냈다. 사진은 생략하고 간단하게 정리했다.

  • LA: 갈 때마다 잘 놀고 오면서 복잡한 마음 생기는 곳.
  • 샌프란시스코 & 산호세: 분위기는 갈 때마다 좋지만, 여기서 살 수 있을까 깊은 고민.
  • 새크라멘토: Fire Wings 꼭 드세요.
  • 요세미티: 애플 배경화면을 눈으로 보니까 기분 이상했다. 기회가 있다면 또.
  • 그랜드캐니언 & 엔탈로프캐니언: 진짜 비현실적이고, 기회가 있다면 2.
  • 라스베가스: 오션스일레븐 생각하고 갔지만, 현실은 행오버랑 더 비슷한 분위기.
  • 플로리다: 4월인데도 제주의 여름처럼 후덥지근했다.
  • 샌디에고: 늘 좋은 기억 만든다.

그동안 있던 일들

순서 없이 목록으로 적어봤다.

  • 걱정을 많이 했던 영주권이 잘 나왔다.
  • 책을 많이 읽어야지 하고 잔뜩 샀는데 역시나 읽은 양이 너무 적다... 요즘 읽는 책은 The Shallows: What the Internet Is Doing to Our Brains.
  • Triplebyte를 통해서 인터뷰를 진행했었다. 프로세스도 좋았고 연결된 회사도 마음에 들었지만 리로케이션은 아무래도 힘들어서 다음을 기약했다.
  • 살쪘다. 운동이 절실하다.
  • 콘텍트렌즈를 맞췄다. 안경 없는 삶 어색하지만 매우 편하다.
  • 식물과 친해졌다. 씨를 뿌려 수확하는 재미를 알게 되었다. 그래도 나름 주기가 짧은 것 하는데도 기다림은 지루하다.
  • 오버워치를 하는데 맨날 비슷한 점수만 오간다. (팀 탓) 역할 고정 기능 기대하고 있다.
  • Friends를 다 봤다. 결말 너무 맘에 들지 않는다. 계속 보는 최근 드라마는 The 100.
  • 운전면허를 취득했다!
  • 학교에 등록해서 조만간 신학기가 시작될 예정이다.

목록으로 적으니까 금방 적는다 👏

나에게

나는 항상 지나치게 걱정하는 편이라서 걱정을 자제할 필요가 있다. 앞으로 학교에서의 생활에도 잡다한 고민은 치우고, 알고 싶었던 분야를 공부한다는 즐거움에 집중할 수 있었으면 좋겠다.

기록하는 습관을 잃어버린 것 같아 아쉽다. 글쓰기도 꾸준히 했으면 좋겠다.

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to react context?를 번역한 글입니다.

2018년 초, 리액트 팀은 처음으로 공식 컨텍스트(context) API를 소개했습니다. 이 새 API에 대해서 글을 쓰기도 했고 많은 사람들이 이 기능에 꽤나 흥분했습니다.

이 API에 대한 가장 흔한 푸념 중 하나는 컨텍스트를 실질적으로 사용하려면 렌더링 프로퍼티 API를 사용해야 한다는 점이었습니다. 여러 컨텍스트를 사용하려고 하면 결국 (로직 재활용을 위해) 여러 렌더링 프로퍼티 API를 사용하게 되고 결과적으로 많은 중첩(nesting)이 발생하게 됩니다. 그래서 여러 렌더링 프로퍼티 기반 API를 조합해서 하나의 함수 컴포넌트를 만들어 컨텍스트를 소비하는 방식을 이전 블로그 포스트에서 제시했습니다.

const ThemeContext = React.createContext('light')
class ThemeProvider extends React.Component {
  /* code */
}
const ThemeConsumer = ThemeContext.Consumer
const LanguageContext = React.createContext('en')
class LanguageProvider extends React.Component {
  /* code */
}
const LanguageConsumer = LanguageContext.Consumer

function AppProviders({children}) {
  return (
    <LanguageProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </LanguageProvider>
  )
}

function ThemeAndLanguageConsumer({children}) {
  return (
    <LanguageConsumer>
      {language => (
        <ThemeConsumer>{theme => children({language, theme})}</ThemeConsumer>
      )}
    </LanguageConsumer>
  )
}

function App() {
  return (
    <AppProviders>
      <ThemeAndLanguageConsumer>
        {({theme, language}) => (
          <div>
            {theme} and {language}
          </div>
        )}
      </ThemeAndLanguageConsumer>
    </AppProviders>
  )
}

리액트 컴포넌트의 강력한 합성 덕분에 위 예시처럼 문제를 해결할 수 있습니다. 이런 해법이 썩 맘에 들지 않습니다. 게다가 저만 그렇게 생각한 것이 아닙니다.

클래스 컴포넌트에서는 새 렌더링 프로퍼티 API를 적용하기 어렵다는 피드백을 받았습니다. 그래서 클래스 컴포넌트에서도 쉽게 컨텍스트 값을 사용할 수 있도록 새로운 편의 API를 추가했습니다. - React v16.6.0: lazy, memo와 contextType

이 새로운 편의 API는 클래스 컴포넌트에서 단 하나의 컨텍스트만 소비한다면 contextType이라는 정적 프로퍼티를 정의하는 것으로 사용하고 싶은 컨텍스트를 할당 받아 this.context로 접근할 수 있도록 기능을 제공합니다. 단일 컨텍스트만 필요로 한다면 일반적인 상황에서 꽤 깔끔하고 멋진 방법입니다.

이 편의 API는 정말 만족스럽습니다. 하지만 저는 리액트 컨텍스트의 미래에 영향을 줄 리액트 훅이 더 기대됩니다. 위에서 봤던 코드를 useContext 훅을 사용해서 다시 작성해보겠습니다.

const ThemeContext = React.createContext('light')
class ThemeProvider extends React.Component {
  /* code */
}
const LanguageContext = React.createContext('en')
class LanguageProvider extends React.Component {
  /* code */
}

function AppProviders({children}) {
  return (
    <LanguageProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </LanguageProvider>
  )
}

function App() {
  const theme = useContext(ThemeContext)
  const language = useContext(LanguageContext)
  return (
    <div>
      {theme} and {language}
    </div>
  )
}

ReactDOM.render(
  <AppProviders>
    <App />
  </AppProviders>,
  document.getElementById('root'),
)

멋지지 않습니까? 렌더링 프로퍼티 기반으로 만든 코드만큼 강력하면서도 훨씬 쉽게 읽고, 이해할 수 있고, 리팩토링하고 관리하기 좋은 구현입니다. 단순히 적은 코드가 아닙니다. 간혹 코드 양을 줄이는 과정에서 코드가 주는 명료성을 해쳐 의사소통에 문제를 만들 때도 있습니다. 하지만 이 경우에는 적은 코드이면서도 동시에 쉽게 이해할 수 있습니다. 이 컨텍스트 API는 강력하며 동시에 새 훅 API에 큰 기능을 제공합니다.

리액트 훅의 또 다른 큰 기능은 완전히 선택적인 기능이며 하위호환성이 보장됩니다. 아마 페이스북은 세계에서 가장 크고 오래된 리액트 코드베이스를 운용하고 있으니 엔지니어에게 고통을 주는 결정을 할 수는 없었을 겁니다. 리액트가 점진적으로 새로운 훅의 세계로 이끌고 있다는 점이 너무나도 멋집니다. 리액트 팀에게 감사합니다. 공식 릴리즈가 기대되네요. (역자 주: useContext는 현재 16.8에 릴리즈되었습니다.)

결론

리액트의 멋진 점은 세세한 구현에 개의치 않고 실제 세계의 문제를 해결하는데 집중할 수 있도록 한다는 점입니다. 크로스 브라우징이나 성능 문제를 오랜 기간동안 겪었습니다. 이제는 리액트를 통해 더 나아가 문제를 단순화했습니다. 이제 간단히 읽고 이해할 수 있는 코드를 작성하고 리팩토링하며 유지관리만 하면 됩니다. 저도 제 코드를 통해서 다른 이들의 작업을 단순하게 할 수 있을 지 궁금하네요 🤔.

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to render props?를 번역한 글입니다.

작년에 "프로퍼티 게터(prop getters)를 사용해서 사용자에게 컴포넌트 렌더링 제어를 넘겨주는 방법"이란 포스트를 작성했습니다. 이 글에서 (당시) react-toggled의 전체 구현을 보여줬는데 downshift에서 사용했던 패턴을 가르치기 위해서 작성했던 라이브러리입니다. 제가 downshift에서 훨씬 간단하고 단순한 컴포넌트를 동일한 패턴으로 구현해서 사용했기 때문에 프로퍼티 게터 패턴을 가르치기 아주 좋은 방법이었습니다.

react-toggled와 downshift는 둘 다 렌더링 프로퍼티 패턴을 사용해서 리액트 컴포넌트 간의 로직 코드를 공유했습니다. 다른 포스트인 "렌더링 프로퍼티를 사용하지 말아야 하는 경우"에서 렌더링 프로퍼티 패턴을 적용하기 좋은 경우를 확인했습니다. 하지만 리액트 훅을 적용하기 좋은 경우도 동일합니다. 리액트 훅은 클래스 컴포넌트와 렌더링 프로퍼티 조합보다 훨씬 간단합니다.

리액트 훅이 안정 버전에서 제공되기 시작하면 더이상 렌더링 프로퍼티를 쓸 필요가 없다는 의미인가요? 아닙니다! 여전히 렌더링 프로퍼티 패턴이 아주 유용하게 쓰일 수 있는 부분이 있으며 그 부분을 곧 살펴보도록 하겠습니다. 하지만 현재의 react-toggled를 훅 기반으로 구현한 코드와 비교해서 왜 훅이 더 간단하다고 주장하는지 비교해보도록 하겠습니다.

코드가 궁금하다면 현재의 react-toggled를 확인해보기 바랍니다.

가장 일반적인 react-toggled 사용 방법입니다.

function App() {
  return (
    <Toggle>
      {({on, toggle}) => <button onClick={toggle}>{on ? 'on' : 'off'}</button>}
    </Toggle>
  )
}

단순히 간단한 토글 기능이 필요한 경우라면 훅으로 작성한 코드는 다음과 같습니다.

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  return {on, toggle}
}

이제 다음과 같은 방식으로 사람들이 사용할 수 있게 됩니다.

function App() {
  const {on, toggle} = useToggle()
  return <button onClick={toggle}>{on ? 'on' : 'off'}</button>
}

훨씬 간단하고 멋집니다. 하지만 react-toggled의 Toggle 컴포넌트는 이보다 더 많은 기능을 제공합니다. 하나를 예로 들자면 getTogglerProps라는 헬퍼(helper)를 제공합니다. 이 함수는 토글하는 엘리먼트에 올바른 프로퍼티를 전달하기 위해 사용합니다. (접근성을 위한 aria 속성도 포함합니다.) 이 기능도 포함해봅니다.

// 주어진 함수 목록을 호출하는 함수를 반환
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  const getTogglerProps = (props = {}) => ({
    'aria-expanded': on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props.onClick, toggle),
  })
  return {
    on,
    toggle,
    getTogglerProps,
  }
}

이제 useToggle 훅에서 getTogglerProps 헬퍼도 사용할 수 있습니다.

function App() {
  const {on, getTogglerProps} = useToggle()
  return <button {...getTogglerProps()}>{on ? 'on' : 'off'}</button>
}

기존 방식보다 더 접근하기 좋고 편리합니다. 만약 getTogglerProps가 필요 없는 경우라면 어떨까요? 다음처럼 코드를 조금 더 나눌 수 있습니다.

// 주어진 함수 목록을 호출하는 함수를 반환
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  return {on, toggle}
}

function useToggleWithPropGetter(initialOn) {
  const {on, toggle} = useToggle(initialOn)
  const getTogglerProps = (props = {}) => ({
    'aria-expanded': on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props.onClick, toggle),
  })
  return {on, toggle, getTogglerProps}
}

이제 react-toggled가 지원하는 getInputTogglerPropsgetElementTogglerProps도 같은 방법으로 구현할 수 있습니다. 이 접근 방식이라면 앱에서 사용하지 않는 추가적인 유틸리티를 쉽게 떨어낼 수 있고 어떤 면에서는 렌더링 프로퍼티 해결책이 오히려 사용하기 조잡하다고 느껴질 수도 있겠습니다. (불가능한 것은 아니지만 그냥 좀 깔끔하지 않습니다.)

하지만! 앱에 있는 모든 렌더링 프로퍼티 API를 새 훅 API로 모두 리팩토링하고 싶지 않아요! 라고 말할 수 있습니다.

두려워하지 마세요! 다음 코드를 보기 바랍니다.

const Toggle = ({children, ...props}) => children(useToggle(props))

여기 렌더링 프로퍼티 컴포넌트가 있습니다. 그냥 간단하게 예전 코드를 사용하면서도 시간이 흐른 후에 고쳐도 됩니다. 사실, 이 방식이 제가 커스텀 훅을 만든 후에 테스트할 때 추천하는 방법입니다.

여기에는 좀 더 내용이 있습니다. (마치 렌더링 프로퍼티 패턴에서 리액트 훅으로 바꾼 방식과 비슷합니다.) 더 설명하지 않고 이 글을 읽고 있는 당신이 발견하도록 조금 남겨두려고 합니다. 무엇인지 조금 더 생각해본 후에 제가 작성한 과정을 확인해보세요. 훅을 사용하면서 테스팅하는데 약간 달라지는 부분을 확인해보기 바랍니다. (자바스크립트 클로저 만세!)

렌더링 프로퍼티 패턴을 적용할 만한 경우

자, 이제 컴포넌트에 훅을 사용해서 리팩토링을 하면서도 여전히 렌더링 프로퍼티 기반 API로 리액트 컴포넌트를 공유할 수 있습니다. (관심 있다면 하이드라 패턴을 사용할 수도 있습니다.) 하지만 미래를 상상해봅시다. 렌더링 프로퍼티를 더이상 로직 재사용에 사용하지 않고 모두가 훅을 사용하고 있는 시대 말이죠. 그렇다면 계속 렌더링 프로퍼티 API를 작성하거나 사용해야 하는 이유가 있을까요?

그렇습니다! 보세요! 여기에 downshift에서 react-virtualized와 함께 사용한 예시입니다. 연관된 부분을 아래서 확인해보세요.

<List
  // ... some props
  rowRenderer={({key, index, style}) => (
    <div
    // ... some props
    />
  )}
/>

여기 rowRenderer 프로퍼티를 확인해보세요. 이 코드가 무엇인지 아세요? 바로 렌더링 프로퍼티입니다. 뭐라고요?! 🙀 바로 제어 역전을 실현한 렌더링 프로퍼티가 여기에 있습니다. 이 프로퍼티는 react-virtualized에서 목록에서 각 행을 렌더링하는 데 있어서 제어를 사용자에게 넘겨주기 위해 존재하는 코드입니다. 만약 react-virtualized를 훅으로 다시 작성한다고 하면 아마도 rowRendereruseVirtualized 훅의 인자로 받을 수 있을 겁니다. 하지만 이런 경우라면 렌더링 프로퍼티 패턴보다 이득이 있다고 말하기는 조금 어렵습니다. 그러니 이런 사용 예시에서는 이 패턴(과 이 방식의 제어 역전)을 사용하는 방법이 더 적합하다고 봅니다.

결론

이 내용이 흥미 있고 도움이 되었기를 바랍니다. 리액트 훅은 아직 알파 단계이며 변경 예정에 있습니다. (역자 주: 현재 16.8.0에 포함되어 배포되었습니다.) 이 기능은 철저히 선택 기능이며 리액트의 다른 API에 문제를 만들지 않습니다. 제가 보기엔 정말 좋은 기능이라고 생각합니다. 앱을 다시 작성하지 마세요! 대신 리팩토링을 하기 바랍니다. (훅이 안정 단계에 들어오면 말입니다.)

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to my tests?를 번역한 글입니다.

새로 나오는 리액트의 훅(hook) 기능에 대해 가장 많이 받는 질문 중 하나가 바로 테스팅입니다. 만약 지금 테스트가 다음과 같은 모양이라면 그 걱정을 충분히 이해할 수 있습니다.

// 이전 블로그 포스트에서 빌린 예제
// https://kcd.im/implementation-details
test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

이 enzyme 테스트는 Accordion이 클래스 컴포넌트며 instance가 존재하는 경우에만 동작합니다. 하지만 함수 컴포넌트라면 "인스턴스"라는 개념이 존재하지 않습니다. 그러므로 상태와 생명주기가 있는 클래스 컴포넌트에서 훅을 사용한 컴포넌트로 리팩토링했을 때에는 이 테스트의 .instance().state()가 동작하지 않을 겁니다.

이제 Accordion 컴포넌트를 함수 컴포넌트로 리팩토링 한다면 이 테스트는 고장 나게 됩니다. 그럼 테스트를 던지거나 다시 작성하지 않고 훅을 사용해서 코드를 리팩토링할 방법은 없을까요? 위 테스트처럼 컴포넌트의 인스턴스를 참조하는 enzyme API의 사용을 피하는 일에서부터 시작할 수 있습니다. 이 내용은 제 "구현 상세(implementation details)" 포스트에서 확인할 수 있습니다.

이제 클래스 컴포넌트의 간단한 예제를 확인해봅시다. 제가 가장 좋아하는 <Counter /> 컴포넌트 예제는 다음과 같습니다.

// counter.js
import React from 'react'

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

export default Counter

이제 이 컴포넌트를 테스트하는데 어떤 방식으로 작성하면 훅이든 클래스 컴포넌트든 상관없이 테스트할 수 있을까요?

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

test('counter increments the count', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

이 테스트는 통과할 겁니다. 이제 동일한 컴포넌트를 훅으로 작성해보겠습니다.

// counter.js
import React from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  const incrementCount = () => setCount(c => c + 1)
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

이 테스트는 구현 상세를 피해서 테스트하고 있기 때문에 훅으로 만든 테스트도 문제 없습니다. 정말 멋지지 않나요? :)

useEffectcomponentDidMount + componentDidUpdate + componentWillUnmount가 아닙니다.

useEffect 훅을 사용하기 전에 고려해야 하는 점이 있습니다. 이 훅은 조금 특별하고 다르고 멋지기 때문인데요. 클래스 컴포넌트에서 훅으로 리팩토링할 때에 componentDidMount, componentDidUpdate, componentWillUnmount를 하나 이상의 useEffect 콜백 함수로 바꾸게 될 겁니다. (컴포넌트의 수명주기에 포함된 컴포넌트의 관심사에 따라 다릅니다.) 하지만 이 코드는 엄밀히 말해서 리팩토링이 아닙니다. "리팩토링"이 무엇인지 빠르게 살펴보도록 하겠습니다.

코드를 리팩토링할 때는 사용자가 볼 수 있는 변화를 만들지 않고 내부 구현을 바꿔야 합니다. 다음은 위키피디아가 말하는 "코드 리팩토링"입니다.

코드 리팩토링은 존재하는 컴퓨터 코드를 구조를 조정하는 과정으로 외부 동작을 바꾸지 않고 팩토링 즉, 분해(decomposition)를 수행하는 일을 의미합니다.

이제 다음 코드에서 개념을 살펴봅니다.

const sum = (a, b) => a + b

이제 이 함수를 리팩토링해 보도록 하겠습니다.

const sum = (a, b) => b + a

앞서 함수와 동일하게 동작하지만 구현은 약간 다릅니다. 근본적으로 이 과정이 "리팩토링"입니다. 다음은 리팩토링이 아닌 경우입니다.

const sum = (...args) => args.reduce((s, n) => s + n, 0)

멋진 코드입니다. sum은 더 멋지게 동작하지만, 엄밀히 따지면 리팩토링이 아니라 개선입니다. 비교해봅시다.

호출 이전 결과 이후 결과
sum() NaN 0
sum(1) NaN 1
sum(1, 2) 3 3
sum(1, 2, 3) 3 6

왜 이 변경은 리팩토링이 아닌가요? 그 이유는 "외부 동작을 변경"했기 때문입니다. 새 코드는 이전 코드보다 바람직하지만 기존 동작을 바꿨습니다.

그러면 왜 이 내용이 useEffect를 사용하는 것과 어떤 관련이 있을까요? 앞서 본 카운터 클래스 컴포넌트에서 새로운 기능을 추가한 예제를 살펴보도록 하겠습니다.

class Counter extends React.Component {
  state = {
    count: Number(window.localStorage.getItem('count') || 0),
  }
  increment = () => this.setState(({count}) => ({count: count + 1}))
  componentDidMount() {
    window.localStorage.setItem('count', this.state.count)
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      window.localStorage.setItem('count', this.state.count)
    }
  }
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

이 코드에서는 count의 값을 componentDidMountcomponentDidUpdate를 사용해서 localStorage에 저장합니다. 구현 상세에 영향받지 않는 테스트는 다음과 같습니다.

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent, cleanup} from 'react-testing-library'
import Counter from '../counter.js'

afterEach(() => {
  window.localStorage.removeItem('count')
})

test('counter increments the count', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

test('reads and updates localStorage', () => {
  window.localStorage.setItem('count', 3)
  const {container, rerender} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('3')
  fireEvent.click(button)
  expect(button.textContent).toBe('4')
  expect(window.localStorage.getItem('count')).toBe('4')
})

테스트가 통과했습니다! 이제 이 컴포넌트를 "리팩토링"하는데 훅을 사용해보겠습니다.

import React, {useState, useEffect} from 'react'

function Counter() {
  const [count, setCount] = useState(() =>
    Number(window.localStorage.getItem('count') || 0),
  )
  const incrementCount = () => setCount(c => c + 1)
  useEffect(() => {
    window.localStorage.setItem('count', count)
  }, [count])
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

좋습니다. 사용자의 관점에서 보면 이 컴포넌트는 이전과 똑같이 동작할 겁니다. 하지만 이 변경은 이전의 동작과는 다릅니다. 여기서 useEffect 콜백은 이후에 구동하도록 예정 되었습니다. 이전 코드에서는 컴포넌트가 렌더링 된 후에 값을 localStorage에 동기적으로 저장했습니다. 새 코드에서는 렌더링 된 후에 호출되도록 변경했습니다. 왜 그럴까요? 리액트 훅 문서에 설명된 부분을 확인해봅니다.

componentDidMountcomponentDidUpdate와 다르게 useEffect로 예정한 효과는 브라우저가 화면을 업데이트하는 것을 막지 않습니다. 앱이 더 높은 응답성을 제공할 수 있도록 이렇게 동작합니다. 대부분의 효과는 동기적으로 구동될 필요가 없습니다. 동기적으로 동작하는 경우는 레이아웃을 측정해야 한다거나 하는 등 특수한 경우에 해당합니다. 이런 코드는 useLayoutEffect에 넣을 수 있으며 useEffect와 동일하게 동작하는 API입니다.

이제 useEffect를 사용해서 더 나은 성능을 얻을 수 있게 되었습니다! 멋지지 않나요! 향상된 컴포넌트를 작성한 것 뿐만 아니라 더 가볍게 구동되는 컴포넌트 코드를 작성했습니다.

하지만 이 과정은 리팩토링이 아닙니다. 실제로는 컴포넌트의 동작을 변경했기 때문입니다. 최종 사용자의 입장에서는 이 변화를 눈치채기 힘듭니다. 테스트를 작성할 때 구현 상세를 피하려고 노력했다면 이런 변화 또한 테스트에서 관측되지 않을겁니다.

다른 두 컴포넌트를 문제 없이 테스트가 가능한 이유는 react-dom/test-utils에서 제공하는 새 act 유틸리티 덕분입니다. react-testing-library는 이 유틸리티와 통합되어 있습니다. 그 덕분에 위 테스트가 문제 없이 통과할 수 있는 것입니다. 그래서 구현 상세에 의존하지 않는 테스트를 계속 작성할 수 있으며 소프트웨어를 기존에 개발하던 방식과 큰 차이 없이 개발을 계속 할 수 있게 되었습니다.

렌더링 프로퍼티 컴포넌트는 어떻게 테스트하죠?

이 부분이 제가 좋아하는 부분입니다. 프로퍼티를 렌더링하는 간단한 카운터 컴포넌트를 확인합니다.

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return this.props.children({
      count: this.state.count,
      increment: this.increment,
    })
  }
}
// 사용방법:
// <Counter>
//   {({ count, increment }) => <button onClick={increment}>{count}</button>}
// </Counter>
// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

function renderCounter(props) {
  let utils
  const children = jest.fn(stateAndHelpers => {
    utils = stateAndHelpers
    return null
  })
  return {
    ...render(<Counter {...props}>{children}</Counter>),
    children,
    // 이 부분으로 컴포넌트 내부의 증감과 횟수에 접근할 수 있는 방법을 제공합니다
    ...utils,
  }
}

test('counter increments the count', () => {
  const {children, increment} = renderCounter()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0}))
  increment()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1}))
})

이제 이 컴포넌트를 훅을 사용해서 리팩토링 합니다.

function Counter(props) {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return props.children({
    count: count,
    increment,
  })
}

좋습니다. 앞서 작성한 테스트를 문제 없이 통과합니다. 하지만 리액트 훅: 렌더링 프로퍼티는 어떻게 되나요? (역자 주: 번역)에서 배운 것처럼 커스텀 훅(custom hook)은 리액트에서 코드 공유에 더 나은 자료 형식(primitive)입니다. 위 코드를 커스텀 훅으로 다시 작성해보도록 하겠습니다.

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}

export default useCounter

// 사용방법:
// function Counter() {
//   const {count, increment} = useCounter()
//   return <button onClick={increment}>{count}</button>
// }

멋집니다... 하지만 이제 useCounter는 어떻게 테스트 하나요? 새 useCounter 훅을 사용한다고 코드 전체를 갱신할 수는 없습니다. <Counter />라는 렌더링 프로퍼티 기반 컴포넌트가 수 백 군데에 위치하면 어떻게 하죠? 정말 최악일 겁니다.

제가 도와드리죠. 다음처럼 해결하세요.

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}

const Counter = ({children, ...props}) => children(useCounter(props))

export default Counter
export {useCounter}

이제 새로 작성한 <Counter /> 렌더링 프로퍼티 기반 컴포넌트는 기존에 사용하던 컴포넌트와 완전히 동일한 형태입니다. 이 방법이 진정한 의미의 리팩토링이죠. 또한 이제 누구든지 useCounter 커스텀 훅을 사용해서 코드를 개선하는 것도 가능하게 되었습니다.

더 대단한 점은 앞서 작성한 테스트가 여전히 통과한다는 점입니다. 멋지지 않습니까?

이제 모든 코드를 새로운 훅을 사용해서 갱신하고 나면 더이상 Counter 함수는 필요가 없어지겠죠? 물론 이 함수를 제거할 수도 있지만 그대로 __tests__ 폴더로 옮겨도 좋을 겁니다. 이 방법이 바로 커스텀 훅을 테스트하는 방법이기 때문입니다. 저는 커스텀 훅을 사용해서 렌더링 프로퍼티 기반 컴포넌트를 만드는 것을 선호하는데 실제로 렌더링 된 결과를 함수 호출 결과와 비교하는 데 유용하기 때문입니다.

흥미로운 기법이죠? 저의 새로운 egghead.io 코스에서도 확인할 수 있습니다.

그럼 훅 라이브러리는 어떻게 하죠?

일반적인 훅이나 오픈소스 훅을 작성한다면 특정 컴포넌트를 생각하지 않고서 테스트를 작성해야 합니다. 그런 경우에는 react-hooks-testing-library를 사용하길 권합니다.

결론

코드를 리팩토링하기 전에 할 수 있는 가장 좋은 일은 좋은 테스트 슈트(suite)와 타입 정의를 준비해서 우연히 무언가를 고장 내더라도 그 실수를 바로 알아차릴 수 있도록 하는 일입니다. 하지만 리팩토링을 할 때 테스트 슈트를 집어 던진다면 아무런 의미 없는 일이 되고 맙니다. 제가 드릴 수 있는 조언은 테스트 내에서 구현 상세를 피하라입니다. 지금 클래스 컴포넌트와도 동작하고 미래에 훅으로 만든 함수가 오더라도 동작하는 테스트를 작성하세요.

이 포스트는 Kent C. DoddsApplication State Management을 번역한 글입니다.

소프트웨어 개발에서 가장 어려운 부분 중 하나는 상태 관리(managing state)입니다. 만약 사용자가 애플리케이션과 상호작용을 전혀 하지 않았더라면 우리 삶은 훨씬 단순했을 겁니다. 물론 90년대 웹사이트에서나 할 이야기입니다. 우리는 상호작용이 필요한 애플리케이션을 만들어야 하니 어디엔가 상태를 저장해야 합니다.

상태는 정말 복잡하며 애플리케이션마다 사용 사례가 극과 극으로 다릅니다. 그래서 애플리케이션에서 상태를 관리하는 방법도 엄청나게 다양합니다. 각각의 방법은 특정 방법을 해결하는데 초점을 맞추고 있으며 어떤 추상이든 이 문제에 따른 고통을 줄이기 위해서 애플리케이션에 복잡도를 더해야 합니다.

어떤 추상을 사용해야 하는가에 대한 핵심은 그 추상의 비용과 이점을 이해하는 데 있습니다. 이득이 무엇인지 알려면 그 추상이 풀기 위한 문제가 무엇인지, 그 문제를 해결하기 위한 다른 해법은 무엇인지 이해할 필요가 있습니다. 이 글에서는 제가 사용해보고 좋았던 추상도 공유하려고 합니다. 물론 여기서는 책이 아니기 때문에 모든 경우를 다 다루지 않고 기본적인 부분에 대해서만 설명합니다.

이 글에서는 애플리케이션의 규모와 복잡도가 성장할 때 추상을 추가하는 것을 어떻게 고려하게 되는지와 동일한 방법으로 접근합니다. 추상을 너무 이르게 추가하지 않는 것은 중요합니다. 필요보다 먼저 추가한 추상은 그 이득보다 더 많은 비용을 지불하게 될 수 있습니다.

그리고 여기서는 리액트에 특정한 해법을 다루지만 다른 프레임워크에서도 충분히 적용할 수 있는 일반적인 개념이 되었으면 합니다.

컴포넌트의 상태

먼저 컴포넌트의 상태부터 시작합니다. 상호작용이 필요한 애플리케이션이라면 어디선가 setState를 사용하게 될겁니다. 저는 생각보다 리액트 컴포넌트의 상태 API가 심할 정도로 인정을 덜 받고 덜 사용된다고 느낍니다. 믿을 수 없을 정도로 단순한 API며 이 API를 사용하더라도 애플리케이션에 그다지 복잡함을 더하지도 않습니다.

이 지식을 배우거나 다시 살펴보고 싶다면 다음 7분 분량의 (무료) 비디오를 확인해보세요. "리액트에서 컴포넌트 상태 사용하기". 그리고 공식 문서의 상태 들어 올리기를 확인해보세요.

이후 내용을 더 보기 전에 상태 API를 어디까지 사용할 수 있는지 보는 것을 진지하게 추천합니다. 이 API 자체만으로도 정말 강력하기 때문입니다.

컴포넌트의 상태를 사용하다가 문제가 생기기 시작하는 지점은 "프로퍼티 내리꽂기"에서 문제를 마주하게 될 때입니다. 프로퍼티 내리꽂기를 참고하세요. (역주: 번역)

자바스크립트 모듈 (싱글톤)

만약 싱글톤(singleton) 패턴에 익숙하지 않더라도 걱정하지 마세요. 자바스크립트에서는 상당히 직관적인 편입니다. 이는 단순히 모듈 안에 상태를 보관하는 패턴입니다. 이 패턴을 JS로 간단히 구현하면 다음과 같습니다.

const state = {}

const getState = () => state
const setState = newState => Object.assign(state, newState)

export {getState, setState}

"프로퍼티 내리꽂기 문제"에서 오는 불편함을 느끼기 시작할 때면 컴포넌트 상태 API에서 다음 단계로 넘어가는 것을 고려하게 됩니다. 리액트 애플리케이션이 성장한다는 것은 "컴포넌트 트리"도 함께 성장한다는 뜻입니다. 이 과정에서 애플리케이션에서 널리 공유되는 상태를 트리 위로 끌어 올리게 됩니다. 비대해진 컴포넌트 트리에서는 각 컴포넌트가 필요로 하는 상태와 상태를 갱신하기 위한 핸들러를 내려받는 과정이 정말 번거로워집니다.

그래서 일반적인 JS 모듈을 사용해 필요한 상태를 불러올 수 있다는 점에서 싱글톤 패턴은 꽤 괜찮은 선택지입니다. 그러니 프로퍼티를 내려 꽂는 것보다 컴포넌트 스스로 모듈을 불러와서 상태를 사용할 수 있게 됩니다.

물론 이 방법은 심각한 한계가 있습니다. 이 싱글톤에서 상태를 갱신할 때마다 컴포넌트가 다시 렌더링을 하도록 주의해야 합니다. 이 과정은 구현에 조금 더 노력을 필요로 합니다. (간단히 말해서 이벤트 에미터 (event emitter)를 구현할 필요가 있습니다.) 하지만 엄청 어려운 것은 아니며 이 과정을 도와줄 라이브러리도 존재합니다. 그러니 싱글톤 패턴을 사용하는 방법이 그렇게 나쁜 선택지는 아닙니다. 서버-사이드 렌더링을 필요로 하지 않는 경우라면 말이죠. (물론 대부분은 이 부분도 필요로 할 겁니다...)

컨텍스트 Context

리액트의 새 컨텍스트 API는 정말 멋집니다. 아직 이 기능을 모르신다면 리액트⚛️ 의 새 컨텍스트 API를 확인해보세요.

리액트 컨텍스트를 사용하면 프로퍼티 내리꽂기 문제와 싱글톤을 갱신하는 문제를 간단한 내장 API로 극복할 수 있게 됩니다. 이 API는 간단히 <ContextInstance.Provider /><ContextInstnace.Consumer /> 컴포넌트를 사용해서 어디서든지 상태에 접근할 수 있도록 구성할 수 있게 됩니다.

새로운 컨텍스트 API가 매우 단순한 덕분에 정말 많은 상황에서 단순히 싱글톤 패턴을 적용하는 것이 훨씬 간단해졌습니다. 리액트에서 컴포넌트 상태를 setState로 간단히 해결하는 것처럼 createContext를 사용해서 애플리케이션 상태를 해결할 수 있게 되었다는 점에서 정말 멋진 기능입니다.

Unstated

제임스 카일은 새 컨텍스트 API와 함께 사용할 수 있는, 새로운 상태 라이브러리를 만들었습니다. 이 새로운 라이브러리는 제가 상태를 공유할 일이 있는 앱이 있을 때 사용할 정도로 최애 라이브러리입니다. 이 라이브러리는 컨텍스트 위에 많은 것을 올리지 않는 조그마한 라이브러리라 좋습니다. 그리고 상태 컨테이너와 표현 컴포넌트를 명확하게 분리하고 있어서 모든 코드가 테스트하기 편리하니 한번 염두해보세요.

redux

Redux가 풀려고 의도했던 문제는 flux를 좀 더 손쉽게 사용할 수 있도록 하는 부분이었습니다. Flux는 상태의 흐름을 예측 가능하도록 만들어서 상태를 디버깅하는 방법을 개선하려고 했습니다. 또한 저장소에서 나온 데이터를 연산하는 과정을 단순화하려고 했습니다. 이 두 문제를 해결하기 위해서 명확한 관심사 분리와 단방향 데이터 흐름을 사용했습니다.

Redux는 이 과정을 정말 단순화해서 "flux 전쟁"을 2015년에 종식했습니다. 기존 Flux라면 필요했던 수많은 추상을 구현하지 않고도 flux의 이점을 누릴 수 있게 되었습니다. Redux 기반 상태 관리는 강점을 갖는 경우도 많고 사용 사례 또한 많습니다. 저는 이 라이브러리가 애플리케이션 상태 관리의 어려움을 해결했다는 점에 너무 기쁩니다. 이 라이브러리는 정말 많은 사람에게 도움이 되었습니다!

다시 말하면, 이 라이브러리를 사용한 앱이라면 엄청난 양의 복잡도를 더하게 되었다는 의미입니다. 이 라이브러리는 우회적인 계층을 애플리케이션에 추가하는 것으로 복잡한 사용자 상호작용을 구현하고 상태를 갱신했습니다. 이 라이브러리를 사용하는 프로젝트에 참여한 적이 있다면 디스패치와 액션, 크리에이터와 리듀서를 다 열어서 액션 타입명을 비교하는 일을 해봤을 겁니다. 그렇다면 복잡도에 대한 이야기도 이해할거라 생각합니다.

많은 애플리케이션이 redux를 사용하고 채택하는 이유는 react-redux의 connect 고차 함수가 프로퍼티 내리꽂기 문제를 우연히 해결했기 때문이라 봅니다. 프로퍼티 내리꽂기 문제는 상대적으로 작은 애플리케이션에서도 겪을 수 있는 고통인데 이 문제를 해결하기 위해 redux를 고려하는 경우가 많습니다. 더 쉬운 대안이 있다는 것을 모른 상태로 말이죠.

프로퍼티 내리꽂기 문제를 해결하기 위해 redux를 선택한다면 해결할 필요 없던 문제를 위해 비용을 지불하게 되는데 그로 인해 실제로 얻는 이득보다 더 많은 비용을 내게 됩니다.

그러니 다른 해결책을 먼저 사용해보세요. 그리고 상태를 필요로 하는 트리를 기준으로 redux에 보관하는 상태의 분량을 제한하세요. (일반적인 redux 사용자라면 최상위에 위치하고 있을 겁니다.)

코리 하우스가 이른 redux 적용에 대해 다음과 같은 트윗을 남겼습니다.

깨달음: Redux를 회사 프레임워크에 기본으로 넣은 것은 실수였다.

결과:

  1. 사람들이 모든 컴포넌트를 연결함.
  2. 사람들이 재사용 가능한 컴포넌트에도 Redux를 포함함.
  3. 모두가 Redux를 사용함. 심지어 필요가 없는 경우에도.
  4. 사람들이 React만 갖고 앱을 만들 줄 모름.

코리 하우스

결론

이 문제를 해결하기 위해 구현할 수 있는 수많은 추상과 패턴이 있지만 제가 이걸 다 적으려면 올해 내내 적어도 시간이 모자랄 겁니다. 😉

그래서 제가 강조하고 싶은 부분은 상태가 존재한다면 필요한 곳에 최대한 가까이 보관하라는 점입니다. 실무적인 용어로 설명하면 폼 입력창의 오류 상태를 전역 스토어에 보관하지 말라는 뜻입니다. 정말로 필요한 경우가 아니라면 말이죠. (그런 경우는 분명 드물껍니다.) 이 규칙을 따른다면 애플리케이션에서 컴포넌트 상태를 사용할 가능성이 매우 높다 는 뜻이고 이런 경우에 컨텍스트나 싱글톤을 애플리케이션 어딘가에서 아마도 사용하고 싶어질 겁니다. 컴포넌트 트리의 작은 일부 영역이라도 이 접근 방법은 매우 유용합니다.

이 포스트는 Kent C. DoddsWhen to break up a component into multiple components를 번역한 글입니다.

리액트 애플리케이션을 작성할 때 하나의 리액트 컴포넌트로 작성해도 된다는 점을 알고 있나요? 하나의 거대한 컴포넌트 안에 애플리케이션 전체를 넣는다고 해도 기술적으로 불가능한 것은 전혀 아닙니다. 거대한 render 메소드와 엄청 많은 인스턴스 메소드, 수많은 상태가 있을 것이고 아마도 모든 생명주기 훅(lifecycle hook)을 사용해야 할 겁니다. (shouldComponentUpdatecomponentWillUnmount는 예외겠네요. 항상 상태가 갱신된 데다 컴포넌트가 언마운트 될 일이 없으니까요!)

이 방법을 사용하면 다음 문제를 마주하게 됩니다.

  1. 성능이 저하될 수 있습니다. 상태 변화가 일어날 때마다 애플리케이션 전체를 새로 그리게 됩니다.
  2. 코드 공유와 재사용성이... 쉽지 않을 겁니다.
  3. 상태 관리도 어렵습니다. 어느 상태와 이벤트 핸들러가 어느 JSX 부분에 사용되는지 보다 보면 두통 😬이 올겁니다. 그리고 버그 🐜를 추적하는 일도 쉽지 않습니다. (이 부분은 관심사 분리의 장점 중 하나입니다.)
  4. 테스트는 100% 통합 테스트입니다. 물론 반드시 나쁜 것은 아닙니다. 하지만 엣지 케이스를 테스트하기 쉽지 않고 테스트를 하려는 부분을 격리하는 일이 어렵습니다. 그래서 이런 테스트를 유지하는 것 자체가 큰 도전입니다.
  5. 여러 엔지니어와 함께 이 코드 위에서 작업하는 일은 끔찍합니다. git diff의 결과가 어떨지, merge로 발생하는 코드 충돌이 상상이라도 가나요?!
  6. 서드파티 컴포넌트 라이브러리를 사용하는 게 불가능하지 않을까요? 단일 컴포넌트에 모든 것을 작성하는 것이 목표라면 서드파티 라이브러리 사용 자체가 좀 이상한 상황이 될 겁니다. 서드파티 컴포넌트를 사용한다고 하더라도 고차컴포넌트를 사용하는 react-emotion 같은 라이브러리는 어떨까요? 쓸 수 없겠죠!
  7. 선언적 컴포넌트 안에서 명령형 추상/API를 캡슐화하는 일은 어렵습니다. 한 거대한 컴포넌트 속 생명주기 훅에 널리 흩뿌려져 있게 되어 코드를 따라가기 더욱 어려워집니다.

이런 이유에서 컴포넌트로 나눠 작성합니다. 컴포넌트를 나누면 위 문제를 해결할 수 있게 됩니다.

한동안 AMA에서 앱을 컴포넌트로 나누는 좋은 방법/패턴이 있는가에 대한 질문을 받았습니다. 이게 제 답변입니다. "만약 위에서 살펴본 문제를 직면하게 될 때, 컴포넌트를 여러 작은 컴포넌트로 나눠야 할 때입니다. 미리 나누지 말고요." 단일 컴포넌트를 여러 컴포넌트로 나누는 일을 우리는 "추상(abstraction)"이라고 부르고 있습니다. 추상은 멋지지만 모든 추상은 비용이 따릅니다. 그래서 추상에 되려 당하기 전에 이 비용과 이득에 대해 유의하고 있어야 합니다.

중복은 잘못된 추상보다 훨씬 저렴합니다.Sandi Metz

저는 제 컴포넌트의 render 메소드가 정말 길어져도 크게 개의치 않습니다. JSX는 JavaScript 표현식의 모음이고 컴포넌트를 위해 선언적 API를 사용한다는 점을 기억하세요. 컴포넌트를 작은 크기로 나눠 이곳저곳에 프로퍼티 내리꽂기 (역자 주: 번역)를 하지 않고 render 메소드를 그대로 유지한다고 해서 그렇게 코드가 엄청 잘못되거나 하는 것은 아니라는 의미입니다.

결론

컴포넌트를 더 작은 크기로 쪼개는 것은 자유입니다. 하지만 진짜 문제를 경험하기 전까지는 크기가 커지는 컴포넌트에 대해 두려워하지 마세요. 너무 이르게 추상적으로 나누는 것보다 정말로 필요할 때까지 기다렸다가 적용하는 것이 코드 관리에 훨씬 편리합니다.

이 포스트는 Kent C. DoddsProp Drilling을 번역한 글입니다.

이 글에서는 프로퍼티 내리꽂기(prop drilling)를 이해하는데 그치지 않고 어떤 부분이 문제가 될 수 있는지, 이 문제를 피하기 위해 사용할 수 있는 방법이 무엇인지 확인하려고 합니다. (혹자는 "나사 구멍 내기 threading" 라고도 합니다.)

프로퍼티 내리꽂기가 무엇인가요?

프로퍼티 내리꽂기(또는 나사 구멍 내기)는 리액트의 컴포넌트 트리에서 데이터를 전달하기 위해서 필요한 과정을 의미합니다. 다음 간단한 상태 컴포넌트 예제로 살펴보도록 합니다. (제가 가장 좋아하는 컴포넌트 예제입니다.)

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => this.setState(({on}) => ({on: !on}))
  render() {
    return (
      <div>
        <div>The button is {on ? 'on' : 'off'}</div>
        <button onClick={this.toggle}>Toggle</button>
      </div>
    )
  }
}

이 컴포넌트를 둘로 리팩토링합니다.

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => this.setState(({on}) => ({on: !on}))
  render() {
    return <Switch on={this.state.on} onToggle={this.toggle} />
  }
}

function Switch({on, onToggle}) {
  return (
    <div>
      <div>The button is {on ? 'on' : 'off'}</div>
      <button onClick={onToggle}>Toggle</button>
    </div>
  )
}

Switchtoggleon 상태(state)에 대한 참조가 필요하기 때문에 간단히 프로퍼티로 전달했습니다. 한번 더 리펙토링 과정을 거쳐 컴포넌트 트리에 하나의 레이어를 더 추가합니다.

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => this.setState(({on}) => ({on: !on}))
  render() {
    return <Switch on={this.state.on} onToggle={this.toggle} />
  }
}

function Switch({on, onToggle}) {
  return (
    <div>
      <SwitchMessage on={on} />
      <SwitchButton onToggle={onToggle} />
    </div>
  )
}

function SwitchMessage({on}) {
  return <div>The button is {on ? 'on' : 'off'}</div>
}

function SwitchButton({onToggle}) {
  return <button onClick={onToggle}>Toggle</button>
}

이 과정을 프로퍼티 내리꽂기라고 합니다. on 상태와 toggle 핸들러를 필요한 위치에 두기 위해서 프로퍼티를 내려 꽂아(또는 나사 구멍을 내서) Switch 컴포넌트까지 전달해야 합니다. Switch 컴포넌트 자체는 동작을 위해 이 프로퍼티가 필요한 것이 아니더라도 자식 컴포넌트에 필요하기 때문에 프로퍼티로 받아서 전달해야 합니다.

왜 프로퍼티 내리꽂기가 좋은가요?

한번이라도 전역 변수를 사용하는 애플리케이션을 작업한 적이 있나요? AngularJS 애플리케이션에서 고립되지 않은 $scope를 사용하는 경우를 겪어본 적이 있나요? 많은 사람들이 이런 기법을 크게 거부하는 이유는 애플리케이션의 데이터 모델을 아주 혼란스러운 모습으로 이끌기 때문입니다. 누구든 데이터가 어디서 초기화되고 갱신되며 사용되는지 판단하기 쉽지 않은 상황에 마주하게 됩니다. 이런 상황에서 "이 코드를 수정하거나 지워도 다른 곳이 고장나지 않을까" 하는 질문에 답하기란 어렵습니다. 또한 이 질문은 당신이 작성한 코드를 최적화하는데 물어봐야 할 필수적인 질문입니다.

전역 변수보다 ES모듈을 선호하는 이유 중 하나는 이 모듈이 값이 어디서 사용되는지 더 명시적이며, 값을 추적하는데 더 쉽고, 코드 변경이 애플리케이션의 다른 영역에 어떤 영향을 주는지 파악하는데 용이하기 때문입니다.

가장 기초적인 수준의 프로퍼티 내리꽂기를 생각해보면 단순히 애플리케이션의 뷰에 어떤 값을 명시적으로 전달하는가에 해당합니다. 위 예제의 Toggle에서 on 상태를 열거형(enum)으로 리팩토링한다고 가정해보면 이 방식이 좋은 이유를 알 수 있습니다. 코드를 실행하지 않고도 정적으로 따라가는 것으로 어디서 사용하는지 쉽게 파악할 수 있고 수정도 간단하게 할 수 있습니다. 여기서 중요한 점은 암시적인 것보다 명시적으로 작성하는데 있습니다.

프로퍼티 내리꽂기로 발생하는 문제는 무엇인가요?

위에서 본 예제에서는 전혀 문제가 없습니다. 하지만 애플리케이션이 성장하면 여러 계층의 컴포넌트를 만들며 프로퍼티 내리꽂기를 하기 위해 이곳저곳에 깊은 구멍을 만드는 자신을 마주하게 될 겁니다. 일반적으로 작성하는 초기에는 큰 문제가 아닙니다. 코드를 계속 몇 주간 작성하다 보면 이런 과정이 거추장스럽게 느껴지는 몇 가지 경우를 겪게 됩니다.

  • 일부 데이터의 자료형을 바꾸게 되는 경우 (예: {user: {name: 'Joe West'}} -> {user: {firstName: 'Joe', lastName: 'West'}})
  • 필요보다 많은 프로퍼티를 전달하다가 컴포넌트를 분리하는 과정에서 필요 없는 프로퍼티가 계속 남는 경우
  • 필요보다 적은 프로퍼티를 전달하면서 동시에 defaultProps를 과용한 결과로 필요한 프로퍼티가 전달되지 않은 상황을 문제를 인지하지 어려운 경우 (또한 컴포넌트 분리 과정에서도)
  • 프로퍼티의 이름이 중간에서 변경되어서 값을 추적하는데 쉽지 않아지는 경우 (예를 들어 <Toggle on={this.state.on} />에서 <Switch toggleIsOn={on} />를 렌더링)

이외에도 프로퍼티 내리꽂기로 나타나는 여러 문제 상황이 있습니다. 특히 리펙토링을 진행하다보면 이런 부분에 고통을 겪습니다.

프로퍼티 내리꽂기에서 나타나는 문제를 어떻게 피할까요?

프로퍼티 내리꽂기에서 문제를 더 악화시키는 것 중 하나는 render 메소드를 불필요하게 여러 컴포넌트로 나누는 경우입니다. 큰 render 메소드 하나를 사용하는 것이 상황을 얼마나 쉽게 만들 수 있는가에 놀랄 겁니다. 가능한 한 메소드에 두세요. 이 메소드를 너무 성급하게 나눠야 할 필요가 없습니다. 다시 재사용해야 하는 상황이 될 때까지 기다린 후에 나누기 바랍니다. 그렇게 하면 불필요한 프로퍼티 내리꽂기를 할 필요가 없게 될겁니다.

재미있는 사실은 단 하나의 리액트 컴포넌트에 애플리케이션 전체를 작성한다고 한들 기술적으로 제약된 부분이 전혀 없습니다. 전체 애플리케이션의 상태를 관리하고 하나의 거대한 render 메소드를 사용하는 것도 가능합니다. 물론 이 방법을 권장하는 것은 아니지만요. 한번 생각해볼 만한 부분이라고 봅니다. :)

노트: 이 개념을 컴포넌트를 여러 컴포넌트로 나눠야 할 때 (역자 주: 번역)에서 작성했습니다. 관심있다면 확인해보세요.

프로퍼티 내리꽂기로 나타나는 문제를 완화하는 또 다른 방법은 defaultProps를 필수 프로퍼티에 사용하지 않는 방법입니다. defaultProps를 사용하면 컴포넌트가 제대로 동작하기 위해 실제로 필요한 프로퍼티를 전달받지 못한 상황인데도 중요한 오류가 숨겨지고 소리없이 실패하게 됩니다. 그렇기 때문에 defaultProps는 컴포넌트에 필수적이지 않은 부분에만 사용하기 바랍니다.

관련있는 상태는 될 수 있으면 가까이에 보관하기 바랍니다. 애플리케이션의 특정 부분에만 상태가 필요하다면 그 상태는 애플리케이션의 가장 높은 계층에 저장할 것이 아니라 최소 공통 부모 컴포넌트에서 관리해야 합니다. 상태 관리에 관해서는 애플리케이션 상태 관리 (역자 주: 번역)를 참고하기 바랍니다.

상태가 리액트 계층에서 정말로 깊숙이 위치한 경우라면 리액트의 새 컨텍스트 API를 사용하세요. 상태라고 애플리케이션 내 어디서나 다 접근할 필요는 없기 때문입니다. (컨텍스트 제공자는 어디서나 렌더링 할 수 있습니다.) 이 API는 프로퍼티 내리꽂기로 나타나는 몇 문제를 피하는 데 도움이 됩니다. 물론 이 컨텍스트는 전역 변수로 문제를 해결하는 방식과 유사성이 있습니다. 하지만 API가 디자인된 방식이 다르기 때문에 여전히 컨텍스트의 원천도 정적으로 찾을 수 있으며 어떻게 사용하든 상대적으로 손쉽게 사용 가능합니다.

결론

프로퍼티 내리꽂기는 장점도, 단점도 있는 양날의 검입니다. 위에서 언급한 올바른 사용법을 따르면 더 유지보수하기 쉬운 애플리케이션을 만드는데 도움되는 기능이 될 겁니다.

웹사이트 설정

웹페이지 색상을 선택하세요

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.