들어가며
현재 봄봄 서비스에서는 구글 로그인과 애플 로그인을 통한 소셜 로그인 기능을 제공한다. 그리고 이는 전부 웹에서 이루어진다. 사용자의 소셜 로그인 과정은 다음과 같다.
기존 로그인 흐름
- 사용자가 웹에서 구글 로그인 버튼을 클릭하면, 서버 엔드포인트(
${서버 url}/auth/login/google)로 이동한다. - 서버는 곧바로 구글 로그인 페이지(
https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?..)로 redirect를 시킨다. - 사용자가 구글 로그인을 완료하면 서버로 다시 redirect되는데, 이 때 구글에서는 서버에 사용자의 토큰을 포함한 정보를 전달한다.
- 만약 사용자가 회원가입을 하지 않은 사용자라면,
isRegistered를 false로 클라이언트에 반환하고, 이후 사용자는 signup 페이지로 이동하게 된다.
- 만약 사용자가 회원가입을 하지 않은 사용자라면,
- 마지막으로 서버는 자체 인증 세션을 만들어 이를 쿠키에 담아 사용자에게 반환한다.
서버는 쿠키의 httpOnly 옵션을 활성화해 브라우저에서 JavaScript로 직접 접근을 하지 못하게 하여 보안성을 높인다. 그리고 secure 옵션으로 HTTPS 연결에서만 쿠키가 전송되도록 설정하여 중간자 공격(man-in-the-middle attack)을 방지한다. 이게 현재 웹에서의 기존 소셜 로그인 방법이다.
웹뷰에서 마주한 문제들
그런데 봄봄 서비스를 앱에서 이용하며 문제가 생겼다. 봄봄은 앱 내에 95% 기능이 웹뷰로 구성이 되어있다. 로그인 또한 웹에서 이뤄지고 있는데 웹뷰 환경에서는 예상치 못한 이슈들이 발생했다.
웹에서 소셜 로그인 버튼을 누르면 2-3초를 기다려야 redirect가 되는 것부터 시작해서, 소셜 로그인 버튼을 누르고 나서도 직접 아이디와 비밀번호를 입력해야 했다. 이는 앱 내 웹뷰이기에 새로운 웹 컨텍스트를 만들어서 생기는 현상으로 추정된다. 무엇보다 심각한 건, 로그인을 끝내 한다고 하더라도 간헐적으로 보안과 관련한 에러 화면이 뜬다는 것이었다.
앱을 출시한 직후에 로그인이 안된다는 제보를 다수 받았고, 당장 이를 해결해야겠다고 생각했다. 원인은 웹뷰에서 로그인을 하는 것이라 추측했으며, 앱 내에서 네이티브로 로그인하는 방법으로 마이그레이션을 해야겠다고 결정했다.
개발
새로운 로그인 흐름
새로운 방식은 웹뷰와 네이티브 앱 간의 협업으로 이루어진다.
- 사용자가 웹뷰에서 로그인 버튼을 누르면, 웹뷰는 앱으로 '로그인 화면 열어주세요'라는 메시지를 전달한다.
- 그럼 앱에서 소셜 로그인을 진행하고, 소셜 로그인을 통해 전달받은 토큰을 '로그인 마쳤어요'와 함께 다시 웹뷰에 메시지로 전달한다.
- 웹뷰는 토큰을 포함한 사용자의 정보를 서버에 전달한다.
이후의 과정은 기존 웹 내 로그인 흐름과 동일하다.
간단하게 요약하면 기존 웹뷰에서 하던 소셜 로그인을 앱에서 하도록 마이그레이션을 한다고 보면 된다. 이 때 서버 측에서 웹 내 redirect를 통해 모든 인증 과정을 서버가 통제하던 방식에서, 앱으로 마이그레이션하게 되며 서버가 완전히 통제할 수 없게 되었다. 따라서 소셜 로그인을 담당하는 주체도 클라이언트(앱)로 자연스레 이동하게 되었다.
실제 구현
실제 코드와 함께 살펴보면 이런 식이다.
- 사용자가 앱 내 웹뷰에서 로그인 버튼을 누르면 웹뷰 환경인지 확인하고, 웹뷰라면 앱에 메시지를 전송한다.
// web/src/components/LoginButton.tsx
const handleLoginClick = () => {
if (isWebView())
sendMessageToRN({
type: 'SHOW_LOGIN_SCREEN',
});
else navigate({ to: '/login' });
}
- 앱에서는 이 메시지를 전달받으면 로그인 Modal을 연다.
// app/src/components/MainScreen.tsx
export const MainScreen = () => {
const { showWebViewLogin, showLogin, hideLogin } = useAuth();
const handleWebViewMessage = (event: WebViewMessageEvent) => {
const message = JSON.parse(event.nativeEvent.data);
switch (message.type) {
case 'SHOW_LOGIN_SCREEN':
showLogin();
break;
// ..
}
// ..
return (
// ..
<LoginScreenOverlay visible={showWebViewLogin} onClose={hideLogin} />
)
}
// app/src/contexts/AuthContext.txt
export const AuthProvider = ({ children }: PropsWithChildren) => {
// ..
const showLogin = () => {
setShowWebViewLogin(true);
};
// ..
}
- 사용자가 소셜 로그인을 완료하면, 앱은 구글에서 전달받은 토큰을 웹뷰에 메시지로 전달한다.
// app/src/components/LoginScreen.tsx
export const loginWithGoogle = async (
const userInfo = await GoogleSignin.signIn();
sendMessageToWeb({
type: 'GOOGLE_LOGIN_TOKEN',
payload: {
identityToken: userInfo.data.idToken,
authorizationCode: userInfo.data.serverAuthCode,
},
});
};
- 웹뷰는 앱에서 전달받은 토큰을 서버에 전달하고, 로그인에 성공하면 다시 앱에 메시지를 보내 로그인 모달을 닫는다.
// web/src/libs/webview/useWebViewAuth.ts
const cleanup = addWebViewMessageListener(
async (message) => {
switch (message.type) {
case 'GOOGLE_LOGIN_TOKEN':
const { identityToken, authorizationCode, email, name } = message.payload;
const response = await postGoogleLogin({
identityToken, authorizationCode, email, name,
});
sendMessageToRN({
type: 'LOGIN_SUCCESS',
payload: {
isAuthenticated: true,
provider: 'google',
},
});
// ..
}
}
)
// app/src/components/MainScreen.tsx
export const MainScreen = () => {
const { showWebViewLogin, showLogin, hideLogin } = useAuth();
const handleWebViewMessage = (event: WebViewMessageEvent) => {
const message = JSON.parse(event.nativeEvent.data);
switch (message.type) {
case 'LOGIN_SUCCESS':
console.log('웹뷰에서 로그인 성공 알림 수신:', message.payload);
hideLogin();
break;
}}
}
}
// app/src/contexts/AuthContext.txt
export const AuthProvider = ({ children }: PropsWithChildren) => {
// ..
const hideLogin = () => {
setShowWebViewLogin(false);
};
// ..
}
- 서버에서 사용자가 회원가입한 사용자인지 전달받고, 회원가입하지 않은 사용자라면 signup페이지로 이동시킨다.
// web/src/libs/webview/useWebViewAuth.ts
const cleanup = addWebViewMessageListener(
async (message) => {
switch (message.type) {
case 'GOOGLE_LOGIN_TOKEN':
// ..
const { isRegistered, email, name } = response;
if (!isRegistered) {
navigate({
to: '/signup',
search: { email, name },
});
return;
}
window.location.reload();
}
// ..
}
}
}
여기서 email과 name을 서버에 전달하고 다시 전달받는 이유는, 사용자가 중복된 email과 name을 사용하는 경우 이를 체크해서 해시값을 붙여서 서버에서 함께 전달받기 위함이다.
만약 회원가입한 사용자라면(isRegistered가 true) 여기서 로그인 프로세스를 종료한다. 서버가 토큰 정보를 쿠키에 담아서 보내주기 때문이다. 만약 회원가입을 하지 않은 사용자라면 회원가입 페이지(/signup)로 이동해 회원가입을 수행한다. 회원가입하지 않은 사용자는 회원가입 페이지에서 추가적인 정보(생년월일과 성별)를 입력받고 서버에 제출하며, 이 때 토큰 정보를 전달받아서 로그인을 수행한다.
이러한 과정으로 로그인이 이루어진다. 이제 앱 로그인 기능을 구현하며 마주한 에러들과 해결 경험을 공유하려고 한다.
트러블 슈팅
앱을 종료하면 로그인이 풀린다.
앱을 종료할 때마다 로그인이 풀린다는 다수의 제보를 받았다. 이를 해결하기 위해서는 기존 세션 기반의 인증 방식에서 JWT 기반의 인증 방식으로 바꿔야 하나 하는 이야기까지 팀원들과 주고받았다. 그 이후에 곰곰히 생각해보았다. JWT 기반으로 바꾼다고 해도 웹뷰의 쿠키에 저장하는 건 동일한데, 이 쿠키가 사라지는 게 맞을까? 앱은 동일한 브라우저(iOS는 Safari, Android는 Chrome)를 띄우는데 쿠키가 매 번 사라진다고?
원인이 무엇인가 파악하기 위해 다양한 시도를 해보았다. 가장 첫 번째는 'PC 웹에서도 동일한가?'였다. 크롬 브라우저에서 로그인을 하고, 완전히 종료(Command + Q)를 해서 세션을 종료했다. 그리고 다시 크롬 브라우저를 열고 사이트에 접속했다. 로그인이 풀려있는 것이다. 여기서 생각했다. '앱 문제가 아니구나. 웹에서 먼저 해결을 해봐야겠다.' 이걸 왜 이제서야 알아챘냐면, PC를 이용할 때 브라우저를 완전히 종료하는 일이 드물기 때문이다. 그래서 지금까지는 브라우저를 완전히 종료했을 때 로그인이 풀린다는 사실을 몰랐던 것이다.
그렇다면 왜 브라우저를 완전히 종료하면 쿠키가 사라질까? 이전에 쿠키에 대해서 공부했던 내용을 되짚어보았다. 쿠키에는 두 가지 종류가 있다. 세션 쿠키와 영속 쿠키이다. 쿠키의 Expires 혹은 Max-Age 속성을 설정하면 해당 시점이 되면 사라지는 영속 쿠키가 되고, 그렇지 않으면 세션이 종료되면 사라지는 세션 쿠키가 된다. 혹시나 해서 PC 웹에서 서버에서 설정해준 쿠키를 봤는데 'Session'이라 적혀있던 것이었다.
이 때 팀원들이랑 하나씩 이야기를 해보면서 실시간으로 디버깅을 했었는데 도파민이 뿜어져나왔다. 이론으로 공부했던 게 이렇게 실무에서 쓰인다는 게 너무 신기했고 직접 활용할 수 있다는 게 정말 재밌었다. 이제 원인은 파악했으니 서버에서 쿠키에 Expires 또는 Max-Age 값을 설정해서 쿠키가 웹을 종료해도 사라지지 않게 되었다. 따라서 로그인도 풀리지 않도록 해서 해결할 수 있었다. 결국 JWT로 인증 방식을 바꾸지 않아도 될 수 있게 되었다.
팀원들과 함께 디버깅을 하며 하나가 되는 기분이었고, 이론이 실제로 실무에서 충분히 활용될 수 있고 앞으로도 열심히 공부해야겠다는 생각을 했다.
Android에서 구글 로그인이 안된다.
iOS에서 구글 로그인은 어렵지 않았다. 따로 설정이 필요 없었다. 그런데 문제는 Android에서의 구글 로그인이었다. 아무리 해도 안되는 거다. 우리가 사용 중이던 라이브러리는 @react-native-google-signin/google-signin이었다.
3일 동안 이것저것 다양한 시도를 해봤지만 끝내 해결을 하지 못했다. 그러고 시간이 조금 흘러 다시 시도했을 때, 1시간 만에 해결을 해냈다. 원인은 SHA-1 설정이었다. 그리고 그 해결 방법은 다름 아닌 공식문서에 명확히 나와있었다. '빠르게 해결해야 해'라는 급한 생각에 이를 놓쳤던 것이었다.
SHA-1은 안드로이드 앱 서명을 구분하는 지문 같은 값이다. 이는 구글 맵, Firebase, 구글 로그인을 사용할 때 필요하며 Google Cloud Console에 SHA-1을 등록해야 API 키가 해당 앱에서만 작동한다. SHA-1을 지금까지 Google Cloud Console에 등록하지 않은 것도 아니었다. 등록을 하고 있었다. 그런데 EAS 빌드를 하지 않는데 EAS credentials에서 얻은 SHA-1을 등록하고 있던 것이 문제였다. 나는 로컬 빌드를 통해 개발을 하고 있었는데도 말이다.
당시 SHA-1의 개념을 몰랐다. SHA-1라는 키워드를 듣고, 뭔지 몰랐으니 AI에게 물어봐서라도 SHA-1의 개념에 대해 먼저 알았더라면 더 빠르게 해결할 수 있지 않았을까 생각한다.
결국 공식문서대로 cd android && ./gradlew signingReport를 통해 로컬의 SHA-1 값을 얻고, 이를 Google Cloud Console에 등록해서 해결할 수 있었다.
약간은 허무하면서도 교훈을 얻은 경험이었다. “Read the docs” 그런 의미에서 노트북에 스티커도 한 장 붙였다.
서버 401 에러와 점진적 마이그레이션
사용자가 구글 로그인을 하고, 구글에서 전달받은 토큰을 서버 측에 전달했는데 401 에러가 발생했다. 알고 보니 앱에서 설정한 구글의 clientId와 서버에서 사용 중인 clientId가 다른 것이었다. 이를 하나로 통일해 해결할 수 있었다.
여기서 문제점은, 이 변경 사항을 서버 측에서 함부로 배포할 수 없었다는 것이다. 이미 iOS에서는 기존의 clientId를 사용 중이기 때문이다. 서버 측에서 새로운 clientId로 바꿔버리면 기존 앱을 사용 중이던 사용자들은 로그인이 갑자기 안되어버릴 것이다.
기존 앱 사용자들은 기존 clientId를 사용해야 하고, 새로운 앱 사용자들은 새로운 clientId를 사용해야 한다. 이 둘에 모두 대응하기 위해 어떻게 할 수 있을까 고민을 했다. 그 중 한 방법은 클라이언트에서 헤더에 앱의 버전 정보를 담아서 서버에 전송하는 것이었다. 그럼 서버는 앱의 버전 정보를 확인하고 이에 맞게 clientId를 사용하는 것이다. 그런데 이 과정은 지금 하기에는 과할 것이라 생각했다. 최종적으로는 새로운 clientId를 시도해보고, 이를 실패하면 기존 clientId로 시도해보는 방식을 선택했다. 이렇게 할 경우 점진적인 clientId 수정이 가능할 것이라 판단했다.
// 서버에서 토큰 검증 시 두 Client ID로 각각 시도
async function verifyGoogleToken(token) {
try {
// 먼저 새로운 clientId로 검증 시도
return await verifyWithClientId(token, NEW_CLIENT_ID);
} catch {
// 실패하면 이전 clientId로 검증 시도
return await verifyWithClientId(token, OLD_CLIENT_ID);
}
}
이렇게 두면 서비스 안 끕고 넘어갈 수 있다. 한동안 둘 다 받다가 구버전 사용자가 줄면 옛 방식을 걷어내면 된다.
Apple 로그인 구현 시 "Apple login error"
애플 로그인을 구현하다가 "Apple login error"라는 에러 메시지를 만났다. 이 에러는 XCode에서 Sign in with Apple capability를 추가하지 않아서 발생한다.
해결 방법: XCode에서 Runner > Targets > Signing & Capabilities > +Capability > Sign in with Apple 추가
Google Sign-in 라이브러리 인식 실패
iOS 실행 시 TurboModuleRegistry.getEnforcing(...): 'RNGoogleSignin' could not be found. Verify that a module by this name is registered in the native binary. 에러가 발생한다면, 이는 네이티브 빌드를 하지 않아서 발생하는 문제다.
@react-native-google-signin/google-signin은 Expo GO를 지원하지 않는 라이브러리다. 네이티브 코드 링크가 필요하기 때문에 개발 빌드(development build)나 프로덕션 빌드를 만들어야 한다. 그래서 네이티브 빌드(npx expo run:ios 또는 npx expo run:android)를 먼저 해야 한다.
이런 라이브러리들은 보통 공식 문서에 "Not compatible with Expo GO" 같은 안내가 있으니, 라이브러리를 도입하기 전에 Expo GO 지원 여부를 확인하는 것이 좋다. 만약 Expo GO에서 테스트가 필요하다면, 네이티브 모듈이 없는 대체 라이브러리를 찾거나 개발 빌드를 만들어서 테스트해야 한다.
결과물
마이그레이션과 관련된 코드는 PR에서 확인해볼 수 있습니다.
iOS에서 구글 로그인과 애플 로그인 모두 네이티브로 원활하게 작동하는 걸 확인할 수 있다. 기존 웹뷰에서의 긴 대기 시간이나 보안 에러는 더 이상 발생하지 않으며, 사용자는 훨씬 더 자연스러운 로그인 경험을 할 수 있게 되었다.
맺으며
웹뷰에서 네이티브로 소셜 로그인을 마이그레이션하며 웹과 앱의 경계에서 발생하는 많은 이슈들을 경험했다. 가장 인상 깊었던 건 세션 쿠키와 영속 쿠키처럼 이론으로만 알던 걸 실전에서 확인하고, SHA-1처럼 아예 몰랐던 개념을 부딪히며 배웠다는 것이다.
무엇보다 '급하게 해결하려다 오히려 시간을 더 쓴다'는 교훈을 얻었다. 공식 문서를 꼼꼼히 읽지 않고 이것저것 시도하다가 3일을 날린 경험은, 앞으로도 어떤 문제를 만나더라도 일단 공식 문서부터 꼼꼼히 읽어야겠다는 생각을 머릿 속 깊이 새길 수 있게 해주었다. "Read the docs"는 그냥 격언이 아니라 실제로 개발 시간을 아껴준다.
이번 경험을 통해 개발자로서 마인드가 좀 바뀐 것 같다는걸 느꼈다. 이제는 문제를 맞닥뜨려도 막막함보다 '이거 해결하면 좋은 성장이 되겠는데'라는 생각이 앞선다. 그래서 점점 과감한 시도를 하는 데에 두려움이 없어지고 있는 듯하다. 최근에 진행한 모노레포 마이그레이션 작업도 그랬다. 패키지 의존성 충돌이나 빌드 설정 문제, 경로 alias 설정 같은 자잘한 이슈부터 큰 이슈까지 많은 문제를 겪을 걸 알고 있었음에도 망설임 없이 뛰어들었다. 그리고 실제로 많은 이슈들을 만났고, 하나씩 해결하며 또 한 번 성장을 할 수 있었다. 이 이야기는 다음 번에 또 한 번 풀어보도록 하겠다.
React Native 앱에서 웹뷰 기반 소셜 로그인을 네이티브로 전환한다면, SHA-1 설정과 쿠키 만료 시간부터 확인해보자. 그리고 공식 문서를 먼저 읽자. 급할 때일수록 말이다.