스프링부트

방 관련 컨트롤러, 서비스 등 로직 작성

masxer 2025. 8. 3. 14:58

방 + 감정 쪽 테이블과 리포지토리 관계성

 

리포지토리

대상 엔티티역할
RoomRepository Room 방 생성/조회/삭제 등
EmotionRoomRepository EmotionRoom 특정 방에 감정 연결, 감정 조회 등
EmotionRepository Emotion 감정 자체(마스터 테이블) 조회 등
RoomMemberRepository RoomMember 방 참여자 정보를 기반으로 방과 회원 간의 관계를 조회, 관리하는 역할

 

이번 프로젝트에서 나는 방 만들기, 스케줄 짜기, 채팅 기능 구현이 나의 담당이기 때문에 방 만들기 로직을 먼저 구현해봤다. 

Emotion과 Room에 대한 N : N 관계를 해소하기 위해 중간 테이블인 EmotionRoom테이블을 만들어서 특정 방에 연결된 감정을 저장하고 조회하는 테이블을 만들었다. Emotion은 다른 팀원이 담당하고 있기 때문에 Emotion이 완성되기 전까지는 Emotion에 관련된 로직은 주석처리를 해놨다.

RoomRequest.java (DTO)

package com.moodTrip.spring.domain.rooms.dto.request;

import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.List;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RoomRequest {

    @NotNull
    private DestinationDto destination;

    @Size(min = 1)
    private List<EmotionDto> emotions;

    @NotNull
    private ScheduleDto schedule;

    @Min(1)
    private int maxParticipants;

    @NotBlank
    private String roomName;

    @NotBlank
    private String roomDescription;

    private String version;


    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public static class DestinationDto {
        private String category; // 예: 지역명 나중에 지역 코드로 받아야됨
        private String name;     // 예: 장소명
    }

    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public static class EmotionDto {
        private Long id;
        private String text;
    }

    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public static class ScheduleDto {

        private List<DateRangeDto> dateRanges;

        private int totalDays;

        private int rangeCount;

        @Getter
        @Setter
        @NoArgsConstructor
        @AllArgsConstructor
        @Builder
        public static class DateRangeDto {

            @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
            private LocalDateTime startDate;

            @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
            private LocalDateTime endDate;

            private String startDateFormatted;
            private String endDateFormatted;
        }
    }
}

 

UpdateRoomRequest (DTO)

package com.moodTrip.spring.domain.rooms.dto.request;

import lombok.*;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UpdateRoomRequest {
    private String roomName;
    private String roomDescription;
    private int maxParticipants;
}

 

RoomResponse (DTO)

package com.moodTrip.spring.domain.rooms.dto.response;

import com.moodTrip.spring.domain.rooms.entity.Room;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class RoomResponse {

    private Long roomId;
    private String roomName;
    private String roomDescription;
    private int maxParticipants;
    private int currentParticipants;

    private String travelStartDate;
    private String travelEndDate;

    private Boolean isDeleted;

    // 정적 팩토리 메서드
    public static RoomResponse from(Room room) {
        return new RoomResponse(
                room.getRoomId(),
                room.getRoomName(),
                room.getRoomDescription(),
                room.getRoomMaxCount(),
                room.getRoomCurrentCount(),
                room.getTravelStartDate() != null ? room.getTravelStartDate().toString() : null,
                room.getTravelEndDate() != null ? room.getTravelEndDate().toString() : null,
                room.getIsDeleteRoom()
        );
    }
}

 

여기서 정적 팩토리는 Room 엔티티를 RoomResponse DTO로 변환하는 역할을 수행합니다.

직관적이고 명확한 변환 메서드 이름을 제공하고, new 키워드 없이 간결하게 객체를 생성하고, 불필요한 setter 사용을 방지할 수 있습니다. 그리고 null 처리 등의 로직을 포함시킬 수 있다는 장점이 있습니다.

 

Room.java (Entity)

package com.moodTrip.spring.domain.rooms.entity;

import com.moodTrip.spring.global.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDate;

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "companion_room")
public class Room extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long roomId;

    private String roomName; // 방 이름

    @Column(length = 1000)
    private String roomDescription; // 방 설명
    private int roomMaxCount;   // 최대 인원
    private int roomCurrentCount; // 현재 인원

    private LocalDate travelStartDate; // 여행 시작 날짜
    private LocalDate travelEndDate; // 여행 종료 날짜

//    @ManyToOne(fetch = FetchType.LAZY)
//    @JoinColumn(name = "attraction_id")
//    private Attraction attraction;
//
//    @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
//    private List<EmotionRoom> emotionRooms = new ArrayList<>();
//
//    @ManyToOne(fetch = FetchType.LAZY)
//    @JoinColumn(name = "creator_id")
//    private Member creator;

    private Boolean isDeleteRoom = false;



}

 

EmotionRoom.java(Entity) 방에 대한 감정과 방 Id가 저장되는 Entity

package com.moodTrip.spring.domain.rooms.entity;

import com.moodTrip.spring.global.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "emotion_room")
public class EmotionRoom extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long emotionRoomId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "room_id")
    private Room room;

//    @ManyToOne(fetch = FetchType.LAZY)
//    @JoinColumn(name = "tag_id")
//    private Emotion emotion;
}

 

RoomRepository.java(Repository)

package com.moodTrip.spring.domain.rooms.repository;

import com.moodTrip.spring.domain.rooms.entity.Room;
import org.springframework.data.jpa.repository.JpaRepository;


public interface RoomRepository extends JpaRepository<Room, Long> {
}

 

EmotionRoomRepository.java(Repository)

package com.moodTrip.spring.domain.rooms.repository;

import com.moodTrip.spring.domain.rooms.entity.EmotionRoom;
import com.moodTrip.spring.domain.rooms.entity.Room;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface EmotionRoomRepository extends JpaRepository<EmotionRoom, Long> {
    List<EmotionRoom> findByRoom(Room room);
}

 

Service.java

package com.moodTrip.spring.domain.rooms.service;


import com.moodTrip.spring.domain.rooms.dto.request.RoomRequest;
import com.moodTrip.spring.domain.rooms.dto.request.UpdateRoomRequest;
import com.moodTrip.spring.domain.rooms.dto.response.RoomResponse;
import com.moodTrip.spring.domain.rooms.entity.Room;

import java.util.List;

import static com.moodTrip.spring.domain.rooms.dto.request.RoomRequest.*;

public interface RoomService {
    RoomResponse createRoom(RoomRequest request); // 방 생성
    RoomResponse getRoomById(Long roomId); // 특정 방 조회(상세 페이지)
    List<RoomResponse> getAllRooms(); // 모든 방 조회
    void addEmotionRooms(Room room, List<EmotionDto> emotions); // 방에서 등록한 감정 저장
    void deleteRoomById(Long roomId); // 방 삭제
    RoomResponse updateRoom(Long roomId, UpdateRoomRequest request); // 방 수정
}

 

ServiceImpl.java

package com.moodTrip.spring.domain.rooms.service;

import com.moodTrip.spring.domain.rooms.dto.request.RoomRequest;
import com.moodTrip.spring.domain.rooms.dto.request.RoomRequest.ScheduleDto.DateRangeDto;
import com.moodTrip.spring.domain.rooms.dto.request.UpdateRoomRequest;
import com.moodTrip.spring.domain.rooms.dto.response.RoomResponse;
import com.moodTrip.spring.domain.rooms.entity.EmotionRoom;
import com.moodTrip.spring.domain.rooms.entity.Room;
import com.moodTrip.spring.domain.rooms.repository.EmotionRoomRepository;
import com.moodTrip.spring.domain.rooms.repository.RoomRepository;
import com.moodTrip.spring.global.common.exception.CustomException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

import static com.moodTrip.spring.domain.rooms.dto.request.RoomRequest.*;
import static com.moodTrip.spring.global.common.code.status.ErrorStatus.*;

@Service
@RequiredArgsConstructor
@Slf4j
public class RoomServiceImpl implements RoomService {
    private final RoomRepository roomRepository;
    private final EmotionRoomRepository emotionRoomRepository;
    // private final EmotionRepository emotionRepository; // 추후 활성화

    // 방 생성 로직
    @Override
    @Transactional
    public RoomResponse createRoom(RoomRequest request) {
        // 날짜 범위 계산
        List<DateRangeDto> ranges = request.getSchedule().getDateRanges();
        LocalDate travelStartDate = ranges.stream()
                .map(r -> r.getStartDate().toLocalDate())
                .min(Comparator.naturalOrder())
                .orElseThrow(() -> new CustomException(INVALID_TRAVEL_DATE));

        LocalDate travelEndDate = ranges.stream()
                .map(r -> r.getEndDate().toLocalDate())
                .max(Comparator.naturalOrder())
                .orElseThrow(() -> new CustomException(INVALID_TRAVEL_DATE));

        Room room = Room.builder()
                .roomName(request.getRoomName())
                .roomDescription(request.getRoomDescription())
                .roomMaxCount(request.getMaxParticipants())
                .roomCurrentCount(1)
                .travelStartDate(travelStartDate)
                .travelEndDate(travelEndDate)
                .isDeleteRoom(false)
                .build();

        Room savedRoom = roomRepository.save(room);

        return RoomResponse.from(savedRoom);
    }

    // Room 단건 조회 서비스
    @Override
    public RoomResponse getRoomById(Long roomId) {
        Room room = roomRepository.findById(roomId)
                .orElseThrow(() -> new CustomException(ROOM_NOT_FOUND));
        return RoomResponse.from(room);
    }

    // 방 목록 조회 서비스
    @Override
    public List<RoomResponse> getAllRooms() {
        return roomRepository.findAll().stream()
                .map(RoomResponse::from)
                .collect(Collectors.toList());
    }


    // 방 감정 연관 저장 로직
    @Override
    public void addEmotionRooms(Room room, List<EmotionDto> emotions) {
        for (EmotionDto dto : emotions) {
            // Emotion emotion = emotionRepository.findById(dto.getId())
            //     .orElseThrow(() -> new CustomException(EMOTION_NOT_FOUND));

            EmotionRoom emotionRoom = EmotionRoom.builder()
                    .room(room)
                    // .emotion(emotion)
                    .build();

            emotionRoomRepository.save(emotionRoom);
        }
    }

    // 방 삭제 (soft delete)
    @Override
    public void deleteRoomById(Long roomId) {
        Room room = roomRepository.findById(roomId)
                .orElseThrow(() -> new CustomException(ROOM_NOT_FOUND));
        room.setIsDeleteRoom(true);
        roomRepository.save(room);
    }

    // 방 수정
    @Override
    public RoomResponse updateRoom(Long roomId, UpdateRoomRequest request) {
        Room room = roomRepository.findById(roomId)
                .orElseThrow(() -> new CustomException(ROOM_NOT_FOUND));

        if(request.getRoomName() != null) {
            room.setRoomName(request.getRoomName());
        }

        if(request.getRoomDescription() != null) {
            room.setRoomDescription(request.getRoomDescription());
        }

        if(request.getMaxParticipants() > 0 && request.getMaxParticipants() >= room.getRoomCurrentCount()) {
            room.setRoomMaxCount(request.getMaxParticipants());
        }else {
            throw new CustomException(INVALID_MAX_PARTICIPANT); // 예외 처리
        }

        Room updated = roomRepository.save(room);
        return RoomResponse.from(updated);
    }


}

 

Api Controller 작성하기

package com.moodTrip.spring.domain.rooms.controller;

import com.moodTrip.spring.domain.rooms.dto.request.DraftRoomCreateRequest;
import com.moodTrip.spring.domain.rooms.dto.response.DraftRoomResponse;
import com.moodTrip.spring.domain.rooms.service.RoomService;
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
@RequiredArgsConstructor
@RequestMapping("/api/v1/companion-rooms")
public class RoomApiController {
    private final RoomService roomService;

}

위는 apiController를 작성하기 위한 기본 틀이다. 여기에 컨트롤러 로직을 생성하면 된다.

RoomServiceImpl이 아닌 RoomService를 사용하는 이유 : 인터페이스에 의존하는게 스프링의 DI 원칙이다.

장점

  • 인터페이스만 보고 개발한 코드는 수정 없이 다른 구현체로 교체 가능

 

ResponseEntity<T>

  • 클라이언트에게 HTTP 응답 전체(= 바디 + 헤더 + 상태 코드)를 보낼 수 있게 도와주는 Spring의 응답 객체 클래스

구조

ResponseEntity<T> = {
    body: T,                  // 실제 응답 데이터
    headers: HttpHeaders,     // 응답 헤더들
    status: HttpStatus        // HTTP 응답 코드 (예: 200, 201, 404 등)
}

 

ex)

return ResponseEntity.ok(response); // == 상태 200 OK + 바디에 response 포함
return ResponseEntity.status(HttpStatus.CREATED).body(roomResponse); // 상태 201 + 바디 포함

 

@RequestBody

  • 클라이언트가 JSON 형식으로 보낸 요청 데이터를 자바 객체(DTO)로 자동 변환해주는 어노테이션

ex)

POST /api/rooms
Content-Type: application/json

{
  "roomName": "테스트 방",
  "roomDescription": "테스트 설명",
  ...
}

위 JSON이 컨트롤러에서 자동으로 매핑됨

@PostMapping
public ResponseEntity<RoomResponse> createRoom(@RequestBody RoomRequest request)

@Valid

  • `@RequestBody`로 받은 DTO 객체에 대해 검증(Validation)을 자동으로 수행해주는 어노테이션
  • DTO 클래스에 `@NotNull`, `@Size`, `@Min`, `@Pattern` 같은 제약 조건이 붙어 있어야 효과가 있음
public class RoomRequest {
    @NotBlank
    private String roomName;

    @Min(1)
    private int maxParticipants;
}

@Valid를 쓰면?

@PostMapping
public ResponseEntity<RoomResponse> createRoom(@RequestBody @Valid RoomRequest request)
  • roomName 이 빈 문자열이거나
  • maxParticipants가 0 이하면

→ 자동으로 400 Bad Request + 에러 메시지 리턴됨 (예외 핸들러 설정 시)

@Autowired: 스프링 컨테이너가 의존 객체를 주입해주는 어노테이션