법률 O2O 플랫폼을 운영하면서 가장 많은 피드백을 받는 부분이 채팅 UX입니다. “메시지를 실수로 보냈는데 삭제할 수 없다”, “어떤 메시지에 답장한 건지 모르겠다”, “그룹채팅방에 공지를 띄우고 싶다”—이런 요구사항을 한 번에 해결하면서 구현한 내용을 공유합니다.
1. 메시지 삭제 — 브라우저 confirm() 탈출
기존에는 메시지 삭제 시 브라우저 기본 confirm()을 썼습니다. 모바일에서는 디자인이 엉망이고 UX도 끊깁니다. 바텀시트 스타일 모달로 교체했습니다.
{#if deleteConfirmMessage}
<div class="fixed inset-0 z-50 flex items-end justify-center"
on:click={() => deleteConfirmMessage = null}>
<div class="w-full max-w-md bg-white rounded-t-2xl shadow-2xl"
on:click|stopPropagation>
<div class="w-10 h-1 bg-gray-300 rounded-full mx-auto mt-3 mb-4"></div>
<div class="px-6 pb-2 text-center">
<p class="text-base font-semibold">메시지 삭제</p>
<p class="text-sm text-gray-500 mt-1">이 메시지를 삭제하면 복구할 수 없어요</p>
</div>
<div class="px-4 py-4 flex flex-col gap-2">
<button on:click={confirmDelete}
class="w-full py-3.5 bg-red-500 text-white rounded-2xl font-semibold">
삭제하기
</button>
<button on:click={() => deleteConfirmMessage = null}
class="w-full py-3.5 bg-gray-100 text-gray-700 rounded-2xl font-semibold">
취소
</button>
</div>
</div>
</div>
{/if}
백엔드는 소프트 삭제 방식을 채택했습니다. 메시지를 실제로 지우지 않고 is_deleted=True로 표시해 “삭제된 메시지입니다” 흔적을 남깁니다. 대화 맥락이 끊기지 않고 자연스럽습니다.
# FastAPI 소프트 삭제
@router.delete("/chat/messages/{message_id}")
async def delete_message(
message_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
msg = db.query(Message).filter(Message.id == message_id).first()
if not msg:
raise HTTPException(404, "메시지를 찾을 수 없습니다")
if msg.sender_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "삭제 권한이 없습니다")
# 소프트 삭제: 내용만 지우고 레코드는 유지
msg.is_deleted = True
msg.content = None
msg.file_url = None
db.commit()
return {"success": True}
2. 답장 기능 — WebSocket 실시간 미리보기
답장의 핵심은 실시간으로 reply_to 미리보기가 상대방에게도 보여야 한다는 점입니다. DB에 reply_to_id 컬럼을 추가하고, WebSocket 브로드캐스트 시 미리보기 데이터를 함께 내려줍니다.
# WebSocket 브로드캐스트 시 reply_to 미리보기 포함
reply_preview = None
if new_message.reply_to_id:
reply_msg = db.query(Message).filter(
Message.id == new_message.reply_to_id
).first()
if reply_msg:
sender = db.query(User).filter(
User.id == reply_msg.sender_id
).first()
reply_preview = {
"id": reply_msg.id,
"sender_name": sender.nickname or sender.full_name if sender else "알 수 없음",
"content": reply_msg.content if not reply_msg.is_deleted else None,
"type": reply_msg.type
}
broadcast_data = {
"type": "message",
"message": {
...message_data,
"reply_to": reply_preview # None이 아닌 실제 데이터
}
}
프론트에서는 탭 한 번으로 내 메시지는 삭제 모달, 상대 메시지는 답장 세팅이 되도록 단순화했습니다. 컨텍스트 메뉴 팝업을 없애니 모바일 UX가 훨씬 깔끔해졌습니다.
<script>
function onMessageTap(e, message) {
if (message.is_deleted) return;
e.stopPropagation();
if (isMyMessage(message)) {
// 내 메시지: 삭제 모달 오픈
deleteConfirmMessage = message;
} else {
// 상대 메시지: 즉시 답장 세팅
replyTo = message;
}
}
</script>
<!-- 메시지 버블에 탭 이벤트 -->
<div on:click={(e) => onMessageTap(e, message)} class="select-none">
{#if message.reply_to}
<!-- 답장 미리보기 클릭 → 원본으로 스크롤 -->
<button on:click|stopPropagation={() => scrollToMessage(message.reply_to.id)}
class="border-l-2 border-green-400 pl-2 mb-2 w-full text-left">
<p class="text-green-300 text-[11px]">{message.reply_to.sender_name}</p>
<p class="text-green-200 text-xs truncate">{message.reply_to.content}</p>
</button>
{/if}
<p>{message.content}</p>
</div>
3. 원본 메시지 바운스 스크롤
답장 미리보기를 클릭하면 원본 메시지로 스크롤되는데, 스크롤이 완료된 후에 바운스 애니메이션이 시작되어야 사용자가 볼 수 있습니다. scrollIntoView가 완료되는 시간(약 500ms)을 기다린 뒤 CSS 애니메이션을 트리거합니다.
<script>
let highlightedId = null;
function scrollToMessage(msgId) {
const el = document.getElementById(`msg-${msgId}`);
if (!el) return;
// 부드러운 스크롤 시작
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 스크롤 완료(~500ms) 후 바운스 애니메이션 시작
setTimeout(() => {
highlightedId = msgId;
setTimeout(() => { highlightedId = null; }, 700);
}, 500);
}
</script>
<!-- 각 메시지에 id + 바운스 클래스 -->
<div id="msg-{message.id}"
class="{highlightedId === message.id ? 'msg-bounce' : ''}">
...
</div>
<style>
@keyframes msgBounce {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-8px); }
40% { transform: translateY(0); }
60% { transform: translateY(-4px); }
80% { transform: translateY(0); }
}
:global(.msg-bounce) {
animation: msgBounce 0.6s ease;
}
</style>
4. 그룹 채팅 공지 시스템
그룹채팅방에 공지사항을 등록하고, 상단 배너에 제목을 노출 후 클릭하면 전체 내용을 모달로 보여주는 기능입니다. 관리자 페이지에서 TipTap 에디터로 서식 있는 공지를 작성할 수 있습니다.
DB에 notice_title, notice(HTML) 컬럼을 추가하고, PATCH API로 업데이트합니다.
<!-- 공지 배너: 제목 미리보기 -->
<button class="w-full bg-[#D1E8DA] border-b border-[#B8D9C5] px-4 py-2"
on:click={() => { if (currentRoom?.notice_title) showNoticeModal = true; }}>
{#if currentRoom?.notice_title}
<div class="flex items-center gap-2">
<svg ...></svg> <!-- 메가폰 아이콘 -->
<p class="text-xs text-green-800 font-medium truncate flex-1">
{currentRoom.notice_title}
</p>
<svg ...></svg> <!-- 화살표 -->
</div>
{:else}
<p class="text-xs text-gray-700 text-center">
전문가와 일반 사용자가 자유롭게 소통하는 공간입니다.
</p>
{/if}
</button>
<!-- 공지 전체 내용 모달 -->
{#if showNoticeModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"
on:click={() => showNoticeModal = false}>
<div class="bg-white rounded-2xl w-full max-w-sm" on:click|stopPropagation>
<div class="bg-[#1B4332] px-5 py-4">
<h3 class="text-white font-semibold">{currentRoom.notice_title}</h3>
</div>
<div class="px-5 py-4 prose prose-sm max-h-96 overflow-y-auto">
{@html currentRoom.notice}
</div>
</div>
</div>
{/if}
마치며
채팅 UX는 작은 디테일이 사용자 경험을 크게 좌우합니다. 브라우저 팝업 하나를 바텀시트로 바꾸는 것, 스크롤 후 0.5초 뒤에 바운스를 시작하는 것—이런 세밀한 타이밍이 앱의 완성도를 만듭니다. 코드벤터는 기획부터 백엔드·프론트엔드 구현, 배포까지 혼자 처리하면서도 이런 디테일을 놓치지 않는 개발 방식을 지향합니다. AI 코딩 도구와 15년의 경험을 결합해 빠르고 완성도 높은 서비스를 만들어드립니다.