diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java
new file mode 100644
index 00000000..f5b828c6
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java
@@ -0,0 +1,49 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.ac.kookmin.stream.api.app.AppApiUser;
+import kr.ac.kookmin.stream.api.app.welfare.rental.request.ItemListParams;
+import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalHistoryListParams;
+import kr.ac.kookmin.stream.api.app.welfare.rental.response.ItemListItemResponse;
+import kr.ac.kookmin.stream.api.app.welfare.rental.response.RentalHistoryListResponse;
+import kr.ac.kookmin.stream.api.app.welfare.rental.response.ReturnRequiredListResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 학생 앱 빌릴게(물품 대여) API의 문서 명세. 구현은 {@link AppRentalController}가 맡는다.
+ *
+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
+ * 파라미터 바인딩(@{@code ModelAttribute} 등)은 구현체에 둔다.
+ */
+@Tag(name = "빌릴게", description = "학생 앱 빌릴게 물품·대여 이력 조회")
+public interface AppRentalApi {
+
+ /** 물품 목록. 이름순 커서 페이지네이션이며 카테고리·검색어로 거른다. */
+ @Operation(summary = "물품 목록 조회",
+ description = "물품을 이름순으로 커서 기반 조회한다. category로 분류를, keyword로 이름 검색을 거르고 "
+ + "cursor/size로 다음 페이지를 넘긴다. 반납 정책(returnPolicy)은 대여품(RENTAL)에만 내려가고 소모품은 null이다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = RentalErrorCode.class, codes = {"ITEM_INVALID_CURSOR"})
+ ApiResponse> getItems(@ParameterObject ItemListParams params);
+
+ /** 내 대여 이력. 최근 신청순이며 상태로 거른다. */
+ @Operation(summary = "내 대여 이력 조회",
+ description = "내 대여 이력을 최근 신청순으로 조회한다. status를 생략하면 전체 상태를 조회한다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ ApiResponse getHistories(
+ AppApiUser apiUser,
+ @ParameterObject RentalHistoryListParams params
+ );
+
+ /** 지금 반납해야 하는 대여와 반납 기한. */
+ @Operation(summary = "반납 필요 대여 조회",
+ description = "지금 반납해야 하는 대여(대여 중 상태)와 반납 기한(dueAt)을 조회한다. "
+ + "반납 기한은 대여일에 최대 대여 일수를 더한 날의 반납 마감 시각이다. 없으면 빈 배열이다.")
+ ApiResponse getReturnRequired(AppApiUser apiUser);
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java
new file mode 100644
index 00000000..974387ba
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java
@@ -0,0 +1,59 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental;
+
+import jakarta.validation.Valid;
+import kr.ac.kookmin.stream.api.app.AppApiUser;
+import kr.ac.kookmin.stream.api.app.welfare.rental.request.ItemListParams;
+import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalHistoryListParams;
+import kr.ac.kookmin.stream.api.app.welfare.rental.response.ItemListItemResponse;
+import kr.ac.kookmin.stream.api.app.welfare.rental.response.RentalHistoryListResponse;
+import kr.ac.kookmin.stream.api.app.welfare.rental.response.ReturnRequiredListResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse;
+import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService;
+import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/app/billilge")
+@RequiredArgsConstructor
+public class AppRentalController implements AppRentalApi {
+
+ private final ItemService itemService;
+ private final RentalHistoryService rentalHistoryService;
+
+ @Override
+ @GetMapping("/items")
+ public ApiResponse> getItems(
+ @Valid @ModelAttribute ItemListParams params
+ ) {
+ CursorSliceResult- result = itemService.getItems(
+ params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault()
+ );
+ return ApiResponse.success(CursorSliceResponse.from(result, ItemListItemResponse::from));
+ }
+
+ @Override
+ @GetMapping("/histories")
+ public ApiResponse getHistories(
+ AppApiUser apiUser,
+ @Valid @ModelAttribute RentalHistoryListParams params
+ ) {
+ return ApiResponse.success(RentalHistoryListResponse.from(
+ rentalHistoryService.getHistories(apiUser.userId(), params.toStatus())
+ ));
+ }
+
+ @Override
+ @GetMapping("/histories/return-required")
+ public ApiResponse getReturnRequired(AppApiUser apiUser) {
+ return ApiResponse.success(ReturnRequiredListResponse.from(
+ rentalHistoryService.getReturnRequiredRentals(apiUser.userId())
+ ));
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java
new file mode 100644
index 00000000..2792022c
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java
@@ -0,0 +1,39 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental.request;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import kr.ac.kookmin.stream.api.common.CursorCodec;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor;
+
+public record ItemListParams(
+ String category,
+
+ String keyword,
+
+ String cursor,
+
+ @Min(value = 1, message = "조회 개수는 1 이상 100 이하여야 합니다.")
+ @Max(value = 100, message = "조회 개수는 1 이상 100 이하여야 합니다.")
+ Integer size
+) {
+
+ private static final int DEFAULT_SIZE = 20;
+
+ public ItemCategory toCategory() {
+ return ItemCategory.from(category);
+ }
+
+ // 공백뿐인 검색어는 검색하지 않는 것과 같다
+ public String toKeyword() {
+ return keyword == null || keyword.isBlank() ? null : keyword.strip();
+ }
+
+ public ItemCursor toCursor() {
+ return cursor == null ? null : ItemCursor.from(CursorCodec.decode(cursor));
+ }
+
+ public int sizeOrDefault() {
+ return size == null ? DEFAULT_SIZE : size;
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java
new file mode 100644
index 00000000..bf704a72
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental.request;
+
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+
+public record RentalHistoryListParams(String status) {
+
+ public RentalStatus toStatus() {
+ return RentalStatus.from(status);
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java
new file mode 100644
index 00000000..a56ff3fe
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java
@@ -0,0 +1,17 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental.response;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * 물품 이미지 파일 키를 공개 URL로 바꾸는 지점.
+ *
+ * 파일 키 → 공개 URL 조립이 아직 없어 현재는 항상 null이다. 조립이 생기면 이 메서드만 채우면 된다.
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+final class ItemImageUrl {
+
+ static String from(String imageKey) {
+ return null;
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java
new file mode 100644
index 00000000..77f03b2c
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java
@@ -0,0 +1,43 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental.response;
+
+import java.time.format.DateTimeFormatter;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnPolicy;
+
+public record ItemListItemResponse(
+ Long itemId,
+ String name,
+ ItemCategory category,
+ ItemType type,
+ int count,
+ String imageUrl,
+ ReturnPolicyResponse returnPolicy
+) {
+
+ public static ItemListItemResponse from(Item item) {
+ ReturnPolicy returnPolicy = item.getReturnPolicy();
+ return new ItemListItemResponse(
+ item.getId(),
+ item.getName(),
+ item.getCategory(),
+ item.getType(),
+ item.getCount(),
+ ItemImageUrl.from(item.getImageKey()),
+ returnPolicy == null ? null : ReturnPolicyResponse.from(returnPolicy)
+ );
+ }
+
+ public record ReturnPolicyResponse(int maxRentalDays, String returnDeadline) {
+
+ private static final DateTimeFormatter DEADLINE_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
+
+ public static ReturnPolicyResponse from(ReturnPolicy returnPolicy) {
+ return new ReturnPolicyResponse(
+ returnPolicy.maxRentalDays(),
+ returnPolicy.returnDeadline().format(DEADLINE_FORMAT)
+ );
+ }
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java
new file mode 100644
index 00000000..77fa4693
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java
@@ -0,0 +1,36 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+
+public record RentalHistoryListResponse(List histories) {
+
+ public static RentalHistoryListResponse from(List records) {
+ return new RentalHistoryListResponse(records.stream().map(History::from).toList());
+ }
+
+ public record History(
+ Long rentalHistoryId,
+ String itemName,
+ String itemImageUrl,
+ LocalDateTime rentAt,
+ LocalDateTime returnedAt,
+ RentalStatus status
+ ) {
+
+ public static History from(RentalRecord record) {
+ RentalHistory history = record.history();
+ return new History(
+ history.getId(),
+ record.itemName(),
+ ItemImageUrl.from(record.itemImageKey()),
+ history.getRentAt(),
+ history.getReturnedAt(),
+ history.getRentalStatus()
+ );
+ }
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java
new file mode 100644
index 00000000..a17b88ad
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java
@@ -0,0 +1,29 @@
+package kr.ac.kookmin.stream.api.app.welfare.rental.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord;
+
+public record ReturnRequiredListResponse(List rentalHistories) {
+
+ public static ReturnRequiredListResponse from(List records) {
+ return new ReturnRequiredListResponse(records.stream().map(Rental::from).toList());
+ }
+
+ public record Rental(
+ Long rentalHistoryId,
+ String itemName,
+ String itemImageUrl,
+ LocalDateTime dueAt
+ ) {
+
+ public static Rental from(RentalRecord record) {
+ return new Rental(
+ record.history().getId(),
+ record.itemName(),
+ ItemImageUrl.from(record.itemImageKey()),
+ record.dueAt()
+ );
+ }
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java
index 76b1861a..a10c5342 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java
@@ -12,11 +12,24 @@ public class Item {
private Long id;
private String name;
+ private ItemCategory category;
private ItemType type;
private int count;
private String imageKey;
+ /** 대여품(RENTAL)의 반납 정책. 소모품이거나 정책이 채워지지 않았으면 null이다. */
+ private ReturnPolicy returnPolicy;
- public static Item of(Long id, String name, ItemType type, int count, String imageKey) {
- return new Item(id, name, type, count, imageKey);
+ public static Item of(
+ Long id,
+ String name,
+ ItemCategory category,
+ ItemType type,
+ int count,
+ String imageKey,
+ ReturnPolicy returnPolicy
+ ) {
+ // 소모품은 반납하지 않으므로 저장된 값이 있어도 반납 정책을 갖지 않는다
+ ReturnPolicy applicablePolicy = type == ItemType.RENTAL ? returnPolicy : null;
+ return new Item(id, name, category, type, count, imageKey, applicablePolicy);
}
}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java
new file mode 100644
index 00000000..19221e15
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.domain;
+
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+
+public enum ItemCategory {
+ ELECTRONICS,
+ DAILY_SUPPLIES,
+ MEDICINE,
+ HYGIENE;
+
+ public static ItemCategory from(String value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return ItemCategory.valueOf(value);
+ } catch (IllegalArgumentException e) {
+ throw new BusinessException(CommonErrorCode.INVALID_INPUT);
+ }
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java
new file mode 100644
index 00000000..c283df36
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java
@@ -0,0 +1,34 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.domain;
+
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.Cursor;
+
+/**
+ * 물품 목록의 keyset 커서. 정렬 기준(이름 오름차순 + itemId 오름차순)과 짝을 이룬다.
+ */
+public record ItemCursor(String name, Long id) implements Cursor {
+
+ private static final char JOIN = '|';
+
+ public static ItemCursor of(Item item) {
+ return new ItemCursor(item.getName(), item.getId());
+ }
+
+ // 이름에 구분자가 들어 있어도 되도록 id가 오는 마지막 구분자를 기준으로 나눈다(id는 숫자라 구분자를 포함하지 않는다)
+ public static ItemCursor from(String raw) {
+ int separator = raw.lastIndexOf(JOIN);
+ if (separator < 0) {
+ throw new BusinessException(RentalErrorCode.ITEM_INVALID_CURSOR);
+ }
+ try {
+ return new ItemCursor(raw.substring(0, separator), Long.valueOf(raw.substring(separator + 1)));
+ } catch (NumberFormatException e) {
+ throw new BusinessException(RentalErrorCode.ITEM_INVALID_CURSOR);
+ }
+ }
+
+ @Override
+ public String format() {
+ return name + JOIN + id;
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java
new file mode 100644
index 00000000..23b0ace6
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java
@@ -0,0 +1,18 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.domain;
+
+import kr.ac.kookmin.stream.common.ErrorCode;
+import kr.ac.kookmin.stream.common.ErrorStatus;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+@Getter
+@Accessors(fluent = true)
+@AllArgsConstructor
+public enum RentalErrorCode implements ErrorCode {
+
+ ITEM_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다.");
+
+ private final int status;
+ private final String message;
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java
new file mode 100644
index 00000000..530640c4
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java
@@ -0,0 +1,37 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.domain;
+
+import java.time.LocalDateTime;
+
+/**
+ * 대여 이력에 그 물품을 붙인 조회용 조합 객체. 필드를 복사하지 않고 도메인 객체를 그대로 담아,
+ * 화면이 어떤 필드를 쓰는지는 응답(api)이 고른다.
+ *
+ * 물품 참조는 DB가 아닌 애플리케이션이 관리하므로 {@code item}은 사라진 물품이면 null이다.
+ */
+public record RentalRecord(RentalHistory history, Item item) {
+
+ /**
+ * 지금 반납해야 하는 대여인지. 대여 중이고, 대여 시각이 있고, 반납 정책이 있는 물품이어야 기한을 계산할 수 있다.
+ */
+ public boolean isReturnRequired() {
+ return history.getRentalStatus() == RentalStatus.RENTAL
+ && history.getRentAt() != null
+ && item != null
+ && item.getReturnPolicy() != null;
+ }
+
+ /**
+ * 반납 기한 시각. {@link #isReturnRequired()}가 true일 때만 계산할 수 있다.
+ */
+ public LocalDateTime dueAt() {
+ return item.getReturnPolicy().dueAt(history.getRentAt());
+ }
+
+ public String itemName() {
+ return item == null ? null : item.getName();
+ }
+
+ public String itemImageKey() {
+ return item == null ? null : item.getImageKey();
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java
index 0e9c4ed1..f93dca1a 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java
@@ -1,5 +1,8 @@
package kr.ac.kookmin.stream.welfare.domain.rental.domain;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+
public enum RentalStatus {
PENDING,
CONFIRMED,
@@ -8,5 +11,16 @@ public enum RentalStatus {
RENTAL,
RETURN_PENDING,
RETURNED,
- RETURN_CONFIRMED
+ RETURN_CONFIRMED;
+
+ public static RentalStatus from(String value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return RentalStatus.valueOf(value);
+ } catch (IllegalArgumentException e) {
+ throw new BusinessException(CommonErrorCode.INVALID_INPUT);
+ }
+ }
}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java
new file mode 100644
index 00000000..abf00b19
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java
@@ -0,0 +1,20 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.domain;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+
+/**
+ * 대여품의 반납 정책.
+ *
+ * @param maxRentalDays 대여일부터 최대 대여 가능 일수. 0이면 당일 반납
+ * @param returnDeadline 반납 마감 시각
+ */
+public record ReturnPolicy(int maxRentalDays, LocalTime returnDeadline) {
+
+ /**
+ * 대여 시각으로부터 반납 기한 시각을 계산한다. 대여한 날짜에 최대 대여 일수를 더한 날의 마감 시각이다.
+ */
+ public LocalDateTime dueAt(LocalDateTime rentAt) {
+ return rentAt.toLocalDate().plusDays(maxRentalDays).atTime(returnDeadline);
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java
new file mode 100644
index 00000000..9747cf48
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.repository;
+
+import java.util.Collection;
+import java.util.List;
+import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor;
+
+public interface ItemRepository {
+
+ /**
+ * 물품을 이름 오름차순(같으면 식별자 오름차순)으로 커서 페이지 단위로 조회한다.
+ *
+ * @param category 카테고리. null이면 전체
+ * @param keyword 이름에 포함된 검색어. null이면 검색하지 않는다
+ * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지
+ */
+ CursorSliceResult- findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size);
+
+ List
- findAllByIds(Collection ids);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java
new file mode 100644
index 00000000..33721bb9
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java
@@ -0,0 +1,15 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.repository;
+
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+
+public interface RentalHistoryRepository {
+
+ /**
+ * 회원의 대여 이력을 신청 시각 내림차순(같으면 식별자 내림차순)으로 조회한다.
+ *
+ * @param status 대여 상태. null이면 전체
+ */
+ List findAllByMemberId(Long memberId, RentalStatus status);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java
new file mode 100644
index 00000000..63448314
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java
@@ -0,0 +1,18 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.service;
+
+import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor;
+
+public interface ItemService {
+
+ /**
+ * 물품 목록을 이름순 커서 페이지로 조회한다.
+ *
+ * @param category 카테고리. null이면 전체
+ * @param keyword 이름 검색어. null이면 검색하지 않는다
+ * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지
+ */
+ CursorSliceResult
- getItems(ItemCategory category, String keyword, ItemCursor cursor, int size);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java
new file mode 100644
index 00000000..fcbd7aad
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.service;
+
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+
+public interface RentalHistoryService {
+
+ /**
+ * 회원의 대여 이력을 최근 신청순으로 조회한다.
+ *
+ * @param status 대여 상태. null이면 전체
+ */
+ List getHistories(Long memberId, RentalStatus status);
+
+ /**
+ * 회원이 지금 반납해야 하는 대여를 조회한다. 반납 기한은 {@link RentalRecord#dueAt()}으로 얻는다.
+ * 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다.
+ */
+ List getReturnRequiredRentals(Long memberId);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java
new file mode 100644
index 00000000..88aa630f
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java
@@ -0,0 +1,23 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.service.impl;
+
+import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor;
+import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository;
+import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+class ItemServiceImpl implements ItemService {
+
+ private final ItemRepository itemRepository;
+
+ // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다.
+ @Override
+ public CursorSliceResult
- getItems(ItemCategory category, String keyword, ItemCursor cursor, int size) {
+ return itemRepository.findSlice(category, keyword, cursor, size);
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java
new file mode 100644
index 00000000..80258a56
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java
@@ -0,0 +1,57 @@
+package kr.ac.kookmin.stream.welfare.domain.rental.service.impl;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository;
+import kr.ac.kookmin.stream.welfare.domain.rental.repository.RentalHistoryRepository;
+import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+class RentalHistoryServiceImpl implements RentalHistoryService {
+
+ private final RentalHistoryRepository rentalHistoryRepository;
+ private final ItemRepository itemRepository;
+
+ // 이력과 물품을 두 번에 나눠 읽어 서비스가 짝짓는다(coding-style.md 2-6). 지금은 items에 쓰기 경로가
+ // 없어 두 조회 사이에 물품이 바뀔 수 없으므로 트랜잭션이 필요 없다. 물품 이름 수정·신규 등록 정도만
+ // 생기는 한 이 필드는 필터·계산에 안 쓰여 트랜잭션 없이도 안전하다. 반납 정책·타입처럼 isReturnRequired·dueAt
+ // 계산에 쓰이는 필드를 수정하는 기능이 생기면 그때 @Transactional(readOnly = true)를 다시 붙인다.
+ @Override
+ public List getHistories(Long memberId, RentalStatus status) {
+ return toRecords(rentalHistoryRepository.findAllByMemberId(memberId, status));
+ }
+
+ // getHistories와 같은 이유로 트랜잭션이 필요 없다. isReturnRequired가 보는 returnPolicy는 지금 계획된
+ // 쓰기(이름 수정·신규 등록)로는 바뀌지 않으므로, 두 조회 사이에 반납 필요 여부가 달라지지 않는다.
+ @Override
+ public List getReturnRequiredRentals(Long memberId) {
+ return toRecords(rentalHistoryRepository.findAllByMemberId(memberId, RentalStatus.RENTAL)).stream()
+ .filter(RentalRecord::isReturnRequired)
+ .toList();
+ }
+
+ private List toRecords(List histories) {
+ Map items = findItemsOf(histories);
+ return histories.stream()
+ .map(history -> new RentalRecord(history, items.get(history.getItemId())))
+ .toList();
+ }
+
+ private Map findItemsOf(List histories) {
+ if (histories.isEmpty()) {
+ return Map.of();
+ }
+ List itemIds = histories.stream().map(RentalHistory::getItemId).distinct().toList();
+ return itemRepository.findAllByIds(itemIds).stream()
+ .collect(Collectors.toMap(Item::getId, Function.identity()));
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java
index ce460bb4..9468f024 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java
@@ -7,15 +7,22 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.Table;
+import java.time.LocalTime;
import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemType;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnPolicy;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@Entity
-@Table(name = "items")
+@Table(
+ name = "items",
+ indexes = @Index(name = "idx_items_name", columnList = "name")
+)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ItemJpaEntity extends BaseTimeEntity {
@@ -26,6 +33,10 @@ public class ItemJpaEntity extends BaseTimeEntity {
@Column(nullable = false)
private String name;
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 30)
+ private ItemCategory category;
+
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private ItemType type;
@@ -36,12 +47,22 @@ public class ItemJpaEntity extends BaseTimeEntity {
@Column(name = "image_key")
private String imageKey;
+ @Column(name = "max_rental_days")
+ private Integer maxRentalDays;
+
+ @Column(name = "return_deadline")
+ private LocalTime returnDeadline;
+
private ItemJpaEntity(Item item) {
this.id = item.getId();
this.name = item.getName();
+ this.category = item.getCategory();
this.type = item.getType();
this.count = item.getCount();
this.imageKey = item.getImageKey();
+ ReturnPolicy returnPolicy = item.getReturnPolicy();
+ this.maxRentalDays = returnPolicy == null ? null : returnPolicy.maxRentalDays();
+ this.returnDeadline = returnPolicy == null ? null : returnPolicy.returnDeadline();
}
public static ItemJpaEntity from(Item item) {
@@ -49,6 +70,10 @@ public static ItemJpaEntity from(Item item) {
}
public Item toDomain() {
- return Item.of(id, name, type, count, imageKey);
+ // 두 값이 모두 채워져야 정책이 성립한다
+ ReturnPolicy returnPolicy = maxRentalDays == null || returnDeadline == null
+ ? null
+ : new ReturnPolicy(maxRentalDays, returnDeadline);
+ return Item.of(id, name, category, type, count, imageKey, returnPolicy);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java
new file mode 100644
index 00000000..2bb2e7fc
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java
@@ -0,0 +1,28 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface ItemJpaRepository extends JpaRepository {
+
+ // 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다. keyword는 이름 부분 일치 검색이다.
+ // LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다
+ @Query("""
+ SELECT i FROM ItemJpaEntity i
+ WHERE (:category IS NULL OR i.category = :category)
+ AND (:keyword IS NULL OR LOCATE(:keyword, i.name) > 0)
+ AND (:cursorName IS NULL OR i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId))
+ ORDER BY i.name ASC, i.id ASC
+ """)
+ List findSlice(
+ @Param("category") ItemCategory category,
+ @Param("keyword") String keyword,
+ @Param("cursorName") String cursorName,
+ @Param("cursorId") Long cursorId,
+ Pageable pageable
+ );
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java
new file mode 100644
index 00000000..d2e42c97
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java
@@ -0,0 +1,41 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.util.Collection;
+import java.util.List;
+import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor;
+import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class ItemRepositoryImpl implements ItemRepository {
+
+ private final ItemJpaRepository itemJpaRepository;
+
+ @Override
+ public CursorSliceResult
- findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size) {
+ Pageable pageable = Pageable.ofSize(size + 1);
+ List entities = itemJpaRepository.findSlice(
+ category, keyword,
+ cursor == null ? null : cursor.name(),
+ cursor == null ? null : cursor.id(),
+ pageable
+ );
+
+ return CursorSliceResult.ofSlice(
+ entities, size, ItemJpaEntity::toDomain, item -> ItemCursor.of(item).format()
+ );
+ }
+
+ @Override
+ public List
- findAllByIds(Collection ids) {
+ return itemJpaRepository.findAllById(ids).stream()
+ .map(ItemJpaEntity::toDomain)
+ .toList();
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java
index 2cb8bd29..ae6ddb95 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java
@@ -20,7 +20,7 @@
@Table(
name = "rental_histories",
indexes = {
- @Index(name = "idx_rental_histories_member_id", columnList = "member_id"),
+ @Index(name = "idx_rental_histories_member_id_applied_at", columnList = "member_id, applied_at"),
@Index(name = "idx_rental_histories_item_id", columnList = "item_id")
}
)
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java
new file mode 100644
index 00000000..4d3606e5
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface RentalHistoryJpaRepository extends JpaRepository {
+
+ @Query("""
+ SELECT h FROM RentalHistoryJpaEntity h
+ WHERE h.memberId = :memberId
+ AND (:status IS NULL OR h.rentalStatus = :status)
+ ORDER BY h.appliedAt DESC, h.id DESC
+ """)
+ List findAllByMemberId(
+ @Param("memberId") Long memberId,
+ @Param("status") RentalStatus status
+ );
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java
new file mode 100644
index 00000000..8dfe1494
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory;
+import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus;
+import kr.ac.kookmin.stream.welfare.domain.rental.repository.RentalHistoryRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class RentalHistoryRepositoryImpl implements RentalHistoryRepository {
+
+ private final RentalHistoryJpaRepository rentalHistoryJpaRepository;
+
+ @Override
+ public List findAllByMemberId(Long memberId, RentalStatus status) {
+ return rentalHistoryJpaRepository.findAllByMemberId(memberId, status).stream()
+ .map(RentalHistoryJpaEntity::toDomain)
+ .toList();
+ }
+}
diff --git a/infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql b/infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql
new file mode 100644
index 00000000..97ec02a3
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql
@@ -0,0 +1,31 @@
+-- 빌릴게 물품 목록에 카테고리 필터와 대여품 반납 정책을 추가한다.
+--
+-- category: 기존 행이 있을 수 있어 DEFAULT로 채운 뒤 DEFAULT를 제거한다(flyway-migration.md 3-3절).
+-- DEFAULT를 남기면 INSERT가 category를 빠뜨려도 조용히 DAILY_SUPPLIES로 들어가므로, 이후엔 항상 명시하게 한다.
+-- ⚠️ 기존 items 행의 카테고리는 실제 분류가 아니라 임시값이다. 물품 등록·수정 API가 아직 없어
+-- 운영진이 DB에서 직접 바로잡아야 한다. 적용 전 SELECT COUNT(*) FROM items 로 행 유무를 확인한다.
+-- max_rental_days / return_deadline: 대여품(RENTAL)에만 있는 값이라 NULL을 허용한다.
+-- 대여일부터 최대 대여 가능 일수(0이면 당일 반납)와 반납 마감 시각(KST 기준 시각)이다.
+-- ⚠️ 기존 RENTAL 행은 NULL로 남으므로 반납 마감이 계산되지 않는다. 마찬가지로 직접 채워야 한다.
+ALTER TABLE items
+ ADD COLUMN category VARCHAR(30) NOT NULL DEFAULT 'DAILY_SUPPLIES' AFTER name, -- ELECTRONICS / DAILY_SUPPLIES / MEDICINE / HYGIENE
+ ADD COLUMN max_rental_days INT NULL AFTER image_key,
+ ADD COLUMN return_deadline TIME NULL AFTER max_rental_days;
+
+ALTER TABLE items
+ ALTER COLUMN category DROP DEFAULT;
+
+-- 물품 목록은 WHERE (category = ?) AND (name LIKE ?) ORDER BY name, id 로 조회한다.
+-- category는 값이 4개뿐인 선택 필터라 앞에 두면 category 없는 조회에서 정렬을 못 받쳐 filesort가 생긴다(V6과 같은 이유).
+-- 그래서 정렬 키인 name만 인덱스로 두고, InnoDB가 세컨더리 인덱스 끝에 PK(id)를 붙이므로 (name, id) 정렬이 커버된다.
+-- keyword는 부분 일치(LIKE '%..%')라 인덱스를 못 타지만, 물품 수가 적어 name 인덱스 순서 스캔 + 필터로 충분하다.
+CREATE INDEX idx_items_name ON items (name);
+
+-- 내 대여 이력은 항상 WHERE member_id = ? [AND rental_status = ?] ORDER BY applied_at DESC, history_id DESC 로 조회한다.
+-- 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둔 복합 인덱스로 바꾼다(V7과 같은 방식).
+-- 기존 idx_rental_histories_member_id는 새 인덱스의 좌측 prefix라 완전히 포함되므로 제거한다.
+-- rental_status는 한 회원의 이력 안에서 거르는 조건이라 인덱스에 넣지 않는다(회원당 이력이 적다).
+DROP INDEX idx_rental_histories_member_id ON rental_histories;
+
+CREATE INDEX idx_rental_histories_member_id_applied_at
+ ON rental_histories (member_id, applied_at);