블로그로 돌아가기
팀 파인더Discord OAuthSupabase실시간매칭

솔로 유저의 외침 - 팀 파인더 개발기

러스트 팀 구인 시스템 만들면서 겪은 Discord OAuth, 실시간 매칭, 트롤 방지 삽질기

Softopia
2024년 11월 30일
7 min read

발단: "솔로인데 팀 구해요"

디스코드 #팀구인 채널이 매일 난리였다.

[유저1] 솔로인데 팀 구해요
[유저2] 저도요
[유저3] 3명 있는데 한 분 더 구해요
[유저1] @유저3 플탐 몇이에요?
[유저3] 1500시간이요
[유저1] 저 200인데 괜찮나요...
[유저3] 아 죄송 경력자 구해요

문제점:

  1. 채팅이 휙휙 지나가서 못 봄
  2. 누가 아직 팀 구하는지 모름
  3. 플탐, 플레이 스타일 매번 물어봐야 함
  4. 트롤/악성 유저 필터링 없음

"그냥 프로필 올려놓고 매칭되면 알림 받게 하면 되는 거 아냐?"

그렇게 팀 파인더 개발이 시작됐다.


Discord OAuth: 가장 귀찮은 부분

왜 Discord 로그인인가?

  1. 본인 확인: 디스코드 아이디로 연락할 수 있음
  2. 트롤 방지: 새 계정 만들어 악용하기 어려움
  3. 편의성: 이미 다들 디스코드 쓰고 있음

Supabase Auth 설정

Supabase가 OAuth를 대신 처리해준다. 편함.

// Discord OAuth 로그인
async function signInWithDiscord() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'discord',
    options: {
      redirectTo: `${window.location.origin}/community/team-finder`,
      scopes: 'identify guilds',  // 유저 정보 + 서버 목록
    }
  });
  
  if (error) throw error;
  return data;
}

삽질 1: Redirect URL

Discord Developer Portal에서 Redirect URL 등록해야 함.

http://localhost:5173/team-finder  ← 개발용
https://pharos.softopia.dev/team-finder  ← 프로덕션

처음에 이거 안 해서 30분 삽질. "OAuth2 Error: redirect_uri_mismatch"

삽질 2: Scope 부족

scopes: 'identify'  // 이것만 했더니

유저가 가입한 서버 목록을 못 가져옴. guilds scope 추가.

scopes: 'identify guilds'  // 이러니까 됨

삽질 3: 아바타 URL

Discord 아바타 URL 형식:

https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png

근데 아바타 없는 유저는 이게 null. 기본 아바타 처리:

function getAvatarUrl(user) {
  if (user.avatar_url) return user.avatar_url;
  
  // 기본 아바타 (discriminator 기반)
  const defaultAvatar = parseInt(user.discriminator || '0') % 5;
  return `https://cdn.discordapp.com/embed/avatars/${defaultAvatar}.png`;
}

프로필 데이터 설계

팀 구할 때 뭘 보여줘야 하나?

필수 정보

interface TeamProfile {
  // Discord에서 가져옴
  discordId: string;
  username: string;
  avatarUrl: string;
  
  // 유저가 입력
  playStyle: 'pvp' | 'pve' | 'farming' | 'building' | 'all';
  playTime: number;        // 러스트 플레이타임 (시간)
  activeHours: string;     // 활동 시간대 "21:00-02:00"
  teamSize: 'solo' | 'duo' | 'trio' | 'quad' | 'zerg';
  language: 'ko' | 'ja' | 'en';
  
  // 추가 정보
  introduction: string;    // 자기소개 (200자)
  experience: string;      // 경력 설명
}

DB 스키마 (Supabase)

CREATE TABLE team_posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id),
  discord_id TEXT NOT NULL,
  username TEXT NOT NULL,
  avatar_url TEXT,
  
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  region TEXT DEFAULT 'KR',  -- KR, JP, Global
  status TEXT DEFAULT 'open' CHECK (status IN ('open', 'closed')),
  
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 검색용 인덱스
CREATE INDEX idx_team_posts_status ON team_posts(status) WHERE status = 'open';
CREATE INDEX idx_team_posts_region ON team_posts(region);
CREATE INDEX idx_team_posts_user ON team_posts(user_id);

실시간 매칭: Supabase Realtime

프로필 목록이 실시간으로 업데이트되어야 한다. 새 프로필이 올라오면 바로 보여야 함.

// 실시간 구독
function subscribeToPosts(onUpdate: (posts: TeamPost[]) => void) {
  const subscription = supabase
    .channel('team_posts_changes')
    .on(
      'postgres_changes',
      { 
        event: '*',  // INSERT, UPDATE, DELETE 모두
        schema: 'public',
        table: 'team_posts',
        filter: 'status=eq.open'  // 열린 게시글만
      },
      (payload) => {
        // 변경 발생 시 전체 목록 다시 fetch
        fetchPosts().then(onUpdate);
      }
    )
    .subscribe();
  
  return () => subscription.unsubscribe();
}

React에서 사용

function TeamFinderPage() {
  const [profiles, setProfiles] = useState<TeamProfile[]>([]);
  
  useEffect(() => {
    // 초기 로드
    fetchProfiles().then(setProfiles);
    
    // 실시간 구독
    const unsubscribe = subscribeToProfiles(setProfiles);
    
    return unsubscribe;
  }, []);
  
  // ...
}

삽질: 너무 많은 업데이트

프로필 하나 바뀔 때마다 전체 목록 다시 fetch하니까 비효율적.

해결: 변경된 것만 업데이트

.on('postgres_changes', { event: 'INSERT' }, (payload) => {
  setProfiles(prev => [...prev, payload.new as TeamProfile]);
})
.on('postgres_changes', { event: 'UPDATE' }, (payload) => {
  setProfiles(prev => 
    prev.map(p => p.id === payload.new.id ? payload.new as TeamProfile : p)
  );
})
.on('postgres_changes', { event: 'DELETE' }, (payload) => {
  setProfiles(prev => prev.filter(p => p.id !== payload.old.id));
})

필터링 & 검색

interface ProfileFilters {
  playStyle?: string;
  teamSize?: string;
  language?: string;
  minPlayTime?: number;
}

async function fetchProfiles(filters: ProfileFilters = {}) {
  let query = supabase
    .from('team_profiles')
    .select('*')
    .eq('is_looking', true)
    .order('last_active', { ascending: false });
  
  if (filters.playStyle) {
    query = query.eq('play_style', filters.playStyle);
  }
  if (filters.teamSize) {
    query = query.eq('team_size', filters.teamSize);
  }
  if (filters.language) {
    query = query.eq('language', filters.language);
  }
  if (filters.minPlayTime) {
    query = query.gte('play_time', filters.minPlayTime);
  }
  
  const { data, error } = await query;
  if (error) throw error;
  return data;
}

UI:

[ PVP ▼ ] [ 듀오 ▼ ] [ 한국어 ▼ ] [ 플탐 500+ ▼ ]

관심 표시 & 매칭

프로필 보고 마음에 들면 "관심 있어요" 버튼 클릭.

CREATE TABLE team_interests (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  from_user_id UUID REFERENCES team_profiles(id),
  to_user_id UUID REFERENCES team_profiles(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  
  UNIQUE(from_user_id, to_user_id)  -- 중복 방지
);
// 관심 표시
async function expressInterest(toUserId: string) {
  const user = await getCurrentUser();
  
  const { error } = await supabase
    .from('team_interests')
    .insert({
      from_user_id: user.id,
      to_user_id: toUserId,
    });
  
  if (error) throw error;
}

상호 관심 = 매칭

// 서로 관심 표시했는지 확인
async function checkMutualInterest(userId1: string, userId2: string) {
  const { data } = await supabase
    .from('team_interests')
    .select('*')
    .or(`and(from_user_id.eq.${userId1},to_user_id.eq.${userId2}),and(from_user_id.eq.${userId2},to_user_id.eq.${userId1})`);
  
  return data?.length === 2;  // 양쪽 다 있으면 매칭
}

매칭되면 서로의 Discord 연락처 공개.


트롤 방지

1. 신고 시스템

CREATE TABLE reports (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  reporter_id UUID REFERENCES team_profiles(id),
  reported_id UUID REFERENCES team_profiles(id),
  reason TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

신고 3회 이상이면 자동 숨김:

-- 프로필 조회 시
SELECT * FROM team_profiles
WHERE is_looking = true
  AND id NOT IN (
    SELECT reported_id 
    FROM reports 
    GROUP BY reported_id 
    HAVING COUNT(*) >= 3
  );

2. 최근 활동 기준

오래된 프로필은 목록에서 제외:

// 7일 이내 활동한 유저만
query = query.gte('last_active', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());

3. 프로필 검증

플탐 1만 시간 같은 거짓 정보 방지... 는 포기.

Steam API 연동하면 실제 플탐 가져올 수 있지만:

  • Steam 로그인 추가 필요
  • 비공개 프로필이면 못 가져옴
  • 개발 공수 대비 효과 낮음

그냥 "허위 정보는 신고해주세요"로 처리.


실패한 시도들

1. 자동 매칭 알고리즘

처음엔 "조건 맞으면 자동 매칭" 시스템을 생각했다.

// 플레이 스타일 + 팀 규모 + 활동 시간대가 맞으면 자동 매칭
function findMatches(profile: TeamProfile) {
  return profiles.filter(p => 
    p.playStyle === profile.playStyle &&
    p.teamSize === profile.teamSize &&
    overlapsActiveHours(p.activeHours, profile.activeHours)
  );
}

문제: 조건 맞아도 사람마다 호불호가 있음. "자기소개 읽고 직접 선택하고 싶어요."

결론: 자동 매칭 대신 필터링 + 직접 선택 방식으로.

2. 평판 점수

매칭 후 "좋았어요/별로였어요" 평가해서 점수 누적.

문제:

  • 평가 안 하는 사람이 대부분
  • 보복 평가 가능성 (서로 나쁜 평가)
  • 초기 사용자는 점수가 없어서 불리

결론: 단순 신고 시스템만 유지. 긍정 평가는 나중에.

3. 채팅 기능

사이트 내에서 채팅하게.

문제:

  • 어차피 Discord로 연락함
  • 채팅 인프라 구축 비용
  • 메시지 저장 = 개인정보 이슈

결론: Discord DM으로 유도. 우리는 연결만 해주자.


현재 기능

  1. Discord 로그인: OAuth로 간편 가입
  2. 프로필 작성: 플레이 스타일, 플탐, 활동 시간대
  3. 실시간 목록: 새 프로필 즉시 표시
  4. 필터링: 조건별 검색
  5. 관심 표시: 상호 관심 시 연락처 공개
  6. 신고: 트롤/악성 유저 처리

사용 통계

| 항목 | 수치 | |------|------| | 등록 프로필 | ~150개 | | 월 매칭 | ~40쌍 | | 신고 | 3건/월 | | 평균 응답 시간 | 4시간 |

매칭률이 생각보다 높다. 등록하면 절반은 팀을 찾음.


배운 것

  1. OAuth는 처음만 어렵다: 한번 해보면 다음부터 쉬움
  2. Supabase Realtime 편함: WebSocket 직접 구현하는 것보다 100배 편함
  3. 자동화보다 수동이 나을 때 있다: 매칭 알고리즘보다 직접 선택이 만족도 높음
  4. 트롤 방지는 단순하게: 복잡한 평판 시스템보다 신고 3회=차단이 효과적

남은 과제

  • [ ] Steam 플탐 연동 (선택 사항)
  • [ ] 모바일 알림 (매칭 시)
  • [ ] 팀 모집글 기능 (개인이 아닌 팀 단위)

다음 편에서는 Discord 봇. 서버 상태, 접속자 알림을 디스코드로 보내는 방법.


질문 있으면 디스코드로: softopia