SpaceStory 2 - 공간 예약 서비스 개발하기

junto·2024년 3월 1일
0

spring

목록 보기
18/30
post-thumbnail

  • 2주간 프로젝트를 진행하면서 구현한 기능은 다음과 같다. 아래 기능을 구현하면서 헤맸던 부분과 새롭게 적용한 경험을 공유하려고 한다!

기능 구현 목록

ERD

1. 예약

  • 사용자, 호스트에게 공간 타입마다 예약 가능한 시간을 보여준다. (READ)
  • 사용자, 호스트가 자신의 예약 목록을 조회한다. (READ)
  • 호스트가 공간에 대한 예약 목록을 조회한다.(READ)
  • 사용자, 호스트는 공간을 예약한다. (CREATE)
  • 사용자, 호스트가 공간 예약 정보(예약일, 예약시간)를 수정한다. (UPDATE)
  • 사용자, 호스트가 공간 예약을 취소한다. (DELETE)

2. 공간

  • 서울에 있는 공간을 조회한다. (READ)
  • 조건에 따라 공간을 조회한다. 조건은 {공간 유형, 공간 위치 - 시도와 시군구, 최대 수용량, 세부타입}을 지정할 수 있다. (READ)
  • 공간을 등록한다. (CREATE)
  • 공간 정보를 수정한다. (UPDATE)
  • 공간 정보를 삭제한다. (DELETE)

3. 리뷰

  • 사용자가 자신이 작성한 리뷰 목록을 조회한다. (READ)
  • 사용자가 공간 예약에 대한 리뷰를 생성한다. (CREATE)
  • 사용자가 자신이 작성한 리뷰를 수정한다. (UPDATE)
  • 사용자가 자신이 작성한 리뷰를 삭제한다. (DELETE)

새로 적용한 것

1. ErrorCode와 AdviceController, ExceptionHandler

  • ErrorCode를 enum으로 관리하면 예외를 throw할 때 구체적인 예외를 던질 수 있다. 즉, 가독성이 올라간다. ErroCode에 포함되는 항목으로는 HTTP 요청이 성공하거나 실패할 수 있으므로 HTTP Status와 응답 본문 그리고 세밀한 오류처리를 위해 에러 코드가 필요하다.
	// 사용자 정의 에러
	RESERVATION_INVALID_ID(HttpStatus.BAD_REQUEST, "R1", "The reservation requested is invalid. Please review your request.");
    
    
    private final HttpStatus status;
    private final String code;
    private final String msg;
  • 서비스 로직에서 실패할 경우 컨트롤러 계층에서 try - catch를 사용하여
    예외 처리할 수 있지만 예외별 분기 처리가 필요하다.
  • @ControllerAdvice를 이용하면 전역에서 발생하는 예외를 잡아 한 곳에서 처리할 수 있으며 @ExceptionHandler로 특정 예외 유형을 지정할 수 있다. 이를 사용하면 컨트롤러 계층에서 서비스 로직을 try-catch하여 예외 별로 핸들링할 필요가 없어진다. 가독성이 좋아진다.
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler({
            CustomException.class
    })
    public ResponseEntity<ErrorResponse> handle(CustomException e) {
        return new ResponseEntity<>(ErrorResponse.of(e.getErrorCode()), e.getErrorCode().getStatus());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handle(Exception e) {
        return new ResponseEntity<>(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR), ErrorCode.INTERNAL_SERVER_ERROR.getStatus());
    }
}
  • 대분류 예외를 선언하고 이에 속하는 중분류 예외, 소분류 예외를 선언함으로써 마치 트리 구조처럼 세분화된 예외 클래스를 선언할 수 있고 큰 분류로 예외를 핸들링할 수 있다.

2. mapstruct (DTO mapper)

  • 서비스 로직에서 Entity를 반환하여 모든 정보가 드러나는 것은 보안상 위험하다. 따라서 엔티티마다 필요한 정보만을 반환할 수 있게 DTO(Data Transfer Object)로 변환하는 작업이 필요하다. 일반적으로 of, to 메서드를 만들어 변환하지만, 코드를 직접 만들어야 하는 번거로움이 있다.

  • MapStruct를 사용하면 필드 이름이 같다면 자동으로 매핑을 해준다. 다른 엔티티에 접근해야 할 때도 간단한 어노테이션으로 쉽게 매핑할 수 있다. 또한, 컴파일 시간에 타입 안정성을 보장하고 매핑 코드를 만들어주므로 성능상 이점이 있다. 필요한 의존성은 다음과 같이 주입한다.

implementation("org.mapstruct:mapstruct:1.5.5.Final")
  • 예약이 공간과 일대다 관계이고, 공간이 부동산과 일대다 관계를 맺고 있을 때 예약 엔티티에서 다른 엔티티 필드를 매핑하는 방법은 다음과 같다.
@Mapper(componentModel = "spring") // 매퍼 구현체를 스프링 빈으로 등록한다
public interface ReservationMapper {
    @Mapping(source = "space.spaceType", target = "spaceType")
    @Mapping(source = "space.spaceName", target = "spaceName")
    @Mapping(source = "space.openingTime", target = "openingTime")
    @Mapping(source = "space.closingTime", target = "closingTime")
    @Mapping(source = "space.hourlyRate", target = "hourlyRate")
    @Mapping(source = "space.spaceSize", target = "spaceSize")
    @Mapping(source = "space.maxCapacity", target = "maxCapacity")
    @Mapping(source = "space.detailedTypes", target = "detailedTypes")
    @Mapping(source = "space.realEstate.floor", target = "floor")
    @Mapping(source = "space.realEstate.hasParking", target = "hasParking")
    @Mapping(source = "space.realEstate.hasElevator", target = "hasElevator")
    ResponseReservation ReservationToResponseReservation(SpaceReservation reservation);

    List<ResponseReservation> ReservationsToResponseReservations(List<SpaceReservation> reservations); // List 변환까지 가능하다
}

3. build.gradle.kvs

  • build.gradle은 groovy 기반 DSL로 작성되어 동적 언어의 특성을 가지는 반면, build.gradle.kvs는 kotlin 기반 DSL로 작성되어 정적 언어의 특성을 가진다.
  • 코틀린 언어적 특성을 활용할 수 있다.
java {
	sourceCompatibility = JavaVersion.VERSION_17 // 열거형 지원
}
  • 정적 언어로 정적 타입 체크를 제공하여 타입 안전하게 작성된다. getting 함수를 사용해서 기존 구성을 불러오고, extensFrom을 통해 다른 구성을 확장할 수 있다.
configurations {
    val compileOnly by getting {
        extendsFrom(configurations.getByName("annotationProcessor"))
    }
}
  • 문자열 대신 함수를 사용해서 타입 안정성 및 오타나 잘못된 값을 컴파일 시점에 확인할 수 있다.
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")

문제해결

1. 하이버네이트 역직렬화 문제

  • 공간 예약 시스템에서 공간은 기본 타입(친목, 교육, 운동, ...)을 가지며 기본 타입에 해당하는 세부 타입을 가진다. 예를 들어 교육 타입에는 스터디룸, 강의룸, 세미나룸, 미팅룸을 가질 수 있다.
  • mysql SET을 이용하여 세부 사항을 저장하면 된다고 생각했다.
CREATE TABLE RoomTypes (
    id INT AUTO_INCREMENT PRIMARY KEY,
    room_type SET('party_room', 'residence', 'cafe',
                  'performance_venue', 'conference_hall', 'exhibition_hall',
                  'study_room', 'lecture_room', 'seminar_room', 'meeting_room',
                  'dance_room', 'vocal_room', 'instrument_room', 'drawing_room', 'craft_room',
                  'badminton_court', 'futsal_court', 'tennis_court',
                  'film_studio', 'broadcast_room')
);
@Column(nullable = false)
private Set<DetailedType> detailedTypes = new HashSet<>();
public enum DetailedType {
    PARTY_ROOM, RESIDENCE, CAFE,
    PERFORMANCE_VENUE, CONFERENCE_HALL, EXHIBITION_HALL,
    STUDY_ROOM, LECTURE_ROOM, SEMINAR_ROOM, MEETING_ROOM,
    DANCE_ROOM, VOCAL_ROOM, INSTRUMENT_ROOM, DRAWING_ROOM, CRAFT_ROOM,
    BADMINTON_COURT, FUTSAL_COURT, TENNIS_COURT,
    FILM_STUDIO, BROADCAST_ROOM;
}
  • 하지만 아래와 같은 에러가 발생하였다. 찾아보니 hibernate가 특정 데이터베이스(Mysql)에 특화된 자료 저장 구조(SET)를 모두 알 수 없기 때문에 발생한 에러였다.
org.hibernate.type.SerializationException: could not deserialize
…
Caused by: java.io.StreamCorruptedException: invalid stream header
  • 공간 엔티티를 이처럼 특정 값(Enum, Embeddable)과 관계를 맺고 싶을 때는 @ElementCollection을 이용하면 된다. 다음과 같이 사용한다.
@ElementCollection(fetch = FetchType.LAZY, targetClass = DetailedType.class)
@CollectionTable(name = "space_detailed_type", joinColumns = @JoinColumn(name = "space_id"))
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Set<DetailedType> detailedTypes = new HashSet<>();
  • 엔티티를 따로 선언하지 않았다고 해서 별도의 연관 테이블이 필요 없다는 것은 아니다. ddl-auto 옵션을 사용하면 hibernate가 다음과 같은 테이블을 만들어준다. (사용하지 않을 경우 별도로 테이블을 만들어주어야 한다)
  • @CollectionTable을 통해 대상 테이블과 관계를 맺는 테이블을 정의한다. 이 테이블에는 공간_id를 외래키로 가지고 있어 join하여 검색할 수 있도록 한다.

2. 검색 조건 필터(세부 사항)

  • 공간 상세유형(LECTURE_ROOM, STUDY_ROOM, SEMINAR_ROOM, MEETING_ROOM)을 추가로 선택할 때마다 검색 조건에 추가되어 검색하는 기능을 구현하고 싶었지만 JPQL로 이를 구현하기가 어려웠다. 아래 JPQL 코드는 단순히 세부사항이 하나만 있거나, 없거나 두 가지 경우만 처리할 수 있다.
  • 세부 사항이 추가될 때마다 서비스 로직에서 분기 처리하는 것도 한계가 명확했다.
@Query("SELECT s FROM Space s " +
        "WHERE s.spaceType = :spaceType " +
        "AND s.realEstate.address.sido = :sido " +
        "AND s.realEstate.address.sigungu = :sigungu " +
        "AND s.maxCapacity >= :minCapacity " +
        "AND (:detailedType IS NULL OR :detailedType MEMBER OF s.detailedTypes) " +
        "AND s.isDeleted = false")
Page<Space> findByCriteria(@Param("spaceType") SpaceType spaceType,
                           @Param("sido") String sido,
                           @Param("sigungu") String sigungu,
                           @Param("minCapacity") int minCapacity,
                           @Param("detailedType") DetailedType detailedType,
                           Pageable pageable);
  • AND (:detailedTypes IS NULL OR s.detailedTypes IN :detailedTypes)처럼 쿼리를 작성할 수 있지 않을까?
    • SQL, JPQL에서 IN은 단일 IN 복수 형태로 사용되어야 하고, 위의 예시에선 복수 IN 복수 형태로 사용되기에 제대로 동작하지 않는다.
  • Querydsl을 이용해서 조건에 따른 동적 쿼리를 적용할 수 있었다.
@Override
public Page<Space> findByCriteriaQuerydsl(SpaceType spaceType, String sido, String sigungu,
                                          int minCapacity, Set<DetailedType> detailedTypes, Pageable pageable) {
    BooleanExpression conditions = space.spaceType.eq(spaceType)
            .and(space.realEstate.address.sido.eq(sido))
            .and(space.realEstate.address.sigungu.eq(sigungu))
            .and(space.maxCapacity.goe(minCapacity))
            .and(space.isDeleted.isFalse());

    if (detailedTypes != null && !detailedTypes.isEmpty()) {
        conditions = conditions.and(space.detailedTypes.any().in(detailedTypes));
    }

    List<Space> spaces = jpaQueryFactory.selectFrom(space)
            .where(conditions)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
	...

3. 더미데이터 만들기(호스트, 부동산, 공간, 예약)

  • 검색 쿼리가 제대로 실행되는지 확인하기 위해 더미 데이터가 필요했다. 공간을 시도, 시군구, 읍면동으로 검색할 수 있어야 한다. 서울을 기준으로 25개의 자치구를 가지고, 467개의 법정동을 가진다. 서울을 기준으로 하여 검색 쿼리를 작성한다.
  • for문으로 데이터를 만든다고 해도 데이터가 의미 있어야 하는 컬럼이 있는데 바로 주소와 타입이다. 주소는 부동산 시도, 시군구, 읍면동을 검색 쿼리 조건으로 쓰기 때문에 강남구에 해당하는 서초동이 올바르게 매칭되어야 한다. 노원구에 있는 중계동이 강남구의 동으로 될 수 없다. 또한, 공간이 교육 타입이면 LECTURE_ROOM이나 STUDY_ROOM이 되어야지 FILM_STUDIO가 될 수 없다.
  • 주소 매칭
private static final Map<String, List<String>> districtsAndDongs = new HashMap<>();

districtsAndDongs.put("강남구", Arrays.asList("역삼동", "개포동", "청담동", "삼성동", "대치동", "신사동", "논현동", "압구정동", "세곡동", "자곡동", "율현동", "일원동", "수서동", "도곡동"));
districtsAndDongs.put("강동구", Arrays.asList("명일동", "고덕동", "상일동", "길동", "둔춘동", "암사동", "성내동", "천호동", "강일동"));
districtsAndDongs.put("강북구", Arrays.asList("미아동", "번동", "수유동", "우이동"));
districtsAndDongs.put("강서구", Arrays.asList("염창동", "등촌동", "화곡동", "가양동", "마곡동", "내발산동", "외발산동", "공항동", "방화동"));
districtsAndDongs.put("관악구", Arrays.asList("봉천동", "신림동", "남현동"));
...

private static String getRandomDistrict(Set<String> districts) {
    Random random = new Random();
    List<String> districtList = new ArrayList<>(districts);
    return districtList.get(random.nextInt(districtList.size()));
}

private static String getRandomDong(List<String> list) {
    Random random = new Random();
    return list.get(random.nextInt(list.size()));
}
  • 타입 매칭
private static final Map<SpaceType, Set<DetailedType>> validDetailedTypesMap = Map.of(
        SpaceType.FRIENDSHIP, EnumSet.of(DetailedType.PARTY_ROOM, DetailedType.RESIDENCE, DetailedType.CAFE),
        SpaceType.EVENT, EnumSet.of(DetailedType.PERFORMANCE_VENUE, DetailedType.CONFERENCE_HALL, DetailedType.EXHIBITION_HALL),
        SpaceType.EDUCATION, EnumSet.of(DetailedType.STUDY_ROOM, DetailedType.LECTURE_ROOM, DetailedType.SEMINAR_ROOM, DetailedType.MEETING_ROOM),
        SpaceType.ART, EnumSet.of(DetailedType.DANCE_ROOM, DetailedType.VOCAL_ROOM, DetailedType.INSTRUMENT_ROOM, DetailedType.DRAWING_ROOM, DetailedType.CRAFT_ROOM),
        SpaceType.SPORT, EnumSet.of(DetailedType.BADMINTON_COURT, DetailedType.FUTSAL_COURT, DetailedType.TENNIS_COURT),
        SpaceType.PHOTOGRAPHY, EnumSet.of(DetailedType.FILM_STUDIO, DetailedType.BROADCAST_ROOM) );

private static SpaceType getRandomSpaceType () {
    Random random = new Random();
    SpaceType[] values = SpaceType.values();
    return values[random.nextInt(values.length)];
}

private static DetailedType getRandomDetailedType(Set<DetailedType> set) {
    int index = new Random().nextInt(set.size());
    Iterator<DetailedType> iter = set.iterator();
    for (int i = 0; i < index; i++) {
        iter.next();
    }
    return iter.next();
}
  • 이렇게 주소와 타입에 올바른 값이 들어가도록 설정하고, 나머지 컬럼값은 단순한 값으로 채워 넣어 더미 데이터를 대량으로 만들 수 있었다.

고민거리

현재 단방향 매핑이다. 양방향 매핑으로 수정해야 할까?

JPA ORM 표준 프로그래밍이라는 책에서 단방향 매핑보다는 양방향 매핑 사용을 권한다. 그 이유는 @OneToMany 관계에서 외래 키가 다른 테이블에 존재하고, 연관 관계 처리를 위한 update sql이 추가로 필요하기 때문이다. 또한 성능 문제가 있다.

  • 현재 해당 프로젝트는 예약->공간->부동산->호스트로 다대일 단방향 관계로 구성되었다.
  • @ManyToOne에서는 매핑한 객체가 외래 키를 가지고 있기 때문에 연관관계 처리를 위한 추가적인 update sql이 필요하지 않는다. 성능 문제가 N+1문제라면 단방향뿐만 아니라 양방향에서도 여전히 문제가 된다.
  • 양방향 관계로 바꾸면 무슨 이점이 있는지 잘 와닿지 않아서 양방향 매핑으로 수정하지 않았다. 공간에서 예약에 접근한다면? 부동산에서 공간에 접근한다면? 양방향 관계가 필요하겠지만 어떤 기능을 만들려고할 때 이러한 접근이 필요한지 아직까지는 잘 모르겠다.

피드백

1. 코드

  • requestMapping을 쓰지마라. 버저닝을 하게 될 때 새로운 컨트롤러를 만들어야 한다. (v1, v2)
  • build.gradle을 kotiln으로 작성해라. 규칙성 있게 작성할 수 있고 정적 언어의 장점을 누릴 수 있다.
  • Mockito.when import해라.
  • Mockito.any를 경계해라.
  • dto에서 값을 받아올 때 기본적인 validation을 적용해라.
  • ResponseEntity를 언제써야할지 생각해라.
  • Get → post → put → delete으로 작성해라.
  • lombok으로 의존성을 주입해라.
  • if 한 줄이라도 중괄호를 항상 사용해라.
  • 코드에서 의미단위로 줄 바꿈을 해라.
  • 에러메시지 한글이 아닌 영어로 써라.
  • stream, map으로 코드를 간결하게 작성해라.
  • swagger 적용해라
  • soft delete를 해라. 개인 정보 보호법 등에 의해 직접 데이터를 삭제할 일이 많지 않다.
  • 값 또는 객체를 다룰 때 null, empty, notEmpty 를 구분하여 생각해라.
  • 중심되는 에러사항이 있고, 작게 작게 뻗어나가게 globalExceptionHandler를 구현해라.
  • 어떻게 깊게 들어갈 것인가?
    • 제한사항을 생각해라. 제한사항을 프로젝트에 깊이 있게 드러내야 한다. 제한사항을 명확히 하여 성공이라는 개념을 객관화시켜라.
    • 문제를 정의하고 어떻게 접근할 것인지 어떤 방법을 사용할 것인지, 어떠한 예제들을 통과시켜야 하는지 생각하라.
    • 예상 시나리오는 통합 테스트로 만들고, 설계는 단위테스트로 검증해라.
    • 내가 쓴 코드를 말로 설명할 줄 알아야 한다. 왜 그 기술을 적용했나요? 왜 배웠나요? 어떤 생각을 하며 개발했나요?
  • 동적 쿼리는 QueryDsl로 작성해라. 복잡한 쿼리를 쉽게 다룰 수 있고, 타입 안정성도 보장받는다.

2. 방향

  • 스프린트를 하게 되면 MVP 모델을 따라가라.
  • 커밋 컨밴션을 적용해라
  • 시나리오를 정해라.. CRUD 주어만 바꾸는데 기능이 되면 안된다. 특정 도메인 내용을 담아라. 도메인을 바꿨는데 코드가 말이 되면 안된다.
  • 문제해결 능력이 중요하다. 구글 검색, chatgpt를 사용하기 전에 먼저 공식 문서를 참고해라. 오래 걸려도 괜찮다.
  • 기록을 남겨라.
  • 간단한 API라면 여러 개 만드는 것보다 하나를 재활용하는 방식을 고려해라.
    • 유저 정보를 가져온다(교수, 학생 정보를 requestparam)으로 가져온다. 분기 처리한다.
  • 엔티티 관계가 너무 복잡하다. 단순화해라. 꼭 필요할까? 없어도 해당 기능을 만들 수 있을지 생각해라.
  • 쉽게 얻은 건 쉽게 없어진다. 책 위주로 정직하게 공부해라. 잔머리 굴리지 말아라.
    • 정답은 아니지만 체계적, 단계적으로 공부할 수 있다. 본인이 커스터마이징해라.
    • 인강은 보충제 역할로 생각해라.
    • 면접 방식을 고민해서 진행해라. 무엇이 핵심이고 이걸 말로 어떻게 해야 하는가?
  • 문서화를 해라.
    • 미래의 나를 위해서 적는 것
  • 해당 기술이 없었더라면 생각해 봐라. 이렇게 생각하면 기술을 빨리 이해할 수 있다.
  • elice 트랙에서만 하는 방식으로 하지 말고, 우테코 등 깃허브 들어가서 코드 스타일 봐서 좀 배워라.
  • 취업하려는 회사 타겟팅이 중요하다. 그 회사에서 개발하고 고민할 법한 문제를 도메인 문제로 정해라.
  • 많은 걸 개발하기 보다는 정확하게 개발해라.
  • 자신을 모델링하고 상품화해라. 나를 객관화해라. 기술 중심 회사를 가라. 스타트업과 대기업 뭐든 장단점이 있다.

결론

  • 즉각적으로 코치님한테 피드백을 받을 수 있어서 그런지 12일간 프로젝트를 진행하면서 시간가는 줄 모르고 코딩하는 유쾌한 경험이었다.
  • JPA 동작 방식에 대해 아직까지 모르는 게 많다. 더 공부하고 알고 써야 한다.
  • Spring Security 적용할 예정이다.

코드링크: https://github.com/ji-jjang/SpaceStory

profile
꾸준하게

0개의 댓글