본문 바로가기
WEB/SpringBoot

[JPA Deep Dive] 02. 연관관계 매핑

by 정권이 내 2026. 2. 15.

🌟 관계형 데이터베이스의 연관관계 필요성

JPA의 연관관계 매핑을 올바르게 이해하고 적용하면, 개발자는 관계형 데이터베이스의 복잡한 외래 키 관리에서 벗어나 순수한 객체지향적인 관점으로 비즈니스 도메인을 설계할 수 있습니다.

객체의 참조만으로 데이터베이스의 조인과 데이터 저장을 자연스럽게 처리할 수 있게 되며, 이는 코드의 유지보수성과 개발 생산성을 향상시키는 원동력이 됩니다.

💣 실무에서 흔히 겪는 매핑 지옥과 성능 저하

JPA의 연관관계 매핑은 잘못 사용했을 때 치러야 할 대가도 큽니다. 많은 개발자들이 공통적으로 겪는 치명적인 시행착오들을 짚어보겠습니다.

😱 1. 무분별한 양방향 매핑이 부른 순환 참조와 메모리 폭발

가장 흔하게 저지르는 실수는 데이터베이스의 조인처럼 객체도 양방향으로 마음껏 접근할 수 있어야 한다고 착각하는 것입니다. 그래서 모든 엔티티에 서로를 참조하는 컬렉션과 객체를 마구잡이로 추가합니다. 이렇게 만들어진 양방향 객체 그래프는 양날의 검과 같습니다.

특히, Spring Boot에서 REST API를 개발할 때 조회된 엔티티를 DTO로 변환하지 않고 컨트롤러에서 바로 JSON으로 직렬화하여 반환하려다 무한 루프에 빠지는 경우가 비일비재합니다.

Jackson 라이브러리가 회원을 JSON으로 만들다가 주문 리스트를 발견하고, 주문 객체로 넘어가서 다시 회원을 발견하고 직렬화하는 과정을 끝없이 반복하다가 결국 StackOverflowError를 뱉어내며 서버가 다운되는 아찔한 상황이 발생합니다.

양방향 매핑은 반드시 필요한 경우에만, 그것도 철저한 통제하에 DTO 변환을 전제로 사용해야 합니다.

🐢 2. 즉시 로딩이 불러온 재앙, N+1 문제

JPA에서 연관된 엔티티를 언제 데이터베이스에서 가져올지 결정하는 것을 페치 전략이라고 합니다. 여기서 @ManyToOne이나 @OneToOne 어노테이션의 기본 페치 전략은 즉시 로딩, 즉 FetchType.EAGER로 설정되어 있습니다.

초기 개발 단계에서는 데이터를 한 번에 다 가져오니 편리하다고 느낄 수 있습니다.

하지만 실무 데이터베이스에 수만 건의 데이터가 쌓이고 나면 상황이 달라집니다. 회원 목록을 100명 조회하는 단순한 쿼리를 실행했는데, JPA가 각 회원의 소속 팀 정보를 가져오기 위해 100번의 추가적인 단건 조회 쿼리를 데이터베이스에 날리게 됩니다.

이른바 N+1 문제 인데, DB 커넥션 풀이 고갈되고 서버 응답 속도는 느려지며, 결국 서비스 장애로 직결되는 대표적인 안티 패턴입니다.

💡 핵심 : 연관관계 매핑의 정석과 실전 가이드

이제 본격적으로 실무에서 통용되는 JPA 연관관계 매핑의 핵심 개념과 바른 사용법을 코드로 파헤쳐 보겠습니다.

🎯 1. 단방향 매핑으로 시작하라, 그리고 연관관계의 주인을 정해라

객체의 연관관계는 철저하게 단방향으로 시작해야 합니다.

데이터베이스 테이블은 외래 키 하나만 있으면 양쪽에서 서로를 조인하여 데이터를 가져올 수 있습니다. 즉, 테이블의 연관관계는 항상 양방향입니다.

하지만 객체는 다릅니다. 객체에서 양방향 연관관계를 맺는다는 것은 서로 다른 두 개의 단방향 연관관계를 억지로 묶어놓은 것에 불과합니다.

여기서 가장 중요한 개념인 연관관계의 주인이 등장합니다. 두 객체가 서로를 참조할 때, 도대체 어떤 객체의 참조 값을 변경해야 데이터베이스의 외래 키가 업데이트되어야 할까요? JPA는 이 혼란을 막기 위해 두 연관관계 중 하나를 정해 데이터베이스 외래 키를 관리하도록 규칙을 정했습니다.

연관관계의 주인이란 데이터베이스 테이블에서 외래 키가 있는 곳을 의미합니다. 주인이 아닌 쪽은 읽기만 가능하며, 외래 키를 변경할 권한이 없습니다. 주인이 아닌 쪽 엔티티에는 mappedBy 속성을 사용하여 주인이 누구인지 명시해야 합니다.

실무적인 팁을 드리자면, 무조건 다(N) 쪽이 연관관계의 주인이 되어야 합니다. 예를 들어 회원(N)과 팀(1)이 있다면 회원 테이블에 팀의 외래 키가 들어가므로 회원이 연관관계의 주인이 됩니다.

🔗 2. 다중성 매핑의 종류와 실무적 판단 기준

JPA가 제공하는 4가지 다중성 어노테이션의 실무적인 쓰임새를 명확히 구분해 드리겠습니다.

가장 기본이자 핵심인 다대일 매핑 (@ManyToOne)

실무에서 가장 많이 사용되며, 가장 자연스러운 매핑 방식입니다. 데이터베이스 설계상 다(N) 쪽에 외래 키가 존재하므로, 객체 역시 다(N) 쪽에서 일(1) 쪽을 참조하는 구조가 매핑하기 가장 수월합니다.

일대다 매핑 (@OneToMany) - 주인이 될 때의 위험성

일(1) 쪽에서 다(N) 쪽을 관리하는 구조입니다. 이 매핑을 연관관계의 주인으로 설정하면 치명적인 단점이 발생합니다. 객체는 일(1) 쪽에서 컬렉션을 수정했는데, 실제 데이터베이스 반영을 위해 다(N) 쪽 테이블에 업데이트 쿼리가 날아가는 기이한 현상이 발생합니다. 직관적이지 않을뿐더러 성능상으로도 불리하므로, 실무에서는 일대다 단방향 매핑은 사용을 지양하고 다대일 양방향 매핑으로 우회하는 것이 정석입니다.

일대일 매핑 (@OneToOne)

일대일 관계는 어느 테이블에나 외래 키를 둘 수 있습니다. 실무에서는 주로 접근이 잦은 주 테이블에 외래 키를 두는 방식을 선호합니다. 예를 들어 회원과 프로필 사진 관계에서 회원 테이블에 프로필 사진 외래 키를 두면, 회원만 조회해도 사진 정보 존재 여부를 쉽게 알 수 있어 성능 최적화에 유리합니다.

다대다 매핑 (@ManyToMany) - 실무 도입 금지

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없어, 중간에 매핑 테이블을 둬야 합니다.

JPA의 다대다 매핑은 이 중간 테이블을 숨겨주고 자동으로 처리해주지만, 실무에서는 중간 테이블에 단순히 조인 키뿐만 아니라 생성일, 수정일, 주문 수량 등 추가적인 비즈니스 데이터가 반드시 필요해집니다.

따라서 @ManyToMany는 절대 사용하지 말고, 중간 테이블을 새로운 엔티티로 승격시켜 일대다, 다대일 관계로 풀어내는 것이 필수적입니다.

아래는 실무적인 관점을 모두 반영하여 작성된 회원과 팀의 다대일 양방향 매핑 코드 예제입니다. 모든 연관관계는 지연 로딩으로 설정되어 있습니다.

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;

    private String name;

    // 주인이 아님을 명시. 회원의 team 필드에 의해 관리됨을 의미
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    protected Team() {}

    public Team(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Member> getMembers() { return members; }
}
import jakarta.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String username;

    // 연관관계의 주인. 이곳의 값을 변경해야 데이터베이스 외래 키가 업데이트 됨
    // 실무 필수 설정: 무조건 지연 로딩(LAZY)을 사용해야 N+1 문제를 방지할 수 있음
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    protected Member() {}

    public Member(String username) {
        this.username = username;
    }

    // 연관관계 편의 메서드: 양방향 객체 그래프를 안전하게 묶어주기 위한 필수 메서드
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }

    public Long getId() { return id; }
    public String getUsername() { return username; }
    public Team getTeam() { return team; }
}

🚀 3. 최적화의 핵심, 모든 연관관계는 지연 로딩으로

위의 코드에서 보셨듯이, @ManyToOne@OneToOne을 사용할 때는 반드시 fetch = FetchType.LAZY 옵션을 적어주어야 합니다.

기본값이 즉시 로딩이기 때문입니다. 지연 로딩을 설정하면 회원 객체를 데이터베이스에서 가져올 때 연관된 팀 객체는 가짜 프록시 객체로 채워 넣고, 실제로 팀 객체의 메서드에 접근하여 값이 필요한 그 순간에만 데이터베이스에 쿼리를 날려 데이터를 가져옵니다.

그렇다면 여러 회원을 조회할 때 회원과 연관된 팀 정보가 한 번에 모두 필요한 비즈니스 로직은 어떻게 처리해야 할때는 페치 조인 을 사용합니다.

지연 로딩 설정을 유지한 채로, 특정 쿼리에서만 SQL 조인을 사용해 연관된 데이터를 한 번에 긁어오는 기능입니다. 아래는 Spring Data JPA에서 페치 조인을 적용하는 예제입니다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long> {

    // N+1 문제를 해결하기 위한 페치 조인 쿼리
    // Member를 조회할 때 연관된 Team까지 단 한 번의 쿼리로 함께 가져옵니다.
    @Query("select m from Member m join fetch m.team")
    List<Member> findAllWithTeam();
}

🌊 4. 부모와 자식의 생명주기를 맞추는 영속성 전이

연관관계 매핑에서 또 하나 중요한 개념은 영속성 전이입니다. 게시판 시스템에서 하나의 게시글은 여러 개의 첨부파일을 가질 수 있습니다. 게시글을 저장할 때 첨부파일 객체들도 한 번에 데이터베이스에 저장하고 싶거나, 게시글을 삭제할 때 첨부파일 데이터도 함께 날려버리고 싶을 때가 있습니다.

이때 부모 엔티티에 cascade = CascadeType.ALL 옵션을 주면 부모의 영속성 상태 변화가 자식에게 그대로 전파됩니다.

여기에 추가로 orphanRemoval = true 옵션까지 켜주면, 부모 엔티티의 컬렉션에서 자식 객체를 제거하기만 해도 데이터베이스에서 해당 자식의 데이터가 DELETE 되는 객체 제거 기능을 사용할 수 있습니다. 단, 이 기능들은 자식 엔티티가 오직 하나의 부모 엔티티에만 종속적인, 즉 소유자가 단 하나일 때만 안전하게 사용할 수 있음을 명심해야 합니다.

🌊 영속성 전이와 고아 객체 제거 실전 코드

가장 먼저 연관관계의 주인이 아닌 부모 엔티티(게시글) 쪽에 옵션을 부여하여 자식 엔티티(첨부파일)의 생명주기를 관리하는 설정입니다.

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Post {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    private String title;
    private String content;

    // 부모 엔티티에서 cascade와 orphanRemoval 옵션을 활성화합니다.
    // 이제 Post를 저장하면 연관된 Attachment들도 함께 저장되고,
    // attachments 컬렉션에서 객체를 제거하면 DB에서도 즉시 DELETE 됩니다.
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Attachment> attachments = new ArrayList<>();

    protected Post() {}

    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }

    // 연관관계 편의 메서드: 양방향 매핑을 안전하게 처리합니다.
    public void addAttachment(Attachment attachment) {
        this.attachments.add(attachment);
        attachment.setPost(this);
    }

    // 고아 객체 제거를 활용하기 위한 컬렉션 요소 제거 메서드입니다.
    public void removeAttachment(Attachment attachment) {
        this.attachments.remove(attachment);
        attachment.setPost(null);
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public List<Attachment> getAttachments() { return attachments; }
}

이어서 연관관계의 주인이자 자식 엔티티인 첨부파일 클래스입니다. 실무 원칙에 따라 반드시 지연 로딩을 적용합니다.

import jakarta.persistence.*;

@Entity
public class Attachment {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "attachment_id")
    private Long id;

    private String fileName;
    private String fileUrl;

    // 실무 필수 설정: N+1 문제를 방지하기 위한 지연 로딩(LAZY) 적용
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    protected Attachment() {}

    public Attachment(String fileName, String fileUrl) {
        this.fileName = fileName;
        this.fileUrl = fileUrl;
    }

    public void setPost(Post post) {
        this.post = post;
    }

    public Long getId() { return id; }
    public String getFileName() { return fileName; }
}

아래 서비스 레이어 코드를 보시면, 자식 엔티티인 첨부파일에 대해서는 별도의 Repository 호출이나 데이터베이스 저장/삭제 명령을 내리지 않는 것을 확인할 수 있습니다.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    @Transactional
    public void savePostWithAttachments() {
        Post post = new Post("JPA 영속성 전이", "내용입니다.");

        Attachment file1 = new Attachment("image1.png", "/url/image1");
        Attachment file2 = new Attachment("image2.png", "/url/image2");

        // 부모 객체에 자식 객체를 메모리 상에서 추가하기만 합니다.
        post.addAttachment(file1);
        post.addAttachment(file2);

        // 부모인 Post만 DB에 저장해도 CascadeType.ALL 설정 덕분에 
        // 연관된 2개의 Attachment 데이터까지 DB에 자동으로 INSERT 쿼리가 날아갑니다.
        postRepository.save(post);
    }

    @Transactional
    public void deleteAttachmentFromPost(Long postId, Attachment attachmentToRemove) {
        Post post = postRepository.findById(postId).orElseThrow();

        // 부모의 컬렉션(List)에서 자식 객체를 제거하기만 합니다.
        // orphanRemoval = true 설정 덕분에 트랜잭션 커밋 시점에 
        // DB에서 해당 Attachment 데이터가 자동으로 DELETE 됩니다.
        post.removeAttachment(attachmentToRemove);
    }
}

📝 오늘 내용의 3줄 요약

  • 외래 키가 있는 다(N) 쪽 엔티티를 연관관계의 주인으로 설정하고, 철저한 단방향 매핑을 기본 설계 원칙으로 삼으세요.
  • 실무에서 N+1 문제와 성능 저하를 막기 위해 모든 연관관계의 페치 전략은 반드시 지연 로딩(LAZY)으로 통일해야 합니다.
  • 한 번에 함께 조회해야 하는 데이터가 있다면, 즉시 로딩이 아닌 JPQL의 페치 조인을 적극적으로 활용하여 쿼리를 최적화하세요.
반응형

댓글