From 2efb52235d8a287a6780d76e7183c81e1fce64cc Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sat, 30 May 2026 18:31:38 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#241]=20Feat:=20ImageValidator=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=ED=8F=AC=EB=A7=B7=20=EC=B6=95=EC=86=8C(JPEG/PNG)?= =?UTF-8?q?=20=EB=B0=8F=20ImageIO=20=ED=8C=8C=EC=8B=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/support/image/ImageValidator.java | 44 ++++----- .../support/image/ImageValidatorUnitTest.java | 90 +++++++++++++++++++ 2 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java diff --git a/src/main/java/soon/fridgely/global/support/image/ImageValidator.java b/src/main/java/soon/fridgely/global/support/image/ImageValidator.java index 0d305231..b310ed9f 100644 --- a/src/main/java/soon/fridgely/global/support/image/ImageValidator.java +++ b/src/main/java/soon/fridgely/global/support/image/ImageValidator.java @@ -7,33 +7,24 @@ import soon.fridgely.global.support.exception.CoreException; import soon.fridgely.global.support.exception.ErrorType; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; import java.util.List; import java.util.Map; -/** - * 이미지 파일 검증을 담당 - * - 파일 크기 검증 - * - Content-Type 검증 - * - 파일 확장자 검증 - * - Magic Number 검증 (파일 위변조 방지) - */ @Slf4j @Component public class ImageValidator { - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif", "webp"); - private static final List ALLOWED_CONTENT_TYPES = List.of( - "image/jpeg", "image/png", "image/gif", "image/webp" - ); + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png"); + private static final List ALLOWED_CONTENT_TYPES = List.of("image/jpeg", "image/png"); private static final Map MAGIC_NUMBERS = Map.of( "image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}, - "image/png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}, - "image/gif", new byte[]{0x47, 0x49, 0x46, 0x38}, - "image/webp", new byte[]{0x52, 0x49, 0x46, 0x46} + "image/png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47} ); public void validate(MultipartFile file) { @@ -41,6 +32,7 @@ public void validate(MultipartFile file) { validateContentType(file); validateFileExtension(file); validateMagicNumber(file); + validateImageParseable(file); } private void validateFileSize(MultipartFile file) { @@ -61,7 +53,6 @@ private void validateFileExtension(MultipartFile file) { if (!StringUtils.hasText(originalFilename)) { throw new CoreException(ErrorType.INVALID_FILE_TYPE); } - String extension = getFileExtension(originalFilename).toLowerCase(); if (!ALLOWED_EXTENSIONS.contains(extension)) { throw new CoreException(ErrorType.INVALID_FILE_TYPE); @@ -73,15 +64,12 @@ private void validateMagicNumber(MultipartFile file) { if (contentType == null) { return; } - byte[] expectedMagicNumber = MAGIC_NUMBERS.get(contentType); if (expectedMagicNumber == null) { return; } - try (InputStream is = file.getInputStream()) { byte[] fileHeader = is.readNBytes(expectedMagicNumber.length); - if (!Arrays.equals(fileHeader, expectedMagicNumber)) { log.debug("[ImageValidator] Magic Number 불일치. (ContentType={})", contentType); throw new CoreException(ErrorType.INVALID_FILE_TYPE); @@ -92,6 +80,21 @@ private void validateMagicNumber(MultipartFile file) { } } + private void validateImageParseable(MultipartFile file) { + try (InputStream is = file.getInputStream()) { + BufferedImage image = ImageIO.read(is); + if (image == null) { + log.debug("[ImageValidator] ImageIO 파싱 실패 — null 반환. (Filename={})", file.getOriginalFilename()); + throw new CoreException(ErrorType.INVALID_FILE_TYPE); + } + } catch (CoreException e) { + throw e; + } catch (IOException e) { + log.debug("[ImageValidator] ImageIO 파싱 실패 — IOException. (Filename={})", file.getOriginalFilename()); + throw new CoreException(ErrorType.INVALID_FILE_TYPE); + } + } + private String getFileExtension(String filename) { int lastDotIndex = filename.lastIndexOf('.'); if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { @@ -99,5 +102,4 @@ private String getFileExtension(String filename) { } return filename.substring(lastDotIndex + 1); } - -} \ No newline at end of file +} diff --git a/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java b/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java new file mode 100644 index 00000000..d981e824 --- /dev/null +++ b/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java @@ -0,0 +1,90 @@ +package soon.fridgely.global.support.image; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import soon.fridgely.global.support.exception.CoreException; +import soon.fridgely.global.support.exception.ErrorType; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ImageValidatorUnitTest { + + private final ImageValidator validator = new ImageValidator(); + + @Test + void 유효한_JPEG_파일은_검증을_통과한다() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "image", "photo.jpg", "image/jpeg", createValidJpeg() + ); + assertThatCode(() -> validator.validate(file)).doesNotThrowAnyException(); + } + + @Test + void 유효한_PNG_파일은_검증을_통과한다() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "image", "photo.png", "image/png", createValidPng() + ); + assertThatCode(() -> validator.validate(file)).doesNotThrowAnyException(); + } + + @Test + void 매직넘버는_통과하지만_파싱_불가한_폴리글랏_파일은_예외가_발생한다() { + byte[] content = new byte[200]; + content[0] = (byte) 0xFF; + content[1] = (byte) 0xD8; + content[2] = (byte) 0xFF; + + MockMultipartFile file = new MockMultipartFile( + "image", "evil.jpg", "image/jpeg", content + ); + + assertThatThrownBy(() -> validator.validate(file)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.INVALID_FILE_TYPE); + } + + @Test + void gif_확장자는_예외가_발생한다() { + MockMultipartFile file = new MockMultipartFile( + "image", "anim.gif", "image/gif", + new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61} + ); + assertThatThrownBy(() -> validator.validate(file)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.INVALID_FILE_TYPE); + } + + @Test + void webp_확장자는_예외가_발생한다() { + MockMultipartFile file = new MockMultipartFile( + "image", "photo.webp", "image/webp", + new byte[]{0x52, 0x49, 0x46, 0x46} + ); + assertThatThrownBy(() -> validator.validate(file)) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.INVALID_FILE_TYPE); + } + + private byte[] createValidJpeg() throws IOException { + BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpeg", baos); + return baos.toByteArray(); + } + + private byte[] createValidPng() throws IOException { + BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return baos.toByteArray(); + } +} From ae86fb9130fe314ac0464a223913bec0c2454b46 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sat, 30 May 2026 18:34:04 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#241]=20Feat:=20UPLOAD=5FRATE=5FLIMIT=5FEX?= =?UTF-8?q?CEEDED=20ErrorType=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/soon/fridgely/global/support/exception/ErrorType.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/soon/fridgely/global/support/exception/ErrorType.java b/src/main/java/soon/fridgely/global/support/exception/ErrorType.java index 41419ab7..6fed4d3c 100644 --- a/src/main/java/soon/fridgely/global/support/exception/ErrorType.java +++ b/src/main/java/soon/fridgely/global/support/exception/ErrorType.java @@ -29,6 +29,7 @@ public enum ErrorType { FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "파일 크기가 허용 범위를 초과했습니다. (최대 10MB)", LogLevel.WARN), INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "허용되지 않은 파일 형식입니다.", LogLevel.WARN), INVALID_IMAGE_URL(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 URL입니다.", LogLevel.WARN), + UPLOAD_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "업로드 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.", LogLevel.WARN), // 멤버 오류 DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "이미 사용 중인 ID입니다.", LogLevel.WARN), From be929ec3826d1a74b1a081ee5da91e1bca144be7 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sat, 30 May 2026 18:37:47 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[#241]=20Feat:=20Redis=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=B3=84=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20Rate=20Limit=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/ratelimit/UploadRateLimit.java | 11 ++ .../ratelimit/UploadRateLimitAspect.java | 65 +++++++++ .../UploadRateLimitAspectUnitTest.java | 127 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimit.java create mode 100644 src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java create mode 100644 src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java diff --git a/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimit.java b/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimit.java new file mode 100644 index 00000000..20180993 --- /dev/null +++ b/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimit.java @@ -0,0 +1,11 @@ +package soon.fridgely.global.security.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface UploadRateLimit { +} diff --git a/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java b/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java new file mode 100644 index 00000000..18ccf030 --- /dev/null +++ b/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java @@ -0,0 +1,65 @@ +package soon.fridgely.global.security.ratelimit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import soon.fridgely.global.support.exception.CoreException; +import soon.fridgely.global.support.exception.ErrorType; + +import java.time.Duration; + +@Slf4j +@RequiredArgsConstructor +@Aspect +@Component +@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis") +public class UploadRateLimitAspect { + + private final StringRedisTemplate stringRedisTemplate; + + @Value("${upload.rate-limit.max-requests}") + private int maxRequests; + + @Value("${upload.rate-limit.period-seconds}") + private long periodSeconds; + + @Before("@annotation(soon.fridgely.global.security.ratelimit.UploadRateLimit)") + public void checkRateLimit() { + Long userId = extractUserId(); + String key = "upload:ratelimit:" + userId; + + Long count = stringRedisTemplate.opsForValue().increment(key); + if (Long.valueOf(1L).equals(count)) { + stringRedisTemplate.expire(key, Duration.ofSeconds(periodSeconds)); + } + if (count != null && count > maxRequests) { + log.warn("[UploadRateLimitAspect] 업로드 Rate Limit 초과. (UserId={}, Count={})", userId, count); + throw new CoreException(ErrorType.UPLOAD_RATE_LIMIT_EXCEEDED); + } + } + + private Long extractUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw new CoreException(ErrorType.AUTHENTICATION_FAILED); + } + Object principal = authentication.getPrincipal(); + if (principal instanceof String str) { + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + throw new CoreException(ErrorType.AUTHENTICATION_FAILED); + } + } + throw new CoreException(ErrorType.AUTHENTICATION_FAILED); + } +} diff --git a/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java b/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java new file mode 100644 index 00000000..1008d007 --- /dev/null +++ b/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java @@ -0,0 +1,127 @@ +package soon.fridgely.global.security.ratelimit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import soon.fridgely.global.support.exception.CoreException; +import soon.fridgely.global.support.exception.ErrorType; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class UploadRateLimitAspectUnitTest { + + @InjectMocks + private UploadRateLimitAspect aspect; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Mock + private ValueOperations valueOperations; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(aspect, "maxRequests", 10); + ReflectionTestUtils.setField(aspect, "periodSeconds", 60L); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void 요청_횟수가_한도_이하이면_예외가_발생하지_않는다() { + setAuthenticatedUser(1L); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.increment("upload:ratelimit:1")).willReturn(5L); + + assertThatCode(() -> aspect.checkRateLimit()).doesNotThrowAnyException(); + } + + @Test + void 첫번째_요청이면_TTL을_설정한다() { + setAuthenticatedUser(1L); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.increment("upload:ratelimit:1")).willReturn(1L); + + aspect.checkRateLimit(); + + then(stringRedisTemplate).should().expire(eq("upload:ratelimit:1"), any()); + } + + @Test + void 첫번째_요청이_아니면_TTL을_재설정하지_않는다() { + setAuthenticatedUser(1L); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.increment("upload:ratelimit:1")).willReturn(5L); + + aspect.checkRateLimit(); + + then(stringRedisTemplate).should(never()).expire(any(), any()); + } + + @Test + void 요청_횟수가_한도를_초과하면_예외가_발생한다() { + setAuthenticatedUser(1L); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.increment("upload:ratelimit:1")).willReturn(11L); + + assertThatThrownBy(() -> aspect.checkRateLimit()) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.UPLOAD_RATE_LIMIT_EXCEEDED); + } + + @Test + void 인증_정보가_없으면_예외가_발생한다() { + SecurityContextHolder.clearContext(); + + assertThatThrownBy(() -> aspect.checkRateLimit()) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + + @Test + void 익명_사용자이면_예외가_발생한다() { + Authentication anon = new AnonymousAuthenticationToken( + "key", "anonymous", + Collections.singletonList(new SimpleGrantedAuthority("ROLE_ANONYMOUS")) + ); + SecurityContextHolder.getContext().setAuthentication(anon); + + assertThatThrownBy(() -> aspect.checkRateLimit()) + .isInstanceOf(CoreException.class) + .extracting("errorType") + .isEqualTo(ErrorType.AUTHENTICATION_FAILED); + } + + private void setAuthenticatedUser(Long userId) { + Authentication auth = new UsernamePasswordAuthenticationToken( + String.valueOf(userId), null, Collections.emptyList() + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } +} From bf688b9e67b46c6c2f55d93a7eae424328925994 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sat, 30 May 2026 18:44:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[#241]=20Feat:=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20Rate=20Limit=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../soon/fridgely/domain/food/controller/FoodController.java | 3 +++ .../fridgely/domain/member/controller/MemberController.java | 2 ++ src/main/resources/application.yml | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/src/main/java/soon/fridgely/domain/food/controller/FoodController.java b/src/main/java/soon/fridgely/domain/food/controller/FoodController.java index 9acd866b..a0c7f98e 100644 --- a/src/main/java/soon/fridgely/domain/food/controller/FoodController.java +++ b/src/main/java/soon/fridgely/domain/food/controller/FoodController.java @@ -19,6 +19,7 @@ import soon.fridgely.domain.food.service.FoodService; import soon.fridgely.domain.refrigerator.dto.command.MemberRefrigeratorKey; import soon.fridgely.global.security.annotation.LoginMember; +import soon.fridgely.global.security.ratelimit.UploadRateLimit; import soon.fridgely.global.support.CursorPageRequest; import soon.fridgely.global.support.response.ApiResponse; @@ -31,6 +32,7 @@ public class FoodController implements FoodControllerDocs { private final FoodService foodService; @Override + @UploadRateLimit @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> createFood( @RequestPart(value = "request") @Valid FoodCreateRequest request, @@ -43,6 +45,7 @@ public ResponseEntity> createFood( } @Override + @UploadRateLimit @PatchMapping(value = "/{foodId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateFood( @RequestPart(value = "request") @Valid FoodUpdateRequest request, diff --git a/src/main/java/soon/fridgely/domain/member/controller/MemberController.java b/src/main/java/soon/fridgely/domain/member/controller/MemberController.java index 8fafeb53..e96781e2 100644 --- a/src/main/java/soon/fridgely/domain/member/controller/MemberController.java +++ b/src/main/java/soon/fridgely/domain/member/controller/MemberController.java @@ -14,6 +14,7 @@ import soon.fridgely.domain.member.service.MemberFacade; import soon.fridgely.domain.member.service.MemberService; import soon.fridgely.global.security.annotation.LoginMember; +import soon.fridgely.global.security.ratelimit.UploadRateLimit; import soon.fridgely.global.support.response.ApiResponse; @RequiredArgsConstructor @@ -54,6 +55,7 @@ public ResponseEntity> syncToken( } @Override + @UploadRateLimit @PatchMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateProfileImage( @RequestPart("file") MultipartFile file, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index de671c13..747673ec 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -60,6 +60,11 @@ management: scheduling: enabled: true +upload: + rate-limit: + max-requests: 10 + period-seconds: 60 + # 공통 Resilience4j 설정 resilience4j: retry: From 08f2a6e6afd79dc3ebc03822d2dc490d7d7c6140 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sat, 30 May 2026 19:09:20 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#241]=20Refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20TTL=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=B2=98=EB=A6=AC,=20RuntimeException=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81,=20BDD=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ratelimit/UploadRateLimitAspect.java | 6 ++-- .../global/support/image/ImageValidator.java | 3 ++ .../UploadRateLimitAspectUnitTest.java | 35 +++++++++++++++++-- .../support/image/ImageValidatorUnitTest.java | 15 +++++++- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java b/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java index 18ccf030..48c2619e 100644 --- a/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java +++ b/src/main/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspect.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Component; import soon.fridgely.global.support.exception.CoreException; import soon.fridgely.global.support.exception.ErrorType; +import soon.fridgely.global.support.logging.SlackMarkers; import java.time.Duration; @@ -37,11 +38,12 @@ public void checkRateLimit() { String key = "upload:ratelimit:" + userId; Long count = stringRedisTemplate.opsForValue().increment(key); - if (Long.valueOf(1L).equals(count)) { + Long ttl = stringRedisTemplate.getExpire(key); + if (ttl != null && ttl == -1L) { stringRedisTemplate.expire(key, Duration.ofSeconds(periodSeconds)); } if (count != null && count > maxRequests) { - log.warn("[UploadRateLimitAspect] 업로드 Rate Limit 초과. (UserId={}, Count={})", userId, count); + log.warn(SlackMarkers.SYSTEM, "[UploadRateLimitAspect] 업로드 Rate Limit 초과. (UserId={}, Count={})", userId, count); throw new CoreException(ErrorType.UPLOAD_RATE_LIMIT_EXCEEDED); } } diff --git a/src/main/java/soon/fridgely/global/support/image/ImageValidator.java b/src/main/java/soon/fridgely/global/support/image/ImageValidator.java index b310ed9f..748ddeb1 100644 --- a/src/main/java/soon/fridgely/global/support/image/ImageValidator.java +++ b/src/main/java/soon/fridgely/global/support/image/ImageValidator.java @@ -92,6 +92,9 @@ private void validateImageParseable(MultipartFile file) { } catch (IOException e) { log.debug("[ImageValidator] ImageIO 파싱 실패 — IOException. (Filename={})", file.getOriginalFilename()); throw new CoreException(ErrorType.INVALID_FILE_TYPE); + } catch (RuntimeException e) { + log.debug("[ImageValidator] ImageIO 파싱 실패 — RuntimeException. (Filename={})", file.getOriginalFilename()); + throw new CoreException(ErrorType.INVALID_FILE_TYPE); } } diff --git a/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java b/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java index 1008d007..bc6b4353 100644 --- a/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java +++ b/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java @@ -53,41 +53,68 @@ void tearDown() { @Test void 요청_횟수가_한도_이하이면_예외가_발생하지_않는다() { + // given setAuthenticatedUser(1L); given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.increment("upload:ratelimit:1")).willReturn(5L); + // expected assertThatCode(() -> aspect.checkRateLimit()).doesNotThrowAnyException(); } @Test - void 첫번째_요청이면_TTL을_설정한다() { + void TTL이_없는_키는_expire를_설정한다() { + // given setAuthenticatedUser(1L); given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.increment("upload:ratelimit:1")).willReturn(1L); + given(stringRedisTemplate.getExpire("upload:ratelimit:1")).willReturn(-1L); + // when aspect.checkRateLimit(); + // then then(stringRedisTemplate).should().expire(eq("upload:ratelimit:1"), any()); } @Test - void 첫번째_요청이_아니면_TTL을_재설정하지_않는다() { + void TTL_없는_기존_키는_방어적으로_expire를_설정한다() { + // given: count가 1이 아니지만 이전 EXPIRE 실패로 TTL이 없는 경우 setAuthenticatedUser(1L); given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.increment("upload:ratelimit:1")).willReturn(5L); + given(stringRedisTemplate.getExpire("upload:ratelimit:1")).willReturn(-1L); + // when aspect.checkRateLimit(); + // then + then(stringRedisTemplate).should().expire(eq("upload:ratelimit:1"), any()); + } + + @Test + void TTL이_설정된_키는_expire를_재설정하지_않는다() { + // given + setAuthenticatedUser(1L); + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.increment("upload:ratelimit:1")).willReturn(5L); + given(stringRedisTemplate.getExpire("upload:ratelimit:1")).willReturn(30L); + + // when + aspect.checkRateLimit(); + + // then then(stringRedisTemplate).should(never()).expire(any(), any()); } @Test void 요청_횟수가_한도를_초과하면_예외가_발생한다() { + // given setAuthenticatedUser(1L); given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.increment("upload:ratelimit:1")).willReturn(11L); + // expected assertThatThrownBy(() -> aspect.checkRateLimit()) .isInstanceOf(CoreException.class) .extracting("errorType") @@ -96,8 +123,10 @@ void tearDown() { @Test void 인증_정보가_없으면_예외가_발생한다() { + // given SecurityContextHolder.clearContext(); + // expected assertThatThrownBy(() -> aspect.checkRateLimit()) .isInstanceOf(CoreException.class) .extracting("errorType") @@ -106,12 +135,14 @@ void tearDown() { @Test void 익명_사용자이면_예외가_발생한다() { + // given Authentication anon = new AnonymousAuthenticationToken( "key", "anonymous", Collections.singletonList(new SimpleGrantedAuthority("ROLE_ANONYMOUS")) ); SecurityContextHolder.getContext().setAuthentication(anon); + // expected assertThatThrownBy(() -> aspect.checkRateLimit()) .isInstanceOf(CoreException.class) .extracting("errorType") diff --git a/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java b/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java index d981e824..97884809 100644 --- a/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java +++ b/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java @@ -19,31 +19,38 @@ class ImageValidatorUnitTest { @Test void 유효한_JPEG_파일은_검증을_통과한다() throws IOException { + // given MockMultipartFile file = new MockMultipartFile( "image", "photo.jpg", "image/jpeg", createValidJpeg() ); + + // expected assertThatCode(() -> validator.validate(file)).doesNotThrowAnyException(); } @Test void 유효한_PNG_파일은_검증을_통과한다() throws IOException { + // given MockMultipartFile file = new MockMultipartFile( "image", "photo.png", "image/png", createValidPng() ); + + // expected assertThatCode(() -> validator.validate(file)).doesNotThrowAnyException(); } @Test void 매직넘버는_통과하지만_파싱_불가한_폴리글랏_파일은_예외가_발생한다() { + // given byte[] content = new byte[200]; content[0] = (byte) 0xFF; content[1] = (byte) 0xD8; content[2] = (byte) 0xFF; - MockMultipartFile file = new MockMultipartFile( "image", "evil.jpg", "image/jpeg", content ); + // expected assertThatThrownBy(() -> validator.validate(file)) .isInstanceOf(CoreException.class) .extracting("errorType") @@ -52,10 +59,13 @@ class ImageValidatorUnitTest { @Test void gif_확장자는_예외가_발생한다() { + // given MockMultipartFile file = new MockMultipartFile( "image", "anim.gif", "image/gif", new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61} ); + + // expected assertThatThrownBy(() -> validator.validate(file)) .isInstanceOf(CoreException.class) .extracting("errorType") @@ -64,10 +74,13 @@ class ImageValidatorUnitTest { @Test void webp_확장자는_예외가_발생한다() { + // given MockMultipartFile file = new MockMultipartFile( "image", "photo.webp", "image/webp", new byte[]{0x52, 0x49, 0x46, 0x46} ); + + // expected assertThatThrownBy(() -> validator.validate(file)) .isInstanceOf(CoreException.class) .extracting("errorType") From 8543dd2eca1d996621625c43fa8eaca17982ea00 Mon Sep 17 00:00:00 2001 From: jjh75607 Date: Sat, 30 May 2026 19:12:07 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[#241]=20Refactor:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20BDD=20=EA=B5=AC=EC=A1=B0=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=E2=80=94=20=EC=84=B1=EA=B3=B5=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=84=20//=20when=20&=20then=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/ratelimit/UploadRateLimitAspectUnitTest.java | 2 +- .../fridgely/global/support/image/ImageValidatorUnitTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java b/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java index bc6b4353..5bdb3552 100644 --- a/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java +++ b/src/test/java/soon/fridgely/global/security/ratelimit/UploadRateLimitAspectUnitTest.java @@ -58,7 +58,7 @@ void tearDown() { given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); given(valueOperations.increment("upload:ratelimit:1")).willReturn(5L); - // expected + // when & then assertThatCode(() -> aspect.checkRateLimit()).doesNotThrowAnyException(); } diff --git a/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java b/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java index 97884809..6509e48a 100644 --- a/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java +++ b/src/test/java/soon/fridgely/global/support/image/ImageValidatorUnitTest.java @@ -24,7 +24,7 @@ class ImageValidatorUnitTest { "image", "photo.jpg", "image/jpeg", createValidJpeg() ); - // expected + // when & then assertThatCode(() -> validator.validate(file)).doesNotThrowAnyException(); } @@ -35,7 +35,7 @@ class ImageValidatorUnitTest { "image", "photo.png", "image/png", createValidPng() ); - // expected + // when & then assertThatCode(() -> validator.validate(file)).doesNotThrowAnyException(); }