Deallo

렌더링 · 성능

채팅 새로고침 시 하단 스크롤 실패 — 이미지 레이아웃 시프트

채팅을 새로고침하면 맨 아래가 아니라 마지막에서 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;
}

충돌은 사라졌다. 그런데 이미지가 있으면 여전히 재현됐다. 충돌은 표면이고 진짜 원인은 따로 있었다. 교훈 하나는 챙겼다 — scrollIntoViewscrollTop을 섞어 쓰면 비동기/동기가 충돌한다. 하나만 쓴다.

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)

문제는 useLayoutEffectuseEffect 사이의 시간 간격이었다. 그 틈에 이미지가 로드되며 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단계로 연결했다.

  1. 타입 추가TMessageAttachmentwidth?, height? 필드 추가
  2. 매퍼 전달mapMediaListToAttachments()에서 item.width, item.height 포함
  3. 동적 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/3243px
동영상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 (채택)즉시 렌더, 정확한 높이, 시프트 0BE에서 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로 시작하는 탓에 두 가지 깜빡임이 새로 생겼다.

  1. 내가 보낸 이미지도 skeleton 깜빡임 — 이미 메모리에 있는 blob URL인데 첫 프레임이 무조건 skeleton부터 나옴
  2. 서버 교체 시 이중 깜빡임 — 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)truetrue즉시 표시, skeleton 없음
서버 교체 (CDN, 캐시됨)truetrue즉시 표시, skeleton 없음
상대가 보낸 이미지 (CDN, 미캐시)falsefalseskeleton → fade-in
이미 본 이미지 (CDN, 캐시됨)truetrue즉시 표시

이걸로 끝인 줄 알았는데, 정작 내가 보낸 이미지가 영영 안 보이는 더 고약한 버그가 숨어 있었다.

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.thumbnailUrl

previewUrl(blob)은 파일 선택 시 URL.createObjectURL(file)로 만든 것이라 메모리에 이미 있다 → isImageCachedtrue → skeleton 없이 즉시 표시. V6-a와 V6-b가 맞물리며, 내가 보낸 이미지는 fetch도 깜빡임도 없이 곧장 떴다.

배운 점

  • "맨 아래로 스크롤"의 적은 레이아웃 시프트다. 높이를 모르는 콘텐츠(이미지) 위에서 스크롤하면 로드 후 반드시 밀린다. gap 판정을 아무리 손봐도 변하는 height를 뒤쫓는 한 한 발 늦는다 — 자리(높이)를 먼저 잡고 스크롤한다
  • 같은 목적의 API를 둘 다 호출하면(scrollIntoView + scrollTop) 비동기/동기 충돌이 난다. 하나로 일원화한다
  • BE 응답에 width/height이미 들어 있었다. 새 API를 뚫기 전에 내려오는 데이터부터 다시 본다
  • 한 버그를 닫으면 다음 버그가 열린다. 공간 예약(V3) → 빈 공간 채우기(V5) → 캐시 최적화(V6) → blob 수명(V6-a/b). 증상을 깎는 시도와 원인을 없애는 시도를 구분하는 눈이, 이 사슬을 끝까지 따라가게 해줬다