효율적으로 연관관계 맺기 - 3

효율적으로 연관관계 맺기 - 3

JPA의 기본 사항을 정리한다.

[Good] OneToOne @MapsId로 연결하기

OneToOne은 RDBMS에서 고유한 외래키를 통해 부모와 자식이 ‘연결’된다.

@MapsId를 안 쓸 경우

  • 단방향
    • 보통 자식쪽에 @OneToOne(fetch = FetchType.LAZY) @JoinColumn(부모_id) 가 선언된다.
    • 부모에서 자식의 식별자를 모르기 때문에 부모 -> 자식 검색을 위해 추가적인 조회 메서드가 필요하다. (연관관계를 통한)
  • 양방향
    • 부모를 가져올 때, LAZY를 양 측에 선언하더라도 쿼리가 부모,자식 두 번 발생한다. ```sql [Hibernate] select a1_0.id, a1_0.age, a1_0.genre, a1_0.name from author a1_0 where a1_0.id=? [Hibernate] select b1_0.id, b1_0.author_id, b1_0.isbn, b1_0.title from book b1_0 where b1_0.author_id=?

```


단방향 @MapsId 사용시 이점

//file: `단방향 OneToOne MapsId 사용`
@Entity
public class Book implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    private Long id;

    private String title;
    private String isbn;

    @MapsId
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

  public Author getAuthor() {
    return author;
  }

  public void setAuthor(Author author) {
    this.author = author;
  }

}
  • Author의 Id가 Book의 식별자에 들어가기 때문에 따로 자동 배정 선언을 해줄 필요 없다.
  • 단방향에서만 가능하다.
  • Insert시에도 컬럼이 하나 줄어든다.
    -- file: `단방향 MapsId 자식 Insert문` 
      insert 
      into
          book
          (isbn, title, author_id) 
      values
          (?, ?, ?)
    
    • author_id가 book의 식별자로 등록된다.
  • 식별자가 공유되니까 부모의 id로 자식을 findById(부모.getId()) 로 가져오기 가능하다.
  • Book이 2차캐시에 있으면 일반 단방향@OneToOne에서 발생하는 추가쿼리 없이 캐시에서 가져온다.
  • 양방향 @OneToOne에서 발생하는 불필요한 자식 select query를 호출하지 않는다. (부모에 연관관계가 없기 때문)
  • 기본키와 외래키를 모두 인덱싱 할 필요가 없어 메모리 사용량이 줄어든다.

[Good] 엔티티 연관관계 유효성 검사 추가하기

BeanValidation API를 사용하여 엔티티 연관관계에서 유효성 검사를 추가할 수 있다.

Bean Validation API란?

Constrain Once, validate everywhere

위의 문구 신념에 맞게, 스프링의 Validation 의존과 책임을 레이어에서 분리해 DomainModel에 선언한다.
Bean Validation API는 명세고 구현체로 보통 Hibernate Validation을 선택한다.

Bean Validation 사용 순서

  1. 제약조건 Constraints 선언
  2. ConstraintValidation 에 유효성 검증 구현체 추가
  3. 검증을 원하는 DomainModel에 적용
  4. Validator Factory 에서 Validator 인스턴스 생성
  5. Validator로 빈 검증
  6. 유효성 검증 실패시 생성되는 ConstraintViolation 이용해서 오류 처리

순서는 위와 같은데, 엔티티 연관관계 제약조건 예시는 JPARepository로 저장한 시점에 알아서 검증을 해준다.


@Constraints 선언

//file: `@Constraint`
@Documented
@Target({ ANNOTATION_TYPE })
@Retention(RUNTIME)
public @interface Constraint {
Class<? extends ConstraintValidator<?, ?>>[] validatedBy();
}
  • @Target({ ANNOTATION_TYPE }): ANNOTATION에 붙일 수 있는 타입이다.
  • RetentionPolicy.RUNTIME: 어노테이션이 컴파일된 클래스 파일에 포함되고, 런타임 시에도 리플렉션을 통해 주석 정보를 사용할 수 있다.
  • Class<? extends ConstraintValidator<?, ?»[] validatedBy()
    • @Constraint를 사용할 때 validatedBy속성을 정의하는데, 이는 ConstraintValidator<?, ?> 의 구현체여야 한다.
    • 구체적인 isValid() 메서드로 검증 로직을 짜는 부분이다.
      이 @Constraint 어노테이션을 내가 만들고 싶은 어노테이션에 붙여주면 된다.


Constraint에 남아있는 주석을 살펴보자.

애노테이션을 Jakarta Bean Validation 제약 조건으로 표시합니다.
주어진 제약 조건 애노테이션은 @Constraint 애노테이션으로 표시되어야 하며, 이는 제약 조건 유효성 검사 구현 목록을 참조합니다.
각 제약 조건 애노테이션은 다음 속성을 포함해야 합니다

String message() default […]: 이는 제약 조건의 정규화된 클래스 이름 뒤에 .message를 붙인 오류 메시지 키로 기본 설정되어야 합니다. 예: “{com.acme.constraints.NotSafe.message}”
Class<?>[] groups() default {}: 사용자가 대상 그룹을 맞춤화할 수 있도록 하기 위한 것입니다.
Class<? extends Payload>[] payload() default {}: 확장성 목적을 위한 것입니다.
제네릭 및 교차-매개변수 제약 조건을 구축할 때, 제약 조건 애노테이션은 validationAppliesTo() 속성을 포함해야 합니다. 제약 조건이 주석이 달린 요소를 대상으로 하면 제네릭이고, 메소드 또는 생성자의 매개변수 배열을 대상으로 하면 교차-매개변수입니다.

ConstraintTarget validationAppliesTo() default ConstraintTarget.IMPLICIT
이 속성은 제약 조건 사용자가 제약 조건이 실행 가능한 반환 타입을 대상으로 하는지 아니면 매개변수 배열을 대상으로 하는지 선택할 수 있게 합니다. 두 가지 종류의 ConstraintValidator가 제약 조건에 부착되어 있거나, 하나의 ConstraintValidator가 ANNOTATED_ELEMENT와 PARAMETERS 모두를 대상으로 하는 경우 제약 조건은 제네릭 및 교차-매개변수입니다.

이러한 이중 제약 조건은 드뭅니다. 자세한 내용은 SupportedValidationTarget을 참조하십시오.

주의깊게 볼 점은 Constraint 사용시에 다음 속성을 같이 포함해야 한다.

  • message : 제약조건 위배 시 생성할 에러메시지를 정의
    • String message() default “A review can be associated with either a book, a magazine or an article”;
  • groups : 유효성 검증을 수행할 그룹을 지정
    • 디폴트는 빈 배열이 넘어가야 한다. Class<?>[] groups() default {};
  • payload : 유효성 검증을 수행하는 클라이언트가 사용할 수 있는 메타 데이터를 지정
  • validationAppliesTo : 제약조건의 Target을 명확하게 지정하기 위해 사용
    • 디폴트 IMPLICIT (제일 범용적이다)
      • 제약조건 어노테이션이 메서드 or 생성자에 부여되지 않았다면, 어노테이션이 부여된 요소
      • 메서드 or 생성자의 파라미터가 없다면 메서드 or 생성자의 반환값
      • 메서드 or 생성자의 반환값이 없다면(void) 메서드 or 생성자의 파라미터
    • RETURN_VALUE
      • 메서드 or 생성자의 반환값을 유효성 검증의 대상으로 지정한다.
    • PARAMETERS
      • 메서드 or 생성자의 파라미터를 유효성 검증의 대상으로 지정한다.
    • Generic Constraints와 Cross-parameter Constraints 모두에 적용 가능
//file: `제약조건 예시`
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {JustOneOfManyValidator.class})
public @interface OnlyOneOfMany {
    String message() default "A review can be associated with either a book, a magazine or an article";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Constraint를 붙여 제약조건을 선언해주고, JustOneOfManyValidator를 valid 로직으로 지정해준다.

ConstraintValidation 유효성 검증 구현체 추가

//file: `유효성 검증 구현체`
public class JustOneOfManyValidator implements ConstraintValidator<OnlyOneOfMany, Review>{
        @Override
        public boolean isValid(Review review, ConstraintValidatorContext ctx) {
            return Stream.of(review.getBook(), review.getArticle(), review.getMagazine())
                    .filter(Objects::nonNull)
                    .count() ==  1;
        }
}

ConstraintValidator에 사용할 제약조건 어노테이션, 검증 대상 타입을 지정해준다.
@OnlyOneOfMany가 붙은 엔티티는 JPA Repository에 의해 관리되는 순간에 JustOneOfManyValidator isValid 로직을 타게 된다.

//file: `Review 클래스`
@Entity
@OnlyOneOfMany 
public class Review implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Book book;

    @ManyToOne(fetch = FetchType.LAZY)
    private Article article;

    @ManyToOne(fetch = FetchType.LAZY)
    private Magazine magazine;

}

Reivew는 Book, Article, Magazine중 하나와만 연관관계를 맺고 있어야 한다.

//file: `조건에 걸리면 아래와 같은 에러가 난다.`
jakarta.validation.ConstraintViolationException: Validation failed for classes [com.example.practicepersistancelayer.chapter1.ChooseOnlyOneAssociation.entity.Review] during persist time for groups [jakarta.validation.groups.Default, ]
List of constraint violations:[
ConstraintViolationImpl{interpolatedMessage='A review can be associated with either a book, a magazine or an article', propertyPath=, rootBeanClass=class com.example.practicepersistancelayer.chapter1.ChooseOnlyOneAssociation.entity.Review, messageTemplate='A review can be associated with either a book, a magazine or an article'}

제약 조건의 종류 두 가지

  1. Generic Constraints (제네릭 제약 조건)
    • 대상: 클래스, 필드, 메소드 반환 값 등
    • 용도: 개별 속성이나 메소드 반환 값의 유효성을 검사
    • 예: @NotNull, @Size, @Min, @Max 등
//file: `제네릭 제약 조건 예`
public class User {
    @NotNull
    private String username;

    // Getter and Setter
}

‘개별’ 적으로 타입에 대해 검사를 한다.

  1. Cross-parameter Constraints (교차-매개변수 제약 조건):
    • 대상: 메소드나 생성자의 매개변수 배열
    • 용도: 매개변수 간의 상호 관계를 유효성 검사
    • 예: startDate, endDate를 파라미터로 받는데 startDate가 endDate보다 빠르면 안된다.
//file: `제네릭 제약 조건`

@Target({ElementType.METHOD,ElementType.CONSTRUCTOR, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface DateRangeParams {
  String message () default "'start date' 가 'end date'보다 빠를 수 없다. " +
          "Found: 'start date'=${validatedValue[0]}, " +
          "'end date'=${validatedValue[1]}";
  Class<?>[] groups () default {};
  Class<? extends Payload>[] payload () default {};
}

@SupportedValidationTarget(ValidationTarget.PARAMETERS)
public class DateRangeValidator implements ConstraintValidator<DateRangeParams, Object[]>{

  @Override
  public boolean isValid(Object[] value, ConstraintValidatorContext context) {
    if (value == null || value.length != 2 ||
            !(value[0] instanceof LocalDate) ||
            !(value[1] instanceof LocalDate)) {
      return false;
    }

    return ((LocalDate) value[0]).isBefore((LocalDate) value[1]);
  }
}


  • @SupportedValidationTarget(ValidationTarget.PARAMETERS) : 메서드의 파라미터를 대상으로 한다고 지정

@OnlyOneOfMany는 Review 클래스의 인스턴스를 받아, 해당 객체의 특정 필드들이 하나만 설정되어 있는지 확인한다.
이 경우, @OnlyOneOfMany는 제네릭 제약 조건으로 동작한다.

이로써 제약조건을 추가해 연관관계에 대한 조건을 추가할 수 있게 되었다.
네이티브 쿼리는 App수준에서 유효성 검사를 무시하기 때문에 이런 상황이 예상된다면 DB에서도 제약을 걸어줘야 한다.


##