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. 다대다 관계에서의 연관 관계 및 엔티티 생명 주기 관리
이제 Student와 Course라는 두 엔티티를 예로 들어 다대다 관계에서의 저장, 업데이트, 삭제 시나리오를 살펴보겠습니다. 학생은 여러 강의를 수강할 수 있고, 강의는 여러 학생에 의해 수강될 수 있는 관계입니다.
2.1. 엔티티 정의
먼저, Student 및 Course 엔티티를 정의합니다.
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();
}
}
위 removeStudent 및 removeCourse 메서드에서 컬렉션 초기화(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>