diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java
index 220e3ac..6e0fd75 100644
--- a/src/main/java/com/sb/web/auth/domain/Member.java
+++ b/src/main/java/com/sb/web/auth/domain/Member.java
@@ -31,6 +31,8 @@ public class Member implements Serializable {
private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM (멤버십)
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
+ private String planProduct; // 구독 상품 ID (premium_monthly 등)
+ private Boolean planAutoRenew; // 자동 갱신 여부 (해지 시 false)
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private LocalDateTime createdAt;
diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java
index fc3ecd3..a37646b 100644
--- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java
+++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java
@@ -19,6 +19,8 @@ public class MemberResponse {
private String role;
private String plan; // FREE / PREMIUM
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
+ private String planProduct; // 구독 상품 ID
+ private Boolean planAutoRenew; // 자동 갱신 여부
private Long points; // 활동 포인트
private String googlePicture; // 구글 아바타 URL
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
@@ -33,6 +35,8 @@ public class MemberResponse {
.role(m.getRole())
.plan(m.getPlan())
.planExpiresAt(m.getPlanExpiresAt())
+ .planProduct(m.getPlanProduct())
+ .planAutoRenew(m.getPlanAutoRenew())
.points(m.getPoints())
.googlePicture(m.getGooglePicture())
.profileImage(m.getProfileImage())
diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
index df719c6..3f94235 100644
--- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
+++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
@@ -50,9 +50,13 @@ public interface MemberMapper {
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
- /** 결제로 멤버십 부여/갱신 (plan + 만료일) */
+ /** 결제로 멤버십 부여/갱신 (plan + 만료일 + 구독상품 + 자동갱신) */
int updateMembership(@Param("id") Long id, @Param("plan") String plan,
- @Param("expiresAt") java.time.LocalDateTime expiresAt);
+ @Param("expiresAt") java.time.LocalDateTime expiresAt,
+ @Param("product") String product, @Param("autoRenew") boolean autoRenew);
+
+ /** 구독 해지/재개 (자동 갱신 플래그) */
+ int updateAutoRenew(@Param("id") Long id, @Param("autoRenew") boolean autoRenew);
/** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
int downgradeExpired();
diff --git a/src/main/java/com/sb/web/billing/BillingController.java b/src/main/java/com/sb/web/billing/BillingController.java
index 761c7e7..b8c6152 100644
--- a/src/main/java/com/sb/web/billing/BillingController.java
+++ b/src/main/java/com/sb/web/billing/BillingController.java
@@ -4,6 +4,7 @@ import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse;
+import com.sb.web.billing.dto.SubscriptionResponse;
import com.sb.web.billing.dto.VerifyRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@@ -36,4 +37,25 @@ public class BillingController {
HttpServletRequest request) {
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req);
}
+
+ /** 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신) */
+ @GetMapping("/subscription")
+ public SubscriptionResponse subscription(
+ @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
+ return billingService.getSubscription(current.getId());
+ }
+
+ /** 구독 해지 (자동 갱신 중단, 만료일까지 이용) */
+ @PostMapping("/cancel")
+ public SubscriptionResponse cancel(
+ @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
+ return billingService.cancel(current.getId());
+ }
+
+ /** 구독 재개 (만료 전 자동 갱신 재개) */
+ @PostMapping("/resume")
+ public SubscriptionResponse resume(
+ @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
+ return billingService.resume(current.getId());
+ }
}
diff --git a/src/main/java/com/sb/web/billing/BillingService.java b/src/main/java/com/sb/web/billing/BillingService.java
index e61ba18..d2a78ff 100644
--- a/src/main/java/com/sb/web/billing/BillingService.java
+++ b/src/main/java/com/sb/web/billing/BillingService.java
@@ -6,6 +6,7 @@ import com.sb.web.auth.service.AuthService;
import com.sb.web.billing.domain.IapPurchase;
import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse;
+import com.sb.web.billing.dto.SubscriptionResponse;
import com.sb.web.billing.dto.VerifyRequest;
import com.sb.web.billing.mapper.IapPurchaseMapper;
import com.sb.web.common.exception.ApiException;
@@ -86,7 +87,7 @@ public class BillingService {
? member.getPlanExpiresAt() : now;
LocalDateTime expires = base.plusMonths(product.getMonths());
- memberMapper.updateMembership(memberId, "PREMIUM", expires);
+ memberMapper.updateMembership(memberId, "PREMIUM", expires, req.getProductId(), true);
purchaseMapper.insert(IapPurchase.builder()
.memberId(memberId).platform(platform).productId(req.getProductId())
.purchaseToken(token).orderId(req.getOrderId())
@@ -101,6 +102,51 @@ public class BillingService {
return MembershipResponse.of(member);
}
+ /** 현재 구독 현황 */
+ public SubscriptionResponse getSubscription(Long memberId) {
+ return toSubscription(mustFind(memberId));
+ }
+
+ /** 구독 해지 — 자동 갱신만 끈다. 이용은 만료일까지 유지(이후 무료 전환). */
+ @Transactional
+ public SubscriptionResponse cancel(Long memberId) {
+ Member m = mustFind(memberId);
+ if (!"PREMIUM".equals(m.getPlan())) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다.");
+ }
+ memberMapper.updateAutoRenew(memberId, false);
+ m.setPlanAutoRenew(false);
+ log.info("[billing] subscription canceled member={} (이용 만료일까지 유지)", memberId);
+ return toSubscription(m);
+ }
+
+ /** 구독 재개 — 만료 전이면 자동 갱신을 다시 켠다. */
+ @Transactional
+ public SubscriptionResponse resume(Long memberId) {
+ Member m = mustFind(memberId);
+ if (!"PREMIUM".equals(m.getPlan())) {
+ throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다.");
+ }
+ memberMapper.updateAutoRenew(memberId, true);
+ m.setPlanAutoRenew(true);
+ return toSubscription(m);
+ }
+
+ private SubscriptionResponse toSubscription(Member m) {
+ boolean premium = "PREMIUM".equals(m.getPlan());
+ boolean autoRenew = premium && Boolean.TRUE.equals(m.getPlanAutoRenew());
+ ProductResponse p = m.getPlanProduct() == null ? null : CATALOG.get(m.getPlanProduct());
+ String label = p != null ? p.getLabel() : (premium ? "프리미엄" : null);
+ return SubscriptionResponse.builder()
+ .premium(premium)
+ .planProduct(m.getPlanProduct())
+ .planLabel(label)
+ .expiresAt(m.getPlanExpiresAt())
+ .autoRenew(autoRenew)
+ .nextBillingAt(autoRenew ? m.getPlanExpiresAt() : null)
+ .build();
+ }
+
private Member mustFind(Long memberId) {
Member m = memberMapper.findById(memberId);
if (m == null) {
diff --git a/src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java b/src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java
new file mode 100644
index 0000000..d48b2ff
--- /dev/null
+++ b/src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java
@@ -0,0 +1,21 @@
+package com.sb.web.billing.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 정기결제(구독) 현황 — 계정정보 화면 표시용.
+ */
+@Data
+@Builder
+public class SubscriptionResponse {
+
+ private boolean premium; // 현재 유료 여부
+ private String planProduct; // 구독 상품 ID
+ private String planLabel; // 상품 표시 이름 (프리미엄 월간 등)
+ private LocalDateTime expiresAt; // 이용 만료일
+ private boolean autoRenew; // 자동 갱신 여부
+ private LocalDateTime nextBillingAt; // 다음 결제일 (자동 갱신 시 = 만료일, 해지 시 null)
+}
diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql
index 4163057..6a5eb26 100644
--- a/src/main/resources/db/member.sql
+++ b/src/main/resources/db/member.sql
@@ -41,6 +41,10 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COM
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan;
+-- 구독 상품(premium_monthly 등) + 자동 갱신 여부 (정기결제 관리)
+ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_product VARCHAR(100) NULL COMMENT '구독 상품 ID' AFTER plan_expires_at;
+ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_auto_renew TINYINT(1) NOT NULL DEFAULT 0 COMMENT '자동 갱신(해지 시 0)' AFTER plan_product;
+
-- 인앱 결제 기록 (영수증 검증·중복 방지·감사)
CREATE TABLE IF NOT EXISTS iap_purchase (
id BIGINT NOT NULL AUTO_INCREMENT,
diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml
index 38d4cb4..7e523f6 100644
--- a/src/main/resources/mapper/MemberMapper.xml
+++ b/src/main/resources/mapper/MemberMapper.xml
@@ -17,6 +17,8 @@
+
+
@@ -24,7 +26,7 @@
- id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, points, status, created_at, updated_at
+ id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at