728x90
반응형
SMALL

스프링 공부를 하면서 가장 자주 사용되며 단순한 그렇지만 중요한 구조에 대해 알아보려 한다.

그냥 내가 공부하고 코드를 작성할 때 유의하며 작성하면 더 좋은 구조가 될 것 같아 스스로 학습하기 위해 작성한다.

 

반응형
SMALL

 

가장 단순한 구조는 역할에 따라 3가지 계층으로 나누는 것이다.

 

프레젠테이션 계층

  • UI 관련 처리 담당
  • 웹 요청과 응답
  • 사용자 요청을 검증
  • 주 사용 기술: 서블릿과 HTTP같은 웹 기술, 스프링 MVC

서비스 계층

  • 비즈니스 로직을 담당
  • 주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성

데이터 접근 계층

  • 실제 데이터베이스에 접근하는 코드
  • 주 사용 기술: JDBC, JPA, Redis, Mongo,...

 

순수한 서비스 계층이란?

서비스 계층은 가급적 특정 기술을 의존하지 말아야 한다는 말은 어떤 의미일까?

시간이 흘러 웹(UI)과 관련된 기술이 변하고, 데이터 저장 기술을 다른 기술로 변경할 여지는 충분하다. 지금 사용하는 기술보다 더 좋은 기술이 탄생한다던가, 지금 사용하고 있는 기술이 요구사항을 만족할 수 없는 기술이라면 사용하다가도 변경해야 한다.

그러나, 그렇다 한들 비즈니스 로직은 사용하는 기술이 바뀐다고 변경하지 않는다. 서비스가 다루는 기능이 변하거나 새로운 기능이 추가, 변경되어야 하는 경우가 아니면 비즈니스 요구사항이 변경될 리 없고 그 요구사항을 다루는 로직도 기술에 의존해서 변경되면 안 된다. (물론 이상적인 경우일 때를 말한다)

 

그렇기 때문에 서비스 계층은 가급적 특정 기술에 의존하면 안 된다는 뜻이다. 그래서 서비스 계층에 대해 코드를 작성할 때도 이 점을 유의하면서 작성해야 한다. 예를 들어, 프레젠테이션 계층은 클라이언트가 접근하는 UI와 관련된 기술인 웹, 서블릿, HTTP와 관련된 부분을 담당해 준다. 그래서 서비스 계층을 이런 UI와 관련된 기술로부터 보호해 준다. 그 결과로, REST API를 사용하다가도 GraphQL로 변경하려 할 때 프레젠테이션 계층의 코드만 변경하고 서비스 계층은 변경하지 않아도 된다. 데이터 접근 계층은 데이터를 저장하고 관리하는 기술을 담당해 주고 그렇기에 JDBC, JPA와 같은 구체적인 데이터 접근 기술로부터 서비스 계층을 보호해 준다. 그 결과로, JDBC를 사용하다가 JPA로 변경하더라도 서비스 계층은 변경하지 않아도 된다. 왜냐하면 서비스 계층은 데이터 접근 계층에 직접 접근하는 게 아니라 데이터 접근 계층이 인터페이스를 제공하고 서비스 계층은 이 인터페이스에만 의존하면 되기 때문이다.

이로 인해 파생되는 긍정적인 부분은 유지보수가 수월해지고 테스트도 편리해진다.

이러한 이유로 인해 잘 만들어진 구조는 인터페이스와 그 인터페이스를 구현한 구현체가 나뉘는 것이고 사용자는 구현체가 어떤 식으로 구현했는지를 세세히 알 필요 없이 인터페이스를 가져다가 사용만 하면 되는 것이다. (예: DataSource 인터페이스와 이 인터페이스를 구현한 여러 가지 구현체(HikariDataSource, DriverManagerDataSource,...) 이것을 추상화라고 한다.

 

 

정리하자면..

구조에 정답이 있는 것은 아니지만, 더 나은 구조에 가까울수록 변경이 있을 때 변경에 대한 제약이 적다. 즉, 서비스 계층은 가급적 비즈니스 로직만 구현하고(순수 자바 코드로) 특정 구현 기술에 의존을 가급적 지양해야 한다. 그래야 이후 사용하는 기술을 변경하더라도 서비스 계층에 변경 영향 범위를 최소화할 수 있기 때문이다. 

 

서비스 계층에서 특정 기술에 의존하고 이 서비스 계층에 사용되는 비즈니스 로직이 별로 없으면 크게 문제가 될 것이 없지만, 만약 그 코드가 수만 줄 수십만 줄이라면 기술하나를 바꿨는데 수십만줄이 모두 변경되어야 한다. 그렇게 변경을 하면? 아주 매우 엄청 높은 확률로 사이드 이펙트가 발생할 것 같다..

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

결국은 한번 만나버린 OutofMemoryError.

회사에서 자바로 플러그인 개발을 하고 사용을 쭉 하다가 이런 에러가 생겼다.

 

우선, 어떻게 해결할지 난감해서 구글링을 계속 하면서 해결해보고자 했는데 아무리 해도 안된다.

처음 해본 방법은 다음과 같이 실행 명령어에 최대 크기를 늘리는것.

mvn clean package -DskipTests -DactivatedProperties=prod -Dspring-boot.run.jvmArguments="-Xmx8192m"

 

그러나 해결되지 않았다. 왜 해결되지 않는지 모르겠는데 왜 해결되지 않는지 구글링을 해보니 이러이러한 이유가 있다고 한다.

애플리케이션 실행에만 영향을 주는 옵션, 이 애플리케이션을 구동하는 JVM 자체에는 영향을 주지 않는다. (JVM의 최대 크기가 이것보다 작기 때문에 의미가 없다)

 

이 말은, 결국 JVM의 최대 크기 자체를 늘려야 한다는 것인데.. 어떻게 해야하나 ? 다시 검색 ! 

"export JAVA_OPTS="-Xmx512m"
 "export CATALINA_OPTS=-Xmx512m"

 

이 방법도 실패. 이유는 모든 JVM이 이를 인지하는 것이 아니라는 가능성.

 

마지막 방법을 찾았다. 내 경우에는 CentOS 배포판으로 된 Linux 서버에 올려놓은 플러그인이고 여기서 JVM 최대 Heap Size를 늘리는 방법은 /etc/profile에 맨 위에(어디에든 상관없긴 하다만) 다음과 같이 설정을 하면 된다.

export _JAVA_OPTIONS=-Xmx6g

 

그 후 파일 변경을 적용하기 위해 다음 명령어를 실행

source /etc/profile

 

그럼 적용한 값을 확인할 수 있는데 다음 명령어로 확인해보자.

java -XshowSettings:vm

 

다음과 같은 화면을 볼 수 있다. 내가 적용한 설정값이 반영된 상태로 Max Heap Size가 적용됐다. 

 

 

그런데, 나는 Docker Container 환경에서 서버를 구동중이다.. 그러니까 내 Linux 서버에서 JVM heap size를 늘리나 마나 아무런 의미가 없다. 그래서 만약 Container의 JVM heap size를 늘리고 싶으면 !

 

docker compose를 사용할 때와 이미지를 가지고 실행하는 경우 두 가지 방법이 있다.

나 같은 경우 docker compose를 사용하는데 그럴 경우에는 이렇게 적용하면 된다.

 

docker-compose.yml

environment:
	- _JAVA_OPTIONS=-Xmx8g

 

(tomcat) image를 가지고 컨테이너를 실행하는 경우 (이미지는 원하는 대로)

docker run -e JAVA_OPTS='-Xmx1g' tomcat

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

업데이트 (2024.12.21)


 

Spring Data JPA와 같이 사용하면 막강의 쿼리 작성을 할 수 있는 QueryDSL을 프로젝트에 설정하는 방법을 기록하고자 한다.

하도 버전에 따라 설치하는 방법이 달라져서 스프링 부트 3.1.5, Gradle에서 설치하는 방법을 작성했다. (아마 3.x.x라면 다 이 방법으로 하면 되지 않을까 싶다)

 

버전 정보

Software or Framework Version
Spring Boot 3.4.1
QueryDSL 5.1.0
Gradle 8.4
JDK 21

 

반응형
SMALL

 

시작하기

우선, build.gradle 파일에서 아래와 같은 dependencies를 추가해준다.

 

build.gradle

// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

그리고, 같은 파일에서 아래와 같은 설정이 필요하다.

// 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 파일들이 정상적으로 생성되었음을 알 수 있다.

 

728x90
반응형
LIST

'Querydsl' 카테고리의 다른 글

참고: 리포지토리 지원 - QuerydslRepositorySupport  (0) 2024.12.30
JPA 레포지토리와 Querydsl  (2) 2024.12.26
Querydsl 중급 문법  (0) 2024.12.22
Querydsl 기본 문법  (0) 2024.12.21
[Renewal] QueryDsl  (0) 2024.12.07
728x90
반응형
SMALL

2024.11.10 업데이트


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를 비활성화한 경우엔 모든 지연 로딩 엔티티를 트랜잭션 안에서 처리해야 하므로 다음과 같이 해결할 수 있을 것이다.

  • 페치 조인을 사용해 데이터를 쿼리 하는 시점에 모두 받아온다.
  • 페치 조인으로 모두 받아오지 못할 경우 지연 로딩을 초기화하는 새로운 트랜잭션 안에서 처리한다.
  • 하나의 트랜잭션에서 모든 지연 로딩 엔티티를 초기화한다.

 

페치 조인으로 모든 데이터를 전부 한 번에 로딩

@GetMapping("orders")
public List<OrderDto> orders() {
    List<Order> orders = orderRepository.findAllWithItem();
    return orders.stream().map(OrderDto::new).toList();
}

 

위 코드는 컨트롤러에서 주문 정보를 모두 가져와서 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로 변환하는 이 코드이다.

return orders.stream().map(OrderDto::new).toList();

 

이 부분을 처리할 때 DTO 클래스가 생성자에서 지연 로딩 엔티티를 처리하기 때문에 문제가 발생하는데 이 코드 처리를 새로운 트랜잭션에서 하면 된다.

package com.example.jpabook.service;

import com.example.jpabook.domain.Order;
import com.example.jpabook.domain.OrderDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class OrderQueryService {

    public List<OrderDto> transferOrderDto(List<Order> orders) {
        return orders.stream().map(OrderDto::new).toList();
    }
}

 

새로운 트랜잭션을 가지는 OrderQueryService 클래스를 가지고 처리하는 로직을 둔다. 그리고 이 클래스의 메서드 transferOrderDto()를 컨트롤러에서 호출 후 반환한다.

@GetMapping("orders")
public List<OrderDto> orders() {
    List<Order> orders = orderRepository.findAllWithItem();
    return orderQueryService.transferOrderDto(orders);
}

 

 

하나의 트랜잭션에서 모든 것을 처리

즉, 서비스 또는 레포지토리에서 모든것을 다 처리한 후 컨트롤러는 받기만 하고 돌려주는 것을 말한다.

나는 비즈니스 로직을 레포지토리에 넣는 것을 매우 싫어하므로 (그래서도 안되고 항상 참조 방향은 다음과 같아야 한다).

컨트롤러 -> 서비스 -> 레포지토리, 엔티티 

역방향이 되면 안 된다. 유지보수에 굉장히 큰 타격을 주므로. 

아무튼 지금 코드에서 레포지토리에서 모든 로직을 넣지 않을 것이다. 근데 만약 서비스를 호출해서 처리하는 경우라면 서비스 안에서 DTO로 변환까지 같이 해준 데이터를 컨트롤러가 받으면 된다.

 

 

결론

OSIV를 활성화 또는 비활성화는 Trade-off가 있다. 그래서 뭐가 더 좋고 나쁘다가 아니라 상황에 맞게 사용하면 된다. 예를 들어 트래픽이 많이 필요 없는 애플리케이션은 활성화 상태로 두면 코드의 복잡도가 떨어지는 장점을 가지면서 애플리케이션에 장애 없이 서비스를 할 수 있을 것이다. 그러나 트래픽이 많은 애플리케이션은 이 옵션을 활성화해서는 안된다. 트랜잭션이 부족할 가능성이 농후하기 때문에. 따라서 상황에 맞게 사용하는 것이 중요하다고 본다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] 엔티티와 인덱스  (0) 2024.11.10
[JPA] Dirty Checking, Merge  (8) 2024.11.10
[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
728x90
반응형
SMALL

회사 프로젝트에서 Pie Chart와 일 별 데이터 카운트를 보여줄 대시보드가 필요했다. 찾아봤는데 좋은 라이브러리가 있어서 공유겸 공부한 것을 까먹지 않기 위해 작성해보고자 한다.

 

반응형
SMALL

 

우선 라이브러리 이름은 'nivo'이다.

https://nivo.rocks/

 

Home | nivo

 

nivo.rocks

 

Install

우선, 다음 명령어로 nivo의 코어가 되는 패키지를 내려받아야 한다.

npm i @nivo/core

 

그리고, 원하는 차트별로 패키지를 내려받으면 된다. 예를 들어 내가 파이차트를 사용하고 싶다면 다음처럼 명령어를 입력하자.

npm i @nivo/pie

 

물론, 매뉴얼에서 친절하게 다 알려준다.

 

사이트가 굉장히 모던하면서 매뉴얼도 극강으로 사용자 경험이 좋다. 들어가보면 여러 차트를 사용할 수 있는데 아래 화면을 보자.

위 화면에서 원하는 차트를 선택하면 해당 차트에 대한 코드 및 매뉴얼이 아주 아주 친절하게 나와있다.

 

나는 파이차트를 선택해서 들어가봤더니 오른쪽엔 파이차트를 뿌렸을 때 보여지는 모양새가 따란 나와있고, 왼쪽은 그 파이차트에 원하는 속성 및 사용가능한 속성을 정의해놨다. 그리고 이게 진짜 좋은게 왼쪽에서 원하는 속성의 값을 변경할 수 있는데 그 값이 오른쪽 차트에 즉각 반영된다.

예를 들어, 테두리의 두께와 색을 변경한다고 해보자. 다음과 같이 색상을 빨간색, 두께를 12px로 변경을 했다.

 

그러면 다음처럼 바로 그 값이 반영된 모습으로 보여진다.

 

더 좋은건 이렇게 반영된 리액트 코드를 나를 위해 알아서 짜준다는 것.

 

그대로 가져다가 사용만하면 된다. 꽤나 괜찮은 라이브러리이고 매뉴얼 또한 아주 준수하다.

 

결과

이렇게 가져다가 사용하여 만든 회사 프로젝트의 대시보드는 다음처럼 생겼다.

 

정말 많은 차트가 있고, 가져다가 사용하기가 너무 수월해서 공유하는 마음으로 작성해보았다.

728x90
반응형
LIST
728x90
반응형
SMALL

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를 내려받자.

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

 

(참고로 Spring ^3.0 에서는 Jackson Datatype Hibernate5 Jakarta를 사용해야 한다.)

@Bean
public Hibernate5JakartaModule hibernate5Module() {
    return new Hibernate5JakartaModule();
}

 

이렇게 @Bean으로 등록한 후 다시 요청해보면 비로소 다음과 같은 정상 결과가 출력된다.

 

그러나, null 값으로 나오는 것도 지저분하고 필요없는 데이터까지 전부 보여진다는 것도 좋은 응답은 아니다. 그렇기 때문에 더 좋은 방법이 필요한데 그 방법은 DTO를 추가적으로 만들어서 리턴하는 것이다.   

SMALL
@GetMapping("orders")
public List<OrdersResponse> orders() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    return orders.stream().map(OrdersResponse::new).toList();
}

    @Data
    static class OrdersResponse {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public OrdersResponse(Order order) {
            orderId = order.getId();
            name = order.getMember().getUsername();
            orderDate = order.getOrderDate();
            orderStatus = order.getOrderStatus();
            address = order.getDelivery().getAddress();
        }
    }

이처럼 이너클래스로 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();
    }
}

 

728x90
반응형
LIST
728x90
반응형
SMALL

 

서버에 Docker를 사용해서 Containerized app을 실행중이었는데, 어떤 이유에선지 실행중인 컨테이너가 다 내려가는 이상한 일이 발생했다. 그래서 다시 컨테이너를 마운트하는데 내가 사용하려는 포트를 이미 사용중이라고 나온다.

6e4b0ce8199e3364a9a979e07a611ae912aa0cad06fac0508d2c32df8a4a83d7
docker: Error response from daemon: driver failed programming external connectivity on endpoint katech-web (e2869131fb101ad044163633db6a1f72a8057a6bb39f9e42ce2f6684f1ac0cf3): Error starting userland proxy: listen tcp4 0.0.0.0:8824: bind: address already in use.

 

뭐지 싶어서 프로세스를 다 띄워 봤다.

❯ netstat -lntp                                                                                                                                                                                                                            ─╯
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 192.168.0.2:9000        0.0.0.0:*               LISTEN      4177698/java
tcp        0      0 192.168.0.2:9870        0.0.0.0:*               LISTEN      4177698/java
tcp        0      0 0.0.0.0:8824            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:8829            0.0.0.0:*               LISTEN      968191/python3
tcp        0      0 0.0.0.0:8822            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:8820            0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:44053         0.0.0.0:*               LISTEN      3623312/node
tcp        0      0 0.0.0.0:8088            0.0.0.0:*               LISTEN      4178189/java
tcp        0      0 0.0.0.0:8032            0.0.0.0:*               LISTEN      4178189/java
tcp        0      0 0.0.0.0:8033            0.0.0.0:*               LISTEN      4178189/java
tcp        0      0 0.0.0.0:8030            0.0.0.0:*               LISTEN      4178189/java
tcp        0      0 0.0.0.0:8031            0.0.0.0:*               LISTEN      4178189/java
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -
tcp        0      0 127.0.0.1:45927         0.0.0.0:*               LISTEN      2354740/node
tcp        0      0 0.0.0.0:3306            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:2306            0.0.0.0:*               LISTEN      -
tcp6       0      0 :::8824                 :::*                    LISTEN      -
tcp6       0      0 :::8822                 :::*                    LISTEN      -
tcp6       0      0 :::8820                 :::*                    LISTEN      -
tcp6       0      0 :::80                   :::*                    LISTEN      -
tcp6       0      0 :::22                   :::*                    LISTEN      -
tcp6       0      0 :::3306                 :::*                    LISTEN      -
tcp6       0      0 :::2375                 :::*                    LISTEN      -
tcp6       0      0 :::2306                 :::*                    LISTEN      -

8824를 LISTEN중이라는데 PID가 없다. 뭐지? 종료하고 싶은데..

이럴 땐, sudo를 사용해야했다.

sudo netstat -antlp | grep 8824

tcp        0      0 0.0.0.0:8824            0.0.0.0:*               LISTEN      2519703/docker-prox
tcp6       0      0 :::8824                 :::*                    LISTEN      2519715/docker-prox

pID가 다 보이고 KILL 성공

 

반응형
SMALL
728x90
반응형
LIST
728x90
반응형
SMALL

Spring boot로 개발을 하고 서버사이드 렌더링을 위해 Thymeleaf를 사용하다보면 template 파일 수정이 빈번하게 일어난다.

그 때마다 변경사항을 반영하기 위해 서버를 재시작하기는 꽤 귀찮은 일이다. 이 때 개발환경에서는 spring-boot-devtools라는 라이브러리를 사용해서 좀 더 간편하게 변경사항을 반영시킬 수 있다.

 

build.gradle

implementation 'org.springframework.boot:spring-boot-devtools'

build.gradle 파일에서 spring-boot-devtools 라이브러리를 하나 추가해준다.

 

서버를 재시작 하면 서버 실행 로그의 쓰레드의 이름이 restartedMain으로 보여진다. 이러면 spring-boot-devtools가 잘 적용된 것이다. thymeleaf 엔진을 사용하는 템플릿 파일이 다음과 같다고 해보자. 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
	<p th:text="'안녕하세요!! ' + ${data}">안녕하세요 손님.</p>
</body>
</html>

이 템플릿을 뿌려주는 컨트롤러가 지정한 URL로 브라우저를 띄워 입력해 들어가보면 다음처럼 나온다.

 

내가 근데 다음과 같이 텍스트를 수정했을 때, 이 수정한 내용을 서버에 바로 반영하고 싶다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
	<p th:text="'안녕하세요 바뀐내용!! ' + ${data}">안녕하세요 손님.</p>
</body>
</html>

그럴 때 이제 spring-boot-devtools로 서버를 실행한 후 Build > Recompile을 클릭하면 된다.

이렇게 하고 다시 들어가보면 변경 사항이 바로 적용되어 있다. 서버를 재시작하지 않아도 된다!

반응형
SMALL

 

728x90
반응형
LIST
728x90
반응형
SMALL

2024.11.01 업데이트


쿼리 한 번으로 여러 테이블 로우(레코드)를 변경하는 걸 말한다. UPDATE, DELETE, INSERT 같은 것들이 이제 벌크 연산이 가능하다.

뭐 별건 아닌데 조금 주의할 사항이 있다.

 

벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다.

이게 뭐가 문제냐면 벌크 연산을 처리한 전과 후에 만약 영속성 컨텍스트에 관리하고 있는 레코드(객체)가 있다면 벌크 연산이 적용되지 않은 채 영속성 컨텍스트에 그대로 관리될 수 있다. 그래서 이걸 해결하는 방법은 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하는 것이다. 또는 영속성 컨텍스트에 뭔가를 관리하기 전 벌크 연산을 먼저 수행하는 것이다. 

반응형
SMALL

 

예를 들어보자.

멤버 3명이 있고 최초에 age0으로 설정했다. 그리고 영속성 컨텍스트에 멤버 3명을 영속시켰는데 그 이후에 벌크 연산으로 멤버 모두의 age값을 20으로 설정했다. 설정한 후 3명의 멤버 중 한명을 아무나 잡고 age를 찍어보면 내가 기대하는 건 20인데 0으로 나온다. 왜냐하면, 영속성 컨텍스트에 관리되는 멤버의 age0에서 변경된 상태가 아니다. 위에서도 말했지만 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문이다. 실제 결과 출력을 보면 다음과 같이 반영된 개수는 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()를 호출해주는 애노테이션이다. 그래서 이렇게 이쁘고 간단하게 벌크 연산이 가능하다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Dirty Checking, Merge  (8) 2024.11.10
[JPA] OSIV (Open Session In View)  (0) 2023.11.16
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
728x90
반응형
SMALL
반응형
SMALL

2024.11.01 업데이트


NamedQuery란, 문자열로만 이루어진 쿼리문에 이름을 부여해서 가져다가 사용할 수 있는 방식을 말하는데, 이게 굉장히 유용하다. 특히 Spring Data JPA와 같이 사용하게 된다면.

 

우선 Spring Data JPA가 없다고 가정했을 때 작성하는 방법은 @Entity 어노테이션이 있는 엔티티에 @NamedQuery 어노테이션을 사용하면 된다.

 

@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username")
public class Member {
	...
}
  • 이처럼 작성하면 된다. 그리고 name은 마음대로 지어도 되지만, 이렇게 엔티티 위에 작성하는 경우에는 `엔티티.Xxx`로 사용하는게 관례다. 
  • 사용은 아래 코드처럼 하면 된다.
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
                            .setParameter("username", "회원1")
                            .getResultList();

 

근데 여기서 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 어노테이션으로 쿼리를 작성하고 쿼리에 오류가 있는지도 잡아주고 가져다가 사용만 하면 된다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] OSIV (Open Session In View)  (0) 2023.11.16
[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 값 타입 컬렉션  (0) 2023.10.25

+ Recent posts