제휴 채널만 열리지 않았다: 인증보다 화면이 먼저 움직인 문제
기본 진입은 되는데 제휴 채널만 멈췄다. 기존 Angular·Ionic 흐름에서 인증 데이터보다 화면 이동이 먼저 실행되는 경쟁 상태와 저장소 초기화 실패를 찾아 순서를 고친 과정을 적었다.
이 글의 목차기록에 남은 장애와 시간10
기본 앱에서는 잘 들어가던 화면이 제휴 채널에서만 열리지 않았다.
서버가 완전히 내려간 것도 아니었고 모든 사용자의 로그인이 실패한 것도 아니었다.
특정 진입 경로를 통과할 때만 인증 데이터가 비어 있는 상태로 다음 화면이 먼저 움직였다.
이런 문제는 한 줄의 오류보다 순서에서 시작된다.
라우트 파라미터를 읽고, 기존 세션을 정리하고, 새 인증 정보를 만들고, 저장소에서 토큰을 복구한 뒤 화면을 열어야 했다.
그런데 기존 흐름은 앞 단계가 끝나기 전에 다음 단계가 실행될 수 있었다.
나는 제휴 채널의 진입 순서를 따라가며 인증 데이터가 만들어지는 시점과 goStart()가 호출되는 시점을 비교했다.
그리고 라우트 파라미터를 받은 콜백 안에서 인증 구성을 마친 뒤에만 다음 단계로 이동하도록 순서를 바꿨다.
WebView 저장소 초기화가 실패했을 때 토큰과 사용자 정보를 복구할 경로도 함께 보완했다.
WebView는 모바일 앱 안에서 웹 기술로 만든 화면을 실행하는 브라우저 영역이다.
인증은 사용자가 누구인지 확인하는 과정이고, 인가는 확인된 사용자가 특정 기능을 사용할 권한이 있는지 판단하는 과정이다.
화면에서 인증 상태를 복원하더라도 실제 API의 인가 판단은 서버가 다시 해야 한다.
기록에 남은 장애와 시간
이 장애는 Minor 등급으로 기록돼 있다.
Minor는 전체 서비스가 멈춘 상황은 아니지만 특정 사용자나 기능에 영향을 주어 수정이 필요한 수준을 뜻한다.
업무 기록에는 대응 소요가 0.25시간, 곧 15분으로 적혀 있다.
한편 별도의 시간 범위는 06:25~06:50으로 남아 있어 25분이다.
두 값의 측정 기준이 같다는 증거가 없기 때문에 “정확히 15분 만에 모든 대응을 끝냈다”고 합치지 않았다.
확인되는 사실은 그 시간대에 제휴 채널 접근 문제를 대응했고, Git 이력에서 관련 커밋과 3개 파일 변경을 확인했다는 점이다.
원인과 조치로 남아 있는 항목은 다음과 같다.
- 인증 데이터가 준비되기 전에 다음 단계가 실행될 수 있었다.
- WebView 저장소 초기화 실패 시 토큰 복구 경로가 부족했다.
- 제휴 채널이 배포 전 회귀 테스트 범위에서 빠져 있었다.
- 라우트 파라미터 처리 안에서 인증 데이터를 구성한 뒤
goStart()를 실행하도록 바꿨다. - 제휴 진입 시 이전 토큰과 사용자 정보를 정리했다.
- Ionic Storage 실패 시 LocalStorage에서 정보를 읽는 폴백을 추가했다.
폴백은 주 경로가 실패했을 때 제한적으로 사용하는 대체 경로다.
이 조치는 당시 접근 장애를 복구하기 위한 구현 사실이다.
LocalStorage에 인증 토큰을 보관하는 것이 현재도 권장되는 보안 설계라는 뜻은 아니다.
기본 앱이 정상이어도 제휴 진입은 다른 제품 경로다
제휴 채널은 같은 화면을 쓰더라도 시작 조건이 다르다.
기본 앱 사용자는 이미 앱을 열고 로그인한 뒤 메뉴로 이동할 수 있지만, 제휴 링크 사용자는 외부 URL에서 곧바로 특정 화면에 들어온다.
이때 URL 파라미터 해석, 이전 세션 정리, 제휴 인증 교환과 WebView 저장소 준비가 짧은 시간에 겹친다.
예를 들어 쇼핑 앱의 일반 사용자는 홈에서 상품 상세로 이동한다.
카드사 이벤트 링크로 들어온 사용자는 캠페인 코드가 있는 주소를 열고, 앱 설치 여부와 로그인 상태를 확인한 뒤 같은 상품 상세에 도착한다.
마지막 화면이 같아도 앞의 경로는 전혀 다르다.
그래서 “기본 앱에서 로그인됐다”는 결과는 서버 인증 전체가 정상이라는 근거는 될 수 있어도 제휴 진입 순서가 정상이라는 근거는 아니다.
이 문제에서는 공통 인증 API를 고치기 전에 제휴 URL에서 첫 보호 API가 호출될 때까지의 시간축을 따로 그렸다.
| 기본 진입 | 제휴 링크 진입 |
|---|---|
| 앱 초기화 뒤 사용자가 화면을 선택 | 외부 링크가 시작 화면을 지정 |
| 기존 세션을 그대로 사용할 가능성이 큼 | 이전 세션을 정리하고 새 문맥을 만들 수 있음 |
| 저장소가 이미 준비됐을 수 있음 | WebView와 저장소가 동시에 시작 |
| 평소 회귀 테스트에 포함되기 쉬움 | 별도 링크·파라미터 조합이 필요 |
결과가 비어 있는데 화면부터 이동했다
경쟁 상태는 둘 이상의 비동기 작업이 완료되는 순서에 따라 결과가 달라지는 문제다.
비동기는 저장소나 네트워크 작업이 끝날 때까지 화면 전체를 멈추지 않고, 결과가 준비되면 나중에 이어서 처리하는 방식이다.
제휴 채널 진입에는 URL에서 받은 파라미터와 로컬 저장소의 인증 정보가 함께 필요했다.
라우트 파라미터 구독은 값이 도착했을 때 콜백을 실행하지만, 구독을 등록했다는 사실만으로 콜백 안의 일이 끝난 것은 아니다.
아래 코드는 당시 전체 소스를 복사한 것이 아니라 순서 문제를 보여 주기 위한 단순화 예시다.
this.route.queryParamMap.subscribe(async (params) => {
await tokenService.clearToken();
await tokenService.clearUser();
this.authData = buildPartnerAuthData(params);
});
await this.goStart();subscribe()는 Observable이 값을 내보낼 때 등록한 함수를 실행한다.
Observable은 시간이 지나며 하나 이상의 값을 전달할 수 있는 Angular·RxJS의 비동기 데이터 형태다.
subscribe()는 콜백이 돌려준 Promise를 바깥의 코드가 기다리게 만들지 않는다.
따라서 콜백 안의 토큰 정리가 끝나기 전에 바깥의 goStart()가 실행될 수 있다.
실행 시점에 따라 authData가 아직 비어 있을 수 있다.
기록에서 확인되는 수정 방향은 다음과 같다.
this.route.queryParamMap.subscribe(async (params) => {
await tokenService.clearToken();
await tokenService.clearUser();
this.authData = buildPartnerAuthData(params);
await this.goStart();
});await는 Promise로 표현된 비동기 작업이 끝날 때까지 현재 함수의 다음 줄을 기다리게 한다.
이 예시에서는 이전 인증 정보를 지우고 새 진입 정보를 만든 뒤에만 goStart()가 실행된다.
실제 클래스명과 제휴사 파라미터는 공개하지 않았다.
이 수정은 콜백 한 번 안의 순서를 바로잡은 당시 조치다.
파라미터가 여러 번 바뀌면 비동기 작업끼리 다시 겹칠 수 있으므로, 현재 구현에서는 아래처럼 한 번만 받을지 이전 작업을 취소할지까지 정해야 한다.
저장소 초기화 실패가 같은 증상을 만들었다
화면 이동 순서만 고쳐도 저장소에서 토큰을 읽지 못하면 인증 정보는 완성되지 않는다.
Ionic 앱의 저장소는 WebView가 시작될 때 초기화되는 비동기 계층이 될 수 있다.
기록에는 Ionic Storage 초기화 실패 시 LocalStorage에서 토큰과 사용자 정보를 읽는 대체 경로를 추가했다고 남아 있다.
당시 복구 흐름을 개념적으로 표현하면 다음과 같다.
async function restoreLegacySession() {
try {
return await ionicStorage.get('session');
} catch {
return window.localStorage.getItem('session');
}
}try 블록의 저장소 읽기가 실패하면 catch가 실행돼 이전 저장 위치를 확인한다.
이 코드는 장애 당시의 파일을 그대로 옮긴 것이 아니며, 저장 키도 공개용으로 일반화했다.
폴백은 마이그레이션 중 이전 버전 사용자가 남긴 데이터를 읽어야 할 때 도움이 된다.
하지만 실패 이유를 기록하지 않고 모든 오류를 폴백으로 숨기면 저장소 손상을 늦게 발견할 수 있다.
현재 다시 만든다면 오류 종류와 앱 버전, 주 저장소 복구 여부를 민감정보 없이 로그로 남기고 폴백 사용률을 관찰한다.
임시 복구와 현재 권장을 분리한다
LocalStorage 폴백은 접근을 복구한 당시 조치다.
브라우저의 LocalStorage는 문자열 키·값을 동기적으로 보관하지만 모바일 운영체제가 WebView 데이터를 정리할 수 있고, JavaScript에서 접근할 수 있다.
Capacitor Preferences는 네이티브 앱의 가벼운 설정을 비동기로 보관하는 API다.
Capacitor는 웹 앱을 iOS·Android 네이티브 기능과 연결하는 런타임이다.
공식 문서는 네이티브 환경의 가벼운 영속 데이터에는 LocalStorage보다 Preferences를 권한다.
다만 Preferences가 인증 토큰을 자동으로 암호화해 준다고 설명하지는 않는다.
테마나 온보딩 완료 여부 같은 설정에는 적합하지만, 액세스 토큰과 리프레시 토큰은 별도의 보안 저장소와 서버 검증이 필요하다.
Ionic도 암호화되지 않은 LocalStorage에 민감한 토큰을 두는 것을 보안 실수로 설명하고, 네이티브 보안 기능을 사용하는 Identity Vault 같은 선택지를 제공한다.
따라서 현재 권장 구조는 세 층으로 나눈다.
| 데이터 | 저장 위치 | 이유 |
|---|---|---|
| 테마·온보딩 상태 | Preferences | 작고 민감하지 않은 설정을 안정적으로 보관 |
| 액세스·리프레시 토큰 | 플랫폼 보안 저장소 | JavaScript 저장소보다 노출 범위를 줄임 |
| 실제 권한 | 서버 세션·토큰 검증 | 클라이언트 화면 코드는 사용자가 바꿀 수 있음 |
클라이언트 라우트 가드는 사용자가 잘못된 화면을 보는 일을 줄이는 사용자 경험 장치다.
서버의 권한 검사를 대신하는 보안 경계는 아니다.
화면 상태를 네 가지로 나누면 순서가 보인다
기존 흐름의 문제는 토큰이 아직 로딩 중인 상태와 토큰이 없는 상태를 같은 값처럼 다룰 수 있었다는 점이다.
지금은 앱 시작 상태를 다음 네 가지로 나누는 편이 낫다.
loading: 저장소와 서버 확인이 아직 끝나지 않음authenticated: 유효한 인증 정보를 확인함anonymous: 확인이 끝났고 로그인 정보가 없음error: 저장소나 네트워크 오류로 상태를 결정하지 못함
loading 상태에서 로그인 화면으로 보내면 저장소가 조금 늦은 기기에서만 잘못된 이동이 생긴다.
error를 anonymous로 바꾸면 서버 장애가 사용자 로그아웃처럼 보인다.
상태를 나누면 로딩 화면, 재시도 안내, 로그인 이동을 각각 다른 조건에서 보여 줄 수 있다.
Angular의 현재 라우트 가드는 Promise나 Observable을 반환할 수 있다.
인증 상태가 비동기로 준비된다면 가드 자체가 그 결과를 기다려 반환하게 만든다.
export const authGuard: CanActivateFn = async () => {
const router = inject(Router);
const sessionStore = inject(SessionStore);
const state = await sessionStore.restore();
if (state.kind === 'authenticated') {
return true;
}
return router.parseUrl('/login');
};CanActivateFn은 해당 경로로 이동할 수 있는지 결정하는 Angular 함수형 가드다.
true는 이동 허용을 뜻한다.
router.parseUrl('/login')이 만드는 UrlTree는 현재 이동을 취소하고 로그인 주소로 리다이렉트하라는 결과다.
Angular 공식 문서는 가드에서 false를 반환한 뒤 별도로 navigate()를 호출하기보다 UrlTree 또는 RedirectCommand를 반환하도록 안내한다.
예시는 현재의 권장 구조이며 당시 3개 파일 변경을 그대로 재현한 코드는 아니다.
라우트 파라미터를 한 번만 읽는 진입 화면이라면 Observable을 Promise로 바꿔 순서를 한 함수에 모으는 방법도 있다.
const params = await firstValueFrom(this.route.queryParamMap);
await sessionStore.clearPreviousSession();
const authData = buildPartnerAuthData(params);
await sessionStore.restore(authData);
await this.goStart();firstValueFrom()은 Observable이 처음 내보낸 값을 Promise로 바꾸며, await 흐름 안에서 다음 작업과 순서를 읽기 쉽게 한다.
파라미터가 바뀔 때마다 화면이 갱신돼야 한다면 첫 값만 받는 이 방식은 맞지 않는다.
그 경우에는 RxJS 연산자로 이전 작업을 취소하거나 이어 붙이는 규칙을 명시하고 컴포넌트가 사라질 때 구독도 종료해야 한다.
제휴 채널을 별도 테스트해야 했던 이유
기본 앱과 제휴 채널이 같은 화면을 사용해도 진입 조건은 다를 수 있다.
기본 앱은 이미 초기화된 세션으로 들어오지만 제휴 채널은 URL 파라미터, WebView 시작, 저장소 마이그레이션을 한 번에 통과할 수 있다.
기본 흐름 한 번의 성공으로 이 경로까지 검증됐다고 볼 수 없다.
재발 방지 테스트는 다음 순서 조합을 포함해야 한다.
- 저장소가 빠르게 준비되고 유효한 토큰이 있는 경우
- 저장소 준비가 늦지만 결국 인증되는 경우
- 주 저장소가 실패하고 이전 저장소에 값이 있는 경우
- 두 저장소 모두 비어 있어 로그인으로 이동하는 경우
- 서버가 토큰을 거절해 세션을 정리하는 경우
- 제휴 파라미터가 누락되거나 변조된 경우
각 테스트는 최종 화면만 확인하지 않는다.
보호 API가 인증 확인 전에 호출되지 않았는지, 이전 사용자 정보가 남지 않았는지, 오류 상태에서 로딩 표시가 끝나는지도 본다.
업무 기록에는 제휴 채널 회귀 테스트를 재발 방지 항목으로 남겼다고 되어 있지만 자동화 완료 여부는 확인되지 않는다.
따라서 “테스트를 구축했다”가 아니라 “테스트 범위에 포함해야 한다는 기준을 남겼다”고 쓰는 것이 맞다.
다음에는 시간도 한 기준으로 남긴다
이번 기록의 0.25시간과 06:25~06:50은 서로 다른 숫자를 말한다.
하나는 실제 코드 수정 시간이고 다른 하나는 최초 확인부터 모니터링까지일 수도 있지만, 남은 자료만으로는 알 수 없다.
다음 장애에서는 시각을 네 단계로 나누어 남긴다.
- 최초 사용자 영향 시각
- 담당자가 장애를 인지한 시각
- 수정 또는 롤백이 반영된 시각
- 핵심 경로가 정상임을 검증한 시각
이렇게 해야 대응 속도를 꾸미지 않고 어디에서 시간이 쓰였는지 개선할 수 있다.
결론
제휴 채널 접근 장애의 핵심은 인증 코드가 없어서가 아니었다.
필요한 함수는 모두 있었지만, 인증 데이터와 저장소가 준비됐다는 완료 신호를 다음 단계가 기다리지 않았다.
나는 기존 진입 경로를 따라가며 라우트 파라미터 안에서 인증 구성을 끝낸 뒤 화면을 이동하도록 바꾸고, 저장소 초기화 실패 때의 폴백을 추가했다.
그 폴백을 현재의 장기 보안 설계와 같다고 포장하지는 않는다.
비동기 인증에서는 “무엇을 호출했는가”보다 “어떤 완료를 기다린 뒤 화면을 열었는가”가 더 중요하다.