Skip to content

Commit 0dce162

Browse files
Feat: [FN-82] S3 presigned url 생성
Feat: [FN-82] S3 presigned url 생성
2 parents 2cdf47e + 51c0a23 commit 0dce162

13 files changed

Lines changed: 468 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ jobs:
2424
permissions:
2525
contents: read
2626

27+
env:
28+
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
29+
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
30+
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
31+
S3_BUCKET_REGION: ${{ secrets.S3_BUCKET_REGION }}
32+
2733
steps:
2834
- uses: actions/checkout@v4
2935
- name: Set up JDK 17

.github/workflows/develop-cd.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ jobs:
88
deploy:
99
runs-on: ubuntu-latest
1010

11+
env:
12+
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
13+
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
14+
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
15+
S3_BUCKET_REGION: ${{ secrets.S3_BUCKET_REGION }}
16+
1117
steps:
1218
- name: Checkout source code
1319
uses: actions/checkout@v4
@@ -44,6 +50,11 @@ jobs:
4450
script: |
4551
export IMAGE_TAG=${{ steps.meta.outputs.version }}
4652
bash ~/deploy/deploy-flipnote.sh
53+
env:
54+
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
55+
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
56+
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
57+
S3_BUCKET_REGION: ${{ secrets.S3_BUCKET_REGION }}
4758

4859
- name: Notify Slack on Success
4960
if: success()

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ dependencies {
4444
testImplementation 'org.springframework.security:spring-security-test'
4545
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
4646
testRuntimeOnly 'com.h2database:h2'
47+
implementation platform('software.amazon.awssdk:bom:2.20.56')
48+
implementation 'software.amazon.awssdk:s3'
4749
}
4850

4951
tasks.named('test') {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package project.flipnote.common.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
8+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
9+
import software.amazon.awssdk.regions.Region;
10+
import software.amazon.awssdk.services.s3.S3Client;
11+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
12+
13+
@Configuration
14+
public class S3Config {
15+
@Value("${cloud.aws.credentials.access-key}")
16+
private String accessKey;
17+
18+
@Value("${cloud.aws.credentials.secret-key}")
19+
private String secretKey;
20+
21+
@Value("${cloud.aws.region}")
22+
private String region;
23+
24+
/*
25+
리전과 자격 증명한 객체 생성
26+
*/
27+
@Bean
28+
public S3Client s3Client() {
29+
return S3Client.builder()
30+
.region(Region.of(region))
31+
.credentialsProvider(
32+
StaticCredentialsProvider.create(
33+
AwsBasicCredentials.create(accessKey, secretKey)
34+
)
35+
)
36+
.build();
37+
}
38+
39+
@Bean
40+
public S3Presigner s3Presigner() {
41+
return S3Presigner.builder()
42+
.region(Region.of(region))
43+
.credentialsProvider(
44+
StaticCredentialsProvider.create(
45+
AwsBasicCredentials.create(accessKey, secretKey)
46+
)
47+
)
48+
.build();
49+
}
50+
51+
}

src/main/java/project/flipnote/group/entity/Group.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
@Getter
2121
@NoArgsConstructor(access = AccessLevel.PROTECTED)
22-
@Table(name = "groups")
22+
@Table(name = "app_groups")
2323
@Entity
2424
public class Group extends BaseEntity {
2525
@Id
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package project.flipnote.image.controller;
2+
3+
import org.springframework.http.ResponseEntity;
4+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
5+
import org.springframework.web.bind.annotation.PostMapping;
6+
import org.springframework.web.bind.annotation.RequestBody;
7+
import org.springframework.web.bind.annotation.RequestMapping;
8+
import org.springframework.web.bind.annotation.RestController;
9+
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import project.flipnote.common.security.dto.AuthPrinciple;
13+
import project.flipnote.image.model.ImageUploadRequestDto;
14+
import project.flipnote.image.model.ImageUploadResponseDto;
15+
import project.flipnote.image.service.ImageUploadService;
16+
17+
@RestController
18+
@RequestMapping("/v1/images")
19+
@RequiredArgsConstructor
20+
public class ImageUploadController {
21+
22+
private final ImageUploadService fileService;
23+
24+
//파일 업로드 API
25+
@PostMapping("/upload")
26+
public ResponseEntity<ImageUploadResponseDto> getPresignedUrl(
27+
@AuthenticationPrincipal AuthPrinciple authPrinciple,
28+
@RequestBody @Valid ImageUploadRequestDto req) {
29+
ImageUploadResponseDto res = fileService.getPresignedUrl(authPrinciple, req.fileName());
30+
return ResponseEntity.ok(res);
31+
}
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package project.flipnote.image.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
import project.flipnote.common.exception.ErrorCode;
8+
9+
@Getter
10+
@RequiredArgsConstructor
11+
public enum ImageErrorCode implements ErrorCode {
12+
CONFLICT_IMAGE(HttpStatus.CONFLICT, "IMAGE_001", "이미 존재하는 파일입니다."),
13+
S3_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE_002", "S3 서비스 처리 중 오류가 발생했습니다.");
14+
15+
private final HttpStatus httpStatus;
16+
private final String code;
17+
private final String message;
18+
19+
@Override
20+
public int getStatus() {
21+
return httpStatus.value();
22+
}
23+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package project.flipnote.image.model;
2+
3+
import jakarta.validation.constraints.Pattern;
4+
5+
public record ImageUploadRequestDto(
6+
@Pattern(
7+
regexp = "^[a-fA-F0-9]{32}\\.(jpg|jpeg|png|gif)$",
8+
message = "파일 이름은 32자리 MD5 해시와 jpg/jpeg/png/gif 확장자 형식이어야 합니다."
9+
)
10+
String fileName
11+
) {
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package project.flipnote.image.model;
2+
3+
import java.net.URL;
4+
5+
public record ImageUploadResponseDto(
6+
URL url
7+
) {
8+
public static ImageUploadResponseDto from(URL url) {
9+
return new ImageUploadResponseDto(url);
10+
}
11+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package project.flipnote.image.service;
2+
3+
import java.net.URL;
4+
import java.time.Duration;
5+
import java.util.Date;
6+
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Service;
9+
10+
import lombok.RequiredArgsConstructor;
11+
import project.flipnote.common.exception.BizException;
12+
import project.flipnote.common.security.dto.AuthPrinciple;
13+
import project.flipnote.image.exception.ImageErrorCode;
14+
import project.flipnote.image.model.ImageUploadResponseDto;
15+
import project.flipnote.user.entity.UserProfile;
16+
import project.flipnote.user.entity.UserStatus;
17+
import project.flipnote.user.exception.UserErrorCode;
18+
import project.flipnote.user.repository.UserProfileRepository;
19+
import software.amazon.awssdk.services.s3.S3Client;
20+
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
21+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
22+
import software.amazon.awssdk.services.s3.model.S3Exception;
23+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
24+
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
25+
26+
@Service
27+
@RequiredArgsConstructor
28+
public class ImageUploadService {
29+
30+
@Value("${cloud.s3.bucket}")
31+
private String bucket;
32+
33+
private final S3Client s3Client;
34+
private final S3Presigner s3Presigner;
35+
private final UserProfileRepository userRepository;
36+
private static final int EXPIRE_MINUTES = 5;
37+
38+
//유저 찾기
39+
private void findUser(AuthPrinciple authPrinciple) {
40+
userRepository.findByIdAndStatus(authPrinciple.userId(), UserStatus.ACTIVE).orElseThrow(
41+
() -> new BizException(UserErrorCode.USER_NOT_FOUND)
42+
);
43+
}
44+
45+
//확장자 형식 찾기
46+
private String getContentType(String fileName) {
47+
String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
48+
49+
return switch (extension) {
50+
case "jpg", "jpeg" -> "image/jpeg";
51+
case "png" -> "image/png";
52+
case "gif" -> "image/gif";
53+
case "webp" -> "image/webp";
54+
default -> "application/octet-stream";
55+
};
56+
}
57+
58+
// presigned URL 생성
59+
public ImageUploadResponseDto getPresignedUrl(AuthPrinciple authPrinciple, String fileName) {
60+
61+
// 유저 찾기
62+
findUser(authPrinciple);
63+
64+
// S3에 동일한 파일명이 이미 존재하는지 확인
65+
if (objectExists(fileName)) {
66+
throw new BizException(ImageErrorCode.CONFLICT_IMAGE);
67+
}
68+
69+
// PutObjectRequest 정의
70+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
71+
.bucket(bucket)
72+
.key(fileName)
73+
.contentType(getContentType(fileName))
74+
.build();
75+
76+
// Presign 요청 생성 (5분 유효)
77+
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
78+
.signatureDuration(Duration.ofMinutes(EXPIRE_MINUTES))
79+
.putObjectRequest(putObjectRequest)
80+
.build();
81+
82+
URL presignedUrl = s3Presigner.presignPutObject(presignRequest).url();
83+
84+
return ImageUploadResponseDto.from(presignedUrl);
85+
}
86+
87+
// 파일 존재 여부 확인
88+
private boolean objectExists(String fileName) {
89+
try {
90+
s3Client.headObject(
91+
HeadObjectRequest.builder()
92+
.bucket(bucket)
93+
.key(fileName)
94+
.build()
95+
);
96+
return true;
97+
} catch (S3Exception e) {
98+
// 404면 존재하지 않음
99+
if (e.statusCode() == 404) {
100+
return false;
101+
}
102+
throw new BizException(ImageErrorCode.S3_SERVICE_ERROR);
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)