[친구하자 2025 회고] 실시간 음성 매칭 서비스, 왜 이 기술들을 선택했나

컴퓨터공학 석사 출신 개발자가 첫 실전 프로젝트에서 마주한 기술 선택의 기록


들어가며

“관심사 기반 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는 단순 캐시를 넘어 세 가지 역할을 한다:

  1. 매칭 대기열 - 카테고리별로 대기 중인 사용자 관리
  2. 실시간 세션 관리 - WebSocket 연결 정보
  3. 캐시 - 자주 조회되는 카테고리 정보

자료구조 선택: 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 같은 전문 메시지 큐도 고려했다.

항목RedisRabbitMQKafka
학습 곡선낮음중간높음
운영 복잡도낮음중간높음
성능 (우리 규모)충분충분과함
비용낮음중간높음

초기 단계에서는 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를 비교 분석했다.

비교 기준:

  1. 통화 품질 - 모두 우수한 수준, 한국 리전 지원 여부가 중요
  2. Cloud Recording - 우리 서비스에 필수 기능
  3. 가격 - 초기 단계라 비용 민감
  4. 문서 및 커뮤니티 - 문제 해결이 쉬운가

최종적으로 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개월간 이 기술들로 서비스를 만들고 운영하며 정말 많이 배웠다. 선택이 완벽하진 않았지만, 후회는 없다. 각 선택의 이유를 명확히 알고 있고, 필요하면 언제든 바꿀 수 있다는 확신이 있기 때문이다!

“왜?”라는 질문을 끊임없이 던지자. 그 답이 쌓이면, 그게 바로 우리의 기술 철학이 된다.


시리즈:

  1. 왜 이 기술들을 선택했나 ← 현재 글
  2. 실시간 매칭 시스템 설계

참고 자료