Blog

[TroubleShooting] 가게에 리뷰목록 불러오기 1 - JSON 직렬화 중 무한루프 재귀, StackOverFlow 발생 문제해결

작성날짜
2023/09/21
작성자
최종 편집 일시
2023/11/15 00:53
문제 발생 상황
http://localhost:8080/stores/2/reviews 로 Document.ready로 DOM페이지 로드 완료 시 리뷰 목록을 불러오는 스크립트가 자동으로 실행된다.
해당 페이지에 접근하면 리뷰 목록 불러오기가 바로 실행되는데, 아래 사진과 같이 목록 불러오기 실패로 나타나고있다.
서버에서는 Hibernate를 통해 정상적으로 select문을 통해 store_id를 기반으로 모든 정보가 조회되고있고,맨 아랫부분에서 store_id를 통해 조인된 review의 컬럼들이 조회되는 것을 확인 할 수 있다. 특별한 오류 메시지는 없다. 프론트엔드에서 데이터를 받아오는 문제가 발생한것
2023-09-21T10:24:50.147+09:00 INFO 90966 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' 2023-09-21T10:24:50.155+09:00 INFO 90966 --- [ main] c.s.s.SpringCafeserviceApplication : Started SpringCafeserviceApplication in 3.918 seconds (process running for 4.357) 2023-09-21T10:24:54.451+09:00 INFO 90966 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet' 2023-09-21T10:24:54.452+09:00 INFO 90966 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' 2023-09-21T10:24:54.453+09:00 INFO 90966 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms Hibernate: /* <criteria> */ select s1_0.id, s1_0.created_at, s1_0.information, s1_0.modified_at, s1_0.store_address, s1_0.store_name, s1_0.user_id from stores s1_0 Hibernate: select u1_0.id, u1_0.email, u1_0.password, u1_0.point, u1_0.regist_num, u1_0.role, s1_0.id, s1_0.created_at, s1_0.information, s1_0.modified_at, s1_0.store_address, s1_0.store_name, s1_0.user_id, m1_0.store_id, m1_0.id, m1_0.image, m1_0.menu_name, m1_0.price, u1_0.username from users u1_0 left join stores s1_0 on u1_0.id=s1_0.user_id left join menus m1_0 on s1_0.id=m1_0.store_id where u1_0.id=? Hibernate: select u1_0.id, u1_0.email, u1_0.password, u1_0.point, u1_0.regist_num, u1_0.role, s1_0.id, s1_0.created_at, s1_0.information, s1_0.modified_at, s1_0.store_address, s1_0.store_name, s1_0.user_id, m1_0.store_id, m1_0.id, m1_0.image, m1_0.menu_name, m1_0.price, u1_0.username from users u1_0 left join stores s1_0 on u1_0.id=s1_0.user_id left join menus m1_0 on s1_0.id=m1_0.store_id where u1_0.id=? Hibernate: select u1_0.id, u1_0.email, u1_0.password, u1_0.point, u1_0.regist_num, u1_0.role, s1_0.id, s1_0.created_at, s1_0.information, s1_0.modified_at, s1_0.store_address, s1_0.store_name, s1_0.user_id, m1_0.store_id, m1_0.id, m1_0.image, m1_0.menu_name, m1_0.price, u1_0.username from users u1_0 left join stores s1_0 on u1_0.id=s1_0.user_id left join menus m1_0 on s1_0.id=m1_0.store_id where u1_0.id=? Hibernate: select r1_0.store_id, r1_0.id, r1_0.created_at, r1_0.modified_at, r1_0.review, r1_0.star, r1_0.user_id from reviews r1_0 where r1_0.store_id=? Hibernate: select r1_0.store_id, r1_0.id, r1_0.created_at, r1_0.modified_at, r1_0.review, r1_0.star, r1_0.user_id from reviews r1_0 where r1_0.store_id=? Hibernate: select r1_0.store_id, r1_0.id, r1_0.created_at, r1_0.modified_at, r1_0.review, r1_0.star, r1_0.user_id from reviews r1_0 where r1_0.store_id=? Hibernate: select r1_0.store_id, r1_0.id, r1_0.created_at, r1_0.modified_at, r1_0.review, r1_0.star, r1_0.user_id from reviews r1_0 where r1_0.store_id=? Hibernate: select r1_0.store_id, r1_0.id, r1_0.created_at, r1_0.modified_at, r1_0.review, r1_0.star, r1_0.user_id from reviews r1_0 where r1_0.store_id=?
Java
복사
해당 프론트엔드 및 JavaScript 함수는 다음과 같다.
<!-------------------------------------------------- REVIEW READ REQUEST --------------------------------------------------------> <script> function getReviews() { const storeId = $('#dynamicStoreId').val(); $.ajax({ type: "GET", url: `/stores/${storeId}/reviews`, // 수정된 URL dataType: "json" }) .done(function (reviews) { // 리뷰 목록을 성공적으로 가져온 경우 displayReviews(reviews); }) .fail(function (error) { alert('리뷰 목록을 가져오는데 실패했습니다.'); }); } // 리뷰 목록을 화면에 표시 function displayReviews(reviews) { const reviewContainer = $('.review-list'); // 리뷰 목록을 나타낼 컨테이너 reviewContainer.empty(); // 기존 리뷰를 지우고 새로운 리뷰 목록으로 대체 // 리뷰 목록을 반복해서 화면에 추가 reviews.forEach(function (review) { const reviewDiv = $('<div class="review">'); const username = $('<strong>').text(review.user.username); const reviewText = $('<p>').text(review.review); reviewDiv.append(username); reviewDiv.append(reviewText); reviewContainer.append(reviewDiv); }); } // 페이지 로딩 시 리뷰 목록 가져오기 $(document).ready(function () { getReviews(); }); </script> <script> document.addEventListener("DOMContentLoaded", function() { // 현재 페이지 URL에서 가게 id 가져오기 const url = window.location.href; const parts = url.split("/"); const storeId = parts[parts.length - 2]; // 가게 id는 URL에서 두 번째로 뒤에 위치함 // hidden 필드에 가게 아이디 설정 document.getElementById("dynamicStoreId").value = storeId; }); </script> <!-- 리뷰 남기기 본문 --> <div class="container"> <div class="row" style="margin-top:40px;"> <div class="col-md-8 col-md-offset-2"> <div class="well well-sm"> <!-- 숨겨진 필드 --> <input type="text" class="form-control" id="dynamicStoreId" name="dynamicStoreId" required disabled> <!-- 리뷰 남기기 Form --> <form accept-charset="UTF-8" action="" method="post"> <input id="ratings-hidden" name="rating" type="hidden"> <textarea class="form-control animated" cols="50" id="review" name="review" placeholder="리뷰를 이곳에 작성하세요..." rows="5"></textarea> <div class="text-right"> <div class="stars starrr" data-rating="0"></div> <a class="btn btn-danger btn-sm" href="#" id="close-review-box" style="display:none; margin-right: 10px;"> <span class="glyphicon glyphicon-remove"></span>취소</a> <button class="btn btn-success btn-lg" type="button" onclick="submitReview()">리뷰 남기기!</button> </div> </form> </div> <!-- 리뷰 리스트를 나타내는 부분 --> <div class="well well-sm"> <h3>리뷰 리스트</h3> <!-- 리뷰 목록을 나타낼 컨테이너 --> <div class="review-list"> <!-- 아래의 코드를 추가하여 실제 리뷰를 표시합니다. --> </div> </div> </div> </div> </div>
Java
복사
기본적으로 dynamicStoreId라는 숨겨진 인풋박스를 통해서 url경로의 “/”를 기준으로 storeId를 동적으로 인풋박스 밸류에 할당하는 방법을 사용하고있다. 그럼 해당 가게의 상세 페이지에서 해당 가게의 리뷰를 작성하면 그 리뷰는 해당 가게의 소속이 되도록 한것이다.
현재 문제가되고있는 부분은 aJax 통신부분으로 리뷰 목록을
url: `/stores/${storeId}/reviews`라는 엔드포인트에 요청보내고있으며
이에 반응하는 서버의 StoreController는 다음과 같다.
@RestController @CrossOrigin(origins = "http://localhost:8080") // 로컬 웹 애플리케이션의 도메인을 지정 @RequestMapping("/api") @RequiredArgsConstructor public class StoreController { private final StoreService storeService; private final ReviewService reviewService; // JSON 데이터를 반환하는 메소드 @GetMapping("/stores/{id}/reviews") public ResponseEntity<List<Review>> getReviewsByStoreId(@PathVariable Long id) { List<Review> reviews = reviewService.getReviewsByStoreId(id); return new ResponseEntity<>(reviews, HttpStatus.OK); } ... }
Java
복사
뷰페이지를 반환하는 HomeController는 다음과 같다.
@Controller public class HomeController { ... // 뷰 페이지를 렌더링하는 메소드 @GetMapping("/stores/{storeId}/reviews/form") public String showReviewForm(@PathVariable Long storeId, Model model) { Review review = new Review(); model.addAttribute("review", review); return "add-reviews"; } ... }
Java
복사
무한 루프 발생
현재 요청에 실패하면서 뷰페이지 요청 URL을 /stores/{storeId}/reviews/form로 변경한 상태인데, 무한루프가 발생하면서 요청에 실패하고있다.
2023-09-21T11:06:52.459+09:00 ERROR 94040 --- [nio-8080-exec-4] 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 java.lang.StackOverflowError: null at java.base/java.math.BigDecimal.valueOf(BigDecimal.java:1291) ~[na:na] at java.base/java.math.BigDecimal.add(BigDecimal.java:4994) ~[na:na] at java.base/java.math.BigDecimal.add(BigDecimal.java:5001) ~[na:na] at java.base/java.math.BigDecimal.subtract(BigDecimal.java:1528) ~[na:na] at java.base/java.time.format.DateTimeFormatterBuilder$FractionPrinterParser.convertToFraction(DateTimeFormatterBuilder.java:3295) ~[na:na] at java.base/java.time.format.DateTimeFormatterBuilder$FractionPrinterParser.format(DateTimeFormatterBuilder.java:3212) ~[na:na] at java.base/java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.format(DateTimeFormatterBuilder.java:2402) ~[na:na] at java.base/java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.format(DateTimeFormatterBuilder.java:2402) ~[na:na] at java.base/java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.format(DateTimeFormatterBuilder.java:2402) ~[na:na] at java.base/java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.format(DateTimeFormatterBuilder.java:2402) ~[na:na] at java.base/java.time.format.DateTimeFormatter.formatTo(DateTimeFormatter.java:1849) ~[na:na] at java.base/java.time.format.DateTimeFormatter.format(DateTimeFormatter.java:1823) ~[na:na] at java.base/java.time.LocalDateTime.format(LocalDateTime.java:1746) ~[na:na] at com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer.serialize(LocalDateTimeSerializer.java:78) ~[jackson-datatype-jsr310-2.15.2.jar:2.15.2] at com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer.serialize(LocalDateTimeSerializer.java:37) ~[jackson-datatype-jsr310-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.2.jar:2.15.2] at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.2.jar:2.15.2]
Java
복사
무한 재귀로 인해 StackOverflowError가 발생하고 있는 것이며
스택 오버플로우 오류는 주로 객체 간에 순환 참조가 있을 때 발생하는데, 여기서는 JSON 직렬화 과정에서 발생한다.
주로 이런 문제는 엔티티 클래스 간의 양방향 참조 또는 연쇄적으로 참조되는 경우에 발생한다. 이런 경우, Jackson (JSON 직렬화/역직렬화를 담당하는 라이브러리)가 객체를 JSON으로 변환하려고 할 때 끝없는 참조 루프로 인해 계속해서 객체를 호출하게 되어 스택 오버플로우가 발생하는것이다.
한 재귀를 방지하려면 해당 엔티티 클래스의 일부 필드나 메서드에 @JsonIgnore 어노테이션을 사용하여 JSON 직렬화에서 해당 필드를 무시하도록 지정해야 한다.
현재 reviews와 stores 테이블은 N:1 연관관계이며 양방향이다.
Review엔티티의 모습이다.
@Entity @Getter @NoArgsConstructor @Table(name = "reviews") public class Review extends TimeStamped { ... @JsonIgnore @JoinColumn(name = "store_id") @ManyToOne(fetch = FetchType.LAZY) private Store store; ... }
Java
복사
Store엔티티의 모습이다.
@Entity @Getter @Table(name = "stores") @NoArgsConstructor public class Store extends TimeStamped{ ... @JsonIgnore //<- 추가 @OneToMany(mappedBy = "store", orphanRemoval = true, fetch = FetchType.EAGER) private List<Review> reviewList = new ArrayList<>(); ... }
Java
복사
위 Store엔티티에서 양방향 참조되고있는 reviewList에 @JsonIgnore 어노테이션을 사용하여 무한루프는 막을 수 있었다.
아직 리뷰 목록 불러오기 부분이 해결되지 않았다 2번 트러블 슈팅에서 이어질 예정