Dev/Spring

[Spring] 연관 관계 매핑

syuare 2025. 5. 19. 20:00

 

 

지난 프로젝트를 JDBC로 구현할 때 가장 힘들어했던 부분이 Entity끼리 관계를 맺는 부분이었던 것 같다. 

schedules 테이블(기존)에서 작성자명을 분리해서 별도의 작성자 테이블(authors)로 관리를 하고, 이 두 테이블을 이어주기 위해서 schedules 테이블에 author_id를 추가하면서 외래 키로 설정, 그리고 authors 테이블에 id 컬럼을 생성 후 Primary Key로 설정하여 이 두 테이블을 연결하는 매개체로 하였다.

 

물론 말로는 쉽고 SQL만 생각해봤을 때는 단순 JOIN 영역이기 때문에 어렵지는 않았지만 여기에 Java, JDBC로 구현하려니까 여간 복잡하고 헷갈려서 힘들었던 것 같다.

 

이런 식으로 JDBC에서 연관 관계 표현을 코드로 구현할 수 있다. 다만, JDBC에는 연관 관계 매핑이라는 개념 자체는 없다.

왜냐하면 연관 관계 매핑이라는 것 자체가 객체 지향적인 개념인데 JDBC는 SQL 중심의 데이터 접근 방법이기 때문이다.

 

아무튼 JPA의 연관 관계 매핑에 대해 알아보자

  • 연관 관계 매핑은 JPA에서 가장 중요한 개념 중 하나이며,
  • 이 개념을 잘 이해해야 객체지향 코드 설계 이점을 살리면서, 동시에 효율적이고 일관된 실제 DB 설계가 가능하게 도와준다.

연관 관계 매핑

Entity 끼리 관계를 맺는 방법.

JPA에서의 연관 관계 매핑은 Java의 객체 참조(필드)와 DB의 테이블 외래 키(FK)를 연결해주는 작업을 말한다.

 

JPA가 이 작업을 해줌에 따라 객체처럼 다루면서도 DB에 잘 저장되고 조회되게 도와준다.


연관 관계 매핑의 배경

Java는 객체 간 참조(주소)로 관계를 표현한다

class Member {
    String name;
    Order order;
}

 

  • Member 객체가 Order 객체를 필드로 가지고 있다.
  • 이 의미는 "name"이라는 사람이 어떤 주문(order)을 했는지 바로 접근이 가능하다는 것을 말하며,

이것이 객체 지향적인 관계를 표현하는 것이라 할 수 있다.


DB는 테이블과 행(row)의 구조로 특정 테이블이에서 숫자 값으로 관계를 표현한다.

  • 이 때 해당 숫자 값은 Foreign Key(FK)로 생성되어 있다.
members
- id
- name

orders
- id
- member_id (외래 키)
  • orders 테이블에서 member_id라는 숫자 키(FK)로 누가 주문했는지 접근이 가능하다.

이처럼 숫자 키로 다른 테이블과 연결되어 있는 것은 관계형 DB 스타일이라 할 수 있다.


정리하면 Java는 "주소" 기반이고 DB 는 "숫자" 기반이라 서로 관계를 표현하는 방식이 달라서

그냥은 Java와 DB를 연결할 수가 없다.

  • 자바는 member.getOrder().getId() 이런 식으로 객체를 타고 접근
  • DB는 SELECT * FROM orders WHERE member_id = ? 이런 식으로 ID로 조회

조금 더 풀어서 설명하자면

  • Java에서는 객체 간 참조로 관계를 표현하지만,
  • DB에서는 관계를 외래 키(FK)로 표현하기 때문에
  • 이 두 방식을 매핑(연결)해두는 과정이 필요한 것이다.

그래서 등장한 것이 JPA의 연관 관계 매핑이라 할 수 있겠다.

 

JPA는 객체 간 참조를 DB의 외래 키(FK)와 연결해주는 역할을 하는데,

아래와 같은 코드를 JPA는 이렇게 이해하게 된다.

  • Order는 member_id 라는 외래 키로 Member와 연결되어 있다.
  • 그리고 DB에서 저장하거나 가져올 때 그걸 자동으로 처리한다.
// 객체 입장: 자바 개발자 방식
class Order {
    Member member;
}

// 매핑 설정
@ManyToOne
@JoinColumn(name = "member_id")
private Member member;

 

즉, 아래의 코드처럼 Java와 DB에서 따로 처리하던 것을 JPA가 자동으로 매핑해서 처리해준다고 볼 수 있다.

Java:

Order order = new Order();
order.setMember(member);  // 그냥 객체 필드 연결

 

DB:

INSERT INTO orders (member_id) VALUES (3);  // 외래 키로 저장

 

한 줄 요약하면 개발자는 객체끼리만 연결하면, 나머진 JPA가 알아서 해준다.


연관 관계 방향

단방향 관계와 양방향 관계가 있다.

  • 단방향 관계: 말 그대로 한 쪽에서만 참조하는 관계
    • 단방향 관계는 구현에는 단순하지만 조회 제약이 있다.
  • 양방향 관계: 양쪽에서 서로 참조를 하는 관계 (mappedBy를 사용해서 연관 관계 주인 구분)
    • 양방향 관계는 양쪽 객체에서 참조가 가능하지만 무한 루프 문제 등이 있어 구현할 때 주의가 필요하다.
      (toString, JSON 직렬화)

*mappedBy: 양방향 관계에서 주인이 아닌 쪽에서 연관 관계를 관리할 때 사용한다.


연관 관계 종류

JPA는 관계를 매핑하기 위해서는 아래와 같은 관계 유형의 어노테이션을 제공한다.

관계 유형 설명 예시
@OneToOne 1:1 관계 사람 - 여권
@OneToMany 1:N (한 개가 여러 개와 관계) 회원 - 주문들
@ManyToOne N:1 (여러 개가 한 개와 관계) 주문 - 회원
@ManyToMany N:M 관계 (권장 X) 학생 - 강의 (조인 테이블 필요)

*@ManyToMany: 실무에서는 잘 사용하지 않음 > 중간 테이블 생성 후 @OneToMany + @ManyToOne 조합으로 처리한다.

@ManyToOne

하나의 주문(Order)은 한 명의 회원(member)에게 속한다.

  • Order 입장에서는 많은 주문이 각각 하나의 회원(member)과 연결된다.
  • 주문(Order) 입장에서는 N:1 관계 → @ManyToOne
@Entity
public class Order {
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "member_id") // 외래 키
    private Member member;

    // ...
}

@OneToMany

회원(Member) 1명은 주문(Order)을 여러 번 할 수 있다.

- 회원 입장에서는 1:N 관계 → @OneToMany

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

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

    // ...
}

연관 관계 매핑에서 고려해야 할 점

1. 외래 키(FK)는 항상 N쪽(다수) 테이블에 들어가며, JPA는 외래 키를 가진 쪽이 연관 관계의 주인이 된다.

따라서 위의 코드에서는 orders 테이블이 members 테이블의 id를 외래 키로 가진다.

  • @OneToMany에서 mappedBy = "member" 를 통해 연관 관계의 주인이 아님을 표시한다.

2. 양방향 연관관계에서는 무한 참조를 주의해야 한다.

  • Author → Schedule, Schedule → Author 와 같이 서로를 참조하게 될 경우
  • toString(), JSON 직렬화 시 무한 루프가 발생할 수 있다.
    • 물론 해결 방법도 있긴 하다. (Lombok, Jaskson 이용)
더보기

연관 관계의 주인

 

외래 키(FK)를 관리하고 DB에 반영할 수 있는 권한을 가진 Entity 필드이다.

 

연관 관계의 주인은 명확히 구분해야 한다.

이 처럼 연관 관계의 주인이 누구냐에 따라 결과가 달라질 수 있기 때문이다.

 

조금 더 풀어서 설명한다면,

 

JPA에서는 양방향 연관관계를 만들게 되면 두 객체가 서로를 참조하게 된다.

이 때 DB에 저장되는 외래 키는 1개이면, 양쪽 다 외래 키를 관리하려고 하다가 충돌이 발생한다.

그렇다고 만약 아무도 연관 관계의 주인을 하지 않으면 DB에는 아무것도 저장이 되지 않거나 덮어쓰기 등 오류가 발생한다.

 

그렇기 때문에 연관 관계의 주인은 명확히 지정해야 한다.

Member member = new Member();
Order order = new Order();

member.getOrders().add(order);  // 부모 → 자식 연결
order.setMember(member);       // 자식 → 부모 연결

// 예시
member.getOrders().add(order)만 했을 경우:	DB에 외래 키 안 바뀜 (무시됨)
order.setMember(member) 했을 경우:		외래 키 반영됨 (주인이니까!)


// 예시2

// 연관 관계 주인 > Order 클래스
@Entity
public class Order {
    @ManyToOne
    @JoinColumn(name = "member_id") // 외래 키 컬럼 → 이 필드가 주인
    private Member member;
}


/**
 * 연관 관계 비주인 > Member 클래스
 * mappedBy는 "나는 주인이 아니고, 진짜 주인은 Order의 member 필드야!" 라고 JPA에게 알려주는 역할
 */
@Entity
public class Member {
    @OneToMany(mappedBy = "member") // "Order.member"가 주인이다
    private List<Order> orders = new ArrayList<>();
}

 

3. 연관관계의 편의 메서드 사용 필요

양방향 관계에서는 두 객체 간의 관계를 양쪽에서 동기화해야 정상 작동한다.

만약 동기화를 누락할 경우 객체는 연결되지만 DB에는 반영되지 않을 수 있다.

public void addSchedule(Schedule schedule) {
    schedules.add(schedule);
    schedule.setAuthor(this);  // 양방향 동기화
}

4. 지연 로딩(LAZY) vs 즉시 로딩(EAGER)

LAZY: 실제 사용될 때 DB에서 가져온다 (성능이 좋다.)

EAGER: 객체가 로딩될 때 연관 객체도 즉시 로딩된다. (성능적으로 위험하다)

@ManyToOne(fetch = FetchType.LAZY)
private Author author;

※ 실무에서는 대부분 LAZY 방식을 기본으로 사용한다.

 

5. JOIN 과 QUERY의 최적화

연관된 데이터를 한 번에 가져오기 위해서는 JOIN을 사용해야하는데

JPA는 이를 자동 처리해주고, 명시적으로 지정도 할 수 있다.

 

@Query("SELECT s FROM Schedule s JOIN FETCH s.author")
List<Schedule> findAllWithAuthor();

/* ----------- OR ---------------- */

@EntityGraph(attributePaths = "author")
List<Schedule> findAll();  // 자동 fetch join 효과

JDBC의 연관 관계

JDBC에서는 직접 쿼리를 작성해서 연관 데이터를 가져와야 한다.

예시: 회원(Member)과 주문(Order)의 관계

// 1. 회원 테이블에서 회원 조회
SELECT * FROM member WHERE id = 1;

// 2. 주문 테이블에서 해당 회원의 주문 조회
SELECT * FROM orders WHERE member_id = 1;

// 3. Java 코드에서 객체를 수동으로 연결
Member member = new Member(1L, "홍길동");

// 직접 쿼리로 주문 목록 조회
List<Order> orders = orderDao.findByMemberId(member.getId());

// 수동으로 연결
member.setOrders(orders);

 

즉, JDBC에서는 관계를 코드로 직접 구현해야 하기 때문에 명확한 한계가 있다고 볼 수 있다.

  • 개발자가 모든 관계를 직접 쿼리로 구현하고 객체로 변환해야 한다.
  • 그렇다보니 객체 간의 연관성이 코드에 명시적으로 표현되지 않아서 이해와 유지보수가 어렵다.
  • 직접 쿼리를 구현해야하다보니 지연 로딩이나 캐싱, 자동 상태 추적 등의 고급 기능이 지원되기는 어렵다.
    (필요하다면 복잡한 코드를 직접 하나하나 구현해야 한다.)

JDBC vs JPA

  • JDBC는 연관 관계 매핑 기능이 없다: 개발자가 직접 쿼리를 구현하고 객체를 수동으로 연결해야 한다.
  • JPA는 객체 간 연관을 자동으로 매핑해주는 기능이 있다:  관계를 정의해두면 JPA가 알아서 처리해준다.
항목 JDBC JPA
접근 방식 SQL 중심 (절차적) 객체 중심 (선언적)
관계 처리 직접 SQL로 JOIN 또는 여러 쿼리 수행 애너테이션으로 관계 설정 (@OneToMany 등)
연관 객체 처리 개발자가 직접 쿼리와 객체 생성 JPA가 자동으로 관계를 매핑하고 객체 연결
자동 추적 없음 영속성 컨텍스트가 객체 상태 추적
편의성 낮음 (수작업 많음) 높음 (자동 변환, 연관된 객체 자동 로딩)

JDBC 방식 - 연관 관계 수동 처리

  • JDBC에서는 아래와 같이 직접 쿼리를 구현해서 각 개체를 수동으로 연결해야 한다.
더보기

1. 테이블 구조

CREATE TABLE authors (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255)
);

CREATE TABLE schedules (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255),
    date DATE,
    author_id BIGINT,
    FOREIGN KEY (author_id) REFERENCES authors(id)
);

 

2. Author 클래스

public class Author {
    private Long id;
    private String name;

    // getter/setter
}

 

3. Schedule 클래스

public class Schedule {
    private Long id;
    private String title;
    private LocalDate date;
    private Author author; // 연관 객체 (직접 연결해야 함)

    // getter/setter
}

 

4. DAO(ScheduleRepository)에서 연관 관계 처리

*DAO(Data Access Object): DB에 접근해서 데이터를 가져오거나 저장하는 역할을 담당하는 클래스를 의미

public Schedule findScheduleWithAuthor(Long scheduleId) throws SQLException {
    String sql = "SELECT s.id AS s_id, s.title, s.date, " +
                 "a.id AS a_id, a.name " +
                 "FROM schedules s " +
                 "JOIN authors a ON s.author_id = a.id " +
                 "WHERE s.id = ?";

    try (Connection conn = dataSource.getConnection();
         PreparedStatement stmt = conn.prepareStatement(sql)) {

        stmt.setLong(1, scheduleId);
        ResultSet rs = stmt.executeQuery();

        if (rs.next()) {
            Author author = new Author();
            author.setId(rs.getLong("a_id"));
            author.setName(rs.getString("name"));

            Schedule schedule = new Schedule();
            schedule.setId(rs.getLong("s_id"));
            schedule.setTitle(rs.getString("title"));
            schedule.setDate(rs.getDate("date").toLocalDate());
            schedule.setAuthor(author); // 수동 연결

            return schedule;
        }
    }

    return null;
}

JPA 방식 - 연관 관계 자동 매핑

Entity를 생성하고 JpaRepository를 상속받은 Repository 클래스를 생성만 하면

  • 조회 시 자동으로 author까지 가져오고
  • 수정/저장 시에도 schedule.setAuthor(autor) 등의 코드를 통해
    JPA가 알아서 Foreign Key 연관을 반영해 준다.
더보기

1. Author Entity

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "author")
    private List<Schedule> schedules = new ArrayList<>();
}

 

 2. Schedule Entity

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Schedule {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private LocalDate date;

    @ManyToOne
    @JoinColumn(name = "author_id")
    private Author author;
}

 

3. JpaRepository 사용

public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
    @EntityGraph(attributePaths = {"author"})
    Optional<Schedule> findById(Long id);
}
항목 JDBC 방식 JPA 방식
연관 관계 표현 직접 SQL + 객체 수동 조립 어노테이션 기반 선언적 매핑
개발자 작업량 SQL + 객체 생성 수동 처리 선언만 해두면 JPA가 자동 처리
객체 지향적 구조 표현이 불가능 자연스럽게 객체간 관계 표현
쿼리 관리 직접 작성해야 함 기본 CRUD 자동 제공, 커스터마이징 쉬움
유지보수 복잡하고 실수 가능성 큼 간결하고 일관성 유지 쉬움
  • JDBC는 연관 데이터를 가져오기 위해서 개발자가 직접 쿼리문을 작성하고 객체렬 연결하는 등 모든 과정을 수동으로 처리
  • JPA는 객체 간의 관계만 선언해주면 조회/저장/삭제 기능을 JPA가 자동으로 처리해준다.

이와 같은 차이로 인해 JPA가 개발 속도, 유지보수, 가독성, 확장성 면에서 훨씬 강하다는 것을 알 수 있다.

'Dev > Spring' 카테고리의 다른 글

[Spring] Session & Cookie (로그인 인증)  (0) 2025.05.26
[Spring] Servlet Filter  (0) 2025.05.22
[Spring] JPA  (0) 2025.05.15
[Spring] IP / Port / Domain / URL  (0) 2025.05.13
[Spring] 클라이언트 - 서버 간 통신 흐름  (0) 2025.05.09