Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

✨ [Feature/room] implement create room #30

Merged
merged 17 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kok-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
implementation project(':kok-core')
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3'

compileOnly 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.kok.kokapi.adapter.in.web;

import com.kok.kokapi.application.usecase.HealthCheckUseCase;
import com.kok.kokapi.application.service.HealthCheckService;
import com.kok.kokapi.common.response.ApiResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -10,10 +10,10 @@
@RequiredArgsConstructor
public class HealthCheckController extends BaseController {

private final HealthCheckUseCase healthCheckUseCase;
private final HealthCheckService healthCheckService;

@GetMapping("/health")
public ApiResponseDto<String> checkHealth() {
return ApiResponseDto.success(healthCheckUseCase.checkHealth());
return ApiResponseDto.success(healthCheckService.checkHealth());
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package com.kok.kokapi.application.service;

import com.kok.kokapi.application.usecase.HealthCheckUseCase;
import org.springframework.stereotype.Service;

@Service
public class HealthCheckService implements HealthCheckUseCase {
public class HealthCheckService {

@Override
public String checkHealth() {
return "OK";
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,37 @@

import com.kok.kokapi.common.response.ApiResponseDto;
import com.kok.kokapi.common.error.ErrorCode;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponseDto<Void>> handleConstraintViolationException(ConstraintViolationException ex) {
String message = ex.getConstraintViolations()
.stream()
.map(cv -> cv.getPropertyPath() + " " + cv.getMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest()
.body(ApiResponseDto.error(ErrorCode.INVALID_INPUT, message));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponseDto<Void>> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors()
.stream()
.map(fe -> fe.getField() + " " + fe.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest()
.body(ApiResponseDto.error(ErrorCode.INVALID_INPUT, message));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponseDto<Void>> handleBadRequestException(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
Expand Down
28 changes: 28 additions & 0 deletions kok-api/src/main/java/com/kok/kokapi/config/redis/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.kok.kokapi.config.redis;

import lombok.RequiredArgsConstructor;
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.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@RequiredArgsConstructor
public class RedisConfig {

// 추후 ConnectionFactory설정 변경을 고려. (Sentinel, Cluster, etc...)
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);

// Key Serializer 설정 (UUID -> String 변환)
redisTemplate.setKeySerializer(new StringRedisSerializer());

// Value Serializer 설정 (객체 -> JSON 변환)
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.kok.kokapi.config.usecase;

import com.kok.kokapi.room.application.service.RoomService;
import com.kok.kokcore.application.port.out.SaveRoomPort;
import com.kok.kokcore.usecase.CreateRoomUseCase;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class UseCaseConfig {
@Bean
public CreateRoomUseCase createRoomUseCase(SaveRoomPort saveRoomPort) {
return new RoomService(saveRoomPort);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.kok.kokapi.room.adapter.in.dto.request;


import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateRoomRequest(
@NotBlank(message = "방 이름은 필수 입력값입니다.")
@Size(max = 30, message = "방 이름은 최대 30자까지 가능합니다.")
String roomName,

@Min(value = 2, message = "참여 인원 수는 최소 2명 이상이어야 합니다.")
Integer capacity,

String hostProfile,

@NotBlank(message = "비밀번호는 필수 입력값입니다.")
String password
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.kok.kokapi.room.adapter.in.dto.response;

import com.kok.kokcore.domain.Room;

public record RoomResponse(
String id,
String roomName,
int capacity,
String hostProfile,
String roomLinkUrl
) {
public static RoomResponse from(Room room) {
return new RoomResponse(
room.getId(),
room.getRoomName(),
room.getCapacity(),
room.getHostProfile(),
room.getRoomLinkUrl()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.kok.kokapi.room.adapter.in.web;

import com.kok.kokapi.common.response.ApiResponseDto;
import com.kok.kokapi.room.adapter.in.dto.request.CreateRoomRequest;
import com.kok.kokapi.room.adapter.in.dto.response.RoomResponse;
import com.kok.kokcore.domain.Room;
import com.kok.kokcore.usecase.CreateRoomUseCase;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

private final CreateRoomUseCase createRoomUseCase;

@PostMapping("/create")
public ResponseEntity<ApiResponseDto<RoomResponse>> createRoom(@Valid @RequestBody CreateRoomRequest request) {
Room room = createRoomUseCase.createRoom(
request.roomName(),
request.capacity(),
request.hostProfile(),
request.password()
);

var response = RoomResponse.from(room);
return ResponseEntity.ok(ApiResponseDto.success(response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.kok.kokapi.room.adapter.out.persistence;

import com.kok.kokcore.application.port.out.SaveRoomPort;
import com.kok.kokcore.domain.Room;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.time.Duration;

@Repository
public class RedisSaveRoomPersistenceAdapter implements SaveRoomPort {

private static final String ROOM_KEY_PREFIX = "room";
private static final Duration ROOM_TTL = Duration.ofDays(3);
private final RedisTemplate<String, Object> redisTemplate;

public RedisSaveRoomPersistenceAdapter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}

@Override
public Room save(Room room) {
String key = buildKey(room.getId());
redisTemplate.opsForValue().set(key, room, ROOM_TTL);
return room;
}

private String buildKey(String roomId) {
return ROOM_KEY_PREFIX + ":" + roomId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.kok.kokapi.room.application.service;

import com.kok.kokcore.application.port.out.SaveRoomPort;
import com.kok.kokcore.domain.Room;
import com.kok.kokcore.usecase.CreateRoomUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class RoomService implements CreateRoomUseCase {

private final SaveRoomPort saveRoomPort;

@Override
public Room createRoom(String roomName, int capacity, String hostProfile, String password) {
Room room = Room.create(roomName, capacity, hostProfile, password);
return saveRoomPort.save(room);
}
}
7 changes: 7 additions & 0 deletions kok-api/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ spring:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
# sentinel:
# master: ${REDIS_SENTINEL_MASTER}
# nodes:
# - ${REDIS_SENTINEL_HOST1}:${REDIS_SENTINEL_PORT1}
# - ${REDIS_SENTINEL_HOST2}:${REDIS_SENTINEL_PORT2}
# - ${REDIS_SENTINEL_HOST3}:${REDIS_SENTINEL_PORT3}

springdoc:
default-consumes-media-type: application/json;charset=UTF-8
default-produces-media-type: application/json;charset=UTF-8
Expand Down
2 changes: 2 additions & 0 deletions kok-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Copy link
Member

Choose a reason for hiding this comment

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

redis는 api 모듈에서 사용하는 것 같은데, core 모듈에 추가한 이유가 궁금해요~

Copy link
Collaborator Author

@yunyoung1819 yunyoung1819 Feb 20, 2025

Choose a reason for hiding this comment

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

저희가 도메인과 엔티티를 따로 분리하지 않고 같이 쓰기로 했었다보니 core 모듈에서도 redis 관련 애노테이션이 쓰일 수 있어 core 모듈에 추가했어요~

예를 들어 Room 객체를 Redis에 hash 타입으로 저장하는 것 등으로 바꾼다면

@RedisHash("room")
public class Room {
  ...
}

이렇게 쓰이게 되어 core 모듈에 추가했었습니다 😄

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kok.kokcore.application.port.out;

import com.kok.kokcore.domain.Room;

public interface SaveRoomPort {
Room save(Room room);
}
49 changes: 49 additions & 0 deletions kok-core/src/main/java/com/kok/kokcore/domain/Room.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.kok.kokcore.domain;

import lombok.*;

import java.io.Serializable;
import java.util.UUID;

@Getter
@ToString
@EqualsAndHashCode
public class Room implements Serializable {

public static final int REQUIRED_CAPACITY = 2;
public static final String ROOM_LINK_URL = "kakao://app/room?roomId=";

private final String id; // 약속방 ID (UUID)
private final String roomName; // 약속방 이름
private final int capacity; // 참여인원 수 (최소 2명 이상)
private final String hostProfile; // 방장 프로필 정보
private final String password; // 방 비밀번호 (옵션)
private final String roomLinkUrl; // 생성된 약속방 입장 링크

public Room(String id, String roomName, int capacity, String hostProfile,
String password, String roomLinkUrl) {
this.id = id;
this.roomName = roomName;
this.capacity = capacity;
this.hostProfile = hostProfile;
this.password = password;
this.roomLinkUrl = roomLinkUrl;
}

public static Room create(String roomName, int capacity, String hostProfile, String password) {
Copy link
Member

Choose a reason for hiding this comment

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

생성자가 아닌 정적 팩터리 메서드로 분리한 이유가 궁금합니다!
VO를 분리하여 검증 책임을 적절히 위임한다면 주생성자 - 부생성자 형식으로 생성자 체이닝도 가능할 것 같아요!

Copy link
Collaborator Author

@yunyoung1819 yunyoung1819 Feb 20, 2025

Choose a reason for hiding this comment

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

주생성자 - 부생성자 형식으로도 구현해서 검증은 부생성자에 위임하는 방식으로 생성자 체이닝 방식도 좋지만, 정적 팩토리 메서드로 구현하는 것이 몇가지 더 장점이 있다고 생각했어요.

  1. 아래처럼 this()를 이용해서 주생성자를 호출하는 것보다는 메서드 이름을 통해 목적을 명확하게 표현가능
this(roomId, roomName, capacity, hostProfile, password, roomLinkUrl);
  1. 반환 타입의 하위 타입 객체를 반환도 가능
  2. this(roomName, password), this(roomName, password, hostProfile), ... 처럼 부생성자가 많아지는 경우 어떤 목적인지 명확히 알기 어렵고, 매개변수가 타입은 같은데 한두개 순서만 다른 경우 다른 사람이 잘못 사용할 수도 있음

이펙티브 자바에도 비슷한 내용이 나오는데 이외에도 장단점이 더 있지만 정적 팩터리 메서드가 좀더 낫다고 생각했습니다 😄

if (roomName == null || roomName.trim().isEmpty()) {
throw new IllegalArgumentException("Room name is required");
}
if (capacity < REQUIRED_CAPACITY) {
throw new IllegalArgumentException("At least 2 participants are required");
}
if (hostProfile == null || hostProfile.trim().isEmpty()) {
throw new IllegalArgumentException("Host profile is required");
}

String roomId = UUID.randomUUID().toString();
String roomLinkUrl = ROOM_LINK_URL + roomId;

return new Room(roomId, roomName, capacity, hostProfile, password, roomLinkUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kok.kokcore.usecase;

import com.kok.kokcore.domain.Room;

public interface CreateRoomUseCase {
Room createRoom(String roomName, int capacity, String hostProfile, String password);
}
Loading