✏️문제
두둥 ..
인스타그램처럼 내 피드에 들어가면 내가 쓴 글만 모아서 볼 수 있게 하는 '내가 쓴 글 보기' API를 만들다가 스택오버플로우 에러가 났다.
처음엔 빨간 ERROR 저 글자 보자마자 어디서 잘못됐지 ..? 착잡하다가,, StackOverflowError 저 글자를 보고 갑자기 오 !!! ㅋㅋㅋㅋ말로만 듣던 스택오버플로우다 !!! 실제로 본 건 처음이라 반가움 + 좋음 콤보에 인증샷 부터 박았다.
이번 기회에 처음 접해본 스택오버플로우 에러를 디버깅해보자.
일단 제일 처음에 알아본건 그래서 스택오버플로우가 정확히 뭐랬더라?
StackOverflow 란?
지정한 스택 메모리 사이즈보다 더 많은 스택 메모리를 사용하게 되어 에러가 발생하는 상황을 일컫는다. 스택오버플로우가 발생하는 대표적인 사례가 재귀함수라고 한다.
✏️분석
일단 필자의 코드다.
[Controller]
1
2
3
4
5
|
// 내가 쓴 게시글 조회
@GetMapping("/user-info/boards")
public ResponseEntity<List<BoardResponseDto>> getBoardsByUserId(@AuthenticationPrincipal UserDetailsImpl userDetails) {
return ResponseUtil.response(userInfoService.getBoardsByUserId(userDetails.getUser()));
}
|
cs |
[Service]
1
2
3
4
5
6
7
8
9
10
11
12
|
// 내가 쓴 게시글 조회
public List<BoardResponseDto> getBoardsByUserId(User user) {
User user1 = userQuery.findUserById(user.getId());
// 내가 쓴 글 작성된 순 대로 내림차순으로 가져옴
List<Board> boards = boardRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
// Dto로 변환하여 반환
return boards.stream()
.map(BoardResponseDto::createBoardDto)
.collect(Collectors.toList());
}
|
cs |
[Repository]
1
2
|
// 내가 쓴 글 보기
List<Board> findByUserIdOrderByCreatedAtDesc(Long userid);
|
cs |
무한 스크롤로 구현을 하고 싶었어서 페이징 처리는 하지 않았고, 정렬은 레포지토리 레이어에서 쿼리 메서드로 정렬 처리를 한 로직이다.
스택오버플로우는 함수 호출이 너무 많이 중첩되서 호출 스택의 한계를 초과할 때 발생한다고 했는데, 그럼 위 코드에서 왜 그 에러가 발생했을까? 생각해보다 에러 해결의 답은 에러 메세지라는 진리와 같은 말이 생각나서 메세지를 다시 자세히 뜯어봤다.
2024-04-01T02:40:49.389+09:00 ERROR 10560 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError)] with root cause
...
"Could not write JSON: Infinite recursion (StackOverflowError)" 이 부분을 찾아보니 JSON을 작성하는 과정에서 무한 재귀 호출로 인해 스택오버플로우가 발생했음을 나타내는 메세지라고 한다.
이러한 문제는 주로 양방향 관계를 가진 객체를 JSON으로 변환하려고 할 때 발생하는데, Jackson 라이브러리가 객체 간의 관계를 순환적으로 탐색하다가 끝없는 루프에 빠지기 때문이라고 했다.
내가 짠 코드에서 양방향 관계로 설계한 User 엔티티와 Board 엔티티에서 User에 Board가 있으니까 Board를 참조하고, Board에 있는 User를 또 참조하면서 무한 재귀가 발생하고 있다고 판단했다.
✏️해결 시도
내가 생각한 해결 방법은 세 가지가 있었다.
- 양방향 → 단방향 관계로 바꾸기
- 지연로딩 전략 사용하기
- (찾아낸 방법) @JsonIdentityInfo 사용하기 👉 성공
- (3번에서 개선한 방법) @JsonIgnore 사용하기
1번 방법
일단 1번 방법을 적용하면 양방향 참조도 없어지고 앞으로의 다른 순환 참조 에러도 없앨 확실한 방법이라고 생각했지만, 코드를 수정하는 데 공수가 많이 들 것 같고 다른 api들에게도 영향을 줄 수 있을 것 같아 패스했다. (내가 결합도가 높은 코드를 짰구나 실감했던 순간 ,,)
2번 방법
그 다음으로 엔티티에 Fetch.Lazy를 적용해보기로 했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "board")
public class Board extends Timestamped {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false) // User 와 연관 관계 설정 (외래키 설정)
private User user; // User 객체 전체
...
|
cs |
결과는 실패였다. User 객체에 지연 로딩 전략을 설정해주어도 계속 스택오버플로우 에러가 났다.
생각해보니 당연했다. 유저 엔티티에서 Fetch.Lazy를 적용해도, 엔티티를 JSON으로 변경하는 중 serialize(직렬화) 과정을 거칠 때 어차피 연관관계가 있는 엔티티를 참조하게 되기 때문에 무한 재귀는 발생하게 되는 거였다.
✏️해결 .. 200 이긴 한데 해결이 아닌 것 같아요.
3번 방법
더 찾아보니 @JsonIdentityInfo 라는 애너테이션을 발견했다.
@JsonIdentityInfo란?
엔티티의 식별자를 이용해서 엔티티를 참조하도록 하는 애너테이션이다. 이를 통해 Jackson은 엔티티를 유일한 식별자로 처리해서 순환 참조 문제를 해결한다.
해당 애너테이션을 문제가 되는 엔티티에 추가해주면, Jackson은 각 객체를 식별자(id)를 기반으로 구분하고, JSON 직렬화 시 순환 참조 문제를 방지할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
|
@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class User extends Timestamped{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
|
cs |
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "board")
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class Board extends Timestamped {
...
@JoinColumn(name = "user_id", nullable = false) // User 와 연관 관계 설정 (외래키 설정)
private User user;
...
|
cs |
유저 엔티티와 보드 엔티티에 @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") 를 추가해 주었다.
결과는 200 OK! 였다. 스택오버플로우는 발생하지 않았다.
하지만 '해결' 이라고 보기는 어려웠다. 반환 데이터를 보니, 이미지가 있는 글과 없는 글의 차이를 볼 수 있었다.
이미지가 없는 id 15번 글의 경우와 이미지가 있는 id 14번 글을 비교해보면, 14번의 imageList 컬럼에서 Board 객체를 또 다시 가져오고 Board 객체에서 다시 User 객체를 가져오는 걸 확인했다 ... AWS S3 연동한 멀티미디어 기능을 맨 나중에 구현해서 전에 테스트 할 땐 이런 현상을 몰랐는데 구현한 후 바로 테스트를 하니 이런 상황으로 데이터를 뱉어냄을 안 것이다.
또한 한 가지 더 걸렸던 점은 해당 API가 매우 중요한 비즈니스 로직을 갖고 있는 API가 아니라고 판단했고, 이 API를 돌아가게 하기 위해 유저 엔티티와 보드 엔티티에 이 애너테이션을 달아 주자니 이걸로 JSON 형태가 변형됨으로써 클라이언트 측 코드나 앞으로 구현될 코드들에 영향을 주지 않을까 하는 점들이 걱정됐다.
어쨌든 각설하고, '원래는 User 객체와 Board 객체의 상호 참조가 문제라고 생각했는데. 이게 직접적인 원인이 아니지 않을까?' 라는 생각이 들었다.
그래서 달아줬던 @JsonIdentityInfo 애너테이션을 전체 주석 처리하고 작성한 글 중 이미지를 포함하지 않는 유저 토큰값으로 다시 테스트 해봤다.
내 예상대로 였다. 이미지를 포함한 글을 쓴 적이 없는 유저의 토큰 값으로 다시 테스트 하니, 스택오버플로우 에러는 나지 않았고 잘 돌아갔다.
✏️찐 해결
따라서, 처음에는 생각하지 못했던 멀티미디어 엔티티에서 나는 순환 참조 문제라고 확신할 수 있었다.
멀티미디어 엔티티를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 기능 : S3에 저장한 이미지 정보 저장 Entity
@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "multimedia")
public class Multimedia {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
...
|
cs |
보드 객체를 참조하고 있다.
그러므로 이 스택오버플로우 문제의 원인은 멀티미디어 엔티티가 보드 엔티티를 참조하고, 보드 엔티티는 유저 엔티티와 멀티미디어 엔티티를 참조하고, 멀티미디어는 다시 보드 엔티티를 참조하는 구조를 가지고 있었기 때문이라고 할 수 있겠다.
이 순환 참조를 어떻게 제거해야할까 고민하면서 @JsonIgnore 방법을 발견했다.
@JsonIgnore란?
반환되는 JSON 데이터에서 불필요한 중첩 정보를 줄이고, User 객체와 같은 특정 정보를 제외하고 싶을 때 쓸 수 있는 애너테이션이다. 필요하지 않은 필드에 이 애너테이션을 추가해서 해당 필드가 JSON 직렬화 과정에서 제외되도록 할 수 있다.
예를 들어, 사용자의 비밀번호를 제외하고 싶다면 해당 필드에 @JsonIgnore를 추가해 주면 된다.
public class User {
// 다른 필드들...
@JsonIgnore
private String password;
// 나머지 코드...
}
이제 내 코드에 적용해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 기능 : S3에 저장한 이미지 정보 저장 Entity
@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "multimedia")
public class Multimedia {
...
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
@JoinColumn(name = "board_id")
private Board board;
...
|
cs |
멀티미디어 엔티티에서 참조하는 보드 객체를 @JsonIgnore 처리 해 준 후 다시 포스트맨으로 테스트를 진행했다.
결과는 200 OK ! 스택오버플로우 에러도 나지 않았고 이미지 리스트 컬럼에서도 깔끔하게 필요한 정보(이미지 id, url)만 가져 오는 것을 볼 수 있다. 전과 비교하면 많이 깔끔해졌다.
고려할 점
아직 걱정되는 부분이 있긴 하다. @JsonIgnore를 사용해서 필드를 직렬화 과정에서 제외하면, 클라이언트 측에서 해당 데이터를 받을 수 없게 된다. 보안이 중요한 정보를 제외할 경우엔 유용하겠지만 클라이언트가 필요로 하는 정보일 경우에는 난감한 경우가 생길거다. 데이터를 실수로 누락하지 않도록 이 애너테이션을 쓸 때는 클라이언트 측과 면밀한 소통이 필요할 것 같다.
또한 @JsonIgnore는 해당 필드에 대해 전역적으로 사용되기 때문에 .. 특정 상황에서만 필드를 제외하고 싶을 때 문제가 될 수 있을 것 같다. (찾아보니 이 경우에는 @JsonView나 @JsonFilter를 사용해서 더 세밀하게 제어할 수 있다고 한다)
✏️평가 및 회고
전에 양방향 관계를 지양하고 단방향 관계를 많이 쓴다고 들었는데, 안쓰는 이유가 이거구나 싶었다. 왜 엔티티 간 관계가 양방향 관계가 당연하다고 생각했을까?
다음 프로젝트를 할 땐 @OneToMany 설정 대신 @ManyToOne을 쓰고데이터들은 쿼리 메서드 등을 통해서 가져오는 방법을 써서 단방향으로만 설계 해 봐야겠다.
Reference
https://wanggonya.tistory.com/46
'Project > Newsfeed' 카테고리의 다른 글
[성능 테스트][트러블슈팅] Artillery로 부하 테스트 하기(3/3), 성능 개선을 해보자 (0) | 2024.04.15 |
---|---|
[성능 테스트][트러블 슈팅] Artillery로 부하 테스트 하기(2/3), 성능 저하 원인을 찾아보자 (1) | 2024.04.13 |
[프로젝트] AOP로 'API 수행 시간/회원 별 총 API 사용시간 누적 저장' 기능 다르게 구현하기와 그에 따른 고민 (0) | 2024.04.10 |
[프로젝트] QueryDSL 사용 시 Q클래스 import 불가 문제 해결 (gradle) (0) | 2024.03.02 |
[성능 테스트] Artillery로 부하 테스트 하기(1/3), Artillery 설치 (0) | 2024.02.27 |