BootCamp

[Error] 일정 관리 트러블 슈팅 2- 양방향vs단방향

limitation01 2025. 11. 14. 08:21

첫번째 트러블슈팅 

 

[Error] 일정 관리 트러블 슈팅 1 - 제네릭

제네릭이 개념중에 가장 코드 적용으로 바로 이어지기 힘든 부분이었다. 필수과제 구현까지 총 3번의 제네릭 활용 리팩토링을 해보았는데 그 과정에서 시행착오가 많았기 때문에 정리해보려고

devz0.tistory.com


문제

제네릭이 문법적으로 가장 시간이 많이들어갔다면 양방향 단방향 연관 관계설정은 생각을 정리하느라 시간이 많이 소요됐다.

리팩토링을 진행하며 User, Schedule, Comment 세 도메인 간 연관관계를 다시 보던 중, “단방향으로 유지할지, 양방향으로 전환할지” 판단이 애매했다. 특히, 단건 조회에서 일정과 댓글을 함께 보여주는 엔드포인트 설계를 하며 “일정 단건 조회시 댓글목록을 항상 참조하는데 Schedule ↔ Comment를 양방향으로 바꿔야 하나?”라는 고민이 있었다.

⚠️ 이전입문과제는 일정과 댓글을 양방향 매핑으로 관계를 설정했다. 뭔가를 알고 했다기 보다 강의대로 진행하다보니 그대로 따라갔다. 단순히 데이터가 함께 필요하다는 이유로 양방향 매핑을 하면 다음과 같은 일이 발생한다.

  • @JsonIgnore 등 직렬화 이슈 발생 -> 내가 저번입문과제에 겪었던 문제!
    • 그대로 JSON으로 직렬화할 경우, 스프링의 Jackson이 두 엔티티를 서로 무한히 순환하며 직렬화하려고 시도
  • 순환참조 위험 (Schedule → Comment → Schedule) -> 내가 저번입문과제에 겪었던 문제!
    • 양방향 매핑을 걸면 객체 그래프가 서로 참조 하면서 StackOverflowError 또는 Infinite recursion 예외가 발생
  • fetch join 과 Pagination 충돌
    • 한 페이지에 10개의 일정만 보여줘야 하는데 실제 쿼리는 JOIN된 댓글 row 수만큼 곱해져서 페이지네이션이 무의미해지는 상황 발생
  • 도메인 간 의존성이 뒤얽혀 테스트 및 유지보수 어려움
    • 양방향을 무분별하게 설정하면 도메인 간의 경계가 모호해진다.
      예를 들어 Schedule이 Comment를 알고, Comment가 다시 Schedule을 알면, 어느 쪽이 데이터를 주도하는 루트(Aggregate Root)인지 헷갈리게 된다.
    • 도메인은 책임 단위로 분리돼야 하는데, 양방향이 많아지면 결국 모든 게 연결된 거대한 그래프가 되어버린다.

해결 과정

각각의 트레이드 오프를 항목별로 나누고 이건 절대 안된다는 기준을 세워 검토했다.

1. 탐색의 방향과 주체

  • 일정 단건 조회 시 댓글 목록도 함께 본다 → 주체는 Schedule
  • 댓글이 어떤 일정에 속했는지 알고 싶다 → 주체는 Comment

👉🏻 탐색 방향이 한쪽(Comment)에서만 필요하면 단방향 유지

2. 쿼리와 N+1

양방향일 때는 다건 조회시 Lazy 로딩으로 N+1 문제가 발생한다

단방향에서는

commentRepository.findAllByScheduleId(scheduleId);

이건 JPA가 날리는 N+1이 아니라 내가 의도한 1+1 쿼리다.

3. CASCADE

양방향 + cascade = REMOVE | ALL

  • JPA가 부모-자식 생명주기를 하나로 묶어서 관리
  • DB에서는 삭제됐는데, 1차 캐시에 여전히 남아 있는 상태 가능
  • cascade 범위가 넓으면 예기치 않게 여러 엔티티가 함께 삭제될 위험

단방향 + @OnDelete(action = OnDeleteAction.CASCADE)

  • DB가 자식 삭제를 담당 → 쿼리 단순성 높음
  • JPA cascade로 인한 예기치 않은 전파 제거
  • 단, JPA는 DB 내부 cascade를 “모르기 때문에”
    같은 트랜잭션 내에서 캐시된 엔티티는 삭제되지 않은 것처럼 보일 수 있다.

두 방식 모두 비슷한 증상을 보이지만, 원인은 다르다.

  • 양방향: JPA가 캐시를 비우지 않음
  • 단방향: DB가 알아서 삭제했지만 JPA는 그걸 모름

4. 페이징 충돌

  • 양방향
    @OneToMany(fetch = LAZY) 관계를 fetch join 시 페이징 깨짐 + row 중복
  • 단방향
    • 다건 조회 (GET /schedules) → 댓글 필요 없음
    • 단건 조회 (GET /schedules/{id}) → commentRepository.findAllByScheduleId() 호출

 

4. 결론

이번 결정의 핵심 기준은 예상한 대로 동작하는 코드 였다.

페이지가 깨지거나 내가 작성하지 않은 쿼리로 문제가 생기는 것들을 허용하지 않았다.

  단방향의 단점 양방향의 단점 기준 내 선택
페이징 / fetch join fetch join 직접 안 쓰고 별도 쿼리 작성 필요 fetch join 시 row 중복 + pagination 깨짐 페이징이 깨지는 건 절대 안 돼 단방향
쿼리 실행 흐름 탐색이 불편해 쿼리 1번 더 써야 함 (commentRepository.findAllByScheduleId()) JPA가 예상치 못한 Lazy 로딩 쿼리를 N+1로 터뜨릴 수 있음 내가 날리지 않은 쿼리가 나가는 건 절대 안 돼 단방향
직렬화 (JSON 응답 안정성) 별도 조합 DTO 필요 순환참조 (Schedule→Comment→Schedule) → StackOverflowError 응답에서 무한루프는 절대 안 돼 단방향

배운점

이전에 구글링할때 연관관계 설정은 탐색 주체와 조회 빈도로 결정한다고 보았다. 그러다 보니 양방향도 맞는것같고 단방향도 맞는것 같고 각각의 트레이드오프로 내가 결정을 내리지 못했다. 

양방향/단방향의 선택은 어떤것이 일어나서는 안되는가를 기준으로 삼아보자.

 

  • 단방향은 코드가 조금 길어지더라도 쿼리를 조금 더 직접 작성해야 하지만, 그만큼 예측 가능한 동작과 안정적인 구조를 얻는다.
  • 양방향은 객채 탐색이 쉬워지지만 잘못 설정하면 페이지 깨짐 / 무한 루프 / 의도치 않은 쿼리가 생길 수 있다.
  • 설계의 기준은 문법이 아니라 내가 감당할 수 있는 리스크의 범위였다.

 

이번 과제에서 가장 의미있는 수확이 있었던 부분이다! 튜터님의 피드백으로 기준을 세우면서 이전처럼 불안하게 선택하는 대신,
지금은 확신을 가지고 구조를 설계할 수 있게 되었다. 물론 아직 완벽하진 않지만..ㅋㅎ 방향은 확실히 잡았다!