새벽에도 개발은 계속된다~ 😪(졸면서 코딩중)
배달 앱 후기를 남길 때, 음식에 대한 사진을 올릴 수 있다.
그렇다면 이러한 음식을 프론트에서 보여줄 때, 사진파일이 직접 왔다갔다 하는 걸까?
정답은 따로 이미지를 호스팅하는 곳을 두고, 그 곳에 갈 수 있는 경로 (URL) 정보를 주면, 프론트는 사용자가 그곳에 직접 접속해서 정보를 받아올 수있게 한다.
AWS의 잘 알려진 EC2가 부품을 마음껏 교체할 수 있는 '컴퓨터를 빌리는 것'이라면 S3는 무한한 용량의 하드디스크를 빌리는 것'이라고 할 수 있다.
S3 서비스는 다음과 같은 특징을 지닌다.
버킷 (Bucket): 프로젝트당 하나 혹은 용도별로 하나씩 만드는 전 세계에서 유일한 이름의 파일 담는 양동이.
객체 (Object): 버킷에 담기는 '파일'
S3는 단순히 파일을 저장하는 게 아니라, 파일에 대한 메타데이터(생성일, 크기 등)를 함께 관리하기 때문에 객체라고 부른다.
IAM (Identity and Access Management): 권한 관리자
Spring Boot 서버가 내 버킷에만 접근할 수 있도록 전용 '출입증(Access Key)'을 끊어주는 곳입니다.
AWS 아이디를 열심히 찾아서 들어갔는데... 미처 지우지 못했던 옛날 S3들이 아직도 남아있었다...!(심지어 버지니아..?)

리전을 서울로 바꿔주고 버킷 만들기 버튼을 눌러준다.

혹시 팀원과 필요하게 될 일이 생길까봐 ACL활성화된 채로 놔두었지만 쓸 일은 없는것 같다.

ACL은 파일마다 개별적으로 붙이는 구식 권한표이고, (개별 관리)
지금은 관리의 편의성과 보안을 위해 버킷 정책(Bucket Policy)이라는 한 곳에서 통제하는 방식을 씁니다.(일괄 관리)
모든 퍼블릭 엑세스 차단을 풀어준다.

나머지는 기본 설정으로 두고 맨 아래 버킷 만들기를 눌러준다.

그러면 이제 S3 목록에 뜨게 되는데, 이 버킷을 사용하려면 추가로 버킷의 권한 정책을 설정해주어야 한다.

버킷의 이름을 누르면 권한 탭에서 버킷 정책 설정을 찾을 수 있다.

편집을 누르면 버킷 정책을 만들 수 있는 화면으로 이동하는데, 보통 이미지 호스팅에 쓰이는 권한은 S3의 읽기 엑세스 수준의 GetObject 권한이다.
오른쪽 문 추가 > 작업추가 > 리소스 추가 등을 통해 키워드를 쉽게 입력할 수 있는데, 뭐가 뭔지 몰라서 큰 도움이 되진 않았다.
설정파일을 복사해서 붙여넣어준다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::omin-dev-test/*"
]
}
]
}
💡 이 설정의 의미
누가? : Principal: "" (전 세계 누구나)
무엇을? : Action: ["s3:GetObject"] (파일을 읽고 다운로드하는 것만)
어디서? : Resource: ["arn:aws:s3:::omin-dev-test/"] (omin-dev-test 버킷 안의 모든 파일)
변경사항 저장을 누르면, 이제 이 양동이는 누구나 찾아와 내용을 '보기만' 할 수 있게 된다.
AWS 상단 검색창에 IAM을 검색합니다.
왼쪽 메뉴에서 [사용자] -> [사용자 생성]을 누릅니다.
사용자 이름: spring-s3-user 정도로 정합니다.
권한 설정: '직접 정책 연결'을 선택하고, 검색창에 AmazonS3FullAccess를 체크합니다. (S3를 마음대로 쓸 수 있는 권한입니다.)


사용자 생성을 완료한 후, 생성된 사용자를 클릭해 [보안 자격 증명] 탭에서
액세스 키 만들기 버튼을 누르고 'Application running outside AWS'를 선택, 설명이나 태그는 건너뛰어도 된다.


Access Key와 Secret Access Key가 나온다. 이건 딱 한 번만 보여주니 꼭 따로 메모장에 복사해놔야한다.
dependencies {
// Spring Cloud AWS 3.x (S3 전용 스타터)
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
spring-cloud-aws-starter-s3: 이 라이브러리를 쓰면, 별도의 복잡한 Config 클래스 필요 없이, yml 설정만 제대로 되어 있으면 Spring이 알아서 S3 접속 객체를 만들어 주기때문에 S3Template이나 S3Client를 주입받아 쓰기만 하면 된다.
(Config 클래스는 SpringBoot 3.5.x 이전 방식)
cloud:
aws:
credentials:
access-key: ${AWS_ACCESS}
secret-key: ${AWS_SECRET}
region:
static: ap-northeast-2 # 아시아 태평양(서울)
s3:
bucket:
omin-dev-test
@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Template s3Template;
@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;
public String uploadImage(MultipartFile file) {
// 1. 파일 이름이 겹치지 않게 UUID를 붙여서 생성합니다.
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
// 2. S3에 업로드합니다.
// s3Template은 Spring Cloud AWS 3.x에서 제공하는 가장 편리한 도구입니다.
try {
var result = s3Template.upload(bucketName, fileName, file.getInputStream());
// 3. 업로드된 파일의 공개 URL을 반환합니다.
return result.getURL().toString();
} catch (IOException e) {
throw new RuntimeException("파일 업로드 중 오류가 발생했습니다.", e);
}
}
}
@RestController
@RequestMapping("/api/images")
@RequiredArgsConstructor
public class ImageController {
private final S3Service s3Service;
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
String imageUrl = s3Service.uploadImage(file);
return ResponseEntity.ok(imageUrl); // 업로드된 S3 주소를 응답으로 보냄
}
}
S3 이미지 업로드 구현을 뒤로 미뤄놨기때문에, 이미지를 S3에 올려놨다 가정하고 문자열을 반환하는 Stub 함수를 사용하고 있었다.
그런데 S3예제를 보니까, 이를 Util 클래스에 적용하면 코드를 수정하지 않고 원하는 동작을 만들 수 있을 것 같았다.
package com.sparta.omin.common.util;
@Component
@RequiredArgsConstructor
public class ImageUploader {
private final S3Service s3Service;
public String uploadReviewImage(MultipartFile file) {
return s3Service.uploadImage(file);
}
public void deleteReviewImage(String url) { // S3에서 해당 url을 가진 이미지 삭제
}
}


가장 일반적인 방법으로, try-catch 문을 사용하여 DB 저장 중 에러가 발생하면 S3에 올라간 파일을 지우는 로직을 직접 넣습니다.
@Transactional
public void saveReview(ReviewRequest dto, MultipartFile file) {
// 1. S3에 이미지 먼저 업로드
String imageUrl = s3Service.uploadImage(file);
try {
// 2. DB에 리뷰 정보 저장 (여기서 에러가 나면 롤백 발생)
Review review = Review.builder()
.content(dto.getContent())
.imageUrl(imageUrl)
.build();
reviewRepository.save(review);
} catch (Exception e) {
// 3. DB 에러 시 S3 파일 수동 삭제
s3Service.deleteImage(imageUrl);
throw e; // 다시 던져서 트랜잭션 롤백 유도
}
}
실무에서는 서버가 다운되는 등 예기치 못한 상황으로 삭제 로직마저 실패할 수 있습니다.
DB에는 파일 경로가 없는데 S3에는 파일이 존재하는 경우를 '고아 파일'이라고 합니다.
주기적으로(예: 매일 새벽) DB 이미지 리스트와 S3 파일 리스트를 대조해서, DB에 없는 S3 파일만 골라 지우는 스케줄러를 돌리기도 합니다.
DB 저장을 먼저 하고 마지막에 S3를 올리는 방법도 있지만, 만약 S3 업로드 단계에서 실패하면 DB는 이미 커밋되어 버려서 정보가 꼬일 위험이 더 큽니다.
@RestController
@RequestMapping("/api/v1/images")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('MANAGER', 'MASTER')")
public class ImageController {
private final S3Service s3Service;
@PostMapping
public ResponseEntity<List<String>> uploadImages(@RequestPart(value = "file") List<MultipartFile> files) {
List<String> urls = new ArrayList<>();
for (MultipartFile file : files) {
if (!file.isEmpty()) {
urls.add(s3Service.uploadImage(file, "Review")); // 도메인 이름을 명시해주는 게 좋겠죠?
}
}
// String 메시지가 아닌, 실제 URL 리스트 객체를 body에 담아 반환합니다.
return ResponseEntity.ok(urls);
}
@DeleteMapping("/{*imageUri}")
public ResponseEntity<String> deleteImage(@PathVariable String imageUri) {
s3Service.deleteImage(imageUri);
return ResponseEntity.ok().build();
}
}
package com.sparta.omin.common.util;
import io.awspring.cloud.s3.S3Template;
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
public class S3Service {
private final S3Template s3Template;
@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;
public String uploadImage(MultipartFile file, String domainName) {
// 1. S3에서 파일 이름이 겹치지 않게 UUID를 붙여서 생성합니다.
String fileName = domainName + "/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
// 2. S3에 업로드
try {
var result = s3Template.upload(bucketName, fileName, file.getInputStream());
// 3. 업로드된 파일의 공개 URL을 반환합니다.
return result.getURL().toString();
} catch (IOException e) {
throw new RuntimeException("S3 파일 업로드 중 오류가 발생했습니다.", e);
}
}
// S3Service.java
public void deleteImage(String fileUrl) {
// 1. URL 앞부분이 들어올 경우를 대비해 Key만 추출
// 예: https://...amazonaws.com/Review/abc.png -> Review/abc.png
String fileKey = extractFileKey(fileUrl);
if (fileKey == null || fileKey.trim().isEmpty() || fileKey.equals("/")) {
throw new IllegalArgumentException("유효하지 않은 S3 Key입니다.");
}
if (fileKey.endsWith("/")) {
throw new IllegalArgumentException("폴더 경로는 삭제할 수 없습니다: " + fileKey);
}
try {
s3Template.deleteObject(bucketName, fileKey);
} catch (Exception e) {
throw new RuntimeException("S3 파일 삭제 실패", e);
}
}
private String extractFileKey(String fileUrl) {
// URL에 "amazonaws.com/"이 포함되어 있다면 그 이후 문자열만 가져옴
String splitStr = ".com/";
if (fileUrl.contains(splitStr)) {
return fileUrl.substring(fileUrl.lastIndexOf(splitStr) + splitStr.length());
}
// 이미 Key 형태라면 (앞에 /가 붙어있을 경우 제거)
return fileUrl.startsWith("/") ? fileUrl.substring(1) : fileUrl;
}
}


:// 같은 특수기호가 포함되면 다른 경로로 인식해 에러가 난다.
extractFileKey() 가 처리해 프로토콜 정보를 빼주면 정상적으로 인식된다.

리뷰 도메인에 필요한 Data Initializer를 작성하고 배포 직전 테스트 하던 도중 리뷰와 연관된 Store 테이블이 생성이 안되는 것을 확인했다.
git pull을 안했다 머지중에 누락됐나 고민하다가 결국 가게 주소에서 좌표를 저장하기 위한 postgres의 GIS 확장이 설치되지 않아서 임을 알게 되었다.
길고 장황한 에러메세지를 보면 당황해서 간단한 문제인데도 지능을 잃고 얼어버리는 경우가 많은 것 같다.
앞으로는 에러메시지를 읽을 때
테이블 생성이 안됐다 -> 테이블 생성 쿼리를 본다
정신차리고 생각을 해야겠다!

PostGIS 는
PostgreSQL 에 공간(위도/경도, geometry 등) 기능을 추가하는 확장입니다.
나는 필요한 데이터는 모두 java 파일로 스프링부트를 띄울 때 프로퍼티에 따라 생성하기 때문에 기존 DB를 날려도 됐기 때문에 새로운 이미지로 도커를 띄우려고 했다.

그런데.. 내 운영체제를 지원하지 않는건가?
설치가 안되는것이다 ㅜㅜ
찾아보니, postgis/postgis:latest 태그에 ARM64용 매니페스트가 포함되어 있지 않아서 라고 했다.

하지만 나와있는 모든 버전을 시도해도 없다고 해서 다른 방법을 찾아봤다
docker run --name 프로젝트이름-역할-환경 \
-e POSTGRES_USER=유저이름 \
-e POSTGRES_PASSWORD=비밀번호 \
-e POSTGRES_DB=db이름 \
-p 5432:5432 \
--platform linux/amd64
-d postgis/postgis


앗 기존 db 종료를 안했다.

이렇게 입력해주면 잘 실행이 된다!
테스트를 할 때 테이블을 지우려고 모든 테이블에서 drop을 눌렀는데

cannot drop table spatial_ref_sys because extension postgis requires it
즉 spatial_ref_sys 테이블은 직접 만든 테이블이 아니라 PostGIS 확장에서 관리하는 시스템 테이블이라서 일반 DROP TABLE이 안 된다.
GIS 확장을 설치하면 자동으로 생성되는 테이블 중 하나인 spatial_ref_sys인데,
그래서 PostgreSQL이 이 테이블은 extension이 관리하니까 직접 지우지 말라고 한다.