Skip to content

Conversation

@jjaeroong
Copy link
Contributor

@jjaeroong jjaeroong commented Dec 23, 2025

🔀 변경 내용

  • 학생회 게시글(행사/제휴) 작성·조회 로직을 개선하고, 카테고리별 날짜/시간 규칙을 명확히 분리했습니다.
  • Presigned URL 기반 이미지 업로드 흐름을 정리하고, 게시글 수정 시 불필요한 이미지가 정리되도록 개선했습니다.
  • 게시글 조회 시 작성자 소속 풀네임 반환 및 N+1 문제를 해결했습니다.
  • Swagger 문서에 카테고리별 요청/응답 예시와 비즈니스 규칙을 상세히 명시했습니다.

✅ 작업 항목

  • StudentCouncil 엔티티에 작성자 소속 풀네임(getFullCouncilName()) 반환 로직 추가
  • EVENT / PARTNERSHIP 카테고리별 날짜·시간 규칙 분리 및 검증 로직 구현
  • 제휴 게시글 날짜/시간 정규화 처리 (시작 00:00, 종료 23:59:59)
  • Presigned URL 기반 이미지 업로드 구조 정리 (URL만 저장)
  • 게시글 수정 시 사용되지 않는 이미지 Object Storage에서 삭제 처리
  • 테스트 완료 (게시글 작성/조회/수정 정상 동작 확인)

📸 스크린샷 (선택)

  • 학생회/일반 학생 로그인 시 권한 검증
  1. 일반 학생 로그인 시 학생회 전용 api 접근 제한
image
  1. 학생회 로그인 시, 접근 성공
image
  • 제휴/행사 게시글 조회/생성/수정/삭제
image image image image image
  • Presigned url API
스크린샷 2025-12-24 010632

📎 참고 이슈

Summary by CodeRabbit

새로운 기능

  • 학생회 게시글 관리 기능 (작성, 조회, 수정, 삭제)
  • 게시글 이미지 업로드 지원 (최대 10개)
  • 행사/파트너십 카테고리 분류
  • 썸네일 아이콘 선택 기능
  • 클라우드 기반 이미지 저장소 통합

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 23, 2025

Walkthrough

학생회 게시글 관리 기능과 OCI Object Storage 연동을 도입합니다. 게시글 및 이미지 엔티티, CRUD 서비스, 이미지 검증 및 저장소 관리, REST API 컨트롤러, OCI 클라우드 저장소 구성, 관련 DTO, 매퍼, 예외 처리를 추가합니다.

Changes

Cohort / File(s) 요약
빌드 및 의존성 관리
build.gradle
OCI Java SDK 플랫폼 BOM, 객체 저장소, 공통 모듈, Jersey3 HTTP 클라이언트 의존성 추가
OCI 인프라 구성
src/main/java/com/campus/campus/global/config/OciConfig.java, src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java, src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java, src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java, src/main/java/com/campus/campus/global/oci/exception/*.java (4개)
OCI 객체 저장소 클라이언트 초기화, 서명된 URL 생성/삭제, 객체 저장소 유틸리티 메서드, OCI 특화 에러 코드 및 예외 클래스
학생회 게시글 도메인 엔티티
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java, PostImage.java, PostCategory.java, ThumbnailIcon.java
게시글 엔티티(작성자, 카테고리, 제목, 내용, 장소, 날짜 필드 및 update/isEvent/isWrittenBy 메서드), 이미지 엔티티, 카테고리별 검증 로직(EVENT/PARTNERSHIP 구분), 썸네일 아이콘 enum
학생회 게시글 저장소
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java, PostImageRepository.java
페이지네이션 쿼리, JOIN FETCH를 통한 즉시 로딩 쿼리(작성자 및 관련 엔티티), 이미지 조회/삭제 쿼리
학생회 게시글 애플리케이션 계층
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java, StudentCouncilPostMapper.java
게시글 CRUD 서비스(이미지 제한 10개 검증, 썸네일 필수 확인, 카테고리별 날짜 정규화, OCI 저장소 이미지 관리), DTO 매핑 로직(목록/상세/생성 변환)
학생회 게시글 예외 처리
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java, PostNotFoundException.java, NotPostWriterException.java, ThumbnailRequiredException.java, EventStartDateTimeRequiredException.java, EventEndDateTimeNotAllowedException.java, PartnershipDateRequiredException.java, PostImageLimitExceededException.java
게시글 특화 에러 코드(POST_IMAGE_LIMIT_EXCEEDED 포함) 및 8개 커스텀 예외 클래스
학생회 게시글 API
src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java, PostResponseCode.java
POST/GET/PATCH/DELETE 엔드포인트(페이지네이션, 카테고리 필터, 권한 검증), REST 응답 코드(생성/조회/목록/수정/삭제/이미지 완료)
학생회 게시글 DTO
src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java, src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostResponseDto.java, PostListItemResponseDto.java, NormalizedDateTime.java
요청/응답 DTO(검증 어노테이션, JSON 포맷팅), 정규화된 날짜 시간 DTO
서명된 URL(Presigned URL) API
src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java, PresignedUrlResponseCode.java, src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java, PresignedUrlResponseDto.java
일반 업로드/게시글 이미지 업로드용 서명된 URL 생성 엔드포인트, MIME 타입 검증 및 확장자 해석, 응답 코드
보안 및 공통 설정
src/main/java/com/campus/campus/global/config/SecurityConfig.java, src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java
@EnableMethodSecurity(prePostEnabled=true) 활성화, StudentCouncilPrincipal 처리 추가
학생회 도메인
src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java, src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java
getFullCouncilName() 메서드 추가(학교/단과대/전공 조합), findByIdWithDetails() 쿼리 추가
학생회 서비스
src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java
내부 CouncilScope 레코드 위치 변경 및 import 정렬(기능 변화 없음)
설정 파일
src/main/resources/application-dev.yml, application-local.yml, application-prod.yml, src/test/resources/application-test.yml
OCI 자격증명 및 메타데이터(tenancy-ocid, user-ocid, fingerprint, private-key, namespace, bucket-name, region) 추가
테스트 설정
src/test/java/com/campus/campus/CampusApplicationTests.java
ObjectStorage 모킹 설정을 위한 @MockitoBean 필드 추가

Sequence Diagram

sequenceDiagram
    autonumber
    participant Client
    participant Controller as StudentCouncilPostController
    participant Service as StudentCouncilPostService
    participant PostRepo as StudentCouncilPostRepository
    participant ImageRepo as PostImageRepository
    participant OCI as PresignedUrlService
    participant Storage as OCI ObjectStorage

    rect rgb(200, 220, 240)
    Note over Client,Storage: 게시글 생성 플로우
    Client->>Controller: POST /posts (PostRequestDto)
    activate Controller
    Controller->>Service: create(councilId, dto)
    activate Service
    
    Service->>Service: 이미지 10개 제한 검증
    Service->>PostRepo: 작성자 상세 정보 조회
    activate PostRepo
    PostRepo-->>Service: StudentCouncil (with details)
    deactivate PostRepo
    
    Service->>Service: 썸네일 필수 확인
    Service->>Service: 카테고리별 날짜 정규화<br/>(EVENT/PARTNERSHIP 검증)
    
    Service->>PostRepo: 게시글 저장
    activate PostRepo
    PostRepo-->>Service: StudentCouncilPost (persisted)
    deactivate PostRepo
    
    Service->>ImageRepo: 이미지 다중 저장
    activate ImageRepo
    ImageRepo-->>Service: PostImage list
    deactivate ImageRepo
    
    Service->>Service: 이미지 URL 조회
    Service-->>Controller: PostResponseDto
    deactivate Service
    Controller-->>Client: CommonResponse<PostResponseDto>
    deactivate Controller
    end

    rect rgb(220, 240, 200)
    Note over Client,Storage: 게시글 수정 플로우
    Client->>Controller: PATCH /posts/{postId} (PostRequestDto)
    activate Controller
    Controller->>Service: update(councilId, postId, dto)
    activate Service
    
    Service->>Service: 이미지 10개 제한 검증
    Service->>PostRepo: 게시글 상세 조회
    activate PostRepo
    PostRepo-->>Service: StudentCouncilPost (full)
    deactivate PostRepo
    
    Service->>Service: 작성자 검증
    Service->>Service: 썸네일 필수 확인
    Service->>Service: 카테고리별 날짜 정규화
    
    Service->>PostRepo: 게시글 필드 업데이트
    Service->>ImageRepo: 기존 이미지 삭제
    activate ImageRepo
    ImageRepo-->>Service: 삭제 완료
    deactivate ImageRepo
    
    Service->>ImageRepo: 신규 이미지 저장
    activate ImageRepo
    ImageRepo-->>Service: PostImage list
    deactivate ImageRepo
    
    Service->>OCI: 미사용 이미지 삭제
    activate OCI
    OCI->>Storage: deleteObject
    Storage-->>OCI: 삭제 완료
    deactivate OCI
    
    Service-->>Controller: PostResponseDto
    deactivate Service
    Controller-->>Client: CommonResponse<PostResponseDto>
    deactivate Controller
    end

    rect rgb(240, 220, 200)
    Note over Client,Storage: 게시글 삭제 플로우
    Client->>Controller: DELETE /posts/{postId}
    activate Controller
    Controller->>Service: delete(councilId, postId)
    activate Service
    
    Service->>PostRepo: 게시글 상세 조회
    activate PostRepo
    PostRepo-->>Service: StudentCouncilPost (with writer)
    deactivate PostRepo
    
    Service->>Service: 작성자 검증
    Service->>ImageRepo: 모든 이미지 조회
    activate ImageRepo
    ImageRepo-->>Service: PostImage list
    deactivate ImageRepo
    
    Service->>ImageRepo: 게시글 관련 이미지 삭제
    activate ImageRepo
    ImageRepo-->>Service: 삭제 완료
    deactivate ImageRepo
    
    Service->>PostRepo: 게시글 삭제
    activate PostRepo
    PostRepo-->>Service: 삭제 완료
    deactivate PostRepo
    
    Service->>OCI: 각 이미지 저장소에서 삭제
    activate OCI
    OCI->>Storage: deleteObject (thumbnail)
    OCI->>Storage: deleteObject (body images)
    Storage-->>OCI: 삭제 완료
    deactivate OCI
    
    Service-->>Controller: void
    deactivate Service
    Controller-->>Client: CommonResponse<Void>
    deactivate Controller
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

이 PR은 다음 이유로 복잡한 리뷰가 필요합니다:

  • 다층 아키텍처: 도메인 엔티티, 저장소, 서비스, 매퍼, 컨트롤러, DTO 등 여러 계층의 변화
  • 복잡한 비즈니스 로직: 이미지 제한 검증(최대 10개), 카테고리별 날짜 정규화, 썸네일 필수 확인, 미사용 이미지 정리
  • 외부 서비스 통합: OCI Object Storage 연동, 서명된 URL 생성/삭제, 보안 키 관리
  • 관계 설정: StudentCouncil↔StudentCouncilPost(다대일) 및 StudentCouncilPost↔PostImage(일대다) 매핑
  • 예외 처리: 8개의 도메인 특화 예외 클래스와 4개의 OCI 특화 예외 클래스
  • JPQL 쿼리: JOIN FETCH를 통한 즉시 로딩 최적화
  • 설정 복잡성: OCI 자격증명 관리, PEM 키 정규화, 파일 권한 설정

Possibly related PRs

Suggested reviewers

  • 1224kang

Poem

🐰 구름 저장소를 향해 게시글이 춤을 추네,
OCI의 손을 잡고 이미지가 날아가고,
서명된 URL로 안전하게 보관되며,
썬네일과 내용이 어우러져,
학생회의 목소리가 더 크게 울려 퍼진다! 📮✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.99% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경 사항인 학생회 제휴/행사 게시글 CRUD 기능 구현을 명확하게 요약하고 있으며, 간결하고 구체적입니다.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#10

📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dbe58c2 and b71f9d1.

📒 Files selected for processing (1)
  • src/test/resources/application-test.yml
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-18T09:42:14.128Z
Learnt from: 1winhyun
Repo: our-campUS/our-campUS-BE PR: 6
File: src/test/resources/application-test.yml:15-16
Timestamp: 2025-11-18T09:42:14.128Z
Learning: In the our-campUS-BE project, the test configuration file (src/test/resources/application-test.yml) uses hardcoded dummy values for OAuth and JWT secrets, as these are arbitrary test values and not real credentials.

Applied to files:

  • src/test/resources/application-test.yml
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
src/test/resources/application-test.yml (1)

43-52: oci.enabled 속성은 실제로 사용되지 않습니다.

OciConfig는 oci.enabled 플래그를 확인하지 않으며, ObjectStorage 빈은 항상 초기화를 시도합니다. 테스트 환경에서 OCI API 호출이 방지되는 실제 메커니즘은 @MockitoBean으로 ObjectStorage 빈을 모킹하는 것입니다(CampusApplicationTests.java 참조). application-test.yml의 enabled: false 설정은 작동하지 않습니다.

추가로, 더미 PEM 키 형식("dummy")은 OciConfig.normalizePem() 메서드의 PEM 헤더/푸터 검증을 통과하지 못해 빈 초기화가 실패할 수 있습니다.

Likely an incorrect or invalid review comment.


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: 10

♻️ Duplicate comments (1)
src/main/resources/application-dev.yml (1)

48-56: OCI 설정의 passphrase 필드가 비어있는지 확인이 필요합니다.

production 환경과 동일하게, Line 55의 passphrase 필드가 비어있습니다. private-key에 패스프레이즈가 설정되어 있는지 확인이 필요합니다.

🧹 Nitpick comments (12)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java (1)

12-21: 에러 코드 번호 순서에서 2404가 누락되었습니다.

에러 코드가 2401, 2402, 2403, 2405, 2406, 2407로 정의되어 있어 2404가 빠져있습니다. 향후 추가될 에러 코드를 위해 의도적으로 예약해둔 것이라면 주석으로 표시하는 것이 좋고, 단순 누락이라면 순서를 재정렬하거나 2404를 추가하는 것을 권장합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java (1)

1-11: 예외 클래스 구현이 적절합니다.

작성자 권한 검증을 위한 예외 클래스가 올바르게 구현되어 있으며, FORBIDDEN(403) 상태 코드를 사용하는 것이 적절합니다. 참고로 파일 끝의 불필요한 빈 줄(lines 10-11)은 제거해도 좋습니다.

src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java (1)

20-26: 콘텐츠 타입 비교 시 대소문자 처리 및 확장성 고려

현재 contentType 비교가 대소문자를 구분합니다. MIME 타입은 대소문자를 구분하지 않으므로 IMAGE/PNGImage/Png 같은 입력이 거부될 수 있습니다. 또한 GIF, WebP 등 다른 이미지 형식이 필요할 수 있습니다.

🔎 대소문자 무시 처리 제안
 @JsonIgnore
 @Schema(hidden = true)
 public String resolveExtension() {
-    return switch (contentType) {
+    return switch (contentType.toLowerCase()) {
         case "image/png" -> ".png";
         case "image/jpeg", "image/jpg" -> ".jpg";
+        case "image/gif" -> ".gif";
+        case "image/webp" -> ".webp";
         default -> throw new InvalidImageContentTypeException(contentType);
     };
 }
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java (1)

18-35: 이미지 엔티티의 감사 필드 필요성 재검토

PostImageBaseEntity를 확장하지 않아 createdAt, updatedAt 감사 필드가 없습니다. 이미지 업로드 시점 추적이 필요하다면 BaseEntity를 확장하는 것을 권장합니다. 그렇지 않다면 현재 설계가 적절합니다.

StudentCouncilPost 삭제 시 연관된 PostImage 삭제는 StudentCouncilPostService.update() (164줄)에서 postImageRepository.deleteByPost(post) 호출로 이미 명시적으로 처리되고 있으므로 문제없습니다.

src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java (1)

4-5: 사용되지 않는 Lombok import 제거 권장

Java record는 생성자와 getter를 자동으로 제공하므로 lombok.AllArgsConstructorlombok.Data import가 필요하지 않습니다.

🔎 제안된 수정
 package com.campus.campus.global.oci.application.dto.response;

 import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.AllArgsConstructor;
-import lombok.Data;

 public record PresignedUrlResponseDto(
src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java (1)

11-26: isWriter 판별 로직 불일치 및 잠재적 NPE

toListItem에서는 직접 ID 비교(post.getWriter().getId().equals(currentUserId))를 사용하고, toDetail에서는 post.isWrittenBy(currentUserId)를 사용하여 일관성이 없습니다. 또한 post.getWriter()가 null인 경우 NPE가 발생할 수 있습니다.

🔎 제안된 수정
     public static PostListItemResponseDto toListItem(
             StudentCouncilPost post,
             Long currentUserId
     ) {
         return new PostListItemResponseDto(
                 post.getId(),
                 post.getCategory(),
                 post.getTitle(),
                 post.getPlace(),
                 post.getEndDateTime(),
                 post.getThumbnailImageUrl(),
                 post.getThumbnailIcon(),
-                currentUserId != null && post.getWriter().getId().equals(currentUserId)
+                post.isWrittenBy(currentUserId)
         );

     }
src/main/java/com/campus/campus/global/oci/OciPresignedUrlService.java (1)

4-6: 사용되지 않는 import 제거 필요

OciImageMoveFailException, OciObjectCopyFailException, CopyObjectDetails, CopyObjectRequest는 현재 코드에서 사용되지 않습니다.

🔎 제안된 수정
 package com.campus.campus.global.oci;

 import com.campus.campus.global.config.OciConfig;
-import com.campus.campus.global.oci.exception.OciImageMoveFailException;
-import com.campus.campus.global.oci.exception.OciObjectCopyFailException;
 import com.campus.campus.global.oci.exception.OciObjectDeleteFailException;
 import com.campus.campus.global.oci.exception.OciPresignedUrlCreateFailException;
 import com.oracle.bmc.model.BmcException;
 import com.oracle.bmc.objectstorage.ObjectStorage;
-import com.oracle.bmc.objectstorage.model.CopyObjectDetails;
 import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails;
-import com.oracle.bmc.objectstorage.requests.CopyObjectRequest;
 import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest;

Also applies to: 10-12

src/main/java/com/campus/campus/global/oci/presentation/OciController.java (1)

29-63: 두 엔드포인트의 중복 로직 통합 고려

createPresignedUrlcreatePostImagePresignedUrl이 유사한 로직을 사용합니다. 경로 prefix를 파라미터로 받는 private 메서드로 추출하면 코드 중복을 줄일 수 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostRequestDto.java (1)

12-39: 필드에 @Schema 설명 추가 권장

코드베이스의 다른 DTO들(EmailVerificationRequest, StudentCouncilSignUpRequest 등)은 각 필드에 @Schema(description = "...") 어노테이션을 사용하여 API 문서화를 강화합니다. Swagger 문서의 일관성을 위해 동일한 패턴을 적용하는 것을 권장합니다.

🔎 예시
public record PostRequestDto(
        @Schema(description = "게시글 카테고리", example = "EVENT")
        @NotNull
        PostCategory category,

        @Schema(description = "게시글 제목", example = "2025 봄 축제 안내")
        @NotBlank
        String title,
        // ... 나머지 필드들
) {}
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (2)

38-47: 클래스 레벨 @Transactional과 메서드 레벨 중복

클래스에 @Transactional이 이미 선언되어 있으므로, create 메서드의 @Transactional은 중복입니다. 단, readOnly = true가 필요한 조회 메서드(findById, findAll)에만 메서드 레벨 어노테이션이 필요합니다.

🔎 제안된 수정
-    @Transactional
     public PostResponseDto create(Long councilId, PostRequestDto dto) {

마찬가지로 Line 116의 delete와 Line 131의 update 메서드도 동일합니다.


189-196: 이미지 비교 시 List.contains() 성능 고려

newUrls.contains(url) 호출은 O(n) 복잡도를 가지며, 이미지가 많을 경우 O(n*m) 성능 저하가 발생할 수 있습니다. 이미지 수가 적다면 문제없지만, 확장성을 고려하면 Set으로 변환하는 것이 좋습니다.

🔎 제안된 수정
-        List<String> newUrls = dto.imageUrls() == null
-                ? List.of()
-                : dto.imageUrls();
+        Set<String> newUrls = dto.imageUrls() == null
+                ? Set.of()
+                : new HashSet<>(dto.imageUrls());

         oldImages.stream()
                 .map(PostImage::getImageUrl)
                 .filter(url -> !newUrls.contains(url))
                 .forEach(this::deleteImageSafely);
src/main/java/com/campus/campus/global/config/OciConfig.java (1)

65-67: 불필요한 @Scope("singleton") 어노테이션입니다.

Spring @Bean의 기본 스코프가 singleton이므로 명시적 선언이 불필요합니다.

🔎 제안하는 수정
 @Bean
-@Scope("singleton")
 public ObjectStorage objectStorage() {
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4069880 and fe1a223.

📒 Files selected for processing (39)
  • build.gradle
  • src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostListItemResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostRequestDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java
  • src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java
  • src/main/java/com/campus/campus/global/config/OciConfig.java
  • src/main/java/com/campus/campus/global/config/SecurityConfig.java
  • src/main/java/com/campus/campus/global/oci/OciPresignedUrlService.java
  • src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
  • src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java
  • src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java
  • src/main/java/com/campus/campus/global/oci/exception/OciImageMoveFailException.java
  • src/main/java/com/campus/campus/global/oci/exception/OciObjectCopyFailException.java
  • src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java
  • src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java
  • src/main/java/com/campus/campus/global/oci/presentation/OciController.java
  • src/main/java/com/campus/campus/global/oci/presentation/OciResponseCode.java
  • src/main/resources/application-dev.yml
  • src/main/resources/application-local.yml
  • src/main/resources/application-prod.yml
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-18T09:42:14.128Z
Learnt from: 1winhyun
Repo: our-campUS/our-campUS-BE PR: 6
File: src/test/resources/application-test.yml:15-16
Timestamp: 2025-11-18T09:42:14.128Z
Learning: In the our-campUS-BE project, the test configuration file (src/test/resources/application-test.yml) uses hardcoded dummy values for OAuth and JWT secrets, as these are arbitrary test values and not real credentials.

Applied to files:

  • src/main/resources/application-dev.yml
  • src/main/resources/application-local.yml
  • src/main/resources/application-prod.yml
🧬 Code graph analysis (16)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java (9)
src/main/java/com/campus/campus/domain/council/application/exception/EmailAlreadyExistsException.java (1)
  • EmailAlreadyExistsException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/InvalidCouncilScopeException.java (1)
  • InvalidCouncilScopeException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/CouncilIdAndVerifiedEmailInvalidException.java (1)
  • CouncilIdAndVerifiedEmailInvalidException (5-9)
src/main/java/com/campus/campus/global/util/jwt/exception/ExpireJwtException.java (2)
  • ExpireJwtException (5-9)
  • ExpireJwtException (6-8)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolCollegeNotSameException.java (1)
  • SchoolCollegeNotSameException (6-8)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (1)
  • StudentCouncilNotFoundException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolMajorNotSameException.java (1)
  • SchoolMajorNotSameException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/CollegeNotFoundException.java (1)
  • CollegeNotFoundException (5-9)
src/main/java/com/campus/campus/global/common/exception/ApplicationException.java (1)
  • ApplicationException (9-12)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java (1)
src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java (1)
  • CouncilType (3-7)
src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java (1)
src/main/java/com/campus/campus/global/util/jwt/StudentCouncilPrincipal.java (8)
  • Override (69-72)
  • Override (74-77)
  • Getter (15-78)
  • Override (59-62)
  • StudentCouncilPrincipal (22-32)
  • from (34-42)
  • Override (44-47)
  • Override (64-67)
src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java (1)
src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java (1)
  • InvalidImageContentTypeException (3-7)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java (4)
src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java (4)
  • StudentCouncilRepository (9-17)
  • findByEmail (12-12)
  • existsByEmail (16-16)
  • existsByLoginId (14-14)
src/main/java/com/campus/campus/domain/school/domain/repository/MajorRepository.java (1)
  • MajorRepository (9-11)
src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java (1)
  • UserRepository (9-11)
src/main/java/com/campus/campus/domain/mail/domain/repository/EmailVerificationRepository.java (1)
  • EmailVerificationRepository (10-16)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java (8)
src/main/java/com/campus/campus/domain/council/application/exception/EmailAlreadyExistsException.java (2)
  • EmailAlreadyExistsException (5-9)
  • EmailAlreadyExistsException (6-8)
src/main/java/com/campus/campus/domain/council/application/exception/InvalidCouncilScopeException.java (1)
  • InvalidCouncilScopeException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (1)
  • StudentCouncilNotFoundException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/CouncilIdAndVerifiedEmailInvalidException.java (1)
  • CouncilIdAndVerifiedEmailInvalidException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolMajorNotSameException.java (1)
  • SchoolMajorNotSameException (5-9)
src/main/java/com/campus/campus/global/util/jwt/exception/ExpireJwtException.java (1)
  • ExpireJwtException (5-9)
src/main/java/com/campus/campus/global/common/exception/ApplicationException.java (1)
  • ApplicationException (9-12)
src/main/java/com/campus/campus/domain/school/application/exception/CollegeNotFoundException.java (1)
  • CollegeNotFoundException (5-9)
src/main/java/com/campus/campus/global/oci/OciPresignedUrlService.java (4)
src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java (1)
  • OciObjectDeleteFailException (5-9)
src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java (1)
  • OciPresignedUrlCreateFailException (5-10)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)
  • Slf4j (35-286)
src/main/java/com/campus/campus/global/config/OciConfig.java (1)
  • Slf4j (25-266)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java (5)
src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java (4)
  • StudentCouncilRepository (9-17)
  • existsByLoginId (14-14)
  • existsByEmail (16-16)
  • findByLoginId (10-10)
src/main/java/com/campus/campus/domain/school/domain/repository/CollegeRepository.java (1)
  • CollegeRepository (9-11)
src/main/java/com/campus/campus/domain/school/domain/repository/MajorRepository.java (1)
  • MajorRepository (9-11)
src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java (1)
  • UserRepository (9-11)
src/main/java/com/campus/campus/domain/mail/domain/repository/EmailVerificationRepository.java (1)
  • EmailVerificationRepository (10-16)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java (2)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java (1)
  • Entity (23-96)
src/main/java/com/campus/campus/global/entity/BaseEntity.java (1)
  • Getter (16-29)
src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)
src/main/java/com/campus/campus/domain/user/presentation/UserController.java (1)
  • RestController (18-32)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (8)
src/main/java/com/campus/campus/domain/council/application/exception/EmailAlreadyExistsException.java (2)
  • EmailAlreadyExistsException (5-9)
  • EmailAlreadyExistsException (6-8)
src/main/java/com/campus/campus/domain/council/application/exception/InvalidCouncilScopeException.java (1)
  • InvalidCouncilScopeException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/CouncilIdAndVerifiedEmailInvalidException.java (1)
  • CouncilIdAndVerifiedEmailInvalidException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolCollegeNotSameException.java (1)
  • SchoolCollegeNotSameException (6-8)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (1)
  • StudentCouncilNotFoundException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolMajorNotSameException.java (1)
  • SchoolMajorNotSameException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/SignupEmailNotFoundException.java (2)
  • SignupEmailNotFoundException (6-8)
  • SignupEmailNotFoundException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/CollegeNotFoundException.java (1)
  • CollegeNotFoundException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java (5)
src/main/java/com/campus/campus/domain/school/application/exception/CollegeNotFoundException.java (2)
  • CollegeNotFoundException (5-9)
  • CollegeNotFoundException (6-8)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (2)
  • StudentCouncilNotFoundException (5-9)
  • StudentCouncilNotFoundException (6-8)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolNotFoundException.java (2)
  • SchoolNotFoundException (5-9)
  • SchoolNotFoundException (6-8)
src/main/java/com/campus/campus/domain/user/application/exception/UserNotFoundException.java (1)
  • UserNotFoundException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/MajorNotFoundException.java (1)
  • MajorNotFoundException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostRequestDto.java (10)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilSignUpRequest.java (1)
  • StudentCouncilSignUpRequest (11-40)
src/main/java/com/campus/campus/domain/mail/application/dto/request/EmailVerificationRequest.java (1)
  • EmailVerificationRequest (6-11)
src/main/java/com/campus/campus/domain/user/application/dto/request/UserProfileRequest.java (1)
  • UserProfileRequest (5-12)
src/main/java/com/campus/campus/global/util/jwt/application/dto/request/TokenReissueRequest.java (1)
  • TokenReissueRequest (6-11)
src/main/java/com/campus/campus/domain/school/application/dto/response/CollegeFindResponse.java (1)
  • CollegeFindResponse (5-12)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilLoginRequest.java (1)
  • StudentCouncilLoginRequest (7-16)
src/main/java/com/campus/campus/domain/school/application/dto/response/MajorFindResponse.java (1)
  • MajorFindResponse (5-12)
src/main/java/com/campus/campus/global/auth/application/dto/KakaoTokenResponse.java (1)
  • KakaoTokenResponse (5-13)
src/main/java/com/campus/campus/domain/user/application/dto/response/UserFirstProfileResponse.java (1)
  • Builder (6-17)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilFindPasswordRequest.java (1)
  • StudentCouncilFindPasswordRequest (9-24)
src/main/java/com/campus/campus/global/config/OciConfig.java (2)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)
  • Slf4j (35-286)
src/main/java/com/campus/campus/global/oci/OciPresignedUrlService.java (1)
  • Slf4j (23-111)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java (2)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java (1)
  • Entity (18-36)
src/main/java/com/campus/campus/global/entity/BaseEntity.java (1)
  • Getter (16-29)
src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java (10)
src/main/java/com/campus/campus/domain/school/application/dto/response/CollegeFindResponse.java (1)
  • CollegeFindResponse (5-12)
src/main/java/com/campus/campus/global/auth/application/dto/KakaoTokenResponse.java (1)
  • KakaoTokenResponse (5-13)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilSignUpRequest.java (1)
  • StudentCouncilSignUpRequest (11-40)
src/main/java/com/campus/campus/domain/school/application/dto/response/MajorFindResponse.java (1)
  • MajorFindResponse (5-12)
src/main/java/com/campus/campus/domain/user/application/dto/response/UserFirstProfileResponse.java (1)
  • Builder (6-17)
src/main/java/com/campus/campus/global/util/jwt/application/dto/response/TokenReissueResponse.java (1)
  • TokenReissueResponse (5-12)
src/main/java/com/campus/campus/domain/mail/application/dto/request/EmailVerificationRequest.java (1)
  • EmailVerificationRequest (6-11)
src/main/java/com/campus/campus/domain/user/application/dto/request/UserProfileRequest.java (1)
  • UserProfileRequest (5-12)
src/main/java/com/campus/campus/global/auth/application/dto/KakaoUserResponse.java (1)
  • KakaoUserResponse (5-19)
src/main/java/com/campus/campus/global/util/jwt/application/dto/request/TokenReissueRequest.java (1)
  • TokenReissueRequest (6-11)
🪛 Gitleaks (8.30.0)
src/main/java/com/campus/campus/global/config/OciConfig.java

[high] 29-30: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.

(private-key)

🔇 Additional comments (28)
src/main/java/com/campus/campus/global/config/SecurityConfig.java (1)

25-25: 메서드 수준 보안이 올바르게 활성화되었습니다.

prePostEnabled = true 설정으로 @PreAuthorize@PostAuthorize 어노테이션을 사용할 수 있게 되었습니다. StudentCouncilPostController에서 @PreAuthorize("hasRole('COUNCIL')") 어노테이션이 적절히 적용되어 학생회 권한 검증이 구현되었습니다.

src/main/resources/application-prod.yml (1)

48-56: OCI 설정의 passphrase 필드가 비어있는지 확인이 필요합니다.

Line 55의 passphrase 필드가 비어있습니다. OCI private-key에 패스프레이즈가 설정되지 않은 경우라면 문제없지만, 실제로 패스프레이즈가 필요한 경우 런타임 오류가 발생할 수 있습니다. private-key 생성 시 패스프레이즈 사용 여부를 확인하고, 필요한 경우 암호화된 값을 추가해주세요.

src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java (1)

1-7: 구현이 깔끔하고 명확합니다.

예외 클래스가 간결하게 구현되어 있으며, 에러 메시지에 contentType 파라미터를 포함하여 디버깅에 유용합니다.

src/main/java/com/campus/campus/global/oci/presentation/OciResponseCode.java (1)

1-17: ResponseCode 구현이 적절합니다.

표준 HTTP 상태 코드를 사용하고 있으며, 프로젝트의 다른 ResponseCode 구현체들과 일관된 패턴을 따르고 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java (1)

1-22: HTTP 상태 코드와 응답 메시지가 적절하게 정의되어 있습니다.

각 CRUD 작업에 대해 표준 HTTP 상태 코드를 올바르게 사용하고 있으며 (생성: 201, 조회/수정: 200, 삭제: 204), 메시지도 명확합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java (1)

1-9: 프로젝트의 예외 처리 패턴을 올바르게 따르고 있습니다.

ApplicationException을 상속하고 ErrorCode를 사용하는 표준 패턴을 따르고 있어, 기존 코드베이스의 다른 예외 클래스들과 일관성이 유지됩니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java (1)

3-6: LGTM!

게시글 카테고리 enum이 명확하게 정의되어 있으며, 제휴(PARTNERSHIP)와 행사(EVENT) 두 가지 타입을 잘 표현하고 있습니다.

src/main/resources/application-local.yml (1)

48-56: LGTM!

OCI 객체 스토리지 설정이 환경 변수를 통해 안전하게 구성되어 있습니다. passphrase의 기본값 처리(${OCI_PASSPHRASE:})도 적절합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java (1)

3-9: LGTM!

썸네일 아이콘 타입을 명확하게 정의한 enum입니다. 카페, 행사, 공지, 음식, 스포츠 등 다양한 카테고리를 잘 표현하고 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java (1)

5-9: LGTM!

기존 예외 클래스 패턴을 잘 따르고 있으며, 중앙화된 ErrorCode를 사용하여 일관성 있게 구현되었습니다.

src/main/java/com/campus/campus/global/oci/exception/OciImageMoveFailException.java (1)

5-9: LGTM!

OCI 이미지 이동 실패 예외가 일관된 패턴으로 구현되어 있습니다. ApplicationException을 확장하고 중앙화된 ErrorCode를 사용합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java (1)

5-9: LGTM!

게시글을 찾을 수 없을 때의 예외 처리가 코드베이스의 기존 패턴(UserNotFoundException, StudentCouncilNotFoundException 등)과 일관되게 구현되었습니다.

src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java (1)

5-10: LGTM!

Presigned URL 생성 실패 예외가 일관된 패턴으로 구현되어 있습니다. OciPresignedUrlService에서 사용될 때 명확한 오류 신호를 제공합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (1)

5-9: LGTM!

제휴 게시글의 날짜 필수 요구사항을 명확하게 표현하는 예외 클래스입니다. 코드베이스의 기존 예외 패턴과 일관성 있게 구현되었습니다.

src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java (1)

5-9: LGTM!

기존 프로젝트의 예외 클래스 패턴을 일관되게 따르고 있습니다. ApplicationException을 확장하고 ErrorCode를 통해 에러 정보를 전달하는 구조가 적절합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java (1)

5-9: LGTM!

프로젝트 내 다른 도메인 예외 클래스(CollegeNotFoundException, InvalidCouncilScopeException 등)와 동일한 패턴을 따르고 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostListItemResponseDto.java (1)

7-16: LGTM!

게시글 목록 조회용 DTO로 적절한 필드 구성입니다. Java record를 활용하여 간결하게 정의되었습니다.

src/main/java/com/campus/campus/global/oci/exception/OciObjectCopyFailException.java (1)

5-9: LGTM!

OciObjectDeleteFailException과 동일한 패턴으로 구현되어 일관성이 유지됩니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java (1)

8-12: Review comment is incorrect — no changes needed

findAllByPost는 실제로 서비스 레이어에서 2곳에서 활발히 사용 중입니다 (라인 121, 151). 제거하면 기존 코드가 깨집니다.

또한 deleteByPost는 이미 @Transactional 메서드 내에서 호출되고 있습니다 (라인 164, update 메서드).

현재 코드는 적절하게 구성되어 있으며 리팩토링이 필요하지 않습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostResponseDto.java (1)

12-36: LGTM!

@JsonInclude(NON_NULL)을 사용하여 카테고리에 따라 조건부 필드(startDate/endDate vs startDateTime)를 유연하게 처리하는 설계가 적절합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)

237-251: normalizeDateTime에서 null 체크 누락 가능성

validateCategoryDateRule에서 검증 후 호출되지만, normalizeDateTime 내부에서 startend에 대한 null 체크가 없습니다. 방어적 코딩을 위해 null 체크를 추가하거나, 메서드 문서에 non-null 전제조건을 명시하는 것을 권장합니다.

또한 EVENT 카테고리의 경우 end가 null인데, 메서드가 이를 new NormalizedDateTime(start, null)로 처리하므로 현재는 문제가 없습니다.

src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java (1)

1-20: LGTM!

OCI 관련 에러 코드가 일관된 패턴으로 잘 정의되어 있습니다. 에러 코드 시퀀스(6100-6103)와 한국어 메시지가 적절합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)

43-116: LGTM! Swagger 문서화가 잘 되어 있습니다.

카테고리별 날짜/시간 규칙과 요청 예시가 명확하게 문서화되어 있어 API 사용자가 이해하기 쉽습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java (2)

57-78: update() 메서드에서 썸네일 필드를 null로 변경할 수 없습니다.

thumbnailImageUrlthumbnailIcon은 null이 아닐 때만 업데이트됩니다. 이 로직으로 인해 썸네일을 제거하거나 다른 유형으로 전환하려는 경우 의도대로 동작하지 않을 수 있습니다.

서비스 레이어의 validateThumbnail() 검증에서 둘 중 하나는 반드시 존재해야 하므로 의도된 동작일 수 있지만, 예를 들어 아이콘에서 이미지 URL로 전환할 때 기존 아이콘 값이 그대로 남아있을 수 있습니다.


80-95: LGTM!

헬퍼 메서드들이 null-safe하게 잘 구현되어 있습니다. isWrittenBy()에서 writer null 체크가 적절합니다.

src/main/java/com/campus/campus/global/config/OciConfig.java (3)

29-30: 정적 분석 경고 무시 - False Positive

Gitleaks가 이 라인을 Private Key로 플래그했지만, 이는 PEM 형식 검증을 위한 헤더/푸터 상수일 뿐 실제 키 데이터가 아닙니다.


132-153: 작업 디렉토리에 키 파일 저장 시 배포 환경 호환성을 확인하세요.

현재 user.dir(작업 디렉토리)에 키 파일을 저장하고 있습니다. 컨테이너화된 환경이나 읽기 전용 파일시스템에서는 이 접근 방식이 동작하지 않을 수 있습니다.

시스템 임시 디렉토리(System.getProperty("java.io.tmpdir")) 사용을 고려해 보세요.


227-264: LGTM!

@PreDestroy를 통한 리소스 정리와 URL 인코딩 처리가 잘 구현되어 있습니다. 빈 디렉토리 삭제 시도 시 예외를 무시하는 방어적 처리도 적절합니다.

Comment on lines 93 to 113
@Transactional(readOnly = true)
public Page<PostListItemResponseDto> findAll(
PostCategory category,
int page,
int size,
Long currentUserId
) {
Pageable pageable = PageRequest.of(
Math.max(page - 1, 0),
size,
Sort.by(Sort.Direction.DESC, "createdAt")
);

Page<StudentCouncilPost> posts = (category == null)
? postRepository.findAll(pageable)
: postRepository.findAllByCategory(category, pageable);

return posts.map(post ->
StudentCouncilPostMapper.toListItem(post, currentUserId)
);
}
Copy link

@coderabbitai coderabbitai bot Dec 23, 2025

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:

find . -name "StudentCouncilPost.java" -type f

Repository: our-campUS/our-campUS-BE

Length of output: 165


🏁 Script executed:

cat -n ./src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java

Repository: our-campUS/our-campUS-BE

Length of output: 3485


🏁 Script executed:

find . -name "StudentCouncilPostMapper.java" -type f

Repository: our-campUS/our-campUS-BE

Length of output: 176


🏁 Script executed:

cat -n ./src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java

Repository: our-campUS/our-campUS-BE

Length of output: 2513


findAll 조회 시 N+1 쿼리 문제 발생

StudentCouncilPostMapper.toListItem 메서드의 23번 라인에서 post.getWriter().getId()를 호출합니다. writer 필드가 @ManyToOne(fetch = FetchType.LAZY)로 설정되어 있기 때문에, 조회된 각 포스트마다 writer를 로드하는 추가 쿼리가 발생합니다.

권장 해결 방법:

  • postRepository.findAll() 또는 findAllByCategory()에 JOIN FETCH를 사용하여 writer를 함께 로드
  • 예: SELECT p FROM StudentCouncilPost p JOIN FETCH p.writer WHERE ...

Copy link
Member

Choose a reason for hiding this comment

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

@jjaeroong 해당 부분에 대한 확인 한번 부탁드려요!! 코드래빗이 빨간 표시의 리뷰를 남겼다면 문제가 될 가능성이 높아요!!

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment on lines 14 to 21
Page<StudentCouncilPost> findAllByCategory(PostCategory category, Pageable pageable);
@Query("SELECT p FROM StudentCouncilPost p " +
"JOIN FETCH p.writer w " +
"JOIN FETCH w.school s " +
"LEFT JOIN FETCH w.college c " +
"LEFT JOIN FETCH w.major m " +
"WHERE p.id = :postId")
Optional<StudentCouncilPost> findByIdWithFullInfo(@Param("postId") Long postId);
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

🧩 Analysis chain

🏁 Script executed:

rg -n "findAllByCategory" --type=java -B 2 -A 10

Repository: our-campUS/our-campUS-BE

Length of output: 3857


🏁 Script executed:

rg -n "StudentCouncilPostMapper" --type=java -l

Repository: our-campUS/our-campUS-BE

Length of output: 283


🏁 Script executed:

cat -n src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java

Repository: our-campUS/our-campUS-BE

Length of output: 2513


findAllByCategory 메서드에서 N+1 문제가 발생합니다.

findAllByCategory는 FETCH JOIN 없이 정의되어 기본 lazy loading을 사용합니다. 그러나 StudentCouncilPostMapper.toListItem() 메서드가 각 post 객체에서 post.getWriter().getId()를 호출(23행)하므로, 페이지의 모든 결과에 대해 writer를 개별 조회하게 되어 N+1 문제가 발생합니다.

findByIdWithFullInfo의 FETCH JOIN 방식을 findAllByCategory에도 적용하거나, 필요한 관계만 명시적으로 로드하는 커스텀 쿼리를 추가하세요.

🤖 Prompt for AI Agents
In
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java
around lines 14 to 21, findAllByCategory currently uses lazy loading causing an
N+1 when mapper accesses post.getWriter().getId(); replace it with a repository
method that loads required associations (e.g., add an @Query with JOIN FETCH
p.writer w JOIN FETCH w.school s LEFT JOIN FETCH w.college c LEFT JOIN FETCH
w.major m WHERE p.category = :category and keep Pageable) and provide a separate
countQuery (SELECT count(p) FROM StudentCouncilPost p WHERE p.category =
:category) to support pagination, or alternatively annotate the existing method
with an appropriate @EntityGraph that includes writer and its
school/college/major to eager-load those associations.

Comment on lines +60 to +64
if (principal instanceof StudentCouncilPrincipal councilPrincipal) {
Long id = councilPrincipal.getCouncilId();
if (id == null && required) throw new UnAuthorizedException();
return 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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java

Repository: our-campUS/our-campUS-BE

Length of output: 3002


🏁 Script executed:

#!/bin/bash
# Find usage of @CurrentUserId annotation in controllers
echo "=== @CurrentUserId 사용 패턴 ===" 
rg -n -B 2 -A 5 '@CurrentUserId' --type java -g '*Controller.java' | head -100

echo -e "\n=== 학생회 관련 권한 검증 패턴 ===" 
rg -n -B 3 -A 3 'StudentCouncilPrincipal|councilId' --type java -g '*Controller.java' | head -100

Repository: our-campUS/our-campUS-BE

Length of output: 15891


🏁 Script executed:

#!/bin/bash
# Check StudentCouncilPostController for authorization checks
echo "=== StudentCouncilPostController 전체 컨텍스트 ===" 
sed -n '1,50p' src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java

# Search for role-based or principal-based authorization
echo -e "\n=== 권한 검증 관련 설정 ==="
rg -n 'role|Role|authority|Authority|Principal' src/main/java/com/campus/campus/domain/studentcouncilpost/ --type java | head -50

# Check for security configuration
echo -e "\n=== 보안 필터 및 설정 ==="
fd -e java -p '.*Security.*Config|.*Filter.*' src/main/java/ | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 2887


🏁 Script executed:

#!/bin/bash
# Check JwtAuthenticationFilter for role assignment
echo "=== JwtAuthenticationFilter 역할 할당 로직 ===" 
sed -n '1,100p' src/main/java/com/campus/campus/global/util/jwt/JwtAuthenticationFilter.java

# Check UserController for authorization
echo -e "\n=== UserController 권한 검증 ===" 
head -50 src/main/java/com/campus/campus/domain/user/presentation/UserController.java

Repository: our-campUS/our-campUS-BE

Length of output: 5253


StudentCouncilPrincipal 처리 로직이 올바르게 구현되었습니다.

기존 UserPrincipal 처리 방식과 동일한 패턴을 따르고 있으며, JwtAuthenticationFilter에서 JWT의 role 클레임에 따라 적절한 principal 타입이 할당되고 있습니다. StudentCouncilPostController는 클래스 레벨의 @PreAuthorize("hasRole('COUNCIL')") 제약으로 권한이 보호되고 있어 역할 간 접근 제어가 properly 구현되어 있습니다.

스타일 일관성 개선 제안

Line 62의 throw 문이 중괄호 없이 한 줄로 작성되었으나, UserPrincipal 분기(Lines 54-56)는 중괄호를 사용합니다. 코드 스타일 일관성을 위해 동일한 형식으로 통일하는 것을 권장합니다:

 if (principal instanceof StudentCouncilPrincipal councilPrincipal) {
     Long id = councilPrincipal.getCouncilId();
-    if (id == null && required) throw new UnAuthorizedException();
+    if (id == null && required) {
+        throw new UnAuthorizedException();
+    }
     return id;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (principal instanceof StudentCouncilPrincipal councilPrincipal) {
Long id = councilPrincipal.getCouncilId();
if (id == null && required) throw new UnAuthorizedException();
return id;
}
if (principal instanceof StudentCouncilPrincipal councilPrincipal) {
Long id = councilPrincipal.getCouncilId();
if (id == null && required) {
throw new UnAuthorizedException();
}
return id;
}
🤖 Prompt for AI Agents
In
src/main/java/com/campus/campus/global/annotation/CurrentUserIdArgumentResolver.java
around lines 60 to 64, the StudentCouncilPrincipal branch uses a single-line if
that throws without braces which is inconsistent with the UserPrincipal branch;
update the code to use braces for the null-check throw (i.e., replace the
single-line `if (id == null && required) throw new UnAuthorizedException();`
with a block form using `{ ... }`) to match the existing style and maintain
consistency.

Comment on lines 72 to 100
try {

keyFilePath = createSecureKeyFile(normalizedPem);

// Provider 생성
SimplePrivateKeySupplier keySupplier =
new SimplePrivateKeySupplier(keyFilePath.toString());

SimpleAuthenticationDetailsProvider provider =
SimpleAuthenticationDetailsProvider.builder()
.tenantId(tenancyOcid)
.userId(userOcid)
.fingerprint(fingerprint)
.privateKeySupplier(keySupplier)
.passPhrase(passPhrase)
.region(Region.fromRegionId(region))
.build();

ObjectStorage client = new ObjectStorageClient(provider);

log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region);

return client;

} catch (Exception e) {
cleanupKeyFile();
log.error(">>> [OCI ERROR] Failed to initialize ObjectStorage", e);
throw new IllegalStateException("Failed to initialize ObjectStorage", e);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ObjectStorageClient 생성 후 예외 발생 시 리소스 누수 가능성이 있습니다.

Line 90에서 ObjectStorageClient가 생성된 후 예외가 발생하면, 해당 클라이언트가 close되지 않습니다. ObjectStorageClientAutoCloseable을 구현하므로 적절히 정리해야 합니다.

🔎 제안하는 수정
     @Bean
     public ObjectStorage objectStorage() {
         log.info(">>> [OCI] Initializing ObjectStorage client...");
 
         String normalizedPem = normalizePem(privateKeyRaw);
+        ObjectStorage client = null;
 
         try {
-
             keyFilePath = createSecureKeyFile(normalizedPem);
 
             // Provider 생성
             SimplePrivateKeySupplier keySupplier =
                     new SimplePrivateKeySupplier(keyFilePath.toString());
 
             SimpleAuthenticationDetailsProvider provider =
                     SimpleAuthenticationDetailsProvider.builder()
                             .tenantId(tenancyOcid)
                             .userId(userOcid)
                             .fingerprint(fingerprint)
                             .privateKeySupplier(keySupplier)
                             .passPhrase(passPhrase)
                             .region(Region.fromRegionId(region))
                             .build();
 
-            ObjectStorage client = new ObjectStorageClient(provider);
+            client = new ObjectStorageClient(provider);
 
             log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region);
 
             return client;
 
         } catch (Exception e) {
+            if (client != null) {
+                try {
+                    client.close();
+                } catch (Exception closeEx) {
+                    log.warn(">>> [OCI] Failed to close ObjectStorageClient", closeEx);
+                }
+            }
             cleanupKeyFile();
             log.error(">>> [OCI ERROR] Failed to initialize ObjectStorage", e);
             throw new IllegalStateException("Failed to initialize ObjectStorage", e);
         }
     }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/com/campus/campus/global/config/OciConfig.java around lines 72
to 100, the ObjectStorageClient created at line ~90 may leak if a later
exception is thrown because it isn't closed; ensure the client is closed on
error by keeping a local ObjectStorageClient variable initialized to null,
assign the newly created ObjectStorageClient to it, and in the catch block (or a
finally) call client.close() if it's non-null (handling/ignoring any close
exceptions) before calling cleanupKeyFile() and rethrowing; alternatively use a
try-with-resources pattern only if you change the method to not return the
client directly (so prefer the null-check close in the catch to preserve current
return behavior).

Comment on lines 80 to 88
SimpleAuthenticationDetailsProvider provider =
SimpleAuthenticationDetailsProvider.builder()
.tenantId(tenancyOcid)
.userId(userOcid)
.fingerprint(fingerprint)
.privateKeySupplier(keySupplier)
.passPhrase(passPhrase)
.region(Region.fromRegionId(region))
.build();
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

🧩 Analysis chain

🌐 Web query:

OCI Java SDK SimpleAuthenticationDetailsProvider passPhrase null vs empty string handling

💡 Result:

Short answer: passPhrase = null means “no passphrase / not applicable” (SDK will return null from getPassPhrase/getPassphraseCharacters). An empty string ("") is treated as a real passphrase value (i.e., an empty char[]) and will be passed through to the underlying key‑decryptor — it is not automatically normalized to null. Use null to indicate no passphrase; use a non‑null (possibly empty) value only when you intend to supply that exact passphrase. [1][2]

Sources:

  • SimpleAuthenticationDetailsProvider javadoc (getPassPhrase / getPassphraseCharacters: returns the pass phrase or null if not applicable). [1]
  • BasicAuthenticationDetailsProvider javadoc (getPassPhrase deprecated; getPassphraseCharacters returns char[] or null if not applicable; builder supports passPhrase(String)/passphraseCharacters(char[])). [2]

🏁 Script executed:

# Let me check the actual code context in the file to verify the default value and usage
find . -name "OciConfig.java" -type f

Repository: our-campUS/our-campUS-BE

Length of output: 130


🏁 Script executed:

# Read the OciConfig.java file to verify the passPhrase default value and context
cat -n ./src/main/java/com/campus/campus/global/config/OciConfig.java | head -100

Repository: our-campUS/our-campUS-BE

Length of output: 4069


passPhrase의 빈 문자열과 null 처리 방식 확인 필요

OCI Java SDK에서 passPhrase는 null과 빈 문자열을 다르게 처리합니다. null은 "passphrase 없음"을 의미하지만, 빈 문자열("")은 실제 passphrase 값(빈 char[])으로 전달됩니다. 현재 코드는 설정에서 passphrase가 제공되지 않을 때 기본값으로 빈 문자열을 전달하므로, passphrase가 없는 개인키 파일은 실패합니다. 빈 문자열 대신 null을 전달하도록 수정하세요. (예: passPhrase = null 또는 StringUtils.isEmpty(passPhrase) ? null : passPhrase)

🤖 Prompt for AI Agents
In src/main/java/com/campus/campus/global/config/OciConfig.java around lines 80
to 88, the builder currently passes an empty-string passPhrase which OCI SDK
treats as an actual empty passphrase; change the code to pass null when no
passphrase is configured by normalizing the configured value (e.g., passPhrase =
(passPhrase == null || passPhrase.isEmpty()) ? null : passPhrase or use
StringUtils.isEmpty(passPhrase) ? null : passPhrase) so the SDK interprets
absence of a passphrase correctly.

Comment on lines 76 to 88
private String extractObjectNameFromUrl(String url) {
try {
URI uri = URI.create(url);
String path = uri.getPath();
int idx = path.indexOf("/o/");
if (idx == -1) {
throw new IllegalArgumentException("Invalid OCI object URL");
}
return path.substring(idx + 3);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid OCI object URL: " + url);
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

URL 디코딩 누락으로 인한 삭제 실패 가능성

OciConfig.fullObjectUrl()에서 object name을 URL 인코딩하므로, 삭제 시 URL에서 추출한 object name을 디코딩해야 합니다. 특수문자가 포함된 파일명의 경우 삭제가 실패할 수 있습니다.

🔎 제안된 수정
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;

     private String extractObjectNameFromUrl(String url) {
         try {
             URI uri = URI.create(url);
             String path = uri.getPath();
             int idx = path.indexOf("/o/");
             if (idx == -1) {
                 throw new IllegalArgumentException("Invalid OCI object URL");
             }
-            return path.substring(idx + 3);
+            String encodedObjectName = path.substring(idx + 3);
+            return URLDecoder.decode(encodedObjectName, StandardCharsets.UTF_8);
         } catch (Exception e) {
             throw new IllegalArgumentException("Invalid OCI object URL: " + url);
         }
     }
🤖 Prompt for AI Agents
In src/main/java/com/campus/campus/global/oci/OciPresignedUrlService.java around
lines 76 to 88, the method extracts the object name from the presigned URL but
does not URL-decode it, which will make deletions fail for names containing
encoded characters; update the method to decode the extracted path segment (use
URLDecoder.decode(..., "UTF-8") or java.net.URLDecoder with
StandardCharsets.UTF_8) after substringing the "/o/" portion, and
preserve/adjust exception handling to throw an IllegalArgumentException with the
original URL on failure.

Copy link
Member

@1winhyun 1winhyun left a comment

Choose a reason for hiding this comment

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

수고하셨습니다!!
전체적으로 한번 확인하고 제 의견이나 수정이 좋을 것 같다고 생각되는 부분에 리뷰 남겨두었습니다!! 확인 부탁드려요!!
추가적으로 ci 빌드가 실패했는데 이건 아마 test 코드에서 healthCheck가 실패했기 때문일 가능성이 큽니다!! 이 부분에 대해 수정이 필요할 것 같습니다!!


// 본문 이미지들
List<String> imageUrls
) {} No newline at end of file
Copy link
Member

Choose a reason for hiding this comment

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

마지막 줄 개행 처리 부탁드립니다!!

@1winhyun 1winhyun added ✨Feat 새로운 기능 개발 ⚙️Chore 인프라 labels Dec 27, 2025
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

♻️ Duplicate comments (8)
src/main/java/com/campus/campus/global/config/OciConfig.java (3)

60-61: passPhrase 빈 문자열 처리 문제

OCI SDK에서 passPhrase가 빈 문자열("")이면 실제 빈 passphrase로 전달됩니다. passphrase가 없는 개인키의 경우 인증 실패가 발생할 수 있습니다.

🔎 제안하는 수정
-    @Value("${oci.passphrase:}")
-    private String passPhrase;
+    @Value("${oci.passphrase:#{null}}")
+    private String passPhrase;

또는 objectStorage() 메서드에서 빈 문자열을 null로 변환:

String effectivePassPhrase = (passPhrase == null || passPhrase.isEmpty()) ? null : passPhrase;

90-100: ObjectStorageClient 생성 후 예외 발생 시 리소스 누수 가능성

Line 90에서 ObjectStorageClient가 생성된 후 예외가 발생하면 클라이언트가 close되지 않습니다.

🔎 제안하는 수정
+        ObjectStorage client = null;
         try {
             keyFilePath = createSecureKeyFile(normalizedPem);
             // ... provider 생성 ...
-            ObjectStorage client = new ObjectStorageClient(provider);
+            client = new ObjectStorageClient(provider);
             log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region);
             return client;
         } catch (Exception e) {
+            if (client != null) {
+                try { client.close(); } catch (Exception ignored) {}
+            }
             cleanupKeyFile();
             log.error(">>> [OCI ERROR] Failed to initialize ObjectStorage", e);
             throw new IllegalStateException("Failed to initialize ObjectStorage", e);
         }

265-265: 파일 끝에 개행 문자가 없습니다.

POSIX 표준에 따라 파일 끝에 개행 문자를 추가해 주세요.

src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java (1)

10-17: LGTM!

Enum 구조와 ResponseCodeInterface 구현이 적절합니다.

참고: 파일 끝 개행 문자 추가가 필요합니다 (기존 리뷰 코멘트 참조).

src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)

75-87: URL 디코딩 누락으로 삭제 실패 가능성

OciConfig.fullObjectUrl()에서 object name을 URL 인코딩하므로, 추출 시 디코딩이 필요합니다. 특수문자가 포함된 파일명의 경우 삭제가 실패할 수 있습니다.

🔎 제안하는 수정
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;

     private String extractObjectNameFromUrl(String url) {
         try {
             URI uri = URI.create(url);
             String path = uri.getPath();
             int idx = path.indexOf("/o/");
             if (idx == -1) {
                 throw new IllegalArgumentException("Invalid OCI object URL");
             }
-            return path.substring(idx + 3);
+            String encodedObjectName = path.substring(idx + 3);
+            return URLDecoder.decode(encodedObjectName, StandardCharsets.UTF_8);
         } catch (Exception e) {
             throw new IllegalArgumentException("Invalid OCI object URL: " + url);
         }
     }
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java (2)

16-21: 가독성을 위해 개행 처리를 권장합니다.

JPQL 쿼리가 한 줄로 연결되어 있어 읽기 어렵습니다.

🔎 가독성 개선 제안
-    @Query("SELECT p FROM StudentCouncilPost p " +
-            "JOIN FETCH p.writer w " +
-            "JOIN FETCH w.school s " +
-            "LEFT JOIN FETCH w.college c " +
-            "LEFT JOIN FETCH w.major m " +
-            "WHERE p.id = :postId")
+    @Query("""
+            SELECT p FROM StudentCouncilPost p
+            JOIN FETCH p.writer w
+            JOIN FETCH w.school s
+            LEFT JOIN FETCH w.college c
+            LEFT JOIN FETCH w.major m
+            WHERE p.id = :postId
+            """)
     Optional<StudentCouncilPost> findByIdWithFullInfo(@Param("postId") Long postId);

14-14: N+1 문제가 발생합니다.

findAllByCategory 메서드는 FETCH JOIN 없이 정의되어, 매퍼에서 post.getWriter().getId()를 호출할 때 각 게시글마다 writer를 개별 조회하게 됩니다.

findByIdWithFullInfo처럼 FETCH JOIN을 적용하거나, @EntityGraph를 사용하여 필요한 연관관계를 즉시 로딩하도록 수정하세요.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)

169-178: @ResponseStatus(NO_CONTENT)CommonResponse 반환이 일관성이 없습니다.

HTTP 204 No Content는 응답 본문이 없어야 하지만, CommonResponse.success()는 본문을 반환합니다. 다른 엔드포인트와의 일관성을 위해 @ResponseStatus 어노테이션을 제거하는 것을 권장합니다.

🔎 제안하는 수정
 @DeleteMapping("/{postId}")
 @Operation(summary = "학생회 게시글 삭제")
-@ResponseStatus(HttpStatus.NO_CONTENT)
 public CommonResponse<Void> deletePost(
         @CurrentUserId Long councilId,
         @PathVariable Long postId
 ) {
     postService.delete(councilId, postId);
     return CommonResponse.success(PostResponseCode.POST_DELETE_SUCCESS);
 }
🧹 Nitpick comments (4)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java (1)

5-9: 구현이 명확하고 적절합니다.

예외 클래스가 올바르게 정의되어 있으며, ApplicationException을 상속받아 에러 코드를 통한 일관된 예외 처리를 지원합니다. 이미지 개수 제한 초과 시나리오를 명확하게 표현합니다.

선택사항: 추가적인 컨텍스트를 위한 생성자 오버로드

필요한 경우 추가 컨텍스트(예: 현재 이미지 개수, 제한 개수 등)를 전달할 수 있도록 오버로드된 생성자를 고려해볼 수 있습니다. 다만 현재 구현도 기본적인 사용에는 충분합니다.

 public class PostImageLimitExceededException extends ApplicationException {
     public PostImageLimitExceededException() {
         super(ErrorCode.POST_IMAGE_LIMIT_EXCEEDED);
     }
+    
+    public PostImageLimitExceededException(String additionalMessage) {
+        super(ErrorCode.POST_IMAGE_LIMIT_EXCEEDED, additionalMessage);
+    }
 }
src/main/java/com/campus/campus/global/config/OciConfig.java (1)

65-66: @Scope("singleton")은 기본값이므로 불필요합니다.

Spring에서 @Bean의 기본 scope는 singleton입니다. 명시적 선언이 필요하지 않습니다.

🔎 제안하는 수정
     @Bean
-    @Scope("singleton")
     public ObjectStorage objectStorage() {
src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java (1)

9-9: 유틸리티 클래스 패턴 적용 권장

정적 메서드만 포함하는 클래스이므로 인스턴스화를 방지하는 것이 좋습니다.

🔎 제안하는 수정
-public class PresignedUrlMapper {
+public final class PresignedUrlMapper {
+
+    private PresignedUrlMapper() {
+        // 유틸리티 클래스 인스턴스화 방지
+    }
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)

62-65: 예외 원인(cause) 전달 고려

커스텀 예외에 원본 예외를 cause로 전달하면 디버깅이 용이해집니다. 현재는 로그에만 기록되고 예외 체인이 끊어집니다.

🔎 제안하는 수정

커스텀 예외 클래스에 cause를 받는 생성자가 있다면:

         } catch (Exception e) {
             log.error(">>> PRESIGNED URL CREATE ERROR", e);
-            throw new OciPresignedUrlCreateFailException();
+            throw new OciPresignedUrlCreateFailException(e);
         }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fe1a223 and 3f950f8.

📒 Files selected for processing (18)
  • src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/NormalizedDateTime.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java
  • src/main/java/com/campus/campus/global/config/OciConfig.java
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
  • src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java
  • src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ErrorCode.java
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java
🧰 Additional context used
🧬 Code graph analysis (3)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java (3)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java (1)
  • EventEndDateTimeNotAllowedException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java (1)
  • EventStartDateTimeRequiredException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (1)
  • PartnershipDateRequiredException (5-9)
src/main/java/com/campus/campus/global/config/OciConfig.java (1)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)
  • Slf4j (19-110)
src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)
src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java (1)
  • RestController (16-49)
🔇 Additional comments (10)
src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java (1)

11-44: LGTM!

OCI SDK 빌더 패턴을 적절하게 사용하고 있으며, PAR 이름에 UUID를 사용하여 고유성을 보장합니다.

src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)

29-41: LGTM!

Presigned URL 생성 로직이 적절하며, UUID 기반 object name으로 충돌을 방지합니다.

src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java (1)

16-48: LGTM!

컨트롤러 구조가 적절하며, Swagger 문서화와 @Valid를 통한 요청 검증이 잘 적용되어 있습니다. 용도별로 디렉토리를 분리한 점도 좋습니다.

src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java (2)

8-9: LGTM!

새로운 쿼리 메서드에 필요한 import 문이 올바르게 추가되었습니다.


17-22: N+1 문제 해결을 위한 FETCH JOIN 적용이 적절합니다.

LEFT JOIN FETCH를 사용하여 연관 엔티티들(school, college, major)을 즉시 로딩하는 방식이 올바르게 구현되었습니다. 세 필드 모두 단일 연관관계(@ManyToOne, @OneToOne)이므로 여러 FETCH JOIN을 함께 사용해도 카르테시안 곱 문제가 발생하지 않습니다. 메서드명 findByIdWithDetails도 의도를 명확히 전달하고 있으며, getFullCouncilName() 메서드에서 세 필드 모두 접근하므로 이 쿼리 최적화는 효과적입니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/NormalizedDateTime.java (1)

5-8: LGTM! 깔끔한 데이터 구조입니다.

날짜 정규화 결과를 담는 불변 레코드로 적절하게 구현되었습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java (1)

10-39: LGTM! Enum을 활용한 우수한 검증 패턴입니다.

카테고리별로 다른 날짜/시간 규칙을 enum 상수의 메서드 오버라이딩으로 처리하여 가독성과 유지보수성이 뛰어납니다. PARTNERSHIP의 날짜 정규화 로직(LocalTime.MIN, LocalTime.MAX)도 정확합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/PostResponseDto.java (1)

11-32: LGTM! 조건부 필드 처리가 적절합니다.

@JsonInclude(NON_NULL)을 사용하여 카테고리별로 불필요한 필드를 응답에서 제외하는 설계가 명확하고 적절합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java (1)

15-89: LGTM! 매퍼 로직이 명확하고 안전합니다.

  • toDetail 메서드의 빌더 패턴 사용이 조건부 날짜 필드 처리에 적합합니다.
  • Line 50의 null 방어 로직이 적절합니다.
  • 전반적으로 깔끔한 매핑 구현입니다.
src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)

41-113: LGTM! 상세한 API 문서화가 훌륭합니다.

Swagger 어노테이션을 통해 카테고리별 요청 예시와 비즈니스 규칙을 명확히 문서화했습니다. 요청 검증(@Valid)도 적절히 적용되어 있습니다.

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

♻️ Duplicate comments (6)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java (1)

16-16: N+1 쿼리 문제가 발생합니다.

findAllByCategory 메서드가 FETCH JOIN 없이 정의되어 있어, StudentCouncilPostMapper.toListItem() 메서드에서 post.getWriter().getId()를 호출할 때 페이지의 각 게시글마다 writer를 개별 조회하는 N+1 문제가 발생합니다.

페이지네이션을 유지하면서 N+1을 해결하려면 @Query 어노테이션으로 FETCH JOIN과 별도의 countQuery를 정의해야 합니다.

🔎 제안하는 수정
-	Page<StudentCouncilPost> findAllByCategory(PostCategory category, Pageable pageable);
+	@Query("SELECT p FROM StudentCouncilPost p " +
+		"JOIN FETCH p.writer w " +
+		"JOIN FETCH w.school s " +
+		"LEFT JOIN FETCH w.college c " +
+		"LEFT JOIN FETCH w.major m " +
+		"WHERE p.category = :category")
+	@Query(name = "StudentCouncilPost.findAllByCategory.count",
+		query = "SELECT COUNT(p) FROM StudentCouncilPost p WHERE p.category = :category")
+	Page<StudentCouncilPost> findAllByCategory(@Param("category") PostCategory category, Pageable pageable);

또는 @EntityGraph를 사용하는 방법:

+	@EntityGraph(attributePaths = {"writer", "writer.school", "writer.college", "writer.major"})
	Page<StudentCouncilPost> findAllByCategory(PostCategory category, Pageable pageable);

전체 게시글 조회(findAll)를 위해서도 동일한 eager loading이 필요합니다:

+	@EntityGraph(attributePaths = {"writer", "writer.school", "writer.college", "writer.major"})
+	Page<StudentCouncilPost> findAll(Pageable pageable);
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)

109-129: 목록 조회에서 N+1 문제가 발생합니다.

Lines 122-124에서 postRepository.findAll() 또는 findAllByCategory()를 호출하는데, 이 메서드들이 writer를 eager loading하지 않습니다. Line 127에서 StudentCouncilPostMapper.toListItem()post.getWriter().getId()를 호출하므로 N+1 쿼리가 발생합니다.

Repository 계층의 findAllByCategory 메서드에 FETCH JOIN 또는 @EntityGraph를 추가하여 해결해야 합니다 (별도 코멘트 참조).

src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)

77-89: URL 디코딩 누락으로 인한 삭제 실패 가능성

OciConfig.fullObjectUrl()에서 object name을 URL 인코딩하므로, 삭제 시 URL에서 추출한 object name을 디코딩해야 합니다. 특수문자가 포함된 파일명의 경우 삭제가 실패할 수 있습니다.

🔎 제안된 수정
+import java.net.URLDecoder;
+
 	private String extractObjectNameFromUrl(String url) {
 		try {
 			URI uri = URI.create(url);
 			String path = uri.getPath();
 			int idx = path.indexOf("/o/");
 			if (idx == -1) {
 				throw new IllegalArgumentException("Invalid OCI object URL");
 			}
-			return path.substring(idx + 3);
+			String encodedObjectName = path.substring(idx + 3);
+			return URLDecoder.decode(encodedObjectName, StandardCharsets.UTF_8);
 		} catch (Exception e) {
 			throw new IllegalArgumentException("Invalid OCI object URL: " + url);
 		}
 	}
src/main/java/com/campus/campus/global/config/OciConfig.java (3)

68-104: ObjectStorageClient 생성 후 예외 발생 시 리소스 누수 가능성

Line 93에서 ObjectStorageClient가 생성된 후 예외가 발생하면, 해당 클라이언트가 close되지 않습니다. ObjectStorageClientAutoCloseable을 구현하므로 적절히 정리해야 합니다.

🔎 제안하는 수정
 	@Bean
 	@Scope("singleton")
 	public ObjectStorage objectStorage() {
 		log.info(">>> [OCI] Initializing ObjectStorage client...");
 
 		String normalizedPem = normalizePem(privateKeyRaw);
+		ObjectStorage client = null;
 
 		try {
 
 			keyFilePath = createSecureKeyFile(normalizedPem);
 
 			// Provider 생성
 			SimplePrivateKeySupplier keySupplier =
 				new SimplePrivateKeySupplier(keyFilePath.toString());
 
 			SimpleAuthenticationDetailsProvider provider =
 				SimpleAuthenticationDetailsProvider.builder()
 					.tenantId(tenancyOcid)
 					.userId(userOcid)
 					.fingerprint(fingerprint)
 					.privateKeySupplier(keySupplier)
 					.passPhrase(passPhrase)
 					.region(Region.fromRegionId(region))
 					.build();
 
-			ObjectStorage client = new ObjectStorageClient(provider);
+			client = new ObjectStorageClient(provider);
 
 			log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region);
 
 			return client;
 
 		} catch (Exception e) {
+			if (client != null) {
+				try {
+					client.close();
+				} catch (Exception closeEx) {
+					log.warn(">>> [OCI] Failed to close ObjectStorageClient", closeEx);
+				}
+			}
 			cleanupKeyFile();
 			log.error(">>> [OCI ERROR] Failed to initialize ObjectStorage", e);
 			throw new IllegalStateException("Failed to initialize ObjectStorage", e);
 		}
 	}

63-64: passPhrase의 빈 문자열과 null 처리 방식 확인 필요

OCI Java SDK에서 passPhrase는 null과 빈 문자열을 다르게 처리합니다. null은 "passphrase 없음"을 의미하지만, 빈 문자열("")은 실제 passphrase 값(빈 char[])으로 전달됩니다. 현재 코드는 설정에서 passphrase가 제공되지 않을 때 기본값으로 빈 문자열을 전달하므로, passphrase가 없는 개인키 파일은 실패할 수 있습니다.

🔎 제안하는 수정
 	@Value("${oci.passphrase:}")
 	private String passPhrase;
 
 	private Path keyFilePath;
 
 	@Bean
 	@Scope("singleton")
 	public ObjectStorage objectStorage() {
 		log.info(">>> [OCI] Initializing ObjectStorage client...");
 
 		String normalizedPem = normalizePem(privateKeyRaw);
+		String normalizedPassPhrase = (passPhrase == null || passPhrase.isEmpty()) ? null : passPhrase;
 
 		try {
 
 			keyFilePath = createSecureKeyFile(normalizedPem);
 
 			// Provider 생성
 			SimplePrivateKeySupplier keySupplier =
 				new SimplePrivateKeySupplier(keyFilePath.toString());
 
 			SimpleAuthenticationDetailsProvider provider =
 				SimpleAuthenticationDetailsProvider.builder()
 					.tenantId(tenancyOcid)
 					.userId(userOcid)
 					.fingerprint(fingerprint)
 					.privateKeySupplier(keySupplier)
-					.passPhrase(passPhrase)
+					.passPhrase(normalizedPassPhrase)
 					.region(Region.fromRegionId(region))
 					.build();
OCI Java SDK SimpleAuthenticationDetailsProvider passphrase null empty string handling

Also applies to: 89-89


268-268: 파일 마지막 줄 개행 처리 필요

Java 코딩 컨벤션에 따라 파일 마지막 줄에 개행을 추가해주세요.

🔎 제안하는 수정
 		}
 	}
 }
+
🧹 Nitpick comments (7)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java (1)

10-10: 파일 끝의 불필요한 빈 줄을 제거하는 것을 고려해보세요.

코드 일관성을 위해 파일 끝의 빈 줄을 제거하는 것이 좋습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java (1)

10-11: 파일 끝의 불필요한 빈 줄들을 제거하는 것을 고려해보세요.

코드 일관성을 위해 파일 끝의 중복된 빈 줄들을 제거하는 것이 좋습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (1)

10-10: 파일 끝의 불필요한 빈 줄을 제거하는 것을 고려해보세요.

코드 일관성을 위해 파일 끝의 빈 줄을 제거하는 것이 좋습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java (1)

10-10: 파일 끝의 불필요한 빈 줄을 제거하는 것을 고려해보세요.

코드 일관성을 위해 파일 끝의 빈 줄을 제거하는 것이 좋습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)

132-156: 페이지네이션 기본값을 상수로 추출하는 것을 고려해보세요.

Line 144-145의 defaultValue = "1"defaultValue = "3"를 클래스 상단에 상수로 정의하면 일관성과 유지보수성이 향상됩니다.

🔎 제안하는 리팩토링
+	private static final int DEFAULT_PAGE = 1;
+	private static final int DEFAULT_SIZE = 3;
+
 	@GetMapping
 	@Operation(...)
 	public CommonResponse<Page<PostListItemResponseDto>> getPostList(
 		@Parameter(...) @RequestParam(required = false) PostCategory category,
-		@RequestParam(defaultValue = "1") int page,
-		@RequestParam(defaultValue = "3") int size,
+		@RequestParam(defaultValue = "" + DEFAULT_PAGE) int page,
+		@RequestParam(defaultValue = "" + DEFAULT_SIZE) int size,
 		@CurrentUserId Long councilId
 	) {
src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java (1)

10-46: 유틸리티 클래스 설계 개선 권장

유틸리티 클래스는 인스턴스화를 방지하기 위해 final로 선언하고 private 생성자를 추가하는 것이 Java 모범 사례입니다.

🔎 제안하는 수정
-public class PresignedUrlMapper {
+public final class PresignedUrlMapper {
+
+	private PresignedUrlMapper() {
+		throw new UnsupportedOperationException("Utility class");
+	}

 	public static CreatePreauthenticatedRequestRequest toPutObjectRequest(
src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java (1)

26-49: 엔드포인트 통합 고려 (선택사항)

두 엔드포인트가 디렉토리 경로만 다르고 동일한 로직을 수행하고 있습니다. 경로 변수를 사용하여 하나의 엔드포인트로 통합할 수 있습니다.

단, 현재 구조가 명시적이고 타입 안전성을 제공한다면 그대로 유지하는 것도 합리적인 설계 선택입니다.

🔎 통합 예시 (선택사항)
-	@PostMapping("/presigned")
-	@Operation(summary = "공통 이미지 업로드용 Presigned URL 생성")
-	public CommonResponse<PresignedUrlResponseDto> createPresignedUrl(
-		@RequestBody @Valid PresignedUrlRequestDto request
-	) {
-		return CommonResponse.success(
-			PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS,
-			presignedUrlService.createPresignedUrl("uploads", request)
-		);
-	}
-
-	@PostMapping("/posts/images/presigned")
-	@Operation(
-		summary = "게시글 이미지 업로드용 Presigned URL 생성",
-		description = "게시글 본문/썸네일 이미지 업로드 전용 Presigned URL을 생성"
-	)
-	public CommonResponse<PresignedUrlResponseDto> createPostImagePresignedUrl(
-		@RequestBody @Valid PresignedUrlRequestDto request
-	) {
-		return CommonResponse.success(
-			PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS,
-			presignedUrlService.createPresignedUrl("posts/images", request)
-		);
-	}
+	@PostMapping("/{directory}/presigned")
+	@Operation(summary = "이미지 업로드용 Presigned URL 생성")
+	public CommonResponse<PresignedUrlResponseDto> createPresignedUrl(
+		@PathVariable String directory,
+		@RequestBody @Valid PresignedUrlRequestDto request
+	) {
+		return CommonResponse.success(
+			PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS,
+			presignedUrlService.createPresignedUrl(directory, request)
+		);
+	}

단, 경로 변수 방식은 잘못된 디렉토리 입력 가능성이 있으므로, 명시적 엔드포인트가 더 안전할 수 있습니다.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3f950f8 and b4cab94.

📒 Files selected for processing (35)
  • src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java
  • src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java
  • src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/NormalizedDateTime.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java
  • src/main/java/com/campus/campus/global/config/OciConfig.java
  • src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
  • src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java
  • src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java
  • src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java
  • src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java
  • src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java
  • src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java
🚧 Files skipped from review as they are similar to previous changes (14)
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java
  • src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java
  • src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java
  • src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java
  • src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java
  • src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java
  • src/main/java/com/campus/campus/global/oci/exception/InvalidImageContentTypeException.java
🧰 Additional context used
🧬 Code graph analysis (7)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java (4)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilSignUpRequest.java (1)
  • StudentCouncilSignUpRequest (11-40)
src/main/java/com/campus/campus/domain/mail/application/dto/request/EmailVerificationRequest.java (1)
  • EmailVerificationRequest (6-11)
src/main/java/com/campus/campus/domain/user/application/dto/request/UserProfileRequest.java (1)
  • UserProfileRequest (5-12)
src/main/java/com/campus/campus/global/util/jwt/application/dto/request/TokenReissueRequest.java (1)
  • TokenReissueRequest (6-11)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java (9)
src/main/java/com/campus/campus/domain/council/application/exception/EmailAlreadyExistsException.java (2)
  • EmailAlreadyExistsException (5-9)
  • EmailAlreadyExistsException (6-8)
src/main/java/com/campus/campus/domain/council/application/exception/InvalidCouncilScopeException.java (1)
  • InvalidCouncilScopeException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/CouncilIdAndVerifiedEmailInvalidException.java (1)
  • CouncilIdAndVerifiedEmailInvalidException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/CollegeNotFoundException.java (1)
  • CollegeNotFoundException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (1)
  • StudentCouncilNotFoundException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/LoginIdAlreadyExistsException.java (1)
  • LoginIdAlreadyExistsException (5-9)
src/main/java/com/campus/campus/domain/council/application/exception/SignupEmailNotFoundException.java (1)
  • SignupEmailNotFoundException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolMajorNotSameException.java (1)
  • SchoolMajorNotSameException (5-9)
src/main/java/com/campus/campus/domain/school/application/exception/SchoolCollegeNotSameException.java (1)
  • SchoolCollegeNotSameException (6-8)
src/main/java/com/campus/campus/global/config/OciConfig.java (2)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)
  • Slf4j (35-265)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)
  • Slf4j (22-112)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (7)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (1)
  • StudentCouncilNotFoundException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java (1)
  • NotPostWriterException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java (1)
  • PostImageLimitExceededException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java (1)
  • PostNotFoundException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java (1)
  • ThumbnailRequiredException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java (1)
  • StudentCouncilPostMapper (14-92)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)
  • Slf4j (22-112)
src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (1)
src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java (1)
  • RestController (18-51)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (3)
src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java (1)
  • OciObjectDeleteFailException (5-9)
src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java (1)
  • OciPresignedUrlCreateFailException (5-9)
src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java (1)
  • PresignedUrlMapper (10-46)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java (2)
src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java (1)
  • Entity (26-77)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostImage.java (1)
  • Entity (18-36)
🔇 Additional comments (24)
src/main/java/com/campus/campus/domain/council/application/service/CouncilLoginService.java (2)

38-38: LGTM!

import 문 재정렬은 코드 구조 개선을 위한 비기능적 변경으로 문제없습니다.


192-198: LGTM!

private record의 위치 이동은 내부 구조 개선을 위한 비기능적 변경입니다. 캡슐화가 유지되고 기능적 영향이 없습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java (1)

5-9: 구현이 올바르게 되어 있습니다.

ErrorCode를 사용하는 예외 처리 패턴이 기존 코드베이스의 다른 예외 클래스들과 일관성 있게 구현되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/NormalizedDateTime.java (1)

5-9: 깔끔한 Record 구현입니다.

정규화된 날짜-시간 쌍을 담는 간단한 데이터 전송 객체로 적절하게 구현되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java (1)

5-9: 권한 검증 예외 처리가 적절합니다.

게시글 작성자 권한을 검증하는 예외 클래스가 올바르게 구현되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java (1)

5-9: 이미지 개수 제한 검증이 적절합니다.

게시글당 최대 10개의 이미지 제한을 검증하는 예외 클래스가 올바르게 구현되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java (1)

5-9: 이벤트 종료일시 검증 예외가 적절합니다.

EVENT 카테고리의 종료일시 검증을 위한 예외 클래스가 올바르게 구현되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (1)

5-9: 제휴 날짜 검증 예외가 적절합니다.

PARTNERSHIP 카테고리의 필수 날짜 검증을 위한 예외 클래스가 올바르게 구현되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java (1)

12-24: 응답 코드 정의가 적절합니다.

HTTP 상태 코드가 RESTful 규칙에 맞게 올바르게 정의되어 있습니다 (생성 201, 조회/수정 200, 삭제 204). 모든 응답 메시지도 명확하게 작성되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java (1)

3-9: 썸네일 아이콘 타입이 적절하게 정의되어 있습니다.

게시글 카테고리를 나타내는 아이콘 타입들이 명확하게 정의되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostResponseDto.java (1)

13-34: LGTM! 조건부 필드 처리가 적절합니다.

@JsonInclude(NON_NULL) 어노테이션을 사용하여 EVENT와 PARTNERSHIP 카테고리에 따라 다른 필드를 노출하는 설계가 잘 구현되었습니다. 팀 논의를 통해 합의된 방식이므로 API 일관성이 유지되고 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java (1)

8-18: LGTM! 목록 조회용 DTO가 간결합니다.

리스트 아이템에 필요한 필드만 포함하여 효율적으로 설계되었습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java (1)

14-42: LGTM! 요청 DTO 검증이 적절합니다.

필수 필드에 대한 Bean Validation 어노테이션이 올바르게 적용되었고, 날짜 포맷과 Swagger 문서화도 명확하게 처리되었습니다. 카테고리별 세부 검증은 PostCategory.validateAndNormalize()에서 처리되는 구조가 적절합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java (3)

16-31: 매핑 로직이 올바르게 구현되었습니다.

리스트 아이템 매핑이 깔끔하게 처리되었습니다. Line 28에서 post.getWriter().getId()를 호출하므로 repository 계층에서 writer를 eager loading해야 N+1 문제를 방지할 수 있습니다.

Repository 계층의 N+1 문제 해결이 필요합니다 (별도 코멘트 참조).


33-61: LGTM! 조건부 로직 처리가 명확합니다.

EVENT와 PARTNERSHIP 카테고리에 따라 다른 날짜 필드를 설정하는 조건부 로직이 빌더 패턴을 활용하여 가독성 있게 구현되었습니다. 팀 논의를 통해 합의된 방식입니다.


63-90: LGTM! 엔티티 변환 메서드가 적절합니다.

게시글과 이미지 엔티티 생성을 위한 매퍼 메서드가 명확하게 분리되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (3)

48-50: 이미지 개수 제한 검증이 적절합니다.

업로드 이미지가 10개를 초과하는 경우 명확한 예외를 발생시켜 사용자에게 피드백을 제공합니다.


156-162: LGTM! 이미지 삭제 에러 처리가 견고합니다.

스토리지 삭제 실패 시 예외를 catch하여 로그로 기록하는 방식이 적절합니다. 이미지 삭제 실패가 게시글 삭제를 막지 않도록 처리한 것이 좋습니다.


230-264: LGTM! 미사용 이미지 정리 로직이 명확합니다.

썸네일 변경과 본문 이미지 제거를 별도로 처리하고, 각 삭제 실패에 대해 로그를 남기는 방식이 적절합니다. Line 254-256의 null/blank 체크도 방어적으로 잘 구현되었습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (2)

43-115: LGTM! Swagger 문서화가 매우 상세합니다.

카테고리별 요청 예시와 비즈니스 규칙을 명확하게 문서화하여 API 사용자가 쉽게 이해할 수 있습니다. EVENT와 PARTNERSHIP의 날짜/시간 규칙 차이를 명확히 설명한 것이 좋습니다.


171-180: HTTP 상태 코드와 응답 본문의 일관성을 확인하세요.

@ResponseStatus(HttpStatus.NO_CONTENT)CommonResponse<Void> 본문 반환이 함께 사용되고 있습니다. HTTP 204는 본문이 없어야 하지만 CommonResponse는 본문을 포함합니다.

과거 리뷰에서 이 부분이 논의되었고 커밋 d8ba38c에서 수정되었다고 표시되어 있으나, 현재 코드에는 여전히 @ResponseStatus(NO_CONTENT)가 남아있습니다. 팀 컨벤션이 이를 허용하는지 확인이 필요합니다.

다른 DELETE 엔드포인트들과의 일관성을 위해 팀 컨벤션을 확인해주세요.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/StudentCouncilPost.java (3)

25-56: LGTM! 엔티티 설계가 적절합니다.

Lazy loading, TEXT 컬럼 타입, enum 매핑이 올바르게 구현되었습니다. BaseEntity를 확장하여 공통 필드를 재사용하는 것도 좋습니다.


58-79: LGTM! 업데이트 로직이 안전하게 구현되었습니다.

Lines 74-78에서 썸네일 필드를 null이 아닐 때만 업데이트하여 의도하지 않은 덮어쓰기를 방지한 것이 좋습니다. 이는 썸네일 이미지 URL과 아이콘 중 하나만 변경될 수 있는 비즈니스 로직을 잘 반영합니다.


81-95: LGTM! 헬퍼 메서드가 명확합니다.

카테고리 확인, 날짜 변환, 작성자 검증 로직이 엔티티 내부 메서드로 캡슐화되어 서비스 계층의 가독성을 높입니다.

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: 1

♻️ Duplicate comments (6)
src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java (1)

19-19: 파일 마지막에 개행 추가를 권장합니다.

이전 리뷰에서 언급된 것처럼 파일 끝에 빈 줄을 추가해주세요. 이는 일반적인 Java 코딩 규칙입니다.

src/main/java/com/campus/campus/global/config/OciConfig.java (3)

63-64: passPhrase의 빈 문자열과 null 처리 문제가 여전히 존재합니다.

OCI SDK에서 passPhrasenull이면 "passphrase 없음"을 의미하지만, 빈 문자열("")은 실제 passphrase 값으로 처리됩니다. 현재 @Value("${oci.passphrase:}")는 설정값이 없을 때 빈 문자열을 반환하므로, passphrase가 없는 개인키 파일에서 인증 실패가 발생할 수 있습니다.

🔎 제안하는 수정
-	@Value("${oci.passphrase:}")
-	private String passPhrase;
+	@Value("${oci.passphrase:#{null}}")
+	private String passPhrase;

또는 objectStorage() 메서드에서 사용 전에 정규화:

String effectivePassPhrase = (passPhrase == null || passPhrase.isEmpty()) ? null : passPhrase;

93-103: ObjectStorageClient 생성 후 예외 발생 시 리소스 누수 가능성이 있습니다.

Line 93에서 ObjectStorageClient가 생성된 후 예외가 발생하면 클라이언트가 close되지 않습니다. 현재 코드에서는 line 93 이후에 예외를 던지는 코드가 없지만, 향후 코드 변경 시 리소스 누수가 발생할 수 있습니다.

🔎 방어적 코딩을 위한 수정 제안
+		ObjectStorage client = null;
 		try {
-
 			keyFilePath = createSecureKeyFile(normalizedPem);
 
 			// Provider 생성
 			SimplePrivateKeySupplier keySupplier =
 				new SimplePrivateKeySupplier(keyFilePath.toString());
 
 			SimpleAuthenticationDetailsProvider provider =
 				SimpleAuthenticationDetailsProvider.builder()
 					.tenantId(tenancyOcid)
 					.userId(userOcid)
 					.fingerprint(fingerprint)
 					.privateKeySupplier(keySupplier)
 					.passPhrase(passPhrase)
 					.region(Region.fromRegionId(region))
 					.build();
 
-			ObjectStorage client = new ObjectStorageClient(provider);
+			client = new ObjectStorageClient(provider);
 
 			log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region);
 
 			return client;
 
 		} catch (Exception e) {
+			if (client != null) {
+				try {
+					client.close();
+				} catch (Exception closeEx) {
+					log.warn(">>> [OCI] Failed to close ObjectStorageClient", closeEx);
+				}
+			}
 			cleanupKeyFile();

268-268: 파일 마지막에 개행 문자를 추가해 주세요.

POSIX 표준 및 대부분의 코드 스타일 가이드에서는 파일 끝에 newline을 권장합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java (1)

16-16: findAllByCategory 메서드에서 N+1 쿼리 문제가 여전히 존재합니다.

이전 리뷰에서 지적된 대로, StudentCouncilPostMapper.toListItem()에서 post.getWriter().getId()를 호출하므로 페이지의 각 결과마다 writer를 개별 조회하는 N+1 문제가 발생합니다.

findByIdWithFullInfo처럼 FETCH JOIN을 적용하거나 @EntityGraph를 사용하세요.

🔎 수정 제안
-	Page<StudentCouncilPost> findAllByCategory(PostCategory category, Pageable pageable);
+	@Query(value = "SELECT p FROM StudentCouncilPost p " +
+		"JOIN FETCH p.writer w " +
+		"LEFT JOIN FETCH w.school " +
+		"LEFT JOIN FETCH w.college " +
+		"LEFT JOIN FETCH w.major " +
+		"WHERE p.category = :category",
+		countQuery = "SELECT count(p) FROM StudentCouncilPost p WHERE p.category = :category")
+	Page<StudentCouncilPost> findAllByCategory(@Param("category") PostCategory category, Pageable pageable);
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)

122-128: findAll 메서드에서 N+1 쿼리 문제가 발생합니다.

postRepository.findAll(pageable)findAllByCategory(category, pageable) 모두 writer를 FETCH JOIN 없이 조회하므로, 매퍼에서 post.getWriter().getId()를 호출할 때 각 게시글마다 추가 쿼리가 발생합니다.

🧹 Nitpick comments (7)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (1)

5-9: 구현이 올바르며 프로젝트 패턴을 잘 따르고 있습니다.

예외 클래스가 의도대로 잘 구현되었습니다. 다만 다음과 같은 개선사항을 고려해보세요:

  1. serialVersionUID 추가 권장: 직렬화 가능한 예외 클래스는 명시적으로 serialVersionUID를 선언하는 것이 권장됩니다.
  2. JavaDoc 추가 선택사항: 이 예외가 언제 발생하는지 설명하는 JavaDoc을 추가하면 유지보수성이 향상됩니다.
🔎 제안하는 개선 코드
+/**
+ * 제휴 게시글 작성 시 필수 날짜 정보가 누락된 경우 발생하는 예외
+ */
 public class PartnershipDateRequiredException extends ApplicationException {
+	private static final long serialVersionUID = 1L;
+
 	public PartnershipDateRequiredException() {
 		super(ErrorCode.PARTNERSHIP_DATE_REQUIRED);
 	}
 }
src/main/java/com/campus/campus/global/config/OciConfig.java (1)

68-69: @Scope("singleton")은 불필요합니다.

Spring 빈은 기본적으로 싱글톤이므로 명시적인 @Scope("singleton") 선언이 필요하지 않습니다.

🔎 제안하는 수정
 	@Bean
-	@Scope("singleton")
 	public ObjectStorage objectStorage() {
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java (1)

18-32: 두 쿼리 메서드 간 JOIN 전략이 일관되지 않습니다.

findByIdWithFullInfoschool에 INNER JOIN을, findByIdWithWriter는 LEFT JOIN을 사용합니다. 의도된 차이인지 확인해 주세요. 일관성을 위해 동일한 JOIN 전략을 사용하거나, 메서드 이름/문서에 차이점을 명시하는 것이 좋습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java (1)

16-16: isWriter 필드는 boolean primitive 타입 사용을 권장합니다.

Boolean wrapper 타입은 null을 허용하므로, 클라이언트에서 NPE를 유발할 수 있습니다. 이 필드는 항상 true/false 값을 가지므로 primitive boolean이 더 적합합니다.

🔎 수정 제안
-	Boolean isWriter
+	boolean isWriter
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java (1)

5-9: 도메인 레이어에서 애플리케이션 레이어 DTO를 직접 참조하고 있습니다.

PostCategory enum은 도메인 레이어에 위치하지만, PostRequestDtoNormalizedDateTime 등 애플리케이션 레이어의 DTO를 import하고 있습니다. 이는 레이어 간 의존성 방향을 위반합니다.

도메인 레이어가 애플리케이션 레이어에 의존하면 향후 DTO 변경 시 도메인 로직도 수정해야 합니다. 검증 로직을 서비스 레이어로 이동하거나, 도메인 전용 value object를 사용하는 것을 고려해 주세요.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java (1)

35-37: 썸네일 필수 조건이 DTO 레벨에서 검증되지 않습니다.

주석에 "둘 중 하나는 필수"라고 명시되어 있지만, 실제 검증은 서비스 레이어에서만 수행됩니다. 현재 구현도 동작하지만, 커스텀 @Constraint 어노테이션을 추가하면 API 문서화와 일관된 검증이 가능합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)

70-76: 이미지 저장 시 saveAll 사용을 권장합니다.

반복문에서 save를 개별 호출하는 대신 saveAll을 사용하면 배치 처리로 성능이 개선됩니다.

🔎 수정 제안
 		if (dto.imageUrls() != null) {
-			for (String imageUrl : dto.imageUrls()) {
-				postImageRepository.save(
-					StudentCouncilPostMapper.toEntity(post, imageUrl)
-				);
-			}
+			List<PostImage> images = dto.imageUrls().stream()
+				.map(url -> StudentCouncilPostMapper.toEntity(post, url))
+				.toList();
+			postImageRepository.saveAll(images);
 		}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b4cab94 and 9f29cb4.

📒 Files selected for processing (18)
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/response/PostListItemResponseDto.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/StudentCouncilPostRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java
  • src/main/java/com/campus/campus/global/config/OciConfig.java
  • src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/main/java/com/campus/campus/global/oci/application/dto/request/PresignedUrlRequestDto.java
  • src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/PostResponseCode.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/domain/repository/PostImageRepository.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java
  • src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java
  • src/main/java/com/campus/campus/global/oci/application/dto/response/PresignedUrlResponseDto.java
🧰 Additional context used
🧬 Code graph analysis (2)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (9)
src/main/java/com/campus/campus/domain/council/application/exception/StudentCouncilNotFoundException.java (1)
  • StudentCouncilNotFoundException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/NotPostWriterException.java (1)
  • NotPostWriterException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostImageLimitExceededException.java (1)
  • PostImageLimitExceededException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PostNotFoundException.java (1)
  • PostNotFoundException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/ThumbnailRequiredException.java (1)
  • ThumbnailRequiredException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/mapper/StudentCouncilPostMapper.java (1)
  • StudentCouncilPostMapper (14-92)
src/main/java/com/campus/campus/global/config/OciConfig.java (1)
  • Slf4j (28-268)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)
  • Slf4j (22-112)
src/main/java/com/campus/campus/domain/council/application/mapper/StudentCouncilLoginMapper.java (2)
  • Component (16-56)
  • createStudentCouncil (21-34)
src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java (3)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventEndDateTimeNotAllowedException.java (1)
  • EventEndDateTimeNotAllowedException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/EventStartDateTimeRequiredException.java (1)
  • EventStartDateTimeRequiredException (5-9)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/exception/PartnershipDateRequiredException.java (1)
  • PartnershipDateRequiredException (5-9)
🔇 Additional comments (15)
src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlResponseCode.java (1)

1-18: 구현이 깔끔하고 프로젝트 패턴을 잘 따르고 있습니다.

ResponseCodeInterface를 구현하고 Lombok을 적절히 활용하여 응답 코드를 정의했습니다. HTTP 200 상태 코드와 명확한 한글 메시지가 적절합니다.

src/main/java/com/campus/campus/global/config/OciConfig.java (6)

106-129: LGTM!

PEM 형식 검증 및 정규화 로직이 적절하게 구현되어 있습니다. Header/footer 검증으로 잘못된 키 형식을 조기에 감지할 수 있습니다.


135-156: LGTM!

키 파일 생성 로직이 적절합니다. 기존 파일 삭제 후 새로 생성하고, 권한 설정을 통해 보안을 강화하고 있습니다.


161-184: LGTM!

POSIX와 Windows 환경을 모두 지원하는 권한 설정 로직이 적절합니다.


189-209: LGTM!

파일 권한 설정이 적절합니다. 600 권한으로 소유자만 읽기/쓰기가 가능하도록 설정되어 있습니다.


214-228: LGTM!

URL 생성 로직이 적절합니다. null/blank 검증과 URL 인코딩으로 안전하게 처리하고 있습니다.


233-267: LGTM!

@PreDestroy를 통한 리소스 정리가 적절하게 구현되어 있습니다. 키 파일과 빈 디렉토리를 안전하게 삭제하며, 예외 발생 시에도 적절히 로깅합니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/ThumbnailIcon.java (1)

1-9: LGTM!

간결하고 명확한 enum 정의입니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/domain/entity/PostCategory.java (1)

13-37: 검증 및 정규화 로직이 명확합니다.

카테고리별 날짜/시간 규칙이 잘 구현되어 있습니다. EVENT는 시작일시만, PARTNERSHIP은 시작/종료일 모두를 요구하며, 제휴 게시글의 시간 정규화(00:00~23:59:59)가 PR 요구사항에 맞게 처리됩니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/dto/request/PostRequestDto.java (1)

1-42: LGTM!

DTO 구조와 검증 어노테이션이 적절합니다. @JsonFormat@Schema를 활용한 날짜/시간 처리가 잘 되어 있습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (3)

153-162: 삭제 순서와 예외 처리가 적절합니다.

DB에서 먼저 삭제한 후 OCI 파일을 정리하는 방식은 데이터 일관성 측면에서 합리적입니다. OCI 삭제 실패 시 고아 파일이 남을 수 있지만, DB 참조가 깨지는 것보다 나은 트레이드오프입니다.


46-89: create 메서드의 전체적인 구조가 잘 설계되어 있습니다.

이미지 개수 제한, 썸네일 검증, 카테고리별 날짜 정규화, 엔티티 저장 순서가 적절합니다.


229-264: cleanupUnusedImages 메서드의 방어적 코딩이 잘 되어 있습니다.

null/blank URL 체크, 예외 발생 시 로깅 후 계속 진행하는 패턴이 적절합니다. 이미지 정리 실패가 전체 트랜잭션을 롤백시키지 않습니다.

src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java (2)

43-115: Swagger 문서화가 매우 상세하고 유용합니다.

카테고리별 요청 예시, 날짜/시간 규칙, 권한 제한 등 API 사용에 필요한 정보가 잘 명시되어 있습니다.


34-42: 컨트롤러 구조가 적절합니다.

클래스 레벨 @PreAuthorize("hasRole('COUNCIL')")로 권한을 일괄 적용하고, @CurrentUserId로 현재 사용자를 주입받는 패턴이 잘 사용되었습니다.

Comment on lines 171 to 180
@DeleteMapping("/{postId}")
@Operation(summary = "학생회 게시글 삭제")
@ResponseStatus(HttpStatus.NO_CONTENT)
public CommonResponse<Void> deletePost(
@CurrentUserId Long councilId,
@PathVariable Long postId
) {
postService.delete(councilId, postId);
return CommonResponse.success(PostResponseCode.POST_DELETE_SUCCESS);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

@ResponseStatus(NO_CONTENT)CommonResponse 반환이 여전히 충돌합니다.

HTTP 204 No Content는 응답 본문이 없어야 하지만, CommonResponse.success()는 본문을 포함합니다. 이전 리뷰에서 "Addressed"로 표시되었지만 코드에는 여전히 @ResponseStatus(HttpStatus.NO_CONTENT)가 남아있습니다.

🔎 수정 제안
 	@DeleteMapping("/{postId}")
 	@Operation(summary = "학생회 게시글 삭제")
-	@ResponseStatus(HttpStatus.NO_CONTENT)
 	public CommonResponse<Void> deletePost(
 		@CurrentUserId Long councilId,
 		@PathVariable Long postId
 	) {
 		postService.delete(councilId, postId);
 		return CommonResponse.success(PostResponseCode.POST_DELETE_SUCCESS);
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@DeleteMapping("/{postId}")
@Operation(summary = "학생회 게시글 삭제")
@ResponseStatus(HttpStatus.NO_CONTENT)
public CommonResponse<Void> deletePost(
@CurrentUserId Long councilId,
@PathVariable Long postId
) {
postService.delete(councilId, postId);
return CommonResponse.success(PostResponseCode.POST_DELETE_SUCCESS);
}
@DeleteMapping("/{postId}")
@Operation(summary = "학생회 게시글 삭제")
public CommonResponse<Void> deletePost(
@CurrentUserId Long councilId,
@PathVariable Long postId
) {
postService.delete(councilId, postId);
return CommonResponse.success(PostResponseCode.POST_DELETE_SUCCESS);
}
🤖 Prompt for AI Agents
In
src/main/java/com/campus/campus/domain/studentcouncilpost/presentation/StudentCouncilPostController.java
around lines 171-180, the method is annotated with
@ResponseStatus(HttpStatus.NO_CONTENT) but still returns a CommonResponse body
which conflicts with HTTP 204 semantics; remove the conflict by deleting the
@ResponseStatus annotation and changing the method to return a
ResponseEntity<Void>, then return ResponseEntity.noContent().build() (also
remove the CommonResponse.success(...) return) so the endpoint correctly sends
an empty 204 response.

Copy link
Member

@1winhyun 1winhyun left a comment

Choose a reason for hiding this comment

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

마지막으로 리뷰 한번만 확인 부탁드려요!! 마지막 리뷰일 것 같습니다!!

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: 1

♻️ Duplicate comments (3)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)

77-89: URL 디코딩 누락으로 인한 삭제 실패 가능성

OciConfig.fullObjectUrl()에서 object name을 URL 인코딩하므로(line 216), 삭제 시 URL에서 추출한 object name을 디코딩해야 합니다. 특수문자(공백, 한글 등)가 포함된 파일명의 경우 삭제가 실패합니다.

🔎 제안된 수정
+import java.net.URLDecoder;
+
 	private String extractObjectNameFromUrl(String url) {
 		try {
 			URI uri = URI.create(url);
 			String path = uri.getPath();
 			int idx = path.indexOf("/o/");
 			if (idx == -1) {
 				throw new IllegalArgumentException("Invalid OCI object URL");
 			}
-			return path.substring(idx + 3);
+			String encodedObjectName = path.substring(idx + 3);
+			return URLDecoder.decode(encodedObjectName, StandardCharsets.UTF_8);
 		} catch (Exception e) {
 			throw new IllegalArgumentException("Invalid OCI object URL: " + url);
 		}
 	}
src/main/java/com/campus/campus/global/config/OciConfig.java (2)

62-63: passPhrase 기본값 처리 이슈

passPhrase의 기본값이 빈 문자열("")로 설정되어 있지만, OCI Java SDK는 빈 문자열을 null과 다르게 처리합니다. null은 "passphrase 없음"을 의미하지만, 빈 문자열은 실제 빈 passphrase 값으로 전달되어 passphrase가 없는 개인키 파일의 인증이 실패합니다.

🔎 제안된 수정
-	@Value("${oci.passphrase:}")
-	private String passPhrase;
+	@Value("${oci.passphrase:#{null}}")
+	private String passPhrase;

또는 builder에서 정규화:

+			String normalizedPassPhrase = (passPhrase == null || passPhrase.isEmpty()) ? null : passPhrase;
+
 			SimpleAuthenticationDetailsProvider provider =
 				SimpleAuthenticationDetailsProvider.builder()
 					.tenantId(tenancyOcid)
 					.userId(userOcid)
 					.fingerprint(fingerprint)
 					.privateKeySupplier(keySupplier)
-					.passPhrase(passPhrase)
+					.passPhrase(normalizedPassPhrase)
 					.region(Region.fromRegionId(region))
 					.build();

91-103: ObjectStorageClient 생성 후 예외 발생 시 리소스 누수 가능성

Line 91-93에서 ObjectStorageClient가 생성된 후 예외가 발생하면 해당 클라이언트가 close되지 않습니다. ObjectStorageClientAutoCloseable을 구현하므로 적절히 정리해야 합니다.

🔎 제안된 수정
 		String normalizedPem = normalizePem(privateKeyRaw);
+		ObjectStorage client = null;
 
 		try {
 
 			keyFilePath = createSecureKeyFile(normalizedPem);
 
 			// Provider 생성
 			SimplePrivateKeySupplier keySupplier =
 				new SimplePrivateKeySupplier(keyFilePath.toString());
 
 			SimpleAuthenticationDetailsProvider provider =
 				SimpleAuthenticationDetailsProvider.builder()
 					.tenantId(tenancyOcid)
 					.userId(userOcid)
 					.fingerprint(fingerprint)
 					.privateKeySupplier(keySupplier)
 					.passPhrase(passPhrase)
 					.region(Region.fromRegionId(region))
 					.build();
 
-			ObjectStorage client = ObjectStorageClient.builder()
+			client = ObjectStorageClient.builder()
 				.region(Region.fromRegionId(region))
 				.build(provider);
 
 			log.info(">>> [OCI] ObjectStorage client initialized successfully for region: {}", region);
 			return client;
 
 		} catch (Exception e) {
+			if (client != null) {
+				try {
+					client.close();
+				} catch (Exception closeEx) {
+					log.warn(">>> [OCI] Failed to close ObjectStorageClient", closeEx);
+				}
+			}
 			cleanupKeyFile();
 			log.error(">>> [OCI ERROR] Failed to initialize ObjectStorage", e);
 			throw new IllegalStateException("Failed to initialize ObjectStorage", e);
 		}
🧹 Nitpick comments (1)
src/main/resources/application-test.yml (1)

24-28: Redis 설정과 헬스체크 비활성화를 확인했습니다.

Redis 연결 정보가 포함되어 있으며, 라인 60-61에서 헬스체크가 비활성화되어 있습니다. 이는 테스트 환경에서 Redis가 항상 사용 가능하지 않을 수 있음을 시사합니다.

테스트에서 Redis가 실제로 필요한 경우, embedded Redis 또는 Testcontainers를 사용하는 것을 고려해 보세요. 그렇지 않으면 불필요한 연결 시도로 테스트 속도가 느려질 수 있습니다.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9a97b17 and dbe58c2.

📒 Files selected for processing (5)
  • src/main/java/com/campus/campus/global/config/OciConfig.java
  • src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java
  • src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java
  • src/main/resources/application-test.yml
  • src/test/java/com/campus/campus/CampusApplicationTests.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-11-18T09:42:14.128Z
Learnt from: 1winhyun
Repo: our-campUS/our-campUS-BE PR: 6
File: src/test/resources/application-test.yml:15-16
Timestamp: 2025-11-18T09:42:14.128Z
Learning: In the our-campUS-BE project, the test configuration file (src/test/resources/application-test.yml) uses hardcoded dummy values for OAuth and JWT secrets, as these are arbitrary test values and not real credentials.

Applied to files:

  • src/main/resources/application-test.yml
📚 Learning: 2025-11-13T06:01:22.585Z
Learnt from: 1winhyun
Repo: our-campUS/our-campUS-BE PR: 4
File: .github/workflows/ci.yaml:35-37
Timestamp: 2025-11-13T06:01:22.585Z
Learning: In the our-campUS-BE project, Jasypt encryption is only used for dev and prod profiles, not for local or test environments. The test profile (application-test.yml) uses plain H2 in-memory database configuration without encryption. During CI, the gradle build step does not require the Jasypt password because it only compiles and packages the application without loading encrypted runtime configurations.

Applied to files:

  • src/main/resources/application-test.yml
🧬 Code graph analysis (2)
src/main/java/com/campus/campus/global/config/OciConfig.java (3)
src/main/java/com/campus/campus/global/config/SecurityConfig.java (1)
  • Configuration (24-68)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (1)
  • Slf4j (22-112)
src/main/java/com/campus/campus/domain/studentcouncilpost/application/service/StudentCouncilPostService.java (1)
  • Slf4j (35-265)
src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (3)
src/main/java/com/campus/campus/global/oci/exception/OciObjectDeleteFailException.java (1)
  • OciObjectDeleteFailException (5-9)
src/main/java/com/campus/campus/global/oci/exception/OciPresignedUrlCreateFailException.java (1)
  • OciPresignedUrlCreateFailException (5-9)
src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java (1)
  • PresignedUrlMapper (10-44)
🔇 Additional comments (10)
src/test/java/com/campus/campus/CampusApplicationTests.java (1)

25-26: 테스트에서 ObjectStorage 모킹이 올바르게 추가되었습니다.

OCI ObjectStorage 의존성을 @MockitoBean으로 모킹하여 테스트 환경에서 실제 OCI 클라이언트 초기화를 방지합니다. 구현이 정확합니다.

src/main/java/com/campus/campus/global/oci/application/service/PresignedUrlService.java (2)

31-42: Presigned URL 생성 로직이 올바르게 구현되었습니다.

디렉토리 경로와 UUID를 조합하여 고유한 오브젝트 이름을 생성하고, presigned PUT URL과 공개 이미지 URL을 반환합니다. 로직이 정확합니다.


69-75: 이미지 삭제 로직이 올바르게 구현되었습니다.

null 및 빈 문자열 검증 후 오브젝트 삭제를 수행합니다. 단, extractObjectNameFromUrl의 URL 디코딩 이슈가 수정되면 정상 동작합니다.

src/main/java/com/campus/campus/global/config/OciConfig.java (4)

91-93: Builder 패턴 사용으로 개선되었습니다.

이전 리뷰에서 지적된 deprecated constructor 대신 권장되는 builder 패턴을 사용하도록 수정되었습니다.


158-206: 파일 및 디렉토리 권한 설정이 올바르게 구현되었습니다.

POSIX 시스템에서는 700(디렉토리), 600(파일) 권한을 설정하고, Windows에서는 적절한 fallback을 제공합니다. 보안 모범 사례를 준수합니다.


230-264: 리소스 정리 로직이 올바르게 구현되었습니다.

@PreDestroy를 사용하여 애플리케이션 종료 시 키 파일 및 디렉토리를 정리합니다. 예외 처리도 적절합니다.


211-225: Object Storage URL 생성이 올바르게 구현되었습니다.

object name을 URL 인코딩하여 특수문자를 안전하게 처리합니다. 단, PresignedUrlService.extractObjectNameFromUrl에서 디코딩이 필요합니다.

src/main/resources/application-test.yml (3)

1-12: 테스트 환경에 적합한 데이터베이스 및 JPA 설정입니다.

H2 인메모리 데이터베이스와 create-drop 설정은 테스트 격리를 보장하며, MODE=MYSQL 옵션으로 프로덕션 MySQL과의 호환성을 유지하고 있습니다.


43-52: 테스트 환경에 적합한 OCI 설정입니다.

enabled: false 플래그로 OCI 서비스를 비활성화하고 더미 값을 사용하는 것은 테스트 환경에서 외부 클라우드 서비스 호출을 방지하는 좋은 방법입니다.


56-61: 외부 서비스 헬스체크 비활성화는 적절한 설정입니다.

테스트 환경에서 메일 및 Redis 헬스체크를 비활성화하여 외부 서비스가 사용 불가능한 경우에도 애플리케이션이 정상적으로 시작될 수 있도록 보장합니다. 이는 테스트 안정성을 향상시키는 좋은 방법입니다.

Based on learnings, 이 프로젝트에서는 테스트 환경에 대해 실제 외부 서비스 연결 없이 더미 설정을 사용하는 패턴을 따르고 있습니다.

Comment on lines 13 to 23
mail:
host: smtp.gmail.com
port: 587
username: illta.campus
password: abcd efgh ijkl mnop
properties:
mail:
smtp:
auth: true
starttls:
enable: true
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, verify the application-test.yml file exists and check its contents
find . -name "application-test.yml" -type f

Repository: our-campUS/our-campUS-BE

Length of output: 151


🏁 Script executed:

# Check the mail configuration in the test application.yml file
cat -n src/main/resources/application-test.yml

Repository: our-campUS/our-campUS-BE

Length of output: 1847


🏁 Script executed:

# Search for JavaMailSender usage in test code - check for mocking patterns
rg -n "@MockBean|@SpyBean" --type=java src/test/ | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

# Search for JavaMailSender bean definitions and usage in test code
rg -n "JavaMailSender|mailSender" --type=java src/test/ -A 2 | head -40

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

# Look for mail service tests and how they're configured
fd "mail" --type=f src/test/ --ignore-case

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

# Check if there are any @Bean definitions for mail in test configuration
rg -n "@Bean.*[Mm]ail|@Configuration" --type=java src/test/ -A 3 | head -30

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

cat -n src/test/resources/application-test.yml

Repository: our-campUS/our-campUS-BE

Length of output: 1599


🏁 Script executed:

# Search more broadly for any mail-related tests or configuration
rg -n "mail|Mail" --type=java src/test/ | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

# Check for any email service implementation
rg -n "email|Email|mail|Mail" --type=java src/main/java/ | grep -i "service\|class" | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 3502


🏁 Script executed:

# Search for test classes related to email verification
fd "EmailVerification" --type=f src/test/

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

# Search for any mail-related test configuration or test classes
fd -e java src/test/ | xargs grep -l "EmailVerification\|mail\|Mail" 2>/dev/null

Repository: our-campUS/our-campUS-BE

Length of output: 411


🏁 Script executed:

# Check the full implementation of EmailVerificationService to understand how JavaMailSender is used
cat -n src/main/java/com/campus/campus/domain/mail/application/service/EmailVerificationService.java

Repository: our-campUS/our-campUS-BE

Length of output: 5710


EmailVerificationService 테스트 시 메일 서비스를 모킹해야 합니다.

EmailVerificationService는 JavaMailSender를 직접 사용하여 메일을 발송하고 있으나, 현재 이 기능을 테스트하는 코드가 없습니다. 향후 EmailVerificationService에 대한 테스트를 작성할 때는 JavaMailSender를 @MockBean으로 선언하여 실제 SMTP 연결을 방지해야 합니다.

현재 test 설정의 더미 자격증명(비밀번호: abcd efgh ijkl mnop)과 비활성화된 메일 헬스체크는 의도적인 것으로 보이지만, 테스트 코드가 없으므로 실제 SMTP 연결 시도가 발생하지 않습니다. 메일 발송 로직의 테스트 추가 시 반드시 모킹 전략을 포함시켜 주세요.

🤖 Prompt for AI Agents
In src/main/resources/application-test.yml around lines 13 to 23, the comment
notes EmailVerificationService tests must mock JavaMailSender to avoid real SMTP
connections; when adding tests for EmailVerificationService, declare
JavaMailSender as a @MockBean (or use a Mockito mock) in the test configuration
and inject it into the service or application context so send operations are
stubbed, and ensure any mail-related health checks are disabled or mocked in the
test profile to prevent real network calls.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚙️Chore 인프라 ✨Feat 새로운 기능 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants