๐ ๊ฐ์
์งํ ์ค์ธ ํ๋ก์ ํธ์์ ์ด๋ฏธ์ง๋ฅผ ์ ์ฅํด์ผ ํ๋ค.
์ด๋ฏธ์ง๋ฅผ Amazon S3์ ์ ์ฅํ๊ธฐ๋ก ํ์๊ณ ,
Amazon S3 ์์ฑ๋ถํฐ Spring Boot์์ ์ด๋ฏธ์ง๋ฅผ ์ ์ฅํ๋ ๊ฒ๊น์ง ์์ฑํด๋ณด๋ ค ํ๋ค.
๐ ๊ฐ๋ฐ ํ๊ฒฝ
SpringBoot : 3.3.5
JDK : 17
build Tools : gradle
Editor : InteliJ
๐ Amazon S3 ์ค์
๐ ๋ฒํท ๋ง๋ค๊ธฐ
๋ฒํท ๋ง๋ค ๋์ ์์ธํ ์ค์ ์ ๋ณด๋ ์ด ๋ธ๋ก๊ทธ์ ์ ๋์์์ผ๋ ์ฐธ๊ณ ํ๋ฉด ์ข์ ๊ฒ ๊ฐ๋ค.
(์๋์ ์์ฑ๋์ด ์๋ ์ค์ ์ธ์ ์ค์ ์ ๋ํดํธ ๊ฐ์ผ๋ก ๋๋๊ณ ๋ฐ๋ก ๋ณ๊ฒฝํ์ง ์์๋ค.)
์์ ๊ฒ์์ฐฝ์ S3
๋ฅผ ๊ฒ์ํ์ฌ Amazon S3๋ก ์ด๋ํด์ฃผ๊ณ ์ผ์ชฝ ๋ค๋น๊ฒ์ด์
๋ฐ์์ ๋ฒํท
์ ํด๋ฆญ
๋ฒํท ๋ง๋ค๊ธฐ
ํด๋ฆญ
๋ฒํท ์ด๋ฆ ์์ฑ
๊ฐ์ฒด ์์ ๊ถ์์ ACL ํ์ฑํ๋จ
์ผ๋ก ๋ณ๊ฒฝ
์ฐ๋ฆฌ ํ๋ก์ ํธ์์๋ Spring ์๋ฒ ์ชฝ ์์ค์ฝ๋๋ฅผ ์ด์ฉํ์ฌ ์ด๋ฏธ์ง ์ ๋ก๋ ๋ฐ ์กฐํ๊ฐ ์ด๋ฃจ์ด์ง๋๋ฐ,
ํด๋น ์ฒ๋ฆฌ๋ฅผ ํ ๋ IAM ๊ณ์ ์ ์์ฑํ์ฌ ๋ฒํท์ ์ ๊ทผํ๊ธฐ ๋๋ฌธ์ ACL ํ์ฑํ๋ก ์ ํ์ค๋ค.
๋ชจ๋ ํผ๋ธ๋ฆญ ์์ธ์ค๋ฅผ ์ฐจ๋จํ๋ฉด ์ธ๋ถ์์ ํ์ผ์ ์ฝ์ง ๋ชปํ๊ฒ ํ๋ค๋ ์๋ฏธ์ด๋ค.
์ฐ๋ฆฌ๋ S3์ ์ ์ฅ๋ ์ด๋ฏธ์ง๋ฅผ ์ฐ๋ฆฌ์ ํ๋ก์ ํธ์์ ์ฝ์ด์ ๋์์ฃผ์ด์ผํ๊ธฐ ๋๋ฌธ์
์์ธ์ค ์ฐจ๋จ ์ค์ ์ ํ์ด์ฃผ์๋ค.
๊ธฐ๋ณธ ์ํธํ๋ฅผ ํ์ฑํ ํ๋ฉด ๋ฒํท์ ์ ์ฅ๋๋ ๋ชจ๋ ์ ๊ฐ์ฒด๋ฅผ ์ํธํํด์ ์ ์ฅํ๋ค.
๊ทธ๋ฆฌ๊ณ ๊ฐ์ฒด๋ฅผ ๋ค์ด๋ก๋ํ ๋ ์ํธ๋ฅผ ๋ณตํธํํด์ ์ ๊ณตํด์ค๋ค.
(์ด ๋ถ๋ถ์ ๋ํด์ ์ข ๋ ์์ธํ ์์๋ด์ผ ํ ๊ฒ ๊ฐ์ ์ผ๋จ ์ผ๋ฐ์ ์ธ ์ค์ ๋ฐฉ๋ฒ์ ๋ฐ๋ผ ๋นํ์ฑํ ํ๋ค.)
๊ทธ๋ฆฌ๊ณ ๋ฒํท ๋ง๋ค๊ธฐ
ํด๋ฆญ
withbee-travel
๋ฒํท์ด ์์ฑ๋ ๊ฒ์ ํ์ธ ํ ์ ์๋ค.
๐ ์ด๋ฏธ์ง ์ ๋ก๋ ํ ์คํธ
์์ฑํ ๋ฒํท์ ์ด๋ฏธ์ง๋ฅผ ์ ๋ก๋ ํด๋ณด๊ณ ์ ๋ค์ด๊ฐ์ง๋์ง ํ์ธํด๋ณด์
์์์ ๋ง๋ ๋ฒํท ์ด๋ฆ์ ๋๋ฌ์ค๋ค.
์
๋ก๋
ํด๋ฆญ
์๋ฌด ์ด๋ฏธ์ง๋ ๋๋๊ทธ ํด์ ํ์ผ์ ์ ๋ก๋ ํด์ค๋ค.
ํ์ผ ์
๋ก๋ ๋ ๊ฒ์ ํ์ธํ ํ, ์
๋ก๋
ํด๋ฆญ
์ ๋ก๋๊ฐ ์ ๋ ๊ฒ์ ํ์ธํ ํ,
์ ๋ก๋ํ ํ์ผ์ ํด๋ฆญํด ํ์ผ ์์ธ๋ก ์ด๋ํด์ค๋ค.
๊ฐ์ฒด URL์ ๋ณต์ฌํด์, ์ด๋ฏธ์ง๊ฐ ์ ๋์์ง๋์ง ํ์ธ
๊ทธ๋ผ ์ด๋ฐ ์์ผ๋ก ์ ๊ทผํ ์ ์๋ค๋ ํ๋ฉด์ด ๋์จ๋ค.
๋ฒํท ์ ์ฑ ์ ์ค์ ํด์ฃผ์ด์ผ ํ๋ค.
๐ ๋ฒํท ์ ์ฑ ์ค์
๋ฒํท์ ๊ถํ
ํญ์ผ๋ก ์ด๋
๋ฐ์ผ๋ก ๋ด๋ฆฌ๋ค๋ณด๋ฉด ๋์ค๋ ๋ฒํท ์ ์ฑ
์ ํธ์ง
ํด๋ฆญ
๋ฒํท ARN
๋ณต์ฌ ํ ์ ์ฑ
์์ฑ๊ธฐ
ํด๋ฆญ
๋ค์์ ์ ๋ณด๋ค์ ์ ๋ ฅํด์ค๋ค.
Select Type of Policy: S3 Bucket Policy
Principal: *
Actions: GetObject
Amazon Resource Name: {๋ณต์ฌํ ARN}/*
์
๋ ฅ ํ Add Statement
ํด๋ฆญ
๋ฐ์ ์๊ธด ์ ์ฑ
์ ํ์ธ ํ, Generate Policy
ํด๋ฆญ
๊ทธ๋ผ json ํ์์ผ๋ก ๋ ์ ์ฑ ์ด ๋ฌ๋ค.
ํด๋น json ๋ณต์ฌ
๋ค์ ๋ฒํท ์ ์ฑ ํธ์ง ์ฐฝ์ผ๋ก ๋์๊ฐ์
์ฌ๊ธฐ์ ๋ณต์ฌํ ๊ฑฐ ๋ถ์ฌ๋ฃ์ ํ ๋ณ๊ฒฝ ์ฌํญ ์ ์ฅ
ํด๋ฆญ
๊ทธ๋ฆฌ๊ณ ๋ค์ ์ด๋ฏธ์ง ์ฃผ์๋ก ๋ค์ด๊ฐ๋ณด๋ฉด
์ด๋ฏธ์ง๊ฐ ์ ๋ก๋๋๋ ๊ฒ์ ํ์ธํ ์ ์๋ค!
๐ IAM ์ฌ์ฉ์ ์์ฑํ๊ธฐ/ํธ์งํ๊ธฐ
์์ฑํ๊ธฐ
S3์ ์ ๊ทผํ๊ธฐ ์ํด์๋ IAM ์ฌ์ฉ์์๊ฒ S3์ ๊ทผ ๊ถํ์ ๋ถ์ฌํ๊ณ
ํด๋น ์ฌ์ฉ์์ ์์ธ์ค ํค, ์ํฌ๋ฆฟ ํค๋ฅผ ์ฌ์ฉํด์ผ ํ๋ค.
์์ ๊ฒ์์ IAM์ ๊ฒ์ ํ ์์ธ์ค ๊ด๋ฆฌ → ์ฌ์ฉ์
๋ก ์ด๋ ํ ์ฌ์ฉ์ ์์ฑ
ํด๋ฆญ
์ฌ์ฉ์ ์ด๋ฆ
์์ฑ ํ ๋ค์
ํด๋ฆญ
์ง์ ์ ์ฑ
์ฐ๊ฒฐ
์ ํ ํ AmazonS3FullAccess
์ ํ ํ ๋ค์
ํด๋ฆญ
์ ์ ํ ๋์๋์ง ๊ฒํ ํ ์ฌ์ฉ์ ์์ฑ
ํด๋ฆญ
ํธ์งํ๊ธฐ
์ฌ์ค ๋๋ ์ฐ๋ฆฌFIS ์์นด๋ฐ๋ฏธ์์ ์ ๊ณตํด์ค AWS ๊ณ์ ์ ์ฌ์ฉํ๊ณ ์๊ธฐ ๋๋ฌธ์ ์ด๋ฏธ IAM๊ฐ ์์๋ค.
๊ทธ๋์ ์ด๋ฏธ ์๋ IAM์ AmazonS3FullAccess
๊ถํ๋ง ์ถ๊ฐํด์คฌ๋ค.
๊ถํ์ ์ถ๊ฐํ ์ฌ์ฉ์ ์ด๋ฆ์ ํด๋ฆญ
๊ถํ ์ถ๊ฐ
ํด๋ฆญ
์ง์ ์ ์ฑ
์ฐ๊ฒฐ
์ ํ ํ AmazonS3FullAccess
์ ํ ํ ๋ค์ ํด๋ฆญ
๊ฒํ ํ ๊ถํ ์ถ๊ฐ
ํด๋ฆญ
๐ IAM ์์ธ์ค ํค ์์ฑํ๊ธฐ
๋ฐฉ๊ธ ์์ฑํ ์ฌ์ฉ์(or ๊ถํ ์ถ๊ฐํ ์ฌ์ฉ์) ์ด๋ฆ์ ๋๋ฌ ์์ธ์ ๋ค์ด์จ ํ ์์ธ์ค ํค ๋ง๋ค๊ธฐ
ํด๋ฆญ
์ด ์ค์์ ์๋ฌด๊ฑฐ๋ ํ๋ ์ ํํ๊ณ ๋ค์
ํด๋ฆญ
(๋๋ AWS ์ปดํจํ ์๋น์ค์์ ์คํ๋๋ ์ ํ๋ฆฌ์ผ์ด์ ์ ์ ํํ๋ค.)
์ค๋ช ํ๊ทธ ๊ฐ์ ์ ํ ์ฌํญ์ด๋ผ ์ ์ด์ฃผ์ง ์์๋ ๋๋ค.
(๋๋ withbee-travel์ s3 ๋ฒํท๊ณผ ์ฐ๊ฒฐํ๋ค๋ ๋ป์ผ๋ก ํ๊ทธ๋ฅผ ์ ์ด์ฃผ์๋ค.)
์์ธ์ค ํค ๋ง๋ค๊ธฐ
ํด๋ฆญ
๋ง๋ค์ด์ง ์์ธ์ค ํค๋ฅผ .csv ํ์ผ ๋ค์ด๋ก๋
๋ฅผ ํด๋ฆญํด์ ๋ค์ด ๋ฐ์์ค๋ค.
๐ Spring์ ์ฐ๋ํ๊ธฐ
๐ dependency ์ถ๊ฐ
// S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
๐ properties ์ถ๊ฐ
์ผ๋จ, aws ๊ด๋ จ properties๋ฅผ ๋ฐ๋ก ์์ฑํด์ฃผ๊ธฐ ์ํด
application.properties
์ aws.properties
๋ ์ฝ์ด์ฃผ๋ผ๊ณ ์์ฑํด์ค๋ค.
# properties ํ์ผ ์ถ๊ฐ
spring.config.import=aws.properties
๊ทธ๋ฆฌ๊ณ aws.properties์ ๋ค์๊ณผ ๊ฐ์ ๋ด์ฉ๋ค์ ์ ์ด์ค๋ค.
# IAM ์ก์ธ์ค ํค
cloud.aws.credentials.accessKey=
# IAM ๋น๋ฐ ์ก์ธ์ค ํค
cloud.aws.credentials.secretKey=
# ๋ฆฌ์ ์ ๋ณด
cloud.aws.region.static=ap-northeast-2
# ๋ฒํท ์ด๋ฆ
cloud.aws.s3.bucket=withbee-travel
# ์ด๋ฏธ์ง URL์ ๋๋ฉ์ธ ์ ๋ณด(์ด๋ฏธ์ง๋ฅผ delete ํ๊ธฐ ์ํด ํ์ํจ)
cloud.aws.s3.bucket.domain=https://{๋ฒํท ์ด๋ฆ}.s3.{๋ฆฌ์ ์ ๋ณด}.amazonaws.com/
๐ Config ์์ฑ
package withbeetravel.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 // ์ค์ ํ์ผ์ ์ฝ๊ธฐ ์ํ annotation
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 s3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
๐ S3Uploader ์์ฑ
package withbeetravel.service;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.UUID;
@Service
public class S3Uploader {
private final AmazonS3 amazonS3;
private final String bucket;
@Value("${cloud.aws.s3.bucket.domain}")
private String bucketDomain;
public S3Uploader(AmazonS3 amazonS3, @Value("${cloud.aws.s3.bucket}") String bucket) {
this.amazonS3 = amazonS3;
this.bucket = bucket;
}
// ์ด๋ฏธ์ง ์ ์ฅ
// file := s3์ ์ ์ฅํ ์ด๋ฏธ์ง ํ์ผ
// dirName := ์ด๋ฏธ์ง ํ์ผ์ ์ ์ฅํ s3 ๋๋ ํ ๋ฆฌ
public String upload(MultipartFile file, String dirName) throws IOException {
// ํ์ผ์ ์๋ ์ด๋ฆ์์ ๊ณต๋ฐฑ์ ์ ๊ฑฐ
String originalFileName = file.getOriginalFilename().replaceAll("\\s", "_");
// ์ ๋ํฌํ ํ์ผ๋ช
์ ๋ง๋ค๊ธฐ ์ํด UUID๋ฅผ ํ์ผ๋ช
์ ์ถ๊ฐ
String uuid = UUID.randomUUID().toString();
String uniqueFileName = uuid + "_" + originalFileName;
// ๋๋ ํ ๋ฆฌ ์์น์ ํ์ผ๋ช
ํฉ์น๊ธฐ
String fileName = dirName + "/" + uniqueFileName;
// MultipartFile -> File
File uploadFile =convert(file, uniqueFileName);
// S3์ ์ด๋ฏธ์ง ์
๋ก๋
String uploadImageUrl = putS3(uploadFile, fileName);
// ์์ ํ์ผ ์ญ์
uploadFile.delete();
// S3์ ์ ์ฅ๋ ์ด๋ฏธ์ง์ URL ๋ฆฌํด
return uploadImageUrl;
}
// ์ด๋ฏธ์ง ์ญ์
// filName := ์ญ์ ํ ์ด๋ฏธ์ง๋ช
(URL ํ์)
public void delete(String fileName) {
if(fileName.startsWith(bucketDomain)) {
amazonS3.deleteObject(bucket, fileName.substring(bucketDomain.length()));
}
}
// ์ด๋ฏธ์ง ์์
// newFile := ์๋ก ์ ์ฅํ ์ด๋ฏธ์ง ํ์ผ
// oldFileName := ๊ธฐ์กด์ ์ ์ฅ๋์ด ์๋ ์ด๋ฏธ์ง๋ช
(URL ํ์)
// dirName := ์ด๋ฏธ์ง ํ์ผ์ ์ ์ฅํ s3 ๋๋ ํ ๋ฆฌ
public String update(MultipartFile newFile, String oldFileName, String dirName) throws IOException {
// ๊ธฐ์กด ํ์ผ ์ญ์
delete(oldFileName);
// ์ ํ์ผ ์
๋ก๋
return upload(newFile, dirName);
}
// MultipartFile -> File
private File convert(MultipartFile file, String uniqueFileName) throws IOException {
File convertFile = new File(uniqueFileName);
if (convertFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(convertFile)) {
fos.write(file.getBytes());
} catch (IOException e) {
System.err.println("ํ์ผ ๋ณํ ์ค ์ค๋ฅ ๋ฐ์: " + e.getMessage());
throw e;
}
return convertFile;
}
throw new IllegalArgumentException("ํ์ผ ๋ณํ์ ์คํจํ์ต๋๋ค. " + file.getOriginalFilename());
}
// S3์ ์ด๋ฏธ์ง ์
๋ก๋
private String putS3(File uploadFile, String fileName) {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3.getUrl(bucket, fileName).toString();
}
}
์ค์ง์ ์ผ๋ก S3Uploader
์์ ํธ์ถ๋๋ ๋ฉ์๋๋ upload
, delete
, update
์ด๋ค.
๐ Service์์ S3Uploader ์ฌ์ฉ
package withbeetravel.service;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import withbeetravel.domain.SharedPayment;
import withbeetravel.exception.CustomException;
import withbeetravel.exception.error.PaymentErrorCode;
import withbeetravel.exception.error.ValidationErrorCode;
import withbeetravel.repository.SharedPaymentRepository;
import java.io.IOException;
@Service
@RequiredArgsConstructor
public class SharedPaymentService {
private final S3Uploader s3Uploader;
private final SharedPaymentRepository sharedPaymentRepository;
// S3์ ์ด๋ฏธ์ง๋ฅผ ์ ์ฅํ ๊ฒฝ๋ก
private static final String SHARED_PAYMENT_IMAGE_DIR = "shared-payments";
@Transactional
public boolean addAndUpdatePaymentRecord(
Long travelId,
Long sharedPaymentId,
MultipartFile image,
String comment,
boolean isMainImage) {
// SharedPayment Entity ๊ฐ์ ธ์ค๊ธฐ
SharedPayment sharedPayment = sharedPaymentRepository.findById(sharedPaymentId)
.orElseThrow(() -> new CustomException(PaymentErrorCode.SHARED_PAYMENT_NOT_FOUND));
// ์ด๋ฏธ์ง ์ถ๊ฐ, ์์ , ์ญ์
String paymentImage = sharedPayment.getPaymentImage(); // ์๋ ์ด๋ฏธ์ง
if(image != null) { // image๊ฐ ์๋ก ๋ค์ด์๋ค๋ฉด S3์ ์ ์ฅ ํ Entity ๋ณ๊ฒฝ
// ์ด๋ฏธ์ง ์ ์ฅํ S3 ๋๋ ํ ๋ฆฌ ์ ๋ณด
String dirName = SHARED_PAYMENT_IMAGE_DIR + "/" + travelId;
try {
if(paymentImage != null) { // ํด๋น ๊ณต๋๊ฒฐ์ ๋ด์ญ์ ์ด๋ฏธ ์ด๋ฏธ์ง๊ฐ ์๋ค๋ฉด ์
๋ฐ์ดํธ
sharedPayment.updatePaymentImage(s3Uploader.update(image, paymentImage, dirName));
} else { // ์๋ค๋ฉด ์
๋ก๋
sharedPayment.updatePaymentImage(s3Uploader.upload(image, dirName));
}
} catch (IOException e) { // ์ด๋ฏธ์ง ์ ์ฅ์ ์คํจํ์ ๊ฒฝ์ฐ
throw new CustomException(ValidationErrorCode.IMAGE_PROCESSING_FAILED);
}
}
// image๊ฐ null๋ก ๋ค์ด์๋ค๋ฉด, ๊ธฐ์กด ์ด๋ฏธ์ง ์ญ์
else {
// S3์ ์ด๋ฏธ์ง ์ญ์
s3Uploader.delete(paymentImage);
// entity์์ ์ด๋ฏธ์ง ์ ๋ณด ์ญ์
sharedPayment.updatePaymentImage(null);
}
...
}
}