Skip to content

✨ Feat: S3 관련 설정, 파일 업로드 관련 기능 구현#37

Merged
imjuyongp merged 15 commits intodevelopfrom
feat/s3
Jan 31, 2026
Merged

✨ Feat: S3 관련 설정, 파일 업로드 관련 기능 구현#37
imjuyongp merged 15 commits intodevelopfrom
feat/s3

Conversation

@imjuyongp
Copy link
Copy Markdown
Member

@imjuyongp imjuyongp commented Jan 31, 2026

#️⃣ Issue Number

📝 요약(Summary)

S3 설정 관련

  • s3 의존성 추가
  • s3Client 생성 후 자격증명 수동 주입
  • StaticCredentialsProvider사용, 개발환경에서는 application.properties에서 찾도록 설정

게시글 파일 업로드 관련

  1. CreatePostRequest.java
  • imgUrl 필드 제거
  • toEntity() 메서드에 imgUrl 파라미터 추가
  1. PostService.java
  • createPost() 메서드에 MultipartFile image 파라미터 추가
  1. PostServiceImpl.java
  • S3Service 의존성 추가
  • 이미지가 있으면 S3에 업로드 후 URL 저장
  1. PostController.java
  • consumes = MediaType.MULTIPART_FORM_DATA_VALUE 추가
  • @RequestBody → @RequestPart("request") 변경
  • @RequestPart(value = "image", required = false) 추가

API 사용법

POST /api/posts/{property}
Content-Type: multipart/form-data

request: {"title": "제목", "content": "내용", "isAnonymous": false} (JSON)
image: [이미지 파일] (선택사항)

  • request는 JSON 형식으로 전송
  • image는 선택사항 (없어도 게시글 생성 가능)

동호회 파일 업로드 관련

  1. Club 엔티티
  • clubImage 필드 추가
  • updateClubImage() 메서드 추가
  1. ClubCreateRequest
  • toEntity(User user, String clubImage) 파라미터 추가
  1. ClubService / ClubServiceImpl
  • createClub()에 MultipartFile image 파라미터 추가
  • updateClubImage() 메서드 추가
  1. ClubImageResponse - 새로 생성

  2. ClubController

  • 동호회 생성: multipart/form-data로 변경
  • 동호회 사진 수정 API 추가

API 사용법

동호회 생성
POST /api/clubs
Content-Type: multipart/form-data

request: {"name": "축구 동호회", "introduce": "소개", "clubType": "SOCCER", "maxMembers": 20}
image: [이미지 파일] (선택사항)

동호회 사진 수정
POST /api/clubs/{clubId}/image
Content-Type: multipart/form-data

image: [이미지 파일] (선택)

🛠️ PR 유형

어떤 변경 사항이 있나요?

  • 새로운 기능 추가
  • 버그 수정
  • 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경)
  • 코드 리팩토링
  • 주석 추가 및 수정
  • 파일 혹은 폴더명 수정

📸스크린샷 (선택)

💬 공유사항 to 리뷰어

  • erd 수정사항 있습니다(필드명만) -> 확인 안해도 될 듯요
  • 게시글 사진 업로드 관련 논의 필요(업로드 가능한 사진 개수)
  • aws 관련 설정 환경변수 노션에 문서화

✅ PR Checklist

PR이 다음 요구 사항을 충족하는지 확인하세요.

  • 커밋 메시지 컨벤션에 맞게 작성했습니다.
  • 변경 사항에 대한 테스트를 했습니다.(버그 수정/기능에 대한 테스트).

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능
    • 동호회 생성 시 이미지를 첨부할 수 있습니다.
    • 동호회 이미지를 업데이트할 수 있습니다.
    • 게시글 작성 시 이미지를 첨부할 수 있습니다.
    • 프로필 이미지를 업로드할 수 있습니다.

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 31, 2026

개요

AWS S3 SDK v2로 업그레이드하고, 클럽·게시글·사용자 프로필 이미지 업로드 기능을 추가했습니다. S3 서비스, 이미지 검증, 멀티파트 요청 처리, 관련 DTO 및 엔드포인트가 구현되었습니다.

변경 사항

코호트 / 파일 요약
의존성 업그레이드
build.gradle
Spring Cloud AWS에서 AWS SDK v2 S3 클라이언트로 변경 (v2.41.18)
S3 인프라 구축
src/main/java/com/be/sportizebe/global/config/S3Config.java, src/main/java/com/be/sportizebe/global/s3/service/S3Service.java, src/main/java/com/be/sportizebe/global/s3/service/S3ServiceImpl.java
S3 클라이언트 설정, 파일 업로드/삭제 기능, 파일 검증(확장자, 크기 5MB 제한), URL 생성 로직 구현
S3 에러 처리 및 설정
src/main/java/com/be/sportizebe/global/s3/enums/PathName.java, src/main/java/com/be/sportizebe/global/s3/exception/S3ErrorCode.java, src/main/java/com/be/sportizebe/global/s3/convertor/MultipartJackson2HttpMessageConverter.java
경로 열거형(PROFILE, CLUB, POST), S3 관련 에러 코드, 멀티파트 메시지 변환기 추가
클럽 이미지 기능
src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java, src/main/java/com/be/sportizebe/domain/club/service/ClubService.java, src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java, src/main/java/com/be/sportizebe/domain/club/entity/Club.java, src/main/java/com/be/sportizebe/domain/club/dto/request/ClubCreateRequest.java, src/main/java/com/be/sportizebe/domain/club/dto/response/ClubImageResponse.java, src/main/java/com/be/sportizebe/domain/club/dto/response/ClubResponse.java
클럽 생성 시 이미지 업로드, 이미지 업데이트 엔드포인트 추가, 멀티파트 요청 처리, clubImage 필드 및 응답 DTO 추가
게시글 이미지 기능
src/main/java/com/be/sportizebe/domain/post/controller/PostController.java, src/main/java/com/be/sportizebe/domain/post/service/PostService.java, src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java, src/main/java/com/be/sportizebe/domain/post/dto/request/CreatePostRequest.java
게시글 생성 시 이미지 업로드 지원, 멀티파트 요청 처리, imgUrl을 메서드 파라미터로 변경
사용자 프로필 이미지 기능
src/main/java/com/be/sportizebe/domain/user/controller/UserController.java, src/main/java/com/be/sportizebe/domain/user/service/UserService.java, src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java, src/main/java/com/be/sportizebe/domain/user/entity/User.java, src/main/java/com/be/sportizebe/domain/user/dto/response/ProfileImageResponse.java
프로필 이미지 업로드 엔드포인트 추가, UserService를 인터페이스로 리팩토링, UserServiceImpl 구현체 추가, profileImage 필드 및 응답 DTO 추가
보안 설정 업데이트
src/main/java/com/be/sportizebe/global/security/SecurityConfig.java
/api/users/signup을 공개 엔드포인트로 명시, /api/users/** 인증 요구
리소스 업데이트
src/main/resources
서브모듈 커밋 해시 변경

시퀀스 다이어그램

sequenceDiagram
    participant Client as Client
    participant Controller as ClubController
    participant Service as ClubServiceImpl
    participant S3Svc as S3Service
    participant S3 as AWS S3
    participant DB as Database

    Client->>Controller: POST /clubs (multipart: request, image)
    Controller->>Controller: 요청 검증
    Controller->>Service: createClub(request, image, user)
    
    alt 이미지 제공됨
        Service->>S3Svc: uploadFile(PathName.CLUB, image)
        S3Svc->>S3Svc: 파일 검증 (확장자, 크기)
        S3Svc->>S3Svc: S3 키 생성
        S3Svc->>S3: putObject (파일 업로드)
        S3->>S3Svc: 업로드 완료
        S3Svc->>S3Svc: 파일 URL 생성
        S3Svc-->>Service: 파일 URL 반환
    end
    
    Service->>Service: Club 엔티티 생성 (clubImageUrl 포함)
    Service->>DB: save(club)
    DB-->>Service: 저장된 club
    Service-->>Controller: ClubResponse
    Controller-->>Client: 201 Created (ClubResponse)
Loading

예상 코드 리뷰 시간

🎯 4 (복잡함) | ⏱️ ~45분

관련 PR

  • PR #5: 게시글/댓글 도메인의 PostController와 CreatePostRequest 변경으로 메인 PR과 동일 클래스의 멀티파트 이미지 처리 구현이 겹칩니다.
  • PR #13: PostController의 createPost 시그니처와 CreatePostRequest.toEntity 메서드를 수정하여 동일 클래스/메서드의 편집이 메인 PR과 겹칩니다.
  • PR #33: 클럽, 게시글, 사용자 관련 컨트롤러/서비스/DTO의 이미지 필드 추가 및 멀티파트 처리를 구현하여 메인 PR과 동일 도메인의 변경이 겹칩니다.

제안 리뷰어

  • angoroa

축하 시

🐰 파일을 구름에 흩날리며
S3는 춤을 추고,
이미지 업로드 마법이 펼쳐지고,
검증은 조용히 지키고,
앞으로 나아가는 여정이 빛나네! ✨📸

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 PR의 주요 변경 사항(S3 설정 및 파일 업로드 기능)을 명확하게 요약하고 있습니다.
Linked Issues check ✅ Passed PR은 연결된 이슈 #36의 모든 체크리스트 항목(프로필 사진 업로드, 동호회 사진 업로드, 게시글 사진 업로드)을 완료했습니다.
Out of Scope Changes check ✅ Passed 모든 변경 사항이 S3 설정 및 파일 업로드 기능 구현과 관련된 범위 내 변경입니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/s3

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.

@angoroa
Copy link
Copy Markdown
Member

angoroa commented Jan 31, 2026

굿뜨

Copy link
Copy Markdown

@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: 4

Caution

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

⚠️ Outside diff range comments (1)
src/main/java/com/be/sportizebe/domain/post/service/PostServiceImpl.java (1)

62-74: ⚠️ Potential issue | 🟡 Minor

게시글 삭제 시 S3 이미지 정리 누락

deletePost에서 게시글을 삭제할 때 연관된 S3 이미지(post.getImgUrl())를 삭제하지 않습니다. 이로 인해 S3에 orphan 파일이 축적될 수 있습니다.

🧹 S3 이미지 삭제 추가 제안
 `@Override`
 `@Transactional`
 public void deletePost(Long postId, User user) {
   Post post = postRepository.findById(postId)
       .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND));

   // 작성자 확인
   if (post.getUser().getId() != user.getId()) {
     throw new CustomException(PostErrorCode.POST_DELETE_DENIED);
   }

+  // S3 이미지 삭제
+  if (post.getImgUrl() != null && !post.getImgUrl().isEmpty()) {
+    s3Service.deleteFile(post.getImgUrl());
+  }
+
   postRepository.delete(post);
 }
🤖 Fix all issues with AI agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`:
- Around line 87-110: The current updateClubImage deletes the old S3 file before
attempting to upload the new one, risking image loss if upload fails; change
updateClubImage (in ClubServiceImpl) to first call
s3Service.uploadFile(PathName.CLUB, image) and get the new clubImageUrl, then
update the entity (club.updateClubImage(clubImageUrl)), persist, and only after
successful upload/update call s3Service.deleteFile(oldClubImageUrl) to remove
the previous file; also add a guard to avoid deleting when oldClubImageUrl is
null or equals the new clubImageUrl and consider wrapping upload+update in
appropriate try/catch to avoid leaking the newly uploaded file on subsequent DB
failures.

In `@src/main/java/com/be/sportizebe/domain/user/controller/UserController.java`:
- Line 28: The controller is injecting the concrete class UserServiceImpl via
the field userService; change the dependency to the interface type UserService
and update any constructor or field injection points in UserController to accept
UserService instead of UserServiceImpl (and adjust imports) so Spring injects
the implementation while the controller depends on the interface for better DIP,
testability, and flexibility.

In `@src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java`:
- Around line 64-70: The current flow in UserServiceImpl deletes the existing S3
file via s3Service.deleteFile(user.getProfileImage()) before calling
s3Service.uploadFile(...), which risks losing the profile image if the upload
fails; change the sequence to first upload the new file with
s3Service.uploadFile(PathName.PROFILE, file), obtain the new profileImageUrl,
update the user's profileImage and persist the user, and only after a successful
persist attempt to delete the old image (user.getProfileImage()) via
s3Service.deleteFile; ensure you compare the old and new URLs to avoid deleting
the newly uploaded file and handle/log upload/persist failures accordingly.

In `@src/main/java/com/be/sportizebe/global/config/S3Config.java`:
- Around line 12-32: The class-level `@Getter` on S3Config is exposing sensitive
fields; update the class to avoid public getters for accessKey and secretKey by
either removing the class-level `@Getter` and adding explicit getters only for
non-sensitive fields (bucket, profile, club, post) or keep `@Getter` on the class
but add `@Getter`(AccessLevel.NONE) (Lombok) to the accessKey and secretKey fields
so they do not generate public getters; ensure you reference the S3Config class
and the accessKey and secretKey fields when making the change.
🧹 Nitpick comments (10)
src/main/java/com/be/sportizebe/global/s3/enums/PathName.java (1)

7-15: 필드가 없는 enum에는 Lombok 어노테이션이 불필요합니다.

이 enum은 필드가 없으므로 @Getter는 코드를 생성하지 않고, @RequiredArgsConstructor는 Java enum의 기본 private 생성자와 동일한 private 생성자만 생성합니다. 두 어노테이션 모두 제거해도 코드 동작에는 영향이 없습니다.

🧹 제안 수정안
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-
-@Getter
-@RequiredArgsConstructor
 public enum PathName {
src/main/java/com/be/sportizebe/global/config/S3Config.java (1)

34-43: 자격 증명 공급자와 리전을 환경별로 분리하는 것을 권장합니다.

현재 코드는 StaticCredentialsProvider로 고정된 자격 증명을 사용하고 리전도 AP_NORTHEAST_2로 하드코딩되어 있어, 운영 환경에서 자격 증명 체인 활용과 리전 변경이 제한됩니다. AWS SDK for Java v2의 공식 가이드에 따르면 프로덕션에서는 DefaultCredentialsProvider를 기본값으로 사용하는 것이 권장되며, 이는 환경 변수, IAM 역할, OIDC 등 보안 자격 증명 체인을 활용합니다. 개발 환경에서만 필요시 정적 키를 사용하도록 분리하면 더 안전합니다.

♻️ 제안 수정안
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.services.s3.S3Client;

+  `@Value`("${spring.cloud.aws.region:ap-northeast-2}")
+  private String region;

   `@Bean`
   S3Client s3Client() {
-    AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
+    AwsCredentialsProvider credentialsProvider =
+        (accessKey == null || accessKey.isBlank() || secretKey == null || secretKey.isBlank())
+            ? DefaultCredentialsProvider.create()
+            : StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));

     return S3Client.builder()
-      .region(Region.AP_NORTHEAST_2)
+      .region(Region.of(region))
-      .credentialsProvider(StaticCredentialsProvider.create(credentials))
+      .credentialsProvider(credentialsProvider)
       .build();
   }
src/main/java/com/be/sportizebe/global/s3/convertor/MultipartJackson2HttpMessageConverter.java (2)

1-1: 패키지명 오타: convertorconverter

패키지명에 오타가 있습니다. 영어 철자법에 따르면 converter가 올바른 표기입니다.


12-17: 주석과 실제 구현 간 불일치

주석은 "multipart/form-data" 헤더 지원을 언급하지만, 실제로는 MediaType.APPLICATION_OCTET_STREAM으로 등록되어 있습니다. 이 컨버터의 목적은 Swagger/OpenAPI와 함께 multipart 요청을 처리할 때 Jackson이 application/octet-stream 타입을 읽을 수 있도록 하면서 쓰기는 방지하는 것입니다. 주석을 실제 동작에 맞게 수정하는 것을 권장합니다.

📝 주석 수정 제안
 `@Component`
 public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
   /**
-   * "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기
+   * Swagger/OpenAPI multipart 요청 처리를 위한 HTTP 메시지 변환기.
+   * APPLICATION_OCTET_STREAM 타입 읽기를 허용하고, 쓰기는 비활성화합니다.
    */
   public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
     super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
   }
src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java (1)

29-56: 코드 들여쓰기 불일치

메서드 내 들여쓰기가 일관되지 않습니다. Lines 33-36은 4칸 들여쓰기를, Lines 38-41은 2칸 들여쓰기를 사용합니다. 일관성을 위해 들여쓰기를 통일해 주세요.

src/main/java/com/be/sportizebe/global/s3/service/S3ServiceImpl.java (3)

113-117: 하드코딩된 AWS 리전

리전 ap-northeast-2가 하드코딩되어 있습니다. 환경별로 다른 리전을 사용할 수 있으므로, S3Config에서 리전 값을 주입받도록 수정하는 것을 권장합니다.

♻️ 리전 설정 외부화 제안
   // s3 url : https://{버킷명}.s3.ap-northeast-2.amazonaws.com/{파일명}
   private String getFileUrl(String fileName) {
-    return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/%s",
-        s3Config.getBucket(), fileName);
+    return String.format("https://%s.s3.%s.amazonaws.com/%s",
+        s3Config.getBucket(), s3Config.getRegion(), fileName);
   }

54-69: deleteFile 메서드 예외 처리 부재

deleteFile에서 S3 클라이언트 호출 시 발생할 수 있는 예외(S3Exception 등)에 대한 처리가 없습니다. uploadFile과 동일하게 try-catch로 감싸서 로깅하고 적절한 에러를 반환하는 것이 좋습니다.

🛡️ 예외 처리 추가 제안
   `@Override`
   public void deleteFile(String fileUrl) {
     if (fileUrl == null || !fileUrl.contains(".com/")) {
       throw new CustomException(S3ErrorCode.FILE_URL_INVALID);
     }

     String fileName = extractFileName(fileUrl);

-    DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
-        .bucket(s3Config.getBucket())
-        .key(fileName)
-        .build();
-
-    s3Client.deleteObject(deleteObjectRequest);
-    log.info("S3 파일 삭제 완료: {}", fileName);
+    try {
+      DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
+          .bucket(s3Config.getBucket())
+          .key(fileName)
+          .build();
+
+      s3Client.deleteObject(deleteObjectRequest);
+      log.info("S3 파일 삭제 완료: {}", fileName);
+    } catch (Exception e) {
+      log.error("S3 파일 삭제 실패: {}", e.getMessage());
+      throw new CustomException(S3ErrorCode.FILE_SERVER_ERROR);
+    }
   }

107-111: URL 파싱 로직의 취약성

extractFileName.com/ 문자열을 기준으로 파싱합니다. 이 방식은 S3 URL 형식이 변경되거나 다른 형식의 URL이 전달될 경우 오작동할 수 있습니다. 보다 견고한 URL 파싱을 위해 java.net.URI를 사용하는 것을 고려해 보세요.

♻️ URI 파싱 사용 제안
   // 파일명 추출
   private String extractFileName(String fileUrl) {
-    // https://bucket.s3.region.amazonaws.com/folder/filename.ext -> folder/filename.ext
-    return fileUrl.substring(fileUrl.indexOf(".com/") + 5);
+    try {
+      java.net.URI uri = java.net.URI.create(fileUrl);
+      String path = uri.getPath();
+      // path starts with "/" so remove it
+      return path.startsWith("/") ? path.substring(1) : path;
+    } catch (IllegalArgumentException e) {
+      throw new CustomException(S3ErrorCode.FILE_URL_INVALID);
+    }
   }
src/main/java/com/be/sportizebe/domain/user/controller/UserController.java (1)

3-3: 사용되지 않는 import

AuthErrorCode가 import되었지만 이 파일에서 사용되지 않습니다.

🧹 사용되지 않는 import 제거
-import com.be.sportizebe.domain.auth.exception.AuthErrorCode;
src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java (1)

30-46: DB 저장 실패 시 S3 파일이 고아로 남을 수 있습니다.

업로드 후 저장 과정에서 예외가 나면 파일이 정리되지 않습니다. 업로드 성공 후 저장 단계에서 예외가 발생할 때 S3 정리를 보장해 주세요.

🔧 제안 수정안
-    String clubImageUrl = null;
-    if (image != null && !image.isEmpty()) {
-      clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
-    }
-
-    // 동호회 엔티티 생성
-    Club club = request.toEntity(user, clubImageUrl);
-    clubRepository.save(club);
+    String clubImageUrl = null;
+    try {
+      if (image != null && !image.isEmpty()) {
+        clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
+      }
+
+      // 동호회 엔티티 생성
+      Club club = request.toEntity(user, clubImageUrl);
+      clubRepository.save(club);
+    } catch (RuntimeException e) {
+      if (clubImageUrl != null) {
+        s3Service.deleteFile(clubImageUrl);
+      }
+      throw e;
+    }

Comment on lines +87 to +110
@Override
@Transactional
public ClubImageResponse updateClubImage(Long clubId, MultipartFile image, User user) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND));

// 동호회 방장만 수정 가능하도록 검증
if (club.getLeader().getId() != user.getId()) {
throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED);
}

// 기존 이미지가 있으면 S3에서 삭제
if (club.getClubImage() != null) {
s3Service.deleteFile(club.getClubImage());
}

// 새 이미지 S3에 업로드
String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);

// 동호회 이미지 URL 업데이트
club.updateClubImage(clubImageUrl);

return ClubImageResponse.from(clubImageUrl);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

기존 이미지 삭제를 업로드 성공 이후로 미루세요.

현재 순서는 업로드 실패 시 기존 이미지가 소실됩니다. 새 이미지 업로드 성공 후에 기존 이미지를 삭제하는 방식이 더 안전합니다.

✅ 제안 수정안
-    // 기존 이미지가 있으면 S3에서 삭제
-    if (club.getClubImage() != null) {
-      s3Service.deleteFile(club.getClubImage());
-    }
-
-    // 새 이미지 S3에 업로드
-    String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
-
-    // 동호회 이미지 URL 업데이트
-    club.updateClubImage(clubImageUrl);
-
-    return ClubImageResponse.from(clubImageUrl);
+    String oldImageUrl = club.getClubImage();
+
+    // 새 이미지 S3에 업로드
+    String clubImageUrl = s3Service.uploadFile(PathName.CLUB, image);
+
+    // 동호회 이미지 URL 업데이트
+    club.updateClubImage(clubImageUrl);
+
+    // 기존 이미지가 있으면 S3에서 삭제
+    if (oldImageUrl != null) {
+      s3Service.deleteFile(oldImageUrl);
+    }
+
+    return ClubImageResponse.from(clubImageUrl);
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/club/service/ClubServiceImpl.java`
around lines 87 - 110, The current updateClubImage deletes the old S3 file
before attempting to upload the new one, risking image loss if upload fails;
change updateClubImage (in ClubServiceImpl) to first call
s3Service.uploadFile(PathName.CLUB, image) and get the new clubImageUrl, then
update the entity (club.updateClubImage(clubImageUrl)), persist, and only after
successful upload/update call s3Service.deleteFile(oldClubImageUrl) to remove
the previous file; also add a guard to avoid deleting when oldClubImageUrl is
null or equals the new clubImageUrl and consider wrapping upload+update in
appropriate try/catch to avoid leaking the newly uploaded file on subsequent DB
failures.

public class UserController {

private final UserService userService;
private final UserServiceImpl userService;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

인터페이스 대신 구체 클래스 주입 사용

UserServiceImpl 구체 클래스를 직접 주입하고 있습니다. 의존성 역전 원칙(DIP)에 따라 인터페이스 타입(UserService)을 사용하는 것이 권장됩니다. 이는 테스트 용이성과 유연성을 높여줍니다.

♻️ 인터페이스 타입 사용 제안
-    private final UserServiceImpl userService;
+    private final UserService userService;

import도 함께 수정:

-import com.be.sportizebe.domain.user.service.UserServiceImpl;
+import com.be.sportizebe.domain.user.service.UserService;
📝 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
private final UserServiceImpl userService;
private final UserService userService;
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/user/controller/UserController.java`
at line 28, The controller is injecting the concrete class UserServiceImpl via
the field userService; change the dependency to the interface type UserService
and update any constructor or field injection points in UserController to accept
UserService instead of UserServiceImpl (and adjust imports) so Spring injects
the implementation while the controller depends on the interface for better DIP,
testability, and flexibility.

Comment on lines +64 to +70
// 기존 프로필 이미지가 있으면 S3에서 삭제
if (user.getProfileImage() != null) {
s3Service.deleteFile(user.getProfileImage());
}

// 새 프로필 이미지 S3에 업로드
String profileImageUrl = s3Service.uploadFile(PathName.PROFILE, file);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

기존 이미지 삭제 후 업로드 실패 시 데이터 손실 가능성

현재 로직에서 기존 프로필 이미지를 먼저 S3에서 삭제한 후 새 이미지를 업로드합니다. 만약 새 이미지 업로드가 실패하면 사용자는 프로필 이미지를 잃게 됩니다. 업로드 성공 후 기존 이미지를 삭제하는 순서로 변경하는 것을 권장합니다.

🛡️ 순서 변경 제안
     `@Override`
     `@Transactional`
     public ProfileImageResponse uploadProfileImage(Long userId, MultipartFile file) {
         User user = userRepository.findById(userId)
             .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));

-        // 기존 프로필 이미지가 있으면 S3에서 삭제
-        if (user.getProfileImage() != null) {
-            s3Service.deleteFile(user.getProfileImage());
-        }
+        String oldImageUrl = user.getProfileImage();

         // 새 프로필 이미지 S3에 업로드
         String profileImageUrl = s3Service.uploadFile(PathName.PROFILE, file);

         // 사용자 프로필 이미지 URL 업데이트
         user.updateProfileImage(profileImageUrl);

+        // 새 이미지 업로드 성공 후 기존 이미지 삭제
+        if (oldImageUrl != null) {
+            s3Service.deleteFile(oldImageUrl);
+        }
+
         log.info("사용자 프로필 이미지 업로드 완료: userId={}, url={}", userId, profileImageUrl);

         return ProfileImageResponse.from(profileImageUrl);
     }
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/domain/user/service/UserServiceImpl.java`
around lines 64 - 70, The current flow in UserServiceImpl deletes the existing
S3 file via s3Service.deleteFile(user.getProfileImage()) before calling
s3Service.uploadFile(...), which risks losing the profile image if the upload
fails; change the sequence to first upload the new file with
s3Service.uploadFile(PathName.PROFILE, file), obtain the new profileImageUrl,
update the user's profileImage and persist the user, and only after a successful
persist attempt to delete the old image (user.getProfileImage()) via
s3Service.deleteFile; ensure you compare the old and new URLs to avoid deleting
the newly uploaded file and handle/log upload/persist failures accordingly.

Comment on lines +12 to +32
@Getter
@Configuration
public class S3Config {

@Value("${spring.cloud.aws.s3.bucket}")
private String bucket; // 버킷 명

@Value("${spring.cloud.aws.s3.path.profile}")
private String profile; // 프로필 사진

@Value("${spring.cloud.aws.s3.path.club}")
private String club;

@Value("${spring.cloud.aws.s3.path.post}")
private String post;

@Value("${spring.cloud.aws.credentials.access-key}")
private String accessKey;

@Value("${spring.cloud.aws.credentials.secret-key}")
private String secretKey;
Copy link
Copy Markdown

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:

# 전체 S3Config.java 파일 내용 확인
find . -name "S3Config.java" -type f

Repository: Sportize/be

Length of output: 116


🏁 Script executed:

# S3Config 사용 패턴 검색 - getAccessKey/getSecretKey 호출 확인
rg "S3Config|getAccessKey|getSecretKey" --type java -B 2 -A 2

Repository: Sportize/be

Length of output: 1429


🌐 Web query:

Lombok @Getter annotation class-level behavior on all fields including private credentials

💡 Result:

Putting Lombok’s @Getter on a class is equivalent to putting @Getter on every non-static field in that class (unless that field already has its own @Getter). That includes private fields, so a private credential like private String password; will get a getter method generated (by default, public) such as public String getPassword(). (projectlombok.org)

To avoid generating a getter for sensitive fields, override at the field level:

import lombok.AccessLevel;
import lombok.Getter;

`@Getter`
class User {
  private String username;

  `@Getter`(AccessLevel.NONE)
  private String password;   // no getter generated
}

AccessLevel.NONE is the Lombok-supported way to suppress generation for a specific field, even when the class is annotated. (projectlombok.org)

Citations:


🌐 Web query:

Spring Boot AWS S3 credentials security best practices sensitive data exposure

💡 Result:

Spring Boot + AWS S3 credentials security best practices (avoid sensitive data exposure)

1) Don’t hardcode AWS keys anywhere (code, application.yml, repos)

  • AWS explicitly warns not to put literal access keys in application files and not to include credential files in your project (they’re often plaintext). [1]

2) Prefer temporary credentials via IAM roles (no long‑term keys)

  • For workloads on AWS (EC2/ECS/EKS/Lambda), use an IAM role so the app automatically receives short‑term STS credentials and you don’t distribute long-lived keys. [2]
  • S3 guidance similarly recommends IAM roles and not storing credentials in the app or instance. [3]
  • Using short-term credentials is a core mitigation for credential compromise. [7]

3) In Spring Boot/Spring Cloud AWS, use the default provider chain (not static properties)

  • Spring Cloud AWS can use a static credentials provider when cloud.aws.credentials.access-key is set—avoid that for anything beyond local throwaway testing. [4]
  • Prefer enabling the default AWS credentials chain, which will pick up role-based credentials (and other standard sources) without embedding secrets:
    cloud.aws.credentials.use-default-aws-credentials-chain=true
    [4]

4) For local development: use SSO / Identity Center or scoped dev keys (only if necessary)

  • AWS guidance recommends avoiding IAM users for “purpose-built software” and using federation (for example, IAM Identity Center) instead of long-term credentials. [1]
  • If dev keys are unavoidable in a sandbox: don’t share them, enforce least privilege, rotate/audit, and enable MFA. [5]

5) Reduce blast radius: least privilege + tight S3 access controls

  • Grant the app role only the required S3 actions on the required bucket/prefix (least privilege is a primary IAM best practice). [2]

6) Detect and respond to exposure

  • Use monitoring/classification tooling (for example, Macie) to detect credentials or other sensitive data that may have landed in S3 and remediate quickly. [6]

🏁 Script executed:

cat -n ./src/main/java/com/be/sportizebe/global/config/S3Config.java

Repository: Sportize/be

Length of output: 1781


🏁 Script executed:

# S3Client bean 생성 및 credentials 사용 패턴 검색
rg "S3Client|AwsBasicCredentials|Credentials" -B 3 -A 3 --type java

Repository: Sportize/be

Length of output: 6058


🏁 Script executed:

# S3Config 내에서 accessKey/secretKey 실제 사용 확인
rg -A 20 "public class S3Config" src/main/java/com/be/sportizebe/global/config/S3Config.java

Repository: Sportize/be

Length of output: 554


클래스 레벨 @Getter로 인한 비밀키 public getter 자동 생성 제거 필요

클래스 레벨 @GetteraccessKey/secretKey 포함 모든 필드에 대해 public getter를 자동 생성하므로, 의도하지 않은 노출 위험이 있습니다. 필드 레벨에서 선택적으로 getter를 제어하거나 민감정보 필드에 @Getter(AccessLevel.NONE)을 명시하는 구성이 필요합니다.

🔒 제안 수정안
-@Getter
 `@Configuration`
 public class S3Config {

+  `@Getter`
   `@Value`("${spring.cloud.aws.s3.bucket}")
   private String bucket; // 버킷 명

+  `@Getter`
   `@Value`("${spring.cloud.aws.s3.path.profile}")
   private String profile; // 프로필 사진

+  `@Getter`
   `@Value`("${spring.cloud.aws.s3.path.club}")
   private String club;

+  `@Getter`
   `@Value`("${spring.cloud.aws.s3.path.post}")
   private String post;

+  `@Getter`(AccessLevel.NONE)
   `@Value`("${spring.cloud.aws.credentials.access-key}")
   private String accessKey;

+  `@Getter`(AccessLevel.NONE)
   `@Value`("${spring.cloud.aws.credentials.secret-key}")
   private String secretKey;
🤖 Prompt for AI Agents
In `@src/main/java/com/be/sportizebe/global/config/S3Config.java` around lines 12
- 32, The class-level `@Getter` on S3Config is exposing sensitive fields; update
the class to avoid public getters for accessKey and secretKey by either removing
the class-level `@Getter` and adding explicit getters only for non-sensitive
fields (bucket, profile, club, post) or keep `@Getter` on the class but add
`@Getter`(AccessLevel.NONE) (Lombok) to the accessKey and secretKey fields so they
do not generate public getters; ensure you reference the S3Config class and the
accessKey and secretKey fields when making the change.

@imjuyongp imjuyongp merged commit 5ba591d into develop Jan 31, 2026
1 check passed
@imjuyongp imjuyongp deleted the feat/s3 branch January 31, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨Feat: S3 적용, 파일 업로드 처리

2 participants