
4주차는 "막막함"이 "할 일 목록"으로 바뀌는 전환점이었다. 3주차까지는 "뭘 해야 하지?"라는 막연함이 있었는데, 이번 주는 Issue를 잘 쓰고, PR을 잘 쓰고, 코드리뷰를 잘 활용하면서 체계적으로 일을 쳐내는 방법을 체득했다.
이번 회고는 크게 세 파트로 나눈다:
1. 기술적 성장: 예약 도메인, 보안, 파일 업로드, Google Calendar 연동
2. 팀장으로서의 인사이트: 협업 방식 개선, Issue/PR 템플릿, 범위 설정
3. 회고와 다음 단계
이번 주 가장 많은 시간을 쏟은 도메인이다. 총 6개 이슈(#57~#62)를 처리하면서 CRUD 전체 사이클을 완성했다.
예약의 상태 전이를 명확히 정의하는 것이 핵심이었다
SCHEDULED → ATTENDED (호스트가 출석 처리)
SCHEDULED → NO_SHOW (호스트가 노쇼 처리)
SCHEDULED → LATE (호스트가 지각 처리)
SCHEDULED → CANCELLED (게스트가 사전 취소)
SCHEDULED → SAME_DAY_CANCEL (게스트가 당일 취소)

여기서 "누가 어떤 상태로 바꿀 수 있는가"를 명확히 하는 것이 중요했다
이 구분이 없으면 게스트가 자기 예약을 "출석 완료"로 바꿔버리는 황당한 상황이 발생한다.

예약 생성 시 @UniqueConstraint(columnNames = {"time_slot_id", "booking_date"})를 걸었는데, 취소된 예약도 차단하는 문제가 발생했다.
문제 상황:
1. A가 1월 20일 10시 예약 → SCHEDULED
2. A가 취소 → CANCELLED
3. B가 1월 20일 10시 예약 시도 → DB constraint violation!
해결: DB 레벨 제약 제거, 애플리케이션 레벨에서 "활성 예약만" 중복 체크
// 취소된 예약은 제외하고 중복 검사
@Query("SELECT COUNT(b) > 0 FROM Booking b " +
"WHERE b.timeSlot = :timeSlot " +
"AND b.bookingDate = :bookingDate " +
"AND b.attendanceStatus NOT IN :excludedStatuses")
boolean existsActiveBooking(...);
배운 점: DB 제약조건은 "절대 위반되면 안 되는 규칙"에만 사용하고, 비즈니스 로직이 복잡하면 애플리케이션 레벨에서 처리하는 게 유연하다.
코드리뷰에서 발견된 Critical 이슈였다.

IDOR 보안 취약점 - 권한 검증 누락이 있다는 내용이었다

특정 엔드포인트에서 인증된 사용자가 리소스 소유자인지 검증하지 않았다 이게 왜 문제냐면...인증된 사용자가 다른 사용자의 예약 정보를 조회 가능 하기 때문이다 (OWASP Top 10 - Broken Access Control 에 소개되어 있다)

@AuthenticationPrincipal로 인증 사용자 받아서 소유권 검증 로직 추가했다.
문제 코드:
@GetMapping("/{bookingId}")
public BookingResponse getBooking(@PathVariable Long bookingId) {
return bookingService.getBookingById(bookingId);
// 인증된 사용자라면 아무 예약이나 조회 가능!
}
해결 코드:
@GetMapping("/{bookingId}")
public BookingResponse getBooking(
@PathVariable Long bookingId,
@AuthenticationPrincipal UserPrincipal user) {
Booking booking = bookingRepository.findById(bookingId)
.orElseThrow(() -> new CustomException(BOOKING_NOT_FOUND));
boolean isGuest = booking.getGuestId().equals(user.getId());
boolean isHost = booking.getTimeSlot().getUserId().equals(user.getId());
if (!isGuest && !isHost) {
throw new CustomException(ACCESS_DENIED);
}
return BookingResponseDTO.from(booking);
}
배운 점: "인증(Authentication)"과 "인가(Authorization)"는 다르다. 로그인했다고 모든 리소스에 접근할 수 있는 게 아니다.

두 사용자가 동시에 같은 슬롯을 예약하면?
Thread A: 중복 체크 → 없음 ✓
Thread B: 중복 체크 → 없음 ✓
Thread A: 저장 ✓
Thread B: 저장 ✓ (중복 예약 발생!)
해결: 비관적 락(Pessimistic Lock) 적용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM TimeSlot t WHERE t.id = :id")
Optional<TimeSlot> findByIdWithLock(@Param("id") Long id);

중복 예약 검사 시 자신을 제외해야 하는 로직이 필요했다.
Before (Query Method):
boolean existsByTimeSlotAndBookingDateAndAttendanceStatusNotInAndIdNot(
TimeSlot timeSlot,
LocalDate bookingDate,
List<AttendanceStatus> excludedStatuses,
Long excludedBookingId
);
// 메서드명이 60자가 넘어감...
메서드명이 60자 넘어가니 코드 리뷰에서 동기식 설명이 필요할 때마다 숨이 차올랐다.
내가 무슨 독일어 공부를 하는 것도 아니고 다른 방식이 있나 고민하게 되었다.

After (@Query):
@Query("SELECT COUNT(b) > 0 FROM Booking b " +
"WHERE b.timeSlot = :timeSlot " +
"AND b.bookingDate = :bookingDate " +
"AND b.attendanceStatus NOT IN :excludedStatuses " +
"AND b.id <> :excludedId")
boolean existsDuplicateBookingExcludingSelf(...);
판단 기준:
Spring Data JPA 공식 문서에서도 "메서드명이 길어지면 @Query를 쓰라"고 권장한다.
"The mechanism to create queries from method names is handy, but it can lead to very long method names. In these cases, you can use the @Query annotation."
고려했지만 선택하지 않은 대안
Querydsl: 동적 쿼리가 많거나 복잡한 검색 조건이 필요할 때 도입하는 라이브러리. 현재 상황(메서드명이 길다)에서는 오버엔지니어링. @Query로 5분이면 해결되는 문제에 새 라이브러리 도입은 불필요.
예약에 파일을 첨부할 수 있는 기능을 구현했다.
핵심 설계:
1. FileStorageService 인터페이스 분리 (추후 S3 전환 대비)
2. LocalFileStorageService 구현 (현재는 로컬 디스크)
3. DB 저장 실패 시 디스크 파일 롤백
롤백 로직:
public BookingFileResponse uploadFile(Long bookingId, MultipartFile file, UUID userId) {
String storedFileName = fileStorageService.store(file);
try {
BookingFile bookingFile = BookingFile.builder()
.booking(booking)
.fileName(storedFileName)
.build();
bookingFileRepository.save(bookingFile);
return BookingFileResponse.from(bookingFile);
} catch (Exception e) {
fileStorageService.delete(storedFileName); // 롤백!
throw e;
}
}
예약 생성 시 Google Calendar에 자동으로 이벤트를 생성하는 기능을 구현했다.
Service Account 방식 선택:
비동기 처리 고려사항:
@Async 적용 예정백엔드를 FastAPI에서 Spring Boot로 전환하면서 프론트엔드 API 호출을 수정했다.
변경 포인트:
| 항목 | Before (Python) | After (Spring Boot) |
|------|-----------------|---------------------|
| Base URL | localhost:8000 | localhost:8080/api |
| 로그인 | /account/login | /members/v1/login |
| 회원가입 | /account/signup | /members/v1/signup |
| 필드명 | snake_case | camelCase |
11개 파일의 fallback URL을 수정하고, .env 파일로 환경변수를 분리했다.
4주차는 양적 성장과 질적 성장이 동시에 일어난 주였다.
10개 이슈를 처리하면서
기술적으로는 성장했지만, 속도에 취한 건 아닌지 경계해야 한다.
이번 주 코드리뷰에서 발견된 보안 이슈가 증명하듯, 빠르게 쳐내는 것보다 제대로 쳐내는 것이 더 중요할 것 같다..
| Issue number | 제목 | 도메인 |
|---|---|---|
| [[57]] | 예약 생성 | Booking |
| [[58]] | 예약 조회 API | Booking |
| [[59]] | 호스트 예약 수정 | Booking |
| [[60]] | 게스트 예약 수정 | Booking |
| [[61]] | 예약 상태 변경/취소 | Booking |
| [[62]] | 예약 파일 업로드 | Booking/File |
| [[63]] | Google Calendar 연동 | Calendar |
| [[76]] | FE 환경 변수 설정 | Frontend |
| [[78]] | 로그인 API 마이그레이션 | Frontend |
| [[79]] | 회원가입 API 마이그레이션 | Frontend |