Skip to content

Conversation

@dlsrks1021
Copy link
Collaborator

@dlsrks1021 dlsrks1021 commented Jul 23, 2025

🛰️ Issue Number

🪐 작업 내용

  • 닉네임을 통한 랭킹 조회를 Path Variable에서 Request Parameter로 변경했습니다.

  • 랭킹 조회 시 결과 값이 없다면 PLAYER_NOT_FOUND 에러 코드를 반환하도록 추가했습니다.

    • PLAYER_NOT_FOUND 메시지를 수정했습니다.
  • 랭킹 업데이트 관련 로직을 추가했습니다.

    • TODO: 게임 종료 시 StatService의 updateRank 메서드 호출을 통해 랭킹 업데이트가 필요합니다.
  • Redis를 적용했습니다.

    • 랭킹 조회를 위해 ZSET, HSET, STRINGS 구조를 사용했습니다.

    • 서비스 간 결합도를 낮추기 위한 Redis pub/sub을 적용했습니다.

    • StatRepository를 인터페이스로 추상화했으며, 구현부에서는 Adapter를 통해 Redis와 MySQL을 사용하도록 했습니다.

      • Redis 조회를 실패할 경우 MySQL로 fallback 하도록 구성했습니다.
    • TestContainer 의존성을 추가해 Redis를 테스트할 수 있도록 설정했습니다.

    • application.yml의 Redis 관련 설정을 복구했습니다.

Redis 정리

[데이터 영구 저장소의 선택]

Redis는 메모리 저장 방식으로 휘발성을 갖고 있으나, AOF와 RDB를 통해 파일로 백업이 가능합니다.
하지만, MySQL과는 달리 FK 관리, 타입 안정성이 존재하지 않아 영구 저장소로 활용하기에는 한계가 있습니다.
따라서, 랭킹 데이터의 영구 저장소로 MySQL을 사용하고, Redis는 캐시 용도로만 활용했습니다.

스프링 서버의 시작 시 MySQL로부터 데이터를 캐싱하는 Warming 작업을 진행하도록 했습니다.

[데이터 동기화 전략]

Redis와 MySQL은 별도의 저장소이지만 같은 데이터를 다루기 때문에 동기화가 필요합니다.
이에 대한 전략으로 기존에는 일정 주기로 Redis의 데이터를 MySQL로 업데이트한다는 전략을 구상했지만,
이는 Redis의 비정상 종료 시에 동기화 이후의 모든 데이터를 잃게 되는 단점이 존재합니다.

따라서, 위 전략의 단점을 보완하기 위해 Redis와 MySQL에 동시에 쓰기 작업을 하는 전략을 선택했습니다.
이 전략은 Redis의 비정상 종료로 인한 데이터의 손실이 발생하지 않는다는 장점을 갖고 있지만,
각각의 업데이트 발생 시마다 MySQL에도 업데이트 쿼리가 발생해 서버 혼잡 시 MySQL에 부하를 줄 수 있다는 단점이 존재합니다.

데이터 손실 없이 MySQL의 부하를 줄이는 보완 전략으로는 Kafka 등의 외부 이벤트 브로커를 이용해 업데이트 내역에 대한 배치 작업을 진행하도록 할 수 있으나, 현재 상황에서는 오버 엔지니어링이라 생각되어 실제 부하 테스트에서 랭킹 업데이트로 인한 MySQL 성능 저하가 발생할 경우 리팩토링을 통해 도입하는 방향이 좋을 것이라 생각됩니다.

[데이터 저장 구조]

데이터의 효율적인 조회를 위해 ZSET, HSET, STRING 구조를 활용했습니다.

ZSET

KEY stat:rank
VALUE userId
SCORE score

ZSET은 score 값을 기반으로 정렬된 value를 조회할 수 있도록 하는 구조입니다.
따라서 ZSET의 score 값을 Stat 엔티티의 score로 설정했으며, 범위 지정 검색을 통해 페이지네이션을 구현했습니다.
value 값은 userId로 아래의 HSET에 접근해 추가 데이터를 얻어올 수 있도록 했습니다.

HSET

KEY stat:user:{userId}
HASHKEY nickname
HASHKEY winningGames
HASHKEY totalGames

HSET은 key를 통해 접근한 value가 HashMap 형태인 구조입니다.
HashKey로 플레이어의 정보 (nickname, winningGames, totalGames)를 접근할 수 있도록 구성했습니다.
이를 통해 ZSET에서 HSET의 key를 획득한 이후 key에 해당하는 플레이어 정보를 얻어올 수 있습니다.

STRINGS

KEY stat:{nickname}
VALUE userId

STRINGS는 key를 통해 value에 접근하는 단순한 Map 구조입니다.
닉네임을 통해 검색하는 기능을 사용할 때, 역인덱스로 활용하기 위해 추가했습니다.
이를 통해 HSET과 ZSET을 조회할 수 있습니다.

[유저 정보 동기화]

위의 구조를 보시면 Redis에서 유저에 대한 정보를 담고 있기 때문에, 유저의 추가/수정/삭제 작업에 대해 Redis에서도 정보의 동기화가 필요합니다.
이에 대해 StatService에서 유저 정보 업데이트에 대한 동기화 작업을 수행할 수 있는 메서드를 제공했습니다.
하지만 UserService에서 StatService를 주입 받아 직접 메서드를 호출할 경우, UserService와 StatService의 결합도가 증가해 StatService의 역할이 UserService에 전이되는 문제가 발생합니다.

따라서, 서비스 간 결합도를 낮추고자 Redis의 pub/sub을 이용했습니다.
UserService에서는 유저의 추가/수정/삭제 작업 시 그에 해당하는 채널에 메시지를 발행하고, 유저 업데이트 정보를 구독 중인 Subscriber에서 StatService의 메서드를 호출하도록 구현했습니다.

이를 통해 UserService는 업데이트 정보를 채널에 발행하는 역할만 수행하게 됨으로써, 추가 로직이 필요할 경우 Subscriber의 코드만 수정하는 것으로 구현이 가능해집니다.

📚 Reference

✅ Check List

  • 코드가 정상적으로 컴파일되나요?
  • 테스트 코드를 통과했나요?
  • merge할 브랜치의 위치를 확인했나요?
  • Label을 지정했나요?

@dlsrks1021 dlsrks1021 self-assigned this Jul 23, 2025
@dlsrks1021 dlsrks1021 added the refactoring 코드 리팩토링 label Jul 23, 2025
@dlsrks1021 dlsrks1021 linked an issue Jul 23, 2025 that may be closed by this pull request
Copy link
Collaborator

@jiwon1217 jiwon1217 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 코드가 꼼꼼하게 작성되었네요 ! 유저 정보가 수정/삭제되는 경우의 로직도 테스트를 통해 검증해주셔서 이해가 잘 되었습니다 !

@@ -0,0 +1,3 @@
package io.f1.backend.domain.user.dto;

public record UserNickname(long userId, String nickname) {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userId와 nickname을 둘 다 가지고 있기 떄문에 UserNickname 보다는 UserInfo와 같은 포괄적인 이름이 명확해보입니다 !

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserNickname을 다른 이름으로 바꾸는 것에는 동의합니다.
다만, UserInfo일 경우에는 뭔가 id, nickname 이외에도 정보를 더 담고 있어야 할 것 같은 이름으로 보일 수 있을 것 같아서 다른 이름이 좋을 것 같은데, 명확하게 UserIdNickname으로 하거나 UserSummary, UserSimple 같은 이름이 있을 것 같습니다.

UserIdNickname의 경우에는 혹여나 나중에 필드가 변경될 경우 네이밍 가치가 없어져서 Summary나 Simple 같은 네이밍이 괜찮다고 생각이 드는데 어떻게 생각하시나요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary도 좋습니다 !

Copy link
Collaborator

@LimKangHyun LimKangHyun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명과 테스트 코드가 아주 꼼꼼하게 작성되어 있어서 전체적인 흐름을 이해하는 데 많은 도움이 됐습니다!
배워갑니다!✏️✏️✏️

String statUserKey = getStatUserKey(userId);

zSetOps.incrementScore(STAT_RANK, getStatUserKey(userId), deltaScore);
hashOps.increment(statUserKey, "totalGame", 1);
Copy link
Collaborator

@LimKangHyun LimKangHyun Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L4-변경제안]
totalGame과 winnigGame 키 명에 s가 붙어야 할 것 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉... update 로직은 아직 사용 위치가 없어 테스트를 안했더니,, 큰일날뻔했네요!
감사합니다 수정하겠습니다!

@dlsrks1021
Copy link
Collaborator Author

각 구조의 value를 다른 구조의 key 값이 아닌 id 값을 갖도록 변경 필요

Copy link
Collaborator

@sehee123 sehee123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우왓 .. PR내용, 구현과 테스트 코드까지 !! 대단하십니닷 👍
구조를 이해하고 코드를 보는데 윗분들 말씀처럼 테스트코드 덕에 이해가 더 잘되었습니다!
왜 구조 짜는데 고민이 많다고 하셨는지 알겠습니다.
수고하셨습니다 🚀

@dlsrks1021 dlsrks1021 merged commit 1cea9a4 into dev Jul 24, 2025
@dlsrks1021 dlsrks1021 deleted the refactor/95 branch July 24, 2025 08:19
silver-eunjoo added a commit that referenced this pull request Jul 25, 2025
* ✨ feat : 게임 종료 로직 구현

* chore: Java 스타일 수정

* ✨ feat: 게임 종료 후 disconnected 참여자 정리

* 🔧 chore : 깃 충돌 해결

* chore: Java 스타일 수정

* ♻️ refactor : 게임 시작 시, 실시간 랭킹도 함께 SEND

* ♻️ refactor : 서비스 간의 순환참조 해결

* chore: Java 스타일 수정

* ♻️ refactor : 게임 종료 시 로직 변경

* chore: Java 스타일 수정

* 🔧 chore: 게임 종료 시 모든 메세지 브로드캐스팅하도록 변경

* chore: Java 스타일 수정

* 🐛 fix: index 예외, Lazy 예외 버그 수정

* chore: Java 스타일 수정

* ✨ [feat] 유저 닉네임으로 정보 조회 (#101)

* ✨ feat: 특정 닉네임이 포함된 유저 정보 조회 기능

* 🐛 fix: 닉네임 중복 확인 시 대소문자 구분하지 않음

* chore: Java 스타일 수정

---------

Co-authored-by: github-actions <>

* ♻️ refactor: 세션 타임아웃 값을 환경변수로 대치 (#105)

* 🔧 chore : 깃 충돌 해결

* 🔧 chore : 깃 충돌 해결

* ♻️ refactor: 랭킹 조회 Redis 적용 (#111)

* ✨ feat: GameSetting 변경 기능 추가 (#107)

* ✨ feat: 게임 설정 변경 인터페이스 및 구현체 추가
- GameSettingChanger 인터페이스 도입

* chore: Java 스타일 수정

* ✨ feat: 게임 설정 변경 기능 추가

* chore: Java 스타일 수정

* 🚚 move: 소켓 요청 DTO request 디렉토리로 이동

* 🚚 move: 소켓 요청 DTO 경로 변경

* chore: Java 스타일 수정

* ♻️ refactor: 게임 세팅 요청 후처리 분리

* chore: Java 스타일 수정

* ♻️ refactor: 코드 리뷰 반영

* chore: Java 스타일 수정

* 🗑️ remove: 불필요 코드 삭제

* chore: Java 스타일 수정

* ♻️ refactor: isHost 원상복구

* ♻️ refactor: QuizChangeRequest, RoundChangeRequest 타입 수정

* 🔧 chore: RoomUpdatedEventListener, RoomDeletedEventListener 빈 등록

* chore: Java 스타일 수정

* chore: Java 스타일 수정

* ♻️ refactor: player 조회 메서드 추가

---------

Co-authored-by: github-actions <>

* 🐛 fix: 라운드 변경 시 question개수 조회에 fetch join 적용 (#116)

* 🐛 fix: round 변경 시 question 조회에 fetch join 적용

* ♻️ refactor: questionCount만 찾는 쿼리로 변경 및 불필요한 퀴즈 아이디 조회 삭제

* chore: Java 스타일 수정

---------

Co-authored-by: github-actions <>

* chore: Java 스타일 수정

* chore: Java 스타일 수정

* 🔧 chore : 테스트 코드 통과

* chore: Java 스타일 수정

---------

Co-authored-by: github-actions <>
Co-authored-by: Jiwon Kwak <[email protected]>
Co-authored-by: dlsrks1021 <[email protected]>
Co-authored-by: kanghyun <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactoring 코드 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[refactor] 랭킹 조회 Redis 적용

5 participants