Hibernate 다대다 연관 관계 및 생명 주기 관리 심층 분석

Hibernate에서 다대다(Many-to-Many) 연관 관계를 효과적으로 관리하는 것은 복잡할 수 있습니다. 특히 연관 테이블의 생명 주기와 엔티티 간의 계단식(cascade) 작업이 중요한 고려사항입니다. 이 글에서는 inverse, lazy, cascade 속성을 중심으로 다대다 관계 설정 시 주의할 점을 자세히 살펴보겠습니다.

1. 다대다 연관 관계의 핵심 속성

Hibernate의 HBM(Hibernate Mapping) XML 파일에서 다대다 관계를 정의할 때 다음 세 가지 속성이 가장 중요합니다.

1.1. inverse 속성: 연관 관계 관리 주체 지정

inverse 속성은 특정 엔티티가 연관 테이블(조인 테이블)의 레코드를 관리할 책임이 있는지 여부를 결정합니다. inverse="true"로 설정하면 해당 엔티티는 연관 관계의 소유자가 아니며, 조인 테이블에 대한 쓰기 작업을 수행하지 않습니다. 반대로 inverse="false" (기본값)는 해당 엔티티가 연관 관계를 관리하는 소유자임을 의미합니다.

  • 다대다 관계에서는 양쪽 엔티티 중 오직 한쪽만 inverse="false"로 설정하여 연관 테이블을 관리해야 합니다. 양쪽 모두 inverse="false"로 설정하면 중복된 연관 관계 생성 및 데이터 불일치와 같은 문제가 발생할 수 있습니다.
  • 보통 비즈니스 로직상 더 자연스럽게 연관 관계를 시작하거나 변경하는 쪽에 inverse="false"를 부여합니다.

1.2. lazy 속성: 지연 로딩 vs 즉시 로딩

lazy 속성은 연관된 컬렉션(Set)을 언제 로딩할지 제어합니다. 다대다 관계에서 엔티티는 다른 엔티티의 컬렉션(예: Set<Course>)을 포함합니다. 이 컬렉션은 Hibernate에 의해 PersistentSet으로 래핑됩니다.

  • lazy="true" (기본값): 엔티티를 로딩할 때 연관된 컬렉션은 즉시 로딩되지 않고, 컬렉션에 접근하는 시점에 로딩됩니다. 이는 성능 최적화에 유리하며, 필요 없는 데이터를 미리 로딩하여 발생하는 오버헤드를 줄입니다.
  • lazy="false": 엔티티를 로딩하는 즉시 연관된 컬렉션도 함께 로딩됩니다. 모든 연관된 데이터를 항상 필요로 하는 경우에 유용할 수 있지만, 불필요한 데이터 로딩으로 인해 성능 저하를 초래할 수 있습니다.

1.3. cascade 속성: 연관 엔티티로 작업 전파

cascade 속성은 특정 엔티티에 대한 작업(저장, 업데이트, 삭제 등)을 연관된 엔티티로 전파할지 여부를 정의합니다. 일반적으로 cascade="all"은 모든 작업을 전파함을 의미합니다.

  • save-update: 엔티티를 저장하거나 업데이트할 때 연관된 엔티티도 함께 저장/업데이트됩니다.
  • delete: 엔티티를 삭제할 때 연관된 엔티티도 함께 삭제됩니다. 다대다 관계에서 delete를 사용하는 경우, 이는 주로 조인 테이블의 해당 레코드 삭제를 의미하며, 연관된 엔티티 자체가 삭제되는 경우는 흔치 않습니다.
  • all: save-update, delete 등을 포함한 모든 작업이 전파됩니다.
  • none (기본값): 어떤 작업도 전파되지 않습니다.
  • all-delete-orphan: (주로 일대다 관계에서 사용되지만) 컬렉션에서 제거된 연관 엔티티를 자동으로 삭제합니다.

2. 다대다 관계에서의 연관 관계 및 엔티티 생명 주기 관리

이제 StudentCourse라는 두 엔티티를 예로 들어 다대다 관계에서의 저장, 업데이트, 삭제 시나리오를 살펴보겠습니다. 학생은 여러 강의를 수강할 수 있고, 강의는 여러 학생에 의해 수강될 수 있는 관계입니다.

2.1. 엔티티 정의

먼저, StudentCourse 엔티티를 정의합니다.

package com.example.model;

import java.io.Serializable;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;

public class Student implements Serializable {
    private Long id;
    private String name;
    private String studentEmail;
    private Date registrationDate;
    private Set<Course> registeredCourses = new HashSet<>();

    public Student() {}
    public Student(String name, String studentEmail, Date registrationDate) {
        this.name = name;
        this.studentEmail = studentEmail;
        this.registrationDate = registrationDate;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getStudentEmail() { return studentEmail; }
    public void setStudentEmail(String studentEmail) { this.studentEmail = studentEmail; }
    public Date getRegistrationDate() { return registrationDate; }
    public void setRegistrationDate(Date registrationDate) { this.registrationDate = registrationDate; }
    public Set<Course> getRegisteredCourses() { return registeredCourses; }
    public void setRegisteredCourses(Set<Course> registeredCourses) { this.registeredCourses = registeredCourses; }

    @Override
    public int hashCode() {
        return (id != null ? id.hashCode() : 0);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Student other = (Student) obj;
        return id != null && id.equals(other.id);
    }
}
package com.example.model;

import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;

public class Course implements Serializable {
    private Long id;
    private String courseTitle;
    private String courseCode;
    private Integer credits;
    private Set<Student> enrolledStudents = new HashSet<>();

    public Course() {}
    public Course(String courseTitle, String courseCode, Integer credits) {
        this.courseTitle = courseTitle;
        this.courseCode = courseCode;
        this.credits = credits;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getCourseTitle() { return courseTitle; }
    public void setCourseTitle(String courseTitle) { this.courseTitle = courseTitle; }
    public String getCourseCode() { return courseCode; }
    public void setCourseCode(String courseCode) { this.courseCode = courseCode; }
    public Integer getCredits() { return credits; }
    public void setCredits(Integer credits) { this.credits = credits; }
    public Set<Student> getEnrolledStudents() { return enrolledStudents; }
    public void setEnrolledStudents(Set<Student> enrolledStudents) { this.enrolledStudents = enrolledStudents; }

    @Override
    public int hashCode() {
        return (id != null ? id.hashCode() : 0);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Course other = (Course) obj;
        return id != null && id.equals(other.id);
    }
}

2.2. Hibernate 매핑 파일 (.hbm.xml)

Student를 연관 관계의 소유자(owning side)로 설정하고, Course는 비소유자(non-owning side)로 설정합니다.

<!-- student.hbm.xml -->
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
    "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
    "http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">

<hibernate-mapping>
    <class name="com.example.model.Student" table="STUDENT">
        <id name="id" type="long" column="STUDENT_ID">
            <generator class="native"/>
        </id>
        <property name="name" column="STUDENT_NAME" type="string" length="100"/>
        <property name="studentEmail" column="EMAIL" type="string" length="100"/>
        <property name="registrationDate" column="REG_DATE" type="timestamp"/>

        <!-- Student is the owning side -->
        <set name="registeredCourses" table="STUDENT_COURSE_ENROLLMENT" lazy="true" cascade="all" inverse="false">
            <key column="STUDENT_ID"/>
            <many-to-many class="com.example.model.Course" column="COURSE_ID"/>
        </set>
    </class>
</hibernate-mapping>
<!-- course.hbm.xml -->
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
    "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
    "http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">

<hibernate-mapping>
    <class name="com.example.model.Course" table="COURSE">
        <id name="id" type="long" column="COURSE_ID">
            <generator class="native"/>
        </id>
        <property name="courseTitle" column="COURSE_TITLE" type="string" length="255"/>
        <property name="courseCode" column="COURSE_CODE" type="string" length="50"/>
        <property name="credits" column="CREDITS" type="int"/>

        <!-- Course is the non-owning side -->
        <set name="enrolledStudents" table="STUDENT_COURSE_ENROLLMENT" lazy="true" cascade="none" inverse="true">
            <key column="COURSE_ID"/>
            <many-to-many class="com.example.model.Student" column="STUDENT_ID"/>
        </set>
    </class>
</hibernate-mapping>

2.3. 서비스 계층에서의 연관 관계 관리

이제 Student 엔티티가 연관 관계의 소유자이므로, Student 객체를 통해 연관 관계를 생성, 업데이트, 삭제하는 로직을 구현합니다.

package com.example.service;

import com.example.dao.StudentDao;
import com.example.dao.CourseDao;
import com.example.model.Student;
import com.example.model.Course;
import org.hibernate.Hibernate;

import java.util.List;
import java.util.Set;

public class EnrollmentService {

    private StudentDao studentDao;
    private CourseDao courseDao;

    public void setStudentDao(StudentDao studentDao) { this.studentDao = studentDao; }
    public void setCourseDao(CourseDao courseDao) { this.courseDao = courseDao; }

    /**
     * 학생을 특정 강의에 등록합니다.
     * Student가 연관 관계의 소유자이므로 Student를 통해 관계를 업데이트합니다.
     */
    public void assignCourseToStudent(Long studentId, Long courseId) {
        Student student = studentDao.findById(studentId);
        Course course = courseDao.findById(courseId);

        if (student != null && course != null) {
            // Lazy loading 이슈를 방지하기 위해 컬렉션을 초기화합니다.
            // 이 메서드는 트랜잭션 범위 내에서 호출되어야 합니다.
            Hibernate.initialize(student.getRegisteredCourses());
            student.getRegisteredCourses().add(course);
            
            // 양방향 관계인 경우, 비소유자 쪽에도 추가하는 것이 좋습니다 (단, 비소유자 쪽은 DB에 영향을 주지 않음)
            Hibernate.initialize(course.getEnrolledStudents());
            course.getEnrolledStudents().add(student);

            studentDao.saveOrUpdate(student); // Student가 Owning side이므로 관계가 저장됩니다.
        }
    }

    /**
     * 학생을 특정 강의에서 제외합니다.
     * Student가 연관 관계의 소유자이므로 Student를 통해 관계를 업데이트합니다.
     */
    public void removeCourseFromStudent(Long studentId, Long courseId) {
        Student student = studentDao.findById(studentId);
        Course course = courseDao.findById(courseId);

        if (student != null && course != null) {
            Hibernate.initialize(student.getRegisteredCourses());
            boolean removed = student.getRegisteredCourses().remove(course);

            if (removed) {
                // 양방향 관계인 경우, 비소유자 쪽에서도 제거
                Hibernate.initialize(course.getEnrolledStudents());
                course.getEnrolledStudents().remove(student);
                studentDao.saveOrUpdate(student); // Student가 Owning side이므로 관계가 업데이트됩니다.
            }
        }
    }

    /**
     * 학생 엔티티를 삭제합니다.
     * Student가 cascade="all" 및 inverse="false"이므로 학생 삭제 시 연관 테이블의 해당 레코드도 함께 삭제됩니다.
     */
    public void removeStudent(Long studentId) {
        Student student = studentDao.findById(studentId);
        if (student != null) {
            // 연관된 Course의 enrolledStudents 컬렉션에서도 해당 Student를 제거합니다.
            // 이는 애플리케이션 메모리 내 객체 간의 일관성을 유지하기 위함입니다.
            // Course 매핑의 cascade="none" 이므로 Course 엔티티 자체는 삭제되지 않습니다.
            Set<Course> courses = student.getRegisteredCourses();
            Hibernate.initialize(courses); // Lazy 로딩된 컬렉션 초기화
            for (Course course : courses) {
                Hibernate.initialize(course.getEnrolledStudents());
                course.getEnrolledStudents().remove(student);
                courseDao.saveOrUpdate(course); // Course를 업데이트하여 내부 컬렉션 동기화
            }
            studentDao.delete(student); // Student와 STUDENT_COURSE_ENROLLMENT 조인 테이블의 해당 레코드를 삭제
        }
    }

    /**
     * 강의 엔티티를 삭제합니다.
     * Course는 inverse="true"이므로 직접 조인 테이블을 관리하지 않습니다.
     * 따라서 강의 삭제 전에 해당 강의와 관련된 모든 학생들의 등록 목록에서 이 강의를 제거하여 조인 테이블 레코드를 정리해야 합니다.
     */
    public void removeCourse(Long courseId) {
        Course course = courseDao.findById(courseId);
        if (course != null) {
            // 모든 학생들을 순회하여 해당 강의를 제거합니다.
            // Course는 non-owning side이므로, 이 작업을 수동으로 처리해야 합니다.
            // 대규모 데이터의 경우, HQL/SQL을 통해 직접 STUDENT_COURSE_ENROLLMENT 테이블에서 해당 COURSE_ID를 가진 레코드를 삭제하는 것이 더 효율적일 수 있습니다.
            Set<Student> students = course.getEnrolledStudents();
            Hibernate.initialize(students); // Lazy 로딩된 컬렉션 초기화
            for (Student student : students) {
                Hibernate.initialize(student.getRegisteredCourses());
                student.getRegisteredCourses().remove(course);
                studentDao.saveOrUpdate(student); // Student를 업데이트하여 관계를 끊음
            }
            course.getEnrolledStudents().clear(); // 메모리 내 참조 끊기
            courseDao.delete(course); // Course 엔티티 삭제
        }
    }

    public List<Student> getAllStudents() {
        return studentDao.findAll();
    }

    public List<Course> getAllCourses() {
        return courseDao.findAll();
    }
}

removeStudentremoveCourse 메서드에서 컬렉션 초기화(Hibernate.initialize())는 트랜잭션 범위 내에서 이루어져야 합니다. OpenSessionInViewFilter를 사용하거나, 서비스 메서드에 @Transactional 어노테이션을 적용하여 트랜잭션 내에서 모든 데이터 접근이 이루어지도록 설정해야 합니다.

2.4. Hibernate 설정 (applicationContext.xml)

Spring과 Hibernate를 연동하는 기본적인 설정의 일부입니다. 세션 팩토리와 트랜잭션 매니저 설정에 중점을 둡니다.

<!-- applicationContext.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd
        http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.0.xsd
        http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd">

    <!-- 데이터 소스 설정 -->
    <bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">
        <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/university_db?serverTimezone=UTC"/>
        <property name="username" value="root"/>
        <property name="password" value="password"/>
    </bean>

    <!-- SessionFactory 설정 -->
    <bean id="sessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
        <property name="dataSource" ref="dataSource"/>
        <property name="hibernateProperties">
            <props>
                <prop key="hibernate.dialect">org.hibernate.dialect.MySQL8Dialect</prop>
                <prop key="hibernate.show_sql">true</prop>
                <prop key="hibernate.format_sql">true</prop>
                <prop key="hibernate.hbm2ddl.auto">update</prop>
                <prop key="hibernate.current_session_context_class">org.springframework.orm.hibernate4.SpringSessionContext</prop>
            </props>
        </property>
        <property name="mappingResources"> <!-- HBM XML 파일 사용 시 -->
            <list>
                <value>com/example/model/student.hbm.xml</value>
                <value>com/example/model/course.hbm.xml</value>
            </list>
        </property>
    </bean>

    <!-- HibernateTransactionManager 설정 -->
    <bean id="transactionManager" class="org.springframework.orm.hibernate4.HibernateTransactionManager">
        <property name="sessionFactory" ref="sessionFactory"/>
    </bean>

    <!-- 선언적 트랜잭션 관리 설정 -->
    <tx:advice id="txAdvice" transaction-manager="transactionManager">
        <tx:attributes>
            <tx:method name="get*" read-only="true"/>
            <tx:method name="find*" read-only="true"/>
            <tx:method name="list*" read-only="true"/>
            <tx:method name="*"/> <!-- 모든 다른 메서드에 트랜잭션 적용 -->
        </tx:attributes>
    </tx:advice>

    <aop:config>
        <aop:pointcut id="serviceOperation" expression="execution(* com.example.service.*.*(..))"/>
        <aop:advisor advice-ref="txAdvice" pointcut-ref="serviceOperation"/>
    </aop:config>

    <!-- DAO 및 Service 빈 정의 (예시) -->
    <!-- StudentDaoImpl과 CourseDaoImpl은 SessionFactory를 주입받아 HibernateTemplate 또는 Session을 직접 사용하는 구현체라고 가정합니다. -->
    <bean id="studentDao" class="com.example.dao.StudentDaoImpl">
        <property name="sessionFactory" ref="sessionFactory"/>
    </bean>
    <bean id="courseDao" class="com.example.dao.CourseDaoImpl">
        <property name="sessionFactory" ref="sessionFactory"/>
    </bean>
    <bean id="enrollmentService" class="com.example.service.EnrollmentService">
        <property name="studentDao" ref="studentDao"/>
        <property name="courseDao" ref="courseDao"/>
    </bean>
</beans>

2.5. 웹 애플리케이션 필터 (web.xml)

OpenSessionInViewFilter는 웹 요청 처리 동안 Hibernate 세션을 열어 지연 로딩 문제를 방지합니다. 이는 편리하지만, 트랜잭션 관리에 대한 이해가 필요합니다.

<!-- web.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:applicationContext.xml</param-value>
    </context-param>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <!-- Hibernate 세션을 요청 범위 내에서 유지하는 필터 -->
    <filter>
        <filter-name>openSessionInViewFilter</filter-name>
        <filter-class>org.springframework.orm.hibernate4.support.OpenSessionInViewFilter</filter-class>
        <init-param>
            <param-name>sessionFactoryBeanName</param-name>
            <param-value>sessionFactory</param-value>
        </init-param>
        <init-param>
            <param-name>singleSession</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>openSessionInViewFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- Struts2 필터 (원본 예시를 참고하여 추가, 필요 없으면 제거 가능) -->
    <filter>
        <filter-name>struts2</filter-name>
        <filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>struts2</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

태그: hibernate many-to-many Inverse cascade Lazy Loading

9월 3일 00:45에 게시됨