베이스캠프 4・5주차 회고
개발하기
4・5주차는 본격적인 예매 시스템의 개발 기간이었다. 아무래도 익숙하지 않은 자바 언어, 스프링 부트를 활용하여 개발하려다 보니 예상보다도 시간이 더 걸렸다. 결과적으로 발표 직전인 5주차 주말부터는 약 3일 간 평균 4시간 정도를 자며 개발에 임해야 했다. 실수도 많았고, 그래서 버그도 많았고, 그렇게 버그를 잡아내다 보니 배우는 것도 많았다. 사실 너무 많은 시행착오가 있었기 때문에 다 열거할 수는 없지만, 가장 기억에 남는 @OneToMany 어노테이션에 대해서만 정리해 두고자 한다.
@OneToMany의 늪
- 첫번째 @OneToMany의 늪 | OneToMany with Paging |
우선 예매내역 Entity 내 예매된 좌석 Entity 리스트에 대하여 @OneToMany 어노테이션을 사용하였다. (예매된 좌석 Entity 내에 @ManyToOne으로 예매내역 Entity가 존재하고, 예매내역 Entity내에 @OneToMany(mappedBy="예매된 좌석")
로 예매된 좌석 Entity 리스트가 존재하는 양방향 참조) 이렇게 하니까 JpaRepository를 상속한 Repository 인터페이스에서 쿼리만 보내도 예매내역 안에 예매된 좌석 리스트(쿼리에서는 EntityGraph를 통해 Join되는 대상)을 담아서 반환해 주었다. 여기까지는 아주 편안했다.
그랬다. 처음에는…
예매 조회 화면상에서 ‘더 보기’ 기능을 구현하려고 페이징을 구현하는 순간 콘솔창에 경고가 발생했다. 쿼리 결과를 메모리에 다 올려놓고 애플리케이션이 일일이 페이징 작업을 하고 있다는 경고였다. 즉, 쿼리에 limit n이 들어가지 않고, 그냥 서버에서 모든 데이터를 받아온 다음, 메모리에서 열심히 정리를 해서 페이징해 준다는 것이었다. 사실 당연한 결과였다. 우리가 Repository를 통해 호출한 쿼리에서 페이징의 기준이 되는 것은 @OneToMany에서 One에 해당하는 예매내역이었다. 그런데 쿼리를 통해 조회하기 전까지는 One을 참조하는 Many가 몇 개인지를 확인할 수가 없고, 결과적으로 조회한 뒤에 페이징을 해야 되는 것이었다.
그렇다고 해서 쿼리의 EntityGraph에서 예매좌석 entity를 제외하자니 N+1 쿼리 문제가 발생했다.
구글링을 해 본 결과 관련한 내용(https://www.podo-dev.com/blogs/169 등)을 발견할 수 있었고, @OneToMany 어노테이션에 fetch=FetchType.EAGER
설정을 추가하고 @BatchSize 어노테이션을 통해 적절한 조회 batch size를 설정해 주면 메모리 누수 방지와 쿼리 축소를 적절히 절충할 수 있었다. 사실 이게 최선의 방법인지는 아직도 모르겠다.
- 두번째 @OneToMany의 늪 | OneToMany with OneToMany |
나의 추천으로 다른 TF원이 회차 정보 Entity에도 예매내역 Entity 리스트를 @OneToMany로 포함하게 되었다. 그렇게 하면 회차 정보를 조회할 때 그에 달려있는 예매내역 리스트를 받아오고, 동시에 예매내역 리스트에 들어 있는 예매좌석 리스트도 받아올 수 있다는 생각이었다.
하지만 역시나 마음대로 되지 않았다.
이상하게 조회 쿼리 결과 중복된 데이터가 반환되었고, 결과적으로 리스트 속 요소가 리스트를 품고 있는 계층관계도 기대처럼 나오지 않았다. 구글링을 해 보니 OneToMany Fetch를 중첩하여 조회하는 기능을 Hibernate가 지원하지 않는다는 내용을 볼 수 있었다.
@OneToMany 어노테이션을 제외하고 JPQL로 JOIN 관계를 명시해주는 방식으로 문제를 해결하였고, 적절한 데이터를 얻을 수 있었다.
- 세번째 @OneToMany의 늪 | OneToMany with Cascade |
그리고 프로젝트 말미에도 이 @OneToMany 예매좌석 리스트 때문에 또 다른 문제가 발생했다. 개발 중 어떠한 이유에서인지 @OneToMany 어노테이션에 cascade = CascadeType.ALL
설정을 추가하게 되었다. 이 부분을 추가하고 나니 예매내역 취소 시에 예매좌석이 삭제되지 않는 문제가 발생했다.
앞서 언급했듯 @OneToMany 어노테이션에 따라서 조회된 예매내역 Entity는 예매좌석 Entity 리스트를 멤버로 포함하고 있다. 예매내역을 취소하고자 할 때, 우리는 예매내역 안에 들어있는 예매좌석을 DB 테이블에서 삭제하고, 예매내역을 취소 status로 전환한 다음 DB 테이블에 저장하는 형태로 구현했다. cascade 관련 옵션이 없었을 때에 이러한 흐름은 정상적으로 작동했다.
하지만 cascade = CascadeType.ALL
설정을 추가하자 상황이 바뀌었다. @OneToMany에 cascade = CascadeType.ALL
옵션을 더하면 One에 해당하는 Entity의 변화를 DB에 반영할 때, Many에 해당하는 리스트 구성요소의 변화까지도 반영하게 된다.
사건을 재구성해 보자.
예매내역은 예매좌석 삭제 전에 조회되었기 때문에, 삭제된 예매좌석을 그 멤버로 포함하고 있었다. 그 예매내역 Entity에서 예매상태만 취소로 전환하여 DB 테이블에 저장하다 보니, 그 안에 있는 예매좌석 Entity 리스트가 덩달아 DB 테이블에 저장되었다. 결과적으로 삭제한 예매좌석이 다시 생긴 것이다. 이전에는 cascade 옵션 추가로 무시되던 다른 Entity에 대한 영향이 처리되기 시작한 것이다.
cascade = CascadeType.ALL
는 불필요한 것으로 결국 판명되어 해당 옵션을 삭제하였고, 버그는 자연스럽게 해결되었다.
정리하며
DB는 어렵다고 생각했지만, 건드려 보니까 생각했던 것보다도 더 어렵다. 그 와중에 잘 모르는 기능을 섣불리 쓰려다 보니 위와 같은 버그들이 속출했다. 어찌저찌 버그는 수습했지만, 현재의 쿼리가 얼마나 비효율적인지에 대해서는 아직도 감이 잘 오지 않는다. 앞으로도 공부가 절실하다.
댓글남기기