자바 아키텍처·Spring 학습 노트 목차

JPA — 객체와 테이블 사이의 통역

메모리 위의 객체와 DB의 테이블은 사고방식이 다르다. 객체는 참조로 연결되고 상속을 갖지만, 테이블은 외래 키로 잇고 상속이 없다 — 이 어긋남을 객체-관계 임피던스 불일치라 부른다. JPA와 Hibernate는 그 사이를 통역하는 도구인데, 그 모든 동작의 중심에 영속성 컨텍스트라는 개념 하나가 있다. 이것만 제대로 이해하면, save를 부르지 않았는데 저장되는 마법도, 악명 높은 N+1도, 그리고 커넥션 풀을 늘렸는데 오히려 느려지는 현장의 사고까지 한 줄기로 풀린다.

JPA의 심장 — 영속성 컨텍스트와 변경 감지의 내부

EntityManager(Spring Data가 감싸 주는) 안에는 영속성 컨텍스트(1차 캐시)가 들어 있다. 조회하거나 저장한 엔티티가 여기에 보관되고, 트랜잭션이 도는 동안 이 컨텍스트가 진실의 원천이 된다.

다이어그램 로딩 중…

이 컨텍스트가 *변경 감지(dirty checking)*를 어떻게 하는지를 내부까지 보면 마법의 정체가 드러난다. 엔티티가 처음 로드되어 영속 상태가 되는 순간, Hibernate는 그 엔티티의 모든 매핑 필드 값을 깊게 떠서 **스냅샷(hydrated state)**으로 따로 보관한다 — DB에서 막 읽어 온 그 순간의 원본 사진이다. 그리고 변경 감지는 백그라운드에서 계속 도는 게 아니라, flush 시점에만 일어난다 — flush 때 관리 중인 모든 엔티티의 현재 값을 그 스냅샷과 비교해, 달라진 엔티티마다 UPDATE를 자동 생성하는 것이다. 게다가 이 변경(INSERT/UPDATE/DELETE)들은 곧장 나가지 않고 *쓰기 지연(write-behind)*으로 모였다가 flush 때 한꺼번에 나가, 같은 종류의 SQL을 배치로 묶을 기회를 준다. (출처: Vlad — Hibernate dirty checking.)

@Transactional
public void rename(Long id, String name) {
    Order o = repository.findById(id).orElseThrow();   // 이 순간 스냅샷이 떠진다
    o.setName(name);                 // 끝. save() 불필요 — flush 때 스냅샷과 비교해 UPDATE
}

그래서 왜 save가 필요 없냐면, 영속 엔티티는 스냅샷과 함께 컨텍스트가 추적하고 있어서 flush(보통 커밋 직전)에 변경을 감지해 자동 반영하기 때문이다. 명시적 save는 오직 새(비영속) 엔티티를 처음 영속화할 때만 필요하다.

동일성 보장 — 그리고 1차 캐시는 스레드 전용

같은 트랜잭션에서 같은 id를 두 번 find하면 같은 인스턴스를 돌려준다(==가 성립한다) — 1차 캐시가 DB 재조회 없이 같은 객체를 주기 때문이다. 한 가지 중요한 성질은, 1차 캐시는 현재 실행 중인 스레드(트랜잭션)에 묶여 있어 스레드 안전하지 않다는 점이다. 즉 여러 사용자가 공유하는 게 아니라 트랜잭션마다 따로다(여러 트랜잭션이 공유하는 캐시는 2차 캐시의 영역으로, 변경이 드문 데이터에만 신중히 쓴다). (출처: Vlad — first-level cache.)

엔티티의 일생

방금 "영속 상태"라는 말이 나왔는데, 엔티티는 컨텍스트와의 관계에 따라 네 가지 상태를 오간다.

다이어그램 로딩 중…

new로 막 만든, 컨텍스트가 모르는 객체가 비영속이다. persistfind로 컨텍스트가 관리하기 시작하면 영속이고(변경 감지·스냅샷의 대상이 바로 이 상태다), 컨텍스트가 끝나(트랜잭션 종료) 더는 추적되지 않으면 준영속이 된다 — 바로 이 준영속 상태에서 지연 로딩을 건드리면 사고가 나는데, 그 이야기는 잠시 뒤에 한다. remove로 표시하면 삭제 상태다. 참고로 준영속 엔티티를 다시 영속화하는 merge전달한 객체 자신이 아니라 복사된 새 영속 인스턴스를 반환하므로, 반환값을 써야 한다(흔한 함정).

연관을 언제 불러올까 — 지연 로딩

엔티티들은 서로 연결돼 있다(@ManyToOne·@OneToMany). 이 연관을 언제 가져오느냐가 성능을 가른다. *즉시 로딩(EAGER)*은 엔티티를 조회할 때 연관까지 함께 끌어오고, *지연 로딩(LAZY)*은 연관을 실제로 쓸 때 프록시를 통해 따로 가져온다. 실무의 답은 분명하다 — 연관은 전부 LAZY로 둬라. @ManyToOne·@OneToOne기본이 EAGER인데 이걸 그냥 두면 원치 않는 조인과 조회가 줄줄이 따라붙는다. 모든 연관을 fetch = LAZY로 명시해 두고, 정말 필요한 곳에서만 fetch join으로 가져오는 게 정석이다. EAGER야말로 다음에 볼 N+1과 예측 못 할 쿼리의 주범이다.

가장 유명한 함정 — N+1

List<Order> orders = repository.findAll();   // ① SELECT * FROM orders  (1번)
for (Order o : orders)
    o.getMember().getName();                 // ② 주문마다 SELECT member  (N번)

무슨 일이 벌어지나 보자. 목록을 한 번 조회하고(쿼리 1번), 루프를 돌며 각 주문의 LAZY 연관(member)을 건드리면 그 순간 프록시가 DB를 친다 — 주문이 N건이면 추가로 N번. 합쳐서 1 + N 쿼리다. 100건이면 101번 DB를 왕복하니, 데이터가 늘수록 조용히 느려진다. 원리는 단순하다 — LAZY 연관은 접근하는 순간 쿼리가 나가는데, 루프에서 하나씩 접근하니 하나씩 나가는 것이다.

고치는 길은 필요한 연관을 한 번에 함께 가져오는 것이다. JPQL에서 join fetch로 한 방에 조회하거나(select o from Order o join fetch o.member), @EntityGraph쿼리를 안 고치고 함께 로드하거나(이쪽은 페이징과도 잘 맞는다 — 컬렉션 fetch join은 페이징이 까다롭다), @BatchSize·hibernate.default_batch_fetch_size로 N개를 where member_id in (?, ?, …) 한 번에 묶어 1+1로 줄이는 방법이 있다. (출처: Vlad — performance tuning.)

컨텍스트 밖에서 LAZY를 건드리면 — LazyInitializationException, 그리고 OSIV

앞에서 미뤄 둔 준영속 상태의 사고가 이것이다.

Order o = service.find(id);   // 트랜잭션 안에서 조회 (member는 LAZY 프록시)
// ... 트랜잭션 종료(영속성 컨텍스트 닫힘) ...
o.getMember().getName();      // LazyInitializationException

LAZY 프록시는 영속성 컨텍스트가 살아 있을 때만 DB를 칠 수 있다. 트랜잭션이 끝나 컨텍스트가 닫힌 뒤(보통 뷰나 컨트롤러 단계에서) LAZY 연관을 건드리면, 칠 데가 없어 **LazyInitializationException**이 터진다. 가장 근본적인 해법은 컨트롤러에 엔티티가 아니라 DTO(필요한 필드만)를 반환하는 것이다 — 그러면 LAZY 접근 자체가 사라진다. 아니면 필요한 연관을 트랜잭션 안에서 미리 fetch join으로 로드하거나, **OSIV(Open Session In View)**를 켜서 뷰까지 컨텍스트를 열어 둘 수도 있다. 그런데 바로 이 OSIV가 다음에 볼 커넥션 풀 사고의 도화선이 된다 — OSIV는 영속성 컨텍스트를 응답이 끝날 때까지 열어 두느라, 그에 묶인 DB 커넥션을 응답 렌더링이 끝날 때까지 붙잡고 있기 때문이다.

커넥션 풀 — 왜 100에서 300으로 늘렸더니 더 느려졌나

여기서 실무에서 가장 반직관적인 사고를 짚자. "느리다"는 신고에 커넥션 풀을 100에서 300으로 늘렸더니 오히려 더 느려졌다. 왜일까? 커넥션은 결국 DB가 동시에 처리하는 작업의 수인데, DB가 실제로 동시에 할 수 있는 일은 CPU 코어·디스크·락이 정한다. 코어가 4개인 DB에 커넥션이 300개라면, 그 300개가 4개의 코어를 두고 다투면서 — 나머지 296개는 일을 못 하고 대기하는데, 그저 대기만 하는 게 아니라 컨텍스트 스위칭 비용, 공유 버퍼·락 경합, 메모리 점유늘린다. 즉 일은 안 하면서 부하만 더한다. 그래서 커넥션을 한계 이상으로 늘리면 처리량이 떨어진다.

HikariCP가 권하는 적정 풀 크기 공식은 의외로 작다 — 커넥션 ≈ (코어 수 × 2) + 디스크 스핀들 수. 4코어에 SSD 한 개면 (4×2)+1 = 9개 정도다. 핵심 직관은 "코어 수 × 2" — 한 커넥션이 디스크나 네트워크를 기다리는 동안 다른 커넥션이 CPU를 쓰므로 코어 수의 약 두 배가 최적이고, 데이터가 캐시에 다 들어가면 디스크 항(스핀들)은 0에 가까워진다. (출처: HikariCP — About Pool Sizing.) 그래서 느림의 진짜 원인은 보통 풀이 작아서가 아니라, 위의 OSIV나 긴 트랜잭션·트랜잭션 안의 느린 외부 호출이 커넥션을 오래 붙잡아 풀이 마르는 것이다 — 답은 풀을 키우는 게 아니라 커넥션을 빨리 돌려주는 것(OSIV off·트랜잭션 단축·외부 호출 분리)이다.

대량 처리의 실무

마지막으로, 변경 감지는 공짜가 아니다. 한 컨텍스트가 수십만 엔티티를 관리하면 flush 때 그 많은 스냅샷을 일일이 비교하느라 비싸진다. 그래서 대량 작업에서는 *일정 건수마다 flush()clear()*로 컨텍스트를 비우고, hibernate.jdbc.batch_size(보통 10~50)로 INSERT/UPDATE를 JDBC 배치로 묶으며, 변경 감지·1차 캐시가 필요 없는 순수 대량 처리에는 아예 StatelessSession(스냅샷도 쓰기 지연도 없는)을 쓴다. 조회 전용이면 @Transactional(readOnly = true)스냅샷 보관·flush 부담을 끄는 것도 같은 결의 최적화다.

정리하면, JPA의 모든 것은 영속성 컨텍스트에서 나온다 — 로드 순간 스냅샷을 떠 두었다가 flush 때 비교해 자동 UPDATE(변경 감지)하고, 쓰기는 지연·배치한다(그래서 명시적 save는 새 엔티티에만). 엔티티는 비영속→영속→준영속→삭제를 오가고, 연관은 전부 LAZY로 두고 필요할 때만 함께 가져오는 게 정석이다. 그 LAZY를 잘못 다루면 N+1(루프 접근)과 LazyInitializationException(컨텍스트 밖 접근)이 나는데, 후자를 OSIV로 덮으면 커넥션을 오래 붙잡아 풀 고갈로 번진다 — 그리고 그 풀은 키운다고 빨라지지 않는다(코어×2+스핀들). 결국 답은 DTO로 반환하고, 트랜잭션을 짧게, 커넥션을 빨리 돌려주는 것이다.

JDBC — 자바가 DB와 말하는 가장 낮은 층Spring Data JPA — 리포지토리·페이징·프로젝션