JPA 한걸음더 -연관관계와 고급매핑

Agida·2025년 9월 10일

JPA

목록 보기
2/8
post-thumbnail

🚀 JPA 연관관계와 고급 기능 정리

📊 연관관계 매핑

다대일 (N:1) - @ManyToOne

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    
    private String username;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
    public void changeTeam(Team team) {
        this.team = team;
    }
}

@Entity  
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
}

핵심 포인트:

  • 외래키가 있는 쪽이 연관관계의 주인
  • fetch = FetchType.LAZY 필수
  • @JoinColumn으로 외래키 이름 지정

일대다 (1:N) - @OneToMany

일대다 단방향은 권장하지 않음. 외래키가 다른 테이블에 있어서 추가 UPDATE SQL이 실행됨.

// 권장하지 않는 방식
@Entity
public class Team {
    @OneToMany
    @JoinColumn(name = "team_id")
    private List<Member> members = new ArrayList<>();
}

문제점: 연관관계 관리를 위해 추가 UPDATE SQL 실행

해결책: 일대다 양방향 사용

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    public void addMember(Member member) {
        members.add(member);
        member.changeTeam(this);
    }
}

@Entity
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
    public void changeTeam(Team team) {
        if (this.team != null) {
            this.team.getMembers().remove(this);
        }
        this.team = team;
        if (team != null && !team.getMembers().contains(this)) {
            team.getMembers().add(this);
        }
    }
}

일대일 (1:1) - @OneToOne

주 테이블에 외래키:

@Entity
public class Member {
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "locker_id")
    private Locker locker;
}

@Entity
public class Locker {
    @OneToOne(mappedBy = "locker")
    private Member member;
}

대상 테이블에 외래키:

@Entity
public class Member {
    @OneToOne(mappedBy = "member")
    private Locker locker;
}

@Entity
public class Locker {
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

선택 기준:

  • 주 테이블에 외래키: 객체지향 개발자 선호, JPA 매핑 편리
  • 대상 테이블에 외래키: DBA 선호, 일대일→일대다 변경 시 테이블 구조 유지

주의사항: 일대일 관계에서 지연 로딩이 제대로 작동하지 않는 경우가 있음

다대다 (N:M) - @ManyToMany

실무에서 사용 금지! 연결 테이블이 단순 연결만으로 끝나지 않음.

// 이렇게 하지 말 것
@Entity
public class Member {
    @ManyToMany
    @JoinTable(name = "member_product")
    private List<Product> products = new ArrayList<>();
}

해결책: 연결 엔티티 생성

@Entity
public class Member {
    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

@Entity
public class Product {
    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProducts = new ArrayList<>();
}

@Entity
public class MemberProduct {
    @Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
    
    private int count;
    private int price;
    private LocalDateTime orderDateTime;
}

🏗️ 상속관계 매핑

조인 전략 - JOINED

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    private int price;
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {
    private String artist;
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    private String director;
    private String actor;
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
    private String author;
    private String isbn;
}

장점:

  • 테이블 정규화
  • 외래키 참조 무결성 제약조건 활용
  • 저장공간 효율화

단점:

  • 조회시 조인 많이 사용, 성능 저하
  • 조회 쿼리 복잡
  • 데이터 저장시 INSERT SQL 2번 호출

단일 테이블 전략 - SINGLE_TABLE

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    private int price;
}

장점:

  • 조인 없어서 조회 성능 빠름
  • 조회 쿼리 단순

단점:

  • 자식 엔티티 컬럼은 모두 null 허용
  • 테이블이 커질 수 있어 조회 성능이 오히려 느려질 수 있음

@MappedSuperclass

공통 매핑 정보가 필요할 때 사용. 엔티티가 아니고 테이블과 매핑되지 않음.

@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastModifiedBy;
    private LocalDateTime lastModifiedDate;
}

@Entity
public class Member extends BaseEntity {
    @Id
    @GeneratedValue
    private Long id;
    
    private String username;
}

@Entity
public class Team extends BaseEntity {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
}

🎭 프록시와 연관관계 관리

프록시 기초

Member member = em.find(Member.class, 1L); // 즉시 로딩
Member reference = em.getReference(Member.class, 1L); // 프록시 객체 반환

프록시 특징:

  • 실제 클래스를 상속받아 만들어짐
  • 실제 클래스와 겉 모양이 같음
  • 프록시 객체는 실제 객체의 참조를 보관
  • 프록시 객체 호출하면 실제 객체의 메소드 호출
Member refMember = em.getReference(Member.class, 1L);
System.out.println(refMember.getClass()); // class Member$HibernateProxy$...

// 이때 실제 쿼리 실행
System.out.println(refMember.getUsername()); 

프록시 확인 방법:

// 프록시 인스턴스 초기화 여부 확인
emf.getPersistenceUnitUtil().isLoaded(refMember);

// 프록시 클래스 확인
refMember.getClass().getName(); // Member$HibernateProxy$...

// 프록시 강제 초기화 (하이버네이트 전용)
Hibernate.initialize(refMember);

즉시 로딩과 지연 로딩

@Entity
public class Member {
    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    @JoinColumn(name = "team_id")
    private Team team;
}

// 지연 로딩
Member member = em.find(Member.class, 1L); // SELECT MEMBER만 실행
Team team = member.getTeam(); // 프록시 객체 반환
System.out.println(team.getName()); // 이때 SELECT TEAM 실행

실무 권장사항:

  • 모든 연관관계에 지연 로딩 사용
  • @ManyToOne, @OneToOne: 기본이 즉시 로딩 → LAZY로 설정
  • @OneToMany, @ManyToMany: 기본이 지연 로딩

영속성 전이 - CASCADE

@Entity
public class Parent {
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> children = new ArrayList<>();
    
    public void addChild(Child child) {
        children.add(child);
        child.setParent(this);
    }
}

@Entity
public class Child {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Parent parent;
}
// CASCADE 없이는
Child child1 = new Child();
Child child2 = new Child();
Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);
em.persist(child1); // 각각 persist 해야 함
em.persist(child2);

// CASCADE.ALL이 있으면
Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);

em.persist(parent); // 이것만으로 자식들도 함께 저장

CASCADE 종류:

  • ALL: 모든 라이프사이클 함께 관리
  • PERSIST: 저장할 때만 함께
  • REMOVE: 삭제할 때만 함께

사용 시기:

  • 하나의 부모가 여러 자식을 관리할 때
  • 라이프사이클이 거의 유사할 때
  • 소유자가 하나일 때

고아 객체

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0); // 자식 엔티티를 컬렉션에서 제거
// DELETE FROM CHILD WHERE ID = ? 실행됨

주의사항:

  • 참조하는 곳이 하나일 때만 사용
  • 특정 엔티티가 개인 소유할 때만 사용
  • @OneToOne, @OneToMany만 가능

영속성 전이 + 고아 객체

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();

두 옵션 모두 활성화하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있음.

프록시 실무 팁

1. 프록시 타입 비교 시 주의:

Member member1 = em.find(Member.class, 1L);
Member member2 = em.getReference(Member.class, 1L);

System.out.println(member1.getClass() == member2.getClass()); // false
System.out.println(member1 instanceof Member); // true
System.out.println(member2 instanceof Member); // true - 이걸 사용

2. 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 실제 엔티티 반환:

Member member1 = em.find(Member.class, 1L); // 실제 엔티티
Member member2 = em.getReference(Member.class, 1L); // 실제 엔티티 반환

System.out.println(member1 == member2); // true

3. 프록시를 먼저 조회하면 find()도 프록시 반환:

Member reference = em.getReference(Member.class, 1L); // 프록시
Member member = em.find(Member.class, 1L); // 프록시 반환

System.out.println(reference == member); // true

4. 준영속 상태일 때 프록시 초기화하면 예외:

Member refMember = em.getReference(Member.class, 1L);
em.detach(refMember); // 준영속 상태
refMember.getUsername(); // LazyInitializationException 발생

💎 값 타입

기본값 타입

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id; // 엔티티 타입
    
    private String username; // 값 타입
    private int age; // 값 타입
}
  • 생명주기를 엔티티에 의존
  • 값 타입은 공유하면 안 됨

임베디드 타입

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    
    private String username;
    
    @Embedded
    private Period workPeriod;
    
    @Embedded
    private Address homeAddress;
    
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "city", column = @Column(name = "work_city")),
        @AttributeOverride(name = "street", column = @Column(name = "work_street")),
        @AttributeOverride(name = "zipcode", column = @Column(name = "work_zipcode"))
    })
    private Address workAddress;
}

@Embeddable
public class Period {
    private LocalDateTime startDate;
    private LocalDateTime endDate;
    
    public Period() {}
    
    public Period(LocalDateTime startDate, LocalDateTime endDate) {
        this.startDate = startDate;
        this.endDate = endDate;
    }
    
    public boolean isWork(LocalDateTime date) {
        return date.isAfter(startDate) && date.isBefore(endDate);
    }
}

@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipcode;
    
    public Address() {}
    
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
    
    public String getFullAddress() {
        return city + " " + street + " " + zipcode;
    }
}

장점:

  • 재사용 가능
  • 높은 응집도
  • 의미 있는 메소드 작성 가능

값 타입과 불변 객체

값 타입 공유 참조의 위험성:

Address address = new Address("city", "street", "10000");

Member member1 = new Member();
member1.setHomeAddress(address);

Member member2 = new Member();
member2.setHomeAddress(address); // 같은 address 인스턴스 공유

member1.getHomeAddress().setCity("NewCity"); // member2의 주소도 바뀜

해결책: 값을 복사해서 사용

Address address = new Address("city", "street", "10000");
Address copyAddress = new Address(address.getCity(), address.getStreet(), address.getZipcode());
member2.setHomeAddress(copyAddress); // 복사본 사용

불변 객체로 설계:

@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipcode;
    
    public Address() {}
    
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
    
    // Getter만 제공, Setter는 제공하지 않음
    public String getCity() { return city; }
    public String getStreet() { return street; }
    public String getZipcode() { return zipcode; }
}

값 변경 시:

Address oldAddress = member.getHomeAddress();
Address newAddress = new Address("NewCity", oldAddress.getStreet(), oldAddress.getZipcode());
member.setHomeAddress(newAddress);

값 타입 비교

@Embeddable
public class Address {
    private String city;
    private String street;
    private String zipcode;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Address address = (Address) o;
        return Objects.equals(city, address.city) &&
               Objects.equals(street, address.street) &&
               Objects.equals(zipcode, address.zipcode);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(city, street, zipcode);
    }
}

값 타입 컬렉션

@Entity
public class Member {
    @ElementCollection
    @CollectionTable(name = "favorite_food", 
                    joinColumns = @JoinColumn(name = "member_id"))
    @Column(name = "food_name")
    private Set<String> favoriteFoods = new HashSet<>();
    
    @ElementCollection
    @CollectionTable(name = "address", 
                    joinColumns = @JoinColumn(name = "member_id"))
    private List<Address> addressHistory = new ArrayList<>();
}

사용 예시:

Member member = new Member();
member.setUsername("김개발");
member.setHomeAddress(new Address("city1", "street1", "10000"));

member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("족발");
member.getFavoriteFoods().add("피자");

member.getAddressHistory().add(new Address("old1", "street1", "10001"));
member.getAddressHistory().add(new Address("old2", "street2", "10002"));

em.persist(member);

값 타입 컬렉션 수정:

Member findMember = em.find(Member.class, 1L);

// 기본값 타입 컬렉션 수정
findMember.getFavoriteFoods().remove("치킨");
findMember.getFavoriteFoods().add("한식");

// 임베디드 값 타입 컬렉션 수정
findMember.getAddressHistory().remove(new Address("old1", "street1", "10001"));
findMember.getAddressHistory().add(new Address("newCity1", "street1", "10001"));

값 타입 컬렉션의 제약사항:

  • 값 타입은 식별자 개념이 없음
  • 변경 사항 발생하면 주인 엔티티와 연관된 모든 데이터를 삭제하고 현재 값을 모두 다시 저장
  • 모든 컬럼을 묶어서 기본키를 구성해야 함

실무에서는 일대다 관계를 고려:

@Entity
public class AddressEntity {
    @Id
    @GeneratedValue
    private Long id;
    
    @Embedded
    private Address address;
    
    public AddressEntity() {}
    
    public AddressEntity(String city, String street, String zipcode) {
        this.address = new Address(city, street, zipcode);
    }
}

@Entity
public class Member {
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "member_id")
    private List<AddressEntity> addressHistory = new ArrayList<>();
}

값 타입 컬렉션 정리:

  • 식별자가 없어서 변경하면 추적 어려움
  • 변경 사항 발생하면 전체 삭제 후 다시 저장
  • 정말 단순한 경우에만 사용
  • 대안으로 일대다 관계를 고려
profile
백엔드

0개의 댓글