이 이야기도 travelzip을 리팩토링 하면서 만났던 트러블 슈팅 이야기이다.
게시글과 서브 게시글의 매핑관계를 단방향에서 양방향으로 변경했을때 이 문제를 만나게되었는데, 결국 알고보니 그 유명한 toString 순환참조의 문제였다.
우리는 큰 틀의 게시글이 있고, 그 안에 1일차, 2일차 등등 날짜에 맞게 서브 게시물을 가지고 있는 구조로 엔티티를 가지고 있다.
설계를 하고 난 후, 개발을 하면서도 계속해서 양방향 관계가 필요하고, 더욱 편리할 것 같다고 느껴져 우선적으로 필요한 개발이 종료된 후 양방향 관계로 매핑관계를 변경하고자 했다.
그래서 열심히 연관관계 편의 메소드도 만들고, 양방향 관계를 매핑하면서 일어날 수 있는 문제가 없도록 주의 깊게 보며 개발했는데,,
(으헝헝)
stackoverflow가 발생한다고...?
그 때 다른 팀원 한명과 함께 같이 리팩토링 작업을 했는데, 처음에는 이전에는 문제가 없었고, 우리가 코드를 변경하고 이러한 문제가 일어났기 때문에 바꾼 코드에 문제가 있다고 생각하고 작업했던 코드만 내내 살펴봤었다.
양방향으로 매핑 관계를 변경 한 후, 테스트 코드에서 오류가 발생했는데, 특히 이 코드에서 발생했던 것이었다.
바로 특정 게시글의 모든 상세 정보를 조회하는 것에서 발생한 것이었는데, 처음에는 진짜 게시글과 서브 게시글의 정보를 가져오면서 무슨 문제가 있는 건가? 라고 생각하며 머리채를 부여잡으며 코드를 봤지만 봐도봐도 해결되지 않았었다.
한참을 고민하며 이것저것 해결을 시도하다가, 문득 우리가 양방향으로 바꾼 코드로 인해 다른 곳에서 영향이 있지는 않을까? 라는 생각이 들었다.
다시 말해 우리가 변경한 코드에 문제가 없다면 분명 다른 곳을 바라봐야 하는 시점이라는 생각이 들었던 것이다.
그래서 위의 코드에서 한 줄 한줄 주석 처리를 해보면서 어떤 코드를 호출할 때 stackoverflow가 발생하는지를 실험해보았더니 답이 나왔다.
바로 이 부분이다.
이 부분은 사용자가 조회한 게시글을 기반으로 연관된 게시글을 추천해주기 위해 조회 내역을 저장하는 것인데, 계속 영구적인 데이터를 가지고 있을 필요가 없기 때문에 Spring Redis cache를 사용하여 구현되어 있다.
그리고 위에서 보이는 것처럼 Redis에 직접 엔티티를 저장하고 있는 것을 볼 수 있을 것이다.
바로 이 과정에서 stackoverflow가 발생한 것인데, 양방향 연관관계에 대해 잘 아시는 분들은 여기까지만 읽어도 아! 하실 수도 있을 것이다.
바로 Redis가 내부적으로 저장을 위해 toString() 을 사용하여 직렬화를 하며, 우리가 양방향 연관관계로 매핑하면서 toString() 순환참조가 발생한 것이었다.
현재 우리 프로젝트의 Redis는 StringRedisTemplate을 사용하고 있고, 이는 value 값을 String으로 저장하는 방식을 사용한다. 그래서 우리가 엔티티를 그대로 전달했을 때 이를 String으로 저장하기 위해 내부에서 toString을 호출하고, 양방향 매핑때문에 에러가 발생해서 터져버리는 것이라고 결론을 내렸다.
Response를 하는 경우에도 자칫 잘못하면 toString 순환참조가 발생할 수 있는데, 내가 가장 선호하는 방법은 DTO를 사용하여 해결하는 방법이다.
사실 알고보니, Suggestion 내부에서 필요로 하는 정보는 Travelogue에 담겨있는 나라 이름이었고, 이 나라이름을 가지기 위해 엔티티 전체를 저장하는 것이 문제였다.
그래서 저장하고자 하는 필드만을 가진 DTO를 생성하여 저장해주는 것으로 변경하니 바로 해결되었다 ! 👍
사실 게시글을 조회하는 메소드를 보면 조회수도 관리하고, 게시글을 조회하는 것도 담당하고, 조회했으니 이것을 추천 관련 데이터에 넣어주는 것까지 담당하고 있는 것을 볼 수 있다.
누군가 딱 처음 이 코드를 보았을 때 분명 이 서비스 코드는 무엇을 하는 친구이지? 라고 생각할 수 있을 것 같다 싶어서 이를 분리해주는 작업을 했다.
추천을 위해 데이터를 넣어주는 것은 사실 이제 엔티티 객체가 필요한 것이 아니기 때문에 Travelogue의 서비스 내부에서 처리해 줄 이유가 없다고 판단했다. 그래서 상세 조회가 종료된 후에 응답받은 게시글 DTO 객체를 활용하여 Suggestion을 저장해주는 방식으로 완전히 도메인을 분리했다.
TravelogueDetailRes가 상세 조회 요청을 통해 찾아온 게시글 응답 DTO이고, Suggestion에 저장할 정보가 다 담겨있기 때문에 컨트롤러에서 suggestionService를 호출하여 저장하도록 변경했다.
이렇게 하니 이제서야 TravelogueService의 getDetail 이라는 메소드가 상세 정보를 조회하는 담당을 하고 있음이 좀 더 명확하게 보이는 것 같았다.
이제 마지막으로 조회수를 올리는 로직을 분리해보자.
조회수는 게시글이 가지고 있는 필드를 다루는 작업이기 때문에 완전히 분리하지는 않았고, 조회수를 관리하는 서비스를 따로 만들었다.
TravelogueViewService에 increase 메소드를 통해 요청을 보내면 그 레이어가 알아서 조회수를 관리하도록 하였다.
글의 초반에 보았던 getDetail 메소드와 비교하면 코드도 간결해지고, 호출을 통해 어떤 일이 일어날 것인지도 어느 정도 보이기 때문에 훨씬 가독성 높고 책임이 분배된 코드가 되었다고 생각한다.
엔티티의 매핑 방식을 양방향으로 바꿔보지 않았다면 절대 몰랐을 순환참조부터 이를 고치면서 많은 책임이 하나에 몰려있는 것을 파악하고 이를 분리한 것까지 꽤 의미있는 리팩토링을 한 것 같아 뿌듯하다.
한번에 잘하려고 욕심 부리지 않고 꾸준하게 리팩토링 하면서 더욱 간결하고 쉬운 코드를 작성하는 것의 가치있음을 더욱 느낀 것 같다 !! 😎