로비 테스트 02 : API 작성

반응형

 

실제 API를 작성해보겠다. 


1. 전체적인 구조

                    Unity
                      │
                   REST API
                      │
                      ▼
              RoomController
                      │
                      ▼
                RoomService
                  │       │
                  │       └──────────────┐
                  ▼                      ▼
          RoomRepository            UserRepository
            (Redis)                    (JPA)
                  │                      │
                  ▼                      ▼
                Redis                  MySQL
                  │                      │
            현재 로비 상태                 User
데이터 저장 위치 이유
User MySQL 영구적으로 존재해야 함
현재 생성된 Room Redis 방이 없어지면 필요 없는 임시 상태
현재 방 참가자 Redis 입장/퇴장에 따라 계속 변하는 현재 상태

 


2. 기능별 흐름

  • [방 만들기]
    • Unity → POST /rooms
    • Spring → Redis에 Room 생성
  • [방 들어가기]
    • Unity → POST /rooms/1/players
    • Spring → MySQL에서 User 존재 확인 → Redis의 Room #1에 userId 추가
  • [현재 방 참가자]
    • Unity → GET /rooms/1/players
    • Spring → Redis에서 Room #1 참가자 조회
  • [방 나가기]
    • Unity → DELETE /rooms/1/players/me
    •  Spring → Redis에서 해당 참가자 제거

 


3. 패키지 구조

com.mafia

├── controller
│   └── RoomController.java
│
├── service
│   └── RoomService.java
│
├── repository
│   ├── RoomRepository.java
│   └── UserRepository.java
│
├── domain
│   └── room
│       ├── Room.java
│       └── RoomPlayer.java
│
├── entity
│   └── User.java
│
└── dto
    └── room
        ├── CreateRoomRequest.java
        ├── JoinRoomRequest.java
        ├── RoomResponse.java
        └── RoomPlayerResponse.java

 


4. Redis에서 사용할 객체

Room #1
│
└── players
     ├── RoomPlayer(10, "철수")
     ├── RoomPlayer(11, "영희")
     └── RoomPlayer(12, "민수")

이런식으로 사용 예정 

 

RoomPlayer

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class RoomPlayer {

    private Long userId;
    private String nickname;
    private boolean ready;
}

 

Room

package com.lobbytest.domain;

import lombok.Getter;

import java.util.ArrayList;
import java.util.List;

@Getter
public class Room {
    private Long id;
    private String title;
    private Long hostUserId;
    private int maxPlayers;
    private List<RoomPlayer> players = new ArrayList<>();

    public Room(Long id, String title, Long hostUserId, int maxPlayers) {
        this.id = id;
        this.title = title;
        this.hostUserId = hostUserId;
        this.maxPlayers = maxPlayers;
        this.players = new ArrayList<>();
    }

    public void addPlayer(RoomPlayer player) {
        players.add(player);
    }

    public void removePlayer(Long userId) {
        players.removeIf(player -> player.getUserId().equals(userId));
    }

    public boolean isFull() {
        return players.size() >= maxPlayers;
    }

    public boolean containsPlayer(Long userId) {
        return players.stream().anyMatch(player -> player.getUserId().equals(userId));
    }
}

 

Room 하나를 Redis에 하나의 값으로 저장

Key
room:1

Value
{
    "id": 1,
    "title": "초보방",
    "hostUserId": 10,
    "maxPlayers": 4,

    "players": [
        {
            "userId": 10,
            "nickname": "철수",
            "ready": false
        },
        {
            "userId": 11,
            "nickname": "영희",
            "ready": false
        }
    ]
}

다른 방도:

room:2
room:3
room:4

이런 식으로 존재.

 


5. Redis Repository

package com.lobbytest.repository;

import com.lobbytest.domain.Room;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class RoomRepository {

    private final RedisTemplate<String, Room> redisTemplate;

    private static final String PREFIX = "room:";

    public void save(Room room) {
        redisTemplate.opsForValue().set(PREFIX + room.getId(), room);
    }

    public Room findById(Long roomId) {
        return redisTemplate.opsForValue().get(PREFIX + roomId);
    }

    public void delete(Long roomId) {
        redisTemplate.delete(PREFIX + roomId);
    }

    public Long generateRoomId() {
        return redisTemplate.opsForValue().increment("room:id:sequence");
    }
}

 

그러면:

roomRepository.save(room);

하면 Redis에:

room:1 → Room 객체

가 들어가는 것이다.

 

redisTemplate 의존성 주입 관련

더보기

Could not autowire. No beans of 'RedisTemplate<String, Room>' type found.

즉 Spring 입장에서는:

private final RedisTemplate<String, Room> redisTemplate;

를 보고

“RedisTemplate<String, Room>을 주입해 달라는 거네.
근데 내가 관리하는 Bean 중에 그런 타입이 없는데?”

라는 상태인 것. 

1. build.gradle이 다음의 의존성이 있는지 확인 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

 

2. Redis 설정 클래스 생성 하고 작성 

com.lobbytest

├── config
│   └── RedisConfig.java
├── controller
├── service
├── repository
└── domain

 

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Room> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Room> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        return template;
    }
}

 

그러면 Spring 시작할 때:

RedisConfig
    ↓
@Bean
    ↓
RedisTemplate<String, Room> 생성
    ↓
Spring Container에 Bean 등록

되고, 네 RoomRepository로 생성자 주입이 가능해진다. 

 

 

그런데 Redis는 Java의 Room 객체를 그대로 이해하는 게 아니기 때문에 직렬화(Serialization) 설정이 필요하다.

우리가 원하는 건 대략:

<Java>
Room 객체
{
    id = 1
    title = "초보방"
    players = ...
}

        ↓ 직렬화

<Redis>
Key "room:1"
Value
JSON 형태

 

그래서 실제 RedisConfig는 이런 식으로 잡는 게 좋다. 

package com.lobbytest.config;

import com.lobbytest.domain.Room;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Room> roomRedisTemplate(RedisConnectionFactory connectionFactory) {

        RedisTemplate<String, Room> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // Key: "room:1" 같은 문자열
        template.setKeySerializer(new StringRedisSerializer());

        // Value: Room 객체를 JSON으로 변환
        template.setValueSerializer(new JacksonJsonRedisSerializer<>(Room.class));

        return template;
    }
}

 

generateRoomId()

더보기

MySQL의 JPA Entity였다면:

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

처럼 DB가 ID를 자동 생성해준다.

그런데 지금 Room은 MySQL에 저장하는 JPA Entity가 아니라 Redis에서 관리하는 객체라서 @GeneratedValue를 사용할 수 없다.

➡️ 그래서 방 번호를 우리가 만들어야 한다.

 

Redis에는 숫자를 원자적으로 1씩 증가시키는 기능이 있다. 

Spring에서는 RedisTemplate의 increment()로 사용할 수 있다.

 

다만 지금 우리가 만든 RedisTemplate<String, Room>으로 이 카운터까지 처리하려고 하면 타입이 안 맞는다.

이 Template은 Value가 Room이라고 정해놨기 때문이다.

RedisTemplate<String, Room>
                      ↑
                   Room 전용

 

따라서 구현할 때는 방 ID 카운터용으로 StringRedisTemplate을 별도로 사용하는 게 깔끔하다고는 하다.

private final StringRedisTemplate stringRedisTemplate;

public Long generateRoomId() {
    return stringRedisTemplate
            .opsForValue()
            .increment("room:id:sequence");
}

StringRedisTemplate은 Spring Boot Redis 설정이 되어 있으면 일반적으로 자동 구성해주기 때문에,

Room 저장은 RedisTemplate<String, Room>, ID 같은 단순 숫자/문자열 처리는 StringRedisTemplate으로 나누면 이해하기도 편하다고는 하는데

음 일단 이거는 패쓰 

 


6. RoomService

방 생성

// 방 생성
@Transactional(readOnly = true)
public RoomResponseDto createRoom(CreateRoomRequestDto request) {
    User user = userRepository
            .findById(request.getUserId())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다."));

    // 새로운 룸 생성
    Long roomId = roomRepository.generateRoomId();
    Room room = new Room(roomId, request.getTitle(), user.getId(), request.getMaxPlayers());

    // 방을 만든 사람은 자동으로 해당 방에 입장
    RoomPlayer host = new RoomPlayer(user.getId(), user.getNickname(), false);
    room.addPlayer(host);

    roomRepository.save(room);
    return RoomResponseDto.from(room);
}

DTO

더보기
package com.lobbytest.dto.room;

import lombok.Getter;

@Getter
public class CreateRoomRequestDto {
    private Long userId;
    private String title;
    private int maxPlayers;
}
package com.lobbytest.dto.room;

import com.lobbytest.domain.Room;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class RoomResponseDto {
    private Long id;
    private String title;
    private Long hostUserId;
    private int maxPlayers;
    private int currentPlayers;

    public static RoomResponseDto from(Room room) {
        return new RoomResponseDto(
                room.getId(),
                room.getTitle(),
                room.getHostUserId(),
                room.getMaxPlayers(),
                room.getPlayers().size()
        );
    }
}

 

방 참가

// 방 참가
public RoomResponseDto joinRoom(Long roomId, JoinRoomRequestDto request) {

    Room room = roomRepository.findById(roomId);

    if (room == null) throw new IllegalArgumentException("존재하지 않는 방입니다.");
    if (room.isFull()) throw new IllegalStateException("방이 가득 찼습니다.");
    if (room.containsPlayer(request.getUserId())) throw new IllegalStateException("이미 참가 중입니다.");

    User user = userRepository
            .findById(request.getUserId())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다."));

    RoomPlayer player = new RoomPlayer(user.getId(), user.getNickname(), false);
    room.addPlayer(player);

    roomRepository.save(room);
    return RoomResponseDto.from(room);
}

DTO

더보기
package com.lobbytest.dto.room;

import lombok.Getter;

@Getter
public class JoinRoomRequestDto{
    private Long userId;
}

 

방 나가기

Service에서는:

// 방 나가기
public void leaveRoom(Long roomId, Long userId) {

        Room room = roomRepository.findById(roomId);

        if (room == null) throw new IllegalArgumentException("존재하지 않는 방입니다.");
        if (!room.containsPlayer(userId)) throw new IllegalStateException("해당 방에 참가 중이지 않습니다.");

        room.removePlayer(userId);
        roomRepository.save(room);
}

 

 

현재 방 참가자 조회

    // 현재 룸 참가자 조회
    public List<RoomPlayerResponseDto> getPlayers(Long roomId) {
        Room room = roomRepository.findById(roomId);

        if (room == null) throw new IllegalArgumentException("존재하지 않는 방입니다.");

        return room.getPlayers()
                .stream()
                .map(RoomPlayerResponseDto::from)
                .toList();
    }

Unity에서:

GET /rooms/1/players

하면:

[
  {
    "userId": 10,
    "nickname": "철수",
    "ready": true
  },
  {
    "userId": 11,
    "nickname": "영희",
    "ready": false
  }
]

를 받는 식.

 

DTO

더보기
package com.lobbytest.dto.room;

import com.lobbytest.domain.RoomPlayer;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class RoomPlayerResponseDto {

    private Long userId;
    private String nickname;
    private boolean ready;

    public static RoomPlayerResponseDto from(RoomPlayer roomPlayer) {
        return new RoomPlayerResponseDto(
                roomPlayer.getUserId(),
                roomPlayer.getNickname(),
                roomPlayer.isReady()
        );
    }
}

 


7. Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/rooms")
public class RoomController {

    private final RoomService roomService;

    @PostMapping
    public ResponseEntity<RoomResponseDto> createRoom(
            @RequestBody CreateRoomRequestDto request
    ) {
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(roomService.createRoom(request));
    }

    @PostMapping("/{roomId}/players")
    public ResponseEntity<RoomResponseDto> joinRoom(
            @PathVariable Long roomId,
            @RequestBody JoinRoomRequestDto request
    ) {
        return ResponseEntity.ok(roomService.joinRoom(roomId, request));
    }

    @GetMapping("/{roomId}/players")
    public ResponseEntity<List<RoomPlayerResponseDto>> getPlayers(
            @PathVariable Long roomId
    ) {
        return ResponseEntity.ok(roomService.getPlayers(roomId));
    }

    @DeleteMapping("/{roomId}/players/{userId}")
    public ResponseEntity<Void> leaveRoom(
            @PathVariable Long roomId,
            @PathVariable Long userId
    ) {
        roomService.leaveRoom(roomId, userId);
        return ResponseEntity.noContent().build();
    }
}

 

현재 API 목록

방 생성 POST /api/v1/rooms
방 참가 POST /api/v1/rooms/{roomId}/players
현재 참가자 GET /api/v1/rooms/{roomId}/players
방 나가기 DELETE /api/v1/rooms/{roomId}/players/{userId}

 

반응형