Skip to content

feat : 홈화면 추천 스터디 - #403

Merged
junggyo1020 merged 21 commits into
developfrom
BE-44
Oct 26, 2025
Merged

junggyo1020 merged 21 commits into
developfrom
BE-44

Conversation

@junggyo1020

@junggyo1020 junggyo1020 commented Oct 12, 2025 •

Copy link
Copy Markdown
Contributor

📌 Related Issue

BE-44

🚀 Description

홈 화면에 표시될 스터디 추천 기능 API를 구현했습니다.

사용자에게 3가지 유형의 추천 스터디를 각각 1개씩 보여주도록 구현했습니다.

  • 이번주 가장 많이 활동한 스터디 MOST_ACTIVE_THIS_WEEK
  • 최근 가입률이 높은 스터디 HIGH_JOIN_RATE_RECENT
  • 난이도가 유사한 스터디 SIMILAR_DIFFICULTY: 유저가 최근 한달 간 푼 문제 난이도랑 근접한 문제를 최근 한달 간 푼 스터디

최근 한달 간 푼 문제의 난이도, 이번 주 스터디 활동지수, 최근 가입률은 변동성이 많은 변수들이라 어떻게 관리해야 할지 고민이 많았습니다.

그래서 저는 사전에 해당 지수들을 기준을 만들어 모두 집계해 점수로 만들었고, 스냅샷 형식으로 저장해 API 성능을 개선하고자 했습니다.

자세한 고민 사항들은 노션 SERVER > 개인페이지에 기록해 두었으니 참고하시면 좋을 것 같습니다!

📢 Review Point

1. 사전에 집계하기 & 스냅샷으로 관리하기

API 요청 시마다 복잡한 계산을 수행하는 대신, 배치 작업으로 주기적으로 데이터를 집계하고 스냅샷을 생성하는 방식을 생각해봤는데요. 이렇게 하면 API는 집계된 테이블을 단순 조회하기만 하면 되서 응답 속도에 문제가 없을 것이라 생각했습니다.

아래는 이번에 추가된 집계 테이블과 그 관계입니다. StudyGroup과 User를 기반으로 각종 활동 지표를 집계하여 저장하는 구조입니다.

    STUDY_GROUP 
      |-- GROUP_ACTIVITY_WEEKLY : "주간 활동 집계"
      |-- GROUP_JOIN_MONTHLY_ROLLING : "월간 가입률 집계"
      |-- GROUP_DIFFICULTY_MONTHLY_ROLLING : "스터디 난이도 집계"
      |-- STUDY_GROUP_TAG : "태그 스냅샷"
    
    USER - USER_DIFFICULTY_MONTHLY_ROLLING : "사용자 난이도 집계"

2. 서비스 로직에 대하여

각각의 서비스 로직에는 제가 임의로 알고리즘과 기준을 정의해놓았는데, 해당 부분이 괜찮을지 확인해주시면 감사하겠습니다 :)

  • MOST_ACTIVE_THIS_WEEK, HIGH_JOIN_RATE_RECENT 태그는 StudyGroupTag 스냅샷 테이블에서 점수가 가장 높은 항목을 조회합니다 -> buildFromTagSnapshot
  • SIMILAR_DIFFICULTY 태그는 사용자의 최근 30일 평균 난이도와 가장 차이가 적은 스터디를 찾습니다. -> buildSimilarDifficulty
  • 추천 데이터가 없는 경우, 가장 최근에 생성된 스터디를 반환하도록 Fallback 로직을 추가했습니다. -> fallbackFromRecentGroup

개선할 수 있는 부분이 있다면 많은 의견 부탁드립니다..!

3. Native Query

사용자와 가장 유사한 난이도의 스터디를 효율적으로 찾기 위해 Native Query를 사용했습니다.

ABS(g.avg_difficulty - :userAvg)로 난이도 차이를 절댓값을 기준으로 정렬한 다음, LIMIT 1로 가장 유사한 스터디를 조회하도록 했습니다. 성능상으로 더 나은 방식이 있다면 조언 부탁드리겠습니다 :)

// GroupDifficultyMonthlyRollingRepository.java
@Query(value = """
        SELECT *
        FROM group_difficulty_monthly_rolling g
        WHERE g.window_start = :windowStart AND g.window_end = :windowEnd
        ORDER BY ABS(g.avg_difficulty - :userAvg) ASC
        LIMIT 1
        """, nativeQuery = true)
Optional<GroupDifficultyMonthlyRolling> findTopSimilarByWindow(...);

📚Etc (선택)

테스트 결과

RecommendationControllerTest
image

RecommendationServiceTest
image

RecommendationSchedulerTest
image

RecommendationBatchServiceTest
image

Swagger 테스트 결과

image image image

@junggyo1020 junggyo1020 self-assigned this Oct 12, 2025
@junggyo1020 junggyo1020 added the new-feature 기능 추가 label Oct 12, 2025
@linear

linear Bot commented Oct 12, 2025

Copy link
Copy Markdown

@channeltalk

channeltalk Bot commented Oct 12, 2025

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Oct 12, 2025 •

Copy link
Copy Markdown

Test Results

 27 files   27 suites   7s ⏱️
351 tests 351 ✅ 0 💤 0 ❌
353 runs  353 ✅ 0 💤 0 ❌

Results for commit 62c96ba.

♻️ This comment has been updated with latest results.

@rladmstn rladmstn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

테스트까지 꼼꼼하게..! 고생하셨습니다!!
도메인부터 코드 이해까지 쉽지 않으셨을텐데 엄청 잘 구현해주셨네요!
PR이랑 노션에도 고민한 부분들 세세하게 잘 남겨주셔서 생각하신 설계를 이해하기 쉬웠습니다 👍


제가 이해한 바로는 GroupActivityWeekly, GroupJoinMonthlyRolling, GroupDifficultMonthlyRolling, UserDifficultyMonthlyRolling 네 가지에 대해 매일 오전 4시쯤에 스케줄러로 계산해서 데이터를 insert 하는 것 같아요!

개인적으로는 각각의 데이터들이 StudyGroup, User 당 하나씩 unique한 레코드로 존재해도 되지 않을까 싶은데요, 그래서 처음 생성된 데이터를 가지고 계속 update를 진행해도 괜찮을 것 같았습니다!
update가 아닌 insert로 데이터를 갱신하신 이유가 무엇인지 궁금합니다!
(혹시 notion에 있는데 제가 놓친거라면.. 보고 오겠습니다 😅)

@junggyo1020

Copy link
Copy Markdown
Contributor Author

추가적으로 Update가 아닌 Insert로 구현한 이유에 대해 말씀 드리려고 해요.

말씀해주신 것처럼 StudyGroup이나 User별로 유니크한 레코드를 두고 UPDATE 하는 방식도 충분히 고려할 수 있는, 그리고 성능 면에서도 장점이 있는 좋은 접근 방식이라고 생각합니다. 실제로 두 가지 방식을 두고 고민했었는데요, 아래와 같은 이유로 INSERT 방식을 최종적으로 선택하게 되었습니다.

  1. 데이터 보존과 추이 분석을 위해서
    : 매번 새로운 레코드로 집계 데이터를 쌓게 되면, 특정 스터디의 과거 활동량 변화나 가입률 추이와 같은 시계열 데이터를 쉽게 분석할 수 있습니다. 예를 들어, "A 스터디가 지난달에 비해 이번 달에 얼마나 더 활발해졌는가?"와 같은 지표를 추출하거나, 나중에 사용자에게 "우리 스터디 활동량 그래프" 같은 기능을 제공할 때 매우 유용하다고 판단했습니다. UPDATE 방식은 항상 최신 상태만 덮어쓰기 때문에 이러한 추이 분석이 불가능하다는 단점이 있다고 생각했고, 기획의 방향성에 따라 이 부분은 달라질 수 있을 것이라 판단했습니다 :)

  2. 데이터 무결성 및 디버깅 용이성
    : INSERT 방식은 특정 시점의 집계 결과를 '스냅샷'처럼 그대로 보존하기 때문에 데이터가 변경될 여지가 없고, 만약 특정 날짜의 추천 결과에 문제가 생겼을 때, 당시 집계 데이터를 그대로 조회하여 원인을 파악하기 용이하다는 장점을 생각했습니다.

위 두가지 이유로 당장의 저장 공간 비용이나 UPDATE로 구현할 때 단순함보다 더 중요하다고 생각했고, 데이터를 보존하는 INSERT 방식으로 결정하게 되었습니다.

다른 분들의 생각은 어떠신지, 혹시나 제가 놓치고 있는 부분이 있다면 편하게 말씀해주세요! 다시 한번 좋은 질문 감사드립니다. 👍

@rladmstn rladmstn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

현재 기획 방향성이 유동적일 수 있다는 점 고려해서 insert로 구현하신 점 공감합니다!
저도 현재의 데이터들이 내부적인 분석 뿐 만 아니라, 더 다양한 방법으로 사용자들에게 보여줄 수 있을 것 같아서 현재의 정교님 구현 방식에 동의합니다 :)
설명 감사합니다!

코멘트 반영 잘해주셔서 추가 피드백은 없는 것 같아요. 고생하셨습니다 -

@sh0723

sh0723 commented Oct 26, 2025

Copy link
Copy Markdown
Contributor

정교님 말대로 update 보다 insert가 데이터보존 및 추이분석을 위해 더 맞는 선택인거같아요!
설명을 세세하게 잘 써놔주셔서 이해하는데 도움이 많이 됐습니다!
고생하셨어요~!

@s-hwan s-hwan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

배운다는 생각으로 읽어봤습니다 노션까지 생각을 어떤식으로 하셨는지 알 수 있게 정리해주셔서 감사합니다 👍

: 추천 스터디의 종류를 나타내는 TagType Enum 클래스 추가
- MOST_ACTIVE_THIS_WEEK: 이번주 가장 많이 활동한 스터디
- HIGH_JOIN_RATE_RECENT: 최근 가입률이 높은 스터디
- SIMILAR_DIFFICULTY: 난이도가 유사한 스터디
- GroupActivityWeekly: 주간 활동 집계
- GroupJoinMonthlyRolling: 월간 가입률 집계
- GroupDifficultyMonthlyRolling: 스터디 월간 난이도 집계
- UserDifficultyMonthlyRolling: 사용자 월간 난이도 집계
- StudyGroupTag: 태그별 기록 저장
- StudyGroupTagRepository: 태그별 Top 1 조회 쿼리 메서드 정의
- UserDifficultyMonthlyRollingRepository: 사용자의 최근 난이도 조회 쿼리 메서드 정의
- GroupDifficultyMonthlyRollingRepository: 사용자와 가장 유사한 난이도의 스터디를 찾는 Native Query 정의
- StudyGroupSummaryDto: 스터디 그룹의 요약 정보
- RecommendationItemDto: 태그별 추천 항목 정보
- HomeRecommendationsResponse: 최종 응답 DTO
- 태그 스냅샷 테이블(StudyGroupTag)에서 '가장 활발한 스터디', '가입률 높은 스터디' 조회
- 사용자와 스터디의 최근 30일 평균 난이도를 비교하여 '유사 난이도 스터디' 조회
- 각 항목 조회 실패 시, 가장 최근에 생성된 스터디를 반환하는 Fallback 로직 추가
- RecommendationServiceTest: 추천 스터디 관련 시나리오에 대한 단위 테스트
- RecommendationControllerTest: API 정상 응답 확인을 위한 MockMvc 테스트
- RecommendationBatchService: 스케줄링될 추천 스터디 집계 로직 구현
- calculateAndSaveMostActiveStudy: 주간 활동 점수 계산 및 스냅샷 저장
- calculateAndSaveHighJoinRateStudy: 월간 가입률 계산 및 스냅샷 저장
- calculateAndSaveDifficultyInfo: 스터디/사용자 난이도 데이터 집계
- Solution/Problem/SolutionCommentRepository: 집계에 필요한 JPQL 쿼리 메서드 추가
- RecommendationServiceTest: 추천 스터디 관련 단위 테스트
- RecommendationControllerTest: API 정상 응답 확인을 위한 MockMvc 테스트
- 실패해도 나머지 assertion은 계속 검증해서 모든 실패를 한 번에 보기 좋다는 의견을 참고해 반영했습니다 :)
- 클라이언트 요청에 따라 StudyGroupSummaryDto에 startDate, endDate 필드 추가
- RecommendationService의 DTO 변환 로직(toSummary)에 해당 필드 매핑
- DTO 변경으로 인한 RecommendationControllerTest, RecommendationServiceTest 수정
@junggyo1020
junggyo1020 merged commit 6d8abe3 into develop Oct 26, 2025
2 checks passed
hwangjokim pushed a commit that referenced this pull request Jan 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants