Skip to main content
분명 save()를 다시 부르지 않았는데, transaction이 끝나자 update가 나갔어요.
지난 글에서는 JDBC, Spring Data JDBC, JPA, R2DBC를 최신순이 아니라 문제 모양으로 골라야 한다고 봤어요. 오늘은 그중 JPA를 더 가까이 볼게요. JPA를 처음 쓰면 편한 장면이 먼저 보여요.
여기까지만 보면 그냥 Java 객체의 setter 비슷한 메서드를 호출한 것 같죠. 근데요, 이 코드가 @Transactional method 안에서 실행됐다면 이야기가 달라져요. JPA provider는 이 order 객체를 그냥 평범한 Java 객체로만 보지 않아요. 영속성 컨텍스트(persistence context)가 관리하는 Entity로 보고, transaction이 끝날 때 변경 내용을 데이터베이스와 맞춰요. 그래서 JPA를 읽을 때는 질문이 바뀌어야 해요.
“Repository가 SQL을 대신 만들어주나요?”에서 멈추면 부족해요.
”이 객체는 지금 영속성 컨텍스트 안에 있나요?”까지 봐야 해요.
이 글은 Spring Data JPA 공식 문서의 repository, auditing, @EntityGraph 설명과 Hibernate ORM 공식 문서의 persistence context, entity state, lazy fetching 설명을 기준으로 작성했어요. Spring Boot 버전별 starter 이름이나 Hibernate 세부 버전은 프로젝트마다 달라질 수 있지만, JPA의 핵심 모델인 Entity 상태와 영속성 컨텍스트 개념은 같은 방향으로 읽으면 돼요.

JPA는 “SQL 자동 생성기”만은 아니에요

JPA를 처음 만나는 코드는 보통 repository예요.
이 interface 하나로 save, findById, findAll, deleteById 같은 method를 쓸 수 있어요. 그래서 처음에는 이렇게 느끼기 쉬워요.
“아, JPA는 SQL을 안 쓰고 repository method로 DB를 다루게 해주는 기술이구나.”
틀린 말은 아니지만 핵심은 조금 더 깊은 곳에 있어요. JPA는 관계형 데이터베이스의 row를 Java 객체처럼 다루게 해주는 ORM(Object-Relational Mapping) 표준이에요. 그리고 Hibernate 같은 JPA provider가 실제 runtime에서 Entity를 추적하고 SQL을 실행해요. Spring Data JPA는 그 위에서 repository boilerplate를 줄여주는 도구예요. 이 그림에서 Spring Data JPA repository는 입구예요. 하지만 JPA 특유의 헷갈림은 대개 그 아래, EntityManager와 영속성 컨텍스트가 객체 상태를 관리하는 층에서 생겨요.

Entity는 그냥 DTO가 아니에요

먼저 작은 주문 Entity를 볼게요.
겉으로 보면 field가 있는 Java class예요. 하지만 @Entity가 붙는 순간 이 class는 JPA가 데이터베이스 row와 연결해서 관리할 수 있는 대상이 돼요. 여기서 DTO와 Entity의 차이를 조심해야 해요. Entity는 “DB에 저장되는 DTO”가 아니에요. JPA가 상태를 추적할 수 있는 domain 객체예요.

Entity에는 상태가 있어요

JPA에서 Entity는 보통 네 가지 상태로 읽으면 좋아요. 흐름으로 보면 이래요. 이 그림의 핵심은 new로 만든 객체와 repository에서 조회한 객체가 같은 의미가 아니라는 점이에요. 둘 다 Java 객체지만, 하나는 아직 JPA가 모르는 객체이고 다른 하나는 영속성 컨텍스트가 추적하는 객체일 수 있어요. 예를 들어 주문을 새로 만들 때는 처음에 transient 상태예요.
이 객체를 repository로 저장하면 managed 상태가 돼요.
반대로 transaction이 끝나고 영속성 컨텍스트가 닫힌 뒤에는 그 객체가 detached 상태가 될 수 있어요. 식별자는 가지고 있지만, 더 이상 변경 추적 대상이 아닌 거예요.
JPA가 변경을 감지하는 대상은 영속성 컨텍스트가 관리하는 managed Entity예요. controller까지 Entity를 들고 나가서 나중에 field를 바꾸는 방식은 transaction 경계를 흐리게 만들고, lazy loading 예외나 예기치 않은 merge 문제를 부를 수 있어요.

영속성 컨텍스트는 “작업대”에 가까워요

영속성 컨텍스트는 Entity를 관리하는 공간이에요. 너무 추상적으로 들리면, service method 하나가 실행되는 동안 JPA가 펼쳐놓는 작업대라고 생각해도 좋아요. 작업대 위에는 이런 일이 생겨요.
  • 같은 row를 한 context 안에서 다시 찾으면 같은 Entity instance로 다뤄질 수 있어요.
  • managed Entity의 변경이 추적돼요.
  • 삭제 예정 Entity가 표시돼요.
  • flush 시점에 insert, update, delete SQL이 데이터베이스로 나가요.
여기서 service code는 update SQL을 직접 호출하지 않았어요. 대신 managed Entity의 field가 바뀌었고, 영속성 컨텍스트가 그 변경을 flush 시점에 데이터베이스로 동기화했어요. 이게 dirty checking이에요.

dirty checking은 편하지만 경계를 흐리게 만들 수 있어요

다음 service를 볼게요.
처음 보면 이상하죠.
save(order)를 안 했는데요?”
JPA에서는 이 코드가 자연스러울 수 있어요. findById로 가져온 order가 transaction 안에서 managed 상태라면, order.changeShippingAddress(...)로 바뀐 내용을 JPA가 감지해요. 그리고 transaction commit 과정에서 flush가 일어나면 필요한 update SQL을 만들 수 있어요. 그래서 JPA 코드 리뷰에서는 이런 질문이 중요해져요.
JPA에서는 managed Entity를 바꾸고 transaction이 끝날 때 반영하는 방식이 흔해요. 다만 그 코드가 읽히려면 service method의 transaction 경계와 Entity 변경 method가 분명해야 해요.

save()는 “항상 즉시 SQL 실행”이라는 뜻이 아니에요

Spring Data JPA를 쓰면 repository.save(entity)를 자주 보게 돼요. 이름 때문에 “지금 바로 DB에 저장한다”처럼 느껴지지만, JPA에서는 조금 더 조심해서 읽어야 해요. 새 Entity를 저장하는 흐름은 이런 느낌이에요.
이때 save는 새 Entity를 영속성 컨텍스트에 연결하는 입구가 될 수 있어요. 하지만 실제 insert SQL이 언제 나가는지는 식별자 생성 전략, flush 시점, transaction commit 시점에 영향을 받아요. 반대로 이미 managed 상태인 Entity를 바꿀 때마다 save를 반복해서 부르면 코드 의도가 흐려져요.
이 줄이 항상 버그라는 뜻은 아니에요. 하지만 “새 객체를 저장한다”와 “관리 중인 객체의 상태를 바꾼다”는 다른 이야기예요. JPA를 제대로 읽으려면 둘을 구분해야 해요.

lazy loading은 편의 기능이면서 성능 함정이에요

주문에는 고객이 있을 수 있어요.
FetchType.LAZY는 관련 객체를 처음부터 무조건 다 가져오지 않고, 필요할 때 가져오겠다는 뜻이에요. 처음에는 좋아 보여요. 주문 목록만 필요한데 고객 상세까지 매번 가져오면 낭비니까요. 하지만 반복문에서 관계를 건드리면 문제가 보이기 시작해요.
처음 findAll()에서 주문 100개를 가져왔고, 그 뒤 customerName()을 부를 때마다 고객을 하나씩 추가 조회한다면 어떻게 될까요? 이게 흔히 말하는 N+1 문제예요. 주문 목록 1번 조회에, 고객 조회 N번이 붙어요. 데이터가 적을 때는 티가 안 나고, 운영 데이터가 늘면 갑자기 느려질 수 있어요. 해결 방법은 상황마다 달라요. 예를 들어 repository method에 @EntityGraph를 붙여 특정 조회에서 고객을 함께 가져오게 표현할 수 있어요.
중요한 건 lazy loading을 나쁘다고 외우는 게 아니에요. 언제 늦게 가져오고, 언제 처음 query에서 같이 가져와야 하는지 use case마다 정해야 한다는 점이에요.
controller가 Entity를 그대로 반환하면 JSON 직렬화 과정에서 lazy 관계를 건드릴 수 있어요. 그러면 예상치 못한 query가 나가거나, 영속성 컨텍스트 밖에서는 lazy loading 예외가 날 수 있어요. API 응답은 DTO로 끊는 편이 안전해요.

감사 필드는 “누가 언제 바꿨나”를 Entity 생명주기와 연결해요

실무 Entity에는 보통 이런 필드가 붙어요.
  • 언제 만들어졌나요?
  • 언제 마지막으로 수정됐나요?
  • 누가 만들었나요?
  • 누가 마지막으로 수정했나요?
Spring Data JPA는 auditing 기능으로 이런 값을 채울 수 있어요.
그리고 설정 쪽에서 JPA auditing을 켜요.
예시는 단순하게 항상 1L을 돌려줬지만, 실제 서비스에서는 현재 로그인한 사용자 ID를 꺼내야 해요. Spring Security를 쓴다면 SecurityContext에서 인증된 사용자를 읽는 방식이 흔해요. 여기서도 중요한 건 Annotation 이름을 외우는 게 아니에요. 감사 필드는 단순 편의 필드가 아니에요. 운영에서 “이 주문 상태가 언제, 누구 때문에 바뀌었나요?”를 추적하는 시작점이에요. 다만 이것만으로 모든 변경 이력이 남는 건 아니에요. 값의 변경 전후 기록까지 필요하면 별도 history table, event, Hibernate Envers 같은 선택지를 검토해야 해요.

실무에서는 이 경계가 중요해져요

JPA는 코드를 짧게 만들어주지만, 그만큼 runtime 경계를 숨기기도 해요. 그래서 실무에서는 아래 냄새를 먼저 봐요. 특히 JPA 문제를 디버깅할 때는 “어떤 Java method가 호출됐나요?”만 보면 부족해요.
  • 실제로 어떤 SQL이 나갔나요?
  • 한 요청에서 SQL이 몇 번 나갔나요?
  • transaction은 어디서 열리고 닫혔나요?
  • Entity는 managed 상태였나요, detached 상태였나요?
  • lazy 관계를 어디서 처음 건드렸나요?
  • API DTO로 바꾸기 전에 Entity 그래프가 JSON 변환기에 넘어가지는 않았나요?
이 질문들이 있어야 JPA가 편한지 위험한지 제대로 판단할 수 있어요.

처음에는 여기까지만 잡아도 충분해요

JPA를 한 번에 다 이해하려고 하면 너무 커요. 오늘은 이 정도만 먼저 붙잡아도 좋아요. 이 그림에서 JPA의 핵심은 managed Entity예요. 객체가 managed 상태인지 아닌지에 따라 같은 Java 코드도 완전히 다르게 읽혀요. 그래서 JPA를 쓸 때는 항상 이렇게 물어보세요.
“이 객체는 지금 누가 관리하고 있나요?”
이 질문 하나가 dirty checking, lazy loading, N+1, detached Entity, transaction boundary를 읽는 출발점이 돼요.

참고한 링크


자, 정리해볼까요?

  • JPA Entity는 DTO가 아니라 영속성 컨텍스트가 상태를 추적할 수 있는 객체예요.
  • Entity는 transient, managed, detached, removed 상태로 읽으면 흐름이 선명해져요.
  • managed Entity를 transaction 안에서 바꾸면 dirty checking을 통해 flush/commit 시점에 SQL이 나갈 수 있어요.
  • save()는 항상 즉시 SQL 실행이라는 뜻이 아니고, 새 Entity 저장과 managed Entity 변경은 다르게 읽어야 해요.
  • lazy loading은 필요한 순간까지 조회를 늦출 수 있지만, N+1과 JSON 직렬화 문제를 만들 수 있어요.
  • @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy는 Entity 생명주기와 운영 추적을 연결하는 auditing 도구예요.
다음 글에서는 transaction boundary를 더 깊게 볼게요. @Transactional이 정확히 어디서 시작되고 끝나는지, rollback은 어떤 예외에서 일어나는지, self-invocation과 propagation이 왜 기대를 흔드는지 이어서 살펴볼 거예요.