Chapter 4: 리포지터리와 모델 구현

💡 책에서 기억하고 싶은 내용

JPA를 이용한 리포지터리 구현

  • 모듈 위치

    • 리포지터리 인터페이스는 애그리거트와 같이 도메인 영역 에 속하고,

    • 리포지터리를 구현한 클래스인프라스트럭처 영역 에 속한다

      • 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치 시켜서 인프라스트럭처 영역에 대한 의존을 낮춰야 한다

리포지터리 기본 기능 구현

  • 인터페이스는 애그리거트 루트를 기준으로 작성한다

  • 애그리거트를 조회하는 기능의 이름을 지을 때 findBy프로퍼티이름(프로퍼티 값) 형식이 널리 사용된다

    • ex) findById()

    • ID 외에 다른 조건으로 애그리거트를 조회할 때는 JPA의 CriteriaJPQL 을 사용할 수 있다

  • 애그리거트를 수정 한 결과를 저장소에 반영하는 method는 추가할 필요 없다

    • JPA 를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영한다!

  • 애그리거트를 삭제 하는 기능을 위한 method는 삭제할 애그리거트 객체를 parameter로 전달받는다

    • 참고 💡

      • 삭제 요구사항이 있더라도 데이터를 실제로 삭제하는 경우는 많지 않다

        • 관리자 기능에서 삭제한 데이터까지 조회해야 하는 경우도 있고, 데이터 원복을 위해 일정 기간 동안 보관해야 할 때도 있기 때문이다!

      • 그래서 사용자가 삭제 기능을 실행할 때 데이터를 바로 삭제하기보다는 삭제 플래그 를 사용해서 데이터를 화면에 보여줄지 여부를 결정하는 방식으로 구현한다

스프링 데이터 JPA를 이용한 리포지터리 구현

  • 스프링과 JPA를 함께 적용할 때는 스프링 데이터 JPA 를 사용한다

    • 스프링 데이터 JPA는 지정한 규칙에 맞게 리포지터리 인터페이스 를 정의하면, 구현한 객체를 알아서 만들어 스프링 빈 으로 등록해 준다

    • 리포지터리 인터페이스를 직접 구현하지 않아도 되기 때문에 개발자는 리포지터리를 쉽게 정의할 수 있다

  • 스프링 데이터 JPA는 다음 규칙에 따라 작성한 인터페이스를 찾아서 인터페이스를 구현한 스프링 빈 객체 를 자동으로 등록한다

    • org.springframework.data.repository.Repository<T, ID> 인터페이스 상속

    • T는 엔티티 타입 을 지정하고 ID는 식별자 타입 을 지정

매핑 구현

  • 애그리거트 루트는 엔티티이므로 @Entity 로 매핑을 설정한다

  • 한 테이블에 엔티티밸류 데이터 가 같이 있다면,

    • 밸류는 @Embeddable 로 매핑을 설정한다

    • 밸류 타입 프로퍼티는 @Embedded 로 매핑 설정한다

    • @Embeddable 타입에 설정한 칼럼 이름과 실제 칼럼 이름이 다를 때에는 @AttributeOverrides annotation을 사용해서 매핑할 칼럼 이름을 변경한다

기본 생성자

  • 엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달받는다

    • ex)

      • Receiver 밸류 타입이 불변 타입이면, 생성 시점에 필요한 값을 모두 전달받으므로 값을 변경하는 set method 를 제공하지 않는다

      • Receiver class에 기본 생성자(parameter가 없는) 를 추가할 필요가 없다는 것을 의미한다

      • but, JPA에서 @Entity@Embeddable 로 class를 매핑하려면 기본 생성자 를 제공해야 한다

        • DB에서 데이터를 읽어와 매핑된 객체를 생성할 때 기본 생성자를 사용해서 객체를 생성하기 때문이다!

      • 그래서 불변 타입은 기본 생성자가 필요 없음에도 불구하고 기본 생성자를 추가해야 한다

  • 기본 생성자는 JPA Provider 가 객체를 생성할 때만 사용한다

    • 기본 생성자를 다른 코드에서 사용하면 값이 온전하지 못한 객체를 만들게 된다

    • 그래서 다른 코드에서 기본 생성자를 사용하지 못하도록 protected 로 선언한다

필드 접근 방식 사용

  • 객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면, JPA 매핑 처리를 Property 방식이 아닌 Field 방식으로 선택해서 불필요한 get/set method를 구현하지 말아야 한다

  • JPA 구현체인 Hibernate@Access 를 이용해서 명시적으로 접근 방식을 지정하지 않으면 @Id@EmbededId 가 어디에 위치 했느냐에 따라 접근 방식 을 결정한다

    • @Id@EmbededIdfield 에 위치하면 field 접근 방식을 선택하고,

    • get method 에 위치하면 method 접근 방식을 나타낸다

AttributeConverter 를 이용한 밸류 매핑 처리

  • int, long, String, LocalDate와 같은 타입은 DB 테이블의 한 개 column에 매핑된다

    • 비슷하게, 밸류 타입의 property를 한 개 column에 매핑해야 할 때도 있다

    • ex)

      • Length class가 value(값)와 unit(단위) 두 property를 갖고 있는데, DB 테이블에는 한 개 column에 ‘1000mm’ 와 같은 형식으로 저장할 수 있다

  • 두 개 이상의 property를 가진 밸류 타입을 한 개 column에 매핑할 때 사용하는 것이 AttributeConverter 이다

    • AttributeConverter 는 밸류 타입과 칼럼 데이터 간의 변환을 처리하기 위한 기능을 정의하고 있다

      package javax.persistence;
      
      public interface AttributeConverter<X,Y> {
      
          public Y convertToDatabaseColumn (X attribute);
      
          public X convertToEntityAttribute (Y dbData);
    • 타입 파라미터 X는 밸류 타입 이고, Y는 DB 타입 이다

    • convertToDatabaseColumn() method는 밸류 타입 → DB Column으로 변환하는 기능을 구현하고,

    • convertToEntityAttribute() method는 DB Column → 밸류타입으로 변환하는 기능을 구현한다

  • @Converter annotation에 autoApply 속성값을 true로 지정하면, 모델에 출현하는 모든 해당 밸류 타입의 property에 대해 Converter를 자동으로 적용한다

    • default인 false로 지정하면, Property 값을 변환할 때 사용할 converter를 직접 지정해야 한다

밸류 컬렉션: 별도 테이블 매핑

  • 밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection@CollectionTable 을 함께 사용한다

  • JPA는 @OrderColumn annotation을 이용해서 지정한 Column에 List의 인덱스 값을 저장한다

  • @CollectionTable 은 밸류를 저장할 테이블을 지정한다

밸류 컬렉션: 한 개 칼럼 매핑

  • AttributeConverter 를 사용하면 밸류 컬렉션을 한 개 칼럼에 쉽게 매핑할 수 있다

    • 단, AttributeConverter 를 사용하려면 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다

    • ex) EmailSet class

밸류를 이용한 ID 매핑

  • 식별자 라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있다

  • 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId annotation을 사용한다

  • JPA에서 식별자 타입은 Serializable 타입이어야 하므로, 식별자로 사용할 밸류 타입은 Serializable 인터페이스를 상속받아야 한다

  • 밸류 타입으로 식별자를 구현할 때 장점

    • 식별자에 기능을 추가할 수 있다

      • ex) 1세대 시스템 주문번호와 2세대 시스템 주문번호를 구분할 때 주문번호 첫 글자를 이용할 경우, 해당 class에 시스템 세대를 구분할 수 있는 기능을 구현할 수 있다

  • JPA는 내부적으로 엔티티를 비교할 목적으로 equals()hashcode() 값을 사용하므로, 식별자로 사용할 밸류 타입은 이 두 method를 구현해야 한다

별도 테이블에 저장하는 밸류 매핑

  • 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자 를 갖는지를 확인하는 것이다

  • but, 식별자를 찾을 때 매핑되는 테이블의 식별자를 애그리거트 구성요소의 식별자와 동일한 것으로 착각하면 안 된다

    • 별도 테이블로 저장하고, 테이블에 PK가 있다고 해서 테이블과 매핑되는 애그리거트 구성요소가 항상 고유 식별자 를 갖는 것은 아니기 때문이다

  • 밸류는 @Embeddable 로 매핑한다

    • 이때 밸류를 매핑 한 테이블을 지정하기 위해 @SecondaryTable@AttributeOverride 를 사용한다

  • @SecondaryTable

    • name 속성은 밸류를 저장할 테이블을 지정한다

    • pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다

  • @AttributeOverride 를 사용해서 해당 밸류 데이터가 저장된 테이블 이름을 지정한다

  • @SecondaryTable 을 이용하면 아래 코드를 실행할 때 두 테이블을 조인해서 데이터를 조회한다

    • ex)

      // @SecondaryTable로 매핑된 article_content 테이블을 조인
      Article article = entityManager.find(Article.class, 1L);

밸류 컬렉션을 @Entity 로 매핑하기

개념적으로 밸류인데 구현 기술의 한계나 팀 표준 때문에 @Entity 를 사용해야 할 때도 있다

  • JPA는 @Embedable 타입 클래스 상속 매핑을 지원하지 않는다

    • 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity 를 이용해서 상속 매핑 으로 처리해야 한다

    • 밸류 타입을 @Entity 로 매핑하므로 식별자 매핑을 위한 필드도 추가해야 한다

    • 또한 구현 클래스를 구분하기 위한 타입 식별(discriminator) 칼럼을 추가해야 한다

  • ex)

    • 한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용한다

      • @Inheritance annotation 적용

      • strategy 값으로 SINGLE_TABLE 적용

      • @DiscriminatorColumn annotation을 이용하여 타입 구분용으로 사용할 칼럼 지정

    • Image로 @Entity 를 매핑했지만 모델에서 Image는 밸류이므로 상태를 변경하는 기능 은 추가하지 않는다

    • Image를 상속받은 클래스는 @Entity@Discriminator 를 사용해서 매핑을 설정한다

  • but, @Entity 에 대한 @OneToMany 매핑에서 컬렉션의 clear() method를 호출하면 삭제 과정이 효율적이지 않다

    • 하이버네이트의 경우 @Entity 를 위한 컬렉션 객체의 clear() method를 호출하면 select 쿼리로 대상 엔티티를 로딩하고, 각 개별 엔티티에 대해 delete 쿼리를 실행한다

      • 즉, images 에 보함되어 있던 Image 개수가 4개면 목록을 가져오기 위한 1번의 select 쿼리와, 각 Image를 삭제하기 위한 4번의 delete 쿼리를 실행한다

      • 변경 빈도가 높으면 이것으로 인해 전체 서비스 성능 에 문제가 생길 수 있다

    • 이 문제를 해결하려면 결국 상속을 포기하고 @Embeddable 로 매핑된 단일 클래스로 구현해야 한다

      • 성능을 위해 다형성을 포기하고 if-else로 구현해야 한다

  • 코드 유지 보수와 성능의 두 가지 측면을 고려해서 구현 방식을 선택해야 한다

ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑

  • 애그리거트 간 집합 연관은 성능 상의 이유로 피해야 하지만, 요구사항을 구현하는데 집합 연관을 사용하는 것이 유리하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다

  • ID 참조를 이용한 애그리거트 간 단방향 M-N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정할 수 있지만, 차이점이 있다면 집합의 값에 밸류 대신 연관을 맺는 식별자 가 온다는 점이다

애그리거트 로딩 전략

  • JPA 매핑을 설정할 때 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다

    • 즉, 다음과 같이 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태 여야 한다

      // product는 완전한 하나여야 한다
      Product product = productRepository.findById(id);
  • 조회 시점 에서 애그리거트를 완전한 상태가 되도록 하려면, 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩(FetchType.EAGER) 으로 설정하면 된다

    • find() method로 애그리거트 루트를 구할 때, 연관된 구성 요소를 DB에서 함께 읽어온다

    • 장점으로는 애그리거트를 로딩하는 시점에 애그리거트에 속한 모든 객체를 함께 로딩할 수 있다는 것이 있지만,

    • 단점 으로 컬렉션에 대해 로딩 전략을 FetchType.EAGER 로 설정했을 때 발생하여 문제가 될 수 있다

      • 보통 조회 성능 문제 때문에 즉시 로딩 방식을 사용하지만, 이렇게 조회되는 데이터 개수가 많아지면 즉시 로딩 방식을 사용할 때 성능 (실행 빈도, 트래픽, 지연 로딩 시 실행속도 등)을 검토해 봐야한다

  • 애그리거트는 개념적으로 하나여야 한다

    • but, 루트 엔티티를 로딩하는 시점에 애그리거트가 속한 객체를 모두 로딩해야 하는 것은 아니다

    • 애그리거트가 완전해야 하는 이유 두 가지 1. 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하기 때문 - but, 일반적인 애플리케이션은 상태 변경 기능을 실행하는 빈도보다 조회 기능을 실행하는 빈도가 훨씬 높다 - 그러므로 상태 변경을 위해 지연 로딩 을 사용함으로써 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다 2. 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문 - but, 별도의 조회 전용 기능과 모델을 구현하는 방식이 더 유리하므로 이것은 주된 이유가 아님

  • 이런 이유로 애그리거트 내의 모든 연관을 즉시 로딩 으로 설정할 필요는 없다

    • 지연 로딩 은 동작 방식이 항상 동일하기 때문에 즉시 로딩처럼 경우의 수를 따질 필요가 없는 장점이 있다

      • 즉시 로딩 설정은 @Entity@Embeddable 에 따라 다르게 동작하고, JPA Provider에 따라 구현 방식이 다를 수 있다

    • 물론 지연 로딩이 즉시 로딩보다 쿼리 실행 횟수가 많아질 가능성이 더 높다

      • 따라서 무조건 즉시 로딩 or 지연 로딩으로 설정하기 보단, 애그리거트에 맞게 선택해야 한다

애그리거트의 영속성 전파

  • 애그리거트가 완전한 상태 여야 한다는 것은, 애그리거트를 조회 할 때 뿐만 아니라 저장 하고 삭제 할 때도 하나로 처리해야 함을 의미한다

    • 저장 method는 애그리거트 루트만 저장하면 안 되고, 애그리거트에 속한 모든 객체를 저장해야 한다

    • 삭제 method는 애그리거트 루트 뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다

  • @Embeddable 매핑 타입은 함께 저장되고 함께 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다

  • 반면 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장 / 삭제 시 함께 처리되도록 설정해야 한다

    • @OneToOne , @OneToMany 는 cascade 속성 기본값이 없으므로 속성값으로 CascadeType.PERSIST, CascadeType.REMOVE 를 설정한다

식별자 생성 기능

  • 식별자는 크게 세 가지 방식 중 하나로 생성한다

    1. 사용자가 직접 생성

    2. 도메인 로직으로 생성

    3. DB를 이용한 일련번호 사용

  • 식별자 생성 규칙 이 있다면, Entity를 생성할 때 식별자를 Entity가 별도 서비스로 식별자 생성 기능을 분리해야 한다

    • 식별자 생성 규칙은 도메인 규칙 이므로 도메인 영역 에 식별자 생성 기능을 위치시켜야 한다

    • 도메인 서비스 혹은 리포지터리 가 식별자 생성 규칙을 구현하기에 적합하다

      • 리포지터리 인터페이스 에 식별자 생성 method를 추가하고, 리포지터리 구현 클래스에서 알맞게 구현하면 된다

도메인 구현과 DIP

  • DIP에 따르면 @Entity , @Table구현 기술에 속하므로 Article과 같은 도메인 모델은 구현 기술은 JPA에 의존하지 말아야 하는데 해당 장에서 다룬 코드는 도메인 모델이 영속성 구현 기술 인 JPA에 의존하고 있다

  • 리포지터리 인터페이스도 마찬가지로, 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA 의 Repository 인터페이스를 상속하고 있다

    • 즉, 도메인이 인프라에 의존하는 것이다

  • 구현 기술에 대한 의존 없이 도메인을 순수하게 유지 하려면,

    • 스프링 데이터 JPARepository 인터페이스를 상속받지 않도록 수정하고,

    • Repository 인터페이스를 구현한 클래스인프라 에 위치시켜야 한다

    • 또한 클래스에서 @Entity@Table 과 같이 JPA에 특화된 annotation을 모두 지우고, 인프라에 JPA를 연결하기 위한 클래스를 추가해야 한다

  • 이렇게 함으로써 구현 기술을 변경하더라도, 도메인이 받는 영향을 최소화 할 수 있다

  • DIP를 적용하는 주된 이유는 저수준 구현 이 변경되더라도, 고수준 이 영향 받지 않도록 하기 위함이다

    • but, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다

    • 이렇게 변경이 없는 상황에서 변경을 미리 대비하는 것은 과할 수 있다

Last updated