배경
동네생활은 네이티브 앱 안의 웹뷰로 동작하는 서비스다. 이 동네생활에 장소 첨부 기능이 3주 전에 업데이트가 되었다. 네이티브 브릿지 통신이 있는 기능이기 때문에 특정 버전 이상에서만 장소 첨부 기능을 사용할 수 있다.
그런데 어느날, CS 인입으로 ‘장소 첨부가 안보여요’라는 이슈가 제보된 것에서 이 이야기는 시작된다.
최초 인입은 6월 18일로, 총 6명의 동일한 이슈가 제보되었다. 해당 기능이 보여야 하는 버전은 2.1.94 이상이며, CS 인입된 사용자의 실제 버전은 2.2.2인 상황이다. 또한 AOS 기기에서만 제보된 이슈이다.
원인 파악
재현을 해보자.
가장 먼저 이 문제의 상황을 재현해보고자 했다. 팀 내 온갖 테스트 기기를 동원해서 현재 버전에서 해당 이슈가 발생하는지, CS 인입의 버전으로 설치했을 때 재현이 되는지 시도해보았다. 전부 재현이 안 되는 거다. 그래서 QA팀에 요청해서 해당 핸드폰 모델을 구해서 시도를 해 봤지만 끝내 재현이 되지 않았다. 가지고 있는 기기로는 재현은 어렵다고 판단했고, 그 외의 방법으로 원인을 찾아봐야겠다고 생각했다.
버전 분기가 잘못된 건 아닐까?
// appVersion.ts
/**
* 앱 버전 분기 처리를 위한 버전 정보
*/
export const APP_VERSIONS = {
/**
* 게시글 장소 첨부 기능 추가 버전
* @description 신버전만 장소 첨부 진입점 노출. 구버전은 미노출.
**/
canPostPlace: {
ios: '>=26.6.1',
android: '>=2.1.94',
},
}
AOS는 2.1.94 버전 이상으로 작성이 잘 되어있다. 이전에도 동일한 상수를 이용해 분기를 했을 때 문제 없이 잘 동작했기에 의심이 많지는 않았지만 그럼에도 돌다리도 한 번 두드려보고 건넌다고, 분기 로직을 한 번 살펴보았다.
import { APP_VERSIONS } from 'app.modules/constant/appVersions';
import { getWebviewCookies } from 'app.modules/cookie/getCookies';
import { satisfies } from 'compare-versions';
// ..
export const useAppVersionCompare = (
versionName: keyof typeof APP_VERSIONS
) => {
const cookie = getWebviewCookies();
const agentDeviceOs = getDeviceOs();
const detectedOs = cookie.deviceOs ?? agentDeviceOs;
const os = detectedOs === 'ios' ? 'ios' : 'android';
try {
return satisfies(cookie.appVersion, APP_VERSIONS[versionName][os]);
} catch (error) {
return true;
}
};
위 코드에서 앱 버전 분기를 판단하는 핵심 로직은 아래와 같다.
satisfies(cookie.appVersion, APP_VERSIONS[versionName][os]);
이 satisfies는 compare-versions 오픈소스에서 가져온 모듈로, 외부 라이브러리이기에 의심을 가장 후순위로 미룰 수 있다.
그렇다면 여기서 의심해볼 수 있는 포인트는 두 가지이다.
1. `cookie.appVersion`
- cookie는 getWebviewCookies 함수에서 가져온 값이다. 쿠키를 잘못 가져온 게 아닐까?
2. `os`
- os를 잘못 감지해서 AOS인데 iOS로 판단하고, 26.6.1 버전과 비교를 해서 버전 분기를 잘못한 게 아닐까?
이 가설이 맞는지 확인하기 위해 Grafana 로깅을 추가하여 배포를 진행했다. 이와 함께, OS를 iOS로 잘못 감지한 것이라 추정하고 fallback을 iOS가 아니라 AOS로 변경하여 배포를 진행했다.
로깅으로 확인해보니, OS가 아니라 버전이었다
OS 판별을 원인으로 추정했기 때문에 쿠키의 deviceOS와 user-agent 기준 OS, 그리고 쿠키 내 appVersion 등을 로깅하는 로직을 추가했다. 그러고 며칠이 지나 CS 인입이 추가로 들어왔다. 위 OS fallback을 변경한 것이 문제를 해결하지 못한 것이다. 이전에 로깅한 데이터를 살펴보았을 때, OS판정에는 문제가 없는 것 또한 확인했다.
위 로깅 데이터로는 쿠키 내 deviceOS가 원인인지만 특정할 수 있고, 그 외의 정보는 파악할 수가 없었다. 그래서 원본 쿠키를 그대로 로깅에 추가하여 추가 배포를 진행했다. 배포 후 며칠 뒤 확인 결과, CS 인입 사용자들의 쿠키 내 버전이 CS 인입 메일에 작성된 최신 버전(2.2.2)이 아니라, 해당 기능을 제공하지 않던 구버전임을 파악했다.
CS 인입 버전이 잘못된 게 아닐까?
웹뷰에서 쿠키는 구버전을 가리키고 있다. 따라서 사용자들이 CS 메일에서 함께 작성되는 앱 버전의 경로를 파악하고자 했다. AOS팀에 문의한 결과, CS 메일의 앱 버전은 런타임 시점을 기준으로 앱 버전을 추출한다고 전달을 받았다. 그리고 웹뷰에 쿠키로 전달하는 버전은 컴파일 시점에 버전을 추출한 것이다.
이를 바탕으로 이런 가설도 세워볼 수 있었다. ‘CS메일의 버전은 런타임 시점이기 때문에 최신 버전이 반영이 되고, 웹뷰에 전달하는 쿠키는 컴파일 시점의 버전을 전달해서 구버전을 전달해준 게 아닐까?’ 이 가설을 증명해보기 위해서 구버전을 설치하고, 신버전을 다음에 설치를 해보았지만 재현되지는 않았다. 앱을 재설치하는 것이 아니라 업데이트를 해봐야 하는 게 아닐까 생각도 했지만, apk파일이 없고 App Tester로 다운로드 받고 있는 상황상 내가 시도해볼 수는 없었다.
그렇다면, 실제 사용자의 앱 버전은 몇일까? 이는 BigQuery로 조회해봤다. 실제로 CS 인입이 되었을 당시 버전과 일치함을 확인할 수 있었다.
지금까지의 상황을 정리하면 CS 인입 내 버전은 실제 사용자의 앱 버전을 반영하고 있었고, 웹뷰에서 인식한 앱 버전은 구버전을 바라보고 있었고, 따라서 해당 사용자들은 새로운 버전에서 제공하는 기능을 이용하지 못하고 있었던 것이다.
왜 구버전의 쿠키를 바라보고 있을까?
이전에 조회한 Grafana 로그를 다시 살펴보았다. 확인 결과, CS 인입 사용자 쿠키가 심어진 지 90일이 넘는 것을 확인할 수 있었다. 그 90일 이전 시점은 4월 23일이었다. 그리고 4월 23일은 feat: 서브 쿠키 임시 대응커밋이라는 웹 배포가 있던 시점이었다. 그 당시 히스토리로 돌아가보았다. 이는 네이티브가 심어준 쿠키를 웹뷰 쿠키로 복사하는 로직을 추가한 커밋이다. 이 복사가 원인이 아닐까 생각도 했다. 하지만 네이티브에서 심어준 쿠키를 먼저 판단하고, 없다면 복사한 웹뷰 쿠키를 바라보는데, 로그에서 네이티브 쿠키는 항상 존재했기에 원인이 아니라 판단했다.
이때, 당시 히스토리를 파악하고 있던 PM님이 관련 작업을 공유해주셨다. 4월 13일, AOS에서 한 배포가 나갔던 것이다. “쿠키의 Path 설정”. 그리고 이 배포가 나간 버전이 2.1.76으로, CS 인입 사용자들의 쿠키 내 버전(2.1.74)보다 뒤의 버전이었다. 또한 30일 이상 지난 쿠키를 보유하고 있는 사용자들의 데이터를 분석했을 때 전부 2.1.68 ~ 2.1.75였던 것이다. 그리고, “쿠키의 Path 설정” AOS 배포가 나간 버전이 2.1.76이니 모든 것이 설명이 되었다.
상황을 정리하면, AOS 2.1.76버전에서 “쿠키의 Path 설정” 배포가 진행이 되었고 그 이전 버전을 사용하던 사용자는 해당 쿠키를 계속 보유하고 있던 것이었다. 따라서 앱이 최신 버전으로 업데이트되었음에도 최신 기능을 사용하지 못하고 있던 것이었다.
구버전 쿠키는 왜 살아남았고, 왜 선택되었을까?
이는 쿠키의 특성과 관련이 있다. 브라우저는 쿠키의 name, domain, path 모두 일치해야 동일한 쿠키로 인식하고 덮어쓴다. 만약 하나라도 다르면 다른 쿠키로 인식하고 함께 전달한다. AOS 2.1.76 이전 버전에서는 path 설정이 없었다. Set-Cookie에서 Path를 생략하면 브라우저가 요청 URL의 디렉토리를 기본 Path로 잡는다. 예를 들어 /local/community/강남구 페이지에서 쿠키를 심으면 path는 /local/community가 된다. 그런데 AOS 2.1.76버전에서 path를 루트(/)로 설정이 되었고, 이는 기존 쿠키를 덮어쓰지 못하게 된 것이다. 그래서 기존 쿠키와 새로 네이티브가 심어주는 쿠키가 공존하고 있던 것이다.
그럼 여기서 왜 기존 쿠키와 네이티브가 심어준 쿠키가 공존하는데, 기존 쿠키를 쓰는지 의아해할 수 있다. 이는 우리가 사용하던 nookies라이브러리의 특징과 관련이 있다. nookies가 내부에서 쓰는 파서는 이미 파싱된 key가 있으면 뒤의 값을 무시한다. 즉 같은 key가 두 번 오면 첫 값만 남는다. 그리고, 브라우저는 쿠키를 보낼 때 더 구체적인 path의 쿠키를 먼저 보낸다. 따라서 nookies는 더 구체적인 path인 기존 쿠키를 가져온 것이었다.
그래서 해결은?
결국 nookies를 걷어내고 커스텀 쿠키 파서로 변경했다. 커스텀 파서는 중복 key가 오면 마지막 값으로 덮어쓰는데, 브라우저가 구체적인 path를 앞에 보내주는 덕분에 마지막 값은 항상 루트 Path의 최신 쿠키다.
그런데 이 구버전 쿠키를 애초에 제거할 수는 없었을까? 이 쿠키는 만료 기한이 없는 세션 쿠키였다. 브라우저라면 창을 닫을 때 사라지지만, AOS 웹뷰에는 '창 닫힘'에 해당하는 이벤트가 없다. CookieManager.removeSessionCookies()를 명시적으로 호출하거나 앱 데이터를 지우지 않는 한 계속 남는다. 4월에 심긴 쿠키가 몇 달 뒤까지 살아있던 이유다. 삭제 역시 name, domain, path 3개가 모두 일치해야 가능하다.
다시 처음으로 돌아간다면,
- 로깅은 내가 원인이라 생각하고 있는 것 뿐 아니라, 관련된 데이터를 모두 포함해서 남기자.
처음에는 OS를 잘못 가져온 것을 원인으로 추정하고 OS와 관련된 로직만 수정을 하고 로깅을 남겼다. 결국 OS를 가져오는 것이 원인이 아니었기 때문에 문제를 해결하지 못했다. 시간만 더 지연이 된 것이다. 처음부터 로깅을 좀 더 포괄적으로 했더라면, OS 가져오는 로직이 문제가 아니라는 것을 더 일찍이 파악할 수 있지 않았을까. 로깅을 처음으로 돌아간다면, 실제로 앱 버전을 분기하는 곳에서 쿠키를 통째로 로깅하는 로직을 추가할 것이다.
처음에 딱 필요한 요소만 로깅을 하려고 한 이유는, 쿠키를 전부 다 로깅하면 잡음이 많이 생기지 않을까, 로깅 데이터가 너무 많아서 비용이 많이 들지 않을까 하는 우려였다. 그런데 당시에는 그러한 잡음의 크기도 알지 못했고, 로깅을 많이 할 때 비용이 얼마나 많이 드는지도 알지 못했다. 실제로는 Grafana에서 내가 보고 싶은 데이터만 필터링해서 조회하기 때문에 많은 양을 로깅하더라도 잡음이 크지 않다. 또한 우리 회사의 Grafana는 셀프 호스팅으로 운영한다. 따라서 많은 양의 데이터를 저장할 때 증가할 수 있는 비용은 스토리지 비용 뿐인데, (실제 비용은 찾아봐야겠지만) S3에 저장하기에 유의미한 추가 비용이 발생하지 않을 가능성이 크다는 것이다.
- 이슈와 관련된 내용을 한 곳에 작성하자. (SSOT)
CS 인입 사용자가 어떤 사용자인지 한 곳에 기입을 해두지 않고 진행을 했다. 그래서 작업을 진행하면서 여러 번 슬랙 CS 인입 스레드를 찾아 사용자의 정보를 받아적고는 했다. 처음부터 한 곳에 가시적으로 정리를 했더라면 이런 수고로움을 덜 수 있었을 것이다.
또한 Grafana/BigQuery에서 어떤 데이터를 어떤 쿼리로 조회했는지, 그리고 각 결과는 어땠는지 노션과 파일명에 명확하게 정리해두었으면 더 좋았을 거 같다.
- CS 인입 사용자에게 휴대폰을 빌릴 수 있었더라면?
Grafana 로깅, BigQuery 조회를 해서까지 원인을 추적하게 된 근본적인 이유는 ‘재현이 안 되어서’이다. 재현되는 기기가 없었다. 그런데 만약 CS 인입 사용자에게서 재현되는 기기를 빌릴 수 있었더라면 어땠을까? 개발자 도구를 연결해서 현재 웹뷰 쿠키가 구버전인 것과 네이티브 쿠키와 버전이 다르다는 것을 파악하고 더 빠르게 해결할 수 있지 않았을까?
사내에 CS 인입 사용자에게 연락을 할 수 있는 방법이 있는지 한 번 찾아보는 것도 좋은 방법이라는 생각이 들었다.
결론
짧지 않은 2주라는 기간 동안 이 이슈를 해결하고자 했다. 지나고 나면 참으로 별거 아닌 이슈였다. ‘네이티브에서 심어주는 쿠키의 Path가 변경되며 쿠키가 중복해서 저장되었고, 두 개의 쿠키가 공존해 기존 쿠키만 바라보고 있었다’는, 돌이켜보면 간단한 문제였다.
아쉬움이 많은 만큼 배운 점도 참 많다. 이번에 로깅을 필요한 값만 남겨서 원인을 파악하는 데 시간이 지연되었으니, 다음부터는 관련된 데이터를 모두 로깅하면 된다. 이슈와 관련된 내용을 한 곳에 정리하지 않아서 나중에 찾아보기 힘들었다면, 다음부터는 문서 한 곳에 깔끔하게 정리하면 된다. 아쉬움이 많았던 만큼, 그리고 이 아쉬움을 극복하기 위한 대책들을 세운 만큼 더 성장한 듯하다.
또한 이론으로 배운 것들이 실무를 통해서 훨씬 더 탄탄해지고 각인이 되었다는 걸 느꼈다. 이전에 면접 준비를 할 때 ‘쿠키는 name/path/domain 모두 같아야 같은 쿠키로 인식한다’는 걸 이론으로 배운 적이 있다. 그런데 이걸 써먹은 적이 없어서 머릿속에서 거의 지워지기 일보 직전이었다. 이번 이슈를 실무에서 겪으며 위 사실은 앞으로도 절대 잊지 않을 것 같다.