[친구하자 2025 회고] 실시간 음성 매칭 서비스, 왜 이 기술들을 선택했나
컴퓨터공학 석사 출신 개발자가 첫 실전 프로젝트에서 마주한 기술 선택의 기록
- 들어가며
- 1. 전체 그림: 우리 서비스가 필요한 것
- 2. Backend: Spring Boot + WebFlux를 선택한 이유
- 3. Database: MySQL을 선택한 이유
- 4. Cache & Queue: Redis를 선택한 이유
- 5. WebRTC: Agora를 선택한 이유
- 6. File Storage: Firebase Storage를 선택한 이유
- 7. Frontend: React + Capacitor를 선택한 이유
- 8. 배포 환경: AWS + Vercel
- 9. 기술 선택의 원칙
- 10. 마치며
들어가며
“관심사 기반 1:1 랜덤 음성 통화”라는 아이디어를 떠올렸을 때, 가장 먼저 든 생각은 “어떻게 구현하지?”였다. 학부와 석사 과정에서 배운 이론과 토이 프로젝트 경험은 있었지만, 실제 사용자가 쓸 서비스를 만드는 것은 처음이었다.
기술 스택을 선택하는 과정에서 수많은 블로그 글을 읽었고, 여러 서비스를 비교했다. 하지만 결국 깨달은 건 “정답은 없다”는 것이었다. 각 기술은 장단점이 있고, 중요한 건 우리 서비스의 맥락에서 어떤 게 적합한가였다.
이 글에서는 내가 왜 이런 선택을 했는지, 어떤 고민을 했는지를 솔직하게 공유해보려 한다.
1. 전체 그림: 우리 서비스가 필요한 것
기술을 선택하기 전에, 먼저 우리 서비스의 특성을 정리했다.
핵심 요구사항
1. 실시간성
- 매칭 대기 시간을 최소화 (목표: 1분 이내)
- 매칭 완료 즉시 알림
- 끊김 없는 음성 통화
2. 확장성
- 초기엔 소규모지만, 사용자 증가에 대응 가능해야 함
- 트래픽 급증 시나리오 고려
3. 운영 편의성
- 문제 발생 시 빠른 파악과 대응
- 모니터링과 로깅
- 배포 자동화
4. 비용 효율성
- MVP 단계에서 과도한 인프라 비용 지양
- 사용량에 따른 탄력적 비용 구조
이 네 가지 축을 기준으로 모든 기술 선택을 평가했다.
2. Backend: Spring Boot + WebFlux를 선택한 이유
왜 비동기가 필요했나?
처음에는 익숙한 Spring MVC를 쓸까 고민했다. 하지만 우리 서비스의 특성을 생각해보니 WebFlux가 더 적합해 보였다.
// 시나리오: 통화 종료 후 녹음 저장
public void endCall(Long callId) {
// 1. 통화 종료 처리
callService.endCall(callId);
// 2. Agora API 호출 (외부 API, 2-3초 소요)
agoraService.stopRecording(callId); // ← 여기서 블로킹!
// 3. 평가 페이지로 리다이렉트
return "redirect:/evaluation";
}
동기 방식의 문제는 Agora API 응답을 기다리는 2-3초 동안 스레드가 블로킹된다는 거였다. 200개 스레드로 동시 200명만 처리 가능하고, 대기 시간이 증가하면 사용자 경험이 저하된다.
비동기 방식을 쓰면:
public Mono<Void> endCall(Long callId) {
return callService.endCall(callId)
.then(agoraService.stopRecording(callId)) // 논블로킹
.then();
// 스레드가 즉시 다른 요청 처리 가능
}
실제 경험: WebFlux의 학습 곡선
솔직히 말하면, WebFlux는 쉽지 않았다.
// ❌ 이렇게 하면 안 됨
public Mono<User> getUser(Long id) {
User user = userRepository.findById(id).block(); // 블로킹!
return Mono.just(user);
}
// ✅ 올바른 방법
public Mono<User> getUser(Long id) {
return userRepository.findById(id); // 논블로킹 체인
}
block()을 쓰는 순간 비동기의 의미가 사라진다. “Reactive하게 생각하기”까지 시간이 필요했지만, 익숙해지니 외부 API 호출이 많은 우리 서비스에 딱 맞았다.
언제 WebFlux를 쓰면 좋을까?
내 경험상 이런 경우 WebFlux가 적합하다:
✅ WebFlux 추천:
- 외부 API 호출이 많은 서비스
- WebSocket 등 실시간 통신 필요
- I/O 대기 시간이 긴 작업
- 동시 접속자가 많은 서비스
❌ WebFlux 비추천:
- CPU 집약적 작업 (이미지 처리, 암호화 등)
- 간단한 CRUD 중심 서비스
- 팀이 Spring MVC에 익숙하고, 학습 시간이 부족한 경우
3. Database: MySQL을 선택한 이유
MySQL vs NoSQL 고민
“요즘 트렌드는 NoSQL 아닌가?”라는 생각도 했다. MongoDB를 쓸까 진지하게 고민했다.
우리 데이터의 특성을 분석해봤다:
User ←→ Friendships ←→ User
↓
Calls ←→ CallRecordings
↓
CallEvaluations
친구 관계는 양방향이고 상태 관리가 필요하다 (요청중/수락/차단). 통화 이력은 두 사용자 간의 연결이고, 평가는 통화와 1:1 관계다.
이런 구조를 보니 관계형 데이터베이스가 더 자연스러웠다.
데이터 일관성의 중요성
특히 친구 관계에서 일관성이 중요했다.
-- 친구 요청은 반드시 양쪽 모두 저장되어야 함
BEGIN TRANSACTION;
INSERT INTO friendships (user_a_id, user_b_id, status) VALUES (1, 2, 'PENDING');
INSERT INTO friendships (user_b_id, user_a_id, status) VALUES (2, 1, 'REQUESTED');
COMMIT;
-- 하나라도 실패하면 전체 롤백
NoSQL에서는 이런 트랜잭션 보장이 상대적으로 약하다.
쿼리 복잡도
통화 이력 조회 같은 경우, JOIN이 필요했다:
-- 내 통화 이력 + 상대방 정보 + 친구 상태
SELECT
c.*,
u.nickname as partner_name,
f.status as friend_status
FROM calls c
JOIN users u ON (c.user_a_id = u.id OR c.user_b_id = u.id) AND u.id != :myId
LEFT JOIN friendships f ON f.friend_id = u.id AND f.user_id = :myId
WHERE c.user_a_id = :myId OR c.user_b_id = :myId
ORDER BY c.created_at DESC;
NoSQL에서 이런 쿼리는 애플리케이션 레벨에서 여러 번 조회해야 한다.
그럼 NoSQL은 언제?
내가 생각하는 NoSQL 적합 케이스:
✅ NoSQL 추천:
- 스키마가 자주 변하는 경우
- 단순 Key-Value 조회가 대부분
- 대용량 로그/이벤트 데이터
- 수평 확장이 필수적인 경우
❌ NoSQL 비추천:
- 복잡한 관계와 트랜잭션 필요
- JOIN 쿼리가 많은 경우
- 데이터 일관성이 중요한 비즈니스 로직
결론은 MySQL로 시작하고, 필요시 캐시(Redis)와 조합하기로 했다.
4. Cache & Queue: Redis를 선택한 이유
Redis의 세 가지 역할
우리 서비스에서 Redis는 단순 캐시를 넘어 세 가지 역할을 한다:
- 매칭 대기열 - 카테고리별로 대기 중인 사용자 관리
- 실시간 세션 관리 - WebSocket 연결 정보
- 캐시 - 자주 조회되는 카테고리 정보
자료구조 선택: ZSET vs SET vs List
매칭 대기열을 어떤 자료구조로 구현할지 고민이 많았다.
시도 1: List (LPUSH/RPOP)
# 입장
LPUSH wait:music userId_101
# 매칭 (2명 추출)
RPOP wait:music # userId_101
RPOP wait:music # userId_205
문제점은 대기 순서는 보장되지만, 타임아웃 처리가 어렵고 랜덤 매칭이 불가능했다.
시도 2: Set (SADD/SPOP)
# 입장
SADD wait:music userId_101
# 매칭 (랜덤 2명)
SPOP wait:music 2
랜덤은 쉽지만 대기 순서를 알 수 없어서 공정성이 없었다.
최종 선택: ZSET (Sorted Set)
# 입장 (점수 = 대기 시작 시각)
ZADD wait:music 1725430000456 userId_101
ZADD wait:music 1725430001234 userId_205
# 대기순 매칭
ZPOPMIN wait:music 2
# 랜덤 매칭
ZRANDMEMBER wait:music 2
장점이 명확했다:
- 대기 시간 기준 정렬 ✅
- 대기순/랜덤 모두 가능 ✅
- 타임아웃 쉬움 (점수 기준 삭제) ✅
왜 별도의 메시지 큐를 안 썼나?
RabbitMQ, Kafka 같은 전문 메시지 큐도 고려했다.
| 항목 | Redis | RabbitMQ | Kafka |
|---|---|---|---|
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 운영 복잡도 | 낮음 | 중간 | 높음 |
| 성능 (우리 규모) | 충분 | 충분 | 과함 |
| 비용 | 낮음 | 중간 | 높음 |
초기 단계에서는 Redis로 충분하다고 판단했다. 트래픽이 늘면 그때 전환을 고려하기로 했다. “과도한 엔지니어링”을 경계해야 한다고 생각했다.
5. WebRTC: Agora를 선택한 이유
직접 구현 vs SaaS
WebRTC를 직접 구현할지, SaaS를 쓸지 고민했다.
직접 구현 시나리오를 생각해보니:
필요한 것:
- STUN/TURN 서버 구축 및 운영
- Signaling 서버 개발
- NAT Traversal 처리
- 통화 품질 모니터링
- 녹음 기능 구현
예상 개발 시간: 2-3개월
예상 운영 부담: 높음
SaaS 사용 시나리오:
필요한 것:
- SDK 통합 (1-2주)
- 설정 및 테스트
예상 개발 시간: 2주
예상 운영 부담: 낮음
MVP는 빠르게 검증하는 게 중요하다고 생각해서 SaaS를 선택했다.
Agora vs Twilio vs Daily
WebRTC SaaS를 비교 분석했다.
비교 기준:
- 통화 품질 - 모두 우수한 수준, 한국 리전 지원 여부가 중요
- Cloud Recording - 우리 서비스에 필수 기능
- 가격 - 초기 단계라 비용 민감
- 문서 및 커뮤니티 - 문제 해결이 쉬운가
최종적으로 Agora를 선택한 이유:
- Cloud Recording 기능이 강력 (Individual Mode 지원)
- Firebase Storage 직접 연동 가능
- 가격 경쟁력
- 한국어 문서와 한국 리전 지원
다만 Twilio보다 커뮤니티가 작고, 일부 고급 기능은 Twilio가 우세하다는 점은 고려사항이었다.
실제 사용 후기
Agora를 6개월간 사용한 경험을 정리하면:
만족스러운 점:
- SDK 통합이 생각보다 쉬움
- 녹음 기능이 안정적
- 통화 품질 좋음
아쉬운 점:
- 일부 에러 메시지가 불친절
- 고급 설정 시 영어 문서 의존도 높음
6. File Storage: Firebase Storage를 선택한 이유
왜 AWS S3가 아닌가?
통화 녹음 파일을 저장할 곳이 필요했다. 가장 먼저 떠오른 건 AWS S3였다.
고민한 점:
AWS S3:
✅ 업계 표준
✅ 많은 레퍼런스
✅ 강력한 기능
Firebase Storage:
✅ 이미 프로필 이미지에 사용 중
✅ GCS 기반 (Agora와 호환성 좋음)
✅ Firebase 생태계 활용 가능
실용적 판단
결정적이었던 건 이미 사용하고 있다는 점이었다.
새로운 기술을 도입하면:
- 학습 시간
- 설정 및 테스트
- 운영 노하우 축적
- 문제 해결 경험
이 모든 비용이 든다.
기존 기술을 활용하면:
- 즉시 사용 가능
- 이미 익숙함
- 통합 관리 편함
“최신/유명 != 우리에게 최선”이라는 걸 다시 한번 깨달았다.
Agora와의 통합
특히 Agora Cloud Recording이 GCS(Google Cloud Storage)와 직접 연동된다는 점이 좋았다.
Agora → Firebase Storage (GCS 기반)
- HMAC 인증으로 직접 업로드
- 별도의 파일 전송 서버 불필요
- 자동 업로드 (15초마다 segment)
7. Frontend: React + Capacitor를 선택한 이유
웹인가, 앱인가?
처음부터 모바일 앱을 목표로 했다. 음성 통화 특성상 앱이 더 자연스럽다고 판단했다.
선택지를 정리하면:
1. 웹 (React)만 개발
→ 빠르지만 접근성 낮음
2. React Native
→ 네이티브 성능, 웹 코드 재사용 불가
3. Flutter
→ 성능 좋음, Dart 새로 학습 필요
4. Capacitor (선택!)
→ 웹 코드 100% 재사용, WebView 기반
Capacitor의 장점
가장 큰 장점은 빠른 개발이었다.
// 기존 React 웹 코드
const CallPage = () => {
const [channel, setChannel] = useState('');
// Agora WebRTC 로직
// ...
}
// Capacitor 추가 후
npm install @capacitor/core
npx cap add android ios
// 앱 빌드 완료!
단일 코드베이스로 관리할 수 있다는 것도 큰 장점이었다:
src/
components/
CallPage.tsx ← 웹, Android, iOS 모두 사용
MatchingButton.tsx
lib/
agora.ts ← 한 번만 작성
websocket.ts
네이티브 기능 접근
Capacitor 플러그인으로 네이티브 기능도 사용 가능했다:
// 카카오 네이티브 로그인
import { KakaoLoginPlugin } from "capacitor-kakao-login-plugin";
const result = await KakaoLoginPlugin.goLogin();
// 구글 네이티브 로그인
import { GoogleAuth } from "@codetrix-studio/capacitor-google-auth";
const user = await GoogleAuth.signIn();
한계와 트레이드오프
WebView 제약이 있다:
- 네이티브 앱보다 성능 떨어짐
- 일부 브라우저 제약 (CORS, Cookie 등)
- 플랫폼별 차이 (iOS vs Android)
그럼에도 선택한 이유는:
- 속도: 2-3개월 단축
- 유지보수: 하나의 코드베이스
- 검증: MVP 빠르게 출시
초기에는 Capacitor로 시작하고, 성장하면 부분적으로 네이티브 전환을 고려하기로 했다.
8. 배포 환경: AWS + Vercel
Backend: AWS EC2
“왜 컨테이너(ECS, EKS)가 아닌가?”라는 질문을 받을 수 있는데:
우리 상황:
- 단일 서버로 충분
- 오토 스케일링 아직 불필요
- 운영 복잡도 최소화
선택:
- EC2 단일 인스턴스
- Nginx 리버스 프록시
- Let’s Encrypt SSL
비용도 고려했다. ECS/EKS는 추가 비용이 들고, Elastic Load Balancer도 고정 비용이 든다. EC2 단독이 가장 경제적이었다.
Frontend: Vercel
왜 Vercel인가?
✅ 장점:
- Git push → 자동 배포
- HTTPS 기본 제공
- CDN 자동 설정
- 무료 플랜으로 충분
vs
AWS S3 + CloudFront:
- 직접 설정 필요
- 비용 발생
- CI/CD 직접 구축
프론트엔드는 정적 파일이므로 Vercel이 훨씬 편했다.
9. 기술 선택의 원칙
6개월간의 경험을 돌아보니, 내게는 몇 가지 원칙이 생겼다.
원칙 1: “맥락”이 가장 중요하다
❌ "요즘 A가 대세니까 A를 써야지"
✅ "우리 상황에서는 B가 더 적합해"
예시:
- Kafka는 강력하지만 우리 규모에는 과함 → Redis 선택
- React Native는 좋지만 웹 코드 재사용 중요 → Capacitor 선택
원칙 2: MVP는 빠르게, 확장은 점진적으로
처음부터 완벽한 설계 (X)
빠른 검증 → 점진적 개선 (O)
실제 적용:
- 단일 EC2로 시작 → 트래픽 증가 시 확장 고려
- Redis 대기열 → 필요시 Kafka 전환
- Capacitor → 성장하면 부분 네이티브
원칙 3: “익숙함”도 중요한 가치
새로운 기술 학습 비용 vs 익숙한 기술 활용
학부/석사에서 Spring 사용 경험
→ Spring Boot 선택이 자연스러움
원칙 4: 비용은 현실적 제약
초기 단계의 인프라 비용:
- EC2, RDS, Redis, Agora, Firebase
→ 월 수십 달러 수준으로 통제
vs
이상적 아키텍처:
- ECS, ELB, CloudFront, Kafka...
→ 월 수백 달러
서비스 검증이 우선이고, 인프라는 필요 시 확장하기로 했다.
10. 마치며
기술 선택은 트레이드오프의 연속이었다.
- 성능 vs 개발 속도
- 이상 vs 현실
- 최신 기술 vs 익숙한 기술
- 비용 vs 확장성
중요한 건 “정답”을 찾는 게 아니라, “우리에게 맞는 답”을 찾는 것이었다.
6개월간 이 기술들로 서비스를 만들고 운영하며 정말 많이 배웠다. 선택이 완벽하진 않았지만, 후회는 없다. 각 선택의 이유를 명확히 알고 있고, 필요하면 언제든 바꿀 수 있다는 확신이 있기 때문이다!
“왜?”라는 질문을 끊임없이 던지자. 그 답이 쌓이면, 그게 바로 우리의 기술 철학이 된다.
시리즈:
- 왜 이 기술들을 선택했나 ← 현재 글
- 실시간 매칭 시스템 설계