spring jpa QuerydslBinderCustomizer

이번 시간에 알아볼 것은 querydsl의 QuerydslBinderCustomizer을 알아볼 예정이다.
QuerydslBinderCustomizer는 인터페이스이며 추상 메서드는 void customize(QuerydslBindings bindings, T root) 한개를 갖고 있다.
현재 필자는 회사나 집에서 java8을 쓰기 때문에 java8 기준으로 설명한다
아주 상세하게 컨트롤은 하지 못해도 기본적인 동적쿼리(예로 있으면 검색 아니면 검색하지 않는다)를 간단하게 만들수 있다. 뭐 기능이 얼마나 있는지는 모르겠지만 일단 필자가 테스트한 경우는 기본적인 것만 해봤기 때문에 그것만 설명을 하겠다.
예전에 querydsl를 공부할때 남겨두었던 클래스들을 재 사용했다.

public interface AccountRepository extends QueryDslPredicateExecutor<Account>,
  QuerydslBinderCustomizer<QAccount>, JpaRepository<Account, Long>, CustomAccountRepository {

  @Override
  default void customize(QuerydslBindings bindings, QAccount user) {
    bindings.bind(String.class)
      .first((StringPath path, String value) -> path.containsIgnoreCase(value));
    bindings.excluding(user.password);
  }
}

여기서 추가 된것은 QueryDslPredicateExecutor 와 QuerydslBinderCustomizer이다. JpaRepository와 CustomAccountRepository는 예전에 공부할때 쓰던거라 일단 남겨두었다.
QueryDslPredicateExecutor 인터페이스는 JpaRepository 와 비슷한 추상 메서드를 갖고 있다. 메서드는 거의 비슷하지만 파라미터가 Predicate predicate 위주로 되어있다.
java8은 인터페이스에서도 구현을 할 수 있어 QuerydslBinderCustomizer의 구현체를 AccountRepository에 만들었다.
구현체에 있는 것들은 실제 request를 보낼때 바인딩되어 그 바인딩된 것으로 쿼리를 하는 것이다. 말로 설명할려니 조금 힘들다. 실제로 테스트를 해보면서 알아가보자.

@RestController
@RequiredArgsConstructor
public class AccountController {

  private final AccountRepository accountRepository;

  private final ModelMapper modelMapper;

  @GetMapping("/accounts")
  public Page<AccountDto.Response> accounts(@QuerydslPredicate(root = Account.class) Predicate predicate,
                                Pageable pageable){
    Page<Account> all = accountRepository.findAll(predicate, pageable);
    List<AccountDto.Response> collect = all.getContent()
      .stream()
      .map(i -> modelMapper.map(i, AccountDto.Response.class)).collect(toList());

    return new PageImpl<>(collect, pageable, all.getTotalElements());
  }
}

어떤한 테스트를 하기 위한 controller이다. 파라미터로는 @QuerydslPredicate(root = Account.class) Predicate 와 Pageable을 받고 있다. 실제로 어떻게 되는지 테스트를 해보자.

http://localhost:8080/accounts

{
  "content": [
    {
      "id": 1,
      "name": "wonwoo",
      "password": "1PassWord",
      "email": "wonwoo@test.com"
    },
    {
      "id": 2,
      "name": "wonwoo",
      "password": "2PassWord11",
      "email": "123@test.com"
    },
    {
      "id": 3,
      "name": "kevin",
      "password": "3PassWord2",
      "email": "aaa@test.com"
    },
    {
      "id": 4,
      "name": "ggg",
      "password": "PassWord33",
      "email": "bbb@test.com"
    },
    {
      "id": 5,
      "name": "ggg",
      "password": "PassWord44",
      "email": "ccc@test.com"
    },
    {
      "id": 6,
      "name": "keven",
      "password": "PassWord5",
      "email": "ddd@test.com"
    },
    {
      "id": 7,
      "name": "qqqq",
      "password": "PassWord6",
      "email": "ggg@test.com"
    }
  ],
  "totalElements": 7,
  "last": true,
  "totalPages": 1,
  "size": 20,
  "number": 0,
  "sort": null,
  "first": true,
  "numberOfElements": 7
}

파라미터를 아무것도 보내지 않았을 때 이런 결과가 있다고 가정하자.
이번에는 파라미터를 보내보자.

http://localhost:8080/accounts?name=won

{
  content: [
    {
      id: 1,
      name: "wonwoo",
      password: "1PassWord",
      email: "wonwoo@test.com"
    },
    {
      id: 2,
      name: "wonwoo",
      password: "2PassWord11",
      email: "123@test.com"
    }
  ],
  totalElements: 2,
  last: true,
  totalPages: 1,
  size: 20,
  number: 0,
  sort: null,
  first: true,
  numberOfElements: 2
}

name에 won이 들어가는 것을 모두 가져왔다.
다음에는 password를 검색해보자.
http://localhost:8080/accounts?password=3

{
  content: [
    {
      id: 1,
      name: "wonwoo",
      password: "1PassWord",
      email: "wonwoo@test.com"
    },
    {
      id: 2,
      name: "wonwoo",
      password: "2PassWord11",
      email: "123@test.com"
    },
    {
      id: 3,
      name: "kevin",
      password: "3PassWord2",
      email: "aaa@test.com"
    },
    {
      id: 4,
      name: "ggg",
      password: "PassWord33",
      email: "bbb@test.com"
    },
    {
      id: 5,
      name: "ggg",
      password: "PassWord44",
      email: "ccc@test.com"
    },
    {
      id: 6,
      name: "keven",
      password: "PassWord5",
      email: "ddd@test.com"
    },
    {
      id: 7,
      name: "qqqq",
      password: "PassWord6",
      email: "ggg@test.com"
    }
  ],
  totalElements: 7,
  last: true,
  totalPages: 1,
  size: 20,
  number: 0,
  sort: null,
  first: true,
  numberOfElements: 7
}

하지만 결과와 다르게 모두 출력 되었다. 그 이유는 위의 코드에서 봤듯이 password는 제외 시켰기 때문이다.

bindings.excluding(user.password);

이번에는 특정 어떠한 파라미터로 오면 그것은 containsIgnoreCase 아닌 eq로 체크 하고 싶다면 아래와 같이 하면 된다.

@Override
default void customize(QuerydslBindings bindings, QAccount user) {
  bindings.bind(user.name).first((path, value) -> path.eq(value));
  bindings.bind(String.class)
    .first((StringPath path, String value) -> path.containsIgnoreCase(value));
  bindings.excluding(user.password);
}

user.name은 like가 아닌 eq으로 해놨다. 그럼 우리는 정확한 값이 나올때만 출력된다.
아까와 동일하게 http://localhost:8080/accounts?name=won 브라우저에 쳐보자.

{
  content: [

  ],
  totalElements: 0,
  last: true,
  totalPages: 0,
  size: 20,
  number: 0,
  sort: null,
  first: true,
  numberOfElements: 0
}

그럼 위와 같은 결과를 볼 수 있을 것이다. 그렇다면 이번에는 정확하게 문자를 집어넣어서 해보자.
http://localhost:8080/accounts?name=wonwoo

{
  content: [
    {
      id: 1,
      name: "wonwoo",
      password: "1PassWord",
      email: "wonwoo@test.com"
    },
    {
      id: 2,
      name: "wonwoo",
      password: "2PassWord11",
      email: "123@test.com"
    }
  ],
  totalElements: 2,
  last: true,
  totalPages: 1,
  size: 20,
  number: 0,
  sort: null,
  first: true,
  numberOfElements: 2
}

그럼 위와 같이 두건이 검색 된다. 우리는 QuerydslBinderCustomizer를 사용해서 좀더 간단하게 코딩을 할 수 있게 되었다. 물론 복잡한 것은 Custom한 레파지토를 만들어 querydsl을 사용하거나 JPQL 혹은 네이티브 SQL을 사용해야 될 것이다. 하지만 저렇게 간단하게 해결 할 수 있는 부분도 물론 있을 것이다.

spring boot Transaction(@Transactional) (수정)

spring boot Transaction(@Transactional) 여기에 잘못된 정보가 있다. 그래서 다시 포스팅을 한다.

여기 보면 3번 @Transactional(readOnly = true)가 적용된 메서드에서 @Transactional 혹은 @Transactional(readOnly = false)가 적용된 메서드를 호출 할 경우 무조건 read-only Transaction이 적용된다. 만약 이때 R을 제외한 CUD를 할 경우 에러를 발생한다. 이런 내용이 있었다.

영 찜찜해서 다시 해봤는데 역시나 잘못되었다. 분명히 저번에 테스트 할때는 에러가 났는데 방금 해보니까 위와 같이 되지 않았다. 테스트를 잘 못했나?
아무튼 각성하고 다시 설명을 하겠다.
일단 중요한건 에러가 나지 않는다. 근데 왜 저번엔 에러가 났지?ㅠㅠ read 어쩌고 저쩌고 하면서 에러가 분명 났는데..
그리고 에러도 나지 않지만 TransactionSynchronizationManager.isCurrentTransactionReadOnly() 메스드 결과가 true지만 롤백도 된다. 또한 @Transactional(readOnly = true) 설정 이라도 insert시 에러를 뱉지 않고 정상적으로 커밋도 된다.

찾아 보니까 JDBC 벤더들마다 다르다고 한다. 근데 오래전 글이라 지금도 적용될지는 모른다. 글쎄다.. 뭐가 어떻게 돌아가는지.. 나원참..

정리하자면 @Transactional(readOnly = true) 에서 @Transactional 메서드를 호출하더라도 정상적으로 작동한다. CRUD 모두 작동한다. 또한 @Transactional(readOnly = true) 에서 CUD를 하더라도 에러가 나지않고 커밋된다. 하지만 벤더들마다 다르다고 하니 잘 판단해서 해야 한다.

좀더 테스트한 결과 3번이 아예 잘못된 결과는 아니 였다. mysql 경우에 readOnly가 true 일경우 에러는 내뱉는다.

java.sql.SQLException: Connection is read-only. Queries leading to data modification are not allowed

위와 같은 로그가 찍혔다. 필자가 그때 테스트 한것이 mysql로 했나부다..

계속 왔다 갔다. 정리가 안된다..ㅠㅠ
마지막으로 다시 정리 하자면 @Transactional(readOnly = true)가 적용된 메서드에서 @Transactional 혹은 @Transactional(readOnly = false)가 적용된 메서드를 호출 할 경우 무조건 read-only Transaction이 적용된다.는 참이다. 트랙잭션이 전파되는 것은 맞지만 JDBC 벤더들 마다 readOnly속성의 구현이 된 벤더들도 있고 그렇지 않은 벤더들도 있다. 그래서 만약 이때 R을 제외한 CUD를 할 경우 에러를 발생한다. 이것은 참일수도 있고 거짓일 수도 있다. h2와 mysql을 테스트를 한 결과 h2는 거짓이지만 mysql은 참이다.

필자의 mysql 버전은 5.6.25이다. mysql의 readOnly는 버전 5.6.5부터 지원한다고 한다.

JPA 고급매핑 (4)

JPA 고급매핑의 마지막 시간이다.

조인 테이블

데이터베이스 테이블의 연관관계를 설계하는 방법은 크게 2가지이다.
1. 조인 컬럼 사용
2. 조인 테이블 사용

조인 컬럼 사용

테이블 간에 관계는 주로 조인 컬럼이라 부르는 외래 키 컬럼을 사용해서 관리한다.
예를 들어 회원과 사물함이 있는데 각각 테이블에 데이터를 등록했다가 회원이 원할 때 사물함을 선택할 수 있다고 가정해보자. 회원이 사물함을 사용하기 전까지는 아직 둘 사이에 관계가 없으므로 Member 테이블의 외래 키에 null을 입력해두어야 한다. 이렇게 외래 키에 null을 허용하는 관계를 선택적 비식별 관계라 한다.

조인 테이블 사용

이방법은 조인 테이블이라는 별도의 테이블을 사용해서 연관관계를 관리한다. 조인 컬럼을 사용하는 방법은 단순히 외래 키 컬럼만 추가해서 연관관계를 맺지만 조인 테이블을 사용하는 방법은 연관관계를 관리하는 조인 테이블을 추가하고 여기서 두 테이블의 외래키를 가지고 연관관계를 관리한다.

일대일 조인 테이블

@Entity
@Data
public class Parent {
  @Id
  @GeneratedValue
  @Column(name = "PARENT_ID")
  private Long id;

  private String name;

  @OneToOne
  @JoinTable(name = "PARENT_CHILD",
    joinColumns = @JoinColumn(name = "PARENT_ID"),
    inverseJoinColumns = @JoinColumn(name = "CHILD_ID"))
  private Child child;
}

@Entity
@Data
public class Child {

  @Id
  @GeneratedValue
  @Column(name = "CHILD_ID")
  private Long id;

  private String name;

}

부모 엔티티를 보면 @JoinColumn 대신 @JoinTable을 사용했다. @JoinTable의 속성을 다음과 같다.
name : 매핑할 조인 테이블 이름
joinColumns : 현재 엔티티를 참조하는 외래 키
inverseJoinColumns : 반대방향 엔티티를 참조하는 외래 키

일대다 조인 테이블

일대다 관계를 만들려면 조인 테이블의 컬럼 중 다(N)와 관련된 컬럼인 CHILD_ID에 유니크 제약 조건을 걸어야 한다.(CHILD_ID 는 기본 키이므로 유니크 제약 조건이 걸려있다.)

@Entity
@Data
public class Parent {
  @Id
  @GeneratedValue
  @Column(name = "PARENT_ID")
  private Long id;

  private String name;

  @OneToMany
  @JoinTable(name = "PARENT_CHILD",
    joinColumns = @JoinColumn(name = "PARENT_ID"),
    inverseJoinColumns = @JoinColumn(name = "CHILD_ID"))
  private List<Child> child = new ArrayList<>();
}

@Entity
@Data
public class Child {

  @Id
  @GeneratedValue
  @Column(name = "CHILD_ID")
  private Long id;

  private String name;
}

다대일 조인 테이블

다대일은 일대다에서 방향만 반대이므로 조인 테이블 모양은 일대다에서 설명한 것도 같다.

@Entity
@Data
public class Parent {
  @Id
  @GeneratedValue
  @Column(name = "PARENT_ID")
  private Long id;

  private String name;

  @OneToMany(mappedBy = "parent")
  private List<Child> child = new ArrayList<>();
}

@Entity
@Data
public class Child {

  @Id
  @GeneratedValue
  @Column(name = "CHILD_ID")
  private Long id;

  private String name;

  @ManyToOne(optional = false)
  @JoinTable(name = "PARENT_CHILD",
    joinColumns = @JoinColumn(name = "CHILD_ID"),
    inverseJoinColumns = @JoinColumn(name = "PARENT_ID"))
  private Parent parent;
}

다대다 조인 테이블

다대다 관계를 만들려면 조인 테이블의 두 컬럼을 합해서 하나의 복합 유니크 제약조건을 걸어야 한다.(PARENT_ID, CHILD_ID 는 복합 기본키이므로 유니크 제약 조건이 걸려 있다.)

@Entity
@Data
public class Parent {

  @Id
  @GeneratedValue
  @Column(name = "PARENT_ID")
  private Long id;

  private String name;

  @ManyToMany
  @JoinTable(name = "PARENT_CHILD",
    joinColumns = @JoinColumn(name = "PARENT_ID"),
    inverseJoinColumns = @JoinColumn(name = "CHILD_ID"))
  private List<Child> child = new ArrayList<>();

}

@Entity
@Data
public class Child {

  @Id
  @GeneratedValue
  @Column(name = "CHILD_ID")
  private Long id;

  private String name;
}

조인 테이블에 컬럼을 추가하면 @JoinTable 전략을 사용할 수 없다. 대신에 새로운 엔티티를 만들어서 조인 테이블과 매핑해야 한다.

엔티티 하나에 여러 테이블 매핑

잘 사용하지는 않지만 @SecondaryTable을 사용하면 한 엔티티에 여러 테이블을 매핑할 수 있다.

@Entity
@Data
@Table(name = "BOARD")
@SecondaryTable(name = "BOARD_DETAIL",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "BOARD_DETAIL_ID"))
public class Board {

  @Id
  @GeneratedValue
  @Column(name = "BOARD_ID")
  private Long id;

  private String title;

  @Column(table = "BOARD_DETAIL")
  private String content;
}

Board 엔티티는 @Table을 사용해서 BOARD 테이블과 매핑했다. 그리고 @SecondaryTable을 사용해서 BOARD_DETAIL 테이블을 추가로 매핑했다. @SecondaryTable속성은 다음과 같다.
name : 매핑할 다른 테이블의 이름, 위에서는 BOARD_DETAIL로 지정 했다.
pkJoinColumns : 매핑할 다른 테이블의 기본 키 컬럼 속성, 위에서는 BOARD_DETAIL_ID로 지정했다.

@Column(table = "BOARD_DETAIL")
private String content;

content 필드는 @Column(table = “BOARD_DETAIL”)을 사용해서 BOARD_DETAIL 테이블의 컬럼에 매핑했다. title 필드처럼 테이블을 지정하지 않으면 기본 테이블인 BOARD에 매핑 된다.
더 많은 데이블을 매핑하려면 @SecondaryTables를 사용하면 된다.

이렇게 고급매핑에 대해서 알아봤다. 쓰면서 이해는 됐는데 다시 천천히 봐야겠다.

출처 : 자바 ORM 표준 JPA 프로그래밍 (김영한)