From 38306874e44da0e480e344e19161acb7f536c335 Mon Sep 17 00:00:00 2001 From: qkr10 Date: Thu, 17 Jul 2025 16:02:35 +0900 Subject: [PATCH] =?UTF-8?q?test:=20presigned=20url=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?api=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/backend/server/ServerApplication.java | 1 - .../CommonPresignedUrlController.java | 16 +++++ .../s3/service/CommonPresignedUrlService.java | 46 ++----------- .../com/backend/server/config/S3Config.java | 57 ++++++++++++++++ .../CommonPresignedUrlControllerTest.java | 56 +++++++++++++++ .../server/config/RestTemplateConfig.java | 68 +++++++++++++++++++ .../com/backend/server/util/MockMvcUtil.java | 8 +-- .../com/backend/server/util/S3ApiUtil.java | 63 +++++++++++++++++ 8 files changed, 268 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/backend/server/config/S3Config.java create mode 100644 src/test/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlControllerTest.java create mode 100644 src/test/java/com/backend/server/config/RestTemplateConfig.java create mode 100644 src/test/java/com/backend/server/util/S3ApiUtil.java diff --git a/src/main/java/com/backend/server/ServerApplication.java b/src/main/java/com/backend/server/ServerApplication.java index 9180fb18..d8826aec 100644 --- a/src/main/java/com/backend/server/ServerApplication.java +++ b/src/main/java/com/backend/server/ServerApplication.java @@ -2,7 +2,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/src/main/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlController.java b/src/main/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlController.java index ab45bdfa..6ce34350 100644 --- a/src/main/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlController.java +++ b/src/main/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlController.java @@ -30,4 +30,20 @@ public ApiResponse getPresignedUrl( String url = presignedUrlService.generatePresignedUrl(fileName); return ApiResponse.success("presigned url 발급 성공", url); } + + @Operation( + summary = "s3에 파일 업로드용 presigned url 발급 API", + description = "presigned url 을 발급합니다. presigned url 은 5분만 유효합니다.

" + + "아래는 curl 명령어로 파일을 업로드하는 예시입니다.
" + + "curl -X PUT \"발급받은 url\" --upload-file 파일 위치

" + + "아래는 js fetch 명령어로 파일을 업로드하는 예시입니다.
" + + "fetch(발급받은 url, { method: 'PUT', body: 파일 객체 });
" + + "(파일 객체는 <input type=\"file\"> 등으로 얻은 File 타입 객체입니다.)" + ) + @GetMapping("/presigned-url-v2") + public ApiResponse getPresignedUrlV2( + @RequestParam String fileName) { + String url = presignedUrlService.generatePresignedUrl(fileName); + return ApiResponse.success("presigned url 발급 성공", url); + } } \ No newline at end of file diff --git a/src/main/java/com/backend/server/api/common/s3/service/CommonPresignedUrlService.java b/src/main/java/com/backend/server/api/common/s3/service/CommonPresignedUrlService.java index 4ea14fd4..6ab0c96f 100644 --- a/src/main/java/com/backend/server/api/common/s3/service/CommonPresignedUrlService.java +++ b/src/main/java/com/backend/server/api/common/s3/service/CommonPresignedUrlService.java @@ -1,56 +1,24 @@ package com.backend.server.api.common.s3.service; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; +import com.backend.server.config.S3Config.S3Properties; +import java.net.URL; import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; - -import java.net.URL; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @Service +@RequiredArgsConstructor public class CommonPresignedUrlService { - @Value("${cloud.aws.s3.bucket}") - private String bucketName; - - @Value("${cloud.aws.credentials.access-key}") - private String accessKey; - - @Value("${cloud.aws.credentials.secret-key}") - private String secretKey; - - @Value("${cloud.aws.region.static}") - private String region; - - private S3Presigner s3Presigner; - - @PostConstruct - public void init() { - AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); - - this.s3Presigner = S3Presigner.builder() - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } - - @PreDestroy - public void shutdown() { - if (s3Presigner != null) { - s3Presigner.close(); - } - } + private final S3Presigner s3Presigner; + private final S3Properties s3Properties; public String generatePresignedUrl(String fileName) { PutObjectRequest objectRequest = PutObjectRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucket()) .key(fileName) .build(); diff --git a/src/main/java/com/backend/server/config/S3Config.java b/src/main/java/com/backend/server/config/S3Config.java new file mode 100644 index 00000000..0654346d --- /dev/null +++ b/src/main/java/com/backend/server/config/S3Config.java @@ -0,0 +1,57 @@ +package com.backend.server.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + @Value("${cloud.aws.credentials.access-key:asdf}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key:asdf}") + private String secretKey; + + private final S3Properties s3Properties; + + @Bean + public AwsBasicCredentials credentials() { + return AwsBasicCredentials.create(accessKey, secretKey); + } + + @Bean(destroyMethod = "close") + public S3Presigner getS3Presigner(AwsBasicCredentials credentials) { + return S3Presigner.builder() + .region(Region.of(s3Properties.region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } + + @Bean + public S3Client s3Client(AwsBasicCredentials credentials) { + return S3Client.builder() + .region(Region.of(s3Properties.region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } + + @Getter + @Component + public static class S3Properties { + @Value("${cloud.aws.s3.bucket:asdf}") + private String bucket; + + @Value("${cloud.aws.region.static:asdf}") + private String region; + } +} diff --git a/src/test/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlControllerTest.java b/src/test/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlControllerTest.java new file mode 100644 index 00000000..90b7c8dc --- /dev/null +++ b/src/test/java/com/backend/server/api/common/s3/controller/CommonPresignedUrlControllerTest.java @@ -0,0 +1,56 @@ +package com.backend.server.api.common.s3.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.backend.server.config.ControllerTest; +import com.backend.server.util.S3ApiUtil; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.annotation.IfProfileValue; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@ControllerTest +@DisplayName("CommonPresignedUrlController") +class CommonPresignedUrlControllerTest { + + @Autowired S3ApiUtil s3ApiUtil; + @Autowired MockMvc mockMvc; + + @Nested + class Presigned_URL_API_는 { + + @Test + @IfProfileValue(name = "spring.profiles.active", value = "integration-test") + void 파일_업로드가_가능한_URL을_응답한다() throws Exception { + //given + final String fileName = "asdf.txt"; + final String fileContent = "Hello World!"; + + //when + ResultActions result = mockMvc.perform(get("/api/s3/presigned-url") + .param("fileName", fileName)); + + //then + result.andExpect(status().isOk()); + + String responseJson = result.andReturn().getResponse().getContentAsString(); + String presignedUrl = JsonPath.parse(responseJson).read("$.data", String.class); + + s3ApiUtil.upload(presignedUrl, fileContent); + + try { + assertThat(s3ApiUtil.get(fileName)) + .as("업로드한 파일 내용과 업로드된 파일 내용이 일치하는지 확인합니다.") + .isEqualTo(fileContent); + } finally { + s3ApiUtil.delete(fileName); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/backend/server/config/RestTemplateConfig.java b/src/test/java/com/backend/server/config/RestTemplateConfig.java new file mode 100644 index 00000000..b3f02111 --- /dev/null +++ b/src/test/java/com/backend/server/config/RestTemplateConfig.java @@ -0,0 +1,68 @@ +package com.backend.server.config; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.stream.Collectors; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .requestFactory(() -> + // LoggingInterceptor 에서 응답 객체를 사용하기 떄문에, 응답 객체를 재사용 가능하게 하는 설정입니다. + new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())) + .additionalInterceptors(new LoggingInterceptor()) + .build(); + } + + @Slf4j + static class LoggingInterceptor implements ClientHttpRequestInterceptor { + + @Override + public @NonNull ClientHttpResponse intercept( + @NonNull HttpRequest request, + byte @NonNull [] body, + ClientHttpRequestExecution execution + ) throws IOException { + logRequest(request, body); + ClientHttpResponse response = execution.execute(request, body); + logResponse(response); + return response; + } + + private void logRequest(HttpRequest request, byte[] body) { + log.info("Request: {} {}", request.getMethod(), request.getURI()); + log.info("Headers: {}", request.getHeaders()); + log.info("Body: {}", new String(body, StandardCharsets.UTF_8)); + } + + private void logResponse(ClientHttpResponse response) throws IOException { + String responseBody = new BufferedReader(new InputStreamReader(response.getBody())) + .lines() + .collect(Collectors.joining("\n")); + + log.info("Status code: {}", response.getStatusCode()); + log.info("Headers: {}", response.getHeaders()); + log.info("Body: {}", responseBody); + } + } +} diff --git a/src/test/java/com/backend/server/util/MockMvcUtil.java b/src/test/java/com/backend/server/util/MockMvcUtil.java index 7a650976..6dbc83c7 100644 --- a/src/test/java/com/backend/server/util/MockMvcUtil.java +++ b/src/test/java/com/backend/server/util/MockMvcUtil.java @@ -12,13 +12,11 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.jayway.jsonpath.JsonPath; import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -@Component public class MockMvcUtil { private static final ObjectMapper mapper = new ObjectMapper() @@ -54,9 +52,7 @@ public static MultiValueMap convertToParams(Object dto) { MultiValueMap params = new LinkedMultiValueMap<>(); JsonNode node = mapper.valueToTree(dto); - node.fields().forEachRemaining(entry -> { - String key = entry.getKey(); - JsonNode value = entry.getValue(); + node.forEachEntry((key, value) -> { if (!value.isNull()) { if (value.isArray()) { for (JsonNode item : value) @@ -90,6 +86,4 @@ public static MockHttpServletRequestBuilder deleteJson(String url, Object body) .contentType(MediaType.APPLICATION_JSON) .content(convertToJson(body)); } - - } diff --git a/src/test/java/com/backend/server/util/S3ApiUtil.java b/src/test/java/com/backend/server/util/S3ApiUtil.java new file mode 100644 index 00000000..98a8fd52 --- /dev/null +++ b/src/test/java/com/backend/server/util/S3ApiUtil.java @@ -0,0 +1,63 @@ +package com.backend.server.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.backend.server.config.S3Config.S3Properties; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; + +@Component +public class S3ApiUtil { + + @Autowired RestTemplate restTemplate; + @Autowired S3Client s3Client; + @Autowired S3Properties s3Properties; + + public void upload(String presignedUrl, String content) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(content.getBytes(StandardCharsets.UTF_8).length); + + ResponseEntity response = restTemplate.exchange( + URI.create(presignedUrl), + HttpMethod.PUT, + new HttpEntity<>(content, headers), + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + public String get(String fileKey) { + final String url = String.format("https://%s.s3.%s.amazonaws.com/%s", + s3Properties.getBucket(), s3Properties.getRegion(), fileKey); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + return response.getBody(); + } + + public void delete(String fileKey) { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(s3Properties.getBucket()) + .key(fileKey) + .build(); + s3Client.deleteObject(request); + } +}