Skip to content

Commit b12f031

Browse files
Merge pull request #55 from marshmallowing/feat/image
#45 Feat: Presigned URL 기능 구현
2 parents 216560b + d7c0eda commit b12f031

File tree

5 files changed

+159
-1
lines changed

5 files changed

+159
-1
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ dependencies {
5555
implementation 'org.webjars:sockjs-client:1.0.2'
5656
implementation 'org.webjars:stomp-websocket:2.3.3'
5757
implementation 'org.webjars:jquery:3.1.1-1'
58+
59+
// s3 관련
60+
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
5861
}
5962

6063
tasks.named('test') {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.memesphere.domain.image.controller;
2+
3+
import com.memesphere.domain.image.service.ImageService;
4+
import com.memesphere.global.apipayload.ApiResponse;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.web.bind.annotation.PostMapping;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RequestParam;
11+
import org.springframework.web.bind.annotation.RestController;
12+
import org.springframework.web.multipart.MultipartFile;
13+
14+
import java.io.IOException;
15+
16+
@RequestMapping("/s3")
17+
@RestController
18+
@RequiredArgsConstructor
19+
public class ImageController {
20+
21+
private final ImageService imageService;
22+
23+
@PostMapping(value="/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
24+
@Operation(summary = "이미지 Presigned URL 발급",
25+
description = "사용자가 이미지를 업로드하고, S3에 저장한 후 해당 이미지에 대한 presigned URL을 발급받습니다. \n" +
26+
"업로드할 파일(JPG, JPEG, PNG만 가능)은 'file'이라는 키로 multipart/form-data 형식으로 첨부해야 합니다.")
27+
public ApiResponse<String> uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
28+
String url = imageService.uploadFile(file);
29+
return ApiResponse.onSuccess(url);
30+
}
31+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.memesphere.domain.image.service;
2+
3+
import com.amazonaws.SdkClientException;
4+
import com.amazonaws.services.s3.AmazonS3;
5+
import com.amazonaws.services.s3.model.ObjectMetadata;
6+
import com.memesphere.global.apipayload.code.status.ErrorStatus;
7+
import com.memesphere.global.apipayload.exception.GeneralException;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.web.multipart.MultipartFile;
12+
13+
import java.io.IOException;
14+
import java.util.Arrays;
15+
import java.util.List;
16+
import java.util.UUID;
17+
18+
@Service
19+
@RequiredArgsConstructor
20+
public class ImageService {
21+
22+
@Value("${cloud.aws.s3.bucket}")
23+
private String bucket;
24+
25+
private final AmazonS3 amazonS3;
26+
private String defaultUrl = "https://s3.amazonaws.com/";
27+
28+
// S3에 파일을 업로드
29+
public String uploadFile(MultipartFile file) throws IOException {
30+
if(file.isEmpty()){
31+
throw new GeneralException(ErrorStatus.EMPTY_FILE_EXCEPTION);
32+
}
33+
34+
validateImageFileExtension(file.getOriginalFilename());
35+
36+
String fileName=generateFileName(file);
37+
try{
38+
amazonS3.putObject(bucket, fileName,file.getInputStream(),getObjectMetadata(file));
39+
return defaultUrl+fileName;
40+
} catch(SdkClientException e){
41+
throw new IOException("error uploading file", e);
42+
}
43+
}
44+
45+
// 파일 확장자 유효성 검사
46+
private void validateImageFileExtension(String filename) {
47+
String extension = getFileExtension(filename);
48+
49+
List<String> allowedExtensions = Arrays.asList("jpg", "jpeg", "png");
50+
51+
if (!allowedExtensions.contains(extension)) {
52+
throw new GeneralException(ErrorStatus.INVALID_FILE_EXTENTION);
53+
}
54+
}
55+
56+
// 파일 확장자 추출
57+
private String getFileExtension(String filename) {
58+
int lastDotIndex = filename.lastIndexOf(".");
59+
60+
// 확장자 없을 시 예외 발생
61+
if (lastDotIndex == -1) {
62+
throw new GeneralException(ErrorStatus.INVALID_FILE_EXTENTION);
63+
}
64+
65+
return filename.substring(lastDotIndex + 1).toLowerCase();
66+
}
67+
68+
// MultipartFile의 사이즈와 타입을 S3에 전달하기 위한 메타데이터 생성
69+
private ObjectMetadata getObjectMetadata(MultipartFile file){
70+
ObjectMetadata objectMetadata = new ObjectMetadata();
71+
objectMetadata.setContentLength(file.getSize());
72+
objectMetadata.setContentType(file.getContentType());
73+
return objectMetadata;
74+
}
75+
76+
// filename 설정 (UUID 이용)
77+
private String generateFileName(MultipartFile file){
78+
return UUID.randomUUID().toString() + "-"+file.getOriginalFilename();
79+
}
80+
}

src/main/java/com/memesphere/global/apipayload/code/status/ErrorStatus.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ public enum ErrorStatus implements BaseCode {
3030
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER NOT FOUND", "유저를 찾을 수 없습니다."),
3131
PASSWORD_NOT_MATCH(HttpStatus.NOT_FOUND, "PASSWORD NOT MATCH", "비밀번호가 틀렸습니다."),
3232
USER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER ALREADY EXIST ", "이미 존재하는 회원입니다."),
33-
NICKNAME_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "NICKNAME ALREADY EXIST ", "이미 사용 중인 닉네임입니다.");
33+
NICKNAME_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "NICKNAME ALREADY EXIST ", "이미 사용 중인 닉네임입니다."),
34+
35+
// 이미지 에러
36+
EMPTY_FILE_EXCEPTION(HttpStatus.BAD_REQUEST, "EMPTY_FILE", "이미지가 빈 파일입니다."),
37+
INVALID_FILE_EXTENTION(HttpStatus.BAD_REQUEST, "INVALID FILE EXTENSION", "지원되지 않는 파일 형식입니다.");
3438

3539
private final HttpStatus httpStatus;
3640
private final String code;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.memesphere.global.config;
2+
3+
import com.amazonaws.auth.AWSCredentials;
4+
import com.amazonaws.auth.AWSCredentialsProvider;
5+
import com.amazonaws.auth.AWSStaticCredentialsProvider;
6+
import com.amazonaws.auth.BasicAWSCredentials;
7+
import com.amazonaws.services.s3.AmazonS3;
8+
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
9+
import jakarta.annotation.PostConstruct;
10+
import lombok.Getter;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.context.annotation.Bean;
13+
import org.springframework.context.annotation.Configuration;
14+
15+
@Configuration
16+
@Getter
17+
public class AmazonConfig {
18+
private AWSCredentials awsCredentials;
19+
20+
@Value("${cloud.aws.credentials.accessKey}")
21+
private String accessKey;
22+
23+
@Value("${cloud.aws.credentials.secretKey}")
24+
private String secretKey;
25+
26+
@Value("${cloud.aws.region.static}")
27+
private String region;
28+
29+
@PostConstruct
30+
public void init() { this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); }
31+
32+
@Bean
33+
public AmazonS3 amazonS3() {
34+
AWSCredentials awsCredentials1=new BasicAWSCredentials(accessKey, secretKey);
35+
return AmazonS3ClientBuilder.standard()
36+
.withRegion(region)
37+
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
38+
.build();
39+
}
40+
}

0 commit comments

Comments
 (0)