• 달리는 냄새

    달리면서 만나는 냄새들에 관한 짧은 생각.

    달리는 습관을 들이는 중이다. 올 여름, 졸업을 위해 좋아하던 회사에서 나와 학교로 돌아왔다. 마지막 학기를 앞두고 여러 목표를 세웠다. 무엇보다도 지금까지 꾸준히 함부로 다루며 망쳐놓은 몸을 다시 건강하게 만들고픈 마음이 간절했다. 술을 끊고 인생 첫 PT를 등록했다.

    달리기를 시작한 이유도 그래서였다. 헬스장에 갈 필요도 없고, 편한 옷 외에는 별다른 준비물도 없으니 빠질 핑계를 댈래야 댈수가 없는 운동이다. 귀찮고 힘들어도 하루, 이틀 참고 나가다보니 조금씩 재미를 느끼고 있다. 스무 해가 넘도록 운동과 담을 쌓고 살았는데 고작 며칠 뛰었다고! 신기하다.


    향수를 좋아한다. 작년 여름, 면세점에서 좋게 보면 시원한, 나쁘게 보면 아저씨 스킨 냄새가 나는 첫 향수를 (반쯤 충동적으로) 샀다. 뿌리다보니 괜히 기분이 좋고, 시간에 따라 향이 변해가는 것이 재미있었다. 그 이후로 하나씩 모으기 시작했는데, 어느새 한 병을 꼬박 쓰고도 다섯 병이 책상에 나란하다.

    그 전까지 나는 냄새를 안 좋은 냄새가 나거나, 아니거나 둘 중 하나 정도로만 받아들였다. 향수의 재미를 알게 되면서 세상의 다양한 냄새를 알아보게 되는 경험은 – 맛을 느끼는 데 후각이 큰 역할을 한다니 코가 좀 억울할지 몰라도 – 새로운 감각을 하나 얻는 것처럼 느껴졌다.


    달리다보면 여러 냄새를 만난다. 비가 온 뒤 달릴 때 도로의 이끼 낀 듯한 흙 냄새. 차가 앞지르고 나면 조금 후 풍기는 매연 냄새. 조용히 흐르는 강변에, 다리 난간에 걸린 화분에 핀 꽃 냄새. 방으로 돌아오는 길 내게서 나는 땀 냄새.

    달리는 중이 아니라면 – 즉 스마트폰에 정신을 팔 수 없는 불가피한 상황이 아니라면 – 몇 걸음 지나지 않아 맡은 줄도 까먹고 넘길 향들이다. 하지만 호흡과 팔다리의 움직임을 일정하게 유지하며 그다지 빠르게 바뀌지 않는 풍경만을 바라보는 상황에서는 이런 냄새가 생각보다 재미있는 자극이 된다.


    향수는 재밌는 물건이다. 30ml, 50ml 짜리 조그만 유리병에 – 아니 용액 자체는 균일하니, 보다 정확히는 고작 한 방울에 – 착용자의 주위를 감싸는 분위기와 장면을 담아내다니. 별로 대단해 “보이지도” 않는 한 방울은 조향사의 역량에 따라 단순하고 납작한 사탕 단내에 그치기도, 여러 시간에 걸쳐 끊임없이 모양새를 바꾸며 말을 걸기도 한다.

    그런 향수의 힘은 강력하다. 한 사람의 인상을 완전히 바꿔놓는다. 길거리에서 잠깐 스친 사람을 뒤돌아볼 수 밖에 없게 만들기도 한다. 힘든 하루를 보낸 어떤 날은, 집에 들어와 샤워하고 입은 향수의 향에 구원받는 느낌이 들었다. 그럴 때면 뜬구름 잡는 것 같고 조금은 웃기기도 했던 제품 소개 문구가 정말 이해가 갈 듯하다.


    그에 비하면, 달리면서 만나는 여러 냄새의 힘은 대체로 미약하다. 어찌보면 평면적이고, 그나마도 금세 멀어진다. 하지만 향수의 그 강력하고 잘 정돈된 향이 어디서 왔을까? 각자의 자리에 자연스럽게 널려 있는, 세상의 여러 냄새가 품은 모티프를 누군가 잘 다듬고 섞고 정제한 결과일 것이다.

    달리면서 지나는 길에서 다양하고 새로운 냄새를 만날 때면 이런 스침으로부터 병 속 세계가 만들어지기까지의 과정을 그려본다. 만약 내가 이 냄새로부터 시작해 새로운 향수를 만든다면 어떤 모습일지도. 하지만 이런 재밌는 생각의 씨앗을 알아보려면 우선 그 미약한 냄새에 주의를 기울일 줄 알아야 한다.

    다행히도, 달리다 보면 주의를 기울이기가 조금 쉬워진다. 그래서인지 나는 달리면서 만나는 냄새가 달려서 받는 선물처럼 느껴진다.


    어제는 처음으로 안양 집 앞에서 안양천을 따라 달렸다. 달리는 동안 떠다니던 생각을 트위터에 적을까– 하다가 블로그에 남겨본다.

  • 웹 개발자의 Travis CI 기반 Flutter 앱 지속적 빌드 및 배포 환경 구축기

    Flutter로 앱 생태계에 처음 발을 담근 웹 개발자가 Travis CI를 이용한 iOS, Android 두 플랫폼의 빌드 및 배포 자동화를 위해 고군분투한 기록.

    들어가며

    얼마 전, 구글의 크로스 플랫폼 UI 프레임워크 Flutter를 사용해 인생 첫 앱을 만들어 배포했다. 앱 개발에 대한 지식은 전무하다시피 했지만, Flutter 덕에 생각보다 짧은 시간 안에 만족할 만한 앱을 iOS, Android 두 플랫폼에 배포할 수 있었다. 처음 써보는 언어와 프레임워크지만 선언적 렌더링 기반인 덕에 적응하기도 쉬웠다. 문서도 잘 되어 있고 전반적인 개발 경험도 훌륭했다.

    문제는 배포였다. 웹 개발만 해오다가 앱 개발의 빌드–업로드–심사–배포 프로세스를 경험해보니 타르가 무릎 높이까지 차오른 오르막길에서 무거운 돌덩이를 굴려 올리는 기분이 들었다. 게다가 이걸 두 플랫폼에 대해서 반복해야 한다니!

    릴리즈가 필요할 때마다 CLI로 각 타겟을 빌드하고, 빌드 완료를 기다렸다가 App Store Connect와 Play Console에 들어가서 바이너리를 올린다. 생각만으로 우울해졌다. 코드를 짜는 것이 문제가 아니라, 이 짓을 하다 지쳐서 개발을 포기할 날이 금세 오리라는 확신이 들었다.

    그래서 자동화했다.

    모든 자동화가 완료된 시점의 스크린샷

    위 스크린샷은 GitHub 푸시에 의해 CI 머신에서 Android, iOS 빌드가 각각 트리거되어 TestFlight / Google Play 내부 테스트 트랙까지 올라가는 설정을 처음으로 성공했을 때 (마음 속으로) 감격의 눈물을 흘리며 찍은 화면이다.

    이 설정에 이르기까지, 원래 각오했던 것보다 훨씬 많은 고생과 삽질을 겪었다. 앱 개발 자체보다 훨씬 어렵다 느낄 정도로, 너무 고통스러웠다. 앱 개발 생태계에 처음 발을 들인 웹 개발자의 입장에서, 나중에 비슷한 고생을 할 사람을 위해 이 경험, 그리고 그 과정에서 배운 지식을 기록으로 남겨기로 결심했다.

    어떤 파일을 어떻게 바꾸라는 기계적인 전달 사항보다는 이 생태계가 어떤 요소들로 이루어지고, 각 작업이 어떤 의미가 있는지 설명하려 노력했다. 그러다보니 생각보다 글이 길어졌다. 앱 개발 생태계에 발을 들였다면 언젠가는 정면돌파 해야 할 내용이라, 긴 분량을 감수할 가치가 있다고 생각한다. 그 생각대로 도움이 되는 내용이면 좋겠다.

    글에서 다룬 모든 설정이 끝난 상태의 코드는 해당 시점의 갈피 GitHub 저장소에서 확인할 수 있다.

    본론에 들어가기 앞서

    이 글은 아래 환경을 기준으로 쓰였습니다. ( flutter doctor -v 커맨드 출력의 일부) 기준이 된 버전과 다른 환경에서는 글에 적힌 내용과 다르게 동작하는 부분이 있을 수 있습니다.

    [✓] Flutter (Channel dev, v1.9.2-pre.34, on Mac OS X 10.14.4 18E226, locale en-US)
        * Flutter version 1.9.2-pre.34 at /Users/travis/build/heejongahn/galpi/flutter
        * Framework revision e833a5820e (12 hours ago), 2019-08-20 11:00:21 -0700
        * Engine revision 10167db433
        * Dart version 2.5.0

    또한, 이 글은 독자가 웹 인터페이스를 이용해 Play Store 내부 테스트 트랙 / TestFlight 에 각각 빌드를 최소 한 번 이상 올려봤다고 가정합니다. 아직 수동 빌드 및 배포를 경험해 본 적이 없다면 이 글을 읽기 전에 먼저 공식 문서( Android , iOS)를 참고해 해당 과정을 밟아보세요.

    이 글에서 사용한 자동화 도구 fastlane은 공식적으로 macOS만을 지원합니다. Linux와 Windows 환경에서도 (제한된 버전의) CLI는 사용가능하다고 쓰여 있지만, 제가 개발 환경으로 맥북을 사용하고 있어서 다른 환경에서는 테스트 해 보지 못했습니다.

    처음부터 글 작성을 염두에 두고 작업을 한게 아닌 탓에, 사후에 커밋 로그와 빌드 히스토리로부터 기억을 되살려가며 글을 적었습니다. 때문에 빠져있거나 잘못된 부분이 있을 수 있습니다. 막히는 부분이나 틀린 내용을 발견하신다면 댓글이나 메일로 남겨주세요. 제가 겪어본 상황이라면 답변을 드리고 글 또한 적절하게 수정하겠습니다.


    👨‍💻 공통: Travis CI 기본 셋업

    가장 먼저, 빌드가 돌아가야 할 시점, 이 글의 경우 GitHub 저장소로의 코드 푸시를 감지하는 것부터 시작해보자. Travis CI의 GitHub Marketplace 페이지에서 리포지토리에 Travis CI를 붙일 수 있다. 오픈 소스 프로젝트의 경우 무료 플랜을 사용 가능하다.

    GitHub Travis CI 어플리케이션 추가 화면 스크린샷

    Open Source 플랜을 선택하고 Install it for free 버튼을 눌러 원하는 저장소에 Travis CI 어플리케이션을 설치할 수 있다. 설치된 어플리케이션은 GitHub 저장소의 메뉴바 “Settings” 를 클릭한 뒤 좌측 “Integrations & services” 에서 확인할 수 있다.

    어플리케이션이 잘 설치되었다면 Travis 설정 파일을 추가할 차례다. 프로젝트 루트에 .travis.yml 파일을 추가해 Travis CI가 어떤 일을 실행해야 할지 알려줄 수 있다. 일단 GitHub 푸시에 Travis 빌드가 트리거되는 것을 확인하기 위해 의존성을 내려받기만 하는 스크립트를 추가해보자.

    # .travis.yml #1: 의존성 설치를 수행한다.
    
    language: generic
    
    before_script:
      - git clone https://github.com/flutter/flutter.git -b stable
      - export PATH=`pwd`/flutter/bin:`pwd`/flutter/bin/cache/dart-sdk/bin:$PATH
    
    script:
      - flutter packages get
    

    이 때, 만약 stable 이외의 빌드 릴리즈 채널을 사용하고 싶다면 git clone 커맨드에서 브랜치를 적절히 설정하면 된다. 예를 들어, 나는 beta 채널을 사용하고 있으므로 -b stable 대신 -b beta 옵션을 사용했다.

    선택 사항: 써드 파티 API Key

    만약 구글, 네이버, 카카오 등의 써드 파티 API를 사용하는 경우, 시크릿 API 키를 CI 머신에서 참조할 수단이 필요하다. 보통 이런 용도로는 VCS에 체크인하지 않는 로컬 파일, 또는 환경 변수를 사용한다. 현재 Flutter는 빌드 시 컴파일 타임 인자를 넘길 수단을 제공하지 않으므로(참고: flutter/flutter issue #26638 ) 이 정보는 앱 프로젝트 내의 파일로 넘겨줘야 한다.

    로컬에서는 VCS에 체크인하지 않는 시크릿 키를 담은 파일을 만들면 간단하게 해결되는 문제다. 하지만 매번 새로운 환경에서 빌드가 일어나는 CI 머신에서는 이런 방식을 적용하기 어렵다. 해결할 방법은 여럿 있겠지만, 나는 다음과 같은 방식을 선택했다.

    1. Travis CI 저장소 설정에서 암호화된 환경 변수를 설정한다 .
    2. 스크립트에서 해당 환경 변수의 내용을 파일에 저장한다.
    3. Flutter 소스코드에서 해당 파일을 참조한다.

    구체적으로 갈피의 예를 들면, 책 검색에 사용하는 카카오 REST API 키를 불러오기 위해 Travis CI 의 KAKAO_REST_API_KEY 환경 변수에 해당 시크릿 키를 추가한 뒤, 아래와 같은 bash 스크립트를 저장소에 추가했다.

    # scripts/populate_secret.sh
    
    touch secrets/keys.json
    echo "{ \"KAKAO_REST_API_KEY\": \"$KAKAO_REST_API_KEY\" }" > secrets/keys.json
    

    Travis CI 스크립트에서 빌드 전에 bash scripts/populate_secret.sh 커맨드를 실행하면 Dart 코드가 시크릿 키를 읽어올 secrets/keys.json 파일이 만들어진다.

    # .travis.yml #2: 환경 변수로부터 써드 파티 API 시크릿 토큰을 준비한다.
    
    language: generic
    
    before_script:
      - git clone https://github.com/flutter/flutter.git -b stable
      - export PATH=`pwd`/flutter/bin:`pwd`/flutter/bin/cache/dart-sdk/bin:$PATH
    
    script:
      - bash scripts/populate_secret.sh
      - flutter packages get
    

    NOTE: 글을 적다보니 keys.json 파일도 아래 Android 빌드에서 다루듯이 Travis CI CLI의 travis encrypt-file로 처리할 수 있다는 것을 깨달았다. (보시다시피 블로깅은 도움이 된다! 우리 모두 블로깅을 합시다!) 하지만 당장 진행한다고 임팩트가 있는 작업은 아니라 일단은 그대로 내버려둔다.

    👨‍💻 공통: Fastlane 설정

    Travis CI를 설정해, 코드가 푸시될 때마다 저장소의 의존성을 내려받을 수 있게 되었다!

    하지만 안타깝게도 이런 스크립트는 CI 머신의 탄소 발자국을 늘려 지구 온난화를 가속화할 뿐, 아무런 실질적인 도움을 제공하지 않는다. 우리의 최종 목표는 GitHub에서 코드가 푸시되면 자동으로 Android, iOS 두 플랫폼 대상의 빌드가 각각 만들어지는 것이다.

    이 목표를 달성하기 위해 fastlane을 사용할 것이다. fastlane은 앱 자동화를 위한 오픈소스 툴체인으로, Android 및 iOS 앱 빌드, 릴리즈, 자동 스크립샷 등을 비롯한 다양한 작업의 자동화를 도와준다. 게다가 별도의 과금 없이 사용할 수 있다. 와우!

    fastlane은 Ruby로 작성된 프로그램이다. gem install fastlane(RubyGems) 또는 brew cask install fastlane(HomeBrew)로 설치할 수 있다.

    fastlane을 설치한 후 Flutter 프로젝트의 android, ios 폴더에 각각 들어가 fastlane init 커맨드를 실행하면 대화형 CLI의 도움을 받아 기본적인 fastlane 프로젝트를 설정할 수 있다.

    참고로 나는 iOS 설정 시의 What would you like to use fastlane for? 질문에는 2. 👩‍✈️ Automate beta distribution to TestFlight 를 선택했다. Android 설정에서는 json secret key file 경로는 일단 스킵 (아무것도 작성하지 않고 엔터), 메타데이터는 yes 로 응답했다. 이 글은 해당 선택지들을 기준으로 작성했다.

    더 나아가기 전에, fastlane init을 이용해 생성된 파일이 각각 어떤 역할을 하는지 간단히 알아보자.

    Fastfile

    Fastfilefastlane 키워드를 사용해 실행할 모든 자동화 작업 설정을 저장하는 파일이다. 이 파일이 포함하는 여러 내용 중 우리가 지금 알아야 할 개념은 두 가지, lane과 action 이다.

    lane

    lane(공식 문서)은 fastlane [lane_name] 커맨드를 이용해 CLI에서, 또는 다른 lane에서, 실행할 수 있는 Ruby 스크립트다. 예를 들어, 베타 릴리즈를 위한 beta lane을 정의한 뒤 fastlane beta 커맨드로 실행할 수 있다.

    action

    lane은 0개 이상의 (보통은 1개 이상의) action(공식 문서)을 호출한다. action은 말 그대로 앱 자동화에 필요한 여러 행동을 정의한 Ruby 함수다. 대표적인 예로는 다음과 같은 동작이 있다.

    • 빌드 관련: build_ios_app, gradle
    • 배포 관련: upload_to_testflight, upload_to_play_store, upload_to_app_store

    fastlane은 이 외에도 다양한 사전 정의된 action을 제공한다. fastlane이 제공하는 전체 action 목록은 공식 문서에서 확인할 수 있다.

    action은 Ruby로 작성된 평범한 함수이므로, 필요하다면 얼마든지 자신만의 action을 정의할 수 있다. 이 글의 범위 내에서는 그럴 일은 없지만, 나중에 필요한 경우가 생긴다면 공식 문서를 참고.

    Appfile

    Appfile(공식 문서)은 앱에 관련된 정보 (거꾸로 된 도메인 형태의 앱 식별자, App Store ID, Play Console 서비스 계정 등)을 포함한다. Appfile에 적힌 정보는 fastlane의 모든 커맨드가 바라볼 수 있다. 당연하지만 iOS와 Android 프로젝트의 Appfile에는 각자의 플랫폼에 알맞은, 다른 내용이 들어있다.

    Gemfile

    Gemfile(공식 문서)은 node의 package.json와 비슷하게 Ruby 프로그램의 의존성을 기술하는 파일이다. fastlane 은 여러 환경에서 동일한 결과를 보장하기 위해 Bundler를 사용한 설치 및 실행을 권장한다.


    🍎 iOS: TestFlight 배포

    fastlane 설정을 마쳤다면 본격적으로 fastlane을 이용한 배포 설정을 시작하자. 먼저 iOS다.

    🍎 fastlane을 사용한 로컬 배포

    Flutter 공식 문서는 클라우드 기반의 배포를 테스트하기 전에 로컬에서 fastlane을 통한 빌드 업로드가 동작하는지 테스트해볼 것을 권장하고 있다. fastlane init을 마친 시점에서 ios/fastlane/Appfile 파일은 아래와 같이 생겼을 것이다.

    # ios/fastlane/Appfile
    
    default_platform(:ios)
    
    platform :ios do
      desc "Push a new beta build to TestFlight"
      lane :beta do
        build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
        upload_to_testflight
      end
    end
    

    사실 TestFlight 배포에 필요한 액션은 이걸로 충분하다. 앱을 빌드 하고, TestFlight에 업로드하는 것.

    이미 TestFlight에 수동으로 업로드 한 적이 있다는 전제 하에, flutter build ios 커맨드를 실행한 뒤 ios 폴더에서 bundle exec fastlane beta 커맨드를 실행하면 (CLI 프롬프트를 통한 Apple 계정 비밀번호 입력 이후) TestFlight에 빌드가 업로드된다.

    로컬에서 fastlane을 사용한 iOS 배포에 성공한 경우의 스크린샷

    위처럼 들뜨는 메시지를 만났다면 성공이다. TestFlight에 들어가보면 실제로 업로드 된 번들을 확인할 수 있을 것이다. 총 걸린 시간이 7분 가량이라 7분을 아껴줬다고 이야기하는 것 같은데 말이 되는 계산인지는 모르겠다.

    🍎 CI 머신에서의 코드 사이닝을 위한 fastlane match 세팅

    로컬에서 fastlane을 사용해 iOS 번들을 빌드하고 TestFlight에 올리는 데에 성공했다! 이제 같은 일을 로컬이 아닌 CI 머신에서 수행할 수 있도록 설정할 차례다.

    Flutter의 공식 iOS 배포 가이드 를 따라왔다면 아마 이 시점에서 코드 사이닝을 위한 인증서는 Xcode가 자동으로 관리하는 상태일 것이다. (아래 그림과 같이 Xcode Runner 타겟의 “General > Signing > Automatically manage signing” 체크박스가 체크되어 있는 상태)

    빌드 설정의 Automatically manage signing 옵션이 켜져있는 Xcode 화면

    iOS 앱을 TestFlight와 App Store에 배포하기 위해서는 배포 인증서(distribution certificate)를 사용한 코드 사이닝이 필요한다. 위 설정이 켜져있는 경우, 이 동작은 Xcode가 알아서 처리해준다. (iOS 코드 사이닝의 큰 그림에 대해선 이 미디엄 글이 잘 설명하고 있다)

    이러한 설정은 로컬에서 개발 및 배포를 수행하기엔 충분했다. 하지만 CI 빌드가 일어날 때마다 로컬 머신에서 했던 것처럼 매번 CI 머신의 키체인 접근에 들어가 인증서를 직접 생성/설정할 수는 없는 노릇이다. CI 머신 상에서는 코드 사이닝을 어떻게 처리할 수 있을까?

    fastlane match 소개

    fastlane이 제공하는 match 액션은 원격 저장소를 사용해 코드 사이닝에 관련된 여러 문제를 풀어준다. 주제가 주제인만큼 다소 복잡하게 느껴질 수 있지만, 기본적인 개념은 다음과 같이 간단하다.

    1. 로컬에 저장된 인증서를 사용하는 대신, 인증서를 사용자가 입력한 패스프레이즈로 암호화한 뒤 원격 Git (또는 Google Cloud Storage) 저장소에 저장한다.
    2. 해당 저장소에 접근 권한이 있는 사용자는 임의의 기기에서 암호화된 인증서를 내려받는다.
    3. 1번에서 암호화에 사용한 패스프레이즈를 알고 있는 사용자는 2번에서 내려받은 인증서를 복호화해 코드 사이닝에 사용한다.

    이 글에서는 원격 저장소로 GitHub에서 호스팅되는 Git 저장소를 사용할 것이다.

    match 인증서 저장소 설정

    match를 사용하기 위해 가장 먼저 GitHub에 프라이빗 저장소를 하나 만들어야 한다. 예를 들어 이 글에서는 myaccount 라는 계정으로 my-app-cert 라는 저장소를 만들었다고 하자.

    저장소를 만들었다면 ios 폴더 내에서 bundle exec fastlane match init 키워드를 실행한다. 저장소 URL을 묻는 프롬프트에는 위에서 생성한 저장소의 URL(https://github.com/myaccount/my-app-cert)을 넘겨준다.

    선택사항: 다음으로 넘어가기 전, bundle exec fastlane match nuke 커맨드를 이용해 지금까지 사용해온 인증서를 날리고 모든 인증서를 fastlane이 관리하도록 설정할 수 있다.

    나는 협업자가 없어 부담도 적고, 기왕이면 깔끔하게 시작하고 싶어서 match nuke를 실행했다. 어느 쪽이 좋은 선택일지는 공식 문서를 참고해서 스스로 결정하면 된다. 참고로 기존 인증서를 날려도 이미 배포된 앱을 내려받지 못하는 사태는 생기지 않는다.

    match init 은 실행시 ios/fastlane/Matchfile를 생성한다. 이 파일은 (당연하게도) match 커맨드가 필요로 하는 정보 – 인증서 저장소의 URL, 인증서 타겟, 깃 브랜치 등 – 를 저장한다.

    Matchfile이 생성된 것을 확인했으면 App Store 및 TestFlight 배포를 위해 appstore 타겟 인증서를 생성해보자. 최초로 bundle exec fastlane match appstore 커맨드를 실행하면 인증서가 생성된 뒤 match init에서 제공한 저장소에 암호화되어 업로드된다. 이 때 입력하는 패스프레이즈는 나중에 필요하니 반드시 안전한 장소에 보관해야 한다.

    암호회된 인증서를 업로드했으면, Travis CI 빌드 머신이 해당 인증서를 복호화할 수 있도록 MATCH_PASSWORD 환경 변수에 앞서 설정한 패스프레이즈 값을 설정해준다.

    NOTE: 최초 fastlane match 실행시 입력한 패스프레이즈는 절대 외부에 평문으로 노출되어선 안 된다!

    Xcode 설정 변경

    모든 과정이 정상적으로 끝났다면 Matchfile은 아래와 비슷한 모양을 하고 있을 것이다.

    # ios/fastlane/Matchfile
    
    git_url("https://github.com/myaccount/my-app-cert")
    
    storage_mode("git")
    
    type("appstore")
    
    app_identifier("거꾸로 된 도메인 형태의 앱 식별자")
    username("Apple ID 이메일")
    

    ios 폴더에서 bundle exec fastlane match appstore 커맨드를 실행하면

    1. 저장소로부터 인증서를 받아온 뒤
    2. 패스프레이즈를 이용해 복호화한 후
    3. 프로비저닝 프로파일을 설치하는

    것을 확인할 수 있다. 만약 MATCH_PASSWORD 환경 변수가 설정되어있지 않다면 2번 과정에서 패스프레이즈를 입력하라는 프롬프트를 만날 것이다.

    다음으로, Xcode가 직접 관리하는 인증서 대신 match가 설치한 인증서를 바라보도록 설정해줘야 한다. Xcode를 켜서 Runner 타겟의 “General > Signing > Automatically manage signing” 체크박스를 해제한다. 그 뒤, 각 환경의 “Signing Provisioning Profile” 에서 match 가 설치한 적절한 프로파일을 설정해준다.

    빌드 설정의 Automatically manage signing 옵션이 꺼져있는 Xcode 화면

    위 스크린샷에서 사실 Debug용 프로파일은 별도로 존재해야 맞다. 글 작성 시점에서 아직까지 TestFlight 출시 없이 직접 기기에 설치할 일이 없어 따로 설정을 해두지 않았다. 시뮬레이터에서 개발하기에는 위 설정으로 충분하지만, 개발 프로파일 설치가 필요하다면 bundle exec fastlane match development를 실행한 뒤 “Signing (Debug)” 의 프로파일을 알맞게 변경해주면 될 것이다.

    match 설정이 완료되었으니 마지막으로 beta lane에서 앱 빌드 이전에 match를 실행하도록 Fastfile을 아래와 같이 수정하자.

    # ios/fastlane/Fastfile
    
    default_platform(:ios)
    
    platform :ios do
     desc "Push a new release build to the TestFlight"
      lane :beta do
        match(
          type: "appstore",
          readonly: is_ci,
          verbose: true
        )
    
        build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
        upload_to_testflight
      end
    

    인증서 저장소 클론을 위한 GitHub 토큰 세팅

    match를 사용해서 생성한 인증서에 Travis CI가 접근하기 위해선 해당 private GitHub 저장소에 접근할 수 있어야 한다. Travis 문서에서 추천하는 User Key 방식은 travis-ci.com의 private 저장소에만 사용 가능하다. 갈피는 공개되어 있는 오픈 소스 프로젝트이므로, 대신 API Token을 사용한 인증을 사용했다.

    먼저 CI 머신에서 사용할 GitHub 토큰을 발급한다. “Setting > Developer Settings > Personal access tokens” 페이지의 “Generate new token” 버튼을 눌러 repo 권한을 갖는 토큰을 생성할 수 있다. 토큰은 최초 생성 시를 제외하고는 다시 읽을 수 없으니 생성 직후 안전한 장소에 보관한다.

    NOTE: 이 때 생성한 GitHub 토큰은 절대 외부에 평문으로 노출되어선 안 된다!

    GitHub 토큰을 생성하는 페이지 스크린샷

    토큰 값을 얻어왔으면 Travis CI 저장소 설정에 GITHUB_TOKEN 환경 변수를 추가하고, .travis.ymlbefore_script에 아래 스크립트를 추가한다.

    echo -e "machine github.com\n  login $GITHUB_TOKEN" >> ~/.netrc
    

    이제 Travis CI는 GitHub API Token을 사용해 인증서가 보관된 저장소에 접근할 수 있다.

    🍎 빌드 파일 업로드를 위한 App Specific Password 설정

    지금쯤 지칠대로 지친 채로 “대체 언제 끝나냐…” 라고 생각하고 있을 독자들의 모습이 눈에 훤하다. 하지만 아직 한 발 남았다.

    Apple은 2019년 2월부터 Account Holder 역할의 애플 개발자 계정 로그인 시 2FA(2-factor authentication) 설정을 필수화했다. 새로운 기기에서 애플 로그인을 하려고 하면 다른 애플 기기에서 확인한 6자리 숫자를 입력하라고 반겨주는 귀찮은 프롬프트가 바로 2FA 프롬프트다.

    Apple Developer Program에 등록된 계정이 하나뿐인 경우 계정이 자동적으로 Account Holder 역할을 갖게 된다. 때문에 CI에서 빌드 업로드를 시도해보면 2FA를 위한 Please enter the 6 digit code: 프롬프트가 뜨는 것을 확인할 수 있다. 누구도 입력해주지 않으니 빌드는 당연히 타임아웃으로 실패한다.

    하지만 app-specific password를 사용하면 2FA 없이 빌드를 업로드할 수 있다. 단, app-specific password를 사용하는 경우 App Store Connect 로그인을 필요로 하는 행동(예를 들어 메타데이터 변경)은 불가능하다. 오직 빌드 파일 업로드만이 허용된다. 이러나 저러나, 별도의 Apple Developer Program을 구매할 생각이 없다면 이게 현존하는 최선의 방식이다. (만약 별도 계정 구매가 가능한 경우의 설정은 fastlane CI 문서 참고)

    먼저 fastlane 공식 문서에 적혀있는 스텝을 따라 배포하려는 앱의 app-specific password를 발급받는다. 발급받은 값을 Travis CI의 FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD 환경 변수로 넣어준 뒤, 아래와 같이 upload_to_testflight 액션이 빌드만 업로드하도록 추가 인자를 넘겨준다.

    # before: 이 부분을
    upload_to_testflight
    
    # after: 이렇게 변경한다
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      apple_id: "배포하려는 앱의 apple id"
    )
    

    apple_id 인자로 넘겨야 할 값은 아래 그림과 같이 App Store Connect의 “App Information” (또는 URL path) 에서 확인할 수 있다. 숫자가 아닌 문자열이라는 점에 유의하자.

    App Information 페이지에서 Apple ID를 찾는 방법을 설명하는 스크린샷

    🍎 Pitfall: dsym 버그

    마지막 Travis CI 설정으로 넘어가기 전에, 갈피 저장소를 설정하면서 겪었던 두가지 장애물을 소개한다.

    먼저, macOS 버전에 따라 fastlane beta를 실행했을 때 *Generating 'Runner.app.dSYM'* 로그 이후로 아무 일도 일어나지 않고 빌드가 타임아웃으로 실패하는 경우가 발생할 수 있다. Flutter 저장소 이슈로도 등록되어 있는데, 원인은 Xcode 10의 일부 버전에 존재하는 버그다.

    이슈에 링크된 StackOverflow 답변처럼 Xcode의 Runner 타겟의 “Build Settings > Debug Information Format” 설정을 DWARF로, “Enable Bitcode” 설정을 No로 설정한 후에 ios/Runner.xcodeproj/project.pbxproj 파일을 저장하고 다시 시도해보면 해결된 것을 확인할 수 있다.

    디버그 옵션이 글에서 설명한 대로 설정된 Xcode

    🍎 Pitfall: Travis CI macOS Sierra 코드 사이닝 버그

    위의 dsym 버그를 해결한 후에도 Travis CI 상에서의 빌드가 *Copying* /Users/travis/build/myaccount/my_app/ios/Flutter/App.framework 등의 로그를 끝으로 아무런 반응이 없다가 타임아웃이 나서 실패하는 경우가 발생할 수 있다.

    이 문제는 Travis CI의 Common Build Problems 문서 에 소개되어 있는데, Travis CI가 사용하는 macOS Sierra 머신에서 코드 사이닝 스텝이 끝나지 않는 버그가 존재하는 것이 원인이다. 문서에 제시된 대로 match 액션 이전에 create_keychain 액션으로 생성한 키체인을 사용하도록 Fastfile을 수정해 해결할 수 있다. (해당 수정 관련 diff)

    이 변경사항이 동작하기 위해선 Travis CI의 설정에서 MATCH_KEYCHAIN_NAME, MATCH_KEYCHAIN_PASSWORD 두 환경 변수를 추가해줘야 한다. MATCH_KEYCHAIN_PASSWORD는 어떤 값이든 무관하고, MATCH_KEYCHAIN_NAMElogin.keychain을 제외한 임의의 <name>.keychain이 모두 유효한 듯 하다. 나는 ios-build.keychain을 사용했다.

    # ios/fastlane/Fastfile
    
    default_platform(:ios)
    
    platform :ios do
      desc "Push a new release build to the TestFlight"
      lane :beta do
        create_keychain(
          name: ENV["MATCH_KEYCHAIN_NAME"],
          password: ENV["MATCH_KEYCHAIN_PASSWORD"],
          default_keychain: true,
          unlock: true,
          timeout: 3600,
          add_to_search_list: true
        )
    
        match(
          type: "appstore",
          readonly: is_ci,
          keychain_name: ENV["MATCH_KEYCHAIN_NAME"],
          keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"],
          verbose: true
        )
    
        build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
        upload_to_testflight(
          skip_waiting_for_build_processing: true,
          apple_id: "배포하려는 앱의 apple id"
        )
      end
    end
    

    사실 갈피 앱 환경을 이렇게 설정해서 문제가 해결된 것을 보고 올바른 해결법을 도입했으며 더 이상의 변경은 필요 없다고 생각했다. 하지만 이 글을 쓰면서 구글링을 하다 fastlane에 이 작업을 알아서 해주는 setup_travis 액션이 존재하는 것을 발견했다. (보시다시피 블로깅은 도움이 된다! 우리 모두 블로깅을 합시다!)

    이 액션을 사용하면 Fastfile이 다음과 같이 단순해진다. 소스 코드를 보면 위와 거의 동일한 작업을 함을 확인할 수 있다.

    # ios/fastlane/Fastfile
    
    default_platform(:ios)
    
    platform :ios do
      desc "Push a new release build to the TestFlight"
      lane :beta do
        setup_travis
    
        match(
          type: "appstore",
          readonly: is_ci,
          verbose: true
        )
    
        build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
        upload_to_testflight(
          skip_waiting_for_build_processing: true,
          apple_id: "배포하려는 앱의 apple id"
        )
      end
    end
    

    이 때, setup_travis 액션을 사용하려면 앞서 설정한 MATCH_KEYCHAIN_NAME 환경 변수를 제거해야 함에 유의하라. (그러지 않으면 match똑똑하게 환경 변수에 설정된 이름의 키체인을 읽어오려 시도해서 빌드가 실패한다.)

    🍎 Travis CI에서 TestFlight 배포

    모든 준비가 끝났다! 이제 로컬에서 돌려본 이 모든 스크립트를 Travis CI에서 실행하도록 하면 iOS 쪽 작업은 끝이 난다. iOS와 Android 두 플랫폼의 빌드가 동시에 도는 것이 최종 목적이므로 Travis CI의 Build Matrix를 사용할 것이다.

    프로젝트 루트의 .travis.yml 파일을 아래와 같이 변경하자.

    # .travis.yml #3: iOS 배포 자동화
    language: generic
    
    env:
      - FLUTTER_BUILD_RELEASE_CHANNEL=stable # 사용할 빌드 릴리즈 채널
    
    matrix:
      include:
        - name: iOS Build
          os: osx
          language: generic
          osx_image: xcode10.2
    
          before_script:
            - echo -e "machine github.com\n  login $GITHUB_TOKEN" >> ~/.netrc # match 인증서 저장소에 접근하기 위해 GitHub Token 설치
    
            - git clone https://github.com/flutter/flutter.git -b $FLUTTER_BUILD_RELEASE_CHANNEL
            - export PATH=`pwd`/flutter/bin:`pwd`/flutter/bin/cache/dart-sdk/bin:$PATH # Flutter를 내려받은 후 PATH 설정
    
            - gem install bundler
            - gem install cocoapods
            - cd ios && bundle install && cd .. # bundler, cocoapods 및 fastlane 설치
    
          script:
            - flutter doctor -v # 빌드 디버깅을 위한 Flutter 정보 로깅
    
            - bash scripts/populate_secret.sh # 환경 변수에 들어있는 써드 파티 라이브러리 시크릿을 파일로 작성
    
            - flutter build ios --no-codesign --build-number=$TRAVIS_BUILD_NUMBER # Flutter iOS 빌드에 필요한 파일을 내려받고 번들의 빌드 이름 및 빌드 번호 설정
    
            - cd ios
            - bundle exec fastlane beta # fastlane을 사용한 코드 사이닝, 빌드 및 TestFligth 배포
    

    위 설정에서는 Travis CI의 기본 환경 변수 중 하나인 TRAVIS_BUILD_NUMBER를 버전 번호로 사용했다. 하지만 꼭 해당 값을 버전 번호로 사용할 필요는 없다. 앱 버저닝 관련 경험이 없는 독자라면 글 마지막 부분의 “부록: 버저닝 전략” 을 한 번 읽어보면 도움이 될 것이다.

    🍎 최종 결과물

    이제 GitHub 푸시에 의해 Travis CI에서 iOS 빌드가 돌고, TestFlight에 앱이 올라가는 것을 확인할 수 있을 것이다. iOS 준비가 완료된 시점에서의 .travis.yml, Appfile, Fastfile, Matchfilegist에서 확인할 수 있다.


    🤖 Android: Google Play 내부 테스트 트랙 배포

    다음은 Android의 차례다. 기본적으로 iOS와 해야할 일은 크게 다르지 않은데다 설정이 iOS 대비 단순한 편이라, 앞의 과정을 잘 따라왔다면 상대적으로 쉽게 느껴질 것이다.

    🤖 로컬 배포를 위한 Fastfile 세팅

    가장 먼저, iOS에서와 동일하게 로컬에서 fsatlane을 이용해 Play Store에 번들을 올리는 작업부터 진행해보자.

    fastlane init이 실행된 시점에서, android 폴더 내에는 (fastlane 폴더 내의) AppfileFastfile, 그리고 Gemfile이 만들어져 있을 것이다.

    fastlane을 이용해 Android 앱을 배포하기 위해선 Google 서비스 계정이 필요하다. 공식 문서에 나와있는 대로, 새로운 서비스 계정은 Play Console에서 발급받을 수 있다. Play Console의 좌측 드로어의 "모든 어플리케이션”을 클릭한 뒤, "설정" 내의 "개발자 계정 > API 액세스” 메뉴에 들어가 하단의 "서비스 계정 만들기"를 클릭한 뒤, 안내 모달의 "Google API 콘솔" 링크를 클릭한다. (왜 이런 식으로 만들었는지는 모르겠다)

    Google 서비스 계정 생성을 위한 절차를 설명하는 스크린샷

    안내대로 Google API 콘솔로 들어가면 서비스 계정을 만들 수 있다. 생성시 JSON 타입의 키를 선택하고, 해당 JSON 파일을 내려받아 android/app/serviceAccount.json 경로에 저장하자. 다시 Play Console로 돌아오면 방금 생성한 서비스 계정이 목록에 나타날 것이다. 파란색 “액세스 권한 부여” 버튼을 눌러 “제품 출시 관리자” 권한을 주면 모든 준비가 끝난다.

    NOTE: 이때 내려받은 서비스 계정 파일은 절대 Git 등의 VCS에 체크인 되어선 안 된다! 서비스 계정 파일을 프로젝트 내에 추가한 후엔 꼭 .gitignore 등에 추가하자.

    이제 Appfile의 내용을 적절하게 변경하자. package_name 필드에는 거꾸로 된 도메인 형태로 되어있는, Play Store에서 사용 중인 앱 식별자를 적어준다. json_key_file에는 아까 내려받은 서비스 계정 파일의 경로인 "app/serviceAccount.json"을 넘겨준다.

    # android/fastlane/Appfile
    
    json_key_file("app/serviceAccount.json")
    package_name("거꾸로 된 도메인 형태의 앱 식별자")
    

    그 후 bundle exec fastlane supply init 커맨드를 실행하면 서비스 계정을 사용해 Google Play에 이미 올라가 있는 정보를 android/fastlane/metadata/android 폴더로 받아올 것이다.

    Appfile 준비가 끝났으니 Fastfile을 수정해보자. fastlane init이 끝난 시점에서 Fastfile은 아래와 같은 형태를 하고 있을 것이다.

    # android/fastlane/Fastfile
    
    default_platform(:android)
    
    platform :android do
      desc "Runs all the tests"
      lane :test do
        gradle(task: "test")
      end
    
      desc "Submit a new Beta Build to Crashlytics Beta"
      lane :beta do
        gradle(task: "clean assembleRelease")
        crashlytics
    
        # sh "your_script.sh"
        # You can also use other beta testing services here
      end
    
      desc "Deploy a new version to the Google Play"
      lane :deploy do
        gradle(task: "clean assembleRelease")
        upload_to_play_store
      end
    end
    

    우리는 Crashlytics Beta 가 아닌 Google Play 내부 테스트 트랙으로의 빌드를 배포한다. 또한 iOS와 마찬가지로, 빌드 이름과 빌드 번호를 넘겨주기 위해 fastlane의 Flutter CLI를 gradle 액션 대신 사용한다. 따라서 필요없는 배포 레인과 gradle 액션을 삭제하고, crashlytics 대신 적절한 인자를 갖는 upload_to_play_store 액션을 사용하도록 Fastfile을 수정한다.

    # android/fastlane/Fastfile
    
    default_platform(:android)
    
    platform :android do
      desc "Submit a new Beta Build to Google Play Internal Test Track"
      lane :beta do
        begin
          upload_to_play_store(
            track: 'internal',
            aab: '../build/app/outputs/bundle/release/app.aab',
          )
          rescue => exception
            raise exception unless exception.message.include?('apkUpgradeVersionConflict')
            puts 'Current version already present on the Play Store. Omitting this upload.'
        end
      end
    end
    

    이제 로컬에서 fastlane을 사용해 배포할 준비가 끝났다. 먼저 flutter build appbundle --build-number=<빌드 번호> 커맨드를 실행해 App Bundle을 만든다. 그 이후 android 폴더에서 bundle exec fastlane beta 커맨드를 실행하면 만들어진 App Bundle이 Google Play의 내부 테스트 트랙으로 배포되는 것을 확인할 수 있다.

    로컬에서 fastlane을 사용한 Android 배포에 성공한 경우의 스크린샷

    NOTE: 테스트 과정에서는 지금까지의 빌드 번호보다는 크되 최대한 작은 빌드 번호를 사용할 것을 권장한다. 이유는 글 하단의 “부록: 버저닝 전략” 부분 참고.

    🤖 CI 머신에서의 코드 사이닝을 위한 파일 업로드

    iOS에서와 마찬가지로, Android 앱 배포에도 코드 사이닝이 필요하다. Flutter의 공식 Android 배포 가이드 를 따라 Play Store에 앱을 올려본 적이 있다면, .jks 확장자를 갖는 키스토어를 로컬 어딘가에 받아둔 뒤, android/key.properties 파일의 storeFile 필드로 참조하고 있을 것이다.

    이제 코드 사이닝이 CI 머신에서도 가능하도록 만들 차례다. fastlane match를 사용했던 iOS와 달리, Android에서는 관련된 파일을 암호화한 뒤 Travis에 직접 업로드하는 방식으로 코드 사이닝을 풀어낼 것이다.

    먼저, android/key.propertiesstoreFile 필드에 적힌 경로에 존재하는 .jks 파일을 android/app/upload.keystore로 옮겨온 뒤, android/key.propertiesstoreFile 필드 값을 upload.keystore로 변경하자.

    NOTE: 이때 리포지토리 안으로 가져온 키스토어 절대 Git 등의 VCS에 체크인 되어선 안 된다! 서비스 계정 파일을 내려받았으면 꼭 .gitignore 등에 추가하자.

    CI 머신에서의 코드 사이닝을 위해 필요한 파일은 세 개다.

    • android/key.properties: 키스토어 파일 위치와 복호화에 필요한 비밀번호
    • android/app/serviceAccount.json: Google Play로의 바이너리 및 메타데이터 업로드에 필요한 계정 정보
    • android/app/upload.keystore: 키스토어 파일

    Travic CI CLI 클라이언트 를 사용해 이 파일들을 안전하게 업로드 및 암복호화 할 것이다. 먼저 gem install travis로 CLI 클라이언트를 설치한 뒤, 이후 모든 요청이 올바른 엔드포인트를 바라보도록 travis endpoint --pro --set-default 커맨드를 실행해준다. 만약 기본값을 변경하기 싫다면, 앞으로의 커맨드에 모두 —pro 인자를 넘겨주면 된다.

    엔드포인트 설정이 끝났으면 tar cvf secrets.tar android/key.properties android/app/serviceAccount.json android/app/upload.keystore 커맨드로 코드 사이닝에 필요한 세 파일을 secrets.tar 파일로 묶어준다.

    NOTE: 암호화되기 전의 압축 파일인 secrets.tar은 절대 Git 등의 VCS에 체크인 되어선 안 된다! 압축 파일을 생성했으면 꼭 .gitignore 등에 추가하자.

    그 뒤, travis encrypt-file secrets.tar 커맨드를 실행해 이 압축 파일을 암호화한다. 이 커맨드는 다음과 같은 일을 한다.

    1. OpenSSL을 이용해 secrets.tar 파일을 secrets.tar.enc 파일로 암호화한다. 이 암호화된 파일은 VCS에 체크인 되어야 한다.
    2. 복호화에 사용할 키와 초기화 벡터 값을 Travis CI 저장소의 환경변수로 설정한다.
    3. 해당 환경변수를 사용해 파일을 복호화하는 커맨드를 출력한다.

    성공적으로 암호화 작업이 끝났다면 터미널에 openssl aes-256-cbc -K $[키_환경변수_이름] -iv $[초기화_벡터_환경변수_이름] -in secrets.tar.enc -out secrets.tar -d 형태의 커맨드가 출력되었을 것이다. 해당 커맨드를 Travis CI의 Android 빌드 잡에서 실행해주면 secrets.tar.enc 파일을 다시 secrets.tar로 복호화할 수 있다.

    🤖 Pitfall: buildToolsVersion과 Travis android component 버전

    Android 에서도 최종 Travis CI 설정으로 넘어가기 전 밟을 수 있는 문제를 먼저 소개한다. 로컬에서 잘 되던 Android 빌드가 Travis CI에서 아래와 같은 로그와 함께 실패하는 경우가 있다.

    FAILURE: Build failed with an exception.
    
    * Where:
    Build file &#39;/home/travis/build/heejongahn/galpi/android/build.gradle&#39; line: 24
    
    * What went wrong:
    A problem occurred evaluating root project &#39;android&#39;.
    
    &gt; A problem occurred configuring project &#39;:app&#39;.
       &gt; Failed to install the following Android SDK packages as some licences have not been accepted.
            platforms;android-28 Android SDK Platform 28
         To build this project, accept the SDK license agreements and install the missing components using the Android Studio SDK Manager.
         Alternatively, to transfer the license agreements from one workstation to another, see http://d.android.com/r/studio-ui/export-licenses.html
    
         Using Android SDK: /usr/local/android-sdk

    이 이슈는 Travis CI에 명시된 것과 다른 버전의 Android SDK Build-Tools를 사용하기 때문에 발생한다. 이 커밋에서처럼 android/app/build.gradle 파일의 android 블락에 buildToolsVersion “.travis.yml에 명시된 build-tools 버전” 라인을 추가해주면 해결된다.

    🤖 Travis CI에서 Google Play 내부 테스트 트랙 배포

    모든 준비가 끝났다. 프로젝트 루트의 .travis.yml Build Matrix에 아래와 같이 Android 빌드 잡을 추가하자.

    # .travis.yml #4: Android 배포 자동화
    language: generic
    
    env:
      - FLUTTER_BUILD_RELEASE_CHANNEL=stable # 사용할 빌드 릴리즈 채널
    
    matrix:
      include:
        - name: iOS Build
          # (이하 iOS 빌드 생략)
    
        - name: Android Build
          language: android
          jdk: openjdk8
          android:
            components:
              - build-tools-28.0.3
              - android-28
          before_script:
            - openssl aes-256-cbc -K $[키_환경변수_이름] -iv $[초기화_벡터_환경변수_이름] -in secrets.tar.enc -out secrets.tar -d
            - tar xvf secrets.tar # travis encrypt-file을 사용해 암호화한 압축 파일 복호화하고 압축 해제
    
            - git clone https://github.com/flutter/flutter.git -b $FLUTTER_BUILD_RELEASE_CHANNEL
            - export PATH=`pwd`/flutter/bin:`pwd`/flutter/bin/cache/dart-sdk/bin:$PATH # Flutter를 내려받은 후 PATH 설정
    
            - gem install bundler && cd android && bundle install && cd .. # bundler와 fastlane 설치
    
          script:
            - flutter doctor -v # 빌드 디버깅을 위한 Flutter 정보 로깅
    
            - bash scripts/populate_secret.sh # 환경 변수에 들어있는 써드 파티 라이브러리 시크릿을 파일로 작성
    
            - flutter build appbundle --build-number=$TRAVIS_BUILD_NUMBER # App Bundle 빌드
    
            - cd android
            - bundle exec fastlane beta # fastlane을 이용해 빌드 파일 업로드
    

    드디어 GitHub 코드 푸시가 일어나면 이 글의 도입부의 이미지처럼 Android와 iOS 빌드가 각각 돌고, Play Store 내부 테스트 트랙과 TestFlight에 빌드가 업로드 되는 아름다운 환경이 구축되었다! 소리 질러!!! (우와) (웅성웅성) (시끌벅적)


    맺으며

    이상으로 앱 개발 경험이 전무한 웹 개발자의 입장에서 iOS, Android 두 플랫폼 빌드 및 내부용 배포 자동화 설정 과정을 단계별로 살펴보았다. 글에서 다룬 내용을 다시 한 번 정리하면 아래와 같다.

    • 공통
      • GitHub 푸시에 Travis CI가 트리거 되도록 설정
      • Travis CI에 써드 파티 API키 설정
    • iOS
      • match를 이용한 코드 사이닝
      • app-specific password를 사용한 TestFlight로의 빌드 업로드
    • Android
      • Travis CI CLI 클라이언트를 이용한 비밀 파일 암복호화 및 업로드
      • 서비스 계정을 사용한 Google Play로의 빌드 업로드

    앱 개발을 하기로 마음 먹었다면 어차피 이르던 늦던 앱 생태계를 이루는 요소들에 대해 배워야 한다. 지난하고 어려운 디버깅의 연속이었지만, 결과적으로 앱 개발 생태계에 대해 어느정도 이해할 수 있는 좋은 경험이었다.

    도입부에서 적었듯, 이 글에서 다룬 모든 설정이 끝난 상태의 코드는 해당 시점의 갈피 GitHub 저장소에서 확인할 수 있다. 이 글과 소스 코드가 나와 비슷한, 앱 개발 외의 배경을 갖고 Flutter 세계에 발을 들인 사람들에게 도움이 되길 바란다.

    남은 작업

    이 글에서는 자동으로 빌드가 일어나고 업로드되는, 아주 작은 작업의 자동화만을 다루었다. 기본적인 준비는 끝났으니 이후 다양한 자동화 작업을 추가할 수 있을 것이다. 추가할 수 있는 작업의 예를 들면 다음과 같다.

    빠른 개발에 가장 큰 걸림돌이 될 포인트는 해결했으니, 이런 작업은 개발을 진행하면서 하나씩 천천히 진행해나갈 생각이다.

    NOTE: 만약 이 글을 읽은 후 바로 위 작업을 이어서 진행하시는 독자가 계시다면, 블로그 글 등으로 경험을 공유해주시면 감사하겠습니다.


    부록: 버저닝 전략

    마지막으로, 빌드/배포 자동화와 직접적 연관은 없지만 결코 빼놓을 수 없는 개념인 버저닝에 대해 배운 바를 부록으로 정리해 보았다.

    Android와 iOS 두 플랫폼 모두에서, 각 어플리케이션 빌드는 크게 두 가지 식별자를 갖는다. ( Android 버저닝 문서, iOS 버저닝 문서) 각 플랫폼마다 비슷한 개념이 존재하지만 부르는 용어가 다른데, 이 글에서는 Flutter에서 사용하는 build name(이하 ‘빌드 이름’)과 build number(이하 ‘빌드 번호’)라는 용어를 사용한다.

    사용자에게 노출되는 버전 식별자: 빌드 이름

    Android에서는 version name, iOS에서는 version number 라는 용어를 사용한다.

    빌드 이름은 1.0.3과 같이 x.y.z 형태를 갖는 문자열로, 사용자에게 노출된다. 사용자의 입장에서 이야기하는 “앱 버전”이 이 개념에 해당한다. 보통은 이 값으로는 유의적 버전을 사용한다.

    사용자에게 노출되지 않는 버전 식별자: 빌드 번호

    Android에서는 version code, iOS에서는 build number 라는 용어를 사용한다.

    빌드 번호는 임의의 숫자로, 어떤 빌드 이름의 특정 빌드 파일을 가리키는 식별자다. 즉, 같은 빌드 이름을 갖는 다섯 개의 빌드 파일이 존재한다면, 다섯 개의 서로 다른 빌드 번호가 존재한다. 이 숫자는 사용자에게 노출되지 않으며, 내부 관리용으로만 사용된다.

    플랫폼 별 빌드 번호는 아래와 같은 제약 사항을 갖는다.

    • iOS: 같은 빌드 이름을 갖는 빌드는 서로 다른 빌드 번호를 가져야 한다. 다른 빌드 이름을 갖는 빌드 사이에는 빌드 번호가 겹칠 수 있다. 즉, (빌드 이름, 빌드 번호) 페어가 특정 빌드의 식별자로 사용된다.
    • Android: 모든 빌드는 서로 다른 빌드 번호를 가져야 한다. 추가적으로, 새로 업로드 되는 빌드는 기존에 존재하는 모든 빌드보다 큰 빌드 번호를 가져야 한다. 또한, 빌드 번호의 최대값은 2100000000이다.

    Flutter 빌드에 빌드 이름과 빌드 번호 넘기기

    flutter build ios —help 또는 flutter build appbundle —help를 실행해보면 알 수 있듯이, flutter build <target> 커맨드에 --build-number, —build-name 인자를 넘겨 빌드 이름과 빌드 번호를 지정할 수 있다.

    넘겨진 인자는 Android의 경우 android/local.properties 파일, iOS의 경우 ios/Flutter/Generated.xcconfig에 쓰인 뒤, 플랫폼별 빌드 툴이 해당 파일의 내용을 참조해 번들의 빌드 이름, 빌드 번호를 설정한다.

    만약 flutter build 커맨드에 인자를 넘기지 않았다면 podfile.yamlversion 속성이 대신 사용된다. 이 때 version 속성 값은 + 문자로 나뉘어 앞 부분이 빌드 이름, 뒷 부분이 빌드 번호가 된다. (예를 들어 version 속성 값이 1.0.1+42라면 빌드 이름 1.0.1, 빌드 번호 42)

    CI에서 빌드 번호 관리하기

    보통 빌드 이름은 개발자가 특정한 의도를 갖고 언제 버전을 올릴지, 또 major, minor, patch 중 어떤 버전을 올릴지를 결정한다. 때문에 CI 스크립트에서 빌드 이름을 건드릴 일은 잘 생기지 않는다. (매 빌드마다 Git 커밋 기반으로 빌드 이름을 올릴 수 있지만, 사용자에게 노출되는 앱의 버전이 지나치게 빠르게 올라가는 문제가 생긴다.)

    반면 위에서 언급한 플랫폼별 제약사항으로 인해, 빌드 번호는 GitHub 푸시로 인해 새 빌드가 생길 때마다 새로운 값으로 설정되어야 한다. 갈피에서는 처음에 타임스탬프를 사용할 생각으로 flutter build ios --build-number=$(date "+%y%m%d%H%M") 같은 스크립트를 사용했고, fastlane을 이용해 1908191509 버전 번호를 갖는 빌드를 TestFlight와 Play Console 내부 테스트 트랙에 배포하는 데에 성공했다.

    실수를 깨닫기까지는 그리 오래 걸리지 않았다. 이 방식으로는 2021년만 되어도 Android 빌드 번호 최대값을 넘어버리는 것이다. Android의 빌드 번호는 단조증가하므로 위 빌드가 있는 이상 빌드 번호는 저 값보다 커지기만 해야 하는데, Play Console 상으로는 올라간 apk를 삭제할 방법이 보이지 않았다.

    패닉한 채로 Google Play 지원팀에 해당 apk를 삭제해줄 수 있는지 문의를 넣었다. 설령 내부 테스트 트랙에만 배포가 되었더라도 일단 배포가 된 apk는 삭제가 불가능하다는 답변을 받았다. 추가적으로, 만약 빌드 번호가 최대값에 도달하면 새로운 앱을 만들지 않는 한 업데이트가 불가능하다는 것까지도. (구글 정도 스케일에서는 이럴 수 밖에 없는 기술적인 이유가 있겠거니 납득했지만 받아들이기까지 시간이 좀 걸렸다)

    전혀 예상하지 못한 문제라 어떻게 해야 깔끔하게 이 사태를 해결할 수 있을지 머리를 싸매던 중, 감사하게도 평소 들어가 있던 IRC 채팅방에서 한 분의 도움을 받아 간단한 해결책을 찾았다. 실수로 업로드한 가장 큰 빌드 번호를 베이스로, 새로운 Travis CI 빌드가 생성될 때마다 1씩 증가하는 TRAVIS_BUILD_NUMBER 환경 변수 더한 값을 빌드 번호로 사용하는 것이다.

    # travis.yml #5: Galpi 앱에서 사용중인 버전
    
    env:
      # Fastlane 세팅 중 Play Store Console에 실수로 이 버전 코드를 갖는 apk를 업로드 했는데
      # 업로드한 apk를 삭제할 수단이 없어서 이 값을 버전 코드의 베이스로 사용한다.
      - VERSION_CODE_BASE=1908191509 FLUTTER_BUILD_RELEASE_CHANNEL=beta
    
    matrix:
      include:
        - name: Android Build
          language: android
          jdk: openjdk8
          android:
            components:
              - build-tools-28.0.3
              - android-28
          before_script:
            # (before script에 들어갈 내용)
          script:
            - flutter doctor -v
            - bash scripts/populate_secret.sh
            - flutter build appbundle --build-number=$(( $VERSION_CODE_BASE + $TRAVIS_BUILD_NUMBER ))
            - cd android
            - bundle exec fastlane beta
        - name: iOS Build
      # (이하 iOS 빌드 생략)
    

    물론 이런 특이한 처리는 나와 같은 실수를 한 경우에나 필요하다. 보통은 이 글에서 예시로 들었듯 TRAVIS_BUILD_NUMBER 등의 값을 사용하면 충분할 것이다. 이 글을 읽는 사람은 부디 나와 같은 실수를 하지 말고, 처음부터 TRAVIS_BUILD_NUMBER 등의 환경 변수를 사용하거나, 이 Medium 글이 제시하는 것처럼 충분히 오래 사용할 수 있는 epoch 기반의 값을 빌드 번호로 사용하길 바란다.


    부록: 참고 자료

  • 갈피: 나의 첫 모바일 앱 개발기

    인생 첫 앱을 만들었습니다. 독후감 앱입니다.

    tl; dr

    인생 첫 앱을 만들었습니다. 독후감 앱입니다. Flutter를 사용했고 오픈소스입니다. 앱스토어에 올라가 있습니다. 플레이 스토어에는 아직 없어요. 금방 올리겠습니다. (2019-08-22 수정: 플레이 스토어에도 올라갔습니다 😊)

    갈피 앱 스크린샷

    들어가며

    어쩌다보니 웹 프론트엔드 엔지니어가 되었다. 클라이언트 작업을 좋아해서, 하는 일에 불만은 없다. 하지만 적어도 2019년 중순 현재까지는 모바일 기기에서 웹 제품만으로는 제공하기 어려운 경험들이 분명히 있다. 때문에 네이티브 앱이 필요할 때 만들 능력을 갖추고 싶다는 생각은 예전부터 해왔다.

    만약 앱을 만든다면 크로스 플랫폼 솔루션을 쓰고 싶었다. 뿌리(?)가 웹이라 더 그런지 몰라도, 한 앱을 두 플랫폼에 제공하기 위해 전혀 다른 두 개의 코드베이스를 관리하는 것은 가급적 피하고 싶었다. 한 기업의 마켓플레이스에 사실상 종속되는 기술을 배우기가 그닥 내키지 않기도 했다.

    기술을 배우는 가장 좋은 방법 중 하나는 그 기술로 무언가를 만들만한 뭔가를 떠올리고, 그걸 실제로 만들어보는 것이다. 크로스 플랫폼 모바일 앱 개발을 배우기 위해 크로스 플랫폼 모바일 앱을 만들어보기로 결심했다.

    독후감 앱을 만든 이유

    독후감 앱을 만든 이유는 두 가지다.

    첫째로, 내가 필요로 하는 앱이기 때문이다. 개인 프로젝트를 시작할 때는 적어도 한 명의 잔존 유저(i.e. 나)을 확보하는 것이 중요하다고 생각한다. 내가 사용하는 동안은 제품을 꾸준히 개선해나갈 동인이 생기는데, 이건 큰 장점이다. (돌이켜보면, 내 첫 웹사이트도 직접 사용할 블로그였다)

    둘째로, 독후감 앱의 스펙이 일반적인 앱 개발에서 요구되는 대부분의 요소 - 여러 UI 패턴, 로컬 데이터베이스, 인증, 써드파티 API 호출 등 - 를 모두 포함하기 때문이다. 쓸모있는 앱을 만드는 과정에서 필요에 의해 이런 요소를 자연스레 학습할 수 있을 것이라 생각했다.

    갈피 galpi

    처음엔 이름 짓기보다 제품 만들기가 더 중요하다는 생각으로 “booklog” 라는 임시 이름으로 시작했다. 개발이 어느정도 진행되고 앱 스토어에 올릴 때가 되자 작명을 더 미룰 수 없어졌다. 그 때부터 딱 맞는 이름을 찾기 위한 고민을 시작했다. 조건은 세 가지였다:

    1. 한글과 영문 두 표기가 모두 조형적으로 마음에 들 것.
    2. 발음하는 느낌이 좋을 것.
    3. 독후감이라는 기능의 본질과 잘 닿아있을 것.

    머리를 쥐어짜던 중 책갈피라는 단어가 스쳐 지나갔다. 앱에 갈피라는 이름을 붙이면 어떨까 하는 생각이 불현듯 떠올랐다. 표준국어대사전에 따르면 갈피라는 단어는 “겹치거나 포갠 물건의 하나하나의 사이. 또는 그 틈.“ 을 뜻한다고 한다.

    몇 번 소리내어 발음도 해 보고 적어놓고 보니 보면 볼수록 너무 마음에 들었다. 더 고민하지 않고 바로 이름을 바꾸었다. 결국 갈피라는 이름으로 앱이 나왔다.

    Flutter로 만든 이유

    무슨 앱을 만들지 다음으로 내려야 할 결정은 어떤 기술을 사용할지였다. 앞에서 밝혔듯 크로스 플랫폼 솔루션을 원했는데, 사용자가 충분히 많으면서도 배워두면 앞으로 쓸만한 법한 기술을 찾으려니 생각보다 선택지가 다양하진 않았다.

    React를 매우 즐겁게 사용하고 있는 만큼 (참고: React를 Vue.js보다 선호하는 이유) 가장 처음으로 떠올린 후보는 React Native였다. 하지만 - 아무것도 안 바꿨는데 캐시 문제로 빌드가 깨지고, 빌드 캐시를 날릴 방법이 공식 문서 어디에도 없는 등 - 문서화와 툴링 관련 아쉬움을 느낄 일이 자꾸 생겼다.

    그런 상황이 반복되자 지금 이 도구에 시간과 노력을 투자할 가치가 있는지 회의가 들었다. 결국 진입장벽을 넘지 못하고 포기했다. 관련해서 부담 없이 도움을 구할 사람이 주변에 있었으면 좀 나앗을 것 같은데, 아쉽게도 그런 상황은 아니었다. 몇 달 전 마지막으로 시도했을 때의 기억인지라, 지금은 또 어떨지는 모르겠다.

    그러던 중 회사 동료 분의 추천으로 구글의 크로스 플랫폼 UI 툴킷인 Flutter 를 다시 들여다보게 되었다. Flutter는 이전에 이름 정도는 들어봤지만 (지금은 기억이 나지 않는 이유로) 그다지 눈여겨보진 않고 넘긴 기술이었다.

    막상 다시 살펴보니 공식 문서가 매우 잘 쓰여있고 이미 대부분의 용례에서는 충분히 검증된 기술로 보였다. 또한 픽셀 단위로 동일한 UI를 보장한다는 내용을 포함해, 크로스 플랫폼 프레임워크가 갖춰야 할 미덕(?)을 잘 이해하고 있다는 인상을 받았다. 커뮤니티도 커지고 있고, 회사 차원의 투자도 활발히 이루어지고 있는 것으로 보여 시도해볼만한 가치가 있다고 판단했다.

    결과적으로, 인생 첫 앱을 Flutter로 만들어 배포까지 마쳤다.

    Flutter를 써 본 감상

    플러터는 아름답고, 하나의 코드베이스에서 모바일, 웹, 데스크톱을 위한 네이티브 어플리케이션을 만들기 위한 구글의 UI 툴킷입니다. Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile , web , and desktop from a single codebase. (source)

    Flutter를 쓰면서 기록할만하다고 느낀 포인트 몇 가지.

    Beginner friendly

    문서와 도구가 상당히 잘 준비되어 있다. 나는 Dart 언어를 사용해본 적도 없고 네이티브 앱 개발 경험 또한 전무한데도 불구하고 앱을 릴리즈하기까지 크게 막히는 부분이 없었다. 빠른 발전 속도 때문인지 조금씩 업데이트가 필요한 부분이 문서 여기저기 보이긴 했으나, 치명적인 오류는 없었다. 공식 유튜브 채널Medium의 Flutter Community부터도 도움을 많이 받았다.

    Everything is a widget!

    Flutter에서는 (HTML에서는 CSS 속성으로 처리될 정렬, 마진/패딩 등의 스타일링을 포함한) 모든 것이 위젯으로 표현된다. 필연적으로 조금만 방심하면 위젯 트리가 매우 깊어지는데… 이런 접근이 가져다주는 장점이 무엇인지는 아직 잘 모르겠다. 그렇다고 엄청 불편한 것도 아니고, 좀 어색하다는 느낌.

    개인적으로 현 시점에서는 React가 더 나은 컴포넌트 API를 갖고 있다고 생각한다. 아주 훌륭한 선구자를 굳이 따라가지 않은 디자인들에 대해서는 조금 의문이다. (다른게 문제는 아닌데, 다르게 함으로서 무엇을 얻었는지 잘 모르겠다) 혁신이라 생각하는 React Hooks에 대응하는 개념이 아직 없는 점도 슬펐다. (참고: 「Hello, React Hooks!」 발표자료 공개)

    Material Design 컴포넌트

    Flutter는 다양한 위젯을 제공하는데, 그 중에는 Material Design 컴포넌트(이하 MDC)들도 포함된다. 컴포넌트를 만들고 적절한 때에 적절한 UI를 그리기 위한 API만을 제공하는 React와는 사뭇 다른 접근인데, 처음에는 내가 프레임워크에 기대하는 영역을 벗어나는 느낌이라 좀 거부감이 들기도 했다.

    막상 개발해보니 MDC의 존재가 그렇게 고마울 수 없었다. 특히 회사에서와는 다르게 디자이너가 없어서 많이 힘들었는데, MDC는 구세주였다. 유튜브를 비롯해, 이미 같은 요소를 사용해 만들어진 여러 앱을 참고하며 정말 많은 도움을 받았다. 익숙하지 않은 환경에서 만약 MDC까지 없었다면 훨씬 많은 시간을 쓰고도 더 완성도가 낮은 앱이 나왔을 것이다.

    ConstraintLayout

    ConstraintLayout 하에서의 오버플로우 등 UI 버그의 디버깅 때문에 고생을 많이 했다. 아직 익숙하지 않아서 그런지, 혹은 오류 메시지가 친절하지 않아서 그런지, 아무튼 고통스러웠다. CSS가 그리웠다.

    Dart

    Dart는 확실히 가장 좋은 언어는 아니다. 아직 Non-null 타입이 지원되지 않음을 깨달았을 때는 꽤 충격을 받기도 했다. (논의가 열심히 이루어지고 있긴 하다.) 하지만 못 쓸 정도 수준은 전혀 아니다. Flutter가 제공하는 장점 중 상당 부분이 Dart 엔진에 의존하고 있다고 들었는데, 만약 그렇다면 Dart를 써야 한다는 것이 그다지 비싼 비용도 아닌 것 같다.

    툴링도 현재로서는 언어와 비슷하게 못 쓸 정도는 아니지만 살짝 부족한 부분들이 있다는 느낌이었다. 예를 들어 VS Code 공식 확장에서 트레일링 콤마를 찍냐 아니냐에 따라 포매팅이 달라지는데 항상 콤마를 찍도록 포매팅할지 여부를 설정할 방법이 없는 등… 미세하게 불편한 부분이 있다.

    충분히 많은 사용자가 있는 언어의 단점은 빠르게 개선하면 되는, 그리고 그렇게 해결되기 마련인 문제라고 생각한다. Flutter 커뮤니티가 지금보다 더 크게 성장하고 Fuschia 등의 다른 프로젝트가 성공한다면 지금 Dart 언어에 느끼는 아쉬움은 금세 사라질 것 같다.

    배운 점

    나의 커리어에 관하여

    이 트윗, 그리고 이어지는 트윗 에서 썼듯이 앱을 만들면서 내가 - 비록 커리어의 시작부터 지금까지 웹 프론트엔드 개발자로 일해왔지만 - 꼭 평생 웹 프론트엔드 개발자로만 살아갈 필요가 없다는 것을 깨달았다. 스스로 나의 가능성을 제한할 필요 없다!

    나에게는 정말 중요한 깨달음이었는데, 회사 일에만 파묻혀 살다보면 떠올리기 어려운 지점인 것 같다. 퇴근 후와 주말에 한두시간씩이라도 꾸준히 시간을 내서 개인 프로젝트를 진행하길 정말 잘 했다는 생각이 들었다. 앱을 만들면서 얻은 가장 큰 (심지어 앱 개발 기술 그 자체보다도 귀한) 수확이었다.

    플랫폼에 종속되지 않는 지식에 관하여

    굉장히 겁을 먹고 시작했던 것에 비해 앱을 내기까지의 과정이 생각보다 순탄했다. 이유를 생각해보면 처음 경험해보는 다른 플랫폼, 다른 언어에서의 개발이지만 기본적인 원칙 - 선언적인 UI 프로그래밍, 컴포넌트 기반의 구조화, 비동기 처리 등 - 은 비슷하게 적용되는 부분이 많아서 그런 것 같다.

    나의 분야에서 쌓아온 지식과 노하우가 다른 플랫폼, 다른 언어에서의 개발에 적용되는 경험이 즐거웠다. 주로 웹 프론트엔드만 해왔지만 한 분야에 국한되는 지식만 쌓은 건 아니라는 생각이 들어 뿌듯하기도 했다.

    앱을 개발하고 배포하는 일에 관하여

    생각보다 어렵지 않다. 재밌다!

    앞으로 해야 할 작업

    개발을 놓지 않고 계속 해나가기 위해선 최대한 빨리 앱 배포 프로세스를 한 번 밟아보는게 중요하다고 생각했다. 그래서 의도적으로 코드 퀄리티나 기능을 희생해가면서 MVP 구현에 집중했다. (그런 것 치고 엄청 빠르진 않았지만… 회사 다니면서 한 것 치고 나쁘지 않았다고 합리화를 해본다 🙄)

    목표를 달성했으니 이제 미뤄둔 일들을 건드릴 수 있게 되었다.

    안드로이드 스토어 등록

    아이폰 및 맥북 사용자라 개발은 iOS 시뮬레이터에서 진행했고, 어쩌다보니 크로스 플랫폼 운운한 것이 무색하게도 아직 앱 스토어에만 앱을 배포해뒀다. 안드로이드 스토어에도 앱을 올려야 한다. 배포만 하면 되는데… 왜 항상 배포는 이리 귀찮은지…

    배포 및 테스트 자동화

    위 작업이 끝나면 fastlane 등의 서비스로 배포, 테스트, 스토어에 올릴 스크린샷을 찍는 등의 작업을 자동화할 예정이다. 그사이 조금 익숙해졌다지만, 네이티브 앱 배포는 웹 어플리케이션 배포에 비해 훨씬 품이 많이 든다. 지치지 않고 개발의 템포를 유지하려면 시급하게 해결해야 할 문제.

    백엔드 작업 및 데이터 이관

    백로그에 있는 기능 중 가장 우선순위가 높은 작업이다. 지금은 데이터를 로컬 데이터베이스에만 저장하고 있어서, 앱 삭제시 데이터가 날아간다. 사용자의 입장에서 생각해보면 요즘 시대에 말이 안 되는 동작이라 생각한다.

    때문에 가입/로그인 및 데이터를 서버로 옮기는 작업까지 MVP에 포함시켜야하나 고민을 많이 했다. 하지만 Firebase 등을 사용하지 않고 직접 백엔드를 구현한다면 (그럴 생각이다) 작지 않은 스펙이라 일단 이렇게 나 자신과 타협을 보았다.

    데이터를 서버로 옮기는 작업이 끝나도, 오프라인 환경에서 앱을 사용 가능하게 만들려면 로컬 데이터베이스 사용은 필요할 것 같다. 빠른 배포를 위해 잠깐 시간이 지나면 필요없어질 작업을 한 것은 아니었다는 점이 불행 중 다행(?).

    클라이언트 코드베이스 개선

    어떤 기능을 구현할 때마다 정말 그러기 위해 필요한 최소한의 언어/프레임워크 지식만 익히고 바로 써먹는 식으로 앱을 만들다보니 클라이언트 코드베이스가 좀 엉망이다. Stream을 써야할 곳에 Future를 state와 엮어서 pseudo-Stream (?) 을 만들어 쓰는 등 알면서도 안/못 고친 민망한 코드가 많다.

    첫 출시를 마쳤으니 잠시 숨을 고르면서 이런 민망한 코드들도 좀 정리하고, 서버로 데이터 이관이 끝나면 provider 같은 상태 관리 라이브러리도 도입해면서 코드베이스 퀄리티를 끌어올릴 생이다.

    맺으며

    굉장히 즐거웠던, 난생 첫 앱을 만드는 경험을 간략히 기록으로 남겨 보았습니다. 갈피의 소스 코드는 GitHub 리포지토리에 공개되어 있습니다. 제가 쓰고 싶어 만들었지만 남들도 쓰고 싶은 앱이 될 수 있게 앞으로도 열심히 만들어 보겠습니다. 읽어주셔서 감사합니다.

  • 「프로그래머의 배움」 발표자료 공개

    2019년 6월 22일 GDG Korea Campus에서 주최한 FRONT-ENDGAME 행사에서 발표한 자료를 공개합니다.

    요즘은 글은 안 쓰고 자꾸 발표자료만 공개하네요. 데드라인이 없는, 회사일이 아닌 무언가를 끝마치기가 쉽지 않음을 새삼 느낍니다. 프로그래머로서의 성장을 도왔던 태도들 이라는 글의 내용을 거의 그대로 옮기기만 하려는 마음이었는데요. 막상 옮겨놓고 보니 부족한 점이 너무 많이 보여서 약간의 보강과 대대적인 재배치를 했습니다.

    프로그래밍을 시작하고부터 - 어쩌면 그보다도 한참 전부터 - 너무 많은 분들의 친절과 도움을 받아온 탓에 늘 빚진 마음으로 살고 있습니다. 대학생, 또 이제 막 커리어를 시작하는 분들이 많이 오는 행사라고 하셔서 받은 걸 조금이나마 돌려주고픈 마음으로 발표 요청을 수락했어요.

    프로그래밍을 시작할 때 누군가 제게 해줬으면 좋았을 것 같은 내용을 담으려 노력했어요. 얼마나 와닿았을지는 모르겠습니다. 행사장에 처음 들어갔을 때 생각보다 너무 많은 분께서 와주셔서 놀랐고 감사했습니다. 귀한 시간 내주신 참가자 분들께 도움이 되었다면 더 바랄 것이 없겠습니다.

  • 전하지 못한 고마움

    어떤 고마움에 관하여.

    나는 그다지 대단하거나 특별한 사람이 아니다. 당연히 돈 많이 벌고 잘 살고 싶다. 하지만 내게는 지금보다 더 많이 버는 것보다 조금 더 중요한 것들이 있다. 그걸 포기해가며 돈 많이 버는 사람을 보면 - 이게 신념인지 질시인지 아직 잘 모르겠지만 - 부럽다가도 저건 내 길은 아니라는 생각을 한다.

    한편 세상엔 짜치는 일 않고, 자존심 안 팔고, 자신까지 속이게 만드는 사짜 노릇 안 하면서도 행복하게, 멋을 잃지 않고 살면서도 부족하지 않게 버는 사람들이 있다. 많지 않지만 없지도 않다. 그런 사람들의 존재를 볼 때면 나는 왠지 힘이 난다. 그들이 내게 이런 길도 있다- 말해주는 것만 같다.