<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>4ou_chan.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Wed, 30 Jul 2025 07:21:39 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. 4ou_chan.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/4ou_chan" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[데이터 조회 시 Redis 캐싱 키 전략으로 인한 보안 이슈]]></title>
            <link>https://velog.io/@4ou_chan/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%8B%9C-Redis-%EC%BA%90%EC%8B%B1-%ED%82%A4-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EB%B3%B4%EC%95%88-%EC%9D%B4%EC%8A%88</link>
            <guid>https://velog.io/@4ou_chan/%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A1%B0%ED%9A%8C-%EC%8B%9C-Redis-%EC%BA%90%EC%8B%B1-%ED%82%A4-%EC%A0%84%EB%9E%B5%EC%9C%BC%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EB%B3%B4%EC%95%88-%EC%9D%B4%EC%8A%88</guid>
            <pubDate>Wed, 30 Jul 2025 07:21:39 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>메뉴 단건 조회 API에서 존재하지 않는 매장의 ID와 유효한 메뉴 ID를 함께 요청했음에도 <strong>정상적인 메뉴 정보가 반환되는 문제</strong>가 발생했습니다.</p>
<h3 id="예시">예시</h3>
<p>매장 목록</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/1ddd1703-6c82-42db-9219-62678af789f4/image.png" alt=""></li>
</ul>
<p>Redis에 저장된 데이터</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/2a181631-2310-49e2-9417-31c93570c552/image.png" alt=""></li>
</ul>
<p>요청 API</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/9f8890bb-6200-41f8-bccc-99c5a7b62b29/image.png" alt=""></li>
</ul>
<p>반환 결과</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/e4d8037b-d746-4851-983f-f8f76af8c9b2/image.png" alt=""></li>
</ul>
<p>존재하지 않는 매장 ID인 333과 Redis에 캐싱된 메뉴 ID인 3을 함께 요청했음에도, 메뉴3이 정상적으로 조회되는 모습입니다.</p>
<h2 id="원인-추론">원인 추론</h2>
<p>캐싱된 메뉴에서만 문제가 발생하는것으로 보아 캐시 키 구조가 매장 ID에 관한 정보는 모르는 상황에서 메뉴 3에 대한 조회 요청이 들어오니, 캐시에서 바로 반환했을것이라 생각했습니다.</p>
<p>따라서 캐시 키를 만들 때 매장 ID에 관한 정보를 포함시켜서, 조회 요청 시 매장 ID와 메뉴 ID가 모두 일치할때만 캐시 데이터를 반환하도록 변경하면 될것이라 판단했습니다.</p>
<h2 id="문제-해결">문제 해결</h2>
<p>기존의 캐시 키 구조입니다.</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/0e3d207b-6f59-4bf7-b5f6-935272060d69/image.png" alt=""></li>
</ul>
<p>위 구조를 아래와 같이 변경하였습니다.</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/bcb4cad5-ada1-4bd1-be9f-2e4c25d6c7ac/image.png" alt=""></li>
</ul>
<h3 id="테스트">테스트</h3>
<p>유효한 매장 ID인 10과 메뉴 ID 17을 바탕으로 캐시 키를 만들겠습니다.</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/b26b916e-7057-4fd7-8630-969f15d14392/image.png" alt=""></li>
</ul>
<p>매장 10의 메뉴 17 키가 생성되었습니다.</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/9d20959a-d9c6-48bd-9944-50262182f7e4/image.png" alt=""></li>
</ul>
<p>다시금 유효하지 않은 매장 ID인 1000과 유효한 메뉴 ID인 17로 조회 요청을 보내겠습니다.</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/0c873ba5-c364-49ef-8f6d-75af1b4bf60f/image.png" alt=""></li>
</ul>
<p>기대하던 결과가 반환되었습니다.</p>
<ul>
<li><img src="https://velog.velcdn.com/images/4ou_chan/post/ad8b0e11-2a50-44f3-b672-185c91fea35f/image.png" alt=""></li>
</ul>
<p>Redis를 사용할 때 키 전략을 더욱 신중하게 고민해야한다는것을 깨달았습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Batch size INSERT 적용법]]></title>
            <link>https://velog.io/@4ou_chan/Spring-Batch-size-%EC%A0%81%EC%9A%A9%EB%B2%95</link>
            <guid>https://velog.io/@4ou_chan/Spring-Batch-size-%EC%A0%81%EC%9A%A9%EB%B2%95</guid>
            <pubDate>Sun, 13 Jul 2025 11:25:33 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-batch-size-insert-적용법">Spring Batch size INSERT 적용법</h2>
<p><code>Batch size INSERT</code> 적용법에 대해 알아보겠습니다.</p>
<h3 id="batch-size란">Batch Size란?</h3>
<p><code>Batch size</code>는 <code>JPA</code>가 다수의 <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code> 작업을 처리할 때 SQL 실행을 모아서 일괄 실행(batch execution) 하는 단위를 뜻합니다.</p>
<p>예를 들어 도서 관리 시스템에서 도서별 평점 평균을 기반으로 순위를 집계하여 통계(Statistics)테이블에 저장하는 로직이 있다면, 통계 테이블에 저장하는 과정에서 엔티티별로 <code>Insert</code> 쿼리가 실행됩니다.</p>
<p>즉 100개 도서의 평점 평균을 집계하여 통계 테이블에 저장하게 되면, 100개의 <code>Insert</code> 쿼리가 실행됩니다.</p>
<p>이 때 <code>Batch size</code>를 50으로 설정해주면, <code>Insert</code>쿼리를 50개씩 묶어서 2번 실행하여 성능을 최적화합니다.</p>
<h3 id="사용-시-주의사항">사용 시 주의사항</h3>
<p>우선 <code>Batch size</code>는 엔티티(Entity)의 <code>PK</code> 생성전략이 <code>IDENTITY</code>일 경우 사용할 수 없습니다.</p>
<p>그 이유는 아래 블로그를 참조하여 설명하겠습니다.</p>
<p><a href="https://dkswnkk.tistory.com/682">해당 블로그 2번 목차 참조</a></p>
<p><code>IDNETITY</code>전략은, <code>DB</code>에 데이터가 <strong>저장될 때</strong> <code>ID</code>값을 <code>AUTO INCREMENT</code>하여 값을 자동으로 1씩 증가시켜 저장해줍니다.</p>
<p>그러나 <code>Batch Size INSERT</code>는 쿼리를 묶어서 처리하기 때문에 값이 저장될 때 <code>ID</code>값을 할당하는 <code>IDENTITY</code>전략과는 맞지 않습니다. 예를들어 10개의 쿼리를 묶어서 처리하려면, 묶어둔 10개의 데이터에 각각 <code>PK</code>를 지정해주어야하는데, <code>IDENTITY</code>전략의 경우 데이터가 저장될 때 PK값을 할당하므로 동작하지 않습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/83022065-0c02-4469-b6e4-d2f3cc35659f/image.png" alt=""></p>
<p><strong><code>@GeneratedValue</code>의 <code>generator</code>값은 <code>@SequenceGenerator</code>의 <code>name</code>과 일치해야합니다.</strong></p>
<ul>
<li>seuqenceName : DB 내의 실제 시퀀스 이름을 뜻합니다.</li>
<li>allocationSize : 한 번에 가져올 ID의 수를 설정합니다. 기본값은 50이나, 예시를위해 임시로 100으로 설정하였습니다.</li>
</ul>
<h3 id="batch-size-사용-방법">Batch Size 사용 방법</h3>
<p>우선 하이버네이트 배치 설정을 해주어야 합니다.</p>
<p><code>application.yml</code> 파일에 <code>jpa.properties.hibernate.jdbc.batch_size</code> 설정을 적용합니다. </p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/dabd05ba-b0b0-4ebe-9a44-ce007133b8da/image.png" alt=""></p>
<p>반드시 <code>properties.hibernate</code>에 적용해야 합니다.</p>
<p><code>saveAll</code>메서드로 한 번에 저장하기 위해 리스트를 만들고, 리스트에 값을 담아준 뒤 <code>saveAll</code>메서드를 실행합니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/831dce59-4a0c-45a5-a9c9-e5d194c64dab/image.png" alt=""></p>
<p>콘솔에 출력된 로그를 보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/aa703424-ab0d-4feb-ac4e-e9a2c9b9e38b/image.png" alt=""></p>
<p>여전히 쿼리가 개별적으로 나가는것으로 확인됩니다. </p>
<p>관련 정보를 찾던 중 
<a href="ttps://techblog.woowahan.com/2695/">참고 블로그</a></p>
<p>MySQL JDBC의 경우 JDBC URL에 rewriteBatchedStatements=true 옵션을 추가해주면 된다. 는 내용을 보아 <code>.env</code>파일에 추가 해주었습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/725c0f08-f16b-4fc5-8e62-c221943c4c5b/image.png" alt=""></p>
<p>다시 실행해보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/1feb176a-4ab5-488e-9077-2e7cadb18192/image.png" alt=""></p>
<p>여전히 개별 처리되는것을 볼 수 있습니다.</p>
<p>다시 블로그 내용을 보니 </p>
<p>MySQL의 경우 실제로 생성된 쿼리는 <code>logger=com.mysql.jdbc.log.Slf4JLogger&amp;profileSQL=true</code> 옵션으로 로그를 통해 확인할 수 있다.는 내용을 확인해 <code>.env</code> 파일의 <code>DB_URL</code>에 <code>&amp;profileSQL=true&amp;logger=Slf4JLogger</code>를 추가해준 후 다시 실행시켰습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/957dd44e-d133-4a14-9755-27271c1d1f38/image.png" alt=""></p>
<p>여전히 콘솔에서는 개별 처리된 것으로 보이지만 아래 새로 찍힌 로그를 자세히 살펴보겠습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/e142fe24-7f45-43a5-808b-0f7a36d547cf/image.png" alt=""></p>
<pre><code class="language-java">: [QUERY] /* insert for com.example.bookify.domain.statistics.domain.model.Statistics */insert into statistics (book_id,created_at,rating_avg,review_count,review_rank,updated_at,statistics_id) values (1,&#39;2025-07-13 20:11:15.800214&#39;,5.0,3,1,&#39;2025-07-13 20:11:15.800214&#39;,402),(2,&#39;2025-07-13 20:11:15.809448&#39;,5.0,3,2,&#39;2025-07-13 20:11:15.809448&#39;,403),(7,&#39;2025-07-13 20:11:15.809448&#39;,5.0,2,3,&#39;2025-07-13 20:11:15.809448&#39;,404),(8,&#39;2025-07-13 20:11:15.810449&#39;,5.0,2,4,&#39;2025-07-13 20:11:15.810449&#39;,405),(3,&#39;2025-07-13 20:11:15.810449&#39;,4.0,3,5,&#39;2025-07-13 20:11:15.810449&#39;,406),(9,&#39;2025-07-13 20:11:15.810449&#39;,4.0,2,6,&#39;2025-07-13 20:11:15.810449&#39;,407),(10,&#39;2025-07-13 20:11:15.810775&#39;,3.0,2,7,&#39;2025-07-13 20:11:15.810775&#39;,408),(4,&#39;2025-07-13 20:11:15.810775&#39;,2.5,6,8,&#39;2025-07-13 20:11:15.810775&#39;,409),(6,&#39;2025-07-13 20:11:15.810775&#39;,2.3333,3,9,&#39;2025-07-13 20:11:15.810775&#39;,410),(5,&#39;2025-07-13 20:11:15.811279&#39;,1.0,3,10,&#39;2025-07-13 20:11:15.811279&#39;,411) [Created on: Sun Jul 13 20:11:15 KST 2025, duration: 0, connection-id: 760, statement-id: 0, resultset-id: 0,    at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]</code></pre>
<p>위 내용을 요약하면 다음과 같습니다.</p>
<pre><code class="language-java">: [QUERY] */insert into statistics (각 컬럼) values (1),(2),(7),(8,),(3,),(9,),(10,),(4,),(6,),(5,) [Created on: Sun Jul 13 20:11:15 KST 2025, duration: 0, connection-id: 760, statement-id: 0, resultset-id: 0,    at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)]</code></pre>
<p>즉 배치 처리가 성공적으로 처리되었지만 하이버네이트는 배치처리 여부와 관계없이 무조건 콘솔에 쿼리를 엔티티마다 한건 씩 처리한다는 사실을 알 수 있습니다.</p>
<h2 id="사용법-정리">사용법 정리</h2>
<ul>
<li><p><code>PK</code> 생성 전략이 <code>IDENTITY</code>가 아닐 것</p>
</li>
<li><p><code>application.yml</code> 파일에 <code>jpa.properties.hibernate.jdbc.batch_size</code> 설정을 적용할 것</p>
</li>
<li><p><code>DB</code> 저장 시 <code>saveAll</code>메서드를 사용할 것</p>
</li>
<li><p><code>JDBC URL</code>에 <code>rewriteBatchedStatements=true</code> 옵션을 추가해줄 것</p>
</li>
</ul>
<p>위 4가지 조건을 만족할 경우 <code>Batch Size</code> 적용이 완료됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[플러스 프로젝트 어려웠던부분]]></title>
            <link>https://velog.io/@4ou_chan/%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98%EB%B6%80%EB%B6%84</link>
            <guid>https://velog.io/@4ou_chan/%ED%94%8C%EB%9F%AC%EC%8A%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98%EB%B6%80%EB%B6%84</guid>
            <pubDate>Fri, 11 Jul 2025 15:05:58 GMT</pubDate>
            <description><![CDATA[<p>도서 리뷰 평점 평균을 구해 순위를 집계하는 로직을 작성했습니다.</p>
<h3 id="처음-생각했던-구조">처음 생각했던 구조</h3>
<ul>
<li>도서 총 리뷰 수를 집계하는 쿼리를 작성하여 통계 엔티티로 반환</li>
<li>도서 평점 평균을 집계하는 쿼리를 작성하여 통계 엔티티로 반환</li>
<li>평점 평균 포매팅<h2 id="변경-이유">변경 이유</h2>
</li>
</ul>
<p>집계 결과를 엔티티로 반환하여 그대로 사용하려했는데, 집계 쿼리의 경우 계산된 값을 엔티티에 직접 매핑할 수 없었습니다.</p>
<ol>
<li><p>엔티티의 컬럼에 값을 넣어볼까? 생각하니 반환 값이 딱 떨어지는 하나의 값이 아닌, 각 도서별 집계 결과여서 하나의 컬럼에 값을 넣는것도 불가능하다고 판단했습니다.</p>
</li>
<li><p>그래서 List에 엔티티를 담아서 따로 반환하면 되지 않을까? 싶어서 시도 해봤지만, 집계 결과와 엔티티가 매핑되지 않아 다른 방법을 모색했습니다.</p>
</li>
</ol>
<p>마땅한 방법이 떠오르지 않아서 구글에 &#39;집계 쿼리 결과 매핑&#39;으로 키워드라도 얻고자 추상적으로 검색했는데, 구글 AI가 해결 방법을 알려주었습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/662e4b91-dee6-4394-b55f-f2a91e07dab1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/0af22797-40d0-4d2e-a855-faeb1f488a4b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/2ee55c24-48ea-44c0-b512-6fb9f74fe9ed/image.png" alt=""></p>
<p>사용 방법과 예시도 작성되어 있었지만, 처음 접하다보니 관련 내용을 좀 더 찾아보았고 한 블로그를 찾았습니다.</p>
<p><a href="https://ddonghyeo.tistory.com/70#2.%20JPA%EC%99%80%20DTO%20%EB%B0%98%ED%99%98-1">참고 블로그 링크</a></p>
<p>제가 사용하고자 하는것이 JPA이므로 해당 블로그 목차 2번을 살펴보았고</p>
<p>다른 블로그도 찾아보았습니다.</p>
<p><a href="https://choihjhj.tistory.com/entry/jpa-Repository%EC%97%90%EC%84%9C-DTO%EB%A1%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%85%8B%ED%8C%85-Query">추가 참고 블로그 링크</a></p>
<p>SELECT 구문에 <code>new DTO경로 (조회 컬럼)</code> 이와 같은 방식으로 사용한다는것을 알았습니다.</p>
<p>이 과정에서 두 개 였던 집계 쿼리를 하나로 합치게 되었는데, 그 이유는 두 개의 집계 쿼리를 매핑할 DTO를 각각 따로 만들게 될 경우 두 개의 리스트에서 도서 관련 정보를 처리하는 과정이 중복된다고 느꼈고 이를 어떻게 처리하면 좋을지 고민하던 중 쿼리를 하나로 합치게 되었습니다. </p>
<p>또한 기존 구조에서는 통계 테이블에서 도서 정보에 접근하고, 해당 도서 정보를 바탕으로 리뷰 정보에 접근하려 했으나, 도서 -&gt; 리뷰 연관관계가 존재하지 않고, 리뷰 -&gt; 도서 연관관계만 존재한다는 사실을 인지했습니다.</p>
<p>하여 리뷰 테이블을 기준으로 도서 정보를 조회하도록 쿼리를 변경하였습니다.</p>
<p>최종 쿼리</p>
<pre><code class="language-java">    @Query(&quot;SELECT new com.example.bookify.domain.statistics.service.dto.AvgReviewGradeDto(b, AVG (r.grades), COUNT (r)) &quot; +
            &quot;FROM Review r &quot; +
            &quot;JOIN r.book b &quot; +
            &quot;WHERE r.updatedAt BETWEEN :startDate AND :endDate &quot; +
            &quot;GROUP BY r.book &quot; +
            &quot;ORDER BY AVG (r.grades) DESC, COUNT (r) DESC&quot;)
    List&lt;AvgReviewGradeDto&gt; avgByReviewByGrades(@Param(&quot;startDate&quot;) LocalDateTime startDate,
                                                @Param(&quot;endDate&quot;) LocalDateTime endDate
    );</code></pre>
<p>추가적으로 평점 평균 포매팅은 아직까지 평점 평균을 반환하지 않으므로 보류하였습니다.</p>
<h2 id="순위-중첩-문제">순위 중첩 문제</h2>
<pre><code class="language-java">        Long reviewRank = 1L;

        for (AvgReviewGradeDto gradeRank : avgGradesReview) {
            Statistics ratingRanking = new Statistics(
                    reviewRank++,
                    gradeRank.getAvgGrade(),
                    gradeRank.getCountReview(),
                    gradeRank.getBook()
            );
            statisticsRepository.save(ratingRanking);
        }</code></pre>
<p>위 코드가 스케줄러에 의해 5분 주기로 실행되어 이미 집계 된 정보를 중복하여 집계하는 문제가 발생했습니다.</p>
<p>따라서 해당 월의 통계를 지우는 로직을 위 코드 위에 작성하면, 해당 월의 통계가 삭제되고 최신 정보를 반영한 채 다시 집계되어 저장되는 효과를 기대했습니다. </p>
<pre><code class="language-java">    @Query(&quot;DELETE FROM Statistics s WHERE s.createdAt BETWEEN :startDate AND :endDate&quot;)
    void deleteStatistics(@Param(&quot;startDate&quot;) LocalDateTime startDate,
                          @Param(&quot;endDate&quot;) LocalDateTime endDate
    );</code></pre>
<p>하여 통계 삭제 쿼리를 작성한 후 실행하였는데 오류가 발생하며 프로그램이 종료되었습니다.</p>
<pre><code class="language-java">    @Modifying
    @Query(&quot;DELETE FROM Statistics s WHERE s.createdAt BETWEEN :startDate AND :endDate&quot;)
    void deleteStatistics(@Param(&quot;startDate&quot;) LocalDateTime startDate,
                          @Param(&quot;endDate&quot;) LocalDateTime endDate
    );</code></pre>
<p>위와 같이 수정하니 프로그램이 의도한대로 작동하였습니다.</p>
<p>이유 = <code>@Query</code>는 기본적으로 <code>SELECT</code> 구문일것이라 가정합니다. 
따라서 조회 쿼리가 아니라 DB 상태를 변경하는 쿼리라는것을 명시적으로 지정해줘야 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Scheduler 사용법]]></title>
            <link>https://velog.io/@4ou_chan/Spring-Scheduler</link>
            <guid>https://velog.io/@4ou_chan/Spring-Scheduler</guid>
            <pubDate>Thu, 10 Jul 2025 08:17:13 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-scheduler">Spring Scheduler</h1>
<ul>
<li>Spring Framework에서 제공하는 기능으로, 정해진 시간에 작업을 자동으로 실행할 수 있게 도와주는 기능입니다.</li>
</ul>
<h2 id="scheduler-사용법">Scheduler 사용법</h2>
<h3 id="enablescheduling">@EnableScheduling</h3>
<p>어플리케이션 클래스에 <code>@EnableScheduling</code>을 사용합니다.</p>
<pre><code class="language-java">@EnableScheduling   //클래스 단위 선언
public class BookifyApplication {

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

}</code></pre>
<h3 id="scheduled">@Scheduled</h3>
<ul>
<li><code>Spring Bean</code>으로 등록된 클래스의 메서드 단위에서 선언하여 사용합니다.</li>
</ul>
<pre><code class="language-java">@Service     // @Service 어노테이션으로인해 컴포넌트 스캔되어 스프링 빈으로 자동 등록
public class StatisticsService {

    @Scheduled     // 메서드 단위에서 사용
    public void bookRatingStatistics(){
    }
}</code></pre>
<h4 id="scheduled-규칙">@Scheduled 규칙</h4>
<p>메서드 타입은 반드시 <code>void</code>입니다.</p>
<ul>
<li><code>@Scheduled</code> 어노테이션은 특정 주기에 따라 메서드를 실행하도록 예약하는 데 사용됩니다. 이 어노테이션이 붙은 메서드는 스케줄러에 의해 주기적으로 호출되는데, 이때 반환 값은 무시됩니다. 따라서 굳이 반환 값을 사용할 필요가 없으므로, void로 설정합니다.</li>
</ul>
<p>매개변수를 받을 수 없습니다.</p>
<ul>
<li><code>@Scheduled</code>어노테이션이 <code>void</code>타입이므로, 매개변수를 가질 수 없습니다.</li>
</ul>
<h2 id="scheduled-속성">@Scheduled 속성</h2>
<p><code>@Scheduled</code>의 속성은 해당 어노테션 내부를 살펴보면 확인할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/54849a42-165d-408e-90c2-105aa73ba109/image.png" alt=""></p>
<p>각 속성의 적용 및 사용 방법을 알아보겠습니다.</p>
<h3 id="cron-크론표현식--실행-주기-설정">cron (크론표현식 : 실행 주기 설정)</h3>
<p>크론 표현식에서 각 자리는 <code>초, 분, 시, 일, 월, 요일</code>을 나타냅니다.</p>
<pre><code class="language-java">    @Scheduled(cron = &quot;* * * * * *&quot;) // &quot;*초 *분 *시 *일 *월 *요일&quot; 즉 매 초마다 실행
    public void bookRatingStatistics() {
    }
}</code></pre>
<pre><code class="language-java">    @Scheduled(cron = &quot;30 * * * * *&quot;) // &quot;*초 *분 *시 *일 *월 *요일&quot; 매분30초마다
    public void bookRatingStatistics() {
    }
}</code></pre>
<pre><code class="language-java">    @Scheduled(cron = &quot;30 5 * * * *&quot;) // &quot;*초 *분 *시 *일 *월 *요일&quot; 매시간5분30초마다
    public void bookRatingStatistics() {
    }
}</code></pre>
<pre><code class="language-java">    @Scheduled(cron = &quot;30 5 15 * * *&quot;) // &quot;*초 *분 *시 *일 *월 *요일&quot; 매일15시5분30초마다
    public void bookRatingStatistics() {
    }
}</code></pre>
<pre><code class="language-java">    @Scheduled(cron = &quot;30 5 15 5 * *&quot;) // &quot;*초 *분 *시 *일 *월 *요일&quot; 매월5일15시5분30초마다
    public void bookRatingStatistics() {
    }
}</code></pre>
<p>요일은 <code>0=일</code>, <code>1=월</code>, <code>2=화</code>, <code>3=수</code>, <code>4=목</code>, <code>5=금</code>, <code>6=토</code> 입니다.</p>
<p>표현식은 다음과 같습니다.</p>
<ol>
<li><code>* (모든 값)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 * * * * *&quot;) // 매 분 0초</code></pre>
</li>
<li><code>? (충돌 회피)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 0 12 ? * WED&quot;) // 매주 수요일 12시에 실행</code></pre>
</li>
<li><code>- (범위)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 0 9-17 * * *&quot;) // 매일 9시~17시 매시 정각</code></pre>
</li>
<li><code>, (다중 값)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 0 9,15 * * *&quot;) // 매일 9시, 15시 정각에 실행</code></pre>
</li>
<li><code>/ (증분)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 */10 * * * *&quot;) // 매 10분마다 실행 (0분부터 시작)</code></pre>
</li>
<li><code>L (마지막)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 0 0 L * *&quot;) // 매월 마지막 날 자정에 실행</code></pre>
</li>
<li><code>W (가장 가까운 평일)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 0 9 10W * *&quot;) // 매월 10일 기준 가장 가까운 평일 9시에 실행</code></pre>
</li>
<li><code># (N번째 요일)</code><pre><code class="language-java">@Scheduled(cron = &quot;0 0 9 ? * 4#2&quot;) // 매월 둘째 주 목요일 오전 9시에 실행</code></pre>
</li>
</ol>
<h3 id="zone-cron-표현식-타임존-설정">zone (cron 표현식 타임존 설정)</h3>
<p>크론 표현식을 사용할 때의 타임존을 설정합니다.</p>
<pre><code class="language-java">    @Scheduled(cron = &quot;* * * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    public void bookRatingStatistics() {
    }
}</code></pre>
<p>위와 같이 설정할 경우 아시아/서울 기준의 타임존이 적용됩니다.</p>
<h3 id="fixedrate-작업-시작-시점부터-정의된-시간이-지나면-다음-작업-실행">fixedRate (작업 시작 시점부터 정의된 시간이 지나면 다음 작업 실행)</h3>
<pre><code class="language-java">    @Scheduled(fixedRate = 2000)
    public void bookRatingStatistics() {
    }</code></pre>
<p><code>milliseconds</code> 단위입니다.</p>
<p>이전 작업 시작 시점으로부터 2초가 지날 경우 다음 작업을 실행합니다.</p>
<h3 id="fixedratestring-fixedrate와-동일하나-문자열로-표기">fixedRateString (fixedRate와 동일하나 문자열로 표기)</h3>
<pre><code class="language-java">    @Scheduled(fixedRate = &quot;2000&quot;)
    public void bookRatingStatistics() {
    }</code></pre>
<p><code>fixedRate</code>와 마찬가지로 이전 작업 시작 시점으로부터 2초 후에 다음 작업을 실행합니다.</p>
<h3 id="fixeddelay-이전-작업-종료-시점부터-정의된-시간이-지나면-다음-작업-실행">fixedDelay (이전 작업 종료 시점부터 정의된 시간이 지나면 다음 작업 실행)</h3>
<pre><code class="language-java">    @Scheduled(fixedDelay = 2000)
    public void bookRatingStatistics() {
    }</code></pre>
<p><code>milliseconds</code> 단위입니다.</p>
<p>이전 작업 종료 시점으로부터 2초가 지날 경우 다음 작업을 실행합니다.</p>
<h3 id="fixeddelaystring-fixeddelay와-동일하나-문자열로-표기">fixedDelayString (fixedDelay와 동일하나 문자열로 표기)</h3>
<pre><code class="language-java">    @Scheduled(fixedDelay = &quot;2000&quot;)
    public void bookRatingStatistics() {
    }</code></pre>
<p><code>fixedDelay</code>와 마찬가지로 이전 작업 종료 시점으로부터 2초가 지날 경우 다음 작업을 실행합니다.</p>
<h3 id="initialdelay-초기-지연-시간-설정">initialDelay (초기 지연 시간 설정)</h3>
<pre><code class="language-java">    @Scheduled(fixedRate = 2000, initialDelay = 10000)
    public void bookRatingStatistics() {
    }</code></pre>
<p> <code>milliseconds</code> 단위입니다.</p>
<p> <code>initialDelay</code>설정 시 10초의 대기시간이 지난 후 작업 종료 2초마다 다음 작업을 실행합니다.</p>
<h3 id="initialdelaystring-initialdelay와-동일하나-문자열로-표기">initialDelayString (initialDelay와 동일하나 문자열로 표기)</h3>
<pre><code class="language-java">    @Scheduled(fixedRate = 2000, initialDelay = &quot;10000&quot;)
    public void bookRatingStatistics() {
    }</code></pre>
<p><code>initialDelay</code>와 마찬가지로 10초의 대기시간이 지난 후 작업 종료 2초마다 다음 작업을 실행합니다.</p>
<h2 id="5분-주기-로그-출력-테스트">5분 주기 로그 출력 테스트</h2>
<pre><code class="language-java">    @Scheduled(fixedDelay = 300000)
    public void bookRatingStatistics() {
        log.info(&quot;테스트중입니다.&quot;);
    }</code></pre>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/49e0ab01-1730-477f-8b0a-2c6e920b7f20/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 플러스주차 개인과제 진행 중 어려웠던 부분]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%ED%94%8C%EB%9F%AC%EC%8A%A4%EC%A3%BC%EC%B0%A8-%EA%B0%9C%EC%9D%B8%EA%B3%BC%EC%A0%9C-%EC%A7%84%ED%96%89-%EC%A4%91-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98-%EB%B6%80%EB%B6%84</link>
            <guid>https://velog.io/@4ou_chan/Spring-%ED%94%8C%EB%9F%AC%EC%8A%A4%EC%A3%BC%EC%B0%A8-%EA%B0%9C%EC%9D%B8%EA%B3%BC%EC%A0%9C-%EC%A7%84%ED%96%89-%EC%A4%91-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98-%EB%B6%80%EB%B6%84</guid>
            <pubDate>Fri, 04 Jul 2025 04:03:44 GMT</pubDate>
            <description><![CDATA[<h2 id="lv2---6-cascadetype">Lv2 - 6 CascadeType</h2>
<p>CascadeType을 ALL로 할지 PERSIST로 할지 고민하던 중 일정 삭제 시 담당자도 삭제되는것이 옳다고 생각해 ALL을 적용하여 해결했었습니다. 그러나 추후 CascadeType을 여러가지를 적용할 수 있다는 사실을 알게되었고, PERSIST와 REMOVE를 함께 적용하도록 변경하였습니다.
<img src="https://velog.velcdn.com/images/4ou_chan/post/19594e4b-4869-4f86-b8c0-6cbe253c93d1/image.png" alt=""></p>
<h2 id="lv2---8-querydsl">Lv2 - 8 QueryDSL</h2>
<p>QueryDSL을 기존에 사용하던 Repository에서 사용하고 싶어서 여러 방법으로 시도 해보았는데 기존 Repository가 인터페이스이기 때문에 JPAQueryFactory를 사용할 수 없었습니다.</p>
<p>왜 그런가 하니 인터페이스는 메서드 선언만 존재하기 때문에 의존성 주입을 할 수 없기 때문이라는 사실을 알았습니다.</p>
<p>하여 결국 CustomRepository와 그 구현체(CustomRepositoryImpl)을 생성하여, 문제를 해결했습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Dynamic Filter(동적 필터링)]]></title>
            <link>https://velog.io/@4ou_chan/Spring-Dynamic-Filter%EB%8F%99%EC%A0%81-%ED%95%84%ED%84%B0%EB%A7%81</link>
            <guid>https://velog.io/@4ou_chan/Spring-Dynamic-Filter%EB%8F%99%EC%A0%81-%ED%95%84%ED%84%B0%EB%A7%81</guid>
            <pubDate>Wed, 25 Jun 2025 12:00:09 GMT</pubDate>
            <description><![CDATA[<h1 id="dynamic-filter동적-필터링--spring-jpa기반">Dynamic Filter(동적 필터링)  Spring JPA기반</h1>
<p>Spring JPA에서 조건에 따라 <strong>검색 조건이 달라지는 쿼리</strong>를 작성할 때 자주 사용하는 기법이 바로 <strong>Dynamic Filter</strong>, 즉 <strong>동적 필터링</strong>입니다.  </p>
<p>사용자의 입력 값이 존재하는 경우에만 조건을 추가하고, 그렇지 않으면 해당 조건을 무시하는 방식으로 <strong>단일 쿼리로 다양한 경우를 처리</strong>할 수 있게 합니다.</p>
<hr>
<h2 id="동적-필터링이-필요한-이유">동적 필터링이 필요한 이유</h2>
<ul>
<li>검색 조건이 많고 그중 일부만 선택적으로 사용될 경우, 모든 조합에 대해 별도의 쿼리를 만들면 코드가 복잡해짐</li>
<li>조건의 유무에 따라 <strong>동적으로 필터링할 수 있는 유연한 쿼리 구조</strong>가 필요</li>
<li>QueryDSL을 쓰지 않고도 복잡한 조건 분기를 깔끔하게 처리 가능</li>
</ul>
<hr>
<h2 id="코드-예시-spring-jpa--jpql">코드 예시 (Spring JPA + JPQL)</h2>
<pre><code>    @Query(&quot;SELECT t&quot; +
            &quot; FROM Todo t &quot; +
            &quot;LEFT JOIN FETCH t.user u &quot; +
            &quot;WHERE (:weather IS NULL OR t.weather = :weather)&quot; +
            &quot;AND (:searchStartDate IS NULL OR t.modifiedAt &gt;= :searchStartDate) &quot; +
            &quot;AND (:searchEndDate IS NULL OR t.modifiedAt &lt;= :searchEndDate)&quot; +
            &quot;ORDER BY t.modifiedAt DESC&quot;)

    Page&lt;Todo&gt; findAllOrSearch(
            @Param(&quot;weather&quot;) String weather,
            @Param(&quot;searchStartDate&quot;) LocalDateTime searchStartDate,
            @Param(&quot;searchEndDate&quot;) LocalDateTime searchEndDate,
            Pageable pageable);</code></pre><hr>
<h2 id="작동-원리">작동 원리</h2>
<ul>
<li><p><code>:weather IS NULL OR t.weather = :weather</code><br>→ weather 파라미터가 null이면 조건 무시 (모든 날씨 허용)<br>→ 값이 있으면 해당 값과 일치하는 항목만 필터링</p>
</li>
<li><p><code>:start IS NULL OR t.modifiedAt &gt;= :start</code><br>→ 시작일이 null이면 조건 무시<br>→ 값이 있으면 해당 시점 이후의 데이터만 필터링</p>
</li>
<li><p><code>:end IS NULL OR t.modifiedAt &lt;= :end</code><br>→ 종료일이 null이면 조건 무시<br>→ 값이 있으면 해당 시점 이전의 데이터만 필터링</p>
</li>
</ul>
<hr>
<h2 id="개념">개념</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Dynamic Filter</strong></td>
<td>조건 값이 null인지 여부에 따라 WHERE 절 조건을 유동적으로 포함하거나 생략</td>
</tr>
<tr>
<td><strong>Optional Query Parameter</strong></td>
<td>검색 요청 시 클라이언트가 보낼 수도, 생략할 수도 있는 파라미터</td>
</tr>
<tr>
<td><strong>null check + OR 패턴</strong></td>
<td><code>(:param IS NULL OR ...)</code>을 통해 조건 생략 가능하게 만듦</td>
</tr>
<tr>
<td><strong>@Query</strong></td>
<td>JPQL로 커스텀 쿼리를 직접 작성하여 동적 조건 적용 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="언제-사용할까">언제 사용할까?</h2>
<ul>
<li>검색 필터 기능을 구현할 때 (예: 기간, 상태, 카테고리 등)</li>
<li>복잡한 조건 조합을 단일 메서드로 처리하고 싶을 때</li>
<li>QueryDSL이 없는 프로젝트에서 유연한 조건 처리가 필요할 때</li>
</ul>
<hr>
<h2 id="팁">팁</h2>
<ul>
<li>쿼리 길이가 너무 길어지면 <code>QueryBuilder</code>로 분리하거나 QueryDSL 도입 고려</li>
<li><code>@Query</code> 방식은 빠르게 구현 가능하지만, 복잡한 조합에는 한계가 있음</li>
<li>조건이 많아질수록 <code>Specification</code> 또는 <code>Criteria API</code> 방식과 비교해보는 것이 좋음</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<blockquote>
<p><strong>동적 필터링(Dynamic Filter)</strong>은 <code>null</code> 조건을 이용해 JPQL에서 다양한 검색 조건을 유연하게 적용하는 기법입니다.<br>불필요한 쿼리 메서드의 중복을 줄이고, 검색 기능 구현에 매우 유용합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Self-invocation]]></title>
            <link>https://velog.io/@4ou_chan/Spring-Self-invocation</link>
            <guid>https://velog.io/@4ou_chan/Spring-Self-invocation</guid>
            <pubDate>Tue, 24 Jun 2025 10:27:41 GMT</pubDate>
            <description><![CDATA[<h1 id="self-invocation이란">Self-invocation이란?</h1>
<ul>
<li>자기 자신의 메서드를 호출하는 것입니다.</li>
</ul>
<h2 id="그게-왜-문제일까">그게 왜 문제일까?</h2>
<p><code>Spring AOP</code>관점에서 살펴보겠습니다.</p>
<ul>
<li><code>AOP</code>기능은 반드시 프록시 객체를 통해 메서드를 호출해야만 작동합니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/db8c8889-9542-44ab-b74b-f4e9a42ba0e3/image.png" alt=""></p>
<p> 이 때 <code>Self-invocation</code>이 발생할 경우, 프록시 객체가 아닌 진짜 자기 자신의 메서드를 호출하므로 <code>AOP</code>가 적용되지 않습니다.</p>
<p> <img src="https://velog.velcdn.com/images/4ou_chan/post/3c93dbc1-fe00-4a55-82bf-df6ec76882ef/image.png" alt=""></p>
<h3 id="예시">예시</h3>
<pre><code> @Service
public class UserService {

    @Transactional
    public void registerUser() {
        saveUser(); // ← Self-invocation 발생!
    }

    @Transactional
    public void saveUser() {
        userRepository.save(...);
    }
}</code></pre><p> <code>UserService</code>클래스에 <code>@Transactional</code>을 사용한 두 메서드가 있습니다.</p>
<p> 그러나 <code>registerUser</code>메서드 내부에서 <code>saveUser</code>메서드를 호출합니다.</p>
<p> 즉 <code>registerUser</code>메서드가 프록시 객체를 거치지 않고 직접 <code>saveUser</code>메서드를 호출하였으므로, <code>@Transactional</code>어노테이션이 적용되지 않습니다.</p>
<p> 따라서 <code>saveUser</code>메서드에서 예외가 발생하게 되더라도, 롤백이 되지 않습니다.</p>
<p> 우리가 기대한것은 예외가 발생할 경우 유저가 등록되지 않고 롤백 되어야하는데, 유저 정보가 <code>DB</code>에 남고, 예외가 발생하게됩니다.</p>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1">1.</h3>
<pre><code>  @Service
public class UserService {

    @Transactional
    public void registerUser() {
        saveUser(); // ← Self-invocation 발생!
    }

//  ↓ 어노테이션 제거
    public void saveUser() {
        userRepository.save(...);
    }
}</code></pre><p><code>saveUser</code>에서 <code>@Transactional</code>을 제거하면, 해당 메서드는 별도의 트랜잭션을 생성하지 않고,
이미 트랜잭션이 적용된 <code>registerUser</code>의 트랜잭션 범위 내에서 실행되므로 <code>Self-invocation</code> 문제가 발생하지 않습니다.</p>
<h3 id="2">2.</h3>
<pre><code>@Service
public class UserService {
    private final UserInternalService internal;

    @Transactional
    public void registerUser() {
        internal.saveUser();  // 프록시를 통해 호출됨
    }
}</code></pre><pre><code>@Service
public class UserInternalService {

    @Transactional
    public void saveUser() {
        // 트랜잭션 적용됨
    }
}</code></pre><p>위 코드와 같이 서비스를 분리할 경우 </p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/153948eb-d6fa-4b9f-ad15-8e59d8352e99/image.png" alt=""></p>
<p><code>UserInternalService</code>는 <code>Spring</code>에 의해 프록시로 감싸진 객체이며,
<code>UserService</code>에서 이를 주입받아 호출하면 프록시를 통해 <code>saveUser</code>가 실행되므로,
<code>@Transactional</code>이 정상적으로 적용됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 트랜잭션]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98</link>
            <guid>https://velog.io/@4ou_chan/Spring-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98</guid>
            <pubDate>Mon, 23 Jun 2025 10:47:38 GMT</pubDate>
            <description><![CDATA[<h2 id="트랜잭션이란">트랜잭션이란?</h2>
<p>트랜잭션(Transaction)은 데이터베이스에서 <strong>하나의 논리적 작업 단위</strong>를 의미합니다. 여러 SQL 명령을 하나의 단위로 묶어 <strong>모두 성공하거나 모두 실패해야만</strong> 데이터 정합성을 유지할 수 있습니다.</p>
<h3 id="예시">예시</h3>
<ul>
<li>A → B 계좌 이체 작업  <ul>
<li>A의 잔액 차감  </li>
<li>B의 잔액 증가<br>→ 하나라도 실패하면 <strong>전체 롤백</strong>되어야 함.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="트랜잭션의-4가지-특성-acid">트랜잭션의 4가지 특성 (ACID)</h2>
<table>
<thead>
<tr>
<th>속성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Atomicity (원자성)</strong></td>
<td>모든 작업이 전부 성공하거나 전부 실패해야 함</td>
</tr>
<tr>
<td><strong>Consistency (일관성)</strong></td>
<td>트랜잭션 전후의 데이터가 항상 일관성 유지</td>
</tr>
<tr>
<td><strong>Isolation (격리성)</strong></td>
<td>동시에 실행되는 트랜잭션 간 간섭 방지</td>
</tr>
<tr>
<td><strong>Durability (지속성)</strong></td>
<td>커밋된 트랜잭션은 시스템 오류 발생해도 유지됨</td>
</tr>
</tbody></table>
<h3 id="예시-1">예시</h3>
<ul>
<li><strong>Atomicity</strong><ul>
<li>주문 처리 중 결제는 되었는데 재고 차감 실패 → 전체 롤백</li>
</ul>
</li>
<li><strong>Consistency</strong><ul>
<li>외래키, 제약조건, 형식 등이 트랜잭션 후에도 여전히 만족되어야 함</li>
</ul>
</li>
<li><strong>Isolation</strong><ul>
<li>두 사용자가 동시에 같은 좌석을 예매하려고 할 때 충돌 방지</li>
</ul>
</li>
<li><strong>Durability</strong><ul>
<li>결제 완료 후 서버가 다운되어도 결제 내역은 보존되어야 함</li>
</ul>
</li>
</ul>
<hr>
<h2 id="트랜잭션-제어-명령어">트랜잭션 제어 명령어</h2>
<table>
<thead>
<tr>
<th>명령어</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>BEGIN</code></td>
<td>트랜잭션 시작</td>
</tr>
<tr>
<td><code>COMMIT</code></td>
<td>성공적으로 종료, 변경사항 반영</td>
</tr>
<tr>
<td><code>ROLLBACK</code></td>
<td>오류 발생 시 변경사항 취소</td>
</tr>
<tr>
<td><code>SAVEPOINT</code></td>
<td>중간 저장점 설정</td>
</tr>
<tr>
<td><code>RELEASE SAVEPOINT</code></td>
<td>저장점 해제</td>
</tr>
</tbody></table>
<h3 id="예시-2">예시</h3>
<pre><code class="language-sql">BEGIN;
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE accounts SET balance = balance + 10000 WHERE id = 2;
COMMIT;</code></pre>
<ul>
<li>중간에 오류 발생 시 <code>ROLLBACK</code>으로 두 작업 모두 취소 가능</li>
</ul>
<hr>
<h2 id="isolation-level-격리-수준">Isolation Level (격리 수준)</h2>
<p>트랜잭션 간 간섭을 얼마나 허용할 것인지 설정하는 기준입니다.</p>
<table>
<thead>
<tr>
<th>수준</th>
<th>허용 정도</th>
<th>발생할 수 있는 문제</th>
</tr>
</thead>
<tbody><tr>
<td>READ UNCOMMITTED</td>
<td>커밋 안 된 데이터 읽음</td>
<td>Dirty Read</td>
</tr>
<tr>
<td>READ COMMITTED</td>
<td>커밋된 데이터만 읽음</td>
<td>Non-repeatable Read</td>
</tr>
<tr>
<td>REPEATABLE READ</td>
<td>읽은 데이터 고정</td>
<td>Phantom Read</td>
</tr>
<tr>
<td>SERIALIZABLE</td>
<td>완벽한 격리, 가장 안전</td>
<td>성능 저하 가능</td>
</tr>
</tbody></table>
<h3 id="예시-3">예시</h3>
<ul>
<li><strong>Dirty Read</strong><br>T1이 수정했지만 아직 COMMIT 안 한 데이터를 T2가 읽음 → 이후 ROLLBACK되면 데이터 모순</li>
<li><strong>Non-repeatable Read</strong><br>T1이 조회한 데이터를 T2가 수정 → T1이 다시 조회 시 값 달라짐</li>
<li><strong>Phantom Read</strong><br>T1이 조건으로 조회한 후, T2가 새 데이터를 INSERT → T1이 같은 조건으로 다시 조회하면 새로운 행이 생김</li>
<li><strong>Serializable</strong><br>여러 트랜잭션이 직렬적으로 실행됨 → 모든 위 현상 방지 가능</li>
</ul>
<hr>
<h2 id="spring에서의-트랜잭션-관리">Spring에서의 트랜잭션 관리</h2>
<p>Spring에서는 <code>@Transactional</code> 어노테이션으로 트랜잭션을 선언적(명시적 코드 없이)으로 설정할 수 있습니다.</p>
<pre><code class="language-java">@Transactional
public void transfer(Long fromId, Long toId, int amount) {
    accountRepository.withdraw(fromId, amount);
    accountRepository.deposit(toId, amount);
}</code></pre>
<h3 id="예시-설명">예시 설명</h3>
<ul>
<li>위 메서드 내에서 오류 발생 시 <code>withdraw()</code>도 자동으로 롤백</li>
<li>예외가 발생하지 않으면 <code>COMMIT</code> 처리됨</li>
</ul>
<hr>
<h2 id="예외와-롤백-조건">예외와 롤백 조건</h2>
<ul>
<li>기본적으로 <strong>RuntimeException</strong> 발생 시 자동으로 롤백됨</li>
<li>CheckedException은 기본적으로 롤백되지 않음 → 명시적으로 설정 가능</li>
</ul>
<pre><code class="language-java">@Transactional(rollbackFor = SQLException.class)
public void doSomething() throws SQLException {
    // 예외 발생 시 롤백됨
}</code></pre>
<hr>
<h2 id="트랜잭션과-테스트">트랜잭션과 테스트</h2>
<ul>
<li>테스트 클래스나 메서드에 <code>@Transactional</code>을 붙이면 테스트 종료 시 자동으로 롤백되어 DB 상태가 유지됨</li>
</ul>
<pre><code class="language-java">@SpringBootTest
@Transactional
public class UserServiceTest {
    // 테스트 DB 변경 후 자동 롤백됨
}</code></pre>
<hr>
<p> 트랜잭션은 단순한 DB 조작이 아닌, <strong>데이터 무결성과 동시성 문제를 방지</strong>하기 위한 핵심 개념입니다. 제대로 이해하고 설계에 반영하는 것이 안정적인 서비스 운영의 출발점입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring AOP]]></title>
            <link>https://velog.io/@4ou_chan/Spring-AOP</link>
            <guid>https://velog.io/@4ou_chan/Spring-AOP</guid>
            <pubDate>Tue, 17 Jun 2025 10:45:49 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-aop-정리--개념부터-로깅-적용까지">Spring AOP 정리 – 개념부터 로깅 적용까지</h1>
<h2 id="1-aop란">1. AOP란?</h2>
<p>AOP(Aspect-Oriented Programming, <strong>관점 지향 프로그래밍</strong>)은 <strong>공통된 기능(=횡단 관심사)</strong>을 분리하여 <strong>핵심 비즈니스 로직과 독립적으로 관리</strong>하기 위한 프로그래밍 패러다임입니다.</p>
<ul>
<li>예: 로깅, 트랜잭션, 보안, 성능 측정 등</li>
</ul>
<p>핵심 로직을 건드리지 않고도 공통 기능을 모듈화할 수 있습니다.</p>
<hr>
<h2 id="2-핵심-개념-정리">2. 핵심 개념 정리</h2>
<table>
<thead>
<tr>
<th>용어</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>횡단 관심사(Cross-cutting Concern)</strong></td>
<td>모든 서비스에 걸쳐 중복되는 기능 (ex. 로깅, 인증)</td>
</tr>
<tr>
<td><strong>어드바이스(Advice)</strong></td>
<td>횡단 관심사를 구현한 실제 실행 코드</td>
</tr>
<tr>
<td><strong>포인트컷(Pointcut)</strong></td>
<td>어드바이스가 적용될 지점(메서드 범위)을 지정하는 표현식</td>
</tr>
<tr>
<td><strong>조인포인트(JoinPoint)</strong></td>
<td>어드바이스가 실행될 수 있는 구체적인 실행 지점 (메서드 실행 시점 등)</td>
</tr>
<tr>
<td><strong>타겟(Target)</strong></td>
<td>어드바이스가 적용되는 실제 객체 (ex. 서비스 클래스)</td>
</tr>
<tr>
<td><strong>에스팩트(Aspect)</strong></td>
<td>어드바이스 + 포인트컷을 합친 모듈 단위</td>
</tr>
<tr>
<td><strong>@Transactional</strong></td>
<td>내부적으로 AOP로 구현된 대표적인 어노테이션</td>
</tr>
<tr>
<td><strong>@Aspect</strong></td>
<td>클래스를 AOP 컴포넌트로 정의하는 어노테이션</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-어드바이스-종류">3. 어드바이스 종류</h2>
<table>
<thead>
<tr>
<th>어노테이션</th>
<th>실행 시점</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Before</code></td>
<td>메서드 실행 전</td>
<td>사전 작업 수행</td>
</tr>
<tr>
<td><code>@After</code></td>
<td>메서드 실행 후 (성공/실패 모두)</td>
<td>무조건 실행</td>
</tr>
<tr>
<td><code>@AfterReturning</code></td>
<td>메서드 정상 종료 후</td>
<td>반환값 처리 가능</td>
</tr>
<tr>
<td><code>@AfterThrowing</code></td>
<td>예외 발생 시</td>
<td>예외 로깅 등에 사용</td>
</tr>
<tr>
<td><code>@Around</code></td>
<td>전/후/예외 모두 제어</td>
<td>실행 시간 측정, 전체 흐름 제어 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-실전-예제--메서드-실행-시간-로깅">4. 실전 예제 – 메서드 실행 시간 로깅</h2>
<pre><code class="language-java">@Aspect
public class AopPractice {

    private static final Logger log = LoggerFactory.getLogger(AopPractice.class);

    // [1] 포인트컷: service 패키지 하위 전체 메서드
    @Pointcut(&quot;execution(* com.spring.basic.service..*(..))&quot;)
    public void serviceLayerPointCut() {}

    // [2] 포인트컷: @TrackTime 어노테이션이 붙은 메서드
    @Pointcut(&quot;@annotation(com.spring.basic.aop.TrackTime)&quot;)
    public void trackTimePointCut() {}

    // [3] 어드바이스 - 실행 전
    @Before(&quot;serviceLayerPointCut()&quot;)
    public void beforeMethod() {
        log.info(&quot;@Before&quot;);
    }

    // [4] 어드바이스 - 정상 실행 후
    @AfterReturning(pointcut = &quot;serviceLayerPointCut()&quot;, returning = &quot;result&quot;)
    public void afterReturningMethod(Object result) {
        log.info(&quot;@AfterReturning&quot;);
        log.info(&quot;::: result : {}&quot;, result.getClass());
    }

    // [5] 어드바이스 - 예외 발생 시
    @AfterThrowing(pointcut = &quot;serviceLayerPointCut()&quot;, throwing = &quot;exception&quot;)
    public void afterThrowingMethod(Throwable exception) {
        log.info(&quot;@AfterThrowing&quot;);
        log.info(&quot;::: exception: {}&quot;, exception.getClass());
    }

    // [6] 어드바이스 - 무조건 실행
    @After(&quot;serviceLayerPointCut()&quot;)
    public void AfterMethod() {
        log.info(&quot;@After&quot;);
    }

    // [7] 어드바이스 - Around 위 모든 경우에 사용 가능 ( ex)시간 측정 )
    @Around(&quot;trackTimePointCut()&quot;)
    public Object AroundMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        try {
            Object proceed = joinPoint.proceed();
            log.info(&quot;@AfterReturning&quot;);
            return proceed;
        } catch (Throwable e) {
            log.info(&quot;@AfterThrowing&quot;);
            throw e;
        } finally {
            long endTime = System.currentTimeMillis();
            long executionTime = endTime - startTime;
            log.info(&quot;::: executionTime: {}ms&quot;, executionTime);
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Outsourcing 프로젝트 진행 중 어려웠던 부분]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%95%84%EC%9B%83%EC%86%8C%EC%8B%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A7%84%ED%96%89-%EC%A4%91-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98-%EB%B6%80%EB%B6%84</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%95%84%EC%9B%83%EC%86%8C%EC%8B%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%A7%84%ED%96%89-%EC%A4%91-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98-%EB%B6%80%EB%B6%84</guid>
            <pubDate>Mon, 16 Jun 2025 11:53:18 GMT</pubDate>
            <description><![CDATA[<h1 id="문제-1">문제 1</h1>
<ul>
<li>댓글 삭제 기능을 만든 뒤 <code>Postman</code>에서 테스트를 해보려는데, 반환값이 출력되지 않는 문제가 발생했습니다.<ul>
<li>해결과정 : <code>HTTP Method</code>가 <code>204 NO CONTENT</code>일 경우 <code>Body</code>를 반환할 수 없다는 사실을 알게되어, <code>200 OK</code>로 변경했습니다. (API 명세서도 수정했습니다.)</li>
</ul>
</li>
</ul>
<h1 id="문제2">문제2</h1>
<p> <code>git pull → git push</code> 과정에서 <code>pull</code>받은 후 충돌을 해결한 뒤, 모든 내용을 <code>commit</code>하지 않고 머지시킬 경우 다른 사람의 작업 내용이 날아가는 문제를 겪었습니다.</p>
<ul>
<li><p>해결과정 : <code>git log → git reset --hard fbd18de</code> 우선 커밋 로그를 확인한 후 리셋을 통해 원하는 시점으로 돌아간 후 해당 부분을 해결하여 다시 <code>push</code>했습니다.</p>
<ul>
<li>(제 커밋이 가장 최근 시점이었기에 이 방법을 택했습니다. 하드리셋을 할 경우 다른 팀원의 커밋 로그가 날아갈 수 있습니다.)</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Validation]]></title>
            <link>https://velog.io/@4ou_chan/Spring-Validation</link>
            <guid>https://velog.io/@4ou_chan/Spring-Validation</guid>
            <pubDate>Thu, 12 Jun 2025 11:39:12 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-validation이란">Spring Validation이란?</h1>
<p>Spring Validation은 <strong>Spring Framework</strong>에서 제공하는 <strong>입력값 검증 기능</strong>으로, 사용자의 입력값이 미리 정해진 조건에 부합하는지를 확인하는 데 사용됩니다.<br>이 기능은 Bean Validation 2.0(JSR-380) 스펙을 기반으로 작동하며, 대표적인 구현체는 Hibernate Validator입니다.</p>
<hr>
<h2 id="검증-기능의-목적">검증 기능의 목적</h2>
<p>Spring Validation의 주요 목적은 다음과 같습니다:</p>
<ul>
<li><strong>서비스 로직 보호</strong>: 잘못된 데이터가 핵심 로직에 도달하는 것을 방지합니다.</li>
<li><strong>사용자 응답 처리</strong>: 오류 발생 시 적절한 에러 메시지를 사용자에게 전달합니다.</li>
<li><strong>객체 무결성 보장</strong>: 엔터티나 DTO가 비정상적인 상태로 시스템에 유입되지 않도록 합니다.</li>
</ul>
<hr>
<h2 id="주요-구성-요소-및-개념">주요 구성 요소 및 개념</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Valid</code></td>
<td>어노테이션으로, Bean Validation을 수행합니다. 기본적인 검증만 가능하며, 그룹 검증은 불가합니다.</td>
</tr>
<tr>
<td><code>@Validated</code></td>
<td>어노테이션으로, AOP 기반이며 그룹 검증도 지원합니다. <code>@Service</code>, <code>@Controller</code> 어디서든 적용 가능.</td>
</tr>
<tr>
<td>제약 어노테이션</td>
<td>검증 규칙을 정의하는 어노테이션들. 예: <code>@NotNull</code>, <code>@Size</code>, <code>@Min</code>, <code>@Max</code>, <code>@Email</code> 등</td>
</tr>
<tr>
<td><code>BindingResult</code></td>
<td>검증 실패 시 오류 정보를 담는 객체로, 컨트롤러 메서드에서 <code>@Valid</code>나 <code>@Validated</code> 다음에 선언하여 사용해야 함</td>
</tr>
<tr>
<td><code>Validator</code> 인터페이스</td>
<td>직접 검증 로직을 작성할 때 사용하는 인터페이스. Spring의 <code>LocalValidatorFactoryBean</code>이 기본 구현체로 등록됨</td>
</tr>
<tr>
<td>Bean Validation</td>
<td>자바 표준 검증 API로, <code>javax.validation</code> 패키지를 기반으로 정의된 스펙. Hibernate Validator가 대표 구현체입니다.</td>
</tr>
</tbody></table>
<hr>
<h2 id="spring-validation-동작-방식-검증-흐름">Spring Validation 동작 방식 (검증 흐름)</h2>
<p>Spring Validation은 다음과 같은 순서로 동작합니다:</p>
<ol>
<li><p><strong>검증 대상 객체에 어노테이션 추가</strong></p>
<ul>
<li>DTO 클래스에 <code>@NotNull</code>, <code>@Size</code>, <code>@Email</code> 등 제약 조건을 선언합니다.</li>
<li>컨트롤러 메서드 파라미터에 <code>@Valid</code> 또는 <code>@Validated</code>를 선언합니다.</li>
</ul>
<pre><code class="language-java">public ResponseEntity&lt;?&gt; createUser(@Valid @RequestBody UserDto dto, BindingResult result) { ... }</code></pre>
</li>
<li><p><strong>Spring이 Validator를 통해 자동 검증 수행</strong></p>
<ul>
<li><code>LocalValidatorFactoryBean</code>을 통해 Hibernate Validator가 동작하며, 선언된 제약 조건을 기준으로 DTO를 자동 검증합니다.</li>
</ul>
</li>
<li><p><strong>검증 오류 발생 시 BindingResult에 에러 바인딩</strong></p>
<ul>
<li><code>BindingResult</code>가 파라미터로 선언된 경우, 예외를 발생시키지 않고 오류 내용을 해당 객체에 저장합니다.</li>
<li>선언되지 않았다면, <code>MethodArgumentNotValidException</code> 등의 예외가 발생합니다.</li>
</ul>
</li>
<li><p><strong>오류 메시지를 기반으로 사용자 응답 처리</strong></p>
<ul>
<li>오류 정보를 바탕으로 에러 응답을 생성하거나, 뷰로 다시 이동시킬 수 있습니다.</li>
<li>REST API인 경우 <code>@RestControllerAdvice</code> 등으로 글로벌 처리도 가능합니다.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="자주-쓰는-제약-어노테이션">자주 쓰는 제약 어노테이션</h2>
<table>
<thead>
<tr>
<th>어노테이션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@NotNull</code></td>
<td>값이 null이 아니어야 함</td>
</tr>
<tr>
<td><code>@NotBlank</code></td>
<td>문자열이 null이 아니고, 공백이 아니어야 함</td>
</tr>
<tr>
<td><code>@Size</code></td>
<td>문자열, 컬렉션 등의 크기 제한</td>
</tr>
<tr>
<td><code>@Email</code></td>
<td>이메일 형식 유효성 검사</td>
</tr>
<tr>
<td><code>@Pattern</code></td>
<td>정규표현식을 통한 문자열 패턴 검사</td>
</tr>
<tr>
<td><code>@Min</code>, <code>@Max</code></td>
<td>숫자 범위 제한</td>
</tr>
</tbody></table>
<hr>
<h2 id="validated-vs-valid-차이"><code>@Validated</code> vs <code>@Valid</code> 차이</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th><code>@Valid</code></th>
<th><code>@Validated</code></th>
</tr>
</thead>
<tbody><tr>
<td>제공 위치</td>
<td><code>javax.validation</code></td>
<td><code>org.springframework.validation</code></td>
</tr>
<tr>
<td>그룹 검증 지원</td>
<td>❌ 지원하지 않음</td>
<td>✅ 그룹 검증 가능 (<code>@Validated(groups=...)</code>)</td>
</tr>
<tr>
<td>대상</td>
<td>주로 Controller 파라미터</td>
<td>Controller, Service, AOP 등 광범위 사용 가능</td>
</tr>
<tr>
<td>내부 처리 방식</td>
<td>직접 Bean Validation 호출</td>
<td>Spring AOP 기반으로 처리됨</td>
</tr>
</tbody></table>
<hr>
<h2 id="bindingresult란">BindingResult란?</h2>
<ul>
<li><code>BindingResult</code>는 검증 후의 결과를 담는 객체입니다.</li>
<li><code>@Valid</code> 또는 <code>@Validated</code>와 함께 사용하면 검증 실패 시 예외가 발생하지 않고, 오류 정보를 확인할 수 있습니다.</li>
<li>반드시 <strong>검증 대상 파라미터 직후</strong>에 선언해야 정상 작동합니다.</li>
</ul>
<pre><code class="language-java">public String submitForm(@Valid FormDto dto, BindingResult result) {
    if (result.hasErrors()) {
        return &quot;formPage&quot;; // 에러 있는 경우
    }
    return &quot;redirect:/success&quot;;
}</code></pre>
<hr>
<h2 id="글로벌-예외-처리와-연계-controlleradvice">글로벌 예외 처리와 연계 (<code>@ControllerAdvice</code>)</h2>
<p>Spring Validation 실패 시 전역적으로 일관된 에러 응답을 만들고 싶다면 <code>@RestControllerAdvice</code>와 함께 사용할 수 있습니다.</p>
<h3 id="발생하는-주요-예외">발생하는 주요 예외</h3>
<ul>
<li><code>MethodArgumentNotValidException</code> → DTO 검증 실패</li>
<li><code>ConstraintViolationException</code> → 파라미터 수준 검증 실패 (<code>@RequestParam</code>, <code>@PathVariable</code> 등)</li>
</ul>
<h3 id="예외-처리-예시">예외 처리 예시</h3>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity&lt;?&gt; handleValidation(MethodArgumentNotValidException ex) {
        Map&lt;String, String&gt; errors = new HashMap&lt;&gt;();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return ResponseEntity.badRequest().body(errors);
    }
}</code></pre>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 심화주차 개인 과제 진행 중 어려웠던 부분]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%8B%AC%ED%99%94%EC%A3%BC%EC%B0%A8-%EA%B0%9C%EC%9D%B8-%EA%B3%BC%EC%A0%9C-%EC%A7%84%ED%96%89-%EC%A4%91-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98-%EB%B6%80%EB%B6%84</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%8B%AC%ED%99%94%EC%A3%BC%EC%B0%A8-%EA%B0%9C%EC%9D%B8-%EA%B3%BC%EC%A0%9C-%EC%A7%84%ED%96%89-%EC%A4%91-%EC%96%B4%EB%A0%A4%EC%9B%A0%EB%8D%98-%EB%B6%80%EB%B6%84</guid>
            <pubDate>Wed, 11 Jun 2025 03:25:16 GMT</pubDate>
            <description><![CDATA[<h1 id="1entitygraph">1.@EntityGraph</h1>
<p>Lv.2 과제 진행 사항에 <code>fetch join</code>을 사용하여 <code>N+1</code> 문제를 해결하고 있는 <code>TodoRepository</code>를 동일하게 동작하는 <code>@EntityGraph</code>기반의 구현으로 수정하는 부분이 있었습니다.</p>
<p>우선 <code>@EntityGraph</code>가 무엇인지 간단하게 알아보았습니다.</p>
<p>간단하게 말해  <code>fetch join</code>을 어노테이션으로 사용할 수 있도록 만들어 준 기능이라는 사실을 알았습니다.</p>
<p>여기서 의문이 생겼습니다.</p>
<blockquote>
<p>그냥 쿼리문 안에 <code>JOIN FETCH</code> 쓰면 굳이 어노테이션 추가 안해도 되는 거 아닌가? 왜 <code>@EntityGraph</code>를 사용하지?</p>
</blockquote>
<p>하여 둘의 차이점을 알아보았습니다.</p>
<p><a href="https://velog.io/@4ou_chan/Spring-EntityGraph-%EC%99%9C-%EC%8D%A8%EC%95%BC%ED%95%A0%EA%B9%8C#entitygraph%EC%99%80-fetch-join%EC%9D%98-%EC%B0%A8%EC%9D%B4">@EntityGraph를 사용하는 이유 및 상황 내용 정리 블로그</a></p>
<pre><code>    @Query(&quot;SELECT t FROM Todo t LEFT JOIN FETCH t.user u ORDER BY t.modifiedAt DESC&quot;)
    Page&lt;Todo&gt; findAllByOrderByModifiedAtDesc(Pageable pageable);</code></pre><p>위 코드를 </p>
<pre><code>    @Query(&quot;SELECT t FROM Todo t ORDER BY t.modifiedAt DESC&quot;)
    @EntityGraph(attributePaths = {&quot;user&quot;})
    Page&lt;Todo&gt; findAllByOrderByModifiedAtDesc(Pageable pageable);</code></pre><p>이와 같이 수정하여 해결했습니다.</p>
<h1 id="2-assertionfailederror">2. AssertionFailedError</h1>
<p>Lv.3-1의 테스트코드 수정 과정에서 <code>AssertionFailedError</code>에러가 발생했습니다.</p>
<p>검색을 통해 테스트를 수행할 때 기대한 값과 실제 값이 일치하지 않을 경우 발생하는 에러라는 것을 알았습니다.</p>
<pre><code>@ExtendWith(SpringExtension.class)
class PasswordEncoderTest {

    @InjectMocks
    private PasswordEncoder passwordEncoder;

    @Test
    void matches_메서드가_정상적으로_동작한다() {
        // given
        String rawPassword = &quot;testPassword&quot;;
        String encodedPassword = passwordEncoder.encode(rawPassword);

        // when
        boolean matches = passwordEncoder.matches(encodedPassword, rawPassword);

        // then
        assertTrue(matches);
    }
}</code></pre><p>위 코드는 저장된 비밀번호와 입력한 비밀번호를 비교하는 테스트 로직입니다.</p>
<p>처음 테스트 코드를 살펴보았을 때 크게 문제가 될만한 부분을 찾지 못했습니다.
하여 테스트 로직을 처음부터 다시 짚어보았습니다.</p>
<p><code>given</code>부분 (테스트 데이터 준비 부분)에서
<code>rawPassword</code>는 <code>testPassword</code>이고,
<code>encodedPassword</code>는 <code>passwordEncoder</code>의 기능인 <code>encoder</code>로 <code>rawPassword</code>를 인코딩한 값을 담고있습니다.</p>
<p><code>when</code>부분 (실제 테스틀 로직)에서 
<code>boolean</code>타입의 변수 <code>matches</code>에 <code>passwordEncoder</code>의 기능인 <code>matches</code>를 이용하여 <code>encodedPassword</code>와 <code>testPassword</code>가 같다면 <code>true</code>, 다르면 <code>false</code>가 반환되도록 합니다.</p>
<p><code>then</code>부분 (테스트 검증 로직)에서
<code>assertTrue</code>로 <code>matches</code>의 값이 <code>true</code>인지 확인합니다.</p>
<p>위 흐름에서 인코딩한 데이터의 값도 <code>testPassword</code>이고, 비교 대상 값도 <code>testPassword</code>인데, 테스트는 실패하는 상황이었습니다.</p>
<p>따라서 비밀번호 인코딩 과정에서 문제가 발생했을 수 있겠다는 생각으로, <code>PasswordEncoder</code>클래스를 확인했습니다.</p>
<p>(<code>PasswordEncoder</code>클래스입니다.)</p>
<pre><code>@Component
public class PasswordEncoder {

    public String encode(String rawPassword) {
        return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    public boolean matches(String rawPassword, String encodedPassword) {
        BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
        return result.verified;
    }
}</code></pre><p><code>PasswordEncoder</code>클래스를 확인해보니 사용중인 <code>matches</code>메서드의 매개변수는 (<code>rawPassword</code>, <code>encodedPassword</code>)의 구조인 반면, 테스트 코드는 (<code>encodedPassword</code>, <code>rawPassword</code>)로 사용중인 사실을 확인했습니다.</p>
<p>따라서 테스트 코드의 <code>when</code>부분을 다음과 같이 수정하였습니다.</p>
<pre><code>        // when
        boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
</code></pre><p>이 후 테스트해보니 정상적으로 테스트가 완료되었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring @EntityGraph 왜 써야할까?]]></title>
            <link>https://velog.io/@4ou_chan/Spring-EntityGraph-%EC%99%9C-%EC%8D%A8%EC%95%BC%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@4ou_chan/Spring-EntityGraph-%EC%99%9C-%EC%8D%A8%EC%95%BC%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Wed, 11 Jun 2025 03:18:48 GMT</pubDate>
            <description><![CDATA[<p><strong>@EntityGraph와 Fetch Join은 모두 N+1 문제를 해결하기 위해 사용하는 도구입니다.</strong></p>
<h2 id="entitygraph란">@EntityGraph란?</h2>
<ul>
<li><p><code>JPA</code>에서 연관된 엔티티를 즉시 로딩(Fetch Join) 하도록 선언하는 방법입니다.
내부적으로 <code>SQL</code>문의 <code>JOIN FETCH</code>와 유사한 <code>SQL</code>을 자동 생성해줍니다.</p>
</li>
<li><p><code>JPQL</code> 없이도 선언적으로 <code>Fetch Join</code> 사용이 가능합니다.</p>
</li>
</ul>
<h2 id="fetch-join-사용-방법">Fetch join 사용 방법</h2>
<ul>
<li>쿼리문 안에 <code>JOIN FETCH</code>를 작성하여 사용합니다.</li>
</ul>
<pre><code>@Query(&quot;SELECT p FROM Post p JOIN FETCH p.author&quot;)
List&lt;Post&gt; findAllWithAuthor();</code></pre><p>즉 위의 코드는 <code>Post</code>를 전체 조회 할 때 <code>author</code>필드도 함께 조회한다는 뜻을 지닙니다.</p>
<h2 id="entitygraph-사용-방법">@EntityGraph 사용 방법</h2>
<ul>
<li><code>JPA Repository</code> 인터페이스의 메서드에서 <code>@EntityGraph(attributePaths = {연관필드명})</code>으로 사용합니다.</li>
<li>fetch join 하고 싶은 연관 필드들의 경로를 지정해야 합니다.</li>
</ul>
<pre><code>@EntityGraph(attributePaths = {&quot;user&quot;})
List&lt;Post&gt; findAll();</code></pre><p>즉 위의 코드는 <code>Post</code>를 전체 조회 할 때 <code>user</code>필드도 함께 조회한다는 뜻을 지닙니다.</p>
<h1 id="entitygraph를-왜-써야할까">@EntityGraph를 왜 써야할까?</h1>
<p>그렇다면 두 기능 모두 <code>N + 1</code>문제를 해결하기 위해 사용하는 기능인데, <code>@EntityGraph</code>는 왜 사용해야 할까요?</p>
<p>저는 처음 <code>@EntityGraph</code>을 알게 되었을 때, 굳이 사용해야하나? 그냥 쿼리문 안에 <code>JOIN FETCH</code>쓰면 되는 거 아닌가? 하는 생각이 들었습니다.</p>
<p>그래서 어떤 상황에서 <code>@EntityGraph</code>를 사용하는지, 둘의 차이점에 대해 알아보았습니다.</p>
<h2 id="entitygraph와-fetch-join의-차이">@EntityGraph와 Fetch join의 차이</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>@EntityGraph</th>
<th>Fetch Join (<code>JOIN FETCH</code>)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>사용 위치</strong></td>
<td>Spring Data JPA Repository 메서드</td>
<td>JPQL 쿼리 (<code>@Query</code> 또는 EntityManager 사용 시)</td>
</tr>
<tr>
<td><strong>문법 형태</strong></td>
<td><code>@EntityGraph(attributePaths = {&quot;user&quot;})</code></td>
<td><code>SELECT p FROM Post p JOIN FETCH p.user</code></td>
</tr>
<tr>
<td><strong>쿼리 작성 필요 여부</strong></td>
<td>❌ 쿼리 없이 선언적으로 지정</td>
<td>✅ 직접 JPQL 쿼리 작성 필요</td>
</tr>
<tr>
<td><strong>가독성 및 유지보수</strong></td>
<td>✅ 깔끔하고 선언적</td>
<td>❌ 복잡한 쿼리 구조 가능성 있음</td>
</tr>
<tr>
<td><strong>중첩 fetch 경로 지원</strong></td>
<td>✅ <code>&quot;user.company&quot;</code>처럼 중첩 필드 fetch 가능</td>
<td>✅ JOIN을 여러 번 명시해 중첩 fetch 가능</td>
</tr>
<tr>
<td><strong>조건부 조인 가능 여부</strong></td>
<td>❌ 불가능 (조건 지정 불가)</td>
<td>✅ WHERE, ON 조건 자유롭게 사용 가능</td>
</tr>
<tr>
<td><strong>페이징(Pageable) 호환</strong></td>
<td>✅ 단일 관계(@ManyToOne 등)에서는 안전하게 사용 가능</td>
<td>⚠️ 컬렉션 조인 시 페이징 깨짐</td>
</tr>
<tr>
<td><strong>쿼리 재사용성</strong></td>
<td>✅ NamedEntityGraph로 재사용 가능</td>
<td>❌ 반복 쿼리 직접 작성해야 함</td>
</tr>
<tr>
<td><strong>N+1 문제 해결</strong></td>
<td>✅ 가능</td>
<td>✅ 가능</td>
</tr>
<tr>
<td><strong>JPA 표준 지원 여부</strong></td>
<td>✅ (<code>jakarta.persistence.EntityGraph</code>)</td>
<td>✅ (<code>JOIN FETCH</code>는 JPQL 표준 문법)</td>
</tr>
<tr>
<td><strong>대표 용도</strong></td>
<td>단순한 fetch join, 반복되는 fetch 로직 선언</td>
<td>복잡한 조건 포함, 고도화된 쿼리 필요 시</td>
</tr>
</tbody></table>
<h3 id="작동-방식의-차이">작동 방식의 차이</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>@EntityGraph</th>
<th>Fetch Join (<code>JOIN FETCH</code>)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>쿼리 생성 주체</strong></td>
<td>Spring Data JPA가 메서드 분석 후 내부적으로 JPQL + JOIN FETCH 생성</td>
<td>개발자가 작성한 JPQL 그대로 사용됨</td>
</tr>
<tr>
<td><strong>기본 조인 전략</strong></td>
<td>항상 <code>LEFT JOIN FETCH</code> (null 값 포함 가능성 고려)</td>
<td>기본은 <code>INNER JOIN FETCH</code>, 명시적으로 LEFT 변경 가능</td>
</tr>
<tr>
<td><strong>쿼리 실행 시점</strong></td>
<td>Repository 메서드 호출 시, 동적으로 쿼리 생성 및 실행</td>
<td>JPQL이 컴파일 시점에 고정됨</td>
</tr>
<tr>
<td><strong>JPQL에서 select 절 조작</strong></td>
<td>불가 – 항상 루트 엔티티 기준으로 select</td>
<td>가능 – 필요한 필드만 <code>SELECT new DTO(...)</code>로 지정 가능</td>
</tr>
<tr>
<td><strong>쿼리 결과 제어 가능성</strong></td>
<td>제한적 – fetch 대상만 제어 가능</td>
<td>높음 – select, where, order by, join on 등 자유롭게 지정 가능</td>
</tr>
<tr>
<td><strong>쿼리 해석의 유연성</strong></td>
<td>자동 처리 – 단순 fetch join 목적에 최적화</td>
<td>수동 처리 – 복잡한 로직 및 성능 튜닝 시 유리</td>
</tr>
<tr>
<td><strong>결과 중복 제거 처리</strong></td>
<td>기본적으로 필요 없음 (단일 연관일 때)</td>
<td>컬렉션 fetch 시 중복 가능 → <code>DISTINCT</code> 필요</td>
</tr>
<tr>
<td><strong>페치 대상 지정 방식</strong></td>
<td><code>attributePaths</code> 문자열로 경로 지정</td>
<td><code>JOIN FETCH</code>로 명시적 경로 지정</td>
</tr>
<tr>
<td><strong>join fetch 최적화 전략</strong></td>
<td>JPA 구현체가 내부적으로 최적화 적용 가능</td>
<td>작성한 쿼리에 따라 성능이 크게 달라짐</td>
</tr>
</tbody></table>
<hr>
<p>둘의 차이점을 알아보니 어떤 상황에서 <code>@EntityGraph</code>를 사용하는지에 대한 판단이 비교적 쉬워졌습니다.</p>
<h2 id="entitygraph를-사용해야-하는-대표적인-상황">@EntityGraph를 사용해야 하는 대표적인 상황</h2>
<table>
<thead>
<tr>
<th>번호</th>
<th>사용해야 하는 상황</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>JPQL 없이 간단하게 fetch join 하고 싶을 때</td>
<td>쿼리 작성 없이 메서드 위에 <code>@EntityGraph</code>만 선언하면 자동으로 fetch join 처리 가능</td>
</tr>
<tr>
<td>2</td>
<td>중첩된 연관 엔티티를 함께 로딩하고 싶을 때</td>
<td><code>&quot;user.company.department&quot;</code>처럼 깊은 연관 관계도 선언만으로 fetch 가능</td>
</tr>
<tr>
<td>3</td>
<td>반복되는 fetch join을 재사용하고 싶을 때</td>
<td><code>@NamedEntityGraph</code>로 선언하면 여러 메서드에서 일관되게 재사용 가능</td>
</tr>
<tr>
<td>4</td>
<td>쿼리 조건, DTO 매핑 없이 전체 엔티티 조회만 필요한 경우</td>
<td>복잡한 select 조건 없이 루트 엔티티와 연관 필드만 간단히 조회할 때 적합</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java 가운데 글자 가져오기 (프로그래머스 Lv1)]]></title>
            <link>https://velog.io/@4ou_chan/Java-%EA%B0%80%EC%9A%B4%EB%8D%B0-%EA%B8%80%EC%9E%90-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0</link>
            <guid>https://velog.io/@4ou_chan/Java-%EA%B0%80%EC%9A%B4%EB%8D%B0-%EA%B8%80%EC%9E%90-%EA%B0%80%EC%A0%B8%EC%98%A4%EA%B8%B0</guid>
            <pubDate>Mon, 09 Jun 2025 00:56:33 GMT</pubDate>
            <description><![CDATA[<h1 id="문제">문제</h1>
<p>프로그래머스 Lv.1 가운데 글자 가져오기 문제.</p>
<h2 id="문제-설명">문제 설명</h2>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/e99e6d40-cf79-477d-9bfb-e54a05bbc1e9/image.png" alt=""></p>
<h2 id="입출력-예시">입출력 예시</h2>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/6c6e3911-3104-4e5e-b1f1-4ac48f01ba79/image.png" alt=""></p>
<h2 id="작성-코드">작성 코드</h2>
<pre><code>class Solution {
    public String solution(String s) {
        String answer = &quot;&quot;;
        int strLength = s.length();


            answer = strLength % 2 != 0 
                ? s.substring(strLength / 2, strLength / 2 +1)
                : s.substring(strLength / 2 -1, strLength / 2 +1);


        return answer;
    }
}</code></pre><p>처음 문제를 접했을 때 들었던 생각은 문자열의 길이를 구하고, 홀수 짝수 여부를 판별해 가져올 글자의 범위를 정하면 되겠다는 생각이 들었습니다.</p>
<p>하여 문자열의 길이를 <code>strLength</code> 변수에 담아준 뒤, <code>if()</code>문을 사용해 홀수 짝수 여부를 판별하려 했습니다.</p>
<p>그러나 현재 문제에서 주어진 경우의 수가, 홀수와 짝수 두 가지밖에 없으므로, 삼항연산자를 사용해도 될 것 같다는 생각이 들었습니다.</p>
<p>리턴 함수인 <code>answer</code>를 <code>strLength % 2 != 0</code> <code>2</code>로 나누었을 때 <code>0</code>이 아니라면(글자의 길이가 홀수라면),</p>
<blockquote>
<p><strong><code>? s.substring(strLength / 2, strLength / 2 +1)</code></strong></p>
</blockquote>
<p><code>s.substring(strLength / 2,</code>
문자열 길이를 <code>2</code>로 나눈 범위부터
(입 출력 예시의 글자수는 5이므로 <code>/ 2</code> 했을 때 <code>2</code>) 즉 <code>index 2</code>부터</p>
<p><code>strLength / 2 +1)</code> 
(<code>index 2 + 1</code>) 즉 <code>index 3</code> 전까지의 글자를 반환해줘.</p>
<p>입 출력 예시의 <code>&#39;abcde&#39;</code>에서 <code>index 2</code>는 <code>c</code>이고, <code>index 3</code>는 <code>d</code>입니다. 
따라서 글자의 길이가 홀수인 <code>&#39;abcde&#39;</code>의 경우 <code>c</code>부터 <code>d</code>전까지의 글자를 반환하므로,
가운데 글자인 <code>c</code>를 반환합니다.</p>
<blockquote>
<p><strong>: s.substring(strLength / 2 -1, strLength / 2 +1);</strong></p>
</blockquote>
<p>글자의 길이가 짝수라면,</p>
<p><code>s.substring(strLength / 2 -1</code> 문자열의 길이를 반으로 나눈 값의 <code>-1</code> 
(입 출력 예시의 글자수는 4이므로 <code>/ 2 -1</code>을 했을 때 <code>1</code>) 즉 <code>index 1</code>부터</p>
<p><code>strLength / 2 +1);</code>
(같은 맥락으로 <code>/ 2 + 1</code>을 했을 때 <code>3</code>) 즉 <code>index 3</code> 전까지의 글자를 반환해줘.</p>
<p>입 출력 예시의 <code>&#39;qwer&#39;</code>에서 <code>index 1</code>은 <code>w</code>이고, <code>index 3</code>은 <code>r</code>입니다. 
따라서 글자의 길이가 짝수인 <code>&#39;qwer&#39;</code>의 경우 <code>w</code>부터 <code>r</code>전까지의 글자를 반환하므로,
가운데 두 글자인 <code>we</code>를 반환합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 예외]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%98%88%EC%99%B8</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%98%88%EC%99%B8</guid>
            <pubDate>Thu, 05 Jun 2025 10:21:25 GMT</pubDate>
            <description><![CDATA[<h1 id="java-예외-처리와-controlleradvice를-통한-글로벌-예외-처리-전략">Java 예외 처리와 @ControllerAdvice를 통한 글로벌 예외 처리 전략</h1>
<hr>
<h2 id="예외란">예외란?</h2>
<p>자바에서는 예외(Exception)를 클래스 형태로 정의해두었으며, 크게 <strong>체크 예외</strong>와 <strong>언체크 예외</strong>로 나뉜다.</p>
<ul>
<li><strong>체크 예외</strong>: <code>Exception</code>을 상속받은 예외 중 <strong><code>RuntimeException</code>을 제외한 모든 예외</strong>이다.</li>
<li><strong>언체크 예외</strong>: <code>RuntimeException</code>과 그 하위 예외이다.</li>
</ul>
<hr>
<h2 id="예외-전가-throws">예외 전가 (throws)</h2>
<p>예외를 메서드 내부에서 직접 처리하지 않고 <strong>상위 계층으로 책임을 넘기고 싶을 때</strong>,  
메서드 선언부에 <code>throws 예외클래스명</code> 을 명시한다.</p>
<pre><code class="language-java">public void connect() throws IOException {
    // 예외 발생 가능 코드
}</code></pre>
<table>
<thead>
<tr>
<th>구분</th>
<th>체크 예외</th>
<th>언체크 예외</th>
</tr>
</thead>
<tbody><tr>
<td>정의</td>
<td><code>Exception</code> 중 <code>RuntimeException</code> 제외한 예외</td>
<td><code>RuntimeException</code> 및 하위 예외</td>
</tr>
<tr>
<td>처리 필요</td>
<td>반드시 처리해야 함 (컴파일 시 검사)</td>
<td>선택적으로 처리 가능 (컴파일러 검사 없음)</td>
</tr>
<tr>
<td>예시</td>
<td><code>IOException</code>, <code>SQLException</code></td>
<td><code>NullPointerException</code>, <code>IllegalArgumentException</code></td>
</tr>
<tr>
<td>사용 예</td>
<td>통신 장애, 파일 없음 등 서버가 복구 시도 가능</td>
<td>잘못된 입력, 인가 실패 등 클라이언트 오류</td>
</tr>
</tbody></table>
<hr>
<h2 id="예외-선택-기준">예외 선택 기준</h2>
<ul>
<li><p>서버에서 <strong>즉시 처리</strong>가 가능한가?<br>→ 가능하면 <strong>체크 예외</strong>, 불가능하면 <strong>언체크 예외</strong></p>
</li>
<li><p>예외가 <strong>복구 가능성</strong>이 있는가?<br>→ 재시도가 가능하거나, 서버에서 처리 경로가 존재하면 체크 예외 사용</p>
</li>
<li><p>예외가 발생했을 때 <strong>사용자 잘못인가?</strong><br>→ 클라이언트 실수로 발생하는 예외는 언체크 예외로 처리</p>
</li>
</ul>
<hr>
<h2 id="controlleradvice란">@ControllerAdvice란?</h2>
<p>Spring에서는 <code>@ControllerAdvice</code>를 통해<br><strong>애플리케이션 전역의 예외를 하나의 클래스에서 처리</strong>할 수 있다.</p>
<p>이를 통해 컨트롤러 단에서 발생하는 예외를 일괄적으로 핸들링할 수 있으며,<br><code>@ExceptionHandler</code>와 함께 사용하여 다양한 예외에 따른 응답을 설정할 수 있다.</p>
<hr>
<h2 id="globalexceptionhandler-예시">GlobalExceptionHandler 예시</h2>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity&lt;String&gt; handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                             .body(&quot;파라미터 타입이 올바르지 않습니다.&quot;);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity&lt;String&gt; handleUnreadable(HttpMessageNotReadableException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                             .body(&quot;요청 본문을 읽을 수 없습니다.&quot;);
    }

    // 기타 예외 처리 추가 가능
}</code></pre>
<h2 id="마무리-정리">마무리 정리</h2>
<ul>
<li><p>자바 예외는 <strong>복구 가능성</strong>, <strong>처리 주체</strong>, <strong>즉시 대응 가능성</strong>에 따라<br><strong>체크 예외</strong>와 <strong>언체크 예외</strong>를 구분해서 사용해야 한다.</p>
</li>
<li><p>서버에서 복구가 가능하거나 예외를 컨트롤할 수 있다면 <strong>체크 예외</strong>,  
복구 불가능하거나 사용자의 잘못으로 발생한 경우는 <strong>언체크 예외</strong>가 적절하다.</p>
</li>
<li><p>스프링에서는 <code>@ControllerAdvice</code>를 활용해 전역 예외를 효율적으로 관리할 수 있으며,<br>코드 중복을 줄이고 <strong>일관된 에러 응답 포맷</strong>을 제공할 수 있다.</p>
</li>
<li><p>실무에서는 도메인마다 <strong>커스텀 예외 클래스</strong>를 만들어<br><strong>에러 코드</strong>, <strong>상태 코드</strong>, <strong>에러 메시지</strong> 등을 체계적으로 관리하는 것이 중요하다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 자주 발생하는 예외 GlobalExceptionHandler로 처리하기]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%9E%90%EC%A3%BC-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%98%88%EC%99%B8-GlobalExceptionHandler%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%9E%90%EC%A3%BC-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%98%88%EC%99%B8-GlobalExceptionHandler%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 01 Jun 2025 10:55:36 GMT</pubDate>
            <description><![CDATA[<h1 id="missingpathvariableexception">MissingPathVariableException</h1>
<ul>
<li><strong><code>@PathVariable</code>의 값이 <code>URL</code> 경로에서 누락된 경우 발생한다.</strong></li>
</ul>
<pre><code>@GetMapping(&quot;/posts/{id}&quot;)
public String getPost(@PathVariable Long id) { ... }</code></pre><p>위 <code>API</code> 사용할 경우 <code>URL</code>경로를 <code>/posts/{id}</code>로 보내야 하는데, 이 때 <code>/posts</code>로 보내고 <code>id</code>값이 누락되는 경우 <code>MissingPathVariableException</code>이 발생한다.</p>
<h2 id="url-경로에-변수가-누락됐을-때-발생한다">URL 경로에 변수가 누락됐을 때 발생한다.</h2>
<h1 id="missingservletrequestparameterexception">MissingServletRequestParameterException</h1>
<ul>
<li><strong><code>@RequestParam</code>이 필수인데 요청에서 누락된 경우 발생한다.</strong></li>
</ul>
<pre><code>@GetMapping(&quot;/search&quot;)
public String search(@RequestParam String keyword) { ... }</code></pre><p>위 코드의 경우 <code>@RequestParam</code>을 사용했으므로 <code>API</code>호출 시 <code>/search?keyword=&quot;??&quot;</code>와 같은 형식으로 보내야 한다. 이 때 <code>keyword</code>값이 누락된 경우 <code>MissingServletRequestParameterException</code>이 발생한다.</p>
<h2 id="requestparam의-파라미터가-누락된-경우-발생한다">@RequestParam의 파라미터가 누락된 경우 발생한다.</h2>
<h1 id="methodargumenttypemismatchexception">MethodArgumentTypeMismatchException</h1>
<ul>
<li><strong>파라미터가 잘못된 타입으로 들어와 변환 실패한 경우에 발생한다.</strong></li>
</ul>
<pre><code>@GetMapping(&quot;/posts/{id}&quot;)
public String getPost(@PathVariable Long id) { ... }</code></pre><p>위와 같은 코드에서 <code>/posts/{id}</code>의 <code>id</code>값은 <code>Long</code>타입이다. 그러나 API 호출 시 <code>/posts/abc</code>이와 같이 호출한 경우 <code>MethodArgumentTypeMismatchException</code>이 발생한다.</p>
<h2 id="요청-url의-타입이-일치하지-않을-때-발생한다">요청 URL의 타입이 일치하지 않을 때 발생한다.</h2>
<h1 id="httpmessagenotreadableexception">HttpMessageNotReadableException</h1>
<ul>
<li>**@RequestBody로 받은 JSON 본문이 비었거나, 파싱이 불가능할 때 발생한다.</li>
</ul>
<pre><code>@PostMapping(&quot;/posts&quot;)
public String create(@RequestBody PostDto dto) { ... }</code></pre><p>위 코드는 <code>@RequestBody</code>를 사용하였으므로 요청 시 <code>Body</code>를 함께 보내주어야 한다. 이 때 요청 시 <code>Body</code>가 비어있거나 <code>{ title: &quot;hello&quot;, content: }</code>처럼 요청값이 잘못된 경우 <code>HttpMessageNotReadableException</code>가 발생한다.</p>
<h2 id="잘못된-json-형식으로-요청-시-발생한다">잘못된 JSON 형식으로 요청 시 발생한다.</h2>
<h1 id="httpmediatypenotacceptableexception">HttpMediaTypeNotAcceptableException</h1>
<ul>
<li><strong>클라이언트의 헤더에 맞는 타입을 서버가 제공하지 못할 때 발생한다.</strong></li>
</ul>
<p>예를들어 컨트롤러에선 <code>json</code>만 반환이 가능한데, 클라이언트에서 <code>xml</code>형식으로 요청을 하게 될 경우 <code>xml</code>을 제공할 수 없으므로 <code>HttpMediaTypeNotAcceptableException</code> 예외가 발생한다.</p>
<h2 id="클라이언트가-요청한-형식을-서버가-지원하지-못할-때-발생한다">클라이언트가 요청한 형식을 서버가 지원하지 못할 때 발생한다.</h2>
<h1 id="noresourcefoundexception">NoResourceFoundException</h1>
<ul>
<li>**요청한 경로에 해당하는 <code>Controller</code>가 아예 없을 때 발생한다. </li>
<li>Spring 6부터 도입된 예외로, 이전에는 404에러만 반환했다.</li>
</ul>
<p>프로그램의 컨트롤러 중 해당 URL을 처리할 수 있는 핸들러가 없을 때 <code>NoResourceFoundException</code>예외가 발생한다.</p>
<h2 id="존재하지-않는-url을-요청했을-때-발생한다">존재하지 않는 URL을 요청했을 때 발생한다.</h2>
<h1 id="httprequestmethodnotsupportedexception">HttpRequestMethodNotSupportedException</h1>
<ul>
<li>**해당 <code>URL</code>은 특정 HTTP 메서드만 허용하는데, 다른 메섣드로 호출한 경우 발생한다.</li>
</ul>
<pre><code>@GetMapping(&quot;/posts&quot;)
public String getPosts() { ... }</code></pre><p>위 코드는 <code>@GetMapping</code>을 사용했기 때문에 호출 시 <code>GET</code> 메서드로 호출해야 한다. 이 때 클라이언트에서 <code>GET</code>이 아닌 다른 메서드로 <code>API</code>를 호출할 경우 <code>HttpRequestMethodNotSupportedException</code>예외가 발생한다.</p>
<h2 id="호출-http-method가-일치하지-않을-때-발생한다">호출 HTTP Method가 일치하지 않을 때 발생한다.</h2>
<h1 id="httpmediatypenotsupportedexception">HttpMediaTypeNotSupportedException</h1>
<ul>
<li>**클라이언트가 보낸 컨텍스트 타입을 서버가 처리할 수 없을 때 발생한다.</li>
</ul>
<pre><code>@PostMapping(&quot;/posts&quot;)
public String create(@RequestBody PostDto dto) { ... }</code></pre><p>위 코드의 경우 <code>@RequestBody</code>를 사용했으므로 <code>JSON</code>형태로 <code>API</code>를 호출해야 한다. 이 때 컨텍스트 타입이 <code>JSON</code>이 아닌 다른 타입일 경우 <code>HttpMediaTypeNotSupportedException</code>예외가 발생한다.</p>
<h2 id="http-본문의-형식이-서버가-처리할-수-있는-타입이-아닐-때-발생한다">HTTP 본문의 형식이 서버가 처리할 수 있는 타입이 아닐 때 발생한다.</h2>
<h1 id="globalexceptionhandler">GlobalExceptionHandler</h1>
<ul>
<li><p><strong>위와 같은 예외들을 각각의 컨트롤러에서 처리하게 될 경우 만일 컨트롤러의 수가 수십가지 이상이라면, 수십가지 이상의 중복된 작업을 해야한다. 따라서 해당 예외들을 한 번에 처리할 수 있는 <code>GlobalExceptionHandler</code>를 생성하여 이곳에서 예외를 관리 및 처리한다.</strong></p>
</li>
<li><p><code>@RestControllerAdvice</code> 또는 <code>@ControllerAdvice</code>를 이용한다.</p>
</li>
</ul>
<pre><code>@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        return buildError(&quot;잘못된 타입의 파라미터입니다.&quot;, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity&lt;ErrorResponse&gt; handleUnreadable(HttpMessageNotReadableException ex) {
        return buildError(&quot;요청 본문을 읽을 수 없습니다.&quot;, HttpStatus.BAD_REQUEST);
    }

    private ResponseEntity&lt;ErrorResponse&gt; buildError(String message, HttpStatus status) {
        return new ResponseEntity&lt;&gt;(new ErrorResponse(status.value(), message), status);
    }

    public static class ErrorResponse {
        private int status;
        private String message;

        public ErrorResponse(int status, String message) {
            this.status = status;
            this.message = message;
        }
        // getter/setter 생략 가능
    }
}</code></pre><p><code>@ExceptionHandler</code>로 처리할 예외 클래스를 지정해준 뒤, 매개변수로 입력받는다.</p>
<p>응답 형식을 통일하고싶다면, 다음과 같은 <code>DTO</code>클래스를 생성한다.</p>
<pre><code>public class ErrorResponse {
    private int status;
    private String message;

    public ErrorResponse(int status, String message) {
        this.status = status;
        this.message = message;
    }
}</code></pre><p>이 후 예외 발생 지점에 해당 기능을 예외로 던지면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 커스텀 예외 처리]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%BB%A4%EC%8A%A4%ED%85%80-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Fri, 30 May 2025 12:25:05 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-커스텀-예외-처리-정리">Spring 커스텀 예외 처리 정리</h1>
<p>스프링 프로젝트를 개발하다 보면 도메인별로 명확하고 통일된 방식의 예외 처리 구조가 필요하다. 이를 위해 CustomException을 활용한 글로벌 예외 처리 구조를 구축할 수 있다. 아래는 지금까지의 구현 내용을 기반으로 커스텀 예외 처리 방식에 대해 정리한 글이다.</p>
<h1 id="왜-커스텀-예외가-필요한가">왜 커스텀 예외가 필요한가?</h1>
<p><strong>도메인에 특화된 명확한 에러 메시지 제공</strong></p>
<ul>
<li>단순한 500 오류나 NullPointerException 같은 시스템 에러 대신, &quot;제목은 필수입니다&quot;, &quot;이미지를 삽입해야 합니다&quot; 같은 사용자 친화적인 메시지를 반환할 수 있다.</li>
</ul>
<p><strong>일관된 API 응답 형식 유지</strong></p>
<ul>
<li>다양한 컨트롤러에서 발생하는 예외를 한 곳에서 처리하여 클라이언트는 예외 응답을 예측 가능하게 처리할 수 있다.</li>
</ul>
<p><strong>비즈니스 로직과 예외 로직 분리</strong></p>
<ul>
<li>핵심 로직은 비즈니스에 집중하고, 예외 처리는 따로 관리할 수 있어 가독성과 유지보수성이 높아진다.</li>
</ul>
<p><strong>에러 코드 중심의 흐름 제어</strong></p>
<ul>
<li>개발자는 ErrorCode를 중심으로 흐름을 설계하고 디버깅 및 로깅이 용이하다.</li>
</ul>
<p><strong>입력 유효성 검사와 명확한 예외 구분</strong></p>
<ul>
<li>유효성 검증 실패, 권한 문제, 데이터 누락 등 다양한 상황을 명확하게 분리하여 처리 가능하다.</li>
</ul>
<p><strong>스택 트레이스를 줄이고 명확한 원인 파악</strong></p>
<ul>
<li>비즈니스 요구사항에 따라 의도적으로 발생시키는 예외는 CustomException으로 명확하게 추적 가능하다.</li>
</ul>
<p>응답 상태 코드를 정밀하게 제어 가능</p>
<ul>
<li>상태 코드와 메시지를 조합해 REST API의 신뢰도를 높일 수 있다.</li>
</ul>
<h1 id="기본-구조-및-클래스-역할-설명">기본 구조 및 클래스 역할 설명</h1>
<h2 id="errorcode-enum">ErrorCode Enum</h2>
<ul>
<li>예외 상황을 명확하게 분리하고 코드화하기 위한 열거형. 각각의 예외에 대해 상태 코드와 메시지를 설정한다.</li>
</ul>
<pre><code>public enum ErrorCode {
    // 게시글 관련
    BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, &quot;해당 게시글을 찾을 수 없습니다.&quot;),
    POST_NOT_OWNED(HttpStatus.BAD_REQUEST, &quot;본인의 게시글만 수정 및 삭제할 수 있습니다.&quot;),
    POST_NOT_CHANGE(HttpStatus.BAD_REQUEST, &quot;변경할 내용을 입력해야 합니다.&quot;),
    POST_NOT_TITLE(HttpStatus.BAD_REQUEST,&quot;제목을 입력해야 합니다.&quot;),
    POST_NOT_CONTENTS(HttpStatus.BAD_REQUEST,&quot;내용을 입력해야 합니다.&quot;),
    POST_NOT_IMAGE(HttpStatus.BAD_REQUEST,&quot;이미지를 삽입해야 합니다.&quot;),
    TITLE_LENGTH_OVER(HttpStatus.BAD_REQUEST, &quot;제목은 255자까지 입력 가능합니다.&quot;)
``
    // 기타 예외
    INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, &quot;비밀번호가 일치하지 않습니다.&quot;);

    private final HttpStatus status;
    private final String message;

    // 생성자 및 Getter
}</code></pre><h2 id="customexception">CustomException</h2>
<ul>
<li>비즈니스 로직에서 예외 상황이 발생할 경우 이 클래스를 사용해 throw 한다. ErrorCode를 기반으로 상태 코드 및 메시지를 커스터마이징할 수 있다.</li>
</ul>
<pre><code>public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public HttpStatus getStatus() {
        return errorCode.getStatus();
    }
}</code></pre><h2 id="globalexceptionhandler">GlobalExceptionHandler</h2>
<ul>
<li><p>모든 Controller에서 발생하는 예외를 한 곳에서 처리. CustomException 외에도 Spring의 유효성 검사 실패 등을 처리할 수 있도록 구성한다.</p>
<pre><code>@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(CustomException.class)
  public ResponseEntity&lt;ExceptionDto&gt; handleCustomException(CustomException e) {
      ExceptionDto dto = new ExceptionDto(e.getStatus(), e.getMessage());
      return ResponseEntity.status(e.getStatus()).body(dto);
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity&lt;Object&gt; handleValidationExceptions(MethodArgumentNotValidException ex) {
      List&lt;Map&lt;String, String&gt;&gt; errors = new ArrayList&lt;&gt;();
      for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
          Map&lt;String, String&gt; error = new HashMap&lt;&gt;();
          error.put(&quot;field&quot;, fieldError.getField());
          error.put(&quot;message&quot;, fieldError.getDefaultMessage());
          errors.add(error);
      }
      Map&lt;String, Object&gt; body = new HashMap&lt;&gt;();
      body.put(&quot;message&quot;, &quot;입력값이 올바르지 않습니다.&quot;);
      body.put(&quot;errors&quot;, errors);
      return ResponseEntity.badRequest().body(body);
  }
}</code></pre><h2 id="exceptiondto">ExceptionDto</h2>
</li>
<li><p>예외 발생 시 클라이언트로 반환할 응답 데이터 포맷을 정의하는 DTO 클래스.</p>
<pre><code>public class ExceptionDto {
  private int status;
  private String message;

  public ExceptionDto(HttpStatus status, String message) {
      this.status = status.value();
      this.message = message;
  }
  // Getter, Setter
}</code></pre><h1 id="사용-예시">사용 예시</h1>
</li>
</ul>
<h2 id="게시글-삭제-예외-처리">게시글 삭제 예외 처리</h2>
<pre><code>@Transactional
public void deletePost(Long id, Long currentUserId, String password) {
    Post post = postRepository.findById(id)
        .orElseThrow(() -&gt; new CustomException(ErrorCode.BOARD_NOT_FOUND));

    if (!currentUserId.equals(post.getUser().getId())) {
        throw new CustomException(ErrorCode.POST_NOT_OWNED);
    }

    passwordManager.validatePasswordMatchOrThrow(password, post.getUser().getPassword());
    postRepository.delete(post);
}</code></pre><h2 id="비밀번호-검증-내부-예외">비밀번호 검증 내부 예외</h2>
<pre><code>public void validatePasswordMatchOrThrow(String input, String actual) {
    if (!passwordEncoder.matches(input, actual)) {
        throw new CustomException(ErrorCode.INVALID_PASSWORD);
    }
}</code></pre><h2 id="게시글-생성-시-유효성-검사-예외-처리">게시글 생성 시 유효성 검사 예외 처리</h2>
<pre><code>@Transactional
public PostResponseDto createPost(Long currentUserId, String title, String contents, String imageUrl) {
    User user = userService.findUserById(currentUserId);
    Post newPost = new Post(user, title, contents, imageUrl);

    if (newPost.getTitle() == null) {
        throw new CustomException(ErrorCode.POST_NOT_TITLE);
    }

    if (newPost.getContents() == null) {
        throw new CustomException(ErrorCode.POST_NOT_CONTENTS);
    }

    if (newPost.getImageUrl() == null) {
        throw new CustomException(ErrorCode.POST_NOT_IMAGE);
    }

    if (newPost.getTitle().length() &gt; 255) {
        throw new CustomException(ErrorCode.TITLE_LENGTH_OVER);
    }

    Post savePost = postRepository.save(newPost);

    return new PostResponseDto(
        savePost.getId(),
        savePost.getUser().getId(),
        savePost.getTitle(),
        savePost.getContents(),
        savePost.getImageUrl(),
        savePost.getUser().getUserUrl(),
        savePost.getPostLikes().size(),
        savePost.getComments().size(),
        savePost.getCreatedAt(),
        savePost.getModifiedAt()
    );
}</code></pre><ol start="4">
<li>사용한 상태코드 정리 및 사용처</li>
</ol>
<p>400 BAD_REQUEST</p>
<ul>
<li>POST_NOT_OWNED: 작성자가 아닌 사용자가 수정/삭제를 시도할 때</li>
<li>POST_NOT_CHANGE: 변경할 내용이 비어 있을 때</li>
<li>POST_NOT_TITLE: 제목이 비어 있을 때</li>
<li>POST_NOT_CONTENTS: 내용이 비어 있을 때</li>
<li>POST_NOT_IMAGE: 이미지가 없을 때</li>
<li>TITLE_LENGTH_OVER: 제목 길이가 255자를 초과할 때</li>
</ul>
<p>401 UNAUTHORIZED</p>
<ul>
<li>INVALID_PASSWORD: 비밀번호가 일치하지 않을 때</li>
</ul>
<p>404 NOT_FOUND</p>
<ul>
<li>BOARD_NOT_FOUND: 해당 게시글을 찾을 수 없을 때</li>
</ul>
<p>결론</p>
<p>CustomException과 GlobalExceptionHandler를 함께 사용하면 REST API의 에러 응답을 일관되고 명확하게 구성할 수 있다. 위 구조를 프로젝트 초기에 설계해두면 이후 유지보수 시에도 매우 유리하다. 각 클래스의 책임과 역할을 분리하면서 사용자에게는 명확한 메시지를, 개발자에게는 유연한 예외처리 체계를 제공할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 뉴스피드 구현 중 문제점]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EB%89%B4%EC%8A%A4%ED%94%BC%EB%93%9C-%EA%B5%AC%ED%98%84-%EC%A4%91-%EB%AC%B8%EC%A0%9C%EC%A0%90</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EB%89%B4%EC%8A%A4%ED%94%BC%EB%93%9C-%EA%B5%AC%ED%98%84-%EC%A4%91-%EB%AC%B8%EC%A0%9C%EC%A0%90</guid>
            <pubDate>Thu, 29 May 2025 12:55:04 GMT</pubDate>
            <description><![CDATA[<h1 id="첫-번째">첫 번째</h1>
<p>원래 계획했던건 ResponseDto를 용도별로 분리하는 브랜치를 만들어서 작업한 후 기존에 작업했던 게시글 전체 조회, 단건 조회, 수정 브랜치 순서로 각각 이동하여 하나씩 구현하려 했는데, 이렇게 작업하면 게시글 전체 조회 기능을 구현할 때 단건 조회, 수정까지 함께 수정해야하는 문제가 생겼습니다. 따라서 Dto 용도별 분리 브랜치에서 한 번에 작업했습니다. </p>
<h1 id="두-번째">두 번째</h1>
<p>게시글 전체 조회 기능에 페이징 기능을 추가하는 작업 중 컨트롤러에서 </p>
<pre><code class="language-jsx">@GetMapping(&quot;/pages/{pageId}&quot;)
    public ResponseEntity&lt;List&lt;FindAllPostResponseDto&gt;&gt; findAllAPI(
            @PathVariable  int page,
            @RequestParam (defaultValue = &quot;10&quot;) int size
    )</code></pre>
<p>위와 같이 사용하려 했는데 MissingPathVariableException이발생했습니다. 말인 즉 
<code>@PathVariable</code> 이 문제라 파악되어, 문제점을 찾다보니, 경로 변수와 <code>@PathVariable</code> 의 이름이 다르다는 사실을 확인했습니다.</p>
<p>이 후 다음과 같이 경로 이름을 수정하여, 해결했습니다.</p>
<pre><code class="language-jsx">        @GetMapping(&quot;/pages/{page}&quot;)
    public ResponseEntity&lt;List&lt;FindAllPostResponseDto&gt;&gt; findAllAPI(
            @PathVariable  int page,
            @RequestParam (defaultValue = &quot;10&quot;) int size
    )</code></pre>
<hr>
<p>추가 수정 </p>
<p>현재는 다음과 같이 변경되었습니다.</p>
<pre><code>    @GetMapping
    public ResponseEntity&lt;PageResponseDto&lt;PostListResponseDto&gt;&gt; findAllPosts(
            @PageableDefault(page = 1, sort = &quot;createdAt&quot;, direction = Sort.Direction.DESC) Pageable pageable
    )
    {
        PageResponseDto&lt;PostListResponseDto&gt; allPost = postService.findAllPosts(pageable);

        return new ResponseEntity&lt;&gt;(allPost, HttpStatus.OK);
    }</code></pre><p><code>@PathVariable</code>을 사용하지 않고, <code>@PageableDefault</code>를 사용해 기본값 할당 및 정렬 기능을 할당했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 인증과 인가]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EC%9D%B8%EA%B0%80</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%9D%B8%EC%A6%9D%EA%B3%BC-%EC%9D%B8%EA%B0%80</guid>
            <pubDate>Thu, 22 May 2025 11:52:40 GMT</pubDate>
            <description><![CDATA[<h1 id="인증과-인가-그리고-인증-방식의-이해">인증과 인가, 그리고 인증 방식의 이해</h1>
<ul>
<li><strong>사용자 신원 확인과 해당 사용자에게 권한을 부여해 할 수 있는 일의 범위를 지정하는 보안의 핵심 개념이다.</strong></li>
</ul>
<h1 id="인증authentication-vs-인가authorization">인증(Authentication) vs 인가(Authorization)</h1>
<table>
<thead>
<tr>
<th align="center">구분</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">인증 (Authentication)</td>
<td align="center">사용자의 신원을 확인하는 절차 (로그인 시 아이디/비밀번호 입력)</td>
</tr>
<tr>
<td align="center">인가 (Authorization)</td>
<td align="center">인증된 사용자에게 허용된 권한을 부여하는 절차 (관리자는 글 삭제 가능, 일반 사용자는 불가)</td>
</tr>
</tbody></table>
<h1 id="쿠키-기반-인증-방식">쿠키 기반 인증 방식</h1>
<ul>
<li><p><strong>가장 기본적인 웹 인증 방식이다.</strong></p>
</li>
<li><p>로그인 시 서버가 인증 정보를 담은 쿠키를 클라이언트에게 발급한다.</p>
</li>
<li><p>클라이언트는 매 요청마다 이 쿠키를 자동 전송한다.</p>
</li>
<li><p>서버는 쿠키의 값을 기반으로 인증 여부를 판단한다.</p>
</li>
</ul>
<h2 id="쿠키-기반-인증-방식의-한계">쿠키 기반 인증 방식의 한계</h2>
<ul>
<li><p><strong>쿠키가 탈취되면 그대로 인증이 뚫린다.</strong></p>
</li>
<li><p>따라서 쿠키에 민감한 정보를 직접 담는 것은 매우 위험하다.</p>
</li>
<li><p>HTTPS 사용은 사실상 필수이다.</p>
</li>
</ul>
<h1 id="세션session-인증-방식">세션(Session) 인증 방식</h1>
<ul>
<li><p>쿠키 인증 방식의 보안 문제를 보완한 구조이다.</p>
</li>
<li><p>서버가 사용자 정보를 메모리에 저장한다.</p>
</li>
<li><p>클라이언트는 해당 정보를 가리키는 세션 ID만 쿠키로 저장한다.</p>
</li>
<li><p>요청 시 세션 ID를 보내면 서버가 해당 사용자를 조회해 인증한다.</p>
</li>
</ul>
<table>
<thead>
<tr>
<th align="center">흐름</th>
</tr>
</thead>
<tbody><tr>
<td align="center">로그인 → 서버 세션 생성 → 세션 ID 발급 → 클라이언트 쿠키 저장 → 요청마다 세션 ID 포함 → 서버가 사용자 확인</td>
</tr>
<tr>
<td align="center">## 보안 주의사항</td>
</tr>
<tr>
<td align="center">+ <strong>세션 ID가 탈취되면 인증이 뚫릴 수 있다.</strong></td>
</tr>
</tbody></table>
<ul>
<li>사용자가 많을수록 서버의 메모리 부담이 커진다.</li>
</ul>
<h1 id="스프링에서의-세션-방식">스프링에서의 세션 방식</h1>
<pre><code>HttpSession session = request.getSession();
session.setAttribute(&quot;user&quot;, user);</code></pre><h1 id="토큰-인증-방식-jwt">토큰 인증 방식 (JWT)</h1>
<ul>
<li><p><strong>JWT(JSON Web Token) :  가장 널리 사용되는 토큰 기반 인증 방식이다.</strong></p>
</li>
<li><p>로그인 시 서버가 서명된 토큰 문자열을 클라이언트에게 발급한다.</p>
</li>
<li><p>클라이언트는 이 토큰을 저장하고 요청마다 헤더에 담아 전송한다.</p>
</li>
<li><p>서버는 토큰만으로 사용자를 검증한다.</p>
</li>
</ul>
<h2 id="특징">특징</h2>
<ul>
<li><p>서버는 사용자 상태를 저장하지 않는다. (Stateless)</p>
</li>
<li><p>인증 정보는 토큰 내부에 <strong>Base64</strong> 인코딩된 형태로 포함된다.</p>
</li>
<li><p>서버는 토큰을 복호화하고 서명을 검증함으로써 인증 여부를 판단한다.</p>
</li>
</ul>
<h1 id="인코딩은-암호화가-아니다">인코딩은 암호화가 아니다.</h1>
<ul>
<li><p><strong>토큰 인증 방식은 단순히 Base64로 인코딩된 문자열일 뿐 암호화된 것이 아니다.</strong></p>
</li>
<li><p>누구든지 토큰을 디코딩하면 내용을 볼 수 있다.</p>
</li>
<li><p>민감한 정보(ID, 주민번호 등)는 절대 토큰에 넣으면 안된다.</p>
</li>
</ul>
<h1 id="세션-vs-토큰-인증-방식-비교">세션 vs 토큰 인증 방식 비교</h1>
<table>
<thead>
<tr>
<th align="center">항목</th>
<th align="center">세션 인증 방식</th>
<th align="center">토큰 인증 방식 (JWT)</th>
</tr>
</thead>
<tbody><tr>
<td align="center">상태</td>
<td align="center">상태 유지 (Stateful)</td>
<td align="center">무상태 (Stateless)</td>
</tr>
<tr>
<td align="center">서버 저장소</td>
<td align="center">세션 메모리 필요</td>
<td align="center">저장 불필요</td>
</tr>
<tr>
<td align="center">클라이언트 저장</td>
<td align="center">세션 ID (쿠키)</td>
<td align="center">토큰 (헤더 또는 localStorage)</td>
</tr>
<tr>
<td align="center">보안 위협</td>
<td align="center">세션 ID 탈취 위험</td>
<td align="center">토큰 탈취 시 내용 노출 가능</td>
</tr>
<tr>
<td align="center">확장성</td>
<td align="center">서버 메모리 부담 ↑</td>
<td align="center">분산 구조, 확장성에 유리</td>
</tr>
</tbody></table>
<h2 id="상황별-인증-방식-가이드">상황별 인증 방식 가이드</h2>
<table>
<thead>
<tr>
<th align="center">상황</th>
<th align="left">추천 방식</th>
</tr>
</thead>
<tbody><tr>
<td align="center">내부 시스템, 사용자 수 적음</td>
<td align="left">세션 인증 방식</td>
</tr>
<tr>
<td align="center">모바일 앱, SPA, 분산 서버 환경</td>
<td align="left">JWT 기반 토큰 인증 방식</td>
</tr>
</tbody></table>
<h1 id="jwt-사용-시-주의사항">JWT 사용 시 주의사항</h1>
<p>+HTTPS 사용은 필수이다.</p>
<ul>
<li><p>토큰 만료 시간을 짧게 설정해야 한다.</p>
</li>
<li><p>리프레시 토큰 전략을 함께 사용하면 보안을 강화할 수 있다.</p>
</li>
</ul>
<h1 id="인증과-인가-요약">인증과 인가 요약</h1>
<ul>
<li><p>인증: “너 누구야?”</p>
</li>
<li><p>인가: “너 이거 해도 돼?”</p>
</li>
<li><p>인증 방식은 쿠키 → 세션 → 토큰 순으로 발전해왔다.</p>
</li>
<li><p>세션은 서버가 상태를 기억하고, 토큰은 클라이언트가 들고 다닌다.</p>
</li>
<li><p>JWT는 암호화가 아닌 인코딩이므로, 노출되면 내용이 그대로 보일 수 있다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring 일정관리 앱 만들기 V2 구현 중 문제]]></title>
            <link>https://velog.io/@4ou_chan/Spring-%EC%9D%BC%EC%A0%95%EA%B4%80%EB%A6%AC-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-V2-%EA%B5%AC%ED%98%84-%EC%A4%91-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@4ou_chan/Spring-%EC%9D%BC%EC%A0%95%EA%B4%80%EB%A6%AC-%EC%95%B1-%EB%A7%8C%EB%93%A4%EA%B8%B0-V2-%EA%B5%AC%ED%98%84-%EC%A4%91-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Wed, 21 May 2025 07:10:42 GMT</pubDate>
            <description><![CDATA[<h1 id="첫-번째-문제">첫 번째 문제</h1>
<p>전체 일정 목록을 조회하는 <code>API</code>를 만들어 <code>POSTMAN</code>으로 테스트하는 과정에서 에러가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/f2d34dad-0e2b-4381-8034-28440c0f79e3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/9d2ec2f8-6ea6-4892-b856-398b983abf21/image.png" alt=""></p>
<p><code>schedule_id</code>컬럼을 조회할 수 없기 때문에 발생되는 오류로 판명되었다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/31e6177c-3536-4aac-91a1-49ee2ea59356/image.png" alt=""></p>
<p>컬럼명을 카멜케이스로 사용중이어서 찾지 못 하는 것 같았다.</p>
<p>그래서 데이터베이스 테이블을 모두 삭제하고 스네이크케이스로 변경하여 다시 생성하였다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/bd8de50e-b418-4d4d-bf83-31b9e66173ac/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/4c6e6342-0359-4076-8604-0f32ac1bc501/image.png" alt=""></p>
<p>정상 출력되는 모습</p>
<h1 id="두-번째-문제">두 번째 문제</h1>
<p>일정을 수정하는 기능을 만든 뒤 확인해보니 수정이 아닌 새로운 일정을 생성하고 있었다. 이는 <code>save()</code>메서드를 사용했기 때문이었고, 제목과 내용을 수정할 수 있는 세터를 만들어서 해결했다. </p>
<p>그러나 이후 <code>POSTMAN</code>에서는 정상적으로 출력되나, 데이터베이스에 반영이 되지 않는 문제가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/df180a74-a5b1-4ef8-a5bb-121b74874f07/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/24cf42e5-fb6f-4d3b-a35d-4b0d841a8485/image.png" alt=""></p>
<p>확인해보니 이는 서비스 레이어에서 <code>@Transactional</code>을 사용하지 않아, 변경 감지를 하지 못 해 생긴 문제였고, <code>@Transactional</code>을 선언하여 문제를 해결했다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/baf81591-a679-43f8-a099-43aa55096ccf/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/ebba4d70-3783-4d00-9025-61cfeadd66b0/image.png" alt=""></p>
<p>정상적으로 데이터베이스에 반영되는 모습이다.</p>
<h1 id="세-번째-문제">세 번째 문제</h1>
<p>일정 수정 기능에 사용자의 비밀번호를 입력받아 같은 비밀번호인지 검증한 후 일정을 수정할 수 있도록 구조 변경 중 <code>NullPointerException</code>이 발생했다.</p>
<p>에러 문구를 읽어보니 참조중인 <code>getUserEntity</code>의 값이 <code>null</code>로 들어가는것으로 확인되었다.</p>
<p>이후 디버깅모드로 값을 한줄 씩 확인해봤고 문제 지점은 내가 예상한 부분이 아닌 다른 부분에 있었다. </p>
<p>유저 식별자를 함꼐 출력하기 위해 <code>scheduleResponseDto</code> 의 구조를 변경했었는데, 이 때 유저 식별자를 불러오면서, 객체에 유저를 매핑해주지 않아서 <code>NullPointerException</code>이 발생되는 것이었다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/1ab035b8-4d3b-432c-ba2f-593eab7a31b7/image.png" alt=""></p>
<p>여기서 <code>scheduleResponseDto</code>객체를 만드는 과정에서 <code>scheduleEntity.getUserEntity().getUserId()</code>를 사용했는데, </p>
<pre><code>ScheduleEntity scheduleEntity = new ScheduleEntity(title, schedule);</code></pre><p>이 부분에서 객체를 새로 생성하여 유저 객체와의 매핑이 이루어지지 않은 것이었다.</p>
<p>따라서 객체를 생성하는 부분을 없앤 후 <code>scheduleEntity.get</code>이 들어가는 모든 부분을 <code>findSchedule.get</code>으로 변경하였다.</p>
<p><img src="https://velog.velcdn.com/images/4ou_chan/post/9fc5b8a4-69c4-4574-9205-8de82b7b2597/image.png" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>