버그 중에 제일 사람 헷갈리게 하는 게 “어떤 건 되고 어떤 건 안 되는” 종류예요. 전부 안 되면 차라리 원인 찾기가 쉬운데, 들쭉날쭉하면 “내가 뭘 잘못 봤나” 싶어지죠. 제 안드로이드 앱의 이름 추천 카드가 딱 그랬습니다. 카드 오른쪽에 공유·저장 아이콘을 뒀는데, 어떤 이름엔 둘 다 보이고, 어떤 이름엔 공유만, 어떤 이름엔 아주 작게, 어떤 이름엔 아예 안 보였어요.
처음엔 랜덤인 줄 알았어요. 그런데 자세히 보니 규칙이 있었습니다. 이 규칙을 발견한 순간 원인이 풀렸고, 알고 보니 화면을 그리는 도구(Jetpack Compose)의 아주 전형적인 함정이었어요. 초보가 Compose로 가로 배치를 짤 때 꼭 한 번은 밟는 지뢰라, 정리합니다.
증상 — 잘림이 ‘한자풀이 길이 순’이었다
아이콘이 보이고 안 보이고가 이름의 한자풀이 길이를 정확히 따라갔어요. 표로 보면 한눈에 들어옵니다.
| 한자풀이 길이 | 보이던 아이콘 |
|---|---|
| 짧음 | 공유 + 하트 |
| 좀 김 | 공유만 |
| 더 김 | 공유가 잘려 아주 작게 |
| 2줄 | 없음 |
풀이가 길어질수록 오른쪽 아이콘이 하나씩 밀려나다가 결국 사라졌어요. 이렇게 증상이 특정 값(여기선 텍스트 길이)에 비례하면, 원인은 십중팔구 “공간을 나눠 갖는 방식”에 있습니다. 채점으로 치면, 답이 틀리는 문제마다 공통점(숫자가 커질 때만 틀림)이 보이면 실수 지점이 좁혀지는 것과 같아요. (오늘의 수학쌤 비유는 여기까지.)
왜 그랬나 — Row가 왼쪽부터 폭을 다 가져갔다
카드 첫 줄은 가로 배치(Row) 안에 왼쪽 텍스트 묶음(Column: 이름 + 한자풀이)과 오른쪽 액션 아이콘들이 나란히 있는 구조였어요. 문제는 그 왼쪽 텍스트 묶음에 폭을 얼마나 쓸지 지정(weight)이 없었다는 거예요.
weight(가중치): 가로/세로 배치에서 남는 공간을 자식들이 비율로 나눠 갖게 하는 지정. 안 주면 그 자식은 “필요한 만큼” 다 가져간다.
Compose의 Row는 배치할 때 순서가 있어요. weight가 없는 자식을 먼저, 순서대로 측정합니다. 그래서 왼쪽 텍스트 Column이 “나는 이만큼 필요해” 하고 행 폭을 먼저 다 선점해버리고, 뒤에 오는 오른쪽 아이콘들은 남은 폭만 받았던 거예요. 풀이가 짧으면 아이콘 자리가 남고, 길면 남는 게 없어 아이콘이 잘리거나 사라졌던 거죠. 증상이 “풀이 길이 순”이었던 이유가 정확히 이거였습니다.
어떻게 고쳤나 — weight 한 줄
고친 건 정말 한 줄이었어요. 왼쪽 텍스트 Column에 “너는 남는 폭만 가져라”를 뜻하는 weight(1f)를 붙였습니다.
Column(modifier = Modifier.weight(1f)) {
// 이름 + 한자 + 한자풀이
}
이렇게 하면 Compose는 weight 없는 오른쪽 아이콘들의 폭을 먼저 확보하고, 남은 공간을 텍스트 Column에 줍니다. 순서가 뒤집힌 거예요. 이제 풀이가 아무리 길어도 아이콘 자리는 늘 보장되고, 텍스트는 남은 폭 안에서 줄바꿈되며 접힙니다. (첫 줄을 NameCardHeader라는 별도 조각으로 떼어내 정리도 같이 했어요.)
배운 것 — 테스트는 ‘크기’가 아니라 ‘위치’로
여기서 회귀(같은 버그가 되살아나는 것)를 막을 테스트를 붙이다가 하나 더 배웠어요. 처음엔 “아이콘 폭이 충분한가”로 검사하려 했는데, 그게 안 통했습니다.
Row는 폭이 모자라도 자식을 줄이지 않고 밖으로 밀어내요. 그러니 화면 밖으로 밀려나 안 보이는 아이콘도 측정된 폭은 원래 크기(48dp) 그대로입니다. “폭이 48dp 이상인가?”로는 잘린 걸 절대 못 잡는다는 뜻이죠.
레이아웃 테스트는 크기가 아니라 위치로 단언하자. 밀려난 요소도 크기는 멀쩡하니, “이 아이콘의 오른쪽 끝이 카드 폭 안에 있는가”(getUnclippedBoundsInRoot().right)를 봐야 잘림을 잡습니다. 짧은/긴/아주 긴 풀이 3가지로 테스트를 짜 두고 일부러 weight(1f)를 빼 보면, 긴 풀이 2개가 정확히 실패합니다 — 실기기 증상과 똑같은 패턴으로요.
기존에 있던 색·대비 스크린샷 테스트가 이 버그를 못 잡은 것도 같은 맥락이에요. 아이콘 색은 멀쩡했고 폭이 문제였으니까요. 무엇이 틀렸는지에 맞는 잣대로 재야 한다는 걸 다시 배웠습니다.
같은 버그가 다른 탭에서 돌아왔다
이 수정은 07-26에 PR #10으로 머지됐어요. 그런데 08-10, 새 버전을 실기기에 올려 테스트한 날 같은 증상이 다시 나왔습니다. 이번엔 「저장한 이름」 탭의 카드였어요. 오케스트레이터 AI(LC)가 저장 탭 카드의 아이콘 코드와 부모 레이아웃을 읽고, 결과 화면 카드에도 같은 버그가 있는지 확인한 뒤 발주서를 냈어요. 발주서에 적힌 증상은 «한자 뜻풀이가 긴 이름 카드에만» 공유·하트 아이콘이 안 보인다는 것이었습니다.
원인도 같았어요. 발주서는 두 줄을 나란히 적어 뒀습니다. 결과 화면 카드는 Column(modifier = Modifier.weight(1f))인데, 저장한 이름 카드는 Column {였어요. 07-26에 고친 건 결과 화면 카드뿐이었고, 저장 탭 카드는 그 카드의 사본이라 수정이 따라가지 않았던 거죠.
AI는 같은 처방을 반복했어요. 저장 탭 첫 줄을 SavedNameCardHeader로 떼어 내고 텍스트 Column에 Modifier.weight(1f)를 붙였습니다(PR #18, 7525d03). 테스트 픽스처는 뜻풀이 25자와 80자(3줄 이상) 두 길이였고, weight(1f)를 빼는 음성 검증에서는 긴 뜻풀이 2케이스가 The component is not displayed!로 실패했어요. AI 보고서는 이걸 «보고된 증상과 문자 그대로 일치»라고 적었습니다. 전체 유닛 테스트는 65개(기존 62 + 신규 3) 모두 통과했고요.
출시노트는 AI가 썼는데, 여기서도 이 사정을 반영했어요. vc8 노트에 이미 «이름 카드의 공유·저장 아이콘이 잘려 보이지 않던 문제를 고쳤습니다»가 나갔기 때문에, 이번 노트는 «저장한 이름 탭에서»를 앞에 세웠습니다. 위치를 밝히지 않으면 예전에 그 증상을 본 테스터가 이미 해결된 줄 알고 넘어갈 수 있어서요.
사본을 없애서 끝냈다
다음 날(08-11) 들어온 발주에는 두 헤더를 하나로 합치는 항목이 있었어요. AI가 두 헤더를 비교해 보니 «The two headers are byte-identical except the heart.» — 하트 아이콘 처리 하나만 빼면 똑같았습니다. 그래서 하나로 합쳤고(PR #20), 합친 뒤 weight(1f)를 다시 빼 봤더니 이번엔 한 번의 수정으로 두 테스트 클래스가 함께 깨졌어요. 사본이 따로 놀다 한쪽만 고쳐지는 일이 구조적으로 막힌 거예요.

그래서 뭘 하면 되나
Compose로 가로 배치를 짜다 요소가 잘리면, 이 순서로 보세요.
| 신호 | 판단 | 액션 |
|---|---|---|
| 잘림이 텍스트 길이에 비례 | 🔴 폭 배분 문제 | 유연하게 늘 텍스트에 weight(1f) |
| 고정 크기 요소(아이콘)가 밀려남 | 🟡 측정 순서 탓 | weight로 남는 폭만 텍스트에 |
| “폭 충분한가” 테스트가 통과 | 🔴 크기론 잘림 못 잡음 | 위치(경계)로 단언 |
| 색 스크린샷 테스트만 있음 | 🟡 치수 사각지대 | 레이아웃 치수 테스트 보강 |
한 문장 요약: Compose Row에서 요소가 잘리면 십중팔구 유연한 자식에 weight가 빠진 것이다 — 늘 텍스트에 weight(1f)를 주고, 잘림 테스트는 크기가 아니라 위치(경계가 부모 안인지)로 단언하라.
이 버그가 남긴 교훈은, 화면 배치도 결국 “누가 먼저 공간을 갖느냐”의 순서 문제라는 거였어요. weight 한 줄이 그 순서를 뒤집어 아이콘 자리를 지켜줬고요. 눈에 보이는 게 전부인 UI일수록, 안 보이게 만든 규칙 하나를 찾는 게 핵심이더라고요.