추천 캐릭터 브라우징 시스템 성능 개선기


우리 서비스의 메인 화면에서는 사용자가 앱에 진입하자마자 여러 캐릭터를 확인하고, 바로 채팅을 시작할 수 있도록 추천 캐릭터 브라우징 기능을 제공하고 있다.

이 영역은 단순 페이징 조회가 아니라 지금 서비스 방향에 맞는 캐릭터를 노출하고 실제로 사용자 반응이 좋은 캐릭터를 우선 보여주며 마케팅, A/B 테스트, 트렌드 실험에 따라 유연하게 조정할 수 있어야 하는 상당히 중요한 진입 지점이다.

우리는 이 추천을 위해 오픈소스 추천 시스템인 Gorse를 사용하고 있었고,
User와 Character를 객체화한 뒤 Feedback API를 활용해 빠르게 추천 시스템을 구축할 수 있다.

하지만 서비스 방향 실험이 잦아지면서, 기존 구조가 점점 한계를 드러내기 시작했다.

서비스를 운영하다 보면 추천 기준은 생각보다 자주 바뀐다.
예를 들어, 여성 유저 대상 마케팅 직전에는 남성 캐릭터를 더 노출해야 했고 특정 카테고리(로맨스, 판타지 등)의 수요를 검증하기 위해 A/B 테스트를 진행하기도 했으며 이때는 단순 랜덤이 아니라 해당 카테고리 내에서 실제로 인기 있는 캐릭터를 보여줘야 했다.

이 요구를 만족하기 위해, 우리는 피드백 기반 집계 방식을 선택했다.


초기 구현 방향

UserCharacterFeedback 컬렉션을 만들고, “어떤 유저가 어떤 캐릭터에 대해 피드백을 남겼는가” 를 계속 저장했다.

위 ERD를 보면 알겠지만, (user_id, character_id) 복합키 구조가 아니기 때문에, 여러 캐릭터에 대해 중복으로 피드백을 줄 수 있다.

하지만 무분별하게 같은 캐릭터에 계속해서 피드백을 주는 것을 막기 위해, Redis로 피드백을 캐싱하여 같은 캐릭터에 대해서는 3시간에 한 번 씩만 피드백을 줄 수 있도록 하였다.

그렇게 하면 사용자들이 많이 보는 캐릭터에 대한 피드백이 쌓일 것이고, 피드백이 많은 캐릭터 순으로 보여주면 자연스럽게 추천이 가능하였다.

쿼리 흐름은 아래와 같이 구성하였다.

  1. Group by character_id
  2. Count each groups
  3. Order by count
  4. Offset and limit (Paging)
  5. Join characters and users

테이블 수정 없이 피드백 컬렉션만 추가하면 되었고, 구현 속도도 빨라 초기 실험용으로는 충분히 합리적이었다.


하지만 배포 약 일주일 후, Grafana를 모니터링 하던 중 API 응답 시간이 4배 정도 증가한 것을 확인했다.

배포 당일 날 200 ms 정도를 유지하던 API Response Time이 일주일 후 시점에 800 ms로 증가한 것이다.

차라리 800 ms 수준을 계속 유지했다면 크게 문제가 안 되었을 수도 있지만, 그래프를 보니 선형적으로 증가한 모습이 관찰되어 개선이 필요하다고 느꼈다.

800ms도 문제지만, 이 추세라면 시간이 갈수록 못 쓰게 될 구조라는 판단이 들었다.

게다가 이 기능은 사용자가 홈화면에 들어오자마자 보게되는 기능이므로 가능한 짧은 지연으로 서비스를 제공하는 것이 좋다고 생각하였다.


원인을 분석해보니, UserCharacterFeedback 컬렉션에 약 50,000개 정도의 데이터가 쌓여있었다.

배포 전 100,000개 정도로 조회 쿼리 테스트를 했는데, 데이터베이스 조회 쿼리 시간이 500-700 ms 정도 되었기에 쿼리에 크게 문제가 없을 것이라고 판단했었다.

크게 비효율적인 쿼리라면 100,000개 정도의 데이터에서도 충분히 그 비효율성을 보여줄 것이라고 생각했다.

하지만 간과한 것은 나의 생각보다 피드백 데이터가 쌓이는 속도가 더 빨랐다는 것이다.

피드백을 부여하는 동작은

  • Character Card Open
  • Like / Scrap Character
  • Enter Chatroom
  • Chat 10 Times

등과 같이 매우 다양했기 때문에, 한 캐릭터에 대한 피드백이 쌓일 수 있는 경로가 많았다.

피드백이 쌓일 수 있는 경로가 많은 것은 문제가 되지 않는다고 생각하나, 캐릭터 추천 API 같은 중요한 API에서 성능 저하가 데이터 개수에 비례하여 눈에 띄게 나타나는 것은 확실히 문제가 있었다.


또한, 이 방법에는 조회 쿼리가 피드백 데이터의 개수에 크게 민감하다는 것 말고도 다른 문제점이 여럿 있었다.

  1. Negative Feedback 불가능

피드백 컬렉션에 Positive / Negative에 대한 정보가 없었기 때문에 이 피드백을 구분하지 못 한다.

물론 필드를 넣으면 될 일이지만, 이를 종합적으로 고려하여 조회 쿼리를 구성하는 것은 쉽지 않을 뿐만 아니라, 피드백 데이터 개수에 민감하다는 것은 해결하지 못 한다.

  1. 캐릭터 형태에 따른 가중치 부여 불가능

피드백 데이터는 삽입만 가능하기 떄문에 같은 정도의 중요도를 갖는다.
하지만 우리가 로맨스 캐릭터를 조금 더 상위로 올려야 한다면 이 피드백은 조금 더 중요하게 다뤄저야 한다.

따라서 이번 개선 시 위와 같은 문제를 함께 해결하여 추천 브라우징 시스템을 개선하기로 하였다.


우선 주목한 점은 UserCharacterFeedback 컬렉션에 user_id 필드가 있는데, 우리는 서비스 단에서 user_id를 사용하지 않는다는 것이었다.

어떤 캐릭터에 피드백이 들어왔는지 중요햐지, 어떤 유저가 피드백을 넣었는지는 크게 중요하지 않았다.

  • 물론 서비스 단에서는 직접 사용하지 않지만, 후에 분석을 위해 이를 식별은 할 수 있어야 한다고 생각한다.

피드백을 저장해서 나중에 계산하지 말고, 피드백이 들어오는 순간 캐릭터의 점수를 갱신하는 방식으로 접근 방식을 바꿨다.

기존에는 Feedback 데이터를 모두 저장하고 있다가 그 피드백 데이터를 기준으로 쿼리를 날려서 추천 캐릭터를 계산했는데, 피드백이 들어올 때마다 Score를 업데이트해서 그 캐릭터의 추천 정도를 확정하는 방식으로 변경하였다.

정책은 아래와 같다.

  • Positive Feedback은 낙관적으로 민감하게 반응해야 한다. (Optimistic Update)
  • Negative Feedback은 비관적으로 민감하게 반응해야 한다. (Pessimistic Update)
  • 하지만 일정 수준 이상의 Negative Feedback은 민감하게 반응되어야 한다.
  • 두 피드백 모두 일정 임계치 이상부터는 민감하게 반응하면 안 된다.

위 정책을 토대로 아래와 같이 수학적 모델링을 해서 캐릭터의 점수를 수학적으로 나타내었다.

x > 0 인 부분에서는 위로 볼록한 지수함수를 사용하여 Positive Feedback에 대해 민감하게 반응하도록 하였다.

x < 0 인 부분에서는 아크탄젠트 함수를 이용하여 특정 지점 전까지는 위로 볼록한 함수, 그 이후로는 아래로 볼록한 함수로 민감성을 조절하였다.

아래 함수는 점근선이 y = -1, y = 1로 피드백을 무한히 받아도 무한히 커지지 않도록 조절하였다.

함수의 계수(α, β)는 캐릭터의 카테고리나 특성에 따라 달라지도록 설계하였다.
따라서 같은 함수값이어도 변화량(Δ)이 달라져서 더 민감한 피드백을 넣을 수 있게 되었다.

이 계수 값은 데이터베이스에서 관리되도록 하여 재배포 없이 업데이트 할 수 있도록 하였다.

  • 기본적으로 50개의 Positive Feedback을 받으면 Score 증가량이 크게 감소하도록 계수를 설정하였다.
  • 기본적으로 10개의 Negative Feedback을 변곡점으로 설정하여 민감도를 조절하였다.

피드백을 업데이트 하는 것은 해당 점수를 갖는 x 값을 역함수를 활용해서 찾고, 피드백 종류에 따라 x + int(feedback_type) 와 같은 방식으로 다음 x 값에 대한 함수 값을 점수로 설정하는 방식으로 하였다.


이젠 user_id가 없어지고, Character와 1 : 1로 UserCharacterScore라는 컬렉션이 생겨서 피드백이 올 때마다 이 필드를 업데이트 하는 방식으로 변경하였다.

이때 Score는 정수 형태로 관리하여 정렬 시 조금 더 효율적으로 처리하도록 하였다.

  • [-1, 1] 범위의 값을 100배하여 [-100, 100]으로 스케일링
  • 소수점은 내림 처리

이렇게 하면 피드백을 더 이상 저장하지 않으므로 피드백의 개수와 상관 없이 일정한 조회 성능을 낼 수 있었다.

또한 Score에는 인덱스를 걸어 조회 시 더 효율적으로 하도록 하였다.

Score는 자주 업데이트 되는 필드라서 인덱스를 걸었을 때 Update는 비교적 비효율적이지만, Score Update는 비동기적으로 이루어지기 때문에 API Response Time이랑 아무 관계가 없다고 판단했고, 조회 성능을 올리는 것이 최우선이기에 Score에 인덱스를 거는 판단을 하였다.

위 방식으로 변경하면서, 브라우징 조회는 복잡한 쿼리 없이 단순 정렬 쿼리로 처리되었고, 이 필드는 인덱스에 의해 정렬돼 있으므로 더욱 빠른 처리가 가능해졌다.

쿼리 흐름은 아래와 같이 구성하였다.

  1. Order by score
  2. Offset and limit (Paging)
  3. Join characters and users

성능 테스트

바뀐 데이터 구조가 브라우징 조회 처리를 효율적으로 처리하는지 확인하는 실험을 진행하였다.

피드백 데이터의 개수를 달리하여, 각 상황마다 피드백 기반, 점수 기반 브라우징 API를 동시에 10번 씩 날려 평균 성능을 구하는 방식으로 실험을 진행하였다.

피드백 데이터는 0개부터 시작하여 1,000,000개까지, 10,000개 씩 증가시켰다.

예상 결과를 분석해보면 아래와 같다.

  • 피드백 기반은 피드백 개수가 늘어날 수록 Group + Sort + Join 성능이 누적되어 피드백 개수에 따라 성능이 선형으로 점점 감소할 것이다.
  • 점수 기반은 캐릭터 필드에 귀속된 단순 필드 정렬 조회이므로 피드백 개수에 무관하게 일정할 것이다.

실험 과정은 아래와 같다.

  1. Test 1은 피드백 기반, Test 2는 점수 기반 실험이고, 요청 URL은 아래와 같다.
Zsh
TEST_1_URL = "http://localhost:8000/api/character/browse"
TEST_2_URL = "http://localhost:8000/api/character/browse/score"
  1. 데이터베이스에 피드백 데이터를 10,000개 삽입한다.
  2. 두 실험에 대해 10개의 요청을 동시에 날린다.
  3. 평균 성능을 기록한다.
  4. 피드백 데이터가 1,000,000개가 될 때까지 반복한다.

아래는 결과 통계과 수집된 데이터, 그리고 그 그래프이다.

통계

피드백 기반 (Test 1)

  • 최솟값 : 227 ms
  • 최댓값 : 4246 ms
  • 평균값 : 2008 ms
  • 중앙값 : 1892 ms

(이 실험은 지연 시간이 점점 증가하므로 평균값과 중앙값은 크게 의미가 없다.)

점수 기반 (Test 2)

  • 최솟값 : 56 ms
  • 최댓값 : 308 ms
  • 평균값 : 74 ms
  • 중앙값 : 67 ms

결과 데이터

JSON
[
  {
    "data_count": 10000,
    "test_1": 227.19,
    "test_2": 78.44
  },
  {
    "data_count": 20000,
    "test_1": 272.93,
    "test_2": 67.53
  },
  {
    "data_count": 30000,
    "test_1": 291.61,
    "test_2": 78.67
  },
  {
    "data_count": 40000,
    "test_1": 324.43,
    "test_2": 93.42
  },
  {
    "data_count": 50000,
    "test_1": 395.89,
    "test_2": 82.4
  },
  // ...
  // ...
  // ...
  {
    "data_count": 980000,
    "test_1": 3338.14,
    "test_2": 66.73
  },
  {
    "data_count": 990000,
    "test_1": 3389.77,
    "test_2": 65.02
  },
  {
    "data_count": 1000000,
    "test_1": 4246.8,
    "test_2": 62.24
  }
]

결과 그래프

예상한 대로, 피드백 기반(Test 1, 파란색 그래프)은 피드백 개수가 늘어날 수록 성능이 선형으로 감소하는 모습을 보였다.

점수 기반(Test 2, 주황색 그래프)은 피드백 개수에 무관하게 일정한 성능을 보였다.


실험으로 새롭게 고안한 데이터 구조가 유효하다는 것으로 판단하고 곧바로 구현을 시작하여 반영하였다.

위에서 언급한 데이터 구조, 로직 외에 아래와 같은 작업을 추가로 하였다.

  • 기존 피드백을 기준으로 점수 업데이트 마이그레이션
  • 계수를 저장하는 컬렉션에 접근하는 Admin용 API
  • Weekly Trending이므로 주에 한 번 씩 결과 집계 / 초기값 재설정 배치 스크립트

개선 전에는 700 ms – 1,100 ms 정도의 성능을 보이던 API가 개선 후에는 200 – 400 ms 정도의 성능을 보였다.

  • 이는 약 66% 정도 개선된 수치이다.

만약 구조를 업데이트하지 않았다면 피드백이 계속 쌓여 지연 시간은 점점 더 길어졌을 것으로 예측한다.
따라서 피드백 개수에 크게 영향을 받는 구조를 제거하여 성능 개선 효과는 시간이 갈수록 더더욱 체감 될 것이라고 예상한다.


개인적으로 이 프로젝트는 토이 프로젝트와 실제 서비스 환경의 차이를 명확하게 체감하게 해준 경험이었다.

한 번 구현하고 배포하는 것으로 끝나는 것이 아니라, 배포 이후에도 지속적으로 지표를 관찰하고, 이상 징후를 발견하면 원인을 추적하며 구조 자체를 다시 고민해야 한다는 점에서 실서비스의 무게를 느낄 수 있었다.

단순히 느려졌다는 사실에 그치지 않고, 왜 지연이 발생하는지, 그 원인이 쿼리 수준의 문제인지 구조적인 문제인지, 그리고 그 구조가 현재 비즈니스 요구와 맞는지까지 함께 고민하는 과정이 필요했다.

특히 이번 개선은 단순한 쿼리 튜닝으로 해결할 수 있는 문제가 아니었고, 데이터가 쌓이는 방식 자체가 장기적으로 적합한지에 대한 질문에서 출발했다.

기술적인 관점뿐만 아니라, 추천 방향이 자주 바뀌는 서비스 특성상 가중치 조정이나 실험이 얼마나 유연하게 가능한지도 함께 고려해야 했다. 그 과정에서 수학적인 모델링을 도입해 점수의 민감도를 조절하고, 실제 데이터로 실험을 진행해 구조 변경의 효과를 검증한 뒤 적용할 수 있었던 점은 실무적인 관점에서 의미 있는 경험이었다.

물론 이번에 선택한 구조가 모든 상황에서 최선의 해답이라고 생각하지는 않는다. 다만 현재 서비스 규모와 운영 환경, 그리고 비즈니스적으로 요구되는 유연성을 종합적으로 고려했을 때, 가장 합리적인 트레이드오프(Trade-Off)를 선택한 결정이었다고 판단한다. 이런 고민과 판단을 실제 서비스에 적용해보고, 그 결과를 지표로 확인할 수 있었다는 점 자체가 이 프로젝트를 통해 얻은 가장 큰 가치였다.

답글 남기기

Dalmeng's Footprints에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기