이번 프로젝트는 싱크라이프라는 회사의 과제로 나온 '스터디룸 예약 시스템'을 주제로 만들었다.
핵심 목표는 '동시성 속에서 무결성 지키기' 로써 중첩 예약 및 시간 겹침 방지를 중점으로 뒀다.
핵심 로직
- PostgreSQL의 tstzrange 타입과 EXCLUDE USING gist 제약을 활용을 활용하여 중첩 예약 및 시간 겹침 방지
- GlobalExceptionHandler를 통한 예외 처리
- Spring Security를 활용한 접근 권한 제어
1. 중첩 예약 및 시간 겹침 방지 구현
이번 프로젝트의 가장 핵심이 되는 부분으로써 예약 시스템에는 반드시 들어가야하는 조건이다.처음에는 이것을 자주 사용하던 Mysql이나 MariaDB등으로 해결하려고 했으나 그렇게 하면 로직이 쓸데없이 복잡해진다.
- 중첩 예약 방지를 위해 이미 예약된 방의 정보를 불러와 비교해야한다.- 시간 겹침을 방지하기 위해서 불러온 정보에서 또 다시 시작시간과 끝 시간을 분리하여 대조해야한다.└> 이러한 과정들은 성능을 저하시키고, 로직을 복잡하게 만들수 있다.
그래서 과제에 힌트로 주어진 가장 효율적인 방법으로 PostgreSQL의 tstzrange 타입과 EXCLUDE USING gist 제약을 활용하기로 하였다.
- tstzrange( timestamp with time zone )시간의 범위를 저장하는 PostgreSQL 타입으로써 시작시간과 끝시간 쌍의 형태로 시간의 범위를 지정한다.
["2025-09-21 06:40:57.954+09","2025-09-21 07:40:57.954+09")
- GiST Index( Generalized Search Tree )
모든 종류의 데이터와 검색 방법을 지원할 수 있도록 일반화된 구조의 인덱스로써 유연성과 확장성이 뛰어남
여기서 사용하는 이유는 특정 영역에 겹치는 (시간이 겹치는) 데이터만 분류하기 위해서 사용한다.
└> EXCLUDE USING gist: GiST 인덱스를 사용해서 겹침 금지 제약을 설정.
하지만 Spring JPA에서는 이 둘을 지원하지 않기에 조금 다른 방법을 사용하여 적용하였다.
먼저 엔티티에서는 @Column 어노테이션의 columnDefinition를 활용하여 tstzrange타입임을 명시해주었다.
// JPA에서는 tstzrange 타입을 지원하지 않기 때문에 String으로 처리
@Column(name = "time_range", columnDefinition = "tstzrange")
private String timeRange;
기본적으로 Spring에서는 String 타입으로 처리하되 실제 데이터 타입은 tstzrange가 된다.
이후 Repository에서 조회 및 저장을 할때는 @Query 어노테이션을 사용하여 직접 SQL문을 작성해주었다.
이때, nativeQuery = true 옵션을 붙여 실제 DB에서 사용되는 문법임을 명시했다.
/**
* 예약 시간 범위가 겹치는 모든 예약을 조회합니다.
* PostgreSQL의 '&&' 연산자를 사용하여 time_range 컬럼과 입력된 범위(tstzrange) 간의 겹침 여부를 검사합니다.
*
* @param start 시작 시간
* @param end 종료 시간
* @return 예약 리스트 반환
*/
@Query(value = """
SELECT * FROM reservation r
WHERE r.time_range && tstzrange(:startDate, :endDate)""", nativeQuery = true)
List<Reservation> findByTimeRange(@Param("startDate") ZonedDateTime start,
@Param("endDate") ZonedDateTime end);
/**
* 예약 정보를 저장합니다.
* PostgreSQL의 tstzrange 타입으로 시간 범위를 저장하며, 문자열로 전달된 범위를 CAST하여 삽입합니다.
* 이후 RETUNING * 을 통하여 Reservation 객체를 반환함
*
* @param userId 유저 ID
* @param roomId 회의실 ID
* @param timeRange timeRange 형식의 시간 범위
* @return 예약 정보 반환
*/
@Query(value = """
INSERT INTO reservation (user_id, rooms_id, time_range)
VALUES (:userId, :reservationId, CAST(:timeRange AS tstzrange))
RETURNING *
""", nativeQuery = true)
Reservation saveReservationWithRange(
@Param("userId") Long userId,
@Param("reservationId") Long roomId,
@Param("timeRange") String timeRange
);
여기서 가장 핵심인 중첩 예약 및 시간 겹침 방지 구현을 위하여 마찬가지로 테이블을 생성할 때, 조건을 붙이려고 했으나
JPA에서 해당 조건을 인식하지 못하여서 SQL 파일을 따로 분리하고, 그곳에 테이블 생성 및 제약조건 처리를 하였다.
-- GIST 인덱스 기능을 위해 확장 설치
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- 테이블이 없으면 생성
CREATE TABLE IF NOT EXISTS reservation (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
rooms_id BIGINT NOT NULL,
time_range tstzrange NOT NULL
);
-- JPA에서는 해당 제약 조건을 처리하지 못하기 때문에 SQL문으로 따로 처리
ALTER TABLE reservation
ADD EXCLUDE USING gist (
rooms_id WITH =,
time_range WITH &&
);
- CREATE EXTENSION IF NOT EXISTS btree_gist;
PostgreSQL에서 데이터베이스에서 Gist 인덱스의 기능을 확장하기 위해 사용하였다.
Gist를 B-tree처럼 일반적인 데이터 타입(숫자, 문자열, 날짜 등)에도 사용할 수 있도록 기능을 추가한다.
여기서는 EXCLUDE 제약 조건을 정의하기 위해서 사용되었다.
해당 SQL 파일은 프로젝트가 실행되고 엔티티로 테이블이 생성되기 전에 실행된다.
하지만 reservation 테이블이 아직 생성되지 않았는데 제약 조건을 걸어버리는 문제가 생겨 여기서 테이블을 한번 더 정의하였다.
이후, EXCLUDE USING gist 제약조건을 걸어서 중첩 예약 및 시간 겹침을 방지하였다.
완성된 프로젝트는 아래에서 확인할 수 있다.
대부분의 코드에 주석을 달아놨기에 이해하는데는 큰 무리가 없을것이다.
GitHub - MSP-31/studyroom
Contribute to MSP-31/studyroom development by creating an account on GitHub.
github.com
'프로그래밍 > 개발 일지' 카테고리의 다른 글
| [꿈찾사] SSL 인증서 만료 오류를 해결하다. (0) | 2025.10.01 |
|---|---|
| [multiSnake] #1 구상과 기본 설계 (0) | 2025.09.29 |
| [꿈찾아] 강의 문의 기획 (0) | 2025.09.12 |