들어가기 전에
이번 회사에서 처음으로 jOOQ를 사용하게 됐다. MyBatis와 JPA만 사용했기 때문에 jOOQ를 어떤 이유에서 사용하는지 정확히 파악하기 위해 여러 글들을 봤다. 그래서 이번 글에서는 jOOQ가 무엇인지 그리고 평소에 잘 사용하던 JPA와 비교해 어떤 점이 다른지를 중점으로 다뤄볼 예정이다.
jOOQ
jOOQ는 Java Object Oriented Querying로 자바 코드를 이용해서 type-safe한 SQL 쿼리를 생성할 수 있게 해주는 데이터베이스 인터페이스를 말한다. 그렇다면 어떻게 type-safe한 쿼리를 작성할 수 있는 걸까?
아래 문구를 보면 알 수 있듯이 데이터베이스 접근을 통해 자바 코드를 생성해 주는데 개발자는 이 메타 모델을 통해서 type-safe한 쿼리를 작성할 수 있게 된다. 특히 대부분의 SQL 데이터베이스를 지원하기 때문에 데이터베이스의 종류에 구애받지 않는다는 장점이 있다.
생성할 수 있는 자바 코드로는 레코드, DAO, Stored procedure 등이 있다. 예를 들어, 하나의 Stored procedure 당 하나의 Java 클래스를 생성해 주기 때문에 CallableStatement 같은 인터페이스를 사용하지 않아도 이 클래스를 통해 편하게 사용할 수 있다. 아래의 두 코드는 CollableStatement 그리고 jOOQ로 프로시저를 활용하는 코드인데 jOOQ를 사용하는 두 번째 코드에서 이미 자바 클래스로 만들어진 프로시저 정보로 쉽게 작업을 하고 있는 것을 확인할 수 있을 것이다.
jOOQ's code generator takes your database schema and reverse-engineers it into a set of Java classes modelling
tables, records, sequences, POJOs, DAOs, stored procedures, user-defined types and many more.
jOOQ의 code generator는 데이터베이스 스키마를 통해 자바 클래스 모델링 테이블, 레코드, 시퀀스, POJO, DAO, Stored Procedures, 사용자 정의 유형 등으로 리버스 엔지니어링(기능 구현을 위해 내부 분석을 하는 행위)합니다.
출처 : https://www.jooq.org/
// 출처 : https://www.ibm.com/docs/ko/i/7.3?topic=callablestatements-example-creating-procedure-return-values
// CollableStatement 사용 예제
public class CallableStatementExample3 {
public static void main(java.lang.String[] args) {
try {
...
// Create a procedure with a return value.
String sql = "CREATE PROCEDURE MYLIBRARY.SQLSPEX3 " +
" LANGUAGE SQL SPECIFIC MYLIBRARY.SQLSPEX3 " +
" EX3: BEGIN " +
" RETURN 1976; " +
" END EX3 ";
...
// Prepare a callable statement used to run the procedure.
CallableStatement cs = c.prepareCall("? = CALL MYLIBRARY.SQLSPEX3");
// You still need to register the output parameter.
cs.registerOutParameter(1, Types.INTEGER);
// Run the procedure.
cs.executeUpdate();
cs.close(); // close the CallableStatement object
c.close(); // close the Connection object.
} catch (Exception e) {
...
}
}
}
// 출처 : https://www.jooq.org/doc/latest/manual/sql-execution/stored-procedures/
// jOOQ 사용 예제
// Make an explicit call to the generated procedure object:
AuthorExists procedure = new AuthorExists();
// All IN and IN OUT parameters generate setters
procedure.setAuthorName("Paulo");
procedure.execute(configuration);
jOOQ는 어떻게 동작하는가
jOOQ는 DSL과 jOOQ Generator로 이루어져있는데 이 Generator로 데이터베이스 정보를 읽어 Java 클래스들을 생성한다. 처음에 클래스가 생성되는 경로를 설정하면 아래 스크린샷처럼 그에 따라 tables 패키지가 생기고 그 하위에서 각각의 클래스들을 확인할 수 있다. 이 클래스들이 테이블에 대한 정보를 모두 가지고 있기 때문에 클래스를 활용하면 type-safe하게 쿼리를 생성할 수 있는 것이다. 참고로 데이터 타입이나 길이에 대한 정보 혹은 comment, nullable 여부 등도 클래스에서 확인 가능하다.
만약 Flyway와 같은 마이그레이션 툴에서 많이 사용되는 SQL script을 통해서 동일한 작업을 수행하고 싶다면 어떻게 해야 할까? 바로 jOOQ에서 제공해주는 DDLDatabse를 사용하면 된다. 하지만 DDLDatabase는 모든 데이터베이스의 문법을 지원하지 않기 때문에 Stored procedure나 특정 데이터 타입은 사용할 수가 없다는 한계점이 있었다.
그래서 jOOQ는 이 문제를 해결할 수 있는 방법에 대해 포스팅을 했는데 해결의 열쇠는 바로 Testcontainers였다. 테스트컨테이너에서는 사용할 JDBC driver를 명시할 수 있고 이 정보를 jOOQ에서 설정하면서 활용할 수 있기 때문이다. 테스트컨테이너 내부의 해당 DB 인스턴스에 마이그레이션이 적용되고 나면 위의 경우처럼 Java 클래스들이 생성된다. 해당 방법을 사용했을 때의 이점은 아래에 남겨두겠다.
The benefits of this approach are:
1. You can integration test your Flyway migration along with your jOOQ code generation and your actual test code.
2. You can run the code generation against your production database product, rather than simulating the database product with e.g. H2.
3. This allows for using all vendor specific features available.All of these steps are automated and can be used on local dev machines as well as in CI environments.
출처 : https://github.com/jOOQ/jOOQ/tree/main/jOOQ-examples/jOOQ-testcontainers-flyway-example
jOOQ는 어떻게 사용하는가
jOOQ의 인터페이스에 접근하기 위해서는 DSLContext를 생성해야 한다.
DSLContext 인터페이스를 통해 다양한 API들을 사용할 수 있는데 Spring boot를 사용하면 자동으로 스프링 빈으로 설정되기 때문에 아래와 같이 @Autowired 어노테이션을 적용해서 사용할 수 있다.
// https://docs.spring.io/spring-boot/docs/1.3.5.RELEASE/reference/html/boot-features-jooq.html
@Component
public class JooqExample {
private final DSLContext dsl;
@Autowired
public JooqExample(DSLContext dslContext) {
this.dsl = dslContext;
}
}
예제 코드
다음으로 테스트 코드를 통해 jOOQ를 어떻게 사용하는지 간단하게 살펴볼 것이다. jOOQ 기반 테스트 시에는 '@JooQTest' 어노테이션을 활용하면 되는데 기존 Datasource를 활용해서 테스트가 가능하다. 만약 인메모리 데이터베이스를 활용하고 싶을 때는 따로 설정하도록 하자.
1. 단건 조회
queryDSL를 사용해 봤다면 굉장히 익숙한 코드일 것이다. select와 from절의 대상이 동일할 때 jOOQ에서도 마찬가지로 selectFrom()을 활용하면 간단하게 조회할 수 있다. 물론 select()와 from()을 따로 사용해도 되며 결과 값을 매핑하기 위해 fetch()를 사용한다.
@JooQTest
public class QueryTest {
@Autowired
private DSLContext dsl;
@Test
void find_competitions() {
Result<CompetitonRecord> competitions = dsl.selectFrom(COMPETITON).fetch();
assertThat(competitions).hasSize(1);
}
}
2. 목록 조회
목록을 조회할 때 사용된 fetchInto()는 내부에 객체를 지정해줘야 한다. 만약 여기서 fetchInto()가 아니라 단일 조회 시에 사용했던 fetch()를 사용한다면 어떻게 될까? 여기서는 반환 객체가 AthleteDTO로 정해져있기 때문에 type-safe하게 바로 컴파일 에러가 발생한다. fetch()를 사용하고자 할 때는 Record를 활용해서 조회할 컬럼의 타입을 지정해 주면 되는데 22개까지 명시하고 조회할 수 있다. 실제로 서브 쿼리를 작성하기 위해 7개 정도 명시해서 사용해봤는데 22개까지 사용한다면 가독성을 해칠 것 같다는 생각을 했다.
@Test
void projection() {
List<AthleteDTO> athletes = dsl
.select(ATHLETE.FIRST_NAME, ATHLETE.LAST_NAME, CLUB.NAME)
.from(ATHLETE)
.join(CLUB).on(CLUB.ID.eq(ATHLETE.CLUB_ID))
.fetchInto(AtheleteDTO.class);
assertThat(athletes).hasSize(1);
assertThat(athletes.get(0)).satisfies(athlete -> {
assertThat(athlete.firstName)).isEqualTo("Armand");
assertThat(athlete.lastName()).isEqualTo("Duplantis");
assertThat(athlete.clubName()).isEqualTo("Louisiana Stat");
}))
}
// 출처 : https://stackoverflow.com/questions/48244208/jooq-use-record7-to-encapsulate-fields-from-different-tables
org.joog.Result<Record7<String,String,Double,Double,String,String,String>> result =
db().select(
PLACE.NAME.as("placeName"),
PLACE.TYPE.as("placeType"),
PLACE.LAT.as("lat"),
PLACE.LON.as("lon"),
EVENT.NAME.as("eventName"),
DSL.field("earth_distance(ll_to_earth(" + wrapper.lat() + "," + wrapper.lon() + "),ll_to_earth(place.lat, place.lon))* 0.000621371192").as("distanceToReach"),
DSL.field(EVENT.TYPE).as("eventType"))
.from(PLACE)
.leftJoin(EVENT)
.on(PLACE.ID.eq(EVENT.LOCATION_ID))
.where(PLACE.ID.eq(1))
.fetch();
참고로 위처럼 언제나 read-only일 때는 불변성이 유지되는 record 클래스를 쓰면 좋다. 우리 회사에서도 DTO 클래스들에 record를 사용하고 있다. 불변성이 유지된다는 것 외에도 코드 구성이 굉장히 짧고 간결하면서 내부 데이터에 직접적으로 접근할 수 있기 때문에 데이터를 저장하거나 읽기 쉽다는 장점이 있어 애용하는 중이다.
public record AthleteDTO(String firstName, String lastName, String clubName) {}
3. 생성
컬럼을 명시해 준 후에 생성하려는 값을 지정해 주면 데이터가 생성된다. 필요하다면 returningResult() 메소드를 사용해 생성된 ID 값을 가져올 수 있다.
@Test
void insert_athlete() {
Long id = dsl.insertInto(ATHLETE)
.columns(
ATHLETE.FIRST_NAME,
ATHLETE.LAST_NAME,
ATHLETE.GENDER,
ATHLETE.YEAR_OF_BIRTH,
ATHLETE.CLUB_ID,
ATHLETE.ORGANIZATION_ID)
.values("Sanya", "Richards-Ross", "f", 1985, 1L, 1L)
.returningResult(ATHLETE.ID)
.fetchOneInto(Long.class);
assertThat(athlete.getId()).isNotNull();
}
4. 수정
store() 메소드를 사용하면 새로운 데이터일 때 insert(), 아니라면 update()를 해줄 수 있다. 물론 그냥 update()를 사용해도 된다.
@Test
void update_record() {
AthleteRecord athlete = dsl.newRecord(ATHLETE);
athlete.setFirstName("Mujinga");
athlete.setLastName("Kambundji");
athlete.setGender("f");
athlete.setYearOfBirth(1992);
athlete.setClubId(1L);
athlete.setOrganizationId(1L);
// update or insert
athlete.store();
assertThat(athlete.getId()).isNotNull();
}
5. 삭제
@Test
void delete() {
int deleteRows = dsl
.deleteFrom(ATHLETE)
.where(ATHELTE.ID.eq(1000L))
.execute();
assertThat(deleteRows).isEqualTo(1);
}
JPA와의 비교
우선 JPA를 사용하면 반복해서 작성해야 하는 쿼리 관련 코드들을 줄일 수 있어 개발자는 편하게 원하는 데이터를 다룰 수 있다. 객체 지향 프로그래밍에 맞게 객체들을 적재적소에 활용할 수 있으니 패러다임 불일치를 해결한다는 이야기 또한 경험할 수 있었다. 그렇지만 MyBatis에서 작성하던 복잡한 쿼리를 JPA로 구현하려다 보면 원래의 깔끔한 코드와는 다르게 가독성이 떨어지게 되고 결국에는 JPQL, Querydsl을 공부해서 쿼리를 작성하게 된다. 어떤 개발자분의 글을 보니 이것을 "우연한 복잡성"이라 하더라. 학습 자체가 나쁘다는 의미가 아니라 결국 SQL 하나를 편하게 다루기 위해 기술을 하나 둘씩 더 익혀야 하는 아이러니함을 표현한 것이다.
만약 JPA의 특징을 제대로 활용할 수 없는 경우에는 어떨까? JPA에는 영속성 컨텍스트를 통해 dirty checking 등과 같이 데이터의 변화를 감지하고 업데이트 시켜준다는 특징이 있다. 아래의 경우에서 이 특징이 어떤 이점을 줄 수 있을까?
- 데이터베이스에 있는 데이터를 보여주기만 하고 싶을 때 혹은 동적 페이징을 사용하는 경우
- 단순히 데이터를 삭제만 하고 싶은 경우
굳이 데이터의 변화를 감지할 필요없이 단순히 데이터베이스에 접근해서 쿼리문을 실행하면 되지 않을까? 결과적으로 이 비교들을 살펴보면 Hibernate와 jOOQ를 놓고 우열을 가리는 것이 아니라 지금 하려는 작업에서 필요한 것이 무엇인지를 잘 파악해야 한다는 이야기이다. 무엇을 선택하든 각 기술의 특징을 잘 이해하고 활용해서 최적의 효과를 낼 수 있으면 된다. 거기다 Hibernate와 jOOQ를 같이 사용하는 경우도 있으니 상황에 맞춰 잘 고려해 볼 수 있겠다.
정리
- jOOQ는 데이터베이스를 기반으로 Java 클래스를 생성하여 type-safe한 쿼리를 작성할 수 있게 해준다.
- jOOQ는 어느 데이터베이스를 사용하더라도 문법을 맞춰주기 때문에 데이터베이스의 종류에 의존적이지 않다.
- JPA처럼 Mapping(entity -> dto로 변환 시)하지 않고 바로 DTO에 맞춰 사용할 수 있다.
- 함수나 프로시저를 간편하게 호출할 수 있기 때문에 대용량의 데이터를 쉽게 수정하거나 삭제할 수 있다.
참고
'DB' 카테고리의 다른 글
[PostgreSQL] Docker를 활용한 Streaming Replication 설정 가이드 (2) | 2024.10.31 |
---|---|
[PostgreSQL] Chapter 26. High Availability, Load Balancing, and Replication (0) | 2024.10.14 |
[MyBatis] selectKey로 다중 컬럼 값 가져오기 (0) | 2023.05.30 |
[MyBatis] BindingException 해결 (feat. IntelliJ Gradle 설정) (1) | 2023.05.29 |
[MySQL] com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure (0) | 2023.03.31 |