저는 원래 이 기사를 러시아어로 썼습니다. 그러니 원어민이시라면 이 링크를 통해 읽어보실 수 있습니다.
지난 1년여 동안 JOOQ가 Hibernate에 대한 현대적이고 우수한 대안임을 시사하는 기사와 강연을 접했습니다. 인수에는 일반적으로 다음이 포함됩니다.
저는 JOOQ를 훌륭한 라이브러리(특히 Hibernate와 같은 프레임워크가 아닌 라이브러리)라고 생각한다는 점을 미리 말씀드립니다. 이는 작업에 탁월합니다. 정적으로 입력된 방식으로 SQL을 사용하여 컴파일 타임에 대부분의 오류를 포착합니다.
그러나 Hibernate의 시대는 지났고 이제 JOOQ를 사용하여 모든 것을 작성해야 한다는 주장을 들으면 관계형 데이터베이스의 시대는 끝났고 이제는 NoSQL만 사용해야 한다는 것처럼 들립니다. 재미있을 것 같나요? 그런데 얼마 전까지만 해도 이런 논의가 꽤 심각했습니다.
문제는 이 두 도구가 해결하는 핵심 문제에 대한 오해에 있다고 생각합니다. 이 글에서는 이러한 질문을 명확히 하고자 합니다. 우리는 다음을 탐구할 것입니다:
데이터베이스를 사용하는 가장 간단하고 직관적인 방법은 트랜잭션 스크립트 패턴입니다. 간단히 말해서 모든 비즈니스 로직을 단일 트랜잭션으로 결합된 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); } }
이 코드에서는:
여기에는 잠재적인 경쟁 조건이 있지만 단순화를 위해 이에 대해서는 다루지 않겠습니다.
이 접근 방식의 장점:
단점:
이 접근 방식은 서비스에 시간이 지나도 더 복잡해질 것으로 예상되지 않는 매우 간단한 로직이 있는 경우 유효하고 합리적입니다. 그러나 도메인은 더 큰 경우가 많습니다. 그러므로 대안이 필요합니다.
도메인 모델 패턴의 아이디어는 더 이상 비즈니스 로직을 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); }
도메인 모델의 장점:
간단히 말하면 장점이 많습니다. 그러나 한 가지 중요한 과제가 있습니다. 흥미롭게도 도메인 모델 패턴을 자주 홍보하는 도메인 기반 디자인(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 라이브러리를 사용하여 데이터베이스와 작동하는 별도의 구현을 작성합니다.
첫눈에(아마도 두 번째에도) 이 솔루션은 꽤 좋아 보일 수 있습니다. 다음을 고려하십시오:
다음과 같은 시나리오를 접할 수 있는 현실 세계에서는 상황이 훨씬 더 흥미로워집니다.
또한 비즈니스 로직과 도메인 개체가 발전함에 따라 매핑 코드를 유지 관리해야 합니다.
이러한 각 사항을 스스로 처리하려고 하면 결국에는 (놀랍게도!) Hibernate와 유사한 프레임워크를 작성하거나 훨씬 더 간단한 버전을 작성하게 될 것입니다.
JOOQ는 SQL 쿼리 작성 시 정적 타이핑 부족 문제를 해결합니다. 이는 컴파일 단계에서 오류 수를 줄이는 데 도움이 됩니다. 데이터베이스 스키마에서 직접 코드를 생성하면 스키마 업데이트 시 코드를 수정해야 하는 위치가 즉시 표시됩니다(컴파일되지 않음).
Hibernate는 도메인 개체를 관계형 데이터베이스에 매핑하거나 그 반대로 매핑하는 문제(데이터베이스에서 데이터를 읽고 이를 도메인 개체에 매핑하는 문제)를 해결합니다.
그러므로 Hibernate가 더 나쁘다거나 JOOQ가 더 낫다고 주장하는 것은 말이 되지 않습니다. 이러한 도구는 다양한 목적으로 설계되었습니다. 귀하의 애플리케이션이 트랜잭션 스크립트 패러다임을 기반으로 구축된 경우 JOOQ는 의심할 여지 없이 이상적인 선택입니다. 그러나 도메인 모델 패턴을 사용하고 최대 절전 모드를 피하려면 사용자 정의 저장소 구현에서 수동 매핑의 즐거움을 처리해야 합니다. 물론, 고용주가 또 다른 Hibernate 킬러를 구축하는 데 비용을 지불한다면 의문의 여지가 없습니다. 그러나 대부분의 사람들은 객체-데이터베이스 매핑을 위한 인프라 코드가 아닌 비즈니스 로직에 집중할 것을 기대합니다.
그런데 저는 Hibernate와 JOOQ의 조합이 CQRS에 잘 맞는다고 생각합니다. CREATE/UPDATE/DELETE 작업과 같은 명령을 실행하는 애플리케이션(또는 그 논리적 부분)이 있습니다. 이것이 Hibernate가 완벽하게 적합한 곳입니다. 반면에 데이터를 읽는 쿼리 서비스가 있습니다. 여기서 JOOQ는 훌륭합니다. Hibernate보다 복잡한 쿼리를 작성하고 최적화하는 것이 훨씬 쉽습니다.
사실이에요. JOOQ를 사용하면 데이터베이스에서 엔터티를 가져오기 위한 표준 쿼리가 포함된 DAO를 생성할 수 있습니다. 여러분의 메서드를 사용하여 이러한 DAO를 확장할 수도 있습니다. 또한 JOOQ는 Hibernate와 유사한 setter를 사용하여 채울 수 있고 DAO의 삽입 또는 업데이트 메서드에 전달될 수 있는 엔터티를 생성합니다. 스프링데이터 같지 않나요?
간단한 경우에는 실제로 작동할 수 있습니다. 그러나 저장소를 수동으로 구현하는 것과 크게 다르지 않습니다. 문제는 비슷합니다.
따라서 복잡한 도메인 모델을 구축하려면 수동으로 구축해야 합니다. Hibernate가 없으면 매핑에 대한 책임은 전적으로 당신에게 있습니다. 물론, JOOQ를 사용하는 것이 JDBI보다 더 즐겁지만 그 과정은 여전히 노동 집약적입니다.
JOOQ의 창시자인 Lukas Eder도 자신의 블로그에서 DAO가 반드시 사용을 권장하기 때문이 아니라 인기 있는 패턴이기 때문에 라이브러리에 추가되었다고 언급했습니다.
글을 읽어주셔서 감사합니다. 나는 Hibernate의 열렬한 팬이고 그것이 훌륭한 프레임워크라고 생각합니다. 하지만 일부 사람들은 JOOQ를 더 편리하게 생각할 수도 있다는 점을 이해합니다. 내 기사의 주요 요점은 Hibernate와 JOOQ가 라이벌이 아니라는 것입니다. 이러한 도구는 가치를 제공한다면 동일한 제품 내에서도 공존할 수 있습니다.
콘텐츠에 대한 의견이나 피드백이 있으면 기꺼이 논의해 드리겠습니다. 생산적인 하루 보내세요!
위 내용은 JOOQ는 Hibernate를 대체하지 않습니다. 그들은 다양한 문제를 해결합니다의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!