들어가며
안녕하세요. 요즘 플러피라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 기존에는 시험 지문에 단순 텍스트만 작성할 수 있었어요. 보통 시험 문제에는 이미지가 필요한 경우가 많아서 문제를 제작하는데 불편함이 많았습니다. 그래서 이미지 업로드 기능을 추가하기로 결정했습니다. 이번 글에서는 스프링에서 S3를 이용한 이미지 업로드 방법에 대해서 알아보겠습니다.
스토리지를 사용하는 이유
왜 이미지를 서버에 직접 저장하지 않고, AWS S3와 같은 클라우드 스토리지에 저장할까요? 이미지를 서버에 저장하면 서버의 디스크 용량이 부족해질 수 있습니다. 서버의 경우 스토리지에 비해 용량 추가가 어렵고, 비용이 더 많이 들 수 있습니다. 분산 환경인 경우 서버마다 이미지를 동기화하는 것도 번거로울 수 있습니다. AWS S3는 잘 알려진 클라우드 스토리지 서비스 중 하나이고, 현재 AWS 서비스를 이용해서 운영하기 때문에 AWS S3를 사용하기로 결정했습니다.
S3 버킷 생성 및 접근 권한 설정
S3 버킷 생성
스프링에서 이미지를 업로드하는 코드를 작성하기에 앞서 AWS S3에서 버킷을 생성하고, 접근 권한을 설정하는 방법에 대해서 알아보겠습니다. 버킷을 생성하기 위해서 Amazon S3로 이동 후, "버킷 만들기" 버튼을 클릭합니다.
버킷 이름은 S3에 저장된 파일들의 경로로 사용됩니다. 예를 들어, 버킷 이름이 "my-bucket"이고, AWS 리전이 "ap-northeast-2"이면 "https://my-bucket.s3.ap-northeast-2.amazonaws.com/"로 시작하는 경로가 부여됩니다.
서비스에서 올린 이미지에 접근 가능해야하므로 "모든 퍼블릭 액세스 차단" 체크박스를 해제해줍니다.
이제 버킷 만들기를 누르면 정상적으로 버킷이 생성됩니다.
S3 버킷 접근 권한 설정
모든 퍼블릭 액세스 차단을 해제했지만 버킷의 객체에 대한 접근은 기본적으로 막혀있습니다. 서비스를 이용하는 사용자들은 이미지를 자유롭게 읽을 수(다운로드 할 수) 있어야 합니다. 또한 외부 이미지 업로드가 가능할 경우 악용될 수 있으므로, 서비스의 서버를 통해서만 업로드할 수 있게 해야합니다.
해당 버킷으로 이동한 후, "권한"을 클릭해줍니다. 아래의 "버킷 정책"에서 "편집" 버튼을 클릭합니다.
버킷 ARN을 복사하고, 정책 생성기 화면으로 넘어갑니다. 그 곳에서 생성한 정책을 여기에 붙여넣고 저장합니다.
버킷 정책 생성기에서 다운로드에 대한 접근 권한(GetObject)을 생성한다. 아래 이미지에 맞게 각 항목을 채워준다. ARN에는 복사한 ARN을 붙여넣고, 뒤에 전체 경로인 "/*"를 추가해준다. 다음으로, "Add Statement" 버튼을 클릭하고, "Generate Policy" 버튼을 눌러 정책을 생성한다. 생성된 정책을 복사하고, 이전 페이지의 정책에 붙여넣는다.
생성된 정책은 다음과 같이 이루어져 있습니다.
{
"Version": "2012-10-17",
"Id": "Policy1738300337433",
"Statement": [
{
"Sid": "Stmt1738300309744",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
- Effect: "Allow"은 이 정책이 허용하는 권한을 나타냅니다.
- Principal: "*"은 모든 사용자를 의미합니다. 즉, 누구나 이 정책의 적용을 받는 리소스에 접근할 수 있습니다.
- Action: "s3:GetObject"는 S3 버킷 내의 객체를 가져오는(다운로드) 권한을 의미합니다.
- Resource: 이 정책이 적용되는 리소스를 정의합니다. 해당 버킷 내의 모든 객체에 대해 해당 권한이 부여됩니다.
다음으로 서비스의 서버에서만 이미지 업로드가 가능하게 업로드 권한을 설정해보겠습니다. IAM에서 사용자를 생성하고, 해당 권한을 부여한 후 액세스 토큰과 시크릿 액세스 토큰을 이용해서 서버에서만 이미지를 업로드할 수 있게 합니다.
IAM에서 사용자 탭에 들어간 후 "사용자 생성" 버튼을 클릭합니다. 사용자 이름을 작성한 후 다음으로 넘어갑니다.
권한 설정 중 직접 정책 연결을 선택합니다. "AmazonS3FullAccess"를 검색하고, 해당 정책을 선택합니다. 그리고 다음으로 넘어가서 "사용자를 생성" 버튼을 클릭합니다.
생성한 IAM 사용자로 이동한 후 "보안 자격 증명" 탭에서 아래로 내려가 "액세스 키 만들기" 버튼을 클릭합니다. EC2 서버에서 사용될 예정이므로 "AWS 컴퓨팅 서비스에서 실행되는 애플리케이션"을 선택하고 넘어갑니다. 여기서 생성된 액세스 키와 비밀 액세스 키는 따로 보관해두어야 합니다.
버킷 생성과 접근 권한 설정이 모두 끝났습니다.
스프링에서 AWS S3를 사용하는 방법
시험 이미지 엔티티 생성
시험 지문에 들어갈 이미지를 업로드하는 것이기 때문에 시험 이미지 엔티티를 생성하겠습니다. 시험 이미지 컬럼에는 시험 아이디, 멤버 아아디, 스토리지에 저장될 경로, 파일 용량(선택)이 들어갑니다.
시험 이미지 엔티티의 아이디는 UUID로 설정했습니다. S3에 저장될 파일의 이름이 중복될 수 없으므로 "원본 이름 + 날짜(밀리초)"로 하려 했지만 이름이 너무 길어지기도 하고, 이렇게 해도 중복될 여지가 있었습니다. 그래서 시험 엔티티의 아이디를 UUID로 하고, 저장될 파일 이름 또한 해당 아이디로 했습니다.
시험 이미지 엔티티 코드의 예시입니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ExamImage extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private Long memberId;
@Column(nullable = false)
private Long examId;
@Column(nullable = false)
private String path;
@Column(nullable = false)
private Long fileSize;
public ExamImage(...) { ... }
}
AWS S3와 상호작용하기 위한 코드 작성
AWS S3와 상호작용하는 코드를 작성하기에 앞서, 파일 업로드 및 삭제 기능을 하는 인터페이스를 작성하겠습니다.
// storage/application/StorageClient.java
public interface StorageClient {
String upload(MultipartFile file, String fileName);
void delete(String fileName);
}
이미지 업로드 방식에는 크게 MultipartFile과 Base64 방식이 있습니다. Base64 방식은 이미지를 문자열로 인코딩하고, 사용하는 곳에서 이를 다시 디코딩하여 이미지로 변환하는 방식입니다. 인코딩 과정에서 용량이 33%정도 커지기 때문에 성능적으로 좋지 않을 수 있습니다. 반면 MultipartFile 방식은 이미지 파일 그대로 업로드하기 때문에 성능적으로 더 좋습니다. 이러한 이유로 저는 MultipartFile 방식을 사용했습니다.
AWS S3를 사용하기 위해서는 의존성을 추가해야 합니다. 공식문서(awspring.io)를 참고해서 의존성을 추가해줍니다.
implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1')
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
"io.awspring.cloud:spring-cloud-aws-starter-s3"는 내부적으로 "software.amazon.awssdk:s3"를 사용합니다. 자동 환경 설정이나 코드 추상화를 통해 더 편하게 사용해주는 역할을 합니다.
자동 환경 설정을 위해서 application.yml에 아래의 값들을 설정해줍니다. access-key와 secret-key는 IAM에서 생성한 액세스 키와 비밀 액세스 키를 각각 넣어줍니다. region.static에는 "ap-northeast-2"를 넣어줍니다. s3.bucket은 원래 설정 값은 아니고, 편의를 위해서 추가했습니다. 여기에는 버킷 이름을 넣어줍니다. servlet.multipart 설정은 최대 파일 크기와 최대 파일 요청 크기를 지정할 수 있습니다. 각각 서비스에 맞게 설정해주시면 됩니다.
spring:
cloud:
aws:
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
region:
static: ${AWS_S3_REGION}
s3:
bucket: ${AWS_S3_BUCKET}
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
다음으로, StorageClient를 구현한 AwsS3Client를 작성해보겠습니다. S3Client를 이용해서 구현할 수도 있고, S3Client에 대해서 더 높은 추상화를 제공하는 S3Template을 이용할 수도 있습니다. 저는 S3Template을 이용하겠습니다. upload 메서드에서 MultipartFile과 저장할 파일 이름을 받고, 해당 버킷에 input stream으로 업로드합니다. 반환 값으로 업로드된 경로를 반환합니다.
// storage/infra/AwsS3Client.java
@Component
@RequiredArgsConstructor
public class AwsS3Client implements StorageClient {
private final S3Template s3Template;
@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;
@Override
public String upload(MultipartFile file, String fileName) {
if (file.isEmpty()) {
throw new BadRequestException("파일이 비어있습니다.");
}
try (InputStream is = file.getInputStream()) {
S3Resource upload = s3Template.upload(bucketName, fileName, is);
return upload.getURL().toString();
} catch (IOException | S3Exception e) {
throw new BadRequestException("파일 업로드에 실패했습니다.", e);
}
}
@Override
public void delete(String fileName) {
try {
s3Template.deleteObject(bucketName, fileName);
} catch (S3Exception e) {
throw new NotFoundException("파일을 찾을 수 없습니다.", e);
}
}
}
시험 이미지 업로드 코드 작성
방금 구현했던 StorageClient와 AwsS3Client를 이용해서 시험 이미지를 업로드할 수 있는 서비스 코드를 작성해보겠습니다.
데이터베이스가 아닌 애플리케이션 코드에서 UUID를 생성하는 이유는 S3에 저장될 파일 이름을 먼저 생성해야하기도 하고, 데이터베이스에서 UUID를 먼저 생성하고, 나머지 값들을 다시 수정해서 넣는 것은 번거로운 것 같아서입니다. 각자 판단해서 작성하시면 됩니다.
일반적으로 외부 API 요청을 트랜잭션 내에 포함시키는 것은 좋지 않지만 이미지 정상 생성 파악을 위해 트랜잭션 내부에 넣었습니다. S3로 이미지 업로드 실패 시 데이터베이스에서 해당 시험 이미지 값도 지우도록 했습니다. 이 부분도 각자 판단해서 작성하시면 됩니다.
@Service
@RequiredArgsConstructor
public class ExamImageService {
private final StorageClient storageClient;
private final ExamImageRepository examImageRepository;
... 생략 ...
@Transactional
public String uploadImage(Long examId, MultipartFile image, Accessor accessor) {
validateExamAuthor(examId, accessor); // 본인 시험인지 여부 검증
UUID imageId = UUID.randomUUID();
Long fileSize = image.getSize();
String filePath = generateUploadPath(imageId, accessor.id(), image); // 저장할 파일 경로 설정
ExamImage examImage = new ExamImage(imageId, accessor.id(), examId, filePath, fileSize);
examImageRepository.save(examImage);
try {
return storageClient.upload(image, filePath);
} catch (Exception e) {
examImageRepository.delete(examImage);
throw e;
}
}
... 생략 ...
}
시험 이미지를 업로드하는 API 코드는 다음과 같습니다.
@PostMapping("/api/v1/exams/{examId}/images")
public ResponseEntity<UploadExamImageResponse> uploadImage(
@PathVariable Long examId,
@RequestParam MultipartFile image,
@Auth Accessor accessor // 접근 권한
) {
String path = examImageService.uploadImage(examId, image, accessor);
return ResponseEntity.ok(new UploadExamImageResponse(path));
}
MultipartFile을 사용하기 위해서는 @RequestParam MultipartFile을 사용합니다. 여기서 "image"는 FormData에 들어갈 이름입니다. 예를 들어, 클라이언트에서 다음과 같이 사용할 수 있습니다.
const formData = new FormData();
formData.append('image', image);
Postman을 이용한 이미지 업로드 테스트
Body의 form-data에서 "image"에 이미지를 추가하고 요청합니다.
마치며
이번 글에서는 S3에서 버킷을 만들고, 접근 권한을 설정하는 방법을 알아봤습니다. 그리고 스프링에서 awspring.s3를 이용해서 S3와 상호작용하는 방법에 대해서도 알아보았습니다. 보통 CloudFront와 같은 CDN을 이용해서 보안을 강화하거나 응답 속도를 향상시키는 편입니다. 관심있으신 분들은 찾아보시면 좋을 것 같습니다.
글 읽어주셔서 감사합니다. 피드백이 있으시면 언제든지 댓글 남겨주세요 :D
참고
'서버' 카테고리의 다른 글
고가용성을 위한 단일 장애 지점(SPOF) 해결 방법 (2) | 2025.01.25 |
---|---|
확장 가능한 좋아요 기능 설계 및 구현하기 (4) | 2025.01.21 |
커버링 인덱스를 활용한 페이지네이션 성능 개선하기 (1) | 2025.01.19 |
Docker Desktop 오류, "'com.docker.vmnetd'에 악성 코드가 포함되어 있어서 열리지 않았습니다." 해결 방법 (1) | 2025.01.13 |
Spring REST Docs로 믿을 수 있는 API 문서 만들기 (1) | 2025.01.12 |