[인프런 워밍업 클럽 Full Stack 3기] 4주차

[인프런 워밍업 클럽 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. 메시지 삭제 기능

image제가 구현한 메시지 삭제 기능은 데이터베이스에서 실제로 행을 제거하지 않고 상태 플래그(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. 기능 설명 요약

  1. 사용자가 메시지의 X 버튼 클릭

  2. handleDeleteMessage 함수가 호출되어 확인 대화상자 표시

  3. 확인 시 deleteMessageMutation.mutate(id) 실행

  4. 서버 액션 deleteMessage가 호출되어 데이터베이스 업데이트

  5. 성공 시 getAllMessageQuery.refetch()로 메시지 목록 새로고침

  6. 삭제된 메시지는 UI에서 "이미 삭제된 메세지입니다"로 표시

2.3. 채팅 '읽음/안읽음' 표시 기능

image

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)

image

  • 서버 액션에서

    createServerSupabaseAdminClient() 를 이용하여 관리자 권한으로 is_read 필드를 수정하려 했지만 강의에서 보고 적용한 update policy를 넘어설 수 없었음.

  • 어쩔 수 없이 위와 같은 설정대로 누구나 업데이트를 할 수 있게 적용하되 모든 서버 액션 코드에서 권한 확인을 하는 코드를 추가

2.3.9. 기능 설명 요약

  1. 초기 설정: 사용자가 로그인하면 Presence 채널 구독 및 상태 추적 시작

  2. 채팅방 전환: 사용자가 특정 채팅방을 선택하면 activeChatId 업데이트

  3. 자동 읽음 처리: 상대방이 대화를 보고 있으면 자동으로 메시지 읽음 처리

  4. 상태 변경 감지: 메시지 읽음 상태가 변경되면 UI 업데이트

  5. 안 읽은 메시지 카운트: 각 사용자별 안 읽은 메시지 개수 조회 및 표시

 

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. 한 달 간의 개발 스터디를 마치며

매주 개발 프로젝트와 미션을 수행하며 그 과정에서 해보고 싶다고 생각되는 것도 시도해보다 보니 어느새 스터디 마지막에 도달했습니다. 한 달 간의 꾸준한 코딩은 개발 트렌드 확인에 많은 도움이 되었습니다. 공부 습관도 다시 생길 것 같습니다.

아쉬운 점은 인프런 블로그 에디터가 일정 분량 이상 작성하면 급격히 느려져 매주 하고 싶었던 이야기를 충분히 담지 못했다는 것입니다. 오늘도 같은 이유로 긴 회고를 남기기 어렵네요.

짧은 시간이었지만 이번 스터디를 통해 얻은 기술적 성장과 개발 습관은 앞으로의 여정에 큰 도움이 될 것입니다.

댓글을 작성해보세요.

채널톡 아이콘