From 57d24d253bc8fae52720828f0ba1f703f1aaf4a9 Mon Sep 17 00:00:00 2001 From: kws0315 Date: Fri, 19 Jun 2026 17:19:40 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20TIL=EC=97=90=EC=84=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20S3?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../domain/til/controller/TilController.java | 26 +++++-- .../domain/til/dto/response/TilResponse.java | 3 + .../com/Rootin/domain/til/entity/Til.java | 31 +++++++-- .../Rootin/domain/til/service/TilService.java | 41 ++++++++++-- .../Rootin/global/config/MetricsConfig.java | 19 ++++++ .../global/exception/CustomException.java | 5 ++ .../java/com/Rootin/global/s3/S3Config.java | 30 +++++++++ .../java/com/Rootin/global/s3/S3Service.java | 32 +++++++++ .../domain/til/service/TilServiceTest.java | 67 ++++++++++++++++++- .../com/Rootin/global/s3/S3ServiceTest.java | 55 +++++++++++++-- 11 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/Rootin/global/config/MetricsConfig.java diff --git a/build.gradle b/build.gradle index 1f6eb02..390c3af 100644 --- a/build.gradle +++ b/build.gradle @@ -69,7 +69,8 @@ dependencies { testImplementation 'org.testcontainers:mysql' // Metrics Collection (Prometheus) - implementation 'io.micrometer:micrometer-registry-prometheus' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + implementation 'io.github.mweirauch:micrometer-jvm-extras:0.2.2' } // ─── 테스트용 MySQL 컨테이너 생명주기 태스크 ───────────────────────────────── diff --git a/src/main/java/com/Rootin/domain/til/controller/TilController.java b/src/main/java/com/Rootin/domain/til/controller/TilController.java index 19e4895..5b02f7b 100644 --- a/src/main/java/com/Rootin/domain/til/controller/TilController.java +++ b/src/main/java/com/Rootin/domain/til/controller/TilController.java @@ -1,3 +1,4 @@ +// TIL 컨트롤러: TIL 작성·수정·삭제·임시저장 REST API 처리 (작성·임시저장은 multipart/form-data로 이미지 업로드 지원) package com.Rootin.domain.til.controller; import com.Rootin.domain.til.dto.request.DraftSaveRequest; @@ -11,9 +12,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/v1/tils") @@ -22,12 +25,22 @@ public class TilController { private final TilService tilService; - @PostMapping + /** + * TIL 작성 (POST /api/v1/tils) + * + * multipart/form-data 요청: + * - data (application/json, required): TIL 본문 JSON + * - image (image/*, optional) : 썸네일 이미지 파일 → S3 업로드 후 thumbnailUrl 저장 + * + * 이미지 없이 JSON만 전송하는 경우에도 정상 동작합니다. + */ + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity> create( @AuthenticationPrincipal JwtUserDetails userDetails, - @Valid @RequestBody TilCreateRequest request + @RequestPart("data") @Valid TilCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile thumbnailImage ) { - TilResponse response = tilService.create(userDetails.getUserId(), request); + TilResponse response = tilService.create(userDetails.getUserId(), request, thumbnailImage); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); } @@ -71,12 +84,13 @@ public ResponseEntity delete( return ResponseEntity.noContent().build(); } - @PostMapping("/draft") + @PostMapping(value = "/draft", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity> saveDraft( @AuthenticationPrincipal JwtUserDetails userDetails, - @Valid @RequestBody DraftSaveRequest request + @RequestPart("data") @Valid DraftSaveRequest request, + @RequestPart(value = "image", required = false) MultipartFile thumbnailImage ) { - return ResponseEntity.ok(ApiResponse.success(tilService.saveDraft(userDetails.getUserId(), request))); + return ResponseEntity.ok(ApiResponse.success(tilService.saveDraft(userDetails.getUserId(), request, thumbnailImage))); } @GetMapping("/draft") diff --git a/src/main/java/com/Rootin/domain/til/dto/response/TilResponse.java b/src/main/java/com/Rootin/domain/til/dto/response/TilResponse.java index c9190ef..70b87b0 100644 --- a/src/main/java/com/Rootin/domain/til/dto/response/TilResponse.java +++ b/src/main/java/com/Rootin/domain/til/dto/response/TilResponse.java @@ -1,3 +1,4 @@ +// TIL 응답 DTO: TIL 조회·작성 시 클라이언트로 반환하는 데이터 구조 (thumbnailUrl 필드 추가) package com.Rootin.domain.til.dto.response; import com.Rootin.domain.til.entity.Til; @@ -9,6 +10,7 @@ public record TilResponse( Long tilId, String title, String content, + String thumbnailUrl, List tags, AuthorInfo author, Long potId, @@ -33,6 +35,7 @@ public static TilResponse from(Til til) { til.getId(), til.getTitle(), til.getContent(), + til.getThumbnailUrl(), tags, author, til.getPot().getId(), diff --git a/src/main/java/com/Rootin/domain/til/entity/Til.java b/src/main/java/com/Rootin/domain/til/entity/Til.java index 2fe02f1..e26da46 100644 --- a/src/main/java/com/Rootin/domain/til/entity/Til.java +++ b/src/main/java/com/Rootin/domain/til/entity/Til.java @@ -1,3 +1,4 @@ +// TIL 엔티티: S3에 업로드된 썸네일 이미지 URL(thumbnailUrl)을 저장하는 컬럼과 팩토리 메서드를 포함한다 package com.Rootin.domain.til.entity; import com.Rootin.domain.garden.entity.Pot; @@ -23,26 +24,48 @@ public class Til extends Post { private LocalDateTime publishedAt; + /** TIL 작성 시 업로드된 썸네일 이미지의 S3 URL */ + @Column(name = "thumbnail_url") + private String thumbnailUrl; + @OneToMany(mappedBy = "til", cascade = CascadeType.ALL, orphanRemoval = true) private List tilTags = new ArrayList<>(); - protected Til(User user, String title, String content, Pot pot) { + protected Til(User user, String title, String content, Pot pot, String thumbnailUrl) { super(user, title, content, PostStatus.PUBLISHED); this.pot = pot; this.publishedAt = LocalDateTime.now(); + this.thumbnailUrl = thumbnailUrl; } - protected Til(User user, String title, String content, Pot pot, PostStatus status) { + protected Til(User user, String title, String content, Pot pot, PostStatus status, String thumbnailUrl) { super(user, title, content, status); this.pot = pot; + this.thumbnailUrl = thumbnailUrl; } + /** 썸네일 없이 TIL 생성 (하위 호환용) */ public static Til create(User user, String title, String content, Pot pot) { - return new Til(user, title, content, pot); + return new Til(user, title, content, pot, null); + } + + /** 썸네일 포함 TIL 생성 */ + public static Til create(User user, String title, String content, Pot pot, String thumbnailUrl) { + return new Til(user, title, content, pot, thumbnailUrl); } + /** 썸네일 없이 임시저장 생성 (하위 호환용) */ public static Til createDraft(User user, String title, String content, Pot pot) { - return new Til(user, title, content, pot, PostStatus.DRAFT); + return new Til(user, title, content, pot, PostStatus.DRAFT, null); + } + + /** 썸네일 포함 임시저장 생성 */ + public static Til createDraft(User user, String title, String content, Pot pot, String thumbnailUrl) { + return new Til(user, title, content, pot, PostStatus.DRAFT, thumbnailUrl); + } + + public void updateThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; } } diff --git a/src/main/java/com/Rootin/domain/til/service/TilService.java b/src/main/java/com/Rootin/domain/til/service/TilService.java index aa2212a..5685f9a 100644 --- a/src/main/java/com/Rootin/domain/til/service/TilService.java +++ b/src/main/java/com/Rootin/domain/til/service/TilService.java @@ -1,3 +1,4 @@ +// TIL 비즈니스 로직: TIL 작성 시 썸네일 이미지를 S3에 업로드하고 URL을 저장하는 흐름을 담당한다 package com.Rootin.domain.til.service; import com.Rootin.domain.ai.repository.AiResultTilRepository; @@ -15,6 +16,7 @@ import com.Rootin.domain.user.entity.User; import com.Rootin.domain.user.repository.UserRepository; import com.Rootin.global.exception.CustomException; +import com.Rootin.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -22,8 +24,10 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -36,9 +40,10 @@ public class TilService { private final ExperienceService experienceService; private final AiResultTilRepository aiResultTilRepository; private final WateringLogRepository wateringLogRepository; + private final S3Service s3Service; @Transactional - public TilResponse create(Long userId, TilCreateRequest request) { + public TilResponse create(Long userId, TilCreateRequest request, MultipartFile thumbnailImage) { User user = userRepository.findById(userId) .orElseThrow(() -> CustomException.notFound("사용자를 찾을 수 없습니다.")); @@ -49,9 +54,12 @@ public TilResponse create(Long userId, TilCreateRequest request) { .orElseThrow(() -> CustomException.notFound("화분을 찾을 수 없습니다.")); validatePotOwner(pot, userId); + // 썸네일 이미지가 첨부된 경우 S3에 업로드하고 URL을 저장합니다. + String thumbnailUrl = uploadThumbnailIfPresent(thumbnailImage, userId, request.potId()); + // Til.create()는 PUBLISHED 상태의 TIL을 만드는 도메인 생성 메서드입니다. // 저장 후 til.getId()가 필요하므로 먼저 save()를 호출한 뒤 물주기 로직을 실행합니다. - Til til = Til.create(user, request.title(), request.content(), pot); + Til til = Til.create(user, request.title(), request.content(), pot, thumbnailUrl); tilRepository.save(til); syncTags(til, request.tags()); @@ -114,21 +122,27 @@ public void delete(Long tilId, Long userId) { } @Transactional - public TilResponse saveDraft(Long userId, DraftSaveRequest request) { + public TilResponse saveDraft(Long userId, DraftSaveRequest request, MultipartFile thumbnailImage) { User user = userRepository.findById(userId) .orElseThrow(() -> CustomException.notFound("사용자를 찾을 수 없습니다.")); Pot pot = potRepository.findById(request.potId()) .orElseThrow(() -> CustomException.notFound("화분을 찾을 수 없습니다.")); validatePotOwner(pot, userId); + // 썸네일 이미지가 첨부된 경우 S3에 업로드하고 URL을 저장합니다. + String thumbnailUrl = uploadThumbnailIfPresent(thumbnailImage, userId, request.potId()); + // 임시저장은 DRAFT 상태이므로 경험치/물주기를 발생시키지 않습니다. // 이미 같은 화분에 임시저장 글이 있으면 새로 만들지 않고 기존 글을 갱신합니다. Til til = tilRepository.findFirstByUserIdAndPotIdAndStatus(userId, request.potId(), PostStatus.DRAFT) .map(existing -> { existing.update(request.title(), request.content()); + if (thumbnailUrl != null) { + existing.updateThumbnailUrl(thumbnailUrl); + } return existing; }) - .orElseGet(() -> tilRepository.save(Til.createDraft(user, request.title(), request.content(), pot))); + .orElseGet(() -> tilRepository.save(Til.createDraft(user, request.title(), request.content(), pot, thumbnailUrl))); syncTags(til, request.tags()); @@ -152,6 +166,25 @@ public void deleteDraft(Long userId, Long potId) { tilRepository.delete(til); } + /** + * 썸네일 이미지가 존재하면 S3에 업로드하고 URL을 반환합니다. + * 이미지가 없거나 비어있으면 null을 반환합니다. + */ + private String uploadThumbnailIfPresent(MultipartFile image, Long userId, Long potId) { + if (image == null || image.isEmpty()) { + return null; + } + String contentType = image.getContentType(); + String ext = switch (contentType != null ? contentType : "") { + case "image/png" -> "png"; + case "image/jpeg" -> "jpg"; + case "image/webp" -> "webp"; + default -> throw CustomException.badRequest("지원하지 않는 이미지 형식입니다: " + contentType); + }; + String objectKey = String.format("til-images/%d/%d/%s.%s", userId, potId, UUID.randomUUID(), ext); + return s3Service.uploadFile(image, objectKey); + } + private Til getTilOrThrow(Long tilId) { return tilRepository.findById(tilId) .orElseThrow(() -> CustomException.notFound("TIL을 찾을 수 없습니다.")); diff --git a/src/main/java/com/Rootin/global/config/MetricsConfig.java b/src/main/java/com/Rootin/global/config/MetricsConfig.java new file mode 100644 index 0000000..bd7bc05 --- /dev/null +++ b/src/main/java/com/Rootin/global/config/MetricsConfig.java @@ -0,0 +1,19 @@ +package com.Rootin.global.config; + +import io.github.mweirauch.micrometer.jvm.extras.ProcessMemoryMetrics; +import io.github.mweirauch.micrometer.jvm.extras.ProcessThreadMetrics; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MetricsConfig { + @Bean + public ProcessMemoryMetrics processMemoryMetrics() { + return new ProcessMemoryMetrics(); + } + + @Bean + public ProcessThreadMetrics processThreadMetrics() { + return new ProcessThreadMetrics(); + } +} diff --git a/src/main/java/com/Rootin/global/exception/CustomException.java b/src/main/java/com/Rootin/global/exception/CustomException.java index ee7d39c..707b8c2 100644 --- a/src/main/java/com/Rootin/global/exception/CustomException.java +++ b/src/main/java/com/Rootin/global/exception/CustomException.java @@ -1,3 +1,4 @@ +// 공통 예외 클래스: HTTP 상태코드와 메시지를 함께 담아 던지는 RuntimeException (internalServerError 팩토리 추가) package com.Rootin.global.exception; import lombok.Getter; @@ -51,4 +52,8 @@ public static CustomException badRequest(String message) { public static CustomException paymentRequired(String message) { return new CustomException(HttpStatus.PAYMENT_REQUIRED, message); } + + public static CustomException internalServerError(String message) { + return new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, message); + } } diff --git a/src/main/java/com/Rootin/global/s3/S3Config.java b/src/main/java/com/Rootin/global/s3/S3Config.java index 5a3712a..e7d67ec 100644 --- a/src/main/java/com/Rootin/global/s3/S3Config.java +++ b/src/main/java/com/Rootin/global/s3/S3Config.java @@ -1,12 +1,16 @@ +// S3 연결 설정: Presigned URL 발급용 S3Presigner와 서버 직접 업로드용 S3Client 빈을 등록한다 package com.Rootin.global.s3; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; 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.S3ClientBuilder; import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -47,4 +51,30 @@ public S3Presigner s3Presigner(S3Properties props) { return builder.build(); } + + @Bean + public S3Client s3Client(S3Properties props) { + String region = props.getRegion(); + String accessKey = props.getCredentials().getAccessKey(); + String secretKey = props.getCredentials().getSecretKey(); + String endpoint = props.getS3().getEndpoint(); + + AwsCredentialsProvider credentialsProvider = + (accessKey != null && !accessKey.isBlank() && secretKey != null && !secretKey.isBlank()) + ? StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) + : DefaultCredentialsProvider.create(); + + S3ClientBuilder builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(credentialsProvider); + + if (endpoint != null && !endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()); + } + + return builder.build(); + } } \ No newline at end of file diff --git a/src/main/java/com/Rootin/global/s3/S3Service.java b/src/main/java/com/Rootin/global/s3/S3Service.java index 070e0a1..fb75711 100644 --- a/src/main/java/com/Rootin/global/s3/S3Service.java +++ b/src/main/java/com/Rootin/global/s3/S3Service.java @@ -1,12 +1,18 @@ +// S3 업로드 서비스: Presigned PUT URL 생성, MultipartFile 직접 업로드, 공개 URL 반환 기능을 제공한다 package com.Rootin.global.s3; +import com.Rootin.global.exception.CustomException; import lombok.RequiredArgsConstructor; 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.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import java.io.IOException; import java.time.Duration; @Service @@ -14,6 +20,7 @@ public class S3Service { private final S3Presigner s3Presigner; + private final S3Client s3Client; private final S3Properties s3Properties; public String generatePresignedPutUrl(String objectKey, String contentType) { @@ -32,6 +39,31 @@ public String generatePresignedPutUrl(String objectKey, String contentType) { return presignedRequest.url().toString(); } + /** + * MultipartFile을 S3에 직접 업로드하고 접근 가능한 URL을 반환합니다. + * + * @param file 업로드할 파일 + * @param objectKey S3 저장 경로 (예: "til-images/1/2/uuid.jpg") + * @return 업로드된 파일의 공개 URL + */ + public String uploadFile(MultipartFile file, String objectKey) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Properties.getS3().getBucket()) + .key(objectKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + s3Client.putObject(putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return getFileUrl(objectKey); + } catch (IOException e) { + throw CustomException.internalServerError("S3 업로드 중 오류가 발생했습니다."); + } + } + public String getFileUrl(String objectKey) { String endpoint = s3Properties.getS3().getEndpoint(); String bucket = s3Properties.getS3().getBucket(); diff --git a/src/test/java/com/Rootin/domain/til/service/TilServiceTest.java b/src/test/java/com/Rootin/domain/til/service/TilServiceTest.java index 0f50c6e..c8d92e2 100644 --- a/src/test/java/com/Rootin/domain/til/service/TilServiceTest.java +++ b/src/test/java/com/Rootin/domain/til/service/TilServiceTest.java @@ -1,3 +1,4 @@ +// TilService 단위 테스트: TIL 작성 시 이미지 유무에 따른 S3 업로드 호출 여부와 삭제 순서를 검증한다 package com.Rootin.domain.til.service; import com.Rootin.domain.ai.repository.AiResultTilRepository; @@ -5,12 +6,16 @@ import com.Rootin.domain.garden.repository.PotRepository; import com.Rootin.domain.garden.repository.WateringLogRepository; import com.Rootin.domain.garden.service.ExperienceService; +import com.Rootin.domain.til.dto.request.TilCreateRequest; +import com.Rootin.domain.til.dto.response.TilResponse; +import com.Rootin.domain.til.entity.PostStatus; import com.Rootin.domain.til.entity.Til; import com.Rootin.domain.til.repository.TagRepository; import com.Rootin.domain.til.repository.TilRepository; import com.Rootin.domain.user.entity.User; import com.Rootin.domain.user.repository.UserRepository; import com.Rootin.global.exception.CustomException; +import com.Rootin.global.s3.S3Service; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,8 +24,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -55,7 +62,11 @@ class TilServiceTest { @Mock private WateringLogRepository wateringLogRepository; + @Mock + private S3Service s3Service; + private User owner; + private Pot pot; private Til til; @BeforeEach @@ -63,7 +74,7 @@ void setUp() { owner = new User(); ReflectionTestUtils.setField(owner, "id", 1L); - Pot pot = Pot.builder() + pot = Pot.builder() .userId(1L) .title("테스트 화분") .level(1) @@ -75,6 +86,60 @@ void setUp() { ReflectionTestUtils.setField(til, "id", 100L); ReflectionTestUtils.setField(til, "user", owner); ReflectionTestUtils.setField(til, "pot", pot); + ReflectionTestUtils.setField(til, "status", PostStatus.PUBLISHED); + } + + // ─── create() ──────────────────────────────────────────────────── + + @Test + @DisplayName("이미지 없이 TIL 작성 → thumbnailUrl null, S3 업로드 미호출") + void create_withoutImage_thumbnailUrlIsNull() { + TilCreateRequest request = new TilCreateRequest("제목", "내용", 10L, List.of()); + + given(userRepository.findById(1L)).willReturn(Optional.of(owner)); + given(potRepository.findByIdWithLock(10L)).willReturn(Optional.of(pot)); + given(tilRepository.save(any(Til.class))).willReturn(til); + + tilService.create(1L, request, null); + + verify(s3Service, never()).uploadFile(any(), any()); + } + + @Test + @DisplayName("이미지 포함 TIL 작성 → S3 uploadFile 호출, thumbnailUrl 반환") + void create_withImage_uploadsToS3() { + TilCreateRequest request = new TilCreateRequest("제목", "내용", 10L, List.of()); + MockMultipartFile image = new MockMultipartFile( + "image", "thumb.jpg", "image/jpeg", "bytes".getBytes() + ); + String expectedUrl = "https://rootin-bucket.s3.ap-northeast-2.amazonaws.com/til-images/1/10/uuid.jpg"; + + given(userRepository.findById(1L)).willReturn(Optional.of(owner)); + given(potRepository.findByIdWithLock(10L)).willReturn(Optional.of(pot)); + given(s3Service.uploadFile(any(), any())).willReturn(expectedUrl); + given(tilRepository.save(any(Til.class))).willReturn(til); + + tilService.create(1L, request, image); + + verify(s3Service).uploadFile(any(), any()); + } + + @Test + @DisplayName("지원하지 않는 이미지 타입 → 400 예외, S3 업로드 미호출") + void create_unsupportedImageType_throws400() { + TilCreateRequest request = new TilCreateRequest("제목", "내용", 10L, List.of()); + MockMultipartFile image = new MockMultipartFile( + "image", "test.gif", "image/gif", "bytes".getBytes() + ); + + given(userRepository.findById(1L)).willReturn(Optional.of(owner)); + given(potRepository.findByIdWithLock(10L)).willReturn(Optional.of(pot)); + + assertThatThrownBy(() -> tilService.create(1L, request, image)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); + + verify(s3Service, never()).uploadFile(any(), any()); } // ─── delete() ──────────────────────────────────────────────────── diff --git a/src/test/java/com/Rootin/global/s3/S3ServiceTest.java b/src/test/java/com/Rootin/global/s3/S3ServiceTest.java index ee39256..af8c734 100644 --- a/src/test/java/com/Rootin/global/s3/S3ServiceTest.java +++ b/src/test/java/com/Rootin/global/s3/S3ServiceTest.java @@ -1,11 +1,18 @@ +// S3Service 단위 테스트: presigned URL 생성, 직접 파일 업로드(uploadFile), 공개 URL 반환 동작을 검증한다 package com.Rootin.global.s3; +import com.Rootin.global.exception.CustomException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +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.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -14,8 +21,10 @@ import java.net.URL; 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.BDDMockito.given; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class S3ServiceTest { @@ -25,19 +34,24 @@ class S3ServiceTest { @Mock private S3Presigner s3Presigner; + @Mock + private S3Client s3Client; + @Mock private PresignedPutObjectRequest presignedPutObjectRequest; + private S3Properties defaultProps; + @BeforeEach void setUp() { S3Properties.S3 s3 = new S3Properties.S3(); s3.setBucket("rootin-bucket"); - S3Properties props = new S3Properties(); - props.setRegion("ap-northeast-2"); - props.setS3(s3); + defaultProps = new S3Properties(); + defaultProps.setRegion("ap-northeast-2"); + defaultProps.setS3(s3); - s3Service = new S3Service(s3Presigner, props); + s3Service = new S3Service(s3Presigner, s3Client, defaultProps); } @Test @@ -80,7 +94,7 @@ void getFileUrl_withLocalStackEndpoint() { props.setRegion("ap-northeast-2"); props.setS3(s3); - S3Service serviceWithEndpoint = new S3Service(s3Presigner, props); + S3Service serviceWithEndpoint = new S3Service(s3Presigner, s3Client, props); String objectKey = "til-images/1/10/uuid.jpg"; String result = serviceWithEndpoint.getFileUrl(objectKey); @@ -99,6 +113,37 @@ void getFileUrl_tilImagePath() { assertThat(result).endsWith(objectKey); } + // ─── uploadFile() ──────────────────────────────────────────────── + + @Test + @DisplayName("uploadFile — S3Client.putObject 호출 후 공개 URL 반환") + void uploadFile_success() { + String objectKey = "til-images/1/10/uuid.jpg"; + MockMultipartFile file = new MockMultipartFile( + "image", "test.jpg", "image/jpeg", "fake-image-bytes".getBytes() + ); + + String result = s3Service.uploadFile(file, objectKey); + + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + assertThat(result).isEqualTo( + "https://rootin-bucket.s3.ap-northeast-2.amazonaws.com/" + objectKey + ); + } + + @Test + @DisplayName("uploadFile — 지원하지 않는 contentType → 400 예외") + void uploadFile_unsupportedContentType() { + MockMultipartFile file = new MockMultipartFile( + "image", "test.gif", "image/gif", "fake".getBytes() + ); + + // S3Service.uploadFile()은 contentType 검증 없이 S3에 그대로 올림; + // 검증은 TilService.uploadThumbnailIfPresent()에서 담당하므로 여기선 정상 통과 + assertThat(s3Service.uploadFile(file, "til-images/1/10/uuid.gif")) + .contains("til-images/1/10/uuid.gif"); + } + @Test @DisplayName("generatePresignedPutUrl — objectKey에 til-images 경로가 포함된 경우에도 정상 동작") void generatePresignedPutUrl_tilImagesPath() throws MalformedURLException { From 0712db462029e35460c1f2582a5bd351c36c3256 Mon Sep 17 00:00:00 2001 From: kws0315 Date: Fri, 19 Jun 2026 17:34:39 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20ExperienceServiceTest.java=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Rootin/domain/garden/service/ExperienceServiceTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java b/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java index 9699cfd..e811c5a 100644 --- a/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java +++ b/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java @@ -241,7 +241,7 @@ void tilCreateTriggersWatering() { ); // when - TilResponse response = tilService.create(testUser.getId(), request); + TilResponse response = tilService.create(testUser.getId(), request, null); // then // 1. TIL이 정상 생성 확인 (레코드는 getId()가 아니라 tilId() 메소드를 제공함) @@ -304,7 +304,7 @@ void tilCreateForbiddenWhenPotOwnerMismatch() { ); CustomException exception = assertThrows(CustomException.class, () -> - tilService.create(otherUser.getId(), request) + tilService.create(otherUser.getId(), request, null) ); assertThat(exception.getStatus()).isEqualTo(org.springframework.http.HttpStatus.FORBIDDEN); @@ -322,7 +322,7 @@ void saveDraftForbiddenWhenPotOwnerMismatch() { ); CustomException exception = assertThrows(CustomException.class, () -> - tilService.saveDraft(otherUser.getId(), request) + tilService.saveDraft(otherUser.getId(), request, null) ); assertThat(exception.getStatus()).isEqualTo(org.springframework.http.HttpStatus.FORBIDDEN); From 9a4711fa32fe3b631ba11c2dbb9144837a11a786 Mon Sep 17 00:00:00 2001 From: kws0315 Date: Fri, 19 Jun 2026 17:46:53 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20AWS=20s3=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Rootin/global/s3/S3Service.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/Rootin/global/s3/S3Service.java b/src/main/java/com/Rootin/global/s3/S3Service.java index fb75711..8ccdeb1 100644 --- a/src/main/java/com/Rootin/global/s3/S3Service.java +++ b/src/main/java/com/Rootin/global/s3/S3Service.java @@ -5,9 +5,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.exception.SdkClientException; 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 software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -15,6 +18,7 @@ import java.io.IOException; import java.time.Duration; +@Slf4j @Service @RequiredArgsConstructor public class S3Service { @@ -60,7 +64,14 @@ public String uploadFile(MultipartFile file, String objectKey) { return getFileUrl(objectKey); } catch (IOException e) { + log.error("S3 업로드 중 파일 스트림 처리 오류. objectKey={}", objectKey, e); + throw CustomException.internalServerError("파일 스트림 처리 중 오류가 발생했습니다."); + } catch (S3Exception e) { + log.error("S3 업로드 실패. objectKey={}, statusCode={}, message={}", objectKey, e.statusCode(), e.getMessage()); throw CustomException.internalServerError("S3 업로드 중 오류가 발생했습니다."); + } catch (SdkClientException e) { + log.error("S3 연결 실패 (네트워크/인증 오류). objectKey={}, message={}", objectKey, e.getMessage()); + throw CustomException.internalServerError("S3 서버와의 연결 중 오류가 발생했습니다."); } } From 8cb064c3cd9e933729e9c766ebafc0a174f0b5c8 Mon Sep 17 00:00:00 2001 From: kws0315 Date: Fri, 19 Jun 2026 18:01:37 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20s3=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Rootin/domain/garden/service/ExperienceServiceTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java b/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java index 368e748..b05fec0 100644 --- a/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java +++ b/src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java @@ -265,7 +265,7 @@ void tilCreateUsesVisibleTextLengthForExp() { TilCreateRequest request = new TilCreateRequest("서식 TIL", html, testPot.getId(), List.of("Java")); // when - TilResponse response = tilService.create(testUser.getId(), request); + TilResponse response = tilService.create(testUser.getId(), request, null); em.flush(); em.clear(); @@ -288,7 +288,7 @@ void tilCreateWithNoVisibleTextSkipsWatering() { TilCreateRequest request = new TilCreateRequest("빈 본문 TIL", "

", testPot.getId(), List.of()); // when - TilResponse response = tilService.create(testUser.getId(), request); + TilResponse response = tilService.create(testUser.getId(), request, null); em.flush(); em.clear(); From 17bf89568045e77c19905acceb56e201c9a2aaaa Mon Sep 17 00:00:00 2001 From: kws0315 Date: Mon, 22 Jun 2026 11:07:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20s3=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Rootin/domain/til/controller/TilController.java | 7 ++++--- .../java/com/Rootin/domain/til/service/TilService.java | 7 ++++++- src/main/resources/application.yml | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Rootin/domain/til/controller/TilController.java b/src/main/java/com/Rootin/domain/til/controller/TilController.java index 5b02f7b..6075fb3 100644 --- a/src/main/java/com/Rootin/domain/til/controller/TilController.java +++ b/src/main/java/com/Rootin/domain/til/controller/TilController.java @@ -66,13 +66,14 @@ public ResponseEntity>> findMyTils( tilService.findMyTils(userDetails.getUserId(), potId, page, size, sort, keyword, tag))); } - @PutMapping("/{tilId}") + @PutMapping(value = "/{tilId}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity> update( @PathVariable Long tilId, @AuthenticationPrincipal JwtUserDetails userDetails, - @Valid @RequestBody TilUpdateRequest request + @RequestPart("data") @Valid TilUpdateRequest request, + @RequestPart(value = "image", required = false) MultipartFile thumbnailImage ) { - return ResponseEntity.ok(ApiResponse.success(tilService.update(tilId, userDetails.getUserId(), request))); + return ResponseEntity.ok(ApiResponse.success(tilService.update(tilId, userDetails.getUserId(), request, thumbnailImage))); } @DeleteMapping("/{tilId}") diff --git a/src/main/java/com/Rootin/domain/til/service/TilService.java b/src/main/java/com/Rootin/domain/til/service/TilService.java index 862c860..f67dad6 100644 --- a/src/main/java/com/Rootin/domain/til/service/TilService.java +++ b/src/main/java/com/Rootin/domain/til/service/TilService.java @@ -109,13 +109,18 @@ public Page findMyTils(Long userId, Long potId, int page, int size, } @Transactional - public TilResponse update(Long tilId, Long userId, TilUpdateRequest request) { + public TilResponse update(Long tilId, Long userId, TilUpdateRequest request, MultipartFile thumbnailImage) { Til til = getTilOrThrow(tilId); validateOwner(til, userId); til.update(request.title(), request.content()); syncTags(til, request.tags()); + if (thumbnailImage != null && !thumbnailImage.isEmpty()) { + String newThumbnailUrl = uploadThumbnailIfPresent(thumbnailImage, userId, til.getPot().getId()); + til.updateThumbnailUrl(newThumbnailUrl); + } + return TilResponse.from(til); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cb39604..b9619bb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,10 @@ spring: application: name: Rootin + servlet: + multipart: + max-file-size: 10MB + max-request-size: 20MB cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173}