최근 소프트웨어 아키텍처에 관심을 가지기 시작하면서 단순히 동작하는 코드를 넘어서 변화에 유연하고 도메인을 잘 분리할 수 있는 구조에 대해 고민하게 되었다.
이러한 과정 속에서 도메인 주도 개발을 알게 되었고, 객체지향적으로 도메인을 모델링하면서 객체 간 관계를 어떻게 맺어야하는 것인지 고민하게 되었다.
이전까지는 도메인 경계를 고려하지 않고 모든 객체들이 서로 직접 참조하는 방식으로 개발해왔다.
이러한 방법은 객체 간 강한 결합이 생겨 변경에 취약하다는 것을 알게 되었다.
이전에는 도메인 간 경계를 고려하지 않고 모든 객체들을 연관관계를 설정하는 방식으로 자유롭게 개발해왔다.
처음에 이 방식이 편리하고 좋다고 생각했지만, 객체 간 강한 결합이 생겨 변경 시 유지보수에 취약하다는 것을 알게되었다.
이후 서로 다른 도메인 간 의존도를 최소화하기 위해 ID값을 참조하는 방식을 알게 되었고, 사이드 프로젝트를 진행하면서 적용해봤다.
따라서 이번 글에서는 객체 참조 시 한계점에 대해 정리하고 어떤 경우에 ID값을 참조했는지 정리해보려고한다.
프로젝트에서 ORM 기술은 JPA를 사용했다. JPA는 객체 지향적인 방식으로 데이터를 다룰 수 있어서 매우 좋은 ORM 기술이라고 생각한다.
이처럼 연관 관계를 설정하면 애플리케이션 단에서 쉽게 조회할 수 있게 된다. 하지만 자유롭지만 이는 문제점을 불러온다....
간단한 예시를 살펴보자
@Entity
public class Order {
@Id
private Long id;
@OneToMany(cascade = CascadeType.ALL)
private List<OrderItem> orderItems;
}
@Entity
public class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Product
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
}
@Entity
public class Product {
@Id
private Long id;
private String name;
private Long price;
private String category;
}
주문과 상품은 1:N 관계이다. 주문 객체에서 상품 리스트 데이터를 쉽게 접근할 수 있고, 상품 객체서도 주문에 접근할 수 있다.
위에서 말했듯이 매우 편리하지만, 이 편리함으로 생기는 문제들이 너무 많다...
예를들어, 주문 생성 시 상품의 카테고리가 book인 경우 1,000원 할인해주는 코드를 생각해보자
@Service
public class OrderService {
...
public void applyDiscount(Order order) {
for(OrderItem item : order.getOrderItems()) {
if (item.getProduct().getCategory().equals("book")) {
item.discount(1000)
}
}
}
}
위 코드는 order 엔티티에서 객체 그래프 탐색으로 product에 접근해 가격정보와 카테고리를 확인하는 코드이다.
여기서 만약에 category 필드를 ENUM으로변경한다고 가정해보자.
그러면 OrderService를 아래와 같이 변경해야한다.
@Service
public class OrderService {
...
public void applyDiscount(Order order) {
for(OrderItem item : order.getOrderItems()) {
if (item.getProduct().getCategory() == ProductCategory.BOOK) {
item.discount(1000)
}
}
}
}
만약에 또 String 값으로 변경한다면 또 OrderService의 코드도 변경해주어야한다. 즉, 주문상품과 상품은 강하게 결합되어서 한쪽 변경이 다른쪽에게 영향을 미친다는 것이다.
그러면 어떻게 해결해야할까 ? 객체의 데이터에 직접 접근하지 않고 메서드를 생성해서 객체에게 호출하기만 하면된다.
public class Product {
...
private ProductCategory category;
public boolean isBook() {
return this.category == ProductCategory.BOOK;
}
}
@Service
public class OrderService {
...
public void applyDiscount(Order order) {
for(OrderItem item : order.getOrderItems()) {
if (item.getProduct().isBook()) {
item.discount(1000)
}
}
}
}
변경된 코드와 같이 Product가 카테고리 검증을 직접 수행하고 OrderService에서는 호출하면 되는 것이다.
그러면 다시 카테고리 타입이 String을 변경된다고 생각해보자.
public class Product {
...
private String category;
public boolean isBook() {
return this.category.equals("book");
}
}
@Service
public class OrderService {
...
public void applyDiscount(Order order) {
for(OrderItem item : order.getOrderItems()) {
if (item.getProduct().isBook()) {
item.discount(1000)
}
}
}
}
Product의 코드는 변경 되었지만, OrderService는 영향을 받지 않는 것을 확인할 수 있다.
정리해보면, 객체를 참조하면 객체에 쉽게 접근할 수 있다.
이 말은 다른 객체의 데이터 혹은 필드에 접근해서 값을 변경하는 상황을 초래한다.
왜냐하면 메서드를 생성하고 호출하는 것보다 필드에 직접 접근하는 것이 쉽고 빠르기 때문이다.
public class Order {
private List<OrderItem> orderItems;
public int getTotalPrice() {
return orderItems.stream()
.mapToInt(OrderItem::getPrice())
.sum();
}
}
int totalPrice = 0;
for (OrderItem item : order.getOrderItems()) {
totalPrice += item.getPrice();
}
현재 코드에서는 크게 차이를 못 느낄 것이다.
만약에 첫 번째 주문 상품 가격을 가져와야한다고 가정해보자.
public class Order {
private List<OrderItem> orderItems;
public int getFirstOrderItemPrice() {
return orderItems.get(0).getPrice();
}
}
order.getOrderItems().get(0).getPrice();
행동 중심은 필요한 데이터에 대해서 메서드를 새로 생성해야한다....
하지만 데이터 중심 접근 메서드를 만들지 않고 당장 필요한 데이터에 접근해서 바로 로직을 작성할 수 있다.
이처럼 객체 간 참조를 하게 될 경우, 연관 객체에 쉽게 .
을 통해 연쇄적으로 쉽게 접근할 수 있다.
그렇기 때문에 초기 개발 당시 쉽고 빠르다는 생각에 빠져 데이터 중심적으로 개발을 진행하게 된다는 것 이고, 이러한 데이터 중심 사고는 추후에 변경에 대해서 유연성이 떨어진다는 문제점이 발생한다.
객체를 참조해서 연관관계를 설정한다면 기본적으로 LazyLoading이 적용된다.
예를들어, 조회용 DTO Projection 객체에서 상품 이름이 필요로 한다고 생각해보자
public class OrderResponse {
...
private List<String> productNames;
...
}
List<String> productNames = orderItems.stream().map(item -> item.getProduct().getName());
위 코드는 주문 상품이 n개인 경우 상품을 가져와서 상품 이름을 확인한다.
이때, N+1 문제가 발생한다. 왜냐하면 상품은 LazyLoading으로 걸려있기 때문에 item.getProduct()
시 JPA가 SELECT * FROM product WHERE id = ?;
쿼리를 날리기 때문이다.
정리해보면, orderItems을 n번 순회하면 n번의 쿼리가 더 발생한다는 것이다.
1. 주문 상품 목록을 조회한다. SELECT * FROM order_item WHERE order_id = ?;
2. 주문 상품 목록을 순회하여 getProduct() 호출 시 로딩되지 않은 상품을 조회한다.SELECT * FROM product WHERE id = ?;
이다.
결국 객체지향적으로 쉽게 데이터를 조회할 수 있지만 1번의 쿼리 실행 후 n번 발생하여 DB에 불필요한 많은 트래픽을 발생시킨다는 것이다.
그렇기 때문에 ID를 참조해서 필요한 시점에 명시적으로 조회하는 방식이 안정적일 것이라고 생각한다.
public OrderResponse getOrder(Long orderId) {
// 1. 주문 조회
Order order = orderRepository.findById(orderId);
// 2. 주문 상품 목록 조회
List<Long> productIds = order.getOrderItems.stream()
.map(OrderItem::getProductId)
.distinct()
.collect(Collectors.toList());
// 3. 한 번의 IN 쿼리로 Product 목록 조회
List<Product> products = productRepository.findByIdIn(productIds);
...
}
코드가 길어지지만 상품 쿼리를 한번에 조회할 수 있기 때문에 성능적인 이슈를 해결할 수 있다.
혹은 직접 SQL JOIN문을 이용해서 조회용 DTO로 Projection하면 성능을 향상시킬 수도 있다.
이처럼 객체 간 참조를 하게 되면 JPA의 LazyLoading에 의해서 연관 객체에 대한 추가 쿼리가 발생해서 성능 저하를 불러올 수 있다는 말이다.
객체 참조를 이용하면 객체 지향적인 방식으로 데이터를 다룰 수 있어서 매우 편리하다는 장점이 있다.
하지만 객체 참조로 인한 2가지 문제점을 발견했다.
첫 번째는 데이터 의존 및 데이터 중심 사고이다. 객체 간 참조를 하게 되면 연관된 객체의 내부 필드에 쉽게 접근할 수 있고, 행동 중심 사고보다 코드량이 적고 직관적이어서 데이터에 의존해서 코드를 작성하게 될 수 있다. 이러한 방식은 객체 간 결합도를 강하게 만들어 한 쪽 변경이 다른쪽에 영향을 미치게 된다.
두 번째는 성능 문제이다. 객체 그래프 탐색으로 쉽게 접근할 수 있어 연관 객체에 접근하여 순회하는 경우 LazyLoading으로 불필요한 쿼리를 유발하고 성능 저하를 일으킨다.
그렇다면 무조건 ID값을 참조하는게 좋을까?
ID값만 참조하는 것은 정답이 아니라고 생각한다.
왜냐하면 동일한 도메인 내부 혹은 생명주기가 같은 경우에는 객체를 참조하면 더 좋을 수 있다.
예를 들어, 생명주기가 같거나 같은 도메인의 경우는 연관관계 매핑을 사용하는 것이 좋을 것이라고 생각한다.
같은 도메인은 대부분 생명주기가 동일하다. 이때, ID 값을 참조하면 객체 하나하나 모두 save를 호출해주어야한다. 하지만 객체를 참조할 경우 Cascade 속성으로 전파가 되기 때문에 코드 가독성 뿐 아니라 개발 생산성이 올라간다.
반면에 도메인 경계를 벗어나거나 단순히 조회 목적으로만 연관 객체의 정보가 필요한 경우는 ID를 참조하는 것이 좋다고 생각한다.
ID를 참조하는 경우 도메인 간 결합도를 낮출 수 있을 뿐 아니라, 성능 이슈를 방지하고 쿼리 발생 시점을 명확히 제어할 수 있다.
따라서 같은 도메인 혹은 생명주기가 같은 객체들 간 객체들을 참조해서 객체지향적 모델링을하고, 다른 도메인이나 조회 전용, 성능 이슈가 우려되는 상황에서는 ID값을 참조하는 것이 좋다고 생각한다.