Introduction
프레임워크는 마법처럼 보입니다. "SQL 쿼리를 더 이상 작성할 필요가 없어요!"라는 달콤한 약속과 함께 말이죠. 15년 전, 이 글의 저자가 처음 JPA를 접했을 때도 그랬다고 합니다. 그리고 지금, GitHub에는 수천 개의 프로젝트가 JPA/Hibernate를 사용하고 있죠.
하지만 이 '마법'의 대가는 생각보다 훨씬 컸습니다.
406페이지에 달하는 공식 문서. 도메인 모델을 왜곡하는 기술적 제약사항들. 추적하기 힘든 성능 이슈들. 그리고 가장 큰 문제는... 우리가 이 모든 것을 '당연하다'고 받아들이고 있다는 점입니다.
이것이 저자가 15년간의 엔터프라이즈 개발 경험을 통해 도달한 결론입니다. JPA/Hibernate가 해결하겠다던 복잡성은 사실 다른 형태의, 더 깊은 복잡성으로 우리에게 돌아왔기 때문입니다.
오늘 번역한 이 글 <Stop using JPA/Hibernate>에서는 JPA/Hibernate의 숨겨진 문제점들을 파헤치고, 실제 프로젝트에서 이를 점진적으로 제거하는 방법을 공유하고자 합니다. 당신의 도메인 모델이 기술적 제약에서 벗어나 본연의 가치를 되찾을 수 있도록 말이죠.
TECH | JPA/Hibernate를 더 이상 사용하지 말아야 하는 이유
들어가며 🐈
15년 전, 제가 석사 과정을 밟고 있을 때 한 교수님께서 새로운 기술을 소개해주셨습니다. 교수님의 말씀을 그대로 인용하자면:
이것이 제가 처음 접한, 당시 급부상하던 프레임워크에 대한 소개였습니다.
SQL 쿼리 작성이라는 복잡한 작업을 제거하고, 스크립트 언어를 객체 지향 프로그래밍으로 대체한다는 것이었죠.
JPA와 그 대표적인 구현체인 Hibernate는 현재 자바(Java) 생태계에서 가장 널리 사용되는 기술 중 하나입니다. GitHub에는 이 기술들을 활용하는 수천 개의 프로젝트가 존재합니다.
하지만 이토록 많은 프로젝트에서 사용되고 있음에도 불구하고, 이 글에서는 여러분의 다음 프로젝트에서 이 기술들을 피해야 하는 이유를 설명드리고자 합니다.
JPA와 'Hibern-HATE'(Hibernate를 싫어한다는 의미의 언어유희)에 대한 제 견해를 말씀드리기에 앞서, 먼저 문서화 문제부터 살펴보겠습니다.
문서화와 복잡성
이 글을 읽는 많은 분들이 제가 JPA/Hibernate를 싫어하는 이유들에 대해 해결책이나 반론을 제시하실 수 있을 것 같습니다.
물론 저는 그러한 의견들을 기꺼이 경청하고 싶습니다.
하지만 그전에 Hibernate 3.6 공식 문서에 대해 잠시 이야기를 나눠보고 싶습니다.
이 문서는 무려 406페이지에 달합니다. 가장 최신 버전은 아니지만, 전체 PDF를 다운로드할 수 있는 최신 버전들 중 하나입니다.
비교를 위해 몇 가지 예를 들어보겠습니다:
- 『반지의 제왕: 반지 원정대』는 같은 PDF 형식으로 231페이지입니다
- 데이터 관리: SQL 버전 2는 288페이지입니다
JPA/Hibernate에 대한 제가 가진 첫 번째이자 가장 중요한 반론이 바로 이것입니다: 단순히 데이터베이스 쿼리를 작성하기 위해 석사 학위 과정을 이수할 필요는 없지 않을까요?
가변성(불변이 X)
기본 생성자가 반드시 필요한 문제
불변성(Invariant)이란 변하지 않거나 변화하지 않는 특성을 의미합니다. 객체 지향 설계에서는 일반적으로 객체의 불변성을 그 생명주기와 결합시키는 것이 관행입니다.
예를 들어, 주문(Order)은 상품(Item) 없이는 존재할 수 없다고 가정해봅시다. 따라서 상품이 제공되지 않으면 주문 자체가 생성되어서는 안 됩니다.
불변성 구현의 간단한 예시를 살펴보겠습니다:
public class Order {
private final String id;
private final Set<Item> items;
public Order(final String uuid, final Set<Item> items) throws AtLeastOneItemShouldBeProvided { AtLeastOneItemShouldBeProvided {
this.id = uuid;
if(items == null || items.isEmpty()) throw new AtLeastOneItemShouldBeProvided();
}
public static class AtLeastOneItemShouldBeProvided extends Exception {}
}
이 예시에서는 상품이 없는 주문은 인스턴스화될 수 없으며, 그러한 시도가 있을 경우 도메인 예외가 발생합니다.
반면 Hibernate는 매개변수가 없는 기본 생성자를 필수적으로 요구합니다. 이는 주문 객체가 상품 없이도, 심지어 이 예시에서는 ID도 없이 인스턴스화될 수 있다는 것을 의미합니다!
엔티티 클래스를 final로 선언할 수 없는 문제
제가 따르는 좋은 관행 중 하나는 클래스를 final이나 abstract로 선언하는 것입니다. 이는 다음과 같은 이점을 제공합니다:
- 가능한 한 상속보다 컴포지션을 선호하도록 유도
- 리스코프 치환 원칙(Liskov Substitution Principle) 위반 방지
이러한 관행에는 단점이 있을 수 있지만, 엔티티 구성을 위해 프록시에 의존하는 프레임워크 때문에 이를 실천할 수 없다는 점은 매우 아쉽습니다.
리플렉션(Reflection)의 문제점
- 리플렉션은 객체 지향 프로그래밍(OOP)의 원칙에 위배됩니다
- 객체는 단순히 메서드가 붙은 데이터 구조가 아닙니다
- 리플렉션은 잘못된 설계에 대한 임시방편일 뿐입니다
'Encapsulation'이라는 가상의 회사를 예로 들어보겠습니다. 이 회사는 아파트용 보안 문을 제작합니다.
이 특수 보안 문에는 두 가지 핵심 기능이 있습니다:
- 외부인이 아파트 내부를 볼 수 없게 합니다
- 거주자의 허가 없이는 외부인이 출입할 수 없습니다
만약 이러한 보안을 우회할 수 있는 '리플렉션'이라는 새로운 기능이 추가된다면 어떨까요?
이것이 바로 '리플렉션'입니다. 애플리케이션이 임의로 문을 열거나 내부에 접근할 수 있게 하는 특별한 기능이죠.
리플렉션을 사용하는 프레임워크에서 수많은 보안 취약점이 발견된 것은 바로 이처럼 권한이 없는 사람들의 접근을 허용할 수 있기 때문입니다.
접근자(Accessor)에서 복사본이나 불변 컬렉션을 반환할 수 없는 문제
이제 불가피하게 getter와 setter를 구현해야 하므로, 최소한 클라이언트가 우리의 데이터를 함부로 수정하지 못하도록 보호해보겠습니다:
public class Order {
// 간략화를 위해 일부 생략
private final Set<Item> items;
public Order(final String uuid, final Set<Item> items) {
// 간략화를 위해 일부 생략
}
public Set<Item> getItems() {
return Collections.unmodifiableSet(items);
}
// 간략화를 위해 일부 생략
}
하지만 이 방식은 Hibernate에서 필드 접근 방식을 사용할 때만 유효합니다. 그렇지 않으면 다른 개발자가 다음과 같이 쉽게 우회할 수 있습니다:
public class Client {
public void myEvilMethod(final Order order) {
order.getItems().clear();
}
}
이에 대한 자세한 내용은 공식 문서를 참고하시기 바랍니다.
이제 지연 로딩에 대해 이야기해보겠습니다.
지연 로딩과 캐시 😿
지연성의 문제
지연 로딩에 대해 너무 깊이 들어가지는 않겠습니다만, Hibernate의 지연 로딩(LAZY LOADING) 메커니즘은 초보자들에게 악몽과도 같습니다.
@Lazy 어노테이션을 사용하게 된다면, 그것은 한 가지를 의미합니다: 여러분의 엔티티 설계에 문제가 있다는 것입니다.
이는 매우 단순한 사실이지만, 다음과 같은 두 가지 상황이 있을 수 있습니다:
- 도메인 설계를 충실히 따랐지만, 불행히도 그것이 Hibernate와 호환되지 않아 이런 매핑을 추가할 수밖에 없었던 경우
- 특정 쿼리를 작성하고 싶었지만 할 수 없어서 이런 매핑을 추가할 수밖에 없었던 경우
이것이 이 프레임워크의 가장 실망스러운 점입니다. 단순한 요구사항을 충족시키기 위해 도메인 모델을 억지로 프레임워크에 맞춰야 한다는 것이죠.
캐시의 문제
아무도 캐시 메커니즘을 제대로 이해하지 못해 결국 비활성화하고 맙니다. 더 안타까운 것은, 이를 이해하는 사람들조차 쿼리 결과를 캐싱하는 대신 엔티티를 캐싱한다는 점입니다.
플러시(Flush) 😾
Hibernate가 flush()를 수행할 때마다 슈뢰딩거의 고양이는 죽습니다. 과연 살아있을까요, 죽었을까요?
flush()는 메모리에 있는 객체의 현재 상태를 데이터베이스와 동기화합니다. 기본적으로 플러시는 자동으로 수행되며, 엔티티를 저장하는 시점부터 트랜잭션이 커밋되는 시점 사이 언제든 발생할 수 있습니다.
이로 인해 두 가지 심각한 문제가 발생합니다:
- 변경사항이 플러시될 때까지 메모리에만 존재하기 때문에, 개발자들이 Hibernate 외의 다른 영속성 도구를 사용할 수 없게 됩니다
- 플러시 도중 충돌이 발생하면 추적하기 어려운 스택 트레이스가 발생하는데, 이러한 오류는 대부분 개발자가 작성한 코드와는 무관합니다
단일 테이블 필드 접근의 문제 😻
API 사용자와 관련된 좋은 실천 방법 중 하나로 이 글에서 설명하는 것이 '관용적 리더(Tolerant Reader)' 패턴입니다.
Martin Fowler의 말을 인용하겠습니다:
저는 이 의견에 전적으로 동의합니다. 이러한 접근 방식은 서비스 제공자와 소비자 간의 결합도를 최소화하는 데 도움이 됩니다.
여기서 제가 강조하고 싶은 점은 데이터베이스 역시 하나의 API라는 것입니다. 데이터베이스는 자체적인 생명주기를 가지고 있어서 이를 사용하는 클라이언트 버전과는 독립적으로 발전할 수 있기 때문입니다.
예를 들어, 데이터베이스에 저장된 이미지의 URL이 필요하다고 가정해봅시다. 다음 두 쿼리의 차이점은 무엇일까요?
첫번째 쿼리:
select url from image where id = 'F462E8D9-9DF7-4A58-9112-EDE0434B4ACE';
두번째 쿼리:
select id, url, content_type, digest from image where id = 'F462E8D9-9DF7-4A58-9112-EDE0434B4ACE';
첫 번째 쿼리는 필요한 필드인 url과 id만 가져옵니다. 두 번째 쿼리는 테이블의 모든 필드를 가져오는데, 이는 select * from ...을 실행하는 것과 유사합니다.
저는 첫 번째 방식을 선호합니다. 만약 다른 컬럼의 이름을 변경해야 할 경우에도 이 간단한 기능은 영향을 받지 않기 때문입니다.
하지만 Hibernate에서는 어떨까요?
이런 방식으로 데이터를 가져오려면 복잡한 프로젝션(projection)을 사용해야 합니다(특정 필드 값만 .
대신 Image를 나타내는 JPA 엔티티를 가져온다면, 두 번째 쿼리처럼 모든 컬럼을 가져오게 됩니다.
제약 조건의 문제 😸
public class User {
// 간략화를 위해 일부 생략
@NotNull
@NotEmpty
@Email
private String email;
// 간략화를 위해 일부 생략
}
이것이 최악입니다. 솔직히 말씀드려서, 이런 어노테이션은 절대 사용하지 마세요. 이 예시에서는 javax validation 어노테이션을 사용해 비즈니스 규칙을 정의하고 있습니다.
이는 다음과 같은 여러 가지 이유로 잘못된 접근입니다:
- 조건을 단위 테스트할 수 없습니다
- 검증이 너무 늦게(플러시 단계에서) 이루어집니다
- 발생하는 예외가 너무 일반적이라 사용하기 어렵습니다
- 비즈니스 규칙을 마치 기술적인 제약사항처럼 다루고 있습니다
전략적 차원의 문제점 😽
프레임워크의 본질
이제 솔직하게 이야기해봅시다. 프레임워크는 훌륭하지만, 동시에 사악합니다.
- 프레임워크 업데이트는 끔찍합니다
- 프레임워크는 하위 호환성을 유지하지 않습니다
- 프레임워크는 여러분이 계속 사용하기를 원합니다
저는 오픈 소스를 정말 좋아합니다. 하지만 오픈 소스 프로젝트를 후원하는 대기업들은 기술 지원이나 서드파티 도구를 통해 수익을 얻습니다.
그들은 시장을 독점할 필요가 있고, 그 방법 중 하나가 자신들의 프레임워크가 필수불가결하다고 여러분을 믿게 만드는 것입니다.
- 특정 프레임워크를 사용하지 않는다는 이유로 회사에서 일하기를 거부하는 개발자들이 얼마나 많습니까?
- 특정 프레임워크를 모른다는 이유로 개발자 채용을 거부하는 회사들이 얼마나 많습니까?
이러한 악순환은 멈춰야 합니다. 우리가 진행하는 프로젝트의 가치는 기술이나 프레임워크와는 무관합니다.
저는 비즈니스 문제를 해결하고 싶습니다. 계속해서 기술적인 문제만 해결하고 싶지 않습니다.
"빠르게 가려면 잘못된 길로 가라"는 착각
"하지만 프레임워크 X는 빠른 개념 증명(PoC)을 개발하는 데 좋잖아요." 아니요, 이는 잘못된 생각입니다.
- 개념 증명의 목적은 비즈니스 문제를 해결할 수 있는지 확인하는 것입니다. 단순히 기술적 실현 가능성만 검증하는 것이 아닙니다
- 개념 증명 단계에서 특정 프레임워크를 사용하는 것은 의미가 없습니다. 어차피 나중에 모든 것을 다시 만들어야 할 테니까요
- "개념 증명을 버리고 다시 시작하면 된다"는 생각은 절대 현실적이지 않습니다
- PoC 과정에서 배운 것들은 절대 '잊을 수 없는' 것들입니다
- 결국 여러분은 배운 것들을 어떤 식으로든 재사용하게 될 것입니다
- 특히 PoC에서 JPA/Hibernate를 사용했다면, 이미 도메인 모델에 대한 여러분의 인식이 편향되어 버렸을 것입니다
우리가 초기에 내리는 아키텍처 결정은 대부분 잘못된 것입니다. 가능하다면 PoC는 영속성이나 인터페이스를 최소화한 상태에서 시작해야 합니다.
SQL이 뭐가 잘못인가요? 🙉
모든 개발자가 알고 있습니다(JPA/Hibernate를 사용하는 개발자들도 마찬가지입니다)
- 개발자가 순수 SQL을 작성할 때:
- 무엇이 실행되는지 정확히 압니다
- EXPLAIN을 통해 누락된 인덱스를 확인할 수 있습니다
- 쿼리별로 데이터를 캐시할 수 있습니다
- SQL 자체는 프레임워크 업데이트가 필요하지 않습니다
순수 SQL로도 충분한 프로젝트에서는 JPA/Hibernate 사용을 중단합시다.
"하지만 관리자가 새 프로젝트에 Hibernate를 사용하라고 강요해요" 🙈
네, 그건 정말 안타까운 일이네요. 직장을 그만두세요...
...아니면 이렇게 해보세요:
- JPA/Hibernate 관련 코드를 도메인 패키지나 모듈 밖에 구현하세요
- 그 골치 아픈 JPA DAO들과 Hibernate 엔티티들을 인프라스트럭처 계층에 숨기세요
- 도메인 계층은 최대한 프레임워크에 독립적으로 유지하세요
제어의 역전(Inversion of Control)을 활용할 수 있지만, 쉽지 않은 작업이 될 것입니다. 도메인 모델과 데이터베이스 관련 JPA 엔티티 사이를 오가는 많은 매퍼를 작성해야 할 것입니다. 하지만 최소한 이후에 다른 개발자들이 이것을 제거하기 쉽도록 만들어 놓을 수는 있습니다.
물론... 관리자가 떠난 후에 말이죠.
"하지만 이미 도메인에 JPA/Hibernate가 깊숙이 들어와 있는 프로젝트예요" 🙈
음... 그건 더 안 좋네요. 몇 가지 조언을 드리겠습니다.
공개 기본 생성자와 setter 사용 중단하기
JPA 엔티티의 예시를 보겠습니다(간단히 하기 위해 Lombok 사용):
@Entity
@Table("offer")
@EqualsAndHashcode
@NoArgsConstructor // Hibernate를 위한 것
@Setter // Hibernate를 위한 것
@Getter
public class BankAccount {
@Id
@Column("id")
private String id;
@Column("opened")
private boolean opened;
@OneToMany(fetch = LAZY)
// ...간략화
private Set<Long> ownerIds;
}
이 엔티티에서는 기본 생성자, setter, getter가 모두 public으로 되어 있습니다.
이제 계좌를 생성하는 서비스를 살펴보겠습니다:
public class BankAccountService {
private BankAccountJPARepository bankAccountJPARepository;
public void createBankAccount(final Set<Long> ownerIds) {
final BankAccount bankAccount = new BankAccount();
bankAccount.setId(UUID.randomUUID().toString());
bankAccount.setOpened(true);
if(ownerIds == null || ownerIds.isEmpty()) {
throw new IllegalStateException();
} bankAccount.setOwnerIds(ownerIds); this.bankAccountJPARepository.saveAndFlush(bankAccount); } }
이 두 클래스에는 여러 가지 문제가 있습니다:
- BankAccount 엔티티가 빈약합니다. 데이터와 다른 엔티티와의 연결만 포함하고 있습니다
- 그 결과로 서비스에서는 일련의 setter를 호출해야 하며, 개발자가 뭔가를 잊어버릴 위험이 있습니다
- 계좌에는 반드시 소유자가 있어야 한다는 조건이 엔티티 자체가 아닌 외부에서 검사됩니다
Hibernate가 기본 생성자와 setter를 요구한다는 점을 감안하더라도, 우리는 더 나은 방법을 찾을 수 있습니다:
@Entity
@Table("bank_account")
@EqualsAndHashcode
@NoArgsConstructor(access = PACKAGE) // Hibernate용으로만 사용, 외부에서 사용 금지
@Setter(value = PACKAGE) // Hibernate용으로만 사용, 외부에서 사용 금지
@Getter
public class BankAccount {
@Id
@Column("id")
private String id;
@Column("opened")
private boolean opened;
@OneToMany(fetch = LAZY) // ...간략화
private Set<Long> ownerIds;
public BankAccount(final String id, final Collection<Long> ownerIds) {
if(ownerIds == null || ownerIds.isEmpty()) {
throw new AtLeastOneOwnerIsRequired();
}
this.id = validateNotBlank(id);
this.ownerIds = new HashSet(ownerIds);
this.opened = true;
}
}
새로운 구현에서 달라진 점들을 살펴보겠습니다:
- 기본 생성자와 setter의 가시성이 package로 제한되었습니다
- 유일한 public 생성자는 id와 ownerIds 두 개의 매개변수를 받습니다
- ownerIds의 타입이 반드시 Set일 필요는 없습니다
- ownerIds 검사가 이제 BankAccount의 책임이 되었습니다. 이는 불변조건입니다
- opened 플래그가 생성자에서 true로 설정되어, 잊어버릴 위험이 없습니다
- 이전의 런타임 예외 IllegalStateException이 의미 있는 이름을 가진 체크 예외로 대체되었습니다
- 예외 이름에서 Exception 접미사를 제거했습니다. AtLeastOneOwnerIsRequired라는 문장만으로도 문제가 발생했다는 의미가 충분히 전달되기 때문입니다
이제 서비스 클래스는 다음과 같이 변경됩니다:
public class BankAccountService {
private BankAccountJPARepository bankAccountJPARepository;
public void createBankAccount(final Set<Long> ownerIds) throws AtLeastOneOwnerIsRequired {
final BankAccount bankAccount = new BankAccount(UUID.randomUUID().toString(), ownerIds);
this.bankAccountJPARepository.saveAndFlush(bankAccount);
}
}
SQL로 생성된 ID 사용 중단하기
가능한 경우 엔티티의 "기능적 키"를 비즈니스 개념의 식별자와 일치시켜야 합니다.
하나의 컨텍스트에서 사용자는 이메일 주소나 주민등록번호로 유일하게 식별될 수 있습니다. (참고: 이는 반드시 이들이 기본 키가 되어야 한다는 의미는 아니지만, 도메인 모델 엔티티는 명확한 식별자를 가져야 합니다)
어쨌든 Hibernate로 인해 도메인 모델이 데이터베이스 모델과 강하게 결합되어 있다면, 이를 새로운 엔티티에 비즈니스 식별자를 부여할 좋은 기회로 삼으세요. 기술적으로 자동 생성되는 ID 대신 말이죠.
만약 이것이 불가능하다면(예를 들어, 은행 계좌에는 기능적 식별자가 없는 경우), 숫자 ID 대신 문자열 타입(예: UUID)을 사용하는 것이 좋습니다.
문자열은 숫자값보다 적응하고 마이그레이션하기가 더 쉽습니다. 게다가 순차적인 ID를 사용하면 인터페이스나 URL을 통해 노출될 수 있어서 해커들이 추측하기 쉽다는 문제가 있습니다.
JPA 리포지토리의 이름을 JPA DAO로 변경하기
스프링이 데이터 모델 객체 컬렉션을 관리하는 인터페이스를 XXXRepository라고 부르기로 한 것이 저는 정말 마음에 들지 않습니다. 제 의견으로는, 이들을 XXXDao라고 부르는 것이 이들이 인프라스트럭처에 속한다는 점을 더 잘 강조합니다.
Hibernate가 ID를 생성하도록 하지 마세요
@Id 기본 키에 @SequenceGenerator를 사용하지 마세요. 대신 ID를 생성하는 명시적인 서비스를 사용하세요.
예를 들어, spring-data-jpa에서는 다음과 같이 할 수 있습니다:
public interface XXXDao extends CrudRepository {
@Query(value = "SELECT nextval('xxxx_sequence')", nativeQuery = true)
BigInteger generateNewId();
}
이렇게 하는 데는 여러 가지 이유가 있습니다:
- ID가 생성된다면, 그것은 의도적으로 이루어져야 합니다
- 나중에 엔티티를 변경하지 않고도 구현을 변경할 수 있습니다
- ID 생성을 테스트할 수 있습니다
요약하자면, ID를 제어할 수 있어야 합니다. 나중에 ID를 변경하고 싶을 때 더 쉽게 변경할 수 있을 것입니다.
도메인에서 JPA DAO를 최대한 분리하기
여러분의 "도메인" 엔티티와 JPA 어노테이션 사이에는 여전히 강한 결합이 있습니다. 죄송하지만, 이는 쉽게 바뀌지 않을 것입니다.
하지만 도메인 계층과 스프링 데이터 사이의 결합도를 줄이는 것은 가능합니다.
다시 한 번 강조하지만, JPA DAO는 도메인 리포지토리가 아닙니다. 도메인 서비스와 JPA DAO 사이에 중간 계층을 두어야 합니다.
우리의 BankAccountService로 돌아가서 개선해보겠습니다:
public class BankAccountService {
private BankAccountJPARepository bankAccountJPARepository;
public void createBankAccount(final Set<Long> ownerIds) throws AtLeastOneOwnerIsRequired {
final BankAccount bankAccount = new BankAccount(UUID.randomUUID().toString(), ownerIds);
this.bankAccountJPARepository.saveAndFlush(bankAccount);
}
}
도메인 계층에 우리의 도메인 리포지토리가 될 인터페이스를 만들어봅시다:
public interface BankAccounts {
void save(BankAccount bankAccount);
}
여기서 두 가지를 주목하세요:
- Repository라는 용어 대신 엔티티의 복수형을 사용했습니다. 도메인 전문가는 Repository가 무엇인지 모르기 때문입니다. 예를 들어, Customer 엔티티의 경우 복수형은 Market이나 Customers가 될 수 있습니다. 도메인이 결정하도록 합시다.
- 이상한 이름의 saveAndFlush 메서드가 덜 기술적인 save 메서드로 대체되었습니다.
이제 새로운 인터페이스의 구현체를 만들어봅시다. 이 클래스는 도메인 모델에서 멀리 떨어진 인프라스트럭처 계층에 위치할 수 있습니다:
public class BankAccountsJPAImpl implements BankAccounts { private BankAccountJPARepository bankAccountJPARepository; public void save(BankAccount bankAccount) { this.bankAccountJPARepository.saveAndFlush(bankAccount); } }
이 BankAccountsJPAImpl 클래스는 생성자를 통해 BankAccountService에 주입될 수 있습니다.
나중에 spring-data-jpa를 업데이트해야 하고 그 업데이트가 코드를 깨뜨리더라도, 도메인 계층은 건드릴 필요가 없을 것입니다.
양방향 연관관계 추가 중단하기
이제 BankAccount에 거래(Transaction) 개념을 도입한다고 가정해봅시다:
@Entity
@Table("bank_account")
// ...간략화를 위해 생략
public class BankAccount {
// ...간략화를 위해 생략
@OneToMany(fetch = LAZY)
// ...간략화
private List<Transaction> transactions;
}
Transaction 엔티티는 다음과 같습니다:
@Entity
@Table("bank_account_transaction")
// ...간략화를 위해 생략
public class Transaction {
// ...간략화를 위해 생략
}
Transaction에 many-to-one 관계를 추가하여 해당 거래의 BankAccount에 쉽게 접근하고 싶은 유혹이 들 것입니다. 그렇게 하면 BankAccount 객체와 그들의 Transaction들 사이를 양방향으로 쉽게 탐색할 수 있을 테니까요.
제 의견으로는 이는 금지되어야 합니다.
JPA 엔티티의 연관관계는 애그리게이트의 논리적인 쓰기 연관관계여야 합니다.
즉, 만약 그 연관관계가 액터의 의도를 표현하는 데 불필요하다면, 피해야 한다는 의미입니다.
BankAccount는 여러 Transaction을 소유합니다. Transaction은 BankAccount에 '연결된' 것이 아닙니다.
가능한 한 엔티티 매핑 추가 중단하기
제 예시에서 보셨듯이 BankAccount는 Owner와 매핑되어 있지 않고, 단지 ID만 가지고 있습니다.
왜 그럴까요?
답은 간단합니다. BankAccount와 Owner 사이에 존재하는 유일한 불변조건(또는 비즈니스 규칙)은 최소한 하나의 소유자가 있어야 한다는 것뿐입니다. 다시 말해, Owner와의 매핑은 불필요합니다.
저는 이러한 매핑이 오히려 위험할 수 있다고 주장하고 싶습니다.
Owner라는 개념이 제 코드베이스에서 의미가 있더라도, 은행 컨텍스트가 아닌 영업 컨텍스트에 속할 수 있습니다.
이 두 클래스 간의 불필요한 관계는 서로 관련이 없는 두 컨텍스트를 결합시키는 결과를 낳을 수 있습니다.
만약 영업 애플리케이션의 확장을 위해 데이터베이스를 둘로 분리해야 한다면 어떨까요? 또는 그 밑의 영속성 계층을 변경해야 한다면? BankAccount <-> Owner 매핑은 깨질 수밖에 없을 것입니다.
ID 이상이 필요하지 않다면, 매핑을 만들지 마세요.
결론
다시는 같은 실수를 반복하지 마세요. 다음에는 과감히 버리세요:
- 읽어야 할 문서가 줄어들고 비즈니스 관련 문제에 더 집중할 수 있는 시간이 늘어날 것입니다
- 성급한 아키텍처 결정을 내리지 마세요. JPA/Hibernate는 필수가 아닙니다
- 다음 개발자들을 위해 자비를 베푸세요. 오늘 여러분이 작성하는 코드는 내일 다른 개발자의 악몽이 될 수 있습니다
👥 더 나은 데브필을 만드는 데 의견을 보태주세요
Top 1% 개발자로 거듭나기 위한 처방전, DevPill 구독자 여러분 안녕하세요 :)
저는 여러분들이 너무 궁금합니다.
어떤 마음으로 뉴스레터를 구독해주시는지,
어떤 환경에서 최고의 개발자가 되기 위해 고군분투하고 계신지,
제가 드릴 수 있는 도움은 어떤 게 있을지.
아래 설문조사에 참여해주시면 더 나은 콘텐츠를 제작할 수 있도록 힘쓰겠습니다. 설문에 참여해주시는 분들 전원 1개월 유료 멤버십 구독권을 선물드립니다. 유료 멤버십에서는 아래와 같은 혜택이 제공됩니다.
- DevPill과의 1:1 온라인 커피챗
- 멤버십 전용 슬랙 채널 참여권
- 채용 정보 공유 / 스터디 그룹 형성 / 실시간 기술 질의응답
- 이력서/포트폴리오 템플릿
의견을 남겨주세요