문제 발생 상황
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번 트러블 슈팅에서 이어질 예정