토이 프로젝트를 진행하면서 발생했던 문제에 대한 본인의 생각과 고민을 기록한 글입니다.
기술한 내용이 공식 문서 내용과 상이할 수 있음을 밝힙니다.
Mysql 공식문서 참고: https://dev.mysql.com/doc/refman/8.0/en/
Postgre 공식문서 참고: https://www.postgresql.org/docs/
테이블 모델이다. 메뉴
와 메뉴 리뷰
가 1:多
로 매핑되어있다. 메뉴 리뷰를 작성하려고 넣어둔 것이다.
대부분의 사용자들은 주문하기 전에 메뉴의 리뷰를 검색한다. 리뷰 댓글의 수가 많을 경우, 이를 처리하기 위해서는 검색 전용 DB를 두어 대용량데이터 처리를 하면 어떨까 생각하여 진행하게 되었다.
멀티 DB에 대한 나의 생각은 아래와 같다.
팀원과는 단일DB와 같은 RDBMS인 MySQL을 사용하려고 했다. 하지만 MySQL은 전문검색(Full Text Search)를 위해서 인덱스 설정(FULLTEXT INDEX)를 설정해야 한다..sql 파일
과 JPA의 DDL 자동 생성 기능을 동시에 사용하면, 데이터베이스 스키마의 버전을 관리하는 것이 어려워지고 두 가지 방법이 서로 충돌하거나, 예상치 못한 변경이 발생할 수 있다기에, 추가적인 인덱싱없이 쿼리로만 전문검색이 가능한 PostgreSQL을 사용해보기로 했다.
멀티 DB 환경에서는 각각의 DB 연결과 트랜잭션 관리를 위해 개발자가 직접 설정을 해주어야 한다.
Spring Boot를 사용할 경우, 단일 DB를 사용하면 yml 또는 properties 파일에서 간단히 데이터 소스 설정을 할 수 있지만, 여러 DB를 사용할 경우에는 보통 각 DB에 대한 DataSource, EntityManager, TransactionManager 등을 별도로 설정해주어야 한다.
spring:
datasource:
main: # 메인DB: mysql
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/yunni_bucks_traffic?rewriteBatchedStatements=true
username: root
password: 1234
hikari:
auto-commit: false
connection-test-query: SELECT 1
maximum-pool-size: 40
pool-name: mysql-example-cp
hibernate:
ddl-auto: create
sub: # 서브DB: postgreSQL
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/yunni_bucks_traffic
username: root
password: 1234
hikari:
auto-commit: false
connection-test-query: SELECT 1
maximum-pool-size: 40
pool-name: postgres-example2-cp
hibernate:
ddl-auto: create
Spring Boot의 @ConfigurationProperties 어노테이션을 이용해 yml 파일의 설정값과 Java 객체를 매핑시키는 역할을 한다.
@ConfigurationProperties(prefix = "spring.datasource") 어노테이션은 spring.datasource로 시작하는 프로퍼티들을 해당 클래스의 필드와 매핑시킨다. 이 클래스는 두 개의 내부 클래스 DatabaseDetail와 Hibernate를 가지고 있으며, 이들은 각각 DB 연결 정보와 Hibernate 관련 설정이다.
@Data
@Configuration
@ConfigurationProperties(prefix = "spring.datasource")
public class DatabaseProperties {
private DatabaseDetail main;
private DatabaseDetail sub;
@Data
public static class DatabaseDetail {
private String driverClassName;
private String url;
private String username;
private String password;
private Hibernate hibernate;
}
@Data
public static class Hibernate {
private String ddlAuto;
private String dialect;
private Naming naming;
private String metadataBuilderContributor;
public static Map<String, Object> propertiesToMap(Hibernate hibernateProperties) {
Map<String, Object> properties = new HashMap<>();
if (hibernateProperties.getDdlAuto() != null) {
properties.put("hibernate.hbm2ddl.auto", hibernateProperties.getDdlAuto());
}
if (hibernateProperties.getMetadataBuilderContributor() != null) {
properties.put("hibernate.metadata_builder_contributor", hibernateProperties.getMetadataBuilderContributor());
}
DatabaseProperties.Naming hibernateNaming = hibernateProperties.getNaming();
if (hibernateNaming != null) {
if (hibernateNaming.getImplicitStrategy() != null) {
properties.put("hibernate.implicit_naming_strategy", hibernateNaming.getImplicitStrategy());
}
if (hibernateNaming.getPhysicalStrategy() != null) {
properties.put("hibernate.physical_naming_strategy", hibernateNaming.getPhysicalStrategy());
}
}
return properties;
}
}
@Data
public static class Naming {
private String implicitStrategy;
private String physicalStrategy;
}
}
DatabaseProperties 클래스는 데이터베이스 연결에 필요한 구성 정보를 제공한다. 이 클래스는 @ConfigurationProperties 어노테이션을 통해 Spring Boot의 구성 시스템과 연결된다. 이를 통해 application.properties 또는 application.yml 파일에 정의된 속성을 읽어온다.
@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(basePackages = {"sejong.coffee.yun.repository"},
entityManagerFactoryRef = sejong.coffee.yun.config.database.PrimaryConfig.ENTITY_MANAGER_BEAN_NAME,
transactionManagerRef = sejong.coffee.yun.config.database.PrimaryConfig.TRANSACTION_MANAGER_BEAN_NAME)
public class PrimaryConfig {
public static final String TRANSACTION_MANAGER_BEAN_NAME = "oneDBTransactionManager";
public static final String ENTITY_MANAGER_BEAN_NAME = "oneDBEntityManager";
private static final String DATASOURCE_BEAN_NAME = "oneDataSource";
private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.main";
private static final String DATASOURCE_PROPERTIES = "oneDataSourceProperties";
private static final String HIBERNATE_PROPERTIES = "oneHibernateProperties";
@Primary
@Bean(name = ENTITY_MANAGER_BEAN_NAME)
public LocalContainerEntityManagerFactoryBean entityManager(EntityManagerFactoryBuilder builder, @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
@Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties) {
return builder.dataSource(dataSource).packages("sejong.coffee.yun.domain")
.persistenceUnit(ENTITY_MANAGER_BEAN_NAME)
.properties(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties)).build();
}
@Bean(name = HIBERNATE_PROPERTIES)
@ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX + ".hibernate")
public DatabaseProperties.Hibernate hibernateProperties() {
return new DatabaseProperties.Hibernate();
}
@Bean(name = DATASOURCE_PROPERTIES)
@ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX)
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Primary
@Bean(name = DATASOURCE_BEAN_NAME)
@ConfigurationProperties(prefix = DATASOURCE_PROPERTIES_PREFIX + ".hikari")
public DataSource dataSource(@Qualifier(DATASOURCE_PROPERTIES) DataSourceProperties dataSourceProperties) {
return dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Primary
@Bean(name = TRANSACTION_MANAGER_BEAN_NAME)
public PlatformTransactionManager transactionManager(@Qualifier(ENTITY_MANAGER_BEAN_NAME) EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
config 해야하는 설정이 복잡하다.
"sejong.coffee.yun.repository"
패키지가 지정되어 있다."sejong.coffee.yun.domain"
패키지 내의 클래스를 엔티티로 등록하겠다는 의미다. .persistenceUnit(ENTITY_MANAGER_BEAN_NAME)은 persistence unit의 이름을 설정한다.@Configuration
@EnableConfigurationProperties(DatabaseProperties.class)
@EnableJpaRepositories(basePackages = {"sejong.coffee.yun.domain.order.menu.postgre"},
entityManagerFactoryRef = SecondConfig.ENTITY_MANAGER_BEAN_NAME,
transactionManagerRef = SecondConfig.TRANSACTION_MANAGER_BEAN_NAME)
public class SecondConfig {
public static final String TRANSACTION_MANAGER_BEAN_NAME = "twoDBTransactionManager";
public static final String ENTITY_MANAGER_BEAN_NAME = "twoDBEntityManager";
private static final String DATASOURCE_BEAN_NAME = "twoDataSource";
private static final String DATASOURCE_PROPERTIES = "twoDataSourceProperties";
private static final String DATASOURCE_PROPERTIES_PREFIX = "spring.datasource.sub";
private static final String HIBERNATE_PROPERTIES = "twoHibernateProperties";
@Bean(name = ENTITY_MANAGER_BEAN_NAME)
public LocalContainerEntityManagerFactoryBean entityManager(EntityManagerFactoryBuilder builder, @Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource,
@Qualifier(HIBERNATE_PROPERTIES) DatabaseProperties.Hibernate hibernateProperties) {
return builder.dataSource(dataSource).packages("sejong.coffee.yun.domain.user", "sejong.coffee.yun.domain.order")
.persistenceUnit(ENTITY_MANAGER_BEAN_NAME)
.properties(DatabaseProperties.Hibernate.propertiesToMap(hibernateProperties))
.build();
}
@Bean(name = HIBERNATE_PROPERTIES)
@ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX + ".hibernate")
public DatabaseProperties.Hibernate hibernateProperties() {
return new DatabaseProperties.Hibernate();
}
@Bean(name = DATASOURCE_PROPERTIES)
@ConfigurationProperties(DATASOURCE_PROPERTIES_PREFIX)
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean(name = DATASOURCE_BEAN_NAME)
@ConfigurationProperties(prefix = DATASOURCE_PROPERTIES_PREFIX + ".hikari")
public DataSource dataSource(@Qualifier(DATASOURCE_PROPERTIES) DataSourceProperties dataSourceProperties) {
return dataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
}
@Bean(name = TRANSACTION_MANAGER_BEAN_NAME)
public PlatformTransactionManager transactionManager(@Qualifier(ENTITY_MANAGER_BEAN_NAME) EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
@Bean
public JdbcTemplate postgresJdbcTemplate(@Qualifier(DATASOURCE_BEAN_NAME) DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
PrimaryConfig와 SecondConfig 클래스의 차이점은 어떤 DB의 datasource를 바라보는지의 차이다.
@EnableJpaRepositories(basePackages = {"..."}
패키지가 겹치면 안 된다. 같은 패키지 또는 인터페이스를 두 개 이상의 @EnableJpaRepositories에서 중복해서 지정하게 되면, 스프링 데이터 JPA가 같은 리포지토리를 두 번 생성하려고 시도하게 된다.public interface JpaPostgresSQLMenuReviewRepository extends JpaRepository<MenuReview, Long> {
@Query(nativeQuery = true, value = "SELECT * FROM menu_review WHERE comments LIKE %:keyword%")
List<MenuReview> findMenuReviewByCommentsContainingWithQuery(@Param("keyword") String keyword);
List<MenuReview> findByCommentsContains(@Param("keyword") String keyword);
@Query(value = "SELECT * FROM menu_review WHERE to_tsvector('english', comments) @@ plainto_tsquery('english', ?)", nativeQuery = true)
List<MenuReview> findMenuReviewByCommentsContainingOnFullTextSearchWithQuery(@Param("keyword") String keyword);
}
메뉴 검색을 하는 조건은 keyword
를 통해서 연관된 리뷰 내용이 검색되도록 시나리오를 가정하고 Repository를 구성하였다.
위와 같이 config를 하고 컴파일 빌드를 했을 때 지속적인 에러가 발생했었다.
2024-01-02 22:08:58,207 WARN o.h.e.jdbc.spi.SqlExceptionHelper - SQL Error: 0, SQLState: 42P01
2024-01-02 22:08:58,207 ERROR o.h.e.jdbc.spi.SqlExceptionHelper - ERROR: relation "menu_review" does not exist
Position: 249
2024-01-02 22:08:58,209 INFO o.h.e.i.DefaultLoadEventListener - HHH000327: Error performing load command
org.hibernate.exception.SQLGrammarException: could not extract ResultSet
at org.hibernate.exception.internal.SQLStateConversionDelegate.convert(SQLStateConversionDelegate.java:103)
Caused by: org.postgresql.util.PSQLException: ERROR: relation "menu_review" does not exist
Position: 249
org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.extract(ResultSetReturnImpl.java:57)
... 153 more
_ERROR: relation "menu_review" does not exist_
메뉴리뷰 테이블 생성이 안 된다는 에러이다.
script문으로 table을 생성하는게 아닌 ddl-auto: create로 JPA 테이블 자동생성이 하고 싶었는데 자동으로 생성이 안되는 문제인 것 같다.
도저히 해결방안을 찾지 못했기에 결국 직접 sql문으로 table 생성을 해보았다.
create table menu_review
(
id bigint not null
primary key,
comments varchar(255) null,
create_at datetime(6) null,
update_at datetime(6) null,
member_id bigint null,
menu_id bigint null,
constraint FK1xu83uc0dfxjy7txwjq10a8wj
foreign key (menu_id) references Menu (id),
constraint FKqn8j3aohwjf7cy04drsi9v9c7
foreign key (member_id) references Member (id)
);
menu_review
table 생성 후 select문 실행하려니 에러가 발생했다.
Error occurred during SQL query execution SQL Error [42501]: ERROR: permission denied for schema hr Position:
데이터베이스 사용자를 조회해보니 root 계정에 권한이 없었던 것이다.
권한 추가 후 재시도 하니까 그제서야 테이블이 생성되었다.
테이블을 drop하고 JPA로 ddl-auto: create가 되는지 다시 테스트를 해보았다.
Spring 진영 연동 문제가 아닌 DB 권한 설정으로 인해 발생한 문제였던 것이다. (6시간 삽질)
예상했던 단점 및 고려사항에서 복잡성 증가가 내 발목을 잡았다.
다음 글에서는 PostgreSQL로 데이터 10만건, 100만건을 생성하여 LIKE, Full Text Search 검색 성능을 비교해보겠다.
JPA로 쿼리 실행 시 Table 'Member' doesn't exist
와 같은 데이터베이스 관련 에러가 발생했다.
(삽질 끝에 아래의 설정을 해주지 않아 발생한 문제였다.)
해당 설정은 JPA가 자바의 카멜 케이스(CamelCase)를 사용하는 필드 이름을 데이터베이스의 스네이크 케이스(Snake_Case)를 사용하는 칼럼 이름에 매핑하는 방식을 지정하는 것이다.
hibernate.physical_naming_strategy: 데이터베이스 테이블 및 칼럼 이름의 실제 이름을 결정하는 전략이다. CamelCaseToUnderscoresNamingStrategy는 카멜 케이스를 스네이크 케이스로 변환한다. 예를 들어, userName 필드는 user_name 칼럼에 매핑된다.
hibernate.implicit_naming_strategy: JPA 엔티티와 테이블, 칼럼 사이의 이름을 암시적으로 결정하는 전략이다. SpringImplicitNamingStrategy는 스프링에서 제공하는 기본적인 명명 전략을 사용한다.
@NotNull
private LocalContainerEntityManagerFactoryBean getLocalContainerEntityManagerFactoryBean(LocalContainerEntityManagerFactoryBean em) {
final HashMap<String, Object> properties = new HashMap<String, Object>();
properties.put("hibernate.physical_naming_strategy", CamelCaseToUnderscoresNamingStrategy.class.getName());
properties.put("hibernate.implicit_naming_strategy", SpringImplicitNamingStrategy.class.getName());
em.setJpaPropertyMap(properties);
return em;
}
따라서 이 설정을 하지 않으면, JPA는 자바 객체의 필드 이름과 데이터베이스의 칼럼 이름을 정확히 일치시키려고 한다. 대소문자의 차이로 인해 JPA가 Member
라는 이름의 테이블을 찾으려고 하지만, 실제 데이터베이스에는 member
라는 이름의 테이블만 존재해서 발생하는 문제였던 것이다. (userName을 user_name으로 매핑해주지 못하는 것도 같은 이유일 것이다.)