렌더링 · 성능
채팅 새로고침 시 하단 스크롤 실패 — 이미지 레이아웃 시프트
채팅을 새로고침하면 맨 아래가 아니라 마지막에서 2~3번째 메시지에 스크롤이 멈췄다. 텍스트만 있을 땐 멀쩡한데 마지막이 이미지면 터졌다. "맨 아래로 스크롤" 한 줄이면 끝날 줄 알았는데, scroll API 충돌 → race condition → 레이아웃 시프트, 세 겹이 쌓여 있었다. 한 겹씩 벗겨내고 나서야 높이를 먼저 잡는다는 답에 닿았다.
증상 — 마지막이 이미지일 때만 멈춘다
새로고침
├─ 메시지 A (텍스트)
├─ 메시지 B (텍스트)
├─ 메시지 C (이미지) ← 여기쯤에 스크롤이 멈춘다
├─ 메시지 D (이미지)
└─ [bottomAnchor] ← 여기까지 못 닿음- 새로고침 시 스크롤이 맨 아래에 안 닿고 마지막에서 2~3번째 부근에 멈춤
- 마지막 메시지가 이미지 또는 앨범일 때 재현율이 높고, 텍스트 전용이면 정상
- 새로고침뿐 아니라 채팅방 전환 시에도 간헐적으로 재현
텍스트만 있을 때 멀쩡하고 이미지일 때 깨진다 — 이 비대칭이 범인을 가리키고 있었다. 높이를 모르는 콘텐츠가 끼면 깨진다는 뜻이었다. 하지만 거기까지 가기 전에, scroll 호출 자체부터 꼬여 있었다.
V1 — scrollIntoView + scrollTop 이중 호출을 걷어냈다
가장 먼저 눈에 띈 건 scrollToBottom()이 같은 목적의 API를 두 개나 부르고 있던 점이었다.
// Before
function scrollToBottom() {
bottomAnchorRef.current?.scrollIntoView?.({ block: "end" });
viewport.scrollTop = viewport.scrollHeight;
}scrollIntoView()는 CSS scroll-behavior 속성을 따른다. 즉 비동기로 동작할 수 있다. 그런데 바로
다음 줄의 viewport.scrollTop = scrollHeight는 동기 즉시 실행이다. 둘이 부딪혔다.
scrollIntoView() → 비동기 스크롤 시작 (~150ms 예상)
scrollTop = scrollHeight → 동기 스크롤 즉시 실행
→ 두 스크롤이 충돌해 최종 위치 불확정동기 호출 하나로 일원화했다.
// After
function scrollToBottom() {
viewport.scrollTop = viewport.scrollHeight; // 동기 하나만
shouldStickToBottomRef.current = true;
}충돌은 사라졌다. 그런데 이미지가 있으면 여전히 재현됐다. 충돌은 표면이고 진짜 원인은 따로
있었다. 교훈 하나는 챙겼다 — scrollIntoView와 scrollTop을 섞어 쓰면 비동기/동기가 충돌한다.
하나만 쓴다.
V2 — 초기화 직후 stick 판정이 false로 뒤집혔다
다음으로 stick-to-bottom 플래그를 추적했다. 초기화 시점엔 분명 true인데, paint 직후 어딘가에서
false로 뒤집히고 있었다.
1. useLayoutEffect → scrollToBottom() → shouldStickToBottom = true
(이미지 로드 전, scrollHeight = 2000px, scrollTop = 2000)
2. 브라우저 paint
3. useEffect 실행 → updateStickToBottom() 호출
(이 사이에 이미지 일부 로드 → scrollHeight = 3000px)문제는 useLayoutEffect와 useEffect 사이의 시간 간격이었다. 그 틈에 이미지가 로드되며
scrollHeight가 늘어나는데, scrollTop은 layout effect에서 박아둔 값 그대로 멈춰 있다. 그 상태로 gap을
재면 이렇게 된다.
scrollHeight = 3000 (이미지 로드로 증가)
scrollTop = 2000 (useLayoutEffect에서 설정한 값, 그대로)
clientHeight = 600
gap = 3000 - 2000 - 600 = 400 > 200 → shouldStickToBottom = false!gap > 200이면 "사용자가 위로 올렸다"고 보고 stick을 끄는 로직이었는데, 정작 사용자는 손도 안
댔고 이미지가 늘어났을 뿐이다. 한 번 false가 되면 이후 ResizeObserver가 발동해도 스크롤하지
않는다.
초기화 단계에서는 gap을 재지 말고 무조건 하단으로 보내도록 바꿨다.
useEffect(() => {
if (isLoading) return;
const viewport = viewportRef.current;
if (!viewport) return;
scrollToBottom(); // updateStickToBottom() 대신 — 무조건 하단 + stick 유지
viewport.addEventListener("scroll", updateStickToBottom);
// ...ResizeObserver setup
}, [room.id, isLoading]);개선됐다. 그런데 세로 사진처럼 이미지가 큰 경우 여전히 미세하게 밀렸다. 여기서 깨달았다. gap 판정을 아무리 손봐도, 늘어나는 height를 뒤쫓는 한 항상 한 발 늦는다. 원인은 판정이 아니라 height가 변한다는 사실 자체였다.
V3 — 높이를 먼저 예약한다 (근본 해결)
발상을 뒤집었다. 스크롤이 밀리는 건 이미지가 로드되며 height가 0px → 수백 px로 변하기 때문이다.
그렇다면 로드 전에 정확한 높이를 미리 확보하면, scrollToBottom() 시점의 scrollHeight가 최종
높이와 같아진다. 뒤쫓을 필요 자체가 사라진다.
기존 SingleMedia(이미지 1건)를 뜯어봤다.
<div className="relative w-81 max-w-full ...">
<img src={url} className="w-full object-contain" />
</div>컨테이너에 높이 제약이 없다. w-full + object-contain이라 이미지 로드 전 높이가 0px이고,
로드 후에야 원본 비율대로 300~500px+로 결정된다. 딱 V2에서 본 그 변동이다.
반면 앨범(2장+)은 멀쩡했던 이유도 코드에서 드러났다.
<div className="h-40 flex-1 ..."> // 2장: h-40 고정
<div className="size-40 ..."> // 3~4장: 160x160 고정앨범 아이템은 처음부터 고정 크기라 로드 전에도 높이가 확정이다. 그래서 SingleMedia만 문제였던 거다. 비대칭의 정체가 여기서 풀렸다.
그렇다면 정확한 비율은 어디서 얻나. BE 응답(MediumResponseDto)을 까보니 이미 width/height가
들어 있었다.
{ "width": 3024, "height": 4032, "mediumType": "IMAGE", "url": "https://.../photo.jpg" }이미 내려오는데 안 쓰고 있었을 뿐이다. 3단계로 연결했다.
- 타입 추가 —
TMessageAttachment에width?,height?필드 추가 - 매퍼 전달 —
mapMediaListToAttachments()에서item.width,item.height포함 - 동적
aspect-ratio적용
function SingleMedia({ item, onClick }: TSingleMediaProps) {
const isVideo = item.type === "video";
// BE에서 width/height 제공 시 정확한 비율로 공간 예약
const aspectStyle =
!isVideo && item.width && item.height
? { aspectRatio: `${item.width} / ${item.height}` }
: undefined;
return (
<div
className={cn(
"relative w-81 max-w-full cursor-pointer overflow-hidden rounded-xl ...",
isVideo ? "aspect-video" : !aspectStyle && "aspect-4/3" // fallback
)}
style={aspectStyle}
>
<img
src={item.thumbnailUrl ?? item.url}
alt={item.filename}
className={cn("size-full", isVideo ? "object-cover" : "object-contain")}
/>
</div>
);
}aspect-ratio는 브라우저가 CSS만으로 높이를 계산하게 한다. 이미지 바이트가 도착하기 전에
컨테이너 너비(w-81 = 324px)에 비율을 곱해 높이가 확정된다.
width=3024, height=4032 (3:4 세로) → 324 × (4032/3024) = 432px ← 정확히 예약
width=4032, height=3024 (4:3 가로) → 324 × (3024/4032) = 243px ← 정확히 예약width/height가 없는 옛 데이터를 위한 fallback도 챙겼다.
| 상황 | 적용 비율 | 높이(w-81 기준) |
|---|---|---|
width/height 있음 | aspect-ratio: w/h | 이미지 비율 그대로 |
| 없음 | aspect-4/3 | 243px |
| 동영상 | aspect-video (16:9) | 182px |
이제 scrollToBottom() 시점에 모든 이미지 컨테이너가 최종 높이와 동일하다. 레이아웃 시프트가
0이 되고, 스크롤 위치가 정확해졌다. V1·V2가 증상을 깎아내는 시도였다면, V3은 원인을 없앤
시도였다.
Before (0px → 로드 후 432px) After (aspect-ratio로 432px 예약)
├─ 메시지 C (이미지) 0→432 SHIFT! ├─ 메시지 C (이미지) 432→432 변동 없음
├─ 메시지 D (이미지) 위로 밀림! ├─ 메시지 D (이미지) 고정
└─ [bottomAnchor] 위로 밀림! └─ [bottomAnchor] 고정다른 접근도 저울에 올려봤지만 결론은 같았다.
| 접근 | 장점 | 단점 |
|---|---|---|
aspect-ratio (채택) | 즉시 렌더, 정확한 높이, 시프트 0 | BE에서 width/height 필요 |
| 이미지 로드 대기 | 확실한 높이 | 로딩 지연, UX 나쁨 |
min-height 고정 | 간단 | 부정확, 여전히 시프트 가능 |
| ResizeObserver만 의존 | 추가 코드 불필요 | race condition, 불안정 |
스크롤 버그는 V3에서 닫혔다. 그런데 공간을 잡고 나니, 그 빈 공간이 채워지는 방식에서 새 문제가 줄줄이 튀어나왔다.
V4~V5 — 빈 공간이 채워지는 게 어색하다 (Skeleton + fade)
공간은 확보됐지만 progressive JPEG가 위→아래로 점진적으로 그려지며 끊겨 보였다. 처음엔 컨테이너에
회색 배경(bg-surface-secondary)만 깔았는데, 회색에서 이미지로 단계적으로 바뀌는 게 더 어색했다.
onLoad로 로드 완료를 감지해 skeleton → 이미지로 opacity fade를 걸었다.
const [loaded, setLoaded] = useState(false);
{!loaded && <Skeleton className="absolute inset-0 rounded-none" />}
<img
onLoad={() => setLoaded(true)}
className={cn(
"size-full transition-opacity duration-300",
loaded ? "opacity-100" : "opacity-0"
)}
/>상대가 보낸 이미지는 부드러워졌다. 그런데 useState(false)가 항상 false로 시작하는 탓에 두 가지
깜빡임이 새로 생겼다.
- 내가 보낸 이미지도 skeleton 깜빡임 — 이미 메모리에 있는 blob URL인데 첫 프레임이 무조건 skeleton부터 나옴
- 서버 교체 시 이중 깜빡임 — optimistic(blob) → 서버(CDN URL)로 바뀔 때 새 URL이라 skeleton이 다시 뜸
V6 — 캐시된 이미지는 skeleton을 건너뛴다
핵심은 초기 loaded 상태를 URL 타입에 따라 지능적으로 정하는 것이었다. 메모리에 이미 있는
이미지는 처음부터 loaded = true로 시작하면 된다.
function isImageCached(src: string): boolean {
if (!src) return false;
// blob/data URL은 메모리에 있으므로 즉시 사용 가능
if (src.startsWith("blob:") || src.startsWith("data:")) return true;
// CDN URL은 브라우저 HTTP 캐시 체크
if (typeof window === "undefined") return false;
const img = new Image();
img.src = src;
return img.complete;
}
const [loaded, setLoaded] = useState(() => isImageCached(src));new Image().complete의 원리는 이렇다 — 화면에 안 보이는 <img>를 만들고 .src를 할당하면
브라우저가 HTTP 캐시를 뒤지고, 이미 메모리에 있으면 .complete가 동기적으로 true를 돌려준다.
| 상황 | isImageCached | 초기 loaded | 결과 |
|---|---|---|---|
| 내가 보낸 이미지 (blob) | true | true | 즉시 표시, skeleton 없음 |
| 서버 교체 (CDN, 캐시됨) | true | true | 즉시 표시, skeleton 없음 |
| 상대가 보낸 이미지 (CDN, 미캐시) | false | false | skeleton → fade-in |
| 이미 본 이미지 (CDN, 캐시됨) | true | true | 즉시 표시 |
이걸로 끝인 줄 알았는데, 정작 내가 보낸 이미지가 영영 안 보이는 더 고약한 버그가 숨어 있었다.
V6-a — clearAttachments가 blob URL을 죽이고 있었다
send 직후 clearAttachments()가 정리 차원에서 URL.revokeObjectURL()을 부르고 있었다. 그런데 그
blob URL을 optimistic 메시지가 아직 쓰고 있었다.
1. optimistic 메시지 생성 → thumbnailUrl = blob:xxx
2. clearAttachments() → URL.revokeObjectURL(blob:xxx) ← 무효화!
3. <img src="blob:xxx"> 로드 실패 → onLoad 안 발동 → opacity-0 영구 유지
4. invalidateQueries → 서버 메시지(CDN)로 교체되고 나서야 이미지가 보임내 이미지가 잠깐 사라졌다 뒤늦게 나타나던 게 이거였다. clearAttachments에서 revoke를 제거했다.
blob URL은 탭이 닫히면 자동 해제되고 메모리 영향도 미미하다.
// Before
clearAttachments: () => {
set((state) => {
for (const a of state.attachments) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
return { attachments: [] };
});
}
// After
clearAttachments: () => {
set({ attachments: [] });
}단, 업로드 취소인 removeAttachment에서는 revoke를 유지했다. 거기서 지우는 이미지는 optimistic
메시지에 안 쓰이므로 해제해도 안전하다.
V6-b — optimistic 메시지가 CDN URL을 보고 있었다
마지막 한 조각. optimistic 메시지의 thumbnailUrl이 CDN URL을 가리키고 있어서, 방금 내가 고른
파일인데도 브라우저가 굳이 새로 fetch하고 있었다. 메모리에 있는 blob을 우선 보게 바꿨다.
// Before: CDN URL (브라우저가 아직 로드한 적 없음)
thumbnailUrl: attachment.media.coverImageUrl ?? attachment.media.thumbnailUrl
// After: blob URL 우선 (이미 메모리에 있음)
thumbnailUrl:
attachment.previewUrl ??
attachment.media.coverImageUrl ??
attachment.media.thumbnailUrlpreviewUrl(blob)은 파일 선택 시 URL.createObjectURL(file)로 만든 것이라 메모리에 이미 있다 →
isImageCached가 true → skeleton 없이 즉시 표시. V6-a와 V6-b가 맞물리며, 내가 보낸 이미지는
fetch도 깜빡임도 없이 곧장 떴다.
배운 점
- "맨 아래로 스크롤"의 적은 레이아웃 시프트다. 높이를 모르는 콘텐츠(이미지) 위에서 스크롤하면 로드 후 반드시 밀린다. gap 판정을 아무리 손봐도 변하는 height를 뒤쫓는 한 한 발 늦는다 — 자리(높이)를 먼저 잡고 스크롤한다
- 같은 목적의 API를 둘 다 호출하면(
scrollIntoView+scrollTop) 비동기/동기 충돌이 난다. 하나로 일원화한다 - BE 응답에
width/height가 이미 들어 있었다. 새 API를 뚫기 전에 내려오는 데이터부터 다시 본다 - 한 버그를 닫으면 다음 버그가 열린다. 공간 예약(V3) → 빈 공간 채우기(V5) → 캐시 최적화(V6) → blob 수명(V6-a/b). 증상을 깎는 시도와 원인을 없애는 시도를 구분하는 눈이, 이 사슬을 끝까지 따라가게 해줬다