채팅 UI 설계
질문
실시간 채팅 UI는 어떻게 설계하며, 실무에서는 어떤 점을 고려해야 하나요?
답변 초안
채팅 UI의 핵심은 크게 두 가지입니다.
- 메시지를 보낸 사람이 누구인지에 따라 말풍선을 좌우로 구분해서 배치하는 것
- 메시지 목록을 불러왔을 때 스크롤을 항상 최신 메시지(최하단)에 위치시키는 것
간단해 보이지만, 이미지나 비디오처럼 로드 시점이 늦는 콘텐츠가 섞이면 스크롤 위치가 어긋나는 문제가 발생하기 때문에 예외 처리가 필요합니다.
내 메시지 / 상대 메시지 구분
메시지 데이터에 포함된 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로 영역을 정확히 확보해 밀림 자체를 없애는 방식을 택할 것 같습니다."