๐ฉ๏ธ [ko] Monolithic to Multi-Module Architecture
๐ ๋ฉํฐ ๋ชจ๋ ๊ตฌ์กฐ๋ก ์ด์ฌ๊ฐ๊ธฐ!
As the project grew, issues such as unpredictable impact of changes, increased management complexity, and frequent code duplication surfaced.
1. ๊ธฐ์กด ๋ชจ๋๋ฆฌํฑ(Monolithic) ๊ตฌ์กฐ์์ ๋๋ ๋ฌธ์ ์
์๋ 10์๋ถํฐ ์งํํ ์ฌ์ด๋ ํ๋ก์ ํธ์ธ ๋ํผ๋ก(Daepiro) ๋ฐฑ์๋๋ ๋ชจ๋๋ฆฌํฑ ๊ตฌ์กฐ๋ก ์ถ๋ฐํ์ต๋๋ค. 2๊ฐ์ ๋ด์ ๋ฐ๋ชจ ์ดํ๋ฆฌ์ผ์ด์ ์ ๋น ๋ฅด๊ฒ ๊ฐ๋ฐํด์ผ ํ๋ ์ด๋ฐํ๋ ์ผ์ ๊ณผ, ๋ฆฌ์์ค๊ฐ ์ ์๊ธฐ ๋๋ฌธ์ ๊ฐ๋จํ ๋ชจ๋๋ฆฌํฑ ๊ตฌ์กฐ๋ฅผ ์ ํํ์์ฃ . ํ์ง๋ง ๊ฐ๋ฐ์ด ์ ์ฐจ ์งํ๋ ์๋ก ์ฌ๋ฌ๊ฐ์ง ๋ฌธ์ ์ ์ด ๋ฐ์ํ์ต๋๋ค.
- ๊ธฐ๋ฅ ๋ณ๊ฒฝ/์ถ๊ฐ ์ ์ํฅ๋๋ฅผ ์์ธกํ ์ ์๋ค.
- ๊ด๋ฆฌ ํฌ์ธํธ๊ฐ ๋์ด๋๋ค.
- ๋จ์ผ ๋ชจ๋์์ ๋ชจ๋ ๊ธฐ๋ฅ์ ๊ด๋ฆฌํ๋ฏ๋ก ๋ณต์ก๋๊ฐ ๋์์ง๊ณ , ํ์ฅ์ฑ์ด ํฌ๊ฒ ๋จ์ด์ง๊ณ ์์์ต๋๋ค.
- ์ฝ๋ ์ค๋ณต์ด ์์ฃผ ๋ฐ์ํ๋ค.
์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ๊ณ ๋ คํ ์ ํ์ง๋ ๋ ๊ฐ์ง๊ฐ ์์์ต๋๋ค. ๊ธฐ์กด ๊ตฌ์กฐ๋ฅผ ์ ์งํ ์ํ์์, ์ ๋ฐ์ ์ผ๋ก ๋ฆฌํฉํ ๋ง์ ์๋ํ๊ฑฐ๋ ์๋ก์ด ์ํคํ ์ณ๋ก์ ์ ํ์ ๋ชจ์ํ๋ ๊ฒ ์ด์์ฃ . ์ฒซ ๋ฒ์งธ ๋ฐฉ๋ฒ์ ๋จ๊ธฐ๊ฐ ๋ด์ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ ์ ์๋ ๋งค๋ ฅ์ ์ธ ์ ํ์ง์ด์์ง๋ง, ๊ทผ๋ณธ์ ์ธ ๋ฌธ์ ์ ์ ํด๊ฒฐํ์ง๋ ๋ชปํ ๊ฒ์ด๋ผ๋ ์ฐ๋ ค๊ฐ ์์์ต๋๋ค. ๊ฒฐ๊ตญ ๋ชจ๋๋ฆฌํฑ ๊ตฌ์กฐ์์ ํํผํ์ฌ, ํ์ฅ์ฑ๊ณผ ์ ์ง๋ณด์์ฑ์ ํ๋ณดํ ์ ์๋ ์๋ก์ด ๊ตฌ์กฐ๋ก์ ์ ํ์ ์ ํํ์ต๋๋ค.
2. ๋ฉํฐ๋ชจ๋(Multi-module) ๊ตฌ์กฐ๋ก์ ์ ํ๊ณผ ๊ธฐ๋ํจ๊ณผ
๋ชจ๋๋ฆฌํฑ ๊ตฌ์กฐ๋ก๋ถํฐ ๋ฐ์ํ ๋ฌธ์ ๋ฅผ ๊ทน๋ณตํ๊ธฐ ์ํด, ๊ฐ ๊ธฐ๋ฅ์ด ๋ชจ๋๋ณ๋ก ๋ ๋ฆฝ์ ์ผ๋ก ๊ด๋ฆฌ๋๊ณ ํ์ฅ์ฑ์ด ๋์ ๋ฉํฐ๋ชจ๋ ๊ตฌ์กฐ๋ก์ ์ ํ์ ์๋ํ์ต๋๋ค. ์์คํ ์ ์ฌ๋ฌ ๊ฐ์ ๋ชจ๋๋ก ๋ถ๋ฆฌํ์ฌ, ๊ฐ ๋ชจ๋์ด ๋ช ํํ ์ฑ ์๊ณผ ์์กด์ฑ์ ๊ฐ์ง๋๋ก ํฉ๋๋ค. ๋ฉํฐ ๋ชจ๋ ๊ตฌ์กฐ๋ก ์ ํํจ์ ๋ฐ๋ผ ๊ธฐ๋ํ ์ ์์๋ ์ฅ์ ์ ์๋์ ๊ฐ์ต๋๋ค.
- ์ฝ๋ ์ค๋ณต ๊ฐ์ ๋ฐ ๋๋ฉ์ธ ๋ถ๋ฆฌ
- ๋ชจ๋ ๋จ์๋ก ์์คํ ์ ๋ถ๋ฆฌ ํจ์ ๋ฐ๋ผ ์ฝ๋ ์ค๋ณต์ ์ต์ํํ ์ ์์ต๋๋ค. ๊ฐ ๋ชจ๋์ ์ฃผ์ด์ง ์ฑ ์์ ์ง์คํ ์ ์๊ณ , ์ฌ๋ฌ ๋ชจ๋์์ ๊ณตํต์ ์ผ๋ก ํ์ํ ์ฝ๋๋ ๋ณ๋์ ๋ชจ๋์์ ๊ด๋ฆฌํ๋ ๋ฑ์ ๋ฐฉ๋ฒ์ ์ ํํ ์ ์์ต๋๋ค. ์ด๋ฅผ ํตํด ์ฝ๋์ ์ฌ์ฌ์ฉ์ฑ์ ํฌ๊ฒ ๋์ด๊ณ ์ ์ง๋ณด์์ฑ์ ๋์ผ ์ ์์ ๊ฒ์ ๋๋ค.
- ๊ด๋ฆฌ ํฌ์ธํธ์ ๊ฐ์
- ๊ฐ ๋ชจ๋์ ์ฑ ์๊ณผ ์์กด์ฑ์ด ๋ช ํํ ์ ์๋์ด์๋ค๋ฉด, ๊ฐ๋ฐ์๋ ์์ ์ ์ํฅ ๋ฒ์๋ฅผ ์ฝ๊ฒ ํ์ ํ ์ ์์ด ๊ด๋ฆฌ ํฌ์ธํธ๊ฐ ์ค์ด๋ญ๋๋ค. ํน์ ๊ธฐ๋ฅ์ ์์ ํ๊ฑฐ๋ ๋ณ๊ฒฝํ ๋, ์ด๋ ๋ชจ๋์ ์ํฅ์ด ์์ ์ง ์ฝ๊ฒ ํ์ ํ๋ฉฐ ๊ณผ๊ฐํ ์์ ์ ์๋ํ ์ ์์ต๋๋ค.
- ํ์ฅ์ฑ, ์ ์ฐ์ฑ ๊ฐ์
- ํน์ ๊ธฐ๋ฅ์ ๋ํ ํ์ฅ์ด ํ์ํ ๊ฒฝ์ฐ์, ํด๋น ๋ชจ๋์ ๋ํด์๋ง ์์ ํ๋ฉด ๋๊ธฐ ๋๋ฌธ์ ์์คํ ์ ์์ ์ฑ์ ๊ฑฑ์ ํ์ง ์๊ณ ์์ ์ด ๊ฐ๋ฅํด์ง๋๋ค.
- MSA ๋ก์ ์ ํ ๊ฐ๋ฅ์ฑ
- ๋ฉํฐ๋ชจ๋ ๊ตฌ์กฐ๋ MSA ๋ก์ ์ ํ์ ์ํ ์ค๊ฐ ๊ณผ์ ์ ์ญํ ์ ํ ์ ์์ต๋๋ค. ํธ๋ํฝ์ ๋ฐ๋ผ ๊ฐ ๋ชจ๋์ ํ์ ์ ๋ ๋ฆฝ์ ์ธ ์๋น์ค๋ก ํ์ฅํ๊ธฐ์ ์ ๋ฆฌํฉ๋๋ค. ๋ง์ฝ ๋ชจ๋๋ฆฌํฑ ๊ตฌ์กฐ์์ ํ๋ฒ์ MSA ๋ก์ ์ ํ์ด ํ์ํ๋ค๋ฉด, ๊ต์ฅํ ํฐ ๊ณต์๊ฐ ๋ค ๊ฒ์ผ๋ก ์์ํฉ๋๋ค๋ง, ๋ชจ๋์ด ์ ๋ถ๋ฆฌ๋ ๋ฉํฐ ๋ชจ๋ ๊ตฌ์กฐ์ ํ๋ก์ ํธ๋ผ๋ฉด ํจ์ฌ ์ ์ ๋ฆฌ์คํฌ๋ก ์ ํํ ์ ์์ ๊ฒ ์ ๋๋ค.
์ด ์ธ์๋ ๊ฐ ํ์์ด ์์ ํ ๋, ์๊ตฌ์ฌํญ์ ๋ณด๊ณ ์ด๋ค ๋ชจ๋์์ ์์ ํด์ผ ํ ์ง ์์ ๊ณํ/์ ๋ต์ ์๋ฆฝํ๊ธฐ ์ฌ์์ง๋ฉฐ, ์๋ก์ด ํ์์ด ํฉ๋ฅ ํ์ ๋ ํ๋ก์ ํธ์ ๋ ์ฝ๊ฒ ์ ์ํ ์ ์์ ๊ฒ์ผ๋ก ์๊ฐ๋ฉ๋๋ค! ์๋ฌด์ชผ๋ก, ๋ฉํฐ ๋ชจ๋๋ก์ ์ ํ์ ๋ํผ๋ก ๋ฐฑ์๋์ ์์ด์ ์์ฃผ ๊ธ์ ์ ์ธ ํจ๊ณผ๊ฐ ์์ ๊ฒ์ผ๋ก ์์๋ฉ๋๋ค ใ ใ
3. ๋ชจ๋ ์ค๊ณ๋ฅผ ์ํ ๊ณ ๋ฏผ
๊ทธ๋ ๋ค๋ฉด ๋ชจ๋์ ์ด๋ป๊ฒ ๋ถ๋ฆฌํ๋ ๊ฒ ์ข์๊น์? ์ด ๋ถ๋ถ์ด ๊ฐ์ฅ ์ด๋ ค์ ๋ ๊ฒ ๊ฐ๋ค์.. ๋ํผ๋ก ๋ฐฑ์๋์์๋ ๊ฐ ๋ชจ๋์ ์ฑ
์
๊ณผ ์์กด์ฑ
์ ์ค์ฌ์ผ๋ก ๋ชจ๋์ ๋ถ๋ฆฌํ๋ ๊ฒ์ผ๋ก ์ปจ์
์ ์ค์ ํ์ต๋๋ค. ๋ฉํฐ ๋ชจ๋ ๊ตฌ์กฐ๋ก ์ ํํจ์ ๋ฐ๋ผ ๊ธ์ ์ ์ธ ํจ๊ณผ๋ฅผ ์ป๊ธฐ ์ํด์๋ ๊ฒฐ๊ตญ ๊ฐ ๋ชจ๋ ๊ฐ์ ์ฑ
์์ด ๋ช
ํํด์ผ ํ๊ณ , ํฉ๋ฆฌ์ ์ผ๋ก ์์กด ๊ด๊ณ๊ฐ ์ ์๋๋ ๊ฒ์ด ํต์ฌ์ด๋ผ๊ณ ์๊ฐํ๊ธฐ ๋๋ฌธ์ด์์ฃ . ๊ฐ ๋ชจ๋์ ํ๋์ ์ฑ
์์ ๊ฐ์ง๋๋ก ํ๊ณ (SRP), ๋ชจ๋ ๊ฐ ์์กด์ฑ์ ๋จ๋ฐฉํฅ์ผ๋ก ํ๋ฅด๊ณ ์ํ ์ฐธ์กฐ๊ฐ ์ผ์ด๋์ง ์์์ผ ํฉ๋๋ค.
์ฐ๋ฆฌ๊ฐ ์ค๊ณํ ๊ตฌ์กฐ๋ ์๋์ ๊ฐ์ต๋๋ค.
- daepiro-core
- ์ดํ๋ฆฌ์ผ์ด์ ์ ๋ฐ์ดํฐ ์คํ ๋ฆฌ์ง์ ๊ด๋ จ๋ ๋ชจ๋ ์ฑ ์์ ๊ฐ์ง๋๋ค. ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์ , ์ํฐํฐ ๊ด๋ฆฌ, ์ฟผ๋ฆฌ ๋จ ์ฝ๋๋ค์ด ํฌํจ๋ฉ๋๋ค.
- daepiro-redis
- ๋ ๋์ค ์ค์ ๊ณผ ๊ตฌํ ๋ถ๋ถ์ ๋ ๋ฆฝ์ ์ผ๋ก ๋ด๋นํฉ๋๋ค.
- daepiro-api
- ๋น์ง๋์ค ๋ก์ง์ ๋ํ ์ฑ ์์ ๋ด๋นํฉ๋๋ค. ์ฌ์ฉ์ ์์ฒญ์ ์ฒ๋ฆฌํ๊ณ ์ ์ ํ ์๋น์ค ๋ก์ง์ ์ํํ์ฌ ์๋ต์ ๋ฐํํ๋๋ก ํฉ๋๋ค.
- daepiro-auth
- ์ธ์ฆ, ์ธ๊ฐ ๋ฉ์ปค๋์ฆ์ ๊ด๋ฆฌํฉ๋๋ค.
- daepiro-common
- ์ดํ๋ฆฌ์ผ์ด์ ์ ๋ฐ์์ ๊ณตํต์ ์ผ๋ก ์ฌ์ฉ๋ ์ ์๋ ์ ํธ๋ฆฌํฐ ๊ธฐ๋ฅ์ ๋ด๋นํฉ๋๋ค. ๋จ, common module ์ด ๋๋ฌด ๋ฌด๊ฑฐ์์ง์ง ์๋๋ก ๊ฒฝ๊ณํด์ผ ํฉ๋๋ค. common module ์ ๋ณ๊ฒฝ์ฌํญ์, ์ผ๋ฐ์ ์ผ๋ก ์ ์ฒด module ์ ์ํฅ์ ๋ฏธ์น ์ ์๊ธฐ ๋๋ฌธ์ ๋๋ค.
- daepiro-crawler
- ๋ํผ๋ก ํ๋ก์ ํธ๋ ์ผ์ ์ฃผ๊ธฐ๋ง๋ค ์ฌ๋ ์๋ฆผ ์ฌ์ดํธ๋ฅผ ํฌ๋กค๋งํ์ฌ ๋ฐ์ดํฐ๋ฅผ ์์งํฉ๋๋ค. ํฌ๋กค๋ฌ ๋ชจ๋์์๋ ํฌ๋กค๋ง์ ํตํ ๋ฐ์ดํฐ ์์ง ๋ก์ง๋ง์ ๋ด๋นํ๋๋ก ํฉ๋๋ค.
- daepiro-app
- ์ํธ๋ฆฌ ๋ชจ๋์ผ๋ก, ์ดํ๋ฆฌ์ผ์ด์ ์ ์ง์ ์ ์ ์ ๊ณตํ๋ ์ญํ ์ ๋ด๋นํฉ๋๋ค.
๊ฐ ๋ชจ๋์ ๊ฒฝ๊ณ๋ฅผ ๋ช ํํ๊ฒ ๋ถ๋ฆฌํ๊ณ , ์์กด์ฑ์ ์ต์ํ ํ๋ฉด์ ๋ฐ๋์ ํ์ํ ๊ฒฝ์ฐ์๋ง ์ค์ ํ ์ ์๋๋ก ๊ณ ๋ฏผํ๋๋ฐ์! ๊ฐ๋ฐ์ ๊ณ์ ์งํํ๋ฉด์, ํ์ฌ ๊ตฌ์กฐ๊ฐ ์ ๋ง ๊ด์ฐฎ์ ์ง ํ์๋ค๊ณผ ์ง์์ ์ผ๋ก ๊ฒํ ํ๋ฉฐ ๋ค์ํ ์๋๋ฅผ ํด๋ณด๋ ค๊ณ ํฉ๋๋ค. ์ต์ ์ ๊ตฌ์กฐ๋ ์ด๋ค ๋ชจ์ต์ผ๊น์?
4. ๋ฉํฐ๋ชจ๋๋ก์ ์ ํ
Grade ์ ์ฌ์ฉํ์ฌ ๋ฉํฐ๋ชจ๋ ๊ตฌ์กฐ๋ฅผ ์ฝ๊ณ ํจ๊ณผ์ ์ผ๋ก ๊ด๋ฆฌํ ์ ์์ต๋๋ค.
settings.gradle
์์๋ ์๋์ ๊ฐ์ด ๋ชจ๋ ์ค์ ์ ํด์ฃผ์ด์ผ ํฉ๋๋ค.
rootProject.name = 'Backend'
include 'daepiro-app'
include 'daepiro-api'
include 'daepiro-core'
include 'daepiro-common'
include 'daepiro-redis'
include 'daepiro-auth'
include 'daepiro-crawler'
๋ฃจํธ ํ๋ก์ ํธ์ build.gradle
์ค์ ์
๋๋ค.
- ์ฌ๊ธฐ์๋ ์ ์ฒด ํ๋ก์ ํธ์ ๋ํ ๊ณตํต ์ค์ ์ ์ ๊ณตํฉ๋๋ค.
plugins {
// ํ์ํ plugin ์ค์ ์ root ์์ ํด๋ก๋๋ค.
id 'java'
// spring boot ๋ฒ์ ์ ๊ด๋ฆฌํฉ๋๋ค.
id 'org.springframework.boot' version '3.1.4'
id 'io.spring.dependency-management' version '1.1.3'
// ๋ํผ๋ก ๋ฐฑ์๋์์๋ jib ๋ฅผ ์ด์ฉํ์ฌ ๋์ปค ์ด๋ฏธ์ง๋ฅผ ๋น๋ํ๊ณ ์๊ธฐ ๋๋ฌธ์ ์ค์ ํ์ต๋๋ค.
id 'com.google.cloud.tools.jib' version '3.4.0'
}
group = 'com.numberone.backend'
version = '0.0.2-SNAPSHOT'
allprojects {
apply plugin: 'java'
// ์๋ฐ ๋ฒ์ ์ ๋ณ๊ฒฝํ๊ณ ์ ํ๋ค๋ฉด, ์๋ ์ค์ ์ ๋ณ๊ฒฝํ๋ฉด ๋ฉ๋๋ค.
sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
ext {
set('springCloudVersion', "2021.0.1")
}
subprojects {
apply plugin: 'io.spring.dependency-management'
apply plugin: 'java-library'
apply plugin: 'org.springframework.boot'
apply plugin: 'jacoco'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencyManagement {
imports {
mavenBom("org.springframework.boot:spring-boot-dependencies:3.1.4")
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// spring
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-aop'
// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
}
}
- core module
dependencies {
implementation project(':daepiro-common')
// p6spy
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.8'
// jpa
api 'org.springframework.boot:spring-boot-starter-data-jpa'
// mysql connector
runtimeOnly 'com.mysql:mysql-connector-j'
// === QueryDsl ===
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// === QueryDsl ===
}
// === Querydsl ๋น๋ ์ต์
===
def querydslDir = "$buildDir/generated/querydsl"
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
tasks.withType(JavaCompile) {
options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}
clean.doLast {
file(querydslDir).deleteDir()
}
// === Querydsl ๋น๋ ์ต์
===
bootJar {
enabled = false
}
jar {
enabled = true
}
- redis module
dependencies {
implementation project(':daepiro-common')
// redis
api 'org.springframework.boot:spring-boot-starter-data-redis'
}
bootJar {
enabled = false
}
jar {
enabled = true
}
- common module
dependencies {
// fcm
implementation 'com.google.firebase:firebase-admin:9.1.1'
//geocoding
implementation 'com.google.code.geocoder-java:geocoder-java:0.16'
}
bootJar {
enabled = false
}
jar {
enabled = true
}
- auth module
dependencies {
implementation project(':daepiro-core')
implementation project(':daepiro-redis')
implementation project(':daepiro-common')
// oauth
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
bootJar {
enabled = false
}
jar {
enabled = true
}
- api module
dependencies {
implementation project(':daepiro-core')
implementation project(':daepiro-common')
implementation project(':daepiro-redis')
}
bootJar {
enabled = false
}
jar {
enabled = true
}
- crawler module
dependencies {
implementation project(':daepiro-common')
implementation project(':daepiro-core')
//crawling
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'org.jsoup:jsoup:1.14.2'
implementation 'net.sourceforge.htmlunit:htmlunit:2.70.0'
}
bootJar {
enabled = false
}
jar {
enabled = true
}
๋ฉํฐ๋ชจ๋ ํ๋ก์ ํธ์์๋ ๊ฐ ๋ชจ๋์ ์ฑ ์์ ๋ฐ๋ผ ์คํ ๊ฐ๋ฅํ ์คํ๋ง๋ถํธ ์ดํ๋ฆฌ์ผ์ด์ ์ผ๋ก ํจํค์ง ๋ ํ์๊ฐ ์๋์ง ๊ณ ๋ คํด์ผ ํ๋๋ฐ์, gradle ์์๋ bootJar, jar task ์ ๋ํ enabled flag ๋ฅผ ์ค์ ํ์ฌ ์ ์ดํ ์ ์์ต๋๋ค.
์คํ๋ง ๋ถํธ ์ดํ๋ฆฌ์ผ์ด์ ์ผ๋ก ์คํ๋ ํ์๊ฐ ์๋ ๋ชจ๋์ ๊ฒฝ์ฐ์๋ ์๋์ ๊ฐ์ด bootJar task ๋ฅผ ๋นํ์ฑํํ๊ณ jar ๋ก์ ํจํค์ง๋ง ์ํ๋์ด ๋ค๋ฅธ ๋ชจ๋์์ ํ์ ์ ํฌํจํ ์ ์๋๋ก ์ค์ ํ ์ ์์ต๋๋ค.
bootJar {
enabled = false
}
jar {
enabled = true
}
์ํธ๋ฆฌ ๋ชจ๋์์๋ bootJar ๋ฅผ true ๋ก ์ค์ ํ์ฌ, ์คํ๊ฐ๋ฅํ ์คํ๋ง ๋ถํธ ์ดํ๋ฆฌ์ผ์ด์ ์ผ๋ก ๋์ํ ์ ์๋๋ก ํด์ผ๊ฒ ์ฃ . ์ถํ์ batch ๋ชจ๋์ ์ถ๊ฐํ ๊ณํ์ด ์๋๋ฐ์, batch application ์ ๋ ๋ฆฝ์ ์ผ๋ก ์คํ๋ ์ ์๋๋ก ๊ตฌ์ฑํ๊ธฐ ์ํด batch ๋ชจ๋์ gradle ์ค์ ๋ถ๋ถ์์๋ bootJar ๋ถ๋ถ์ enable ํด์ฃผ์ด์ผ ํ ๊ฒ ๊ฐ์ต๋๋ค.
- app module
plugins {
id 'com.google.cloud.tools.jib' version '3.4.0'
}
dependencies {
implementation project(':daepiro-core')
implementation project(':daepiro-common')
implementation project(':daepiro-redis')
implementation project(':daepiro-api')
implementation project(':daepiro-crawler')
implementation project(':daepiro-auth')
}
// system environment variables
def serverIP = System.getenv("EC2_PUBLIC_IP")
def jmxPort = System.getenv("JMX_PORT")
def dockerUserName = System.getenv("DOCKER_USERNAME")
def dockerImageName = System.getenv("DOCKER_IMAGE")
// jib
jib {
from {
image = "openjdk:17"
}
to {
image = String.format("%s/%s", dockerUserName, dockerImageName)
tags = ["latest"]
}
container {
mainClass = 'com.numberone.backend.BackendApplication'
creationTime = "USE_CURRENT_TIMESTAMP"
jvmFlags = [
"-Duser.timezone=Asia/Seoul",
"-Xms128m", "-Xmx128m",
"-Dcom.sun.management.jmxremote=true",
"-Dcom.sun.management.jmxremote.local.only=false",
"-Dcom.sun.management.jmxremote.port=" + jmxPort.toString(),
"-Dcom.sun.management.jmxremote.ssl=false",
"-Dcom.sun.management.jmxremote.authenticate=false",
"-Djava.rmi.server.hostname=" + serverIP.toString(),
"-Dcom.sun.management.jmxremote.rmi.port=" + jmxPort.toString(),
"-XX:+HeapDumpOnOutOfMemoryError",
"-XX:HeapDumpPath=/heap-dumps/heapdump.hprof"]
ports = ['8080']
}
}
bootJar {
enabled = true
}
jar {
enabled = true
}
- app module ์ entry module ์ ์ญํ ์ ์ํํฉ๋๋ค.
- ๋ฉํฐ๋ชจ๋ ํ๋ก์ ํธ์์ jib ๊ธฐ๋ฐ์ผ๋ก ์ด๋ฏธ์ง๋ฅผ ๋น๋ํ๊ธฐ ์ํด์๋ mainClass ๋ฅผ ๋ช
์ํ์ง ์์ผ๋ฉด, jib ์์ ๋ฉ์ธ ํด๋์ค๋ฅผ ์ฐพ์ง ๋ชปํ์ฌ ์คํจํฉ๋๋ค.
- ์๋์ ๊ฐ์ด mainClass path ๋ฅผ ๋ช
์ํด์ฃผ๋๋ก ํฉ์๋ค.
mainClass = 'com.numberone.backend.BackendApplication'
- ์ธ์ jvmFlags ๋ ํ์์ ๋ฐ๋ผ ์ถ๊ฐํ๊ณ ์ ๊ฑฐํด์ฃผ๋๋ก ํฉ์๋ค.
- ์๋์ ๊ฐ์ด mainClass path ๋ฅผ ๋ช
์ํด์ฃผ๋๋ก ํฉ์๋ค.
์ธ๋ถ ์ฝ๋๋ ์๋ ๋ ํฌ์งํ ๋ฆฌ์์ ํ์ธํ ์ ์์ต๋๋ค.
- https://github.com/Team-NumberOne/Backend
5. ๋ฉํฐ๋ชจ๋๋ก ์ ํํ๋ฉฐ ์๊ธด ๊ณ ๋ฏผ๋ค
์ฐ๋ฆฌ ํ๋ก์ ํธ์์ ์ ๊ตฌ์กฐ๊ฐ ๊ณผ์ฐ ์ต์ ์ผ๊น์..? ๊ฐ ๋ชจ๋ ๊ฐ ์ฑ ์๊ณผ ์์กด๊ด๊ณ๊ฐ ํฉ๋ฆฌ์ ์ผ๊น์..?
ํ์๊ณผ ํจ๊ป ๋ง์ด ๊ณ ๋ฏผํ๋ฉฐ ์ค๊ณํ ๊ตฌ์กฐ์ด์ง๋ง, ํ์ฌ์ ๊ตฌ์กฐ๊ฐ ํญ์ ์ต์ ์ด ์๋ ์ ์๋ค๋ ์ ์ ๋ช ํํ ์ธ์งํ๋ฉฐ ์์ ํ๋ ค๊ณ ํฉ๋๋ค. ์์ผ๋ก ๋ฐ์ํ ์ถ๊ฐ์ ์ธ ์๊ตฌ์ฌํญ๋ค์ ๊ฐ๋ฐํ๊ฑฐ๋, ๋ฆฌํฉํ ๋งํ๋ฉด์ ๊ฐ๊ฐ์ ๋ชจ๋์ ์ฑ ์์ ๋ชธ์ ๊ฒฝํํ๋ฉฐ ์ต์ข ์ ์ผ๋ก๋ ๋ ๋์ ๊ตฌ์กฐ๋ก ๋์๊ฐ ์ ์๋๋ก ํด์ผ๊ฒ ์ฃ !
์ถ๊ฐ์ ์ผ๋ก ๊ฐ ๋ชจ๋์์ ํ์ํ ํ๋กํผํฐ ํ์ผ๋ค์ ์ด๋ป๊ฒ ๊ด๋ฆฌํ ์ง ๊ณ ๋ฏผ์ด ์์ต๋๋ค. ํ์ฌ๋ ํ๋์ application.yml ์ ๋ชจ๋ ์ค์ ์ ๋ชฐ์๋์ด ๊ด๋ฆฌํ๊ณ ์๋๋ฐ์, ์ฌ๋ฌ ๋ชจ๋์ ์ค์ ํ์ผ์ด ํ๋์ ๋ชฐ๋ ค์๋ค๋ ๊ฒ์ ๊ฒฐ์ฝ ์ข์ ๋ฐฉ์์ด ์๋ ๊ฒ์ ๋๋ค. ๊ฒฐ๊ตญ, ๋ชจ๋ ๋ณ๋ก ๋ ๋ฆฝ๋ application.yml ํ์ผ์ ์์ฑํด๋๋ฉด ๋ ํ ๋ฐ์, ํ์ฌ ๋ฐฐํฌ ๋ฐฉ์์์ application.yml ์ github secrets ์ผ๋ก ๊ด๋ฆฌํ๊ณ ์์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ ์๋์ ๊ฐ์ด directory ์ ์ง์ ํ์ผ์ ์์ฑํด์ ์ฌ์ฉํ๊ณ ์์ฃ .
- name: ๐ง create application.yml
run: |
mkdir -p ./daepiro-app/src/main/resources
cd ./daepiro-app/src/main/resources
touch ./application.yml
echo "$" | base64 --decode > ./application.yml
ls -la
shell: bash
๊ฐ ๋ชจ๋ ๋ณ๋ก application.yml ์ด ๋ถ๋ฆฌ๋๋ค๋ฉด, ๊ฐ ๋ชจ๋ ๋ณ PROPERTY ๋ฅผ GitHub secrets ์ ๋ณ๋๋ก ์ ์ฅํด๋์ด์ผ ํ ๊ฒ ์ ๋๋ค. ๊ฐ๋ฐํ๋ฉฐ ๋ณ๊ฒฝ์ฌํญ์ด ์๊ธฐ๋ฉด, ๊ทธ๋๋ง๋ค ์๋์ผ๋ก github secrets ์ ๋ณ๊ฒฝํ ๋ค ํ์์๊ฒ ์์ ์ฌํญ์ ์ค๋ช ํด์ผ ํ๋ ๊ณผ์ ์ด ํ์ฐ์ ์ผํ ๋ฐ์.. ์์ฐ์ฑ์ด ๋ง์ด ๋จ์ด์ง ๊ฒ ๊ฐ์ต๋๋ค. ๋๋ถ์ด, GitHub secrets ์ ๋ณ๊ฒฝ history ๊ฐ ๋จ์ง ์๊ธฐ ๋๋ฌธ์ ์๋ชป ๋ณ๊ฒฝํ์๋ ์๋น์ค ์ฅ์ ๋ก ์ด์ด์ง๋ ํฐ ๋ฆฌ์คํฌ๊ฐ ์กด์ฌํ์ฃ . ์ฌ๋์ ์ค์๊ฐ ์๋น์ค ์ฅ์ ๋ก ์จ์ ํ ์ด๋ฃจ์ด์ง๋ ์ด ๊ตฌ์กฐ๋ ์ ๋ง ์ข์ง ์์ ๊ฒ ๊ฐ์ต๋๋ค. ์ด๋ป๊ฒ ํด๊ฒฐํ ์ ์์๊น์? ์ฌ๋ฌ ๋ฐฉ๋ฉด์ผ๋ก ๊ณ ๋ฏผ ์ค์ธ๋ฐ์, ์ผ๋ฅธ ํด๊ฒฐํด์ ํฌ์คํ ํ๊ณ ์ถ๋ค์!
6. ๊ธฐ์กด์ ๋ฐฐํฌ ๋ฐฉ์์ ๋ฌธ์ ์ , ๊ทธ๋ฆฌ๊ณ blue - green ๋ฐฐํฌ๋ก์ ์ ํ
๊ธฐ์กด์ ๋ํผ๋ก ๋ฐฑ์๋ ๋ฐฐํฌ๋ ๋์ํ๋ ์ปจํ ์ด๋๋ฅผ ๋ด๋ฆฌ๊ณ , ์ด๋ฏธ์ง๋ฅผ ๋ค์ ๋ฐ์์์ ์ปจํ ์ด๋๋ก ๋์ฐ๊ธฐ ๋๋ฌธ์ 1๋ถ ๊ฐ๋์ ๋ค์ดํ์์ด ๋ฐ์ํ๋ ์น๋ช ์ ์ธ ๋ฌธ์ ๊ฐ ์์์ต๋๋ค. ์์ผ๋ก ์ค ์๋น์ค๊ฐ ๋๋ค๋ฉด, ๋ฐ๋์ ๋ฌธ์ ๊ฐ ๋ ์์์๊ธฐ ๋๋ฌธ์, ์ด๋ฒ ์์ ์ ํฌํจํ์ฌ ๋ฌด์ค๋จ ๋ฐฐํฌ๊ฐ ์ผ์ด๋๋๋ก ๊ฐ์ ํ์์ต๋๋ค.
์ ํํ ๋ฐฉ๋ฒ์ blue-green ๋ฐฐํฌ์ธ๋ฐ์, ํ์ฌ ์๋น์ค ์ค์ธ ํ๊ฒฝ(blue) ์ธ์ ์๋ก์ด ๋ฒ์ ์ ๋ฐฐํฌํ ํ๊ฒฝ(green)์ ๋ชจ๋ ๊ตฌ์ฑํ ๋ค, nginx ๋ฅผ ์ด์ฉํ์ฌ ์๋ก์ด ๋ฒ์ ์ด ์์ ํ ๋ฐฐํฌ๋๊ณ health-check ๊ฐ ๋๋ค๋ฉด ํธ๋ํฝ์ blue ์์ green ์ผ๋ก ํ๋ฒ์ ์ ํํ๋ ๋ฐฉ์์ ๋๋ค.
์ด๋ฅผ ํตํด ์ฌ์ฉ์๋ ๋ค์ดํ์์ ๊ฑฐ์ ์ฒด๊ฐํ์ง ๋ชปํ๊ณ (nginx ๋ฅผ reload ํ๋ ์ ๋์ ๋ค์ดํ์์ด ์กด์ฌํ์ง๋ง ๋งค์ฐ ์งง๊ธฐ ๋๋ฌธ์ ์ผ๋ฐ์ ์ผ๋ก ์ธ์งํ๊ธฐ ์ด๋ ค์ธ ๊ฒ) ์๋น์ค๋ฅผ ์ง์์ ์ผ๋ก ์ด์ฉํ ์ ์์ต๋๋ค. ๋ํ ๋กค๋ฐฑ์ด ํ์ํ ๊ฒฝ์ฐ์ ์ด์ ๋ฒ์ ์ ์ปจํ ์ด๋๋ก ๊ณง๋ฐ๋ก ํธ๋ํฝ์ ์ ํํ๋ฉด์ ๋น ๋ฅด๊ฒ ๋กค๋ฐฑํ ์ ์์ฃ .
( ์ฌ๋ด ) ๊ฐ๋ํ.. ๋ํผ๋ก ๋ฐฑ์๋๋ aws ec2 t2.micro ํ๊ฒฝ์์ ๋์๊ฐ๊ณ ์๋๋ฐ์,, ๋ํผ๋ก ์คํ๋ง ์ดํ๋ฆฌ์ผ์ด์ ์ปจํ ์ด๋๋ฅผ ๋์์ 2๊ฐ ์ด์ ๋์ฐ๋ ๊ฒ์ ์ฃผ์ด์ง ์๋ฒ ๋ฆฌ์์ค ์ ์ด๋ ค์ธ ๊ฒ์ผ๋ก ํ๋จํ์์ต๋๋ค. ์์ง ์์ต์ด ๋ฐ์ํ์ง ์์ ์๋ฒ ์ฌ์์ ๋์ผ ์๊ฐ ์์ด, t2.micro ๋ฅผ ์ฌ์ฉํ๊ณ ์๋ ํ์ฌ, ์์์ ์ผ๋ก ํ๋์ ์คํ๋ง ์ปจํ ์ด๋๋ง ์ด์์๋๋ก ์กฐ์นํด๋๊ณ ์์ต๋๋ค. ๊ฐ๋ น, blue ์ ์๋ก์ด ๋ฒ์ ์ ๋ฐฐํฌํ๊ฒ ๋ ๊ฒฝ์ฐ๋ผ๋ฉด blue ๋ฅผ ์๋ก ๋์ฐ๊ณ green ์ ๋กค๋ฐฑ ์ ์ฌ์ฉํ๊ธฐ ์ํด ์ด๋ ค๋์ด์ผ ํ์ง๋ง, ์ง๊ธ์ green ์ kill ํ๊ณ ์์ฃ ..
์ ์ฉ์ ๋งค์ฐ ๊ฐ๋จํฉ๋๋ค. ๋ํผ๋ก ๋ฐฑ์๋์์๋ (์๊ธ ์ ์ฝ ์ด์๋ก) blue, green ์ปจํ ์ด๋๋ฅผ ํ ์๋ฒ์์ ๋์์ผ ํ์ผ๋ฏ๋ก port ๋ฅผ ๋ค๋ฅด๊ฒ ํ์ฌ ๋ ํ๊ฒฝ์ ๊ตฌ๋ถํ์ต๋๋ค.
docker-compose ์์ blue, green ์ ๋ํ ext - in port ๋ฅผ ๊ฐ๊ฐ 8081:8080, 8082:8080 ์ผ๋ก ์ค์ ํด๋์์ด์.
version: '3'
services:
blue:
image: your-docker-image
container_name: blue
restart: always
ports:
- 8081:8080
green:
image: your-docker-image
container_name: green
restart: always
ports:
- 8082:8080
...
๋ํ blue, green ์ฉ๋๋ก ์ฌ์ฉํ nginx.conf ๋ฅผ ์๋์ ๊ฐ์ด ์ค์ ํด๋์์ต๋๋ค.
- blue
server {
listen 80 default_server;
listen [::]:80 default_server;
# SSL configuration
#
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
#
# Note: You should disable gzip for SSL traffic.
# See: https://bugs.debian.org/773332
#
# Read up on ssl_ciphers to ensure a secure configuration.
# See: https://bugs.debian.org/765782
#
# Self signed certs generated by the ssl-cert package
# Don't use them in a production server!
#
# include snippets/snakeoil.conf;
root /var/www/html;
# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
server_name localhost;
location / {
proxy_pass http://localhost:8081;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
# pass PHP scripts to FastCGI server
#
location ~ \.php$ {
include snippets/fastcgi-php.conf;
#
# # With php-fpm (or other unix sockets):
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
# # With php-cgi (or other tcp sockets):
# fastcgi_pass 127.0.0.1:9000;
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
- green
server {
listen 80 default_server;
listen [::]:80 default_server;
# SSL configuration
#
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
#
# Note: You should disable gzip for SSL traffic.
# See: https://bugs.debian.org/773332
#
# Read up on ssl_ciphers to ensure a secure configuration.
# See: https://bugs.debian.org/765782
#
# Self signed certs generated by the ssl-cert package
# Don't use them in a production server!
#
# include snippets/snakeoil.conf;
root /var/www/html;
# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
server_name localhost;
location / {
proxy_pass http://localhost:8082;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
# pass PHP scripts to FastCGI server
#
location ~ \.php$ {
include snippets/fastcgi-php.conf;
#
# # With php-fpm (or other unix sockets):
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
# # With php-cgi (or other tcp sockets):
# fastcgi_pass 127.0.0.1:9000;
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
๋ฐฐํฌ ์ ์คํฌ๋ฆฝํธ๋ ์๋์ ๊ฐ์์.
#!/bin/bash
IS_GREEN_EXIST=$(docker ps | grep green)
DEFAULT_CONF=" /etc/nginx/nginx.conf"
# blue๊ฐ ์คํ ์ค์ด๋ฉด green์ upํฉ๋๋ค.
if [ -z $IS_GREEN_EXIST ];then
echo "### BLUE => GREEN ####"
echo ">>> green image๋ฅผ pullํฉ๋๋ค."
docker-compose pull green
echo ">>> green container๋ฅผ upํฉ๋๋ค."
docker-compose up -d green
while [ 1 = 1 ]; do
echo ">>> green health check ์ค..."
sleep 3
REQUEST=$(curl http://127.0.0.1:8082/hello)
if [ -n "$REQUEST" ]; then
echo ">>> ๐ health check success !"
break;
fi
done;
sleep 3
echo ">>> nginx๋ฅผ ๋ค์ ์คํ ํฉ๋๋ค."
sudo ln -s -f /etc/nginx/sites-available/green /etc/nginx/sites-enabled/default
sudo nginx -s reload
echo ">>> blue container๋ฅผ downํฉ๋๋ค."
docker-compose stop blue
# green์ด ์คํ ์ค์ด๋ฉด blue๋ฅผ upํฉ๋๋ค.
else
echo "### GREEN => BLUE ###"
echo ">>> blue image๋ฅผ pullํฉ๋๋ค."
docker-compose pull blue
echo ">>> blue container upํฉ๏ฟฝ๏ฟฝ๋ค."
docker-compose up -d blue
while [ 1 = 1 ]; do
echo ">>> blue health check ์ค..."
sleep 3
REQUEST=$(curl http://127.0.0.1:8081/hello)
if [ -n "$REQUEST" ]; then
echo ">>> ๐ health check success !"
break;
fi
done;
sleep 3
echo ">>> nginx๋ฅผ ๋ค์ ์คํ ํฉ๋๋ค."
sudo ln -s -f /etc/nginx/sites-available/blue /etc/nginx/sites-enabled/default
sudo nginx -s reload
echo ">>> green container๋ฅผ downํฉ๋๋ค."
docker-compose stop green
fi
7. ์์ผ๋ก์ ๊ณผ์ ๋?
์ด๋ฒ ํฌ์คํ ์์๋ ์ฌ์ด๋ ํ๋ก์ ํธ์ธ ๋ํผ๋ก์ ์ํคํ ์ณ๋ฅผ ๋ฉํฐ๋ชจ๋ ๊ตฌ์กฐ๋ก ์ ํํ ๊ฒ๊ณผ, blue-green ๋ฌด์ค๋จ ๋ฐฐํฌ๋ฅผ ์ ์ฉํ ๊ฒฝํ์ ๋ํด์ ์์ฑํด๋ณด์์ต๋๋ค! ์ด์ ๋ํผ๋ก ๋ฐฑ์๋๋ฅผ ์กฐ๊ธ ๋ ๋ฐ์ ์ํค๊ธฐ ์ํด ๋ค๋ฅธ ๊ณผ์ ๋ฅผ ํด๋ณด๋ ค๊ณ ํด์! ์ง๊ธ ์ดํด๋ณด๊ณ ์๋ ๊ฒ์.. ํ์ฌ์ ๊ตฌ์กฐ์์ ํ ์คํธ๊ฐ ์ฉ์ดํ๋๋ก ํ๋ ค๋ฉด ์ด๋ป๊ฒ ํด์ผํ ์ง์ ๋ํ ๋ด์ฉ์ ๋๋ค. ์ง๊ธ ๋ํผ๋ก ๋ฐฑ์๋์ ์กด์ฌํ๋ ๊ฐ์ฅ ํฐ ๋ฌธ์ ๋ ์ผ๋ถ api ๋ค์ด mysql ์ ์์ ํ ์์กด์ ์ธ ๋ถ๋ถ์ด ์๋ค๋ ๊ฒ์ธ๋ฐ์, ์ผ๋ฐ์ ์ผ๋ก ํ ์คํธ ํ๊ฒฝ์์๋ h2 database ๋ฅผ ์ด์ฉํ๋ ๊ฒฝ์ฐ๊ฐ ๋ง์๋ฐ ํ์ฌ ๋ํผ๋ก ๋ฐฑ์๋์์๋ ๋ถ๊ฐ๋ฅํ์ฃ . ์ด ๋ถ๋ถ์ ์ด๋ป๊ฒ ํด๊ฒฐํ ์ ์์๊น์? ์ฌ์ค ํ๋ก๋์ ์ฝ๋๊ฐ ํน์ db ๊ตฌํ์ฒด์ ์์กดํ๊ณ ์๋ ์ํฉ์ ์ข์ง ์๋ค๊ณ ์๊ฐํฉ๋๋ค. mysql ์ด๋ผ๋ ๊ตฌํ์ฒด์ ์์กดํ๊ณ ์๋ ํ๋ก๋์ ์ฝ๋๋ฅผ ๋ฆฌํฉํ ๋งํ์ฌ, ํ ์คํธ๊ฐ ์ฉ์ดํ๋๋ก ํด๋ณผ ๊ณํ์ด์์!! ํด๋น ์์ ์ด ์๋ฃ๋๋ฉด ์กฐ๋ง๊ฐ ๋ค์ ํฌ์คํ ํ๋๋ก ํ๊ฒ ์ต๋๋ค! ๊ฐ์ฌํฉ๋๋ค :)
- (24.03.07 ์ถ๊ฐ)
- ์ผ๋ฐ์ ์ธ ํ ์คํธ ํ๊ฒฝ์์ h2 ๋ฅผ ์ด์ฉํ๋ ์ด์ ๋ ํธ๋ฆฌ์ฑ ๋๋ฌธ์ผํ ๋ฐ์, ํ๋ก๋์ ์ฝ๋์ ํต์ฌ ๋ก์ง์ ํ ์คํธ ํ ์ ์์์๋ h2 ๋ฅผ ๊ณ ์งํด์ผ ํ ๊น์!? ์ด ๋ถ๋ถ์ ๐ ๋ ๋น ๋ฅด๊ฒ ๋ฌ๋ฆฌ๊ธฐ ์ํด ์ ๊น ๋ฉ์ถ๊ธฐ ํฌ์คํ ์์ ๋ค๋ค๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค!
Leave a comment