S3 이미지 업로드 완벽 이해하기

정다운·2026년 5월 6일

완벽 이해하기

목록 보기
1/5
post-thumbnail

예제 소스코드입니다.
https://github.com/wjdekdns1028/spring-s3-image-upload

이론

이미지 업로드가 뭔가요?

우리가 일상에서 쓰는 서비스들 중

  • 카카오톡에서 프로필 사진 바꾸기
  • 인스타그램에서 사진 게시물 올리기
    이 모든 게 이미지 업로드에요!

사용자 입장에서는 그냥 사진 올리기 버튼이지만 백엔드 입장에서는 이런 일이 일어납니다

사용자 기기 안에 있는 이미지 파일

네트워크를 통해 서버로 전송

서버가 파일을 어딘가에 저장

나중에 꺼내 쓸 수 있도록 URL로 관리

왜 S3를 쓰나요?

서버에 이미지를 직접 저장하면 안되냐고 생각할 수 있어요

하지만 서버에 직접 저장하면 생기는 문제가 있답니다

  • 서버가 여러대면 어떤 서버에 이미지가 있는지 알 수 없어요
  • 서버가 죽으면 이미지도 같이 날아가요
  • 서버 디스크 용량이 한계가 있어요

따라서 S3를 쓰면

  • 이미지 저장 공간을 서버와 완전히 분리할 수 있고
  • 사실상 S3의 용량은 제한이 없답니다
  • 이미지 URL 하나로 어디서든 접근 가능합니다

S3 핵심 용어 3가지

Bucket

쉽게 말해 폴더입니다
이미지들을 담을 최상위 저장 공간이에요
버킷 이름은 전 세계에서 유일해야 한답니다

Object

버킷 안에 저장된 파일 하나하나를 오브젝트라고 합니다
이미지, 영상, 문서 등 모든 파일이 오브젝트에요

Key

오브젝트의 경로 + 파일명이에요
예) images/profile/uuid-123.jpg이 Key로 파일을 식별합니다

업로드 전체 흐름

① 클라이언트가 이미지를 서버로 전송

② 서버가 파일을 받아서 S3에 업로드

③ S3가 저장 후 접근 가능한 URL 생성

④ 서버가 URL을 DB에 저장하고 클라이언트에 응답

클라이언트는 이미지를 직접 가지고 다니는 게 아니라 URL만 저장하고 필요할 때 URL로 접근하는 구조랍니다

실습

1. S3 버킷 생성


s3 검색하고 들어가 줍니다

버킷 만들기 버튼을 눌러줍니다

버킷 이름을 likelion-이름 으로 만들어 줍니다

퍼플릭 액세스 차단을 모두 해제해주고 동의에 체크해 줍니다

나머지는 건들지 말고 버킷 만들기를 클릭합니다

2. 권한 편집


만든 버킷의 권한 섹터로 들어와 버킷 정책의 편집을 클릭해 줍니다

들어와서 새 문 추가를 클릭해 줍니다

서비스 선택에 S3를 검색하고 제일 위에 것을 클릭합니다

클릭 후 검색으로 GetObject를 검색하고 맨 위에 것을 선택합니다

그 다음 리소스 추가를 눌러줍니다

리소스 유형을 object로 선택 후 {본인 버킷명}/* 으로 설정해 줍니다
(모든 오브젝트에 접근 가능)

Principal을 사진과 같이 수정해 줍니다

그 다음 변경 사항 저장을 해주세요

3. IAM에서 엑세스 키 발급

IAM & 액세스 키가 왜 필요할까?

S3는 아무나 접근하면 안되겠죠! 그래서 AWS는 너 누구야? 를 확인합니다

IAM (Identity and Access Management)
: AWS 서비스에 접근할 수 있는 신분증을 만들어주느 곳이에요
Spring 서버가 S3에 업로드하려면 IAM에서 발급한 신분증이 있어야 해요

액세스 키, 시크릿 키
: IAM에서 발급하는 아이디, 비밀번호 같은 거에요
Spring boot는 이 키를 가지고 S3에 접근해요


다시 검색창에 iam을 검색해서 들어가 줍니다

왼쪽에 액세스 관리 아래 사용자에 들어가서 사용자 생성을 클릭해 줍니다

사용자 이름을 likelion-이름 으로 지정하고 다음을 눌러줍니다

직접 정책 연결을 클릭한 뒤 권한 정책의 S3Full을 검색해서 체크한 뒤 다음으로 넘어가 주세요

그 다음 건들지 말고 사용자 생성을 해주세요

만든 사용자를 클릭한 뒤

보안 자격 증명에 들어가 액세스 키 만들기를 클릭해 줍니다

AWS 외부에서 실행되는 애플리케이션을 체크한 뒤 다음을 눌러줍니다

다음은 건들지 말고 액세스 키 만들기를 클릭해 줍니다

그럼 액세스 키와 비밀 액세스 키가 나오는데 꼭꼭 메모장에 잘 저장해주세요~!!

4. 실제 코드 연동

(추가된 코드를 구별하기 어려우신 경우는 이 링크에서 보시면 됩니다)

Build.Grdle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.13'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.likelion'
version = '0.0.1-SNAPSHOT'
description = 'likelion-crud'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

    //swagger세팅
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.12'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:2021.0.8"
    }
}

tasks.named('test') {
    useJUnitPlatform()
}

(dependencyManagement를 선언하면 dependencies 블록에서 버전을 따로 안 써도 BOM이 알아서 맞는 버전을 골라줌)

yml 파일 설정

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/likelion   # 연결할 MySQL 데이터베이스의 주소
    username: root                                   # 본인 MySQL 사용자 이름
    password: mysql0!!                                  # 본인 MySQL 비밀번호
    driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 드라이버 클래스

  jpa:
    hibernate:
      ddl-auto: create     # 테이블 자동 생성 (create, update, validate, none 중 택 1)
    show-sql: true         # 실행되는 SQL을 콘솔에 출력
    properties:
      hibernate:
        format_sql: true   # SQL 쿼리를 보기 좋게 출력 (정렬됨)

logging:
  level:
    org.hibernate.SQL: debug                  # 실행되는 SQL 로그 출력
    org.hibernate.type.descriptor.sql: trace  # 바인딩된 파라미터 값 로그 출력

cloud:
  aws:
    credentials:
      access-key: ${AWS_ACCESS_KEY}
      secret-key: ${AWS_SECRET_KEY}
    s3:
      bucket: ${S3_BUCKET_NAME}
    region:
      static: ap-northeast-2             # 서울 리전
    stack:
      auto: false                        # CloudFormation 스택 자동감지 비활성화

아까 발급받은 키들과 버킷명을 작성해 주세요

3. S3Config 클래스

application.ymlS3ConfigS3Uploader
access-key: ...AmazonS3 빈 생성amazonS3.putObject()
secret-key: ...(인증 정보 주입)(실제 업로드)
region: ...
  1. access-key, secret-key로 AWS에 인증
  2. 인증된 S3 클라이언트 객체 생성
  3. 이후엔 어디서든 이 객체 꺼내서 업로드/삭제 가능
package com.likelion.likelioncrud.common.config;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration          // "나는 설정 클래스야" → Spring이 앱 시작 시 가장 먼저 읽음
public class S3Config {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;   // application.yml에서 값 꺼내서 여기에 넣어줌

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

    @Value("${cloud.aws.region.static}")
    private String region;      // 실제 값: ap-northeast-2

    @Bean                       // "이 메서드가 반환하는 객체를 Spring이 관리해줘"
    public AmazonS3 amazonS3() {

        // 1단계: 아이디/비밀번호 묶기
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        // 2단계: 인증 정보 + 리전으로 S3 클라이언트 생성
        return AmazonS3ClientBuilder.standard()
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .withRegion(region)
                .build();       // 완성된 S3 클라이언트 반환
    }
}

4. S3Uploader 서비스 클래스

S3Uploader.upload() 호출

① 파일명 생성 (UUID + 원본파일명)
② 메타데이터 생성 (파일크기, 타입)
③ S3에 업로드
④ 업로드된 URL 반환

사용자에게 URL 응답

UUID : 세상에서 단 하나뿐인 랜덤 문자열을 만들어주는 도구

// UUID 없을 때
홍길동 → "고양이.jpg" 업로드
김철수 → "고양이.jpg" 업로드 ← 홍길동 파일 덮어써짐!

// UUID 있을 때
홍길동 → "a1b2c3고양이.jpg"
김철수 → "d4e5f6
고양이.jpg" ← 둘 다 안전하게 저장

package com.likelion.likelioncrud.image;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.UUID;

@Service
@RequiredArgsConstructor    // final 필드 생성자 자동 생성 (Lombok)
public class S3Uploader {

    private final AmazonS3 amazonS3;  // S3Config에서 만든 빈 자동 주입

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;            // application.yml에서 버킷명 주입

    public String upload(MultipartFile file) throws IOException {

        // ① 중복 방지 파일명 생성
        String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();

        // ② S3에 전달할 파일 정보 세팅
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(file.getSize());
        metadata.setContentType(file.getContentType());

        // ③ 실제 업로드
        amazonS3.putObject(bucket, fileName, file.getInputStream(), metadata);

        // ④ 업로드된 파일 URL 반환
        return amazonS3.getUrl(bucket, fileName).toString();
    }
}

5. ErrorCode

package com.likelion.likelioncrud.common.response.code;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter // getter 메소드 자동 생성 lombok 어노테이션
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드를 파라미터로 받는 생성자 자동 생성 어노테이션
public enum ErrorCode {

    /**
     * 404 NOT FOUND (찾을 수 없음)
     */
    MEMBER_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 사용자가 없습니다. memberId = "),
    POST_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 게시글이 없습니다. postId = "),

    /**
     * 400 BAD REQUEST
     */
    VALIDATION_EXCEPTION(HttpStatus.BAD_REQUEST, "유효성 검사에 실패하였습니다 - "),

    /**
     * 500 INTERNAL SERVER ERROR (내부 서버 에러)
     */
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러가 발생했습니다"),

    // S3 이미지 업로드 에러
    FILE_UPLOAD_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.");

    private final HttpStatus httpStatus;    // HTTP 상태 코드를 스프링에서 쉽게 작성하기 위한 enum값들의 모임
    private final String message;           // 에러 메세지


    public int getHttpStatusCode() {        // HTTP 상태 코드에서 404와 같은 숫자 값만 반환해 주기 위한 메소드
        return httpStatus.value();
    }
}

6. Post

package com.likelion.likelioncrud.post.domain;

import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long postId;

    private String title;

    private String contents;

		// S3에 업로드된 이미지의 URL을 저장, 이미지가 없을 경우 null 허용
    private String imageUrl;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Builder
    private Post(String title, String contents, String imageUrl, Member member) {
        this.title = title;
        this.contents = contents;
        this.imageUrl = imageUrl; // 이미지 없이 글만 작성할 경우 null로 저장됨
        this.member = member;
    }

    public void update(PostUpdateRequestDto postUpdateRequestDto) {
        this.title = postUpdateRequestDto.title();
        this.contents = postUpdateRequestDto.contents();
    }
}

7. PostInfoResponseDto

package com.likelion.likelioncrud.post.api.dto.response;

import com.likelion.likelioncrud.post.domain.Post;
import lombok.Builder;

@Builder
public record PostInfoResponseDto(
        String title,
        String contents,
        String writer,
        String imageUrl // S3에 업로드된 이미지 URL, 이미지 없으면 null 반환
) {
    public static PostInfoResponseDto from(Post post) {
        return PostInfoResponseDto.builder()
                .title(post.getTitle())
                .contents(post.getContents())
                .writer(post.getMember().getName())
                .imageUrl(post.getImageUrl()) // 이미지 없이 저장된 게시글이면 null
                .build();
    }
}

8. PostService

게시글 텍스트만 보낼 때는 아래와 같은 JSON으로 충분합니다

{
  "title": "제목",
  "contents": "내용"
}

근데 파일을 보내려면? 파일은 텍스트가 아니라 바이트데이터(2진수의 나열)이라 JSON에 담을 수 없어요

그래서 multipart/form-data를 사용합니다(여러 데이터를 경계선으로 구분해서 한 요청에 같이 보내는 방식)

--boundary
Content-Disposition: form-data; name="data"
{ "title": "제목", "contents": "내용" }

--boundary
Content-Disposition: form-data; name="image"; filename="고양이.jpg"
Content-Type: image/jpeg
(이미지 바이트 데이터.....)
--boundary--

MultipartFile이란 :

HTTP로 전송된 파일 데이터를 Spring이 자바 객체로 변환해서 우리가 쓸 수 있게 해주는 인터페이스입니다

package com.likelion.likelioncrud.post.application;

import com.likelion.likelioncrud.common.exception.BusinessException;
import com.likelion.likelioncrud.common.response.code.ErrorCode;
import com.likelion.likelioncrud.image.S3Uploader;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.member.domain.repository.MemberRepository;
import com.likelion.likelioncrud.post.api.dto.request.PostSaveRequestDto;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import com.likelion.likelioncrud.post.api.dto.response.PostInfoResponseDto;
import com.likelion.likelioncrud.post.domain.Post;
import com.likelion.likelioncrud.post.domain.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;
    private final S3Uploader s3Uploader; // S3 이미지 업로드를 위해 주입

    // 게시물 저장
    @Transactional
    public void postSave(PostSaveRequestDto postSaveRequestDto, MultipartFile image) {
        Member member = memberRepository.findById(postSaveRequestDto.memberId()).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND_EXCEPTION, ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + postSaveRequestDto.memberId()));

        // 이미지가 있을 때만 S3에 업로드. 이미지 없이 글만 작성하는 경우도 허용하기 위해 null 체크
        String imageUrl = null;
        if (image != null && !image.isEmpty()) {
            try {
                imageUrl = s3Uploader.upload(image); // S3 업로드 후 반환된 URL을 저장
            } catch (IOException e) {
                // 업로드 실패 시 커스텀 예외로 변환해서 던짐 → GlobalExceptionHandler가 처리
                throw new BusinessException(ErrorCode.FILE_UPLOAD_FAIL_EXCEPTION, ErrorCode.FILE_UPLOAD_FAIL_EXCEPTION.getMessage());
            }
        }

        Post post = Post.builder()
                .title(postSaveRequestDto.title())
                .contents(postSaveRequestDto.contents())
                .imageUrl(imageUrl) // 이미지 없으면 null, 있으면 S3 URL
                .member(member)
                .build();

        postRepository.save(post);
    }

    // 특정 작성자가 작성한 게시글 목록을 조회
    public Page<PostInfoResponseDto> postFindMember(Long memberId, Pageable pageable) {
        Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND_EXCEPTION, ErrorCode.MEMBER_NOT_FOUND_EXCEPTION.getMessage() + memberId));

        Page<Post> posts = postRepository.findByMember(member, pageable);
        return posts.map(PostInfoResponseDto::from);
    }

    // 게시물 수정
    @Transactional
    public void postUpdate(Long postId, PostUpdateRequestDto postUpdateRequestDto)
    {
        Post post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND_EXCEPTION, ErrorCode.POST_NOT_FOUND_EXCEPTION.getMessage() + postId));
        post.update(postUpdateRequestDto);
    }

    // 게시물 삭제
    @Transactional
    public void postDelete(Long postId) {
        Post post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND_EXCEPTION, ErrorCode.POST_NOT_FOUND_EXCEPTION.getMessage() + postId));
        postRepository.delete(post);
    }
}

9. PostController

package com.likelion.likelioncrud.post.api;

import com.likelion.likelioncrud.common.response.code.SuccessCode;
import com.likelion.likelioncrud.common.template.ApiResTemplate;
import com.likelion.likelioncrud.post.api.dto.request.PostSaveRequestDto;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import com.likelion.likelioncrud.post.api.dto.response.PostInfoResponseDto;
import com.likelion.likelioncrud.post.application.PostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
@RequestMapping("/post")
@Tag(name = "POST API", description = "게시글 관리하는 api ")
public class PostController {

    private final PostService postService;

    // 게시물 저장
    // consumes = "multipart/form-data": JSON이 아닌 파일 전송 방식으로 요청을 받음
    // @RequestPart("data"): multipart 요청에서 "data" 키에 담긴 JSON을 DTO로 변환
    // required = false: 이미지 없이 글만 저장하는 경우도 허용
    @PostMapping(consumes = "multipart/form-data")
    @Operation(summary = "게시물 저장", description = "게시물 저장. 이미지는 선택사항입니다.")
    public ApiResTemplate<Void> postSave(
            @RequestPart("data") PostSaveRequestDto postSaveRequestDto,
            @RequestPart(value = "image", required = false) MultipartFile image) {
        postService.postSave(postSaveRequestDto, image);
        return ApiResTemplate.successWithNoContent(SuccessCode.POST_SAVE_SUCCESS);
    }

    // 사용자 id를 기준으로 해당 사용자가 작성한 게시글 목록 조회
    @GetMapping("/{memberId}")
    @Operation(summary = "게시물 memberId로 조회", description = "게시물 memberId로 조회")
    public ApiResTemplate<Page<PostInfoResponseDto>> myPostFindAll(@PathVariable("memberId") Long memberId, @ParameterObject @PageableDefault(size = 10, sort = "postId", direction = Sort.Direction.ASC) Pageable pageable) {
        Page<PostInfoResponseDto> posts = postService.postFindMember(memberId, pageable);
        return ApiResTemplate.successResponse(SuccessCode.GET_SUCCESS, posts);
    }

    // 게시물 id를 기준으로 사용자가 작성한 게시물 수정
    @PatchMapping("/{postId}")
    @Operation(summary = "게시물 Id로 수정", description = "게시물 제목, 내용 수정")
    public ApiResTemplate<Void> postUpdate(@PathVariable("postId") Long postId,
                                             @RequestBody PostUpdateRequestDto postUpdateRequestDto) {
        postService.postUpdate(postId, postUpdateRequestDto);
        return ApiResTemplate.successWithNoContent(SuccessCode.POST_UPDATE_SUCCESS);
    }

    // 게시물 id를 기준으로 사용자가 작성한 게시물 삭제
    @DeleteMapping("/{postId}")
    @Operation(summary = "게시물 삭제", description = "게시물 Id로 삭제")
    public ApiResTemplate<Void> postDelete(@PathVariable("postId") Long postId) {
        postService.postDelete(postId);
        return ApiResTemplate.successWithNoContent(SuccessCode.POST_DELETE_SUCCESS);
    }
}

10. 테스트

스웨거가 아니라 Postman으로 테스트하는 이유는 뭔가요?

비교 항목SwaggerPostman
multipart Content-Type자동으로 octet-stream으로 보내버림multipart/form-data를 안정적으로 처리함
헤더 제어제한적자유롭게 설정 가능
용도API 문서 확인용실제 테스트용

octet = 8비트(1바이트). 즉 application/octet-stream = "그냥 바이트 덩어리인데 타입을 모르겠음" 이라는 의미.

포스트맨을 켜서 POST로 위와 같이 새로 생성하고

body는 form-data로 설정해주세요

그 다음 맨 오른쪽 쩜쩜쩜을 클릭해 Content-Type을 추가해줍니다

그 후 data키는 Text 타입으로 사진과 같이 만들어 주시고
(꼭 Content-Type안에 application/json를 넣어주세요!)

image키는 file 타입으로 만들어서

이렇게 이미지를 넣어줍니다

성공했다면

S3 버킷에 들어가서

업로드된 객체에 들어가서

속성 속 객체 URL을 클릭해보면

사진이 뜨면 성공입니다~

과제

필수 과제

  1. 이미지를 포함한 게시물 수정 API 완성
    1. 현재 게시물 수정 API(PATCH /post/{postId})는 제목과 내용만 수정 가능하며, 이미지 교체가 되지 않는 상태입니다. 이미지도 함께 수정할 수 있도록 구현해주세요!
    2. 이미지를 보내지 않은 경우 기존 이미지가 유지되어야 합니다.
    3. 구현이 필요한 파일 : Post.java, PostService.java, PostController.java
  2. 이미지 수정 시, 자동으로 이전 이미지가 버킷에서 삭제되도록 구현
    1. 새 이미지로 교체할 때 S3 버킷에는 이전 이미지가 그대로 남아 있게 됩니다. 이미지 수정 시 이전 이미지가 S3 버킷에서도 함께 삭제되도록 구현해주세요!
    2. 구현이 필요한 파일 : S3Uploader.java, PostService.java

선택 과제

  1. 이미지 단독 삭제 구현
    1. 게시물 자체를 삭제하는 것이 아닌, 게시물에 첨부된 이미지만 삭제하는 기능을 별도로 구현해주세요.
    2. 이 경우 DB의 imageUrlnull로 설정되며, S3 버킷에서도 해당 이미지가 삭제되어야 합니다.
    3. 구현이 필요한 파일 : PostService.java, PostController.java

제출 방식

  1. Postman 캡쳐 화면 제출 (총 2장 이상)
    1. 각 구현 항목에 알맞는 Postman 실행 화면 (이미지 업로드 + 수정) → 각 1장씩 총 2장
    2. 업로드 사진 (A.png)과 수정 사진 (B.png)은 각기 다른 사진으로 진행해야 합니다.
    3. 선택 과제 구현 시 이미지 삭제 Postman 화면 1장 추가 제출
  2. S3 버킷 캡쳐 화면 제출 (총 2장 이상)
    1. 업로드 사진 (A.png) 캡쳐 화면 1장
    2. 수정 후, 수정 사진 (B.png)만 존재하는 캡쳐 화면 1장 → 이전 이미지는 자동 삭제되니 B.png만 있는 상태!

0개의 댓글