F1MOA라는 서비스를 알고있나요? 아마 대부분이 모르실겁니다. 제가 올해 1월부터 개발해 2월 말쯤 배포한 F1 레이스 데이터와 라이브 타이밍을 제공하는 웹 서비스거든요, 홍보차 쓴 글들이 디시인사이드나 에펨코리아에서 많은 추천을 받았고 지금까지고 사용해주시는 분들이 종종 계십니다. 혹시 궁금한 분들을 위해서 글 하단에 링크를 걸어놓겠습니다.

어드민도 지원하고 풀스택으로 빠르게 개발하기에는 장고 만한게 없다고 생각해서 빠르게 프로토타입을 개발 했었는데요, 이후에 구글 애널리틱스에서 트래픽에 대한 욕심이 생기면서 뉴스 번역 크롤링, 승부 예측, 라이브 타이밍 등 여러가지 기능을 붙이다니 보니까 초기에 생각했던거 보다 프로젝트 사이즈가 커졌습니다.
프로젝트가 더 커지고 코드가 많아지기전에 코틀린과 스프링으로 리팩토링을 해야겠다고 생각은 하고있었는데 중동 정세 변화로.. 4월에 있던 그랑프리가 취소가 되면서 할거면 5월 그랑프리 전에 빨리 해야겠다고 생각해서 진행하게 되었습니다.
요즘 Spring Boot로 프로젝트를 시작하면 JPA를 안 쓰는 경우가 더 드물잖아요? 별다른 고민 없이 JPA를 떠올렸습니다. 다들 그렇게 쓰니까요. 그런데 토스나 우하한테크 블로그를 돌아다니다가 뭔가 내가 놓치고 있던 본질을 건드리는 말을 보게 됐습니다.
"표준은 기본 가이드를 제시하는 것이지 꼭 지켜야 하는 것은 아닙니다. 항상 문제를 먼저 고민하고, 어떤 기술로 해결하는것이 적절한지 고민하는 방식으로 접근합니다."
JPA를 권장하는 글에 적힌 문장인데, 정작 이 문장은 "JPA가 답이 아닐 수 있다" 고 말하고 있습니다. 우아한형제들이 JPA를 사내 표준으로 쓰는 이유는 그들이 문제를 먼저 고민한 결과 JPA가 적합하다고 판단했기 때문이지, 그 반대가 아니었던 거예요. 정산 시스템처럼 복잡한 도메인 로직과 객체 그래프, 수많은 변경 추적이 필요한 워크로드에는 JPA가 적합하고 그래서 그들에게는 JPA가 답이었던 거죠
이 문장을 읽고 standings 서비스 코드를 다시 봤습니다. 며칠 전에 JPA로 옮겨놓은 함수였는데, 영속성 컨텍스트가 거기서
무슨 일을 하고 있는지 깊게 고민해본적이 없었습니다.
이후 F1MOA의 워크로드를 점검해봤습니다
F1MOA의 경우 예측 투표, 댓글 ,좋아요 정도를 제외하면 나머지는 전부 읽기 작업입니다.
쓰기는 대부분 백그라운드에서 일어납니다.
JPA를 단순한 ORM 매핑 도구로만 보면 본질을 놓칩니다. JPA의 핵심은 영속성 컨텍스트(Persistence Context) 라는 메모리 공간입니다. 트랜잭션 안에서 엔티티를 추적하고, 변경을 감지하고, 1차 캐시를 제공하고, 동일성을 보장하는 공간이죠.
영속성 컨텍스트가 만들어내는 가치는 크게 네 가지입니다.
1. Dirty Checking
엔티티를 메모리에서 수정하면 트랜잭션 종료 시점에 자동으로 UPDATE 쿼리가 나갑니다. save()를 명시적으로 호출할 필요가 없습니다.
@Transactional
fun changeNickname(userId: Long, newNickname: String) {
val user = userRepository.findById(userId).orElseThrow()
user.nickname = newNickname
// 트랜잭션 종료 시 UPDATE 자동 실행
}
2. 1차 캐시
같은 트랜잭션 안에서 같은 엔티티를 두 번 조회하면 두 번째는 DB로 가지 않습니다.
@Transactional(readOnly = true)
fun something(userId: Long) {
val u1 = userRepository.findById(userId).orElseThrow() // SELECT
val u2 = userRepository.findById(userId).orElseThrow() // 1차 캐시
// u1 === u2
}
3. Cascade와 객체 그래프
연관 관계가 있는 엔티티를 자연스럽게 다룰 수 있습니다. 부모를 저장하면 자식도 함께 저장되고, 부모에 자식을 추가하면 자동으로 영속화됩니다.
@Transactional
fun createOrder(userId: Long, items: List<OrderItem>) {
val order = Order(userId = userId)
items.forEach { order.addItem(it) }
orderRepository.save(order) // OrderItem까지 cascade로 저장
}
4. Lazy Loading
연관 엔티티를 필요한 시점에만 로드합니다. @ManyToOne(fetch = LAZY) 를 걸어두면 user.team을 호출하는 시점에 SELECT가 나갑니다.
이 네 가지를 보면 공통점이 있습니다. 전부 엔티티를 변경하거나 그래프를 다루는 시나리오에 집중되어 있습니다. Dirty checking은 변경 추적이고, cascade는 그래프 영속화이고, 1차 캐시는 같은 트랜잭션 안의 반복 접근이고, lazy loading은 그래프 탐색의 효율화입니다. 전부 "쓰기와 도메인 로직이 풍부한 워크로드"를 위한 장치입니다.
구현했던 F1MOA의 순위 조회 코드
@Service
@Transactional(readOnly = true)
class StandingsService(
private val driverStandingRepository: DriverStandingRepository,
) {
@Cacheable("standings", key = "'drivers-' + #year")
fun getDriverStandings(year: Int): List<DriverStandingResponse> {
return driverStandingRepository.findBySeasonYearOrderByPosition(year)
.map { it.toResponse() }
}
}
이 함수를 보면 아시겠지만 영속성 컨텍스트를 전부 사용하지 않고있습니다.
그런데 JPA는 이 함수가 호출될 때마다 22개(드라이버 수)의 엔티티를 영속성 컨텍스트에 등록하고, dirty checking을 위한 스냅샷을 떠놓고, 트랜잭션 종료 시 정리하는 비용을 지불합니다.
성능 차이는 크지 않습니다. 22개짜리 read-only 쿼리에서 영속성 컨텍스트 오버헤드는 측정하기 어려운 수준이고, @Cacheable까지 걸려있어서 호출 빈도 자체도 낮습니다. 성능만 보면 무시해도 되는 수준입니다.
문제는 성능이 아니라 정렬이었습니다. 도구의 가치와 풀고 있는 문제가 어긋나 있습니다.
스탠딩 메서드 뿐만이 아니라 레이스 리스트, 상세, 뉴스 리스트, 컬럼 리스트 등 전부 같은 패턴으로 읽기만 하는 작업이 대다수입니다.
F1MOA의 90%를 차지하는 조회 코드 어디에서도 영속성 컨텍스트의 가치는 발휘되지 않고 있었습니다.
제가 JPA를 쓰는 이유를 다시 점검해봤습니다.
1번은 이유가 아니고, 2번은 Spring Data JDBC도 비슷한 기능을 제공합니다. 저는 JPA를 기술적으로 고른 스택이라고 생각했지만, 사실은 고른 적이 없었고 그냥 거기 있어서 익숙해서 썼던거였죠.
이러한 고민들이 과연 필요한가에 대해서 사람마다 생각이 다를 거라고 생각합니다만 적어도 내가 내 서비스 내 프로젝트를 개발하는데 있어서 단순히 "기술적 고민을 하고 접근했나?" 라고 물어본다면 아니였던 겁니다.
여기까지 점검하고 나니 자연스럽게 이런 결론이 떠올랐습니다. "조회만 하는 코드에서 영속성 컨텍스트를 안 쓴다면, JPA가 아닌 더 가벼운 도구를 쓰는 게 맞지 않나?"
실제로 선택지는 있습니다. Spring Data JDBC, JdbcTemplate, MyBatis, QueryDSL 영속성 컨텍스트가 없는 도구들이고, F1MOA처럼 read-heavy한 서비스에는 이론적으로 더 적합합니다.
한 프로젝트에 ORM 두 개를 섞으면 트랜잭션 경계가 복잡해진다
JPA와 JdbcTemplate을 혼용하면 트랜잭션 동기화 문제가 생깁니다. 같은 트랜잭션 안에서 두 도구가 동작할 때 영속성 컨텍스트의 변경 사항이 JdbcTemplate에는 보이지 않거나, 반대로 JdbcTemplate의 변경이 영속성 컨텍스트와 어긋나는 경우가 생깁니다. 이걸 다 인지하고 다루려면 인지 비용이 크게 늘어납니다.
1인 개발 서비스에 인지 비용은 곧 장애로 이어지는 비용입니다. 일관된 한 가지 도구로 가는 게 훨씬 좋다고 판단했습니다.
영속성 컨텍스트의 비용은 F1MOA 규모에서 측정되지 않는다
영속성 컨텍스트가 만드는 오버헤드는 분명히 존재합니다. 다만 그 비용이 사용자 경험에 영향을 주는 규모는 초당 수천에서 수만 TPS가 나오는 시스템부터입니다. F1MOA는 MAU 4,000에 불과해요. 솔직히 캐시까지 걸려있는 standings API에서 영속성 컨텍스트 오버헤드가 응답 시간에 영향을 주는 일은 전무하죠.
그래도 JPA
Spring Data JPA가 제공하는 메서드명 기반 쿼리 생성, 페이징, 정렬, 동적 쿼리 등은 다른 도구로 똑같이 만들려면 보일러플레이트 코드가 몇 배로 늘어납니다. 저 혼자 개발하는데 신경 써야 할 코드가 늘어난다는 건 그만큼 저의 워크플로우가 늘어난다는 뜻이겠죠?
이 생산성을 포기할 만큼 내 워크로드가 절박한지를 물어본다면, 답은 "아니다"였습니다.
설계 단계에서 기술 스택을 선정하는것에 대해서는 프로젝트의 사이즈와 기간, 인력, 그리고 목적성에 부합하는가? 기술적 호환과 성능에 문제없는가? 많은 부분을 고려해야 합니다. 같은 기술을 쓰더라도 알고 쓰는것과 모르고 쓰는것의 차이는 프로젝트가 커지고 기술의 사용이 깊어질수록 그 차이는 확연하게 드러나기 마련이구요. 개발하는데서 중요한것은 끊임없이 왜?를 던져야 하고 그 질문속에서 정답을 찾아가는 과정이 중요하다고 생각합니다.
"표준을 쓰든 안 쓰든, 그 결정이 자기 문제에서 출발했는지가 중요하다"
F1MOA : https://f1-moa.site/