Play 검토자는 내 앱을 처음 여는 사람이다. 로그인하자마자 레시피 0개, 이번 달 식단 0개짜리 빈 화면을 보면 그 사람은 "기능이 없는 앱"이라고 적을 것이다. 그래서 스토어에 올리기 전에 검토자가 쓸 계정을 만들고, 그 계정의 서랍에 깍두기와 소불고기, 제육볶음을 먼저 넣어 두었다.

웹으로만 돌던 서비스를 스토어에 올린다는 건 이런 일이었다. 기능을 더 만드는 게 아니라, 낯선 사람과 낯선 규칙 앞에서 이 서비스가 무엇인지 증명하는 일. 연재의 마지막 편은 그 바깥쪽 이야기다.

Play 스토어 등록정보에 쓴 기능 그래픽. 식단서랍 로고와 휴대폰 화면

검토자를 위한 서랍

검토용 계정은 처음에 SQL 스크립트로 채웠다. 레시피 3개에 재료 41줄, 조리 단계 23줄. 스크립트는 같은 제목의 레시피를 먼저 지우고 다시 넣기 때문에 몇 번을 돌려도 결과가 같다. 그런데 이 스크립트는 계정이 이미 있다는 전제에서만 돈다. 계정이 없으면 사용자 ID 를 못 찾고 외래 키에서 실패한다.

그래서 계정 쪽은 앱이 직접 챙기게 했다. 앱이 시작할 때 도는 시더 묶음에 검토자 시더를 하나 더 넣었다. 계정이 없으면 만들고, 있으면 건너뛴다. 만들 때 EmailConfirmed 를 처음부터 참으로 둔다. 이 한 줄이 나중에 효과를 봤다. 시더 하나가 실패해도 앱은 뜨도록 하나하나 try 로 감쌌고, 마이그레이션이 덜 된 상태면 다음 기동 때 다시 시도한다.

Play 가 요구하는 페이지도 두 개 만들었다. 개인정보 처리방침은 수집하는 것과 수집하지 않는 것, 보관과 파기까지 항목별로 썼다. 뒤에 카카오 간편로그인, 이메일 인증, 앱 푸시를 붙이면서 그때 받는 정보와 지우는 시점도 처리방침에 더했다. 계정 및 데이터 삭제 안내 페이지는 로그인 없이 열리게 했다. 앱을 이미 지운 사람도 데이터 삭제를 요청할 수 있어야 하기 때문이다.

이 도메인은 내 앱의 것이다

안드로이드 앱이 웹 페이지를 주소창 없이 띄우려면 도메인과 앱이 서로를 알아봐야 한다. 앱은 "나는 이 도메인을 연다"고 선언하고, 도메인은 /.well-known/assetlinks.json 에서 "이 서명으로 서명된 이 패키지는 내 편이다"라고 답한다. Digital Asset Links 다.

{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "kr.co.addsoft.recipedrawer",
    "sha256_cert_fingerprints": ["<업로드 키 지문>", "<앱 서명 키 지문>"]
  }
}

처음엔 지문을 하나만 넣었다. 내가 빌드할 때 쓰는 업로드 키의 지문이다. 직접 빌드한 앱을 기기에 깔면 잘 열렸다. 그런데 Play 앱 서명을 쓰면 사용자가 스토어에서 받는 앱은 내 키가 아니라 Google 이 가진 앱 서명 키로 다시 서명된다. 서명이 바뀌면 도메인은 그 앱을 모르는 앱으로 본다. 그래서 두 번째 지문으로 앱 서명 키를 추가했다. 배열에 두 개를 넣어 두면 내 손으로 빌드한 앱도, 스토어에서 받은 앱도 같은 도메인의 앱이 된다.

앱 껍데기는 처음 TWA 로 시작했다가 나중에 WebView 로 옮겼는데, 그건 이 연재 바깥의 이야기다.

출시 순서는 문서에 적어 두었다. 내부 테스트로 전체 화면 동작을 확인하고, 비공개 테스트로 테스터를 모아 14일을 운영한 뒤에 정식 출시. 그 순서를 지나 지금은 Play 스토어에 올라가 있다.

비밀번호 없이 들어오는 사람

스토어에서 앱을 처음 받은 사람에게 이메일과 비밀번호를 새로 만들라고 하면 많이들 돌아선다. 그래서 카카오 간편로그인을 붙였다. 별도 패키지 없이 ASP.NET Core 의 기본 OAuth 핸들러로 등록했고, 설정에 키가 없으면 버튼 자체가 나오지 않는다.

외부 로그인 콜백에서 갈래는 셋이다. 이미 연결된 계정이면 바로 로그인한다. 카카오가 준 이메일과 같은 기존 계정이 있으면 자동으로 연결한다. 둘 다 아니면 그 자리에서 가입시키고 로그인 상태로 돌려보낸다. 카카오에서 받은 이메일은 카카오가 "유효하고 인증됐다"고 표시한 경우에만 클레임으로 쓴다. 자동 연결의 근거가 되는 값이라 확인된 것만 믿기로 했다.

그다음 회원가입에 이메일 인증을 붙이면서 두 번째 갈래에 구멍이 보였다. 누군가 남의 이메일로 먼저 가입해 두고 인증하지 않은 채 놔두면, 나중에 진짜 주인이 카카오로 들어왔을 때 그 계정에 자동으로 연결된다. 주인이 대신 인증해 준 셈이 되고, 그 계정의 비밀번호는 먼저 가입한 사람이 알고 있다. 그래서 인증 안 된 비밀번호 계정에는 자동 연결을 하지 않는다.

if (user != null && !user.EmailConfirmed && await _userManager.HasPasswordAsync(user))
{
    return Fail("같은 이메일로 가입했지만 인증이 완료되지 않은 계정이 있습니다. 메일 인증 후 이용해 주세요.", returnUrl);
}

이메일 인증을 켜는 것 자체에도 함정이 두 개 있었다. 하나는 기본 판정이 EmailConfirmed 만 본다는 것이다. 카카오에서 이메일을 받지 못한 간편가입 계정은 영영 로그인을 못 하게 된다. 그래서 판정을 직접 구현해서 "이메일이 인증됐거나, 비밀번호가 없는 계정"이면 통과시켰다. 비밀번호가 없다는 건 외부 제공자로만 들어올 수 있다는 뜻이니까. 다른 하나는 이미 가입한 사람들이다. 인증이라는 개념이 없던 시절의 회원이 갑자기 로그인 화면에서 막히면 안 되니, 이메일이 있는 기존 회원을 한꺼번에 인증 완료로 바꾸는 마이그레이션을 같이 넣었다. 검토자 계정은 처음부터 인증 완료로 만들어 두었으니 이 변경에 걸리지 않았다.

구독한 사람에게만 가는 한 줄

앱이 생기면 알림을 보낼 수 있다. 푸시는 FCM 으로 붙였다. 기기 토큰을 담는 구독 테이블에 사용자당 여러 기기를 허용하고, 발송 서비스는 그 사용자의 활성 구독 전부에 보낸다. 메시지는 data 전용이다. notification 필드를 쓰면 앱이 백그라운드일 때 시스템이 알림을 대신 그려 버리지만, data 만 보내면 앱이 어떤 상태든 앱의 수신 코드가 받아서 직접 그린다. 발송이 실패하면 오류 종류를 본다. 앱이 지워졌거나 토큰이 만료됐거나 다른 프로젝트의 토큰이면 그 구독은 바로 끈다. 그 밖의 오류는 일시 장애일 수 있으니 실패 횟수만 세다가, 연속으로 쌓이면 끈다.

처음 푸시를 쓴 곳은 달력 공유 초대였고, 그다음이 레시피 구독이다. 작성자를 구독해 두면 그 사람이 새 레시피를 공개했을 때 알림이 온다. 여기서 "새 레시피를 공개했을 때"를 정확히 잡는 게 일이었다. 공개 레시피의 오타 하나를 고칠 때마다 구독자 전원에게 알림이 가면 곤란하다. 그래서 수정 서비스가 저장 결과와 함께 "이번 저장으로 비공개에서 공개가 됐는지"를 돌려주게 했다.

var becamePublic = !recipe.IsPublic && model.IsPublic;

컨트롤러는 이 값이 참일 때만 알림을 보낸다. 처음부터 공개로 등록할 때, 수정 폼에서 공개로 바꿀 때, 목록에서 공개 토글을 켤 때. 알림 발송이 실패해도 레시피 저장은 성공으로 끝난다. 알림은 덤이고, 레시피가 본체다.

배포 버튼이 세 번 실패한 이유

스토어에 앱이 올라가면 서버 배포가 잦아진다. 손으로 게시하던 걸 사내 Git 서버의 Actions 워크플로로 옮겼다. 게시 대상이 IIS 이고 Web Deploy 가 Windows 전용이라 러너는 self-hosted Windows 일 수밖에 없었다. 배포는 수동 실행으로만 두고, 순서는 빌드, 테스트, 멱등 마이그레이션 스크립트 생성, 마이그레이션 적용, 게시, 헬스 체크로 정했다. 마이그레이션이 실패하면 게시하지 않는다.

첫 실행은 '시크릿 확인' 스텝에서 파서 에러로 죽었다. 문자열 종결 따옴표가 없다는 에러였는데, 스크립트에는 따옴표가 멀쩡히 있었다. 러너는 스텝 스크립트를 UTF-8 .ps1 로 써 두는데, Windows PowerShell 5.1 은 그 파일을 시스템 코드페이지(949)로 읽는다. 오류 메시지 안의 한글이 깨지면서 바이트가 따옴표를 먹어 버린 것이다. 러너 호스트에는 PowerShell 7 이 없어서 셸을 바꿀 수도 없었다. 그래서 규칙을 하나 정했다. run: 블록 본문은 ASCII 로만 쓴다. 스텝 이름과 YAML 주석은 .ps1 에 들어가지 않으니 한글이어도 된다. 이 규칙은 워크플로 파일 맨 위에 주석으로 박아 두었다. 그래도 한 번 더 어겼다. 게시 스텝 안에 단 한글 주석 한 줄 때문에 다시 고쳤다.

두 번째 실행은 더 이상했다. 같은 스텝이 "Both secrets present." 까지 정상으로 출력하고 종료 코드 1 로 실패했다. 러너는 스텝 스크립트 앞에 chcp 65001 을 끼워 넣는데, 이 환경에서는 이게 실패하면서 $LASTEXITCODE 가 1 로 남는다. 스크립트가 끝나면 PowerShell 은 그 값을 종료 코드로 쓴다. 앞의 '툴체인 확인' 스텝이 통과한 건 dotnet --list-sdks 가 $LASTEXITCODE 를 0 으로 덮어썼기 때문이었다. 네이티브 명령이 없는 스텝만 죽는다.

      - name: 시크릿 확인
        run: |
          # ... (secret checks, ASCII only)
          Write-Host "Both secrets present."
          exit 0

모든 여러 줄 run: 블록 끝에 exit 0 을 명시했다. 성공이면 성공이라고 스크립트가 직접 말하게 한 것이다.

세 번째는 마이그레이션까지 성공하고 게시에서 실패했다. msdeploy.exe 가 코드 -1 로 끝났다는 한 줄 말고는 아무 메시지가 없었다. 게시를 상세 로그로 돌려 실패하면 msdeploy 관련 줄만 추려 보여 주게 하고, 게시 임시 산출물에서 가장 긴 경로의 길이를 출력하게 했다. msdeploy 는 경로가 260자를 넘으면 아무 말 없이 죽기 때문이다. 러너의 기본 작업 폴더는 사용자 폴더 밑 깊숙한 곳이라 프로젝트 경로가 붙으면 그 한도에 쉽게 닿는다. 러너의 작업 경로를 짧은 위치로 옮겼다.

세 실패 모두 내 코드가 아니라 내 코드가 놓인 환경에서 났다.

다시, 오늘은 뭐 먹지

이 연재는 가족과 매일 나누던 질문 하나에서 시작했다. 오늘은 뭐 먹지. 가장 정하기 어렵고, 매번 반복되고, 미뤄 두어도 결국 정해야 하는 문제. 그걸 놓치지 않으려고 기록할 곳을 만들었는데, 기록할 곳은 레시피 서랍이 되고, 월간 식단 달력이 되고, 장보기 목록이 되고, 결국 스토어에 올라가는 앱이 되었다.

스토어 바깥의 일은 끝나지 않을 것이다. 그래도 저녁이 되면 질문은 똑같이 돌아온다. 오늘은 뭐 먹지? 이제 그 질문을 들으면 서랍을 연다.

식단서랍은 웹(recipe.addsoft.co.kr)과 Play 스토어에서 쓸 수 있다.