SpringBoot JPA 예제(@OneToMany, 단방향)
이번에도 JPA입니다. @OneToMany 어노테이션을 이용해서 Entity간의 관계를 맺어봅시다. 이 포스팅에서는 단방향 관계(unidirectional relationships)만 다루겠습니다.
단방향 or 양방향
포스트 처음에 언급했지만 Entity간의 관계를 맺을 때에는 방향이 있습니다. 예를 들면 아래와 같은 두 테이블이 있다고 가정합니다.
CREATE TABLE `member` ( `seq` INT(10) NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) NULL DEFAULT NULL, PRIMARY KEY (`seq`) ) COLLATE='utf8_general_ci' ENGINE=InnoDB AUTO_INCREMENT=1;
CREATE TABLE `phone` ( `seq` INT(10) NOT NULL AUTO_INCREMENT, `member_id` INT(10) NULL DEFAULT NULL, `no` VARCHAR(50) NULL DEFAULT NULL, PRIMARY KEY (`seq`) ) COLLATE='utf8_general_ci' ENGINE=InnoDB AUTO_INCREMENT=1;
외래키(foreign key) 제약 조건은 걸지 않았습니다. DB는 MariaDB입니다.
위의 테이블은 각각 회원(member)과 핸드폰(phone)을 가리킵니다. 회원들은 핸드폰을 가지지 않거나 여러 개를 가질 수 있습니다. 핸드폰에서는 자신의 주인이 누구인지 회원 번호를 가지고 있습니다.
여기서 방향이라 하는 것은 해당 Entity가 다른 Entity를 가질 수 있느냐로 보시면 됩니다. 예를 들어 위의 회원 Entity에서 핸드폰 Entity를 멤버 변수로 가지고 있다면 회원 -> 핸드폰이라는 방향성을 가집니다. 이렇게 한쪽의 방향만 가지면 단방향(unidirectional)이라고 하죠.
만약, 위와 같은 상황에서 핸드폰 Entity에서도 회원 Entity를 가진다면 회원 <-> 핸드폰이라는 관계가 성립됩니다. 이런것을 양방향(bidirectional) 관계라고 합니다.
물론 회원 <- 핸드폰 관계도 가능합니다. 이때는 단방향 관계를 가지지만 아까와 방향이 다를 뿐입니다.
@OneToMany
이제 회원과 핸드폰 테이블을 기반으로 해서 설명을 진행합니다. 회원은 기본적으로 핸드폰이 없거나 1개 이상을 소지할 수 있습니다. 그러면 회원 하나에 핸드폰을 여러개 가지니 1:N 관계가 됩니다. 이것을 JPA 어노테이션으로는 @OneToMany라고 표현합니다.
@OneToMany 속성
@OneToMany 속성에는 다음과 같은 것들이 있습니다.
- targetEntity
- cascade
- fetch
- mappedBy
- orphanRemoval
targetEntity
관계를 맺을 Entity Class를 정의합니다.
cascade
현 Entity의 변경에 대해 관계를 맺은 Entity도 변경 전략을 결정합니다.
속성값에는 CascadeType라는 enum에 정의 되어 있으며 enum값에는 ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH가 있습니다.
fetch
관계 Entity의 데이터 읽기 전략을 결정합니다.
FetchType.EAGER, FetchType.LAZY로 전략을 변경 할 수 있습니다. 두 전략의 차이점은 EAGER인 경우 관계된 Entity의 정보를 미리 읽어오는 것이고 LAZY는 실제로 요청하는 순간 가져오는겁니다.
mappedBy
양방향 관계 설정시 관계의 주체가 되는 쪽에서 정의합니다.
orphanRemoval
관계 Entity에서 변경이 일어난 경우 DB 변경을 같이 할지 결정합니다. cascade와 다른것은 cascade는 JPA 레이어 수준이고 이것은 DB레이어에서 처리합니다. 기본은 false입니다.
단방향 @OneToMany 예제
실제로 예제를 보면 더 이해가 빠를듯 합니다. 예제를 진행합시다.
Entity
회원 Entity 및 핸드폰 Entity를 만듭니다.
Member Entity
package jpa3; import java.util.ArrayList; import java.util.Collection; import java.util.List; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.OneToMany; @Entity public class Member { @Id @Column(name="seq") @GeneratedValue(strategy=GenerationType.AUTO) private int seq; @Column(name="name") private String name; @OneToMany(fetch=FetchType.EAGER, cascade = CascadeType.ALL) // (1) @JoinColumn(name="member_id") private Collection<Phone> phone; public Member(){} public Member(String name){ this.name = name; } public int getSeq() { return seq; } public void setSeq(int id) { this.seq = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Collection<Phone> getPhone() { return phone; } public void setPhone(List<Phone> phone) { this.phone = phone; } public void addPhone(Phone p){ if( phone == null ){ phone = new ArrayList<Phone>(); } phone.add(p); } @Override public String toString() { String result = "["+seq+"] " + name; for( Phone p : phone ){ result += "\n" + p.toString(); } return result; } }
(1)에서 @OneToMany 어노테이션을 정의하고 있습니다. 그리고 멤버 변수로 핸드폰 Entity를 가리키고 있습니다. 따라서 회원 -> 핸드폰이라는 단방향 1:N 관계를 만든 것입니다. 또한 FetchType.EAGER 전략을 취해 항상 Phone 목록을 가져오게 되어 있습니다. 불필요한 조회를 막으려면 FetchType.LAZY를 설정해야 합니다.
Phone Entity
package jpa3; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; @Entity public class Phone { @Id @Column(name="seq") @GeneratedValue(strategy=GenerationType.AUTO) private int seq; @Column(name="member_id") private int memberId; @Column(name="no") private String no; public Phone() {} public Phone(String no){ this.no = no; } public int getSeq() { return seq; } public void setSeq(int seq) { this.seq = seq; } public int getMemberId() { return memberId; } public void setMemberId(int memberId) { this.memberId = memberId; } public String getNo() { return no; } public void setNo(String no) { this.no = no; } @Override public String toString() { String result = "[phone_"+seq+"] " + no; return result; } }
Repository
Repository는 간단하게 코드만 보고 넘어가겠습니다.
package jpa3; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository<Member, Integer>{}
package jpa3; import org.springframework.data.jpa.repository.JpaRepository; public interface PhoneRepository extends JpaRepository<Phone, Integer> {}
Application
실제로 위의 코드를 돌려보는 프로그램을 작성합니다.
package jpa3; import java.util.List; import javax.transaction.Transactional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Jpa3Application implements CommandLineRunner{ @Autowired private MemberRepository mr; @Autowired private PhoneRepository pr; public static void main(String[] args) { SpringApplication.run(Jpa3Application.class, args); } @Override public void run(String... args) throws Exception { Member first = new Member("Jung"); // (1) first.addPhone(new Phone("010-XXXX-XXXX")); first.addPhone(new Phone("010-YYYY-YYYY")); Member second = new Member("Dong"); second.addPhone(new Phone("010-ZZZZ-ZZZZ")); Member third = new Member("Min"); // (2) mr.save(first); // (3) mr.save(second); mr.save(third); // (4) List<Member> list = mr.findAll(); // (5) for( Member m : list ){ System.out.println(m.toString()); } // (6) mr.deleteAll(); // (7) } }
[106] Jung [phone_97] 010-XXXX-XXXX [phone_98] 010-YYYY-YYYY [107] Dong [phone_99] 010-ZZZZ-ZZZZ [108] Min
(1) ~ (2) 범위는 새로운 회원을 생성하고 해당 회원들의 핸드폰을 정의했습니다. 그리고 (3) ~ (4) 범위에서 해당 정보를 DB에 저장합니다. (5) ~ (6) 범위는 DB에 저장된 정보를 다시 읽어서 해당 정보를 출력합니다. 최종적으로 (7)에서 모든 정보를 삭제합니다. 삭제할 때 @OneToMany 설정시 CascadeType.ALL 을 취했기 때문에 관련 Entity의 내용도 같이 삭제가 됩니다.
결과 출력은 제가 수많은(?) 테스트 이후 복사한 것이기 때문에 seq 값들이 커진 상태입니다. :)
참조
만약 Member Entity의 @OneToMany 항목중 fetch 타입을 FetchType.LAZY로 바꾸면 어떻게 될까요? 바로 org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role 오류가 나올겁니다.
해당 오류는 데이터가 영속성(persistence)을 잃었을 경우 생깁니다. 영속성이란 데이터를 생성한 프로그램이 실행이 종료되더라도 사라지지 않는 특징을 말합니다. 따라서 영속성을 잃었다는 것은 데이터를 생성한 프로그램이 종료되면 해당 데이터는 더 이상 추가 적재 및 갱신이 안됩니다. 메모리에만 남아있을 뿐입니다.
따라서, 현재 LAZY 전략을 취한 경우 Member Entity의 멤버 변수는 살아 있는것처럼 보이지만 @OneToMany로 묶인 Phone Entity는 호출한 이후 영속성을 잃었기 때문에 LazyInitializationException 오류를 내는겁니다.
이를 해결하려면 전략을 다시 EAGER로 바꿔주면 됩니다. 하지만 이러한 방법은 경우에 따라 불필요한 조회를 하게 만들고 볼륨이 큰 데이터일 경우 더욱 성능을 나쁘게 만듭니다.
다른 방법은 @Transactional 어노테이션을 메소드에 설정하면 됩니다. 만약 현재 포스트에 있는 예제를 사용한다면 다음처럼 바뀝니다.
@SpringBootApplication public class Jpa3Application implements CommandLineRunner{ ... @Override @Transactional public void run(String... args) throws Exception { ... } }
위 코드는 Application 항목의 코드를 가져온 겁니다.
run 메소드가 실행되는 동안은 영속성을 잃지 않게 해줍니다. 따라서 오류 없이 처리 가능하게 됩니다. 다만 주의할 것은 @Transactional 어노테이션이 설정된 구간 내에서 모델 객체에 변경이 일어나면 변경된 상태로 DB에 저장됩니다. 이것은 Repository에서 꺼내온 객체라도 동일하게 적용되기 때문에 주의해야 합니다.
JPA Web MVC 애플리케이션이라면 보통 Controller - Service(or implement) - Repository의 구성을 가지게 됩니다. 이 경우 Service 클래스에서 LAZY 전략이 가미된 Entity를 사용한다면 해당 메소드에 @Transactional 어노테이션을 정의해주면 됩니다. 아니면 Service 클래스 자체에 @Transactional을 줘도 됩니다. 상황에 맞게 쓰시면 되요.
마무리
@OneToMany 단방향 예제에 대해 알아봤습니다. 다음번엔 @ManyToOne에 대해 알아보아요. :)