[친구하자 2025 회고] 실시간 매칭 시스템, 생각보다 어려웠던 이야기
“버튼 하나면 되는 거 아냐?” 라고 생각했던 나에게 현실이 알려준 것들
- 들어가며
- 1. 처음 그린 그림
- 2. Redis 대기열, 생각보다 복잡했다
- 3. 매칭 알고리즘의 딜레마
- 4. “알림이 안 가요”
- 5. 동시성의 늪
- 6. DB와 Redis 사이에서
- 7. 배운 것들
들어가며
“매칭이요? 그냥 대기열 만들고, 2명 되면 연결하면 되는 거 아니에요?”
처음에 나도 그렇게 생각했다. 버튼 하나 누르면 매칭되고, 바로 통화 시작. 간단하지 않나?
그런데 막상 만들기 시작하니까… 전혀 간단하지 않았다.
1. 처음 그린 그림
가장 먼저 그린 건 이런 흐름이었다:
1. 사용자가 "매칭 시작" 버튼 클릭
2. 대기열에 들어감
3. 다른 사람이 들어오면 자동 매칭
4. 통화 시작!
어렵지 않아 보였다. 그래서 바로 코딩을 시작했다.
// 첫 번째 시도
public void startMatching(Long userId) {
queue.add(userId);
if (queue.size() >= 2) {
User user1 = queue.poll();
User user2 = queue.poll();
createCall(user1, user2);
}
}
로컬에서 테스트하니까 잘 됐다. “오, 쉽네?” 싶었다.
그런데…
2. Redis 대기열, 생각보다 복잡했다
“같은 사람이랑 계속 매칭돼요”
첫 번째 문제는 금방 발견됐다. 테스트하던 친구가 말했다.
“어… 나 방금 매칭된 사람이랑 또 매칭됐는데?”
그제야 깨달았다. 그냥 랜덤으로 매칭하면 방금 통화한 사람이랑 또 만날 수 있다는 걸.
“그럼 대기순으로 하면 되겠네!”
List를 써서 먼저 온 사람부터 매칭하도록 바꿨다. 문제 해결!
“10분째 기다리고 있는데요”
그런데 이번엔 다른 문제가 생겼다.
새벽 시간대에 사용자가 적을 때, 한 명이 들어와서 10분을 기다리고 있었다. 상대방이 안 와서.
“아… 타임아웃 처리를 해야 하는구나.”
그런데 List로는 “언제 들어왔는지”를 알 수가 없었다. 그냥 순서만 알 수 있었다.
여기서 Redis의 Sorted Set(ZSET) 을 알게 됐다.
# 들어온 시각을 점수로 저장
ZADD queue:music 1734840123456 user_101
ZADD queue:music 1734840125789 user_205
이제 시간 기준으로 정렬도 되고, 오래된 사람 찾기도 쉬워졌다.
“랜덤도 하고 싶은데요?”
대기순만 하니까 너무 지루하다는 피드백이 왔다.
“가끔은 랜덤 매칭도 하면 재밌을 것 같아요!”
ZSET의 ZRANDMEMBER 명령어를 발견했다. 완벽했다.
하지만 여기서 또 고민이 생겼다. 항상 랜덤? 항상 대기순? 어떻게 섞지?
3. 매칭 알고리즘의 딜레마
공정성 vs 다양성
고민을 정리해봤다:
대기순만 하면:
✅ 공정함
❌ 지루함
랜덤만 하면:
✅ 재미있음
❌ 오래 기다린 사람 불공평
결국 하이브리드 방식을 선택했다.
// 대기순과 랜덤을 적절히 섞기
boolean useRandom = shouldUseRandom();
if (useRandom) {
// 랜덤 매칭
} else {
// 대기순 매칭
}
(비율은 사실 감으로 정했다. 나중에 데이터 보면서 조정할 생각이다.)
스케줄러 vs 실시간
또 다른 고민: 누가 들어올 때마다 매칭을 시도할까? 아니면 주기적으로 확인할까?
실시간 시도:
✅ 빠름
❌ 트래픽 많으면 부하
주기적 확인:
✅ 안정적
❌ 약간 느림
일단 주기적으로 확인하는 스케줄러로 구현했다. 1분 이내 매칭이 목표였으니까 충분했다.
@Scheduled(fixedDelay = 30000) // 30초
public void processMatching() {
// 매칭 로직
}
4. “알림이 안 가요”
매칭은 되는데, 사용자가 모른다는 게 문제였다.
WebSocket의 등장
“알림 어떻게 보내지?”
처음엔 클라이언트가 계속 서버에 물어보는 폴링 방식을 생각했다.
// 이렇게?
setInterval(() => {
fetch("/api/matching/status");
}, 1000); // 1초마다
그런데 이건 너무 비효율적이었다. 서버도 부담스럽고.
그래서 WebSocket을 쓰기로 했다.
// 구독하고 기다리면 알림이 옴
client.subscribe(`/topic/matching/${userId}`, (message) => {
// 매칭 완료!
navigate("/call");
});
타이밍 이슈
그런데 여기서 또 함정이 있었다.
1. 매칭 완료 → DB 저장
2. WebSocket 알림 발송
3. 클라이언트가 통화 정보 조회
4. ❌ 아직 DB에 커밋 안 됨!
알림은 보냈는데 DB에는 아직 데이터가 없는 상황.
클라이언트에서 에러가 났다. “통화를 찾을 수 없습니다.”
해결: 트랜잭션 커밋 후에 알림 보내기
// DB 커밋 성공 후에만 알림
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 이제야 알림 발송
sendNotification();
}
}
);
이 부분은 Spring 문서를 뒤져가며 찾았다. “트랜잭션 동기화”라는 개념을 처음 알았다.
5. 동시성의 늪
로컬에서는 잘 되던 게, 배포하니까 문제가 생겼다.
“저 두 명이랑 동시에 매칭됐어요”
한 사용자가 두 개의 통화에 동시에 매칭되는 버그였다.
원인을 찾아보니… 여러 스레드가 동시에 매칭 로직을 실행하면서 생긴 문제였다.
[스레드 A] 사용자 101, 102 매칭 시작
[스레드 B] 사용자 101, 103 매칭 시작
↓
사용자 101이 두 번 매칭됨!
해결: 사용자 상태 체크
// "나 지금 매칭 중이야" 표시
String lockKey = "user:queued:" + userId;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofMinutes(10));
if (!acquired) {
throw new AlreadyInQueueException();
}
Redis의 SETNX (Set if Not eXists) 명령어를 쓰면 원자적으로 체크하고 설정할 수 있다는 걸 배웠다.
카테고리별 독립 처리
음악, 영화, 게임… 카테고리가 여러 개인데, 하나의 카테고리에서 에러가 나면 다른 카테고리 매칭도 다 실패했다.
// 문제가 있던 코드
@Transactional
public void processMatching() {
for (Category category : categories) {
matchCategory(category); // 하나 실패하면 전체 롤백!
}
}
해결: 각 카테고리마다 독립적인 트랜잭션
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void matchCategory(Category category) {
// 이제 하나 실패해도 다른 카테고리는 계속 진행
}
REQUIRES_NEW라는 옵션이 있다는 걸 이때 알았다. Spring은 정말 배울 게 많다.
6. DB와 Redis 사이에서
어디에 뭘 저장할까?
처음엔 Redis에만 대기열을 관리했다.
Redis만 사용:
✅ 빠름
❌ 서버 재시작하면 대기열 사라짐
❌ 누가 언제 들어왔는지 기록 없음
그래서 DB에도 저장하기로 했다.
// 1. DB에 저장
MatchingQueue queue = matchingQueueRepository.save(
new MatchingQueue(userId, categoryId)
);
// 2. Redis에도 추가
redisService.enqueue(userId, categoryId);
그런데 Redis 추가가 실패하면?
1. DB 저장 성공 ✅
2. Redis 추가 실패 ❌
↓
대기열에는 없는데 DB엔 있는 상태
이게 생각보다 골치 아팠다.
처음엔 “둘 다 성공하게 만들어야지!” 라고 생각했다. 그런데 현실은 그렇지 않았다.
DB와 Redis는 완전히 다른 시스템이다. 하나의 트랜잭션으로 묶을 수 없다.
결론: 완벽한 일치는 불가능하다.
대신 이렇게 접근했다:
1. DB는 "기록용"
- 누가 언제 매칭을 시도했는지
- 통계와 분석용
2. Redis는 "실시간 처리용"
- 지금 대기 중인 사람들
- 빠른 매칭용
3. 불일치가 생기면?
- Redis 실패하면 로그 남기고
- 서버 시작할 때 DB에서 복구
“최선”을 “완벽” 대신 선택했다.
7. 배운 것들
“간단해 보이는 기능도 간단하지 않다”
“매칭 버튼 하나면 되는 거 아니야?”
아니었다. 정말 아니었다.
- 타임아웃 처리
- 중복 방지
- 알림 발송
- 동시성 제어
- 데이터 일관성
- 서버 재시작 대응
- …
하나하나 다 신경 써야 할 게 있었다.
“완벽은 없다”
처음엔 “완벽하게 만들어야지!” 라고 생각했다.
DB와 Redis가 항상 일치해야 하고, 알림이 절대 빠지면 안 되고, 매칭이 1초 만에 되어야 하고…
그런데 현실은:
- Redis는 가끔 연결이 끊긴다
- 알림은 가끔 실패한다
- 네트워크는 언제나 불안정하다
완벽을 추구하다가 영원히 못 끝낼 뻔했다.
대신 이렇게 접근했다:
✅ 대부분의 경우 잘 작동하면 OK
✅ 실패해도 복구할 수 있으면 OK
✅ 사용자가 크게 불편하지 않으면 OK
“문제는 운영하면서 발견된다”
로컬에서는 몰랐던 문제들:
- 동시성 이슈
- 타임아웃 처리
- 메모리 관리
- 네트워크 지연
다 운영하면서 만났다.
그리고 하나씩 해결했다.
“설계보다 중요한 건 개선”
처음 설계가 완벽할 순 없다.
중요한 건:
- 문제를 빨리 발견하고
- 빨리 고치고
- 다시 배포하는 것
이 사이클을 빠르게 돌리는 게 핵심이었다.
“검색과 시도의 힘”
이 과정에서 새로 배운 것들:
- Redis ZSET
- WebSocket과 STOMP
- 트랜잭션 동기화
- SETNX (원자적 연산)
- REQUIRES_NEW 전파 속성
하나도 몰랐다. 다 검색하고, 블로그 읽고, 시도하고, 실패하고, 다시 해보면서 배웠다.
“모르는 게 부끄러운 게 아니라, 배우지 않는 게 부끄러운 것”이라는 걸 다시 한번 느꼈다.
마치며
6개월 전 “매칭 기능 만들어야지!” 라고 생각했을 때는 몰랐다.
이렇게 많은 고민과 삽질이 필요할 줄.
하지만 돌아보니, 이 과정에서 정말 많이 배웠다.
- Redis의 다양한 자료구조
- WebSocket 실시간 통신
- 동시성 제어
- 분산 시스템의 어려움
- 완벽함보다 실용성
그리고 가장 중요한 것:
“모르는 건 부끄러운 게 아니다. 배우면 된다.”
하나씩 검색하고, 블로그 읽고, 시도해보고, 실패하고, 다시 해보고.
그렇게 만들어졌다.
완벽하진 않지만, 작동한다.
그리고 계속 개선하고 있다.
다음 편에서는 이 시스템을 운영하면서 만난 진짜 문제들을 이야기해보려고 한다.
- Redis 메모리가 터졌던 날
- WebSocket이 다 끊어졌던 날
- 매칭이 안 되는 버그를 3일 동안 찾았던 이야기
기대해주시길!
시리즈:
- 왜 이 기술들을 선택했나
- 실시간 매칭 시스템, 생각보다 어려웠던 이야기 ← 현재 글