Skip to content

Conversation

@eraser502
Copy link
Contributor

@eraser502 eraser502 commented Dec 8, 2025

Overview

  • CMS 사이트 페이지 버전 관리 전체 기능을 추가했어요.

    • CMS에서 앱 버전을 생성(PUBLISHED/DRAFT)하고, 기존 PUBLISHED 버전을 PREVIOUS로 변경한 뒤 페이지 구성(retain/add)까지 처리해 버전 정보를 조회할 수 있어요.
  • PUBLIC SITE PAGE 조회 API를 추가해 PUBLISHED 버전에서 경로별 페이지를 조회할 수 있는 API를 구현했어요.

  • app_pages, app_versions, app_version_pages 테이블과 매핑 엔티티를 도입해 페이지-버전 구조를 저장하도록 했어요.

  • 추가 작업으로 기존 RedotAppFilter를 거치는 API들에 대해서 Swagger에서 X-App-Domain을 넣을 수 있도록 추가했어요.


Related Issue


PR Checklist

  • [ ]

Additional Information

@eraser502 eraser502 self-assigned this Dec 8, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 8, 2025

Walkthrough

이 PR은 CMS 빌더와 사이트 공개 API를 위해 페이지 및 버전 관리 기능을 추가합니다. 새로운 JPA 엔티티(AppPage, AppVersion, AppVersionPage), 리포지토리, 서비스(CMSSitePageService, SitePageService), 컨트롤러(CMSSitePageController, SitePageController), DTO, 예외/에러코드, Swagger 문서 인터페이스, 데이터베이스 마이그레이션(V3__create_site_pages.sql) 및 보안 필터 체인(SecurityConfig에 sitePublicChain)을 도입합니다. 또한 다수의 컨트롤러 문서에 요청 헤더 X-App-Subdomain에 대한 OpenAPI @Parameter 주석을 추가합니다.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CMSSitePageController as CMS Page Controller
    participant CMSSitePageService as CMS Page Service
    participant AppVersionRepo as AppVersion Repo
    participant AppPageRepo as AppPage Repo
    participant AppVersionPageRepo as AppVersionPage Repo
    participant Database

    Client->>CMSSitePageController: POST /api/v1/app/cms/pages/versions (AppVersionCreateRequest)
    CMSSitePageController->>CMSSitePageService: createAppVersion(redotAppId, request)
    CMSSitePageService->>AppVersionRepo: findFirstByRedotAppIdAndStatus(PUBLISHED)
    AppVersionRepo->>Database: SELECT existing published version
    Database-->>AppVersionRepo: Optional<AppVersion>
    AppVersionRepo-->>CMSSitePageService: existingVersion?

    alt Publishing now
        CMSSitePageService->>AppVersionRepo: changeStatus(existingVersion -> PREVIOUS)
        AppVersionRepo->>Database: UPDATE version status
    end

    CMSSitePageService->>AppVersionRepo: save(new AppVersion)
    AppVersionRepo->>Database: INSERT app_versions
    CMSSitePageService->>AppPageRepo: validate/create AppPage(s) (added)
    AppPageRepo->>Database: INSERT/SELECT app_pages
    AppPageRepo-->>CMSSitePageService: persisted pages

    CMSSitePageService->>AppVersionPageRepo: save AppVersionPage links (retained+added)
    AppVersionPageRepo->>Database: INSERT app_version_pages (enforce unique paths)
    Database-->>AppVersionPageRepo: OK

    CMSSitePageService-->>CMSSitePageController: AppVersionSummaryResponse
    CMSSitePageController-->>Client: 200 OK (version summary)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • 집중 검토 필요 파일/영역:
    • CMSSitePageService.java — 발행 워크플로우, 상태 전환, 경로 중복 검증 로직
    • AppVersionRepository / AppVersionPageRepository — JPQL 쿼리와 페이징/조회 경로
    • 엔티티 매핑 및 제약조건 (AppPage, AppVersion, AppVersionPage) — 유니크 제약과 FK 동작
    • V3__create_site_pages.sql — CHECK/UNIQUE/FK 제약과 마이그레이션 안전성
    • SecurityConfig.sitePublicChain 우선순위와 기존 체인과의 상호작용
    • Swagger 문서에 추가된 X-App-Subdomain @Parameter 주석 일관성

Possibly related PRs

  • Docs / swagger api #113 — 동일한 컨트롤러 문서 인터페이스들에 X-App-Subdomain 주석을 추가/확장한 변경과 직접 연관됨.
  • Deploy / Merge to Main #115 — CMSAuthControllerDocs 및 유사 문서 인터페이스의 초기 추가/수정 PR로, 본 PR의 문서 주석 변경과 코드 수준 연결이 강함.

Poem

🐰 버전이 뽀송, 페이지가 폴짝폴짝,
발행은 찰칵, 초안은 꾸물꾸물.
데이터는 정리, 경로는 유일하게!
서브도메인 깃발 들고 달려요,
빌더와 누리의 행복한 점검 🎉

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 2 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive PR 제목은 변경사항의 주요 기능(CMS 및 SITE API 빌더)을 언급하지만, '기능/빌더/cms-및-site-api'는 매우 일반적이고 구체적인 구현 내용을 충분히 반영하지 못합니다. PR 제목을 더 명확하게 수정하세요. 예: 'CMS 페이지 버전 관리 및 공개 SITE API 추가' 또는 'CMS 빌더 및 SITE 공개 페이지 조회 API 구현'으로 변경하면 변경사항의 범위를 더 명확하게 표현할 수 있습니다.
Out of Scope Changes check ❓ Inconclusive 대부분의 변경사항이 PR 목표와 일치하나, X-App-Subdomain 헤더 매개변수를 여러 기존 API 문서에 추가한 작업은 주요 요구사항(이슈 #116)과 직접적인 관련이 다소 제한적입니다. X-App-Subdomain 헤더 추가 작업이 필수적인지 또는 별도 PR로 분리해야 하는지 확인하세요. 이 작업이 #116의 요구사항에 포함되거나 밀접하게 연관되어 있는지 검토 필요합니다.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 필수 섹션을 포함하고 있으나, 체크리스트가 비어있고 추가 정보 섹션에 제한적인 내용만 포함되어 있습니다. 주요 기능은 설명되었습니다.
Linked Issues check ✅ Passed PR의 코드 변경사항은 이슈 #116의 주요 요구사항들을 충족합니다. CMS Builder 페이지 생성/조회/버전 생성(PUBLISH·DRAFT)과 SITE 공개 페이지 조회 API가 모두 구현되었습니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/builder/cms-and-site-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java (1)

46-48: signOut 메서드의 문서화가 불일치합니다.

signOut 메서드는 X-App-Subdomain 헤더가 실제로는 필수입니다(RedotAppFilter가 모든 요청에서 강제). 그러나 다른 4개 메서드와 달리 docs에 이를 명시하지 않았습니다. 또한 signOut은 redotAppId 파라미터를 사용하지 않으므로, 이 차이를 의도적인 설계인지 명확히 하고 docs에 반영해야 합니다:

  • CMSAuthControllerDocs: signOut에 X-App-Subdomain @parameter 어노테이션 추가 또는
  • CMSAuthController: signOut에 @CurrentRedotApp 어노테이션 추가 (다른 메서드들과 일관성)

현재 상태에서는 RedotAppFilter가 X-App-Subdomain 헤더를 요구하지만 문서에는 반영되지 않아 클라이언트가 혼란을 겪을 수 있습니다.

🧹 Nitpick comments (20)
src/main/resources/db/migration/V3__create_site_pages.sql (1)

14-23: app_versions 테이블 개선 사항

  1. published_app_id 컬럼명 개선: 이 컬럼은 redot_apps(id)를 참조하지만, 네이밍이 혼란스럽습니다. 용도가 명확하지 않다면 published_to_app_id 또는 더 명확한 이름으로 변경하는 것을 권장합니다.

  2. 인덱스 추가: FK 컬럼과 status 컬럼에 인덱스가 없습니다. AppVersionRepository.findByRedotAppIdAndStatus 같은 쿼리는 이 컬럼들을 자주 사용할 것으로 예상됩니다.

다음 인덱스를 추가하세요:

CREATE INDEX idx_app_versions_redot_app_id ON app_versions(redot_app_id);
CREATE INDEX idx_app_versions_status ON app_versions(status);
CREATE INDEX idx_app_versions_app_status ON app_versions(redot_app_id, status);
src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java (1)

1-7: 향후 필요할 수 있는 커스텀 쿼리 메서드 고려

현재는 기본 CRUD 메서드만 제공하지만, 향후 다음과 같은 쿼리가 필요할 수 있습니다:

  • 특정 앱의 모든 페이지 조회
  • 앱 ID와 경로로 페이지 조회 (중복 검증용)

필요 시 다음과 같은 메서드를 추가하세요:

public interface AppPageRepository extends JpaRepository<AppPage, Long> {
    List<AppPage> findByRedotAppId(Long redotAppId);
    Optional<AppPage> findByRedotAppIdAndPath(Long redotAppId, String path);
    boolean existsByRedotAppIdAndPath(Long redotAppId, String path);
}
src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java (2)

24-33: 메서드명과 반환 타입 불일치

메서드명이 getSitePages(복수형)인데 단일 AppPageResponse를 반환합니다. 혼란을 줄이기 위해 getSitePage(단수형)로 변경하는 것을 권장합니다.

-public AppPageResponse getSitePages(Long appId, String path) {
+public AppPageResponse getSitePage(Long appId, String path) {

24-26: 입력 파라미터 검증 추가 고려

path 파라미터에 대한 null/empty 검증이 없습니다. 컨트롤러 레벨에서 검증하고 있다면 괜찮지만, 서비스 레벨에서도 방어적으로 검증하는 것을 고려해보세요.

public AppPageResponse getSitePage(Long appId, String path) {
    if (path == null || path.isBlank()) {
        throw new SitePageException(SitePageErrorCode.PAGE_NOT_FOUND);
    }
    // ... 기존 로직
}
src/main/java/redot/redot_server/domain/site/page/dto/response/AppPageResponse.java (1)

1-26: 엔티티 → 응답 DTO 매핑이 명확합니다. (Boolean 언박싱만 주의)

필드 매핑은 모두 AppPage 엔티티와 잘 맞습니다. 다만 엔티티의 isProtectedBoolean, DTO 필드는 boolean 이라 page.getIsProtected() 가 null 이 되는 경우 오토언박싱 NPE 가능성이 있습니다. 스키마에서 nullable = false 라 실제로는 문제 없을 가능성이 크지만, 장기적으로는 엔티티 필드를 boolean 으로 바꾸거나 DTO 필드를 Boolean 으로 맞춰 타입을 일치시키는 것도 고려해 볼 만합니다.

src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageCreateRequest.java (1)

6-12: 요청 DTO에 컬럼 길이 기반 Bean Validation을 추가하는 것을 고려해 주세요.

AppPage 엔티티에서 pathlength = 255, titlelength = 100 으로 제한되어 있지만, 여기 DTO에는 길이 제약이 없어 너무 긴 값이 들어오면 DB 레벨에서만 제약 위반 예외가 발생하고, 클라이언트 입장에서는 400 대신 5xx 계열로 보일 수 있습니다.

@Size(max = 255) (path), @Size(max = 100) (title) 같은 Bean Validation 을 추가해 요청 단계에서 검증하는 것을 추천드립니다.

src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppVersionCreateRequest.java (1)

8-13: 버전 생성 요청에서 retained/added 리스트의 null 처리 정책을 명확히 하면 좋겠습니다.

retained, added 에는 @Valid 만 있고 @NotNull 이 없어서, 클라이언트가 필드를 아예 생략하면 리스트가 null 로 들어올 수 있습니다. 서비스 레이어에서 request.retained().stream() 같이 사용할 경우 NPE 가 날 여지가 있으므로,

  • API 계약상 “빈 리스트로 보내야 한다” 가 맞다면 @NotNull 을 추가하고,
  • null 을 허용한다면 서비스 쪽에서 Optional.ofNullable(...).orElse(List.of()) 식으로 방어적으로 처리

중 하나로 정책을 고정하는 것을 추천드립니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java (1)

19-63: AppPage 엔티티 매핑은 전반적으로 좋고, 성능/무결성 측면에서 소폭 개선 여지가 있습니다.

  • 현재 @ManyToOne 은 기본값으로 FetchType.EAGER 인데, 페이지 조회 시 RedotApp 이 항상 필요하지 않다면 fetch = FetchType.LAZY 로 변경해 N+1 및 불필요한 조인을 줄이는 것을 고려해 볼 수 있습니다.
  • SITE 공개 API에서 path 기반 조회가 자주 일어날 것으로 보이므로, DB 마이그레이션 수준에서 redot_app_id, path 조합에 인덱스(또는 유니크 제약)를 두면 조회 성능과 데이터 무결성에 도움이 됩니다.
  • isProtected 필드는 Boolean 이지만 컬럼은 nullable = false 이므로, 실제로 null 이 될 일이 없다면 필드 타입을 boolean 으로 바꿔 의도를 더 명확히 할 수 있습니다. (앞서 AppPageResponse 에서의 언박싱 이슈와도 타입이 맞춰집니다.)

기존 설계 의도만 크게 벗어나지 않는다면 위 사항들을 다음 단계에서 검토해 보시면 좋겠습니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1)

31-37: ManyToOne 관계에 FetchType.LAZY 고려 필요

@ManyToOne 기본 fetch 타입은 EAGER입니다. AppVersionPage를 조회할 때마다 AppVersionAppPage가 항상 함께 로드되어 N+1 문제가 발생할 수 있습니다.

     @ManyToOne
+    @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "app_version_id", nullable = false)
     private AppVersion appVersion;

-    @ManyToOne
+    @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "app_page_id", nullable = false)
     private AppPage appPage;
src/main/java/redot/redot_server/domain/site/page/controller/docs/SitePageControllerDocs.java (2)

16-22: 에러 응답 문서화 추가 권장

성공 응답(200)만 문서화되어 있습니다. 페이지를 찾을 수 없거나 앱 버전이 없는 경우 등 에러 케이스에 대한 @ApiResponse도 추가하면 API 소비자에게 도움이 됩니다.

     @ApiResponse(responseCode = "200", description = "조회 성공",
             content = @Content(schema = @Schema(implementation = AppPageResponse.class)))
+    @ApiResponse(responseCode = "404", description = "페이지를 찾을 수 없음")
     @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true,
             description = "요청 대상 사이트 앱의 서브도메인")

21-22: 메서드명과 반환 타입 불일치

getSitePages(복수형)이지만 단일 AppPageResponse를 반환합니다. 단일 페이지 조회라면 getSitePage가 더 명확합니다.

src/main/java/redot/redot_server/domain/site/page/controller/SitePageController.java (1)

21-25: path 파라미터 유효성 검증 고려

path 파라미터에 대한 유효성 검증이 없습니다. 빈 문자열이나 잘못된 경로 형식이 서비스 레이어까지 전달될 수 있습니다. @NotBlank 또는 패턴 검증 추가를 권장합니다.

+import jakarta.validation.constraints.NotBlank;
+import org.springframework.validation.annotation.Validated;

 @RestController
 @RequiredArgsConstructor
+@Validated
 @RequestMapping("/api/v1/app/site/pages")
 public class SitePageController implements SitePageControllerDocs {

     private final SitePageService sitePageService;

     @GetMapping
     @Override
-    public ResponseEntity<AppPageResponse> getSitePages(@CurrentRedotApp Long appId, @RequestParam(name = "path") String path) {
+    public ResponseEntity<AppPageResponse> getSitePages(@CurrentRedotApp Long appId, @RequestParam(name = "path") @NotBlank String path) {
         return ResponseEntity.ok(sitePageService.getSitePages(appId, path));
     }
 }
src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java (2)

16-18: 불필요한 @query 어노테이션

Spring Data JPA는 메서드명 findFirstByRedotAppIdAndStatusOrderByIdDesc에서 쿼리를 자동 파생할 수 있습니다. 명시적 @Query가 필수는 아닙니다.

-    @org.springframework.data.jpa.repository.Query("select av from AppVersion av where av.redotApp.id = :redotAppId and av.status = :status order by av.id desc")
-    Optional<AppVersion> findFirstByRedotAppIdAndStatusOrderByIdDesc(@Param("redotAppId") Long redotAppId,
-                                                                     @Param("status") AppVersionStatus status);
+    Optional<AppVersion> findFirstByRedotAppIdAndStatusOrderByIdDesc(Long redotAppId, AppVersionStatus status);

7-7: 사용되지 않는 import

@Param이 import되어 있지만, 명시적 @Query 제거 시 불필요해집니다. 현재도 Spring Data가 파라미터명을 자동 바인딩하므로 @Param은 선택적입니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1)

34-36: ManyToOne 관계에 FetchType.LAZY 권장

@ManyToOne 기본 fetch 타입은 EAGER입니다. AppVersion 조회 시 항상 RedotApp이 함께 로드됩니다. 필요시에만 로드하도록 LAZY 설정을 권장합니다.

-    @ManyToOne
+    @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "redot_app_id", nullable = false)
     private RedotApp redotApp;
src/main/java/redot/redot_server/domain/cms/site/page/controller/docs/CMSSitePageControllerDocs.java (2)

44-47: POST 엔드포인트에서 HTTP 201 상태 코드 사용을 권장합니다.

createVersion은 새 리소스를 생성하는 POST 요청이므로, RESTful 관례에 따라 200 대신 201(Created)을 반환하는 것이 적절합니다.

-    @ApiResponse(responseCode = "200", description = "생성 성공",
+    @ApiResponse(responseCode = "201", description = "생성 성공",
             content = @Content(schema = @Schema(implementation = AppVersionSummaryResponse.class)))

22-30: @parameter 어노테이션이 메서드마다 중복되어 있습니다.

X-App-Subdomain 헤더 파라미터가 모든 메서드에 반복 선언되어 있습니다. 인터페이스 또는 클래스 레벨에서 공통 파라미터를 정의하거나, 컨트롤러 클래스에서 @Parameters 어노테이션을 활용하여 중복을 줄일 수 있습니다.

src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java (1)

48-53: POST 엔드포인트는 HTTP 201 반환을 권장합니다.

리소스 생성 성공 시 ResponseEntity.ok() 대신 ResponseEntity.status(HttpStatus.CREATED).body(...)를 사용하여 201 상태 코드를 반환하는 것이 RESTful 관례에 맞습니다.

+import org.springframework.http.HttpStatus;
     @PostMapping("/versions")
     @Override
     public ResponseEntity<AppVersionSummaryResponse> createVersion(@CurrentRedotApp Long redotAppId,
                                                                    @Valid @RequestBody AppVersionCreateRequest request) {
-        return ResponseEntity.ok(cmsSitePageService.createAppVersion(redotAppId, request));
+        return ResponseEntity.status(HttpStatus.CREATED)
+                .body(cmsSitePageService.createAppVersion(redotAppId, request));
     }
src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (2)

59-66: 존재 확인과 조회 사이에 미세한 경쟁 조건이 있습니다.

existsByAppVersion_RedotApp_IdAndAppPage_Id 호출과 findById 호출 사이에 데이터가 삭제될 수 있는 이론적인 경쟁 조건이 존재합니다. 현재 구현에서는 PAGE_NOT_FOUND 예외로 처리되므로 실질적인 문제는 없지만, 단일 쿼리로 소유권 검증과 조회를 동시에 수행하면 더 효율적입니다.

선택적으로 다음과 같은 repository 메서드를 추가할 수 있습니다:

// AppPageRepository에 추가
@Query("SELECT ap FROM AppPage ap JOIN AppVersionPage avp ON avp.appPage = ap " +
       "WHERE avp.appVersion.redotApp.id = :redotAppId AND ap.id = :pageId")
Optional<AppPage> findByIdAndRedotAppId(@Param("redotAppId") Long redotAppId, 
                                        @Param("pageId") Long pageId);

116-150: retainedPages 메서드의 중복 ID 처리를 고려하세요.

retained 리스트에 동일한 id가 중복으로 전달될 경우, 현재 로직에서는 중복 ID마다 AppVersionPage가 생성됩니다. 의도된 동작인지 확인이 필요하며, 중복을 방지하려면 idsSet으로 변환하는 것이 좋습니다.

         List<Long> ids = retained.stream()
                 .map(AppPageRetainRequest::id)
                 .filter(Objects::nonNull)
-                .toList();
+                .distinct()
+                .toList();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8bdbae5 and 5832618.

📒 Files selected for processing (33)
  • src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java (3 hunks)
  • src/main/java/redot/redot_server/domain/cms/inquiry/controller/docs/RedotAppInquiryControllerDocs.java (3 hunks)
  • src/main/java/redot/redot_server/domain/cms/member/controller/docs/CMSMemberControllerDocs.java (3 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/menu/controller/docs/CMSMenuControllerDocs.java (2 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/controller/docs/CMSSitePageControllerDocs.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageCreateRequest.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageRetainRequest.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppVersionCreateRequest.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionPageSummaryResponse.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionSummaryResponse.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/service/dto/AppVersionPageSummaryWithVersionResponse.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/setting/controller/docs/SiteSettingControllerDocs.java (2 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/style/controller/docs/StyleInfoControllerDocs.java (2 hunks)
  • src/main/java/redot/redot_server/domain/redot/app/controller/docs/RedotAppControllerDocs.java (2 hunks)
  • src/main/java/redot/redot_server/domain/site/page/controller/SitePageController.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/controller/docs/SitePageControllerDocs.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/dto/response/AppPageResponse.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/entity/AppVersionStatus.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/exception/SitePageErrorCode.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java (1 hunks)
  • src/main/java/redot/redot_server/global/security/config/SecurityConfig.java (1 hunks)
  • src/main/resources/db/migration/V3__create_site_pages.sql (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java (1)
src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java (1)
  • SitePageException (5-9)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (2)
src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java (1)
  • Entity (19-64)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1)
  • Entity (18-42)
src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java (2)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1)
  • Entity (23-69)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1)
  • Entity (18-42)
🔇 Additional comments (36)
src/main/java/redot/redot_server/domain/cms/site/setting/controller/docs/SiteSettingControllerDocs.java (2)

5-5: LGTM!

필요한 import가 올바르게 추가되었습니다.


19-20: LGTM!

세 개의 엔드포인트 모두에 X-App-Subdomain 헤더 파라미터 문서화가 일관되게 추가되었습니다. Swagger 문서에 필요한 메타데이터가 올바르게 설정되어 있습니다.

Also applies to: 27-28, 35-36

src/main/java/redot/redot_server/domain/cms/member/controller/docs/CMSMemberControllerDocs.java (2)

5-5: LGTM!

ParameterIn import가 새로 추가된 @Parameter 어노테이션에서 ParameterIn.HEADER를 사용하기 위해 필요하며, 올바르게 추가되었습니다.


27-28: Header naming is consistent—no discrepancy exists.

The search confirms that "X-App-Subdomain" is the correct and only header name used throughout the codebase. There is no "X-App-Domain" anywhere. The RedotAppFilter implementation (line 38 of src/main/java/redot/redot_server/global/security/filter/redotapp/RedotAppFilter.java) correctly retrieves request.getHeader("X-App-Subdomain"), which matches the documentation added in this PR. All 27+ occurrences across controller docs files use the same header name consistently.

Likely an incorrect or invalid review comment.

src/main/java/redot/redot_server/domain/cms/site/menu/controller/docs/CMSMenuControllerDocs.java (2)

5-5: LGTM!

ParameterIn enum 임포트가 올바르게 추가되었으며, 아래 헤더 파라미터 문서화에 필요합니다.


17-18: X-App-Subdomain 헤더 문서화가 적절하게 추가되었습니다.

Swagger 문서에 X-App-Subdomain 헤더를 명시하여 API 사용자에게 필수 헤더 요구사항을 명확히 전달하고 있습니다. 이 헤더명은 RedotAppFilter에서 실제로 사용되는 이름과 일치하며, 다른 모든 컨트롤러(총 29개 위치)에서도 일관되게 사용되고 있습니다.

src/main/java/redot/redot_server/domain/cms/inquiry/controller/docs/RedotAppInquiryControllerDocs.java (2)

5-5: LGTM!

ParameterIn.HEADER 사용을 위해 필요한 import가 올바르게 추가되었습니다.


22-23: This review comment is incorrect. The header naming is consistent throughout the codebase.

Verification Results:

  • Filter implementation (RedotAppFilter.java:38) uses X-App-Subdomain
  • All 29 occurrences across the codebase use X-App-Subdomain consistently
  • No references to X-App-Domain exist in the codebase
  • All Docs files, including the 5 methods in RedotAppInquiryControllerDocs (lines 22-23, 30-31, 38-39, 48-49, 56-57), correctly use X-App-Subdomain

The documentation and implementation are already aligned.

src/main/java/redot/redot_server/domain/cms/site/style/controller/docs/StyleInfoControllerDocs.java (2)

5-5: LGTM!

ParameterIn import가 올바르게 추가되었으며, 아래 헤더 파라미터 정의에 필요합니다.


17-18: The header name is correct and consistent with the implementation.

Verification confirms that RedotAppFilter uses "X-App-Subdomain" (line 38 of RedotAppFilter.java), which matches the @Parameter annotations in lines 17-18 and 24-25. All other controller documentation interfaces across the codebase consistently use the same header name. The annotations are correct.

src/main/java/redot/redot_server/domain/redot/app/controller/docs/RedotAppControllerDocs.java (2)

5-5: 필요한 Swagger enum import 추가 적절

@Parameter(in = ParameterIn.HEADER, ...) 사용을 위한 ParameterIn import 추가 잘 되어 있고, 부작용이나 스타일 이슈는 없어 보입니다.


22-23: Header naming is consistent across the codebase—no mismatch found.

Verification confirms that X-App-Subdomain is used uniformly throughout the codebase:

  • RedotAppFilter.java retrieves the header using "X-App-Subdomain"
  • All controller documentation files (RedotAppControllerDocs, CMSAuthControllerDocs, CMSMemberControllerDocs, etc.) document the same header name
  • No usage of X-App-Domain exists anywhere in the repository

The documented header name is correct and aligned with the filter implementation.

src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java (2)

5-5: 필요한 import 추가 완료.

ParameterIn.HEADER 사용을 위해 필요한 import가 올바르게 추가되었습니다.


21-22: Swagger 헤더 파라미터 문서화가 일관되게 구현되었습니다.

redotAppId 파라미터를 사용하는 모든 메서드에 X-App-Subdomain 헤더 어노테이션이 일관된 패턴으로 추가되었습니다. required = true 설정과 한글 설명이 적절합니다.

Also applies to: 30-31, 38-39, 50-51

src/main/java/redot/redot_server/global/security/config/SecurityConfig.java (1)

224-233: Public site page retrieval is correctly configured with appropriate security settings.

The implementation correctly permits unauthenticated access to /api/v1/app/site/** endpoints via SitePageController, which retrieves site pages without requiring authentication. The use of RedotAppFilter to extract app context and the placement before SecurityContextHolderFilter align with the existing pattern for app-aware public endpoints.

The @Order(-3) priority is necessary to ensure this chain evaluates before redotAppApiChain (@Order(4)), which requires authentication and also matches /api/v1/app/**. While @Order(-1) would also work (no conflicts exist between this chain and socialLoginChain/publicDomainChain), using -3 is a valid and safe choice that explicitly elevates this public endpoint above all other app-related chains.

src/main/java/redot/redot_server/domain/site/page/exception/SitePageErrorCode.java (1)

1-16: LGTM!

에러 코드가 명확하게 정의되어 있고, 프로젝트의 표준 패턴을 따르고 있습니다.

src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java (1)

1-19: LGTM!

CMS 관련 에러 코드들이 명확하게 정의되어 있고, 적절한 HTTP 상태 코드를 사용하고 있습니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppVersionStatus.java (1)

1-7: LGTM!

버전 상태가 명확하게 정의되어 있고, 데이터베이스의 CHECK constraint와 일치합니다.

src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java (1)

1-9: LGTM!

프로젝트의 표준 예외 패턴을 올바르게 따르고 있습니다.

src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageRetainRequest.java (1)

1-8: LGTM!

유지할 페이지의 ID를 전달하는 단순하고 명확한 DTO입니다.

src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java (1)

16-33: 서비스 로직이 올바르게 구현됨

PUBLISHED 버전을 조회하고 해당 버전 내에서 경로로 페이지를 찾는 로직이 정확합니다. 적절한 예외 처리와 함께 read-only 트랜잭션을 사용하고 있습니다.

src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java (1)

1-9: 사이트 페이지 도메인 예외 정의가 적절합니다.

BaseException 을 상속해서 SitePageErrorCode 만 받도록 한 단일 생성자 패턴이면 다른 도메인 예외들과도 일관성이 있을 것이고, 현재 요구사항에는 충분해 보입니다.

src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionPageSummaryResponse.java (1)

1-9: 버전 페이지 요약 DTO 정의가 단순하고 명확합니다.

버전별 페이지 리스트용으로 필요한 최소 필드만 잘 노출하고 있어 사용처에서 읽기 편할 것 같습니다.

src/main/java/redot/redot_server/domain/cms/site/page/service/dto/AppVersionPageSummaryWithVersionResponse.java (1)

5-15: 버전 포함 요약 DTO와 toSummary 변환 메서드 구성이 적절합니다.

리포지토리 프로젝션용으로 appVersionId 를 유지하면서, 컨트롤러 응답에는 toSummary() 로 페이지 요약만 노출하는 구조가 명확하고 사용성이 좋아 보입니다.

src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionSummaryResponse.java (1)

8-25: 버전 요약 DTO와 from 팩토리 메서드 구성이 명확합니다.

AppVersion 엔티티에서 id/status/createdAt/remark 를 그대로 가져오고, 페이지 요약 리스트를 외부에서 주입하는 방식이라 조회용 응답 모델로 사용하기에 깔끔합니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1)

39-41: 팩토리 메서드 구현 적절

create 정적 팩토리 메서드가 AllArgsConstructor를 직접 호출하여 인스턴스를 생성합니다. 다른 엔티티(AppVersion, AppPage)의 builder 패턴과 일관성을 위해 builder를 사용할 수도 있지만, 현재 구현도 유효합니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (2)

44-55: publishedAppId 유니크 제약조건 패턴 적절

앱당 하나의 PUBLISHED 버전만 허용하는 로직을 publishedAppId의 unique 제약조건으로 구현한 것은 좋은 접근입니다. DB 레벨에서 무결성을 보장합니다.

단, 동시에 두 버전을 PUBLISHED로 변경하려는 동시성 시나리오에서 unique constraint violation이 발생할 수 있으므로, 서비스 레이어에서 적절한 예외 처리가 필요합니다.


65-68: changeStatus()에서 syncPublishedAppId() 호출 중복 가능성

changeStatus()syncPublishedAppId()를 명시적으로 호출하지만, JPA의 @PreUpdate가 flush 시점에 다시 호출합니다. 기능상 문제는 없지만, changeStatus() 직후 publishedAppId 값에 의존하는 코드가 있다면 명시적 호출이 필요합니다. 현재 구현이 의도된 것이라면 괜찮습니다.

src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java (1)

14-14: Method signature allows multiple matching records despite Optional return type

findByRedotAppIdAndStatus returns Optional but doesn't restrict which statuses can be queried. While currently only used with AppVersionStatus.PUBLISHED (which has a unique constraint via publishedAppId), the method could throw IncorrectResultSizeDataAccessException if called with DRAFT or PREVIOUS status where multiple versions may exist.

Consider clarifying the method's contract: either rename to findFirstByRedotAppIdAndStatus to indicate single-result ordering, add a @Query with explicit ordering, or document that it only accepts PUBLISHED status. The overloaded three-parameter version correctly uses Page<AppVersion> for multiple results.

src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java (2)

12-33: LGTM!

Repository 인터페이스가 잘 구성되어 있습니다. Spring Data JPA 명명 규칙을 따르고 있으며, JPQL 쿼리의 DTO 프로젝션도 적절합니다.


25-30: No changes needed — empty list is already handled by the caller.

The fetchPageSummaries method in CMSSitePageService contains two guards (lines checking versions.isEmpty() and ids.isEmpty()) that ensure findSummariesByAppVersionIds is never invoked with an empty list. The concern about SQL errors from empty IN clauses does not apply here.

src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java (1)

25-46: LGTM!

컨트롤러 구조가 깔끔합니다. @CurrentRedotApp을 통한 앱 컨텍스트 해결, @Valid를 통한 요청 검증, @PageableDefault를 통한 페이지네이션 기본값 설정이 적절하게 적용되어 있습니다.

src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (4)

178-182: LGTM!

validateStatus 메서드가 PREVIOUS 상태 직접 생성을 적절히 방지하고 있습니다. null 체크도 포함되어 있어 방어적 코딩이 잘 되어 있습니다.


184-200: LGTM!

fetchPageSummaries 메서드에서 빈 리스트와 null을 적절히 처리하고 있어, AppVersionPageRepository.findSummariesByAppVersionIds에 빈 리스트가 전달되는 것을 방지하고 있습니다.


152-176: No LazyInitializationException risk detected. The @ManyToOne relationship between AppVersion and RedotApp (line 34-36 of AppVersion.java) uses the default FetchType.EAGER, not LAZY. RedotApp is eagerly loaded, and the version.getRedotApp() call at line 159 executes safely within the @Transactional context inherited from createAppVersion. The code requires no changes.


87-104: Add documentation for the two-step version creation approach and consider explicit flush for the existing version status change.

The DRAFT→PUBLISHED pattern creates a new version as DRAFT before changing it to PUBLISHED. This design triggers the syncPublishedAppId() lifecycle callback properly, but the intent should be documented with an inline comment.

Additionally, at lines 92–93, the existing PUBLISHED version is modified via changeStatus() but relies on dirty checking without an explicit flush. While this works within the same transaction, consider calling appVersionRepository.saveAndFlush() on the existing version before saving the new one for clarity and to ensure ordering guarantees.

Comment on lines +1 to +12
CREATE TABLE IF NOT EXISTS app_pages
(
id BIGSERIAL PRIMARY KEY,
redot_app_id BIGINT NOT NULL REFERENCES redot_apps (id),
content TEXT NOT NULL,
path VARCHAR(255) NOT NULL,
is_protected BOOLEAN NOT NULL,
description TEXT,
title VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

app_pages 테이블에 unique constraint와 인덱스 추가 필요

현재 스키마에서 다음 문제가 있습니다:

  1. (redot_app_id, path) 조합에 unique constraint가 없어 동일 앱에서 중복 경로가 허용됩니다. CMSSitePageErrorCode에서 PAGE_PATH_DUPLICATED 에러를 정의했지만, DB 레벨에서 이를 강제하지 않습니다.
  2. path 컬럼에 인덱스가 없어 경로 기반 조회(SitePageService.getSitePages) 시 성능 저하가 예상됩니다.
  3. redot_app_id FK 컬럼에 인덱스가 없어 조인 성능이 저하될 수 있습니다.

다음 변경사항을 적용하세요:

CREATE TABLE IF NOT EXISTS app_pages
(
    id           BIGSERIAL PRIMARY KEY,
    redot_app_id BIGINT        NOT NULL REFERENCES redot_apps (id),
    content      TEXT          NOT NULL,
    path         VARCHAR(255)  NOT NULL,
    is_protected BOOLEAN       NOT NULL,
    description  TEXT,
    title        VARCHAR(100)  NOT NULL,
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+   CONSTRAINT uk_app_pages_app_path UNIQUE (redot_app_id, path)
);

+CREATE INDEX idx_app_pages_redot_app_id ON app_pages(redot_app_id);
+CREATE INDEX idx_app_pages_path ON app_pages(path);
🤖 Prompt for AI Agents
In src/main/resources/db/migration/V3__create_site_pages.sql around lines 1 to
12, add a UNIQUE constraint on (redot_app_id, path) and create indexes on
redot_app_id and path to prevent duplicate page paths per app and improve
query/join performance; modify the CREATE TABLE to include CONSTRAINT
uk_app_pages_app_path UNIQUE (redot_app_id, path) and add separate CREATE INDEX
statements for idx_app_pages_redot_app_id on redot_app_id and idx_app_pages_path
on path immediately after the table creation.

Comment on lines 28 to 36
CREATE TABLE IF NOT EXISTS app_version_pages
(
id BIGSERIAL PRIMARY KEY,
app_version_id BIGINT NOT NULL REFERENCES app_versions (id) ON DELETE CASCADE,
app_page_id BIGINT NOT NULL REFERENCES app_pages (id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_app_version_page UNIQUE (app_version_id, app_page_id)
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find Java entity classes for AppPage and AppVersion
fd -t f -e java | xargs rg -l "class AppPage|class AppVersion" | head -20

Repository: redotlabs/server

Length of output: 279


🏁 Script executed:

# Search for deletion logic related to AppPage
rg -nP --type=java -C3 "deleteById|delete\(|DELETE" | rg -A5 -B5 "AppPage|app_page" | head -100

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

# Look for cascade configurations in JPA annotations
rg -nP --type=java "CascadeType|cascade|ON DELETE" | head -50

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

# Search for any PUBLISHED or status checks that might affect deletion
rg -nP --type=java "PUBLISHED|published|version.*status|page.*status" | head -50

Repository: redotlabs/server

Length of output: 2910


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java

Repository: redotlabs/server

Length of output: 2472


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java

Repository: redotlabs/server

Length of output: 2612


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java

Repository: redotlabs/server

Length of output: 1712


🏁 Script executed:

# Search for delete methods in repositories
rg -nP --type=java "deleteBy|delete\(" src/main/java/redot/redot_server/domain/site/page/ | head -30

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

# Find repository interfaces
fd -t f -e java | xargs rg -l "repository|Repository" | rg "AppPage|AppVersion" | head -20

Repository: redotlabs/server

Length of output: 321


🏁 Script executed:

# Search for repository classes directly
fd -type f -name "*Repository.java" | xargs rg -l "AppPage|AppVersion"

Repository: redotlabs/server

Length of output: 2020


🏁 Script executed:

# Look for delete endpoints or operations
rg -nP --type=java "delete|DELETE|remove|REMOVE" src/main/java/redot/redot_server/domain/cms/site/page/ | head -50

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

# Search across entire site/page domain for deletion operations
rg -nP --type=java "deleteBy|deleteAll|delete\(" src/main/java/redot/redot_server/domain/site/page/ -A2 -B2

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java

Repository: redotlabs/server

Length of output: 363


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java

Repository: redotlabs/server

Length of output: 1393


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java

Repository: redotlabs/server

Length of output: 2055


🏁 Script executed:

# Check if cascade is configured anywhere in JPA annotations
rg -nP --type=java "cascade\s*=|CascadeType" src/main/java/redot/redot_server/domain/site/page/

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java

Repository: redotlabs/server

Length of output: 1962


🏁 Script executed:

cat -n src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java | head -150

Repository: redotlabs/server

Length of output: 8581


🏁 Script executed:

# Search for delete endpoints in controllers
rg -nP --type=java "delete|DELETE|remove|REMOVE" src/main/java/redot/redot_server/domain/cms/site/page/controller/ -B3 -A3

Repository: redotlabs/server

Length of output: 42


🏁 Script executed:

rg -nP --type=java "delete|DELETE|remove|REMOVE" src/main/java/redot/redot_server/domain/site/page/controller/ -B3 -A3

Repository: redotlabs/server

Length of output: 42


Remove CASCADE DELETE or add business-level protection

The database currently has ON DELETE CASCADE on both foreign keys in app_version_pages, but this is not reflected in JPA annotations and no application-level deletion logic exists. This creates a critical data integrity risk: if deletions are ever added to the application layer or performed directly on the database, all related app_version_pages records would be silently deleted without validation, including those associated with PUBLISHED versions.

Since the system manages version lifecycle through status changes (PUBLISHED → PREVIOUS), consider:

  • Changing to ON DELETE RESTRICT to prevent accidental deletions and force explicit handling
  • Adding business-level validation in any future delete operations to prevent removing PUBLISHED resources
  • Documenting the cascade behavior if it is intentional
🤖 Prompt for AI Agents
In src/main/resources/db/migration/V3__create_site_pages.sql around lines 28 to
36, remove the ON DELETE CASCADE on both foreign keys (app_version_id and
app_page_id) and replace it with ON DELETE RESTRICT (or omit cascade) so
database prevents cascading deletions; update any migration comments to document
this change and, if deletion of versions/pages is later required, add
application-level deletion routines that explicitly validate status (e.g.,
prevent deletion of PUBLISHED versions) before deleting related
app_version_pages records.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (8)
src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java (1)

7-19: 에러 코드 enum 구조 전반적으로 잘 정의되어 있습니다.

  • statusCode / exceptionCode / message 3필드 구성과 @Getter, @AllArgsConstructor 조합이 표준적인 패턴이라 유지보수 측면에서 좋습니다.
  • 페이지/버전 도메인별로 3200대 예외 코드를 묶은 것도 나중에 추적하기 편해 보입니다.

선택적으로 고려해볼 만한 점은 두 가지 정도입니다:

  1. HTTP 상태 코드 선택

    • PAGE_PATH_DUPLICATED(Line 14), PUBLISHED_VERSION_ALREADY_EXISTS(Line 15)는 리소스 상태 충돌에 가까워서, 도메인 정책에 따라 400 대신 409(CONFLICT)를 쓰는 것도 한 번쯤 검토해볼 수 있습니다. 지금처럼 400을 유지해도 기능적 문제는 없습니다.
  2. 코드 범위 중복 여부

    • exceptionCode(3200~3205)가 다른 도메인 에러 코드와 겹치지 않는지만 한 번만 전체 프로젝트 기준으로 확인해두시면 좋겠습니다.

현재 상태로도 머지에는 무리가 없어 보입니다.

src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1)

32-34: ManyToOne 관계에 FetchType.LAZY 설정을 고려하세요.

@ManyToOne의 기본 fetch 타입은 EAGER입니다. AppVersion 목록을 조회할 때 N+1 쿼리 문제가 발생할 수 있으므로 FetchType.LAZY를 명시적으로 설정하는 것을 권장합니다.

-    @ManyToOne
+    @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "redot_app_id", nullable = false)
     private RedotApp redotApp;
src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (2)

33-39: ManyToOne 관계에 FetchType.LAZY 설정을 고려하세요.

두 개의 @ManyToOne 관계 모두 기본값인 EAGER fetch를 사용합니다. AppVersionPage 목록 조회 시 불필요한 추가 쿼리가 발생할 수 있으므로 FetchType.LAZY를 권장합니다.

-    @ManyToOne
+    @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "app_version_id", nullable = false)
     private AppVersion appVersion;

-    @ManyToOne
+    @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "app_page_id", nullable = false)
     private AppPage appPage;

44-46: create() 메서드에서 null 체크 누락.

appPage 또는 appVersion이 null인 경우 NPE가 발생할 수 있습니다. 방어적 코딩을 위해 null 체크를 추가하거나, 호출 측에서 null이 전달되지 않음을 보장해야 합니다.

     public static AppVersionPage create(AppVersion appVersion, AppPage appPage) {
+        Objects.requireNonNull(appVersion, "appVersion must not be null");
+        Objects.requireNonNull(appPage, "appPage must not be null");
         return new AppVersionPage(null, appVersion, appPage, appPage.getPath());
     }
src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (4)

60-67: 두 번의 DB 조회를 하나로 합칠 수 있습니다.

existsBy... 체크 후 findById를 별도로 호출하고 있습니다. 이 두 쿼리를 하나로 합쳐 성능을 개선할 수 있습니다. 예를 들어, appVersionPageRepository에서 직접 AppPage를 조인하여 가져오는 쿼리를 사용할 수 있습니다.


130-153: 중복된 페이지 ID 처리 누락.

retained 리스트에 동일한 페이지 ID가 여러 번 포함될 경우, 첫 번째는 성공하고 두 번째에서 PAGE_PATH_DUPLICATED 에러가 발생합니다. 이는 사용자에게 혼란을 줄 수 있으므로, 중복 ID를 먼저 검증하거나 더 명확한 에러 메시지를 제공하는 것이 좋습니다.

+        Set<Long> uniqueIds = new HashSet<>(ids);
+        if (uniqueIds.size() != ids.size()) {
+            throw new CMSSitePageException(CMSSitePageErrorCode.DUPLICATE_PAGE_ID_IN_REQUEST);
+        }
+
         Map<Long, AppPage> pagesById = appPageRepository.findAllById(ids).stream()
                 .collect(Collectors.toMap(AppPage::getId, page -> page));

156-177: 루프 내 개별 저장 대신 배치 저장을 권장합니다.

AppPage를 루프 내에서 개별적으로 저장하고 있습니다. saveAll을 사용한 배치 저장이 더 효율적입니다. 다만, path 중복 검증을 위해 현재 구조가 필요하다면 유지해도 됩니다.


194-198: DataIntegrityViolationException을 일괄 처리하면 다른 제약조건 위반을 놓칠 수 있습니다.

DataIntegrityViolationException을 잡아 PAGE_PATH_DUPLICATED로 변환하고 있는데, 다른 제약조건(예: null 제약, 외래키 제약) 위반도 같은 에러로 처리됩니다. 예외 메시지를 로깅하거나 제약조건 이름을 확인하는 것을 고려하세요.

         try {
             appVersionPageRepository.saveAll(versionPages);
         } catch (DataIntegrityViolationException e) {
+            // Log for debugging - could be path duplicate or other constraint
+            log.warn("Failed to save version pages: {}", e.getMessage());
             throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED);
         }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5832618 and 6f14bd2.

📒 Files selected for processing (5)
  • src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java (1 hunks)
  • src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1 hunks)
  • src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1 hunks)
  • src/main/resources/db/migration/V3__create_site_pages.sql (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/resources/db/migration/V3__create_site_pages.sql
🧰 Additional context used
🧬 Code graph analysis (3)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (2)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (1)
  • Entity (19-47)
src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java (1)
  • Entity (19-64)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java (2)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1)
  • Entity (21-53)
src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java (1)
  • Entity (19-64)
src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (2)
src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java (1)
  • CMSSitePageException (5-9)
src/main/java/redot/redot_server/domain/redot/app/exception/RedotAppException.java (1)
  • RedotAppException (5-9)
🔇 Additional comments (4)
src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java (1)

50-52: 상태 변경 시 유효성 검증 부재.

changeStatus() 메서드가 모든 상태 전환을 허용합니다. 예를 들어 PREVIOUS → PUBLISHED와 같은 의도하지 않은 전환이 가능합니다. 서비스 레이어에서 검증하고 있다면 문제없지만, 도메인 로직으로 유효한 상태 전환만 허용하는 것이 더 안전합니다.

src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java (3)

49-58: LGTM!

버전 목록 조회 시 페이지 요약을 배치로 가져와 N+1 쿼리 문제를 방지하는 좋은 패턴입니다.


207-223: LGTM!

여러 버전의 페이지 요약을 배치로 가져와 그룹핑하는 효율적인 구현입니다.


86-103: No explicit fix needed—the code correctly relies on JPA dirty checking within a transactional context.

The changeStatus() call on line 92 is automatically persisted by JPA's dirty checking because createVersion() executes within the @Transactional boundary of createAppVersion(). The double-save pattern (creating with DRAFT status, then changing to PUBLISHED) is necessary because of the partial unique index on (redot_app_id) WHERE status = 'PUBLISHED' defined in the migrations—it prevents multiple published versions per app. Demoting the existing published version to PREVIOUS before creating the new version ensures the constraint is respected.

Likely an incorrect or invalid review comment.

@eraser502 eraser502 merged commit 987eb33 into develop Dec 8, 2025
1 check passed
@coderabbitai coderabbitai bot mentioned this pull request Jan 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature / CMS BUILDER & SITE PUBLIC API 설계 정리 및 구현

2 participants