약국 찾기 앱의 목록 화면이 LazyColumn 키 중복으로 죽었는데, 그날 저는 앱을 한 줄도 바꾸지 않았습니다. 바뀐 것은 앱이 받아 오는 데이터였고, 수집 스크립트가 서로 다른 구의 약국 두 곳에 같은 id를 붙여 내보낸 것이 원인이었습니다. 겹친 약국들은 기본 화면에는 나오지 않고 '전체' 목록의 맨 아래에만 있어서, 제 폰으로 평소처럼 열어 보면 아무 일도 없었습니다. 데이터 쪽 가드와 앱 쪽 방어를 둘 다 둔 이유와, 지우지 않고 꼬리를 붙인 이유를 정리합니다.
증상 — 사용자 기기에서만 목록이 죽는다
2026년 10월 6일, 출시해 둔 0.5.1 버전에서 치명 크래시가 잡혔습니다. 기기는 SM-A235F였고 메시지는 이랬습니다.
java.lang.IllegalArgumentException: Key "L033084000019" was already used.
If you are using LazyColumn/Row please make sure you provide a unique key for each item.
목록은 약국 id를 키로 씁니다. 실제 코드도 이렇게 단순합니다.
items(shown, key = { it.p.id }) { r -> PharmacyRow(r, state, onOpen, onCall) }
코드만 보면 틀린 곳이 없습니다. id는 약국마다 고유하다는 전제였고, 그 전제는 앱이 아니라 서버 쪽 데이터가 지키고 있었습니다. 그런데 그 데이터는 앱 배포 없이 바뀝니다. 수집 스크립트가 공공데이터를 받아 묶음 파일을 만들고, 앱은 실행할 때 그 파일을 받아 갑니다. 그러니 스토어에 나간 바이너리는 그대로인데 어느 날부터 목록이 죽을 수 있습니다.
원인 — id를 끝 12자리로 잘랐다
약국 데이터는 두 출처를 합칩니다. 그중 한쪽에만 있는 약국에는 인허가 데이터의 관리번호로 id를 만들었는데, 숫자만 뽑은 뒤 끝 12자리만 썼습니다.
'id': 'L' + re.sub(r'\D', '', r.get('관리번호', ''))[-12:],
관리번호는 앞에서부터 연도, 개방자치단체코드, 일련번호 순으로 이어집니다. 끝 12자리에는 자치단체 자리가 들어가지 않습니다. 그래서 다른 구에 있는 약국끼리 일련번호 부분이 같으면 id가 같아졌습니다. 그날 발행된 묶음에서 세어 보니 겹친 id가 96종, 걸린 레코드가 362건이었습니다.
| 무엇이 | 왜 안 걸렸나 |
|---|---|
| 앱 코드 | 바뀐 것이 없다. 키를 id로 쓰는 것 자체는 맞다 |
| 단위 테스트 | 테스트용 레코드는 id가 전부 달랐다 |
| 내 폰에서 열기 | 기본 화면은 '지금 영업 중'이다. 겹친 약국은 여기 안 나온다 |
| '전체' 목록 | 겹친 약국은 전부 운영시간 정보가 없는 약국이라 맨 아래에 모여 있다. 끝까지 내려야 닿는다 |
| 데이터 수집 검사 | 좌표 범위·시도 누락 같은 검사는 있었지만 id 중복 검사는 없었다 |
겹친 레코드는 전부 한쪽 출처에만 있는 약국, 곧 운영시간을 모르는 약국이었습니다. 이 앱은 운영시간을 모르는 곳을 '영업 확인 필요'로 목록 맨 아래에 둡니다. 기본 화면에서는 아예 빠지고, '전체'를 골라도 한참 내려야 나옵니다. 제가 확인할 때 늘 보던 경로로는 그 자리에 닿지 않았던 것입니다. 나중에 실기기 확인 절차에 이 항목을 넣으면서 "'지금 영업 중'만 보면 통과한다"는 경고를 같이 적어 두었습니다.
제 경우 '전체' 목록을 끝까지 스크롤하는 확인 절차를 넣은 뒤에야 그 자리를 실제로 지나가게 됐습니다. 화면 위쪽만 보고 넘어가면 키가 겹친 데이터를 들고 있어도 멀쩡해 보입니다.
해결 1 — 데이터 쪽: id를 전체 자릿수로, 그리고 발행 전 가드
먼저 원인을 고쳤습니다. 자르지 않고 숫자 전체를 씁니다. 전체 자릿수로 바꾼 뒤 같은 날 영업 중인 25,895행을 다시 세었을 때 중복은 0이었습니다.
def mois_id(r):
"""S2 에만 있는 약국의 id — 'L' + 관리번호의 숫자 전체.
2026-10-06 까지는 끝 12자리만 썼다. 끝 12자리에는 자치단체가 안 들어간다."""
return 'L' + re.sub(r'\D', '', r.get('관리번호', ''))
고치는 것만으로는 부족해서, 발행 전에 도는 검사 목록에 id 중복을 넣었습니다. 하나라도 겹치거나 비어 있으면 그 묶음은 발행하지 않습니다.
# 앱이 id 를 목록 key·즐겨찾기 키로 쓴다 — 겹치면 앱이 죽는다(2026-10-06).
dup = [k for k, c in collections.Counter(r['id'] for r in recs).items()
if c > 1 or not k or k == 'L']
add('unique_ids', not dup, len(dup) if len(dup) > 5 else dup, 'id 중복·빈 id 0건')
k == 'L'까지 잡는 것은 관리번호가 비어 있으면 id가 접두어 L 한 글자만 남기 때문입니다. 그런 레코드가 둘 이상이면 그것도 같은 키입니다.
대가도 있었습니다. 이 방식으로 만든 id 563건이 한 번 바뀌었습니다. id는 즐겨찾기 키이기도 해서, 그 약국을 즐겨찾기해 둔 사용자는 다시 담아야 합니다. 그래도 목록이 죽는 것보다는 낫다고 판단했습니다.
해결 2 — 앱 쪽: 지우지 말고 꼬리를 붙인다
데이터를 고쳐도 앱 쪽 방어가 따로 필요했습니다. 이유는 두 가지입니다. 이 앱은 받기에 실패하면 마지막으로 저장해 둔 묶음으로 물러나는데, 그 옛 캐시에는 중복이 그대로 들어 있습니다. 그리고 데이터는 앞으로도 앱 배포 없이 바뀝니다. 다음 데이터 사고가 또 목록을 죽이게 둘 수는 없었습니다.
/**
* 같은 id 가 두 번 오면 뒤엣것에 꼬리(`~2`)를 붙인다. id 는 목록 key·지도 마커·즐겨찾기 키다.
* 지우지 않는다 — 겹친 것은 서로 다른 약국이라 하나를 버리면 실제 약국이 사라진다.
*/
internal fun uniqueIds(list: List<Pharmacy>): List<Pharmacy> {
val seen = HashMap<String, Int>()
return list.map { p ->
val n = (seen[p.id] ?: 0) + 1
seen[p.id] = n
if (n == 1) p else p.copy(id = "${p.id}~$n")
}
}
fun region(body: String): List<Pharmacy> {
val o = envelope(body, "pharmacies")
val arr = o.getJSONArray("pharmacies")
return uniqueIds((0 until arr.length()).map { pharmacy(arr.getJSONObject(it)) })
}
화면이 아니라 파싱 단계에 둔 것이 요점입니다. 키 중복을 LazyColumn 앞에서만 막으면 지도 마커나 즐겨찾기처럼 같은 id를 쓰는 다른 곳은 여전히 겹친 값을 받습니다. 받아 오는 순간 한 번 고유하게 만들어 두면 그 뒤로는 어디서 쓰든 같습니다.
흔히 보이는 해법은 distinctBy { it.id }로 중복을 지우는 것입니다. 이 경우에는 틀린 답이었습니다. 겹친 두 레코드는 이름도 주소도 다른 약국이었고, 하나를 지우면 실제로 문을 연 약국이 목록에서 사라집니다. 첫 번째는 원래 id를 그대로 두었는데, 즐겨찾기 키가 바뀌지 않게 하려는 것입니다.
같은 자리, 다른 판단 — 지역 코드는 지운다
이틀 뒤 같은 패턴이 남은 곳을 찾다가 지역 목록도 지역 코드를 키로 쓰는 것을 봤습니다. 거기서는 반대로 앞엣것만 남기도록 했습니다. 이건 실제 사고가 난 곳이 아니라 예방으로 넣은 것입니다.
// 같은 지역 코드가 두 번 오면 앞엣것만 쓴다. 코드는 지역 목록의 LazyColumn key 라,
// 겹치면 약국 목록이 0.5.1 에서 죽은 것과 같은 자리에서 죽는다.
return DataIndex(o.getInt("schema"), o.optString("generated_at"), regions.distinctBy { it.code })
| 키 | 겹친 두 줄은 | 처리 | 이유 |
|---|---|---|---|
| 약국 id | 서로 다른 약국 | 뒤엣것에 ~2 꼬리 | 지우면 실제 약국이 사라진다 |
| 지역 코드 | 같은 지역 | 앞엣것만 남김 | 코드가 파일 선택·저장된 지역의 키라 이름을 바꾸면 조회가 끊긴다. 버려도 잃는 것이 없다 |
중복을 어떻게 처리할지는 키가 아니라 겹친 두 줄이 같은 것인지가 정합니다. 둘을 같은 함수로 묶지 않은 이유가 이것입니다.
확인 방법
단위 테스트 — 실제 사고 모양 그대로
서로 다른 약국 셋에 같은 id를 주고, 셋이 다 남는지와 id가 갈리는지를 봅니다. 레코드를 만드는 도우미 함수는 줄였습니다.
// 2026-10-06 실제 사고 — 서로 다른 약국 셋이 같은 id 로 왔고 목록이 죽었다.
@Test fun duplicate_ids_are_kept_but_made_unique() {
val ps = Parse.region(region(rec("삼성중앙약국"), rec("압구정굿모닝약국"), rec("한강바로약국"), record))
assertEquals(4, ps.size)
assertEquals(4, ps.map { it.id }.toSet().size)
assertEquals("L033084000019", ps[0].id) // 첫 번째는 그대로 — 즐겨찾기 키가 안 바뀐다
assertEquals("C1110651", ps[3].id) // 안 겹친 것은 건드리지 않는다
}
수집 스크립트 쪽에도 끝 12자리가 같고 자치단체 자리만 다른 두 관리번호가 서로 다른 id를 받는지, 가드가 중복을 실패로 내는지를 테스트로 박아 두었습니다.
실기기 — 겹친 자리까지 직접 간다
릴리스 빌드로 서울 강남구를 고른 뒤 '전체' 545곳을 끝까지 스크롤했습니다. 죽지 않았고, 맨 아래 운영시간 없는 약국 하나를 열어 즐겨찾기에 담은 뒤 즐겨찾기 1건으로 나오는 것까지 봤습니다. 이 확인은 이후 배포 때마다 같은 절차로 다시 돌립니다.
이미 깔린 옛 버전
고친 0.5.2가 나가도 0.5.1을 쓰는 사람은 남습니다. 그래서 다음 날 원격 설정으로 최소 버전코드를 고친 버전으로 올려, 옛 버전을 연 사용자에게 업데이트 안내가 뜨게 했습니다. 버전코드만 15로 바꾼 빌드로 안내가 뜨는 것, 16에서는 안 뜨는 것을 실기기로 대조했습니다. 닫을 수 있는 안내라 강제는 아닙니다.
자주 묻는 것
key를 아예 빼면 이 크래시는 안 나지 않나요?
안 납니다. 키를 주지 않으면 위치(인덱스)로 항목을 구분하니까요. 대신 목록 순서가 바뀔 때 항목 상태가 엉뚱한 줄로 옮겨 갈 수 있습니다. 이 앱처럼 30초마다 목록을 다시 계산하는 화면에서는 키를 빼는 게 증상을 덮는 것에 가깝다고 봤고, 키는 그대로 두고 키가 고유해지게 고쳤습니다.
key = { "${it.id}-$index" } 처럼 인덱스를 섞으면요?
크래시는 막지만 키가 위치에 묶여 키를 쓰는 의미가 반쯤 사라집니다. 더 큰 문제는 중복이 생겼다는 사실이 아무 데도 남지 않는다는 점입니다. 저는 데이터 쪽 가드에서 중복을 발행 실패로 드러내고, 앱 쪽은 그래도 새어 들어온 것만 조용히 받아 내도록 나눴습니다.
서버 데이터를 믿지 말라는 얘기인가요?
제 경우는 데이터를 만드는 쪽도 저였습니다. 그래도 터졌습니다. 앱 배포와 데이터 갱신의 주기가 다르면, 앱이 의존하는 전제(id는 고유하다)는 앱 바깥에서 깨질 수 있습니다. 그 전제를 데이터 쪽에서 검사하고, 앱이 받는 지점에서 한 번 더 막는 것이 이번에 얻은 결론입니다.
LazyRow나 LazyVerticalGrid도 같은가요?
메시지에 LazyColumn/Row가 함께 적혀 있듯이 키를 받는 Lazy 목록은 같은 규칙을 따릅니다. 제가 직접 겪은 것은 LazyColumn 두 곳(약국 목록, 지역 목록)입니다.
