<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://zzzyoonnn.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zzzyoonnn.github.io/" rel="alternate" type="text/html" /><updated>2026-07-13T23:33:48+00:00</updated><id>https://zzzyoonnn.github.io/feed.xml</id><title type="html">Jiyoon Shin / zzzyoonnn</title><subtitle>Your Name&apos;s academic portfolio</subtitle><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><entry><title type="html">왜 금융 시스템에서는 Redis를 사용하는가</title><link href="https://zzzyoonnn.github.io/posts/2026/07/architecture/redis/" rel="alternate" type="text/html" title="왜 금융 시스템에서는 Redis를 사용하는가" /><published>2026-07-11T00:00:00+00:00</published><updated>2026-07-11T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/07/architecture/redis</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/07/architecture/redis/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>작성 이유</li>
  <li>금융 시스템에서 가장 중요한 가치</li>
  <li>멱등성이란</li>
  <li>Redis란</li>
  <li>금융 시스템에서 Redis는 어디에 있을까?</li>
  <li>실제 금융 시스템에서의 Redis 활용 사례
    <ul>
      <li>세션 저장소(Session Storage)</li>
      <li>인증 토큰(Authentication Token) 저장소</li>
      <li>사용자 프로필 저장소(User Profile Storage)</li>
      <li>캐시 프리페칭(Cache Prefetching)</li>
    </ul>
  </li>
  <li>멱등성 처리 시스템 아키텍처
    <ul>
      <li>Architecutre Overview</li>
      <li>시스템 내 Redis의 구체적 역할</li>
      <li>멱등성 보장을 위한 3단계 로직
        <ul>
          <li>SUCCESS (이미 처리된 요청)</li>
          <li>PROCESSING (처리 중인 요청)</li>
          <li>NEW (최초 요청)</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Spring Boot + Redisson 분산 락 구현 예시
    <ul>
      <li>금융 시스템에서 각 기술의 역할</li>
      <li>코드</li>
      <li>코드의 금융 아키텍처적 포인트</li>
      <li>흐름도</li>
    </ul>
  </li>
</ol>

<hr />

<h1 id="작성-이유">작성 이유</h1>

<p>입금, 출금, 송금과 같은 금융 거래는 단순한 데이터 처리가 아니라 <strong>데이터 정합성과 원자성</strong>이 반드시 보장되어야 한다. 예를 들어 출금은 성공했지만 입금이 실패한다면 치명적인 문제가 발생할 수 있다.</p>

<p>Kafka를 학습하며 <strong>왜 금융 시스템이 이벤트 기반 아키텍처를 사용하는지</strong>를 고민하게 되었고, 그 과정에서 이벤트 전달만큼 중요한 것이 <strong>멱등성(Idempotency)</strong> 이라는 점을 알게 되었다. 금융 시스템에서는 동일한 거래가 중복 처리되는 것을 막아야 하기 때문이다.</p>

<p>이번 글에서는 <strong>금융 시스템에서 Redis가 멱등성을 보장하는 데 어떤 역할을 하는지</strong> 살펴본다.</p>

<hr />

<h1 id="금융-시스템에서-가장-중요한-가치">금융 시스템에서 가장 중요한 가치</h1>

<p>금융 시스템에서 가장 중요한 가치는 <strong>돈을 정확하고 안전하게 처리하는 것</strong>이다. 사용자는 화려한 기능보다 내 돈이 사라지지 않고, 중복 결제되지 않으며, 정확하게 관리된다는 신뢰를 기대한다.</p>

<p>이를 위해 금융 시스템은 <strong>정확성(Correctness), 일관성(Consistency), 신뢰성(Reliability), 보안(Security)</strong> 을 핵심 가치로 삼으며, 이를 대규모 환경에서도 유지하기 위해 <strong>멱등성(Idempotency), 낮은 지연 시간(Low Latency), 확장성(Scalability)</strong> 을 고려한 기술과 아키텍처를 사용한다.</p>

<hr />

<h1 id="멱등성이란">멱등성이란</h1>

<p>멱등성(Idempotency)은 <strong>동일한 요청이 여러 번 수행되더라도 시스템의 최종 상태가 한 번 수행한 것과 동일하게 유지되는 성질</strong>을 의미한다.</p>

<p>예를 들어 송금 요청이 네트워크 문제로 여러 번 전달되더라도 실제 출금은 한 번만 이루어지고, 거래 내역도 중복 생성되지 않아야 한다.</p>

<p>즉, 멱등성은 <strong>중복 요청으로부터 데이터 정합성을 보장하는 금융 시스템의 핵심 설계 원칙</strong>이다.</p>

<hr />

<h1 id="redis란">Redis란</h1>

<p>멱등성은 다양한 방법으로 구현할 수 있지만, <strong>Redis는 가장 널리 사용되는 기술 중 하나</strong>이다.</p>

<p>Redis는 메모리 기반 저장소로 요청을 빠르게 조회할 수 있으며, <strong>SETNX</strong>와 같은 원자적 연산을 통해 중복 요청을 효과적으로 차단한다. 또한 <strong>TTL(Time To Live)</strong> 을 이용해 만료된 멱등성 키를 자동으로 삭제할 수 있어 관리도 효율적이다.</p>

<p>이러한 특징 덕분에 Redis는 금융 시스템을 비롯한 대규모 서비스에서 멱등성 구현에 널리 활용된다.</p>

<hr />

<h1 id="금융-시스템에서-redis는-어디에-있을까">금융 시스템에서 Redis는 어디에 있을까?</h1>

<p>오늘날 은행의 가장 중요한 지점은 더 이상 건물이 아니라 <strong>고객의 스마트폰</strong>이다. 모바일 뱅킹이 금융 서비스의 중심이 되면서 고객은 계좌 조회, 송금 등 대부분의 업무를 언제 어디서나 처리한다.</p>

<p>하지만 모바일 뱅킹의 확산은 금융 시스템에 새로운 과제를 안겨주었다. 오랜 기간 운영된 레거시 시스템은 증가하는 트래픽과 실시간 서비스 요구를 모두 만족시키기 어렵기 때문이다.</p>

<p>대표적인 과제는 다음과 같다.</p>

<h3 id="낮은-지연-시간low-latency">낮은 지연 시간(Low Latency)</h3>

<p>고객은 계좌 조회나 송금과 같은 기능에서 즉각적인 응답을 기대한다.</p>

<h3 id="확장성scalability">확장성(Scalability)</h3>

<p>급여일이나 이벤트처럼 트래픽이 급증하는 상황에서도 안정적으로 서비스를 제공해야 한다.</p>

<h3 id="신뢰성reliability">신뢰성(Reliability)</h3>

<p>서비스 장애나 성능 저하는 고객 신뢰와 직결되므로 항상 안정적인 서비스가 요구된다.</p>

<h3 id="데이터-사일로data-silos">데이터 사일로(Data Silos)</h3>

<p>여러 시스템에 흩어진 데이터를 통합해 웹, 모바일 등 모든 채널에서 일관된 금융 경험을 제공해야 한다.</p>

<p>이러한 과제를 해결하기 위해 금융권은 <strong>실시간 데이터 처리, 캐싱, 멱등성 관리, 이상 거래 탐지(Fraud Detection)</strong> 등 다양한 영역에서 Redis를 활용한다. Redis는 단순한 캐시를 넘어 금융 시스템의 성능과 안정성을 높이는 핵심 인프라로 사용되고 있다.</p>

<hr />

<h1 id="실제-금융-시스템에서의-redis-활용-사례">실제 금융 시스템에서의 Redis 활용 사례</h1>

<h2 id="세션-저장소session-storage">세션 저장소(Session Storage)</h2>

<p>세션 저장소는 사용자가 로그인한 후 로그아웃하거나 세션이 만료될 때까지 사용자 상태를 저장하는 공간이다. 이를 통해 애플리케이션은 사용자의 로그인 상태와 활동을 지속적으로 관리할 수 있다.</p>

<h3 id="redis를-사용하는-이유">Redis를 사용하는 이유</h3>
<ul>
  <li>메모리 기반으로 빠른 조회 속도 제공</li>
  <li>다양한 자료구조 지원</li>
  <li>TTL을 통한 세션 자동 만료</li>
  <li>수평 확장 지원</li>
</ul>

<h2 id="인증-토큰authentication-token-저장소">인증 토큰(Authentication Token) 저장소</h2>

<p>인증 토큰은 사용자가 매번 로그인하지 않고도 안전하게 서비스를 이용할 수 있도록 하는 인증 정보이다. 금융 서비스와 오픈뱅킹에서는 토큰을 이용해 일정 시간 동안만 안전하게 API 접근 권한을 부여한다.</p>

<h3 id="redis를-사용하는-이유-1">Redis를 사용하는 이유</h3>
<ul>
  <li>빠른 인증 처리</li>
  <li>TTL을 통한 토큰 자동 만료</li>
  <li>복제와 클러스터링을 통한 높은 가용성</li>
</ul>

<h2 id="사용자-프로필-저장소user-profile-storage">사용자 프로필 저장소(User Profile Storage)</h2>

<p>사용자 프로필에는 계좌 설정, 사용자 환경설정, 기기 정보 등이 저장된다. 금융사는 이를 활용해 개인화 서비스를 제공하고 이상 거래(Fraud Detection)를 실시간으로 탐지한다.</p>

<h3 id="redis를-사용하는-이유-2">Redis를 사용하는 이유</h3>
<ul>
  <li>빠른 데이터 조회</li>
  <li>다양한 자료구조 지원</li>
  <li>실시간 개인화 및 이상 거래 탐지에 적합</li>
</ul>

<h2 id="캐시-프리페칭cache-prefetching">캐시 프리페칭(Cache Prefetching)</h2>
<p>캐시 프리페칭은 사용자가 요청하기 전에 필요한 데이터를 Redis에 미리 저장해 두는 방식이다. 금융사는 이를 활용해 여러 시스템에 분산된 계좌, 카드, 대출, 투자 정보를 하나의 대시보드에서 빠르게 제공한다.</p>

<p>또한 <strong>Redis Data Integration(RDI)</strong> 을 이용하면 원본 데이터 변경 사항을 Redis에 실시간으로 반영하여 최신 상태를 유지할 수 있다.</p>

<p>앞의 세 가지가 <strong>사용자 상태(Session, Authentication, Profile)</strong> 를 관리하는 용도였다면, Cache Prefetching은 여러 금융 시스템에 분산된 데이터를 Redis에 미리 적재하여 <strong>통합 조회 성능을 높이는 아키텍처 패턴</strong>에 가깝다. 금융권에서 Redis를 <strong>캐시 계층(Cache Layer)</strong> 으로 활용하는 대표적인 사례 중 하나이다.</p>

<h3 id="redis를-사용하는-이유-3">Redis를 사용하는 이유</h3>
<ul>
  <li>빠른 응답 속도</li>
  <li>여러 시스템의 데이터 통합</li>
  <li>높은 확장성과 가용성</li>
</ul>

<hr />

<h1 id="멱등성-처리-시스템-아키텍처">멱등성 처리 시스템 아키텍처</h1>
<h2 id="architecutre-overview">Architecutre Overview</h2>

<p><img src="/images/posts/architecture/redis/1.webp" /></p>

<ul>
  <li>클라이언트가 Spring Boot 기반 결제 API를 호출한다.</li>
  <li>백엔드는 요청을 검증한 후 Stripe의 PaymentIntent 또는 Checkout Session을 생성한다.</li>
  <li>Redis에 멱등성 키(Idempotency Key)를 저장하여 동일한 결제 요청이 중복 처리되지 않도록 한다.</li>
  <li>결제 정보와 거래 내역을 데이터베이스에 저장한다.</li>
  <li>Stripe가 카드 결제를 처리한다.</li>
  <li>Stripe는 Webhook을 통해 최종 결제 결과를 백엔드에 전달한다.</li>
  <li>백엔드는 Webhook 이벤트를 기반으로 결제 상태를 최종적으로 업데이트한다.</li>
</ul>

<h2 id="시스템-내-redis의-구체적-역할">시스템 내 Redis의 구체적 역할</h2>

<h3 id="idempotency-key-검증">Idempotency Key 검증</h3>

<p>결제 요청이 들어오면 Spring Boot 애플리케이션은 메인 데이터베이스를 조회하기 전에 <strong>Redis에서 Idempotency Key의 존재 여부를 먼저 확인한다.</strong></p>

<p>이미 동일한 키가 존재한다면 중복 요청으로 판단하여 결제를 다시 수행하지 않는다.</p>

<h3 id="원자적-상태-관리-store-idempotency">원자적 상태 관리 (Store Idempotency)</h3>

<p>처음 들어온 요청이라면 <strong>SETNX(Set if Not Exists)</strong> 와 같은 원자적 연산을 이용해 해당 키를 <code class="language-plaintext highlighter-rouge">PROCESSING</code> 상태로 저장한다. 이를 통해 동일한 요청이 동시에 들어와도 하나의 요청만 실제 결제를 수행할 수 있다.</p>

<p>결제가 완료되면 <code class="language-plaintext highlighter-rouge">SUCCESS</code> 상태와 응답 데이터를 함께 저장한다. 이후 동일한 Idempotency Key로 요청이 들어오면 결제 로직을 다시 실행하지 않고 Redis에 저장된 결과를 그대로 반환한다.</p>

<p>즉, Redis는 <strong>중복 결제를 방지하고 동일한 요청에 항상 동일한 응답을 제공하는 방파제 역할</strong>을 수행한다.</p>

<h2 id="멱등성-보장을-위한-3단계-로직">멱등성 보장을 위한 3단계 로직</h2>

<p>단순히 <strong>Redis에 키가 존재하는지만 확인하면 동시성(Race Condition)</strong> 문제가 발생할 수 있다. 따라서 금융 시스템에서는 <strong>SETNX와 같은 원자적 연산이나 분산 락(Distributed Lock)</strong> 을 이용해 상태를 관리한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[클라이언트 요청] ➔ Idempotency-Key 추출
                     │
         ┌───────────┴───────────┐
      (존재함)                (존재하지 않음)
         │                       │
 [Redis 상태 확인]        [Redis에 키 선점 (PROCESSING)]
   ├── PROCESSING: "잠시 후 재시도" 예외 반환       │
   └── SUCCESS: 기존에 저장된 성공 응답 반환  [비즈니스 로직 및 DB 반영]
                                 │
                          [Redis 상태 업데이트]
                          (PROCESSING ➔ SUCCESS)
                                 │
                           [최종 응답 반환]
</code></pre></div></div>

<h3 id="success-이미-처리된-요청">SUCCESS (이미 처리된 요청)</h3>

<p>동일한 <strong>Idempotency Key</strong> 가 이미 성공적으로 처리된 상태이다.</p>

<p>비즈니스 로직이나 DB 트랜잭션을 다시 수행하지 않고, Redis에 저장된 응답을 그대로 반환한다. 이를 통해 동일한 요청에 항상 동일한 결과를 제공한다.</p>

<h3 id="processing-처리-중인-요청">PROCESSING (처리 중인 요청)</h3>

<p>동일한 요청이 현재 처리 중인 상태이다.</p>

<p>예를 들어 첫 번째 요청이 PG 연동이나 DB 트랜잭션을 수행하는 동안 동일한 요청이 다시 들어오면, 새로운 결제를 수행하지 않고 409 Conflict와 같은 응답을 반환하거나 처리가 끝날 때까지 대기시킨다.</p>

<h3 id="new-최초-요청">NEW (최초 요청)</h3>

<p>Redis에 키가 없다면 최초 요청으로 판단한다.</p>

<p>먼저 <strong>PROCESSING</strong> 상태를 원자적으로 저장한 뒤 비즈니스 로직을 수행한다. 처리가 완료되면 상태를 <strong>SUCCESS</strong>로 변경하고 응답을 저장한다. 이후 동일한 요청이 들어오면 저장된 응답을 반환하여 멱등성을 보장한다.</p>

<hr />

<h1 id="spring-boot--redisson-분산-락-구현-예시">Spring Boot + Redisson 분산 락 구현 예시</h1>

<p>금융 시스템에서는 락 획득에 실패할 때 Redis에 계속 요청을 보내는 <strong>스핀 락(Spin Lock)</strong> 방식보다, <strong>Redis Pub/Sub</strong>을 이용해 락 해제 알림을 받은 뒤 다시 락을 획득하는 <strong>Redisson 분산 락</strong>이 많이 사용된다.</p>

<p>이 방식은 불필요한 Redis 접근을 줄여 높은 동시성 환경에서도 Redis의 부하를 효과적으로 완화할 수 있다.</p>

<h2 id="금융-시스템에서-각-기술의-역할">금융 시스템에서 각 기술의 역할</h2>

<p>분산 환경에서는 하나의 기술만으로 중복 결제를 완벽하게 방지할 수 없다. <strong>Kafka, Redis, 그리고 Idempotency 상태 관리</strong>가 각자의 역할을 수행하며 함께 동작한다.</p>

<h3 id="kafka">Kafka</h3>

<p>Kafka는 결제 이벤트를 여러 서비스에 <strong>안정적으로 전달</strong>하는 역할을 한다. 다만, 동일한 이벤트의 중복 처리까지 보장하지는 않는다.</p>

<h3 id="redis-분산-락redisson">Redis 분산 락(Redisson)</h3>

<p>Redis 분산 락은 동일한 <strong>Idempotency Key</strong>에 대해 하나의 서버만 비즈니스 로직을 수행하도록 보장하여, 여러 서버가 동시에 동일한 결제를 처리하는 <strong>Race Condition</strong>을 방지한다.</p>

<h3 id="idempotency-상태processing--success">Idempotency 상태(PROCESSING / SUCCESS)</h3>

<p>Idempotency 상태는 요청이 <strong>처리 중인지(PROCESSING), 이미 완료되었는지(SUCCESS)</strong> 를 판단하는 기준이다. 이미 처리된 요청이라면 기존 응답을 반환하여 중복 실행을 방지한다.</p>

<p>Redis는 단순한 캐시가 아니라, <strong>세션 관리, 인증 토큰 저장, 캐시 계층, 그리고 멱등성 보장</strong>까지 금융 시스템의 신뢰성을 뒷받침하는 핵심 인프라로 활용된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        Kafka
          │
          ▼
   Redis Distributed Lock
          │
          ▼
  Payment Business Logic
          │
          ▼
 Redis (PROCESSING → SUCCESS)
          │
          ▼
       Response
</code></pre></div></div>

<h2 id="코드">코드</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@Component
@RequiredArgsConstructor
@Slf4j
public class IdempotencyFacade {

    private final RedissonClient redissonClient;
    private final PaymentService paymentService;

    // Redis 분산 락 Key Prefix
    private static final String LOCK_PREFIX = "lock:idempotency:";

    // 멱등성 상태 저장 Key Prefix
    private static final String KEY_PREFIX = "idempotency:";

    public PaymentResponse processPayment(String idempotencyKey, PaymentRequest request) {

        // 동일한 idempotencyKey에 대해 하나의 요청만 처리하도록 분산 락 생성
        RLock lock = redissonClient.getLock(LOCK_PREFIX + idempotencyKey);

        // 요청 처리 상태(PROCESSING / SUCCESS)를 저장할 Redis Bucket
        RBucket&lt;String&gt; statusBucket = redissonClient.getBucket(KEY_PREFIX + idempotencyKey);

        try {

            // 최대 5초 동안 락 획득을 대기
            // leaseTime을 지정하지 않았기 때문에 Redisson Watchdog가 자동으로 TTL을 연장한다.
            // (작업이 오래 걸려도 락이 중간에 만료되지 않음)
            boolean locked = lock.tryLock(5, TimeUnit.SECONDS);

            // 이미 동일 요청이 수행 중이라 락을 획득하지 못한 경우
            if (!locked) {
                throw new ConflictException("동일한 요청이 처리 중입니다.");
            }

            // Redis에 저장된 현재 멱등성 상태 조회
            String status = statusBucket.get();

            // 이미 성공한 요청이라면
            // 실제 결제 로직을 다시 수행하지 않고 기존 응답을 반환
            if ("SUCCESS".equals(status)) {
                log.info("멱등성 요청 감지. 기존 응답 반환. key={}", idempotencyKey);
                return getCachedResponse(idempotencyKey);
            }

            // 다른 서버 또는 스레드에서 이미 처리 중이라면
            // 중복 실행을 막기 위해 요청 거부
            if ("PROCESSING".equals(status)) {
                throw new ConflictException("현재 처리 중인 요청입니다.");
            }

            // 최초 요청이므로 처리 중 상태 저장
            // TTL을 두어 장애가 발생해도 영구적으로 PROCESSING 상태가 남지 않도록 한다.
            statusBucket.set("PROCESSING", 30, TimeUnit.MINUTES);

            // 실제 결제 비즈니스 로직 수행
            PaymentResponse response = paymentService.execute(request);

            // 성공 응답을 Redis에 저장하여
            // 동일 요청이 다시 들어오면 그대로 반환할 수 있도록 한다.
            cacheResponse(idempotencyKey, response);

            // 결제가 정상적으로 완료되었음을 기록
            statusBucket.set("SUCCESS", 30, TimeUnit.MINUTES);

            return response;

        } catch (InterruptedException e) {

            // 락 대기 중 인터럽트 발생
            Thread.currentThread().interrupt();
            throw new SystemException("락 대기 중 인터럽트가 발생했습니다.");

        } catch (Exception e) {

            // 결제 실패 시 PROCESSING 상태 제거
            // 이후 동일 요청이 다시 들어오면 재시도할 수 있도록 한다.
            statusBucket.delete();

            throw e;

        } finally {

            // 현재 스레드가 락을 보유한 경우에만 안전하게 락 해제
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }

        }
    }
}
</code></pre></div></div>

<h2 id="코드의-금융-아키텍처적-포인트">코드의 금융 아키텍처적 포인트</h2>

<h3 id="trylock을-통한-동시성-제어"><code class="language-plaintext highlighter-rouge">tryLock()</code>을 통한 동시성 제어</h3>

<p>Kafka 환경에서는 재시도(Retry)나 장애 복구 과정에서 동일한 결제 이벤트가 여러 번 전달될 수 있다. 이때 여러 Consumer가 동시에 같은 결제를 처리하려고 시도하면 중복 결제가 발생할 수 있다.</p>

<p><code class="language-plaintext highlighter-rouge">Redisson</code>의 <code class="language-plaintext highlighter-rouge">tryLock()</code>을 사용하면 동일한 <strong>Idempotency Key</strong>에 대해 하나의 스레드만 비즈니스 로직을 수행할 수 있다. Kafka는 이벤트를 안정적으로 전달하지만 <strong>동일 자원에 대한 상호 배제(Mutual Exclusion)</strong> 는 보장하지 않기 때문에 Redis 분산 락이 필요하다.</p>

<h3 id="redis-기반-분산-락distributed-lock">Redis 기반 분산 락(Distributed Lock)</h3>

<p>여러 애플리케이션 서버가 동일한 Redis를 공유하면, 하나의 락은 하나의 서버만 획득할 수 있다.</p>

<p>따라서 동일한 <strong>Idempotency Key</strong>로 여러 요청이 동시에 들어오더라도 가장 먼저 락을 획득한 서버만 결제를 수행하고, 나머지 요청은 대기하거나 실패한다. 이를 통해 분산 환경에서도 안전한 동시성 제어가 가능하다.</p>

<h3 id="processing-상태를-이용한-멱등성-보장"><code class="language-plaintext highlighter-rouge">PROCESSING</code> 상태를 이용한 멱등성 보장</h3>

<p>분산 락만으로는 모든 중복 요청을 방지할 수 없다.</p>

<p>예를 들어 락을 획득한 요청이 PG사와 통신하는 동안 동일한 요청이 다시 들어올 수 있다. 이때 Redis에 <code class="language-plaintext highlighter-rouge">PROCESSING</code> 상태를 저장해 두면 후속 요청은 현재 처리 중인 거래임을 확인하고 중복 실행을 막을 수 있다.</p>

<p>결제가 완료되면 상태를 <code class="language-plaintext highlighter-rouge">SUCCESS</code>로 변경하고 응답을 저장한다. 이후 동일한 요청이 들어오면 저장된 응답을 반환하여 멱등성을 보장한다.</p>

<p>즉, Redis 분산 락은 <strong>동시성을 제어</strong>하고, <code class="language-plaintext highlighter-rouge">PROCESSING</code>과 <code class="language-plaintext highlighter-rouge">SUCCESS</code> 상태는 <strong>요청의 생명주기를 관리</strong>한다. 두 가지를 함께 사용해야 분산 환경에서도 중복 결제를 안정적으로 방지할 수 있다.</p>

<h2 id="흐름도">흐름도</h2>

<p>아래 흐름도를 보면 Kafka는 이벤트 전달, Redis는 동시성 제어, Idempotency는 중복 실행 방지를 담당하며, 세 기술이 함께 안전한 결제 시스템을 구성하는 과정을 확인할 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>결제 요청
     │
     ▼
 Kafka 이벤트 전달
     │
     ▼
 Redis 분산 락 (동시성 제어)
     │
     ▼
 PROCESSING 상태 저장
     │
     ▼
 결제 비즈니스 로직 수행
     │
     ▼
 SUCCESS 상태 저장 및 응답 캐싱
     │
     ▼
 동일 요청 재발생
     │
     ▼
 기존 응답 반환 (멱등성 보장)
</code></pre></div></div>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://blog.naver.com/le_voyage_03/224339790976">코어뱅킹과 Kafka는 어떻게 연결되는가</a><br />
<a href="https://blog.naver.com/le_voyage_03/224341953957">왜 Redis를 학습하려 하는가</a><br />
<a href="https://redis.io/blog/how-leading-financial-institutions-use-redis-to-drive-growth/">How leading financial institutions use Redis to drive growth</a><br />
<a href="https://redis.io/blog/power-real-time-mobile-banking-with-redis/">From tap to transaction: power real-time mobile banking with Redis</a><br />
<a href="https://medium.com/@bharathdayals/building-a-spring-boot-stripe-checkout-redis-idempotency-system-complete-guide-58f063dbb244">Building a Payment System with Spring Boot, Stripe, Redis Idempotency &amp; Webhooks (Complete Guide)</a></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="architecture" /><category term="Architecture" /><category term="Financial" /><category term="FinTech" /><category term="Redis" /><category term="Kafka" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">N+1은 GC를 왜 자주 발생시킬까?🤔</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/8/" rel="alternate" type="text/html" title="N+1은 GC를 왜 자주 발생시킬까?🤔" /><published>2026-04-12T00:00:00+00:00</published><updated>2026-04-12T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-8</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/8/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>들어가며..</li>
  <li>자바 가상 머신(JVM)의 동작 방식</li>
  <li>힙(Heap) 영역</li>
  <li>Garbage Collection(GC)</li>
  <li>가비지 컬렉션 동작 과정</li>
  <li>N+1 상황에서 메모리에 벌어지는 일</li>
  <li>N+1은 GC를 왜 자주 발생시킬까?🤔</li>
  <li>JVM은 GC를 어떻게 제어할까?🤔</li>
  <li>결론</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/8/">N+1은 GC를 왜 자주 발생시킬까?🤔</a></p>

<hr />

<h1 id="들어가며">들어가며..</h1>

<p>스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
</code></pre></div></div>

<p>Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.</p>

<hr />

<h1 id="자바-가상-머신jvm의-동작-방식">자바 가상 머신(JVM)의 동작 방식</h1>

<p>자바 가상 머신(Java Virtual Machine)인 JVM의 역할은 자바 애플리케이션을 클래스 로더를 통해 읽어 자바 API와 함께 실행하는 것이다. 다음은 자바 소스 파일을 어떤 동작으로 코드를 읽는지에 대한 간단한 요약 도식이다.</p>

<p><img src="/images/posts/n+1/15.png" width="60%" /></p>

<ol>
  <li>자바 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당받는다.</li>
  <li>자바 컴파일러(javac)가 자바 소스코드(.java)를 자바 바이트 코드(.class)로 컴파일 한다.</li>
  <li>Class Loader는 동적 로딩을 통해 필요한 클래스들을 로딩 및 링크 하여 Runtime Data Area(실질적인 메모리를 할당 받아 관리하는 영역)에 올린다.</li>
  <li>Runtime Data Area에 로딩 된 바이트 코드는 Execution Engine을 통해 해석된다.</li>
  <li>이 과정에서 Execution Engine에 의해 Garbage Collector의 작동과 Thread 동기화가 이루어진다.</li>
</ol>

<h2 id="런타임-데이터-영역-runtime-data-area">런타임 데이터 영역 (Runtime Data Area)</h2>

<p><img src="/images/posts/n+1/16.png" width="50%" /></p>

<p>런타입 데이터 영역은 쉽게 말하면 JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다. 런타임 데이터 영역은 위 그림과 같이 크게 Method Area, Heap Area, Stack Area, PC Register, Native Method Stack로 나눌 수 있다.</p>

<hr />

<h1 id="힙heap-영역">힙(Heap) 영역</h1>

<p>힙 영역은 메서드 영역와 함께 모든 쓰레드가 공유하며, JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이다. 즉, new 연산자로 생성되는 클래스와 인스턴스 변수, 배열 타입 등 Reference Type이 저장되는 곳이다. 당연히 Method Area 영역에 저장된 클래스만이 생성이 되어 적재된다.</p>

<p>유의할점은 힙 영역에 생성된 객체와 배열은 Reference Type으로서, JVM 스택 영역의 변수나 다른 객체의 필드에서 참조된다는 점이다. 만일 참조하는 변수나 필드가 없다면 의미 없는 객체가 되기 때문에 이것을 쓰레기로 취급하고 JVM은 쓰레기 수집기인 Garbage Collector를 실행시켜 쓰레기 객체를 힙 영역에서 자동으로 제거된다. 이처럼 힙 영역은 가비지 컬렉션에 대상이 되는 공간이다. 그리고 효율적인 가비지 컬렉션을 수행하기 위해서 세부적으로 다음과 같이 5가지 영역으로 나뉘게 된다.</p>

<p><img src="/images/posts/n+1/17.png" width="70%" /></p>

<p>이렇게 다섯가지 영역(Eden, survivor 0, survivor 1, Old, Permanent)으로 나뉜 힙 영역은 다시 물리적으로 Young Generation 과 Old Generation 영역으로 구분되게 되는데 다음과 같다.</p>

<ul>
  <li>Young Generation : 생명 주기가 짧은 객체를 GC 대상으로 하는 영역
    <ul>
      <li>Eden : new를 통해 새로 생성된 객체가 위치. 정기적인 쓰레기 수집 후 살아남은 객체들은 Survivor로 이동</li>
      <li>Survivor 0 / Survivor 1 : 각 영역이 채워지게 되면, 살아남은 객체는 비워진 Survivor로 순차적으로 이동</li>
    </ul>
  </li>
  <li>Old Generation : 생명 주기가 긴 객체를 GC 대상으로 하는 영역. Youn Generation에서 마지막까지 살아남은 객체가 이동</li>
</ul>

<p>위에 사진을 보면 JVM 메모리는 부분적으로 나누어져 있습니다. 큰 부분에서 보면 JVM Heap 메모리는 물리적으로 Young 영역과 Old 영역 두부분으로 나누어져 있습니다.</p>

<h2 id="young-generation">Young Generation</h2>

<p>Young 영역은 새로운 객체가 생성되는 곳이다. Young 영역이 가득차게 되면 GC가 동작한다. 이 동작을 Minor GC라고 한다. Young 영역은 3가지 부분으로 나눌 수 있는데, Eden 공간과 두개의 Survivor 공간이다.</p>

<p><img src="/images/posts/n+1/17.png" width="70%" /></p>

<h3 id="young-generation의-흐름">Young Generation의 흐름</h3>

<p>최근에 만들어진 객체는 Eden 영역에 위치한다.</p>

<p><img src="/images/posts/n+1/18.png" width="50%" /></p>

<p>Eden 영역이 가득차면, Minor GC가 발생하는데, 여기서 살아남은 객체들은 survivor 공간 중 하나로 이동한다.</p>

<p><img src="/images/posts/n+1/19.png" width="50%" /></p>

<p>Minor GC는 survivor 공간도 같이 검사를 해서 다른 survivor 공간으로 이동시킨다. 그러므로 survivor 공간 두개 중 하나는 항상 비어있다.</p>

<p><img src="/images/posts/n+1/20.png" width="50%" /></p>

<p>여러 번의 Minor GC를 많이 했음에도 불구하고 살아남은 객체들은 Old 영역으로 이동한다. Minor GC를 당했지만 살아남은 객체는 나이가 든다. 몇 살이 먹으면 Old 영역으로 가야하는지 설정 해 줄 수 있다.</p>

<h2 id="old-generation">Old Generation</h2>

<p>Old 영역에는 여러 번의 Minor GC를 겪고 살아남은 객체들이 있다. Old 영역에서 메모리가 가득차게되면 Major GC가 발생한다. Major GC는 Minor GC에 비해 많은 시간이 걸린다.</p>

<hr />

<h1 id="garbage-collectiongc">Garbage Collection(GC)</h1>

<p>가비지 컬렉션(Garbage Collection, 이하 GC)은 자바의 메모리 관리 방법 중의 하나로 JVM(자바 가상 머신)의 Heap 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객체(garbage)를 모아 주기적으로 제거하는 프로세스를 말한다.</p>

<p><img src="/images/posts/n+1/21.png" width="50%" /></p>

<p>C/C++ 언어에서는 이러한 가비지 컬렉션이 없어 프로그래머가 수동으로 메모리 할당과 해제를 일일이 해줘야 했었다. 반면 Java에서는 가비지 컬렉터가 메모리 관리를 대행해주기 때문에 Java 프로세스가 한정된 메모리를 효율적으로 사용할수 있게 하고, 개발자 입장에서 메모리 관리, 메모리 누수(Memory Leak) 문제에서 대해 관리하지 않아도 되어 오롯이 개발에만 집중할 수 있다는 장점이 있다.</p>

<p>그러나 이런 만능 같은 가비지 컬렉션에도 단점이 존재한다. 자동으로 처리해준다 해도 메모리가 언제 해제되는지 정확하게 알 수 없어 제어하기 힘들며, 가비지 컬렉션(GC)이 동작하는 동안에는 다른 동작을 멈추기 때문에 오버헤드가 발생되는 문제점이 있다. 이를 전문적인 용어로 Stop The World라 한다.</p>

<h2 id="stop-the-world">Stop the World</h2>

<p>모든 GC는 “Stop the World” 라는 이벤트가 발생한다. GC가 동작하면 GC를 제외한 모든 스레드가 멈추기 때문이다. Young 영역의 Minor GC는 객체는 수명이 짧고 많은 객체를 검사하지 않기 때문에 속도가 매우 빨라 거의 애플리케이션에 영향을 주지 않는다.</p>

<p>그러나 Major GC는 살아 있는 모든 객체를 검사해야 하기 때문에 오랜 시간이 걸린다. Major GC가 일어나면 애플리케이션이 아무런 동작을 하지 않기 때문에 Major GC가 일어나는 횟수를 최소화 해야한다. 만약 애플리케이션이 Major GC가 일어나는 횟수가 많다면 timeout 에러를 발생시킬 확률이 높다. GC가 동작하는 시간은 GC의 전략에 따라 다르다. 그래서 Stop the World를 발생시키는 횟수를 줄이기 위해서는 GC를 모니터링 하여 적절하게 튜닝하는 습관이 필요하다.</p>

<hr />

<h1 id="가비지-컬렉션-동작-과정">가비지 컬렉션 동작 과정</h1>

<p><img src="/images/posts/n+1/22.png" width="70%" /></p>

<p>위 그림에서, Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않으며 큰 객체들은 Young 영역이 아니라 바로 Old 영역에 할당되기 때문이다.</p>

<h2 id="heap-메모리의-구조">heap 메모리의 구조</h2>

<p>JVM의 힙(heap) 영역은 동적으로 레퍼런스 데이터가 저장되는 공간으로서, 가비지 컬렉션에 대상이 되는 공간이다. Heap영역은 처음 설계될 때 다음의 2가지를 전제 (Weak Generational Hypothesis)로 설계되었다.</p>

<ul>
  <li>대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.</li>
  <li>오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.</li>
</ul>

<p>즉, 객체는 대부분 일회성되며, 메모리에 오랫동안 남아있는 경우는 드물다는 것이다. 이러한 특성을 이용해 JVM 개발자들은 보다 효율적인 메모리 관리를 위해, 객체의 생존 기간에 따라 물리적인 Heap 영역을 나누게 되었고 Young 과 Old 총 2가지 영역으로 설계하였다.</p>

<p><img src="/images/posts/n+1/23.png" width="70%" /></p>

<ul>
  <li>Young 영역(Young Generation)
    <ul>
      <li>새롭게 생성된 객체가 할당(Allocation)되는 영역</li>
      <li>대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.</li>
      <li>Young 영역에 대한 가비지 컬렉션(Garbage Collection)을 Minor GC라고 부른다.</li>
    </ul>
  </li>
  <li>Old 영역(Old Generation)
    <ul>
      <li>Young영역에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역</li>
      <li>Young 영역보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.</li>
      <li>Old 영역에 대한 가비지 컬렉션(Garbage Collection)을 Major GC 또는 Full GC라고 부른다.</li>
    </ul>
  </li>
</ul>

<p>위 그림에서, Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않으며 큰 객체들은 Young 영역이 아니라 바로 Old 영역에 할당되기 때문이다.</p>

<h3 id="young-generation의-3가지-영역">Young Generation의 3가지 영역</h3>

<p><img src="/images/posts/n+1/24.png" width="70%" /></p>

<ul>
  <li>Eden
    <ul>
      <li>new를 통해 새로 생성된 객체가 위치</li>
      <li>정기적인 쓰레기 수집 후 살아남은 객체들은 Survivor 영역으로 보냄</li>
    </ul>
  </li>
  <li>Survivor 0 / Survivor 1
    <ul>
      <li>최소 1번의 GC 이상 살아남은 객체가 존재하는 영역</li>
      <li>Survivor 0 또는 Survivor 1 둘 중 하나는 꼭 비어 있어야 함</li>
    </ul>
  </li>
</ul>

<p>이렇게 하나의 힙 영역을 세부적으로 쪼갬으로서 객체의 생존 기간을 면밀하게 제어하여 가비지 컬렉터(GC)를 보다 정확하게 불필요한 객체를 제거하는 프로세스를 실행하도록 한다.</p>

<h2 id="minor-gc-과정">Minor GC 과정</h2>

<p><img src="/images/posts/n+1/25.png" width="70%" /></p>

<p>Young Generation 영역은 짧게 살아남는 메모리들이 존재하는 공간이다. 모든 객체는 처음에는 Young Generation에 생성된다. Young Generation의 공간은 Old Generation에 비해 상대적으로 작기 때문에 메모리 상의 객체를 찾아 제거하는데 적은 시간이 걸린다.(작은 공간에서 데이터를 찾으니까) 이 때문에 Young Generation 영역에서 발생되는 GC를 Minor GC라 불린다.</p>

<p>1. 처음 생성된 객체는 Young Generation 영역의 일부인 Eden 영역에 위치한다.</p>

<p><img src="/images/posts/n+1/26.png" width="70%" /></p>

<p>2. 객체가 계속 생성되어 Eden 영역이 꽉차게 되고 Minor GC가 실행한다.</p>

<p><img src="/images/posts/n+1/27.png" width="70%" /></p>

<p>3. Mark 동작을 통해 reachable 객체를 탐색한다.</p>

<p><img src="/images/posts/n+1/28.png" width="70%" /></p>

<p>4. Eden 영역에서 살아남은 객체는 1개의 Survivor 영역으로 이동한다.</p>

<p><img src="/images/posts/n+1/29.png" width="70%" /></p>

<p>5. Eden 영역에서 사용되지 않는 객체(unreachable)의 메모리를 해제(sweep)한다.</p>

<p><img src="/images/posts/n+1/30.png" width="70%" /></p>

<p>6. 살아남은 모든 객체들은 age값이 1씩 증가한다.</p>

<p><img src="/images/posts/n+1/31.png" width="70%" /></p>

<p>7. 또다시 Eden 영역에 신규 객체들로 가득 차게 되면 다시한번 minor GC 발생하고 mark 한다.</p>

<p><img src="/images/posts/n+1/32.png" width="70%" /></p>

<p>8. marking 한 객체들을 비어있는 Survival 1으로 이동하고 sweep한다.</p>

<p><img src="/images/posts/n+1/33.png" width="70%" /></p>

<p>9. 다시 살아남은 모든 객체들은 age가 1씩 증가한다.</p>

<p><img src="/images/posts/n+1/34.png" width="70%" /></p>

<p>10. 이러한 과정을 반복한다.</p>

<p><img src="/images/posts/n+1/35.gif" width="70%" /></p>

<h2 id="major-gc-과정">Major GC 과정</h2>

<p><img src="/images/posts/n+1/36.png" width="70%" /></p>

<p>Old Generation은 길게 살아남는 메모리들이 존재하는 공간이다. Old Generation의 객체들은 거슬러 올라가면 처음에는 Young Generation에 의해 시작되었으나, GC 과정 중에 제거되지 않은 경우 age 임계값이 차게되어 이동된 녀석들이다. 그리고 Major GC는 객체들이 계속 Promotion되어 Old 영역의 메모리가 부족해지면 발생하게 된다.</p>

<p>1. 객체의 age가 임계값(여기선 8로 설정)에 도달하게 되면, 이 객체들은 Old Generation 으로 이동된다. 이를 promotion이라 부른다.</p>

<p><img src="/images/posts/n+1/37.png" width="70%" /></p>

<p>2. 위의 과정이 반복되어 Old Generation 영역의 공간(메모리)가 부족하게 되면 Major GC가 발생되게 된다.</p>

<p><img src="/images/posts/n+1/38.gif" width="70%" /></p>

<p>Major GC는 Old 영역은 데이터가 가득 차면 GC를 실행하는 단순한 방식이다.  Old 영역에 할당된 메모리가 허용치를 넘게 되면, Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 한꺼번에 삭제하는 Major GC가 실행되게 된다. 하지만 Old Generation은 Young Generation에 비해 상대적으로 큰 공간을 가지고 있어, 이 공간에서 메모리 상의 객체 제거에 많은 시간이 걸리게 된다.</p>

<p>여기서 앞서 소개했던 Stop The World 문제가 발생한다. Major GC가 일어나면 Thread가 멈추고 Mark and Sweep 작업을 해야 해서 CPU에 부하를 주기 때문에 멈추거나 버벅이는 현상이 일어나기 때문이다.</p>

<h3 id="minor-gcmajor-gc-정리">Minor GC/Major GC 정리</h3>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>Minor GC</th>
      <th>Major GC</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>대상</td>
      <td>Young Generation</td>
      <td>Old Generation</td>
    </tr>
    <tr>
      <td>실행 시점</td>
      <td>Eden 영역이 꽉 찬 경우</td>
      <td>Old 영역이 꽉 찬 경우</td>
    </tr>
    <tr>
      <td>실행 속도</td>
      <td>빠르다</td>
      <td>느리다</td>
    </tr>
  </tbody>
</table>

<hr />

<h1 id="n1-상황에서-메모리에-벌어지는-일">N+1 상황에서 메모리에 벌어지는 일</h1>

<p>N+1 문제는 보통 1번의 부모 조회 쿼리 이후, 연관된 자식 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 현상이다. 이때 메모리에서는 다음과 같은 ‘객체 파티’가 열린다.</p>

<h3 id="폭발적인-임시-객체-생성">폭발적인 임시 객체 생성</h3>
<p>하나의 Join 쿼리로 가져오면 DB 드라이버 수준에서 효율적으로 처리될 데이터가 N번의 개별 쿼리로 쪼개지면서 매번 다음과 같은 객체들이 생성된다.</p>

<ul>
  <li>JDBC 관련 객체: ResultSet, PreparedStatement 등 통신을 위한 임시 객체</li>
  <li>Entity &amp; Proxy 객체: Hibernate 같은 ORM이 상태 관리를 위해 만드는 수많은 프록시와 실제 엔티티 객체</li>
  <li>DTO 및 Collection: 데이터를 담기 위해 생성되는 리스트와 변환 객체들</li>
</ul>

<h3 id="eden-영역의-급격한-포화">Eden 영역의 급격한 포화</h3>
<p>N이 커질수록(예: 1,000개의 게시글에 대한 댓글 조회) 순식간에 수만 개의 객체가 Eden 영역에 할당된다.</p>

<p><a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a></p>

<hr />

<h1 id="n1은-gc를-왜-자주-발생시킬까">N+1은 GC를 왜 자주 발생시킬까?🤔</h1>
<p>결론부터 말하면 메모리 할당 속도가 해제 속도보다 빨라지기 때문이다.</p>

<h3 id="잦은-minor-gc-유발">잦은 Minor GC 유발</h3>
<p>Eden 영역은 크기가 제한적이다. N+1로 인해 단시간에 엄청난 양의 임시 객체가 쏟아져 들어오면 Eden이 순식간에 꽉 차버리고, JVM은 이를 비우기 위해 Minor GC를 매우 빈번하게 호출한다.</p>

<h3 id="조기-승격premature-promotion-문제">조기 승격(Premature Promotion) 문제</h3>
<p>객체 생성 속도가 빨라 Eden이 너무 빨리 차버리면, 아직 참조가 끝나지 않은(쿼리 수행 중인) 객체들이 충분히 성숙하지 않았음에도 Old Generation으로 강제 이동한다. Old Gen이 꽉 차면 훨씬 파괴적인 Major GC(Full GC)가 발생하며 서버가 눈에 띄게 버벅거리게 된다.</p>

<h3 id="object-churn-발생">Object Churn 발생</h3>
<p>대부분의 객체는 짧게 살고 죽는다. N+1 상황에서 생성된 객체는 요청 처리 중에만 사용되고 이후 바로 GC 대상이 된다. 이런 객체를 Short-lived object라고 한다. 이 현상을 CS에서는 Object Churn라고 한다. 객체가 빠르게 생성되고 빠르게 사라지는 패턴 N+1은 대표적인 high churn 상황이다.</p>

<h3 id="gc-pause-증가">GC Pause 증가</h3>
<p>아무리 짧은 Minor GC라도 발생 시 애플리케이션 스레드가 잠시 멈춥니다. N+1로 인해 GC 빈도가 초당 수십 번으로 늘어나면, 전체적인 처리량(Throughput)이 급감한다. 이 때, Stop-the-world 발생하며 애플리케이션이 멈춘다. 결국 응답 속도가 느려진다.(Latency 증가)</p>

<hr />

<h1 id="jvm은-gc를-어떻게-제어할까">JVM은 GC를 어떻게 제어할까?🤔</h1>

<p>현재의 JVM은 다양한 GC 알고리즘을 기반으로, 애플리케이션의 상태에 따라 가장 적절한 시점에 GC를 수행하도록 설계되어 있다. 그래서 일반적으로는 개발자가 System.gc()를 통해 GC를 직접 호출하는 방식은 지양되는 편이다.</p>

<p>그렇다면 GC를 제어해야 하는 상황에서는 어떻게 접근해야 할까 고민해보았는데, 직접 호출하기보다는 다음과 같이 간접적으로 영향을 주는 것이 현실적인 방법이라는 점을 알게 되었다.</p>

<ul>
  <li>객체 생성과 생명주기를 관리</li>
  <li>불필요한 참조를 줄이기</li>
  <li>JVM 옵션을 통해 GC 동작을 튜닝</li>
</ul>

<p>다만 이러한 튜닝은 단순히 코드 레벨에서 해결되는 문제가 아니라, 운영 환경과 모니터링이 함께 고려되어야 하기 때문에 실무에서는 더욱 신중하게 접근해야 할 영역이라는 생각도 들었다.</p>

<hr />

<h1 id="메모리가-충분한-시대에도-gc는-왜-필요할까">메모리가 충분한 시대에도 GC는 왜 필요할까?🤔</h1>

<p>요즘은 하드웨어 사양이 크게 향상되어 과거처럼 메모리 부족을 걱정하지 않아도 되는 환경이 되었다. 그렇다면 GC에 무관심해도 되는 것은 아닐까?란 생각이 들었다.</p>

<p>하지만 찾아보니 오히려 메모리 용량이 커질수록 GC의 비용 또한 함께 증가할 수 있다는 점이 흥미로웠다. GC 과정에서 발생하는 STW(Stop-The-World)는 애플리케이션 실행을 일시적으로 멈추게 하는데, 관리해야 할 메모리 범위가 넓어질수록 한 번의 STW가 미치는 영향도 그만큼 커지기 때문이다.</p>

<p>이 흐름을 따라가다 보니 자연스럽게 “어떻게 하면 GC를 덜 발생시킬 수 있을까”라는 방향으로 고민이 이어졌다. GC를 완전히 없애는 것이 목표가 아니라, 불필요한 객체 생성을 줄이고 객체의 생명주기를 짧고 명확하게 관리하여 GC가 과도하게 발생하지 않도록 하는 것이 더 현실적인 목표라고 생각한다.</p>

<p>결국 이 고민의 끝은 메모리 최적화로 이어졌다. 낭비하는 메모리를 최대한 줄이는 것이 GC의 발생 빈도를 낮추고, 나아가 STW로 인한 성능 저하를 최소화하는 방향으로 귀결되었다.</p>

<hr />

<h1 id="결론">결론</h1>

<p>N+1이 왜 GC를 자주 발생시키는가에 대한 의문으로 시작한 고민이었지만, 결국 메모리 최적화로 수렴된다는 점이 흥미로웠다. N+1 문제를 해결하는 것 자체가 불필요한 객체 생성을 줄이는 일이고, 그것이 곧 GC 부담을 낮추는 메모리 최적화이기도 하다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://blog.naver.com/le_voyage_03/224188438706">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://inpa.tistory.com/entry/JAVA-%E2%98%95-JVM-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-%EC%8B%AC%ED%99%94%ED%8E%B8">☕ JVM 내부 구조 &amp; 메모리 영역 💯 총정리</a><br />
<a href="https://donghyeon.dev/java/2020/03/31/%EC%9E%90%EB%B0%94%EC%9D%98-JVM-%EA%B5%AC%EC%A1%B0%EC%99%80-Garbage-Collection/">자바의 JVM 구조와 Garbage Collection</a><br />
<a href="https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%EC%85%98GC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%F0%9F%92%AF-%EC%B4%9D%EC%A0%95%EB%A6%AC">☕ 가비지 컬렉션 동작 원리 &amp; GC 종류 💯 총정리</a><br />
<a href="https://youtu.be/Fe3TVCEJhzo?si=1bkf2VYKtzH45Bop">[10분 테코톡] 🐥엘리의 GC</a><br />
<a href="https://docs.oracle.com/en/java/javase/21/gctuning/garbage-first-g1-garbage-collector1.html#GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573">Garbage-First (G1) Garbage Collector</a></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/7/" rel="alternate" type="text/html" title="Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔" /><published>2026-03-15T00:00:00+00:00</published><updated>2026-03-15T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-7</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/7/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>들어가며..</li>
  <li>Hibernate에서의 영속성 컨텍스트</li>
  <li>Hibernate 내부 아키텍처</li>
  <li>Hibernate 핵심 내부 클래스</li>
  <li>결론</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="들어가며">들어가며..</h1>

<p>스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
</code></pre></div></div>

<p>Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.</p>

<h2 id="영속성-컨텍스트">영속성 컨텍스트</h2>

<p>영속성 컨텍스트는 DB에서 조회되거나 저장된 엔티티를 캐싱하고 변경 사항을 추적하는 역할을 한다. 이곳에 저장된 객체는 Entity Manger가 관리하는 “영속 상태(Persistent State)”가 되며, JPA는 해당 객체들의 상태를 추적한다.</p>

<p>Hibernate에서 영속성 컨텍스트는 1차 캐시 역할을 하며 DB와 애플리케이션 사이에서 엔티티 상태를 관리힌다.</p>

<ul>
  <li>DB에서 조회된 엔티티는 영속성 컨텍스트에 저장</li>
  <li>동일 PK 엔티티는 항상 하나의 객체 인스턴스만 존재</li>
  <li>엔티티 변경을 dirty checking으로 추적</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Application
    ↓
Persistence Context (1st Level Cache)
    ↓
Database
</code></pre></div></div>

<hr />

<h1 id="hibernate에서의-영속성-컨텍스트">Hibernate에서의 영속성 컨텍스트</h1>

<p>Hibernate에서는 영속성 컨텍스트 역할을 Session이 수행한다.</p>

<ul>
  <li>JPA
    <ul>
      <li>EntityManager = 영속성 컨텍스트를 관리하는 인터페이스</li>
    </ul>
  </li>
  <li>Hibernate
    <ul>
      <li>Session = JPA의 EntityManager 구현체</li>
      <li>내부적으로 PersistenceContext 구현체를 가지고 있음</li>
    </ul>
  </li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityManager (JPA)
        ↓
Hibernate Session
        ↓
PersistenceContext (1차 캐시)
</code></pre></div></div>

<h2 id="hibernate-내부-구조">Hibernate 내부 구조</h2>

<ul>
  <li>Hibernate Session 내부
    <ul>
      <li>PersistenceContext</li>
      <li>entitiesByKey</li>
      <li>entityEntries</li>
      <li>ActionQueue</li>
      <li>DirtyChecking 메커니즘</li>
    </ul>
  </li>
  <li>핵심 클래스
    <ul>
      <li>org.hibernate.engine.spi.PersistenceContext</li>
      <li>org.hibernate.internal.SessionImp</li>
    </ul>
  </li>
</ul>

<h2 id="트랜잭션과-영속성-컨텍스트">트랜잭션과 영속성 컨텍스트</h2>

<p>Spring + Hibernate 환경에서는 @Transactional 단위로 영속성 컨텍스트가 생성되고 종료된다. 이것이 트랜잭션 단위 영속성 컨텍스트 (Transaction-scoped)이다.</p>

<hr />

<h1 id="hibernate-내부-아키텍처">Hibernate 내부 아키텍처</h1>

<p>애플리케이션은 @Transactional로 정의된 트랜잭션 범위 안에서 EntityManager를 사용해 엔티티를 조회하고 변경한다. 이때 EntityManager는 JPA 표준 인터페이스일 뿐이고, 실제 동작은 Hibernate의 Session이 담당한다.</p>

<p>Session은 내부적으로 <strong>PersistenceContext(영속성 컨텍스트)</strong> 를 관리하며, 여기서 엔티티를 1차 캐시에 저장해 동일성(identity)을 보장하고 변경 사항을 추적한다. 엔티티를 수정하더라도 즉시 DB에 반영하지 않고, 변경 내용은 ActionQueue에 쌓인다.</p>

<p>이후 트랜잭션이 종료되거나 flush 시점이 되면, ActionQueue에 모인 SQL이 한 번에 실행되고, JDBC를 통해 최종적으로 데이터베이스에 반영된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Application]
      ↓
@Transactional
      ↓
EntityManager (JPA)
      ↓
Session (Hibernate)
      ↓
-----------------------------------
|        PersistenceContext       |
|  - entitiesByKey (1차 캐시)      |
|  - entityEntries (스냅샷)        |
-----------------------------------
      ↓
ActionQueue (쓰기 지연 SQL 저장소)
      ↓
JDBC → DB
</code></pre></div></div>

<h2 id="엔티티-조회-시-내부-동작">엔티티 조회 시 내부 동작</h2>

<h3 id="find-호출">find() 호출</h3>

<p>내부 흐름</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// find() 호출
Member member = em.find(Member.class, 1L);
</code></pre></div></div>

<ol>
  <li>Session.find()</li>
  <li>PersistenceContext 조회</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├─ entitiesByKey 에 key 존재?
│     ├─ YES → 바로 반환 (DB 안감)
│     └─ NO  → DB 조회
</code></pre></div></div>

<ol>
  <li>
    <p>DB에서 row 조회</p>
  </li>
  <li>
    <p>엔티티 객체 생성</p>
  </li>
  <li>
    <p>PersistenceContext 저장</p>
  </li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├─ entitiesByKey 에 저장
├─ entityEntries 에 스냅샷 저장
</code></pre></div></div>

<ol>
  <li>반환</li>
</ol>

<h3 id="1차-캐시">1차 캐시</h3>

<p>같은 트랜잭션에서 동일 객체 보장하므로 ‘member1 == member2’ 가 true이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>entitiesByKey
------------------------------
| (Member, 1L) → Member@A1  |
| (Member, 2L) → Member@B2  |
------------------------------
</code></pre></div></div>

<h3 id="변경-감지dirty-checking">변경 감지(Dirty Checking)</h3>

<p>엔티티 로딩 시 Hibernate는 스냅샷을 저장한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>entityEntries
----------------------------------------
| Member@A1 → [id=1, name="old"]     |
----------------------------------------

member.setName("new");
</code></pre></div></div>

<p>Flush 시점 내부 동작</p>

<ol>
  <li>현재 엔티티 값 조회</li>
  <li>스냅샷과 비교</li>
  <li>변경됨 감지</li>
  <li>update SQL 생성</li>
  <li>ActionQueue에 저장</li>
</ol>

<h3 id="쓰기-지연-write-behind">쓰기 지연 (Write Behind)</h3>

<p>아래의 명령어 실행 시에도 바로 INSERT 안 나간다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>em.persist(member);
</code></pre></div></div>

<p>내부 구조</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PersistenceContext
   ↓
ActionQueue
---------------------------------
| InsertAction(Member@A1)      |
| UpdateAction(Member@B2)      |
---------------------------------
</code></pre></div></div>

<p>Flush 발생 시 반영된다.</p>

<ul>
  <li>commit 직전</li>
  <li>JPQL 실행 직전</li>
  <li>em.flush() 호출 시</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ActionQueue.execute()
      ↓
JDBC batch
      ↓
DB
</code></pre></div></div>

<h2 id="flush-전체-흐름">flush 전체 흐름</h2>

<p>Hibernate는 foreign key 순서를 고려해서 SQL을 정렬한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>flush()

  ↓

1. Dirty Checking
2. SQL 생성
3. ActionQueue 정렬
4. JDBC batch 실행
5. DB 반영
</code></pre></div></div>

<h2 id="지연-로딩-내부">지연 로딩 내부</h2>

<p>실제 저장 구조</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Order
  |
  └── member → MemberProxy
</code></pre></div></div>

<p>접근 시</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>order.getMember().getName()

   ↓

프록시가 Session 통해 DB 조회
   ↓
PersistenceContext 저장
   ↓
실제 객체로 교체
</code></pre></div></div>

<h2 id="트랜잭션-종료">트랜잭션 종료</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit
  ↓
flush
  ↓
DB 반영
  ↓
PersistenceContext clear
  ↓
모든 엔티티 Detached
</code></pre></div></div>

<h2 id="정리">정리</h2>

<p>Hibernate 영속성 컨텍스트는 다음과 같은 순서로 동작한다.</p>

<ol>
  <li>1차 캐시(Map 구조)</li>
  <li>스냅샷 기반 Dirty Checking</li>
  <li>ActionQueue 기반 쓰기 지연</li>
  <li>Flush 시점에 SQL 실행</li>
  <li>트랜잭션 단위 생명주기</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 ┌────────────────────┐
                 │   Application      │
                 └─────────┬──────────┘
                           ↓
                 ┌────────────────────┐
                 │   Session          │
                 └─────────┬──────────┘
                           ↓
        ┌─────────────────────────────────┐
        │       PersistenceContext        │
        │  ┌──────────────────────────┐   │
        │  │ entitiesByKey (1차 캐시)  │   │
        │  └──────────────────────────┘   │
        │  ┌──────────────────────────┐   │
        │  │ entityEntries (스냅샷)    │   │
        │  └──────────────────────────┘   │
        └───────────────┬─────────────────┘
                        ↓
                ┌────────────────┐
                │  ActionQueue   │
                └────────┬───────┘
                         ↓
                       JDBC
                         ↓
                         DB

</code></pre></div></div>

<hr />

<h1 id="hibernate-핵심-내부-클래스">Hibernate 핵심 내부 클래스</h1>

<p>실제 코드에서 중요한 것들은 다음과 같다. 이 네 개는 Hibernate의 엔티티 관리 핵심 구조이다.</p>

<ul>
  <li>org.hibernate.internal.SessionImpl</li>
  <li>org.hibernate.engine.spi.PersistenceContext</li>
  <li>org.hibernate.engine.spi.ActionQueue</li>
  <li>org.hibernate.engine.spi.EntityEntry</li>
</ul>

<h3 id="아키텍처-관계">아키텍처 관계</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SessionImpl
   │
   ├── PersistenceContext
   │       └── EntityEntry
   │
   └── ActionQueue
</code></pre></div></div>

<h2 id="sessionimpl">SessionImpl</h2>

<p>Hibernate의 핵심 엔진이며 Hibernate Session의 실제 구현체이다. 실제 모든 동작은 SessionImpl에서 수행된다.</p>

<p>JPA를 사용할 때 구조는 다음과 같다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Application
   ↓
EntityManager (JPA 인터페이스)
   ↓
Hibernate EntityManager 구현체
   ↓
SessionImpl
</code></pre></div></div>

<h3 id="주요-역할">주요 역할</h3>

<ul>
  <li>영속성 컨텍스트 관리</li>
  <li>엔티티 조회</li>
  <li>flush 관리</li>
  <li>SQL 실행</li>
  <li>트랜잭션 연동</li>
</ul>

<h3 id="실제-내부-호출-흐름">실제 내부 호출 흐름</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityManager.find()
      ↓
SessionImpl.find()
      ↓
PersistenceContext 조회
      ↓
없으면 DB 조회
</code></pre></div></div>

<h3 id="내부-구조">내부 구조</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SessionImpl
   ├─ PersistenceContext (엔티티 관리)
   └─ ActionQueue (SQL 실행 대기)
</code></pre></div></div>

<h2 id="persistencecontext">PersistenceContext</h2>

<p>영속성 컨텍스트의 실제 구현이다.</p>

<h3 id="핵심-역할">핵심 역할</h3>

<ul>
  <li>엔티티 1차 캐시 관리</li>
  <li>엔티티 상태 관리</li>
  <li>Dirty Checking 지원</li>
</ul>

<h3 id="내부-구조-1">내부 구조</h3>

<p>대표적으로 이런 Map들이 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PersistenceContext
   ├── entitiesByKey
   ├── entityEntries
   ├── proxiesByKey
</code></pre></div></div>

<h3 id="entitiesbykey1차-캐시">entitiesByKey(1차 캐시)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Map&lt;EntityKey, Object&gt;
</code></pre></div></div>

<p>같은 트랜잭션에서 true가 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(Member,1) → Member@123
(Member,2) → Member@456

member1 == member2
</code></pre></div></div>

<h3 id="entityentries">entityEntries</h3>

<p>엔티티 메타 정보 저장소이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Map&lt;Object, EntityEntry&gt;
</code></pre></div></div>

<p>엔티티 객체는 관리 정보가 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Member@123 → EntityEntry
</code></pre></div></div>

<h2 id="entityentry">EntityEntry</h2>

<p>엔티티 상태 메타데이터다. Hibernate가 엔티티를 관리하기 위해 필요한 정보가 들어 있다.</p>

<h3 id="대표-필드">대표 필드</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityEntry
   ├─ Status status
   ├─ Object[] loadedState
   ├─ Object id
   ├─ EntityPersister persister
   └─ LockMode lockMode
</code></pre></div></div>

<h3 id="status">status</h3>

<p>엔티티 상태를 나타낸다.</p>

<ul>
  <li>MANAGED</li>
  <li>DELETED</li>
  <li>DETACHED</li>
  <li>LOADING</li>
</ul>

<h3 id="loadedstate">loadedState</h3>

<p>Dirty Checking의 핵심이다. 변경 감지를 수행한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>loadedState = ["id=1", "name=old"]

// flush 시 비교
현재값 vs loadedState
</code></pre></div></div>

<p>Dirty Checking 과정</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityEntry.loadedState
        │
        ▼
현재 엔티티 값 비교
        │
        ▼
다르면 UPDATE SQL 생성
</code></pre></div></div>

<h3 id="actionqueue">ActionQueue</h3>

<p>SQL 실행 대기 큐다. Hibernate의 쓰기 지연 전략(write-behind)을 담당한다. 각각 SQL 작업 리스트다.</p>

<p>내부 구조</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ActionQueue
   ├─ insertions
   ├─ updates
   ├─ deletions
   ├─ collectionUpdates
</code></pre></div></div>

<p>persist 시 동작</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SessionImpl.persist()  // INSERT SQL
      ↓
PersistenceContext 저장  // ActionQueue에 저장
      ↓
ActionQueue.insertions 추가  // flush 때 실행
</code></pre></div></div>

<h2 id="전체-동작-연결">전체 동작 연결</h2>

<p>Hibernate 내부 핵심 흐름을 합치면 다음과 같다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SessionImpl
   │
   ├── PersistenceContext
   │       │
   │       ├── entitiesByKey (1차 캐시)
   │       └── entityEntries
   │              └── EntityEntry
   │
   └── ActionQueue
           ├── insert
           ├── update
           └── delete
</code></pre></div></div>

<p>실제 조회 흐름</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find()
  ↓
SessionImpl
  ↓
PersistenceContext.entitiesByKey 조회
  ↓
없으면 DB 조회
  ↓
엔티티 생성
  ↓
EntityEntry 생성
  ↓
PersistenceContext 저장
</code></pre></div></div>

<p>실제 update 흐름</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>member.setName("new")
        ↓
flush
        ↓
EntityEntry.loadedState 비교
        ↓
변경 감지
        ↓
ActionQueue update 추가
        ↓
SQL 실행
</code></pre></div></div>

<hr />

<h1 id="결론">결론</h1>

<p>Hibernate 영속성 컨텍스트는 <strong>Map(entitiesByKey) + Snapshot(EntityEntry) + SQL Queue(ActionQueue) 구조</strong>로 동작한다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://chaewsscode.tistory.com/200">[JPA] 영속성 컨텍스트의 동작원리와 이점</a><br />
<a href="https://docs.hibernate.org/orm/current/userguide/html_single/">Hibernate ORM User Guide</a><br />
<a href="https://github.com/hibernate/hibernate-orm">hibernate-orm</a><br />
<a href="https://docs.hibernate.org/orm/3.5/api/org/hibernate/impl/SessionImpl.html">SessionImpl (Hibernate JavaDocs)</a><br />
<a href="https://docs.hibernate.org/orm/4.2/devguide/en-US/html/ch03.html">Chapter 3. Persistence Contexts</a><br />
<a href="https://docs.hibernate.org/orm/5.1/javadocs/org/hibernate/engine/spi/EntityEntry.html">EntityEntry (Hibernate JavaDocs)</a><br />
<a href="https://docs.hibernate.org/orm/5.3/javadocs/org/hibernate/engine/spi/ActionQueue.html">ActionQueue (Hibernate JavaDocs)</a><br /></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/6/" rel="alternate" type="text/html" title="Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔" /><published>2026-03-06T00:00:00+00:00</published><updated>2026-03-06T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-6</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/6/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>들어가며..</li>
  <li>일반 Join</li>
  <li>Fetch Join</li>
  <li>일반 Join vs Fetch Join</li>
  <li>Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</li>
  <li>결론</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="들어가며">들어가며..</h1>

<p>스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
</code></pre></div></div>

<p>Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.</p>

<h2 id="영속성-컨텍스트">영속성 컨텍스트</h2>

<p>영속성 컨텍스트는 DB에서 조회되거나 저장된 엔티티를 캐싱하고 변경 사항을 추적하는 역할을 한다. 이곳에 저장된 객체는 Entity Manger가 관리하는 “영속 상태(Persistent State)”가 되며, JPA는 해당 객체들의 상태를 추적한다.</p>

<p>Hibernate에서 영속성 컨텍스트는 1차 캐시 역할을 하며 DB와 애플리케이션 사이에서 엔티티 상태를 관리힌다.</p>

<ul>
  <li>DB에서 조회된 엔티티는 영속성 컨텍스트에 저장</li>
  <li>동일 PK 엔티티는 항상 하나의 객체 인스턴스만 존재</li>
  <li>엔티티 변경을 dirty checking으로 추적</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Application
    ↓
Persistence Context (1st Level Cache)
    ↓
Database
</code></pre></div></div>

<hr />

<h1 id="일반-join">일반 Join</h1>

<p>일반 Join은 SQL의 JOIN과 동일하게 동작하지만, <strong>조회 대상 엔티티는 하나만 로딩</strong>한다. 즉 Join은 조회 조건이나 필터링을 위해 사용되며 연관 엔티티는 영속성 컨텍스트에 로딩되지 않는다. 연관된 엔티티는 실제로 사용하기 전까지는 프록시(가짜 객체) 상태로 남아있는다. 만약 연관된 데이터를 조회하려고 하면, 그때마다 별도의 SQL이 실행되는 N+1 문제가 발생한다.</p>

<p>단순히 “연관된 엔티티의 특정 필드로 필터링만 하고 싶고, 그 엔티티 자체는 필요 없을 때”는 일반 조인이 메모리 측면에서 효율적이다.</p>

<hr />

<h1 id="fetch-join">Fetch Join</h1>

<p>SELECT 절에 명시하지 않아도 연관된 엔티티까지 한 번의 쿼리로 다 긁어오기에, 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화한다. 이로 인해 Entity를 참조하더라도 이미 영속성 컨텍스트 안에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1 문제가 해결된다.</p>

<p>SQL의 문법은 아니며, <strong>JPA(JPQL)에서 성능 최적화를 위해 제공하는 기능</strong>이다.</p>

<h2 id="hibernate-내부-관점">Hibernate 내부 관점</h2>

<p>Fetch Join은 SQL 결과를 기반으로 엔티티 그래프를 생성한다.</p>

<table>
  <thead>
    <tr>
      <th>member_id</th>
      <th>team_id</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>1</td>
    </tr>
    <tr>
      <td>2</td>
      <td>1</td>
    </tr>
    <tr>
      <td>3</td>
      <td>2</td>
    </tr>
  </tbody>
</table>

<p>Hibernate는 이를 읽고 영속성 컨텍스트에서 다음과 같이 중복을 merge 한다.</p>

<ul>
  <li>Team 1 -&gt; 이미 존재</li>
  <li>Team 2 -&gt; 새로 생성</li>
</ul>

<p>그래서 row는 여러 개지만 entity는 하나가 된다. 이 때문에 Fetch Join이 영속성 컨텍스트 때문에 row를 merge한다.</p>

<hr />

<h1 id="일반-join-vs-fetch-join">일반 Join vs Fetch Join</h1>

<p>데이터를 어디까지 가져와서 영속성 컨텍스트(메모리)에 올릴 것인가의 차이이다. 연관된 데이터를 화면에 같이 뿌려야 한다면 Fetch Join을, 단순히 조건 검색용으로만 쓰고 연관 데이터는 필요 없다면 일반 Join을 선택하는게 정석이다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>일반 Join</th>
      <th>Fetch Join</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>핵심 목적</td>
      <td>조건 필터링 및 조회</td>
      <td>N+1 문제 해결을 위한 성능 최적화</td>
    </tr>
    <tr>
      <td>영속성 컨텍스트</td>
      <td>연관 엔티티는 프록시 상태 (로딩 X)</td>
      <td>연관 엔티티까지 실제 객체로 즉시 로딩</td>
    </tr>
    <tr>
      <td>SELECT 절</td>
      <td>조회 대상 엔티티만 포함</td>
      <td>연관 엔티티 컬럼까지 모두 포함</td>
    </tr>
    <tr>
      <td>로딩 전략</td>
      <td>Lazy Loading 기반</td>
      <td>Eager Loading처럼 동작</td>
    </tr>
    <tr>
      <td>정리</td>
      <td>Join만 수행, 연관 엔티티는 미로딩</td>
      <td>Join + 연관 엔티티까지 함께 로딩</td>
    </tr>
  </tbody>
</table>

<hr />

<h1 id="fetch-join은-영속성-컨텍스트에서-어떻게-동작할까">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</h1>

<p>일반 Join과 Fetch Join의 동작을 확인해보자.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 일반 Join
SELECT o FROM Order o
JOIN o.member m

// Fetch Join
SELECT o FROM Order o
JOIN FETCH o.member
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>상태</th>
      <th>일반 Join</th>
      <th>Fetch Join</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Order</td>
      <td>영속 상태</td>
      <td>영속 상태</td>
    </tr>
    <tr>
      <td>Member</td>
      <td>프록시 객체 (지연 로딩)</td>
      <td>실제 엔티티 (즉시 로딩)</td>
    </tr>
    <tr>
      <td>접근 시</td>
      <td>추가 SQL 발생 (N+1 가능)</td>
      <td>추가 쿼리 없음</td>
    </tr>
  </tbody>
</table>

<p>일반 JOIN은 연관 엔티티를 즉시 초기화하지 않는다. Fetch 전략에 따라 추가 쿼리가 발생할 수 있다. Fetch Join은 연관 엔티티를 부모 엔티티와 함께 즉시 초기화하여 한 번의 쿼리로 로딩한다.</p>

<h2 id="hibernate-내부-특징">Hibernate 내부 특징</h2>

<p>Hibernate는 PK 기준으로 엔티티를 병합한다. 이는 영속성 컨텍스트가 동일 PK 엔티티를 하나만 유지하기 때문이다. 따라서 Fetch Join 결과는 SQL row 수와 엔티티 수가 다를 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Order 1 - Item A
Order 1 - Item B
Order 1 - Item C

// SQL 결과
3 rows

// Hibernate
Order(1) 하나만 생성
items = [A,B,C]
</code></pre></div></div>

<h2 id="fetch-join이-중복-row를-merge하는-이유">Fetch Join이 중복 row를 merge하는 이유</h2>

<p>Fetch Join이 중복 row를 merge하는 이유는 영속성 컨텍스트의 “엔티티 동일성(identity) 보장 규칙” 때문이다. Hibernate/JPA는 같은 PK를 가진 엔티티는 영속성 컨텍스트에 단 하나의 객체만 존재하도록 강제한다. 그래서 Fetch Join으로 SQL 결과에 중복 row가 생겨도 하나의 엔티티로 merge된다.</p>

<h3 id="entity-identity">Entity Identity</h3>

<p>JPA 스펙에서 영속성 컨텍스트는 다음을 보장한다.</p>

<blockquote>
  <p>For any persistent entity identity there is a unique entity instance.<br />
하나의 엔티티 식별자에 대해 영속성 컨텍스트에는 하나의 객체 인스턴스만 존재한다.</p>
</blockquote>

<p>이는 다음과 같은 구조이다. 같은 PK가 나오면 새 객체를 만들지 않고 기존 객체를 재사용한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Persistence Context
Map&lt;EntityKey, Entity&gt;

// EntityKey(Order, 1) -&gt; Order@1234
// EntityKey(Member, 10) -&gt; Member@5678
</code></pre></div></div>

<h3 id="hibernate-내부-알고리즘">Hibernate 내부 알고리즘</h3>

<p>Hibernate는 내부적으로 다음 로직을 사용한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1 row 읽기
↓
PK 추출
↓
PersistenceContext.contains(entityKey)?
↓
YES → 기존 entity 반환
NO → 새 entity 생성
</code></pre></div></div>

<h2 id="fetch-join-동작-전체-흐름">Fetch Join 동작 전체 흐름</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>JPQL 실행
     │
     ▼
SQL Join 실행
     │
     ▼
ResultSet 생성
     │
     ▼
Hibernate row 순회
     │
     ├─ EntityKey 확인
     │
     ├─ 없으면 생성
     │
     └─ 있으면 재사용
     │
     ▼
Persistence Context 저장
     │
     ▼
완성된 Entity Graph 반환
</code></pre></div></div>

<hr />

<h1 id="결론">결론</h1>

<p>Fetch Join에서 SQL 결과는 연관 엔티티 때문에 중복 row가 발생하지만, Hibernate는 영속성 컨텍스트에서 엔티티 식별자(PK)를 기준으로 동일성을 보장하기 때문에 같은 엔티티를 여러 번 생성하지 않고 하나의 엔티티로 병합한다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://dhstroy.tistory.com/35">Inner Join 과 Fetch Join 의 차이</a><br />
<a href="https://cobbybb.tistory.com/18">[JPA] 일반 Join과 Fetch Join의 차이</a><br />
<a href="https://mannakingdom.tistory.com/116">[Spring] JPA 영속성 컨텍스트와 JOIN 활용</a><br />
<a href="https://frogand.tistory.com/156">[Spring JPA] 일반 Join과 Fetch Join의 차이</a><br />
<a href="https://velog.io/@gogidosirak/JPA%EC%9D%98-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%99%80-N1-%EB%AC%B8%EC%A0%9C-%EB%A1%9C%EB%94%A9-%EC%A0%84%EB%9E%B5-Fetch-join">JPA의 영속성 컨텍스트와 N+1 문제 및 해결방법, 로딩 전략 (Fetch join)</a><br />
<a href="https://docs.hibernate.org/orm/current/userguide/html_single/#fetching">12. Fetching</a><br />
<a href="https://jakarta.ee/specifications/persistence/3.2/">Jakarta Persistence 3.2</a><br />
<a href="https://docs.hibernate.org/orm/current/userguide/html_single/">Hibernate ORM User Guide</a><br /></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/5/" rel="alternate" type="text/html" title="영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)" /><published>2026-03-04T00:00:00+00:00</published><updated>2026-03-04T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-5</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/5/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>들어가며..</li>
  <li>엔티티 매니저</li>
  <li>영속성 컨텍스트</li>
  <li>영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔</li>
  <li>결론</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="들어가며">들어가며..</h1>

<p>스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
</code></pre></div></div>

<p>Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.</p>

<hr />

<h1 id="엔티티-매니저">엔티티 매니저</h1>

<p>엔티티 매니저는 엔티티를 저장하고, 수정하고, 삭제하고, 조회하는 등 엔티티와 관련된 모든 일을 처리한다. 이름 그대로 엔티티를 관리하는 관리자다. 데이터베이스를 하나만 사용하는 애플리케이션은 일반적으로 EntityManagerFactory를 하나만 생성한다. 그리고 필요할 때마다 엔티티매니저 팩토리에서 엔티티 매니저를 생성하면 된다.</p>

<p>엔티티매니저팩토리는 이름 그대로 엔티티 매니저를 만드는 공장인데, 공장을 만드는 비용은 상당히 크다. 따라서 한 개만 만들어서 애플리케이션 전체에서 공유하도록 설계되어 있다.</p>

<p>반면 공장에서 엔티티매니저를 생성하는 비용은 거의 들지 않는다. 그리고 엔티티매니저팩토리는 여러 스레드가 동시에 접근해도 안전하므로 서로 다른 스레드 간에 공유해도 되지만, 엔티티 매니저는 여러 스레드가 동시에 접근하면 동시성 문제가 발생하므로 스레드 간에 절대 공유하면 안된다.</p>

<p><img src="/images/posts/n+1/11.png" alt="엔티티 매니저" /></p>

<hr />

<h1 id="영속성-컨텍스트">영속성 컨텍스트</h1>

<p><img src="/images/posts/n+1/12.png" alt="영속성 컨텍스트" /></p>

<p>ORM은 객체와 데이터베이스 테이블의 매핑을 통해 엔티티 클래스 객체 안에 포함된 정보를 테이블에 저장하는 기술이다. JPA에서는 테이블과 매핑되는 엔티티 객체 정보를 영속성 컨텍스트를 통해 애플리케이션 내에서 오래 지속되도록 보관한다.</p>

<p><img src="/images/posts/n+1/13.png" alt="영속성 컨텍스트" /></p>

<p>JPA를 이해하는 데 가장 중요한 용어인 영속성 컨텍스트(persistence context)는 해석하자면 <strong>엔티티를 영구 저장하는 환경</strong>이란 뜻이다. 서버와 데이터베이스 사이에 엔티티를 저장하는 환경으로, 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관하고 관리한다. 이러한 영속성 컨텍스트는 엔티티 매니저를 생성할 때(Session) 하나씩 만들어진다. 그리고 엔티티 매니저(Session)을 통해서 영속성 컨텍스트에 접근할 수 있고 영속성 컨텍스트를 관리할 수 있다. 즉, 스레드(사용자 요청)마다 하나의 엔티티 매니저가 있고 영속성 컨텍스트가 있고, 요청마다 공유하면 안 된다.</p>

<p>JPA로 엔티티 조회 시 DB에 접근하기 전 영속성 컨텍스트에 엔티티가 있는지 먼저 확인하는 과정을 거친다. 공식적으로는 엔티티를 관리하는 1차 캐시이지만 실제로는 단순 캐시가 아닌 [엔티티 + 상태 정보 + 스냅샷 + 변경 추적 메타데이터]를 저장하는 관리 공간이다.</p>

<h2 id="hibernate에서의-영속성-컨텍스트">Hibernate에서의 영속성 컨텍스트</h2>

<p>영속성 컨텍스트는 엔티티를 관리하는 1차 캐시이자, 엔티티의 생명주기를 관리하는 공간이다. Hibernate에서는 이 역할을 Session이 수행한다.</p>

<ul>
  <li>JPA
    <ul>
      <li>EntityManager = 영속성 컨텍스트를 관리하는 인터페이스</li>
    </ul>
  </li>
  <li>Hibernate
    <ul>
      <li>Session = JPA의 EntityManager 구현체</li>
      <li>내부적으로 PersistenceContext 구현체를 가지고 있음</li>
    </ul>
  </li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityManager (JPA)
        ↓
Hibernate Session
        ↓
PersistenceContext (1차 캐시)
</code></pre></div></div>

<h3 id="hibernate-내부-구조">Hibernate 내부 구조</h3>

<p>자세한 내용은 Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔에서 다룬다.</p>

<ul>
  <li>Hibernate Session 내부
    <ul>
      <li>PersistenceContext</li>
      <li>entitiesByKey</li>
      <li>entityEntries</li>
      <li>ActionQueue</li>
      <li>DirtyChecking 메커니즘</li>
    </ul>
  </li>
  <li>핵심 클래스
    <ul>
      <li>org.hibernate.engine.spi.PersistenceContext</li>
      <li>org.hibernate.internal.SessionImpl</li>
    </ul>
  </li>
</ul>

<h3 id="트랜잭션과-영속성-컨텍스트">트랜잭션과 영속성 컨텍스트</h3>

<p>Spring + Hibernate 환경에서는 @Transactional 단위로 영속성 컨텍스트가 생성되고 종료된다. 이것이 트랜잭션 단위 영속성 컨텍스트 (Transaction-scoped)이다.</p>

<h2 id="엔티티의-생명주기">엔티티의 생명주기</h2>

<p><img src="/images/posts/n+1/14.png" alt="엔티티의 생명주기" /></p>

<ul>
  <li>비영속(new/traslent) : 영속성 컨텍스트와 전혀 관계가 없는 상태</li>
  <li>영속(managed) : 영속성 컨텍스트에 저장된 상태</li>
  <li>준영속(detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태</li>
  <li>삭제(removed) : 삭제된 상태</li>
</ul>

<h3 id="비영속">비영속</h3>

<p>엔티티 객체를 생성했으나 순수한 객체 상태이며 아직 저장하지 않았다. 따라서 영속성 컨텍스트나 데이터베이스와는 전혀 관련이 없다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 객체를 생성한 상태(비영속)
Member member = new Member();
member.setId(“member1”);
member.setUsername(“회원1”);
</code></pre></div></div>

<h3 id="영속">영속</h3>

<p>엔티티 매니저를 통해서 엔티티를 영속성 컨텍스트에 저장했다. 이렇게 영속성 컨텍스트가 관리하는 엔티티를 영속 상태라 한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 객체를 저장한 상태(영속)
em.persist(member);
</code></pre></div></div>

<h3 id="준영속">준영속</h3>

<p>영속성 컨텍스트가 관리하던 영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않으면 준영속 상태가 된다. 특정 엔티티를 준영속 상태로 만들려면 em.detach()를 호출하면 된다. em.close()를 호출해서 영속성 컨텍스트를 닫거나 em.clear()를 호출해서 영속성 컨텍스트를 초기화해도 영속성 커넥스트가 관리하던 엔티티는 준영속 상태가 된다.</p>

<p>준영속 상태 시 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩을 포함한 영속성 컨텍스트가 제공하는 어떠한 기능도 동작하지 않는다. 식별자 값을 가지고 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태
em.detach(member);
</code></pre></div></div>

<h3 id="삭제">삭제</h3>

<p>엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 객체를 삭제한 상태(삭제)
em.remove(member);
</code></pre></div></div>

<h2 id="엔티티-상태-변화">엔티티 상태 변화</h2>

<p>Hibernate/JPA에서 엔티티는 4가지 상태가 있다.</p>

<table>
  <thead>
    <tr>
      <th>상태</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Transient</td>
      <td>새 객체 (영속성 컨텍스트에 없음)</td>
    </tr>
    <tr>
      <td>Persistent</td>
      <td>영속성 컨텍스트에 의해 관리됨</td>
    </tr>
    <tr>
      <td>Detached</td>
      <td>영속성 컨텍스트에서 분리된 상태</td>
    </tr>
    <tr>
      <td>Removed</td>
      <td>삭제가 예정된 상태</td>
    </tr>
  </tbody>
</table>

<h3 id="상태-흐름">상태 흐름</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>new → persist() → Persistent
Persistent → detach() → Detached
Persistent → remove() → Removed
</code></pre></div></div>

<h2 id="특징">특징</h2>

<h3 id="영속성-컨텍스트와-식별자-값">영속성 컨텍스트와 식별자 값</h3>

<p>영속성 컨텍스트는 엔티티를 식별자 값(@Id로 테이블의 기본 키와 매핑한 값)으로 구분한다. 따라서 영속 상태는 식별자 값이 반드시 있어야 한다.</p>

<h3 id="영속성-컨텍스트와-데이터베이스-저장">영속성 컨텍스트와 데이터베이스 저장</h3>

<p>JPA는 보통 트랜잭션을 커밋하는 순간 영속성 컨텍스트에 새로 저장된 엔티티를 데이터베이스에 반영하는데 이를 플러시(flush)라 한다.</p>

<h2 id="장점">장점</h2>

<h3 id="1차-캐시">1차 캐시</h3>

<p>영속성 내부에는 1차 캐시가 존재한다. 영속 상태의 엔티티를 이곳에 저장하기 때문에 만약 엔티티를 조회했을 때 1차 캐시에 엔티티가 존재한다면 DB를 찾아보지 않아도 된다. 말 그대로 캐시로써의 기능과 장점을 가지고 있다.</p>

<h3 id="영속-엔티티의-동일성-보장">영속 엔티티의 동일성 보장</h3>

<p>1차 캐시로 반복 가능한 읽기(Repeatable Read) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공해 줄 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");

System.out.println(a==b) // true
</code></pre></div></div>

<h3 id="트랜잭션을-지원하는-쓰기-지연">트랜잭션을 지원하는 쓰기 지연</h3>

<p>em.persist()로 객체를 영속성 컨텍스트에 저장해도 DB에 바로 Insert 쿼리를 날리지 않는다. SQL 쿼리들을 모아놓았다가 flush 될 때(영속성 컨텍스트의 변경 내용을 DB에 반영할 때) 모아둔 쿼리를 모두 날린다. 이를 쓰기 지연이라고 한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
// 엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 한다.
transaction.begin(); // [트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.

// 커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
transaction.commit(); // [트랜잭션] 커밋
</code></pre></div></div>

<h3 id="변경-감지dirty-checking">변경 감지(Dirty Checking)</h3>

<p>영속성 컨텍스트에서 엔티티를 조회해서 해당 엔티티를 수정한다고 하자. 이때 조회한 엔티티를 다시 업데이트하는 코드가 있어야 할 것 같지만, 그러한 코드가 없어도 영속성 컨텍스트 내의 스냅샷과 엔티티를 비교해 변경된 엔티티가 있으면 Update 쿼리를 자동으로 생성한다. 물론 이 Update 쿼리도 쓰기 지연이 될 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin(); // [트랜잭션] 시작

// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);

//em.update(member) 이런 코드가 있어야 하지 않을까?

transaction.commit(); // [트랜잭션] 커밋
</code></pre></div></div>

<h3 id="지연-로딩lazy-loading">지연 로딩(Lazy Loading)</h3>

<p>지연 로딩은 연관 관계 매핑되어 있는 엔티티를 조회 시 우선 프록시 객체를 반환하고, 실제로 필요할 때 쿼리를 날려 가져오는 기능이다. 즉, 필요할 때 데이터를 가져오는 기능이다.</p>

<h2 id="플러시">플러시</h2>

<p>트랜잭션 커밋을 실행하면 변경 내용을 데이터베이스에 반영하게 된다. 트랜잭션 커밋이 일어날 때 플러시도 함께 발생한다. 플러시(flush())는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다. 영속성 컨텍스트의 엔티티를 지우는 게 아니라 변경 내용을 데이터베이스에 동기화하는 것이다.​</p>

<h3 id="플러시의-흐름">플러시의 흐름</h3>

<ol>
  <li>변경 감지가 동작해서 스냅샷과 비교해서 수정된 엔티티를 찾는다.</li>
  <li>수정된 엔티티에 대해서 수정 쿼리를 만들고 SQL 저장소에 등록한다.</li>
  <li>쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다.</li>
</ol>

<h3 id="플러시-하는-방법">플러시 하는 방법</h3>

<ol>
  <li>em.flush()</li>
  <li>트랜잭션 커밋 시 자동 호출, tx.commit()</li>
  <li>JPQL 쿼리 실행 시 자동 호출</li>
</ol>

<hr />

<h1 id="영속성-컨텍스트는-n1과-어떤-관계가-있을까">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔</h1>

<p>N+1 문제는 흔히 “지연 로딩(Lazy Loading) 때문에 발생한다.”로 알고 있다. 그러나 조금 더 깊이 들어가 보면, N+1은 단순히 Lazy 옵션 때문이 아니라 영속성 컨텍스트의 동작 방식과 밀접하게 연결된 문제다.</p>

<h2 id="영속성-컨텍스트-1">영속성 컨텍스트</h2>

<p>영속성 컨텍스트는 엔티티를 관리하는 트랜잭션 단위의 관리 공간이다. 조금 더 구체적으로 보면, 다음과 같은 역할을 한다.</p>

<ul>
  <li>1차 캐시</li>
  <li>동일성 보장</li>
  <li>변경 감지(Dirty Checking)</li>
  <li>쓰기 지연(Write-Behind)</li>
</ul>

<p>Hibernate에서는 이 영속성 컨텍스트가 Session 내부에 구현되어 있다. 즉, 우리가 엔티티를 조회하면 단순히 DB에서 가져오는 것이 아니라 영속성 컨텍스트가 관리하는 객체로 로딩된다.</p>

<h2 id="지연-로딩은-어디에서-동작할까">지연 로딩은 어디에서 동작할까?</h2>

<p>지연 로딩된 연관 엔티티는 실제 객체가 아니라 프록시 객체로 주입된다. 그리고 우리가 해당 필드에 접근하는 순간 다음처럼 동작한다.</p>

<ol>
  <li>프록시가 현재 영속성 컨텍스트에 접근하고 1차 캐시에 해당 엔티티가 있는지 확인한다.</li>
  <li>없다면 SELECT 쿼리를 실행한다.</li>
</ol>

<p>이 과정은 영속성 컨텍스트 안에서 일어난다.</p>

<h2 id="n1이-발생하는-이유">N+1이 발생하는 이유</h2>

<p>예를 들어, 부모 엔티티를 한 번에 조회했다고 가정해보자.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>List&lt;Order&gt; orders = orderRepository.findAll();
</code></pre></div></div>

<p>이 시점에서는 아직 연관된 Member는 로딩되지 않았다.(지연 로딩이기 때문에 프록시 상태) 그리고 반복문에서 접근한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>for (Order order : orders) {
    System.out.println(order.getMember().getName());
}
</code></pre></div></div>

<p>이 때, 각 order.getMember() 호출 시 영속성 컨텍스트가 매번 초기화를 시도하고 1차 캐시에 없다면 SELECT 쿼리가 N번 실행된다. 즉, 지연 로딩 + 영속성 컨텍스트 내부 초기화 과정이 엔티티 수만큼 반복하며 N+1가 발생한다.</p>

<h2 id="영속성-컨텍스트-범위가-중요한-이유">영속성 컨텍스트 “범위”가 중요한 이유</h2>

<p>영속성 컨텍스트는 보통 트랜잭션 단위로 생성된다. 트랜잭션이 살아있는 동안 프록시는 정상적으로 초기화되고 필요할 때마다 DB에 접근한다. 만약 반복문 안에서 지연 로딩 필드를 계속 호출한다면, 같은 영속성 컨텍스트 내에서 계속 쿼리가 발생한다. 반대로 트랜잭션이 종료된 뒤 접근하면 LazyInitializationException이 발생한다. 이 역시 영속성 컨텍스트와 직접적으로 연결된 동작이다.</p>

<h2 id="n1은-무엇의-문제일까">N+1은 무엇의 문제일까?</h2>

<p>N+1은 단순히 “Lazy를 썼기 때문”이 아니다. 정확히는 <strong>지연 로딩된 프록시가 영속성 컨텍스트 안에서 반복 초기화되면서 발생하는 구조적 문제</strong>이다. 즉, Lazy는 트리거이고 영속성 컨텍스트는 그 동작이 수행되는 공간이다. 영속성 컨텍스트의 생명주기와 내부 동작을 이해하지 못하면 N+1은 언제든지 다시 나타난다.</p>

<h1 id="결론">결론</h1>

<p>N+1은 쿼리 최적화 문제가 아니라, 영속성 컨텍스트의 동작 원리를 이해했는지 묻는 질문에 가깝다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://blog.naver.com/le_voyage_03/224092290262">스프링 H2(+ Hibernate)</a><br />
<a href="https://velog.io/@neptunes032/JPA-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%9E%80">JPA 영속성 컨텍스트란?</a><br />
<a href="https://brightstarit.tistory.com/23">[JPA] 영속성 컨텍스트(persistence context)란?</a><br />
<a href="https://ittrue.tistory.com/254">[JPA] 영속성 컨텍스트(Persistence Context)란? - 개넘 정리 및 사용법</a><br />
<a href="https://velog.io/@jinyeong-afk/%EA%B8%B0%EC%88%A0-%EB%A9%B4%EC%A0%91-%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EB%9E%80">[기술 면접] 영속성 컨텍스트란?</a><br />
<a href="https://incheol-jung.gitbook.io/docs/q-and-a/spring/persistence-context">영속성 컨텍스트(Persistence Context)</a><br />
<a href="https://khdscor.tistory.com/110">JPA 영속성 컨텍스트는 어떻게 사용되는가</a><br />
<a href="https://jhzlo.tistory.com/68">JPA 영속성 컨텍스트 내부 동작의 흐름 깊게 파헤치기</a><br />
<a href="https://jakarta.ee/specifications/persistence/3.2/">Jakarta Persistence 3.2</a><br />
<a href="https://docs.hibernate.org/orm/current/userguide/html_single/">Hibernate ORM User Guide</a><br />
<a href="https://docs.hibernate.org/orm/current/userguide/html_single/#pc">6. Persistence Context</a><br />
<a href="https://docs.hibernate.org/orm/current/userguide/html_single/#flushing">7. Flushing</a><br />
<a href="https://github.com/hibernate/hibernate-orm">hibernate-orm</a><br /></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/3/" rel="alternate" type="text/html" title="N+1은 왜 JPA에서 발생할까?🤔(JPQL)" /><published>2026-03-01T00:00:00+00:00</published><updated>2026-03-01T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-3</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/3/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>작성 이유</li>
  <li>Spring Boot JPA</li>
  <li>JPQL</li>
  <li>N+1은 왜 JPA에서 발생할까?🤔</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="작성-이유">작성 이유</h1>

<p>저번 주 스터디에서 “JPQL을 사용하신 건가요?”란 질문에 나는 “MySQL을 사용했습니다.”라고 답했다. 하지만 실제로 사용한 것은 JPQL이었다. 글에도 분명 적혀 있었지만, 나는 그 차이를 제대로 이해하지 못하고 있었다. 이 경험을 계기로 JPQL이 무엇인지, 그리고 왜 중요한지 다시 학습해보기로 했다.</p>

<p><a href="https://zzzyoonnn.github.io/posts/2026/02/n+1-2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a></p>

<hr />

<h1 id="spring-boot-jpa">Spring Boot JPA</h1>

<p>데이터베이스를 쉽게 다루기 위한 ‘데이터 액세스 기술’로 ORM(Object-Relational Mapping) 기법을 사용하여 자바 애플리케이션에서 사용하는 객체와 관계형 데이터베이스 사이의 매핑을 관리하는 <strong>ORM 기술에 대한 API 표준 명세서(인터페이스)</strong>이다. 이 API를 사용하여 개발자가 직접적인 SQL을 작성하지 않고도 데이터베이스에서 데이터를 저장, 업데이트, 삭제, 조회하는 등의 작업을 수행할 수 있다. JPA는 표준화된 API를 제공함으로써, 다양한 ORM 프레임워크(Hibernate, EclipseLink, OpenJPA 등)와의 호환성을 보장한다. 이로 인해 개발자는 특정 ORM 프레임워크에 종속되지 않고 필요에 따라 다른 프레임워크로 쉽게 전환할 수 있다.
<img src="/images/posts/n+1/9.png" alt="JPA" /></p>

<h2 id="jparepository">JpaRepository</h2>

<p>Spring Data JPA에서 제공하는 기능 중 하나로 <strong>개발자가 데이터베이스와의 CRUD(Create, Read, Update, Delete) 연산을 더욱 쉽게 처리할 수 있도록 도와주는 인터페이스</strong> 이다. JpaRepository 인터페이스를 상속받은 인터페이스를 만들면, Spring Data JPA가 자동으로 해당 인터페이스의 구현체를 만들어서 Bean으로 등록한다. 이를 통해 개발자는 SQL 쿼리를 직접 작성하지 않고도 메서드 이름만으로 데이터베이스 연산을 수행할 수 있다.</p>

<h2 id="entity">Entity</h2>

<p>해당 Entity는 <strong>데이터베이스 컬럼과 매핑되는 객체를 구성한 공간</strong>이다. 해당 명세한 객체를 기반으로 데이터베이스 테이블 및 컬럼이 생성된다. Entity 구성 이후 서버를 수행하였을 시 구성한 객체를 기반으로 테이블이 생성된다.</p>

<h3 id="해당-entity-구성에-사용된-jpa-어노테이션">해당 Entity 구성에 사용된 JPA 어노테이션</h3>

<table>
  <thead>
    <tr>
      <th>어노테이션</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>@Entity</td>
      <td>JPA가 관리하는 Entity 클래스임을 지정한다. 이 어노테이션이 붙은 클래스는 JPA가 관리하며, 테이블과 매핑된다.</td>
    </tr>
    <tr>
      <td>@Table</td>
      <td>Entity 클래스가 매핑될 테이블을 지정한다.</td>
    </tr>
    <tr>
      <td>@Column</td>
      <td>필드가 매핑될 테이블의 컬럼을 지정한다.</td>
    </tr>
    <tr>
      <td>@Id</td>
      <td>해당 필드가 테이블의 주키(primary key) 역할을 하는 필드임을 지정한다.</td>
    </tr>
    <tr>
      <td>@GeneratedValue</td>
      <td>주키의 생성 전략을 지정한다.</td>
    </tr>
  </tbody>
</table>

<h2 id="디렉터리-구조">디렉터리 구조</h2>

<table>
  <thead>
    <tr>
      <th>디렉터리</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>controller</td>
      <td>사용자의 요청을 처리하는 레이어로, HTTP 요청을 받아 서비스 레이어에 작업을 위임하고 결과를 클라이언트에게 전달한다.</td>
    </tr>
    <tr>
      <td>entity</td>
      <td>데이터베이스 테이블에 해당하는 클래스를 담는 레이어로, 이 클래스는 데이터베이스의 테이블과 매핑된다.</td>
    </tr>
    <tr>
      <td>repository</td>
      <td>데이터베이스와 관련된 연산을 처리하는 레이어로, 데이터베이스 CRUD(Create, Read, Update, Delete) 연산이 이 레이어에서 수행된다.</td>
    </tr>
    <tr>
      <td>service</td>
      <td>비즈니스 로직을 담당하는 레이어로, 컨트롤러로부터 요청을 받아 필요한 데이터를 리포지토리에서 가져와 처리하고 결과를 컨트롤러에게 반환한다.</td>
    </tr>
    <tr>
      <td>service.Impl</td>
      <td>서비스 인터페이스를 구현하는 클래스를 담는 레이어로, 이 레이어에는 실제 비즈니스 로직이 구현되어 있다.</td>
    </tr>
  </tbody>
</table>

<hr />

<h1 id="jpqljava-persistence-query-language">JPQL(Java Persistence Query Language)</h1>

<p><strong>SQL을 기반으로 한 객체 모델용 쿼리 언어</strong>이다. SQL과 매우 유사한 형태지만 데이터베이스 ‘테이블과 컬럼’이 아닌 ‘자바 클래스와 변수(객체)’에 작업을 수행한다는 점에서 차이가 있다. 그렇기에 데이터베이스 테이블을 대상으로 쿼리 하는 것이 아닌 엔티티(객체)를 대상으로 쿼리를 수행한다. JPQL은 SQL문과 형태가 비슷하다.
​</p>

<h2 id="특징">특징</h2>

<h3 id="기본적인-연산-지원">기본적인 연산 지원</h3>

<p>SELECT, UPDATE, DELETE, INSERT(Embeddable 클래스에 한정) 등의 기본적인 연산을 지원하며, 함수, 연산자, 키워드 등 다양한 기능을 제공한다.</p>

<h3 id="객체지향-쿼리-언어">객체지향 쿼리 언어</h3>

<p>자바의 특성을 최대한 활용할 수 있으며, 쿼리 결과를 객체 또는 객체의 컬렉션으로 직접 반환받을 수 있다.</p>

<h3 id="타입-안정성-제공">타입 안정성 제공</h3>

<p>컴파일 시점에 쿼리의 문법 오류를 검사할 수 있다.</p>

<h2 id="jparepository와-비교">JpaRepository와 비교</h2>

<table>
  <thead>
    <tr>
      <th>분류</th>
      <th>JpaRepository</th>
      <th>JPQL</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>정의</td>
      <td>Spring Data JPA에서 제공하는 인터페이스로 개발자가 JPA를 더 쉽고 편하게 사용할 수 있게 도와준다.</td>
      <td>Java Persistence Query Language의 약자로, SQL을 기반으로 한 객체 모델용 쿼리 언어이다.</td>
    </tr>
    <tr>
      <td>사용 용이성</td>
      <td>JpaRepository를 상속받은 Repository 인터페이스를 생성함으로써 간단하게 CRUD 기능을 사용할 수 있다.</td>
      <td>SQL과 비슷한 문법을 가지며, 객체 지향적인 쿼리를 작성할 수 있다.</td>
    </tr>
    <tr>
      <td>유연성</td>
      <td>Spring Data JPA에 의해 구현되므로, 개발자는 쿼리를 직접 작성하지 않아도 된다. 하지만 이는 복잡한 쿼리를 작성하는데 한계가 있을 수 있다.</td>
      <td>개발자가 직접 쿼리를 작성하므로 상황에 따른 복잡한 쿼리 작성이 가능하다.</td>
    </tr>
    <tr>
      <td>호환성</td>
      <td>JpaRepository는 JPA를 기반으로 하므로 JPA를 지원하는 모든 데이터베이스 시스템과 호환된다.</td>
      <td>JPQL도 JPA를 기반으로 하므로, JPA를 지원하는 모든 데이터베이스 시스템과 호환된다.</td>
    </tr>
  </tbody>
</table>

<h2 id="jpql-처리방식">JPQL 처리방식</h2>

<ol>
  <li>리턴 타입 ‘쿼리 타입’을 지정한다.(TypedQuery, Query)</li>
  <li>쿼리를 구성한다.(createQuery)</li>
  <li>쿼리 프로젝션을 설정한다.(엔티티, 임베디드 타입, 스칼라 타입 프로젝션)</li>
  <li>쿼리 파라미터를 지정한다.(이름 기준, 위치 기준)</li>
  <li>쿼리 결과를 조회 방식을 선택한다.(getSingleResult(), getResultList())</li>
</ol>

<hr />

<h1 id="n1은-왜-jpa에서-발생할까">N+1은 왜 JPA에서 발생할까?🤔</h1>

<p>JPQL(Java Persistence Query Language)을 사용하다 보면 가장 흔하게 마주치는 문제가 N+1 문제이다.</p>

<h2 id="n1문제">N+1문제</h2>

<p>쿼리 1번으로 N개의 데이터를 가져왔는데, 관련 데이터를 더 가져오기 위해 N번의 쿼리가 추가로 실행되는 현상이다.</p>

<p>예를 들어, ‘팀(Team)’과 ‘멤버(Member)’가 1:N 관계라고 가정하자.</p>

<ul>
  <li>1(기본 쿼리)
    <ul>
      <li>모든 멤버를 조회한다.(SELECT * FROM Member)</li>
    </ul>
  </li>
  <li>N(추가 쿼리)
    <ul>
      <li>조회된 각 멤버가 속한 팀의 이름을 알고 싶어서 각 멤버마다 팀 조회 쿼리를 날린다.(SELECT * FROM Team WHERE id = ?)</li>
    </ul>
  </li>
</ul>

<p>결과적으로 데이터가 100건이라면 쿼리는 1 + 100 = 101번 실행된다.</p>

<p>JPQL은 객체 기준으로 조회하고, 연관관계는 기본적으로 즉시 조인하지 않기 때문에 N+1 문제가 발생할 수 있다.</p>

<h2 id="발생-이유">발생 이유</h2>

<h3 id="jpql의-특성">JPQL의 특성</h3>

<p>JPQL은 작성된 SQL 형태 그대로를 먼저 해석해서 SQL을 생성한다. SELECT m FROM Member m이라는 JPQL을 실행하면, JPA는 일단 SELECT * FROM MEMBER를 실행한다.</p>

<h3 id="연관-데이터-로딩">연관 데이터 로딩</h3>

<ul>
  <li>EAGER(즉시 로딩): 데이터를 가져온 직후, 연관된 엔티티가 비어있는 것을 보고 즉시 N번의 추가 쿼리를 날린다.</li>
  <li>LAZY(지연 로딩): 일단은 프록시 객체로 채워두지만, 나중에 루프를 돌며 실제 데이터를 사용하는 시점에 N번의 추가 쿼리가 나간다.</li>
</ul>

<p>결국 어떤 로딩 전략을 쓰든 JPQL만으로는 N+1 문제를 원천 봉쇄할 수 없다.</p>

<h2 id="해결-방법">해결 방법</h2>

<h3 id="fetch-join">Fetch Join</h3>

<p>JPQL의 JOIN FETCH 키워드를 사용하여 연관된 엔티티를 한 번의 SQL Join으로 다 가져온다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SELECT m FROM Member m JOIN FETCH m.team
</code></pre></div></div>

<h3 id="entitygraph">@EntityGraph</h3>

<p>어노테이션을 통해 어떤 연관 관계를 한 번에 조회할지 명시한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@EntityGraph(attributePaths = {"team"})
@Query("select m from Member m")
List&lt;Member&gt; findAllWithTeam();
</code></pre></div></div>

<h3 id="batch-size-설정">Batch Size 설정</h3>

<p>완벽하게 1번의 쿼리로 줄이지는 못하지만, N번 나갈 쿼리를 IN절을 사용해 설정한 개수만큼 묶어서 보낸다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hibernate.default_batch_fetch_size: 100 (전역 설정)
</code></pre></div></div>

<p>이렇게 하면 100번 나갈 쿼리가 1번으로 줄어든다. N+1 문제를 ‘N/100 + 1’ 정도로 획기적으로 줄여주는 안전장치 역할을 한다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://adjh54.tistory.com/421">[Java/JPA] Spring Boot Data JPA 이해하기 -1: ORM, JPA, Hibernate, QueryDSL 이론</a><br />
<a href="https://adjh54.tistory.com/422">[Java/JPA] Spring Boot Data JPA 이해하기 -2 : 초기 환경 구성 + JpaRepository 활용 방법</a></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/4/" rel="alternate" type="text/html" title="N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)" /><published>2026-03-01T00:00:00+00:00</published><updated>2026-03-01T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-4</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/4/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>들어가며..</li>
  <li>N+1은 단순히 쿼리 문제일까?🤔</li>
  <li>JVM 메모리 구조</li>
  <li>N+1 발생 시 실제 내부의 동작 과정</li>
  <li>N+1은 왜 JPA에서 발생할까?🤔</li>
  <li>결론</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="들어가며">들어가며..</h1>

<p>스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
</code></pre></div></div>

<p>Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.</p>

<hr />

<h1 id="n1은-단순히-쿼리-문제일까">N+1은 단순히 쿼리 문제일까?🤔</h1>

<p>N+1이 그저 ‘쿼리가 많이 나가서 느리다’로 알려져 있다. 하지만 실제 운영 환경에서는 다음과 같은 문제 상황이 있다.</p>

<ul>
  <li>객체가 너무 많이 생성된다</li>
  <li>영속성 컨텍스트가 비대해진다</li>
  <li>GC가 자주 돈다</li>
</ul>

<p>즉, DB 문제와 JVM 메모리 문제가 동시에 발생한다.</p>

<h1 id="jvm-메모리-구조">JVM 메모리 구조</h1>

<p><img src="/images/posts/n+1/10.png" alt="JVM 메모리 구조" /></p>

<h3 id="메서드-영역-method-area">메서드 영역 (Method Area)</h3>

<p>메서드 영역은 JVM이 시작될 때 생성되는 공간으로 바이트 코드(.class)를 처음 메모리 공간에 올릴 때 초기화되는 대상을 저장하기 위한 메모리 공간이다. 모든 쓰레드가 공유하는 메모리 영역이다. JVM이 동작하고 클래스가 로드될 때 적재되서 프로그램이 종료될 때까지 저장 된다. 메소드 영역은 클래스, 인터페이스, 메소드, 필드, Static 변수 등의 바이트 코드를 보관한다. 메서드 영역(Method Area) 은 Class Area 나 Static Area 로도 불리운다.</p>

<h3 id="힙-영역-heap-area">힙 영역 (Heap Area)</h3>

<p>힙 영역은 메서드 영역와 함께 모든 쓰레드가 공유하며, JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이다. 모든 쓰레드가 공유하며, new 키워드로 생성된 객체와 배열이 생성된다. 또한, 메소드 영역에 로드된 클래스만 생성이 가능하고 Garbage Collector가 참조되지 않는 메모리를 확인하고 제거하는 영역이다.</p>

<h3 id="스택-영역-stack-area">스택 영역 (Stack Area)</h3>

<p>메서드 호출 시마다 각각의 스택 프레임(그 메서드만을 위한 공간)이 생성된다. 스택 영역은 int, long, boolean 등 기본 자료형을 생성할 때 저장하는 공간으로, 임시적으로 사용되는 변수나 정보들이 저장되는 영역이다.</p>

<hr />

<h1 id="n1-발생-시-실제-내부의-동작-과정">N+1 발생 시 실제 내부의 동작 과정</h1>

<p>다음의 코드를 실행한다고 가정하자.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>List&lt;Order&gt; orders = orderRepository.findAll();

for (Order order : orders) {
    order.getMember().getName();
}
</code></pre></div></div>

<h3 id="1-order-조회">1. Order 조회</h3>

<p>이미 여기서 객체 N개가 생성된다.</p>

<ol>
  <li>Order 엔티티 N개 생성</li>
  <li>전부 힙 메모리에 올라감</li>
  <li>전부 영속성 컨텍스트(1차 캐시)에 저장됨</li>
</ol>

<h3 id="2-지연-로딩-트리거">2. 지연 로딩 트리거</h3>

<p>order.getMember() 호출 순간 Member도 N개 생성된다.</p>

<ol>
  <li>프록시 객체 초기화</li>
  <li>실제 Member 엔티티 생성</li>
  <li>영속성 컨텍스트에 추가</li>
  <li>힙 메모리에 추가</li>
</ol>

<h3 id="결과적으로-생성되는-객체-수">결과적으로 생성되는 객체 수</h3>

<table>
  <thead>
    <tr>
      <th>대상</th>
      <th>개수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Order 엔티티</td>
      <td>N</td>
    </tr>
    <tr>
      <td>Member 엔티티</td>
      <td>N</td>
    </tr>
    <tr>
      <td>프록시 객체</td>
      <td>N</td>
    </tr>
    <tr>
      <td>내부 Hibernate 관리 객체</td>
      <td>N 이상 (추가 메모리 사용 발생)</td>
    </tr>
  </tbody>
</table>

<p>최소 2N ~ 4N개의 객체를 생성한다. 만약 N = 10,000이면이라면 20,000~40,000개의 객체가 생성된다.</p>

<hr />

<h1 id="n1은-메모리에-어떤-영향을-미칠까">N+1은 메모리에 어떤 영향을 미칠까?🤔</h1>

<h2 id="메모리-관점에서의-n1-문제">메모리 관점에서의 N+1 문제</h2>

<h3 id="힙-메모리에-객체가-계속-쌓인다">힙 메모리에 객체가 계속 쌓인다</h3>

<p>JPA는 DB에서 가져온 데이터를 엔티티 객체로 변환해서 힙에 올린다. 예를 들어 Order 100개 조회하면 각 Order마다 Member lazy loading 발생한다. 그러면 Order 객체 100개와 Member 객체 100개(혹은 그 이상)가 전부 힙에 올라간다.</p>

<p>특히 문제는 다음과 같은 경우이다.</p>

<ul>
  <li>같은 Member를 여러 번 조회하면?</li>
  <li>JPA 1차 캐시에 없다면?</li>
  <li>세션이 길다면?</li>
</ul>

<h3 id="영속성-컨텍스트-1차-캐시도-힙이다">영속성 컨텍스트(= 1차 캐시)도 힙이다</h3>

<p>JPA의 영속성 컨텍스트는 다음과 같은 형태로 힙 메모리 안에 존재한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Map&lt;엔티티ID, 엔티티객체&gt;
</code></pre></div></div>

<p>N+1이 발생하면 다음과 같은 이유로 메모리 점유가 계속 유지된다.</p>

<ul>
  <li>엔티티가 하나씩 조회될 때마다 영속성 컨텍스트에 등록됨</li>
  <li>트랜잭션이 끝날 때까지 안 지워짐​</li>
</ul>

<h3 id="gc-부담-증가">GC 부담 증가</h3>

<p>객체가 많이 생성되면 다음과 같은 상황이 발생한다. 이는 결국 GC 압박 문제로 이어질 수 있다.</p>

<ul>
  <li>Minor GC 자주 발생</li>
  <li>Eden → Survivor → Old 영역 이동</li>
  <li>Old 영역까지 가면 Full GC 위험</li>
</ul>

<h2 id="gc-관점에서의-n1">GC 관점에서의 N+1</h2>

<p>객체가 과도하게 생성되면 다음과 같은 GC 흐름이 발생하며, 이는 트랜잭션 처리 시스템에 치명적인 성능 저하를 유발할 수 있다.</p>

<ol>
  <li>Eden 영역 가득 참</li>
  <li>Minor GC 자주 발생</li>
  <li>Survivor 영역 이동</li>
  <li>오래 살아남으면 Old 영역 승격</li>
  <li>Old 영역 차면 Full GC</li>
  <li>Full GC로 인한 STW(Stop The World)</li>
</ol>

<h2 id="fetch-join이-메모리에도-좋은-이유">Fetch Join이 메모리에도 좋은 이유</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>select o from Order o
join fetch o.member
</code></pre></div></div>

<p>객체 생성 패턴이 예측 가능해지면서 GC 압박이 완화된다.</p>

<ul>
  <li>한 번의 SQL</li>
  <li>프록시 초기화 과정 줄어듦</li>
  <li>추가 쿼리 없음</li>
  <li>불필요한 지연 초기화 없음</li>
  <li>트랜잭션 내 객체 생성 흐름이 단순해짐</li>
</ul>

<hr />

<h1 id="결론">결론</h1>

<p>N+1은 단순히 “쿼리가 많이 나간다”가 아니라 “객체 그래프를 제어하지 못해서 JVM 메모리를 비효율적으로 사용한다”는 문제이다. N+1은 힙에 객체를 대량으로 생성하므로 Fetch Join은 단순히 DB 최적화가 아니라 힙 메모리 최적화이기도 하다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://blog.naver.com/le_voyage_03/223279185867">JVM의 메모리 구조</a><br />
<a href="https://blog.naver.com/le_voyage_03/224092290262">스프링 H2(+ Hibernate)</a><br />
<a href="https://steady-coding.tistory.com/305">JVM 메모리 구조란? (JAVA)</a><br />
<a href="https://inpa.tistory.com/entry/JAVA-%E2%98%95-JVM-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-%EC%8B%AC%ED%99%94%ED%8E%B8">JVM 내부 구조 &amp; 메모리 영역 💯 총정리</a></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/2/" rel="alternate" type="text/html" title="N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)" /><published>2026-02-19T00:00:00+00:00</published><updated>2026-02-19T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-2</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/2/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>작성 이유</li>
  <li>N+1</li>
  <li>나의 코드는..🤔</li>
  <li>Fetch Join 적용 전후 성능 비교하기</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="작성-이유">작성 이유</h1>

<p>블로그 스터디를 진행하던 중 “N+1은 어떻게 해결하신건가요?”라는 질문을 받았다. 막상 설명하려니 제대로 이해하지 못한 부분도 있는 것 같아 이 기회에 정리해 보려고 한다.</p>

<hr />

<h1 id="n1">N+1</h1>

<p><img src="/images/posts/n+1/4.png" alt="N+1" />
N+1 문제는 관계형 데이터베이스(RDB)와 객체 관계 매핑(ORM, e.g JPA, Hibernate) 기술 사이의 간극에서 발생하는 대표적인 성능 이슈이다. 연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(N) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다.</p>

<ul>
  <li>1: 하위 엔티티들을 조회하기 위한 쿼리 1번</li>
  <li>N: 조회된 결과(N개)만큼 각각의 하위 엔티티를 조회하기 위해 추가로 발생하는 쿼리</li>
</ul>

<p>즉, N번 결과적으로 데이터 1건을 조회하려다 N+1번의 쿼리가 실행되어 서버 부하가 급증하는 현상이다. 한 번에 가져올 수 있는 데이터를 여러 번 나눠서 가져오므로 비효율적이다.</p>

<h2 id="예시">예시</h2>

<p>User(사용자)와 Order (주문) 구조가 있다고 가정한다. 사용자 한 명이 여러 주문을 가질 수 있는 1:N 관계이다. 코드의 의도는 “모든 사용자를 조회하고, 각 사용자의 주문도 같이 볼 수 있다.” 이다.</p>

<h3 id="동작-과정">동작 과정</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 사용자 전체 조회 쿼리 1번
SELECT * FROM user;

// 사용자 수가 10명이라면 각 사용자마다 주문 조회 쿼리 10번 실행
SELECT * FROM order WHERE user_id = 1;
SELECT * FROM order WHERE user_id = 2;
...
SELECT * FROM order WHERE user_id = 10;

// 총 11번의 쿼리 발생 = 1 + 10 -&gt; 데이터가 1,000명이면 1,001번 쿼리 발생
</code></pre></div></div>

<p>N+1 문제는 MyBatis, JdbcTemplate 등 Mapper를 사용해 JDBC를 처리할 때는 없는 현상이다. 그러나 JPA를 사용하는 경우에는 N+1 문제가 발생할 수 있다. 이는 JPA의 프록시(Proxy) 때문이다.</p>

<h2 id="프록시proxy">프록시(Proxy)</h2>

<p>JPA에서 프록시는 실제 엔티티 객체 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체를 의미한다. 가짜 객체라고 해서 실제 엔티티의 동작을 수행하지 못하는 것은 아니다. 실제 엔티티 클래스를 상속받아 만들어지므로 실제 클래스와 겉모양이 같으므로 사용하는 입장에서는 진짜 객체인지 가짜 객체인지 구분하지 않고 사용한다.
<img src="/images/posts/n+1/5.png" alt="프록시(Proxy)" />
겉모양은 같아 사용하는 입장에서는 동일하게 사용하면 되지만 내부적으로는 다른 동작을 수행한다. 프록시 객체는 실제 객체에 대한 참조를 가지고 있기에 프록시 객체의 메서드를 호출하면 프록시 객체는 참조를 통해 메서드 호출을 위임하고 실제 객체의 메서드를 호출한다.</p>

<h3 id="동작-과정-1">동작 과정</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 1. Order 조회(User는 아직 조회 X)
SELECT * FROM orders;

// 2. 코드에서 접근하는 순간(프록시 작동)
order.getUser().getName();

// 3. 프록시 입장
// "어? 진짜 객체 필요하네?"
// DB에 쿼리 날림 -&gt; 실제 User 객체로 초기화
SELECT * FROM user WHERE id = ?;
</code></pre></div></div>

<p>프록시 초기화 시 영속성 컨텍스트에 해당 엔티티가 없으면 추가 쿼리가 발생하고, 이 과정이 반복되면서 N+1 문제가 발생한다.</p>

<h3 id="특징">특징</h3>

<ul>
  <li>실제 클래스를 상속받아서 만들어진다.</li>
  <li>실제 클래스와 겉모양이 같다.</li>
  <li>사용하는 입장에서 진짜 엔티티 객체인지, 프록시 객체인지 구분하지 않고 사용한다.</li>
  <li>프록시 객체는 실제 객체의 참조(target)를 보관한다.</li>
  <li>프록시 객체를 호출하면 프록시 객체는 실제 객체의 메서드를 호출한다.</li>
  <li>프록시 객체는 처음 사용할 때 한 번만 초기화된다.</li>
  <li>프록시 객체의 초기화는 실제 엔티티 객체로의 변경을 의미하는 것이 아니라 실제 엔티티 객체의 참조를 가지는 것으로 이해할 수 있다.</li>
  <li>프록시 객체는 원본 엔티티를 상속받은 객체이므로 타입 체크 시 주의해서 사용해야 한다.</li>
  <li>영속성 컨텍스트에 이미 존재한다면 데이터베이스 조회가 필요 없고 프록시 객체 사용 필요성이 없으므로 프록시 객체가 아닌 실제 엔티티 객체를 반환한다.</li>
  <li>동일성 보장을 위해 먼저 나온 객체가 프록시 객체일 때 find를 사용하더라도 프록시 객체가 나올 수 있다.</li>
</ul>

<h2 id="n1의-영향">N+1의 영향</h2>

<h3 id="성능-저하">성능 저하</h3>

<ul>
  <li>DB 왕복 횟수 급증</li>
  <li>네트워크 비용 증가</li>
  <li>응답 속도 느려짐</li>
</ul>

<h3 id="서버-부하-증가">서버 부하 증가</h3>

<p>쿼리 수가 많아질수록 DB가 힘들어한다.</p>

<h3 id="개발자가-모르면-더-위험">개발자가 모르면 더 위험</h3>

<p>코드만 보면 한 번에 가져오는 것처럼 보이는데 실제로는 수십~수백 번 쿼리가 나간다.</p>

<h2 id="해결-방법">해결 방법</h2>

<h3 id="fetch-join-사용jpa에서-가장-대표적">Fetch Join 사용(JPA에서 가장 대표적)</h3>

<p>한 번의 쿼리로 User + Orders를 같이 가져올 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SELECT u FROM User u JOIN FETCH u.orders
</code></pre></div></div>

<h3 id="entitygraph-사용">EntityGraph 사용</h3>

<p>특정 연관 관계만 선택적으로 즉시 로딩한다.</p>

<h3 id="batch-size-설정">Batch Size 설정</h3>

<p>1개씩이 아니라 여러 개를 묶어서 조회한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@BatchSize(size = 100)
</code></pre></div></div>

<h3 id="dto-조회">DTO 조회</h3>

<p>필요한 데이터만 직접 쿼리로 가져온다.</p>

<hr />

<h1 id="나의-코드는">나의 코드는..🤔</h1>

<h2 id="테스트-코드">테스트 코드</h2>

<p>withdrawAccount가 LAZY 로딩 상태이기 때문에, 트랜잭션을 조회하는 시점에는 Account 엔티티가 함께 조회되지 않는다. 반복문에서 getWithdrawAccount()에 접근하는 순간 프록시 객체가 초기화되며 Account를 조회하는 추가 쿼리가 실행된다. 이 과정이 트랜잭션 개수(N)만큼 반복되면서 1번의 조회 쿼리 + N번의 추가 쿼리, 즉 N+1 문제가 발생한다.
<a href="https://github.com/zzzyoonnn/FinFlow-backend/blob/main/src/test/java/com/FinFlow/domain/transaction/TransactionRepositoryImplTests.java">src/test/java/com/FinFlow/domain/transaction/TransactionRepositoryImplTests.java</a></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@Test
public void findTransactionList_withdraw_test() throws Exception {
  // given
  Long accountId = 1L;

  // when
  List&lt;Transaction&gt; transactionList = transactionRepository.findTransactionList(accountId, "WITHDRAW", 0);

  transactionList.forEach(transaction -&gt; {
    // 이 부분이 있어야 N+1 발생
    System.out.println("withdrawAccount 잔액: " + transaction.getWithdrawAccount().getBalance());
    System.out.println("withdrawAccount 번호: " + transaction.getWithdrawAccount().getNumber());
  });
}
</code></pre></div></div>

<h2 id="레포지토리-코드">레포지토리 코드</h2>

<p><a href="https://github.com/zzzyoonnn/FinFlow-backend/blob/main/src/main/java/com/FinFlow/repository/TransactionRepositoryImpl.java">src/main/java/com/FinFlow/repository/TransactionRepositoryImpl.java</a></p>

<h3 id="fetch-join-적용-전">fetch join 적용 전</h3>

<p>해당 메서드는 transaction_type 값에 따라 JPQL을 동적으로 생성하여 거래 목록을 조회한다. 하지만 select t from Transaction t 구문은 Transaction 엔티티만 조회하며, 연관된 Account 엔티티는 LAZY 전략에 의해 즉시 조회되지 않는다. 따라서 이후 연관 엔티티에 접근하는 시점에 추가 쿼리가 발생하게 되며, 이것이 N+1 문제의 원인이 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@Override
public List&lt;Transaction&gt; findTransactionList(Long accountId, String transaction_type, Integer page) {
// 동적 쿼리 (transaction_type 값을 가지고 동적 쿼리 = DEPOSIT, WITHDRAW, ALL)

// JPQL
String sql = "";
sql += "select t from Transaction t ";

if (transaction_type.equals("WITHDRAW")) {
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
} else if (transaction_type.equals("DEPOSIT")) {
  sql += "where t.depositAccount.id = :depositAccountId ";
} else {
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
  sql += "or ";
  sql += "t.depositAccount.id = :depositAccountId ";
}
</code></pre></div></div>

<p>로그를 보면 Transaction을 먼저 조회한 뒤, getWithdrawAccount().getBalance()를 호출하는 순간 프록시가 초기화되면서 account를 조회하는 쿼리가 추가로 실행되었음을 확인할 수 있다.
<img src="/images/posts/n+1/6.png" alt="fetch join 적용 전" /></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: select t1_0.id,t1_0.amount,t1_0.created_at,t1_0.deposit_account_id,t1_0.deposit_account_balance,t1_0.receiver,t1_0.sender,t1_0.tel,t1_0.transaction_type,t1_0.updated_at,t1_0.withdraw_account_id,t1_0.withdraw_account_balance from account_transaction t1_0 where t1_0.withdraw_account_id=? offset ? rows fetch first ? rows only
Hibernate: select a1_0.id,a1_0.balance,a1_0.created_at,a1_0.is_active,a1_0.number,a1_0.password,a1_0.updated_at,a1_0.user_id from account a1_0 where a1_0.id=?
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
</code></pre></div></div>

<h3 id="fetch-join-적용-후">fetch join 적용 후</h3>

<p>이 코드는 transaction_type 값에 따라 동적으로 JPQL을 생성하며, 연관된 Account 엔티티를 fetch join으로 함께 조회하도록 구성하였다. 기존에는 Transaction 엔티티만 조회한 뒤, 연관된 withdrawAccount 또는 depositAccount에 접근하는 시점에 추가 쿼리가 발생하여 N+1 문제가 발생했다. 이는 연관 관계가 LAZY 로딩으로 설정되어 있기 때문이다.</p>

<p>이를 해결하기 위해 JPQL에서 join fetch를 사용하여 Transaction 조회 시점에 연관된 Account 엔티티를 함께 로딩하도록 수정했다. 이렇게 하면 연관 엔티티가 프록시로 남지 않고 즉시 초기화되므로, 이후 필드에 접근하더라도 추가 쿼리가 발생하지 않는다. 이로써 fetch join 적용 전에는 조회된 Transaction 개수만큼 추가 쿼리가 발생했지만, 적용 후에는 단일 쿼리로 모든 연관 데이터를 가져오게 되어 N+1 문제가 해결된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@Override
public List&lt;Transaction&gt; findTransactionList(Long accountId, String transaction_type, Integer page) {
// 동적 쿼리 (transaction_type 값을 가지고 동적 쿼리 = DEPOSIT, WITHDRAW, ALL)

// JPQL
String sql = "";
sql += "select t from Transaction t ";

if (transaction_type.equals("WITHDRAW")) {
  sql += "join fetch t.withdrawAccount wa ";
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
} else if (transaction_type.equals("DEPOSIT")) {
  sql += "join fetch t.depositAccount da ";
  sql += "where t.depositAccount.id = :depositAccountId ";
} else {
  sql += "left join fetch t.withdrawAccount wa ";
  sql += "left join fetch t.depositAccount da ";
  sql += "where t.withdrawAccount.id = :withdrawAccountId ";
  sql += "or ";
  sql += "t.depositAccount.id = :depositAccountId ";
}
</code></pre></div></div>

<p>로그를 보면 Transaction 조회와 동시에 Account까지 한 번에 가져옴을 확인할 수 있다.
<img src="/images/posts/n+1/7.png" alt="fetch join 적용 전" /></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: insert into account_transaction (amount,created_at,deposit_account_id,deposit_account_balance,receiver,sender,tel,transaction_type,updated_at,withdraw_account_id,withdraw_account_balance,id) values (?,?,?,?,?,?,?,?,?,?,?,default)
Hibernate: select t1_0.id,t1_0.amount,t1_0.created_at,t1_0.deposit_account_id,t1_0.deposit_account_balance,t1_0.receiver,t1_0.sender,t1_0.tel,t1_0.transaction_type,t1_0.updated_at,t1_0.withdraw_account_id,wa1_0.id,wa1_0.balance,wa1_0.created_at,wa1_0.is_active,wa1_0.number,wa1_0.password,wa1_0.updated_at,wa1_0.user_id,t1_0.withdraw_account_balance from account_transaction t1_0 join account wa1_0 on wa1_0.id=t1_0.withdraw_account_id where wa1_0.id=? offset ? rows fetch first ? rows only
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
withdrawAccount 잔액: 1000
withdrawAccount 번호: 1111111111
</code></pre></div></div>

<hr />

<h1 id="fetch-join-적용-전후-성능-비교하기">Fetch Join 적용 전후 성능 비교하기</h1>

<p>fetch join 적용 전후의 성능 차이를 명확히 비교하기 위해, 트랜잭션 생성 로직을 1000회 반복하도록 확장했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>for (int i = 0; i &lt; 1000; i ++) {
  Transaction withdrawTransaction1 = transactionRepository
          .save(newWithdrawTransaction(alicesAccount, accountRepository));
  Transaction depositTransaction1 = transactionRepository
          .save(newDepositTransaction(bobAccount, accountRepository));
  Transaction transferTransaction1 = transactionRepository
          .save(newTransferTransaction(alicesAccount, bobAccount, accountRepository));
  Transaction transferTransaction2 = transactionRepository
          .save(newTransferTransaction(alicesAccount, charlieAccount, accountRepository));
  Transaction transferTransaction3 = transactionRepository
          .save(newTransferTransaction(bobAccount, alicesAccount, accountRepository));
}
</code></pre></div></div>

<p><img src="/images/posts/n+1/8.png" alt="Fetch Join 적용 전후 성능 비교하기" />
트랜잭션 데이터를 1000회 반복 생성한 후, fetch join 적용 전후의 실행 시간을 비교하였다. 소량 데이터 환경에서는 큰 차이가 체감되지 않았지만, 1000건 이상의 트랜잭션을 조회하는 상황에서는 명확한 성능 차이를 확인할 수 있었다.</p>

<p>fetch join 적용 전에는 Transaction을 조회한 뒤, 각 연관된 Account 엔티티에 접근할 때마다 추가 쿼리가 발생하였다. 이로 인해 조회된 트랜잭션 수(N)만큼 추가 쿼리가 실행되는 N+1 Query Problem이 발생했고, 전체 실행 시간이 크게 증가하였다. 반면, fetch join을 적용한 후에는 연관된 Account 엔티티를 한 번의 쿼리로 함께 조회하므로 추가 쿼리가 발생하지 않았다. 이로써 실행 쿼리 수가 크게 감소하였고 전체 응답 시간이 눈에 띄게 단축되었으며 데이터 양이 증가할수록 성능 차이가 더욱 뚜렷하게 나타났다.</p>

<p>이번 테스트를 통해 N+1 문제는 소량 데이터에서는 잘 드러나지 않지만, 데이터가 증가할수록 성능 저하가 기하급수적으로 커질 수 있음을 확인할 수 있었다. 또한 fetch join은 이러한 문제를 해결하는 가장 직관적이고 효과적인 방법임을 검증할 수 있었다.</p>

<h2 id="소량-데이터-환경에서는-성능-차이가-적은-이유">소량 데이터 환경에서는 성능 차이가 적은 이유</h2>

<p>이는 테스트 환경의 특성 때문이다.</p>

<ul>
  <li>데이터가 너무 적다.
    <ul>
      <li>Transaction 3건, Account 4건밖에 없어서 N+1이 발생해도 추가 쿼리가 고작 1번이다.</li>
      <li>이 정도 차이는 의미있는 성능 차이를 만들지 못한다.</li>
    </ul>
  </li>
  <li>H2 인메모리 DB라 네트워크 비용이 없다.
    <ul>
      <li>실제 운영 환경에서 N+1이 위험한 이유는 쿼리 1번당 네트워크 왕복 비용이 발생하기 때문인데, H2는 같은 JVM 내에서 동작하니까 쿼리가 몇 번 더 나가도 시간 차이가 거의 없다.</li>
    </ul>
  </li>
  <li>오히려 페치조인이 더 느린 이유
    <ul>
      <li>JOIN 쿼리가 단순 단일 테이블 조회보다 무겁고, SELECT 컬럼 수도 더 많아서 데이터 전송량이 늘어나기 때문이다.</li>
    </ul>
  </li>
</ul>

<p>결론적으로 페치조인의 효과는 데이터가 수백~수천 건이고, 실제 DB에 네트워크로 연결된 운영 환경에서야 체감할 수 있다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://claude.ai/">Claude.ai</a><br />
<a href="https://tech.trenbe.com/2022/02/23/%EC%95%BC!-%EB%84%88%EB%8F%84-%ED%95%A0-%EC%88%98-%EC%9E%88%EC%96%B4,-N+1%ED%95%B4%EA%B2%B0.html">야! 너도 할 수 있어, N+1 해결</a><br />
<a href="https://dev-coco.tistory.com/165">[JPA] N+1 문제 원인 및 해결방법 알아보기</a><br />
<a href="https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1">N+1 문제</a><br />
<a href="https://woo-chang.tistory.com/28">[JPA] 프록시와 로딩 전략</a><br /></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">영화표 결제는 됐는데 예매는 안 됐다구요?😱 (Saga 패턴)</title><link href="https://zzzyoonnn.github.io/posts/2026/01/architecture/saga/" rel="alternate" type="text/html" title="영화표 결제는 됐는데 예매는 안 됐다구요?😱 (Saga 패턴)" /><published>2026-01-24T00:00:00+00:00</published><updated>2026-01-24T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/architecture/saga</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/architecture/saga/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>트랜잭션을 구현하다가 생각난 일화</li>
  <li>시스템 관점에서 본 그날의 결제 과정</li>
  <li>의심해본 개념들🧐
    <ul>
      <li>트랜잭션(Transaction)</li>
      <li>비동기(Asynchronous)</li>
      <li>스레드(Thread)</li>
      <li>결론</li>
    </ul>
  </li>
  <li>분산 트랜잭션
    <ul>
      <li>분산 트랜잭션의 핵심 원칙(ACID)</li>
      <li>분산 트랜잭션 관리 기법
        <ul>
          <li>2단계 커밋(Two-Phase Commit, 2PC)</li>
          <li>사가(Saga) 패턴</li>
          <li>2PC vs Saga 패턴 비교</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>정리</li>
</ol>

<hr />

<h1 id="트랜잭션을-구현하다가-생각난-일화">트랜잭션을 구현하다가 생각난 일화</h1>

<p>현재 프로젝트에서 트랜잭션을 구현하면서 트랜잭션에 대해 학습했다. ACID 특성, 커밋과 롤백, 그리고 “모두 성공하거나 모두 실패해야 한다”는 원칙이 트랜잭션의 특성이다.</p>

<p>학습을 진행할 수록 작년 여름에 겪었던 한 경험이 떠올랐다. 영화표를 예매하던 날이었다. 결제 수단에서는 분명 결제가 완료됐다고 나왔는데, 영화관 마이페이지에는 예매 내역이 없었다. 그땐 그저 오류라고만 생각했고, 사건 발생 약 1시간 후 자동으로 환불 처리되었다.</p>

<p><img src="/images/posts/architecture/saga/1.png" width="50%" /></p>

<p>트랜잭션을 학습하고 나니 문득 이런 의문이 들었다. ‘그때 내가 겪었던 일이 혹시 트랜잭션이었을까?’ ‘만약 트랜잭션이었다면 왜 롤백은 즉시 일어나지 않았을까?’ ‘결제는 성공했고 예매는 실패한 그 과정을 트랜잭션 개념으로 설명할 수 있을까?’ ‘롤백이 한 시간이나 걸린다는 건 잘못된 거 아닐까?’ 이 질문은 곧 또 다른 의문으로 이어졌다. ‘혹시 트랜잭션이 아니라 스레드나 비동기 처리였던 건 아닐까?’ 이런 생각에 이르러 각 개념을 다시 정리해보고, 당시의 결제 과정을 하나씩 추적해보기로 했다.</p>

<hr />

<h1 id="시스템-관점에서-본-그날의-결제-과정">시스템 관점에서 본 그날의 결제 과정</h1>

<p>기억을 바탕으로, 당시의 결제 과정을 시스템 관점에서 다시 정리해보면 다음과 같다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[사용자]
   ↓ 결제 버튼
[영화관 서버]
   ↓ 결제 요청
[결제 앱 / PG]
   → 결제 승인 완료 ✅
   ↑ 승인 응답
[영화관 서버]
   ❌ 응답 처리 중 에러 (중복 클릭 / 타임아웃 / 화면 이탈)
   ❌ 예매 데이터 생성 실패
</code></pre></div></div>

<p><img src="/images/posts/architecture/saga/2.png" width="80%" /></p>

<hr />

<h1 id="의심해본-개념들">의심해본 개념들🧐</h1>
<p>이때의 결제 과정을 이해하기 위해 의심해본 개념들은 다음과 같다.</p>

<h2 id="트랜잭션transaction">트랜잭션(Transaction)</h2>

<p>트랜잭션은 여러 작업을 하나의 묶음으로 처리하는 개념이다. 데이터베이스에서 “전부 성공 아니면 전부 실패”를 보장한다. 모든 작업이 성공하면 커밋되고, 하나라도 실패하면 이전 상태로 되돌아간다.</p>

<p>만약 영화표 결제가 하나의 트랜잭션이었다면, 예매 생성에 실패한 순간 결제 역시 즉시 취소되어야 했을 것이다. 하지만 실제로는 결제가 완료된 상태로 한 시간가량 유지되었고, 환불은 나중에 별도의 처리로 이루어졌다. 즉, 영화관 DB와 결제사 DB가 따로 놀았고, 1시간이라는 시차가 발생했다.이 부분에서 트랜잭션이라고 부르기엔 무리가 있다.</p>

<h2 id="비동기asynchronous">비동기(Asynchronous)</h2>

<p>비동기는 요청을 보낸 쪽이 결과를 기다리지 않고, 다음 작업을 계속 수행할 수 있도록 하는 처리 방식이다. 사용자는 즉각적인 응답을 받고, 실제 처리는 백그라운드에서 진행된다. 비동기는 일을 처리하는 방식일 뿐, 책임을 지는 개념이 아니다. 비동기 통신을 하다가 에러가 나면 그냥 “메시지 전달 실패”로 끝날 수도 있다.</p>

<p>결제 버튼을 누른 뒤 몇 초 후에 화면이 전환되었던 점이나, 결과가 즉시 확정되지 않았던 상황은 비동기 처리로 충분히 설명이 가능해 보였다. 하지만 비동기는 처리 방식의 문제이지, 결제와 예매라는 두 작업의 성공/실패 상태를 어떻게 일관되게 유지할지까지는 설명해주지 않는다. 비동기라는 이유만으로 “결제는 성공했고 예매는 실패했다”는 상태가 발생하지 않는다. 그리고 1시간 뒤에 굳이 다시 찾아내서 환불을 해준다는 것은, 비동기라는 수단을 이용해 누군가(트랜잭션 관리자)가 끝까지 추적해서 결과를 맞췄다는 뜻이다.</p>

<h2 id="스레드thread">스레드(Thread)</h2>

<p>스레드는 하나의 프로세스 내에서 실제로 작업을 실행하는 가장 작은 단위이다. 멀티스레드는 작업을 병렬로 처리해 성능을 높이기 위해 사용된다.</p>

<p>처음에는 버튼을 누른 것이 스레드 충돌이나 동시성 문제를 일으킨 건 아닐까 하는 생각도 들었다. 하지만 곧 이 가설은 맞지 않다는 걸 알게 됐다. 스레드는 영화관 서버 내부에서만 돌아간다. 결제 담당은 결제 앱 (외부 PG)에 있고, 티켓은 영화관 예매 시스템(내부 DB)에 있다. 즉, 결제 앱과 영화관 시스템은 같은 프로세스 안에서 동작하는 스레드가 아니라, 서로 다른 서비스, 서로 다른 시스템이다. 이 문제는 실행 흐름의 문제가 아니라, 시스템 간 상태가 어긋난 문제였다. 그리고 시스템간의 데이터 정합성을 맞추는 건 쓰레드의 관리 영역이 아니다.</p>

<h2 id="결론">결론</h2>
<p>트랜잭션도, 스레드도, 비동기 처리만으로도 이 결제 과정을 온전히 설명하기는 어려웠다. 결제는 성공했지만 예매는 실패한 상태가 일정 시간 유지되었고, 환불 역시 즉시 이루어지지 않고 한참 뒤에 처리되었다. 그러나 확실히 알 수 있는 건 이 과정이 단일 시스템 안에서 발생한 일이 아니라 서로 다른 시스템들 사이에서 일어난 일이라는 점이었다.</p>

<p>그리고 이 과정을 이해하기 위해 조사를 이어가며 분산 트랜잭션이라는 개념을 알게 되었다.</p>

<hr />

<h1 id="분산-트랜잭션">분산 트랜잭션</h1>

<p>분산 트랜잭션(Distributed transaction)은 여러 개의 네트워크 노드나 서로 다른 데이터베이스(DB)에 걸쳐 있는 데이터를 하나의 작업 단위로 묶어 처리하는 트랜잭션이다. 여러 시스템(또는 서비스)에 걸친 작업을 하나의 트랜잭션처럼 일관성 있게 처리한다. 데이터베이스는 흔한 트랜잭션 자원이며 트랜잭션은 이러한 데이터베이스들을 통과한다.</p>

<p>분산 트랜잭션은 하나의 작업이 여러 시스템에 걸쳐 실행될 때, 이 작업들을 논리적으로 하나의 트랜잭션처럼 다루려는 시도를 말한다.</p>

<h2 id="분산-트랜잭션의-핵심-원칙acid">분산 트랜잭션의 핵심 원칙(ACID)</h2>

<p>분산 환경에서도 트랜잭션은 다음의 네 가지 성질을 지켜야 한다.</p>

<ul>
  <li>원자성(Atomicity)
    <ul>
      <li>전체가 성공하거나, 전체가 실패해야 함.</li>
    </ul>
  </li>
  <li>일관성(Consistency)
    <ul>
      <li>트랜잭션 전후의 데이터 상태가 규칙에 맞아야 함.</li>
    </ul>
  </li>
  <li>격리성(Isolation)
    <ul>
      <li>동시에 실행되는 트랜잭션들이 서로 방해하면 안 됨.</li>
    </ul>
  </li>
  <li>지속성(Durability)
    <ul>
      <li>성공한 결과는 시스템 장애가 나도 보존되어야 함.</li>
    </ul>
  </li>
</ul>

<h2 id="분산-트랜잭션-관리-기법">분산 트랜잭션 관리 기법</h2>

<p>MSA 환경에서 분산 트랜잭션의 일관성을 유지하기 위한 여러 기법들이 존재한다. 여기서는 대표적인 두 가지 기법인 2단계 커밋(Two-Phase Commit, 2PC)과 사가(Saga) 패턴을 중심으로 설명한다.</p>

<h3 id="cap-정리">CAP 정리</h3>

<p>분산 시스템에서는 일관성(Consistency), 가용성(Availability), 분할 내성(Partition Tolerance) 세 가지를 동시에 완벽히 만족할 수 없다는 CAP 정리가 있다. 분산 트랜잭션을 고집하면 가용성(속도나 접속 가능성)이 떨어질 수밖에 없는 숙명을 가지고 있다.</p>

<h2 id="2단계-커밋two-phase-commit-2pc">2단계 커밋(Two-Phase Commit, 2PC)</h2>

<p>2PC는 여러 데이터베이스에 걸친 트랜잭션을 처리하기 위한 전통적인 방법이다. 트랜잭션을 처리하는 데 참여하는 모든 데이터베이스를 참여자(Participant)라고 부르며, 트랜잭션의 전체적인 진행을 관리하는 조정자(Coordinator)가 존재한다. 2PC는 조정자가 모든 참여자에게 의사를 묻는 방식이다.</p>

<h3 id="동작-과정">동작 과정</h3>
<h4 id="1-준비-단계-prepare">1. 준비 단계 (Prepare)</h4>
<p>조정자는 모든 참여자에게 트랜잭션을 준비하라는 메시지를 전송한다. 각 참여자는 자신의 트랜잭션 준비를 완료하고, 코디네이터에게 준비 완료 응답을 보낸다.</p>

<h4 id="2-커밋-단계-commit">2. 커밋 단계 (Commit)</h4>

<p>조정자는 모든 참여자로부터 준비 완료 응답을 받으면, 모든 참여자에게 트랜잭션 커밋 메시지를 전송한다. 참여자들은 메시지를 받은 후 트랜잭션을 커밋한다. 만약 준비 단계에서 한 참여자라도 실패하면, 코디네이터는 모든 참여자에게 롤백 메시지를 보내 트랜잭션을 취소한다.</p>

<h3 id="특징">특징</h3>

<p>다음과 같은 특징으로 2PC는 높은 일관성을 보장해야 하고, 데이터 불일치가 심각한 결과를 초래할 수 있는 상황에서 적합하다. 예를 들어 금융 거래 시스템과 같이 데이터의 정확성이 매우 중요한 시스템에서 사용할 수 있다.</p>

<ul>
  <li>강력한 일관성
    <ul>
      <li>2PC는 트랜잭션의 원자성을 보장하므로 모든 참여자가 성공하거나 모두 실패하는 것을 보장한다.</li>
    </ul>
  </li>
  <li>ACID 속성 준수
    <ul>
      <li>분산 환경에서도 ACID 속성(원자성, 일관성, 격리성, 지속성)을 유지할 수 있다.</li>
    </ul>
  </li>
</ul>

<h3 id="주의할-점">주의할 점</h3>
<ul>
  <li>성능 저하
    <ul>
      <li>준비 단계와 커밋 단계에서 코디네이터와 참여자 간의 통신이 필요하므로 성능 오버헤드가 발생할 수 있다.</li>
    </ul>
  </li>
  <li>가용성 문제
    <ul>
      <li>코디네이터가 실패하면 트랜잭션은 진행될 수 없고, 모든 참여자는 코디네이터가 복구될 때까지 대기해야 한다.</li>
      <li>한 참여자가 실패하면 전체 트랜잭션이 실패하므로 가용성에 문제가 발생할 수 있다.</li>
    </ul>
  </li>
  <li>확장성 문제
    <ul>
      <li>많은 참여자를 처리해야 할 때 코디네이터의 부담이 증가하여 시스템 전체의 확장성에 제약을 줄 수 있다.</li>
    </ul>
  </li>
</ul>

<h2 id="사가saga-패턴">사가(Saga) 패턴</h2>

<p>사가 패턴은 트랜잭션을 여러 개의 보상 트랜잭션(Compensation Transaction)으로 분해하고, 각 트랜잭션은 성공하거나 실패할 수 있다. 만약 하나의 트랜잭션이 실패하면, 이전에 성공한 트랜잭션들을 취소하는 보상 트랜잭션을 통해 수행한다.</p>

<h3 id="구현-방식">구현 방식</h3>

<p><img src="/images/posts/architecture/saga/3.png" alt="Saga Pattern" /></p>

<ul>
  <li>코레오그래피 사가(Choreography Saga)
    <ul>
      <li>각 서비스가 이벤트를 발행하고, 다른 서비스는 해당 이벤트를 구독하여 자신의 작업을 수행한다.</li>
    </ul>
  </li>
  <li>오케스트레이션 사가(Orchestration Saga)
    <ul>
      <li>사가 오케스트레이터가 전체 트랜잭션의 흐름을 제어하고, 각 서비스에게 작업을 지시한다.</li>
    </ul>
  </li>
</ul>

<h3 id="특징-1">특징</h3>

<p>다음과 같은 특징으로 사가 패턴은 일관성보다 가용성과 확장성이 더 중요하고, 데이터 불일치가 시스템에 큰 영향을 미치지 않는 경우에 적합하다. 예를 들어 사용자 프로필 업데이트나 상품 정보 변경과 같은 트랜잭션에 사용할 수 있다.</p>

<ul>
  <li>느슨한 결합
    <ul>
      <li>각 서비스가 독립적으로 동작하므로 서비스 간의 결합도가 낮아진다.</li>
    </ul>
  </li>
  <li>높은 가용성
    <ul>
      <li>한 서비스가 실패해도 전체 시스템에 미치는 영향이 적고, 다른 서비스들은 계속해서 작업을 수행할 수 있다.</li>
    </ul>
  </li>
  <li>확장성
    <ul>
      <li>서비스의 확장과 축소가 용이하다.</li>
    </ul>
  </li>
</ul>

<h3 id="2pc-vs-saga-패턴-비교">2PC vs Saga 패턴 비교</h3>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>2PC (Two-Phase Commit)</th>
      <th>Saga Pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>데이터 일관성</td>
      <td>매우 높음 (강한 일관성)</td>
      <td>최종적 일관성 (Eventual Consistency)</td>
    </tr>
    <tr>
      <td>성능</td>
      <td>낮음 (대기 시간 발생)</td>
      <td>높음 (비동기 방식 가능)</td>
    </tr>
    <tr>
      <td>복잡도</td>
      <td>상대적으로 낮음</td>
      <td>높음 (보상 로직 설계 필요)</td>
    </tr>
    <tr>
      <td>적합한 곳</td>
      <td>금융 결제 등 정밀한 시스템</td>
      <td>대규모 마이크로서비스 환경</td>
    </tr>
  </tbody>
</table>

<hr />

<h1 id="정리">정리</h1>

<p>분산 트랜잭션을 학습하며 과거의 경험을 다시 떠올려보니, 당시 상황을 조금 더 명확하게 이해할 수 있게 되었다. 결제와 예매는 서로 분리된 시스템에서 처리되므로, 이 과정은 하나의 트랜잭션이 아니라 분산 트랜잭션에 해당한다. 실제로 결제 트랜잭션은 성공했지만 예매 트랜잭션에서 오류가 발생했고, 그 결과 결제 성공과 예매 실패라는 상태가 동시에 존재하게 되었다. 이는 분산 트랜잭션에서의 실패가 반드시 “전체 실패”가 아니라, 부분 성공의 형태로 나타날 수 있음을 보여준다. 그리고 과정은 보상 트랜잭션의 특징이기도 하다. 앞선 단계의 트랜잭션이 성공하더라도 이후 단계에서 실패가 발생하면, 이미 완료된 작업을 되돌리기 위한 보상 작업이 수행된다. 결제가 먼저 성공한 뒤 일정 시간이 지난 후 환불이 처리된 것도 메시지 큐를 통한 보상 트랜잭션의 흐름으로 이해할 수 있다. 표로 정리하면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>상태</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>결제 승인됨</td>
    </tr>
    <tr>
      <td>2</td>
      <td>예매 생성 실패</td>
    </tr>
    <tr>
      <td>3</td>
      <td>일정 시간 동안 재시도 / 대기</td>
    </tr>
    <tr>
      <td>4</td>
      <td>끝내 예매 없음 → 보상 로직 실행</td>
    </tr>
    <tr>
      <td>5</td>
      <td>결제 취소 (환불)</td>
    </tr>
  </tbody>
</table>

<p>현실의 서비스 환경에서는 하나의 비즈니스 요청이 여러 시스템과 여러 서비스에 걸쳐 실행되는 경우가 많다. 이때 모든 작업을 하나의 트랜잭션으로 묶어 처리하는 것은 기술적·운영상의 부담이 크기 때문에, 분산 트랜잭션 구조를 선택하는 추세이다. 분산 시스템에서는 강한 정합성보다는 결과적 일관성(Eventual Consistency)이 더 중요해진다. 그만큼 사용자가 겪을 수 있는 혼란을 줄이기 위한 상태 표시, 안내 메시지, 재처리 전략과 같은 설계 요소가 중요해진다. 결제는 성공했지만 예매는 실패한 상태는 이상한 버그가 아니라, 분산 시스템에서는 충분히 발생 가능한 중간 상태였다.</p>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://ko.wikipedia.org/wiki/%EB%B6%84%EC%82%B0_%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98">분산 트랜잭션</a><br />
<a href="https://www.msap.ai/docs/msa-expert-from-concepts-to-practice/part-1-msa-fundamentals/chapter-2-core-concepts-of-msa/section-2-5-data-management/subsection-2-5-3-distributed-transactions/">이제 나도 MSA 전문가: 개념부터 실무까지</a><br />
<a href="https://junhyunny.github.io/msa/design-pattern/distributed-transaction/">사가 패턴(saga pattern)과 분산 트랜잭션(distributed transaction)</a><br />
<a href="https://technet.tmax.co.kr/upload/download/online/tibero/pver-20240502-000002/tibero_admin/chapter_07.html">제12장 분산 트랜잭션</a><br />
<a href="https://haon.blog/article/toss-slash/msa-reward-transaction/">MSA 환경에서 SAGA 패턴으로 안전한 분산 트랜잭션 구현하기</a><br />
<a href="https://ones1kk.tistory.com/entry/ETC-%EB%B6%84%EC%82%B0-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%B2%98%EB%A6%AC-%EC%A0%84%EB%9E%B5-MA-%ED%99%98%EA%B2%BD%EC%97%90-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B01">[ETC] 분산 트랜잭션</a><br />
<a href="https://tech.kakaopay.com/post/msa-transaction/">MSA 환경에서 네트워크 예외를 잘 다루는 방법</a><br />
<a href="https://f-lab.kr/insight/understanding-message-queues">메시지 큐의 이해와 실제 적용 사례</a></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="architecture" /><category term="architecture" /><category term="MSA" /><category term="Saga pattern" /><category term="Saga 패턴" /><summary type="html"><![CDATA[목차]]></summary></entry><entry><title type="html">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</title><link href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/" rel="alternate" type="text/html" title="계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔" /><published>2026-01-19T00:00:00+00:00</published><updated>2026-01-19T00:00:00+00:00</updated><id>https://zzzyoonnn.github.io/posts/2026/01/n+1/n+1-1</id><content type="html" xml:base="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/"><![CDATA[<h3 id="목차">목차</h3>

<ol>
  <li>작성 이유</li>
  <li>계정 집계(Account Aggregation) 아키텍처</li>
  <li>나의 코드는..🤔</li>
  <li>더 적절한 자료구조는 없을까?🤔</li>
  <li>자료구조의 책임은 어디까지일까?🤔</li>
  <li>금융결제원(KFTC)</li>
</ol>

<h3 id="n1기">N+1기</h3>

<p><a href="https://zzzyoonnn.github.io/posts/2026/01/n+1/1/">계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/02/n+1/2/">N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/3/">N+1은 왜 JPA에서 발생할까?🤔(JPQL)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/4/">N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/5/">영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/6/">Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔</a><br />
<a href="https://zzzyoonnn.github.io/posts/2026/03/n+1/7/">Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔</a></p>

<hr />

<h1 id="작성-이유">작성 이유</h1>

<p>블로그 스터디를 진행하던 중 “계좌가 많아질 경우, 어떻게 설계하는 게 좋을까요?”라는 질문을 받았다.</p>

<p>이에 일단 다음 두 가지 경우를 생각해냈다. 예를 들어, 현재 DB에 총 100개의 계좌가 존재한다고 가정해보자.</p>

<h3 id="한-명의-사용자가-100개의-계좌를-보유한-경우">한 명의 사용자가 100개의 계좌를 보유한 경우</h3>

<ul>
  <li>특정 사용자 기준으로 조회되는 데이터의 양이 많다</li>
  <li>계좌 목록 조회, 정렬, 페이징이 중요해진다</li>
  <li>한 객체에 데이터가 몰릴 수 있다</li>
  <li>더 나아가, 적은 유저 수 + 유저당 압도적인 계좌 수(법인 고객)</li>
</ul>

<h3 id="100명의-사용자가-각각-1개의-계좌를-보유한-경우">100명의 사용자가 각각 1개의 계좌를 보유한 경우</h3>

<ul>
  <li>사용자 수는 많지만, 사용자 단위 조회는 가볍다</li>
  <li>사용자 → 계좌 매핑을 빠르게 찾는 것이 중요하다</li>
  <li>전체 사용자 대상 조회나 집계 시 부담이 발생할 수 있다</li>
  <li>더 나아가, 압도적인 유저 수 + 유저당 소수의 계좌 수(일반 사용자)</li>
</ul>

<p>물론 이는 단순화한 경우의 수이며, 실제 서비스에서는 이 두 상황이 섞여 더 다양한 패턴이 존재할 수 있다. 하지만 “계좌가 많아진다”는 말을 조금 더 구체적으로 바라보는 출발점으로는 충분하다고 느꼈다.</p>

<p>그러나 답변하며 상황을 더 구체적으로 나눠보니 교착상태에 빠지게 되었다. 고민이 시작됐다. 계좌 존재 여부 확인에는 HashSet이 좋아 보이고 사용자 기준 접근에는 HashMap이 더 적합해 보인다.</p>

<p>그렇다면, 이 두 가지를 하나의 자료구조로 해결할 수는 없을까? 아니면, 역할에 따라 자료구조를 분리하는 것이 맞을까? 하나의 책임에 하나의 구조를 두는 게 더 나은 선택일까? 이러한 고민이 이어지면서 점점 미궁으로 빠져들었다.</p>

<hr />

<h1 id="계정-집계account-aggregation-아키텍처">계정 집계(Account Aggregation) 아키텍처</h1>

<p>유럽에서는 법적으로 Open Banking 규격이 도입되면서, 계좌 정보를 API를 통해 안전하게 공유하는 구조가 보편화되었다. 이 과정에서 자연스럽게 등장한 것이 바로 ‘계정 집계(Account Aggregation)’라는 아키텍처 패턴이다. 계정 집계 서비스는 여러 금융기관에 흩어져 있는 계좌 정보를 한 곳에서 조회·관리할 수 있도록 하며, 이를 위해 표준화된 인터페이스와 데이터 모델을 적극적으로 활용한다. 단순히 계좌를 많이 다루기 위한 구조라기보다는, 계좌 수가 늘어나도 복잡도가 급격히 증가하지 않도록 하기 위한 설계 선택에 가깝다.</p>

<p>다음과 같은 구조로 중앙 허브 기관이 없고 각 은행이 직접 API(Open Banking API)를 제공한다. 계정 집계는 <strong>핀테크 서비스가 직접 수행하여 책임과 복잡성이 각 서비스로 분산</strong>된다.</p>

<p><img src="/images/posts/n+1/1.png" alt="Open Banking APIs" /></p>

<p>이 패턴의 핵심은 다음과 같다.</p>

<ul>
  <li>API 우선 설계
    <ul>
      <li>계좌를 내부 객체가 아닌 외부에서도 접근 가능한 리소스로 정의하고, 조회 단위와 책임을 명확히 한다.</li>
    </ul>
  </li>
  <li>표준 데이터 모델 채택
    <ul>
      <li>ISO 20022와 같은 국제 표준을 사용해 계좌 구조를 통일함으로써, 계좌 수나 금융기관이 늘어나도 동일한 방식으로 처리할 수 있다.</li>
    </ul>
  </li>
  <li>사용자 동의 기반 접근 제어
    <ul>
      <li>모든 계좌 접근은 사용자 동의를 전제로 하며, 권한 범위를 명확히 제한한다.</li>
    </ul>
  </li>
</ul>

<p>이러한 접근 덕분에 유럽의 금융 시스템에서는 계좌 수의 증가를 ‘자료구조 문제’가 아닌 ‘설계와 책임 분리의 문제’로 다룬다. 결과적으로 계좌가 많아질수록 복잡해지는 것이 아니라, 오히려 확장에 유리한 구조를 유지할 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>은행 A ─┐
은행 B ─┼→ Account Aggregator (핀테크) → 사용자
은행 C ─┘
</code></pre></div></div>

<h2 id="유럽open-banking-psd2의-핵심-철학">유럽(Open Banking, PSD2)의 핵심 철학</h2>

<p>다음과 같은 이유로 계정 집계는 개념만 표준화되고, 구현은 분산되었다.</p>

<h3 id="경쟁과-혁신-중심">경쟁과 혁신 중심</h3>

<p>특정 기관에 데이터가 집중되는 것을 피한다. 핀테크가 은행과 직접 연결해 차별화된 서비스를 만들 수 있도록 허용한다.</p>

<h3 id="국가은행-구조의-다양성">국가·은행 구조의 다양성</h3>

<p>EU는 다국가·다은행이므로 환경 하나의 중앙 허브를 만들고 운영하기가 현실적으로 어렵다.</p>

<h3 id="규제는-하되-구현은-자율">규제는 하되, 구현은 자율</h3>

<p>규제 당국은 “API를 열어라”까지만 강제하고 누가 집계할지는 시장에 맡긴다.</p>

<h2 id="유럽형-서드파티">유럽형 서드파티</h2>

<p>유럽(Open Banking, PSD2)에서는 서드파티가 반드시 존재한다.</p>

<ul>
  <li>TPP (Third Party Provider)</li>
  <li>AISP (Account Information Service Provider)</li>
  <li>PISP (Payment Initiation Service Provider)</li>
</ul>

<h3 id="서드파티-역할">서드파티 역할</h3>

<ul>
  <li>은행과 직접 API로 통신</li>
  <li>계좌 데이터를 자신이 직접 집계</li>
  <li>중앙 허브에 의존하지 않음</li>
  <li>집계 책임을 서비스가 감당</li>
</ul>

<h2 id="기술적-팁">기술적 팁</h2>

<p>해시맵이나 해시셋과 같은 자료구조는 메모리 기반에서 매우 빠르지만, 계좌 정보처럼 영속적으로 저장되어야 하고 지속적으로 조회되는 데이터를 다루기에는 한계가 있다. 계좌 수와 트래픽이 증가하는 상황을 고려한다면, 결국 RDBMS를 중심으로 인덱스와 캐시 계층을 함께 설계하는 구조가 필수적이다.</p>

<p>이러한 구조에서는 <strong>도메인 주도 설계(DDD)</strong>가 특히 효과적이다. User, Account, Transaction과 같은 핵심 개념의 경계를 명확히 나누고, 각 도메인 서비스가 자신의 책임에만 집중하도록 하면, 계좌 수가 늘어나더라도 도메인 모델이 불필요하게 비대해지는 것을 막을 수 있다. 또한 계좌 정보를 외부에 제공하거나 내부 서비스 간에 조회해야 한다면, API 설계 역시 표준화된 방식을 따르는 것이 중요하다.</p>

<p>/users/{userId}/accounts/{accountId}와 같은 RESTful한 리소스 경로를 기준으로, 페이지네이션, 필터링, 버저닝을 함께 고려하면 계좌 수가 증가해도 안정적인 조회와 확장이 가능해진다.</p>

<h3 id="확장성-높은-기본-설계-구조">확장성 높은 기본 설계 구조</h3>

<table>
  <thead>
    <tr>
      <th>요구사항</th>
      <th>구조적 대응</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>적은 유저 × 많은 계좌</td>
      <td>사용자당 계좌 리스트 → 분산/파티셔닝 고려</td>
    </tr>
    <tr>
      <td>많은 유저 × 계좌 소수</td>
      <td>유저-계좌 인덱스 기반 접근</td>
    </tr>
    <tr>
      <td>계좌 정보 API 제공</td>
      <td>RESTful + Pagination + 필터링</td>
    </tr>
    <tr>
      <td>표준 준수 필요</td>
      <td>ISO 20022 / Open Banking API 적용</td>
    </tr>
  </tbody>
</table>

<hr />

<h1 id="나의-코드는">나의 코드는..🤔</h1>

<h2 id="엔티티-간-연관관계entity-association">엔티티 간 연관관계(Entity Association)</h2>

<p>Account는 User를 참조하는 단방향 연관관계를 가진다. Account 내부에 User 필드가 존재하며, User는 Account를 전혀 알지 못한다. 또한 여러 Account가 하나의 User를 참조할 수 있으므로, 이 관계는 교과서적인 단방향 Many-to-One 연관관계에 해당한다.</p>

<p><img src="/images/posts/n+1/2.png" alt="Entity Association" /></p>

<h3 id="user가-여러-account를-가지니까-one-to-many-아닌가">User가 여러 Account를 가지니까 One-to-Many 아닌가?🤔</h3>

<p>User가 여러 Account를 가지니까 One-to-Many 아닌가란 생각이 들 수도 있다. 이는 개념적으로 보면 맞는 말이다. 하지만 JPA와 객체 모델에서 연관관계를 정의할 때의 기준은 다르다.</p>

<p>객체 연관관계는 항상 다음 두 가지를 기준으로 판단한다.</p>

<ul>
  <li>누가 누구를 참조하고 있는가</li>
  <li>어디에 참조 필드가 존재하는가
이 기준으로 다시 보면, User는 Account를 전혀 참조하지 않고 Account만이 User를 참조하고 있다. 따라서 이 관계는 User 기준의 One-to-Many가 아니라, Account 기준의 Many-to-One 연관관계가 된다.</li>
</ul>

<h2 id="user와-account의-존재-여부-검증">User와 Account의 존재 여부 검증</h2>

<p>계좌 생성 흐름을 보면, 가장 먼저 다음 두 가지를 검증한다.</p>

<ul>
  <li>User가 존재하는지</li>
  <li>계좌 번호가 이미 존재하는지</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// User가 DB에 있는지 검증
User user = userRepository.findById(userId).orElseThrow(
        () -&gt; new CustomApiException("유저를 찾을 수 없습니다.")
);

// 해당 계좌가 DB에 있는 중복 여부를 체크
Optional&lt;Account&gt; accountOptional = accountRepository.findByNumber(accountSaveReqDto.getNumber());
if (accountOptional.isPresent()) {
  throw new CustomApiException("해당 계좌가 이미 존재합니다.");
}
</code></pre></div></div>

<p>이 검증은 모두 Repository를 통해 DB 조회를 기반으로 수행된다. 즉, ‘존재 여부’는 컬렉션의 문제라기보다는 영속성 계층의 책임이다. 계좌 존재 여부를 빠르게 확인하기 위해 굳이 메모리 자료구조(HashSet)를 도입하지 않아도 DB와 인덱스가 이미 그 역할을 수행하고 있다.</p>

<h2 id="account의-계좌의-행위와-검증">Account의 계좌의 행위와 검증</h2>

<p>계좌의 주인인지, 비밀번호가 맞는지, 잔액이 충분한지와 같은 검증은 모두 Account 내부에서 수행된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>public void checkOwner(Long userId) {
  if (user.getId().longValue() != userId.longValue()) {
    throw new CustomApiException("계좌 소유자가 아닙니다.");
  }
}

public void checkSamePassword(Long password) {
  if (this.password.longValue() != password.longValue()) {
    throw new CustomApiException("계좌 비밀번호 검증에 실패했습니다.");
  }
}

public void checkBalance(Long amount) {
  if (this.balance &lt; amount) {
    throw new CustomApiException("계좌 잔액이 부족합니다.");
  }
}
</code></pre></div></div>

<p>계좌의 상태(balance, owner, password)를 가장 잘 아는 객체는 Account이다. 외부에서 if 문으로 검증하는 대신, 행위와 검증을 함께 둔다. 결과적으로 도메인 로직이 흩어지지 않는다. 즉, “이 계좌로 이 행위를 해도 되는가?”라는 질문의 책임은 항상 Account 자신에게 있다.</p>

<h2 id="user의-계좌-목록">User의 계좌 목록</h2>

<p>사용자의 계좌 목록을 조회하는 로직은 다음과 같다. 여기서 계좌는 List<Account> 형태로 반환된다.</Account></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>public AccountListRespDTO findAccountsByUser(Long userId) {
  // User가 DB에 있는지 검증
  User user = userRepository.findById(userId).orElseThrow(
          () -&gt; new CustomApiException("유저를 찾을 수 없습니다.")
  );

  // 유저의 모든 계좌목록
  List&lt;Account&gt; accountList = accountRepository.findByUserId(userId);

  return new AccountListRespDTO(user, accountList);
}
</code></pre></div></div>

<p>사용자 계좌는 목록 조회 대상이다. 순서, 개수, 반복 처리, 페이징이 중요하며 이미 DB에서 중복 여부를 판단하기에 이 시점에서 중복 여부를 판단할 필요는 없다. 따라서 계좌 컬렉션은 ‘존재 여부 검증용’이 아니라 ‘표현과 조회용’이다.</p>

<h2 id="hashset--hashmap은-적절한가">HashSet + HashMap은 적절한가?🤔</h2>

<p>질문을 받았을 당시에는 다음과 같은 고민을 했다. 계좌 수가 많아질수록 존재 여부 확인과 사용자별 접근을 빠르게 처리해야 한다고 생각했기 때문이다.</p>

<ul>
  <li>계좌 존재 여부 → HashSet</li>
  <li>사용자별 계좌 접근 → HashMap&lt;User, Accounts&gt;</li>
</ul>

<p>하지만 코드를 다시 살펴보며 생각이 정리되었다. 이 시스템에서 계좌의 존재 여부와 사용자–계좌 매핑 관계는 이미 DB와 Repository가 책임지고 있었다. 그리고 도메인 계층에서는 역할이 비교적 명확하게 나뉘어 있었다. Account는 자신의 상태와 행위를 책임지고 Service는 전체 흐름을 조율하며 List는 단순히 “조회 결과”를 표현한다. 즉, 현재 프로젝트의 설계 초점은 자료구조의 선택이 아니라 책임을 어디에 둘 것인가에 맞춰져 있었다.</p>

<p>계좌가 많아질 경우를 고민하며 자료구조부터 떠올렸지만, 정작 코드 안에는 이미 어느 정도의 답이 정리되어 있었다.</p>

<ul>
  <li>존재 여부 → DB / Repository</li>
  <li>소유자/비밀번호/잔액 검증 → Account</li>
  <li>사용자 계좌 목록 → List</li>
</ul>

<p>결국 중요한 것은 어떤 자료구조를 쓰느냐보다, 역할과 책임의 경계를 명확히 나누는 일일지도 모른다.</p>

<hr />

<h1 id="더-적절한-자료구조는-없을까">더 적절한 자료구조는 없을까?🤔</h1>

<p>앞선 구현에서는 계좌 존재 여부는 DB 조회로 검증하고 사용자 계좌 목록은 List<Account>로 조회하며 계좌의 행위와 검증은 Account 도메인에 위임했다. 그렇다고 해서 "이 설계에서는 자료구조를 전혀 고민할 필요가 없다"는 뜻은 아니다. 다른 자료구조를 생각해보자.</Account></p>

<h2 id="mapuserid-list-구조">Map&lt;UserId, List<Account>&gt; 구조</Account></h2>

<p>가장 먼저 떠올릴 수 있는 대안은 사용자 기준 접근을 명확히 드러내는 구조다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Map&lt;String, List&lt;Account&gt;&gt; userAccounts;
</code></pre></div></div>

<h3 id="장점">장점</h3>

<ul>
  <li>특정 사용자 계좌 조회가 빠르다​</li>
  <li>[사용자 → 계좌들] 관계가 명확하다</li>
  <li>캐시 계층(Redis 등)과 결합하기 좋다</li>
</ul>

<h3 id="단점">단점</h3>

<ul>
  <li>계좌 추가/삭제 시 동기화 비용 발생</li>
  <li>Account 단위 조회(계좌 번호 기준)가 불편</li>
  <li>결국 DB 상태와 이중 관리 문제가 생길 수 있다</li>
</ul>

<p>그러나 이 구조는 메인 저장소라기보다는 캐시나 읽기 전용 뷰에 가까운 역할에 더 적합해 보인다.</p>

<h2 id="set--map-조합-구조">Set + Map 조합 구조</h2>

<p>초기에 떠올렸던 고민으로 다시 생각해보자.</p>

<ul>
  <li>계좌 존재 여부 → Set</li>
  <li>사용자 접근 → Map</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Set&lt;String&gt; accountNumbers;
Map&lt;Long, List&lt;Account&gt;&gt; userAccounts;
</code></pre></div></div>

<p>이 구조는 기능적으로는 가능하다. 하지만 곧 문제가 드러난다.</p>

<ul>
  <li>데이터 중복 관리</li>
  <li>동기화 실패 가능성</li>
  <li>계좌 삭제 시 두 군데 모두 수정 필요</li>
</ul>

<p>즉, 자료구조를 합친 것이 아니라 복잡성을 늘린 것에 가깝다.</p>

<hr />

<h1 id="자료구조의-책임은-어디까지일까">자료구조의 책임은 어디까지일까?🤔</h1>

<p>여기까지 탐색하며 점점 명확해진 사실은 이것이다. 계좌 수가 많아질 때의 문제는 메모리 자료구조의 문제가 아니라 조회 패턴과 책임 분리의 문제에 가깝다.</p>

<ul>
  <li>존재 여부 → DB + Unique Index</li>
  <li>사용자별 조회 → Repository 쿼리</li>
  <li>도메인 규칙 → Account</li>
</ul>

<p>이 구조는 데이터가 많아질수록 오히려 더 안정적으로 동작한다.</p>

<h2 id="그럼에도-불구하고-자료구조를-바꿔야-하는-순간은-언제일까">그럼에도 불구하고 자료구조를 바꿔야 하는 순간은 언제일까?</h2>

<p>다음과 같은 경우라면, 지금의 선택을 다시 고민해볼 수 있다.</p>

<ul>
  <li>DB 조회가 병목이 되는 경우</li>
  <li>계좌 조회 트래픽이 폭증하는 경우</li>
  <li>읽기 전용 데이터가 압도적으로 많은 경우</li>
</ul>

<p>이때는</p>

<ul>
  <li>In-memory Cach</li>
  <li>Read Model (CQRS)</li>
  <li>Redis Hash / Set
같은 보조 자료구조가 의미를 갖기 시작한다.</li>
</ul>

<hr />

<h1 id="금융결제원kftc">금융결제원(KFTC)</h1>

<p>계정 집계(Account Aggregation) 서비스는 유럽뿐만 아니라 우리나라에서도 이미 사용되고 있는 구조다. 한국에서는 이 역할을 “금융결제원(KFTC, Korea Financial Telecommunications and Clearing Institute)”이 수행하고 있다.</p>

<p><img src="/images/posts/n+1/3.png" alt="KFTC" />
대한민국 금융위원회의 오픈뱅킹(Open Banking) 정의는 은행의 금융결제 시스템을 핀테크 기업에 개방하는 것을 의미한다. 1986년에 설립된 금융결제원(KFTC)은 국내 소액결제망을 운영하며 오픈뱅킹 모델 설계를 담당했다. 이 모델은 금융결제원이 핀테크 기업과 은행을 위한 데이터 집중기관(Data Aggregator) 역할을 수행하는 통합 플랫폼으로 구성된다.</p>

<h2 id="한국형-오픈뱅킹-구조">한국형 오픈뱅킹 구조</h2>

<p>한국의 오픈뱅킹 모델은 금융결제원을 중심으로 한 통합 플랫폼 구조로 구성된다. 금융결제원은 모든 은행의 계좌 정보를 한데 모아 표준화된 API로 제공하는 중앙 허브(Hub) 역할을 수행한다. 핀테크 기업은 개별 은행과 각각 연동할 필요 없이, 금융결제원 한 곳에만 연결하면 모든 은행의 계좌 데이터에 접근할 수 있다. 즉, 금융결제원은 한국형 오픈뱅킹에서 <strong>중추적인 데이터 집합소(Centralized Account Aggregator)</strong>로 기능한다.</p>

<h2 id="kftc-등장-이유">KFTC 등장 이유</h2>

<p>일반적인 계정 집계 모델과 달리, 한국에서는 금융결제원이 중앙에 위치한 구조를 선택했다. 이는 다음과 같은 장점 때문이다.</p>

<ul>
  <li>표준화
    <ul>
      <li>모든 은행이 동일한 데이터 양식(ISO 20022 기반 등)으로 정보를 제공한다.</li>
      <li>그 결과, 계좌 수나 은행 수가 늘어나더라도 데이터 구조의 복잡도가 증가하지 않는다.</li>
    </ul>
  </li>
  <li>보안 및 통제
    <ul>
      <li>핀테크 기업이 개별 은행에 직접 접근하는 구조보다, 공신력 있는 기관인 금융결제원을 경유하는 방식이 보안과 리스크 관리 측면에서 훨씬 안정적이다.</li>
    </ul>
  </li>
  <li>효율성
    <ul>
      <li>수많은 은행 계좌를 각각 연동·관리하는 비용을 줄이고, 계좌 집계에 필요한 중복 작업을 획기적으로 감소시킨다.</li>
    </ul>
  </li>
</ul>

<p>이 구조는 계좌 집계를 단순한 기능이 아니라, 국가 단위 금융 인프라 문제로 다루었기 때문에 가능한 선택이다.</p>

<h2 id="인프라-vs-서비스">인프라 vs 서비스</h2>

<p>이 개념을 이해할 때 한 가지 구분해야 할 점이 있다.</p>

<h3 id="금융결제원인프라형-어그리게이터">금융결제원(인프라형 어그리게이터)</h3>

<ul>
  <li>모든 은행 데이터를 표준화된 API로 묶는 기술적 허브이다.</li>
  <li>소비자의 눈에는 보이지 않는다.</li>
  <li>금융 시스템의 안정성과 확장성을 책임진다.</li>
</ul>

<h3 id="핀테크-앱서비스형-어그리게이터">핀테크 앱(서비스형 어그리게이터)</h3>

<ul>
  <li>금융결제원이라는 ‘중앙 허브’에서 데이터를 받아와서 사용자에게 보여준다.</li>
  <li>e.g. 토스, 카카오페이, 뱅크샐러드처럼 소비자가 실제로 사용하는 앱들</li>
</ul>

<h3 id="참고">참고</h3>

<p><a href="https://gemini.google.com/">Gemini</a><br />
<a href="https://chatgpt.com/">ChatGPT</a><br />
<a href="https://learn.microsoft.com/ko-kr/azure/architecture/best-practices/api-design">RESTful 웹 API 디자인에 대한 모범 사례</a><br />
<a href="https://www.wallarm.com/what/open-banking-api-how-it-works-use-cases-selection-guide">Open Banking API: How It Works, Use Cases &amp; Selection Guide</a><br />
<a href="https://www.digiteal.eu/open-banking-apis-all-you-need-to-know/">Open Banking APIs: All you need to know</a><br />
<a href="https://www.bis.org/publ/othp56.pdf">API standards for data-sharing (account aggregator)</a></p>]]></content><author><name>Jiyoon Shin</name><email>zzzyoonnn@gmail.com</email></author><category term="n+1" /><category term="N+1" /><category term="Fintech" /><category term="핀테크" /><summary type="html"><![CDATA[목차]]></summary></entry></feed>