완성된 코드는 Project-GithubLink 깃허브 링크를 통해 확인 할 수 있다!
토이프로젝트를 진행하면서 Spring Data JPA만으로는 한계가 있다. 애플리케이션에서 단순히 DB에 데이터 넣고, 수정하고, 삭제하는 (Create, Update, Delte) 로직은 그 자체만으로는 어렵지 않은 동작이다. 문제는 데이터를 읽기 (Read)로 여러 테이블의 데이터를 Join 하거나, 특정 계산과정 등을 적용해서, 정제되고 유의미한 데이터 형태로 가공이 필요해 진다.
나의 경우에도 Read의 경우 여러 개의 도메인이 합쳐져서 가공된 완전한 데이터가 필요했고, 이 때 Querydsl을 고려하게 됐다.
Spring Data JPA로 기능을 구현 하는 경우, 각각의 도메인에서 데이터를 조회하고, 그것을 다시 따로 하나로 합쳐서 완성된 데이터 형태로 조립하는 방식으로도 처리 할 수는 있지만, 애플리케이션에서의 가장 큰 비용 중 하나인 통신 (여기서는 DB에게 질의)이 빈번하게 발생하는 것은 비효율율이기에, 한 번의 SQL 질의를 통해 원하는 데이터를 가져오도록 하는 것이 목적이다.
물론 JPA에서 Querydsl과 같은 라이브러리를 사용하지 않고도 다른 선택지가 있는데, 바로 스프링의 어노테이션 중 Query나 NetiveQuery를 이용한 방법이다.
Query
는 JPQL(Java Persistence Query Language)을 사용하는 방식으로 JPA Entity를 대상으로 쿼리를 작성하는 방식으로 타입 안정성을 일부 보장 할 수있는 방식이다. JPQL을 사용할 때 보조적인 방법으로 Criteria 라는 API도 있다.NetiveQuery
는 말 그대로 DB에 대한 직접적인 SQL 문장을 작성하는 방식으로, 튜닝이나 고유한 문법 등을 유연하게 처리 할 수 있지만 DB에 종속적인 문제가 있다.위 2가지 방법에서의 가장 큰 문제점은 개발자가 sql문장을 일부 혹은 전부를 문자열로 작성해야 한다는 점인데, 이것은 유지보수의 큰 문제가 있다. 또한 가독성도 대체적으로 떨어지는 편으로 이것에 대한 해결책으로 보통 Querydsl이 선택지로 알려져있는 상황이다.
SQL을 사용하면서도 타입 안정성을 보장하고, 유지보수와 가독성, 생산성 등에서 이점을 보이는 Querydsl은 좋은 선택지이며, 나도 이전에 사용하고 공부했던 적이 있었다. 그래서 이번에도 Querydsl의 도입을 적극적으로 하려고 했다. 그런데 처음 환경을 세팅하면서 기존의 라이브러리가 관리 되고 있지 않은 것을 확인 했다. 현재 시점에서는 OpenFeign-Querydsl 이것을 사용하면 될 것 같다.(fork 되어 관리되는 버전이다.)
이 정보를 찾으면서 Querydsl은 이전부터 지속적으로 유지보수가 문제가 있었다는 것과, 대체할 수 있는 라이브러리로 Jooq가 있다는 정보를 찾았다.
외국에서는 Querydsl 대신 Jooq의 사용 비중이 훨씬 높고, 점점 늘어가고 있다는 점 등 Jooq를 사용 하는 것을 고려하게 되었다. Jooq는 사용법 자체는 Querydsl과 그리 큰 차이가 없고, 상용 라이브러리로 유지보수는 상당부분 보장되어있다고 볼 수 있겠다. 가격 문제에서는 사용하려는 DB가 오픈소스 기반이라면 Jooq를 무료로 사용 할 수 있기에 도입하지 않을 이유가 없었다.
다만 문제라면 한글로 된 자료가 극히 적다는 점이 단점이며, 초기 세팅이 조금 어렵다는 점이 있다고 한다.
Jooq-Learn은 공식 문서로 사용법을 익힐 수 있다.
Jooq를 사용하기 위해 세팅하고 바로 사용해 보려고 했으나 한 가지 문제가 생겼다. 나는 기존에 JPA를 통해 JPAEntity를 코드로 매핑하고, DDL문도 함께 생성하도록 관리 했다. Querydsl을 사용 했다면 이 JPAEntity를 기반으로 Q로 시작하는 별도의 클래스를 만들고 이것을 통해 Querydsl을 구현하는 방식이었다.
Jooq의 경우에도 비슷하게 DB의 테이블과 컬럼명과 같은 메타데이터를 코드로 생성할 필요가 있다. 문제는 Jooq의 경우에는 JPA와는 관련이 없는 기술이고, 코드생성은 실제 DB를 접근해서 만드는 방식이었다. 나의 경우 개발단계에서는 H2 DB를 통해서 언제든지 만들고 수정 할 수 있는 DB를 사용하고 싶었기에 별도의 물리적인 DB 환경을 따로 구축하고 싶지는 않았다.
그래서 Spring 환경에서 처음 프로젝트가 실행되면 설정한 초기 sql문이 실행되고, 이것을 기본으로 DB 메타데이터에 관련된 코드를 생성하도록 처리하려고 했으나, 검색 해 본 결과 Jooq와 Flyway를 조합하여 DB를 관리하는 것이 일반적이라고 해서 사용해 보게 되었다.
이 Flyway는 데이터베이스의 스키마를 버전별로 관리하며, 이력과 함께 안전한 데이터베이스 스키마 관리에 도움을 주는 도구이다. 그 간단한 사용법을 먼저 알아보겠다.
현재 기준으로 SpringBoot의 최신버전과 다른 라이브러리도 최신 버전을 사용한다.
주요 설정은 다음과 같다.
spring:
application:
name: jooq
flyway:
baseline-on-migrate: true
baseline-version: 0
datasource:
url: 'jdbc:h2:file:./build/data/testdb;MODE=PostgreSQL;AUTO_SERVER=TRUE;'
username: sa
password:
driverClassName: org.h2.Driver
jpa:
properties:
hibernate:
show_sql: true
format_sql: true
use_sql_comments: true
ddl-auto: validate
h2:
console:
enabled: true
path: /h2-console
server:
port: 8009
중요 포인트는 H2 DB 설정 중에서 file DB로 전환한 점이다. 원래는 계속 메모리 DB 설정인 mem 을 사용하고 있었는데, Flyway와 Jooq를 동시에 사용하면서는 실행 순서로 인한 문제로 (DB 정보가 먼저 선행 되어있어야 함) 오류가 발생해서 file DB 형태로 바꾸기로 했다.
또 hibernate의 설정 중 ddl-auto: validate로 설정했다. 더 이상 테이터베이스의 스키마는 JPA가 관리하지 않으며, 코드 내 JPAEntity를 테이블과 매핑했을 경우 실제 데이터베이스와 제대로 맞는지 검증하는 작업만을 하도록 했다. 이제 스키마는 전적으로 Flyway가 관리하도록 하기 위함이다.
다음 의존성과 관련된 설정은 다음과 같으며 나의 경우는 gradle을 사용했다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.4'
id 'io.spring.dependency-management' version '1.1.6'
id 'org.flywaydb.flyway' version '10.18.2'
}
group = 'onyx'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.flywaydb:flyway-core'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
// Flyway 설정
flyway {
url = "jdbc:h2:file:${projectDir}/build/data/testdb;MODE=PostgreSQL;AUTO_SERVER=TRUE;"
user = 'sa'
password = ''
schemas = ['PUBLIC'] // H2 에서는 대문자 사용
}
Flyway에 대한 플러그인, 라이브러리, 설정을 추가했다.
이제 스키마를 만들어야 한다. 직접 필요한 sql파일을 통해서 DDL 문장을 작성하면 된다.
resources/db/migration 하위에 sql파일을 만들면 된다. 나의 경우 V1__Create.sql라는 이름을 선택했는데, 이름의 규칙에서는 앞에서의 V1 (버전정보) 와 __ 언더스코어 2개만 잘 지켜주면 된다.
CREATE TABLE team
(
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE member
(
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
team_id INTEGER,
CONSTRAINT fk_team
FOREIGN KEY (team_id)
REFERENCES team (id)
ON DELETE SET NULL
);
팀과 멤버의 관계를 나타내는 스키마(테이블)를 정의했다. 이제 gradle에서 flywayMigrate를 실행 해보자. (IDE의 플러그인 기능으로 사용하면 더 쉽다.)
제대로 실행이 되었다면 이번에는 flywayInfo를 실행 해 보자.
> Task :flywayInfo
Schema version: 1
+-----------+---------+-------------+------+---------------------+---------+----------+
| Category | Version | Description | Type | Installed On | State | Undoable |
+-----------+---------+-------------+------+---------------------+---------+----------+
| Versioned | 1 | Create | SQL | 2024-10-03 17:13:21 | Success | No |
+-----------+---------+-------------+------+---------------------+---------+----------+
위와 같은 정보가 확인 되었다면 정상적으로 동작하는 것이다. 이 flywayInfo를 통해 스키마의 버전과 변경 이력을 확인할 수 있다.
이제 실제 프로젝트에서도 DB가 제대로 적용되는지 확인을 위해서 스프링 어플리케이션을 실행해 본다.
웹 브라우저에서 localhost/h2-console을 확인 한다.
JDBC URL을 jdbc:h2:file:./build/data/testdb
인지 확인하고 접속 해 보자.
h2-console을 통해서 제대로 테이블이 생성된 것을 확인 할 수 있다.
이번에는 다음의 스키마 변경을 시도 해 본다.
resources/db/migration/V2__Create.sql 파일을 새로 만들고 아래의 내용을 입력한다.
ALTER TABLE team ADD COLUMN description VARCHAR(500);
이제 다시 프로젝트를 다시 시작 해 본다. 만약 flyway에서 에러가 발생했다면 flywayRepair 이후 다시 flywayMigrate의 작업을 실행하는 해 보면 된다.
정상적으로 프로젝트가 동작 되었다면 다시 DB를 확인 해보면 정상적으로 description 컬럼이 추가된 것을 확인 할 수 있으며, flywayInfo의 경우도
> Task :flywayInfo
Schema version: 2
+-----------+---------+-------------+------+---------------------+---------+----------+
| Category | Version | Description | Type | Installed On | State | Undoable |
+-----------+---------+-------------+------+---------------------+---------+----------+
| Versioned | 1 | Create | SQL | 2024-10-03 17:13:21 | Success | No |
| Versioned | 2 | Create | SQL | 2024-10-03 17:26:55 | Success | No |
+-----------+---------+-------------+------+---------------------+---------+----------+
이런식으로 버전 관리가 되는 것을 확인 할 수 있다.
DB 스키마의 상태를 관리해주기 때문에 안정성은 보장되나, 매번 조금 귀찮은 작업이 추가된 것은 사실이다. 따라서 프로젝트 초기에 DB 설계 단계가 막 끝나고 아직 세부요구사항의 변경이 잦아서 DB변경이 많이 예상된다면, 이 시기가 조금 지나고 안정화된 시점부터 Flyway를 통해 DB를 관리하는 것이 조금 나은 선택일 것이다.
본격적으로 Jooq를 설정하고 사용해 보기로 한다. 전제조건인 Flyway를 통한 DB 스키마 관리의 환경에서 적용하므로, 이전의 설정이 선행 되어 있어야 한다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.4'
id 'io.spring.dependency-management' version '1.1.6'
id 'org.flywaydb.flyway' version '10.18.2'
id "nu.studer.jooq" version "9.0"
}
group = 'onyx'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.flywaydb:flyway-core'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
// jOOQ Dependencies
implementation group: 'org.jooq', name: 'jooq', version: '3.19.13'
implementation group: 'org.jooq', name: 'jooq-meta', version: '3.19.13'
implementation group: 'org.jooq', name: 'jooq-codegen', version: '3.19.13'
jooqGenerator 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
// Flyway 설정
flyway {
url = "jdbc:h2:file:${projectDir}/build/data/testdb;MODE=PostgreSQL;AUTO_SERVER=TRUE;"
user = 'sa'
password = ''
schemas = ['PUBLIC'] // H2 에서는 대문자 사용
}
// jOOQ 설정
jooq {
version = '3.19.13'
configurations {
main { // jOOQ 설정의 이름
generateSchemaSourceOnCompilation = true
generationTool {
logging = org.jooq.meta.jaxb.Logging.WARN
jdbc {
driver = 'org.h2.Driver'
url = "jdbc:h2:file:${projectDir}/build/data/testdb;MODE=PostgreSQL;AUTO_SERVER=TRUE;"
user = 'sa'
password = ''
properties {
property {
key = 'ssl'
value = 'true'
}
}
}
generator {
name = 'org.jooq.codegen.DefaultGenerator'
database {
name = 'org.jooq.meta.h2.H2Database' // H2 방언으로 설정
inputSchema = 'PUBLIC' // H2 스키마 이름 대문자 사용
forcedTypes {
// 필요 시 H2에서 지원하는 타입으로 설정하거나 제거
/*
forcedType {
name = 'varchar'
includeExpression = '.*'
includeTypes = 'VARCHAR'
}
*/
}
}
generate {
deprecated = false
records = true
immutablePojos = true
fluentSetters = true
}
target {
packageName = 'onyx.jooq' // 프로젝트의 기본 패키지와 일치
}
strategy.name = 'org.jooq.codegen.DefaultGeneratorStrategy'
}
}
}
}
}
Jooq 플러그인과 의존성, 설정 등을 추가했다. 설정은 대부분 기본설정을 따르고 있다. Flyway와 Jooq의 아쉬운 점이라고 생각하는 점은 DB에 대한 종속성이 강하다. 사용하는 DB에 대한 설정과 jooqGenerator를 맞춰줘야 하며, 일부 DB마다 강제로 타입을 변경해 줘야 하는 경우도 있다. (forcedType)
이 설정의 원래 의도는 개발/테스트 단계에서는 H2 를 사용하고, 실제 운영 레벨에서는 Postgresql을 사용할 것으로 상정했다. 그래서 H2 DB 설정에서 호환모드로 MODE=PostgreSQL;를 설정 해 주고, 다른 옵션 값을 이용해서 최대한 Postgresql과 비슷한 문법을 갖추도록 했는데 결국 에러를 잡지 못해서 롤백했다. MODE=PostgreSQL는 그리 큰 문제는 없어서 다만 냅둔 상태이다. 따라서 Flyway와 Jooq의 경우에는 환경에 따른 DB가 다르다면 다른 설정을 해 줘야 하므로, 설정 파일을 분리하거나, 따로 처리 할 수 있도록 하는 조치가 필요하다.
gradle을 통해서 generateJooq를 실행한다. 위 설정을 그대로 사용했다면 정상적으로 실행 될 것이다. 그리고 실행 이후에는 build/generated-src/jooq
경로 하위에 여러 코드들이 생성된 것을 확인 했다면 정상적인 동작과 기본 시작을 할 수있는 환경이 완료된 것이다.
이 작업을 통해서 생성된 코드는 DB스키마를 기초로 만들어진 코드로, 이 코드를 통해서 타입안정성과 함께 SQL 문을 코드 형식으로 작성 할 수 있게 되는 동작 원리의 기본사항이라고 생각하면 된다.
이제 본격적으로 Jooq를 통한 간단한 Join문을 구현 해 보기로 한다.
예시는 Team과 Member를 만들어 본다. Team에 여러 Member가 속할 수 있는 구조이다.
public class Team {
private Long id; // 식별자
private String name; // 팀 이름
private String description; // 팀 설명
}
public class Member {
private Long id; // 식별자
private Long teamId; // 소속된 팀의 식별자
private String name; // 멤버 이름
}
도메인을 정의 했다. 참고로 지금 단계에서 이 도메인코드는 사용하지 않을 것이다.
package onyx.demo.repository;
import onyx.demo.MemberListDTO;
import java.util.List;
public interface MemberRepository {
List<MemberListDTO> findMemberListByTeamName(String teamName);
}
멤버목록을 가져오는데, 팀 이름으로 검색해서 가져오는 메서드의 인터페이스를 정의했다.
package onyx.demo.repository;
import lombok.RequiredArgsConstructor;
import onyx.demo.MemberListDTO;
import org.jooq.DSLContext;
import org.jooq.impl.DSL;
import org.springframework.stereotype.Repository;
import java.util.List;
import static onyx.jooq.tables.Member.MEMBER;
import static onyx.jooq.tables.Team.TEAM;
@Repository
@RequiredArgsConstructor
public class MemberRepositoryJooqImpl implements MemberRepository {
private final DSLContext dsl;
@Override
public List<MemberListDTO> findMemberListByTeamName(String teamName) {
return dsl.select(
MEMBER.NAME,
MEMBER.ID,
DSL.count().over().as("total_member_count")
)
.from(MEMBER)
.join(TEAM)
.on(MEMBER.TEAM_ID.eq(TEAM.ID))
.where(TEAM.NAME.eq(teamName))
.fetchInto(MemberListDTO.class);
}
}
그리고 구현체에 드디어 Jooq를 통한 코드 기반의 쿼리를 작성했다. generateJooq를 통해서 생성된 코드가 드디어 사용이 된다. 테이블 그 자체의 정보를 가지고 있는 객체로 생각하면 되는데, static import 방식을 이용해서 처리하는 것이 코드가 좀 더 읽기가 쉬워진다. (그래서 이 코드는 import 코드를 생략하지 않았다)
문자열로 작성한 쿼리가 아니기 때문에 개발자의 실수를 줄여주고, 컴파일 단계에서 에러를 체크 해 주는 장점이 있다.
SQL의 문법을 알고 있다면 해석하는데 크게 어려운 코드는 아닐 것이다. 다만 count를 사용하기 위해서 org.jooq.impl.DSL를 따로 import해서 사용했다는 점과 여기서의 .as 뒤에 나오는 이름은 결과로 반환될 데이터의 alias 라는 것을 알면 된다. 반환되는 필드의 이름과 동일하게 맞춰야 하는데, 별도 설정 없이 기본값으로 카멜케이스와 스네이크케이스 간 변환을 처리하니 규칙에 맞게 사용하면 된다.
(일반적으로 DB에서는 스네이크케이스를, Java에서는 카멜케이스가 규칙이다)
반환되는 자료의 코드는 다음과 같이 선언 했다.
@Data
@AllArgsConstructor
public class MemberListDTO {
private String name;
private long memberId;
private int totalMemberCount; // DB에서의 반환컬럼명은 total_member_count
}
Jooq로 생성한 쿼리의 결과는 fetch()
를 사용하여 종료(또는 결과를 반환)를 나타낸다. 이 경우 반환 타입은 2가지의 선택을 할 수 있다. 구체적인 Record 타입과 일반 Record 타입이 있다.
구체적인 Record타입의 경우 generateJooq를 통해 생성된 타입으로, MemberRecord, TeamRecord와 같은 이름으로 사용 할 수 있고, 실제 DB를 정보를 가지고 만들어진 타입이므로 반환 데이터에서도 타입안정성을 보장 받을 수 있다. 다만 기존에 별도의 컬럼이 없는 데이터라면 가지고 올 수 없는 점이 있다.
이를 보안하기 위해서 만들어진 것은 일반 Record 타입으로, 데이터를 유연하게 받아서 처리가 가능하다. 다만 타입 안정성에 대한 보장이 없고, 코드의 직관성이 떨어지는 단점이 있다.
결국 데이터 조회의 경우는 가공된 별도의 데이터가 필요한 경우가 많아지기에, 별도의 반환객체를 만들어서 처리 하는 것이 더 나은 선택이라고 생각한다.
이때 fetchInto()
는 반환된 데이터를 전용 반환용 객체에 담을 때 쓰면 된다.
테스트를 위한 데이터를 넣기로 한다. 테스트 코드 단계에서 직접 Jooq를 통해서 데이터를 삽입 할 수도 있는데, Flyway에서 테스트 데이터도 관리하기로 해 본다.
resources/db/migration/V3__AddTestData.sql
에 파일을 만든다.
-- Insert Teams
INSERT INTO team (name, description) VALUES ('에스파', 'SM ENT Idol Group');
INSERT INTO team (name, description) VALUES ('르세라핌', 'Source Music Idol Group');
-- Insert Members for 에스파 (Assuming team_id = 1)
INSERT INTO member (name, team_id) VALUES ('카리나', 1);
INSERT INTO member (name, team_id) VALUES ('지젤', 1);
INSERT INTO member (name, team_id) VALUES ('윈터', 1);
INSERT INTO member (name, team_id) VALUES ('닝닝', 1);
-- Insert Members for 르세라핌 (Assuming team_id = 2)
INSERT INTO member (name, team_id) VALUES ('사쿠라', 2);
INSERT INTO member (name, team_id) VALUES ('김채원', 2);
INSERT INTO member (name, team_id) VALUES ('허윤진', 2);
INSERT INTO member (name, team_id) VALUES ('카즈하', 2);
INSERT INTO member (name, team_id) VALUES ('홍은채', 2);
추가로 테스트 코드를 작성하기 전에, 기본적인 레이어드 아키텍처 구조를 유지하기 위해 따로 서비스 레이어를 만들기로 한다. (여기서는 별도의 Impl은 구현하지 않음)
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public List<MemberListDTO> getAllMemberListByTeamName(String teamName) {
return memberRepository.findMemberListByTeamName(teamName);
}
}
import onyx.demo.MemberListDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Transactional
public class MemberServiceTest {
@Autowired
private MemberService memberService;
@Test
void getAllMemberListByTeamName_Aespa_ShouldReturnMembers() {
String teamName = "에스파";
List<MemberListDTO> members = memberService.getAllMemberListByTeamName(teamName);
assertThat(members).hasSize(4);
assertThat(members)
.extracting(MemberListDTO::getName)
.containsExactlyInAnyOrder("카리나", "지젤", "윈터", "닝닝");
for (MemberListDTO dto : members) {
assertThat(dto.getTotalMemberCount()).isEqualTo(4);
}
}
@Test
void getAllMemberListByTeamName_LeSSERAFIM_ShouldReturnMembers() {
String teamName = "르세라핌";
List<MemberListDTO> members = memberService.getAllMemberListByTeamName(teamName);
assertThat(members).hasSize(5);
assertThat(members)
.extracting(MemberListDTO::getName)
.containsExactlyInAnyOrder("사쿠라", "김채원", "허윤진", "카즈하", "홍은채");
for (MemberListDTO dto : members) {
assertThat(dto.getTotalMemberCount()).isEqualTo(5);
}
}
@Test
void getAllMemberListByTeamName_NonExistentTeam_ShouldReturnEmptyList() {
String teamName = "NonExistentTeam";
List<MemberListDTO> members = memberService.getAllMemberListByTeamName(teamName);
assertThat(members).isEmpty();
}
}
작성한 테스트 코드를 동작 해 보고, 정상적인 통과가 되었다면 성공이다. Querydsl과 동작 원리와 사용법 등은 크게 다른것 같지 않아서 쉽게 테스트과정을 만드는데 어려운 점은 없었다.
추가로 보통 이 테스트코드에 대한 믿음(?)이 조금 떨어지는 경우에, 직접 눈으로 확인하고 싶다면 다음과 같이 WebController를 구현 해 보도록 하자.
@RestController
@RequiredArgsConstructor
@RequestMapping("/teams")
public class TeamController {
private final MemberService memberService;
@GetMapping("/{teamName}/members")
public List<MemberListDTO> getMembersByTeamName(@PathVariable String teamName) {
return memberService.getAllMemberListByTeamName(teamName);
}
}
여태까지 MemberRepository와 Service를 구현 했는데, 왜 TeamController인가?
에 대해서는 예제 프로젝트지만, RestAPI를 구축한다고 생각 했을 때, 만약 MemberController의 경우라면 엔드 포인트의 이름이 중첩이 된다.
/members/teamName/members
와 같은 명칭이 될 수 밖에 없었기에 조금 생각을 해보니, /teams/teamName/members
구조가 더 자원을 잘 나타내는 구조라고 생각했다. 또 책임의 문제에 있어서는 Member의 목록을 가져오기 때문에 MemberService에서 구현되는 것이 맞다는 판단이 있었다.
하지만 이 API도 사용법 자체는 조금 어색하다고 생각한다. 보통의 경우라면 직접적인 검색은 쿼리스트링을 사용하고, 만약 내가 의도했던대로 엔드포인트를 사용 했다면 /teams/teamId/members
의 형식으로 팀 ID를 검색하는 것이 적절해 보인다.
아무튼 중요한 내용은 아니였고... 결과만 놓고 보면
이렇게 정상 결과를 확인 해 볼 수 있다.
이렇게 해서 가장 기본적인 Jooq 사용법의 정리는 끝이다. 기존에 Querydsl의 사용을 해봤다면 그리 큰 어려움 없이 구조를 이해 할 수 있을 것으로 본다.
사용입장에서는 SQL을 알고 있다면 그것을 결국 코드로 만드는 작업이므로, 이 쿼리 문장은 어떻게 구현할수 있는지에 대한 부분은 바로 메서드 명칭만 봐도 유추할 수 있고, 몇몇 특수 케이스만 검색해 보면 충분히 구현 할 수 있을 것으로 생각된다.
글 내용은 Spring Data JPA를 마지막에 추가했지만, 내가 실제로 겪은 것은 정반대의 경우이다. 원래 구현하고 있던 프로젝트에서는 이미 Spring Data JPA를 통해 쿼리메서드 등을 통해서 필요한 기능을 개발중이였고, 이것은 그대로 유지하면서도 Jooq를 사용하고 싶었다.(정확히는 당시 시점은 Querydsl이다.) 기존에 사용하고 있던 JPA는 그대로 유지하기를 원했기에 두 라이브러리를 공존해서 사용하기를 원했다. SpringBoot에서는 만약 어느 한쪽만을 사용할 것이라면 spring-boot-starter-data-jpa
spring-boot-starter-jooq
중 하나를 선택하면 설정 자체나 사용을 쉽게 지원받고 사용 할 수 있는 선택지가 있다.
다시 이번에는 JPA로도 접근하기 위해 JPA를 위한 JPAEntity를 만들기로 한다.
@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 식별자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private TeamJpaEntity team;
private String name; // 멤버 이름
}
@Entity
@Table(name = "team")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TeamJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 식별자
private String name; // 팀 이름
private String description; // 팀 설명
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<MemberJpaEntity> members;
}
별도의 JPAEntity를 만들었다. JPA코드의 단점은 관계 매핑에 대한 복잡도가 있는 것을 예제를 만들면서 느꼈다.
public interface MemberJpaRepository extends JpaRepository<MemberJpaEntity, Long> {
List<MemberJpaEntity> findByTeamName(String teamName);
}
@Repository
@RequiredArgsConstructor
@Qualifier("jpaMemberRepository")
public class MemberRepositoryJpaImpl implements MemberRepository {
private final MemberJpaRepository memberJpaRepository;
@Override
public List<MemberListDTO> findMemberListByTeamName(String teamName) {
List<MemberJpaEntity> members = memberJpaRepository.findByTeamName(teamName);
if (members.isEmpty()) {
return List.of();
}
int totalCount = members.size();
return members.stream()
.map(member -> new MemberListDTO(member.getName(), member.getId(), totalCount))
.toList();
}
}
Jooq와 비교하면 코드 자체가 직관성이 떨어져 가독성은 조금 떨어진다.
데이터를 한번에 구해 오지 못하고 별도의 count 계산이 따로 필요했다.
또 데이터의 불일치로 인해 MemberListDTO에 직접 매핑하고, 데이터를 가공하는 부분이 생겼다.
기존의 MeberRepository는 그대로 유지하면서 2번째 구현체를 구현한다. 내부에서 다시 또 SpringDataJPA를 사용하기 위해 MemberJpaRepository를 만들고 불러와서 사용하는 구조이다. 구현체가 2개이기 때문에 Spring이 자동으로 의존성을 주입할 때, 어떤 구현체를 사용해야 할지 직접적으로 알려줘야 할 필요가 있다.
여러 방법중 나는 @Qualifier
를 선언하고 Service에서 이것을 선택하는 방식을 채택하기로 했다.
기존 코드에 추가
@Qualifier("jooqMemberRepository")
public class MemberRepositoryJooqImpl implements MemberRepository {
// 구현코드 생략
}
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(@Qualifier("jpaMemberRepository") MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public List<MemberListDTO> getAllMemberListByTeamName(String teamName) {
return memberRepository.findMemberListByTeamName(teamName);
}
}
레포지토리에서 @Qualifier로 이름을 부여한다. 다시 서비스에서 @Qualifier로 어떤 이름을 선택할지 지정하면 된다. Lombok에서 생성자 주입을 사용하고 있을 때에는 @Qualifier가 제대로 동작하지 않아서, 직접 생성자 주입 코드를 사용했다.
결론적으로 Service에서 @Qualifier를 jooqMemberRepository
/ jpaMemberRepository
선택하는 것으로 Jooq / JPA의 구현체 사용의 전환이 가능하다.
기존의 테스트 코드는 그대로 사용하면 된다. 기존의 의도대로 JPA와 Jooq를 동시에 사용하는 것은 가능하다. 동일하게 JPA로 구현한 findMemberListByTeamName()
메서드는 그리 복잡한 로직이 없음에도, 코드의 가독성이 떨어지고, 구현이 복잡해질 가능성이 높다. 결국 SpringDataJPA의 한계를 극복하기 위해 Querydsl을 대신하여 Jooq를 사용한 결론은 JPA 환경에서는 Querydsl을 사용하자!이다. 아니면 아예 JPA는 배제하고 Jooq를 통해 모든 DB 동작을 처리하는 것도 선택지이다.
그 이유는 Jooq와 JPA가 공존하는 환경에서는 관리 포인트가 많아지는 단점이 생긴다. JPA는 JPAEntity객체가 필요하며, Jooq는 DB접근을 통한 스키마 관리가 필요하다. Querydsl을 사용하게 되면 JPAEntity를 기준으로 Querydsl에 필요한 스카마에 필요한 코드를 생성하기 때문에 관리 포인트가 특별히 더 늘어나지는 않는다.
또 2개의 기술이 공존하는 경우 결국 반환 타입에 대한 문제가 있다. 결국 예시 코드에서는 사용하지 않았지만 특정한 구현체에 의해서 반환타입이 달라지는 경우를 막기위해 최종 반환 타입은 Team, Member와 같은 순수한 도메인으로 사용할 필요가 있다. 이러한 일종의 빈번한 변환은 비용도 들고 (사실 비용은 미비하다) 보일러플레이트 코드가 필요한 단점이 생긴다.
처음의 라이브러리의 사후 관리문제로 Querydsl을 Jooq로 대체하고자 했던 생각은 쉽지 않음을 깨달았다. 2개를 조합해서 아주 간단한 로직은 여전히 SpringDataJPA를 사용하고, 복잡한 로직의 경우 Jooq를 사용하려는 의도는 나쁘진 않았지만 관리포인트의 증가가 예상된다.
다만 어느 한쪽이 무조건적으로 좋다고 할 수 없는 점으로는
- 단순한 CRUD 작업과 기본적인 관계 매핑이 주된 요구사항이라면 Spring Data JPA만으로도 충분히 효과적인 개발이 가능
- 개발 과정 중 동적 쿼리 작성과 타입 안전성이 중요하다면 Querydsl도 여전히 유효한 선택지. 한국 한정으로 Querydsl은 실무에서 여전히 사용중인 기업이 많은 것으로 알고 있으므로, 일반적인 환경이라면 이 선택이 맞을 것으로 판단한다
- Jooq의 경우 복잡한 쿼리와 타입 안정성이 중요한 요구사항이 있는 경우, 신규프로젝트를 대상으로 고려 해 볼만한 선택지로 생각된다.
의 개인적인 결론을 내려본다. 물론 현재의 시점과 나의 개인적인 감상일 뿐이다. 미래에는 Querydsl이 갑자기 다시 활발하게 관리되고 기능이 개선되는 등 이유로 Querydsl을 안쓰는 것이 더 이상할 수도 있고, 제 3의 다른 라이브러리의 등장으로 Querydsl, Jooq 모두 사용을 안하게 될 가능성도 있다. 다만 현재 시점에서 SpringBoot에서 Jooq를 지원하고 있기 때문에 Jooq가 좀 더 발전될 가능성과 함께, 자바 진영의 사실상 표준 기술처럼 자리 잡을 가능성이 좀 더 높다고 생각한다.
잘 봤습니다.
jOOQ에서는 Flyway + Testcontainer 사용하는것을 공식적인 예제로 제공하며, 이 방식을 추천하고 있는데요. H2에서 타 db와 compatible 한 모드를 지원하고 있는지는 몰랐네요 ㅎㅎ
이 예제를 보면 testcontainer + flyway 환경으로 jOOQ DSL을 생성 할 수 있어 이 방식도 공유드려봅니다.
https://github.com/SightStudio/jOOQ-inflearn/tree/main/4.2_jOOQ-codegen-with-testcontainers-and-flyway