From 8cc706020eb10c92e8b31821f9c436c25cad299f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:03:09 +0900 Subject: [PATCH 01/54] =?UTF-8?q?[chore]:=20gitignore=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 726f44e..104b6c6 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ out/ /mysql-data-local/ /src/main/resources/.env /src/main/resources/application.yml +/coupon-consumer/src/main/resources/.env +/coupon-api/src/main/resources/.env From 42960492eae2d825ae20ab7563638a07b3d18e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:08:05 +0900 Subject: [PATCH 02/54] =?UTF-8?q?[refactor]:=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=9D=84=20api=EC=99=80=20consumer=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 API 및 도메인 로직을 coupon-api 모듈로 이동 - Kafka Consumer 및 Worker 로직을 coupon-consumer 모듈로 분리 --- coupon-api/Dockerfile | 8 +++++ coupon-api/build.gradle.kts | 33 +++++++++++++++++++ .../CouponsystemApiApplication.kt | 6 ++-- .../coupon/api/controller/CouponController.kt | 24 +++++++------- .../coupon/api/request/CouponCreateRequest.kt | 2 +- .../coupon/api/request/CouponIssueRequest.kt | 2 +- .../api/request/CouponStockInitRequest.kt | 2 +- .../response/CouponIssueResponse.kt | 2 +- .../application/response/CouponResponse.kt | 2 +- .../application/service/CouponIssueService.kt | 9 +++++ .../application/service/CouponService.kt | 20 +++++------ .../service/impl/AtomicCouponIssueService.kt | 18 +++++----- .../impl/AtomicQueryCouponIssueService.kt | 21 ++++++------ .../impl/DistributedLockCouponIssueService.kt | 14 ++++---- .../service/impl/LuaCouponIssueService.kt | 18 +++++----- .../service/impl/NoLockCouponIssueService.kt | 16 ++++----- .../impl/PessimisticLockCouponIssueService.kt | 16 ++++----- .../impl/ReentrantLockCouponIssueService.kt | 20 +++++------ .../impl/SemaphoreCouponIssueService.kt | 8 ++--- .../impl/Synchronized2CouponIssueService.kt | 17 +++++----- .../impl/SynchronizedCouponIssueService.kt | 20 +++++------ .../coupon/domain/entity/Coupon.kt | 8 ++--- .../coupon/domain/entity/IssuedCoupon.kt | 4 +-- .../repository/AppliedUserRepository.kt | 2 +- .../domain/repository/CouponRepository.kt | 4 +-- .../repository/IssuedCouponRepository.kt | 4 +-- .../infra/AppliedUserRedisRepository.kt | 4 +-- .../coupon/infra/CouponRedisRepository.kt | 2 +- .../coupon/infra/JpaCouponRepository.kt | 6 ++-- .../coupon/infra/JpaIssuedCouponRepository.kt | 6 ++-- .../coupon/status/CouponErrorStatus.kt | 4 +-- .../couponsystem/global/annotaion/Bulkhead.kt | 2 +- .../couponsystem/global/aop/BulkheadAspect.kt | 8 ++--- .../global/exception/CustomException.kt | 7 ++++ .../exception/GlobalExceptionHandler.kt | 10 ++---- .../couponsystem/global/jpa/JpaConfig.kt | 2 +- .../global/jpa/PrimaryKeyEntity.kt | 2 +- .../global/response/ApiResponse.kt | 8 ++--- .../global/response/code/BaseCode.kt | 2 +- .../global/response/code/BaseErrorStatus.kt | 2 +- .../global/response/status/ErrorStatus.kt | 4 +-- .../global/response/status/SuccessStatus.kt | 4 +-- .../infra/lock/DistributedLockExecutor.kt | 2 +- .../infra/lock/impl/LettuceLockExecutor.kt | 8 ++--- .../infra/lock/impl/RedissonLockExecutor.kt | 8 ++--- .../couponsystem/infra/redis/RedisConfig.kt | 2 +- .../infra/redis/RedissonConfig.kt | 6 ++-- .../woong2e/couponsystem/user/domin/User.kt | 2 +- .../src}/main/resources/application-local.yml | 0 .../src}/main/resources/application-prod.yml | 26 ++++++++++++--- coupon-api/src/main/resources/application.yml | 5 +++ coupon-api/src/main/resources/sql/data.sql | 22 +++++++++++++ coupon-api/src/main/resources/sql/schema.sql | 30 +++++++++++++++++ .../CouponsystemApiApplicationTests.kt | 6 ++-- .../src}/test/resources/application-test.yml | 0 .../application/service/CouponIssueService.kt | 9 ----- .../global/exception/CustomException.kt | 7 ---- 57 files changed, 307 insertions(+), 199 deletions(-) create mode 100644 coupon-api/Dockerfile create mode 100644 coupon-api/build.gradle.kts rename src/main/kotlin/com/woong2e/couponsystem/CouponsystemApplication.kt => coupon-api/src/main/kotlin/com/woong2e/couponsystem/CouponsystemApiApplication.kt (57%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt (65%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt (56%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt (61%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt (62%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt (64%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt (67%) create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt (64%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt (75%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt (59%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt (61%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt (57%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt (59%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt (60%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt (65%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt (56%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt (59%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt (61%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt (62%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt (84%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt (66%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt (68%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt (61%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt (83%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt (96%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt (80%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt (65%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt (83%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt (77%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt (86%) create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt (77%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt (76%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt (95%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt (85%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt (67%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt (68%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt (87%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt (80%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt (75%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt (88%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt (77%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt (93%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt (72%) rename {src => coupon-api/src}/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt (77%) rename {src => coupon-api/src}/main/resources/application-local.yml (100%) rename {src => coupon-api/src}/main/resources/application-prod.yml (56%) create mode 100644 coupon-api/src/main/resources/application.yml create mode 100644 coupon-api/src/main/resources/sql/data.sql create mode 100644 coupon-api/src/main/resources/sql/schema.sql rename src/test/kotlin/com/woong2e/couponsystem/CouponsystemApplicationTests.kt => coupon-api/src/test/kotlin/com/woong2e/couponsystem/CouponsystemApiApplicationTests.kt (57%) rename {src => coupon-api/src}/test/resources/application-test.yml (100%) delete mode 100644 src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt delete mode 100644 src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt diff --git a/coupon-api/Dockerfile b/coupon-api/Dockerfile new file mode 100644 index 0000000..9c57ff3 --- /dev/null +++ b/coupon-api/Dockerfile @@ -0,0 +1,8 @@ +# coupon-api/Dockerfile +FROM openjdk:21-jdk-slim + +WORKDIR /app + +COPY build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/coupon-api/build.gradle.kts b/coupon-api/build.gradle.kts new file mode 100644 index 0000000..a4b3480 --- /dev/null +++ b/coupon-api/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("org.springframework.boot") + id("io.spring.dependency-management") + kotlin("plugin.jpa") +} + +dependencies { + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib") + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + + implementation("org.springframework.kafka:spring-kafka") + + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-jdbc") + runtimeOnly("com.mysql:mysql-connector-j") + runtimeOnly("com.h2database:h2") + + implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.redisson:redisson-spring-boot-starter:4.1.0") + + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") + + implementation("com.github.f4b6a3:ulid-creator:5.2.2") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} \ No newline at end of file diff --git a/src/main/kotlin/com/woong2e/couponsystem/CouponsystemApplication.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/CouponsystemApiApplication.kt similarity index 57% rename from src/main/kotlin/com/woong2e/couponsystem/CouponsystemApplication.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/CouponsystemApiApplication.kt index b4bed82..495567b 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/CouponsystemApplication.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/CouponsystemApiApplication.kt @@ -1,11 +1,11 @@ -package com.woong2e.couponsystem +package main.kotlin.com.woong2e.couponsystem import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication -class CouponsystemApplication +class CouponsystemApiApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt similarity index 65% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt index 86d4d23..65ec45b 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/controller/CouponController.kt @@ -1,16 +1,16 @@ -package com.woong2e.couponsystem.coupon.api.controller +package main.kotlin.com.woong2e.couponsystem.coupon.api.controller -import com.woong2e.couponsystem.coupon.api.request.CouponCreateRequest -import com.woong2e.couponsystem.coupon.api.request.CouponIssueRequest -import com.woong2e.couponsystem.coupon.api.request.CouponStockInitRequest -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.response.CouponResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.application.service.CouponService -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.global.response.ApiResponse -import com.woong2e.couponsystem.global.response.status.SuccessStatus +import main.kotlin.com.woong2e.couponsystem.coupon.api.request.CouponCreateRequest +import main.kotlin.com.woong2e.couponsystem.coupon.api.request.CouponIssueRequest +import main.kotlin.com.woong2e.couponsystem.coupon.api.request.CouponStockInitRequest +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponService +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.response.ApiResponse +import main.kotlin.com.woong2e.couponsystem.global.response.status.SuccessStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.PathVariable diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt similarity index 56% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt index e9ac79d..981f9b5 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponCreateRequest.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.api.request +package main.kotlin.com.woong2e.couponsystem.coupon.api.request data class CouponCreateRequest( val title: String, diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt similarity index 61% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt index c1479be..c9d3a2b 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponIssueRequest.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.api.request +package main.kotlin.com.woong2e.couponsystem.coupon.api.request import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt similarity index 62% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt index fae2c4f..66fa95c 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/api/request/CouponStockInitRequest.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.api.request +package main.kotlin.com.woong2e.couponsystem.coupon.api.request import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt similarity index 64% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt index 385eab4..792c0b4 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.application.response +package main.kotlin.com.woong2e.couponsystem.coupon.application.response import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt similarity index 67% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt index e9ae8e6..dd2c65d 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponResponse.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.application.response +package main.kotlin.com.woong2e.couponsystem.coupon.application.response import java.util.UUID diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt new file mode 100644 index 0000000..018c30e --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt @@ -0,0 +1,9 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.service + +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import java.util.UUID + +interface CouponIssueService { + + fun issue(couponId: UUID, userId: Long): CouponIssueResponse? +} \ No newline at end of file diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt similarity index 64% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt index 95e6141..48fba8f 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt @@ -1,13 +1,13 @@ -package com.woong2e.couponsystem.coupon.application.service - -import com.woong2e.couponsystem.coupon.api.request.CouponCreateRequest -import com.woong2e.couponsystem.coupon.application.response.CouponResponse -import com.woong2e.couponsystem.coupon.domain.entity.Coupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.infra.CouponRedisRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +package main.kotlin.com.woong2e.couponsystem.coupon.application.service + +import main.kotlin.com.woong2e.couponsystem.coupon.api.request.CouponCreateRequest +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponResponse +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.Coupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.infra.CouponRedisRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt similarity index 75% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt index fc5c854..3572a1b 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicCouponIssueService.kt @@ -1,12 +1,12 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import java.util.UUID @@ -27,7 +27,7 @@ class AtomicCouponIssueService( private val couponCounts = ConcurrentHashMap() - override fun issue(couponId: UUID, userId: Long): CouponIssueResponse { + override fun issue(couponId: UUID, userId: Long): CouponIssueResponse? { val state = couponCounts.computeIfAbsent(couponId) { id -> val coupon = couponRepository.findById(id) diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt similarity index 59% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt index 40135dc..69da56a 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AtomicQueryCouponIssueService.kt @@ -1,15 +1,14 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.annotaion.Bulkhead -import com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.annotaion.Bulkhead +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionTemplate import java.util.UUID @@ -21,7 +20,7 @@ class AtomicQueryCouponIssueService( ) : CouponIssueService { @Bulkhead(permits = 20, fair = true) - override fun issue(couponId: UUID, userId: Long): CouponIssueResponse { + override fun issue(couponId: UUID, userId: Long): CouponIssueResponse? { return transactionTemplate.execute { val affectedRows = couponRepository.increaseIssuedQuantity(couponId) diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt similarity index 61% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt index 54011e1..ebf0733 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/DistributedLockCouponIssueService.kt @@ -1,11 +1,11 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.repository.AppliedUserRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.infra.lock.DistributedLockExecutor +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.AppliedUserRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.infra.lock.DistributedLockExecutor import org.springframework.stereotype.Service import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt similarity index 57% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt index 8c8a0a5..c90cad0 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt @@ -1,13 +1,13 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.infra.CouponRedisRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.global.response.status.ErrorStatus +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.infra.CouponRedisRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus import org.springframework.stereotype.Service import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt similarity index 59% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt index bccad20..5c005bb 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/NoLockCouponIssueService.kt @@ -1,12 +1,12 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt similarity index 60% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt index c4ba976..f1b0131 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/PessimisticLockCouponIssueService.kt @@ -1,12 +1,12 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt similarity index 65% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt index 126fcf5..0838a41 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/ReentrantLockCouponIssueService.kt @@ -1,12 +1,12 @@ -package com.woong2e.couponsystem.coupon.application.service.impl - -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl + +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import java.util.UUID @@ -21,7 +21,7 @@ class ReentrantLockCouponIssueService( private val lock = ReentrantLock(true) - override fun issue(couponId: UUID, userId: Long): CouponIssueResponse { + override fun issue(couponId: UUID, userId: Long): CouponIssueResponse? { lock.lock() try { diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt similarity index 56% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt index ae25758..a98e0f7 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SemaphoreCouponIssueService.kt @@ -1,8 +1,8 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.global.annotaion.Bulkhead +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.global.annotaion.Bulkhead import org.springframework.stereotype.Service import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt similarity index 59% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt index 4c2bb45..f86400f 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/Synchronized2CouponIssueService.kt @@ -1,15 +1,14 @@ -package com.woong2e.couponsystem.coupon.application.service.impl +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate import java.util.UUID @Service("synchronized2") diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt similarity index 61% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt index f525dd6..0c7ef66 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/SynchronizedCouponIssueService.kt @@ -1,12 +1,12 @@ -package com.woong2e.couponsystem.coupon.application.service.impl - -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import com.woong2e.couponsystem.coupon.application.service.CouponIssueService -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl + +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import java.util.UUID @@ -19,7 +19,7 @@ class SynchronizedCouponIssueService( ) : CouponIssueService { @Synchronized - override fun issue(couponId: UUID, userId: Long): CouponIssueResponse { + override fun issue(couponId: UUID, userId: Long): CouponIssueResponse? { return transactionTemplate.execute { val coupon = couponRepository.findById(couponId) diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt similarity index 62% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt index 8060ad3..5b78e15 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt @@ -1,10 +1,10 @@ -package com.woong2e.couponsystem.coupon.domain.entity +package main.kotlin.com.woong2e.couponsystem.coupon.domain.entity -import com.woong2e.couponsystem.coupon.status.CouponErrorStatus -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.global.jpa.PrimaryKeyEntity import jakarta.persistence.Entity import jakarta.persistence.Table +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.jpa.PrimaryKeyEntity @Entity @Table(name = "coupons") diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt similarity index 84% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt index ac2ccf7..6370257 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt @@ -1,7 +1,7 @@ -package com.woong2e.couponsystem.coupon.domain.entity +package main.kotlin.com.woong2e.couponsystem.coupon.domain.entity -import com.woong2e.couponsystem.global.jpa.PrimaryKeyEntity import jakarta.persistence.* +import main.kotlin.com.woong2e.couponsystem.global.jpa.PrimaryKeyEntity import org.springframework.data.annotation.CreatedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDateTime diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt similarity index 66% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt index ee49afa..a1d3e84 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/AppliedUserRepository.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.domain.repository +package main.kotlin.com.woong2e.couponsystem.coupon.domain.repository interface AppliedUserRepository { diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt similarity index 68% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt index 7e765cf..475055e 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/CouponRepository.kt @@ -1,6 +1,6 @@ -package com.woong2e.couponsystem.coupon.domain.repository +package main.kotlin.com.woong2e.couponsystem.coupon.domain.repository -import com.woong2e.couponsystem.coupon.domain.entity.Coupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.Coupon import java.util.Optional import java.util.UUID diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt similarity index 61% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt index 5c529eb..70621ca 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt @@ -1,6 +1,6 @@ -package com.woong2e.couponsystem.coupon.domain.repository +package main.kotlin.com.woong2e.couponsystem.coupon.domain.repository -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon import java.util.UUID interface IssuedCouponRepository { diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt similarity index 83% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt index 0dc2b85..8fba980 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt @@ -1,6 +1,6 @@ -package com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra -import com.woong2e.couponsystem.coupon.domain.repository.AppliedUserRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.AppliedUserRepository import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Repository import java.time.Duration diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt similarity index 96% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt index 090f8de..df8c235 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.core.script.DefaultRedisScript diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt similarity index 80% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt index 9215721..fae7f7e 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt @@ -1,8 +1,8 @@ -package com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra -import com.woong2e.couponsystem.coupon.domain.entity.Coupon -import com.woong2e.couponsystem.coupon.domain.repository.CouponRepository import jakarta.persistence.LockModeType +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.Coupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Modifying diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt similarity index 65% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt index 3452004..ff3d037 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt @@ -1,7 +1,7 @@ -package com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra -import com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon -import com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt similarity index 83% rename from src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt index afc36f9..b261013 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt @@ -1,6 +1,6 @@ -package com.woong2e.couponsystem.coupon.status +package main.kotlin.com.woong2e.couponsystem.coupon.status -import com.woong2e.couponsystem.global.response.code.BaseErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus import org.springframework.http.HttpStatus enum class CouponErrorStatus( diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt similarity index 77% rename from src/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt index 4959f9d..f7cfe5c 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/annotaion/Bulkhead.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.global.annotaion +package main.kotlin.com.woong2e.couponsystem.global.annotaion @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt similarity index 86% rename from src/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt index 3070d42..cb8df2e 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/aop/BulkheadAspect.kt @@ -1,8 +1,8 @@ -package com.woong2e.couponsystem.global.aop +package main.kotlin.com.woong2e.couponsystem.global.aop -import com.woong2e.couponsystem.global.annotaion.Bulkhead -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.global.response.status.ErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.annotaion.Bulkhead +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Aspect diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt new file mode 100644 index 0000000..f4bc0e4 --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt @@ -0,0 +1,7 @@ +package main.kotlin.com.woong2e.couponsystem.global.exception + +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus + +class CustomException( + val errorCode: BaseErrorStatus +) : RuntimeException(errorCode.message) \ No newline at end of file diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt similarity index 77% rename from src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt index ef08744..bc4541a 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt @@ -1,16 +1,12 @@ -package com.woong2e.couponsystem.global.exception +package main.kotlin.com.woong2e.couponsystem.global.exception -import com.woong2e.couponsystem.global.response.ApiResponse -import com.woong2e.couponsystem.global.response.status.ErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.response.ApiResponse +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus import org.slf4j.LoggerFactory -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatusCode import org.springframework.http.ResponseEntity import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.context.request.WebRequest -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler @RestControllerAdvice class GlobalExceptionHandler { diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt similarity index 76% rename from src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt index 8f0b98e..8ddb111 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.global.jpa +package main.kotlin.com.woong2e.couponsystem.global.jpa import org.springframework.context.annotation.Configuration import org.springframework.data.jpa.repository.config.EnableJpaAuditing diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt similarity index 95% rename from src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt index 0d37e99..5cdef72 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.global.jpa +package main.kotlin.com.woong2e.couponsystem.global.jpa import com.github.f4b6a3.ulid.UlidCreator import jakarta.persistence.* diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt similarity index 85% rename from src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt index e842919..feeeb2d 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt @@ -1,15 +1,15 @@ -package com.woong2e.couponsystem.global.response +package main.kotlin.com.woong2e.couponsystem.global.response import com.fasterxml.jackson.annotation.JsonInclude -import com.woong2e.couponsystem.global.response.code.BaseCode -import com.woong2e.couponsystem.global.response.code.BaseErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseCode +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus import org.springframework.http.ResponseEntity data class ApiResponse( val isSuccess: Boolean, val code: String, val message: String, - @JsonInclude(JsonInclude.Include.NON_NULL) + @param:JsonInclude(JsonInclude.Include.NON_NULL) val result: T? = null ) { companion object { diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt similarity index 67% rename from src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt index c7729ee..2f0b6fa 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.global.response.code +package main.kotlin.com.woong2e.couponsystem.global.response.code import org.springframework.http.HttpStatus diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt similarity index 68% rename from src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt index 5b93244..cfa3cdf 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.global.response.code +package main.kotlin.com.woong2e.couponsystem.global.response.code import org.springframework.http.HttpStatus diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt similarity index 87% rename from src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt index 0bef3ba..a3147f8 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt @@ -1,6 +1,6 @@ -package com.woong2e.couponsystem.global.response.status +package main.kotlin.com.woong2e.couponsystem.global.response.status -import com.woong2e.couponsystem.global.response.code.BaseErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus import org.springframework.http.HttpStatus enum class ErrorStatus( diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt similarity index 80% rename from src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt index 75f0754..633eea3 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt @@ -1,6 +1,6 @@ -package com.woong2e.couponsystem.global.response.status +package main.kotlin.com.woong2e.couponsystem.global.response.status -import com.woong2e.couponsystem.global.response.code.BaseCode +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseCode import org.springframework.http.HttpStatus enum class SuccessStatus( diff --git a/src/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt similarity index 75% rename from src/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt index 3b2d71b..204ac07 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/DistributedLockExecutor.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.infra.lock +package main.kotlin.com.woong2e.couponsystem.infra.lock interface DistributedLockExecutor { diff --git a/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt similarity index 88% rename from src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt index c8fbc0e..f245a7b 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/LettuceLockExecutor.kt @@ -1,8 +1,8 @@ -package com.woong2e.couponsystem.infra.lock.impl +package main.kotlin.com.woong2e.couponsystem.infra.lock.impl -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.global.response.status.ErrorStatus -import com.woong2e.couponsystem.infra.lock.DistributedLockExecutor +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus +import main.kotlin.com.woong2e.couponsystem.infra.lock.DistributedLockExecutor import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.core.script.DefaultRedisScript import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt similarity index 77% rename from src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt index c9eebfc..275c88a 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/lock/impl/RedissonLockExecutor.kt @@ -1,8 +1,8 @@ -package com.woong2e.couponsystem.infra.lock.impl +package main.kotlin.com.woong2e.couponsystem.infra.lock.impl -import com.woong2e.couponsystem.global.exception.CustomException -import com.woong2e.couponsystem.global.response.status.ErrorStatus -import com.woong2e.couponsystem.infra.lock.DistributedLockExecutor +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus +import main.kotlin.com.woong2e.couponsystem.infra.lock.DistributedLockExecutor import org.redisson.api.RedissonClient import org.springframework.context.annotation.Primary import org.springframework.stereotype.Component diff --git a/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt similarity index 93% rename from src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt index 4e23f5f..3bcaa2b 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedisConfig.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.infra.redis +package main.kotlin.com.woong2e.couponsystem.infra.redis import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt similarity index 72% rename from src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt index c0953d4..ec1a92e 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/redis/RedissonConfig.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.infra.redis +package main.kotlin.com.woong2e.couponsystem.infra.redis import org.redisson.Redisson import org.redisson.api.RedissonClient @@ -9,8 +9,8 @@ import org.springframework.context.annotation.Configuration @Configuration class RedissonConfig( - @Value("\${spring.data.redis.host}") private val host: String, - @Value("\${spring.data.redis.port}") private val port: Int + @param:Value("\${spring.data.redis.host}") private val host: String, + @param:Value("\${spring.data.redis.port}") private val port: Int ) { @Bean diff --git a/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt similarity index 77% rename from src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt index 892fd33..dcfb3c4 100644 --- a/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt @@ -1,4 +1,4 @@ -package com.woong2e.couponsystem.user.domin +package main.kotlin.com.woong2e.couponsystem.user.domin import jakarta.persistence.* diff --git a/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml similarity index 100% rename from src/main/resources/application-local.yml rename to coupon-api/src/main/resources/application-local.yml diff --git a/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml similarity index 56% rename from src/main/resources/application-prod.yml rename to coupon-api/src/main/resources/application-prod.yml index c7ec4a6..5c16efd 100644 --- a/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -1,22 +1,35 @@ +server: + port: 8080 + spring: + application: + name: coupon-api # 모니터링 시 식별용 이름 config: activate: on-profile: prod + # Kafka Producer 설정 + kafka: + bootstrap-servers: kafka:29092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all + retries: 10 + properties: + enable.idempotence: true + datasource: url: ${DATABASE_URL} username: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - hikari: - maximum-pool-size: 15 - connection-timeout: 30000 jpa: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect - format_sql: true + format_sql: false use_sql_comments: false hibernate: ddl-auto: validate @@ -37,4 +50,7 @@ management: include: prometheus, health, info endpoint: prometheus: - enabled: true \ No newline at end of file + enabled: true + metrics: + tags: + application: coupon-api \ No newline at end of file diff --git a/coupon-api/src/main/resources/application.yml b/coupon-api/src/main/resources/application.yml new file mode 100644 index 0000000..ef1a90f --- /dev/null +++ b/coupon-api/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: couponsystem + profiles: + active: local # 기본적으로 'local' 프로필을 사용 (RDS 쓸 땐 'prod'로 변경) diff --git a/coupon-api/src/main/resources/sql/data.sql b/coupon-api/src/main/resources/sql/data.sql new file mode 100644 index 0000000..58de4b9 --- /dev/null +++ b/coupon-api/src/main/resources/sql/data.sql @@ -0,0 +1,22 @@ +-- 1. 기존 데이터 초기화 (ID를 1번부터 다시 시작하기 위해 TRUNCATE 사용) +SET FOREIGN_KEY_CHECKS = 0; -- 혹시 모를 제약조건 잠시 해제 +TRUNCATE TABLE issued_coupons; +TRUNCATE TABLE coupons; +TRUNCATE TABLE users; +SET FOREIGN_KEY_CHECKS = 1; + +-- 2. 쿠폰 생성 (선착순 100명) +INSERT INTO coupons (title, total_quantity, issued_quantity) +VALUES ('선착순 100명 치킨', 100, 0); + +-- 3. 유저 10,000명 생성 (Recursive CTE 활용) +-- MySQL 8.0의 재귀 제한(기본 1000)을 늘려줍니다. +SET SESSION cte_max_recursion_depth = 1000000; + +INSERT INTO users (name) +WITH RECURSIVE sequence AS ( + SELECT 1 AS n + UNION ALL + SELECT n + 1 FROM sequence WHERE n < 10000 +) +SELECT CONCAT('User_', n) FROM sequence; \ No newline at end of file diff --git a/coupon-api/src/main/resources/sql/schema.sql b/coupon-api/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..588f130 --- /dev/null +++ b/coupon-api/src/main/resources/sql/schema.sql @@ -0,0 +1,30 @@ +DROP TABLE IF EXISTS issued_coupons; +DROP TABLE IF EXISTS coupons; +DROP TABLE IF EXISTS users; + +-- 1. 사용자 테이블 +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. 쿠폰 정책 테이블 +CREATE TABLE coupons ( + id BINARY(16) PRIMARY KEY COMMENT 'ULID (UUID)', + title VARCHAR(100) NOT NULL, + total_quantity INT NOT NULL, + issued_quantity INT NOT NULL DEFAULT 0, + created_at DATETIME(6), + updated_at DATETIME(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. 발급 이력 +CREATE TABLE issued_coupons ( + id BINARY(16) PRIMARY KEY COMMENT 'ULID', + coupon_id BINARY(16) NOT NULL COMMENT 'UUID', + user_id BIGINT NOT NULL COMMENT 'Long (숫자)', + status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', + issued_at DATETIME(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- CREATE UNIQUE KEY uk_coupon_user (coupon_id, user_id); \ No newline at end of file diff --git a/src/test/kotlin/com/woong2e/couponsystem/CouponsystemApplicationTests.kt b/coupon-api/src/test/kotlin/com/woong2e/couponsystem/CouponsystemApiApplicationTests.kt similarity index 57% rename from src/test/kotlin/com/woong2e/couponsystem/CouponsystemApplicationTests.kt rename to coupon-api/src/test/kotlin/com/woong2e/couponsystem/CouponsystemApiApplicationTests.kt index f463f3b..ca2ada7 100644 --- a/src/test/kotlin/com/woong2e/couponsystem/CouponsystemApplicationTests.kt +++ b/coupon-api/src/test/kotlin/com/woong2e/couponsystem/CouponsystemApiApplicationTests.kt @@ -1,12 +1,12 @@ -package com.woong2e.couponsystem +package test.kotlin.com.woong2e.couponsystem import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles -@SpringBootTest +@SpringBootTest(classes = [CouponsystemApiApplicationTests::class]) @ActiveProfiles("test") -class CouponsystemApplicationTests { +class CouponsystemApiApplicationTests { @Test fun contextLoads() { diff --git a/src/test/resources/application-test.yml b/coupon-api/src/test/resources/application-test.yml similarity index 100% rename from src/test/resources/application-test.yml rename to coupon-api/src/test/resources/application-test.yml diff --git a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt b/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt deleted file mode 100644 index d7717d9..0000000 --- a/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.woong2e.couponsystem.coupon.application.service - -import com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse -import java.util.UUID - -interface CouponIssueService { - - fun issue(couponId: UUID, userId: Long): CouponIssueResponse -} \ No newline at end of file diff --git a/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt b/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt deleted file mode 100644 index 088f5df..0000000 --- a/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.woong2e.couponsystem.global.exception - -import com.woong2e.couponsystem.global.response.code.BaseErrorStatus - -class CustomException( - val errorCode: BaseErrorStatus -) : RuntimeException(errorCode.message) \ No newline at end of file From eafa4efcad4a8a7b8f4c7a87fd5117885cf8604e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:09:37 +0900 Subject: [PATCH 03/54] =?UTF-8?q?[config]:=20consumer=20application.yml=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coupon-consumer: Kafka Consumer, Ack-mode, Redis 설정 추가 --- .../src/main/resources/application-local.yml | 33 +++++++++++ .../src/main/resources/application-prod.yml | 59 +++++++++++++++++++ .../src/main/resources/application.yml | 5 ++ .../src/test/resources/application-test.yml | 20 +++++++ 4 files changed, 117 insertions(+) create mode 100644 coupon-consumer/src/main/resources/application-local.yml create mode 100644 coupon-consumer/src/main/resources/application-prod.yml create mode 100644 coupon-consumer/src/main/resources/application.yml create mode 100644 coupon-consumer/src/test/resources/application-test.yml diff --git a/coupon-consumer/src/main/resources/application-local.yml b/coupon-consumer/src/main/resources/application-local.yml new file mode 100644 index 0000000..7a87396 --- /dev/null +++ b/coupon-consumer/src/main/resources/application-local.yml @@ -0,0 +1,33 @@ +spring: + datasource: + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + use_sql_comments: false + hibernate: + ddl-auto: validate + defer-datasource-initialization: true + open-in-view: false + generate-ddl: false + show-sql: true + + data: + redis: + host: localhost + port: 6379 + +management: + endpoints: + web: + exposure: + include: prometheus, health, info + endpoint: + prometheus: + enabled: true \ No newline at end of file diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e368edd --- /dev/null +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -0,0 +1,59 @@ +server: + port: 8081 + +spring: + config: + activate: + on-profile: prod + + kafka: + bootstrap-servers: kafka:29092 + consumer: + group-id: coupon-worker-group + auto-offset-reset: latest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + listener: + ack-mode: record + + datasource: + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 20 + connection-timeout: 30000 + + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + use_sql_comments: false + hibernate: + ddl-auto: validate + defer-datasource-initialization: true + open-in-view: false + generate-ddl: false + show-sql: false + + data: + redis: + host: redis + port: 6379 + +management: + endpoints: + web: + exposure: + include: prometheus, health, info + endpoint: + prometheus: + enabled: true + metrics: + tags: + application: coupon-consumer \ No newline at end of file diff --git a/coupon-consumer/src/main/resources/application.yml b/coupon-consumer/src/main/resources/application.yml new file mode 100644 index 0000000..ef1a90f --- /dev/null +++ b/coupon-consumer/src/main/resources/application.yml @@ -0,0 +1,5 @@ +spring: + application: + name: couponsystem + profiles: + active: local # 기본적으로 'local' 프로필을 사용 (RDS 쓸 땐 'prod'로 변경) diff --git a/coupon-consumer/src/test/resources/application-test.yml b/coupon-consumer/src/test/resources/application-test.yml new file mode 100644 index 0000000..1abb2d0 --- /dev/null +++ b/coupon-consumer/src/test/resources/application-test.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + database-platform: org.hibernate.dialect.H2Dialect + + data: + redis: + host: localhost + port: 6379 \ No newline at end of file From b6c401d55141f2e50a232eeccf6d0728b938cdf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:10:37 +0900 Subject: [PATCH 04/54] =?UTF-8?q?[ci]:=20=EB=A9=80=ED=8B=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B9=8C=EB=93=9C=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 58 +++++++++++++++++++++++-------- coupon-consumer/Dockerfile | 8 +++++ docker-compose/docker-compose.yml | 25 +++++++++---- 3 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 coupon-consumer/Dockerfile diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7b85bd2..6f77e3e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -40,26 +40,42 @@ jobs: - name: Build & Run Tests run: ./gradlew clean build - - name: Upload Build Artifact + # [API] JAR 파일 업로드 + - name: Upload API Artifact uses: actions/upload-artifact@v4 with: - name: build-artifacts - path: build/libs/*.jar + name: api-jar + path: coupon-api/build/libs/*.jar + + # [Consumer] JAR 파일 업로드 + - name: Upload Consumer Artifact + uses: actions/upload-artifact@v4 + with: + name: consumer-jar + path: coupon-consumer/build/libs/*.jar # 2. 도커 이미지 빌드 및 푸시 docker-build-and-push: - name: Build & Push Docker Image + name: Build & Push Docker Images runs-on: ubuntu-latest needs: build-and-test steps: - name: Checkout Repository uses: actions/checkout@v4 - - name: Download Build Artifact + # (1) API JAR 다운로드 + - name: Download API Artifact + uses: actions/download-artifact@v4 + with: + name: api-jar + path: coupon-api/build/libs/ + + # (2) Consumer JAR 다운로드 + - name: Download Consumer Artifact uses: actions/download-artifact@v4 with: - name: build-artifacts - path: build/libs/ + name: consumer-jar + path: coupon-consumer/build/libs/ - name: Log in to Docker Hub uses: docker/login-action@v3 @@ -67,10 +83,17 @@ jobs: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - name: Build and Push Docker Image + # (3) API 이미지 빌드 & 푸시 + - name: Build & Push API Image + run: | + docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/coupon-api:latest -f coupon-api/Dockerfile coupon-api + docker push ${{ secrets.DOCKER_HUB_USERNAME }}/coupon-api:latest + + # (4) Consumer 이미지 빌드 & 푸시 + - name: Build & Push Consumer Image run: | - docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/couponsystem:latest . - docker push ${{ secrets.DOCKER_HUB_USERNAME }}/couponsystem:latest + docker build -t ${{ secrets.DOCKER_HUB_USERNAME }}/coupon-consumer:latest -f coupon-consumer/Dockerfile coupon-consumer + docker push ${{ secrets.DOCKER_HUB_USERNAME }}/coupon-consumer:latest # 3. 서버 배포 deploy: @@ -84,18 +107,19 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - # (1) 설정 파일들 서버로 전송 (SCP) + # (1) 설정 파일 전송 (docker-compose 폴더 등) - name: Copy Config Files via SCP uses: appleboy/scp-action@master with: host: ${{ secrets.AWS_EC2_HOST }} username: ${{ secrets.AWS_EC2_USER }} key: ${{ secrets.AWS_SSH_PRIVATE_KEY }} + # docker-compose 폴더, nginx, deploy 스크립트 등 필요한 파일 전송 source: "docker-compose/*,nginx/*,deploy/*" target: "/home/${{ secrets.AWS_EC2_USER }}/deploy" strip_components: 1 - # (2) 서버 접속, .env 생성 및 배포 스크립트 실행 + # (2) 배포 스크립트 실행 - name: Execute Deploy Script uses: appleboy/ssh-action@v1.0.3 env: @@ -107,8 +131,14 @@ jobs: envs: ENV_FILE_CONTENT script: | cd /home/${{ secrets.AWS_EC2_USER }}/deploy - + + # .env 파일 생성 echo "$ENV_FILE_CONTENT" > .env - + + # 스크립트 위치 확인 및 실행 + if [ -f "deploy/deploy.sh" ]; then + cd deploy + fi + chmod +x deploy.sh ./deploy.sh \ No newline at end of file diff --git a/coupon-consumer/Dockerfile b/coupon-consumer/Dockerfile new file mode 100644 index 0000000..faee936 --- /dev/null +++ b/coupon-consumer/Dockerfile @@ -0,0 +1,8 @@ +# coupon-consumer/Dockerfile +FROM openjdk:21-jdk-slim + +WORKDIR /app + +COPY build/libs/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 4837732..6835686 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -15,7 +15,7 @@ services: networks: - app-network depends_on: - - app + - api-server restart: always # 2. Certbot @@ -61,22 +61,35 @@ services: networks: - app-network - # 4. Spring Boot App - app: - image: ${DOCKER_HUB_USERNAME}/couponsystem:latest + # 4. [API] Producer Server + api-server: + image: ${DOCKER_HUB_USERNAME}/coupon-api:latest restart: always deploy: replicas: 2 env_file: - .env environment: - - JAVA_TOOL_OPTIONS=-Xms512m -Xmx800m + - JAVA_TOOL_OPTIONS=-Xms512m -Xmx512m ports: - "8081-8082:8080" networks: - app-network -# ✅ 네트워크 설정 변경 + # 5. [Worker] Consumer Server + consumer-worker: + image: ${DOCKER_HUB_USERNAME}/coupon-consumer:latest + container_name: consumer-worker + restart: always + deploy: + replicas: 1 + env_file: + - .env + environment: + - JAVA_TOOL_OPTIONS=-Xms400m -Xmx400m + networks: + - app-network + networks: app-network: external: true From 5bdf5a553d08a2c115b1f0f6969b2682044f34b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:11:16 +0900 Subject: [PATCH 05/54] =?UTF-8?q?[feat]:=20coupon-consumer=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4=20(#1?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CouponsystemConsumerApplication.kt | 11 ++++ .../coupon/domain/entity/Coupon.kt | 27 ++++++++++ .../coupon/domain/entity/IssuedCoupon.kt | 27 ++++++++++ .../coupon/status/CouponErrorStatus.kt | 17 +++++++ .../global/exception/CustomException.kt | 7 +++ .../exception/GlobalExceptionHandler.kt | 37 ++++++++++++++ .../couponsystem/global/jpa/JpaConfig.kt | 8 +++ .../global/jpa/PrimaryKeyEntity.kt | 43 ++++++++++++++++ .../global/response/ApiResponse.kt | 50 +++++++++++++++++++ .../global/response/code/BaseCode.kt | 9 ++++ .../global/response/code/BaseErrorStatus.kt | 9 ++++ .../global/response/status/ErrorStatus.kt | 19 +++++++ .../global/response/status/SuccessStatus.kt | 18 +++++++ .../woong2e/couponsystem/user/domin/User.kt | 13 +++++ .../CouponsystemConsumerApplicationTests.kt | 16 ++++++ 15 files changed, 311 insertions(+) create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplication.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt create mode 100644 coupon-consumer/src/test/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplicationTests.kt diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplication.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplication.kt new file mode 100644 index 0000000..d9d00c8 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplication.kt @@ -0,0 +1,11 @@ +package main.kotlin.com.woong2e.couponsystem + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class CouponsystemConsumerApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt new file mode 100644 index 0000000..5b78e15 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/Coupon.kt @@ -0,0 +1,27 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.domain.entity + +import jakarta.persistence.Entity +import jakarta.persistence.Table +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.jpa.PrimaryKeyEntity + +@Entity +@Table(name = "coupons") +class Coupon( + val title: String, + val totalQuantity: Int, + var issuedQuantity: Int = 0 +) : PrimaryKeyEntity() { + + fun available(): Boolean { + return issuedQuantity < totalQuantity + } + + fun issue() { + if (!available()) { + throw CustomException(CouponErrorStatus.COUPON_OUT_OF_STOCK) + } + this.issuedQuantity++ + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt new file mode 100644 index 0000000..6370257 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt @@ -0,0 +1,27 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.domain.entity + +import jakarta.persistence.* +import main.kotlin.com.woong2e.couponsystem.global.jpa.PrimaryKeyEntity +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime +import java.util.* + +@Entity +@Table(name = "issued_coupons") +@EntityListeners(AuditingEntityListener::class) +class IssuedCoupon( + @Column(name = "coupon_id", nullable = false, columnDefinition = "BINARY(16)") + val couponId: UUID, + + @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + val userId: Long +) : PrimaryKeyEntity() { + + @Column(nullable = false) + var status: String = "ISSUED" + + @CreatedDate + @Column(name = "issued_at", updatable = false) + var issuedAt: LocalDateTime? = null +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt new file mode 100644 index 0000000..b261013 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/status/CouponErrorStatus.kt @@ -0,0 +1,17 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.status + +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus +import org.springframework.http.HttpStatus + +enum class CouponErrorStatus( + override val status: HttpStatus, + override val code: String, + override val message: String +) : BaseErrorStatus { + + COUPON_NOT_FOUND(HttpStatus.NOT_FOUND, "C001", "존재하지 않는 쿠폰입니다."), + COUPON_ALREADY_ISSUED(HttpStatus.CONFLICT, "C002", "이미 쿠폰이 발급되었습니다."), + COUPON_OUT_OF_STOCK(HttpStatus.CONFLICT, "C003", "쿠폰이 모두 소진되었습니다."), + INVALID_COUPON_ISSUE_SERVICE_TYPE(HttpStatus.BAD_REQUEST, "C004", "지원하지 않는 쿠폰 발급 방식입니다."), + DUPLICATED_COUPON_ISSUE(HttpStatus.CONFLICT, "C004", "이미 발급된 쿠폰입니다."); +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt new file mode 100644 index 0000000..f4bc0e4 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/CustomException.kt @@ -0,0 +1,7 @@ +package main.kotlin.com.woong2e.couponsystem.global.exception + +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus + +class CustomException( + val errorCode: BaseErrorStatus +) : RuntimeException(errorCode.message) \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..bc4541a --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/exception/GlobalExceptionHandler.kt @@ -0,0 +1,37 @@ +package main.kotlin.com.woong2e.couponsystem.global.exception + +import main.kotlin.com.woong2e.couponsystem.global.response.ApiResponse +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + + private val log = LoggerFactory.getLogger(this::class.java) + + // [1] 커스텀 예외 + @ExceptionHandler(CustomException::class) + fun handleCustomException(e: CustomException): ResponseEntity> { + log.warn("CustomException: {}", e.errorCode.message) + return ApiResponse.onFailure(e.errorCode) + } + + // [2] 유효성 검사 실패 (@Valid) + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationException(ex: MethodArgumentNotValidException): ResponseEntity> { + val errorMessage = ex.bindingResult.allErrors.firstOrNull()?.defaultMessage ?: "요청 입력값이 올바르지 않습니다." + log.warn("Validation Failed: {}", errorMessage) + return ApiResponse.onFailure(ErrorStatus.INVALID_INPUT_VALUE, errorMessage) + } + + // [3] 나머지 예외 처리 + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity> { + log.error("UnhandledException: ", e) + return ApiResponse.onFailure(ErrorStatus.INTERNAL_SERVER_ERROR) + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt new file mode 100644 index 0000000..8ddb111 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/JpaConfig.kt @@ -0,0 +1,8 @@ +package main.kotlin.com.woong2e.couponsystem.global.jpa + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaConfig \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt new file mode 100644 index 0000000..5cdef72 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/jpa/PrimaryKeyEntity.kt @@ -0,0 +1,43 @@ +package main.kotlin.com.woong2e.couponsystem.global.jpa + +import com.github.f4b6a3.ulid.UlidCreator +import jakarta.persistence.* +import org.hibernate.proxy.HibernateProxy +import org.springframework.data.domain.Persistable +import java.util.* + +@MappedSuperclass +abstract class PrimaryKeyEntity : Persistable { + @Id + @Column(columnDefinition = "BINARY(16)") + private val id: UUID = UlidCreator.getMonotonicUlid().toUuid() + + @Transient + private var _isNew = true + + override fun getId(): UUID = id + + override fun isNew(): Boolean = _isNew + + override fun equals(other: Any?): Boolean { + if (other == null) return false + if (other !is HibernateProxy && this::class != other::class) return false + return id == getIdentifier(other) + } + + private fun getIdentifier(obj: Any): UUID { + return if (obj is HibernateProxy) { + obj.hibernateLazyInitializer.identifier as UUID + } else { + (obj as PrimaryKeyEntity).id + } + } + + override fun hashCode() = Objects.hashCode(id) + + @PostPersist + @PostLoad + protected fun load() { + _isNew = false + } +} diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt new file mode 100644 index 0000000..feeeb2d --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/ApiResponse.kt @@ -0,0 +1,50 @@ +package main.kotlin.com.woong2e.couponsystem.global.response + +import com.fasterxml.jackson.annotation.JsonInclude +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseCode +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus +import org.springframework.http.ResponseEntity + +data class ApiResponse( + val isSuccess: Boolean, + val code: String, + val message: String, + @param:JsonInclude(JsonInclude.Include.NON_NULL) + val result: T? = null +) { + companion object { + // [성공] 데이터가 있는 경우 & 없는 경우 (result 기본값 null 활용) + fun onSuccess(code: + BaseCode, result: T? = null): ResponseEntity> { + return ResponseEntity + .status(code.status) + .body( + ApiResponse( + isSuccess = true, + code = code.code, + message = code.message, + result = result + ) + ) + } + + // [실패] 커스텀 메시지 없이 기본 메시지 사용 + fun onFailure(code: BaseErrorStatus): ResponseEntity> { + return onFailure(code, code.message) + } + + // [실패] 커스텀 메시지 사용 + fun onFailure(code: BaseErrorStatus, message: String): ResponseEntity> { + return ResponseEntity + .status(code.status) + .body( + ApiResponse( + isSuccess = false, + code = code.code, + message = message, + result = null + ) + ) + } + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt new file mode 100644 index 0000000..2f0b6fa --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseCode.kt @@ -0,0 +1,9 @@ +package main.kotlin.com.woong2e.couponsystem.global.response.code + +import org.springframework.http.HttpStatus + +interface BaseCode { + val status: HttpStatus + val code: String + val message: String +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt new file mode 100644 index 0000000..cfa3cdf --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/code/BaseErrorStatus.kt @@ -0,0 +1,9 @@ +package main.kotlin.com.woong2e.couponsystem.global.response.code + +import org.springframework.http.HttpStatus + +interface BaseErrorStatus { + val status: HttpStatus + val code: String + val message: String +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt new file mode 100644 index 0000000..a3147f8 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/ErrorStatus.kt @@ -0,0 +1,19 @@ +package main.kotlin.com.woong2e.couponsystem.global.response.status + +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseErrorStatus +import org.springframework.http.HttpStatus + +enum class ErrorStatus( + override val status: HttpStatus, + override val code: String, + override val message: String + ) : BaseErrorStatus { + + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류입니다."), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "올바르지 않은 입력값입니다."), + SYSTEM_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."), + // 세마포어/트래픽 초과 시 (Fail-Fast 또는 타임아웃 발생 시) + SYSTEM_BUSY(HttpStatus.SERVICE_UNAVAILABLE, "S002", "현재 접속량이 많아 처리가 지연되고 있습니다. 잠시 후 다시 시도해주세요."), + // 대기 시간 초과 (Blocking 방식에서 타임아웃 발생 시) + COUPON_ISSUE_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "S003", "대기 시간이 초과되었습니다. 다시 시도해주세요."); +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt new file mode 100644 index 0000000..633eea3 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/global/response/status/SuccessStatus.kt @@ -0,0 +1,18 @@ +package main.kotlin.com.woong2e.couponsystem.global.response.status + +import main.kotlin.com.woong2e.couponsystem.global.response.code.BaseCode +import org.springframework.http.HttpStatus + +enum class SuccessStatus( + override val status: HttpStatus, + override val code: String, + override val message: String +) : BaseCode { + + // 일반적인 성공 (200 OK) + OK(HttpStatus.OK, "200", "요청이 성공적으로 처리되었습니다."), + // 생성 성공 (201 Created) + CREATED(HttpStatus.CREATED, "201", "리소스가 성공적으로 생성되었습니다."), + // 삭제 요청 성공 (204 NO_CONTENT) + NO_CONTENT(HttpStatus.NO_CONTENT, "204", "요청한 리소스가 성공적으로 삭제되었습니다."); +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt new file mode 100644 index 0000000..dcfb3c4 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/user/domin/User.kt @@ -0,0 +1,13 @@ +package main.kotlin.com.woong2e.couponsystem.user.domin + +import jakarta.persistence.* + +@Entity +@Table(name = "users") +class User( + val name: String +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null +} \ No newline at end of file diff --git a/coupon-consumer/src/test/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplicationTests.kt b/coupon-consumer/src/test/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplicationTests.kt new file mode 100644 index 0000000..cde211d --- /dev/null +++ b/coupon-consumer/src/test/kotlin/com/woong2e/couponsystem/CouponsystemConsumerApplicationTests.kt @@ -0,0 +1,16 @@ +package test.kotlin.com.woong2e.couponsystem + +import main.kotlin.com.woong2e.couponsystem.CouponsystemConsumerApplication +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles + +@SpringBootTest(classes = [CouponsystemConsumerApplication::class]) +@ActiveProfiles("test") +class CouponsystemConsumerApplicationTests { + + @Test + fun contextLoads() { + } + +} From f6c4015e41e64c893145815726491578e87ce495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:11:57 +0900 Subject: [PATCH 06/54] =?UTF-8?q?[build]:=20=EB=A9=80=ED=8B=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20Gradle=20=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AA=A8=EB=93=88=EC=9D=84=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 91 ++++++++++++++------------------ coupon-consumer/build.gradle.kts | 30 +++++++++++ settings.gradle.kts | 5 +- 3 files changed, 74 insertions(+), 52 deletions(-) create mode 100644 coupon-consumer/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index dfd0bac..e138a51 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,57 +1,46 @@ -plugins { - kotlin("jvm") version "2.2.21" - kotlin("plugin.spring") version "2.2.21" - kotlin("plugin.jpa") version "2.2.21" - id("org.springframework.boot") version "4.0.0" - id("io.spring.dependency-management") version "1.1.7" -} +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -group = "com.woong2e" -version = "0.0.1-SNAPSHOT" -description = "FCFS-EventSystem" - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() +plugins { + kotlin("jvm") version "2.2.21" apply false + kotlin("plugin.spring") version "2.2.21" apply false + kotlin("plugin.jpa") version "2.2.21" apply false + id("org.springframework.boot") version "4.0.0" apply false + id("io.spring.dependency-management") version "1.1.7" apply false } -dependencies { - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("org.jetbrains.kotlin:kotlin-stdlib") +allprojects { + group = "com.woong2e" + version = "0.0.1-SNAPSHOT" - implementation("org.springframework.boot:spring-boot-starter-jdbc") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - runtimeOnly("com.mysql:mysql-connector-j") - runtimeOnly("com.h2database:h2") - - implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("io.micrometer:micrometer-registry-prometheus") - - implementation("com.github.f4b6a3:ulid-creator:5.2.2") - - implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.redisson:redisson-spring-boot-starter:4.1.0") - - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + repositories { + mavenCentral() + } } -kotlin { - compilerOptions { - freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") - } -} - -tasks.withType { - useJUnitPlatform() -} +subprojects { + // 플러그인 적용 (ID 문자열 사용) + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "org.jetbrains.kotlin.plugin.spring") + + // Java Toolchain 설정 + configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + } + + // 컴파일 옵션 + tasks.withType { + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + jvmTarget.set(JvmTarget.JVM_21) + } + } + + tasks.withType { + useJUnitPlatform() + } +} \ No newline at end of file diff --git a/coupon-consumer/build.gradle.kts b/coupon-consumer/build.gradle.kts new file mode 100644 index 0000000..82332c3 --- /dev/null +++ b/coupon-consumer/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("org.springframework.boot") + id("io.spring.dependency-management") + kotlin("plugin.jpa") +} + +dependencies { + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib") + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") + + implementation("org.springframework.kafka:spring-kafka") + + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-jdbc") + runtimeOnly("com.mysql:mysql-connector-j") + runtimeOnly("com.h2database:h2") + + implementation("org.springframework.boot:spring-boot-starter-data-redis") + + implementation("com.github.f4b6a3:ulid-creator:5.2.2") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 43a4704..067996f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,4 @@ -rootProject.name = "couponsystem" \ No newline at end of file +rootProject.name = "couponsystem" + +include("coupon-api") +include("coupon-consumer") \ No newline at end of file From db9aa5359c3fec4d949dfc66c65d553913d7aca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:12:23 +0900 Subject: [PATCH 07/54] =?UTF-8?q?[chore]:=20=EB=B0=B0=ED=8F=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/deploy.sh | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 2b92a61..49afef7 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -16,24 +16,42 @@ echo "🚀 배포 시작..." # 2. ✅ 공용 네트워크 생성 docker network create app-network 2>/dev/null || true -# 3. ✅DB & Redis 인프라 배포 (변경 사항이 있을 때만 재시작됨) +# 3. ✅ Kafka 인프라 배포 (Consumer 실행을 위해 필수!) +if [ -f "docker-compose-kafka.yml" ]; then + echo "🐦 Kafka 인프라 배포 중..." + docker-compose -f docker-compose-kafka.yml pull + docker-compose -f docker-compose-kafka.yml up -d + + echo "⏳ Kafka 초기화 대기 (10초)..." + sleep 10 +else + echo "⚠️ docker-compose-kafka.yml 없음. Kafka 배포 스킵." +fi + +# 4. ✅ DB & Redis 인프라 배포 if [ -f "docker-compose-database.yml" ]; then + echo "💾 DB & Redis 인프라 배포 중..." docker-compose -f docker-compose-database.yml pull docker-compose -f docker-compose-database.yml up -d else - echo "⚠️ docker-compose-database.yml 파일이 없습니다. DB 인프라 배포 스킵." + echo "⚠️ docker-compose-database.yml 없음. DB 배포 스킵." fi -# 4. App 및 Nginx 배포 -echo "Start Application..." +# 5. App 및 Nginx 배포 +echo "☕ Application (API & Worker) 배포 시작..." -# (1) 최신 이미지 Pull (App만) -docker-compose -f docker-compose.yml pull app +if [ -f "docker-compose.yml" ]; then + # (1) 최신 이미지 Pull (API, Consumer, Nginx 등 모두 다운로드) + docker-compose -f docker-compose.yml pull -# (2) 컨테이너 실행 -docker-compose -f docker-compose.yml up -d --scale app=2 + # (2) 컨테이너 실행 + docker-compose -f docker-compose.yml up -d --remove-orphans --scale api-server=2 +else + echo "❌ docker-compose.yml 파일이 없습니다!" + exit 1 +fi -# 5. 미사용 이미지 정리 +# 6. 미사용 이미지 정리 docker image prune -f echo "✅ 배포 완료!" \ No newline at end of file From e5a0d8fe12ce8ce56806d30a4a3448172bfa8e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:12:35 +0900 Subject: [PATCH 08/54] =?UTF-8?q?[chore]:=20nginx=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 7abdd12..1b6b0b1 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -1,6 +1,6 @@ # ✅ Load Balancing 설정 upstream spring_app { - server app:8080; + server api-server::8080; } server { From 903c2b6c7e2551fd3b6f53f62730bb09360c7262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:26:07 +0900 Subject: [PATCH 09/54] =?UTF-8?q?[chore]:=20Kafka=20docker-compose=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(KRaft,=20UI,=20Exporter)=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/docker-compose-kafka.yml | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docker-compose/docker-compose-kafka.yml diff --git a/docker-compose/docker-compose-kafka.yml b/docker-compose/docker-compose-kafka.yml new file mode 100644 index 0000000..f94b258 --- /dev/null +++ b/docker-compose/docker-compose-kafka.yml @@ -0,0 +1,68 @@ +version: '3.8' + +services: + kafka: + image: confluentinc/cp-kafka:7.6.1 + container_name: kafka + hostname: kafka + environment: + # KRaft + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: 'broker,controller' + KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093' + KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' # 절대 변경 금지 + + # 내부 통신만 사용 + KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093' + KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT' + KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' + KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT' + + # Data dir + KAFKA_LOG_DIRS: '/var/lib/kafka/data' + + # single broker + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + + KAFKA_HEAP_OPTS: "-Xmx400m -Xms400m" + + volumes: + - ./kafka-data:/var/lib/kafka/data + + networks: + - app-network + restart: always + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + ports: + - "8090:8080" + environment: + KAFKA_CLUSTERS_0_NAME: prod-kraft + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092 + networks: + - app-network + depends_on: + - kafka + restart: always + + kafka-exporter: + image: danielqsj/kafka-exporter + container_name: kafka-exporter + command: ["--kafka.server=kafka:29092"] + ports: + - "9308:9308" + networks: + - app-network + depends_on: + - kafka + restart: always + +networks: + app-network: + external: true From b5742d269609d08f2fd5f98fff177e3bc1842ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:32:47 +0900 Subject: [PATCH 10/54] =?UTF-8?q?[ci]:=20DockerFile=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 11 ----------- coupon-api/Dockerfile | 2 +- coupon-consumer/Dockerfile | 2 +- 3 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d26603b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -# 1. Base Image (JDK 21 환경) -FROM eclipse-temurin:21-jdk-alpine - -# 2. 작업 디렉토리 설정 -WORKDIR /app - -# 3. JAR 파일 복사 -COPY build/libs/*.jar app.jar - -# 4. 실행 명령어 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/coupon-api/Dockerfile b/coupon-api/Dockerfile index 9c57ff3..851d736 100644 --- a/coupon-api/Dockerfile +++ b/coupon-api/Dockerfile @@ -1,5 +1,5 @@ # coupon-api/Dockerfile -FROM openjdk:21-jdk-slim +FROM eclipse-temurin:21-jdk-alpine WORKDIR /app diff --git a/coupon-consumer/Dockerfile b/coupon-consumer/Dockerfile index faee936..4a5ab22 100644 --- a/coupon-consumer/Dockerfile +++ b/coupon-consumer/Dockerfile @@ -1,5 +1,5 @@ # coupon-consumer/Dockerfile -FROM openjdk:21-jdk-slim +FROM eclipse-temurin:21-jdk-alpine WORKDIR /app From f94e875275eb191cc7ce040a705f899dd2e17b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 22:48:57 +0900 Subject: [PATCH 11/54] =?UTF-8?q?[chore]:=20nginx=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=98=A4=ED=83=80=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 1b6b0b1..6418e49 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -1,6 +1,6 @@ # ✅ Load Balancing 설정 upstream spring_app { - server api-server::8080; + server api-server:8080; } server { From 0e2eb49689b8487dc649a11aa9d4377a6c150dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 23:06:25 +0900 Subject: [PATCH 12/54] =?UTF-8?q?[chore]:=20=EC=95=A0=ED=94=8C=EB=A6=AC?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=84=9C=EB=B2=84=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=EC=8B=9C=20--remove-orphans=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 49afef7..f63696b 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -45,7 +45,7 @@ if [ -f "docker-compose.yml" ]; then docker-compose -f docker-compose.yml pull # (2) 컨테이너 실행 - docker-compose -f docker-compose.yml up -d --remove-orphans --scale api-server=2 + docker-compose -f docker-compose.yml up -d --scale api-server=2 else echo "❌ docker-compose.yml 파일이 없습니다!" exit 1 From 838b525457f9da821c22dc0c475d386dd61aba36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 23:16:38 +0900 Subject: [PATCH 13/54] =?UTF-8?q?[chore]:=20kafka=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/docker-compose-kafka.yml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docker-compose/docker-compose-kafka.yml b/docker-compose/docker-compose-kafka.yml index f94b258..a03a355 100644 --- a/docker-compose/docker-compose-kafka.yml +++ b/docker-compose/docker-compose-kafka.yml @@ -6,23 +6,23 @@ services: container_name: kafka hostname: kafka environment: - # KRaft + # KRaft 설정 KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: 'broker,controller' KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093' - KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' # 절대 변경 금지 + KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' # 사용자 지정 ID 유지 - # 내부 통신만 사용 + # 리스너 설정 (내부 통신용) KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093' KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092' KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT' KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT' - # Data dir + # 데이터 경로 KAFKA_LOG_DIRS: '/var/lib/kafka/data' - # single broker + # 브로커 설정 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 @@ -30,6 +30,17 @@ services: KAFKA_HEAP_OPTS: "-Xmx400m -Xms400m" + # 포맷팅 스크립트 추가 + # meta.properties가 없으면 지정된 CLUSTER_ID로 포맷팅 후 실행 + command: > + bash -c ' + if [ ! -f /var/lib/kafka/data/meta.properties ]; then + echo "⏳ KRaft Storage Formatting (ID: $KAFKA_CLUSTER_ID)..." + kafka-storage format -t $KAFKA_CLUSTER_ID -c /etc/kafka/kafka.properties + fi + echo "🚀 Starting Kafka..." + /etc/confluent/docker/run' + volumes: - ./kafka-data:/var/lib/kafka/data @@ -65,4 +76,4 @@ services: networks: app-network: - external: true + external: true \ No newline at end of file From cb98ea2b894467f0940b28524152fa0d3e3fb7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 23:32:42 +0900 Subject: [PATCH 14/54] =?UTF-8?q?[chore]:=20kafka=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/docker-compose-kafka.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose/docker-compose-kafka.yml b/docker-compose/docker-compose-kafka.yml index a03a355..884f96e 100644 --- a/docker-compose/docker-compose-kafka.yml +++ b/docker-compose/docker-compose-kafka.yml @@ -35,10 +35,10 @@ services: command: > bash -c ' if [ ! -f /var/lib/kafka/data/meta.properties ]; then - echo "⏳ KRaft Storage Formatting (ID: $KAFKA_CLUSTER_ID)..." - kafka-storage format -t $KAFKA_CLUSTER_ID -c /etc/kafka/kafka.properties - fi - echo "🚀 Starting Kafka..." + echo "⏳ KRaft Storage Formatting (ID: $KAFKA_CLUSTER_ID)..."; + kafka-storage format -t $KAFKA_CLUSTER_ID -c /etc/kafka/kafka.properties; + fi; + echo "🚀 Starting Kafka..."; /etc/confluent/docker/run' volumes: From b2970136cd298e70e3c016dd6cefe62fcb7a46a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 4 Jan 2026 23:46:16 +0900 Subject: [PATCH 15/54] =?UTF-8?q?[chore]:=20kafka=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=BB=A4=EB=A9=98=EB=93=9C=EB=A5=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/docker-compose-kafka.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docker-compose/docker-compose-kafka.yml b/docker-compose/docker-compose-kafka.yml index 884f96e..0c5bf32 100644 --- a/docker-compose/docker-compose-kafka.yml +++ b/docker-compose/docker-compose-kafka.yml @@ -10,7 +10,8 @@ services: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: 'broker,controller' KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093' - KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' # 사용자 지정 ID 유지 + KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' + CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' # 리스너 설정 (내부 통신용) KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093' @@ -30,17 +31,6 @@ services: KAFKA_HEAP_OPTS: "-Xmx400m -Xms400m" - # 포맷팅 스크립트 추가 - # meta.properties가 없으면 지정된 CLUSTER_ID로 포맷팅 후 실행 - command: > - bash -c ' - if [ ! -f /var/lib/kafka/data/meta.properties ]; then - echo "⏳ KRaft Storage Formatting (ID: $KAFKA_CLUSTER_ID)..."; - kafka-storage format -t $KAFKA_CLUSTER_ID -c /etc/kafka/kafka.properties; - fi; - echo "🚀 Starting Kafka..."; - /etc/confluent/docker/run' - volumes: - ./kafka-data:/var/lib/kafka/data From 0483b8c77d271281b47a4e895a4fce4fb7548ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 00:21:13 +0900 Subject: [PATCH 16/54] =?UTF-8?q?[refactor]:=20coupon=EC=9D=98=20infra=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../couponsystem/coupon/application/service/CouponService.kt | 2 +- .../coupon/application/service/impl/LuaCouponIssueService.kt | 2 +- .../couponsystem/coupon/infra/{ => jpa}/JpaCouponRepository.kt | 2 +- .../coupon/infra/{ => jpa}/JpaIssuedCouponRepository.kt | 2 +- .../coupon/infra/{ => redis}/AppliedUserRedisRepository.kt | 2 +- .../coupon/infra/{ => redis}/CouponRedisRepository.kt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{ => jpa}/JpaCouponRepository.kt (94%) rename coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{ => jpa}/JpaIssuedCouponRepository.kt (90%) rename coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{ => redis}/AppliedUserRedisRepository.kt (92%) rename coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{ => redis}/CouponRedisRepository.kt (96%) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt index 48fba8f..45dd7e6 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponService.kt @@ -5,7 +5,7 @@ import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponRe import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.Coupon import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.CouponRepository import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import main.kotlin.com.woong2e.couponsystem.coupon.infra.CouponRedisRepository +import main.kotlin.com.woong2e.couponsystem.coupon.infra.redis.CouponRedisRepository import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import org.springframework.stereotype.Service diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt index c90cad0..098f4ed 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/LuaCouponIssueService.kt @@ -4,7 +4,7 @@ import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIs import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository -import main.kotlin.com.woong2e.couponsystem.coupon.infra.CouponRedisRepository +import main.kotlin.com.woong2e.couponsystem.coupon.infra.redis.CouponRedisRepository import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaCouponRepository.kt similarity index 94% rename from coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaCouponRepository.kt index fae7f7e..17b4d4a 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaCouponRepository.kt @@ -1,4 +1,4 @@ -package main.kotlin.com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra.jpa import jakarta.persistence.LockModeType import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.Coupon diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt similarity index 90% rename from coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt index ff3d037..a59317f 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/JpaIssuedCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt @@ -1,4 +1,4 @@ -package main.kotlin.com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra.jpa import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/redis/AppliedUserRedisRepository.kt similarity index 92% rename from coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/redis/AppliedUserRedisRepository.kt index 8fba980..69d864f 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/AppliedUserRedisRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/redis/AppliedUserRedisRepository.kt @@ -1,4 +1,4 @@ -package main.kotlin.com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra.redis import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.AppliedUserRepository import org.springframework.data.redis.core.RedisTemplate diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/redis/CouponRedisRepository.kt similarity index 96% rename from coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/redis/CouponRedisRepository.kt index df8c235..395b2c2 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/CouponRedisRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/redis/CouponRedisRepository.kt @@ -1,4 +1,4 @@ -package main.kotlin.com.woong2e.couponsystem.coupon.infra +package main.kotlin.com.woong2e.couponsystem.coupon.infra.redis import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.core.script.DefaultRedisScript From d6a8aacb112cf3888cd9ec0a4c2204005351f7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 18:55:45 +0900 Subject: [PATCH 17/54] =?UTF-8?q?[feat]:=20=EB=8F=99=EA=B8=B0,=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=98=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/CouponIssueResponse.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt index 792c0b4..b81a730 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/response/CouponIssueResponse.kt @@ -3,6 +3,25 @@ package main.kotlin.com.woong2e.couponsystem.coupon.application.response import java.util.UUID data class CouponIssueResponse( - val result: String = "SUCCESS", - val issuedCouponId: UUID? = null -) + val isSuccess: Boolean = true, + val issuedCouponId: UUID? = null, + val message: String? = null +) { + companion object { + fun asyncIssued(): CouponIssueResponse { + return CouponIssueResponse( + isSuccess = true, + issuedCouponId = null, + message = "쿠폰 발급 요청이 대기열에 추가되었습니다. 잠시 후 보관함을 확인해주세요." + ) + } + + fun syncIssued(id: UUID): CouponIssueResponse { + return CouponIssueResponse( + isSuccess = true, + issuedCouponId = id, + message = "쿠폰 발급이 완료되었습니다." + ) + } + } +} From 47510aad20e374759283fa1ef5b30eb17580c833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 19:06:05 +0900 Subject: [PATCH 18/54] =?UTF-8?q?[feat]:=20Redis=20Lua=20+=20Kafka=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EB=B0=9C=EA=B8=89=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4(Producer)=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis Lua Script를 활용한 원자적 재고 검증 로직 적용 - Kafka 도입을 통한 발급 요청과 저장 로직의 비동기 처리 분리 --- .../application/event/CouponIssueEvent.kt | 8 ++++ .../port/out/CouponIssueEventPublisher.kt | 7 ++++ .../impl/AsyncLuaCouponIssueService.kt | 33 +++++++++++++++ .../infra/producer/IssuedCouponProducer.kt | 40 +++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueEvent.kt create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueEventPublisher.kt create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AsyncLuaCouponIssueService.kt create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueEvent.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueEvent.kt new file mode 100644 index 0000000..8251415 --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueEvent.kt @@ -0,0 +1,8 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.event + +import java.util.UUID + +data class CouponIssueEvent( + val couponId: UUID, + val userId: Long +) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueEventPublisher.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueEventPublisher.kt new file mode 100644 index 0000000..253daef --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueEventPublisher.kt @@ -0,0 +1,7 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.port.out + +import java.util.UUID + +interface CouponIssueEventPublisher { + fun publish(couponId: UUID, userId: Long) +} \ No newline at end of file diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AsyncLuaCouponIssueService.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AsyncLuaCouponIssueService.kt new file mode 100644 index 0000000..c52f661 --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/impl/AsyncLuaCouponIssueService.kt @@ -0,0 +1,33 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.service.impl + +import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueEventPublisher +import main.kotlin.com.woong2e.couponsystem.coupon.application.response.CouponIssueResponse +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueService +import main.kotlin.com.woong2e.couponsystem.coupon.infra.redis.CouponRedisRepository +import main.kotlin.com.woong2e.couponsystem.coupon.status.CouponErrorStatus +import main.kotlin.com.woong2e.couponsystem.global.exception.CustomException +import main.kotlin.com.woong2e.couponsystem.global.response.status.ErrorStatus +import org.springframework.stereotype.Service +import java.util.UUID + +@Service("asyncLua") +class AsyncLuaCouponIssueService( + private val couponRedisRepository: CouponRedisRepository, + private val couponIssueEventPublisher: CouponIssueEventPublisher +) : CouponIssueService { + + override fun issue(couponId: UUID, userId: Long): CouponIssueResponse { + val result = couponRedisRepository.issueRequest(couponId.toString(), userId) + + when (result) { + "DUPLICATED" -> throw CustomException(CouponErrorStatus.COUPON_ALREADY_ISSUED) + "SOLD_OUT" -> throw CustomException(CouponErrorStatus.COUPON_OUT_OF_STOCK) + "SUCCESS" -> { + couponIssueEventPublisher.publish(couponId, userId) + + return CouponIssueResponse.asyncIssued() + } + else -> throw CustomException(ErrorStatus.SYSTEM_BUSY) + } + } +} \ No newline at end of file diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt new file mode 100644 index 0000000..17f4f5f --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt @@ -0,0 +1,40 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.infra.producer + +import main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueEvent +import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueEventPublisher +import org.slf4j.LoggerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class IssuedCouponProducer( + private val kafkaTemplate: KafkaTemplate +) : CouponIssueEventPublisher { + + private val log = LoggerFactory.getLogger(this::class.java) + + companion object { + private const val TOPIC = "coupon-issue-topic" + } + + override fun publish(couponId: UUID, userId: Long) { + val event = CouponIssueEvent(couponId, userId) + + val future = kafkaTemplate.send(TOPIC, userId.toString(), event) + + future.whenComplete { result, ex -> + if (ex == null) { + log.info( + "Success send message: topic={}, partition={}, offset={}, couponId={}", + result.recordMetadata.topic(), + result.recordMetadata.partition(), + result.recordMetadata.offset(), + couponId + ) + } else { + log.error("Failed to send message: couponId=$couponId, userId=$userId", ex) + } + } + } +} \ No newline at end of file From b43619b1ee1019a650920bca45c05c8d356723c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 19:30:01 +0900 Subject: [PATCH 19/54] =?UTF-8?q?[feat]:=20Redis=20Lua=20+=20Kafka=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EB=B0=9C=EA=B8=89=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4(Consumer)=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CouponIssueWorkerService.kt | 21 ++++++++++++ .../coupon/consumer/event/CouponIssueEvent.kt | 8 +++++ .../consumer/listener/CouponIssueConsumer.kt | 34 +++++++++++++++++++ .../repository/IssuedCouponRepository.kt | 8 +++++ .../infra/jpa/JpaIssuedCouponRepository.kt | 9 +++++ 5 files changed, 80 insertions(+) create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueEvent.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt new file mode 100644 index 0000000..ef49837 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt @@ -0,0 +1,21 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.service + +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +class CouponIssueWorkerService( + private val issuedCouponRepository: IssuedCouponRepository +) { + + @Transactional + fun issue(couponId: UUID, userId: Long) { + val issuedCoupon = IssuedCoupon( + couponId = couponId, + userId = userId + ) + + issuedCouponRepository.save(issuedCoupon) + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueEvent.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueEvent.kt new file mode 100644 index 0000000..9c8937a --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueEvent.kt @@ -0,0 +1,8 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.consumer.event + +import java.util.UUID + +data class CouponIssueEvent( + val couponId: UUID, + val userId: Long +) \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt new file mode 100644 index 0000000..d6c9605 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt @@ -0,0 +1,34 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.consumer.listener + +import com.fasterxml.jackson.databind.ObjectMapper +import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueWorkerService +import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueEvent +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.support.Acknowledgment +import org.springframework.stereotype.Component + +@Component +class CouponIssueConsumer( + private val couponIssueWorkerService: CouponIssueWorkerService, +) { + + private val log = LoggerFactory.getLogger(this::class.java) + + @KafkaListener( + topics = ["coupon-issue-topic"], + groupId = "coupon-issue-group", + containerFactory = "kafkaListenerContainerFactory" + ) + fun couponIssueListener(event: CouponIssueEvent, acknowledgment: Acknowledgment) { + try { + log.info("Consumer Listen: couponId={}, userId={}", event.couponId, event.userId) + + couponIssueWorkerService.issue(event.couponId, event.userId) + + acknowledgment.acknowledge() + } catch (e: Exception) { + log.error("Consumer Failed: couponId=${event.couponId}, userId=${event.userId}", e) + } + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt new file mode 100644 index 0000000..713c1c4 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponRepository.kt @@ -0,0 +1,8 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.domain.repository + +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon + +interface IssuedCouponRepository { + + fun save(issuedCoupon: IssuedCoupon): IssuedCoupon +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt new file mode 100644 index 0000000..ca98c1d --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt @@ -0,0 +1,9 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.infra.jpa + +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface JpaIssuedCouponRepository : IssuedCouponRepository, JpaRepository { +} \ No newline at end of file From c497dc45d23636f8f4b049b879e42e68eaa30bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 19:30:27 +0900 Subject: [PATCH 20/54] =?UTF-8?q?[feat]:=20Kafka=20Producer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/kafka/KafkaProducerConfig.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt new file mode 100644 index 0000000..5da1f32 --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -0,0 +1,31 @@ +package main.kotlin.com.woong2e.couponsystem.infra.kafka + +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.apache.kafka.common.serialization.LongSerializer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory + +@Configuration +class KafkaProducerConfig { + + @Bean + fun producerFactory(): ProducerFactory { + val configProps = HashMap() + configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = "kafka:29092" + + configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + + configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = LongSerializer::class.java + + return DefaultKafkaProducerFactory(configProps) + } + + @Bean + fun kafkaTemplate(): KafkaTemplate { + return KafkaTemplate(producerFactory()) + } +} \ No newline at end of file From 6536a7fd869d76a8b5894ea390330a4c18644757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 19:30:36 +0900 Subject: [PATCH 21/54] =?UTF-8?q?[feat]:=20Kafka=20Consumer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/kafka/KafkaConsumerConfig.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt new file mode 100644 index 0000000..04fb879 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -0,0 +1,36 @@ +package main.kotlin.com.woong2e.couponsystem.infra.kafka + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.LongDeserializer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.annotation.EnableKafka +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory + +@EnableKafka +@Configuration +class KafkaConsumerConfig { + + @Bean + fun consumerFactory(): ConsumerFactory { + val props = HashMap() + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = "kafka:29092" + props[ConsumerConfig.GROUP_ID_CONFIG] = "coupon-group" + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = LongDeserializer::class.java + + return DefaultKafkaConsumerFactory(props) + } + + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory()) + factory.setConcurrency(3) + + return factory + } +} \ No newline at end of file From b3846db958c748821d569658076e19270eda4c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 19:30:52 +0900 Subject: [PATCH 22/54] =?UTF-8?q?[feat]:=20Kafka=20Topic=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/kafka/KafkaTopicConfig.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt new file mode 100644 index 0000000..b61274a --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt @@ -0,0 +1,18 @@ +package main.kotlin.com.woong2e.couponsystem.infra.kafka + +import org.apache.kafka.clients.admin.NewTopic +import org.springframework.kafka.config.TopicBuilder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class KafkaTopicConfig { + + @Bean + fun couponCreateTopic(): NewTopic { + return TopicBuilder.name("coupon-issue-topic") + .partitions(3) + .replicas(1) + .build() + } +} \ No newline at end of file From 38d11200d54be019bb86dd427488f3ab1b25fe11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 19:35:57 +0900 Subject: [PATCH 23/54] =?UTF-8?q?[fix]:=20CouponIssueWorkerService?= =?UTF-8?q?=EC=9D=B4=20Component=20=EC=8A=A4=EC=BA=94=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=EC=9D=B4=20=EB=90=98=EB=8F=84=EB=A1=9D=20`@Service`=EB=A5=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/application/service/CouponIssueWorkerService.kt | 2 ++ .../coupon/consumer/listener/CouponIssueConsumer.kt | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt index ef49837..bce0a9f 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt @@ -2,9 +2,11 @@ package main.kotlin.com.woong2e.couponsystem.coupon.application.service import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository +import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID +@Service class CouponIssueWorkerService( private val issuedCouponRepository: IssuedCouponRepository ) { diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt index d6c9605..0844cf0 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt @@ -1,6 +1,5 @@ package main.kotlin.com.woong2e.couponsystem.coupon.consumer.listener -import com.fasterxml.jackson.databind.ObjectMapper import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueWorkerService import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueEvent import org.slf4j.LoggerFactory From d94286c5ddfd8213d3fd7f15ad2d1a1f108a73e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 20:27:43 +0900 Subject: [PATCH 24/54] =?UTF-8?q?[feat]:=20=EB=A1=9C=EC=BB=AC=20kafka=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-api/src/main/resources/application-local.yml | 10 ++++++++++ .../src/main/resources/application-local.yml | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index 7a87396..a7b81ea 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -1,4 +1,14 @@ spring: + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer + acks: all + retries: 10 + properties: + enable.idempotence: true + datasource: url: ${DATABASE_URL} username: ${DATABASE_USERNAME} diff --git a/coupon-consumer/src/main/resources/application-local.yml b/coupon-consumer/src/main/resources/application-local.yml index 7a87396..a2e5984 100644 --- a/coupon-consumer/src/main/resources/application-local.yml +++ b/coupon-consumer/src/main/resources/application-local.yml @@ -1,4 +1,17 @@ spring: + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: coupon-worker-group + auto-offset-reset: latest + enable-auto-commit: false + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + properties: + spring.json.trusted.packages: "*" + listener: + ack-mode: record + datasource: url: ${DATABASE_URL} username: ${DATABASE_USERNAME} From f413eea37bd8665c182dc0acf40a545cdcbf1d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 20:29:32 +0900 Subject: [PATCH 25/54] =?UTF-8?q?[fix]:=20Json=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94,=20=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=EB=A5=BC=20=EB=B2=84=EC=A0=84=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/kafka/KafkaProducerConfig.kt | 12 ++++++++---- coupon-api/src/main/resources/application-prod.yml | 2 +- .../infra/kafka/KafkaConsumerConfig.kt | 14 ++++++++++---- .../src/main/resources/application-prod.yml | 2 +- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index 5da1f32..d6ba933 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -2,24 +2,28 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer -import org.apache.kafka.common.serialization.LongSerializer +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JacksonJsonSerializer @Configuration -class KafkaProducerConfig { +class KafkaProducerConfig( + @param:Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String +) { @Bean fun producerFactory(): ProducerFactory { val configProps = HashMap() - configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = "kafka:29092" + configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java - configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = LongSerializer::class.java + configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JacksonJsonSerializer::class.java return DefaultKafkaProducerFactory(configProps) } diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index 5c16efd..a49edfb 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -13,7 +13,7 @@ spring: bootstrap-servers: kafka:29092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer acks: all retries: 10 properties: diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index 04fb879..cc4b6f9 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -2,25 +2,31 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.serialization.StringDeserializer -import org.apache.kafka.common.serialization.LongDeserializer +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.ConsumerFactory import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.support.serializer.JacksonJsonDeserializer @EnableKafka @Configuration -class KafkaConsumerConfig { +class KafkaConsumerConfig( + @param:Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String +) { @Bean fun consumerFactory(): ConsumerFactory { val props = HashMap() - props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = "kafka:29092" + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers + props[ConsumerConfig.GROUP_ID_CONFIG] = "coupon-group" + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java - props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = LongDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = JacksonJsonDeserializer::class.java return DefaultKafkaConsumerFactory(props) } diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml index e368edd..0de780b 100644 --- a/coupon-consumer/src/main/resources/application-prod.yml +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -13,7 +13,7 @@ spring: auto-offset-reset: latest enable-auto-commit: false key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer properties: spring.json.trusted.packages: "*" listener: From 2bb0eb9d955ad5fc86307475a5cd19dda76a67e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 5 Jan 2026 20:29:56 +0900 Subject: [PATCH 26/54] =?UTF-8?q?[chore]:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?kafka=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-consumer/src/test/resources/application-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/coupon-consumer/src/test/resources/application-test.yml b/coupon-consumer/src/test/resources/application-test.yml index 1abb2d0..3089772 100644 --- a/coupon-consumer/src/test/resources/application-test.yml +++ b/coupon-consumer/src/test/resources/application-test.yml @@ -17,4 +17,9 @@ spring: data: redis: host: localhost - port: 6379 \ No newline at end of file + port: 6379 + + kafka: + bootstrap-servers: localhost:9092 + listener: + auto-startup: false \ No newline at end of file From 0d9bbf7093c09a2c01f5a1acc62721dc07c5fd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 00:21:04 +0900 Subject: [PATCH 27/54] =?UTF-8?q?[build]:=20SpringBoot=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9D=84=20=EC=95=88=EC=A0=95=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8B=A4=EC=9A=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 15 +++++++++------ coupon-api/build.gradle.kts | 16 +++++++++------- coupon-consumer/build.gradle.kts | 13 +++++++------ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e138a51..f535d84 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,10 +4,10 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "2.2.21" apply false - kotlin("plugin.spring") version "2.2.21" apply false - kotlin("plugin.jpa") version "2.2.21" apply false - id("org.springframework.boot") version "4.0.0" apply false + kotlin("jvm") version "1.9.25" apply false + kotlin("plugin.spring") version "1.9.25" apply false + kotlin("plugin.jpa") version "1.9.25" apply false + id("org.springframework.boot") version "3.4.1" apply false id("io.spring.dependency-management") version "1.1.7" apply false } @@ -21,11 +21,14 @@ allprojects { } subprojects { - // 플러그인 적용 (ID 문자열 사용) + // 하위 모듈에 플러그인 적용 apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "org.jetbrains.kotlin.plugin.spring") + apply(plugin = "org.jetbrains.kotlin.plugin.jpa") + apply(plugin = "org.springframework.boot") + apply(plugin = "io.spring.dependency-management") - // Java Toolchain 설정 + // Java 21 설정 configure { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) diff --git a/coupon-api/build.gradle.kts b/coupon-api/build.gradle.kts index a4b3480..8f5702b 100644 --- a/coupon-api/build.gradle.kts +++ b/coupon-api/build.gradle.kts @@ -1,32 +1,34 @@ -plugins { - id("org.springframework.boot") - id("io.spring.dependency-management") - kotlin("plugin.jpa") -} - dependencies { + // Kotlin & Core implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib") + // Web & Validation implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") + // Kafka implementation("org.springframework.kafka:spring-kafka") + // DB & JPA implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-jdbc") runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.h2database:h2") + // Redis & Redisson implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.redisson:redisson-spring-boot-starter:4.1.0") + implementation("org.redisson:redisson-spring-boot-starter:3.25.0") + // Monitoring implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus") + // Utils implementation("com.github.f4b6a3:ulid-creator:5.2.2") + // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/coupon-consumer/build.gradle.kts b/coupon-consumer/build.gradle.kts index 82332c3..31e36e0 100644 --- a/coupon-consumer/build.gradle.kts +++ b/coupon-consumer/build.gradle.kts @@ -1,29 +1,30 @@ -plugins { - id("org.springframework.boot") - id("io.spring.dependency-management") - kotlin("plugin.jpa") -} - dependencies { + // Kotlin & Core implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib") + // Spring Boot Web & Monitoring implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus") + // Kafka implementation("org.springframework.kafka:spring-kafka") + // DB & JPA implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-jdbc") runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.h2database:h2") + // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis") + // Utils implementation("com.github.f4b6a3:ulid-creator:5.2.2") + // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") From e93754711f2a0929c84cee8eb9dfc41c6fde9cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 00:21:28 +0900 Subject: [PATCH 28/54] =?UTF-8?q?[chore]:=20=EC=84=A4=EC=A0=95=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#1?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-api/src/main/resources/application-local.yml | 2 +- coupon-api/src/main/resources/application-prod.yml | 2 +- coupon-consumer/src/main/resources/application-local.yml | 4 ---- coupon-consumer/src/main/resources/application-prod.yml | 4 ---- coupon-consumer/src/test/resources/application-test.yml | 6 +++++- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index a7b81ea..d0da06d 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -3,7 +3,7 @@ spring: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer acks: all retries: 10 properties: diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index a49edfb..5c16efd 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -13,7 +13,7 @@ spring: bootstrap-servers: kafka:29092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer acks: all retries: 10 properties: diff --git a/coupon-consumer/src/main/resources/application-local.yml b/coupon-consumer/src/main/resources/application-local.yml index a2e5984..67a4dab 100644 --- a/coupon-consumer/src/main/resources/application-local.yml +++ b/coupon-consumer/src/main/resources/application-local.yml @@ -5,10 +5,6 @@ spring: group-id: coupon-worker-group auto-offset-reset: latest enable-auto-commit: false - key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer - properties: - spring.json.trusted.packages: "*" listener: ack-mode: record diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml index 0de780b..d148e93 100644 --- a/coupon-consumer/src/main/resources/application-prod.yml +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -12,10 +12,6 @@ spring: group-id: coupon-worker-group auto-offset-reset: latest enable-auto-commit: false - key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer - properties: - spring.json.trusted.packages: "*" listener: ack-mode: record diff --git a/coupon-consumer/src/test/resources/application-test.yml b/coupon-consumer/src/test/resources/application-test.yml index 3089772..460b706 100644 --- a/coupon-consumer/src/test/resources/application-test.yml +++ b/coupon-consumer/src/test/resources/application-test.yml @@ -21,5 +21,9 @@ spring: kafka: bootstrap-servers: localhost:9092 + consumer: + group-id: test-group + auto-offset-reset: latest + enable-auto-commit: true listener: - auto-startup: false \ No newline at end of file + ack-mode: record \ No newline at end of file From 7dde18e76e56da4c1d7d0857d99f0a0df710c2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 00:22:07 +0900 Subject: [PATCH 29/54] =?UTF-8?q?[refactor]:=20=EB=B3=80=EA=B2=BD=EB=90=9C?= =?UTF-8?q?=20SpringBoot=20=EB=B2=84=EC=A0=84=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20Kafka=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/kafka/KafkaProducerConfig.kt | 19 +++++++------ .../infra/kafka/KafkaConsumerConfig.kt | 28 ++++++++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index d6ba933..64b4d34 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -1,31 +1,34 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka +import com.fasterxml.jackson.databind.JsonSerializer import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.kafka.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory -import org.springframework.kafka.support.serializer.JacksonJsonSerializer @Configuration class KafkaProducerConfig( - @param:Value("\${spring.kafka.bootstrap-servers}") - private val bootstrapServers: String + private val kafkaProperties: KafkaProperties ) { @Bean fun producerFactory(): ProducerFactory { - val configProps = HashMap() - configProps[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers + val props = HashMap() - configProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + props[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers - configProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JacksonJsonSerializer::class.java + props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java + props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java - return DefaultKafkaProducerFactory(configProps) + props[ProducerConfig.ACKS_CONFIG] = kafkaProperties.producer.acks + props[ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG] = kafkaProperties.producer.properties["enable.idempotence"] ?: true + + return DefaultKafkaProducerFactory(props) } @Bean diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index cc4b6f9..c670537 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -2,31 +2,38 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.serialization.StringDeserializer -import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.kafka.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.annotation.EnableKafka import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.ConsumerFactory import org.springframework.kafka.core.DefaultKafkaConsumerFactory -import org.springframework.kafka.support.serializer.JacksonJsonDeserializer +import org.springframework.kafka.listener.ContainerProperties +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer +import org.springframework.kafka.support.serializer.JsonDeserializer @EnableKafka @Configuration class KafkaConsumerConfig( - @param:Value("\${spring.kafka.bootstrap-servers}") - private val bootstrapServers: String + private val kafkaProperties: KafkaProperties ) { @Bean fun consumerFactory(): ConsumerFactory { - val props = HashMap() - props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = bootstrapServers + val props = kafkaProperties.buildConsumerProperties() - props[ConsumerConfig.GROUP_ID_CONFIG] = "coupon-group" + props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers + props[ConsumerConfig.GROUP_ID_CONFIG] = kafkaProperties.consumer.groupId + + props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = kafkaProperties.consumer.autoOffsetReset + props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = kafkaProperties.consumer.enableAutoCommit props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java - props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = JacksonJsonDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java + props[ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS] = JsonDeserializer::class.java + + props[JsonDeserializer.TRUSTED_PACKAGES] = "*" return DefaultKafkaConsumerFactory(props) } @@ -34,9 +41,10 @@ class KafkaConsumerConfig( @Bean fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { val factory = ConcurrentKafkaListenerContainerFactory() - factory.setConsumerFactory(consumerFactory()) - factory.setConcurrency(3) + factory.consumerFactory = consumerFactory() + factory.setConcurrency(3) + factory.containerProperties.ackMode = kafkaProperties.listener.ackMode ?: ContainerProperties.AckMode.RECORD return factory } } \ No newline at end of file From 9df0ba5b624abf8e88bb367b303c0a033fa7261f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 00:42:54 +0900 Subject: [PATCH 30/54] =?UTF-8?q?[build]:=20gradle=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9D=84=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..e2847c8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 8ccd714d4cf036b5c8c9920b1597f4c5b5e9cfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 00:43:27 +0900 Subject: [PATCH 31/54] =?UTF-8?q?[fix]:=20=EC=A0=81=EC=A0=88=ED=95=9C=20Js?= =?UTF-8?q?onSerializer=20import=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index 64b4d34..cc22cfe 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -1,15 +1,14 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka -import com.fasterxml.jackson.databind.JsonSerializer import org.apache.kafka.clients.producer.ProducerConfig import org.apache.kafka.common.serialization.StringSerializer -import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.kafka.KafkaProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonSerializer @Configuration class KafkaProducerConfig( From 1260e5c33f75f7c53ee336f11c176946b895d475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 01:00:40 +0900 Subject: [PATCH 32/54] =?UTF-8?q?[fix]:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=EC=9D=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=AC=B4=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20Consumer=EA=B0=80=20=EC=A7=80=EC=A0=95=ED=95=9C=20E?= =?UTF-8?q?vent=20=EA=B0=9D=EC=B2=B4=EB=A1=9C=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index c670537..27135a4 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -35,6 +35,8 @@ class KafkaConsumerConfig( props[JsonDeserializer.TRUSTED_PACKAGES] = "*" + props[JsonDeserializer.USE_TYPE_INFO_HEADERS] = false + return DefaultKafkaConsumerFactory(props) } From 49e353d9f1e1ed3b9609057e09b8a70d421e3c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 01:18:49 +0900 Subject: [PATCH 33/54] =?UTF-8?q?[chore]:=20prometheus=20scraping=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=B4=208083=20=ED=8F=AC=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=EC=97=90=20=EC=98=A4=ED=94=88=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 6835686..8989cde 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -87,6 +87,8 @@ services: - .env environment: - JAVA_TOOL_OPTIONS=-Xms400m -Xmx400m + ports: + - "8083:8081" networks: - app-network From 228568aed33ea4b5cf5ab93f49b2316ca5f6036d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 01:19:06 +0900 Subject: [PATCH 34/54] =?UTF-8?q?[chore]:=20gradlew=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/wrapper/gradle-wrapper.jar | Bin 45633 -> 43583 bytes gradlew | 12 ++++++++---- gradlew.bat | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f8e1ee3125fe0768e9a76ee977ac089eb657005e..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch delta 35498 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 }= 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 F{{RkpxF!Gq delta 37538 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 /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +115,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -171,6 +173,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,14 +206,15 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index e509b2d..9b42019 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,10 +70,11 @@ goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell From 1d8ad4093fcf6fa6e0e8175638bb890726ffa7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 01:19:46 +0900 Subject: [PATCH 35/54] =?UTF-8?q?[fix]:=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EC=A7=81=EC=A0=91=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=A7=A4=ED=95=91=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index 27135a4..829e407 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -1,5 +1,6 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka +import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueEvent import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.serialization.StringDeserializer import org.springframework.boot.autoconfigure.kafka.KafkaProperties @@ -35,7 +36,9 @@ class KafkaConsumerConfig( props[JsonDeserializer.TRUSTED_PACKAGES] = "*" - props[JsonDeserializer.USE_TYPE_INFO_HEADERS] = false + props[JsonDeserializer.USE_TYPE_INFO_HEADERS] = true + props[JsonDeserializer.TYPE_MAPPINGS] = + "main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueEvent:" + CouponIssueEvent::class.java.name return DefaultKafkaConsumerFactory(props) } From 00ddf9325cec79171dac71710c195a1dbb4b329c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 6 Jan 2026 01:29:49 +0900 Subject: [PATCH 36/54] =?UTF-8?q?[chore]:=20listener=EC=9D=98=20ack-mode?= =?UTF-8?q?=EB=A5=BC=20manual=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=88=98=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-consumer/src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml index d148e93..2141358 100644 --- a/coupon-consumer/src/main/resources/application-prod.yml +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -13,7 +13,7 @@ spring: auto-offset-reset: latest enable-auto-commit: false listener: - ack-mode: record + ack-mode: manual datasource: url: ${DATABASE_URL} From 13562d74dac82b9b9f670928f37e9e59065588c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 11 Jan 2026 20:24:20 +0900 Subject: [PATCH 37/54] =?UTF-8?q?[pref]:=20Kafka=20Producer=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=A0=84=EC=86=A1=20=EB=B0=8F=20=EC=95=95=EC=B6=95?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - linger.ms 설정 추가 (0 -> 5ms): 메시지를 모아서 전송하여 시스템 콜 횟수 감소 - batch.size 증설 (16KB -> 64KB): 한 번에 전송하는 패킷 크기 증가 - compression.type 적용 (lz4): 페이로드 압축을 통한 네트워크 및 CPU 효율 최적화 --- .../couponsystem/infra/kafka/KafkaProducerConfig.kt | 6 ++++++ coupon-api/src/main/resources/application-local.yml | 8 +++++++- coupon-api/src/main/resources/application-prod.yml | 8 +++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index cc22cfe..0d707cd 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -21,12 +21,18 @@ class KafkaProducerConfig( props[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers + props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java props[ProducerConfig.ACKS_CONFIG] = kafkaProperties.producer.acks props[ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG] = kafkaProperties.producer.properties["enable.idempotence"] ?: true + props[ProducerConfig.LINGER_MS_CONFIG] = 5 + props[ProducerConfig.BATCH_SIZE_CONFIG] = kafkaProperties.producer.batchSize + + props[ProducerConfig.COMPRESSION_TYPE_CONFIG] = kafkaProperties.producer.compressionType + return DefaultKafkaProducerFactory(props) } diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index d0da06d..5d611f7 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -4,10 +4,16 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - acks: all + acks: all # 데이터 유실 방지 (필수) retries: 10 + # 배치 크기 증가 (기본 16KB -> 64KB) + batch-size: 65536 + # 압축 알고리즘 (CPU 부하가 적고 속도가 빠른 lz4 권장) + compression-type: lz4 properties: enable.idempotence: true + # 5ms 동안 기다렸다가 모아서 발송 + linger.ms: 5 datasource: url: ${DATABASE_URL} diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index 5c16efd..dfe3241 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -14,10 +14,16 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - acks: all + acks: all # 데이터 유실 방지 (필수) retries: 10 + # 배치 크기 증가 (기본 16KB -> 64KB) + batch-size: 65536 + # 압축 알고리즘 (CPU 부하가 적고 속도가 빠른 lz4 권장) + compression-type: lz4 properties: enable.idempotence: true + # 5ms 동안 기다렸다가 모아서 발송 + linger.ms: 5 datasource: url: ${DATABASE_URL} From 78c7383a3006545494bfa3f01168e76fbd4cf2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 11 Jan 2026 20:24:56 +0900 Subject: [PATCH 38/54] =?UTF-8?q?[chore]:=20=EC=84=9C=EB=B2=84=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=8B=9C=20redis-exporter=EA=B0=80=20?= =?UTF-8?q?=ED=95=AD=EC=83=81=20=EC=9E=AC=EC=8B=9C=EC=9E=91=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose/docker-compose-database.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose/docker-compose-database.yml b/docker-compose/docker-compose-database.yml index 4ab8ecd..21727a6 100644 --- a/docker-compose/docker-compose-database.yml +++ b/docker-compose/docker-compose-database.yml @@ -49,6 +49,7 @@ services: - "9121:9121" networks: - app-network + restart: always networks: app-network: From ce09098173b6e9044aab3cf193dc85f230cf2a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 11 Jan 2026 20:36:43 +0900 Subject: [PATCH 39/54] =?UTF-8?q?[fix]:=20=EB=B0=B0=EC=B9=98=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=EB=A5=BC=20Int=EB=A1=9C=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index 0d707cd..471cd0a 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -29,7 +29,7 @@ class KafkaProducerConfig( props[ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG] = kafkaProperties.producer.properties["enable.idempotence"] ?: true props[ProducerConfig.LINGER_MS_CONFIG] = 5 - props[ProducerConfig.BATCH_SIZE_CONFIG] = kafkaProperties.producer.batchSize + props[ProducerConfig.BATCH_SIZE_CONFIG] = 65536 props[ProducerConfig.COMPRESSION_TYPE_CONFIG] = kafkaProperties.producer.compressionType From feeed81433f87e6479b49015a6f99388e0ecc947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 11 Jan 2026 23:50:04 +0900 Subject: [PATCH 40/54] =?UTF-8?q?[chore]:=20=EB=B0=B0=EC=B9=98=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88,=20linger.ms=EB=A5=BC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-api/src/main/resources/application-local.yml | 8 ++++---- coupon-api/src/main/resources/application-prod.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index 5d611f7..f2529ae 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -6,14 +6,14 @@ spring: value-serializer: org.springframework.kafka.support.serializer.JsonSerializer acks: all # 데이터 유실 방지 (필수) retries: 10 - # 배치 크기 증가 (기본 16KB -> 64KB) - batch-size: 65536 + # 배치 크기 증가 (기본 16KB -> 1MB) + batch-size: 1000000 # 압축 알고리즘 (CPU 부하가 적고 속도가 빠른 lz4 권장) compression-type: lz4 properties: enable.idempotence: true - # 5ms 동안 기다렸다가 모아서 발송 - linger.ms: 5 + # 50ms 동안 기다렸다가 모아서 발송 + linger.ms: 50 datasource: url: ${DATABASE_URL} diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index dfe3241..2972326 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -16,14 +16,14 @@ spring: value-serializer: org.springframework.kafka.support.serializer.JsonSerializer acks: all # 데이터 유실 방지 (필수) retries: 10 - # 배치 크기 증가 (기본 16KB -> 64KB) - batch-size: 65536 + # 배치 크기 증가 (기본 16KB -> 1MB) + batch-size: 1000000 # 압축 알고리즘 (CPU 부하가 적고 속도가 빠른 lz4 권장) compression-type: lz4 properties: enable.idempotence: true - # 5ms 동안 기다렸다가 모아서 발송 - linger.ms: 5 + # 50ms 동안 기다렸다가 모아서 발송 + linger.ms: 50 datasource: url: ${DATABASE_URL} From 71791b72367d8d0215a8a3954acb80f30e1a87f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 11 Jan 2026 23:51:31 +0900 Subject: [PATCH 41/54] =?UTF-8?q?[feat]:=20=EB=B0=B0=EC=B9=98=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A6=88,=20linger.ms=EB=A5=BC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../couponsystem/infra/kafka/KafkaProducerConfig.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index 471cd0a..cb86827 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -15,6 +15,11 @@ class KafkaProducerConfig( private val kafkaProperties: KafkaProperties ) { + companion object { + private const val DEFAULT_LINGER_MS = 50 + private const val DEFAULT_BATCH_SIZE = 1000000 + } + @Bean fun producerFactory(): ProducerFactory { val props = HashMap() @@ -28,8 +33,8 @@ class KafkaProducerConfig( props[ProducerConfig.ACKS_CONFIG] = kafkaProperties.producer.acks props[ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG] = kafkaProperties.producer.properties["enable.idempotence"] ?: true - props[ProducerConfig.LINGER_MS_CONFIG] = 5 - props[ProducerConfig.BATCH_SIZE_CONFIG] = 65536 + props[ProducerConfig.LINGER_MS_CONFIG] = DEFAULT_LINGER_MS + props[ProducerConfig.BATCH_SIZE_CONFIG] = DEFAULT_BATCH_SIZE props[ProducerConfig.COMPRESSION_TYPE_CONFIG] = kafkaProperties.producer.compressionType From fe7a50684c5c894e05bfd2ceaacd572fdf8c4f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Sun, 11 Jan 2026 23:53:49 +0900 Subject: [PATCH 42/54] =?UTF-8?q?[feat]:=20=EC=B2=98=EB=A6=AC=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20event=EB=A5=BC=20dlt=20?= =?UTF-8?q?=ED=86=A0=ED=94=BD=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=B2=98=EB=A6=AC=ED=95=9C=EB=8B=A4=20(#1?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/event/CouponIssueDltEvent.kt | 11 ++++ .../infra/producer/IssuedCouponProducer.kt | 58 +++++++++++++++++-- .../couponsystem/coupon/value/DltSource.kt | 5 ++ .../infra/kafka/KafkaTopicConfig.kt | 8 +++ .../port/out/CouponIssueDltPublisher.kt | 7 +++ .../consumer/event/CouponIssueDltEvent.kt | 11 ++++ .../consumer/listener/CouponIssueConsumer.kt | 55 +++++++++++++++--- .../kafka/KafkaCouponIssueDltPublisher.kt | 29 ++++++++++ .../couponsystem/coupon/value/DltSource.kt | 5 ++ 9 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueDltEvent.kt create mode 100644 coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueDltPublisher.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueDltEvent.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/kafka/KafkaCouponIssueDltPublisher.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueDltEvent.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueDltEvent.kt new file mode 100644 index 0000000..0c5a179 --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/application/event/CouponIssueDltEvent.kt @@ -0,0 +1,11 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.event + +import main.kotlin.com.woong2e.couponsystem.coupon.value.DltSource +import java.util.UUID + +data class CouponIssueDltEvent( + val source: DltSource, + val couponId: UUID, + val userId: Long, + val reason: String? = null, +) \ No newline at end of file diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt index 17f4f5f..caaf194 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt @@ -1,7 +1,9 @@ package main.kotlin.com.woong2e.couponsystem.coupon.infra.producer +import main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueDltEvent import main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueEvent import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueEventPublisher +import main.kotlin.com.woong2e.couponsystem.coupon.value.DltSource import org.slf4j.LoggerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Component @@ -16,24 +18,68 @@ class IssuedCouponProducer( companion object { private const val TOPIC = "coupon-issue-topic" + private const val TOPIC_DLT = "coupon-issue-dlt-topic" } override fun publish(couponId: UUID, userId: Long) { val event = CouponIssueEvent(couponId, userId) + val key = userId.toString() - val future = kafkaTemplate.send(TOPIC, userId.toString(), event) + runCatching { kafkaTemplate.send(TOPIC, key, event) } + .onFailure { ex -> + log.error("Kafka send() call failed -> send to DLT. couponId={}, userId={}", couponId, userId, ex) + sendToDlt( + key = key, + dltEvent = CouponIssueDltEvent( + source = DltSource.PRODUCER, + couponId = couponId, + userId = userId, + reason = ex.message ?: ex::class.java.simpleName + ) + ) + } + .onSuccess { future -> + future.whenComplete { result, ex -> + if (ex == null) { + log.info( + "Producer sent: topic={}, partition={}, offset={}, couponId={}, userId={}", + result.recordMetadata.topic(), + result.recordMetadata.partition(), + result.recordMetadata.offset(), + couponId, + userId + ) + return@whenComplete + } + + log.error("Producer async send failed -> send to DLT. couponId={}, userId={}", couponId, userId, ex) + sendToDlt( + key = key, + dltEvent = CouponIssueDltEvent( + source = DltSource.PRODUCER, + couponId = couponId, + userId = userId, + reason = ex.message ?: ex::class.java.simpleName + ) + ) + } + } + } - future.whenComplete { result, ex -> + private fun sendToDlt(key: String, dltEvent: CouponIssueDltEvent) { + kafkaTemplate.send(TOPIC_DLT, key, dltEvent).whenComplete { result, ex -> if (ex == null) { - log.info( - "Success send message: topic={}, partition={}, offset={}, couponId={}", + log.warn( + "Sent to DLT: topic={}, partition={}, offset={}, source={}, couponId={}, userId={}", result.recordMetadata.topic(), result.recordMetadata.partition(), result.recordMetadata.offset(), - couponId + dltEvent.source, + dltEvent.couponId, + dltEvent.userId ) } else { - log.error("Failed to send message: couponId=$couponId, userId=$userId", ex) + log.error("Failed to send to DLT: source={}, couponId={}, userId={}", dltEvent.source, dltEvent.couponId, dltEvent.userId, ex) } } } diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt new file mode 100644 index 0000000..bf0c4bc --- /dev/null +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt @@ -0,0 +1,5 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.value + +enum class DltSource { + PRODUCER, CONSUMER +} diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt index b61274a..7812bd3 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaTopicConfig.kt @@ -15,4 +15,12 @@ class KafkaTopicConfig { .replicas(1) .build() } + + @Bean + fun couponCreateDltTopic(): NewTopic { + return TopicBuilder.name("coupon-issue-dlt-topic") + .partitions(3) + .replicas(1) + .build() + } } \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueDltPublisher.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueDltPublisher.kt new file mode 100644 index 0000000..e61da1a --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/port/out/CouponIssueDltPublisher.kt @@ -0,0 +1,7 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.application.port.out + +import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueDltEvent + +interface CouponIssueDltPublisher { + fun publish(event: CouponIssueDltEvent) +} diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueDltEvent.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueDltEvent.kt new file mode 100644 index 0000000..a522ea2 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/event/CouponIssueDltEvent.kt @@ -0,0 +1,11 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.consumer.event + +import main.kotlin.com.woong2e.couponsystem.coupon.value.DltSource +import java.util.UUID + +data class CouponIssueDltEvent( + val source: DltSource, + val couponId: UUID, + val userId: Long, + val reason: String? = null, +) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt index 0844cf0..7e90a5f 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt @@ -1,7 +1,10 @@ package main.kotlin.com.woong2e.couponsystem.coupon.consumer.listener +import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueDltPublisher import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueWorkerService +import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueDltEvent import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueEvent +import main.kotlin.com.woong2e.couponsystem.coupon.value.DltSource import org.slf4j.LoggerFactory import org.springframework.kafka.annotation.KafkaListener import org.springframework.kafka.support.Acknowledgment @@ -10,24 +13,60 @@ import org.springframework.stereotype.Component @Component class CouponIssueConsumer( private val couponIssueWorkerService: CouponIssueWorkerService, + private val couponIssueDltPublisher: CouponIssueDltPublisher ) { private val log = LoggerFactory.getLogger(this::class.java) + companion object { + private const val TOPIC = "coupon-issue-topic" + private const val TOPIC_DLT = "coupon-issue-dlt-topic" + private const val GROUP_ID = "coupon-issue-group" + private const val GROUP_ID_DLT = "coupon-issue-dlt-group" + + } + @KafkaListener( - topics = ["coupon-issue-topic"], - groupId = "coupon-issue-group", + topics = [TOPIC], + groupId = GROUP_ID, containerFactory = "kafkaListenerContainerFactory" ) - fun couponIssueListener(event: CouponIssueEvent, acknowledgment: Acknowledgment) { - try { + fun onMessage(event: CouponIssueEvent, ack: Acknowledgment) { + runCatching { log.info("Consumer Listen: couponId={}, userId={}", event.couponId, event.userId) - couponIssueWorkerService.issue(event.couponId, event.userId) + }.onSuccess { + ack.acknowledge() + }.onFailure { ex -> + log.error("Consumer Failed -> send to DLT. couponId={}, userId={}", event.couponId, event.userId, ex) - acknowledgment.acknowledge() - } catch (e: Exception) { - log.error("Consumer Failed: couponId=${event.couponId}, userId=${event.userId}", e) + couponIssueDltPublisher.publish( + CouponIssueDltEvent( + source = DltSource.CONSUMER, + couponId = event.couponId, + userId = event.userId, + reason = ex.message ?: ex::class.java.simpleName + ) + ) + ack.acknowledge() } } + + @KafkaListener( + topics = [TOPIC_DLT], + groupId = GROUP_ID_DLT, + containerFactory = "kafkaListenerContainerFactory" + ) + fun onDltMessage(event: CouponIssueDltEvent, ack: Acknowledgment) { + log.warn( + "[DLT][{}] couponId={}, userId={}, reason={}", + event.source, + event.couponId, + event.userId, + event.reason + ) + + // 일단 그냥 소비해서 토픽에 쌓이지 않게만 + ack.acknowledge() + } } \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/kafka/KafkaCouponIssueDltPublisher.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/kafka/KafkaCouponIssueDltPublisher.kt new file mode 100644 index 0000000..a7ca296 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/kafka/KafkaCouponIssueDltPublisher.kt @@ -0,0 +1,29 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.infra.kafka + +import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueDltPublisher +import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueDltEvent +import org.slf4j.LoggerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component + +@Component +class KafkaCouponIssueDltPublisher( + private val kafkaTemplate: KafkaTemplate, +) : CouponIssueDltPublisher { + + private val log = LoggerFactory.getLogger(javaClass) + + companion object { + private const val TOPIC_DLT = "coupon-issue-dlt-topic" + } + + override fun publish(event: CouponIssueDltEvent) { + val key = event.userId.toString() + + kafkaTemplate.send(TOPIC_DLT, key, event).whenComplete { _, ex -> + if (ex != null) { + log.error("Failed to send to DLT. couponId={}, userId={}", event.couponId, event.userId, ex) + } + } + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt new file mode 100644 index 0000000..bf0c4bc --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/value/DltSource.kt @@ -0,0 +1,5 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.value + +enum class DltSource { + PRODUCER, CONSUMER +} From 3f2f2be97dabc803a4e2e82d2c1020abbfec2d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Mon, 12 Jan 2026 23:59:51 +0900 Subject: [PATCH 43/54] =?UTF-8?q?[feat]:=20consumer=EA=B0=80=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CouponIssueWorkerService.kt | 11 +++++- .../consumer/listener/CouponIssueConsumer.kt | 36 ++++++++++++------- .../repository/IssuedCouponBatchRepository.kt | 8 +++++ .../persistence/IssuedCouponJdbcRepository.kt | 36 +++++++++++++++++++ .../infra/kafka/KafkaConsumerConfig.kt | 3 +- .../src/main/resources/application-local.yml | 2 +- .../src/main/resources/application-prod.yml | 2 +- .../src/test/resources/application-test.yml | 2 +- 8 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponBatchRepository.kt create mode 100644 coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt index bce0a9f..8d26859 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/application/service/CouponIssueWorkerService.kt @@ -1,6 +1,8 @@ package main.kotlin.com.woong2e.couponsystem.coupon.application.service +import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueEvent import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponBatchRepository import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -8,7 +10,8 @@ import java.util.UUID @Service class CouponIssueWorkerService( - private val issuedCouponRepository: IssuedCouponRepository + private val issuedCouponRepository: IssuedCouponRepository, + private val issuedCouponBatchRepository: IssuedCouponBatchRepository ) { @Transactional @@ -20,4 +23,10 @@ class CouponIssueWorkerService( issuedCouponRepository.save(issuedCoupon) } + + @Transactional + fun issueRequestBatch(events: List) { + val coupons = events.map { IssuedCoupon(it.couponId, it.userId) } + issuedCouponBatchRepository.batchInsert(coupons) + } } \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt index 7e90a5f..5cd33b4 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt @@ -23,35 +23,46 @@ class CouponIssueConsumer( private const val TOPIC_DLT = "coupon-issue-dlt-topic" private const val GROUP_ID = "coupon-issue-group" private const val GROUP_ID_DLT = "coupon-issue-dlt-group" - } + /** + * [Batch Listener 적용] + * ack-mode: batch, listener.type: batch 설정 필요 + * 메시지를 List 형태로 묶어서 수신 -> Bulk Insert로 처리량 극대화 + */ @KafkaListener( topics = [TOPIC], groupId = GROUP_ID, containerFactory = "kafkaListenerContainerFactory" ) - fun onMessage(event: CouponIssueEvent, ack: Acknowledgment) { + fun onMessage(events: List, ack: Acknowledgment) { runCatching { - log.info("Consumer Listen: couponId={}, userId={}", event.couponId, event.userId) - couponIssueWorkerService.issue(event.couponId, event.userId) + log.info("Consumer Batch Listen: size={}", events.size) + + couponIssueWorkerService.issueRequestBatch(events) }.onSuccess { ack.acknowledge() }.onFailure { ex -> - log.error("Consumer Failed -> send to DLT. couponId={}, userId={}", event.couponId, event.userId, ex) + log.error("Batch Consumer Failed -> Send all to DLT. size={}", events.size, ex) - couponIssueDltPublisher.publish( - CouponIssueDltEvent( - source = DltSource.CONSUMER, - couponId = event.couponId, - userId = event.userId, - reason = ex.message ?: ex::class.java.simpleName + events.forEach { event -> + couponIssueDltPublisher.publish( + CouponIssueDltEvent( + source = DltSource.CONSUMER, + couponId = event.couponId, + userId = event.userId, + reason = ex.message ?: ex::class.java.simpleName + ) ) - ) + } + ack.acknowledge() } } + /** + * DLT 리스너는 처리가 급하지 않으므로 단건 처리 유지, 일단 소모하도록 + */ @KafkaListener( topics = [TOPIC_DLT], groupId = GROUP_ID_DLT, @@ -66,7 +77,6 @@ class CouponIssueConsumer( event.reason ) - // 일단 그냥 소비해서 토픽에 쌓이지 않게만 ack.acknowledge() } } \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponBatchRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponBatchRepository.kt new file mode 100644 index 0000000..33369a6 --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/repository/IssuedCouponBatchRepository.kt @@ -0,0 +1,8 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.domain.repository + +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon + +interface IssuedCouponBatchRepository { + + fun batchInsert(coupons: List) +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt new file mode 100644 index 0000000..33d25eb --- /dev/null +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt @@ -0,0 +1,36 @@ +package main.kotlin.com.woong2e.couponsystem.coupon.infra.persistence + +import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon +import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponBatchRepository +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.PreparedStatement +import java.sql.Timestamp +import java.time.LocalDateTime + +@Repository +class IssuedCouponJdbcRepository( + private val jdbcTemplate: JdbcTemplate +) : IssuedCouponBatchRepository { + + override fun batchInsert(coupons: List) { + val sql = """ + INSERT INTO issued_coupon (coupon_id, user_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """.trimIndent() + + jdbcTemplate.batchUpdate( + sql, + coupons, + coupons.size, + { ps: PreparedStatement, coupon: IssuedCoupon -> + ps.setString(1, coupon.couponId.toString()) + ps.setLong(2, coupon.userId) + ps.setString(3, "ISSUED") + val now = Timestamp.valueOf(LocalDateTime.now()) + ps.setTimestamp(4, now) + ps.setTimestamp(5, now) + } + ) + } +} \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index 829e407..7019e9f 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -49,7 +49,8 @@ class KafkaConsumerConfig( factory.consumerFactory = consumerFactory() factory.setConcurrency(3) - factory.containerProperties.ackMode = kafkaProperties.listener.ackMode ?: ContainerProperties.AckMode.RECORD + factory.isBatchListener = true + factory.containerProperties.ackMode = kafkaProperties.listener.ackMode ?: ContainerProperties.AckMode.BATCH return factory } } \ No newline at end of file diff --git a/coupon-consumer/src/main/resources/application-local.yml b/coupon-consumer/src/main/resources/application-local.yml index 67a4dab..b67a587 100644 --- a/coupon-consumer/src/main/resources/application-local.yml +++ b/coupon-consumer/src/main/resources/application-local.yml @@ -6,7 +6,7 @@ spring: auto-offset-reset: latest enable-auto-commit: false listener: - ack-mode: record + ack-mode: batch datasource: url: ${DATABASE_URL} diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml index 2141358..515675e 100644 --- a/coupon-consumer/src/main/resources/application-prod.yml +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -13,7 +13,7 @@ spring: auto-offset-reset: latest enable-auto-commit: false listener: - ack-mode: manual + ack-mode: batch datasource: url: ${DATABASE_URL} diff --git a/coupon-consumer/src/test/resources/application-test.yml b/coupon-consumer/src/test/resources/application-test.yml index 460b706..fcb0df5 100644 --- a/coupon-consumer/src/test/resources/application-test.yml +++ b/coupon-consumer/src/test/resources/application-test.yml @@ -26,4 +26,4 @@ spring: auto-offset-reset: latest enable-auto-commit: true listener: - ack-mode: record \ No newline at end of file + ack-mode: batch \ No newline at end of file From bdb71c25b5c79622ae305e045d73e51135e7a1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 00:01:26 +0900 Subject: [PATCH 44/54] =?UTF-8?q?[rename]:=20JPA=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EB=AA=85=20=EC=BB=A8=EB=B2=A4=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CouponJpaRepository.kt} | 2 +- .../IssuedCouponJpaRepository.kt} | 2 +- .../IssuedCouponJpaRepository.kt} | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{jpa/JpaCouponRepository.kt => persistence/CouponJpaRepository.kt} (94%) rename coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{jpa/JpaIssuedCouponRepository.kt => persistence/IssuedCouponJpaRepository.kt} (90%) rename coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/{jpa/JpaIssuedCouponRepository.kt => persistence/IssuedCouponJpaRepository.kt} (68%) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/CouponJpaRepository.kt similarity index 94% rename from coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/CouponJpaRepository.kt index 17b4d4a..7a84d18 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/CouponJpaRepository.kt @@ -11,7 +11,7 @@ import org.springframework.data.repository.query.Param import java.util.Optional import java.util.UUID -interface JpaCouponRepository : CouponRepository, JpaRepository { +interface CouponJpaRepository : CouponRepository, JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select c from Coupon c where c.id = :id") diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJpaRepository.kt similarity index 90% rename from coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt rename to coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJpaRepository.kt index a59317f..a8db2b1 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJpaRepository.kt @@ -7,7 +7,7 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import java.util.UUID -interface JpaIssuedCouponRepository : IssuedCouponRepository, JpaRepository { +interface IssuedCouponJpaRepository : IssuedCouponRepository, JpaRepository { @Modifying @Query("DELETE FROM IssuedCoupon ic WHERE ic.couponId = :couponId") diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJpaRepository.kt similarity index 68% rename from coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt rename to coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJpaRepository.kt index ca98c1d..623de88 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/jpa/JpaIssuedCouponRepository.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJpaRepository.kt @@ -1,9 +1,9 @@ -package main.kotlin.com.woong2e.couponsystem.coupon.infra.jpa +package main.kotlin.com.woong2e.couponsystem.coupon.infra.persistence import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponRepository import org.springframework.data.jpa.repository.JpaRepository import java.util.UUID -interface JpaIssuedCouponRepository : IssuedCouponRepository, JpaRepository { +interface IssuedCouponJpaRepository : IssuedCouponRepository, JpaRepository { } \ No newline at end of file From 3f8c8439b64c163a1572bd33c65542e40cea3d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 00:29:00 +0900 Subject: [PATCH 45/54] =?UTF-8?q?[fix]:=20AckMode=EB=A5=BC=20MANUAL?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt | 2 +- coupon-consumer/src/main/resources/application-local.yml | 3 ++- coupon-consumer/src/main/resources/application-prod.yml | 3 ++- coupon-consumer/src/test/resources/application-test.yml | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index 7019e9f..4d109bd 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -50,7 +50,7 @@ class KafkaConsumerConfig( factory.setConcurrency(3) factory.isBatchListener = true - factory.containerProperties.ackMode = kafkaProperties.listener.ackMode ?: ContainerProperties.AckMode.BATCH + factory.containerProperties.ackMode = kafkaProperties.listener.ackMode ?: ContainerProperties.AckMode.MANUAL return factory } } \ No newline at end of file diff --git a/coupon-consumer/src/main/resources/application-local.yml b/coupon-consumer/src/main/resources/application-local.yml index b67a587..c2b1890 100644 --- a/coupon-consumer/src/main/resources/application-local.yml +++ b/coupon-consumer/src/main/resources/application-local.yml @@ -6,7 +6,8 @@ spring: auto-offset-reset: latest enable-auto-commit: false listener: - ack-mode: batch + ack-mode: manual + type: batch datasource: url: ${DATABASE_URL} diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml index 515675e..60ba651 100644 --- a/coupon-consumer/src/main/resources/application-prod.yml +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -13,7 +13,8 @@ spring: auto-offset-reset: latest enable-auto-commit: false listener: - ack-mode: batch + ack-mode: manual + type: batch datasource: url: ${DATABASE_URL} diff --git a/coupon-consumer/src/test/resources/application-test.yml b/coupon-consumer/src/test/resources/application-test.yml index fcb0df5..a612b88 100644 --- a/coupon-consumer/src/test/resources/application-test.yml +++ b/coupon-consumer/src/test/resources/application-test.yml @@ -26,4 +26,5 @@ spring: auto-offset-reset: latest enable-auto-commit: true listener: - ack-mode: batch \ No newline at end of file + ack-mode: manual + type: batch \ No newline at end of file From 53c43427aa5ca27544b5aecf61007f37fb354f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 00:31:24 +0900 Subject: [PATCH 46/54] =?UTF-8?q?[chore]:=20test=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=98=20enable-auto-commit=EC=9D=84=20fal?= =?UTF-8?q?se=EB=A1=9C=20=EC=84=A4=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-consumer/src/test/resources/application-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coupon-consumer/src/test/resources/application-test.yml b/coupon-consumer/src/test/resources/application-test.yml index a612b88..0fbc799 100644 --- a/coupon-consumer/src/test/resources/application-test.yml +++ b/coupon-consumer/src/test/resources/application-test.yml @@ -24,7 +24,7 @@ spring: consumer: group-id: test-group auto-offset-reset: latest - enable-auto-commit: true + enable-auto-commit: false listener: ack-mode: manual type: batch \ No newline at end of file From 18e52dd59154c7b0350a584d0111685932856a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 00:41:56 +0900 Subject: [PATCH 47/54] =?UTF-8?q?[fix]:=20=EC=BF=BC=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=AA=85=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/infra/persistence/IssuedCouponJdbcRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt index 33d25eb..547a3b7 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt @@ -15,7 +15,7 @@ class IssuedCouponJdbcRepository( override fun batchInsert(coupons: List) { val sql = """ - INSERT INTO issued_coupon (coupon_id, user_id, status, created_at, updated_at) + INSERT INTO issued_coupons (coupon_id, user_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?) """.trimIndent() From f7773cca3b9886075d3b92400542782de1aa0865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 00:43:09 +0900 Subject: [PATCH 48/54] =?UTF-8?q?[fix]:=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=ED=95=98=EC=97=AC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/producer/IssuedCouponProducer.kt | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt index caaf194..cfda45e 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt @@ -1,5 +1,6 @@ package main.kotlin.com.woong2e.couponsystem.coupon.infra.producer +import com.fasterxml.jackson.databind.ObjectMapper import main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueDltEvent import main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueEvent import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueEventPublisher @@ -11,7 +12,9 @@ import java.util.UUID @Component class IssuedCouponProducer( - private val kafkaTemplate: KafkaTemplate + // Value 타입을 String으로 명시 (직렬화 문제 해결 핵심) + private val kafkaTemplate: KafkaTemplate, + private val objectMapper: ObjectMapper ) : CouponIssueEventPublisher { private val log = LoggerFactory.getLogger(this::class.java) @@ -25,7 +28,14 @@ class IssuedCouponProducer( val event = CouponIssueEvent(couponId, userId) val key = userId.toString() - runCatching { kafkaTemplate.send(TOPIC, key, event) } + val messageJson = try { + objectMapper.writeValueAsString(event) + } catch (e: Exception) { + log.error("JSON serialization failed for event: $event", e) + return + } + + runCatching { kafkaTemplate.send(TOPIC, key, messageJson) } .onFailure { ex -> log.error("Kafka send() call failed -> send to DLT. couponId={}, userId={}", couponId, userId, ex) sendToDlt( @@ -49,25 +59,31 @@ class IssuedCouponProducer( couponId, userId ) - return@whenComplete - } - - log.error("Producer async send failed -> send to DLT. couponId={}, userId={}", couponId, userId, ex) - sendToDlt( - key = key, - dltEvent = CouponIssueDltEvent( - source = DltSource.PRODUCER, - couponId = couponId, - userId = userId, - reason = ex.message ?: ex::class.java.simpleName + } else { + log.error("Producer async send failed -> send to DLT. couponId={}, userId={}", couponId, userId, ex) + sendToDlt( + key = key, + dltEvent = CouponIssueDltEvent( + source = DltSource.PRODUCER, + couponId = couponId, + userId = userId, + reason = ex.message ?: ex::class.java.simpleName + ) ) - ) + } } } } private fun sendToDlt(key: String, dltEvent: CouponIssueDltEvent) { - kafkaTemplate.send(TOPIC_DLT, key, dltEvent).whenComplete { result, ex -> + val dltJson = try { + objectMapper.writeValueAsString(dltEvent) + } catch (e: Exception) { + log.error("Failed to serialize DLT event", e) + return + } + + kafkaTemplate.send(TOPIC_DLT, key, dltJson).whenComplete { result, ex -> if (ex == null) { log.warn( "Sent to DLT: topic={}, partition={}, offset={}, source={}, couponId={}, userId={}", From da606baa2e7d1a803a96e1ae7f8b80163ff5c1a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 01:03:15 +0900 Subject: [PATCH 49/54] =?UTF-8?q?[feat]:=20Json=20->=20String=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94/=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/producer/IssuedCouponProducer.kt | 1 - .../infra/kafka/KafkaProducerConfig.kt | 8 +-- .../src/main/resources/application-local.yml | 2 +- .../src/main/resources/application-prod.yml | 2 +- .../consumer/listener/CouponIssueConsumer.kt | 57 ++++++++++++------- .../infra/kafka/KafkaConsumerConfig.kt | 21 ++----- 6 files changed, 46 insertions(+), 45 deletions(-) diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt index cfda45e..b3c951f 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/producer/IssuedCouponProducer.kt @@ -12,7 +12,6 @@ import java.util.UUID @Component class IssuedCouponProducer( - // Value 타입을 String으로 명시 (직렬화 문제 해결 핵심) private val kafkaTemplate: KafkaTemplate, private val objectMapper: ObjectMapper ) : CouponIssueEventPublisher { diff --git a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt index cb86827..bea8def 100644 --- a/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt +++ b/coupon-api/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaProducerConfig.kt @@ -8,7 +8,6 @@ import org.springframework.context.annotation.Configuration import org.springframework.kafka.core.DefaultKafkaProducerFactory import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory -import org.springframework.kafka.support.serializer.JsonSerializer @Configuration class KafkaProducerConfig( @@ -21,14 +20,13 @@ class KafkaProducerConfig( } @Bean - fun producerFactory(): ProducerFactory { + fun producerFactory(): ProducerFactory { val props = HashMap() props[ProducerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers - props[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java - props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java + props[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java props[ProducerConfig.ACKS_CONFIG] = kafkaProperties.producer.acks props[ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG] = kafkaProperties.producer.properties["enable.idempotence"] ?: true @@ -42,7 +40,7 @@ class KafkaProducerConfig( } @Bean - fun kafkaTemplate(): KafkaTemplate { + fun kafkaTemplate(): KafkaTemplate { return KafkaTemplate(producerFactory()) } } \ No newline at end of file diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index f2529ae..2519246 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -3,7 +3,7 @@ spring: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all # 데이터 유실 방지 (필수) retries: 10 # 배치 크기 증가 (기본 16KB -> 1MB) diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index 2972326..2d92021 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -13,7 +13,7 @@ spring: bootstrap-servers: kafka:29092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all # 데이터 유실 방지 (필수) retries: 10 # 배치 크기 증가 (기본 16KB -> 1MB) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt index 5cd33b4..274be16 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/consumer/listener/CouponIssueConsumer.kt @@ -1,5 +1,6 @@ package main.kotlin.com.woong2e.couponsystem.coupon.consumer.listener +import com.fasterxml.jackson.databind.ObjectMapper import main.kotlin.com.woong2e.couponsystem.coupon.application.port.out.CouponIssueDltPublisher import main.kotlin.com.woong2e.couponsystem.coupon.application.service.CouponIssueWorkerService import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueDltEvent @@ -13,7 +14,8 @@ import org.springframework.stereotype.Component @Component class CouponIssueConsumer( private val couponIssueWorkerService: CouponIssueWorkerService, - private val couponIssueDltPublisher: CouponIssueDltPublisher + private val couponIssueDltPublisher: CouponIssueDltPublisher, + private val objectMapper: ObjectMapper // [추가] JSON 파싱용 ) { private val log = LoggerFactory.getLogger(this::class.java) @@ -25,20 +27,30 @@ class CouponIssueConsumer( private const val GROUP_ID_DLT = "coupon-issue-dlt-group" } - /** - * [Batch Listener 적용] - * ack-mode: batch, listener.type: batch 설정 필요 - * 메시지를 List 형태로 묶어서 수신 -> Bulk Insert로 처리량 극대화 - */ @KafkaListener( topics = [TOPIC], groupId = GROUP_ID, containerFactory = "kafkaListenerContainerFactory" ) - fun onMessage(events: List, ack: Acknowledgment) { + fun onMessage(messages: List, ack: Acknowledgment) { + val events = mutableListOf() + + messages.forEach { json -> + try { + val event = objectMapper.readValue(json, CouponIssueEvent::class.java) + events.add(event) + } catch (e: Exception) { + log.error("Failed to parse event json: {}", json, e) + } + } + + if (events.isEmpty()) { + ack.acknowledge() + return + } + runCatching { log.info("Consumer Batch Listen: size={}", events.size) - couponIssueWorkerService.issueRequestBatch(events) }.onSuccess { ack.acknowledge() @@ -55,28 +67,29 @@ class CouponIssueConsumer( ) ) } - ack.acknowledge() } } - /** - * DLT 리스너는 처리가 급하지 않으므로 단건 처리 유지, 일단 소모하도록 - */ @KafkaListener( topics = [TOPIC_DLT], groupId = GROUP_ID_DLT, containerFactory = "kafkaListenerContainerFactory" ) - fun onDltMessage(event: CouponIssueDltEvent, ack: Acknowledgment) { - log.warn( - "[DLT][{}] couponId={}, userId={}, reason={}", - event.source, - event.couponId, - event.userId, - event.reason - ) - - ack.acknowledge() + fun onDltMessage(message: String, ack: Acknowledgment) { + try { + val event = objectMapper.readValue(message, CouponIssueDltEvent::class.java) + log.warn( + "[DLT][{}] couponId={}, userId={}, reason={}", + event.source, + event.couponId, + event.userId, + event.reason + ) + } catch (e: Exception) { + log.error("Failed to parse DLT event json: {}", message, e) + } finally { + ack.acknowledge() + } } } \ No newline at end of file diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt index 4d109bd..689b281 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/infra/kafka/KafkaConsumerConfig.kt @@ -1,6 +1,5 @@ package main.kotlin.com.woong2e.couponsystem.infra.kafka -import main.kotlin.com.woong2e.couponsystem.coupon.consumer.event.CouponIssueEvent import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.common.serialization.StringDeserializer import org.springframework.boot.autoconfigure.kafka.KafkaProperties @@ -11,8 +10,6 @@ import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory import org.springframework.kafka.core.ConsumerFactory import org.springframework.kafka.core.DefaultKafkaConsumerFactory import org.springframework.kafka.listener.ContainerProperties -import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer -import org.springframework.kafka.support.serializer.JsonDeserializer @EnableKafka @Configuration @@ -21,31 +18,25 @@ class KafkaConsumerConfig( ) { @Bean - fun consumerFactory(): ConsumerFactory { + fun consumerFactory(): ConsumerFactory { val props = kafkaProperties.buildConsumerProperties() props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = kafkaProperties.bootstrapServers props[ConsumerConfig.GROUP_ID_CONFIG] = kafkaProperties.consumer.groupId - props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = kafkaProperties.consumer.autoOffsetReset - props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = kafkaProperties.consumer.enableAutoCommit - props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java - props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java - props[ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS] = JsonDeserializer::class.java + props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = false - props[JsonDeserializer.TRUSTED_PACKAGES] = "*" + props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java + props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = StringDeserializer::class.java - props[JsonDeserializer.USE_TYPE_INFO_HEADERS] = true - props[JsonDeserializer.TYPE_MAPPINGS] = - "main.kotlin.com.woong2e.couponsystem.coupon.application.event.CouponIssueEvent:" + CouponIssueEvent::class.java.name return DefaultKafkaConsumerFactory(props) } @Bean - fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { - val factory = ConcurrentKafkaListenerContainerFactory() + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() factory.consumerFactory = consumerFactory() factory.setConcurrency(3) From bb2081ce10800b5cb3d82ccfe9474b56d6cf8e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 01:28:15 +0900 Subject: [PATCH 50/54] =?UTF-8?q?[fix]:=20=EC=BB=AC=EB=9F=BC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=83=80=EC=9E=85=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20PreparedStatement=EB=A5=BC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/IssuedCouponJdbcRepository.kt | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt index 547a3b7..8a8a21e 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/infra/persistence/IssuedCouponJdbcRepository.kt @@ -4,9 +4,11 @@ import main.kotlin.com.woong2e.couponsystem.coupon.domain.entity.IssuedCoupon import main.kotlin.com.woong2e.couponsystem.coupon.domain.repository.IssuedCouponBatchRepository import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository +import java.nio.ByteBuffer import java.sql.PreparedStatement import java.sql.Timestamp import java.time.LocalDateTime +import java.util.* @Repository class IssuedCouponJdbcRepository( @@ -15,22 +17,33 @@ class IssuedCouponJdbcRepository( override fun batchInsert(coupons: List) { val sql = """ - INSERT INTO issued_coupons (coupon_id, user_id, status, created_at, updated_at) + INSERT INTO issued_coupons (id, coupon_id, user_id, status, issued_at) VALUES (?, ?, ?, ?, ?) """.trimIndent() + val now = Timestamp.valueOf(LocalDateTime.now()) + jdbcTemplate.batchUpdate( sql, coupons, - coupons.size, - { ps: PreparedStatement, coupon: IssuedCoupon -> - ps.setString(1, coupon.couponId.toString()) - ps.setLong(2, coupon.userId) - ps.setString(3, "ISSUED") - val now = Timestamp.valueOf(LocalDateTime.now()) - ps.setTimestamp(4, now) - ps.setTimestamp(5, now) - } - ) + coupons.size + ) { ps: PreparedStatement, coupon: IssuedCoupon -> + ps.setBytes(1, uuidToBytes(coupon.id)) + + ps.setBytes(2, uuidToBytes(coupon.couponId)) + + ps.setLong(3, coupon.userId) + + ps.setString(4, coupon.status) + + ps.setTimestamp(5, now) + } + } + + private fun uuidToBytes(uuid: UUID): ByteArray { + val bb = ByteBuffer.wrap(ByteArray(16)) + bb.putLong(uuid.mostSignificantBits) + bb.putLong(uuid.leastSignificantBits) + return bb.array() } } \ No newline at end of file From b2500f04ba19192270667d6ac20cbafb1c9ef928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 01:28:38 +0900 Subject: [PATCH 51/54] =?UTF-8?q?[fix]:=20userId=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=EB=A5=BC=20DB=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt index 6370257..2128f17 100644 --- a/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt +++ b/coupon-consumer/src/main/kotlin/com/woong2e/couponsystem/coupon/domain/entity/IssuedCoupon.kt @@ -14,7 +14,7 @@ class IssuedCoupon( @Column(name = "coupon_id", nullable = false, columnDefinition = "BINARY(16)") val couponId: UUID, - @Column(name = "user_id", nullable = false, columnDefinition = "BINARY(16)") + @Column(name = "user_id", nullable = false, columnDefinition = "BIGINT") val userId: Long ) : PrimaryKeyEntity() { From 58eeab2c33703b9813bcad19c5791381fbf28d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 01:57:20 +0900 Subject: [PATCH 52/54] =?UTF-8?q?[chore]:=2010ms=20=EB=8F=99=EC=95=88=20?= =?UTF-8?q?=EA=B8=B0=EB=8B=A4=EB=A0=B8=EB=8B=A4=EA=B0=80=20=EB=AA=A8?= =?UTF-8?q?=EC=95=84=EC=84=9C=20=EB=B0=9C=EC=86=A1=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-api/src/main/resources/application-prod.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index 2d92021..efd1a79 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -22,8 +22,8 @@ spring: compression-type: lz4 properties: enable.idempotence: true - # 50ms 동안 기다렸다가 모아서 발송 - linger.ms: 50 + # 10ms 동안 기다렸다가 모아서 발송 + linger.ms: 10 datasource: url: ${DATABASE_URL} From da17645dd04ee2bc00449a2cc06ecbdb4232aee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 02:03:21 +0900 Subject: [PATCH 53/54] =?UTF-8?q?[chore]:=20redis=EC=9D=98=20Connection=20?= =?UTF-8?q?pool=EC=9D=84=20=EC=A6=9D=EA=B0=80=EC=8B=9C=ED=82=A8=EB=8B=A4?= =?UTF-8?q?=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-api/src/main/resources/application-local.yml | 6 ++++++ coupon-api/src/main/resources/application-prod.yml | 6 ++++++ coupon-consumer/src/main/resources/application-local.yml | 3 +++ coupon-consumer/src/main/resources/application-prod.yml | 3 +++ 4 files changed, 18 insertions(+) diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index 2519246..7f00cf9 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -38,6 +38,12 @@ spring: redis: host: localhost port: 6379 + lettuce: + pool: + max-active: 64 + max-idle: 32 + min-idle: 16 + max-wait: 3000ms management: endpoints: diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index efd1a79..0b614d1 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -48,6 +48,12 @@ spring: redis: host: redis port: 6379 + lettuce: + pool: + max-active: 64 + max-idle: 32 + min-idle: 16 + max-wait: 3000ms management: endpoints: diff --git a/coupon-consumer/src/main/resources/application-local.yml b/coupon-consumer/src/main/resources/application-local.yml index c2b1890..ca63db6 100644 --- a/coupon-consumer/src/main/resources/application-local.yml +++ b/coupon-consumer/src/main/resources/application-local.yml @@ -32,6 +32,9 @@ spring: redis: host: localhost port: 6379 + max-active: 64 + max-idle: 32 + min-idle: 16 management: endpoints: diff --git a/coupon-consumer/src/main/resources/application-prod.yml b/coupon-consumer/src/main/resources/application-prod.yml index 60ba651..9c7c0c9 100644 --- a/coupon-consumer/src/main/resources/application-prod.yml +++ b/coupon-consumer/src/main/resources/application-prod.yml @@ -42,6 +42,9 @@ spring: redis: host: redis port: 6379 + max-active: 64 + max-idle: 32 + min-idle: 16 management: endpoints: From 2b46e800b98f3033cbc185054ef1647d51a68e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9E=AC=EC=9B=85=20=28JaeWoong=20Shin=29?= Date: Tue, 13 Jan 2026 02:23:54 +0900 Subject: [PATCH 54/54] =?UTF-8?q?[chore]:=20producer=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20Tomcat=EC=9D=98=20=EC=8A=A4=EB=A0=88=EB=93=9C=EB=A5=BC=2050?= =?UTF-8?q?=EA=B0=9C=EB=A1=9C=20=EC=A4=84=EC=9D=B8=EB=8B=A4=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coupon-api/src/main/resources/application-local.yml | 5 +++++ coupon-api/src/main/resources/application-prod.yml | 3 +++ 2 files changed, 8 insertions(+) diff --git a/coupon-api/src/main/resources/application-local.yml b/coupon-api/src/main/resources/application-local.yml index 7f00cf9..854be92 100644 --- a/coupon-api/src/main/resources/application-local.yml +++ b/coupon-api/src/main/resources/application-local.yml @@ -1,3 +1,8 @@ +server: + tomcat: + threads: + max: 50 + spring: kafka: bootstrap-servers: localhost:9092 diff --git a/coupon-api/src/main/resources/application-prod.yml b/coupon-api/src/main/resources/application-prod.yml index 0b614d1..208365b 100644 --- a/coupon-api/src/main/resources/application-prod.yml +++ b/coupon-api/src/main/resources/application-prod.yml @@ -1,5 +1,8 @@ server: port: 8080 + tomcat: + threads: + max: 50 spring: application: