<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발공부계정</title>
    <link>https://wanna-developer02.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 23 Jul 2026 00:37:35 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>와나나나</managingEditor>
    <image>
      <title>개발공부계정</title>
      <url>https://tistory1.daumcdn.net/tistory/6446373/attach/b8a59bb2712e46d5b7e6bfe3d5ba2b79</url>
      <link>https://wanna-developer02.tistory.com</link>
    </image>
    <item>
      <title>[Spring] 요청을 보내면 어떤 일이 벌어질까? : 트랜잭션과 DB 커넥션</title>
      <link>https://wanna-developer02.tistory.com/224</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하다보면 당연하게 transaction 어노테이션을 사용한다. 트랜잭션을 쓰는 이유라고 한다면 성공하면 전체 성공, 실패하면 전체 롤백을 하는 방식으로 &lt;b&gt;원자성을 확보&lt;/b&gt;하기 위함인게 가장 크다고 생각한다. 이렇게 얄팍하게 알고있던 트랜잭션을 정리해보려고 한다. 목차는 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션을 사용하는 이유&lt;/li&gt;
&lt;li&gt;트랜잭션의 전파 레벨과 DB커넥션&lt;/li&gt;
&lt;li&gt;요청이 들어온다면 어떤일이 벌어질까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 트랜잭션을 사용하는 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션을 사용하는 이유는 앞서 말했던 원자성 확보도 있지만, 다른 이유들이 있다. 트랜잭션을 사용함으로써 보장되는 4가지가 있는데 이를 ACID라고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ACID&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;원자성 (Atomicity)&lt;/b&gt;: 트랜잭션 안의 모든 작업은 전부 성공하거나 전부 실패한다. 하나라도 실패하면 나머지도 전부 되돌린다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일관성 (Consistency)&lt;/b&gt;: 트랜잭션이 끝나면 데이터는 항상 DB가 정한 규칙(제약조건, 무결성 등)을 만족하는 상태여야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;격리성 (Isolation)&lt;/b&gt;: 동시에 실행되는 트랜잭션들은 서로의 중간 상태에 영향을 주지 않아야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지속성 (Durability)&lt;/b&gt;: 커밋이 끝난 데이터는 이후 시스템에 장애가 나도 사라지지 않아야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;✔️ @Transactional은 실제로 어떻게 동작할까&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@Transactional&lt;/b&gt;이 붙은 빈은 스프링 컨테이너에 원본 그대로 등록되지 않는다. 대신 그 빈을 감싸는 프록시 객체가 대신 등록된다. @Transactional 뿐만 아니라 @Async, @Cacheable, @PreAuthorize 등 프록시로 받아야하는 어노테이션들이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 빈이 이 서비스를 주입받아 메서드를 호출하면, 실제로는 원본이 아닌 프록시를 먼저 거친다.&lt;/p&gt;
&lt;pre id=&quot;code_1784531212477&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class OrderServiceProxy extends OrderService {
    @Override
    public void placeOrder() {
        beginTransaction();      // 커넥션 획득, autocommit=false
        try {
            super.placeOrder();  // 진짜 로직은 원본에만 있음
            commit();
        } catch (RuntimeException e) {
            rollback();
            throw e;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 트랜잭션 처리 코드는 범용적으로 작성이 되어있고, 실행시점에 지금 호출된 메서드가 뭔지만 판단하여 실행시킨다. 이렇게 트랜잭션 처리가 구현되어 있고, 알아서 실행시키기 때문에 트랜잭션 시작, 커밋, 롤백 코드를 매번 추가하지 않아도 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 처리가 프록시 객체로 일어나는 만큼, 자기참조를 하지 않도록 주의해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;self-invocation&lt;br /&gt;&lt;/b&gt;&lt;br /&gt;프록시는&lt;b&gt; 외부에서 컨테이너에게 빈을 요청하는 경로&lt;/b&gt;에만 개입한다. 즉 this를 이용해 &lt;b&gt;클래스 내부에서&amp;nbsp;&lt;/b&gt;자신의 메서드를 호출할 때는 프록시가 개입하지 않는다는 의미이다. 클래스 내부에서 메서드를 호출하면, 이미 원본객체 내부에 들어와있는 상태이고, 원본객체는 프록시에 감싸져 있다는 걸 인지하지 않기 때문에 개입하지 않는 것이다.&lt;br /&gt;따라서 자신의 메서드를 호출하는 경우, 트랜잭션이 호출되지 않을 수 있어 주의해야한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션을 어떻게 사용하는지 더 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 트랜잭션의 전파 레벨과 DB 커넥션&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 하면 대부분의 비즈니스 로직에는 @Transactional 가 붙는다. 따라서 @Transactional 메서드가 실행되는 중에 @Transactional이 붙은 또다른 메서드를 호출하는 경우가 많다.&lt;/p&gt;
&lt;pre id=&quot;code_1784531942994&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void placeOrder() {
    validateStock();      // 이것도 @Transactional
    paymentService.pay(); // 이것도 @Transactional
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 안쪽 메서드가 바깥쪽 트랜잭션에 합류해야할지, 분리해야할지 생각해보아야 하는데, 이 개념이 바로 트랜잭션의 전파(propagation) 속성이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;트랜잭션 전파 속성&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전파 속성은 다음과 같이 어노테이션에 설정하면 된다. 설정하지 않으면 기본값은 &lt;b&gt;REQIRED&lt;/b&gt;로 들어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1784532104637&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logPayment() {
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 옵션들이 있지만 가장 많이 쓰이는 속성만 소개하면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;REQUIRED (기본값)&lt;/b&gt;: 기존 트랜잭션이 있으면 참여하고, 없으면 새로 시작한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;REQUIRES_NEW&lt;/b&gt;: 기존 트랜잭션을 일시 중단하고, 완전히 독립된 새 트랜잭션을 시작한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로직이 요구하는 것에 따라 그때그때 설정해 사용하면 된다. &lt;b&gt;REQUIRES_NEW&lt;/b&gt;에 대해좀 더 자세히 풀어보려 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;REQUIRES_NEW에 대해&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REQUIRES_NEW의 &quot;일시 중단&quot;이라는 표현이 추상적으로 느껴질 수 있는데, 커넥션 풀 관점에서 생각하면 좀 더 이해가 편하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 트랜잭션이 물고 있던 커넥션 A는 &lt;b&gt;반납되지 않고 그대로 대기&lt;/b&gt; 상태가 된다.&lt;/li&gt;
&lt;li&gt;새 트랜잭션을 위해 커넥션 풀에서 커넥션 B를 &lt;b&gt;추가로&lt;/b&gt; 대출한다.&lt;/li&gt;
&lt;li&gt;새 트랜잭션이 끝나면 커넥션 B를 반납하고, 그제야 커넥션 A로 돌아가 원래 로직을 이어간다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 REQUIRES_NEW를 쓰는 순간, 그 구간 동안은 &lt;b&gt;커넥션 두 개를 동시에 점유&lt;/b&gt;하게 된다. 격리가 되니 안전해 보일 수는 있지만, 실제로는 자원을 두 배 쓰고있는 셈이다. 특히 반복문 안에서 REQUIRES_NEW 메서드를 호출하는 패턴은, 반복할 때마다 커넥션을 새로 대출하고 반납하는 비용이 누적되고, 풀 사이즈가 작을수록 대기가 빠르게 쌓인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; &lt;b&gt; DB 커넥션풀&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;DB 커넥션을 모아둔 커넥션 풀이 있고, 스프링은&amp;nbsp;HikariCP를 사용한다. 커넥션풀에는 정해진 사이즈 만큼의 커넥션이 들어있으며, 트랜잭션이 걸린 로직을 실행할 때마다 커넥션을 빌려서 쓴다. 트랜잭션이 커밋되거나 롤백되는 시점에 커넥션이 반납되기 떄문에&amp;nbsp;트랜잭션 구간의 길이가 곧 커넥션 점유 시간인 것이다.&lt;br /&gt;&lt;br /&gt;별도의 설정을 하지 않으면 커넥션풀의 크기는 보통 10이다. 커넥션 10개를 스레드가 나눠써야하니, 커넥션은 필요할때 최대한 짧게 갖고있는게 좋다. 풀에 있는 커넥션을 모두 사용중이라면, 나머지 요청은 pending 상태가 되고 타임아웃을 넘기면 예외로 터진다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;동시 요청이 발생하는 경우 - DB 격리수준&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커넥션을 붙잡고 있는 그 짧은 시간 동안, 다른 요청이 같은 데이터를 동시에 건드리면 어떤 일이 벌어질까. 이 질문에 답을 정해두는 게 격리 수준(Isolation Level)이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 접근이 만들어내는 문제는 크게 세 가지로 정리할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Dirty Read&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다른 트랜잭션이 &lt;b&gt;아직 커밋하지 않은 데이터를 읽어버리는 것&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;트랜잭션이 나중에 롤백되면, 나는 애초에 존재한 적 없는 데이터를 읽은 셈이 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Non-Repeatable Read&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 트랜잭션 안에서 같은 행을 &lt;b&gt;두 번 조회했는데 값이 달라져 있는 것&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;그 사이에 다른 트랜잭션이 값을 바꾸고 커밋해버린 경우다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Phantom Read&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 조건으로 두 번 조회했는데 조회되는 &lt;b&gt;행의 개수 자체가 달라지는 것&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;그 사이에 다른 트랜잭션이 행을 추가하거나 지운 경우다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL 표준은 이 세 문제를 얼마나 허용할지에 따라 격리 수준을 4단계로 나누며, 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;READ UNCOMMITTED&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 낮은 단계이며 다른 트랜잭션이 아직 커밋하지 않은 데이터까지 그대로 읽을 수 있다.&lt;/li&gt;
&lt;li&gt;세 가지 이상 현상(Dirty Read, Non-Repeatable Read, Phantom Read)이 전부 발생할 수 있는, 사실상 정합성을 거의 포기한 수준이라 실무에서 쓰이는 경우는 드물다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;READ COMMITTED&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;커밋된 데이터만 읽도록 보장한다. 그래서 &lt;b&gt;Dirty Read는 막을 수 있지만,&lt;/b&gt; 같은 트랜잭션 안에서 같은 행을 두 번 읽는 사이에 다른 트랜잭션이 커밋해버리면 값이 달라져 있을 수 있다&lt;b&gt;(Non-Repeatable Read). &lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Phantom Read도 마찬가지로 막지 못한다.&lt;/b&gt; Oracle, PostgreSQL 등에서 기본값으로 쓰인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;REPEATABLE READ&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션이 시작된 시점의 데이터를 트랜잭션이 끝날 때까지 &lt;b&gt;일관되게 읽도록 보장&lt;/b&gt;한다. 그래서 Dirty Read와 Non-Repeatable Read는 막지만, 표준 정의상으로는 &lt;b&gt;Phantom Read까지 막지는 못한다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 조건으로 조회했을 때 새로 추가된 행(Phantom)이 보일 수 있다는 뜻이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&amp;nbsp;MySQL(InnoDB)의 기본값이 이 단계이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SERIALIZABLE&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 엄격한 단계. 모든 트랜잭션이 마치 순서대로(직렬로) 실행된 것과 동일한 결과를 보장한다.&lt;/li&gt;
&lt;li&gt;세 이상 현상을 전부 막지만, 그만큼 락을 가장 많이, 가장 오래 잡기 때문에 동시 처리량이 크게 떨어진다.&lt;/li&gt;
&lt;li&gt;정합성이 무엇보다 중요한 극히 일부 로직에만 제한적으로 쓰인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단계가 올라갈수록 &lt;b&gt;정합성&lt;/b&gt;이 강해지고, 그만큼 락을 오래 잡아야해서 &lt;b&gt;동시 처리량이 떨어진다.&lt;/b&gt; 따라서 서비스의 특성을 파악해 설정해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 MySQL (InnoDB) 의 기본 격리 수준은 &lt;b&gt;REPEATABLE READ&amp;nbsp;&lt;/b&gt;인데 표준 &lt;b&gt;REPEATABLE READ&lt;/b&gt; 과 달리 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Phantom Read&lt;/b&gt;까지 상당 부분 막을 수 있다. 이는&lt;b&gt; InnoDB의 MVCC&lt;/b&gt; 덕분이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;MVCC (다중 버전 동시성 제어)&lt;br /&gt;&lt;/b&gt;&lt;br /&gt;MVCC는 간단히 말하면, 데이터를 수정할 때 기존 행을 바로 덮어쓰지 않고 버전을 여러 개 유지하는 방식이다. 어떤 행이 수정되면 이전 버전은 바로 사라지는 게 아니라 그대로 남아있고, &lt;b&gt;새 버전이 하나 더 생긴다.&lt;/b&gt; 그래서 트랜잭션 시작 당시 버전을 계속 읽게 해 부딪히지 않도록 한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 서버에 트랜잭션이 걸린 요청이 들어온다면&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 프록시, 전파, 커넥션 점유, 격리 수준을 따로따로 뜯어봤다. 이들이 실제로는 어떻게 한 흐름 안에서 동작하는지 처음부터 끝까지 따라가 보면서 확인해보려 한다. &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;클라이언트가 주문 생성 API를 호출했고, 서버는 @Transactional이 붙은 placeOrder()를 통해 이 요청을 처리한다고 가정하려한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1) 요청이 프록시에 도착한다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러가 orderService.placeOrder()를 호출하는 순간, 실제로 먼저 응답하는 건 원본 객체가 아니라 프록시다. 여기서 트랜잭션의 시작과 끝을 감싸는 코드가 실행될 준비를 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2) 트랜잭션이 시작되고, 커넥션이 대출된다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시는 beginTransaction()을 호출한다. 이 시점에 커넥션 풀(HikariCP)에서 커넥션 하나를 대출하고, autocommit을 false로 바꾼다. 별다른 전파 속성을 지정하지 않았다면 기본값인 REQUIRED가 적용되어, 이 요청을 위한 새 트랜잭션이 시작된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3) 로직이 실행되는 동안&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;placeOrder() 내부에서 재고를 확인하고 차감하는 로직이 실행된다. 만약 이 로직 안에서 결제 이력처럼 바깥 트랜잭션이 롤백돼도 반드시 남아야 하는 처리가 필요하다면, 여기서 &lt;b&gt;REQUIRES_NEW로 별도 트랜잭션&lt;/b&gt;을 열게 된다. 그렇게 되면 커넥션을 동시에 2개 쥐게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4) flush와 commit (실제로 DB에 반영)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로직이 끝나면 영속성 컨텍스트가 변경된 엔티티를 감지해서(dirty checking) DB에 반영할 쿼리를 만들고(flush), 트랜잭션을 커밋한다. 만약 이 트랜잭션이 조회 전용이라 readOnly = true로 선언되어 있었다면, 애초에 이 dirty checking 자체를 생략하도록 최적화가 시도된다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5) 커넥션이 반납된다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋(혹은 예외가 발생했다면 롤백)이 끝나면 커넥션은 그제야 풀로 돌아간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>백엔드/스프링</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/224</guid>
      <comments>https://wanna-developer02.tistory.com/224#entry224comment</comments>
      <pubDate>Mon, 20 Jul 2026 17:00:16 +0900</pubDate>
    </item>
    <item>
      <title>[DB] 낙관적 락과 비관적 락에 대하여</title>
      <link>https://wanna-developer02.tistory.com/223</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스를 다룰 때, 운영체제를 배웠을 때 등 공부하다보면 락(lock)이라는 개념을 자주 마주한다. 상황에 따라 낙관적 락과 비관적 락이라는 개념이 등장하는데 얄팍하게 알고있던 이 개념을 한 번 정리해 보려고 한다. 목차는 아래와 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;락이 필요한 이유&lt;/li&gt;
&lt;li&gt;낙관적 락과 비관적 락, 각각이 무엇이고 무엇을 써야할까&lt;/li&gt;
&lt;li&gt;Spring에서 락 구현하기&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 락이 필요한 이유&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같은 상황이 생겼다고 해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;9:1-9:27;329-355&quot;&gt;주문 A가 재고를 읽습니다 &amp;rarr; &quot;10개네&quot;&lt;/li&gt;
&lt;li data-sourcepos=&quot;10:1-10:27;356-382&quot;&gt;주문 B도 재고를 읽습니다 &amp;rarr; &quot;10개네&quot;&lt;/li&gt;
&lt;li data-sourcepos=&quot;11:1-11:34;383-416&quot;&gt;주문 A가 1개 팔고 저장합니다 &amp;rarr; &quot;9개로 줄여야지&quot;&lt;/li&gt;
&lt;li data-sourcepos=&quot;12:1-12:34;417-450&quot;&gt;주문 B도 1개 팔고 저장합니다 &amp;rarr; &quot;9개로 줄여야지&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로는 2개가 팔렸으나, 결과적으로 재고는 9개가 된다. 실제 서비스에서 이런 오류가 발생한다면 꽤 치명적일 것이다. 이런 일이 발생하는 이유는, 모두 같은 출발점을 읽고, 서로의 변경을 모른 채 각자 계산을 했기 때문일 것이다. 이렇게 나중 저장이 앞 저장을 덮어쓰는 현상을 &lt;b&gt;갱신 손실 (lost update)&lt;/b&gt; 라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위같은 일은 생각보다 빈번하게 일어난다. 재고 뿐 아니라 계좌 잔액, 좋아요 수, 포인트 차감 같은 데서 똑같이 발생할 수 있다. 이러한 현상을 막기 위해 락이 필요하다. 전략에 따라 두 가지 전략으로 나뉘고, 여기서 나오는 게 &lt;b&gt;낙관적 락과 비관적 락&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 낙관적 락과 비관적 락 - 정의와 활용&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름이 꽤 직관적이라 추측이 가능하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;26:1-26:62;863-924&quot;&gt;&lt;b&gt;비관적(pessimistic)&lt;/b&gt;: &quot;충돌은 자주 일어날 거야.&quot; &amp;rarr; 그러니 일단 잠그고 시작&lt;/li&gt;
&lt;li data-sourcepos=&quot;27:1-27:85;925-1009&quot;&gt;&lt;b&gt;낙관적(optimistic)&lt;/b&gt;: &quot;충돌은 거의 안 일어날 거야.&quot; &amp;rarr; 그러니 잠그지 말고 진행하다가, 저장할 때만 충돌이 났는지 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;비관적 락 (Pessimistic Lock)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적 락(Pessimistic Lock)은 트랜잭션이 충돌할 가능성이 높다고 &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;1&quot;&gt;'비관적'으로 가정&lt;/b&gt;하고, 데이터를 읽거나 수정하기 전에 &lt;b data-index-in-node=&quot;75&quot; data-path-to-node=&quot;1&quot;&gt;데이터에 대한 권한(락)을 미리 선점&lt;/b&gt;하는 방식이다. 데이터를 읽는 순간 잠가버려 작업이 끝날 때까지 다른 작업은 해당 데이터에 접근할 수 없게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면, 책을 빌리는 상황을 생각할 수 있다. 내가 어떤 책을 빌리면, 반납하기 전까지 다른 사람은 그 책을 빌리지 못하는 상황을 생각하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL 쿼리문으로는 아래와 같이 락을 걸 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1782806739692&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM product WHERE id = 1 FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비관적 락의 종류&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;공유 락 (Shared Lock / S-Lock):&lt;/b&gt; 다른 트랜잭션에서&lt;b&gt; 읽기(SELECT)는 허용&lt;/b&gt;하지만, &lt;b&gt;수정(UPDATE, DELETE)은 불가능&lt;/b&gt;하게 막는 락&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;배타 락 (Exclusive Lock / X-Lock):&lt;/b&gt; &lt;b&gt;다른 트랜잭션이 읽지도, 쓰지도 못하게&lt;/b&gt; 혼자서 독점하는 락 (주로 수정할 때 사용)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비관적 락의 장단점&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;충돌이 원천 차단되므로 갱신 손실이 발생하지 않아 &lt;b&gt;데이터 무결성&lt;/b&gt;이 보장된다.&lt;/li&gt;
&lt;li&gt;재시도 로직을 구현할 필요가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;락이 걸린 동안 다른 요청이 대기해야 하므로 &lt;b&gt;처리량이 떨어져 처리 속도가 느려진&lt;/b&gt;다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;충돌이 거의 발생하지 않는 상황에서도 잠금 비용을 치러야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데드락&lt;/b&gt;이 발생할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;낙관적 락 (Optimistic Lock) &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 락(Optimistic Lock)은 트랜잭션 대부분이 서로 충돌하지 않는다고 &lt;b data-index-in-node=&quot;47&quot; data-path-to-node=&quot;1&quot;&gt;'낙관적'으로 가정&lt;/b&gt;하는 방식이다. 무조건 락을 거는 비관적 락과 달리 저장 시점에 충돌 여부만 체크한다. 데이터베이스가 제공하는 락 메커니즘을 사용하지 않고, 코드 단에서 버전 관리를 통해 동시성을 제어한다는 특징을 갖는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터에 버전 번호를 하나 붙여두고, 저장할 때 내가 처음 읽었던 버전 번호와 동일한지 확인해 중간에 누가 끼어들었었는지를 확인한다.&lt;/p&gt;
&lt;pre id=&quot;code_1782807308415&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UPDATE product
SET stock = 9, version = version + 1
WHERE id = 1 AND version = 3;   -- 내가 읽었을 때 버전이 3이었음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;70:1-70:61;2171-2231&quot;&gt;그새 아무도 안 건드렸다면 버전은 여전히 3 &amp;rarr; 조건이 맞아 &lt;b&gt;1개 수정 성공&lt;/b&gt;, 버전은 4로 올라감&lt;/li&gt;
&lt;li data-sourcepos=&quot;71:1-71:81;2232-2312&quot;&gt;그새 누가 먼저 저장했다면 버전은 이미 4 &amp;rarr; 조건(version = 3)에 맞는 행이 없어 &lt;b&gt;0개 수정&lt;/b&gt; &amp;rarr; 충돌이 났다고 판단&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 낙관적 락은 충돌을 &lt;b&gt;막는 게 아니라 감지(detect)만 한다&lt;/b&gt;는 특징을 갖는다. 충돌을 탐지하면 그 작업은 실패시키기 때문에 재시도 로직이 추가로 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;낙관적 락의 장단점&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;평소 잠그는 비용이 거의 없어 처리 속도가 빠르다.&lt;/li&gt;
&lt;li&gt;데드락 위험이 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;충돌이 빈번하게 일어나는 경우 실패한 트랜잭션을 롤백하고 재시도 해야하기 때문에 그만큼의 서버 자원이 낭비된다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그래서 충돌이 많은 경우 비효율적이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;비관적 락과 낙관적 락, 무엇을 사용해야할까?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 내용에서 느낄 수 있었듯, &lt;b&gt;충돌이 얼마나 자주 발생하는가&lt;/b&gt;가 선택의 기준이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;충돌이 드문 경우 - 낙관적 락&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 자기 프로필을 수정하는 경우, 같은 데이터를 동시에 건드릴 일이 거의 없다. 이런 곳에 비관적 락을 쓰면 충돌도 안 날 작업들까지 매번 잠그느라 손해일 수 있어 낙관적 락이 효율적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;충돌이 잦은 경우 - 비관적 락&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인기 상품의 한정 수량 선착순처럼 같은 데이터에 요청이 몰리는 경우, 낙관적 락을 쓰면 다들 충돌나서 재시도만 반복하게 된다. 차라리 줄을 세워 처리하는게 더 빠르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 충돌이 예외적인 상황이면 낙관적 락을, 충돌이 자주 날 수 있다면 비관적 락을 사용하는 게 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  트랜잭션 격리 수준을 높이면?&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;락을 보면서 잠깐 들었던 생각은, 트랜잭션 격리 수준(isolation level)을 높이면 안되나? 였다. 여기서 격리 수준은 동시에 도는 트랜잭션이 서로의 중간 상태를 얼마나 보게 할지를 정하는 설정이다. 낮은 쪽부터 네 단계가 있고, 위로 갈수록 더 엄격하게 격리된다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;READ UNCOMMITTED&lt;/b&gt;&lt;br /&gt;가장 느슨하다. 다른 트랜잭션이 아직 커밋하지 않은 값까지 그대로 읽히기(dirty read) 때문에,&amp;nbsp; 그 값이 나중에 롤백되면, 존재한 적도 없는 데이터를 본 셈이 된다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;READ COMMITTED&lt;/b&gt;&lt;br /&gt;커밋된 값만 읽는다(dirty read 방지). 다만 한 트랜잭션 안에서 같은 행을 두 번 읽을 때, 그사이 누군가 커밋해 버리면 두 결과가 달라질 수 있다.(non-repeatable read).&lt;br /&gt;예를 들어 내가 상품 가격을 1000원으로 읽었는데, 잠시 뒤 같은 트랜잭션에서 다시 읽었더니 (그새 누가 바꿔서) 1200원이 나오는 식이다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;REPEATABLE READ&lt;/b&gt;&lt;br /&gt;한 트랜잭션 안에서 같은 행을 몇 번 읽어도 항상 같은 값이 보장된다(non-repeatable read 방지). 단, 예를 들어&lt;br /&gt;재고 100개 이상인 상품처럼 범위로 조회할 때는 없던 행이 새로 끼어드는 현상이 남을 수 있다.(phantom read).&lt;br /&gt;&lt;br /&gt;&lt;b&gt;SERIALIZABLE&lt;/b&gt;&lt;br /&gt;가장 엄격한 격리 수준으로, 여러 트랜잭션이 한 줄로 줄 서서 차례대로 실행된 것처럼 동작한다(phantom read까지 방지). 대신 그만큼 동시 처리가 느려진다.&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;SERIALIZABLE을 제외한 나머지 단계는 전부 읽기와 관련한 격리이다. 우리가 다룬 lost update는 읽고 &amp;rarr; 애플리케이션에서 계산하고 &amp;rarr; 다시 쓰는 흐름에서 생기기 때문에, 읽기 격리를 높인다고 깔끔하게 사라지지 않는다.&lt;br /&gt;&lt;br /&gt;예를 들어 MySQL의 기본 격리 수준은 REPEATABLE READ인데, 이 수준에서도 두 트랜잭션이 각자 재고 10을 읽고, 각자 9를 계산해서 쓰면 둘 다 10에서 시작한다는 건 동일하기 때문에 lost update가 그대로 일어날 수 있다.&lt;br /&gt;&lt;br /&gt;SERIALIZABLE까지 올리면 막을 수 있긴 하지만, 관계 없는 작업까지 느려지고, 재시도가 늘어나기 때문에 과한 설정일 수 있다. 따라서 락을 사용한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. Spring에서 락 구현하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA를 쓰면 두 방식을 어노테이션 몇 개로 적용이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;낙관적 락 : @Version&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티에 버전 필드를 추가하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1782808651990&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;

    private int stock;

    @Version                 // 이 한 줄이면 낙관적 락
    private Long version;     // Long, Integer, Timestamp 등 가능 (숫자형 권장)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 Product를 수정하면 JPA가 알아서 버전을 비교하고 올려준다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; @Version은 언제 올라갈까 &amp;mdash; flush 타이밍&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA에는 &lt;b&gt;영속성 컨텍스트(persistence context)&lt;/b&gt; 라는 1차 캐시가 있다. 엔티티를 조회하면 여기 올라오고, 수정하더라도 바로 DB에 반영되지 않는다. 변경사항을 모아두었다가 특정 시점에 한 번에 내보낸다. 이렇게 내보내는 것을 &lt;b&gt;flush&lt;/b&gt;라고 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-sourcepos=&quot;149:1-149:25;5591-5615&quot; data-ke-size=&quot;size16&quot;&gt;flush가 일어나는 시점은 보통 셋이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;151:1-153:22;5617-5694&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;151:1-151:22;5617-5638&quot;&gt;&lt;b&gt;트랜잭션이 커밋&lt;/b&gt;될 때&lt;/li&gt;
&lt;li data-sourcepos=&quot;152:1-152:34;5639-5672&quot;&gt;JPQL 같은&lt;b&gt; 쿼리가 실행되기 직전&lt;/b&gt;&lt;/li&gt;
&lt;li data-sourcepos=&quot;153:1-153:22;5673-5694&quot;&gt;직접&lt;b&gt; flush()를 호출&lt;/b&gt;할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;flush 시점에 JPA는 바뀐 엔티티(dirty entity)를 찾아 UPDATE 쿼리를 만든다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 알아두어야 할 것은, 버전을 개발자가 올리지 않는다는 것이다. 엔티티가 dirty 상태가 되면 JPA가 flush할 때 알아서 버전을 올려준다. flush 시점에 확인되기 때문에 충돌 감지 또한 flush 시점에 일어난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비관적 락 : @Lock&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회할 때 잠그고 읽으라고 지정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1782809044849&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface ProductRepository extends JpaRepository&amp;lt;Product, Long&amp;gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;select p from Product p where p.id = :id&quot;)
    Optional&amp;lt;Product&amp;gt; findByIdForUpdate(@Param(&quot;id&quot;) Long id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-sourcepos=&quot;198:1-198:37;7094-7130&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LockModeType&lt;/b&gt;에 따라 실제로 나가는 SQL이 달라진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;200:1-202:47;7132-7330&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;200:1-200:71;7132-7202&quot;&gt;&lt;b&gt;PESSIMISTIC_WRITE&lt;/b&gt; : SELECT ... FOR UPDATE (배타 락, 남의 읽기&amp;middot;쓰기 모두 대기)&lt;/li&gt;
&lt;li data-sourcepos=&quot;201:1-201:81;7203-7283&quot;&gt;&lt;b&gt;PESSIMISTIC_READ&lt;/b&gt; : SELECT ... FOR SHARE (공유 락, 남의 읽기는 허용&amp;middot;쓰기는 대기)&amp;nbsp;&lt;/li&gt;
&lt;li data-sourcepos=&quot;202:1-202:47;7284-7330&quot;&gt;&lt;b&gt;PESSIMISTIC_FORCE_INCREMENT&lt;/b&gt; : 배타 락 + 버전 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기다리는 시간이 무한정이면 곤란하&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;니&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;락 타임아웃&lt;/b&gt;을 줄 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1782809152519&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = &quot;jakarta.persistence.lock.timeout&quot;, value = &quot;3000&quot;)) // 3초
@Query(&quot;select p from Product p where p.id = :id&quot;)
Optional&amp;lt;Product&amp;gt; findByIdForUpdate(@Param(&quot;id&quot;) Long id);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>백엔드</category>
      <category>DB</category>
      <category>낙관적 락</category>
      <category>락</category>
      <category>비관적 락</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/223</guid>
      <comments>https://wanna-developer02.tistory.com/223#entry223comment</comments>
      <pubDate>Tue, 30 Jun 2026 17:46:45 +0900</pubDate>
    </item>
    <item>
      <title>[Spring/테스트] N+1을 테스트 코드로 막아본다면 - 쿼리 카운트 테스트 도입기</title>
      <link>https://wanna-developer02.tistory.com/222</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;N+1은 보통 사후에 발견된다. 더미데이터를 잔뜩 넣고 API 테스트를 하는 도중에 너무 느려서 발견한다던가, 코드리뷰로 발견한다던가 보통 그런식으로 발견해왔다. 물론! 처음부터 N+1이 발생하지 않도록 구현하는게 가장 좋겠지만, 놓칠때도 종종 있다. 요즘은 AI로 코드를 짜는 일이 훨씬 많기 때문에, 더더욱 놓칠때가 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번 fetch join으로 고치고 나서도, 누군가가 연관 엔티티를 lazy하게 건드리는 코드를 추가하면 또다시 N+1이 발생한다. 이런 문제를 겪다보니 문득 &lt;b&gt;테스트코드로 만들면 CI를 돌릴 때 잡을 수 있지 않을까?&lt;/b&gt; 하는 생각을 하게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로직을 짤 때 쿼리가 몇 번 나가는지를 안다면 수를 비교해서 N+1 발생을 인지할 수 있다. 쿼리가 나가는 수는 Hibernate에서 제공을 해준다! SessionFactory의 Statics가 prepared statement를 실행횟수를 갖고 있어 이것을 이용해 테스트 인프라를 만들어보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트 인프라 구축&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;세팅&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Statics는&lt;/b&gt; 기본적으로 꺼져있어서 테스트 프로파일에서 켜주어야 한다. 운영환경에서 켜면 모든 쿼리마다 카운터를 갱신해서 오버헤드만 추가되기 때문에 테스트환경에서만 넣어주면 충분하다.&lt;/p&gt;
&lt;pre id=&quot;code_1781770158279&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인프라 구축하기&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1781770107319&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Statistics statistics = em.getEntityManagerFactory()
        .unwrap(SessionFactory.class)
        .getStatistics();

statistics.clear();             // 0으로 리셋
action.run();                   // 측정 대상 실행
long actual = statistics.getPrepareStatementCount();  // 그동안 나간 SQL 수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 짜두면 이걸 헬퍼로 감싸서 테스트 할 때 사용할 수 있다! 사용 예시는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1781770350079&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;assertQueryCount(em, 1, () -&amp;gt; bookmarkService.getBookmarkedParties(memberId));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만들어두면 아주 간단하게 사용할 수 있다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트 주의사항&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 통합테스트에 얹어 실행한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트의 목적은 JPA가 최종적으로 몇 개의 쿼리를 날리는지를 측정하는 것이기 때문에, 모킹을&amp;nbsp; 사용하는 단위테스트에서는 무의미하다. 진짜 EntityManager와 커넥션이 있어야 유의미하므로, 꼭 &lt;b&gt;통합테스트&lt;/b&gt; 위에서 실행되어야 한다! 그래서 필자는 도커로 DB를 띄워서 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 영속성 컨텍스트를 초기화 해야한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 테스트코드를 짜고 돌렸는데 테스트가 실패할 때도 있고 성공할 때도 있었다. 원인을 살펴보니 영속성 컨텍스트였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JPA의 1차 캐시와 쓰기 지연&lt;/b&gt;은 SQL이 언제 실제로 나가는지를 미룬다. 이로 인해 집계가 더 적게 되거나, 더 많이 집계되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;과소 집계&lt;/b&gt;: setup에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;persist한 엔티티가 1차 캐시에 살아있으면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;action&lt;span&gt;&amp;nbsp;&lt;/span&gt;안의&lt;span&gt;&amp;nbsp;&lt;/span&gt;findById가 DB를 안 때리고 캐시에서 반환된다 &amp;rarr; SELECT가 0번 &amp;rarr; 기대값 미달로 실패&lt;/li&gt;
&lt;li&gt;&lt;b&gt;과다/지연 집계&lt;/b&gt;: 쓰기 지연된 INSERT가 측정 창 밖(커밋 시점)에서 나가거나, 반대로&lt;span&gt;&amp;nbsp;&lt;/span&gt;action&lt;span&gt;&amp;nbsp;&lt;/span&gt;안의 JPQL 직전 auto-flush로 끼어들어 카운트를 흔든다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 고민했던 건 equals로 측정하지 않고 부등호를 사용할까? 였다. N+1은 어차피 더 많은 쿼리가 나가는 경우일 것이기 때문에, 적은건 상관없지 않나? 였다. 그런데 이렇게 할 경우 아래 두 케이스를 구분할 수 없다는 단점이 있었다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;지연됐지만 커밋 때 결국 나갈 정상 쿼리&lt;/li&gt;
&lt;li&gt;비정상적으로 누락된 경우&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 그냥 영속성 컨텍스트 상태를 강제했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;측정 전&lt;span&gt;&amp;nbsp;&lt;/span&gt;em.clear()&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1차 캐시를 비움&lt;/li&gt;
&lt;li&gt;그래야&lt;span&gt;&amp;nbsp;&lt;/span&gt;action의 조회가 캐시가 아니라 진짜 DB를 때린다. (운영의 콜드 캐시 재현 &amp;rarr; 과소 집계 제거)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;측정 후&lt;span&gt;&amp;nbsp;&lt;/span&gt;em.flush()&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;action이 유발한 쓰기 지연 SQL을 카운트를 읽기&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;전에&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;강제로 내보낸다. (늦게 나가서 측정 창을 벗어나는 문제 제거)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이렇게 캐시를 비우는 경우, &lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 메서드 호출&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;이후&lt;/b&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;에 픽스처 엔티티를 다시 만지면 detach 상태라 lazy 접근에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;LazyInitializationException&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;이 날 수 있기 때문에 유의해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 CI에서 N+1을 잡을 수 있도록 테스트 인프라를 만들었다!&amp;nbsp;&lt;/p&gt;</description>
      <category>백엔드/스프링</category>
      <category>스프링</category>
      <category>쿼리카운트 테스트</category>
      <category>테스트코드</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/222</guid>
      <comments>https://wanna-developer02.tistory.com/222#entry222comment</comments>
      <pubDate>Thu, 18 Jun 2026 17:24:54 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] @OneToOne LAZY의 함정 - N+1 해결하기</title>
      <link>https://wanna-developer02.tistory.com/221</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;작년에 시작한 프로젝트를 최근 리팩토링 하기 시작했다. 지금도 감자지만 더더욱 감자일 때 작성한 코드다 보니, 리팩토링 할 부분이 많았고 오늘은 찜 관련 기능의 리팩토링을 진행했다. 우리 프로젝트에선 모임과 운동을 찜할 수 있었고, 각각의 찜 목록 조회에서&amp;nbsp; N+1 문제가 있어서 이를 해결하는 게 목적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제의 발견&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1을 해결하기 위해 주로&lt;b&gt; fetch join&lt;/b&gt;을 사용해왔고, 찜과 관련한 눈에 보이는 연관 객체들을 fetch join으로 한 번에 가져오도록 수정했다. 그러고 쿼리 카운트 테스트를 돌렸는데 실패했다. N+1문제가 해결되지 않았다는 뜻이었다.&lt;/p&gt;
&lt;pre id=&quot;code_1781692074693&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;북마크 찜 목록 N+1 회귀 테스트 &amp;gt; 찜한 운동 목록 - 찜 개수와 무관하게 고정된 쿼리 수만 실행한다 FAILED
북마크 찜 목록 N+1 회귀 테스트 &amp;gt; 찜한 모임 목록 - 찜 개수와 무관하게 고정된 쿼리 수만 실행한다 FAILED
&amp;gt; Task :test FAILED
BUILD FAILED in 2m&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 파악해보고자 SQL 쿼리문을 살펴보았는데, Party 한 개당 partyImg와 chatRoom이 함께 조회되고 있었다. 이 도메인들이 원인이었던 거다.&lt;/p&gt;
&lt;pre id=&quot;code_1781692266580&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;4753:    select
4754:        cr1_0.id,
4755:        cr1_0.created_at,
4756:        cr1_0.party_id,
4757:        cr1_0.type,
4758:        cr1_0.updated_at 
4759:    from
4760:        chat_room cr1_0 
4761:    where
4762:        cr1_0.party_id=?
4764:    select
4765:        cr1_0.id,
4766:        cr1_0.created_at,
4767:        cr1_0.party_id,
4768:        cr1_0.type,
4769:        cr1_0.updated_at 
4770:    from
4771:        chat_room cr1_0 
4772:    where
4773:        cr1_0.party_id=?
4793:    select
4794:        cr1_0.id,
4795:        cr1_0.created_at,
4796:        cr1_0.party_id,
4797:        cr1_0.type,&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 나는 둘 다&lt;b&gt; LAZY로 설정&lt;/b&gt;해두었고, 찜 목록 조회 로직에서는 &lt;b&gt;chatRoom과 partyImg에 접근하지 않아&lt;/b&gt;서 N+1이 발생하지 않아야 정상이었다. 필요하지도 않은 엔티티들을 매번 접근하다보니 발생했던 거였다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781692226130&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@OneToOne(mappedBy = &quot;party&quot;)
private PartyImg partyImg;   // LAZY인데...?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 찾아본 결과는 다음과 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;역방향 nullable OneToOne은 LAZY가 먹지 않는다&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;강제 EAGER 행 된 엔티티들&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 강제 EAGER로 적용되고 있었다. 이유는 무엇이었을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아보니 FK를 갖지 않는 쪽에 있는 엔티티는, 부모 엔티티를 만들 때 그 자리에&lt;b&gt; 프록시를 넣을지 null을 넣을지&lt;/b&gt; 정하게 된다. &lt;b&gt;nullable하지 않은 경우&lt;/b&gt;에는 어차피 null이 들어가지 않기 때문에 프록시 객체를 넣어버릴 수 있지만, &lt;b&gt;nullable한 경우&lt;/b&gt; null과 프록시 중 무엇을 넣을지 알려면, 상대 테이블을 조회해봐야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무작정 프록시를 넣게 되면, 실제로 존재하지 않는데도 not null이 반환되어 예외가 발생하게 되기 때문에, EAGER로 동작하게 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;batch 기능&amp;nbsp;&lt;/b&gt;또한 LAZY 로딩들을 모아 IN으로 묶는 기능이기 때문에, 위처럼 즉시로딩이 되는 경우 해결되지 못한다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 로직에서는 사용하지 않지 않지만 fetch join을 해서 해결했다. 보통은 사용하는 객체를 fetch join 하지만, 사용하지 않는 eager 객체까지 같이 fetch join 해서 해결했다!&lt;/p&gt;</description>
      <category>백엔드/JPA</category>
      <category>JPA</category>
      <category>N+1</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/221</guid>
      <comments>https://wanna-developer02.tistory.com/221#entry221comment</comments>
      <pubDate>Wed, 17 Jun 2026 20:36:58 +0900</pubDate>
    </item>
    <item>
      <title>[DB] 쿼리 튜닝은 무엇인가 (3) - 복합 인덱스에서 순서가 중요한 이유</title>
      <link>https://wanna-developer02.tistory.com/220</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 게시글에서는 B-tree와 B+tree가 어떤 원리인지를 배웠고, 키를 정렬된 순서로 보관한다는 것도 배웠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wanna-developer02.tistory.com/219&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wanna-developer02.tistory.com/219&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1780902499448&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[DB] 쿼리 튜닝은 무엇인가 (2) - 인덱스 (B-tree와 B+tree)&quot; data-og-description=&quot;지난 게시글에서는 쿼리 튜닝이 무엇인지에 대해 다루었고, 이번 게시글에서는 인덱스에 대해 이어서 정리하려고 한다. 지난 게시글은 아래 링크를 참고하면 된다.https://wanna-developer02.tistory.com/2&quot; data-og-host=&quot;wanna-developer02.tistory.com&quot; data-og-source-url=&quot;https://wanna-developer02.tistory.com/219&quot; data-og-url=&quot;https://wanna-developer02.tistory.com/219&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c6mgwt/dJMb9eflR9H/pOZlN8jysNCCUFhZv9IBC0/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/oRLc8/dJMb9dHv3rC/BOPXbvTDeylrxYNMq4i4xk/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/bBw1Nm/dJMb8SpPV1v/VBxE7wBPlBPxQXgHgnYcD0/img.png?width=240&amp;amp;height=240&amp;amp;face=0_0_240_240&quot;&gt;&lt;a href=&quot;https://wanna-developer02.tistory.com/219&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wanna-developer02.tistory.com/219&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c6mgwt/dJMb9eflR9H/pOZlN8jysNCCUFhZv9IBC0/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/oRLc8/dJMb9dHv3rC/BOPXbvTDeylrxYNMq4i4xk/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/bBw1Nm/dJMb8SpPV1v/VBxE7wBPlBPxQXgHgnYcD0/img.png?width=240&amp;amp;height=240&amp;amp;face=0_0_240_240');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[DB] 쿼리 튜닝은 무엇인가 (2) - 인덱스 (B-tree와 B+tree)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;지난 게시글에서는 쿼리 튜닝이 무엇인지에 대해 다루었고, 이번 게시글에서는 인덱스에 대해 이어서 정리하려고 한다. 지난 게시글은 아래 링크를 참고하면 된다.https://wanna-developer02.tistory.com/2&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wanna-developer02.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 게시글에서는 칼럼 여러개를 묶어 하나의 인덱스로 만들면 어떻게 될지 알아보려 한다. 목차는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복합 인덱스란&lt;/li&gt;
&lt;li&gt;칼럼 순서를 정하는 방법&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 복합 인덱스란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 게시글에서는 하나의 칼럼으로 인덱스를 만드는 걸 배웠는데, 여러 칼럼으로도 인덱스를 만들 수 있다. 예를 들면 (user_id, created_at) 으로 인덱스를 만들면, B+tree는 userId로 먼저 정렬 후 createdAt으로 정렬을 한다.&amp;nbsp;이렇게 &lt;b&gt;여러 칼럼으로 하나의 인덱스&lt;/b&gt;를 만든 것을 &lt;b&gt;복합인덱스&lt;/b&gt;라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 예시를 들었듯이 칼럼의 순서가 중요하다. 첫번째 칼럼으로 먼저 정렬한 후, 두번째 칼럼을 정렬한다. 즉, 첫번쨰 칼럼이 같은 그룹 안에서만 두번쨰 칼럼이 정렬되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;(user_id, created_at)&lt;span&gt; 로 복합인덱스를 만든 경우, 아래 쿼리들에는 잘 쓰인다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780902933593&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WHERE user_id = 123                          -- 1순위 컬럼으로 검색
WHERE user_id = 123 AND created_at &amp;gt; '2024'  -- 1순위 + 2순위&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아래 쿼리에서는 제대로 쓸 수 없다. 이 경우, 결국 인덱스 전체나 테이블을 훑어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780902966371&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WHERE created_at &amp;gt; '2024'   -- 2순위 컬럼 단독&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 복합 인덱스는, 가장 왼쪽 칼럼부터 연속된 접두사로 조건이 주어진 경우에만 작동한다. 이를 &lt;b&gt;왼쪽 접두사 규칙&lt;/b&gt;이라고 한다.&amp;nbsp; (a,b,c) 인덱스라면 (a,b). (a,b,c), (a) 로는 쓸 수 있지만 (b), (b,c) 이렇게는 쓰이지 않게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 규칙으로 인해 (a,b) 인덱스와 (b,a) 인덱스는 완전 다른 인덱스가 된다. 그럼 복합 인덱스는 칼럼 순서를 어떻게 정하는 게 좋을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 칼럼 순서를 정하는 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1순위 기준은&lt;b&gt; 쿼리패턴&lt;/b&gt;이다. 어떤 칼럼 조합으로 자주 검색하는지 확인한 후, &lt;b&gt;단독이나&lt;/b&gt; &lt;b&gt;접두사로 자주 등장하는 칼럼&lt;/b&gt;을 앞에 둔다. 또, &lt;b&gt;등호 조건(=) 칼럼&lt;/b&gt;을 &lt;b&gt;앞&lt;/b&gt;에, &lt;b&gt;범위 조건 (&amp;lt;, &amp;gt;, BETWEEN) 칼럼&lt;/b&gt;을 &lt;b&gt;뒤&lt;/b&gt;에 둔다. 예를 들어 확인해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780903783536&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WHERE user_id = 123 AND created_at &amp;gt; '2024-01-01'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우, (user_id, created_at) 인덱스를 쓰면 user_id = 123으로 &lt;b&gt;연속된 한 블록&lt;/b&gt;이 잡히고, 그 블록 안은 created_at으로 정렬돼 있으니 &amp;gt; '2024-01-01' 범위를 깔끔하게 이어서 읽을 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 만약 인덱스가 (created_at, user_id) 라면, created_at에 범위 조건이 먼저 걸리는 순간, 그 범위 안에서는 user_id가 더 이상 한 덩어리로 모여 있지 않다 (범위 안의 여러 날짜마다 user_id가 제각각 섞여 있다). 그래서 &lt;b&gt;범위 조건이 한 번 걸리면 그 뒤 컬럼들은 인덱스로 좁히는 능력을 잃게 된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 간단하게 인덱스에 대한 정리가 끝났다. 원리를 공부했으니, 필요한 상황에 맞게 최소한의 인덱스를 활용할 수 있도록 프로젝트를 하면서 고민해보아야겠다!&amp;nbsp;&lt;/p&gt;</description>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/220</guid>
      <comments>https://wanna-developer02.tistory.com/220#entry220comment</comments>
      <pubDate>Mon, 8 Jun 2026 16:36:08 +0900</pubDate>
    </item>
    <item>
      <title>[DB] 쿼리 튜닝은 무엇인가 (2) - 인덱스 (B-tree와 B+tree)</title>
      <link>https://wanna-developer02.tistory.com/219</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 게시글에서는 쿼리 튜닝이 무엇인지에 대해 다루었고, 이번 게시글에서는 인덱스에 대해 이어서 정리하려고 한다. 지난 게시글은 아래 링크를 참고하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wanna-developer02.tistory.com/218&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wanna-developer02.tistory.com/218&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1780844344038&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[DB] 쿼리 튜닝은 무엇인가 (1) - 랜덤 I/O와 쿼리 튜닝&quot; data-og-description=&quot;프로젝트를 진행하면서 조회 성능을 올리기 위해 인덱스를 건 적이 있다. 어떨 때는 성능이 개선되지만, 어떨 때는 오히려 성능이 안 좋아지기도 한다. &amp;quot;인덱스를 많이 걸면 성능이 저하된다&amp;quot; 라&quot; data-og-host=&quot;wanna-developer02.tistory.com&quot; data-og-source-url=&quot;https://wanna-developer02.tistory.com/218&quot; data-og-url=&quot;https://wanna-developer02.tistory.com/218&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b4QkGb/dJMb8YXTwB4/Qe3w2C9HswMHeSS6glWRPk/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/7NMpm/dJMb8XSdES4/OIiateNRmnJqX7cUfZOZFk/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/dkKTev/dJMb8SpPQLm/kJbP9HbjZzvRvMfCQTy8lk/img.png?width=240&amp;amp;height=240&amp;amp;face=0_0_240_240&quot;&gt;&lt;a href=&quot;https://wanna-developer02.tistory.com/218&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wanna-developer02.tistory.com/218&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b4QkGb/dJMb8YXTwB4/Qe3w2C9HswMHeSS6glWRPk/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/7NMpm/dJMb8XSdES4/OIiateNRmnJqX7cUfZOZFk/img.png?width=40&amp;amp;height=34&amp;amp;face=0_0_40_34,https://scrap.kakaocdn.net/dn/dkKTev/dJMb8SpPQLm/kJbP9HbjZzvRvMfCQTy8lk/img.png?width=240&amp;amp;height=240&amp;amp;face=0_0_240_240');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[DB] 쿼리 튜닝은 무엇인가 (1) - 랜덤 I/O와 쿼리 튜닝&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하면서 조회 성능을 올리기 위해 인덱스를 건 적이 있다. 어떨 때는 성능이 개선되지만, 어떨 때는 오히려 성능이 안 좋아지기도 한다. &quot;인덱스를 많이 걸면 성능이 저하된다&quot; 라&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wanna-developer02.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스는 점프를 통해 데이터의 위치를 찾아내는데 그 원리는 무엇인지에 대해 정리해볼 예정이다. 목차는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정렬배열과 이진 탐색의 한계&lt;/li&gt;
&lt;li&gt;B-tree 와 B+tree&amp;nbsp;&lt;/li&gt;
&lt;li&gt;클러스터드 인덱스와 세컨더리 인덱스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 정렬배열과 이진 탐색의 한계&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 데이터를 찾는다고 생각해보자. 아마 정렬된 배열에 값을 넣고 &lt;b&gt;이진탐색&lt;/b&gt;을 하면 될것이다. 그럼 이론상 O(logn) 이기 때문에 나쁘지 않다. 하지만 디스크에는 두가지 문제가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 이진탐색은 비교할 때마다 배열의 멀리 떨어진 위치로 건너뛴다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100만건의 데이터에서 이진탐색을 하면 대략 20번의 비교가 이루어지는데, 곧 20번의 랜덤 점프가 이루어지는 셈이다. 계속 가운데 값으로 뛰다보니, 멀리 떨어진 위치로 뛰는게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 정렬 배열은 중간에 값을 하나 끼우려면 뒤를 전부 밀어야한다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 삽입이 O(n) 이 된다. 게시글로 예시를 들었으니, 게시글이 생길 때마다 밀어야해서 사실상 사용할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 데이터의 삽입과 수정이 이루어지기 때문에 이진탐색은 힘들것이다. 그럼 다른 자료구조는 어떨까?&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;해시인덱스&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;검색이 O(1) 로 매우 빠르다. 하지만 해시는 순서가 사라지기 때문에&lt;b&gt; order by 정렬이 불가능&lt;/b&gt;하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;일반 이진트리 / AVL 트리&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정렬이 가능하지만, 노드 하나에 키가 1개뿐이라 100만건의 데이터를 기준으로는 트리의 높이가 20정도 된다. 따라서 20번 가량의 점프가 이루어지는 거는 동일하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 알 수 있는 건 &lt;b&gt;점프 횟수 = 트리높이&lt;/b&gt; 라는 점이다. 성능을 올리려면 점프 횟수를 줄여야하고, 이는 곧 트리의 높이를 줄여야 한다는 뜻이다. 즉, &lt;b&gt;한 노드가 자식 노드를 많이 갖게 하면 되는 것&lt;/b&gt;이다. 여기서 B-tree가 나온다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. B-tree 와 B+tree&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;B-tree&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B-tree&lt;/b&gt;는 &lt;b&gt;한 노드의 크기를 디스크 페이지 한 개에 맞춰서 키를 수백개씩&lt;/b&gt; 담는다. 즉 디스크를 한 번 읽으면 수백개의 키를 한번에 얻고, 그 안에서는 메모리상 비교만 하면 된다. 때문에 트리가 극적으로 얕아진다. 한 노드가 갖는 자식 개수가 많으니 높이가 낮아지는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 다른 특징은 &lt;b&gt;항상 균형을 유지&lt;/b&gt;한다는 것이다. B-tree의 B는 Binary가 아닌 Balanced 다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 리프 노드가 같은 깊이에 있어 어떤 키를 찾던 노드 수가 같다. 이를 유지하기 위해 노드가 꽉차면 노드를 둘로 쪼개고, 가운데 키를 부모로 올린다. 삭제의 경우 너무 비면 형제노드와 합치거나 빌려오기도 한다. (merge/borrow)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 DB에서 B-tree 인덱스라고 부르는 건 거의 B+tree 이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;B+tree&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B+tree&lt;/b&gt;는 실제 데이터를 리프노드에만 두고,&lt;b&gt; 중간 노드는 길 안내용 키&lt;/b&gt;만 갖는다. 중간 노드가 데이터 공간을 쓰지 않으니 갈래는 더 넓어지고 높이는 더 낮아지게 된다. 또, 리프노드끼리는 좌우로 연결되어있는 구조는 갖는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색을 할 때 B+tree는 시작점을 찾고, 리프 사이의 연결을 따라 옆으로 읽는다. 매번 루트부터 내려갈 필요 없이 옆으로 스캔하니 &lt;b&gt;순차 i/O에 가깝게 검색&lt;/b&gt;이 진행된다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 클러스터드 인덱스 vs 세컨더리 인덱스&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;InnoDB(MySQL)&lt;/b&gt; 에서는 테이블 자체가&lt;b&gt; PK 기준 B+tree&lt;/b&gt;이고, 이를 &lt;b&gt;클러스터드 인덱스&lt;/b&gt;라고 한다. 리프노드에는 행 전체가 들어있기 때문에, PK로 검색하는 경우 리프가 곧 데이터라 추가 점프가 거의 필요없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 다른 칼럼으로 만든 인덱스인데, 이를 &lt;b&gt;세컨더리 인덱스 (보조인덱스)&lt;/b&gt; 라고 한다. 보조 인덱스의 리프에는 행 데이터가 아닌 &lt;b&gt;PK 값만 들어있기&lt;/b&gt; 때문에, 실제 데이터를 가져오려면 보조인덱스에서 PK를 얻고, 이 PK로 클러스터드 인덱스를 한 번 더 이용해야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;참고로 PostgreSQL은 클러스터드 인덱스 개념이 없고 데이터는 힙에 따로 저장된다. 그리고 인덱스는 힙의 물리 위치를 가리킨다. 즉, PostgreSQL는 사실상 세컨더리처럼 동작하게 된다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 인덱스는 조회를 빠르게 해주지만, Insert, Update, Delete 가 일어날 때마다 갱신이 필요하기 때문에 느려질 수 있다. 인덱스를 생성할 때에는 Command와 Query의 트레이드오프를 항상 생각하며 필요한 것만 만들어야 한다!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 B-tree와 B+tree의 구조를 알아보았다. 다음 게시글은 복합인덱스에 대해 정리해볼 생각이다!&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <category>B+tree</category>
      <category>b-tree</category>
      <category>인덱스</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/219</guid>
      <comments>https://wanna-developer02.tistory.com/219#entry219comment</comments>
      <pubDate>Mon, 8 Jun 2026 01:06:36 +0900</pubDate>
    </item>
    <item>
      <title>[DB] 쿼리 튜닝은 무엇인가 (1) - 랜덤 I/O와 쿼리 튜닝</title>
      <link>https://wanna-developer02.tistory.com/218</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하면서 조회 성능을 올리기 위해 인덱스를 건 적이 있다. 어떨 때는 성능이 개선되지만, 어떨 때는 오히려 성능이 안 좋아지기도 한다. &quot;인덱스를 많이 걸면 성능이 저하된다&quot; 라는 이야기를 많이 들어봤는데, 그 이유가 무엇인지 정리해보려고 한다.&amp;nbsp; 목차는 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;순차 I/O vs 랜덤 I/O&lt;/li&gt;
&lt;li&gt;쿼리 튜닝이란 무엇인가&lt;/li&gt;
&lt;li&gt;인덱스가 성능을 저하시키는 경우에 대하여&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 순차 I/O vs 랜덤 I/O&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 조회 성능은 &quot;몇 행을 읽었는가&quot; 보다는 &quot;디스크에 점프를 몇 번 했는가&quot;로 결정된다. 디스크는 데이터를 물리적으로 연속된 블록 단위로 저장하는데, 데이터를 읽기 위해서는 그 위치로 이동해야한다. 이때 나오는 개념이 순차 I/O와 랜덤 I/O이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;순차 I/O&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물리적으로 인접한 블록을 연속해서 읽는 것이다. 연속으로 읽기 때문에, 헤드를 거의 움직일 필요가 없어 빠르다. 또, 아무리 많이 읽어도 점프가 없기 때문에, 페이지당 비용이 싸다는 특징이 있다. 예시로는 &lt;b&gt;풀 테이블 스캔&lt;/b&gt;이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;풀 테이블 스캔&lt;/b&gt;이란,&lt;b&gt; 테이블 페이지를 처음부터 끝까지 순서대로 읽는 것&lt;/b&gt;을 의미한다. DB는 데이터를 페이지 단위로 읽는데, 이 페이지를 순서대로 읽는 것이다. 읽는 양은 많지만 페이지당 비용이 싸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;랜덤 I/O&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기저기 흩어진 블록을 읽는 것이다. 매번 다른 위치로 이동해야 하기 때문에&lt;b&gt; seek time&lt;/b&gt;과 &lt;b&gt;회전 지연&lt;/b&gt;이 누적되어 느려진다. 예시로는 인덱스가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스로 조회한다는 것은 인덱스에서 원하는 행의 위치를 찾고, 실제 데이터가 저장된 테이블 페이지로 점프해서 읽는 것이다. 이때 읽는 행의 수와 읽는 페이지의 수는 다른데, 원하는 행이 모두 다른 페이지에 있다면, 랜덤 I/O가 계속 일어나지만, 여러 행이 모두 같은 페이지에 모여있다면 한 번의 점프로 끝난다. 즉, 행의 개수와 점프 횟수가 같지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;seek time (탐색 시간)&lt;br /&gt;&lt;/b&gt;디스크의 헤드가 데이터가 저장된 트랙 위치까지 이동하는데 걸리는 시간&lt;br /&gt;&lt;br /&gt;&lt;b&gt;회전지연 (Rotational Latency)&lt;/b&gt;&lt;br /&gt;디스크 헤드가 원하는 트랙에 도달한 후 플래터가 회전하면서 실제 데이터가 저장된 섹터가 헤드 밑으로 올떄까지 기다리는 시간&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HDD&lt;/b&gt; 기준으로 랜덤 접근 한 번의 seek time은 수 ms 수준인데, 그 시간이면 순차 I/O로는 수백~수천 블록을 읽을 수 있다. 즉, 점프 한 번의 비용이 비싼 셈이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;비용&lt;/b&gt;은 &lt;b&gt;페이지가 메모리에 없어서 디스크까지 내려가는 경우&lt;/b&gt;의 이야기이다. DB는 자주 읽는 페이지를 &lt;b&gt;메모리의&lt;/b&gt; &lt;b&gt;버퍼 풀&lt;/b&gt;에 캐시해둔다. 만약 필요한 데이터가 이미 메모리에 올라와있다면, 순차던 랜덤이던 디스크 seek이 일어나지 않아 문제가 없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 쿼리 튜닝이란 무엇인가&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 보았듯이 쿼리 튜닝의 결론적인 목표는 &lt;b&gt;랜덤 I/O를 최소화&lt;/b&gt; 하는 것이다. 더 정확하게는 &lt;b&gt;점프 횟수를 줄이는 것&lt;/b&gt;이다. 풀 스캔과 달리 인덱스를 타면 읽는 행이 적어 빠르다고 생각할 수 있지만, 꼭 그런 건 아니라는 뜻이다. 예시를 들어보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;post 테이블에 100만건의 데이터가 있고, 특정 유저의 post를 조회하는 쿼리를 자주 날린다고 가정하면 다음과 같이 쿼리문을 작성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1780820524598&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT title, created_at
FROM posts
WHERE user_id = 123
ORDER BY created_at DESC;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회하는 데이터가 50건이라고 가정한다면 100만건을 풀스캔 하는 것보다는 userId에 인덱스를 거는게 성능상 유리할 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 userId, createAt, title를 이용해 &lt;b&gt;커버링 인덱스&lt;/b&gt;로 만든다면 실제 테이블로 점프할 필요가 없어져 성능을 더 올릴 수 있게 된다. 이렇게 쿼리튜닝의 본질은, 점프를 덜하게 만드는 것이라고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  커버링 인덱스&lt;/b&gt;&lt;br /&gt;만약 인덱스를 userId, createAt, title로 만든 복합인덱스라고 가정하면, 위 쿼리문이 요구하는 칼럼을 인덱스가 모두 포함하는 셈이다. 이처럼&amp;nbsp;쿼리를 충족시키는 데&amp;nbsp;필요한 모든 컬럼이 인덱스에 이미 다 포함되어 있어서, 실제 테이블 블록(디스크)으로 점프(랜덤 I/O)하지 않고&amp;nbsp;인덱스만 읽어서 쿼리를 완료하는 상태를 커버링 인덱스라고 한다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 인덱스가 성능을 저하시키는 경우에 대하여&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위처럼 잘 쓴 인덱스는 성능을 올려주지만,&amp;nbsp; 항상 올려주지는 않는다. 만약 user_id = 123 인 데이터가 50만건이라면, 점프가 많이 일어나게 되어 오히려 성능을 저하시킬 수 있다. 즉, 모든 데이터를 스캔하는 게 차라리 빠를 수도 있다는 뜻이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 걸었는데 풀스캔이 일어나는 경우는 이렇게 풀스캔이 오히려 빠른 상황인 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 결과가 전체의 20%를 넘으면 풀 스캔이 유리하다고 하고, 정확하게는 비용으로 계산해 어떤 선택이 유리한지 판단한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스 경로: (예상 결과 행 수) &amp;times; (랜덤 페이지 비용)&lt;/li&gt;
&lt;li&gt;풀 스캔 경로: (테이블 전체 페이지 수) &amp;times; (순차 페이지 비용)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵티마이저는 위 계산을 통해 비용이 더 싼 쪽을 선택하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 게시글에서는 인덱스의 원리에 대해 정리해보려고 한다!&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <category>DB</category>
      <category>인덱스</category>
      <category>쿼리튜닝</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/218</guid>
      <comments>https://wanna-developer02.tistory.com/218#entry218comment</comments>
      <pubDate>Sun, 7 Jun 2026 17:37:12 +0900</pubDate>
    </item>
    <item>
      <title>[시큐리티] 세션과 JWT의 비교와 보안 취약점 알아보기</title>
      <link>https://wanna-developer02.tistory.com/217</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입과 로그인을 구현하는 과정에서 인증 처리를 하는 방식에는 크게 &lt;b&gt;세션방식&lt;/b&gt;과 &lt;b&gt;토큰방식(JWT)&lt;/b&gt;이 있다. 대부분 프로젝트에서는 JWT를 사용하는 것을 많이 봤고, 나 또한 JWT를 사용했었다. 문득 JWT가 더 선호되는 이유가 궁금해져 세션과 토큰방식의 차이점을 정리해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP는 기본적으로 무상태(stateless) 프로토콜이기 때문에, 서버는 방금 요청을 보낸 사람을 기억하지 못한다. 로그인이라는 기능은 본질적으로 이 사용자가 누구인지 계속 기억하고 있어야 성립하기 때문에, 상태를 유지해야 한다. 상태를 유지하는 방식 중 하나가 세션과 JWT 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;✅ 세션과 JWT 비교&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;세션 방식&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1221&quot; data-origin-height=&quot;828&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CtJb7/dJMcafz6yBH/qnq8Tx7yVNTHpDrLA2oS21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CtJb7/dJMcafz6yBH/qnq8Tx7yVNTHpDrLA2oS21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CtJb7/dJMcafz6yBH/qnq8Tx7yVNTHpDrLA2oS21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCtJb7%2FdJMcafz6yBH%2Fqnq8Tx7yVNTHpDrLA2oS21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1221&quot; height=&quot;828&quot; data-origin-width=&quot;1221&quot; data-origin-height=&quot;828&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션방식은 서버에서 사용자의 상태를 저장해두고, 클라이언트에게는 그 정보를 가리키는 무의미한 식별자만 쥐여주는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로그인 단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 로그인을 시도하면, 서버는 그 자격증명이 맞는지 DB에서 사용자를 조회해 확인한다. 확인되면 서버는 이 사용자를 위한 세션을 하나 만들고, 그 세션에 사용자 정보를 담아 서버측 &lt;b&gt;세션저장소&lt;/b&gt;에 보관한다. 세션을 만들 때 이 세션을 식별할 세션ID를 함께 발급하고, 클라이언트로 응답을 보낼 때 이 &lt;b&gt;세션ID를 Set-Cookie 헤더&lt;/b&gt;에 실어 클라이언트로 보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 세션ID는 아무 의미 없는 난수로 발급되며, 쿠키에 넣는 이유는 브라우저가 저장과 전송을 자동으로 해주기 때문이다. 개발자가 코드를 쓰지 않아도 알아서 저장하고 다음 요청에 알아서 동봉한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로그인 이후 단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 클라이언트가 같은 서버로 요청을 보낼 때마다 브라우저는 저장해둔 세션ID 쿠키를 Cookie 헤더에 자동으로 붙여서 보낸다. 서버는 해당 세션ID를 키로 저장소를 뒤져 세션을 찾고, 해당 사용자 정보를 복원한다. 복원에 성공하면 해당 사용자를 인식하는 과정을 반복하면서 HTTP위에서 로그인 상태가 유지되는 것처럼 보이게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션 저장소는 설정에 따라 달라지지만, 기본값은 &lt;b&gt;톰캣의 메모리 힙&lt;/b&gt;에 저장된다. 그렇기 때문에 서버를 재시작하면 세션이 전부 사라진다는 단점이 있고, 각 서버가 자기 메모리에만 세션을 갖고있기 때문에, 다중 서버 환경이라면 세션을 못찾는 문제가 생길 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 해결하기 위해 Redis 같은 외부 저장소에 저장한다. 모든 서버가 같은 Redis를 바라보게 하면 어느 서버로 요청이 가던 세션을 찾을 수 있게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;JWT 방식&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1222&quot; data-origin-height=&quot;817&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBHT5W/dJMcageHqB6/TZOOH4gf0kmDKXYZ634NHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBHT5W/dJMcageHqB6/TZOOH4gf0kmDKXYZ634NHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBHT5W/dJMcageHqB6/TZOOH4gf0kmDKXYZ634NHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBHT5W%2FdJMcageHqB6%2FTZOOH4gf0kmDKXYZ634NHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1222&quot; height=&quot;817&quot; data-origin-width=&quot;1222&quot; data-origin-height=&quot;817&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 상태를 서버가 가지고있는 세션과 달리, JWT는 사용자 정보를 &lt;b&gt;토큰 자체에 담아 클라이언트에게 통째로 맡기는 방식&lt;/b&gt;이다. 서버는 그 정보를 따로 보관하지 않고, 클라이언트가 요청할 때 보내는 토큰이 위조되지는 않았는지만 검증한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로그인 단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 로그인을 시도하면, 서버는 그 자격증명이 맞는지 DB에서 사용자를 조회해 확인한다. 확인되면 사용자 정보를 담은 &lt;b&gt;토큰을 생성하고, 서버의 비밀키로 서명&lt;/b&gt;한다. JWT 구조는 아래 링크에 간단히 정리해두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wanna-developer02.tistory.com/166&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wanna-developer02.tistory.com/166&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1780724756821&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[시큐리티] RSA, JWT 기본개념 정리&quot; data-og-description=&quot;스프링 시큐리티를 공부하기 앞서 보안과 관련된 개념을 정리하고자 한다. 목차는 다음과 같다.RSA 암호화공개키와 개인키JWT에 대하여RFC?JWT 구&amp;nbsp;&amp;nbsp;데이터를 전달할 때 발생하는 보안이슈를 해결&quot; data-og-host=&quot;wanna-developer02.tistory.com&quot; data-og-source-url=&quot;https://wanna-developer02.tistory.com/166&quot; data-og-url=&quot;https://wanna-developer02.tistory.com/166&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cj7fcG/dJMb8XSdwk0/oe3UgL9WGCTfVrTC1h43b1/img.png?width=274&amp;amp;height=218&amp;amp;face=0_0_274_218,https://scrap.kakaocdn.net/dn/bKUChw/dJMb8SXFzl4/laC0gfUrko1v2eDKIA45jK/img.png?width=274&amp;amp;height=218&amp;amp;face=0_0_274_218,https://scrap.kakaocdn.net/dn/0QBjc/dJMb8RRZBUs/D7657G0HSOYBfWkR3jw03K/img.jpg?width=1440&amp;amp;height=1019&amp;amp;face=0_0_1440_1019&quot;&gt;&lt;a href=&quot;https://wanna-developer02.tistory.com/166&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wanna-developer02.tistory.com/166&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cj7fcG/dJMb8XSdwk0/oe3UgL9WGCTfVrTC1h43b1/img.png?width=274&amp;amp;height=218&amp;amp;face=0_0_274_218,https://scrap.kakaocdn.net/dn/bKUChw/dJMb8SXFzl4/laC0gfUrko1v2eDKIA45jK/img.png?width=274&amp;amp;height=218&amp;amp;face=0_0_274_218,https://scrap.kakaocdn.net/dn/0QBjc/dJMb8RRZBUs/D7657G0HSOYBfWkR3jw03K/img.jpg?width=1440&amp;amp;height=1019&amp;amp;face=0_0_1440_1019');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[시큐리티] RSA, JWT 기본개념 정리&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;스프링 시큐리티를 공부하기 앞서 보안과 관련된 개념을 정리하고자 한다. 목차는 다음과 같다.RSA 암호화공개키와 개인키JWT에 대하여RFC?JWT 구&amp;nbsp;&amp;nbsp;데이터를 전달할 때 발생하는 보안이슈를 해결&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wanna-developer02.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 세션과 달리, 보통 응답 바디나 헤더에 담아서 클라이언트로 보내고, 서버에는 저장하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;로그인 이후 단계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 요청마다 클라이언트는 보관해둔 토큰을 직접 헤더 (Authorization) 에 실어 보낸다. 쿠키에 들어있지 않기 때문에 클라이언트 코드가 수동으로 챙겨야한다! 서버는 토큰의 서명을 검증하여 일치하면 사용자를 신뢰하게 된다. 클라이언트에서 정보를 가지고있기 때문에, DB나 별도의 저장소를 조회하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 별도로 저장하지 않기 때문에, 어디에 저장할지에 대한 고민은 하지 않아도 괜찮다. 다만, JWT의 경우 상태를 서버에서 관리하지 않기 때문에 토큰을 한 번 발급하고 나면 무효화를 하기 어렵다는 문제점이 있다. 탈취를 당하더라도 해당 토큰을 무효화 할 수 없는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;✅ JWT 보안 취약점 비교하기&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증 과정에서의 보안 취약점을 이야기 할 때 주로 xss와 csrf가 거론된다. 각각을 먼저 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;XSS (Cross-Site Scripting)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;xss는 공격자가 악성 자바스크립트를 심고, 그 자바스크립트가 피해자의 브라우저에서 실행되는 공격이다. 브라우저 입장에서는 그 사이트가 보낸 정상 스크립트와 악성 스크립트를 구분하지 못하기 때문에 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;&lt;b&gt;xss의 공격 유형&lt;br /&gt;&lt;/b&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Stored (저장형) : 악성 스크립트가 DB에 저장됐다가 그 데이터를 보는 모두에게 실행&lt;/li&gt;
&lt;li&gt;Reflected (반사형) : 요청에 담긴 값이 응답에 그대로 되돌아오면서 실행됨 (ex| 악성 링크 클릭 유도)&lt;/li&gt;
&lt;li&gt;DOM-based : 서버를 거치지 않고 클라이언트 js가 URL 등의 값을 DOM에 직접 꽂으면서 실행. 서버 응답에는 흔적이 없기 때문에 탐지가 까다로움&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XSS는 스크립트가 피해자 브라우저에서 돌기 때문에, JS로 접근 가능한 건 다 빼갈 수 있다. 즉, 로컬스토리지에 저장된 JWT 정도는 쉽게 탈취가 가능하다는 이야기이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방지 대책&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;출력 이스케이프&lt;/b&gt; : HTML을 그대로 사용하지 않고 특수문자를 무력한 글자로 변환하여 그대로 실행되는 것을 방지
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;보통 요즘 프레임워크는 이를 기본으로 해주기 때문에 자동 이스케이프를 끄는 위험한 기능을 쓰지 않으면 된다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ex| thymeleaf 의 th:utext는 이스케이프를 끄므로 th:text를 사용&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CSP (Content-Security-Policy)&lt;/b&gt; : 응답헤더로 이 페이지에서 실행 가능한 스크립트의 출처를 브라우저에 선언하는 것
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;악성 스크립트가 들어와도 실행 자체를 막아줌&lt;/li&gt;
&lt;li&gt;설정이 까다로워서 보강 용도로 보통 사용함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;액세스 토큰 수명을 짧게 하고, 리프레시 토큰 로테이션으로 재사용 탐지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리프레시토큰은 액세스토큰보다 수명이 길어 더 위험하기 때문에 읽지 못하도록 쿠키에 넣어두는 방식으로 설계한다. 그래서 이렇게 구현할 경우, 리프레시토큰은 xss 공격에는 강하지만 csrf 공격에는 취약해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;CSRF (Cross-Site Request Forgery)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인된 피해자의 브라우저를 속여 피해자가 의도하지 않은 요청을 신뢰된 사이트로 보내게 만드는 공격이다. 한마디로 악성 코드를 실행하는 건 아니지만, 피해자가 의도하지 않은 요청을 피해자의 이름으로 보내게 하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 이야기 했듯, 브라우저는 해당 도메인으로 요청을 보낼 때 도메인의 쿠키를 자동으로 붙인다.그래서 사용자가 로그인된 상태에서 공격자가 만든 악성 페이지에 들어가면 그 페이지가 사용자가 로그인한 페이지에 요청을 쏘게 하고, 브라우저는 친절히 쿠키를 자동으로 동봉한다. 이렇게 되면 서버는 유효한 쿠키가 왔기 때문에, 요청을 처리해버리게 된다. 이 점을 이용하면 원치않은 송금을 해버릴 수도 있다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방지 대책&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SameSite 쿠키 속성 : SameSite 속성을 Lax나 Strict로 두면 브라우저가 외부 사이트 요청에는 쿠키를 자동으로 붙이지 않아 CSRF 공격을 막을 수 있다.&lt;/li&gt;
&lt;li&gt;CSRF 토큰 : 서버가 예측 불가한 토큰을 발급해 페이지에 심어두어 요청시 해당 토큰을 함께 보내게 하는 방식
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공격자의 외부 페이지는 해당 토큰을 알 수 없기 때문에 위조 요청을 만들 수 없게 된다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트의 구조마다 트레이드오프가 존재하기 때문에, 본인 프로젝트를 보고 방식을 선택해야 할 거 같다!&lt;/p&gt;</description>
      <category>백엔드/스프링 Security</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/217</guid>
      <comments>https://wanna-developer02.tistory.com/217#entry217comment</comments>
      <pubDate>Sat, 6 Jun 2026 17:29:44 +0900</pubDate>
    </item>
    <item>
      <title>[백준] 11729: 하노이 탑 이동 순서 (재귀를 푸는 사고 흐름 고치기) - JAVA</title>
      <link>https://wanna-developer02.tistory.com/216</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;백트래킹을 공부해볼까 하다가 재귀를 먼저 학습했다. 정말 유명한 문제이지만, 재귀를 공부하면서 풀어보았다. 이번 게시글은 하노이 자체보다는 재귀를 공부한 내용에 초점을 맞춰볼까 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 나는 재귀를 풀 때, 코드 흐름을 계속 따라들어가면서 풀었다. 그래서 복잡한 문제를 만나면 풀기 힘들고 이해가 전혀 되지 않았다. 이걸 견뎌내면서 모두가 풀고 있는 줄 알았는데 공부를 하면서 &lt;b&gt;귀납적 풀이&lt;/b&gt;라는 걸 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수학을 공부할 때 나오는 귀납법과 같다고 생각하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;귀납법&lt;br /&gt;&lt;br /&gt;&lt;/b&gt;1. BaseCase에서 명제가 참&lt;br /&gt;2. N일 때 명제가 참이라고 가정&lt;br /&gt;3. 위 가정을 이용해 N+1일때도 명제가 참임을 증명&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 절차지향적으로 코드를 따라들어가는 게 아니라, 가능하다는 걸 전제하고 문제풀이를 진행하는 방식인 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하노이 문제에서 한 번 확인해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.acmicpc.net/problem/11729&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.acmicpc.net/problem/11729&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;# 문제&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1161&quot; data-origin-height=&quot;1131&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YpyJ1/dJMcaduz1gs/LAH6pJhxFMalDhtU4w6lKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YpyJ1/dJMcaduz1gs/LAH6pJhxFMalDhtU4w6lKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YpyJ1/dJMcaduz1gs/LAH6pJhxFMalDhtU4w6lKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYpyJ1%2FdJMcaduz1gs%2FLAH6pJhxFMalDhtU4w6lKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1161&quot; height=&quot;1131&quot; data-origin-width=&quot;1161&quot; data-origin-height=&quot;1131&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 기둥은 1, 2, 3 으로 세 개이다. 1에서 3으로 넣는다고 가정하면, start = 1, end = 3으로 잡았고 남은 기둥은 6 - start - end로 잡았다. 이제 재귀를 이용해서 원판을 옮기는 걸 상상해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 조건에 따라 맨 아래에 가장 큰 원판을 두어야 한다. n개의 원판이 1번에 꽂혀있다면, 우선&lt;b&gt; n - 1개의 원판을 2번에 옮긴다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, 절차지향으로 생각하면 '여러개를 어케 옮겨' 라고 생각할 수 있으나, 정상적인 방법으로 2번 기둥으로 어찌어찌 옮겼다고 가정하는거다. 어차피 그렇게 옮기고 가장 큰 원판을 3번으로 옮기는 방식으로 구현해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옮기고 나면 n번째 원판을 3번 기둥으로 옮긴다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 이후에는 n - 1개의 원판을 3번으로 옮기는 거다. 과정을 생략하고 생각해서 이상한거지 결론적으로는 맞으니까! 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉,&lt;b&gt; 원판이 n - 1개일 때 옮길 수 있다면, 원판이 n개일 때도 옮길 수 있는 것이다. 원판이 1개 일때 또한 자명하게 내가 원하는 곳으로 옮길 수 있다.&lt;/b&gt; 따라서 귀납적 접근이 가능하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 재귀 코드로 짜면 된다. 어려울 거 없이&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. n이 1일때 (basecase) -&amp;gt; 그냥 출력하면 끝&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. n - 1개를 2번으로 옮기기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. n번 판을 3번으로 옮기기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. n - 1개를 3번으로 옮기기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거만 작성하면 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;# Code&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1774423267117&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.PriorityQueue;
import java.util.StringTokenizer;


public class Main {
    static StringBuilder sb = new StringBuilder();
    static int cnt = 0;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int N = Integer.parseInt(br.readLine());
        hanoi(1, 3, N);
        System.out.println(cnt);
        System.out.println(sb);
    }

    private static void hanoi(int start, int end, int N) {
        if (N == 1) {
            sb.append(start).append(&quot; &quot;).append(end).append(&quot;\n&quot;);
            cnt++;
            return;
        }
        hanoi(start, 6 - start - end, N - 1);
        sb.append(start).append(&quot; &quot;).append(end).append(&quot;\n&quot;);
        cnt++;
        hanoi(6 - start - end, end, N - 1);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 너무 어렵게 생각됐는데, 귀납적으로 접근하니 한결 편안해진 게 느껴졌다! 재귀 문제들을 더 풀어보면서 사고 정리하는 시간을 계속 가져봐야겠다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;# 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;716&quot; data-origin-height=&quot;79&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8JrRH/dJMcadVFu8r/yad64zzb7FkK1tBZKw5dJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8JrRH/dJMcadVFu8r/yad64zzb7FkK1tBZKw5dJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8JrRH/dJMcadVFu8r/yad64zzb7FkK1tBZKw5dJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8JrRH%2FdJMcadVFu8r%2Fyad64zzb7FkK1tBZKw5dJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;716&quot; height=&quot;79&quot; data-origin-width=&quot;716&quot; data-origin-height=&quot;79&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>코딩테스트/백준 골드</category>
      <category>골드5</category>
      <category>재귀</category>
      <category>하노이탑</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/216</guid>
      <comments>https://wanna-developer02.tistory.com/216#entry216comment</comments>
      <pubDate>Wed, 25 Mar 2026 16:38:06 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] DB와 JPA Persistence Context 불일치 해결하기 - trouble shooting</title>
      <link>https://wanna-developer02.tistory.com/215</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;뚱땅뚱땅 개발을 하고있던 어느 날,, 투표가 존재하는 공지사항 삭제가 안 된다는 연락을 받았다. 로그를 열심히 뒤져서 에러로그를 발견했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1550&quot; data-origin-height=&quot;331&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6XTDL/dJMcadVB0c3/KNQ3S3WRCnLAvCCxLqlE9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6XTDL/dJMcadVB0c3/KNQ3S3WRCnLAvCCxLqlE9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6XTDL/dJMcadVB0c3/KNQ3S3WRCnLAvCCxLqlE9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6XTDL%2FdJMcadVB0c3%2FKNQ3S3WRCnLAvCCxLqlE9k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1550&quot; height=&quot;331&quot; data-origin-width=&quot;1550&quot; data-origin-height=&quot;331&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;에러 로그를 바탕으로 조금 찾아본 결과, DB와 Persistence Context 불일치로 인한 오류가 발생한 것이었다. JPA를 공부하다보면 영속성 컨텍스트, Persistence Context라는 단어를 많이 접하게 되는데, 이번 기회에 개념을 정리해보고자 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;영속성 컨텍스트 (Persistence Context)&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;b&gt;영속성&lt;/b&gt;이란, 데이터나 객체가 프로그램 종료 후에도 &lt;b&gt;사라지지 않고 지속되는 특성&lt;/b&gt;을 의미한다. 그렇다면 영속성 컨텍스트는&amp;nbsp;&lt;b&gt;JPA가 엔티티를 관리하는 저장소&lt;/b&gt;로 appication과 데이터베이스 사이에 있는 &lt;b&gt;메모리 속 1차 캐시&lt;/b&gt;라고 이해하면 된다. 이 덕분에 엔티티를 여러 번 조회할 때 매번 DB에 쿼리를 날리는 것이 아니라, Persistence Context에서 가져온다. 즉, &lt;b&gt;1차 캐시&lt;/b&gt; 역할을 해서 성능 측면에서 도움을 준다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Entity의 생명주기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA는 엔티티의 생명주기를 추척하며 4가지의 상태로 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비영속&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA가 아직 모르는 즉, 관리되지 않는 상태를 의미하며, 영속성 컨텍스트에 들어가지 않은 순수 자바 객체&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;영속&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Persistence Context가 관리하며 변경사항을 추적하고 있는 상태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;준영속&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한때 영속상태였으나, 더이상 Persistence Context가 관리하지 않는 상태.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 트랜잭션이 종료되었다거나 명시적으로 컨텍스트에서 제거하는 경우 준영속 상태가 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;삭제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에서 삭제 예정으로 마킹된 상태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Dirty Cheking - 변경감지&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;영속 상태의 엔티티는 트랜잭션이 커밋될 때&lt;span&gt; JPA&lt;/span&gt;가 스냅샷&lt;span&gt;(&lt;/span&gt;최초 조회 시점 상태&lt;span&gt;)&lt;/span&gt;과 현재 상태를 비교해서 변경된 필드가 있으면 자동으로&lt;span&gt; UPDATE &lt;/span&gt;쿼리를 날린다&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;&amp;nbsp;이를 &lt;b&gt;Dirty Checking&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;이라고 하며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;개발자가 &lt;/span&gt;&lt;span style=&quot;background-color: #f1f5f9; color: #7c3aed;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;save() &lt;/span&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;를 명시적으로 호출하지 않아도 변경이&lt;span&gt; DB&lt;/span&gt;에 반영되는 이유이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774069079830&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void updateNotice(Long id, String newTitle) {
    Notice notice = noticeRepository.findById(id).get(); // 영속 상태
    notice.changeTitle(newTitle); // 그냥 setter
    // save() 호출 없어도 트랜잭션 커밋 시 UPDATE 쿼리 발생
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; &lt;b&gt;@Modifying과 JPQL 벌크 연산&lt;/b&gt; &lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA에서 커스텀 DELETE/UPDATE 쿼리를 쓸 때 @Modifying을 붙인 경험이 있을 것이다. 이게 없으면 JPA는 이 쿼리를 읽기 전용으로 간주하고 예외를 던진다. 해당 어노테이션이 붙은 JPQL 벌크 연산은 Persistence Context를 거치지 않고 DB로 직접 쿼리를 날리게 된다.&amp;nbsp; 그렇게 되면 JPQL DELETE는 DB의 레코드를 삭제하지만, Persistence Context에 남아있는 엔티티 객체는 그대로 남아있게 된다.&amp;nbsp;즉,&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt; &lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;DB&lt;/span&gt;&lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;와&lt;/span&gt;&lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;1&lt;/span&gt;&lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;차 캐시 사이에 불일치&lt;/span&gt;&lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;(Inconsistency)&lt;/span&gt;&lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;가 생긴다&lt;/span&gt;&lt;span style=&quot;color: #1e293b; text-align: start;&quot;&gt;!&lt;br /&gt;&lt;/span&gt; &lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 오류는 해당 문제로 인해 발생했다. 그런데, 공지에 투표가 있는 경우에만 오류가 발생하고 이미지, 링크는 아무 문제가 없었다. 왜 투표만 터졌는지 코드를 찾아본 결과 원인을 알 수 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1774069493867&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
public void removeContentsByNoticeId(Long noticeId, Long memberId) {
    // Image, Link &amp;rarr; 삭제하고 끝. 이후 해당 엔티티에 접근하는 코드 없음.
    saveNoticeImagePort.deleteAllImagesByNoticeId(noticeId);
    saveNoticeLinkPort.deleteAllLinksByNoticeId(noticeId);
 
    loadNoticeVotePort.findVoteByNoticeId(noticeId)
        .ifPresent(vote -&amp;gt; {
            // JPQL DELETE &amp;rarr; DB에서 NoticeVote 레코드 삭제
            // 그러나 Persistence Context 캐시엔 아직 남아있음!
            saveNoticeVotePort.deleteAllVotesByNoticeId(noticeId);
 
            // vote.getVoteId()로 추가 UseCase 실행
            //  &amp;rarr; 같은 트랜잭션 안에서 voteId로 엔티티를 다시 조회/조작
            //  &amp;rarr; 캐시-DB 불일치 충돌 발생!
            deleteVoteUseCase.delete(new DeleteVoteCommand(vote.getVoteId(), memberId));
        });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;delete 후 voteId를 재사용하려고 했기 때문이었다. 이렇게 DB와 1차 캐시 불일치가 만드는 에러 패턴이 몇 개 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;1. DB&lt;/span&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;에서 이미 삭제된&lt;span&gt; row&lt;/span&gt;를 캐시에서 찾아&lt;span&gt; UPDATE &lt;/span&gt;시도&lt;span&gt; &amp;rarr; StaleStateException&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;2. &lt;/span&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;삭제된&lt;span&gt; FK&lt;/span&gt;를 가진 캐시 엔티티로 부모 엔티티를 다시 로드&lt;span&gt; &amp;rarr; &lt;/span&gt;정합성 오류&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;3. EntityManager&lt;/span&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;가&lt;span&gt; removed &lt;/span&gt;상태가 아닌 캐시 엔티티를&lt;span&gt; flush&lt;/span&gt;하려다&lt;span&gt; constraint &lt;/span&gt;위반&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;문제 해결&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;해결방법은 간단했다. @Modifying에 &lt;b&gt;Automatically = true&amp;nbsp;&lt;/b&gt;옵션을 넣어주는 것이다. 해당 옵션을 사용하면&lt;span style=&quot;color: #1e293b;&quot;&gt;&amp;nbsp;벌크 연산&lt;span&gt;(JPQL DELETE/UPDATE) &lt;/span&gt;실행 후 자동으로 &lt;/span&gt;&lt;span style=&quot;background-color: #f1f5f9; color: #000000;&quot;&gt;&amp;nbsp;&lt;b&gt;EntityManager.clear() &lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;를 호출한다&lt;span&gt;. 이를 통해 Persistence Context를 완전히 비울 수 있다. 1차 캐시에 올라가있던 엔티티들은 &lt;b&gt;준영속상태&lt;/b&gt;로 변경된다. 이후 조회를 하면 DB에서 최신 데이터를 로드해올 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;&lt;span&gt;해당 옵션을 사용할 때 주의사항도 존재한다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;bull;&lt;span&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;벌크 연산 전에 조회한 엔티티를 이후에 재사용할 경우&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #64748b;&quot;&gt;em.clear() &lt;/span&gt;&lt;span style=&quot;color: #64748b;&quot;&gt;이후 해당 엔티티는&lt;span&gt; detached &lt;/span&gt;상태가 된다&lt;span&gt;. &lt;/span&gt;이 상태에서 필드에 접근하면&lt;span&gt; LazyInitializationException&lt;/span&gt;이 발생할 수 있다&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;bull;&lt;span&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;같은 트랜잭션 안에서 여러 벌크 연산이 있을 경우&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #64748b;&quot;&gt;각 연산마다&lt;span&gt; clearAutomatically&lt;/span&gt;를 쓰면 앞 연산에서 만든 캐시가 다 날아간다&lt;span&gt;. &lt;/span&gt;필요한 엔티티는&lt;span&gt; clear &lt;/span&gt;이후 다시 조회해야 한다&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;bull;&lt;span&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;성능 영향&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #64748b;&quot;&gt;clear &lt;/span&gt;&lt;span style=&quot;color: #64748b;&quot;&gt;이후 캐시가 비어있으므로 이후 조회들은&lt;span&gt; DB&lt;/span&gt;에 직접 쿼리를 날린다&lt;span&gt;. 1&lt;/span&gt;차 캐시의 이점을 포기하는 것이므로 빈번한 사용은 성능에 영향을 줄 수 있다&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;&lt;span style=&quot;color: #1e293b;&quot;&gt;&lt;span&gt;결론적으로는 캐시와 DB의 불일치 문제였고,,, 해결할 수 있어서 다행이다!&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>백엔드/JPA</category>
      <category>JPA</category>
      <category>Spring</category>
      <author>와나나나</author>
      <guid isPermaLink="true">https://wanna-developer02.tistory.com/215</guid>
      <comments>https://wanna-developer02.tistory.com/215#entry215comment</comments>
      <pubDate>Sat, 21 Mar 2026 14:14:21 +0900</pubDate>
    </item>
  </channel>
</rss>