현재 헥사고날 구조로 아키텍처를 짜다보니 클라이언트에서 들어온 요청은 Domain 객체로 다루고 저장할 때 Entity 객체로 변환해서 DB에 저장을 해주고 있다.
나는 Domain 객체 > Entity 객체로 변환할 때 Mapstruct라는 Mapper를 사용중인데, 이때 JPA로 워낙 연관관계가 많이 얽혀있다보니 변환과정에서 에러가 있었다.
따라서 해결과정을 담아보려고 한다.
여기서 다뤄볼 Entity만 가져온 것이지 사실 위에 보는 것처럼 간단하진 않다.
CourseInfo
는 연관관계 맺어진 Course
, List<StudentToCourseEntity>
이 있고,
Student
는 연관관계 맺어진 List<PatientEntity>
, List<StudentToCourseEntity>
가 있다.
또한 오늘 다뤄볼 메인 Entity인 StudentToCourse
의 연관관계는 위에 언급한 StudentEntity
와 CourseInfoEntity
이므로 이 세개만 보도록 하겠다.
지금 하고있는 프로젝트의 화면 디자인을 보니까, 학생을 등록할 때 학생 정보를 입력하고, 학생이 들을 강의를 강의 목록에서 선택해서 학생의 수강 강의 리스트를 같이 저장한다.
그말은 즉, Student
객체를 저장할 때 StudentToCourse
객체를 함께 저장한다는 말이다.
그런데 이때 StudentToCourse
는 Student
객체와 CourseInfo
객체를 필요로 한다. (Student
와 CourseInfo
가 FK이기 때문에)
- 클라이언트에게 학생에 필요한 정보, CourseInfo의 Id 리스트를 받는다.
{ "name": "학생이름", "studentNumber": "학생학번", "studentEmail": "학생이메일", "registrationStatus": "재학정보", "schoolYear": "학년정보", "courseInfoIds" : [강의, 정보, 아이디리스트] }
- 학생이름 ~ 학년정보는 Student로 저장, 강의정보 아이디리스트는 풀어서 StudentToCourse로 저장
2-1. Student(도메인) 객체는 StudentMapper를 이용해서 StudentEntity(엔티티) 객체로 변환 후JpaRepository의 save 메서드를 통해 저장
2-2. 2-1 성공 후 저장한 Student 객체와 강의 정보 아이디로 찾은 CourseInfo 객체를 StudentToCourse(도메인) 객체의 인자로 StudentToCourseMapper를 이용해서 StudentToCourseEntity(엔티티) 객체로 변환후 JpaRepository의 save 메서드를 통해 저장
Attempting to save one or more entities that have a non-nullable association with an unsaved transient entity
Not-null property references a transient value - transient instance must be saved before current operation : 객체 -> 객체
FK로 사용되는 컬럼값이 없는 상태에서 데이터를 넣으려다 발생한 에러라고 한다.
하지만 나는 Debug 모드를 통해 아래의 studentToCourse 객체를 봤을 때 student, courseInfo 모두 정보가 다 잘 들어가는 상황.
StudentToCourse studentToCourse = StudentToCourse.builder()
.student(student)
.courseInfo(courseInfo)
.build();
따라서 저장과정에서 문제가 있었다고 짐작을 했고, 도메인 객체를 엔티티 객체로 변환하는 과정에서 문제가 있다고 판단했다.
선임한테 여쭈어보았는데, MapperImpl에서 Debug모드로 보니 student와 courseInfo가 매핑과정에서 null로 들어가고 있음을 발견! (MapperImpl에서 Debug할 생각도 못하고 있었다...바보...! 🥹)
toEntity 메서드 구현부를 보니까 메서드도 텅텅 비어있었다.
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
import java.util.List;
@Entity
@Getter
@SuperBuilder
@Table(name = "courseInfo")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "deletedAt is null")
public class CourseInfoEntity extends DateColumnEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "courseInfoId")
private Long courseInfoId;
@JsonBackReference
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "courseId", nullable = false)
private CourseEntity courseEntity;
@JsonBackReference
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "userId", nullable = false)
private ProfessorEntity professorEntity;
@Convert(converter = StudClassConverter.class)
@Column(name = "studClass")
private StudClass studClass;
@Column(name = "deletedAt")
private LocalDateTime deletedAt;
@JsonManagedReference
@OneToMany(mappedBy = "courseInfoEntity", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
private List<StudentToCourseEntity> studentToCourseEntityList;
}
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import java.util.List;
@Entity
@Getter
@SuperBuilder
@DynamicInsert
@DynamicUpdate
@Table(name = "student")
@DiscriminatorValue("ROLE_STUDENT")
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentEntity extends UsersEntity {
@Convert(converter = RegistrationStatusConverter.class)
@Column(name = "registrationStatus", nullable = false)
private RegistrationStatus registrationStatus;
@Column(name = "year", nullable = false)
private int year;
@Column(name = "semester", nullable = false)
private int semester;
@Column(name = "schoolYear", nullable = false)
private int schoolYear;
@JsonManagedReference
@OneToMany(mappedBy = "studentEntity", fetch = FetchType.LAZY)
private List<PatientEntity> patientEntityList;
@JsonManagedReference
@OneToMany(mappedBy = "studentEntity", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
private List<StudentToCourseEntity> studentToCourseEntityList;
}
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
@Entity
@Getter
@SuperBuilder
@DynamicInsert
@DynamicUpdate
@Table(name = "studentToCourse")
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class StudentToCourseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "studentToCourseId")
private Long studentToCourseId;
@JsonBackReference
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "userId", nullable = false)
private StudentEntity studentEntity;
@JsonBackReference
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "courseInfoId", nullable = false)
private CourseInfoEntity courseInfoEntity;
}
import org.mapstruct.InjectionStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(
componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface StudentToCourseMapper {
@Mapping(target = "courseInfo.studentToCourseList", ignore = true)
@Mapping(target = "student.studentToCourseList", ignore = true)
StudentToCourse toDomain(StudentToCourseEntity studentEntity);
List<StudentToCourse> toDomainList(List<StudentToCourseEntity> entityList);
@Mapping(target = "courseInfoEntity.courseEntity", ignore = true)
@Mapping(target = "courseInfoEntity.studentToCourseEntityList", ignore = true)
@Mapping(target = "courseInfoEntity.professorEntity", ignore = true)
@Mapping(target = "studentEntity.patientEntityList", ignore = true)
@Mapping(target = "studentEntity.studentToCourseEntityList", ignore = true)
StudentToCourseEntity toEntity(StudentToCourse studToCourse);
List<StudentToCourseEntity> toEntityList(List<StudentToCourse> domainList);
}
toEntity
메서드를 통해 원했던 것
- StudentToCourse의 컬럼이자 FK인 CourseInfoEntity와 StudentEntity까지는 매핑
- CourseInfoEntity와 StudentEntity의 연관관계의 연관관계인 courseEntity, professorEntity, patientEntityList, studentToCourseEntityList의 Mapping은 끊어주는것.
그런데
StudentToCourseMapperImpl
을 보니 courseInfoEntity와 studentEntity도 매핑이 끊어져서 StudentToCourseEntity 객체의 인자인 courseInfoEntity와 studentEntity가 null로 들어가고 있었다.따라서, 아래 해결 버전처럼 courseInfoEntity와 studentEntity을 직접 매핑을 추가해주었다.
import org.mapstruct.InjectionStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(
componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface StudentToCourseMapper {
@Mapping(target = "courseInfo.studentToCourseList", ignore = true)
@Mapping(target = "student.studentToCourseList", ignore = true)
StudentToCourse toDomain(StudentToCourseEntity studentEntity);
List<StudentToCourse> toDomainList(List<StudentToCourseEntity> entityList);
/**
* 1. StudentToCourseEntity에서는 StudentEntity, CourseInfoEntity 를 FK로 가지기 때문에 Mapping이 되어야한다.
* 2. 그러나 1. 수행시 StudentEntity, CourseInfoEntity의 연관관계인 Patient, course, professor 등을 매핑 하려고 한다.
* 3. 따라서 연관관계 엔티티.연관관계 엔티티의 연관관계 엔티티마다 순환참조 매핑을 끊어준다.예) (target = {}Entity.{}Entity(List), ignore = true)
*/
@Mapping(target = "courseInfoEntity", source = "courseInfo")
@Mapping(target = "studentEntity", source = "student")
@Mapping(target = "studentEntity.patientEntityList", ignore = true)
@Mapping(target = "studentEntity.studentToCourseEntityList", ignore = true)
@Mapping(target = "courseInfoEntity.courseEntity", ignore = true)
@Mapping(target = "courseInfoEntity.professorEntity", ignore = true)
@Mapping(target = "courseInfoEntity.studentToCourseEntityList", ignore = true)
StudentToCourseEntity toEntity(StudentToCourse studToCourse);
// 위의 방법으로 or 아래 방법으로!
/**
* 1. StudentToCourseEntity에서는 StudentEntity, CourseInfoEntity 를 FK로 가지기 때문에 Mapping이 되어야한다.
* 2. 그러나 1. 수행시 StudentEntity, CourseInfoEntity의 연관관계인 Patient, course, professor 등을 매핑 하려고 한다.
* 3. 따라서 toStudDomain, toCourseDomain 메서드를 통해 해당 순환참조 매핑을 끊어준다.
*/
@Mapping(target = "courseInfoEntity", source = "courseInfo")
@Mapping(target = "studentEntity", source = "student")
StudentToCourseEntity toEntity(StudentToCourse studToCourse);
@Mapping(target = "patientEntityList", ignore = true)
@Mapping(target = "studentToCourseEntityList", ignore = true)
StudentEntity toStudDomain(Student student);
@Mapping(target = "courseEntity", ignore = true)
@Mapping(target = "professorEntity", ignore = true)
@Mapping(target = "studentToCourseEntityList", ignore = true)
CourseInfoEntity toCourseDomain(CourseInfo courseInfo);
List<StudentToCourseEntity> toEntityList(List<StudentToCourse> domainList);
}