🧐 개요

모의 면접 서비스는 주로 면접관 캐릭터를 사용하면서 면접관의 사진을 주기적으로 상태에 따라 교체함으로써 사용자가 상호작용을 받는 느낌을 주도록 했다. 하지만 이렇게 상태에 따라 이미지를 보여주는 과정에서 레이턴시가 눈에 띌 정도로 보이게 되었고, 이를 개선하는 경험을 담는다.

🧐 왜 발생했을까?

상태에 따라 이미지를 보여주는 과정에서 처음에는 단순하게 이미지를 상태에 따라 다른 src값을 주면서 바꾸면 되겠다!라고 생각했었다.

<Image
            src={ROBOT_SOURCES[state.status].src}
            alt={ROBOT_SOURCES[state.status].alt}
            width={300}
            height={300}
            className={`w-full absolute top-0 left-0`}
          />

따라서 위 코드처럼 인터뷰 자체의 상태를 받아 이와 연결되어있는 로봇의 이미지 src를 가져와 동적으로 넣어주도록 했다.

하지만 이 경우 웹 페이지가 로드되고, 초기 상태에 따른 로봇의 이미지는 이미 로드가 되었지만, 다른 상태에 따른 이미지는 로드되지 않은 상태인 것이다. 그렇기 때문에 인터뷰의 상태가 바뀌면서 기존의 대기 질문 상태로 바뀔 때면, 이에 따라 로봇의 이미지도 달라지면서 src가 바뀌게 되고, 브라우저는 이 src가 동적으로 바뀐 때를 기점으로 변경된 src 에 대해서 클라이언트 서버로 네트워크 요청을 보내고 이에 따른 이미지를 그떄부터 다운로드 받는다.

그렇기 때문에 이미지를 그 때부터 다운로드 받기 시작하면, 이미지를 최적화한다 하더라도 이에 대한 서버로의 요청과 응답시간이 있기 때문에 레이턴시가 생기기 마련이다. 이러한 이유 때문에 위의 gif처럼 면접 질문이 바뀌면서 이미지 또한 동시에 변경되는 동작을 기대했는데, 둘의 타이밍에 차이가 생긴 것이 눈에 띄게 보인다.

빠른 4G를 기반으로 네트워크 요청/응답 시간을 봤을 때, 약 200ms가 평균적으로 나왔다.

느린 4G를 기반으로 한 이미지 요청/응답은 무려 600ms나 걸린다. 위에서 올린 gif가 느린 4G일때인데, 확실히 사용자가 느낄 저옫로 레이턴시가 있다는 사실을 알 수 있다.

👀 해결 방법 찾기

이미지들을 다 DOM에 띄워놓기

가장 브루트포스한 방식이 아닐까…생각한다.

{Obeject.keys(ROBOT_SOURCES).map((status) => (
            <Image
              key={status}
              src={ROBOT_SOURCES[status].src}
              alt={ROBOT_SOURCES[status].alt}
              width={300}
              height={300}
              className={`w-full absolute top-0 left-0 ${
                state.status === status ? "opacity-100" : "opacity-0"
              } transition-opacity duration-500`}
            />
  ))}

일단 이미지를 모두 DOM에 올려놓은 다음에, 스타일을 통해서 당장의 상태에 따른 이미지가 아닌 이미지들은 opacity를 0으로 줌으로써 스위칭 하는 방식이다.

하지만 이 방식의 경우

  • 초기 렌더링 시 모든 이미지를 load하게 됨
    → 상태에 따른 이미지가 많아질수록 초기 페이지 로딩 속도가 느려질 수 있음.
  • 불필요한 메모리 사용
    → 모든 <img>가 실제로 DOM에 존재하므로, 렌더링과 메모리 사용이 증가 라는 치명적인 단점이 있다.
<link rel="preload" as="image" href="/images/robot_idle.png">
<link rel="preload" as="image" href="/images/robot_active.png">

html의 link 태그의 preload를 사용해서 미리 서버에 html을 요청할 때부터 필요한 정적 자원을 다 가져오는 방법도 있다. 하지만 이 방식의 경우, 무조건 서버로부터 요청하여 이미지를 미리 다운로드하고 캐싱을 하도록 해준다.

시간이 오래 걸린건 속도에 제한을 걸어놔서 그런건데, 이를 감안하고 보면 DOM parsing과 같은 브라우저의 작업을 따로 막지 않고 렌더링시키는 것을 알 수 있다.

useEffect + new Image()

useEffect(() => {
  Object.values(ROBOT_SOURCES).forEach(({ src }) => {
    const img = new Image();
    img.src = src;
  });
}, []);

useEffect와 new Image()를 사용하여 src 태그를 넣어놓는 경우, 컴포넌트가 마운트될 때부터 이미지를 페칭하기 시작하기 때문에

항목<link rel="preload">useEffect + new Image()
동작 시점HTML 파싱 중 즉시React 컴포넌트 마운트 이후
렌더링 영향없음 (렌더링 이전 네트워크 요청 시작)초기 렌더 완료 후 실행됨
우선순위높음 (as="image" 명시)일반 fetch와 동일 수준
브라우저 최적화 반영잘 됨 (프리로드 큐 포함)프리로드 큐에 포함 안 됨
사용 위치HTML <head> (_document.tsx or next/head)React 내부 (useEffect)
코드 유연성정적 preload에 유리동적 preload에 유리
Next.js와 궁합✅ 권장 (빌드 타임에 자동 최적화 가능)✅ 가능 (런타임 제어가 쉬움)