Skip to content

Conversation

@hyobin-yang
Copy link
Contributor

S3 이미지 업로드 기능 구현

개요

기존의 이미지 URL을 String으로 직접 저장하는 방식에서, 실제 이미지 파일을 AWS S3에 업로드하고 업로드된 이미지 URL을 저장하는 방식으로 변경했습니다. 프로필 이미지와 포스트 이미지 업로드를 지원합니다.

구현 내용

1. API 엔드포인트

POST /images/upload

  • Content-Type: multipart/form-data
  • 요청 파라미터: image (MultipartFile)
  • 응답:
    {
      "imageUrl": "https://bucket-name.s3.ap-northeast-2.amazonaws.com/images/uuid.jpg"
    }

2. 파일 크기 제한: 10MB

이미지 파일 크기를 10MB로 제한한 이유:

  1. 서버 메모리(RAM) 보호

    • MultipartFile은 업로드 시 서버 메모리에 임시로 저장됩니다
    • 대용량 파일 업로드 시 서버 메모리 부족으로 인한 OutOfMemoryError 방지
    • 동시 업로드 요청이 많을 경우 메모리 사용량이 급증하는 것을 방지
  2. 충분한 크기

    • 10MB는 고화질 사진 한 장(예: 4000x3000 해상도 JPEG)으로도 충분한 크기
    • 일반적인 웹/모바일 앱에서 사용하는 이미지 크기를 고려한 적절한 제한
  3. 네트워크 및 처리 시간 최적화

    • 업로드 시간 단축으로 사용자 경험 개선
    • S3 전송 비용 절감

3. S3Client 사용 이유

S3Template 대신 AWS SDK v2의 S3Client를 직접 사용한 이유:

  1. 영구적인 이미지 URL 제공

    • S3TemplategetObjectUrl() 메서드는 기본적으로 Presigned URL을 생성합니다
    • Presigned URL은 만료 시간이 있어 영구적인 이미지 제공에 부적합합니다
    • 직접 https://버킷명.s3.리전.amazonaws.com/키 형태의 문자열을 생성하는 방식이 영구적인 이미지 제공에 더 적합합니다
  2. 명시적인 URL 생성

    • URL 형식을 직접 제어할 수 있어 일관성 있는 URL 구조 유지 가능
    • 버킷 정책을 통해 공개 읽기 권한을 설정하면 영구적으로 접근 가능한 URL 제공
  3. 더 세밀한 제어

    • AWS SDK v2를 직접 사용하여 더 세밀한 설정과 제어 가능
    • 향후 추가 기능(예: 메타데이터 설정, 태깅 등) 확장 용이

4. S3 URL 생성 규칙

S3 이미지 URL은 다음 규칙으로 생성됩니다:

https://{bucketName}.s3.{region}.amazonaws.com/{key}

구성 요소:

  • bucketName: application.yml에서 설정한 S3 버킷 이름
  • region: AWS 리전 (기본값: ap-northeast-2)
  • key: images/{UUID}.{확장자} 형식

예시:

https://kldd-images-bucket.s3.ap-northeast-2.amazonaws.com/images/550e8400-e29b-41d4-a716-446655440000.jpg

파일명 생성 규칙:

  • 원본 파일명의 확장자를 추출
  • UUID를 사용하여 고유한 파일명 생성
  • images/ 디렉토리 하위에 저장하여 구조화

UUID 사용 이유:

  1. 파일명 충돌 방지

    • 여러 사용자가 동일한 파일명으로 업로드해도 충돌 없이 저장 가능
    • 타임스탬프 기반 파일명보다 더 안전하고 예측 불가능한 파일명 생성
  2. 보안 강화

    • 원본 파일명을 노출하지 않아 파일 시스템 구조나 사용자 정보 유출 방지
    • 예측 가능한 파일명을 통한 무단 접근 시도 차단
  3. 고유성 보장

    • UUID는 전역적으로 고유한 식별자를 보장하여 파일 덮어쓰기 방지
    • 동일한 이미지를 여러 번 업로드해도 각각 별도의 파일로 저장
  4. 확장성

    • 분산 환경에서도 중복 없이 파일명 생성 가능
    • 데이터베이스나 외부 시스템과의 연동 시 고유 식별자로 활용 가능

5. 허용하는 파일 형식

다음 이미지 형식만 업로드 가능합니다:

  • JPEG (image/jpeg, image/jpg)
  • PNG (image/png)
  • GIF (image/gif)

검증 방식:

  • MultipartFile.getContentType()을 사용하여 MIME 타입 검증
  • 파일 확장자가 아닌 실제 Content-Type 헤더를 확인하여 보안 강화

6. 예외 처리

다음과 같은 예외 상황을 처리합니다:

에러 코드 HTTP 상태 메시지 발생 조건
IMAGE_400 400 Bad Request 지원하지 않는 이미지 형식입니다. (jpg, jpeg, png, gif만 지원) 허용되지 않은 Content-Type
IMAGE_401 400 Bad Request 파일 크기가 너무 큽니다. (최대 10MB) 파일 크기 > 10MB
IMAGE_402 400 Bad Request 파일이 비어있습니다. null 또는 빈 파일
IMAGE_500 500 Internal Server Error 이미지 업로드에 실패했습니다. S3 업로드 실패 또는 IOException

7. 파일 업로드 프로세스

  1. 파일 검증

    • 파일이 null이거나 비어있는지 확인
    • 파일 크기 제한 확인 (10MB)
    • Content-Type 검증
  2. 파일명 생성

    • 원본 파일명에서 확장자 추출
    • UUID를 사용하여 고유한 파일명 생성
  3. S3 업로드

    • 메타데이터 설정 (Content-Type, Content-Length)
    • InputStream을 사용하여 메모리 효율적인 업로드
  4. URL 생성 및 반환

    • 영구적인 S3 URL 문자열 생성
    • 업로드 성공 로그 기록

8. 컨트롤러 구현

  • @RequestPart 사용: multipart/form-data 요청에서 특정 파트를 명확하게 지정
  • consumes = MediaType.MULTIPART_FORM_DATA_VALUE: Content-Type 명시
  • 파라미터명: image (클라이언트에서 image 필드로 전송)

9. 설정

  • 파일 크기 제한: application.yml에서 max-file-size: 10MB 설정
  • S3 설정: application-secret.yml 또는 application-prod.yml에서 버킷명, 자격 증명, 리전 설정
  • S3Client Bean: AppConfig에서 AWS 자격 증명을 사용하여 S3Client Bean 생성

10. 보안 고려사항

  1. 파일 형식 검증: Content-Type 헤더를 확인하여 실제 파일 형식 검증
  2. 파일 크기 제한: 서버 메모리 보호 및 DoS 공격 방지
  3. 고유 파일명: UUID 사용으로 파일명 충돌 및 보안 이슈 방지
  4. S3 버킷 정책: 공개 읽기 권한 설정 필요 (이미지 URL 접근을 위해)

12. 향후 개선 사항

  • 이미지 리사이징 기능 (썸네일 생성)
  • 이미지 삭제 기능
  • 이미지 최적화 (압축)
  • CDN 연동 (CloudFront)

@hyobin-yang hyobin-yang self-assigned this Jan 5, 2026
@hyobin-yang hyobin-yang linked an issue Jan 5, 2026 that may be closed by this pull request
@gemini-code-assist
Copy link

Summary of Changes

Hello @hyobin-yang, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 Pull Request는 애플리케이션의 이미지 저장 방식을 AWS S3 기반으로 전환하여, 기존의 단순 URL 저장 방식의 한계를 극복하고 이미지 관리의 안정성과 확장성을 확보합니다. 사용자 프로필 및 게시물 이미지 업로드를 위한 전용 API를 제공하며, 파일 크기 및 형식 검증, 고유 파일명 생성 등 견고한 이미지 처리 로직을 포함합니다. 이를 통해 사용자 경험을 개선하고, 향후 이미지 관련 기능 확장을 위한 기반을 마련합니다.

Highlights

  • S3 이미지 업로드 기능 구현: 기존의 이미지 URL을 String으로 직접 저장하는 방식에서 AWS S3에 실제 이미지 파일을 업로드하고, 업로드된 이미지의 영구적인 URL을 저장하는 방식으로 변경되었습니다. 프로필 이미지와 포스트 이미지 업로드를 지원합니다.
  • 새로운 이미지 업로드 API 엔드포인트: POST /images/upload 엔드포인트가 추가되어 multipart/form-data 형식으로 이미지 파일을 받아 S3에 업로드하고, 생성된 이미지 URL 목록을 반환합니다.
  • 파일 크기 및 형식 제한: 업로드되는 이미지 파일은 최대 10MB로 제한되며, JPEG, PNG, GIF 형식만 허용됩니다. 이는 서버 메모리 보호, 네트워크 최적화 및 보안 강화를 위한 조치입니다.
  • AWS SDK v2 S3Client 직접 사용: S3Template 대신 AWS SDK v2의 S3Client를 직접 사용하여 Presigned URL이 아닌 영구적인 이미지 URL을 제공하고, 더 세밀한 제어를 가능하게 했습니다.
  • UUID 기반 파일명 생성: S3에 저장되는 이미지 파일명은 UUID를 사용하여 고유하게 생성되며, images/{UUID}.{확장자} 형식으로 저장됩니다. 이는 파일명 충돌 방지, 보안 강화 및 고유성 보장을 위함입니다.
  • 회원가입 시 프로필 이미지 업로드 지원: 회원가입 API(POST /auth/signup)가 multipart/form-data를 통해 프로필 이미지 파일 업로드를 지원하도록 변경되었습니다. 이미지를 제공하지 않으면 기본 이미지가 사용됩니다.
  • 예외 처리 강화: 이미지 업로드 관련 다양한 예외 상황(지원하지 않는 형식, 파일 크기 초과, 빈 파일, S3 업로드 실패 등)에 대한 구체적인 에러 코드와 메시지를 정의하고, GlobalExceptionHandler를 통해 처리하도록 개선되었습니다.
  • AWS S3 설정 및 의존성 추가: build.gradle에 AWS SDK BOM 의존성이 추가되었고, application.ymldocker-compose.yml에 S3 버킷, 자격 증명, 리전 등 AWS S3 관련 설정이 추가되었습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces AWS S3 image upload functionality. Key changes include adding the AWS SDK v2 BOM to build.gradle, configuring S3 credentials and bucket details in application.yml files, and setting multipart file size limits. A new S3ImageService was created to handle image uploads, including validation for file type, size, and emptiness, and generating unique filenames. A new ImageController provides an endpoint for uploading multiple images to S3, returning their URLs. The AuthService and AuthController were modified to integrate this new image upload capability into the user signup process, allowing users to upload a profile image as a MultipartFile instead of providing a URL, and removing the profileImage field from SignupRequestDto. Error handling in GlobalExceptionHandler was enhanced to better manage BusinessExceptions wrapped within other exceptions. Review comments suggest improving the S3Client configuration to use DefaultCredentialsProvider for more flexible credential management, changing ALLOWED_CONTENT_TYPES to a Set for better performance, refactoring the uploadImages method to use Java Streams, and enhancing the filename generation logic for robustness. Additionally, it was suggested to include the user ID in S3 object keys for better organization and access control.

Comment on lines +40 to +46
public S3Client s3Client() {
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}

Choose a reason for hiding this comment

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

high

StaticCredentialsProvider를 사용하여 자격 증명을 명시적으로 설정하는 것보다 DefaultCredentialsProvider.create()를 사용하는 것이 더 유연하고 안전합니다. DefaultCredentialsProvider는 환경 변수, 시스템 속성, 자격 증명 파일, EC2/ECS 인스턴스 프로필 등 표준적인 위치에서 자동으로 자격 증명을 찾아 사용합니다. 이렇게 하면 로컬 개발 환경과 실제 배포 환경(e.g., EC2)에서 코드를 변경하지 않고도 각 환경에 맞는 자격 증명을 사용할 수 있습니다. 이 변경을 적용하면 accessKey, secretKey 필드와 관련 @Value 어노테이션, 그리고 AwsBasicCredentials, StaticCredentialsProvider import를 제거할 수 있습니다.

Suggested change
public S3Client s3Client() {
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider.create())
.build();
}

Comment on lines 27 to 29
private static final List<String> ALLOWED_CONTENT_TYPES = Arrays.asList(
"image/jpeg", "image/jpg", "image/png", "image/gif"
);

Choose a reason for hiding this comment

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

medium

ALLOWED_CONTENT_TYPESList 대신 Set으로 관리하면 contains 연산의 시간 복잡도가 O(N)에서 O(1)로 개선되어 성능상 이점이 있습니다. 상수의 경우 Set.of()를 사용하여 불변 Set으로 선언하는 것을 권장합니다. 이 변경과 함께 java.util.Set을 import하고, java.util.Arrays import는 제거해주세요.

Suggested change
private static final List<String> ALLOWED_CONTENT_TYPES = Arrays.asList(
"image/jpeg", "image/jpg", "image/png", "image/gif"
);
private static final java.util.Set<String> ALLOWED_CONTENT_TYPES = java.util.Set.of(
"image/jpeg", "image/jpg", "image/png", "image/gif"
);

Comment on lines 41 to 49
public List<String> uploadImages(List<MultipartFile> images) {
List<String> uploadedUrls = new ArrayList<>();

for (MultipartFile image : images) {
String url = uploadImage(image);
uploadedUrls.add(url);
}

return uploadedUrls;

Choose a reason for hiding this comment

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

medium

for 루프를 사용하는 대신 Java Stream API를 사용하면 코드를 더 간결하게 작성할 수 있습니다.

    public List<String> uploadImages(List<MultipartFile> images) {
        return images.stream()
                .map(this::uploadImage)
                .toList();
    }


try {
String fileName = generateFileName(file.getOriginalFilename());
String key = "images/" + fileName;

Choose a reason for hiding this comment

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

medium

현재 S3 객체 키를 images/{UUID}.{확장자} 형식으로 생성하고 있습니다. 파일을 사용자별로 관리하고, 추후 사용자별 접근 정책을 적용하기 용이하도록 키 경로에 사용자 ID를 포함하는 것을 고려해볼 수 있습니다. 예를 들어, images/{userId}/{UUID}.{확장자}와 같은 구조를 사용하면 좋습니다. 이를 위해 uploadImageuploadImages 메소드가 User 객체나 userId를 파라미터로 받도록 수정이 필요합니다.

Comment on lines +98 to +104
private String generateFileName(String originalFilename) {
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
return UUID.randomUUID() + extension;
}

Choose a reason for hiding this comment

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

medium

현재 확장자 추출 로직은 .bashrc와 같이 점으로 시작하는 파일의 경우 .bashrc 전체를 확장자로 인식하고, myfile.와 같이 점으로 끝나는 파일의 경우 .을 확장자로 인식하는 등 예외적인 케이스에 취약할 수 있습니다. 보다 안전하게 확장자를 추출하도록 로직을 개선하는 것을 제안합니다.

    private String generateFileName(String originalFilename) {
        String extension = "";
        if (originalFilename != null) {
            int dotIndex = originalFilename.lastIndexOf('.');
            if (dotIndex > 0 && dotIndex < originalFilename.length() - 1) {
                extension = originalFilename.substring(dotIndex);
            }
        }
        return UUID.randomUUID().toString() + extension;
    }

@hyobin-yang hyobin-yang merged commit f8f5a79 into main Jan 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] S3 이미지 저장 기능 구현

2 participants