스프링 공부를 하면서 가장 자주 사용되며 단순한 그렇지만 중요한 구조에 대해 알아보려 한다.
그냥 내가 공부하고 코드를 작성할 때 유의하며 작성하면 더 좋은 구조가 될 것 같아 스스로 학습하기 위해 작성한다.
반응형
SMALL
가장 단순한 구조는 역할에 따라 3가지 계층으로 나누는 것이다.
프레젠테이션 계층
UI 관련 처리 담당
웹 요청과 응답
사용자 요청을 검증
주 사용 기술: 서블릿과 HTTP같은 웹 기술, 스프링 MVC
서비스 계층
비즈니스 로직을 담당
주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
데이터 접근 계층
실제 데이터베이스에 접근하는 코드
주 사용 기술: JDBC, JPA, Redis, Mongo,...
순수한 서비스 계층이란?
서비스 계층은 가급적 특정 기술을 의존하지 말아야 한다는 말은 어떤 의미일까?
시간이 흘러 웹(UI)과 관련된 기술이 변하고, 데이터 저장 기술을 다른 기술로 변경할 여지는 충분하다. 지금 사용하는 기술보다 더 좋은 기술이 탄생한다던가, 지금 사용하고 있는 기술이 요구사항을 만족할 수 없는 기술이라면 사용하다가도 변경해야 한다.
그러나, 그렇다 한들 비즈니스 로직은 사용하는 기술이 바뀐다고 변경하지 않는다. 서비스가 다루는 기능이 변하거나 새로운 기능이 추가, 변경되어야 하는 경우가 아니면 비즈니스 요구사항이 변경될 리 없고 그 요구사항을 다루는 로직도 기술에 의존해서 변경되면 안 된다. (물론 이상적인 경우일 때를 말한다)
그렇기 때문에 서비스 계층은 가급적 특정 기술에 의존하면 안 된다는 뜻이다. 그래서 서비스 계층에 대해 코드를 작성할 때도 이 점을 유의하면서 작성해야 한다. 예를 들어, 프레젠테이션 계층은 클라이언트가 접근하는 UI와 관련된 기술인 웹, 서블릿, HTTP와 관련된 부분을 담당해 준다. 그래서 서비스 계층을 이런 UI와 관련된 기술로부터 보호해 준다. 그 결과로, REST API를 사용하다가도 GraphQL로 변경하려 할 때 프레젠테이션 계층의 코드만 변경하고 서비스 계층은 변경하지 않아도 된다. 데이터 접근 계층은 데이터를 저장하고 관리하는 기술을 담당해 주고 그렇기에 JDBC, JPA와 같은 구체적인 데이터 접근 기술로부터 서비스 계층을 보호해 준다. 그 결과로, JDBC를 사용하다가 JPA로 변경하더라도 서비스 계층은 변경하지 않아도 된다. 왜냐하면 서비스 계층은 데이터 접근 계층에 직접 접근하는 게 아니라 데이터 접근 계층이 인터페이스를 제공하고 서비스 계층은 이 인터페이스에만 의존하면 되기 때문이다.
이로 인해 파생되는 긍정적인 부분은 유지보수가 수월해지고 테스트도 편리해진다.
이러한 이유로 인해 잘 만들어진 구조는 인터페이스와 그 인터페이스를 구현한 구현체가 나뉘는 것이고 사용자는 구현체가 어떤 식으로 구현했는지를 세세히 알 필요 없이 인터페이스를 가져다가 사용만 하면 되는 것이다. (예: DataSource 인터페이스와 이 인터페이스를 구현한 여러 가지 구현체(HikariDataSource, DriverManagerDataSource,...) 이것을 추상화라고 한다.
정리하자면..
구조에 정답이 있는 것은 아니지만, 더 나은 구조에 가까울수록 변경이 있을 때 변경에 대한 제약이 적다. 즉, 서비스 계층은 가급적 비즈니스 로직만 구현하고(순수 자바 코드로) 특정 구현 기술에 의존을 가급적 지양해야 한다. 그래야 이후 사용하는 기술을 변경하더라도 서비스 계층에 변경 영향 범위를 최소화할 수 있기 때문이다.
서비스 계층에서 특정 기술에 의존하고 이 서비스 계층에 사용되는 비즈니스 로직이 별로 없으면 크게 문제가 될 것이 없지만, 만약 그 코드가 수만 줄 수십만 줄이라면 기술하나를 바꿨는데 수십만줄이 모두 변경되어야 한다. 그렇게 변경을 하면? 아주 매우 엄청 높은 확률로 사이드 이펙트가 발생할 것 같다..
// querydsl directory path
def querydslDir = "src/main/generated"
// querydsl directory 를 자동 임포트 할 수 있게 설정
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
// task 중 compileJava 를 실행하면 Q 클래스 파일들을 생성
tasks.withType(JavaCompile).configureEach {
options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}
// clean 을 하면 querydsl directory 를 삭제
clean.doLast {
file(querydslDir).deleteDir()
}
이렇게 설정을 하고 gradle을 다시 빌드하면 된다. 다시 빌드하고 나면 이제 QueryDsl에서 반드시 필요한 파일인 Q파일을 생성해야 하는데 생성하기 위해 우측 gradle > (artifact 명) > Tasks > other > compileJava를 실행하면 된다.
결과
이를 실행하면 설정한 경로에 맞게 좌측 트리에서 찾아보면 다음과 같이 Q 파일들이 정상적으로 생성되었음을 알 수 있다.
JPA를 사용할 때 "Open Session In View"라는 게 있다. 이 녀석이 은근 골치가 아픈 녀석인데 Spring Boot와 JPA를 같이 사용할 때 서버를 실행해 보면 (OSIV 관련 어떠한 작업도 하지 않았을 때) 이런 경고 문구가 노출된다.
2023-11-16T08:31:26.467+09:00 WARN 76673 --- [ restartedMain] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
그러니까 이 옵션은 기본값이 "true"인데 얘가 true이면 화면이 렌더링되는 시점에도 데이터베이스에 쿼리가 날아갈 수 있다는 경고 문구를 날리고 있다. 이 속성값은 application.yml 파일에서 spring.jpa.open-in-view 키로 값을 줄 수 있다.
반응형
SMALL
이 OSIV가 그래서 정확히 어떻게 동작을 하느냐? 아래 컨트롤러 코드를 보자.
@GetMapping("members")
public Result<List<MemberDto>> members() {
List<Member> findMembers = memberService.findMembers();
List<MemberDto> collect = findMembers
.stream()
.map(m -> new MemberDto(m.getUsername()))
.toList();
return new Result<>(collect.size(), collect);
}
코드를 보면 memberService.findMember()를 호출하고 있다. 이 MemberService를 들어가 보면 이렇게 생겼다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
public List<Member> findMembers() {
return memberRepository.findAll();
}
}
클래스 레벨에 @Transactional() 어노테이션이 달려있고 findMembers() 메소드는 데이터베이스에서 멤버를 전부 가져온다. 이때 OSIV 속성이 true라면 이 클래스의 메소드가 시작하고 끝나는 시점에 트랜잭션이 커밋이 되거나 영속 컨텍스트가 닫히지 않는다. 이 메소드를 호출한 컨트롤러도 트랜잭션 안에서 동작하게 된다. 이게 OSIV 속성이 true일 때 일어나는 현상이다.
즉, 트랜잭션이 활성화된 메서드가 끝나도 트랜잭션은 닫히지 않고 그 메서드를 호출한 녀석도 역시 트랜잭션이 활성화된 상태로 남아있는다. 그렇기 때문에 위 경고 문구처럼 화면을 뿌리는 컨트롤러가 화면을 렌더링 하는 시점에도 역시 데이터베이스 쿼리가 날아갈 수 있다는 경고 문구를 하고 있는 것.
그럼 OSIV 속성은 항상 false로 설정해야 할까?
OSIV가 활성화된 상태일 때 장단점
장점은 다음과 같다.
트랜잭션 어노테이션이 붙어 있는 곳이 아니더라도 그것을 호출한 곳도 트랜잭션이 활성화 된 상태이다 보니 지연로딩을 초기화하는데 좀 더 유연하다. 즉, 서비스에서 데이터베이스 쿼리를 하고 받은 데이터를 가지고 컨트롤러에서 지연 로딩 관련 데이터를 초기화할 수 있다는 얘기다.
단점은 다음과 같다.
트랜잭션이 예상하는 것보다 오래 살아 있기 때문에 네트워크 상황에 따라 트랜잭션이 부족할 수 있다. 즉, 컨트롤러에서 서비스를 호출해서 데이터베이스에 쿼리를 했고 받은 데이터를 컨트롤러가 받을 때 네트워크 상황이 좋지 않아 컨트롤러가 재빨리 응답을 줄 수 없는 경우 그 시간 동안 내내 트랜잭션은 유효하게 된다. 그러면 트래픽이 많은 애플리케이션은 이러한 경우 때문에 커넥션풀이 부족할 수 있고 부족하게 되는 경우 장애로 이어질 수 있다.
OSIV가 비활성화된 상태일 때 장단점
장점은 다음과 같다.
트랜잭션이 시작하고 끝나는 시점이 명확하고 짧아지기 때문에 커넥션 풀에 여유가 생긴다.
단점은 다음과 같다.
트랜잭션 하나가 영속 컨텍스트 하나와 매핑되기 때문에 트랜잭션이 끝나는 동시에 지연로딩을 초기화할 수 없고 그렇게 되면 지연 로딩으로 데이터를 가져올 때 언제나 트랜잭션 안에서 모든 지연로딩을 초기화한 상태로 돌려주어야 한다.
OSIV를 비활성화했을 때 단점을 극복하는 방법
만약, OSIV를 비활성화한 경우엔 모든 지연 로딩 엔티티를 트랜잭션 안에서 처리해야 하므로 다음과 같이 해결할 수 있을 것이다.
페치 조인을 사용해 데이터를 쿼리 하는 시점에 모두 받아온다.
페치 조인으로 모두 받아오지 못할 경우 지연 로딩을 초기화하는 새로운 트랜잭션 안에서 처리한다.
위 코드는 컨트롤러에서 주문 정보를 모두 가져와서 DTO로 반환 후 JSON으로 데이터를 돌려준다. 여기서 OrderDto 클래스는 지연로딩 엔티티를 받아 처리한다. 다음 코드를 보자.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
OrderDto의 생성자에서 OrderItems를 처리한다. OrderItem은 지연 로딩 엔티티이다. 그래서 OSIV가 비활성화된 경우 위 컨트롤러에서 지연로딩을 처리할 수 없는 상태이다. 그러나, 페치 조인으로 지연 로딩 데이터를 모두 한 번의 쿼리로 로딩해 온다면 가능하다.
public List<Order> findAllWithItem() {
return em.createQuery("SELECT DISTINCT o " +
"FROM Order o " +
"JOIN FETCH o.member m " +
"JOIN FETCH o.delivery d " +
"JOIN FETCH o.orderItems oi " +
"JOIN FETCH oi.item i", Order.class).getResultList();
}
지연 로딩을 초기화하는 새로운 트랜잭션에서 처리
다른 방법으로는 새로운 트랜잭션을 받아오면 된다. 지금 문제가 되는 부분은 레포지토리에서 받아온 주문 데이터들을 DTO로 변환하는 이 코드이다.
즉, 서비스 또는 레포지토리에서 모든것을 다 처리한 후 컨트롤러는 받기만 하고 돌려주는 것을 말한다.
나는 비즈니스 로직을 레포지토리에 넣는 것을 매우 싫어하므로 (그래서도 안되고 항상 참조 방향은 다음과 같아야 한다).
컨트롤러 -> 서비스 -> 레포지토리, 엔티티
역방향이 되면 안 된다. 유지보수에 굉장히 큰 타격을 주므로.
아무튼 지금 코드에서 레포지토리에서 모든 로직을 넣지 않을 것이다. 근데 만약 서비스를 호출해서 처리하는 경우라면 서비스 안에서 DTO로 변환까지 같이 해준 데이터를 컨트롤러가 받으면 된다.
결론
OSIV를 활성화 또는 비활성화는 Trade-off가 있다. 그래서 뭐가 더 좋고 나쁘다가 아니라 상황에 맞게 사용하면 된다. 예를 들어 트래픽이 많이 필요 없는 애플리케이션은 활성화 상태로 두면 코드의 복잡도가 떨어지는 장점을 가지면서 애플리케이션에 장애 없이 서비스를 할 수 있을 것이다. 그러나 트래픽이 많은 애플리케이션은 이 옵션을 활성화해서는 안된다. 트랜잭션이 부족할 가능성이 농후하기 때문에. 따라서 상황에 맞게 사용하는 것이 중요하다고 본다.
그리고, 원하는 차트별로 패키지를 내려받으면 된다. 예를 들어 내가 파이차트를 사용하고 싶다면 다음처럼 명령어를 입력하자.
npm i @nivo/pie
물론, 매뉴얼에서 친절하게 다 알려준다.
사이트가 굉장히 모던하면서 매뉴얼도 극강으로 사용자 경험이 좋다. 들어가보면 여러 차트를 사용할 수 있는데 아래 화면을 보자.
위 화면에서 원하는 차트를 선택하면 해당 차트에 대한 코드 및 매뉴얼이 아주 아주 친절하게 나와있다.
나는 파이차트를 선택해서 들어가봤더니 오른쪽엔 파이차트를 뿌렸을 때 보여지는 모양새가 따란 나와있고, 왼쪽은 그 파이차트에 원하는 속성 및 사용가능한 속성을 정의해놨다. 그리고 이게 진짜 좋은게 왼쪽에서 원하는 속성의 값을 변경할 수 있는데 그 값이 오른쪽 차트에 즉각 반영된다.
예를 들어, 테두리의 두께와 색을 변경한다고 해보자. 다음과 같이 색상을 빨간색, 두께를 12px로 변경을 했다.
그러면 다음처럼 바로 그 값이 반영된 모습으로 보여진다.
더 좋은건 이렇게 반영된 리액트 코드를 나를 위해 알아서 짜준다는 것.
그대로 가져다가 사용만하면 된다. 꽤나 괜찮은 라이브러리이고 매뉴얼 또한 아주 준수하다.
결과
이렇게 가져다가 사용하여 만든 회사 프로젝트의 대시보드는 다음처럼 생겼다.
정말 많은 차트가 있고, 가져다가 사용하기가 너무 수월해서 공유하는 마음으로 작성해보았다.
JPA와 Spring을 공부하던 중 REST API를 통해 데이터를 주고 받아야하는 상황이 빈번하게 있을텐데 이 때 컨트롤러에서 엔티티를 반환하는 일이 생기면 절대 안된다. 많은 문제가 있지만, 그 중에서도 어떤 문제가 있냐면, 엔티티의 변경이 생겼을 때 API 스펙까지도 아예 변경되어야 하기 때문이다. 또한, 엔티티는 지연 로딩 전략을 사용할텐데 지연 로딩 전략을 사용한 상태에서 이를 처리하지 않는 경우 Response를 제대로 할 수 없거나 성능에 문제가 생기거나 불필요한 Response data가 생긴다.
예시를 살펴보자, 우선 컨트롤러가 엔티티를 다루는 코드를 살펴보자.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/")
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("orders")
public List<Order> orders() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
return orders;
}
}
간단한 주문정보 리스트를 반환하는 컨트롤러를 만들고 이를 실제로 요청해보면 (나같은 경우) 다음과 같은 에러를 마주하게 된다.
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:145) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25) ~[jackson-databind-2.15.3.jar:2.15.3]
at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
이 에러는 왜 발생하냐면 Jackson 라이브러리에서 엔티티를 JSON으로 반환할 때 무한 루프에 빠지기 때문인데 왜 무한 루프에 빠지냐면 Order 엔티티는 Member 엔티티를 참조하고 Member 엔티티가 Order 엔티티를 참조하기 때문에 계속 서로가 서로를 참조하는 무한 루프에 빠지는 것.
반응형
Order Entity
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // Order가 persist될 때 order에 넣어져있는 delivery 역시 persist
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
}
Member Entity
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
@Embedded
private Address address;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
이런 경우부터 문제가 생기는데 이를 어찌저찌 해결할 순 있다. Jackson 라이브러리에게 JSON으로 변환할 때 무시하라고 명령하면 된다.
@JsonIgnore 어노테이션을 Order가 참조하는 엔티티 중 Order를 참조하는 엔티티에서 @JsonIgnore 어노테이션을 달아주면 된다.
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
@Embedded
private Address address;
@JsonIgnore 🟢🟢🟢🟢
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore 🟢🟢🟢🟢
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
private int orderPrice;
private int count;
}
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore 🟢🟢🟢🟢
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus deliveryStatus;
}
이렇게 다시 Order를 참조하는 모든 엔티티에 @JsonIgnore를 걸어주고 다시 요청하면 다음과 같은 에러가 출력된다.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->com.example.jpabook.domain.Order["member"]->com.example.jpabook.domain.Member$HibernateProxy$7rXeVz14["hibernateLazyInitializer"])
이번엔 다른 에러지만 역시 에러는 발생한다. 이는 또 어떤 경우냐면 org.hibernate.proxy.bytebuddy.ByteBuddyInterceptor에 주목하면 되는데 지연 로딩 전략으로 데이터베이스에서 가져올 때 스프링은 지연 로딩 객체를 객체 그대로로 받아오지 않고 프록시로 받아온다. 그리고 프록시로 받아오는 객체가 바로 ByteBuddyInterceptor이다. 이 때 이 녀석은 초기화되지 않은 상태이기 때문에 리턴값으로 돌려주는 JSON으로 변환하는 과정에서 Jackson 라이브러리는 어찌할 방도가 없는 것이다.
이를 해결하기 위해 Jackson Datatype Hibernate5를 내려받고 @Bean으로 등록하면 지연 로딩 전략으로 가져오는 모든 엔티티에 대해서 그냥 없는값으로 처리를 해준다. 그래서 우선은 저 dependency를 내려받자.
이처럼 이너클래스로 Response에 대한 DTO 클래스를 정의한 후 컨트롤러는 반환 타입을 해당 DTO로 설정해준다. 그리고 해당 DTO 형식에 맞게 데이터를 변환해서 만들어주면 불필요한 데이터 노출도 하지 않고 엔티티가 변경된다고 한들 API 스펙이 변경될 일이 없다.
주의할 점은 "컨트롤러는 반환을 DTO를 만들어서 해야하니 DTO를 만들자!" 이렇게 잘 했는데 DTO 클래스의 필드로 엔티티를 받는 경우가 있다. 이 경우도 안된다. 반환하는 DTO에 엔티티가 있으면 이 엔티티도 DTO로 바꿔줘야 한다.
다음 예시를 보자.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItem> orderItems; //엔티티가 DTO에 포함된 경우
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
order.getOrderItems().stream().forEach(o -> o.getItem().getName());
orderItems = order.getOrderItems();
}
}
OrderDto 클래스를 보면 필드로 List<OrderItem> 타입의 orderItems 필드가 있는데, 여기서 OrderItem은 엔티티다. 이렇게 되면 안된다. 이 또한 DTO로 변경해줘야 한다. 다음 코드처럼.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
쿼리 한 번으로 여러 테이블 로우(레코드)를 변경하는 걸 말한다. UPDATE, DELETE, INSERT 같은 것들이 이제 벌크 연산이 가능하다.
뭐 별건 아닌데 조금 주의할 사항이 있다.
벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다.
이게 뭐가 문제냐면 벌크 연산을 처리한 전과 후에 만약 영속성 컨텍스트에 관리하고 있는 레코드(객체)가 있다면 벌크 연산이 적용되지 않은 채 영속성 컨텍스트에 그대로 관리될 수 있다. 그래서 이걸 해결하는 방법은 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하는 것이다. 또는 영속성 컨텍스트에 뭔가를 관리하기 전 벌크 연산을 먼저 수행하는 것이다.
반응형
SMALL
예를 들어보자.
멤버 3명이 있고 최초에 age를 0으로 설정했다. 그리고 영속성 컨텍스트에 멤버 3명을 영속시켰는데 그 이후에 벌크 연산으로 멤버 모두의 age값을 20으로 설정했다. 설정한 후 3명의 멤버 중 한명을 아무나 잡고 age를 찍어보면 내가 기대하는 건 20인데 0으로 나온다. 왜냐하면, 영속성 컨텍스트에 관리되는 멤버의 age는 0에서 변경된 상태가 아니다. 위에서도 말했지만 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문이다. 실제 결과 출력을 보면 다음과 같이 반영된 개수는 3개지만 그 중 하나를 임의로 찍었을 때 나이가 0으로 나온다. 이럴때가 문제가 될 수 있다.
그래서, 벌크 연산은 그냥 무조건 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하자.
플러시를 한다고 초기화하는게 아니다. 플러시는 영속 컨텍스트 데이터를 DB에 반영하는거고 초기화는 clear()를 호출해야 한다.
참고로, 이렇게 JPQL을 사용하면 무조건 사용하는 시점에 flush()를 호출한다. 벌크 연산도 마찬가지다. 그래서 JPQL 호출하기 전에 flush()를 먼저하고 JPQL을 실행한다. 근데 그거랑 clear()는 다른 얘기다. 이미 영속성 컨텍스트에서 관리하는 객체가 있으면 flush()를 하더라도 그 데이터는 그대로 남아있는거다. 그러니까 벌크 연산을 수행할 때 flush()가 자동 호출되지만, clear()를 하지 않은 상태에서 영속성 컨텍스트에 있는 데이터를 그대로 가져오면 DB를 거쳐서 가져오는게 아니고 영속성 컨텍스트에 있는 데이터를 가져오고 이 데이터는 벌크 연산에 적용되는 데이터라고 할 지라도 데이터가 반영되어 있는 상태가 아닌건 똑같다. 그래서 결론은 벌크 연산은 반드시 수행하고 clear()를 호출하자!
그런데! 이 역시도 Spring Data JPA를 사용하면 굉장히 깔끔하고 편리하게 해결해준다. 아래 코드를 보자.
interface UserRepository extends Repository<User, Long> {
@Modifying
@Query("delete from User u where u.role.id = ?1")
void deleteInBulkByRoleId(long roleId);
}
지금 저기 NamedQuery가 있다. 그리고 저건 벌크 연산이다. Role이 특정 Role인 유저들을 모두 지우니까.
그런데, 그 위에 @Modifying 애노테이션이 있다. 저 애노테이션은 영속성 컨텍스트를 clear()하는 애노테이션이다. 즉, 이 쿼리가 수행된 후 clear()를 호출해주는 애노테이션이다. 그래서 이렇게 이쁘고 간단하게 벌크 연산이 가능하다.
근데 여기서 NamedQuery가 주는 이점은 컴파일 시점에 쿼리에 문제가 있으면 잡아준다는 것이다.
이 덕분에 쿼리에 문제, 문법 오류가 있는 경우를 방지할 수 있게 된다. 나중에 Spring Data JPA를 사용하면 다음과 같이 정말 편리하고 보기 좋게 사용할 수 있다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u where u.emailAddress = ?1")
User findByEmailAddress(String emailAddress);
}
인터페이스에서 메서드 시그니쳐만 생성해놔도 @Query 어노테이션으로 쿼리를 작성하고 쿼리에 오류가 있는지도 잡아주고 가져다가 사용만 하면 된다.