솔로 유저의 외침 - 팀 파인더 개발기
러스트 팀 구인 시스템 만들면서 겪은 Discord OAuth, 실시간 매칭, 트롤 방지 삽질기
발단: "솔로인데 팀 구해요"
디스코드 #팀구인 채널이 매일 난리였다.
[유저1] 솔로인데 팀 구해요
[유저2] 저도요
[유저3] 3명 있는데 한 분 더 구해요
[유저1] @유저3 플탐 몇이에요?
[유저3] 1500시간이요
[유저1] 저 200인데 괜찮나요...
[유저3] 아 죄송 경력자 구해요
문제점:
- 채팅이 휙휙 지나가서 못 봄
- 누가 아직 팀 구하는지 모름
- 플탐, 플레이 스타일 매번 물어봐야 함
- 트롤/악성 유저 필터링 없음
"그냥 프로필 올려놓고 매칭되면 알림 받게 하면 되는 거 아냐?"
그렇게 팀 파인더 개발이 시작됐다.
Discord OAuth: 가장 귀찮은 부분
왜 Discord 로그인인가?
- 본인 확인: 디스코드 아이디로 연락할 수 있음
- 트롤 방지: 새 계정 만들어 악용하기 어려움
- 편의성: 이미 다들 디스코드 쓰고 있음
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으로 유도. 우리는 연결만 해주자.
현재 기능
- Discord 로그인: OAuth로 간편 가입
- 프로필 작성: 플레이 스타일, 플탐, 활동 시간대
- 실시간 목록: 새 프로필 즉시 표시
- 필터링: 조건별 검색
- 관심 표시: 상호 관심 시 연락처 공개
- 신고: 트롤/악성 유저 처리
사용 통계
| 항목 | 수치 | |------|------| | 등록 프로필 | ~150개 | | 월 매칭 | ~40쌍 | | 신고 | 3건/월 | | 평균 응답 시간 | 4시간 |
매칭률이 생각보다 높다. 등록하면 절반은 팀을 찾음.
배운 것
- OAuth는 처음만 어렵다: 한번 해보면 다음부터 쉬움
- Supabase Realtime 편함: WebSocket 직접 구현하는 것보다 100배 편함
- 자동화보다 수동이 나을 때 있다: 매칭 알고리즘보다 직접 선택이 만족도 높음
- 트롤 방지는 단순하게: 복잡한 평판 시스템보다 신고 3회=차단이 효과적
남은 과제
- [ ] Steam 플탐 연동 (선택 사항)
- [ ] 모바일 알림 (매칭 시)
- [ ] 팀 모집글 기능 (개인이 아닌 팀 단위)
다음 편에서는 Discord 봇. 서버 상태, 접속자 알림을 디스코드로 보내는 방법.
질문 있으면 디스코드로: softopia