본문으로 건너뛰기

채팅 UI 설계

질문​

실시간 채팅 UI는 어떻게 설계하며, 실무에서는 어떤 점을 고려해야 하나요?

답변 초안​

채팅 UI의 핵심은 크게 두 가지입니다.

  1. 메시지를 보낸 사람이 누구인지에 따라 말풍선을 좌우로 구분해서 배치하는 것
  2. 메시지 목록을 불러왔을 때 스크롤을 항상 최신 메시지(최하단)에 위치시키는 것

간단해 보이지만, 이미지나 비디오처럼 로드 시점이 늦는 콘텐츠가 섞이면 스크롤 위치가 어긋나는 문제가 발생하기 때문에 예외 처리가 필요합니다.


내 메시지 / 상대 메시지 구분​

메시지 데이터에 포함된 userId와 현재 로그인한 사용자의 userId를 비교해서 내가 보낸 메시지인지 판별합니다.

const isMine = message.userId === currentUser.id;

이 값을 기준으로 flex 속성을 활용해 정렬 방향을 바꿉니다.

  • 내가 보낸 메시지: 우측 정렬, 말풍선의 우측 상단 모서리를 뾰족하게 처리
  • 상대방 메시지: row-reverse를 활용해 좌측 정렬, 말풍선의 좌측 상단 모서리를 뾰족하게 처리
<div className={isMine ? 'message mine' : 'message'}>
<div className="bubble">{message.content}</div>
</div>
.message {
display: flex;
flex-direction: row-reverse; /* 상대 메시지: 좌측 배치 */
}

.message .bubble {
border-radius: 12px;
border-top-left-radius: 0; /* 좌측 상단 뾰족 */
}

.message.mine {
flex-direction: row; /* 내 메시지: 우측 배치 */
justify-content: flex-end;
}

.message.mine .bubble {
border-top-left-radius: 12px;
border-top-right-radius: 0; /* 우측 상단 뾰족 */
}

이미지나 비디오 메시지도 동일한 규칙으로 처리합니다. 말풍선 내부의 콘텐츠 타입만 달라질 뿐, 좌우 배치와 모서리 처리는 텍스트 메시지와 같은 기준을 따릅니다.

{message.type === 'text' && <p>{message.content}</p>}
{message.type === 'image' && <img src={message.url} />}
{message.type === 'video' && <video src={message.url} controls />}

스크롤 최하단 이동​

채팅방에 입장하면 기존 메시지 히스토리를 불러온 뒤 스크롤을 최하단으로 이동시켜 최신 메시지가 보이게 합니다.

const bottomRef = useRef<HTMLDivElement>(null);

useEffect(() => {
bottomRef.current?.scrollIntoView();
}, [messages]);
<div ref={bottomRef} />

목록의 맨 마지막에 빈 요소를 두고 scrollIntoView()를 호출하는 방식이 가장 단순합니다.


문제: 이미지 로드로 인해 스크롤이 어긋나는 현상​

텍스트만 있을 때는 위 방식으로 충분하지만, 히스토리에 이미지가 포함되어 있으면 문제가 생깁니다.

메시지 목록 렌더링 완료
↓
스크롤 최하단 이동 (이 시점에는 이미지 높이가 0)
↓
이미지가 뒤늦게 로드되면서 높이가 늘어남
↓
콘텐츠가 밀려나 스크롤이 최하단에 있지 않게 됨

스크롤을 내리는 시점에는 이미지가 아직 로드되지 않아 높이가 반영되지 않았기 때문에, 이미지가 로드되고 나면 그만큼 스크롤 위치가 위로 밀려 올라간 것처럼 보입니다.


해결: placeholder로 밀림 최소화 + onLoad 재스크롤 보정​

실무에서는 다음 방법들을 조합해서 해결했습니다.

1. 채팅 히스토리를 역순으로 불러오기​

최신 메시지부터 역순으로 불러오면 사용자가 가장 먼저 봐야 하는 최신 영역이 먼저 렌더링됩니다. 채팅은 위로 스크롤할수록 과거 메시지를 불러오는 구조이므로, 무한 스크롤도 일반 피드와 반대로 상단 방향으로 동작합니다.

2. min/max 높이 + 회색 배경으로 placeholder 영역 확보​

이미지에 고정 크기를 지정하는 대신, img가 원본 크기 그대로 렌더링되는 기본 동작을 활용하되 min-height와 max-height로 표시 크기를 한정했습니다. 여기에 회색 배경을 깔아 로드 전에는 placeholder 박스로 보이게 했습니다.

.chat-image {
min-height: 150px; /* 로드 전에도 이 높이만큼 공간을 확보 */
max-height: 300px; /* 원본이 큰 이미지가 지나치게 커지는 것을 방지 */
background-color: #e5e7eb; /* 이미지 로드 전 회색 배경 */
border-radius: 8px;
object-fit: cover; /* min/max로 강제된 크기와 원본 비율이 다를 때 왜곡 방지 */
cursor: pointer;
}

핵심은 min-height입니다. min-width나 max-height는 로드 전 높이가 0인 이미지에 높이를 만들어주지 못하지만, min-height는 로드 전에도 최소 높이만큼 세로 공간을 확보해줍니다.

덕분에 로드 후 밀림 폭이 "이미지 전체 높이"에서 "min~max 사이의 오차"로 크게 줄어듭니다. 다만 최종 렌더링 높이가 min-height와 다르면 그 차이만큼의 잔여 밀림은 남습니다.

3. 마지막 이미지의 onLoad에서 스크롤 재보정​

placeholder로 줄이고 남은 잔여 밀림은, 불러온 히스토리의 마지막 이미지가 로드 완료된 시점에 스크롤을 한 번 더 최하단으로 이동시켜 보정했습니다.

<img
className="chat-image"
src={message.url}
onClick={() => openImageModal(message.url)}
onLoad={() => {
if (isLastImage) {
bottomRef.current?.scrollIntoView();
}
}}
/>

4. 클릭 시 모달로 원본 크기 표시​

채팅 목록에서는 min/max 높이로 한정된 크기로 보여주고, 클릭하면 모달에서 원본 크기로 이미지를 보여주는 방식으로 역할을 분리했습니다.

  • 목록은 대화 흐름을 읽는 용도이므로 작게 한정하고, 원본 확인은 모달에서 하도록 나눴습니다.
  • 비디오도 동일하게 목록에서는 작게 보여주고, 재생이나 확대는 클릭 이후에 처리합니다.
  • 더 개선한다면 목록에는 별도의 썸네일 URL을 로드하고 원본은 모달에서만 로드해서 네트워크 비용까지 줄일 수 있습니다.

지금 다시 설계한다면: aspect-ratio로 밀림 자체를 없애기​

당시에는 min/max 크기와 onLoad 보정을 조합했지만, 더 근본적인 방법은 이미지가 차지할 영역을 로드 전에 정확히 확보해서 밀림 자체를 없애는 것입니다.

업로드 시점에 서버가 이미지의 원본 크기(width, height)를 저장해두고 메시지 데이터에 함께 내려주면, 클라이언트는 aspect-ratio로 정확한 비율의 영역을 미리 렌더링할 수 있습니다.

<div
className="image-placeholder"
style={{ aspectRatio: `${message.width} / ${message.height}` }}
>
<img src={message.thumbnailUrl} alt="" />
</div>
.image-placeholder {
width: 240px; /* 폭이 확정돼야 aspect-ratio가 높이를 계산할 수 있음 */
background-color: #e5e7eb;
border-radius: 8px;
overflow: hidden;
}

이미지가 로드돼도 높이가 변하지 않으므로 밀림이 아예 발생하지 않고, onLoad 재스크롤 같은 보정 로직도 필요 없어집니다. 카카오톡, 텔레그램 같은 서비스들이 흐릿한 썸네일이나 회색 박스를 정확한 비율로 먼저 보여주는 것이 이 방식입니다.


실무 주의점​

  • 내 메시지 여부는 userId 비교로 판별하고, flex 정렬과 말풍선 모서리 방향으로 시각적으로 구분합니다.
  • 이미지, 비디오 메시지도 텍스트와 동일한 좌우 배치 규칙을 따르게 해서 일관성을 유지합니다.
  • 공간 확보의 조건은 "로드 전에 높이가 계산 가능한가"입니다. min-height는 세로 공간을 확보해주지만, min-width나 max-height만으로는 로드 전 높이가 0이라 밀림을 막지 못합니다.
  • min/max 높이 방식은 최종 렌더링 높이와 min-height의 차이만큼 잔여 밀림이 남습니다. 완전히 없애려면 서버에서 이미지 크기를 내려받아 aspect-ratio로 정확히 확보하는 것이 정석입니다.
  • onLoad 재스크롤로 보정할 때는 사용자가 위로 스크롤해 과거 메시지를 읽는 중이라면 강제로 끌어내리지 않도록 최하단 근처 여부를 먼저 확인해야 합니다. 캐시된 이미지는 onLoad가 실행되지 않을 수 있어 img.complete 확인이 필요한 경우도 있습니다.
  • 목록에는 썸네일만 렌더링하고 원본은 모달에서 로드하면 초기 로딩 비용을 크게 줄일 수 있습니다.
  • 과거 메시지를 위로 불러오는 무한 스크롤에서는 데이터 추가 후 기존 스크롤 위치를 유지하는 처리가 별도로 필요합니다.

면접 답변​

"채팅 UI는 메시지의 userId와 로그인한 사용자의 userId를 비교해서 내가 보낸 메시지는 flex로 우측에 배치하고 우측 상단 모서리를 뾰족하게, 상대방 메시지는 row-reverse로 좌측에 배치하고 좌측 상단 모서리를 뾰족하게 처리해 시각적으로 구분했습니다. 이미지와 비디오도 동일한 규칙을 적용했습니다. 채팅방 진입 시 스크롤을 최하단으로 내렸는데, 이미지가 뒤늦게 로드되면서 콘텐츠가 밀려 스크롤이 최신 메시지에 위치하지 않는 문제가 있었습니다. 이미지에 min-height와 max-height, 회색 배경을 지정해 placeholder로 세로 공간을 확보해서 밀림을 줄였고, 남는 오차는 마지막 이미지의 onLoad 이벤트에서 스크롤을 한 번 더 최하단으로 이동시켜 보정했습니다. 목록에서는 크기를 한정해 보여주고 클릭하면 모달에서 원본 크기로 보여주는 방식으로 역할을 분리했습니다. 지금 다시 설계한다면 서버에서 이미지 원본 크기를 내려받아 aspect-ratio로 영역을 정확히 확보해 밀림 자체를 없애는 방식을 택할 것 같습니다."