From 01b46a23f879309266c6cae7afd8e0f3df37a4b8 Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:29:53 +0900
Subject: [PATCH 01/11] =?UTF-8?q?[ADD]=20git=20=EA=B4=80=EB=A0=A8=20?=
 =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .editorconfig                            |   8 +
 .gitattributes                           |   3 +
 .gitignore                               |  40 ++++
 gradle.properties                        |  18 ++
 gradle/wrapper/gradle-wrapper.jar        | Bin 0 -> 43583 bytes
 gradle/wrapper/gradle-wrapper.properties |   7 +
 gradlew                                  | 252 +++++++++++++++++++++++
 gradlew.bat                              |  94 +++++++++
 8 files changed, 422 insertions(+)
 create mode 100644 .editorconfig
 create mode 100644 .gitattributes
 create mode 100644 .gitignore
 create mode 100644 gradle.properties
 create mode 100644 gradle/wrapper/gradle-wrapper.jar
 create mode 100644 gradle/wrapper/gradle-wrapper.properties
 create mode 100755 gradlew
 create mode 100644 gradlew.bat

diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..5ba9ca464
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,8 @@
+root = true
+
+[*.{java,class,kt,kts}]
+max_line_length=130
+insert_final_newline = true
+
+[*Test.java]
+max_line_length = off
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..8af972cde
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+/gradlew text eol=lf
+*.bat text eol=crlf
+*.jar binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..5a979af6f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,40 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Kotlin ###
+.kotlin
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 000000000..142d7120f
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,18 @@
+### Project Group ###
+projectGroup=com.loopers
+### Project dependency versions ###
+kotlinVersion=2.0.20
+### Plugin dependency versions ###
+ktLintPluginVersion=12.1.2
+ktLintVersion=1.0.1
+### Spring dependency versions ###
+springBootVersion=3.4.4
+springDependencyManagementVersion=1.1.7
+springCloudDependenciesVersion=2024.0.1
+### Library versions ###
+springDocOpenApiVersion=2.7.0
+springMockkVersion=4.0.2
+mockitoVersion=5.14.0
+instancioJUnitVersion=5.0.2
+slackAppenderVersion=1.6.1
+kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189
GIT binary patch
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..37f853b1c
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 000000000..f5feea6d6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+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
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# 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" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * 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" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..9d21a2183
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+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%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

From dc893a0b6d2fc52e15f625dde2ab6844a27a269a Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:31:24 +0900
Subject: [PATCH 02/11] =?UTF-8?q?[ADD]=20gradle=20=EC=84=A4=EC=A0=95=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 apps/commerce-api/build.gradle.kts   |  18 +++++
 build.gradle.kts                     | 111 +++++++++++++++++++++++++++
 modules/jpa/build.gradle.kts         |  18 +++++
 settings.gradle.kts                  |  30 ++++++++
 supports/jackson/build.gradle.kts    |  11 +++
 supports/logging/build.gradle.kts    |   9 +++
 supports/monitoring/build.gradle.kts |   4 +
 7 files changed, 201 insertions(+)
 create mode 100644 apps/commerce-api/build.gradle.kts
 create mode 100644 build.gradle.kts
 create mode 100644 modules/jpa/build.gradle.kts
 create mode 100644 settings.gradle.kts
 create mode 100644 supports/jackson/build.gradle.kts
 create mode 100644 supports/logging/build.gradle.kts
 create mode 100644 supports/monitoring/build.gradle.kts

diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
new file mode 100644
index 000000000..6ff16d9e9
--- /dev/null
+++ b/apps/commerce-api/build.gradle.kts
@@ -0,0 +1,18 @@
+dependencies {
+    // add-ons
+    implementation(project(":modules:jpa"))
+    implementation(project(":supports:jackson"))
+    implementation(project(":supports:logging"))
+    implementation(project(":supports:monitoring"))
+
+    // web
+    implementation("org.springframework.boot:spring-boot-starter-web")
+    implementation("org.springframework.boot:spring-boot-starter-actuator")
+    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
+
+    // querydsl
+    implementation("com.querydsl:querydsl-jpa::jakarta")
+
+    // test-fixtures
+    testImplementation(testFixtures(project(":modules:jpa")))
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 000000000..9c8490b8a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,111 @@
+import org.gradle.api.Project.DEFAULT_VERSION
+import org.springframework.boot.gradle.tasks.bundling.BootJar
+
+/** --- configuration functions --- */
+fun getGitHash(): String {
+    return runCatching {
+        providers.exec {
+            commandLine("git", "rev-parse", "--short", "HEAD")
+        }.standardOutput.asText.get().trim()
+    }.getOrElse { "init" }
+}
+
+/** --- project configurations --- */
+plugins {
+    java
+    id("org.springframework.boot") apply false
+    id("io.spring.dependency-management")
+}
+
+java {
+    toolchain {
+        languageVersion = JavaLanguageVersion.of(21)
+    }
+}
+
+allprojects {
+    val projectGroup: String by project
+    group = projectGroup
+    version = if (version == DEFAULT_VERSION) getGitHash() else version
+
+    repositories {
+        mavenCentral()
+    }
+}
+
+subprojects {
+    apply(plugin = "java")
+    apply(plugin = "org.springframework.boot")
+    apply(plugin = "io.spring.dependency-management")
+    apply(plugin = "jacoco")
+
+    dependencyManagement {
+        imports {
+            mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}")
+        }
+    }
+
+    dependencies {
+        // Web
+        runtimeOnly("org.springframework.boot:spring-boot-starter-validation")
+        // Spring
+        implementation("org.springframework.boot:spring-boot-starter")
+        // Serialize
+        implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
+        // Lombok
+        implementation("org.projectlombok:lombok")
+        annotationProcessor("org.projectlombok:lombok")
+        // Test
+        testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+        // testcontainers:mysql 이 jdbc 사용함
+        testRuntimeOnly("com.mysql:mysql-connector-j")
+        testImplementation("org.springframework.boot:spring-boot-starter-test")
+        testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}")
+        testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}")
+        testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}")
+        // Testcontainers
+        testImplementation("org.springframework.boot:spring-boot-testcontainers")
+        testImplementation("org.testcontainers:testcontainers")
+        testImplementation("org.testcontainers:junit-jupiter")
+    }
+
+    tasks.withType(Jar::class) { enabled = true }
+    tasks.withType(BootJar::class) { enabled = false }
+
+    configure(allprojects.filter { it.parent?.name.equals("apps") }) {
+        tasks.withType(Jar::class) { enabled = false }
+        tasks.withType(BootJar::class) { enabled = true }
+    }
+
+    tasks.test {
+        maxParallelForks = 1
+        useJUnitPlatform()
+        systemProperty("user.timezone", "Asia/Seoul")
+        systemProperty("spring.profiles.active", "test")
+        jvmArgs("-Xshare:off")
+    }
+
+    tasks.withType<JacocoReport> {
+        mustRunAfter("test")
+        executionData(fileTree(layout.buildDirectory.asFile).include("jacoco/*.exec"))
+        reports {
+            xml.required = true
+            csv.required = false
+            html.required = false
+        }
+        afterEvaluate {
+            classDirectories.setFrom(
+                files(
+                    classDirectories.files.map {
+                        fileTree(it)
+                    },
+                ),
+            )
+        }
+    }
+}
+
+// module-container 는 task 를 실행하지 않도록 한다.
+project("apps") { tasks.configureEach { enabled = false } }
+project("modules") { tasks.configureEach { enabled = false } }
+project("supports") { tasks.configureEach { enabled = false } }
diff --git a/modules/jpa/build.gradle.kts b/modules/jpa/build.gradle.kts
new file mode 100644
index 000000000..fdc9c08f1
--- /dev/null
+++ b/modules/jpa/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+    `java-library`
+    `java-test-fixtures`
+}
+
+dependencies {
+    // jpa
+    api("org.springframework.boot:spring-boot-starter-data-jpa")
+    // querydsl
+    api("com.querydsl:querydsl-jpa::jakarta")
+    // jdbc-mysql
+    runtimeOnly("com.mysql:mysql-connector-j")
+
+    testImplementation("org.testcontainers:mysql")
+
+    testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa")
+    testFixturesImplementation("org.testcontainers:mysql")
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 000000000..2b1b10f99
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,30 @@
+rootProject.name = "loopers-java-spring-template"
+
+include(
+    ":apps:commerce-api",
+    ":modules:jpa",
+    ":supports:jackson",
+    ":supports:logging",
+    ":supports:monitoring",
+)
+
+// configurations
+pluginManagement {
+    val springBootVersion: String by settings
+    val springDependencyManagementVersion: String by settings
+
+    repositories {
+        maven { url = uri("https://repo.spring.io/milestone") }
+        maven { url = uri("https://repo.spring.io/snapshot") }
+        gradlePluginPortal()
+    }
+
+    resolutionStrategy {
+        eachPlugin {
+            when (requested.id.id) {
+                "org.springframework.boot" -> useVersion(springBootVersion)
+                "io.spring.dependency-management" -> useVersion(springDependencyManagementVersion)
+            }
+        }
+    }
+}
diff --git a/supports/jackson/build.gradle.kts b/supports/jackson/build.gradle.kts
new file mode 100644
index 000000000..f73320711
--- /dev/null
+++ b/supports/jackson/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+    `java-library`
+}
+
+dependencies {
+    // spring
+    implementation("org.springframework:spring-web")
+    // jackson
+    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
+    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
+}
diff --git a/supports/logging/build.gradle.kts b/supports/logging/build.gradle.kts
new file mode 100644
index 000000000..b75ad0508
--- /dev/null
+++ b/supports/logging/build.gradle.kts
@@ -0,0 +1,9 @@
+dependencies {
+    // spring
+    implementation("org.springframework.boot:spring-boot-starter-actuator")
+    // monitoring
+    implementation("io.micrometer:micrometer-registry-prometheus")
+    implementation("io.micrometer:micrometer-tracing-bridge-brave")
+    // Slack Appender
+    implementation("com.github.maricn:logback-slack-appender:${project.properties["slackAppenderVersion"]}")
+}
diff --git a/supports/monitoring/build.gradle.kts b/supports/monitoring/build.gradle.kts
new file mode 100644
index 000000000..96f767afc
--- /dev/null
+++ b/supports/monitoring/build.gradle.kts
@@ -0,0 +1,4 @@
+dependencies {
+    implementation("org.springframework.boot:spring-boot-starter-actuator")
+    implementation("io.micrometer:micrometer-registry-prometheus")
+}

From 80a7e3963b429df894cab47bd22f430b04cdf744 Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:32:08 +0900
Subject: [PATCH 03/11] =?UTF-8?q?[ADD]=20:supports:logging=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../appenders/json-console-appender.xml       |  6 +++
 .../resources/appenders/slack-appender.xml    | 23 +++++++++
 .../src/main/resources/logback/logback.xml    | 49 +++++++++++++++++++
 .../logging/src/main/resources/logging.yml    |  1 +
 .../resources/properties/slack-log-dev.xml    |  6 +++
 .../resources/properties/slack-log-prd.xml    |  6 +++
 .../resources/properties/slack-log-qa.xml     |  6 +++
 7 files changed, 97 insertions(+)
 create mode 100644 supports/logging/src/main/resources/appenders/json-console-appender.xml
 create mode 100644 supports/logging/src/main/resources/appenders/slack-appender.xml
 create mode 100644 supports/logging/src/main/resources/logback/logback.xml
 create mode 100644 supports/logging/src/main/resources/logging.yml
 create mode 100644 supports/logging/src/main/resources/properties/slack-log-dev.xml
 create mode 100644 supports/logging/src/main/resources/properties/slack-log-prd.xml
 create mode 100644 supports/logging/src/main/resources/properties/slack-log-qa.xml

diff --git a/supports/logging/src/main/resources/appenders/json-console-appender.xml b/supports/logging/src/main/resources/appenders/json-console-appender.xml
new file mode 100644
index 000000000..d3b22c38b
--- /dev/null
+++ b/supports/logging/src/main/resources/appenders/json-console-appender.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<included>
+    <property name="CONSOLE_LOG_STRUCTURED_FORMAT" value="logstash"/>
+    <include resource="org/springframework/boot/logging/logback/defaults.xml" />
+    <include resource="org/springframework/boot/logging/logback/structured-console-appender.xml" />
+</included>
\ No newline at end of file
diff --git a/supports/logging/src/main/resources/appenders/slack-appender.xml b/supports/logging/src/main/resources/appenders/slack-appender.xml
new file mode 100644
index 000000000..a6175a185
--- /dev/null
+++ b/supports/logging/src/main/resources/appenders/slack-appender.xml
@@ -0,0 +1,23 @@
+<included>
+    <springProperty name="appName" source="spring.application.name"/>
+    <appender name="SLACK" class="com.github.maricn.logback.SlackAppender">
+        <layout class="ch.qos.logback.classic.PatternLayout">
+            <pattern>
+                *Application:* *`${appName:-}`*
+                *[%-5level]* *`%X{method:-}`* *`%X{requestUri:-}`* *Pod:* `${HOSTNAME:-}` *Trace ID:* `%X{traceId:-}` *Span ID:* `%X{spanId:-}` *Client IP:* `%X{clientIp:-}`%n%msg%n
+            </pattern>
+        </layout>
+        <webhookUri>${SLACK_WEBHOOK_URI}</webhookUri>
+        <username>${SLACK_USERNAME}</username>
+        <channel>${SLACK_CHANNEL}</channel>
+        <iconEmoji>:bell:</iconEmoji>
+        <colorCoding>true</colorCoding>
+    </appender> <!-- Currently recommended way of using Slack appender -->
+
+    <appender name="ASYNC-SLACK" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="SLACK"/>
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>ERROR</level>
+        </filter>
+    </appender>
+</included>
\ No newline at end of file
diff --git a/supports/logging/src/main/resources/logback/logback.xml b/supports/logging/src/main/resources/logback/logback.xml
new file mode 100644
index 000000000..780d4fb8f
--- /dev/null
+++ b/supports/logging/src/main/resources/logback/logback.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<configuration>
+    <springProfile name="local,local-dev,test">
+        <include resource="appenders/plain-console-appender.xml"/>
+        <logger name="com.loopers" level="DEBUG"/>
+        <root level="INFO">
+            <appender-ref ref="CONSOLE"/>
+        </root>
+    </springProfile>
+
+    <springProfile name="dev">
+        <include resource="appenders/json-console-appender.xml"/>
+        <include resource="appenders/slack-appender.xml"/>
+        <include resource="properties/slack-log-dev.xml"/>
+        <logger name="com.loopers" level="DEBUG"/>
+        <root level="INFO">
+            <appender-ref ref="CONSOLE"/>
+            <!-- /properties.slack-log-dev.xml 설정 후 활성화
+            <appender-ref ref="ASYNC-SLACK"/>
+             -->
+        </root>
+    </springProfile>
+
+    <springProfile name="qa">
+        <include resource="appenders/json-console-appender.xml"/>
+        <include resource="appenders/slack-appender.xml"/>
+        <include resource="properties/slack-log-qa.xml"/>
+        <logger name="com.loopers" level="INFO"/>
+        <root level="INFO">
+            <appender-ref ref="CONSOLE"/>
+            <!-- /properties.slack-log-qa.xml 설정 후 활성화
+            <appender-ref ref="ASYNC-SLACK"/>
+             -->
+        </root>
+    </springProfile>
+
+    <springProfile name="prd">
+        <include resource="appenders/json-console-appender.xml"/>
+        <include resource="appenders/slack-appender.xml"/>
+        <include resource="properties/slack-log-prd.xml"/>
+        <logger name="com.loopers" level="INFO"/>
+        <root level="INFO">
+            <appender-ref ref="CONSOLE"/>
+            <!-- /properties.slack-log-prd.xml 설정 후 활성화
+            <appender-ref ref="ASYNC-SLACK"/>
+             -->
+        </root>
+    </springProfile>
+</configuration>
\ No newline at end of file
diff --git a/supports/logging/src/main/resources/logging.yml b/supports/logging/src/main/resources/logging.yml
new file mode 100644
index 000000000..f5ec7a95b
--- /dev/null
+++ b/supports/logging/src/main/resources/logging.yml
@@ -0,0 +1 @@
+logging.config: classpath:logback/logback.xml
diff --git a/supports/logging/src/main/resources/properties/slack-log-dev.xml b/supports/logging/src/main/resources/properties/slack-log-dev.xml
new file mode 100644
index 000000000..02da0736a
--- /dev/null
+++ b/supports/logging/src/main/resources/properties/slack-log-dev.xml
@@ -0,0 +1,6 @@
+<included>
+    <property name="SLACK_USERNAME" value=""/>
+    <property name="SLACK_CHANNEL" value=""/>
+    <property name="SLACK_WEBHOOK_URI"
+              value=""/>
+</included>
diff --git a/supports/logging/src/main/resources/properties/slack-log-prd.xml b/supports/logging/src/main/resources/properties/slack-log-prd.xml
new file mode 100644
index 000000000..02da0736a
--- /dev/null
+++ b/supports/logging/src/main/resources/properties/slack-log-prd.xml
@@ -0,0 +1,6 @@
+<included>
+    <property name="SLACK_USERNAME" value=""/>
+    <property name="SLACK_CHANNEL" value=""/>
+    <property name="SLACK_WEBHOOK_URI"
+              value=""/>
+</included>
diff --git a/supports/logging/src/main/resources/properties/slack-log-qa.xml b/supports/logging/src/main/resources/properties/slack-log-qa.xml
new file mode 100644
index 000000000..02da0736a
--- /dev/null
+++ b/supports/logging/src/main/resources/properties/slack-log-qa.xml
@@ -0,0 +1,6 @@
+<included>
+    <property name="SLACK_USERNAME" value=""/>
+    <property name="SLACK_CHANNEL" value=""/>
+    <property name="SLACK_WEBHOOK_URI"
+              value=""/>
+</included>

From f658a5d964235acd9e7281ff9891ca0c5ee7b37f Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:32:20 +0900
Subject: [PATCH 04/11] =?UTF-8?q?[ADD]=20:supports:monitoring=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/main/resources/monitoring.yml         | 40 +++++++++++++++++++
 1 file changed, 40 insertions(+)
 create mode 100644 supports/monitoring/src/main/resources/monitoring.yml

diff --git a/supports/monitoring/src/main/resources/monitoring.yml b/supports/monitoring/src/main/resources/monitoring.yml
new file mode 100644
index 000000000..c6a87a9cf
--- /dev/null
+++ b/supports/monitoring/src/main/resources/monitoring.yml
@@ -0,0 +1,40 @@
+management:
+  metrics:
+    distribution:
+      percentiles-histogram:
+        http.server.requests: true
+    tags:
+      application:
+        ${spring.application.name}
+  endpoints:
+    web:
+      exposure:
+        include:
+          - health
+          - prometheus
+  endpoint:
+    health:
+      probes:
+        enabled: true
+      group:
+        liveness:
+          show-components: always
+          include:
+            - livenessState
+        readiness:
+          show-components: always
+    prometheus:
+      access: read_only
+  health:
+    livenessState:
+      enabled: true
+    readinessState:
+      enabled: true
+  server:
+    port: 8081
+  observations:
+    annotations:
+      enabled: true
+    key-values:
+      application:
+        ${spring.application.name}
\ No newline at end of file

From 549249b35ac0a892ac7305e6beaa5afb53ed6d99 Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:35:43 +0900
Subject: [PATCH 05/11] =?UTF-8?q?[ADD]=20:modules:jpa=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/config/jpa/JpaConfig.java     | 13 +++
 .../loopers/config/jpa/QueryDslConfig.java    | 17 ++++
 .../java/com/loopers/domain/BaseEntity.java   | 74 ++++++++++++++
 modules/jpa/src/main/resources/jpa.yml        | 96 +++++++++++++++++++
 .../MySqlTestContainersConfig.java            | 36 +++++++
 .../com/loopers/utils/DatabaseCleanUp.java    | 41 ++++++++
 6 files changed, 277 insertions(+)
 create mode 100644 modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java
 create mode 100644 modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java
 create mode 100644 modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java
 create mode 100644 modules/jpa/src/main/resources/jpa.yml
 create mode 100644 modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
 create mode 100644 modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java

diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java
new file mode 100644
index 000000000..7fad5872b
--- /dev/null
+++ b/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java
@@ -0,0 +1,13 @@
+package com.loopers.config.jpa;
+
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+@Configuration
+@EnableTransactionManagement
+@EntityScan({"com.loopers"})
+@EnableJpaRepositories({"com.loopers.infrastructure"})
+public class JpaConfig {
+}
diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java
new file mode 100644
index 000000000..2b95dd37a
--- /dev/null
+++ b/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java
@@ -0,0 +1,17 @@
+package com.loopers.config.jpa;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+@Configuration
+class QueryDslConfig {
+
+    @Primary
+    @Bean
+    JPAQueryFactory mySqlJpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}
diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java
new file mode 100644
index 000000000..5f2282a47
--- /dev/null
+++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java
@@ -0,0 +1,74 @@
+package com.loopers.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.PreUpdate;
+import lombok.Getter;
+import java.time.ZonedDateTime;
+
+
+/**
+ * 생성/수정/삭제 정보를 자동으로 관리해준다.
+ * 재사용성을 위해 이 외의 컬럼이나 동작은 추가하지 않는다.
+ */
+@MappedSuperclass
+@Getter
+public abstract class BaseEntity {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private final Long id = 0L;
+
+    @Column(name = "created_at", nullable = false, updatable = false)
+    private ZonedDateTime createdAt;
+
+    @Column(name = "updated_at", nullable = false)
+    private ZonedDateTime updatedAt;
+
+    @Column(name = "deleted_at")
+    private ZonedDateTime deletedAt;
+
+    /**
+     * 엔티티의 유효성을 검증한다.
+     * 이 메소드는 PrePersist 및 PreUpdate 시점에 호출된다.
+     */
+    protected void guard() {}
+
+    @PrePersist
+    private void prePersist() {
+        guard();
+
+        ZonedDateTime now = ZonedDateTime.now();
+        this.createdAt = now;
+        this.updatedAt = now;
+    }
+
+    @PreUpdate
+    private void preUpdate() {
+        guard();
+
+        this.updatedAt = ZonedDateTime.now();
+    }
+
+    /**
+     * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록)
+     */
+    public void delete() {
+        if (this.deletedAt == null) {
+            this.deletedAt = ZonedDateTime.now();
+        }
+    }
+
+    /**
+     * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록)
+     */
+    public void restore() {
+        if (this.deletedAt != null) {
+            this.deletedAt = null;
+        }
+    }
+}
diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml
new file mode 100644
index 000000000..37f4fb1b0
--- /dev/null
+++ b/modules/jpa/src/main/resources/jpa.yml
@@ -0,0 +1,96 @@
+spring:
+  jpa:
+    open-in-view: false
+    generate-ddl: false
+    show-sql: false
+    hibernate:
+      ddl-auto: none
+    properties:
+      hibernate:
+        default_batch_fetch_size: 100
+        timezone.default_storage: NORMALIZE_UTC
+        jdbc.time_zone: UTC
+
+datasource:
+  mysql-jpa:
+    main:
+      driver-class-name: com.mysql.cj.jdbc.Driver
+      jdbc-url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}
+      username: ${MYSQL_USER}
+      password: "${MYSQL_PWD}"
+      pool-name: mysql-main-pool
+      maximum-pool-size: 40
+      minimum-idle: 30
+      connection-timeout: 3000 # 커넥션 획득 대기시간(ms) ( default: 3000 = 3sec )
+      validation-timeout: 5000 # 커넥션 유효성 검사시간(ms) ( default: 5000 = 5sec )
+      keepalive-time: 0 # 커넥션 최대 생존시간(ms) ( default: 0 )
+      max-lifetime: 1800000 # 커넥션 최대 생존시간(ms) ( default: 1800000 = 30min )
+      leak-detection-threshold: 0  # 커넥션 누수 감지 (주어진 ms 내에 반환 안 하면 로그 경고) ( default: 0 = 비활성화 )
+      initialization-fail-timeout: 1  # DB 연결 실패 시 즉시 예외 발생 ( default: -1 = 무한대기 )
+      data-source-properties:
+        rewriteBatchedStatements: true
+
+---
+spring.config.activate.on-profile: local
+
+spring:
+  jpa:
+    show-sql: true
+    hibernate:
+      ddl-auto: create
+
+datasource:
+  mysql-jpa:
+    main:
+      jdbc-url: jdbc:mysql://localhost:3306/loopers
+      username: application
+      password: application
+
+---
+spring.config.activate.on-profile: test
+
+spring:
+  jpa:
+    show-sql: true
+    hibernate:
+      ddl-auto: create
+
+datasource:
+  mysql-jpa:
+    main:
+      maximum-pool-size: 10
+      minimum-idle: 5
+
+---
+spring.config.activate.on-profile: dev
+
+spring:
+  jpa:
+    show-sql: true
+
+datasource:
+  mysql-jpa:
+    main:
+      jdbc-url: jdbc:mysql://localhost:3306/loopers
+      username: application
+      password: application
+
+---
+spring.config.activate.on-profile: qa
+
+datasource:
+  mysql-jpa:
+    main:
+      jdbc-url: jdbc:mysql://localhost:3306/loopers
+      username: application
+      password: application
+
+---
+spring.config.activate.on-profile: prd
+
+datasource:
+  mysql-jpa:
+    main:
+      jdbc-url: jdbc:mysql://localhost:3306/loopers
+      username: application
+      password: application
diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
new file mode 100644
index 000000000..8f46fa8e2
--- /dev/null
+++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
@@ -0,0 +1,36 @@
+package com.loopers.testcontainers;
+
+import org.springframework.context.annotation.Configuration;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+@Configuration
+public class MySqlTestContainersConfig {
+
+    private static final MySQLContainer<?> mySqlContainer;
+
+    static {
+        mySqlContainer = new MySQLContainer<>(DockerImageName.parse("mysql:5.7"))
+            .withDatabaseName("loopers")
+            .withUsername("test")
+            .withPassword("test")
+            .withExposedPorts(3306)
+            .withCommand(
+                "--character-set-server=utf8mb4",
+                "--collation-server=utf8mb4_general_ci",
+                "--skip-character-set-client-handshake"
+            );
+        mySqlContainer.start();
+
+        String mySqlJdbcUrl = String.format(
+            "jdbc:mysql://%s:%d/%s",
+            mySqlContainer.getHost(),
+            mySqlContainer.getFirstMappedPort(),
+            mySqlContainer.getDatabaseName()
+        );
+
+        System.setProperty("datasource.mysql-jpa.main.jdbc-url", mySqlJdbcUrl);
+        System.setProperty("datasource.mysql-jpa.main.username", mySqlContainer.getUsername());
+        System.setProperty("datasource.mysql-jpa.main.password", mySqlContainer.getPassword());
+    }
+}
diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
new file mode 100644
index 000000000..85c43ace9
--- /dev/null
+++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
@@ -0,0 +1,41 @@
+package com.loopers.utils;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.Table;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+public class DatabaseCleanUp implements InitializingBean {
+
+    @PersistenceContext
+    private EntityManager entityManager;
+
+    private final List<String> tableNames = new ArrayList<>();
+
+    @Override
+    public void afterPropertiesSet() {
+        entityManager.getMetamodel().getEntities().stream()
+            .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null)
+            .map(entity -> entity.getJavaType().getAnnotation(Table.class).name())
+            .forEach(tableNames::add);
+    }
+
+    @Transactional
+    public void truncateAllTables() {
+        entityManager.flush();
+        entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
+
+        for (String table : tableNames) {
+            entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate();
+        }
+
+        entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
+    }
+}

From 32ddc467b68c2b9fa8de18cf32485f570dcf92e8 Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:36:04 +0900
Subject: [PATCH 06/11] =?UTF-8?q?[ADD]=20:supports:jackson=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/config/jackson/JacksonConfig.java | 48 +++++++++++++++++++
 1 file changed, 48 insertions(+)
 create mode 100644 supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java

diff --git a/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java b/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
new file mode 100644
index 000000000..4f8bda6f4
--- /dev/null
+++ b/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
@@ -0,0 +1,48 @@
+package com.loopers.config.jackson;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+
+@Configuration
+class JacksonConfig {
+
+    @Order(Ordered.HIGHEST_PRECEDENCE)
+    @Bean
+    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
+        return builder -> {
+            // Classpath 내의 모든 Jackson 모듈 자동 등록
+            builder.findModulesViaServiceLoader(true);
+
+            // Serialization Features
+            builder.serializationInclusion(JsonInclude.Include.NON_NULL);
+            builder.featuresToEnable(
+                JsonGenerator.Feature.AUTO_CLOSE_JSON_CONTENT,
+                JsonGenerator.Feature.IGNORE_UNKNOWN,
+                JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN
+            );
+            builder.featuresToDisable(
+                SerializationFeature.FAIL_ON_EMPTY_BEANS
+            );
+
+            // Deserialization Features
+            builder.featuresToEnable(
+                DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT,
+                DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY,
+                DeserializationFeature.READ_ENUMS_USING_TO_STRING,
+                DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES
+            );
+            builder.featuresToDisable(
+                DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL,
+                DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES,
+                DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
+            );
+        };
+    }
+}

From 956131232fde9684fe6ed1cfd1e680558c2975fc Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:36:42 +0900
Subject: [PATCH 07/11] =?UTF-8?q?[ADD]=20=EB=88=84=EB=9D=BD=EB=90=9C=20Dat?=
 =?UTF-8?q?aSourceConfig=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/config/jpa/DataSourceConfig.java  | 24 +++++++++++++++++++
 1 file changed, 24 insertions(+)
 create mode 100644 modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java

diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java
new file mode 100644
index 000000000..723ffa6a4
--- /dev/null
+++ b/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java
@@ -0,0 +1,24 @@
+package com.loopers.config.jpa;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+
+@Configuration
+class DataSourceConfig {
+    @Bean
+    @ConfigurationProperties(prefix = "datasource.mysql-jpa.main")
+    HikariConfig mySqlMainHikariConfig() {
+        return new HikariConfig();
+    }
+
+    @Primary
+    @Bean
+    HikariDataSource mySqlMainDataSource(@Qualifier("mySqlMainHikariConfig") HikariConfig hikariConfig) {
+        return new HikariDataSource(hikariConfig);
+    }
+}

From 3f869a37c8dae404b2bd5d64585fb60d2de62843 Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:37:14 +0900
Subject: [PATCH 08/11] =?UTF-8?q?[ADD]=20:apps:commerce-api=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/CommerceApiApplication.java   |  22 +++
 .../application/example/ExampleFacade.java    |  17 +++
 .../application/example/ExampleInfo.java      |  13 ++
 .../loopers/domain/example/ExampleModel.java  |  44 ++++++
 .../domain/example/ExampleRepository.java     |   7 +
 .../domain/example/ExampleService.java        |  20 +++
 .../example/ExampleJpaRepository.java         |   6 +
 .../example/ExampleRepositoryImpl.java        |  19 +++
 .../interfaces/api/ApiControllerAdvice.java   | 126 ++++++++++++++++++
 .../loopers/interfaces/api/ApiResponse.java   |  32 +++++
 .../api/example/ExampleV1ApiSpec.java         |  19 +++
 .../api/example/ExampleV1Controller.java      |  28 ++++
 .../interfaces/api/example/ExampleV1Dto.java  |  15 +++
 .../loopers/support/error/CoreException.java  |  19 +++
 .../com/loopers/support/error/ErrorType.java  |  19 +++
 .../src/main/resources/application.yml        |  57 ++++++++
 .../com/loopers/CommerceApiContextTest.java   |  14 ++
 .../domain/example/ExampleModelTest.java      |  65 +++++++++
 .../ExampleServiceIntegrationTest.java        |  72 ++++++++++
 .../interfaces/api/ExampleV1ApiE2ETest.java   | 115 ++++++++++++++++
 .../support/error/CoreExceptionTest.java      |  34 +++++
 21 files changed, 763 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
 create mode 100644 apps/commerce-api/src/main/resources/application.yml
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
new file mode 100644
index 000000000..9027b51bf
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
@@ -0,0 +1,22 @@
+package com.loopers;
+
+import jakarta.annotation.PostConstruct;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import java.util.TimeZone;
+
+@ConfigurationPropertiesScan
+@SpringBootApplication
+public class CommerceApiApplication {
+
+    @PostConstruct
+    public void started() {
+        // set timezone
+        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
+    }
+
+    public static void main(String[] args) {
+        SpringApplication.run(CommerceApiApplication.class, args);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java
new file mode 100644
index 000000000..552a9ad62
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java
@@ -0,0 +1,17 @@
+package com.loopers.application.example;
+
+import com.loopers.domain.example.ExampleModel;
+import com.loopers.domain.example.ExampleService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class ExampleFacade {
+    private final ExampleService exampleService;
+
+    public ExampleInfo getExample(Long id) {
+        ExampleModel example = exampleService.getExample(id);
+        return ExampleInfo.from(example);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java
new file mode 100644
index 000000000..877aba96c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java
@@ -0,0 +1,13 @@
+package com.loopers.application.example;
+
+import com.loopers.domain.example.ExampleModel;
+
+public record ExampleInfo(Long id, String name, String description) {
+    public static ExampleInfo from(ExampleModel model) {
+        return new ExampleInfo(
+            model.getId(),
+            model.getName(),
+            model.getDescription()
+        );
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java
new file mode 100644
index 000000000..c588c4a8a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java
@@ -0,0 +1,44 @@
+package com.loopers.domain.example;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+
+@Entity
+@Table(name = "example")
+public class ExampleModel extends BaseEntity {
+
+    private String name;
+    private String description;
+
+    protected ExampleModel() {}
+
+    public ExampleModel(String name, String description) {
+        if (name == null || name.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다.");
+        }
+        if (description == null || description.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다.");
+        }
+
+        this.name = name;
+        this.description = description;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public void update(String newDescription) {
+        if (newDescription == null || newDescription.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다.");
+        }
+        this.description = newDescription;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java
new file mode 100644
index 000000000..3625e5662
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java
@@ -0,0 +1,7 @@
+package com.loopers.domain.example;
+
+import java.util.Optional;
+
+public interface ExampleRepository {
+    Optional<ExampleModel> find(Long id);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
new file mode 100644
index 000000000..c0e8431e8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
@@ -0,0 +1,20 @@
+package com.loopers.domain.example;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Component
+public class ExampleService {
+
+    private final ExampleRepository exampleRepository;
+
+    @Transactional(readOnly = true)
+    public ExampleModel getExample(Long id) {
+        return exampleRepository.find(id)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다."));
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java
new file mode 100644
index 000000000..ce6d3ead0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java
@@ -0,0 +1,6 @@
+package com.loopers.infrastructure.example;
+
+import com.loopers.domain.example.ExampleModel;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ExampleJpaRepository extends JpaRepository<ExampleModel, Long> {}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java
new file mode 100644
index 000000000..37f2272f0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java
@@ -0,0 +1,19 @@
+package com.loopers.infrastructure.example;
+
+import com.loopers.domain.example.ExampleModel;
+import com.loopers.domain.example.ExampleRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class ExampleRepositoryImpl implements ExampleRepository {
+    private final ExampleJpaRepository exampleJpaRepository;
+
+    @Override
+    public Optional<ExampleModel> find(Long id) {
+        return exampleJpaRepository.findById(id);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
new file mode 100644
index 000000000..20b2809c8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
@@ -0,0 +1,126 @@
+package com.loopers.interfaces.api;
+
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.exc.InvalidFormatException;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.server.ServerWebInputException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
+
+import java.util.Arrays;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@RestControllerAdvice
+@Slf4j
+public class ApiControllerAdvice {
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handle(CoreException e) {
+        log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e);
+        return failureResponse(e.getErrorType(), e.getCustomMessage());
+    }
+
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentTypeMismatchException e) {
+        String name = e.getName();
+        String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown";
+        String value = e.getValue() != null ? e.getValue().toString() : "null";
+        String message = String.format("요청 파라미터 '%s' (타입: %s)의 값 '%s'이(가) 잘못되었습니다.", name, type, value);
+        return failureResponse(ErrorType.BAD_REQUEST, message);
+    }
+
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handleBadRequest(MissingServletRequestParameterException e) {
+        String name = e.getParameterName();
+        String type = e.getParameterType();
+        String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type);
+        return failureResponse(ErrorType.BAD_REQUEST, message);
+    }
+
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handleBadRequest(HttpMessageNotReadableException e) {
+        String errorMessage;
+        Throwable rootCause = e.getRootCause();
+
+        if (rootCause instanceof InvalidFormatException invalidFormat) {
+            String fieldName = invalidFormat.getPath().stream()
+                .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
+                .collect(Collectors.joining("."));
+
+            String valueIndicationMessage = "";
+            if (invalidFormat.getTargetType().isEnum()) {
+                Class<?> enumClass = invalidFormat.getTargetType();
+                String enumValues = Arrays.stream(enumClass.getEnumConstants())
+                    .map(Object::toString)
+                    .collect(Collectors.joining(", "));
+                valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]";
+            }
+
+            String expectedType = invalidFormat.getTargetType().getSimpleName();
+            Object value = invalidFormat.getValue();
+
+            errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s",
+                fieldName, value, expectedType, valueIndicationMessage);
+
+        } else if (rootCause instanceof MismatchedInputException mismatchedInput) {
+            String fieldPath = mismatchedInput.getPath().stream()
+                .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
+                .collect(Collectors.joining("."));
+            errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath);
+
+        } else if (rootCause instanceof JsonMappingException jsonMapping) {
+            String fieldPath = jsonMapping.getPath().stream()
+                .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
+                .collect(Collectors.joining("."));
+            errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s",
+                fieldPath, jsonMapping.getOriginalMessage());
+
+        } else {
+            errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요.";
+        }
+
+        return failureResponse(ErrorType.BAD_REQUEST, errorMessage);
+    }
+
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handleBadRequest(ServerWebInputException e) {
+        String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : "");
+        if (!missingParams.isEmpty()) {
+            String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams);
+            return failureResponse(ErrorType.BAD_REQUEST, message);
+        } else {
+            return failureResponse(ErrorType.BAD_REQUEST, null);
+        }
+    }
+
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handleNotFound(NoResourceFoundException e) {
+        return failureResponse(ErrorType.NOT_FOUND, null);
+    }
+
+    @ExceptionHandler
+    public ResponseEntity<ApiResponse<?>> handle(Throwable e) {
+        log.error("Exception : {}", e.getMessage(), e);
+        return failureResponse(ErrorType.INTERNAL_ERROR, null);
+    }
+
+    private String extractMissingParameter(String message) {
+        Pattern pattern = Pattern.compile("'(.+?)'");
+        Matcher matcher = pattern.matcher(message);
+        return matcher.find() ? matcher.group(1) : "";
+    }
+
+    private ResponseEntity<ApiResponse<?>> failureResponse(ErrorType errorType, String errorMessage) {
+        return ResponseEntity.status(errorType.getStatus())
+            .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage()));
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java
new file mode 100644
index 000000000..33b77b529
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java
@@ -0,0 +1,32 @@
+package com.loopers.interfaces.api;
+
+public record ApiResponse<T>(Metadata meta, T data) {
+    public record Metadata(Result result, String errorCode, String message) {
+        public enum Result {
+            SUCCESS, FAIL
+        }
+
+        public static Metadata success() {
+            return new Metadata(Result.SUCCESS, null, null);
+        }
+
+        public static Metadata fail(String errorCode, String errorMessage) {
+            return new Metadata(Result.FAIL, errorCode, errorMessage);
+        }
+    }
+
+    public static ApiResponse<Object> success() {
+        return new ApiResponse<>(Metadata.success(), null);
+    }
+
+    public static <T> ApiResponse<T> success(T data) {
+        return new ApiResponse<>(Metadata.success(), data);
+    }
+
+    public static ApiResponse<Object> fail(String errorCode, String errorMessage) {
+        return new ApiResponse<>(
+            Metadata.fail(errorCode, errorMessage),
+            null
+        );
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java
new file mode 100644
index 000000000..219e3101e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java
@@ -0,0 +1,19 @@
+package com.loopers.interfaces.api.example;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.")
+public interface ExampleV1ApiSpec {
+
+    @Operation(
+        summary = "예시 조회",
+        description = "ID로 예시를 조회합니다."
+    )
+    ApiResponse<ExampleV1Dto.ExampleResponse> getExample(
+        @Schema(name = "예시 ID", description = "조회할 예시의 ID")
+        Long exampleId
+    );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java
new file mode 100644
index 000000000..917376016
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java
@@ -0,0 +1,28 @@
+package com.loopers.interfaces.api.example;
+
+import com.loopers.application.example.ExampleFacade;
+import com.loopers.application.example.ExampleInfo;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/examples")
+public class ExampleV1Controller implements ExampleV1ApiSpec {
+
+    private final ExampleFacade exampleFacade;
+
+    @GetMapping("/{exampleId}")
+    @Override
+    public ApiResponse<ExampleV1Dto.ExampleResponse> getExample(
+        @PathVariable(value = "exampleId") Long exampleId
+    ) {
+        ExampleInfo info = exampleFacade.getExample(exampleId);
+        ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info);
+        return ApiResponse.success(response);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java
new file mode 100644
index 000000000..4ecf0eea5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java
@@ -0,0 +1,15 @@
+package com.loopers.interfaces.api.example;
+
+import com.loopers.application.example.ExampleInfo;
+
+public class ExampleV1Dto {
+    public record ExampleResponse(Long id, String name, String description) {
+        public static ExampleResponse from(ExampleInfo info) {
+            return new ExampleResponse(
+                info.id(),
+                info.name(),
+                info.description()
+            );
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java
new file mode 100644
index 000000000..0cc190b6b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java
@@ -0,0 +1,19 @@
+package com.loopers.support.error;
+
+import lombok.Getter;
+
+@Getter
+public class CoreException extends RuntimeException {
+    private final ErrorType errorType;
+    private final String customMessage;
+
+    public CoreException(ErrorType errorType) {
+        this(errorType, null);
+    }
+
+    public CoreException(ErrorType errorType, String customMessage) {
+        super(customMessage != null ? customMessage : errorType.getMessage());
+        this.errorType = errorType;
+        this.customMessage = customMessage;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
new file mode 100644
index 000000000..5d142efbf
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
@@ -0,0 +1,19 @@
+package com.loopers.support.error;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@RequiredArgsConstructor
+public enum ErrorType {
+    /** 범용 에러 */
+    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."),
+    BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."),
+    NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."),
+    CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다.");
+
+    private final HttpStatus status;
+    private final String code;
+    private final String message;
+}
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
new file mode 100644
index 000000000..8bb897a51
--- /dev/null
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -0,0 +1,57 @@
+server:
+  shutdown: graceful
+  tomcat:
+    threads:
+      max: 200 # 최대 워커 스레드 수 (default : 200)
+      min-spare: 10 # 최소 유지 스레드 수 (default : 10)
+    connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m)
+    max-connections: 8192 # 최대 동시 연결 수 (default : 8192)
+    accept-count: 100 # 대기 큐 크기 (default : 100)
+    keep-alive-timeout: 60s # 60s
+  max-http-request-header-size: 8KB
+
+spring:
+  main:
+    web-application-type: servlet
+  application:
+    name: commerce-api
+  profiles:
+    active: local
+  config:
+    import:
+      - jpa.yml
+      - logging.yml
+      - monitoring.yml
+
+springdoc:
+  use-fqn: true
+  swagger-ui:
+    path: /swagger-ui.html
+
+---
+spring:
+  config:
+    activate:
+      on-profile: local, test
+
+---
+spring:
+  config:
+    activate:
+      on-profile: dev
+
+---
+spring:
+  config:
+    activate:
+      on-profile: qa
+
+---
+spring:
+  config:
+    activate:
+      on-profile: prd
+
+springdoc:
+  api-docs:
+    enabled: false
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java b/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java
new file mode 100644
index 000000000..a485bafe8
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java
@@ -0,0 +1,14 @@
+package com.loopers;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class CommerceApiContextTest {
+
+    @Test
+    void contextLoads() {
+        // 이 테스트는 Spring Boot 애플리케이션 컨텍스트가 로드되는지 확인합니다.
+        // 모든 빈이 올바르게 로드되었는지 확인하는 데 사용됩니다.
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java
new file mode 100644
index 000000000..44ca7576e
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java
@@ -0,0 +1,65 @@
+package com.loopers.domain.example;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class ExampleModelTest {
+    @DisplayName("예시 모델을 생성할 때, ")
+    @Nested
+    class Create {
+        @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.")
+        @Test
+        void createsExampleModel_whenNameAndDescriptionAreProvided() {
+            // arrange
+            String name = "제목";
+            String description = "설명";
+
+            // act
+            ExampleModel exampleModel = new ExampleModel(name, description);
+
+            // assert
+            assertAll(
+                () -> assertThat(exampleModel.getId()).isNotNull(),
+                () -> assertThat(exampleModel.getName()).isEqualTo(name),
+                () -> assertThat(exampleModel.getDescription()).isEqualTo(description)
+            );
+        }
+
+        @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.")
+        @Test
+        void throwsBadRequestException_whenTitleIsBlank() {
+            // arrange
+            String name = "   ";
+
+            // act
+            CoreException result = assertThrows(CoreException.class, () -> {
+                new ExampleModel(name, "설명");
+            });
+
+            // assert
+            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+        }
+
+        @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.")
+        @Test
+        void throwsBadRequestException_whenDescriptionIsEmpty() {
+            // arrange
+            String description = "";
+
+            // act
+            CoreException result = assertThrows(CoreException.class, () -> {
+                new ExampleModel("제목", description);
+            });
+
+            // assert
+            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java
new file mode 100644
index 000000000..bbd5fdbe1
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java
@@ -0,0 +1,72 @@
+package com.loopers.domain.example;
+
+import com.loopers.infrastructure.example.ExampleJpaRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@SpringBootTest
+class ExampleServiceIntegrationTest {
+    @Autowired
+    private ExampleService exampleService;
+
+    @Autowired
+    private ExampleJpaRepository exampleJpaRepository;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    @DisplayName("예시를 조회할 때,")
+    @Nested
+    class Get {
+        @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.")
+        @Test
+        void returnsExampleInfo_whenValidIdIsProvided() {
+            // arrange
+            ExampleModel exampleModel = exampleJpaRepository.save(
+                new ExampleModel("예시 제목", "예시 설명")
+            );
+
+            // act
+            ExampleModel result = exampleService.getExample(exampleModel.getId());
+
+            // assert
+            assertAll(
+                () -> assertThat(result).isNotNull(),
+                () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()),
+                () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()),
+                () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription())
+            );
+        }
+
+        @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.")
+        @Test
+        void throwsException_whenInvalidIdIsProvided() {
+            // arrange
+            Long invalidId = 999L; // Assuming this ID does not exist
+
+            // act
+            CoreException exception = assertThrows(CoreException.class, () -> {
+                exampleService.getExample(invalidId);
+            });
+
+            // assert
+            assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
new file mode 100644
index 000000000..70c36f361
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
@@ -0,0 +1,115 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.domain.example.ExampleModel;
+import com.loopers.infrastructure.example.ExampleJpaRepository;
+import com.loopers.interfaces.api.example.ExampleV1Dto;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ExampleV1ApiE2ETest {
+
+    private static final Function<Long, String> ENDPOINT_GET = id -> "/api/v1/examples/" + id;
+
+    private final TestRestTemplate testRestTemplate;
+    private final ExampleJpaRepository exampleJpaRepository;
+    private final DatabaseCleanUp databaseCleanUp;
+
+    @Autowired
+    public ExampleV1ApiE2ETest(
+        TestRestTemplate testRestTemplate,
+        ExampleJpaRepository exampleJpaRepository,
+        DatabaseCleanUp databaseCleanUp
+    ) {
+        this.testRestTemplate = testRestTemplate;
+        this.exampleJpaRepository = exampleJpaRepository;
+        this.databaseCleanUp = databaseCleanUp;
+    }
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    @DisplayName("GET /api/v1/examples/{id}")
+    @Nested
+    class Get {
+        @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.")
+        @Test
+        void returnsExampleInfo_whenValidIdIsProvided() {
+            // arrange
+            ExampleModel exampleModel = exampleJpaRepository.save(
+                new ExampleModel("예시 제목", "예시 설명")
+            );
+            String requestUrl = ENDPOINT_GET.apply(exampleModel.getId());
+
+            // act
+            ParameterizedTypeReference<ApiResponse<ExampleV1Dto.ExampleResponse>> responseType = new ParameterizedTypeReference<>() {};
+            ResponseEntity<ApiResponse<ExampleV1Dto.ExampleResponse>> response =
+                testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
+
+            // assert
+            assertAll(
+                () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()),
+                () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()),
+                () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription())
+            );
+        }
+
+        @DisplayName("ID 없이 요청하면, 400 BAD_REQUEST 응답을 받는다.")
+        @Test
+        void throwsBadRequest_whenIdIsNotProvided() {
+            // arrange
+            String requestUrl = "/api/v1/examples/나나";
+
+            // act
+            ParameterizedTypeReference<ApiResponse<ExampleV1Dto.ExampleResponse>> responseType =
+                new ParameterizedTypeReference<ApiResponse<ExampleV1Dto.ExampleResponse>>() {};
+            ResponseEntity<ApiResponse<ExampleV1Dto.ExampleResponse>> response =
+                testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
+
+            // assert
+            assertAll(
+                () -> assertTrue(response.getStatusCode().is4xxClientError()),
+                () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)
+            );
+        }
+
+        @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.")
+        @Test
+        void throwsException_whenInvalidIdIsProvided() {
+            // arrange
+            Long invalidId = -1L;
+            String requestUrl = ENDPOINT_GET.apply(invalidId);
+
+            // act
+            ParameterizedTypeReference<ApiResponse<ExampleV1Dto.ExampleResponse>> responseType = new ParameterizedTypeReference<>() {};
+            ResponseEntity<ApiResponse<ExampleV1Dto.ExampleResponse>> response =
+                testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
+
+            // assert
+            assertAll(
+                () -> assertTrue(response.getStatusCode().is4xxClientError()),
+                () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)
+            );
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java
new file mode 100644
index 000000000..44db8c5e6
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java
@@ -0,0 +1,34 @@
+package com.loopers.support.error;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CoreExceptionTest {
+    @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.")
+    @Test
+    void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() {
+        // arrange
+        ErrorType[] errorTypes = ErrorType.values();
+
+        // act & assert
+        for (ErrorType errorType : errorTypes) {
+            CoreException exception = new CoreException(errorType);
+            assertThat(exception.getMessage()).isEqualTo(errorType.getMessage());
+        }
+    }
+
+    @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.")
+    @Test
+    void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() {
+        // arrange
+        String customMessage = "custom message";
+
+        // act
+        CoreException exception = new CoreException(ErrorType.INTERNAL_ERROR, customMessage);
+
+        // assert
+        assertThat(exception.getMessage()).isEqualTo(customMessage);
+    }
+}

From 1ee9ed0013b3f114602290f85826ac11fc6b5bda Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:37:26 +0900
Subject: [PATCH 09/11] =?UTF-8?q?[ADD]=20=EB=88=84=EB=9D=BD=EB=90=9C=20pla?=
 =?UTF-8?q?in-console-appender=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/main/resources/appenders/plain-console-appender.xml  | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 supports/logging/src/main/resources/appenders/plain-console-appender.xml

diff --git a/supports/logging/src/main/resources/appenders/plain-console-appender.xml b/supports/logging/src/main/resources/appenders/plain-console-appender.xml
new file mode 100644
index 000000000..162856618
--- /dev/null
+++ b/supports/logging/src/main/resources/appenders/plain-console-appender.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<included>
+    <include resource="org/springframework/boot/logging/logback/defaults.xml" />
+    <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
+</included>
\ No newline at end of file

From 5ec69b7513507503e7636e1c4b7eb07b7b34ee65 Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:37:46 +0900
Subject: [PATCH 10/11] =?UTF-8?q?[ADD]=20infra-compose,=20monitoring-compo?=
 =?UTF-8?q?se=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 docker/grafana/prometheus.yml                 |  8 +++++++
 .../provisioning/datasources/datasource.yml   |  7 ++++++
 docker/infra-compose.yml                      | 22 +++++++++++++++++++
 docker/monitoring-compose.yml                 | 18 +++++++++++++++
 4 files changed, 55 insertions(+)
 create mode 100644 docker/grafana/prometheus.yml
 create mode 100644 docker/grafana/provisioning/datasources/datasource.yml
 create mode 100644 docker/infra-compose.yml
 create mode 100644 docker/monitoring-compose.yml

diff --git a/docker/grafana/prometheus.yml b/docker/grafana/prometheus.yml
new file mode 100644
index 000000000..e8bab667b
--- /dev/null
+++ b/docker/grafana/prometheus.yml
@@ -0,0 +1,8 @@
+global:
+  scrape_interval: 5s
+
+scrape_configs:
+  - job_name: 'spring-boot-app'
+    metrics_path: '/actuator/prometheus'
+    static_configs:
+      - targets: ['host.docker.internal:8081']
\ No newline at end of file
diff --git a/docker/grafana/provisioning/datasources/datasource.yml b/docker/grafana/provisioning/datasources/datasource.yml
new file mode 100644
index 000000000..8d9f9d8fe
--- /dev/null
+++ b/docker/grafana/provisioning/datasources/datasource.yml
@@ -0,0 +1,7 @@
+apiVersion: 1
+datasources:
+  - name: Prometheus
+    type: prometheus
+    access: proxy
+    url: http://prometheus:9090
+    isDefault: true
\ No newline at end of file
diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml
new file mode 100644
index 000000000..bff8a641f
--- /dev/null
+++ b/docker/infra-compose.yml
@@ -0,0 +1,22 @@
+version: '3'
+services:
+  mysql:
+    image: mysql:8.0
+    ports:
+      - "3306:3306"
+    environment:
+      - MYSQL_ROOT_PASSWORD=root
+      - MYSQL_USER=application
+      - MYSQL_PASSWORD=application
+      - MYSQL_DATABASE=loopers
+      - MYSQL_CHARACTER_SET=utf8mb4
+      - MYSQL_COLLATE=utf8mb4_general_ci
+    volumes:
+      - mysql-8-data:/var/lib/mysql
+
+volumes:
+  mysql-8-data:
+
+networks:
+  default:
+    driver: bridge
\ No newline at end of file
diff --git a/docker/monitoring-compose.yml b/docker/monitoring-compose.yml
new file mode 100644
index 000000000..0e50d10a0
--- /dev/null
+++ b/docker/monitoring-compose.yml
@@ -0,0 +1,18 @@
+version: '3'
+services:
+  prometheus:
+    image: prom/prometheus
+    ports:
+      - "9090:9090"
+    volumes:
+      - ./grafana/prometheus.yml:/etc/prometheus/prometheus.yml
+
+  grafana:
+    image: grafana/grafana
+    ports:
+      - "3000:3000"
+    volumes:
+      - ./grafana/provisioning:/etc/grafana/provisioning
+    environment:
+      - GF_SECURITY_ADMIN_USER=admin
+      - GF_SECURITY_ADMIN_PASSWORD=admin
\ No newline at end of file

From 634492c9e75c255aae56df9a98cd738c0f7a3e9a Mon Sep 17 00:00:00 2001
From: hubtwork <hubtwork@gmail.com>
Date: Sat, 12 Jul 2025 12:38:30 +0900
Subject: [PATCH 11/11] =?UTF-8?q?[ADD]=20git,=20github=20=EA=B4=80?=
 =?UTF-8?q?=EB=A0=A8=EB=90=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .github/auto_assign.yml           |  3 +++
 .github/pull_request_template.md  | 34 ++++++++++++++++++++++++++++++
 README.md                         | 35 +++++++++++++++++++++++++++++++
 http/commerce-api/example-v1.http |  2 ++
 http/http-client.env.json         |  5 +++++
 5 files changed, 79 insertions(+)
 create mode 100644 .github/auto_assign.yml
 create mode 100644 .github/pull_request_template.md
 create mode 100644 README.md
 create mode 100644 http/commerce-api/example-v1.http
 create mode 100644 http/http-client.env.json

diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml
new file mode 100644
index 000000000..8f1c6d58b
--- /dev/null
+++ b/.github/auto_assign.yml
@@ -0,0 +1,3 @@
+# Set addAssignees to 'author' to set the PR creator as the assignee.
+addAssignees: author
+runOnDraft: false
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..eb4f55c98
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,34 @@
+## 📌 Summary
+<!--
+    어떤 기능/이슈를 해결했는지 요약해주세요.
+-->
+
+## 💬 Review Points
+<!--
+    리뷰어가 더 효과적인 리뷰를 할 수 있도록 도와주는 부분입니다.
+    (1) 리뷰어가 중점적으로 봐줬으면 하는 부분
+    (2) 고민했던 설계 포인트나 로직
+    (3) 리뷰어가 확인해줬으면 하는 테스트 케이스나 예외 상황
+    (4) 기타 리뷰어가 참고해야 할 사항
+-->
+
+## ✅ Checklist
+<!--
+    해당 작업이 완료되었는지 확인하기 위한 체크리스트입니다.
+    리뷰어가 확인해야 할 사항을 포함합니다.
+    계획중이나 아직 완료되지 않은 작업 또한 `TODO -` 로 작성해주세요.  
+
+    ex.
+    - [ ] 테스트 코드 포함
+    - [ ] 불필요한 코드 제거
+    - [ ] README or 주석 보강 (필요 시)
+-->
+
+## 📎 References
+<!--
+  (Optional: 참고 자료가 없는 작업 - 단순 버그 픽스 등 의 경우엔 해당 란을 제거해주세요 !)
+  리뷰어가 참고할 수 있는 추가적인 정보나 문서, 링크 등을 작성해주세요.
+  예시:
+  - 관련 문서 링크
+  - 관련 정책 링크
+-->
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..2203f7b85
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# Loopers Template (Spring + Kotlin)
+Loopers 에서 제공하는 스프링 코틀린 템플릿 프로젝트입니다.
+
+## Getting Started
+현재 프로젝트 안정성 및 유지보수성 등을 위해 아래와 같은 장치를 운용하고 있습니다. 이에 아래 명령어를 통해 프로젝트의 기반을 설치해주세요.
+### Environment
+`local` 프로필로 동작할 수 있도록, 필요 인프라를 `docker-compose` 로 제공합니다.
+```shell
+docker-compose -f ./docker/infra-compose.yml up
+```
+### Monitoring
+`local` 환경에서 모니터링을 할 수 있도록, `docker-compose` 를 통해 `prometheus` 와 `grafana` 를 제공합니다.
+
+애플리케이션 실행 이후, **http://localhost:9090** 로 접속해, admin/admin 계정으로 로그인하여 확인하실 수 있습니다.
+```shell
+docker-compose -f ./docker/monitoring-compose.yml up
+```
+
+## About Multi-Module Project
+본 프로젝트는 멀티 모듈 프로젝트로 구성되어 있습니다. 각 모듈의 위계 및 역할을 분명히 하고, 아래와 같은 규칙을 적용합니다.
+
+- apps : 각 모듈은 실행가능한 **SpringBootApplication** 을 의미합니다.
+- modules : 특정 구현이나 도메인에 의존적이지 않고, reusable 한 configuration 을 원칙으로 합니다.
+- supports : logging, monitoring 과 같이 부가적인 기능을 지원하는 add-on 모듈입니다.
+
+```
+Root
+├── apps ( spring-applications )
+│   └── 📦 commerce-api
+├── modules ( reusable-configurations )
+│   └── 📦 jpa
+└── supports ( add-ons )
+    ├── 📦 monitoring
+    └── 📦 logging
+```
diff --git a/http/commerce-api/example-v1.http b/http/commerce-api/example-v1.http
new file mode 100644
index 000000000..2a924d265
--- /dev/null
+++ b/http/commerce-api/example-v1.http
@@ -0,0 +1,2 @@
+### 예시 조회
+GET {{commerce-api}}/api/v1/examples/1
\ No newline at end of file
diff --git a/http/http-client.env.json b/http/http-client.env.json
new file mode 100644
index 000000000..0db34e6a1
--- /dev/null
+++ b/http/http-client.env.json
@@ -0,0 +1,5 @@
+{
+  "local": {
+    "commerce-api": "http://localhost:8080"
+  }
+}
