JPA 연관관계 정리중에 @OneToMany에서 멘붕왔다...

fetchType부터 시작해서 mappedBy까지....

fetchType은 아직도 찾는중이라 mappedBy 먼저 포스팅.

 

mappedBy에 대해 찾아보다보면 양방향 연관관계가 꼭 따라온다.

연관관계 정리해서 포스팅 해야되니까 여기서는 그냥 간단하게만.......

 

연관관계는 단방향, 양방향이 존재한다.

제일 많이 나오는 예로 팀과 멤버가 있다.

 

하나의 팀이 있다면 그 안에는 여러 멤버들이 존재한다.

그렇다면 팀은 1이 되고 멤버들은 N이 된다.

팀은 하나지만 그 안에 속한 멤버는 여러명이기 때문이다.

그래서 이런 관계를 다대일, N:1, 일대다, 1:N 이라고 한다.

 

보통 데이터베이스 기준에서 보면 N에 외래키를 두게 된다.

매핑은 다대일 먼저 설명.

@Entity
public class User {
  ...
  
  @ManyToOne
  @JoinColumn(name = "tean_id")
  private Team team;
  
  ...
}

@Entity
public class Team {
  ...
  
  @Id
  @GenerataionValue
  @Column(name = "team_id")
  private Long no;
  
  ...
}

다대일 매핑은 이렇게 @ManyToOne을 사용해 처리해준다.

그럼 User가 '다'에 속하기 때문에 User 안에 team의 id값이 존재해야 한다.

이 엔티티들을 erd로 보면 아래와 같다.

 

코드에서 보면 ManyToOne으로 매핑을 해주겠다고 선언을 하고 JoinColumn 어노테이션을 통해 매핑할 엔티티의 Id 컬럼을 명시한다.

그럼 이제 멤버를 조회할때 어느 팀에 속해있는지 찾을 수 있게 된다.

 

이렇게 매핑된 상태를 단방향 연관관계라고 한다.

단방향 연관관계는 이렇게 한 방향으로만 참조할 수 있다.

멤버를 통해 소속 팀을 조회할 수 있지만 팀을 조회했을때는 멤버정보를 알 수 없다.

 

그럼 팀내에 속한 선수들의 목록을 보려면  다시 단방향 매핑으로 연결을 더 해야되나???

 

이걸 양방향으로 만들어주게 되면 팀에서도 멤버를 조회할 수 있게 된다.

근데 말이 양방향이지 두개의 단방향 연관관계가 묶여있다고 볼 수 있다.

 

@Entity
public class User {
  ...
  
  @ManyToOne
  @JoinColumn(name = "team_id")
  private Team team;
  
  ...
}

@Entity
public class Team {
  ...
  
  @Id
  @GenerataionValue
  @Column(name = "team_id")
  private Long no;
  
  @OneToMany(mappedBy = "team")
  private List<User> user = new ArrayList<User>();
  
  ...
}

 

이렇게 팀에서도 OneToMany로 연관관계를 설정해준다.

그럼 User - N:1 - Team,  Team - 1:N - User 이렇게 두개의 단방향 연관관계가 설정된다.

이 두 단방향 연관관계가 묶여 양방향 연관관계가 된다.

 

처음에는 양방향 연관관계는 user <-> Team 형식이라고 생각했지만 그게 아니라 User -> Team, Team -> User 형태로 두개의 단방향 연관관계를 묶어야 한다고 한다.

 

그럼 이제 여기서 mappedBy 속성이 추가되었다.

 

 

mappedBy에 대해 검색해보면 엔티티의 양방향 연관관계에서의 주인이 누구인지를 알려주는 옵션이라고 한다.

 

처음에 이거 보고 뭔소린가 했다...

 

mappedBy를 설명하기 전에 양방향 연관관계를 간단하게 설명한 이유가 여기있다.

위에서 JPA에서의 연관관계를 묶을때 단방향으로 설정하게 되면 한쪽에서는 조회가 되지만 한쪽에서는 조회할 수 없다고 했다.

 

즉, User에서는 속한 Team을 조회할 수 있지만 Team에서는 User를 조회할 수 없다.

 

하지만 데이터베이스에서 보면 Join을 통해 이것을 처리할 수 있다.

 

위에서와 동일하게 이런 테이블이 있다고 할 때

SELECT
  u.user_name
  , t.team_name
FROM
  user u
  inner join
    team t
      on u.team_id = t.team_id
WHERE
  u.user_id = 1;


SELECT
  u.user_name
  , t.team_name
FROM
  team t
  inner join
    user u
      on t.team_id = u.team_id
WHERE
  t.team_id = 1;

이렇게 양쪽 테이블에서 서로에 대한 조회가 가능하다.

 

그래서 데이터베이스에서는 연관관계의 방향성이라는 것이 존재하지 않는다.

 

하지만 JPA에서는 객체를 참조하는 방식으로 두 엔티티의 연관관계를 찾게 된다.

User에서 team을 찾거나 Team에서 user를 찾게 할 수 있다.

아까 언급했듯이 JPA에서의 양방향 관계가 단방향 두개를 묶어놓은것과 마찬가지라는 것이 이런 의미이다.

 

그렇기 때문에 JPA에서는 외래키를 누가 관리할지를 명시해줘야 한다.

위 ERD를 기준으로 user테이블에 team_id라는 외래키를 관리하고 있으므로 user가 갖고 있는 team이 연관관계의 주인이라고 명시해야 한다는 것이다.

 

JPA에서는 mappedBy를 명시하지 않는다면 두 엔티티가 양방향 관계임을 모르게 된다.

 

@Entity
public class User {
  ...
  
  @ManyToOne
  @JoinColumn(name = "team_id")
  private Team team;
  
  ...
}

@Entity
public class Team {
  ...
  
  @Id
  @GenerataionValue
  @Column(name = "team_id")
  private Long no;
  
  @OneToMany(mappedBy = "team")
  private List<User> user = new ArrayList<User>();
  
  ...
}

그래서 이렇게 User에서 team이라는 외래키를 갖고 있게 하고 있으니 mappedBy로 외래키의 객체명을 작성해줘야 한다.

 

 

이제 여기까지는 여기저기 찾아보면서 알게된 내용들이고 이제부터는 인강 보면서 알게된 내용으로 정리.

 

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
@ToString(CallSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Book extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private String name;
  
  private String category;
  
  private Long authorId;
  
  private Long publisherId;
  
  @OneToOne
  @ToString.Exclude
  private BookReviewInfo bookReviewInfo;
  
}
@Entity
@NoArgsConstructor
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class BookReviewInfo extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @OneToOne(optional = false)
  private Book book;
  
  private float averageReviewScore;
  
  private int reviewCount;
  
}
@SpringBootTest
class BookReviewInfoRepositoryTest {

  @Autowired
  private BookReviewInfoRepository bookReviewInfoRepository;
  
  @Autowired
  private BookRepository bookRepository;
  
  @Test
  void crudTest() {
    givenBookReviewInfo();
    
    Book result = bookReviewInfoRepository
          .findById(1L)
          .orElseThrow(RuntimeException::new)
          .getBook();
          
    System.out.println("result : " + result);
    
    BookReviewInfo result2 = bookRepository
          .findById(1L)
          .orElseThrow(RuntimeException::new)
          .getBookReviewInfo();
          
    System.out.println("result2 : " + result2);
  }
  
  private Book givenBook() {
    
    Book book = new Book();
    
    book.setName("new Book");
    book.setAuthorId(1L);
    book.setPublisherId(1L);
    
    return bookRepository.save(book);
    
  }
  
  private void givenBookReviewInfo() {
    
    BookReviewInfo bookReviewInfo = new BookReviewInfo();
    
    bookReviewinfo.setBook(givenBook());
    bookReviewinfo.setAverageReviewScore(4.5f);
    bookReviewinfo.setReviewCount(2);
    
    bookReviewInfoRepository.save(bookReviewInfo);
    
    System.out.println(">>>>>>> " + bookReviewInfoRepository.findAll());
    
  }
  
}

 

공부하다가 @OneToOne 하면서 mappedBy가 나왔기 때문에 다대일이 아닌 일대일로 정리.

일단 위 예제는 mappedBy를 붙이지 않고 테스트했다.

 

이대로 테스트를 하면 나머지는 다 제대로 출력되지만 result2는 null로 출력된다.

 

두 엔티티 모두 OneToOne으로 매핑해줬지만 bookReviewRepository를 통해 조회하는것은 정상 출력되나 bookRepository를 통해 조회하는 것은 null이 출력되는 것이다.

 

그래서 쿼리문을 확인해봤다.

select
        book0_.id as id1_1_0_,
        book0_.created_at as created_2_1_0_,
        book0_.updated_at as updated_3_1_0_,
        book0_.author_id as author_i4_1_0_,
        book0_.book_review_info_id as book_rev8_1_0_,
        book0_.category as category5_1_0_,
        book0_.name as name6_1_0_,
        book0_.publisher_id as publishe7_1_0_,
        bookreview1_.id as id1_2_1_,
        bookreview1_.created_at as created_2_2_1_,
        bookreview1_.updated_at as updated_3_2_1_,
        bookreview1_.average_review_score as average_4_2_1_,
        bookreview1_.book_id as book_id6_2_1_,
        bookreview1_.review_count as review_c5_2_1_,
        book2_.id as id1_1_2_,
        book2_.created_at as created_2_1_2_,
        book2_.updated_at as updated_3_1_2_,
        book2_.author_id as author_i4_1_2_,
        book2_.book_review_info_id as book_rev8_1_2_,
        book2_.category as category5_1_2_,
        book2_.name as name6_1_2_,
        book2_.publisher_id as publishe7_1_2_ 
    from
        book book0_ 
    left outer join
        book_review_info bookreview1_ 
            on book0_.book_review_info_id=bookreview1_.id 
    left outer join
        book book2_ 
            on bookreview1_.book_id=book2_.id 
    where
        book0_.id=?

select 부분도 book이 두번 중복되어 있는 것을 볼 수 있고

조인 부분도 이상하다.

book과 reviewInfo를 left outer join으로 한번 조회하고 book을 book2로 또 만들어 다시 조인한다.

 

그리고 또하나. book 테이블에서도 review_info_id를 조회하고 있고 reviewInfo 테이블에서도 book_id를 조회하고 있다.

DB 테이블 구성을 생각해보면 아무래도 이상하다.

그럼 서로의 기본키를 서로 외래키로 갖고 있다는 얘기가 되니까.

 

그래서 디버그 모드로 돌려봤다.

 

book이 생성된 후와 bookReview가 생성된 후를 break point로 찍어 데이터베이스에 어떻게 들어가는지 봤더니

book이 생성될때는 bookReviewInfo가 존재하지 않으니 당연히 review_info_id가 null이 들어간다.

 

그럼 이 두 데이터를 추가했을때 데이터 상황은 아래와 같다.

Book
id name book_review_info author_id category publisher_id
1 new Book null 1 null 1
book_review_info
id book_id averageReviewScore reviewCount
1 1 4.5 2

그럼 테스트 코드를 봤을때 bookRepository.findById(1L).getBookReviewInfo(); 를 처리한다면

findById를 통해 저 데이터를 찾을 수는 있지만 getBookReviewInfo는 null로 되어있기 때문에 처리가 불가능해진다.

 

그리고 저 쿼리문만 보더라도 book.book_review_info_id = book_review_info.id 이 조인문에서 결과가 나올리가 없다.

그 뒤 조인은 말할것도 없고.

 

 

그래서 Book 엔티티에 mappedBy 속성을 추가해준다.

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
@ToString(CallSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Book extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private String name;
  
  private String category;
  
  private Long authorId;
  
  private Long publisherId;
  
  @OneToOne(mappedBy = "book")
  @ToString.Exclude
  private BookReviewInfo bookReviewInfo;
  
}

 

그리고 동일하게 테스트를 실행 하면 결과가 달라진다.

 

result2도 잘 출력되고 쿼리문도 바뀐다.

 

select
        book0_.id as id1_1_0_,
        book0_.created_at as created_2_1_0_,
        book0_.updated_at as updated_3_1_0_,
        book0_.author_id as author_i4_1_0_,
        book0_.category as category5_1_0_,
        book0_.name as name6_1_0_,
        book0_.publisher_id as publishe7_1_0_,
        bookreview1_.id as id1_2_1_,
        bookreview1_.created_at as created_2_2_1_,
        bookreview1_.updated_at as updated_3_2_1_,
        bookreview1_.average_review_score as average_4_2_1_,
        bookreview1_.book_id as book_id6_2_1_,
        bookreview1_.review_count as review_c5_2_1_ 
    from
        book book0_ 
    left outer join
        book_review_info bookreview1_ 
            on book0_.id=bookreview1_.book_id 
    where
        book0_.id=?

book 테이블에서 review_info_id도 조회하지 않고 조인 역시 book_id로 조인하는걸로 변경되었다.

 

그럼 이 테스트로 알 수 있는것은 mappedBy를 선언하지 않게되면 양쪽 테이블 모두 서로의 Id를 외래키로 갖게 된다.

서로의 Id를 외래키로 갖게 되면 어느 한쪽은 null이 될 수 밖에 없고 그럼 다시 수정을 통해 넣어줘야 하는 상황이 생기게 된다.

 

mappedBy를 선언함으로서 외래키를 관리할 연관관계의 주인을 명시해줘서 해당 테이블에만 외래키가 존재하도록 하게된다.

 

여기서 관리한다는 것은 외래키를 등록하거나 수정하고 DB에 접속해 그 값을 바꿀 수 있다는 것을 의미한다.

즉, 연관관계의 주인이 아닌 객체에서 아무리 등록 혹은 수정 작업을 하더라도 DB에는 전혀 반영이 되지 않고 오직 읽기만 가능하다.

 

 

 

좀 정리해보자면 mappedBy는 양방향 연관관계의 주인을 명시하는 것이고 주인이란 두 연관관계에서의 외래키에 매핑되는 객체이다.

이 연관관계의 주인은 양방향 연관관계에서 외래키를 등록하거나 수정하고 DB에 접속해 값을 바꿀 수 있는 관리 권한이 있다고 보면 된다.

양방향 연관관계에서 mappedBy를 선언하지 않는다면 양쪽 테이블 모두 서로의 id를 외래키로 갖게 되고 JPA에서는 양방향 관계임을 모르게 되어 조회 처리가 제대로 이루어지지 않거나 단방향 조회만 가능해지게 된다.

 

 

처음에 무슨소린지 도통 모르겠어서 이해하는데도 시간이 엄청 걸리기도 했고 포스팅마다 여러번 읽어보면서 겨우 이해한 내용은 이정도...

분명 아직 모르는 부분이 많을거같은데 기본적인 개념은 이렇게 되는것 같고 여러 방법으로 사용해보면서 더 테스트해보며 알아봐야될것같다...

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • 참고한 블로그

 

 

JPA 양방향 연관관계와 mappedBy

JPA를 공부하면서 가장 헷갈렸던 것 중 하나가 매핑이었다. 연관관계가 있는 두 엔티티를 어떻게 묶어야 하고, 어떤 어노테이션을 써야 했으며 주의해야 할 점은 무엇인지에 대해 간단히 정리해

velog.io

 

 

[JPA] 양방향 연관관계란 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

 

 

[JPA] 양방향 연관관계

양방향 연관관계와 연관관계의 주인 Team을 통해서도 getMemberList()로 특정 팀에 속한 멤버 리스트를 가져오고 싶다. 객체 설계는 위와 같이 Member에서는 Team을 가지고 있고, Team에서는 Members를 가지

ict-nroo.tistory.com

 

'Spring' 카테고리의 다른 글

IoC와 DI(2. IoC Container의 종류와 사용방법)  (0) 2022.08.18
IoC와 DI(1. IoC Container란)  (0) 2022.08.18
JPA Entity Listener 2  (0) 2022.03.17
JPA Entity Listener 1  (0) 2022.03.16
Jpa Entity 기본 Annotation 2 (@Column, @Transient)  (0) 2022.03.15

이전 포스팅에 이어 Entity Listener 정리.

 

예제 환경은 이전 포스팅과 마찬가지로 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

createdAt과 updatedAt을 활용해 계속 Listener를 테스트해보고 있는데 이것들은 많이 사용되는 데이터이기 때문에 스프링에서는 별도의 기본 Listener를 제공하고 있다.

 

이 기본 Listener를 사용하기 위해 Application에 @EnableJpaAuditing 어노테이션을 추가하고

각 엔티티의 @EntityListeners에서 value로 MyEntityListener가 아닌 AuditingEntityListener를 추가해준다.

 

그리고 각 Entity에서 createdAt에는 @CreatedDate, updatedAt에는 @LastModifiedDate 어노테이션을 붙여주면 자동으로 해당 값을 처리해주게 된다.

 

@SpringBootApplication
@EnableJpaAuditing
public class BookmanagerApplication {
  
  public static void main(String[] args) {
    SpringApplication.run(BookmanagerApplication.class, args);
  }
  
}
//User Entity

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = { AuditingEntityListener.class, UserEntityListener.class } )
public class User implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  @CreatedDate
  private LocalDateTime createdAt;
  
  @LastModifiedDate
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
}


//UserHistory Entity

@Data
@NoArgsConstructor
@Entity
@EntityListeners(value = AuditingEntityListener.class)
public class User implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  private Long userId;
  
  private String name;
  
  private String email;
  
  @CreatedDate
  private LocalDateTime createdAt;
  
  @LastModifiedDate
  private LocalDateTime updatedAt;
  
}

그리고 테스트를 실행해보면 정상적으로 잘 처리된다.

 

 

이렇게 기본적으로 제공하는 리스너를 사용함으로써 기존에 사용하던 MyEntityListener는 사용하지 않아도 된다.

 

이 AuditingListener에서는 몇가지를 제공해주고 있는데 안에 들어가보면 여러 어노테이션들이 있다.

그중에 createdDate안에 있는 LastModifiedBy가 있는데

이건 생성 또는 수정한 사람의 정보를 함께 저장할 수 있는 기능이다.

누가 언제 생성했는지 누가 언제 수정했는지에 대한 것인데 스프링 시큐리티의 인증정보를 가져와 사용하는 방법으로 활용하면 좀 더 편리하게 사용이 가능하다.

 

 

그럼 여기까지가 이제 Entity Listener를 사용하는 방법들이었고 좀 더 실용적인 방법이 있다.

 

지금은 각 엔티티에 createdAt과 updatedAt이 중복되고 있다. 둘다 같은 형태로 사용하고 있는데 계속 반복해서 엔티티들에 들어가있는 상태다.

이런것 또한 분리해서 처리해주는게 좀 더 실용적이라고 볼 수 있다.

 

지금까지의 정리된 부분들은 이렇게 쓸 수 있구나 정도로 넘어가고 실용적인 방법으로 넘어간다.

 

일단 엔티티에 중복되는 createdAt과 updatedAt을 삭제한다.

그리고 BaseEntity라는 새로운 클래스를 생성해 그 안에 createdAt과 updatedAt을 넣어준다.

 

@Data
@MappedSuperclass
@EntityListeners(value = AuditingEntityListener.class)
public class BaseEntity {

  @Column(updatable = false, columnDefinition = "datetime(6) default now(6)")
  @CreatedDate
  private LocalDateTime createdAt;
  
  @Column(columnDefinition = "default(6) default now(6)")
  @LastModifiedDate
  private LocalDateTime updatedAt;
  
}
//User Entity

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = UserEntityListener.class)
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class User extends BaseEntity implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
}


//UserHistory Entity

@Data
@NoArgsConstructor
@Entity
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class User extends BaseEntity implements Auditable {
  
  @Id
  @GenereatedValue
  private Long id;
  
  private Long userId;
  
  private String name;
  
  private String email;
  
}

BaseEntity에 붙어있는 @MappedSuperclass 어노테이션은 해당 클래스의 필드를 상속받는 엔티티의 컬럼으로 포함시키겠다는 의미다.

즉, BaseEntity 안에 있는 createdAt과 updatedAt을 상속받고 있는 User 엔티티와 UserHistory 엔티티의 컬럼으로 포함된다는 의미다.

그래서 각 엔티티에서는 createdAt과 updatedAt을 따로 작성하지 않아도 된다.

 

그리고 BaseEntity에서 EntityListener를 명시하고 있기 때문에 상속받는 엔티티에서는 EntityListener를 명시할 필요가 없다.

단, User 엔티티에서는 UserHistory 데이터를 같이 처리하기 위해 UserEntityListener를 처리하도록 해야 하기 때문에 명시 해야한다.

 

각 엔티티에 추가적으로 붙은 어노테이션으로 @EqualsAndHashCode(callSuper = true)와 @ToString(callSuper = true)가 있다.

 

이 두 어노테이션을 붙이지 않은 상태로 테스트코드를 실행하게 되면

Generating equals/hashCode implementation but without a call to superclass, even though this class does not extend java.lang.Object. If this is Intentional, add ‘@EqualsAndHashCode(callSuper=false)’ to your type.

이런 오류가 발생한다.

equals/hashCode 구현을 생성하지만 이 클래스가 java.lang.Object를 확장하지 않더라도 superClass에 대한 호출이 없다. 이것이 의도적인 경우 유형에 @EqualsAndHashCode(callSuper = false)를 추가하라는 내용이다.

 

super class인 BaseEntity에 대한 정보가 처리되지 않는다는 것이다.

callSuper의 경우 이 호출이 제대로 처리되고 있지 않은 상황인데 이걸 의도적으로 한게 맞다면 false를 추가해 내가 의도적으로 이렇게 호출이 제대로 처리되지 않도록 했다 라고 명시하라는 것인데 지금은 의도적으로 한것이 아니기 때문에 true로 선언해 처리해줘야 한다.

 

@ToString(callSuper = ture)의 경우는 누락되면 별다른 오류가 발생하지 않고 createdAt과 updatedAt이 출력되지 않는다.

강의에서도 별다른 언급이 없어서 고민을 좀 해봤다.

 

@EqualsAndHashCode에서 발생한 오류처럼 동일하게 super class인 BaseEntity 호출이 제대로 처리되지 않은 상태라 callSuper = true 라고 명시하지 않는다면 BaseEntity에 대한 ToString을 무시해서 출력이 안되는것이라는 생각이 든다.

 

그래서 callSuper = true로 설정해 상속받고 있는 클래스까지 ToString을 생성하겠다는 것이라고 생각한다.

 

 

 

여기까지 완성된 코드를 전체적으로 보면 각 엔티티에서 중복되는 createdAt과 updatedAt은 BaseEntity라는 하나의 엔티티에 넣어 이것을 상속받는 모든 엔티티에서 컬럼으로 포함시킬 수 있게 되었고

상속받는 엔티티에서 데이터추가나 수정을 한다면 createdAt과 updatedAt에 현재 시간을 바로바로 넣어줄 수 있게 되었다.

 

일반적으로 이렇게 중복되는 컬럼들을 따로 모아 Entity를 작성하고 그 Entity를 상속받도록 하는 형태의 개발방식으로 많이 진행한다고 한다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA

 

 

Entity Listener는 엔티티의 변화를 감지하고 테이블의 데이터를 조작하는 일을 한다.

컬럼값이 추가되거나 수정되는 것에 대해 반복적인 코드를 계속 작성해 처리하는 것을 개선할 수 있게 해준다.

 

Entity Listener에는 7가지 이벤트가 있다.

 

  1. @PrePersist
  2. @PostPersist
  3. @PreUpdate
  4. @PostUpdate
  5. @PreRemove
  6. @PostRemove
  7. @PostLoad

 

목록을 대충 봐도 Pre와 Post로 구분이 된것을 볼 수 있다.

Pre의 경우는 메소드 호출되기 전, Post는 메소드가 호출된 이후에 실행되도록 한다.

 

Persist는 insert를 의미한다.

그래서 @PrePersist는 persist 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트이고

@PostPersist는 persist 메소드가 호출되어 실행된 직후에 실행하도록 하는 이벤트이다.

 

Update는 의미 그대로 merge 메소드의 호출 시점에 따른 이벤트다.

@PreUpdate는 merge 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트이고

@PostUpdate는 merge 메소드가 호출되어 실행된 직후 실행되도록 하는 이벤트다.

 

Remove는 delete 메소드의 호출시점에 따른 이벤트다.

@PreRemove는 delete 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트,

@PostRemove는 delete 메소드가 호출되어 실행된 직후 실행되도록 하는 이벤트다.

 

마지막으로 @PostLoad는 select 조회가 일어난 직후에 실행되는 메소드이다.

 

이 시점들을 확인해보기 위해 테스트 코드를 작성.

 

정리전 예제 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

-- data.sql

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(1, 'coco', 'coco@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(2, 'mozzi', 'mozzi@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(3, 'coco2', 'coco2@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(4, 'mozzi2', 'mozzi2@gmail.com', now(), now());

call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(5, 'coco3', 'coco3@gmail.com', now(), now());
public enum Gender {
  MALE,
  FEMALE
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
  
  @Prepersist
  public void prePersist(){
    System.out.println(">>>>>> prePersist");
  }
  
  @PostPersist
  public void postPersist(){
    System.out.println(">>>> postPersist");
  }
  
  @PreUpdate
  public void preUpdate(){
    System.out.println(">>>>> preUpdate");
  }
  
  @PostUpdate
  public void postUpdate(){
    System.out.println(">>>>>> postUpdate");
  }
  
  @PreRemove
  public void preRemove(){
    System.out.println(">>>> preRemove");
  }
  
  @PostRemove
  public void postRemove(){
    System.out.println(">>>>> postRemove");
  }
  
  @PostLoad
  public void postLoad(){
    System.out.println(">>>> postLoad");
  }
  
}
@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Test
  void listenerTest(){
    User user = new User();
    
    user.setEmail("coco1@gmail.com");
    user.setName("coco1");
    
    userRepository.save(user); //persist 동작 시점
    
    
    User user2 = userRepository.findById(1L).orElseThrow(RuntimeException::new); //select 시점
    
    user2.setName("coooocoooo");
    userRepository.save(user2); //merge 동작 시점
    
    userRepository.deleteById(4L); //delete 동작 시점
    
  }
  
}

테스트 코드로 실행 순서를 보면

persist(insert) -> select -> merge(update) -> delete 순서로 진행된다.

그럼 테스트를 실행하기 전 시점을 미리 예상해보자면

PrePersist -> persist -> PostPersist -> select -> PostLoad -> PreUpdate -> merge -> PostUpdate -> PreRemove

-> delete -> PostRemove 순서로 나오게 될것이다.

 

그럼 이제 테스트를 실행해서 보면

제일먼저 prePersist가 출력된다.

그 후 insert 쿼리가 실행이 되고 PostPersist가 찍힌다.

 

그리고 select 쿼리가 실행된 후에 postLoad가 찍히고 select 쿼리가 한번 더 찍히는데 이건 save메소드에서 update 전 존재 여부때문에 찍어보는것이다. 이 쿼리문 이후에 postLoad가 한번 더 찍힌다.

 

다음으로는 preUpdate 출력되고 update 쿼리 실행 이후 postUpdate가 출력되었고

delete 이전 존재여부에 대한 select 조회 이후 postLoad가 한번 더 찍힌 후에 바로 preRemove 출력 후에

delete 쿼리가 실행되고 postRemove가 찍히는 것을 볼 수 있다.

 

이렇게 해당 쿼리 실행을 기준으로 이전 이후로 나누어져 이벤트가 호출되어 실행되는 것을 볼 수 있다.

 

 

그럼 이런 이벤트를 어디서 쓸것이고 어떻게 쓸것인지를 본다.

일단 복잡하진 않지만 간단한 예제로 테스트를 한다.

 

@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Test
  void prePersistTest(){
    User user = new User();
    
    user.setEmail("coco1@gmail.com");
    user.setName("coco1");
    user.setCreatedAt(LocalDateTime.now());
    user.setUpdatedAt(LocalDateTime.now());
    
    userRepository.save(user); 
    
  }
  
}

 

 

보통 회원 정보에 대해 저장을 하게 되면 아이디, 이름, 성별, 이메일 등의 정보는 사용자가 입력한 정보를 받아 추가해주거나 수정하게 된다.

그럼 그 정보들은 set을 통해 처리해야 한다고 볼 수 있다.

 

하지만 추가된 시간과 수정된 시간을 넣어주는 createdAt과 updatedAt은??

이건 사용자가 직접 입력하는 시간이 아닌 이 코드가 처리되는 시점의 현재시간을 그냥 넣어주기만 하면 된다.

 

그럼 매번 처리 코드를 짤때마다 set으로 넣어줘야 한다.

문제는 없지만 중복되는 코드가 너무 많고 시간을 잘못 입력한다거나 빼먹는다거나 하는 실수가 발생할 수도 있다.

 

그래서 이런 상황에서 엔티티 리스너를 활용해 처리하면 편해진다.

 

 

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
  
  @Prepersist
  public void prePersist(){
    System.out.println(">>>>>> prePersist");
    this.createdAt = LocalDateTime.now();
    this.updatedAt = LocalDateTime.now();
  }
  
  @PreUpdate
  public void preUpdate(){
    System.out.println(">>>>> preUpdate");
    this.updatedAt = LocalDateTime.now();
  }
  
  
}​

이렇게 처리해주게 되면 persist나 merge 동작전에 createdAt과 updatedAt이 쿼리실행전 현재 시간을 갖고 있도록 할 수 있다.

 

@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Test
  void prePersistTest(){
    User user = new User();
    
    user.setEmail("coco1@gmail.com");
    user.setName("coco1");
    //user.setCreatedAt(LocalDateTime.now());
    //user.setUpdatedAt(LocalDateTime.now());
    
    userRepository.save(user); 
    
    System.out.println(userRepository.findByName("coco1");
    
    user.setName("cococo1");
    
    userRepository.save(user);
    
    System.out.println(userRepository.findByName("cococo1");
    
  }
  
}

 

이렇게 테스트를 실행해보면 첫 출력문에서는 createdAt과 updatedAt이 동일한 시간대가 들어가있는것을 볼 수 있고

마지막 출력문에서는 updatedAt이 수정되어 있는것을 확인할 수 있다.

 

 

다음 문제는 이제 엔티티의 개수가 많은데 모든 엔티티에서 createdAt과 updatedAt을 사용하는 경우다.

지금까지 본 구조로는 엔티티마다 @PrePersist와 @PreUpdate 메소드를 만들어 추가해줘야 한다.

그럼 엔티티마다 중복되는 코드가 발생하게 되고 중간에 놓칠 가능성도 있을 뿐더러 나중에 수정해야 하는 상황이 발생하게 되면 너무 많은 엔티티를 수정해야 한다.

 

이때는 Entity Listener를 지정해서 활용하는 방법으로 처리하면 좋다.

 

그래서 MyEntityListener라는 클래스를 하나 생성하고 MyEntityListener에서는 createdAt과 updatedAt이 존재해야 한다는 것을 알아야 하기 때문에 인터페이스를 하나 생성해 처리해준다.

 

그래서 Auditable 이라는 인터페이스를 생성해 그 안에 createdAt과 updatedAt을 처리할 수 있도록 해주면 된다.

 

public interface Auditable {
  
  LocalDateTime getCreatedAt();
  LocalDateTime getUpdatedAt();
  
  void setCreatedAt(LocalDateTime createdAt);
  void setUpdatedAt(LocalDateTime updatedAt);
  
}
public class MyEntityListener {

  @PrePersist
  public void prePersist(Object o) {
    if(o instanceof Auditable) {
      ((Auditable) o).setCreatedAt(LocalDateTime.now());
      ((Auditable) o).setUpdatedAt(LocalDateTime.now());
    }
  }
  
  @PreUpdate
  public void preUpdate(Obejct o) {
    if(o instanceof Auditable) {
      ((Auditable) o).setUpdatedAt(LocalDateTime.now());
    }
  }
  
}

MyEntityListener에서 두 메소드는 파라미터로 Object를 꼭 받아야 한다.

아무것도 받지 않게 작성하면 Method 'prePersist' should take parameter of type 'object here이라는 오류가 발생한다.

엔티티 객체에서는 파라미터를 받지 않아도 this의 값이기 때문에 object를 확인할 수 있지만 listener는 해당 엔티티를 받아서 처리해야 하고 이 object가 어떤 타입인지 Listener에서 알기 힘들기 때문에 object로 강제화 해야 한다.

 

그래서 대신 object를 받으면 Auditable 객체 타입인지 확인하는 작업만 추가해 Auditable 객체일때는 createdAt과 updatedAt을 now로 설정하도록 추가해준다.

 

Auditable의 createdAt과 updatedAt은 각 엔티티에 implements Auditable을 붙여줘 getter와 setter를 받아 처리하도록 해주고 엔티티의 @PrePersist와 @PreUpdate 메소드들은 지워준다.

그리고 엔티티에서 @EntityListener(value = MyEntityListener.class)를 추가해 해당 리스너 클래스를 지정해준다. 

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListener(value = MyEntityListener.class)
public class User implements Auditable{

  @Id
  @GeneratedValue
  private Long id;
  
  @NonNull
  private String name;
  
  @NonNull
  private String email;
  
  @Column(updatable = false)
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
  @Enumerated(value = EnumType.STRING)
  private Gender gender;
  
}

 

그리고 이전 테스트 코드인 prePersistTest 메소드를 실행시켜보면 잘 처리되는것을 확인할 수 있다.

 

이게 이전 코드랑 겹치지는 않았는지 확인을 해보고 싶으면 User 엔티티에서 @EntityListener(value = MyEntityListener.class)를 주석처리하고 테스트를 실행해보면 createdAt과 updatedAt이 null로 나오는것을 볼 수 있다.

 

null로 나오지 않는다면 어딘가 잘못 설정한것...

 

 

 

 

지금까지 테스트들 처럼 추가하는 케이스도 있지만 Hitory Data의 경우에는 DB에 특정 데이터가 수정되면 해당 값의 복사본을 다른 테이블에 저장해두는 경우가 있다.

 

그래서 이 예제는 User 테이블의 데이터가 수정될때 이 복사본을 userHistory 라는 테이블에 저장하도록 할것이다.

 

UserHistory 엔티티와 UserEntityListener를 생성한다.

UserEntityListener를 생성하는 이유는 User가 생성될때마다 userHistory에도 생성해주기 위함이다.

마지막으로 userHistoryRepository도 추가해준다.

 

@Entity
@NoArgsConstructor
@Data
@EntityListener(value = MyEntityListener.class)
public class UserHistory implements Auditable {
  
  @Id
  @GeneratedValue
  private Long id;
  
  private Long userId;
  
  private String name;
  
  private String email;
  
  private LocalDateTime createdAt;
  
  private LocalDateTime updatedAt;
  
}
public interface UserHistoryRepository extends JpaRepository<UserHistory, Long> {

}
@Component
public class UserEntityListener{

  @Autowired
  private UserHistoryRepository userHistoryRepository;
  
  @PreUpdate
  public void prePersistAndPreUpdate(Object o) {
    User user = (User) o;
    
    UserHistory userHistory = new UserHistory();
    
    userHistory.setUserId(user.getId());
    userHistory.setName(user.getName());
    userHistory.setEmail(user.getEmail());
    
    userHistoryRepository.save(userHistory);
    
  }
  
}

 

 

UserEntityListener에서는 User의 정보를 UserHistoryRepository를 통해 userHistory에 저장해야 하기 때문에

@Autowired로 userHistoryRepository를 주입받아야 한다.

 

그리고 아래 테스트 코드를 실행해본다.

@SpringBootTest
class UserRepositoryTest {
  
  @Autowired
  private UserRepository userRepository;
  
  @Autowired
  private UserHistoryRepository userHistoryRepository;
  
  @Test
  void userHistoryTest(){
    User user = new User();
    
    user.setEmail("coco-new@gmail.com");
    user.setName("coco-new");
    
    userRepository.save(user);
    
    user.setName("coco-new-new");
    
    userRepository.save(user);
    
    userHistoryRepository.findAll().forEach(System.out::println);
    
  }
  
}

 

이 테스트를 실행하게 되면 오류가 발생한다.

nullPointerException이 발생하는데 EntityListener는 Spring Bean을 주입받지 못하기 때문이다.

그런데 지금 UserEntityListener에서 UserHistoryRepository를 주입받으려 했는데 주입받지 못해서 nullPointerException이 발생하게 되는것.

 

이 문제를 해결하기 위해 BeanUtils라고 클래스를 하나 생성해서 처리하고

UserEntityListener를 수정한다.

 

@Component
public class BeanUtils implements ApplicationContextAware {

  private static ApplicationContext applicationContext;
  
  @Override
  public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
    BeanUtils.applicationContext = applicationContext;
  }
  
  public static <T> T getBean(Class<T> clazz) {
    return applicationContext.getBean(clazz);
    //해당 클래스에 맞는 applicationContext에서 getBean을 통해 class에 맞는 bean을 리턴해준다.
  }
  
}
import com.fastcampus.jpa.bookmanager.support.BeanUtils;

public class UserEntityListener{

  @PrePersist
  @PreUpdate
  public void prePersistAndPreUpdate(Object o) {

    UserHistoryRepository userHistoryRepository = BeanUtils.getBean(UserHistoryRepository.class);
	//BeanUtils는 새로 생성한 클래스 외에도
    //org.springframework.beans.BeanUtils가 있기 때문에 import 시에 주의.
    User user = (User) o;
    
    UserHistory userHistory = new UserHistory();
    
    userHistory.setUserId(user.getId());
    userHistory.setName(user.getName());
    userHistory.setEmail(user.getEmail());
    
    userHistoryRepository.save(userHistory);
    
  }
  
}

 

User 데이터가 추가될때 history도 같이 추가될 수 있도록 @PrePersist도 같이 붙여준다.

 

그리고 테스트를 실행.

 

그리고 로그를 확인해보면 history insert -> user insert -> select(merge 이전 select) -> history insert -> user update

-> select(출력 조회)

이 순서대로 쿼리가 실행되는 것을 볼 수 있다.

그럼 의도한대로 persist 이전, update이전에 history에 추가해주는 것을 확인할 수 있다.

 

여기서 createdAt과 updatedAt이 User와 UserHistory 엔티티에 중복되어 들어가 있는데 이것도 분리할 수 있다.

 

이건 다음포스팅에....

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA

'Spring' 카테고리의 다른 글

JPA 양방향 연관관계(mappedBy)  (0) 2022.03.18
JPA Entity Listener 2  (0) 2022.03.17
Jpa Entity 기본 Annotation 2 (@Column, @Transient)  (0) 2022.03.15
Jpa Entity 기본 Annotation 1 (@GeneratedValue, @Table)  (0) 2022.03.14
JPA Enum 적용  (0) 2022.02.19

이전 포스팅에 이어 @Column과 @Transient를 정리.

 

정리전 예제 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

1. @Column

@Column 어노테이션 속성을 먼저 확인해보면 name, unique, nullable, insertable, updatable, columnDefinition 등등이 존재한다.

name 속성의 경우 해당 컬럼의 이름을 지정해주는 속성이다.

 

처음부터 새로 만드는 프로젝트의 경우 별도로 지정해주지 않는것이 좋지만 DB는 유지하는 상태에서 리빌딩하는 경우 과거 네이밍 규칙으로 인해 가독성이 떨어지는 네이밍에 대해 수정하는 경우 사용하면 좋다.

 

강의에서의 예를 든것으로 createdAt을 예전에는 crtdat 이런 식으로 줄여서 사용하는 경우가 많았다고 한다.

그럼 이 crtdat을 DB에서는 유지하지만 코드에서는 createdAt으로 바꿔 가독성이 좋아지도록 변경할 수 있다는 것이다.

 

@Column(name = "crtdat")
private LocalDateTime createdAt;

이렇게 사용할 수 있고 어노테이션의 name 속성에 DB 컬럼명을 넣어주면 된다.

 

 

그리고 다음으로 많이 사용하는것이 nullable이다.

일반적인 쿼리를 사용하지 않는 경우에는 사전에 걸러주는 validation 역할을 하지 않는다.

DDL 쿼리를 자동으로 생성할 때 not null 필드를 만들어줄때 사용한다.

default 값은 true이기 때문에 따로 설정하지 않는다면 기본적으로 null을 허용하지만 false로 설정하게 되면 DDL 쿼리가 동작해 생성할 때 해당 컬럼을 not null로 생성한다.

@Column(nullable = true)
private LocalDateTime createdAt;

 

 

unique 속성은 @Table 어노테이션의 Unique Constraints와 동일한 역할을 하지만 @Table에서의 UniqueConstraints는 여러 컬럼들을 엮어 Unique 제약조건을 설정하는 것이라고 한다면 @Column에서의 unique 속성은 해당 컬럼 하나에 대한 제약조건을 설정할 때 사용한다.

default는 false로 되어있고 true로 설정하게 되면 unique 제약조건을 생성하게 된다.

하지만 이 경우 제약조건명이 랜덤생성되기 때문에 추천하진 않는다고 한다.

 

@Column(unique = true)
private LocalDateTime createdAt;

 

columnDefinition은 컬럼의 정보를 직접 지정할 수 있는 속성이다.

createdAt은 DDL이 생성될때 timestamp로 생성되고 있다.

 

그런데

@Column(columnDefinition = "datetime")
private LocalDateTime createdAt;

이렇게 columnDefinition을 사용하면 datetime으로 생성되는것을 볼 수 있다.

그리고 하나더.

default 값을 여기서 설정할 수도 있다.

 

createdAt의 경우 데이터가 insert되는 시점마다 현재 시간이 들어가야 한다.

그때마다 코드에서 직접 처리하도록 하게 되면 누락되는 부분도 생길것이고 모든 데이터에 대해 createdAt을 사용한다고 하면 수많은 엔티티의 insert 처리를 할때마다 다 데이터를 넣어 처리해줘야 한다.

 

그래서 DB 를 설계할때도 default 값으로 들어가도록 설정하는 경우가 있는데 이걸 columnDefinition으로 설정할 수 있다.

 

@Column(columnDefinition = "datetime default now()")
private LocalDateTime createdAt;

이렇게 설정하게 되면 DDL에서 쿼리를 확인했을 때 created_at datetime default now()로 생성되는것을 볼 수 있다.

 

여기서 주의해야할 점이 있따.

그럼 columnDefinition을 사용하지 않았을때 timestamp라는 데이터 타입이 기본적으로 생성되었으니까

데이터타입을 굳이 바꿔줄게 아니라면 default만 사용해도 되지 않을까??

 

@Column(columnDefinition = "default now()")
private LocalDateTime createdAt;

그래서 이렇게 테스트를 실행하고 DDL 쿼리를 확인해보면

created_at default now()

이렇게 테이터 타입이 사라진 것을 볼 수 있다.

 

왜 이렇게 되는지에 대해서는 Ejb3Column.java에서 확인할 수 있다.

 

코드가 좀 길게 되어있는데 이부분에서 확인이 가능하다

columnDefinition이 비어있다면 sqlType은 null이 된다.

이때 jpa에서 entity에 명시한 타입에 따라 데이터 타입을 넣어준다고 볼 수 있다.

그럼 columnDefinition이 비어있지 않다면??

columnDefinition의 값이 그대로 applyGlobalQuoting에 인자로 넘어가기 때문에 validaition 없이 값 그대로 들어간다고 볼 수 있다.

 

그래서 아예 작성하지 않는다면 알아서 타입을 잡아주지만 그렇지 않다면 있는 그대로 넘겨버리기 때문에 꼭 데이터 타입을 같이 적어줘야 한다.

 

 

table 속성은 하나의 엔티티를 두개 이상의 테이블에 매핑할 때 사용한다.

이걸 사용하게 되면 해당 필드를 다른 테이블에 매핑할 수 있다.

타입은 String 타입으로 매핑하고자 하는 테이블을 명시하면 된다.

 

하지만 @SecondaryTable 어노테이션을 사용하야 하고 @SecondaryTable은 위에서 말한것 처럼 하나의 엔티티를 두개 이상의 테이블에 매핑하기 위해 사용하는 어노테이션이다.

 

아무래도 하나의 엔티티가 두개의 테이블에 매핑되다보니 엔티티 하나를 조회하는데 테이블 2개를 조회해야 하기 때문에 최적화가 어렵다고 한다.

그리고 잘 사용하지도 않는다고 한다.

 

이 table 속성에 대한것은 레퍼런스에서 확인.

 

 

length 속성은 문자 길이 제약조건이다.

쉽게 보통 DB 설계할때 VARCHAR(255) 이렇게 만드는것처럼 문자열 길이를 명시한다.

default는 255이다.

 

@Column(length = 20)
private String name;

이렇게 실행하게 되면 name 컬럼은 varchar(20)이 된다.

 

 

precision과 scale은 BigDecimal이나 BigInteger에서 사용한다.

precision은 소수점을 포함한 전체 자리수를, scale은 소수의 자리수를 명시한다.

 

 

 

그럼 여기서 아직 확인하지 않은 속성이 insertable과 updatable인데 이 두 속성을 빼둔 이유는 위의 속성들은 DDL 생성시에 적용되지만 이 두 속성은 DML에도 영향을 끼치기 때문이다.

 

의미 그대로 엔티티 저장시, 수정시에 영향을 끼치는 속성으로 insertable은 엔티티 저장시에 이 필드도 같이 저장을 하고자 할때 사용하며 updatable은 엔티티 수정시에 같이 수정하고자 할때 사용한다.

 

둘다 동일하게 false로 설정하게 되면 DB에 적용되지 않고 false는 읽기전용으로 사용하고자 할때만 사용한다.

default는 true로 설정되어 있다.

 

@Column(updatable = false, columnDefinition = "datetime default now()")
@CreatedDate
private LocalDateTime createdAt;

@Column(columnDefinition = "datetime default now()")
@LastModifiedDate
private LocalDateTime updatedAt;

이런 형태로 설정하게 되는데 createdAt은 처음 insert 시에만 저장되어야 하기 때문에 updatable을 false로 설정해 수정이 발생할때는 createdAt은 수정하지 않게 된다.

그리고 updatedAt은 처음 insert시에도 같이 저장되어야 하지만 수정될때마다 계속해서 수정되어야 하기 때문에 아무것도 명시하지 않게 되면 default가 true이기 때문에 insertable = true, updatable = true 상태가 되어 저장시, 수정시 모두 처리하게 된다.

 

 

 

 

1. @Transient

@Transient 어노테이션은 DB에 존재하지 않지만 객체로서 사용하고 싶을 때 붙여 사용한다.

이 어노테이션이 붙어있는 필드는 영속성 처리에서 제외가 되기 때문에 DB 데이터에 반영이 되지 않고 해당 객체와 생명주기를 같이하게 되는 값이 된다.

 

이전에 설명한 insertable과 updatable의 false 기능에 추가적으로 select도 불가능하고 auto-ddl을 수행할때도 DDL에 반영되지 않도록 하는 어노테이션이라고 보면된다.

 

말그대로 DB에 전혀 영향을 주지 않고 쿼리문에도 영향을 끼칠 수 없는 단순한 객체로 만들어주는 어노테이션이다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • @Column의 table 속성

 

 

JPA - 하나의 엔티티에 다수 테이블 매핑(@SecondaryTable,@SecondaryTables)

JPA - 하나의 엔티티에 다수 테이블 매핑(@SecondaryTable,@SecondaryTables) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Entity @Table(name = "BOARD") @SecondaryTable(name = "BOARD_DETAIL",        ..

coding-start.tistory.com

 

'Spring' 카테고리의 다른 글

JPA Entity Listener 2  (0) 2022.03.17
JPA Entity Listener 1  (0) 2022.03.16
Jpa Entity 기본 Annotation 1 (@GeneratedValue, @Table)  (0) 2022.03.14
JPA Enum 적용  (0) 2022.02.19
JPA QueryMethod 2 (where 절 조건 추가)  (0) 2022.02.17

Entity내에서 많이 사용할법한 기본 어노테이션을 정리.

정리할 어노테이션은 아래와 같다.

 

  1. @GeneratedValue
  2. @Table
  3. @Column
  4. @Transient

정리전 예제 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

1. @GeneratedValue

@GeneratedValue는 JPA 기초 포스팅에서도 정리했지만 기본키 생성을 데이터베이스에게 위임해 id값을 따로 할당하지 않아도 데이터베이스가 자동으로 기본키를 생성해주도록 하는 어노테이션이다.

속성으로는 TABLE, SEQUENCE, IDENTITY, AUTO가 존재한다.

타입은 enum 클래스인 GenerationType에서 볼 수 있다.

사용은 아래와 같이 한다.

좀 더 자세한 사용법은 각 전략을 설명하면서 추가.

//AUTO

@Entity
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.AUTO) //AUTO는 default이기 때문에 strategy를 명시하지 않아도 된다.
  private Long id;
  
  ...
  
}

//IDENTITY

@Entity
public class User {

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

//SEQUENCE

@Entity
public class User {

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


//TABLE

@Entity
public class User {

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

 

@GeneratedValue에 들어가서 보면 default가 AUTO로 되어있는데 이 경우 각 DB에 적합한 값을 자동으로 넘겨 처리해주게 된다.

DB 의존성 없이 코딩할 수 있다는 장점때문에 AUTO를 사용하는 경우도 있다.

 

 

IDENTITY는 MySQL에서 주로 많이 사용하는 전략이며 MySQL에서의 Auto Increment라고 볼 수 있다.

 

 

SEQUENCE는 Oracle이나 Postgre, h2에서 사용한다.

SEQUENCE 전략을 사용한다면 @SequenceGenerator 어노테이션이 필요하다.

 

@Entity
@SequenceGenerator(
    name = "USER_SEQ",
    sequenceName = "USER_PK_SEQ",
    initialValue = 1,
    allocationSize = 50
    )
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ")
  private Long id;
  
  ...
  
}

 

@SequenceGenerator는 이렇게 사용하고 속성으로는 name, sequenceName, initialValue, allocationSize, catalog, schema가 있다.

 

name은 식별자 생성기의 이름.

sequenceName은 데이터베이스에 등록할 sequence명.

initialValue는 sequence 초기값.

allocationSize는 sequence 호출시에 증가하는 수이며 기본값은 50이다. 이 크기를 적당히 설정해야 성능저하를 개선할 수 있다.

catalog, schema는 데이터베이스 catalog, schema 이름이다.

 

그리고 @GeneratedValue에서 generator에 name을 넣어서 사용하게 된다.

 

 

TABLE은 DB 종류에 상관없이 아이디값을 관리하는 별도의 테이블을 만들어두고 그 테이블에서 아이디값을 계속 추출해서 사용할 수 있도록 하고 있다.

Sequence랑 유사하며 @TableGenerator 어노테이션이 필요하다.

@TableGenerator는 @SequenceGenerator와 비슷한 속성을 사용한다.

별도의 테이블을 만들어두고 그 테이블에서 아이디값을 추출한다는 것은 Oracle에서 sequence를 만들어 사용하는것처럼 Sequence 테이블을 만들어 아이디값을 관리하고 이 테이블을 조회해 아이디 값을 가져온 뒤에 업데이트해 다음에 사용할 값을 증가시키는 형태로 운영하는 것을 의미한다.

하지만 최적화 되지 않은 테이블에서 키를 생성하기 때문에 성능 이슈가 발생할 수 있다.

 

@Entity
@TableGenerator(
    name = "USER_SEQ",
    table = "USER_PK_SEQ",
    pkColumnName = "USER_SEQ_NAME",
    pkColumnValue = "USER_SEQ_KEY",
    valueColumnName = "USER_SEQ_VAL",
    initialValue = 0,
    allocationSize = 50
    )
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.TABLE, generator = "USER_SEQ")
  private Long id;
  
  ...
  
}

@TaleGenerator 속성을 좀 보자면

name은 식별자 생성기의 이름.

table은 아이디값을 관리하는 테이블명.

pkColumnName은 컬럼명.

pkColumnValue는 키로 사용할 값의 이름.

valueColumnName은 Sequence 값의 컬럼명.

initialValue는 시작값. 이건 DDL 생성시에 사용한다.

allocationSize는 호출시 증가하는 수. 위에서 설명한것과 같이 적절한 크기를 잘 설정해야 한다.

 

좀 더 이해하기 편하게 표로 정리.

user_pk_seq(테이블명)
user_seq_name (pkColumnName) user_seq_val (valueColumnName)
user_seq_key (pkColumnValue) 0 (DDL로 생성시 초기 initialValue)

 

 

2. @Table

 

@Table 어노테이션은 테이블에 catalog나 schema를 지정해서 사용할 수 있고 name 역시 지정할 수 있다.

name은 일반적으로 Entity 이름에 맞는 Table name을 자동으로 지정해주기 때문에 따로 사용할 일은 없지만 특별한 경우에 name이나 schema, catalog를 지정할 일이 생긴다.

 

@Entity
@Table(name = "user_legacy")
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  ...
  
}

 

일반적으로 Entity와 동일한 테이블이 DB에 생성되게 되는데 위 처럼 @Table 어노테이션을 통해 name을 설정하게 되면 user 테이블 대신 user_legacy 테이블이 생성된다.

DB 마이그레이션을 하거나 legacy DB에 대해 적용해야 하는 경우 사용할만한 경우고 일반적으로는 Entity 명과 테이블명이 동일한것이 제일 좋은 방식이다.

 

그리고 @Table 어노테이션에서는 unique Constraints와 indexes를 사용할 수 있다.

@Entity
@Table(name = "user",
       indexes = { @Index(ColumnList = "name") },
       uniqueConstraints = { @UniqueConstraint(columnNames = {"email"}) }
       )
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  ...
  
}

 

이런식으로 사용하게 되는데 그럼 로그를 확인해보면 create index .... on user(name) 과 

alter table user add constraint ... unique (email) 이런 로그를 확인할 수 있다.

로그를 봤을때 indexes 속성의 경우는 해당 엔티티의 index를 만들어주는데 columnList의 컬럼으로 인덱스를 생성한다는 것을 할 수 있다.

 

그리고 uniqueConstraints 속성으로 columnNames의 컬럼에 unique 제약조건을 걸어주게 된다.

 

좀 더 추가적인 속성들을 보자면 indexes의 경우는 index 인터페이스에서 확인할 수 있으며 name, columnList, unique라는 세가지 속성이 있는 것을 볼 수 있다.

 

name은 생성하고자 하는 index 명이다.

unique의 경우 타입이 boolean으로 되어있는것을 볼 수 있고 default는 false로 되어있다.

unique index를 생성할것인가에 대한 전략인데 기본이 false이기 때문에 따로 설정하지 않는다면

일반적인 index, unique = true로 설정한다면 unique index가 생성된다.

그리고 columnList는 지정할 컬럼이 꼭 있어야 하기 때문에 필수로 넣어야 한다.

 

uniqueConstarint도 name 속성이 존재하며 제약조건명을 적어주면 된다.

 

@Entity
@Table(name = "user",
       indexes = { @Index(ColumnList = "name", name = "user_index", unique = true) },
       uniqueConstraints = { @UniqueConstraint(columnNames = {"email", "name"}, name = "user_unique_constraint") }
       )
public class User {

  @Id
  @GeneratedValue
  private Long id;
  
  ...
  
}

이렇게 사용한다.

 

여기서 주의해야할 점은 이 index나 constriant는 실제 DB에 적용되어있는것과 다를 수 있다는 점이다.

JPAEntity를 활용해 DB DDL을 생성하는 경우에는 어노테이션에 명시한 속성대로 적용이 되지만 일반적으로 많이 사용되는 CRUD 쿼리에 대해서는 아무런 영향을 주지 못한다.

 

즉, 실제 DB에 Index가 적용되어 있지 않은 상태인데 JPA에 index가 적용되어 있다고 해서 index를 활용한 쿼리가 동작하거나 하는 것은 아니라는 것이다.

 

호불호가 있는 편이지만 보편적으로는 index나 contraint들은 DB에 맡기고 JPA에서는 표기하지 않는 경우가 좀 더 많다고 한다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • @GenerationValue
 

@GeneratedValue 전략

직접 기본키를 생성하는 방법 @Id 어노테이션 만을 사용하여 기본키를 직접 할당해주는 방법이 있다. 기본키를 자동으로 생성하는 방법 4가지 > 기본키를 자동으로 생성할 때에는 @Id와 @GenerratedVa

velog.io

 

 

JPA - 식별자(@Id) 값 자동 생성

데이터베이스가 관리하는 테이블의 로(ROW)는 기본키(Primary Key)에 의해 식별되고, JPA가 관리하는 엔티티 객체는 @Id로 지정한 식별자 변수를 통해 식별됩니다. JPA는 테이블의 기본 키와 엔티티 식

kdg-is.tistory.com

 

'Spring' 카테고리의 다른 글

JPA Entity Listener 1  (0) 2022.03.16
Jpa Entity 기본 Annotation 2 (@Column, @Transient)  (0) 2022.03.15
JPA Enum 적용  (0) 2022.02.19
JPA QueryMethod 2 (where 절 조건 추가)  (0) 2022.02.17
JPA QueryMethod 1 (기본 키워드)  (0) 2022.02.14

JPA에 Enum을 적용해 처리하는 방법에 대해 정리.

JPA를 사용할때 MALE, FEMALE이나 TRUE, FALSE 형태의 코드로 저장하는 컬럼들을 사용할 때 enum을 사용하면 데이터를 관리하기에 용이하다.

 

강의 내용을 정리하는 포스팅이다보니 굉장히 단순하고 기초적인 내용만 있고 아직 제대로 프로젝트에서 활용해보기 전이기 때문에 좀 더 상세한 내용은 Reference에서 확인.

 

일단 공부한 환경은 아래와 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

예제는 MALE, FEMALE을 저장하는 컬럼이라는 가정하에 진행한다.

public enum Gender {

  MALE,
  FEMALE

}
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    private Gender gender;
}
public interface UserRepository extends JpaRepository<User, Long>{

}

 

DB 테이블은 User 테이블이고 컬럼은 MySQL기준 BIGINT id, VARCHAR name, VARCHAR email, INT gender가 존재한다.

h2를 통해 data.sql을 사용한다면 굳이 gender 값을 insert 하도록 할 필요는 없다.

 

테스트코드는 아래와 같이 작성.

 

@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void enumTest(){
        
        User user = userRepository.findById(1L).orElseThrow(RuntimeException::new);
        
        user.setGender(Gender.MALE);
        
        userRepository.save(user);
        
        userRepository.findAll().forEach(System.out::println);
        
}

 

이렇게 테스트를 실행해보면 첫번째 데이터에 gender=MALE로 값이 잘 들어가있는것을 확인할 수 있다.

근데 여기서 발생할 수 있는 장애포인트가 있다.

 

h2가 아닌 다른 데이터베이스를 사용하고 있다면 데이터를 확인했을 때 gender 에 0이 들어가 있는것을 볼 수 있다.

 

그럼 이제 여기서 수정하고 다시 테스트해볼 부분.

public enum Gender {

  FEMALE,
  MALE

}

enum클래스를 이렇게 순서를 바꿔서 다시 테스트를 해본뒤에 데이터를 확인해보면 1로 바뀐것을 볼 수 있다.

 

만약 h2 In-memory DB를 사용하고 있다면 NativeQuery를 만들어서 확인하면 된다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  @Query(value = "select * from user limit 1;", nativeQuery = true)
  Map<String, object> findRowRecord();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void enumTest(){
        
        User user = userRepository.findById(1L).orElseThrow(RuntimeException::new);
        
        user.setGender(Gender.MALE);
        
        userRepository.save(user);
        
        userRepository.findAll().forEach(System.out::println);
        
        System.out.println(userRepository.findRowRecord().get("gender"));
        
}

이렇게 테스트해보면 h2 In-memory DB에 data.sql을 사용하는 환경에서도 확인이 가능하다.

 

그럼 다시 테스트 결과로 돌아와서 enum 클래스에 MALE, FEMALE 로 작성했을때는 MALE을 save 해준 데이터에

gender 값이 0이고 순서를 바꿔줬을때는 1이 들어가있는것을 확인할 수 있었다.

 

이유는 @Enumerated에서 확인할 수 있다.

 

user Entity의 gender위에 @Enumerated를 붙여주고 안에 들어가서 확인해보면

이렇게 되어있는것을 확인할 수 있다.

EnumType value의 default는 ORDINAL이라는 것인데 enum에서 ORDINAL은 zeroIndex를 의미한다.

즉, 배열에서의 index값처럼 0부터 시작하는 형태라는것.

 

그렇기 때문에 MALE을 먼저 작성했을때는 0이 들어가고 위치를 바꿨을때는 1이 들어가는것이다.

DB에는 이렇게 숫자형태로 들어가지만 JPA Entity에 의해 가져와 출력될때는 Enum클래스를 통해 MALE 혹은 FEMALE로 출력이 되는것이다.

 

그럼 이제 여기서 발생할 수 있는 장애포인트는 Enum클래스에 상수를 추가하게 된다면 추가 하는 위치에 따른 기존 데이터에서의 오류가 발생하는것이다.

 

예를들어 성별을 선택하지 않은 경우 NONE이라는 값을 준다고 했을때

public enum Gender {

  NONE,
  MALE,
  FEMALE

}

 

이렇게 제일 상단에다가 추가해버리면 기존에 MALE로 선택해 저장했기 때문에 0이라는 값을 갖고 있던 데이터들이 모두 NONE으로 출력되는 문제가 발생한다.

 

그래서 보통 이렇게 default 형태로 저장하는 방법 보다는 상수명 그대로 저장하는 방법을 사용한다고 한다.

물론 포스팅 최 상단에서 언급했듯이 다른 형태로 저장하더라도 직접 명시해 처리하는 방법도 있다.

일단은 그냥 default 상태에서 처리하는 방법만 정리한다.

 

그럼 이제 이걸 이 상수명 그대로 저장하기 위해서 아래와 같이 수정한다.

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    @Enumerated(value = EnumType.STRING)
    private Gender gender;
}

 

그리고 h2 In-memory가 아닌 다른 DB를 사용하는 경우에는 gender 컬럼의 타입을 VARCHAR로 변경해준다.

그리고 원래 들어가있던 숫자형태의 gender 값을 FEMALE로 변경해준다.

gender값을 그대로 유지하거나 삭제만 한 상태로 테스트를 실행하게 되면 IllegalArgumentException이 발생하기 때문.

 

그리고 다시 테스트를 실행해보면 MALE로 변경된것을 볼 수 있다.

 

그럼 결과를 보면 @Enumrated(value = EnumType.STRING)을 붙여줌으로써

enum 클래스에 작성한 상수명 대로 값이 들어간다는것을 확인할 수 있다.

 

강의에서는 이렇기때문에 반드시 String으로 하는것을 추천한다고 하셨지만

아예 추가되거나 삭제될 일이 없는 값들이라면 사용해도 되지 않을까 싶긴 하다.

물론 어떻게 처리되는지에 대한 주석은 필수로 달아야 한다는 조건이 붙을것 같다.

 

포스팅 전 Jpa Enum으로 검색해 여러 포스팅들을 봤는데 보통은 상수명이 아닌 다른 값으로 넣게 된다면

여기처럼 0, 1 같은 숫자형태가 아닌 상수마다 명시해 처리할 수 있는 방법이 있었다.

 

이건 아직 해보지도 못했기 때문에 나중에 추가하거나 따로 포스팅..

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • Jpa Enum 적용
 

JPA Enum Type 적용기~

Enum를 JPA에 적용하며 있었던 문제와 해결한 내용입니다.

velog.io

 

 

지난 포스팅에서는 쿼리메소드의 기본 키워드에 대해 정리했다면

이번에는 where절에 조건을 추가하는 방법에 대해 포스팅.

 

where 절에 조건을 추가할 수 있는 키워드들은 Jpa Document에서

Appendix C: Repository query keywords -> Supported query method predicate keywords and modifiers

에서 확인할 수 있다.

 

일단 테스트 환경은 다음과 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

제일 처음에 해볼건 And와 Or다.

 

테스트는 아래와 같이 진행.

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    private LocalDateTime createdAt;
    
    private LocalDateTime updatedAt;
    
}
public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByEmailAndName(String email, String name);
  
  List<User> findByEmailOrName(String email, String name);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println(userRepository.findByEmailAndName("coco@gmail.com", "coco"));
        
        System.out.println(userRepository.findByEmailOrName("coco@gmail.com", "coco"));
        
}

현재 데이터는

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(1, 'coco', 'coco@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(2, 'mozzi', 'mozzi@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(3, 'cococo', 'cococo@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(4, 'cocococo', 'cocococo@gmail.com', now(), now());

insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) 
values(5, 'coco', 'coco2@gmail.com', now(), now());

이렇게 들어가있다.

그럼 테스트를 실행했을 때 And 조건에 맞는 1번 데이터만 출력하게 되고 or에서는 1,5번 데이터가 출력되게 된다.

 

로그에서 쿼리문을 확인해보면 where 절에서 email = ? and name = ? 과 email = ? or name = ? 이렇게 실행되고 있는것을 볼 수 있다.

어렵지 않은 부분이라 빠르게 패스.

 

 

다음은 after 와 before다.

위 데이터들을 보면 created_at과 updated_at으로 시간을 받고있는것을 볼 수 있다.

after와 before는 의미 그대로 이후 이전을 의미하고 보통 시간에 대한 조건으로 사용한다고 한다.

그래서 created_at을 통해 조회한다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByCreatedAtAfter(LocalDateTime yesterday);
  
  List<User> findByCreatedAtBefore(LocalDateTime tommorow);
  
  List<User> findByIdAfter(Long id);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("findByCreatedAtAfter : " + userRepository.findByCreatedAtAfter(LocalDateTime.now().minusDays(1L)));
        
        System.out.println("findByCreatedAtBefore : " + userRepository.findByCreatedAtBefore(LocalDateTime.now().plusDays(1L)));
        
        System.out.println("findByIdAfter : " + userRepository.findByIdAfter(4L));
        
}

이렇게 테스트를 하게 되면 어제날짜의 현재 시간 이후에 저장된 데이터들이 모두 출력되고

before에서는 내일날짜의 현재시간 이전에 저장된 데이터들이 모두 출력된다.

 

쿼리문에서는 after의 경우 created_at > ? 형태로 실행하고 before는 반대로 created_at < ? 형태로 실행된다.

 

마지막에 findByIdAfter의 경우 id 값이 4보다 큰 데이터만 출력한다.

 

after와 before는 시간에 대한 조건으로 사용한다고 했는데 결과를 보면 다른 형태로도 사용이 가능하다.

하지만 after와 before라는 의미의 가독성을 위해 보통 시간에 대한 조건에서만 사용한다고 한다.

 

 

 

다음은 greaterThan과 LessThan이다.

after, before와 동일한 형태의 쿼리가 실행된다.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByCreatedAtGreaterThan(LocalDateTime yesterday);
  
  List<User> findByCreatedAtLessThan(LocalDateTime tommorow);
  
  List<User> findByCreatedAtGreaterThanEqual(LocalDateTime yesterday);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("findByCreatedAtGreaterThan : " + userRepository.findByCreatedAtGreaterThan(LocalDateTime.now().minusDays(1L)));
        
        System.out.println("findByCreatedAtLessThan : " + userRepository.findByCreatedAtLessThan(LocalDateTime.now().plusDays(1L)));
        
        System.out.println("findByCreatedAtGreaterThanEqual : " + userRepository.findByCreatedAtGreaterThanEqual(LocalDateTime.now().minusDays(1L)));
        
}

테스트를 실행해보면 이전 after와 before테스트때의 쿼리와 완전 동일한것을 볼 수 있다.

단, GreaterThan와 LessThan은 동일하게 > < 형태지만 GreaterThanEqual의 경우 >= 형태로 동일한 값도 포함한 데이터를 조회하는것을 볼 수 있다.

 

의미 그대로 보면 큰것, 작은것이기 때문에 after와 before 처럼 똑같이 사용할 수 있지만 가독성을 위해

after와 before는 시간, GreaterThan, LessThan은 그 외 필드에 사용하는것이 좋다고 한다.

가독성을 생각안하면 어떻게 쓰던 상관은 없다는것.

 

그리고 after와 before에서는 Equals를 포함하지 않는다.

 

 

 

다음은 between이다.

범위 내의 데이터 조회를 하기 위해 사용하기 때문에 변수가 두가지 필요하다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByCreatedAtBetween(LocalDateTime yesterday, LocalDateTime tomorrow);

  List<User> findByIdBetween(Long id1, Long id2);

  List<User> findByIdGreaterThanEqualAndIdLessThanEqual(Long id1, Long id2);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByCreatedAtBetween : " + userRepository.findByCreatedAtBetween(LocalDateTime.now().minusDays(1L), LocalDateTime.now().plusDays(1L)));
      
      System.out.println("findByIdBetween : " + userRepository.findByIdBetween(2L, 4L));
      
      System.out.println("findByIdGreaterThanEqualAndIdLessThanEqual : " + userRepository.findByIdGreaterThanEqualAndIdLessThanEqual(2L, 4L));
        
}

이렇게 테스트를 진행하게 되면 날짜는 어제의 현재 시간 이후에 등록된 데이터라면 모두 출력하게 된다.

첫번째 테스트는 사실상 그냥 날짜도 가능하다 라는것을 보여주기 위함이고

두번째 테스트부터 보면 id 값이 2, 3, 4에 해당하는 데이터를 가져오게 된다.

between은 지정한 범위의 데이터를 모두 가져오기 때문에 지정 범위까지 모두 포함해 가져오게 된다.

 

로그에서 쿼리문을 보게 되면 id between ? and ?  형태가 된다.

 

마지막 3번째 테스트는 between을 사용하지 않고 풀어서 처리하는 경우다.

GreaterThanEqual은 >= ? 를 의미하고 LessThanEqual은 <= ? 를 의미하기 때문에 두개의 조건을 만족한다는것은

해당 범위를 포함한 그 안에 위치하는 데이터들을 의미하는것과 마찬가지다.

그래서 between과 동일한 결과를 출력하게 된다.

이렇게 사용하는경우는 Equal이 꼭 붙어있어야 이상, 이하로 조회되기 때문에 붙여줘야 하고 이부분은 잠재적인 오류 포인트로 자주 나타나는 부분이라고 한다.

개발툴이 많이 발달했어도 아직 쿼리상의 논리적인 오류는 잘 찾지 못하기 때문에 조심해야 한다고 한다.

어차피 같은 결과를 출력해야 한다면 between이 당연히 더 편하다!!!!!

이렇게도 사용이 가능하구나 정도만 알아두면 될듯!

 

 

다음은 Null값에 대한 조회다.

 

제일먼저 볼것은 isNotNull

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByIdIsNotNull();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByIdIsNotNull : " + userRepository.findByIdIsNotNull());
        
}

isNotNull은 의미 그대로 null이 아닌것을 조회한다.

primary key인 Id를 기준으로 조회했기 때문에 당연히 모든 데이터가 출력된다.

쿼리문만 잘 보면 되는데 로그에서 보면

where

  user0_.id is not null

이렇게 조회하는것을 볼 수 있다.

 

 

다음은 isNotEmpty다.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByIdIsNotEmpty();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByIdIsNotEmpty : " + userRepository.findByIdIsNotEmpty());
        
}

이렇게 그냥 테스트를 실행하면 오류가 발생한다.

오류 내용을 보면

IsEmpty / IsNotEmpty can only be used on collection properties!

IsEmpty와 isNotEmpty는 Collection 속성에서만 사용할 수 있다는 것이다.

Collection은 객체의 모음, 그룹이라 볼 수 있다.

아직 collection 포스팅은 안해서 좀 더 자세한건 제일 아래에 Reference에서 확인.

Collection을 이해해야 좀 더 보기 편하긴 하다..

Relation이 있어야 가능하다고 보면 될듯.

 

근데 지금 테스트에서는 문자열이 아닌 long타입이기 때문에 괜찮지만 만약 문자열을 사용한 경우에는 오해의 소지가 좀 생길 수 있다.

일반적인 not empty는 notNull이면서 NotEmpty인 경우이기 때문이다. 문자열에서는 ""가 empty를 의미하기때문에 NotEmpty를 사용했을 때 실수할 수도 있다.

 

중요한건 IsNotEmpty 에서의 NotEmpty는 문자열에서의 NotEmpty가 아닌 Collection에서의 NotEmpty를 의미한다.

즉, 비어있지 않으면 가져와! 라는것이 아니라 collection이 비어있지 않으면 가져오라는것이다.

 

그래서 쿼리를 좀 보기 위해 테스트를 하나 더 진행.

 

그리고 User Etity 필드 하나와 Entity하나를 추가해준다.

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
    
    private LocalDateTime createdAt;
    
    private LocalDateTime updatedAt;
    
    @OneToMany(fetch = FatchType.EAGER)
    private List<Address> address;
    
}
@Entity
public class Address {
  
  @Id
  private Long id;
  
}
public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByAddressIsNotEmpty();

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByAddressIsNotEmpty : " + userRepository.findByAddressIsNotEmpty());
        
}

Address는 데이터를 따로 넣지 않는다.

테스트를 실행하면 아무것도 출력되지 않는다.

Address에 데이터가 없으니까.

 

쿼리문만 보면 되니까 로그에서 쿼리문을 확인한다.

select
  user0_.id as id1_2_,
  user0_.created_at as created_2_2_,
  user0_.updated_at as updated_3_2_,
  user0_.email as email4_2_,
  user0_.name as name6_2_ 
from
  user user0_ 
    where
      exists (
        select
          address2_.id 
        from
          user_address address1_,
          address address2_ 
        where
          user0_.id=address1_.user_id 
          and address1_.address_id=address2_.id
      )

isNotEmpty라는 네이밍을 보면 Empty. 즉, 비어있지 않은 값을 조회한다고 생각하기 딱 좋다.

그럼 name을 기준으로 조회한다면 name is Not Null and name != ' ' 이런 형태를 생각하기 쉬운데

쿼리문을 보면 전혀 그런것이 아니라는것을 볼 수 있다.

where 절에서 exists를 통해 조회하는데 User안에 있는 address와 Address의 id를 비교해 동일한 값만 가져오도록 한다.

그럼 쿼리를 봤을때 결과가 나오는것은 isNotNull이랑 전혀 다른 결과가 나온다는것을 예측할 수 있다.

 

 

 

다음은 In와 NotIn.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByNameIn(List<String> names);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByNameIn : " + userRepository.findByNameIn(Lists.newArrayList("coco", "mozzi")));
        
}

In의 경우는 iterator타입인 List가 들어간다. 그리고 조회하는 타입이 제네릭으로 들어가야 하기 때문에

List<String> names 로 인자를 받는다.

 

테스트에서는 그냥 Lists로 테스트했지만 이렇게 하기 보다는 복수의 결과값을 리턴하는 다른 쿼리의 결과값을 이용해 조회한다.

In을 사용할때 조심해야하는 점은 Lists로 넣지 않고 쿼리 결과를 넘기도록 하게 되면 얼마나 많은 데이터가 in절로 넘어가는지 알수가 없다. in절안에 List의 길이가 너무 길어지게 되면 성능이슈가 발생할 수 있기 때문에 데이터가 어느정도 들어갈것인지를 사전에 검토하고 사용하는것이 좋다.

 

NotIn은 동일하게 사용하기 때문에 패스~!

 

 

다음은 containing, ending_with, starting_with.

이 세가지는 한번에 테스트.

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByNameStartingWith(String name);
  
  List<User> findByNameEndingWith(String name);
  
  List<User> findByNameContains(String name);
  
  List<User> findByNameLike(String name);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
      System.out.println("findByNameStartingWith : " + userRepository.findByNameStartingWith("mo"));
      
      System.out.println("findByNameEndingWith : " + userRepository.findByNameEndingWith("zi"));
      
      System.out.println("findByNameContains : " + userRepository.findByNameContains("zz"));
      
      System.out.println("findByNameLike : " + userRepository.findByNameLike("%oz%"));
      
}

Like를 추가해서 테스트를 진행했는데

일단 위 세가지만 좀 보자면 로그에서 쿼리를 봤을때는 셋다 동일하게

name like ? escape ? 로 출력되는것을 볼 수 있다.

그럼 차이를 보자면 출력되는 데이터의 name은 mozzi다.

그럼 메소드 네이밍이랑 같이 예측해보면 StartingWith는 mo%, EndingWith는 %zi, contating은 %zz% 형태라는것을

알 수 있다.

 

Like를 추가해서 작성한 이유는 차이를 좀 보기 위해서다.

나머지 세개의 메소드는 원하는 검색 키워드만 딱 입력해주면 되지만 Like는 그렇지 않다는것을 볼 수 있다.

이렇게 직접 입력하는 경우는 크게 어렵지 않을 수 있지만 만약 값을 받아서 검색을 한다고 하면

다른 메소드들은 (keyword) 형태로 작성해주면 되지만 Like의 경우는

("%" + keyword + "%") 이런 형태로 사용해야 하기때문에 아무래도 가독성이 떨어진다.

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
  • Collection

 

 

[JAVA] Java 컬렉션(Collection) 정리

[JAVA] Java 컬렉션(Collection) 정리 ■ Java Collections Framework(JCF) Java에서 컬렉션(Collection)이란 데이터의 집합, 그룹을 의미하며 JCF(Java Collections Framework)는 이러한 데이터, 자료구조인 컬..

gangnam-americano.tistory.com

 

QueryMethod는 메소드의 이름을 분석해서 JPQL 쿼리를 실행한다.

사용하기 위해서는 JpaRepository를 상속받아야 한다.

 

일단 테스트 환경은 다음과 같다.

  • Intelli J
  • SpringBoot 2.6.2
  • Lombok
  • Gradle 7.3.2

 

이전 포스팅인 JpaRepository를 뜯어보면서 거기서 지원해주는 findById, findAll 등등 조회를 도와주는 메소드들을 확인했었다.

근데 대부분 메소드들이 Id를 인자로 받아 처리하는 메소드가 많았는데 사실상 개발하다보면 Id가 아닌 다른 필드로 where절을 구성하는 경우도 많다.

물론 @Query 어노테이션으로 쿼리를 직접 작성하는 방법도 있지만 간단한 쿼리를 편하게 사용할 수 있는 쿼리메소드를 먼저 정리한다.

 

쿼리메소드는 특정 키워드가 존재한다.

이 키워드들은 Jpa Document에서 확인할 수 있고 목록 하단 Appendix C: Repository query keywords 아래에

Supported query method subject keywords에서 확인할 수 있다.

 

Spring Data JPA - Reference Documentation

Example 109. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

 

 

Description을 보면 Can be used as findBy..., findMyDomainTypeBy... or in combination with additional keywords.

라는 문장을 볼 수 있는데 ...부분에 도메인타입을 넣어주면 된다는것이다.

User에 대한 데이터 조회를 한다고 하면 findUserBy~~~ 이런 형태로.

하지만 생략도 가능하다.

그리고 보통 이미 상속받는 JpaRepository에 타입을 명시하기 때문에 findBy~~~ 형태로 단축해서 많이 사용한다고 한다.

 

 

테스트는 아래와 같이 진행했다.

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User{
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NonNull
    private String name;
    
    @NonNull
    private String email;
}
public interface UserRepository extends JpaRepository<User, Long>{

  User findByName(String name);

}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println(userRepository.findByName("coco"));
        
}

이렇게 테스트를 실행하게 되면 정상적으로 coco라는 이름을 갖고 있는 데이터를 조회해 출력해준다.

단, 이렇게 테스트를 실행했는데 만약 coco라는 이름을 갖고 있는 데이터가 복수로 존재한다면

IncorrectResultSizeDataAccessException이 발생한다.

UserRepository에서 findByName은 User 단일 객체 타입인데 여러개의 객체가 조회되기 때문에 발생하는 에러다.

이런 경우 Repository에서 타입을 List타입으로 변경해주면 된다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  List<User> findByName(String name);

}

 

복수의 데이터를 가져오기 위해서는 무조건 List만 써야하는것은 아니고 Set도 가능하다.

 

그리고 JpaRepository안에 있는 메소드들 처럼 Optional 타입도 가능하다.

이렇게 리턴 타입에 대해서도 JpaDocument에 나와있다.

 

Spring Data JPA - Reference Documentation

Example 109. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

 

이렇게 여러 리턴 타입을 사용한다는 것은 개발자가 리턴되는 레코드의 수를 예측해 미리 타입을 정의해야 한다고 볼 수 있다.

 

Query method subject keywords를 보면 find...By, read...By, get...By, query...By, search...By, stream...By가 묶여있는것을 볼 수 있다.

 

그래서 테스트를 한번 실행.

 

public interface UserRepository extends JpaRepository<User, Long>{

  User findByName(String name);
  User getByName(String name);
  User readByName(String name);
  User queryByName(String name);
  User searchByName(String name);
  User streamByName(String name);
  User findUserByName(String name);
  User findSomethingByName(String name);
  
}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("findByName : " + userRepository.findByName("coco"));
        System.out.println("getByName : " + userRepository.getByName("coco"));
        System.out.println("readByName : " + userRepository.readByName("coco"));
        System.out.println("queryByName : " + userRepository.queryByName("coco"));
        System.out.println("searchByName : " + userRepository.searchByName("coco"));
        System.out.println("streamByName : " + userRepository.streamByName("coco"));
        System.out.println("findUserByName : " + userRepository.findUserByName("coco"));

        System.out.println("findSomethingByName : " + userRepository.findSomethingByName("coco"));
        
}

이 테스트를 실행하면 모두 동일한 결과를 출력한다.

그럼 결국 묶여있는 모든 키워드가 동일하게 사용된다고 볼 수 있고 강의에서는 가독성을 생각해 편한것으로 작성하면 된다고 한다.

 

그리고 마지막 두줄의 테스트는 Entity 타입을 인식하는지를 볼 수 있는 테스트다.

findUserByName이나 findSomethigByName이나 둘다 동일한 결과를 리턴하는데 그말은 결국 키워드의 ... 부분에는

존재하는 제대로 된 Entity 타입을 넣어야 하는것이 아닌 단지 가독성을 위한 것이고 인식하는 것은 맨 앞 find 와 by 두 키워드를 인식하고 그 뒤의 필드명을 통해 처리한다고 볼 수 있다.

 

그리고 생각보다 자주 발생하는 오류라고 나왔던 문제점 하나.

public interface UserRepository extends JpaRepository<User, Long>{

  User findByByName(String name);
  
}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("findByByName : " + userRepository.findByByName("coco"));
        
}

이렇게 ByBy 형태로 두번 들어가게 되면 오류가 발생한다.

find 와 By를 인식하기 때문에 ByBy의 경우 둘 다 인식하는듯 하다.

테스트를 실행해보면 BeanCreationException이 발생하고 쭉 옆으로 넘겨서 보면 

QueryCreationException: Could not create query for public abstract ...... UserRepository.findByByName(java.lang.String)! Reason: Failed to create query for .......

이런 부분을 볼 수 있는데 query를 만드는 과정에서 발생한 Exception이며 findByByName 쿼리를 생성할 수 없다는 것이다.

Jpa에서는 키워드를 통해 쿼리문을 생성하는데 등록되지 않은 키워드인 find ByBy가 나오니 오류가 발생하는것이다.

 

 

다음은 exists...By와 count...By다.

이전 Repository 내의 메소드와 동일하다고 볼 수 있다.

exists...By는 존재여부, count...By는 개수를 출력해준다.

public interface UserRepository extends JpaRepository<User, Long>{

  Boolean existsByName(String name);
  
  int countByName(Stirng name);
  
}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    void select(){
        
        System.out.println("existsByName : " + userRepository.existsByName("coco"));
        
        System.out.println("countByName : " + userRepository.countByName("coco"));
        
}

exists By는 boolean타입으로 리턴하고 count는 숫자 결과를 리턴한다.

그래서 결과는 존재하는 데이터를 조회했다면 true와 그 개수를 출력해준다.

 

 

delete는 아래와 같이 사용한다.

public interface UserRepository extends JpaRepository<User, Long>{

  int deleteByName(String name);
  
}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    @Transactional
    void select(){
        
        userRepository.findAll().forEach(System.out::println);
        
        System.out.println(userRepository.deleteByName("coco"));
        
        userRepository.findAll().forEach(System.out::println);
        
}

키워드에서 오는 의미 그대로 삭제해주는 쿼리메소드다. Jpa document에서 보면 remove와 묶여있는데 동일하게 사용되기 때문에 delete만 작성.

결과는 모든 객체를 출력해준 뒤 coco라는 이름을 갖고 있는 데이터 개수를 출력해주고 해당 데이터가 삭제 된 뒤 남은 데이터들을 출력해준다.

 

JpaDocument에서 키워드를 보면 결과가 없거나 삭제 개수를 리턴한다고 되어 있다.

그래서 deleteByName을 출력하도록 하면 삭제될 데이터 개수가 출력되는 것이다.

그렇기 때문에 Repository에서 타입을 void나 int로 처리해주면 된다.

당연히 void로 하면 출력하도록 할 수 없다.

 

만약 Entity 타입을 그대로 사용하게 되면 ClassCastException이 발생한다.

 

그리고 중요한점.

기존 포스팅에서도 그렇고 대체적으로 테스트코드에서 @Transactional 어노테이션을 거의 사용하지 않았는데

deleteBy를 사용하려면 꼭 붙여줘야 한다.

붙이지 않으면 TransactionRequiredException이 발생한다.

 

근데 deleteBy는 잘 안쓰고 JpaRepository에 있는 delete 메소드로 많이 사용한다고 한다.

 

 

다음은 First와 Top이다.

Document의 Description에서 볼 수 있듯이 결과를 첫번째 데이터만 리턴한다.

테스트를 위해 5개의 데이터의 Name을 coco로 통일했다.

 

public interface UserRepository extends JpaRepository<User, Long>{

  User findTop1ByName(String name);
  
  List<user> findFirst2ByName(String name);
  
}
@SpringBootTest
class UserRepositoryTest{

    @Autowired
    private UserRepository userRepository;
    
    @Test
    @Transactional
    void select(){
        
        System.out.println("findTop1ByName : " + userRepository.findTop1ByName("coco"));
        
        System.out.println("findFirst2ByName : " + userRepository.findFirst2ByName("coco"));
        
}

이렇게 실행하면 top1에서는 id가 1인 데이터 하나만 출력하고 first2에서는 id가 1, 2 인 두개의 데이터만 출력한다.

repository에서는 Top은 User타입으로 first는 List 타입으로 받았는데 둘다 상관없다.

물론 first2ByName처럼 복수의 데이터가 리턴되어야 하는데 단일 객체 타입으로 받으면 오류가 발생한다.

Jpa Document에서는 First<number>, Top<number> 로 나타나있는데 메소드에 그냥 위 처럼 사용해주면 된다.

숫자만 붙여주면 된다.

 

그리고 이 두 쿼리 메소드의 쿼리를 확인해보면 limit를 통해 가져오는것을 볼 수 있다.

 

강의에서 first나 top이 있으니 last도 사용할 수 있지 않을까요? 했는데 findLast1ByName을 하게 되면

findByName과 동일한 결과가 출력된다.

즉, 정의되어있지 않은 키워드라는 의미다.

 

 

이 쿼리메소드들은 and, or 등 조건을 추가할 수 있는데 그건 다음 포스팅에서............................................................

 

 

 

Reference

  • 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA

+ Recent posts