Development/TrobleShooting

테스트코드 내 StackOverflow 발생 원인과 해결 과정

이대코 2025. 4. 25. 17:52

1.  문제 발생

서비스 레이어의 테스트 코드를 작성하고 실행했을 때, 예상치 못한 StackOverflowError가 발생했습니다.

테스트 코드는 다음과 같았습니다.

@ExtendWith(MockitoExtension::class)
class BoardServiceTest {

    @Mock
    private lateinit var boardRepository: BoardRepository

    @Mock
    private lateinit var userRepository: UserRepository

    @Mock
    private lateinit var chatGptService: ChatGptService

    @Mock
    private lateinit var sessionUtil: SessionUtil

    @InjectMocks
    private lateinit var boardService: BoardService

    private lateinit var user: User
    private lateinit var user2: User
    private lateinit var board: Board

    @BeforeEach
    fun setUp() {
        user = User(id = 1L, userId = "testUserId", username = "testUser", password = "password", role = Role.PATIENT)
        user2 = User(id = 2L, userId = "testUserId2", username = "testUser2", password = "password", role = Role.FAMILIY)
        println("user: $user")
        board = Board(
            title = "Test Title",
            content = "Test Content",
            category = BoardCategory.ANTI_CANCER,
            user = user,
            comments = emptyList()
        )
    }

    @Test
    fun `executeCreateBoard - 성공 케이스`() {
        // Given
        val request = BoardCreateRequest("Test Title", "Test Content", BoardCategory.ANTI_CANCER)
        `when`(sessionUtil.getCurrentUserId()).thenReturn(1L)
        `when`(userRepository.findByIdOrNull(1L)).thenReturn(user)
        `when`(boardRepository.save(any(Board::class.java))).thenReturn(board)

        // When
        val result = boardService.executeCreateBoard(request)

        // Then
        println(result)
        assertTrue(result.isSuccess)
        val response = result.getOrNull()
        assertEquals("Test Title", response?.title)
        assertEquals("Test Content", response?.content)
        assertEquals(BoardCategory.ANTI_CANCER, response?.category)

    }

실행 결과, 테스트 실행 중 스택오버플로우가 발생했고, 원인은 Board 엔티티 내부에서의 순환참조였습니다.

 

2. 원인 분석

문제가 되었던 Board.kt는 아래와 같습니다.

package com.example.cancerbreaker.board.entity

import com.example.cancerbreaker.board.dto.request.BoardEditRequest
import com.example.cancerbreaker.comment.entity.Comment
import com.example.cancerbreaker.global.entity.BaseEntity
import com.example.cancerbreaker.member.entity.User
import com.fasterxml.jackson.annotation.JsonCreator
import jakarta.persistence.*

@Entity
@Table(
    name = "board",
    indexes = [Index(name = "idx_fts", columnList = "title, content", unique = false)]
)
class Board private constructor(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(nullable = false)
    var title: String,

    @Column(columnDefinition = "TEXT", nullable = false)
    var content: String,

    @Column(nullable = false)
    var category: BoardCategory,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    var user: User,

    @OneToMany(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    var comments: List<Comment> = emptyList(),
) : BaseEntity() {
    init {
        check (title.isNotBlank()) {throw IllegalStateException("제목은 빈 값일 수 없습니다.") }
        check (content.isNotBlank()) {throw IllegalStateException("내용은 빈 값일 수 없습니다.")}
    }

    fun updateBoard(boardEditRequest: BoardEditRequest) {
        require(boardEditRequest.title.isNotBlank()) { "제목은 빈 값일 수 없습니다." }
        require(boardEditRequest.content.isNotBlank()) { "내용은 빈 값일 수 없습니다." }
        this.title = boardEditRequest.title
        this.content = boardEditRequest.content
        this.category = boardEditRequest.category
    }
    companion object{
        @JsonCreator
        fun from(
            title: String,
            content: String,
            category: BoardCategory,
            user: User,
            comments: List<Comment>
	        ): Board = Board(title, content, category, user, comments)
        operator fun invoke(
            title: String,
            content: String,
            category: BoardCategory,
            user: User,
            comments: List<Comment>
        ) : Board = from(title, content, category, user,comments)
    }

}

더 자세히 들어가면, 위 Board 엔티티에서 에러가 발생한 부분은 invoke 함수와 from 함수 부분입니다.

companion object{
        @JsonCreator
        fun from(
            title: String,
            content: String,
            category: BoardCategory,
            user: User,
            comments: List<Comment>
	        ): Board = Board(title, content, category, user, comments)

        operator fun invoke(
            title: String,
            content: String,
            category: BoardCategory,
            user: User,
            comments: List<Comment>
        ) : Board = from(title, content, category, user,comments)
    }

invoke가 from을 호출하고, from은 다시 Board(...)를 호출하는 구조입니다.

표면적으로는 문제가 없어 보이지만, 이 과정에서 Board → comments → Comment.board → Board → ...와 같은 순환이 발생하게 됩니다.

❓WHY❓

  • Board는 Comment 리스트를 가지고 있고,
  • Comment는 다시 Board를 참조하는 양방향 매핑 관계이기 때문입니다.
// Board
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
var comments: List<Comment> = emptyList()

// Comment
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
var board: Board

양방향 매핑이 되어 있기 때문에, Jackson은 이 관계를 따라가면서 무한 루프에 빠지게 됩니다.

Board 엔티티가 @ManyToOne으로 User 엔티티도 참조하는데, User 쪽 문제일수도 있지 않나?

@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    var user: User,

일단 Board 엔티티는 User 엔티티를 @ManyToOne으로 참조하고 있는 것은 사실입니다.

그러나 이 둘은 순환참조를 일으키지 않습니다.

왜나하면 순환 참조가 발생하려면 두 엔티티가 양방향 매핑이어야하는데, User는 Board를 따로 참조하고 있지 않기 때문입니다.

package com.example.cancerbreaker.member.entity

import com.example.cancerbreaker.global.entity.BaseEntity
import com.fasterxml.jackson.annotation.JsonCreator
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id

@Entity
class User private constructor(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    var userId: String = "",
    var username: String = "",
    var password: String = "",
    var role: Role = Role.PATIENT
): BaseEntity() {
    protected constructor() : this(null, "", "", "", Role.PATIENT) {}

    companion object{
        @JsonCreator
        fun from(
            id : Long?,
            userId: String,
            username: String,
            password: String,
            role: Role
        ): User {
            return User(id = id,userId = userId, username = username, password = password, role = role)
        }


        operator fun invoke(
            id: Long?,
            userId: String,
            username: String,
            password: String,
            role: Role
        ): User = from(id,userId, username, password, role)
    }
}

반대로, Comment 엔티티와는 양방향 참조를 이루고 있으므로 순환참조가 발생할 것임을 알 수 있습니다.

문제가 발생한 Board 엔티티 코드를 보면, from 메서드에서 Board(title, content, category, user, comments)를 호출한 상태입니다.

이는 Board 생성자를 직접 호출한다는 의미이며, 당연히 comments 파라미터 또한 전달됩니다.

이 comments 파라미터에 포함된 comment 객체들은 다시 각각 매핑된 Board 객체를 참조합니다.

왜냐하면 실제로 Comment 엔티티는 @manyToOne으로 Board 엔티티를 참조하고 있으며, Board 엔티티는 @OneToMany로 Comment 엔티티를 참조하고 있기 때문입니다. (JPA 양방향 매핑)

 

즉, BoardcommentsComment.boardBoard → ... 와 같은 간접적인 순환 참조가 발생하면서 객체 생성 과정에서 무한 재귀가 발생합니다다.

Board 객체 생성 시 Comment와의 관계로 인해 발생하는 문제였던 것입니다.

 

3. 해결방안

문제로 돌아와서, 결국 문제가 발생했던 부분은 아래와 같습니다.

companion object{
        @JsonCreator
        fun from(
            title: String,
            content: String,
            category: BoardCategory,
            user: User,
            comments: List<Comment>
          ): Board = Board(title, content, category, user, comments)<- 문제가 발생한 부분
        operator fun invoke(
            title: String,
            content: String,
            category: BoardCategory,
            user: User,
            comments: List<Comment>
        ) : Board = from(title, content, category, user,comments)
    }

✅ 1) 생성자 호출 시 명시적 파라미터 지정

이 순환참조 문제를 해결하는 방법은 생각보다 간단한데, 문제가 되었던 from 메서드 내부를 명시적 파라미터로 수정하면 됩니다.

명시적으로 Board(title = title, content = content, category = category, user = user, comments = comments)를 호출함으로써, Board 생성자가 직접 호출됩니다.

이는 Board 생성자가 from이나 invoke를 암시적으로 호출하지 않도록 보장한다는 의미입니다. 명시적 매개변수 지정은 Jackson이 생성자를 정확히 호출하도록 돕고, 재귀 호출을 방지하는 역할을 합니다.

companion object {
    @JsonCreator
    fun from(
        title: String,
        content: String,
        category: BoardCategory,
        user: User,
        comments: List<Comment>
    ): Board = Board(title = title, content = content, category = category, user = user, comments = comments)

    operator fun invoke(
        title: String,
        content: String,
        category: BoardCategory,
        user: User,
        comments: List<Comment>
    ): Board = from(title, content, category, user, comments)
}

✅ 2) DTO로 Entity 직렬화 분리

사실 대부분JPA에서 발생하는 순환참조 문제는 엔티티를 바로 적용해 문제가 발생하기 때문에 엔티티 대신 DTO를 활용하면 해결됩니다.

그리고 엔티티를 직접 직렬화하는 대신 DTO로 변환해서 사용하는 것이 일반적인 권장 방식입니다. BoardDTO와 같은 DTO를 만들어 View나 API 응답용으로 사용하면 순환참조 문제가 발생하지 않습니다.

 

✅ 3) Jackson 어노테이션 활용

양방향 관계를 유지하되 직렬화 중 순환참조를 방지하려면 아래 어노테이션을 사용합니다.

Board 엔티티의 comments 필드에 @JsonManagedReference를 추가
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
@JsonManagedReference
var comments: List<Comment> = emptyList(),
Comment 엔티티의 Board 참조에 @JsonBackReference를 추가
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
@JsonBackReference
var board: Board,
이렇게 하면 Jackson이 Board → Comment → Board 관계를 탐색하는 과정에서 한 방향만 직렬화하게 되어 StackOverflow를 방지할 수 있습니다.

 

4. 조금 더 깊이 들어가 보기

❓ "comments = emptyList()"인데 왜 순환 참조?

사실 이상한 부분이 있습니다. 테스트 코드에서는 comments를 emptyList()로 주었기 때문에, Comment 객체는 하나도 없습니다.

그럼에도 왜 문제가 생겼을까요?

 

이는 Jackson이 직렬화 시 실제 데이터가 아닌 클래스 구조 자체를 기준으로 탐색하기 때문입니다.

즉, comments가 비어 있더라도, Comment 클래스 내부에 Board가 있다는 사실만으로 Jackson은 이를 순환 구조로 인식합니다.

결국 구조적으로만 보더라도 Board → Comment → Board가 연결되어 있기 때문에, StackOverflow가 발생한 것입니다.

@ExtendWith(MockitoExtension::class)
class BoardServiceTest {

    @Mock
    private lateinit var boardRepository: BoardRepository

    @Mock
    private lateinit var userRepository: UserRepository

    @Mock
    private lateinit var chatGptService: ChatGptService

    @Mock
    private lateinit var sessionUtil: SessionUtil

    @InjectMocks
    private lateinit var boardService: BoardService

    private lateinit var user: User
    private lateinit var user2: User
    private lateinit var board: Board

    @BeforeEach
    fun setUp() {
        user = User(id = 1L, userId = "testUserId", username = "testUser", password = "password", role = Role.PATIENT)
        user2 = User(id = 2L, userId = "testUserId2", username = "testUser2", password = "password", role = Role.FAMILIY)
        println("user: $user")
        board = Board(
            title = "Test Title",
            content = "Test Content",
            category = BoardCategory.ANTI_CANCER,
            user = user,
            comments = emptyList() <-----------이 부분!!
        )
    }

 

또한 @JsonCreator를 자세히 볼 필요가 있습니다.

Jackson은 JSON 데이터를 Java/Kotlin 객체로 변환(역직렬화)할 때, 기본 생성자나 @JsonCreator로 지정된 메서드를 사용합니다.

Board 엔티티를 보면, @JsonCreator가 붙은 from 메서드는 JSON 데이터를 받아 Board 객체를 생성하도록 Jackson에 지시합니다. from 메서드는 Board 생성자를 호출하여 객체를 만듭니다.

companion object {
    @JsonCreator <--- 얘
    fun from(
        title: String,
        content: String,
        category: BoardCategory,
        user: User,
        comments: List<Comment>
    ): Board = Board(title = title, content = content, category = category, user = user, comments = comments)

    operator fun invoke(
        title: String,
        content: String,
        category: BoardCategory,
        user: User,
        comments: List<Comment>
    ): Board = from(title, content, category, user, comments)
}

Board 엔티티는 @OneToManycomments: List<Comment>를 가지며, Comment 엔티티는 @ManyToOne으로 Board를 참조하는 양방향 관계입니다.

Jackson이 Board 객체를 직렬화할 때, comments 필드를 탐색합니다. 비록 commentsemptyList()로 초기화되어 실제 데이터가 없더라도, Jackson은 클래스 구조 자체를 분석하여 양방향 관계를 탐색합니다. 즉 Jackson은 Comment 엔티티의 구조를 분석하며 Comment 내의 Board 참조(양방향 관계)를 확인하고 무한 루프가 시작되는 것입니다.

그러므로, Comment 객체가 직렬화될 때 Board를 참조하고, 이 Board는 다시 comments를 참조하는 식으로 무한 루프가 발생할 가능성이 있는 것입니다.

5. 마무리

이번 문제는 테스트 중 흔히 만날 수 있는 순환참조 이슈였습니다.

특히, JPA의 양방향 관계와 Jackson 직렬화가 동시에 얽힐 경우 주의가 필요합니다.

🔑 핵심 요약

  • 양방향 관계에서는 명시적 파라미터, DTO 또는 어노테이션으로 순환을 제어하자.
  • Jackson 역직렬화 시 @JsonCreator + 명시적 파라미터는 필수!(또는 DTO 활용)
  • 테스트에서도 구조적 순환이 문제가 될 수 있다.