[친구하자 2025 회고] 실시간 매칭 시스템, 생각보다 어려웠던 이야기

“버튼 하나면 되는 거 아냐?” 라고 생각했던 나에게 현실이 알려준 것들


들어가며

“매칭이요? 그냥 대기열 만들고, 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일 동안 찾았던 이야기

기대해주시길!


시리즈:

  1. 왜 이 기술들을 선택했나
  2. 실시간 매칭 시스템, 생각보다 어려웠던 이야기 ← 현재 글