Spring boot를 이용한 게시글에 AWS S3 이미지 업로드

2023. 12. 14. 20:11Springboot

0. 개요

       Spring boot의 프로젝트 내부의 폴더 안에 MultipartFile을 받지 않고 S3를 이용하여

       해당 MultipartFile을 받는 방법을 구현해보고자 합니다.

       ** 변경 사항으로 이미지를 S3에 업로드를 하고 해당 이미지 PATH는 db에 저장하는 방식

       ** 페이징 처리 기술 포함

       ** 해당 Spring boot 코드는 Security를 구현한 Board 이미지 업로드

       ** 시큐리티 구현을 위해 github의 security부분 참고 

https://github.com/parkjihwanKr/plusProject/tree/master/src/main/java/com/pjh/plusproject/Global

 

GitHub - parkjihwanKr/plusProject

Contribute to parkjihwanKr/plusProject development by creating an account on GitHub.

github.com

 

1. AWS S3를 이용하는 이유?

     1) 많은 사용자가 접속을 해도 감당할 수 있다.

     2) 저장 할 수 있는 파일의 수와 파일 용량이 5TB까지 저장이 가능하다.

     3) 액세스키와 비밀키를 통한 인증을 하기에 안전하다.

     4) 실수에 의한 데이터 손실이 발생하여도 자동으로 복원해준다. 

2. Requirement

  • Spring boot version 3.1.6 
  • Loombook
  • Spring Boot DevTools
  • Spring Web

3. AWS S3와 Spring boot 연동

     AWS S3 spring boot 연동하기 : 

https://velog.io/@gmlstjq123/AWS-S3%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0

 

AWS S3와 스프링부트 연동하기

이전 포스팅을 읽지 않으신 분들은 반드시 이전 포스팅의 내용을 숙지하고 아래의 내용을 읽어주세요. >> 이전 포스팅 1. Access key와 Secret key 발급받기 S3를 스프링 부트에서 사용하려면 AWS 계정의

velog.io

     

     1) build.gradle

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

group = 'com.pjh'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'

    implementation 'org.springframework.boot:spring-boot-starter-validation'
    // validation 추가

    compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
    // jwt token 추가

    implementation 'org.springframework.boot:spring-boot-starter-mail:2.7.1'
    // email 인증 코드 추가

    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
    // s3 연동

    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder-jammy-base:latest'
}

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

 

     2) application.yml

         ** cloud : aws : region : static : ap-northeast-2 >> aws 한국서버

         ** spring : servlet : multipart : max-file-size : multipartfile로 받는 최대 용량

spring:
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true      
  datasource:
    url: jdbc:mysql://localhost:3306/your_project_name
    # Database에 create database "your_project_name"; 후에
    # yml에 plusproject의 프로젝트 명은 자신이 만든 프로젝트 명(= your_project_name) 적기
    username: ${your_username}
    password: ${your_password}
    driver-class-name: com.mysql.cj.jdbc.Driver
  servlet:
    multipart:
      max-file-size: 2MB
      
cloud:
  aws:
    s3:
      bucket: ${your_bucket_name}
    region:
      static: ap-northeast-2
    credentials:
      accessKey: ${your_accessKey}
      secretKey: ${your_secretKey}

       

     3) S3Config.java

           ** 환경변수 지정 이후, accessKey, secretKey, region 지정

              https://pjh3797.tistory.com/30

 

Spring boot 환경 변수 설정

0. 개요 application.yml 또는 application.properties에 민감한 정보를 넣어야할 때, 이를 안 보이게 설정하기 1. application.yml spring: jpa: show-sql: true properties: hibernate: format_sql: true defer-datasource-initialization: true

pjh3797.tistory.com

           ** amazonS3Client() : amazon s3의 자신의 정보를 저장

package com.pjh.s3imageupload.Config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3Client;
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
public class S3Config {

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

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

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3 amazonS3Client(){
        // accessKey, secretKey를 Credentials에 저장

        BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);

        /*  해당 BasicAWSCredenitals의 AllArgumentConstructor :
            public BasicAWSCredentials(String accessKey, String secretKey) {
            if (accessKey == null) {
                throw new IllegalArgumentException("Access key cannot be null.");
            }
            if (secretKey == null) {
                throw new IllegalArgumentException("Secret key cannot be null.");
            }

            this.accessKey = accessKey;
            this.secretKey = secretKey;
        }*/

        return AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
                .build();
    }
}

 

     4) Board.java

           ** showBoard() : 민감정보를 제외한 커스텀한 정보만 추출해서 응답하기 위해 만듦.

              이를 하지 않으면 Member의 Password같은 민감정보를 추출해서 응답함.

package com.pjh.plusproject.Board.Entity;

import com.pjh.plusproject.Board.DTO.BoardResponseDTO;
import com.pjh.plusproject.Global.Common.BaseEntity;
import com.pjh.plusproject.Member.Entity.Member;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Board extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String description;

    private String imageUrl;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "member_id")
    private Member member;
    // 지연 로딩 전략 -> 즉시 로딩 변경

    public BoardResponseDTO showBoard(Board board){
        return BoardResponseDTO.builder()
                .memberId(board.getMember().getId())
                .boardId(board.getId())
                .createAt(board.getCreatedAt())
                .description(board.getDescription())
                .title(board.getTitle())
                .writer(board.getMember().getUsername())
                .build();
    }
}

 

     5) Member.java

           ** Member Entity

package com.pjh.plusproject.Member.Entity;

import com.pjh.plusproject.Global.Common.BaseEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Member extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    private String password;

    private String email;

    private String introduction;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private MemberRoleEnum role;

    // password와 같은 정보 차단
    public Member(Long id, String username){
        this.id = id;
        this.username = username;
    }
}

 

     6) MemberRoleEnum.java

           ** Member의 권한 부여

package com.pjh.plusproject.Member.Entity;

public enum MemberRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    MemberRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

 

     7) BoardRequestDTO.java

           ** client에서 요청을 받기 위해 받아오는 data 객체

package com.pjh.plusproject.Board.DTO;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@Builder
public class BoardRequestDTO {
    private String title;
    private String description;

}

 

     8) BoardResponseDTO.java

           ** client에서 응답을 하기 위한 data 객체

package com.pjh.plusproject.Board.DTO;

import com.pjh.plusproject.Board.Entity.Board;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;

@Getter
@Setter
@Builder
public class BoardResponseDTO {
    private Long memberId;
    private Long boardId;
    private String writer;
    private String title;
    private String description;
    private LocalDateTime createAt;
}

 

     9) BoardController.java

           ** showAllBoardList() : @Pageable를 이용하여 list에 들어가있는 Board 3개당 한 페이지에

               보이도록 만든 메서드

           ** createBoard() : 게시글을 만드는 메서드, 해당 메서드 안에 파일을 담아서 가져갈 수 있음

package com.pjh.plusproject.Board.Controller;

import com.pjh.plusproject.Board.DTO.BoardRequestDTO;
import com.pjh.plusproject.Board.Service.BoardService;
import com.pjh.plusproject.Global.Common.CommonResponseDto;
import com.pjh.plusproject.Global.Security.MemberDetailsImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;


@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class BoardController {
    private final BoardService boardService;

    @GetMapping("/board")
    public ResponseEntity<CommonResponseDto<?>> showAllBoardList(
            @PageableDefault(size = 3) Pageable pageable){
            CommonResponseDto<?> responseDto = boardService.getAllBoardList(pageable);
        return new ResponseEntity<>(responseDto, HttpStatus.valueOf(responseDto.getStatusCode()));
    }

    @PostMapping("/board")
    public ResponseEntity<CommonResponseDto<?>> createBoard(
            @RequestParam("file")MultipartFile multipartFile,
            BoardRequestDTO requestDTO,
            @AuthenticationPrincipal MemberDetailsImpl memberDetails) throws IOException {
        System.out.println("requestDTO.getTitle() : "+requestDTO.getTitle());
        System.out.println("requestDTO.getDescription() : "+requestDTO.getDescription());
        CommonResponseDto<?> responseDto = boardService.createBoard(multipartFile, requestDTO, memberDetails);
        return new ResponseEntity<>(responseDto, HttpStatus.valueOf(responseDto.getStatusCode()));
    }
}

 

     10) BoardService.java

           ** getAllBoardList() : BoardRepsitory에 존재하는 List를 BoardResponseDTO에 넣어서 가져감

           ** createBoard() : 게시글을 만드는 메서드, 해당 메서드 안에 파일을 담고 s3에 저장

package com.pjh.plusproject.Board.Service;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.pjh.plusproject.Board.DTO.BoardRequestDTO;
import com.pjh.plusproject.Board.DTO.BoardResponseDTO;
import com.pjh.plusproject.Board.Entity.Board;
import com.pjh.plusproject.Board.Repository.BoardRepository;
import com.pjh.plusproject.Global.Common.CommonResponseDto;
import com.pjh.plusproject.Global.Security.MemberDetailsImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

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

@Slf4j
@Service
public class BoardService {
    private final BoardRepository boardRepository;
    private final AmazonS3 amazonS3;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public BoardService(BoardRepository boardRepository, AmazonS3 amazonS3){
        this.boardRepository = boardRepository;
        this.amazonS3 = amazonS3;
    }

    // Page<BoardResponseDto> reponseList 형태로 repository 접근은 좋지 않음
    // 이유 : 역할의 분리 측면에서 좋지 않음
    // Presentation layer와 Service layer의 역할 분리, 변경의 유연성
    // Presentation layer는 데이터 전송에 역할을 맞춰야함
    // Service layer은 비지니스 로직의 역할에 충실해야함
    // 그러므로 Persistentce layer(repository)에 Dto형태가 들어가는 것은 말이 안됨
    // 만약 dto의 변경이 일어나면 dto, controller, service까지만 고치면 됨
    // 만약 DTO가 Persistence layer까지 간다면 코드 변경에 repository의 변경까지 이어짐
    @Transactional(readOnly = true)
    public CommonResponseDto<?> getAllBoardList(Pageable pageable){
        // 커스텀 정렬 페이지
        // 해당 페이지는 list를 3개를 받아오며 id는 오름차순으로 정렬합니다.
        Page<Board> boardPage = boardRepository.showBoardPage(pageable);

        List<BoardResponseDTO> responseList =
                boardPage.map(board ->
                        BoardResponseDTO.builder()
                                .title(board.getTitle())
                                .description(board.getDescription())
                                .boardId(board.getId())
                                .memberId(board.getMember().getId())
                                .writer(board.getMember().getUsername())
                                .createAt(board.getCreatedAt())
                                .build()
                ).getContent();

        return new CommonResponseDto<>("모든 게시글 조회", 200, responseList);
    }
    @Transactional
    public CommonResponseDto<?> createBoard(
            MultipartFile image,
            BoardRequestDTO requestDTO,
            MemberDetailsImpl memberDetails) throws IOException {
        log.info("Service method start!");
        String uuidImageName = null;

        try {
            String uuid = UUID.randomUUID().toString();
            String originalImageName = image.getOriginalFilename();

            uuidImageName = uuid+"_"+originalImageName;

            ObjectMetadata metaData = new ObjectMetadata();
            metaData.setContentType(image.getContentType());
            metaData.setContentLength(image.getSize());
            amazonS3.putObject(bucket, uuidImageName, image.getInputStream(), metaData);

        }catch (IOException e){
            e.printStackTrace();
            return new CommonResponseDto<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR.value(), null);
        }

        Board boardEntity = Board.builder()
                .title(requestDTO.getTitle())
                .description(requestDTO.getDescription())
                .member(memberDetails.getMember())
                .imageUrl(uuidImageName)
                .build();

        boardRepository.save(boardEntity);
        BoardResponseDTO responseDTO = boardEntity.showBoard(boardEntity);
        return new CommonResponseDto<>("게시글 작성 성공", 200, responseDTO);
    }
}

 

11) BoardRepository.java

           ** Page<Board> showBoardPage(Pageable pageable) : nativeQuery를 사용해서

              커스텀한 Query사용

           ** select * from board Order by board.id DESC :

               board table에서 id에 따라 내림차순한 모든 행과 열을 출력

               // JPA도 기본 제공하는 Query가 존재, 둘 중 하나 선택 사용하시면 됩니다

Page<Board> findAllByOrderByBoardIdDesc(Pageable pageable);
package com.pjh.plusproject.Board.Repository;

import com.pjh.plusproject.Board.Entity.Board;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface BoardRepository extends JpaRepository<Board, Long> {
    @Query(value = "SELECT * FROM board ORDER BY board.id DESC", nativeQuery = true)
    Page<Board> showBoardPage(Pageable pageable);
}

 

4. 출력 화면

     1) Postman 

           1. 회원가입(postman 화면 존재 x)과 로그인을 한 후 [이미지 1]

              Authorization에 해당하는 value값, 즉 : 'eyJhbG... '라고 써있는 부분을 복사

           2. 해당 http://localhost:8080/api/v1/board로 들어온다.

           3. http://localhost:8080/api/v1/board부분의 Auth부분에 붙혀넣기 [이미지 2]

               ** 선택항목 Bearer Token 안바꾸면 안됨

           4. Body에 알맞는 형태를 넣은 후, [send]버튼 누르기 [이미지 3]

[이미지 1]

 

[이미지 2]

 

[이미지 3]

 

 

     2) AWS S3

           ** 해당 사항 이미지 또는 파일 업로드