<?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, 07 Apr 2026 18:01:38 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/0aeb9d18-b0bf-456f-8e75-24c8a97a0398/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[[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] API & Swagger & Annotation]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-API-Swagger-Annotation</link>
            <guid>https://velog.io/@jayaione_ele/Spring-API-Swagger-Annotation</guid>
            <pubDate>Tue, 25 Nov 2025 10:35:27 GMT</pubDate>
            <description><![CDATA[<p><strong>미션 목표:</strong></p>
<ul>
<li><strong>@DynamicInsert, @DynamicUpdate</strong> 어떻게 작동되는 지 파악하고 장단점을 파악 후에 언제 <strong>적용</strong>하면 좋을 지 파악해보기</li>
<li><strong>Rest Docs</strong>가 무엇인지 알아보고 <strong>Swagger</strong>와 장단점 파악하기</li>
</ul>
<p><strong>미션 상세 내용:</strong></p>
<h3 id="1️⃣-dynamicinsert와-dynamicupdate가-어떻게-작동되는-지-파악하기">1️⃣ @DynamicInsert와 @DynamicUpdate가 어떻게 작동되는 지 파악하기</h3>
<ul>
<li>기존 JPA 쿼리 문이 어떻게 만들어지는 지 알아보기</li>
<li>@DynamicInsert, @DynamicUpdate 적용 시 어떻게 바뀌는 지 알아보기</li>
</ul>
<h3 id="2️⃣-기존과-dynamicinsert-dynamicupdate-적용-시-장단점-파악하기">2️⃣ 기존과 @DynamicInsert, @DynamicUpdate 적용 시 장단점 파악하기</h3>
<ul>
<li>찾아본 원리를 토대로 서로의 장단점 적어보기</li>
</ul>
<h3 id="3️⃣-언제-적용하면-좋을-지-파악하기">3️⃣ 언제 적용하면 좋을 지 파악하기</h3>
<ul>
<li>장단점을 토대로 언제 @DynamicInsert, @DynamicUpdate를 적용하면 좋을 지 적기<ul>
<li>시간이 되신다면 테스트 용 엔티티를 만들어서 시간까지 측정해보면 좋을 것 같습니다. 그 후 정말 쓸모가 있는 지도 파악해보면 좋을 것 같습니다. (이것은 시니어 분들 포함 필수가 아닙니다.)</li>
</ul>
</li>
</ul>
<h3 id="1️⃣-rest-docs가-무엇인지-알아보기">1️⃣ Rest Docs가 무엇인지 알아보기</h3>
<ul>
<li>Rest Docs가  무엇인지 검색해보기</li>
</ul>
<h3 id="2️⃣-swagger와-rest-docs의-장단점-비교하기">2️⃣ Swagger와 Rest Docs의 장단점 비교하기</h3>
<ul>
<li>Swagger와 Rest Docs의 장단점 적어보기</li>
</ul>
<h3 id="3️⃣-언제-적용하면-좋을-지-파악하기-1">3️⃣ 언제 적용하면 좋을 지 파악하기</h3>
<ul>
<li>장단점을 토대로 언제 Swagger와 Rest Docs를 적용하면 좋을 지 적기<ul>
<li>시간이 되신다면 Rest Docs를 실제로 적용해보고 테스트 코드를 적어본 후 문서를 생성해보며 직접 경험해본 후에 적어봐도 좋을 것 같습니다. (이것은 Rest Docs를 적용하는 데도 시간이 많이 걸릴 수 있기에 시니어 분들 포함 필수가 아닙니다.)</li>
</ul>
</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>
        <item>
            <title><![CDATA[[Spring] JPA 연관관계 매핑, 최적화 적용]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@jayaione_ele/Spring-JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Wed, 19 Nov 2025 18:26:20 GMT</pubDate>
            <description><![CDATA[<p>UMC 4주차 시니어 미션 진행합니다. </p>
<p><strong>성능을 고려한 연관관계 매핑 &amp; 최적화 적용</strong></p>
<ul>
<li><strong><code>@OneToMany</code> 컬렉션을 조회할 때 <code>List&lt;MemberPrefer&gt;</code>를 <code>Set&lt;MemberPrefer&gt;</code>로 변경 후 차이점 분석</strong></li>
<li><strong>데이터 정합성을 고려하여 <code>orphanRemoval = true</code>가 필요한 곳 확인 후 적용</strong></li>
</ul>
<hr>
<h3 id="성능을-고려한-연관관계-매핑--최적화-적용"><strong>성능을 고려한 연관관계 매핑 &amp; 최적화 적용</strong></h3>
<p><strong>List vs Set 비교해보기</strong></p>
<ul>
<li><p>List - 순서 보장</p>
<ul>
<li><p>일반적으로 순서가 보장되어, 클라이언트에 저장 순서대로 그대로 응답할 때에 편리하고 직관적임</p>
</li>
<li><p>게시글과 같이 최신순으로 저장하고 조회하는 요구사항이 일반적일 경우 List를 사용</p>
</li>
<li><p>List 타입 컬렉션을 2개 이상 Fetch join할 경우, 예외가 발생함  :<strong>MultipleBagFetchException 발생</strong></p>
<ul>
<li>Bag: 순서가 없으며 중복도 허용하는 컬렉션</li>
<li>Hibernatesms List를 내부적으로 Bag으로 취급하는데, 카타시안 곱 문제가 생길 수 있음 → user</li>
<li><strong>Hibernate 내부에서 리스트를 중복 제거 없이 매핑하려 할 때, 조인 결과가 증가</strong></li>
</ul>
</li>
<li><p>Featch join은 꼭 필요한 연관 객체를 1개만 사용하도록 하고, 나머지는 @BatchSize를 사용하여 지연 로딩 시에 N+1문제를 완화하거나, 전역으로 batch_fetch_size를 설정하는 방법이 있음</p>
</li>
<li><p>다음과 같이 해결 가능</p>
<pre><code class="language-java">  @BatchSize(size = 100)
  @OneToMany(mappedBy = &quot;user&quot;, cascade = CascadeType.REMOVE)
  private List&lt;UserCategory&gt; userCategories;</code></pre>
</li>
</ul>
</li>
<li><p>Set - 순서 보장 안됨</p>
<ul>
<li>순서가 보장이 안되어있으므로, 순서 구분이 무의미할 때 사용해도 된다.</li>
<li>중복이 안되기 때문에 중복을 자동으로 방지해야 할 때 사용하면 좋다.</li>
<li>List 타입 컬렉션을 2개 이상 Fetch join할 경우, 예외가 발생함  :<strong>MultipleBagFetchException 발생한다고 했는데.. Set으로 하면 중복제거가 자동으로 되기 때문에, 해당 예외가 발생하지 않는다.</strong></li>
</ul>
</li>
<li><p><strong>orphanRemoval = true</strong></p>
<pre><code class="language-java">
   @BatchSize(size = 100)
   @OneToMany(mappedBy = &quot;user&quot;, orphanRemoval = true)
   private List&lt;UserCategory&gt; userCategories;</code></pre>
<p>  연관관계가 끊긴 엔티티에 대해서 REMOVE 작업을 진행하고, 전파할 지에 대한 옵션</p>
<p>  true로 설정해두면, 연관관계가 끊어진 고아 객체를 자동으로 삭제, 부모 엔티티가 삭제되지 않아도, 연관관계만 끊으면 삭제되는 옵션이다.</p>
<p>  다음과 같이 연관관계를 끊으면, usercategory가 DB에서 삭제된다. </p>
<pre><code class="language-java">  // 연관관계 끊기
  user.getUserCategories().remove(userCategory);</code></pre>
</li>
<li><p>cascade = CascadeType.REMOVE와의 차이?</p>
<pre><code class="language-java">  // 부모 삭제 -&gt; 해당 user를 부모로 가지고 있는 userCategory가 모두 DELETE됨
  userRepository.delete(user); 

  // 연관관계만 끊을 경우에는 DB에 여전히 남아있음</code></pre>
<p>  연관관계를 끊는것만으로는 삭제가 안됨, 부모 엔티티가 삭제될 때 자식도 함께 삭제된다. </p>
</li>
<li><p>orphanRemoval = true  사용해야 하는 곳</p>
<ul>
<li><p>userCategory같은 중간 테이블에서 사용해야 한다. 카테고리와 user 간의 연관관계가 끊기면 삭제하도록 해야 한다. PERSIST 사용 시 자동 저장되도록 하기 때문에 일반적으로 둘 다 사용한다.</p>
<pre><code class="language-java">  @OneToMany(mappedBy = &quot;user&quot;, 
             cascade = CascadeType.PERSIST, 
             orphanRemoval = true)
  private List&lt;UserCategories&gt; userCategories = new ArrayList&lt;&gt;();</code></pre>
</li>
<li><p>중간 테이블의 삭제 흐름</p>
<p>  유저 조회 → 카테고리 조회 → 선호 카테고리 삭제 </p>
</li>
</ul>
</li>
</ul>
<pre><code>```java
SELECT * FROM user WHERE id = 1;
SELECT * FROM category WHERE id = 5;
// 삭제 호출 시, 지연로딩이 된다. 
SELECT * FROM user_category WHERE user_id = 1;
```

여기서 만약에 BatchSize를 안쓰면, user가 100명이라고 할 때 각 유저의 유저 카테고리를 조회하면..

**BatchSize 사용 안할경우**

```java
-- 1번: User 조회
SELECT * FROM user;  -- 100명

-- N번: 각 User의 UserCategory 조회
SELECT * FROM user_category WHERE user_id = 1;   -- User 1
SELECT * FROM user_category WHERE user_id = 2;   -- User 2
SELECT * FROM user_category WHERE user_id = 3;   -- User 3
...
SELECT * FROM user_category WHERE user_id = 100; -- User 100

-- 총 101번의 쿼리 발생, N+1 문제..
```

user 조회를 하고, 각 유저의 연관 데이터도 조회를 하게 된다. 

- `@BatchSize` 를 사용한다면..?</code></pre><p> batchsize는 n개씩 묶어서 조회를 할거다라고 명시를 해주는 어노테이션이다. 즉 batchsize가 0이라고 하면, 10명씩 묶어서 IN 쿼리를 사용해서 조회하게 된다. User를 접근한다고 할 때, 위의 쿼리처럼 하나하나씩 접근하지 않고 한번에 쿼리를 날리게 된다. </p>
<pre><code>**BatchSize사용할 경우**</code></pre><pre><code class="language-sql">User 1-10  → SELECT ... WHERE user_id IN (1,2,3,...,10)
User 11-20 → SELECT ... WHERE user_id IN (11,12,...,20)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] ControllerAdvice 웹훅 연결해서 알림받기  ]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-ControllerAdvice-%EC%9B%B9%ED%9B%85-%EC%97%B0%EA%B2%B0%ED%95%B4%EC%84%9C-%EC%95%8C%EB%A6%BC%EB%B0%9B%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Spring-ControllerAdvice-%EC%9B%B9%ED%9B%85-%EC%97%B0%EA%B2%B0%ED%95%B4%EC%84%9C-%EC%95%8C%EB%A6%BC%EB%B0%9B%EA%B8%B0</guid>
            <pubDate>Tue, 18 Nov 2025 09:27:45 GMT</pubDate>
            <description><![CDATA[<p>UMC 7주차 시니어 미션입니다. </p>
<h3 id="환경변수-설정">환경변수 설정</h3>
<h4 id="운영-서버">운영 서버</h4>
<pre><code class="language-yml">app:
  notification:
    discord:
      webhook-url: ${DISCORD_WEBHOOK_URL}
      enabled: true
    slack:
      webhook-url: ${SLACK_WEBHOOK_URL}
      enabled: false
    alert-env: prod</code></pre>
<h4 id="개발-서버">개발 서버</h4>
<pre><code class="language-yml">app:
  notification:
    discord:
      webhook-url: ${DISCORD_WEBHOOK_URL}
      enabled: true
    slack:
      webhook-url: ${SLACK_WEBHOOK_URL}
      enabled: false
    alert-env: dev</code></pre>
<h3 id="notificationservice-구현">NotificationService 구현</h3>
<h4 id="1-500에러-감지">1. 500에러 감지</h4>
<pre><code class="language-java">    public void notifyError(String requestUrl, Exception exception, String method) {
        // 현재 환경이 알림 대상 환경인지 확인
        if (!isAlertTargetEnv()) {
            log.debug(&quot;Alert is disabled for environment: {}&quot;, currentEnv);
            return;
        }

        String errorMessage = formatErrorMessage(requestUrl, exception, method);

        if (discordEnabled &amp;&amp; !discordWebhookUrl.isBlank()) {
            sendToDiscord(errorMessage);
        }

        if (slackEnabled &amp;&amp; !slackWebhookUrl.isBlank()) {
            sendToSlack(errorMessage);
        }
    }</code></pre>
<h4 id="2-discordslack으로-에러메세지를-전송하는-메서드">2. Discord/Slack으로 에러메세지를 전송하는 메서드</h4>
<pre><code class="language-java">    /**
     * Discord로 에러 메시지 전송
     */
    private void sendToDiscord(String errorMessage) {
        try {
            Map&lt;String, Object&gt; payload = new HashMap&lt;&gt;();
            payload.put(&quot;content&quot;, errorMessage);

            restTemplate.postForObject(discordWebhookUrl, payload, String.class);
            log.info(&quot;Discord notification sent successfully&quot;);
        } catch (RestClientException e) {
            log.error(&quot;Failed to send Discord notification&quot;, e);
        } catch (Exception e) {
            log.error(&quot;Unexpected error while sending Discord notification&quot;, e);
        }
    }

    /**
     * Slack으로 에러 메시지 전송
     */
    private void sendToSlack(String errorMessage) {
        try {
            Map&lt;String, Object&gt; payload = new HashMap&lt;&gt;();
            Map&lt;String, Object&gt; attachments = new HashMap&lt;&gt;();

            attachments.put(&quot;color&quot;, &quot;danger&quot;);
            attachments.put(&quot;text&quot;, errorMessage);

            payload.put(&quot;attachments&quot;, new Object[]{attachments});

            restTemplate.postForObject(slackWebhookUrl, payload, String.class);
            log.info(&quot;Slack notification sent successfully&quot;);
        } catch (RestClientException e) {
            log.error(&quot;Failed to send Slack notification&quot;, e);
        } catch (Exception e) {
            log.error(&quot;Unexpected error while sending Slack notification&quot;, e);
        }
    }</code></pre>
<h4 id="3-에러-메세지를-포맷팅">3. 에러 메세지를 포맷팅</h4>
<pre><code class="language-java">
    /**
     * 에러 메시지 포맷팅
     */
    private String formatErrorMessage(String requestUrl, Exception exception, String method) {
        LocalDateTime now = LocalDateTime.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;);

        String exceptionName = exception.getClass().getSimpleName();
        String exceptionMessage = exception.getMessage() != null ? exception.getMessage() : &quot;No message&quot;;

        // 스택 트레이스의 첫 번째 라인 추출
        String stackTrace = &quot;&quot;;
        if (exception.getStackTrace().length &gt; 0) {
            StackTraceElement element = exception.getStackTrace()[0];
            stackTrace = String.format(&quot;%s.%s (Line %d)&quot;, 
                    element.getClassName(), element.getMethodName(), element.getLineNumber());
        }

        return String.format(
                &quot;[500 ERROR ALERT]\n&quot; +
                &quot;====================================\n&quot; +
                &quot;Time: %s\n&quot; +
                &quot;Environment: %s\n&quot; +
                &quot;Request URL: %s\n&quot; +
                &quot;Method: %s\n&quot; +
                &quot;Exception: %s\n&quot; +
                &quot;Message: %s\n&quot; +
                &quot;Location: %s\n&quot; +
                &quot;====================================&quot;,
                now.format(formatter),
                currentEnv.toUpperCase(),
                requestUrl,
                method,
                exceptionName,
                exceptionMessage,
                stackTrace
        );
    }
</code></pre>
<h4 id="4-현재-환경이-알림-대상인지-확인">4. 현재 환경이 알림 대상인지 확인</h4>
<pre><code class="language-java">    /**
     * 현재 환경이 알림 대상인지 확인
     */
    private boolean isAlertTargetEnv() {
        if (alertEnv == null || alertEnv.isBlank()) {
            return false;
        }
        String[] envs = alertEnv.split(&quot;,&quot;);
        for (String env : envs) {
            if (env.trim().equals(currentEnv)) {
                return true;
            }
        }
        return false;
    }</code></pre>
<h3 id="exceptionadvice-파일-수정">ExceptionAdvice 파일 수정</h3>
<pre><code class="language-java"> private final NotificationService notificationService;</code></pre>
<p>500 에러를 처리하는 로직에서 알림 전송 로직을 추가한다. </p>
<pre><code class="language-java">    // 모든 미처리 예외 → 500 
    @ExceptionHandler(Exception.class)
    public ResponseEntity&lt;Object&gt; handleUnknownException(Exception e, HttpServletRequest request) {
        log.error(&quot;Unhandled exception&quot;, e);

        // 요청 정보 추출
        String requestUrl = request.getRequestURI();
        String method = request.getMethod();

        // Discord/Slack으로 알림 전송
        notificationService.notifyError(requestUrl, e, method);

        ApiResponse&lt;Object&gt; body = ApiResponse.onFailure(ErrorCode.INTERNAL_SERVER_ERROR, null);
        WebRequest webRequest = new ServletWebRequest(request);
        return handleExceptionInternal(e, body, new HttpHeaders(),
                ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus(), webRequest);
    }
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] QueryDSL 검색 기능 구현하기]]></title>
            <link>https://velog.io/@jayaione_ele/Spring-QueryDSL-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/Spring-QueryDSL-%EA%B2%80%EC%83%89-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 11 Nov 2025 12:56:43 GMT</pubDate>
            <description><![CDATA[<p>UMC 시니어 미션 6주차 과제입니다.</p>
<h2 id="미션-개요"><strong>미션 개요</strong></h2>
<p>프로젝트가 너무 잘되어서, PM님이 날뛰고 계십니다.</p>
<p>기존 기능에서, 가게를 검색하는 기능을 추가하신다고 합니다!!</p>
<p>사용자가 원하는 가게 정보를 쉽고 정확하게 찾을 수 있도록 <strong>검색 API</strong>를 설계하고 구현해야합니다.</p>
<p>검색 기능은 <strong>지역 필터</strong>, <strong>이름 검색</strong>, <strong>정렬 조건</strong>, <strong>페이징</strong>을 지원해야 합니다.</p>
<hr>
<h4 id="1-1-필터링"><strong>1-1. 필터링</strong></h4>
<ul>
<li><p>지역(<code>region</code>) 기반 필터링 가능</p>
</li>
<li><p>예: 강남구, 도봉구, 영등포구 등</p>
<p>(원하시는 분들은 다중 선택 가능 기능도 추가해보세요!!) </p>
</li>
</ul>
<h4 id="1-2-이름-검색"><strong>1-2. 이름 검색</strong></h4>
<ul>
<li>검색어 띄어쓰기에 따라 검색 로직이 달라집니다.<ul>
<li><strong>공백 포함 검색어</strong>: 각 단어가 포함된 가게의 <strong>합집합</strong> 조회<ul>
<li>예: <code>&#39;민트 초코&#39;</code> → <code>&#39;민트&#39;</code> 포함 가게 + <code>&#39;초코&#39;</code> 포함 가게</li>
</ul>
</li>
<li><strong>공백 없는 검색어</strong>: 검색어 전체가 포함된 가게만 조회<ul>
<li>예: <code>&#39;민트초코&#39;</code> → <code>&#39;민트초코&#39;</code> 포함 가게만 조회</li>
</ul>
</li>
</ul>
</li>
</ul>
<h4 id="1-3-정렬-조건"><strong>1-3. 정렬 조건</strong></h4>
<ul>
<li><code>latest</code> : 최신순</li>
<li><code>name</code> : 이름순<ul>
<li>정렬 우선순위: <strong>가나다 → 영어 대문자 → 영어 소문자 → 특수문자</strong></li>
<li>이름이 동일한 경우: <strong>최신순</strong>으로 정렬</li>
</ul>
</li>
</ul>
<h4 id="1-4-페이징"><strong>1-4. 페이징</strong></h4>
<ul>
<li>기본 페이징: <code>page</code> + <code>size</code></li>
<li>원하면 <strong>커서 기반 페이징</strong>도 지원</li>
</ul>
<hr>
<h3 id="필요한-querydsl-문법-공부하기">필요한 QueryDSL 문법 공부하기</h3>
<h4 id="querydsl-세팅">queryDSL 세팅</h4>
<p>두 가지 방법이 있다. </p>
<ul>
<li><p>JpaQueryFactory를 bean으로 등록</p>
</li>
<li><p>Config class 따로 생성
config class 따로 생성하기</p>
<pre><code class="language-java">@Configuration
public class QueryDslConfig {

  @Bean
  public JPAQueryFactory jpaQueryFactory(EntityManager em) {
      return new JPAQueryFactory(em);
  }
}
</code></pre>
</li>
</ul>
<p>// Repository에서 주입받아서 사용
private final JpaQueryFactory queryFactory;</p>
<pre><code>생성없이 하기
```java
JpaQueryFactory queryFactory = new JPAQueryFactory(em);

// Q클래스 정의 

queryFactory
                .selectFrom(QUser.user) // from절
                .where(QUser.user.age.gt(20)) // where절
                .fetch(); 실행 </code></pre><h4 id="where절-조건-생성---booleanbuilder">where절 조건 생성 - BooleanBuilder</h4>
<pre><code class="language-java">// 기본 형태
BooleanBuilder builder = new BooleanBuilder();

// and 조건 추가
builder.and(store.region.eq(&quot;강남구&quot;));

// or 조건 추가
builder.or(store.region.eq(&quot;서초구&quot;));

// not 조건
builder.and(store.active.eq(true).not());

// andAllOf() , 여러 조건을 한번에 or 
// orAllOf()도 마찬가지
builder.andAllOf(
    store.region.eq(&quot;강남구&quot;),
    store.rating.goe(4.0)
    );

// hasValue() - builder가 비어있지 않은지 확인
if (builder.hasValue()) {
    // 조건이 있음
}
// 일반적으로 사용방식: 조건이 입력되었으면 추가
if(condition.getRating() ! = null)
if(StringUtils.hasText(condition.getName()) 
// 이런식으로, 입력되어있으면 추가하는 방식으로 구현

// 초기값을 설정
BooleanBuilder builder = new BooleanBuilder(store.active.eq(true));

// 사용 방법
.where(builder)</code></pre>
<p>공백 처리 방법 </p>
<pre><code class="language-java">// 공백을 처리하기
BooleanBuilder nameBuilder = new BooleanBuilder();
// 먼저 name이 공백을 포함하는지를 확인하기
String[] keywords = name.split(&quot; &quot;);
for (String keyword : keywords) {
}</code></pre>
<h4 id="casebuilder-사용해보기">caseBuilder 사용해보기</h4>
<p>SELECT문에서 CASE WHEN을 java에서 사용할 수 있게 해준다. </p>
<pre><code class="language-java">// 기본 사용방법
new CaseBuilder()
    .when(조건1).then(값1)      // 조건1 만족 → 값1
    .when(조건2).then(값2)      // 조건2 만족 → 값2
    .when(조건3).then(값3)      // 조건3 만족 → 값3
    .otherwise(기본값)          // 아무 조건도 안 만족 → 기본값

// 반드시 SELECT에 포함되어야 함!</code></pre>
<h4 id="expressions-사용하기">Expressions 사용하기</h4>
<p>sql 문법을 querydsl에서 바로 사용할 수 없기 때문에, 사용할 수 있게 해준다. 
Expressions.stringTemplate() 방식으로 사용한다. </p>
<pre><code class="language-java">// REPLACE 함수
Expressions.stringTemplate(
    &quot;replace({0}, &#39; &#39;, &#39;&#39;)&quot;,
    store.name
)
// 의미: replace(name, &#39; &#39;, &#39;&#39;)

// CONCAT 함수
Expressions.stringTemplate(
    &quot;concat({0}, {1})&quot;,
    store.region.name,
    store.name
)
// 의미: concat(region_name, name)</code></pre>
<h4 id="projectionsconstructor">Projections.constructor</h4>
<p>Sql 결과를 DTO로 반환한다. 자주 사용되는 방식이다.</p>
<pre><code class="language-java">Projections.constructor(Dto.class, ...)

 // 사용 방법

            .select(Projections.constructor(
                StoreSearchDto.class,
                store.id,
                store.name,
                store.region,
                store.rating,
                new CaseBuilder()  // CaseBuilder
                    .when(store.rating.goe(4.5)).then(&quot;최고&quot;)
                    .when(store.rating.goe(4.0)).then(&quot;좋음&quot;)
                    .when(store.rating.goe(3.0)).then(&quot;보통&quot;)
                    .otherwise(&quot;나쁨&quot;)
                    .as(&quot;ratingLabel&quot;)
            ))</code></pre>
<h4 id="정렬-구현하기">정렬 구현하기</h4>
<pre><code class="language-java">// 단순 정렬. 다양한 조건 추가 못함
.orderBy(store.createdAt.desc())

// OrderSpecifier 사용
OrderSpecifier&lt;?&gt; orderSpec = switch(sortBy) {
    case &quot;name&quot; -&gt; new OrderSpecifier&lt;&gt;(order, sotre.name);
    default -&gt; new OrderSpecifier&lt;&gt;(Order.DESC, store.createdAt);
};

// 다중 정렬해보기. 우선순위 순서대로 넣기
return queryFactory
        .selectFrom(store)
        .orderBy(
            new OrderSpecifier&lt;&gt;(Order.ASC, store.region),     // 지역순
            new OrderSpecifier&lt;&gt;(Order.DESC, store.rating),    // 그 다음 평점순
            new OrderSpecifier&lt;&gt;(Order.DESC, store.createdAt)  // 그 다음 최신순
        )
        .fetch();
}

// 적용하기
return queryFactory
        .selectFrom(store)
        .orderBy(orderSpec)
        .fetch();</code></pre>
<h4 id="page-vs-slice">page vs slice</h4>
<p>정렬,조건설정 등등을 해줬으니.. 이제 페이징을 처리해줘야 한다. </p>
<h5 id="page">page</h5>
<p>page로 할 경우, 전체 개수를 포함해야 하기 때문에 Count(*) 별도 쿼리가 추가되어서, 성능이 낭비된다. 
이런 식으로 fetchCount(), fetch() 총 두번의 쿼리가 실행된다.</p>
<pre><code class="language-java">// count 별도 쿼리가 추가됨
queryFactory
        .selectFrom(store)
        .where(...)
        .offset(0)
        .limit(20)
        .fetchCount();</code></pre>
<h5 id="slice">slice</h5>
<p>slice는 다음 페이지가 있는지 여부만을 체크함</p>
<ul>
<li>limit을 size+1로 설정</li>
<li>count 쿼리를 별도 실행하지 않음</li>
<li>전체 개수를 알 필요 없는 경우</li>
</ul>
<pre><code class="language-java">List&lt;store&gt; stores = queryFactory
        .selectFrom(store)
        .where(...)
        .offset(0)
        .limit(21) // size+1개 조회

boolean hasNext = stores.size() &gt; 20;
// 21개 이상이 조회됐다면, 다음 페이지가 있다는 뜻이므로 조회, 이하면 그대로 반환
List&lt;Store&gt; result = hasNext ? stores.subList(0,20) : stores;</code></pre>
<h4 id="fetch">fetch</h4>
<pre><code class="language-java">// fetch() 결과 여러 개, List 반환
List&lt;Store&gt; stores = queryFactory
        .selectFrom(store)
        .fetch();

// fetchOne() 단일 객체만 반환
Store store = queryFactory
        .selectFrom(store)
        .where(store.id.eq(1))
        .fetchOne()

// fetchFirst() 첫 번째 객체만
// fetchCount() 개수만, long 타입

// exists() 존재 여부, boolean 타임
boolean exists = queryFactory
        .selectFrom(store)
        .where(store.region.eq(&quot;강남&quot;))
        .select(Expressions.ONE)
        .fetchFirst() != null;</code></pre>
<h4 id="paging-관련-dto-생성하기">paging 관련 DTO 생성하기</h4>
<p>offset 기반의 경우</p>
<pre><code class="language-java">@Data
@Builder
public class PagingResponse&lt;T&gt; {
    private List&lt;T&gt; content; // 데이터
    private int page; //현재 페이지
    private int size; // 페이지 크기
    private long total; // 전체 개수
    private long totalPages; // 전체 페이지
    private boolean hasNext; // 다음 페이지 존재 여부
}
</code></pre>
<p>cursor 기반</p>
<pre><code class="language-java">@Data
@Builder
public class SliceResponse&lt;T&gt; {
        private List&lt;T&gt; content;
        private boolean hasNext;
        private int currentPage;
        private int size;
}</code></pre>
<hr>
<h3 id="최종-코드-작성">최종 코드 작성</h3>
<p>이제 필요한 문법을 찾아봤으니, 요구사항에 기반에 최종 코드를 작성해 보았다. 
DTO 정의</p>
<pre><code class="language-java">// DTO
@Data
@AllArgsConstructor
public class StoreResponseDTO {
    private Long id;
    private String name;
    private String regionName;      
    private Double rating;
    private LocalDateTime createdAt;
}

// PageResponse
@Data
@Builder
public class CursorPageResponse&lt;T&gt; {
    private List&lt;T&gt; content;      // 데이터
    private String nextCursor;    // 다음 조회 시 사용할 커서
    private boolean hasNext;      // 다음 페이지가 있는지
}</code></pre>
<p>레포지토리 코드 작성하기(Controller 코드는 생략)</p>
<pre><code class="language-java">// 검색 조건 설정
@Getter
@Builder
public class SearchCondition {
    private List&lt;Long&gt; regions;
    private String query;
    private String sort;
}

public class StoreCustomRepositoryImpl extends StoreCustomRepository {

private final JpaQueryFactory queryFactory;

public PageResponse&lt;StoreResponseDTO&gt; searchStoreByCondition(SearchCondition condition, Long cursor, int size) {

    QRegion region = QRegion.Region;
    QStore store = QStore.Store;


    BooleanBuilder builder = new BooleanBuilder();

    // 지역 ID 다중 필터링
    if(condition.getRegions!=null &amp;&amp; !condition.getRegions().isEmpty()) {
            builder.and(store.region.id.in(condition.getRegions());
    }

    if(StringUtils.hasText(condition.getQuery()) {
            // 앞뒤 공백 제거
            String query = condition.getQuery().trim();

            BooleanBuilder nameBuilder = new BooleanBuilder();

            // 전체 공백을 제거해서 검색
            String withOutSpace = query.replace(&quot; &quot;,&quot;&quot;);
            nameBuilder.or(
                Expresstions.stringTemplate(
                    &quot;replace({0},&#39; &#39;, &#39;&#39;)&quot;,
                    store.name // name에 공백을 제거하는 함수
                ).contains(withOutSpace) // LIKE 조건 추가,%검색어%
            );
            // 공백으로 구분된 단어를 각각 검색
            if(query.contains(&quot; &quot;)) {
                    // 공백 기준으로 키워드를 나눠서 저장, or 조건으로 저장
                    String[] keywords = query.split(&quot; &quot;);
                    for (String keyword : keywords) {
                        nameBuilder.or(store.name.contains(keyword));
                    } 
            }
            else {
                // 공백이 없으면 그냥 검색
                nameBuilder.or(store.name.contains(name));
            }

                builder.and(nameBuilder);
        }


        // 커서 기반 페이징 : cursor보다 큰 ID만 조회
        if(cursor != null) {
            builder.and(store.id.gt(cursor));
        }

        // 정렬 우선순위: 가나다 → 영어 대문자 → 영어 소문자 → 특수문자
        OrderSpecifier&lt;?&gt; typeSort = new OrderSpecifier&lt;&gt;(
            Order.ASC,
            new CaseBuilder()
                .when(store.name.mathes(&quot;[가-힣].*&quot;)).then(0)
                .when(store.name.matches(&quot;[A-Z].*&quot;)).then(1)
                .when(store.name.matches(&quot;[a-z].*&quot;)).then(2)
                .otherwise(3)  // 특수문자
            );
        // size+1을 조회하는 쿼리
        List&lt;StoreResponseDTO&gt; stores = queryFactory
            .select(Projections.constructor(
                StoreResponseDTO.class,
                store.id,
                store.name,
                store.rating,
                store.createdAt))
                .from(store)
                .innerJoin(store.region, region) // 지역이 있는 가게만 join
                .where(builder)
                .orderBy( // 가나다순, 최신순 순서의 우선순위로 조회
                    typeSort,
                    new OrderSpecifier&lt;&gt;(Order.ASC,store.name),
                    new OrderSpecifier&lt;&gt;(Order.DESC,store.createdAt)
                )
                .limit(size + 1)
                .fetch();


        boolean hasNext = stores.size() &gt; size;
        String nextCursor = null;
        if(hasNext) {
            stores = stores.subList(0,size);
            nextCurosr = stores.get(stores.size() - 1).getId().toString();
        }

        return CursorPageResponse.&lt;StoreResponseDTO&gt;builder()
               .content(stores)
        .nextCursor(nextCursor)
        .hasNext(hasNext)
        .build();
    }</code></pre>
<h3 id="느낀-점-고민한-점">느낀 점? 고민한 점</h3>
<p>정렬 방식에 대한 고민이 있었는데, </p>
<p>CaseBuilder를 사용할지, Java 정규표현식으로 거를지..</p>
<p>java에서 할 경우, CaseBuilder 없이 SearchCondition 클래스 안에 먼저 넣고 검색을 진행할 수가 있다. 그렇게 하면 OrderSpecifier를 sort조건에 따라 간단하게 설정할 수 있다. CaseBuilder를 다양하게 사용해보기 위해 CaseBuilder 방식을 사용했다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring]JPA 성능 최적화 하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/SpringJPA-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/SpringJPA-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 04 Nov 2025 06:32:31 GMT</pubDate>
            <description><![CDATA[<p>UMC 5주차 시니어 미션입니다. </p>
<h2 id="키워드-정리">키워드 정리</h2>
<ul>
<li><strong>지연로딩과 즉시로딩의 차이</strong></li>
<li><strong>JPQL</strong></li>
<li><strong>Fetch Join</strong></li>
<li><strong>@EntityGraph</strong></li>
<li><strong>commit과 flush 차이점</strong></li>
<li><strong>QueryDSL, OpenFeign의 QueryDSL</strong></li>
<li><strong>N+1 문제 해결할 수 있는 여러 방안들</strong></li>
<li><strong>영속 상태의 종류</strong></li>
</ul>
<h2 id="1-sql-로그-분석">1. SQL 로그 분석</h2>
<p>(spring.jpa.show-sql, logging.level.org.hibernate.SQL=DEBUG)</p>
<h3 id="설정">설정</h3>
<pre><code class="language-java">spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE</code></pre>
<h3 id="실행-로그-예시">실행 로그 예시</h3>
<p>GET /api/books/yearly-count 호출 시 다음 SQL이 출력된다.</p>
<pre><code>select count(ubs1_0.user_book_shelf_id)
from user_book_shelf ubs1_0
where ubs1_0.user_id = ?
  and ubs1_0.reading_status in (?, ?)
  and ubs1_0.recorded_at between ? and ?</code></pre><h3 id="문제점-분석">문제점 분석</h3>
<blockquote>
<p>단순 COUNT 쿼리지만, @Query를 사용할 경우 Hibernate가 내부적으로 User 엔티티를 프록시 초기화하기 위해 불필요한 추가 select를 수행할 수 있다.</p>
</blockquote>
<blockquote>
<p>findByUserAndReadingStatusOrderByCreatedDateDesc 쿼리에서는 연관된 book 조회 시 N+1 쿼리 발생 가능성이 있다.</p>
</blockquote>
<pre><code>select * from user_book_shelf where user_id = ?
select * from book where book_id = ?  -- 각 행마다 반복 실행</code></pre><h2 id="2-querydsl-기반-리팩토링">2. QueryDSL 기반 리팩토링</h2>
<h3 id="기존-코드">기존 코드</h3>
<pre><code>@Query(&quot;SELECT COUNT(ubs) FROM UserBookShelf ubs &quot; +
       &quot;WHERE ubs.user = :user &quot; +
       &quot;AND ubs.readingStatus IN (:statuses) &quot; +
       &quot;AND ubs.recordedAt BETWEEN :startOfYear AND :endOfYear&quot;)
long countByUserAndReadingStatusInAndRecordedAtBetween(...);</code></pre><h3 id="querydsl-변환-코드">QueryDSL 변환 코드</h3>
<pre><code>public long countBooksByStatusAndPeriod(User user, List&lt;ReadingStatus&gt; statuses,
                                        LocalDate startOfYear, LocalDate endOfYear) {
    QUserBookShelf ubs = QUserBookShelf.userBookShelf;

    return queryFactory
        .select(ubs.count())
        .from(ubs)
        .where(
            ubs.user.eq(user),
            ubs.readingStatus.in(statuses),
            ubs.recordedAt.between(startOfYear, endOfYear)
        )
        .fetchOne();
}
</code></pre><blockquote>
<p>@Transactional(readOnly = true) 적용 효과</p>
<ul>
<li>미적용 시: Hibernate flush check로 인해 불필요한 flush 발생</li>
<li>적용 시: 읽기 전용 트랜잭션으로 flush 과정 생략 → 응답 속도 약 5~10% 개선</li>
</ul>
</blockquote>
<h3 id="3-batch-fetch-size-및-fetch-join-비교">3. Batch Fetch Size 및 Fetch Join 비교</h3>
<p>배치 크기 설정</p>
<pre><code>@Entity
@BatchSize(size = 100)
public class UserBookShelf { ... }
</code></pre><p>또는 전역 설정:</p>
<p><code>spring.jpa.properties.hibernate.default_batch_fetch_size=100</code></p>
<p>Fetch Join 예시</p>
<pre><code>selectFrom(ubs)
    .leftJoin(ubs.book, book).fetchJoin()
    .where(ubs.user.eq(user))
    .fetch();</code></pre><h4 id="쿼리-실행-비교">쿼리 실행 비교</h4>
<table>
<thead>
<tr>
<th>설정</th>
<th>SQL 실행 횟수</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>기본</td>
<td>101회 (1 + N)</td>
<td>N+1 문제 존재</td>
</tr>
<tr>
<td>@BatchSize(100)</td>
<td>약 2회</td>
<td>in-query로 100개 단위 조회</td>
</tr>
<tr>
<td>fetch join</td>
<td>1회</td>
<td>조인으로 단일 쿼리 해결 가능</td>
</tr>
</tbody></table>
<hr>
<h3 id="성능-개선-결과-및-적용-전략">성능 개선 결과 및 적용 전략</h3>
<h4 id="결과-요약">결과 요약</h4>
<table>
<thead>
<tr>
<th>항목</th>
<th>Before</th>
<th>After</th>
<th>개선 내용</th>
</tr>
</thead>
<tbody><tr>
<td>COUNT 쿼리</td>
<td>35ms</td>
<td>12ms</td>
<td>QueryDSL + readOnly 적용</td>
</tr>
<tr>
<td>목록 조회</td>
<td>210ms</td>
<td>60ms</td>
<td>fetch join + batch size</td>
</tr>
<tr>
<td>SQL 수</td>
<td>101</td>
<td>2</td>
<td>N+1 제거</td>
</tr>
</tbody></table>
<hr>
<h3 id="실제-서비스에-적용-가능한-전략">실제 서비스에 적용 가능한 전략</h3>
<h4 id="1-읽기-전용-트랜잭션-적용">1. 읽기 전용 트랜잭션 적용</h4>
<p>조회 전용 서비스 메서드에 <code>@Transactional(readOnly = true)</code>를 적용하여 불필요한 flush를 제거한다.</p>
<h4 id="2-querydsl-전환">2. QueryDSL 전환</h4>
<p>동적 조건, fetch join, subquery가 필요한 복잡한 쿼리는 QueryDSL로 작성한다.</p>
<h4 id="3-batchsize--fetchjoin-병행-사용">3. BatchSize + FetchJoin 병행 사용</h4>
<p>컬렉션 연관 관계를 효율적으로 조회하기 위해 <code>@BatchSize</code>와 <code>fetch join</code>을 병행한다.</p>
<h4 id="4-sql-로그-주기적-점검">4. SQL 로그 주기적 점검</h4>
<p>주요 API 호출 시 실행되는 쿼리와 시간을 정기적으로 모니터링하여 병목 지점을 파악한다.</p>
<h4 id="5-페이징-주의">5. 페이징 주의</h4>
<p><code>fetch join</code>은 페이징 처리가 불가능하므로, 페이징이 필요한 경우 Batch Fetch로 대체한다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>단순히 <code>@Query</code>를 QueryDSL로 전환하고<br><code>@Transactional(readOnly = true)</code>를 적용하는 것만으로도 눈에 띄는 속도 개선을 얻을 수 있었다.  </p>
<p>복잡한 연관 관계에서는 <code>Batch Fetch Size</code> 조절이 가장 실질적인 최적화 방법으로 확인되었으며,<br>실제 서비스에서는 API별 접근 패턴에 따라 <code>fetch join</code>과 <code>batch fetch</code>를 병행하는 전략이 필요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 스프링 MVC - 서블릿, 스레드,HTML]]></title>
            <link>https://velog.io/@jayaione_ele/SpringMVC-%EC%84%9C%EB%B8%94%EB%A6%BF%EC%8A%A4%EB%A0%88%EB%93%9CHTML</link>
            <guid>https://velog.io/@jayaione_ele/SpringMVC-%EC%84%9C%EB%B8%94%EB%A6%BF%EC%8A%A4%EB%A0%88%EB%93%9CHTML</guid>
            <pubDate>Sun, 21 Sep 2025 18:31:38 GMT</pubDate>
            <description><![CDATA[<h3 id="서블릿">서블릿</h3>
<p>서블릿은 서버 소켓 연결, 요청 메시지를 파싱해서 읽기, Post 방식인지 어떤 URL인지 읽기, Content-Type 확인, body 내용 파싱, 저장 프로세스 실행 .. 등을 하고, 
의미있는 것은 &#39;데이터베이스에 저장을 하는&#39;비즈니스 로직인데, 비즈니스 로직을 실행 하고 응답 메시지를 생성하는 등의 과정을 거쳐야 하는게 서버에서 처리하는 업무다.
즉 비즈니스 로직 외에 나머지 과정은 매번 실행을 해줘야 하는데, 이러한 과정들을 자동화해주는 것이 서블릿이다. </p>
<pre><code class="language-java">@WebServlet(name = &quot;helloServelt&quot;, urlPatterns = &quot;/hello&quot;)
public class HelloServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) {
     //로직
     }</code></pre>
<p>urlPatterns에 해당하는 URL이 호출되면, 서블릿 코드가 실행된다. 
HttpServletRequest, HttpServletResponse 를 사용하면 HTTP 요청, 응답 정보를 사용할 수 있다. 즉 응답 메시지를 편리하게 사용할 수 있도록 해준다. 원하는 데이터를 Response 객체에 넣는 방식이고, HTTP 스펙을 매우 편리하게 사용 가능하다.</p>
<p>전체 흐름
HTTP 요청 시 WAS가 request, response 객체 생성
서블릿 컨테이너에 있는 서블릿 객체를 호출
개발자가 Request에서 HTTP 요청 정보를 꺼내서 사용, Response 객체에서 HTTP 응답 정보를 입력  WAS는 Response 객체에 담겨있는 내용으로 HTTP 응답 생성
응답 내용으로 html에 랜더링해서 보여줌</p>
<h4 id="서블릿-컨테이너">서블릿 컨테이너</h4>
<p>서블릿을 지원하는 WAS 안에는 서블릿 컨테이너가 있는데, 서블릿 컨테이너가 서블릿 객체를 생성해준다. WAS가 종료될 때 서블릿도 종료해준다. 톰캣이 바로 서블릿 컨테이너이다. </p>
<ul>
<li>서블릿 컨테이너는 서블릿 객체를 생성,초기화,호출,종료하는 생명주기를 관리한다. </li>
<li>서블릿 객체는 싱글톤으로 관리된다.
고객의 요청마다 데이터가 달라야 하기 때문에, 요청에 따라 response,request 객체가 달라야 한다. 즉 이 객체들은 요청이 올 때마다 생성된다. 그렇지만 요청이 올 때마다 객체 생성하는 것은 비효율적이다. 즉 최초 로딩 시점에 서블릿 객체를 미리 생성해두고 모든 고객 요청은 하나의 서블릿 객체에 접근하게 된다.</li>
<li><blockquote>
<p>공유 변수 사용을 주의해야한다. (하나의 객체를 모든 고객이 사용하기 때문)</p>
</blockquote>
</li>
<li>서블릿 컨테이너가 종료될 때 함께 종료된다. </li>
<li>JSP도 서블릿으로 변환되어서 사용된다. </li>
<li><strong>동시 요청을 위한 멀티 스레드 처리를 지원</strong>한다. 즉 개발자가 요청이 많이 오는 것에 대해 신경 쓰지 않아도, WAS가 알아서 처리를 해준다. </li>
</ul>
<h3 id="동시요청-멀티스레드">동시요청 멀티스레드</h3>
<h4 id="스레드">스레드</h4>
<ul>
<li>스레드는 애플리케이션 코드를 하나하나 순차적으로 실행한다. </li>
<li>자바 메인 메서드를 처음 실행하면 메인이라는 이름의 스레드가 실행되고, 스레드가 없다면 자바 애플리케이션 실행이 불가능하다. </li>
<li>스레드는 한번에 하나의 코드 라인만 실행하므로, 동시 처리가 필요하면 스레드를 추가로 생성해야 한다. </li>
</ul>
<p>단일 요청의 경우, 요청이 오면 스레드를 할당하고, 응답이 완료되면 스레드가 휴식을 하게 된다.
다중 요청의 경우, 서블릿 안에서 요청 1의 경우 처리가 지연될 수 있다. 스레드가 하나만 있다면, 이 상황에서 요청2가 오게 되면 요청1,요청2 둘 다 실행을 기다리기만 하게 된다. 
요청마다 스레드를 생성한다면, 요청1이 처리가 지연되더라도, 요청2가 들어오면 새로운 스레드를 만들어서 요청을 처리하면 된다. </p>
<h4 id="요청마다-스레드-생성">요청마다 스레드 생성</h4>
<p>장점</p>
<ul>
<li>동시 요청을 처리할 수 있다. </li>
<li>CPU, 메모리가 허용할 때까지 처리가 가능하다. 
단점</li>
<li>스레드 생성 비용은 매우 비싼데, 고객의 요청이 올 때마다 스레드를 생성하게 되면 응답 속도가 늦어진다. </li>
<li>코어가 하나인데, 스레드가 두개일 경우 전환할 때, 컨텍스트 스위칭을 하게 된다. 이러면 컨텍스트 스위칭 비용이 발생하게 된다. </li>
<li>스레드 생성에 제한이 없다. 고객 요청이 너무 많이 오면, CPU, 메모리 임계점을 넘어서 서버가 죽을 수도 있다. </li>
</ul>
<h4 id="스레드-풀">스레드 풀</h4>
<p>이러한 단점들을 해결하기 위해, WAS는 스레드 풀이라는 걸 활용한다. 
필요한 스레드를 스레드 풀에 보관하고 관리하는 것이 스레드 풀의 역할이다. 스레드 풀에 생성 가능한 스레드의 최대치를 관리하고, 스레드 사용을 하고 반납하는 형식으로 활용한다. 톰캣은 최대 200개가 기본으로 설정되어 있으며, 변경 가능하다. </p>
<p><strong>스레드 사용하기</strong></p>
<ul>
<li>스레드의 사용 : 스레드가 필요하면 이미 생성되어 있는 스레드를 스레드 풀에서 꺼내서 사용한다. </li>
<li>스레드 반납 : 사용을 종료하면 스레드 풀에 해당 스레드를 반납한다. </li>
<li>스레드 대기, 거절: 최대 스레드가 모두 사용중이어서 스레드 풀에 스레드가 없을 경우, 기다리는 요청은 거절하거나 특정 숫자만큼만 대기하도록 설정 가능하다. </li>
</ul>
<p><strong>실무 팁</strong>
WAS의 주요 튜닝 포인트는 최대 스레드 수이다.</p>
<ul>
<li>너무 낮게 설정하면 ? 요청 수가 갑자기 많아지면, 스레드 수는 적기 때문에 나머지 요청들은 대기하게 된다. 그렇지만 스레드 수가 적으면 CPU 사용률이 매우 낮다. 즉 서버 리소스가 여유롭지만, 클라이언트는 금방 응답 지연이 발생한다. </li>
<li>너무 높게 설정하면 ? 동시 요청이 많아지만 CPU, 메모리 리소스의 임계점 초과로 서버가 다운된다. </li>
<li>장애 발생 시에, 클라우드일 경우 일단 서버부터 늘리고 이후에 튜닝하면 된다. 클라우드가 아니라면 평상시에 열심히 튜닝을 해야 한다. </li>
</ul>
<p><strong>스레드 풀의 적정 숫자</strong></p>
<ul>
<li>애플리케이션 로직의 복잡도, CPU, 메모리, IO 리소스 상황에 따라 모두 다르다. </li>
<li>성능 테스트를 통해 찾아야 한다. 최대한 실제 서비스와 유사하게 성능 테스트를 시도하고, 툴은 아파치 ab, 제이미터, nGrinder를 사용한다. 개발 서버를 구축해놓고, 병목 포인트를 찾아서 튜닝을 하면 된다. </li>
</ul>
<h4 id="was의-멀티-스레드-지원">WAS의 멀티 스레드 지원</h4>
<ul>
<li>멀티 스레드에 대한 부분은 WAS가 처리한다. 즉 개발자가 멀티 스레드 관련 코드를 신경쓰지 않아도 된다. </li>
<li>개발자는 싱글 스레드 프로그래밍을 하듯이 편리하게 개발해 되지만, 멀티 스레드 환경이므로 싱글톤 객체(서블릿, 스프링 빈)는 주의해서 사용해야 한다. </li>
</ul>
<h3 id="서버에서-알아야-할-html-관련-지식">서버에서 알아야 할 HTML 관련 지식</h3>
<h4 id="html-페이지">HTML 페이지</h4>
<p>웹 브라우저에서 정적 리소스를 요청할 경우, 고정된 HTML 파일, CSS, JS, 이미지, 영상 등을 제공한다. 
웹 브라우저에서 요청을 하면, WAS에서 DB에서 조회를 해온 뒤에, 동적으로 필요한 HTML 파일을 생성해서 전달하면, 웹 브라우저는 HTML 파일을 해석해서 제공한다. </p>
<h4 id="http-api">HTTP API</h4>
<p>HTML이 아닌 데이터를 전달하는데, JSON 형태로 전달한다. 다양한 시스템에서 이를 호출하게 되는데, 데이터만 주고 받을 경우 활용한다. 앱 -&gt; 서버, 서버 -&gt; 서버, 웹 클라이언트 -&gt; 서버 의 경우들이 있다. 
React,Vue.js와 같은 웹 클라이언트나 웹 브라우저에서 자바스크립트을 이용하여 HTTP API를 호출하는 경우들이 있다. 
서버와 서버 간의 통신의 경우 주문 서버와 결제 서버가 분리되어 있을 경우, json으로 통신할 수 있다. </p>
<h4 id="ssr--서버-사이드-렌더링-">SSR ( 서버 사이드 렌더링 )</h4>
<p>서버에서 최종 결과, 즉 최종 HTML을 동적으로 만들어서, 클라이언트에 전달하는 것이다. </p>
<h3 id="csr">CSR</h3>
<p>HTML 결과를 자바스크립트를 사용해서 웹 브라우저에서 동적으로 생성해서 적용하는 것이다. 
주로 동적인 화면에 사용을 하는데, 관련 기술로는 React, Vue.js가 있다. 
흐름은 다음과 같다. 
내용이 없는 html을 요청하는데, 내부에는 자바스크립트 링크가 있다. 그러면 이 자바스크립트를 요청을 하게 되는데, 서버에서는 자바스크립트 내부에 있는 클라이언트 로직과 html 랜더링 코드를 응답을 하게 된다. 
웹 브라우저는 이번에는 HTTP API로 서버를 호출한다. 즉 데이터를 요청하는 것이기 때문에, DB에서 정보를 조회하고 json 타입으로 웹 브라우저에 반환한다. 그러면 최종적으로 클라이언트,웹 브라우저에서 동적으로 HTML을 생성해서 반환을 해준다. 즉 클라이언트에서 동적으로 자바스크립트를 사용하여 HTML을 생성해주는 방법이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UMC][DB] Query 작성하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/UMCDB-Query-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/UMCDB-Query-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 17 Sep 2025 19:58:43 GMT</pubDate>
            <description><![CDATA[<p>목차는 다음과 같다. </p>
<blockquote>
<ol>
<li>커서 기반 페이지네이션 수정</li>
<li>트랜잭션</li>
<li>인덱스 연구하기 </li>
</ol>
</blockquote>
<h2 id="커서-기반-페이지네이션">커서 기반 페이지네이션</h2>
<p>내가 진행중, 진행 완료한 미션 모아서 보는 쿼리(페이징 포함)에서
정렬 기준을 1순위는 포인트로 2순위는 최신순으로 하여 Cursor기반 페이지네이션을 구현해볼 것이다. 
기존 구현 방식은 최신순, id 조합으로 구현을 했었다.</p>
<pre><code class="language-sql">SELECT 
    m.id, m.points, m.content, s.name AS mission_name, s.store_id, um.user_mission_status 
FROM (
    SELECT user_mission_id, mission_id, user_mission_status
    FROM user_missions 
    WHERE um.user_id = 1 
        AND um.user_mission_status IN (&#39;PROGRESS&#39;, &#39;SUCCESS&#39;)
        AND (created_at &lt; &#39;2025-09-18 12:00:00.000000&#39; OR 
            (um.user_mission_id &lt; 123 AND created_at = &#39;2025-09-18 12:00:00.000000&#39;))
    ORDER BY created_at, user_mission_id DESC
    LIMIT 10;
)
JOIN missions AS um ON m.mission_id = um.mission_id
JOIN stores s ON m.store_id = s.store_id;</code></pre>
<p>포인트,날짜,id 조합으로 구현하면 된다. WHERE절을 다음과 같이 수정해야 한다. </p>
<pre><code class="language-sql">m.points &lt; 100 
    OR (m.points &lt; 100 AND m.created_at &#39;2025-09-18 12:00:00.000000&#39;)
    OR (m.points &lt; 100 AND m.created_at &#39;2025-09-18 12:00:00.000000&#39; AND m.mission_id &lt; 123)</code></pre>
<p>전체 코드는 다음과 같이 된다. </p>
<pre><code class="language-sql">SELECT 
    m.mission_id,
    m.points,
    m.content,
    s.name AS store_name,
    s.store_id,
    um.user_mission_status
FROM (
    SELECT um.user_mission_id, um.mission_id, um.user_mission_status
    FROM user_missions um
    WHERE um.user_id = 1
      AND um.user_mission_status IN (&#39;PROGRESS&#39;, &#39;SUCCESS&#39;)
      AND (
            m.points &lt; 100
         OR (m.points = 100 AND um.created_at &lt; &#39;2025-09-18 12:00:00.000000&#39;)
         OR (m.points = 100 AND um.created_at = &#39;2025-09-18 12:00:00.000000&#39; AND um.user_mission_id &lt; 123)
      )
    ORDER BY m.points DESC, um.created_at DESC, um.user_mission_id DESC
    LIMIT 10
) um
JOIN missions m ON um.mission_id = m.mission_id
JOIN stores s ON m.store_id = s.store_id;
</code></pre>
<h2 id="트랜잭션">트랜잭션</h2>
<p>트랜잭션은 데이터베이스의 상태를 변화시키는 한 개의 논리적 작업 단위이다. SQL문을 사용해서 데이터베이스에 접근하는 것이다. 여러 SQL문이더라도 한번 실행하는 단위라고 볼 수 있다. 데이터의 일관성을 보장하기 위해서 사용된다. </p>
<h3 id="다양한-트랜젝션-상태">다양한 트랜젝션 상태</h3>
<ul>
<li>Active : 트랜잭션이 시작되어 쿼리 작업을 수행 중인 상태</li>
<li>partially Committed : 연산, 작업은 끝났는데 commit은 되지 않은 상태</li>
<li>committed: 트랜잭션 정상 종료, 변경 사항 DB에 반영된 상태</li>
<li>Falied : 실행 도중 오류 발생해서, 트랜잭션이 진행이 되지 않은 상태</li>
<li>Aborted/rolled back : 오류, 취소로 인해 변경 사항이 반영되지 않은 상태</li>
</ul>
<p>트랜잭션 관리를 비즈니스 로직과 분리하는 것이 권장되는데, 스프링에서는 @Transactional 어노테이션을 통해, AOP를 사용하여 트랜잭션 관리를 쉽게 분리할 수 있도록 한다. </p>
<h3 id="트랜젝션-전파">트랜젝션 전파</h3>
<p>스프링에서 메서드 실행 시, 트랜잭션에 대해 하나의 트랜잭션이 다른 트랜잭션 내에서 호출될 때 해당 트랜잭션을 어떻게 처리할 지, 기존 트랜잭션이 있는지, 없는 채로 실행해도 되는지, 새로 만들어야만 하는지를 결정하는 규칙을 전파라고 한다. </p>
<p>Spring에서는 @Transactional의 propagation 옵션으로 설정 가능하다. </p>
<ol>
<li>Propagation.REQUIRED : 트랜잭션이 필요하고, 기존 트랜잭션이 있으면 사용하고 없으면 새로 생성한다. </li>
<li>Propagation.REQUIRES_NEW : 항상 새로운 트랜잭션이 필요하다. 기존 트랜잭션이 있어도 일시 중지하고, 새로 생성하여 실행한다. 하위 메서드와 상위 메서드는 독립적으로, 하위 메서드에서 예외 발생되어서 롤백된다고 해도, 상위 트랜잭션은 계속 실행된다. </li>
<li>Propagation.SUPPORTS : 기존 트랜잭션이 있으면 사용하고, 없으면 없이 진행한다. </li>
<li>Propagation.MANDATORY : 트랜잭션이 필수이다. 메서드 호출 시 트랜잭션이 설정되어 있어야 한다. </li>
<li>Propagation.NOT_SUPPORTED : 트랜잭션을 지원하지 않고 기존에 트랜잭션이 있었어도 없이 진행한다.</li>
<li>Propagation.NEVER : 트랜잭션을 지원하지 않고 상위 스코프에도 트랜잭션이 설정되있으면 안 된다.</li>
<li>Propagation.NESTED : 중첩 트랜잭션을 만든다. <h2 id="인덱스">인덱스</h2>
</li>
</ol>
<p>인덱스는 데이터 검색 성능 향상을 위해서 사용하는 자료구조로, 주로 B-Tree 또는 Hash이다.
종류는 다음과 같은 게 있다. </p>
<ul>
<li>unique index: unique 제약 조건으로 생성되며, 값이 중복될 수 없다. 즉 연관된 테이블의 하나의 행만 가리킬 수 있다. </li>
<li>primary index: primary key 제약 조건을 걸면 생성되며, 식별자 역할을 한다. </li>
<li>non-unique index: 검색 속도를 높이기 위해 만든 인덱스로, 중복 가능하다. 즉 연관된 테이블의 여러 행을 가리킬 수가 있다. 주로 자주 where 조건에 들어가는 컬럼에 설정된다. </li>
<li>function-based index: 함수나 표현식의 계산값으로 생성된다. </li>
<li>composite / multi-column index: 여러 컬럼을 묶어서 생성되며, 컬럼 순서가 중요하다. </li>
</ul>
<h2 id="function-based-vs-composite">function based vs composite</h2>
<p>함수 기반 인덱스와 복합 인덱스를, 위의 커서 기반 페이징 쿼리 기반으로 생성해보고, 성능 비교를 해 볼 것이다. </p>
<h3 id="함수-기반-인덱스-만들어보기">함수 기반 인덱스 만들어보기</h3>
<p>커서가 문자열(title)일 때 대소문자 구분을 없애고 싶다면 다음과 같이 생성할 수 있다. </p>
<pre><code class="language-sql">CREATE INDEX idx_mission_lower_title
ON mission ((LOWER(title)));</code></pre>
<p>장점</p>
<ul>
<li>표현식이나 함수를 적용해서 WHERE 절로 결과를 자주 조회할 경우 유리하다. </li>
<li>날짜를 가공해서 검색한다거나, 특정 문자열로 매핑을 해서 저장해야 할 때 유리하다. </li>
</ul>
<p>단점</p>
<ul>
<li>인덱스는 해당 테이블에 생성이 되는 것이기 때문에, INSERT, UPDATE 할 때마다 자주 적용되는 함수의 결과도 계산을 해서 저장을 해야 한다. 성능이 약간 저하될 수 있다. </li>
<li>DBMS별로 지원 차이가 있을 수 있다. </li>
</ul>
<h3 id="복합-인덱스-만들어보기">복합 인덱스 만들어보기</h3>
<p>복합 인덱스는 여러 컬럼을 묶어서, 특정 조합을 조회할 때 유리하다. 사용자의 상태별로 미션을 조회할 때, 
사용자id, 사용자의 미션 진행 상태, 미션 id로 조회를 하게 된다. 조건이 많을 경우에 인덱스를 사용하면 성능이 약간 향상될 수 있다. 즉 자주 조회하는 컬럼들을 묶어서, 인덱스를 생성하는 것이다. </p>
<pre><code class="language-sql">CREATE INDEX idx_user_status_mission
ON user_mission(user_id, status, mission_id);</code></pre>
<p>장점 </p>
<ul>
<li>커서 페이징을 할 때, 여러 컬럼을 묶어서 조회하는 경우가 많으므로 성능이 향상된다.</li>
</ul>
<p>단점</p>
<ul>
<li>컬럼 순서가 중요하다. </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Deploy] Route 53 + https + nginx + 가비아 도메인 연결하기]]></title>
            <link>https://velog.io/@jayaione_ele/CICD-https-nginx-%EA%B0%80%EB%B9%84%EC%95%84-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/CICD-https-nginx-%EA%B0%80%EB%B9%84%EC%95%84-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 30 Jul 2025 16:05:50 GMT</pubDate>
            <description><![CDATA[<p>aws ec2로 서버 배포를 완료한 상태라고 가정한 상태에서, 가비아 도메인을 구매해서 연결하고, nginx로 ssl 인증서 발급 과정을 거쳐서 https 배포를 진행할 예정이다. </p>
<h2 id="가비아-도메인-연결">가비아 도메인 연결</h2>
<p>먼저 가비아 도메인을 구매한다. 가격은 1년에 500-550원 정도로 저렴한 편이다. </p>
<h3 id="route-53-설정">route 53 설정</h3>
<p>도메인을 구매했다면 AWS의 Route53 페이지로 들어간다. 호스팅 영역이라는 메뉴로 들어가서 호스팅 영역을 생성한다. 
<img src="https://velog.velcdn.com/images/jayaione_ele/post/176cf5f8-1e87-4acb-bb9e-c84e0b058657/image.png" alt=""></p>
<p>도메인 이름에는 구매한 도메인명을 입력하면 된다. 퍼블릭 호스팅 영역으로 설정한다. 호스팅 영역을 생성하면 자동으로 레코드가 생성된다. 여기서 추가로 레코드를 또 생성해준다.</p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/db0691f7-ceb4-40e4-9daf-e54f380e45cd/image.png" alt=""></p>
<p>A 레코드를 생성해주는데, 여기서 레코드 이름에는 www와 같은 서브도메인이 들어간다. 값에는 EC2 인스턴스의 IP 주소를 입력해준다. </p>
<p>서브도메인도 추가해주고 싶다면, A 레코드를 서브도메인 없는 버전과 있는 버전으로 두개를 생성해준다. 그러면 다음과 같이 레코드 목록이 떠야한다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/0755725b-35f1-450c-a55e-b639be9e9ccf/image.png" alt=""></p>
<p>먼저 활용할 값은 NS의 1,2,3,4 값이다. </p>
<p>my가비아에서 구메한 도메인의 네임서버 설정으로 들어간다. 네임서버 목록에서 1,2,3,4차로 구분이 되는데, 위의 NS 레코드의 값을 순서대로 입력해주는데, 끝의 마침표를 제거하고 입력해줘야 한다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/d293e095-fc8d-4afd-a676-59273f09d69e/image.png" alt=""></p>
<p>연결이 잘 되어 있는지 확인하려면 다음 명령어를 입력한다. 연결되는 데에 시간이 걸릴 수 있다. </p>
<pre><code>nslookup [도메인명]</code></pre><p>다음은 A 레코드를 생성해줘야 한다. 도메인 툴 관리에 들어가준다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/10873e68-6f64-40e2-9546-dfc94ee34765/image.png" alt=""></p>
<p>레코드 설정으로 들어가서 레코드 수정을 클릭하면 다음과 같이 레코드를 추가해준다. 
호스트는 기본 도메인(@), 서브 도메인(www)을 입력해서 총 2개를 생성했다. 값에는 ip 주소를 입력해준다.  </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/a5d470eb-c78d-4f79-a9c9-51f6f7fec25d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/81ad460b-29c6-4ef6-bdc9-8ec83109e8b6/image.png" alt=""></p>
<p>반영되는 데에 시간이 걸릴 수 있다. 해당 명령어를 입력하고, ip주소를 반환하면 제대로 연결되어 있음을 알 수 있다. </p>
<pre><code>dig [도메인명] +short</code></pre><h2 id="lets-encrypt-ssl-인증서-발급">Let&#39;s Encrypt SSL 인증서 발급</h2>
<h3 id="설치하기">설치하기</h3>
<pre><code class="language-bash">sudo apt install nginx -y</code></pre>
<p>-y 옵션을 추가하면 중간에 추가 공간을 요구하는 메시지에 무조건 동의를 하도록 해서 바로 설치할 수 있다. </p>
<pre><code class="language-bash">sudo apt install -y certbot python3-certbot-nginx</code></pre>
<pre><code class="language-bash">sudo certbot --nginx</code></pre>
<p>해당 명령어를 입력하면, 밑에처럼 따로 이메일입력, 동의, 도메인 입력을 진행해야 한다. </p>
<pre><code class="language-bash">Invalid email address: .


If you really want to skip this, you can run the client with
--register-unsafely-without-email but you will then be unable to receive notice
about impending expiration or revocation of your certificates or problems with
your Certbot installation that will lead to failure to renew.

Enter email address (used for urgent renewal and security notices)
 (Enter &#39;c&#39; to cancel): [이메일 입력]

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.5-February-24-2025.pdf. You must
agree in order to register with the ACME server. Do you agree?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: Y

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Would you be willing, once your first certificate is successfully issued, to
share your email address with the Electronic Frontier Foundation, a founding
partner of the Let&#39;s Encrypt project and the non-profit organization that
develops Certbot? We&#39;d like to send you email about our work encrypting the web,
EFF news, campaigns, and ways to support digital freedom.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: Y
Account registered.
Please enter the domain name(s) you would like on your certificate (comma and/or
space separated) (Enter &#39;c&#39; to cancel): [도메인 입력]
Requesting a certificate for 도메인

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/도메인/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/도메인/privkey.pem
This certificate expires on 2025-10-27.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.

Deploying certificate
Successfully deployed certificate for 도메인 to /etc/nginx/sites-enabled/default
Congratulations! You have successfully enabled HTTPS on https://[도메인]

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
If you like Certbot, please consider supporting our work by:
 * Donating to ISRG / Let&#39;s Encrypt:   https://letsencrypt.org/donate
 * Donating to EFF:                    https://eff.org/donate-le</code></pre>
<p>조금 번거로운 과정을 거치기 싫다면 다음과 같이 입력하면 한번에 Nginx와 Cerbot을 연동할 수 있다. </p>
<pre><code>sudo certbot --nginx \
  -d 도메인 \
  -d 서브도메인 \
  --email 이메일 \
  --agree-tos \
  --no-eff-email</code></pre><h2 id="nginx로-https-설정">nginx로 https 설정</h2>
<p>nginx 설정은 두가지 방법이 있다. Docker를 사용하거나, ec2 서버에 직접 설치하는 방법이 있다. 여기서는 ec2 서버에 직접 설치하는 방법을 선택했다. </p>
<p>먼저 심볼릭 링크가 있는지 확인한다. </p>
<pre><code> ls -l /etc/nginx/sites-enabled/</code></pre><p>다음 결과를 반환하면 default로 심볼릭 링크가 존재함을 알 수 있다. </p>
<pre><code> lrwxrwxrwx 1 root root 34 Jul 29 16:26 default -&gt; /etc/nginx/sites-available/default </code></pre><p> default는 certbot 설정 시 기본으로 생성되는 파일이다. 직접 커스텀하고 싶다면, 해당 파일을 제거하고 도메인명으로 따로 파일을 생성한다. </p>
<pre><code> sudo rm /etc/nginx/sites-enabled/default</code></pre><pre><code> sudo vi /etc/nginx/sites-available/[도메인명]</code></pre><p>편집기를 열어서 다음과 같이 입력한다. </p>
<pre><code># HTTP → HTTPS 리디렉션
server {
    listen 80;
    listen [::]:80;
    server_name [도메인명] www.[도메인명];

    return 301 https://$host$request_uri;
}

# HTTPS 리버스 프록시
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name [도메인명] www.[도메인명];

    ssl_certificate /etc/letsencrypt/live/www.[도메인명]/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/www.[도메인명]/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # 웹소켓 사용 시 추가
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
    }
}</code></pre><p>첫 번째 블록은 http 요청을 https로 리디렉션하도록 하는 코드이다. </p>
<p><code>listen 80</code>; : 80 포트에서 http 요청을 수신할 경우를 말한다. 
<code>server_name [도메인명] www.[도메인명];</code> : 해당 도메인으로 들어온 요청만 처리하도록 한다. 
<code>return 301 https://$host$request_uri;</code> 모든 요청을 301 Moved Permanently로 리디렉션하여 HTTPS 주소로 강제 이동하도록 한다. host는 도메인 주소, request_uri는 요청 url을 말한다. 이 주소로 접속 시, 서버에서 301 응답 코드를 보내면서 https로 접속하도록 요청한다는 의미이다. </p>
<p>두 번째 블록은 https 요청을 처리한다. </p>
<p><code>listen 443 ssl;</code> : https용 포트인 443을 열어 ssl 통신을 수신한다. 
 <code>ssl_certificate /etc/letsencrypt/live/www.[도메인명]/fullchain.pem;</code>
 <code>ssl_certificate_key /etc/letsencrypt/live/www.[도메인명]/privkey.pem;</code> : Let&#39;s Encrypt에서 발급한 공개 키(인증서) 와 비공개 키를 지정한다. 
 <code>include /etc/letsencrypt/options-ssl-nginx.conf;</code>
    <code>ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;</code> : TLS 보안 옵션들을 포함하는 설정을 추가한다. </p>
<p>location 블록은 실제 백엔드 서버로 프록시하는 역할을 한다. 
<code>proxy_pass http://localhost:8080;</code> : 현재 스프링 서버는 8080포트를 사용하고 있으므로, / 경로로 들어오는 모든 요청을 내부의 8080포트로 저장하도록 한다. </p>
<p><code>proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;</code> : 클라이언트의 기존 요청 정보를 백엔드로 전달해주는 헤더를 설정한다. 즉  https://[도메인] 으로 요청을 보내더라도, 스프링 8080포트인 백엔드로 요청을 전달해주는 역할을 하는 것이다. </p>
<p>웹소켓을 사용하는 기능이 있다면 설정을 추가해줘야 한다. 웹소켓 요청 시, 클라이어트는 다음과 같은 헤더를 보낸다. </p>
<pre><code class="language-http">GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: upgrade
...
</code></pre>
<p><code>proxy_http_version 1.1;</code> : nginx는 기본적으로 HTTP.1.0을 사용하는데, WebSocket은 HTTP/1.1부터 지원되기 때문에, 설정해주지 않으면 웹소켓 프로토콜을 시작할 수 없다. 
<code>proxy_set_header Upgrade $http_upgrade;</code>: 일반 HTTP가 아닌 WebSocket으로 업그레이드를 한다는 의미이다. 즉 $http_upgrade로 업그레이드한다는 것이다. 일반 웹소켓 요청에서는 websocket이라는 값이 담긴다. 
<code>proxy_set_header Connection &quot;upgrade&quot;;</code> Upgrade 헤더의 유효성을 보장하기 위한 것으로, Connection: upgrade가 같이 있어야 WebSocket 핸드셰이크가 성립한다.해당 부분이 없으면 다음과 같은 오류가 뜰 수 있다. </p>
<pre><code class="language-bash">&quot;Handshake failed due to invalid Upgrade header: null&quot;</code></pre>
<p>이렇게 작성을 완료했으면, 구문 오류가 없는지 확인해주고 적용한다. </p>
<pre><code>sudo nginx -t</code></pre><p>구문 오류가 없다면 Successful 메세지가 뜰 것이다. </p>
<pre><code>sudo systemctl restart nginx</code></pre><p>최종적으로 nginx를 재시작해서 적용하면, https://도메인명으로 접속이 가능해진다. </p>
<h3 id="nginx-설정---웹소켓-sse-둘다-사용할-경우">nginx 설정 - 웹소켓, SSE 둘다 사용할 경우</h3>
<p>프록시 헤더를 다르게 설정해야 하기 때문에 동일한 블록을 사용하면 안된다. 
그래서 location 블록을 다르게 설정해줬다. </p>
<pre><code class="language-yml">server {
    server_name [도메인명] www.[도메인명];

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600;
        proxy_send_timeout 3600;
    }

    location /ws {
        proxy_pass http://localhost:8080/ws;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &#39;upgrade&#39;;
        proxy_buffering off;
        proxy_http_version 1.1;
        proxy_read_timeout 3600;
        proxy_send_timeout 3600;
    }

    location /sse {
        proxy_pass http://localhost:8080/sse;
        proxy_set_header Connection &#39;&#39;;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 3600;
        proxy_send_timeout 3600;
    }

    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/peaceproject.site/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/peaceproject.site/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

server {
    listen 80;
    server_name [도메인명] www.[도메인명];
    return 301 https://$host$request_uri;
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UMC][Spring Boot] Spring Boot의 코어 개념 - MVC, AOP, DispatchServlet]]></title>
            <link>https://velog.io/@jayaione_ele/UMCSpring-Boot-Spring-Boot%EC%9D%98-%EC%BD%94%EC%96%B4-%EA%B0%9C%EB%85%90-MVC-AOP-DispatchServlet</link>
            <guid>https://velog.io/@jayaione_ele/UMCSpring-Boot-Spring-Boot%EC%9D%98-%EC%BD%94%EC%96%B4-%EA%B0%9C%EB%85%90-MVC-AOP-DispatchServlet</guid>
            <pubDate>Wed, 09 Apr 2025 03:27:56 GMT</pubDate>
            <description><![CDATA[<h2 id="서블릿">서블릿</h2>
<ul>
<li><p>HttpServlet 클래스 : 전통적인 서블릿 개발에서는 HttpServlet 클래스를 상속받아 사용한다. 이 클래스는 HTTP 요청을 처리하는 메서드(doGet(), doPost(), doPut(), doDelete() 등)를 제공하는데, 
개발자는 각 HTTP 메서드에 맞게 doGet(), doPost() 등을 구현해야 하며, 요청 데이터를 수동으로 파싱하고 응답을 생성하는 방식으로 API를 구현한다. </p>
</li>
<li><p>직접적인 요청 처리: 각 서블릿은 URL에 직접 매핑되며, URL로부터 요청을 받아 로직을 처리하고 응답을 반환한다. </p>
<h2 id="spring-mvc">Spring MVC</h2>
</li>
</ul>
<p>@Controller 어노테이션: Spring MVC에서는 @Controller 어노테이션을 사용하여 컨트롤러 클래스를 정의하고, 이 컨트롤러는 웹 요청을 처리하는 메서드들을 포함한다. </p>
<p>@RequestMapping 어노테이션: 메서드에 @RequestMapping 어노테이션을 사용하여 특정 URL 패턴에 대한 처리를 지정할 수 있고, 이를 통해 HTTP 메서드와 URL을 처리할 로직을 매핑한다. </p>
<p>DispatcherServlet: 모든 요청을 받아 적절한 컨트롤러 메서드에 전달한다. 설정과 다양한 웹 요청 처리 로직을 중앙에서 관리할 수 있게 해 준다.</p>
<h3 id="spring-mvc가-더-편리한-이유">Spring MVC가 더 편리한 이유?</h3>
<ul>
<li><p>중앙 집중화된 설정: DispatcherServlet을 통해 모든 요청을 중앙에서 관리하며, 각 요청을 알맞은 컨트롤러로 라우팅한다. 이는 설정을 간소화하고 개발자가 비즈니스 로직에 집중할 수 있게 한다. </p>
</li>
<li><p>선언적 매핑: @RequestMapping 등의 어노테이션을 사용하여 URL 매핑을 선언적으로 처리할 수 있다. 이는 코드의 가독성과 유지 보수성을 높여 준다.</p>
</li>
<li><p>다양한 편의 기능: 데이터 바인딩, 유효성 검사, 예외 처리 등을 위한 풍부한 어노테이션과 클래스를 제공한다. </p>
</li>
</ul>
<h3 id="dispatcherservlet이-요청을-처리하는-방식">DispatcherServlet이 요청을 처리하는 방식</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/f295178b-4754-4210-803f-75906b8ef4f1/image.png" alt=""></p>
<p>Dispatcher Servlet은 HTTP 프로토콜로 들어오는 요청을 가장 먼저 받고, 적합한 세부 컨트롤러에 보내주는 역할을 한다. 즉 컨트롤러를 구현해두기만 하면 Dispathcer Servlet이 적합한 컨트롤러로 요청을 위임해준다. HandlerMapping과 HandlerAdapter는 다음과 같은 역할을 한다. </p>
<p>HandlerMapping : 요청을 적절한 컨트롤러에 매핑
HandlerAdapter : 컨트롤러의 메서드를 실행</p>
<ol>
<li>클라이언트가 /hello 요청을 DispatcherServlet에 전달</li>
<li>요청한 URL에 맞는 Controller를 HandlerMapping이 검색</li>
<li>DispatcherServlet이 HandlerAdapter를 통해 컨트롤러 실행 방법 결정</li>
<li>Interceptor의 preHandle() 실행 → 인증/권한 체크, 로깅 등 전처리</li>
<li>Controller 메소드 실행 → 비즈니스 로직 처리(Service, Repository 호출)</li>
<li>Interceptor의 postHandle() 실행 → Controller 실행 후, View 렌더링 전 단계에서 동작</li>
<li>ViewResolver가 Controller가 반환한 View 이름을 기반으로 실제 View 객체 선택</li>
<li>View가 최종 HTML/JSON 응답을 생성</li>
<li>Interceptor의 afterCompletion() 실행 → 뷰 렌더링 완료 후 리소스 정리, 예외 로그 처리</li>
<li>DispatcherServlet이 최종 응답을 클라이언트에게 반환</li>
</ol>
<p>이를 실행하는 Dispatcher Servlet의 주요 메서드를 보면 다음과 같다. </p>
<ul>
<li>getHandler(HttpServletRequest) : 등록된 HandlerMapping을 순회하면서 요청 URL과 매칭되는 핸들러를 찾는다. <pre><code class="language-java">protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
  for (HandlerMapping hm : this.handlerMappings) {
      HandlerExecutionChain handler = hm.getHandler(request);
      if (handler != null) {
          return handler;
      }
  }
  return null;
}
</code></pre>
</li>
</ul>
<pre><code>- getHandlerAdapter(Object handler) : 

```java
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
    for (HandlerAdapter ha : this.handlerAdapters) {
        if (ha.supports(handler)) {
            return ha;
        }
    }
    throw new ServletException(&quot;No adapter for handler [&quot; + handler +
                               &quot;]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler&quot;);
}
</code></pre><ul>
<li><p>doDispatch(HttpServletRequest, HttpServletResponse) : 요청을 실제로 처리하는 메인 메서드로, 요청을 적절한 핸들러에 매핑하고, 핸들러를 실행하고 결과를 처리한다. </p>
<pre><code class="language-java">protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
  HttpServletRequest processedRequest = request;
  HandlerExecutionChain mappedHandler = null;
  ModelAndView mv = null;

  try {
      mappedHandler = getHandler(processedRequest);
      if (mappedHandler == null) {
          noHandlerFound(processedRequest, response);
          return;
      }

      HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
      mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

      processDispatchResult(processedRequest, response, mappedHandler, mv, null);
  } catch (Exception ex) {
      dispatchException(processedRequest, response, mappedHandler, ex);
  } finally {
      cleanup(processedRequest, response, mappedHandler);
  }
}
</code></pre>
</li>
</ul>
<pre><code>
- processDispatchResult(HttpServletRequest, HttpServletResponse, HandlerExecutionChain, ModelAndView, Exception) : 모델 및 뷰 처리를 담당하는 메서드로, ModelAndView 객체를 사용하여 뷰를 랜더링하고 클라이언트에 응답을 보낸다. 
```java
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
                                   HandlerExecutionChain mappedHandler, ModelAndView mv, Exception exception)
                                   throws Exception {
    boolean errorView = false;
    if (exception != null) {
        if (exception instanceof ModelAndViewDefiningException) {
            mv = ((ModelAndViewDefiningException) exception).getModelAndView();
        } else {
            Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
            mv = processHandlerException(request, response, handler, exception);
            errorView = (mv != null);
        }
    }

    // Render the ModelAndView, if requested.
    if (mv != null &amp;&amp; !mv.wasCleared()) {
        render(mv, request, response);
        if (errorView) {
            WebUtils.clearErrorRequestAttributes(request);
        }
    } else {
        if (logger.isTraceEnabled()) {
            logger.trace(&quot;No view rendering, null ModelAndView returned.&quot;);
        }
    }

    if (WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted()) {
        return;
    }

    if (mappedHandler != null) {
        mappedHandler.triggerAfterCompletion(request, response, null);
    }
}
</code></pre><p>Spring Web MVC에서는 WebApplicationInitializer를 구현하면 컨테이너 초기화 작업을 처리해준다. </p>
<p>일반적으로는 스프링 컨테이너를 하나 만들고, 디스패처 서블릿도 하나만 생성하고 매핑 경로도 / 로 설정해서 하나의 디스패처 서블릿을 통해 모든 것을 처리하도록 한다.</p>
<pre><code class="language-java">public class MyWebApplicationInitializer implements WebApplicationInitializer {

    @Override
    public void onStartup(ServletContext servletContext) {

        // 스프링 컨테이너 생성
        AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
        context.register(AppConfig.class);

        // DispatcherServlet 생성 및 등록(컨테이너에 연결)
        DispatcherServlet servlet = new DispatcherServlet(context);
        ServletRegistration.Dynamic registration = servletContext.addServlet(&quot;app&quot;, servlet);
        registration.setLoadOnStartup(1);

        // /app/* 요청이 디스패처 서블릿을 통하도록 설정
        registration.addMapping(&quot;/app/*&quot;);
    }
}</code></pre>
<h2 id="aop">AOP</h2>
<p>Aspect-Oriented Programming (AOP)은 객체 지향 프로그래밍(OOP)을 보완하는 프로그래밍 패러다임이다.
OOP에서는 클래스가 모듈화의 기본 단위이지만, AOP에서는 Aspect가 그 역할을 한다. Aspect는 여러 타입과 객체에 걸쳐 있는 관심사(예: 트랜잭션 관리)를 모듈화하는 데 사용된다. 
Spring loC 컨테이너는 AOP에 의존하지 않지만, AOP는 Spring loC를 보완하여 유능한 미들웨어 솔루션을 제공한다. 
관점 지향 프로그래밍이라고 하는데, 어떤 로직을 핵심적 관점, 부가적 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것이다. </p>
<ul>
<li>핵심적 관점(Core Concerens) : 애플리케이션의 주요 기능을 담당하는 비즈니스 로직 부분이다. 예를 들어 사용자 정보를 데이터베이스에 저장하거나, 특정 계산을 수행하는 로직 등이 해당한다. </li>
<li>부가적 관점(Cross-cutting Concerens) : 로깅, 트랜젝션 관리, 보안, 에러 처리와 같이 여러 모듈이나 함수에서 공통적으로 사용되는 기능으로, 애플리케이션의 전반적인 품질,성능,보안과 관련된 기능이 해당한다. </li>
</ul>
<h3 id="aop의-모듈화">AOP의 모듈화</h3>
<p>AOP는 이러한 부가적 관점을, Aspect라는 별도의 모듈로 분리한다. Aspect 모듈은 특정 &quot;조인 포인트&quot;(Join points)에서 &quot;어드바이스&quot;(Advice)를 통해 핵심적인 비즈니스 로직에 적용된다.  조인 포인트는 애플리케이션 실행 중 Aspect가 적용될 수 있는 특정 지점을 의미하며, 어드바이스는 그 지점에서 실행되어야 할 코드를 말한다. </p>
<p>AOP는 다음과 같은 이유로 필요하다고 할 수 있다. </p>
<ul>
<li><p>중복 코드 감소: 로깅, 에러 처리, 트랜잭션 관리와 같은 반복적인 코드를 분리하여 관리할 수 있다.</p>
</li>
<li><p>모듈성 향상: 관련된 코드를 독립된 모듈(Aspect)로 분리하여 각 모듈이 주 업무 로직에 미치는 영향을 최소화한다. </p>
</li>
<li><p>유지보수 용이: Crosscutting concerns의 중앙집중화된 관리로 인해 시스템 전반의 변경이 용이해진다.</p>
</li>
</ul>
<h3 id="oop와-aop의-차이점">OOP와 AOP의 차이점</h3>
<ul>
<li><p>OOP (Object-Oriented Programming): OOP는 데이터와 기능을 객체로 묶어서 관리한다. 이런 접근법은 코드의 재사용, 확장 및 유지 관리를 쉽게 한다. 기본 원칙으로 상속, 다형성, 캡슐화를 포함한다.</p>
</li>
<li><p>AOP (Aspect-Oriented Programming): AOP는 부가적인 기능을 핵심 로직에서 분리해서 모듈화한다. 이는 코드의 중복을 줄이고, 유지 보수를 간편하게 만든다.</p>
</li>
</ul>
<h3 id="aop의-핵심-개념">AOP의 핵심 개념</h3>
<ul>
<li><p>Advice: 코드 실행의 특정 지점에서 실행되는 코드 블록이다. Before, After 같은 다양한 타입이 있다.</p>
</li>
<li><p>Join Point: 프로그램 실행 중에 Aspect의 코드가 적용될 수 있는 지점이다. 예를 들어 메소드 호출이나 필드 접근 등이다.</p>
</li>
<li><p>Pointcut: Advice가 적용될 Join Points를 정의한다. 이는 특정 조건에 맞는 Join Point를 선별한다.</p>
</li>
<li><p>Aspect: 부가적인 기능을 모듈화한 클래스다. 하나 이상의 Advice와 Pointcut을 포함할 수 있다.</p>
</li>
<li><p>Weaving: 컴파일 시간, 로드 시간, 또는 런타임에 Aspect를 주요 로직에 삽입하는 과정이다.</p>
<h3 id="런타임-위빙-vs-컴파일-타임-위빙">런타임 위빙 vs 컴파일 타임 위빙</h3>
</li>
<li><p>컴파일 타임 위빙: 소스 코드를 컴파일하는 단계에서 Aspect가 적용된다. AspectJ 같은 도구가 이를 사용한다.</p>
</li>
<li><p>런타임 위빙: 애플리케이션을 실행하는 동안에 Aspect가 동적으로 적용된다. Spring AOP가 이 방법을 사용한다.</p>
</li>
</ul>
<h3 id="spring에서-aop가-프록시-패턴proxy-pattern을-활용하여-동작하는-원리-분석">Spring에서 AOP가 프록시 패턴(Proxy Pattern)을 활용하여 동작하는 원리 분석</h3>
<p>Spring AOP는 프록시 기반 AOP를 사용한다. 이는 런타임 위빙을 통해 이루어지며, 스프링 빈에 등록해야 적용이 가능하다. </p>
<p>프록시 패턴은 구조적 디자인 패턴 중 하나인데, 특정 객체에 대한 접근을 제어하거나 추가 기능을 제공하기 위해 그 객체의 대리자나 대체물을 제공하는 방법이다. 
프록시는 실제 객체와 같은 인터페이스를 구현하므로, 클라이언트는 프록시를 통해 실제 객체를 사용하는 것처럼 동작할 수 있다. </p>
<p>프록시 패턴은 접근을 제어하거나, 부가 기능을 추가하고 싶을 때 사용한다. 스프링 AOP는 런타임시 동적으로 프록시객체를 만들어준다. 이 부분에서 중복 코드를 줄일 수 있는 것이다. </p>
<ul>
<li>Spring AOP 의존성을 추가 <pre><code class="language-xml">&lt;!-- Spring AOP 의존성 --&gt;
&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter-aop&lt;/artifactId&gt;
&lt;/dependency&gt;
</code></pre>
</li>
</ul>
<pre><code>
- 프록시 생성: @Aspect 어노테이션을 사용하여 로깅을 수행할 Aspect 클래스를 정의하고, @Component 어노테이션을 통해 빈으로 등록해준다. 

- 프록시의 메서드 호출 : 객체의 메소드를 호출할 때 실제로는 프록시의 메소드가 먼저 호출된다. 프록시는 필요한 Advice 로직을 실행한 다음, 실제 객체의 메소드를 호출한다. @Service 어노테이션을 사용하여 구현하는 서비스 클래스에서, Advice를 적용해준다.

- 부가 기능 실행: Advice에 정의된 부가 기능이 실행되고, 이로 인해 핵심 로직은 부가 기능과 분리된다.

## 기존 프로젝트에서의 AOP 적용

이전 프로젝트에서 AOP가 필요한 상황에 적용을 해보았다. 
- 호출 시간을 측정하고 싶은 경우
- 공통 관심 사항이거나, 핵심 관심 사항인 경우
Controller -&gt; Service -&gt; Reposiotry 순서로 시간을 측정하는 코드를 작성해봤다. 
독서 통계를 내는 API가 1-2초씩 지연되는 경우가 있기 때문에, 통계 관련 API를 대상으로 위와 같은 순서로 코드를 작성해볼 것이다. 책이 많을 경우에는 시간이 더 오래 걸릴 수 있기 때문이다. .


### 독서 통계 조회


기존 컨트롤러 코드
```java
    // 홈 화면 통계 조회
    @GetMapping(&quot;/insight&quot;)
    @Operation(summary = &quot;홈 화면 독서 통계 조회&quot;, description = &quot;사용자의 전체 책 수와 상태별 책 수를 반환합니다.&quot;)
    public ApiResponse&lt;BookShelfDTO.BooksInsightDTO&gt; getBooksInsight(@AuthenticationPrincipal CustomUserDetails userDetails) {
        BookShelfDTO.BooksInsightDTO insight = bookshelfService.viewBooksInsight(userDetails.getUser());
        return ApiResponse.onSuccess(insight,SuccessCode.OK);
    }</code></pre><p>BookShelfService 코드</p>
<pre><code class="language-java">    // 서재 통계 조회
    @Transactional(readOnly = true)
    public BookShelfDTO.BooksInsightDTO viewBooksInsight(User user) {
        return userBookshelfRepository.getBooksInsight(user);
    }</code></pre>
<pre><code class="language-java">
@Aspect 
@Component
@Slf4j
public class TimeTraceAspect {

    // 통계 관련 서비스 메소드만 타겟팅
    @Around(&quot;execution(* umc.nook.records.service.RecordService.view*(..)) &quot; +
           &quot;|| execution(* umc.nook.bookshelf.service.BookShelfService.view*(..))&quot;)
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        String method = joinPoint.getSignature().toShortString();

        log.info(&quot;[START] {}&quot;, method);
        try {
            return joinPoint.proceed();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            log.info(&quot;[END] {} 실행시간={}ms&quot;, method, timeMs);
        }
    }
}</code></pre>
<ol>
<li><p>@Aspect : 해당 클래스가 AOP 기능을 가진다고 선언하는 어노테이션이다. </p>
</li>
<li><p>@Component : 스프링 빈으로 등록해서 자동으로 적용되도록 해준다. </p>
</li>
<li><p>@Around : AOP 대상이 되는 메소드의 실행 전후를 감싸주는데, 원하는 시점에 실행이 가능해도록 해준다. 즉 실행 전,후,예외 발생하는 경우 모두 제어가 가능하다. execution(..) 표현식으로, 어떤 메소드에 적용할지를 지정해준다. 서재에 등록한 책들을 조회하는 메서드와 독서 기록을 조회하는 메서드들의 실행 시간을 측정하고 싶은 것이기 때문에, view로 시작하는 모든 메서드들을 적용 대상으로 지정해준다. </p>
</li>
<li><p>실행 시작 시간을 먼저 기록해준다. </p>
</li>
<li><p>JoinPoint : Advice가 적용될 수 있는 지점인데, 메소드 실행 시점을 말한다. 실행 시작 시간을 기록한 뒤에, 어떤 메소드를 적용했는지, 메소드명을 String으로 받아온다. </p>
</li>
<li><p>ProceedingJoinPoint : proceed()를 호출해서, 대상이 되는 메소드의 실제 실행을 제어할 수 있다. 실제 대상이 되는 메소드(독서 통계 관련)를 proceed() 를 통해 실행해해준다. </p>
</li>
<li><p>이제 실행이 끝났으니 finally 블록에서는 먼저 실행 종료 시간을 기록하고, 실행 시간을 계산해준다. </p>
</li>
<li><p>로그에 실행이 종료되었음을 명시해주고, 실행시간을 표시해준다. </p>
</li>
</ol>
<p>시간 측정 AOP를 등록해봤다. 프록시 역할을 하기 때문에, 의존 관계가 다음과 같이 변화한다. AOP 등록 전에는 사용자가 API를 호출하면 다음과 같은 흐름으로 들어온다. </p>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/5a51efd2-9fd0-4976-8302-3a58cbbbfdd9/image.png" alt=""></p>
<p>Spring이 @Aspect로 등록된 클래스를 보고, 해당 빈 대신에 프록시 객체를 생성해둔다. 즉 메소드가 호출되면, 프록시가 가로채가는 것이다. execution(..) 안의 표현식과 같은 조건에 부합하는지 확인한다. 부합하면 @Around Advice를 실행한다. 즉 통계 메소드라면, 위의 코드를 먼저 실행하는 것이다. 통계가 아니라면 원래 메소드를 바로 실행한다.
실행 시간, 결과 로그를 출력하고, 트랜잭션을 종료한다. 
joinPoint.proceed()가 호출될 경우 대상 서비스의 비즈니스 메소드가 실행되고, 결과가 다시 @Around로 돌아와서 로그를 출력한다.그리고 실행한 메소드의 결과값을 클라이언트에 출력한다. </p>
<p>전체적으로 다음과 같이 변화한다. 메서드 호출 시, proxy가 먼저 호출된다. 
<img src="https://velog.velcdn.com/images/jayaione_ele/post/51880571-9e35-4dd8-bb15-c19ae6b7322e/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UMC][Server] API 설계하기 ]]></title>
            <link>https://velog.io/@jayaione_ele/UMCServer-API-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/UMCServer-API-%EC%84%A4%EA%B3%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 01 Apr 2025 16:12:59 GMT</pubDate>
            <description><![CDATA[<h2 id="soft-delete">Soft Delete</h2>
<p>Soft Delete는 데이터를 실제로 삭제하지 않고, 삭제된 것처럼 표시만 해두는 방식이다. 
데이터베이스에서는 보통 is_deleted, deleted_at 같은 필드를 활용해 삭제 여부를 판단한다.삭제한 데이터를 복구하거나 삭제 이력을 추적할 수 있도록 할 때 사용한다. </p>
<blockquote>
<p>장점 </p>
<ul>
<li>실수로 삭제한 데이터 복구 가능</li>
<li>삭제 이력 추적 가능
통계/로그 보존</li>
</ul>
</blockquote>
<p>즉 삭제 여부를 표시하는 플래그를 사용하는 것이기 때문에 실제 DB에서는 삭제되지는 않고, 다음과 같이 쿼리를 실행할 수 있다. </p>
<pre><code class="language-sql">UPDATE reviews SET is_deleted = true WHERE id = 1;</code></pre>
<p>HTTP 메서드는 일반적으로 해당 API의 작업이 무엇인지 명시하는 역할을 한다. 클라이언트 입장에서도 데이터를 삭제하는 것이고, 삭제 필드를 변경한 것이고 조회 시에도 표시되지 않도록 하는 작업이기 때문에, HTTP 메서드는DELETE를 사용한다. </p>
<pre><code>DELETE /api/reviews/1</code></pre><h2 id="컨트롤-uri">컨트롤 URI</h2>
<h3 id="✅-컨트롤-uri란">✅ 컨트롤 URI란?</h3>
<p>RESTful 구조에서 리소스의 상태를 제어하거나 동작을 트리거하는 API URI이다. 
리소스를 직접 다루는 것이 아니라, 그 리소스에 대해 추가 동작을 요청하는 경우에 사용한다. 주로 PATCH,POST 메서드와 함께 사용되며, URI에는 동작을 명시하는 명사/동사 구조를 사용한다. </p>
<h3 id="예시">예시</h3>
<pre><code>PATCH /api/missions/1/complete</code></pre><p>해당 API는 id가 1인 미션을 성공 상태로 처리한다. 즉 리소스에 대해 상태만 변경하는 것이다. </p>
<pre><code>POST /api/users/1/deactivate</code></pre><p>일반적으로 유저의 상태 필드를 active, inactive 등으로 설정한다. 유저가 탈퇴할 경우 위와 같이 유저의 상태를 비활성화시키고, 30일이 지나면 유저 정보를 자동으로 삭제하거나 하는 방식으로 설정할 수 있다. </p>
<h2 id="문서-분석--microsoft-restful-웹-api-디자인">문서 분석 : Microsoft RESTful 웹 API 디자인</h2>
<p><a href="https://learn.microsoft.com/ko-kr/azure/architecture/best-practices/api-design">Microsoft RESTful 웹 API 디자인</a></p>
<h3 id="✅-rest란">✅ Rest란?</h3>
<p>Rest(Representational State Transfer)는 웹 서비스를 디자인하는 아키텍처 접근 방식으로 제안된 것이다. REST는 특정 프로토콜에 의존하는 것은 아니지만, 대부분의 REST API 구현은 HTTP를 애플리케이션 프로토콜로 사용하고 있다. </p>
<h3 id="✅-http---rest-api-기본-설계-원칙">✅ HTTP - REST API 기본 설계 원칙</h3>
<h4 id="1-리소스를-중심으로-설계되어야-한다">1. 리소스를 중심으로 설계되어야 한다.</h4>
<ul>
<li>동사가 아닌 명사를 기반으로 해야 한다. </li>
</ul>
<p>api가 표시하려는 비즈니스 엔터티에 집중해야 한다. 주문,고객이라는 엔터티를 포함해서 주문을 하는 api를 설계하려고 한다면, 주문을 한다라는 행동이 아니라 주문에 집중해야 한다. </p>
<ul>
<li>내부 구조를 반영하지 않도록 해야 한다. </li>
</ul>
<p>실제 데이터 항목을 기반으로 할 필요는 없으며, 해당 uri가 클라이언트에 포함되는 만큼, 클라이언트에서 내부 구현과 관련된 부분을 포함하지 않도록 해야 한다. </p>
<ul>
<li>리소스 간 계층 구조를 구성하도록 하는 것이 좋다.</li>
</ul>
<p>컬렉션/항목/컬렉션과 같은 구조로 하면 api를 직관적으로 구성할 수 있다. 접근해야할 엔터티가 늘어난다고 해서 컬렉션/항목/컬렉션보다 더 복잡한 구조를 가지지 않도록 하는 것이 좋다. 다수의 작은 리소스를 포함하는 api의 경우 해당 요청을 많이 보내야하고, 요청이 많을수록 부하가 커지기 때문이다. 단일 요청을 통해 관련 정보를 얻을 수 있도록 더 큰 리소스와 결합하는 구조로 하는 것이 좋을 수도 있다. </p>
<h4 id="2-http-메서드를-사용해야-한다">2. HTTP 메서드를 사용해야 한다.</h4>
<p>GET, POST, PUT, PATCH, DELETE 와 같은 HTTP 메서드의 측면에서 API의 작업을 정의해야 한다. 
PUT,PATCH의 개념이 헷갈릴 수 있는데, PUT요청은 리소스를 생성하거나 업데이트하는 것이고 PATCH는 기존 리소스의 부분 업데이트를 실행한다. PUT 요청은 idempotent여야한다. 클라이언트에서 동일한 PUT 요청을 여러번 제출하더라도 결과가 항상 같아야 한다는 것이다. </p>
<h4 id="3-http-의미-체계를-준수해야-한다">3. HTTP 의미 체계를 준수해야 한다.</h4>
<ul>
<li><p>상태 코드를 명시해야 한다. 
200(성공), 201(생성), 204(내용 없음), 400(잘못된 요청), 404(없음), 409(충돌)</p>
</li>
<li><p>형식을 명시해야 한다.
Content-Type 및 Accept 헤더로 JSON, XML 등 형식 명시</p>
</li>
</ul>
<h3 id="✅-고급-기술">✅ 고급 기술</h3>
<h4 id="hateoashypermedia-as-the-engine-of-application-state">HATEOAS(Hypermedia as the Engine of Application State)</h4>
<p>각 응답에 다음 동작을 위한 링크를 제공해야 한다. 
클라이언트는 URI를 기억하지 않고도 링크를 따라 리소스를 탐색이 가능해야 한다. 
예를 들어 사용자가 작성한 리뷰 조회, 리뷰 단일 조회라는 두 api가 있다면 클라이언트가 따로 uri를 기억할 필요 없이 링크를 따라 조회가 가능하도록 응답을 구성해야 한다. </p>
<p>리뷰 조회</p>
<pre><code class="language-json">{
  &quot;userId&quot;: 3,
  &quot;reviews&quot;: [
    {
      &quot;reviewId&quot;: 10,
      &quot;title&quot;: &quot;좋은 상품이에요&quot;,
      &quot;rating&quot;: 5,
      &quot;links&quot;: [
        { &quot;rel&quot;: &quot;self&quot;, &quot;href&quot;: &quot;/reviews/10&quot;, &quot;action&quot;: &quot;GET&quot; },
        { &quot;rel&quot;: &quot;delete&quot;, &quot;href&quot;: &quot;/reviews/10&quot;, &quot;action&quot;: &quot;DELETE&quot; },
        { &quot;rel&quot;: &quot;edit&quot;, &quot;href&quot;: &quot;/reviews/10&quot;, &quot;action&quot;: &quot;PUT&quot; }
      ]
    }
}</code></pre>
<p>리뷰 단건 조회 </p>
<pre><code class="language-json">{
  &quot;reviewId&quot;: 10,
  &quot;userId&quot;: 3,
  &quot;title&quot;: &quot;좋은 상품이에요&quot;,
  &quot;content&quot;: &quot;정말 마음에 들어요!&quot;,
  &quot;rating&quot;: 5,
  &quot;links&quot;: [
    { &quot;rel&quot;: &quot;self&quot;, &quot;href&quot;: &quot;/reviews/10&quot;, &quot;action&quot;: &quot;GET&quot; },
    { &quot;rel&quot;: &quot;edit&quot;, &quot;href&quot;: &quot;/reviews/10&quot;, &quot;action&quot;: &quot;PUT&quot; },
    { &quot;rel&quot;: &quot;delete&quot;, &quot;href&quot;: &quot;/reviews/10&quot;, &quot;action&quot;: &quot;DELETE&quot; },
    { &quot;rel&quot;: &quot;user&quot;, &quot;href&quot;: &quot;/users/3&quot;, &quot;action&quot;: &quot;GET&quot; }
  ]
}
</code></pre>
<h3 id="버전-관리">버전 관리</h3>
<p>api는 리소스 구조나 기능이 변경될 수 있기 때문에, 버전 관리는 클라이언트의 호환성을 유지하는 데 중요하다. API 버전 관리는 여러 방식으로 구현할 수 있으며, 각각 장단점이 있다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>설명</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>URI 버전 관리</strong><br><code>/v1/orders</code></td>
<td>URI 경로에 버전 명시</td>
<td>명확하고 간편</td>
<td>HATEOAS와 충돌 가능</td>
</tr>
<tr>
<td><strong>쿼리 문자열</strong><br><code>/orders?version=2</code></td>
<td>쿼리 파라미터로 버전 전달</td>
<td>URI 재사용</td>
<td>캐싱 문제 발생 가능</td>
</tr>
<tr>
<td><strong>헤더 기반 버전 관리</strong><br><code>Custom-Header: version=2</code></td>
<td>사용자 정의 헤더로 버전 명시</td>
<td>URI 깔끔</td>
<td>숨겨져 있어서 명시성 낮음</td>
</tr>
<tr>
<td><strong>미디어 타입 버전 관리</strong><br><code>Accept: application/vnd.company.v2+json</code></td>
<td>MIME 타입에 버전 포함</td>
<td>HATEOAS 친화적, 유연함</td>
<td>구현 복잡, 인지 난이도 있음</td>
</tr>
</tbody></table>
<p>성능을 고려한다면 캐시 관점에서도 URI 기반이 유리하고, swagger에서도 주로 URI 버전 관리 방식이 사용된다. </p>
<h4 id="필터링--페이지네이션">필터링 &amp; 페이지네이션</h4>
<p>데이터 조회 시, 쿼리 파라미터로 구현해서 필요한 데이터만 효율적으로 조회하는 방식이 권장된다. </p>
<pre><code class="language-http">/orders?minPrice=100&amp;sort=price&amp;limit=10&amp;offset=20</code></pre>
<blockquote>
<p>기본값 설정 권장: limit=10, offset=0 등
필드 선택: /orders?fields=id,name,price
응답에도 총 리소스 수 등의 메타데이터를 포함하는 것이 권장된다. </p>
</blockquote>
<h4 id="대용량-리소스-처리">대용량 리소스 처리</h4>
<p>대용량 리소스에 대한 응답을 설계할 때는, Accept-Ranges, HEAD, Range 메서드를 사용하여 부분 응답을 지원할 수 있다. 이미지나 PDF 등의 대용량 리소스 응답 시에 유용한 방법이다. </p>
<pre><code class="language-http">GET /images/1 HTTP/1.1  
Range: bytes=0-1023  
</code></pre>
<p>이렇게 요청을 보낸다면, <code>206 Partial Content</code> 이렇게 응답 상태 코드를 보낼 수 있다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UMC] SQL 쿼리 작성 및 분석해보기]]></title>
            <link>https://velog.io/@jayaione_ele/UMC-SQL-%EC%BF%BC%EB%A6%AC-%EC%9E%91%EC%84%B1-%EB%B0%8F-%EB%B6%84%EC%84%9D%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/UMC-SQL-%EC%BF%BC%EB%A6%AC-%EC%9E%91%EC%84%B1-%EB%B0%8F-%EB%B6%84%EC%84%9D%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Wed, 26 Mar 2025 04:20:57 GMT</pubDate>
            <description><![CDATA[<ol>
<li>(내가 진행중, 진행 완료한 미션 모아서 보는 쿼리(페이징 포함))에서 정렬 기준을 1순위는 포인트로 2순위는 최신순으로 하여 Cursor기반 페이지네이션을 구현해보세요</li>
</ol>
<pre><code class="language-sql">SELECT m.content, m.point, u.status, m.store_id
FROM (
    SELECT * FROM user_missions
    WHERE status IN (&#39;SUCCESS&#39;, &#39;PROGRESS&#39;)
) u
JOIN (
    SELECT mission_id, content, point, store_id FROM missions
) m ON u.mission_id = m.mission_id
WHERE (u.start_at, u.mission_id) &gt; (#{cursorStartAt}, #{cursorMissionId})
ORDER BY m.point DESC ,u.start_at DESC
LIMIT #{size};</code></pre>
<ol start="2">
<li>SQL Injection에 대해 조사하고 어떠할 때 일어나고 어떻게 막을 수 있는 지를 적어주세요</li>
</ol>
<p>SQL Injection은 사용자가 데이터를 입력하는 부분에서, 유효성을 검사하지 못하는 부분을 활용하여 데이터베이스에 접근하기 위한 SQL 명령을 악의적으로 전달하는 것이다. 데이터베이스 권한을 획득하거나, 정보를 직접 검색함으로써 사용자의 데이터를 무단 침해할 수 있다. </p>
<p>사용자의 정보를 입력하는 로그인 부분을 예로 들어 볼 수 있다. 
username, password를 입력하고 로그인 버튼을 클릭하면 실행되는 쿼리를 다음과 같이 작성할 수 있다. </p>
<pre><code>&quot;SELECT Count(*) FROM WHERE username =&#39; &quot; + txt.User.Text+&quot; &#39; AND Password=&#39; &quot; + txt.Password.Text+&quot; &#39; &quot;;</code></pre><p>사용자 이름을 admin으로
입력한 유저가 존재하는지 확인하는 쿼리이다. 해커들은 Magical String이라는 문자열을 사용해서 접근 권한을 획득한다. </p>
<pre><code>&quot;SELECT Count(*) FROM Users WHERE Username=&#39; admin &#39; AND Password=&#39; anything &#39;or&#39;1&#39;=&#39;1 &#39; &quot;;</code></pre><p>1=1이 항상 true이기 때문에 admin 권한을 얻을 수 있다. </p>
<ol start="3">
<li>다양한 JOIN 방법들에 대해 찾아보고, 각 방식에 대해 비교하여 간단히 정리해주세요.</li>
</ol>
<table>
<thead>
<tr>
<th>JOIN 종류</th>
<th>설명</th>
<th>결과 예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>INNER JOIN</code></td>
<td>두 테이블에서 <strong>일치하는 값</strong>만 반환</td>
<td>A와 B 테이블 모두에 존재하는 공통된 행만 보여줌</td>
</tr>
<tr>
<td><code>LEFT JOIN</code></td>
<td>왼쪽 테이블의 <strong>모든 행</strong>과, 오른쪽 테이블에서 일치하는 값 반환</td>
<td>왼쪽 테이블에 있는 데이터는 모두 보이고, 오른쪽 테이블에 없으면 <code>NULL</code>로 채움</td>
</tr>
<tr>
<td><code>RIGHT JOIN</code></td>
<td>오른쪽 테이블의 <strong>모든 행</strong>과, 왼쪽 테이블에서 일치하는 값 반환</td>
<td>오른쪽 테이블 데이터는 모두 보이고, 왼쪽 테이블에 없으면 <code>NULL</code>로 채움</td>
</tr>
<tr>
<td><code>FULL JOIN</code></td>
<td>양쪽 테이블의 <strong>모든 행</strong> 반환, 일치하지 않는 행은 <code>NULL</code>로 채움</td>
<td>INNER + LEFT + RIGHT 조합, 양쪽 모두 포함</td>
</tr>
<tr>
<td><code>CROSS JOIN</code></td>
<td>두 테이블의 <strong>모든 조합(곱집합)</strong> 반환</td>
<td></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UMC][Spring Boot] 정규화, 동시성 문제 알아보기  ]]></title>
            <link>https://velog.io/@jayaione_ele/UMC8%EA%B8%B0Spring-Boot-1%EC%A3%BC%EC%B0%A8-%EC%8B%9C%EB%8B%88%EC%96%B4-%EB%AF%B8%EC%85%98</link>
            <guid>https://velog.io/@jayaione_ele/UMC8%EA%B8%B0Spring-Boot-1%EC%A3%BC%EC%B0%A8-%EC%8B%9C%EB%8B%88%EC%96%B4-%EB%AF%B8%EC%85%98</guid>
            <pubDate>Sat, 22 Mar 2025 18:17:10 GMT</pubDate>
            <description><![CDATA[<h2 id="1번">1번</h2>
<blockquote>
<p>미션 자료를 보고 ERD를 설계한 후 제 1,2,3 정규화를 통해 제 1,2,3 정규형을 만들고 각각 중복된 데이터가 어떻게 변화하였고 어떠한 이점이 있었는 지 작성하기</p>
</blockquote>
<p>정규화 개념을 먼저 짚고 넘어가자면, </p>
<blockquote>
<ol>
<li>제1정규형 : 각 속성이 원자값을 보장해야 한다.</li>
<li>제2정규형: 부분 함수 종속이 제거되어야 한다. </li>
<li>제3정규형: 이행 함수 종속이 제거되어야 한다. </li>
</ol>
</blockquote>
<p>정규화를 거쳤던 테이블을 보면서, 설계 이전에 제 1,2,3 정규화를 어떻게 진행했었는지 보려고 한다. </p>
<h3 id="가게-지역">가게, 지역</h3>
<p>지역별로 가게가 있을 수 있다. 이에 따르면 다음과 같은 함수 종속성이 있음을 알 수 있다. </p>
<p><code>가게번호 -&gt; 지역번호 -&gt; 지역명</code></p>
<p>지역 번호는 지역명을 결정하지만, 가게 테이블의 기본키가 아니므로 지역명이 지역번호에 종속되는 것은 이행 함수 종속이 될 수 있다. 지역명을 가게 테이블에서 분리를 해줘야 한다는 것이다. 이 경우 가게와 지역 테이블로 분리를 해주면 이행 종속이 제거된다. 가게 테이블에서 지역 테이블을 참조하고, 지역 테이블에서 지역번호와 지역명을 가지고 있으면 된다. </p>
<p>다음과 같이 설계하면 제3정규형을 만족한다. </p>
<p>가게 (stores)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>store_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>가게 번호</td>
</tr>
<tr>
<td>longitude</td>
<td>DECIMAL</td>
<td>NOT NULL</td>
<td>위도</td>
</tr>
<tr>
<td>latitude</td>
<td>DECIMAL</td>
<td>NOT NULL</td>
<td>경도</td>
</tr>
<tr>
<td>type</td>
<td>enum</td>
<td>NOT NULL</td>
<td>가게 유형</td>
</tr>
<tr>
<td>address</td>
<td>varchar(10)</td>
<td>NOT NULL</td>
<td>주소</td>
</tr>
<tr>
<td>status</td>
<td>enum</td>
<td>NOT NULL</td>
<td>영업 상태 (OPEN, CLOSED 등)</td>
</tr>
<tr>
<td>created_at</td>
<td>datetime</td>
<td>NOT NULL</td>
<td>생성일자</td>
</tr>
<tr>
<td>updated_at</td>
<td>datetime</td>
<td>NOT NULL</td>
<td>수정일자</td>
</tr>
<tr>
<td>store_score</td>
<td>float</td>
<td>NOT NULL</td>
<td>가게 평점</td>
</tr>
<tr>
<td>region_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>지역 번호</td>
</tr>
</tbody></table>
<p>지역 (regions)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>region_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>지역 번호</td>
</tr>
<tr>
<td>region_name</td>
<td>varchar(10)</td>
<td>NOT NULL</td>
<td>지역명</td>
</tr>
</tbody></table>
<h3 id="사진">사진</h3>
<p>리뷰, 문의 글에는 사진을 여러 장 업로드할 수가 있다. 사진에는 순서가 있고, 사진을 업로드할 시 생성되는 URL이 있다. </p>
<p>리뷰에 사진이 여러 장 올 수 있으므로, 이미지 URL이라는 속성에 여러 값이 오게 되는데, 이렇게 되면 제1정규형을 만족할 수 없다. 속성은 원자값이 되어야 하기 때문이다. 그러므로 리뷰 사진, 리뷰 테이블로 분리해야 한다. 리뷰 사진에서는 리뷰 번호를 외래키로 참조함으로써, 어떤 리뷰의 사진인지를 알 수 있도록 한다. 문의 테이블도 동일하게 설계해 주면 된다. </p>
<p>리뷰 (reviews)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>review_img_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>리뷰 이미지 ID</td>
</tr>
<tr>
<td>img_url</td>
<td>varchar</td>
<td>NOT NULL</td>
<td>이미지 URL</td>
</tr>
<tr>
<td>review_order</td>
<td>int</td>
<td>NOT NULL</td>
<td>리뷰 사진 순서</td>
</tr>
<tr>
<td>is_main</td>
<td>boolean</td>
<td>NOT NULL</td>
<td>메인 여부</td>
</tr>
<tr>
<td>review_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>리뷰 ID</td>
</tr>
</tbody></table>
<p>리뷰 사진 (review_images)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>review_img_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>리뷰 이미지 ID</td>
</tr>
<tr>
<td>img_url</td>
<td>varchar</td>
<td>NOT NULL</td>
<td>이미지 URL</td>
</tr>
<tr>
<td>review_order</td>
<td>int</td>
<td>NOT NULL</td>
<td>리뷰 사진 순서</td>
</tr>
<tr>
<td>is_main</td>
<td>boolean</td>
<td>NOT NULL</td>
<td>메인 여부</td>
</tr>
<tr>
<td>review_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>리뷰 ID</td>
</tr>
</tbody></table>
<h3 id="리뷰---사용자">리뷰 - 사용자</h3>
<p>리뷰를 작성한 사용자가 있을 것이기 때문에 다음과 같이 종속성을 나타낼 수 있다. </p>
<p>리뷰 번호 -&gt; 사용자 번호 -&gt; 사용자 이름 </p>
<p>이 경우에도 리뷰 테이블에서 사용자 번호가 기본키가 아니지만, 사용자 이름을 결정할 수 있으므로 이행 함수 종속이 된다. 그러므로 리뷰 테이블과 사용자 테이블을 분리해야 한다. </p>
<p>*<em>1. 분리하지 않고 저장한다면 사용자 이름 데이터가 중복 저장될 수 있기 때문이다. *</em></p>
<table>
<thead>
<tr>
<th>리뷰 번호</th>
<th>사용자 번호</th>
<th>사용자 이름</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>4</td>
<td>김수정</td>
<td>맛있어요</td>
</tr>
<tr>
<td>2</td>
<td>4</td>
<td>김수정</td>
<td>또 왔어요</td>
</tr>
<tr>
<td>3</td>
<td>44</td>
<td>김영희</td>
<td>별로예요</td>
</tr>
</tbody></table>
<p>김수정이라는 이름이 여러 번 저장되므로 비효율적이다. 사용자 수가 많아질수록 중복된 데이터가 많아질 수밖에 없기 때문이다.</p>
<p>*<em>2. 사용자 이름이 변경될 경우에도 리뷰 테이블에서 해당 사용자가 작성한 리뷰 데이터를 모두 수정해야 하기 때문에, 사용자 이름을 사용자 테이블에서 따로 관리하는 방법이 바람직하다. *</em></p>
<p>위의 테이블에서 김수정이라는 이름을 이수정으로 변경하고 싶다면, 사용자 번호가 4인 리뷰를 모두 수정해야 한다. 사용자 테이블에서만 사용자 이름을 저장하도록 한다면, 리뷰 입장에서는 사용자 번호만 가지고 있으므로 수정이 발생하더라도 리뷰 테이블에서는 따로 수정할 필요가 없다. 
사용자를 다음과 같이 설계했다. </p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>user_id</td>
<td>BIGINT</td>
<td>PK, AUTO_INCREMENT</td>
<td>사용자 고유 ID</td>
</tr>
<tr>
<td>name</td>
<td>VARCHAR(50)</td>
<td>NOT NULL</td>
<td>사용자 이름</td>
</tr>
<tr>
<td>email</td>
<td>VARCHAR(100)</td>
<td>UNIQUE, NOT NULL</td>
<td>사용자 이메일</td>
</tr>
<tr>
<td>phone_num</td>
<td>VARCHAR(20)</td>
<td>UNIQUE, NULLABLE</td>
<td>전화번호</td>
</tr>
<tr>
<td>is_phone_authorized</td>
<td>BOOLEAN</td>
<td>DEFAULT FALSE</td>
<td>전화번호 인증 여부</td>
</tr>
<tr>
<td>point</td>
<td>INT</td>
<td>DEFAULT 0</td>
<td>포인트</td>
</tr>
<tr>
<td>created_at</td>
<td>DATETIME</td>
<td>DEFAULT NOW()</td>
<td>가입 일시</td>
</tr>
<tr>
<td>updated_at</td>
<td>DATETIME</td>
<td>자동 갱신 트리거 등</td>
<td>수정 일시</td>
</tr>
</tbody></table>
<p>이렇게 정규화 과정을 거쳤고, 다음과 같이 ERD를 설계했다. </p>
<p>미션 (missions)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>mission_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>미션 번호</td>
</tr>
<tr>
<td>point</td>
<td>bigint</td>
<td>NOT NULL</td>
<td>포인트</td>
</tr>
<tr>
<td>mission_number</td>
<td>bigint</td>
<td>NOT NULL</td>
<td>사장님 구분 번호</td>
</tr>
<tr>
<td>content</td>
<td>varchar(255)</td>
<td>NOT NULL</td>
<td>미션 내용</td>
</tr>
<tr>
<td>created_at</td>
<td>datetime</td>
<td>NOT NULL</td>
<td>생성일자</td>
</tr>
<tr>
<td>store_id</td>
<td>bigint</td>
<td>-</td>
<td>가게 번호</td>
</tr>
<tr>
<td>period</td>
<td>time</td>
<td>NOT NULL</td>
<td>수행 기간</td>
</tr>
<tr>
<td>status</td>
<td>enum</td>
<td>NOT NULL</td>
<td>상태 (AVAILABLE 등)</td>
</tr>
</tbody></table>
<p>사용자 미션 (user_missions)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>user_mission_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>사용자 미션 번호</td>
</tr>
<tr>
<td>start_at</td>
<td>datetime</td>
<td>NOT NULL</td>
<td>미션 시작 일자</td>
</tr>
<tr>
<td>mission_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>미션 ID</td>
</tr>
<tr>
<td>user_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>사용자 ID</td>
</tr>
<tr>
<td>status</td>
<td>enum</td>
<td>NOT NULL</td>
<td>상태 (SUCCESS 등)</td>
</tr>
</tbody></table>
<p>알림 (alarms)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>alarm_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>알림 ID</td>
</tr>
<tr>
<td>content</td>
<td>varchar</td>
<td>NOT NULL</td>
<td>알림 내용</td>
</tr>
<tr>
<td>type</td>
<td>enum</td>
<td>NOT NULL</td>
<td>알림 유형</td>
</tr>
<tr>
<td>is_read</td>
<td>boolean</td>
<td>-</td>
<td>읽음 여부</td>
</tr>
<tr>
<td>created_at</td>
<td>DATETIME</td>
<td>NOT NULL</td>
<td>생성 시간</td>
</tr>
<tr>
<td>user_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>사용자 ID</td>
</tr>
</tbody></table>
<p>리뷰 (reviews)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>review_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>리뷰 ID</td>
</tr>
<tr>
<td>review_score</td>
<td>int</td>
<td>NOT NULL</td>
<td>평점</td>
</tr>
<tr>
<td>content</td>
<td>varchar</td>
<td>NOT NULL</td>
<td>리뷰 내용</td>
</tr>
<tr>
<td>created_at</td>
<td>DATETIME</td>
<td>NOT NULL</td>
<td>작성 시간</td>
</tr>
<tr>
<td>updated_at</td>
<td>updated_at</td>
<td>-</td>
<td>수정 시간</td>
</tr>
<tr>
<td>store_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>가게 ID</td>
</tr>
<tr>
<td>user_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>사용자 ID</td>
</tr>
</tbody></table>
<p>문의 (inquiries)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>inquiry_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>문의 ID</td>
</tr>
<tr>
<td>type</td>
<td>varchar</td>
<td>-</td>
<td>문의 유형</td>
</tr>
<tr>
<td>title</td>
<td>varchar(10)</td>
<td>NOT NULL</td>
<td>문의 제목</td>
</tr>
<tr>
<td>content</td>
<td>varchar(255)</td>
<td>NOT NULL</td>
<td>문의 내용</td>
</tr>
<tr>
<td>is_replied</td>
<td>boolean</td>
<td>-</td>
<td>답변 여부</td>
</tr>
<tr>
<td>created_at</td>
<td>datetime</td>
<td>-</td>
<td>생성 시간</td>
</tr>
<tr>
<td>updated_at</td>
<td>datetime</td>
<td>-</td>
<td>수정 시간</td>
</tr>
<tr>
<td>user_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>사용자 ID</td>
</tr>
</tbody></table>
<p>문의 사진 (inquiry_images)</p>
<table>
<thead>
<tr>
<th>컬럼명</th>
<th>타입</th>
<th>제약조건</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>inquiry_img_id</td>
<td>bigint</td>
<td>PK, NOT NULL</td>
<td>문의 사진 ID</td>
</tr>
<tr>
<td>img_url</td>
<td>varchar(50)</td>
<td>NOT NULL</td>
<td>이미지 URL</td>
</tr>
<tr>
<td>inquiry_order</td>
<td>int</td>
<td>NOT NULL</td>
<td>문의 이미지 순서</td>
</tr>
<tr>
<td>is_main</td>
<td>boolean</td>
<td>NOT NULL</td>
<td>메인 여부</td>
</tr>
<tr>
<td>inquiry_id</td>
<td>bigint</td>
<td>NOT NULL, FK</td>
<td>문의 ID</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/bea967e3-9b43-4684-b86a-2cf5d6ad164c/image.png" alt=""></p>
<h2 id="2번">2번</h2>
<blockquote>
<p>가게마다 미션이 있고, 사용자는 미션에 도전할 수 있다. 한 사람이 “미션 도전!” 버튼을 빠르게 여러 번 눌렀을 때 여러 가지 이유(비동기 로직 등)로 요청이 지연되어 완전히 처리하기 전 두 번 요청이 들어갈 수 있다. 이를 해결할 수 있는 방법을 생각해보기</p>
</blockquote>
<p>사용자는 하나의 미션에 한번만 참여할 수가 있는데, 미션 도전 버튼을 여러 번 누르게 되면, 하나의 사용자에 대해서 여러 개의 &#39;사용자 미션&#39; 이라는 레코드가 여러 개가 생길 수 있다는 것이다. 이를 해결할 수 있는 방법에 대해 알아볼 것이다. </p>
<h3 id="1-유일키unique-key-제약조건-지정하기">1. 유일키(Unique Key) 제약조건 지정하기</h3>
<ul>
<li>값 중복을 허용하지 않는다. 각 행을 고유하기 식별하는 데에 사용되기 때문이다. </li>
<li>NULL값을 허용한다. 중복된 값은 될 수 없지만 NULL이 될 수는 있다. </li>
<li>기본키와 달리 여러 개의 유니크키가 존재할 수 있다. </li>
</ul>
<p>사용자 미션에는 사용자번호, 미션번호라는 속성이 존재한다. 이 두 속성이 중복되지 않도록 하기 위해 유니크 제약조건을 다음과 같이 설정하면 된다. </p>
<pre><code>UNIQUE (user_id, mission_id)</code></pre><p>이렇게 하면 INSERT문 실행 시에 중복된 값이 존재하는지 확인하므로, 중복된 값이 들어가는 것을 방지할 수 있다. </p>
<h3 id="2-유니크-인덱스unique-index-사용하기">2. 유니크 인덱스(Unique index) 사용하기</h3>
<p>인덱스는 추가적인 쓰기 작업과 저장 공간을 활용하여 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조이다. 
데이터가 매우 많을 경우, 원하는 데이터를 찾으려면 시간이 많이 걸린다. 데이터와 데이터의 위치를 포함한 자료구조인 인덱스를 생성하면 원하는 데이터를 빠르게 조회할 수 있다. 
일반적으로 B+Tree 구조와 해시테이블 등의 자료구조로 구현할 수 있다. 인덱스는 두 가지 종류로 나눌 수 있는데, </p>
<ul>
<li>클러스터 인덱스 : 기본키를 통해 인덱스를 설정한다. </li>
<li>보조 인덱스 : 유일키를 통해 인덱스를 설정한다. </li>
</ul>
<p>보조 인덱스를 사용해서 중복 값을 방지하도록 하면 된다. 새로운 값을 insert할 때 인덱스 구조에 존재하는 값인지 검사한다. 자료구조로 만들어져 있으므로 검색 속도가 빠르다. 다음과 같이 유니크 인덱스를 생성할 수 있다. </p>
<pre><code>CREATE UNIQUE INDEX idx_user_mission
ON user_missions (user_id, mission_id);</code></pre><h3 id="차이점-">차이점 ?</h3>
<p>유니크 제약조건은 말 그대로 중복 데이터를 방지하기 위한, 무결성을 위한 제약조건이고, 유니크 인덱스는 조회 성능도 고려한 것이라고 할 수 있다. 정리해보자면 다음과 같다. </p>
<p>*<em>유니크 제약조건 *</em></p>
<ul>
<li>데이터 무결성 보장</li>
<li>CREATE, ALTER TABLE 문에서 사용</li>
<li>내부적으로 유니크 인덱스 자동 생성</li>
</ul>
<p>*<em>유니크 인덱스 *</em></p>
<ul>
<li>데이터 중복 방지, 조회 성능 향상</li>
<li>CREATE UNIQUE INDEX 문에서 생성 </li>
<li>중복 체크 + 검색 최적화 고려 </li>
</ul>
<p>예를 들어 사용자가 새로운 미션을 도전한다고 할 때, 해당 사용자가 해당 미션을 이미 수행했는지, 또는 이미 도전을 한 상태인지를 확인하려면 <code>user_id</code> , <code>mission_id</code> 의 조합으로 자주 검색해야 한다. 
이 경우에는 성능까지 고려한다면 유니크 인덱스가 더 적합할 수 있다. </p>
<h3 id="3-분산-락">3. 분산 락</h3>
<p>여러 스레드나 프로세스가 동시에 데이터에 접근하려고 할 때 동시성 문제가 발생한다. 이를 해결하기 위해서 공유 자원에 대해서 락을 요청하고, 락을 획득한 프로세스만 자원에 접근 가능하도록 하는 것을 분산 락이라고 한다. 작업이 완료되면 락을 해제하고 다른 프로세스도 접근이 가능하다. 이렇게 하면 데이터를 공유하더라도 원자성이 보장되기 때문에 데이터에 결함이 생기는 것을 방지할 수 있다.</p>
<p>즉 미션 도전 버튼을 여러 번 클릭하면 발생하는 중복 처리 문제도 방지할 수 있다. </p>
<p>일반적으로 Zookeeper, mysql, redis를 사용하여 구현이 가능하다. </p>
<ul>
<li>Zookeeper : 분산 서버 관리 시스템이다. 분산 락만을 위해 사용하기에는 추가적인 인프라 구성이 필요하기 때문에 사용하기 적절하다고 보기에는 어렵다.</li>
<li>MySQL : 트랜잭션과 레코드 락 활용이 가능하나, 동시성이 낮다. </li>
<li>Redis : 성능이 빠르고 사용이 간편해서 분산 락 용도로 많이 사용된다. </li>
</ul>
<p>spring에서는 Redis + Redisson 기반 구현이 가능하다. </p>
<ul>
<li>락 획득 시도</li>
<li>중복 처리 </li>
<li>사용자 미션 데이터 삽입</li>
<li>인터럽트 오류 처리</li>
<li>락 해제</li>
</ul>
<pre><code class="language-java">
private final RedissionClient redissonClient;

private static final String LOCK_PREFIX = &quot;lock:mission&quot;;

public void challengeMisson(Long userId,Long missionId) {

    // 고유 락 생성 
    String lockKey = LOCK_PREFIX + userID + &quot;:&quot; + missionId;

    RLock lock = redissonClient.getLock(lockKey);

    boolean isLocked = false; 

    try {
        // 락 획득 시도
        isLocked = lock.tryLock(3,5,TimeUnit.SECONDS);
        if(!isLocked) {
            throw new IllegalStateException(&quot;락 획득 실패&quot;);
        }

      // 중복 처리 및 데이터 삽입 로직

     } catch (InterruptedException e) {
         Thread.currentThread().interrupt();
        throw new RuntimeException(&quot;인터럽트 발생&quot;,e);
      } finally {
          if (isLocked &amp;&amp; lock.isHeldByCurrentThread()) { // 현재 스레드가 락을 가지고 있는지 확인 
            lock.unlock(); // 락 해제 


}
</code></pre>
<ul>
<li><code>tryLock()</code> : 락 획득을 시도한다. 유지시간 5초, 대기시간 3초로 설정하였으며 락 유지 및 대기시간이 길어지면 로드 시간이 느려지기 때문에, 너무 길게 설정하지 않는 것이 좋다. </li>
<li><code>isHeldByCurrentThread()</code> : 락은 반드시 현재 스레드가 가지고 있을 때에만 해제해야 하기 때문에, 현재 스레드가 락을 가지고 있는지 확인한다. </li>
<li><code>unlock()</code> : 락을 해제한다.</li>
</ul>
<h3 id="4-transaction-처리">4. Transaction 처리</h3>
<p>Spring에서는 Service 구현 시에 <code>@Transactional</code> 어노테이션을 사용하면 된다.</p>
<p>해당 메서드에서 중복 확인 및 삽입하는 로직을 묶어서 설계하고, 하나의 트랜잭션으로 처리하면 중간에 다른 요청이 들어오더라도 트랜잭션이 격리된다. 이 방법과 DB 락을 병행하면 더 안전하게 처리할 수 있다.</p>
<h3 id="느낀-점">느낀 점</h3>
<p>데이터베이스 설계를 1차로 하고, 개발 중에 정규화 작업을 동시해 하는 경우가 많았는데, 설계 과정에서 정규화를 조금 더 꼼꼼하게 하게 되어 개발 시에 수정이 적을 것 같아 편할 것 같다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UMC 8기] Spring Boot 파트 서류&면접 합격 후기]]></title>
            <link>https://velog.io/@jayaione_ele/UMC-8%EA%B8%B0-Spring-%ED%8C%8C%ED%8A%B8-%EC%84%9C%EB%A5%98%EB%A9%B4%EC%A0%91-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@jayaione_ele/UMC-8%EA%B8%B0-Spring-%ED%8C%8C%ED%8A%B8-%EC%84%9C%EB%A5%98%EB%A9%B4%EC%A0%91-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Thu, 20 Mar 2025 16:58:44 GMT</pubDate>
            <description><![CDATA[<h2 id="👋-소개">👋 소개</h2>
<p>일단 나는 컴퓨터공학과 전공자이고, 4학년 1학기 재학 중인 상태였다. 
UMC를 3기에 Node.js 파트로 참여했었고, 이번에는 Spring Boot 파트로 지원하게 되었다. 
이번에 시니어 코스와 주니어 코스가 처음으로 나눠져 있어서 시니어 코스를 경험해 보면 도움이 될까 싶어서 지원하게 되었다. </p>
<h2 id="📑-준비-과정">📑 준비 과정</h2>
<h3 id="서류-준비">서류 준비</h3>
<p>서류는 다음과 같은 문항을 포함해야 했다.</p>
<ul>
<li>파트 선택 이유</li>
<li>지원 동기</li>
<li>어려움 극복 경험</li>
<li>깃허브 링크</li>
<li>얻어가고 싶은 것과 기대하는 것</li>
<li>포트폴리오 pdf</li>
</ul>
<p>전반적으로 두괄식으로 작성하려고 노력했고, 커리큘럼이나 행사를 보고 마음에 드는 점을 경험이랑 연관지어서 쓰려고 노력했다. 
지원 동기의 경우 3기때는 이런 부분이 좋았고, 이런 부분으로 인해 다시 참여해보고 싶다는 생각이 들었다는 식으로 작성했다. 어떤 행사를 꼭 참여하고 싶고, 행사에서 얻어가고 싶은 것도 추가로 작성했다. 어려움 극복 경험의 경우 개발 경험이 아니어도 된다고 했지만 배포 중에 겪었던 어려움에 대해 적었다. 
포트폴리오는 노션으로 작성해뒀던 포트폴리오를 pdf로 내보내기해서 첨부했다. </p>
<h3 id="서류-결과">서류 결과</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/de58a183-a330-47ad-869c-4613d1e2a4ce/image.jpeg" alt=""></p>
<p>서류를 촉박하게 준비한 감이 있었는데, 1차에 합격하게 되어서 2차는 열심히 준비해야겠다고 하고 준비를 시작했다. </p>
<h3 id="면접-준비">면접 준비</h3>
<p>기술 질문이 있다고 해서 기술 질문과 포트폴리오를 보면서 질문을 몇개 만들어보면서 준비를 했다. </p>
<ul>
<li>스프링과 스프링부트의 차이</li>
<li>스프링부트의 장점</li>
<li>QueryDsl을 사용하는 이유</li>
<li>REST API의 장점</li>
</ul>
<p>포트폴리오 질문은 뭐가 나올지 예상이 잘 안됐지만, 준비를 조금 해봤다. </p>
<ul>
<li>redis 사용 이유</li>
<li>CICD 프로젝트 구조</li>
<li>엔진엑스 사용 이유</li>
<li>프로젝트별로 서버 구조</li>
<li>각 프로젝트에서의 어려웠던 점</li>
</ul>
<p>면접은 대면으로 진행되었고, 지원 동기 및 자기소개, 기본질문 + 포트폴리오 질문 + 파트별 기술질문으로 구성되었다. 총 2명이서 면접을 봤는데, 둘 다 다른 파트로 질문도 거의 달랐다. </p>
<p>기본 질문 </p>
<ul>
<li>Node.js와 Spring 경험이 둘 다 있는데, 둘의 장단점 및 차이점 </li>
<li>JPA의 장점</li>
</ul>
<p>포트폴리오 질문</p>
<ul>
<li>CICD를 해본 경험이 있는데, 어떤 구조로 했는지 </li>
<li><blockquote>
<p>이 질문은 준비를 했었는데, 조금 어버버 거리면서 말했던 것 같다. 
다른 질문은 기억이 잘 안난다. ..</p>
</blockquote>
</li>
</ul>
<p>파트별 기술 질문</p>
<ul>
<li>서버 요청이 많을 때 해결 방법</li>
<li>해시태그와 게시글 DB 설계 </li>
<li>API, REST API에 대한 설명</li>
<li>Oauth와 자체 로그인의 차이 </li>
<li>의존성 주입에 대한 설명 </li>
</ul>
<p>기술질문은 대부분 준비했던 내용이라서 잘 대답했던 것 같다. </p>
<h3 id="최종-결과">최종 결과</h3>
<p><img src="https://velog.velcdn.com/images/jayaione_ele/post/0aec17ab-0a2e-4bff-bc4c-0e7ce55776d9/image.jpeg" alt=""></p>
<p>합격~!</p>
<h2 id="📍조언--합격-팁">📍조언 &amp; 합격 팁</h2>
<p>면접에서는 준비한 티를 조금 내려고 했던 것 같고, 서류도 깃허브나 포트폴리오의 경우도 정리가 깔끔하게 된 상태였어서 준비가 수월했다. 3기 때에는 백엔드 경험이 아예 없는 상태에서 지원을 했는데, 팀 프로젝트를 리드했던 경험과 새로운 언어를 배움에 있어서 빠르게 적응했던 경험 등을 어필했었다. 프론트로 참여했던 프로젝트에서도 프론트 입장에서 API 연결을 해봤을 때 겪었던 어려움과, 백엔드를 해보면서 해결을 해보고 싶다는 식으로 어필했다.</p>
<h2 id="기대--하고-싶은-것">기대 ? 하고 싶은 것</h2>
<p>일단 데모데이가 제일 기대가 됐고, 시니어 코스에서 시니어 미션으로 어떤 게 나올 지 기대가 됐다. 4학년 마지막 동아리라고 생각하고 열심히 참여할 것 같다. </p>
]]></description>
        </item>
    </channel>
</rss>