로그인하고 나면 왜 내 앱으로 돌아올까? (그 길, 제가 안 깔아둬서 한 번 잃어버렸습니다)

네이버 로그인 버튼을 누릅니다. 네이버 앱이 열립니다. 동의하고 나면, 어느새 원래 앱으로 돌아와 있습니다.

너무 매끄러워서 아무도 이걸 "기능"이라고 생각하지 않죠. 사용자 입장에선 화면이 잠깐 바뀌었다 돌아온 것뿐이니까요.

그런데 이 돌아오는 길은 공짜가 아닙니다. 누군가 미리 깔아둬야 하고, 그걸 깔아두는 건 SDK도 OS도 아니고 앱 개발자 본인입니다. 정확히는 Info.plist에 넣는 텍스트 몇 줄이요.

저는 제가 만든 라이브러리 react-native-naver-login의 예제 앱에서 이 길을 제대로 안 깔아둔 채로 0.1.1까지 왔습니다. (네, 라이브러리 만든 사람이요. 민망하지만 이게 오늘 글의 재료입니다.)

먼저: 소셜 로그인은 2단 사다리다

길 이야기를 하려면 먼저 어디로 가는지를 알아야 하니까, 흐름부터 봅시다. 네이버든 카카오든 국내 OAuth SDK는 거의 같은 구조를 씁니다.

1단은 네이티브 앱 로그인입니다. 제공자 앱이 기기에 깔려 있고 네이티브 앱 로그인 토글이 켜져 있으면(기본값입니다), SDK가 naversearchthirdlogin://(네이버)이나 kakaokompassauth://(카카오) 같은 scheme으로 -[UIApplication openURL:options:completionHandler:]를 호출합니다. 그러면 OS가 그 scheme을 설치된 네이티브 앱으로 해석해서 앱을 띄우고, 사용자는 그 앱 안에서 인증을 마칩니다. 끝나면 그 앱이 YOUR_URL_SCHEME://thirdPartyLoginResult 같은 커스텀 scheme 주소로 원래 앱을 다시 엽니다. 이게 아까 말한 그 "돌아오는 길"입니다.

2단은 인앱 WebView 로그인입니다. 1단이 불가능하면 — 제공자 앱이 안 깔려 있거나, 소비자가 토글을 꺼뒀거나 — SDK가 인앱 WebView를 띄워서 제공자의 웹 OAuth 엔드포인트로 보냅니다. 이때는 완료를 어떻게 아느냐? OS의 scheme 디스패치가 아니라 WebView의 URL 내비게이션을 가로채서 압니다. 같은 로그인인데 완료를 감지하는 메커니즘 자체가 다른 거죠.

중요한 건 이 폴백이 자동이라는 점입니다. 소비자가 "네이버 앱 깔려 있나?" 직접 확인하거나 WebView로 라우팅할 필요가 없습니다. SDK가 내부적으로 제공자 앱 scheme에 대해 -canOpenURL:을 호출해서 알아서 단을 고릅니다. (이 문장에 밑줄 그어두세요. 오늘 사고의 절반이 여기서 납니다.)

기기마다 일관된 흐름이 필요할 때를 위해 강제로 2단만 쓰게 만드는 토글도 있긴 합니다 (react-native-naver-logindisableNaverAppAuthIOS, 기본값 false). 다만 기본값으로 두는 한, 단을 고르는 건 여러분이 아니라 SDK입니다.

그리고 실무자용 사족 하나. iOS 시뮬레이터에는 보통 네이버·카카오 앱이 안 깔려 있습니다. 그래서 시뮬레이터에서 아무리 눌러봐야 여러분은 2단(WebView) 경로만 반복해서 테스트하고 있는 겁니다. 1단을 진짜로 확인하려면 유효한 Team과 Provisioning Profile이 붙은 실기기가 필요해요. "시뮬레이터에선 잘 되는데요"는 여기선 아무 증거가 못 됩니다.

iOS — 문 하나, 창문 하나. 둘 다 있어야 합니다

iOS에서 이 길을 깔려면 Info.plist에 성격이 완전히 다른 키 두 개가 필요합니다. 하나는 들어오는 쪽, 하나는 나가는 쪽이에요.

들어오는 쪽은 CFBundleURLTypes 배열 안의 CFBundleURLSchemes 배열입니다. "우리 앱은 이 scheme으로 오는 주소를 받습니다"라고 OS에 신고하는 것이고, 집으로 치면 현관문입니다. 콜백이 이 문으로 들어옵니다.

나가는 쪽은 최상위 LSApplicationQueriesSchemes 배열입니다. iOS 9에서 도입된 키인데, -[UIApplication canOpenURL:]조회해도 되는 scheme의 목록을 미리 선언하게 합니다. 이쪽은 창문에 가깝습니다. 밖에 저 앱이 있는지 내다보는 용도요.

그리고 iOS 9 이후로는, 이 허용목록에 없는 scheme은 해당 앱이 실제로 깔려 있든 말든 -canOpenURL:이 무조건 NO를 반환합니다. openURL:도 같이 실패하고요. "응 네 이름 우리 목록에 없어서 우리는 그런 앱 모름~" 하고 OS가 시치미를 떼는 겁니다.

네이버가 요구하는 조회용 scheme은 두 개이고, 역할이 각각 다릅니다. naversearchapp은 네이버 모바일 앱의 범용 scheme으로 설치 여부를 확인하는 데 쓰이고, naversearchthirdlogin은 네이버 SDK가 서드파티 로그인 흐름을 설치된 앱으로 넘길 때 쓰는 OAuth 전용 scheme입니다. 둘 중 하나만 빠져도 SDK의 네이티브 앱 감지가 "미설치"로 결론 내고, 네이버 앱이 멀쩡히 깔린 기기에서도 조용히 2단 WebView로 내려갑니다.

README에 적어둔 형태는 이렇습니다.

<!-- URL Scheme: receives the OAuth callback from the Naver app -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>naverloginexample</string>
    </array>
  </dict>
</array>

<!-- Allows querying whether the Naver app is installed -->
<key>LSApplicationQueriesSchemes</key>
<array>
  <string>naversearchapp</string>
  <string>naversearchthirdlogin</string>
</array>

같은 README에 이런 경고도 박아뒀습니다.

A missing CFBundleURLSchemes entry is the most common mistake.

이게 빠지면 앱은 OAuth 콜백을 영영 못 받고, Failed to open URL naverloginexample://thirdPartyLoginResult를 보게 됩니다. 왜 이런 경고를 굳이 대문짝만하게 써뒀는지는 조금 뒤에 나옵니다. (예고편: 제 얘기입니다.)

한쪽만 넣으면 어떻게 되나

문과 창문이라는 비유가 왜 쓸모 있냐면, 하나만 빠뜨렸을 때의 증상이 서로 완전히 다르기 때문입니다.

창문 없이 문만 있으면, 그러니까 CFBundleURLSchemes는 넣었는데 LSApplicationQueriesSchemes가 없으면 콜백은 받을 수 있습니다. 다만 SDK가 동반 앱을 감지하지도 실행하지도 못해서, 네이버 앱이 깔린 폰인데도 WebView로 내려가거나 아예 앱을 못 엽니다. 불편하긴 해도 로그인 자체는 되죠.

반대로 문 없이 창문만 있으면 훨씬 고약합니다. 감지와 실행은 잘 됩니다. 앱도 뜨고 동의도 끝납니다. 그런데 돌아올 scheme의 핸들러가 OS에 등록돼 있지 않으니 콜백 URL이 실패하고, OAuth 흐름이 영영 안 끝납니다. 사용자는 로그인을 다 하고 왔는데 앱은 아직도 로그인 화면에 앉아 있는 거죠.

세 번째가 제일 헷갈립니다. CFBundleURLTypes 항목 안에 CFBundleURLName(사람이 읽는 식별자)만 넣고 CFBundleURLSchemes 배열을 빠뜨리는 경우요. 겉보기엔 URL 타입을 등록한 것 같지만, OS가 실제로 디스패치 기준으로 삼는 scheme 문자열은 CFBundleURLSchemes 안에 있습니다. 이게 없으면 OS는 콜백 URL을 이 앱으로 라우팅하기를 조용히 거부합니다.

이게 react-native-naver-login 0.1.1에서 실제로 고친 설정 오류입니다 — 정확히는 저장소에 딸린 예제 앱의 Info.plistCFBundleURLName만 들고 있었습니다. 변명을 좀 하자면, SDK 셋업 가이드들이 두 키를 나란히 보여주다 보니 이름만 넣고 배열을 건너뛰기가 정말 쉽습니다. (변명 끝. 이제 왜 이게 하필 최악의 버그였는지를 이야기하죠.)

이 버그가 지독했던 두 가지 이유

첫째, 에러가 아무것도 안 알려줍니다.

관측된 증상은 LSApplicationWorkspace Code=115였습니다. 여기엔 하한을 하나 달아야 합니다. 이 코드가 애플이 문서화한 안정적인 에러 계약이라는 근거는 제게 없습니다. 0.1.1 노트가 경험적으로 관측한 증상으로 기록해둔 것이니, "확정된 에러 분류"가 아니라 "유력한 원인 신호" 정도로만 받아들여 주세요.

어쨌든 이 코드는 런치 서비스 데몬이 뱉는 것이고, 앱 프로세스까지 도달하는 메시지는 "Info.plist에 키 X가 없습니다"라는 친절한 지적이 아닙니다. OAuth 핸드셰이크가 완료되는 시점의 일반적인 실패로 보일 뿐이에요. 저 문자열만 보고 "아 Info.plist를 봐야겠다"로 점프할 수 있는 사람은 없습니다. 있으면 제보 부탁드립니다. 진심으로요.

둘째, 초기화가 아니라 콜백 경계에서 터집니다.

initialize()는 성공합니다. login()도 잘 돌아서 동반 앱이나 인앱 브라우저를 엽니다. 사용자는 동의까지 다 마칩니다. 그러다 SDK가 커스텀 scheme으로 앱에 돌아오는 바로 그 순간에야 빠진 CFBundleURLSchemes가 실패를 만듭니다.

파이프라인 맨 끝에서 터지는 버그는 원인을 앞이 아니라 옆에서 찾게 만듭니다. 그래서 이 버그는 SDK 탓, 네트워크 탓, 심지어 "사용자가 동의를 안 눌렀나 본데요" 탓으로 오진하기 아주 쉽습니다. 설정 파일 한 줄이 범인일 거라고는 아무도 의심하지 않아요.

Android — 같은 함정, 다른 문법

여기까지 읽고 "iOS 진짜 까다롭네"라고 생각하셨다면, 안드로이드도 준비돼 있습니다.

Android 11(API 30)부터는 패키지 가시성 필터링이 들어왔습니다. getPackageInfo(), queryIntentActivities(), getLaunchIntentForPackage(), resolveActivity() 같은 API로 들여다볼 수 있는 설치 패키지가 제한됩니다. 기본적으로, 호출하는 앱의 AndroidManifest.xml에 선언되지 않은 패키지는 이 API들에게 보이지 않습니다.

해법은 <queries> 원소이고, 위치가 중요합니다. 이건 <manifest>직계 자식이지 <application> 안이 아닙니다. (들여쓰기 한 단계가 동작 여부를 가릅니다.)

제가 만든 다른 라이브러리 react-native-in-app-review의 실제 manifest는 이렇게 생겼습니다. (이건 비공개 저장소라 링크는 못 걸고, 파일 내용만 옮깁니다.)

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <queries>
    <package android:name="com.android.vending" />
  </queries>
</manifest>

이건 CHANGELOG 0.1.4(2026-07-10)에서 고친 내용입니다. 이 선언이 없으면 InAppReviewModule.isAvailable() 안의 getPackageInfo("com.android.vending")가 API 30+ 타겟 소비자에서 NameNotFoundException을 던집니다. 플레이스토어가 멀쩡히 깔려 있는데 false negative가 나오는 거죠. 아까 iOS에서 본 그 무늬 그대로입니다.

설계상 재밌는 포인트가 하나 있습니다. manifest merger가 이 선언을 모든 소비자 앱에 union으로 합쳐준다는 점이에요. 덕분에 이 선언은 소비자에게 "여러분 manifest에 이거 넣으세요"라고 떠넘기지 않고 라이브러리 자기 manifest에 둘 수 있습니다. 잘 만든 라이브러리는 자기가 조회할 패키지를 자기가 신고합니다.

그리고 이 규칙은 인앱 리뷰만의 이야기가 아닙니다. 다른 패키지의 존재를 프로그램적으로 확인하는 모든 라이브러리에 적용됩니다. 동반 앱을 확인하는 결제 SDK, 신원 제공자 앱을 확인하는 OAuth 라이브러리, 브라우저를 확인하는 딥링크 핸들러, 플레이스토어를 확인하는 인앱 리뷰 API까지 전부요.

그래서 결국, 같은 벽의 양쪽 면입니다

iOS의 LSApplicationQueriesSchemes와 Android의 <queries>. 문법도 다르고 키 이름도 다르고 도입 시기도 다르지만, 이 둘은 정확히 같은 제약의 두 얼굴입니다.

OS가 "다른 앱이 있는지 알아보는 행위"를 명시적 manifest 선언 뒤에 가둬놓은 겁니다. 프라이버시 측면에서는 완전히 합리적인 결정이에요. 앱이 기기에 뭐가 깔렸는지 자유롭게 스캔할 수 있으면 그건 그것대로 무섭잖아요.

문제는 선언이 없을 때 OS가 에러를 내지 않는다는 겁니다. 예외도 안 던지고, 경고 로그도 안 남기고, 그냥 "없는데요"라고 대답합니다. 조회는 false를 반환하고, 콜백은 조용히 라우팅되지 않습니다.

크래시는 스택 트레이스라도 남깁니다. 조용한 부정 결과는 아무것도 안 남깁니다. 그래서 여러분은 SDK 이슈 트래커를 뒤지고, 네트워크 로그를 켜고, 심지어 사용자가 뭘 잘못 눌렀나 의심하다가, 마지막의 마지막에 설정 파일을 열어보게 됩니다. 이게 이 버그 계열이 그토록 잡기 어려운 진짜 이유입니다.

마무리 — 문은 열려 있어야 손님이 옵니다

만약 지금 소셜 로그인이 "앱은 열리는데 안 돌아온다"면 CFBundleURLSchemes를, "앱이 깔려 있는데 자꾸 WebView로 간다"면 LSApplicationQueriesSchemes를, 안드로이드에서 "설치돼 있는 앱을 못 찾는다"면 <queries>를 제일 먼저 열어보세요. 십중팔구 거기 있습니다. 저는 거기 있었거든요. 헤헤.

그리고 혹시 제 설명 중에 틀린 부분이나, 제가 놓친 다른 실패 모드를 아신다면 꼭 알려주세요. 저는 이 두 개를 제 라이브러리에서 직접 밟고 나서야 배웠고, 아직 안 밟아본 세 번째가 어딘가에 있을 거라고 꽤 확신하고 있습니다.