From f6ca2323e2b8bc660a67ef68463440aa376234bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:53:38 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20s3=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 478be9f..eae24ee 100644 --- a/build.gradle +++ b/build.gradle @@ -77,6 +77,7 @@ dependencies { annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // aws s3 + implementation platform('software.amazon.awssdk:bom:2.25.35') implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1' } From 834f088f04043f1fd0efc3af94c354fc31044b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:54:37 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20s3=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + src/main/resources/application-ci.yml | 12 ++++++++++-- src/main/resources/application-prod.yml | 22 +++++++++++----------- src/main/resources/application.yml | 4 ++++ src/test/resources/application.yml | 11 +++++++++++ 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index bbe9056..b5834ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - SPRING_REDIS_PORT=6379 - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME} - AWS_S3_SECRET_ACCESS_KEY=${AWS_S3_SECRET_ACCESS_KEY} + - AWS_S3_ACCESS_KEY_ID=${AWS_S3_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} depends_on: - redis diff --git a/src/main/resources/application-ci.yml b/src/main/resources/application-ci.yml index 744ed0f..a8305dc 100644 --- a/src/main/resources/application-ci.yml +++ b/src/main/resources/application-ci.yml @@ -5,18 +5,26 @@ spring: mail: username: ${{SPRING_MAIL_USERNAME}} password: ${{SPRING_MAIL_PASSWORD}} - jpa: hibernate: ddl-auto: validate properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect - flyway: enabled: true locations: classpath:db/migration +aws: + s3: + bucket: ${AWS_S3_BUCKET_NAME} + credentials: + access-key: ${AWS_S3_ACCESS_KEY_ID} + secret-key: ${AWS_S3_SECRET_ACCESS_KEY} + region: + static: ap-northeast-2 + stack: + auto: false logging: level: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2d9f0aa..a30001a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -21,17 +21,17 @@ spring: mail: username: ${SPRING_MAIL_USERNAME} password: ${SPRING_MAIL_PASSWORD} - cloud: - aws: - s3: - bucket: ${AWS_S3_BUCKET_NAME} - credentials: - access-key: ${AWS_S3_ACCESS_KEY_ID} - secret-key: ${AWS_S3_SECRET_ACCESS_KEY} - region: - static: ap-northeast-2 - stack: - auto: false + +aws: + s3: + bucket: ${AWS_S3_BUCKET_NAME} + credentials: + access-key: ${AWS_S3_ACCESS_KEY_ID} + secret-key: ${AWS_S3_SECRET_ACCESS_KEY} + region: + static: ap-northeast-2 + stack: + auto: false logging: level: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b07b420..f22ce7f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,10 @@ spring: timeout: 5000 starttls: enable: true + servlet: + multipart: + max-file-size: 10MB + max-request-size: 50MB server: port: 8080 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 233d87f..965f652 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -28,6 +28,17 @@ spring: username: test@test.com password: testpassword +aws: + s3: + bucket: test-bucket + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 + stack: + auto: false + jwt: secret-key: test-secret-key-for-ci-cd-environment-only-do-not-use-in-production-1234567890 access-time: 3600000 From f295b09e413707d94c2362398e3aa48755c44577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:55:07 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=95=EA=B5=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ErrorCategory.java | 3 +- .../exception/GlobalExceptionHandler.java | 83 ++++++++++++++++++- .../common/exception/ImageErrorStatus.java | 21 +++++ 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/daramg/server/common/exception/ImageErrorStatus.java diff --git a/src/main/java/com/daramg/server/common/exception/ErrorCategory.java b/src/main/java/com/daramg/server/common/exception/ErrorCategory.java index dc4b57b..9614d5b 100644 --- a/src/main/java/com/daramg/server/common/exception/ErrorCategory.java +++ b/src/main/java/com/daramg/server/common/exception/ErrorCategory.java @@ -5,7 +5,8 @@ public enum ErrorCategory { COMMON("COMMON_"), AUTH("AUTH_"), USER("USER_"), - POST("POST_"); + POST("POST_"), + IMAGE("IMAGE_"); private final String prefix; diff --git a/src/main/java/com/daramg/server/common/exception/GlobalExceptionHandler.java b/src/main/java/com/daramg/server/common/exception/GlobalExceptionHandler.java index 0bba7be..827cddb 100644 --- a/src/main/java/com/daramg/server/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/daramg/server/common/exception/GlobalExceptionHandler.java @@ -7,9 +7,9 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @@ -19,7 +19,7 @@ @Slf4j @RequiredArgsConstructor -@RestControllerAdvice(annotations = {RestController.class}) +@RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private final ErrorCodeRegistry errorCodeRegistry; @@ -34,6 +34,59 @@ public ResponseEntity handleGeneralException(BusinessException e) .body(ErrorResponse.of(errorCode)); } + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException e) { + // AuthenticationException의 원인 예외에서 BusinessException 확인 + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof BusinessException businessException) { + BaseErrorCode errorCode = businessException.getErrorCode(); + log.warn("BusinessException (from AuthenticationException): {}", errorCode.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode)); + } + cause = cause.getCause(); + } + + // 기본 인증 실패 응답 + log.warn("AuthenticationException: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.of(CommonErrorStatus.UNAUTHORIZED)); + } + + + @Override + protected ResponseEntity handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) { + // 예외 자체가 BusinessException인지 확인 + if (ex instanceof BusinessException businessException) { + BaseErrorCode errorCode = businessException.getErrorCode(); + log.warn("BusinessException (from handleExceptionInternal, direct): {}", errorCode.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode)); + } + + // HandlerMethodArgumentResolver에서 발생한 BusinessException을 확인 + Throwable current = ex; + int depth = 0; + while (current != null && depth < 10) { + if (current instanceof BusinessException businessException) { + BaseErrorCode errorCode = businessException.getErrorCode(); + log.warn("BusinessException (from handleExceptionInternal, depth={}): {}", depth, errorCode.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode)); + } + current = current.getCause(); + depth++; + } + + log.warn("handleExceptionInternal: {} - {}", ex.getClass().getName(), ex.getMessage()); + return super.handleExceptionInternal(ex, body, headers, statusCode, request); + } + @Override protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) { log.warn("MethodArgumentNotValidException: {}", e.getMessage()); @@ -60,7 +113,31 @@ protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotV @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpectedException(Exception e) { - log.error("Unexpected Exception: ", e); + // 예외 자체가 BusinessException인지 확인 + if (e instanceof BusinessException businessException) { + BaseErrorCode errorCode = businessException.getErrorCode(); + log.warn("BusinessException (direct): {}", errorCode.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode)); + } + + // HandlerMethodArgumentResolver에서 발생한 BusinessException을 확인 + Throwable current = e; + int depth = 0; + while (current != null && depth < 10) { + if (current instanceof BusinessException businessException) { + BaseErrorCode errorCode = businessException.getErrorCode(); + log.warn("BusinessException (in chain, depth={}): {}", depth, errorCode.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ErrorResponse.of(errorCode)); + } + current = current.getCause(); + depth++; + } + + log.error("Unexpected Exception: {} - {}", e.getClass().getName(), e.getMessage(), e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ErrorResponse.of(CommonErrorStatus.INTERNAL_SERVER_ERROR)); diff --git a/src/main/java/com/daramg/server/common/exception/ImageErrorStatus.java b/src/main/java/com/daramg/server/common/exception/ImageErrorStatus.java new file mode 100644 index 0000000..e9b27db --- /dev/null +++ b/src/main/java/com/daramg/server/common/exception/ImageErrorStatus.java @@ -0,0 +1,21 @@ +package com.daramg.server.common.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ImageErrorStatus implements BaseErrorCode { + + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, ErrorCategory.IMAGE.generate(400), "지원하지 않는 이미지 형식입니다. (jpg, jpeg, png, gif만 지원)"), + FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, ErrorCategory.IMAGE.generate(401), "파일 크기가 너무 큽니다. (최대 10MB)"), + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCategory.IMAGE.generate(500), "이미지 업로드에 실패했습니다."), + EMPTY_FILE(HttpStatus.BAD_REQUEST, ErrorCategory.IMAGE.generate(402), "파일이 비어있습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} + From cc513928efba497c7d252e236a37c2b2f6fd2ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:55:44 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20s3=EC=97=90=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/application/S3ImageService.java | 105 ++++++++++++++++++ .../server/common/config/AppConfig.java | 23 ++++ .../common/dto/ImageUploadResponseDto.kt | 6 + .../common/presentation/ImageController.java | 41 +++++++ 4 files changed, 175 insertions(+) create mode 100644 src/main/java/com/daramg/server/common/application/S3ImageService.java create mode 100644 src/main/java/com/daramg/server/common/dto/ImageUploadResponseDto.kt create mode 100644 src/main/java/com/daramg/server/common/presentation/ImageController.java diff --git a/src/main/java/com/daramg/server/common/application/S3ImageService.java b/src/main/java/com/daramg/server/common/application/S3ImageService.java new file mode 100644 index 0000000..8f5e66c --- /dev/null +++ b/src/main/java/com/daramg/server/common/application/S3ImageService.java @@ -0,0 +1,105 @@ +package com.daramg.server.common.application; + +import com.daramg.server.common.exception.BusinessException; +import com.daramg.server.common.exception.ImageErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3ImageService { + + private static final List ALLOWED_CONTENT_TYPES = Arrays.asList( + "image/jpeg", "image/jpg", "image/png", "image/gif" + ); + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final String S3_URL_FORMAT = "https://%s.s3.%s.amazonaws.com/%s"; + + private final S3Client s3Client; + + @Value("${aws.s3.bucket}") + private String bucketName; + + @Value("${aws.region.static:ap-northeast-2}") + private String region; + + public List uploadImages(List images) { + List uploadedUrls = new ArrayList<>(); + + for (MultipartFile image : images) { + String url = uploadImage(image); + uploadedUrls.add(url); + } + + return uploadedUrls; + } + + public String uploadImage(MultipartFile file) { + validateFile(file); + + try { + String fileName = generateFileName(file.getOriginalFilename()); + String key = "images/" + fileName; + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + try (InputStream inputStream = file.getInputStream()) { + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); + } + + String imageUrl = String.format(S3_URL_FORMAT, bucketName, region, key); + log.info("Image uploaded successfully: {}", imageUrl); + return imageUrl; + + } catch (S3Exception e) { + log.error("Failed to upload image to S3", e); + throw new BusinessException(ImageErrorStatus.FILE_UPLOAD_FAILED); + } catch (IOException e) { + log.error("Failed to read file input stream", e); + throw new BusinessException(ImageErrorStatus.FILE_UPLOAD_FAILED); + } + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ImageErrorStatus.EMPTY_FILE); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new BusinessException(ImageErrorStatus.FILE_TOO_LARGE); + } + + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase())) { + throw new BusinessException(ImageErrorStatus.INVALID_FILE_TYPE); + } + } + + private String generateFileName(String originalFilename) { + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + return UUID.randomUUID() + extension; + } +} diff --git a/src/main/java/com/daramg/server/common/config/AppConfig.java b/src/main/java/com/daramg/server/common/config/AppConfig.java index de4d455..b1bbd23 100644 --- a/src/main/java/com/daramg/server/common/config/AppConfig.java +++ b/src/main/java/com/daramg/server/common/config/AppConfig.java @@ -2,12 +2,17 @@ import com.daramg.server.auth.resolver.AuthUserResolver; 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.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +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 java.util.List; @@ -17,11 +22,29 @@ public class AppConfig implements WebMvcConfigurer { private final AuthUserResolver authUserResolver; + @Value("${aws.credentials.access-key}") + private String accessKey; + + @Value("${aws.credentials.secret-key}") + private String secretKey; + + @Value("${aws.region.static:ap-northeast-2}") + private String region; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public S3Client s3Client() { + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } + @Override public void addArgumentResolvers(List resolvers) { resolvers.add(authUserResolver); diff --git a/src/main/java/com/daramg/server/common/dto/ImageUploadResponseDto.kt b/src/main/java/com/daramg/server/common/dto/ImageUploadResponseDto.kt new file mode 100644 index 0000000..6abacd1 --- /dev/null +++ b/src/main/java/com/daramg/server/common/dto/ImageUploadResponseDto.kt @@ -0,0 +1,6 @@ +package com.daramg.server.common.dto + +data class ImageUploadResponseDto( + val imageUrls: List +) + diff --git a/src/main/java/com/daramg/server/common/presentation/ImageController.java b/src/main/java/com/daramg/server/common/presentation/ImageController.java new file mode 100644 index 0000000..af8066a --- /dev/null +++ b/src/main/java/com/daramg/server/common/presentation/ImageController.java @@ -0,0 +1,41 @@ +package com.daramg.server.common.presentation; + +import com.daramg.server.common.application.S3ImageService; +import com.daramg.server.common.dto.ImageUploadResponseDto; +import com.daramg.server.common.exception.BusinessException; +import com.daramg.server.common.exception.ImageErrorStatus; +import com.daramg.server.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/images") +public class ImageController { + + private final S3ImageService s3ImageService; + + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ImageUploadResponseDto uploadImage( + @RequestPart("images") List images, + User user + ) { + log.info("이미지 업로드 요청 - User ID: {}, 이미지 개수: {}", user.getId(), images != null ? images.size() : 0); + + // 이미지가 비어있는지 체크 + if (images == null || images.isEmpty()) { + throw new BusinessException(ImageErrorStatus.EMPTY_FILE); + } + + List imageUrls = s3ImageService.uploadImages(images); + return new ImageUploadResponseDto(imageUrls); + } +} From 5ea3a923bc52b473c22137294b4bc78449dc029d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:56:10 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20s3=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/S3ImageServiceTest.java | 316 ++++++++++++++++++ .../presentation/ImageControllerTest.java | 93 ++++++ 2 files changed, 409 insertions(+) create mode 100644 src/test/java/com/daramg/server/common/application/S3ImageServiceTest.java create mode 100644 src/test/java/com/daramg/server/common/presentation/ImageControllerTest.java diff --git a/src/test/java/com/daramg/server/common/application/S3ImageServiceTest.java b/src/test/java/com/daramg/server/common/application/S3ImageServiceTest.java new file mode 100644 index 0000000..e6cc1ad --- /dev/null +++ b/src/test/java/com/daramg/server/common/application/S3ImageServiceTest.java @@ -0,0 +1,316 @@ +package com.daramg.server.common.application; + +import com.daramg.server.common.exception.BusinessException; +import com.daramg.server.common.exception.ImageErrorStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("S3ImageService 테스트") +public class S3ImageServiceTest { + + @Mock + private S3Client s3Client; + + @InjectMocks + private S3ImageService s3ImageService; + + private static final String BUCKET_NAME = "test-bucket"; + private static final String REGION = "ap-northeast-2"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(s3ImageService, "bucketName", BUCKET_NAME); + ReflectionTestUtils.setField(s3ImageService, "region", REGION); + } + + @Nested + @DisplayName("이미지 업로드 성공") + class UploadImageSuccess { + @Test + void JPEG_이미지를_업로드한다() throws Exception { + // given + MockMultipartFile file = new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // when + String imageUrl = s3ImageService.uploadImage(file); + + // then + assertThat(imageUrl).isNotNull(); + assertThat(imageUrl).startsWith("https://" + BUCKET_NAME + ".s3." + REGION + ".amazonaws.com/images/"); + assertThat(imageUrl).endsWith(".jpg"); + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void PNG_이미지를_업로드한다() throws Exception { + // given + MockMultipartFile file = new MockMultipartFile( + "image", + "test.png", + "image/png", + "test image content".getBytes() + ); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // when + String imageUrl = s3ImageService.uploadImage(file); + + // then + assertThat(imageUrl).isNotNull(); + assertThat(imageUrl).endsWith(".png"); + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void GIF_이미지를_업로드한다() throws Exception { + // given + MockMultipartFile file = new MockMultipartFile( + "image", + "test.gif", + "image/gif", + "test image content".getBytes() + ); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // when + String imageUrl = s3ImageService.uploadImage(file); + + // then + assertThat(imageUrl).isNotNull(); + assertThat(imageUrl).endsWith(".gif"); + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + } + + @Nested + @DisplayName("파일 검증 실패") + class FileValidationFail { + @Test + void 빈_파일은_업로드할_수_없다() { + // given + MockMultipartFile emptyFile = new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + new byte[0] + ); + + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(emptyFile)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.EMPTY_FILE.getCode()); + } + + @Test + void null_파일은_업로드할_수_없다() { + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(null)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.EMPTY_FILE.getCode()); + } + + @Test + void 파일_크기가_10MB를_초과하면_업로드할_수_없다() { + // given + byte[] largeContent = new byte[10 * 1024 * 1024 + 1]; // 10MB + 1 byte + MockMultipartFile largeFile = new MockMultipartFile( + "image", + "large.jpg", + "image/jpeg", + largeContent + ); + + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(largeFile)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.FILE_TOO_LARGE.getCode()); + } + + @Test + void 허용되지_않은_파일_형식은_업로드할_수_없다() { + // given + MockMultipartFile invalidFile = new MockMultipartFile( + "image", + "test.pdf", + "application/pdf", + "test content".getBytes() + ); + + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(invalidFile)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.INVALID_FILE_TYPE.getCode()); + } + + @Test + void ContentType이_null이면_업로드할_수_없다() { + // given + MockMultipartFile fileWithoutContentType = new MockMultipartFile( + "image", + "test.jpg", + null, + "test content".getBytes() + ); + + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(fileWithoutContentType)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.INVALID_FILE_TYPE.getCode()); + } + } + + @Nested + @DisplayName("S3 업로드 실패") + class S3UploadFail { + @Test + void S3_업로드_실패시_예외를_던진다() throws Exception { + // given + MockMultipartFile file = new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(software.amazon.awssdk.services.s3.model.S3Exception.builder() + .message("S3 upload failed") + .build()); + + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(file)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.FILE_UPLOAD_FAILED.getCode()); + } + + @Test + void IOException_발생시_예외를_던진다() throws Exception { + // given + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(1024L); + when(file.getContentType()).thenReturn("image/jpeg"); + when(file.getOriginalFilename()).thenReturn("test.jpg"); + when(file.getInputStream()).thenThrow(new IOException("IO error")); + + // when & then + assertThatThrownBy(() -> s3ImageService.uploadImage(file)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .extracting("code") + .isEqualTo(ImageErrorStatus.FILE_UPLOAD_FAILED.getCode()); + } + } + + @Nested + @DisplayName("여러 이미지 업로드") + class UploadImagesSuccess { + @Test + void 여러_이미지를_업로드한다() throws Exception { + // given + MockMultipartFile file1 = new MockMultipartFile( + "images", + "test1.jpg", + "image/jpeg", + "test image content 1".getBytes() + ); + MockMultipartFile file2 = new MockMultipartFile( + "images", + "test2.png", + "image/png", + "test image content 2".getBytes() + ); + MockMultipartFile file3 = new MockMultipartFile( + "images", + "test3.gif", + "image/gif", + "test image content 3".getBytes() + ); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // when + List imageUrls = s3ImageService.uploadImages(Arrays.asList(file1, file2, file3)); + + // then + assertThat(imageUrls).hasSize(3); + assertThat(imageUrls.get(0)).startsWith("https://" + BUCKET_NAME + ".s3." + REGION + ".amazonaws.com/images/"); + assertThat(imageUrls.get(0)).endsWith(".jpg"); + assertThat(imageUrls.get(1)).endsWith(".png"); + assertThat(imageUrls.get(2)).endsWith(".gif"); + verify(s3Client, times(3)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void 단일_이미지도_업로드할_수_있다() throws Exception { + // given + MockMultipartFile file = new MockMultipartFile( + "images", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // when + List imageUrls = s3ImageService.uploadImages(Arrays.asList(file)); + + // then + assertThat(imageUrls).hasSize(1); + assertThat(imageUrls.get(0)).startsWith("https://" + BUCKET_NAME + ".s3." + REGION + ".amazonaws.com/images/"); + assertThat(imageUrls.get(0)).endsWith(".jpg"); + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + } +} + diff --git a/src/test/java/com/daramg/server/common/presentation/ImageControllerTest.java b/src/test/java/com/daramg/server/common/presentation/ImageControllerTest.java new file mode 100644 index 0000000..36db89c --- /dev/null +++ b/src/test/java/com/daramg/server/common/presentation/ImageControllerTest.java @@ -0,0 +1,93 @@ +package com.daramg.server.common.presentation; + +import com.daramg.server.common.application.S3ImageService; +import com.daramg.server.testsupport.support.ControllerTestSupport; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Arrays; +import java.util.List; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ImageController.class) +public class ImageControllerTest extends ControllerTestSupport { + + @MockitoBean + private S3ImageService s3ImageService; + + @Test + void 이미지를_업로드한다() throws Exception { + // given + MockMultipartFile imageFile1 = new MockMultipartFile( + "images", + "test-image1.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test image content 1".getBytes() + ); + MockMultipartFile imageFile2 = new MockMultipartFile( + "images", + "test-image2.png", + MediaType.IMAGE_PNG_VALUE, + "test image content 2".getBytes() + ); + + List expectedImageUrls = Arrays.asList( + "https://bucket-name.s3.ap-northeast-2.amazonaws.com/images/uuid1.jpg", + "https://bucket-name.s3.ap-northeast-2.amazonaws.com/images/uuid2.png" + ); + when(s3ImageService.uploadImages(any())).thenReturn(expectedImageUrls); + + // when + ResultActions result = mockMvc.perform( + multipart("/images/upload") + .file(imageFile1) + .file(imageFile2) + .contentType(MediaType.MULTIPART_FORM_DATA) + ); + + // then + result.andExpect(status().isCreated()) + .andExpect(jsonPath("$.imageUrls[0]").value(expectedImageUrls.get(0))) + .andExpect(jsonPath("$.imageUrls[1]").value(expectedImageUrls.get(1))) + .andDo(restDocsHandler.document( + resource(ResourceSnippetParameters.builder() + .tag("Image API") + .summary("이미지 업로드") + .description("여러 이미지 파일을 S3에 업로드하고 영구적인 URL 목록을 반환합니다. " + + "업로드된 이미지는 UUID 기반의 고유한 파일명으로 저장되며, " + + "반환된 URL을 통해 영구적으로 접근할 수 있습니다.") + .responseFields( + fieldWithPath("imageUrls").type(JsonFieldType.ARRAY) + .description("업로드된 이미지들의 S3 URL 목록 (형식: https://{bucket}.s3.{region}.amazonaws.com/images/{uuid}.{extension})") + ) + .build() + ), + requestParts( + partWithName("images").description("업로드할 이미지 파일들 (여러 개 가능)\n\n" + + "**제약조건:**\n" + + "- 지원 형식: JPEG, JPG, PNG, GIF (Content-Type: image/jpeg, image/jpg, image/png, image/gif)\n" + + "- 최대 파일 크기: 파일당 10MB\n" + + "- 최대 요청 크기: 50MB (여러 파일 합계)\n" + + "- 빈 파일 리스트는 업로드할 수 없습니다\n\n" + + "**참고:**\n" + + "- 파일명은 UUID로 자동 생성되어 원본 파일명과 무관하게 저장됩니다\n" + + "- 업로드된 이미지는 `images/` 디렉토리 하위에 저장됩니다") + ) + )); + } +} From 62b7ed9306a4f2f40b3c6b6411e5718361c23fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:56:43 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=93=B1=EB=A1=9D=20s3=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/application/AuthService.java | 14 ++++++++++++-- .../com/daramg/server/auth/dto/SignupRequestDto.kt | 7 ------- .../server/auth/presentation/AuthController.java | 11 ++++++++--- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/daramg/server/auth/application/AuthService.java b/src/main/java/com/daramg/server/auth/application/AuthService.java index 1a88940..af86e52 100644 --- a/src/main/java/com/daramg/server/auth/application/AuthService.java +++ b/src/main/java/com/daramg/server/auth/application/AuthService.java @@ -3,6 +3,7 @@ import com.daramg.server.auth.dto.TokenResponseDto; import com.daramg.server.auth.exception.AuthErrorStatus; import com.daramg.server.auth.util.JwtUtil; +import com.daramg.server.common.application.S3ImageService; import com.daramg.server.common.exception.BusinessException; import com.daramg.server.user.domain.User; import com.daramg.server.auth.domain.SignupVo; @@ -17,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.concurrent.TimeUnit; @@ -32,8 +34,9 @@ public class AuthService { private final JwtUtil jwtTokenProvider; private final PasswordEncoder passwordEncoder; private final RedisTemplate redisTemplate; + private final S3ImageService s3ImageService; - public void signup(SignupRequestDto dto){ + public void signup(SignupRequestDto dto, MultipartFile image){ if (userRepository.existsByEmail(dto.getEmail())) { throw new BusinessException("중복된 이메일입니다."); } @@ -42,12 +45,19 @@ public void signup(SignupRequestDto dto){ } // TODO: bio, 닉네임에 금칙어 검사 String encodedPassword = passwordEncoder.encode(dto.getPassword()); + + // 이미지가 있으면 S3에 업로드하고 URL을 받아옴, 없으면 null (기본 이미지 사용) + String profileImageUrl = null; + if (image != null && !image.isEmpty()) { + profileImageUrl = s3ImageService.uploadImage(image); + } + SignupVo vo = new SignupVo( dto.getName(), dto.getBirthdate(), dto.getEmail(), encodedPassword, - dto.getProfileImage(), + profileImageUrl, dto.getNickname(), dto.getBio() ); diff --git a/src/main/java/com/daramg/server/auth/dto/SignupRequestDto.kt b/src/main/java/com/daramg/server/auth/dto/SignupRequestDto.kt index 8fa1f99..68ab8d1 100644 --- a/src/main/java/com/daramg/server/auth/dto/SignupRequestDto.kt +++ b/src/main/java/com/daramg/server/auth/dto/SignupRequestDto.kt @@ -20,13 +20,6 @@ class SignupRequestDto( ) val password: String, - @get:Pattern( - regexp = "^(https?://).+\\.(jpg|jpeg|png|gif|svg)$", - flags = [Pattern.Flag.CASE_INSENSITIVE], - message = "올바른 이미지 URL 형식이 아닙니다" - ) - val profileImage: String?, - @get:NotBlank(message = "닉네임은 필수입니다") @get:Pattern( regexp = "^[a-zA-Z0-9가-힣._]{2,8}$", diff --git a/src/main/java/com/daramg/server/auth/presentation/AuthController.java b/src/main/java/com/daramg/server/auth/presentation/AuthController.java index 9c413c2..e5eacb7 100644 --- a/src/main/java/com/daramg/server/auth/presentation/AuthController.java +++ b/src/main/java/com/daramg/server/auth/presentation/AuthController.java @@ -15,8 +15,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/auth") @@ -50,10 +52,13 @@ public void verify(@RequestBody @Valid CodeVerificationRequestDto request) { mailVerificationService.verifyEmailWithCode(request); } - @PostMapping("/signup") + @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) - public void signup(@Valid @RequestBody SignupRequestDto request) { - authService.signup(request); + public void signup( + @RequestPart("signupRequest") @Valid SignupRequestDto request, + @RequestPart(value = "image", required = false) MultipartFile image + ) { + authService.signup(request, image); } @PostMapping("/login") From 25243b407730e237c45b61077635d66fe81f23e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 01:57:00 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=93=B1=EB=A1=9D=20s3=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuthServicePasswordEncodingTest.java | 3 +- .../auth/application/AuthServiceTest.java | 18 +++--- .../daramg/server/auth/domain/AuthTest.java | 10 ---- .../auth/presentation/AuthControllerTest.java | 55 ++++++++++++++----- 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/test/java/com/daramg/server/auth/application/AuthServicePasswordEncodingTest.java b/src/test/java/com/daramg/server/auth/application/AuthServicePasswordEncodingTest.java index 4eda7d5..c1d5a0d 100644 --- a/src/test/java/com/daramg/server/auth/application/AuthServicePasswordEncodingTest.java +++ b/src/test/java/com/daramg/server/auth/application/AuthServicePasswordEncodingTest.java @@ -49,7 +49,6 @@ void signup_encodesPassword() { LocalDate.of(1990, 1, 1), "user@example.com", rawPassword, - null, "nickname", "hello" ); @@ -57,7 +56,7 @@ void signup_encodesPassword() { ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); // when - authService.signup(dto); + authService.signup(dto, null); // then verify(userRepository).save(userCaptor.capture()); diff --git a/src/test/java/com/daramg/server/auth/application/AuthServiceTest.java b/src/test/java/com/daramg/server/auth/application/AuthServiceTest.java index eb851bc..428852d 100644 --- a/src/test/java/com/daramg/server/auth/application/AuthServiceTest.java +++ b/src/test/java/com/daramg/server/auth/application/AuthServiceTest.java @@ -60,13 +60,12 @@ class SignupSuccess { LocalDate.of(1990, 1, 1), "test@example.com", "Password123!", - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); //when - authService.signup(signupDto); + authService.signup(signupDto, null); //then User savedUser = userRepository.findAll().get(1); @@ -75,7 +74,8 @@ class SignupSuccess { assertThat(savedUser.getBirthDate()).isEqualTo(signupDto.getBirthdate()); assertThat(savedUser.getEmail()).isEqualTo(signupDto.getEmail()); // 비밀번호 암호화 정책으로 인해 원문과 같지 않을 수 있음 (기존 테스트 유지) - assertThat(savedUser.getProfileImage()).isEqualTo(signupDto.getProfileImage()); + // 이미지가 없으면 기본 이미지가 사용됨 + assertThat(savedUser.getProfileImage()).isNotNull(); assertThat(savedUser.getNickname()).isEqualTo(signupDto.getNickname()); assertThat(savedUser.getBio()).isEqualTo(signupDto.getBio()); } @@ -133,13 +133,12 @@ void setUp() { LocalDate.of(1990, 1, 1), "existing@example.com", // 중복된 이메일 "Password123!", - "https://example.com/profile.jpg", "새로운닉네임", "안녕하세요" ); //when & then - assertThatThrownBy(() -> authService.signup(signupDto)) + assertThatThrownBy(() -> authService.signup(signupDto, null)) .isInstanceOf(BusinessException.class) .hasMessage("중복된 이메일입니다."); } @@ -152,13 +151,12 @@ void setUp() { LocalDate.of(1990, 1, 1), "new@example.com", "Password123!", - "https://example.com/profile.jpg", "기존닉네임", // 중복된 닉네임 "안녕하세요" ); //when & then - assertThatThrownBy(() -> authService.signup(signupDto)) + assertThatThrownBy(() -> authService.signup(signupDto, null)) .isInstanceOf(BusinessException.class) .hasMessage("중복된 닉네임입니다."); } @@ -171,13 +169,12 @@ void setUp() { LocalDate.of(1990, 1, 1), "existing@example.com", // 중복된 이메일 "Password123!", - "https://example.com/profile.jpg", "기존닉네임", // 중복된 닉네임 "안녕하세요" ); //when & then - assertThatThrownBy(() -> authService.signup(signupDto)) + assertThatThrownBy(() -> authService.signup(signupDto, null)) .isInstanceOf(BusinessException.class) .hasMessage("중복된 이메일입니다."); } @@ -303,13 +300,12 @@ void signup_passwordIsEncodedAndMatches() { LocalDate.of(1995, 5, 5), "encode@example.com", "Encode123!", - null, "encUser", "bio" ); // when - authService.signup(dto); + authService.signup(dto, null); User saved = userRepository.findByEmail("encode@example.com").orElseThrow(); // then diff --git a/src/test/java/com/daramg/server/auth/domain/AuthTest.java b/src/test/java/com/daramg/server/auth/domain/AuthTest.java index 1e99a9e..c00dfdc 100644 --- a/src/test/java/com/daramg/server/auth/domain/AuthTest.java +++ b/src/test/java/com/daramg/server/auth/domain/AuthTest.java @@ -36,7 +36,6 @@ class SignupDtoEmailValidationTest { LocalDate.of(1990, 1, 1), "", // 빈 이메일 "Password123!", - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -58,7 +57,6 @@ class SignupDtoEmailValidationTest { LocalDate.of(1990, 1, 1), "invalid-email", // 잘못된 이메일 형식 "Password123!", - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -80,7 +78,6 @@ class SignupDtoEmailValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", // 올바른 이메일 형식 "Password123!", - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -106,7 +103,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "", // 빈 비밀번호 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -128,7 +124,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "Pass123!", // 9자리 비밀번호 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -150,7 +145,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "password123!", // 대문자 없음 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -172,7 +166,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "PASSWORD123!", // 소문자 없음 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -194,7 +187,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "Password!", // 숫자 없음 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -216,7 +208,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "Password123", // 특수문자 없음 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); @@ -238,7 +229,6 @@ class SignupDtoPasswordValidationTest { LocalDate.of(1990, 1, 1), "test@example.com", "Password123!", // 올바른 비밀번호 형식 - "https://example.com/profile.jpg", "홍길동123", "안녕하세요" ); diff --git a/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java b/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java index a30aa39..16290ed 100644 --- a/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java +++ b/src/test/java/com/daramg/server/auth/presentation/AuthControllerTest.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; @@ -25,10 +26,13 @@ import static org.mockito.Mockito.when; import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; import static org.springframework.restdocs.cookies.CookieDocumentation.responseCookies; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -141,17 +145,31 @@ public class AuthControllerTest extends ControllerTestSupport { LocalDate.of(1996, 6, 15), "hamster@gmail.com", "Password123!", - "https://example.com/profile.jpg", "햄쥑이", "나라 지키는 중" ); - doNothing().when(authService).signup(any(SignupRequestDto.class)); + MockMultipartFile signupRequestPart = new MockMultipartFile( + "signupRequest", + "", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ); + + MockMultipartFile imageFile = new MockMultipartFile( + "image", + "test-image.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test image content".getBytes() + ); + + doNothing().when(authService).signup(any(SignupRequestDto.class), any()); // when - ResultActions result = mockMvc.perform(post("/auth/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions result = mockMvc.perform(multipart("/auth/signup") + .file(signupRequestPart) + .file(imageFile) + .contentType(MediaType.MULTIPART_FORM_DATA)); // then result.andExpect(status().isCreated()) @@ -159,17 +177,24 @@ public class AuthControllerTest extends ControllerTestSupport { resource(ResourceSnippetParameters.builder() .tag("Auth API") .summary("회원가입") - .description("새로운 유저를 생성합니다.") - .requestFields( - fieldWithPath("name").type(JsonFieldType.STRING).description("이름"), - fieldWithPath("birthdate").type(JsonFieldType.STRING).description("생년월일 (YYYY-MM-DD)"), - fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), - fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호 (영어 대/소문자, 숫자, 특수문자 포함 10자 이상)"), - fieldWithPath("profileImage").type(JsonFieldType.STRING).description("프로필 이미지 URL").optional(), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("닉네임 (2~8자)"), - fieldWithPath("bio").type(JsonFieldType.STRING).description("bio (12자 이하)").optional() - ) + .description("새로운 유저를 생성합니다. 프로필 이미지는 선택사항이며, 제공하지 않으면 기본 이미지가 사용됩니다.") .build() + ), + requestParts( + partWithName("signupRequest").description("회원가입 정보 (JSON)\n\n" + + "**Content-Type:** application/json\n\n" + + "**JSON 필드:**\n" + + "- `name` (String, 필수): 이름\n" + + "- `birthdate` (String, 필수): 생년월일 (YYYY-MM-DD)\n" + + "- `email` (String, 필수): 이메일\n" + + "- `password` (String, 필수): 비밀번호 (영어 대/소문자, 숫자, 특수문자 포함 10자 이상)\n" + + "- `nickname` (String, 필수): 닉네임 (2~8자)\n" + + "- `bio` (String, 선택): bio (12자 이하)"), + partWithName("image").description("프로필 이미지 파일 (선택사항)\n\n" + + "**제약조건:**\n" + + "- 지원 형식: JPEG, JPG, PNG, GIF\n" + + "- 최대 파일 크기: 10MB\n" + + "- 제공하지 않으면 기본 이미지가 사용됩니다").optional() ) )); } From cc634342ebfffc2c0c1a76235ba8333be9d38789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=96=91=ED=9A=A8=EB=B9=88?= Date: Tue, 6 Jan 2026 02:28:15 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=ED=95=98=EC=97=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/common/application/S3ImageService.java | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/daramg/server/common/application/S3ImageService.java b/src/main/java/com/daramg/server/common/application/S3ImageService.java index 8f5e66c..42e24f9 100644 --- a/src/main/java/com/daramg/server/common/application/S3ImageService.java +++ b/src/main/java/com/daramg/server/common/application/S3ImageService.java @@ -14,8 +14,6 @@ import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -24,7 +22,7 @@ @RequiredArgsConstructor public class S3ImageService { - private static final List ALLOWED_CONTENT_TYPES = Arrays.asList( + private static final java.util.Set ALLOWED_CONTENT_TYPES = java.util.Set.of( "image/jpeg", "image/jpg", "image/png", "image/gif" ); private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -39,14 +37,9 @@ public class S3ImageService { private String region; public List uploadImages(List images) { - List uploadedUrls = new ArrayList<>(); - - for (MultipartFile image : images) { - String url = uploadImage(image); - uploadedUrls.add(url); - } - - return uploadedUrls; + return images.stream() + .map(this::uploadImage) + .toList(); } public String uploadImage(MultipartFile file) {