-
Glide → Coil 전환 후 썸네일이 커졌다 — wrap_content ImageView가 원본 크기로 로드되는 이유카테고리 없음 2026. 8. 14. 21:57
이미지 로더를 Glide에서 Coil로 바꿨습니다. 로드하는 코드는 하나도 안 건드렸고, 래퍼 한 겹 아래에서 엔진만 갈아끼웠습니다.
그런데 목록 썸네일이 커지고 아래 텍스트가 잘린다는 제보가 들어왔습니다. 그것도 특정 기기, 특정 상품에서만요. 플래그를 끄면 멀쩡해집니다. 바뀐 게 엔진 하나뿐이니 범인은 정해져 있는 셈이었습니다.
원인은 이렇습니다. 크기를 안 정해주고 이미지를 로드할 때
wrap_contentImageView를 얼마나 크게 디코딩할지 정하는 규칙이, 두 엔진에서 정반대입니다. Coil 버그도 아니고 문서에 적혀 있는 설계입니다.확인한 버전은 Glide 4.16.0, Coil 3.0.4입니다.
세 가지가 겹쳐야 터지는 문제
시작하기 전에 하나 짚고 갈게요. 뷰를 키운 건 이미지 로더가 아니라 Android 레이아웃 시스템입니다. 로더는 그냥 좀 큰 드로어블을 넣었을 뿐입니다. 이걸 먼저 인정해야 대응 순서가 정리됩니다. 로더를 안 건드려도 레이아웃만 고치면 끝나는 문제니까요.
1. ImageView의
wrap_content는 드로어블 크기를 따라간다wrap_content는 컨텐츠가 요구하는 크기입니다. ImageView에서 컨텐츠는 드로어블이니까drawable.intrinsicWidth/Height가 그대로 뷰 크기가 됩니다. 원본 픽셀이 큰 이미지가 들어오면 뷰도 같이 커집니다.2.
minWidth는 하한이지 상한이 아니다여기서 많이들 헷갈립니다.
<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:minWidth="140dp" android:minHeight="140dp" />이건 140dp로 그리라는 뜻이 아니고 최소 140dp는 확보하라는 뜻입니다. 위로는 아무 제한이 없습니다. 드로어블이 없을 때만 140dp로 측정될 뿐이죠.
문제는 이게 고정처럼 보인다는 겁니다. 시안에도 140dp 정사각이라고 적혀 있고, XML에도 140이라는 숫자가 보이고, Glide에서 실제로 140dp로 잘 나왔으니까요. 그런데 알고 보면 아무도 크기를 고정한 적이 없습니다.
3. 뷰 dp = 비트맵 px ÷ density
px = dp * (dpi / 160)을 뒤집으면 나오는 식입니다. 같은 이미지, 같은 코드인데 기기마다 결과가 달라집니다.서버 이미지 density 3.0 (480dpi) density 2.0 (320dpi) 474px 158dp 237dp 여기에 서버 이미지 픽셀이 상품마다 다르다는 변수까지 곱해집니다. 그래서 특정 기기에서 특정 상품만 깨지는, 재현하기 참 애매한 버그가 됩니다.
Glide는 실측 뷰 크기를 기다린다
Glide 4.16.0의
ViewTarget.SizeDeterminer.getTargetDimen()을 보겠습니다.private int getTargetDimen(int viewSize, int paramSize, int paddingSize) { int adjustedParamSize = paramSize - paddingSize; if (adjustedParamSize > 0) return adjustedParamSize; // ① 고정 dp if (waitForLayout && view.isLayoutRequested()) return PENDING_SIZE; // ② wrap_content → 레이아웃 대기 int adjustedViewSize = viewSize - paddingSize; if (adjustedViewSize > 0) return adjustedViewSize; // ③ 실측 뷰 크기 ★ if (!view.isLayoutRequested() && paramSize == LayoutParams.WRAP_CONTENT) return getMaxDisplayLength(view.getContext()); // ④ 폴백 = 화면 크기 return PENDING_SIZE; }wrap_content는 ②에서 일단 물러납니다. 그리고 레이아웃이 끝난 뒤 ③에서 실제로 측정된 뷰 크기를 가져옵니다. RecyclerView 안이라면 측정 시점엔 드로어블이 아직 없으니minHeight인 140dp로 측정되고, 타깃은 420px이 됩니다. 그래서 140dp가 유지됩니다.재밌는 건 ④입니다. 어떤 경로로도 크기를 못 구했을 때 마지막으로 잡는 값이 원본이 아니라 화면 크기입니다. 왜 이렇게 만들었는지는 소스 주석에 그대로 나와 있습니다.
Target.SIZE_ORIGINAL은 coherent한 선택이지만 extremely dangerous하다 — 원본이 메모리에 안 들어가거나, 몇 장만으로 OOM이 난다.Glide는 OOM을 피하는 쪽을 기본값으로 잡고, 원본이 필요하면
.override(Target.SIZE_ORIGINAL)로 직접 말하게 합니다. 이 상황이 되면Log.i로 경고랑 대안까지 찍어줍니다.
Coil은
wrap_content를 unbounded로 본다같은 자리에 해당하는 Coil 3.0.4의
ViewSizeResolver.getDimension()입니다.private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension? { if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) { return Dimension.Undefined // ★ 첫 줄에서 즉시 탈출 } val insetParamSize = paramSize - paddingSize if (insetParamSize > 0) return Dimension(insetParamSize) val insetViewSize = viewSize - paddingSize // ← 도달하지 못한다 if (insetViewSize > 0) return Dimension(insetViewSize) return null }두 가지가 눈에 걸립니다.
하나는 실측 뷰 크기를 쓰는 코드가 아래에 분명히 있는데 그 위에서 return 되어버린다는 점입니다. Glide의 ③에 해당하는 경로가
wrap_content에서는 죽어 있는 셈입니다.다른 하나는 이 판정이
size()의 fast path에서 끝난다는 점입니다. 레이아웃을 기다리지 않습니다. "바인딩 시점에는 뷰가 아직 측정되지 않아서 그렇다"는 설명을 자주 보는데, 여기서는 해당되지 않습니다. 언제 물어봐도 답은Undefined입니다.가로세로 둘 다
wrap_content면Size(Undefined, Undefined), 즉Size.ORIGINAL이 되고DecodeUtils와MemoryCacheService가 스케일링을 건너뜁니다.size()를 부른 적도 없는데size(Size.ORIGINAL)을 지정한 것과 같아집니다.의도된 동작입니다
Dimension은 픽셀 값 또는Dimension.Undefined로, undefined/unbounded constraint를 나타낸다. 이 변경은 타깃의 한 축이 unbounded인 경우(View의WRAP_CONTENT, Compose의Constraints.Infinity)를 제대로 지원하기 위한 것이다. — Upgrading to Coil 2.x크기를 모르는 뷰라면 제약을 걸지 않는다는 게 Coil의 입장이고, 크기를 모르면 일단 안전한 값으로 줄여둔다는 게 Glide의 입장입니다. 어느 쪽이 틀린 게 아니라 기본값을 반대로 잡은 것뿐입니다.
Coil도 예전엔 Glide처럼 동작했습니다
이 부분 때문에 함정이 더 커집니다.
- 1.1.0 (2020-11-24): "Return the display size from
ViewSizeResolverif the view's layout param isWRAP_CONTENT." → Glide의 ④와 같은 동작 - 2.0.0:
Dimension타입이 들어오면서WRAP_CONTENT가Dimension.Undefined(원본)로 바뀜 - 3.x: 그대로 유지
그러니까 Glide에서 갈아탄 프로젝트만의 얘기가 아닙니다. Coil 1.x에서 2.x나 3.x로 올린 프로젝트도 이 변경을 같이 받았습니다. 릴리스 노트를 훑고 지나갔다면 놓쳤을 항목이죠.
두 엔진 비교
Glide 4.16.0 Coil 3.0.4 고정 dp ( layout_width="140dp")그 값 사용 그 값 사용 match_parent부모가 준 실측 크기 실측 크기 wrap_content레이아웃 대기 → 실측 뷰 크기 즉시 Dimension.Undefined(원본)wrap_content최후 폴백화면 크기 없음 (이미 Undefined) 레이아웃 대기 대기함 대기 안 함 원본 크기 로드 명시 요청만 ( override(SIZE_ORIGINAL))wrap_content면 기본 동작설계 철학 OOM 회피 우선 뷰가 크기를 모르면 제약 없음 경고 로그 있음 없음 나머지는 거의 같습니다.
wrap_content한 줄에서 갈립니다.
커진다고 다 잘리는 건 아니다
썸네일이 커지면 아래 텍스트가 밀립니다. 근데 밀린 게 꼭 잘림으로 이어지진 않습니다.
- 컨테이너가
wrap_content거나minimumHeight(하한)면 같이 늘어나고 안 잘립니다 - 컨테이너가
layoutParams.height로 고정돼 있으면 넘친 만큼 클리핑됩니다
이미지가 커지는 것과 컨테이너 높이가 고정된 것, 이 둘이 만나야 눈에 보이는 버그가 됩니다. 스크롤 성능이나 높이 안정성 때문에 컨테이너 높이를 계산해서 고정해 둔 화면이 제일 먼저 깨집니다. 잘 만들어둔 화면부터 터지는 셈이라 좀 억울합니다.
실제로 재본 값입니다. Galaxy S22, density 320, 같은 상품 기준입니다.
Coil ON Glide 썸네일 474×474px = 237dp 280×280px = 140dp 2행 상품명 21px 잘림 41px 정상 2행 가격 렌더 안 됨 정상 왜 2행만 잘렸냐면 별개 원인이 겹쳐 있었습니다. 컨테이너 높이 산식이
row × itemHeight + marginBottom이라 행 간격이 빠져 있었고, 그래서 1행에서 넘친 만큼은 간격 자리로 흘러가고 2행에서만 터졌습니다. 이미지 쪽을 고쳐도 남아 있는 버그라 따로 잡아야 합니다.
대응은 로더보다 레이아웃 먼저
1. 컨테이너 dp 고정
wrap_content+min*을140dp로 바꾸는 겁니다. 엔진이 뭐든, 이미지 픽셀이 얼마든, density가 뭐든 상관없어지고, 덤으로 과하게 디코딩하던 것도 사라집니다. Glide 문서에서 권장하는 방식("explicit dp sizes are set on Views")과도 같습니다. 원인이 레이아웃이니 여기서 끝내는 게 맞습니다.2. 호출부에서 크기 명시
override(w, h)나size(w, h)를 쓰는 방법입니다. 다만 이런 코드는 주의하세요.if (view.width > 0) load(url) { size(view.width, view.height) } // RecyclerView 안에서 사실상 항상 실패바인딩 시점엔
view.width가 0입니다. 조건이 안 걸리니 그대로 원본 경로로 흘러갑니다.3. 공통 래퍼에서 보정
전환 작업 중이라면 이게 제일 빠릅니다. 전역
loadImage()래퍼가 있다면 크기 미지정 +wrap_content조합일 때만 Glide처럼 뷰 크기를 기다리도록 Coil 경로를 보정하면 됩니다.getDimension은 재정의가 안 되니SizeResolver를 직접 구현하거나view.doOnLayout { size(view.width, view.height) }로 우회합니다. XML 수백 개를 다 고치기 전에 출혈부터 막는 용도입니다.4.
maxBitmapSize는 안전망기본값이 4096×4096입니다. OOM은 어느 정도 막아주지만 4096px까지는 그냥 통과하니까 레이아웃 깨짐은 못 막습니다. 크기 고정 대신 쓸 수 있는 카드가 아니고, 최후 방어선 정도로 생각하는 게 맞습니다.
Compose로 옮기면 해결되냐면, 아닙니다
Coil은 Compose의
Constraints.Infinity도 unbounded로 해석합니다. 크기 제약 없는AsyncImage는 View의wrap_content와 똑같은 상황에 놓입니다. 마이그레이션했다고 알아서 사라지는 문제가 아닙니다.
전환 전에 볼 것들
wrap_content+minWidth/minHeightImageView 전수 검색- 로더 호출 중 크기 미지정 +
wrap_content조합 찾기 - 그 조합이
layoutParams.height고정 컨테이너와 겹치는 화면부터 확인 - density 낮은 조건으로 눈으로 확인. 개발자 옵션에서 최소 너비를 키우면 재현이 잘 됩니다. 굳이 특정 기기를 구할 필요는 없습니다.
- 서버 이미지 픽셀이 상품마다 다른 화면은 아이템 여러 개로 확인
- 비트맵 할당량 비교. 원본 디코딩은 화면이 안 깨져도 메모리는 먹습니다.
엉뚱한 데를 짚기 쉽습니다
뷰 dp = 비트맵 px ÷ density인데 서버 이미지 크기까지 제각각이라, 재현 조건을 자꾸 이상한 변수로 적게 됩니다.- 특정 기기 문제처럼 보이지만 실제 변수는 density입니다
- OS나 One UI 버전 문제로 보이기도 하는데 상관없습니다. 구버전에서도 density만 낮추면 재현됩니다.
- 스토어 버전은 정상이라는 얘기는 플래그 롤아웃 대상이 아니거나 노출된 상품이 달랐던 경우입니다
재현 조건을 OS 버전으로 적어두면 QA에서 false pass, false fail이 납니다. 플래그, density, 이미지 픽셀 이 세 개를 같이 적어야 합니다.
정리
wrap_content에서 Glide는 레이아웃을 기다려 실측 뷰 크기를 쓰고, Coil은 바로 원본으로 갑니다. 문서화된 설계 차이입니다.minWidth는 하한입니다. 시안에 140dp라고 적혀 있어도 실제로는 아무도 크기를 고정하지 않은 상태일 수 있습니다.- 뷰를 키운 건 로더가 아니라 레이아웃 시스템입니다. 그래서 컨테이너 dp 고정이 근본 처방입니다.
- Coil 1.x에서 2.x로 올린 것만으로도 같은 변경을 받습니다.
- Compose로 옮겨도
Constraints.Infinity는 그대로 unbounded입니다.
- 1.1.0 (2020-11-24): "Return the display size from