<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>왕초보개발자</title>
    <link>https://nooblette.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 29 Jun 2026 16:27:14 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>nooblette</managingEditor>
    <image>
      <title>왕초보개발자</title>
      <url>https://tistory1.daumcdn.net/tistory/4467158/attach/4547f03c2d27493da55e92d8857cef5a</url>
      <link>https://nooblette.tistory.com</link>
    </image>
    <item>
      <title>[Java] JVM 버전별 특징</title>
      <link>https://nooblette.tistory.com/entry/Java-JVM-%EB%B2%84%EC%A0%84%EB%B3%84-%ED%8A%B9%EC%A7%95</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 진영에서는 꾸준히 LTS 버전을 출시하며 최신 트렌드와 기술적 요구사항을 준수하는 버전을 제공한다. 이번 글에서는 대표적인 Java LTS 버전인 Java 5, Java 8, Java 11, Java 17, Java 21에서 제공하는 대표적인 특징을 정리해본다.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Java 5: Generic, Enum&lt;/li&gt;
&lt;li&gt;Java 8: Lambda, Functioinal Interface, Stream API, Optional&lt;/li&gt;
&lt;li&gt;Java 11: Lambda내 var 키워드 사용, HTTP Client API 표준화, ZGC 실험 도입&lt;/li&gt;
&lt;li&gt;Java 17: Record, Sealed Class&lt;/li&gt;
&lt;li&gt;Java 21: Virtural Thread, Pattern Matching, Generational ZGC 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;최신 문법을 왜 학습하고 활용해야하는가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;들어가기에 앞서, 과거 버전으로도 서비스가 잘 동작하는데 왜 최신 문법을 학습하고 활용해야할까? 그 이유는 코드의 안전성과 생산성을 동시에 높일 수 있기 때문이다. 특히 Recrod와 Sealed Calss는 복잡한 도메인 로직에서 객체의 불변성을 보장해 사이드 이펙트를 방지하고, 패턴 매칭 등으로 읽기 쉽게 나타내며, 컴파일 시점에 모든 케이스에 대한 처리가 이루어지고 있는지 검증할 수 있다. 새로운 타입을 추가하거나 리팩토링할 때 컴파일 시점에 누락된 부분을 인지해 런타임 에러를 방지할 수 있다. 예를 들어 Record 클래스는 단 한줄로 불변 데이터 클래스를 정의할 수 있다. 이를 통해 기존 30줄 가량의 보일러플레이트 코드를 제거하여 생산성을 높인다. Sealed Class는 상속 가능한 타입을 명시적으로 제한하여 switch 문에서 모든 케이스의 처리가 이루어지고 있는지 컴파일 시점에 검증하고 런타임 에러를 방지한다. 이러한 특징은 &lt;a href=&quot;https://nooblette.tistory.com/entry/%EC%99%84%EB%B2%BD%EC%A3%BC%EC%9D%98-%EC%84%B1%ED%96%A5-%EB%B2%84%EB%A6%AC%EA%B8%B0-Railway-Oriented-Programming-%EC%A0%81%EC%9A%A9-%EC%97%AC%EC%A0%95&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Railway Oriented Programming&lt;/a&gt;에서 특히 그  이점을 발휘한다. 서비스가 성장하면서 요구사항은 더욱 복잡해지고, 이에 따라 수정해야하는 코드의 양과 영향범위도 증가한다. 결과적으로 서비스 영향도를 최소화하면서 복잡한 요구사항을 빠르게 충족하는, 더 적은 코드로 더 안정하고 이해하기 쉬운 프로그램을 작성할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java 5&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;대표적으로 Generic이 도입되었다. 이는 컴파일 시점에 타입을 강제한다. 런타임 시점에 타입 오류를 방지하여 타입 안정성을 강화한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856078444&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
public class Box {
    private Object object;
    public void set(Object object) { this.object = object; }
    public Object get() { return object; }
}

// After
public class Box&amp;lt;T&amp;gt; {
    private T value;
    public void set(T value) { this.value = value; }
    public T get() { return value; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;동일한 속성을 갖는 일련의 한정된 데이터 집합을 타입으로 나타내는 Enum도 이 버전에서 도입되었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856105930&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;enum Color {
    RED(&quot;빨강&quot;),
    BLUE(&quot;파랑&quot;),
    BLACK(&quot;검정&quot;)
    
    private String name;
    
    Color(String name) { this.name = name; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java 8&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;함수형 프로그래밍으로 데이터 처리 방식을 추상화하기 위한 Lambda 표현식과 &lt;a href=&quot;https://nooblette.tistory.com/entry/Java-%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8DFunctional-%08Programming%EA%B3%BC-%EB%9E%8C%EB%8B%A4%EC%8B%9DLambda-Expression-12&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Functional Interface&lt;/a&gt;이 도입되었다. 이를 통해 완전한 객체지향으로 설계된 Java 진영에서도 함수형 프로그래밍을 어느정도 우회하여 제공하기 시작한다. (Lambda도 결국 Functional Interface를 익명 객체로 구현하는 방식으로 컴파일되어 동작하기 때문에 &lt;b&gt;우회&lt;/b&gt;라는 표현을 사용했다.)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856217601&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
List&amp;lt;String&amp;gt; names = Arrays.asList(&quot;Alice&quot;, &quot;Bob&quot;, &quot;Charlie&quot;);
Collections.sort(names, new Comparator&amp;lt;String&amp;gt;() {
    @Override
    public int compare(String a, String b) {
        return a.compareTo(b);
    }
});

// After
List&amp;lt;String&amp;gt; names = Arrays.asList(&quot;Alice&quot;, &quot;Bob&quot;, &quot;Charlie&quot;);
Collections.sort(names, (a, b) -&amp;gt; a.compareTo(b));
// 또는 메서드 참조
Collections.sort(names, String::compareTo);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;a href=&quot;https://nooblette.tistory.com/entry/Java-Streams-API%EC%9D%98-%EC%9E%A5%EC%A0%90%EA%B3%BC-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Stream&amp;nbsp;API&lt;/a&gt;로 컬렉션 처리 방식 추상화한다. 컬렉션 데이터의 처리 방식을 명시하고 원소 순회를 컬렉션 내부에서 처리한다. 개발자가 각 원소를 탐색하는 로직을 작성하지 않아도 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856258187&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
List&amp;lt;String&amp;gt; names = Arrays.asList(&quot;Alice&quot;, &quot;Bob&quot;, &quot;Charlie&quot;, &quot;David&quot;);
List&amp;lt;String&amp;gt; result = new ArrayList&amp;lt;&amp;gt;();
for (String name : names) {
    if (name.startsWith(&quot;A&quot;)) {
        result.add(name.toUpperCase());
    }
}

// After
List&amp;lt;String&amp;gt; result = names.stream()
    .filter(name -&amp;gt; name.startsWith(&quot;A&quot;))
    .map(String::toUpperCase)
    .collect(Collectors.toList());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Null 체크와 처리 로직을 명시적으로 작성하여 NPE를 방지하기 위한 Optional 클래스도 Java 8에서 도입되었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856307441&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
public String getUserName(User user) {
    if (user != null) {
        String name = user.getName();
        if (name != null) {
            return name.toUpperCase();
        }
    }
    return &quot;UNKNOWN&quot;;
}

// After
public String getUserName(User user) {
	return Optional.ofNullable(user)
		.map(User::getName)
		.map(String::toUpperCase)
		.orElse(&quot;UNKNOWN&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;또한 인터페이스의 default 메서드와 static 메서드를 제공하여 인터페이스의 활용도를 높였다. 이를 통해 구현체가 가져야할 메서드를 인터페이스에서 제공하여 공통화하고 하위 호환성을 향상할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856344297&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface Vehicle {
    // 추상 메서드
    void start();
    
    // default 메서드
    default void stop() {
        System.out.println(&quot;Vehicle stopped&quot;);
    }
    
    // static 메서드
    static void honk() {
        System.out.println(&quot;Beep beep!&quot;);
    }
}

class Car implements Vehicle {
    @Override
    public void start() {
        System.out.println(&quot;Car started&quot;);
    }
    // stop() 메서드를 오버라이딩하지 않아도 됨
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java 11&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java 10에서 도입된 타입 추론 var 키워드를 Lambda에서도 사용할 수 있게 확장한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856387928&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Java 10 도입
var numbers = List.of(1, 2, 3, 4, 5);
var name = &quot;Alice&quot;;

// 람다에서 var 키워드 사용
// 람다 파라미터에 어노테이션을 추가할 수 있음
List&amp;lt;String&amp;gt; names = Arrays.asList(&quot;Alice&quot;, &quot;Bob&quot;);
names.forEach((@NonNull var name) -&amp;gt; System.out.println(name));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Http Client API 표준화하여 코드 작성량을 줄이며, 비동기 Http 요청 등 처리량 개선도 가능하도록 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856441888&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before (Java 8 - HttpURLConnection)
URL url = new URL(&quot;https://api.example.com/data&quot;);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();

conn.setRequestMethod(&quot;GET&quot;);

BufferedReader in = new BufferedReader(
new InputStreamReader(conn.getInputStream()));
String inputLine;
StringBuilder content = new StringBuilder();
while ((inputLine = in.readLine()) != null) {
    content.append(inputLine);
}
in.close();

// After
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(&quot;https://api.example.com/data&quot;))
    .build();

HttpResponse&amp;lt;String&amp;gt; response = client.send(request, 
    HttpResponse.BodyHandlers.ofString());
String body = response.body();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;String 클래스에 메서드 추가(isBalnk, lines, strip, repeat)되어 문자열 처리가 더욱 간편해진다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856473005&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// isBlank() - 빈 문자열 또는 공백만 있는지 확인
String str1 = &quot;   &quot;;
System.out.println(str1.isBlank()); // true
System.out.println(str1.isEmpty()); // false

// strip() - 앞뒤 공백 제거 (유니코드 공백 포함)
String str2 = &quot;  Hello  &quot;;
System.out.println(str2.strip()); // &quot;Hello&quot;

// lines() - 줄 단위로 분리
String multiLine = &quot;Line1\nLine2\nLine3&quot;;
multiLine.lines().forEach(System.out::println);

// repeat() - 문자열 반복
String star = &quot;*&quot;.repeat(5); // &quot;*****&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;또한 GC로 인한 애플리케이션 중단 시간을 제어하고 최소화하기 위한 &lt;a href=&quot;https://nooblette.tistory.com/entry/JVM-Garbage-CollectionGC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;ZGC&lt;/a&gt;가 실험적으로 도입되었다. Humongous Object가 사용되는 대용량 힙에서도 10ms 이하의 일시 정지 시간을 목표로 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856515482&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# JVM 옵션으로 활성화
java -XX:+UseZGC -Xmx16g MyApplication&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Java 17&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Sealed Class로 상속 제어 강화했다. 상속 가능한 타입을 제한하여 클래스 계층 구조를 명시적으로 제한한다. 이는 API 호출에 따른 응답을 처리하거나 &lt;a href=&quot;https://nooblette.tistory.com/entry/%EC%99%84%EB%B2%BD%EC%A3%BC%EC%9D%98-%EC%84%B1%ED%96%A5-%EB%B2%84%EB%A6%AC%EA%B8%B0-Railway-Oriented-Programming-%EC%A0%81%EC%9A%A9-%EC%97%AC%EC%A0%95&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Railway Oriented Programming&lt;/a&gt;에서 유용하게 활용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856601205&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 허용된 하위 클래스만 상속 가능
public sealed class Shape 
    permits Circle, Rectangle, Triangle {
}

final class Circle extends Shape {
    double radius;
}

final class Rectangle extends Shape {
    double width, height;
}

final class Triangle extends Shape {
    double base, height;
}

// 컴파일 에러: Square는 permits에 없음
// class Square extends Shape { }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;불변 데이터 타입의 Record 클래스가 도입되었다. 함수형 프로그래밍에서 활용도가 높고 간결하게 불변 객체를 생성할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772856633005&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
public final class Person {
    private final String name;
    private final int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String name() { return name; }
    public int age() { return age; }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age &amp;amp;&amp;amp; 
               Objects.equals(name, person.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

// After
public record Person(String name, int age) {
    // 생성자, getter, equals, hashCode, toString 자동 생성
}

// 사용
Person p = new Person(&quot;Alice&quot;, 30);
System.out.println(p.name()); // Alice&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 줄의 String을 간편하게 작성할 수 있는 Text Blocks도 Java 17에서 도입되었다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856651399&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
String json = &quot;{\n&quot; +
              &quot;  \&quot;name\&quot;: \&quot;Alice\&quot;,\n&quot; +
              &quot;  \&quot;age\&quot;: 30,\n&quot; +
              &quot;  \&quot;city\&quot;: \&quot;Seoul\&quot;\n&quot; +
              &quot;}&quot;;


// After
String json = &quot;&quot;&quot;
    {
      &quot;name&quot;: &quot;Alice&quot;,
      &quot;age&quot;: 30,
      &quot;city&quot;: &quot;Seoul&quot;
    }
    &quot;&quot;&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 타입 체크와 캐스팅을 동시에 수행하는 instanceof의 패턴 매칭도 지원한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856673124&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
if (obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.length());
}

// After
if (obj instanceof String str) {
    System.out.println(str.length());
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Java 21&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Proejct Loom의 Virtual Threads가 출시되었다. Platform Thread의 Conext Switch가 아닌 JVM 레벨에서의 작업 전환으로 수백 만개의 동시 작업을 효율적으로 처리한다. OS 자원 소모를 줄이면서 처리량을 높일 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856763599&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before (Platform Thread)
// 1만 개의 작업 실행 시 1만 개의 OS 스레드 필요
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i &amp;lt; 10000; i++) {
    executor.submit(() -&amp;gt; {
        // 작업 수행
        Thread.sleep(1000);
    });
}

// After (Virtual Thread)
// 수백만 개의 작업도 적은 리소스로 처리
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i &amp;lt; 1000000; i++) {
        executor.submit(() -&amp;gt; {
            // 작업 수행
            Thread.sleep(1000);
        });
    }
}

// 또는 직접 생성
Thread.startVirtualThread(() -&amp;gt; {
    System.out.println(&quot;Virtual thread!&quot;);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;switch 문에서 패턴 매칭을 개선한 Patter Matching도 지원한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856782491&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Before
public String format(Object obj) {
    if (obj instanceof Integer i) {
        return String.format(&quot;int %d&quot;, i);
    } else if (obj instanceof Long l) {
        return String.format(&quot;long %d&quot;, l);
    } else if (obj instanceof Double d) {
        return String.format(&quot;double %f&quot;, d);
    } else if (obj instanceof String s) {
        return String.format(&quot;String %s&quot;, s);
    } else {
        return obj.toString();
    }
}

// After
public String format(Object obj) {
    return switch (obj) {
        case Integer i -&amp;gt; String.format(&quot;int %d&quot;, i);
        case Long l    -&amp;gt; String.format(&quot;long %d&quot;, l);
        case Double d  -&amp;gt; String.format(&quot;double %f&quot;, d);
        case String s  -&amp;gt; String.format(&quot;String %s&quot;, s);
        default        -&amp;gt; obj.toString();
    };
}

// null 체크와 가드 조건도 가능
String result = switch (obj) {
    case null -&amp;gt; &quot;null value&quot;;
    case String s when s.isEmpty() -&amp;gt; &quot;empty string&quot;;
    case String s -&amp;gt; &quot;string: &quot; + s;
    default -&amp;gt; &quot;other&quot;;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 17에서 도입된 Record 클래스를 패턴 매칭으로 분해할 수 있다. 불필요한 getter 호출을 개선한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856805363&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;record Point(int x, int y) {}

public void processPoint(Object obj) {
    // Before: instanceof와 getter 사용
    if (obj instanceof Point p) {
        int x = p.x();
        int y = p.y();
        System.out.println(&quot;x: &quot; + x + &quot;, y: &quot; + y);
    }
    
    // After: Record Pattern으로 직접 분해
    if (obj instanceof Point(int x, int y)) {
        System.out.println(&quot;x: &quot; + x + &quot;, y: &quot; + y);
    }
}

// switch와 결합
record Circle(Point center, double radius) {}

public String describe(Object shape) {
    return switch (shape) {
        case Circle(Point(int x, int y), double r) -&amp;gt; 
            String.format(&quot;Circle at (%d,%d) with radius %.2f&quot;, x, y, r);
        default -&amp;gt; &quot;Unknown shape&quot;;
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Generational ZGC가 개선되었다. 세대별 가비지 컬렉션을 지원하여 ZGC의 성능이 더욱 향상되었다. Young generation과 Old generation 분리로 GC 효율성 증가하며, 짧은 생명주기 객체를 더 빠르게 수집할 수 있다. 결과적으로 GC의 전체적인 처리량과 지연 시간이 개선된다.&lt;/p&gt;
&lt;pre id=&quot;code_1772856851018&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Generational ZGC 활성화
java -XX:+UseZGC -XX:+ZGenerational -Xmx16g MyApplication&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Java &amp;amp; Kotlin</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/427</guid>
      <comments>https://nooblette.tistory.com/entry/Java-JVM-%EB%B2%84%EC%A0%84%EB%B3%84-%ED%8A%B9%EC%A7%95#entry427comment</comments>
      <pubDate>Sat, 7 Mar 2026 13:16:47 +0900</pubDate>
    </item>
    <item>
      <title>[Redis] 분산 락 해제 로직을 Atomic하게 작성해야하는 이유</title>
      <link>https://nooblette.tistory.com/entry/Redis-%EB%B6%84%EC%82%B0-%EB%9D%BD-%ED%95%B4%EC%A0%9C-%EB%A1%9C%EC%A7%81%EC%9D%84-Atomic%ED%95%98%EA%B2%8C-%EC%9E%91%EC%84%B1%ED%95%B4%EC%95%BC%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 분산 락 해제 로직은 Lua 스크립트로 작성하고 Eval을 날려서 원자적으로 실행한다. 왜냐하면 락 소유권 문제 때문이다.&lt;/p&gt;
&lt;pre id=&quot;code_1772616528321&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public boolean deleteIfValueEquals(String key, String expectedValue) {
	String script = &quot;if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end&quot;;
    DefaultRedisScript&amp;lt;Long&amp;gt; redisScript = new DefaultRedisScript&amp;lt;&amp;gt;(script, Long.class);
    return redisTemplate.execute(redisScript, Collections.singletonList(key), expectedValue) &amp;gt; 0;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원자성을 제공하기 위해 위와 같이 Lua Script를 사용하는 경우 Lettuce와 같은 Redis Client는 네트워크 비용을 줄이기 위해 애플리케이션과 Redis에서 각각 (애플리케이션은 빌드 시에 기록함) 스크립트에 해시함수를 적용해 그 결과 값을 key로 캐싱한다. 그리고 EvalSha로 key 캐시 값을 호출해서 통신한다.&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;하지만 Redis에서 최초 1회 요청시에는 Key 캐시값에 일치하는 스크립트가 없으므로(캐시 미스) 에러를 던진다. 이 경우 Eval 명령어로 스크립트를 그대로 전달하면 Redis가 해당 스크립트를 캐시하므로 단지 1회 더 호출하면 된다. Lettuce의 구현을 살펴보면 아래와 같이 EvalSha 호출시 예외가 발생하면 Eval로 호출한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772616626804&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protected &amp;lt;T&amp;gt; T eval(RedisConnection connection, RedisScript&amp;lt;T&amp;gt; script, ReturnType returnType, int numKeys,
   byte[][] keysAndArgs, RedisSerializer&amp;lt;T&amp;gt; resultSerializer) {

  Object result;
  try {
   result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
  } catch (Exception ex) {

   if (!ScriptUtils.exceptionContainsNoScriptError(ex)) {
    throw ex instanceof RuntimeException runtimeException ? runtimeException
      : new RedisSystemException(ex.getMessage(), ex);
   }

   result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
  }

  if (script.getResultType() == null) {
   return null;
  }

  return deserializeResult(resultSerializer, result);
 }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redisson은 RedissonBaseLock을 사용하는 경우 Eval로만 호출한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772616651889&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protected RFuture&amp;lt;Boolean&amp;gt; unlockInnerAsync(long threadId, String requestId, int timeout) {
    return evalWriteSyncedNoRetryAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                          &quot;local val = redis.call('get', KEYS[3]); &quot; +
                                &quot;if val ~= false then &quot; +
                                    &quot;return tonumber(val);&quot; +
                                &quot;end; &quot; +

                                &quot;if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then &quot; +
                                    &quot;return nil;&quot; +
                                &quot;end; &quot; +
                                &quot;local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); &quot; +
                                &quot;if (counter &amp;gt; 0) then &quot; +
                                    &quot;redis.call('pexpire', KEYS[1], ARGV[2]); &quot; +
                                    &quot;redis.call('set', KEYS[3], 0, 'px', ARGV[5]); &quot; +
                                    &quot;return 0; &quot; +
                                &quot;else &quot; +
                                    &quot;redis.call('del', KEYS[1]); &quot; +
                                    &quot;redis.call(ARGV[4], KEYS[2], ARGV[1]); &quot; +
                                    &quot;redis.call('set', KEYS[3], 1, 'px', ARGV[5]); &quot; +
                                    &quot;return 1; &quot; +
                                &quot;end; &quot;,
                            Arrays.asList(getRawName(), getChannelName(), getUnlockLatchName(requestId)),
                            LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime,
                            getLockName(threadId), getSubscribeService().getPublishCommand(), timeout);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 다소 위험할 수도 있는 Eval 명령어와 네트워크 호출 비용을 줄이기 위한 캐시 값 사용과 이로 인한 1회 재호출까지 수행하면서까지 Redis 분산 락 로직을 원자적으로 제공해야하는 이유가 무엇일까? 단순히 get-del을 나눠서 호출하면 안되는 이유를 살펴보았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;락&amp;nbsp;소유권&amp;nbsp;문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 락 소유권 문제와 관련이 있다. 일반적으로 분산 락 로직은 Key에 TTL을 걸어 장애 상황에서 무한정 점유되는 것을 방지하며, 이 경우 아래와 같이 동작한다. 요약해보면 &lt;b&gt;요청 A가 본인이 소유하고 있다고 착각한 요청 B의 Key를 제거하면서 Race Condition이 발생&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;1. 요청 A - key lock:1 적재 성공, Lock 획득&lt;/p&gt;
&lt;pre id=&quot;code_1772616770255&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SET lock:1 uuid-A NX PX 3000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;2. 요청 A - 임계영역 진입&lt;br /&gt;3. 요청 A - lock 해제를 위해 get, 아직 TTL이 유효하므로 Value를 반환한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772616778587&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;GET lock:1 &amp;rarr; uuid-A&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;4. Redis - TTL 만료, Key 자동 삭제&lt;br /&gt;5. 동일한 임계영역에 대해 작업할 요청 B 발생 - key lock:1 적재 성공, Lock 획득, 요청 A의&amp;nbsp; key가 TTL이 만료되어 사라졌으니 성공&lt;/p&gt;
&lt;pre id=&quot;code_1772616846804&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SET lock:1 uuid-B NX PX 3000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;6. 요청 A - del 요청으로 요청 B B의 key lock:1 제거&lt;/p&gt;
&lt;pre id=&quot;code_1772616855987&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;DEL lock:1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;7. 동일한 임계영역에 대해 작업할 요청 C 발생 - key lock:1 적재 성공, Lock 획득, Race Condition 발생&lt;/p&gt;
&lt;pre id=&quot;code_1772616897171&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SET lock:1 uuid-C NX PX 3000&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Lua&amp;nbsp;Script를&amp;nbsp;사용하면?&lt;/h2&gt;
&lt;pre id=&quot;code_1772616971904&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 Redis 내부에서 비교와 삭제가&lt;b&gt; 한 번에 원자적으로 실행&lt;/b&gt;된다. Lua Script 실행 중에 Redis는 싱글 스레드이므로 다른 일을 못하므로 TTL 만료로 인한 Key 삭제가 되지 않는다. 따라서 요청 A는 반드시 자신이 소유한 Key만 제거하고 위 예시에서 요청 B의 락이 삭제되지 않아 동시성을 제어할 수 있다.&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;참고로, TTL 만료로 인한 삭제 자체는 이벤트 루프로 진행된다. 하지만 Lua 안에서 redis.call('get', key)를 호출하면 그 순간 &lt;b&gt;Key&amp;nbsp; Expire 체크&lt;/b&gt;가 먼저 수행된다. 시점에 TTL 만료되어 있으면 nil 반환, 이후 del 실행 여부가 결정된다. 여전히 해당 Key에 대해 원자적 연산이 수행 중이므로 다른 연산은 끼어들 수 없다. (요청 B의 Set 등)&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/426</guid>
      <comments>https://nooblette.tistory.com/entry/Redis-%EB%B6%84%EC%82%B0-%EB%9D%BD-%ED%95%B4%EC%A0%9C-%EB%A1%9C%EC%A7%81%EC%9D%84-Atomic%ED%95%98%EA%B2%8C-%EC%9E%91%EC%84%B1%ED%95%B4%EC%95%BC%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0#entry426comment</comments>
      <pubDate>Wed, 4 Mar 2026 18:38:13 +0900</pubDate>
    </item>
    <item>
      <title>[Redis] COW(Copy-on Write)와 사용시 주의사항</title>
      <link>https://nooblette.tistory.com/entry/Redis-COWCopy-on-Write%EC%99%80-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;COW(Copy-on&amp;nbsp;Write)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리를 효율적으로 사용하여 데이터를 복제하는 기법. 예를 들어 아래와 같이 fork 시스템 콜을 통해 자식 프로세스를 생성하는 경우, 메모리 공간을 그대로 복제하지 않고 같은 공간을 참조하도록해 메모리를 효율적으로 사용한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;435&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TESyR/dJMcagxQVqH/0LOKb0P7l1HjgnwrsD8Zb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TESyR/dJMcagxQVqH/0LOKb0P7l1HjgnwrsD8Zb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TESyR/dJMcagxQVqH/0LOKb0P7l1HjgnwrsD8Zb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTESyR%2FdJMcagxQVqH%2F0LOKb0P7l1HjgnwrsD8Zb1%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;660&quot; height=&quot;435&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;435&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로세스가 fork()를 통해 자식 프로세스를 생성하면 두 프로세스가 동일한 메모리 공간을 참조하다가 데이터&amp;nbsp;변경이&amp;nbsp;일어나면&amp;nbsp;서로&amp;nbsp;다른&amp;nbsp;메모리&amp;nbsp;공간을&amp;nbsp;바라보도록&amp;nbsp;한다.&amp;nbsp;쓰기&amp;nbsp;시점에&amp;nbsp;메모리&amp;nbsp;사용량이&amp;nbsp;발생한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dXNcE9/dJMcaaqTFxJ/2QJtN0f160z1ZJWW8PA6P0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dXNcE9/dJMcaaqTFxJ/2QJtN0f160z1ZJWW8PA6P0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dXNcE9/dJMcaaqTFxJ/2QJtN0f160z1ZJWW8PA6P0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdXNcE9%2FdJMcaaqTFxJ%2F2QJtN0f160z1ZJWW8PA6P0%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;656&quot; height=&quot;442&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;442&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis에서의&amp;nbsp;COW&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;COW는 메모리를 효율적으로 사용하기 위한 기법이지만 Redis에서는 잘못 사용할 경우 메모리 사용량이 예상치 못하게 급증할 수 있어 주의가 필요하다. 예를 들어 위 프로세스에서, 부모 프로세스가 Page C의 데이터를 수정하려면 우선 Page C를 복사(copy)한 다음 수정(write)한다. 리눅스는 페이지 프레임에 4kB 페이지를 사용한다. 10 바이트만 수정해도 한 페이지(4kB)를 복사해야 한다. (= 내부 단편화)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 1초에 1만 개의 데이터를 처리(입력/수정/삭제)하는 레디스 서버에 RDB background save가 발생해서 RDB 파일을 작성하는데 1분이 걸렸다고 하자. 그러면 60만 개의 데이터를 처리하는 것이고 데이터가 각각 다른 페이지에 들어갔다고 가정하면, 한 페이지가 4kB이므로 RDB background save가 진행되는 1분 동안 약 2.3gB의 메모리(RAM)이 추가로 필요하게 되는 것이다. 그러므로 Copy-on-Write가 발생하면 평소보다 많은 메모리가 필요한 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis에서의&amp;nbsp;COW가&amp;nbsp;발생하는&amp;nbsp;경우&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis를 안전하게 운용하려면 메모리 사용량 관리가 필수적이다. 특히 COW가 발생하면 메모리 사용량이 치솟을 수 있으므로, 발생 케이스를 인지하고 있어야한다. Redis에서의 COW가 발생하는 경우는 크게 RDB와 AOF Rewrite로 나뉜다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(RDB)&amp;nbsp;save&amp;nbsp;파라미터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;redis.conf 파일에 RDB 파일 생성 기준을 설정하고 해당 기준을 만족하면 RDB 파일을 생성한다. 이때 RDB 파일을 자식 프로세스를 생성(fork)하여 작업하는데 COW가 발생한다. 예를 들어 save 60 10000으로 설정한다면 60초간 1만개의 키에 쓰기 작업이 발생한 경우 RDB 파일을 생성한다. 물리적 메모리가 32gB인 시스템에서 레디스 서버 인스턴스가 30gB를 사용하고 있었고, RDB 파일을 새로 쓰는 동안 COW가 발생해서 3gB의 메모리가 추가로 필요했다면, Real 메모리가 부족하므로 스왑(swap)이 발생해서 처리가 늦어져서 문제가 발생할 것이다. 그러므로 COW에 대비해서 여유 메모리가 필요하다. (일반적으로 메모리 사용량에 30%를 가용 메모리 공간으로 두는 것을 권고한다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(RDB) BGSAVE 명령&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;save 파라미터는 설정 값에 의해 자동으로 RDB 파일을 생성한다면, BGSAVE 명령은 수동으로 RDB 파일 생성을 작업하는 경우를 말한다. 이때도 동일하게 자식 프로세스를 fork하고 RDB 파일에 생성하는데, 쓰기 작업이 발생하면 메모리 공간을 2배로 사용하게 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(RDB) 복제 Replication&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Master 노드가 Slave 노드와 최초로 연결했거나 Replication Offset이 너무 차이나 psync를 할 수 없는 경우 fsync로 데이터를 복제하게 되는데, 이 과정에서 RDB 파일을 생성하면서 COW가 발생한다. 이는 diskless-fsync에서도 마찬가지이다. Disk에 쓰지 않고 네트워크 소켓을 통해 데이터를 복제할 뿐, RDB 파일 생성을 자식 프로세스가 하는건 동일하기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(AOF Rewrite) auto-aof-rewirte-percentage 파라미터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;redis.conf 파일에 AOF 파일 재작성 설정 비율을 설정하고 해당 비율을 만족하는 경우 AOF 파일을 재작성하는데, 이 또한 자식 프로세스가 수행하고 이 과정에서 COW가 발생한다. 예를 들어 auto-aof-rewirte-percentage = 100으로 설정했다면, 기존 aof 파일보다 크기가 2배가 된 경우 Rewite 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(AOF Rewrite) BGREWRITEAOF 명령&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;auto-aof-rewirte-percentage 파라미터는 자동으로 Rewrite를 수행한다면, 이는 수동으로 AOF 파일을 재작성하는 경우를 말한다. 여기서 AOF 파일 재작성도 자식 프로세스가 작업한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회피 방법&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RDB save 파라미터: 사용하지 말 것을 권한다. 대신 AOF를 everysec로 사용한다.&lt;/li&gt;
&lt;li&gt;RDB BGSAVE 명령: 꼭 필요한 경우에만 서버 부하가 적을 때 사용한다.&lt;/li&gt;
&lt;li&gt;복제 Replication: 새 슬레이브 연결은 부하가 적은 때를 이용한다. 기존 슬레이브에 문제가 생겨서 전체 데이터 복제(full resync)는 어쩔 수 없다.&lt;/li&gt;
&lt;li&gt;AOF auto-aof-rewrite-percentage 파라미터: 사용하지 말 것을 권한다. 즉 0으로 설정해서 disable 한다.&lt;/li&gt;
&lt;li&gt;AOF BGREWRITEAOF 명령: 서버 부하가 적을 때 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://redisgate.kr/redis/configuration/copy-on-write.php&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://redisgate.kr/redis/configuration/copy-on-write.php&lt;/a&gt;&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/425</guid>
      <comments>https://nooblette.tistory.com/entry/Redis-COWCopy-on-Write%EC%99%80-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD#entry425comment</comments>
      <pubDate>Wed, 4 Mar 2026 18:26:29 +0900</pubDate>
    </item>
    <item>
      <title>[Redis] 복제(Replication) 원리</title>
      <link>https://nooblette.tistory.com/entry/Redis-%EB%B3%B5%EC%A0%9CReplication-%EC%9B%90%EB%A6%AC</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis는 HA를 위해 Replication를 제공한다. 이번 글에서는 Redis가 Replication를 제공하는 방법에 대해서 다룬다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;복제(Replication)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Master 노드와 Replica(= Slave) 노드로 구성하고, Master 노드의 변경 사항을 Replica 노드로 &lt;b&gt;비동기&lt;/b&gt;로 반영한다. Master 노드의 장애가 발생하면, Replica 노드를 Master 노드로 승격하여(자동 Failover) HA를 제공한다. 이때 복제는 비동기로 진행하므로 승격 과정에서 데이터 유실이 발생할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;데이터&amp;nbsp;복제&amp;nbsp;동작 원리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 Replication stream을 통해 동기화한다. 부분적으로 이 Stream이 닫히는 경우 psync와 fsync로 동기화한다. 자세한 내용은 아래와 같다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Replication stream&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Master 노드에서 데이터 변경 작업이 발생하면 해당 명령어를 Replication stream에 추가한다. Replication stream에 어느지점까지 추가되었는지 Replication offset을기록하고 해당 명령어를 TCP 소켓을 통해 Replica 노드에 전송한다. Replica 노드는 Master 노드와 TCP 연결을 유지하고 있다가 변경 사항을 전달받아 &lt;b&gt;실행(Replay)&lt;/b&gt;한다. 이 과정은 비동기로 처리되므로, 일시적으로 데이터 정합성이 맞지 않을 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;psync (partial sync)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Replica 노드가 Master 노드와 연결이 일시적으로 끊어진 경우(예를 들어 만료 시간에 도달해 TCP 소켓이 닫힌 경우) Master 노드에게 psync을 요청한다. Master 노드는 Replica 노드가 마지막으로 처리한 replication offset을 자신이 가지고 있는 replication offset과 비교하여 수정사항을 반영할 수 있다면 해당 수정사항만 부분적으로 반영한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;fsync (full sync)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Replica 노드의 변경 사항이 너무 에전 데이터이거나 최초 연결인 경우, 부분 동기화를 수행할 수 없으므로 fsync을 수행한다. Master 노드는 이때 fork를 통해 자식 프로세스를 생성하고, 자식 프로세스를 통해서 전체 동기화를 수행한다. 이 과정에서 &lt;a href=&quot;https://nooblette.tistory.com/entry/Redis-COWCopy-on-Write%EC%99%80-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%82%AC%ED%95%AD&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;COW&lt;/a&gt;로 인해 추가 메모리 공간이 필요해질 수 있어 주의해야한다. fsync는 disk 기반과 diskless (네트워크 기반)으로 나뉜다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;disk-based fsync&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;703&quot; data-origin-height=&quot;393&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKkmKB/dJMb99ZQuKG/PhoKJxyQsho4mLqn20D8z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKkmKB/dJMb99ZQuKG/PhoKJxyQsho4mLqn20D8z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKkmKB/dJMb99ZQuKG/PhoKJxyQsho4mLqn20D8z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKkmKB%2FdJMb99ZQuKG%2FPhoKJxyQsho4mLqn20D8z0%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;703&quot; height=&quot;393&quot; data-origin-width=&quot;703&quot; data-origin-height=&quot;393&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Master 노드의 자식 프로세스는 현재 메모리의 스냅샷인 RDB 파일을 생성하고 Replica 노드에 전달한다. Replica 노드는 해당 RDB 파일을 디스크를 통해 전달받고 메모리에 올린다. Master 노드가 RDB 파일을 생성하고 Replica 노드가 메모리에 올리는 과정 동안 Master 노드에서 발생한 변경사항은 Replication Buffer에 기록된다. Replica 노드가 Disk를 통해 전달받은 RDB 파일을 모두 메모리에 올리고나면 Replication Buffer에 기록된 명령어를 실행(Replay)하여 Master 노드와 정합성을 맞춘다. 만약 이 과정에서 Disk 공간이 부족하면 계속해서 fsync를 반복하게 된다. 이 경우 Slave 노드를 Cluster에서 제외하고 (일시적으로 Replica를 사용하지 않음) Disk를 증설한뒤 다시 Master 노드에 연결해준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;diskless fsync&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bufbpS/dJMb99S2GqV/42rueqh2dCfmfYgXutZRbK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bufbpS/dJMb99S2GqV/42rueqh2dCfmfYgXutZRbK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bufbpS/dJMb99S2GqV/42rueqh2dCfmfYgXutZRbK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbufbpS%2FdJMb99S2GqV%2F42rueqh2dCfmfYgXutZRbK%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;702&quot; height=&quot;390&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;390&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Master 노드의 자식 프로세스가 Disk를 통해 Master 노드의 변경 사항을 복제하는 것이 아니라 Memory를 통해 전달한다. Master 노드가 RDB 파일을 생성하고 Replica 노드가 메모리에 올리는 과정 동안 Master 노드에서 발생한 변경사항은 Replication Buffer에 기록된다. Replica 노드가 네트워크를 통해 전달받은 RDB 파일을 모두 메모리에 올리고나면 Replication Buffer에 기록된 명령어를 실행(Replay)하여 Master 노드와 정합성을 맞춘다. (disk-based fsync와 동일) Master 노드가 디스크에 RDB 스냅샷을 생성하거나, Replica 노드의 디스크 공간 관리가 별도로 필요하지 않다. diskless fsync도 fork()를 통해 자식 프로세스가 데이터를 복제하므로 COW가 발생한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://redisgate.kr/redis/configuration/replication.php&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://redisgate.kr/redis/configuration/replication.php&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pius712.tistory.com/53#toc3&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://pius712.tistory.com/53#toc3&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>데이터베이스</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/424</guid>
      <comments>https://nooblette.tistory.com/entry/Redis-%EB%B3%B5%EC%A0%9CReplication-%EC%9B%90%EB%A6%AC#entry424comment</comments>
      <pubDate>Wed, 4 Mar 2026 18:16:41 +0900</pubDate>
    </item>
    <item>
      <title>[JVM] Garbage Collection(GC) 동작 원리와 알고리즘</title>
      <link>https://nooblette.tistory.com/entry/JVM-Garbage-CollectionGC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 또는 Kotlin 환경에서 백엔드 개발 업무를 하면서 그 전체 동작 구조를 이해하는 것은 필수적이다. 장애 발생시 빠른 대응이나 기술적 문제를 개선하여 서비스 성장에 기여할 수 있기 때문이다. 이번 글에서는 그 중 JVM의 Heap 영역을 다루는 프로세스인 GC를 Garbage&amp;nbsp;Collection(GC)를 다룬다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Garbage&amp;nbsp;Collection(GC)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java의 메모리 관리 방법 중의 하나, JVM(자바 가상 머신)의 &lt;b&gt;Heap 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객체(Garbage)를 모아 주기적으로 제거&lt;/b&gt;하는 프로세스이다.&amp;nbsp;주 목표는 개발자가 메모리 해제를 신경 쓰지 않으면서, &lt;b&gt;서비스에 방해가 안 되게, 서비스 영향 시간을 최소화하여&amp;nbsp;&lt;/b&gt;조용히 메모리를 관리하는 것이다. 이러한 GC가 없는 경우 개발자가 직접 힙 메모리를 관리해주어야하는데, 대표적으로 C/C++는 동적으로 할당한 메모리를 개발자가 직접 정리해야하고, 만약 이를 누락하면 메모리 누수(Memory Leak)가 발생할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GC의 기본 원리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GC는 가장 기본적으로 &lt;b&gt;Reachability(도달 가능성)&lt;/b&gt;과 &lt;b&gt;Mark And Sweep&lt;/b&gt; 기법으로 사용하지 않는 메모리(이 글에서 메모리는 모두 JVM에서 Heap Area를 말한다.) 판별하고 정리한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Reachability(도달&amp;nbsp;가능성)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1698&quot; data-origin-height=&quot;712&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dMyugI/dJMcagq58G2/OznXfKb30JHz2y6svh3b90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dMyugI/dJMcagq58G2/OznXfKb30JHz2y6svh3b90/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dMyugI/dJMcagq58G2/OznXfKb30JHz2y6svh3b90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdMyugI%2FdJMcagq58G2%2FOznXfKb30JHz2y6svh3b90%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;1698&quot; height=&quot;712&quot; data-origin-width=&quot;1698&quot; data-origin-height=&quot;712&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM 메모리에서는 인스턴스화되는 객체들은 실질적으로 Heap 영역에서 생성되고 Method Area이나 Stack Area에서는 Heap Area에 생성된 객체의 주소만 참조하는 형식으로 구성된다. 하지만 이렇게 생성된 Heap Area의 객체들은 메서드가 끝나는 등의 특정 이벤트들로 인하여 Heap Area 객체의 메모리 주소를 가지고 있는 참조 변수가 삭제되는 현상이 발생하면, 위의 그림에서의 빨간색 객체와 같이&lt;b&gt; 어디서든 참조하고 있지 않은 객체(Unreachable)&lt;/b&gt;들이 발생하게 된다. 이러한 객체들을 주기적으로 가비지 컬렉터가 동작하여 제거하는 것이 기본 원리이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;&lt;b&gt;참고&lt;br /&gt;&lt;/b&gt;&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Method Area(메소드 영역) : 클래스 변수의 이름, 타입, 접근제어자 등과 같은 클래스와 관련된 정보를 저장한다. 그외에도 static 변수, 인터페이스 등을 저장한다. 모든 스레드에서 공유하며, JVM 시작시 생성되고 프로그램 종료까지 유지된다.&lt;/li&gt;
&lt;li&gt;Heap Area (힙 영역) : new 키워드를 통해서 생성된 객체와 배열의 인스턴스가 저장되는 위치. 힙영역에서 생성된 객체는 스택 영역의 변수나 다른 객체의 필드에서 참조된다. 명시적으로 null로 선언되거나, 어느곳에서도 참조되지 않는 객체가 GC의 대상이 된다.&lt;/li&gt;
&lt;li&gt;Stack Area (스택 영역) : 메소드가 실행되면 스택 영역에 메소드에 대한 영역이 1개 생성된다. 스택 영역에서는 지역변수, 매개변수, 리턴값 등을 저장되며, 메소드가 호출될 때마다 프레임이라고 불리는 영역으로 할당되며 프레임별로 관리됩니다. Primitive 타입은 바로 스택 영역에 저장되고, 그 외에는 힙 영역이나 메소드 영역의 저장되고 스택 영역에서는 이 저장된 위치에 해당하는 주소를 저장한다.&lt;/li&gt;
&lt;li&gt;PC register : 스레드가 시작될 때 생성되며, 현재 스레드가 실행되는 부분의 주소와 명령을 저장한다.&lt;/li&gt;
&lt;li&gt;Native Method Stack : 자바 외의 언어(대표적으로 C/C++)로 작성된 코드를 위한 메모리 영역. JNI (Java Native Interface)에 의해 실행된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JVM Garbage Collection의 기본 원리&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;740&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PBI83/dJMcaa5uPf5/yeOdYgTtlpCwMayNVt5FJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PBI83/dJMcaa5uPf5/yeOdYgTtlpCwMayNVt5FJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PBI83/dJMcaa5uPf5/yeOdYgTtlpCwMayNVt5FJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPBI83%2FdJMcaa5uPf5%2FyeOdYgTtlpCwMayNVt5FJ1%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;1440&quot; height=&quot;740&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;740&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM에서 GC의 기본 동작은 Mark-Sweep-Compact 3단계로 구성되며 그 절차는 아래와 같다.&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;Mark(식별): 객체가 Root Set(Method area, Static 변수, Stack, JNI 레퍼런스등)에서 도달 가능한지 판단하여 Reachable 객체와 Unreachable 객체를 식별(Mark)한다.&lt;/li&gt;
&lt;li&gt;Sweep(제거): Mark되지 않은 객체(Unreachable 객체)를 JVM Heap 영역에서 제거(Sweep)한다.&lt;/li&gt;
&lt;li&gt;Compact(압축): Sweep 후에 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 압축하며, 이를 통해 외부 단편화를 해소한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GC의&amp;nbsp;Root&amp;nbsp;Space&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1654&quot; data-origin-height=&quot;726&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Y4Lop/dJMcai3s5pa/Vn5kLz9MoRzbcGozGczO80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Y4Lop/dJMcai3s5pa/Vn5kLz9MoRzbcGozGczO80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Y4Lop/dJMcai3s5pa/Vn5kLz9MoRzbcGozGczO80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FY4Lop%2FdJMcai3s5pa%2FVn5kLz9MoRzbcGozGczO80%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;1654&quot; height=&quot;726&quot; data-origin-width=&quot;1654&quot; data-origin-height=&quot;726&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mark And Sweep 방식은 Root Set으로 부터 해당 객체에 접근이 가능한지가 해제의 기준이 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;Stop-The-World (STW)&lt;br /&gt;&lt;/b&gt;GC로 인해 발생하는 문제로 대표적으로 Stop-The-World (STW)가 있다. GC 과정에서 Stop-The-World (STW)는 필수적이며, JVM에서 GC 발전 과정은 이 Stop-The-World (STW)를 예측 가능하게 제어하고, 그로 인한 서비스 중단 시간을 줄이는데에 의의가 있다.&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;742&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d3TtBF/dJMcahDwFEj/YbSzwlbtkf7jYnKIlKsX11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d3TtBF/dJMcahDwFEj/YbSzwlbtkf7jYnKIlKsX11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d3TtBF/dJMcahDwFEj/YbSzwlbtkf7jYnKIlKsX11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd3TtBF%2FdJMcahDwFEj%2FYbSzwlbtkf7jYnKIlKsX11%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;314&quot; height=&quot;379&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;742&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 GC 과정에서 STW는 왜 필수적일까? 그 이유는 GC가 Mark 작업을 수행하는 순간, 객체의 참조 관계가 변동되어서는 안 되기 때문이다. 만약 GC가 한 객체를 Mark 하려는 순간 애플리케이션 스레드가 그 객체의 참조를 끊거나(Nullify) 새로운 참조를 만들면 GC의 정확성이 무너진다. 따라서 GC는 일시적으로 &lt;b&gt;모든 애플리케이션 스레드를 멈춘 후&lt;/b&gt;에 안전하게 메모리를 정리한다. 앞서 말했듯이, GC의 모든 발전은 이 &lt;b&gt;STW 시간을 줄이거나, STW 중 해야 할 작업을 애플리케이션 실행과 가급적 동시에 수행하는&lt;/b&gt;&amp;nbsp;것에 집중되어 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Generational GC&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GC의 효율성을 획기적으로 높인 핵심 전략, Mark and Sweep을 전체 Heap에서 매번 수행하는 비효율성을 해소한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;약한&amp;nbsp;세대&amp;nbsp;가설&amp;nbsp;(Weak&amp;nbsp;Generational&amp;nbsp;Hypothesis)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;626&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ykS7T/dJMcaivEAF8/c9jZFI0VLKjJaTEmT80pk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ykS7T/dJMcaivEAF8/c9jZFI0VLKjJaTEmT80pk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ykS7T/dJMcaivEAF8/c9jZFI0VLKjJaTEmT80pk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FykS7T%2FdJMcaivEAF8%2Fc9jZFI0VLKjJaTEmT80pk1%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;1666&quot; height=&quot;626&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;626&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;약한 세대 가설이 주장하는 바는 다음과 같다. 이 가설을 통해 Heap을 &lt;b&gt;Young와&lt;/b&gt; &lt;b&gt;Old&lt;/b&gt;&amp;nbsp;영역으로 분리하여 차등 관리합니다.&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;대부분의 객체는 금방 죽는다 (Infant Mortality): 생성된 지 얼마 안 된 객체의 소멸률이 매우 높다.&lt;/li&gt;
&lt;li&gt;오래된 객체에서 젊은 객체로의 참조는 매우 적다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Young&amp;nbsp;Generation의&amp;nbsp;상세&amp;nbsp;원리&amp;nbsp;(Minor&amp;nbsp;GC)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1750&quot; data-origin-height=&quot;608&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEE4vs/dJMcabQQ0Mo/gefxqG0ijxnILuhhPwsSZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEE4vs/dJMcabQQ0Mo/gefxqG0ijxnILuhhPwsSZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEE4vs/dJMcabQQ0Mo/gefxqG0ijxnILuhhPwsSZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEE4vs%2FdJMcabQQ0Mo%2FgefxqG0ijxnILuhhPwsSZk%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;1750&quot; height=&quot;608&quot; data-origin-width=&quot;1750&quot; data-origin-height=&quot;608&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Young Generation은 Eden 영역과 Survivor 0/1 영역으로 나뉜다.&lt;br /&gt;Eden 영역은 새로운 객체가 할당되는 최초의 공간이며, 전체 Young 영역의 약 80%를 차지한다. Survivor 영역은 두 개(Survivor 0/Survivor 1)로 나뉘어 있으며 각각 10%씩 차지한다. 항상 하나는 사용 중(From), 다른 하나는 비어 있는(To) 상태를 유지한다.&lt;br /&gt;Generation GC에서 Minor GC는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Mark &amp;amp; Copy로 동작하는데, &lt;/span&gt;Eden과 From Survivor 영역에서 살아남은 객체만 To Survivor 영역으로 복사한다. 이때 Age 카운트로 복사된 객체의 수명을 관리하여&amp;nbsp; Old 영역으로의 Promotion 여부를 결정합니다. Age Count는 Object Header에 기록된다. (JVM 중 가장 일반적인 HotSpot JVM의 경우 이 age의 기본 임계값은 31이다.)&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;Minor GC 후에는 기존의 Eden과 From 영역은 통째로 비우고, From과 To 영역의 역할을 교체하는 Flip이 발생한다. 이때 Survivor 영역의 제한 조건으로 Survivor 영역 중 반드시 1개는 사용되어야 하고, 나머지는 비어 있어야 한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 모두 사용량이 0이라면 현재 시스템이 정상적인 상황이 아니라는 반증이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;복사하는 작업은 삭제하는 작업보다 대게 효율적이다. 또한 복사 과정에서 객체가 연속된 메모리 공간으로 배치되므로 Compaction 효과를 얻을 수 있고 외부 단편화를 방지할 수 있다.&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;Minor GC의 동작 흐름을 정리해보면 다음과 같다.&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;처음 생성된 객체는 Young Generation 영역 중 Eden 영역에 위치한다.&lt;/li&gt;
&lt;li&gt;객체가 계속 생성되어 Eden 영역이 가득 차면 Minor GC가 실행된다.&lt;/li&gt;
&lt;li&gt;Mark를 통해 Eden 영역에서 Reachable 객체를 식별한다. 살아남은 객체는 From Survivor(Survivor 0/1 중 하나)로 이동하고 그렇지 않는 객체는 메모리를 해제한다. (Sweep)&lt;/li&gt;
&lt;li&gt;모든 살아남은 객체는 Age 카운트 값이 1 증가한다.&lt;/li&gt;
&lt;li&gt;다시 Eden 영역이 새로운 객체들로 가득차면 Minor GC가 발생한다.&lt;/li&gt;
&lt;li&gt;이때 Eden 영역과 From Survivor에서 살아남은 객체들은 To Survivor로 복사하여 이동한다. 또한 다시 살아남은 객체들의 Age 카운트 값이 1 증가한다.&lt;/li&gt;
&lt;li&gt;이후 두 Survivor의 역할을 교체(Flip)한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Old&amp;nbsp;Generation과&amp;nbsp;Promotion&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Minor GC를 여러 번(Age 카운트를 기록하는 bit 크기에 따라 15번에서 31번) 살아남아 Age Threshold에 도달한 객체는 Young에서 Old 영역으로 Promotion된다. Old 영역은 크기가 매우 커서 Compaction이 필수적이며, 여기서 발생하는 GC를 Major GC (혹은 Full GC)라고 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Major GC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체들이 계속 Promotion되어 Old 영역의 메모리가 부족해지면 동작한다. Old 영역에 할당된 메모리가 허용치를 넘게 되면, Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 한꺼번에 삭제하는 Major GC가 실행된다. 하지만 Old Generation은 Young Generation에 비해 상대적으로 큰 공간을 가지고 있어, 이 공간에서 메모리 상의 객체 제거에 많은 시간이 걸리게 된다.&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;예를들어 Young 영역은 일반적으로 Old 영역보다 크키가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝나 애플리케이션에 크게 영향을 주지 않지만, Old 영역의 Major GC는 일반적으로 Minor GC보다 10배 이상의 시간을 사용하고, 이로 인해 Stop-The-World 문제가 발생한다. Major GC가 일어나면 Thread가 멈추고 Mark and Sweep 작업을 해야 해서 CPU에 부하를 주기 때문에 멈추거나 버벅이는 현상이 일어난다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Card Table (세대 간 참조 추적)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Generational GC의 핵심 난제는 &lt;b&gt;Minor GC 시 Old 영역을 스캔해야하는가?&lt;/b&gt;&amp;nbsp;이다. 이 가설에 따르면 Old -&amp;gt; Young 참조는 적지만, 만약 Old 객체가 Young 객체를 참조하고 있다면, Minor GC 시 해당 Young 객체를 제거해선 안된다. JVM은 이를 위해 Card Table을 제공한다.&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;Old 영역을 작은 단위(Card)로 나누고, Old 객체가 Young 객체를 참조할 때마다 해당 Card에 Dirty Bit를 설정한다. (Write Barrier 사용) Minor GC 시에는 전체 Old 영역 전체 대신 &lt;b&gt;Old 영역 중 Dirty Bit가 설정된 Card만 스캔&lt;/b&gt;하여 Young 객체의 생존 여부를 판단한다. 이로써 &lt;b&gt;Minor GC의 속도를 극대화&lt;/b&gt;한다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Write Barrier&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM이 객체 필드에 참조를 기록(write)할 때 실행되는 작은 코드 조각 JVM은 Old 영역을 Card(보통 512B ~ 1KB 단위)로 잘게 나누고, 각 카드에 해당하는 메타 데이터를 Card Table 배열에 보관한다. 이때 특정 카드 안의 객체가 Young 객체를 참조하면 Dirty Bit를 1로 기록한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Serial&amp;nbsp;GC&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;742&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d3TtBF/dJMcahDwFEj/YbSzwlbtkf7jYnKIlKsX11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d3TtBF/dJMcahDwFEj/YbSzwlbtkf7jYnKIlKsX11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d3TtBF/dJMcahDwFEj/YbSzwlbtkf7jYnKIlKsX11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd3TtBF%2FdJMcahDwFEj%2FYbSzwlbtkf7jYnKIlKsX11%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;314&quot; height=&quot;379&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;742&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 CPU 코어가 1개일 때 사용하기 위해 개발된 가장 단순한 GC, GC를 처리하는 쓰레드가 1개 (싱글 쓰레드) 이어서 가장 stop-the-world 시간이 오래 걸린다. Minor GC 에는 Mark-Sweep을 사용하고, Major GC에는 Mark-Sweep-Compact를 사용한다. 디바이스 성능이 안좋아서 CPU 코어가 1개인 경우에만 사용하며, 성능 문제로 보통 실무에서 사용하는 경우는 거의 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Parallel GC&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnQbao/dJMcacCfuIW/4rRKNju8o2lOtvqCjiWqbK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnQbao/dJMcacCfuIW/4rRKNju8o2lOtvqCjiWqbK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnQbao/dJMcacCfuIW/4rRKNju8o2lOtvqCjiWqbK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnQbao%2FdJMcacCfuIW%2F4rRKNju8o2lOtvqCjiWqbK%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;1564&quot; height=&quot;832&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;832&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;STW는 발생하지만 처리량(Throughput)을 극대화하여 전체 GC 시간을 줄이자는 목표에서 등장했다. Minor GC와 Major GC 모두 멀티 스레드를 사용(GC 스레드를 여러개 투입하여 작업을 병렬로 처리)하여 STW 시간을 단축한다. 하지만 멀티 스레드를 사용한다고 하더라도 Root scanning, Marking(Reachability 분석), Sweeping, Compaction(메모리 단편화 제거)은 결국 전체 Heap 크기에 의존하게 되어, 즉 Heap의 크기가 커지면 Full GC에서 병목이 발생한다. (참고) Java 8의 디폴트 GC이다.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CMS&amp;nbsp;GC&amp;nbsp;(Concurrent&amp;nbsp;Mark&amp;nbsp;Sweep)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1758&quot; data-origin-height=&quot;990&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IZHW9/dJMcajnK6kR/eFa5UCK8yTCzNPFODTAcNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IZHW9/dJMcajnK6kR/eFa5UCK8yTCzNPFODTAcNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IZHW9/dJMcajnK6kR/eFa5UCK8yTCzNPFODTAcNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIZHW9%2FdJMcajnK6kR%2FeFa5UCK8yTCzNPFODTAcNk%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;1758&quot; height=&quot;990&quot; data-origin-width=&quot;1758&quot; data-origin-height=&quot;990&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가능한 STW를 줄이고, Mark와 Sweep을 애플리케이션 스레드와 동시에 작업하여 애플리케이션 지연 시간(Latency) 최소화하자는 목표에서 등장했다. Mark와 Sweep의 상당 부분을 애플리케이션 스레드와 동시에(Concurrent) 수행하여 STW 구간을 극단적으로 줄인다. 하지만 Compaction 단계는 애플리케이션과 동시에 수행할 수 없어 누적되고, 결국 누적된 단편화를 해소하기 위해 강제로 Full GC가 발생할 때 STW가 길어진다. 또한 Concurrent 작업 때문에 GC 스레드가 상주하며 CPU 자원을 소모하게 된다. 여러 문제점과 G1 GC의 등장으로 인해 Java 9부터 Deprecated 되었고, Java 14에서는 사용이 중지 되었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;G1 GC (Garbage First)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;G1 GC는 CMS GC의 한계를 극복하고 대규모 힙에서 안정적인 성능을 내기 위해 등장했으며, Java 9에서 기본 GC로 채택되었다. 4GB 이상의 힙 메모리, Stop the World 시간이 0.5초 정도 필요한 상황에 사용이 적절하다. Heap이 너무 작을경우는 Serial GC가 적절하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Region&amp;nbsp;기반&amp;nbsp;Heap&amp;nbsp;구조&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1748&quot; data-origin-height=&quot;600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxaHL9/dJMcafeHuxi/QWc0oAt8cPf6zMIRuFtm40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxaHL9/dJMcafeHuxi/QWc0oAt8cPf6zMIRuFtm40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxaHL9/dJMcafeHuxi/QWc0oAt8cPf6zMIRuFtm40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxaHL9%2FdJMcafeHuxi%2FQWc0oAt8cPf6zMIRuFtm40%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;1748&quot; height=&quot;600&quot; data-origin-width=&quot;1748&quot; data-origin-height=&quot;600&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;힙 전체를 Region(1MB ~ 32MB)이라는 논리적 단위로 나누어 관리한다. 각 Region은 필요에 따라 Eden, Survivor, Old, Humongous(Region 크기의 50%를 초과하는 대형 객체) 영역의 역할을 맡고, 이 역할은 동적으로 주어진다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RSet(Remembered&amp;nbsp;Set)과&amp;nbsp;Minor&amp;nbsp;GC&amp;nbsp;최적화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;G1 GC는 RSet(Remembered Set)을 통해 Minor GC를 최적화한다. G1 GC는 전체 Old 영역을 매번 스캔하지 않고, Region 단위 GC를 수행하는데, 이를 위해 외부에서 어떤 Region이 자신을 참조하고 있는지를 RSet이라는 자료 구조에 저장한다. 각 Region은 자신의 RSet을 갖고 있으며, RSet에는 자신을 참조하고 있는 외부 Region 번호와 Card (외부 Region에서 자신을 참조하는 위치를 나타내는 최소 단위) Index 목록이 저장된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RSet과 Card Table과의 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Generation GC에서 Card Table은 참조를 만드는 쪽(쓰는 쪽)에서만 update되며, dirty bit만 표시하는 매우 단순한 배열이었다. 반면 G1 GC에서 RSet은 참조를 받는 Region의 RSet에 자신을 참조하는 외부 Region 번호와 그 위치 목록을 저장한다. 이를 통해 실제 GC 수행 시 내 Region을 참조하는 곳만 선택적으로 스캔할 수 있게 한다. 이처럼 훨씬 구조화되어 있는 고수준 데이터로 GC를 최적화한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Minor GC 최적화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 RSet은 &amp;ldquo;누가 나를 참조하는가?&amp;rdquo;를 나타내고 있으므로, Young GC(Minor GC)동안 Old -&amp;gt; Young으로 참조를 추적할 수 있다. Young GC 시 전체 Old 영역을 스캔하지 않고, Young Region의 RSet에 저장된 외부 Region의 특정 Card 영역들만 스캔하여 Young을 참조하는 Old Region만 확인할 수 있다. 이를 통해 성능과 GC 시간을 크게 단축한다.&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;예를 들어 Young Region 20을 GC할 때 RSet(20)은 아래와 같다고 가정해보자.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;Old&amp;nbsp;Region&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;Card Index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;10&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;354&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;10&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;356&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;33&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;이 경우 GC는 Old Region 안의 특정 Card 영역 (Old Region 10의 Card 354, Old Region 10의 Card 356, Old Region 33의 Card 44)만 스캔한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Write Barrier&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 Old &amp;rarr; Young 참조를 새로 만들 때, Write Barrier가 동작하여, 대상 Old Region의 Card Table에 Dirty Bit를 설정하여 참조가 존재함을 표시힌다. 이 Dirty Card 정보는 Queue에 들어가고 GC background Threads(Refinement Threads)가 처리하여 Old Region에서 참조하는 Young Region의 RSet을 갱신한다. 즉, 대상 Young Region의 RSet에 Old Region 번호와 Card index를 추가한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;G1&amp;nbsp;GC의&amp;nbsp;핵심&amp;nbsp;프로세스:&amp;nbsp;Mixed&amp;nbsp;GC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mixed GC는 G1 GC에서 Full GC를 대체하는 부분 청소(Partial GC) 방식으로 메모리를 정리한다. 이는 전체 Old 영역을 한 번에 스캔하지 않고, Garbage 비율이 높은 &lt;b&gt;일부 Region만 선택적으로 수거&lt;/b&gt;한다. Young Region과 선택된 Old Region을 동시(= Mixed)에 청소할 수 있으며, 이를 통해 Pause time을 일정 수준으로 유지(GC 소요 시간을 예측 가능)할 수 있는 구조가 된다.&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;Mixed GC의 동작 단계를 정리해보면 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Collection Set(CSet) 선정: Garbage 비율이 높은 Old Region을 통계적으로 선정한다. 이때 Young Region의 Minor GC도 포함된다. Pause Prediction Model을 사용하여, 목표 Pause 시간(MaxGCPauseMillis)을 초과하지 않도록 Region 수를 조정한다.&lt;/li&gt;
&lt;li&gt;Copy 기반 청소(STW): 선정된 CSet의 Region 내부에서 살아있는 객체만 Free List의 비워진 Region으로 복사한다. 원래 Region은 비워지고 해제된다. 부분 STW(Pause) 상태에서 수행되며, 이 과정에서 자연스레 객체가 연속적으로 배치되며 Compaction이 일어나 외부 단편화가 해소된다.&lt;/li&gt;
&lt;li&gt;RSet 활용: Old &amp;rarr; Young 참조 정보를 RSet에서 확인하면서, Young 객체 참조를 안전하게 유지하며 Old Region을 정리한다.&lt;/li&gt;
&lt;li&gt;청소 결과 반영: CSet에서 비워진 Region은 다시 Free List에 추가한다. (Mixed GC의 Copy 기반 청소는 Free List에 항상 비워진 Region이 준비되어 있다는 전제에서 동작한다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;목적과 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;G1 GC의 이 Mixed GC는 STW 시간이 MaxGCPauseMillis 목표를 넘지 않도록 하는 &lt;b&gt;통계 기반의 예측 모델(Pause Prediction Model)&lt;/b&gt;을 사용하여 CSet을 선정한다. G1 GC는 Copy-based Evacuation 방식을 사용하므로, 살아있는 객체를 이동할 &lt;b&gt;빈 Region(Free List)&lt;/b&gt;이 항상 필요하다. Free List가 충분히 준비되지 않으면 Young 객체에서 Old 객체로의 Promotion Failure, 강제 Full GC 등이 발생하여 G1 GC의 예측 가능한 Pause 시간 전략이 깨질 수 있다.&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;2MB 이상 객체는 Humongous Region으로 할당되는데, 연속된 여러 Region을 사용한다. 큰 객체가 많으면 Free List에서 연속된 Region 확보가 어려워 Copy 기반 청소가 예상대로 동작하기 어려워진다. Old Region으로 살아있는 객체 밀도가 높거나(Garbage 대상 Region으로 잡히지 않음) Humongous Region이 많으면 외부 단편화가 발생할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ZGC (Z Garbage Collector)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;834&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NGNsq/dJMcadujUXU/sPwp9lwab1QPjFbHLUBlx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NGNsq/dJMcadujUXU/sPwp9lwab1QPjFbHLUBlx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NGNsq/dJMcadujUXU/sPwp9lwab1QPjFbHLUBlx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNGNsq%2FdJMcadujUXU%2FsPwp9lwab1QPjFbHLUBlx1%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;660&quot; height=&quot;423&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;834&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수백 GB 이상의 대규모 Heap과 Humongous Object 환경에서도 STW 시간을 10ms 이하로 보장하는 것이 목표인 최신 GC, Java 11에서 처음 공개되었으며 15부터 정식 GC로 채택되었다.&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;G1의 Region 처럼, ZGC는 ZPage라는 영역을 사용하는데, G1의 Region은 크기가 고정인데 비해, ZPage는 2mb 배수로 동적으로 운영된다. 즉 큰 객체가 들어오면, 2^ 로 영역을 구성해서 처리하고, 이를 통해 Humongous Object로 다룰 수 있다. 이를 통해 G1 GC의 한계(외부 단편화, Humongous Object, 긴 STW)를 극복한다.&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;Heap 크기 수 TB 이상에서도 예측 가능한 낮은 STW(Pause) 유지한다. 힙 크기가 증가하더도 Stop-the-world 시간이 절대 10ms를 넘지 않는 것이 목적이다. Humongous Object와 외부 단편화 문제를 해결하고, 애플리케이션과 GC가 &lt;b&gt;동시에 수행&lt;/b&gt;되는 구조를 구현하여 STW를 최소화한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Heap 구조와 객체 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZGC도 G1 GC처럼 Heap을 Region 단위로 나누어 관리한다.&amp;nbsp; Humongous Object는 여러 단위로 쪼개어 여러 Region에 걸쳐 저장한다. 이때 &lt;b&gt;Colored Pointer와 Forwarding Table&lt;/b&gt;를 이용하여 메모리 주소를 계속 참조하여 안전하게 이동할 수 있다. 이를 통해 &lt;b&gt;물리적으로 연속된 Region을 요구하지 않고 논리적 연속성을 유지&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Colored Pointers&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;64비트 포인터 주소 중 사용되지 않는 상위 비트(Bit)를 이용하여 &lt;b&gt;객체의 상태(Marked, Remapped, Finalizable 등)를 인코딩&lt;/b&gt;한다. GC가 객체 상태를 추적하기 위해 별도의 메타데이터를 관리할 필요 없이, &lt;b&gt;포인터 주소 자체만 보고 객체 상태를 파악&lt;/b&gt;할 수 있다. 이로써 대부분의 Mark와 Relocate 작업을 애플리케이션을 실행하면서 동시에(Concurrent하게) 수행한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Load Barriers&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 스레드가 객체를 읽을때 마다 JVM이 미리 삽입해둔 Load Barrier 코드를 통해 GC가 청소 중(= 메모리 주소가 이동 중)인 객체인지 확인한다. 이를 통해 &lt;b&gt;객체 이동(Concurrent Relocate) 중에도 애플리케이션 스레드가 멈추지 않고 실행&lt;/b&gt;할 수 있다.&lt;br /&gt;만약 Relocation 중인 객체를 발견하면, 그 객체를 새로운 주소로 이동시킨 후 애플리케이션에 반환하여, 애플리케이션이 항상 최신 주소를 참조하도록 보장한다. 이때 Forwarding Table 을 통해 최신 주소를 참조한다.&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;Forwarding Table은 &lt;b&gt;원래 주소 &amp;rarr; 새 주소 매핑&lt;/b&gt;을 기록하여, 이동 중 객체 주소를 안전하게 관리한다. 애플리케이션이 Load Barrier를 통해 객체를 읽으면, Forwarding Table을 참조하여 최신 주소를 반환한다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GC 프로세스 (Concurrent-First)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZGC는 Initial Mark를 제외한 모든 Mark, Relocate 작업을 애플리케이션 스레드와 Concurrent하게 처리하여 Puase 시간을 극단적으로 최소화한다. STW는 시작(Initial Mark) 및 종료(Remark &amp;amp; Cleanup)시에만 극히 짧게 발생하며, 대규모 Heap에서도 STW 시간은 O(1)에 가깝게 유지된다. 이는 Concurrent Mark 동안 발생한 참조 변경은 Barrier가 기록해둔 SATB Buffer와 Root Set을 직접 참조하는 객체만 처리하여 STW시에 정합성 보정 작업만 수행하기 때문이다. 자세한 작업 순서는 다음과 같다.&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;Initial Mark (STW): Root Set에서 직접 참조되는 객체를 마킹한다. Pause 시간 매우 짧으 며(수 ms 수준), Heap 전체 스캔 없이 최소한의 마킹만 수행한다. Initial Mark 단계에서 최소한의 STW를 발생시키고, 나머지 Marking과 Relocate 작업은 Concurrent하게 진행한다. 이때 Root Set (Stack Area, Method Area 등)에서 &lt;b&gt;직접 참조 중인 객체&lt;/b&gt;를 확보합니다.&lt;/li&gt;
&lt;li&gt;Concurrent Mark (Concurrent): 애플리케이션과 동시(Concurrent)에 Heap 전체에서 살아있는 객체를 탐색하고 마킹한다. 이는 Colored Pointer를 활용하여 객체 상태(Marked, Remapped 등)를 포인터 자체에서 확인할 수 있어 STW를 극복한다.&lt;/li&gt;
&lt;li&gt;Concurrent Relocate: 살아있는 객체를 새 Region으로 이동하고 Heap 압축(Compaction)을 수행한다. 애플리케이션과 동시(Concurrent)에 실행한다. 이때는 Load Barriers와 Forwarding Table을 활용하여 애플리케이션이 항상 최신 객체 주소를 참조 가능하게 보장한다. Humongous Object를 여러 단위로 쪼개어 여러 Region에 걸쳐 저장하고, Forwarding Table을 이용하여 메모리 주소를 계속 참조하여 논리적 연속성을 유지하며 이동할 수 있다. 이를 통해 Heap의 외부 단편화를 해결한다.&lt;/li&gt;
&lt;li&gt;Remark &amp;amp; Cleanup (부분 STW): Colored Pointer는 메모리 참조 변경과 Colored Pointer를 통해 마킹하는 것을 완전히 동기화할 수 없다. 이 작업은 원자적이지 않기 때문이다. 이러한 오차를 완전히 맞추기 위해 Remark 단계를 거치는데 이는 STW를 유발한다. 이때 Concurrent Mark 중 변경된 참조 관계를 최종 확인한다. Concurrent Mark 중 참조가 바뀐 객체는 SATB Buffer에 저장되는데, 이를 기반으로 최종 Garbage ZPage를 분석하여 미사용 객체를 힙에서 정리합니다. 이는 Cleanup 단계로 살아있는 객체가 없는 Zpage를 Free List로 반환한다. 이 순간 만큼은 애플리케이션 스레드에서 더이상 참조 변경이 일어나면 안되므로 STW를 유발한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%EC%85%98GC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%F0%9F%92%AF-%EC%B4%9D%EC%A0%95%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%EC%85%98GC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%F0%9F%92%AF-%EC%B4%9D%EC%A0%95%EB%A6%AC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mangkyu.tistory.com/118&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://mangkyu.tistory.com/118&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Java &amp;amp; Kotlin</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/423</guid>
      <comments>https://nooblette.tistory.com/entry/JVM-Garbage-CollectionGC-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98#entry423comment</comments>
      <pubDate>Wed, 4 Mar 2026 17:52:35 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] @RefreshScope 동작 원리와 주의 사항</title>
      <link>https://nooblette.tistory.com/entry/Spring-RefreshScope-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%A3%BC%EC%9D%98-%EC%82%AC%ED%95%AD</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서는 빈을 재생성할 수 있는 기능인 @RefreshScope를 제공한다. 빈 재생성을 통해 런타임 시점에 동적으로 설정 값을 바꿔 장애 발생시 배포 없이 Fallback을 수행하거나 Feature Flag로도 활용할 수 있다. 하지만 활용도가 높은만큼 Spring 컨테어너가 DB Connection이나 Transaction을 관리하는 방안을 제대로 이해하지 않고 사용하는 경우 빈을 재생성하는 과정에서 예상치 못한 장애를 일으킬 수 있어 주의가 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;사용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;@RefreshScope&lt;span&gt;은 아래와 같이 애노테이션 추가만으로 간편하게 사용할 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772610786407&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RefreshScope
@Component
public class PaymentClient {

    @Value(&quot;${external.payment.mode}&quot;)
    private String mode;

    public PaymentResult pay(...) {
        switch (mode) {
            case &quot;stub&quot;: return stubPay();
            case &quot;fallback&quot;: return fallbackPay();
            default: return realPay();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;주의할 점은 @RefreshScope가 설정된 빈은 프록시(Proxy)로 등록된다. 즉 빈 재생성 이벤트가 발생하면 프록시 빈 자체를 파괴(Destory)하고 재생성하는게 아니라 타깃 클래스를 제거한다. 호출자는 항상 프록시를 참조하므로, 빈이 재생성되는 중에도 호출 에러는 발생하지 않는다. 만약 Refresh 이벤트 도중 타깃 클래스의 새 인스턴스가 아직 초기화되지 않았다면, 프록시가 &lt;b&gt;지연 초기화(Lazy Initialization)&lt;/b&gt; 후 새 빈을 반환한다. 즉, &lt;b&gt;호출 시점에 항상 유효한 인스턴스가 보장&lt;/b&gt;된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;재생성 원리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh 시 기존 빈 인스턴스(타깃 클래스)는 파괴(Destroy)되고 &lt;b&gt;호출시&lt;/b&gt; 새로 생성(Recreate)된다. (= 지연 초기화) Spring은 파괴 전에 기존 인스턴스의 처리 중인 로직을 완료한 뒤 @PreDestroy 혹은 DisposableBean.destroy()를 호출한다. 따라서 내부 로직은 강제 중단되지 않지만, 재생성 타이밍에 따라 처리 지연이 발생할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;리소스 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@RefreshScope을 사용하고 있는 Bean이 외부 리소스(Network Socket, DB Connection 등)를 의존하고 이를 정리하는 로직이 다른 곳에 있어서 중복 정리하는 경우 IllegalStateException 발생 가능하므로 외부 리소스는 가급적 Spring 빈으로 위임 (DataSource, KafkaTemplate 등)하거나, 직접 close() 호출 시 상태 체크 후 예외 삼키기 등의 예외 처리 전략이 필요하다.&lt;/p&gt;
&lt;pre id=&quot;code_1772610932956&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PreDestroy
public void close() {
    if (producer != null &amp;amp;&amp;amp; producer.isOpen()) {
        producer.close(); // 중복 호출 시 IllegalStateException 가능
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스프링 생명주기 외 로직&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@RefreshScope이 설정된 빈이 Spring 컨테이너가 제어할 수 없는 로직(@Scheduled, 멀티스레드(ExecutorService), 비동기처리(@Async, CompletableFuture 등)을 실행하고 있는 경우 빈이 재생성되는 시점에 스프링은 그 로직의 생명주기를 제어할 수 없기 때문에 &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;예를 들어 @Scheduled 메서드로 특정 작업을 스케쥴링을 걸어두었고 이 작업이 진행 중인 상황에서 Refresh 시 동일 Job이 중복 등록되어 중복 실행될 수 있다. ExecutorService 등으로 멀티 스레드 환경에서 작업을 진행하고 있었다면 이전 스레드가 동작중인 경우 동시성 문제가 발생할 수 있다. 또한 @Async나 CompletableFuture로 비동기 처리를 진행 중인 경우 실행 중인 작업이 이전 인스턴스 상태를 참조할 수 있다. 테스트 코드에서는 @MockBean 적용시 모킹이 동작하지 않는다. @RefreshScope이 붙은 빈은 지연 초기화되는데 테스트 코드에서는 지연 초기화로 인해 실제 호출이 일어날 때 Mock Bean이 아니라 실제 클래스가 주입되어 모킹이 동작하지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;리소스 측면에서의 주의점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 @RefreshScope 빈은 DB Connection Pool이나 Transaction 등 외부 리소스 관리 설정 빈에서는 &lt;b&gt;사용해서는 안된다.&lt;/b&gt; 예를 들어 DB Connection Pool 관리 빈에 @RefreshScope를 둔 경우, 그리고 Refresh 이벤트가 동작하는 경우 Connection Pool이 재생성되면서 장애를 유발할 수 있다. DB 작업은 Connection Pool에 있는 Connection을 할당받아 처리하는데, 처리 하는 도중에 Connection Pool이 재생성되버리면 Refresh 이전에 작업을 처리하던 Connection을 관리할 Pool이 사라져 예외가 발생할 수 있다. 이 경우 Connection을 사용하던 모든 요청에 예외가 발생하므로 서비스 장애로 이어질 수 있어 주의가 필요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주의사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@RefreshScope 빈은 &lt;b&gt;상태 없는 설정 값 제공자&lt;/b&gt;로만 사용해야한다. 실제 실행(스케줄, 비동기, 쓰레드)은 일반 @Service 빈으로 분리해야한다. Refresh 이벤트에 반응해야 할 경우 @EventListener(RefreshScopeRefreshedEvent.class)을 사용하고, 단위 테스트 환경에서는 @RefreshScope을 사용하고 있는 빈이 의존하는 클래스에 대해 모킹할 객체를 Reflection을 이용해 직접 주입하는 방법이 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1772611301722&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 테스트 코드에서 @RefreshSCope의 타깃 빈을 직접 모킹 클래스로 명시하여 주입
// 타겟 객체(targetObject)의 특정 필드(name)에 값을(value) 설정
// 개발자가 호출하는 시점 = 실제 적용 시점
@BeforeEach
void setUp() {
	ReflectionTestUtils.setField(
		serviceTargetClass,
        &quot;repository&quot;,
        mockbean
	);
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Spring</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/422</guid>
      <comments>https://nooblette.tistory.com/entry/Spring-RefreshScope-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%A3%BC%EC%9D%98-%EC%82%AC%ED%95%AD#entry422comment</comments>
      <pubDate>Wed, 4 Mar 2026 17:05:32 +0900</pubDate>
    </item>
    <item>
      <title>[단위테스트] 테스트 더블 (Test Double)</title>
      <link>https://nooblette.tistory.com/entry/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8D%94%EB%B8%94-Test-Double</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;목차&lt;/span&gt;&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트 코드는 흔히 FIRST 원칙을 준수해야한다고 한다. 실행 속도가 빨라야하며(First) 외부로부터 격리(Isolated)되어 검증하고 하는 기능에 집중할 수 있어야하고 반복 가능(Repeatable)해야한다. 또한 사용자의 개입 없이 테스트 코드 스스로 검증(Self-validating)할 수 있어야하고 적절한 시점(Timeley)에 바로 작성할 수 있어야한다. 한편 실제 애플리케이션에서 기능을 작성할 때는 자연스레 데이터베이스 연결 등 외부 환경에 의존하게 된다. 이처럼 복잡한 애플리케이션에서 단위 테스트 원칙을 준수하여 효과적인 테스트 코드를 작성하기 위해서는 테스트 더블이 유용하게 사용된다. 특히 F와 I, R을 준수하기 쉽다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 더블은 Dummy, Fake, Stub, Spy, Mock으로 구성된다. 각 경계가 명확하게 분리되어 명칭을 반드시 지켜야한다기보다는 테스트 범위에 따라 정의되는 일종의 스펙트럼이라고 이해하는게 적절할 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;234&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UPJau/dJMcagqQ5a6/GVvzLK7LwIAFfyMck91fe0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UPJau/dJMcagqQ5a6/GVvzLK7LwIAFfyMck91fe0/img.gif&quot; data-alt=&quot;https://learn.microsoft.com/en-us/archive/msdn-magazine/2007/september/unit-testing-exploring-the-continuum-of-test-doubles&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UPJau/dJMcagqQ5a6/GVvzLK7LwIAFfyMck91fe0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/UPJau/dJMcagqQ5a6/GVvzLK7LwIAFfyMck91fe0/img.gif&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;400&quot; height=&quot;234&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;234&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://learn.microsoft.com/en-us/archive/msdn-magazine/2007/september/unit-testing-exploring-the-continuum-of-test-doubles&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Dummy&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Dummy는 의존 관계 주입, 파라미터 전달 등 인스턴스화된 객체가 필요한 경우에 사용할 수 있다. 별다른 로직은 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래와 같이 이메일 발송 클래스에 의존하고 있는 로직의 테스트 코드를 작성한다고 가정하자.&lt;/p&gt;
&lt;pre id=&quot;code_1770380477730&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;class UserService(
    private val repository: UserRepository,
    private val emailService: EmailService
) {
    fun registerUser(user: User): Boolean {
        val savedUser = repository.save(user)
        return emailService.sendEmail(
            user.email,
            &quot;Welcome!&quot;,
            &quot;Thanks for registering&quot;
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 UserSerivce 클래스는 EmailService에 의존하고 있다. 따라서 위 클래스를 검증하기 위해서는 EamilService의 구현체가 필요하다. 단순히 의존관계 주입에 필요한 구현체만 필요한 경우 아래와 같이 Dummy 객체를 정의하여 활용할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1770380433835&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;class DummyEmailService : EmailService {
    override fun sendEmail(to: String, subject: String, body: String): Boolean {
        // 실제로 아무 동작을 하지 않는다.
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Fake&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 객체가 아니라 어떤 동작을 해야한다면, Fake로 정의할 수 있다. 예를 들어 위 UserSerivce 클래스는 DB 접근을 위해 UserRepository에 의존하고 있다. 하지만 테스트 코드에서는 실제 DB 환경에 의존할 필요는 없고 DB 조회, 적재 등에 따른 특정 기능만 동작하면 된다. 이 경우 Fake 객체로 임의의 가짜 로직을 선언하여 단위 테스트를 작성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1770380634575&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class FakeUserRepository : UserRepository {
    private val users = mutableMapOf&amp;lt;String, User&amp;gt;()
    
    override fun findById(id: String): User? {
        return users[id]
    }
    
    override fun save(user: User): User {
        users[user.id] = user
        return user
    }
    
    fun clear() {
        users.clear()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Stub&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 시나리오상 객체가 특정한 응답 값을 반환하는 경우가 필요할 수 있다. 이때는 Stub을 통해 정해진 응답을 반환하는 Stub 객체를 통해 단위 테스트를 작성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1770380856524&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class StubUserRepository : UserRepository {
    private var userToReturn: User? = null
    
    fun setUserToReturn(user: User?) {
        userToReturn = user
    }
    
    override fun findById(id: String): User? {
        return userToReturn
    }
    
    override fun save(user: User): User {
        return user
    }
}

class StubEmailService(private val returnValue: Boolean) : EmailService {
    override fun sendEmail(to: String, subject: String, body: String): Boolean {
        return returnValue
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spy&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 케이스에서 정의한 특정 동작을 하면서 대상 메서드가 전달받은 파라미터 값, 메서드 호출 횟수 등을 검증해야하는 경우도 있다. 이때는 전달받은 파라미터 값이나 호출 횟수 등을 기록하는 Spy 객체를 활용할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1770380759146&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;// Spy - 실제 동작을 수행하면서 호출을 기록
class SpyEmailService : EmailService {
    var sendEmailCallCount = 0
    var lastRecipient: String? = null
    var lastSubject: String? = null
    var lastBody: String? = null
    
    override fun sendEmail(to: String, subject: String, body: String): Boolean {
        sendEmailCallCount++
        lastRecipient = to
        lastSubject = subject
        lastBody = body
        
        // 실제 동작 수행 (여기서는 단순화)
        return true
    }
    
    fun reset() {
        sendEmailCallCount = 0
        lastRecipient = null
        lastSubject = null
        lastBody = null
    }
}

// 사용 예제
fun testWithSpy() {
    val spy = SpyEmailService()
    val fakeRepo = FakeUserRepository()
    val service = UserService(fakeRepo, spy)
    
    val user = User(&quot;1&quot;, &quot;Charlie&quot;, &quot;charlie@example.com&quot;)
    service.registerUser(user)
    
    // Spy를 통해 호출 검증
    assert(spy.sendEmailCallCount == 1)
    assert(spy.lastRecipient == &quot;charlie@example.com&quot;)
    assert(spy.lastSubject == &quot;Welcome!&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mock&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 시나리오에 따라 예상되는 입력 값과 응답 값을 사전에 정의하여 검증에 사용할 수 있다. 흔히 Mockito 라이브러리에서 제공하는 Mock을 활용하는데, 입력 값에 따른 응답 값 정의, 호출 횟수 검증, 메서드가 전달받은 파라미터 값을 기록하고 검증 등 앞서 설명한 Dummy, Fake, Stub, Spy를 모두 포괄할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1770380970408&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import io.mockk.*
import org.junit.jupiter.api.Test
import kotlin.test.assertTrue

class UserServiceTest {
    
    @Test
    fun `MockK를 사용한 Mock 예제`() {
        // Mock 생성
        val mockRepo = mockk&amp;lt;UserRepository&amp;gt;()
        val mockEmail = mockk&amp;lt;EmailService&amp;gt;()
        
        val user = User(&quot;1&quot;, &quot;Eve&quot;, &quot;eve@example.com&quot;)
        
        // 동작 정의
        every { mockRepo.save(user) } returns user
        every { 
            mockEmail.sendEmail(
                &quot;eve@example.com&quot;,
                &quot;Welcome!&quot;,
                &quot;Thanks for registering&quot;
            )
        } returns true
        
        val service = UserService(mockRepo, mockEmail)
        val result = service.registerUser(user)
        
        // 검증
        assertTrue(result)
        verify(exactly = 1) { mockRepo.save(user) }
        verify(exactly = 1) { 
            mockEmail.sendEmail(
                &quot;eve@example.com&quot;,
                &quot;Welcome!&quot;,
                &quot;Thanks for registering&quot;
            )
        }
    }
    
    @Test
    fun `MockK를 사용한 Spy 예제`() {
        // Spy 생성 (실제 객체를 감싸서 호출 기록)
        val realRepo = FakeUserRepository()
        val spyRepo = spyk(realRepo)
        
        val user = User(&quot;1&quot;, &quot;Frank&quot;, &quot;frank@example.com&quot;)
        spyRepo.save(user)
        
        // 실제 동작이 수행되면서 호출이 기록됨
        verify { spyRepo.save(user) }
        assert(spyRepo.findById(&quot;1&quot;) != null)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://martinfowler.com/bliki/TestDouble.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://martinfowler.com/bliki/TestDouble.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://velog.io/@sdb016/%EC%A2%8B%EC%9D%80-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-FIRST%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@sdb016/%EC%A2%8B%EC%9D%80-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-FIRST%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hudi.blog/test-double/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://hudi.blog/test-double/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>테스트코드</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/421</guid>
      <comments>https://nooblette.tistory.com/entry/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8D%94%EB%B8%94-Test-Double#entry421comment</comments>
      <pubDate>Fri, 6 Feb 2026 21:31:50 +0900</pubDate>
    </item>
    <item>
      <title>완벽주의 성향 버리기 - Railway-Oriented Programming 적용 여정</title>
      <link>https://nooblette.tistory.com/entry/%EC%99%84%EB%B2%BD%EC%A3%BC%EC%9D%98-%EC%84%B1%ED%96%A5-%EB%B2%84%EB%A6%AC%EA%B8%B0-Railway-Oriented-Programming-%EC%A0%81%EC%9A%A9-%EC%97%AC%EC%A0%95</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;목차&lt;/span&gt;&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;배경&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사이드 프로젝트를 진행하며 결제 API 연동 로직 개발에 많은 시간이 들었다. 결제 API는 금액이 걸려있고 엮여있는 비즈니스 로직(예를 들어 결제에 성공하면 재고 차감, 실패시 차감하지 않는다.)이 많아 연동 성공과 실패에 따른 케이스 처리가 중요했다. 사실 개발보다는 예외 처리가 더욱 중요했는데, 처음에는 발생할 것 같다고 예상되는 케이스를 먼저 구분하고 진행하려고 했다. 하지만 &lt;b&gt;발생 가능한 예외 케이스&lt;/b&gt;와&amp;nbsp;&lt;b&gt;예외 케이스별 적절한 처리 방식&lt;/b&gt;을 실제로 개발하기 전에 완벽히 예측하기는 거의 불가능했다. 개발이나 시스템 설계보다는 오히려 예외 케이스 구분에 더 많은 시간을 쏟게 되었다. 따라서 완벽한 결과물보다는 우선 개발하고나서 개선해보는 방향으로 진행했고 결과적으로 개발 소요 시간을 줄이면서 실제로 발생 가능한 케이스, 케이스별 적절한 처리 방안을 고려하면서 개선할 수 있었다. 이 과정에서 Scott Wlaschin의 &lt;a href=&quot;https://fsharpforfunandprofit.com/rop/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Railway-Oriented Programming&lt;/a&gt;를 적용할 수 있었는데, 그 적용 과정을 함께 소개하려고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;문제의 시작: try-catch 지옥&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;예외 케이스 식별이 어려운 만큼 처음에는 직관적으로 try-catch 구문으로 반복 작성했다. 작성하다보니 다음과 같이 들여쓰기가 매우 깊어지는 상황까지 오게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770211146271&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
fun processPayment(request: PaymentRequest) {
    try {
        validatePayment(request)
        try {
            reserveInventory(request)
            try {
                callPgApi(request)
                try {
                    updateOrderStatus()
                } catch (UpdateException e) {
                    throw PaymentException(&quot;주문 상태 업데이트 실패&quot;, e)
                }
            } catch (PgApiException e) {
                throw PaymentException(&quot;PG 연동 실패&quot;, e)
            }
        } catch (InventoryException e) {
            throw PaymentException(&quot;재고 부족&quot;, e)
        }
    } catch (ValidationException e) {
        throw PaymentException(&quot;검증 실패&quot;, e)
    } catch (PaymentException e) {
        // 롤백됨 - 실패 정보 손실
        logger.error(&quot;결제 처리 실패&quot;, e)
        throw e
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;또한 예외 케이스별 다른 처리 방식이 요구되는 경우 동일한 형태의 try-catch 구문을 반복 작성하게 되었다. 결과적으로 코드 가독성과 유지보수성이 낮아지게 됐다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770211222209&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
fun processPayment(request: PaymentRequest) {
    try {
        validatePayment(request) // 예외 던질 수 있음
        reserveInventory(request) // 예외 던질 수 있음
        callPgApi(request) // 예외 던질 수 있음
        updateOrderStatus() // 예외 던질 수 있음
    } catch (ValidationException e) {
        // 롤백됨 - 실패 기록 없음
        throw PaymentException(&quot;검증 실패&quot;)
    } catch (InventoryException e) {
        // 롤백됨 - 어디서 실패했는지 추적 어려움
        throw PaymentException(&quot;재고 부족&quot;)
    } catch (PgApiException e) {
        // 메서드 뎁스와 예외 체인이 복잡해짐
        throw PaymentException(&quot;PG 연동 실패&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;try-catch 중첩으로 메서드 뎁스가 증가해 코드 가독성이 떨어지면서, 정상적인 비즈니스 흐름을 한 눈에 정확히 식별하기 어려웠다. 결국 유지보수성이 낮아지기 시작했다. 이 문제점을 인식한 시점에 Railway-Oriented Programming을 도입했다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Railway-Oriented Programming 도입&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Railway-Oriented Programming은 일반적으로 성공, 실패로 분류하고 실패를 상세 케이스로 분류하여 로직이 철로를 지나가듯이 작성하고 읽히게 만들 수 있다. 따라서 일반적으로 Sealed Class로 성공과 실패 케이스를 구분하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;비즈니스 로직을 작성하면서 발생 가능한 예외 케이스와 처리 방법이 정의되었으니, 비즈니스별(e.g. PaymentResult) 예외 타입 선언이 필요한 케이스를 명확히 구분하여 정의할 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770211445300&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sealed class PaymentResult {
    data class Success(val paymentId: String, val transactionId: String) : PaymentResult()
    data class ValidationFailed(val reason: String) : PaymentResult()
    data class InventoryInsufficient(val availableQty: Int) : PaymentResult()
    data class PgApiFailed(val errorCode: String, val message: String) : PaymentResult()
    data class NetworkTimeout(val retryAfter: Long) : PaymentResult()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이제 앞서 작성한 반복되는 try-catch 구문처럼, 예외를 던지고 그걸 처리하는 로직이 아닌 PaymentResult 결과에 따른 상태 기반 처리로 개선했다. 중첩되고 반복되는 try-catch 구문을 제거함으로써 코드 가독성을 높일 수 있었다. 특히 예외 처리를 별개 케이스가 아닌 비즈니스의 일부로 바라봄으로써, DB에 사유 기록을 하고 실패 케이스에 대해 어떻게 처리하는지를 명확히 작성할 수 있었다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770211489617&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
fun processPayment(request: PaymentRequest): PaymentResult {
    // 항상 결제 시도를 기록 (커밋됨)
    val payment = paymentRepository.save(Payment(status = &quot;처리중&quot;))
    
    val validationResult = validationService.validate(request)
    if (validationResult.isFailure) {
        paymentRepository.updateStatus(payment.id, &quot;검증실패&quot;, validationResult.reason)
        return PaymentResult.ValidationFailed(validationResult.reason)
    }
    
    val inventoryResult = inventoryService.reserve(request)
    if (inventoryResult.isFailure) {
        paymentRepository.updateStatus(payment.id, &quot;재고부족&quot;, inventoryResult.message)
        return PaymentResult.InventoryInsufficient(inventoryResult.availableQty)
    }
    
    val pgResult = pgService.charge(request)
    return when (pgResult) {
        is PgResult.Success -&amp;gt; {
            paymentRepository.updateStatus(payment.id, &quot;성공&quot;)
            PaymentResult.Success(payment.id, pgResult.transactionId)
        }
        is PgResult.Failed -&amp;gt; {
            paymentRepository.updateStatus(payment.id, &quot;PG실패&quot;, pgResult.errorCode)
            PaymentResult.PgApiFailed(pgResult.errorCode, pgResult.message)
        }
        is PgResult.Timeout -&amp;gt; {
            paymentRepository.updateStatus(payment.id, &quot;타임아웃&quot;)
            PaymentResult.NetworkTimeout(pgResult.retryAfter)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Kotlin 예외 처리의 올바른 방법&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;예전에 &lt;a href=&quot;https://medium.com/@galcyurio/kotlin%EC%97%90%EC%84%9C%EC%9D%98-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95-48a5cd94a4e6&quot;&gt;Kotlin에서의 예외 처리 방법&lt;/a&gt; 글을 읽었던 적이 있는데, 이 문제 해결 과정을 통해서 그 원칙들을 실제 상황에 적용해보면서 이해할 수 있었다. 위 글에서 말하는 예외 처리의 핵심 원칙은 다음과 같다.&lt;/span&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;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;논리 오류일 때만 예외를 던지세요&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;논리 오류가 아니면 예외를 던지지 말고 null/sealed class를 반환하세요&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;일반적인 코틀린 코드에서 try-catch를 사용하지 마세요&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;예외는 전역 핸들러로 처리하세요&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;앞서 작성한 결제 API 연동 로직에 위 원칙을 적용해본다면 다음과 같이 작성할 수 있을 것이다. 예상 가능한 실패는 sealed class로 구현하고 when 절을 사용함으로써, card 객체의 상태에 따른 처리 방법을 명확하게 나타낼 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1770211681709&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 잘못된 방식: 예상 가능한 실패를 예외로 처리
fun validateCard(card: Card) {
    if (card.isExpired()) throw CardExpiredException()
    if (card.balance &amp;lt; amount) throw InsufficientBalanceException()
}

// 올바른 방식: 예상 가능한 실패는 sealed class로
sealed class CardValidationResult {
    object Valid : CardValidationResult()
    object Expired : CardValidationResult()
    data class InsufficientBalance(val current: Long, val required: Long) : CardValidationResult()
}

fun validateCard(card: Card): CardValidationResult = when {
    card.isExpired() -&amp;gt; CardValidationResult.Expired
    card.balance &amp;lt; amount -&amp;gt; CardValidationResult.InsufficientBalance(card.balance, amount)
    else -&amp;gt; CardValidationResult.Valid
}&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;span style=&quot;color: #333333;&quot;&gt;중요한 것은 실패를 &lt;b&gt;예외가 아닌 비즈니스 로직&lt;/b&gt;으로 바라보는 것이다. 결제 시스템에서 아래 케이스들은 비즈니스 시나리오의 일부이므로, 예외로 처리하면 코드가 복잡해지고 실패 정보가 소실될 것이다.&lt;/span&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;span style=&quot;color: #333333;&quot;&gt;카드 만료: 예외가 아님, 예상 가능한 비즈니스 케이스&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;재고 부족: 예외가 아님, 정상적인 비즈니스 시나리오&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;네트워크 타임아웃: 예외가 아님, 처리해야 할 케이스&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;핵심 비즈니스일수록 상태 기반 설계와 DB와 같은 공간에 기록하는 것이 중요할텐데, 예외를 던지지 않고 상태와 예외 케이스를 함께 기록함으로써 &lt;span style=&quot;text-align: start;&quot;&gt;또배치 재처리, 모니터링 및 알림, 데이터 복구/분석, 장애 복구 등도 가능하다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;만약 처음부터 Railway-Oriented Programming를 적용했다면?&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;만약 처음부터&amp;nbsp;Railway-Oriented Programming를 적용했다면 어땠을까, 결과만 본다면 반복 try-catch 코드 작성과 문제 인식, 개선 없이 원하는 결과물을 얻어낼 수 있으니 이상적일 것 같다. 하지만 처음부터 에외 케이스를 완벽히 설계하고 시작하려고 했다면 케이스 도출만 하다가 시간을 보냈을 것이다. 또한 실제 코드를 작성하다보면 그 중 절반에서 대부분은 실제로 사용하지 않았을 것이다. 완벽한 설계를 처음부터 하려는 것보다, 동작하는 코드에서 문제를 경험하고 구체적으로 개선하는 것이 더 효율적이라는 것을 느꼈다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;흔히 Kent Beck의 TDD 철학(Make it work, make it right, make it fast)이나, Martin Fowler의 리팩토링(처음부터 완벽한 설계는 불가능하다. 점진적 개선이 답이다)과 같은 말을 많이 들었는데, 이번 사례를 통해서 그 의미를 더욱 잘 이해하게 되었다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;</description>
      <category>CHAT</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/420</guid>
      <comments>https://nooblette.tistory.com/entry/%EC%99%84%EB%B2%BD%EC%A3%BC%EC%9D%98-%EC%84%B1%ED%96%A5-%EB%B2%84%EB%A6%AC%EA%B8%B0-Railway-Oriented-Programming-%EC%A0%81%EC%9A%A9-%EC%97%AC%EC%A0%95#entry420comment</comments>
      <pubDate>Wed, 4 Feb 2026 22:37:20 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] Spring Cacheable 동작 원리와 에러 Fallback 구성하기</title>
      <link>https://nooblette.tistory.com/entry/Spring-Spring-Cacheable-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%97%90%EB%9F%AC-Fallback-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;애플리케이션을 개발하며 캐시는 특히나 성능 향상과 서비스 고가용성에 필수적인 부분이다. 하지만 무작정 캐시를 도입했다가, 캐시의 잘못된 동작으로 서비스 품질이 저하되고 경우에 따라 SPOF가 될 수 있다. 이번에는 Spring의 @Cacheable 애노테이션을 비롯한 Spring Cache 동작 원리와 캐시 Fallback 구성 방안을 다룬다.&lt;/p&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;Spring Cache 계층 구조&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;Spring에서는 캐시가 필요한 메서드에 key와 value를 지정하여 @Cacheable 애노테이션을 통해 캐시를 적용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;Java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;Java&quot;&gt;&lt;code&gt;@Cacheable(value = &quot;memberCache&quot;, key = &quot;#memberId&quot;)
public Member getMember(Long memberId) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return memberRepository.findById(memberId)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.orElseThrow(() -&amp;gt; new NotFoundException(&quot;member not found&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;기본적으로 Look Aside로 동작하는데, Spring Cache의 구조를 구체적으로 살펴보면 다음과 같이 계층 구조로 이루어져있다.&lt;/p&gt;
&lt;pre class=&quot;HTML&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;HTML&quot;&gt;&lt;code&gt;@Cacheable
&amp;nbsp;&amp;nbsp; &amp;darr; (AOP)
CacheInterceptor
&amp;nbsp;&amp;nbsp; &amp;darr;
CacheManager (ex: RedisCacheManager)
&amp;nbsp;&amp;nbsp; &amp;darr;
Cache (ex: RedisCache, CaffeineCache ...)
&amp;nbsp;&amp;nbsp; &amp;darr;
실제 저장소 API (RedisTemplate, Caffeine 내부 Map 등)&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이는 프록시 기반으로 동작하며, 실제 타겟 클래스를 호출하기 전에 CacheInterceptor가 그 호출을 가로채어 캐시와 관련된 로직을 수행한다. 캐시 전략에 따라 Redis Cache, 로컬 캐시를 선택하는 경우 Caffeine Cache가 동작한다. 캐시 적용 로직을 디버깅해보면 다음과 같이 타겟 클래스에 대해 Advice로 org.springframework.cache.interceptor.CacheInterceptor가 걸려있는 것을 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;HTML&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;HTML&quot;&gt;&lt;code&gt;org.springframework.aop.framework.ProxyFactory: 0 interfaces []; 1 advisors [org.springframework.cache.interceptor.BeanFactoryCacheOperationSourceAdvisor: advice org.springframework.cache.interceptor.CacheInterceptor@2f8ab988]; targetSource [SingletonTargetSource for target object [targetCalss@5efb7f82]]; proxyTargetClass=true; optimize=false; opaque=false; exposeProxy=false; frozen=false&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;Cache 에러 발생시&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이때 캐시 저장소(Redis, HashMap 등)문제가 발생하는 경우 어떻게 동작하게 될까? 그 로직은 스프링 캐시의 핵심 역할을 하는 CacheInterceptor가 담당한다.&lt;br /&gt;CacheInterceptor에는 CacheErrorHandler를 등록하여, 실제 캐시 저장소에서 문제가 발생하는 경우 적절한 Fallback 전략을 취할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;HTML&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;HTML&quot;&gt;&lt;code&gt;@Cacheable
&amp;nbsp;&amp;nbsp; &amp;darr;
CacheInterceptor
&amp;nbsp;&amp;nbsp; &amp;darr;
CacheManager (RedisCacheManager)
&amp;nbsp;&amp;nbsp; &amp;darr;
Cache (RedisCache)
&amp;nbsp;&amp;nbsp; &amp;darr;&amp;nbsp;&amp;nbsp; ──(예외 발생 시)──▶&amp;nbsp;&amp;nbsp;CacheInterceptor에 등록된 CacheErrorHandler 동작
실제 저장소 API (RedisTemplate, Caffeine 내부 Map 등)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;Redis를 사용하는 경우&lt;/h3&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;캐시 저장소로 Redis를 사용하는 경우를 생각해보자. 이 경우 스프링의 캐시는 RedisTemplate와 결합하여 다음과 같은 계층 구조를 이루게 된다.&lt;br /&gt;스프링은 캐시 저장소를 추상화한 CacheManager 인터페이스를 제공하는데, 이 구현체로 RedisCacheManager를 주입하게 된다.&lt;br /&gt;이 후에는 Redis 연결과 클라이언트에 필요한 라이브러리를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;HTML&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;HTML&quot;&gt;&lt;code&gt;@Cacheable
&amp;nbsp;&amp;nbsp; &amp;darr;
CacheInterceptor
&amp;nbsp;&amp;nbsp; &amp;darr;
CacheManager (RedisCacheManager)
&amp;nbsp;&amp;nbsp; &amp;darr;
Cache (RedisCache)
&amp;nbsp;&amp;nbsp; &amp;darr; 
RedisTemplate
&amp;nbsp;&amp;nbsp; &amp;darr;
RedisConnection (Lettuce/Jedis)
&amp;nbsp;&amp;nbsp; &amp;darr;
RedisClient
&amp;nbsp;&amp;nbsp; &amp;darr;
Redis 서버&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;Spring Cache ErrorHandler&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;서비스 가용성 향상을 위해 도입한 캐시가 서비스 품질을 저하시키는 것을 방지하기 위해 Fallback 전략을 적절히 선정할 필요가 있다. &lt;a href=&quot;https://docs.spring.io/spring-framework/docs/4.1.x/spring-framework-reference/html/cache.html#:~:text=By%20default%2C%20any%20exception%20throw%20during%20a%20cache%20related%20operations%20are%20thrown%20back%20at%20the%20client&quot; target=&quot;_self&quot;&gt;&lt;span&gt;Spring 공식 문서&lt;/span&gt;&lt;/a&gt;에 따르면 Error Handler는&amp;nbsp;SimpleCacheErrorHandler를 기본 값으로 사용하고 있으며 기본적으로 에러를 클라이언트에 직접 반환한다.&lt;br /&gt;&lt;br /&gt;SimpleCacheErrorHandler의 구현을 살펴보면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;Java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;Java&quot;&gt;&lt;code&gt;package org.springframework.cache.interceptor;

public class SimpleCacheErrorHandler implements CacheErrorHandler {

	@Override
	public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
		throw exception;
	}

	@Override
	public void handleCachePutError(RuntimeException exception, Cache cache, Object key, @Nullable Object value) {
		throw exception;
	}

	@Override
	public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
		throw exception;
	}

	@Override
	public void handleCacheClearError(RuntimeException exception, Cache cache) {
		throw exception;
	}
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;캐시 Fallback 구현하기&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;캐시 에러 발생시 예외 처리를 커스텀하여 Fallback 전략을 구현해보자. 이를 위해 CacheErrorHandler 핸들러를 등록한다. CacheInterceptor가 캐시 관련 로직을 처리하다가 에러가 발생하면 등록된 CacheErrorHandler를 찾아 호출한다. (등록된게 없다면 SimpleCacheErrorHandler를 사용한다.)&lt;br /&gt;Spring Bean으로 주입받을 수는 없고 아래와 같이 CachingConfigurerSupport 클래스의 errorHandler()가 커스텀한 에러 핸들러 익명 객체(e.g. new CacheErrorHandler() { &amp;hellip; })를 반환하도록 한다.&lt;/p&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;CachingConfigurerSupport 구현&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;CacheConfig 클래스가 CachingConfigurerSupport를 상속받고 errorHandler()가 커스텀한 에러 핸들러 객체를 반환하도록 한다.&lt;/p&gt;
&lt;pre class=&quot;Java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;Java&quot;&gt;&lt;code&gt;public class CachingConfigurerSupport implements CachingConfigurer {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Nullable
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheManager cacheManager() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Nullable
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheResolver cacheResolver() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Nullable
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public KeyGenerator keyGenerator() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Nullable
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheErrorHandler errorHandler() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return null;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}

@Slf4j
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Primary
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheManager caffeineCacheManager() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CaffeineCacheManager cacheManager = new CaffeineCacheManager();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cacheManager.registerCustomCache(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;saleStore&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Caffeine.newBuilder()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.expireAfterWrite(60, TimeUnit.MINUTES)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.maximumSize(1000)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.build()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return cacheManager;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// Json 포맷으로 직렬화
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;// &amp;plusmn;30초 범위의 Jitter 적용 (캐시 스탬피드 방지 목적)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.entryTtl(Duration.ofMinutes(5).plusSeconds(ThreadLocalRandom.current().nextLong(-30, 30)));

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return RedisCacheManager.builder(redisConnectionFactory)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.cacheDefaults(config)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.build();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheErrorHandler errorHandler() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return new CacheErrorHandler() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isRedisCache(cache)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.error(&quot;[확인필요] Redis 조회 실패, DB fallback. key={}&quot;, cache.getName() + &quot;::&quot; + key, exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw exception;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isRedisCache(cache)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.error(&quot;[확인필요] Redis 적재 실패, key={}&quot;, cache.getName() + &quot;::&quot; + key, exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw exception;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isRedisCache(cache)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.error(&quot;[확인필요] Redis EVICT 실패, key={}&quot;, cache.getName() + &quot;::&quot; + key, exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw exception;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Override
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public void handleCacheClearError(RuntimeException exception, Cache cache) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if (isRedisCache(cache)) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;log.error(&quot;[확인필요] Redis CLEAR 실패, cache={}&quot;, cache.getName(), exception);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;} else {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;throw exception;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;private boolean isRedisCache(Cache cache) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return cache != null &amp;amp;&amp;amp; cache.getClass().getName().contains(&quot;RedisCache&quot;);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;};
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;등록 원리&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;스프링의 캐시 추상화는 캐시 프록시 설정 클래스인 ProxyCachingConfiguration를 통해 CacheInterceptor를 Bean 등록하여 AOP 프록시 기반으로 캐시 관련 로직을 실행한다. CacheAspectSupport가 실제 캐시 접근 로직을 실행하며, 캐시 연산 중 예외가 발생하면 CacheErrorHandler를 호출한다. 이때 CacheErrorHandler는 빈으로 등록 후 주입하여 사용되는게 아니라 직접 에러 핸들러 생성 메서드를 호출한다.&lt;br /&gt;&lt;br /&gt;Spring의 캐시 추상화 등록 순서를 살펴보면 다음과 같다.&lt;br /&gt;&amp;nbsp;&amp;nbsp;1. @EnableCaching &amp;rarr; ProxyCachingConfiguration import&lt;br /&gt;&amp;nbsp;&amp;nbsp;2. ProxyCachingConfiguration &amp;rarr; CacheInterceptor Bean 등록&lt;br /&gt;&amp;nbsp;&amp;nbsp;3. CacheInterceptor는 AOP 기반으로 @Cacheable, @CachePut, @CacheEvict 메서드를 감싸서 실행&lt;br /&gt;&amp;nbsp;&amp;nbsp;4. 내부적으로 CacheAspectSupport가 실제 캐시 접근 로직을 실행&lt;br /&gt;&amp;nbsp;&amp;nbsp;5. 캐시 연산(get/put/evict/clear) 중 예외 발생 시 CacheErrorHandler 호출 -&amp;gt; 즉, 핸들러는 CacheInterceptor 내부에서만 사용된다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;CacheInterceptor 생성 시점&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;ProxyCachingConfiguration는 interceptor.configure()를 호출하여 errorHandler, keyGenerator, cacheResolver, cacheManager를 설정한다.&lt;/p&gt;
&lt;pre class=&quot;Java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;Java&quot;&gt;&lt;code&gt;@Configuration
@Role(2)
public class ProxyCachingConfiguration extends AbstractCachingConfiguration {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;
	...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Bean
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Role(2)
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;public CacheInterceptor cacheInterceptor() {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CacheInterceptor interceptor = new CacheInterceptor();
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptor.configure(this.errorHandler, this.keyGenerator, this.cacheResolver, this.cacheManager);
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;interceptor.setCacheOperationSource(this.cacheOperationSource());
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return interceptor;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이때 this.errorHandler는 CachingConfigurer 인터페이스의 errorHandler() 메서드를 호출하여 객체를 생성하여 설덩한다.&amp;nbsp;&amp;nbsp;이 CacheErrorHandler.errorHandler()를 호출하여 생성한 객체를 ProxyCachingConfiguration에서 빈으로 등록한 CacheInterceptor의 configure()로 설정하면 에러 발생시 동작하게 된다. 즉, 에러 Fallback을 빈으로 등록하는 것이 아닌 익명 객체나 Lambda로 작성하여 이를 전달하여 사용하게 된다.&lt;/p&gt;
&lt;pre class=&quot;Java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;Java&quot;&gt;&lt;code&gt;@Configuration
public abstract class AbstractCachingConfiguration implements ImportAware {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;...

&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@Nullable
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;protected Supplier&amp;lt;CacheErrorHandler&amp;gt; errorHandler;

	protected void useCachingConfigurer(CachingConfigurer config) {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;	this.cacheManager = config::cacheManager;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;	this.cacheResolver = config::cacheResolver;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;	this.keyGenerator = config::keyGenerator;
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;	this.errorHandler = config::errorHandler;
	}
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Spring</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/419</guid>
      <comments>https://nooblette.tistory.com/entry/Spring-Spring-Cacheable-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%97%90%EB%9F%AC-Fallback-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0#entry419comment</comments>
      <pubDate>Mon, 20 Oct 2025 12:22:50 +0900</pubDate>
    </item>
    <item>
      <title>[Jpa] HikariCP와 Jpa의 비관적 락 요청 타임아웃 처리 방식과 DBMS별 테스트 한계</title>
      <link>https://nooblette.tistory.com/entry/JPA-HikariCP%EC%99%80-Jpa%EC%9D%98-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%9A%94%EC%B2%AD-%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D%EA%B3%BC-DBMS%EB%B3%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%9C%EA%B3%84</link>
      <description>&lt;div class=&quot;book-toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목차&lt;/p&gt;
&lt;ul id=&quot;toc&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이커머스에서 상품 주문 중 재고 수량 체크와 차감은 필수적이다. 이때 동일한 상품에 대해 동시 재고 차감 요청이 발생할 수 있고, 이를 제어하기 위해서는 다양한 락 전략을 적용할 수 있다. 예를 들어 동시성 제어를 위해 &lt;b&gt;비관적 락&lt;/b&gt;을 적용할 수 있다. 이때 요청이 무한정 대기하여 리소스를 소모하는 것을 방지하기 위해 락 획득 요청 유효시간을 설정할 필요가 있는데, 락 획득 요청 유효시간 테스트 과정에서 발생했던&amp;nbsp; &lt;b&gt;DBMS별로 상이한 동작으로 인한 HikariCP와 Jpa의 처리 한계&lt;/b&gt;와 Spring을 통한 해결 방법을 다룬다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 주문을 요청하면, 요청 수량만큼 재고가 남아있는지 확인하고 가능하다면 재고를 차감하여 주문을 진행한다. 이는 아래와 같이 작성할 수 있다. (Kotlin + Spring Boot + Jpa)&lt;/p&gt;
&lt;pre id=&quot;code_1757208554962&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun decreaseStock(orderItem: OrderItem): DecreaseStockResult {
    val itemEntity = itemRepository.findById(orderItem.id) 
		?: throw IllegalArgumentException(&quot;잘못된 상품 Id(Id = ${orderItem.id}) 입니다.&quot;)

    if (orderItem.exceedsStock(itemEntity.stock)) {
        return DecreaseStockResult.Failure(message = generateFailureReason(itemEntity.stock))
    }

    itemEntity.stock -= orderItem.quantity
    return DecreaseStockResult.Success
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 한 상품에 대해 동시 재고 차감 요청이 발생할 수 있다. 이 경우 위 코드는 재고 수량을 적절하게 차감하지 못할 것이다. 예를 들어 기존 재고가 10개, 각각 2개의 주문 요청이 발생하는 경우 최종 잔여 재고 수량은 6개가 남아야 하지만 8개가 남게 될 수도 있다. 자세한 내용은 &lt;a href=&quot;https://nooblette.tistory.com/entry/%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95-13-%EB%AC%B8%EC%A0%9C-%EC%9D%B8%EC%8B%9D%EA%B3%BC-Application-Level%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[시스템 디자인] 재고시스템으로 알아보는 동시성이슈 해결방법 (1/3) - 동시성 이슈와 Application Level로 해결하기&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;b&gt; 비관적 락&lt;/b&gt;을 적용해본다. 또한 다른 주문 요청에 의해 재고를 차감하는 동안 발생한 또다른 재고 차감 요청은 실패가 아니라 이전 주문 요청이 끝난 후 재고 차감을 진행해야하므로 낙관적 락보다는 비관적 락이 적절할 것이다. 낙관적 락을 적용한다면 재처리와 순서 보장 로직을 작성해주어야한다. 락 전략과 특성별 비교, 사용 방식은 &lt;a href=&quot;https://nooblette.tistory.com/entry/%EC%8B%9C%EC%8A%A4%ED%85%9C-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%9E%AC%EA%B3%A0%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9C%BC%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95-23-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EB%9D%BDLock%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[시스템&amp;nbsp;디자인]&amp;nbsp;재고시스템으로&amp;nbsp;알아보는&amp;nbsp;동시성이슈&amp;nbsp;해결방법&amp;nbsp;(2/3)&amp;nbsp;-&amp;nbsp;데이터베이스&amp;nbsp;락(Lock)으로&amp;nbsp;해결하기&lt;/a&gt;를 참고한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비관적 락 획득 요청 타임아웃 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락 획득 요청을 무한정 대기하면&lt;b&gt; 시스템 리소스와 DB 커넥션을 오래 점유&lt;/b&gt;하게 된다. 이는&lt;b&gt; 시스템의 처리량을 줄이고 리소스 사용량을 비효율적&lt;/b&gt;으로 만든다. 장기간 요청 대기를 방지하기 위해 재고 수량 확인을 위한 ItemEntity 조회 메서드에 &lt;b&gt;Jpa QueryHint로 락 획득 유효시간을 5000ms(5초)로 설정&lt;/b&gt;하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1757208772455&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(
	QueryHint(name = &quot;jakarta.persistence.query.timeout&quot;, value = &quot;5000&quot;),
)
@Query(&quot;SELECT i FROM ItemEntity i WHERE i.id = :id&quot;)
fun findByIdWithPessimisticLock(id: Long): ItemEntity?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹은&amp;nbsp;다음과&amp;nbsp;같이&amp;nbsp;프로젝트&amp;nbsp;전역에&amp;nbsp;설정할 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1757208818787&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:
        query.timeout: 5000&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;재고 차감 로직은 다음과 같이 수정할 수 있다. findByIdWithPessimisticLock() 메서드를 호출하여 SELECT FOR UPDATE 쿼리로 ItemEntity를 조회한다. 만약 락 유효시간을 초과하여 락 획득에 실패한다면 StockLockTimeoutException으로 래핑한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757209657814&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun decreaseStock(orderItem: OrderItem): DecreaseStockResult {
    val itemEntity =
        try {
            itemRepository.findByIdWithPessimisticLock(orderItem.id)
                ?: throw IllegalArgumentException(&quot;잘못된 상품 Id(Id = ${orderItem.id}) 입니다.&quot;)
        } catch (exception: PessimisticLockingFailureException) {
            logger.warn(&quot;[락 획득 유효시간 초과] 상품 재고 차감 실패 (상품Id: ${orderItem.id}, 요청 수량: ${orderItem.quantity})&quot;)
            throw StockLockTimeoutException()
        }

    if (orderItem.exceedsStock(itemEntity.stock)) {
        return DecreaseStockResult.Failure(message = generateFailureReason(itemEntity.stock))
    }

    itemEntity.stock -= orderItem.quantity
    return DecreaseStockResult.Success
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StockLockTimeoutException은 예외 클래스 계층 구조로 아래와 같이 설계하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1757209069609&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class StockLockTimeoutException(
    override val message: String = &quot;상품 주문 요청이 많습니다. 잠시 후 다시 시도해주세요.&quot;,
) : ResourceConflictException(message = message)

open class ResourceConflictException(
    override val message: String,
    val code: String = ErrorCodes.CONFLICT,
) : RuntimeException(message)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GloabalExceptiopnHandler에서는 ResourceConflictException이 발생하면 409 에러를 응답한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757209097471&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@ExceptionHandler(ResourceConflictException::class)
fun handleResourceConflictException(exception: ResourceConflictException): ResponseEntity&amp;lt;ApiResponse.Error&amp;gt; =
    ResponseEntity(
		ApiResponse.Error(
			message = exception.message,
			code = exception.code,
		),
        HttpStatus.CONFLICT,
    )&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;H2 DB를 통해 비관적 락 획득 요청 유효시간 테스트를 진행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757209216458&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  datasource:
	url: jdbc:h2:mem:testdb
    username: sa
	password:
	driver-class-name: org.h2.Driver&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 시나리오는 다음과 같다. 데이터베이스에 직접 아래 쿼리를 호출하여 테스트 대상 상품에 대해 락을 획득한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757209257695&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BEGIN;
SELECT id, name, stock FROM items WHERE id = 1 FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 대상 API를 호출한다. 하지만&lt;b&gt; GloabalExceptiopnHandler가 동작하지 않고 500 에러를 반환&lt;/b&gt;하는 것을 볼 수 있다. 또한 락 획득 요청&lt;b&gt; 유효시간은 5초로 지정했으나 2초만에 실패 응답을 반환&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1667&quot; data-origin-height=&quot;404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kFvCj/btsQokAt3Qh/6eiSct5y7ZbNN2EKYGIVkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kFvCj/btsQokAt3Qh/6eiSct5y7ZbNN2EKYGIVkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kFvCj/btsQokAt3Qh/6eiSct5y7ZbNN2EKYGIVkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkFvCj%2FbtsQokAt3Qh%2F6eiSct5y7ZbNN2EKYGIVkk%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;660&quot; height=&quot;160&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1667&quot; data-origin-height=&quot;404&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GloabalExceptionHandler가 동작하지 않은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 GloabalExceptiopnHandler가 동작하지 않은 이유를 분석해본다. 에러 로그를 살펴보면 다음과 같이 Rollback 실패(Unable to rollback against JDBC Connection)로 인해 JpaSystemException가 발생하는 것을 볼 수 있다. 즉, StockLockTimeoutException이 발생하지 않고 JpaSystemException이 발생하여, 의도한대로 GlobalExceptionHandler가 예외를 처리하지 않아 409 에러가 아닌 500 에러를 응답으로 내린다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2050&quot; data-origin-height=&quot;368&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIu5xL/btsQpxeRfqd/SVnAhTgDJFQdiDIbVjU5ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIu5xL/btsQpxeRfqd/SVnAhTgDJFQdiDIbVjU5ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIu5xL/btsQpxeRfqd/SVnAhTgDJFQdiDIbVjU5ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIu5xL%2FbtsQpxeRfqd%2FSVnAhTgDJFQdiDIbVjU5ok%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;2050&quot; height=&quot;368&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2050&quot; data-origin-height=&quot;368&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 추적&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스택 트레이스를 간략히 살펴보면 다음과 같다. MVStoreException이 최초로 발생하고 Spring의 예외 처리 메커니즘을 거쳐 PessmisticLockingFailureException으로 변환된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1757295814354&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.h2.mvstore.MVStoreException 
-&amp;gt; org.h2.jdbc.JdbcSQLTimeoutException 
-&amp;gt; org.hibernate.PessimisticLockException 
-&amp;gt; org.springframework.dao.PessimisticLockingFailureException 
-&amp;gt; StockLockTimeoutException 
-&amp;gt; org.springframework.orm.jpa.JpaSystemException: Unable to rollback against JDBC Connection&lt;/code&gt;&lt;/pre&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;size18&quot;&gt;&lt;b&gt;org.h2.mvstore.MVStoreException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최초 발생한 예외이다. H2 데이터베이스에서 트랜잭션 1이 레코드를 잠그고 있는데, 트랜잭션 2가 2초(2000ms) 내에 락을 획득하지 못하여 H2 내부 예외가 발생한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757295910336&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Caused by: org.h2.mvstore.MVStoreException: Map entry &amp;lt;table.3&amp;gt; with key &amp;lt;1&amp;gt; ... 
is locked by tx 1 and can not be updated by tx 2 within allocated time interval 2000 ms&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;size18&quot;&gt;&lt;b&gt;org.h2.jdbc.JdbcSQLTimeoutException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;H2 내부 예외는 JDBC 타임아웃 예외로 변환된다.&lt;/p&gt;
&lt;pre id=&quot;code_1757296958040&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Caused by: org.h2.jdbc.JdbcSQLTimeoutException: Timeout trying to lock table &quot;ITEMS&quot;&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;size18&quot;&gt;&lt;b&gt;org.hibernate.PessimisticLockException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate&amp;nbsp;계층에 해당한다. JDBC 예외가 Hibernate의 비관적 락 예외로 변환된다.&lt;/p&gt;
&lt;pre id=&quot;code_1757297013665&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Caused by: org.hibernate.PessimisticLockException: JDBC exception executing SQL 
[/* SELECT i FROM ItemEntity i WHERE i.id = :id */ 
select 
ie1_0.id,ie1_0.created_at,ie1_0.name,ie1_0.price,ie1_0.quantity,ie1_0.stock,ie1_0.unit,ie1_0.updated_at 
from items ie1_0 where ie1_0.id=? for update] 
[Timeout trying to lock table &quot;ITEMS&quot;;&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;size18&quot;&gt;&lt;b&gt;org.springframework.dao.PessimisticLockingFailureException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Spring 계층에 해당한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Hibernate 예외가 Spring의 DAO 예외로 최종 변환된다.&lt;/p&gt;
&lt;pre id=&quot;code_1757297054847&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.springframework.dao.PessimisticLockingFailureException: JDBC exception executing SQL 
[/* SELECT i FROM ItemEntity i WHERE i.id = :id */ 
select 
ie1_0.id,ie1_0.created_at,ie1_0.name,ie1_0.price,ie1_0.quantity,ie1_0.stock,ie1_0.unit,ie1_0.updated_at 
from items ie1_0 where ie1_0.id=? for update] 
[Timeout trying to lock table &quot;ITEMS&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그래서&amp;nbsp;JpaSystemException&amp;nbsp;발생&amp;nbsp;원인은?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 케이스를 Stackoverflow의 &lt;a href=&quot;https://stackoverflow.com/questions/63393161/why-is-db-connection-closed-after-trying-and-failing-to-get-a-lock-with-spring-d&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Why is db connection closed after trying and failing to get a lock with spring-data-jpa?&lt;/a&gt; 글에서 찾을 수 있었다. 원인은 HikariCP와 관련이 있다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;ProxyConnection 클래스의&lt;span&gt; checkException(SQLException sqle) 메서드의 구현을 확인해보면, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;HikariCP는 특정 에러 코드 또는 예외 상황에서 DB 커넥션을 방출(evict)&lt;/b&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 style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;아래 코드를 보면 sqlState가 08로 시작하는 에러가 발생했거나, SQLTimeoutException 예외가 발생하면 커넥션 풀에서 해당 커넥션을 방출(evict)한다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1757297248593&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final SQLException checkException(SQLException sqle) {
    ...
	if (sqlState != null &amp;amp;&amp;amp; sqlState.startsWith(&quot;08&quot;) || nse instanceof SQLTimeoutException || ERROR_STATES.contains(sqlState) || ERROR_CODES.contains(nse.getErrorCode())) {
		if (exceptionOverride == null || exceptionOverride.adjudicate(nse) != Override.DO_NOT_EVICT) {
			evict = true;
		}

    if (evict) {
        SQLException exception = nse != null ? nse : sqle;
        LOGGER.warn(&quot;{} - Connection {} marked as broken because of SQLSTATE({}), ErrorCode({})&quot;, new Object[]{this.poolEntry.getPoolName(), this.delegate, exception.getSQLState(), exception.getErrorCode(), exception});
        this.leakTask.cancel();
        this.poolEntry.evict(&quot;(connection is broken)&quot;);
        this.delegate = ProxyConnection.ClosedConnection.CLOSED_CONNECTION;
    }

    return sqle;
}&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;스택 트레이스 중 발생한 org.h2.jdbc.JdbcSQLTimeoutException는 SQLTimeoutException의 하위 클래스에 해당하고, 이는 HikariCP의 커넥션 방출(evict) 정책에 포함된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1757297388940&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public final class JdbcSQLTimeoutException extends SQLTimeoutException implements JdbcException {
    private static final long serialVersionUID = 1L;
    private final String originalMessage;
    private final String stackTrace;
    private String message;
    private String sql;

	...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 H2에서 락 획득 유효시간이 만료되면 발생하는 예외(JdbcSQLTimeoutException)는 &lt;b&gt;HikariCP에서는 커넥션에 문제(broken)가 생겼다고 판단하여 임의로 종료(evict)하는 예외이므로 커넥션을 임의로 종료&lt;/b&gt;한다. 하지만 일반적으로 서비스 클래스에 @Transational 애노테이션을 두어 DB 트랜잭션 작업을 포함한 비즈니스 로직을 처리하게 되는데, Spring의 &lt;b&gt;@Transactional 애노테이션은 원자성을 제공하기 위해 RuntimeException이 발생하면 트랜잭션을 롤백&lt;/b&gt;한다. 하지만 이미 커넥션이 종료된 후라서 &lt;b&gt;롤백이 실패&lt;/b&gt;하고 JpaSystemException이 발생한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HikariCP는 방출하지 않을 DB 에러 코드 또는 예외 케이스를 명시하여 커스텀할 수 있는 SQLExceptionOverride 인터페이스를 제공한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757297552127&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface SQLExceptionOverride {
    default Override adjudicate(SQLException sqlException) {
        return SQLExceptionOverride.Override.CONTINUE_EVICT;
    }

    public static enum Override {
        CONTINUE_EVICT,
        DO_NOT_EVICT;

        private Override() {
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 SQLExceptionOverride 인터페이스를 구현하여 커스텀하게 커넥션 방출 여부를 다룰 수 있다. SQLTimeoutException 예외는 커넥션을 유지하고 Spring의 @Transactional 애노테이션에 의해 처리할 수 있도록 LockTimeoutOnlyOverride를 작성하면 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1757297639774&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class LockTimeoutOnlyOverride : SQLExceptionOverride {
    override fun adjudicate(exception: SQLException): SQLExceptionOverride.Override {
        logger.debug(&quot;sqlState: ${exception.sqlState}, errorCode: ${exception.errorCode}&quot;)

        return when {
            // SQLTimeoutException 예외는 커넥션을 방출(Evict) 하지 않는다.
            exception is SQLTimeoutException -&amp;gt; SQLExceptionOverride.Override.DO_NOT_EVICT

            // 이 외 예외는 기본 정책대로 처리
            else -&amp;gt; SQLExceptionOverride.Override.CONTINUE_EVICT
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 방법은 다음과 같다. application.yml의 spring:hikar:exception-override-class-name에&amp;nbsp;Hikari&amp;nbsp;CP가&amp;nbsp;커넥션을&amp;nbsp;반납(evict)&amp;nbsp;않을&amp;nbsp;예외&amp;nbsp;케이스를&amp;nbsp;정의한&amp;nbsp;클래스(LockTimeoutOnlyOverride)의&amp;nbsp;패키지&amp;nbsp;경로&amp;nbsp;명시한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757297670495&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
    password:
    driver-class-name: org.h2.Driver
    hikari:
       # Hikari CP가 커넥션을 반납(evict) 않을 예외 케이스의 패키지 경로 명시
       exception-override-class-name: com.tangerine.api.common.hikari.LockTimeoutOnlyOverride&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 동일한 시나리오로 테스트한다. 의도대로 409 에러를 던지지만 QueryHint에 정의한 락 획득 유효시간(5초)가 아닌 2초만에 API가 실패를 응답한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1678&quot; data-origin-height=&quot;372&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzQwX6/btsQpYXARG8/8e4QFReQ0ezNQ3L5Bv95tK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzQwX6/btsQpYXARG8/8e4QFReQ0ezNQ3L5Bv95tK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzQwX6/btsQpYXARG8/8e4QFReQ0ezNQ3L5Bv95tK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzQwX6%2FbtsQpYXARG8%2F8e4QFReQ0ezNQ3L5Bv95tK%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;660&quot; height=&quot;146&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1678&quot; data-origin-height=&quot;372&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 2초는 H2 DB의 기본 락 획득 유효시간 정책에 해당한다. 다음과 같이 url을 통해 락 획득 요청 유효시간 설정할 수 있으나, 여전히 QueryHint로 지정한 락 획득 유효시간에 따라 처리되지 않는다.&lt;/p&gt;
&lt;pre id=&quot;code_1757297756911&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  application:
    name: api
  datasource:
    # LOCK_TIMEOUT=15000 : 락 획득 요청 유효시간을 10초로 설정 (기본값 15초)
    url: jdbc:h2:mem:testdb;LOCK_TIMEOUT=10000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.mimacom.com/handling-pessimistic-locking-jpa-oracle-mysql-postgresql-derbi-h2&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Handling Pessimistic Locking with JPA on Oracle, MySQL, PostgreSQL, Apache Derby and H2&lt;/a&gt; 글의 Implementing Pessimistic Locking With Different Providers 부분을 참고해보면 다음과 같은 내용을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;592&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wd3xI/btsQojn4bDW/Z17lVRwN7OwNdlTcgMIOhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wd3xI/btsQojn4bDW/Z17lVRwN7OwNdlTcgMIOhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wd3xI/btsQojn4bDW/Z17lVRwN7OwNdlTcgMIOhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwd3xI%2FbtsQojn4bDW%2FZ17lVRwN7OwNdlTcgMIOhk%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;660&quot; height=&quot;258&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;592&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;H2 DB는 Jpa에서 제공하는 LockModeType.PESSIMISTIC_WRITE 모드는 지원하지만 javax.persistence.lock.timeout 설정을 지원하지 않는다. 또한 즉시 실패(javax.persistence.lock.timeout=0(nowait)) 같은 모드도 지원하지 않는다. 결국 H2는 락 타임아웃 예외를 우아하게 처리할 수 있도록 지원하지 않는다. 실제로 커넥션을 끊어버리는 방식이라 Jpa 레벨에서 LockTimeoutException으로 정상 매핑되지 않는다. 따라서 &lt;b&gt;Jpa로 정의한 비관적 락 획득 유효시간 테스트는 H2 DB로 동작을 테스트할 수 없다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다른 DBMS는?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 연결 설정을 아래와 같이 변경하여, DBMS를 PostgreSQL로 변경하고 동일한 테스트를 진행해본다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298309939&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  application:
    name: api
  datasource:
    url: jdbc:postgresql://localhost:5432/testdb
    username: testuser
    password: testpass
    driver-class-name: org.postgresql.Driver
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostrgreSQL의 기본 락 획득 유효시간은 0 무제한이며, 아래와 같이 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298332994&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
class DataInitializer(
    private val jdbcTemplate: JdbcTemplate,
) {
    @PostConstruct
    fun init() {
		val lockTimeout = jdbcTemplate.queryForObject(&quot;SHOW lock_timeout&quot;, String::class.java)
		println(&quot;Current lock_timeout = $lockTimeout&quot;) // 0 : 무제한
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지정한 유효시간인 5초 후 실패처리는 되지만 500 에러를 반환하는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1675&quot; data-origin-height=&quot;460&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AuezW/btsQm1u1Txi/34ftlCX6ftsVjKfIFdnH40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AuezW/btsQm1u1Txi/34ftlCX6ftsVjKfIFdnH40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AuezW/btsQm1u1Txi/34ftlCX6ftsVjKfIFdnH40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAuezW%2FbtsQm1u1Txi%2F34ftlCX6ftsVjKfIFdnH40%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;660&quot; height=&quot;218&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1675&quot; data-origin-height=&quot;460&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에러 추적&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostrgreSQL에서도 GloabalExceptiopnHandler가 동작하지 않는다. 그 원인을 파악하기 위해 스택 트레이스를 다시 살펴보면 다음과 같이, PSQLException 예외가 최초로 발생하고 Spring의 예외 처리 추상화 메커니즘을 거쳐 QueryTimeException으로 변환되는 것을 볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298551333&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.postgresql.util.PSQLException 
-&amp;gt; org.hibernate.QueryTimeoutException 
-&amp;gt; org.springframework.dao.QueryTimeoutException&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;size18&quot;&gt;&lt;b&gt;org.postgresql.util.PSQLException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자(Hibnernate의 QueryHint) 요청에 의해 중단된다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298593425&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Caused by: org.postgresql.util.PSQLException: 
ERROR: canceling statement due to user request
  Where: while locking tuple (0,1) in relation &quot;items&quot;&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;size18&quot;&gt;&lt;b&gt;org.hibernate.QueryTimeoutException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PSQLException는 Hibernate 계층의 예외로 변환된다. 여기서는 QueryTimeoutException로 변환된다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298642049&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Caused by: org.hibernate.QueryTimeoutException: JDBC exception executing SQL 
[/* SELECT i 
FROM ItemEntity i 
WHERE i.id = :id */ 
select 
ie1_0.id,ie1_0.created_at,ie1_0.name,ie1_0.price,ie1_0.quantity,ie1_0.stock,ie1_0.unit,ie1_0.updated_at 
from items ie1_0 where ie1_0.id=? for no key update] 
[ERROR: canceling statement due to user request
Where: while locking tuple (0,1) in relation &quot;items&quot;]&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;size18&quot;&gt;&lt;b&gt;org.springframework.dao.PessimisticLockingFailureException&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate 예외가 Spring의 DAO 예외로 최종 변환된다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;여기서도&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;QueryTimeoutException로 변환된다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298675418&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.springframework.dao.QueryTimeoutException: JDBC exception executing SQL 
[/* SELECT i 
FROM ItemEntity i 
WHERE i.id = :id */ 
select 
ie1_0.id,ie1_0.created_at,ie1_0.name,ie1_0.price,ie1_0.quantity,ie1_0.stock,ie1_0.unit,ie1_0.updated_at 
from items ie1_0 where ie1_0.id=? for no key update] 
[ERROR: canceling statement due to user request
Where: while locking tuple (0,1) in relation &quot;items&quot;] [n/a]; SQL [n/a]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 QueryTimeoutException은 GlobalExceptionHandler에 정의하지 않았다. GlobalExceptionHandler에는 StockLockTimeoutException(또는 ResourceConflictException)이 발생하면 409 에러를 응답하도록 정의했으므로 적절하게 핸들링 되지 않아 500 에러를 반환하게 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring은 &lt;b&gt;특정 영속성 기술(Jpa, Hibernate, MyBatis 등)에 구현을 의존하지 않도록, 발생하는 예외를 변환하여 추상화&lt;/b&gt;한다. 여기서 예외 처리의 추상화는 PersistenceExceptionTranslator 클래스가 담당하며, 처리 과정은 다음과 같다.&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;JpaTransactionManager의 기본 translator&lt;/li&gt;
&lt;li&gt;HibernateJpaDialect의 translator (1순위가 null을 반환하는 경우)&lt;/li&gt;
&lt;li&gt;원본 예외 그대로 throw (2순위가 null을 반환하는 경우)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PersistenceExceptionTranslator를 직접 구현하여 커스텀하게 예외를 변환할 수 있다. 예를 들어 사용자(Hibernate) 요청에 의해 취소된 경우 CannotAcquireLockException을 던지도록 한다. 공식문서 중 &lt;a href=&quot;https://www.postgresql.org/docs/current/errcodes-appendix.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Appendix A. PostgreSQL Error Codes&lt;/a&gt;를 살펴보면, PostgreSQL는 사용자 요청에 의해 취소하는 경우 &lt;b&gt;에러 코드 57014(query_canceled)&lt;/b&gt;를 반환한다. 따라서 에러 코드 중 57014가 포함되어 있다면 CannotAcquireLockException 예외를 던지도록 변환(translate)한다. 그 외 예외는 기본 PersistenceExceptionTranslator로 기존과 동일하게 동작하도록 null을 반환한다.&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;CannotAcquireLockException은 PessimisticLockingFailureException의 하위 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 앞서 정의한 decreadseStock() 메서드의 예외 처리 로직에 의해 StockLockTimeoutException으로 변환될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2058&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brwYLG/btsQoDs4ZZU/DLJgUXE2pQ4M7Poegpizp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brwYLG/btsQoDs4ZZU/DLJgUXE2pQ4M7Poegpizp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brwYLG/btsQoDs4ZZU/DLJgUXE2pQ4M7Poegpizp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrwYLG%2FbtsQoDs4ZZU%2FDLJgUXE2pQ4M7Poegpizp0%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;2058&quot; height=&quot;400&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2058&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PersistenceExceptionTranslator는 Spring Bean으로 등록해야 정상적으로 동작하므로 @Component 애너테이션을 추가한다. 또한&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;기본&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;PersistenceExceptionTranslator보다 먼저 PostgreSQL의 57014 에러를 처리할 수 있도록 @Primary 애너테이션을 지정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1757298852384&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Primary
@Component
class PostgresSQLExceptionTranslator : PersistenceExceptionTranslator {
    override fun translateExceptionIfPossible(ex: RuntimeException): DataAccessException? =
        when (ex) {
            is QueryTimeoutException -&amp;gt; {
                val sqlException = findSQLException(ex)
                // 57014: canceling statement due to user request
                if (sqlException?.sqlState == &quot;57014&quot;) {
                    CannotAcquireLockException(&quot;Cannot acquire lock due to timeout&quot;, ex)
                } else {
                    // 그 외는 기본 PersistenceExceptionTranslator로 처리
                    null
                }
            }

            // 그 외는 기본 PersistenceExceptionTranslator로 처리
            else -&amp;gt; null
        }

    private fun findSQLException(throwable: Throwable?): SQLException? {
        var current = throwable
        while (current != null) {
            if (current is SQLException) return current
            current = current.cause
        }
        return null
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Spring에서는 다음과 같은 예외 변환 과정을 거쳐 예외를 처리한다.&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;PostgresSQLExceptionTranslator&amp;nbsp;(커스텀)&amp;nbsp;&lt;/li&gt;
&lt;li&gt;JpaTransactionManager의 기본 translator (1순위가 null을 반환하는 경우)&lt;/li&gt;
&lt;li&gt;HibernateJpaDialect의 translator (2순위가 null을 반환하는 경우)&lt;/li&gt;
&lt;li&gt;원본 예외 그대로 throw (3순위가 null을 반환하는 경우)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 지정한 락 획득 요청 시간(5000ms) 내에 획득에 실패한 경우 409 에러를 반환하는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;346&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JH12C/btsQoFdmCG6/pzVjM8dAj6D8cSW8BWXG40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JH12C/btsQoFdmCG6/pzVjM8dAj6D8cSW8BWXG40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JH12C/btsQoFdmCG6/pzVjM8dAj6D8cSW8BWXG40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJH12C%2FbtsQoFdmCG6%2FpzVjM8dAj6D8cSW8BWXG40%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;660&quot; height=&quot;137&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;346&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jpa의 비관적 락 획득 요청 유효시간을 테스트하려 했으나, DBMS별 메커니즘 차이로&lt;b&gt; 다르게 동작&lt;/b&gt;했다. 또한 HikariCP는 특정 에러 코드에 대해 커넥션을 반납하는데, DBMS별 메커니즘 차이로 HikariCP가 개입하는 부분도 있어 &lt;b&gt;예상치 못한 결과&lt;/b&gt;(JpaSystemExcpetion으로 인한 500 에러)를 낳기도 했다. &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;span style=&quot;background-color: #ffffff; color: #474747; text-align: start;&quot;&gt;Non deterministic) &lt;/span&gt;테스트는 단위 테스트나 H2 기반 테스트로는 실제 동작을 검증하기 어렵고, 가능한 프로덕션 환경과 유사한 DB에서 테스트가 필요하다. 참고로 DBMS별 락 획득 요청 타임아웃은 다음과 같다. 결국 특정 벤더사마다 동작 결과가 달라질 수 있다.&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;H2 Database : 명시하지 않는 경우 내부 락 타임아웃 2000ms (2초)&lt;/li&gt;
&lt;li&gt;MySQL(InnoDB) : innodb_lock_wait_timeout 50초 (기본값)&lt;/li&gt;
&lt;li&gt;PostgreSQL : lock_timeout 0 (비활성화/무제한)&lt;/li&gt;
&lt;li&gt;Oracle : DDL_LOCK_TIMEOUT 0초 (즉시 실패/NOWAIT), DML 락은 기본적으로 무제한 대기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에 의해 요청이 끊어지면 예상치 못한 에러(@Transcational 롤백, 커밋 실패 등)를 발생시킬 확률이 높다. 실무에서 이러한 동작은 로그 노출로 보안상 위험, 유지보수 어려움, 디버깅 어려움을 낳을 것이다. 따라서 가능한 DB 타임아웃으로 요청이 끊어지는 것보다는 애플리케이션이 제어, 애플리케이션이 처리하지 못했다면 HikariCP 설정을 다뤄 &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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Jpa</category>
      <author>nooblette</author>
      <guid isPermaLink="true">https://nooblette.tistory.com/418</guid>
      <comments>https://nooblette.tistory.com/entry/JPA-HikariCP%EC%99%80-Jpa%EC%9D%98-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EC%9A%94%EC%B2%AD-%ED%83%80%EC%9E%84%EC%95%84%EC%9B%83-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D%EA%B3%BC-DBMS%EB%B3%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%95%9C%EA%B3%84#entry418comment</comments>
      <pubDate>Mon, 8 Sep 2025 12:08:37 +0900</pubDate>
    </item>
  </channel>
</rss>