>Java >java지도 시간 >JOOQ는 Hibernate를 대체하지 않습니다. 그들은 다양한 문제를 해결합니다

JOOQ는 Hibernate를 대체하지 않습니다. 그들은 다양한 문제를 해결합니다

DDD
DDD원래의
2025-01-11 20:10:41472검색

저는 원래 이 기사를 러시아어로 썼습니다. 그러니 원어민이시라면 이 링크를 통해 읽어보실 수 있습니다.

지난 1년여 동안 JOOQ가 Hibernate에 대한 현대적이고 우수한 대안임을 시사하는 기사와 강연을 접했습니다. 인수에는 일반적으로 다음이 포함됩니다.

  1. JOOQ를 사용하면 컴파일 타임에 모든 것을 확인할 수 있지만 Hibernate는 그렇지 않습니다!
  2. Hibernate는 항상 최적이 아닌 이상한 쿼리를 생성하지만 JOOQ를 사용하면 모든 것이 투명해집니다!
  3. Hibernate 엔터티는 변경 가능하며 이는 좋지 않습니다. JOOQ에서는 모든 엔터티를 불변으로 만들 수 있습니다(안녕하세요, 함수형 프로그래밍)!
  4. JOOQ는 주석에 어떤 "마법"도 사용하지 않습니다!

저는 JOOQ를 훌륭한 라이브러리(특히 Hibernate와 같은 프레임워크가 아닌 라이브러리)라고 생각한다는 점을 미리 말씀드립니다. 이는 작업에 탁월합니다. 정적으로 입력된 방식으로 SQL을 사용하여 컴파일 타임에 대부분의 오류를 포착합니다.

그러나 Hibernate의 시대는 지났고 이제 JOOQ를 사용하여 모든 것을 작성해야 한다는 주장을 들으면 관계형 데이터베이스의 시대는 끝났고 이제는 NoSQL만 사용해야 한다는 것처럼 들립니다. 재미있을 것 같나요? 그런데 얼마 전까지만 해도 이런 논의가 꽤 심각했습니다.

문제는 이 두 도구가 해결하는 핵심 문제에 대한 오해에 있다고 생각합니다. 이 글에서는 이러한 질문을 명확히 하고자 합니다. 우리는 다음을 탐구할 것입니다:

  1. 트랜잭션 스크립트란 무엇인가요?
  2. 도메인 모델 패턴이란 무엇인가요?
  3. Hibernate와 JOOQ는 구체적으로 어떤 문제를 해결하나요?
  4. 왜 하나는 다른 하나를 대체하지 못하고, 어떻게 공존할 수 있나요?

JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems

거래 스크립트

데이터베이스를 사용하는 가장 간단하고 직관적인 방법은 트랜잭션 스크립트 패턴입니다. 간단히 말해서 모든 비즈니스 로직을 단일 트랜잭션으로 결합된 SQL 명령 집합으로 구성합니다. 일반적으로 클래스의 각 메소드는 비즈니스 작업을 나타내며 하나의 트랜잭션으로 제한됩니다.

발표자가 자신의 강연을 컨퍼런스에 제출할 수 있는 애플리케이션을 개발한다고 가정해 보겠습니다(단순화를 위해 강연 제목만 녹음하겠습니다). 트랜잭션 스크립트 패턴에 따라 강연을 제출하는 방법은 다음과 같습니다(SQL용 JDBI 사용).

@Service
@RequiredArgsConstructor
public class TalkService {
    private final Jdbi jdbi;

    public TalkSubmittedResult submitTalk(Long speakerId, String title) {
        var talkId = jdbi.inTransaction(handle -> {
            // Count the number of accepted talks by the speaker
            var acceptedTalksCount =
                handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'")
                    .bind("id", speakerId)
                    .mapTo(Long.class)
                    .one();
            // Check if the speaker is experienced
            var experienced = acceptedTalksCount >= 10;
            // Determine the maximum allowable number of submitted talks
            var maxSubmittedTalksCount = experienced ? 5 : 3;
            var submittedTalksCount =
                handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'")
                    .bind("id", speakerId)
                    .mapTo(Long.class)
                    .one();
            // If the maximum number of submitted talks is exceeded, throw an exception
            if (submittedTalksCount >= maxSubmittedTalksCount) {
                throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount);
            }
            return handle.createUpdate(
                    "INSERT INTO talk (speaker_id, status, title) " +
                    "VALUES (:id, 'SUBMITTED', :title)"
                ).bind("id", speakerId)
                   .bind("title", title)
                   .executeAndReturnGeneratedKeys("id")
                   .mapTo(Long.class)
                   .one();
        });
        return new TalkSubmittedResult(talkId);
    }
}

이 코드에서는:

  1. 발표자가 이미 제출한 강연 수를 계산합니다.
  2. 최대 제출 가능 개수를 초과했는지 확인합니다.
  3. 모든 것이 정상이면 SUBMITTED 상태로 새 토크를 만듭니다.

여기에는 잠재적인 경쟁 조건이 있지만 단순화를 위해 이에 대해서는 다루지 않겠습니다.

이 접근 방식의 장점:

  1. 실행되는 SQL은 간단하고 예측 가능합니다. 필요한 경우 성능 향상을 위해 쉽게 조정할 수 있습니다.
  2. 데이터베이스에서 필요한 데이터만 가져옵니다.
  3. JOOQ를 사용하면 이 코드를 정적 타이핑으로 더욱 간단하고 간결하게 작성할 수 있습니다!

단점:

  1. 단위 테스트만으로는 비즈니스 로직을 테스트하는 것이 불가능합니다. 통합 테스트(그리고 그 중 상당수)가 필요합니다.
  2. 도메인이 복잡한 경우 이 접근 방식은 스파게티 코드로 빠르게 이어질 수 있습니다.
  3. 코드 중복의 위험이 있으며, 이는 시스템이 발전함에 따라 예상치 못한 버그로 이어질 수 있습니다.

이 접근 방식은 서비스에 시간이 지나도 더 복잡해질 것으로 예상되지 않는 매우 간단한 로직이 있는 경우 유효하고 합리적입니다. 그러나 도메인은 더 큰 경우가 많습니다. 그러므로 대안이 필요합니다.

도메인 모델

도메인 모델 패턴의 아이디어는 더 이상 비즈니스 로직을 SQL 명령에 직접 연결하지 않는다는 것입니다. 대신 동작을 설명하고 도메인 엔터티에 대한 데이터를 저장하는 도메인 객체(Java, 클래스의 맥락에서)를 생성합니다.

이 기사에서는 빈혈 모델과 부유한 모델의 차이점에 대해 논의하지 않습니다. 관심이 있으시면 해당 주제에 대해 자세히 글을 작성해 두었습니다.

비즈니스 시나리오(서비스)는 이러한 개체만 사용해야 하며 특정 데이터베이스 쿼리에 얽매이지 않아야 합니다.

물론 실제로는 성능 요구 사항을 충족하기 위해 도메인 개체와 직접적인 데이터베이스 쿼리와의 상호 작용이 혼합되어 있을 수 있습니다. 여기서는 캡슐화와 격리를 위반하지 않는 도메인 모델 구현에 대한 고전적인 접근 방식에 대해 논의하고 있습니다.

예를 들어 앞에서 언급한 것처럼 Speaker 및 Talk 엔터티에 대해 이야기하는 경우 도메인 개체는 다음과 같습니다.

@Service
@RequiredArgsConstructor
public class TalkService {
    private final Jdbi jdbi;

    public TalkSubmittedResult submitTalk(Long speakerId, String title) {
        var talkId = jdbi.inTransaction(handle -> {
            // Count the number of accepted talks by the speaker
            var acceptedTalksCount =
                handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'")
                    .bind("id", speakerId)
                    .mapTo(Long.class)
                    .one();
            // Check if the speaker is experienced
            var experienced = acceptedTalksCount >= 10;
            // Determine the maximum allowable number of submitted talks
            var maxSubmittedTalksCount = experienced ? 5 : 3;
            var submittedTalksCount =
                handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'")
                    .bind("id", speakerId)
                    .mapTo(Long.class)
                    .one();
            // If the maximum number of submitted talks is exceeded, throw an exception
            if (submittedTalksCount >= maxSubmittedTalksCount) {
                throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount);
            }
            return handle.createUpdate(
                    "INSERT INTO talk (speaker_id, status, title) " +
                    "VALUES (:id, 'SUBMITTED', :title)"
                ).bind("id", speakerId)
                   .bind("title", title)
                   .executeAndReturnGeneratedKeys("id")
                   .mapTo(Long.class)
                   .one();
        });
        return new TalkSubmittedResult(talkId);
    }
}

여기서 Speaker 클래스에는 강연 제출을 위한 비즈니스 로직이 포함되어 있습니다. 데이터베이스 상호 작용이 추상화되어 도메인 모델이 비즈니스 규칙에 집중할 수 있습니다.

이 저장소 인터페이스를 가정하면:

@AllArgsConstructor
public class Speaker {
    private Long id;
    private String firstName;
    private String lastName;
    private List<Talk> talks;

    public Talk submitTalk(String title) {
        boolean experienced = countTalksByStatus(Status.ACCEPTED) >= 10;
        int maxSubmittedTalksCount = experienced ? 3 : 5;
        if (countTalksByStatus(Status.SUBMITTED) >= maxSubmittedTalksCount) {
            throw new CannotSubmitTalkException(
              "Submitted talks count is maximum: " + maxSubmittedTalksCount);
        }
        Talk talk = Talk.newTalk(this, Status.SUBMITTED, title);
        talks.add(talk);
        return talk;
    }

    private long countTalksByStatus(Talk.Status status) {
        return talks.stream().filter(t -> t.getStatus().equals(status)).count();
    }
}

@AllArgsConstructor
public class Talk {
    private Long id;
    private Speaker speaker;
    private Status status;
    private String title;
    private int talkNumber;

    void setStatus(Function<Status, Status> fnStatus) {
        this.status = fnStatus.apply(this.status);
    }

    public enum Status {
        SUBMITTED, ACCEPTED, REJECTED
    }
}

그러면 다음과 같이 SpeakerService를 구현할 수 있습니다.

public interface SpeakerRepository {
    Speaker findById(Long id);
    void save(Speaker speaker);
}

도메인 모델의 장점:

  1. 도메인 개체는 구현 세부 정보(예: 데이터베이스)와 완전히 분리됩니다. 이를 통해 일반 단위 테스트를 통해 쉽게 테스트할 수 있습니다.
  2. 비즈니스 로직은 도메인 개체 내에 중앙 집중화되어 있습니다. 이는 트랜잭션 스크립트 접근 방식과 달리 애플리케이션 전체에 논리가 확산되는 위험을 크게 줄여줍니다.
  3. 원하는 경우 도메인 개체를 완전히 불변으로 만들 수 있으므로 작업 시 안전성이 향상됩니다(우발적인 수정에 대한 걱정 없이 어떤 메서드에든 전달할 수 있음).
  4. 도메인 개체의 필드를 값 개체로 대체할 수 있습니다. 이렇게 하면 가독성이 향상될 뿐만 아니라 할당 시 필드의 유효성이 보장됩니다(잘못된 콘텐츠가 포함된 값 개체를 생성할 수 없음).

간단히 말하면 장점이 많습니다. 그러나 한 가지 중요한 과제가 있습니다. 흥미롭게도 도메인 모델 패턴을 자주 홍보하는 도메인 기반 디자인(Domain-Driven Design)에 관한 책에서는 이 문제가 전혀 언급되지 않거나 간략하게만 다루어집니다.

문제는 도메인 개체를 데이터베이스에 저장한 다음 다시 읽는 방법입니다. 즉, 저장소를 어떻게 구현하나요?

요즘 답은 뻔하다. Hibernate(또는 더 나은 Spring Data JPA)를 사용하여 문제를 해결하세요. 하지만 우리가 ORM 프레임워크가 발명되지 않은 세상에 있다고 상상해 봅시다. 이 문제를 어떻게 해결할까요?

수동 매핑

SpeakerRepository를 구현하기 위해 JDBI도 사용합니다.

@Service
@RequiredArgsConstructor
public class TalkService {
    private final Jdbi jdbi;

    public TalkSubmittedResult submitTalk(Long speakerId, String title) {
        var talkId = jdbi.inTransaction(handle -> {
            // Count the number of accepted talks by the speaker
            var acceptedTalksCount =
                handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'")
                    .bind("id", speakerId)
                    .mapTo(Long.class)
                    .one();
            // Check if the speaker is experienced
            var experienced = acceptedTalksCount >= 10;
            // Determine the maximum allowable number of submitted talks
            var maxSubmittedTalksCount = experienced ? 5 : 3;
            var submittedTalksCount =
                handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'")
                    .bind("id", speakerId)
                    .mapTo(Long.class)
                    .one();
            // If the maximum number of submitted talks is exceeded, throw an exception
            if (submittedTalksCount >= maxSubmittedTalksCount) {
                throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount);
            }
            return handle.createUpdate(
                    "INSERT INTO talk (speaker_id, status, title) " +
                    "VALUES (:id, 'SUBMITTED', :title)"
                ).bind("id", speakerId)
                   .bind("title", title)
                   .executeAndReturnGeneratedKeys("id")
                   .mapTo(Long.class)
                   .one();
        });
        return new TalkSubmittedResult(talkId);
    }
}

접근 방법은 간단합니다. 각 저장소에 대해 JOOQ 또는 JDBI와 같은 SQL 라이브러리를 사용하여 데이터베이스와 작동하는 별도의 구현을 작성합니다.

첫눈에(아마도 두 번째에도) 이 솔루션은 꽤 좋아 보일 수 있습니다. 다음을 고려하십시오:

  1. 트랜잭션 스크립트 접근 방식과 마찬가지로 코드는 매우 투명하게 유지됩니다.
  2. 통합 테스트를 통해서만 비즈니스 로직을 테스트하는 데 더 이상 문제가 없습니다. 이는 저장소 구현(및 일부 E2E 시나리오)에만 필요합니다.
  3. 매핑 코드가 바로 눈앞에 있습니다. Hibernate 마법은 포함되지 않습니다. 버그를 발견하셨나요? 알맞은 줄을 찾아서 수정해주세요.

최대 절전 모드의 필요성

다음과 같은 시나리오를 접할 수 있는 현실 세계에서는 상황이 훨씬 더 흥미로워집니다.

  1. 도메인 객체는 상속을 지원해야 할 수도 있습니다.
  2. 필드 그룹을 별도의 값 개체로 결합할 수 있습니다(JPA/Hibernate에 포함됨).
  3. 성능 향상(지연 로딩)을 위해 일부 필드는 도메인 개체를 가져올 때마다 로드되지 않고 액세스할 때만 로드하면 됩니다.
  4. 객체 간에 복잡한 관계가 있을 수 있습니다(일대다, 다대다 등).
  5. 다른 필드는 거의 변경되지 않고 네트워크(DynamicUpdate 주석)를 통해 전송할 필요가 없기 때문에 UPDATE 문에 변경된 필드만 포함해야 합니다.

또한 비즈니스 로직과 도메인 개체가 발전함에 따라 매핑 코드를 유지 관리해야 합니다.

이러한 각 사항을 스스로 처리하려고 하면 결국에는 (놀랍게도!) Hibernate와 유사한 프레임워크를 작성하거나 훨씬 더 간단한 버전을 작성하게 될 것입니다.

JOOQ와 Hibernate의 목표

JOOQ는 SQL 쿼리 작성 시 정적 타이핑 부족 문제를 해결합니다. 이는 컴파일 단계에서 오류 수를 줄이는 데 도움이 됩니다. 데이터베이스 스키마에서 직접 코드를 생성하면 스키마 업데이트 시 코드를 수정해야 하는 위치가 즉시 표시됩니다(컴파일되지 않음).

Hibernate는 도메인 개체를 관계형 데이터베이스에 매핑하거나 그 반대로 매핑하는 문제(데이터베이스에서 데이터를 읽고 이를 도메인 개체에 매핑하는 문제)를 해결합니다.

그러므로 Hibernate가 더 나쁘다거나 JOOQ가 더 낫다고 주장하는 것은 말이 되지 않습니다. 이러한 도구는 다양한 목적으로 설계되었습니다. 귀하의 애플리케이션이 트랜잭션 스크립트 패러다임을 기반으로 구축된 경우 JOOQ는 의심할 여지 없이 이상적인 선택입니다. 그러나 도메인 모델 패턴을 사용하고 최대 절전 모드를 피하려면 사용자 정의 저장소 구현에서 수동 매핑의 즐거움을 처리해야 합니다. 물론, 고용주가 또 다른 Hibernate 킬러를 구축하는 데 비용을 지불한다면 의문의 여지가 없습니다. 그러나 대부분의 사람들은 객체-데이터베이스 매핑을 위한 인프라 코드가 아닌 비즈니스 로직에 집중할 것을 기대합니다.

그런데 저는 Hibernate와 JOOQ의 조합이 CQRS에 잘 맞는다고 생각합니다. CREATE/UPDATE/DELETE 작업과 같은 명령을 실행하는 애플리케이션(또는 그 논리적 부분)이 있습니다. 이것이 Hibernate가 완벽하게 적합한 곳입니다. 반면에 데이터를 읽는 쿼리 서비스가 있습니다. 여기서 JOOQ는 훌륭합니다. Hibernate보다 복잡한 쿼리를 작성하고 최적화하는 것이 훨씬 쉽습니다.

JOOQ의 DAO는 어떻습니까?

사실이에요. JOOQ를 사용하면 데이터베이스에서 엔터티를 가져오기 위한 표준 쿼리가 포함된 DAO를 생성할 수 있습니다. 여러분의 메서드를 사용하여 이러한 DAO를 확장할 수도 있습니다. 또한 JOOQ는 Hibernate와 유사한 setter를 사용하여 채울 수 있고 DAO의 삽입 또는 업데이트 메서드에 전달될 수 있는 엔터티를 생성합니다. 스프링데이터 같지 않나요?

간단한 경우에는 실제로 작동할 수 있습니다. 그러나 저장소를 수동으로 구현하는 것과 크게 다르지 않습니다. 문제는 비슷합니다.

  1. 엔티티에는 어떤 관계도 없습니다. ManyToOne, OneToMany가 없습니다. 데이터베이스 열만 있으면 비즈니스 논리 작성이 훨씬 더 어려워집니다.
  2. 엔티티는 개별적으로 생성됩니다. 상속 계층 구조로 구성할 수 없습니다.
  3. 엔터티가 DAO와 함께 생성된다는 사실은 원하는 대로 수정할 수 없다는 것을 의미합니다. 예를 들어, 필드를 값 개체로 바꾸거나, 다른 엔터티에 관계를 추가하거나, 필드를 Embeddable로 그룹화하는 것은 엔터티를 재생성하면 변경 사항을 덮어쓰게 되므로 불가능합니다. 예, 엔터티를 약간 다르게 생성하도록 생성기를 구성할 수 있지만 사용자 정의 옵션은 제한되어 있습니다(코드를 직접 작성하는 것만큼 편리하지도 않습니다).

따라서 복잡한 도메인 모델을 구축하려면 수동으로 구축해야 합니다. Hibernate가 없으면 매핑에 대한 책임은 전적으로 당신에게 있습니다. 물론, JOOQ를 사용하는 것이 JDBI보다 더 즐겁지만 그 과정은 여전히 ​​노동 집약적입니다.

JOOQ의 창시자인 Lukas Eder도 자신의 블로그에서 DAO가 반드시 사용을 권장하기 때문이 아니라 인기 있는 패턴이기 때문에 라이브러리에 추가되었다고 언급했습니다.

결론

글을 읽어주셔서 감사합니다. 나는 Hibernate의 열렬한 팬이고 그것이 훌륭한 프레임워크라고 생각합니다. 하지만 일부 사람들은 JOOQ를 더 편리하게 생각할 수도 있다는 점을 이해합니다. 내 기사의 주요 요점은 Hibernate와 JOOQ가 라이벌이 아니라는 것입니다. 이러한 도구는 가치를 제공한다면 동일한 제품 내에서도 공존할 수 있습니다.

콘텐츠에 대한 의견이나 피드백이 있으면 기꺼이 논의해 드리겠습니다. 생산적인 하루 보내세요!

자원

  1. JDBI
  2. 거래 스크립트
  3. 도메인 모델
  4. 내 기사 – Spring Boot 및 Hibernate를 사용한 풍부한 도메인 모델
  5. 저장소 패턴
  6. 값 개체
  7. JPA 임베디드
  8. JPA 동적 업데이트
  9. CQRS
  10. Lukas Eder: DAO로 갈 것인가, 말 것인가

위 내용은 JOOQ는 Hibernate를 대체하지 않습니다. 그들은 다양한 문제를 해결합니다의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.