1. 프로젝트 개요와 요구사항 분석
실시간 강의실 예약 시스템을 개발했다. 학생과 관리자가 강의실을 예약하고 관리할 수 있는 웹 애플리케이션으로, Spring Boot 3.5.7, JWT 인증, H2 Database를 기반으로 구축했다. 단순한 CRUD를 넘어서 예약 충돌 검증, 그룹 예약, 대기열 자동 할당, 실시간 업데이트 등의 복잡한 비즈니스 로직이 핵심이다.
요구사항을 크게 인증, 예약 로직, 검색 및 필터링, 실시간 현황, 대기열 시스템, 통계로 나눴다. 각 기능은 서로 독립적이면서도 강하게 결합되어 있어, 도메인별로 패키지를 분리하고 의존성을 명확히 해야 했다.
// Backend
- Spring Boot 3.5.7
- Spring Security (JWT 기반 인증)
- Spring Data JPA
- H2 Database
- Lombok
// Frontend
- Thymeleaf
- Vanilla JavaScript
- Fetch API
1-1. 인증 시스템 설계
학생(Student)과 관리자(Admin) 역할을 구분해야 했고, JWT를 사용해 세션 없이 인증을 처리했다. UserStatus enum으로 역할을 관리하고, SecurityConfig에서 역할별 접근 권한을 제어한다.
public enum UserStatus {
ADMIN,
STUDENT
}
@Entity
@Table(name = "Users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "ID", length = 9, unique = true)
private String loginId;
@Enumerated(EnumType.STRING)
@Column(name = "role", length = 10)
private UserStatus role;
// password, email, name 등...
}
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
public String generateToken(String loginId, String role) {
return Jwts.builder()
.subject(loginId)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public boolean validateToken(String token) {
try {
Claims claims = getClaimsFromToken(token);
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
프론트엔드에서는 로그인 성공 시 토큰을 localStorage에 저장하고, 이후 모든 API 요청에 Authorization: Bearer {token} 헤더를 포함시킨다. JwtAuthenticationFilter가 요청을 가로채 토큰을 검증하고 SecurityContext에 인증 정보를 설정한다.
1-2. 예약 정책 요구사항 정리
예약 로직에서 가장 복잡했던 부분은 시간 충돌 검증과 예약 정책 검증이었다. 요구사항을 정리하면 다음과 같다:
- 과거 시간은 예약 불가
- 1시간 단위로만 예약 가능 (14:00~15:00, 15:00~16:00 등)
- 최대 7일 후까지만 예약 가능 (오늘+6일)
- 1인당 활성화된 미래 예약은 최대 3개
- 같은 강의실 같은 시간대 중복 예약 불가
- 그룹 예약 시 모든 참여자의 예약 개수 제한 체크
이런 정책들은 서비스 레이어에서 모두 검증해야 하고, 어떤 정책을 어느 순서로 체크할지가 성능과 사용자 경험에 영향을 준다.
2. ERD 설계와 데이터베이스 스키마
5개의 핵심 테이블을 설계했다: Users, Classrooms, Reservation, Reserve_User, Wait_List. 각 테이블은 명확한 책임을 가지고 있고, 외래키 제약조건과 인덱스를 통해 데이터 무결성과 조회 성능을 보장한다.
-- 1. Users: 사용자 정보 및 역할
CREATE TABLE Users (
user_id BIGINT AUTO_INCREMENT PRIMARY KEY,
ID CHAR(9) NOT NULL UNIQUE, -- 학번/관리자 ID
password VARCHAR(100) NOT NULL,
email VARCHAR(50) NOT NULL,
name VARCHAR(15) NOT NULL,
role VARCHAR(10) NOT NULL DEFAULT 'STUDENT',
reservation_num INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 2. Classrooms: 강의실 정보 및 비품
CREATE TABLE Classrooms (
room_id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(15) NOT NULL,
location VARCHAR(50) NOT NULL,
capacity INT NOT NULL,
has_whiteboard BOOLEAN DEFAULT FALSE,
has_projector BOOLEAN DEFAULT FALSE,
reserve_count INT DEFAULT 0,
available_date DATE NOT NULL
);
-- 3. Reservation: 예약 정보
CREATE TABLE Reservation (
reservation_id BIGINT AUTO_INCREMENT PRIMARY KEY,
reservation_started_at DATETIME NOT NULL,
reservation_ended_at DATETIME NOT NULL,
room_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(20) DEFAULT 'ACTIVE',
-- 외래키 제약조건
FOREIGN KEY (room_id) REFERENCES Classrooms(room_id),
FOREIGN KEY (user_id) REFERENCES Users(user_id),
CHECK (reservation_started_at < reservation_ended_at)
);
-- 4. Reserve_User: 그룹 예약 참여자
CREATE TABLE Reserve_User (
reservation_participant_id BIGINT AUTO_INCREMENT PRIMARY KEY,
student_id BIGINT NOT NULL,
reservation_id BIGINT NOT NULL,
FOREIGN KEY (student_id) REFERENCES Users(user_id),
FOREIGN KEY (reservation_id) REFERENCES Reservation(reservation_id),
UNIQUE (student_id, reservation_id) -- 중복 참여 방지
);
-- 5. Wait_List: 예약 대기열
CREATE TABLE Wait_List (
waitlist_id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
room_id BIGINT NOT NULL,
reservation_started_at DATETIME NOT NULL,
reservation_ended_at DATETIME NOT NULL,
queue_position BIGINT DEFAULT 1,
status VARCHAR(20) DEFAULT 'WAITING',
reserved_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES Users(user_id),
FOREIGN KEY (room_id) REFERENCES Classrooms(room_id),
UNIQUE (user_id, room_id, reservation_started_at, reservation_ended_at)
);
Reserve_User 테이블은 그룹 예약의 핵심이다. 한 예약에 여러 명이 참여할 수 있고, 참여자 각자의 예약 현황에도 해당 예약이 표시되어야 한다. 이 테이블 덕분에 대표 예약자와 참여자를 분리해 관리할 수 있다.
-- 예약 조회 성능 향상
CREATE INDEX idx_reservation_room_time
ON Reservation (room_id, reservation_started_at, reservation_ended_at);
CREATE INDEX idx_reservation_user
ON Reservation (user_id);
CREATE INDEX idx_reservation_status
ON Reservation (status);
-- 대기열 조회 최적화
CREATE INDEX idx_waitlist_room_time
ON Wait_List (room_id, reservation_started_at, reservation_ended_at);
CREATE INDEX idx_waitlist_queue
ON Wait_List (room_id, reservation_started_at, reservation_ended_at, queue_position);
예약 조회는 주로 "특정 강의실의 특정 날짜 예약 목록"과 "특정 사용자의 예약 목록"으로 이루어진다. 따라서 room_id와 reservation_started_at 조합 인덱스가 핵심이다.
3. 도메인별 구현 전략
도메인별로 패키지를 분리했다: user, classroom, reservation, waitlist, admin. 각 패키지 안에는 domain, repository, service, controller, dto가 있다. 이렇게 하면 기능 추가나 수정 시 영향 범위가 명확해진다.
3-1. User 도메인: 인증과 역할 관리
User 도메인은 회원가입, 로그인, 사용자 검색 기능을 담당한다. CustomUserDetailsService에서 Spring Security와 연동하고, BCryptPasswordEncoder로 비밀번호를 해시화한다.
@Entity
@Table(name = "Users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "ID", length = 9, unique = true)
private String loginId;
@Enumerated(EnumType.STRING)
@Column(name = "role")
private UserStatus role;
@Column(name = "reservation_num")
private Integer reservationNum;
@PrePersist
protected void onCreate() {
if (role == null) {
role = UserStatus.STUDENT; // 기본값: 학생
}
if (reservationNum == null) {
reservationNum = 0;
}
}
}
3-2. Classroom 도메인: 강의실 관리와 검색
관리자는 강의실을 생성·수정·삭제하고, 사용자는 빈 강의실을 검색할 수 있다. 검색 시 수용인원, 프로젝터 유무, 화이트보드 유무로 필터링할 수 있다.
public List searchAvailableClassrooms(ClassroomSearchRequest request) {
LocalDate date = LocalDate.parse(request.getDate());
LocalDateTime startDateTime = date.atTime(request.getStartHour(), 0);
LocalDateTime endDateTime = date.atTime(request.getEndHour(), 0);
return classroomRepository.findAll().stream()
.filter(classroom -> {
// 1. 날짜 필터링
if (classroom.getAvailableDate().isAfter(date)) {
return false;
}
// 2. 수용인원 필터링
if (request.getMinCapacity() != null &&
classroom.getCapacity() < request.getMinCapacity()) {
return false;
}
// 3. 프로젝터 필터링
if (request.getHasProjector() != null &&
request.getHasProjector() && !classroom.isHasProjector()) {
return false;
}
// 4. 화이트보드 필터링
if (request.getHasWhiteboard() != null &&
request.getHasWhiteboard() && !classroom.isHasWhiteboard()) {
return false;
}
return true;
})
.map(classroom -> {
// 해당 시간대에 예약이 있는지 확인
List reservations = reservationRepository
.findByClassroomIdAndDate(classroom.getId(), dateStart, dateEnd);
boolean isAvailable = reservations.stream()
.noneMatch(r -> {
LocalDateTime rStart = r.getReservationStartedAt();
LocalDateTime rEnd = r.getReservationEndedAt();
return (startDateTime.isBefore(rEnd) && endDateTime.isAfter(rStart)) &&
"ACTIVE".equals(r.getStatus());
});
return ClassroomSearchResult.builder()
.id(classroom.getId())
.name(classroom.getName())
.isAvailable(isAvailable)
.build();
})
.collect(Collectors.toList());
}
검색 로직은 스트림 API를 활용해 필터링과 매핑을 깔끔하게 처리한다. 하지만 모든 강의실을 메모리에 올려 필터링하므로, 강의실이 많아지면 JPA Specification이나 QueryDSL을 고려해야 한다.
3-3. Reservation 도메인: 예약 생성과 검증
Reservation 도메인이 가장 복잡하다. 예약 생성 시 시간 충돌, 정원 초과, 예약 개수 제한, 과거 시간 예약 불가, 7일 제한 등을 모두 검증해야 한다.
@Transactional
public Long createReservation(ReservationRequest request) {
Classroom classroom = classroomService.findClassroom(request.getRoomId());
User user = userRepository.findByLoginId(request.getRepresentativeId())
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
LocalDate date = LocalDate.parse(request.getDate());
LocalDateTime startDateTime = date.atTime(request.getStartHour(), 0);
LocalDateTime endDateTime = date.atTime(request.getEndHour(), 0);
LocalDateTime now = LocalDateTime.now();
// 1. 강의실 정원 체크
int totalParticipants = 1 + (request.getMembers() != null ?
request.getMembers().size() : 0);
if (totalParticipants > classroom.getCapacity()) {
throw new IllegalArgumentException("강의실 정원을 초과했습니다.");
}
// 2. 대표 예약자의 예약 개수 제한 (최대 3개)
List representativeReservations =
reservationRepository.findActiveFutureReservationsByUserId(user.getUserId(), now);
if (representativeReservations.size() >= 3) {
throw new IllegalArgumentException("활성화된 예약이 3개 이상입니다.");
}
// 3. 그룹 멤버들의 예약 개수 제한 체크
if (request.getMembers() != null && !request.getMembers().isEmpty()) {
for (ReservationRequest.Member member : request.getMembers()) {
User memberUser = userRepository.findByLoginId(member.getStudentId())
.orElseThrow(() -> new IllegalArgumentException("참여자를 찾을 수 없습니다."));
List memberReservations =
reservationRepository.findActiveFutureReservationsByUserId(
memberUser.getUserId(), now);
if (memberReservations.size() >= 3) {
throw new IllegalArgumentException(
"참여자 '" + member.getStudentName() + "'의 예약이 3개 이상입니다.");
}
}
}
// 4. 시간 충돌 검증
LocalDateTime dateStart = date.atStartOfDay();
LocalDateTime dateEnd = date.plusDays(1).atStartOfDay();
List existingReservations =
reservationRepository.findByClassroomIdAndDate(
classroom.getId(), dateStart, dateEnd);
boolean isOverlapping = existingReservations.stream()
.anyMatch(r -> {
LocalDateTime rStart = r.getReservationStartedAt();
LocalDateTime rEnd = r.getReservationEndedAt();
return (startDateTime.isBefore(rEnd) && endDateTime.isAfter(rStart)) &&
"ACTIVE".equals(r.getStatus());
});
if (isOverlapping) {
throw new IllegalArgumentException("해당 시간대에 이미 예약이 있습니다.");
}
// 5. 예약 생성
Reservation reservation = Reservation.of(
startDateTime, endDateTime, classroom, user);
return reservationRepository.save(reservation).getId();
}
시간 충돌 검증 로직에서 주의할 점은 겹치는 시간대를 판단하는 방식이다. startDateTime.isBefore(rEnd) && endDateTime.isAfter(rStart) 조건으로 두 시간 구간이 겹치는지 확인한다. 예를 들어 14:00~16:00 예약이 있을 때, 15:00~17:00 요청은 겹치므로 실패해야 한다.
// 기존 예약: 14:00 ~ 16:00
// 신청 예약: 15:00 ~ 17:00
//
// startDateTime.isBefore(rEnd) → 15:00 < 16:00? → true
// endDateTime.isAfter(rStart) → 17:00 > 14:00? → true
// → 겹침! 예약 실패
// 기존 예약: 14:00 ~ 16:00
// 신청 예약: 16:00 ~ 18:00
//
// startDateTime.isBefore(rEnd) → 16:00 < 16:00? → false
// → 겹치지 않음. 예약 성공
3-4. Waitlist 도메인: 대기열 자동 할당
대기열 시스템은 예약이 꽉 찬 시간대에 사용자가 대기 신청을 하고, 예약이 취소되면 자동으로 다음 순위에게 예약을 할당한다. queue_position으로 순위를 관리하고, 예약 취소 시 processWaitlistOnReservationCancel 메서드가 호출된다.
@Transactional
public void processWaitlistOnReservationCancel(Long roomId,
LocalDateTime startDateTime,
LocalDateTime endDateTime) {
LocalDate date = startDateTime.toLocalDate();
LocalDateTime dateStart = date.atStartOfDay();
LocalDateTime dateEnd = date.plusDays(1).atStartOfDay();
// 해당 시간대의 대기 목록 조회 (순위 순으로)
List waitlists = waitlistRepository
.findByRoomIdAndDateRange(roomId, dateStart, dateEnd)
.stream()
.filter(w -> "WAITING".equals(w.getStatus()))
.filter(w -> {
// 시간대가 겹치는지 확인
return (startDateTime.isBefore(w.getReservationEndedAt()) &&
endDateTime.isAfter(w.getReservationStartedAt()));
})
.sorted((a, b) -> Long.compare(a.getQueuePosition(), b.getQueuePosition()))
.collect(Collectors.toList());
if (waitlists.isEmpty()) {
return; // 대기 목록이 없으면 종료
}
// 대기 1순위부터 순차적으로 처리
for (Waitlist waitlist : waitlists) {
User waitlistUser = waitlist.getUser();
// 대기 신청자의 활성화된 미래 예약 개수 확인
LocalDateTime now = LocalDateTime.now();
List userReservations =
reservationRepository.findActiveFutureReservationsByUserId(
waitlistUser.getUserId(), now);
// 예약이 3개 미만인 경우에만 예약 할당
if (userReservations.size() < 3) {
// 예약 생성
Reservation newReservation = Reservation.of(
waitlist.getReservationStartedAt(),
waitlist.getReservationEndedAt(),
waitlist.getRoom(),
waitlistUser);
reservationRepository.save(newReservation);
// 대기 신청을 APPROVED로 변경
waitlist.approve();
waitlistRepository.save(waitlist);
return; // 성공적으로 할당했으므로 종료
} else {
// 예약이 3개 이상이면 해당 대기 신청 삭제하고 다음 순위 확인
waitlist.cancel();
waitlistRepository.save(waitlist);
// 다음 순위로 계속 진행
}
}
}
대기 1순위 사용자가 이미 예약 3개를 가지고 있다면, 해당 대기 신청은 자동으로 취소되고 다음 순위로 넘어간다. 이렇게 하면 정책 위반 없이 자동으로 다음 사용자에게 기회가 주어진다.
3-5. Repository 레이어: 커스텀 쿼리
Spring Data JPA의 메서드 네이밍만으로는 복잡한 조회가 어려워 @Query 어노테이션을 활용했다. 특히 날짜 범위 조회와 활성화된 미래 예약 조회는 자주 사용되므로 Repository 메서드로 추출했다.
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
// 특정 강의실과 날짜의 예약 조회
@Query("SELECT r FROM Reservation r WHERE r.room.id = :roomId " +
"AND r.reservationStartedAt >= :startDate " +
"AND r.reservationStartedAt < :endDate")
List findByClassroomIdAndDate(
@Param("roomId") Long roomId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate);
// 특정 사용자의 활성화된 미래 예약 조회
@Query("SELECT r FROM Reservation r WHERE r.user.userId = :userId " +
"AND r.reservationStartedAt >= :now " +
"AND r.status = 'ACTIVE'")
List findActiveFutureReservationsByUserId(
@Param("userId") Long userId,
@Param("now") LocalDateTime now);
// 특정 사용자의 모든 예약 조회 (최신순)
@Query("SELECT r FROM Reservation r WHERE r.user.userId = :userId " +
"ORDER BY r.reservationStartedAt DESC")
List findAllByUserId(@Param("userId") Long userId);
}
4. 핵심 비즈니스 로직 구현
단순한 CRUD를 넘어서 복잡한 비즈니스 규칙을 구현해야 했다. 특히 예약 정책 검증, 그룹 예약 처리, 예약 취소 시 대기열 자동 할당이 핵심이다.
4-1. 예약 정책 검증: 다층 검증 로직
예약 생성 시 검증 순서는 사용자 경험과 성능에 영향을 준다. 빠르게 실패할 수 있는 검증을 먼저 수행하는 것이 좋다. 예를 들어, 정원 초과는 데이터베이스 조회 없이 바로 체크할 수 있으므로 먼저 검증한다.
// 1. 정원 체크 (빠른 검증, DB 조회 불필요)
if (totalParticipants > classroom.getCapacity()) {
throw new IllegalArgumentException("정원 초과");
}
// 2. 대표 예약자 예약 개수 제한 (DB 조회 1회)
List representativeReservations =
reservationRepository.findActiveFutureReservationsByUserId(...);
if (representativeReservations.size() >= 3) {
throw new IllegalArgumentException("예약 개수 초과");
}
// 3. 그룹 멤버 예약 개수 제한 (멤버 수만큼 DB 조회)
// 멤버가 많을수록 비용이 커지므로, 멤버 추가 전에 대표 예약자 검증을 먼저 수행
// 4. 시간 충돌 검증 (가장 비용이 큰 검증)
// DB 조회 후 스트림으로 시간 겹침 판단
List existingReservations =
reservationRepository.findByClassroomIdAndDate(...);
boolean isOverlapping = existingReservations.stream()...
실제로는 시간 충돌 검증이 가장 비용이 크다. 특정 날짜의 모든 예약을 조회하고 메모리에서 시간 겹침을 판단하기 때문이다. 대규모 시스템이라면 BETWEEN 쿼리나 인덱스를 더 세밀하게 튜닝해야 한다.
4-2. 그룹 예약: 참여자 관리
그룹 예약에서는 대표 예약자가 예약을 생성하고, 참여자는 Reserve_User 테이블에 등록된다. 참여자의 예약 개수 제한도 함께 검증해야 하므로, 각 멤버마다 활성화된 예약을 조회해야 한다.
// 그룹 멤버들의 활성화된 미래 예약 개수 제한 체크
if (request.getMembers() != null && !request.getMembers().isEmpty()) {
for (ReservationRequest.Member member : request.getMembers()) {
User memberUser = userRepository.findByLoginId(member.getStudentId())
.orElseThrow(() -> new IllegalArgumentException(
"참여자 '" + member.getStudentName() +
"(" + member.getStudentId() + ")'를 찾을 수 없습니다."));
List memberReservations =
reservationRepository.findActiveFutureReservationsByUserId(
memberUser.getUserId(), now);
if (memberReservations.size() >= 3) {
throw new IllegalArgumentException(
"참여자 '" + member.getStudentName() +
"(" + member.getStudentId() + ")'의 활성화된 예약이 3개 이상입니다.");
}
}
}
현재 구현에서는 각 멤버마다 개별적으로 조회하지만, 성능 최적화를 위해 IN 절을 사용한 일괄 조회나 배치 조회로 개선할 수 있다. 다만 현재 규모에서는 N+1 문제가 크지 않으므로 우선 구현했고, 필요 시 리팩터링을 진행할 수 있다.
4-3. 예약 취소와 대기열 연동
예약 취소 시 대기열의 첫 번째 사용자에게 자동으로 예약을 할당한다. 이 로직은 ReservationService.cancelReservation 메서드에서 waitlistService.processWaitlistOnReservationCancel을 호출해 처리한다.
@Transactional
public void cancelReservation(Long reservationId, Long userId) {
Reservation reservation = reservationRepository.findById(reservationId)
.orElseThrow(() -> new IllegalArgumentException("예약을 찾을 수 없습니다."));
// 본인의 예약인지 확인
if (!reservation.getUser().getUserId().equals(userId)) {
throw new IllegalArgumentException("본인의 예약만 취소할 수 있습니다.");
}
// 이미 취소된 예약인지 확인
if ("CANCELLED".equals(reservation.getStatus())) {
throw new IllegalArgumentException("이미 취소된 예약입니다.");
}
// 예약 시작 10분 전까지만 취소 가능
LocalDateTime now = LocalDateTime.now();
LocalDateTime reservationStart = reservation.getReservationStartedAt();
if (now.isAfter(reservationStart.minusMinutes(10))) {
throw new IllegalArgumentException("예약 시작 10분 전까지만 취소 가능합니다.");
}
// 예약 상태를 CANCELLED로 변경
reservation.cancel();
reservationRepository.save(reservation);
// 예약 취소 시 대기 목록에서 자동 할당 처리
waitlistService.processWaitlistOnReservationCancel(
reservation.getRoom().getId(),
reservation.getReservationStartedAt(),
reservation.getReservationEndedAt()
);
}
대기열 자동 할당은 @Transactional 내에서 수행되므로, 예약 취소와 대기열 처리 중 하나라도 실패하면 전체가 롤백된다. 이렇게 하면 데이터 일관성이 보장된다.
4-4. 관리자 통계: 인기 강의실 Top 5
관리자는 인기 강의실 통계를 확인할 수 있다. 각 강의실별 예약 수를 집계해 상위 5개를 반환한다.
private List getPopularClassrooms() {
List classrooms = classroomRepository.findAll();
return classrooms.stream()
.map(classroom -> {
Long reservationCount =
reservationRepository.countByRoomId(classroom.getId());
return AdminStatsDTO.PopularClassroomDTO.builder()
.roomId(classroom.getId())
.roomName(classroom.getName())
.reservationCount(reservationCount)
.build();
})
.sorted((a, b) -> Long.compare(
b.getReservationCount(), a.getReservationCount()))
.limit(5)
.collect(Collectors.toList());
}
현재 구현은 모든 강의실을 조회한 뒤 메모리에서 정렬하지만, 강의실이 많아지면 GROUP BY와 ORDER BY를 활용한 네이티브 쿼리로 개선할 수 있다.
5. 브랜치 전략, 배포, 그리고 마무리
프로젝트를 진행하면서 Git 브랜치 전략을 활용해 기능별로 분리해 개발했다. main 브랜치를 기준으로 dev 브랜치에서 통합 개발을 진행하고, 기능별로 feature/* 브랜치를 생성했다.
5-1. Git 브랜치 전략
사용한 브랜치들을 정리하면 다음과 같다:
feature/auth: JWT 인증 기능 구현feature/DB: 데이터베이스 스키마 설계 및 초기화feature/classroom-crud: 강의실 CRUD 기능feature/reserve: 예약 생성 기능feature/reservation_detail: 예약 상세 페이지feature/search: 빈 강의실 검색 및 필터링feature/waitlistagain: 대기열 시스템 재구현feature/mypage: 마이페이지feature/admin: 관리자 기능 (통계, 사용자 관리)fix/bug,fix/bug2,fix/bug3,fix/bug4,fix/bug5: 버그 수정
각 기능 브랜치에서 개발을 완료한 후 dev 브랜치로 병합하고, 충돌이나 버그를 해결한 뒤 main으로 병합했다. 이렇게 하면 한 기능의 문제가 다른 기능에 영향을 주지 않고 독립적으로 개발할 수 있다.
5-2. Spring Security 설정과 의존성 주입
Spring Security 설정에서 의존성 주입(DI)을 적극 활용했다. SecurityConfig에서 CustomUserDetailsService와 JwtAuthenticationFilter를 생성자 주입으로 받아 사용한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor // Lombok이 생성자 자동 생성
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService); // 주입받은 서비스 사용
authProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(authProvider);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// ... 나머지 설정
return http.build();
}
}
이렇게 하면 SecurityConfig는 구체 구현을 모르고 CustomUserDetailsService와 JwtAuthenticationFilter 인터페이스(또는 클래스)에만 의존한다. 나중에 인증 방식을 바꾸거나 테스트용 더블을 주입하기 쉬워진다.
5-3. 배포 환경 구성
개발 환경에서는 H2 Database를 파일 기반으로 사용했고, 배포 환경에서도 동일하게 구성했다. application.properties에서 데이터베이스 연결 정보를 관리한다.
spring.application.name=adminContest
spring.datasource.url=jdbc:h2:file:./data/reservation_db
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.sql.init.mode=never
spring.sql.init.schema-locations=classpath:schema.sql
# JWT
jwt.secret=your-secret-key-must-be-at-least-256-bits-long-for-HS256-algorithm-change-this-in-production
jwt.expiration=86400000
배포 시에는 schema.sql을 통해 데이터베이스 스키마를 초기화하고, 환경 변수로 JWT 시크릿 키를 관리하는 것이 보안상 좋다.
5-4. 프로젝트 아키텍처 요약
전체 아키텍처를 레이어별로 정리하면 다음과 같다:
Controller Layer (API 엔드포인트)
↓
Service Layer (비즈니스 로직)
↓
Repository Layer (데이터 접근)
↓
Domain Layer (엔티티)
↓
Database (H2)
각 레이어는 명확한 책임을 가지고 있고, 상위 레이어는 하위 레이어에만 의존한다. Service 레이어는 Repository에 의존하고, Controller는 Service에 의존한다. 이렇게 하면 테스트와 리팩터링이 쉬워진다.
5-5. 개선 가능한 부분과 향후 계획
현재 구현에서 개선할 수 있는 부분들을 정리했다:
- 실시간 업데이트: WebSocket이나 Server-Sent Events(SSE)를 활용해 예약 변경 시 클라이언트에 즉시 반영
- 예약 알림 시스템: 예약 시작 30분 전 알림 기능을 실제 푸시 알림이나 이메일로 확장
- 성능 최적화: 강의실 검색 시 JPA Specification이나 QueryDSL로 쿼리 최적화
- 그룹 예약 최적화: 참여자 예약 개수 조회 시 배치 조회로 N+1 문제 해결
- 테스트 코드: 단위 테스트와 통합 테스트 추가로 코드 안정성 향상
특히 실시간 업데이트는 요구사항에 있었지만, 현재는 프론트엔드에서 주기적으로 폴링하거나 사용자가 새로고침해야 한다. WebSocket을 도입하면 예약이 생성되거나 취소되는 순간 모든 클라이언트에 자동으로 반영할 수 있다.
5-6. 마무리와 배운 점
이 프로젝트를 통해 도메인별 패키지 분리, 복잡한 비즈니스 로직 구현, 트랜잭션 관리, Git 브랜치 전략 등을 경험했다. 특히 예약 시스템의 시간 충돌 검증과 대기열 자동 할당 로직은 실제 서비스에서도 자주 사용되는 패턴이다.
초기 설계 단계에서 ERD를 먼저 그린 것이 큰 도움이 됐다. 데이터베이스 스키마가 명확하면 엔티티 관계도 명확해지고, 비즈니스 로직 구현도 수월해진다. 또한 브랜치 전략을 통해 기능별로 독립적으로 개발하면서도 안정적으로 통합할 수 있었다.
정리: 실시간 강의실 예약 시스템은 단순한 CRUD를 넘어서 복잡한 비즈니스 규칙을 구현해야 하는 프로젝트였다. 도메인별 패키지 분리, 레이어드 아키텍처, 트랜잭션 관리, Git 브랜치 전략을 통해 안정적이고 유지보수하기 쉬운 코드를 작성할 수 있었다. 특히 예약 정책 검증, 그룹 예약 처리, 대기열 자동 할당 로직은 실제 서비스에서도 활용 가능한 패턴이다.
'백엔드' 카테고리의 다른 글
| [Spring Security] Basic Authentication (0) | 2025.10.23 |
|---|---|
| [Spring Boot] DI(Dependency Injection) (2) | 2025.10.13 |
| [Spring Boot] Spring IoC - 컨테이너, Bean 등록/스코프/생명주기 (0) | 2025.10.11 |
| [Spring Boot] Spring IoC - 개념 (0) | 2025.10.11 |
| [SpringBoot] Bean이란? (0) | 2025.10.11 |