비즈니스 현실14 분 읽기

제가 고객에게 "설계상 그렇습니다"라고 답했고, 그건 데이터 손실이었습니다

환불 요청에 "하루 한 건이 설계입니다"라고 답했습니다. 코드를 열어보니 설계가 아니라 버그였습니다. 체크인 화면이 항상 빈 상태로 열려서, 저녁에 저장하면 아침에 기록한 값 위에 0을 덮어쓰고 있었습니다. 스트릭은 계속 초록불이었습니다.

#support#data-loss#postgres#reality-check#gotchas#onboarding
왼쪽 패널은 내가 고객에게 보낸 답변 - 하루 한 건, 설계상 그렇다, 다시 저장하면 교체된다, 이 앱은 맞지 않는다. 오른쪽 패널은 코드가 실제로 한 일 - 폼이 모든 증상을 0으로 초기화하고 하루 유니크 행에 upsert 해서 아침의 hot_flash 3·sleep 4가 저녁에 hot_flash 0·sleep 3으로 바뀌었으며 스트릭은 초록불로 남았다
왼쪽은 제가 확신을 갖고 보낸 문장, 오른쪽은 같은 시각 코드가 하던 일입니다.

오늘 낮에 유료 구독자 한 명의 온보딩 버그를 고쳤습니다. 그 부검은 따로 썼습니다. 고치고, 확인하고, 사과하고, 답장을 보냈습니다. 저는 건이 닫혔다고 생각했습니다.

몇 시간 뒤에 회신이 왔습니다. 환불해 주세요. 이 앱은 제가 원하는 걸 못 합니다.

원했던 것은 이것이었습니다 — 하루 중 일어나는 대로 그때그때 기록하기. 아침에 안면홍조, 오후 2시에 또 한 번, 저녁 8시에 불안. 그게 이 앱의 존재 이유입니다. 몸에 뭐가 일어났는지 그날 적어두고, 몇 달 뒤에 패턴으로 보는 것.

저는 이렇게 답했습니다.

Sage는 하루에 한 건을 기록합니다. 같은 날 다시 저장하면 그날의 기록을 교체합니다. 하루 중에 그때그때 추가하는 건 현재 버전에서 불가능합니다. 스트릭과 주간 뷰가 둘 다 하루 한 건 기준으로 만들어져 있어서, 켜드릴 수 있는 설정이 아닙니다.

그렇게 추적하고 싶으시다면 Sage는 맞는 앱이 아니고, 환불 요청은 타당합니다.

그리고 애플 환불 절차를 정성껏 안내했습니다. 정중하고, 명확하고, 도움이 되는 답장이었습니다. 그리고 틀렸습니다.

여기서 하나 묻고 싶습니다. 당신이 마지막으로 고객에게 "그건 설계상 그렇습니다"라고 답한 건 언제입니까? 그 답을 보내기 전에 코드를 열어봤습니까? 저는 안 열어봤습니다.

1. 코드를 열자 설계가 아니었습니다

답장을 보낸 뒤에 마음이 불편해서 저장 경로를 읽었습니다. 두 파일이면 끝나는 확인이었습니다.

// CheckInViewModel.swift
init(symptoms: [String]) {
    self.symptoms = symptoms
    self.intensities = Dictionary(uniqueKeysWithValues: symptoms.map { ($0, 0) })
}
@Published var sleepQuality: Int = 3
@Published var selectedTags: Set<String> = []

체크인 화면은 오늘 이미 기록한 것을 불러오지 않습니다. 언제 열든 모든 증상 강도가 0, 수면 품질은 기본값 3, 태그는 빈 집합으로 시작합니다.

그리고 저장은 이렇게 나갑니다.

// SupabaseService.swift
try await client.schema("sage")
    .from("symptom_logs")
    .upsert(SaveSymptomLogBody(
        userId: log.userId.uuidString,
        loggedAt: log.loggedAt,      // "yyyy-MM-dd"
        symptoms: log.symptoms,       // 지금 화면에 있는 값 전체
        sleepQuality: log.sleepQuality,
        tags: log.tags
    ))
    .execute()

테이블은 하루 한 행으로 못 박혀 있습니다.

CREATE TABLE IF NOT EXISTS sage.symptom_logs (
  ...
  logged_at date NOT NULL DEFAULT current_date,
  symptoms  jsonb NOT NULL DEFAULT '{}',
  UNIQUE(user_id, logged_at)
);

세 조각을 붙이면 이렇게 됩니다.

  • 아침 8시: 안면홍조 3, 수면 4 저장 → 행 생성
  • 저녁 8시: 같은 화면을 열면 빈 폼. 불안 2만 체크하고 저장
  • upsertUNIQUE(user_id, logged_at)에 걸려 행 전체를 교체 → 안면홍조 3 → 0, 수면 4 → 3(기본값), 태그 → []

즉 제가 "교체합니다"라고 쓴 건 말이 너무 부드러웠습니다. 앱은 아침에 사용자가 실제로 겪은 것 위에 0을 덮어쓰고 있었습니다. 지운 게 아니라 "그런 일 없었음"으로 바꿔 쓴 겁니다. 증상 추적 앱이 할 수 있는 최악의 동작에 가깝습니다.

2. 앱은 그 사실을 성공이라고 표시했습니다

이 부분이 저를 가장 오래 붙잡았습니다. 데이터가 파괴되는 순간, 화면의 모든 신호는 초록불이었습니다.

isSaved = true
StreakService.shared.recordCheckIn()
requestReviewIfNeeded()
  • isSaved = true — 저장 성공 표시
  • recordCheckIn() — 스트릭 유지. "오늘도 기록했습니다"
  • requestReviewIfNeeded()방금 데이터를 지운 직후에 앱스토어 리뷰를 요청합니다

대시보드도 공범입니다.

func intensityForDate(_ dateStr: String, symptom: String) -> Int {
    recentLogs.first { $0.loggedAt == dateStr }?.symptoms[symptom] ?? 0
}

값이 없으면 0을 돌려줍니다. 그래서 0으로 밀린 날과 원래 증상이 없던 날이 화면에서 완전히 똑같이 보입니다. 히트맵도, 스트릭도, 주간 뷰도 아무 이상을 보고하지 않습니다. 같은 파일에 이런 것도 있습니다.

do {
    recentLogs = try await SupabaseService.shared.fetchRecentLogs(...)
    latestInsight = try await SupabaseService.shared.fetchLatestInsight(...)
} catch {}

catch {}. 조회가 실패해도 목록이 빈 채로 화면이 그려집니다. "데이터 없음"과 "가져오기 실패"가 구분되지 않습니다. 초록불이 아무 증거도 아니라는 것을 저는 CI에서 이미 한 번 배웠는데, 앱 안에서 같은 짓을 하고 있었습니다.

3. 그래서 제가 고객에게 틀린 것은 세 군데였습니다

정리하면 이렇습니다.

  1. "설계상 그렇습니다" — 설계가 아니라 폼 프리필 누락 + 전체 행 upsert. 즉 버그.
  2. "교체합니다" — 실제로는 사용자가 입력한 값 위에 0과 기본값을 씀. 교체가 아니라 파괴.
  3. "Sage는 맞는 앱이 아닙니다" — 버그를 근거로 유료 고객에게 떠나라고 권했습니다. 이게 셋 중 가장 나쁩니다. 저는 제 버그를 제품의 한계로 포장해서 고객을 내보냈습니다.

세 번째가 왜 가장 나쁜가 하면, 앞의 둘은 코드를 안 읽은 실수인데 세 번째는 그 잘못된 전제로 상대의 결정을 유도한 것이기 때문입니다. 그분은 제 설명을 신뢰하고 환불로 갔습니다. 신뢰한 설명이 틀렸습니다.

4. 당신이라면 어느 쪽을 먼저 고치겠습니까?

수정 방향은 둘로 갈립니다.

  1. 하루 한 행 유지 + 병합. 화면 열 때 오늘 행을 불러 프리필하고, 저장은 기존 값에 더한다. 스키마 그대로, 앱만 배포. 하루 안에 나갈 수 있음.
  2. 행 구조 변경. 기록마다 발생 시각(logged_time)을 가진 별개 행으로 쪼개고, 일간·주간 집계가 합산하게 한다. "오후 2시 안면홍조"와 "저녁 8시 불안"이 각각 남는 진짜 모델. 대신 스트릭·주간 패턴·AI 인사이트 계산이 전부 바뀜.

저는 1번을 먼저 냈습니다. 심사에 올렸고, 통과하면 평범한 앱스토어 업데이트로 나갑니다. 2번이 그분이 애초에 원한 모델이지만, 그건 며칠이 걸리고 그 사이에도 매일 저녁 데이터가 계속 지워집니다. 반쯤 맞는 것을 지금 멈추는 게 완전히 맞는 것을 다음 주에 내는 것보다 낫다고 판단했습니다. 데이터 파괴는 대기시킬 항목이 아닙니다.

5. 진짜 교훈은 코드가 아니라 답장에 있습니다

이 글에서 남길 게 하나라면 버그가 아닙니다. 제가 확신을 갖고 보낸 지원 답장이 버그 보고서였다는 것입니다.

저는 제 앱을 만든 사람입니다. 그러니 "이건 하루 한 건 구조야"를 기억으로 알고 있었습니다. 그 기억은 스키마(UNIQUE(user_id, logged_at))와는 일치했고, 실제 동작(빈 폼 + 전체 upsert)과는 일치하지 않았습니다. 스키마를 아는 것과 코드 경로를 아는 것은 다릅니다. 그 차이가 정중한 오답 한 통이 됐습니다.

지원 답장은 발행물입니다. 저는 대시보드 숫자를 쓸 때는 카피의 입력이 코드에 있는지 대조하는데, 고객 메일에는 그 규율을 안 걸고 있었습니다. 지금은 규칙을 하나 걸었습니다. "설계상 그렇습니다"라고 쓰기 전에 그 설계를 구현한 줄을 열어서 붙일 수 있어야 한다. 못 붙이면 그건 설계가 아니라 추측입니다.

6. 솔직한 부분

환불은 그분 것입니다. 안내 그대로 진행하시라고 했고, 재고를 권하지 않았습니다. 대신 하나 덧붙였습니다 — 환불이 승인돼서 접근 권한이 사라지면 메일 한 통 주시면 제가 무료로 복구해 드립니다. 권한 플래그는 제가 세우는 것이라 그건 제가 지킬 수 있는 약속입니다. 돈을 돌려받는 것과 앱을 계속 쓰는 것이 양자택일일 필요는 없습니다.

그리고 이 결말은 아직 열려 있습니다. 마지막 메일에는 아직 회신이 없습니다. 이 글을 쓰는 지금 시점에서 결과를 모릅니다. 그분이 업데이트를 기다려 볼지, 그냥 떠날지 모릅니다. 어느 쪽이든 정당합니다.

한 가지는 분명합니다. 오늘 이 앱에서 나온 실제 수정 두 건은 둘 다 한 사람이 조용히 지우지 않고 두 번 메일을 썼기 때문에 나왔습니다. 대시보드는 두 번 다 초록불이었습니다. 스트릭도 계속 올라갔습니다. 저에게 사용자가 몇 명 있는지는 지표가 알려줬지만, 그 앱이 실제로 뭘 하고 있었는지는 화가 난 고객 한 명만 알려줬습니다.

자가진단 — 3개만 확인하십시오

  1. 오늘 하루 안에 두 번 저장되는 화면이 있습니까? 그 화면이 열릴 때 기존 값을 불러옵니까? 안 불러오면서 전체 레코드를 upsert/PUT 하고 있다면, 지금 데이터를 지우고 있는 중입니다.
  2. "값 없음"과 "0"을 화면에서 구분할 수 있습니까? ?? 0, || 0, .get(k, 0)으로 렌더링하면 파괴된 데이터가 정상으로 보입니다.
  3. 최근 지원 답장 3통을 열어, 단정한 문장마다 근거 코드 줄을 붙여 보십시오. 하나라도 못 붙이면 그건 추측을 고객에게 사실로 보낸 것입니다.

결론

앱의 버그는 한 시간 만에 고쳤습니다. 고치기 오래 걸린 건 제가 아는 걸 안다고 믿은 습관입니다.

지금 딱 하나만 하십시오. 당신이 이번 주에 보낸 지원 답장에서 "설계상", "의도된 동작", "그건 원래 그렇습니다"를 검색해 보십시오. 그리고 그 문장 하나를 코드로 확인해 보십시오. 저는 그걸 안 해서, 유일한 유료 고객에게 떠나라고 권했습니다. 결과가 어땠는지 알려주시면 좋겠습니다 — 특히 확인해 보니 틀렸던 경우에요.

관련 글