[기획 & PM & BE] BE:OUR 프로젝트

[BE:OUR] 공간 데이터 조회/수정(PUT)/삭제 API 구현

SemInDev 2025. 5. 19. 11:58

[기능 흐름과 필요한 개발 스택]

1. 기능 흐름: space id 받으면 space에 관한 데이터를 조회/수정/삭제 가능

- 조회

    1) 간단(요약) 정보 조회: hostId를 통해 이름, 주소(동까지), 가격, 태그 목록, 썸네일 이미지 URL 반환.

       + 주소 데이터를 동까지만 표시하도록 추가적인 메소드 구현

    2) 상세 정보 조회: hostId를 통해 공간 정보와 관련된 모든 데이터 반환

- 수정(PUT, PATCH 버전)

- 삭제(소프트 삭제 방식)

 

2. 필요한 기술 스택

  • Spring Boot(REST API 서버)
  • JPA (Hibernate): DB 저장용 ORM
  • MySQL(RDB)

 

 


1. 간단 정보 조회 API

SpaceSimpleResponseDto

@Getter
@AllArgsConstructor
public class SpaceSimpleResponseDto {
    private String name;
    private String address;
    private int pricePerHour;
    private List<String> tags;
    private String thumbnailUrl;
}

 

 

SpaceService

@Transactional(readOnly = true)
public SpaceSimpleResponseDto getSimpleSpaceInfo(Long spaceId) {
    Space space = spaceRepository.findById(spaceId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 공간입니다."));

    List<String> tagContents = space.getTags().stream()
            .map(Tag::getContents)
            .collect(Collectors.toList());

    return new SpaceSimpleResponseDto(
            space.getName(),
            extractDongFromAddress(space.getAddress()),
            space.getPricePerHour(),
            tagContents,
            space.getThumbnailUrl()
    );
}

// ex: "서울시 강남구 역삼동 어딘가 123" -> "서울시 강남구 역삼동"
private String extractDongFromAddress(String address) {
    String[] parts = address.split(" ");
    return parts.length >= 3 ? String.join(" ", parts[0], parts[1], parts[2]) : address;
}

 

*@Transactional(readOnly = true)
@Transactional: 메서드나 클래스에 트랜잭션(=데이터베이스의 '하나의 작업 단위')을 설정.
readOnly = true: 이 옵션을 주면 쓰기 작업은 막고, 읽기 성능을 최적화함. 즉, 조회만 하는 메서드에 붙인다.

 

*space.getTags(): space객체와 연관된(@OneToMany(mappedBy = "space")기반으로) Tag 엔티티들의 리스트를 가져옴.

 

 

 

SpaceController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/spaces")
public class SpaceController {

    private final SpaceService spaceService;

    @GetMapping("/{id}/simple")
    public ResponseEntity<SpaceSimpleResponseDto> getSimpleInfo(@PathVariable Long id) {
        return ResponseEntity.ok(spaceService.getSimpleSpaceInfo(id));
    }

}

 

*ResponseEntity.ok(): 정상적인 응답을 의미(응답 200 OK + 본문(body) 객체)

HTTP/1.1 200 OK
Content-Type: application/json

{
  "name": "비워 공간",
  "price_per_hour": 15000,
  "tags": ["카페", "단독", "브런치"]
}

 

 

 


2. 상세 정보 조회 API

SpaceDetailResponseDto

@Getter
@Builder
public class SpaceDetailResponseDto {
    private Long id;
    private String name;
    private String address;
    private String detailAddress;
    private int pricePerHour;
    private int maxCapacity;
    private SpaceCategory spaceCategory;
    private UseCategory useCategory;
    private Double avgRating;

    // Description
    private String description;
    private String priceGuide;
    private String facilityNotice;
    private String notice;
    private String locationDescription;
    private String refundPolicy;
    private String websiteUrl;

    private List<String> tags;
    private List<AvailableTimeDto> availableTimes;
    private List<String> imageUrls;

    @Getter
    @AllArgsConstructor
    public static class AvailableTimeDto {
        private LocalDate date;
        private LocalTime startTime;
        private LocalTime endTime;
    }
}

 

 

SpaceService

@Transactional(readOnly = true)
public SpaceDetailResponseDto getDetailedSpaceInfo(Long spaceId) {
    Space space = spaceRepository.findById(spaceId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 공간입니다."));

    Description desc = space.getDescription();

    return SpaceDetailResponseDto.builder()
            .id(space.getId())
            .name(space.getName())
            .address(space.getAddress())
            .detailAddress(space.getDetailAddress())
            .pricePerHour(space.getPricePerHour())
            .maxCapacity(space.getMaxCapacity())
            .spaceCategory(space.getSpaceCategory())
            .useCategory(space.getUseCategory())
            .avgRating(space.getAvgRating())
            .description(desc.getDescription())
            .priceGuide(desc.getPriceGuide())
            .facilityNotice(desc.getFacilityNotice())
            .notice(desc.getNotice())
            .locationDescription(desc.getLocationDescription())
            .refundPolicy(desc.getRefundPolicy())
            .websiteUrl(desc.getWebsiteUrl())
            .tags(space.getTags().stream().map(Tag::getContents).toList())
            .availableTimes(space.getAvailableTimes().stream()
                    .map(t -> new SpaceDetailResponseDto.AvailableTimeDto(
                            t.getDate(), t.getStartTime(), t.getEndTime()))
                    .toList())
            .imageUrls(space.getSpaceImages().stream().map(SpaceImage::getImageUrl).toList())
            .build();
}

 

 

SpaceController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/spaces")
public class SpaceController {

    private final SpaceService spaceService;

    @GetMapping("/{id}")
    public ResponseEntity<SpaceDetailResponseDto> getDetailInfo(@PathVariable Long id) {
        return ResponseEntity.ok(spaceService.getDetailedSpaceInfo(id));
    }
}

 

 

 

 


3. 정보 수정 API - PUT(PATCH는 다음 글에 작성 예정)

SpaceUpdateRequestDto

@Getter
public class SpaceUpdateRequestDto {
    private String name;
    private String address;
    private String detailAddress;
    private int pricePerHour;
    private int maxCapacity;
    private SpaceCategory spaceCategory;
    private UseCategory useCategory;
    private String thumbnailUrl;

    // Description
    private String description;
    private String priceGuide;
    private String facilityNotice;
    private String notice;
    private String locationDescription;
    private String refundPolicy;
    private String websiteUrl;

    private List<String> tags;
    private List<AvailableTimeDto> availableTimes;
    private List<String> imageUrls;

    @Getter
    public static class AvailableTimeDto {
        private LocalDate date;
        private LocalTime startTime;
        private LocalTime endTime;
    }
}

 

 


SpaceService

@Transactional
public void updateSpace(Long spaceId, SpaceUpdateRequestDto dto) {
    Space space = spaceRepository.findById(spaceId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 공간입니다."));

    double[] latLng = kakaoMapService.getLatLng(dto.getAddress());

    // 1. Space 수정
    space.update(
        dto.getName(), dto.getAddress(), dto.getDetailAddress(), dto.getPricePerHour(),
        dto.getMaxCapacity(), dto.getSpaceCategory(), dto.getUseCategory(),
        dto.getThumbnailUrl(), latLng[0], latLng[1]
    );

    // 2. Description 수정
    Description desc = space.getDescription();
    desc.update(
        dto.getDescription(), dto.getPriceGuide(), dto.getFacilityNotice(), dto.getNotice(),
        dto.getLocationDescription(), dto.getRefundPolicy(), dto.getWebsiteUrl()
    );

    // 3. Tags 재저장
    tagRepository.deleteAll(space.getTags());
    List<Tag> tags = dto.getTags().stream()
            .map(content -> Tag.builder().space(space).contents(content).build())
            .toList();
    tagRepository.saveAll(tags);

    // 4. AvailableTimes 재저장
    availableTimeRepository.deleteAll(space.getAvailableTimes());
    List<AvailableTime> times = dto.getAvailableTimes().stream()
            .map(t -> AvailableTime.builder()
                    .space(space)
                    .date(t.getDate())
                    .startTime(t.getStartTime())
                    .endTime(t.getEndTime())
                    .build())
            .toList();
    availableTimeRepository.saveAll(times);

    // 5. Images 재저장
    spaceImageRepository.deleteAll(space.getSpaceImages());
    List<SpaceImage> images = dto.getImageUrls().stream()
            .map(url -> SpaceImage.builder().space(space).imageUrl(url).build())
            .toList();
    spaceImageRepository.saveAll(images);
}

 

 

Space

public void update(String name, String address, String detailAddress, int pricePerHour,
                   int maxCapacity, SpaceCategory spaceCategory, UseCategory useCategory,
                   String thumbnailUrl, double lat, double lng) {
    this.name = name;
    this.address = address;
    this.detailAddress = detailAddress;
    this.pricePerHour = pricePerHour;
    this.maxCapacity = maxCapacity;
    this.spaceCategory = spaceCategory;
    this.useCategory = useCategory;
    this.thumbnailUrl = thumbnailUrl;
    this.latitude = lat;
    this.longitude = lng;
}

Description

public void update(String description, String priceGuide, String facilityNotice,
                   String notice, String locationDescription, String refundPolicy, String websiteUrl) {
    this.description = description;
    this.priceGuide = priceGuide;
    this.facilityNotice = facilityNotice;
    this.notice = notice;
    this.locationDescription = locationDescription;
    this.refundPolicy = refundPolicy;
    this.websiteUrl = websiteUrl;
}

 

 

 

SpaceController

@PutMapping("/{id}")
public ResponseEntity<Void> updateSpace(@PathVariable Long id,
                                        @RequestBody SpaceUpdateRequestDto dto) {
    spaceService.updateSpace(id, dto);
    return ResponseEntity.noContent().build();
}

*@PutMapping: RESTful API에서 리소스 전체를 수정(갱신)할 때 사용

*ResponseEntity.noContent().build(): HTTP 응답 204 No Content를 반환

   (요청은 성공적으로 처리 + 응답 본문은 없음 -> 삭제 요청 시 자주 사용)

 

 

 


4. 정보 삭제 API(소프트 삭제 방식)

Space

public void delete() {
    this.deletedAt = LocalDateTime.now();
}

 

소프트 삭제(Soft Delete) 방식

: DB에서 데이터를 실제로 지우지 않고'deleted_at', 'is_deleted' 같은 컬럼으로 삭제된 것처럼 처리하는 방식.
  -> 보통은 사용자 정보, 게시글, 주문 등 이력이 중요한 데이터에 소프트 삭제를 사용함.

 

 

 

SpaceService

@Transactional
public void deleteSpace(Long spaceId) {
    Space space = spaceRepository.findById(spaceId)
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 공간입니다."));
    space.delete();
}

 

 

 

SpaceController

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteSpace(@PathVariable Long id) {
    spaceService.deleteSpace(id);
    return ResponseEntity.noContent().build();
}