본문 바로가기
  • Let's study
회고/GameLog 프로젝트

2차 프로젝트: GameLog - 프로필 탭 API 구현

by 코딩고수이고파 2026. 9. 21.

사용자 게임 활동을 하나의 데이터로 보여주기

게임 기록 서비스의 프로필 탭 API를 구현하였다.
단순히 사용자의 정보를 조회하는 것이 아니라, 게임 플레이 기록과 리뷰 데이터를 조합하여 사용자의 게임 취향과 활동을 보여주는 것을 목표로 했다.

 

프로필 탭에서 사용자의 게임 활동을 한눈에 확인할 수 있도록 다음 정보를 제공하는 API를 구현하였다.

 

  • 인생게임 5개
  • 플레이한 게임 수
  • 평균 리뷰 평점
  • 총 플레이 시간
  • 게임별 플레이 시간과 평점을 보여주는 산점도
  • 사용자의 게임 취향 요약
  • 장르별 플레이 비율
  • 최근 플레이한 게임 5개
  • 최근 작성한 리뷰 3개

최종적으로 하나의 프로필 API에서 필요한 데이터를 조합하여 반환하도록 구현하였다.

GET /users/me/profile

1. 어떤 데이터를 보여줄지 정의

프로필 화면을 구성하면서 가장 먼저 고민한 부분은 서로 다른 테이블의 데이터를 어떻게 조합할 것인가였다.

게임 플레이 기록은 UserGame에 있고, 리뷰 평점은 Review에 있기 때문에 단순히 UserGame만 조회해서는 모든 정보를 만들 수 없었다.

주요 데이터 관계

User
 └── UserGame
      ├── Game
      ├── Platform
      └── Review

 

특히 Review는 UserGame을 참조하고 있기 때문에,

User → UserGame → Review

 

관계를 기준으로 필요한 데이터를 조회하도록 설계하였다.

2. 프로필 통계 구현

프로필 상단에는 다음 세 가지 정보를 제공하였다.

플레이한 게임 수
평균 평점
총 플레이 시간

플레이한 게임의 기준

단순히 UserGame이 존재한다고 해서 플레이한 게임으로 판단하지 않았다.

프로젝트에서 정의한 플레이 게임 기준은 다음과 같다.

inLibrary = true
AND
(playStatus IS NOT NULL OR playing = true)

 

이를 Repository에서 공통적으로 사용하도록 하였다.

@Query("""
    SELECT ug
    FROM UserGame ug
    WHERE ug.user.id = :userId
      AND ug.inLibrary = true
      AND (
          ug.playStatus IS NOT NULL
          OR ug.playing = true
      )
""")
List<UserGame> findPlayedGames(@Param("userId") Long userId);

 

이렇게 하면 이후 통계에서도 동일한 기준을 사용할 수 있어 프로필 데이터 간 기준이 달라지는 문제를 줄일 수 있다.

총 플레이 시간

playTimeHours가 BigDecimal이기 때문에 Long으로 변환하지 않고 그대로 합산하였다.

BigDecimal totalPlayTime = playedGames.stream()
        .map(UserGame::getPlayTimeHours)
        .filter(Objects::nonNull)
        .reduce(BigDecimal.ZERO, BigDecimal::add);

 

null인 플레이 시간은 제외하고 합산하도록 처리하였다.

3. 게임 플레이 산점도

프로필에서 사용자의 게임 취향을 시각적으로 보여주기 위해

 

  • X축: 플레이 시간
  • Y축: 리뷰 평점

으로 산점도를 구성하였다.

따라서 UserGame의 플레이 시간과 Review의 평점을 함께 조회해야 했다.

@Query("""
    SELECT new com.gamelog.nbe121423355.domain.usergame.dto.UserGameScatterResponse(
        g.id,
        g.title,
        g.coverImageUrl,
        ug.playTimeHours,
        r.rating
    )
    FROM UserGame ug
    JOIN ug.game g
    JOIN Review r ON r.userGame = ug
    WHERE ug.user.id = :userId
      AND ug.inLibrary = true
      AND (
          ug.playStatus IS NOT NULL
          OR ug.playing = true
      )
      AND ug.playTimeHours IS NOT NULL
""")
List<UserGameScatterResponse> findPlayedGameScatterData(
        @Param("userId") Long userId
);

 

여기서는 Entity 자체를 반환하지 않고 필요한 값만 DTO로 조회하였다.

gameId
title
coverImageUrl
playTimeHours
rating

 

이를 통해 프론트에서는 별도의 추가 API 호출 없이 산점도를 구성할 수 있도록 하였다.

4. "내 게임 취향" 데이터 구현

프로필에서 단순 통계뿐만 아니라 사용자가 어떤 게임을 좋아하는지 보여주고 싶었다.

그래서 다음 세 가지 지표를 만들었다.

① 플레이 시간 성향

플레이 시간이 30시간 이상인 게임을 장시간 플레이 게임으로 분류하였다.

70% 이상 → 장시간 플레이 성향
40% 이상 → 균형형
그 미만 → 짧게 즐기는 성향

② 평점 성향

사용자가 남긴 평점을 기준으로 분류하였다.

4.0 이상 → 높은 평점
2.5 ~ 4.0 → 보통
2.5 미만 → 낮은 평점

 

각 그룹의 비율을 계산하여 가장 높은 비율의 성향을 선택하였다.

③ 게임 완료 성향

플레이한 게임 중 완료한 게임의 비율을 계산하였다.

완료 게임 수 / 플레이 게임 수

데이터가 적을 경우

여기서 고민한 부분은 게임을 거의 플레이하지 않은 사용자의 취향을 어떻게 보여줄 것인가였다.

게임 1~2개만 플레이한 상태에서 취향을 판단하는 것은 신뢰하기 어렵다고 생각했다.

따라서 최소 데이터를 별도로 설정하였다.

private static final int MIN_DATA_COUNT = 3;

 

데이터가 3개 미만이면 해당 취향을 계산하지 않고 충분한 데이터가 쌓인 이후부터 성향을 제공하도록 하였다.

이를 통해 적은 데이터로 사용자의 취향을 과도하게 판단하는 문제를 줄였다.

5. 장르별 플레이 비율

사용자가 어떤 장르의 게임을 많이 플레이했는지도 프로필에 표시하였다.

먼저 Repository에서 장르별 게임 수를 조회한다.

RPG      10
Action    5
Strategy  3

 

그 후 전체 장르 게임 수를 기준으로 비율을 계산하였다.

BigDecimal ratio = BigDecimal.valueOf(dto.gameCount())
        .divide(
                BigDecimal.valueOf(totalGenreGameCount),
                4,
                RoundingMode.HALF_UP
        );

 

최종 API에서는 내부 계산에 사용한 gameCount를 그대로 노출하지 않고,

{
  "genreName": "RPG",
  "ratio": 0.5556
}

 

처럼 화면에 필요한 데이터만 반환하도록 구성하였다.

6. 인생게임 구현

프로필에서 사용자가 직접 선택한 인생게임 최대 5개도 함께 제공하였다.

여기서 UserGame에 favorite 같은 필드를 추가하는 방법도 생각할 수 있었지만, 현재 프로젝트에서는 UserGame과 인생게임 선택을 별도의 관계로 관리하였다.

UserGame
→ 사용자의 게임 기록/라이브러리

UserFavoriteGame
→ 사용자가 선택한 인생게임

 

UserFavoriteGame에는 displayOrder를 두어 사용자가 지정한 순서를 유지하였다.

1 → 게임 A
2 → 게임 B
3 → 게임 C
...

등록 방식

인생게임 수정 API는 PUT으로 구현하였다.

PUT /users/me/favorite-games
{
  "gameIds": [10, 20, 30]
}

 

PUT을 선택한 이유는 현재 인생게임 목록을 요청한 목록으로 교체한다는 의미를 표현하기 좋았기 때문이다.

초기 등록인지 수정인지 프론트에서 구분할 필요도 없다.

요청
 ↓
현재 인생게임 삭제
 ↓
요청받은 게임으로 다시 구성
 ↓
displayOrder 부여

 

또한 다음 조건을 서버에서 검증하였다.

  • 최대 5개
  • 중복 게임 불가
  • 사용자의 라이브러리에 등록된 게임만 선택 가능

7. 최근 플레이 게임

프로필 하단에는 사용자가 최근 플레이한 게임 5개를 보여주도록 구현하였다.

UserGame.lastPlayedAt을 기준으로 정렬하였다.

@Query("""
    SELECT ug
    FROM UserGame ug
    JOIN FETCH ug.game
    WHERE ug.user.id = :userId
      AND ug.lastPlayedAt IS NOT NULL
    ORDER BY ug.lastPlayedAt DESC
""")
List<UserGame> findRecentPlayedGames(
        @Param("userId") Long userId,
        Pageable pageable
);

 

여기서 고민한 부분은 JPQL에서 5개만 가져오는 방법이었다.

JPQL에서는 SQL의 LIMIT을 직접 사용할 수 없기 때문에 Pageable을 활용하여 최근 데이터의 조회 개수를 제한하였다.
최근 플레이 게임은 lastPlayedAt 기준으로 내림차순 정렬한 뒤 PageRequest.of(0, 5)를 전달하여 최대 5개만 조회하도록 구현하였다.

Pageable pageable = PageRequest.of(0, 5);

8. 최근 리뷰

최근 리뷰는 createdDate가 아니라 lastModifiedDate 기준으로 정렬하였다.

사용자가 예전에 작성한 리뷰를 수정한 경우에도 최근 활동으로 볼 수 있도록 하기 위해서이다.

@Query("""
    SELECT r
    FROM Review r
    JOIN FETCH r.userGame ug
    JOIN FETCH ug.game g
    LEFT JOIN FETCH ug.platform p
    WHERE ug.user.id = :userId
    ORDER BY r.lastModifiedDate DESC
""")
List<Review> findRecentReviews(
        @Param("userId") Long userId,
        Pageable pageable
);

 

여기서도 pageable을 사용하여 최근 리뷰 3개만 조회하였다.

최종적으로는 게임 정보와 리뷰 정보를 하나의 DTO로 묶었다.

reviewId
gameId
gameTitle
gameCoverImageUrl
playStatus
platform
rating
content
lastModifiedDate

 

구현하면서 고민했던 부분

고민 1. 어디까지를 "플레이한 게임"으로 볼 것인가?

단순히 UserGame 존재 여부만 확인하면 찜하거나 라이브러리에만 추가한 게임까지 포함될 수 있었다.

→ inLibrary와 실제 플레이 상태를 함께 확인하는 기준을 정의하였다.

고민 2. 데이터가 적은 사용자의 취향을 어떻게 처리할 것인가?

게임 1~2개만 가지고 취향을 판단하면 결과가 지나치게 왜곡될 수 있었다.

→ 최소 데이터 개수 3개를 설정하고 충분한 데이터가 있을 때만 취향을 계산하였다.

고민 3. 최근 데이터의 개수를 어떻게 제한할 것인가?

JPQL에 LIMIT을 직접 사용하는 것보다 Spring Data JPA에서 제공하는 기능을 활용하는 것이 적절하다고 판단하였다.

→ Pageable을 사용하여 최근 게임 5개, 최근 리뷰 3개를 조회하였다.

고민 4. 여러 테이블의 데이터를 어떻게 하나의 응답으로 만들 것인가?

프로필 하나를 구성하기 위해 UserGame, Review, Game, Platform, UserFavoriteGame 등 여러 데이터가 필요했다.

→ Repository에서 필요한 데이터만 조회하고, Service에서 이를 조합하여 프로필 전용 Response DTO로 반환하였다.

구현을 통해 배운 점

이번 프로필 API 구현에서는 단순 CRUD보다 "어떤 데이터를 어떻게 조합해서 사용자에게 의미 있는 정보로 보여줄 것인가"를 많이 고민하였다.

특히 UserGame과 Review처럼 서로 다른 데이터를 하나의 화면에서 사용해야 할 때, 엔티티를 그대로 반환하기보다는 화면의 목적에 맞는 DTO를 정의하고 필요한 데이터만 조회하는 것이 중요하다는 것을 배웠다.

또한 단순 통계뿐만 아니라 최소 데이터 기준, 플레이 게임의 정의, 최근 활동의 기준과 같이 서비스에서 사용하는 데이터의 의미를 먼저 정의해야 API의 결과도 일관되게 만들 수 있다는 것을 경험하였다.

프로필 API는 데이터를 단순히 모아서 반환하는 기능이 아니라, 사용자의 게임 기록을 의미 있는 정보로 가공하는 API로 설계하였다.

댓글