본문 바로가기
  • Let's study
회고/프로그래머스 데브코스

2차 프로젝트: GameLog - HTTP Method 설계와 테스트

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

1. 문제 상황

게임 라이브러리에서 사용자의 게임 상태를 변경하는 API를 구현하면서 HTTP Method를 어떻게 결정할지 고민하였다.

UserGame에는 다음과 같은 상태가 존재한다.

  • 플레이 상태 (PlayStatus)
  • 플레이 중 (playing)
  • 백로그 (backlog)
  • 위시리스트 (wishlist)
  • 좋아요 (liked)

처음에는 상태 변경이라는 이유로 모든 API를 POST로 구현하였다.

POST /games/{gameId}/play-status
POST /games/{gameId}/playing
POST /games/{gameId}/wishlist
POST /games/{gameId}/backlog
POST /games/{gameId}/liked

하지만 단순히 "상태를 변경한다"는 이유만으로 POST를 사용하는 것이 적절한지 고민하게 되었다.

특히 동일한 요청을 여러 번 보냈을 때 결과가 동일하게 유지되는 멱등성(Idempotency)도 고려할 필요가 있었다.


2. 처음 구현한 방식의 문제

기존 boolean 상태 변경은 현재 값을 반전시키는 토글 방식이었다.

boolean playing = !userGame.isPlaying();
userGame.changePlaying(playing);

따라서 동일한 요청을 두 번 보내면 결과가 달라졌다.

첫 번째 요청
false → true

두 번째 요청
true → false

이 방식에서는 같은 요청을 반복해도 같은 결과가 나오지 않기 때문에 멱등적인 상태 변경 API라고 보기 어려웠다.

또한 클라이언트가 원하는 최종 상태를 명확하게 전달하지 않고 서버가 현재 상태를 기준으로 다음 상태를 결정하고 있었다.


3. 해결 방법을 고민하다

HTTP Method를 크게 두 가지 방향으로 검토하였다.

방법 1. POST + 토글 방식

POST /games/{gameId}/playing

서버에서 현재 값을 확인한 뒤 반대 값으로 변경한다.

boolean playing = !userGame.isPlaying();
userGame.changePlaying(playing);

버튼을 한 번 클릭하면 활성화되고 다시 클릭하면 비활성화되는 기능과 잘 맞는다는 장점이 있었다.

하지만 같은 요청을 반복했을 때 상태가 계속 변경되므로 멱등성을 보장하기 어려웠다.

방법 2. 상태를 직접 전달하는 방식

PATCH /games/{gameId}/playing?playing=true

클라이언트가 원하는 최종 상태를 전달하고 서버는 해당 값으로 변경한다.

userGame.changePlaying(playing);

이 경우 동일한 요청을 반복해도 결과가 동일하다.

PATCH ...?playing=true
→ true

PATCH ...?playing=true
→ true

따라서 상태 변경의 의도가 더 명확하다고 판단하였다.


4. HTTP Method를 역할에 따라 분리

최종적으로 상태 변경의 성격에 따라 HTTP Method를 구분하였다.

PlayStatus

PlayStatus는 PLAYED, PLAYING, BACKLOG 등 여러 상태 중 하나를 지정하는 구조이다.

따라서 특정 플레이 상태를 설정한다는 의미에서 PUT을 사용하였다.

PUT /games/{gameId}/play-status?status=PLAYING
@PutMapping("/{gameId}/play-status")
public RsData<UserGamePlayStatusResponse> changePlayStatus(
        @PathVariable Long gameId,
        @RequestParam PlayStatus status,
        @AuthenticationPrincipal SecurityUser user
) {
    PlayStatus playStatus =
            userGameService.changePlayed(
                    user.getId(),
                    gameId,
                    status
            );

    return new RsData<>(
            "200-1",
            "플레이 상태를 변경했습니다.",
            new UserGamePlayStatusResponse(playStatus)
    );
}

PUT은 동일한 상태를 반복해서 지정해도 최종 결과가 동일하다는 점에서도 적합하였다.


Boolean 상태

playing, wishlist, backlog, liked는 UserGame의 여러 필드 중 특정 boolean 필드만 변경한다.

따라서 부분적인 리소스 변경이라는 의미에서 PATCH를 사용하였다.

PATCH /games/{gameId}/playing?playing=true
PATCH /games/{gameId}/wishlist?wishlist=true
PATCH /games/{gameId}/backlog?backlog=true
PATCH /games/{gameId}/liked?liked=true

서비스도 기존 토글 방식에서 전달받은 값을 직접 설정하는 방식으로 변경하였다.

@Transactional
public boolean changePlaying(
        Long userId,
        Long gameId,
        boolean playing
) {
    UserGame userGame = getOrCreateUserGame(userId, gameId);

    userGame.changePlaying(playing);

    return playing;
}

wishlist, backlog, liked 역시 같은 방식으로 수정하였다.

이를 통해 API가 "현재 상태를 반전시켜라"가 아니라 "해당 필드를 이 값으로 변경하라"는 의미를 가지도록 변경하였다.


5. UserGame이 없는 경우의 처리

또 하나 고민했던 부분은 상태를 변경하려는 게임이 아직 사용자의 라이브러리에 등록되지 않은 경우였다.

UserGame을 먼저 별도의 API로 등록하도록 할 수도 있었지만, 상태 변경 API에서도 UserGame이 없으면 생성하도록 구현하였다.

private UserGame getOrCreateUserGame(Long userId, Long gameId) {

    return userGameRepository.findByUser_IdAndGame_Id(userId, gameId)
            .orElseGet(() -> {
                User user = findUser(userId);
                Game game = findGame(gameId);

                UserGame userGame = new UserGame(
                        user,
                        game,
                        null,
                        false,
                        false,
                        false,
                        false,
                        null,
                        null,
                        null,
                        null,
                        null,
                        null,
                        null
                );

                return userGameRepository.save(userGame);
            });
}

따라서 처음 상태를 변경하는 경우에도 다음과 같이 동작한다.

UserGame 조회
     ↓
없음
     ↓
UserGame 생성
     ↓
요청받은 상태 설정
     ↓
저장

HTTP Method를 결정하는 기준은 단순히 DB에 새로운 데이터가 생성되는지 여부가 아니라 요청이 표현하는 변경의 의미라는 점을 확인하였다.


6. PlayStatus의 null 처리

기존에는 play-status의 status를 선택적으로 받아 null이면 플레이 상태를 초기화하도록 구현하였다.

if (playStatus == null) {
    userGame.clearPlayStatus();
} else {
    userGame.changePlayStatus(playStatus);
}

따라서 다음 요청이 플레이 상태 초기화의 역할을 한다.

PUT /games/{gameId}/play-status

서비스 테스트를 통해 기존 상태가 PLAYED인 UserGame에 null을 전달했을 때 플레이 상태가 null로 초기화되는지 검증하였다.

다만 API의 의미를 더 명확하게 표현하려면 이후 다음과 같이 분리하는 것도 고려할 수 있다.

PUT    /games/{gameId}/play-status?status=PLAYED
DELETE /games/{gameId}/play-status

즉, PUT은 상태 지정, DELETE는 상태 제거로 역할을 명확하게 분리하는 방식이다.


7. 테스트 작성

HTTP Method 변경으로 끝내지 않고 서비스의 동작을 검증하기 위한 테스트도 추가하였다.

UserGame이 존재하는 경우

UserGame 조회
→ 기존 UserGame 사용
→ 상태 변경

UserGame이 없는 경우

UserGame 조회
→ UserGame 생성
→ 상태 변경
→ 저장

두 경우를 각각 테스트하여 getOrCreateUserGame()의 동작을 검증하였다.

또한 boolean 상태에 대해 true, false를 각각 전달하여 원하는 값으로 정확하게 변경되는지도 테스트하였다.


8. 멱등성 테스트

이번 변경에서 가장 중요한 부분 중 하나는 동일한 상태 변경 요청을 반복했을 때 결과가 유지되는지 확인하는 것이었다.

예를 들어:

changePlaying(true)
changePlaying(true)

두 번 호출해도 최종 상태는 모두 true여야 한다.

테스트에서는 다음과 같이 검증하였다.

boolean firstResult =
        userGameService.changePlaying(userId, gameId, true);

boolean secondResult =
        userGameService.changePlaying(userId, gameId, true);

assertThat(firstResult).isTrue();
assertThat(secondResult).isTrue();
assertThat(userGame.isPlaying()).isTrue();

기존 토글 방식이었다면 두 번째 요청에서 false가 되었지만, 변경 후에는 동일한 요청을 반복해도 상태가 유지된다.


9. 배운 점

이번 작업을 통해 HTTP Method를 단순히 "조회는 GET, 생성은 POST, 수정은 PUT/PATCH"처럼 암기하는 것보다 API가 표현하는 행위와 상태 변경의 의미를 기준으로 결정해야 한다는 점을 알게 되었다.

특히 POST → PATCH 변경 과정에서 단순히 Annotation만 변경하는 것이 아니라 서비스의 토글 로직까지 함께 수정해야 했다.

최종적으로 다음과 같이 역할을 구분하였다.

APIMethod변경 방식

PlayStatus PUT 원하는 플레이 상태를 지정
playing PATCH boolean 값을 지정
wishlist PATCH boolean 값을 지정
backlog PATCH boolean 값을 지정
liked PATCH boolean 값을 지정

또한 동일한 상태를 반복해서 전달해도 결과가 변하지 않도록 구현하여 멱등성을 고려한 API 설계까지 적용하였다.

이를 통해 API 설계는 Controller의 Annotation만 결정하는 작업이 아니라 HTTP Method, 요청 데이터, Service 로직, 데이터 생성 정책, 테스트가 서로 일관되게 맞물려야 한다는 점을 경험하였다.

댓글