JPA 연관관계 매핑 (2)

김소연·2026년 4월 20일

JPA 연관관계 매핑

@OneToOne(1:1)

회원 멤버와 프로필 사진처럼 1:1로 매핑되는 관계입니다.

  • 핵심
    외래 키(FK)를 어디에 둘지 선택해야 합니다.

    1. 주도 테이블에 FK 두기 (추천): MemberProfile를 사용하는 관계라면, Member 테이블에 profile_id를 둡니다. Member가 주인이 되어 조회 시 더 직관적입니다.
    2. 대상 테이블에 FK 두기: Profile 테이블에 profile_id를 둡니다. 이 경우 Profile가 주인이 됩니다.

      헷갈린다면, “누가 먼저냐”를 따져보는 것도 좋습니다.
      이 세상에 사람이 먼저일지, 사람의 사진이 먼저일지 생각해본다면 사람이 우선입니다.
      사람이 존재해야 사진도 존재하니까요!
      따라서 위 경우에 Member 테이블이 주인(주도 테이블)이 되는 것이 자연스럽습니다.

  • 코드 예시

    @Getter
    @Entity
    @Table(name = "members")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String username;
    
        @OneToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "profile_id")
        private Profile profile;
        // ...
    }
    @Getter
    @Entity
    @Table(name = "profiles")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Profile {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String imageUrl;
        // ...
    }

@ManyToOne(N:1)

MemberTeam처럼, '다수' 쪽에서 '단일' 쪽을 참조하는 관계입니다. 사실상 거의 모든 관계의 핵심입니다!

  • 핵심
    연관관계의 주인이 되는 쪽입니다. (@JoinColumn으로 FK를 관리)

    🚨 중요! ⭐⭐⭐⭐⭐
    모든 연관관계를 @ManyToOne 하나만으로 정의 가능합니다.

  • 코드 예시

    @Getter
    @Entity
    @Table(name = "members")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String username;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "team_id")
        private Team team;
        // ...
    }
    @Getter
    @Entity
    @Table(name = "teams")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Team {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String name;
        // ...
    }

@OneToMany(1:N)

TeamList<Member>처럼, '단일' 쪽에서 '다수' 쪽을 참조하는 관계입니다.

  • 핵심
    @OneToMany는 @ManyToOne이 반드시 매핑 관계에 있는 엔티티에 정의되어 있어야 사용할 수 있습니다.
    따라서 @OneToMany를 사용하기 전에 @ManyToOne을 먼저 정의해야합니다.

  • 코드 예시

    @Getter
    @Entity
    @Table(name = "members")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String username;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "team_id")
        private Team team;
        // ...
    }
    @Getter
    @Entity
    @Table(name = "teams")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Team {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String name;
    
        @OneToMany(mappedBy = "team")
        private List<Member> members = new ArrayList<>();
        // ...
    }

@ManyToMany(N:M)

한 권의 책은 여러 명의 저자를 가질 수 있고, 한 명의 저자는 여러 권의 책을 집필할 수 있습니다.
이처럼, 다대다 관계를 표현합니다.

  • 핵심

    ⚠️ 실무에서도, 연습에서도 @ManyToMany를 절대 사용하지 않습니다.

  • 코드 예시

    @Getter
    @Entity
    @Table(name = "books")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Book {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String title;
    
        // Book이 관계의 주인 (조인 테이블 생성)
        @ManyToMany
        @JoinTable(
            name = "book_author", // 자동으로 생성되는 조인 테이블
            joinColumns = @JoinColumn(name = "book_id"), // 현재 엔티티의 FK
            inverseJoinColumns = @JoinColumn(name = "author_id") // 반대쪽 엔티티의 FK
        )
        private List<Author> authors = new ArrayList<>();
    }
    @Getter
    @Entity
    @Table(name = "authors")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Author {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String name;
    
        @ManyToMany(mappedBy = "authors")
        private List<Book> books = new ArrayList<>();
    }
  • 사용하지 않아야하는 이유
    코드를 보면 Book과 Author 엔티티 클래스가 정의되어있습니다.
    하지만 실제로 테이블은 Book과 Author만 생성되지 않습니다.
    (BOOK_AUTHOR라는 중간 테이블이 자동으로 만들어짐.)
    이런 식으로 코드로 작성하지 않은 숨겨진 SQL이 실행되기 때문에 추후에 수정 사항이 있어도 유지보수가 어렵습니다.

  • 💡 예상되는 문제점들

    1. BOOK_AUTHOR 테이블을 코드에서 조회 불가합니다.
    2. BOOK_AUTHOR 테이블에 코드로 새로운 필드를 추가하거나 삭제할 수 없습니다.
      따라서 @ManyToMany는 절대 사용하지 않습니다.

JPA 조회 전략(FetchType)

FetchType

  • JPA가 하나의 엔티티를 조회할 때, 그 엔티티와 연관된 다른 엔티티를 언제 데이터베이스에서 함께 조회할지를 결정하는 옵션입니다.

💡 비유를 통해 알아보기

  • FetchType.EAGER (즉시 로딩): 햄버거와 감자튀김을 반드시 세트로만 시킬 수 있는 패스트푸드점입니다. 원하든 원치 않든 무조건 햄버거와 감자튀김이 세트로만 나옵니다.
  • FetchType.LAZY (지연 로딩): 햄버거만 먼저 받고, 나중에 감자튀김이 먹고 싶어지면 따로 주문할 수 있는 패스트푸드점입니다. 감자튀김은 먹어도 되고, 먹지 않아도 됩니다.

1. EAGER (즉시 로딩)

  • EAGER는 이름 그대로 '즉시' 로딩하는 전략입니다. 부모 엔티티를 조회하면, 연관된 자식 엔티티까지 한 번에 모두 조회합니다.

    @Getter
    @Entity
    @Table(name = "members")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String username;
    
        @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
        @JoinColumn(name = "team_id")
        private Team team;
        // ...
    }

    (코드에서 @ManyToOne(fetch = FetchType.EAGER)를 확인!)

    @Getter
    @Entity
    @Table(name = "teams")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Team {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String name;
        // ...
    }

    Team과 Member가 이미 존재하는 상황에서 Member를 조회하면,(SELECT 쿼리가 딱 1번 실행!)Team과 Member를 join문을 사용하여 동시에 가져오는 것을 로그로 확인해볼 수 있습니다.


2. LAZY (지연 로딩)

  • LAZY는 '지연'해서 로딩하는 전략입니다. 부모 엔티티를 조회할 때는 연관된 자식 엔티티를 가져오지 않고, 실제로 그 데이터가 필요한 시점에 비로소 DB에서 조회합니다.

    @Getter
    @Entity
    @Table(name = "members")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String username;
    
        @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
        @JoinColumn(name = "team_id")
        private Team team;
        // ...
    }

    (코드에서 @ManyToOne(fetch = FetchType.LAZY)를 확인!)

    @Getter
    @Entity
    @Table(name = "teams")
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Team {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String name;
        // ...
    }

    Team과 Member가 이미 존재하는 상황에서 Member를 조회하면,(SELECT 쿼리가 2번 실행!)
    Member 데이터 따로, Team 테이블을 따로 쿼리해서 가져오는 것을 확인할 수 있습니다.


프록시(Proxy) 객체

  • FetchType.LAZY를 사용했을 때, 실제로 데이터베이스에서 연관 데이터를 가져오지 않는다고 했습니다.
    Member 엔티티 객체의 team 필드는 그럼 어떻게 되는 걸까요?

        @Transactional(readOnly = true)
        public GetMemberResponse findOne(Long memberId) {
            Member member = memberRepository.findById(memberId).orElseThrow(
                    () -> new IllegalStateException("없는 멤버입니다.")
            );
            System.out.println("팀 확인: " + member.getTeam().getClass().getName());
            return new GetMemberResponse(
                    member.getId(),
                    member.getUsername(),
                    member.getTeam().getId(),
                    member.getTeam().getName()
            );
        }

    (위와 같은 코드를 실행하여 Member를 조회하는 상황)
    null이거나 실제 객체라고 생각할 수 있겠지만, 실제로는 프록시 객체가 매핑되어있습니다..

  • ($HibernateProxy라고 적혀있는 것을 확인 가능하다.)
    그리고 실제 사용할 때, 실제 객체의 데이터를 내부적으로 로드하여 마치 실제 객체처럼 행동하게 됩니다. (이를 Proxy Initialization이라고 합니다)

  • JPA의 프록시 객체는 마치 책의 목차와 같습니다. 우리가 특정 정보를 찾을 때 책 전체를 다 읽지 않고 목차부터 보듯이, JPA도 Member를 조회할 때 Team 정보는 목차(Proxy)만 살짝 가져옵니다. 그리고 우리가 실제로 그 Team 정보를 펼쳐볼 때 해당 페이지(데이터)를 읽어오는 방식으로 최적화를 한 것입니다.


0개의 댓글