Spring Boot의 open-in-view, 그 위험성에 대하여.

실제 서버 장애 해결과정을 중심으로

Gunwoo Kim
FRIP
8 min readJun 9, 2022

--

Photo by Kevin Jarrett on Unsplash

안녕하세요 프립의 백엔드 엔지니어 김건우 입니다. 재작년 말부터, 올해 초 까지 프립 백엔드 팀은, 모든 api 코드를 개편하는 작업을 진행하였습니다. 사실상 갈아끼웠다는 표현이 어울릴 정도이죠. 목적은 좋았습니다. 모놀리식으로 개발된 restful api 서버를, 각각의 api의 의존성을 줄이는 msa 구조로 바꾸었고, restful api 대신 graphql을 사용하도록 바꾸었죠. 다만, 너무 급진적인 변화 때문이었을까요. 올해 초, 강업을 진행하고 정말 많은 이슈가 발생했습니다. 지금도 당시를 생각하면 아찔합니다.

그 중에서도 가장 심각했던 문제는 바로, 무려 상품 api 가 매일 죽는 문제였죠. 여러 해결 방법이 제시되었지만, 결국 해결되지 않았고, 해당 문제는 몇달간 지속되었습니다. 물론, 서버가 죽는 주기가 몇 시간에서 하루, 며칠로 늘어나긴 했지만 여전히 심각한 문제였습니다.

당시 저도 이슈에 둘러쌓여 해당 문제에 거의 신경을 못 쓰고 있다가, CTO 님께서 해당 문제를 집중해서 해결해보는게 어떠냐는 제안을 주셨고, 심각성을 인지한 저는 며칠간 상품 api 의 문제를 해결하기 위해 노력했습니다.

아래는 그 해결과정 입니다.

주의사항

아래 글은 Graphql, Spring, Jpa 등의 기술에 대한 전반적인이해를 위한 글이 아닙니다.
따라서, 다소 설명에 친절하지 않은 부분이 있으나, 문제 해결 과정에 의의를 두고 봐주시면 감사하겠습니다.

대체 무엇이 문제였을까요?

처음엔 정말 막막한 심정이었습니다. Spring과 Kotlin 코드를 손에 잡은 지 한달이 채 안되었기 때문이죠.

Photo by Elisa Ventur on Unsplash

제가 상품 api 가 죽는 문제에서 발견했던 단서는 아래와 같습니다.

  • 서버를 강제로 재배포하면 문제가 해결된다.
  • 트래픽이 몰릴 때 발생하지만 낮은 트래픽에서도 가끔 발생한다.

잘은 몰라도, 트래픽이 그리 높지도 않는데 서버가 잘 죽는 것은, 서버의 성능 문제가 아니라는 생각이 들었습니다. 또한 재배포를 하면 정상 동작하는 것으로 보아 막연히 DB connection이 문제일 수도 있겠다는 생각이 들었죠. 예를 들면, connection이 어떠한 이유(이때는 그 이유를 알지 못했습니다.) 때문에 반납 되지 않아서 에러가 발생하고, 재배포 하면 connection 이 다 반납되기 때문에 에러가 발생한다구요. 무엇보다 결정적이었던 증거는 바로 로그에 남아있었습니다.

2022-03-27 17:42:50.273 DEBUG 7 --- [io-9200-exec-21] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Timeout failure stats (total=10, active=10, idle=0, waiting=155)

즉, 이유는 몰라도 DB pool의 connection이 문제였던 것이죠.

그렇다면, 그 이유는 무엇이었을까요?

상품 api에 부하테스트를 진행한 결과, 그 이유도파악할 수 있었습니다. 저희는 graphql을 사용할 때, n+1 문제를 해결하기 위해 DataLoader를 사용하는 경우가 잦습니다. 실제 문제가 되었던 Query를 간략화하면 아래와 같습니다.

query GetProductDetailPageData($id: ID!) {
product {
product(id: $id) {
//DataLoader를 사용하지 않는 부분.(A)
id
title
...
//DataLoader를 사용하는 부분.(B)
productInfo {
...
}
}
}
}

부하테스트 후 에러가 발생한 시점의 hibernatesql 로그를 확인해보니, (A)를 위한 데이터는 조회가 되었으나, (B)를 조회하는 sql이 실행되지 못하고 있었죠. (B)를 조회할 때, DataLoader는 새로운 thread를 만들고 새로운 connection을 사용해야하는데, (A) 조회 후, 즉 (A)를 위한 트랜잭션이 끝난 뒤에도 connection이 반납되지 않은 것입니다. 때문에, 위 Query의 실행이 끝나기 위해서는 최소 2개의 connection이 동시에 필요하였고, maximumPoolSize는 10개 밖에 안되니, 적은 트래픽에서도 DB connection의 데드락이 발생하여 서버가 죽었던 것이죠.
(참고 자료 : https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)

당시에는 원인을 발견했으니, hikarimaximumPoolSize를 늘리고 추가적으로 캐싱 처리에 대한 전반적인 개선작업을 진행하였고, 상품 api는 지금까지도 문제없이 작동하고 있습니다.

다만, 아직 풀리지 않은 의문이 있었습니다.

Photo by Tachina Lee on Unsplash

왜 트랜잭션이 끝나도 connection이 반납되지 않은 것일까요?

그 이유는, 이미 제목에서 스포했듯이 JPA의 open-in-view 였습니다.

open-in-view 를 간단히 설명하면, api 의 요청부터 응답까지 영속성 컨텍스트가 유지된다는 것입니다. 충격적이게도 spring boot에서는 open-in-view 속성의 default값이 true이기 때문에, 트랜잭션이 끝나도 DB connection이 반납되지 않았던 것입니다! 아래는 문제가 되었던 Query를 직접 테스트 해본 결과입니다.

jpa.open-in-view : true (default)

open-in-view 설정이 true 일때의 transaction 로그

2번째 로그를 보면 JPA transaction이 commit 되었지만, open-in-view 속성때문에, JPA EntityManager는 닫히지 않습니다. 이때문에 트랜잭션이 끝나도 DB connection도 반납되지 않았죠.

jpa.open-in-view : false

open-in-view 설정이 false 일때의 transaction 로그

open-in-viewfalse로 설정했을 때는, 3번째 로그와 같이, JPA transactioncommit 된 후, JPA EntityManager도 닫힙니다. 그 뒤 새로운 transactionEntityManager가 생성되죠. 즉, 트랜잭션이 끝났을 때 DB connection도 반납 됩니다.

그럼 이제, open-in-view 설정만 수정하면 될까요?

물론, open-in-viewfalse로 설정하면 DB Connection이 해제되지 않는 문제는 해결될 것입니다. 다만, 그럴 경우에는 lazy loading 을 사용할 때 문제가 발생할 수 있습니다. EntityMangertransactioncommit과 함께 닫히기 때문에, 트랜잭션 외부에서 lazy loading 을 사용하면 no Session 이라는 예외가 발생하게 됩니다. 특히 프립에서는 클라이언트에서 응답값의 형태를 자유롭게 요청할 수 있는 graphql 을 사용하기 때문에, 조회 성능을 높이기 위해 lazy loading 을 빈번하게 사용합니다. 요청 부터 응답 까지를 하나의 트랜잭션으로 묶는 방법도 있기는 합니다만, open-in-viewtrue 로 설정하는 것과 크게 다르지 않을 듯 합니다. 따라서 open-in-view 문제는 아직 숙제로 남아있습니다.

마치며

결국 문제의 직접적인 원인은 open-in-view 에 있었지만, 근본적인 원인은 프레임 워크(툴)를 너무 의심없이 사용했다 는 점이라고 생각합니다. 조금 더 꼼꼼히, 설정을 읽어보고 문서를 찾아봤다면 문제를 미연에 방지할 수도 있었을 것입니다. 하지만, 저희는 시간이 없었고 결국 문제가 발생하고 나서야 깨달았죠. 물론, 이번 경험은 저희에게는 경각심을 일깨우고 깨달음을 주는 좋은 경험이었습니다. 다만, 이 글이 비슷한 문제를 겪을 여러분에게는 좋은 안전 장치가 되어 주었으면 좋겠습니다.

--

--

Gunwoo Kim
FRIP
Writer for

KAIST CS undergraduate Software Engineer of Toss