Skip to content

Commit b4c75cd

Browse files
authored
Merge pull request #113 from billilge/feat/#112
feat: 파일 스토리지 AWS S3에서 minio로 마이그레이션
2 parents 4693f17 + f835dae commit b4c75cd

11 files changed

Lines changed: 314 additions & 138 deletions

File tree

CLAUDE.md

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Billilge Backend
2+
3+
대학교 학생회 물품 대여 관리 시스템 백엔드
4+
5+
## 기술 스택
6+
7+
- **Spring Boot 3.4.1** / **Kotlin 1.9.25** / **JDK 21**
8+
- **JPA + QueryDSL** (MySQL)
9+
- **Spring Security** (JWT + Google OAuth2)
10+
- **Firebase Cloud Messaging** (푸시 알림)
11+
- **AWS S3** (이미지 업로드)
12+
- **Apache POI** (Excel 생성)
13+
14+
## 빌드 & 실행
15+
16+
```bash
17+
./gradlew compileKotlin # 컴파일 확인
18+
./gradlew build # 전체 빌드
19+
./gradlew bootRun # 실행
20+
```
21+
22+
## 아키텍처
23+
24+
```
25+
Controller → Facade → Service → Repository
26+
```
27+
28+
| 레이어 | 역할 | DTO 참조 |
29+
|--------|------|----------|
30+
| **Controller** | HTTP 요청/응답, `@AuthenticationPrincipal`로 인증 정보 추출 | Request/Response DTO |
31+
| **Facade** | Request DTO 분해, 크로스 도메인 조합, Response DTO 생성 | Request/Response DTO |
32+
| **Service** | 비즈니스 로직, 자기 도메인 Repository만 사용 | Entity/primitives만 |
33+
| **Repository** | 데이터 접근 (JPA + QueryDSL) | Entity/Query DTO만 |
34+
35+
### 핵심 규칙
36+
37+
- **Service는 Request/Response DTO를 참조하지 않는다** — Entity, primitives, 글로벌 DTO(`PageableCondition`, `SearchCondition`)만 사용
38+
- **Service는 타 도메인 Repository를 직접 의존하지 않는다** — 타 도메인 Service를 통해 접근 (예외: `PayerService → MemberRepository` 순환 의존 방지)
39+
- **크로스 도메인 조합은 Facade에서 수행한다** — Facade가 여러 Service를 호출해 엔티티를 조합 후 Service에 전달
40+
- **Facade에서 트랜잭션 필요 시 `@Transactional` 명시** — 여러 서비스 호출을 하나의 persistence context로 묶어야 할 때
41+
42+
### 트랜잭션 패턴
43+
44+
- Service 클래스에 `@Transactional(readOnly = true)` 기본 적용
45+
- 쓰기 메서드만 `@Transactional`로 오버라이드
46+
- JPA dirty checking 활용 — `repository.save()` 없이 엔티티 필드 변경으로 자동 반영
47+
48+
## 도메인 구조
49+
50+
```
51+
domain/
52+
├── item/ # 물품 관리
53+
├── member/ # 회원, 인증
54+
├── notification/ # 알림 (FCM 푸시)
55+
├── payer/ # 회비 납부자 관리
56+
└── rental/ # 대여/반납 관리
57+
```
58+
59+
각 도메인 패키지 구조:
60+
```
61+
domain/{name}/
62+
├── controller/ # API 컨트롤러 + Api 인터페이스 (Swagger)
63+
├── facade/ # Facade (DTO 변환, 크로스 도메인 조합)
64+
├── service/ # 비즈니스 로직
65+
├── repository/ # JPA Repository + Custom(QueryDSL)
66+
├── entity/ # JPA Entity
67+
├── dto/ # request/, response/
68+
├── enums/ # 도메인 열거형
69+
└── exception/ # 도메인 에러 코드
70+
```
71+
72+
## 서비스 의존성
73+
74+
```
75+
ItemService → ItemRepository, S3Service
76+
MemberService → MemberRepository, TokenProvider, PayerService
77+
NotificationService → NotificationRepository, FCMService, MemberService
78+
PayerService → PayerRepository, MemberRepository, ExcelGenerator
79+
RentalService → RentalRepository, NotificationService
80+
```
81+
82+
## 대여 상태 머신
83+
84+
```
85+
[사용자 신청] PENDING → CONFIRMED → RENTAL → RETURN_PENDING → RETURN_CONFIRMED → RETURNED
86+
→ REJECTED
87+
PENDING → CANCEL (사용자 취소)
88+
89+
[관리자 생성] 대여물품: → RENTAL (바로 대여중)
90+
소모품: → RETURNED (즉시 반납 처리)
91+
```
92+
93+
- **CONFIRMED**: 재고 차감, 담당자(worker) 배정
94+
- **RETURNED**: 재고 복원 (소모품 제외)
95+
- **소모품(CONSUMPTION)**: RENTAL 상태 요청 시 자동으로 RETURNED 처리
96+
97+
## 대여 비즈니스 규칙
98+
99+
- 회비 납부자(`isFeePaid`)만 대여 가능
100+
- 동일 물품 중복 대여 불가 (`ignoreDuplicate`로 우회 가능)
101+
- 시험 기간 대여 불가 (`exam-period.start-date` / `end-date`)
102+
- 주말 대여 불가
103+
- 과거 시간 대여 불가
104+
- 10시~17시만 대여 가능
105+
- Dev 모드(`/rentals/dev`): 시간 검증 생략, ADMIN 역할 필요
106+
107+
## API 엔드포인트
108+
109+
### 인증 (Public)
110+
| Method | Path | 설명 |
111+
|--------|------|------|
112+
| POST | `/auth/sign-up` | 회원가입 |
113+
| POST | `/auth/admin-login` | 관리자 로그인 |
114+
115+
### 물품 (Public)
116+
| Method | Path | 설명 |
117+
|--------|------|------|
118+
| GET | `/items` | 물품 검색 |
119+
120+
### 회원 (JWT 필요)
121+
| Method | Path | 설명 |
122+
|--------|------|------|
123+
| POST | `/members/me/fcm-token` | FCM 토큰 등록 |
124+
125+
### 알림 (JWT 필요)
126+
| Method | Path | 설명 |
127+
|--------|------|------|
128+
| GET | `/notifications` | 알림 목록 |
129+
| GET | `/notifications/count` | 안읽은 알림 수 |
130+
| PATCH | `/notifications/{id}` | 알림 읽음 |
131+
| PATCH | `/notifications/all` | 전체 읽음 |
132+
133+
### 대여 (JWT 필요)
134+
| Method | Path | 설명 |
135+
|--------|------|------|
136+
| POST | `/rentals` | 대여 신청 |
137+
| POST | `/rentals/dev` | 개발용 대여 (시간 검증 생략) |
138+
| GET | `/rentals` | 대여 이력 조회 |
139+
| PATCH | `/rentals/{id}` | 대여 취소 |
140+
| PATCH | `/rentals/return/{id}` | 반납 신청 |
141+
| GET | `/rentals/return-required` | 반납 필요 목록 |
142+
143+
### 관리자 (JWT + @OnlyAdmin)
144+
| Method | Path | 설명 |
145+
|--------|------|------|
146+
| GET | `/admin/items` | 물품 목록 (대여자 수 포함) |
147+
| POST | `/admin/items` | 물품 추가 |
148+
| PUT | `/admin/items/{id}` | 물품 수정 |
149+
| GET | `/admin/items/{id}` | 물품 상세 |
150+
| DELETE | `/admin/items/{id}` | 물품 삭제 |
151+
| GET | `/admin/members` | 회원 목록 |
152+
| GET | `/admin/members/admins` | 관리자 목록 |
153+
| POST | `/admin/members/admins` | 관리자 등록 |
154+
| DELETE | `/admin/members/admins` | 관리자 해제 |
155+
| GET | `/admin/members/payers` | 납부자 목록 |
156+
| POST | `/admin/members/payers` | 납부자 등록 |
157+
| DELETE | `/admin/members/payers` | 납부자 삭제 |
158+
| GET | `/admin/members/payers/excel` | 납부자 엑셀 다운로드 |
159+
| GET | `/admin/notifications` | 관리자 알림 |
160+
| GET | `/admin/rentals/dashboard` | 대시보드 |
161+
| GET | `/admin/rentals` | 대여 이력 |
162+
| PATCH | `/admin/rentals/{id}` | 대여 상태 변경 |
163+
| POST | `/admin/rentals` | 관리자 대여 생성 |
164+
| DELETE | `/admin/rentals/{id}` | 대여 이력 삭제 |
165+
166+
## Global 패키지
167+
168+
```
169+
global/
170+
├── annotation/ # @OnlyAdmin
171+
├── config/ # SecurityConfig, CorsConfig, SwaggerConfig, QueryDslConfig, AsyncConfig
172+
├── dto/ # PageableCondition, SearchCondition, PageableResponse
173+
├── exception/ # ApiException, ErrorCode, GlobalExceptionHandler
174+
├── external/
175+
│ ├── fcm/ # FCMConfig, FCMService
176+
│ └── s3/ # S3Config, S3Service
177+
├── logging/ # LoggingFilter
178+
├── security/
179+
│ ├── jwt/ # TokenProvider, TokenAuthenticationFilter
180+
│ └── oauth2/ # Google OAuth2 핸들러, UserAuthInfo
181+
└── utils/ # DateUtils(isWeekend), ExcelGenerator
182+
```

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ dependencies {
4747

4848
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
4949

50-
implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4")
50+
implementation("io.minio:minio:8.5.7")
5151
implementation("javax.xml.bind:jaxb-api:2.3.1")
5252
implementation("org.apache.tika:tika-core:2.9.2")
5353
implementation("org.apache.tika:tika-parsers-standard-package:2.9.2")

src/main/kotlin/site/billilge/api/backend/domain/display/facade/DisplayPosterFacade.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import site.billilge.api.backend.domain.display.dto.response.DisplayPosterFindAl
88
import site.billilge.api.backend.domain.display.service.DisplayPosterService
99
import site.billilge.api.backend.global.exception.ApiException
1010
import site.billilge.api.backend.global.exception.GlobalErrorCode
11-
import site.billilge.api.backend.global.external.s3.S3Service
11+
import site.billilge.api.backend.global.external.FileStorageService
1212
import java.util.*
1313

1414
@Component
1515
class DisplayPosterFacade(
1616
private val displayPosterService: DisplayPosterService,
17-
private val s3Service: S3Service,
17+
private val fileStorageService: FileStorageService,
1818
) {
1919
fun getAllPosters(): DisplayPosterFindAllResponse {
2020
val posters = displayPosterService.getAllPosters()
@@ -24,14 +24,14 @@ class DisplayPosterFacade(
2424
}
2525

2626
fun addPoster(image: MultipartFile, request: DisplayPosterRequest) {
27-
val imageUrl = s3Service.uploadImageFile(image, "posters/${UUID.randomUUID()}")
27+
val imageUrl = fileStorageService.uploadImageFile(image, "posters/${UUID.randomUUID()}")
2828
?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED)
2929
displayPosterService.addPoster(imageUrl, request.title)
3030
}
3131

3232
fun updatePoster(posterId: Long, image: MultipartFile?, request: DisplayPosterRequest) {
3333
val imageUrl = if (image != null && !image.isEmpty) {
34-
s3Service.uploadImageFile(image, "posters/${UUID.randomUUID()}")
34+
fileStorageService.uploadImageFile(image, "posters/${UUID.randomUUID()}")
3535
?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED)
3636
} else {
3737
null

src/main/kotlin/site/billilge/api/backend/domain/item/service/ItemService.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ import site.billilge.api.backend.global.dto.PageableCondition
1515
import site.billilge.api.backend.global.dto.SearchCondition
1616
import site.billilge.api.backend.global.exception.ApiException
1717
import site.billilge.api.backend.global.exception.GlobalErrorCode
18-
import site.billilge.api.backend.global.external.s3.S3Service
18+
import site.billilge.api.backend.global.external.FileStorageService
1919

2020
@Service
2121
@Transactional(readOnly = true)
2222
class ItemService(
2323
private val itemRepository: ItemRepository,
24-
private val s3Service: S3Service,
24+
private val fileStorageService: FileStorageService,
2525
) {
2626
fun getAllItems(): List<Item> {
2727
return itemRepository.findAll()
@@ -46,7 +46,7 @@ class ItemService(
4646

4747
checkImageIsSvg(image)
4848

49-
val imageUrl = s3Service.uploadImageFile(image)
49+
val imageUrl = fileStorageService.uploadImageFile(image)
5050
?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED)
5151

5252
val newItem = Item(
@@ -70,7 +70,7 @@ class ItemService(
7070
imageUrl = item.imageUrl
7171
} else {
7272
checkImageIsSvg(image)
73-
imageUrl = s3Service.uploadImageFile(image)
73+
imageUrl = fileStorageService.uploadImageFile(image)
7474
?: throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED)
7575
}
7676

@@ -99,7 +99,7 @@ class ItemService(
9999
}
100100

101101
if (isEntityDeleted) {
102-
s3Service.deleteImageFile(imageUrl)
102+
fileStorageService.deleteImageFile(imageUrl)
103103
return true
104104
}
105105

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package site.billilge.api.backend.global.external
2+
3+
import org.springframework.web.multipart.MultipartFile
4+
import java.util.UUID
5+
6+
interface FileStorageService {
7+
fun uploadImageFile(imageFile: MultipartFile, newFileName: String = "items/${UUID.randomUUID()}"): String?
8+
9+
fun deleteImageFile(fileName: String)
10+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package site.billilge.api.backend.global.external.minio
2+
3+
import io.minio.MinioClient
4+
import org.springframework.beans.factory.annotation.Value
5+
import org.springframework.context.annotation.Bean
6+
import org.springframework.context.annotation.Configuration
7+
8+
@Configuration
9+
class MinioConfig(
10+
@Value("\${minio.endpoint}")
11+
private val endpoint: String,
12+
13+
@Value("\${minio.access-key}")
14+
private val accessKey: String,
15+
16+
@Value("\${minio.secret-key}")
17+
private val secretKey: String,
18+
) {
19+
20+
@Bean
21+
fun minioClient(): MinioClient {
22+
return MinioClient.builder()
23+
.endpoint(endpoint)
24+
.credentials(accessKey, secretKey)
25+
.build()
26+
}
27+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package site.billilge.api.backend.global.external.minio
2+
3+
import io.minio.MinioClient
4+
import io.minio.PutObjectArgs
5+
import io.minio.RemoveObjectArgs
6+
import io.minio.StatObjectArgs
7+
import io.minio.errors.ErrorResponseException
8+
import org.springframework.beans.factory.annotation.Value
9+
import org.springframework.stereotype.Service
10+
import org.springframework.web.multipart.MultipartFile
11+
import site.billilge.api.backend.global.exception.ApiException
12+
import site.billilge.api.backend.global.exception.GlobalErrorCode
13+
import site.billilge.api.backend.global.external.FileStorageService
14+
import java.io.IOException
15+
import java.util.*
16+
17+
@Service
18+
class MinioService(
19+
private val minioClient: MinioClient,
20+
@Value("\${minio.bucket}")
21+
private val bucket: String,
22+
@Value("\${minio.base-url}")
23+
private val baseUrl: String,
24+
) : FileStorageService {
25+
26+
override fun uploadImageFile(imageFile: MultipartFile, newFileName: String): String? {
27+
val originalName = imageFile.originalFilename ?: return null
28+
29+
val ext = originalName.substring(originalName.lastIndexOf("."))
30+
val changedName = newFileName + ext
31+
32+
try {
33+
minioClient.putObject(
34+
PutObjectArgs.builder()
35+
.bucket(bucket)
36+
.`object`(changedName)
37+
.stream(imageFile.inputStream, imageFile.size, -1)
38+
.contentType(imageFile.contentType)
39+
.build()
40+
)
41+
} catch (e: IOException) {
42+
throw ApiException(GlobalErrorCode.IMAGE_UPLOAD_FAILED, e)
43+
}
44+
45+
return "${baseUrl}/${bucket}/${changedName}"
46+
}
47+
48+
override fun deleteImageFile(fileName: String) {
49+
val imageKey = fileName.replace("${baseUrl}/${bucket}/", "")
50+
51+
try {
52+
minioClient.statObject(
53+
StatObjectArgs.builder()
54+
.bucket(bucket)
55+
.`object`(imageKey)
56+
.build()
57+
)
58+
} catch (e: ErrorResponseException) {
59+
throw ApiException(GlobalErrorCode.IMAGE_NOT_FOUND)
60+
}
61+
62+
try {
63+
minioClient.removeObject(
64+
RemoveObjectArgs.builder()
65+
.bucket(bucket)
66+
.`object`(imageKey)
67+
.build()
68+
)
69+
} catch (e: IOException) {
70+
throw ApiException(GlobalErrorCode.IMAGE_DELETE_FAILED, e)
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)