<?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>Tue, 19 May 2026 18:44:00 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[[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>
        <item>
            <title><![CDATA[[Java] 백준 2156 - 포도주 시식 DP]]></title>
            <link>https://velog.io/@jayaione_ele/Java-%EB%B0%B1%EC%A4%80-2156-%ED%8F%AC%EB%8F%84%EC%A3%BC-%EC%8B%9C%EC%8B%9D-DP</link>
            <guid>https://velog.io/@jayaione_ele/Java-%EB%B0%B1%EC%A4%80-2156-%ED%8F%AC%EB%8F%84%EC%A3%BC-%EC%8B%9C%EC%8B%9D-DP</guid>
            <pubDate>Sun, 12 Apr 2026 18:20:40 GMT</pubDate>
            <description><![CDATA[<h2 id="포도주-시식">포도주 시식</h2>
<blockquote>
<p>효주는 포도주 시식회에 갔다. 그 곳에 갔더니, 테이블 위에 다양한 포도주가 들어있는 포도주 잔이 일렬로 놓여 있었다. 효주는 포도주 시식을 하려고 하는데, 여기에는 다음과 같은 두 가지 규칙이 있다.</p>
</blockquote>
<p>포도주 잔을 선택하면 그 잔에 들어있는 포도주는 모두 마셔야 하고, 마신 후에는 원래 위치에 다시 놓아야 한다.
연속으로 놓여 있는 3잔을 모두 마실 수는 없다.
효주는 될 수 있는 대로 많은 양의 포도주를 맛보기 위해서 어떤 포도주 잔을 선택해야 할지 고민하고 있다. 1부터 n까지의 번호가 붙어 있는 n개의 포도주 잔이 순서대로 테이블 위에 놓여 있고, 각 포도주 잔에 들어있는 포도주의 양이 주어졌을 때, 효주를 도와 가장 많은 양의 포도주를 마실 수 있도록 하는 프로그램을 작성하시오. </p>
<p>예를 들어 6개의 포도주 잔이 있고, 각각의 잔에 순서대로 6, 10, 13, 9, 8, 1 만큼의 포도주가 들어 있을 때, 첫 번째, 두 번째, 네 번째, 다섯 번째 포도주 잔을 선택하면 총 포도주 양이 33으로 최대로 마실 수 있다.</p>
<pre><code class="language-java">import java.util.*;
import java.io.*;

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

        for(int i = 0; i &lt; n; i++){
            int k = Integer.parseInt(br.readLine());
            arr[i] = k;
        }

        if(n == 3){
            answer = Math.max(arr[1] + arr[2], Math.max(arr[0] + arr[1], arr[0] + arr[2]));
        }
        else if(n == 2) {
            answer = arr[0] + arr[1];
        }
        else if(n == 1){
            answer = arr[0];
        }
        else {
            dp = new int[n];
            dp[0] = arr[0];
            dp[1] = arr[0] + arr[1];
            // 1,2번째 마셨을 때, 1,3번째 마셨을 때, 2,3번째 마셨을 때
            dp[2] = Math.max(dp[1], Math.max(arr[0] + arr[2], arr[1] + arr[2]));

            // i번째만 마신 경우 vs i번째를 안마신 경우 vs i번째, i-1번째 마신 경우
            for(int i = 3; i &lt; n; i++) {
                dp[i] = Math.max(dp[i-2] + arr[i], 
                             Math.max(dp[i-1], dp[i-3] + arr[i] + arr[i-1]));
            }
            answer = dp[n-1];
         }

        System.out.println(answer);
    }
}</code></pre>
<p>푼 방법</p>
<ul>
<li>i번째만 마신 경우 vs i번째를 안마신 경우 vs i번째, i-1번째 마신 경우를 고려해서 점화식을 세움</li>
<li>1잔, 2잔, 3잔씩 있는 경우를 각각 계산</li>
</ul>
<p>삽질했던 이유</p>
<ul>
<li>3연속 마실 수 없다는 걸 고려해서 점화식을 세우는 게 생각보다 어려웠다.</li>
<li>n이 3보다 작거나 딱 3인 경우를 생각을 안해서 자꾸 실패했다. </li>
</ul>
<p>다음부터 실수 안하기 &amp; 고치기</p>
<ul>
<li>DP 문제 풀 때 여러 경우의 수 고려하기</li>
<li>숫자가 너무 작거나 하는 경우 고려하기 </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 백준 14889 스타트와 링크 - 백트래킹 ]]></title>
            <link>https://velog.io/@jayaione_ele/Java-%EB%B0%B1%EC%A4%80-14889-%EC%8A%A4%ED%83%80%ED%8A%B8%EC%99%80-%EB%A7%81%ED%81%AC-%EB%B0%B1%ED%8A%B8%EB%9E%98%ED%82%B9</link>
            <guid>https://velog.io/@jayaione_ele/Java-%EB%B0%B1%EC%A4%80-14889-%EC%8A%A4%ED%83%80%ED%8A%B8%EC%99%80-%EB%A7%81%ED%81%AC-%EB%B0%B1%ED%8A%B8%EB%9E%98%ED%82%B9</guid>
            <pubDate>Sun, 12 Apr 2026 17:09:35 GMT</pubDate>
            <description><![CDATA[<p>스타트와 링크 - 실버1</p>
<pre><code class="language-java">import java.util.*;
import java.io.*;

class Main {
    static int answer = Integer.MAX_VALUE;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());

        int [][] strength = new int[n][n];

        StringTokenizer st; 
        for(int i = 0; i &lt; n; i++){
            st = new StringTokenizer(br.readLine());
            for(int j = 0; j &lt; n; j++) {
                strength[i][j] = Integer.parseInt(st.nextToken());
            }
        }

        boolean[] visited = new boolean[n];

        matchTeam(strength, 0, visited, 0);
        System.out.println(answer);
    }

    static void matchTeam(int[][] arr, int start, boolean[] visited, int count){

        if(count == arr.length / 2){
            int startSum = 0;
            int linkSum = 0;

            for(int i = 0; i &lt; arr.length; i++){
                for(int j = i + 1; j &lt; arr.length; j++){
                    if(visited[i] &amp;&amp; visited[j]){
                        startSum += arr[i][j] + arr[j][i];
                    }
                    else if(!visited[i] &amp;&amp; !visited[j]){
                        linkSum += arr[i][j] + arr[j][i];
                    }
                }
            }

            answer = Math.min(answer, Math.abs(startSum - linkSum));
            return;
        }

        for(int i = start; i &lt; arr.length; i++){
            visited[i] = true;
            matchTeam(arr, i + 1, visited, count + 1);
            // 백트래킹
            visited[i] = false;
        }
    }
}</code></pre>
<p>처음에 팀을 따로따로 만들어서 잘 안됐다.. 
그래서 하나만 만들고 나머지 팀원은 링크 팀에 들어가도록 하고,
dfs를 재귀로 돌리면서, 백트래킹하도록 구현했다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Kotlin] Coroutine]]></title>
            <link>https://velog.io/@jayaione_ele/Kotlin-Coroutine</link>
            <guid>https://velog.io/@jayaione_ele/Kotlin-Coroutine</guid>
            <pubDate>Tue, 07 Apr 2026 18:01:38 GMT</pubDate>
            <description><![CDATA[<p>코루틴에 대해 공식 문서와 블로그를 참고한 글입니다. </p>
<h3 id="비동기-프로그래밍">비동기 프로그래밍</h3>
<p>비동기 프로그래밍: 메인 스레드에서 시간이 오래 걸리는 작업을 하게 되면, Application Not responding이 발생함 즉 메인 스레드가 특정 시간동안 응답하지 않으므로, 오래걸리는 작업을 안드로이드에서는 메인스레드와 분리해서 처리하도록 한다. </p>
<h3 id="코루틴">코루틴</h3>
<ul>
<li>비동기 작업을 효율적으로 처리하기 위해 설계된 경량화된 동시성 처리 방식</li>
<li>일시 중단이 가능: 동시 실행 코드가 있으면 운영체제가 관리하는 스레드에서 실행된다. 코루틴은 스레드를 차단하는 대신 실행을 일시 중단할 수 있다. 즉 하나의 코루틴이 데이터 도착을 기다리는 동안 일시 중단하고, 다른 코루틴이 동일한 스레드에서 실행될 있다. 
즉 작업을 일시 중단을 하고 나중에 재개할 수 있으므로 매우 적은 자원을 사용할 수 있다. 코루틴 자체가 자발적으로 작업을 일시 중단하고 다른 작업에 CPU 점유를 넘기게 되는 것이다. </li>
</ul>
<h3 id="virtual-thread와의-비교">Virtual Thread와의 비교</h3>
<p>기존의 스레드 모델을 경량화한 형태로, JVM 레벨에서 관리되어 운영체제의 스레드보다 훨씬 가볍다. 
수십만 개의 스레드를 생성할 수 있으며, I/O 작업 시 효율적으로 대기 상태에 들어가서 시스템 자원을 적게 소모한다. </p>
<p>스레드는 운영체제에 의해 관리되는데, 여러 CPU 코어에서 병렬로 작업 실행이 가능하다. 
스레드를 생성하면 해당 스레드의 스택에 메모리를 할당하고 스레드 간 전환을 수행한다. 그렇기 때문에 많은 리소스를 소모하게 된다. </p>
<p>코루틴도 JVM의 스레드처럼 코드를 동시 실행하는 일시 중단 가능한 연산이지만, 내부적으로는 다르게 작동한다. </p>
<p>코루틴은 여러 스레드에 종속되지 않는다!</p>
<p>한 스레드에서 일시 중단되었다가, 다른 스레드에서 다시 시작할 수가 있다. 즉 여러 코루틴이 동일한 스레드 풀을 공유할 수 있다. 코루틴이 일시 중단되어도 해당 스레드는 다른 작업을 실행할 수 있는 것이다. 스레드보다 자원을 과도하게 소모하지 않아도 된다. </p>
<h2 id="코루틴-스코프에-대해서-">코루틴 스코프에 대해서 ..</h2>
<h3 id="launch-함수">launch 함수</h3>
<p>코루틴 스코프의 launch라는 함수가 있는데...
현재 스레드를 차단하지 않고 새로운 코루틴을 시작하고, 해당 코루틴에 대한 참조를 job 객체로 반환한다. 
생성된 job이 취소되면 해당 코루틴도 취소된다. </p>
<p>즉 스코프 기반으로 실행 환경이 결정된다. </p>
<p>그래서 scope.launch 를 실행하게 되면,</p>
<p>부모 job과 dispatcher를 상속받는다!</p>
<h3 id="suspend-함수-구성">Suspend 함수 구성</h3>
<p>suspend 함수는 보통 원격 서비스 호출, 계산과 같은 유용한 작업을 수행한다. 
suspenc 함수로 정의를 하게 된다면 , 함수는 순차적으로 호출이 된다. </p>
<p>함수들을 동시에 실행하고 싶다면, 이럴 때 비동기 프로그래밍이 유용한다. </p>
<p>비동기는 다른 모든 코루틴과 동시에 작동하는 별도의 코루틴을 시작한다. launch 함수랑 유사한데, launch는 결과 값을 전달하지 않고 Job 객체를 반환한다. 
비동기는 반면에 결과값을 받을 수가 있다. 
반환 값이 Defferred인데, 미래에 결과를 받을 수 있는 객체이다. 즉 아직 결과가 없고, 나중에 꺼내는 것이다. </p>
<p>핵심은 await 함수 호출이다. </p>
<p><code>result.await()</code> 이런식으로 호출하면 결과를 반환해준다!
이렇게 호출되면 스레드 블로킹이 되는 게 아니라, 결과값이 나올 때까지 코루틴만 잠시 멈춘다. </p>
<p>async는 반드시 await를 해야 의미가 있다. 결과값을 사용을 안한다는 것이므로 launch랑 다를 게 없다. </p>
<h3 id="구조적-동시성">구조적 동시성</h3>
<pre><code class="language-kotlin">coroutineScope {
    val one = async { ... }
    val two = async { ... }
}</code></pre>
<p>코루틴스코프 안에서 aysnc를 호출해주게 되면, 스코프가 끝나기 전에 무조건 다 끝나야만 한다.
그래서 자동으로 await되는 것 처럼 보인다. 
왜냐, 코루틴 설계 자체가 부모가 자식이 끝나기 전에 절대 끝나지 않는다는 철학이 있어서이다. </p>
<p>내부적으로는 
스코프 시작 -&gt; one -&gt; two -&gt; 둘 다 끝날 때까지 기다림 -&gt; 스코프 종료</p>
<p>이렇게 되기때문에 무조건 끝나는 것이다. 즉 스코프 자체가 suspend 함수 역할을 한다. </p>
<p>여기서 주의사항은, 코루틴 스코프의 async 자식 중 하나라도 실패하면 첫 번째 부모와 대기 중인 부모가 모두 취소된다. </p>
<h3 id="코루틴스코프">코루틴스코프</h3>
<p>코루틴이 언제까지 실행할지 관리하는 것이다. 
Scope가 종료되면 그 안에서 실행 중이던 모든 코루틴도 자동으로 멈춘다. </p>
<ul>
<li>GlobalScope: 앱이 실행되는 동안 내내 살아있다. </li>
<li>lifecycleScope: Activity나 Fragment의 생명주기에 맞춰진다. 화면이 닫히면 코루틴도 종료된다. </li>
<li>viewModelScope: ViewModel의 생명주기를 따르며, 안드로이드 개발에서 가장 많이 쓰이는 스코프 </li>
</ul>
<p>순서 </p>
<ul>
<li>사용할 Dispatcher를 결정한다. </li>
<li>Dispatcher를 사용해서 CoroutineScope를 만든다.</li>
<li>CoroutineScope의 launch 또는 async에 수행할 코드 블록을 넘긴다.</li>
</ul>
<h3 id="코루틴-컨텍스트-및-디스패처">코루틴 컨텍스트 및 디스패처</h3>
<p>코루틴을 실행하면 코루틴스코프 실행을 제어하는 컨텍스트가 생성된다. </p>
<p>코루틴은 코루틴스코프의 컨텍스트를 그대로 물려받는다. 구성 요소는 다음과 같다. </p>
<ul>
<li>Job ( 부모-자식 관계 )</li>
<li>Dispatcher - 어디서 실행할지를 결정</li>
<li>CoroutineName 등등</li>
</ul>
<h4 id="dispatcher">Dispatcher</h4>
<p>코루틴 디스페처는 해당 코루틴이 실행에 사용할 스레드를 결정한다. 
코루틴 실행을 특정 스레드로 제한하거나, 스레드 풀로 디스패치하거나, 제한 없이 실행하도록 할 수 있다. </p>
<p>launch, async 와 같은 코루틴 생성 함수는 선택적으로 컨텍스트 매개변수를 허용한다. </p>
<ul>
<li><p>매개변수 없음: 매개변수가 없다면 코루틴은 실행되는 코루틴스코프의 컨텍스트를 상속받는다. 부모 또는 메인 코루틴의 컨텍스트를 가지는 것이다. </p>
</li>
<li><p>Dispatchers.Unconfined: 제한 되지 않으며 메인 스레드에서 작동한다. </p>
</li>
<li><p>Dispatchers.Default: DefaultDispatcher로 디스패치된다. 범위 내에 다른 디스패처가 명시적으로 지정되지 않는 경우, 기본 디스패처가 사용된다는 뜻이다. 공유 백그라운드 스레드 풀을 사용한다. </p>
</li>
<li><p>newSingleThreadContext : 코루틴 실행을 위한 스레드를 생성한다. 전용 스레드는 많은 자원을 사용한다는 뜻이므로 , 더 이상 필요하지 않을 때 close 함수를 사용해서 해제하거나 최상위 변수에 저장해서 재사용해야 한다. </p>
</li>
<li><p>Dispatcher.IO: </p>
<ul>
<li>읽기 쓰기 작업에 최적화되어 있다. </li>
<li>최대 64개까지 늘어나는 가변 스레드 풀을 가진다.</li>
<li>네트워크 DB 작업 시에 사용한다. </li>
</ul>
</li>
</ul>
<h3 id="다음에-조사해볼-것">다음에 조사해볼 것</h3>
<ul>
<li>컨텍스트에 대해 더 조사해봐야겠다. 특히 컨텍스트 요소 중 하나인 CoroutineExceptionHandler에 대해.. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JAVA] 다익스트라 알고리즘 ]]></title>
            <link>https://velog.io/@jayaione_ele/JAVA-%EB%8B%A4%EC%9D%B5%EC%8A%A4%ED%8A%B8%EB%9D%BC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</link>
            <guid>https://velog.io/@jayaione_ele/JAVA-%EB%8B%A4%EC%9D%B5%EC%8A%A4%ED%8A%B8%EB%9D%BC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</guid>
            <pubDate>Mon, 06 Apr 2026 12:49:05 GMT</pubDate>
            <description><![CDATA[<ol>
<li>특정 거리의 도시 찾기</li>
</ol>
<p>bfs로 풀었고, 거리가 1이라서 간단하게 풀렸음</p>
<pre><code>import java.util.*;
import java.lang.*;
import java.io.*;

class Main {
    // 입력값: 도시 개수, 간선 개수, 최단 거리, 출발 도시 번호
    // 모든 도로 거리는 1
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine()); 

        int n = Integer.parseInt(st.nextToken());
        int m = Integer.parseInt(st.nextToken());
        int k = Integer.parseInt(st.nextToken());
        int x = Integer.parseInt(st.nextToken())-1;

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

        for(int i = 0; i &lt; n; i++) {
            graph.add(new ArrayList&lt;&gt;());
        }

        for(int i = 0; i &lt; m; i++) {
            StringTokenizer stt = new StringTokenizer(br.readLine()); 
            int a = Integer.parseInt(stt.nextToken())-1;
            int b = Integer.parseInt(stt.nextToken())-1;

            graph.get(a).add(b);
        }

        int[] dist = bfs(graph,n,x,k);
        int count = 0;
        for(int i = 0; i &lt; n; i++) {
            if( dist[i] == k) {
                System.out.println(i+1);
                count++;
            }

        }
        if(count == 0) System.out.println(-1);

    }
    static int[] bfs(List&lt;List&lt;Integer&gt;&gt; graph, int n, int start, int k){
            int[] dist = new int[n+1];
            Arrays.fill(dist,-1);

            Queue&lt;Integer&gt; q = new LinkedList&lt;&gt;();
            q.offer(start);
            // 방문 표시
            dist[start] = 0;
            while(!q.isEmpty()){
                int now = q.poll();
                for(int next : graph.get(now)){
                    // 방문 안했다면 거리 +1 , 다음노드 큐에 삽입
                    if(dist[next] == -1){
                        dist[next] = dist[now] + 1;
                        q.offer(next);
                    }
                }
            }
        return dist;
    }
}</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] TransactionPhase.AFTER_COMMIT 적용기 ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-TransactionPhase.AFTERCOMMIT-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Spring-TransactionPhase.AFTERCOMMIT-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Fri, 27 Mar 2026 18:04:14 GMT</pubDate>
            <description><![CDATA[<h2 id="요구사항">요구사항</h2>
<p>독서 기록 생성 및 삭제 API를 구현하는데, 독서 기록에는 사진도 있고 내용도 존재한다. 
지금 문제는, s3에 올라간 사진 delete를 하게 되면 DB 트랜잭션이랑 별개이기 때문에 <code>@Transactional</code>로 설정해둔 메서드 안에서 s3를 지우게 되면, 실패 시 롤백이 불가능하다..</p>
<p>보통 이러한 경우에 <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>를 사용하는 경우가 많은 것 같아서, 사용해보고자 한다. </p>
<p><code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code> 이게 뭐냐면
트랜잭션이 성공적으로 commit 후에만 실행하도록 설정하는 것이다. </p>
<p>따라서 s3 delete는 트랜잭션 안에서 진행하면 롤백이 안되기 때문에, db 트랜잭션이 성공적으로 실행이 된다면 s3에서도 삭제를 진행하도록 해야 한다는 것이다. </p>
<p>내부적으로 어떻게 동작하는지 알아보자. 지금 상황이 딱 이런 식이다.</p>
<pre><code class="language-java">@Transactional
public void save() {
    repository.save(entity);
    eventPublisher.publishEvent(new Event());
}</code></pre>
<p>DB 저장 -&gt; 이벤트 실행(사진을 s3에 저장) -&gt; commit or rollback</p>
<p>지금 실행 흐름이 이렇게 되는데, rollback 이 되더라도 사진은 s3에 저장된 상태라는 것이다.</p>
<p><code>AFTER_COMMIT</code>을 사용하게 되면, 흐름은 다음과 같다.</p>
<p>DB 저장 -&gt; commit 성공 -&gt; 이벤트 실행(사진 저장)</p>
<p>이렇게 된다면, DB에서 저장된 다음 rollback이 되더라도 사진이 쓸데없이 저장되는 일이 생기지 않는다. </p>
<p>TransactionPhase 종류는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>phase</th>
<th>실행 시점</th>
<th>commit 성공 시</th>
<th>rollback 시</th>
</tr>
</thead>
<tbody><tr>
<td><code>AFTER_COMMIT</code></td>
<td>commit 이후</td>
<td>실행됨</td>
<td>실행 안됨</td>
</tr>
<tr>
<td><code>BEFORE_COMMIT</code></td>
<td>commit 직전</td>
<td>실행됨</td>
<td>실행 안됨</td>
</tr>
<tr>
<td><code>AFTER_ROLLBACK</code></td>
<td>rollback 이후</td>
<td>실행 안됨</td>
<td>실행됨</td>
</tr>
<tr>
<td><code>AFTER_COMPLETION</code></td>
<td>트랜잭션 종료 후</td>
<td>실행됨</td>
<td>실행됨</td>
</tr>
</tbody></table>
<h3 id="선택-기준">선택 기준</h3>
<table>
<thead>
<tr>
<th>상황</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 확정 후 실행해야 함</td>
<td><code>AFTER_COMMIT</code></td>
</tr>
<tr>
<td>실패했을 때만 처리</td>
<td><code>AFTER_ROLLBACK</code></td>
</tr>
<tr>
<td>성공/실패 상관없이 실행</td>
<td><code>AFTER_COMPLETION</code></td>
</tr>
<tr>
<td>commit 전에 꼭 필요</td>
<td><code>BEFORE_COMMIT</code></td>
</tr>
</tbody></table>
<h3 id="transactionaleventlistener-사용해서-구현하기">TransactionalEventListener 사용해서 구현하기</h3>
<p>afterCommit doc을 읽어 보면, NOTE에 사용 팁이 간단하게 나와있다. </p>
<pre><code class="language-java">default void afterCommit() {
}</code></pre>
<pre><code class="language-java">@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code></pre>
<p>문서를 요약해보면, </p>
<p><code>&quot;data access code will still participate in original transaction&quot;</code>
이 부분을 보면, 그대로 해석을 해본다면 기존 트랜잭션에서 DB save 코드를 구현한다면 기존 트랜잭션에 참여를 한다는 것이다. 이게 무슨 뜻이냐면, 
트랜잭션이 끝나도 DB 커넥션과 영속성 컨텍스트는 살아있다는 것이다. </p>
<p>즉 이 <code>afterCommit</code>을 사용한다면 안에서 <code>repository.save()</code>를 하면, 실제로 commit이 되지 않으며, DB에 반영이 안될 수도 있다. </p>
<p>그래서 note 부분에서는 <code>PROPAGATION_REQUIRES_NEW</code>를 사용하는 것을 추천한다. AFTER_COMMIT 안에서 DB 작업을 하게 되면 commit이 안될 수 있기 때문이다.</p>
<h3 id="propagation_requires_new"><code>PROPAGATION_REQUIRES_NEW</code></h3>
<h4 id="propagation">propagation</h4>
<p><code>@Transactional</code> 안에서는 기존 트랜잭션이 있고, 또 다른 트랜잭션이 있을 수 있다. propagtion은 기존 트랜잭션이 있을 때, 어떻게 행동할지를 정하는 옵션이다. 트랜잭션에 같이 들어갈지, 새로운 트랜잭션을 만들지, 트랜잭션 없이 실행할지 등의 처리 방식을 결정하는 것이다.</p>
<p>종류가 여러가지 있는데, 자주 쓰이는 것은 세 개 정도이다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>REQUIRED</code></td>
<td>있으면 참여, 없으면 생성</td>
</tr>
<tr>
<td><code>REQUIRES_NEW</code></td>
<td>무조건 새로 생성</td>
</tr>
<tr>
<td><code>NESTED</code></td>
<td>중첩 트랜잭션처럼 처리</td>
</tr>
</tbody></table>
<p><code>REQUIRES_NEW</code>를 여기서 왜 써야 하냐면, 지금 독서 기록 저장 &amp; 이미지를 S3에 저장이라는 트랜잭션 두개가 있다. 여기서는 <code>REQUIRED</code> 처럼 있으면 참여하면 롤백이 되지 않는다. 무조건 새로 만들어야 한다!</p>
<p>afterCommit은 실행 타이밍을 제어해서, 메인 트랜잭션이 성공한 뒤에 실행하는 것을 보장해 주는 것이라면,
<code>REQUIRES_NEW</code> 옵션을 붙이면 <code>@TransactionalEventListener</code> 안의 DB작업을 새로운 트랜잭션으로 따로 commit을 하게 해준다. 즉 독립적으로 commit 및 rollback을 해주는 것이다. </p>
<p>최종적으로 두 옵션을 같이 사용해 줘야 한다. 그리고 delete이벤트를 매개변수로 받는 메서드를 만들어서 사용을 해준다. </p>
<p><code>ApplicationEventPublisher</code>는 스프링 내부 이벤트를 발행하는 객체다. 스프링에 이벤트가 발생했음을 알리면, 해당 이벤트를 듣고 있는 리스너들이 실행되는 방식이다. </p>
<p><code>eventPublisher.publishEvent()</code> : 이벤트가 발생했음을 알리는 메서드이다. </p>
<pre><code class="language-java">private final ApplicationEventPublisher eventPublisher;</code></pre>
<pre><code class="language-java">public record RecordDeletedEvent(  
        Long recordId,  
        List&lt;String&gt; imageKeys  
) {  
}</code></pre>
<pre><code class="language-java">// 이벤트가 발행되면 실행하는 리스너 
@Slf4j  
@Component  
@RequiredArgsConstructor  
public class RecordDeletedEventListener {  

    private final PresignedUrlService presignedUrlService;  

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)  
    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    public void handle(RecordDeletedEvent event) {  
        for (String imageKey : event.imageKeys()) {  
            try {  
                presignedUrlService.deleteFile(imageKey);  
            } catch (Exception e) {  
                log.warn(&quot;[RECORD] R2 스토리지에서 기록 이미지 삭제 실패 recordId={}, key={}&quot;, event.recordId(), imageKey, e);  
            }  
        }  
    }  
}</code></pre>
<pre><code class="language-java">// 실제 삭제 처리
public void deleteRecord(  
        RecordDeleteEvent event  
) {  
    // record 조회, 삭제 대상 
    Record record = recordRepository.findById(event.recordId())  
            .orElseThrow(() -&gt; new CustomException(RecordErrorCode.RECORD_NOT_FOUND));  
    // 권한 체크
    if (!record.getLibrary().getUser().getId().equals(event.userId())) {  
        throw new CustomException(RecordErrorCode.RECORD_NOT_AUTHORIZED);  
    }  

  // 삭제할 이미지 key 추출
    List&lt;String&gt; keysToDelete = record.getImages().stream()  
            .map(RecordImage::getKey)  
            .filter(Objects::nonNull)  
            .toList();  

    // DB에서 삭제 
    record.getImages().clear();  
    recordRepository.delete(record);  


    // 기록 삭제 이벤트 발행
    eventPublisher.publishEvent(new RecordDeletedEvent(event.recordId(), keysToDelete));  
}</code></pre>
<p>삭제 처리를 하는 <code>deleteRecord</code> 메서드에서 기록 삭제 이벤트가 발행되면, <code>RecordDeletedEventListener</code>에서 <code>RecordDeletedEvent</code>를 listen하고 있으므로 실행이 된다. </p>
<p>이렇게 하면 메인 트랜잭션이 성공적으로 commit된 이후 실행되며, 기존 트랜잭션과는 완전히 분리된 채로 싱행이 된다. </p>
<h3 id="번외---transactional-주요-속성">번외 - <code>@Transactional</code> 주요 속성</h3>
<table>
<thead>
<tr>
<th>속성</th>
<th>의미</th>
<th>핵심 역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>propagation</code></td>
<td>기존 트랜잭션과 관계</td>
<td>참여/분리 여부</td>
</tr>
<tr>
<td><code>isolation</code></td>
<td>트랜잭션 간 데이터 격리 수준</td>
<td>동시성 제어</td>
</tr>
<tr>
<td><code>timeout</code></td>
<td>실행 시간 제한</td>
<td>장기 트랜잭션 방지</td>
</tr>
<tr>
<td><code>readOnly</code></td>
<td>읽기 전용 여부</td>
<td>성능 최적화</td>
</tr>
<tr>
<td><code>rollbackFor</code></td>
<td>롤백 기준 예외 지정</td>
<td>롤백 정책 제어</td>
</tr>
<tr>
<td><code>noRollbackFor</code></td>
<td>롤백 제외 예외</td>
<td>예외 커스터마이징</td>
</tr>
</tbody></table>
<p>보통 <code>readOnly</code>를 제일 많이 사용하고, <code>rollbackFor</code>, <code>propagation</code>은 간간히 사용한다. </p>
<h4 id="readonly"><code>readOnly</code></h4>
<p>읽기 전용 트랜잭션으로, 더티 체킹을 하지 않으므로 읽기 전용이라면 성능이 향상된다. 단 이 트랜잭션 안에서 쓰기를 하게 된다면 적용이 되지 않는다. </p>
<p>예를 들어서.. JPA 내부 동작 방식은 다음과 같다.</p>
<ol>
<li>엔티티 조회</li>
<li>엔티티 값 변경</li>
<li>트랜잭션 끝나기 직전</li>
<li>JPA가 변경 감지</li>
<li>UPDATE SQL 실행</li>
<li>commit</li>
</ol>
<p>여기서 4,5번 변경감지 -&gt; UPDATE 실행 이 단계가 flush라고 한다. 영속성 컨텍스트에 쌓여 있던 변경 내용을, DB에 SQL문으로 반영을 하는 과정이다. 즉 커밋 직전에 flush가 일어난다. </p>
<p>더티 체킹은 JPA가 관리 중인 엔티티를 보고 있다가, 트랜잭션이 끝날 때 원래 값과 비교를 하게 된다.</p>
<p>이때 달라지만 UPDATE 쿼리를 날리는데, 이걸 더티 체킹이라고 한다. 직접 SQL문을 날리지 않아도 변경 사항을 자동 반영을 해주는 것이다. </p>
<p>그런데 단순 조회만 하는 메서드에서 이 변경 감지를 하는 것은 쓸모가 없지 않느냐는 것이다. </p>
<p>그래서 <code>readOnly=true</code>로 설정을 해준다면, 이 트랜잭션이 수정이 없을 것이라고 믿고, 쓰기 기능을 최대한 줄이는 방식으로 가서 flush mode가 덜 적극적으로 사용이 된다. </p>
<p>그래서 우리가 조회 메서드에서는 <code>readOnly=true</code>로 해주고, 쓰기 메서드에서는 생략하는 것이다. </p>
<p>근데 그렇다고 아예 이 옵션 안에서 쓰기가 불가능하냐.. 그건 아니다. </p>
<p>그렇지만 트랜잭션 안에서 쓰기를 수행하지 않겠다 라고 하는 것이기 때문에, 별도 트랜잭션에서 호출, JDBC 직접 실행, native query 직접 실행.. 이러한 방법들로 우회하는 것이라면 또 가능할 수는 있다. 그렇지만 권장되는 것은 아니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] BFS, DFS 문제 풀기]]></title>
            <link>https://velog.io/@jayaione_ele/Java-BFS-DFS-%EB%AC%B8%EC%A0%9C-%ED%92%80%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Java-BFS-DFS-%EB%AC%B8%EC%A0%9C-%ED%92%80%EA%B8%B0</guid>
            <pubDate>Mon, 23 Mar 2026 09:41:44 GMT</pubDate>
            <description><![CDATA[<h3 id="1-타겟-넘버---프로그래머스">1. 타겟 넘버 - 프로그래머스</h3>
<p><strong>내 코드</strong> 
dfs 재귀로 구현했다. 
index가 순서대로이기 때문에 1씩 증가해서 재귀를 하고, sum은 플러스와 마이너스 두 경우 다 계산하도록 해서 
numbers 배열의 숫자를 다 사용했을 때 &amp;&amp; sum이 target에 도달했을 때 이렇게 두 경우에 count를 증가하고 종료하도록 구현했다. 
어려웠던 점: 종료 조건 설정을 잘 못하는 것 같다.</p>
<pre><code class="language-java">class Solution {
    static int count = 0;
    public int solution(int[] numbers, int target) {
        dfs(numbers,0,0,target);
        return count;
    }

    public static void dfs(int[] numbers, int index, int sum, int target){
        // 종료 조건 설정
        if (index == numbers.length){
            if(sum == target){
                count++;
            }
            return;
        }

        dfs(numbers, index+1, sum + numbers[index], target);
        dfs(numbers, index+1, sum - numbers[index], target);
    }
}</code></pre>
<p><strong>더 나은 방법</strong></p>
<ol>
<li><p>return 방식 dfs를 사용하자 -&gt; 항상 static 변수를 사용했었는데 이 문제에서는 return 방식으로 해야 전역 변수 count에 의존하지 않을 수 있음</p>
<pre><code class="language-java">     return dfs(numbers, index+1, sum + numbers[index], target)
          + dfs(numbers, index+1, sum - numbers[index], target);</code></pre>
</li>
<li><p>BFS로 하는 방법 - 더 복잡한데 나은 방법은 아님
큐 사용해서 현재 인덱스, 현재합 상태를 큐에 넣는 방식으로</p>
</li>
<li><p>잘 쓴 풀이 분석하기</p>
<pre><code class="language-java">class Solution {
 public int solution(int[] numbers, int target) {
     int answer = 0;
     answer = dfs(numbers, 0, 0, target);
     return answer;
 }
 int dfs(int[] numbers, int n, int sum, int target) {
     if(n == numbers.length) {
         if(sum == target) {
             return 1;
         }
         return 0;
     }
     return dfs(numbers, n + 1, sum + numbers[n], target) + dfs(numbers, n + 1, sum - numbers[n], target);
 }
}</code></pre>
</li>
</ol>
<ul>
<li>전역으로 변수를 두지 않음 </li>
<li>return 방식으로 dfs 돌림 -&gt; 내 코드는 백트래킹 방식인데, 상태 변경하고 다시 되돌리는 방식으로 하기 때문에 이 문제에서는 return dfs 방식이 더 적합. 개수만 구하기 때문에</li>
</ul>
<h3 id="2-촌수-계산---백준">2. 촌수 계산 - 백준</h3>
<p><strong>내 코드</strong> </p>
<pre><code class="language-java">import java.util.*;
import java.io.*;

class Main {
    static boolean[] visited;
    static List&lt;Integer&gt;[] graph;
    static int answer = -1;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());
        StringTokenizer st = new StringTokenizer(br.readLine());
        int target1 = Integer.parseInt(st.nextToken()) - 1;
        int target2 = Integer.parseInt(st.nextToken()) - 1;
        int count = Integer.parseInt(br.readLine());

        // 사람 인접리스트로 만들기
        graph = new ArrayList[n];
        for(int i = 0; i &lt; n; i++){
            graph[i] = new ArrayList&lt;&gt;();
        }
        for(int i = 0; i &lt; count; i++){
            StringTokenizer stz = new StringTokenizer(br.readLine());
            int p1 = Integer.parseInt(stz.nextToken());
            int p2 = Integer.parseInt(stz.nextToken());
            graph[p1-1].add(p2-1);
            graph[p2-1].add(p1-1);
        }

        visited = new boolean[n];

        int depth = 0;
        dfs(target1,target2,depth);

        System.out.println(answer);

    }

    public static void dfs(int node, int target, int depth){
        if (node == target){
            answer = depth;
            return;
        }
        visited[node] = true;
        for(int nextNode : graph[node]){
            if(!visited[nextNode]){
                dfs(nextNode,target,depth+1);
            }
        }
    }
}
</code></pre>
<p>어려웠던 점: 인접리스트로 풀어본적이 처음이라서 어떻게 풀어야할지 좀 어려웠다. 촌수를 depth를 넘겨주는 방식으로 해서 풀었다. </p>
<pre><code>[사람] -&gt; [사람1,사람2,사람3] </code></pre><h3 id="3-게임-맵-최단거리">3. 게임 맵 최단거리</h3>
<pre><code>import java.util.*;
class Solution {
    static int[] dx = {1,-1,0,0};
    static int[] dy = {0,0,1,-1};
    static int n,m;
    public int solution(int[][] maps) {
        n = maps.length;
        m = maps[0].length;
        boolean [][] visited = new boolean[n][m];
        int answer = bfs(0,0,visited,maps);
        return answer;
    }

    public int bfs(int x, int y,boolean[][] visited,int[][] maps) {
        Queue&lt;int[]&gt; q = new LinkedList&lt;&gt;();
        // x, y, 거리 저장
        q.add(new int[]{x,y,1});
        // 시작 지점은 방문처리
        visited[x][y] = true; 
        while(!q.isEmpty()){
            // 현재 상태 처리
            int [] cur = q.poll();
            int curx = cur[0];
            int cury = cur[1];
            int dist = cur[2];

            // 범위 검사 조건: 도착했을 시
            if(curx == n - 1 &amp;&amp; cury == m - 1) {
                return dist;
            }

            // 4방향으로 탐색
            for(int i = 0; i &lt; 4;i++){
                int nx = curx + dx[i];
                int ny = cury + dy[i];

                if (nx &gt;= 0 &amp;&amp; ny&gt;=0 &amp;&amp; nx&lt;n &amp;&amp; ny&lt;m &amp;&amp; maps[nx][ny] == 1 &amp;&amp; !visited[nx][ny]){
                    // 방문 처리
                    visited[nx][ny] = true;
                    // 상태 전이
                    q.add(new int[]{nx,ny,dist+1});
                } 
            }            
        }
        return -1;
    }
}</code></pre><p>어려웠던 점: 방향으로 나누어져 있을 때 4방향 탐색이랑 이런 식으로 배열 만들어서 현재 좌표 계산하는 식으로 하는 걸 잘 몰랐어서 어려웠다. </p>
<pre><code>    static int[] dx = {1,-1,0,0};
    static int[] dy = {0,0,1,-1};</code></pre><h3 id="4반복수열---백준">4.반복수열 - 백준</h3>
<pre><code class="language-java">import java.util.*;
import java.io.*;

class Main {
    static List&lt;Integer&gt; numbers = new ArrayList&lt;&gt;();
    static int result = 0;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        int a = Integer.parseInt(st.nextToken());
        int p = Integer.parseInt(st.nextToken());
        numbers.add(a);
        int next = toNextNum(a,p);
        dfs(next,p);
        System.out.println(result);
    }

    public static void dfs(int next, int p) {
        if(numbers.contains(next)){
            result = numbers.indexOf(next);
            return;
        }
        numbers.add(next);
        dfs(toNextNum(next,p),p);
    }

    public static int toNextNum(int a, int p){
        String[] nums = String.valueOf(a).split(&quot;&quot;);
        int sum = 0;
        for(String next : nums){
            int thisN = 1;
            for(int i = 0; i &lt; p; i++){
                thisN*=Integer.parseInt(next);
            }
            sum+=thisN;
        }
        return sum;
    }
}</code></pre>
<p>푼 방법: </p>
<ul>
<li>리스트에서 수열 숫자 저장하고 리스트에 있는것들 중에 나오면 반복 시작이므로 종료</li>
<li>toNextNum 메서드에서 다음 수열 숫자 계산</li>
</ul>
<p>개선 방법:</p>
<ol>
<li>contains, indexOf은 각각 O(n)이므로 최악의 경우 O(n^2)이다 ..</li>
</ol>
<ul>
<li><p>해결: HashMap을 사용해서 수열 숫자, 인덱스 이렇게 저장하면 O(n)이다. </p>
<pre><code class="language-java">public static void dfs(int current, int p) {
  int next = toNextNum(current, p);

  if (map.containsKey(next)) {
      result = map.get(next);
      return;
  }

  map.put(next, map.size());
  dfs(next, p);
}</code></pre>
</li>
</ul>
<ol start="2">
<li><p>문제: split으로 하고있는데 문자열처리 말고 계산으로 처리. 나머지연산자 사용하면 된다. 이건 그냥 순수 수학 문제 느낌</p>
<pre><code class="language-java">public static int toNextNum(int a, int p){
 int sum = 0;

 while (a &gt; 0) {
     int digit = a % 10;
     int pow = 1;

     for (int i = 0; i &lt; p; i++) {
         pow *= digit;
     }

     sum += pow;
     a /= 10;
 }

 return sum;
}</code></pre>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Redis를 활용한 캐싱]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-Redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%BA%90%EC%8B%B1</link>
            <guid>https://velog.io/@jayaione_ele/Spring-Redis%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%BA%90%EC%8B%B1</guid>
            <pubDate>Wed, 25 Feb 2026 19:57:39 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/41475b36-0815-453d-bf26-28d8c646a520/image.png" alt=""></p>
<h2 id="📚-nook-월별-통계-redis-캐싱-도입기">📚 NOOK 월별 통계 Redis 캐싱 도입기</h2>
<h3 id="캐싱-고민-배경">캐싱 고민 배경</h3>
<p>독서 집중 및 기록 서비스 <strong>NOOK</strong>에서는
홈 화면에 월별 독서 통계를 제공한다.</p>
<p>서재 월별 통계 API는 다음과 같은 특징을 가진다.</p>
<ul>
<li>포커스 기록은 로그 형태로 계속 쌓임 (삭제는 거의 없음)</li>
<li>홈 진입 시마다 월별 통계 API 호출 가능</li>
<li>월 통계는 <code>group by</code>, <code>sum</code> 집계 연산 필요</li>
<li>현재 월은 계속 변경됨</li>
<li>지난 월은 거의 변경되지 않음 (서재에서 책을 삭제하는 경우만 변경)</li>
</ul>
<p>즉, <strong>읽기 연산은 반복되고, 쓰기 연산은 비교적 적은 구조</strong>이다.</p>
<p>이 특성 때문에 단순 DB 조회만으로 운영하기에는
장기적으로 비효율적일 수 있다고 판단했다.</p>
<p>그래서 캐싱을 도입하기로 결정했고,
그 전에 캐싱 전략을 정리해보기로 했다.</p>
<hr>
<h2 id="캐싱-전략-조사">캐싱 전략 조사</h2>
<p>캐싱 전략은 대표적으로 다음과 같다.</p>
<h3 id="cache-aside-lazy-loading">Cache-Aside (Lazy Loading)</h3>
<ul>
<li>조회 시 캐시 확인</li>
<li>없으면 DB 조회 후 캐시에 저장</li>
<li>데이터 변경 시 캐시 무효화</li>
</ul>
<p>✔ 읽기 트래픽이 많고
✔ 데이터 변경이 자주 일어나지 않을 때 적합</p>
<p>→ 통계 API에 가장 일반적인 방식</p>
<hr>
<h3 id="write-through">Write-Through</h3>
<ul>
<li>DB 저장 시 캐시도 함께 저장</li>
<li>조회는 항상 캐시에서만 수행</li>
</ul>
<p>✔ 데이터 정합성이 매우 중요할 때
✔ 캐시 미스를 최소화하고 싶을 때 사용</p>
<hr>
<h3 id="write-back">Write-Back</h3>
<ul>
<li>Redis에 먼저 반영</li>
<li>일정 시간 후 DB에 반영</li>
</ul>
<p>✔ 실시간 랭킹, 조회수, 좋아요 카운트 등에 적합
✔ 트래픽이 매우 많을 때 사용</p>
<hr>
<h3 id="ttl-기반-캐싱">TTL 기반 캐싱</h3>
<ul>
<li>일정 시간이 지나면 자동 만료</li>
<li>가장 단순한 전략</li>
</ul>
<hr>
<p>NOOK의 월 통계는:</p>
<ul>
<li>실시간성보다 &quot;빠른 조회&quot;가 중요</li>
<li>삭제/수정이 거의 없음</li>
<li>집계 비용 존재</li>
</ul>
<p>따라서 <strong>Cache-Aside 전략 + TTL + 이벤트 기반 무효화</strong>가 적합하다고 판단했다.</p>
<hr>
<h3 id="캐싱-도입-기준-점검">캐싱 도입 기준 점검</h3>
<p>캐싱을 적용해도 되는 요구사항인지 먼저 확인했다.</p>
<p>다음 조건을 만족하면 캐싱을 적용하는 것이 좋다.</p>
<ul>
<li>데이터 변경이 자주 발생하지 않을 때</li>
<li>조회 트래픽이 반복적으로 발생할 때</li>
<li>집계 연산 비용이 존재할 때</li>
</ul>
<p>NOOK의 월 통계는 위 조건을 만족한다.</p>
<p>특히 포커스 기록은 로그 형태로 쌓이는 구조이기 때문에
수정/삭제가 거의 없다는 점이 캐싱에 매우 유리하다.</p>
<hr>
<h3 id="로컬-캐시-vs-글로벌-캐시redis">로컬 캐시 vs 글로벌 캐시(Redis)</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>로컬 캐시</th>
<th>Redis</th>
</tr>
</thead>
<tbody><tr>
<td>저장 위치</td>
<td>JVM 메모리</td>
<td>외부 인메모리 서버</td>
</tr>
<tr>
<td>속도</td>
<td>매우 빠름</td>
<td>빠름</td>
</tr>
<tr>
<td>서버 여러 대</td>
<td>불일치 발생 가능</td>
<td>공유 가능</td>
</tr>
<tr>
<td>블루그린 배포</td>
<td>캐시 초기화됨</td>
<td>유지 가능</td>
</tr>
</tbody></table>
<p>NOOK는 <strong>블루그린 배포 전략</strong>으로 갈 예정이었기 때문에
글로벌 캐시인 <strong>Redis</strong>를 선택했다.</p>
<p>이유:</p>
<ul>
<li>배포 시 캐시 유지 가능</li>
<li>향후 서버 확장 대비 가능</li>
<li>캐시 무효화 일관성 유지 가능</li>
</ul>
<hr>
<h2 id="최종-캐싱-전략">최종 캐싱 전략</h2>
<h3 id="전략-선택">전략 선택</h3>
<ul>
<li>Cache-Aside 적용</li>
<li>Redis 사용</li>
<li>월에 따라 TTL 차등 적용</li>
<li>이벤트 기반 캐시 무효화</li>
</ul>
<hr>
<h3 id="현재-월">현재 월</h3>
<ul>
<li>포커스가 계속 추가됨</li>
<li>자주 변경됨</li>
<li>TTL: 3~5분</li>
<li>포커스 생성/삭제 시 해당 월만 evict</li>
</ul>
<pre><code class="language-java">@Cacheable(
    value = &quot;libraryMonthlyCurrent&quot;,
    key = &quot;#userId + &#39;:&#39; + #yearMonth&quot;
)</code></pre>
<hr>
<h2 id="🔹-지난-월">🔹 지난 월</h2>
<ul>
<li>거의 변경 없음</li>
<li>조회는 반복 가능</li>
<li>TTL: 1시간~24시간</li>
<li>서재 삭제 시에만 evict</li>
</ul>
<hr>
<h3 id="무효화-전략">무효화 전략</h3>
<ul>
<li>포커스 생성 / 삭제 / 수정 → 현재 월 캐시만 evict</li>
<li>서재 삭제 → 해당 월(또는 전체 월) 캐시 evict</li>
</ul>
<hr>
<h3 id="cachemanager-구성">CacheManager 구성</h3>
<p>캐시별 TTL을 다르게 설정하기 위해
RedisCacheManager를 커스터마이징했다.</p>
<pre><code class="language-java">@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {

        RedisCacheConfiguration defaultConfig =
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(5));

        Map&lt;String, RedisCacheConfiguration&gt; cacheConfigs = new HashMap&lt;&gt;();

        cacheConfigs.put(&quot;libraryMonthlyCurrent&quot;,
                defaultConfig.entryTtl(Duration.ofMinutes(3)));

        cacheConfigs.put(&quot;libraryMonthlyPast&quot;,
                defaultConfig.entryTtl(Duration.ofHours(24)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}</code></pre>
<hr>
<h3 id="집계-테이블-고민">집계 테이블 고민</h3>
<p>트래픽이 증가할 경우를 대비해 집계 테이블을 도입하는 것도 고려했다. </p>
<ul>
<li>원본 로그 (focus)</li>
<li>월 집계 테이블 (user_monthly_stats)</li>
<li>일자별 책 집계 테이블 (user_daily_book_stats)</li>
</ul>
<p>하지만 현재 트래픽 규모에서는
<strong>캐시 기반 집계로 충분하다고 판단하여 보류</strong>했다.</p>
<p>향후 트래픽 증가 시, </p>
<p>로컬 캐시+레디스 캐시를 혼합해서 사용하는 방식도 고려할 예정이다. </p>
<hr>
<h2 id="느낀-점">느낀 점</h2>
<p>통계 관련 API를 개발하면서, 당연히 캐시를 도입해야겠지 라고만 생각하고 있었다. 
그렇지만 생각보다 고려할 게 많았다. </p>
<ul>
<li>데이터 변경 빈도</li>
<li>월별 데이터 특성</li>
<li>배포 전략</li>
<li>서버 확장 가능성</li>
<li>무효화 정책</li>
</ul>
<p>까지 고려해야 한다는 점을 알게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Paging 및 Stream/for 비교분석]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-Paging-%EB%B0%8F-Streamfor-%EB%B9%84%EA%B5%90%EB%B6%84%EC%84%9D</link>
            <guid>https://velog.io/@jayaione_ele/Spring-Paging-%EB%B0%8F-Streamfor-%EB%B9%84%EA%B5%90%EB%B6%84%EC%84%9D</guid>
            <pubDate>Tue, 02 Dec 2025 11:55:57 GMT</pubDate>
            <description><![CDATA[<h2 id="1-page와-slice-비교">1. Page와 Slice 비교</h2>
<h3 id="1-1-page와-slice-출력값-비교">1-1. Page와 Slice 출력값 비교</h3>
<h4 id="page-사용-시-출력-json-예시">Page 사용 시 출력 (JSON 예시)</h4>
<p>Page는 전체 데이터 개수, 전체 페이지 수, 현재 페이지 정보, 정렬 정보 등 추가적인 메타데이터를 모두 포함한다.</p>
<pre><code class="language-json">{
  &quot;content&quot;: [
    { &quot;id&quot;: 1, &quot;title&quot;: &quot;post1&quot; },
    { &quot;id&quot;: 2, &quot;title&quot;: &quot;post2&quot; }
  ],
  &quot;pageable&quot;: {
    &quot;pageNumber&quot;: 0,
    &quot;pageSize&quot;: 10
  },
  &quot;totalElements&quot;: 132,
  &quot;totalPages&quot;: 14,
  &quot;last&quot;: false,
  &quot;size&quot;: 10,
  &quot;number&quot;: 0,
  &quot;sort&quot;: {},
  &quot;numberOfElements&quot;: 10,
  &quot;first&quot;: true,
  &quot;empty&quot;: false
}</code></pre>
<h4 id="slice-사용-시-출력-json-예시">Slice 사용 시 출력 (JSON 예시)</h4>
<p>Slice는 content + 다음 페이지 존재 여부만 알려주며, 전체 개수와 전체 페이지 수는 제공하지 않는다.</p>
<pre><code class="language-json">{
  &quot;content&quot;: [
    { &quot;id&quot;: 1, &quot;title&quot;: &quot;post1&quot; },
    { &quot;id&quot;: 2, &quot;title&quot;: &quot;post2&quot; }
  ],
  &quot;pageable&quot;: {
    &quot;pageNumber&quot;: 0,
    &quot;pageSize&quot;: 10
  },
  &quot;size&quot;: 10,
  &quot;number&quot;: 0,
  &quot;sort&quot;: {},
  &quot;numberOfElements&quot;: 10,
  &quot;first&quot;: true,
  &quot;last&quot;: false,
  &quot;empty&quot;: false
}</code></pre>
<hr>
<h3 id="1-2-page와-slice-각각의-장단점">1-2. Page와 Slice 각각의 장단점</h3>
<h4 id="page-장점">Page 장점</h4>
<ul>
<li>전체 데이터 개수(<code>totalElements</code>) 제공</li>
<li>전체 페이지 수(<code>totalPages</code>) 제공</li>
<li>페이지 네비게이션 UI 구현 용이</li>
<li>검색/관리자 화면에 적합</li>
</ul>
<h4 id="page-단점">Page 단점</h4>
<ul>
<li>count 쿼리가 추가 실행됨 → 성능 비용</li>
<li>대용량 데이터에서 병목 가능</li>
</ul>
<h4 id="slice-장점">Slice 장점</h4>
<ul>
<li>count 쿼리 없음 → 빠르고 가벼움</li>
<li>다음 페이지 존재 여부만 체크</li>
<li>무한 스크롤 UI에 최적화</li>
</ul>
<h4 id="slice-단점">Slice 단점</h4>
<ul>
<li>전체 데이터 개수 제공 불가</li>
<li>전체 페이지 수 제공 불가</li>
<li>페이지 버튼 형태 UI에는 부적합</li>
</ul>
<hr>
<h3 id="1-3-page-slice-적용-기준">1-3. Page, Slice 적용 기준</h3>
<h4 id="page-추천-상황">Page 추천 상황</h4>
<ul>
<li>전체 페이지 수가 필요한 경우</li>
<li>페이지 번호 UI (1, 2, 3 …)</li>
<li>관리자 페이지</li>
<li>검색 결과 페이지</li>
</ul>
<h4 id="slice-추천-상황">Slice 추천 상황</h4>
<ul>
<li>무한 스크롤 기반 UI</li>
<li>모바일 피드 (인스타그램, 유튜브 등)</li>
<li>전체 개수가 필요 없고 성능이 중요한 경우</li>
</ul>
<hr>
<h2 id="2-for-vs-stream-비교">2. For vs Stream 비교</h2>
<h3 id="2-1-for과-stream-작동-방식">2-1. for과 stream 작동 방식</h3>
<h4 id="for문-예시-sum-계산">for문 예시 (sum 계산)</h4>
<pre><code class="language-java">int sum = 0;
for (int i : list) {
    sum += i;
}</code></pre>
<h4 id="stream-예시-sum-계산">Stream 예시 (sum 계산)</h4>
<pre><code class="language-java">int sum = list.stream()
              .mapToInt(i -&gt; i)
              .sum();</code></pre>
<hr>
<h4 id="filter-비교">filter 비교</h4>
<p><strong>for문</strong></p>
<pre><code class="language-java">List&lt;Integer&gt; result = new ArrayList&lt;&gt;();
for (int i : list) {
    if (i % 2 == 0) result.add(i);
}</code></pre>
<p><strong>Stream</strong></p>
<pre><code class="language-java">List&lt;Integer&gt; result = list.stream()
                           .filter(i -&gt; i % 2 == 0)
                           .toList();</code></pre>
<hr>
<h4 id="성능-테스트-요약-10만-건-기준">성능 테스트 요약 (10만 건 기준)</h4>
<ul>
<li>for문이 대부분 더 빠름</li>
<li>stream은 람다 호출 및 내부 처리 과정의 오버헤드 존재</li>
<li>parallelStream은 CPU 코어 활용 시 유리할 수 있으나,<ul>
<li>스레드 전환 비용</li>
<li>공유 자원 접근 시 동기화 문제 때문에 오히려 느려질 수 있음</li>
</ul>
</li>
</ul>
<hr>
<h3 id="2-2-for와-stream-장단점">2-2. for와 stream 장단점</h3>
<h4 id="for문-장점">for문 장점</h4>
<ul>
<li>성능 가장 우수</li>
<li>단순하고 직관적</li>
<li>디버깅 쉬움</li>
</ul>
<h4 id="for문-단점">for문 단점</h4>
<ul>
<li>코드 길어짐</li>
<li>가독성이 떨어질 수 있음</li>
<li>조건/누적 로직 많아지면 복잡해짐</li>
</ul>
<h4 id="stream-장점">Stream 장점</h4>
<ul>
<li>코드 간결하고 가독성 우수</li>
<li>선언형 프로그래밍 방식 → 유지보수 용이</li>
<li>필터/매핑 등 데이터 변환에 최적화</li>
<li>병렬 처리 (parallelStream) 가능</li>
</ul>
<h4 id="stream-단점">Stream 단점</h4>
<ul>
<li>오버헤드 존재 → 성능 손해</li>
<li>디버깅 어려움</li>
<li>작은 연산에서는 for문보다 느림</li>
<li>병렬 사용 시 스레드 이슈 발생 가능</li>
</ul>
<hr>
<h3 id="2-3-for와-stream-적용-기준">2-3. for와 stream 적용 기준</h3>
<h4 id="for문이-좋은-경우">for문이 좋은 경우</h4>
<ul>
<li>성능 최우선</li>
<li>반복문 내부에서 복잡한 로직 필요</li>
<li>디버깅이 자주 필요한 경우</li>
</ul>
<h4 id="stream이-좋은-경우">Stream이 좋은 경우</h4>
<ul>
<li>가독성이 중요한 비즈니스 로직</li>
<li>filter, map 등 변환 중심 처리</li>
<li>함수형 프로그래밍 스타일 유지</li>
<li>대량 데이터에서 병렬 처리 가능할 때</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 트랜잭션 & 동시성 이슈 처리]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@jayaione_ele/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Wed, 19 Nov 2025 18:27:49 GMT</pubDate>
            <description><![CDATA[<p>UMC 4주차 시니어 미션 진행합니다. </p>
<h3 id="2️⃣-트랜잭션--동시성-이슈-처리"><strong>2️⃣ 트랜잭션 &amp; 동시성 이슈 처리</strong></h3>
<p><strong>하나의 트랜잭션에서 여러 엔티티를 처리하는 비즈니스 로직 작성</strong></p>
<ul>
<li>예) <code>Member</code>가 탈퇴할 경우 <strong>관련된 모든 데이터를 삭제하는 API</strong> 구현</li>
<li><code>@Transactional</code>을 적용하고, <code>@Modifying</code>을 활용하여 <strong>Batch Delete 쿼리 최적화</strong></li>
<li><strong>동시성 문제가 발생할 수 있는 시나리오</strong>를 고민하고 해결책 적용<ul>
<li>예) 같은 회원이 동시에 같은 <code>Store</code>를 찜하려고 할 때 중복이 발생하지 않도록 <code>@Lock</code> 사용</li>
<li>다양한 락킹 전략에 대해 공부해보고, 이를 정리하기</li>
</ul>
</li>
</ul>
<ol>
<li><code>Member</code>가 탈퇴할 경우 <strong>관련된 모든 데이터를 삭제하는 API</strong> 구현</li>
</ol>
<p>Controller</p>
<pre><code class="language-java">    @DeleteMapping(&quot;/me&quot;)
    @Operation(summary = &quot;회원 탈퇴&quot;,
            description = &quot;로그인한 본인 계정을 탈퇴 처리합니다. 계정 정보는 30일 후에 자동 삭제됩니다.&quot;)
    public ApiResponse&lt;String&gt; withdrawMe(@AuthenticationPrincipal CustomUserDetails userDetails) {
        String msg = userService.withdrawUser(userDetails.getUser());
        return ApiResponse.onSuccess(msg,SuccessCode.OK);
    }</code></pre>
<p>Repository</p>
<p>기존 구현 방식은 다음과 같다. 30일이 지나면 삭제되도록 구현을 해놓은 상태인데, <code>@Modifying</code> 을 사용해서 구현했다. </p>
<pre><code class="language-java">    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query(&quot;delete from User u &quot; +
            &quot;where u.deletedAt is not null &quot; +
            &quot;and   u.deletedAt &lt;= :threshold &quot; +
            &quot;and   u.status = umc.nook.users.domain.Status.INACTIVE&quot;)
    int hardDeleteUsersOlderThan(@Param(&quot;threshold&quot;) LocalDateTime threshold);</code></pre>
<ul>
<li><p><code>clearAutomatically = true</code> : 해당 옵션은, 쿼리 실행 후에 1차 캐시를 비운다. 즉 DELETE 쿼리를 반영하고 나서 entityManager.clear()를 자동으로 호출한다. 즉 메모리가 초기화된다.</p>
<p>  유저1을 조회하는 레포지토리 메서드를 실행 → 메모리에 캐싱되어서 유저1 정보가 아직 남아있다.</p>
<p>  해당 메서드 실행 → 캐시를 비움</p>
<p>  유저1을 조회하는 메서드를 다시 실행 → 아무 값도 반환되지 않는다. </p>
<p>  → userId가 1 인 사용자를 삭제하고 싶을 때, 먼저 해당 사용자가 존재하는지 조회를 해야 한다. 사용자를 조회하고, 삭제하면 캐시가 남아있으면 안되기 때문에, 데이터 수정 뒤에 바로 적용되고자 할 때 사용하는 옵션이다. </p>
</li>
<li><p><code>flushAutomatically = true</code> : DELETE 쿼리 실행 전에, pending 변경사항을 DB에 먼저 반영한다. 즉 먼저 자동으로 UPDATE 쿼리가 실행되고, 그 다음에 DELETE를 실행한다.</p>
</li>
</ul>
<p>두 옵션 다 사용하면 ?</p>
<ul>
<li>flush() : pending 변경사항을 DB에 반영한다.</li>
<li>user delete 쿼리를 실행한다.</li>
<li>clear() : 1차 캐시를 비운다.</li>
</ul>
<p>→ 변경사항이 있으면 반영하고, 삭제 후에 메모리를 초기화하도록 한다. </p>
<p><strong>동시성 문제가 발생할 수 있는 시나리오를 고민하고 해결책 적용</strong></p>
<p>요구사항 : 통화 기능을 구현하면서, 통화 종료 시에 메모리에서 관리되는 통화 세션을 동시에 접근하지 않도록 해야 한다. 즉 사용자가 여러 브라우저에서 접근하는 경우에는 통화를 한쪽에서만 관리하도록 설정해야 한다.</p>
<ol>
<li>StartCall, EndCall 메서드에서 synchronized 를 사용했다. </li>
</ol>
<pre><code class="language-java">    public synchronized void endCall(String userId) {
        CallSession session = activeCalls.get(userId);
        if (session == null) {
            log.warn(&quot;[CallManager-endCall] FAILED: No session found for userId={}&quot;, userId);
            return;
        }

        String callerId = session.getCallerId();
        String receiverId = session.getReceiverId();
        Status statusBeforeEnd = session.getCurrentStatus();

        session.markEnded();
        activeCalls.remove(callerId);
        activeCalls.remove(receiverId);

        log.info(&quot;[CallManager-endCall] SESSION REMOVED: caller={}, receiver={}, lastStatus={}, endTime={}&quot;,
                callerId, receiverId, statusBeforeEnd, LocalDateTime.now());
        log.info(&quot;[CallManager-endCall] ACTIVE SESSIONS NOW: {}&quot;, activeCalls.size());
    }</code></pre>
<ol>
<li><p><code>ReentrantLock</code> 사용 : 자바 메모리 락</p>
<p> CallManger 클래스 안에 존재하는 메서드들이다. 메모리에서 통화 정보를 관리하기 때문에, 메모리에서 스레드를 직접 제어하고자 해서 사용했다. </p>
<ol>
<li><p><code>lock.lock();</code>  : 락을 획득 </p>
</li>
<li><p>try 블록은 임계 영역으로, 해당 코드를 실행하는 동안 lockd이 걸려있는 것이다. 종료 후에 finally 블록으로 진입하면, unlock()을 해준다. </p>
<pre><code class="language-java">private final ReentrantLock lock = new ReentrantLock();
public void markConnected() {
         lock.lock(); // 락을 획득 
         try {
             if (this.connectedTime != null) {
                 log.warn(&quot;[CallSession-markConnected] Already connected at {}, ignoring duplicate call&quot;,
                         this.connectedTime);
                 return;
             }
             this.connectedTime = LocalDateTime.now();
             Status oldStatus = this.currentStatus;
             this.currentStatus = Status.IN_CALL;
             cancelTimeout();
             log.info(&quot;[CallSession-markConnected] CONNECTED: caller={}, receiver={}, connectedTime={}, status: {} -&gt; {}&quot;,
                     callerId, receiverId, this.connectedTime, oldStatus, Status.IN_CALL);
         } finally {
             lock.unlock();
         }
     }

     public void markTimedOut() {
         lock.lock();
         try {
             this.wasTimedOut = true;
             Status oldStatus = this.currentStatus;
             this.currentStatus = Status.MISSED;
             log.warn(&quot;[CallSession-markTimedOut] TIMED OUT after {}s: caller={}, receiver={}, status: {} -&gt; {}&quot;,
                     CALL_TIMEOUT_SECONDS, callerId, receiverId, oldStatus, Status.MISSED);
         } finally {
             lock.unlock();
         }
     }

     public void markRejected() {
         lock.lock();
         try {
             this.wasRejected = true;
             Status oldStatus = this.currentStatus;
             this.currentStatus = Status.REJECTED;
             log.info(&quot;[CallSession-markRejected] REJECTED: caller={}, receiver={}, status: {} -&gt; {}&quot;,
                     callerId, receiverId, oldStatus, Status.REJECTED);
         } finally {
             lock.unlock();
         }
     }

     public void markCancelled() {
         lock.lock();
         try {
             this.wasCancelled = true;
             Status oldStatus = this.currentStatus;
             this.currentStatus = Status.CANCELLED;
             log.info(&quot;[CallSession-markCancelled] CANCELLED: caller={}, receiver={}, status: {} -&gt; {}&quot;,
                     callerId, receiverId, oldStatus, Status.CANCELLED);
         } finally {
             lock.unlock();
         }
     }

     public void markEnded() {
         lock.lock();
         try {
             Status oldStatus = this.currentStatus;
             this.currentStatus = Status.ENDED;
             log.info(&quot;[CallSession-markEnded] ENDED: caller={}, receiver={}, status: {} -&gt; {}&quot;,
                     callerId, receiverId, oldStatus, Status.ENDED);
         } finally {
             lock.unlock();
         }
     }
</code></pre>
</li>
</ol>
</li>
</ol>
<p><strong>다른 락킹 전략 종류</strong></p>
<ol>
<li><p>Pessimistic Write Lock (비관적 락)</p>
<p> 조회 시에 보통 사용하는 락으로, 행을 잠그고, 다른 스레드의 진입을 차단한다. </p>
<p> 사용 방법</p>
<pre><code class="language-java">         @Lock(LockModeType.PESSIMISTIC_WRITE)
     @Query(&quot;SELECT w FROM Wishlist w WHERE w.user.id = :userId AND w.store.id = :storeId&quot;)
     Optional&lt;Wishlist&gt; findByUserAndStoreWithWriteLock(
             @Param(&quot;userId&quot;) Long userId,
             @Param(&quot;storeId&quot;) Long storeId);</code></pre>
<p> 가게 찜 리스트를 조회하려고 할 때, 이 시점에 조회쿼리를 다른 스레드에서 실행하려는 접근이 있더라도 막아준다. 다음과 같은 SQL문이 실행된다. </p>
<p> 장점: 충돌이 없고, 순서가 보장된다. </p>
<p> 단점 : 스레드2는 스레드1의 조회 쿼리가 끝날때까지 대기하기 때문에 성능이 저하되거나 데드락 상황이 발생할 수 있다. </p>
<pre><code class="language-sql"> -- 스레드1
 SELECT w.* FROM wishlist w 
 WHERE w.user_id = 1 AND w.store_id = 100 
 FOR UPDATE;  -- 해당 행을 LOCK

 -- 스레드2
 SELECT w.* FROM wishlist w 
 WHERE w.user_id = 1 AND w.store_id = 100 
 FOR UPDATE;  -- 대기 중

 -- 스레드1 커밋
 COMMIT;

 -- 스레드2 획득 → 계속 진행</code></pre>
</li>
<li><p>Pessimistic Read Lock (비관적 읽기 락)</p>
<p> 쓰기 중에 읽기를 하지 못하도록 하는 락이다. 읽기가 많고, 쓰기 충돌이 적을 때 사용한다. </p>
</li>
<li><p>Optimistic Lock (낙관적 락) </p>
<p> 충돌 시에만 처리하는 락이다. 엔티티 코드에서 보통 사용한다. wishlist 엔티티에 다음 코드를 추가하면, </p>
<p> WishList 를 save하는 작업 실생 시, version이 자동으로 증가한다. 만약 다른 스레드에서 먼저 저장할 경우, 이 버전 필드가 충돌하기 때문에 저장되지 못하도록 <code>OptimisticLockingFailureException</code> 을 던진다.</p>
</li>
</ol>
]]></description>
        </item>
    </channel>
</rss>