JPA 사용 시 주의할 점

영속 상태 보장

JPA Entity는 영속상태인가, 아닌가에 따라서 동작 상 제한사항이 있을 수 있다.

예를 들면, 영속 상태의 entity는 delete가 가능하지만 비영속 상태의 entity는 불가능하다.

특정 로직을 수행하는 경우 영속 상태를 인지하고 있어야한다.

@Transactional
public void deleteQuestion(User loginUser, Question question) throws CannotDeleteException {

    if (question.isDeleted()) {
        throw new CannotDeleteException("이미 삭제된 질문입니다.");
    }

    if (!question.isOwner(loginUser)) {
        throw new CannotDeleteException("질문을 삭제할 권한이 없습니다.");
    }

    List<Answer> answers = question.getAnswers();
    for (Answer answer : answers) {
        if (!answer.isOwner(loginUser)) {
            throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다.");

    List<DeleteHistory> deleteHistories = new ArrayList<>();
    question.setDeleted(true);
    deleteHistories.add(new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), LocalDateTime.now()));
    for (Answer answer : answers) {
        answer.setDeleted(true);
        deleteHistories.add(new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), LocalDateTime.now()));
    }
    deleteHistoryService.saveAll(deleteHistories);
}

위의 코드는 질문에 쓰여진 답변을 삭제하는 서비스 메소드이다.

메소드 명은 deleteQuestion 이며 매개변수로 Question 을 넘겨주고 있다.

entity를 삭제하기 위해서는 넘겨받는 Question 이 반드시 영속 상태여야 하지만

코드 사용자 측에서는 실수로 비영속 상태의 Question 을 넘겨 버릴 가능성이 있다.

Question 대신 question_id를 넘기도록 수정하면 영속 상태를 보장할 수 있다.

@Transactional
public void deleteQuestion(long loginUserId, long questionId) throws CannotDeleteException {

    Question question =
        questionRepository.findById(questionId)
                            .orElseThrow(() -> new CannotDeleteException("질문을 찾을 수 없습니다."));

    if (question.isDeleted()) {
        throw new CannotDeleteException("이미 삭제된 질문입니다.");
    }

    User loginUser =
        userRepository.findById(loginUserId)
                        .orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다."));

    if (!question.isOwner(loginUser)) {
        throw new CannotDeleteException("질문을 삭제할 권한이 없습니다.");
    }

    List<Answer> answers = question.getAnswers();
    for (Answer answer : answers) {
        if (!answer.isOwner(loginUser)) {
            throw new CannotDeleteException("다른 사람이 쓴 답변이 있어 삭제할 수 없습니다.");
        }
    }

    List<DeleteHistory> deleteHistories = new ArrayList<>();
    question.delete();
    deleteHistories.add(new DeleteHistory(ContentType.QUESTION, question.getId(), question.getWriter(), LocalDateTime.now()));
    for (Answer answer : answers) {
        question.deleteAnswer(answer);
        deleteHistories.add(new DeleteHistory(ContentType.ANSWER, answer.getId(), answer.getWriter(), LocalDateTime.now()));
    }
    deleteHistoryService.saveAll(deleteHistories);
}

의존성 사이클

의존성 세미나 자료

https://youtu.be/dJ5C4qRqAgA

[수정본] 우아한 객체지향


의존성 사이클에 관한 문제는 JPA만의 문제점은 아니다.

다만 JPA에서 다대일 관계를 표현하면 양방향으로 의존하는 소스 코드가 간혹 나타나기 때문에 함께 적었다.

객체 참조를 통해 더 객체지향적인 코드를 작성할 수 있지만 결합도가 크게 상승하여 문제가 될 수 있다.

질문과 답변의 예시를 살펴볼건데, 의존성은 아래처럼 순환하고 있다.

AnswerQuestionQuestionAnswersAnswer → ...

여기서 QuestionAnswersList<Answer> 를 일급 콜렉션으로 포장한 클래스이다.


@Entity
public class Answer extends BaseEntity implements Serializable {

    private static final long serialVersionUID = 951549577740790162L;

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

    @ManyToOne
    @JoinColumn(name = "writer_id", foreignKey = @ForeignKey(name = "fk_answer_writer"))
    private User writer;

    @ManyToOne
    @JoinColumn(name = "question_id", foreignKey = @ForeignKey(name = "fk_answer_to_question"))
    private Question question;

    @Column(columnDefinition = "LONGTEXT")
    private String contents;

    @Column
    private boolean deleted = false;
}

@Entity
public class Question extends BaseEntity implements Serializable {

    private static final long serialVersionUID = -5316964078122252034L;

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

    @Column(length = 100)
    private String title;

    @Column(columnDefinition = "LONGTEXT")
    private String contents;

    @ManyToOne
    @JoinColumn(name = "writer_id", foreignKey = @ForeignKey(name = "fk_question_writer"))
    private User writer;

    @Embedded
    private QuestionAnswers questionAnswers;

    @Column
    private boolean deleted = false;
}

@Embeddable
public class QuestionAnswers implements Serializable {

    private static final long serialVersionUID = 8457250053092405727L;

    @OneToMany(mappedBy = "question")
    @Where(clause = "deleted = 0")
    private final Set<Answer> answers = new HashSet<>();
}

사이클 관계를 이루는 구조에서 하나의 클래스만 수정해도 다른 클래스에 전부 영향이 가게 된다.

예를 들어 QuestionAnswersSet<Answer>List<Answer> 로 교체한다고 하면 AnswerQuestion 에서 참조하는 부분을 모두 수정해주어야 한다.

@Entity
public class Answer extends BaseEntity implements Serializable {

    private static final long serialVersionUID = 951549577740790162L;

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

    @ManyToOne
    @JoinColumn(name = "writer_id", foreignKey = @ForeignKey(name = "fk_answer_writer"))
    private User writer;

    @Column // Question -> question_id
    private Long questionId;

    @Column(columnDefinition = "LONGTEXT")
    private String contents;

    @Column
    private boolean deleted = false;
}

Answer 의 필드였던 QuestionquestionId 로 바꿔주면서 사이클이 완전히 끊겼다.

필드 참조를 통해 얻어갈 수 있던 객체지향적인 장점은 잃게 되지만 결합도를 낮춰 코드 수정 위험도를 줄일 수 있다.

'Java > JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계 매핑  (0) 2020.04.07
[JPA] 연관관계 매핑 기초  (1) 2020.02.26
[JPA] 엔티티 매핑  (0) 2020.02.22
[JPA] EntityManager, 영속성 컨텍스트  (0) 2020.02.17

김영한 님의 자바 ORM 표준 JPA 프로그래밍을 읽고 정리한 내용입니다.

교보문고 링크

😀 다양한 연관관계 매핑

  • 엔티티 연관관계 매핑 시 고려할 3가지
    1. 다중성
    2. 단방향/양방향
    3. 연관관계의 주인

1. 다중성

연관관계는 보통 4가지 다중성을 갖는다.

  1. 다대일
  2. 일대다
  3. 일대일
  4. 다대다

다대일, 일대다 관계를 많이 사용하며 다대다 관계는 거의 사용되지 않는다.

2. 단방향/양방향

테이블과는 달리 객체는 참조용 필드를 가지며, 그 필드를 통해 연관 객체를 조회한다.

한 쪽이 다른 한 쪽을 일방적으로 참조하면 단방향이며 서로 참조하면 양방향이다.

3. 연관관계의 주인

테이블은 외래키 하나로 두 테이블이 연관 관계를 가질 수 있기 때문에 연관관계를 관리하는 포인트는 외래키 하나이다.

엔티티가 양방향으로 참조되면 A → B, B → A 둘 중 하나를 정하여 외래키를 관리해야 한다.

보통은 외래키를 가진 쪽을 연관관계의 주인으로 선택한다.

주인이 아닌 방향은 읽기만 가능하다.

연관관계의 주인은 mappedBy 속성을 사용할 수 없으며, 주인이 아닌 쪽에 mappedBy 로 주인 필드(외래키)를 지정한다.

🎨 다대일

다대일과 일대다는 항상 반대 방향에 존재한다.

외래키는 항상 쪽에 있기 때문에 연관관계의 주인은 항상 다쪽이다.

회원(N)과 팀(1)이 있으면 회원이 연관관계의 주인이다.

🔗 다대일 단방향

  1. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @ManyToOne
         @JoinColumn(name = "TEAM_ID")
         private Team team;
    
         ...
     }
  2. 팀 엔티티

     @Entity
     public class Team {
    
         @Id
         @GeneratedValue
         @Column(name = "TEAM_ID")
         private Long id;
    
         private String name;
    
         ...
     }

회원은 Member.team 으로 참조가 가능하지만, 팀에선 회원을 참조할 필드가 없어 단방향이다.

테이블로 구성한다면 외래키는 Member에만 존재한다.

🔗 다대일 양방향

  1. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @ManyToOne
         @JoinColumn(name = "TEAM_ID")
         private Team team;
    
         public void setTeam(Team team) {
             this.team = team;
    
             // 무한루프 방지
             if (!team.getMembers().contains(this)) {
                 team.getMembers().add(this);
             }
         }
     }
  2. 팀 엔티티

     @Entity
     public class Team {
    
         @Id
         @GeneratedValue
         @Column(name = "TEAM_ID")
         private Long id;
    
         private String name;
    
         @OneToMany(mappedBy = "team")
         private List<Member> members = new ArrayList<>();
    
         public void addMember(Member member) {
             this.members.add(member);
             // 무한루프 방지
             if (member.getTeam() != this) {
                 member.setTeam(this);
             }
         }
     }
  • 팀 엔티티에 List가 추가되었고, 주인 필드인 Member.team을 가르키고 있다.
    • Team.members 는 주인이 아니므로 조회가 필요할 때 사용한다.
  • 양방향 연관관계는 항상 서로를 참조해야 함에 주의한다.
    • setTeam, addMember는 서로를 참조할 때 무한루프에 빠지지 않도록 처리되어 있다.

📎 일대다

🔍 일대다 단방향

하나의 팀이 여러 팀원을 참조할 수 있는 경우가 일대다 단방향 관계이다.

  1. 팀 엔티티

     @Entity
     public class Team {
    
         @Id
         @GeneratedValue
         @Column(name = "TEAM_ID")
         private Long id;
    
         private String name;
    
         @OneToMany
         @JoinColumn(name = "TEAM_ID")
         private List<Member> members = new ArrayList<>();
    
         ...
     }
  2. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         ...
     }

팀 엔티티의 @JoinColumn 을 보면 일대다인 경우에도 N인 쪽에 외래키가 존재함을 알 수 있다.

엔티티가 관리하는 테이블과 다른 테이블에 외래키가 있기 때문에 insert 시 문제가 발생한다.

1) member 1, 2 생성
2) team 1 생성 및 member 1, 2의 소속을 team 1로 옮김.
3) member 1, 2, team 1 저장
    - member 1, 2의 저장엔 문제가 없다.
  - team 1 저장 시 team 1을 참조하는 member의 정보가 team 엔티티 내부에 없다.
  - 따라서, JPA는 update Member set TEAM_ID = ? where MEMBER_ID를 한번 더 실행할 수밖에 없다.

이러한 문제는 쿼리 성능에 문제를 끼치며 관리도 어려워진다.

일대다 대신 다대일을 사용하는 것을 권장한다.

🔍 일대다 양방향

다대일 양방향과 같은 말이지만 JPA에선 약간의 차이가 있다.

일단. 외래키는 무조건 다쪽에 존재해야 한다는 점엔 변화가 없다.

  1. 팀 엔티티

     @Entity
     public class Team {
    
         @Id
         @GeneratedValue
         @Column(name = "TEAM_ID")
         private Long id;
    
         private String name;
    
         @OneToMany
         @JoinColumn(name = "TEAM_ID")
         private List<Member> members = new ArrayList<>();
    
         ...
     }
  2. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @ManyToOne
         @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
         private Team team;
    
         ...
     }

다대일은 읽기 전용 필드 Team 이 추가되어 있다(insertable, updatable).

일대다 단방향 매핑이 갖는 성능, 관리 측면 문제가 그대로 존재하므로 웬만하면 다대일 단방향을 사용하는 방법이 권장된다.

👭 일대일

  • 일대일 관계는 반대도 일대일이다.
  • 일대일은 어느 곳이든 외래 키를 가질 수 있다.
  • 외래키 하나만 있으면 양쪽 조회가 가능하다.

외래키 소유 전략은 2가지이다.

  1. 주 테이블이 외래키 소유
    • 외래키를 객체 참조처럼 쓸 수 있어서 객체 지향 개발에 편리하다.
    • 주 테이블이 외래키를 가지므로 주 테이블만 확인해도 대상 테이블과 연관 관계를 확인할 수 있다.
  2. 대상 테이블이 외래키 소유
    • 일반적인 DB 개발자들은 이 방법을 선호한다.
    • 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조가 그대로 유지된다.

회원이 하나의 라커룸을 가지는 예시로 일대일 관계를 알아본다.

주 테이블은 회원, 대상 테이블은 라커룸이다.

🤼‍♂️ 주 테이블에 외래키

  1. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @OneToOne
         @JoinColumn(name = "LOCKER_ID")
         private Locker locker;
    
         ...
     }
  2. 라커룸 엔티티

     @Entity
     public class Locker {
    
         @Id
         @GeneratedValue
         @Column(name = "LOCKER_ID")
         private Long id;
    
         private String name;
    
         ...
     }

주 테이블인 회원에 Locker 필드(외래키)가 포함되어 있다.

👯‍♂️ 양방향

라커룸 엔티티를 약간만 바꾸어본다.

@Entity
public class Locker {

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

    private String name;

    @OneToOne(mappedBy = "member")
    private Member member;
    ...
}

주인 필드인 회원 엔티티가 외래키를 가지므로 주인 필드를 나타내는 mappedBy 속성과 @OneToOne 어노테이션을 추가했다.

🤼‍♀️ 대상 테이블에 외래키

JPA 2.0 이상

JPA 2.0부터 대상 테이블에 외래키가 있는 매핑이 지원된다.

위 예시와 반대로 LockerMember 필드를 추가하면 된다.

👯‍♀️ 양방향

위 예시와 반대로 Member.locker 가 주인 필드인 Locker.member 를 가리키면 된다.

👨‍👩‍👧‍👧 다대다

RDBMS는 정규화된 2개의 테이블로 다대다 관계를 표현할 수 없으며 두 테이블을 연결하는 별도의 테이블이 필요하다.

회원과 상품이 다대다의 관계라고 하면 먼저 회원_상품 테이블을 생성한다.

  1. 회원 : 회원_상품 = 1:N
  2. 회원_상품 : 상품 = M:1

객체는 테이블과는 다르게 @ManyToMany 를 사용하여 다대다 관계를 깔끔하게 만들 수 있다.

👨‍👧‍👧 다대다 단방향

  1. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @ManyToMany
         @JoinTable(name = "MEMBER_PRODUCT",
                     joinColumns = @JoinColumn(name = "MEMBER_ID"),
                     inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
         private List<Product> products = new ArrayList<>();
    
         ...
     }
  2. 상품 엔티티

     @Entity
     public class Product {
    
         @Id
         @Column(name = "PRODUCT_ID")
         private String id;
    
         private String name;
    
         ...
     }

회원 엔티티에는 @ManyToMany 로 다대다 매핑 처리가 되어있다.

@JoinTable 은 연결 테이블을 매핑하는 어노테이션이다.

속성을 정리해보자.

  1. name
    • 연결할 별도의 테이블 이름을 지정한다.
  2. joinColumns
    • 현재 방향인 회원 엔티티를 기준으로, 매핑할 조인 컬럼 정보를 지정한다.
  3. inverseJoinColumns
    • 반대 방향인 상품 엔티티를 기준으로, 매핑할 조인 컬럼 정보를 지정한다.

회원_상품 테이블은 @JoinTable 의 name 속성에서만 존재할 뿐, 접근할 객체는 존재하지 않는다.

Product product1 = new Product();
product1.setId("product1");
product1.setName("상품1");
em.persist(product1);

Member member1 = new Member();
member1.setId("member1");
member1.setUsername("회원1");
member1.getProducts().add(product1);
em.persist(member1);

실제로 코드를 작성할 때도 회원_상품 테이블을 전혀 신경쓰지 않고 처리할 수 있다.

member.getProducts() 를 호출할 때 JPA가 해석하여 실제 테이블에 사용할 쿼리를 확인하면 MEMBER_PRODUCT이 FROM 절에 포함된다.

👨‍👩‍👧 다대다 양방향

다른 양방향과 마찬가지로 역방향도 @ManyToMany 를 지정하고, mappedBy로 주인 필드를 지정한다.

  • 상품 엔티티에 역방향 참조 추가

    @Entity
    public class Product {

      @Id
      @Column(name = "PRODUCT_ID")
      private String id;
    
      private String name;
    
      @ManyToMany(mappedBy = "products")
      private List<Member> members;
    
      ...

    }

🗝다대다 매핑 한계와 극복

실제 업무를 하다 보면 회원_상품 테이블은 단순히 연결을 위해서 양쪽 외래키만 포함하는 테이블로 만들지는 않는다.

이를테면 상품 주문 시간이나 주문량 같은 정보가 포함될 수 있다.

그렇게 되면 위에서 @ManyToMany 를 사용한 예제는 사용할 수 없기 때문에 별도의 엔티티를 만들어야 한다.

추가적으로 회원과 회원_상품, 회원_상품과 상품의 관계를 별도로 정의한다.

  1. 회원 엔티티

     @Entity
     public class Member {
    
         @Id
         @GeneratedValue
         @Column(name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @OneToMany(mappedBy = "member")
         private List<MemberProduct> emberProducts = new ArrayList<>();
    
         ...
     }
  2. 상품 엔티티

     @Entity
     public class Product {
    
         @Id
         @Column(name = "PRODUCT_ID")
         private String id;
    
         private String name;
    
         ...
     }
  3. 회원_상품 엔티티

     @Entity
     @IdClass(MemberProductId.class)
     public class MemberProduct {
    
         @Id
         @ManyToOne
         @JoinColumn(name = "MEMBER_ID")
         private Member member;
    
         @Id
         @ManyToOne
         @JoinColumn(name = "PRODUCT_ID")
         private Product product;
    
         private Date orderAmount;
    
         ...
     }
  4. MemberProductId 정의

     @NoArgsConstructor
     @EqualsAndHashCode
     public class MemberProductId implements Serializable {
         private String member;
         private String product;
     }

회원_상품 엔티티에는 @IdClass 어노테이션이 포함된다.

@IdClass 는 복합키를 사용하겠다는 의미이다.

MemberProductId 의 각 필드는 MemberProduct 엔티티의 Member 필드와 Product 필드에 매핑된다.

  • 복합키

    • 복합키는 별도의 클래스를 정의해야 하며(MemberProductId), 이 클래스는 public으로 선언되어야 한다.

    • Serializable 을 구현해야 한다.

    • Equals, Hashcode를 재정의해야 한다.

    • 기본 생성자가 있어야 한다.

    • @EmbeddedId 를 사용하여 정의하는 방법도 있다.

    • 회원_상품의 예시처럼 외래키를 자신의 기본키로 사용하는 것을 식별관계라고 한다.

      // 데이터 저장하기

      Member m1 = new Member();
      // m1 데이터 설정
      em.persist(member1);

      Product p1 = new Product();
      // p1 데이터 설정
      em.persist(p1);

      MemberProduct mp = new MemberProduct();
      mp.setMember(m1);
      mp.setProduct(p1);
      ...

      em.persist(mp);

      MemberProductId mpId = new MemberProductId();
      // mpId 데이터 설정

      MemberProduct mp = em.find(MemberProduct.class, mpId);
      Member m1 = mp.getMember();
      Product p1 = mp.getProduct();
      ...

💡다대다 테이블의 기본 키를 새로 정의하기

복합 키를 사용하면 식별자 클래스도 새로 정의해야 하는 등 굉장히 번거롭다.

반면 DB에서 자동 생성하는 Long 타입 대리키를 사용하면 간편하게 사용할 수 있다.

회원_상품 테이블을 주문이라고 다시 명명하고 새로 작성한다.

  • 주문 엔티티

      @Entity
      public class Order {
    
          @Id
          @GeneratedValue
          @Column(name = "ORDER_ID")
          private Long id;
    
          @ManyToOne
          @JoinColumn(name = "MEMBER_ID")
          private Member member;
    
          @ManyToOne
          @JoinColumn(name = "PRODUCT_ID")
          private Product product;
    
          private Date orderAmount;
    
          ...
      }

MemberProduct를 재정의한 Order 는 식별 관계가 사라졌다.

다대다 테이블은 복합키를 사용한 식별 관계, 대리키를 사용한 비식별 관계 중 적절한 방법을 선택하여 구현할 수 있다.

'Java > JPA' 카테고리의 다른 글

JPA 사용 시 주의할 점  (1) 2021.07.22
[JPA] 연관관계 매핑 기초  (1) 2020.02.26
[JPA] 엔티티 매핑  (0) 2020.02.22
[JPA] EntityManager, 영속성 컨텍스트  (0) 2020.02.17

김영한 님의 자바 ORM 표준 JPA 프로그래밍을 읽고 정리한 내용입니다.

교보문고 링크

연관관계 매핑 기초

객체는 참조를 사용하여 연관 관계를 맺고 테이블은 외래 키를 사용해서 연관 관계를 맺는다.

관계가 있는 다른 데이터를 참조한다는 점에서 동일하지만, 참조와 외래 키는 완전히 다른 특징을 갖는다.

연관관계 매핑을 이해하기 위한 두 가지 키워드가 있다.

  1. 방향 : 회원/팀이라는 관계를 생각해본다.

    • 단방향 : 회원 → 팀, 팀 → 회원 둘 중 한 쪽만 참조하는 관계
    • 양방향 : 회원 → 팀, 팀 → 회원 둘이 서로를 참조하는 관계
  2. 다중성

    • 1:1 (일대일)
    • 1:N (일대다 혹은 다대일)
    • N:M (다대다)
  3. 연관관계의 주인 : 양방향 연관관계를 만들 떄 연관 관계의 주인을 정해야 한다.

단방향 연관관계

가장 먼저 이해해야 할 것은 다대일 단방향 관계이다.

  • 회원은 하나의 팀에만 소속될 수 있다.
  • 회원과 팀은 다대일 관계이다.

SQL로 회원에 대한 정보를 가져오고 싶을 때, 회원 중심 혹은 팀 중심의 쿼리를 입력할 수 있다.

  1. 회원 중심

     SELECT *
     FROM MEMBER M
     INNER JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
  2. 팀 중심

     SELECT *
     FROM TEAM T
     INNER JOIN MEMBER M ON M.TEAM_ID = T.TEAM_ID

자바 객체는 필드를 사용하여 참조를 할 수 있다.

  1. 회원 중심

     class Team {
         Long id;
         ...
     }
     class Member {
         Long id;
         Team team;
         ...
     }
    
     // 참조는 아래처럼
     Member a = new Member();
     a.getTeam.getId();
  2. 팀 중심

     class Team {
         Long id;
         Member id;
         ...
     }
     class Member {
         Long id;
     }
    
     // 참조는 아래처럼
     Team a = new Team();
     a.getMember.getId();

둘은 비슷해 보이지만 큰 차이가 있다.

SQL문은 동일한 JOIN절 하나로 양방향 조인이 가능했다.

객체는 양방향 참조를 하기 위해서는 각 객체에 참조할 대상을 반드시 필드로 넣어줘야 한다.

객체 관계 매핑

JPA를 활용하여 연관관계를 매핑하려면 어노테이션을 사용한다.

  1. Member class

     @Entity
     @Getter
     @Setter
     public class Member {
    
         @Id
         @Column (name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @ManyToOne
         @JoinColumn (name = "TEAM_ID")
         private Team team;
     }
  2. Team class

     @Entity
     @Getter
     @Setter
     public class Team {
    
         @Id
         @Column (name = "TEAM_ID")
         private Long id;
    
         private String name;
     }

@ManyToOne

  • 다대일 관계를 나타내는 매핑 정보
  • 속성
    • optional (default true) : false로 설정하면 연관된 엔티티가 반드시 있어야 함.
    • fetch : 글로벌 패치 전략 설정
    • cascade : 영속성 전이 기능 사용
    • targetEntity : 연관된 엔티티의 타입 정보 설정 (targetEntity = Member.class 식으로 사용)

@JoinColumn (name="TEAM_ID")

  • 외래 키 매핑 시 사용
  • name 속성은 매핑할 외래키의 이름
  • 어노테이션을 생략해도 외래 키가 생성됨.
    • 생략 시 외래키의 이름이 기본 전략을 활용하여 생성된다.
  • 속성
    • name : 매핑할 외래 키의 이름
    • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명
    • foreignKey : 외래 키 제약조건 지정 (테이블 생성 시에만 적용됨)
    • unique/nullable/insertable/updateable/columnDefinition/table : @Column의 속성과 같음

연관관계 사용

CRUD 예제를 둘러보며 연관 관계 사용법을 익혀보자.

저장

public void save() {

    Team team = Team.builder()
        .name("team");
        .build();

    em.persist(team);

    Member member1 = Member.builder()
        .username("member1")
        .team(team)
        .build();

    em.persist(member1);

    Member member2 = Member.builder()
        .username("member2");
        .team(team)
        .build();

    em.persist(member2);
}

builder(), build()는 롬복 플러그인으로 생성되는 builder를 의미한다.

위 예제에서 회원 엔티티는 팀 엔티티를 참조하고 저장했다.

em.persist(team); 이 실행되는 순간 team 객체에는 id가 부여된다.

내부적으로는 이를 활용한 쿼리가 생성되어 DB에 저장하게 된다.

조회

연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지다.

  1. 객체 그래프 탐색 (객체 연관관계를 사용한 조회)

     Member member = em.find(Member.class, member1_id);
     Team team = member.getTeam();
  2. 객체지향 쿼리(JPQL) 사용

     private statid void read(EntityManager em) {
    
         String jpql = "select m from Member m join m.team t where" +
             "t.name=:teamName";
    
         List<Member> resultList = em.createQuery(jpql, Member.class)
             .setParameter("teamName", "team1")
             .getResultList();
     }

JPQL의 쿼리는 일반 쿼리처럼 ID 기반으로 조회하지 않고 엔티티 객체를 활용해서 조회하는 것에 가깝다.

중간에 t.name=:teamName 이라는 문구에서 :teamName과 같이 :로 시작하는 것은 파라미터를 바인딩받는 문법이다.

위의 JPQL은 아래의 쿼리로 변환되어 실행된다.

SELECT M.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE T.NAME = 'team1'

수정

회원1의 소속을 팀1에서 팀2로 변경해보자.

private static void update(EntityManager em) {

    Team team2 = Team.builder()
        .name("team2");
        .build();

    em.persist(team2);

    Member member = em.find(Member.class, member1_id);
    member.setTeam(team2);
}

트랜잭션이 커밋되는 순간 플러시가 일어나고 변경 감지 기능이 동작한다.

그리고 변경사항을 DB에 반영한다.

연관관계 제거

회원1의 소속을 제거하는 예제이다.

private static void delete(EntityManager em) {

    Member member1 = em.find(Member.class, member1_id);
    member1.setTeam(null);
}

team을 null로 설정하면 연관된 엔티티가 삭제된다.

만약 특정 팀을 삭제하고 싶다면 그 팀 소속의 회원 정보에서 특정 팀에 대한 정보를 모두 삭제해야 한다.

(데이터베이스의 제약 조건을 따른다)

양방향 연관관계

지금까지 예제로는 회원이 어느 팀 소속이었는지 쉽게 확인할 수 있었다.

이번엔 팀 소속의 회원이 누구인지를 더 쉽게 구해보는 양방향 연관 예제이다.

  • 회원은 1개의 소속 팀을 가질 수 있다. → Team (단일 객체 필드)
  • 팀에 소속된 회원은 N명이다 → List (복수 객체 필드)
    • List 말고 다른 Collection들도 지원한다.

Team 클래스는 List 필드를 갖지만 데이터베이스의 TEAM 테이블은 member id을 가질 필요가 없다.

DB에서는 MEMBER 테이블의 TEAM_ID 외래키 하나로도 양방향을 구현할 수 있기 때문이다.

양방향 관계를 갖도록 기존의 엔티티를 수정한다.

  1. Member class

     @Entity
     @Getter
     @Setter
     public class Member {
    
         @Id
         @Column (name = "MEMBER_ID")
         private Long id;
    
         private String username;
    
         @ManyToOne
         @JoinColumn (name = "TEAM_ID")
         private Team team;
     }
  2. Team class

     @Entity
     @Getter
     @Setter
     public class Team {
    
         @Id
         @Column (name = "TEAM_ID")
         private Long id;
    
         private String name;
    
         @OneToMany (mappedBy = "team")
         private List<Member> members = new ArrayList<>();
     }

Team 클래스에 List 타입 필드를 추가했다.

그리고 @OneToMany 어노테이션으로 매핑 정보를 표시했다.

mappedBy 속성은 양방향 매핑 시 반대쪽 매핑(Member 클래스의 Team team)값을 주면 된다.

추가된 리스트로 인해 소속 회원을 쉽게 조회할 수 있게 되었다.

private void memberList() {
    Team team = em.find(Team.class, team1_id);
    List<Member> members = team.getMembers();
}

연관관계의 주인

실제 쿼리는 TEAM_ID 하나로 양방향 매핑이 가능했지만, 객체 참조의 경우엔 양쪽 다 참조할 필드를 써주어야 한다.

이런 경우, 두 테이블에 서로의 고유 키를 외래 키로 포함할 필요가 없다.

mappedBy는 이런 상황에서 외래 키의 위치를 정의하기 위해 사용한다.

외래 키를 갖는 엔티티가 연관 관계의 주인이 되며, mappedBy는 주인을 명시한다.

  • Team.members에는 mappedBy="team" 속성을 사용한다.
  • Member.team이 주인이다.

양방향 연관관계 저장

mappedBy를 사용하여 양방향 연관관계를 설정 시 주의해야 할 점이 있다.

주인이 아닌 필드는 변경하더라도 저장되지 않는다는 것이다.

private void updateEntity(EntityManager em) {

    Team team = em.find(Team.class, team1_id);

    team.getMembers.add(
        Member.builder()
            .name("member3")
            .build();
    ); // 저장되지 않음
    team.getMembers.add(
        Member.builder()
            .name("member4")
            .build();
    ); // 저장되지 않음

    Member member1 = em.find(Member.class, member1_id);
    member1.setTeam(team2); // 저장됨
}

위의 예시에서 members의 변동 내역은 저장되지 않는다.

물리적인 TEAM 테이블에는 MEMBER 관련 컬럼이 존재하지 않기 때문이다.

반대로 MEMBER 테이블에는 TEAM_ID라는 컬럼이 존재하기 때문에 밑 두 줄의 명령은 DB에 반영된다.

  • 회원의 소속 변경?

    만약 member1의 소속을 바꾸고 싶다면, Member 클래스와 Team 클래스를 모두 바꾸어주는 것이 좋다.

    테이블 관점에서는 Member 클래스의 team 필드만 바꿔주면 되지만 JPA로 불러왔으나 아직 값이 변하지 않은 순수 자바 객체인 경우 문제가 생길 수 있다.

      private void example(EntityManager em) {
    
          Team team2 = em.find(Team.class, team2_id);
    
          // member1의 소속 팀은 현재 1
          Member member1 = em.find(Member.class, member1_id);
    
          // member1의 소속 팀이 2로 변경됨
          member1.setTeam(team2);
    
          for (Member member : team2.getMembers()) {
              // member1이 team2의 회원 목록에 없어 출력되지 않는다.
              System.out.println(member.getUsername());
          }
      }
  • Refactoring

    엔티티 코드를 조금만 변경하여 위와 같은 문제를 해결해보자.

      @Entity
      @Getter
      @Setter
      public class Member {
    
          @Id
          @Column (name = "MEMBER_ID")
          private Long id;
    
          private String username;
    
          @ManyToOne
          @JoinColumn (name = "TEAM_ID")
          @Setter (AccessLevel.NONE)
          private Team team;
    
          public void setTeam(Team team) {
    
              if (this.team != null) {
                  this.team.getMembers().remove(this);
              }
    
              this.team = team;
              team.getMembers().add(this);
          }
      }

    setTeam에서 소속 팀 변경 및 소속 팀의 회원 변경까지 한 번에 처리하면 개발자가 신경 쓸 요소를 줄일 수 있다.

'Java > JPA' 카테고리의 다른 글

JPA 사용 시 주의할 점  (1) 2021.07.22
[JPA] 다양한 연관관계 매핑  (0) 2020.04.07
[JPA] 엔티티 매핑  (0) 2020.02.22
[JPA] EntityManager, 영속성 컨텍스트  (0) 2020.02.17

김영한 님의 자바 ORM 표준 JPA 프로그래밍을 읽고 정리한 내용입니다.

교보문고 링크

엔티티 매핑

JPA에서 실제 테이블에 연결하는 객체를 엔티티라고 부른다.

따라서 엔티티와 테이블을 정확히 매핑시켜 사용하는 것이 매우 중요하다.

아래는 매핑을 위한 대표적인 Annotation 목록이다.

  1. 객체와 테이블 매핑
    • @Entity
    • @Table
  2. 기본 키 매핑
    • @Id
  3. 필드와 컬럼 매핑
    • @Column
  4. 연관관계 매핑
    • @ManyToOne
    • @JoinColumn

@Entity

테이블과 매핑할 클래스엔 @Entity가 반드시 붙어야 한다.

속성

  1. name (Default : 클래스명)
    • entity에 사용할 이름
    • 엔티티 중복은 허용되지 않는다.
    • entity의 이름과 테이블 이름은 다른 개념이다.

주의사항

  1. 기본 생성자는 반드시 존재해야 하며 public/proteced 접근자를 가져야 한다.
    • 자바는 기본 생성자를 만들지 않으면 빈 생성자를 자동으로 생성한다.
    • 그러나 별도의 생성자를 정의할 경우 기본 생성자가 만들어지지 않는다.
    • 그럴 땐 반드시 기본 생성자를 명시적으로 정의해야 한다.
  2. final, enum 클래스, 내부 클래스, 인터페이스엔 사용 불가능
  3. 저장할 필드에 final 사용 불가능

@Table

엔티티와 매핑할 테이블을 지정한다.

생략 시 엔티티 이름이 테이블 이름으로 사용된다.

@Table 속성

  1. name (Default : 엔티티명)

    • 매핑할 테이블 이름
  2. catalog

    • catalog 기능이 있는 DB에서 catalog 매핑
  3. schema

    • schema 기능이 있는 DB에서 schema 매핑
  4. uniqueConstraints

    • DDL 생성 시 유니크 제약조건 생성
    • 복합 제약조건 생성 가능
    • JPA의 스키마 자동 생성 기능을 사용하여 DDL을 만들 때에만 적용됨.

스키마 자동 생성하기

JPA는 엔티티와 테이블을 매핑하고 DB 스키마를 자동으로 생성한다.

Spring properties에 아래의 속성을 입력한다.

spring:
    jpa:
        hibernate:
            ddl-auto: create
            show-sql: true

ddl-auto 속성은 엔티티를 읽어 테이블을 자동으로 생성한다.

show-sql은 어플리케이션에서 JPA를 통해 사용한 SQL문을 로그로 뿌려주는 기능이다.

Entity 클래스 Member가 정의되어 있다고 가정하고, Spring boot 어플리케이션을 실행한다면 아래와 같은 로그를 확인할 수 있다.

Hibernate: drop table MEMBER if exists
Hibernate: create table MEMBER ( ... )

drop을 먼저 실행하고 테이블을 생성했다.

ddl-auto 속성이 create인 경우 어플리케이션 시작 시 모든 테이블을 삭제하고 다시 만들기 때문에 drop을 실행한 것이다.

ddl-auto 옵션

  1. create
    • 위의 예제처럼 테이블을 모두 삭제한 뒤 다시 생성한다.
    • DROP → CREATE
  2. create-drop
    • create 속성을 포함한다.
    • 애플리케이션 종료 시 생성한 DDL도 제거한다.
    • DROP → CREATE → DROP
  3. update
    • DB 테이블과 엔티티 매핑정보를 비교한다.
    • 변경 사항만 수정한다.
  4. validate
    • DB 테이블과 엔티티 매핑 정보를 비교한다.
    • 변경 사항이 있는 경우 경고를 발생시키고 어플리케이션을 실행시키지 않는다.
  5. none
    • 자동 생성 기능을 사용하지 않는다.

ddl-auto 옵션 전략

  1. 운영 서버에서는 DDL 수정 옵션을 절대 사용하지 않는다
    • create, create-drop, update 절대 금지
  2. 개발 초기 단계
    • create, update 사용
  3. 초기화 상태로 자동화된 테스트를 진행하는 환경 / CI 서버
    • create, create-drop 사용
  4. 테스트 서버
    • update, validate 사용
  5. 스테이징/운영 서버
    • validate, none 사용

기본 키 매핑

@Id를 사용하면 테이블의 기본 키를 매핑할 수 있다.

기본 키 할당 전략

  1. 직접 할당

    • 애플리케이션에서 직접 할당한다.

        Board board = new Board();
        board.setId(1);
        boardRepository.save(board);
  2. 자동 생성

    • @GeneratedValue를 사용하여 키를 자동으로 생성한다.

    • strategy 옵션을 활용하여 자동 생성 전략을 정의할 수 있다.

      1. GenerationType.IDENTITY

        • DB에 위임하는 방법이다.

        • MySQL의 AUTO_INCREMENT와 같은 기능을 수행한다.

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

        • 유일한 값을 순서대로 생성하는 전략으로, DB에서 지원해야 가능하다.

        • 오라클, PostgreSQL, DB2, H2에서 사용 가능하다.

            @Entity
            @SequenceGenerator(
                name = "BOARD_SEQ_GENERATOR",
                sequenceName = "BOARD_SEQ",
                initialValue = 1, allocationSize = 1)
            public class Board {
                @Id
                @GeneratedValue(strategy = GenerationType.SEQUENCE,
                                generator = "BOARD_SEQ_GENERATOR")
                private Long id;
            }
        • @SequenceGenerator

          • name : 식별자 생성기 (필수)
          • sequenceName : DB에 등록된 시퀀스 이름
          • initialValue : 시퀀스 DDL을 생성할 때 시작할 수를 정함 (Default 1)
          • allocationSize : 시퀀스 한 번 호출에 증가하는 수
          • catalog, schema : DB의 catalog, schema 이름
      3. GenerationType.TABLE

        • 키 생성 전용 테이블을 만들어 여기에 이름과 값으로 사용할 컬럼을 만든다.

        • 위의 시퀀스를 흉내내는 전략이다. 모든 DB에서 사용 가능하다.

        • 테이블

            @Entity
            @TableGenerator( // 테이블 키 생성기를 등록한다.
                name = "BOARD_SEQ_GENERATOR",
                table = "MY_SEQUENCES",
                pkColumnValue = "BOARD_SEQ", allocationSize = 1)
            public class Board {
                @Id
                @GeneratedValue(strategy = GenerationType.TABLE,
                                generator = "BOARD_SEQ_GENERATOR")
                private Long id;
            }
        • @TableGenerator

          • name : 식별자 생성기 (필수)
          • table : 키 생성 테이블명
          • pkColumnName : 시퀀스 컬럼명
          • valueColumnName : 시퀀스 값 컬럼명
          • pkCOlumnValue : 키로 사용할 값 이름
          • initialValue : 초기값 (Default 0)
          • allocationSize : 시퀀스 한 번 호출에 증가하는 수
          • catalog, schema : DB의 catalog, schema 이름
      4. GenerationType.AUTO

        • 선택한 데이터베이스의 방언에 따라 IDENTITY, SEQUENCE, TABLE 중 하나를 자동으로 선택한다.
        • 오라클인 경우 SEQUENCE, MySQL인 경우 IDENTITY를 선택한다.
        • 데이터베이스를 변경해도 코드 수정이 필요 없다는 장점이 있다.

필드와 컬럼 매핑

아래는 컬럼과 매핑되는 대표적인 Annotation의 종류이다.

  1. @Column
    • 일반적인 컬럼 매핑용
  2. Enumerated
    • enum 타입 매핑
  3. @Temporal
    • 날짜 타입 매핑
  4. @Lob
    • BLOB, CLOB 타입 매핑
  5. @Transient
    • 매핑하지 않는 필드 명시
  6. @Access
    • JPA가 엔티티에 접근하는 방식 지정

@Column

@Column은 객체 필드를 테이블 컬럼과 매핑한다.

가장 일반적으로 사용된다.

아래는 속성 목록이다.

  1. name (Default 필드 이름)
    • 테이블의 컬럼 이름
  2. insertable (Default true)
    • 엔티티 저장 시 이 필드도 같이 저장
    • false로 설정 시 이 필드를 DB에 저장하지 않음.
  3. updatable (Default true)
    • 엔티티 수정 시 이 필드도 같이 수정
    • false로 설정 시 DB 수정하지 않음.
  4. table
    • 하나의 엔티티를 두 개 이상의 테이블에 매핑할 떄 사용
  5. nullable (Default true)
    • false로 설정 시 이 필드에 NOT NULL 옵션 추가
  6. unique
    • 유니크 제약조건 설정
  7. columnDefinition
    • DB의 컬럼 정보를 직접 줄 수 있음.
  8. length
    • 문자 길이 제한
  9. precision, scale
    • BigDecimal, BigInteger 등에서 사용
    • precision은 소수점을 포함한 전체 자리수를 명시한다
    • scale은 소수의 자리수를 명시한다.

@Enumerated

enum 타입을 매핑할 때 사용한다.

  1. enum 정의

     enum LogType {
         CREATE, READ, UPDATE, DELETE
     }
  2. enum 사용

     // 필드 정의
     @Enumerated(EnumType.STRING)
     private LogType logType;
    
     // 실제 사용
     logType.setLogType(LogType.READ);

@Enumerated의 속성은 value가 있다.

EnumType.ORDINAL을 사용하면 enum 순서를 DB에 저장하고 EnumType.STRING을 사용하면 enum 이름을 데이터베이스에 저장한다.

@Temporal

날짜 타입을 매핑할 때 사용한다. 생략 시 timestamp 형식으로 자동 저장된다.

옵션으로 형식을 정해주어야 한다.

  1. TemporalType.DATE
    • 날짜 (2020-02-22) 저장
    • DB의 date 타입과 매핑
  2. TemporalType.TIME
    • 시간 (16:50:30) 저장
    • DB의 time 형식에 매핑
  3. TemporalType.TIMESTAMP
    • 날짜와 시간 (2020-02-22 16:50:30) 저장
    • DB의 timestamp에 매핑

@Lob

@Lob 에는 속성을 정하지 않는다.

매핑 필드 타입이 문자면 CLOB, 나머지는 BLOB로 자동 설정된다.

@Transient

데이터베이스에 저장되지도 않고 불러올 수도 없다.

객체에 임시로 값을 저장하고자 할 때 사용하기 적합하다.

@Access

JPA가 엔티티 데이터에 접근하는 방식을 지정한다.

  1. 필드로 접근하기 (AccessType.FIELD)

    • 객체 필드에 직접 접근하는 방식
  2. 프로퍼티로 접근하기 (AccessType.PROPERTY)

    • getter 메소드에 접근하는 방식

사용 예시를 보면 쉽게 이해할 수 있다.

  1. AccessType.FIELD

         @Entity
         @Access (AccessType.FIELD)
         public class Member {
             @Id
             private Long id;
    
             @Column
             private String name;
         }
  2. AccessType.PROPERTY

     @Entity
     @Access (AccessType.PROPERTY)
     public class Member {
         private Long id;
         private String name;
    
         @Id
         public Long getId() { return id; }
         @Column
         public String getName() { return name; }
     }
  3. 같이 사용 (getter에 특별한 로직이 필요한 경우 유용)

     @Entity
     public class Member {
         @Id
         private Long id;
         private String name;
    
         @Column
         public String getName() { return name; }
     }

'Java > JPA' 카테고리의 다른 글

JPA 사용 시 주의할 점  (1) 2021.07.22
[JPA] 다양한 연관관계 매핑  (0) 2020.04.07
[JPA] 연관관계 매핑 기초  (1) 2020.02.26
[JPA] EntityManager, 영속성 컨텍스트  (0) 2020.02.17

+ Recent posts