2023. 12. 14. 20:11ㆍSpringboot
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 연동하기 :
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]



2) AWS S3
** 해당 사항 이미지 또는 파일 업로드

'Springboot' 카테고리의 다른 글
Access Token과 Refresh Token(0) (0) | 2023.12.21 |
---|---|
@Value, @ConfigurationProperties에 대해서 (0) | 2023.12.20 |
Spring boot 간단한 AWS S3 MultipartFile 업로드 (0) | 2023.12.13 |
Spring boot 순환 참조 문제 (0) | 2023.12.12 |
Spring boot OAuth 로그인(0) (0) | 2023.12.11 |