-
SpringData JPA를 사용하는 환경에서 multi-database (feat. master/slave구분, querydsl) 구성하기Spring 2025. 1. 21. 00:35
이번에 면허 재검증 프로세스를 구현하면서, multi database 환경을 구성해야 하는 작업을 수행했다.
작업하면서 구성한 내용을 개인화해서 정리해본다!
구현 환경 및 dependency 설정
작업을 구현해야 하는 환경은 Kotlin, SpringBoot, JPA, Mysql 환경이었다.
두가지의 데이터베이스를 다룰때 모두 JPA를 사용한다.
디폴트 데이터베이스와 추가로 연결할 데이터베이스를 지정한다.
여기서는 디폴트로 사용할 데이터베이스는 DefaultDatabase, 추가로 사용할 데이터베이스는 ADatabase라고 지정한다.
추가한 dependency는 다음과 같다.
plugins { kotlin("jvm") version "1.9.25" kotlin("plugin.spring") version "1.9.25" id("org.springframework.boot") version "3.4.1" id("io.spring.dependency-management") version "1.1.7" kotlin("kapt") version "1.9.21" kotlin("plugin.jpa") version "1.9.21" } group = "com.yunhalee.database" version = "0.0.1-SNAPSHOT" java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } repositories { mavenCentral() } dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") kapt("com.querydsl:querydsl-apt:5.0.0:jakarta") } kotlin { compilerOptions { freeCompilerArgs.addAll("-Xjsr305=strict") } } tasks.withType<Test> { useJUnitPlatform() } noArg { annotation("jakarta.persistence.Entity") } allOpen { annotation("jakarta.persistence.Entity") }
multi 데이터베이스 설정하기 + JPA
먼저 트랜잭션 readOnly 여부에 따라서 라우팅해주는 DynamicRoutigDatasource를 구현한다.
예전에 넥스트 스텝에 참여하면서 DB 부하분산을 학습하면서 써둔 글이 있다.
↓
https://dodop-blog.tistory.com/326
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource import org.springframework.transaction.support.TransactionSynchronizationManager.isCurrentTransactionReadOnly import javax.sql.DataSource class DynamicRoutingDataSource( master: DataSource, readOnly: DataSource, ) : AbstractRoutingDataSource() { init { super.setTargetDataSources( mapOf( DataBaseRole.MASTER to master, DataBaseRole.READ_ONLY to readOnly, ), ) super.setDefaultTargetDataSource(master) } override fun determineCurrentLookupKey(): Any = when { isCurrentTransactionReadOnly() -> DataBaseRole.READ_ONLY else -> DataBaseRole.MASTER } enum class DataBaseRole { MASTER, READ_ONLY, } }
application.yaml에 다음과 같이 jpa설정과 더불어 데이터베이스 설정을 넣어준다. (각각의 데이터베이스에 임의로 readOnly 테이블을 만들었다.)
spring: jpa: properties: default_batch_fetch_size: 500 org.hibernate.flushMode: COMMIT hibernate: format_sql: false dialect: org.hibernate.dialect.MariaDBDialect query: in_clause_parameter_padding: true plan_cache_max_size: 128 plan_parameter_metadata_max_size: 16 hibernate: ddl-auto: validate show-sql: false datasource: hikari: connection-timeout: 5000 max-lifetime: 58000 leak-detection-threshold: 30000 datasources: default: master-datasource: hikari: jdbc-url: jdbc:mysql://localhost:33306/default username: root password: root driver-class-name: org.mariadb.jdbc.Driver readonly-datasource: hikari: jdbc-url: jdbc:mysql://localhost:33306/default_read_only username: root password: root driver-class-name: org.mariadb.jdbc.Driver a: master-datasource: hikari: jdbc-url: jdbc:mysql://localhost:33307/a username: root password: root driver-class-name: org.mariadb.jdbc.Driver readonly-datasource: hikari: jdbc-url: jdbc:mysql://localhost:33307/a_read_only username: root password: root driver-class-name: org.mariadb.jdbc.Driver
이를 활용하는 DefaultDatabaseConfig를 구성한다.
masterDatasource와 readonlyDatasource를 만들고 아까 만들어둔 DynamicRoutingDatasource를 이용해서 트랜잭션에 따라 분산하는 routingDatasource를 사용하는 defaultDatasource를 빈으로 등록한다.
이때 해당 데이터소스를 기본으로 사용할 것이기 때문에 @Primary 로 설정해주었다. 만약, 여러 데이터 소스를 빈으로 등록하였는데 우선적용할 데이터 소스가 명시되어 있지 않으면, 예외가 발생한다.
이후에 Jpa설정에서 사용할 defaultDatasource를 사용하는 JdbcTemplate도 빈으로 등록해준다.
- 💡 참고
- 이때, ConditionalOnBean 설정을 해주지 않으면 AutoConfiguration 환경 조건이 충족되지 않아 예외가 발생할 수 있다.
- https://lomuto.tistory.com/21
import com.zaxxer.hikari.HikariDataSource import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.jdbc.DataSourceBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy import org.springframework.transaction.annotation.EnableTransactionManagement import javax.sql.DataSource @Configuration @EnableTransactionManagement class DefaultDatabaseConfig { @ConfigurationProperties(prefix = "spring.datasources.default.master-datasource.hikari") @Bean("masterDataSource") fun masterDataSource(): DataSource { return DataSourceBuilder.create().type(HikariDataSource::class.java).build() } @ConfigurationProperties(prefix = "spring.datasources.default.readonly-datasource.hikari") @Bean("readonlyDataSource") fun readonlyDataSource(): DataSource { return DataSourceBuilder.create().type(HikariDataSource::class.java).build() } @Bean("routingDataSource") @ConditionalOnBean(name = ["masterDataSource", "readonlyDataSource"]) fun routingDataSource( @Qualifier("masterDataSource") masterDataSource: DataSource, @Qualifier("readonlyDataSource") readonlyDataSource: DataSource, ): DataSource { return DynamicRoutingDataSource(master = masterDataSource, readOnly = readonlyDataSource) } @Primary @Bean @ConditionalOnBean(name = ["routingDataSource"]) fun defaultDataSource(routingDataSource: DataSource): DataSource = LazyConnectionDataSourceProxy(routingDataSource) @Bean(name = ["defaultJdbcTemplate"]) @ConditionalOnBean(name = ["defaultDataSource"]) fun defaultJdbcTemplate(@Qualifier("defaultDataSource") dataSource: DataSource): JdbcTemplate = JdbcTemplate(dataSource) }
동일한 방식으로 ADatabaseConfig도 만들어준다. 여기서 aDatasource는 primary로 등록되지 않는다.
import com.zaxxer.hikari.HikariDataSource import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.jdbc.DataSourceBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy import org.springframework.transaction.annotation.EnableTransactionManagement import javax.sql.DataSource @Configuration @EnableTransactionManagement class ADatabaseConfig { @ConfigurationProperties(prefix = "spring.datasources.a.master-datasource.hikari") @Bean("aMasterDataSource") fun masterDataSource(): DataSource { return DataSourceBuilder.create().type(HikariDataSource::class.java).build() } @ConfigurationProperties(prefix = "spring.datasources.a.readonly-datasource.hikari") @Bean("aReadonlyDataSource") fun readonlyDataSource(): DataSource { return DataSourceBuilder.create().type(HikariDataSource::class.java).build() } @Bean("aRoutingDataSource") @ConditionalOnBean(name = ["aMasterDataSource", "aReadonlyDataSource"]) fun routingDataSource( @Qualifier("aMasterDataSource") masterDataSource: DataSource, @Qualifier("aReadonlyDataSource") readonlyDataSource: DataSource, ): DataSource { return DynamicRoutingDataSource(master = masterDataSource, readOnly = readonlyDataSource) } @Bean("aDataSource") @ConditionalOnBean(name = ["aRoutingDataSource"]) fun accountsDataSource(@Qualifier("aRoutingDataSource") aRoutingDataSource: DataSource): DataSource = LazyConnectionDataSourceProxy(aRoutingDataSource) @Bean(name = ["aJdbcTemplate"]) @ConditionalOnBean(name = ["aDataSource"]) fun accountsJdbcTemplate(@Qualifier("aDataSource") dataSource: DataSource): JdbcTemplate = JdbcTemplate(dataSource) }
이제 각 데이터베이스 별로 사용할 엔티티 및 repository를 정의한다.
여기서는 패키지별로 구분할 수 있게 등록해주었다.
TeamEntity의 경우 a데이터베이스를 사용하고, UserEntity의 경우 default 데이터베이스를 사용한다.
package com.yunhalee.database.multidatabase.user.entity import com.yunhalee.database.multidatabase.util.jpa.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Convert import jakarta.persistence.Entity @Entity class UserEntity( @Column var email: String, @Column var name: String, @Column var phone: String, @Convert(converter = UserStatusConverter::class) @Column var status: UserStatus ) : BaseEntity()
package com.yunhalee.database.multidatabase.user.entity import jakarta.persistence.AttributeConverter import jakarta.persistence.Converter enum class UserStatus { ACTIVE, INACTIVE } @Converter(autoApply = true) class UserStatusConverter : AttributeConverter<UserStatus, String> { override fun convertToDatabaseColumn(attribute: UserStatus): String { return attribute.name } override fun convertToEntityAttribute(dbData: String): UserStatus { return UserStatus.values().find { it.name == dbData } ?: UserStatus.INACTIVE } }
package com.yunhalee.database.multidatabase.user.repository import com.yunhalee.database.multidatabase.user.entity.UserEntity import org.springframework.data.jpa.repository.JpaRepository interface UserRepository: JpaRepository<UserEntity, Long>{ }
package com.yunhalee.database.multidatabase.team.entity import com.yunhalee.database.multidatabase.util.jpa.BaseEntity import jakarta.persistence.Entity @Entity class TeamEntity( var email: String, var name: String, var phone: String, ) : BaseEntity()
package com.yunhalee.database.multidatabase.team.repository import com.yunhalee.database.multidatabase.team.entity.TeamEntity import org.springframework.data.jpa.repository.JpaRepository interface TeamRepository : JpaRepository<TeamEntity, Long> { }
공통으로 사용할 수 있는 JPA 설정은 다음과 같이 넣어주었다.
import org.springframework.context.annotation.Configuration import org.springframework.data.jpa.repository.config.EnableJpaAuditing @Configuration @EnableJpaAuditing class JpaConfig { }
이제 데이터베이스별로 Jpa 설정파일을 만들어준다.
사용할 레파지토리 패키지와 엔티티 패키지 위치를 설정해준다.
이때, default database는 특정 패키지를 제외한 모든 패키지를 스캔할 수 있게 aDatasource를 사용하는 패키지에만 exclude filter를 사용했다.
entityManagerFactoryBuilder를 사용해서 설정할때도 동일한 방법이 있을지 찾아보았는데, string 값으로 지정하고 있기때문에 모든 패키지를 스캔해서 필터링하고 넣는 것는 방법 이외에는 방법을 찾지 못했다. 🥲
그래서, 모든 패키지를 스캔하는 방법 보다는 지정해주는 것이 좋을 것 같아 패키지를 지정해주었다.
이때도 마찬가지로 defaultTransactionManager에 @Primary로 우선순위를 부여했다.
import jakarta.persistence.EntityManagerFactory import org.hibernate.cfg.AvailableSettings import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.config.ConfigurableListableBeanFactory import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.FilterType import org.springframework.context.annotation.Primary import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.orm.hibernate5.SpringBeanContainer import org.springframework.orm.jpa.JpaTransactionManager import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean import org.springframework.transaction.PlatformTransactionManager import javax.sql.DataSource @Configuration @EnableJpaRepositories( basePackages = ["com.yunhalee.database.multidatabase"], excludeFilters = [ ComponentScan.Filter( type = FilterType.REGEX, pattern = ["com.yunhalee.database.multidatabase.team.*"], ), ], entityManagerFactoryRef = "defaultEntityManagerFactory", transactionManagerRef = "defaultTransactionManager", ) class DefaultDatabaseJpaConfig { @Primary @Bean("defaultEntityManagerFactory") fun defaultEntityManagerFactory( @Qualifier("defaultDataSource") dataSource: DataSource, jpaProperties: JpaProperties, hibernateProperties: HibernateProperties, builder: EntityManagerFactoryBuilder, beanFactory: ConfigurableListableBeanFactory, ): LocalContainerEntityManagerFactoryBean { val build = builder .dataSource(dataSource) .packages( "com.yunhalee.database.multidatabase.user" ) .persistenceUnit("default") // 이때 jpaProperties의 경우 ddl auto 설정과 같은 hibernate 설정은 가지고 있지 않기 때문에 해당 설정의 사용을 원한다면, hibernate 설정으로 감싸서 설정해야한다. .properties(hibernateProperties.determineHibernateProperties(jpaProperties.properties, HibernateSettings())) .build() // AttributeConverter와 같은 등록된 빈을 가져오기 위해 SpringBeanContainer를 설정 // 설정하지 않으면 AttributeConverter를 찾지 못해 NoSuchMethodException가 발생한다. build.jpaPropertyMap[AvailableSettings.BEAN_CONTAINER] = SpringBeanContainer(beanFactory) return build } @Primary @Bean("defaultTransactionManager") fun defaultTransactionManager( @Qualifier("defaultEntityManagerFactory") entityManagerFactory: EntityManagerFactory, ): PlatformTransactionManager { return JpaTransactionManager(entityManagerFactory) } }
위에 주석처리된 설명과 같이 나의 경우 enum 상태 속성을 관리할때 AttributeConverter를 사용하고 있었는데, 이때 LocalContainerEntityManagerFactoryBean을 설정할때, 빈을 스캔하도록 지정해주지 않으면 기존에는 자동으로 주입되던 AttributeConverter를 찾지 못해 NoSuchMethodException가 발생한다.
또한 application.yaml에 ddl-auto 설정을 해주더라도 다음과 같이 jpa설정만 적용하면 정상동작 하지 않는다는 것을 알게 되었는데, 이는 JpaProperties에 해당 설정값이 없기 때문이다!
- 💡 참고
fun accountsEntityManagerFactory( dataSource: DataSource, jpaProperties: JpaProperties, builder: EntityManagerFactoryBuilder, beanFactory: ConfigurableListableBeanFactory ): LocalContainerEntityManagerFactoryBean { val build = builder .dataSource(dataSource) .packages("") .persistenceUnit("default") // 문제 부분 .properties(jpaProperties.properties) .build() build.jpaPropertyMap[AvailableSettings.BEAN_CONTAINER] = SpringBeanContainer(beanFactory) return build }
JpaProperties를 살펴보면 다음과 같다. hibernate 설정을 가지고 있지 않다.
import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.orm.jpa.vendor.Database; /** * External configuration properties for a JPA EntityManagerFactory created by Spring. * * @author Dave Syer * @author Andy Wilkinson * @author Stephane Nicoll * @author Eddú Meléndez * @author Madhura Bhave * @since 1.1.0 */ @ConfigurationProperties(prefix = "spring.jpa") public class JpaProperties { /** * Additional native properties to set on the JPA provider. */ private Map<String, String> properties = new HashMap<>(); /** * Mapping resources (equivalent to "mapping-file" entries in persistence.xml). */ private final List<String> mappingResources = new ArrayList<>(); /** * Name of the target database to operate on, auto-detected by default. Can be * alternatively set using the "Database" enum. */ private String databasePlatform; /** * Target database to operate on, auto-detected by default. Can be alternatively set * using the "databasePlatform" property. */ private Database database; /** * Whether to initialize the schema on startup. */ private boolean generateDdl = false; /** * Whether to enable logging of SQL statements. */ private boolean showSql = false; /** * Register OpenEntityManagerInViewInterceptor. Binds a JPA EntityManager to the * thread for the entire processing of the request. */ private Boolean openInView; public Map<String, String> getProperties() { return this.properties; } public void setProperties(Map<String, String> properties) { this.properties = properties; } public List<String> getMappingResources() { return this.mappingResources; } public String getDatabasePlatform() { return this.databasePlatform; } public void setDatabasePlatform(String databasePlatform) { this.databasePlatform = databasePlatform; } public Database getDatabase() { return this.database; } public void setDatabase(Database database) { this.database = database; } public boolean isGenerateDdl() { return this.generateDdl; } public void setGenerateDdl(boolean generateDdl) { this.generateDdl = generateDdl; } public boolean isShowSql() { return this.showSql; } public void setShowSql(boolean showSql) { this.showSql = showSql; } public Boolean getOpenInView() { return this.openInView; } public void setOpenInView(Boolean openInView) { this.openInView = openInView; } }
그렇다면, 어떻게 적용할 수 있을까?
다음과 같이 Hibernate설정을 발견하고 이를 활용하도록 했다. ✨✨✨✨✨
@ConfigurationProperties("spring.jpa.hibernate") public class HibernateProperties { private static final String DISABLED_SCANNER_CLASS = "org.hibernate.boot.archive.scan.internal.DisabledScanner"; private final Naming naming = new Naming(); /** * DDL mode. This is actually a shortcut for the "hibernate.hbm2ddl.auto" property. * Defaults to "create-drop" when using an embedded database and no schema manager was * detected. Otherwise, defaults to "none". */ private String ddlAuto; // 발견한 부분 public Map<String, Object> determineHibernateProperties(Map<String, String> jpaProperties, HibernateSettings settings) { Assert.notNull(jpaProperties, "JpaProperties must not be null"); Assert.notNull(settings, "Settings must not be null"); return getAdditionalProperties(jpaProperties, settings); } }
다음으로 aDatabaseJpaConfig 파일은 다음과 같다.
import jakarta.persistence.EntityManagerFactory import org.hibernate.cfg.AvailableSettings import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.config.ConfigurableListableBeanFactory import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.FilterType import org.springframework.context.annotation.Primary import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.orm.hibernate5.SpringBeanContainer import org.springframework.orm.jpa.JpaTransactionManager import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean import org.springframework.transaction.PlatformTransactionManager import javax.sql.DataSource @Configuration @EnableJpaRepositories( basePackages = ["com.yunhalee.database.multidatabase.team.*"], entityManagerFactoryRef = "defaultEntityManagerFactory", transactionManagerRef = "defaultTransactionManager", ) class ADatabaseJpaConfig { @Primary @Bean("aEntityManagerFactory") fun defaultEntityManagerFactory( @Qualifier("aDataSource") dataSource: DataSource, jpaProperties: JpaProperties, hibernateProperties: HibernateProperties, builder: EntityManagerFactoryBuilder, beanFactory: ConfigurableListableBeanFactory, ): LocalContainerEntityManagerFactoryBean { val build = builder .dataSource(dataSource) .packages( "com.yunhalee.database.multidatabase.team" ) .persistenceUnit("a") .properties(hibernateProperties.determineHibernateProperties(jpaProperties.properties, HibernateSettings())) .build() build.jpaPropertyMap[AvailableSettings.BEAN_CONTAINER] = SpringBeanContainer(beanFactory) return build } @Bean("aTransactionManager") fun defaultTransactionManager( @Qualifier("aEntityManagerFactory") entityManagerFactory: EntityManagerFactory, ): PlatformTransactionManager { return JpaTransactionManager(entityManagerFactory) } }
entityManager별로 QueryDsl을 설정하기
이번엔 설정한 엔티티 매니저별로 queryDsl을 사용할 수 있도록 설정해보자.
PersistenceContext의 unitName에 들어갈 설정은 앞서 JpaConfig에서 EntityManagerFactory 빈 설정에 넣어주었던 persistenceUnit 명을 넣어주면 된다.
import com.querydsl.jpa.impl.JPAQueryFactory import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary @Configuration class QueryDslConfig { @PersistenceContext(unitName = "default") private val defaultEntityManager: EntityManager? = null @PersistenceContext(unitName = "a") private val aEntityManager: EntityManager? = null @Primary @Bean fun defaultQueryFactory(): JPAQueryFactory { return JPAQueryFactory(defaultEntityManager) } @Bean fun aQueryFactory(): JPAQueryFactory { return JPAQueryFactory(aEntityManager) } }
사용할떄는 queryFactory대신 지정된 aQueryFactory나 defaultQueryFactory를 이용해서 사용하면 된다!
끝!!✨✨✨✨✨
+ 다음으로 해보고 싶은 것 -> JTA를 이용해서 분산트랜잭션 적용해보기 ✨
✨ 참고한 사이트)
- https://velog.io/@suhongkim98/Spring-Data-JPA-multi-datasource-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0
- https://brunch.co.kr/@purpledev/33
- https://lomuto.tistory.com/21
- https://mudchobo.github.io/posts/spring-boot-jpa-multiple-database
- https://jong-bae.tistory.com/58
- https://jong-bae.tistory.com/62
- https://jong-bae.tistory.com/61 ✨✨✨✨✨
- https://velog.io/@kdohyeon/Spring-EnableJpaAuditing
💡 추후 JTA에서 참고할만한 글
- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-jta-atomikos/2.7.18
- https://devssul.tistory.com/33
- https://d2.naver.com/helloworld/5812258
'Spring' 카테고리의 다른 글
Grpc Service의 호출오류가 Grpc Client 헬스체크 실패를 야기하는 문제 해결 (0) 2024.11.19 동시 삭제 요청으로 인한 StaleObjectStateException 해결 - redisson lock 적용기 (feat. Spring AOP, applicationEventListener) (2) 2024.10.01 Grpc + Spring : 예외 처리 구현 (0) 2024.09.22 Grpc Spring Security - 3) Grpc Client에서 header를 포함한 grpc 호출하기 (3) 2024.09.21 Grpc Spring Security - 2) Grpc Service에 인증, 인가 구현하기 (0) 2024.09.21 - 💡 참고