diff --git a/Dockerfile b/Dockerfile index 8b3cbf1..3879edf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN chmod +x ./gradlew && ./gradlew --no-daemon clean bootJar -x test FROM eclipse-temurin:17-jre WORKDIR /app -ENV JAVA_TOOL_OPTIONS="-Xmx384m -XX:MaxRAMPercentage=75" +ENV JAVA_TOOL_OPTIONS="-Xms64m -Xmx256m -XX:+UseSerialGC -XX:ActiveProcessorCount=1 -XX:MaxMetaspaceSize=128m -XX:ReservedCodeCacheSize=64m -XX:MaxDirectMemorySize=32m -Xss256k -XX:+ExitOnOutOfMemoryError" COPY --from=build /app/build/libs/*.jar app.jar EXPOSE 8080 diff --git a/docs/RENDER_FREE_DEPLOYMENT_RUNBOOK.md b/docs/RENDER_FREE_DEPLOYMENT_RUNBOOK.md index 74008d6..5d7a543 100644 --- a/docs/RENDER_FREE_DEPLOYMENT_RUNBOOK.md +++ b/docs/RENDER_FREE_DEPLOYMENT_RUNBOOK.md @@ -78,7 +78,10 @@ spring: ```env DB_MAX_POOL_SIZE=2 DB_MIN_IDLE=1 -JAVA_TOOL_OPTIONS=-Xmx384m -XX:MaxRAMPercentage=75 +JAVA_TOOL_OPTIONS=-Xms64m -Xmx256m -XX:+UseSerialGC -XX:ActiveProcessorCount=1 -XX:MaxMetaspaceSize=128m -XX:ReservedCodeCacheSize=64m -XX:MaxDirectMemorySize=32m -Xss256k -XX:+ExitOnOutOfMemoryError +SPRING_MAIN_LAZY_INITIALIZATION=true +SPRINGDOC_ENABLED=false +HIBERNATE_DEFAULT_BATCH_FETCH_SIZE=100 ``` ## DB 이전 @@ -168,9 +171,12 @@ CHARACTER_IMAGE_QUALITY=medium CHARACTER_IMAGE_WIDTH=1024 CHARACTER_IMAGE_HEIGHT=1024 CHARACTER_IMAGE_COUNT=1 -JAVA_TOOL_OPTIONS=-Xmx384m -XX:MaxRAMPercentage=75 +JAVA_TOOL_OPTIONS=-Xms64m -Xmx256m -XX:+UseSerialGC -XX:ActiveProcessorCount=1 -XX:MaxMetaspaceSize=128m -XX:ReservedCodeCacheSize=64m -XX:MaxDirectMemorySize=32m -Xss256k -XX:+ExitOnOutOfMemoryError DB_MAX_POOL_SIZE=2 DB_MIN_IDLE=1 +SPRING_MAIN_LAZY_INITIALIZATION=true +SPRINGDOC_ENABLED=false +HIBERNATE_DEFAULT_BATCH_FETCH_SIZE=100 ``` Google OAuth를 Render 백엔드로 사용할 경우: diff --git a/src/main/java/com/kw/readwith/config/AsyncConfig.java b/src/main/java/com/kw/readwith/config/AsyncConfig.java index 582177e..b2ed2d6 100644 --- a/src/main/java/com/kw/readwith/config/AsyncConfig.java +++ b/src/main/java/com/kw/readwith/config/AsyncConfig.java @@ -25,17 +25,17 @@ public class AsyncConfig implements AsyncConfigurer { public Executor imageGenerationExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - // 코어 스레드: 동시에 2개의 이미지만 생성 - executor.setCorePoolSize(2); + // Render Free 512MB 환경에서는 이미지 생성을 한 번에 하나만 실행한다. + executor.setCorePoolSize(1); - // 최대 스레드: 부하가 높을 때 최대 3개까지 허용 - executor.setMaxPoolSize(3); + executor.setMaxPoolSize(1); - // 대기 큐: 50개까지 대기 가능 - executor.setQueueCapacity(50); + executor.setQueueCapacity(10); // 스레드 이름 prefix (로그 추적 용이) executor.setThreadNamePrefix("image-gen-"); + executor.setAllowCoreThreadTimeOut(true); + executor.setKeepAliveSeconds(60); // 큐가 가득 찰 경우 호출한 스레드에서 직접 실행 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); @@ -50,9 +50,11 @@ public Executor imageGenerationExecutor() { public Executor normalizationJobExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(1); - executor.setMaxPoolSize(2); - executor.setQueueCapacity(20); + executor.setMaxPoolSize(1); + executor.setQueueCapacity(5); executor.setThreadNamePrefix("normalization-job-"); + executor.setAllowCoreThreadTimeOut(true); + executor.setKeepAliveSeconds(60); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; diff --git a/src/main/java/com/kw/readwith/service/CharacterImageService.java b/src/main/java/com/kw/readwith/service/CharacterImageService.java index 30cedda..c8f6bb4 100644 --- a/src/main/java/com/kw/readwith/service/CharacterImageService.java +++ b/src/main/java/com/kw/readwith/service/CharacterImageService.java @@ -206,8 +206,8 @@ public GeneratedCharacterImage generateTextImage(Character character) throws IOE } public String uploadGeneratedImage(Character character, byte[] imageData, String s3KeyName) { - String base64Image = Base64.getEncoder().encodeToString(imageData); - return s3Manager.uploadFileFromBase64(s3KeyName, base64Image, "image/png"); + s3Manager.uploadBytes(s3KeyName, imageData, "image/png"); + return s3Manager.getObjectUrl(s3KeyName); } public String buildReferenceCandidateS3KeyName(Character character, Long assetId) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 38823ac..16fc83e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,8 @@ server: port: ${PORT:8080} spring: + main: + lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION:true} datasource: url: ${DATABASE_URL:jdbc:mysql://${AWS_DB_URL}:3306/readwith_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=true&requireSSL=true} username: ${DATABASE_USERNAME:${AWS_DB_USERNAME}} @@ -30,7 +32,7 @@ spring: use_sql_comments: true hbm2ddl: auto: validate - default_batch_fetch_size: 1000 + default_batch_fetch_size: ${HIBERNATE_DEFAULT_BATCH_FETCH_SIZE:100} # 배치 INSERT 최적화 (성능 개선) jdbc: batch_size: 50 @@ -69,6 +71,12 @@ spring: quality: ${CHARACTER_IMAGE_QUALITY:medium} n: ${CHARACTER_IMAGE_COUNT:1} +springdoc: + api-docs: + enabled: ${SPRINGDOC_ENABLED:false} + swagger-ui: + enabled: ${SPRINGDOC_ENABLED:false} + epub: normalization: enabled: ${EPUB_NORMALIZATION_ENABLED:true} diff --git a/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java b/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java index 2b66f77..d04e37b 100644 --- a/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java +++ b/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java @@ -161,7 +161,7 @@ void generateAndSaveImage_succeeds() throws Exception { Files.write(tempImage, new byte[]{1, 2, 3, 4}); when(characterRepository.findByIdWithBook(testCharacter.getId())).thenReturn(Optional.of(testCharacter)); - when(s3Manager.uploadFileFromBase64(anyString(), anyString(), eq("image/png"))) + when(s3Manager.getObjectUrl("character-images/1/10.png")) .thenReturn("https://cdn.readwith.store/character-images/1/10.png"); ImageResponse imageResponse = mock(ImageResponse.class); @@ -183,7 +183,7 @@ void generateAndSaveImage_succeeds() throws Exception { assertThat(imageOptions.getWidth()).isEqualTo(1024); assertThat(imageOptions.getHeight()).isEqualTo(1024); assertThat(imageOptions.getN()).isEqualTo(1); - verify(s3Manager).uploadFileFromBase64(eq("character-images/1/10.png"), anyString(), eq("image/png")); + verify(s3Manager).uploadBytes(eq("character-images/1/10.png"), any(byte[].class), eq("image/png")); verify(transactionService).updateImageAndStatus( testCharacter.getId(), "https://cdn.readwith.store/character-images/1/10.png", @@ -197,7 +197,7 @@ void generateAndSaveImage_uploadsBase64JsonResponse() { byte[] generatedImage = new byte[]{9, 8, 7, 6}; when(characterRepository.findByIdWithBook(testCharacter.getId())).thenReturn(Optional.of(testCharacter)); - when(s3Manager.uploadFileFromBase64(anyString(), anyString(), eq("image/png"))) + when(s3Manager.getObjectUrl("character-images/1/10.png")) .thenReturn("https://cdn.readwith.store/character-images/1/10.png"); ImageResponse imageResponse = mock(ImageResponse.class); @@ -210,13 +210,13 @@ void generateAndSaveImage_uploadsBase64JsonResponse() { characterImageService.generateAndSaveImage(testCharacter.getId()); - ArgumentCaptor base64Captor = ArgumentCaptor.forClass(String.class); - verify(s3Manager).uploadFileFromBase64( + ArgumentCaptor imageCaptor = ArgumentCaptor.forClass(byte[].class); + verify(s3Manager).uploadBytes( eq("character-images/1/10.png"), - base64Captor.capture(), + imageCaptor.capture(), eq("image/png") ); - assertThat(Base64.getDecoder().decode(base64Captor.getValue())).containsExactly(generatedImage); + assertThat(imageCaptor.getValue()).containsExactly(generatedImage); verify(transactionService).updateImageAndStatus( testCharacter.getId(), "https://cdn.readwith.store/character-images/1/10.png",