잡다한 프로그래밍 :: 잡다한 프로그래밍
반응형

JPA에서 멤버를 조회할 때 팀도 항상 조회해야 할까?

우리는 Member와 Team 간의 다대일(N:1) 관계를 가진 도메인을 가지고 있습니다.

그런데, 단순히 회원 정보만 필요한 비즈니스 로직에서 팀 정보까지 항상 같이 가져와야 할까?

 

JPA는 이 문제를 해결하기 위해 지연 로딩(LAZY Loading) 이라는 기능을 제공합니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

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

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
Member m = em.find(Member.class, 99L);
System.out.println("m.getTeam().getClass() = " + m.getTeam().getClass());
// 출력: class Team$HibernateProxy$xYz1234 (프록시 객체)

m.getTeam().getName(); // 이 시점에서 SELECT 쿼리 날아감

 

지연 로딩을 사용하면, team 필드는 프록시 객체로 채워지고, 실제로 getName() 등으로 접근할 때 그 시점에 DB에서 조회됩니다.

 

 

대부분 상황에 Member와 Team을 함께 사용한다면? (즉시 로딩 EAGER)

@ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
@JoinColumn(name = "TEAM_ID")
private Team team;
Member m = em.find(Member.class, 99L);
// 이 시점에 member와 team을 조인해서 **한 번의 쿼리**로 가져옴

System.out.println(m.getTeam().getClass());
// 출력: class Team (진짜 객체)

m.getTeam().getName(); // 이미 로딩되어 있음

 

 

실무에서는 즉시 로딩(EAGER)을 피하자 (LAZY만 사용하도록 권장)

이유 1. 원하지 않는 쿼리 발생

즉시 로딩은 JPA가 내부적으로 JOIN 쿼리를 자동으로 생성하기 때문에, 의도하지 않았던 SQL이 발생할 수 있습니다.

특히 모든 연관관계에 EAGER를 걸면… (너무많은 조인으로 성능이 나빠질 수 있음)

select * from member m
left join team t on m.team_id = t.id
left join ... // N개의 다른 엔티티들까지 조인됨

 

이유 2. JPQL 사용 시 N+1 문제 발생

 
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();

 

  • 이 JPQL은 Member만 조회하는 쿼리(SQL)로 번역됩니다.
  • 그런데 Member의 team이 EAGER로 설정되어 있다면?
select * from member;           -- 1회 (기본 쿼리)
select * from team where id=?;  -- N회 (멤버 수만큼 반복 조회)

 

 

즉 1번의 최초쿼리 (멤버조회) + N번의 추가 쿼리가 발생하게 됩니다.

 

해결책 (항상 LAZY 로 사용)

1. fetch join으로 필요한 곳에서만 즉시 조회 (대부분 이방법으로 해결)

String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
 
  • 프록시 없이 실제 Team 객체까지 한 번에 로딩됩니다.
  • N+1 문제 방지
  • JPQL로 컨트롤 가능

2. 엔티티 그래프 사용하기

@EntityGraph(attributePaths = {"team"})
List<Member> findAll(); // Spring Data JPA 사용 시
  • Repository 메서드에 애노테이션을 붙이면, 동적으로 fetch join 수행됨
더보기

실무에서 fetch join을 더 선호하는 이유

1. 쿼리 명시성

select m from Member m join fetch m.team

 

→ 어떤 테이블과 조인되고 있는지 직접 눈으로 확인 가능하고, 튜닝하기도 쉬움.

반면, @EntityGraph는 IDE에서 자동 완성도 부족하고, 쿼리가 어떤 식으로 실행되는지 추측해야 하는 경우가 많음.

 

2. 복잡한 조인에는 한계

select m from Member m
join fetch m.team
join fetch m.team.leader

위처럼 2단계 이상 중첩된 fetch join은 엔티티 그래프로는 거의 불가능하거나 매우 불편합니다.

3. Hibernate의 @BatchSize로 in-query 최적화

@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
Lazy 로딩을 유지하면서도, N+1을 in 절 쿼리로 묶어서 최적화
select * from team where id in (?, ?, ?, ..., ?)
반응형
반응형

JPA에서 사용하는 프록시란 무엇인가?

JPA는 성능 최적화를 위해 지연 로딩(Lazy Loading) 기능을 지원한다.

이를 가능하게 만드는 핵심 기술이 바로 프록시(Proxy) 객체다.

왜 프록시가 필요한가?

예를 들어, Member 엔티티와 Team 엔티티가 연관관계에 있다고 하자.

 

 

  • 어떤 경우에는 Member와 Team 정보를 모두 필요로 한다. → 이때는 fetch join이나 즉시 로딩(EAGER) 이 유리할 수 있다.
  • 하지만 대부분의 경우에는 Member 정보만 필요하다. → Team까지 항상 불러오는 건 성능 낭비다.

 

그래서 JPA는 Team은 나중에 필요할 때만 조회하도록 설정할 수 있고, 이때 Team 대신 프록시 객체를 넣어준다.

 

em.find() vs em.getReference()

 
find 실제 엔티티 조회 즉시
getReference 프록시 객체 반환 필요한 시점까지 지연
Member m = em.find(Member.class, memeber.getId()); // 이때 조회 쿼리가 실제로 발생함
System.out.prinln("id = " + m.getId());
Member m = em.getReference(Member.class, member.getId()); // select 쿼리 안나감
System.out.prinln("id = " + m.getId()); // 이땐 쿼리 X 이미 ID값으로 조회했으니 ID는 있음
// name 출력전 조회 쿼리 발생
System.out.prinln("id = " + m.getName());

// 만약에 조회하는 id값이 없으면??
// javax.persistence.EntityNotFoundException: Unable to find com.example.Member with id 999 익셉션 발생
  • 주의: 해당 ID가 실제 DB에 없으면 EntityNotFoundException 발생.

프록시의 정체

어떻게 이런식으로 구현이 가능할까?

하이버네이트가 실제 엔티티를 상속받은 프록시 객체를 구현하고 있기 때문이다

 

  • 이 프록시는 진짜 Member 엔티티의 참조를 내부에 보관하고 있다가, 메서드가 호출되면 그 시점에 DB에서 데이터를 조회한다.

프록시의 동작 흐름

 

1. 프록시 객체는 초기엔 데이터가 없음
- MemberProxy 내부에 실제 Member 객체(target)가 비어 있음.

2. getName() 호출 시 프록시가 초기화 요청
- 클라이언트가 member.getName()을 호출하면 프록시가 실제 데이터를 가져오기 위해 초기화 요청.

3. 영속성 컨텍스트가 DB에서 데이터를 조회함

4. 조회된 결과로 실제 엔티티(Member)를 생성

5. 프록시 객체가 실제 엔티티(target)와 연결됨

6. 이후부터는 실제 엔티티의 메서드를 통해 값 반환

프록시의 특징 요약

  • 실제 클래스를 상속받아 생성된다.
  • 겉으로 보면 동일한 타입처럼 보인다.
  • 프록시 객체는 실제 객체의 참조(target)을 보관한다
  • 프록시를 통해 실제 엔티티를 조회하면, 내부적으로 프록시 → 타겟 연결 → 타겟 메서드 호출 순으로 진행된다.
  • 프록시는 한 번만 초기화되며, 초기화되었다고 해서 객체가 진짜 엔티티로 바뀌는 것은 아니다.

 

프록시 사용 시 주의사항

1. 타입 비교 시 == 사용 금지

프록시 객체는 원본 엔티티를 상속받음, 따라서 타입 체크시 주의 해야함 (== 비교 실패, 대신 instance of 사용)

Member m1 = em.find(Member.class, 1L);
Member m2 = em.getReference(Member.class, 1L);

System.out.println(m1 == m2); // false
System.out.println(m1 instanceof Member); // true

 

2. 영속성 컨텍스트에 존재 여부에 따라 실제 객체 반환 가능

Member m = em.find(Member.class, 1);
System.out.println("m == " + m.getClass()); // 멤버 엔티티

Member reference = em.getReference(Member.class, 1);
System.out.prinln("m== " + m.getClass()); // 이또한 멤버 엔티티

같은 트랜잭션, 같은 영속성 컨텍스트에서는 JPA가 동일성(==) 보장을 위해 프록시 대신 실제 엔티티를 반환한다.  (반대도 마찬가지) + 이미 영속성 컨텍스트에 있는데 프록시로 반환해도 이점이 없음

 

3. 준영속 상태일 때 프록시 초기화 하면 에러 발생

 
Member reference = em.getReference(Member.class, 1);
System.out.prinln("m== " + reference.getClass());

em.detach(reference); // clear(), close()도 동일함
System.out.println("refMember = " + reference.getUsername());
// 에러발생 LazyInitializationException 발생!

프록시 유틸리티

초기화 여부 확인

PersistenceUnitUtil util = emf.getPersistenceUnitUtil();
util.isLoaded(m); // true or false

클래스 확인

entity.getClass()

 

프록시 강제 초기화

Hibernate.initialize(refMember); // 쿼리가 나가도록 강제 초기화

 

반응형
반응형

JPA의 @MappedSuperclass 정리

@MappedSuperclass는 공통 매핑 정보만 제공하는 용도의 클래스에 붙이는 JPA 애노테이션입니다.
실제 엔티티나 테이블로 매핑되지 않으며, 자식 엔티티가 이 클래스의 필드를 그대로 자신의 필드처럼 포함하여 테이블 컬럼으로 매핑되도록 합니다.

 

언제 사용하는가?

  • 여러 엔티티에 공통된 필드(ex. 생성일, 수정일, 삭제일 등)가 반복될 때
  • 상속 관계를 표현하려는 것이 아니라, 중복 제거 및 일관성 유지가 목적일 때
  • JPA의 상속 전략(@Inheritance) 을 사용하지 않고 테이블은 각각 따로 유지하고 싶을 때

@MappedSuperclass
pulbic abstract class BaseEntity {
	private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private LocalDateTime deletedAt;
}
@Entity
public class Member extends BaseEntity {
	private String name;
}

 

 

  • BaseEntity는 엔티티도 아니고 테이블도 생성되지 않습니다.
  • 대신 Member, Seller 등의 하위 클래스 테이블에 created_at, updated_at, deleted_at 컬럼이 자동으로 포함됩니다.
  • 이 방식은 ORM의 매핑 정보를 재사용하는 것이지, DB 상속 전략을 구현하는 것은 아님.
  • 따라서 직접 조회, 검색 불가
  • 직접 테이블 생성할 일 없으므로 추상클래스 권장
반응형
반응형

JPA의 상속 매핑 전략

JPA에서는 객체지향 언어에서 제공하는 상속(Inheritance) 개념을 관계형 데이터베이스 위에서도 그대로 표현할 수 있도록 상속 매핑 전략을 제공합니다.

 

하지만 DB는 상속 개념이 없기 때문에, 이를 다음의 세 가지 전략으로 풀어냅니다.

 

1. JOINED 전략 (@Inheritance(strategy = JOINED))

가장 정규화된 방식

 

  • 공통 속성은 Item 테이블에 저장하고
  • 개별 속성은 Album, Book, Movie 테이블에 저장
  • 각 자식 테이블은 ITEM_ID를 외래키로 가지며
  • 조회 시 JOIN으로 연결
  • ITEM 입장에서 어떤 타입의 데이터인지 알 수있게 DTYPE으로 구분할 수 있음 (넣는것을 권장)

 

@Entity
@Ingeritance(strategy = IngeritanceType.JOINED) // 해당 값이 없으면 기본 한테이블에 다 넣는 2번방식
@DiscriminatorColumn // 해당 값이 있으면 DTYPE이 생김
public abstract class Item { // 추상클래스로 안하면 > ITEM을 독단적으로 쓰는 경우가 있다고보고 생성하는것 추상클래스로 만들자
	@Id @GeneratedValue
    private Long id;
    
    private String name;
    private int price;
}
@Entity
// @DiscriminatorValue("A") 값으로 DTYPE값을 변경 가능
public class Album extends Item {
	private String artist;
}
@Entity
pulbic class Moview extends Item {
	private String director;
    private String actor;
}

장점

  • 테이블이 정규화되어 있어 무결성 보장
  • NOT NULL, FK, 유니크 제약조건 사용 가능
  • 저장공간 효율화

단점

  • JOIN이 많아져 조회 성능 저하 가능 (조회 쿼리가 복잡)
  • INSERT 시 부모 테이블과 자식 테이블에 2번 INSERT

 

2. 단일 테이블 전략 (@Inheritance(strategy = SINGLE_TABLE))

모든 데이터를 한 테이블에 넣는 방식 (주로 프로젝트가 단순하거나, 복잡한 테이블 구조가 필요없을 때 사용)

 

  • DTYPE으로 구분
  • 공통 컬럼 + 각 타입별 컬럼 모두 포함됨
  • 관련 없는 데이터는 NULL 처리됨

 

@Entity
@Ingeritance(strategy = IngeritanceType.SINGLE_TABLE) //혹은 생략
@DiscriminatorColumn // 해당 값이 없어도 무조건 생김 = DTYPE이 없으면 누가 누군지 몰라서 필수임
public abstract class Item {
	@Id @GeneratedValue
    private Long id;
    
    private String name;
    private int price;
}

장점

  • 조인이 없기 때문에 조회 성능 우수
  • 쿼리 단순함

단점

  • null 칼럼 다수 발생
  • 테이블이 점점 넓어짐 (column 수 증가) → 인덱싱 비효율
  • 구조상 제약 조건 사용 제한

3. 구현 클래스마다 테이블 전략 (@Inheritance(strategy = TABLE_PER_CLASS))

자식 클래스마다 완전한 테이블을 따로 생성 (비추천)

@Entity
@Ingeritance(strategy = IngeritanceType.TABLE_PER_CLASS)
public abstract class Item {
	@Id @GeneratedValue
    private Long id;
    
    private String name;
    private int price;
}

※ 추상클래스를 사용하지 않으면, ITEM 테이블이 단독으로 생길수 있으니 주의!

 

이 전략은 절대 실무에서 쓰지 말자

  • 부모 타입으로 조회하면 UNION ALL 쿼리 발생
    → SELECT * FROM album UNION ALL SELECT * FROM movie ...
Item item = em.find(Item.class, move.getId());
System.out.println("Item = " + item);
  • DTYPE 사용하지 않음

장점

  • 서브타입 명확히 분리
  • 각 테이블 독립적 → NOT NULL, 유니크 키 사용 가능

단점

  • 통합 조회 어렵고 성능 저하
  • 변경 시 확장성, 유지보수 최악 (테이블 변경시 비즈니스 로직 변경되어야함)
반응형
반응형

1. 다대일 단방향 (@ManyToOne)

 

실무에서 가장 많이 사용하는 구조
외래키가 N(다)쪽 테이블에 존재
DB와 객체 매핑이 가장 자연스럽고, 성능/유지보수/조회 모두 장점

코드로는?

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

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

정리

 

DB입장에서 다에 외래키가 가야함, @ManyToOne 맴버 입장에서 팀찾기 위해 Team을 매핑함

그니까 즉외래키가 있는곳에 (ManyToOne) 매핑하면됨

  • DB 기준: N쪽에 외래키가 가야함 (member 테이블에 team_id 외래키)
  • @ManyToOne 어노테이션 사용

 

2. 다대일 양방향 (@ManyToOne + @OneToMany)

Member 뿐 아니라, Team에서 member 조회 필요할 때
실제 연관관계의 주인외래키가 있는 곳(N쪽)
즉, @ManyToOne이 항상 주인, @OneToMany(mappedBy = "team_id")는 읽기용

코드 예시

 @Entity
 public class Member {
 	@Id @GeneratedValue
     @Column("MEMBER_ID")
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     //@Column(name = "TEAM_ID")
     //private Long teamId;
     
     @ManyToOne
     @JoinColumn(name = "TEAM_ID")
     private Team team;
 }
@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<>();
}

 

3. 일대다 단방향 (@OneToMany Only)

1쪽(Team)이 연관관계의 주인 (멤버입장에서 팀을 알필요가 없을 때)
1쪽(Team)에 외래키 관리 책임이 생김 → 비효율
JPA 표준 스펙은 지원하지만, 실무에서는 추천 안함

 

하지만 DB 설계 자체는 Member쪽에 외래키가 들어가야함

@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<>();    
}
 @Entity
 public class Member {
     @Id @GeneratedValue
     @Column("MEMBER_ID")
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
 }
Member member = new Member();
member.setUsername("member1");

em.persist(member); // insert member

Team team = new Team(); // insert team
team.setName("teamA");

team.getMembers().add(member); // update Member set TEAM_ID = ? WHERE MEMBER_ID=?

 

  • 동작:
    • team.getMembers().add(member) 시
    • 실제로는 update 쿼리 1번 더 나감
      (update Member set TEAM_ID = ? where MEMBER_ID = ?)
  • 비추천 이유:
    • 실질적으로 외래키는 항상 N쪽(Member)에 있으므로 update쿼리가 한번 더 나가서 성능상 손해
    • 1쪽(Team)이 외래키 관리 = 성능 손해, 객체지향적으로도 어색 (team을 수정했는데 > member테이블을 수정하는 쿼리 요청)
  • 조인컬럼을 꼭 써야함, 그렇지 않으면 조인 테이블 방식을 사용해버림
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;

    // 조인컬럼 명시 안 함!
    @OneToMany
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String username;
}

 

create table team (
    id bigint not null,
    name varchar(255),
    primary key (id)
);

create table member (
    id bigint not null,
    username varchar(255),
    primary key (id)
);

-- <== JPA가 만든 중간 테이블!
create table team_members (
    team_id bigint not null,
    members_id bigint not null,
    primary key (team_id, members_id)
);

4. 일대다 양방향 (거의 안 씀, 읽기전용 트릭)

사실상 쓸 일 없음. 읽기전용 컬렉션이나, 조인 쿼리/화면용
insertable=false, updatable=false 옵션 활용

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

이런 식:

  • insertable, updateable 옵션으로 읽기전용으로 사용 가능 (필수)
    • 읽기 전용으로 사용하지 않을경우 양방향으로 수정이 가능해서 운영상 큰 문제가 생길 수 있음
    • 하지만 그냥 이구조는 사용하지 말자
  • 사실상 데이터 일관성 보장 어렵고, 양방향 필요하면 다대일 양방향 구조 사용 권장

 

5. 일대일 매핑

DB입장에서 일대일 매핑의 경우 외래키는 양쪽 모두 가능하다.

즉 일대일 관계의 반대도 일대일 매핑이다. (외래키에는 UNI 제약조건이 필요하다)

 

외래키를 주 테이블(Member)에 둘 경우

 

 @Entity
 public class Member {
     @Id @GeneratedValue
     @Column("MEMBER_ID")
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     @ManyToOne
     @JoinColumn(name = "TEAM_ID", insertable = false, updateable = false)
     private Team team;
     
     @OneToOne
     @JoinColumn(name = "LOCKER_ID")
     private Locker locker;
 }
@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    //@OneToOne(mappedBy = "locker")  양방향 매핑이 필요한 경우 mappedBy로 읽기전용 매핑
    //private Member member;
}

 

정리:

  • 다대일 양방향 매핑처럼 외래키가 있는 곳이 연관관계의 주인
  • 반대편은 mappedBy 사용

 

외래키를 대상 테이블(Locker)에 둘 경우

JPA에서 Member에 Locker를 두는 경우는 지원하지 않는다.

즉, 단방향 연관관계에서는 항상 주 테이블이 외래 키를 가지고 있어야 합니다.

 

 

하지만 이런 양방향 관계는?

가능하다 Locker의 멤버를 외래키의 주인으로 둔다 (위쪽을 반대로 바꾼상황)

 

그럼 외래 키를 어디에 두는 게 좋을까?

DBA 관점

  • 정책이 바뀔 가능성 고려 (ex. 나중에 Member가 여러 개의 Locker를 가질 수도 있음)
    • 이 경우 외래 키를 **대상 테이블(Locker)**에 두고, UNIQUE 제약만 제거하면 1:N 구조로 쉽게 확장 가능
    • 반면 Member 쪽에 FK가 있으면 컬럼 삭제 및 스키마 변경 필요 → 유지보수 부담 증가
  • 데이터 모델의 유연성 확보에 유리

📌 선호: 외래 키를 **대상 테이블(Locker)**에 둔다.

ORM/JPA 개발자 관점

  • 대부분의 비즈니스 로직은 **Member(주 테이블)**를 중심으로 조회됨
  • Member에서 Locker 존재 유무에 따라 로직 분기가 많음
    쿼리 하나로 Locker 존재 여부 확인 가능
  • JPA 매핑이 쉬움
  • 성능상 이점 (불필요한 조인 생략 가능)
  • 📌 권장: 외래 키를 **주 테이블(Member)**에 둔다. (하지만 업무환경에 맞게...)

 

정리

 

주 테이블(주로 많이 접근하는 테이블)의 외래키

  • 주 객체가 대상 객체의 참조를 가지는 것 처럼 주 테이블에 외래키를 두고 대상 테이블을 찾음
  • JPA 매핑 편리
  • 주 테이블만 조회해도 대상 테이블의 데이터가 있는지 확인 가능 (member만 조회해도 locker의 유무를 알 수 있음)

만약 Locker값이 없으면 외래 키에 null을 허용해야한다는 단점이 있음

 

 

대상 테이블에 외래 키

  • 주 테이블과 대상 테이블을 일대일에서 일대다 관계로 변경할 때 테이블 구조 유지가능

프록시 기능의 한계로 지연로딩으로 설정해도 항상 즉시 로딩된다.

더보기

 

 

6. 다대다 매핑 (N:M) (실무에서 쓰면 안됨)

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없음

연결 테이블을 추가해서 일대다, 다대일 관계로 작성해야함

 

 

하지만 객체는 컬렉션을 사용해서 객체 2개로 다대다 관계 가능 (즉 객체관계를 아래 테이블 구조로 만들어준다)

 

예제 코드

 @Entity
 public class Member {
     @Id @GeneratedValue
     @Column("MEMBER_ID")
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     @ManyToOne
     @JoinColumn(name = "TEAM_ID", insertable = false, updateable = false)
     private Team team;
     
     @OneToOne
     @JoinColumn(name = "LOCKER_ID")
     private Locker locker;
     
     // 해당 부분
     @ManyToMany
     @JoinTable(name = "MEMBER_PRODUCT")
     private List<Product> products = new ArrayList<>();
 }
@Entity
public class Product {
	@Id @GeneratedValue
    private Long id;
    private String name;
    
    // 양방향으로 쓸경우 해당 부분 작성
    @ManyToMany(mappedBy = "roducts")
    private List<Member> members = new ArrayList<>();
}

실무에서 사용하면 안 되는 이유

굉장히 편해보이는데?

실제 운영 환경에서는 연결테이블이 단순하게 연결만 하고 끝나지 않고, 추가정보들을 필요로 한다.

ex) ORDERAMUNT, ORDERDATE...등

 

1. ManyToMany의 경우 중간 테이블에 추가 정보를 넣을 수 없다

2. 쿼리 구조가 비직관적

JPA가 생성하는 SQL이 다음과 같은 형태로 나가게 됩니다

Member 자체를 조회후 아래와 같은 2번의 쿼리를 실행한다.

 
select * from member_product where member_id = ?
select * from product where id in (?, ?, ?, ...)

 

  • join 없이 2번의 쿼리 실행
  • 추가 필터 조건이나 정렬이 어렵고,
  • 중간 테이블을 조작할 수 없음
왜 JOIN이 아닌가?

JPA에서 @ManyToMany는 **중간 테이블(member_product)**을 직접 엔티티로 취급하지 않고, 그냥 연결 매핑용 메타 정보만 활용하기 때문에 join하지 않고 2단계 접근합니다.

JPA는 연관 관계를 객체 기준으로 관리하기 때문에 단방향/양방향 여부와 Lazy/Eager 여부에 따라 내부 쿼리가 달라지지만, 대부분 @ManyToMany + LAZY 조합은 중간 테이블 → 대상 테이블 IN 절 조회 구조로 됩니다

 

 

 

실무에서의 해결 방법: 중간 엔티티로 승격

중간 테이블을 엔티티(MemberProduct)로 만들어 명시적으로 다뤄야 합니다.

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

     // 해당 부분
     @OneToMay(mappedBy="member")
     private List<MemberProduct> memberProducts = new ArrayList<>();
 }
@Entuty
public class MemberProduct {
	@Id @GeneratedValue
    private Long id;
    
    @ManyToOne
    @JoinColumn(name="MEMBER_ID")
    private Member member;
    
    @ManyToOne
    @JoinColumn(name="PRODUCT_ID")
    private Product product;
@Entity
public class Product {
	@Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "product")
    private List<MemberProduct> memberProduct = new ArrayList<>();
}

장점

  • orderDate, orderAmount 등 추가 컬럼 관리 가능
  • 비즈니스 로직에서 MemberProduct 자체를 조작 가능
  • 복잡한 조회 조건, 정렬 등도 자유롭게 작성 가능
  • 쿼리 최적화 가능 (JOIN, INDEX, FETCH 등)
반응형
반응형

양방향 연관관계 정리

 

양방향 매핑시 가장 많이 하는 실수 (연관관계의 주인을 입력하지 않음)

1. 연관관계의 주인 (owner)에 데이터를 변경해야함

 

  • @ManyToOne 또는 @JoinColumn이 주인
  • @OneToMany(mappedBy = "xxx")는 주인이 아님

[잘못된 예시]

// ManyToOne
Member member = new Member();
member.setUsername("member1");
em.persist(member);

// OneToMany
Team team = new Team();
team.setName("TeamA");
team.getMembers().add(memeber);
em.persist(team);

결과 = member의 team_id컬럼이 null

 

 

2. 양방향 매핑 시에는 양쪽 모두 값을 세팅하자

  • 객체의 일관성을 위해 필수
  • Hibernate 1차 캐시에 의한 착각을 방지

flush, clear를 하지 않고 그대로 쓰면 1차캐시에서 가져오기 때문에 null일 수 있음

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

// ManyToOne
Member member = new Member();
member.setUsername("member1");
member.setTeam(team);
em.persist(member);
// team.getMembers().add(member);

Team findTeam = em.find(Team.class, team.getId()); // 1차캐시에서 조회함
findTeam.getMembers() = 없음

 

 

팁: 별도의 메소드 또는 정적 팩토리 메소드로 분리하자

@Entity
 public class Member {
     @Id @GeneratedValue
     @Column("MEMBER_ID")
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     @Column(name = "TEAM_ID")
     private Long teamId;

	// 분리
	public void changeTeam(Team team) {
    	this.team = team;
        team.getMembers().add(this);
    }
 }

 

3. 무한 루프를 조심하자

3-1. toString() 무한 루프 주의 (롬복 toString 사용을 하지말자)

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

3-2. JSON 생성 라이브러리

엔티티를 JSON 변환시 ToString 처럼 무한 루프에 빠질수 있다.

 

따라서 DTO로 변환 하여 사용한다

 

정리 마무리

  • 단방향 매핑으로도 이미 연관관계 매핑은 완료 
  • 양방향은 편의 기능이며, 주인은 꼭 @ManyToOne
  • 객체와 DB의 일관성을 위해 양쪽 다 set해야 함
  • 무한 루프 방지: DTO, @ToString.Exclude, @JsonIgnore 등 사용
  • 초기엔 단방향 설계 → 필요할 때만 양방향 추가
반응형

'프로그래밍 > JPA' 카테고리의 다른 글

[JPA] 상속관계 매핑  (1) 2025.07.03
[JPA] 다양한 연관관계 매핑  (0) 2025.06.30
[JPA] 양방향 연관관계와 연관관계의 주인[1]  (0) 2025.06.24
[JPA] 단방향 연관관계  (0) 2025.06.24
[JPA] 기본키 매핑  (0) 2025.06.19
반응형

이번엔 양방향 연관관계에 대해 정리해본다.

 

단방향 연관관계와 양방향 연관관계의 차이점

Team team = new Team();
team.setName("A");
em.persist(team);

Member m = new Member();
member.setUsername("member1");
member.setTeam(team);

Member m = em.find(Member.class, member.getId());
Team t = m.getTeam(); // 팀 찾을 수 있음

지난 번 단방향 연관관계에서는 member에서 team을 객체지향적으로 찾을 수 있었다.

 

하지만 team에서 반대로 member를 찾을 수 없다, 양방향 매핑이 안되어 있기 때문임!

 

 

양방향 연관관계란?

 

JPA에서 객체와 테이블의 연관관계는 다르게 동작한다.

  • DB 테이블은 외래키(예: MEMBER.TEAM_ID) 하나로 양방향 조인 가능
  • 객체는 서로 참조해야만 양방향이 된다 → 단방향 두 개의 조합

즉, JPA에서 양방향 연관관계란 서로를 참조하는 단방향 관계 2개를 만드는 것과 같다.

 

member 엔티티

 @Entity
 public class Member {
 	@Id @GeneratedValue
     @Column("MEMBER_ID")
     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 엔티티

 

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

 

이렇게 명시적으로 양방향 관계를 맺을경우, team에서도 member를 get할 수 있다.

 

JPA 양방향 연관관계에서 연관관계의 주인이란?

앞서 말했듯 객체와 테이블의 관계에는 차이가 있다.

 

  • 객체 세계에서는 연관관계를 2개의 단방향으로 표현한다.
  • 반면 DB 세계에서는 외래키 하나로 양방향 조인이 가능하다.

 

연관관계 주인이 왜 필요할까?

이제 이런 상황을 생각해보자.

나는 A라는 멤버의 팀정보를 바꾸고 싶을때, member에 있는 Team 객체를 수정해야할까? 아니면 팀에 있는 member 객체를 수정해야할까?

 

이런 애매한 상황이 생길 수 있으므로 명확한 연관관계의 주인이 필요하다

연관관계의 주인이란?

🔑 정의

DB의 외래키를 관리하는 객체

즉, 외래키를 직접 가지고 있는 쪽, 대부분 @ManyToOne이다.

만약 oneToMany를 주인으로 사용할경우 Team에 있는 member를 변경했는데 쿼리는 member를 수정하는 쿼리가 나가게된다.

즉 Team 객체를 수정했는데 쿼리는 member를 수정(?) 복잡해지고 객체 지향적이지 못함 (성능이슈도 있음)

규칙 정리

항목 설명
연관관계의 주인 외래키가 있는 객체 (대개 @ManyToOne)
외래키 관리 여부 연관 관계의 주인만이 외래키를 관리 (등록 수정 = INSERT/UPDATE 쿼리 발생)
mappedBy 주인이 아닌 쪽에서 사용, 주인이 아닌경우 읽기만 가능
연관관계 설정 방식 주인만 세팅하면 DB에 반영됨

mappedBy의 의미

@OneToMany(mappedBy = "team")
private List<Member> members;

mappedBy = "team" 은 다음과 같은 뜻이다:

  • "나는 주인이 아니다"
  • "연관관계 관리는 Member.team이 한다"
  • "나는 읽기 전용이며 insert/update 대상이 아니다"
 

 

반응형

'프로그래밍 > JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계 매핑  (0) 2025.06.30
[JPA] 양방향 연관관계와 연관관계의 주인[2]  (0) 2025.06.25
[JPA] 단방향 연관관계  (0) 2025.06.24
[JPA] 기본키 매핑  (0) 2025.06.19
[JPA] 필드와 컬럼 매핑  (1) 2025.06.19
반응형

객체 지향적 설계의 시작인 단방향 연관관계에 대해서 학습해보자

배경: Member와 Team의 관계

회원(Member)은 하나의 팀(Team)에만 소속될 수 있습니다.

즉, 다대일(N:1) 관계이며, DB에서는 다음과 같은 테이블 구조를 가집니다.

관계형 DB에서는 TEAM_ID 외래 키를 통해 팀과 연결됩니다. 그런데 이 구조를 JPA로 그대로 옮기면 아래처럼 됩니다.

 

비객체지향적 JPA 매핑

Member 엔티티

 @Entity
 public class Member {
     @Id @GeneratedValue
     @Column("MEMBER_ID")
     private Long id;
     
     @Column(name = "USERNAME")
     private String name;
     
     @Column(name = "TEAM_ID")
     private Long teamId;

 }

 

TEAM 엔티티

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

 

문제점

Team team = new Team();
team.setName("A");
em.persist(team);

Member m = new Member();
member.setUsername("member1");
member.setTeamId(team.getId()); // 객체 지향적이지 못함 setTeam이여야 객체 지향적이지 않을까?

 

 

실제 저장 결과

MEMBER_ID TEAM_ID USERNAME
1 1 member1

 

TEAM_ID NAME
1 A
  • 저장은 되지만, setTeamId()는 객체지향스럽지 않습니다.
  • Member는 Team 객체와 연관이 없음
  • 조회 시도도 번거로움:
Member m = em.find(Member.class, memberId);
Team team = em.find(Team.class, m.getTeamId()); // 직접 Team ID로 또 조회해야 함
객체지향 세계에서는 "객체가 객체를 참조"해야 자연스럽습니다.
반면, 위 방식은 단지 외래 키 숫자만 저장하고 있어 테이블 중심 사고에 머물러 있습니다.

 

즉 객체를 테이블에 맞추어 데이터 중심으로 모델링하면 협력 관계(연관 관계)를 만들 수 없음

해결: 단방향 연관관계 매핑

Member 객체가 Team 객체를 직접 참조하게 만듭니다. 즉, 연관 관계를 객체 간 참조로 표현합니다.

 

- N이되는 쪽에 @ManyToOne 어노테이션을 사용한다

- DB JOIN 컬럼에 @JoinColumn 어노테이션을 사용한다.

 @Entity
 public class Member {
 	@Id @GeneratedValue
     @Column("MEMBER_ID")
     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 team = new Team();
team.setName("A");
em.persist(team);

Member m = new Member();
member.setUsername("member1");
member.setTeam(team);

조회할 때도 깔끔하게 객체를 통해 참조 가능합니다:

 

정리: 왜 단방향 연관관계가 중요한가?

설계 패턴 테이블 중심 객체지향 중심
코드 복잡도 조회 시 매번 ID → DB 재조회 필요 객체 내부 필드에서 바로 접근 가능
유지보수 ID와 객체 동기화 이슈 발생 가능 일관성 있는 모델링
장점 단순, 초기 진입 쉬움 JPA와 객체지향 철학에 부합
반응형

+ Recent posts