노동절 주말에 처제네 보러 텍사스에 다녀왔다. 텍사스는 처음으로 가보기도 하고 처제네도 오랜만에 보는 터였다. 계획을 멋지게 만들어준 덕분에 좋은 시간 함께 보내서 즐거웠다. 짧게라도 사진과 함께 정리했다.

여행 내내 텍사스로 이사를 오는 것은 어떨까에 대해서도 이야기했었다. 지진을 피할 수 있다든지, 집값이 저렴하다든지 이런 저런 이야기도 했고. 사람 사는 곳이야 다 장단점이 있고 즐거운 일이야 찾으면 있을테니 난 큰 걱정은 없다. 하지만 이제는 가족이 있으니 혼자서 결정하던 그런 시절처럼 막 결정할 수는 없으니까. 그래도 다양한 선택지를 두고 생각할 수 있다는 것 자체가 특권이고, 지나치게 스트레스 받지 말고 감사해야 하는 것이 분명하다.

노동절이라고 학교가 쉬기는 하지만 그런 이유로 과제를 더 내주셔서 여행가기 직전까지 엄청나게 바빴다. 늘 여행 가기 전에 여행지를 엄청 알아보고 공부하고 가는 편인데 전혀 그러지도 않고. 다녀와서도 바뻐야 하는데 지금 사진 정리한다고 이렇게 시간을 쓰고 있어서 글 올리고 다시 과제의 세계로 들어가야 하는 운명... 😇😇

이번 여행에는 카메라를 들고 가지 않고 스마트폰으로만 사진을 찍었다. 결과물에 아쉬운 부분도 있지만 그래도 가볍게 다닐 수 있어서 좋았다. 아쉬움 49%, 편함 51%. 앱은 모먼트로, raw 촬영했고, 라이트룸으로 편집했다.

LAX는 올 때마다 복잡하다!
그런데 Urth 카페도 생기고 🥰
처음 겪은 출발 지연. 지루했지만 잘 이겨냈다! 다만 같이 탄 아이들이 다 울어서 잠도 못자고.
새벽에 도착했는데 지연 탓인지 공항에 아무도 없었다. 다들 청소중이고.
숙소로 가자마자 쓰러졌다..
아침에 일어나서 보니 숙소도 독특했다
아침부터 라면 먹는 것 여행 모먼트
밖에 나와서 깜짝 놀랐다. 아침 일찍부터 습하고 찜질방.

날씨 생각보다 엄청 후끈했다. 더운 것 자체보다는 습도에 놀랐는데 조금만 걸어도 체력이 바로 바닥이 되는 기분이었다. 바베큐로 에너지를 충전하는 것으로 일정을 시작했다.

많은 사람들이 이미 기다리고 있었다. 한국서도 어떤 프로에서 와서 촬영했다는, 바베큐로 유명한 곳.

이렇게 더운데 어떻게 바베큐를 만드는 것이지? 정답은 빵빵한 에어컨 틀어놓고 실내에서 만든다. 현대 문명 최고다.

고기 굽는 곳은 너무 비현실적이고 징그러워서 좀 놀랐는데.
바베큐, 브리스킷, 소세지, 빵, 콘슬로까지 전부 다 대박, 정말 배부르게 먹었다. 최고였고 왜 다들 바베큐 노래를 부르는지 배웠다.

고기를 먹었으니 이제 알콜로 소화를 돕는 곳으로 왔다.

배부르게 먹고 양조장으로 향했다.
같이 투어한 멍뭉이
영화에서나 봤던 그런 분위기
양조 과정을 설명해줘서 재미있었다. 위스키와 진의 차이라든지, 숙성 과정이라든지, 등등.
아 네, 칵테일과 샷 몇 잔에 얼굴이 시뻘개졌습니다. 심지어 빨간 티셔츠 입고 갔는데 진정 홍인이 되었다.
너무 평화롭고.

술 만드는 과정이야 다 비슷하겠지만 들어가는 재료가 어떻게 다른지 설명해주고 살펴볼 수 있어서 재미있었다. 술 잘 못마셔서 늘 숙취가 있는데 고급 알콜이라 숙취도 없었나 싶다. 하하하... 발효실은 더워서 땀이 났는데 그 사이 모기도 엄청 물리고. 여행 내내 모기와의 전쟁이었는데.

호수 옆에 있던 귀여운 카페인데 호수는 정작 사진도 안남겼다.
학생이 많은 것 같아서 물어봤더니 근처에 학교가 있다고. 연휴에 다들 이렇게 폭풍 공부하는데 저는 놀러왔습니다. 흑흐겋그흑..
커피 굿, 브라우니 굿, 분위기 너무 좋고, 여기서 공부하면 만점 나오겠다.

그러고 오스틴에 도착했다. 크게 구경하진 않고 그냥 차타고 돌았는데 창 밖에 보이는 풍경도 좋았고.

벽화 있는 곳에서 사진 찍으려고 잠시 내렸는데 동네 분위기 너무 평화롭고 좋았다.
샌안토니오로 다시 이동

오스틴 구경 슬쩍하고 다시 돌아가는데, 오스틴도 힙한 카페도 많고 또 시간 내서 오기로 약속했다. 텍사스는 땅이 커서 그런지 동네 동네 이동하는 것도 일이다. 캘리도 꽤 멀리 이동한다고 생각했는데 텍사스는 더 멀고.

왼쪽엔 비가 오고 오른쪽은 맑은 날씨.
다음 날.
주일 아침 예배. 화에 대해서.
점심 타코... 택사스 타코랑 캘리포니아 타코랑 너무 다르다. 그리고 너무 맛있다. 울면서 먹었네.
다들 코스트코 간 사이에 아내와 데이트했다. 식물 많은 카페 너무 이쁘다. 커피도 맛있고.
온실처럼 꾸며놔서 조금 덥긴 했다 😅 커피 맛있었고, 분위기도 좋았고.
대체로 맑고 구름 많고 습한 날씨.
샌 안토니오 여행 오면 꼭 가야 한다는 리버워크인데, 귀여운데 사람 엄청 많고... 모기 엄청 물렸다.
예전에 브루어리였던 곳을 꾸민 곳인데 너무 이뻤다.
브루어리였던 건물은 호텔로, 주변에 먹을 곳도 많고, 우린 더워서 서둘러 보고 나왔다.

집에 오면서 사천 음식 사와서 함께 먹었다. 가족 만나는 일은 시간이 얼마나 되었든 항상 짧은 것 같다. 이런 저런 이야기하고 저녁 먹고.

다음 날은 새벽 일찍 나와서 공항으로 향했다.

한참 잠자다가 일어났는데 옆에서 엄청 공부하고 있어서 좀 뜨끔했고
아침 LAX는 복잡했다. 빨리 집으로.

오랜만에 글을 쓴다고 자리에 앉았다. 작년에 미국으로 넘어 온 이후로 지금까지 많은 일이 있었는데 기록을 제대로 남기지 않았던 탓에 블로그도 휑하고 기억도 가물가물하다. 올해 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가 디자인된 방식이 다르기 때문에 여전히 컨텍스트의 원천도 정적으로 찾을 수 있으며 어떻게 사용하든 상대적으로 손쉽게 사용 가능합니다.

결론

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

App Screenshot

iOS 앱 Tiny Tip Calculator를 만들었다.

계기

매번 식사를 밖에서 할 때마다 팁을 계산하는 모습을 보고 간편한 팁 계산기가 있으면 좋겠다고 생각했다. 그래서 앱스토어에서 받으려고 검색했는데 수많은 팁 계산기가 다음 부류였다.

  • 광고가 지나치게 많아서 사용성을 크게 해침
  • 결과를 보기까지 인터렉션이 너무 많이 필요
  • 그냥 안 이쁨

어떤 앱은 세 가지 모두에 해당했다. 그래서 간단한 앱을 하나 만들기로 했다.

도구 선정

집에 있는 모든 사람이 아이폰을 사용하고 있어서 iOS 앱을 만들기로 했고 무엇으로 개발할지 고민했다.

  • Swift는 일단 네이티브니 성능도 좋고 원하는 만큼 뜯어고칠 수 있겠지만 익숙하지 않았다. 물론 만들면서 배우는 것만큼 학습에 좋은 방식은 없지만 원하는 결과물을 만들어 내는 데 집중하고 싶었다.
  • React Native도 고려했다. React Native의 툴링도 좋고, 성능도 마음에 들고 예전보다 확실히 리소스가 많았다. expo.io도 멋지다.
  • Ionic은 Cordova와 Angular를 잘 섞은 프레임워크였는데 아무래도 웹앱이라서 성능에 대한 걱정이 들었다. 그래도 문서도 정리가 잘 된 편이었고 네이티브 기능을 쉽게 사용할 수 있도록 플러그인도 많이 제공했다.

React도 계속 공부하고 써보려고 하고 있지만, 여전히 Angular가 익숙한 데다 생각보다 Ionic의 성능이 좋아서 Ionic으로 선택했다. React에 좀 더 익숙하다면 React Native를 고민 없이 선택했을 것이다. 결과물을 빠르게 보겠다는 생각 탓에 편향적인 결정을 내렸다.

개발 목표

개발하기 전에 다음 목표를 정했다.

  • 내장 키보드 말고 키패드 직접 만들 것, 계산기처럼 모든 기능이 보이도록.
  • 입력 횟수를 최소로 하기.
  • 팁 비율을 목록으로 보여줌. 비율은 프리셋 설정할 수 있도록.
  • 이쁘고 깔끔하게, 슬라이드 같은 것도 넣지 말고. 테마도 지원하면 좋겠음.
  • 앱처럼 보이도록!

개발 계획

프로젝트를 생성할 때 정도만 문서를 봤고 Angular는 이미 익숙해서 평소 작업하듯 만들었다. 로직은 별문제 없이 만들었지만, 스타일에 수고가 많이 들었다.

전체적으로 페이지 수는 얼마 되지 않았다.

  • 계산 페이지
    • (계산 모드)
    • (결과 모드)
  • 설정 목록
    • 통화 및 팁 비율 설정
    • 테마 설정
    • 소개 페이지

Angular는 각 컴포넌트나 디렉티브, 모듈의 선언적 관리가 전체적인 코드 구성에 정말 편리했다. TypeScript와 함께 궁합도 너무 좋았다. 프레임워크 답게 전체적인 만듦새나 구조는 Angular가 확실히 더 이해하기 좋게 느껴진다.

앱에서 필요한 부분은 모두 ionic에서 제공하는 플러그인으로 충분했다. Storage 등도 이미 다 고수준으로 제공하고 있는 데다 angular에서 손쉽게 의존성 주입으로 활용할 수 있었다.

Good & Bad

웹과 다른 흐름의 도구를 제대로 만들어서 결과를 낸 것은 처음이었다. 이 과정에서 좋았던 점은 다음 같았다.

  • 익숙한 도구를 선택해서 빠른 결과를 냈다. 첫 PoC를 만드는 데 하루 걸렸고 전체적으로 코드를 정리하고 작성하는데 3일 정도 사용했다. 테마 구현을 가장 고민했었는데 의외로 손쉽게 해결했다.
  • 다른 앱의 리뷰를 찾아보고 새로운 유즈케이스를 찾아 기능을 추가했다. 리뷰 중에 세금 불포함 계산 방식이 필요하다는 이야기가 반복되었지만 지원하는 앱이 거의 없었다. 그래서 해당 기능을 첫 릴리즈 이후 추가해서 배포했다.
  • 파워 유저(이자 아내)에게 빠른 피드백을 받을 수 있었다. 실제로 앱을 사용하는 과정도 옆에서 볼 수 있었던 점도 새로운 경험이었다. 버튼의 위치나 세부적인 기능에 대한 조언은 앱에 반영되었다.

아쉬운 점도 있었다.

  • 익숙한 도구를 사용해서 기술 배경에서 크게 도전적인 프로젝트는 아니었다. 게다가 네이티브 환경이 아니라서 이쁘지 않은 부분이 계속 눈에 걸렸다. 예를 들면 스크롤바가 화면 밖에 걸쳐 있다든지 하는 부분은 하이브리드앱에서 흔히 겪는 문제다. 이런 부분이 크게 사용성을 해치는 상황은 아니지만 안 이쁜 건 계속 거슬렸다.
  • 테스트가 미흡했다. Ionic과 Angular 모두 테스트에 유리한 환경을 기본적으로 제공하는데도 테스트를 부지런히 작성하지 않았다. 빠르게 작성하는 데는 성공했지만 좋은 품질을 유지하고 작성하는 일에는 소홀했다.
  • 수익성을 고려하지 않았고 프로모션도 하지 않았다. 다른 앱 리뷰에서 광고 얘기를 보면서 아예 광고를 생각하지 않았다. 지금이야 크게 나쁜 결정이라는 생각은 들지 않지만, 애플 개발자 계정을 연장할 때면 왜 이런 결정을 했을까 생각 들 것 같다.

결과

이미 많은 앱이 존재해서 검색 목록 위로 올라가는 일조차 쉽지 않지만 어떤 방식으로든 결과물을 빠르게 냈다는 점에 즐거웠다. 그리고 작은 앱이더라도 옆에서 부지런히 쓰는 사용자가 있어서 재미있었다. 다음 또 앱을 만든다면 어떤 점을 미리 고려해야 하는지도 배웠다.


트러블 슈팅

iOS에서 overflow로 생성한 스크롤 부드럽게(?) 하기

스타일에서 overflow 프로퍼티를 적용하면 웹 페이지 내에 스크롤을 넣을 수 있다. 그런데 iOS에서는 그 스크롤을 써보면 일반적으로 경험할 수 있는 스크롤처럼 부드럽지 않고 뻑뻑하게 움직인다. 이 동작을 iOS의 기본 동작처럼 바꾸려면 다음 스타일을 추가로 넣어야 한다.

--webkit-overflow-scrolling: touch;

최근 모델의 notch 해결하기

이 스타일은 Safari만 지원한다.

먼저 웹뷰의 페이지에서는 기본적으로 상단의 스테이터스바를 안전 영역으로 처리한다. 그래서 고정 영역을 예로 들어 top: 0로 설정해도 스테이터스바 바로 아래에 자리를 잡는다. 화면 전체를 웹 영역에 넣고 처리하고 싶은 경우에는 viewport-fit을 viewport에 추가해야 한다.

viewport-fit은 다음 3가지 값을 지원한다. csswg 문서에 그림을 잘 그려놨다.

  • auto: 기본 값으로 아무 동작하지 않는다.
  • contain: 웹뷰 영역이 잘리지 않도록 디바이스 영역에 맞춘다.
  • cover: 디바이스 영역에 빈틈이 없도록 웹뷰 영역을 맞춘다.
<meta name="viewport" content="viewport-fit=cover, ..." />

이제 화면 전체 영역을 사용할 수 있게 되었다. 이제 스테이터스바 영역만큼 밀어야 할 필요가 생긴다.

header.global {
    padding-top: 20px;
}

하지만 이렇게 고정값을 넣으면 노치가 없는 기기에서 높이가 이상해진다. 이런 상황에서는 safe-area-inset-* 상수를 사용하면 된다.

heaader.global {
    padding-top: constant(safe-area-inset-top); /* iOS 11.0 */
    padding-top: env(safe-area-inset-top); /* iOS 11+ */
}

constraint()는 없어질 예정이며 이후로는 env()를 사용하면 되겠다.

CSS Grid

고정된 화면을 기준으로 각 컴포넌트를 배치하는데 css grid가 정말 편했다. vw, vhcalc()를 함께 쓰면 어떤 컴포넌트든 원하는 위치에 놓을 수 있었다.

position: sticky

stickyposition 프로퍼티에 사용하면 필요한 요소를 고정으로 띄울 수 있다. 예전엔 위치 계산해서 fixed를 직접 설정해줬어야 했는데 손쉽게 구현할 수 있다.

다만 iOS에서 sticky에 배경을 지정해도 위에 1px 정도 아래 엘리먼트가 보이는 문제가 있었다. transform: translateY(-1px)를 추가해서 약간 비틀어서 해결되었고 GPU 가속이 동작해선지 약간 버벅이던 동작도 없어졌다.

:host-context()

하위 컴포넌트가 상위 컴포넌트의 스타일에 따라 스타일을 제어해야 할 때가 있는데 여기에 :host-context()를 사용할 수 있다. [theme]과 같은 디렉티브를 사용해서 부모 엘리먼트에 클래스를 추가하도록 했다면 :host-context()로 해당 클래스를 추적하는 것이 가능하다.

// hello.theme.scss
:host-context(.theme--hello) {
    * {
        font-family: 'Arial Rounded MT Bold', sans-serif;
    }

    header {
        color: #ff00c3;
    }
    // ...
}

현재는 이렇게 만든 각 테마 scss를 한 곳에서 모두 불러오는 구조로 되어 있다. 이 구현을 다시 한다면 중간에 테마를 제어하는 컴포넌트를 ViewEncapsulation.None로 놓고 테마 스타일을 동적으로 불러오도록 처리하고 싶다.

오버 스크롤 끄기

하이브리드앱을 가장 하이브리드앱처럼 보이게 하는 동작 중 하나가 오버 스크롤이다. 이 동작으로 전체 레이아웃이 고정된 부분 없이 움직이면 앱처럼 느껴지지 않는다. Cordova에서는 다음 속성을 config.xml에 추가하면 이 문제를 해결할 수 있다.

<!--config.xml in the project-->
<preference name="DisallowOverscroll" value="true" />

Version API 만들기

최신 버전을 확인하고 새 버전이 있다면 업데이트를 하도록 작은 버튼을 띄워주고 싶었다. 예전엔 블로그용 서버가 있어서 코드를 작성해서 올리면 되었겠지만 이제는 정적 블로그를 사용하고 있어서 아무래도 제한되는 부분이 있었다. 요구 사항은 이랬다.

  • 적어도 response header를 제어할 수 있어야 함
  • 반환값은 하드 코딩이어도 큰 상관 없음, 디비 연동도 필요 없음
  • 비용으로 가장 저렴할 것 (연 5불 이하)
  • 인증 필요 없음
  • https 지원
  • 관리는 최소로

그래서 Azure Functions를 선택하게 되었다.

module.exports = async function (context) {
    context.res = {
        headers: {
            'Content-Type': 'application/json',
        },
        body: {
            version: '1.1.4',
            tagline: 'New Theme: Mono',
            link: 'https://itunes.apple.com/app/tiny-tip-calculator/id1448227957?mt=8'
        }
    };
};

아쉽게도 현재 netlify에 물려 있는 도메인을 사용해서는 API 주소를 https로 사용할 수 없었다. Azure의 앱서비스 내로 도메인을 가져와야만 A 레코드로 사용할 수 있었고 CNAME은 https의 인증서에 문제가 있다고 접근이 되질 않았다. 큰 기능의 API도 아닌 탓에 그냥 기본으로 제공하는 azurewebsites.net의 서브 도메인을 사용했다.

Azure Functions의 비용은 앱 서비스 플랜과 종량제 플랜 중 하나를 고를 수 있는데 앱 서비스 플랜의 경우는 앱 서비스를 사용해서 function app을 실행하는 방식으로 앱 서비스만큼 비용을 내야 하고 종량제는 쓰는 만큼 비용을 지불하는 방식이다. 내 경우에는 크게 사용량이 많지 않을 것이라는 판단에서 종량제 플랜을 선택했다.

하지만 종량제 플랜에서는 Cold start가 너무 느렸다. 앱서비스 플랜은 인스턴스를 계속 띄우고 있기 때문에 항상 빠른 응답을 받을 수 있다. 하지만 종량제 플랜에서는 Function app 호출이 특정 시간 동안 없을 땐 해당 코드가 구동되는 인스턴스를 없엤다가 호출이 있을 때 인스턴스를 다시 생성해서 코드를 올려 구동한다. 그래서 인스턴스가 올라와 있는 상황에서 호출하면 warm start로 응답이 100ms 미만으로 매우 빠르지만, cold start는 인스턴스를 올리는 것부터 시작하기 때문에 더 오랜 시간이 걸렸다. 내 경우에는 코드가 의존성도 없고 매우 단순한데도 8초에서 22초까지 걸렸다. 비용은 코드를 실행해서 결과를 반환하기까지 시간에 대해서만 청구되기 때문에 비용적으로 문제는 없었다.

현재 사용량이 상당히 적은 상태인데 이 문제 때문에 앱 서비스 플랜을 사용하고 싶지는 않았다. 그래서 해결 방법을 찾아보니 그냥 계속 살아 있도록 function을 호출하는 방법이 있었다. 그래서 5분 간격으로 function app을 실행하도록 Timer 트리거를 추가했다.

{
  "bindings": [
    {
      "name": "myTimer",
      "type": "timerTrigger",
      "direction": "in",
      "schedule": "0 */5 * * * *"
    }
  ]
}
module.exports = async function () {
    console.log('health checked');
};

만약 사용량이 많아지면 Azure Functions는 알아서 스케일링을 수행한다. 스케일링을 수행하면 새 인스턴스가 생성되면서 cold start가 다시 발생할 수 있지만, 현재는 두 API 모두 큰 자원을 소모하지 않고 있고 사용량도 적기 때문에 계속 warm start를 유지할 수 있었다. 만약 다른 인스턴스가 올라가서 다시 cold start가 감지된다면 그때는 앱 서비스 플랜으로 변경할 생각이다.

현재까지 비용 예측은 월간 $0.25 정도고 이조차도 Functions 호출 비용보다 코드가 저장된 공간인 스토리지의 비용이 대부분을 차지하고 있다.

빌드와 배포

빌드와 배포 과정에서 문제가 되었던 부분이 몇 있었는데 검색으로 쉽게 해소했다.

  • Xcode의 모던 빌드 시스템을 사용하면 문제가 생긴다. 빌드를 생성할 때 다음 플래그를 추가한다.
    $ ionic cordova build ios -- --buildFlag="-UseModernBuildSystem=0"
    
  • Xcode에서 **File > Project Settings...**에 들어가서 Build System을 Legacy Build System으로 변경한다.
  • Signing에 문제가 있다고 나오면 Automatically manage signing의 체크 박스를 해제하고 Xcode를 다시 실행해서 다시 체크한다.
  • 일반적인 암호화 외의 기능을 사용하면 앱 배포에 추가적인 절차가 필요하다. 하지만 단순히 API 호출에 HTTPS를 사용하거나 인증 절차에 사용하는 경우에는 예외에 해당한다. info.plistITSAppUsesNonExemptEncryptionNO로 설정한다.

기술 문서 번역 모임에서 2019년 1월 10일 첫 모임을 가졌다. 원격으로 발표할 수 있는 기회가 주어져 감사하다. 다녀온 분들 말씀이 다들 좋았다고 하셔서 현장에서 참여하지 못한 점이 더 아쉽다.

발표를 하기 위해 슬라이드를 두 번 만들었다. 처음 만든 슬라이드는 생각보다 내용이 너무 길어져버려서 랩이라도 하듯 슬라이드를 읽어도 분량이 도무지 줄어들지 않았다. 그래서 아예 새로 만들었는데 다시 보니 도구 얘기보다 너무 겉도는 이야기만 한 것 같아 아쉽다. 연습이 필요한 부분이다!


행사 정보와 발표자 슬라이드는 기술 문서 번역 모임: 번역 도구 이야기에서 볼 수 있다.

다음 모임 공지는 현우님 트위터에서 가장 먼저 접할 수 있을 것 같다.

WRITE THE DOCS 서울이 2019년 3월 23일 오후 2시 마루180에서 있다고 한다.


다른 발표 슬라이드 보면서도 적어두고 싶은 부분이 많았다.

  • 기술 용어 옮길 때의 책임: 독자가 내용을 명확히 이해, 좋은 번역 단어가 자리 잡는데 일조함
  • CAT 도구들: transifex, smartcat, OmegaT, RedPen
  • LibreOffice Writer 괜찮다고 하니 써봐야겠다.
  • 딴 생각 할 시간에 한 글자다로 더 번역하는 게 좋습니다.
  • 번역이 즐거운 이유: 간접 지식 체득, 뿌듯함
  • 성장하기 위한 번역 - 알아가는 즐거움 - 나누기 위해 내가 더 알아야
  • muchtrans.com (github)
    • 읽다보니 번역이 주는 경험이 게이미피케이션과 유사한 것 같다.
  • Django 공동 번역: GetText, Sphinx, Transifex
  • Notion 가이드 번역을 Notion으로 한 이야기. 파워 유저를 부르는 도구 너무 매력적이다.
  • 기계 번역은 아직 불완전해서 사람이 현재라고. MS Word, Google Docs, Trados, MemoQ, OmegaT
  • W3C와 Google의 문서 번역 경험. 주변에서 피드백 주는 사람 있는 것 너무 좋다.

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

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