
서버를 되돌리는 데 20분쯤 걸렸고, 앱은 스토어 심사를 거쳐야 했기 때문에 바로 대응할 수가 없었다. 그래서 서버만 배포 전으로 돌아가고, 앱은 배포되었던 버전으로 사용자가 이용할 수밖에 없었다.
그런데 아무 일도 일어나지 않았다. 서버에서는 관련 API가 404와 500을 내려주기 시작했는데 사용자에게 발생한 이슈는 없었다. 폴백 코드를 따로 짠 적이 없는데도.
이 글은 지금까지 클라이언트가 들고 있던 상태를 서버로 옮기려는 개발자를 위해 작성했다.
무엇을 바꾸고 있었나
우리 서비스는 담은 메뉴가 오래 남아 있지 않았다. 매장을 벗어나거나 앱을 종료하면 담았던 메뉴를 제거했다. 즉, 장바구니가 비워졌다.
메뉴 정보를 화면 사이에서 Intent로 넘기는 구조였기 때문이다.
미리 밝혀두면 이 글에 나오는 코드는 전부 프로덕션 코드가 아니다. 구조만 남기고 새로 쓴 예시이고, 클래스와 함수, 필드 이름도 실제와 다르다. 실제 코드에는 매장 확인이나 장바구니 검증 같은 게 더 붙어 있는데 이 글의 주제가 아니라 걷어냈다.
val intent = Intent(this, BasketActivity::class.java).apply {
putParcelableArrayListExtra(KEY_ITEMS, ArrayList(selectedItems))
putExtra(KEY_STORE_ID, storeId)
putExtra(KEY_TABLE_NUMBER, tableNumber)
}
startActivity(intent)
담은 메뉴 목록이 UI 생명주기와 함께 살고 함께 죽는 구조다. 화면을 벗어나면 그 목록도 같이 사라진다.
이번에 할 일은 이걸 서버에서 관리하는 걸로 바꾸는 거였다. 그래야 매장을 벗어났다 돌아와도 담아둔 게 남아 있고, 담아두었던 메뉴를 통해 사용자에게 더 많은 서비스를 제공할 수 있었다.
덧붙이면 주문 종류가 셋(일반 주문, 재주문, 같이주문)인데 같은 화면을 공유하고 있어서, 구현에 들어가기 전에 코드에서 먼저 분리했다.
서버로 옮기면서 로컬을 지울 뻔했다
장바구니를 어디에 둘지 세 가지를 놓고 봤다.
| 방법 | 어떻게 | 판단 |
|---|---|---|
| 지금처럼 Intent로 전달 | 화면 사이에 메뉴 목록을 실어 보냄 | 화면을 벗어나면 사라진다. 이번에 고칠 문제가 바로 이거다 |
| 서버만 원천, 캐시 없음 | 화면 전환마다 서버에서 조회 | 화면마다 로딩이 걸린다. 그리고 장바구니 서버에 장애가 나면 담는 것부터 막힌다 |
| 서버 원천 + 로컬 캐시 | 서버가 진실, 앱은 캐시를 들고 화면에 뿌림 | 골랐다 |
두 번째를 선택하지 않은 이유가 이 글의 시작점이다.
매번 서버에 API Call을 하면 사용자 눈에는 메뉴 상세에서 장바구니로 넘어갈 때마다 스피너 또는 스켈레톤이 보이거나 화면이 한 번 비었다 채워진다. 담기 버튼을 누를 때도 응답을 기다려야 한다. 장바구니처럼 자주 오가는 화면에서 이건 꽤 거슬린다고 생각했다.
그래서 앱에 캐시를 남겼다. 화면 전환은 캐시로 즉시 처리하고 서버와는 뒤에서 맞추기로.
캐시를 남긴 이유는 둘이었다. 하나는 방금 말한 사용자 경험이고, 다른 하나는 장바구니 서버에 장애가 나거나 롤백됐을 때를 대비하는 것이었다. 두 번째는 뒤에서 이야기하자.
캐시를 메모리에 둔 이유
캐시를 DataStore나 Room 같은 영속 저장소가 아니라 메모리에 뒀다. 이유는 두 가지가 있었다.
하나는 한 세션 안에서만 유지되면 충분하다고 봤기 때문이다. 담고 나갔다 다시 들어왔을 때 남아 있으면 목표는 달성된다. 앱을 완전히 종료했다 켜는 경우까지 로컬이 책임질 필요는 없었다. 그건 서버의 책임이라고 생각했다.
다른 하나는 원천이 어디인지를 명확하게 하기 위해서였다. 서버 DB가 진실이고 앱이 든 건 캐시다. 캐시를 디스크에 영구 저장하기 시작하면 "둘이 다를 때 뭐가 맞나"라는 문제가 생긴다. 메모리에만 두면 앱을 껐다 켜는 순간 캐시가 사라지고 서버에서 다시 받아오니까 그 질문 자체가 생기지 않는다.
interface BasketRepository {
/** 장바구니 상태. 캐시가 비어 있으면 서버에서 한 번 받아옵니다. */
val basketFlow: Flow<Basket>
/** 현재 캐시 스냅샷 */
fun current(): Basket
/** 장바구니 저장 */
suspend fun save(basket: Basket, reason: ChangeReason): Result<Unit>
}
그런데 이 캐시가 폴백 역할을 하게 되었다
구현체를 보면 왜 그런지 바로 보인다.
class BasketRepositoryImpl @Inject constructor(
private val api: BasketApi,
) : BasketRepository {
private val cache = MutableStateFlow(Basket.empty())
override val basketFlow: Flow<Basket> = cache.onStart {
if (cache.value.isEmpty) fetch()
}
override fun current(): Basket = cache.value
override suspend fun save(basket: Basket, reason: ChangeReason): Result<Unit> =
runCatching {
// 서버보다 로컬을 먼저 갱신합니다 (낙관적 업데이트)
cache.update { basket }
api.save(basket.toRequest(reason))
}
private suspend fun fetch() {
runCatching {
val remote = api.get().toDomain()
cache.update { remote }
}
// 실패해도 아무것도 하지 않습니다.
// 캐시는 빈 채로 남고, 앱은 로컬 장바구니만으로 동작합니다.
}
}
위 코드가 이 글의 전부다.
save()는 서버보다 로컬을 먼저 갱신한다. 낙관적 업데이트다. 서버 저장이 실패해도 로컬은 이미 갱신돼 있으니 화면은 정상이다.
이게 눈속임이 아닌 이유가 있다. 다음에 뭘 담거나 지울 때 로컬에 있는 장바구니 전체를 다시 서버로 보낸다. 한 번 실패해도 다음 동작에서 싱크가 저절로 맞는다.
fetch()는 실패를 삼킨다. runCatching으로 감싸고 실패했을 때 아무것도 하지 않는다. 캐시가 빈 상태로 남고 앱은 로컬 장바구니만 가지고 동작한다.
그런데 로컬 장바구니만으로 동작하는 상태가 뭘까. 장바구니 유지를 넣기 전과 거의 같은 동작이다. 앱을 껐다 켜면 비워지는 그 상태. 왜 "거의"인지는 뒤에서 이야기하자.
여기가 이 글에서 하고 싶은 말이다. 폴백을 따로 만들지 않았다. 사용자 경험 때문에 남긴 캐시가 그대로 폴백이 되었다.
우연은 아니었다
앞에서 캐시를 남긴 이유가 두 가지라고 했는데 두 번째가 이거다. fetch() 실패를 삼키도록 둔 것이다.
이유는 세 가지가 있는데, 첫 번째로 이 기능이 점진 배포로 나갈 예정이었고, 두 번째로 장바구니 서버 API가 신규였고, 세 번째로 여기가 주문으로 이어지는 길목이었다. 주문 flow에서 신규 API는 롤백될 수 있다고 봤다.
덧붙이면 우리는 서버가 기능별로 나뉘어 있고, 결제는 다른 서버가 맡는다. 그러니까 장바구니 서버가 죽어도 결제 자체는 살아 있다. 담아둔 걸 못 불러오는 것과 결제가 안 되는 건 다른 문제인데, 앱이 장바구니 서버 응답에만 매달려 있으면 그 둘이 하나로 묶인다. 그걸 끊어두고 싶었다.
이슈는 하루 만에 발생했다
20% 점진 배포로 나갔다.
이번 스프린트에는 서버 개발쪽에서 하나가 더 묶여 있었다. 주문을 하기 직전 할인 계산과 검증을 앱에서 서버로 옮기는 일이었다. 장바구니를 서버가 들고 있으니 금액도 서버가 계산하는 게 맞다고 봤다.
첫 주문 할인, 매장 자체 할인, 매장이 여는 이벤트, 포장 여부 등 이런 조건이 겹쳐서 최종 금액이 나온다. 옮기는 과정에서 이 조합 가운데 일부를 놓쳤고 할인이 잘못 붙은 주문이 나왔다.
QA에서도 못 잡았다. 조합이 워낙 많은 데다 오래된 기능들까지 얽혀 있어서 거기까지 보지 못했다.
그리고 우리가 먼저 안 것도 아니었다. VOC를 통해 알았다.
할인이 잘못 붙는 건 비용과 관련된 문제라 롤백은 선택이 아닌 필수였다. 배포 하루 만에 이상을 확인했고 20분쯤 뒤에 장바구니 서버를 롤백시켰다.
앱은 재배포하지 않았는데 문제도 없었다
장바구니 서버만 되돌리고 앱은 20% 배포 상태 그대로 뒀다.
장바구니 API가 404와 500을 뱉기 시작했다. 그리고 아무 일도 일어나지 않았다.
fetch()가 실패하고, runCatching이 그 실패를 삼키고, 캐시는 빈 채로 남고, 앱은 로컬 장바구니만으로 동작했다.
save()도 계속 실패했다. 다만 로컬은 이미 갱신된 뒤라 담고 지우는 건 그대로 됐다. 서버에 안 올라갔을 뿐이다.
사용자에게 보인 증상은 없었다. 앱을 껐다 켜면 장바구니가 비워지는 것 정도인데 그건 원래도 그랬다.
롤백하고 나서 주문부터 결제까지 직접 돌려봤다. 앱은 재배포하지 않은 상태 그대로. 정상적으로 되는 걸 확인하고 나서야 숨을 돌렸다.
폴백은 기능이 아니라 구조였다
이번 스프린트를 통해 남은 것은 다음과 같다.
폴백을 기능으로 만든다는 건 이런 코드를 짠다는 뜻이다.
// 폴백을 "기능"으로 만들면 이렇게 된다
suspend fun getBasket(): Basket {
val basket = if (serverHealthChecker.isAlive()) {
api.get().toDomain()
} else {
localBasketStore.load() // 서버가 죽었을 때만 도는 길
}
return basket
}
서버가 멀쩡한 동안 else 안쪽은 한 번도 실행되지 않는다. 필요한 날이 오기 전까지 아무도 그게 도는지 모른다. 게다가 서버가 죽었는지 판단하는 코드를 따로 짜야 하고, 그 분기를 테스트하려면 장애를 흉내 내야 한다.
구조로 만들면 이야기가 달라진다. 화면이 서버를 직접 보지 않게 하고 캐시만 보게 한다. 그러면 서버가 사라져도 화면이 보는 대상은 그대로다. 분기가 없으니 안 도는 코드도 없다. 그리고 캐시만 남은 상태가 예전 정상 동작과 같으면 그게 곧 폴백이다.
다만 조건이 하나 있다. 서버를 걷어냈을 때 남는 상태가 사용자에게 버그가 아니어야 한다. 우리 경우엔 서버 장바구니를 빼면 기능 추가 전 앱이 됐다. 정확히 같지는 않았다. 예전에는 매장을 벗어나면 담은 게 사라졌는데 메모리 캐시는 앱이 켜져 있는 동안 남았으니, 오히려 조금 더 관대해졌다. 그래도 상관없었다. 폴백에 필요한 건 예전과 같아지는 게 아니라 예전보다 나빠지지 않는 것이니까.
이 조건이 안 맞으면 이 설계는 잘못된 거다. 새 기능이 예전 동작을 대체하는 게 아니라 확장하는 거라면, 캐시만 남았을 때 반쪽짜리가 된다. 그럴 땐 폴백을 따로 설계해야 한다.
놓친 것
틀린 값을 우리가 먼저 볼 방법이 없었다. 사용자가 무엇을 하는지는 유저행동기록과 GA를 통해 추적하고 있었다. 어느 화면에 들어왔고 무엇을 눌렀는지. 그런데 계산 결과가 맞는지 보는 로직은 없었다.
행동을 추적하는 일과 값이 맞는지 보는 일은 다른 일인데 나는 그걸 같은 걸로 여기고 있었다. 할인 금액이 평소와 다르게 찍히는 건 사람이 아니라 기계가 먼저 알아차려야 하는 신호다. 아직 그 로직을 만들지 못했다. 다음 작업에서 제일 먼저 손대야 할 부분이다.
로직이 없던 건 이것만이 아니다. 이번 작업에도 테스트는 짰다. 그런데 정작 아쉬운 건 이미 있던 테스트다.
클라이언트에는 할인 계산 테스트가 있었다. 로직을 서버로 넘길 때 그 케이스들도 같이 넘겼어야 했다. 평소 리팩터링할 때는 이렇게 한다. 테스트를 두고, 구현을 바꾸고, 같은 테스트가 그대로 통과하는지 본다. 통과하면 동작이 안 바뀐 것이다.
이번에도 같은 로직을 옮기는 일이었다. 다만 옮겨 간 곳이 다른 플랫폼이라는 이유로 그 순서를 지키지 않았다. 테스트 케이스는 검증 도구이면서 명세이기도 하다. 클라이언트가 알고 있던 할인 규칙이 거기 다 적혀 있었는데 그걸 건네지 않았다.
처음으로 돌아가 다시 개발한다면
서버 이관과 클라이언트 변경을 같은 배포에 묶지 않겠다. 이번에 서버만 되돌리고 넘어갈 수 있었던 건 캐시 구조 덕이지 배포 설계 덕이 아니었다. 배포를 나눴다면 애초에 폴백에 기댈 일도 없었다.
돈에 직결된 로직을 옮길 때는 테스트부터 넘기겠다. 앱과 서버가 같은 입력에 같은 금액을 내는지 한동안 비교해보고 넘겼어야 했다. 사용자가 알려주는 방식 말고.
값이 맞는지 확인하는 기능과 같이 내보내겠다. 나중에 붙이자고 미뤄뒀는데 이슈가 먼저 발생했다.
서버가 죽었는데 앱이 멀쩡했던 건 대단한 설계를 해서가 아니다. 사용자 경험 때문에 남긴 캐시가 마침 폴백 자리에 맞았고, 실패를 삼키도록 둔 판단이 거기 얹혔을 뿐이다. 조건이 맞아떨어진 거지 어디서나 되는 방법은 아니다. 그래서 이번에 조건에 기댄 걸, 다음엔 설계로 만들어두려 한다.
'Develop > Kotlin' 카테고리의 다른 글
| 신입 개발자의 AI 주도 개발 생존기 (0) | 2026.02.21 |
|---|---|
| 안드로이드 신입 개발자의 시선에서 본 테스트 코드 (0) | 2025.12.28 |
| 프로세스 실종 사건 - Activity 스택이 사라졌다. (0) | 2025.09.19 |
| Jetpack Compose 환경에서 ExoPlayer 최적화 (0) | 2025.08.15 |
| 안드로이드에서 일회성 이벤트 처리, 어떻게 할 것인가? (2) | 2024.10.27 |