grep

항공 프론트엔드 구축기 (8/10): 토큰이 세 군데서 온다

여기어때

2026년 9월 10일

원문에서 보기 ↗

글. 정용욱(Eric) / 서비스웹개발팀 항공 프론트엔드 구축기: 토큰이 세 군데서 온다

안녕하세요. 서비스웹개발팀의 에릭입니다.

이번 편은 인증입니다. 토큰이 들어오는 길은 환경마다 다른데, 나가는 길은 하나로 모았습니다. 그 사이를 어떻게 메웠는지 적었습니다.

화면이 알고 싶은 건 하나입니다

예약 목록을 보려면 로그인이 필요합니다. 마이페이지도 그렇고, 쿠폰을 적용하는 것도 그렇습니다. 예약자 정보를 미리 채우려면 그 회원이 누구인지도 알아야 합니다.

화면 코드가 알고 싶은 건 결국 하나입니다. 지금 로그인한 사람이 누구인가.

const { isLoggedIn, myInfo } = useAuth();

그런데 이 한 줄에 답을 주려면 토큰이 필요하고, 토큰은 환경마다 다른 곳에서 옵니다. 웹에서는 쿠키에 있고, 앱 웹뷰에서는 앱이 갖고 있습니다.

앱이 갖고 있는 걸 웹이 가져오려면 지난 편의 브릿지를 지나야 합니다. 그러면 왕복이 생기고, 왕복에는 시간이 걸리고, 그동안 화면은 로그인 여부를 모릅니다.

화면마다 물어보면

가장 단순한 방법은 필요한 화면에서 각자 처리하는 것입니다. 그러면 이런 코드가 화면마다 생깁니다.

// 화면마다 반복되는 모양
if (isApp) {
  const token = await 앱에서_토큰_가져오기();
  const user = await 그_토큰으로_회원조회(token);
} else {
  const user = await 쿠키로_회원조회();
}

세 가지가 걸립니다.

다른 방법도 생각할 수 있습니다.

앱에서 받은 토큰을 클라이언트가 들고 다니는 방법. 한 번 받아서 메모리나 스토리지에 두고, API를 부를 때마다 헤더에 붙이는 방식입니다. 앱과 웹을 같은 방식으로 맞출 수 있습니다.

문제는 방향이 반대라는 것입니다. 웹은 이미 쿠키로 잘 되고 있고, 그 쿠키는 JS가 읽을 수 없게 막아 둘 수 있습니다. 굳이 토큰을 꺼내서 JS가 만질 수 있는 곳에 두는 건 후퇴입니다. 앱을 맞추려고 웹을 낮추는 셈입니다.

무엇이 후퇴냐면, 액세스 토큰은 그 자체가 신분증이기 때문입니다. 값만 있으면 누구든 그 사람으로 행동할 수 있고, 만료되기 전까지 서버는 진짜와 구분하지 못합니다.

쿠키는 JS가 읽지 못하게 막아 둘 수 있는데, 막아 두더라도 요청에는 자동으로 실려 갑니다. 읽히지는 않으면서 쓰이기는 하는 자리입니다. 스토리지나 메모리는 그렇지 않습니다. 헤더에 붙이려면 JS가 먼저 값을 읽어야 하니, 토큰을 쓰는 코드는 반드시 값을 알게 됩니다.

값을 아는 코드가 생기면 나가는 길도 같이 생깁니다. 광고 스크립트든 외부 라이브러리든 페이지에서 도는 코드는 전부 같은 JS 세계에 있어서, 그중 하나만 잘못 들어와도(XSS) 읽어 갈 수 있습니다. 우리가 짠 코드도 예외는 아닙니다. 로그에 찍히거나 에러 리포팅에 실려 나가는 것도 값을 아는 코드에서만 일어나는 일입니다. 막아 둔 쿠키에는 그 경로가 아예 없습니다.

웹용과 앱용 인증을 따로 만드는 방법 도 있습니다. 각자 자기 방식대로 하고, 화면은 둘 중 하나를 쓰는 겁니다. 그런데 그러면 화면이 어느 쪽을 쓸지 골라야 합니다. 6편에서 컨테이너를 둘로 쪼개려다 접었던 것과 정확히 같은 자리입니다. 고르는 일이 남으면 감춘 게 아닙니다.

들어오는 문은 셋입니다

실제로 토큰이 오는 경로를 세어 보면 셋입니다.

하나, 쿠키. 웹입니다. 그런데 이쪽은 브라우저가 하는 일이 거의 없습니다.

서버가 화면을 그리기 전에 쿠키를 읽고, 그 토큰으로 회원 정보를 조회하고, 결과를 화면에 붙여서 내려보냅니다. 1편에서 라우트 파일의 실질 차이가 레이아웃 하나에 모였다고 했는데, 그 레이아웃이 하는 일 중 하나가 이겁니다.

그래서 웹에서는 클라이언트가 인증에 대해 할 일이 없습니다. 첫 화면부터 답이 맞습니다.

둘, 앱이 들고 있는 세션. 앱 웹뷰입니다. 브릿지로 앱의 저장소를 읽습니다.

셋, 앱에 직접 요청. 둘째가 비어 있을 때 앱에게 토큰을 달라고 다시 부릅니다.

두 번 가져오는 게 이상해 보일 텐데, 이유가 있습니다.

왜 두 번 물어보나

로그인 직후에 앱 저장소가 옛날 값을 줍니다. 방금 로그인했는데 토큰이 없다고 나옵니다. 여러 번 불러도 마찬가지였습니다.

기존에 운영하던 웹뷰에서는 이런 일이 없었는데 이번 프로젝트에서 나타났습니다. 원인을 앱 쪽에서 찾는 것보다, 웹에서 한 번 더 물어보는 게 확실하고 빨랐습니다.

// 1차: 앱 저장소를 읽는다
let token = (await readAppSession())?.Authorization || '';

// 2차: 비어 있으면 앱에게 직접 달라고 한다
if (!token) {
  token = (await requestToken()) || '';
}

썩 예쁜 코드는 아닙니다. 그래도 이런 게 브릿지를 사이에 둔 작업의 실제 모양입니다. 양쪽 타이밍이 어긋나는 지점이 남고, 그걸 어딘가는 떠안아야 합니다. 7편에서 iOS가 null을 요구하던 것과 같은 자리입니다.

나가는 문은 하나입니다

여기가 이번 편에서 하고 싶은 이야기입니다. 토큰이 어디서 왔든, 받고 나면 같은 곳에 심습니다.

// 앱에서 받은 토큰을 쿠키에 심는다
cookie.set(액세스_토큰, token, {
  httpOnly: true,
  path: '/',
});

return await getMyInfo();   // 웹이 쓰는 그 함수

마지막 줄이 핵심입니다. 앱 토큰을 쿠키에 심은 다음, 웹이 쓰던 회원 조회 함수를 그대로 부릅니다. 이 지점부터는 웹인지 앱인지가 의미가 없습니다.

여기서 일부러 안 한 게 하나 있습니다. 앱은 회원 정보도 갖고 있습니다. 토큰만 받지 말고 회원 정보까지 받아 오면 왕복이 한 번 줄고, 그만큼 빠릅니다.

그렇게 하지 않았습니다. 앱이 주는 회원 정보와 웹이 API로 받는 회원 정보가 같다는 보장이 없기 때문입니다. 필드 하나가 다르거나, 한쪽에만 최신 값이 반영되는 순간 화면은 다시 "지금이 앱인가"를 물어야 합니다. 여기까지 모아 온 게 마지막에 갈리는 셈입니다.

그래서 앱에서 받는 건 토큰까지입니다. 회원 정보는 웹이든 앱이든 같은 API에서 받습니다. 기기 식별자처럼 앱에만 있는 값은 따로 받아 두지만, 그건 회원 정보가 아니라 별개입니다.

왕복 한 번을 더 하는 대신 회원 정보의 모양이 하나로 유지됩니다. 1편에서 로직만 공유하는 안을 접었던 이유와 같습니다. 값의 모양이 갈리면 그걸 쓰는 코드가 따라서 갈립니다.

httpOnly도 눈여겨볼 만합니다. 이 옵션이 붙으면 JS에서 쿠키를 읽을 수 없습니다. 브릿지에서 받은 토큰을 클라이언트가 들고 다니지 않게 되는 것이고, 앞에서 걱정한 것 중 하나가 여기서 해결됩니다.

정리하면 이렇습니다. 어느 문으로 들어왔든 쿠키에서 만나고, 그 뒤는 한 길이다.

들어오는 문은 셋이고 나가는 문은 하나입니다. 화면에서 보면 문이 하나만 보입니다.

이 심는 일은 서버에서 합니다. 쿠키를 httpOnly로 두려면 브라우저가 아니라 서버가 내려 줘야 하니까요. 그래서 클라이언트는 토큰을 서버로 넘기기만 하고, 그 뒤는 보지 않습니다. 브릿지에서 받은 값이 화면 코드에 머무는 시간이 그만큼 짧아집니다.

쿠키로 모은 이유가 하나 더 있습니다. 서버가 화면을 그릴 때도 그대로 쓸 수 있기 때문입니다. 서버 렌더링(SSR) 이야기입니다.

화면을 여는 첫 요청은 서버가 받습니다. 서버가 HTML을 만들어 내려보내고, 브라우저는 그걸 받아서 띄웁니다. 그러니 그 시점에 서버가 볼 수 있는 건 요청에 실려 온 것뿐입니다. 스토리지든 메모리든 브라우저 안에 있는 값은 아직 만들어지지도 않았습니다.

쿠키는 요청에 자동으로 실려 갑니다. 그래서 서버는 첫 HTML을 만들 때 이미 토큰을 손에 쥐고 있습니다. 앞에서 본 대로 회원 정보까지 조회해 붙여 내려보낼 수 있고, 첫 화면부터 로그인한 상태로 나갑니다.

토큰을 클라이언트가 들고 있으면 이 길이 막힙니다. 서버는 누구의 요청인지 모른 채 HTML을 만들고, 첫 화면은 늘 비로그인으로 나갔다가 브라우저에서 다시 그려집니다. 웹뷰도 서버 렌더링을 쓰기로 한 이상 이건 앱에서도 손해입니다.

서버 쪽 코드가 단순해지는 것도 같은 이유입니다. 인증을 특별히 신경 쓸 필요 없이, 앱에서 온 요청이든 웹에서 온 요청이든 똑같이 처리합니다.

그런데 이 코드는 라이브러리에 없습니다

앞에서 본 쿠키 심는 코드는 서버 액션입니다. 그리고 서버 액션은 Next.js 기능입니다.

문제는 인증 훅이 공용 라이브러리에 있다는 점입니다. 라이브러리는 어느 프로젝트가 가져다 쓸지 모릅니다. 지금은 Next를 쓰지만 다음에도 그럴지는 알 수 없고, 이것 하나 때문에 라이브러리가 Next를 의존하게 되면 그 뒤로 계속 따라다닙니다. 라이브러리를 쓰려면 Next를 써야 하는 상태가 됩니다.

그래서 훅은 함수 셋을 받기만 합니다.

useAuthCore({
  initialUser,
  initialIsError,
  actions,   // 회원 조회 / 토큰 심고 조회 / 토큰 정리
});

훅이 아는 건 언제 무엇을 불러야 하는지까지입니다. 앱이면 마운트 때 세션을 맞추고, 로그인 직후에는 다시 맞추고, 실패한 토큰은 정리한다. 그 순서와 조건이 훅의 내용이고, 각 동작을 실제로 어떻게 할지는 넘겨준 쪽 사정입니다.

지금 프로젝트에서는 그 셋이 서버 액션으로 구현돼 있습니다. 다른 프로젝트에서는 다른 방식일 수 있고, 훅은 그걸 몰라도 됩니다.

이 원칙은 라이브러리 전체에 적용돼 있습니다. 의존성 목록에 Next가 없습니다. 날짜, 클래스 병합, UA 파싱 정도가 전부입니다.

경우가 셋이 아니라 넷이었습니다

만들다 보니 환경 구분이 하나 더 늘었습니다.

const isAppByNative = isApp && !isWebAccess;   // 진짜 앱. 브릿지로 간다
const isAppByWeb    = isApp && isWebAccess;    // 앱 빌드인데 브라우저. 쿠키로 간다

두 번째가 4편에서 이야기한 그 장치입니다. 배포된 앱 빌드를 웹브라우저로 열어서 확인하는 경우요. 그때는 브릿지가 없으니 앱이라고 판정돼 있어도 쿠키로 가야 합니다.

디버깅 편의로 만든 조건이 인증 분기에까지 영향을 준 셈입니다. 이런 게 몇 개 쌓이면 관리가 어려워지는데, 지금은 이 한 곳에 모여 있어서 감당됩니다.

시작 상태도 경우마다 다릅니다. 웹은 서버가 이미 답을 줬으니 로딩이 아니고, 앱은 브릿지 왕복이 남았으니 로딩입니다. 그리고 로딩 동안에는 화면을 아예 그리지 않습니다.

{isLoading ? <div /> : children}

빈 화면을 잠깐 보여주는 게 나은 이유는, 비로그인 화면을 보여줬다가 로그인 화면으로 바뀌는 것보다 덜 어색하기 때문입니다. 어차피 앱에서만 생기는 짧은 순간입니다.

한 번 잘못 돌면 전부 돌아갑니다

이 훅에 유난히 자주 나오는 메모가 있습니다.

// deps에 actions안하고 actions.getMyInfo 한 이유 = 불필요한 리렌더링(무한루프) 방지

같은 말이 이 파일에 세 번 반복됩니다. 의존성 목록에 객체를 통째로 넣지 말고 그 안에서 실제로 쓰는 함수만 넣으라는 뜻입니다.

인증은 화면 전체를 감싸는 자리라 이런 게 특히 중요합니다. 여기서 한 번 잘못 돌면 앱 전체가 돌아갑니다. 그것도 대개 조용히 돌아갑니다. 화면은 멀쩡해 보이는데 회원 조회 요청만 계속 나갑니다.

그래서 만들 때보다 고칠 때 더 조심해야 하는 파일이 됐습니다. 같은 메모를 세 번 적어 둔 것도 그래서일 겁니다.

로그인 상태를 밖에서도 읽어야 합니다

지난 편에 로그인 확인 함수가 나왔습니다. 앱에서 로그인 창을 띄우고, 성공하면 세션을 다시 맞추고, 화면 상태가 실제로 바뀌는 것까지 확인한 뒤에 끝난다고 했습니다.

마지막 단계가 왜 필요한지가 이번 편에서 설명됩니다.

앱이 "로그인 성공"이라고 알려 오는 순간, 웹은 아직 아무것도 모릅니다. 토큰을 받아 오고, 쿠키에 심고, 회원 정보를 조회하는 왕복이 그다음에 남아 있습니다. 앱의 신호만 믿고 끝내면 로그인은 됐는데 화면은 비로그인인 채로 다음 단계가 진행됩니다.

그래서 확인할 것이 하나 더 있어야 했습니다. 앱의 신호가 아니라 우리 쪽 상태가 실제로 바뀌었는가요.

문제는 확인하는 쪽의 위치입니다. 그 코드는 브릿지가 부르는 함수라 React 트리 바깥에 있습니다. 컨텍스트를 구독할 수 없습니다.

그래서 로그인 여부를 DOM에 새겨 둡니다.

<div id="auth-sync-observer" className="yf-hidden" data-logged-in={isLoggedIn} />

보이지 않는 요소 하나에 속성을 붙여 두고, 바깥에서는 그 속성이 바뀌는지 지켜봅니다.

5편에서 나온 방식과 같습니다. 그때는 뒤로가기 트리거에 순번을 새겼고, 이번에는 로그인 여부를 새깁니다. 부르는 쪽이 React 바깥이면 DOM이 가장 확실한 게시판입니다.

반대 방향도 있습니다. 인증 훅은 자기 동기화 함수를 전역에 심어 둡니다.

window.syncAppSession = syncAppSession;

앱에서 로그인이 끝나면 브릿지 쪽 코드가 이걸 부릅니다. 그러면 훅이 토큰을 다시 받아 오고, 회원 정보를 갱신하고, 그 결과가 위의 속성에 반영되고, 지켜보던 쪽이 그제야 끝냅니다.

나가는 길도 들어오는 길도 React 바깥을 지납니다. 브릿지가 끼는 일은 대체로 이렇게 생겼습니다. 앱 신호부터 DOM 속성이 바뀌기까지의 여섯 단계 3번부터는 웹과 같은 길이다. 앱에서만 필요한 건 그 앞의 두 단계뿐이다.

남은 것

쿠키는 있는데 그 토큰으로 회원 정보를 못 가져오는 경우가 있습니다. 만료됐거나 서버에서 무효가 된 토큰입니다. 코드에서는 좀비 토큰이라고 부릅니다.

이건 지워야 하는데, 서버가 화면을 그리는 도중에는 쿠키를 지울 수 없습니다. 응답이 이미 나가기 시작했기 때문입니다. 그래서 서버는 "실패했다"만 알려 주고, 화면이 브라우저에 도착한 뒤에 지웁니다.

// 웹 SSR 중에는 쿠키 제거가 불가능하므로, SSR 이후 확실히 삭제처리
if (initialIsError && !isAppByNative) {
  clearToken();
}

즉 못 쓰는 토큰을 가진 채로 화면이 한 번 그려집니다. 그 화면은 비로그인 상태로 보이니 사용자가 이상하게 느낄 일은 없지만, 지우는 시점을 앞당길 방법은 지금 구조에서 없습니다.

조건이 하나 붙어 있는 것도 눈에 띕니다. 앱일 때는 지우지 않습니다. 앱은 브릿지로 토큰을 다시 받아올 수 있어서, 쿠키가 잘못됐다고 지워 버리면 오히려 다시 받아오는 길을 막습니다. 같은 "잘못된 토큰"인데 환경에 따라 대응이 다릅니다.

결과

1편에서 만들어야 할 것 다섯 가지를 뽑았습니다. 공용 컴포넌트, 환경 추상화, 뒤로가기, 레이아웃, 인증. 이 편이 마지막입니다.

다섯 개를 만들고 나니 화면 코드에 남은 게 이만큼입니다.

const { isLoggedIn, myInfo } = useAuth();

토큰이 쿠키에서 왔는지 앱에서 왔는지, 두 번 물어봤는지, 어디에 심었는지 묻지 않습니다. 지금이 웹인지 앱인지도요.

그러고 보면 다섯 편이 같은 말을 다르게 한 셈입니다. 차이를 없앤 게 아니라 차이가 사는 자리를 정했습니다. 뒤로가기는 트리거와 매니저가, 레이아웃은 컨테이너가, 브릿지는 메시지 함수 하나가, 인증은 이 훅이 떠안았습니다. 그리고 그 자리들은 전부 화면 코드가 아닙니다.

자리를 정하는 기준도 매번 같았습니다. 부르는 쪽이 어디에 있는지 를 먼저 봤습니다. 뒤로가기는 앱이 전역 함수를 부르니 DOM에 순번을 새겼고, 로그인 확인도 브릿지가 부르니 DOM에 상태를 새겼습니다. 화면 크기는 컴포넌트가 물어보니 prop으로 내려보냈고요. 감출 수 있느냐 없느냐보다 누가 언제 묻느냐가 답을 정했습니다.

물론 이게 공짜는 아닙니다. 화면 코드가 얇아진 만큼 라이브러리가 두꺼워졌고, 그 두꺼운 쪽을 아는 사람은 여전히 필요합니다. 1편에서 병목 이야기를 했는데, 그 병목이 사라진 게 아니라 한 군데로 모인 것에 가깝습니다. 다만 모여 있으면 찾아볼 수는 있습니다. 흩어져 있으면 그것도 안 됩니다.

다음 편에서는 상태관리 이야기를 하겠습니다. 전역 저장소를 하나 넣으려다 결국 안 넣기로 한 과정입니다.


항공 프론트엔드 구축기 (8/10): 토큰이 세 군데서 온다 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.