앱 업데이트 좀... 해주시면 안 될까요?

서버 - 클라이언트단의 버전 관리 방법론

Jun Noh

최근 주주를 열흘이 조금 넘게 서비스하면서 계속 안정화와 홍보 준비를 하고 있는데, 이게… 참 1인 개발자로써 백단 코드의 변경 사항을 배포하면 항상 모바일의 버저닝 문제가 골치다.

서버 코드야, 뭐… 그냥 Azure 컨테이너 앱을 업데이트하면 바로 배포가 끝나는데, 앱은 빌드 → 스토어 심사 → 사용자 업데이트의 단계를 거쳐야 사용자에게 가닿는다.

게다가 사용자 중 일부는 자동 업데이트를 꺼놨거나, 저장공간이 없거나, 그냥 앱을 안 연다.

결국 서버는 항상 여러 버전의 앱과 동시에 통신을 한다는 것이다.

그래서 이것도 나름의 방법론이 있을 거 같아서, 현재 내 방식의 장단과 다양한 회사나 사람들이 사용하는 방법론을 정리해보려고 한다.

내가 지금 쓰는 방식

매우 단순하다.

  1. DB에 min_supported_version(사실상 빌드 넘버)을 박아둔다. 예: 24
  2. 앱 실행 시 자기 버전을 서버에 보낸다. 응답에 강제 업데이트 플래그가 오면, 클라이언트는 스토어로 리다이렉트시키는 차단 화면을 띄운다.
  3. 백엔드 코드를 바꿀 때마다 옛 버전 영향도를 수동으로 체크한다. 변경 사항의 내용을 체크하고, 내 뇌피셜과 AI inspector를 약 2:8 정도..?로 사용해서 문제가 혹시 있는지 하나하나 확인하는 거다.
  4. 옛 버전 앱 빌드로 회귀 테스트한 뒤 심사 제출.

이게 사실상 두 개의 도구로 정리된다.

  • 강제 업데이트 게이트 — 새 버전을 너무 자랑하고 싶을 때 진행.
  • 수동 백워드 호환성 체크

이 방식의 장점과 단점

장점

진짜 단순해서, 뭐 생각할 것도 없다.

추가 라이브러리도 없고, 외부 SaaS 의존도 없다. Firebase Remote Config 같은 거 안 붙여도 일단 굴러간다. 운영 비용 0, 학습 비용 0.

내가 가장 무서워하는 게 “관리해야 할 시스템이 한 개 더 늘어나는 것” 인데, 이 방식은 그게 거의 없다.

잘 안 깨지는 시스템이다.

단점

근데 단점은 명확하다.

  • 휴먼 에러에 절대적으로 의존한다. 내 뇌피셜 + AI inspector 조합이 한 번 놓치면 그대로 사고다. PR 검토를 혼자 하니까 두 번째 시선이 없다.
  • 강제 업데이트 카드를 자주 써야함 강제 업데이트는 사실 마지막 방법이여야 맞는건데… 너무 자주 사용하게 된다.
  • 기능 단위 제어가 안 된다. 새 기능에 버그 나도 그것만 끌 방법이 없다. 핫픽스 빌드 올리고 심사 받는 24시간 동안 사용자는 그 버그를 계속 겪는다.
  • 버전 분포를 모른다. 어떤 버전이 얼마나 살아있는지 모르니까 “이 변경 깨도 되나?”가 그냥 직감이다.
  • A/B 테스트는 사실상 못 한다.

이 단점들을 메우려면, 평소 코드 수정할 때 자체 디시플린이 빡빡해야 한다.

백엔드 수정할 때 내가 지키려고 노력하는 규칙들

이건 사실 1인 개발만의 노하우가 아니라, 모바일 클라이언트 깔린 모든 백엔드의 공통 규칙이다.

다만 1인이라 두 번째 검토가 없으니까 더 빡빡하게 의식하고 들어가야 한다.

크게 묶으면 세 갈래다.

1. 스키마는 추가만, 변경·삭제 금지

이게 첫 번째이자 가장 중요한 규칙이다. 영문 이름으로는 Expand-Contract 또는 Parallel Change 라고 부른다.

// 나쁨 — 옛 클라가 phone 읽다가 깨짐
{ "user": { "phones": ["010-1234-5678"] } }

// 좋음 — phone은 그대로 두고 phones를 추가
{
  "user": {
    "phone": "010-1234-5678",
    "phones": ["010-1234-5678"]
  }
}

옛 클라는 phone을 계속 읽고, 새 클라는 phones를 읽는다. 시간이 흘러 옛 클라 사용자가 0에 수렴하면 그때 phone을 제거한다.

요청도 마찬가지다. 새로 만드는 필드는 무조건 optional + 서버 default 값. Required 필드를 기존 엔드포인트에 추가하면 옛 클라가 그 필드 없이 요청 보내서 무조건 400 받는다.

2. Validation은 풀어주는 방향만, 조이는 건 금지

이건 처음에 의외였는데, 옛 클라이언트는 새 validation 규칙을 모른다. 예전에 통과하던 요청이 지금 실패하면 그게 곧 사용자한테는 알 수 없는 에러다.

예전: 닉네임 max 20자
변경: max 15자

옛 클라가 17자 닉네임으로 폼 제출
→ 갑자기 "닉네임이 너무 깁니다" 에러
→ 사용자는 영문도 모름 → 이탈

기존 제약은 풀어주는 방향만 가능. 조이는 변경은 새 엔드포인트나 새 필드로 우회한다.

3. 응답 필드의 의미는 절대 바꾸지 말기

같은 필드 이름인데 의미가 달라지면 옛 클라가 엉뚱하게 해석한다.

// 예전: status는 사용자 활성화 상태
{ "status": "active" | "inactive" }

// 변경: status에 결제 상태도 통합
{ "status": "active" | "inactive" | "payment_pending" }

옛 클라가 status === "active"로 분기하던 코드가, "payment_pending" 사용자한테 잘못된 UI를 보여준다. 의미를 바꿔야 하면 새 필드를 만든다: status는 그대로 두고 payment_status를 추가.

Enum 값을 늘릴 때도 비슷한 문제가 있다. 옛 클라가 모르는 enum 값을 받으면 switch문 default로 빠지거나, 강타입 언어면 파싱이 깨진다. 새 enum 추가 자체를 막을 순 없지만, 클라이언트에 처음부터 “모르는 enum은 unknown으로 처리” 같은 fallback을 깔아두는 게 안전하다.

4. 에러 코드와 응답 포맷의 안정성

옛 클라는 에러 코드를 보고 분기한다. 기존 코드의 의미 바꾸면 안 된다. 새 코드 추가는 OK.

응답 포맷 자체도 깨면 안 된다. { "code": "...", "message": "..." } 로 응답하던 걸 갑자기 { "error_code": "...", "error_message": "..." } 로 바꾸면 옛 클라는 파싱 자체가 깨진다.

5. 정말 못 호환할 때만 새 엔드포인트 (/v2)

위의 규칙들로도 안 되는 변경(데이터 의미가 근본적으로 달라지는 경우 등)이 있다. 그럴 땐 기존 엔드포인트를 그대로 두고 /v2/users 같은 새 걸 판다.

옛 클라는 /users 계속, 새 클라는 /v2/users. 옛 사용자가 충분히 빠지면 /users를 단순한 어댑터(내부에서 /v2/users 호출 → 옛 포맷으로 변환)로 바꿔서 코드 중복을 줄인다.

한 줄로 줄이면

추가는 자유, 변경은 신중, 제거는 금지.

PR마다 이 한 줄만 떠올려도 70%는 막힌다고 본다.

다른 방법론?

이런 버저닝도 분명히 일반적으로 많이들 사용하는 방법론이 있다고 생각했다.

그래서 한 번 정리해봤다.

Feature Flag 인프라 자체 운영

모든 새 기능을 처음부터 flag 뒤에 숨겨서 배포한다. 코드는 이미 운영에 들어가 있지만, flag가 off라 동작 안 함. 충분히 검증되면 flag를 1%, 10%, 50%, 100% 단계적으로 켠다.

문제 생기면 코드 롤백이 아니라 flag만 다시 끄면 끝. 배포 사이클이 코드 변경과 분리된다는 게 핵심이다.

이걸 1인이 만들면 사실상 Firebase Remote Config나 자체 config 엔드포인트가 그 역할을 한다.

모바일 게임 회사들 — 강제 업데이트 적극 사용

게임은 좀 다르다. 점검 + 패치 + 강제 업데이트가 거의 일상이고, 사용자도 그것에 익숙하다. “점검 후 업데이트 필요” 화면을 띄우는 게 별로 어색하지 않다.

근데 일반 앱에서 이걸 하면 사용자가 짜증 낸다. “왜 또 업데이트 해야 해” 라고. 그래서 게임 회사처럼 강제 업데이트를 자주 쓰는 전략은 우리 같은 케이스에선 안 통한다.

React Native / Flutter — OTA 적극 활용

JS 번들이나 Dart 코드만 스토어 심사 없이 푸시할 수 있다.

  • React Native: 2025년 3월에 CodePush가 sunset 되면서 지금은 EAS Update가 사실상 표준. SDK 55부터 Hermes bytecode diffing, phased rollout, 한 명령어 rollback이 다 된다.
  • Flutter: Shorebird (유료지만 잘 만들어짐)

⚠️ 한계는 명확하다. 네이티브 모듈이 안 바뀐다. JS에서 새 네이티브 API를 부르는 코드를 OTA로 푸시하면, 그 모듈이 빌드에 없을 때 그대로 크래시한다. OTA는 “버튼 색 바꾸기, 문구 수정, 작은 로직 패치” 같은 진짜 작은 변경에만 안전하다.

일반 SaaS / 웹 회사 — API Versioning을 명확히

/v1, /v2, /v3 같은 식으로 메이저 버전을 병행한다. 새 버전 나오면 옛 버전은 deprecation 정책에 따라 6개월~2년 뒤 종료. 모바일 클라이언트 없이 웹만 운영하면 이게 가장 깔끔하다.

회사에서 전에 관리했던 네이티브 앱 서비스가 이런 방식이었는데, 이것도 한계는 명확하다.

일단 엔드포인트 하나 고치려면 v1, v2, v3을 다 같이 손봐야 한다. 공통 비즈니스 로직 한 줄 바꿀 때마다 세 군데 동시에 수정 + 세 번 회귀 테스트가 따라붙는다. 시간이 미친 듯이 갈렸다.

DB도 골치다. v1은 옛 컬럼만 알고, v2는 새 컬럼만 알고, v3는 또 다른 구조를 가진다. 그 사이를 동기화하는 어댑터 코드가 어느 순간 본 로직보다 두꺼워진다. 새 기능 추가할 때 “이거 v1에도 백포트해야 하나?” 라는 질문이 매번 따라붙고, 그 답이 yes면 일은 세 배가 된다.

가장 황당한 건 deprecation이었다. “6개월 후 v1 종료합니다” 안내를 그렇게 띄웠는데도, 막상 종료 직전에 보니 사용자 상당수가 아직 v1에 매달려 있었다. 결국 종료 날짜를 또 미뤘다. 한 번도 아니고 두 번 세 번.

웹이라면 그래도 강제 종료 가능하다. 사용자 브라우저가 다음에 사이트 들어오는 순간 새 버전을 받으니까.

근데 모바일은 사용자 업데이트가 강제가 아니라서, 옛 버전을 무한히 살려둬야 하는 게 다르다.

내 방식의 개선 사항

내가 지금 쓰는 방식 위에, 비용 적게 들면서 ROI 높은 순서로 하나씩 붙일 계획이다. 한 번에 다 갈아엎지 않고, 단계별로.

참고로 min_supported_version / latest_version을 분리해서 부드러운 권유와 강제 차단을 두 카드로 나누는 건 이미 쓰고 있다. 위에서 정리한 4단계 중 그 부분만 먼저 들어가 있는 셈.

1단계. 모든 요청 헤더에 앱 버전 박기

X-App-Version: 1.2.3
X-Platform: ios
X-Build-Number: 24

서버 로그에 그대로 적재. 이게 박혀 있으면 어떤 버전이 어떤 API를 얼마나 부르는지가 보인다.

버전 분포가 보이기 시작하면 의사결정 질이 완전히 달라진다. “옛 버전 차단해도 되나?” 가 직감이 아니라 숫자로 판단 가능해진다. 비용 대비 효과가 진짜 압도적이라 이건 이번 주 안에 박을 거다.

2단계. 스토어 단계적 출시 (Staged Rollout)

비용 0. 그냥 콘솔에서 옵션만 켜면 된다.

  • Google Play: 1%, 5%, 10%, 50%, 100% 선택 가능
  • Apple App Store: phased release (7일에 걸쳐 자동 확대)

새 버전 무조건 100% 푸시 안 한다. 작은 비율로 먼저 풀고 24시간 크래시 / 오류 지표 보고 확대. 문제 생기면 중단.

이건 별도로 새 시스템 도입하는 것도 아니고, 그냥 배포 습관만 바꾸면 된다. 그래서 ROI가 좋다.

3단계. EAS Update

주주는 React Native라 이게 결국 가야 할 길이다. 다만 위에서 말한 네이티브 모듈 한계가 있어서, 정말 작은 변경(문구, 로직 패치 정도)에만 안전하게 쓸 거다.

마치며

1인으로 모바일 + 백엔드를 같이 운영할 때, 진짜 핵심은 “완벽한 시스템”이 아니라 “스스로 안 깨지는 시스템” 을 만드는 거다.

지금 내가 쓰는 min_supported_version / latest_version + 수동 호환성 체크는 사실 가장 정직한 출발점이다.

외부 의존성 없으니까 시스템 자체가 잘 안 깨진다.

다만 휴먼 에러에 취약하고, 버전 분포라는 데이터가 없어서 의사결정이 직감 기반이라는 한계가 있다.

그 한계를 메우는 길이 “더 큰 시스템 도입”이 아니라, 앱 버전 헤더 + staged rollout 같은 저비용 보강의 누적이라는 게 이번 정리의 결론이다.

Remote Config 같은 무거운 도구는 진짜 필요해지는 시점까지 굳이 안 끌어들여도 된다.

그리고 결국, 도구를 늘려도 PR마다 “옛 클라 깰까?” 라고 자문하는 습관이 무너지면 사고는 난다.

도구는 그 습관의 안전망일 뿐, 대체재는 아니다.

마침. (아니, 근데 애초에 앱 심사 시간을 좀 줄여주세요…)

다른 글 보기