본문 바로가기
코딩 공부/web & Java

[Java / TDD] 테스트 케이스 작성시 발생한 영속성 관련 오류

by 현장 2024. 8. 28.

✔️ 사용된 엔티티

1. QuestionBoard

@Getter
@Setter
@Table(name = "question_board")
@ToString(callSuper = true)
@Entity
public class QuestionBoard extends AuditingFields {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // UsreAccount와 연결 -> JoinColumn을 통해 user_id와 연결
    @JoinColumn(name = "user_id")
    @ManyToOne
    private UserAccount userAccount; // 유저 정보

    @Column(nullable = false, name = "title")
    private String title; // 제목

    @Column(nullable = false, name = "content")
    private String content; // 내용

    @Enumerated(EnumType.STRING)
    private QuestionBoardTag tag; // 태그

    @Column(name = "view_count")
    private Integer viewCount; // 조회수
	
    // 뒤는 생략
}

2. study group

@Getter
@Setter
@Table(name = "study_group")
@Entity
public class StudyGroup extends AuditingFields {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "study_group_name")
    private String StudyGroupName;

    @Column(name = "description")
    private String description;
	
    // 생략
}

3. study group board

@Getter
@Setter
@Table(name = "study_group_board")
@Entity
public class StudyGroupBoard extends AuditingFields {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private UserAccount userAccount;

    @ManyToOne
    @JoinColumn(name = "study_group_id")
    private StudyGroup studyGroup;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "content", nullable = false)
    private String content;

    // 생략
}

✔️ 발생한 문제

@Test
void 질문_게시글_삭제_성공한_경우() {
    // Given
    String userId = "userId";
    Long questionBoardId = 1L;

    QuestionBoard questionBoard = QuestionBoardFixture.get(userId);
    UserAccount userAccount = questionBoard.getUserAccount();

    // When
    when(userAccountRepository.findById(userId)).thenReturn(Optional.of(userAccount));
    when(questionBoardRepository.findById(questionBoardId)).thenReturn(Optional.of(questionBoard));

    // Then
    assertDoesNotThrow(() -> questionBoardService.deleteQuestionBoard(userId, questionBoardId));
}
    
// 사용된 fixture
public class QuestionBoardFixture {

    public static QuestionBoard get(String userId) {
        UserAccount userAccount = UserAccount.of(userId, "test", "test", "test", null);

        QuestionBoard questionBoard = QuestionBoard.of(userAccount, "title", "content", QuestionBoardTag.Free, 0);

        return questionBoard;
    }

    public static QuestionBoard get(String userId, String title, String content) {
        UserAccount userAccount = UserAccount.of(userId, "test", "test", "test", null);

        QuestionBoard questionBoard = QuestionBoard.of(userAccount, title, content, QuestionBoardTag.Free, 0);

        return questionBoard;
    }
}

위 테스트는 통과가 잘되나

// study group
@Test
void 스터디_그룹_삭제가_성공한_경우() {
    // Given
    String userId = "userId";
    StudyGroup studyGroupFixture = StudyGroupFixture.get();

    UserStudyGroup userStudyGroupLeaderFixture = UserStudyGroupFixture.getLeader(userId, studyGroupFixture);
    UserAccount userAccount = userStudyGroupLeaderFixture.getUserAccount();

    // When
    when(userAccountRepository.findById(userId))
            .thenReturn(Optional.of(userAccount));
    when(userStudyGroupRepository.findByUserAccount(userAccount))
            .thenReturn(Optional.of(userStudyGroupLeaderFixture));

    // Then
    assertDoesNotThrow(() -> studyGroupService.deleteStudyGroup(userId));
}

// study group fixture
public class StudyGroupFixture {
    public static StudyGroup get() {
        return StudyGroup.of("StudyGroupName", "description");
    }

    public static StudyGroup get(String studyGroupName, String description) {
        return StudyGroup.of(studyGroupName, description);
    }
}

// study group board
@Test
void 스터디_그룹_게시글_삭제가_성공한_경우() {
    // Given
    String userId = "userId";
    Long studyGroupBoardId = 1L;

    StudyGroupBoard studyGroupBoard = StudyGroupBoardFixture
            .get(userId, "title", "content");
    UserAccount userAccount = studyGroupBoard.getUserAccount();

    // When
    when(userAccountRepository.findById(userId))
            .thenReturn(Optional.of(userAccount));
    when(studyGroupBoardRepository.findById(studyGroupBoardId))
            .thenReturn(Optional.of(studyGroupBoard));

    // Then
    assertDoesNotThrow(() -> studyGroupBoardService.deleteStudyGroupBoard(userId, studyGroupBoardId));
}

// study group board fixture
public class StudyGroupBoardFixture {

    public static StudyGroupBoard get(String userId, String title, String content) {
        UserAccount userAccount = UserAccount.of(userId, "test", "test", "test", null);
        StudyGroup studyGroup = StudyGroup.of("test", "test");

        return StudyGroupBoard.of(userAccount, studyGroup, title, content);
    }
}

위의 study group과 study group board는 오류가 생겨서 무엇이 문제인지 찾아 보았습니다.

🏷️ 엔티티 간 연관 관계 및 영속성 관리

✅ 연관 관계

▪️ StudyGroupBoard는 StudyGroup 및 UserAccount와 @ManyToOne 관계를 가집니다.

▪️ UserStudyGroup도 StudyGroup 및 UserAccount와 @ManyToOne 관계를 가집니다.

 

이러한 연관 관계에서는 각 엔티티가 데이터베이스에서 영속화될 때 관련된 엔티티가 함께 영속화되지 않으면 문제가 발생할 수 있습니다. 특히 @ManyToOne 관계에서 연관된 엔티티가 null이거나 ID가 없는 상태로 영속성 컨텍스트에 등록되면 Hibernate는 TransientObjectException을 발생시킵니다.

 

✅ ID 설정 문제

▪️ StudyGroup, StudyGroupBoard, UserStudyGroup의 경우 ID는 @GeneratedValue(strategy = GenerationType.IDENTITY)로 자동 생성됩니다. 따라서 데이터베이스에 저장되기 전까지는 ID 값이 null입니다.

▪️ 그러나, 테스트 중에는 실제로 데이터베이스에 저장하지 않기 때문에, ID가 수동으로 설정되지 않으면 영속성 컨텍스트에서 관리되지 않아 문제가 발생할 수 있습니다.

❗ 왜 일부 테스트에서는 문제가 발생하지 않는가?

  • 단순 연관 관계: QuestionBoard의 경우, 연관된 엔티티가 비교적 단순하여 영속성 관리가 간단합니다. 특히 ID가 필요하지 않은 단순한 관계에서는 Hibernate가 자동으로 연관된 객체를 처리할 수 있습니다.
  • Mocking: 테스트에서 리포지토리 메서드 (findById 등)를 Mocking하여 사용하기 때문에, 실제 데이터베이스 접근 없이도 연관된 객체를 생성하여 테스트를 진행할 수 있습니다. 그러나 이 경우에도 연관된 엔티티의 ID가 필요하거나 영속성 컨텍스트에 있어야 하는 경우에는 수동으로 ID를 설정해주는 것이 필요할 수 있습니다.

✅ 해결 방법

1. ID 수동 설정:

테스트에서 엔티티를 생성할 때, ID 값을 수동으로 설정해줍니다. 예를 들어, StudyGroup을 생성할 때 ID를 수동으로 설정하여 문제를 해결할 수 있습니다.

StudyGroup studyGroup = StudyGroup.of(1L, "StudyGroupName", "description");

 

2. Cascade 옵션 사용

StudyGroupBoard와 UserStudyGroup에서 연관된 엔티티가 영속화될 때 자동으로 연관된 객체도 영속화되도록 CascadeType 옵션을 설정할 수 있습니다. 예를 들어, @ManyToOne 관계에서 CascadeType.PERSIST 또는 CascadeType.ALL을 추가할 수 있습니다.

@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "study_group_id")
private StudyGroup studyGroup;

3. TestEntityManager 사용

Spring Boot에서 TestEntityManager를 사용하여 테스트 중에 엔티티를 명시적으로 영속화할 수 있습니다. 이를 통해 엔티티 간의 관계를 올바르게 설정하고 테스트할 수 있습니다.

@Autowired
private TestEntityManager entityManager;

@Test
void test() {
    StudyGroup studyGroup = StudyGroup.of("StudyGroupName", "description");
    entityManager.persistAndFlush(studyGroup); // 영속화

    StudyGroupBoard studyGroupBoard = StudyGroupBoard.of(null, userAccount, studyGroup, "title", "content");
    entityManager.persistAndFlush(studyGroupBoard); // 영속화
}

🔹요약

StudyGroup, StudyGroupBoard, UserStudyGroup 간의 복잡한 연관 관계에서, ID 값이 없거나 연관된 엔티티가 영속화되지 않았을 때 발생할 수 있는 문제를 방지하기 위해, 테스트 시 ID를 수동으로 설정하거나 Cascade 옵션을 사용하거나 TestEntityManager를 사용하는 것이 중요합니다.

 

따라서, 위와 같은 방법을 통해 테스트에서 발생하는 TransientObjectException과 같은 영속성 관련 예외를 예방할 수 있습니다.