로그인 화면에는 버튼이 두 개 있었다. 이메일로 로그인, 그리고 노란 카카오 버튼. Android 앱에서는 아무 문제가 없던 화면이다. 아이폰 앱으로 옮길 목록을 정리하다가 이 화면에서 손이 멈췄다.

App Store 심사 가이드라인 4.8 은 로그인 서비스에 관한 조항이다. 앱이 카카오나 구글 같은 제3자 로그인으로 계정을 만들게 하면, 그와 동등한 다른 로그인 방법도 함께 제공해야 한다. 그 방법은 이름과 이메일만 받고, 이메일을 가릴 수 있게 하고, 동의 없이 광고 추적에 쓰지 않아야 한다. 이 조건을 확실하게 채우는 답이 Sign in with Apple 이다. 식단서랍 웹은 앱이 감싼 화면이라, 웹에 카카오 버튼이 있으면 앱에도 있는 셈이다.

버튼을 숨길까, 하나 더 달까

길은 셋이었다. 서버에 Apple 로그인을 넣는 것, iOS 앱에서만 카카오 버튼을 CSS 로 숨기는 것, 그대로 내고 이메일 가입이 있다는 걸 심사 메모로 설명하는 것.

숨기는 쪽이 가장 쌌다. 서버를 건드리지 않고 앱이 스타일 한 줄만 넣으면 된다. 문제는 이미 카카오로 가입한 사람이었다. 그 사람은 아이폰 앱에서 로그인할 방법이 사라진다. 비밀번호를 따로 만들라고 안내해야 하는데, 카카오로 가입한 사람에게 비밀번호는 낯선 개념이다. 그대로 내는 쪽은 심사에서 거절될 가능성을 안고 가는 길이다.

서버에 Apple 로그인을 넣기로 했다. 웹에 넣으면 웹 사용자에게도 열리지만, 그게 단점은 아니었다. 아이폰으로 가입한 사람이 PC 브라우저에서 같은 계정으로 들어올 수 있어야 하니까.

form_post 로 돌아오는 웹 흐름

ASP.NET Core 서버에는 카카오 로그인이 이미 OAuth 핸들러로 붙어 있었다. Apple 도 같은 자리에 AspNet.Security.OAuth.Apple 패키지를 붙였다. Apple 의 웹 로그인은 OpenID Connect 다. 사용자가 Apple 화면에서 로그인하면 Apple 이 우리 서버의 /signin-apple 로 결과를 보낸다. 범위는 openid name email 이다.

카카오와 다른 점이 셋 있었다.

첫째, client_secret 이 고정 문자열이 아니다. Apple Developer 에서 Services ID 를 만들고 Sign in with Apple 용 키(.p8)를 받은 다음, 그 키로 서명한 ES256 JWT 를 비밀값으로 쓴다. 패키지가 JWT 를 만들고 메모리에 캐시한다. Apple 이 허용하는 만료는 최대 6개월인데 30일로 짧게 잡았다. 어차피 캐시가 만료되면 다시 만든다. 설정에 Services ID, 팀 ID, 키 ID, 키 중 하나라도 비어 있으면 핸들러를 등록하지 않는다. 그러면 로그인 화면에 Apple 버튼도 뜨지 않는다. 눌러도 실패할 버튼을 보여 줄 이유가 없다. 카카오 버튼도 같은 방식으로 다뤄 왔다.

둘째, 돌아오는 방식이 POST 다. 카카오는 브라우저를 콜백 주소로 리다이렉트(GET)하지만, Apple 은 이름과 이메일 범위를 요청하면 결과를 response_mode=form_post 로 보낸다. 다른 출처(appleid.apple.com)에서 우리 서버로 폼을 제출하는 것이다. 이때 로그인 시작 때 심어 둔 correlation 쿠키가 실려 와야 핸들러가 "내가 시작한 로그인" 임을 확인한다. 기본값인 SameSite=Lax 쿠키는 다른 사이트에서 오는 POST 에 실리지 않는다.

options.GenerateClientSecret = true;
options.ClientSecretExpiresAfter = TimeSpan.FromDays(30);

// Apple 은 콜백을 cross-site POST(form_post)로 보낸다 → SameSite=None; Secure 여야 쿠키가 실려 온다
options.CorrelationCookie.SameSite = SameSiteMode.None;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;

이 두 줄이 없으면 Apple 화면에서 로그인을 마치고 돌아오는 순간 "Correlation failed" 로 끝난다.

셋째, 이름은 딱 한 번 온다. Apple 은 사용자의 이름을 id_token 에 넣지 않는다. 처음 로그인할 때만 폼의 user 필드에 JSON 으로 실어 보내고, 두 번째부터는 보내지 않는다. 그래서 티켓을 만드는 순간 user 필드를 읽어 이름 클레임으로 바꿔 넣었다. 기존의 간편 가입 흐름은 이름 클레임을 닉네임으로 저장하니 그 뒤는 손댈 게 없다. firstName 과 lastName 이 둘 다 한글이면 "김하늘" 처럼 성을 앞에 붙이고, 그 밖에는 "John Appleseed" 처럼 이름을 앞에 둔다.

이메일은 id_token 의 email_verified 가 true 일 때만 남긴다. 같은 이메일로 이미 가입한 계정이 있으면 자동으로 연결하는 규칙이 카카오 로그인에 있는데, 그 규칙의 근거가 이메일이다. 확인되지 않은 이메일로 남의 계정에 연결되면 안 된다. 카카오에 적용하던 조건을 그대로 Apple 에도 걸었다.

로그인 화면에는 검은 배경에 흰 Apple 로고와 'Apple로 로그인' 문구를 넣었다. Apple 의 버튼 디자인 지침을 따른 것이다. 다크 모드에서 사이트 팔레트가 뒤집혀도 버튼은 검정과 흰색 그대로 남도록 색을 고정했다.

로그인은 같은 WebView 안에서 끝나야 한다

서버가 준비되자 앱 쪽이 남았다. 식단서랍 iOS 앱은 앱 도메인만 WKWebView 안에서 열고, 다른 도메인은 SFSafariViewController 로 넘긴다. 카카오 인증 도메인만은 예외로 앱 안에서 연다. 유니버설 링크가 없으니 밖으로 내보낸 로그인은 돌아올 길이 없고, Safari 화면의 쿠키는 WebView 와 따로 논다. 이 판단은 2편에서 정리했다.

Apple 로그인은 같은 이유에 이유가 하나 더 붙는다. 돌아오는 방식이 리다이렉트가 아니라 폼 POST 다. 카카오처럼 콜백 주소로 이동하는 거라면 유니버설 링크를 갖췄을 때 앱으로 이어 붙일 여지라도 있다. appleid.apple.com 이 /signin-apple 로 폼을 제출하는 흐름은 그 화면이 떠 있는 바로 그 웹 엔진 안에서만 완성된다. correlation 쿠키를 들고 있는 것도 그 엔진이다. Safari 화면에서 Apple 로그인을 마치면 폼은 Safari 쪽 쿠키로 서버에 가고, 서버는 로그인을 시작한 적 없는 요청으로 보고 거절한다. 설령 통과해도 로그인 쿠키는 Safari 에 생기고 앱은 로그아웃 상태 그대로다.

/// 웹 로그인 중 거치는 외부 도메인 — 앱 안에서 연다
private static let inAppLoginHosts = ["kauth.kakao.com", "accounts.kakao.com", "logins.daum.net", "appleid.apple.com"]

그래서 목록에 appleid.apple.com 한 줄을 더했다. 판정은 https 이고 호스트가 정확히 같거나 그 하위 도메인일 때만이다. 목록에 든 도메인은 앱 도메인과 똑같이 WebView 안에서 열리지만, 앱이 웹에 넣는 브리지 스크립트는 프레임 출처가 앱 도메인일 때만 동작한다. Apple 의 로그인 페이지에서는 푸시 브리지가 보이지 않는다. 남의 페이지를 앱 안에 들이는 대신, 그 페이지에 앱의 손잡이는 내주지 않는 셈이다.

앱이 직접 만드는 로그인이었다면 ASAuthorizationController 로 시스템의 Apple 로그인 화면을 띄웠을 것이다. 이 앱은 웹 로그인을 그대로 담는 그릇이고, 웹 흐름 하나로 웹과 Android 와 iOS 를 함께 덮는 쪽을 택했다. 그 대신 앱 안에서 열리는 외부 도메인은 로그인에 꼭 필요한 이 네 곳으로만 묶어 두었다. 실기기에서 Apple 로그인이 끝까지 이어지는지는 TestFlight 에서 확인할 차례다.

연결이 끊기는 쪽도 봤다

Apple 로그인을 붙이면서 간편 로그인의 반대쪽 끝을 처음 제대로 들여다봤다. 로그인 연결이 생기는 일만 만들었지, 끊기는 일은 생각하지 않았다.

카카오 사용자는 카카오계정 설정에서 언제든 식단서랍과의 연결을 끊을 수 있다. 카카오계정을 아예 탈퇴할 수도 있다. 그동안 그 사실을 우리 서버는 몰랐다. 카카오로만 가입한 계정이면 로그인할 방법이 사라진 채로 식단과 레시피를 들고 서버에 남는다. 주인이 다시 찾아올 수 없는 개인정보다.

카카오는 이런 순간을 연결 해제 웹훅으로 알려 준다. 카카오 디벨로퍼스에 웹훅 주소를 등록하면, 연결이 끊길 때 카카오가 app_id 와 user_id 를 실어 우리 서버를 부른다. 요청에는 KakaoAK {어드민 키} 형식의 Authorization 헤더가 붙는다. 이 헤더를 고정 시간 비교로 확인하고, app_id 가 우리 앱인지 본다. 인증이 틀리면 401, 그 밖에는 처리 결과와 상관없이 200 이다. 카카오는 3초 안에 200 을 받기를 기대한다.

var removed = await _users.RemoveLoginAsync(user, KakaoAuthentication.Scheme, kakaoUserId);
// ...
var hasOtherWay = await _users.HasPasswordAsync(user) || (await _users.GetLoginsAsync(user)).Count > 0;
if (hasOtherWay) return KakaoUnlinkOutcome.LinkRemoved;

var deleted = await _deletion.DeleteAsync(user, ct);

처리는 두 갈래다. 먼저 카카오 로그인 연결을 지운다. 그 뒤에도 비밀번호나 다른 간편 로그인, 이를테면 이번에 넣은 Apple 로그인이 남아 있으면 연결만 지우고 끝낸다. 아무것도 남지 않으면 회원 탈퇴로 처리한다. 카카오 문서가 권하는 조치이기도 하다. 탈퇴는 원래 있던 탈퇴 서비스를 그대로 부른다. 사용자 데이터를 먼저 정리하고 계정을 지우는 순서가 거기 다 들어 있다.

반대 방향도 막았다. 사용자가 식단서랍에서 탈퇴하면, 탈퇴가 끝난 뒤 카카오의 연결 끊기 API 를 불러 카카오 쪽의 앱 연결과 동의 기록도 지운다. 사용자 행이 지워지면 로그인 기록도 함께 사라지니, 카카오 회원번호는 지우기 전에 미리 챙겨 둔다. 연결 끊기가 실패해도 탈퇴는 그대로 진행하고 로그만 남긴다. 탈퇴가 외부 API 하나 때문에 막히면 안 된다. 어드민 키는 비밀값이라 운영 설정에만 둔다. 키가 없으면 웹훅은 404 를 돌려주고 연결 끊기도 건너뛴다.

App Store 는 앱에서 계정을 만들 수 있으면 앱 안에서 계정을 삭제할 수도 있어야 한다는 조항(5.1.1(v))을 두고 있다. 식단서랍 웹에는 탈퇴 화면이 이미 있었다. 이번 일은 그 탈퇴가 우리 서버 안에서만 끝나지 않고, 연결된 바깥 서비스까지 정리되게 만든 것이다.

마지막으로 작은 것 하나. 간편 가입 때 제공자가 닉네임을 주지 않으면 "카카오 사용자" 같은 기본값이 들어간다. 그런데 닉네임은 가입할 때만 저장해서, 나중에 카카오 닉네임 제공에 동의해도 계정은 기본값 그대로였다. 로그인할 때 계정 닉네임이 비었거나 "{제공자} 사용자" 꼴이고 이번에 닉네임을 받았으면 바꾸게 했다. 사용자가 직접 바꾼 닉네임은 건드리지 않는다.

버튼 하나를 더 달려고 시작한 일이었다. 끝나고 보니 로그인 수단이 둘이 되면서, 그 둘이 한 계정 안에서 어떻게 남고 어떻게 사라지는지를 처음으로 정리하게 됐다. 연결은 만드는 순간보다 끊기는 순간에 더 많은 질문을 던진다.

다음 편은 이 연재의 마지막이다. 빌드를 올리고, 심사 앞에서 채워야 하는 칸들을 하나씩 메운 이야기다.