[JPA] 자바 ORM 표준 JPA 프로그래밍 - 기본편 #5. 연관관계 매핑 기초

bien·2024년 1월 20일
0

jpa-basic

목록 보기
3/6

📋 목표

  • 객체와 테이블 연관관계의 차이를 이해
  • 객체의 참조와 테이블의 외래키를 매핑
  • 용어 이해
    • 방향(Direction): 단방향, 양방향
    • 다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M) 이해
    • 연관관계의 주인(Owner): 객체 양방향 연관관계는 관리 주인이 필요

단방향 연관관계

연관관계가 필요한 이유

예제 시나리오

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

객체 연관관계 vs 테이블 연관관계

  • 객체는 참조(주소)로 연관 관계를 맺는다.
  • 테이블은 외래 키로 연관 관계를 맺는다.

테이블 연관관계

  • 회원 테이블은 TEAM_ID 외래 키로 팀 테이블과 연관관계를 맺는다.
  • 외래키를 통해 JOIN할 수 있는 양방향 관계이다.
// 회원과 팀 조인
SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

// 팀과 회원 조인
SELECT * 
FROM TEAM T
JOIN MEMER M ON T.TEAM_ID = M.TEAM_ID

순수한 객체 연관관계

public class Member {
	
    private String id;
    private String username;
    
    private Team team; // 팀의 참조를 보관
    
    public void setTeam(Team team) {
    	this.team = team;
    }
    
    // Getter, Setter ...
}

public class Team {
	
    private String id;
    private String name;
    
    // Getter, Setter ...
}

실제로 회원1과 회원2를 팀1에 소속시키는 코드는 아래와 같다.

public static void main(String[] args) {
	
    // 생성자(id, 이름)
    Member member1 = new Member("member1", "회원1");
    Member member2 = new Member("member2", "회원2");
    Team team1 = new Team("team1", "팀1");
    
    member1.setTeam(team1);
    member2.setTeam(team2);

	Team findTeam = member1.getTeam();
    
}

이처럼 객체는 참조를 사용해 연관관계를 탐색할 수 있는데 이를 객체 그래프 탐색이라 한다.

객체 연관관계 (객체 지향 모델링)

  • 참조를 통한 연관관계언제나 단방향이다.
    • 객체간에 연관관계를 양방향으로 만들고 싶으면 반대쪽에도 필드를 추가해서 참조를 보관해야 한다. 즉, 연관관계를 하나 더 만들어 서로 참조하는 것양방향 연관관계라고 한다.
    • 그러나 정확히 이는 양방향 관계가 아니라 서로 다른 단방향 관계 2개 이다.
// 단방향 연관관계
class A {
	B b;
}
 class B {}
 
 // 양방향 연관관계
 class B {
 	A a;
}
class A {
	B b;
}

객체를 테이블에 맞추어 모델링

  • 객체와 테이블 간의 패러다임의 차이로 인해 참조 대신에 외래키를 그대로 사용하고, 외래 키 식별자를 직접 다룬다.
  • 객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.
@Entity
public class Member {
	
    @Id @GeneratedValue
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    
    @Column(name = "TEAM_ID")
    private Long teamId;
    ...
}

@Entity
public class Team {

	@Id @GeneratedValue
    private Long id;
    private String name;
    ...
}
public class JpaMain {

    public static void main(String[] args) {

        // 엔티티 매니저 팩토리: 생성
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook");

        // 엔티티 매니저: 생성
        EntityManager em = emf.createEntityManager();

        // 트랜잭션: 획득
        EntityTransaction tx = em.getTransaction();
        tx.begin(); // 트랜잭션 시적


        try {
            Team team = new Team();
            team.setName("TeamA");
            em.persist(team);

            Member member = new Member();
            member.setUsername("member1");
            member.setTeamId(team.getId());
            em.persist(member);

            tx.commit(); //트랜잭션 커밋
        } catch (Exception e) {
            tx.rollback(); //트랜잭션 롤백
        } finally {
            em.close(); // 엔티티 매니저 종료
        }

        emf.close();// 엔티티매니저 팩토리 종료
    }
}

객체 지향 모델링 (객체 연관관계 사용)

  • 객체의 참조와 테이블의 외래 키를 매핑

Member

@Entity
public class Member {

	// ...
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
}
  • @ManyToOne
    • 이름 그대로 다대일(N:1) 관계라는 매핑 정보.
    • 회원과 팀은 다대일 관계이다.
    • 연관관계를 매핑할 때 이렇게 다중성을 나타내는 애노테이션을 필수로 사용해야 한다.
  • @JoinColumn(name = "TEAM_ID")
    • 조인 컬럼은 외래 키를 매핑할 때 사용한다.
    • 회원과 팀 테이블은 TEAM_ID 외래 키를 연관관계를 맺으므로 이 값을 지정하면 된다.
// 팀 저장
Team team = new Team();
Team.setName("TeamA");
em.persist(team);

// 회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); // 단방향 연관관계 설정, 참조 저장
em.persist(member);

양방향 연관관계와 연관관계의 주인1: 기본

양방향 연관관계 매핑

  • 앞서 살펴본 것 처럼, 테이블에서는 외래키 만으로 양쪽(팀과 멤버)의 정보를 모두 알 수 있다. 외래키 만으로 조인을 통해 양쪽의 정보를 다 획득할 수 있다.
    • 그러나 객체 참조는 양방향 연관관계를 위해, 두 객체가 모두 정보를 가지고 있어야 한다. (이 부분이 객체 참조와 외래키의 가장 큰 차이이다.)

Member

  • 이전과 코드가 변경되지 않았다.
@Entity
@Table(name="member")
public class Member {

    @Id @GeneratedValue
    private Long id;

    @Column(name = "USERNAME")
    private String name;

//    @Column(name = "TEAM_ID")
//    private Long teamId;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

}

Team

  • @OneToMany(mappedBy = "team") 코드가 추가되었다.
    • mappedBy 속성: 양방향 매핑일 때 사용하는 반대쪽 매핑의 필드 이름을 값으로 주면 된다.
      • 반대쪽 매핑이 Member.team이므로 team을 값으로 주었다.
@Entity
public class Team {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

}

양방향 매핑

결과적으로 반대방향으로도 객체 그래프 탐색이 가능해진다.

// 조회
Team findTeam = em.find(Team.class, team.getId);

int memberSize = findTeam.getMembers().size(); // 역방향 조회

연관관계의 주인

mappedBy 속성은 어떤 속성이고 왜 필요할까?

  • 객체와 엔티티의 양방향 연관관계
    • 객체의 연관관계
      • 회원 -> 팀 연관관계 1개 (단방향)
      • 팀 -> 회원 연관관계 1개 (단방향)
      • 즉, 객체의 연관관계 관리 포인트가 2곳으로 늘어난다.
    • 엔티티의 연관관계
      • 회원 <-> 팀 연관관계 1개 (양방향)
  • 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다.
    • (Member의 Team team & Team의 List members)
    • 이런 차이로 인해 JPA에서는 두 객체 연관관계중 하나를 정해 테이블의 외래키를 관리하도록 지정해야 하는데, 이를 연관관계의 주인(Owner)이라 한다.
  • 위 사진의 Member와 Team의 예시에서 멤버를 변경하거나 팀을 바꿀 때, Member의 Team team값을 변경해야 할지 Team의 List members를 변경해야 할지 선택해야 한다.
    • DB 입장에서는 Member에 있는 외래키 값만 변경되면 된다.
    • DB 입장에서는 Member 클래스가 변경의 주체가 되든 Team 클래스가 변경의 주체가 되든 알바 아니다.

양방향 매핑 규칙: 연관관계의 규칙

  • 두 연관 관계중 하나를 연관관계의 주인으로 정해야 한다.
    • 연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것이다.
  • 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다.
    • 연관관계의 주인은 mappedBy 속성을 사용하지 않는다.
  • 반면 주인이 아닌 쪽은 읽기만 가능하다.
    • 주인이 아니면 mappedBy 속성으로 주인을 지정해야 한다.

누구를 주인으로?

  • 외래 키가 있는 곳을 주인으로 지정하자.
    • 권장사항. 이렇게 지정해야 혼란스럽지 않다.
      • Team의 members를 변경했는데 다른 테이블에 업데이트 쿼리가 전송되는 등의 과정들이 혼란스러울 수 있다. 성능 이슈도 있다.
      • Team을 INSERT할 때 Member가 주인일 때는 Foreign key로 바로 입력 가능하지만, Team이 주인인 경우 Team은 INSERT되고 Member 는 UPDATE 되어야 해 또 혼란스러울 수 있다.
  • 예시에서는 Member.team이 연관관계의 주인이다.

  • DB입장에서 보면 외래키가 있는 곳이 무조건 '다'이고, 외래키가 없는 곳은 무조건 '1'이다. 즉, 1대N이 된다.
    • 즉, DB의 N쪽이 무조건 연관관계의 주인이 된다.

양방향 연관관계 저장

public void testsave() {

	// 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team);
    
    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    membre1.setTeam(team1); // 연관관계 설정 member1 -> team1
    em.persist(member1);
    
    // 회원2 저장
    Member member = new Member("member2", "회원2");
    member2.setTeam(team1); // 연관관계 설정 member2 -> team1
    em.persist(memger2);
}

결과

SELECT * FROM MEMBER;
MEMBER_IDUSERNAMETEAM_ID
member1회원1team1
member2회원2team1

연관관계의 주인인 경우

Member1.setTeam(team1); // 연관관계 설정 (연관관계의 주인)
Member2.setTeam(tesm1); // 연관관계 설정 (연관관계의 주인)
  • 엔티티 매니저는 이곳에 입력된 값을 사용해 외래키를 관리한다.

연관관계의 주인이 아닌 경우

team1.getMember().add(member1); // 무시(연관관계의 주인이 아님)
team1.getMember().add(member2); // 무시(연관관계의 주인이 아님)
  • 주인이 아닌 곳에 입력된 값들은 외래키에 영향을 주지 않는다.
    • 따라서이 코드는 데이터베이스에 저장될 때 무시된다.

양방향 연관관계와 연관관계의 주인2: 주의점, 정리

⛑️ 오류: 주인이 아닌 곳에만 값 입력

양방향 연관관계를 설정하고 가장 흔히 하는 실수는 연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 것이다. 데이터베이스에 외래키 값이 정상적으로 저장되지 않으면 이것부터 의심해보자.

public void testSaveNonOwner() {

	// 회원1 저장
    Member member1 = new Member("member1", "회원1");
    em.persist(member1);
    
    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    em.persist(member2);
    
    Team team1 = new Tema("team1", "팀1");
    // 주인이 아닌 곳에만 연관관계 설정
    team1.getMembers().add(member1);
    team1.getMembers().add(member2);
    
    em.persist(team1);
}

결과

SELECT * FROM MEMBER;
MEMBER_IDUSERNAMETEAM_ID
member1회원1null
member2회원2null
  • 예시코드에서 연관관계의 주인이 아닌 Team.Members에만 값을 저장했다.
    • 연관관계의 주인만이 외래키의 값을 변경할 수 있다.
    • 결과적으로, 외래 키 TEAM_ID에 team1이 아닌 null이 입력되었다.

정상코드: 연관관계의 주인에게 관계 매핑

public void testSaveNonOwner() {

	// 팀 저장
	Team team = new Team()
    team.setName("TeamA");
    em.persist(team);

	// 회원 저장
    Member member = new Member();
    member.setUsername("member1");
    // 연관관계의 주인에 값 설정.
    member.setTeam(team); //**
    em.persist(member);
    
    team.getMembers().add(member);
    
    em.persist(team1);
}
// member(주인)에 team 매핑
member.setTeam(team); // **
// team에 member 매핑
team.getMembers().add(member);
  • 정상적으로 연관관계의 주인인 member에 team을 매핑했다.
  • 그렇다면 연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않는 것이 권장될까?
    • 그렇지 않다. 사실은 양쪽에 모두 값을 입력해주는 것이 좋다.

순수한 객체까지 고려한 양방향 연관관계

순수한 객체의 상태를 고려하여 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다. 양쪽 모두에 값을 입력해주지 않으면 어떤 일이 발생하는지 살펴보자.

for문이 db에서 데이터를 가져오는 경우

 // 저장
 Team team = new Team();
 team.setName("TeamA");
 em.persist(team);

 Member member = new Member();
 member.setName("member1");
 member.setTeam(team); // ** 주인 연관관계 값 입력
 em.persist(member);
 
team.getMembers().add(member);

 em.flush();
 em.clear(); // flush와 clear로 디비에 데이터가 입력되었다.

 Team findTeam = em.find(Team.class, team.getId());
 List<Member> members = findTeam.getMembers();

 System.out.println("================");
 for (Member m : members) { // 디비에서 값이 조회되어 출력된다.
     System.out.println("m = " + m.getName());
 }
 System.out.println("================");
  • em.flushem.clear로 DB에 데이터가 추가되었고, DB의 데이터를 조회하고 있다.
    • 정상적으로 for문에서의 멤버 출력이 이루어진다.
    • 하지만 DB를 거치지 않고 1차 캐시에서만 데이터를 사용한다면 어떨까?

for문이 1차 캐시에서 데이터를 가져오는 경우

 // 저장
 Team team = new Team();
 team.setName("TeamA");
 em.persist(team);

 Member member = new Member();
 member.setName("member1");
 member.setTeam(team); // ** 주인 연관관계 값 입력
 em.persist(member);
 
 // 연관관계의 주인이 아닌 쪽에 값을 입력하지 않았다.
 // team.getMembers().add(member);

 // DB에 데이터를 입력하지 않는다.
 // em.flush();
 // em.clear();

 Team findTeam = em.find(Team.class, team.getId()); // 1차캐시에서 데이터 조회
 List<Member> members = findTeam.getMembers();

 System.out.println("================");
 for (Member m : members) { // 1차 캐시에서 값을 찾지만, 값이 없다.
     System.out.println("m = " + m.getName());
 }
 System.out.println("================");
  • em.flushem.clear를 주석 처리하고, Team(주인이 아닌쪽)에 값을 주입하지 않았다.
    • Team에 값을 주입하지 않았으므로 1차 캐시에도 member와 관련된 정보를 가지고 있지 않고, 아직 디비에 데이터가 작성되기 전(트랜잭션 내부)이므로, for문으로 멤버 출력이 이루어지지 않는다.
  • 이 같은 경우가 얼마든지 발생할 수 있으므로, 객체 지향적으로 생각해 양쪽에 모두 값을 셋팅해주는 것이 좋다.

📌 결론

객체의 양방향 연관관계는 양쪽 모두 관계를 맺어주자!

🔨 해결법: 연관관계 편의 메서드

 member.setTeam(team); // ** 주인 연관관계 값 입력 
 team.getMembers().add(member);

결과적으로, 양쪽 객체 모두에게 위와 같이 값을 넣어줘야한다. 그러나 실제로 개발을 하다보면 깜빡하는 일이 더러 있을 수 있다. 이 같은 경우에 연관관계 편의메서드를 사용할 수 있다.

setTeam()에 추가하기

@Entity
public class Member {

	// ...
    public void setTeam(Team team) {
    	this.team = team;
        team.getMembers().add(this);
	}       

}
  • 기존에 team을 설정해주는 코드를 Member의 setter에 추가해줬다. 따라서 member.setTeam(team)호출 만으로 양쪽 모두 값을 주입해줄 수 있다.
    • 이제 이 메서드는 원자적으로 사용할 수 있다. (하나만 호출해도 양쪽에 값이 주입되므로)
  • 연관관계 편의 메서드 사용시 set이란 키워드는 추천하지 않는다.
    • set은 자바의 getter, setter 관례로 단순히 로직 없이 값만 주입할때 사용하고, 로직이 추가되면 이름을 변경해주는 것이 좋다.
      • 예) changeTeam: 보는 사람 입장에서 '아, 그냥 뭔가 중요한걸 하는구나!'라고 인지할 수 있다.

주의사항

1. 삭제되지 않은 매핑관계

위와 같은 setTeam() 메서드에는 버그가 있다.

member1.setTeam(teamA); // 1
member1.setTeam(teamB); // 2
Member findMember = teamA.getmember(); // member1이 여전히 조회된다.

  • member1.setTeam(teamA)
    • member1이 TeamA와 관계를 맺고 있다.

  • member1.setTeam(teamB)
    • member1이 teamB로 변경될 때, teamA -> member1의 관계가 변경되지 않았다.
    • 연관관계 변경 시 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 한다.

참고

  • teamA -> member1의 관계가 제거되지 않아도 데이터베이스 외래 키를 변경하는 데는 문제가 없다. 왜냐하면 teamA -> member1 관계를 설정한 Team.members는 연관관계의 주인이 아니기 때문이다. 연관관계의 주인인 Member.team의 참조가 member1 -> teamB로 변경되었으므로 데이터베이스에 외래 키는 teamB를 참조하도록 정상 반영되었다.
  • 이후에 새로운 영속성 컨텍스트에서 teamA를 조회해 teamA.getMembers()를 호출하면 데이터베이스 외래 키에는 관계가 끊어져 있으므로 아무것도 조회되지 않는다.
  • 문제는 관계를 변경하고 영속성 컨텍스트가 아직 살아있는 상태에서 teamA의 getMembers()를 호출하면 member1이 반환된다는 점이다. 따라서 변경된 연관관계는 앞서 설명한 것처럼 관계를 제거하는 것이 안전하다.

1-1. 해결법: 기존 관계 제거

public void setTeam(Team team) {
	
    // 기존 팀과 관계를 제거
    if (this.team != null) {
    	this.team.getMembers().remove(this);
    }
    
    this.team = team;
    team.getMembers().add(this);
}
  • 기존의 관계를 삭제하는 코드를 추가해야 한다.

2. 무한 루프

  • 양방향 매핑시 무한 루프를 조심해야 한다.
    • toString(), lombok, JSON생성 라이브러리 코드
public class Member {
    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", username=" + name + '\'' +
                ", team=" + team + 
                '}';
    }
}

public class Team {
    @Override
    public String toString() {
        return "Team{" +
                "id=" + id +
                ", name=" + name + '\'' +
                ", members=" + members + 
                '}';
    }
}
  • Member의 toString에서 team의 toString을 호출하고, Team의 toString에서 member의 toString을 서로 호출하고 있다. 롬복의 경우 이같은 호출을 자동으로 생성해준다.
    • 양쪽에서 무한루프로 호출하면서 StackOverflowError가 발생하게 된다.
  • 특히 컨트롤러에서 Entity를 바로 반환할 때, Entity를 JSON으로 변환하면서 서로 계속 호출하게 된다.

2-1. 해결법

  • Lombok의 toString은 사용하지 말자.
  • Controller에서는 절대 Entity를 반환하지 않는다.
    • 장점1. 무한 루프 발생을 예방한다.
    • 장점2. 엔티티는 여러가지 이유로 필드가 추가 및 삭제될 수 있는데, 컨트롤러에서 엔티티를 반환하는 경우 API 스팩이 바뀌어버린다. (API를 가져다 쓰는 사람들 입장에서는 곤란한 상황)

정리

  • 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
  • 단방향을 양방향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
  • 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.
  • 기본적으로 설계시에 가져야 하는 사고방식:
    • "단방향 매핑으로 모두 끝낸다."
    • "필요 시 연관관계 매핑을 추가한다."

📌 내 정리

  • 객체 연관관계:1 (단방향) & 테이블 연관관계:2 (양방향)
  • 이 간극을 매우기 위해 객체에 연관관계 추가
    • 왜 매꿈? 그럼 객체에서 양쪽을 다 조회가능함. 그래서 더 객체지향적 코드 작성 가능.

연관관계의 주인을 정하는 기준

단방향은 항상 외래 키가 있는 곳을 기준으로 매핑하면 된다. 연관관계의 주인(Owner)이라는 이름으로 인해 비즈니스 로직상 더 중요하다고 생각되는 객체가 연관관계의 주인이라고 오해할 수 있다. 그러나 연관관계의 주인은 외래 키의 위치와 관련해서 정해야지 비즈니스 중요도로 접근하면 안된다.


실전예제2 : 연관관계 매핑 시작

테이블 구조

객체 구조

  • 참조를 사용하도록 변경

일대다, 다대일 연관관계 매핑

회원(Member) 엔티티

@Entity
public class Member {
	
    // ... 이전과 동일 ...
    @OneToMany(mappedBy = "member");
    private List<Order> orders = new ArrayList<Order>();
    
    // Getter, Setter
    ...
}    

주문(Order) 엔티티

@Entity
public class Order {

	// 이전과 동일...
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID");
    private Member member; // 주문 회원
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<OrderItem>();

	//==연관관계 메소드==//
    public void setMember(Member member) {
    	// 기존 관계 제거
        if (this.member != null) {
        	this.member.getOrders().remove(this);
		}
        
        this.member = member;
        member.getOrders().add(this);   
	}
    
    public void addOrderItem(OrderItem orderItem) {
    	orderItems.add(orderItem);
        orderItem.setOrder(this);
	}
    
    // Getter, Setter
    ...
}    
  • 아래의 연관관계 편의 메소드 setMember() 를 추가했다.
    • 따라서 다음과 같이 관계를 설정하면 된다.
Member member = new Member();
Order order = new Order();
order.setMember(member); // member -> order, order -> member

주문상품(OrderItem) 엔티티

@Entity
public class OrderItem {
	
    // 이전과 동일...
  
    @ManyToOne
    @JoinColumn(name = "Team_id)
    private Item item; // 주문 상품
    
    @ManyToOne
    @JoinColumn(name = "ORDER_ID")
    private Order order; // 주문

}    
  • Order와 Item과의 관계에서 OrderItem이 '다'의 관계이므로 두 관계에서 모두 주인이 된다.

상품(Item) 엔티티

@Entity
public class Item {

	@Id @GeneratedValue
    private Long id;
    
    ..
}
  • 코드가 이전과 동일하다.
  • 주문상품과 관계를 맺고 있지만, 상품에서 주문상품을 참조할 일은 거의 없다.
    • 주문상품과 상품의 관개를 다대일 단방향 관계로 설정했다.

객체 그래프 탐색

// 주문한 회원을 객체 그래프로 탐색
Order order = em.find(Order.class, orderId);
Member member = order.getMember(); // 주문한 회원, 참조 사용

// 주문한 상품 하나를 객체 그래프로 탐색
Order order = em.find(Order.class, orderId);
orderItem = order.getOrderItems().get(0);
Item = orderItem.getItem();

Reference

profile
Good Luck!

0개의 댓글