<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>easyone.log</title>
        <link>https://velog.io/</link>
        <description>백엔드 개발자 지망 대학생</description>
        <lastBuildDate>Wed, 08 Jul 2026 07:07:49 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>easyone.log</title>
            <url>https://velog.velcdn.com/images/jayaione_ele/profile/bd2d4c9a-d8c0-4234-a510-32d4b1628e45/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. easyone.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jayaione_ele" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Java/Kotlin] equals()와 hashCode()]]></title>
            <link>https://velog.io/@jayaione_ele/JavaKotlin-equals%EC%99%80-hashCode</link>
            <guid>https://velog.io/@jayaione_ele/JavaKotlin-equals%EC%99%80-hashCode</guid>
            <pubDate>Wed, 08 Jul 2026 07:07:49 GMT</pubDate>
            <description><![CDATA[<h2 id="개념">개념</h2>
<p><code>equals()</code> : </p>
<ul>
<li>두 객체 비교, true면 hashCode()도 같아야 함</li>
<li>객체 주소가 같으면 True</li>
<li>== 는 값만 판단 가능, 같은 객체인지 판단하려면 equals 사용 필수</li>
<li>String 객체에서는 주소가 같으면 True, 오버라이딩된 equals 함수를 사용하기 때문</li>
</ul>
<p><code>hashCode()</code> : </p>
<ul>
<li>hashCode()가 같다고 해서 equals()가 true일 필요는 없음</li>
<li>동일한 객체들이 해시 기반 컬렉션에서 같은 버켓에 가도록 보장</li>
</ul>
<h2 id="java">Java</h2>
<p>equals():</p>
<ul>
<li>Object.equals()는 참조 비교</li>
<li>같은 타입 객체여도 같은 인스턴스가 아니라면 false</li>
</ul>
<p>equals()를 오버라이드할 때 중요한 점</p>
<ul>
<li>hasCode() 오버라이드 필수: hashCode()를 오버라이드하지 않는다면 기본 Object.hashCode()가 사용됨 -&gt; 이럴 경우 equals에서 같다고 판정이 나더라도 해시값이 다를수가 있음 -&gt; hashSet에서 contain 여부를 조회할 때, false가 반환될 수 있음</li>
<li>자기 자신 비교, 타입 체크, 필드 비교, hashCode도 같은 필드 기준 계산 필요<pre><code class="language-java">import java.util.Objects;
</code></pre>
</li>
</ul>
<p>public class Member {
    private final String name;
    private final int age;</p>
<pre><code>public Member(String name, int age) {
    this.name = name;
    this.age = age;
}

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Member)) return false;
    Member member = (Member) o;
    return age == member.age &amp;&amp;
            Objects.equals(name, member.name);
}

@Override
public int hashCode() {
    return Objects.hash(name, age);
}</code></pre><p>}</p>
<pre><code>

단점
- equals만 오버라이드하고, hashCode를 만들지 않는다면 버그 발생하기 때문에 번거로움
- equalsI()와 hashCode()가  다른 필드를 보게 되면 계약이 깨짐
- mutable 필드를 기준으로 hashCode()를 생성하게 되면 문제가 생길 수 있음
    - 추후 HashSet에 해당 객체를 넣고, mutable 필드를 수정하면 hash값이 넣었을 때와 변경 이후가 다르기 때문에 문제가 생길 수 있음
    - 해시 기반 컬렉션의 key가 되는 객체는 불변이 안전함

```java
class Member {
    String name;
}


Set&lt;Member&gt; set = new HashSet&lt;&gt;();
Member m = new Member(&quot;kim&quot;);
set.add(m);

m.name = &quot;lee&quot;;

set.contains(m); // 이상 동작 가능
</code></pre><h3 id="kotlin">Kotlin</h3>
<p>equals보다는 <code>==</code> , <code>===</code> 를 사용한 equality 를 위한 연산자 문법 사용이 권장된다. </p>
<ul>
<li><p>== : equals() 기반 비교, 내부적으로 equals 사용</p>
<ul>
<li><p>Kotlin의 모든 클래스는 기본적으로 Any를 상속하는데, 자바 Object처럼..</p>
</li>
<li><p>Any에는 이미 equals 함수가 있는데, 같은 인스턴스면 true, 다르면 false</p>
<pre><code class="language-kotlin">// 기본 equals 함수
open operator fun equals(other: Any?): Boolean

// 오버라이드 equals 함수
override fun equals(other: Any?): Boolean
</code></pre>
</li>
</ul>
</li>
<li><p>=== : 참조 동일성 비교, 같은 인스턴스인지 비교 </p>
</li>
<li><p><code>Any.equals()</code>: 참조 동일성 성격</p>
</li>
<li><p><code>data class</code>는<code>equals()</code>와 <code>hashCode()</code> 자동 생성</p>
<ul>
<li>같은 타입이라면 true</li>
</ul>
</li>
<li><p>data class의 경우</p>
</li>
</ul>
<pre><code class="language-kotlin">data class Member(
    val name: String,
    val age: Int
)

val m1 = Member(&quot;kim&quot;, 20)
val m2 = Member(&quot;kim&quot;, 20)

println(m1 == m2)   // true
println(m1 === m2)  // false
</code></pre>
<ul>
<li><p>일반 클래스에서 오버라이드할  경우 - hashCode() 오버라이드 필수</p>
<pre><code class="language-java">class Money(val amount: Int) {
  override fun equals(other: Any?): Boolean {
      if (this === other) return true
      if (other !is Money) return false
      return this.amount == other.amount
  }

  override fun hashCode(): Int {
      return amount
  }
}
</code></pre>
</li>
</ul>
<p>```</p>
<h3 id="주의할-점">주의할 점</h3>
<ul>
<li>일반 클래스는 자동 값 비교가 아님, 데이터클래스만 해당</li>
<li>배열 비교의 경우 <code>equals()</code>보다는 <code>contentEquals()</code>로 비교</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Server] Spring Boot 메트릭 수집 모니터링 구축 (Railway + Better Stack OTLP push)]]></title>
            <link>https://velog.io/@jayaione_ele/Server-Spring-Boot-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EA%B5%AC%EC%B6%95-Railway-Better-Stack-OTLP-push</link>
            <guid>https://velog.io/@jayaione_ele/Server-Spring-Boot-%EB%A9%94%ED%8A%B8%EB%A6%AD-%EC%88%98%EC%A7%91-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EA%B5%AC%EC%B6%95-Railway-Better-Stack-OTLP-push</guid>
            <pubDate>Mon, 06 Jul 2026 13:30:59 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-boot-메트릭은-어떻게-전달되는지">Spring Boot 메트릭은 어떻게 전달되는지..?</h1>
<p>이번에 모니터링을 평소 하던 AWS가 아닌 Railway와 연결해서 하면서, 어떤 모니터링 방식으로 메트릭 수집을 하면 좋을지 생각해보게 되었다.</p>
<p>메트릭은 매 이벤트를 전송하는 게 아니라, 애플리케이션 메모리 안에서 숫자를 계속 누적하거나 갱신하다가 일정 주기마다 스냅샷을 보내는 구조였다.</p>
<p>이번 글에서는 Spring Boot Actuator, Micrometer, OTLP, Grafana까지 메트릭이 흘러가는 과정을 한 번에 정리한다.</p>
<h2 id="전체-흐름">전체 흐름</h2>
<p>Spring Boot 애플리케이션에서 JVM 메트릭이 Grafana 같은 백엔드까지 가는 흐름은 대략 이렇다.</p>
<pre><code class="language-text">JVM 내부 상태
-&gt; Micrometer 계측/집계
-&gt; OTLP registry가 주기적으로 전송
-&gt; Grafana 같은 백엔드에서 저장/조회</code></pre>
<p>중요한 점은 요청마다 외부로 메트릭을 보내지 않는다는 것이다.</p>
<p>애플리케이션 안의 <code>MeterRegistry</code>가 숫자를 관리하고, <code>OtlpMeterRegistry</code> 같은 registry가 설정된 주기마다 현재 상태를 묶어서 전송한다. 예를 들어 step이 30초라면 30초마다 한 번씩 스냅샷을 보낸다.</p>
<p>그래서 트래픽이 많아져도 메트릭 전송량이 요청 수만큼 폭증하지 않는다. 전송량은 주로 메트릭 개수, 라벨 조합 수, 전송 주기에 영향을 받는다.</p>
<h2 id="jvm-상태">JVM 상태</h2>
<p>JVM은 내부 상태를 MXBean, 즉 JMX 기반 인터페이스로 노출한다.</p>
<p>대표적으로 이런 값들이 있다.</p>
<ul>
<li><code>MemoryMXBean</code>: 힙/논힙 메모리 사용량</li>
<li><code>GarbageCollectorMXBean</code>: GC 횟수, 누적 시간</li>
<li><code>OperatingSystemMXBean</code>: CPU 사용률</li>
<li><code>ThreadMXBean</code>: 스레드 수</li>
</ul>
<p>Spring Boot Actuator를 붙이면 기동 시 여러 MeterBinder가 자동으로 등록된다.</p>
<ul>
<li><code>JvmMemoryMetrics</code></li>
<li><code>JvmGcMetrics</code></li>
<li><code>ProcessorMetrics</code></li>
<li><code>JvmThreadMetrics</code></li>
</ul>
<p>이 Binder들이 JVM의 MXBean 값을 읽어서 Micrometer의 Meter로 연결한다. 그래서 애플리케이션에서 <code>jvm.memory.used</code>, <code>process.cpu.usage</code>, <code>jvm.threads.live</code> 같은 메트릭을 볼 수 있게 된다.</p>
<h2 id="micrometer로-값-관리">Micrometer로 값 관리</h2>
<p>Micrometer는 계측 추상화 계층이다. 중심에는 <code>MeterRegistry</code>가 있고, 그 안에 여러 Meter가 등록된다.</p>
<p>대표적인 Meter는 세 가지다.</p>
<table>
<thead>
<tr>
<th>Meter</th>
<th>의미</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Gauge</td>
<td>현재 순간 값</td>
<td>힙 사용량, 스레드 수</td>
</tr>
<tr>
<td>Counter</td>
<td>단조 증가 누적값</td>
<td>이벤트 횟수, 누적 바이트</td>
</tr>
<tr>
<td>Timer</td>
<td>호출 수, 총 시간, 최대값, 히스토그램</td>
<td>HTTP 지연 시간, GC pause</td>
</tr>
</tbody></table>
<p>여기서 헷갈리기 쉬운 점은 계측과 전송이 분리되어 있다는 것이다.</p>
<p>Micrometer는 앱 안에서 값을 관리한다. 
외부로 보내는 일은 Prometheus registry, OTLP registry 같은 구현체가 맡는다.</p>
<h2 id="otlp는-어떻게-전송하는지">OTLP는 어떻게 전송하는지?</h2>
<p>OTLP 방식에서 <code>OtlpMeterRegistry</code>는 push 방식으로 동작한다.</p>
<p>앱이 시작되면 백그라운드 스레드가 돌고, 설정된 step마다 publish 과정이 실행된다.</p>
<pre><code class="language-text">1. Registry의 모든 Meter를 확인한다.
2. Gauge는 지금 값을 읽는다.
3. Counter, Timer는 누적값과 히스토그램 상태를 읽는다.
4. OTLP protobuf 포맷으로 직렬화한다.
5. 설정된 endpoint로 HTTP POST를 보낸다.</code></pre>
<p>설정에서는 보통 이런 값이 중요하다.</p>
<pre><code class="language-yaml">management:
  otlp:
    metrics:
      export:
        url: https://example-otlp-endpoint
        step: 30s
        headers:
          Authorization: Bearer ${OTLP_TOKEN}</code></pre>
<p>실제 속성 이름은 Spring Boot와 Micrometer 버전에 따라 확인이 필요하지만, 개념적으로는 &quot;어디로&quot;, &quot;얼마나 자주&quot;, &quot;어떤 인증으로&quot; 보낼지가 핵심이다.</p>
<h2 id="prometheus-pull과-otlp-push의-차이">Prometheus pull과 OTLP push의 차이</h2>
<p>메트릭을 수집하는 방식은 크게 pull과 push로 나눠서 볼 수 있다.</p>
<h3 id="prometheus-pull">Prometheus pull</h3>
<p>Prometheus 방식은 수집기가 앱으로 들어와서 값을 가져간다.</p>
<pre><code class="language-text">Prometheus -&gt; /actuator/prometheus -&gt; Spring Boot App</code></pre>
<p>앱은 <code>/actuator/prometheus</code> 같은 엔드포인트에 현재 값을 노출하고, Prometheus가 주기적으로 scrape한다.</p>
<p>Docker Compose나 Kubernetes처럼 내부 네트워크와 service discovery가 잘 잡힌 환경에서는 자연스럽다. 하지만 Railway처럼 외부 수집기가 앱 주소로 접근하거나 target을 관리하기 애매한 환경에서는 설정이 번거로울 수 있다.</p>
<h3 id="otlp-push">OTLP push</h3>
<p>OTLP 방식은 앱이 외부 endpoint로 직접 보낸다.</p>
<pre><code class="language-text">Spring Boot App -&gt; OTLP endpoint -&gt; Grafana Cloud</code></pre>
<p>수집기가 앱으로 들어올 필요가 없다. 앱이 인증 정보를 들고 외부 endpoint로 POST를 보내면 된다.</p>
<p>그래서 PaaS 환경이나 외부 접근이 까다로운 환경에서는 push 방식이 더 단순할 수 있다.</p>
<h2 id="grafana에서는-어떻게-보이는지">Grafana에서는 어떻게 보이는지?</h2>
<p>Grafana 같은 백엔드는 받은 숫자를 시계열로 저장한다.</p>
<p>시계열은 보통 다음 조합으로 구분된다.</p>
<pre><code class="language-text">메트릭 이름 + 라벨 조합</code></pre>
<p>예를 들어 같은 <code>jvm.memory.used</code>라도 라벨이 다르면 다른 시계열이다.</p>
<ul>
<li><code>area=heap</code></li>
<li><code>area=nonheap</code></li>
<li><code>id=G1 Eden Space</code></li>
<li><code>application=gerd</code></li>
</ul>
<p><code>management.metrics.tags.application=gerd</code>처럼 공통 라벨을 붙여두면 나중에 Grafana에서 특정 앱만 필터링하기 좋다.</p>
<p>PromQL에서는 이런 식으로 조회할 수 있다.</p>
<pre><code class="language-promql">process_cpu_usage</code></pre>
<pre><code class="language-promql">rate(jvm_gc_pause_seconds_sum[5m])</code></pre>
<p>미리 만들어진 JVM 대시보드를 가져오면 heap, GC pause, CPU, thread 같은 기본 패널을 빠르게 구성할 수 있다.</p>
<h2 id="better-stack이랑-다른점">Better Stack이랑 다른점</h2>
<p>Better Stack은 로그 수집, 업타임 모니터링, 온콜까지 한 번에 묶기 좋다. 별도로 Loki나 알림 시스템을 붙이지 않아도 시작하기 쉽다는 장점이 있다.</p>
<p>대신 메트릭 대시보드는 Grafana의 PromQL 대시보드를 그대로 쓰기보다 Better Stack의 SQL 방언에 맞춰 직접 패널을 구성해야 할 수 있다.</p>
<p>예를 들어 진단용으로는 이런 패널을 만들 수 있다.</p>
<ul>
<li>Heap used vs max</li>
<li>Live threads</li>
<li>GC pause p50/p95/p99</li>
<li>CPU usage</li>
<li>Disk free</li>
<li>Logback events by level</li>
</ul>
<p>Better Stack 쿼리에서는 게이지 값에 <code>avgMerge(value_avg)</code>를 쓰거나, 라벨을 <code>label(&#39;level&#39;)</code>처럼 꺼내는 식의 차이가 있다. 히스토그램 quantile도 저장소 방언에 맞춰 확인해야 한다.</p>
<p>여기서 중요한 건 쿼리를 외우는 게 아니라, 실제 저장된 metric name과 label을 먼저 확인하는 것이다. Micrometer에서 보던 이름, Prometheus 이름, OTLP 저장소에서 보이는 이름이 조금씩 다를 수 있다.</p>
<h2 id="커스텀-대시보드-구성">커스텀 대시보드 구성</h2>
<p>Better Stack은 그라파냐처럼 spring boot 전용 세팅되어있는게 없다. 
그래서 확인할 항목을 골라서 커스텀으로 쿼리를 입력해서 직접 만들어야 한다. </p>
<p>다음과 같은 순서로 제작했다. </p>
<h3 id="1-better-stack-source-만들기">1. Better Stack Source 만들기</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/f242b225-db7e-4cab-a157-d42836cc05f2/image.png" alt=""></p>
<p>Telemetry -&gt; Sources 탭에 들어가서 Connect Source를 누른다. 
Source를 만들면 Source token, url 이렇게 두개가 나온다. </p>
<pre><code class="language-bash">curl -X POST \
  -H &#39;Content-Type: application/json&#39; \
  -H &#39;Authorization: Bearer [token]&#39; \
  -d &#39;{&quot;dt&quot;:&quot;&#39;&quot;$(date -u +&#39;%Y-%m-%d %T UTC&#39;)&quot;&#39;&quot;,&quot;message&quot;:&quot;Hello from Better Stack!&quot;}&#39; \
  --insecure \
  https://[url]</code></pre>
<pre><code class="language-yml">management:
  endpoints:
    web:
      exposure:
        include: health,info,env,configprops  
  endpoint:
    health:
      show-details: never
      probes:
        enabled: true
  health:
    diskspace:
      enabled: true
    ping:
      enabled: true
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
  otlp:
    metrics:
      export:
        enabled: true
        url: ${BETTERSTACK_OTLP_URL}/v1/metrics
        headers:
          Authorization: Bearer ${BETTERSTACK_SOURCE_TOKEN}
        step: 30s</code></pre>
<p>각각 OTLP, SOURCE_TOKEN으로 넣어준다!</p>
<h3 id="2-커스텀-대시보드-만들기">2. 커스텀 대시보드 만들기</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/aa9612da-bc0e-448b-8838-538ae13d8162/image.png" alt=""></p>
<p>해당 탭에서 Create dashboard로 들어간다. 그러면 처음에는 아예 대시보드가 비어있다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/a3fd3fba-795b-4378-81d3-f99e95622bbb/image.png" alt=""></p>
<p>맨 오른쪽의 플러스 버튼을 누르면 차트를 만들 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/bbbb088c-24e7-49b1-88e2-bcbd16556204/image.png" alt=""></p>
<p>source를 아까 만들었던것으로 절정하고, Query를 입력하고 Run query를 하면 설정한 차트 타입에 따라 볼 수 있다. </p>
<p>나는 다음과 같은 항목을 추가해줬다. </p>
<table>
<thead>
<tr>
<th>패널</th>
<th>확인하려는 것</th>
</tr>
</thead>
<tbody><tr>
<td>Heap used vs max</td>
<td>메모리 기준선과 누수 의심</td>
</tr>
<tr>
<td>GC pause p95/p99</td>
<td>사용자 지연으로 이어질 수 있는 멈춤</td>
</tr>
<tr>
<td>CPU usage</td>
<td>앱 CPU와 시스템 CPU 추세</td>
</tr>
<tr>
<td>Live threads</td>
<td>스레드 증가나 고착 여부</td>
</tr>
<tr>
<td>Logback events</td>
<td>error/warn 로그 급증</td>
</tr>
<tr>
<td>Disk free</td>
<td>디스크 고갈 위험</td>
</tr>
</tbody></table>
<p>이렇게 항목을 달아주면...</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/69179452-91e2-4a7b-b157-d7b2cd7224c9/image.png" alt=""></p>
<p>완성이된다~!</p>
<h2 id="정리">정리</h2>
<p>메트릭 수집의 핵심은 이벤트 전송이 아니라 숫자 상태의 주기적 스냅샷이다.</p>
<ul>
<li>JVM은 MXBean/JMX로 내부 상태를 노출한다.</li>
<li>Spring Boot Actuator의 MeterBinder가 JVM 값을 Micrometer Meter로 연결한다.</li>
<li>Micrometer는 앱 메모리의 MeterRegistry에서 값을 관리한다.</li>
<li>OTLP registry는 step마다 protobuf로 직렬화해서 HTTP POST한다.</li>
<li>Grafana나 Better Stack은 이를 시계열로 저장하고 label 기준으로 조회한다.</li>
</ul>
<p>Prometheus pull이 좋은 환경도 있고, OTLP push가 더 단순한 환경도 있다. 지금 나같은 경우는 Railway를 사용하고 있기에 Better Stack을 사용했다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] 다사다난 취준생의 상반기 회고록]]></title>
            <link>https://velog.io/@jayaione_ele/%ED%9A%8C%EA%B3%A0-%EB%8B%A4%EC%82%AC%EB%8B%A4%EB%82%9C-%EC%B7%A8%EC%A4%80%EC%83%9D%EC%9D%98-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@jayaione_ele/%ED%9A%8C%EA%B3%A0-%EB%8B%A4%EC%82%AC%EB%8B%A4%EB%82%9C-%EC%B7%A8%EC%A4%80%EC%83%9D%EC%9D%98-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Tue, 30 Jun 2026 20:04:39 GMT</pubDate>
            <description><![CDATA[<p>상반기 시간이 너무 빨리 흘러간 것 같기도 하고 .. 이래저래 많은 일이 있었어서 상반기 회고록을 작성했다.!</p>
<p>가장 큰 사건 위주로 보면</p>
<ul>
<li>UMC 9기 수료 및 데모데이 </li>
<li>새로운 프로젝트 백엔드 합류, 8월 런칭 예정</li>
<li>UMC 스프링 파트장, 안드로이드 챌린저로 스터디 진행</li>
<li>개발동아리 프로그라피 11기 참여 </li>
</ul>
<p>등등등.. 이 있었다. 학교도 안다니면서 정말 다사다난했다!</p>
<h3 id="12월">1~2월</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/beb8c0b9-89ba-46c0-bfb4-73414f34f9eb/image.png" alt=""></p>
<p>1월에는 일단 UMC 데모데이를 위한 프로젝트 TAMINGO를 시작했다. 
백엔드 5명은 처음이었고 팀장을 맡게 되었다. IOS와 연동하는 것도 처음이었기에 기대가 되었다.
그리고 프로젝트 자체가 LLM API 호출이나 Redis 캐싱이 많이 필요한 프로젝트라서 좀 애먹었던 것 같다.
생각보다 시간투자를 많이 해야 해서 힘들었다 ..
그리고 길찾기 API를 사용해서 도착 예상 시간을 매번 계산해야 했기에 지도 API는 이것저것 거의 다 만져본 것 같다.</p>
<p>그리고 런칭 준비 중인 독서 기록 서비스 NOOK의 리뉴얼 버전 개발을 시작했다. 
기존 기능과 많이 달라지기도 했고 Rest Docs를 도입해서, 안좋았던 부분들을 다 버리고 시작하는 느낌이라 좀 기분이 좋았다 .. ^ㅡ^</p>
<p>2월 말! 드디어 UMC 데모데이를 했다. 생각보다 내가 맡은 기능 질문이 많이 들어와서 당황스러웠지만.. 
하루종일 단체티 입고 다니면서 구경하니 재밌었다. 
마지막으로 참여하는 스프링 부트 프로젝트였는데 꽤나 만족스러웠다!</p>
<h3 id="34월">3~4월</h3>
<p>3월에는 역시 동아리 시작이다.. 
UMC 스프링부트 파트장으로서의 마지막 10기를 시작했다. 
서류,면접,OT 등등 하니 시간이 빨리 갔다.</p>
<p>이번 기수에는 스프링부트가 아닌 Android 파트로 프로젝트에 참여하게 되었다. 
코틀린 공부를 하려고 했지만 잘 안하게 되어서.. 강제로라도 하기 위해 한것도 있고,
AX 시대에 풀스택 개발을 해보면 좋지 않을까? 해서 하게 되었다. 
그리고 React도 공부를 해봤지만 재미가 없었기에.. 
1학년때 하고 포기한 Android.. 를 다시 시작하게 되었다.</p>
<p>그리고 4월에는 새로운 동아리를 시작했다.
다른 개발 동아리들과 달리 과제를 받는게 흥미로웠고, 빨리 출시해서 돈을 벌어보고 싶다는 생각에 프로그라피 11기에 지원하게 되었다.
과제를 비록 전날에 시작해서 대충대충 해서 냈지만...! 
면접까지 거쳐서 백엔드 파트로 합격하게 되었다~! 두근두근.. </p>
<p>4월에는 또 새로운 프로젝트에 백엔드 파트로 합류하게 되었다.
알림 기능을 맡게 되었고, 필요하면 다른 기능도 맡기로 하고 들어가게 되었다.</p>
<h3 id="56월">5~6월</h3>
<p>음.. 이제 내가 벌려놓은 일을 수습할 시간이다.</p>
<p>먼저 프로그라피에서는 5월에 기획 단계에 들어갔기에 초기 세팅 작업을 열심히 했다.
AI 세팅, 코틀린 스프링 세팅 등등.. 을 진행했다. 
이번에는 AWS가 아닌 Railway를 사용하면서 비용 절감에 집중해보려고 한다!</p>
<p>6월에는 많은 일이 있었다. 
싸피 코딩테스트 응시 및 면접 준비(하느라 개발을 거의 못했다..), 프로그라피 백엔드 팀원 탈주로 인한 업무량 증가..
이것때문에 코틀린 스프링을 우다다다다 해서 깨달은게 많다.</p>
<p>이것 말고도 다른 프로젝트를 간간히 진행했다.</p>
<p>그리고 매주 나를 힘들게했던 안드로이드 스터디가 끝났다. 스프링 스터디 피드백도!
프론트는 나랑 잘 안맞는다고 느꼈다. 그렇지만 내부 로직 짜는 건 재밌게 했던 것 같다. </p>
<h3 id="회고록">회고록</h3>
<p>졸업유예를 하고 코딩테스트,포트폴리오 제작,CS 공부 등등을 하려던 나의 목표가 프로젝트 기계(?)로 바뀌었지만 프로젝트를 하면서 많이 배웠다. 
확실히 학교를 안다니면서 프로젝트를 하니 삶의 질이 달라지는 것 같다. ^^
그렇지만 하반기에는 프로젝트를 많이 하더라도 공부를 꾸준히 병행해야겠다는 생각이 들었다.
코딩테스트 문제 풀기도 초반엔 열심히 했는데 나중에는 바빠서 거의 못했다.</p>
<h3 id="하반기에-할일들">하반기에 할일들</h3>
<ul>
<li>운동을 열심히 하자.주4회이상(중요)</li>
<li>성공적으로 프로젝트 런칭하기</li>
<li>공모전 참여하기 </li>
<li>CS 공부, 코딩테스트 공부 매일 꾸준히 하기</li>
<li>본격적 취준하기</li>
<li>안드로이드 프로젝트하기</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/50195ad3-0a46-4490-83e8-37cbd3d7b8a8/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring/Kotlin] Sealed Class로 DTO 생성하기]]></title>
            <link>https://velog.io/@jayaione_ele/SpringKotlin-Sealed-Class%EB%A1%9C-DTO-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/SpringKotlin-Sealed-Class%EB%A1%9C-DTO-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 21 Jun 2026 13:04:11 GMT</pubDate>
            <description><![CDATA[<h1 id="kotlin-sealed-class">Kotlin Sealed Class</h1>
<p>sealed class는 상속 가능한 범위를 같은 파일로 제한하는 클래스다.</p>
<pre><code class="language-kotlin">sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val w: Double, val h: Double) : Shape()</code></pre>
<p>컴파일러가 모든 하위 타입을 알고 있기 때문에, <code>when</code> 분기에서 <code>else</code> 없이도 모든 케이스를 강제 처리할 수 있다.</p>
<pre><code class="language-kotlin">when (shape) {
    is Circle -&gt; ...
    is Rectangle -&gt; ...
    // else 불필요 — 누락 시 컴파일 에러
}</code></pre>
<p>나중에 <code>Triangle</code>을 추가하면, 처리하지 않은 <code>when</code>에서 즉시 컴파일 에러가 발생한다. 런타임이 아닌 컴파일 타임에 누락을 잡아주는 게 핵심.</p>
<hr>
<h3 id="언제-쓰는지">언제 쓰는지?</h3>
<p>타입에 따라 가지고 있는 데이터 구조가 다를 때 유용하다.</p>
<hr>
<h2 id="실제-사용-예--타임라인-api-응답-dto">실제 사용 예 — 타임라인 API 응답 DTO</h2>
<p>요구사항 </p>
<p>타임라인 아이템은 식사 기록이 1개인 <code>SINGLE</code>과 2개 이상인 <code>GROUP</code> 두 가지 타입이 있다. 문제는 등급(<code>grade</code>) 필드가 <code>SINGLE</code>일 때만 존재한다는 점이다. 또한 여러개이려면 대표 음식도 두개를 표시해야 한다. 그리고 같이 기록된 음식의 개수도 표시해야 한다. </p>
<p>이걸 하나의 DTO로 표현하면 이렇게 된다.</p>
<pre><code class="language-kotlin">data class TimeLineItemDTO(
    val type: TimeLineType,
    val grade: String?,  // SINGLE이면 값 있고, GROUP이면 null
    ...
)</code></pre>
<p><code>grade</code>가 null인 이유가 타입 레벨에서 드러나지 않는다. 클라이언트는 왜 null인지 알 수 없고, 서버도 <code>grade</code>를 실수로 채워 보내도 컴파일러가 잡아주지 못한다. 즉 타입에 따라 해당하지 않는 필드를 null로 채우면 비효율적이라는 것이다. </p>
<p><code>sealed class</code>로 분리하면 이 문제가 사라진다.</p>
<pre><code class="language-kotlin">sealed class TimeLineItemDTO {
    data class Single(
        val grade: String,  // non-null 보장
        ...
    ) : TimeLineItemDTO()

    data class Group(
        // grade 자체가 없음
        ...
    ) : TimeLineItemDTO()
}</code></pre>
<p><code>SINGLE</code>이면 <code>grade</code>가 반드시 있고, <code>GROUP</code>이면 애초에 필드가 없다. nullable 대신 타입 구조로 제약을 표현한 것이다. 서비스 레이어에서 <code>when</code> 분기로 각 타입을 생성하면 컴파일러가 모든 케이스 처리를 강제한다.</p>
<hr>
<p><code>abstract class</code>와 비슷하지만 <strong>상속 범위 제한 + 분기 누락 검사</strong> 가 추가된 것으로 이해하면 된다.</p>
<p><code>abstract class</code>는 누군가 다른 파일에서 <code>Triangle : Shape()</code>를 추가할 수도 있으니, 컴파일러는 <code>when</code>에 무조건 <code>else</code>를 강제한다. 즉 해당 요구사항에서는 두 타입만 올것인데 다른게 올 수 있다고 하는 것이다. </p>
<p><code>sealed class</code>는 하위 타입을 전부 안다는 것이 보장되니까, <code>when</code>에서 모든 케이스를 빠짐없이 처리했는지를 컴파일러가 검사해줄 수 있다. 
만약 <code>Triangle</code>을 새로 추가했는데 <code>when</code>에서 빼먹으면, 그 즉시 컴파일 에러가 난다는 점에서 <code>abstract class</code>와 차이가 있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 외부 LLM API의 트랜잭션 문제 ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%EC%99%B8%EB%B6%80-LLM-API%EC%9D%98-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%EC%99%B8%EB%B6%80-LLM-API%EC%9D%98-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sat, 20 Jun 2026 20:06:04 GMT</pubDate>
            <description><![CDATA[<h2 id="트러블슈팅-비동기-증상-분석-결과가-최신-데이터를-덮어쓰는-문제">트러블슈팅: 비동기 증상 분석 결과가 최신 데이터를 덮어쓰는 문제</h2>
<h3 id="문제">문제</h3>
<p>증상 생성/수정 후 Gemini 기반 패턴 분석을 비동기로 갱신하고 있었다.</p>
<p>기존 흐름은 다음과 같았다.</p>
<pre><code class="language-kotlin">@Async(&quot;analysisTaskExecutor&quot;)
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun refreshAsync(symptomId: String, userId: Long) {
    val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return
    if (!symptom.isAnalysisDirty) return

    val result = symptomPatternAnalysisService.generate(symptom, userId)

    if (result.shouldUpdate) {
        symptom.updateAnalysis(result.analysisJson)
    }
}</code></pre>
<p>이 구조에는 두 가지 문제가 있었다.</p>
<ol>
<li>Gemini 호출이 DB 트랜잭션 안에서 실행된다.</li>
<li>오래 걸린 이전 비동기 작업이 나중에 끝나면서 최신 분석 결과를 덮어쓸 수 있다.</li>
</ol>
<blockquote>
<p>요구사항 : 사용자가 증상 생성 시 증상에 대한 분석을 생성해야 한다. 결과는 anslysisJson이라는 컬럼으로 저장되며, 응답을 내려줄 때는 dto로 매핑한다.  </p>
</blockquote>
<p>예를 들어 사용자가 증상을 생성하면 분석 작업 A가 시작되고, 바로 메모를 수정하면 분석 작업 B가 다시 시작된다.<br>B가 먼저 끝나 최신 데이터 기준 분석을 저장했더라도, A가 늦게 끝나면 과거 데이터 기준 분석으로 <code>analysisJson</code>을 다시 덮어쓸 수 있었다.</p>
<h3 id="원인">원인</h3>
<p>비동기 작업마다 “이 분석이 어떤 시점의 증상 상태를 기준으로 만든 결과인지”를 검증하지 않았다.</p>
<p>즉, 작업 시작 시점의 증상 상태와 결과 저장 시점의 증상 상태가 같은지 확인하는 장치가 없었다.</p>
<p>또한 <code>@Transactional(REQUIRES_NEW)</code>가 refresh 전체에 걸려 있어, DB 조회뿐 아니라 Gemini 외부 호출 중에도 트랜잭션이 유지될 수 있었다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p><code>Symptom</code>에 분석 전용 버전 필드인 <code>analysisVersion</code>을 추가했다.</p>
<p>증상 내용이 바뀌어 분석이 다시 필요해질 때마다 버전을 증가시킨다.</p>
<pre><code class="language-kotlin">@Column(name = &quot;analysis_version&quot;, nullable = false, columnDefinition = &quot;bigint default 0&quot;)
var analysisVersion: Long = analysisVersion
    protected set

fun markAnalysisDirty() {
    isAnalysisDirty = true
    analysisVersion += 1
}</code></pre>
<p>분석 결과를 저장할 때는 작업 시작 시점의 버전과 현재 버전이 같은 경우에만 반영한다.</p>
<pre><code class="language-kotlin">fun updateAnalysis(analysisJson: String, expectedVersion: Long): Boolean {
    if (!isAnalysisDirty || analysisVersion != expectedVersion) return false

    this.analysisJson = analysisJson
    isAnalysisDirty = false
    return true
}</code></pre>
<p>비동기 서비스에서는 작업 시작 시점의 버전을 캡처한다.</p>
<pre><code class="language-kotlin">@Async(&quot;analysisTaskExecutor&quot;)
fun refreshAsync(symptomId: String, userId: Long) {
    val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return
    if (!symptom.isAnalysisDirty) return

    val expectedVersion = symptom.analysisVersion

    val result = symptomPatternAnalysisService.generate(symptom, userId)

    if (result.shouldUpdate) {
        symptomAnalysisResultWriter.updateIfVersionMatches(
            symptomId = symptomId,
            userId = userId,
            expectedVersion = expectedVersion,
            analysisJson = result.analysisJson,
        )
    }
}</code></pre>
<p>결과 저장은 별도 writer에서 짧은 쓰기 트랜잭션으로 처리했다.</p>
<pre><code class="language-kotlin">@Service
class SymptomAnalysisResultWriter(
    private val symptomRepository: SymptomRepository,
) {
    @Transactional
    fun updateIfVersionMatches(
        symptomId: String,
        userId: Long,
        expectedVersion: Long,
        analysisJson: String,
    ): Boolean {
        val symptomExternalId = runCatching { UUID.fromString(symptomId) }.getOrNull() ?: return false
        val symptom = symptomRepository.findByExternalIdAndUser_Id(symptomExternalId, userId) ?: return false

        return symptom.updateAnalysis(analysisJson, expectedVersion)
    }
}</code></pre>
<h3 id="결과">결과</h3>
<p>오래된 비동기 작업이 늦게 끝나더라도, 그 사이 증상이 수정되어 <code>analysisVersion</code>이 증가했다면 결과를 저장하지 않는다.</p>
<p>즉, 최신 증상 상태에 기반한 분석만 반영된다.</p>
<p>또한 Gemini 호출을 감싼 긴 쓰기 트랜잭션을 제거하고, 최종 저장 시점에만 짧은 트랜잭션을 사용하도록 분리했다.</p>
<h3 id="검증">검증</h3>
<p>오래된 분석 결과가 버려지는 테스트를 추가했다.</p>
<pre><code>@Test
fun `작업 시작 이후 증상이 바뀌어 버전이 다르면 오래된 분석 결과를 버린다`() {
    val symptom = SymptomFixture.symptom(isAnalysisDirty = true, analysisVersion = 3L)

    whenever(symptomRepository.findByExternalIdAndUser_Id(SymptomFixture.SYMPTOM_EXTERNAL_ID, userId))
        .thenReturn(symptom)

    val updated = writer.updateIfVersionMatches(
        symptomId = SymptomFixture.SYMPTOM_EXTERNAL_ID.toString(),
        userId = userId,
        expectedVersion = 2L,
        analysisJson = &quot;&quot;&quot;{&quot;label&quot;:&quot;오래된 결과&quot;}&quot;&quot;&quot;,
    )

    assertThat(updated).isFalse()
    assertThat(symptom.analysisJson).isNull()
    assertThat(symptom.isAnalysisDirty).isTrue()
    assertThat(symptom.analysisVersion).isEqualTo(3L)
}</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] TestContainers 도입기]]></title>
            <link>https://velog.io/@jayaione_ele/DB-TestContainers-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/DB-TestContainers-%EB%8F%84%EC%9E%85%EA%B8%B0</guid>
            <pubDate>Mon, 15 Jun 2026 07:46:48 GMT</pubDate>
            <description><![CDATA[<h1 id="testcontainers로-postgresql-통합-테스트-환경-맞추기">Testcontainers로 PostgreSQL 통합 테스트 환경 맞추기</h1>
<p>이번에 테스트 환경을 보다가 찝찝한 지점이 있었다.</p>
<p>운영은 PostgreSQL을 쓰고 있는데, 테스트는 H2의 PostgreSQL mode로 돌고 있었다.</p>
<p>처음에는 PostgreSQL mode면 어느 정도 비슷하지 않나?라고 생각할 수 있다.<br>그런데 Repository 테스트나 JPA 매핑 테스트에서는 이 차이가 생각보다 크다.
H2, PostgreSQL 예약어를 둘다 피해야 하고, 같은 환경이 아니므로 테스트 시 예외가 생길 수도 있는 것이다.</p>
<h2 id="testcontainers란">TestContainers란?</h2>
<p>통합테스트의 중요성이 강조될수록 필요하다고 생각한다. 보통 통합 테스트를 위해서 인메모리나 임베디드를 사용한다. 
H2 인메모리 데이터베이스를 주로 통합테스트에 사용하는데,
통합테스트에 필요한 기능을 전부 지원하지 않을 수도 있다고 한다. 예를들어 PostgreSQL을 애플리케이션에서 사용한다고 하면 고급 기능을 지원하지 않을 수 있다는 것이다. </p>
<h3 id="동작-원리">동작 원리</h3>
<p>도커 컨테이너로 래핑된 실제 서비스와의 통합 테스트를 위해서, 간단한 API를 제공하는 테스트 라이브러리이다. </p>
<ul>
<li>TestContainers API를 사용해서 도커 컨테이너 시작</li>
<li>서비스를 사용해서 테스트 실행</li>
<li>사용된 서비스 컨테이너 파괴, 성공 여부와 상관없음</li>
</ul>
<p>즉 테스트를 성공하려면 도커가 설치되어있어야 한다.</p>
<h2 id="왜-h2를-사용하지-않는지">왜 H2를 사용하지 않는지</h2>
<p>H2 PostgreSQL mode는 PostgreSQL을 완전히 재현하지 않는다.<br>PostgreSQL처럼 보이게 일부 문법을 맞춰주는 것에 가깝다.</p>
<p>그래서 다음 같은 부분에서 차이가 생길 수 있다.</p>
<ul>
<li><code>@SQLRestriction</code> 기반 소프트 삭제 필터</li>
<li>native query</li>
<li>PostgreSQL 전용 타입이나 함수</li>
<li><code>@MapsId</code> 공유 PK 매핑</li>
<li>FK, unique, not null 같은 제약 조건</li>
<li>cascade, orphan removal 동작</li>
</ul>
<p>예를 들어 이런 native query가 있다고 하자.</p>
<pre><code class="language-sql">SELECT COUNT(*)
FROM users
WHERE user_id = ?</code></pre>
<p>H2에서는 통과했는데 PostgreSQL에서는 식별자, 타입, 문법 차이 때문에 실패할 수 있다.<br>이러면 테스트는 통과인데 운영에서 깨지는 상황이 생긴다.</p>
<p>그래서 테스트 DB도 운영과 같은 PostgreSQL 16으로 맞추기로 했다.</p>
<h2 id="선택한-방식-jdbctc-url">선택한 방식: jdbc:tc URL</h2>
<p>이번에는 Testcontainers의 JDBC URL 방식을 썼다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:tc:postgresql:16:///testdb
    username: test
    password: test
  test:
    database:
      replace: none</code></pre>
<p><code>jdbc:tc:postgresql:16:///testdb</code>는 Testcontainers가 제공하는 특수한 JDBC URL이다.<br>애플리케이션이 이 datasource로 연결하려고 하면 Testcontainers가 PostgreSQL 컨테이너를 띄워준다.</p>
<p>이 방식의 장점은 설정이 가볍다는 것이다.</p>
<pre><code class="language-kotlin">dependencies {
    testImplementation(&quot;org.testcontainers:junit-jupiter&quot;)
    testImplementation(&quot;org.testcontainers:postgresql&quot;)
}</code></pre>
<p>컨테이너를 직접 생성하는 테스트 설정 클래스를 만들 필요가 없다.<br>지금처럼 PostgreSQL 하나만 필요한 상황에서는 가장 단순하다.</p>
<h2 id="activeprofilestest-추가하기">@ActiveProfiles(&quot;test&quot;) 추가하기</h2>
<p>놓치기 쉬운 부분이 있다.</p>
<p><code>application-test.yml</code>에 Testcontainers datasource를 잡아도, 테스트가 test profile로 실행되지 않으면 해당 설정을 읽지 않는다.</p>
<p>그래서 Repository 테스트나 통합 테스트에 profile을 명시했다.</p>
<pre><code class="language-kotlin">@DataJpaTest
@ActiveProfiles(&quot;test&quot;)
class UserRepositoryTest {
    // ...
}</code></pre>
<p>통합 테스트도 마찬가지다.</p>
<pre><code class="language-kotlin">@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
class HealthControllerTests {
    // ...
}</code></pre>
<p>그리고 <code>@DataJpaTest</code> 같은 slice test에서는 Spring Boot가 datasource를 임베디드 DB로 바꾸려고 할 수 있다.<br>그래서 다음 설정도 같이 둔다.</p>
<pre><code class="language-yaml">spring:
  test:
    database:
      replace: none</code></pre>
<p>이 설정이 없으면 열심히 Testcontainers datasource를 잡아놓고도 테스트에서는 다른 DB를 볼 수 있다.</p>
<h2 id="serviceconnection">@ServiceConnection</h2>
<p>Spring Boot에서는 <code>@ServiceConnection</code>으로 Testcontainers 연결 정보를 자동 등록할 수도 있다.</p>
<pre><code class="language-kotlin">@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {

    @Bean
    @ServiceConnection
    fun postgresContainer(): PostgreSQLContainer&lt;*&gt; =
        PostgreSQLContainer(&quot;postgres:16&quot;)
}</code></pre>
<p>이 방식은 컨테이너를 코드로 직접 다룬다.<br>그래서 다음 상황에서는 JDBC URL보다 더 낫다.</p>
<ul>
<li>PostgreSQL 외에 Redis, Kafka 같은 컨테이너가 추가된다.</li>
<li>컨테이너 옵션을 코드로 제어해야 한다.</li>
<li>datasource 외의 연결 정보도 함께 등록해야 한다.</li>
<li>테스트별 컨테이너 생명주기를 명확하게 관리해야 한다.</li>
</ul>
<p>즉 이번 선택 기준은 이렇게 잡았다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>PostgreSQL 하나만 필요하다</td>
<td><code>jdbc:tc:</code> URL</td>
</tr>
<tr>
<td>여러 컨테이너가 필요하다</td>
<td><code>@ServiceConnection</code></td>
</tr>
<tr>
<td>Spring Boot 지원 범위를 벗어난 세부 설정이 필요하다</td>
<td><code>@DynamicPropertySource</code></td>
</tr>
</tbody></table>
<p><code>@ServiceConnection</code>을 쓰기 어려운 경우에는 <code>@DynamicPropertySource</code>로 직접 datasource property를 넣을 수도 있다.</p>
<pre><code class="language-kotlin">@Testcontainers
@SpringBootTest
class UserIntegrationTest {

    companion object {
        @Container
        val postgres = PostgreSQLContainer(&quot;postgres:16&quot;)

        @JvmStatic
        @DynamicPropertySource
        fun properties(registry: DynamicPropertyRegistry) {
            registry.add(&quot;spring.datasource.url&quot;, postgres::getJdbcUrl)
            registry.add(&quot;spring.datasource.username&quot;, postgres::getUsername)
            registry.add(&quot;spring.datasource.password&quot;, postgres::getPassword)
        }
    }
}</code></pre>
<h2 id="트레이드오프">트레이드오프</h2>
<p>Testcontainers를 쓰면 분명 H2보다 느리다.</p>
<p>첫 실행 때는 Docker 이미지 pull이 필요할 수 있고, 컨테이너 기동 시간도 있다.<br>또 로컬이나 CI에 Docker가 없으면 테스트가 돌지 않는다.</p>
<p>하지만 DB 통합 테스트의 목적이 운영 DB와의 정합성 검증이라면 이 비용은 낼 만하다.</p>
<p>정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>얻은 것</th>
<th>감수한 것</th>
</tr>
</thead>
<tbody><tr>
<td>정합성</td>
<td>운영과 같은 PostgreSQL에서 테스트</td>
<td>H2보다 무거움</td>
</tr>
<tr>
<td>속도</td>
<td>JVM 내 컨테이너 공유로 비용 분산 가능</td>
<td>첫 컨테이너 기동 수 초</td>
</tr>
<tr>
<td>환경</td>
<td>로컬과 CI의 DB 조건 통일</td>
<td>Docker 필수</td>
</tr>
<tr>
<td>설정</td>
<td>JDBC URL은 코드 0줄</td>
<td>DB 단독일 때 가장 적합</td>
</tr>
<tr>
<td>확장성</td>
<td>지금 단계에서는 충분</td>
<td>Redis 등 추가 시 방식 전환 필요</td>
</tr>
</tbody></table>
<h2 id="ci에서는">CI에서는?</h2>
<p>GitHub Actions의 <code>ubuntu-latest</code> runner는 Docker를 사용할 수 있다.<br>그래서 일반적인 Testcontainers 기반 테스트는 별도의 PostgreSQL service를 workflow에 띄우지 않아도 동작한다.</p>
<p>물론 Docker가 필요한 테스트가 된다는 점은 명확히 해야 한다.<br>로컬 개발자 환경이나 CI runner가 Docker를 지원하지 않으면 테스트가 실패한다.</p>
<h2 id="정리">정리</h2>
<ul>
<li>운영이 PostgreSQL이면 DB 통합 테스트도 PostgreSQL에서 돌리는 편이 낫다.</li>
<li>H2 PostgreSQL mode는 PostgreSQL과 같지 않다.</li>
<li><code>jdbc:tc:postgresql:16:///testdb</code>는 단일 DB 컨테이너 테스트에 가장 가볍다.</li>
<li><code>@DataJpaTest</code>에서는 <code>spring.test.database.replace: none</code>을 확인해야 한다.</li>
<li><code>application-test.yml</code>을 쓰려면 <code>@ActiveProfiles(&quot;test&quot;)</code>가 필요하다.</li>
<li>Redis, Kafka 등 컨테이너가 늘어나면 <code>@ServiceConnection</code>이나 <code>@DynamicPropertySource</code>로 넘어가는 게 낫다.</li>
</ul>
<p>테스트 속도만 보면 H2가 더 좋다.<br>하지만 운영에서 깨질 가능성을 줄이는 테스트라면, 조금 느려도 실제 DB에서 검증하는 쪽이 더 낫다고 판단해서 도입하게 되었다. </p>
<h3 id="출처">출처</h3>
<p><a href="https://java.testcontainers.org/modules/databases/jdbc/">https://java.testcontainers.org/modules/databases/jdbc/</a>
<a href="https://java.testcontainers.org/modules/databases/postgres/">https://java.testcontainers.org/modules/databases/postgres/</a>
<a href="https://docs.spring.io/spring-boot/reference/testing/testcontainers.html">https://docs.spring.io/spring-boot/reference/testing/testcontainers.html</a>
<a href="https://docs.spring.io/spring-boot/api/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.Replace.html">https://docs.spring.io/spring-boot/api/java/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.Replace.html</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Page, Slice, Cursor Pagination 언제 써야 할지?]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-Page-Slice-Cursor-Pagination-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EC%A7%80</link>
            <guid>https://velog.io/@jayaione_ele/Spring-Page-Slice-Cursor-Pagination-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EC%A7%80</guid>
            <pubDate>Sun, 14 Jun 2026 15:56:01 GMT</pubDate>
            <description><![CDATA[<p>커서 페이징을 다시 보다가 갑자기 헷갈렸다.</p>
<p><code>PageRequest.of(0, size)</code>를 쓰면 offset이 0이니까 cursor 기반 조회에서 사실상 <code>limit size</code>처럼 동작하는 것 아닌가?<br>그럼 <code>Limit.of(size)</code>랑 뭐가 다른가?<br>또 <code>Slice</code>는 <code>hasNext()</code>가 있으니까 cursor pagination처럼 써도 되는 걸까?</p>
<p>정리해보니 핵심은 이거였다.</p>
<blockquote>
<p><code>Page</code>, <code>Slice</code>, <code>PageRequest</code>는 Spring Data의 페이징 추상화이고, cursor pagination은 조회 전략이다.  </p>
</blockquote>
<h2 id="pagerequest에-대해-살펴보기">PageRequest에 대해 살펴보기</h2>
<p><code>PageRequest</code>는 <code>Pageable</code>의 구현체다.</p>
<p>구조를 단순화하면 다음과 같다.</p>
<pre><code class="language-text">AbstractPageRequest
  - int page
  - int size

PageRequest
  - Sort sort</code></pre>
<p>즉 <code>PageRequest</code>는 사실상 다음 세 가지를 가진다.</p>
<pre><code class="language-text">page + size + sort</code></pre>
<p>여기서 중요한 점은 <code>offset</code>을 필드로 저장하지 않는다는 것이다.<br>offset은 필요할 때 계산된다.</p>
<pre><code class="language-kotlin">val request = PageRequest.of(2, 500)

request.pageNumber // 2
request.pageSize   // 500
request.offset     // 1000</code></pre>
<p>계산식은 단순하다.</p>
<pre><code class="language-kotlin">offset = page * size</code></pre>
<p>그래서 다음 요청은:</p>
<pre><code class="language-kotlin">PageRequest.of(2, 500)</code></pre>
<p>SQL로 보면 대략 이런 의미가 된다.</p>
<pre><code class="language-sql">limit 500 offset 1000</code></pre>
<h2 id="pagerequestof0-size는-limitofsize와-같을까">PageRequest.of(0, size)는 Limit.of(size)와 같을까?</h2>
<p>완전히 같지는 않다.</p>
<pre><code class="language-kotlin">PageRequest.of(0, size)
Limit.of(size)</code></pre>
<p>둘 다 &quot;size개만 가져온다&quot;는 결과만 보면 비슷할 수 있다.<br>하지만 표현하는 정보가 다르다.</p>
<p><code>PageRequest</code>는 다음 정보를 가진다.</p>
<ul>
<li>page number</li>
<li>page size</li>
<li>offset</li>
<li>sort</li>
<li>다음/이전 페이지 요청 생성 메서드</li>
</ul>
<p>반면 <code>Limit</code>은 말 그대로 개수 제한만 표현한다.</p>
<p>그래서 cursor 기반 조회에서 <code>PageRequest.of(0, size)</code>를 쓰면 offset은 항상 0이다.<br>이동은 offset이 아니라 cursor 조건이 담당한다.</p>
<pre><code class="language-kotlin">fun findNextUsers(lastUserId: Long?, pageable: Pageable): List&lt;User&gt; {
    return queryFactory
        .selectFrom(user)
        .where(
            lastUserId?.let { user.id.gt(it) }
        )
        .orderBy(user.id.asc())
        .offset(pageable.offset)
        .limit(pageable.pageSize.toLong())
        .fetch()
}</code></pre>
<pre><code class="language-kotlin">findNextUsers(
    lastUserId = 1000L,
    pageable = PageRequest.of(0, 100),
)</code></pre>
<p>이 경우 offset은 0이고, 실제 다음 페이지 이동은 <code>id &gt; 1000</code> 조건이 담당한다.</p>
<p>다만 개인적으로는 cursor 조회라면 <code>PageRequest</code>보다 명시적인 <code>size</code>, <code>Limit</code>, 또는 <code>CursorRequest</code> 같은 타입이 더 읽기 좋다고 생각한다.<br><code>PageRequest</code>라는 이름 자체가 offset 기반 페이지를 떠올리게 만들기 때문이다.</p>
<h2 id="page와-slice-차이">Page와 Slice 차이</h2>
<p>Spring Data에서 <code>Page</code>와 <code>Slice</code>는 비슷해 보이지만 목적이 다르다.</p>
<h3 id="page">Page</h3>
<p><code>Page</code>는 현재 페이지 데이터뿐 아니라 전체 개수와 전체 페이지 수를 알고 있다.</p>
<pre><code class="language-kotlin">val page: Page&lt;User&gt; = userRepository.findAll(PageRequest.of(0, 20))

page.content
page.totalElements
page.totalPages
page.hasNext()</code></pre>
<p>여기서 헷갈렸던 부분이 있다.</p>
<blockquote>
<p>Page도 hasNext()를 가지고 있을까?</p>
</blockquote>
<p>정답은 가지고 있다.</p>
<p><code>Page</code>는 <code>Slice</code>를 상속하기 때문에 <code>hasNext()</code>를 사용할 수 있다.</p>
<p>다만 <code>Page</code>는 전체 개수와 전체 페이지 수를 알기 위해 count query가 추가로 실행될 수 있다.<br>그래서 전체 개수가 필요 없는 상황이라면 굳이 <code>Page</code>를 쓸 필요가 없다.</p>
<h3 id="slice">Slice</h3>
<p><code>Slice</code>는 전체 개수는 모른다.</p>
<p>대신 다음 slice가 있는지만 알 수 있다.</p>
<pre><code class="language-kotlin">val slice: Slice&lt;User&gt; = userRepository.findByActiveTrue(PageRequest.of(0, 20))

slice.content
slice.hasNext()
slice.number
slice.size</code></pre>
<p>그래서 다음 같은 상황에 적합하다.</p>
<ul>
<li>무한 스크롤</li>
<li>더보기 버튼</li>
<li>전체 페이지 수가 필요 없는 목록</li>
<li>count query 비용을 줄이고 싶은 경우</li>
</ul>
<h2 id="slice는-cursor-pagination이-아니다">Slice는 cursor pagination이 아니다</h2>
<p>여기서 중요한 점이 있다.</p>
<p><code>Slice</code>는 반환 타입이다.<br>cursor pagination은 조회 방식이다.</p>
<p>다음 코드는 <code>Slice</code>를 반환하지만 cursor pagination은 아니다.</p>
<pre><code class="language-kotlin">fun findByActiveTrue(pageable: Pageable): Slice&lt;User&gt;

val first = repository.findByActiveTrue(PageRequest.of(0, 100))
val second = repository.findByActiveTrue(first.nextPageable())</code></pre>
<p><code>first.nextPageable()</code>을 쓰면 다음 요청은 page가 1이 된다.</p>
<pre><code class="language-text">page = 1
size = 100
offset = 100</code></pre>
<p>즉 여전히 offset 기반이다.</p>
<p>cursor pagination은 마지막으로 읽은 값을 다음 조회 조건으로 넘겨야 한다.</p>
<pre><code class="language-kotlin">fun findNextUsers(lastUserId: Long?, size: Int): List&lt;User&gt; {
    return queryFactory
        .selectFrom(user)
        .where(
            lastUserId?.let { user.id.gt(it) }
        )
        .orderBy(user.id.asc())
        .limit(size.toLong() + 1)
        .fetch()
}</code></pre>
<p>보통은 <code>size + 1</code>개를 조회해서 다음 데이터가 있는지 판단한다.</p>
<pre><code class="language-kotlin">data class CursorPage&lt;T&gt;(
    val items: List&lt;T&gt;,
    val nextCursor: Long?,
    val hasNext: Boolean,
)

fun toCursorPage(rows: List&lt;User&gt;, size: Int): CursorPage&lt;User&gt; {
    val hasNext = rows.size &gt; size
    val items = rows.take(size)

    return CursorPage(
        items = items,
        nextCursor = if (hasNext) items.lastOrNull()?.id else null,
        hasNext = hasNext,
    )
}</code></pre>
<h2 id="삭제가-섞이면-offset은-위험하다">삭제가 섞이면 offset은 위험하다</h2>
<p>offset 기반 pagination이 특히 위험한 상황은 조회하면서 데이터를 삭제하거나 상태를 바꾸는 경우다.</p>
<p>예를 들어 FCM 토큰 발송 실패 데이터를 조회해서 삭제한다고 해보자.</p>
<pre><code class="language-text">처음 데이터: [1, 2, 3, 4, 5, 6]
page=0, size=2 조회: [1, 2]
처리 후 1, 2 삭제
남은 데이터: [3, 4, 5, 6]
page=1, size=2 조회: offset 2부터 조회 -&gt; [5, 6]</code></pre>
<p>이러면 <code>[3, 4]</code>를 건너뛰게 된다.</p>
<p>이런 경우에는 page나 slice보다 cursor 기반 조회가 낫다.</p>
<pre><code class="language-kotlin">var cursor: Long? = null

while (true) {
    val rows = tokenRepository.findFailedTokensAfter(
        lastId = cursor,
        limit = batchSize + 1,
    )

    val hasNext = rows.size &gt; batchSize
    val targets = rows.take(batchSize)

    if (targets.isEmpty()) break

    tokenRepository.deleteAll(targets)
    cursor = targets.last().id

    if (!hasNext) break
}</code></pre>
<p>핵심은 다음 페이지를 몇 개 건너뛸지로 찾지 않는다는 것이다.<br>마지막으로 처리한 id 이후를 찾는다. 이렇게 되면 중간에 삭제되더라도 offset이 밀리지 않는다. </p>
<h2 id="상황에-따른-정리">상황에 따른 정리</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>전체 개수, 전체 페이지 수가 필요하다</td>
<td>Page</td>
</tr>
<tr>
<td>다음 페이지 존재 여부만 필요하다</td>
<td>Slice</td>
</tr>
<tr>
<td>단순히 N개만 제한하면 된다</td>
<td>List + Limit</td>
</tr>
<tr>
<td>삭제나 상태 변경을 동반한다</td>
<td>Cursor</td>
</tr>
<tr>
<td>무한 스크롤이나 대량 순회다</td>
<td>Cursor 또는 Slice</td>
</tr>
</tbody></table>
<p>마지막으로 다시 정리하면, </p>
<ul>
<li><code>Page</code>도 <code>hasNext()</code>를 가진다.</li>
<li><code>Page</code>는 전체 개수 계산을 위해 count query가 붙을 수 있다.</li>
<li><code>Slice</code>는 전체 개수를 모르고 다음 slice 존재 여부만 안다.</li>
<li><code>Slice</code>를 쓴다고 cursor pagination이 되는 것은 아니다.</li>
<li><code>PageRequest.of(0, size)</code>는 cursor 조건과 함께 쓰면 offset 0이라 limit처럼 동작할 수 있다.</li>
<li>하지만 cursor 의도를 명확히 하려면 별도 <code>CursorRequest</code>, <code>Limit</code>, <code>size</code> 파라미터를 고려하는 게 좋다.</li>
</ul>
<p>결국 중요한 건 반환 타입 이름이 아니라 조회 방식이다.<br>데이터가 중간에 바뀔 수 있는지, 전체 개수가 필요한지, 다음 페이지 판단만 필요한지를 보고 선택해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] MapsId로 일대일 매핑하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-MapsId%EB%A1%9C-%EC%9D%BC%EB%8C%80%EC%9D%BC-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Spring-MapsId%EB%A1%9C-%EC%9D%BC%EB%8C%80%EC%9D%BC-%EB%A7%A4%ED%95%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 07 Jun 2026 17:49:06 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>User마다 한개씩만 생성되어야 하는 엔티티들이 있다. 
기존에는 기본키를 따로 두고 user를 FK로 두는 방식으로 진행했다. </p>
<p>그렇지만 기본키를 많이 안쓰고 findByUserId 식의 메서드를 많이 사용한다면 컬럼 낭비라고 생각하여 전략을 변경해보게 되었다. </p>
<h2 id="전략">전략</h2>
<p>기존 방식은 다음과 같다. </p>
<p><code>@OneToOne</code> + id + user_id </p>
<pre><code>  @Entity
  @Table(name = &quot;user_notification_settings&quot;)
  class UserNotificationSetting(

      // 자체 PK
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = &quot;notification_setting_id&quot;)
      val id: Long? = null,                           

      // user FK
      @OneToOne(fetch = FetchType.LAZY, optional = false)
      @JoinColumn(name = &quot;user_id&quot;, nullable = false, unique = true)
      @OnDelete(action = OnDeleteAction.CASCADE)
      val user: User,                                

      // ...
  )</code></pre><ul>
<li>user_id를 unique 제약 조건으로 두면서도 불필요한 notification_setting_id가 존재하는 방식이다. </li>
</ul>
<p><code>@MapsId</code>를 사용해서 개선하고자 한다. </p>
<h3 id="mapsid">MapsId</h3>
<ul>
<li>부모 엔티티의 PK를 자식 엔티티의 PK로 공유하는 패턴이다.</li>
<li>즉 별도의 id 컬럼을 생성하지 않고 외래 키가 곧 기본키가 된다. </li>
</ul>
<p>부모 - User</p>
<pre><code class="language-kotlin">  @Entity
  @Table(name = &quot;users&quot;)
  class User(
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      @Column(name = &quot;user_id&quot;)
      val id: Long? = null,
      // ...
  )</code></pre>
<p>자식 - UserNotificationSetting</p>
<ul>
<li>신규 저장 시 DB가 user_id를 자동 생성한다.</li>
<li>user에서 가져온 PK를 <code>@Id</code> 필드에서 자동으로 채우는 방식이다. </li>
<li>DB 레벨에서 User 삭제 시 해당 엔티티도 자동 삭제된다. </li>
</ul>
<pre><code class="language-kotlin">
  @Entity
  @Table(name = &quot;user_notification_settings&quot;)
  class UserNotificationSetting(

      // 
      @MapsId
      @OneToOne(fetch = FetchType.LAZY, optional = false)
      @JoinColumn(name = &quot;user_id&quot;, nullable = false)
      @OnDelete(action = OnDeleteAction.CASCADE)
      val user: User,

      @Id
      @Column(name = &quot;user_id&quot;)
      val userId: Long? = null,                        

      // ...
  )</code></pre>
<p>저장 방법은 다음과 같다. </p>
<pre><code>  // UserAccountRegistrar.kt
  // user를 먼저 저장
  val user = userRepository.save(buildUser())   
  // MapsId가 user.id를 읽어서 user_notification_settings.user_id에 자동 세팅 
  notificationSettingRepository.save(
      UserNotificationSetting(user = user)            
  )
  // @MapsId가 user.id를 읽어서 user_notification_settings.user_id에
  자동 세팅</code></pre><ul>
<li>user의 자식이기 때문에 user가 null인 상태에서는 절대 저장할 수 없다. </li>
</ul>
<p>결론적으로 <code>@MapsId</code> 방식에서는 컬럼이 하나다.
user_id가 PK면서 동시에 FK이기 때문이다. </p>
<p>일대일 매핑을 할 때 무지성 중간테이블 기본키 + 외래키 해서 추가 컬럼을 만들기보다는 이런 식으로 하면 컬럼 낭비를 줄일 수 있을 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin/Spring] Kotlin 환경에서 효율적으로 로그 작성]]></title>
            <link>https://velog.io/@jayaione_ele/KotlinSpring-Kotlin-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8-%EC%9E%91%EC%84%B1</link>
            <guid>https://velog.io/@jayaione_ele/KotlinSpring-Kotlin-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8-%EC%9E%91%EC%84%B1</guid>
            <pubDate>Sun, 07 Jun 2026 09:49:44 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>자바 스프링 -&gt; 코틀린 스프링으로 갈아타면서, <code>Slf4j</code>을 당연하게 사용했는데 코틀린은 롬북을 잘 사용하지 않기도 하고, 이참에 다른 로거와의 성능을 비교해서 써보자 하고 다른 로그 작성 방법을 찾아보게 되었다. </p>
<h3 id="로거-종류">로거 종류</h3>
<h4 id="slf4j-기본-parameterized">SLF4J 기본 parameterized</h4>
<ul>
<li>로그 수준에 따라서 로그가 남겨진다. 즉 logger.info, logger.debug로 지정하면 레벨에 따라 표시 여부가 졀정된다.</li>
<li>문제점은 로그가 현재 로그 수준에 해당하지 않으면 남겨지지 않는데, 로그에서 호출하는 객체의 메서드는 그대로 호출된다. </li>
<li>즉 비용이 있는 메서드면 쓸데없는 성능 저하로 이어진다. </li>
<li>String 결합만 막힐 뿐, 인자 평가 자체는 막지 못한다 → 불완전한 Lazy</li>
<li>Object, 비싼 메서드는 실행이 되긴 한다는 것</li>
</ul>
<p>해결..?</p>
<ul>
<li>람다를 사용하면 로그 수준 조건문을 사용하지 않아도 성능 영향 없이 가능하다. 
  -&gt; but Object 가변인자만 받을 수 있고, 람다를 사용할 방법은 없다.. 결국 조건문 사용해야한다. </li>
</ul>
<h4 id="해결-시도-1----조건문">해결 시도 1  - 조건문</h4>
<pre><code>  if (logger.isDebugEnabled()) {

  if (logger.isDebugEnabled()) {
      logger.debug(&quot;{}&quot;, foo.veryExpensiveMethod());
  }</code></pre><p> -&gt; 문장마다 매번 체크 붙여줘야 해서 귀찮다.. </p>
<h4 id="slf4j-supplier-20">SLF4J Supplier (2.0+)</h4>
<pre><code class="language-kotlin">logger.debug(&quot;{}&quot;, () -&gt; foo.veryExpensiveMethod());</code></pre>
<ul>
<li>이렇게 하면 인자를 람다로 감싸게 됨, 조건문에 걸려야 실행된다.</li>
<li>그렇지만 인자를 람다로 감싸야 진짜 Lazy 방법이다. </li>
<li>그런데 인자를 감싸면 인자가 많을수록 람다도 여러 개 필요해서 아주 번거롭다. </li>
</ul>
<h4 id="kotlin-logging">Kotlin-logging</h4>
<p>사용 방법</p>
<pre><code class="language-kotlin"> logger.debug { &quot;${foo.veryExpensiveMethod()}&quot; }</code></pre>
<ul>
<li>위의 문제 해결하려면 인자마다 람다로 감싸야 하는데, 코틀린에서는 함수 자체가 람다를 인자로 받는 구조라서 해결된다. </li>
<li>{} 안의 코드 전체가 하나의 람다 블록이다. 함수 호출을 포함한 문자열 생성 자체가 람다 안에 있어서, debug 레벨이 활성화됐을 때만 블록 전체가 실행된다. 
내부적으로  if (isDebugEnabled) logger.debug(msg().toString()) 으로 동작한다. </li>
</ul>
<h3 id="결론">결론</h3>
<p>Kotlin-logging은 조건문과 SLF4J Supplier와 동일한 효과를 가지면서, 매번 조건문을 쓰지 않아도 되고 인자마다 람다를 감싸지 않아도 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Jetpack Compose 에서 Lazy 레이아웃 사용하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/Android-Jetpack-Compose-%EC%97%90%EC%84%9C-Lazy-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Android-Jetpack-Compose-%EC%97%90%EC%84%9C-Lazy-%EB%A0%88%EC%9D%B4%EC%95%84%EC%9B%83-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 19 May 2026 18:44:00 GMT</pubDate>
            <description><![CDATA[<p>UMC 안드로이드 8주차 미션 블로그 챌린지입니다.</p>
<h1 id="android---jetpack-compose-에서-lazy-레이아웃-사용하기">Android - Jetpack Compose 에서 Lazy 레이아웃 사용하기</h1>
<h1 id="개념정리">개념정리</h1>
<h2 id="recyclerview-vs-lazy-레이아웃의-차이">RecyclerView vs Lazy 레이아웃의 차이</h2>
<h4 id="recyclerview는-기존-view-방식">RecyclerView는 기존 View 방식</h4>
<ul>
<li>RecyclerView, Adapter, ViewHolder, 각 항목의 레이아웃 XML이 필요하다. </li>
<li>즉 XML파일, 코틀린 파일 분리해서 관리해야 한다. </li>
</ul>
<h4 id="lazy-레이아웃">Lazy 레이아웃</h4>
<ul>
<li>LazyColumn, LazyRow로 전부 대체 가능</li>
<li>Compose에서는 화면에 보이는 영역에 보이는 아이템만 컴포즈하고 배치</li>
<li>스크롤로 화면 밖을 벗어난 아이템은 컴포지션에서 제거됨 <ul>
<li>Lazy 레이아웃은 컴포즈의 컴포지션 단계를 제어함<ul>
<li>컴포지션(Composition) → 레이아웃(Layout) → 그리기(Drawing)</li>
</ul>
</li>
<li>일반 컬럼에서는 화면 밖이더라도 컴포즈됨</li>
<li>LazyColumn은 화면 밖에 있다면 컴포지션 트리에 존재하지 않음</li>
<li>즉 스크롤할 때 화면 밖으로 나가면 컴포지션에서도 제거되며, onDispose 호출 + remember로 들고 있던 상태도 사라짐</li>
<li>스크롤하면서 새로 화면에 보이게된 요소는 새로 컴포즈될 때 상태가 초기화됨</li>
</ul>
</li>
<li>즉 화면에 보이지 않는 아이템은 컴포지션 트리에서도 존재하지 않기 때문에, 메모리나 CPU 낭비가 없다.</li>
<li>Key를 통한 아이템 재배치<ul>
<li>Key가 없다면 위치 기반으로 상태를 추적한다. 이렇게되면 아이템 순서가 바뀔 수 있기 때문에, id 기반으로 상태를 추적해서 순서가 변경된다면 key에 따라서 재배치되어 정확성이 보장됨</li>
</ul>
</li>
</ul>
<h3 id="lazycolumn--lazyrow의-기본-사용법-숙지">LazyColumn / LazyRow의 기본 사용법 숙지</h3>
<h4 id="lazycolumn">LazyColumn</h4>
<ul>
<li>아이템을 수직으로 배치해서 세로 스크롤을 지원<pre><code class="language-kotlin">LazyColumn {
  item { Text(&quot;헤더&quot;) }
  items(5) { index -&gt; Text(&quot;Item $index&quot;) }
  item { Text(&quot;푸터&quot;) }
}</code></pre>
</li>
</ul>
<h4 id="lazyrow">LazyRow</h4>
<ul>
<li>아이템 수평으로 배치해서 가로 스크롤 지원<pre><code class="language-kotlin">LazyRow {
  items(photos) { photo -&gt; PhotoCard(photo) }
}</code></pre>
</li>
</ul>
<ul>
<li>대부분의 Compose 레이아웃은 <code>@Composable</code> 콘텐츠 블록을 직접 받지만, Lazy 컴포넌트는 <code>LazyListScope.()</code> <strong>DSL 블록</strong>을 받음</li>
<li>레이아웃과 스크롤 위치에 따라 필요한 아이템을 알아서 추가</li>
</ul>
<h3 id="옵션">옵션</h3>
<h4 id="content-padding--콘텐츠-가장자리-여백">Content Padding — 콘텐츠 가장자리 여백</h4>
<ul>
<li>LazyColumn 자체가 아닌 아이템들에 패딩이 적용됨</li>
<li>Modifier.padding()으로 직접 붙이면 컴포저블 자체에 여백이 생김</li>
<li>contentPadding은 스크롤 가능한 콘텐츠 영역에 여백 추가<pre><code class="language-kotlin">LazyColumn(
  contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { /* ... */ }</code></pre>
</li>
</ul>
<h4 id="content-spacing--아이템-간격">Content Spacing — 아이템 간격</h4>
<ul>
<li><code>Arrangement.spacedBy()</code> 사용</li>
<li>아이템 사이에 간격을 줌</li>
</ul>
<pre><code class="language-kotlin">LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }
LazyRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { /* ... */ }</code></pre>
<h3 id="lazylistscope-dsl의-다양한-함수-활용">LazyListScope DSL의 다양한 함수 활용</h3>
<ul>
<li><p>레이아웃 내에 아이템 관련 다양한 함수 제공</p>
</li>
<li><p>item(){} : 단일 아이템</p>
</li>
<li><p>item(count){} : 개수로 아이템 추가</p>
</li>
<li><p>item(list): 컬렉션으로 아이템 추가 </p>
</li>
<li><p>itemIndexed(): 인덱스로 아이템 추가, 아이템의 순서가 필요할 때 사용</p>
</li>
<li><p>contentType: 성능 최적화, 다양한 타입 아이템이 혼재할 때 지정하면 컴포즈가 동일 타입끼리만 컴포즈 재사용해서 성능을 높일 수 있음</p>
</li>
</ul>
<h3 id="lazyverticalgrid--lazyhorizontalgrid-그리드-레이아웃-이해">LazyVerticalGrid / LazyHorizontalGrid 그리드 레이아웃 이해</h3>
<ul>
<li>아이템을 그리드 형태로 표시할 때 사용</li>
</ul>
<h4 id="gridcellsadaptive--최소-너비로-열-수-자동-결정"><code>GridCells.Adaptive</code> — 최소 너비로 열 수 자동 결정</h4>
<ul>
<li><p>화면 너비에 따라 열 수가 자동으로 결정됨</p>
</li>
<li><p>다양한 화면 크기를 설정할 수 있음</p>
<pre><code class="language-kotlin">  LazyVerticalGrid(
  columns = GridCells.Adaptive(minSize = 128.dp)
  ) {
      items(photos) { photo -&gt; PhotoItem(photo) }
  }</code></pre>
</li>
</ul>
<h3 id="gridcellsfixed--고정-열-수"><code>GridCells.Fixed</code> — 고정 열 수</h3>
<pre><code class="language-kotlin">LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item -&gt; PhotoItem(item) }
}</code></pre>
<h3 id="griditemspan---특정-아이템에-커스텀-span-적용">GridItemSpan - 특정 아이템에 커스텀 span 적용</h3>
<ul>
<li>특정 아이템이 여러 열을 차지하게 할 때 <code>span</code> 파라미터를 활용</li>
<li>아이템이 몇 칸을 차지할지 span 파라미터로 지정</li>
<li>maxLineSpan은 현재 행의 전체 컬럼 수 -&gt; 한 행을 통째로 점유 가능</li>
</ul>
<pre><code class="language-kotlin">LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 30.dp)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        CategoryCard(&quot;Fruits&quot;)  // 전체 행을 차지
    }
    items(items) { item -&gt; ItemCard(item) }
}</code></pre>
<h3 id="lazyverticalstaggeredgrid">LazyVerticalStaggeredGrid</h3>
<ul>
<li>각 아이템의 높이 또는 너비가 다를 수 있는 그리드 </li>
<li>핀터레스트처럼 불규칙한 높이를 가진 그리드를 생성</li>
<li>벽돌을 쌓듯이 빈 공간을 채움</li>
<li>짧은 컬럼에 다음 아이템을 채우는 방식</li>
</ul>
<p><strong><code>columns = StaggeredGridCells.Adaptive(200.dp)</code></strong></p>
<ul>
<li>각 컬럼이 최소 200dp가 되도록 화면 너비에 따라 컬럼 수 자동 결정</li>
<li>고정하고 싶으면 <code>StaggeredGridCells.Fixed(2)</code>처럼 개수 지정</li>
</ul>
<p><strong><code>verticalItemSpacing = 4.dp</code></strong></p>
<ul>
<li>세로 방향 아이템 간 간격</li>
<li>같은 컬럼 내 위/아래 아이템 사이 여백</li>
</ul>
<p><strong><code>horizontalArrangement = Arrangement.spacedBy(4.dp)</code></strong></p>
<ul>
<li>가로 방향 컬럼 간 간격</li>
</ul>
<p>아이템 블록 </p>
<pre><code class="language-kotlin">modifier = Modifier.fillMaxWidth().wrapContentHeight()</code></pre>
<ul>
<li><code>fillMaxWidth()</code>: 컬럼 너비를 꽉 채움 </li>
<li><code>wrapContentHeight()</code>: 이미지 원본 비율에 맞춰 높이 결정, staggered 효과가 생김</li>
</ul>
<h3 id="아이템-key-애니메이션-sticky-header-등-심화-기능-활용">아이템 Key, 애니메이션, Sticky Header 등 심화 기능 활용</h3>
<h4 id="아이템-key">아이템 Key</h4>
<ul>
<li>각 아이템의 상태는 리스트에서 아이템의 위치를 키로 사용해서, 데이터가 변경되면 위치가 바뀐 아이템은 상태를 잃을 수 있음</li>
<li>아이템에서 Key를 사용하면, 아이템 위치가 변경되더라도 컴포즈가 상태(remember한 값 등)를 아이템과 함께 이동시킴</li>
</ul>
<pre><code class="language-kotlin">LazyColumn {
    items(
        items = messages,
        key = { message -&gt; message.id }  // key 사용
    ) { message -&gt;
        MessageRow(message)
    }
}</code></pre>
<h4 id="아이템-변경-애니메이션">아이템 변경 애니메이션</h4>
<ul>
<li><code>animateItem()</code> modifier를 사용하면 아이템 추가·삭제·이동 시 애니메이션을 자동으로 적용할 수 있음, Key와 함게 사용해야 효과적<pre><code class="language-kotlin">  LazyColumn {
      items(messages, key = { it.id }) { message -&gt;
          MessageRow(
              message,
              modifier = Modifier.animateItem()
          )
      }
  }</code></pre>
</li>
</ul>
<h3 id="sticky-header--고정-헤더">Sticky Header — 고정 헤더</h3>
<ul>
<li>그룹화된 데이터를 표시할 때 유용함</li>
<li><code>stickyHeader()</code>를 사용 </li>
</ul>
<p>단일 헤더</p>
<pre><code class="language-kotlin">@Composable
fun ListWithHeader(items: List&lt;Item&gt;) {
    LazyColumn {
        stickyHeader { Header() }
        items(items) { item -&gt; ItemRow(item) }
    }
}</code></pre>
<p>여러 헤더 (이름 첫 글자별 그룹)</p>
<ul>
<li>groupBy: 리스트를 어떤 기준으로 묶어서 Map으로 만들어 줌</li>
<li>Key는 첫글자, value는 첫글자로 시작하는 연락처 목록 -&gt; Map으로 선언</li>
<li>그룹별로 선언하고, CharacterHeader로 해당 그룹의 헤더를 그림, 스크롤해도 상단에 붙어있게 할 수 있음</li>
</ul>
<pre><code class="language-kotlin">// 그룹핑은 ViewModel에서 처리하는 것을 권장
val grouped = contacts.groupBy { it.firstName[0] }

@Composable
fun ContactsList(grouped: Map&lt;Char, List&lt;Contact&gt;&gt;) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) -&gt;
            stickyHeader { CharacterHeader(initial) }
            items(contactsForInitial) { contact -&gt; ContactListItem(contact) }
        }
    }
}</code></pre>
<p>주의 - 같은 방향 스크롤 중첩 금지</p>
<ul>
<li>크기가 정해지지 않은 <code>LazyColumn</code>을 세로 스크롤 <code>Column</code> 안에 중첩하면 <code>IllegalStateException</code>이 발생</li>
<li>방향이 다른 경우 허용: 가로 스크롤 Row 안에 세로 <code>LazyColumn</code> 은 가능</li>
</ul>
<p>주의 - 하나의 item 블록에 여러 요소 넣지 않기</p>
<ul>
<li>하나의 <code>item { }</code> 블록에 여러 컴포저블을 넣으면 하나의 단위로 취급되어 성능이 안좋아짐</li>
<li>Divider는 이전 아이템과 같은 블록에 넣어도 됨 </li>
</ul>
<h3 id="스크롤-상태-제어-lazyliststate-이해">스크롤 상태 제어 (LazyListState) 이해</h3>
<ul>
<li>LazyListState: LazyColumn, Row의 현재 상태를 담고 있는 객체 - 스크롤, 아이템 </li>
<li>state 파라미터에 상태를 넘겨주면 , 상태를 통해 리스트를 조회하거나 조작할 수 있음</li>
<li>리스트의 스크롤 상태를 읽거나 제어하고 싶을 때 사용</li>
</ul>
<pre><code class="language-kotlin">@Composable
fun MessageList(messages: List&lt;Message&gt;) {
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) { /* ... */ }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                listState.animateScrollToItem(index = 0)  // 부드럽게 상단으로
            }
        }
    )
}</code></pre>
<table>
<thead>
<tr>
<th>API</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>firstVisibleItemIndex</code></td>
<td>현재 화면에 보이는 첫 번째 아이템의 인덱스</td>
</tr>
<tr>
<td><code>firstVisibleItemScrollOffset</code></td>
<td>첫 번째 아이템의 스크롤 오프셋</td>
</tr>
<tr>
<td><code>scrollToItem(index)</code></td>
<td>즉시 해당 위치로 이동</td>
</tr>
<tr>
<td><code>animateScrollToItem(index)</code></td>
<td>애니메이션과 함께 부드럽게 이동 (smooth scroll)</td>
</tr>
</tbody></table>
<ul>
<li><code>scrollToItem()</code>과 <code>animateScrollToItem()</code> 모두 <strong>suspend 함수</strong>이므로 반드시 코루틴 내에서 호출해야 함</li>
<li>코루틴으로 해야하는 이유: 부드럽게 스크롤을 하는 함수인데 시간이 걸리는 작업이라서, 코루틴을 시작하고 그 안에서 호출 가능</li>
<li><code>rememberCoroutineScope()</code> : 해당 컴포저블이 살아있는 동안 사용하는 코루틴</li>
</ul>
<h3 id="derivedstateof-최적화"><code>derivedStateOf</code> 최적화</h3>
<ul>
<li><p>firstVisibleItemIndex는 스크롤하면 계속 바뀌는 값인데, 컴포즈에서 State값이 바뀌면 그걸 읽는 컴포저블은 리컴포지션되어 다시 그러짐</p>
</li>
<li><p>즉 인덱스가 바뀔 때마다 매번 화면을 다시 그림, 결과가 똑같으면 매번 다시 그릴 필요가 없음</p>
</li>
<li><p><code>derivedStateOf</code>: 결과값을 감지해서, 변경이 된다고 하면 그때 리컴포지션이 일어남 </p>
</li>
<li><p>즉 스크롤할때마다 리컴포지션이 발생하지 않고, 값이 실제로 바뀔때만 된다~</p>
</li>
<li><p>스크롤 이벤트는 매우 빈번하게 발생하므로, <code>firstVisibleItemIndex</code>를 읽을 때는 <code>derivedStateOf</code>로 감싸 결과값이 실제로 변경될 때만 Recomposition이 발생하도록 최적화해야 함</p>
</li>
<li><p><code>rememberLazyListState()</code> — LazyListState를 컴포지션에 기억</p>
</li>
<li><p><code>rememberCoroutineScope()</code> — 컴포저블 수명에 묶인 코루틴 스코프</p>
</li>
<li><p><code>derivedStateOf</code> — 다른 State에서 파생된 State, 변화 감지 최적화</p>
</li>
<li><p><code>Arrangement</code> — 아이템 배치 전략 (<code>spacedBy</code>, <code>Center</code>, <code>SpaceBetween</code> 등)</p>
</li>
<li><p><code>PaddingValues</code> — contentPadding에 사용하는 패딩 값 래퍼</p>
</li>
</ul>
<hr>
<h2 id="구현-내용">구현 내용</h2>
<h3 id="화면-구성">화면 구성</h3>
<ul>
<li><strong>홈</strong>: 메인 배너 이미지 + What&#39;s new 가로 스크롤 상품 목록</li>
<li><strong>구매하기</strong>: 탭 바(전체/Tops&amp;T-Shirts/sale) + 2열 상품 그리드, BestSeller 뱃지</li>
<li><strong>위시리스트</strong>: 좋아요한 상품만 필터링해서 표시, 비어있을 때 빈 상태 처리</li>
<li><strong>상품 상세</strong>: 이미지, 상품 정보, 뒤로가기</li>
</ul>
<h3 id="아키텍처">아키텍처</h3>
<ul>
<li><strong>Route/Screen 분리</strong>: <code>Route</code>에서 ViewModel 연결 및 상태 관리, <code>Screen</code>은 UI만 담당</li>
<li><strong>Hilt</strong>: ViewModel 의존성 주입</li>
<li><strong>UiState</strong>: 각 화면마다 sealed class로 상태 관리</li>
</ul>
<h3 id="네비게이션">네비게이션</h3>
<ul>
<li><code>@Serializable</code> data object/class 기반 타입 안전 네비게이션</li>
<li><code>navigation&lt;MainGraph&gt;</code>로 중첩 그래프 구성</li>
<li>하단 탭 바: 현재 route 기반으로 선택 탭 자동 반영</li>
</ul>
<h3 id="기타">기타</h3>
<ul>
<li><strong>Coil</strong>: <code>AsyncImage</code>로 네트워크 이미지 로딩</li>
<li><strong>BackHandler</strong>: 홈 화면에서 2초 내 두 번 뒤로가기 시 앱 종료</li>
<li><strong>Mock 데이터</strong>: 실제 API 연동 전 Mock 데이터로 UI 검증</li>
<li><strong>Desgin System</strong>: 자주 사용하는 Color, text style을 디자인 시스템 패키지로 분리</li>
</ul>
<h2 id="트러블-슈팅">트러블 슈팅</h2>
<h2 id="1--navigation-graph-scope-방식으로-변경해서-viewmodel-공유">1 . Navigation Graph Scope 방식으로 변경해서 ViewModel 공유</h2>
<h4 id="문제">문제</h4>
<p>좋아요 뷰모델을 만들어서 여러 화면에서 사용하려고 했는데, 이러면 Main에 등록해야 하는데, 공식문서를 찾아보니까 좋은 방식이 아니었다. </p>
<ul>
<li><code>MainScreen → AppNavHost → 각 Route</code>로 계속 파라미터 전달해야 함</li>
<li>ViewModel 생명주기가 Activity 전체에 묶임 (앱 켜있는 내내 살아있음)</li>
<li>공식 Android에서 권장하지 않는 방향.. </li>
</ul>
<h4 id="해결">해결</h4>
<p>Navigation Graph Scope 방식으로 변경, <code>hiltViewModel</code>로 같은 NavGraph 안에서 동일한 인스턴스를 공유하도록 해서 좋아요 상태가 공유되도록 설정했다. </p>
<pre><code class="language-kotlin">val parentEntry = remember(backStackEntry) {
    navController.getBackStackEntry&lt;MainGraph&gt;()
}
val favoriteViewModel: FavoriteViewModel = hiltViewModel(parentEntry)</code></pre>
<ul>
<li>좋아요 클릭 -&gt; FavoirteViewModel.toggleLike(id) 호출</li>
<li>Wish 화면에서 likedProductsIds를 받아오므로, 변경 감지해서 해당 상품 표시되도록</li>
<li>같은 그래프 내부이므로 뷰모델이 같은 인스턴스라서 상태가 공유된다.</li>
<li>MainGraph를 벗어나면 ViewModel도 자동 소멸하는 방식이다. </li>
</ul>
<h3 id="2--상세-페이지-진입-시-하단-탭-구매하기-유지">2. ## 상세 페이지 진입 시 하단 탭 구매하기 유지</h3>
<ul>
<li>상품 상세 페이지로 이동하면 하단 탭이 아무것도 선택되지 않은 상태가 되어버린다. </li>
<li><code>this?.hasRoute&lt;PurchaseDetail&gt;() == true -&gt; BottomNavItem.PURCHASE</code> </li>
<li>이렇게 하면 상세보기의 경우에도 구매하기 탭 선택된 상태로 매핑된다. </li>
</ul>
<pre><code class="language-kotlin">this?.hasRoute&lt;PurchaseDetail&gt;() == true -&gt; BottomNavItem.PURCHASE</code></pre>
<h3 id="3-asyncimage-큰-사이즈-적용-안됨">3. AsyncImage 큰 사이즈 적용 안됨</h3>
<p>Box 안에서 이미지를 사용하는 방식으로 구현했는데, 
AsyncImage에서 직접 사이즈 지정했는데 적용이 안되었다. .
Box에서 사이즈 고정, 이미지에서 max로 채우도록 변경했다. </p>
<pre><code class="language-kotlin">Box(  
    modifier = Modifier  
        .size(314.dp)  
        .background(Color(0xFFF5F5F5))  
        .aspectRatio(1f)  
) {  
    AsyncImage(  
        model = item.productImage,  
        contentDescription = item.name,  
        modifier = Modifier.fillMaxSize(),  
        contentScale = ContentScale.Crop  
    )  
}</code></pre>
<h3 id="4-tabbar-언더라인이-화면-전체를-채움---text기준으로-채워지지도-않음">4. TabBar 언더라인이 화면 전체를 채움 /  Text기준으로 채워지지도 않음</h3>
<h4 id="문제-1">문제</h4>
<p>탭을 왼쪽 정렬하려고 <code>weight(1f)</code>를 제거했더니 선택된 탭의 언더라인이 화면 전체 너비를 채워버렸다 ... </p>
<h4 id="원인">원인</h4>
<p><code>weight(1f)</code> 제거 후에  Column에 너비 제약이 없어지면서 내부 <code>fillMaxWidth()</code>가 부모의 max constraint을 그대로 사용해버렸다. </p>
<h3 id="해결-1">해결</h3>
<ul>
<li><code>Row</code>: <code>fillMaxWidth()</code> + <code>weight(1f)</code> 제거 → 탭이 왼쪽부터 붙음, <code>padding(start = 9.dp)</code> 은 유지 </li>
<li><code>Column</code>: <code>Modifier.width(IntrinsicSize.Max)</code>로 Column을 텍스트 너비로 고정해 해결,<br>horizontal 패딩은 Column에서 Text로 이동해 언더라인이 탭 전체 너비를 정확히 채우도록 수정함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] Kotlin 기초 - 변수,타입,연산자]]></title>
            <link>https://velog.io/@jayaione_ele/Kotlin-Kotlin-%EA%B8%B0%EC%B4%88-%EB%B3%80%EC%88%98%ED%83%80%EC%9E%85%EC%97%B0%EC%82%B0%EC%9E%90</link>
            <guid>https://velog.io/@jayaione_ele/Kotlin-Kotlin-%EA%B8%B0%EC%B4%88-%EB%B3%80%EC%88%98%ED%83%80%EC%9E%85%EC%97%B0%EC%82%B0%EC%9E%90</guid>
            <pubDate>Sun, 17 May 2026 17:22:57 GMT</pubDate>
            <description><![CDATA[<p><a href="https://www.inflearn.com/course/java-to-kotlin/dashboard?cid=328606">자바 개발자를 위한 코틀린 입문</a>
해당 강의를 듣고 작성하였다. </p>
<p>안드로이드,스프링에서 코틀린을 많이 사용하게 되었는데, 자바랑 비슷한 것 같다가도 너무 다른 것 같은데 지식 없이 사용하자니 어떤 실수를 하는지도 모르는 것 같아서 빠르게 기초를 공부하고자 한다. </p>
<h3 id="변수">변수</h3>
<ul>
<li><p><code>var</code>, <code>val</code>이 있는데 <code>val</code>을 주로 사용한다. <code>final</code> 같은 느낌이다.</p>
</li>
<li><p>객체는 <code>new</code> 없이 생성 가능하다.</p>
</li>
</ul>
<h3 id="null">Null</h3>
<ul>
<li><p><strong><strong>Safe Call</strong></strong>: <code>null</code>이 아닌 경우에만 호출한다. <code>?.</code>로 호출하면 <code>null</code>이면 <code>null</code>을 반환한다.</p>
</li>
<li><p><strong><strong>Not-null assertion</strong></strong>: <code>null</code>이 절대 아니라면 <code>!!</code>로 단언 가능하다. <code>null</code>이라면 NPE를 던진다.</p>
</li>
<li><p><strong><strong>엘비스 연산자</strong></strong>: <code>?:</code>는 <code>null</code>이라면 이 값으로 정의한다. 즉 <code>null</code>일 때만 호출된다.</p>
</li>
<li><p><strong>**</strong><code>**?.let { }</code>****: <code>null</code>이 아닐 때만 블록을 실행한다.</p>
</li>
<li><p><strong><strong>플랫폼 타입</strong></strong>: 코틀린이 <code>null</code> 관련 정보를 알 수 없는 타입은 exception이 날 수 있다. 자바 코드 사용 시 유의한다.</p>
</li>
<li><p><code>null</code> 검사를 한 번 하면 non-null임을 컴파일러가 알 수 있다.</p>
</li>
</ul>
<h3 id="type">Type</h3>
<p><strong>기본 타입</strong></p>
<ul>
<li><p>기본값을 보고 타입을 추론한다. <code>L</code>이 붙으면 <code>Long</code>, <code>f</code>가 붙으면 <code>Float</code>이다.</p>
</li>
<li><p>코틀린에서 타입 변환은 명시적으로 이루어져야 한다. 자바는 암시적으로 큰 타입으로 자동 변형되지만, 코틀린은 타입이 안 맞으면 에러가 난다. <code>to변환타입()</code>을 반드시 사용한다.</p>
</li>
<li><p>결과를 타입 변환할 때 자바는 괄호를 사용하지만, 코틀린은 <code>결과.to변환타입()</code>으로 한다.</p>
</li>
<li><p>변수가 nullable이면 적절한 처리가 필요하다.</p>
</li>
</ul>
<p><strong>타입 캐스팅</strong></p>
<ul>
<li><p><code>instanceOf</code> 대신 <code>is</code>로 해당 타입인지 <code>true</code>/<code>false</code>를 반환한다.</p>
</li>
<li><p><code>value as Type</code>: 괄호 대신 <code>as</code>로 타입 캐스팅한다. 타입이 아니면 예외를 던진다.</p>
</li>
<li><p><code>value as? Type</code>: <code>value</code>가 해당 타입이 아니거나 <code>null</code>이면 <code>null</code>을 반환한다. 안전한 타입 형변환 방법이다.</p>
</li>
<li><p><strong><strong>스마트 캐스트</strong></strong>: <code>is</code>를 통과했다면 <code>as</code>를 생략하고 바로 해당 타입으로 써도 된다. 컴파일러가 이를 기억하기 때문이다.</p>
</li>
<li><p><code>!is</code>는 <code>instanceOf</code>의 반대이다.</p>
</li>
</ul>
<p><strong>코틀린의 특이한 타입</strong></p>
<ul>
<li><code>Any</code></li>
</ul>
<p>    - Java의 <code>Object</code> 역할로, 모든 객체와 모든 원시타입의 최상위 타입이다.</p>
<p>    - <code>equals</code>, <code>hashCode</code>, <code>toString</code>이 존재한다.</p>
<p>    - <code>Any</code> 자체는 <code>null</code>을 포함하지 못한다. 포함하려면 <code>Any?</code>로 쓴다.</p>
<ul>
<li><code>Unit</code></li>
</ul>
<p>    - Java의 <code>void</code>와 동일한 역할을 한다.</p>
<p>    - <code>Unit</code>은 그 자체로 타입 인자로 사용 가능하다. 제네릭 <code>void</code>를 하려면 <code>Void</code>를 써야 하는데, <code>Unit</code>은 그냥 쓰면 된다.</p>
<p>    - 코틀린의 <code>Unit</code>은 실제로 존재하는 타입이다.</p>
<ul>
<li><code>Nothing</code></li>
</ul>
<p>    - 함수가 정상적으로 끝나지 않았다는 사실을 표현하는 역할을 한다.</p>
<p>    - 무조건 예외 반환하거나 무한 루프인 함수에 사용한다.</p>
<p>    - 잘 사용하지 않는다.</p>
<p><strong>String interpolation / String indexing</strong></p>
<ul>
<li><p><code>${변수}</code>를 사용하면 값이 들어간다.</p>
</li>
<li><p>자바에서는 <code>String.format</code>으로 문자열 포맷을 했어야 했다.</p>
</li>
<li><p>단일 변수라면 중괄호를 생략할 수도 있다.</p>
</li>
<li><p>그런데 변수 이름만 사용하더라도 <code>${변수}</code>로 쓰는 것이 가독성, 정규식 활용, 통일성 측면에서 좋다.</p>
</li>
<li><p>여러 줄에 걸친 문자열 작성 시 <code>&quot;&quot;&quot; &quot;&quot;&quot;.trimIndent()</code>로 한다. 자바에서는 <code>StringBuilder</code>의 <code>append</code>를 써야 했다.</p>
</li>
<li><p>문자열에서 특정 문자 가져오기: 자바에서는 <code>charAt</code>을 사용했지만, 코틀린에서는 배열처럼 <code>str[인덱스]</code>로 가져온다.</p>
</li>
</ul>
<h3 id="연산자">연산자</h3>
<ul>
<li><p>**단항, 산술 연산자: 자바와 완전히 동일하다.</p>
</li>
<li><p><strong>비교 연산자와 동등성, 동일성</strong></p>
</li>
</ul>
<p>    - 비교 연산자는 사용법이 완전히 동일하다.</p>
<p>    - 객체에 비교 연산자를 사용하면 <code>compareTo</code>를 자동으로 호출한다. 자바는 무조건 <code>compareTo</code>를 직접 사용했어야 했다.</p>
<p>    - <strong><strong>동등성</strong></strong>: 두 객체의 값이 같은지를 본다.</p>
<p>    - <strong><strong>동일성</strong></strong>: 객체가 동일한지, 즉 주소가 같은지를 본다.</p>
<p>    - 코틀린에서는 동일성에 <code>===</code>, 동등성에 <code>==</code>를 사용한다. <code>==</code> 호출 시 간접적으로 <code>equals</code>를 호출해준다.</p>
<p>    - <code>==</code>은 값 비교, <code>===</code>은 주소까지 같은지를 본다.</p>
<ul>
<li><p><strong><strong>논리 연산자</strong></strong>: 자바와 동일하게 lazy 연산을 한다. <code>or</code> 연산 시 앞 내용이 <code>true</code>면 뒤를 수행하지 않고 바로 본문으로 이동하고, <code>and</code> 연산 시 앞 내용이 <code>false</code>면 수행하지 않고 바로 이동한다.</p>
</li>
<li><p><strong><strong>코틀린의 특이한 연산자</strong></strong></p>
</li>
</ul>
<p>    - <code>in</code> / <code>!in</code>: 컬렉션이나 범위에 포함되어 있는지/아닌지를 본다.</p>
<p>    - <code>a..b</code>: <code>a</code>부터 <code>b</code>까지의 범위 객체를 생성한다.</p>
<p>    - <code>a[i]</code>: 특정 인덱스 <code>i</code>로 값을 가져온다.</p>
<p>    - <code>a[i] = b</code>: <code>i</code>에 <code>b</code>를 넣는다.</p>
<ul>
<li><strong><strong>연산자 오버로딩</strong></strong></li>
</ul>
<p>    - 코틀린에서는 객체마다 연산자를 직접 정의할 수 있다.</p>
<p>    - 자바에서는 객체 안 요소에서 연산자를 쓰려면 함수를 만들어야 했다. 코틀린에서도 함수를 정의하면 되지만, 한 번 정의하면 해당 연산자를 그대로 사용 가능하다. 대신 내부적으로 해당 연산의 함수가 호출되며, 연산마다 정해진 이름의 함수여야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring Batch로 OOM 방지하기]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-Spring-Batch%EB%A1%9C-OOM-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Spring-Spring-Batch%EB%A1%9C-OOM-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 16 May 2026 19:05:55 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-batch---대량-데이터-처리와-report-생성-배치-도입">Spring Batch - 대량 데이터 처리와 Report 생성 배치 도입</h1>
<p>매월 1일에 생성되는 <code>Report</code>에서 OOM 원인을 분석하고, 스프링 배치 도입을 고려한다.
OOM은 out of Memory로, 힙 메모리를 더 이상 확보하지 못해서 터지는 상황이다. 
다음 코드에서 예상 원인의 흐름을 찾아보면 다음과 같다. </p>
<pre><code class="language-java">// 유저의 Report를 자동 생성
@Override
@Transactional
public void generateMonthlyReportForAllUsers() {
    YearMonth prevMonth = YearMonth.now().minusMonths(1);
    String month = prevMonth.toString();
    String thumbnailUrl = thumbnailUrlProvider.getUrlForMonth(&quot;report&quot;, month);
// findAll()로 유저를 한 번에 다 메모리에 올린다
    List&lt;Users&gt; users = userRepository.findAll();
    for (Users user : users) {
        if (reportRepository.existsByUserAndMonth(user, month)) continue;
        // 하나의 긴 트랜잭션 안에서 Report를 계속 생성한다
        Report report = Report.builder()
                .user(user)
                .month(month)
                .thumbnailUrl(thumbnailUrl)
                .build();
        // 영속성 컨텍스트 1차 캐시에 엔티티가 계속 쌓인다
        reportRepository.save(report);
        reportTopLogService.calculateAndSaveTopLogs(user.getId(), report);

        // 커밋 이후에 외부 추천 비동기 실행 (레이스 방지)
        Long rid = report.getReportId();
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                externalRecommendMaterializer.generateAndStoreExternalAsync(rid);
            }
        });
    }
}</code></pre>
<ul>
<li>findAll()로 유저를 한 번에 다 메모리에 올린다</li>
<li>하나의 긴 트랜잭션 안에서 Report를 계속 생성한다</li>
<li>영속성 컨텍스트 1차 캐시에 엔티티가 계속 쌓인다</li>
<li>for 루프가 길어질수록 메모리 사용량이 커진다</li>
<li>결국 힙을 버티지 못하면 OOM이 난다</li>
</ul>
<h2 id="해결방안-분석하기">해결방안 분석하기</h2>
<p>하나의 트랜잭션으로 묶여 있는 로직 안에서 <code>findAll</code>로 유저를 한 번에 다 가지고 오고 있다.
그리고 그 안에서 <code>Report</code> 존재 여부 조회, <code>Report</code> 저장, <code>Report</code> 계산까지 하나에서 다 하고 있다.
이걸 하나의 트랜잭션에서 다 하게 되면 1차 캐시에 <code>Report</code> 엔티티가 다 누적되어 메모리를 잡아먹을 가능성이 높다.</p>
<p>해결 방안은 다음과 같다.</p>
<ul>
<li>유저별로 별도의 트랜잭션으로 분리한다.</li>
<li>페이징을 해서 유저를 가져온다. 한 번에 <code>findAll</code>로 가져오면 모든 유저가 한 번에 메모리에 올라가게 된다.</li>
<li><code>afterCommit</code> 방식이 너무 옛날 방식이라서 listener 방식으로 구현한다. listener를 생성하고, 외부 추천은 비동기로 <code>Report</code> 저장이 제대로 되었을 때 진행한다.</li>
</ul>
<p>그런데 Spring Batch가 이 모든 것을 해결해 준다.</p>
<pre><code class="language-text">Job
 └─ Step: generateReportStep
       Reader: JpaPagingItemReader&lt;Users&gt;     (페이징 자동)
       Processor: User → Report               (existsByUserAndMonth 체크)
       Writer: chunk 단위 저장 + 이벤트 발행</code></pre>
<h2 id="spring-batch">Spring Batch</h2>
<p>대량 데이터를 끊어서 안전하게 처리하는 프레임워크다.</p>
<ul>
<li>중간에 실패하게 되면, <code>JobRepository</code> 메타테이블에 자동 기록된다.</li>
<li>일반적으로 운영 환경에서 이 테이블을 그라파나 대시보드에 연결하고, 실패 시 알림을 전송한다.</li>
</ul>
<h3 id="기본-개념-간단-정리">기본 개념 간단 정리</h3>
<p><code>Job</code>: 배치 실행 단위<br><code>Step</code>: <code>Job</code>을 구성하는 단계<br><code>chunk</code>: 읽기, 처리, 저장 후 커밋하는 묶음 단위<br><code>reader</code>: 데이터를 읽는 컴포넌트<br><code>processor</code>: 읽은 데이터를 가공하거나 필터링하는 컴포넌트<br><code>writer</code>: 처리 결과를 저장하는 컴포넌트</p>
<table>
<thead>
<tr>
<th>테이블</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>BATCH_JOB_INSTANCE</code></td>
<td>&quot;월간 Report + 2026년 4월&quot; 같은 논리적 실행 단위</td>
</tr>
<tr>
<td><code>BATCH_JOB_EXECUTION</code></td>
<td>실제 실행 시도. 재시작하면 새 row가 추가된다.</td>
</tr>
<tr>
<td><code>BATCH_JOB_EXECUTION_PARAMS</code></td>
<td><code>Job</code> 실행 시 넘긴 파라미터</td>
</tr>
<tr>
<td><code>BATCH_STEP_EXECUTION</code></td>
<td><code>Step</code>별 처리 건수, 성공, 실패</td>
</tr>
<tr>
<td><code>BATCH_JOB_EXECUTION_CONTEXT</code></td>
<td><code>Job</code> 단위 임시 저장소</td>
</tr>
<tr>
<td><code>BATCH_STEP_EXECUTION_CONTEXT</code></td>
<td><code>Step</code> 단위 임시 저장소. 어디까지 읽었는지 등을 저장한다.</td>
</tr>
</tbody></table>
<h3 id="chunk-size-튜닝-방법론">Chunk size 튜닝 방법론</h3>
<ul>
<li>기본은 동기 방식으로 청크 1, 2, 3 이런 식의 단일 스레드 순차 방식이다.</li>
<li>청크는 적절히 튜닝하는 게 중요하다.</li>
<li>청크가 너무 작으면 청크마다 커밋 오버헤드가 발생하고 DB 왕복이 많아진다.</li>
<li>청크가 너무 크면 한 청크의 메모리 점유가 커지고, 실패 시 재처리 비용도 커진다.</li>
</ul>
<p>관련 튜닝 방법들은 다음과 같다.</p>
<ul>
<li><code>메모리</code>: <code>chunk</code> 안의 아이템이 차지하는 메모리 * <code>chunk size</code> * 스레드 수 &lt; 가용 힙</li>
<li><code>트랜잭션 시간</code>: <code>chunk</code> 처리 시간이 DB 트랜잭션 타임아웃보다 짧아야 한다.</li>
<li><code>재처리 비용</code>: 실패 시 <code>chunk</code> 통째로 다시 처리하므로 너무 크면 손해다.</li>
<li><code>JDBC batch size</code>: <code>hibernate.jdbc.batch_size</code>와 <code>chunk size</code>를 맞춰야 효과가 있다. 둘이 다르면 batch insert가 안 된다.</li>
<li><code>외부 시스템 한도</code>: FCM처럼 한 호출에 <code>N</code>개 제한이 있으면 그게 상한이다.</li>
</ul>
<h3 id="reader-캐시-청크에-모이는-것의-차이">Reader 캐시, 청크에 모이는 것의 차이</h3>
<ul>
<li>Reader의 내부 캐시에서는 <code>JpaPagingItemReader</code>가 효율을 위해 내부적으로 페이지 단위로 미리 가져온다.</li>
<li>Reader를 호출할 때는 한 개씩 받는 것처럼 보이고, 처음 호출할 때만 한 번씩 가져오고, 필요 시 1개씩 캐시에서 꺼내서 반환한다.</li>
<li>100개까지 Reader 캐싱되도록 설정해놨다고 치면, 101번째에서는 100개씩 가져오는 쿼리를 실행한다.</li>
<li>Reader 페이지 캐시는 Reader 내부 최적화 방식이고, DB 쿼리를 줄이기 위함이다.</li>
</ul>
<pre><code class="language-text">pageSize = 100 설정
  ↓
read() 첫 호출 시 → DB에 &quot;SELECT ... LIMIT 100 OFFSET 0&quot; 쿼리 1번
  → 결과 100개를 Reader 내부 List에 저장 (이게 캐시)
  → 그 중 1개 반환

read() 2번째 호출 → 캐시에서 꺼냄 (쿼리 안 함)
read() 3번째 호출 → 캐시에서 꺼냄
...
read() 101번째 호출 → 캐시 다 떨어짐 → &quot;SELECT ... LIMIT 100 OFFSET 100&quot; 쿼리</code></pre>
<ul>
<li>청크는 <code>Step</code>에서의 처리 단위이고, Writer가 한 번에 받는 묶음이며, 트랜잭션 단위다.</li>
<li>청크와 Reader의 <code>pageSize</code>는 다른 개념이고 보통 같게 맞추는 편이다.</li>
</ul>
<h3 id="processor">Processor</h3>
<ul>
<li>Processor에서는 Reader가 읽어준 것을 입력으로 받아서 Writer로 넘긴다.</li>
<li>즉 유저 조회, 유저와 관련된 <code>Report</code> 저장이 있다고 하면, 유저 조회 결과를 받아서 만들지 말지를 판단한다.</li>
<li>조금 더 효율적인 조회가 필요한데, 현재 로직은 유저 조회 → 존재 여부 조회 → 생성 순서로 되어 있다. 이렇게 하면 존재 여부 조회에서 <code>N</code>번 쿼리가 실행되기 때문에, 유저를 조회할 때 존재 여부까지 같이 조회해서 중복되지 않게 <code>Set</code>을 들고 있게 한다.</li>
<li>즉 프로세서의 역할은 &quot;만들지 말지&quot; 결정이다. 존재 여부까지 프로세서가 실행하게 된다면 비효율적이기 때문에, Reader에서 이를 같이 처리하도록 한다.</li>
</ul>
<pre><code class="language-java">ItemProcessor&lt;Users, Report&gt; processor = user -&gt; {
    // input: Reader가 읽어준 User 한 명

    // 처리 로직
    if (reportRepository.existsByUserAndMonth(user, month)) {
        return null;  // null 반환 = 이 아이템 skip
    }

    Report report = Report.builder()
            .user(user)
            .month(month)
            .thumbnailUrl(thumbnailUrl)
            .build();

    // output: Writer에 넘길 Report 한 개
    return report;
};</code></pre>
<h3 id="writer">Writer</h3>
<ul>
<li>청크 단위로 묶어서 동작한다.</li>
<li><code>JpaItemWriter</code>에서는 내부적으로 100개를 영속화만 한다.</li>
</ul>
<pre><code class="language-java">ItemWriter&lt;Report&gt; writer = chunk -&gt; {
    // chunk = Report 100개가 담긴 묶음
    // 100개를 한 번에 저장
    for (Report report : chunk) {
        entityManager.persist(report);  // 영속화만 함, 아직 DB 안 감
    }
    entityManager.flush();  // 그제서야 한 번에 DB로
};</code></pre>
<h3 id="단일-vs-멀티-차이">단일 vs 멀티 차이</h3>
<p>단일과 멀티 방식의 차이는 <code>taskExecutor</code>를 주입한다는 것이다.
기본은 단일 스레드로, 청크를 하나씩 차례대로 처리한다.</p>
<p>대부분의 경우 <code>단일 스레드 + chunk size 튜닝</code>으로 충분하다.
멀티스레드는 정말 처리량이 부족할 때만 사용한다.</p>
<ul>
<li><code>chunk</code>를 여러 스레드가 병렬로 처리한다.</li>
<li>Reader는 thread-safe해야 한다.</li>
<li><code>JpaPagingItemReader</code>는 thread-safe가 아니므로, <code>SynchronizedItemStreamReader</code>로 감싸야 한다.</li>
</ul>
<pre><code class="language-java">// 단일 (기본)
return new StepBuilder(&quot;step&quot;, jobRepository)
        .&lt;Users, Report&gt;chunk(100, txManager)
        .reader(reader)
        .processor(processor)
        .writer(writer)
        .build();

// 멀티 (taskExecutor 추가만)
return new StepBuilder(&quot;step&quot;, jobRepository)
        .&lt;Users, Report&gt;chunk(100, txManager)
        .reader(reader)
        .processor(processor)
        .writer(writer)
        .taskExecutor(taskExecutor)     // taskExecutor를 주입받음
        .build();</code></pre>
<h3 id="faulttolerant에-대해서">FaultTolerant에 대해서</h3>
<p>종류는 다음과 같다.</p>
<ul>
<li><code>retry</code>: 같은 아이템 다시 시도한다. 3번까지 같은 방식으로 일시적 네트워크 오류에 유효하다.</li>
<li><code>skip</code>: 그 아이템만 건너뛰고 계속 진행한다. 100건까지 같은 방식으로 잘못된 데이터에 유효하다.</li>
<li><code>fail</code>: 즉시 <code>Job</code> 실패다.</li>
</ul>
<p>이걸 적용해보면 다음과 같다.
알림 전송 배치 작업 로직이다.</p>
<pre><code class="language-java">return new StepBuilder(&quot;sendNotificationStep&quot;, jobRepository)
        .&lt;Report, Report&gt;chunk(100, txManager)
        .reader(reportReader)
        .writer(fcmWriter)
        .faultTolerant()
        .retry(FcmServerException.class).retryLimit(3)       // FCM 5xx → 3번 재시도
        .skip(InvalidTokenException.class).skipLimit(1000)   // 만료 토큰 → 1000건까지 skip
        .build();</code></pre>
<p>동작은 다음과 같다.</p>
<ul>
<li><code>chunk</code> 처리 중 <code>FcmServerException</code>이 발생하면 같은 <code>chunk</code>를 다시 시도한다. 최대 3번이다.</li>
<li>3번 다 실패하면 <code>skip</code> 정책을 확인한다.</li>
<li><code>InvalidTokenException</code>이 발생하면 그 아이템을 <code>skip</code>하고 카운터를 1 증가시킨다.</li>
<li><code>skip</code> 카운터가 1000을 넘으면 <code>Step</code>이 실패한다.</li>
<li>그 외 예외는 즉시 실패한다.</li>
</ul>
<h3 id="tasklet">Tasklet</h3>
<p>단일 작업을 수행하는 <code>Step</code>의 다른 형태다.
배치에서 일반적인 Reader-Processor-Writer 사이클 없이, 메서드를 하나 실행하고 끝이다.</p>
<pre><code class="language-java">@Bean
public Tasklet cleanupTasklet() {
    return (contribution, chunkContext) -&gt; {
        log.info(&quot;임시 파일 정리 시작&quot;);
        fileService.cleanupTempFiles();
        log.info(&quot;완료&quot;);
        return RepeatStatus.FINISHED;
    };
}

@Bean
public Step cleanupStep(Tasklet cleanupTasklet) {
    return new StepBuilder(&quot;cleanupStep&quot;, jobRepository)
            .tasklet(cleanupTasklet, txManager)
            .build();
}</code></pre>
<p>용도는 다음과 같다.</p>
<ul>
<li>작업 시작 전 준비: 임시 테이블 <code>truncate</code>, 디렉토리 생성</li>
<li>작업 끝난 후 정리: 임시 파일 삭제, 캐시 무효화</li>
<li>단일 외부 호출: 이번 달 환율 한 번 가져와서 저장</li>
<li>알림 1회: <code>Job</code> 완료됐다고 Slack에 한 번 메시지</li>
</ul>
<p>다음에는 직접 만들어보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DB] 심심해서 하는 The SQL Murder Mystery 풀이 ]]></title>
            <link>https://velog.io/@jayaione_ele/DB-%EC%8B%AC%EC%8B%AC%ED%95%B4%EC%84%9C-%ED%95%98%EB%8A%94-The-SQL-Murder-Mystery-%ED%92%80%EC%9D%B4</link>
            <guid>https://velog.io/@jayaione_ele/DB-%EC%8B%AC%EC%8B%AC%ED%95%B4%EC%84%9C-%ED%95%98%EB%8A%94-The-SQL-Murder-Mystery-%ED%92%80%EC%9D%B4</guid>
            <pubDate>Sat, 16 May 2026 18:18:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/aa02ee3a-f6f6-4118-8178-d5d95b7d44a5/image.png" alt=""></p>
<p><a href="https://mystery.knightlab.com/">문제 사이트</a></p>
<p>SQL 게임이 있다고 해서, 특히 추리 게임이라는 게 흥미로워서 풀어봤다. 
SQLite 기반인데, 자주 사용한 MySQL이랑 유사해서 금방 풀었다. </p>
<p>전체 ERD가 주어지고, 그걸 보고 알아서 sql문을 작성해서 solution 테이블에 insert하고 최종 확인하는 그런 방식이다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/96517ba0-02c8-4a84-b3da-2ef22a9bfce6/image.png" alt=""></p>
<p>ERD는 다음과 같이 주어진다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/08889654-cf42-49b2-b607-e7e49ffb643e/image.png" alt=""></p>
<h2 id="사건-개요">사건 개요</h2>
<p>2018년 1월 15일, <strong>SQL City</strong>에서 살인 사건이 발생했다. 단서는 한 줄도 주어지지 않고, 오직 SQL 쿼리만으로 범인을 추적해야 한다.</p>
<h2 id="step-1-범죄-현장-보고서-조회">Step 1. 범죄 현장 보고서 조회</h2>
<p>처음에 주어진게 날짜와 장소밖에 없어서, 먼저 리포트를 조회해보기로 했다. </p>
<p>먼저 <code>crime_scene_report</code> 테이블에서 사건 당일, 해당 도시의 기록을 조회했다.</p>
<pre><code class="language-sql">SELECT description 
FROM crime_scene_report
WHERE city LIKE &#39;%SQL City%&#39;
  AND date = 20180115;</code></pre>
<p>여기서 사건 유형(murder)과 목격자 단서를 확인했다.</p>
<p>CCTV 분석 결과 목격자는 두 명:</p>
<ul>
<li><strong>첫 번째 목격자</strong>: <code>Northwestern Dr</code>의 마지막 집 거주자</li>
<li><strong>두 번째 목격자</strong>: <code>Franklin Ave</code>에 사는 <strong>Annabel</strong></li>
</ul>
<h2 id="step-2-annabel의-진술-확보">Step 2. Annabel의 진술 확보</h2>
<p>이름 단서가 있는 Annabel부터. <code>person</code> 테이블에서 ID를 찾고 <code>interview</code> 테이블과 조인했다.</p>
<pre><code class="language-sql">SELECT it.transcript
FROM interview it
JOIN person p ON p.id = it.person_id
WHERE p.name LIKE &#39;%Annabel%&#39;;</code></pre>
<p><strong>진술:</strong></p>
<blockquote>
<p>I saw the murder happen, and I recognized the killer from my gym when I was working out last week on January the 9th.
<em>(살인이 일어나는 걸 봤고, 지난주 1월 9일에 운동하던 헬스장에서 범인을 알아봤어요.)</em></p>
</blockquote>
<p>→ 범인은 <strong>Get Fit Now Gym</strong> 회원이며, <strong>1월 9일</strong>에 헬스장에 있었다. </p>
<h2 id="step-3-첫-번째-목격자의-진술">Step 3. 첫 번째 목격자의 진술</h2>
<p>Northwestern Dr의 마지막 집 거주자도 같은 방식으로 인터뷰를 조회했다.</p>
<p><strong>진술:</strong></p>
<blockquote>
<p>총소리가 들린 후 한 남자가 뛰쳐나오는 것을 봤다. <strong>&quot;Get Fit Now Gym&quot;</strong> 가방을 들고 있었고, 가방의 회원 번호는 <strong>&quot;48Z&quot;</strong>로 시작했다. 골드 회원만 그 가방을 사용한다. 그 남자는 번호판에 <strong>&quot;H42W&quot;</strong>가 포함된 차에 탔다.</p>
</blockquote>
<p>이제 결정적인 단서가 모두 모였다:</p>
<table>
<thead>
<tr>
<th>단서</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>회원권 등급</td>
<td>gold</td>
</tr>
<tr>
<td>회원 번호 prefix</td>
<td><code>48Z</code></td>
</tr>
<tr>
<td>차량 번호판 포함</td>
<td><code>H42W</code></td>
</tr>
<tr>
<td>헬스장 방문일</td>
<td>2018-01-09</td>
</tr>
</tbody></table>
<h2 id="step-4-범인-특정">Step 4. 범인 특정</h2>
<p>세 테이블을 조인해서 조건을 만족하는 사람을 찾았다.</p>
<pre><code class="language-sql">SELECT p.id, p.name
FROM get_fit_now_member gfm
JOIN person p ON gfm.person_id = p.id
JOIN drivers_license d ON p.license_id = d.id
WHERE d.plate_number LIKE &#39;%H42W%&#39;
  AND gfm.membership_status = &#39;gold&#39;;</code></pre>
<p><code>get_fit_now_member</code> → <code>person</code> → <code>drivers_license</code>로 이어지는 조인 한 번으로 범인이 특정됐다.</p>
<blockquote>
<p>더 엄밀하게 가려면 <code>gfm.id LIKE &#39;48Z%&#39;</code> 조건과 <code>get_fit_now_check_in</code> 테이블에서 <code>check_in_date = 20180109</code> 까지 걸어주면 좋다. 단서를 다 활용해야 운에 의존하지 않는다. 그렇지만 이렇게 하고 풀기는 했다.. ^^ </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/a5578b47-537e-4899-bcae-3f08b7026a3c/image.png" alt=""></p>
<p>이렇게 해서 범인 이름이 나왔다!</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/b1c8b957-1ab6-4e63-be91-1202e1a37b20/image.png" alt=""></p>
<p>음.. 그런데 풀고 나니까 배후가 있단다. </p>
<blockquote>
<p>축하합니다, 범인을 찾았네요! 하지만 끝이 아닙니다... 도전할 준비가 됐다면, 범인의 인터뷰 기록을 조회해서 이 범죄의 진짜 흑막을 찾아보세요. SQL 실력에 자신 있다면, 이 마지막 단계를 쿼리 2개 이내로 완료해보세요. 새 용의자로 동일한 INSERT 문을 사용해 정답을 확인할 수 있습니다.</p>
</blockquote>
<h2 id="step-5-사건의-전말-찾기">Step 5: 사건의 전말 찾기</h2>
<p>일단 범인이 배후를 불었을 테니? 인터뷰 내용을 찾아봤다. </p>
<pre><code class="language-sql">SELECT transcript
FROM interview
WHERE person_id = 67318;</code></pre>
<blockquote>
<p>돈 많은 여자한테 고용됐다. 이름은 모르지만 키는 약 5&#39;5&quot;(65인치) ~ 5&#39;7&quot;(67인치), 빨간 머리, 테슬라 모델 S를 몬다. 그 여자가 2017년 12월에 SQL Symphony Concert에 3번 참석했다는 건 안다.</p>
</blockquote>
<p>진짜 배후를 특정할 단서는 다음과 같다. </p>
<table>
<thead>
<tr>
<th>속성</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>성별</td>
<td>female</td>
</tr>
<tr>
<td>키</td>
<td>65 ~ 67 inch</td>
</tr>
<tr>
<td>머리색</td>
<td>red</td>
</tr>
<tr>
<td>차량</td>
<td>Tesla Model S</td>
</tr>
<tr>
<td>참석 이벤트</td>
<td>SQL Symphony Concert</td>
</tr>
<tr>
<td>참석 시기</td>
<td>2017년 12월, 총 3회</td>
</tr>
</tbody></table>
<p>쿼리 1개로 해결하는 방법이 있다. </p>
<ul>
<li>외형 단서(키, 머리색, 성별, 차량)는 person + drivers_license 조인으로 거른다.</li>
<li>콘서트 단서는 facebook_event_checkin에서 event_name = &#39;SQL Symphony Concert&#39;이고 date가 2017년 12월(20171201 ~ 20171231)인 row를 person_id로 GROUP BY해서 COUNT(*) = 3인 사람을 찾는다.</li>
<li>수익은 income을 묶어서 order by로 높은 순서로 정렬한다. 돈이 많은 여자이기 때문이다.!</li>
<li>income은 없는 사람도 있어서, 아니면 진범이 수익 테이블에 안나와 있을 수도 있어서 left 조인으로 했다. 없으면 null로 뜬다. </li>
</ul>
<pre><code class="language-sql">SELECT p.id, p.name, i.annual_income
FROM person p
JOIN drivers_license d ON p.license_id = d.id
JOIN facebook_event_checkin fec ON fec.person_id = p.id
LEFT JOIN income i ON i.ssn = p.ssn
WHERE d.gender = &#39;female&#39;
  AND d.hair_color = &#39;red&#39;
  AND d.car_make = &#39;Tesla&#39;
  AND d.car_model = &#39;Model S&#39;
  AND d.height BETWEEN 65 AND 67
  AND fec.event_name = &#39;SQL Symphony Concert&#39;
  AND fec.date BETWEEN 20171201 AND 20171231
GROUP BY p.id, p.name, i.annual_income
HAVING COUNT(*) = 3
ORDER BY i.annual_income DESC;</code></pre>
<p>이렇게 최종 SQL문을 입력했더니 범인이 딱 나왔다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/75eefef3-1e79-4228-a3c3-832320f2600f/image.png" alt=""></p>
<p>흑막까지 맞추니까 축하해줬다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/4c1096d1-8c9d-470e-adf2-c83cf9af73a4/image.png" alt=""></p>
<h2 id="배운-점--후기">배운 점 , 후기</h2>
<ul>
<li><strong><code>LIKE &#39;%...%&#39;</code>는 텍스트 단서를 다룰 때 강력하다.</strong> 부분 일치 하나로 후보를 빠르게 좁힐 수 있다.</li>
<li><strong>도메인 모델이 잘 잡혀 있으면 복잡한 조사도 결국 JOIN 몇 번이다.</strong> <code>person ↔ membership ↔ license</code>처럼 식별자 중심으로 연결돼 있으면 추적 경로가 한눈에 보인다.</li>
<li>음.. SQL 코테 보는 것 같기도 하고, 재밌었다 .. ^^</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] DTO 구현하기 Java to Kotlin]]></title>
            <link>https://velog.io/@jayaione_ele/Kotlin-DTO-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Java-to-Kotlin-2lc3splg</link>
            <guid>https://velog.io/@jayaione_ele/Kotlin-DTO-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Java-to-Kotlin-2lc3splg</guid>
            <pubDate>Sat, 25 Apr 2026 15:42:44 GMT</pubDate>
            <description><![CDATA[<p>Kotlin으로의 전환을 연구하면서, DTO는 어떻게 구현해야하는지 생각해보게 됐다. </p>
<p>자바 코드 
다음과 같은 자바 코드를 Kotlin으로 전환해 볼 것이다. </p>
<pre><code class="language-java">@Getter
public class CursorResponse&lt;T, C&gt; {

    private final List&lt;T&gt; items;
    private final C nextCursor;
    private final boolean hasNext;

    public CursorResponse(
            List&lt;T&gt; items,
            C nextCursor,
            boolean hasNext
    ) {
        this.items = items;
        this.nextCursor = nextCursor;
        this.hasNext = hasNext;
    }

    public static &lt;T, C&gt; CursorResponse&lt;T, C&gt; of(
            List&lt;T&gt; items,
            C nextCursor,
            boolean hasNext
    ) {
        return new CursorResponse&lt;&gt;(items, nextCursor, hasNext);
    }
}</code></pre>
<h3 id="방법1--일반-클래스로-변환">방법1 : 일반 클래스로 변환</h3>
<pre><code class="language-kotlin">class CursorResponse&lt;T, C&gt;(
    val items: List&lt;T&gt;,
    val nextCursor: C,
    val hasNext: Boolean,
) {
    companion object {
        fun &lt;T, C&gt; of(
            items: List&lt;T&gt;,
            nextCursor: C,
            hasNext: Boolean,
        ): CursorResponse&lt;T, C&gt; {
            return CursorResponse(items, nextCursor, hasNext)
        }
    }
}</code></pre>
<h4 id="val과-var">val과 var</h4>
<p>private final 필드와 public getter, 생성자 할당을 합친 게 val이다. 
var로 선언하게 되면 private 필드, public getter/setter 이렇게 생성된다. </p>
<h4 id="companion-object">companion object</h4>
<p>자바의 public static class 대용으로 사용한다고 보면 된다. 클래스 안에 companion object를 두고, 그 안에 메서드를 구현한다. 
호출 방식은 자바 static 메서드와 똑같다. </p>
<p>이 방식도 돌아가기는 하지만 코틀린스럽지는 않다. 코틀린 장점을 잘 못 살렸다고 볼 수 있는 코드다. </p>
<h3 id="방법2-데이터-클래스로-변환">방법2: 데이터 클래스로 변환</h3>
<p>자바에서는 record 클래스 방식이랑 유사하다. 단순 데이터를 담는 Response 이므로 data class가 조금 더 적합하다고 판단했다. </p>
<pre><code class="language-kotlin">data class CursorResponse&lt;T, C&gt;(
    val items: List&lt;T&gt;,
    val nextCursor: C? = null,
    val hasNext: Boolean = nextCursor != null,
) {
    companion object {
        /**
         * size + 1 만큼 조회된 결과로부터 다음 커서를 추출하여 응답을 만든다.
         * 마지막 항목이 cutoff 역할
         */
        fun &lt;T, C&gt; from(
            items: List&lt;T&gt;,
            pageSize: Int,
            cursorExtractor: (T) -&gt; C,
        ): CursorResponse&lt;T, C&gt; {
            val hasNext = items.size &gt; pageSize
            val pagedItems = if (hasNext) items.take(pageSize) else items
            val nextCursor = if (hasNext) cursorExtractor(pagedItems.last()) else null
            return CursorResponse(pagedItems, nextCursor, hasNext)
        }
    }
}</code></pre>
<p>data class를 생성하면 컴파일러가 자동으로 만들어준다. </p>
<ul>
<li><code>equals()</code> / <code>hashCode()</code> : 필드 기반 비교</li>
<li><code>toString()</code> — <code>CursorResponse(items=[...], nextCursor=..., hasNext=true)</code></li>
<li><code>copy()</code> : 일부 필드만 바꿔서 복사 (<code>response.copy(hasNext = false)</code>)</li>
<li><code>componentN()</code> :   구조 분해 (<code>val (items, cursor, hasNext) = response</code>)</li>
</ul>
<p>java의 Lombok <code>@Data</code>와 비슷한 역할이다. </p>
<h3 id="정적-팩터리-메서드-생성-불필요">정적 팩터리 메서드 생성 불필요</h3>
<p>자바에서는 of, create와 같은 정적 팩터리 메서드를 생성하는 방식을 주로 사용한다. new 키워드로 생성하지 않고 기본값을 고정해두기 위해서다. 그런데 코틀린에서는 이게 불필요해서 제거해도 된다. </p>
<p>왜냐.. 코틀린에서는 new 키워드가 없다.
그래서 생성자 호출 자체가 깔끔해지고, 제네릭 추론 자체가 자바보다 더 유연하다. 람다 안에서 하더라도 명시를 딱히 안해줘도 되고, 양방향 추론도 된다. 자바는 거꾸로 쓰면 안된다 ...</p>
<p>코틀린</p>
<pre><code class="language-kotlin">val list = listOf(1, 2, 3)              // List&lt;Int&gt;로 추론
val map = mapOf(&quot;a&quot; to 1, &quot;b&quot; to 2)     // Map&lt;String, Int&gt;로 추론
val pair = &quot;key&quot; to 42                  // Pair&lt;String, Int&gt;로 추론</code></pre>
<p>자바
자바는 List, Map 선언 시 타입 명시가 강제된다. </p>
<pre><code class="language-java">List&lt;Integer&gt; list = List.of(1, 2, 3);
Map&lt;String, Integer&gt; map = Map.of(&quot;a&quot;, 1, &quot;b&quot;, 2);</code></pre>
<p>이 점 때문에 of가 있었던 것이다. 
커서 페이징에서 변환 로직 같은게 아니라면.. 이제 생략해도 될 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] 접근 제어자, 변수 선언 방식, BaseEntity 구현]]></title>
            <link>https://velog.io/@jayaione_ele/Kotlin-%EC%A0%91%EA%B7%BC-%EC%A0%9C%EC%96%B4%EC%9E%90-%EB%B3%80%EC%88%98-%ED%83%90%EA%B5%AC%EC%99%80-BaseEntity</link>
            <guid>https://velog.io/@jayaione_ele/Kotlin-%EC%A0%91%EA%B7%BC-%EC%A0%9C%EC%96%B4%EC%9E%90-%EB%B3%80%EC%88%98-%ED%83%90%EA%B5%AC%EC%99%80-BaseEntity</guid>
            <pubDate>Sat, 25 Apr 2026 15:13:34 GMT</pubDate>
            <description><![CDATA[<p>java에서 코틀린으로 spring boot 프로젝트를 전환하면서 가장 기초적인 BaseEntity 작성부터 접근 제어자나 getter, setter 측면에서 다른 점이 은근히 많아서 정리해보고자 했다. </p>
<pre><code class="language-kotlin">// 컴파일 에러
var createdAt: LocalDateTime
// &quot;Property must be initialized&quot;

// null로 초기화 (nullable 타입)
var createdAt: LocalDateTime? = null

// lateinit (non-null 타입이지만 나중에 초기화)
lateinit var createdAt: LocalDateTime</code></pre>
<p>자바에서는 <code>LocalDateTime createdAt;</code>이라고 하면 자동으로 <code>null</code>로 초기화돼서 신경 쓸 일이 없다.
근데 Kotlin은 모든 프로퍼티를 명시적으로 초기화해야 컴파일이 된다. 
자바에서는 다음과 같이 정의한다. 이렇게 하면 null로 초기화가 되는데 코틀린에서는 자동으로 안된다. </p>
<pre><code class="language-java">@MappedSuperclass  
@QuerySupertype // QClass 생기지 않도록 설정  
@EntityListeners(AuditingEntityListener::class)  
class BaseEntity {  
    @CreatedDate  
    @JsonFormat(  
        shape = JsonFormat.Shape.STRING,  
        pattern = &quot;yyyy-MM-dd HH:mm:ss&quot;,  
        timezone = &quot;Asia/Seoul&quot;)  
    var createdAt: LocalDateTime? = null;  

    @LastModifiedDate  
    @JsonFormat(  
        shape = JsonFormat.Shape.STRING,  
        pattern = &quot;yyyy-MM-dd HH:mm:ss&quot;,  
        timezone = &quot;Asia/Seoul&quot;)  
    var modifiedAt: LocalDateTime? = null;</code></pre>
<p>그렇다면 lateinit이라는 게 있는데 이걸 안 쓰는 이유가 궁금할 것이다.</p>
<p>나중에 초기화를 하는 게 lateinit인데, JPA Auditing에는 부적합하다.</p>
<ol>
<li><p>lateinit은 non-null 타입에만 가능하다. primitive, nullable 타입에 사용할 수 없다. </p>
<ul>
<li>modifiedAt은 초기에 null이 될 수 있기 때문에 </li>
</ul>
</li>
<li><p>초기화 전 접근 시 예외 </p>
<ul>
<li>JPA에서 초기화하기 전에 읽게 되면 UninitializedPropertyAccessException 예외가 터진다. 예를 들어 save() 호출 전에 로그를 찍으면 안된다. </li>
</ul>
</li>
<li><p>데이터베이스에 null이 들어갈 수도 있는데 어쩌다가.. 이걸 아예 non null로 받으면 터짐</p>
</li>
</ol>
<p>-&gt; 그렇지만 null로 초기화하게 되면 , 웬만하면 createdAt이 있을 텐데도 nullable 체크를 매번 해줘야 하는 번거로움이 있다. </p>
<h4 id="대안">대안</h4>
<ol>
<li>초기값을 now()로 설정</li>
<li>protected set으로 외부 변경 차단<ul>
<li>var로 두게 되면 외부에서도 변경 가능한 public인 셈이다.</li>
<li>JPA Auditing이 reflection으로 값을 주입할 수 있으려면 setter가 있어야 하는데 , 외부에서는 변경이 불가능하게 protected set으로 설정한다. </li>
<li>이렇게 하면 같은 패키지와 하위 클래스만 접근이 가능하고, 다른 코드에서는 read only만 가능하다. </li>
<li>참고: protected는 코틀린에서 패키지 무관하게 하위 클래스만 접근 가능하다. <pre><code class="language-kotlin">var createdAt: LocalDateTime = LocalDateTime.now()
protected set</code></pre>
</li>
</ul>
</li>
<li><code>@Prepersist</code> 사용<ul>
<li>이 방법은 JpaAuditing을 안쓰고 persist를 직접 해줘야 한다.  </li>
<li>entityManager.persist(entity) 를 호출하고 실제 SQL insert 실행 사이에 끼어들어서 메서드를 실행한다. </li>
<li>Insert 직전에 다시 한번 now로 초기화한다. </li>
<li>이렇게 하면 두 번이나 초기화가 된다. 객체 생성 시, insert 직전 이렇게 두번! -&gt; 두 시점 사이 시간차가 있을 수 있으므로</li>
<li>Auditing을 안써서 별도 설정이 필요 없지만 직접 제어해야 해서 불편하다. Update도 별도로 설정해줘야한다. </li>
</ul>
</li>
</ol>
<h3 id="getter와-setter">Getter와 Setter</h3>
<p>protected set이란 그럼 무엇인가..</p>
<h4 id="var의-동작-원리">var의 동작 원리</h4>
<p>kotlin에서 var로 선언한 변수는 컴파일러가 자동으로 getter, setter를 생성해준다. 
다음 두 버전이 동일한 내용이라고 보면 된다. </p>
<p>코틀린 작성 코드</p>
<pre><code class="language-kotlin">var createdAt: LocalDateTime? = LocalDateTime.now()</code></pre>
<p>자바 작성 코드</p>
<p>Lombok의 @Getter, @Setter를 사용해줄 수도 있다. </p>
<pre><code class="language-java">private LocalDateTime createdAt = LocalDateTime.now();

public LocalDateTime getCreatedAt() {        // getter
    return createdAt;
}

public void setCreatedAt(LocalDateTime value) {  // setter
    this.createdAt = value;
}</code></pre>
<h4 id="코틀린에서의-가시성-분리-문법">코틀린에서의 가시성 분리 문법</h4>
<p>kotlin은 getter, setter의 가시성을 다르게 지정할 수가 있다. 
getter는 public, setter는 protected로 하고 싶다면 protected set으로 명시해주면 되는 것이다. </p>
<h3 id="최종-baseentity">최종 BaseEntity</h3>
<pre><code class="language-kotlin">@MappedSuperclass  
@QuerySupertype // QClass 생기지 않도록 설정  
@EntityListeners(AuditingEntityListener::class)  
class BaseEntity {  
    @CreatedDate  
    @JsonFormat(  
        shape = JsonFormat.Shape.STRING,  
        pattern = &quot;yyyy-MM-dd HH:mm:ss&quot;,  
        timezone = &quot;Asia/Seoul&quot;)  
    var createdAt: LocalDateTime? = LocalDateTime.now()  
            protected set  

    @LastModifiedDate  
    @JsonFormat(  
        shape = JsonFormat.Shape.STRING,  
        pattern = &quot;yyyy-MM-dd HH:mm:ss&quot;,  
        timezone = &quot;Asia/Seoul&quot;)  
    var modifiedAt: LocalDateTime? = LocalDateTime.now()  
            protected set  
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java][프로그래머스] 기능 개발 - 큐]]></title>
            <link>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C-%ED%81%90</link>
            <guid>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C-%ED%81%90</guid>
            <pubDate>Thu, 23 Apr 2026 18:55:24 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/8a1be61c-d8d4-462e-acaf-dfb8f7bc5c7a/image.jpeg" alt=""></p>
<h3 id="기능-개발---level-2">기능 개발 - level 2</h3>
<pre><code class="language-java">import java.util.*;

class Solution {
    public int[] solution(int[] progresses, int[] speeds) {
        int[] answer = {};
        Queue&lt;Integer&gt; q = new ArrayDeque&lt;&gt;();
        int n = progresses.length;

        for(int i = 0; i &lt; n;i++){
            int days = (100 - progresses[i] + speeds[i] - 1) / speeds[i];
            q.offer(days);
        }

        List&lt;Integer&gt; list = new ArrayList&lt;&gt;();

        while(!q.isEmpty()){
            int count = 1;
            int job = q.poll();
            // 작업완료된 기능보다 더 빨리 아니면 같은날에 배포가 가능하다면 같이 poll
            while(!q.isEmpty() &amp;&amp; q.peek() &lt;= job){
                q.poll();
                count++;
            }
            list.add(count);
        }

        answer = new int[list.size()];
        for (int i = 0; i &lt; list.size();i++){
            answer[i] = list.get(i);
        }
        return answer;
    }
}
</code></pre>
<h3 id="푼-방법">푼 방법</h3>
<ul>
<li>책이랑 조금 다르게 배포 전까지의 최소 날짜를 큐에 넣었다. </li>
<li>먼저 끝내도 배포를 못하기 때문에 순서대로 큐에 넣고, 다음 작업의 최소 남은 일수가 현재 끝낸 작업의 일수보다 작거나 같으면 같이 배포하는 걸로 처리한다. </li>
</ul>
<h3 id="회고">회고</h3>
<ul>
<li>큐를 써서 풀긴 했는데 꼭 쓸 필요는 없는 것 같다.</li>
<li>핵심 아이디어? 가 딱히 없는 것 같아서 금방 풀었다. 그냥 배포 가능하면 같이 한다 정도..?</li>
<li>stream을 쓰면 코드가 간결해서 쓰려고 했는데 성능이 안좋아지는 것 같아서 그냥 stream 없이 했다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java][프로그래머스] 구명 보트 - 그리디 ]]></title>
            <link>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EA%B5%AC%EB%AA%85-%EB%B3%B4%ED%8A%B8-%EA%B7%B8%EB%A6%AC%EB%94%94</link>
            <guid>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EA%B5%AC%EB%AA%85-%EB%B3%B4%ED%8A%B8-%EA%B7%B8%EB%A6%AC%EB%94%94</guid>
            <pubDate>Thu, 23 Apr 2026 17:59:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/82bb8e61-cfd1-4298-a503-a1564f1529dd/image.png" alt=""></p>
<h2 id="구명-보트---level-2">구명 보트 - Level 2</h2>
<pre><code class="language-java">
class Solution {
    public int solution(int[] people, int limit) {
        int answer = 0;

        Arrays.sort(people);

        int n = people.length;

        int left = 0;
        int right = people.length - 1;

        while(left &lt;= right){
            if(people[left] + people[right] &lt;= limit){
                left++;
            }
            right--;
            answer++;
        }

        return answer;
    }
}</code></pre>
<h3 id="푼-방법">푼 방법</h3>
<ul>
<li>오름차순으로 정렬을 한다. </li>
<li>left, right로 각각 한명씩 선택하고, 만족을 못하면 이동하는 방식으로 한다. </li>
<li>left 인덱스가 right보다 커지면 종료하므로 전체를 다 돌게 된다. </li>
<li>가장 가벼운 사람 선택하고, 가장 무거운 사람과 합쳐서 limit보다 작거난 같으면 선택하고 아니면 옆으로 이동한다. </li>
</ul>
<p>20 30 70 80 이런식으로 있다고 가정한다. limit은 100이다. </p>
<p>20을 선택하면 80과 같이 태워야 가장 효율적이다. 그러므로 20을 선택하면 80을 선택하도록 설계해야 한다. 
그래서 가장 가벼운 사람을 고르고, 그 사람과 가장 무거운 사람을 비교하고 초과하면 두번째로 무거운 사람과 비교하는 식으로 했다. 
가벼운 사람을 고르는 건 오름차순이므로 left, 무거운 사람을 고르는 건 오른쪽이므로 right로 해서 left는 둘을 태우게 되면 이동하는 식으로, 가벼운 사람 기준으로 같이 태울 사람을 정하는 식으로 했다. </p>
<h3 id="회고">회고</h3>
<ul>
<li>처음에 아이디어 자체는 빨리 떠올렸던 것 같다. 가벼운 사람 한명 태우고, 가장 무거운 사람을 내림차순으로 검사하는 방식으로..</li>
<li>처음이 이중 for문으로 돌아가면서 선택해야 하나 싶었는데 생각해보니 모두 돌 필요가 없고 두명을 고르는 것이기 때문에 반씩 돌면 되니까, 두 변수를 두고 아니면 이동하는 식으로 구현했다. 근데 이 방식을 생각하는게 조금 걸렸다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java][프로그래머스] 표 편집 - 스택]]></title>
            <link>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%ED%91%9C-%ED%8E%B8%EC%A7%91-%EC%8A%A4%ED%83%9D</link>
            <guid>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%ED%91%9C-%ED%8E%B8%EC%A7%91-%EC%8A%A4%ED%83%9D</guid>
            <pubDate>Wed, 22 Apr 2026 05:48:32 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/e9e2a830-c262-45e4-9bf2-13152ec40172/image.png" alt=""></p>
<h3 id="표-편집---level-3">표 편집 - Level 3</h3>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/81303">문제 보기</a></p>
<pre><code class="language-java">import java.util.Stack;
import java.util.StringBuilder;


class Solution {
    public String solution(int n, int k, String[] cmd) {
        int current = k;
        // 삭제된 노드 보관하는 스택
        Stack&lt;Integer&gt; deleted = new Stack&lt;&gt;();
        // 해당 인덱스의 이전 노드, 첫 번재 인덱스는 이전 노드 없으므로 -1이 들어감
        int[] prev = new int[n];
        // 해당 인덱스의 이후 노드 
        int[] next = new int[n];
        // 삭제 여부를 관리하는 배열
        boolean[] removed = new boolean[n];

        // 초기화
        for(int i = 0; i &lt; n; i++){
            prev[i] = i - 1;
            next[i] = i + 1;
        }
        // 마지막 인덱스는 이후 노드가 없으므로 -1을 넣어줌
        next[n - 1] = -1;

        int x;

        for(String s: cmd){
            String[] command = s.split(&quot; &quot;);
            switch(command[0]){
                case &quot;U&quot;:
                    x = Integer.parseInt(command[1]);
                    for(int i = 0; i &lt; x;i++) current = prev[current];
                    break;
                case &quot;D&quot;:
                    x = Integer.parseInt(command[1]);
                    for(int i = 0; i &lt; x;i++) current = next[current];
                    break;
                case &quot;C&quot;:
                    deleted.push(current);
                    removed[current] = true;
                    if(prev[current]!= -1) next[prev[current]] = next[current];
                    if(next[current]!=-1) prev[next[current]] = prev[current];
                    current = (next[current]!=-1) ? next[current] : prev[current];
                    break;
                case &quot;Z&quot;:
                    if(!deleted.isEmpty()){
                        int restored = deleted.pop();
                        removed[restored] = false;
                        // 복구 시에는 양옆이 restored를 가리키게 해야 함
                        // 이전 인덱스의 next를 복구하려는 인덱스로 설정
                        if(prev[restored]!=-1) next[prev[restored]] = restored;
                        // 다음 인덱스의 prev를 복구하려는 인덱스로 설정
                        if(next[restored]!=-1) prev[next[restored]] = restored;
                    }
                    break;
            }
        }
        StringBuilder sb = new StringBuilder();

            for(boolean r: removed){
                sb.append(r ? &quot;X&quot; : &quot;O&quot;);
            }

        return sb.toString();
    }
}
</code></pre>
<h3 id="푼-방법">푼 방법</h3>
<h4 id="1-초기화">1. 초기화</h4>
<ul>
<li>이중 연결리스트를 구현했다. </li>
<li><code>Stack&lt;Integer&gt; deleted</code> : 삭제된 노드를 보관하는 스택 </li>
<li><code>int[] prev</code> : i번째 노드의 이전 값들을 보관하는 배열, 첫번째 노드는 이전 노드가 없으므로 -1을 넣는다.</li>
<li><code>int[] next</code> : i번째 노드의 다음 값들을 보관하는 배열, 마지막 노드는 다음 노드가 없으므로 -1을 넣는다. </li>
<li><code>boolean[] removed</code> : 최종 출력시에 삭제 여부 관리하는 배열</li>
</ul>
<p>즉 prev,next는 연결 관계를 유지해야한다. </p>
<h4 id="인덱스-이동">인덱스 이동</h4>
<p>x만큼 위/아래로 현재 인덱스를 이동시킨다고 할 때, 반복문을 돌려서 prev과 next의 현재값을 하나씩 옮겨준다. </p>
<pre><code class="language-text">1  -  2  -  3  - 4</code></pre>
<p>지금 현재 인덱스가 1이고 2만큼 아래로 옮겨야 하면 반복문 한턴이 돌았을 때,</p>
<p>current가 이렇게 2번째 인덱스를 가리키려면 다음 인덱스를 가져가야 한다. 
반대로 위로 옮기려면 이전 인덱스를 가져가야 한다. 그래서 위로 이동할 때는 prev, 아래로 이동할 때는 next를 사용해준다. </p>
<pre><code class="language-text">1  -  2  -  3  - 4

           cur</code></pre>
<h4 id="인덱스-삭제">인덱스 삭제</h4>
<p>이것도 간단하게 생각하면 삭제를 해주고 이어 붙여주면 된다. </p>
<pre><code class="language-text">1  -  2  -  3  - 4</code></pre>
<p>이렇게 있을 때 2를 삭제해야 한다고 가정해 보면,
1의 다음 인덱스를 3의 이전 인덱스로 설정해줘야 한다. 
즉 next[prev[current]] 는 이전 인덱스의 다음 인덱스 값이므로, 이거를 삭제하려는 인덱스의 다음 값으로 바꿔준다. </p>
<p>이렇게만 해주면 [삭제노드] 의 이전 인덱스만 처리를 해준 것이다. 
다음 인덱스의 prev 배열도 처리해주면 삭제 노드가 없어지고 이어붙여진다. </p>
<p>이렇게 값을 삭제하고 나서 stack에 push해줘야 한다. 영구적으로 삭제한 게 아니고 Z 명령어가 나오면 다시 넣어줘야 하기 때문이다. 
removed에 true 처리도 해줘야 한다. 마지막 표시용으로.. </p>
<pre><code class="language-java">// 이전 인덱스의 next 처리
if(prev[current]!= -1) next[prev[current]] = next[current];
// 다음 인덱스의 prev 처리
if(next[current]!=-1) prev[next[current]] = prev[current];</code></pre>
<h4 id="인덱스-복구">인덱스 복구</h4>
<p>인덱스 복구도 삭제랑 비슷한 느낌으로 구현하면 되는데, 
이전 인덱스의 next와 다음 인덱스 prev가 복구하려는 인덱스를 가리키게 하면 된다.</p>
<p>먼저 삭제해둔 인덱스를 복구처리해준다. 가장 최근에 삭제된 인덱스를 stack에서 pop하고 removed 배열도 false처리해준다. </p>
<h3 id="회고">회고</h3>
<ul>
<li>처음에 연결리스트로 풀어서 간단하게 풀릴 줄 알았는데 시간초과가 났다 .. 다른 방법 찾아보니까 배열로 이중 연결리스트를 만드는 방법이 있어서 이렇게 하면 시간초과가 안날 것 같아서 시도해 봤다. </li>
<li>책에서는 마지막에 StringBuilder를 사용하지 않고 arrays fill 방법을 사용했다. 그다지 성능에 많은 차이가 날 것 같지는 않은데 StringBuilder를 남발하는 습관때문에 그런 것 같다. 최대한 util을 안쓰고 하는 방법을 항상 생각해봐야겠다. </li>
<li>책에서 switch case 말고 else if를 사용했는데 이것도 괜찮은 방법 같은게 C,Z는 숫자 매개변수가 딱히 없는 연산이라서.. 그렇지만 switch문이 뭔가 보기 깔끔해서 그냥 썼다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java][프로그래머스] 크레인 인형뽑기 - 스택]]></title>
            <link>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%ED%81%AC%EB%A0%88%EC%9D%B8-%EC%9D%B8%ED%98%95%EB%BD%91%EA%B8%B0-%EC%8A%A4%ED%83%9D</link>
            <guid>https://velog.io/@jayaione_ele/Java%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%ED%81%AC%EB%A0%88%EC%9D%B8-%EC%9D%B8%ED%98%95%EB%BD%91%EA%B8%B0-%EC%8A%A4%ED%83%9D</guid>
            <pubDate>Tue, 21 Apr 2026 18:56:44 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/33b6336b-5c4e-4de4-86ca-2bf210577de6/image.png" alt=""></p>
<h2 id="크레인-인형뽑기">크레인 인형뽑기</h2>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/64061">문제 보기</a></p>
<pre><code class="language-java">import java.util.*;

class Solution {
    public int solution(int[][] board, int[] moves) {
        int answer = 0;

        // n-1,0 계열부터 넣고, n-2,0 계열부터 다음에 넣기 
        // 그러면 n-1,0 n-2, 1 이렇게 순서대로 넣기
        int n = board[0].length;
        Stack&lt;Integer&gt; picked = new Stack&lt;&gt;();

        // 초기화 
        List&lt;Stack&lt;Integer&gt;&gt; stacks = new ArrayList&lt;&gt;();
        for(int i = 0; i &lt; n;i++){
            Stack&lt;Integer&gt; stack = new Stack&lt;&gt;();
            for(int j = n-1; j &gt;= 0; j--){
                int current = board[j][i];
                if(current == 0){
                    continue;
                }
                stack.push(current);
            }
            stacks.add(stack);
        }

        // 움직일 때마다 stack에 넣기
        for(int m : moves){
             // 가장 위에있는거랑 지금 넣을거랑 같으면 둘다 삭제
            if(!picked.isEmpty() &amp;&amp; !stacks.get(m-1).isEmpty()){
                if(stacks.get(m-1).peek() == picked.peek()){
                    answer+=2;
                    stacks.get(m-1).pop();
                    picked.pop();
                } else{
                    // 같지 않으면 picked에 넣기 
                    picked.push(stacks.get(m-1).pop());
                }
            } else if (!stacks.get(m-1).isEmpty()){
                picked.push(stacks.get(m-1).pop());
            } else if (stacks.get(m-1).isEmpty()){
                continue;
            }
        }

        return answer;
    }
}</code></pre>
<h3 id="푼-방법">푼 방법</h3>
<ul>
<li>초기화: 세로 라인별로 stack을 만들어서 리스트에 저장한다. </li>
<li>스택에 먼저 넣기: [n-1][0]이 제일 끝부분에 들어가야 하므로 n-1,n-2 이렇게 해서 해당 라인 stack에 저장한다. </li>
<li>크레인을 돌아다니면서 뽑기: 뽑은 것을 picked stack에 넣는다. </li>
</ul>
<p>이건 세 가지 경우가 있는데</p>
<ol>
<li>picked의 peek(제일 위에 있는 것)이 현재 위에 있는거랑 다르면 라인이랑 뽑힌 것에서 삭제</li>
<li>같지 않으면 picked에 넣기</li>
<li>라인 자체가 안비어있으면 picked에 넣고 라인에서 삭제</li>
</ol>
<h3 id="개선할-점">개선할 점</h3>
<p>stack에 넣기 전에 그냥 검사해서 하는 방법도 괜찮을 것 같은데 코드가 너무 길어진 것 같다.. 그래도 숫자 범위 자체가 그렇게 크지는 않아서 별 차이가 없을 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 백준 1106 - 호텔 DP]]></title>
            <link>https://velog.io/@jayaione_ele/Java-%EB%B0%B1%EC%A4%80-1106-%ED%98%B8%ED%85%94-DP</link>
            <guid>https://velog.io/@jayaione_ele/Java-%EB%B0%B1%EC%A4%80-1106-%ED%98%B8%ED%85%94-DP</guid>
            <pubDate>Sun, 12 Apr 2026 20:04:46 GMT</pubDate>
            <description><![CDATA[<h2 id="호텔">호텔</h2>
<blockquote>
<p>세계적인 호텔인 형택 호텔의 사장인 김형택은 이번에 수입을 조금 늘리기 위해서 홍보를 하려고 한다.
형택이가 홍보를 할 수 있는 도시가 주어지고, 각 도시별로 홍보하는데 드는 비용과, 그 때 몇 명의 호텔 고객이 늘어나는지에 대한 정보가 있다.
예를 들어, “어떤 도시에서 9원을 들여서 홍보하면 3명의 고객이 늘어난다.”와 같은 정보이다. 이때, 이러한 정보에 나타난 돈에 정수배 만큼을 투자할 수 있다. 즉, 9원을 들여서 3명의 고객, 18원을 들여서 6명의 고객, 27원을 들여서 9명의 고객을 늘어나게 할 수 있지만, 3원을 들여서 홍보해서 1명의 고객, 12원을 들여서 4명의 고객을 늘어나게 할 수는 없다.
각 도시에는 무한 명의 잠재적인 고객이 있다. 이때, 호텔의 고객을 적어도 C명 늘이기 위해 형택이가 투자해야 하는 돈의 최솟값을 구하는 프로그램을 작성하시오.</p>
</blockquote>
<pre><code class="language-java">import java.util.*;
import java.io.*;

class Main {
    static int[] dp;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st;
        st = new StringTokenizer(br.readLine());
        int c = Integer.parseInt(st.nextToken());
        int n = Integer.parseInt(st.nextToken());

        // 큰 값으로 지정 및 초기화 
        int INF = 1_000_000_000;
        dp = new int[c+100];
        Arrays.fill(dp, INF);
        dp[0] = 0;

        for(int i = 0; i &lt; n;i++) {
            st = new StringTokenizer(br.readLine());
            int value = Integer.parseInt(st.nextToken());
            int weight = Integer.parseInt(st.nextToken());
            for(int w = weight; w &lt; dp.length;w++){
                dp[w] = Math.min(dp[w], dp[w - weight] + value);
            }
        }

        int answer = INF;
        for (int i = c; i &lt; dp.length; i++) {
            answer = Math.min(answer, dp[i]);
        }
        System.out.println(answer);
    }
}</code></pre>
<p>문제 분석</p>
<ul>
<li>DP 유형 중 요소를 여러 개 사용해도 되는 배낭 문제이다.</li>
</ul>
<p>삽질한 이유</p>
<ul>
<li>DP문제를 풀 때마다 초기화하는걸 자꾸 까먹는다 .. 최솟값을 구하는 문제이므로 큰 값으로 초기화해주기</li>
<li>손님 수 이상이 되어도 상관 없고 적어도 c명 이상이라는걸 간과하고 처음에 dp 배열 크기를 잘못 잡았다. </li>
</ul>
]]></description>
        </item>
    </channel>
</rss>