๐ ๊ฐ์
์งํ ์ค์ธ ํ๋ก์ ํธ์์ ์ด๋ฏธ์ง๋ฅผ ์ ์ฅํด์ผ ํ๋ค.
์ด๋ฏธ์ง๋ฅผ 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.global;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
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.IOException;
import java.io.InputStream;
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;
// S3์ ์ด๋ฏธ์ง ์
๋ก๋
String contentType = file.getContentType();
if(contentType == null) contentType = "application/octet-stream";
String uploadImageUrl = putS3(file.getInputStream(), fileName, file.getSize(), contentType);
// 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);
}
// S3์ ์ด๋ฏธ์ง ์
๋ก๋
private String putS3(InputStream inputStream, String fileName, long contentLength, String contentType) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(contentLength);
metadata.setContentType(contentType); // Content-Type ์ค์
amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, metadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3.getUrl(bucket, fileName).toString();
}
}
์ค์ง์ ์ผ๋ก S3Uploader์์ ํธ์ถ๋๋ ๋ฉ์๋๋ upload, delete, update์ด๋ค.
๐ Service์์ S3Uploader ์ฌ์ฉ
package withbeetravel.service.payment;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import withbeetravel.domain.SharedPayment;
import withbeetravel.domain.Travel;
import withbeetravel.dto.response.payment.SharedPaymentRecordResponse;
import withbeetravel.exception.CustomException;
import withbeetravel.exception.error.PaymentErrorCode;
import withbeetravel.exception.error.TravelErrorCode;
import withbeetravel.exception.error.ValidationErrorCode;
import withbeetravel.repository.SharedPaymentRepository;
import withbeetravel.repository.TravelRepository;
import withbeetravel.service.global.S3Uploader;
import java.io.IOException;
@Service
@RequiredArgsConstructor
public class SharedPaymentRecordServiceImpl implements SharedPaymentRecordService {
private final S3Uploader s3Uploader;
private final TravelRepository travelRepository;
private final SharedPaymentRepository sharedPaymentRepository;
// S3์ ์ด๋ฏธ์ง๋ฅผ ์ ์ฅํ ๊ฒฝ๋ก
private static final String SHARED_PAYMENT_IMAGE_DIR = "travels/";
@Override
@Transactional
public void 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));
// ์ฌํ ์ ๋ณด ์ฐพ์์ค๊ธฐ
Travel travel = travelRepository.findById(travelId)
.orElseThrow(() -> new CustomException(TravelErrorCode.TRAVEL_NOT_FOUND));
// ์ด๋ฏธ์ง ์ถ๊ฐ, ์์ , ์ญ์
if(image != null && image.getSize() != 0) {
String newImageUrl = uploadImage(travelId, sharedPayment, image);
sharedPayment.updatePaymentImage(newImageUrl);
// ๋ฉ์ธ ์ด๋ฏธ์ง๋ก ์ค์ ํ๋ค๋ฉด, ์ฌํ ๋ฉ์ธ ์ฌ์ง ๋ฐ๊ฟ์ฃผ๊ธฐ
if(isMainImage) {
// ์ฌํ ์ด๋ฏธ์ง ์์
travel.updateMainImage(newImageUrl);
}
}
// ๊ธฐ์กด ์ด๋ฏธ์ง๋ฅผ ๋ฉ์ธ ์ด๋ฏธ์ง๋ก ์ค์ ํ ๊ฒฝ์ฐ
else if(isMainImage && sharedPayment.getPaymentImage() != null) {
// ์ฌํ ์ด๋ฏธ์ง ์์
travel.updateMainImage(sharedPayment.getPaymentImage());
}
// comment ์ ๋ณด ์ํฐํฐ์์ ๋ณ๊ฒฝ
sharedPayment.updatePaymentCommnet(comment);
}
// ์ด๋ฏธ์ง ์ถ๊ฐ, ์์ , ์ญ์
private String uploadImage(Long travelId, SharedPayment sharedPayment, MultipartFile image) {
// ์๋ ์ด๋ฏธ์ง
String paymentImage = sharedPayment.getPaymentImage();
// ์๋ก ์ถ๊ฐํ ์ด๋ฏธ์ง
String newImage = null;
// image๊ฐ ์๋ก ๋ค์ด์๋ค๋ฉด S3์ ์ ์ฅ
if(!image.isEmpty()) {
// ์ด๋ฏธ์ง ์ ์ฅํ S3 ๋๋ ํ ๋ฆฌ ์ ๋ณด
String dirName = SHARED_PAYMENT_IMAGE_DIR + travelId;
try {
if(paymentImage != null) { // ํด๋น ๊ณต๋๊ฒฐ์ ๋ด์ญ์ ์ด๋ฏธ ์ด๋ฏธ์ง๊ฐ ์๋ค๋ฉด ์
๋ฐ์ดํธ
newImage = s3Uploader.update(image, paymentImage, dirName);
} else { // ์๋ค๋ฉด ์
๋ก๋
newImage = s3Uploader.upload(image, dirName);
}
} catch (IOException e) { // ์ด๋ฏธ์ง ์ ์ฅ์ ์คํจํ์ ๊ฒฝ์ฐ
throw new CustomException(ValidationErrorCode.IMAGE_PROCESSING_FAILED);
}
}
// ์๋ก์ด ์ด๋ฏธ์ง ์ ๋ณด ๋ฐํ
return newImage;
}
}