[인프런 워밍업 클럽 Full Stack 3기] 4주차
1. 학습 내용
1.1. Supabase 인증 방식
1.1.1. Confirmation URL
사용자가 이메일로 받은 확인 링크를 클릭하여 계정을 인증하는 방식입니다.
1.1.2. 6-Digit OTP 방식
이메일로 6자리 일회용 코드를 전송하고, 사용자가 이를 입력하여 인증하는 방식입니다.
1.2. Supabase realtime
Supabase Realtime은 PostgreSQL 데이터베이스의 변경사항을 실시간으로 클라이언트에 제공하는 기능으로, 채팅, 알림, 실시간 협업 등을 구현할 수 있게 해주는 서비스입니다.
1.2.1. Broadcast
특정 채널을 통해 연결된 모든 클라이언트 간에 직접 메시지를 주고받을 수 있는 기능입니다. 데이터베이스를 거치지 않고 실시간 통신이 가능합니다.
특징
- 클라이언트 간 직접 메시지 교환
- 데이터베이스 저장 없이 임시 통신 가능
- 타이핑 표시기, 커서 위치 등 임시 상태에 적합
- 채널 기반 구독 시스템
1.2.2. Presence (중요)
Presence는 채널에 연결된 사용자의 상태 정보를 실시간으로 추적하고 공유하는 기능입니다. 온라인 상태, 사용자 활동, 현재 위치 등을 관리할 수 있습니다. 이번 미션을 진행할 때 presence가 굉장히 핵심적인 역할을 합니다.
특징
- 사용자 온라인/오프라인 상태 관리
- 자동 정리(cleanup) 기능
- 상태 데이터 저장 및 공유
- 연결 끊김 자동 감지
1.2.3. Postgres Changes
PostgreSQL 데이터베이스의 변경사항(INSERT, UPDATE, DELETE)을 실시간으로 감지하여 클라이언트에 전달하는 기능입니다.
특징
- 데이터베이스 수준의 변경사항 감지
- 테이블, 로우, 열 수준의 구독 가능
- 정교한 필터링 옵션
- RLS(Row Level Security)와 통합
1.3. RLS (중요)
PostgreSQL의 보안 기능으로, 데이터베이스 테이블의 개별 행(row)에 대한 접근을 사용자 수준에서 제어할 수 있는 메커니즘입니다. Supabase에서는 이를 통해 인증된 사용자가 자신의 데이터만 접근할 수 있도록 세밀한 보안 정책을 설정할 수 있습니다.(Policy 기능)
2. 미션
2.1. 지금까지 만든 모든 프로젝트 배포
TODO: https://inf.run/Gi4eA
DROPBOX: https://inf.run/ScSd4
NETFLIX: https://inf.run/TMWKr
INSTAGRAM: https://inf.run/HS4Sn
2.2. 메시지 삭제 기능
제가 구현한 메시지 삭제 기능은 데이터베이스에서 실제로 행을 제거하지 않고 상태 플래그(is_deleted)만 변경하는 방식으로 구현했습니다.
2.2.1. 클라이언트 측 구현
// /components/chat/MessageArea.tsx
const deleteMessageMutation = useMutation({
mutationFn: async (id: number) => {
return deleteMessage({ id });
},
onSuccess: () => {
getAllMessageQuery.refetch();
},
onError: error => {
console.error('메시지 삭제 오류:', error);
alert('메시지 삭제 중 오류가 발생했습니다.');
},
});
function handleDeleteMessage(id: number) {
confirm('정말 메시지를 삭제하시겠습니까?') &&
deleteMessageMutation.mutate(id);
}useMutation 훅을 사용하여 메시지 삭제 작업 정의
삭제 전 confirm 대화상자로 사용자 확인 절차 구현
성공 시 메시지 목록 갱신을 위해 refetch 호출
오류 발생 시 콘솔 로그 및 사용자 알림 표시
2.2.2. 서버 액션 구현
// /actions/chatAction.ts
export async function deleteMessage({ id }: { id: number }) {
try {
const user = await getCurrentUser();
const supabase = await createServerSupabaseClient();
const { error } = await supabase
.from('message')
.update({ is_deleted: true })
.eq('id', id)
.eq('sender', user.id);
if (error) {
throw new Error(error.message);
}
} catch (error) {
console.error('메시지 삭제 중 오류:', error);
throw error;
}
}서버 측에서 메시지 삭제를 논리적 삭제로 처리 (is_deleted 필드 업데이트)
현재 인증된 사용자 정보 가져오기
본인이 보낸 메시지만 삭제할 수 있도록 보안 검증 (eq('sender', user.id))
오류 발생 시 클라이언트로 전파하여 적절한 처리 가능
2.2.3. Message UI 구현
// components/chat/Message.tsx
export function Message({
isFromMe,
message,
deleteMessage,
isDeleted,
isRead = false,
}: MessageProps) {
const DELETE_MESSAGE = '이미 삭제된 메세지입니다.';
return (
<div className={`group flex w-fit ${isFromMe && 'ml-auto'} `}>
{/* ... 메시지 내용 ... */}
<p>{isDeleted ? DELETE_MESSAGE : message}</p>
{/* 삭제 버튼 (자신의 메시지이고 삭제되지 않은 경우에만 표시) */}
{isFromMe && !isDeleted && (
<button
onClick={deleteMessage}
className="hidden rounded-md rounded-l-none bg-red-400 px-2 text-sm text-white opacity-70 hover:opacity-100 group-hover:block"
>
X
</button>
)}
</div>
);
}삭제된 메시지는 "이미 삭제된 메세지입니다" 텍스트로 대체
자신의 메시지(isFromMe)이고 삭제되지 않은 경우에만 삭제 버튼 표시
메시지에 마우스를 올렸을 때만 삭제 버튼 표시 (group-hover)
2.2.4. 기능 설명 요약
사용자가 메시지의 X 버튼 클릭
handleDeleteMessage 함수가 호출되어 확인 대화상자 표시
확인 시 deleteMessageMutation.mutate(id) 실행
서버 액션 deleteMessage가 호출되어 데이터베이스 업데이트
성공 시 getAllMessageQuery.refetch()로 메시지 목록 새로고침
삭제된 메시지는 UI에서 "이미 삭제된 메세지입니다"로 표시
2.3. 채팅 '읽음/안읽음' 표시 기능

2.3.1. 읽음 상태 관리
// /hooks/reactQueries.ts
export function useMarkMessagesAsRead(selectedUserId: string | null) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => {
if (!selectedUserId) return Promise.resolve()
return markMessagesAsRead(selectedUserId)
},
onSuccess: () => {
// 쿼리 무효화 로직
},
})
}선택된 사용자 ID를 받아 해당 사용자의 메시지를 읽음 처리함
사용자 ID가 없으면 즉시 빈 Promise 반환
성공 시 안 읽은 메시지 카운트와 메시지 목록 쿼리를 무효화하여 UI 갱신
React Query의
useMutation사용으로 서버 상태 변경 관리
2.3.2. 메시지 카운트 조회 기능
// /actions/chatActions.ts
export async function getNotReadMessageCount(chatUserId: string) {
// 수행 로직
const { count, error: countError } = await supabase
.from('message')
.select('*', { count: 'exact', head: true })
.match({
sender: chatUserId,
receiver: user.id,
is_read: false,
})
}특정 사용자(
chatUserId)가 보낸 안 읽은 메시지 개수만 조회head: true옵션으로 데이터 없이 카운트만 가져와 효율성 향상count: 'exact'옵션으로 정확한 카운트 값 요청is_read: false필터로 안 읽은 메시지만 카운트
2.3.3. 자동 읽음 처리 기능
// /components/chat/ChatScreen.tsx
const checkUserPresence = useCallback(async () => {
if (!selectedUserId || !loggedUser) return
const chatPartnerId = presence?.[`${selectedUserId}`]?.[0]?.activeChatId
if (chatPartnerId === loggedUser.id) {
markAsRead()
}
}, [selectedUserId, presence, markAsRead, loggedUser])Presence 정보에서 상대방의 활성 채팅 ID 확인
상대방이 현재 사용자와의 채팅을 보고 있으면 자동으로 읽음 처리
옵셔널 체이닝(
?.)으로 안전하게 데이터 접근useCallback으로 함수 재생성 방지 및 의존성 관리
2.3.4. 읽음 상태 UI 표시
// /compoents/chat/Message.tsx
export function Message({ isFromMe, message, isRead = false }) {
return (
<div>
<p>{message}</p>
{isFromMe && (
<span className="ml-1 text-xs text-gray-500">
{isRead ? '읽음' : '안읽음'}
</span>
)}
</div>
)
}메시지 발신자(
isFromMe)가 본인일 경우에만 읽음 상태 표시isRead상태에 따라 '읽음' 또는 '안읽음' 텍스트 표시기본값
false로 설정하여 읽음 상태 없을 때 안전하게 처리
2.3.5. Presence 채널 설정
// /components/chat/CHatPeopleList.tsx
const channel = supabase.channel('online_users', {
config: {
presence: {
key: loggedInUser.id,
},
},
})
channel.on('presence', { event: 'sync' }, () => {
const newState = channel.presenceState()
setPresence(newStateOObject)
})
'online_users' 채널 생성으로 사용자 상태 공유
현재 사용자 ID를 키로 설정해 사용자별 상태 구분
presence이벤트의sync타입으로 상태 변경 감지presenceState()로 전체 사용자의 최신 상태 조회Recoil 상태로 저장해 앱 전체에서 접근 가능
2.3.6. 활성 채팅 상태 추적 기능
// /components/chat/ChatPeopleList.tsx
channel.Subscribe(async status => {
if (status !== 'SUBSCRIBED') return
const newPresenceStatus = await channel.track({
onlineAt: new Date().toISOString(),
activeChatId: selectedUserId,
})
})채널 구독 성공(
SUBSCRIBED) 확인 후 상태 추적 시작현재 시간을 ISO 문자열로 저장해 최신 접속 시간 기록
activeChatId에 현재 대화 중인 상대방 ID 저장channel.track()으로 상태 정보 실시간 브로드캐스트
2.3.7. 실시간 메시지 변경 감지 및 처리
// /components/chat/ChatScreen.tsx
supabase
.channel('message_postgres_changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message',
},
payload => {
if (payload.eventType === 'INSERT') {
refetch()
checkUserPresence()
}
}
)
.subscribe()'message_postgres_changes' 채널로 메시지 테이블 변경 구독
새 메시지 추가(
INSERT) 이벤트만 필터링새 메시지 발생 시 즉시 메시지 목록 갱신(
refetch())동시에 사용자 상태 확인하여 자동 읽음 처리 실행
관련 쿼리(안 읽은 메시지 카운트 등) 무효화로 UI 일관성 유지
2.3.8. 정책 설정 (supabase policy)

서버 액션에서
createServerSupabaseAdminClient() 를 이용하여 관리자 권한으로 is_read 필드를 수정하려 했지만 강의에서 보고 적용한 update policy를 넘어설 수 없었음.
어쩔 수 없이 위와 같은 설정대로 누구나 업데이트를 할 수 있게 적용하되 모든 서버 액션 코드에서 권한 확인을 하는 코드를 추가
2.3.9. 기능 설명 요약
초기 설정: 사용자가 로그인하면 Presence 채널 구독 및 상태 추적 시작
채팅방 전환: 사용자가 특정 채팅방을 선택하면
activeChatId업데이트자동 읽음 처리: 상대방이 대화를 보고 있으면 자동으로 메시지 읽음 처리
상태 변경 감지: 메시지 읽음 상태가 변경되면 UI 업데이트
안 읽은 메시지 카운트: 각 사용자별 안 읽은 메시지 개수 조회 및 표시
3. 그 외 추가 작업 사항
3.1. 코드 구조 개선
3.1.1. 인증 및 상태 관리
session 대신 supabase
getUser메서드를 활용하여 로그인된 사용자 상태 확인
3.1.2. 커스텀 훅 도입
react-query 및 로그인 확인 코드는 hooks로 모듈화하여 필요한 컴포넌트마다 사용할 수 있게 변경
3.1.3. 컴포넌트 구조 개선
ChatScreen 컴포넌트 분리
MessageArea.tsx: 채팅 메시지 표시 영역 담당ChatInput.tsx: 사용자 입력 폼 처리 담당
3.2. 사용자 경험(UX) 개선
3.2.1. 현재 사용자 표시
로그인한 사용자를 유저 리스트 최상단에 시각적으로 구분하여 표시
3.2.2. 가이드 메시지 추가
새 대화 시작 시 "아직 메시지가 없습니다. 대화를 시작해 보세요!" 안내 문구 표시
3.2.3. 자동 스크롤 구현
채팅방 진입 시 최신 메시지 위치로 자동 스크롤
새 메시지 전송 시 스크롤 위치 자동 조정
3.2.4. 입력 경험 개선
<form>태그 활용으로Enter키 제출 지원전송 버튼
type="submit"속성 적용메시지 전송 후 입력창 자동 포커스 유지로 연속 입력 편의성 향상
4. 한 달 간의 개발 스터디를 마치며
매주 개발 프로젝트와 미션을 수행하며 그 과정에서 해보고 싶다고 생각되는 것도 시도해보다 보니 어느새 스터디 마지막에 도달했습니다. 한 달 간의 꾸준한 코딩은 개발 트렌드 확인에 많은 도움이 되었습니다. 공부 습관도 다시 생길 것 같습니다.
아쉬운 점은 인프런 블로그 에디터가 일정 분량 이상 작성하면 급격히 느려져 매주 하고 싶었던 이야기를 충분히 담지 못했다는 것입니다. 오늘도 같은 이유로 긴 회고를 남기기 어렵네요.
짧은 시간이었지만 이번 스터디를 통해 얻은 기술적 성장과 개발 습관은 앞으로의 여정에 큰 도움이 될 것입니다.
댓글을 작성해보세요.