<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dev_hyjang.log</title>
        <link>https://velog.io/</link>
        <description>낭만감자</description>
        <lastBuildDate>Thu, 11 Sep 2025 05:02:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dev_hyjang.log</title>
            <url>https://velog.velcdn.com/images/dev_hyjang/profile/0cb47cf0-2b70-4b91-a170-f9fbe66b7570/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dev_hyjang.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_hyjang" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Spring AOP로 구현하는 활동 로그 시스템]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-AOP%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-%ED%99%9C%EB%8F%99-%EB%A1%9C%EA%B7%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C</link>
            <guid>https://velog.io/@dev_hyjang/Spring-AOP%EB%A1%9C-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-%ED%99%9C%EB%8F%99-%EB%A1%9C%EA%B7%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C</guid>
            <pubDate>Thu, 11 Sep 2025 05:02:15 GMT</pubDate>
            <description><![CDATA[<p>지난 몇 년간 개발자로 일하면서 <strong>로그 보기</strong>는 개발의 시작이자 끝이였습니다.</p>
<ul>
<li>&quot;에러가 발생한 코드는 무엇인지?&quot;</li>
<li>&quot;어떤 에러가 난 것인지?&quot;</li>
</ul>
<p>개발뿐 아니라 유지보수 상황에서도 위 두가지 질문은 끊임없이 반복되었고, 이때마다 &quot;로그&quot;를 보고 문제를 해결하곤 했습니다.</p>
<p>로그는 시스템의 모든 중요한 활동, 특히 API 호출에 대한 상세한 기록을 남기는 것입니다. 이러한 기록이 시스템 유지보수, 개발, 확장에 가장 중요한 단서가 됩니다.</p>
<hr>
<h2 id="로그-남기기의-여러-방식">로그 남기기의 여러 방식</h2>
<pre><code class="language-java">@PostMapping(&quot;/news/{newsId}/like&quot;)
public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; toggleLike(...) {
    log.info(&quot;사용자 {}가 {}번 뉴스에 &#39;좋아요&#39;를 눌렀습니다.&quot;, userId, newsId);
    return ResponseEntity.ok(response);
}</code></pre>
<p>위와 같은 방식은 실제 업무 중 프로젝트에서도 활용되는 아주 흔한 방식입니다. 로그가 필요한 중요한 코드 주변에 로그를 남기는 코드를 작성하는 방식입니다. 하지만 이 방식은 다음과 같은 명백한 단점을 가집니다.</p>
<ul>
<li>코드 중복: 모든 메소드에 거의 동일한 로그 기록 코드가 반복적으로 나타납니다.</li>
<li>비즈니스 로직 오염: 컨트롤러는 요청을 받아 서비스를 호출하고 응답을 반환하는 핵심적인 역할에만 집중해야 합니다. 로그 기록과 같은 부가적인 코드가 섞여 들어가면 코드의 가독성과 유지보수성이 크게 저하됩니다.</li>
<li>유지보수의 어려움: 로그 형식을 변경하거나 새로운 정보(예: 실행 시간)를 추가하고 싶을 때, 수십 개의 모든 메소드를 일일이 찾아 수정해야 합니다.</li>
</ul>
<p>이번에는 프로젝트의 꽃! 로그를 기록하는 코드를 진행 중인 프로젝트에 추가하겠습니다. 객체지향적이면서도 간단하게 컨트롤러의 메소드마다 직접 로그를 기록하는 방법으로 진행해보겠습니다.</p>
<hr>
<h2 id="객체지향적-로깅-분리하기">객체지향적 &#39;로깅&#39; 분리하기</h2>
<p><strong>AOP(Aspect-Oriented Programming)</strong>는 애플리케이션의 여러 부분에 공통적으로 나타나는 부가 기능(예: 로깅, 트랜잭션, 보안)을 공통 모듈로 분리하여 관리하는 프로그래밍 패러다임입니다. 저는 이번 프로젝트에서 AOP 관점을 활용하여 &#39;활동 로그 기록&#39;이라는 공통 관심사를 모든 컨트롤러의 비즈니스 로직으로부터 완벽하게 분리하기로 결정했습니다.</p>
<p>구현할 아키텍처는 아래와 같습니다.</p>
<ol>
<li><p>커스텀 어노테이션 생성: 로그를 기록하고 싶은 컨트롤러 메소드에 붙여주기만 하면 되는 어노테이션을 직접 생성해 보겠습니다.</p>
</li>
<li><p>공통 로깅 클래스 구현: 로깅 어노테이션이 붙은 메소드의 실행을 &#39;가로채서(Advice)&#39;, 로그 기록이라는 공통 로직을 실행 전후에 실행시키는 AOP 모듈을 만듭니다.</p>
</li>
<li><p>동작: 특정 API가 호출되면, Spring은 해당 컨트롤러 메소드에 직접 생성한 로깅 어노테이션이 붙어있는지 확인합니다. 만약 붙어있다면, 메소드를 직접 실행하기 전에 ActivityLogAspect를 먼저 실행하여 로그를 기록하고, 그 후에 원래의 메소드를 실행합니다.</p>
</li>
</ol>
<p>이 방식을 통해 메소드의 핵심 로직을 건드리지 않고 로그를 남길 수 있으며, 추후 로깅 정책 개선 시 공통 로깅 클래스만 관리하면 되어 효율적입니다.</p>
<hr>
<h2 id="구현">구현</h2>
<h3 id="1-aop-의존성-추가">1. AOP 의존성 추가</h3>
<p>기본적으로 프로젝트에 AOP 관련 의존성을 추가합니다.</p>
<pre><code class="language-javascript">dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-aop&#39;
}</code></pre>
<h3 id="2-로그용-커스텀-어노테이션-생성">2. 로그용 커스텀 어노테이션 생성</h3>
<p>로그를 적용할 대상을 지정하기 위한 어노테이션을 직접 생성합니다.</p>
<pre><code class="language-java">@Target(ElementType.METHOD) // 메소드에만 적용 가능
@Retention(RetentionPolicy.RUNTIME) // 런타임에도 정보 유지
public @interface LogActivity {
    String value() default &quot;&quot;; // 활동 설명을 위한 속성
}</code></pre>
<ul>
<li>메소드에만 적용하겠다는 선언을 합니다.</li>
<li>런타임에도 정보가 유지될 수 있도록 합니다.</li>
<li>이루어지고 있는 활동에 대한 설명을 String 값으로 받도록 합니다.</li>
</ul>
<h3 id="3-aop-aspect-구현">3. AOP Aspect 구현</h3>
<p>공통 로깅 클래스인 ActivityLogAspect 클래스를 구현합니다.</p>
<ul>
<li>@Aspect, @Component: 이 클래스가 Spring Bean으로 관리되는 AOP Aspect임을 선언합니다.</li>
<li>@Around(&quot;@annotation(logActivity)&quot;): @LogActivity가 붙은 모든 메소드를 타겟으로 지정하고, 해당 메소드의 실행 전후에 개입합니다.</li>
</ul>
<pre><code class="language-java">package com.ccp.simple.aop;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
@Slf4j
public class ActivityLogAspect {

    @Around(&quot;@annotation(logActivity)&quot;)
    public Object logActivity(ProceedingJoinPoint joinPoint, LogActivity logActivity) throws Throwable {
        // 1. 요청 정보 수집
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String ip = request.getRemoteAddr();
        String method = request.getMethod();
        String url = request.getRequestURI();
        String activity = logActivity.value();

        // 2. 사용자 정보 수집
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userId = (authentication != null &amp;&amp; authentication.isAuthenticated() &amp;&amp; !&quot;anonymousUser&quot;.equals(authentication.getPrincipal()))
                ? authentication.getName() : &quot;GUEST&quot;;

        // 3. 로그 기록 (메소드 실행 전)
        log.info(&quot;[Activity Log] User: {}, IP: {}, Method: {}, URL: {}, Action: {}&quot;, userId, ip, method, url, activity);

        try {
            // 4. 원래의 타겟 메소드 실행
            Object result = joinPoint.proceed();

            // 5. 로그 기록 (메소드 성공 후)
            log.info(&quot;[Activity Log] Completed: {} - Success&quot;, activity);
            return result;
        } catch (Throwable throwable) {
            // 6. 로그 기록 (메소드 실패 시)
            log.error(&quot;[Activity Log] Failed: {} - Error: {}&quot;, activity, throwable.getMessage());
            throw throwable;
        }
    }
}</code></pre>
<p>API 요청시 받을 수 있는 기본적이면서 중요한 정보를 모두 수집할 수 있도록 하였습니다. 또한 메소드 전, 후, 실패 시 모두 로그가 남을 수 있도록 설정하였습니다.</p>
<h3 id="4-메소드에-어노테이션-적용">4. 메소드에 어노테이션 적용</h3>
<pre><code class="language-java">    @LogActivity(&quot;뉴스 검색&quot;)
    @GetMapping(&quot;/news/search&quot;)
    public List&lt;NewsResponseDto&gt; searchNews(@RequestParam(&quot;q&quot;) String query) {
        return newsService.searchNews(query);
    }</code></pre>
<ul>
<li>메소드에 직접 생성한 <code>@LogActivity</code> 을 달고 내용을 작성해줍니다.</li>
<li>이렇게 간단하게 어노테이션을 달기만 해도 API 요청시 어노테이션에서 이루어지는 공통 로깅 클래스의 로직 실행 후 본 메소드의 작업이 이루어집니다.</li>
<li>추후 로그에 해당 메소드가 동작할 때 내용을 확인할 수 있습니다.</li>
</ul>
<hr>
<h2 id="결과">결과</h2>
<p>실제로 아래와 같이 로그 정보가 남는 것을 확인할 수 있었습니다. 해당 로그 정보만으로도 어떠한 메소드가 실행되었고, 어떠한 이유로 에러가 났는지 명확하게 확인할 수 있었습니다.</p>
<pre><code>2025-09-11T13:58:38.126+09:00  INFO 26216 --- [nio-8080-exec-1] com.ccp.simple.aop.ActivityLogAspect     : [Activity Log] User: anonymousUser, IP: 0:0:0:0:0:0:0:1, Method: POST, URL: /api/login, Action: 사용자 로그인

2025-09-11T13:58:42.820+09:00  INFO 26216 --- [nio-8080-exec-3] com.ccp.simple.aop.ActivityLogAspect     : [Activity Log] Completed: 뉴스 목록 조회 - Success


2025-09-11T13:58:38.412+09:00 ERROR 26216 --- [nio-8080-exec-1] com.ccp.simple.aop.ActivityLogAspect     : [Activity Log] Failed: 사용자 로그인 - Error: 401 UNAUTHORIZED &quot;Invalid credentials&quot;
</code></pre><hr>
<h2 id="결론">결론</h2>
<p>AOP 방식으로 로그 남기기를 적용한 결과, 아래와 같은 두 가지 큰 성과를 얻었습니다.</p>
<ol>
<li><p>향상된 코드 품질: 로그 기록 코드를 개별적으로 메소드 안에 작성하지 않아도 됩니다. 컨트롤러는 오직 자신의 핵심 책임에만 집중할 수 있게 되어, 코드의 가독성과 유지보수성이 비약적으로 향상되었습니다.</p>
</li>
<li><p>일관된 로그: 모든 API 호출에 대해 &quot;누가, 어디서, 무엇을, 어떻게&quot; 했는지에 대한 일관된 형식의 로그가 자동으로 기록됩니다. 이는 서비스 운영 중 문제 추적과 사용자 행동 분석에 매우 중요한 기록이 됩니다.</p>
</li>
</ol>
<p>Spring AOP를 활용한 로깅 방식은 여러 모듈에 걸쳐 반복적으로 나타나는 작업을 따로 분리하여 관리합니다. 간단하지만 객체 지향적 프로그래밍을 확실하게 해볼 수 있는 기능 구현이였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot와 SSE로 구현하는 실시간 알림 시스템]]></title>
            <link>https://velog.io/@dev_hyjang/sse-%ED%86%B5%EC%8B%A0</link>
            <guid>https://velog.io/@dev_hyjang/sse-%ED%86%B5%EC%8B%A0</guid>
            <pubDate>Tue, 09 Sep 2025 08:51:51 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅에서는 관심 키워드 뉴스가 등록되면 사용자에게 즉시 알림이 오는 기능을 구현해보겠습니다. 이 기능을 구현하기 위해서는 클라이언트와 서버 간의 실시간 통신이 가능해야 합니다. 이때 사용되는 기술이 바로 WebSocket/SSE 입니다. </p>
<p>실시간 통신 기술에 대해서는 예전부터 익히 들었지만 실제로 사용해본 적이 없는 기술이기 때문에 이번 프로젝트에 기능을 추가하여 직접 경험해보도록 하겠습니다.</p>
<hr>
<h2 id="실시간-통신-기술-websocket과-sse">실시간 통신 기술 WebSocket과 SSE</h2>
<p>서버에서 클라이언트로 실시간 데이터를 전송하는 기술의 양대 산맥은 <strong>WebSocket</strong>과 <strong>SSE(Server-Sent Events)</strong>입니다.</p>
<h3 id="websocket">WebSocket</h3>
<p>클라이언트와 서버 사이에 <strong>양방향</strong> 통신 채널을 구축하는 프로토콜 입니다. 연결이 되면 클라이언트와 서버 모두 자유롭게 데이터를 주고 받을 수 있습니다. 양방향 통신을 하기 때문에 그만큼 구현이 복잡하고 더 많은 리소스를 필요로 합니다.</p>
<ul>
<li>양방향 통신: 클라이언트가 요청하지 않아도 서버가 클라이언트에게 데이터를 보낼 수 있고, 클라이언트도 서버에 데이터를 보낼 수 있습니다.</li>
<li>활용: <strong>실시간</strong>성이 중요한 애플리케이션에 적합합니다. 예를 들어, 온라인 게임, <strong>채팅</strong> 애플리케이션, 주식 시세, 실시간 협업 도구 등에 사용됩니다.</li>
<li>프로토콜: HTTP 프로토콜을 사용해 핸드셰이크(Handshake) 과정을 거쳐 연결을 설정한 후, 독자적인 WebSocket 프로토콜로 전환이 됩니다. 이 덕분에 통신 오버헤드가 적고 효율적입니다.</li>
</ul>
<h3 id="sse-server-sent-events">SSE (Server-Sent Events)</h3>
<p>오직 서버에서 클라이언트로 즉, <strong>단방향</strong>으로 통신하는 기술입니다. </p>
<ul>
<li>단방향 통신: 서버에서 클라이언트로만 데이터가 전달됩니다. (클라이언트는 서버에 메시지를 보내기 위해 별도의 HTTP 요청을 해야 합니다.)</li>
<li>활용: 서버에서 발생하는 이벤트를 실시간으로 클라이언트에게 전달하는 경우에 활용됩니다. 예를 들어, 뉴스 피드 업데이트, <strong>알림</strong>, 스포츠 경기 스코어 업데이트 등에 사용됩니다.</li>
<li>프로토콜: HTTP를 기반으로 하며, 특별한 Content-Type(text/event-stream)을 사용합니다. 이 때문에 방화벽이나 프록시 서버에 대한 호환성이 좋다는 장점이 있습니다.</li>
</ul>
<h3 id="주요-차이점-요약">주요 차이점 요약</h3>
<table>
<thead>
<tr>
<th>특징</th>
<th>WebSocket</th>
<th>SSE (Server-Sent Events)</th>
</tr>
</thead>
<tbody><tr>
<td>통신 방향</td>
<td>양방향 (Bi-directional)</td>
<td>단방향 (Server → Client only)</td>
</tr>
<tr>
<td>주요 활용처</td>
<td>실시간 채팅, 온라인 게임, 화상 통화</td>
<td>뉴스 피드, 알림, 실시간 스포츠 점수</td>
</tr>
<tr>
<td>프로토콜</td>
<td>별도의 WebSocket 프로토콜</td>
<td>HTTP 기반</td>
</tr>
<tr>
<td>호환성</td>
<td>방화벽이나 프록시 문제 가능성 있음</td>
<td>HTTP 기반이라 호환성이 좋음</td>
</tr>
<tr>
<td>오버헤드</td>
<td>연결 후 적은 오버헤드</td>
<td>헤더 정보 포함으로 상대적으로 있음</td>
</tr>
</tbody></table>
<h3 id="그렇다면-프로젝트에서는-무엇을-써야할까">그렇다면 프로젝트에서는 무엇을 써야할까?</h3>
<p>이번 프로젝트에서는 스케줄러를 통해 뉴스가 자동으로 수집이 됩니다. 이때 사용자 본인이 등록한 키워드에 해당하는 뉴스가 수집이 되면 &quot;새로운 뉴스가 수집되었음&quot;을 알림으로 제공할 예정입니다. 이 기능을 통해 사용자는 더 이상 중요한 정보를 놓칠까 봐 초조해하며 페이지를 새로고침할 필요 없이, 가장 관심 있는 주제의 최신 뉴스를 누구보다 빠르게 접할 수 있게 됩니다.</p>
<blockquote>
<p>&quot;사용자가 관심 키워드로 등록한 새로운 뉴스가 시스템에 수집되는 즉시, 해당 사용자에게 실시간으로 알림을 보내준다.&quot;</p>
</blockquote>
<p>즉, 현재 요구사항은 &quot;서버가 사용자에게 알림을 보낸다&quot;는 명확한 단방향 통신입니다. 사용자가 알림에 대해 실시간으로 서버에 응답할 필요는 없습니다. 따라서 더 가볍고, 구현이 간단하며, HTTP 표준을 그대로 사용하여 프록시나 방화벽 문제에서 더 자유로운 SSE를 채택하기로 결정했습니다. Spring MVC는 SseEmitter라는 클래스를 통해 SSE를 매우 쉽게 구현할 수 있도록 지원하므로, 별도의 라이브러리 추가도 필요 없었습니다.</p>
<hr>
<h2 id="구현">구현</h2>
<p>실시간 알림 시스템은 크게 세 가지 요소로 구성됩니다.</p>
<ol>
<li>Subscriber (구독자): 알림을 받기 위해 서버에 연결하는 클라이언트</li>
<li>Emitter (발신자): 서버에서 생성되어 각 클라이언트와의 연결을 유지하는 &#39;파이프라인&#39;</li>
<li>Trigger (방아쇠): 알림을 보내야 할 특정 이벤트가 발생했을 때, Emitter를 통해 메시지를 발송하는 로직</li>
</ol>
<h3 id="알림-서비스-및-컨트롤러-구현-emitter와-subscriber">알림 서비스 및 컨트롤러 구현 (Emitter와 Subscriber)</h3>
<ul>
<li><p>가장 먼저, 모든 SSE 연결(SseEmitter)을 관리하고 알림을 전송하는 역할을 전담할 NotificationService를 구현했습니다.</p>
</li>
<li><p>연결 관리: ConcurrentHashMap을 사용하여 userId와 SseEmitter 객체를 1:1로 매핑하여 관리합니다. 사용자가 접속을 끊거나 타임아웃이 발생하면, 이 맵에서 자동으로 해당 Emitter를 제거하여 메모리 누수를 방지합니다.</p>
</li>
</ul>
<pre><code class="language-java">@Slf4j
@Service
public class NotificationServiceImpl implements NotificationService {

    private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간
    private final Map&lt;String, SseEmitter&gt; emitters = new ConcurrentHashMap&lt;&gt;();

    @Override
    public SseEmitter subscribe(String userId) {
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        emitters.put(userId, emitter);

        emitter.onCompletion(() -&gt; emitters.remove(userId));
        emitter.onTimeout(() -&gt; emitters.remove(userId));
        emitter.onError(e -&gt; emitters.remove(userId));

        sendToClient(userId, &quot;EventStream Created. [userId=&quot; + userId + &quot;]&quot;);

        return emitter;
    }

    @Override
    public void send(String userId, Object data) {
        sendToClient(userId, data);
    }

    private void sendToClient(String userId, Object data) {
        SseEmitter emitter = emitters.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                        .id(String.valueOf(System.currentTimeMillis()))
                        .name(&quot;sse&quot;)
                        .data(data));
            } catch (IOException e) {
                emitters.remove(userId);
                log.error(&quot;SSE 연결 오류 발생 [userId={}]&quot;, userId, e);
            }
        }
    }
}</code></pre>
<ul>
<li>클라이언트가 알림을 구독할 수 있는 API 엔드포인트를 NotificationController에 추가했습니다.</li>
</ul>
<pre><code class="language-java">    @GetMapping(value = &quot;/subscribe&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(Authentication authentication) {
        String userId = authentication.getName();
        return notificationService.subscribe(userId); // 사용자를 위한 Emitter 생성 및 반환
    }
</code></pre>
<h3 id="알림-발생-로직-연동-trigger">알림 발생 로직 연동 (Trigger)</h3>
<ol>
<li><p>구독자 조회: 새로운 뉴스가 어떤 keywordId와 관련 있는지 알고 있으므로, user_keyword_mapping 테이블을 조회하여 해당 keywordId를 구독하는 모든 userId 목록을 가져오는 매퍼 쿼리를 추가했습니다.</p>
</li>
<li><p>알림 전송: insertNews 메소드 안에서, 새로운 뉴스가 DB에 성공적으로 저장된 직후, 위 쿼리를 통해 얻은 모든 구독자에게 NotificationService를 통해 알림을 전송합니다.</p>
</li>
</ol>
<pre><code class="language-java">List&lt;String&gt; subscribedUserIds = newsMapper.findUserIdsByKeywordId(keywordId);
for (String userId : subscribedUserIds) {
    String notificationMessage = &quot;관심 키워드 관련 새 뉴스가 도착했습니다: &quot; + news.getTitle();
    notificationService.send(userId, notificationMessage);
}</code></pre>
<hr>
<h2 id="테스트">테스트</h2>
<p>사용자가 웹사이트에 접속하여 알림 채널을 구독하고 있는 상태에서, 백엔드 스케줄러가 해당 사용자의 관심 키워드와 일치하는 새로운 뉴스를 수집하면, 사용자의 화면에는 페이지 새로고침 없이도 즉시 알림이 표시됩니다.</p>
<p>해당 로직은 curl로 연결하여 테스트를 진행하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/e04b7ea3-433b-43da-aacf-4d7a2723df78/image.png" alt=""></p>
<hr>
<h2 id="결론">결론</h2>
<p>서버와 클라이언트 간의 실시간 통신 기능은 SSE를 활용해 비교적 간단하게 구현할 수 있었습니다. 직접 구현을 해보니 간단한 기술로 사용자 경험을 극적으로 향상시킬 수 있겠다는 생각이 들었습니다. 특히 이번 구현을 통해 비동기 스케줄러(뉴스 수집)와 실시간 이벤트(알림 전송)라는 두 가지 다른 시간적 흐름을 가진 로직을 자연스럽게 연동하는 경험을 할 수 있었습니다.</p>
<p>추후 기회가 된다면 양방향 통신 기술도 직접 적용하고 활용해보도록 하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis를 활용한 조회수 집계 시스템 구축]]></title>
            <link>https://velog.io/@dev_hyjang/%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%88%98%EC%A7%91</link>
            <guid>https://velog.io/@dev_hyjang/%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%88%98%EC%A7%91</guid>
            <pubDate>Mon, 08 Sep 2025 08:22:33 GMT</pubDate>
            <description><![CDATA[<p>많은 웹 서비스에서 &#39;조회수&#39;는 콘텐츠의 인기도를 나타내는 가장 기본적인 지표입니다. 가장 직관적인 구현 방법은 사용자가 게시물을 조회할 때마다 해당 게시물의 view_count 컬럼을 1씩 증가시키는 UPDATE 쿼리를 실행하는 것입니다.</p>
<pre><code class="language-sql">    UPDATE TABLE_A SET VIEW_COUNT = VIEW_COUNT + 1 WHERE ID = A;</code></pre>
<p>이 방식은 구현이 매우 간단하지만, 서비스가 성장하고 트래픽이 증가함에 따라 심각한 성능 병목의 주범이 됩니다.</p>
<ul>
<li>과도한 DB 쓰기 부하: 모든 조회 요청이 데이터베이스에 직접적인 쓰기(Write) 작업을 발생시킵니다. 인기 있는 게시물 하나에 트래픽이 집중되면, 해당 테이블의 특정 행(Row)에 엄청난 양의 UPDATE 쿼리가 몰리게 됩니다.</li>
<li>I/O 병목과 Lock 경쟁: 디스크 기반의 RDBMS에서 쓰기 작업은 비용이 높은 I/O를 동반합니다. 또한, 빈번한 UPDATE는 테이블 또는 행 수준의 락(Lock)을 유발하여, 다른 중요한 트랜잭션(예: 글 작성, 결제)의 성능까지 저하시킬 수 있습니다.</li>
<li>응답 시간 저하: DB의 부하가 증가하면, 사용자가 게시물을 조회하는 API의 응답 시간 또한 길어져 전반적인 사용자 경험(UX)을 해치게 됩니다.</li>
</ul>
<hr>
<h2 id="redis--rdbms-하이브리드-아키텍처">Redis + RDBMS 하이브리드 아키텍처</h2>
<p>이 문제를 해결하기 위해, 우리는 In-Memory 데이터 저장소인 Redis를 도입하여 RDBMS의 부하를 덜어주는 하이브리드 아키텍처를 설계했습니다.</p>
<p>Redis는 Key-Value 기반의 고성능 데이터 저장소로, 모든 데이터를 디스크가 아닌 메모리에 저장합니다. 이 특징은 &#39;조회수 집계&#39;와 같은 시나리오에서 다음과 같은 결정적인 장점을 제공합니다.</p>
<ul>
<li>원자적 연산(Atomic Operation)과 엄청난 속도: Redis는 특정 키의 숫자 값을 1씩 증가시키는 INCR 명령어를 제공합니다. 이 명령어는 <strong>원자성(Atomicity)</strong>이 보장되므로, 여러 클라이언트가 동시에 요청해도 데이터의 정합성이 깨지지 않습니다. 또한, 모든 연산이 메모리 상에서 이루어지므로 RDBMS의 UPDATE와는 비교할 수 없는 속도를 자랑합니다.</li>
<li>DB 부하 분산: 빈번하게 발생하는 모든 조회수 증가 요청을 Redis가 먼저 처리하도록 하여, RDBMS는 더 이상 사소한 UPDATE 작업에 시달리지 않고 데이터의 영구 저장 및 복잡한 조회라는 본연의 역할에 집중할 수 있습니다.</li>
</ul>
<h3 id="실시간-처리와-비동기-동기화">실시간 처리와 비동기 동기화</h3>
<p>Redis의 &#39;속도&#39;와 RDBMS의 &#39;안정성&#39;을 모두 보장하기 위해 아래와 같이 설계하였습니다.</p>
<ol>
<li><p>실시간 조회수 기록 (Redis): 사용자가 뉴스를 조회하면, API 서버는 즉시 Redis의 INCR 명령어를 호출하여 news:view:{뉴스ID} 형태의 키 값을 1 증가시킵니다. 이 과정은 DB를 전혀 거치지 않으므로 매우 빠릅니다.</p>
</li>
<li><p>주기적인 DB 동기화 (Scheduler): Spring Scheduler를 이용한 배치 작업이 주기적으로(예: 5분마다) 실행됩니다.</p>
</li>
<li><p>최종 결과 업데이트 (RDBMS): 스케줄러는 Redis에 기록된 모든 뉴스의 최종 조회수 값을 가져와, RDBMS(PostgreSQL)의 view_count 컬럼에 한 번에 업데이트합니다.</p>
</li>
</ol>
<p>실시간 처리는 Redis가 전담하고, 데이터의 영구 저장은 스케줄러를 통한 비동기 방식으로 처리함으로써, 시스템의 성능과 안정성을 모두 확보할 수 있습니다.</p>
<hr>
<h2 id="구현">구현</h2>
<pre><code class="language-java">@PostMapping(&quot;/news/{newsId}/view&quot;)
public ResponseEntity&lt;Void&gt; incrementViewCount(@PathVariable Long newsId) {
    newsService.incrementViewCount(newsId);
    return ResponseEntity.ok().build();
}</code></pre>
<pre><code class="language-java">@Override
public void incrementViewCount(Long newsId) {
    String viewKey = &quot;news:view:&quot; + newsId;
    redisTemplate.opsForValue().increment(viewKey);
}</code></pre>
<h4 id="db-동기화-스케줄러-구현">DB 동기화 스케줄러 구현</h4>
<pre><code class="language-java">// ViewCountSyncScheduler.java

@Slf4j
@Component
@RequiredArgsConstructor
public class ViewCountSyncScheduler {

    private final RedisTemplate&lt;String, String&gt; redisTemplate;
    private final NewsService newsService;

    // 5분마다 실행
    @Scheduled(cron = &quot;0 */5 * * * *&quot;)
    public void syncViewCountsToDb() {
        log.info(&quot;Redis &#39;조회수&#39; DB 동기화 시작&quot;);
        // 1. &#39;news:view:&#39; 패턴의 모든 키를 조회
        Set&lt;String&gt; viewKeys = redisTemplate.keys(&quot;news:view:*&quot;);

        if (viewKeys == null || viewKeys.isEmpty()) {
            return;
        }

        for (String key : viewKeys) {
            try {
                // 2. 키에서 newsId 파싱
                Long newsId = Long.parseLong(key.split(&quot;:&quot;)[2]);
                // 3. Redis에서 최종 조회수 GET
                String viewCountStr = redisTemplate.opsForValue().get(key);

                if (viewCountStr != null) {
                    int viewCount = Integer.parseInt(viewCountStr);
                    // 4. 서비스 계층을 통해 DB에 업데이트
                    newsService.updateViewCountInDb(newsId, viewCount);
                }
            } catch (Exception e) {
                log.error(&quot;키 &#39;{}&#39; 처리 중 오류 발생: {}&quot;, key, e.getMessage());
            }
        }
        log.info(&quot;Redis &#39;조회수&#39; DB 동기화 완료&quot;);
    }
}</code></pre>
<h4 id="데이터-조회-로직">데이터 조회 로직</h4>
<ul>
<li>DB에 저장된 값이 기본 + Redis에 실시간 값이 존재하면 그 값으로 덮어쓰는 방식으로 구현하였습니다.</li>
</ul>
<pre><code class="language-java">// NewsServiceImpl.java - matchEsResultsWithRedisData() 메소드 내부

private void matchEsResultsWithRedisData(List&lt;NewsResponseDto&gt; newsList, String userId) {
    for (NewsResponseDto news : newsList) {
        // ... (기존 좋아요 로직)

        // 실시간 조회수 보강
        String viewKey = &quot;news:view:&quot; + news.getNewsId();
        String viewCountStr = redisTemplate.opsForValue().get(viewKey);

        // Redis에 실시간 조회수 값이 있으면, 그 값으로 DTO의 viewCount를 덮어쓴다.
        if (viewCountStr != null) {
            news.setViewCount(Integer.parseInt(viewCountStr));
        } 
        // Redis에 값이 없으면, DB에서 조회해 온 기존 viewCount 값이 그대로 유지된다.
    }
}</code></pre>
<hr>
<h2 id="결론">결론</h2>
<p>단순한 UPDATE 쿼리 방식에서 벗어나 Redis와 스케줄러를 활용한 비동기 아키텍처를 도입함으로써, 대규모 트래픽 상황에서도 안정적으로 조회수를 집계하고 사용자에게 빠른 응답을 제공할 수 있는 견고한 기반을 마련했습니다. 
이 아키텍처는 조회수뿐만 아니라, &#39;좋아요 수&#39;, &#39;실시간 랭킹&#39;, &#39;재고 관리&#39; 등 빈번한 쓰기 작업이 발생하는 다양한 시나리오에 효과적으로 적용될 수 있는 매우 유연하고 강력한 패턴입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA["나와 비슷한 사람들이 좋아한 뉴스는?" 협업 필터링 추천 기능 구현하기]]></title>
            <link>https://velog.io/@dev_hyjang/%EC%9C%A0%EC%82%AC-%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B8%B0%EB%B0%98-%EB%89%B4%EC%8A%A4-%EC%B6%94%EC%B2%9C-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B5%AC%ED%98%84%ED%95%98</link>
            <guid>https://velog.io/@dev_hyjang/%EC%9C%A0%EC%82%AC-%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B8%B0%EB%B0%98-%EB%89%B4%EC%8A%A4-%EC%B6%94%EC%B2%9C-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B5%AC%ED%98%84%ED%95%98</guid>
            <pubDate>Fri, 05 Sep 2025 07:49:01 GMT</pubDate>
            <description><![CDATA[<p>사용자 맞춤형 서비스를 만들 때 추천 시스템은 빠질 수 없는 요소입니다. 앞서 엘라스틱서치 MLT를 활용하여 사용자가 검색한 결과와 유사도가 높은 뉴스를 추천하는 기능을 구현했었습니다. </p>
<p>이번에는 &quot;나와 비슷한 취향을 가진 다른 사람들은 무엇을 좋아했을까?&quot;라는 아이디어를 기반으로 뉴스 &#39;추천&#39; 기능을 구현해보겠습니다. 이 방식은 콘텐츠의 내용과는 무관하게, <strong>사용자의 &#39;좋아요&#39; 정보를 활용하여 추천</strong>하는 기능입니다.</p>
<hr>
<h2 id="구현">구현</h2>
<h4 id="1-컨트롤러-계증에-추천-api-엔드-포인트-추가">1. 컨트롤러 계증에 &#39;추천&#39; API 엔드 포인트 추가</h4>
<pre><code class="language-JAVA">// 개인화 추천
@GetMapping(&quot;/news/recommend&quot;)
public List&lt;NewsResponseDto&gt; getRecommend(Authentication authentication) {
    String userId = authentication.getName();
    return recommendationService.recommendNewsBySimilarUsers(userId, 10); // 상위 10개 추천
}</code></pre>
<ul>
<li>NewsController에 GET /api/news/recommend 라는 API 엔드포인트를 만듭니다.</li>
<li>이 엔드포인트는 Authentication 정보를 파라미터로 받아 사용자의 정보를 조회한 뒤 해당 정보를 넘겨 &#39;추천&#39; 결과를 반환합니다.</li>
</ul>
<h4 id="2-좋아요-데이터에-기반한-추천-메소드-작성">2. &#39;좋아요&#39; 데이터에 기반한 &#39;추천&#39; 메소드 작성</h4>
<pre><code class="language-JAVA">@Service
@RequiredArgsConstructor
public class RecommendationServiceImpl implements RecommendationService {

    private final NewsMapper newsMapper;
    private final RedisTemplate&lt;String, String&gt; redisTemplate;

    @Override
    public List&lt;NewsResponseDto&gt; recommendNewsBySimilarUsers(String userId, int limit) {
        // 1. 현재 사용자와 취향이 비슷한 사용자 조회
        List&lt;String&gt; similarUserIds = newsMapper.searchSimilarUsers(userId, 5);

        if (similarUserIds.isEmpty()) {
            return Collections.emptyList();
        }

        // 2. 유사 사용자들이 좋아한 뉴스 ID 조회
        List&lt;Long&gt; recommendedNewsIds = newsMapper.searchNewsIdsBySimilarUsers(similarUserIds);

        if (recommendedNewsIds.isEmpty()) {
            return Collections.emptyList();
        }

        // 3. 현재 사용자가 이미 좋아요한 뉴스 제외
        List&lt;Long&gt; likedNewsIdsByUser = newsMapper.searchLikedNewsIdsByUser(userId);

        List&lt;Long&gt; distinctRecommendNewsIds = new ArrayList&lt;&gt;();
        Set&lt;Long&gt; set = new HashSet&lt;&gt;();

        for (Long newsId : recommendedNewsIds) {
            if (!likedNewsIdsByUser.contains(newsId) &amp;&amp; set.add(newsId)) {
                distinctRecommendNewsIds.add(newsId);
                if (distinctRecommendNewsIds.size() &gt;= limit) break;
            }
        }

        if (distinctRecommendNewsIds.isEmpty()) {
            return Collections.emptyList();
        }

        // 4. 뉴스 상세 조회 + Redis 데이터 결합
        List&lt;NewsResponseDto&gt; recommendations = newsMapper.findNewsByIds(distinctRecommendNewsIds);
        matchEsResultsWithRedisData(recommendations, userId);

        return recommendations;
    }

    // Redis에 저장된 좋아요 수 &amp; 상태 매칭
    private void matchEsResultsWithRedisData(List&lt;NewsResponseDto&gt; newsList, String userId) {
        for (NewsResponseDto news : newsList) {
            String likeKey = &quot;news:like:&quot; + news.getNewsId();
            Long likeCount = redisTemplate.opsForSet().size(likeKey);
            news.setLikeCount(likeCount != null ? likeCount.intValue() : 0);

            if (userId != null) {
                news.setLiked(Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(likeKey, userId)));
            } else {
                news.setLiked(false);
            }
        }
    }
}</code></pre>
<pre><code class="language-SQL">    &lt;select id=&quot;searchSimilarUsers&quot; resultType=&quot;string&quot;&gt;
        SELECT u2.user_id
        FROM user_news_like u1
        JOIN user_news_like u2 ON u1.news_id = u2.news_id AND u1.user_id != u2.user_id
        WHERE u1.user_id = #{userId}
        GROUP BY u2.user_id
        ORDER BY COUNT(*) DESC
        LIMIT #{limit}
    &lt;/select&gt;

    &lt;select id=&quot;searchNewsIdsBySimilarUsers&quot; resultType=&quot;long&quot;&gt;
        SELECT DISTINCT news_id
        FROM user_news_like
        WHERE user_id IN
        &lt;foreach collection=&quot;list&quot; item=&quot;id&quot; open=&quot;(&quot; separator=&quot;,&quot; close=&quot;)&quot;&gt;
            #{id}
        &lt;/foreach&gt;
    &lt;/select&gt;

    &lt;select id=&quot;searchLikedNewsIdsByUser&quot; resultType=&quot;long&quot;&gt;
        SELECT news_id
        FROM user_news_like
        WHERE user_id = #{userId}
    &lt;/select&gt;</code></pre>
<p>이번에 구현한 서비스는 간단히 DB 데이터만 활용하여 구현하였습니다. 
기능 구현 과정은 아래와 같이 논리적 설계만 잘하면 추천 기능을 구현할 수 있었습니다.</p>
<blockquote>
<ol>
<li>현재 사용자와 유사한 사용자들(&#39;좋아요&#39;한 데이터가 같은 사용자)을 찾는다.</li>
<li>유사한 사용자들이 좋아한 뉴스를 모은다.</li>
<li>현재 사용자가 이미 좋아요한 뉴스는 제외한다.</li>
<li>추천 후보군을 DB에서 조회하고, Redis에 저장된 좋아요 수와 상태를 결합하여 반환한다.</li>
</ol>
</blockquote>
<hr>
<h2 id="테스트">테스트</h2>
<h4 id="테스트-시나리오">테스트 시나리오</h4>
<ul>
<li>사용자 A: 뉴스 70번, 71번, 69번, 74번 &#39;좋아요&#39;</li>
<li>사용자 B: 뉴스 70번, 71번, 73번, 76번 &#39;좋아요&#39; (A와 1, 2번이 겹침 -&gt; 유사도 높음)</li>
<li>사용자 C: 뉴스 59번, 62번, 72번 &#39;좋아요&#39; (A와 겹치는 뉴스가 없음 -&gt; 유사도 낮음)</li>
</ul>
<p>&#39;사용자 A&#39;로 로그인하여 추천을 요청하면, A와 취향이 비슷한 &#39;사용자 B&#39;가 좋아했던 뉴스 중에서 A가 아직 보지 않은 73번, 76번 뉴스가 추천되어야 합니다.</p>
<h4 id="api-요청">API 요청</h4>
<ol>
<li>&#39;사용자 A&#39;로 로그인 합니다.</li>
<li>새 요청을 열고, Method를 GET으로 설정합니다.</li>
<li>GET <code>/api/news/recommendations</code> 요청을 보냅니다.</li>
</ol>
<h4 id="결과-조회">결과 조회</h4>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/e21524d9-c3a9-4ddd-a30c-27e0bc15c5ab/image.png" alt=""></p>
<ul>
<li>위 테스트 시나리오에서 예상한 결과대로 반환된 뉴스 목록은 73번, 76번 뉴스로 확인하였습니다.</li>
<li>사용자가 이미 &#39;좋아요&#39;를 누른 뉴스(70, 71, 69, 74번)는 추천 목록에서 제외되어 있는 것을 확인하였습니다.</li>
</ul>
<hr>
<p>이번 구현에서는 <strong>유사 사용자 기반 추천(Collaborative Filtering)</strong>을 단순화해서 적용해봤습니다. 이전에 작업한 <strong>콘텐츠 기반 추천(Content-based Filtering)</strong> 방식과 더불어 조금 더 편리한 검색, 추천 서비스를 제공할 수 있는 기능을 구현해볼 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Elasticsearch MLT로 유사도 기반 추천 기능 구현하기]]></title>
            <link>https://velog.io/@dev_hyjang/%EC%BD%98%ED%85%90%EC%B8%A0-%EA%B8%B0%EB%B0%98-%ED%95%84%ED%84%B0%EB%A7%81%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%9C-%EC%9C%A0%EC%82%AC-%EC%BD%98%ED%85%90%EC%B8%A0-%EC%B6%94%EC%B2%9C-%EA%B8%B0%EB%8A%A5</link>
            <guid>https://velog.io/@dev_hyjang/%EC%BD%98%ED%85%90%EC%B8%A0-%EA%B8%B0%EB%B0%98-%ED%95%84%ED%84%B0%EB%A7%81%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%9C-%EC%9C%A0%EC%82%AC-%EC%BD%98%ED%85%90%EC%B8%A0-%EC%B6%94%EC%B2%9C-%EA%B8%B0%EB%8A%A5</guid>
            <pubDate>Thu, 04 Sep 2025 02:31:34 GMT</pubDate>
            <description><![CDATA[<p>오늘은 Elasticsearch의 내장 기능 중 <strong>More Like This (MLT)</strong> 쿼리를 활용하여 유사도가 높은 데이터를 조회하는 기능을 구현해보겠습니다. &quot;유사도&quot;는 문서에 포함된 단어들의 출현 빈도(Term Frequency) 등을 복합적으로 계산하여 결정됩니다. 이 MLT 기능을 활용하여, 사용자가 특정 뉴스 기사를 보고 있을 때 그와 내용이 <strong>비슷한 다른 뉴스 기사들을 추천해 주는 기능</strong>을 구현해 보겠습니다.</p>
<hr>
<h2 id="elasticsearch-more-like-this">Elasticsearch &#39;More Like This&#39;</h2>
<h3 id="mlt-쿼리란-">MLT 쿼리란 ?</h3>
<p>우리는 웹, 앱 어디서든 검색 결과와 관련된 &#39;추천&#39; 콘텐츠를 쉽게 마주합니다. 아래 이미지와 같이 벨로그에서도 포스팅 하단에 &#39;관심 있을 만한 포스트&#39;를 제공합니다. Elasticsearch의 MLT 쿼리는 바로 이러한 추천 기술을 가능하게 합니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/e325b53a-ae28-4a2f-b1e0-508f43a387be/image.png" alt=""></p>
<p>MLT 쿼리는 말 그대로 &quot;이것과 비슷한&quot; 문서를 찾아주는 쿼리입니다. 사용자가 특정한 문서(또는 텍스트)를 선택하면 그것와 내용이 유사한 다른 것들을 추천해주는 기능을 합니다.</p>
<p>MTL 쿼리 방식은 아주 간단하게 아래와 같이 동작합니다.</p>
<ul>
<li>&quot;엘라스틱서치 MLT를 활용한 검색 시스템&quot;이라는 뉴스 기사를 기준 문서로 정함</li>
<li>엘라스틱서치가 이 문서에서 중요한 단어(키워드)를 뽑음</li>
<li>그 키워드들을 기준으로 비슷한 단어가 많이 들어있는 다른 문서를 찾아줌</li>
</ul>
<p>또한 아래와 같이 쿼리를 작성하여 검색 내용을 설정할 수 있습니다.</p>
<pre><code class="language-java">GET news/_search
{
  &quot;query&quot;: {
    &quot;more_like_this&quot;: {                     // MLT 쿼리를 쓰겠다
      &quot;fields&quot;: [&quot;title&quot;, &quot;content&quot;],    // 어떤 필드에서 비교할지(제목과 콘텐츠)
      &quot;like&quot;: [                          // 이 문서와 비슷한 걸 찾아라
        {
          &quot;_index&quot;: &quot;news&quot;,              // news로 인덱싱 되어 있고,
          &quot;_id&quot;: &quot;1&quot;                     // id가 1인 문서
        }
      ],
      &quot;min_term_freq&quot;: 1,              // 최소 몇 번은 나와야 키워드로 쓸지(1번 이상)
      &quot;min_doc_freq&quot;: 1                // 최소 몇 개 문서에 등장해야 쓸지(1번 이상)
    }
  }
}
</code></pre>
<ul>
<li>검색어가 들어오면 어떤 필드와 비교할지를 결정</li>
<li>어떤 문서를 대상으로 할지 결정</li>
<li>그 문서에서 몇 번 이상 언급되어야 키워드로 취급할지 결정</li>
<li>다른 문서 중 몇 번 이상 키워드가 언급되어야 관련 문서로 취급하는지 결정</li>
</ul>
<p>해당 쿼리는 아주 기본적인 MLT 쿼리 작성 방식이며, 아래의 공식 문서 참고하여 추가적으로 검색 설정을 고급화할 수 있습니다.</p>
<ul>
<li><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-mlt-query">https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-mlt-query</a></li>
</ul>
<h4 id="mlt-쿼리-장점">MLT 쿼리 장점</h4>
<ul>
<li>강력한 유사도 분석: 단순히 단어의 빈도를 넘어, BM25/TF-IDF 알고리즘을 기반으로 문서(뉴스) 간의 연관성을 지능적으로 분석합니다.<ul>
<li>엘라스틱서치 5.x 버전 이후 TF-IDF → BM25 알고리즘으로 변경(TF-IDF를 개선한 가중치 모델)</li>
<li>TF-IDF/BM25 알고리즘 비교 참고글 : <a href="https://velog.io/@mayhan/Elasticsearch-%EC%9C%A0%EC%82%AC%EB%8F%84-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98">https://velog.io/@mayhan/Elasticsearch-%EC%9C%A0%EC%82%AC%EB%8F%84-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</a></li>
</ul>
</li>
<li>간결한 구현: 복잡한 유사도 계산 로직을 직접 구현할 필요 없이, <strong>간단한 쿼리 하나</strong>로 해결할 수 있습니다.</li>
<li>유연한 설정: 최소 단어 빈도, 최소 문서 빈도 등 다양한 파라미터를 조정하여 추천의 정밀도를 튜닝할 수 있습니다.</li>
</ul>
<hr>
<h2 id="3-구현">3. 구현</h2>
<p>진행하고 있는 프로젝트에 이미 검색 엔진으로 사용 중인 Elasticsearch 의 내장 기능 <code>More Like This</code> 쿼리를 활영하여 뉴스 목록 검색 시 관련 뉴스도 함께 검색되도록 구현해보겠습니다.</p>
<h4 id="1-유사-뉴스-검색-서비스-메소드-작성">1. &#39;유사 뉴스 검색&#39; 서비스 메소드 작성</h4>
<ul>
<li>NewsService 인터페이스에 searchSimilarNews(Long newsId) 메소드를 선언합니다.</li>
<li>NewsServiceImpl에 moreLikeThisQuery를 사용하여 실제 추천 로직을 구현합니다. </li>
<li>이 쿼리는 Elasticsearch에게 &quot;이 newsId를 가진 문서와 title, description 필드의 내용이 유사한 다른 문서들을 찾아줘&quot; 라고 요청하는 역할을 부여합니다.</li>
<li>반환되는 데이터에 Redis의 정보를 결합(추가 정보 최신화 ex. 좋아요 수, 사용자 정보 등)하여 NewsResponseDto 형태로 제공합니다.</li>
</ul>
<pre><code class="language-java">    @Override
    public List&lt;NewsResponseDto&gt; searchSimilarNews(Long newsId) {
        Query esQuery = new Query.Builder()
                .moreLikeThis(mlt -&gt; mlt
                        .fields(&quot;title&quot;, &quot;description&quot;)
                        .like(l -&gt; l.document(d -&gt; d.index(&quot;news&quot;).id(String.valueOf(newsId))))
                        .minTermFreq(1)
                        .minDocFreq(1)
                )
                .build();
        return searchElasticsearch(esQuery);
    }

    //엘라스틱서치 검색
    private List&lt;NewsResponseDto&gt; searchElasticsearch(Query esQuery) {
        NativeQuery nativeQuery = new NativeQueryBuilder().withQuery(esQuery).build();
        SearchHits&lt;NewsDocument&gt; searchHits = elasticsearchOperations.search(nativeQuery, NewsDocument.class);

        List&lt;Long&gt; newsIds = new ArrayList&lt;&gt;();
        //searchHits에서 newsIds를 반환
        for(SearchHit&lt;NewsDocument&gt; hit : searchHits.getSearchHits()) {
            NewsDocument content = hit.getContent();
            newsIds.add(content.getNewsId());
        }
        return orderResults(newsIds);
    }

    // 결과 유사도 정렬 유지
    private List&lt;NewsResponseDto&gt; orderResults(List&lt;Long&gt; newsIds) {
        if (newsIds.isEmpty()) {
            return Collections.emptyList();
        }

        Map&lt;Long, NewsResponseDto&gt; newsDtoMap = new HashMap&lt;&gt;();
        List&lt;NewsResponseDto&gt; newsByIds = newsMapper.findNewsByIds(newsIds);

        for (NewsResponseDto newsDto : newsByIds) {
            newsDtoMap.put(newsDto.getNewsId(), newsDto);
        }

        List&lt;NewsResponseDto&gt; newsList = (List&lt;NewsResponseDto&gt;) newsDtoMap.values();
        String userId = checkUserId();
        matchEsResultsWithRedisData(newsList, userId);

        List&lt;NewsResponseDto&gt; resultList = new ArrayList&lt;&gt;();
        //검색결과 유사도 정렬 유지
        for (Long newsId : newsIds) {
            NewsResponseDto newsDto = newsDtoMap.get(newsId);
            if (newsDto != null) {
                resultList.add(newsDto);
            }
        }
        return resultList;
    }
</code></pre>
<ol>
<li><code>moreLikeThisQuery</code>를 사용하여 Elasticsearch에 요청할 쿼리를 생성합니다.</li>
<li>아래의 검색 정보를 설정합니다.<ul>
<li>어떤 필드를 비교할 것인가: title과 description 필드의 내용을 비교하도록 지정</li>
<li>어떤 문서를 기준으로 할 것인가: like() 메소드에 기준이 되는 뉴스의 ID(newsId)를 전달</li>
<li>최소 빈도 설정: minTermFreq(1), minDocFreq(1) 옵션 설정(너무 드물게 나타나는 단어는 유사도 계산에서 제외)</li>
</ul>
</li>
<li>searchNews 메소드에서 구현했던 로직을 재사용하여, Elasticsearch에서 찾은 유사 뉴스 ID 목록에 DB와 Redis의 최신 상태 정보를 결합하여 최종 결과를 반환합니다.</li>
</ol>
<h4 id="2-컨트롤러-계층에-유사-검색-api-엔드-포인트-추가">2. 컨트롤러 계층에 유사 검색 API 엔드 포인트 추가</h4>
<pre><code class="language-java">    // 유사 뉴스 추천
    @GetMapping(&quot;/news/{newsId}/similar&quot;)
    public List&lt;NewsResponseDto&gt; findSimilarNews(@PathVariable Long newsId) {
        return newsService.findSimilarNews(newsId);
    }
</code></pre>
<ul>
<li>NewsController에 GET <code>/api/news/{newsId}/similar</code> 라는 API 엔드포인트를 만듭니다.</li>
<li>이 엔드포인트는 경로의 newsId를 기준으로, 해당 뉴스와 유사한 뉴스 목록을 클라이언트에게 반환합니다.</li>
</ul>
<hr>
<h2 id="테스트-및-화면단-코드-추가">테스트 및 화면단 코드 추가</h2>
<p>위과 같이 백엔드 코드를 추가 후 화면에서 &#39;관련 뉴스&#39;가 표출될 수 있도록 vue.js 코드를 수정 및 추가하여 테스트를 진행하였습니다.</p>
<ul>
<li>기본 엘라스틱서치 검색 후 반환되는 뉴스 중 첫 번째 뉴스(가장 관련도가 높음)를 기준으로</li>
<li>유사 뉴스 검색 API 전송</li>
<li>일반 뉴스 검색 목록 + 유사 뉴스 목록 구분하여 표출</li>
</ul>
<pre><code class="language-javascript">const news = ref([])
const similarNews = ref([]);
const searchQuery = ref(&quot;&quot;)

const searchNews = async () =&gt; {
    const accessToken = userStore.token;
    if (!accessToken) {
        alert(&quot;로그인이 필요합니다.&quot;);
        router.push(&#39;/&#39;);
        return;
    }

    if (!searchQuery.value.trim()) {
        fetchNews();
        similarNews.value = [];
        return;
    }

    //뉴스 초기화
    news.value = [];
    similarNews.value = [];

    try {
        // 뉴스 검색
        const response = await apiRequest(`/api/news/search?q=${encodeURIComponent(searchQuery.value)}`, {
            method: &#39;GET&#39;,
            headers: { Authorization: `Bearer ${accessToken}` },
        });

        if (response.status === 401 || response.status === 403) {
            alert(&quot;인증이 필요합니다. 로그인 페이지로 이동합니다.&quot;);
            router.push(&#39;/&#39;);
            return;
        }

        if (!response.ok) {
            throw new Error(&#39;뉴스 검색 API 호출 실패&#39;);
        }

        const searchData = await response.json();
        news.value = searchData.map(item =&gt; ({
            ...item,
            likeCount: item.likeCount || 0,
            isLiked: item.liked || false,
        }));

        // 유사 뉴스 검색
        if (news.value.length &gt; 0) {
            const firstNewsId = news.value[0].newsId; // 첫 번째 뉴스 ID 추출

            const similarNewsResponse = await apiRequest(`/api/news/${firstNewsId}/similar`, {
                method: &#39;GET&#39;,
                headers: { Authorization: `Bearer ${accessToken}` },
            });

            if (similarNewsResponse.ok) {
                const similarNewsData = await similarNewsResponse.json();
                similarNews.value = similarNewsData.map(item =&gt; ({
                    ...item,
                    likeCount: item.likeCount || 0,
                    isLiked: item.liked || false,
                }));
            } else {
                console.error(&#39;유사 뉴스 API 호출 실패&#39;);
                similarNews.value = [];
            }
        }

    } catch (err) {
        console.error(&#39;오류 발생:&#39;, err);
        alert(err.message || &#39;뉴스 검색 중 오류가 발생했습니다.&#39;);
        news.value = [];
        similarNews.value = [];
    } 
};</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/70e2e726-d4d4-453a-acf1-93c1fae18e93/image.png" alt=""></p>
<ul>
<li>검색 결과뿐만 아니라 &#39;관련 뉴스&#39;도 표출이 되는 것을 확인할 수 있었습니다. </li>
<li>Redis의 최신 상태 정보를 결합 결과가 표출되는 것을 확인할 수 있었습니다.</li>
</ul>
<hr>
<h2 id="결론">결론</h2>
<p>Elasticsearch의 More Like This 쿼리를 활용해 복잡한 추천 알고리즘 없이도 간단하게 유사 뉴스 추천 기능을 구현할 수 있었습니다. 처음 접했을 때는 다소 낯설고 어렵게 느껴졌지만, 엘라스틱서치가 제공하는 간단한 설정만으로 서비스에 꼭 필요한 ‘추천’ 기능을 손쉽게 구현할 수 있음을 확인했습니다. </p>
<p>오늘 구현한 작업을 바탕으로 추천 결과에 사용자 행동 데이터(예: 조회수, 좋아요, 클릭 이력)를 반영하거나, 가중치를 조정하는 방식으로 기능을 고도해볼 계획입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot와 Elasticsearch를 활용한 고성능 검색 엔진 구축]]></title>
            <link>https://velog.io/@dev_hyjang/%EB%8F%84%EC%9E%85</link>
            <guid>https://velog.io/@dev_hyjang/%EB%8F%84%EC%9E%85</guid>
            <pubDate>Tue, 02 Sep 2025 04:11:27 GMT</pubDate>
            <description><![CDATA[<p>앞선 게시글을 통해 Elasticsearch의 특징과 핵심 개념 등을 살펴 보았습니다. 이번 글에서는 진행하고 있는 개인 프로젝트에 Elasticsearch를 직접 적용하여 효과적인 검색이 가능하도록 구현해보겠습니다.</p>
<p>현재 프로젝트는 &#39;개인 맞춤형 뉴스 제공&#39;을 목적으로 스프링 스케줄러를 통해 네이버 뉴스 기사를 수집하여 데이터로 활용하고 있습니다. 이에 엘라스틱서치를 활용하여 수집되는 뉴스 데이터를 쌓아두고, 강력한 검색 기능을 제공할 수 있도록 하겠습니다.</p>
<hr>
<h2 id="elasticsearch-도입-이유-">Elasticsearch 도입 이유 ?</h2>
<p>우선 엘라스틱서치를 도서관을 예를 들어 진행 중인 프로젝트에 어떤 이점이 있는지 살펴보겠습니다.</p>
<ul>
<li><p>PostgreSQL (RDBMS): 도서관의 모든 책(데이터)을 책장에 순서대로 꽂아두는 서고와 같습니다. 특정 책(예: ID가 123인 책)을 찾거나, 특정 저자의 책 목록을 찾는 데에는 매우 효율적입니다. 하지만 &quot;인공지능에 대한 내용이 포함된 모든 책을 찾아줘&quot; 와 같은 내용 기반 검색에는 매우 취약하고 느립니다.</p>
</li>
<li><p>Elasticsearch: 이 도서관의 슈퍼 파워를 가진 사서 또는 디지털 검색 시스템과 같습니다. 모든 책의 제목, 저자, 목차, 본문 내용을 전부 키워드별로 분석해서 색인(Index)을 만들어 둡니다. 그래서 다음과 같은 복잡하고 강력한 검색이 가능해집니다.</p>
<ul>
<li>&quot;머신러닝&quot;으로 검색했지만 &quot;기계학습&quot;이 포함된 책도 찾아주기 (동의어 처리)</li>
<li>&quot;develop&quot;으로 검색했지만 &quot;developing&quot;, &quot;developed&quot;가 포함된 책도 찾아주기 (형태소 분석)</li>
<li>&quot;인공지능&quot;과 &quot;빅데이터&quot;가 모두 언급된 책 찾아주기 (복합 조건 검색)</li>
<li>오타가 좀 있어도 (예: &quot;머신러닝&quot; -&gt; &quot;머신러니&quot;) 알아서 찾아주기 (오타 교정)</li>
</ul>
</li>
</ul>
<p>이와 같이 &#39;개인 맞춤형 뉴스 제공&#39;을 목표로 하는 프로젝트에 엘라스틱서치를 통해 아래와 같은 이점을 취할 수 있을 것으로 예상됩니다. </p>
<ol>
<li><p><strong><code>강력한 전문(Full-text) 검색</code></strong> : 사용자가 뉴스 제목이나 내용의 일부만으로 원하는 기사를 빠르고 정확하게 찾을 수 있습니다. (SQL의 LIKE &#39;%...%&#39; 와는 비교할 수 없는 성능과 정확도)</p>
</li>
<li><p><strong><code>콘텐츠 기반 추천의 초석</code></strong> : 특정 뉴스 기사와 &quot;유사한 내용의 다른 뉴스&quot;를 찾는 것이 매우 쉬워집니다. 예를 들어, 사용자가 &#39;인공지능&#39;에 대한 뉴스를 좋아했다면, Elasticsearch에 &quot;이 기사와 비슷한 기사들을 찾아줘&quot;라고 요청하여 관련 뉴스를 추천해 줄 수 있습니다.</p>
</li>
<li><p><strong><code>다양한 통계 및 필터링</code></strong> : 카테고리별, 기간별, 키워드별 뉴스 집계 및 필터링을 매우 빠른 속도로 처리하여 관리자 대시보드나 사용자 필터 기능을 구현하는 데 유리합니다.</p>
</li>
</ol>
<hr>
<h2 id="1-elasticsearch-서버-생성-및-실행">1. Elasticsearch 서버 생성 및 실행</h2>
<pre><code># Docker로 Elasticsearch 8.14.1 버전 실행 명령어
docker run -d --name simple-elastic -p 9200:9200 -p 9300:9300 -e &quot;discovery.type=single-node&quot; -e &quot;xpack.security.enabled=false&quot; docker.elastic.co/elasticsearch/elasticsearch:8.14.1 </code></pre><h2 id="2-스프링부트-프로젝트-의존성-및-설정-추가">2. 스프링부트 프로젝트 의존성 및 설정 추가</h2>
<p>Spring Boot 프로젝트가 Elasticsearch와 통신할 수 있도록 관련 라이브러리를 build.gradle 파일에 추가합니다.</p>
<pre><code class="language-java">implementation &#39;org.springframework.boot:spring-boot-starter-data-elasticsearch&#39;</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/94b521c7-cdd4-4d25-9476-398c6bb51547/image.png" alt=""></p>
<p>Elasticsearch 서버 연결을 위해 <code>application.yml</code> 에 설정 정보를 추가합니다. 일반적으로 Elasticsearch는 9200번 포트로 통신합니다. 로컬 개발 환경을 기준으로, localhost:9200으로 접속하도록 설정합니다.</p>
<pre><code>  elasticsearch:
    uris: http://localhost:9200</code></pre><h2 id="3-document-모델-생성-elasticsearch에-저장될-데이터-설계">3. Document 모델 생성 (Elasticsearch에 저장될 데이터 설계)</h2>
<p>이제 Elasticsearch에 어떤 데이터를, 어떤 형태로 저장할지 설계합니다. 이 역할을 하는 것이 바로 <strong>Document</strong> 클래스입니다.</p>
<p><strong><code>@Document</code></strong></p>
<ul>
<li><p>Elasticsearch에서는 <code>@Document</code> 어노테이션을 붙인 클래스를 만들어 인덱스(Index)에 저장될 문서(Document) 데이터를 표현합니다.</p>
<ul>
<li>인덱스(Index) : RDBMS의 테이블(Table)과 유사한 개념. 데이터가 저장되는 논리적인 공간</li>
<li>문서(Document) : RDBMS의 행(Row)과 유사한 개념. 실제로는 <strong>JSON 객체 형태</strong>로 저장</li>
</ul>
</li>
</ul>
<p><strong>NewsDocument.java 파일 생성:</strong></p>
<ul>
<li><p>검색에 필요한 필드만 모아서 NewsDocument 라는 새로운 클래스를 만듭니다. 이 클래스는 com.ccp.simple.document 라는 새로운 패키지에 생성하여, DB용 <code>domain</code> 객체와 명확히 분리합니다.</p>
</li>
<li><p>이 클래스에는 다음과 같은 어노테이션을 사용하여 각 필드를 어떻게 저장하고 검색할지 Elasticsearch에게 알려줍니다.</p>
</li>
</ul>
<pre><code class="language-java">@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = &quot;news&quot;) // &quot;news&quot;라는 이름의 인덱스에 문서를 저장
@Setting(settingPath = &quot;elasticsearch/nori-settings.json&quot;) // nori 분석기 설정 파일 경로 지정
public class NewsDocument {

    @Id
    private Long newsId; // 뉴스 원본 ID

    @Field(type = FieldType.Text, analyzer = &quot;nori_analyzer&quot;)
    private String title;

    @Field(type = FieldType.Text, analyzer = &quot;nori_analyzer&quot;)
    private String description;

    @Field(type = FieldType.Keyword) // Keyword 타입 사용
    private String link;

    @Field(type = FieldType.Date)
    private LocalDateTime pubDt;
}</code></pre>
<pre><code class="language-json">{
  &quot;analysis&quot;: {
    &quot;analyzer&quot;: {
      &quot;nori_analyzer&quot;: {
        &quot;tokenizer&quot;: &quot;nori_tokenizer&quot;
      }
    }
  }
}
</code></pre>
<ul>
<li>이 클래스는 Elasticsearch에 저장된 뉴스 데이터의 &#39;설계도&#39; 역할을 합니다.</li>
<li><code>@Document(indexName = &quot;news&quot;)</code>: 이 클래스의 데이터는 Elasticsearch의 &quot;news&quot;라는 인덱스에 저장하라는 의미입니다.</li>
<li><code>@Id</code>: 이 필드가 문서의 고유 식별자(Primary Key)임을 나타냅니다.</li>
<li><code>@Field(type = FieldType.Text)</code>: 이 필드는 <strong>전문(Full-text) 검색</strong>이 가능한 텍스트 데이터임을 나타냅니다. Elasticsearch는 이 필드의 내용을 단어 단위로 쪼개고 분석(Analyze)하여, 내용 기반 검색이 가능하도록 만듭니다. (<code>title</code>, <code>description</code>에 적합)</li>
<li><code>@Field(type = FieldType.Date)</code>: 날짜 형식의 데이터임을 나타냅니다.</li>
</ul>
<blockquote>
<p><strong>nori 분석기란?</strong>
Elasticsearch가 한국어 텍스트를 더 잘 이해하도록 도와주는 <strong>형태소 분석기</strong>입니다.
&#39;nori&#39; 분석을 사용하면 &quot;뉴스를 검색합니다&quot; 라는 문장을 단순히 공백으로 자르는 것이 아니라, 의미를 가진 최소 단위인 형태소, 즉 &#39;뉴스&#39;, &#39;검색&#39;, &#39;하다&#39;로 분해합니다. 사용자가 &quot;검색하는 뉴스&quot;라고 입력해도 &quot;뉴스를 검색합니다&quot;라는 제목의 기사를 조회할 수 있게 되어 검색 품질이 비약적으로 향상됩니다.
<del>Elasitcsearch에 기본적으로 포함되어 있어 별도의 설치 없이 바로 사용할 수 있습니다.</del></p>
</blockquote>
<h4 id="참고-nori-플러그인-설치-필요">참고!!! nori 플러그인 설치 필요</h4>
<ul>
<li>테스트 중 nori가 자동으로 내장 설치되지 않아 오류가 계속 발생하였습니다.</li>
<li>도커로 elasticsearch 를 설치하고 bin/bash 안에 nori 플러그인을 직접 설치했습니다.</li>
</ul>
<ul>
<li>nori 설치 &gt; 도커 컨테이서 stop &gt;  도커 컨테이너 run
<img src="https://velog.velcdn.com/images/dev_hyjang/post/6a4db4a7-aead-406b-850e-42e858618104/image.png" alt=""></li>
<li>nori 분석기 설정 파일 경로로 매핑된 것을 확인
<img src="https://velog.velcdn.com/images/dev_hyjang/post/cb01e4c7-47da-4a58-9081-ff6f654b2876/image.png" alt=""></li>
<li>노리 플러그인 설치 확인
<img src="https://velog.velcdn.com/images/dev_hyjang/post/18abe7a0-9766-419d-8c1a-ed89a0d0f3dc/image.png" alt=""></li>
</ul>
<h2 id="4-elasticsearchrepository-상속">4. ElasticsearchRepository 상속</h2>
<p>Spring Data에서는 <code>ElasticsearchRepository</code> 인터페이스를 상속받아 엘라스틱서치 관련 기본적인 CRUD 메소드를 구현할 수 있습니다. (<em>예전에 공부했던 JPA 와 비슷한 형식</em>)
덕분에 별도의 SQL 쿼리를 작성하지 않고도  <code>newsSearchRepository.save()</code>, <code>newsSearchRepository.findById()</code>, <code>newsSearchRepository.delete()</code> 와 같은 메소드를 바로 사용할 수 있어 매우 편리합니다.</p>
<pre><code class="language-java">package com.ccp.simple.repository;

import com.ccp.simple.document.NewsDocument;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface NewsSearchRepository extends ElasticsearchRepository&lt;NewsDocument, Long&gt; {
}</code></pre>
<hr>
<h2 id="데이터-인덱싱-뉴스-수집-시-elasticsearch에-저장하기">데이터 인덱싱 (뉴스 수집 시 Elasticsearch에 저장하기)</h2>
<p>뉴스 수집 시 PostgreSQL에 저장함과 동시에 Elasticsearch에도 저장(Indexing) 하도록 기존 뉴스 수집 로직을 수정합니다.</p>
<ul>
<li>기존에는 DB 저장만 진행</li>
</ul>
<pre><code class="language-java">// DB 뉴스 저장
newsMapper.insertNews(news);</code></pre>
<ul>
<li>DB 저장 + 엘라스틱서치 인덱싱 진행<pre><code class="language-java">// 1. DB 뉴스 저장
newsMapper.insertNews(news);
</code></pre>
</li>
</ul>
<p>// 2. Elasticsearch에 뉴스 인덱싱
NewsDocument newsDocument = NewsDocument.builder()
    .newsId(newsId)
    .title(news.getTitle())
    .description(news.getDescription())
    .link(news.getLink())
    .pubDt(news.getPubDt())
    .build();
newsSearchRepository.save(newsDocument);</p>
<pre><code>
---
## Elasticsearch를 활용한 검색 API 구현

### 1. GET 메소드로 요청을 받는 /api/news/search 엔드포인트를 만듭니다.
```java
    // 뉴스 검색
    @GetMapping(&quot;/news/search&quot;)
    public List&lt;NewsDocument&gt; searchNews(@RequestParam(&quot;q&quot;) String query) {
        return newsService.searchNews(query);
    }</code></pre><ul>
<li>@RequestParam(&quot;q&quot;) 어노테이션을 사용하여, URL의 쿼리 파라미터(예: ?q=검색어)로 들어온 검색어를 query라는 이름의 String 변수로 받습니다.</li>
</ul>
<h3 id="2-엘라스틱서치-검색-서비스-코드를-작성합니다">2. 엘라스틱서치 검색 서비스 코드를 작성합니다.</h3>
<pre><code class="language-java">    @Override
    public List&lt;NewsDocument&gt; searchNews(String query) {
        // 1. multi_match_query 생성
        NativeQuery nativeQuery = new NativeQueryBuilder()
                .withQuery(q -&gt; q
                        .multiMatch(mmq -&gt; mmq
                                .fields(&quot;title&quot;, &quot;description&quot;)
                                .query(query)
                        )
                )
                .build();

        // 2. 검색 실행
        SearchHits&lt;NewsDocument&gt; searchHits = elasticsearchOperations.search(nativeQuery, NewsDocument.class);

        // 3. 결과 반환
        return searchHits.getSearchHits().stream()
                .map(hit -&gt; hit.getContent())
                .collect(Collectors.toList());
    }</code></pre>
<ul>
<li>검색어를 title과 description 필드에서 찾는 multi-match 쿼리를 구현합니다.</li>
</ul>
<hr>
<h2 id="인덱싱-및-검색-테스트">인덱싱 및 검색 테스트</h2>
<h4 id="1-elasticsearch-인덱싱-테스트">1. Elasticsearch 인덱싱 테스트</h4>
<ul>
<li>Elasticsearch 서버 실행 중</li>
<li>뉴스 수집 스케줄러를 실행하여 DB INSERT와 Elasticsearch 에 동일한 데이터가 인덱싱 되도록 함</li>
</ul>
<ul>
<li>DB에 20개의 새로운 뉴스 데이터가 INSERT
<img src="https://velog.velcdn.com/images/dev_hyjang/post/d51bac4c-4266-478c-afc9-fb7bd34b01d0/image.png" alt=""></li>
</ul>
<ul>
<li>엘라스틱서치 카운트 확인 결과 20개로 동일
<img src="https://velog.velcdn.com/images/dev_hyjang/post/84f58f1c-87cb-41a2-8535-7615bf797db0/image.png" alt=""></li>
</ul>
<ul>
<li>DB 데이터와 동일한 데이터가 엘라스틱서치에 인덱싱 된 것을 확인
<img src="https://velog.velcdn.com/images/dev_hyjang/post/2ad965f4-9a46-438b-b8f5-39d786d043b3/image.png" alt=""></li>
</ul>
<h4 id="2-검색-api-테스트">2. 검색 API 테스트</h4>
<ul>
<li>Postman에 아래와 같은 형식의 URL을 입력하여 GET 요청을 보냅니다.</li>
<li>요청 예시 : <a href="http://localhost:8080/api/news/search?q=%EC%A0%95%EC%B9%98">http://localhost:8080/api/news/search?q=정치</a>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/92a55841-0139-4df2-ab7a-de6851e10966/image.png" alt=""></li>
</ul>
<h4 id="3-응답값-확인-및-비교">3. 응답값 확인 및 비교:</h4>
<ul>
<li><p>응답(Response)으로 검색어와 일치하는 NewsDocument 객체의 JSON 배열이 반환되는지 확인</p>
</li>
<li><p>검색어를 바꿔가며, 제목이나 내용에 해당 검색어가 포함된 뉴스들이 정확히 찾아지는지 테스트 진행</p>
<ul>
<li>검색어 : 교육과정
<img src="https://velog.velcdn.com/images/dev_hyjang/post/6b483fe2-2721-4ed3-86f2-9ae2baac889a/image.png" alt=""></li>
<li>엘라스틱서치를 통한 검색으로는 &#39;교육과정&#39;으로 검색을 했을 때 &#39;교육 과정&#39;이 속한 기사뿐만 아니라 &#39;교육&#39; 형태소가 속한 데이터를 결과로 조회하는 것을 확인
<img src="https://velog.velcdn.com/images/dev_hyjang/post/4259ab2a-42f6-49ab-bf4b-e1bc30e9d8c3/image.png" alt="">    </li>
<li>SQL 검색 결과 &#39;%교육과정%&#39; LIKE 검색이 이루어졌기 때문에 정확히 일치하지 않는 데이터는 조회되지 않음</li>
</ul>
</li>
</ul>
<hr>
<h2 id="결과">결과</h2>
<p>지금까지 Spring Boot 환경에 Elasticsearch를 성공적으로 연동하고, nori 한국어 분석기를 적용하여 RDBMS의 한계를 뛰어넘는 효율적인 전문 검색 기능을 구현했습니다.</p>
<ul>
<li><p>다중 필드 동시 검색: 사용자는 검색어 하나만 입력했지만, 시스템은 multi_match 쿼리를 통해 뉴스의 제목과 본문 내용을 동시에 탐색하여 관련도 높은 결과를 찾아냅니다.</p>
</li>
<li><p>한국어 형태소 분석: nori 분석기 덕분에, 사용자가 &quot;뉴스를 검색하는 방법&quot;이라고 검색해도, &quot;뉴스 검색 방법&quot;이나 &quot;뉴스의 검색&quot;이 포함된 문서를 찾아낼 수 있습니다. 이처럼 단어의 원형과 조사를 이해하는 지능적인 검색이 가능해졌습니다.</p>
</li>
<li><p>높은 성능과 확장성: 수백만 건의 문서가 쌓이더라도 Elasticsearch는 거의 실시간에 가까운 검색 속도를 보장합니다. 이는 사용자에게 쾌적한 검색 경험을 제공할 뿐만 아니라, 향후 데이터가 증가하더라도 안정적인 성능을 유지할 수 있는 확장성을 확보했음을 의미합니다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[DB와는 다른 검색 엔진, Elasticsearch 이해하기]]></title>
            <link>https://velog.io/@dev_hyjang/Elasticsearch-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyjang/Elasticsearch-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 02 Sep 2025 02:35:29 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<h2 id="elasticsearch-란">Elasticsearch 란?</h2>
<ul>
<li>엘라스틱서치는 Apache Lucene 기반의 오픈소스 분산 검색 및 분석 엔진입니다.</li>
<li><strong>방대한 데이터를 빠르고 거의 실시간으로 저장하고 검색하고, 효과적으로 분석, 시각화</strong>할 수 있습니다.</li>
<li>엘라스틱서치는 데이터베이스가 아니라 <strong>문서 기반 데이터</strong>를 저장하고 이를 기반으로 아주 빠르게 검색할 수 있도록 설계된 시스템입니다.</li>
<li>기존 관계형 데이터베이스(RDB)가 복잡한 쿼리로 인해 검색 속도가 느려지는 문제를 해결하기 위해 <strong>전문 검색</strong> 기능이 특화되어 있습니다.</li>
<li>데이터베이스는 구조적 데이터 저장 / 관계 연산에 강하지만, 엘라스틱서치는 비정형 데이터 저장 / 검색과 분석에 강한 특징이 있습니다. </li>
</ul>
<hr>
<h2 id="elasticsearch-특징">Elasticsearch 특징</h2>
<p><strong>1. 분산 검색 엔진</strong>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/b9e82f26-2edc-4955-88d4-e52282ebb840/image.png" width="500"/></p>
<ul>
<li>엘라스틱서치는 분산형 시스템(구조)로 설계되어 있습니다.</li>
<li>여러 서버(노드)에 데이터를 분산해 저장하거나 검색하여, 확장성과 고가용성을 제공합니다.</li>
<li>데이터를 여러 서버에 분산하여 저장하고 처리하기 때문에, 단일 서버의 한계를 뛰어넘어 대규모 데이터를 처리할 수 있고, 장애가 발생해도 시스템이 멈추지 않았기 때문에 안정적인 서비스를 제공할 수 있습니다.</li>
</ul>
<p><strong>2. 역색인 구조</strong></p>
<ul>
<li><p>SQL과 달리 색인된 데이터에서 효율적으로 문서를 찾을 수 있는 방식(inverted index)으로 동작합니다.</p>
</li>
<li><p>일반적인 데이터베이스는 문서(document)를 기준으로 단어를 찾아야 해서 느리지만, <strong>역색인은 단어를 기준으로 그 단어가 포함된 문서를 검색</strong>합니다.</p>
</li>
<li><p>이러한 검색 방법을 통해 엘라스틱서치의 검색 속도가 매우 빠를 수 있습니다.</p>
</li>
<li><p>예시) &quot;자바 스프링부트&quot; 라는 문서가 있으면 역색인은 다음과 같이 구성됩니다.</p>
<ul>
<li>&quot;자바&quot; : [문서1, 문서2]</li>
<li>&quot;스프링부트&quot; &quot; [문서1, 문서3]<ul>
<li>이렇게 미리 단어별로 문서를 매핑해두면, &quot;자바&quot;를 검색할 때 전체 문서를 훑어보지 않고, 바로 해당 문서 목록을 찾아내기 때문에 검색 속도가 매우 빨라집니다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<p><strong>3. RESTful API 제공</strong></p>
<ul>
<li>데이터를 저장(CREATE), 검색(READ), 수정(UPDATE), 삭제(DELETE) 등의 작업을 HTTP 기반의 API로 할 수 있습니다.</li>
</ul>
<p><strong>4. 모든 데이터 지원</strong></p>
<ul>
<li>정형 및 비정형 데이터(텍스트, 숫자, 위치 등)를 인덱싱하고 분석할 수 있습니다.</li>
</ul>
<p><strong>5. 실시간성</strong></p>
<ul>
<li>Near Real Time으로 데이터를 거의 즉시 검색·분석할 수 있습니다.</li>
</ul>
<p><strong>6. ELK 스택의 핵심</strong></p>
<ul>
<li>Logstash(수집), Elasticsearch(검색/분석), Kibana(시각화)로 이루어진 ELK 스택에서 주로 사용됩니다.</li>
</ul>
<hr>
<h2 id="elasticsearch-핵심-개념">Elasticsearch 핵심 개념</h2>
<p><strong><code>Index (인덱스)</code></strong> : 관계형 DB의 Database랑 비슷. 데이터를 모아두는 공간.</p>
<p><strong><code>Document (문서)</code></strong> :  DB의 Row에 해당. JSON 형태로 저장됨.</p>
<p><strong><code>Field (필드)</code></strong> : DB의 Column처럼 각 문서의 속성.</p>
<p>예시) 뉴스 기사 데이터</p>
<pre><code class="language-json">{
  &quot;title&quot;: &quot;스프링부트에 엘라스틱서치 도입&quot;,
  &quot;author&quot;: &quot;홍길동&quot;,
  &quot;content&quot;: &quot;오늘부터 우리 프로젝트에 엘라스틱서치를 적용한다.&quot;,
  &quot;published_date&quot;: &quot;2025-09-02&quot;
}</code></pre>
<ul>
<li>이 JSON 하나가 Document</li>
<li>여러 개가 모이면 하나의 Index (예: &quot;news&quot;)</li>
</ul>
<hr>
<h2 id="rdbms와-elasticsearch의-차이점">RDBMS와 Elasticsearch의 차이점</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>관계형 데이터베이스(DB, MySQL/PostgreSQL 등)</th>
<th>Elasticsearch</th>
</tr>
</thead>
<tbody><tr>
<td><strong>데이터 구조</strong></td>
<td>테이블(<code>Table</code>), 행(<code>Row</code>), 열(<code>Column</code>)</td>
<td>인덱스(<code>Index</code>), 문서(<code>Document</code>), 필드(<code>Field</code>)</td>
</tr>
<tr>
<td><strong>데이터 형식</strong></td>
<td>정형 데이터(숫자, 문자열, 날짜 등)</td>
<td>JSON 기반 문서 (정형 + 반정형 + 텍스트)</td>
</tr>
<tr>
<td><strong>저장 목적</strong></td>
<td>데이터의 무결성, 트랜잭션 관리, 관계형 모델</td>
<td>검색 속도, 텍스트 분석, 실시간 분석</td>
</tr>
<tr>
<td><strong>쿼리 언어</strong></td>
<td>SQL</td>
<td>DSL(Query DSL, JSON 기반 쿼리)</td>
</tr>
<tr>
<td><strong>검색 기능</strong></td>
<td>정확한 값 조회, 범위 검색, JOIN</td>
<td>풀텍스트 검색, 유사도 검색, 점수 기반 랭킹</td>
</tr>
<tr>
<td><strong>성능 특화</strong></td>
<td>다중 트랜잭션 처리, 정합성 보장</td>
<td>대규모 데이터의 빠른 검색과 분석</td>
</tr>
<tr>
<td><strong>트랜잭션(ACID)</strong></td>
<td>지원 (강력한 일관성)</td>
<td>제한적 (최종적 일관성, 분산 처리 중심)</td>
</tr>
<tr>
<td><strong>확장성</strong></td>
<td>수직 확장(서버 스펙 업그레이드 중심)</td>
<td>수평 확장(노드 추가로 클러스터 확장)</td>
</tr>
<tr>
<td><strong>활용 예시</strong></td>
<td>주문/결제 시스템, 고객 관리, 금융 거래</td>
<td>상품 검색, 로그 수집·분석, 추천 시스템</td>
</tr>
</tbody></table>
<p><strong>데이터 저장 구조</strong></p>
<ul>
<li>RDBMS는 테이블, 열(컬럼), 행(로우), 스키마에 기반해서 데이터를 정형화하여 저장합니다.</li>
<li>엘라스틱서치는 JSON 문서 형태로 데이터를 저장하며, 필드와 구조가 자유롭고, 스키마가 유연합니다.</li>
</ul>
<p><strong>검색 방식</strong></p>
<ul>
<li>RDBMS는 SQL 쿼리와 전통적인 B-Tree, 해시 인덱스 등을 사용하여 컬럼 값 기반 조회에 최적화되어 있습니다.</li>
<li>엘라스틱서치는 역색인(inverted index) 구조 및 텍스트 분석 기반의 검색을 기본으로 하여, 대규모 텍스트 및 비정형 데이터의 빠른 &#39;키워드&#39;, &#39;Full-text&#39; 검색에 매우 강점을 가집니다.</li>
</ul>
<p><strong>확장성과 처리 성능</strong></p>
<ul>
<li>RDBMS는 수직 확장(CPU, 메모리 업그레이드)에 기반하며, 분산과 대용량 처리에 한계가 있을 수 있습니다.</li>
<li>엘라스틱서치는 분산형 아키텍처이므로, 수평 확장(노드 추가)을 통해 대용량 데이터를 빠르고 효율적으로 관리할 수 있습니다.</li>
</ul>
<p><strong>트랜잭션 및 데이터 일관성</strong></p>
<ul>
<li>RDBMS는 ACID(Atomicity, Consistency, Isolation, Durability) 성질을 갖추고 있어 트랜잭션과 데이터 무결성, 복잡한 조인과 관리에 강합니다.</li>
<li>엘라스틱서치는 트랜잭션 처리, 롤백, 일관성이 약하거나 제공되지 않으며, 검색과 집계에 특화되어 있습니다.</li>
</ul>
<p><strong>활용 및 목적</strong></p>
<ul>
<li>RDBMS: 비즈니스·업무 시스템에서 정형 데이터 관리와 복잡한 관계 처리, 데이터의 정확성과 안정성이 반드시 필요한 경우 사용합니다.</li>
<li>엘라스틱서치: 로그, 텍스트, 다양한 형태의 비정형 데이터에 대해 빠른 검색·집계·분석이 필요할 때 적합합니다.</li>
</ul>
<p><strong>DB vs Elasticsearch 예시</strong></p>
<p>DB 검색</p>
<pre><code class="language-sql">SELECT * FROM news WHERE content LIKE &#39;%엘라스틱 서치%&#39;;</code></pre>
<ul>
<li>단순한 LIKE 매칭만 가능</li>
<li>테이블 전체를 스캔해야 하므로 시간이 오래 걸림</li>
</ul>
<p>엘라스틱서치 검색</p>
<pre><code class="language-sql">{
  &quot;query&quot;: {
    &quot;match&quot;: {
      &quot;content&quot;: &quot;엘라스틱&quot;
    }
  }
}</code></pre>
<ul>
<li>미리 생성된 역색인에서 &quot;엘라스틱&quot;과 &quot;서치&quot;라는 단어가 포함된 모든 문서 검색 가능</li>
<li>형태소 분석, 유사어 검색, 점수(관련도)순 정렬까지 가능</li>
<li>오타 보정 및 유사어 제안 기능도 쉽게 구현 가능</li>
</ul>
<hr>
<h2 id="elasticsearch의-활용">Elasticsearch의 활용</h2>
<p>위와 같은 특장점을 가지고 있기에 엘라스틱서치는 검색 성능과 확장성, 실시간 분석 기능 때문에 많이 사용되고 있습니다.</p>
<ul>
<li>검색 기능: 쇼핑몰 상품 검색, 블로그/뉴스 검색</li>
<li>로그/모니터링: 서버 로그, 사용자 이벤트 데이터 (보통 ELK Stack: Elasticsearch + Logstash + Kibana)</li>
<li>실시간 분석: 유저 행동 데이터, 클릭/조회수 통계</li>
<li>쇼핑몰 검색 기능: 고객이 상품 이름이나 설명을 자유롭게 검색 가능</li>
<li>로그 분석: 서버 로그 데이터를 실시간으로 수집·검색</li>
<li>위치 기반 서비스: 지도/위치 데이터에 대한 검색 및 분석</li>
<li>대규모 데이터 분석: 수백만 건의 데이터에 대한 빠른 탐색과 집계.</li>
</ul>
<hr>
<p>내용 심화 참고
<a href="https://velog.io/@big9810/%EC%97%98%EB%9D%BC%EC%8A%A4%ED%8B%B1%EC%84%9C%EC%B9%98">https://velog.io/@big9810/%EC%97%98%EB%9D%BC%EC%8A%A4%ED%8B%B1%EC%84%9C%EC%B9%98</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[대규모 트래픽 처리를 위한 Redis 기반 '좋아요' 기능 구현]]></title>
            <link>https://velog.io/@dev_hyjang/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-Redis-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyjang/%EC%A2%8B%EC%95%84%EC%9A%94-%EA%B8%B0%EB%8A%A5-Redis-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 28 Aug 2025 07:39:20 GMT</pubDate>
            <description><![CDATA[<h3 id="좋아요-기능에-redis를-도입한-이유">좋아요 기능에 Redis를 도입한 이유</h3>
<p>기존에는 사용자가 &#39;좋아요&#39; 버튼을 클릭할 때마다 RDBMS의 특정 테이블에 UPDATE 쿼리를 실행하는 방식을 사용했습니다.</p>
<ul>
<li>장점: 구현이 단순하고 직관적</li>
<li>단점:
  1) 좋아요 수를 보여주려면 항상 COUNT(*) 쿼리를 날려야 함
  2) 사용자가 많아질수록 조회 성능 저하
  3) 좋아요 버튼 클릭이 늘어나면 DB에 잦은 쓰기 부하 발생</li>
</ul>
<p>=&gt; <strong>성능 병목(Performance Bottleneck)</strong>: 디스크 기반의 RDBMS는 메모리 기반 스토리지에 비해 쓰기 작업의 비용이 높음. 트래픽이 집중될 경우, 잦은 I/O 작업으로 인해 DB에 과도한 부하가 발생하며 이는 전체 애플리케이션의 응답 시간 저하로 이어짐.</p>
<p>=&gt; <strong>확장성 한계</strong>: DB 커넥션 풀이 고갈되거나 테이블에 락(Lock)이 발생하여 다른 중요한 트랜잭션 처리를 방해할 수 있음.</p>
<p>이러한 문제를 해결하기 위해, 실시간 데이터 처리를 위한 In-Memory 데이터 저장소인 Redis를 도입하여 시스템 아키텍처를 개선하기로 결정했습니다.</p>
<hr>
<h3 id="redis란-무엇인가">Redis란 무엇인가?</h3>
<p>Redis는 In-Memory 기반의 데이터 저장소로, 데이터를 메모리에 올려두기 때문에 DB보다 훨씬 빠른 속도로 읽고 쓸 수 있습니다.</p>
<ul>
<li>Key-Value 형태로 데이터를 저장</li>
<li>다양한 자료구조 지원 (String, List, Set, Sorted Set, Hash 등)</li>
<li>빠른 속도: 밀리초 단위 응답</li>
<li>캐시 &amp; 실시간 데이터 처리에 강점</li>
</ul>
<p>즉, <strong>빠르게 바뀌고 자주 읽히는 데이터 관리</strong>에 특화된 저장소라고 할 수 있습니다.</p>
<p>Redis는 대규모 트래픽을 처리하는 서비스에서 사용하는 매우 효율적이고 표준적인 아키텍처입니다. 사용자가 &#39;좋아요&#39;를 누를 때마다 RDBMS에 직접 쓰기 작업을 하면 부하가 상당한데, 이를 Redis를 통해 비동기적으로 처리하면 다음과 같은 큰 이점을 얻을 수 있습니다.</p>
<hr>
<h3 id="redis를-도입하면-좋은-이유">Redis를 도입하면 좋은 이유?</h3>
<p>(1) 빠른 좋아요 수 조회</p>
<ul>
<li>기존: SELECT COUNT(*) FROM USER_NEWS_LIKE WHERE news_id = ?</li>
<li>Redis: SCARD news:like:{newsId} (SET의 크기 조회) =&gt; O(1) 연산으로 즉시 결과 제공</li>
</ul>
<p>(2) 중복 방지 및 상태 관리</p>
<p><strong>순서가 없고, 중복된 데이터를 허용하지 않는 집합인 Set 자료구조</strong>를 사용해,</p>
<ul>
<li>새로운 사용자가 &#39;좋아요&#39;를 누르면: SADD 명령어로 그냥 집합에 원소를 추가</li>
<li>기존 사용자가 &#39;좋아요&#39;를 취소하면: SREM 명령어로 그냥 집합에서 원소를 제거</li>
</ul>
<p>특정 사용자가 좋아요 했는지 확인도 SISMEMBER로 즉시 확인 가능</p>
<p>(3) DB 부하 감소</p>
<ul>
<li>모든 요청을 DB까지 보내지 않고, Redis에서 처리</li>
<li>Redis는 메모리 기반이라 초당 수만~수십만 요청도 무난히 처리 가능</li>
</ul>
<p>(4) 확장성과 실시간성</p>
<ul>
<li>좋아요 수는 실시간으로 변하는 데이터</li>
<li>캐싱을 두지 않으면 매번 DB를 조회해야 해서 느려짐</li>
<li>Redis는 실시간 업데이트 + 실시간 조회에 최적화</li>
</ul>
<p>(5) 장애 대응 전략</p>
<ul>
<li><p>Redis는 메모리 기반이라 장애 시 데이터 유실 가능성 있음</p>
</li>
<li><p>따라서 USER_NEWS_LIKE 테이블에는 기록을 유지하고,
주기적으로 Redis의 데이터를 DB like_count와 동기화하는 방식을 채택
=&gt; 안정성과 성능을 모두 확보</p>
</li>
<li><p>응답성 향상: 사용자는 Redis에만 데이터가 기록되므로 &#39;좋아요&#39; 요청에 대한 응답을 즉시 받게 되어 UX가 향상됩니다.</p>
</li>
<li><p>DB 부하 감소: 빈번한 쓰기 작업이 DB가 아닌 메모리 기반의 Redis에서 처리되므로, DB는 더 중요한 읽기나 트랜잭션 처리에 집중할 수 있습니다.</p>
</li>
<li><p>확장성: 트래픽이 증가하더라도 Redis 클러스터링 등을 통해 쉽게 확장할 수 있습니다.</p>
</li>
</ul>
<hr>
<h3 id="redis-활용한-대규모-트래픽-관리">Redis 활용한 대규모 트래픽 관리</h3>
<p>전체적인 작업 흐름은 다음과 같습니다.</p>
<blockquote>
</blockquote>
<p><strong>1. Redis 의존성 추가</strong>: build.gradle에 Spring Data Redis 라이브러리를 추가
<strong>2. Redis 접속 정보 설정</strong>: application.yml에 Redis 서버 접속 정보를 추가
<strong>3. 데이터 모델 및 매퍼 수정</strong>:
<strong>4. 서비스 로직 변경</strong>: NewsServiceImpl의 toggleLike 메소드가 DB 대신 Redis와 상호작용하도록 수정
<strong>5. 배치 스케줄러 생성</strong>: 주기적으로 Redis의 &#39;좋아요&#39; 수를 DB에 동기화하는 스케줄러 생성</p>
<h3 id="아키텍처-설계">아키텍처 설계</h3>
<p>본 프로젝트는 Redis의 성능적 이점과 RDBMS의 데이터 영속성 및 일관성의 장점을 모두 취하는 하이브리드 아키텍처를 채택했습니다.</p>
<h4 id="이점">이점</h4>
<ol>
<li><p><strong>실시간 처리 (Redis)</strong>: 사용자의 &#39;좋아요&#39; 요청은 즉시 Redis에만 기록됩니다. DB 접근이 없으므로 사용자에게 매우 빠른 응답을 제공할 수 있습니다.</p>
<ul>
<li>Redis의 <code>Set</code> 자료구조를 사용하여 <code>news:like:{뉴스ID}</code> 형태의 Key에 &#39;좋아요&#39;를 누른 <code>사용자ID</code>를 저장합니다. <code>Set</code>은 중복된 원소를 허용하지 않으므로, 한 사용자가 중복으로 &#39;좋아요&#39;를 누르는 것을 원천적으로 방지합니다.</li>
</ul>
</li>
<li><p><strong>비동기 동기화 (Scheduler -&gt; RDBMS)</strong>: Spring Scheduler를 이용한 배치 작업이 주기적으로 실행됩니다.</p>
<ul>
<li>스케줄러는 Redis에 저장된 모든 &#39;좋아요&#39; 키를 조회하여, <code>SCARD</code> 명령어로 각 뉴스의 최종 &#39;좋아요&#39; 개수를 집계합니다.</li>
<li>집계된 최종 결과만을 RDBMS(PostgreSQL)의 <code>like_count</code> 컬럼에 <code>UPDATE</code> 합니다.</li>
</ul>
</li>
<li><p>*<em>상태 기반(State-based) *</em> : Redis와 스케줄러는 &quot;변경 내역(Delta)을 추적&quot; 하는 것이 아니라, 매번 &quot;전체 상태(Full State)를 기준으로&quot; 동작합니다.</p>
<ul>
<li>로직이 매우 간단하여 버그가 발생할 확률이 적습니다.</li>
<li>중간에 스케줄러가 실패하더라도 다음 스케줄러 동작에서 동기화가 가능합니다(멱등성 보장)</li>
<li>Redis에서 <code>SCARD(전체 개수 세기)</code> 속도가 매우 빨라 성능이 좋습니다.</li>
</ul>
</li>
</ol>
<p>이를 통해 빈번한 쓰기 작업을 Redis가 흡수하도록 하여 DB 부하를 최소화하고, 스케줄러를 통해 데이터의 최종 일관성을 보장합니다.</p>
<blockquote>
<p><strong>Redis (Set)</strong></p>
</blockquote>
<ul>
<li>&#39;좋아요&#39;를 누른 사용자 목록을 실시간으로 관리</li>
<li>SADD (추가), SREM (삭제), SISMEMBER (멤버 확인)</li>
<li>현재 누가 &#39;좋아요&#39;를 누르고 있는가? (상태)를 확인</li>
</ul>
<blockquote>
<p><strong>스케줄러</strong></p>
</blockquote>
<ul>
<li>Redis와 DB를 연결하는 데이터 동기화 파이프라인</li>
<li>Redis에서 SCARD (개수 세기), DB에 UPDATE (집계 결과 저장)</li>
<li>특정 시점의 &#39;좋아요&#39; 개수를 집계</li>
<li>이때 스케줄러는 &quot;지난번 동기화 이후에 새로 추가된 데이터만 찾아서 더한다&quot;는 식으로 복잡하게 동작하지 않음</li>
<li>스케줄러가 동작하는 &quot;현재&quot; Redis에서 데이터를 전체 카운팅해서 DB를 덮어쓰는 식으로 진행</li>
</ul>
<blockquote>
<p><strong>RDBMS</strong></p>
</blockquote>
<ul>
<li>집계된 &#39;좋아요&#39; 수를 저장하는 캐시 또는 요약 정보</li>
<li>주요 연산: SELECT (뉴스 목록과 함께 &#39;좋아요&#39; 수를 빠르게 조회)</li>
<li>데이터의 의미: 특정 과거 시점에 집계된 &#39;좋아요&#39; 총 개수</li>
</ul>
<hr>
<h3 id="구현-상세">구현 상세</h3>
<h4 id="1-환경-설정-의존성-및-설정-추가">1. 환경 설정: 의존성 및 설정 추가</h4>
<ul>
<li>Redis 서버를 도커로 설치하고 실행합니다.
<img src="https://velog.velcdn.com/images/dev_hyjang/post/aedf63aa-53ca-45fb-b036-0ff7b33f3313/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/07012d52-3e5e-451c-b5c3-f9b9ac2c255f/image.png" alt=""></p>
<ul>
<li>build.gradle 파일에 Spring Data Redis 의존성을 추가합니다.<pre><code class="language-javascript">dependencies {
  implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;
}</code></pre>
다음으로 application.yml에 Redis 서버 접속 정보를 추가합니다.</li>
</ul>
<pre><code class="language-javascript"># application.yml
spring:
  # ... datasource 등 기존 설정
  data:
    redis:
      host: localhost
      port: 6379</code></pre>
<h4 id="2-데이터베이스-및-모델-수정">2. 데이터베이스 및 모델 수정</h4>
<p>RDBMS의 NEWS_INFO 테이블에 &#39;좋아요&#39; 수를 저장할 컬럼을 추가합니다.</p>
<pre><code class="language-sql">ALTER TABLE NEWS_INFO
ADD COLUMN like_count INT NOT NULL DEFAULT 0;</code></pre>
<p>News 엔티티와 프론트엔드로 데이터를 전달할 NewsResponseDto에도 likeCount 필드를 추가합니다.</p>
<pre><code class="language-java">@Data
@NoArgsConstructor
public class News {
    private int likeCount; // 좋아요 수
}

// NewsResponseDto.java
@Data
public class NewsResponseDto {
    private int likeCount;
}</code></pre>
<h4 id="3-redis-설정-클래스-추가">3. Redis 설정 클래스 추가</h4>
<p>RedisTemplate을 프로젝트 전반에서 편리하게 사용하기 위해, Key와 Value를 문자열로 직렬화하는 설정 클래스를 만듭니다.</p>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate&lt;String, String&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate&lt;String, String&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());
        return template;
    }
}</code></pre>
<h4 id="4-mybatis-매퍼-수정">4. MyBatis 매퍼 수정</h4>
<p>스케줄러가 DB에 &#39;좋아요&#39; 수를 업데이트할 수 있도록 NewsMapper.xml에 update 구문을 추가하고, 뉴스 목록 조회 시 like_count를 포함하도록 수정합니다.</p>
<pre><code class="language-xml">&lt;mapper namespace=&quot;com.ccp.simple.mapper.NewsMapper&quot;&gt;
    &lt;update id=&quot;updateLikeCount&quot;&gt;
        UPDATE NEWS_INFO
        SET like_count = #{likeCount}
        WHERE news_id = #{newsId}
    &lt;/update&gt;
&lt;/mapper&gt;</code></pre>
<p>NewsMapper.java 인터페이스에도 해당 메소드를 추가합니다.</p>
<pre><code class="language-java">@Mapper
public interface NewsMapper {
    void updateLikeCount(@Param(&quot;newsId&quot;) Long newsId, @Param(&quot;likeCount&quot;) int likeCount);
}</code></pre>
<h4 id="5-서비스-로직-변경-db-대신-redis와-통신">5. 서비스 로직 변경: DB 대신 Redis와 통신</h4>
<p>NewsServiceImpl의 toggleLike 메소드가 DB 대신 Redis와 통신하도록 수정합니다.</p>
<pre><code class="language-java">    @Override
    @Transactional
    public boolean toggleLike(String userId, Long newsId) {
        String likeKey = &quot;news:like:&quot; + newsId;
        Boolean isMember = redisTemplate.opsForSet().isMember(likeKey, userId);

        if (Boolean.TRUE.equals(isMember)) {
            // Redis 좋아요 취소
            redisTemplate.opsForSet().remove(likeKey, userId);
            newsMapper.deleteLike(userId, newsId);

            String countKey = &quot;news:like_count:&quot; + newsId;
            redisTemplate.opsForValue().decrement(countKey);

            return false;
        } else {
            // Redis 좋아요 추가
            redisTemplate.opsForSet().add(likeKey, userId);
            newsMapper.insertLike(userId, newsId);

            String countKey = &quot;news:like_count:&quot; + newsId;
            redisTemplate.opsForValue().increment(countKey);

            return true;
        }
    }</code></pre>
<h4 id="6-db-동기화-스케줄러-생성">6. DB 동기화 스케줄러 생성</h4>
<p>주기적으로 Redis의 &#39;좋아요&#39; 수를 DB에 동기화할 스케줄러를 만듭니다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class LikeCountSyncScheduler {

    private final RedisTemplate&lt;String, String&gt; redisTemplate;
    private final NewsService newsService;

    // 10분마다 실행
    @Scheduled(cron = &quot;0 */10 * * * *&quot;)
    public void syncLikeCountsToDb() {
        log.info(&quot;Redis &#39;좋아요&#39; 수 DB 동기화 시작&quot;);
        Set&lt;String&gt; likeKeys = redisTemplate.keys(&quot;news:like:*&quot;);

        if (likeKeys == null || likeKeys.isEmpty()) {
            log.info(&quot;동기화할 &#39;좋아요&#39; 데이터가 없습니다.&quot;);
            return;
        }

        for (String key : likeKeys) {
            try {
                Long newsId = Long.parseLong(key.split(&quot;:&quot;)[2]);
                Long likeCount = redisTemplate.opsForSet().size(key);
                if (likeCount != null) {
                    newsService.updateLikeCount(newsId, likeCount.intValue());
                }
            } catch (Exception e) {
                log.error(&quot;키 &#39;{}&#39; 처리 중 오류 발생: {}&quot;, key, e.getMessage());
            }
        }
        log.info(&quot;Redis &#39;좋아요&#39; 수 DB 동기화 완료&quot;);
    }
}</code></pre>
<h4 id="7-api-컨트롤러-수정">7. API 컨트롤러 수정</h4>
<p>NewsController에서 toggleLike 서비스를 호출하고 인증된 사용자 정보를 넘겨줍니다.</p>
<pre><code class="language-java">    @PostMapping(&quot;/news/{newsId}/like&quot;)
    public ResponseEntity&lt;Map&lt;String, Object&gt;&gt; toggleLike(@PathVariable Long newsId, Authentication authentication) {
        String userId = authentication.getName();
        boolean isLiked = newsService.toggleLike(userId, newsId);

        Map&lt;String, Object&gt; response = new HashMap&lt;&gt;();
        response.put(&quot;isLiked&quot;, isLiked);
        response.put(&quot;message&quot;, isLiked ? &quot;좋아요를 눌렀습니다.&quot; : &quot;좋아요를 취소했습니다.&quot;);

        return ResponseEntity.ok(response);
    }</code></pre>
<p>사용자가 좋아요 후 새로고침을 했을 때 Redis의 정보와 DB 불일치를 방지하기 위해 본인의 좋아요 정보를 Redis에서 조회하여 표출합니다.</p>
<pre><code class="language-java">@Override
    public List&lt;NewsResponseDto&gt; getAllNews() {
        String userId = null;
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null &amp;&amp; authentication.isAuthenticated()) {
            userId = authentication.getName();
        }

        List&lt;NewsResponseDto&gt; newsList = newsMapper.getAllNews();

        for (NewsResponseDto news : newsList) {
            String likeKey = &quot;news:like:&quot; + news.getNewsId();

            // Redis에서 실시간 좋아요 개수 가져오기
            Long likeCount = redisTemplate.opsForSet().size(likeKey);
            news.setLikeCount(likeCount != null ? likeCount.intValue() : 0);

            // 현재 사용자가 좋아요를 눌렀는지 확인
            if (userId != null) {
                Boolean isMember = redisTemplate.opsForSet().isMember(likeKey, userId);
                news.setLiked(Boolean.TRUE.equals(isMember));
            } else {
                news.setLiked(false); // 로그인하지 않은 사용자는 항상 false
            }
        }
        return newsList;
    }</code></pre>
<hr>
<h3 id="테스트-진행">테스트 진행</h3>
<h4 id="1-좋아요-토글-api-테스트">1. 좋아요 토글 API 테스트</h4>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/f9364a26-44b7-4053-947e-369846f1f429/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/c45275f9-50fb-48a8-a97d-346e88f6e58f/image.png" alt=""></p>
<h4 id="2--redis-데이터-직접-확인">2.  Redis 데이터 직접 확인</h4>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/28b0c46c-148e-4a11-b659-82cab2f52b55/image.png" alt=""></p>
<pre><code class="language-javascript">C:\Users\hyjan&gt;docker ps
CONTAINER ID   IMAGE     COMMAND                   CREATED        STATUS          PORTS                                         NAMES
bd7980a61b9a   redis     &quot;docker-entrypoint.s…&quot;   17 hours ago   Up 11 minutes   0.0.0.0:6379-&gt;6379/tcp, [::]:6379-&gt;6379/tcp   simple-redis

C:\Users\hyjan&gt;docker exec -it simple-redis redis-cli
127.0.0.1:6379&gt; SMEMBERS news:like:1
(empty array)  &lt;= 좋아요 취소 했을 때
127.0.0.1:6379&gt; SMEMBERS news:like:1
1) &quot;aaa&quot; &lt;= 좋아요 했을 때
127.0.0.1:6379&gt; keys *
1) &quot;news:like:1&quot; &lt;= 저장된 모든 키 목록 확인</code></pre>
<ul>
<li>&#39;좋아요&#39;를 했을 때 로그인 사용자의 아이디를 반환</li>
<li>&#39;좋아요&#39;를 취소했을 때 로그인 사용자의 아이디가 비어있음(empty)을 출력</li>
<li>저장된 모든 키 목록 확인
news → 도메인/리소스 이름 (뉴스)
like → 동작/속성 (좋아요)
1 → 특정 엔티티의 ID (뉴스 ID = 1)</li>
</ul>
<h4 id="3-db-동기화-스케줄러-확인">3. DB 동기화 스케줄러 확인</h4>
<ul>
<li>5개의 아이디로 좋아요를 누른 후 redis 서버에만 집계
<img src="https://velog.velcdn.com/images/dev_hyjang/post/32749a9c-756a-49b5-b770-96fa218a2d77/image.png" alt=""></li>
</ul>
<ul>
<li>디비 UPDATE 스케줄러 동작
<img src="https://velog.velcdn.com/images/dev_hyjang/post/024bb18e-d830-4891-9dcf-abf2927dd71c/image.png" alt=""></li>
</ul>
<ul>
<li>디비 UPDATE 된 결과 확인
<img src="https://velog.velcdn.com/images/dev_hyjang/post/9c9c5f08-3746-4851-82ae-78c367a7d131/image.png" alt=""></li>
</ul>
<hr>
<h3 id="결론">결론</h3>
<p>Redis-스케줄러를 활용한 하이브리드 아키텍처 도입을 통해, 한 번에 많은 요청이 발생하는 &#39;좋아요&#39; 기능을 RDBMS의 부하 없이 안정적이고 확장성 있게 구현할 수 있었습니다. </p>
<p>이 접근 방식은 실시간 랭킹, 조회수 집계 등 유사한 요구사항을 가진 다른 기능에도 동일하게 적용될 수 있으며, 대규모 트래픽을 처리해야 하는 작업에서 유용하게 활용될 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Scheduler를 활용한 데이터 수집 자동화]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Scheduler%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%88%98%EC%A7%91-%EC%9E%90%EB%8F%99%ED%99%94</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Scheduler%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%88%98%EC%A7%91-%EC%9E%90%EB%8F%99%ED%99%94</guid>
            <pubDate>Tue, 26 Aug 2025 01:19:11 GMT</pubDate>
            <description><![CDATA[<p>Spring Boot 애플리케이션에서 주기적인 작업을 자동화하는 가장 간단하고 강력한 방법, 바로 <strong>Spring Scheduler와 @Scheduled 어노테이션</strong>을 활용하여 작업해 보았습니다. API를 호출할 때만 데이터를 수집하는 것이 아니라, 매주 또는 매일 정해진 시간에 자동으로 데이터를 수집하고 싶을 때가 있죠? 최근 제가 진행한 &#39;주기적인 뉴스 데이터 수집&#39; 프로젝트를 예시로 어떻게 스케줄러를 적용하는지 단계별로 보여드리겠습니다.</p>
<hr>
<h3 id="spring-scheduler의-핵심-개념">Spring Scheduler의 핵심 개념</h3>
<p>Spring Scheduler는 특정 시간에 또는 주기적으로 특정 메소드를 실행하도록 예약하는 기능입니다. Spring 프레임워크에 내장되어 있어 별도의 라이브러리 추가 없이 바로 사용할 수 있습니다.</p>
<h4 id="1-스케줄링-활성화-enablescheduling">1. 스케줄링 활성화: @EnableScheduling</h4>
<p>가장 먼저 해야 할 일은 Spring Boot 애플리케이션에 스케줄링 기능이 켜져 있음을 알려주는 것입니다. 보통 메인 애플리케이션 클래스에 @EnableScheduling 어노테이션을 추가하여 활성화합니다.</p>
<pre><code class="language-java">@SpringBootApplication
@EnableScheduling // 스케줄링 기능을 활성화합니다.
public class SimpleApplication {
    public static void main(String[] args) {
        SpringApplication.run(SimpleApplication.class, args);
    }
}</code></pre>
<p>이 어노테이션이 없으면 @Scheduled가 붙은 메소드는 스케줄링되지 않고 일반 메소드처럼 동작합니다.</p>
<h4 id="2-작업-예약-scheduled">2. 작업 예약: @Scheduled</h4>
<p>스케줄링할 메소드 위에 @Scheduled 어노테이션을 붙여줍니다. 이 어노테이션은 다양한 속성을 통해 실행 주기를 설정할 수 있습니다.</p>
<h4 id="주요-속성">주요 속성</h4>
<p><strong>1. cron</strong>: 가장 유연하고 강력한 방법입니다. Cron 표현식을 사용하여 매우 구체적인 실행 시간을 예약할 수 있습니다.</p>
<ul>
<li>형식: 초 분 시 일 월 요일</li>
<li>예시:<ul>
<li>@Scheduled(cron = &quot;0 0 0 * * MON&quot;) : 매주 월요일 자정에 실행</li>
<li>@Scheduled(cron = &quot;0/10 * * * * ?&quot;) : 매 10초마다 실행</li>
<li>@Scheduled(cron = &quot;0 0 12 * * ?&quot;) : 매일 정오(12시)에 실행</li>
</ul>
</li>
</ul>
<p><strong>2.fixedRate</strong>: 이전 작업의 시작 시간을 기준으로, 정해진 시간(밀리초)마다 작업을 실행합니다.</p>
<ul>
<li>만약 작업 실행 시간이 fixedRate보다 길어지면, 이전 작업이 끝나자마자 다음 작업이 바로 시작됩니다. =&gt; 병렬로 실행되지는 않음</li>
<li>예시: @Scheduled(fixedRate = 60000): 1분마다 실행 (이전 작업이 30초 걸렸다면, 30초 후에 다음 작업 시작)</li>
</ul>
<p><strong>3.fixedDelay</strong>: 이전 작업이 끝난 시간을 기준으로, 정해진 시간(밀리초) 후에 다음 작업을 실행합니다.</p>
<ul>
<li>항상 이전 작업의 완료와 다음 작업의 시작 사이에 일정한 지연 시간을 보장합니다.</li>
<li>예시: @Scheduled(fixedDelay = 60000): 이전 작업이 끝난 후 1분 뒤에 실행 (이전 작업이 30초 걸렸다면, 1분 30초 후에 다음 작업 시작)</li>
</ul>
<p><strong>4.initialDelay</strong>: 스케줄링된 작업이 처음 실행되기 전의 대기 시간(밀리초)을 설정합니다. fixedRate나 fixedDelay와 함께 사용됩니다.</p>
<ul>
<li>예시: @Scheduled(initialDelay = 10000, fixedRate = 60000): 애플리케이션 시작 후 10초 뒤에 첫 실행, 그 후 1분마다 실행</li>
</ul>
<h4 id="스케줄링-동작-방식-스레드">스케줄링 동작 방식 (스레드)</h4>
<ul>
<li>기본적으로 Spring Scheduler는 단일 스레드로 동작합니다.</li>
<li>즉, <strong>여러 개의 @Scheduled 메소드가 있더라도 동시에 실행되지 않고 순차적으로 실행됩니다.</strong> 만약 하나의 작업이 오래 걸리면 다른 작업들이 지연될 수 있습니다.</li>
<li>만약 병렬 실행이 필요하거나 더 복잡한 스케줄링이 필요하다면, 별도의 TaskScheduler 빈을 등록하여 스레드 풀의 크기를 조절하는 등 세부적인 설정을 할 수 있습니다.</li>
</ul>
<hr>
<h3 id="1-기존-코드-수동으로-실행해야-하는-데이터-수집">1. 기존 코드: 수동으로 실행해야 하는 데이터 수집</h3>
<p>처음 작성했던 코드는 다음과 같습니다. </p>
<p>NewsApiController.java (수정 전)</p>
<pre><code class="language-Java">@RestController
@RequestMapping(&quot;/api&quot;)
@RequiredArgsConstructor
public class NewsApiController {

    private final NewsService newsService;

    @GetMapping(&quot;/newsCollect&quot;)
    public void collectNews() throws JsonProcessingException {
        // ... 네이버 API를 통해 뉴스를 수집하는 로직 ...
        newsService.insertNews(responseBody);
    }

    @GetMapping(&quot;/news&quot;)
    public List&lt;News&gt; getAllNews() {
        return newsService.getAllNews();
    }

    // ... API 호출 관련 private 메서드들 ...
}</code></pre>
<p>/api/newsCollect 라는 API 엔드포인트를 호출해야만 네이버 뉴스 API에서 &#39;날씨&#39; 관련 뉴스를 가져오는 collectNews() 메서드가 실행되는 구조였습니다.</p>
<p>이 방식은</p>
<ul>
<li>누군가 직접 API를 호출해야만 데이터가 수집되고,</li>
<li>컨트롤러가 API 요청 처리와 스케줄링(수동)이라는 두 가지 책임을 갖게 됩니다.</li>
</ul>
<p>이번 목표는 스프링 스케줄러를 활용하여 collectNews() 로직을 매주 월요일 00시에 자동으로 실행하는 것입니다.</p>
<hr>
<h3 id="2-spring-scheduler-적용하기">2. Spring Scheduler 적용하기</h3>
<h4 id="1-스케줄링-기능-활성화-enablescheduling">1. 스케줄링 기능 활성화 (@EnableScheduling)</h4>
<pre><code class="language-java">import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling // 스케줄링 기능을 활성화합니다!
public class SimpleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SimpleApplication.class, args);
    }

}</code></pre>
<h4 id="2-역할-분리---스케줄러-클래스-생성">2. 역할 분리 - 스케줄러 클래스 생성</h4>
<p>스케줄링 관련 로직을 컨트롤러에서 분리하여 별도의 클래스로 만드는 것이 좋습니다. 이는 코드의 역할을 명확히 나누어 유지보수를 쉽게 만듭니다. scheduler 패키지를 만들고 NewsCollectScheduler 클래스를 생성한 뒤, 기존 NewsApiController에 있던 뉴스 수집 로직을 그대로 옮겨옵니다.</p>
<pre><code class="language-java">package com.ccp.simple.scheduler;

@Component
@RequiredArgsConstructor
public class NewsCollectScheduler {

    private final NewsService newsService;

    /**
     * 매주 월요일 00:00에 실행
     */
    @Scheduled(cron = &quot;0 0 0 * * MON&quot;)
    public void collectNews() throws JsonProcessingException {
        // ... NewsApiController에서 가져온 뉴스 수집 로직 ...
    }

    // ... API 호출 관련 private 메서드들 ...
}</code></pre>
<h4 id="3-작업-예약-scheduled">3. 작업 예약 (@Scheduled)</h4>
<p>새로 만든 collectNews() 메서드 위에 @Scheduled 어노테이션을 추가하여 실행 주기를 설정합니다.</p>
<pre><code class="language-java">@Scheduled(cron = &quot;0 0 0 * * MON&quot;)
public void collectNews() { ... }</code></pre>
<h4 id="4-컨트롤러-리팩토링">4. 컨트롤러 리팩토링</h4>
<p>이제 스케줄러가 뉴스 수집을 담당하므로, NewsApiController는 본연의 임무인 &#39;수집된 뉴스 데이터를 제공하는 API&#39; 역할만 수행하도록 코드를 정리합니다.</p>
<pre><code class="language-java">package com.ccp.simple.controller;

@RestController
@RequestMapping(&quot;/api&quot;)
@RequiredArgsConstructor
public class NewsApiController {

    private final NewsService newsService;

    //뉴스 수집
    @GetMapping(&quot;/news&quot;)
    public List&lt;News&gt; getAllNews() {
        return newsService.getAllNews();
    }
}</code></pre>
<hr>
<h3 id="마무리">마무리</h3>
<p>Spring Scheduler를 사용하면 @EnableScheduling과 @Scheduled 단 두 개의 어노테이션만으로 복잡한 주기적 작업을 손쉽게 구현할 수 있습니다.</p>
<ul>
<li>별도 라이브러리 없이 바로 사용 가능한 간단한 기능입니다.</li>
<li>스케줄링 로직을 분리하여 코드 구조를 개선할 수 있습니다.</li>
<li>cron, fixedRate, fixedDelay 등 다양한 옵션으로 원하는 거의 모든 스케줄링 시나리오를 구현할 수 있습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue.js 로그인 상태 관리, Pinia와 localStorage를 혼용하면 생기는 문제]]></title>
            <link>https://velog.io/@dev_hyjang/Vue.js-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-Pinia%EC%99%80-localStorage%EB%A5%BC-%ED%98%BC%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%83%9D%EA%B8%B0%EB%8A%94-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@dev_hyjang/Vue.js-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-Pinia%EC%99%80-localStorage%EB%A5%BC-%ED%98%BC%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%83%9D%EA%B8%B0%EB%8A%94-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Thu, 21 Aug 2025 05:45:23 GMT</pubDate>
            <description><![CDATA[<p>이번 포스팅에서는 Vue.js와 Pinia를 사용해 로그인 상태를 관리하며 겪었던 시행착오와 해결 방법을 공유하려고 합니다. Pinia는 강력한 상태 관리 라이브러리지만, localStorage와 제대로 통합하지 않으면 예상치 못한 문제가 발생할 수 있습니다.</p>
<hr>
<h3 id="1-문제-발생-새로고침하면-로그아웃되는-현상">1. 문제 발생: 새로고침하면 로그아웃되는 현상</h3>
<p>로그인 후 router.push(&#39;/newsBoard&#39;)로 이동하면 뉴스 목록이 정상적으로 보입니다. 하지만 이 상태에서 F5(새로고침)를 누르면 &quot;로그인이 필요합니다&quot;라는 메시지와 함께 로그인 페이지로 돌아가는 현상이 발생했습니다.</p>
<h4 id="원인-분석">원인 분석:</h4>
<p>문제의 핵심은 Pinia의 상태는 페이지를 새로고침하면 초기화된다는 점입니다.</p>
<ul>
<li>로그인 코드: Access Token을 Pinia Store에 저장합니다.</li>
</ul>
<pre><code class="language-JavaScript">
userStore.setToken(accessToken); // Pinia에 저장
localStorage.setItem(&quot;refreshToken&quot;, refreshToken); // localStorage에 저장</code></pre>
<ul>
<li>목록 코드: Pinia Store에서 Access Token을 가져와 사용합니다.</li>
</ul>
<pre><code class="language-JavaScript">const accessToken = userStore.token; // 새로고침하면 이 값이 null이 됨</code></pre>
<p>Pinia는 Vue 애플리케이션의 메모리에 데이터를 저장하기 때문에, 새로고침 시 이 데이터가 모두 사라집니다. 반면, localStorage는 브라우저에 데이터를 영구적으로 저장하므로 새로고침해도 데이터가 유지됩니다. 이렇게 두 가지 저장소를 혼용하면서 데이터 불일치가 발생했습니다.</p>
<hr>
<h3 id="2-해결책-pinia와-localstorage-동기화하기">2. 해결책: Pinia와 localStorage 동기화하기</h3>
<p>새로고침 시에도 로그인 상태를 유지하려면, Pinia Store의 상태를 localStorage에 자동으로 저장하고 불러오는 작업이 필요합니다. 
=&gt; 이를 위해 <strong>pinia-plugin-persistedstate</strong>라는 유용한 라이브러리를 사용했습니다.</p>
<h4 id="1단계-라이브러리-설치-및-pinia-설정">1단계: 라이브러리 설치 및 Pinia 설정</h4>
<p>먼저, 라이브러리를 설치하고 Pinia 인스턴스에 플러그인을 등록합니다.</p>
<pre><code class="language-Bash">npm install pinia-plugin-persistedstate</code></pre>
<p>main.js 파일을 열어 아래와 같이 수정합니다.</p>
<pre><code class="language-JavaScript">// main.js
import { createApp } from &#39;vue&#39;;
import App from &#39;./App.vue&#39;;
import router from &#39;./router&#39;;
import { createPinia } from &quot;pinia&quot;;
import piniaPluginPersistedstate from &#39;pinia-plugin-persistedstate&#39;; //추가

const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); // Pinia에 플러그인 등록

app.use(router);
app.use(pinia);
app.mount(&#39;#app&#39;);</code></pre>
<h4 id="2단계-pinia-store에-persist-옵션-추가">2단계: Pinia Store에 persist 옵션 추가</h4>
<p>이제 Pinia Store(stores/user.js)에서 persist: true 옵션을 추가하여 어떤 상태를 영구적으로 저장할지 지정합니다. 이때 persist 옵션의 위치가 매우 중요합니다. state, getters, actions와 같은 최상위 레벨에 위치해야 합니다.</p>
<pre><code class="language-JavaScript">
// stores/user.js
import { defineStore } from &quot;pinia&quot;;
import { jwtDecode } from &quot;jwt-decode&quot;; 

export const useUserStore = defineStore(&quot;user&quot;, {
  state: () =&gt; ({
    token: null,
    role: null,
    userId: null,
  }),
  getters: {
    isAdmin: (state) =&gt; state.role === &quot;ROLE_ADMIN&quot;,
    isLoggedIn: (state) =&gt; !!state.userId,
  },
  actions: {
    setToken(token) {
      this.token = token;
      const decoded = jwtDecode(token);
      this.role = decoded.role;
      this.userId = decoded.sub;
    },
    logout() {
      this.$reset();
      localStorage.removeItem(&quot;refreshToken&quot;);
    },
  },
  // 해당 내용 추가
  persist: {
    storage : localStorage,
    paths: [&quot;token&quot;, &quot;role&quot;, &quot;userId&quot;],
  },
});</code></pre>
<h4 id="3단계-컴포넌트-코드-수정">3단계: 컴포넌트 코드 수정</h4>
<p>이제 목록 컴포넌트에서 userStore.token을 가져와도 새로고침 시 null이 되지 않습니다. 
<img src="https://velog.velcdn.com/images/dev_hyjang/post/e6deb295-8e1a-47e2-8c63-d3d1a56a4d71/image.png" alt=""></p>
<p>logout 함수 역시 Pinia Store의 logout 액션을 호출하도록 수정하여 코드의 일관성을 높일 수 있습니다.</p>
<pre><code class="language-JavaScript">
// NewsBoard.vue
&lt;script setup&gt;
import { onMounted } from &#39;vue&#39;;
import { useRouter } from &#39;vue-router&#39;;
import { useUserStore } from &quot;@/stores/user&quot;; //Pinia Store 임포트
// ... (나머지 임포트)

const router = useRouter();
const userStore = useUserStore();
// ... (나머지 코드)

const fetchNews = async () =&gt; {
  const accessToken = userStore.token; //Pinia에서 직접 가져옴
  if (!accessToken) {
    alert(&quot;로그인이 필요합니다.&quot;);
    router.push(&#39;/&#39;);
    return;
  }
  // ... (API 호출 코드)
};

onMounted(() =&gt; {
  fetchNews();
});

const logout = async () =&gt; {
  // ... (기존 API 호출 코드)
  if (!response.ok) {
    throw new Error(data.message || &quot;로그아웃 실패&quot;);
  }
  userStore.logout(); //Pinia의 logout 액션 호출
  // ... (나머지 코드)
};
&lt;/script&gt;</code></pre>
<hr>
<h3 id="결론">결론</h3>
<p>Pinia는 Vue.js의 강력한 상태 관리 도구이지만, 
웹페이지 새로고침과 같은 비동기적인 환경에서는 데이터가 휘발되는 문제가 발생합니다. 
pinia-plugin-persistedstate를 사용하면 Pinia 상태를 localStorage에 쉽게 동기화하여 이 문제를 해결할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue 3에서 Pinia와 JWT를 활용한 전역 권한 관리]]></title>
            <link>https://velog.io/@dev_hyjang/Vue-3%EC%97%90%EC%84%9C-Pinia%EC%99%80-JWT%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%A0%84%EC%97%AD-%EA%B6%8C%ED%95%9C-%EA%B4%80%EB%A6%AC</link>
            <guid>https://velog.io/@dev_hyjang/Vue-3%EC%97%90%EC%84%9C-Pinia%EC%99%80-JWT%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%A0%84%EC%97%AD-%EA%B6%8C%ED%95%9C-%EA%B4%80%EB%A6%AC</guid>
            <pubDate>Tue, 19 Aug 2025 08:38:08 GMT</pubDate>
            <description><![CDATA[<p>Vue 프로젝트에서 로그인 상태나 사용자 권한(role) 같은 데이터를 관리할 때, 모든 컴포넌트에서 매번 localStorage를 확인하고 토큰을 decode하는 방식은 번거롭고 오류가 생기기 쉽습니다.</p>
<p>이번 글에서는 Pinia를 사용해 전역 상태로 관리하고, JWT 토큰에서 role 추출 → 메뉴 권한 제어까지 구현하는 방법을 정리해보겠습니다.</p>
<hr>
<h3 id="1-문제-상황">1. 문제 상황</h3>
<p>예를 들어, 관리자만 접근할 수 있는 메뉴가 있다고 가정해봅시다.</p>
<pre><code class="language-HTML">&lt;li class=&quot;nav-item mb-2&quot; v-if=&quot;isAdmin&quot;&gt;
  &lt;a class=&quot;nav-link&quot; href=&quot;/manageBoard&quot;&gt;관리 페이지&lt;/a&gt;
&lt;/li&gt;</code></pre>
<p>매 페이지마다 localStorage에서 토큰을 읽고 decode해서 role을 확인하려면 코드가 반복되고 관리가 어렵습니다.</p>
<p>토큰이 refresh로 갱신될 때 화면 반영도 자동으로 되지 않습니다.</p>
<hr>
<h3 id="2-pinia란">2. Pinia란?</h3>
<p>Pinia는 Vue 3 공식 상태 관리 라이브러리입니다.</p>
<p>Vue 컴포넌트는 보통 자기 자신 내부의 상태만 관리하는데,
로그인 정보(토큰, 사용자 역할, 이름 등)처럼 앱 전역에서 필요한 데이터는 각 컴포넌트마다 props로 주고받기엔 불편하죠.</p>
<p>쉽게 말해, localStorage는 단순 저장소지만, Pinia store는 Vue가 반응형으로 바라보는 <strong>전역 상태 저장소(store)</strong> 라고 생각하면 됩니다.</p>
<h4 id="왜-pinia를-쓰냐">왜 Pinia를 쓰냐?</h4>
<ol>
<li><p>중앙 집중 관리
로그인 유저 정보(role, userId, isAdmin) 같은 걸 한 곳에서 관리 → 여러 컴포넌트에서 동일한 데이터 보장</p>
</li>
<li><p>반응성 유지
store 값이 바뀌면(store.state.role 같은 거) → 그 값을 쓰는 모든 컴포넌트가 자동 업데이트됨</p>
</li>
<li><p>토큰 갱신 자동 반영
지금 만든 apiRequest에서 새 토큰을 발급받으면 → store 안의 role도 자동 업데이트
그럼 메뉴 같은 UI가 즉시 바뀌어요 (새로고침 필요 없음)</p>
</li>
<li><p>Vue 3와 궁합 최고
Vuex의 차세대 버전인데 훨씬 가볍고, TypeScript 지원도 잘됨</p>
</li>
</ol>
<h4 id="예시-상황">예시 상황</h4>
<p>지금 관리자 페이지 메뉴를 관리자만 보이게 하고 싶은데?</p>
<ul>
<li><p>Pinia store 없으면 
→ 매 페이지마다 localStorage.getItem(&quot;accessToken&quot;) → jwtDecode() → role 뽑기 해야 해요(localStorage = 그냥 로컬에 저장된 값 (변경돼도 Vue는 모름))</p>
</li>
<li><p>Pinia store 쓰면 
→ 로그인 시 한 번만 role을 저장해두고, userStore.role이나 userStore.isAdmin만 체크하면 끝!(Pinia store = Vue 반응형 데이터 창고 (바뀌면 화면도 자동 반영))</p>
</li>
</ul>
<h4 id="pinia-없이localstorage-직접-구현">Pinia 없이(localStorage 직접) 구현</h4>
<p>-&gt; 모든 컴포넌트에서 role을 확인하려면 매번 decode 해야 함</p>
<pre><code class="language-JAVASCRIPT">&lt;script setup&gt;
import jwtDecode from &quot;jwt-decode&quot;;

const token = localStorage.getItem(&quot;accessToken&quot;);
let role = null;

if (token) {
  try {
    const decoded = jwtDecode(token);
    role = decoded.role;
  } catch (e) {
    console.error(&quot;토큰 decode 실패&quot;, e);
  }
}
&lt;/script&gt;

&lt;template&gt;
  &lt;div&gt;
    &lt;a v-if=&quot;role === &#39;ADMIN&#39;&quot; href=&quot;/manageBoard&quot;&gt;관리 페이지&lt;/a&gt;
  &lt;/div&gt;
&lt;/template&gt;</code></pre>
<p>문제점:</p>
<ul>
<li>모든 컴포넌트에서 중복 코드 (localStorage → jwtDecode → role)</li>
<li>토큰이 refresh로 바뀌면 화면 자동 반영 X (새로고침 해야 반영됨)</li>
<li>상태 동기화 어려움 (A 컴포넌트에서 로그인 했는데, B 컴포넌트는 아직 로그아웃 상태처럼 보이는 문제)</li>
</ul>
<hr>
<h3 id="3-pnia-store-구성">3. pnia Store 구성</h3>
<p>stores/user.js로 유저 정보를 관리합니다.</p>
<pre><code class="language-JAVASCRIPT">import { defineStore } from &quot;pinia&quot;;
import { jwtDecode } from &quot;jwt-decode&quot;;

export const useUserStore = defineStore(&quot;user&quot;, {
  state: () =&gt; ({
    role: null,
    userId: null,
  }),
  getters: {
    isAdmin: (state) =&gt; state.role === &quot;ADMIN&quot;,
    isLoggedIn: (state) =&gt; !!state.userId,
  },
  actions: {
    setToken(token) {
      localStorage.setItem(&quot;accessToken&quot;, token);
      const decoded = jwtDecode(token);
      this.role = decoded.role;
      this.userId = decoded.sub;
    },
    logout() {
      localStorage.clear();
      this.role = null;
      this.userId = null;
    }
  }
});</code></pre>
<ul>
<li>setToken()으로 JWT 토큰을 decode하여 role과 userId를 store에 저장</li>
<li>logout()으로 모든 상태와 localStorage 초기화</li>
</ul>
<hr>
<h3 id="4-pinia-앱-등록">4. Pinia 앱 등록</h3>
<p>main.js에서 Pinia를 Vue 앱에 등록합니다.</p>
<pre><code class="language-JAVASCRIPT">import { createApp } from &#39;vue&#39;;
import App from &#39;./App.vue&#39;;
import router from &#39;./router&#39;;
import { createPinia } from &quot;pinia&quot;;

const app = createApp(App);
const pinia = createPinia();

app.use(router);
app.use(pinia);  // 반드시 필요
app.mount(&#39;#app&#39;);</code></pre>
<hr>
<h3 id="5-api-요청과-토큰-갱신-연동">5. API 요청과 토큰 갱신 연동</h3>
<p>apiRequest.js에서 AccessToken 만료 시 RefreshToken으로 갱신하고, 갱신된 토큰으로 store를 업데이트합니다.</p>
<pre><code class="language-JAVASCRIPT">import { useUserStore } from &quot;@/stores/user&quot;;

export async function apiRequest(url, options = {}) {
  const userStore = useUserStore();
  let accessToken = localStorage.getItem(&quot;accessToken&quot;);
  const refreshToken = localStorage.getItem(&quot;refreshToken&quot;);

  options.headers = {
    ...options.headers,
    &quot;Content-Type&quot;: &quot;application/json&quot;,
    &quot;Authorization&quot;: accessToken ? `Bearer ${accessToken}` : undefined
  };

  let response = await fetch(url, options);

  if (response.status === 401 || response.status === 403) {
    if (!refreshToken) {
      alert(&quot;로그인이 필요합니다.&quot;);
      userStore.logout();
      window.location.href = &quot;/&quot;;
      return;
    }

    const refreshResponse = await fetch(&quot;/refresh&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ refreshToken })
    });

    if (!refreshResponse.ok) {
      alert(&quot;세션 만료. 다시 로그인 해주세요.&quot;);
      userStore.logout();
      window.location.href = &quot;/&quot;;
      return;
    }

    const data = await refreshResponse.json();
    localStorage.setItem(&quot;accessToken&quot;, data.accessToken);
    accessToken = data.accessToken;

    // Pinia store 업데이트
    userStore.setToken(accessToken);

    options.headers[&quot;Authorization&quot;] = `Bearer ${accessToken}`;
    response = await fetch(url, options);
  }

  return response;
}</code></pre>
<ul>
<li>토큰 갱신 시 store가 자동 업데이트 되므로, 화면은 새로고침 없이도 변경 사항이 반영됩니다.</li>
</ul>
<hr>
<h3 id="6-컴포넌트에서-role-확인">6. 컴포넌트에서 role 확인</h3>
<pre><code class="language-JAVASCRIPT">&lt;script setup&gt;
import { useUserStore } from &quot;@/stores/user&quot;;
const userStore = useUserStore();
&lt;/script&gt;

&lt;template&gt;
  &lt;li class=&quot;nav-item mb-2&quot; v-if=&quot;userStore.isAdmin&quot;&gt;
    &lt;a class=&quot;nav-link&quot; href=&quot;/manageBoard&quot;&gt;관리 페이지&lt;/a&gt;
  &lt;/li&gt;
&lt;/template&gt;</code></pre>
<p>이렇게 하면</p>
<ul>
<li>로그인 후 토큰 저장 → userStore에 role 자동 반영</li>
<li>토큰이 만료돼서 갱신되더라도 → apiRequest가 새 토큰 decode 해서 userStore 갱신</li>
<li>로그아웃(세션 만료) → userStore.clear()</li>
</ul>
<p><strong>즉, 모든 페이지에서 userStore.isAdmin만 보면 끝입니다.</strong></p>
<hr>
<h3 id="7-결론">7. 결론</h3>
<ul>
<li>localStorage만 사용: 코드 중복, 토큰 갱신 시 UI 반영 안됨</li>
<li><strong>Pinia + store: 전역 상태 관리, 반응형 UI, 코드 재사용성 높음</strong></li>
<li>프로젝트가 클수록 Pinia를 사용하는 것이 훨씬 편하고 유지보수가 쉽습니다.</li>
<li>로그인/권한 관리 같은 전역 상태는 store로 관리하는 것이 효율적입니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[네이버 검색 API 활용한 뉴스 크롤링(10분 컷)]]></title>
            <link>https://velog.io/@dev_hyjang/%EB%84%A4%EC%9D%B4%EB%B2%84-%EA%B2%80%EC%83%89-API-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%89%B4%EC%8A%A4-%ED%81%AC%EB%A1%A4%EB%A7%8110%EB%B6%84-%EC%BB%B7</link>
            <guid>https://velog.io/@dev_hyjang/%EB%84%A4%EC%9D%B4%EB%B2%84-%EA%B2%80%EC%83%89-API-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%89%B4%EC%8A%A4-%ED%81%AC%EB%A1%A4%EB%A7%8110%EB%B6%84-%EC%BB%B7</guid>
            <pubDate>Wed, 13 Aug 2025 13:51:15 GMT</pubDate>
            <description><![CDATA[<p>이 글에서는 네이버에서 제공하는 검색(뉴스) API 를 활용하여 뉴스 데이터를 수집해보겠습니다. 해당 작업은 NAVER Developers 에서 제공하는 정보를 바탕으로 기능 구현이 되었습니다.
네이버 검색 결과의 목록을 조회하기 까지 약 10분의 시간밖에 소요되지 않으니 지금 바로 도전해보세요!</p>
<h2 id="사전-준비">사전 준비</h2>
<p>검색 API를 사용해 뉴스 검색을 실행하려면 먼저 네이버 개발자 센터에서 애플리케이션을 등록하고 클라이언트 아이디와 클라이언트 시크릿을 발급받아야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/0254ef03-7f9e-479f-aae5-ddb27e970e76/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/46ad0850-6c6b-445f-88b2-a71e1f76074c/image.png" alt="">
오픈 API 이용 신청 버튼 클릭 &gt; 로그인 &gt; 이용 약관 동의 &gt; 애플리케이션 등록 하는 과정을 순조롭게 진행합니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/95a8b91a-6630-4a0a-890a-c45b62699569/image.png" alt="">
위와 같이 클라이언트 아이디와 클라이언트 시크릿이 발급된 것을 확인할 수 있습니다. API 요청에 필요한 필수 정보입니다.</p>
<p>여기까지 네이버 검색을 위한 사전 준비가 마무리 되었습니다!</p>
<hr>
<h2 id="네이버-검색-api-정보">네이버 검색 API 정보</h2>
<p>네이버에서 제공하는 뉴스 검색 API 에 대한 모든 정보는 아래의 페이지에서 제공합니다. 
<a href="https://developers.naver.com/docs/serviceapi/search/news/news.md#%EB%89%B4%EC%8A%A4">https://developers.naver.com/docs/serviceapi/search/news/news.md#%EB%89%B4%EC%8A%A4</a></p>
<ul>
<li>해당 페이지에서는 API 래퍼런스 즉, 검색 결과를 조회하는 HTTP 메서드, 파라미터 등의 정보를 제공합니다.</li>
<li>또한 요청과 응답 예시 코드 뿐만 아니라 오류 메시지에 대한 정보도 제공합니다.</li>
<li>핵심적인 정보를 아래에 정리해 놓았습니다.</li>
</ul>
<h4 id="http-메서드--get">HTTP 메서드 =&gt; GET</h4>
<h4 id="요청-파라미터">요청 파라미터</h4>
<table>
<thead>
<tr>
<th><strong>파라미터</strong></th>
<th><strong>타입</strong></th>
<th><strong>필수 여부</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td>query</td>
<td>String</td>
<td>Y</td>
<td>검색어. UTF-8로 인코딩되어야 합니다.</td>
</tr>
<tr>
<td>display</td>
<td>Integer</td>
<td>N</td>
<td>한 번에 표시할 검색 결과 개수(기본값: 10, 최댓값: 100)</td>
</tr>
<tr>
<td>start</td>
<td>Integer</td>
<td>N</td>
<td>검색 시작 위치(기본값: 1, 최댓값: 1000)</td>
</tr>
<tr>
<td>sort</td>
<td>String</td>
<td>N</td>
<td>검색 결과 정렬 방법- <code>sim</code>: 정확도순으로 내림차순 정렬(기본값)- <code>date</code>: 날짜순으로 내림차순 정렬</td>
</tr>
</tbody></table>
<h4 id="응답-요소-및-정보">응답 요소 및 정보</h4>
<table>
<thead>
<tr>
<th><strong>요소</strong></th>
<th><strong>타입</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td>rss</td>
<td>-</td>
<td>RSS 컨테이너. RSS 리더기를 사용해 검색 결과를 확인할 수 있습니다.</td>
</tr>
<tr>
<td>rss/channel</td>
<td>-</td>
<td>검색 결과를 포함하는 컨테이너. <code>channel</code> 요소의 하위 요소인 <code>title</code>, <code>link</code>, <code>description</code>은 RSS에서 사용하는 정보이며, 검색 결과와는 상관이 없습니다.</td>
</tr>
<tr>
<td>rss/channel/lastBuildDate</td>
<td>dateTime</td>
<td>검색 결과를 생성한 시간</td>
</tr>
<tr>
<td>rss/channel/total</td>
<td>Integer</td>
<td>총 검색 결과 개수</td>
</tr>
<tr>
<td>rss/channel/start</td>
<td>Integer</td>
<td>검색 시작 위치</td>
</tr>
<tr>
<td>rss/channel/display</td>
<td>Integer</td>
<td>한 번에 표시할 검색 결과 개수</td>
</tr>
<tr>
<td>rss/channel/item</td>
<td>-</td>
<td>개별 검색 결과. JSON 형식의 결괏값에서는 <code>items</code> 속성의 JSON 배열로 개별 검색 결과를 반환합니다.</td>
</tr>
<tr>
<td>rss/channel/item/title</td>
<td>String</td>
<td>뉴스 기사의 제목. 제목에서 검색어와 일치하는 부분은 <code>&lt;b&gt;</code> 태그로 감싸져 있습니다.</td>
</tr>
<tr>
<td>rss/channel/item/originallink</td>
<td>String</td>
<td>뉴스 기사 원문의 URL</td>
</tr>
<tr>
<td>rss/channel/item/link</td>
<td>String</td>
<td>뉴스 기사의 네이버 뉴스 URL. 네이버에 제공되지 않은 기사라면 기사 원문의 URL을 반환합니다.</td>
</tr>
<tr>
<td>rss/channel/item/description</td>
<td>String</td>
<td>뉴스 기사의 내용을 요약한 패시지 정보. 패시지 정보에서 검색어와 일치하는 부분은 <code>&lt;b&gt;</code> 태그로 감싸져 있습니다.</td>
</tr>
<tr>
<td>rss/channel/item/pubDate</td>
<td>dateTime</td>
<td>뉴스 기사가 네이버에 제공된 시간. 네이버에 제공되지 않은 기사라면 기사 원문이 제공된 시간을 반환합니다.</td>
</tr>
</tbody></table>
<h4 id="오류-코드">오류 코드</h4>
<table>
<thead>
<tr>
<th><strong>오류 코드</strong></th>
<th><strong>HTTP 상태 코드</strong></th>
<th><strong>오류 메시지</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td>SE01</td>
<td>400</td>
<td>Incorrect query request (잘못된 쿼리요청입니다.)</td>
<td>API 요청 URL의 프로토콜, 파라미터 등에 오류가 있는지 확인합니다.</td>
</tr>
<tr>
<td>SE02</td>
<td>400</td>
<td>Invalid display value (부적절한 display 값입니다.)</td>
<td><code>display</code> 파라미터의 값이 허용 범위의 값(1~100)인지 확인합니다.</td>
</tr>
<tr>
<td>SE03</td>
<td>400</td>
<td>Invalid start value (부적절한 start 값입니다.)</td>
<td><code>start</code> 파라미터의 값이 허용 범위의 값(1~1000)인지 확인합니다.</td>
</tr>
<tr>
<td>SE04</td>
<td>400</td>
<td>Invalid sort value (부적절한 sort 값입니다.)</td>
<td><code>sort</code> 파라미터의 값에 오타가 있는지 확인합니다.</td>
</tr>
<tr>
<td>SE06</td>
<td>400</td>
<td>Malformed encoding (잘못된 형식의 인코딩입니다.)</td>
<td>검색어를 UTF-8로 인코딩합니다.</td>
</tr>
<tr>
<td>SE05</td>
<td>404</td>
<td>Invalid search api (존재하지 않는 검색 api 입니다.)</td>
<td>API 요청 URL에 오타가 있는지 확인합니다.</td>
</tr>
<tr>
<td>SE99</td>
<td>500</td>
<td>System Error (시스템 에러)</td>
<td>서버 내부에 오류가 발생했습니다. &quot;<a href="https://developers.naver.com/forum"><strong>개발자 포럼</strong></a>&quot;에 오류를 신고해 주십시오.</td>
</tr>
</tbody></table>
<h4 id="구현-예제">구현 예제</h4>
<p><a href="https://developers.naver.com/docs/serviceapi/search/blog/blog.md#java">https://developers.naver.com/docs/serviceapi/search/blog/blog.md#java</a>
네이버 디벨로퍼스에서는 친절하게 다양한 프로그래밍 언어로 구현 예제를 제공합니다. 해당 내용만 보고도 기능을 구현할 수 있을 정도로 정확한 정보를 제공합니다.</p>
<hr>
<h3 id="뉴스-검색-기능-구현">뉴스 검색 기능 구현</h3>
<p>API 래퍼런스를 참고하여 네이버 뉴스를 검색한 결과를 수집하는 기능을 구현해보도록 하겠습니다.</p>
<pre><code class="language-JAVA">package com.ccp.simple.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping(&quot;/api&quot;)
@RequiredArgsConstructor
public class NewsApiController {

    @GetMapping(&quot;/news&quot;)
    public String collectNews() {
        String clientId = &quot;&quot;; //애플리케이션 클라이언트 아이디
        String clientSecret = &quot;&quot;; //애플리케이션 클라이언트 시크릿

        String searchKeyword = &quot;네이버&quot;;   // 검색어
        if (searchKeyword == null || searchKeyword.isEmpty()) {
            throw new RuntimeException(&quot;검색어가 비어 있습니다.&quot;);
        }
        String encodeQuery;
        try {
            encodeQuery = URLEncoder.encode(searchKeyword, &quot;UTF-8&quot;);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(&quot;검색어 인코딩 실패&quot;,e);
        }

        String apiURL = &quot;https://openapi.naver.com/v1/search/news?query=&quot; + encodeQuery;

        Map &lt;String, String&gt; requestHeaders = new HashMap&lt;&gt;();
        requestHeaders.put(&quot;X-Naver-Client-Id&quot;, clientId);
        requestHeaders.put(&quot;X-Naver-Client-Secret&quot;, clientSecret);

        String responseBody = get(apiURL,requestHeaders);
        //responseBody 의 내용을 db에 insert 하기
        return &quot;&quot;;
    }

    private static String get(String apiUrl, Map&lt;String, String&gt; requestHeaders){
        HttpURLConnection con = connect(apiUrl);
        try {
            con.setRequestMethod(&quot;GET&quot;);
            for(Map.Entry&lt;String, String&gt; header :requestHeaders.entrySet()) {
                con.setRequestProperty(header.getKey(), header.getValue());
            }
            int responseCode = con.getResponseCode();
            if (responseCode == HttpURLConnection.HTTP_OK) { // 정상 호출
                return readBody(con.getInputStream());
            } else { // 오류 발생
                return readBody(con.getErrorStream());
            }
        } catch (IOException e) {
            throw new RuntimeException(&quot;API 요청과 응답 실패&quot;, e);
        } finally {
            con.disconnect();
        }
    }

    private static HttpURLConnection connect(String apiUrl){
        try {
            URL url = new URL(apiUrl);
            return (HttpURLConnection)url.openConnection();
        } catch (MalformedURLException e) {
            throw new RuntimeException(&quot;API URL이 잘못되었습니다. : &quot; + apiUrl, e);
        } catch (IOException e) {
            throw new RuntimeException(&quot;연결이 실패했습니다. : &quot; + apiUrl, e);
        }
    }

    private static String readBody(InputStream body){
        InputStreamReader streamReader = new InputStreamReader(body);
        try (BufferedReader lineReader = new BufferedReader(streamReader)) {
            StringBuilder responseBody = new StringBuilder();
            String line;
            while ((line = lineReader.readLine()) != null) {
                responseBody.append(line);
            }
            return responseBody.toString();
        } catch (IOException e) {
            throw new RuntimeException(&quot;API 응답을 읽는 데 실패했습니다.&quot;, e);
        }
    }
}
</code></pre>
<p>코드에서 변경한 내용은 고작 검색 키워드의 변수 정도입니다. 클라이언트 아이디와 시크릿키의 값만 잘 넣으면 성공적으로 결과를 조회할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/8fc8d85e-5832-4536-b0ce-ca2b7ec0355d/image.png" alt=""></p>
<p>이와 같이 단 10분만에 네이버에서 뉴스를 검색한 결과를 조회하는 기능을 구현할 수 있었습니다.
네이버 덕분에 쉽고 빠르게 고품질의 데이터를 제공받을 수 있어 기쁩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot HTML 파일을 Vue로 마이그레이션하기]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot-HTML-%ED%8C%8C%EC%9D%BC%EC%9D%84-Vue%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot-HTML-%ED%8C%8C%EC%9D%BC%EC%9D%84-Vue%EB%A1%9C-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 13 Aug 2025 00:52:15 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 기존 Spring Boot 프로젝트에서 사용하던 HTML 템플릿을 Vue 프론트엔드로 옮기고, 백엔드와 연동하는 과정을 정리합니다.</p>
<hr>
<h3 id="1-개발-환경">1. 개발 환경</h3>
<table>
<thead>
<tr>
<th>도구</th>
<th>버전</th>
</tr>
</thead>
<tbody><tr>
<td>Java</td>
<td>17</td>
</tr>
<tr>
<td>Spring Boot</td>
<td>3.4.7</td>
</tr>
<tr>
<td>Node.js</td>
<td>20.19.4</td>
</tr>
<tr>
<td>npm</td>
<td>10.8.2</td>
</tr>
<tr>
<td>Vue</td>
<td>3.5.18</td>
</tr>
<tr>
<td>Vite</td>
<td>7.1.1</td>
</tr>
</tbody></table>
<h3 id="2-nodejs-설치">2. Node.js 설치</h3>
<p>Vue 프로젝트를 생성하려면 Node.js가 필요합니다.
Node.js 공식 홈페이지에서 LTS 버전을 설치합니다.</p>
<ul>
<li>설치 후 버전 확인 명령어</li>
</ul>
<pre><code>node -v
npm -v</code></pre><h3 id="3-vue-프로젝트-생성">3. Vue 프로젝트 생성</h3>
<p>Spring Boot 프로젝트 루트와는 별도로 frontend 폴더를 만들어 Vue 프로젝트를 생성합니다.</p>
<pre><code>npm create vite@latest simple-app</code></pre><ul>
<li>프로젝트 이름: simple-app</li>
<li>프레임워크: Vue</li>
<li>Variant: JavaScript</li>
</ul>
<pre><code class="language-javaScript">D:\&gt;npm create vue@latest simple-app

&gt; npx
&gt; create-vue simple-app

T  Vue.js - The Progressive JavaScript Framework
|
o  Select features to include in your project: (↑/↓ to navigate, space to select, a to toggle all, enter to confirm)
|  Router (SPA development), Pinia (state management)
|
o  Select experimental features to include in your project: (↑/↓ to navigate, space to select, a to toggle all, enter to
confirm)
|  none
|
o  Skip all example code and start with a blank Vue project?
|  Yes

Scaffolding project in D:\simple-app...
|
—  Done. Now run:

   cd simple-app
   npm install
   npm run dev

| Optional: Initialize Git in your project directory with:

   git init &amp;&amp; git add -A &amp;&amp; git commit -m &quot;initial commit&quot;


D:\&gt;cd simple-app

D:\simple-app&gt;npm install

added 152 packages, and audited 153 packages in 10s

47 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

D:\simple-app&gt;npm run dev

&gt; simple-app@0.0.0 dev
&gt; vite


  VITE v7.1.1  ready in 722 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  Vue DevTools: Open http://localhost:5173/__devtools__/ as a separate window
  ➜  Vue DevTools: Press Alt(⌥)+Shift(⇧)+D in App to toggle the Vue DevTools
  ➜  press h + enter to show help</code></pre>
<h3 id="4-프로젝트-실행">4. 프로젝트 실행</h3>
<pre><code>cd simple-app
npm install
npm run dev</code></pre><p>기본적으로 <a href="http://localhost:5173%EC%97%90%EC%84%9C">http://localhost:5173에서</a> Vue 개발 서버가 실행됩니다.</p>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/98568592-b61f-4753-a10c-257dd64c68bf/image.png">



<h3 id="5-bootstrap-설치">5. Bootstrap 설치</h3>
<p>기존 HTML에서 Bootstrap을 사용했다면, Vue 프로젝트에도 설치합니다.</p>
<pre><code>npm install bootstrap</code></pre><p>main.js에 CSS를 추가:</p>
<pre><code class="language-javascript">import { createApp } from &#39;vue&#39;
import App from &#39;./App.vue&#39;
import &#39;bootstrap/dist/css/bootstrap.min.css&#39;

createApp(App).mount(&#39;#app&#39;)</code></pre>
<h3 id="6-프로젝트-구조">6. 프로젝트 구조</h3>
<pre><code class="language-xml">simple-app/
├── public/            # 정적 파일들 (index.html, 이미지 등)
├── src/
│   ├── assets/        # 이미지, 스타일 등 자원
│   ├── components/    # Vue 컴포넌트들
│   ├── router/        # Vue Router 설정 (SPA 라우팅)
│   ├── api/           # API 요청 관련 모듈 (apiRequest.js)
│   ├── App.vue        # 최상위 컴포넌트
│   └── main.js        # 진입점 스크립트
├── package.json
├── vite.config.js
└── ...</code></pre>
<div style="display: flex; gap: 10px;">
  <img src="https://velog.velcdn.com/images/dev_hyjang/post/e0f7def2-4a32-4490-9522-b5abce00a655/image.png" width="30%">
  <img src="https://velog.velcdn.com/images/dev_hyjang/post/17442935-79c1-49bb-b845-ce63ad6b47ee/image.png">
</div>


<h3 id="7-html-→-vue-컴포넌트-변환">7. HTML → Vue 컴포넌트 변환</h3>
<p>기존 Spring Boot resources/templates의 HTML 파일들을 Vue 컴포넌트로 변환합니다.</p>
<p>예: login.html → Login.vue</p>
<pre><code>&lt;template&gt;
  &lt;div class=&quot;login-page&quot;&gt;
    &lt;h1&gt;로그인&lt;/h1&gt;
    &lt;form @submit.prevent=&quot;handleLogin&quot;&gt;
      &lt;input type=&quot;text&quot; v-model=&quot;username&quot; placeholder=&quot;아이디&quot; /&gt;
      &lt;input type=&quot;password&quot; v-model=&quot;password&quot; placeholder=&quot;비밀번호&quot; /&gt;
      &lt;button type=&quot;submit&quot;&gt;로그인&lt;/button&gt;
    &lt;/form&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
  data() {
    return {
      username: &#39;&#39;,
      password: &#39;&#39;
    }
  },
  methods: {
    handleLogin() {
      console.log(&#39;로그인 요청&#39;, this.username, this.password)
    }
  }
}
&lt;/script&gt;</code></pre><h3 id="8-api-호출-공통-함수-만들기-apirequestjs">8. API 호출 공통 함수 만들기 (apiRequest.js)</h3>
<p>src/js/apiRequest.js 생성:</p>
<pre><code class="language-javascript">export async function apiRequest(url, options = {}) {
  let accessToken = localStorage.getItem(&quot;accessToken&quot;);

  options.headers = {
    ...options.headers,
    &quot;Content-Type&quot;: &quot;application/json&quot;,
    &quot;Authorization&quot;: accessToken ? `Bearer ${accessToken}` : undefined
  };

  let response = await fetch(url, options);

  if (response.status === 401) {
    console.error(&quot;인증 실패&quot;);
  }

  return response.json();
}</code></pre>
<h3 id="9-vite에서-백엔드-api-프록시-설정">9. Vite에서 백엔드 API 프록시 설정</h3>
<p>vite.config.js</p>
<pre><code class="language-javascript">import { defineConfig } from &#39;vite&#39;
import vue from &#39;@vitejs/plugin-vue&#39;
import path from &#39;path&#39;

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      &#39;@&#39;: path.resolve(__dirname, &#39;./src&#39;),
    },
  },
  server: {
    proxy: {
      &#39;/api&#39;: {
        target: &#39;http://localhost:8080&#39;,
        changeOrigin: true,
      },
    },
  },
})</code></pre>
<h3 id="10-spring-boot-cors-설정">10. Spring Boot CORS 설정</h3>
<p>백엔드 @Configuration에 다음 설정 추가:</p>
<pre><code class="language-java">@Bean
public WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping(&quot;/**&quot;)
                    .allowedOrigins(&quot;http://localhost:5173&quot;)
                    .allowedMethods(&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;, &quot;DELETE&quot;, &quot;OPTIONS&quot;)
                    .allowCredentials(true);
        }
    };
}</code></pre>
<h3 id="11-실행-및-테스트">11. 실행 및 테스트</h3>
<ul>
<li>백엔드: ./gradlew bootRun (포트: 8080)</li>
<li>프론트엔드: npm run dev (포트: 5173)</li>
</ul>
<p>브라우저에서 Vue 페이지 접속 후 API 요청이 정상적으로 작동하는지 확인합니다.</p>
<h3 id="12-마이그레이션-시-팁">12. 마이그레이션 시 팁</h3>
<ul>
<li>HTML을 Vue로 옮길 때, 템플릿 구조 + CSS 클래스는 그대로 두고, id/onclick 대신 Vue의 v-model, @click 등을 사용합니다.</li>
<li>공통 CSS는 src/assets에 넣고 main.js에서 import합니다.</li>
<li>Vue Router를 사용하면 기존 HTML 페이지 전환을 SPA 방식으로 처리할 수 있습니다.</li>
</ul>
<hr>
<p>이렇게 하면 기존 Spring Boot HTML 기반 프론트를 Vue로 완전히 분리하고, API 연동까지 마칠 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot H2 DB를 Render PostgreSQL로 전환하기]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot-H2-DB%EB%A5%BC-Render-PostgreSQL%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot-H2-DB%EB%A5%BC-Render-PostgreSQL%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 12 Aug 2025 06:28:05 GMT</pubDate>
            <description><![CDATA[<p>이 글에서는 Spring Boot 프로젝트의 내장 DB인 H2를 클라우드 기반의 PostgreSQL DB(Render)로 전환하고, 로컬 개발 환경에서 DBeaver를 통해 원격 DB에 접근하는 과정을 다룹니다.</p>
<blockquote>
<h3 id="h2에서-postgresql로-전환해야-하는-이유">H2에서 PostgreSQL로 전환해야 하는 이유</h3>
<p>로컬 개발 환경에서는 H2와 같은 내장 DB를 사용하면 편리하지만, 실제 서비스 환경에서는 데이터 영속성을 보장하고 여러 사용자가 동시에 접근할 수 있는 독립된 DB 서버가 필요합니다. 
Render는 PostgreSQL DB를 무료로 제공하므로 개인 프로젝트에 적합한 솔루션입니다.</p>
</blockquote>
<hr>
<h2 id="redner-postgresql-프로젝트-생성">Redner PostgreSQL 프로젝트 생성</h2>
<h3 id="postgresql-새-프로젝트-생성">PostgreSQL 새 프로젝트 생성</h3>
<ul>
<li>Render 사이트에서 postgresql 프로젝트를 아래와 같이 생성합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/d4d8ff6a-d8b3-49ae-93f3-cdd2f0b78b55/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/bd563f6d-33c3-49ba-98bb-1b92a5b2a4b5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/7de8147f-afe5-4faf-8b29-394167d4d274/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/b86a2d5c-21d8-41f2-9e2f-adee71fc2188/image.png" alt=""></p>
<ul>
<li>프로젝트가 성공적으로 생성되면 Connections  정보가 채워집니다.</li>
<li>해당 정보를 데이터베이스 연결에 활용합니다.</li>
</ul>
<hr>
<h2 id="2-스프링부트에-postgresql-연동">2. 스프링부트에 PostgreSQL 연동</h2>
<h3 id="1-postgresql-드라이버-의존성-추가">1. PostgreSQL 드라이버 의존성 추가</h3>
<p>Spring Boot가 PostgreSQL에 연결할 수 있도록 프로젝트에 드라이버 의존성을 추가해야 합니다.
build.gradle(Gradle) 파일에 postgresql을 추가해 주세요.</p>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/b054d6dc-eac8-42a9-b745-c1b6370dc199/image.png">

<ul>
<li>기존 h2 데이터베이스를 주석처리한 후 postgresql 데이터베이스로 변경하였습니다.</li>
</ul>
<h3 id="2-applicationyml-설정-변경">2. application.yml 설정 변경</h3>
<ul>
<li>H2 관련 설정은 모두 지우고, Render에서 제공하는 PostgreSQL 연결 정보를 application.yml에 추가해야 합니다.</li>
<li>src/main/resources/application.yml 파일에 다음 내용을 작성해 주세요. {}로 표시된 부분은 반드시 본인의 Render DB 정보로 바꿔야 합니다.</li>
</ul>
<pre><code class="language-JAVASCRIPT">spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://{host_value}:5432/simple_iltf
    username: simple_iltf_user
    password: {password_value}</code></pre>
<ul>
<li>spring.datasource.url: Render 대시보드의 External Database URL에서 호스트 주소를 복사하여 입력합니다.</li>
<li>spring.datasource.username &amp; password: Render DB 사용자 이름과 비밀번호를 정확하게 입력합니다.</li>
</ul>
<hr>
<h2 id="3-데이터베이스-연결-확인">3. 데이터베이스 연결 확인</h2>
<h3 id="로그-확인">로그 확인</h3>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/b68e3188-9b51-4147-86b0-4c99e10eaad0/image.png">

<ul>
<li>위의 설정을 마친 뒤 프로젝트를 실행해 보았습니다.</li>
<li>로그를 통해 PostgreSQL 데이터베이스로 변경된 것을 확인할 수 있습니다.</li>
</ul>
<h3 id="디비버-연결-및-조회">디비버 연결 및 조회</h3>
<p>추가로 백엔드와 DB 연결이 완료되었다면, DBeaver를 사용해 GUI 환경에서 데이터를 직접 확인하고 관리할 수 있습니다.</p>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/957f20a3-634a-401e-b306-8e9a559b8c9a/image.png" width="80%">

<ul>
<li>Render에서 제공하는 Connections 정보를 바탕으로 데이터베이스 연결에 성공했습니다.</li>
</ul>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/8a876284-c92f-4922-9a28-e8a4eb729c95/image.png" width="80%">

<ul>
<li>기존 테이블의 내용이 조회되는 것도 확인할 수 있습니다. </li>
</ul>
<h3 id="api-전송-테스트">API 전송 테스트</h3>
<p>백엔드와 데이터베이스 연결이 되었는지 확인하기 위해 API 요청을 보내 테스트를 진행하였습니다. 성공적으로 데이터를 조회하는 것을 확인할 수 있었습니다.
<img src="https://velog.velcdn.com/images/dev_hyjang/post/349239f2-ba5f-4d0f-8787-cadd625deb96/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + JWT + Refresh Token 로그인 & 인증/인가 구현]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot-JWT-Refresh-Token-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot-JWT-Refresh-Token-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 08 Aug 2025 07:41:25 GMT</pubDate>
            <description><![CDATA[<p>이번 글에서는 <strong>Spring Boot + JWT</strong> 기반의 로그인 기능에 <strong>Refresh Token</strong> 기능을 추가하고,<br>토큰 재발급 및 로그아웃 처리까지 구현한 과정을 기록합니다.</p>
<hr>
<h2 id="구현-목표">구현 목표</h2>
<ul>
<li><strong>Access Token + Refresh Token</strong> 기반 인증 구조 설계  </li>
<li>Access Token 만료 시 Refresh Token으로 재발급  </li>
<li>Refresh Token 저장·관리  </li>
<li>로그아웃 시 Refresh Token 삭제 처리  </li>
<li>Postman을 활용한 전체 테스트  </li>
</ul>
<hr>
<h2 id="1-refresh-token-설계">1. Refresh Token 설계</h2>
<h3 id="저장-위치">저장 위치</h3>
<ul>
<li><strong>DB에 저장</strong>하여 관리 (<code>user_info</code> 테이블에 컬럼 추가)<pre><code class="language-sql">ALTER TABLE user_info ADD refresh_token VARCHAR(255);</code></pre>
</li>
</ul>
<p>저장 이유</p>
<ul>
<li>Refresh Token은 장기 보관이 필요한 토큰이므로 서버에서 안전하게 관리</li>
<li>클라이언트와 서버 양쪽에서 토큰을 검증 가능</li>
</ul>
<h2 id="2-jwt-발급-구조">2. JWT 발급 구조</h2>
<h3 id="access-token">Access Token</h3>
<ul>
<li>만료 시간: 짧게 (예: 1시간)</li>
<li>인증/인가 요청 시 사용</li>
<li>탈취 시 피해를 최소화하기 위해 짧은 수명 설정</li>
</ul>
<h3 id="refresh-token">Refresh Token</h3>
<ul>
<li>만료 시간: 길게 (예: 7일)</li>
<li>Access Token이 만료됐을 때 새로운 Access Token 발급에만 사용</li>
<li>DB에 저장하여 유효성 검증 가능</li>
</ul>
<h2 id="3-api-설계">3. API 설계</h2>
<table>
<thead>
<tr>
<th>API 명</th>
<th>Method</th>
<th>URL</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>로그인</td>
<td>POST</td>
<td>/api/login</td>
<td>ID/PW 검증 후 토큰 발급</td>
</tr>
<tr>
<td>인증 테스트</td>
<td>GET</td>
<td>/api/user/test</td>
<td>Access Token 유효성 검증</td>
</tr>
<tr>
<td>토큰 재발급</td>
<td>POST</td>
<td>/api/refresh</td>
<td>Refresh Token으로 Access Token 재발급</td>
</tr>
<tr>
<td>로그아웃</td>
<td>POST</td>
<td>/api/logout</td>
<td>Refresh Token 삭제</td>
</tr>
</tbody></table>
<h2 id="4-구현-핵심-코드">4. 구현 핵심 코드</h2>
<h3 id="로그인-시-refresh-token-저장">로그인 시 Refresh Token 저장</h3>
<pre><code class="language-java">String refreshToken = jwtUtil.createRefreshToken(username);
userMapper.updateRefreshToken(username, refreshToken);

return Map.of(
    &quot;accessToken&quot;, accessToken,
    &quot;refreshToken&quot;, refreshToken
);</code></pre>
<h3 id="토큰-재발급">토큰 재발급</h3>
<pre><code class="language-java">@PostMapping(&quot;/api/refresh&quot;)
public Map&lt;String, String&gt; refreshToken(@RequestBody Map&lt;String, String&gt; request) {
    String refreshToken = request.get(&quot;refreshToken&quot;);

    if (!jwtUtil.validateToken(refreshToken)) {
        throw new RuntimeException(&quot;Refresh Token이 유효하지 않습니다.&quot;);
    }

    String username = jwtUtil.getUsername(refreshToken);
    String savedRefreshToken = userMapper.findRefreshToken(username);

    if (!refreshToken.equals(savedRefreshToken)) {
        throw new RuntimeException(&quot;Refresh Token 불일치&quot;);
    }

    String newAccessToken = jwtUtil.createAccessToken(username);
    return Map.of(&quot;accessToken&quot;, newAccessToken);
}</code></pre>
<h3 id="로그아웃-처리">로그아웃 처리</h3>
<pre><code class="language-java">@PostMapping(&quot;/api/logout&quot;)
public void logout(@RequestBody Map&lt;String, String&gt; request) {
    String refreshToken = request.get(&quot;refreshToken&quot;);
    String username = jwtUtil.getUsername(refreshToken);
    userMapper.updateRefreshToken(username, null);
}</code></pre>
<h2 id="5-postman-테스트-시퀀스">5. Postman 테스트 시퀀스</h2>
<h3 id="1-로그인-요청">1) 로그인 요청</h3>
<ul>
<li>POST /api/login</li>
<li>Body(JSON)<pre><code>json
{ &quot;username&quot;: &quot;testuser&quot;, &quot;password&quot;: &quot;1234&quot; }
응답: Access Token + Refresh Token</code></pre><h3 id="2-인증-api-호출">2) 인증 API 호출</h3>
</li>
<li>GET /api/user/test</li>
<li>Header</li>
</ul>
<pre><code>css
Authorization: Bearer {accessToken}</code></pre><h3 id="3-access-token-만료-시-재발급">3) Access Token 만료 시 재발급</h3>
<ul>
<li>POST /api/refresh</li>
<li>Body(JSON)</li>
</ul>
<pre><code>json
{ &quot;refreshToken&quot;: &quot;발급받은_refresh_token&quot; }
응답: 새 Access Token</code></pre><h3 id="4-로그아웃">4) 로그아웃</h3>
<ul>
<li>POST /api/logout</li>
<li>Body(JSON)</li>
</ul>
<pre><code>json
{ &quot;refreshToken&quot;: &quot;발급받은_refresh_token&quot; }</code></pre><h3 id="5-로그아웃-후-재발급-요청-테스트">5) 로그아웃 후 재발급 요청 테스트</h3>
<ul>
<li>POST /api/refresh</li>
<li>Body(JSON)<pre><code>json
{ &quot;refreshToken&quot;: &quot;발급받은_refresh_token&quot; }</code></pre></li>
<li>응답:<pre><code>mathematica
Refresh Token 불일치</code></pre></li>
<li>로그아웃 후에는 DB에서 Refresh Token이 삭제되어 재발급 불가
<img src="https://velog.velcdn.com/images/dev_hyjang/post/fd8a50c8-4c8f-4d90-b2da-2f4ae115bfa5/image.png" alt=""></li>
</ul>
<h3 id="6-마무리">6. 마무리</h3>
<ul>
<li>JWT 기반의 인증/인가 구조 완성</li>
<li>Refresh Token을 통한 안전한 Access Token 재발급</li>
<li>로그아웃 시 Refresh Token 무효화</li>
<li>Postman으로 로그인 → 인증 → 재발급 → 로그아웃 → 재발급 실패 시나리오 검증 완료</li>
</ul>
<p>다음 단계에서는:</p>
<ul>
<li>Refresh Token을 Redis 같은 인메모리 저장소로 관리</li>
<li>HTTPS 적용</li>
<li>토큰 탈취 방지 로직 추가</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + JWT - DB 기반 권한 관리와 JWT 권한 인증 구현하기]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot-JWT-2%ED%8E%B8-DB-%EA%B8%B0%EB%B0%98-%EA%B6%8C%ED%95%9C-%EA%B4%80%EB%A6%AC%EC%99%80-JWT-%EA%B6%8C%ED%95%9C-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot-JWT-2%ED%8E%B8-DB-%EA%B8%B0%EB%B0%98-%EA%B6%8C%ED%95%9C-%EA%B4%80%EB%A6%AC%EC%99%80-JWT-%EA%B6%8C%ED%95%9C-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 08 Aug 2025 02:34:36 GMT</pubDate>
            <description><![CDATA[<p>지난 글에서 JWT를 이용한 기본 로그인과 토큰 발급, 그리고 인증 과정까지 구현했습니다.<br>이번 글에서는 사용자 권한(Role) 정보를 DB에서 관리하고, JWT 토큰에 권한 정보를 포함시켜 Spring Security에서 권한별 API 접근 제어를 구현하는 방법을 다뤄보겠습니다.</p>
<hr>
<h2 id="1-사용자-권한-정보db-컬럼-추가하기">1. 사용자 권한 정보(DB 컬럼) 추가하기</h2>
<p>JWT 권한 인증을 위해선 토큰에 권한 정보가 포함되어야 합니다.<br>그래서 기존 사용자 테이블에 권한을 저장하는 컬럼을 추가했습니다.</p>
<p>예를 들어, 다음과 같이 두 사용자 계정을 준비했습니다:</p>
<table>
<thead>
<tr>
<th>user_id</th>
<th>password</th>
<th>role</th>
</tr>
</thead>
<tbody><tr>
<td>testuser</td>
<td>(암호화된 비밀번호)</td>
<td>ROLE_USER</td>
</tr>
<tr>
<td>testadmin</td>
<td>(암호화된 비밀번호)</td>
<td>ROLE_ADMIN</td>
</tr>
</tbody></table>
<p>권한은 반드시 <code>ROLE_</code> 접두사가 붙는 형태로 저장하거나, 애플리케이션 코드에서 접두사를 붙여 일관성 있게 관리하는 것이 중요합니다.
<img src="https://velog.velcdn.com/images/dev_hyjang/post/52c74518-54f2-4743-9f8e-c867e002ae0b/image.png" alt=""></p>
<hr>
<h2 id="2-로그인-시-권한-정보를-포함한-jwt-토큰-발급">2. 로그인 시 권한 정보를 포함한 JWT 토큰 발급</h2>
<p><code>AuthController</code>에서 로그인 성공 후, DB에서 권한 정보를 읽어 JWT 토큰에 포함시켰습니다.</p>
<pre><code class="language-java">String role = userService.getRoleByUserId(userId); // DB에서 권한 조회
String roleClaim = role.startsWith(&quot;ROLE_&quot;) ? role : &quot;ROLE_&quot; + role;
String token = jwtTokenProvider.createToken(userId, roleClaim);</code></pre>
<p>이렇게 발급된 토큰은 내부에 role 클레임을 포함하고 있으며, 클라이언트는 이 토큰을 API 요청 시 헤더에 담아 전송합니다.</p>
<h2 id="3-jwt-토큰에서-권한-정보-파싱-및-securitycontext에-권한-부여">3. JWT 토큰에서 권한 정보 파싱 및 SecurityContext에 권한 부여</h2>
<p>JwtAuthenticationFilter에서 요청 헤더의 토큰을 꺼내 검증하고,
토큰의 role 클레임을 추출해 SimpleGrantedAuthority 리스트로 변환합니다.</p>
<pre><code class="language-java">String role = jwtTokenProvider.getUserRole(token);
List&lt;SimpleGrantedAuthority&gt; authorities = Collections.singletonList(new SimpleGrantedAuthority(role));

UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);</code></pre>
<p>이 과정을 통해 Spring Security가 현재 요청 사용자의 권한을 인식할 수 있게 됩니다.</p>
<h2 id="4-securityfilterchain에서-권한별-api-접근-제한-설정">4. SecurityFilterChain에서 권한별 API 접근 제한 설정</h2>
<p>SecurityConfig에서는 다음과 같이 경로별 권한을 제한합니다.</p>
<p><code>java
.authorizeHttpRequests(auth -&gt; auth
    .requestMatchers(&quot;/api/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
    .requestMatchers(&quot;/api/user/**&quot;).hasAnyRole(&quot;USER&quot;, &quot;ADMIN&quot;)
    .anyRequest().authenticated()
)</code></p>
<p>즉, /api/admin/** 경로는 ADMIN 권한을 가진 사용자만 접근 가능하고,
/api/user/** 경로는 USER 권한 또는 ADMIN 권한 사용자 모두 접근 가능합니다.</p>
<h2 id="5-postman으로-권한별-api-호출-테스트하기">5. Postman으로 권한별 API 호출 테스트하기</h2>
<ul>
<li>로그인 : /api/login에 ID, PW 전송 → 토큰 수신</li>
<li>USER 권한 테스트 : /api/user/test 호출 → 정상 접근(200 OK)</li>
<li>ADMIN 권한 테스트 : /api/admin/test 호출 → USER 권한이면 403 Forbidden, ADMIN 권한이면 200 OK</li>
<li>토큰 미포함 요청 : 401 Unauthorized 또는 403 Forbidden
<img src="https://velog.velcdn.com/images/dev_hyjang/post/4f98a41b-29c4-4229-9d28-fc15d2afe431/image.png" alt=""></li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 글에서는 DB에 권한 정보를 추가하고 JWT 토큰에 권한을 포함하여, Spring Security와 연동해 권한별 API 접근 제어를 구현하는 방법을 살펴봤습니다.
다음 글에서는 Refresh Token 구현과 토큰 만료 시 자동 갱신 방법, 로그아웃 처리 등을 다룰 예정입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + JWT 로그인 인증 구현 (간단 예제)]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84-%EA%B0%84%EB%8B%A8-%EC%98%88%EC%A0%9C</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D-%EA%B5%AC%ED%98%84-%EA%B0%84%EB%8B%A8-%EC%98%88%EC%A0%9C</guid>
            <pubDate>Thu, 07 Aug 2025 08:49:42 GMT</pubDate>
            <description><![CDATA[<h1 id="🔐-spring-boot--jwt-로그인-인증-구현하기-초간단-예제">🔐 Spring Boot + JWT 로그인 인증 구현하기 (초간단 예제)</h1>
<p>이번 포스트에서는 Spring Boot를 기반으로 JWT(Json Web Token)를 이용한 <strong>로그인 인증 기능</strong>을 구현하는 과정을 단계별로 정리합니다.</p>
<hr>
<h2 id="✅-사용-기술-스택">✅ 사용 기술 스택</h2>
<ul>
<li>Java 17</li>
<li>Spring Boot</li>
<li>Spring Security</li>
<li>H2 Database</li>
<li>Lombok</li>
<li>jjwt (JWT 라이브러리)</li>
</ul>
<hr>
<h2 id="📁-프로젝트-구조-개요">📁 프로젝트 구조 개요</h2>
<p>src/
└─ com.ccp.simple
├─ controller
│ └─ AuthController.java
├─ security
│ └─ JwtTokenProvider.java
├─ config
│ └─ SecurityConfig.java
├─ service
│ └─ UserService.java
└─ dto
└─ LoginRequestDto.java</p>
<hr>
<h2 id="🔐-jwt란-왜-사용하는가">🔐 JWT란? 왜 사용하는가?</h2>
<p><strong>JWT (JSON Web Token)</strong>는 사용자의 인증 정보를 안전하게 전달하기 위한 토큰 기반 인증 방식입니다.</p>
<p>✅ 특징</p>
<ul>
<li>Stateless: 서버가 세션/쿠키를 저장하지 않음 → 서버 확장에 유리</li>
<li>Self-contained: 토큰 자체에 유저 정보(예: ID, Role 등)를 담고 있어 별도 DB 조회 없이 인증 가능</li>
<li>Bearer Token 방식 → HTTP 요청의 Authorization 헤더에 담아 전달</li>
</ul>
<h3 id="🔁-전체-인증-흐름-요약">🔁 전체 인증 흐름 요약</h3>
<ol>
<li>로그인 요청 : 사용자가 /api/login에 ID/PW를 보내면 서버는</li>
</ol>
<ul>
<li>사용자 인증 (UserService에서 유효성 확인)</li>
<li>JWT 토큰 생성 (JwtTokenProvider.createToken)</li>
<li>Authorization: Bearer {token} 헤더에 담아 응답</li>
</ul>
<ol start="2">
<li><p>클라이언트는 받은 토큰을 저장 : 로컬 스토리지 / 세션 스토리지 / 쿠키 등에 저장</p>
</li>
<li><p>인증이 필요한 요청마다 토큰을 같이 전송
GET /api/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...</p>
</li>
<li><p>서버는 요청 시 토큰을 검증
JwtTokenProvider.validateToken()으로 유효성 체크
토큰이 유효하면 SecurityContext에 사용자 정보 설정
이후 컨트롤러 등에서 Authentication 객체로 사용자 정보 사용 가능</p>
</li>
</ol>
<h3 id="✅-정리">✅ 정리</h3>
<ol>
<li>로그인 : ID/PW로 JWT 토큰 발급</li>
<li>클라이언트 저장 :    받은 토큰을 저장 후 인증 요청에 포함</li>
<li>인증 요청 :    Authorization 헤더에 Bearer {token} 포함</li>
<li>토큰 검증 :    서버에서 토큰 검증 후 사용자 인증 처리</li>
<li>사용자 정보 접근 :    Authentication 객체로 현재 사용자 확인 가능</li>
</ol>
<hr>
<h2 id="🔧-1-jwt-토큰-생성-및-검증-클래스">🔧 1. JWT 토큰 생성 및 검증 클래스</h2>
<pre><code class="language-java">// JwtTokenProvider.java
@Component
public class JwtTokenProvider {

    private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private final long validityInMilliseconds = 3600000; // 1시간

    public String createToken(String userId) {
        Claims claims = Jwts.claims().setSubject(userId);
        Date now = new Date();
        Date expiry = new Date(now.getTime() + validityInMilliseconds);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(key)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public String getUserId(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token).getBody().getSubject();
    }
}
</code></pre>
<h2 id="🛡️-2-spring-security-설정">🛡️ 2. Spring Security 설정</h2>
<pre><code class="language-java">
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -&gt; csrf.disable())
            .headers(headers -&gt; headers.frameOptions().disable())
            .authorizeHttpRequests(auth -&gt; auth
                .requestMatchers(&quot;/h2-console/**&quot;, &quot;/api/login&quot;, &quot;/login.html&quot;).permitAll()
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}</code></pre>
<p>/h2-console/**, /api/login 경로는 인증 없이 접근 가능하도록 허용합니다.</p>
<h2 id="🧪-3-로그인-및-인증-컨트롤러">🧪 3. 로그인 및 인증 컨트롤러</h2>
<pre><code class="language-java">// AuthController.java
@RestController
@RequestMapping(&quot;/api&quot;)
@RequiredArgsConstructor
public class AuthController {

    private final UserService userService;
    private final JwtTokenProvider jwtTokenProvider;

    @PostMapping(&quot;/login&quot;)
    public String login(@RequestBody LoginRequestDto request, HttpServletResponse response) {
        boolean valid = userService.validateUser(request.getUserId(), request.getUserPassword());
        if (valid) {
            String token = jwtTokenProvider.createToken(request.getUserId());
            response.setHeader(&quot;Authorization&quot;, &quot;Bearer &quot; + token);
            return &quot;Login successful&quot;;
        } else {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return &quot;Invalid credentials&quot;;
        }
    }

    @GetMapping(&quot;/me&quot;)
    public String currentUser(Authentication authentication) {
        if (authentication == null) {
            return &quot;No Authentication found&quot;;
        }
        return &quot;Current User ID : &quot; + authentication.getPrincipal();
    }
}
</code></pre>
<h2 id="📬-4-postman으로-테스트">📬 4. Postman으로 테스트</h2>
<p>✔️ 1) 로그인 요청 (POST)
URL: <a href="http://localhost:8080/api/login">http://localhost:8080/api/login</a></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/6be50736-a074-4752-bd75-514f9ff39e9b/image.png" alt=""></p>
<p>✔️ 2) 인증된 사용자 정보 요청 (GET)
URL: <a href="http://localhost:8080/api/me">http://localhost:8080/api/me</a></p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/bca9f55b-7269-4721-8475-e62bc8397617/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot에 Swagger(Springdoc OpenAPI) 적용하기]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot%EC%97%90-SwaggerSpringdoc-OpenAPI-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot%EC%97%90-SwaggerSpringdoc-OpenAPI-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 21 Jul 2025 06:44:24 GMT</pubDate>
            <description><![CDATA[<p>간단한 CRUD 백엔드 프로젝트에 API 문서를 자동 생성해주는 Swagger를 적용해보았습니다.
Spring Boot 3.x에서는 springdoc-openapi를 사용하는 것이 공식적으로 권장됩니다.</p>
<h3 id="🔧-사용하는-기술-스택">🔧 사용하는 기술 스택</h3>
<ul>
<li>Java 17</li>
<li>Spring Boot 3.5.x</li>
<li>Gradle</li>
<li>H2 Database</li>
<li>MyBatis</li>
<li>Springdoc OpenAPI (Swagger)</li>
</ul>
<h3 id="🚀-springdoc-openapi-의존성-추가">🚀 Springdoc OpenAPI 의존성 추가</h3>
<p>build.gradle 파일에 아래 의존성을 추가합니다:</p>
<pre><code class="language-xml">dependencies {
    implementation &#39;org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0&#39;
}</code></pre>
<p>✅ 의존성 추가 후에는 Gradle 프로젝트를 리프레시하거나 빌드해줘야 합니다.</p>
<h3 id="🧩-swagger-설정-선택">🧩 Swagger 설정 (선택)</h3>
<p>Swagger 문서의 제목, 설명 등을 직접 설정하고 싶다면 아래처럼 Config 클래스를 만들어줍니다:</p>
<pre><code class="language-java">package com.ccp.simple.controller;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {
    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title(&quot;회원 관리 API&quot;)
                        .description(&quot;Spring Boot + MyBatis 기반 회원관리 시스템 API 문서&quot;)
                        .version(&quot;v1.0&quot;));
    }
}</code></pre>
<p>✅ 이 설정은 필수는 아니며, 생략해도 기본 UI는 잘 작동합니다.</p>
<h3 id="🌐-swagger-ui-접속-방법">🌐 Swagger UI 접속 방법</h3>
<p>Spring Boot 앱을 실행한 후 아래 URL로 접속하면 Swagger UI를 확인할 수 있습니다.
<a href="http://localhost:8080/swagger-ui/index.html">http://localhost:8080/swagger-ui/index.html</a>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/5a50d6fa-0598-4b79-a888-b928830c628d/image.png" alt="">참고) /v3/api-docs 경로는 Swagger가 내부적으로 사용하는 JSON 문서입니다.</p>
<h3 id="📌-참고-정상-노출을-위한-조건">📌 참고) 정상 노출을 위한 조건</h3>
<ul>
<li>@RestController, @RequestMapping, @GetMapping, @PostMapping 등이 제대로 선언되어 있어야 합니다.</li>
<li>DTO, Controller가 @ComponentScan 범위에 포함되어야 합니다.</li>
</ul>
<hr>
<h3 id="✍️-마무리">✍️ 마무리</h3>
<p>Swagger는 API 서버를 개발할 때 문서화 자동화 + 테스트 편의성을 동시에 잡을 수 있는 좋은 도구입니다.
간단한 설정으로 프로젝트의 퀄리티를 높일 수 있으니 꼭 한 번 적용해보세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + Docker + Render 배포 자동화 과정

]]></title>
            <link>https://velog.io/@dev_hyjang/Spring-Boot-Docker-Render-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EA%B3%BC%EC%A0%95</link>
            <guid>https://velog.io/@dev_hyjang/Spring-Boot-Docker-Render-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EA%B3%BC%EC%A0%95</guid>
            <pubDate>Mon, 21 Jul 2025 04:43:29 GMT</pubDate>
            <description><![CDATA[<p>이 글에서는 <strong>Spring Boot 애플리케이션을 Docker 컨테이너로 만들고, Render를 이용해 자동 배포하는 전체 과정을</strong> 정리했습니다.<br>프로젝트 초기 세팅부터 Dockerfile 작성, Render 환경 변수 설정, 그리고 Git 연동을 통한 자동 배포까지 단계별로 다룹니다.<br>특히, 실제 배포 과정에서 마주쳤던 문제와 해결 방법도 함께 기록하여, 처음 Render 배포를 시도하는 분들이 시행착오를 줄일 수 있도록 구성했습니다.</p>
<hr>
<h2 id="스프링부트-프로젝트-세팅">스프링부트 프로젝트 세팅</h2>
<h3 id="프로젝트-생성">프로젝트 생성</h3>
<p>이번 프로젝트는 Spring Boot 애플리케이션을 Docker로 컨테이너화하고,
Render를 통해 자동 배포하는 것을 목표로 합니다.
먼저 Spring Initializr를 이용해 기본 프로젝트를 생성했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/162c4f16-c995-4df6-912d-8b0f249b43ba/image.png" alt=""></p>
<ul>
<li>Framework: Spring Boot 3.5.3
→ 최신 버전의 Spring Boot로, Java 17 이상에서 실행 가능</li>
<li>Build Tool: Gradle
→ 의존성 관리가 편리하고 빌드 속도가 빠름</li>
<li>JDK: Java 17
→ LTS(Long Term Support) 버전으로 안정성이 높음</li>
</ul>
<h3 id="프로젝트-실행-테스트">프로젝트 실행 테스트</h3>
<p>프로젝트를 생성한 직후, IDE에서 바로 실행해 보고
Spring Boot 로고와 함께 Started Application 로그가 나오면 성공입니다.
이 단계에서 실행이 안 되면 Gradle 설정이나 JDK 경로부터 점검해야 합니다.</p>
<hr>
<h2 id="데이터베이스-연결-h2-database">데이터베이스 연결 (H2 Database)</h2>
<p>이번 예제에서는 개발 단계에서만 H2 Database를 사용합니다.
H2는 가볍고 메모리 모드 지원 덕분에 빠른 테스트에 유리합니다.</p>
<ol>
<li>build.gradle에 의존성 추가</li>
</ol>
<pre><code class="language-yaml">  runtimeOnly &#39;com.h2database:h2&#39;</code></pre>
<ol start="2">
<li>application.yml에 데이터베이스 설정 추가</li>
<li>H2 1.4.198 이후 버전에서는 보안 정책 변경으로 파일 기반 DB가 자동 생성되지 않음
따라서 DB 파일을 직접 생성해야 할 수 있습니다.</li>
</ol>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/9246afbc-e886-4ce8-bbe6-8bc0d528f65c/image.png" width="80%">

<p>위 위치에 test.mv.db 파일을 생성한 뒤 접속하면 정상적으로 접근됩니다.</p>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/03c10edc-6b0e-4ccc-a94b-2a2542db3bec/image.png" width="80%">

<p>H2 콘솔 접속 후 테스트용 테이블을 만들어 확인합니다.</p>
<img src="https://velog.velcdn.com/images/dev_hyjang/post/2d8bb475-4184-4aa3-ad18-13f15897e3c7/image.png" width="50%">

<h3 id="메모리-모드-사용-예시">메모리 모드 사용 예시</h3>
<p>테스트용 데이터베이스를 메모리에만 유지하려면 아래와 같이 설정합니다.
이 경우 애플리케이션이 종료되면 데이터는 사라집니다.</p>
<pre><code class="language-yaml">datasource:  
  url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1  
  driver-class-name: org.h2.Driver  
  username: sa  
  password:</code></pre>
<h3 id="로깅-설정">로깅 설정</h3>
<p>Spring Boot의 기본 로깅 레벨은 INFO입니다.
전체 로그를 보고 싶다면 application.yml에 다음과 같이 설정합니다.</p>
<pre><code class="language-yaml">logging:
  level:
    root: INFO</code></pre>
<ul>
<li>개발 중에는 DEBUG로 변경하면 쿼리 실행 로그까지 확인 가능하지만,
운영 환경에서는 성능 저하를 막기 위해 INFO 또는 WARN을 권장합니다.</li>
</ul>
<h3 id="mybatis-연결-설정">MyBatis 연결 설정</h3>
<p>MyBatis를 사용해 DB와 연동하기 위한 기본 설정입니다.</p>
<pre><code class="language-yaml">mybatis:  
  mapper-locations: classpath:/mapper/**/*.xml  
  type-aliases-package: com.ccp.simple.domain  
  configuration:  
    map-underscore-to-camel-case: true</code></pre>
<ul>
<li>mapper-locations: XML 매퍼 파일 위치 지정</li>
<li>type-aliases-package: DTO/VO 클래스의 패키지 경로
  → 클래스명을 짧게 사용할 수 있음</li>
<li>map-underscore-to-camel-case: DB 컬럼명(user_name)을 Java 필드명(userName)으로 자동 변환</li>
</ul>
<h3 id="설정파일-전체-예시">설정파일 전체 예시</h3>
<p>아래는 지금까지의 설정을 합친 application.yml 예시입니다.</p>
<pre><code class="language-yaml">spring:  
  datasource:  
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1  
    driver-class-name: org.h2.Driver  
    username: sa  
    password:  
  h2:  
    console:  
      enabled: true  
  sql:  
    init:  
      schema-locations: classpath:schema.sql  
      data-locations: classpath:data.sql  
      mode: always  

mybatis:  
  mapper-locations: classpath:/mapper/**/*.xml  
  type-aliases-package: com.ccp.simple.domain  
  configuration:  
    map-underscore-to-camel-case: true  

logging:  
  level:  
    root: INFO</code></pre>
<hr>
<h2 id="프로젝트-및-테이블-구조">프로젝트 및 테이블 구조</h2>
<h3 id="프로젝트-구조">프로젝트 구조</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/d79b5c8e-cd64-4adc-aed6-1fee15daf4e8/image.png" alt=""></p>
<h3 id="테이블-구조">테이블 구조</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/6b61de3d-95f1-46a6-a7e1-dcafe728fcf4/image.png" alt=""></p>
<ul>
<li>참고: <code>bbs</code> 테이블은 생성하지 않음</li>
</ul>
<hr>
<h2 id="조회-기능-테스트">조회 기능 테스트</h2>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;user&quot;)
public class UserController {
    private final UserService userService;

    @GetMapping()
    public List&lt;User&gt; getUsers() {
        return userService.getUsers();
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/be3558e5-37db-41e2-aa59-b1ed486fb68a/image.png" alt=""></p>
<hr>
<h2 id="도커-배포">도커 배포</h2>
<h3 id="데이터베이스-메모리-모드-변경">데이터베이스 메모리 모드 변경</h3>
<pre><code class="language-yaml">datasource:  
  url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1  
  driver-class-name: org.h2.Driver  
  username: sa  
  password:</code></pre>
<ul>
<li>H2 메모리 모드 사용 시 앱 종료하면 데이터 초기화됨</li>
<li>파일 모드로 변경 필요</li>
</ul>
<pre><code class="language-yaml">datasource:  
  url: jdbc:h2:file:./data/testdb;DB_CLOSE_ON_EXIT=FALSE  
  driver-class-name: org.h2.Driver  
  username: sa  
  password:</code></pre>
<ul>
<li>도커 컨테이너에 데이터 파일 복사 또는 볼륨 마운트 예정</li>
</ul>
<hr>
<h3 id="빌드-jar-파일-생성">빌드 (Jar 파일 생성)</h3>
<pre><code class="language-bash">./gradlew clean build</code></pre>
<ul>
<li><code>build/libs/</code> 폴더 내 <code>yourprojectname-version.jar</code> 생성됨</li>
</ul>
<hr>
<h3 id="도커-설치">도커 설치</h3>
<ul>
<li><strong>Docker Desktop (Windows용)</strong> 설치</li>
<li><code>docker --version</code>으로 설치 확인</li>
<li>Docker는 WSL2 위에서 작동하므로 WSL 설치 및 업데이트 필요</li>
</ul>
<hr>
<h3 id="wsl-업데이트-이슈-참고">WSL 업데이트 이슈 참고</h3>
<ul>
<li><code>wsl --update</code> 실패 시 해결책 링크</li>
<li><a href="https://github.com/microsoft/WSL/issues/13203#issuecomment-3020312346">GitHub Issue</a></li>
<li><a href="https://devwooki.tistory.com/14">블로그 글</a></li>
</ul>
<hr>
<h3 id="1-dockerfile-생성">1. Dockerfile 생성</h3>
<ul>
<li>프로젝트 루트(예: <code>build.gradle</code> 위치)에 생성</li>
</ul>
<pre><code class="language-dockerfile">FROM openjdk:17-jdk-slim
WORKDIR /app
COPY build/libs/simple-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<hr>
<h3 id="2-docker-이미지-빌드">2. Docker 이미지 빌드</h3>
<pre><code class="language-bash">docker build -t simple-app .</code></pre>
<ul>
<li>이미지 확인</li>
</ul>
<pre><code class="language-bash">docker images</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/a2f5d959-7526-4c88-81e5-25990e4d4ac7/image.png" alt=""></p>
<hr>
<h3 id="3-docker-컨테이너-실행">3. Docker 컨테이너 실행</h3>
<pre><code class="language-bash">docker run -d -p 8080:8080 --name simple-container simple-app</code></pre>
<ul>
<li>컨테이너 실행 확인</li>
</ul>
<pre><code class="language-bash">docker ps</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/70be8c59-532f-4034-8036-7833f344f2d4/image.png" alt=""></p>
<hr>
<h2 id="자동-배포">자동 배포</h2>
<h3 id="github-push-→-자동-빌드-→-자동-docker-배포">GitHub Push → 자동 빌드 → 자동 Docker 배포</h3>
<table>
<thead>
<tr>
<th>방법</th>
<th>설명</th>
<th>자동화 수준</th>
<th>설정 난이도</th>
<th>비용</th>
</tr>
</thead>
<tbody><tr>
<td>✅ Render 사용</td>
<td>GitHub Push 시 Docker 빌드 + 실행 자동화</td>
<td>⭐ 완전 자동</td>
<td>매우 쉬움</td>
<td>무료 플랜 있음</td>
</tr>
<tr>
<td>✅ GitHub Actions + AWS EC2</td>
<td>GitHub Push → EC2에서 Docker 자동 실행</td>
<td>⭐⭐ 유연함</td>
<td>약간 어려움</td>
<td>AWS 비용 발생</td>
</tr>
</tbody></table>
<hr>
<h3 id="render-자동-배포-과정">Render 자동 배포 과정</h3>
<ol>
<li>Render 가입 (<a href="https://render.com">https://render.com</a>)</li>
<li>Web Service 선택</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/7744b46d-2c71-407a-af91-b855771b3ee1/image.png" alt=""></p>
<ol start="3">
<li>GitHub 연결</li>
<li>레포지토리 선택, Language는 Docker 설정</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/11ba080d-f31f-46b5-b901-303c72c29fa9/image.png" alt=""></p>
<ol start="5">
<li>배포 버튼 클릭</li>
</ol>
<hr>
<h3 id="배포-중-발생한-오류">배포 중 발생한 오류</h3>
<pre><code>error: failed to solve: failed to compute cache key: failed to calculate checksum of ref ... no such file or directory</code></pre><ul>
<li><p>원인: Render가 Dockerfile만 보고 빌드 시도하는데, 빌드된 Jar가 없어 실패</p>
</li>
<li><p>해결법</p>
<ol>
<li>Jar 파일을 깃허브에 수동 업로드 (비효율)</li>
<li>Dockerfile에 Gradle 빌드 명령어 포함 (추천)</li>
</ol>
</li>
</ul>
<hr>
<h3 id="dockerfile-수정-전">Dockerfile 수정 전</h3>
<pre><code class="language-dockerfile">FROM openjdk:17-jdk-slim
WORKDIR /app
COPY build/libs/simple-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<hr>
<h3 id="dockerfile-수정-후-빌드-포함">Dockerfile 수정 후 (빌드 포함)</h3>
<pre><code class="language-dockerfile"># Build stage
FROM gradle:8.5-jdk17 AS builder
COPY --chown=gradle:gradle . /home/gradle/project
WORKDIR /home/gradle/project
RUN gradle build --no-daemon

# Run stage
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=builder /home/gradle/project/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<hr>
<h3 id="배포-재시도-결과">배포 재시도 결과</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/6afd7ee5-1586-4bc2-b572-d0642016e1bf/image.png" alt=""></p>
<hr>
<h3 id="자동-배포-테스트-성공-화면">자동 배포 테스트 성공 화면</h3>
<p><img src="https://velog.velcdn.com/images/dev_hyjang/post/5f911c01-9f71-4ab9-88cf-d8bff15c188c/image.png" alt=""></p>
<hr>
<h1 id="마무리">마무리</h1>
<ul>
<li>Render를 이용한 자동 배포는 설정이 매우 간단해서 초보자도 쉽게 따라할 수 있음</li>
<li>Dockerfile 내에서 Gradle 빌드 포함은 자동화에 필수적</li>
<li>앞으로도 GitHub Push → Render 자동 배포 흐름으로 운영 가능</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[세션 관리(5/13)]]></title>
            <link>https://velog.io/@dev_hyjang/%EC%84%B8%EC%85%98-%EA%B4%80%EB%A6%AC513</link>
            <guid>https://velog.io/@dev_hyjang/%EC%84%B8%EC%85%98-%EA%B4%80%EB%A6%AC513</guid>
            <pubDate>Sat, 15 Jun 2024 14:30:03 GMT</pubDate>
            <description><![CDATA[<h1 id="동시-세션-제어">동시 세션 제어</h1>
<ul>
<li>사용자가 동시에 여러 세션을 생성하는 것을 관리</li>
<li>사용자의 인증 후 활성화된 세션의 수가 설정된 maximumSession 값과 비교하여 제어 여부를 결정</li>
</ul>
<ol>
<li>사용자 세션 강제 만료</li>
<li>사용자 인증 시도 차단</li>
</ol>
<pre><code class="language-java">@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.sessionManagement(session -&gt; session
    .invalidSessionUrl(“/invalidSessionUrl”) // 이미 만료된 세션으로 요청을 하는 사용자를 특정 엔드포인트로 리다이렉션 할 Url 을 지정한다
    .maximumSessions(1) // 사용자당 최대 세션 수를 제어한다. 기본값은 무제한 세션을 허용한다
    .maxSessionsPreventsLogin(true) // true 이면 최대 세션 수(maximumSessions(int))에 도달했을 때 사용자의 인증을 방지한다
// false(기본 설정)이면 인증하는 사용자에게 접근을 허용하고 기존 사용자의 세션은 만료된다
.expiredUrl(&quot;/expired&quot;) // 세션을 만료하고 나서 리다이렉션 할 URL 을 지정한다
 );
     return http.build();
}</code></pre>
<br>

<h1 id="세션-고정-보호">세션 고정 보호</h1>
<ul>
<li>악의적인 공격자가 사이트에 접근하여 세션을 생성한 다음 다른 사용자가 같은 세션으로 로그인하도록 유도하는 위험</li>
<li>스프링 시큐리티는 사용자가 로그인할 때 새로운 세션을 생성하거나 세션 ID를 변경하여 자동 대응</li>
</ul>
<h3 id="세션-고정-보호-전략">세션 고정 보호 전략</h3>
<ul>
<li>changeSessionId() : 기존 세션을 유지하면서 세션 ID만 변경하여 인증 과정에서 세션 고정 공격을 방지하는 방식이다. 기본 값으로 설정되어 있다</li>
<li>newSession() : 새로운 세션을 생성하고 기존 세션 데이터를 복사하지 않는 방식이다(SPRING_SECURITY_ 로 시작하는 속성은 복사한다)</li>
<li>migrateSession() : 새로운 세션을 생성하고 모든 기존 세션 속성을 새 세션으로 복사한다</li>
<li>none() : 기존 세션을 그대로 사용한다</li>
</ul>
<br>

<h1 id="세션-정책">세션 정책</h1>
<ul>
<li>인증된 사용자에 대한 세션 생성 정책을 설정하여 어떻게 세션을 관리할지 결정할 수 있으며 이 정책은 SessionCreationPolicy 로 설정</li>
</ul>
<h4 id="세션-생성-정책-전략">세션 생성 정책 전략</h4>
<ol>
<li>SessionCreationPolicy. ALWAYS</li>
</ol>
<ul>
<li>인증 여부에 상관없이 항상 세션을 생성한다</li>
<li>ForceEagerSessionCreationFilter 클래스를 추가 구성하고 세션을 강제로 생성시킨다</li>
</ul>
<ol start="2">
<li>SessionCreationPolicy. NEVER</li>
</ol>
<ul>
<li>스프링 시큐리티가 세션을 생성하지 않지만 애플리케이션이 이미 생성한 세션은 사용할 수 있다</li>
</ul>
<ol start="3">
<li>SessionCreationPolicy. IF_REQUIRED</li>
</ol>
<ul>
<li>필요한 경우에만 세션을 생성한다. 예를 들어 인증이 필요한 자원에 접근할 때 세션을 생성한다</li>
</ul>
<ol start="4">
<li>SessionCreationPolicy. STATELESS</li>
</ol>
<ul>
<li>세션을 전혀 생성하거나 사용하지 않는다</li>
<li>인증 필터는 인증 완료 후 SecurityContext 를 세션에 저장하지 않으며 JWT 와 같이 세션을 사용하지 않는 방식으로 인증을 관리할 때 유용할 수 있다</li>
<li>SecurityContextHolderFilter 는 세션 단위가 아닌 요청 단위로 항상 새로운 SecurityContext 객체를 생성하므로 컨텍스트 영속성이 유지되지 않는다</li>
</ul>
<ol start="4">
<li>STATELESS 설정에도 세션이 생성될 수 있다</li>
</ol>
<ul>
<li>스프링 시큐리티에서 CSRF 기능이 활성화 되어 있고 CSRF 기능이 수행 될 경우 사용자의 세션을 생성해서 CSRF 토큰을 저장하게 된다</li>
<li>세션은 생성되지만 CSRF 기능을 위해서 사용될 뿐 인증 프로세스의 SecurityContext 영속성에 영향을 미치지는 않는다</li>
</ul>
<br>

<h1 id="sessionmanagementfilter--concurrentsessionfilter">SessionManagementFilter &amp; ConcurrentSessionFilter</h1>
<ul>
<li>요청이 시작된 이후 사용자가 인증되었는지 감지하고, 인증된 경우에는 세션 고정 보호 메커니즘을 활성화하거나 동시 다중 로그인을 확인하는 등 세션 관련 활동을 수행하기 위해 설정된 세션 인증 전략(SessionAuthenticationStrategy)을 호출하는 필터 클래스</li>
<li>스프링 시큐리티 6 이상에서는 SessionManagementFilter 가 기본적으로 설정 되지 않으며 세션관리 API 를 설정을 통해 생성할 수 있음</li>
</ul>
<h4 id="concurrentsessionfilter">ConcurrentSessionFilter</h4>
<ul>
<li>각 요청에 대해 SessionRegistry에서 SessionInformation 을 검색하고 세션이 만료로 표시되었는지 확인하고 만료로 표시된 경우 로그아웃 처리를 수행한다(세션 무효화)</li>
<li>각 요청에 대해 SessionRegistry.refreshLastRequest(String)를 호출하여 등록된 세션들이 항상 &#39;마지막 업데이트&#39; 날짜/시간을 가지도록 한다
<img src="https://velog.velcdn.com/images/dev_hyjang/post/890259ea-67c5-435e-9a21-634385a9b84e/image.png" alt=""></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>