BorrowMe는 물건을 대여하고 반납하는 과정을 다룬 Spring Boot API입니다. 2024년에는 첫 백엔드 팀 프로젝트로 알림을 프론트엔드와 연결했고, 2026년에 코드를 다시 열어 요청부터 반납까지의 대여 생명주기를 완성했습니다.
2024년 팀 작업과 2026년 개인 작업은 PROVENANCE.md에서 나눠 설명합니다.
| 궁금한 점 | 바로 확인할 곳 |
|---|---|
| 대여 상태와 역할 규칙 | ReservationServiceImpl |
| 동시 요청에서 재고를 지키는 방법 | ReservationConcurrencyTest |
| 승인·인도·반납 권한과 재시도 | ReservationLifecycleIntegrationTest |
| 기존 DB를 새 상태로 옮기는 방법 | V2__reservation_lifecycle.sql |
BorrowMe는 2024년 GGUM 해커톤을 위해 약 2주간 준비하고, 1박 2일 동안 본 행사를 진행한 11인 팀 프로젝트였습니다. 이 정보와 프론트엔드 연동·팀 시연 경험은 저장소 소유자가 확인한 사실입니다. 기존 팀 저장소는 현재 비공개이므로, 접근할 수 없는 PR을 근거로 제시하지 않습니다.
당시 제가 맡은 범위는 다음과 같습니다.
- 댓글·답글 알림 생성
- 알림 조회, 읽음 처리, 읽은 알림 삭제
NotificationController의 REST API 전환- 프론트엔드 연동과 팀 시연
2024년 예약 기능 전체를 제가 만들었다고 설명하지 않습니다.
기존 코드의 예약은 요청 즉시 재고를 차감하고, 취소하면 복원하는 수준에 머물러 있었습니다. 상품 소유자가 요청을 승인하고 물건을 인도한 뒤 반납을 확인하는 경로가 없었고, 누가 어떤 상태를 바꿘는지도 남지 않았습니다.
예약 버튼 하나가 아니라 실제 대여 과정을 다루기 위해 다음 질문으로 범위를 다시 잡았습니다.
- 대여자와 소유자는 각각 어떤 작업을 할 수 있는가?
- 같은 요청이 반복되어도 재고와 알림이 한 번만 바뀌는가?
- 상품을 숨겨도 진행 중인 대여를 반납할 수 있는가?
REQUESTED -> APPROVED -> BORROWED -> RETURNED
| |
+-> REJECTED +-> CANCELED
+----------------> CANCELED
| 상태 | 의미 | 재고 |
|---|---|---|
REQUESTED |
대여자가 요청함 | 요청 수량 hold |
APPROVED |
소유자가 요청을 승인함 | hold 유지 |
BORROWED |
소유자가 인도를 확인함 | hold 유지 |
RETURNED |
소유자가 반납을 확인함 | 정확히 한 번 복원 |
REJECTED |
소유자가 요청을 거절함 | 정확히 한 번 복원 |
CANCELED |
대여자가 인도 전에 취소함 | 정확히 한 번 복원 |
다이어그램 transcript:
- 시작: 대여자가 요청하면
REQUESTED가 되고 요청 수량만큼 재고를 hold합니다.
- 소유자가 요청을 승인하면
APPROVED가 되고 hold를 유지합니다. - 소유자가 인도를 확인하면
BORROWED가 되고 hold를 유지합니다. - 소유자가 반납을 확인하면
RETURNED가 되고 재고를 한 번 복원합니다. - 소유자가
REQUESTED를 거절하면REJECTED가 되고 재고를 한 번 복원합니다. - 대여자는
REQUESTED또는APPROVED에서 취소할 수 있으며,CANCELED로 전이할 때 재고를 한 번 복원합니다.
종료 action을 다시 요청해도 상태, 재고, 이력, 알림은 다시 바뀌지 않습니다.
| 행위자 | 할 수 있는 작업 |
|---|---|
| 대여자 | 대여 요청, REQUESTED/APPROVED 취소 |
| 상품 소유자 | 승인, 거절, 인도 확인, 반납 확인 |
| 관계없는 사용자 | 목록·상세·상태 변경 모두 차단 |
응답 DTO는 현재 사용자가 실행할 수 있는 allowedActions와 전이 이력을 함께 돌려줍니다.
대여 요청과 상태 변경은 모두 ReservationServiceImpl을 통합니다.
Product또는Reservation을PESSIMISTIC_WRITE잠금으로 조회합니다.- 현재 상태와 행위자 역할을 검사합니다.
- 상태, 재고, transition history, 알림을 같은 transaction에서 저장합니다.
같은 action이 재시도되면 이미 도달한 상태를 반환합니다. transition과 알림에는 예약별 event key unique constraint도 두어 중복 저장을 막습니다.
상품의 전체 재고를 변경할 때는 전체 재고 - 사용 가능 재고를 활성 hold로 계산합니다.
새 전체 재고가 활성 hold보다 작으면 변경을 거절합니다.
DELETE /api/products/{id}는 행을 지우지 않고 archived_at을 기록합니다.
보관된 상품은 목록과 새 대여 요청에서 제외되지만, 기존 대여의 반납과 이력은 남습니다.
POST /api/products/{productId}/reservations
GET /api/reservations?perspective=BORROWER|OWNER
GET /api/reservations/{reservationId}
POST /api/reservations/{reservationId}/actions
상태 변경 요청:
{
"action": "APPROVE | REJECT | MARK_BORROWED | MARK_RETURNED | CANCEL",
"reason": "optional"
}기존 POST /api/products/{id}/reserve는 새 서비스로 위임하는 deprecated 경계로 남겨 두었습니다.
응답의 Deprecation 헤더와 Link 헤더가 새 API를 안내합니다.
| 시나리오 | 검증 내용 |
|---|---|
| 재고 50개, 동시 요청 100건 | 성공 50건, 실패 50건, 최종 재고 0 |
| 요청 -> 승인 -> 인도 -> 반납 | 상태·재고·이력·알림이 함께 바뀐 |
| 권한 밖 접근 | 비관계자 조회와 역할 밖 action 차단 |
| 중복·동시 취소/반납 | 재고, transition, 알림이 한 번만 반영 |
| 전체 재고 수정 | 활성 hold 보존, hold보다 작은 값 거절 |
| 상품 보관 | 새 요청 차단, 진행 중 대여 반납 허용 |
| V1 -> V2 migration | 기존 상태 변환과 최초 transition 생성 |
주요 테스트:
ReservationLifecycleIntegrationTestReservationConcurrencyTestReservationControllerTestFlywayMigrationTest
필요 환경은 Java 17, MySQL, Docker(Testcontainers 실행 시)입니다.
./gradlew bootRun --args='--spring.profiles.active=local'프로덕션 프로필에서는 DB, JWT, AWS, mail 정보를 환경 변수로 주입합니다.
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew testDocker 데몬이 실행 중이어야 MySQL Testcontainers 테스트가 동작합니다.
- Java 17, Spring Boot 3.1.5
- Spring Data JPA, MySQL, Flyway
- Spring Security, JWT
- AWS S3, Spring Mail
- JUnit 5, Testcontainers
docs/ARCHITECTURE.md: 요청·상태 변경·스토리지 경계docs/RESERVATION_CONSISTENCY.md: 잠금, 전이, 재고 불변식docs/MIGRATION_STRATEGY.md: V1 baseline과 V2 생명주기 migrationdocs/TESTING.md: 조회 경로·동시성 테스트 범위docs/RUNBOOK.md: 재고 불일치 조사 절차docs/LIMITATIONS.md: 현재 검증이 답하지 못하는 것
- 2024년 예약 기능 전체를 혼자 구현했다는 주장
- 접근할 수 없는 팀 PR을 공개 근거로 사용하는 일
- 과거 수치와의 성능 개선율
- 운영 환경의 성능, 사용자 수, 장기 가용성
현재 공개 근거는 이 저장소의 코드와 자동 테스트입니다.
