시작하며
안녕하세요. 레브잇 Frontend Engineer 유건입니다.
이번 글에서는 올웨이즈 앱이 백그라운드에서 조용히 종료되던 문제를 추적하다가, 뜻밖에 상품 이미지 한 장의 크기에서 답을 찾게 된 과정을 공유하려고 합니다.
결제처럼 잠시 외부 화면을 다녀오면 앱이 홈 화면부터 다시 시작되는 경우가 있었습니다. 크래시도, JavaScript 오류도 아니었습니다. Android가 메모리 압박 상황에서 백그라운드에 있던 앱 프로세스를 종료한 것이었습니다.
앱 전체의 메모리는 여러 요소가 함께 만들고 있었습니다. React Native 런타임, 여러 WebView, 그래픽 리소스, 외부 SDK를 한 번에 바꿀 수는 없었습니다. 그래서 저희는 먼저 적은 변경으로, 검증 가능한 메모리를, 가장 많이 줄일 수 있는 지점을 찾기로 했습니다. 그 과정에서 WebView 안의 상품 이미지가 화면에 필요한 크기보다 훨씬 크게 디코드되고 있다는 사실을 발견했습니다.
서버에서 슬롯 크기에 맞춘 이미지를 내려주도록 바꾼 결과, 홈 화면의 이미지 디코드 총량은 40.3MB에서 21.1MB로 48%, 대형 이벤트 화면은 264.4MB에서 168.3MB로 36% 줄었습니다. 같은 구간의 WebView 렌더러 메모리(RSS)도 약 10% 내려갔습니다.
재미있는 점은 최적화 전 이미지도 이미 WebP였다는 것입니다. 이 문제를 풀면서 저희는 이미지의 “파일 크기”와 “메모리 크기”가 전혀 다른 문제라는 것을 배웠습니다.
첫 번째 가설: WebView가 많아서 무거운 걸까?
처음에는 앱에 떠 있는 WebView의 개수가 원인이라고 생각했습니다. 커머스 앱에서는 홈, 기획전, 상품 상세처럼 이미지가 많은 화면이 이어지고, 일부 화면은 빠른 탭 전환을 위해 마운트된 상태로 남아 있습니다. WebView 수만 줄이면 문제가 바로 해결될 것처럼 보였습니다.
하지만 비교 측정 결과는 이 가설과 맞지 않았습니다. WebView를 더 많이 유지하면서도 메모리는 더 적게 쓰는 앱도 있었습니다. 개수만으로는 프로세스의 무게를 설명할 수 없었습니다.
그래서 질문을 바꿨습니다.
WebView가 몇 개인지가 아니라, 그 안에 무엇이 얼마나 크게 올라가 있는가?
화면별로 메모리를 나눠 보니 상품 카드가 많은 홈과 이벤트 화면에서 이미지 픽셀 총량이 유독 컸습니다. 130~160 CSS px 너비로 표시되는 카드에 800×800 상품 원본이 그대로 내려오고 있었습니다.
두 번째 가설: WebP라면 이미지 메모리도 작지 않을까? 🤔
네트워크 패널에서 본 상품 이미지 한 장은 약 135KB였습니다. 수십 장을 합쳐도 앱 전체의 메모리 문제를 설명하기에는 작아 보였습니다. 그러나 이것은 네트워크에서 전송되는 압축 파일의 크기였습니다.
브라우저가 이미지를 화면에 그리려면 WebP를 픽셀 데이터로 펼쳐야 합니다. 일반적인 8비트 RGBA 픽셀 버퍼의 크기는 대략 다음과 같습니다.
디코드 픽셀 비용 ≈ 가로 픽셀 × 세로 픽셀 × 4바이트800×800 이미지라면 계산 결과는 약 2.44MiB입니다.
800 × 800 × 4 ÷ 1024 ÷ 1024 ≈ 2.44MiB
전송할 때는 135KB였던 WebP가 디코드된 뒤에는 약 2.44MiB의 픽셀 데이터가 될 수 있습니다. 포맷을 WebP로 바꾸거나 품질을 낮추면 다운로드 크기는 줄지만, 가로와 세로가 그대로라면 픽셀 수는 줄지 않습니다.

실제 상주 메모리는 브라우저의 래스터 스케일 최적화, 색 공간, GPU 업로드, 캐시 퇴거 정책에 따라 달라집니다. 따라서 이 계산은 “현재 프로세스가 정확히 이만큼을 사용한다”는 값이 아니라, 원본 크기로 펼쳤을 때의 디코드 비용 상한입니다. 저희는 이 값을 낭비가 큰 이미지 그룹을 찾는 지표로 사용하고, 실제 프로세스 변화는 렌더러 RSS로 함께 검증했습니다.
이 지점에서 두 번째 가설도 수정됐습니다.
WebP는 전송 최적화의 답이지만, 디코드 메모리까지 줄이려면 이미지의 픽셀 수를 줄여야 한다.
이미지 캐시는 하나가 아니었습니다
리뷰에서는 “이미지는 한 번 받으면 캐시되니 괜찮지 않을까?”라는 질문이 나왔습니다. 답은 캐시를 층별로 나눠 보면 보입니다.

HTTP 캐시가 들고 있는 것은 45KB짜리 압축 파일입니다. 화면에 그리려면 여전히 0.88MiB의 픽셀 데이터가 필요하고, 이 픽셀은 디코드 캐시와 GPU 리소스에 올라갑니다. 이 층은 Chromium이 메모리 예산에 따라 관리하므로 Cache-Control로 수명을 정할 수 없습니다. 저희가 통제할 수 있는 가장 확실한 값은 브라우저에 전달하는 이미지의 픽셀 수였습니다.
그런데 180px 슬롯에 180px 이미지를 보내면 흐렸습니다 👀
그렇다면 CSS에서 180px로 보이는 이미지에는 180px 원본을 보내면 될까요? 모바일에서는 그렇지 않습니다. CSS 픽셀과 실제 디스플레이 픽셀 사이에는 DPR(Device Pixel Ratio) 이 있기 때문입니다.
목표 소스 폭 ≈ CSS 슬롯 폭 × DPR180 CSS px 슬롯을 예로 들면 DPR 2 기기에는 360px, DPR 3 기기에는 540px 정도의 소스가 필요합니다. 180px 소스를 그대로 보내면 고밀도 화면에서 브라우저가 이미지를 확대하므로 선명도가 떨어집니다.

같은 180 CSS px 슬롯이라도 기기마다 필요한 픽셀 수가 다릅니다.
여기서 저희는 모든 기기와 슬롯에 정확히 맞는 크기를 무한히 만들지 않았습니다. 크기 변형이 지나치게 많으면 CDN과 브라우저의 캐시 키가 조각나고, 저장 공간과 이미지 변환 비용이 늘기 때문입니다. 실제 카드 폭과 주요 사용 기기의 화면 밀도를 바탕으로 소수의 이미지 폭 버킷을 정했습니다.
주요 상품 카드에는 480px을 선택했습니다. 대상 카드의 실제 폭은 주로 132~160 CSS px였고, DPR 3 기준으로 396~480 디바이스 픽셀이 필요했습니다.
400px도 후보였습니다. 한 장당 디코드 비용을 약 0.27MiB 더 줄일 수 있었기 때문입니다. 그러나 고DPR 기기의 520px 슬롯에서 400px 이미지는 1.30배 확대됐습니다. 원래 크기에서는 차이가 작지만, 확대해 보면 작은 글씨의 획이 480px보다 뭉개졌습니다. 480px은 확대를 1.08배 수준으로 낮췄습니다.


장당 0.27MiB를 더 사용해 고DPR 기기의 화질을 지키는 것이 저희가 선택한 균형점이었습니다. 가장 작은 숫자가 아니라, 메모리와 화질 그리고 캐시 적중률을 함께 만족하는 숫자를 골랐습니다.
첫 구현은 완전하지 않았습니다
처음에는 측정한 화면에 보이는 컴포넌트 네 곳만 수정했습니다. 변경은 단순했습니다. 원본 URL을 바로 <img>에 넘기던 코드를 기존 이미지 리사이즈 유틸을 거치도록 바꿨습니다.
// Before
<img src={product.imageUrl} />// After
<img src={resize(product.imageUrl, 480)} />하지만 리뷰에서 “같은 패턴이 다른 상품 카드에도 남아 있지 않은가?”라는 질문이 나왔습니다. 전체 <img> 사용처를 다시 조사하자, 측정 화면 밖에 동일한 문제가 있는 카드가 더 발견됐습니다.
여기서 첫 번째 교훈을 얻었습니다.
성능 문제에서 측정 화면은 전수가 아니다. 원인을 확인한 다음에는 같은 데이터 흐름을 사용하는 전체 경로를 조사해야 한다.
전수 조사 과정에서는 반대의 실수도 찾았습니다. 일부 컴포넌트는 공용 hook에서 이미 리사이즈된 URL을 받고 있었습니다. 그 위에 다시 resize()를 감싸도 아무 변화가 없는 코드였습니다. 단순히 <img> 주변만 검색하면 실제 이미지 URL이 만들어지는 흐름을 놓칠 수 있었습니다.
그래서 최종적으로는 컴포넌트 이름이 아니라 브라우저에 전달되는 최종 URL을 기준으로 경로를 분류했습니다.
480px을 일괄 적용했다가 화질 회귀를 만들 뻔했습니다 😅
상품 카드 대부분은 480px 버킷으로 해결됐지만, 이벤트 상단에 가로로 넓게 펼쳐지는 와이드 카드는 달랐습니다. 이 카드는 높이만 고정돼 있고 너비는 부모 컨테이너를 따라 늘어났습니다. DPR 3 환경에서 실제 슬롯은 약 888 디바이스 픽셀이었습니다.
여기에 480px 소스를 사용하면 브라우저가 이미지를 약 1.85배 확대해야 했습니다. 메모리는 줄지만 변경 전보다 화질이 나빠지는 회귀였습니다.
처음 리뷰 제안은 1080px이었습니다. 슬롯만 보면 합리적으로 보였지만 원본 이미지가 800×800이었습니다. 사내 이미지 리사이즈 서버에 1080을 요청하자 서버는 원본을 1080×1080으로 확대했습니다. 새로운 디테일은 생기지 않았지만 디코드 비용은 2.44MiB에서 4.45MiB로 늘었습니다.
저희는 이 카드만 800px로 분기했습니다.
const thumbnailWidth = isWideCard ? 800 : 480이 결정은 화질을 변경 전 수준으로 유지하면서 불필요한 서버 업스케일을 막았습니다.
두 번째 교훈은 다음과 같았습니다.
목표 슬롯만 보지 말고 원본 해상도도 상한으로 봐야 한다. 원본보다 큰 리사이즈는 디테일 없이 메모리만 늘릴 수 있다.
CSS에서 작게 그리는 것만으로는 해결되지 않았습니다
width, height, object-fit으로 이미지를 작게 보이게 하는 것과 작은 이미지를 받는 것은 다릅니다. CSS는 받은 이미지를 화면에 어떻게 배치할지 결정할 뿐, 네트워크 응답의 픽셀 수를 바꾸지 않습니다.
서버 리사이즈의 fit 정책과 CSS의 object-fit도 함께 봐야 했습니다.
고정 비율 슬롯은 서버와 CSS가 같은 비율로 crop해야 이중 크롭을 피할 수 있습니다.
가변 비율 슬롯은 서버에서 특정 비율로 미리 잘라 버리면 기기 너비에 따라 콘텐츠가 추가로 잘릴 수 있습니다.
서버에서
contain한 이미지를 CSS에서cover하면 작은 축을 다시 확대해 흐려질 수 있습니다.
저희는 고정 비율이 아닌 와이드 카드에는 정사각 변형을 유지하고 CSS에서 최종 노출 영역을 결정하게 했습니다. 세로 픽셀을 더 줄일 수 있었지만, 기기별로 카드 비율이 달라지는 상황에서 상품 이미지의 보이는 범위가 바뀌는 위험이 더 컸기 때문입니다. 약간의 메모리보다 일관된 상품 노출을 우선한 결정이었습니다.
어떻게 측정했나요? 📏
WebView에서 로드된 각 이미지의 원본 크기와 실제 렌더 크기를 수집했습니다.
const diagnostics = [...document.images].map((image) => {
const rect = image.getBoundingClientRect()return {
src: image.currentSrc,
natural: `${image.naturalWidth}×${image.naturalHeight}`,
renderedCss: `${Math.round(rect.width)}×${Math.round(rect.height)}`,
dpr: window.devicePixelRatio,
decodedUpperBound:
image.naturalWidth * image.naturalHeight * 4,
}
})측정은 다음 원칙으로 반복했습니다.
같은 기기와 같은 화면 이동·스크롤 순서를 사용했습니다.
naturalWidth × naturalHeight × 4로 이미지 그룹별 낭비 배수를 찾았습니다.디코드 총량만 믿지 않고 같은 구간의 렌더러 RSS를 함께 확인했습니다.
화면 전체 합계뿐 아니라 같은 카드 그룹이 반복해서 동일한 폭으로 줄어드는지 확인했습니다.
실제 표시 크기로 원본과 리사이즈본을 비교해 화질 회귀를 확인했습니다.
결과는 다음과 같았습니다.

대표 상품 카드 한 장은 다음과 같이 변했습니다.

한 장의 차이는 약 1.56MiB입니다. 같은 문제가 카드 30장에 반복되면 이론상 약 47MiB의 차이가 됩니다. 이미지 한 장의 압축률보다 동시에 사용하는 이미지 수 × 픽셀 면적이 커머스 피드의 메모리에 더 큰 영향을 줄 수 있었습니다.
리사이징 다음에는 이미지 수를 줄였습니다
리사이징은 이미지 한 장의 비용을 줄입니다. 하지만 아직 보이지 않는 이미지까지 모두 로드한다면 작업 집합에 들어오는 이미지 수는 그대로입니다.
별도 후속 작업에서는 접힌 상품 상세 영역과 화면 밖 이벤트 이미지에 지연 로딩을 적용했습니다. 상품 상세 화면의 디코드 총량은 94.2MB에서 11.3MB로 줄었습니다.
두 최적화의 역할은 분명히 달랐습니다.
서버 리사이징: 한 장당 픽셀 비용을 줄입니다.
지연 로딩: 동시에 로드되는 이미지 수를 줄입니다.
둘 중 하나만으로는 긴 커머스 화면의 이미지 메모리를 충분히 관리하기 어려웠습니다.
저희가 정리한 커머스 이미지 원칙
이번 문제를 해결한 뒤 이미지 경로를 검토하는 기준을 다음과 같이 정리했습니다.
포맷보다 픽셀 수를 먼저 확인합니다. WebP인지 확인하는 것으로 끝내지 않고 원본 크기, 슬롯 크기, DPR을 함께 봅니다.
CSS px와 소스 px를 구분합니다.
CSS 슬롯 폭 × DPR을 출발점으로 삼고 실사용 기기의 화질을 검증합니다.원본 크기를 상한으로 둡니다. 서버 업스케일은 디테일 없이 디코드 메모리만 늘릴 수 있습니다.
소수의 크기 버킷을 사용합니다. 슬롯마다 임의의 숫자를 만들지 않고 화질을 만족하는 공통 규격으로 캐시 적중률을 지킵니다.
최종 이미지 URL을 추적합니다. 공용 hook의 선행 리사이즈, 호스트별 예외, 중복 변환을 확인합니다.
리사이징과 지연 로딩을 따로 측정합니다. 하나는 픽셀 면적, 다른 하나는 이미지 수를 줄입니다.
계산값과 프로세스 지표를 함께 봅니다. 디코드 상한은 원인을 찾는 도구이고, 실제 효과는 RSS/PSS와 동일 시나리오 반복 측정으로 판단합니다.
마치며 💭
처음에는 “결제 후 앱이 다시 시작된다”는 큰 문제에서 출발했습니다. 원인은 하나가 아니었고, 이미지 리사이징만으로 앱의 모든 메모리 문제가 해결된 것도 아닙니다. 다만 측정 가능한 문제를 작은 단위로 나누고, 구현 비용 대비 효과가 큰 순서로 풀어 가면서 의미 있는 만큼의 메모리를 줄일 수 있었습니다.
이 과정에서 가장 크게 바뀐 관점은 이것입니다.
브라우저가 화면을 그릴 때 이미지는 파일이 아니라 픽셀이다.
WebP와 HTTP 캐시는 네트워크 비용을 줄여 줍니다. 디코드 캐시는 반복 작업을 줄이는 대신 메모리를 사용합니다. 그리고 모바일의 CSS 1px은 실제 디스플레이의 1px과 다를 수 있습니다.
그래서 커머스 이미지 최적화의 목표는 무조건 가장 작은 파일이 아닙니다. 각 슬롯과 기기에 충분히 선명한 최소 픽셀 수를, 캐시하기 좋은 소수의 규격으로, 필요한 시점에만 전달하는 것입니다.
이미지가 많은 화면을 다루고 계신 분들께 “이 이미지는 지금 몇 픽셀로 펼쳐지고 있을까?”라는 질문이 작은 실마리가 되기를 바랍니다. 끝으로, 리뷰에서 날카로운 질문을 던져 주시고 측정과 검증을 함께해 주신 동료분들께 감사를 전합니다. 🙇
참고 자료
