<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>int i = 710</title>
        <link>https://velog.io/</link>
        <description>노력하고 있다니까요?</description>
        <lastBuildDate>Mon, 26 May 2025 07:03:05 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>int i = 710</title>
            <url>https://velog.velcdn.com/images/giwon_wg/profile/10c30a21-4748-41b8-abfb-70c932ecf216/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. int i = 710. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/giwon_wg" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Project]꽃 향기만 남기고 갔단다_KPT]]></title>
            <link>https://velog.io/@giwon_wg/Project%EA%BD%83-%ED%96%A5%EA%B8%B0%EB%A7%8C-%EB%82%A8%EA%B8%B0%EA%B3%A0-%EA%B0%94%EB%8B%A8%EB%8B%A4KPT</link>
            <guid>https://velog.io/@giwon_wg/Project%EA%BD%83-%ED%96%A5%EA%B8%B0%EB%A7%8C-%EB%82%A8%EA%B8%B0%EA%B3%A0-%EA%B0%94%EB%8B%A8%EB%8B%A4KPT</guid>
            <pubDate>Mon, 26 May 2025 07:03:05 GMT</pubDate>
            <description><![CDATA[<h3 id="📋-kpt-회고">📋 KPT 회고</h3>
<ul>
<li><strong>KEEP</strong><ul>
<li><strong>커뮤니케이션 &amp; 협업 문화</strong><ul>
<li>SNS(Slack)을 활용한 팀원 간 소통</li>
<li>정기, 비 정기 회의 진행</li>
<li>Git 코멘트 남겨주는 것(질문, 건의, 개선 사항)</li>
<li>화기애애한 팀 분위기</li>
</ul>
</li>
<li><strong>기술적 협업 도구 활용</strong><ul>
<li>Git 이슈와 같은 편의 기능 사용 시도</li>
<li>팀원의 코드 리뷰</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Problem</strong><ul>
<li><strong>프로젝트 진행</strong><ul>
<li>스케줄에 맞춰 프로젝트가 진행되지 못한 점</li>
</ul>
</li>
<li><strong>기술적 빌드 업</strong><ul>
<li>트러블 슈팅이 빈약했던 점</li>
<li>코드 주석이 미흡했던 점</li>
</ul>
</li>
<li><strong>프로젝트 발표</strong><ul>
<li>발표자 랜덤 선출</li>
<li>회고 내용 업로드 안됨</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Try</strong><ul>
<li><strong>코드/기술 고도화</strong><ul>
<li>팀원 전체가 코드 리뷰 진행</li>
<li>기술의 사용을 넘어 성능 고도화 시도</li>
<li>접목한 기술에 대한 설명/전수 세션</li>
</ul>
</li>
<li><strong>클라우드 인프라 및 배포 자동화 도구 활용</strong><ul>
<li>도커(Docker) 활용</li>
<li>AWS &amp; CI / CD 활용</li>
</ul>
</li>
<li><strong>Git 활용</strong><ul>
<li>Git 메시지 템플릿 활용</li>
<li>Git 이슈를 활용한 프로젝트 진행</li>
<li>Git 프로젝트를 활용한 프로젝트 관리</li>
</ul>
</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project]꽃 향기만 남기고 갔단다]]></title>
            <link>https://velog.io/@giwon_wg/Project%EA%BD%83-%ED%96%A5%EA%B8%B0%EB%A7%8C-%EB%82%A8%EA%B8%B0%EA%B3%A0-%EA%B0%94%EB%8B%A8%EB%8B%A4</link>
            <guid>https://velog.io/@giwon_wg/Project%EA%BD%83-%ED%96%A5%EA%B8%B0%EB%A7%8C-%EB%82%A8%EA%B8%B0%EA%B3%A0-%EA%B0%94%EB%8B%A8%EB%8B%A4</guid>
            <pubDate>Mon, 26 May 2025 06:50:20 GMT</pubDate>
            <description><![CDATA[<h1 id="꽃-향기만-남기고-갔단다">꽃 향기만 남기고 갔단다</h1>
<h1 id="0-목차">0. 목차</h1>
<h2 id="1-프로젝트-소개">1. 프로젝트 소개</h2>
<h2 id="2-핵심-기술-요약">2. 핵심 기술 요약</h2>
<h2 id="3-동시성-제어">3. 동시성 제어</h2>
<h2 id="4-대용량-데이터-처리">4. 대용량 데이터 처리</h2>
<h2 id="5-캐싱">5. 캐싱</h2>
<h2 id="6-성과-및-회고">6. 성과 및 회고</h2>
<h1 id="1-프로젝트-소개-1">1. 프로젝트 소개</h1>
<h2 id="🌸-프로젝트-개요">🌸 프로젝트 개요</h2>
<h3 id="프로젝트-명--온라인-꽃-쇼핑몰-꽃-향기만-남기고-갔단다"><strong>프로젝트 명</strong> : 온라인 꽃 쇼핑몰 &quot;꽃 향기만 남기고 갔단다&quot;</h3>
<h3 id="팀-명-💰1조-벌자">팀 명 :💰1조 벌자</h3>
<table>
<thead>
<tr>
<th>이름</th>
<th>직책</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>기 원</strong></td>
<td>팀장</td>
<td><strong>가게</strong> 도메인, 분위기 메이커, <strong>1조 대장</strong></td>
</tr>
<tr>
<td><strong>김 하경</strong></td>
<td>부 팀장</td>
<td><strong>상품</strong> 도메인</td>
</tr>
<tr>
<td><strong>김 광민</strong></td>
<td>팀원</td>
<td><strong>주문</strong> 도메인</td>
</tr>
<tr>
<td><strong>정 기백</strong></td>
<td>팀원</td>
<td><strong>인증/인가</strong>, <strong>유저</strong> 도메인</td>
</tr>
<tr>
<td><strong>안 유빈</strong></td>
<td>팀원</td>
<td><strong>쿠폰</strong> 도메인</td>
</tr>
</tbody></table>
<h2 id="📡--기술-스택"><strong>📡  기술 스택</strong></h2>
<p>Language:  <strong>Java 17</strong> </p>
<p>Framework:  <strong>Spring Boot 3.4.4</strong></p>
<p>ORM:  <strong>Spring Boot JPA</strong></p>
<p>Database:  <strong>MySQL</strong>, <strong>H2</strong></p>
<p>Cache:  <strong>Redis</strong></p>
<p>Security:  <strong>Spring Security</strong>, <strong>JWT</strong></p>
<p>API Docs:  <strong>Swagger</strong></p>
<p>Version Control:  <strong>Git</strong>, <strong>GitHub</strong></p>
<p>Test Tool: <strong>k6, Postman, Java Test Code</strong></p>
<h2 id="🎨-와이어-프레임">🎨 와이어 프레임</h2>
<p><img src="attachment:f07d8c89-f4ad-470d-82d5-6e4fc597e819:%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-05-26_%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB_9.23.08.png" alt="스크린샷 2025-05-26 오전 9.23.08.png"></p>
<h2 id="☁️-erd"><strong>☁️</strong> <strong>ERD</strong></h2>
<p><img src="attachment:8cc811c3-872f-46d0-af4b-91f478fca6bc:image.png" alt="image.png"></p>
<h2 id="📝-api-명세">📝 API 명세</h2>
<p><img src="attachment:8706058f-09d6-4e7e-8558-3251b3baa0f0:03f519c0-5712-4a79-9113-582c4a7350a6.png" alt="스크린샷 2025-05-26 오전 9.40.47.png"></p>
<h1 id="2-핵심-기술-요약-1"><strong>2. 핵심 기술 요약</strong></h1>
<h2 id="동시성-제어">동시성 제어</h2>
<table>
<thead>
<tr>
<th><strong>도메인</strong></th>
<th>상세 내용</th>
<th>제어 전략</th>
</tr>
</thead>
<tbody><tr>
<td>User</td>
<td>회원 가입 시 이메일/닉네임 중복 제어</td>
<td>분산 락 (Redis)</td>
</tr>
<tr>
<td>Store</td>
<td>가게 생성 시 가게 명 중복 제어</td>
<td>분산 락 (Redis)</td>
</tr>
<tr>
<td>Coupon</td>
<td>쿠폰 발급 시 재고 감소 제어</td>
<td>분산 락 (Redis)</td>
</tr>
<tr>
<td>Order</td>
<td>주문 시 상품 재고 감소 제어</td>
<td>비관적 락 → 고도화 : 분산 락 (Redis)</td>
</tr>
</tbody></table>
<h2 id="대용량-데이터-처리">대용량 데이터 처리</h2>
<table>
<thead>
<tr>
<th><strong>도메인</strong></th>
<th>상세 내용</th>
<th>처리 전략</th>
<th>key</th>
</tr>
</thead>
<tbody><tr>
<td>Store</td>
<td>가게 검색 속도 개선</td>
<td>복합 인덱스 + Redis 직렬화 최적화</td>
<td>store_name + deleted</td>
</tr>
<tr>
<td>Coupon</td>
<td>유저별 발급 가능 쿠폰 조회 최적화</td>
<td>복합 인덱스 + EXISTS + DTO Projection</td>
<td>user_id + coupon_id + is_used</td>
</tr>
</tbody></table>
<h2 id="캐싱">캐싱</h2>
<table>
<thead>
<tr>
<th><strong>도메인</strong></th>
<th>상세 내용</th>
<th>캐싱 전략</th>
<th>저장소</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Store</td>
<td>인기 가게 리스트</td>
<td>조회 수 기반 자동 캐싱</td>
<td>Redis (카운터 기반)</td>
<td>실시간 조회 수를 누적하여 동적으로 반영</td>
</tr>
<tr>
<td>Flower</td>
<td>인기 검색어(오늘/이번 달/올해 기준) 검색 결과</td>
<td>스케줄 기반 사전 캐싱</td>
<td>Redis</td>
<td>프로그램 시작 시 + 매 시간 주기로 인기 검색어 미리 캐싱</td>
</tr>
</tbody></table>
<h1 id="3-동시성-제어-1">3. 동시성 제어</h1>
<h2 id="🎟️-쿠폰-발급-시-재고-감소-제어">🎟️ 쿠폰 발급 시 재고 감소 제어</h2>
<h3 id="📌-배경">📌 배경</h3>
<ul>
<li><p>한정 수량 쿠폰이기 때문에 동시에 많은 사용자가 발급을 시도할 경우,</p>
<p>  → 재고가 <strong>음수</strong>로 떨어지거나,</p>
<p>  → <strong>중복 발급</strong>이 발생할 위험 존재!</p>
</li>
<li><p>비관적 락 / 낙관적 락만으로는 분산 환경에서의 정확한 동시성 제어에 <strong>한계</strong>가 있음</p>
</li>
</ul>
<h3 id="💡-해결-전략">💡 해결 전략</h3>
<ul>
<li><p><strong>Redis 기반 분산 락 사용</strong></p>
<p>  → 쿠폰 ID 단위로 락을 획득해 재고 감소 과정에서의 충돌 방지</p>
<p>  → 재고 음수화 방지</p>
<p>  → 수평 확장 용이 (트래픽 증가에도 유연한 대응 가능)</p>
</li>
</ul>
<h3 id="🧑💻-구현-코드">🧑‍💻 구현 코드</h3>
<pre><code class="language-java">    @Override
    public UserCouponIssueResponseDto issueUserCoupon(Long storeId, Long couponId, CustomUserPrincipal principal) {

        User user = validateActivateUser(principal.getUsername());
        Long discountCouponId = discountCouponRepository.findById(couponId)
            .orElseThrow(() -&gt; new ApiException(ErrorStatus.COUPON_NOT_FOUND)).getId();
        // 쿠폰ID 기반으로 락 키 생성
        String lockKey = &quot;coupon-lock:id: &quot; + discountCouponId;
        log.info(&quot;lockKey = {}&quot;, lockKey);
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked;

        try {
                  // 락 획득 시도
            isLocked = lock.tryLock(3000, 3000, TimeUnit.MILLISECONDS);

                        // 락 획득 실패 예외처리
            if (!isLocked) {
                log.warn(&quot;[Coupon - 쿠폰 발급] 락 획득 실패, couponId: {}&quot;, discountCouponId);
                throw new ApiException(ErrorStatus.COUPON_BAD_REQUEST);
            }

                        // 락 획득 후에만 실제 쿠폰 발급 로직 수행 
            return userCouponTransactionalService.issueUserCoupon(storeId, couponId, user);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn(&quot;[Coupon - 쿠폰 발급] 락 인터럽트&quot;, e);
            throw new ApiException(ErrorStatus.STORE_BAD_REQUEST);
        } finally { // 락 해제
            if (lock.isHeldByCurrentThread()) {
                try {
                    lock.unlock();
                } catch (Exception e) {
                    log.error(&quot;[Coupon - 쿠폰 발급] 락 해제 실패&quot;, e);
                }
            }
        }
    }</code></pre>
<ul>
<li><strong>쿠폰 ID 기반으로 락 키 생성</strong><ul>
<li><code>&quot;coupon-lock:id:123&quot;</code> 형태로 고유하게 생성</li>
</ul>
</li>
<li><code>tryLock()</code>으로 락 획득을 시도함<ul>
<li>첫 번째 인자 <code>waitTime</code>: 락을 기다릴 최대 시간 (3초)</li>
<li>두 번째 인자 <code>leaseTime</code>: 락이 자동으로 해제될 시간 (3초)</li>
<li>→ 락이 일정 시간 후 자동으로 풀려 <strong>데드 락 방지</strong></li>
</ul>
</li>
<li>락 획득 실패 시 예외 처리 → <strong>재고가 동시에 소진 중일 경우 안정적 대응</strong></li>
<li>동시성 환경에서도 <strong>정확히 하나의 요청만 쿠폰을 발급</strong></li>
</ul>
<h3 id="📊-결과">📊 결과</h3>
<p><img src="attachment:7f40346f-5d92-4778-bc7a-ce07b49dfd37:image.png" alt="image.png"></p>
<h1 id="4-대용량-데이터-처리-1">4. 대용량 데이터 처리</h1>
<h2 id="🧾-발급-가능한-쿠폰-목록-조회">🧾 발급 가능한 쿠폰 목록 조회</h2>
<h3 id="📌-배경-1">📌 배경</h3>
<ul>
<li><p>유저가 발급 가능한 쿠폰 목록을 조회하기 위해</p>
<p>  →  <code>DiscountCoupon</code> 테이블과 <code>UserCoupon</code> 테이블을 <strong>조인</strong>하여 필터링 수행</p>
</li>
<li><p>유저 수 × 쿠폰 수가 많아질수록 → <strong>조회 성능 급격히 저하</strong></p>
</li>
</ul>
<h3 id="💡-해결-전략-1">💡 해결 전략</h3>
<ul>
<li><strong>DB 인덱스 최적화</strong><ul>
<li><code>user_id + coupon_id + is_used</code>  복합 인덱스 설정 → <strong>조인 및 서브쿼리 성능 개선</strong></li>
</ul>
</li>
<li><strong>DTO Projection</strong> 적용<ul>
<li>조회 대상 컬럼만 선택적으로 가져와 불필요한 데이터 로딩 방지</li>
</ul>
</li>
</ul>
<h3 id="👩💻-구현-코드">👩‍💻 구현 코드</h3>
<pre><code class="language-java">1. DB 인덱스 최적화

// DiscountCoupon : 가게별 쿠폰 + 삭제 여부로 필터링
@Table(name = &quot;discount_coupon&quot;, indexes = @Index(name = &quot;idx_discount_coupon_store_deleted &quot;, columnList = &quot;store_id, is_deleted&quot;))

// UserCoupon : (user, coupon) 유일성 보장 + 미사용 쿠폰만 필터링
@Table(name = &quot;user_coupon&quot;, uniqueConstraints = {
    @UniqueConstraint(columnNames = {&quot;user_id&quot;, &quot;coupon_id&quot;})
}, indexes = {
    @Index(name = &quot;idx_user_coupon_user_coupon_used&quot;, columnList = &quot;user_id, coupon_id, is_used&quot;)
})</code></pre>
<pre><code class="language-java"> 2. DTO Projection

    @Query(&quot;&quot;&quot;
    SELECT new com.example.springplusteamproject.domain.coupon.dto.response.IssuableUserCouponResponseDto(
        dc.id, dc.couponName, dc.discount, dc.issuedAt, dc.expiresAt, dc.stock
    ) 
    FROM DiscountCoupon dc
    WHERE dc.store.id = :storeId
      AND dc.isDeleted = false
      AND NOT EXISTS (
          SELECT 1
          FROM UserCoupon uc
          WHERE uc.discountCoupon.id = dc.id
            AND uc.user.id = :userId
            AND uc.isUsed = false
      )
    &quot;&quot;&quot;)
    List&lt;IssuableUserCouponResponseDto&gt; findIssuableCouponDtoList(
        @Param(&quot;userId&quot;) Long userId,
        @Param(&quot;storeId&quot;) Long storeId
    );</code></pre>
<h3 id="📊-결과-1">📊 결과</h3>
<table>
<thead>
<tr>
<th>적용 항목</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td>복합 인덱스 적용</td>
<td>대용량 환경에서도 효율적 조회</td>
</tr>
<tr>
<td><code>EXISTS</code> 사용</td>
<td>인덱스 활용 최적화, <strong>최대 43% 성능 개선</strong></td>
</tr>
<tr>
<td>DTO Projection</td>
<td>필요한 필드만 조회, <strong>최대 68% 성능 개선</strong></td>
</tr>
</tbody></table>
<p><img src="attachment:6143de75-0db2-47ab-be4a-59c7963ce1db:image.png" alt="image.png"></p>
<h1 id="5-캐싱-1">5. 캐싱</h1>
<h2 id="🛒--인기-가게-조회">🛒  인기 가게 조회</h2>
<h3 id="📌-배경-2">📌 배경</h3>
<ul>
<li><p><strong>인기 가게 리스트는 조회 빈도가 매우 높음</strong></p>
</li>
<li><p>하지만 실시간으로 자주 변경되지는 않음</p>
<p>  → 매번 DB에서 조회 시 <strong>성능 저하 및 부하</strong> 발생</p>
</li>
</ul>
<h3 id="💡-해결-전략-2">💡 해결 전략</h3>
<ul>
<li><p>Redis 캐싱 활용</p>
<ul>
<li>Redis를 단순 캐시가 아닌 <strong>카운터 저장소로 사용</strong><ul>
<li>각 가게에 대한 <strong>조회 수 증가</strong>를 Redis에서 처리</li>
<li>인기 순위는 사용자 액션(조회수)에 따라 <strong>실시간으로 변동</strong></li>
<li>추후 <strong>DB 저장 방식</strong>으로 변환 용이</li>
</ul>
</li>
</ul>
</li>
<li><p>따라서, 정적인 캐싱이 아닌 <strong>동적인 캐싱 구조</strong> 설계</p>
<p>  → 사용자 행동 기반의 <strong>실시간 인기 가게 목록 제공</strong></p>
</li>
</ul>
<h3 id="👩💻-구현-코드-1">👩‍💻 구현 코드</h3>
<pre><code class="language-java">    // 인기 가게 조회수 기준 정렬 및 TOP 10 추출
    @Override
    public List&lt;StoreResponseDto&gt; getPopularStoresByView() {
        Set&lt;String&gt; keys = longRedisTemplate.keys(&quot;store:viewcount:*&quot;);

        return keys.stream()
            .map(k -&gt; {
                Long id = Long.parseLong(k.replace(&quot;store:viewcount:&quot;, &quot;&quot;));
                Object raw = longRedisTemplate.opsForValue().get(k);
                Long count = (raw instanceof Long) ? (Long) raw
                    : (raw instanceof Integer) ? ((Integer) raw).longValue()
                    : 0L;
                return Map.entry(id, count);
            })
            .sorted((a, b) -&gt; Long.compare(b.getValue(), a.getValue()))
            .limit(10)
            .map(e -&gt; storeRepository.findByIdAndDeletedFalse(e.getKey()).orElse(null))
            .filter(Objects::nonNull)
            .map(StoreResponseDto::fromEntity)
            .toList();
    }

        // 인기 가게 리스트를 10분마다 Redis에 캐싱
    @Scheduled(initialDelay = 0, fixedRate = 600_000)
    public void updatePopularStoresCache() {
        List&lt;StoreResponseDto&gt; topStores = getPopularStoresByView();
        objectRedisTemplate.opsForValue().set(&quot;popular:stores:view&quot;, topStores, CACHE_TTL);
        log.info(&quot;[Store - 캐싱]인기 상점 캐시 갱신 완료 ({}개)&quot;, topStores.size());
    }</code></pre>
<h3 id="📊-결과-2">📊 결과</h3>
<ul>
<li><strong>테스트 설계</strong></li>
</ul>
<pre><code>| 항목 | 내용 |
| --- | --- |
| 목적 | 커서 기반 **DB조회** vs **Redis 캐시** 기반 인기 상점 조회 성능 비교 |
| 실행 환경 | Local |
| 도구 | k6 |
| 테스트 대상 | 1,000 ~ 10,000명의 유저의 동시 요청 시나리오 |
| 부하 단계 | 1단계 : 1~10 초간 1,000명의 유저가 동시 요청, 2단계 : 11~20 초간 5,000명의 유저가 동시 요청, 3단계 : 21~30 초간 10,000명의 유저가 동시 요청 |

1 단계 부하 테스트 결과 요약

| 항목 | DB 커서 조회 | Redis 캐시 조회 |
| --- | --- | --- |
| 평균 응답 | 347ms | **238ms** |
| 최대 응답 | 804ms | **595ms** |
| 95% 응답 | 457ms | **317ms** |
| 요청 처리량
(req/s) | 2,761 | **4,184** |
| 총 요청 수 | 28,506 | **41,657** |
| 실패율 | 1.8% | **1.2%** |

1 ~3 단계 부하 테스트 결과 요약

| 항목 | DB 커서 조회 | Redis 캐시 조회 |
| --- | --- | --- |
| 평균 응답  | 1.6s | **1.06s** |
| 최대 응답 | 13.5s | **13.5s** |
| 95% 응답 | 3.37s | **2.34s** |
| 요청 처리량(req/s) | 1,774 | **2,155** |
| 총 요청 수 | 108,000 | **150,845** |
| 실패율 | 5.6% | 5.6% |</code></pre><blockquote>
<p><strong>캐시는 빠르지만, 갱신 타이밍이나 Redis saturation 시 실패율이 증가하였음</strong>
    1. 캐시가 응답 시간, 처리량 면에서 <strong>전 구간 우세</strong>
    2. <strong>Redis saturation 시점(갱신 or 트래픽 픽크)</strong>에 실패율 급증
    3. 캐시가 빠르지만 <strong>구조적 보완 필요 → 추후 개선</strong> </p>
</blockquote>
<h1 id="6-성과-및-회고-1">6. 성과 및 회고</h1>
<h2 id="👩💻-추후-개선-내용">👩‍💻 <strong>추후 개선 내용</strong></h2>
<hr>
<h3 id="✅-redis-캐싱-구조-개선">✅ Redis 캐싱 구조 개선</h3>
<h3 id="문제점">‼문제점</h3>
<ul>
<li>Redis는 빠르고 확장성 높지만 <strong>TTL 만료, 동시 갱신 충돌, SPOF, 부하 집중 시 불안정</strong></li>
</ul>
<h3 id="💡-개선-방향">💡 개선 방향</h3>
<ul>
<li><p><strong>Double Buffering</strong></p>
<p>  → 캐시 갱신 중 충돌 방지 (기존 캐시 유지 + 신규 캐시 준비 후 전환)</p>
</li>
<li><p><strong>Lazy TTL /  Fallback</strong></p>
<p>  → TTL 만료 시 캐시 miss 최소화 및 DB fallback 후 재캐싱</p>
</li>
<li><p><strong>Redis 구조 확장</strong></p>
<p>  <strong>→ Cluster</strong>: 샤딩 통한 수평 확장</p>
<p>  <strong>→ Sentinel</strong>: 자동 Failover로 고가용성</p>
<p>  <strong>→  Pipeline</strong>: 명령어 일괄 처리로 속도 향상</p>
</li>
<li><p>⚠️ <strong>Null 응답 + DB fallback</strong></p>
<pre><code> → 캐시 miss 시 null 반환 → DB 조회 후 재캐싱 (병목 최소화)</code></pre></li>
</ul>
<h3 id="✅-재고-감소-redis-리팩토링">✅ 재고 감소 Redis 리팩토링</h3>
<h3 id="문제점-1">‼문제점</h3>
<ul>
<li><p><code>createOrder</code>가 <strong>조회 ~ 주문 생성까지 과도한 책임</strong> 수행 → SRP 위반</p>
</li>
<li><p>내부에서 <code>@Transactional</code> + <code>REQUIRES_NEW</code> 중첩 호출</p>
<p>  → <strong>재고는 차감됐지만 주문은 롤백되는</strong> 트랜잭션 충돌</p>
<p>  → <strong>락 해제 누락 시 Deadlock 위험</strong></p>
</li>
</ul>
<h3 id="💡-개선-방향-1">💡 개선 방향</h3>
<p><strong>✔ 락 → 트랜잭션 → 커밋 → 락 해제</strong> 흐름 보장</p>
<ol>
<li>Controller에서 <code>lockAndCreateOrder</code> 호출 → <strong>락 먼저 획득</strong></li>
<li>Service에서 단일 <code>@Transactional</code>로 <strong>재고 차감부터 주문 생성까지 일괄 처리</strong></li>
</ol>
<h3 id="👩💻-구현-코드-2">👩‍💻 구현 코드</h3>
<ul>
<li><strong>락을 먼저 획득</strong></li>
</ul>
<pre><code class="language-java">public Flower lockAndDecreaseStock(OrderRequestDTO dto, CustomUserPrincipal principal) {
        String lockKey = &quot;order-lock:user:&quot; + principal.getId();
    RLock lock = redissonClient.getFairLock(lockKey);

    try {
        if (!lock.tryLock(500, -1, TimeUnit.MILLISECONDS)) {
            throw new RuntimeException(락 획득 실패: &quot; + lockKey);
        }

        return orderService.createOrder(dto, principal);

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new ApiException(ErrorStatus.ORDER_BAD_REQUEST);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}</code></pre>
<ul>
<li><strong>Create 호출</strong></li>
</ul>
<pre><code class="language-java">@Transactional
public OrderResponseDTO createOrder(
OrderRequestDTO requestDTO, CustomUserPrincipal principal
) {
    //유저 조회
    User user = userRepository.findById(principal.getId())
        .orElseThrow(() -&gt; new ApiException(ErrorStatus.ORDER_USER_NOT_FOUND));

    List&lt;OrderItem&gt; orderItems = new ArrayList&lt;&gt;();
    //전체 금액
    int priceTotal = 0;
    Long discount = 0L;
    for(OrderItemRequestDTO item : requestDTO.getItems()) {
        // 꽃 Id(상품Id)
        Long flowerId = item.getFlowerId();
        //수량
        int quantity = item.getQuantity();

        Flower flower = flowerRepository.findById(flowerId)
            .orElseThrow(() -&gt; new ApiException(ErrorStatus.ORDER_FLOWER_NOTFOUND));
            log.info(&quot;재고 감소전 &gt;&gt;&gt; {}&quot;, flower.getStock());
            flower.decreaseStock(quantity);
            log.info(&quot;재고 감소후 &gt;&gt;&gt; {}&quot;, flower.getStock());

        //단가
        int unitPrice = foundFlower.getPrice();
        int subTotal = unitPrice * quantity;
        priceTotal += subTotal;

        Price price = Price.of(subTotal, discount);
        OrderItem orderItem = OrderItem.of(null, foundFlower, quantity, price);
        orderItems.add(orderItem);
    }
    UserCoupon foundUserCoupon = null;

    //쿠폰이있으면
    if(requestDTO.getUserCouponId() != null) {
            foundUserCoupon = userCouponRepository
                .findById(requestDTO.getUserCouponId())
                .orElseThrow(() -&gt; 
                    new ApiException(ErrorStatus.ORDER_COUPON_NOTFOUND));

        //본인확인
        if(!foundUserCoupon.getUser().getId().equals(user.getId())){
            throw new ApiException(ErrorStatus.ORDER_COUPON_OWNER_MISMATCH);
        }

        //중복 방지
        if(foundUserCoupon.isUsed()){
            throw new ApiException(ErrorStatus.ORDER_COUPON_ALREADY_USED);
        }

        //할인
        discount = foundUserCoupon.getDiscountCoupon().getDiscount();
        //dirty
        foundUserCoupon.useCoupon();

    }
    //가격 저장
    Price price = Price.of(priceTotal,discount);

    //주문 저장
    Order order = Order.of(user, price, orderItems);

    if(foundUserCoupon != null){
        order.setUserCoupon(foundUserCoupon);
    }
    Order savedOrder = orderRepository.save(order);

    //결제 API

    //dirty PAID
    order.updateStatus();
    return OrderResponseDTO.from(savedOrder);
}
</code></pre>
<h2 id="🧾최종-정리">🧾<strong>최종 정리</strong></h2>
<p>이번 프로젝트 “꽃향기만 남기고 갔단다”는 실제 쇼핑몰 서비스 구조를 기반으로,</p>
<p><strong>동시성 제어</strong>, <strong>대용량 데이터 처리</strong>, <strong>캐싱 구조 고도화</strong> 등</p>
<p><strong>실무 중심의 백엔드 핵심 기술을 직접 다뤄볼 수 있었던 경험</strong>이었습니다.</p>
<ul>
<li><p>Redis 기반 <strong>분산 락</strong>과 <strong>비관적 락</strong>을 통해 <strong>재고, 쿠폰 발급 등의 동시성 문제 해결</strong></p>
</li>
<li><p><strong>복합 인덱스 + DTO Projection</strong>으로 대용량 데이터 <strong>조회 성능 최적화</strong></p>
</li>
<li><p>Redis 캐시를 <strong>단순 저장소가 아닌 실시간 인기 카운터</strong>로 활용</p>
<p>  → <strong>k6 기반 부하 테스트</strong>를 통해 성능 수치화 및 병목 파악, 개선 포인트 도출</p>
</li>
</ul>
<p>또한, 기술적인 고도화 외에도</p>
<p><strong>팀원 간 자유로운 소통</strong>, <strong>정기적인 회의</strong>, <strong>문서 기반 협업</strong>을 통해</p>
<p>실제 개발 <strong>협업</strong>에서의 <strong>유연한 커뮤니케이션</strong>과 <strong>협업 역량의 중요성</strong>을 <strong>체감</strong>할 수 있었습니다.</p>
<p>다만,</p>
<ul>
<li><strong>일정 관리 미흡</strong></li>
<li><strong>트러블 슈팅 및 기술 회고의 부족</strong></li>
<li><strong>코드 주석 작성의 일관성 부족</strong></li>
</ul>
<p>등은 다음 프로젝트에서 <strong>개선</strong>해야 할 <strong>과제</strong>로 남았습니다.</p>
<p>이번 프로젝트는 단순한 기능 구현을 넘어,</p>
<p><strong>서비스의 확장성과 안정성</strong>을 <strong>고려</strong>한 <strong>기술 설계</strong>와 <strong>협업 경험</strong>을 모두 담아낸 <strong>소중한 기회</strong>였습니다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]Docker]]></title>
            <link>https://velog.io/@giwon_wg/TILDocker</link>
            <guid>https://velog.io/@giwon_wg/TILDocker</guid>
            <pubDate>Wed, 14 May 2025 11:19:10 GMT</pubDate>
            <description><![CDATA[<h1 id="1-cicd">1. CI/CD</h1>
<h2 id="1-cicd란">1. CI/CD란?</h2>
<h3 id="1-정의">1. 정의</h3>
<ul>
<li><strong>CI</strong>: Continuous Integration (지속적인 통합)</li>
<li><strong>CD</strong>: Continuous Delivery / Deployment (지속적인 제공/배포)</li>
</ul>
<h3 id="2-특징">2. 특징</h3>
<ul>
<li>자동화된 빌드, 테스트, 배포</li>
<li>안정적이고 빠른 코드 제공 가능</li>
<li>스크럼 기반의 애자일 개발과 잘 어울림</li>
</ul>
<h3 id="3-전통적인-배포-vs-현대적-배포">3. 전통적인 배포 vs 현대적 배포</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>전통적 방식</th>
<th>현대적 방식</th>
</tr>
</thead>
<tbody><tr>
<td>배포 방식</td>
<td>수동, 롤백 어려움</td>
<td>자동화된 테스트 + 배포</td>
</tr>
<tr>
<td>서버 구성</td>
<td>개별 설정 필요</td>
<td>Docker 등으로 표준화</td>
</tr>
<tr>
<td>속도</td>
<td>느림</td>
<td>빠름</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-docker-기초">2. Docker 기초</h2>
<h3 id="1-왜-docker인가">1. 왜 Docker인가?</h3>
<ul>
<li>환경 차이 없이 동일한 컨테이너 환경에서 실행 가능</li>
<li>독립적이고 확장성 있는 배포 가능</li>
<li>CI/CD와의 궁합 좋음</li>
<li>호스트 OS에 영향 없음</li>
</ul>
<hr>
<h2 id="3-docker-설치">3. Docker 설치</h2>
<h3 id="windows">Windows</h3>
<ul>
<li>WSL2 설치: <code>wsl --install</code></li>
<li>Ubuntu 22.04 설치</li>
<li>Docker GPG 키 및 리포지토리 설정 후 설치</li>
<li>Docker Desktop 설정에서 WSL 연동</li>
</ul>
<hr>
<h2 id="4-container-실행-테스트">4. Container 실행 테스트</h2>
<pre><code class="language-bash">docker image pull nginx:1.25.3-alpine
docker run -d -p 8001:80 --name webserver01 nginx:1.25.3-alpine
curl localhost:8001</code></pre>
<hr>
<h2 id="5-docker-image-관리">5. Docker Image 관리</h2>
<h3 id="1-개념">1. 개념</h3>
<ul>
<li><strong>Stateless</strong>: 상태 비저장</li>
<li><strong>Immutable</strong>: 변경 불가 이미지</li>
</ul>
<h3 id="2-주요-명령어">2. 주요 명령어</h3>
<pre><code class="language-bash">docker pull nginx:latest
docker image inspect nginx:latest
docker image history nginx:latest
docker login
docker logout</code></pre>
<hr>
<h2 id="6-docker-container-관리">6. Docker Container 관리</h2>
<h3 id="1-image-vs-container">1. Image vs Container</h3>
<ul>
<li>Image: 레시피(붕어빵 틀)</li>
<li>Container: 실행된 인스턴스(붕어빵)</li>
</ul>
<h3 id="2-주요-명령어-1">2. 주요 명령어</h3>
<pre><code class="language-bash">docker run -ti --name test ubuntu:22.04
docker start test
docker attach test
docker ps -a
docker stop test
docker rm test
docker logs test
docker inspect test</code></pre>
<h3 id="3-포트-로그-리소스-확인">3. 포트, 로그, 리소스 확인</h3>
<pre><code class="language-bash">docker port test
docker stats test
docker logs -f test</code></pre>
<hr>
<h2 id="7-docker-파일-생성-및-실행-실습">7. Docker 파일 생성 및 실행 실습</h2>
<pre><code class="language-bash">mkdir nodejsapp &amp;&amp; cd nodejsapp
# app.js 및 Dockerfile 생성
docker buildx build -t node-test:1.0 .
docker run -itd -p 6060:6060 --name=node-test node-test:1.0
curl http://localhost:6060</code></pre>
<hr>
<h2 id="8-정리-및-정리-명령어">8. 정리 및 정리 명령어</h2>
<h3 id="1-리소스-정리">1. 리소스 정리</h3>
<pre><code class="language-bash">docker container prune         # 중지된 컨테이너 삭제
docker image prune             # Dangling 이미지 삭제
docker system prune            # 전체 정리</code></pre>
<hr>
<h1 id="2-github--cicd">2. Github &amp; CI/CD</h1>
<h2 id="1-github-actions란">1. Github Actions란?</h2>
<h3 id="정의">정의</h3>
<ul>
<li>Github에 내장된 CI/CD 도구</li>
<li><code>.github/workflows</code> 디렉토리에 YAML 파일 작성</li>
<li>무료 제공: 500MB 저장공간 + 월 2000분</li>
</ul>
<hr>
<h2 id="2-github-actions로-ci-구성">2. Github Actions로 CI 구성</h2>
<h3 id="예시-워크플로우">예시 워크플로우</h3>
<pre><code class="language-yaml">name: CI
on:
  push:
    branches: [develop, feature/*]
  pull_request:
    branches: [develop]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: java setup
        uses: actions/setup-java@v2
        with:
          distribution: &#39;adopt&#39;
          java-version: &#39;17&#39;
      - name: run unittest
        run: ./gradlew clean test</code></pre>
<hr>
<h2 id="3-github-actions로-cd-구성">3. Github Actions로 CD 구성</h2>
<h3 id="예시-워크플로우-1">예시 워크플로우</h3>
<pre><code class="language-yaml">name: CD
on:
  push:
    branches: [main]

jobs:
  cd:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: java setup
        uses: actions/setup-java@v3
        with:
          distribution: &#39;adopt&#39;
          java-version: &#39;17&#39;
      - name: run unittest
        run: ./gradlew clean test
      - name: deploy to heroku
        uses: akhileshns/heroku-deploy@v3.12.12
        with:
          heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
          heroku_app_name: &quot;sampleapp-github-actions&quot;
          heroku_email: &quot;nbcdocker@proton.me&quot;</code></pre>
<hr>
<h2 id="4-github-actions-주요-구성-요소">4. Github Actions 주요 구성 요소</h2>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Workflow</td>
<td>전체 자동화된 프로세스</td>
</tr>
<tr>
<td>Event</td>
<td>트리거(예: push, PR)</td>
</tr>
<tr>
<td>Runner</td>
<td>작업을 실행하는 가상머신</td>
</tr>
<tr>
<td>Job</td>
<td>여러 Step 묶음</td>
</tr>
<tr>
<td>Step</td>
<td>개별 명령 또는 Action</td>
</tr>
<tr>
<td>Action</td>
<td>재사용 가능한 기능 단위</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-예제-실행">5. 예제 실행</h2>
<h3 id="간단한-데모-워크플로우">간단한 데모 워크플로우</h3>
<pre><code class="language-yaml">name: GitHub Actions Demo
on: [push]

jobs:
  Explore-GitHub-Actions:
    runs-on: ubuntu-latest
    steps:
      - run: echo &quot;🎉 자동 트리거됨!&quot;
      - run: echo &quot;🐧 OS: ${{ runner.os }}&quot;
      - name: Check out repository
        uses: actions/checkout@v4</code></pre>
<hr>
<h2 id="6-spring-boot--github-actions-ci">6. Spring Boot + Github Actions (CI)</h2>
<ul>
<li>develop, feature 브랜치에 push 또는 PR 발생 시 gradle test 실행</li>
</ul>
<h3 id="✔-예시-githubworkflowsrun-testyaml">✔ 예시: <code>.github/workflows/run-test.yaml</code></h3>
<pre><code class="language-yaml">name: Run Test
on:
  push:
    branches: [develop, feature/*]
  pull_request:
    branches: [develop]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: java setup
        uses: actions/setup-java@v2
        with:
          distribution: &#39;adopt&#39;
          java-version: &#39;17&#39;
      - name: make executable gradlew
        run: chmod +x ./gradlew
      - name: run unittest
        run: ./gradlew clean test</code></pre>
<hr>
<h2 id="7-cloudtype-배포-자동화">7. Cloudtype 배포 자동화</h2>
<h3 id="1-개념-흐름">1. 개념 흐름</h3>
<ol>
<li>feature/** → PR 생성 → 자동 테스트</li>
<li>PR 성공 시 → main 브랜치 merge</li>
<li>main → 자동 배포 to cloudtype</li>
</ol>
<h3 id="2-pr-테스트용-test-pryml">2. PR 테스트용: <code>test-pr.yml</code></h3>
<pre><code class="language-yaml">name: test every pr
on:
  workflow_dispatch:
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: setup jdk
        uses: actions/setup-java@v3
        with:
          java-version: &#39;17&#39;
          distribution: &#39;temurin&#39;
          cache: gradle
      - name: Grant execute permission
        run: chmod +x ./gradlew
      - name: gradlew test
        run: ./gradlew test</code></pre>
<h3 id="3-cloudtype-배포용-deploy-mainyml">3. Cloudtype 배포용: <code>deploy-main.yml</code></h3>
<pre><code class="language-yaml">name: Deploy to cloudtype
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Connect deploy key
        uses: cloudtype-github-actions/connect@v1
        with:
          token: ${{ secrets.CLOUDTYPE_TOKEN }}
          ghtoken: ${{ secrets.GHP_TOKEN }}
      - name: Deploy
        uses: cloudtype-github-actions/deploy@v1
        with:
          token: ${{ secrets.CLOUDTYPE_TOKEN }}
          project: nbc.docker/cicd
          stage: main
          yaml: |
            name: cicd
            app: java@17
            options:
              ports: 8080
            context:
              git:
                url: git@github.com:${{ github.repository }}
                ref: ${{ github.ref }}
              preset: java-springboot</code></pre>
<hr>
<h2 id="8-환경-설정-가이드">8. 환경 설정 가이드</h2>
<h3 id="1-cloudtype">1. Cloudtype</h3>
<ul>
<li>가입: <a href="https://app.cloudtype.io/">cloudtype.io</a></li>
<li>카드 등록 후 무료 요금제 활성화 필요</li>
<li>API 키 발급 후 Github Secrets에 등록</li>
</ul>
<h3 id="2-github-secrets-등록">2. Github Secrets 등록</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>GHP_TOKEN</code></td>
<td>Github Personal Token</td>
</tr>
<tr>
<td><code>CLOUDTYPE_TOKEN</code></td>
<td>Cloudtype API Key</td>
</tr>
</tbody></table>
<hr>
<h1 id="3-docker">3. Docker</h1>
<h2 id="1-dockerfile로-나만의-docker-image-만들기">1. Dockerfile로 나만의 Docker Image 만들기</h2>
<h3 id="1-개념-1">1. 개념</h3>
<ul>
<li>Dockerfile은 앱 실행을 위한 모든 환경 설정 레시피</li>
<li>동일한 환경, 자동화된 이미지 생성, 재사용 가능</li>
</ul>
<h3 id="2-예제">2. 예제</h3>
<pre><code class="language-dockerfile">FROM ubuntu:latest
RUN apt-get update &amp;&amp; apt-get install -y nginx
COPY index.html /usr/share/nginx/html
EXPOSE 80
CMD [&quot;nginx&quot;, &quot;-g&quot;, &quot;daemon off;&quot;]</code></pre>
<h3 id="3-명령어-정리">3. 명령어 정리</h3>
<table>
<thead>
<tr>
<th>명령어</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>FROM</td>
<td>베이스 이미지 지정</td>
</tr>
<tr>
<td>RUN</td>
<td>명령 실행</td>
</tr>
<tr>
<td>COPY</td>
<td>파일 복사</td>
</tr>
<tr>
<td>CMD / ENTRYPOINT</td>
<td>컨테이너 시작 시 실행 명령</td>
</tr>
<tr>
<td>EXPOSE</td>
<td>외부 노출 포트 설정</td>
</tr>
<tr>
<td>WORKDIR</td>
<td>작업 디렉토리 설정</td>
</tr>
<tr>
<td>ENV</td>
<td>환경변수 설정</td>
</tr>
<tr>
<td>USER</td>
<td>사용자 변경</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-docker-compose를-사용한-멀티-컨테이너-관리">2. Docker Compose를 사용한 멀티 컨테이너 관리</h2>
<h3 id="1-docker-compose란">1. Docker Compose란?</h3>
<ul>
<li>여러 컨테이너를 하나의 YAML 파일로 정의</li>
<li>의존성 관리, 포트 설정, 환경변수 관리 가능</li>
</ul>
<h3 id="2-예제-docker-composeyaml">2. 예제: <code>docker-compose.yaml</code></h3>
<pre><code class="language-yaml">version: &#39;3&#39;
services:
  web:
    image: nginx:latest
    ports:
      - 80:80
    depends_on:
      - api
  api:
    image: java:latest
    ports:
      - 8080:8080
    environment:
      - MYSQL_HOST=mysql
  mysql:
    image: mysql:latest
    ports:
      - 3306:3306
    environment:
      - MYSQL_ROOT_PASSWORD=password</code></pre>
<hr>
<h2 id="3-docker-compose-명령어">3. Docker Compose 명령어</h2>
<table>
<thead>
<tr>
<th>명령어</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>docker-compose up -d</code></td>
<td>백그라운드 실행</td>
</tr>
<tr>
<td><code>docker-compose down</code></td>
<td>컨테이너 및 네트워크 삭제</td>
</tr>
<tr>
<td><code>docker-compose logs</code></td>
<td>로그 출력</td>
</tr>
<tr>
<td><code>docker-compose exec</code></td>
<td>실행 중인 컨테이너 명령 실행</td>
</tr>
<tr>
<td><code>docker-compose run</code></td>
<td>일회성 컨테이너 실행</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-모니터링과-로깅">4. 모니터링과 로깅</h2>
<h3 id="1-docker-stats">1. docker stats</h3>
<pre><code class="language-bash">docker stats</code></pre>
<ul>
<li>CPU, 메모리 사용량, I/O 실시간 확인</li>
</ul>
<h3 id="2-htop-도구-설치-및-실행">2. htop 도구 설치 및 실행</h3>
<pre><code class="language-bash">docker exec -ti test-tools /bin/bash
apt update &amp;&amp; apt install htop -y
htop</code></pre>
<h3 id="3-디스크-확인">3. 디스크 확인</h3>
<pre><code class="language-bash">docker exec -ti test-tools /bin/bash
df -h
du -sh</code></pre>
<hr>
<h2 id="5-컨테이너-로깅">5. 컨테이너 로깅</h2>
<h3 id="1-로그-확인">1. 로그 확인</h3>
<pre><code class="language-bash">docker logs [컨테이너명]
docker logs -f --tail 10 [컨테이너명]</code></pre>
<h3 id="2-로그-파일-경로-확인">2. 로그 파일 경로 확인</h3>
<pre><code class="language-bash">docker inspect [컨테이너명] --format &quot;{{.LogPath}}&quot;</code></pre>
<h3 id="3-로그-로테이션-예제">3. 로그 로테이션 예제</h3>
<pre><code class="language-bash">docker run -d \
  --log-driver json-file \
  --log-opt max-size=10m \
  --log-opt max-file=5 \
  --name nginxtest \
  nginx:latest</code></pre>
<h3 id="4-docker-compose-로-설정">4. docker-compose 로 설정</h3>
<pre><code class="language-yaml">services:
  app:
    logging:
      driver: &#39;json-file&#39;
      options:
        max-size: &#39;10m&#39;
        max-file: &#39;10&#39;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.05.12]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.05.12</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.05.12</guid>
            <pubDate>Mon, 12 May 2025 11:39:47 GMT</pubDate>
            <description><![CDATA[<h1 id="2025-05-12-spring-boot--kotlin-기반-프로젝트">2025-05-12 Spring Boot + Kotlin 기반 프로젝트</h1>
<hr>
<h2 id="프로젝트-전환-및-기본-설정">프로젝트 전환 및 기본 설정</h2>
<h3 id="1-kotlin-기반-프로젝트">1. Kotlin 기반 프로젝트</h3>
<ul>
<li>기존 Java 기반 코드에서 Kotlin으로 전환.</li>
</ul>
<h3 id="2-디렉토리-구조-정리">2. 디렉토리 구조 정리</h3>
<ul>
<li><code>/src/main/kotlin</code>에 코어 비즈니스 로직 이전.</li>
<li><code>/java/org.example.expert</code> → <code>/kotlin/com.example.domain</code>로 이전하며 패키지 통일.</li>
</ul>
<h3 id="3-공통-설정-파일">3. 공통 설정 파일</h3>
<ul>
<li><code>application.yml</code> 구성 완료.</li>
<li><code>.env</code> 파일을 통해 외부 환경변수 분리.</li>
<li><code>SECRET_KEY</code>, <code>MYSQL_USERNAME</code> 등 중요한 값들은 <code>.env</code>로 관리.</li>
</ul>
<hr>
<h2 id="보안--인증-시스템-구축">보안 / 인증 시스템 구축</h2>
<h3 id="1-jwt-유틸-클래스-구현-jwtutilkt">1. JWT 유틸 클래스 구현 (<code>JwtUtil.kt</code>)</h3>
<ul>
<li>HS256 알고리즘 기반 JWT 생성 및 파싱 구현.</li>
<li>Claims 필드: <code>userId</code>, <code>email</code>, <code>userRole</code>, <code>nickname</code></li>
<li>만료 시간: 60분 설정.</li>
</ul>
<pre><code class="language-kotlin">@PostConstruct
fun init() {
    val bytes = Base64.getDecoder().decode(secretKey)
    key = Keys.hmacShaKeyFor(bytes)
}</code></pre>
<h3 id="2-jwt-필터-구현-jwtauthenticationfilterkt">2. JWT 필터 구현 (<code>JwtAuthenticationFilter.kt</code>)</h3>
<ul>
<li><code>OncePerRequestFilter</code> 상속.</li>
<li>토큰 유효 시 사용자 인증 정보를 <code>SecurityContextHolder</code>에 등록.</li>
</ul>
<h3 id="3-spring-security-설정-securityconfigkt">3. Spring Security 설정 (<code>SecurityConfig.kt</code>)</h3>
<ul>
<li><code>/auth/**</code>는 허용</li>
<li><code>/admin/**</code>은 ADMIN Role만 허용</li>
<li>나머지는 모두 인증 필요</li>
<li>Stateless 정책, Form Login / HTTP Basic 비활성화</li>
</ul>
<pre><code class="language-kotlin">http.csrf().disable()
    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)</code></pre>
<hr>
<h2 id="사용자-기능">사용자 기능</h2>
<h3 id="1-user-엔티티-변경">1. User 엔티티 변경</h3>
<ul>
<li><code>User.kt</code> 클래스에 <code>profileImage</code> 필드 추가</li>
<li><code>fromAuthUser()</code> 정적 팩토리 메서드 도입</li>
<li><code>changePassword</code>, <code>updateRole</code>, <code>setProfileImage</code> 메서드 구현</li>
</ul>
<h3 id="2-usercontrollerkt--userservicekt">2. UserController.kt / UserService.kt</h3>
<ul>
<li>비밀번호 변경 API (<code>/users</code>, PUT)</li>
<li>사용자 정보 조회 (<code>/users/{userId}</code>)</li>
<li>닉네임 기반 조회 캐싱 적용 (<code>@Cacheable</code>)</li>
</ul>
<h3 id="3-예외-처리">3. 예외 처리</h3>
<ul>
<li><code>InvalidRequestException</code>, <code>AuthException</code>, <code>ServerException</code> 공통 처리</li>
<li><code>@RestControllerAdvice</code> 기반 글로벌 예외 핸들러 작성</li>
</ul>
<hr>
<h2 id="todo-기능">Todo 기능</h2>
<h3 id="1-todo-엔티티-및-repository">1. Todo 엔티티 및 Repository</h3>
<ul>
<li><code>title</code>, <code>contents</code>, <code>weather</code>, <code>user</code> 필드 포함</li>
<li><code>findAllByOrderByModifiedAtDesc()</code> / <code>searchTodos()</code> 구현</li>
</ul>
<h3 id="2-todoservicekt">2. TodoService.kt</h3>
<ul>
<li><code>saveTodo</code>: 날씨 조회 후 저장</li>
<li><code>getTodos</code>: 페이징 조회</li>
<li><code>searchTodos</code>: 조건부 검색</li>
<li><code>getTodo</code>: 단일 조회 + User join</li>
<li><code>searchTodosWithFilters</code>: QueryDSL 기반 키워드, 닉네임, 날짜 필터링</li>
</ul>
<h3 id="3-todoqueryrepositoryimpl">3. TodoQueryRepositoryImpl</h3>
<ul>
<li><code>Projections.constructor</code>를 사용한 커스텀 DTO 매핑</li>
<li><code>groupBy</code>, <code>leftJoin</code>, <code>where</code>절 조합</li>
<li>count 쿼리 분리하여 페이징 대응</li>
</ul>
<hr>
<h2 id="manager-기능">Manager 기능</h2>
<h3 id="1-manager-엔티티">1. Manager 엔티티</h3>
<ul>
<li><code>@ManyToOne</code> 연관관계로 <code>User</code>, <code>Todo</code>를 소유</li>
<li><code>@Id</code>, <code>@GeneratedValue</code> 전략</li>
</ul>
<h3 id="2-managerservice">2. ManagerService</h3>
<ul>
<li><code>saveManager</code>: 일정 작성자와의 매칭 여부 검증</li>
<li><code>deleteManager</code>: 생성자만 삭제 가능</li>
<li><code>getManagers</code>: 일정별 매니저 목록 조회</li>
</ul>
<h3 id="3-로깅-기능-추가">3. 로깅 기능 추가</h3>
<ul>
<li><code>Log</code> 엔티티: <code>action</code>, <code>details</code>, <code>createdAt</code></li>
<li><code>LogService</code>: <code>@Transactional(REQUIRES_NEW)</code>로 로그 분리 저장</li>
<li>예외가 발생해도 로그는 저장되도록 <code>finally</code> 블록 활용</li>
</ul>
<hr>
<h2 id="comment-기능">Comment 기능</h2>
<h3 id="1-comment-엔티티">1. Comment 엔티티</h3>
<ul>
<li><code>contents</code>, <code>user</code>, <code>todo</code>, <code>Timestamped</code> 상속</li>
</ul>
<h3 id="2-commentservice">2. CommentService</h3>
<ul>
<li><code>saveComment</code>: Todo 존재 여부 확인 후 저장</li>
<li><code>getComments</code>: TodoId로 Comment 전체 조회 (User Join 포함)</li>
</ul>
<hr>
<h2 id="aws--redis-설정">AWS / Redis 설정</h2>
<h3 id="1-s3-연동-s3servicekt">1. S3 연동 (<code>S3Service.kt</code>)</h3>
<ul>
<li><code>updateFile()</code> 메서드 구현</li>
<li>파일은 UUID 기반 이름으로 업로드</li>
</ul>
<h3 id="2-redis-설정-redisconfigkt">2. Redis 설정 (<code>RedisConfig.kt</code>)</h3>
<ul>
<li><code>RedisTemplate</code> 및 <code>RedisCacheManager</code> 설정</li>
<li>TTL: 10분</li>
</ul>
<hr>
<h2 id="테스트-및-부가사항">테스트 및 부가사항</h2>
<ul>
<li><code>@GetMapping(&quot;/health&quot;)</code>: Health Check API 제공</li>
<li><code>AdminAccessLoggingAspect</code>: AOP로 <code>UserController.getUser()</code> 접근 로그 출력</li>
<li>전체적으로 <code>!!</code>, <code>?</code>, <code>lateinit</code> 등의 코틀린 nullable 처리 점검</li>
<li><code>@Valid</code>, <code>@RequestParam</code>, <code>@PageableDefault</code> 등 다양한 Spring 어노테이션 적용</li>
</ul>
<hr>
<h2 id="해결한-문제-목록">해결한 문제 목록</h2>
<h3 id="passwordencoder-bean-오류"><code>PasswordEncoder</code> Bean 오류</h3>
<ul>
<li>의존성 누락 문제였으나, <code>Spring Boot Starter Security</code>로 해결됨</li>
</ul>
<h3 id="jwtutil-빈-주입-실패"><code>JwtUtil</code> 빈 주입 실패</h3>
<ul>
<li><code>SECRET_KEY</code> 환경변수가 <code>.env</code>로만 존재할 때 인식 안됨</li>
<li>해결 방법: <code>.env</code> -&gt; OS 환경변수로 등록 or <code>.yml</code>에 직접 선언</li>
</ul>
<h3 id="localdatetime-→-localdatetime-타입-불일치"><code>LocalDateTime?</code> → <code>LocalDateTime</code> 타입 불일치</h3>
<ul>
<li>Repository 쿼리에서 nullable 타입이어서 타입 mismatch 오류 발생</li>
<li><code>todoRepository.searchTodos(pageable, weather, start ?: now(), end ?: now())</code> 식으로 대응</li>
</ul>
<h3 id="generated-q-클래스-import-오류"><code>@Generated Q 클래스 import 오류</code></h3>
<ul>
<li>Kotlin, Java 혼합 프로젝트에서 QueryDSL의 <code>Q클래스</code> import 경로 꼬임 문제 발생</li>
<li>해결: QueryDSL 생성 경로를 <code>kotlin</code>에 통일 / <code>querydslDir</code> 명시</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]Kotlin]]></title>
            <link>https://velog.io/@giwon_wg/TILKotlin</link>
            <guid>https://velog.io/@giwon_wg/TILKotlin</guid>
            <pubDate>Fri, 09 May 2025 11:28:23 GMT</pubDate>
            <description><![CDATA[<h1 id="kotlin이란">Kotlin이란?</h1>
<h2 id="1-kotlin-vs-java-기본-비교">1. Kotlin vs Java 기본 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Java</th>
<th>Kotlin</th>
</tr>
</thead>
<tbody><tr>
<td>클래스</td>
<td><code>class User {}</code></td>
<td><code>class User</code></td>
</tr>
<tr>
<td>메서드</td>
<td><code>void run()</code></td>
<td><code>fun run(): Unit</code></td>
</tr>
<tr>
<td>게터/세터</td>
<td><code>getX()</code>, <code>setX()</code></td>
<td><code>val x</code> / <code>var x</code> (자동 생성)</td>
</tr>
<tr>
<td>null 처리</td>
<td>일반 타입에 null 할당 가능</td>
<td><code>String?</code> → nullable 명시 필요</td>
</tr>
<tr>
<td>데이터 클래스</td>
<td>POJO + Lombok</td>
<td><code>data class</code> 한 줄</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-nullable-과-non-nullable">2. Nullable (<code>?</code>)과 Non-nullable</h2>
<pre><code class="language-kotlin">val name: String = &quot;hello&quot;       // null 불가
val nickname: String? = null     // null 가능</code></pre>
<ul>
<li>Kotlin은 null safety 기본 제공</li>
<li><code>?</code>를 붙여야 null 할당 가능</li>
</ul>
<hr>
<h2 id="3--non-null-단언-연산자">3. !! (Non-null 단언 연산자)</h2>
<pre><code class="language-kotlin">val length = name!!.length</code></pre>
<ul>
<li><code>!!</code>는 null이 절대 아님을 강제</li>
<li><strong>null이면 런타임에서 NPE 발생</strong> → 사용 지양</li>
</ul>
<hr>
<h2 id="4--safe-call--엘비스">4. ?. (safe call), ?: (엘비스)</h2>
<pre><code class="language-kotlin">val length = name?.length               // null이면 null 반환
val finalName = name ?: &quot;기본값&quot;        // null이면 우측 값 반환</code></pre>
<hr>
<h2 id="5-자바-→-코틀린-자동-변환-후-필수-점검-항목">5. 자바 → 코틀린 자동 변환 후 필수 점검 항목</h2>
<ol>
<li><code>!!</code> 남용</li>
<li>DTO → <code>data class</code> 변환</li>
<li><code>@NotBlank</code> → <code>@field:NotBlank</code></li>
<li>클래스 상속 필요 시 <code>open</code> 키워드 사용</li>
<li><code>companion object</code>에 <code>@JvmStatic</code> 필요한 경우</li>
</ol>
<hr>
<h2 id="6-kotlin의-대표-문법들">6. Kotlin의 대표 문법들</h2>
<h3 id="data-class"><code>data class</code></h3>
<pre><code class="language-kotlin">data class User(val id: Long, val name: String)</code></pre>
<ul>
<li><code>equals</code>, <code>hashCode</code>, <code>toString</code>, <code>copy()</code> 자동 생성</li>
</ul>
<h3 id="when"><code>when</code></h3>
<pre><code class="language-kotlin">when (role) {
    &quot;ADMIN&quot; -&gt; ...
    &quot;USER&quot; -&gt; ...
    else -&gt; ...
}</code></pre>
<h3 id="문자열-템플릿">문자열 템플릿</h3>
<pre><code class="language-kotlin">val name = &quot;Kotlin&quot;
println(&quot;Hello $name!&quot;)  // Hello Kotlin!</code></pre>
<hr>
<h2 id="7-코틀린-스타일">7. 코틀린 스타일</h2>
<ul>
<li><code>val</code> → 불변 (기본적으로 사용)</li>
<li><code>var</code> → 변경 가능한 경우만 사용</li>
<li>명확한 null 구분 (<code>?</code>, <code>!!</code>, <code>?:</code>)</li>
<li>한 줄 함수는 <code>=</code> 로 작성 가능</li>
</ul>
<pre><code class="language-kotlin">fun square(x: Int) = x * x</code></pre>
<hr>
<h2 id="8-팁">8. 팁</h2>
<ul>
<li>자동 변환 → <code>!!</code> 제거 → 의존성 주입 수정 → DTO 정리</li>
<li>QueryDSL은 <code>@QueryProjection</code> 사용 + <code>data class</code> 유지</li>
<li>Entity는 가급적 <code>var</code>, DTO는 <code>val</code></li>
<li><code>lateinit</code>은 <code>@PrePersist</code>, <code>Bean</code> 주입 외에는 지양</li>
</ul>
<hr>
<h1 id="2-kotlin-리팩토링">2. Kotlin 리팩토링</h1>
<h2 id="1-buildgradle-설정">1. Build.gradle 설정</h2>
<pre><code class="language-java">plugins {
    id &#39;org.jetbrains.kotlin.jvm&#39; version &#39;2.0.0&#39;  
    id &#39;org.jetbrains.kotlin.plugin.lombok&#39; version &#39;2.0.0&#39;  
    id &#39;org.jetbrains.kotlin.plugin.spring&#39; version &#39;2.0.0&#39;  
    id &#39;org.jetbrains.kotlin.plugin.jpa&#39; version &#39;2.0.0&#39; 
    id &#39;org.jetbrains.kotlin.kapt&#39; version &#39;2.0.0&#39;
}

sourceSets {
    main {
        java {
            srcDirs += &quot;src/main/kotlin&quot;
        }
    }
}

dependencies {

//   annotationProcessor &#39;com.querydsl:querydsl-apt:5.0.0:jakarta&#39; 주석처리

    //Kotlin
    implementation &quot;org.jetbrains.kotlin:kotlin-stdlib&quot;
    implementation &quot;org.jetbrains.kotlin:kotlin-reflect&quot; 
    kapt &quot;com.querydsl:querydsl-apt:5.0.0:jakarta&quot;  
    kapt &quot;jakarta.annotation:jakarta.annotation-api&quot;  
    kapt &quot;jakarta.persistence:jakarta.persistence-api&quot;
}

kapt {  
    keepJavacAnnotationProcessors = true  
}

allOpen {
    annotation(&quot;jakarta.persistence.Entity&quot;)
    annotation(&quot;jakarta.persistence.Embeddable&quot;)
    annotation(&quot;jakarta.persistence.MappedSuperclass&quot;)
}</code></pre>
<h2 id="2-주요-작업">2. 주요 작업</h2>
<ul>
<li>기존 Java 코드를 Kotlin으로 자동 변환(<code>ctrl + shift + a</code> -&gt; <code>Convert Java File to Kotlin File</code> ) 후 수작업 리팩토링</li>
<li><code>!!</code> 제거, 명확한 null-safe 설계</li>
<li>DTO는 <code>data class</code>, Entity는 <code>class</code></li>
<li><code>@field:NotBlank</code> 등 validation 어노테이션 위치 수정</li>
<li>Service/Controller의 의존성 주입은 생성자 기반으로 재작성</li>
</ul>
<hr>
<h2 id="3-user--todo-도메인-리팩토링">3. User / Todo 도메인 리팩토링</h2>
<h3 id="user-엔티티">User 엔티티</h3>
<pre><code class="language-kotlin">@Entity
@Table(name = &quot;users&quot;)
class User(
    @Column(unique = true)
    var email: String,

    var password: String? = null,

    @Enumerated(EnumType.STRING)
    var userRole: UserRole,

    var nickname: String,

    var profileImage: String? = null
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set

    companion object {
        @JvmStatic
        fun fromAuthUser(authUser: AuthUser): User {
            return User(authUser.email, null, authUser.userRole, authUser.nickname).apply {
                this.id = authUser.id
            }
        }
    }
}</code></pre>
<hr>
<h3 id="todo-엔티티">Todo 엔티티</h3>
<pre><code class="language-kotlin">@Entity
@Table(name = &quot;todos&quot;)
class Todo(
    var title: String,
    var contents: String,
    var weather: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;user_id&quot;, nullable = false)
    var user: User
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @OneToMany(mappedBy = &quot;todo&quot;, cascade = [CascadeType.REMOVE])
    val comments: MutableList&lt;Comment&gt; = mutableListOf()

    @OneToMany(mappedBy = &quot;todo&quot;, cascade = [CascadeType.ALL], orphanRemoval = true)
    val managers: MutableList&lt;Manager&gt; = mutableListOf()

    init {
        managers.add(Manager(user, this))
    }
}</code></pre>
<hr>
<h3 id="todoservice-일부">TodoService 일부</h3>
<pre><code class="language-kotlin">@Transactional
fun saveTodo(authUser: AuthUser, request: TodoSaveRequest): TodoSaveResponse {
    val user = User.fromAuthUser(authUser)
    val weather = weatherClient.todayWeather
    val saved = todoRepository.save(Todo(request.title, request.contents, weather, user))
    return TodoSaveResponse(saved.id!!, saved.title, saved.contents, weather, UserResponse(user.id!!, user.email))
}</code></pre>
<hr>
<h2 id="4-이슈-및-해결">4. 이슈 및 해결</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>해결 방법</th>
</tr>
</thead>
<tbody><tr>
<td><code>!!</code> 사용</td>
<td>생성자 주입 및 null-safe 구조로 교체</td>
</tr>
<tr>
<td>DTO validation 안됨</td>
<td><code>@field:NotBlank</code> 명시</td>
</tr>
<tr>
<td>Kotlin Entity + private setter 사용 시 오류</td>
<td>setter 열어두거나 <code>val createdAt = now()</code> 로 변경</td>
</tr>
<tr>
<td><code>JPAQueryFactory</code> 주입 실패</td>
<td><code>@Configuration</code>에서 직접 Bean 등록</td>
</tr>
<tr>
<td>Java → Kotlin companion object 접근 불가</td>
<td><code>@JvmStatic</code> 사용</td>
</tr>
<tr>
<td><code>@JvmRecord</code> 사용 시 QueryDSL 오류</td>
<td>제거하고 <code>data class</code> 사용 유지</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-리팩토링-항목">5. 리팩토링 항목</h2>
<ul>
<li><code>TodoRepository</code>, <code>TodoQueryRepository</code> Kotlin 스타일로 정리</li>
<li><code>TodoController</code>에서 <code>!!</code> 제거, request/response 명확화</li>
<li><code>Log</code> Entity에서 <code>@PrePersist</code>와 <code>lateinit var</code> 안전하게 구성</li>
<li>DTO는 모두 <code>data class</code>로 통일</li>
</ul>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>User, Todo 도메인의 CRUD Kotlin 전환 완료</li>
<li>QueryDSL, validation, JPA 호환 확인</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]대용량 데이터 처리]]></title>
            <link>https://velog.io/@giwon_wg/TIL%EB%8C%80%EC%9A%A9%EB%9F%89-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@giwon_wg/TIL%EB%8C%80%EC%9A%A9%EB%9F%89-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Thu, 08 May 2025 11:32:34 GMT</pubDate>
            <description><![CDATA[<blockquote>
<ul>
<li>대용량 데이터 처리 실습을 위해, <em>테스트 코드</em>로 유저 데이터를 100만 건 생성해주세요.<ul>
<li>데이터 생성 시 닉네임은 랜덤으로 지정해주세요.</li>
<li>가급적 동일한 닉네임이 들어가지 않도록 방법을 생각해보세요.</li>
</ul>
</li>
</ul>
</blockquote>
<ul>
<li>닉네임을 조건으로 유저 목록을 검색하는 API를 만들어주세요.<ul>
<li>닉네임은 정확히 일치해야 검색이 가능해요.</li>
</ul>
</li>
<li>여러가지 아이디어로 유저 검색 속도를 줄여주세요.<ul>
<li>조회 속도를 개선할 수 있는 여러 방법을 고민하고, 각각의 방법들을 실행해보세요.</li>
<li><code>README.md</code> 에 각 방법별 실행 결과를 비교할 수 있도록 최초 조회 속도와 개선 과정 별 조회 속도를 확인할 수 있는 표 혹은 이미지를 첨부해주세요.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="1-최초-설계">1. 최초 설계</h2>
<h3 id="코드">코드</h3>
<blockquote>
<h4 id="controller">Controller</h4>
</blockquote>
<pre><code class="language-java">    @GetMapping(&quot;/user/nickname&quot;)
    public ResponseEntity&lt;User&gt; getUserByNickname(@RequestParam String nickname) {
        User user = userService.getUserByNickname(nickname);
        return ResponseEntity.ok(user);
    }</code></pre>
<h4 id="repository">Repository</h4>
<pre><code class="language-java">Optional&lt;User&gt; findByNickname(String nickname);</code></pre>
<h4 id="service">Service</h4>
<pre><code class="language-java">public User getUserByNickname(String nickname) {
        return userRepository.findByNickname(nickname)
            .orElseThrow(() -&gt; new IllegalArgumentException(&quot;사용자가 없습니다.&quot;));
    }</code></pre>
<h3 id="k6">K6</h3>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/9ff691b4-5126-4e17-a0fb-73f0f47ac7ed/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>63.91% (673/1053)</td>
</tr>
<tr>
<td>실패률</td>
<td>36.08% (연결 거부 등 380건 실패)</td>
</tr>
<tr>
<td>평균 요청 응답 시간</td>
<td>12.86초(12860ms), 최대 39.8초(39800ms)</td>
</tr>
<tr>
<td>동시 사용자 (VU)</td>
<td>1000명 동시에 요청</td>
</tr>
<tr>
<td>요청 수 (총)</td>
<td>1053건, 초당 약 26건 처리</td>
</tr>
</tbody></table>
<ul>
<li><strong>요약</strong> 
<code>actively refused</code> 발생: 서버가 감당 못하고 연결 자체를 끊어버림
평균 응답 시간 <strong>12.8초(12860ms)</strong>, p95 = <strong>36.7초(39800ms)</strong>
부하가 1초에 수백건이 들어 오면 일부만 응답 성공</li>
</ul>
<hr>
<h2 id="2-코드-개선---db-인덱싱">2. 코드 개선 - DB 인덱싱</h2>
<p>조회를 진행할때 인덱싱이 되어 있지 않으면 풀 스캔 -&gt; nickname 인덱싱</p>
<h4 id="userentity">UserEntity</h4>
<pre><code class="language-java">@Table(name = &quot;users&quot;, indexes = {
    @Index(name = &quot;idx_nickname&quot;, columnList = &quot;nickname&quot;)
})</code></pre>
<ul>
<li>위 코드를 추가함으로 <code>nickname</code> 컬럼에 인덱스 지정<br>

</li>
</ul>
<p><strong>주의</strong></p>
<ul>
<li>이미 생성된 테이블의 경우 적용 안됨</li>
<li><blockquote>
<p>직접 DDL 실행</p>
</blockquote>
</li>
</ul>
<h4 id="직접-sql로-인덱스-추가---이미-생성된-테이블의-경우">직접 SQL로 인덱스 추가 - 이미 생성된 테이블의 경우</h4>
<pre><code class="language-sql">CREATE INDEX idx_nickname ON users(nickname);</code></pre>
<h3 id="k6-1">K6</h3>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/4720bbf9-64a2-4ecd-8531-12a149931886/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>97.93% (29,272 / 29,888)</td>
</tr>
<tr>
<td>실패률</td>
<td>2.06% (연결 거부 등 616건 실패)</td>
</tr>
<tr>
<td>평균 요청 응답 시간</td>
<td>0.331초(331.98ms), 최대 0.711(711.46ms)초</td>
</tr>
<tr>
<td>동시 사용자 (VU)</td>
<td>1000명 동시에 요청</td>
</tr>
<tr>
<td>요청 수 (총)</td>
<td>29888건, 초당 약 2901건 처리</td>
</tr>
</tbody></table>
<ul>
<li><strong>요약</strong> 
<code>nickname</code> 필드에 인덱스 추가 후, 성능이 비약적으로 향상됨
실패율이 2% 이하로 감소했고, 응답 시간도 평균 1초 이내로 안정화
서버가 동시 1000명 × 10초간 요청을 처리 가능함
평균 응답 시간 <strong>0.331초(331.98ms)</strong>, p95 = <strong>0.405초(405.08ms)</strong></li>
<li><em>이전 테스트 대비 성공률 +34%, 평균 응답 시간 -11초 이상 개선됨*</em></li>
</ul>
<hr>
<h2 id="3-db-커넥션-풀-튜닝hikaricp">3. DB 커넥션 풀 튜닝(HikariCP)</h2>
<p>병목 가능성: 다수의 요청이 들어 올 경우 커넥션 부족으로 대기 시간 존재
-&gt; <code>.yml</code>파일 <code>maximumPoolSize</code>, <code>connectionTimeout</code> 조정</p>
<pre><code class="language-yml">spring:
  datasource:
    url: jdbc:mysql://localhost:3306/${MYSQL_NAME}?useSSL=${SSL}&amp;allowPublicKeyRetrieval=${ALLOWPUBLICKEYRETRIEVAL}
    username: ${MYSQL_USERNAME}
    password: ${MYSQL_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 32 //동시에 사용할 수 있는 최대 커넥션 (서버 CPU의 스레드 * 2)
      minimum-idle: 10 // 최소 대기 커넥션
      idle-timeout: 30000 // 최대 대기 시간
      max-lifetime: 1800000 // 커넥션 하나의 최대 생존시간
      connection-timeout: 30000 // 커넥션 획득까지 기다릴 최대 시간</code></pre>
<h3 id="k6-2">K6</h3>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/03627ab3-db99-4fdb-a722-364c3db52278/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>98.75% (30,378/30,761)</td>
</tr>
<tr>
<td>실패률</td>
<td>1.24% (383건)</td>
</tr>
<tr>
<td>평균 요청 응답 시간</td>
<td>0.323초(323.39ms), 최대 0.616초(616.23ms)</td>
</tr>
<tr>
<td>동시 사용자 (VU)</td>
<td>1000명 동시에 요청</td>
</tr>
<tr>
<td>요청 수 (총)</td>
<td>30761건, 초당 약 2984건 처리</td>
</tr>
</tbody></table>
<ul>
<li><strong>요약</strong> 
응답 시간은 평균 <strong>0.323초(323.39ms)</strong>, p95 = <strong>0.396초(396.79ms)</strong>
실패율 36.08%(최초) -&gt; 2.06%(1차 개선) -&gt; 1.24%(현재)</li>
</ul>
<hr>
<h2 id="4-redis-도입">4. Redis 도입</h2>
<p>한번 호출한 유저 정보를 Redis에 10분 간 저장하고, 10분내에 재 호출 할 경우 Redis에서 바로 응답</p>
<h4 id="yml">.yml</h4>
<pre><code class="language-yml">spring:
  data:
    redis:
      host: localhost
      port: 6379</code></pre>
<h4 id="redisconfig">RedisConfig</h4>
<pre><code class="language-java">@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory();
    }

    @Bean
    public RedisTemplate&lt;String, Object&gt; redisTemplate() {
        RedisTemplate&lt;String, Object&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10)) // 10분 TTL
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(connectionFactory).cacheDefaults(config).build();
    }
}</code></pre>
<h4 id="userservice">UserService</h4>
<pre><code class="language-java">@Cacheable(value = &quot;userByNickname&quot;, key = &quot;#nickname&quot;, unless = &quot;#result == null&quot;)
    public User getUserByNickname(String nickname) {
        return userRepository.findByNickname(nickname)
            .orElseThrow(() -&gt; new IllegalArgumentException(&quot;사용자가 없습니다.&quot;));
    }</code></pre>
<h3 id="k6-3">K6</h3>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/12152f20-06a0-49ef-bbb1-d36c323c3a5c/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>성공률</td>
<td>100 (40117)</td>
</tr>
<tr>
<td>실패률</td>
<td>0 (0)</td>
</tr>
<tr>
<td>평균 요청 응답 시간</td>
<td>0.250초(250.80ms), 최대 0.404초(404.21ms)</td>
</tr>
<tr>
<td>동시 사용자 (VU)</td>
<td>1000명 동시에 요청</td>
</tr>
<tr>
<td>요청 수 (총)</td>
<td>40117건, 초당 약 3919건 처리</td>
</tr>
</tbody></table>
<ul>
<li><strong>요약</strong> 
응답 시간은 평균 <strong>0.250초(250.80ms)</strong>, p95 = <strong>0.292초(292.66ms)</strong>
실패율 36.08%(최초) -&gt; 2.06%(1차 개선) -&gt; 1.24%(2차 개선) -&gt; 0%(Only Redis)</li>
</ul>
<h3 id="최종-요약">최종 요약</h3>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/448f2d5c-7e49-4928-a30b-39b470d9acb4/image.png" alt=""></p>
<ul>
<li>응답 시간</li>
</ul>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/8a9a6ca8-eea6-4c37-bcda-7d1b8db920f4/image.png" alt=""></p>
<ul>
<li>실패률</li>
</ul>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/a8435a79-2155-4991-a8ef-e07b457da90d/image.png" alt=""></p>
<ul>
<li>요청 수</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.05.07]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.05.07</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.05.07</guid>
            <pubDate>Wed, 07 May 2025 11:07:23 GMT</pubDate>
            <description><![CDATA[<h1 id="til---2025-05-07">TIL - 2025-05-07</h1>
<h2 id="aws-핵심-정리">AWS 핵심 정리</h2>
<h3 id="iam이란">IAM이란?</h3>
<ul>
<li>AWS 리소스 접근 제어를 위한 서비스</li>
<li>사용자(User), 그룹(Group), 역할(Role), 정책(Policy)을 통해 접근 권한을 관리함</li>
</ul>
<h3 id="iam-주요-기능">IAM 주요 기능</h3>
<ol>
<li><strong>인증(Authentication)</strong>: 사용자 로그인 확인 (아이디/비밀번호, MFA 등)</li>
<li><strong>권한 부여(Authorization)</strong>: 정책(Policy)을 통해 리소스 접근 권한 부여</li>
<li><strong>권한 검증</strong>: 요청이 해당 사용자에게 허용된 것인지 검사</li>
</ol>
<h3 id="정책policy-구성-요소">정책(Policy) 구성 요소</h3>
<ul>
<li><code>Version</code>: 정책 언어 버전</li>
<li><code>Statement</code>: 하나 이상의 접근 제어 규칙<ul>
<li><code>Effect</code>: Allow / Deny</li>
<li><code>Action</code>: 수행할 수 있는 작업 (예: <code>s3:ListBucket</code>)</li>
<li><code>Resource</code>: 리소스 ARN</li>
</ul>
</li>
</ul>
<h3 id="best-practices">Best Practices</h3>
<ul>
<li>최소 권한 원칙(Least Privilege) 적용</li>
<li>루트 사용자 사용 금지, MFA 활성화</li>
<li>역할 기반 접근 제어(RBAC), 태그 기반 제어도 가능</li>
</ul>
<hr>
<h3 id="ec2-elastic-compute-cloud">EC2 (Elastic Compute Cloud)</h3>
<ul>
<li>가상 서버를 제공하는 서비스</li>
<li>인스턴스 타입, AMI, 보안 그룹, 키 페어 설정 필요</li>
<li>상태: Stopped, Running 등</li>
<li>탄력적 IP(Elastic IP)로 고정 주소 사용 가능</li>
</ul>
<h3 id="s3-simple-storage-service">S3 (Simple Storage Service)</h3>
<ul>
<li>객체(파일) 저장을 위한 스토리지 서비스</li>
<li>버킷 단위로 파일 관리</li>
<li>퍼블릭 접근 제어, 버전 관리, 정적 웹 호스팅 가능</li>
<li>각 객체는 고유한 URL로 접근 가능</li>
</ul>
<h4 id="controller">Controller</h4>
<pre><code class="language-java">@PostMapping(value = &quot;/upload&quot;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity&lt;String&gt; uploadFile(
        @AuthenticationPrincipal AuthUser authUser,
        @RequestParam(&quot;file&quot;) MultipartFile file
    ) {
        Long userId = authUser.getId();
        String imageUrl = s3Service.updateFile(file, &quot;profile/&quot; + userId, userId); // S3에 업로드 후 URL 반환
        return ResponseEntity.ok(imageUrl);
    }</code></pre>
<h4 id="service">Service</h4>
<pre><code class="language-java">private final AmazonS3 amazonS3;
    private final UserRepository userRepository;

    @Value(&quot;${cloud.aws.s3.bucket}&quot;)
    private String bucket; // S3 버킷 이름 (application.yml에서 설정)


    public String updateFile(MultipartFile multipartFile, String dirName, Long userId) {
        // 고유한 파일명을 생성 (디렉토리/UUID_원본파일명)
        String fileName = dirName + &quot;/&quot; + UUID.randomUUID() + &quot;_&quot; + multipartFile.getOriginalFilename();

        // S3 객체 메타데이터 설정
        ObjectMetadata metadata = new ObjectMetadata();
        metadata.setContentLength(multipartFile.getSize());
        metadata.setContentType(multipartFile.getContentType());
        metadata.setContentDisposition(&quot;inline&quot;);


        try (InputStream inputStream = multipartFile.getInputStream()) {
            // 파일을 S3에 업로드
            amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, metadata));
        } catch (IOException e) {
            throw new RuntimeException(&quot;S3 업로드에 실패하였습니다.&quot;, e);
        }

        // 기존 유저 이미지가 있다면 삭제
        userRepository.findById(userId).ifPresent(user -&gt; {
            String oldUrl = user.getProfileImage();
            if (oldUrl != null &amp;&amp; oldUrl.contains(bucket)) {
                // 이전 이미지 key 추출 후 삭제
                String oldKey = oldUrl.substring(oldUrl.indexOf(bucket) + bucket.length() + 1);
                amazonS3.deleteObject(bucket, oldKey);
            }

            // 새 이미지 URL 설정 및 저장
            String newUrl = amazonS3.getUrl(bucket, fileName).toString();
            user.setProfileImage(newUrl);
            userRepository.save(user);
        });

        // 새 이미지의 S3 URL 반환
        return amazonS3.getUrl(bucket, fileName).toString();
    }</code></pre>
<h4 id="config">config</h4>
<pre><code class="language-java">@Value(&quot;${cloud.aws.credentials.access-key}&quot;)
    private String accessKey;

    @Value(&quot;${cloud.aws.credentials.secret-key}&quot;)
    private String secretKey;

    @Value(&quot;${cloud.aws.region.static}&quot;)
    private String region;

    // AmazonS3 클라이언트 Bean 등록
    @Bean
    public AmazonS3 amazonS3() {
        // 자격 증명 객체 생성
        BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);

        // S3 클라이언트 빌더를 통해 리전 및 인증 정보 설정
        return AmazonS3ClientBuilder.standard()
            .withRegion(region)
            .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
            .build();
    }</code></pre>
<h3 id="rds-relational-database-service">RDS (Relational Database Service)</h3>
<ul>
<li>MySQL, PostgreSQL, MariaDB, Oracle, SQL Server 등 관계형 데이터베이스 관리형 서비스</li>
<li>Multi-AZ 배포 가능 (고가용성)</li>
<li>자동 백업, 모니터링, 보안 그룹 적용 가능</li>
<li>DB 인스턴스와 엔드포인트 개념 존재</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 19년 6월 교육청 고2 수학 '가'형 30번 알고리즘 구현]]></title>
            <link>https://velog.io/@giwon_wg/TIL-19%EB%85%84-6%EC%9B%94-%EA%B5%90%EC%9C%A1%EC%B2%AD-%EA%B3%A02-%EC%88%98%ED%95%99-%EA%B0%80%ED%98%95-30%EB%B2%88-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@giwon_wg/TIL-19%EB%85%84-6%EC%9B%94-%EA%B5%90%EC%9C%A1%EC%B2%AD-%EA%B3%A02-%EC%88%98%ED%95%99-%EA%B0%80%ED%98%95-30%EB%B2%88-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 02 May 2025 11:31:25 GMT</pubDate>
            <description><![CDATA[<h1 id="til-2019년-6월-교육청-고2-수학-가형-30번-알고리즘-구현">TIL: 2019년 6월 교육청 고2 수학 &#39;가&#39;형 30번 알고리즘 구현</h1>
<h2 id="문제-요약">문제 요약</h2>
<p>주어진 조건을 만족하는 네 양의 정수 <code>(a, b, c, d)</code> 쌍의 개수가 정확히 59개가 되는 가장 작은 자연수 <code>m</code>과 가장 큰 자연수 <code>M</code>을 찾고, 그 합 <code>M + m</code>을 구하는 문제.</p>
<p><strong>조건</strong>:</p>
<pre><code>(a^(1/b)) * (c^(1/d)) = 24^(1/5)</code></pre><h2 id="수학적-접근">수학적 접근</h2>
<p>해당 문제는 수치 비교에서 실수 오차가 발생할 수 있기 때문에, 정확한 수 비교를 위해 로그를 이용한 비교로 전환했다.</p>
<p>변형 수식:</p>
<pre><code>log(a^(1/b)) + log(c^(1/d)) = log(24^(1/5))
⇒ (1/b) * log(a) + (1/d) * log(c) = log(24)/5</code></pre><p>이 식을 기반으로 실수 비교를 수행함.</p>
<h2 id="구현-전략">구현 전략</h2>
<ol>
<li><code>k</code>의 범위를 2부터 200까지 순차적으로 증가시키며 검사</li>
<li><code>a, b, c, d</code>를 각각 [2, k], [1, k] 범위로 완전 탐색</li>
<li>위 수식의 좌변이 <code>log(24)/5</code>와의 차가 <code>epsilon</code>보다 작을 경우 조건 만족으로 판단</li>
<li>조건을 만족하는 쌍을 <code>Set&lt;String&gt;</code>에 저장해 중복 제거</li>
<li><code>count == 59</code>일 때의 최소 <code>k</code>와 최대 <code>k</code>를 저장</li>
<li>최종적으로 <code>M + m</code> 출력</li>
</ol>
<h2 id="핵심-코드">핵심 코드</h2>
<pre><code class="language-java">for (int k = 2; k &lt;= 200; k++) {
    Set&lt;String&gt; seen = new HashSet&lt;&gt;();
    int count = 0;

    for (int a = 2; a &lt;= k; a++) {
        for (int b = 1; b &lt;= k; b++) {
            for (int c = 2; c &lt;= k; c++) {
                for (int d = 1; d &lt;= k; d++) {
                    double lhs = Math.log(a) / b + Math.log(c) / d;
                    if (Math.abs(lhs - target) &lt; epsilon) {
                        seen.add(a + &quot;,&quot; + b + &quot;,&quot; + c + &quot;,&quot; + d);
                        count++;
                    }
                }
            }
        }
    }

    if (count == 59) {
        if (minK == 0) minK = k;
        maxK = k;
    }
}</code></pre>
<h2 id="주요-고려-사항">주요 고려 사항</h2>
<ul>
<li><strong>부동소수점 오차 방지</strong>: <code>Math.abs(lhs - target) &lt; epsilon</code> 조건 활용</li>
<li><strong>중복 제거</strong>: (a,b,c,d) 쌍을 문자열로 구성하여 Set에 저장</li>
<li><strong>정수 범위 제한</strong>: <code>a</code>, <code>c</code>는 2 이상부터 시작 (1이면 의미 없는 조합이 되므로)</li>
</ul>
<h2 id="결과">결과</h2>
<p>정확히 <code>k = 72</code>와 <code>k = 80</code>에서 59개의 조합이 존재함을 확인했고, 최종 결과는:</p>
<pre><code>M + m = 152</code></pre><h2 id="배운-점">배운 점</h2>
<ul>
<li>로그를 통한 수식 비교는 실수 오차 처리에 매우 유용하다</li>
<li>완전 탐색 알고리즘은 조건을 명확히 이해하고 효율적으로 구성하면 계산 가능한 수준에서 문제를 해결할 수 있다</li>
<li>수학적 사고력과 구현력이 결합되면 교과서 수준 문제도 코드로 정복 가능하다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[스파르타 코딩클럽]AWS의 모든것]]></title>
            <link>https://velog.io/@giwon_wg/%EC%8A%A4%ED%8C%8C%EB%A5%B4%ED%83%80-%EC%BD%94%EB%94%A9%ED%81%B4%EB%9F%BDAWS%EC%9D%98-%EB%AA%A8%EB%93%A0%EA%B2%83</link>
            <guid>https://velog.io/@giwon_wg/%EC%8A%A4%ED%8C%8C%EB%A5%B4%ED%83%80-%EC%BD%94%EB%94%A9%ED%81%B4%EB%9F%BDAWS%EC%9D%98-%EB%AA%A8%EB%93%A0%EA%B2%83</guid>
            <pubDate>Fri, 02 May 2025 10:38:07 GMT</pubDate>
            <description><![CDATA[<h2 id="aws-클라우드가-무엇인가">AWS 클라우드가 무엇인가?</h2>
<h3 id="aws-클라우드">AWS 클라우드</h3>
<blockquote>
<p>AWS는 Amazon Web Services의 약어로, 아마존닷컴이 제공하는 클라우드 컴퓨팅 플랫폼입니다. AWS는 전 세계에 분산되어 있는 데이터 센터에서 고객에게 IT 인프라를 제공하며, 이를 사용하여 고객은 필요한 인프라를 빠르고 쉽게 설정하고 관리할 수 있습니다.</p>
</blockquote>
<p><strong>Amazon</strong>: 책방에서 IT업계의 공룡기업!</p>
<h3 id="1-회원가입">1. 회원가입</h3>
<h4 id="httpsawsamazoncomko"><a href="https://aws.amazon.com/ko/">https://aws.amazon.com/ko/</a></h4>
<p>으로 접속 후 계정 생성 진행</p>
<p><strong>비자, 마스터 카드등 필요</strong></p>
<h3 id="2-iam-핵심">2. IAM 핵심</h3>
<h4 id="01-iam-개요">01. IAM 개요</h4>
<ul>
<li>IAM (Identity and Access Management):
AWS 리소스에 대한 인증(Authentication) 과 권한 부여(Authorization) 를 담당하는 서비스.</li>
</ul>
<p>주요 기능:</p>
<ol>
<li>인증 (Authentication): 사용자 이름/비밀번호를 통해 로그인.</li>
<li>권한 부여 (Authorization): 정책을 기반으로 각 사용자/역할/그룹에 필요한 권한만 부여.</li>
<li>권한 검증: 요청 시 정책을 기반으로 허용/거부 판단.<blockquote>
<p>보안 강화 + 최소 권한 원칙 적용 + 규정 준수 지원</p>
</blockquote>
</li>
</ol>
<h4 id="02-iam-구성-요소">02. IAM 구성 요소</h4>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>User</td>
<td>AWS에 로그인하는 개별 사용자. 비밀번호 또는 액세스 키를 사용함.</td>
</tr>
<tr>
<td>Group</td>
<td>여러 User를 묶어 동일한 권한을 한 번에 부여. (예: 개발자 그룹)</td>
</tr>
<tr>
<td>Policy</td>
<td>JSON 형식의 권한 규칙. User, Group, Role에 연결됨.</td>
</tr>
</tbody></table>
<h4 id="03-iam-정책-구조">03. IAM 정책 구조</h4>
<pre><code class="language-json">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;, // 또는 &quot;Deny&quot;
      &quot;Action&quot;: [&quot;s3:ListBucket&quot;], // 수행 가능한 작업
      &quot;Resource&quot;: [&quot;arn:aws:s3:::my-bucket/*&quot;], // 대상 리소스
      &quot;Condition&quot;: {
        &quot;IpAddress&quot;: {
          &quot;aws:SourceIp&quot;: &quot;192.168.0.1/32&quot;
        }
      }
    }
  ]
}</code></pre>
<ul>
<li><code>Effect</code>: 허용(Allow) 또는 거부(Deny)</li>
<li><code>Action</code>: 수행할 수 있는 작업 (예: s3:GetObject)</li>
<li><code>Resource</code>: 적용 대상 리소스 ARN</li>
<li><code>Condition</code>: 특정 조건에서만 정책 적용 (선택)</li>
</ul>
<h4 id="04-mfa-다중-인증">04. MFA (다중 인증)</h4>
<ul>
<li><p><strong>MFA (Multi-Factor Authentication):</strong>
비밀번호 외에 추가 인증 수단(앱, 하드웨어 토큰 등)을 요구하여 보안을 강화.</p>
</li>
<li><p><strong>MFA 적용 대상:</strong></p>
<ul>
<li>루트 사용자: 무조건 설정 필수</li>
<li>IAM 사용자: 가능하면 모두 설정</li>
</ul>
</li>
</ul>
<blockquote>
<p>계정 정보가 유출되어도 MFA가 없다면 로그인 불가 → 보안 강화 필수!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT란?]]></title>
            <link>https://velog.io/@giwon_wg/JWT%EB%9E%80</link>
            <guid>https://velog.io/@giwon_wg/JWT%EB%9E%80</guid>
            <pubDate>Thu, 01 May 2025 11:30:58 GMT</pubDate>
            <description><![CDATA[<h2 id="jwt란">JWT란?</h2>
<ul>
<li><strong>서버가 사용자에게 인증 완료 증명서를 발급해주는 디지털 쪽지</strong></li>
</ul>
<h3 id="어떻게-생겨먹었나">어떻게 생겨먹었나?</h3>
<pre><code>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiUk9MRV9VU0VSIn0.
1234567890abcdef</code></pre><p>디코딩 한다면</p>
<pre><code>{&quot;alg&quot;:&quot;HS256&quot;,&quot;typ&quot;:&quot;JWT&quot;}{&quot;userId&quot;:&quot;123&quot;,&quot;role&quot;:&quot;ROLE_USER&quot;}5~9=Ѧu</code></pre><ol>
<li>Header: 어떤 암호화 알고리즘을 썼는가? ex. HS256</li>
<li>Payload: 사용자 정보</li>
<li>signature: 위 정보를 변조 못하게 서명한 것</li>
</ol>
<h3 id="jwt-흐름">JWT 흐름</h3>
<ol>
<li>로그인 요청</li>
<li>서버가 이메일/비밀번호 확인</li>
<li>성공하면 JWT 발급</li>
<li>클라이언트는 발급 받은 토큰을 <code>Authorization: Bearer {token}</code>으로 사용</li>
<li>서버는 매 요청마다 JWT를 해석해서 사용자 인증 처리</li>
</ol>
<h3 id="step-1---로그인-컨트롤러와-레파지토리">Step. 1 - 로그인 컨트롤러와 레파지토리</h3>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/auth&quot;)
public class AuthController {

    private final AuthService authService;

    @PostMapping(&quot;/login&quot;)
    public ResponseEntity&lt;TokenResponse&gt; login(@RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request));
    }
}</code></pre>
<ul>
<li>로그인 API가 있어야 토큰을 발급받겠죠?</li>
<li>이미 많이 만들어 보셨을 테니 자세한 설명은 패스</li>
</ul>
<pre><code class="language-java">public record LoginRequest(String email, String password) {

}</code></pre>
<pre><code class="language-java">public record TokenResponse(String accessToken, String refreshToken) {

}</code></pre>
<ul>
<li>레파지토리는 아무 내용도 없어도 됩니다!</li>
</ul>
<h3 id="step-2---jwt-생성-클래스-jwttokenprovider-">Step. 2 - JWT 생성 클래스( JwtTokenProvider )</h3>
<pre><code class="language-java">@Component // Spring Bean으로 등록해 다른 컴포넌트에서 주입 받을 수 있게 해줌
public class JwtTokenProvider {

    // jwt.secret 값을 주입받음 -&gt; 사용자가 아무 값이나 지정해주면 됨(한 20자리 정도?)
    // ex. jwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecret
    @Value(&quot;${jwt.secret}&quot;) 
    private String secret;

    // 엑세스 토큰 유효기간 설정 ms단위임!
    private final long accessTokenValidity = 1000L * 60 * 60; // 1시간

    // 리프레쉬 토큰 유효기간 설정 역시 ms단위임!
    private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일

    // 사용자 ID와 역할을 바탕으로 액세스 토큰을 생성 -&gt; 최대한 적은 정보가 들어가는게 좋음
    public String createAccessToken(Long userId, String role) {
        return createToken(userId, role, accessTokenValidity);
    }

    // ID와 역할을 바탕으로 뭐? 리프레쉬 토큰을 만들어 준다~
    public String createRefreshToken(Long userId, String role) {
        return createToken(userId, role, refreshTokenValidity);
    }

    // 공통 로직 -&gt; 토큰 생성 claims(정보) 설정, 발급 시간/ 만료 시간 지정, 서명
    private String createToken(Long userId, String role, long validity) {
        Claims claims = Jwts.claims().setSubject(String.valueOf(userId)); // subject에 userId 설정
        claims.put(&quot;role&quot;, role); //claims에 룰 추가

        Date now = new Date(); // 지금 시간
        Date expiry = new Date(now.getTime() + validity); // 만료 시간 계산

        // JWT 빌더를 이용해서 토큰 생성해줌
        return Jwts.builder()
                .setClaims(claims) // 위에 열심히 적은 정보들
                .setIssuedAt(now) // 발급 시간
                .setExpiration(expiry) // 만료 시간
                .signWith(SignatureAlgorithm.HS256, secret.getBytes()) // 서명 알고리즘 + secretkey 지정
                .compact(); // 최종적으로 토큰 생성
    }

    // 토큰에서 사용자 ID(subject) 추출
    public Long getUserId(String token) {
        return Long.valueOf(parseClaims(token).getSubject());
    }

    // 토큰에서 룰 추출
    public String getRole(String token) {
        return parseClaims(token).get(&quot;role&quot;, String.class);
    }

    // 토큰이 유효한지 검사 -&gt; 성공하면 혁며..아니고 ture, 실패하면 false
    public boolean validateToken(String token) {
        try {
            parseClaims(token); // 내부적으로 예외 던짐
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // 토큰을 파싱해서 Claims 객체 반환 (위에 적은 jwt.secret 이용해서 검증)
    private Claims parseClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secret.getBytes()) // 서명 키
                .build()
                .parseClaimsJws(token) //JWT 파싱
                .getBody(); // Claims 반환
    }
}</code></pre>
<ul>
<li>자세한 설명은 주석으로 대신했습니다.</li>
</ul>
<h3 id="step-3-jwt-발급---만든거-줘야겠지">Step. 3 JWT 발급 - 만든거 줘야겠지?</h3>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;

    public TokenResponse login(LoginRequest request) {
        User user = userRepository.findByEmail(request.email())
            .orElseThrow(() -&gt; new RuntimeException(&quot;유저 없음&quot;));

        if (!user.getPassword().equals(request.password())) {
            throw new RuntimeException(&quot;비밀번호 틀림&quot;);
        }

        // 엑세스 토큰 생성
        String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name());

        // 리프레쉬 토큰 생성
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getId(), user.getRole().name());

        // 토큰 두개 담아서 반환
        return new TokenResponse(accessToken, refreshToken);
    }
}</code></pre>
<ul>
<li><code>Service</code>에서 가장 중요한건 열심히 만든 토큰을 똑바로 주는것!</li>
</ul>
<h3 id="step-4-인증-필터-spring-security">Step. 4 인증 필터 (Spring Security)</h3>
<pre><code class="language-java">@Configuration // 이 클래스가 Spring의 설정 클래스임을 알려줌
@EnableWebSecurity // Spring Security 기능을 활성화
@RequiredArgsConstructor 
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider; // JWT 관련 로직을 처리할 Provider 주입

    @Bean // SecurityFilterChain을 Bean으로 등록
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -&gt; csrf.disable()) // CSRF 보호 비활성화 (JWT 기반 인증 할꺼니까?)
            .sessionManagement(session -&gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션을 사용하지 않도록 설정 (역시 JWT 인증 이니까)
            .authorizeHttpRequests(auth -&gt; auth
                .requestMatchers(&quot;/auth/**&quot;).permitAll() // /auth/** 경로는 인증 없이 접근 허용
                .anyRequest().authenticated() // 그 외의 모든 요청은 인증 필요
            )
            .addFilterBefore(new JwtAuthFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); // JWT 인증 필터를 Spring Security 필터 체인 앞단에 추가

        return http.build(); // 최종 SecurityFilterChain 객체 생성 및 반환
    }
}</code></pre>
<h3 id="step-5-filter">Step. 5 Filter</h3>
<pre><code class="language-java">// 매 요청마다 실행됨
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    // 실제 필터 동작을 정의함
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String token = resolveToken(request); // Authorization 헤더에서 토큰 추출

        // 토큰이 존재하고 유효하면 인증 처리
        if (token != null &amp;&amp; jwtTokenProvider.validateToken(token)) {
            Long userId = jwtTokenProvider.getUserId(token); // 토큰에서 userId 추출
            String role = jwtTokenProvider.getRole(token); // 토큰에서 role 추출

            // 인증 객체 생성: principal에 커스텀 AuthUser, credentials는 null, 권한 목록 설정
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                new AuthUser(userId, role),
                null,
                List.of(new SimpleGrantedAuthority(role))
            );

            // 인증 객체를 현재 SecurityContext에 설정 → 컨트롤러에서 @AuthenticationPrincipal 등으로 접근 가능
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 다음 필터로 요청 전달 -체인 계속 진행
        chain.doFilter(request, response);
    }

    // Authorization 헤더에서 Bearer 토큰 파싱
    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader(&quot;Authorization&quot;);
        return (bearer != null &amp;&amp; bearer.startsWith(&quot;Bearer &quot;)) ? bearer.substring(7) : null;
    }
}</code></pre>
<ul>
<li>여기까지가 발급에 필요한 기본 포맷입니다~</li>
</ul>
<p>그 다음은 어떤게 있냐고요?</p>
<ol>
<li>리프레쉬 토큰을 저장 -&gt; 로그아웃, 재발급 대비</li>
<li>로그아웃 시 블랙리스트 처리</li>
<li>PasswordEncoder 적용
등이 있죠!</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project] Transaction 심화]]></title>
            <link>https://velog.io/@giwon_wg/Project-Transaction-%EC%8B%AC%ED%99%94</link>
            <guid>https://velog.io/@giwon_wg/Project-Transaction-%EC%8B%AC%ED%99%94</guid>
            <pubDate>Thu, 01 May 2025 09:10:23 GMT</pubDate>
            <description><![CDATA[<h2 id="lv-3-transaction-심화">Lv. 3 Transaction 심화</h2>
<blockquote>
<h3 id="조건">조건</h3>
</blockquote>
<ul>
<li>매니저 등록 요청을 기록하는 로그 테이블을 만들어주세요.<ul>
<li>DB 테이블명: <code>log</code></li>
</ul>
</li>
<li>매니저 등록과는 별개로 로그 테이블에는 항상 요청 로그가 남아야 해요.<ul>
<li>매니저 등록은 실패할 수 있지만, 로그는 반드시 저장되어야 합니다.</li>
<li>로그 생성 시간은 반드시 필요합니다.</li>
<li>그 외 로그에 들어가는 내용은 원하는 정보를 자유롭게 넣어주세요.</li>
</ul>
</li>
</ul>
<h3 id="구현-전략">구현 전략</h3>
<ol>
<li>Log 엔티티 &amp; Repository 생성</li>
<li>로그 저장용 서비스 생성</li>
<li><code>@Transactional(propagation = Propagation.REQUIRES_NEW)</code> 사용</li>
<li>매니저 등록을 수행 하는 서비스에서 로그 저장 호출</li>
</ol>
<h3 id="1-log-엔티티-설계">1. Log 엔티티 설계</h3>
<pre><code class="language-java">@Entity
@Setter
@Table(name = &quot;log&quot;)
public class Log {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;

    private String action;

    private String details;

    private LocalDateTime createdAt;

    @PrePersist
    public void prePersist() {
        this.createdAt = LocalDateTime.now();
    }
}</code></pre>
<h3 id="2-repository-설계">2. Repository 설계</h3>
<pre><code class="language-java">public interface LogRepository extends JpaRepository&lt;Log, Long&gt; {
}</code></pre>
<h3 id="3-service-설계">3. Service 설계</h3>
<pre><code class="language-java">@Service
public class LogService {

    private final LogRepository logRepository;

    public LogService(LogRepository logRepository) {
        this.logRepository = logRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(String action, String details) {
        Log log = new Log();
        log.setAction(action);
        log.setDetails(details);
        logRepository.save(log);
    }
}</code></pre>
<h3 id="4-매니저-등록-서비스에-try-finally문-추가">4. 매니저 등록 서비스에 try-finally문 추가</h3>
<pre><code class="language-java">        try{
            // 일정을 만든 유저
            User user = User.fromAuthUser(authUser);
            Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -&gt; new InvalidRequestException(&quot;Todo not found&quot;));

            if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
                throw new InvalidRequestException(&quot;담당자를 등록하려고 하는 유저가 유효하지 않거나, 일정을 만든 유저가 아닙니다.&quot;);
            }

            User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                .orElseThrow(() -&gt; new InvalidRequestException(&quot;등록하려고 하는 담당자 유저가 존재하지 않습니다.&quot;));

            if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
                throw new InvalidRequestException(&quot;일정 작성자는 본인을 담당자로 등록할 수 없습니다.&quot;);
            }

            Manager newManagerUser = new Manager(managerUser, todo);
            Manager savedManagerUser = managerRepository.save(newManagerUser);

            return new ManagerSaveResponse(
                savedManagerUser.getId(),
                new UserResponse(managerUser.getId(), managerUser.getEmail())
            );
        } catch (Exception e) {
            throw e;
        } finally {
            logService.saveLog(
                &quot;매니저 요청&quot;, &quot;userId: &quot; + authUser.getId() + &quot;, todoId: &quot; + todoId + &quot;, managerUserId: &quot; + managerSaveRequest.getManagerUserId()
            );
        }</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Project] QueryDSL]]></title>
            <link>https://velog.io/@giwon_wg/Project-QueryDSL</link>
            <guid>https://velog.io/@giwon_wg/Project-QueryDSL</guid>
            <pubDate>Thu, 01 May 2025 08:16:28 GMT</pubDate>
            <description><![CDATA[<h2 id="lv-3-querydsl">Lv. 3 QueryDSL</h2>
<blockquote>
<h3 id="조건">조건</h3>
</blockquote>
<ul>
<li>검색 조건은 다음과 같아요.<ul>
<li>검색 키워드로 일정의 제목을 검색할 수 있어요.<ul>
<li>제목은 부분적으로 일치해도 검색이 가능해요.</li>
</ul>
</li>
<li>일정의 생성일 범위로 검색할 수 있어요.<ul>
<li>일정을 생성일 최신순으로 정렬해주세요.</li>
</ul>
</li>
<li>담당자의 닉네임으로도 검색이 가능해요.<ul>
<li>닉네임은 부분적으로 일치해도 검색이 가능해요.</li>
</ul>
</li>
</ul>
</li>
<li>다음의 내용을 포함해서 검색 결과를 반환해주세요.<ul>
<li>일정에 대한 모든 정보가 아닌, 제목만 넣어주세요.</li>
<li>해당 일정의 담당자 수를 넣어주세요.</li>
<li>해당 일정의 총 댓글 개수를 넣어주세요.</li>
</ul>
</li>
<li>검색 결과는 페이징 처리되어 반환되도록 합니다.</li>
</ul>
<h3 id="1-컨트롤러-작성">1. 컨트롤러 작성</h3>
<pre><code class="language-java">    // 키워드, 날짜, 닉네임으로 필터링된 할일 목록을 페이징 처리하여 조회
    @GetMapping(&quot;/todos/searchFilters&quot;)
    public ResponseEntity&lt;Page&lt;TodoSummaryResponse&gt;&gt; searchTodosWithFilters(
        @RequestParam(required = false) String keyword, // 제목 검색 키워드
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)LocalDate startDate, // 시작 날짜
        @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)LocalDate endDate, // 종료 날짜
        @RequestParam(required = false) String nickname, // 닉네임
        @PageableDefault(size = 10, sort = &quot;createdAt&quot;, direction = Sort.Direction.DESC) Pageable pageable //페이징 정보
    ) {
        Page&lt;TodoSummaryResponse&gt; result = todoService.searchTodosWithFilters(keyword, nickname, startDate, endDate, pageable);
        return ResponseEntity.ok(result);
    }</code></pre>
<ul>
<li><code>required = false</code> 사용하여 빈값도 받음</li>
</ul>
<h3 id="2-응답-dto-작성">2. 응답 DTO 작성</h3>
<pre><code class="language-java">public record TodoSummaryResponse (

    String title,
    Long managerCount,
    Long commentCount
) {
    // QueryDSL의 Projection 기반 생성자
    @QueryProjection
    public TodoSummaryResponse(String title, Long managerCount, Long commentCount) {
        this.title = title;
        this.managerCount = managerCount;
        this.commentCount = commentCount;
    }
}</code></pre>
<h3 id="3-repository-작성">3. Repository 작성</h3>
<pre><code class="language-java">// 인터페이스
Page&lt;TodoSummaryResponse&gt; searchTodosWithFilters(String keyword, String nickname, LocalDate startDate, LocalDate endDate, Pageable pageable);

// 구현체
    @Override
    public Page&lt;TodoSummaryResponse&gt; searchTodosWithFilters(String keyword, String nickname, LocalDate startDate,
        LocalDate endDate, Pageable pageable) {
        // Q 클래스 선언
        QTodo todo = QTodo.todo;
        QUser user = QUser.user;
        QManager manager = QManager.manager;
        QComment comment = QComment.comment;

        // 실제 데이터 리스트 조회
        List&lt;TodoSummaryResponse&gt; content = queryFactory
            .select(Projections.constructor(
                TodoSummaryResponse.class, //DTO를 생성자 기반으로 맵핑
                todo.title,
                manager.countDistinct(), // 관리자 수
                comment.count() // 댓글 수
            ))
            .from(todo)
            .leftJoin(todo.managers, manager)
            .leftJoin(manager.user, user)
            .leftJoin(todo.comments, comment)
            .where(
                titleContains(keyword), // 제목 필터
                nicknameContains(nickname), // 닉네임 필터
                createdBetween(startDate, endDate) // 날짜 필터
            )
            .groupBy(todo.id) // count를 쓰기위해 작성
            .orderBy(todo.createdAt.desc()) // 정렬 조건
            .offset(pageable.getOffset()) // 페이징 시작위치
            .limit(pageable.getPageSize()) // 페이징 한페이지 크기
            .fetch();

        // 전체갯수 조회 카운트용
        Long count = queryFactory
            .select(todo.countDistinct())
            .from(todo)
            .leftJoin(todo.managers, manager)
            .leftJoin(manager.user, user)
            .where(
                titleContains(keyword),
                nicknameContains(nickname),
                createdBetween(startDate, endDate)
            )
            .fetchOne();

        return new PageImpl&lt;&gt;(content, pageable, count);
    }

    private BooleanExpression titleContains(String keyword) {
        return StringUtils.hasText(keyword) ? QTodo.todo.title.contains(keyword) : null;
    }

    private BooleanExpression nicknameContains(String nickname) {
        return StringUtils.hasText(nickname) ? QManager.manager.user.nickname.contains(nickname) : null;
    }

    private BooleanExpression createdBetween(LocalDate start, LocalDate end) {
        if (start != null &amp;&amp; end != null) {
            return QTodo.todo.createdAt.between(start.atStartOfDay(), end.plusDays(1).atStartOfDay());
        }
        return null;
    }</code></pre>
<h3 id="4-서비스-작성">4. 서비스 작성</h3>
<pre><code class="language-java">public Page&lt;TodoSummaryResponse&gt; searchTodosWithFilters(String keyword, String nickname, LocalDate startDate, LocalDate endDate, Pageable pageable) {
        return todoQueryRepository.searchTodosWithFilters(keyword, nickname, startDate, endDate, pageable);
    }</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.05.01]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.05.01</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.05.01</guid>
            <pubDate>Thu, 01 May 2025 06:26:15 GMT</pubDate>
            <description><![CDATA[<h3 id="querydsl-리-마인드">QueryDSL 리 마인드</h3>
<h4 id="1-querydsl-장점">1. QueryDSL 장점</h4>
<ol>
<li><p>타입 안전성</p>
<ul>
<li><p>컴파일 타임에 오류 잡아줌. 문자열로 쿼리 짜는 JPQL보다 안전함.</p>
</li>
<li><p>오타, 컬럼 이름 틀림 -&gt; 컴파일 에러로 바로 확인 가능.</p>
</li>
</ul>
</li>
<li><p>IDE 자동완성</p>
<ul>
<li><p>Q클래스를 기반으로 작성하니까 IDE가 필드 자동완성해줌.</p>
</li>
<li><p>실수 줄고 개발 속도 상승</p>
</li>
</ul>
</li>
<li><p>가독성과 유지보수성</p>
<ul>
<li><p>복잡한 동적 쿼리도 자바 코드처럼 명확하게 표현 가능.</p>
</li>
<li><p>조건문 분기 같은 것도 자연스럽게 if문으로 처리 가능.</p>
</li>
</ul>
</li>
<li><p>동적 쿼리 작성 쉬움</p>
<ul>
<li>BooleanBuilder나 where 조건에 null 무시 기능으로 조건 선택적으로 붙이기 간단.</li>
</ul>
</li>
<li><p>쿼리 재활용 쉬움</p>
<ul>
<li>공통 조건들을 메서드로 빼서 재사용 가능.</li>
</ul>
</li>
</ol>
<h4 id="2-querydsl-단점">2. QueryDSL 단점</h4>
<ol>
<li><p>기본 세팅이 귀찮음</p>
</li>
<li><p>러닝 커브 존재</p>
</li>
<li><p>코드가 길어짐</p>
</li>
</ol>
<h4 id="3-언제-querydsl를-쓰는게-좋을까">3. 언제 QueryDSL를 쓰는게 좋을까?</h4>
<ol>
<li><p>복잡한 검색/필터 조건이 많은 경우</p>
<ul>
<li><p>회원을 이름, 나이, 주소, 등록일 등으로 조합해서 검색해야 할 경우</p>
</li>
<li><p><code>if != null</code> 조건이 여러 개 붙는 경우
  -&gt; <strong>JPQL</strong> 이나 <strong>Criteria</strong>으로 하려면 머리 터짐</p>
</li>
</ul>
</li>
<li><p>동적 쿼리가 자주 필요한 경우</p>
<ul>
<li><p>검색 조건이 optional인 경우 (<code>검색창 입력 여부에 따라 필터 적용</code>)</p>
</li>
<li><p><code>BooleanBuilder</code> 또는 <code>where(x, y, z)</code> 구조로 처리 가능</p>
</li>
</ul>
</li>
<li><p>리포지토리 쿼리 재사용하고 싶은 경우</p>
<ul>
<li>쿼리 조건들을 메서드로 분리해서 공통화 가능
  -&gt; 유지보수, 테스트 쉬워짐</li>
</ul>
</li>
<li><p>프론트에서 정렬, 페이징 요청 자주 들어올 때</p>
<ul>
<li><p><code>orderBy</code>, <code>offset</code>, <code>limit</code> 등 DSL로 처리 가능</p>
</li>
<li><p>Pageable과 연동도 쉬움</p>
</li>
</ul>
</li>
<li><p>엔티티 간 join이 많은 경우</p>
<ul>
<li>fetch join, left join 등 복잡한 join이 필요할 때 더 안전하게 처리 가능</li>
</ul>
</li>
</ol>
<h4 id="4-간단한-비교-예시">4. 간단한 비교 예시</h4>
<pre><code class="language-java">// JPQL
String jpql = &quot;SELECT m FROM Member m WHERE m.age &gt; :age&quot;;

// QueryDSL
QMember m = QMember.member;
List&lt;Member&gt; result = queryFactory
    .selectFrom(m)
    .where(m.age.gt(20))
    .fetch();</code></pre>
<h4 id="5-결론">5. 결론!</h4>
<ul>
<li>복잡한 검색 조건이 많고, 동적 쿼리 많이 짜야 하면 무조건 쓰는 게 좋다.</li>
</ul>
<h1 id="개별-프로젝트">개별 프로젝트</h1>
<h2 id="lv-2">Lv. 2</h2>
<h3 id="1-spring-security">1. Spring Security</h3>
<blockquote>
<p><strong>Spring 기반 애플리케이션의 인증(Authentication)과 인가(Authorization)</strong>를 처리해주는 보안 프레임워크</p>
</blockquote>
<h3 id="핵심-기능">핵심 기능</h3>
<ol>
<li><strong>인증(Authentication)</strong>
사용자가 누구인지 확인 (로그인 처리)
ex) 아이디/비번으로 로그인, 소셜 로그인, JWT 토큰 검증 등</li>
<li><strong>인가(Authorization)</strong>
인증된 사용자가 어떤 리소스에 접근 가능한지 검사
ex) 관리자만 /admin, 점주만 /store/** 접근 가능</li>
<li><strong>보안 관련 필터 자동 적용</strong>
CSRF 방어, 세션 고정 방지, 비밀번호 암호화 등</li>
</ol>
<p>기존 <code>Filter</code>와 <code>Argument Resolver</code>를 사용하던 코드들을 <strong>Spring Security</strong>로 변경</p>
<h4 id="0-buildgradle에-의존성-추가">0. build.gradle에 의존성 추가</h4>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;</code></pre><h4 id="1-jwtutil-클래스에-resolvetoken-및-validatetoken-메서드-추가">1. JwtUtil 클래스에 resolveToken() 및 validateToken() 메서드 추가</h4>
<pre><code class="language-java">    public String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader(&quot;Authorization&quot;); // 토큰값 헤더에서 가져오기
        if (StringUtils.hasText(bearer) &amp;&amp; bearer.startsWith(&quot;bearer &quot;)) { // 값이 있고, bearer 로 시작한다면
            return bearer.substring(7); // 앞의 &quot;bearer &quot;(7자리)를 때고 토큰값만 리턴 할것
        }
        return null;
    }

    public boolean validateToken(String token) {
        try {
            extractClaims(token); //토큰에서 클레임을 추출하여 유효성 검사
            return true; // 예외 없으면 유효한 토큰으로 판단
        } catch (Exception e) { 
            return false; // 예외 발생 시 유효하지 않은 토큰으로 판단
        }
    }</code></pre>
<h4 id="2-jwtauthenticationfilter-클래스-설계---기존의-filter-대체">2. JwtAuthenticationFilter 클래스 설계 -&gt; 기존의 Filter 대체</h4>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws
        ServletException, IOException {

        String token = jwtUtil.resolveToken(request); // 요청 헤더에서 토큰값 추출

        if (token != null &amp; jwtUtil.validateToken(token)) { // 토큰이 존재하고, 유효성 검증메서드를 통과 할 경우
            Claims claims = jwtUtil.extractClaims(token); //토큰에서 클레임(정보) 추출

            Long userId = Long.parseLong(claims.getSubject()); // 사용자 ID를 클레임에서 꺼냄
            String email = claims.get(&quot;email&quot;, String.class); // 사용자 email를 클레임에서 꺼냄
            String role = claims.get(&quot;userRole&quot;, String.class); // 사용자 role을 클레임에서 꺼냄
            String nickname = claims.get(&quot;nickname&quot;, String.class); //사용자 nickname을 클레임에서 꺼냄

            AuthUser authUser = new AuthUser(userId, email, UserRole.of(role), nickname);
            List&lt;GrantedAuthority&gt; authorities = List.of(new SimpleGrantedAuthority(&quot;ROLE_&quot; + role)); // Spring Security에서 사용할 권한 정보 생성

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(authUser, null, authorities); // 인증 객체 생성 / 비밀번호는 null

            SecurityContextHolder.getContext().setAuthentication(authenticationToken); // SecurityContext에 인증 객체 저장
            System.out.println(role);
            System.out.println(authorities);
            System.out.println(authenticationToken);
        }
        filterChain.doFilter(request, response); // 다음 필터로 요청 전달
    }
}</code></pre>
<h4 id="3-securityconfig-설계">3. SecurityConfig 설계</h4>
<pre><code class="language-java">    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable) // CSRF(사이트 간 위조 요청) 보안 비활성화 - JWT 기반에서는 필요 없음
            .sessionManagement(session -&gt; session // 세션을 사용하지 않음 - JWT 사용
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -&gt; auth // 요청별 인가 규칙
                .requestMatchers(&quot;/auth/**&quot;).permitAll() // /auth/** 경로는 누구나 접근 가능
                .requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;) // /admin/** 경로는 어드민 만 접근 가능
                .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 인증 필터 등록 (기본 인증 필터 앞에 위치)
            .formLogin(AbstractHttpConfigurer::disable) // 기본 로그인 폼 사용 안함 - JWT 사용
            .httpBasic(AbstractHttpConfigurer::disable); // HTTP Basic 인증 사용 안 함 (ID/PW를 매 요청마다 보내는 방식)

        //최종 보안 설정을 적용한 SecurityFilterChain 반환
        return http.build();
    }</code></pre>
<h4 id="4-passwordencoderconfig-설개">4. PasswordEncoderConfig 설개</h4>
<pre><code class="language-java">import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}</code></pre>
<p>여기서 문제
기존의 PasswordEncoder 커스텀 클래스의 처리</p>
<ul>
<li>이름이 PasswordEncoder라서 Spring Security의 인터페이스랑 이름이 겹쳐서 충돌 남</li>
<li>Spring Security 기반으로 전환 중 임으로, 커스텀 로직은 불필요</li>
<li>BCryptPasswordEncoder는 Spring Security에서 안정적이고 표준적으로 제공</li>
<li><blockquote>
<p>결론 지워 버리자</p>
</blockquote>
</li>
</ul>
<p>수정 했으니 해당 클레스를 사용하는 서비스의 <strong>import</strong> 수정 필요</p>
<ol>
<li>auth.AuthService</li>
<li>user.UserService<pre><code class="language-java">//삭제
import org.example.expert.config.PasswordEncoder;</code></pre>
<pre><code class="language-java">//추가
import org.springframework.security.crypto.password.PasswordEncoder;</code></pre>
</li>
</ol>
<h4 id="5-필요-없어진-java파일-정리">5. 필요 없어진 java파일 정리</h4>
<ol>
<li><code>JwtFilter.java</code><ul>
<li>기존에 <code>Filter</code> 인터페이스 상속해서 토큰 처리하던 클래스</li>
<li><blockquote>
<p>Spring Security의 <code>JwtAuthenticationFilter</code>로 대체됐으므로 삭제</p>
</blockquote>
</li>
</ul>
</li>
<li><code>FilterConfig.java</code><ul>
<li><code>JwtFilter</code>를 등록하던 <code>FilterRegistrationBean</code> 설정 클래스</li>
<li><blockquote>
<p><code>SecurityFilterChain</code>에서 관리하므로 필요 없음</p>
</blockquote>
</li>
</ul>
</li>
<li><code>AuthUserArgumentResolver.java</code><ul>
<li><code>@Auth</code> 어노테이션 + 커스텀 객체 <code>AuthUser</code> 주입 기능 제공</li>
<li><blockquote>
<p><code>@AuthenticationPrincipal</code>로 대체 가능</p>
</blockquote>
</li>
</ul>
</li>
<li><code>WebConfig.java</code><ul>
<li><code>AuthUserArgumentResolver</code>를 등록하던 설정 클래스
-&gt; 위 클래스 제거하면 이 설정도 쓸모 없음</li>
</ul>
</li>
<li><code>PasswordEncoder.java</code><ul>
<li>BCrypt 직접 썼던 커스텀 인코더</li>
<li><blockquote>
<p><code>PasswordEncoderConfig</code>로 대체됨, 이름 충돌 및 중복 문제 있음</p>
</blockquote>
</li>
</ul>
</li>
</ol>
<h3 id="도전과제-최종-테스트와-문제">도전과제 최종 테스트와 문제</h3>
<h4 id="1-not-null-property-references-a-transient-value---transient-instance-must-be-saved-before-current-operation---todouser---user">1. Not-null property references a transient value - transient instance must be saved before current operation -&gt; Todo.user -&gt; User</h4>
<p>원인 : 컨트롤러 단에서 <code>@Auth</code> 사용함 -&gt; JWT토큰으로 변경하며 <code>AuthUserArgumentResolver</code> 삭제
null 발생</p>
<p>해결:  <code>@Auth</code> 에서 <code>@AuthenticationPrincipal</code>으로 변경</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.04.30]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.04.30</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.04.30</guid>
            <pubDate>Wed, 30 Apr 2025 11:30:12 GMT</pubDate>
            <description><![CDATA[<h3 id="properties-vs-yml">properties vs yml</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>properties</th>
<th>yml</th>
</tr>
</thead>
<tbody><tr>
<td>문법</td>
<td>단순 key=value</td>
<td>구조화된 계층형</td>
</tr>
<tr>
<td>가독성</td>
<td>낮음</td>
<td>높음</td>
</tr>
<tr>
<td>실수 가능성</td>
<td>적음</td>
<td>들여쓰기 실수 주의 필요</td>
</tr>
<tr>
<td>최근 추세</td>
<td>감소 중</td>
<td>Spring 공식 권장</td>
</tr>
</tbody></table>
<br>

<table>
<thead>
<tr>
<th>항목</th>
<th>.yml 예시</th>
<th>.properties 예시</th>
</tr>
</thead>
<tbody><tr>
<td>계층 구조 표현</td>
<td>들여쓰기 (: 사용)</td>
<td>.으로 연결된 키로 표현</td>
</tr>
<tr>
<td>가독성</td>
<td>설정이 많을수록 보기 편함</td>
<td>길어지고 복잡해짐</td>
</tr>
<tr>
<td>에러 가능성</td>
<td>들여쓰기 오류 가능</td>
<td>오타나 중복 키 가능성 있음</td>
</tr>
<tr>
<td>실제 쓰는 상황</td>
<td>대규모 프로젝트, 구조화된 설정에 강함</td>
<td>간단하거나 오래된 프로젝트와 호환에 유리함</td>
</tr>
</tbody></table>
<h4 id="yml-기본-세팅">yml 기본 세팅</h4>
<pre><code class="language-yml">spring:
  datasource:
    url: jdbc:mysql://localhost:3306/yourDb
    username: username
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

server:
  port: 8080

jwt:
  secret:
    key: secretKey</code></pre>
<h4 id="properties-기본-세팅">properties 기본 세팅</h4>
<pre><code class="language-java">spring.datasource.url=jdbc:mysql://localhost:3306/yourDb
spring.datasource.username=username
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

server.port=8080

jwt.secret.key=secretKey</code></pre>
<blockquote>
<p>properties 코드블럭 문법 하이라이딩 하실 수 있는 분...?</p>
</blockquote>
<hr>
<h1 id="개별-프로젝트">개별 프로젝트</h1>
<h2 id="lv-0">Lv. 0</h2>
<h3 id="0-프로젝트-기본-설정">0. 프로젝트 기본 설정</h3>
<h4 id="1-applicationyml">1. application.yml</h4>
<pre><code class="language-yml">spring:
  datasource:
    url: jdbc:mysql://localhost:3306/${MYSQL_NAME}?useSSL=${SSL}&amp;allowPublicKeyRetrieval=${ALLOWPUBLICKEYRETRIEVAL}
    username: ${MYSQL_USERNAME}
    password: ${MYSQL_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

server:
  port: 8080

jwt:
  secret:
    key: ${SECRET_KEY}</code></pre>
<h4 id="2-환경변수파일env">2. 환경변수파일(.env)</h4>
<pre><code>#MYSQL 설정
MYSQL_NAME=
SSL=false
ALLOWPUBLICKEYRETRIEVAL=true

MYSQL_USERNAME=root
MYSQL_PASSWORD=

#JWT
SECRET_KEY=</code></pre><hr>
<h2 id="lv-1">Lv. 1</h2>
<h3 id="1-코드-개선-퀴즈---transactional의-이해">1. 코드 개선 퀴즈 - @Transactional의 이해</h3>
<pre><code class="language-java">    @PostMapping(&quot;/todos&quot;)
    public ResponseEntity&lt;TodoSaveResponse&gt; saveTodo(
            @Auth AuthUser authUser,
            @Valid @RequestBody TodoSaveRequest todoSaveRequest
    ) {
        return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest));
    }</code></pre>
<blockquote>
<p>해당 API 호출 시 에러 발생</p>
</blockquote>
<pre><code>jakarta.servlet.ServletException: Request processing failed: org.springframework.orm.jpa.JpaSystemException: could not execute statement [Connection is read-only. Queries leading to data modification are not allowed] [insert into todos (contents,created_at,modified_at,title,user_id,weather) values (?,?,?,?,?,?)]</code></pre><ol>
<li><p>에러 내용
DB 커넥션이 읽기 전용(<code>read-only</code>) 상태인데, 쓰기 작업 시도</p>
</li>
<li><p>에러 예상
<code>Service</code> 클래스 전체가 <code>@Transactional(readOnly = true)</code>로 되어 있거나, <code>saveTodo</code> 메서드가 <code>@Transactional(readOnly = true)</code> 일 것으로 예상</p>
</li>
<li><p>확인 결과
<code>TodoService</code> 클래스 전체에 <code>@Transactional(readOnly = true)</code> 걸려있음을 확인</p>
</li>
<li><p>개선
방법 1 : 클래스에 걸려있는 <code>readOnly = true</code> 삭제
방법 2 : 클래스 설정보다 메서드 설정을 우선한다는 점에서 메서드 위에 <code>@Transactional</code> 명시 (기본값 : false)</p>
</li>
</ol>
<hr>
<h3 id="2-코드-추가-퀴즈---jwt의-이해">2. 코드 추가 퀴즈 - JWT의 이해</h3>
<p>토큰안에 nickname값 넣기</p>
<ol>
<li><p>User Entity에 컬럼 추가
<code>private String nickname;</code></p>
</li>
<li><p>Entity 내부 메서드 수정</p>
<pre><code class="language-java">public User(String email, String password, UserRole userRole, String nickname) {
     this.email = email;
     this.password = password;
     this.userRole = userRole;
     this.nickname = nickname; // &lt;- 추가
 }

 private User(Long id, String email, UserRole userRole, String nickname) {
     this.id = id;
     this.email = email;
     this.userRole = userRole;
     this.nickname = nickname; // &lt;- 추가
 }

 public static User fromAuthUser(AuthUser authUser) {
     return new User(authUser.getId(), authUser.getEmail(), authUser.getUserRole(), authUser.getNickname()); // &lt;- 추가
 }</code></pre>
</li>
<li><p>회원가입 Request에 nickname 추가</p>
<pre><code class="language-java">@NotBlank 
private String nickname; // &lt;- 추가</code></pre>
</li>
<li><p><code>domain.auth.service.AuthService</code> 수정</p>
<pre><code class="language-java">public SignupResponse signup(SignupRequest signupRequest) {

     if (userRepository.existsByEmail(signupRequest.getEmail())) {
         throw new InvalidRequestException(&quot;이미 존재하는 이메일입니다.&quot;);
     }

     String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

     UserRole userRole = UserRole.of(signupRequest.getUserRole());

     User newUser = new User(
             signupRequest.getEmail(),
             encodedPassword,
             userRole,
             signupRequest.getNickname() // &lt;- 추가
     );
     User savedUser = userRepository.save(newUser);

     String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole, savedUser.getNickname()); // &lt;- 추가

     return new SignupResponse(bearerToken);
 }

 public SigninResponse signin(SigninRequest signinRequest) {
     User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
             () -&gt; new InvalidRequestException(&quot;가입되지 않은 유저입니다.&quot;));

     // 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
     if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
         throw new AuthException(&quot;잘못된 비밀번호입니다.&quot;);
     }

     String bearerToken = jwtUtil.createToken(user.getId(), user.getEmail(), user.getUserRole(), user.getNickname()); // &lt;- 추가

     return new SigninResponse(bearerToken);
 }</code></pre>
</li>
<li><p><code>config.AuthUserArgumentResolver</code> 수정</p>
<pre><code class="language-java">// JwtFilter 에서 set 한 userId, email, userRole, nickname 값을 가져옴
     Long userId = (Long) request.getAttribute(&quot;userId&quot;);
     String email = (String) request.getAttribute(&quot;email&quot;);
     UserRole userRole = UserRole.of((String) request.getAttribute(&quot;userRole&quot;));
     String nickname = (String) request.getAttribute(&quot;nickname&quot;); // &lt;- 추가

     return new AuthUser(userId, email, userRole, nickname); // &lt;- 추가</code></pre>
</li>
<li><p><code>JwtUtill</code> 수정</p>
<pre><code class="language-java">public String createToken(Long userId, String email, UserRole userRole, String nickname) {
     Date date = new Date();

     return BEARER_PREFIX +
             Jwts.builder()
                     .setSubject(String.valueOf(userId))
                     .claim(&quot;email&quot;, email)
                     .claim(&quot;userRole&quot;, userRole)
                     .claim(&quot;nickname&quot;, nickname) // &lt;- 추가
                     .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                     .setIssuedAt(date) // 발급일
                     .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                     .compact();
 }</code></pre>
</li>
</ol>
<hr>
<h3 id="3-코드-개선-퀴즈---jpa의-이해">3. 코드 개선 퀴즈 - JPA의 이해</h3>
<ol>
<li><p>JPQL을 사용하여 목표 달성</p>
<pre><code class="language-java"> @Query(&quot;&quot;&quot;
     SELECT t
     FROM Todo t LEFT JOIN FETCH t.user u
     WHERE (:weather IS NULL OR t.weather = :weather)
         AND (:start IS NULL OR t.modifiedAt &gt;= :start)
         AND (:end IS NULL OR t.modifiedAt &lt;= :end)
     ORDER BY t.modifiedAt DESC
 &quot;&quot;&quot;)
 Page&lt;Todo&gt; searchTodos(
     Pageable pageable,
     @Param(&quot;weather&quot;) String weather,
     @Param(&quot;start&quot;) String start,
     @Param(&quot;end&quot;) String end
     );</code></pre>
<pre><code class="language-sql">SELECT t
FROM Todo t LEFT JOIN FETCH t.user u
WHERE (:weather IS NULL OR t.weather = :weather)
AND (:start IS NULL OR t.modifiedAt &gt;= :start)
AND (:end IS NULL OR t.modifiedAt &lt;= :end)
ORDER BY t.modifiedAt DESC</code></pre>
<h4 id="1-문제점">1. 문제점</h4>
<blockquote>
<p><code>fetch join</code>과 <code>page&lt;Todo&gt;</code> 같이 쓸 수 있는가?</p>
<ul>
<li>참고자료: <a href="https://lsj31404.tistory.com/94">https://lsj31404.tistory.com/94</a></li>
</ul>
</blockquote>
</li>
<li><p>이유</p>
</li>
</ol>
<ul>
<li><code>fetch join</code>은 JPA가 내부적으로 중복 row를 만듬</li>
<li><code>@OneToMany</code>, <code>@ManyToOne</code> 관계에서 <code>fetch join</code>을 쓰면 SQL 쿼리 상에서 중복된 row가 만들어짐</li>
<li>이럴 경우 JPA는 하나의 <code>Todo</code>를 복수 row로 인식해서 메모리에서 중복 제거를 시도</li>
</ul>
<p>그 결과 <code>Todo</code> 여러 개가 리턴될 수 있음</p>
<ol start="2">
<li>근본적 이유</li>
</ol>
<ul>
<li>JPA가 페이징을 위해 count query를 자동 생성하는데, <code>fetch join</code>은 count 쿼리에 적합하지 않음</li>
<li>fetch join은 row 수를 늘려버리기 때문에 정확한 페이징 깨짐</li>
</ul>
<ol start="3">
<li><p>요약 -&gt; 
<code>fetch join</code>을 왜 쓰는가? : N+1문제를 해결하기 위해
N+1은 왜 생기는가? : 한개의 엔티티를 검색할때 여기에 연결된 엔티티가 여러개 라서!</p>
</li>
<li><p>그래서 그게 왜 문제 인가?
<img src="https://velog.velcdn.com/images/giwon_wg/post/fd09eae9-7c49-435f-b203-7dc44a1b9175/image.png" alt="">
위 그림과 같은 상황에서
각각 <code>page</code>처리를 하여 한 페이지에 3개 묶음 씩 해서 2페이지 보여주세요 라고 한다면?
일반 경우 게시글 4, 5, 6 을 출력
<code>fetch join</code>의 경우 게시글 1의 댓글 4, 5 와 게시글 2의 댓글 1 출력</p>
</li>
</ol>
<h4 id="2-해결법">2. 해결법</h4>
<ol>
<li><code>fetch join</code> + <code>List&lt;T&gt;</code> -&gt; 전체 조회에 적합, 페이징 X</li>
<li><code>fetch join</code> + <code>Page&lt;T&gt; + countQuery</code> 직접 명시 -&gt; 복잡하지만 안정적</li>
<li><code>Page&lt;T&gt;</code> + 지연 로딩(<code>LAZY</code>) -&gt; 일반적으로 사용</li>
</ol>
<h4 id="3-코드작성">3. 코드작성</h4>
<ol>
<li><p>TodoRepository 작성</p>
<pre><code class="language-java">@Query(value = &quot;&quot;&quot;
     SELECT t
     FROM Todo t LEFT JOIN FETCH t.user u
     WHERE (:weather IS NULL OR t.weather = :weather)
         AND (:start IS NULL OR t.modifiedAt &gt;= :start)
         AND (:end IS NULL OR t.modifiedAt &lt;= :end)
     ORDER BY t.modifiedAt DESC
 &quot;&quot;&quot;,
 countQuery = &quot;&quot;&quot;
     SELECT COUNT(t)
     FROM Todo t
     WHERE (:weather IS NULL OR t.weather = :weather)
         AND (:start IS NULL OR t.modifiedAt &gt;= :start)
         AND (:end IS NULL OR t.modifiedAt &lt;= :end)
 &quot;&quot;&quot;
 )
 Page&lt;Todo&gt; searchTodos(
     Pageable pageable,
     @Param(&quot;weather&quot;) String weather,
     @Param(&quot;start&quot;) LocalDateTime start,
     @Param(&quot;end&quot;) LocalDateTime end
     );</code></pre>
</li>
<li><p>TodoService 작성</p>
<pre><code class="language-java">public Page&lt;TodoResponse&gt; searchTodos(int page, int size, String weather, LocalDateTime start, LocalDateTime end) {
     Pageable pageable = PageRequest.of(page - 1, size);

     Page&lt;Todo&gt; todos = todoRepository.searchTodos(pageable, weather, start, end);

     return todos.map(todo -&gt; new TodoResponse(
         todo.getId(),
         todo.getTitle(),
         todo.getContents(),
         todo.getWeather(),
         new UserResponse(todo.getUser().getId(), todo.getUser().getEmail()),
         todo.getCreatedAt(),
         todo.getModifiedAt()
     ));
 }</code></pre>
</li>
<li><p>TodoController 작성</p>
<pre><code class="language-java">@GetMapping(&quot;/todos/search&quot;)
 public ResponseEntity&lt;Page&lt;TodoResponse&gt;&gt; searchTodos(
     @RequestParam(defaultValue = &quot;1&quot;) int page,
     @RequestParam(defaultValue = &quot;10&quot;) int size,
     @RequestParam(required = false) String weather,
     @RequestParam(required = false) String start,
     @RequestParam(required = false) String end
 ) {
     LocalDateTime startDateTime = (start != null) ? LocalDateTime.parse(start) : null;
     LocalDateTime endDateTime = (end != null) ? LocalDateTime.parse(end) : null;
     return ResponseEntity.ok(todoService.searchTodos(page, size, weather, startDateTime, endDateTime));
 }</code></pre>
</li>
</ol>
<hr>
<h3 id="4-테스트-코드-퀴즈---컨트롤러-테스트의-이해">4. 테스트 코드 퀴즈 - 컨트롤러 테스트의 이해</h3>
<p><code>todo_단건_조회_시_todo가_존재하지_않아_예외가_발생한다()</code> 테스트가 성공적으로 작동할 수 있게 코드 수정</p>
<pre><code class="language-java">mockMvc.perform(get(&quot;/todos/{todoId}&quot;, todoId))
                .andExpect(status().isOk())
                .andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.OK.name()))
                .andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.OK.value()))
                .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;Todo not found&quot;));</code></pre>
<ul>
<li>예외 처리임에도 기대값을 <code>OK</code>로 설정<pre><code class="language-java">mockMvc.perform(get(&quot;/todos/{todoId}&quot;, todoId))
              .andExpect(status().isBadRequest())
              .andExpect(jsonPath(&quot;$.status&quot;).value(HttpStatus.BAD_REQUEST.name()))
              .andExpect(jsonPath(&quot;$.code&quot;).value(HttpStatus.BAD_REQUEST.value()))
              .andExpect(jsonPath(&quot;$.message&quot;).value(&quot;Todo not found&quot;));</code></pre>
</li>
<li>기대값을 <code>BAD_REQUEST</code>로 수정</li>
</ul>
<hr>
<h3 id="5-코드-개선-퀴즈---aop의-이해">5. 코드 개선 퀴즈 - AOP의 이해</h3>
<p>특정 클래스의 메소드가 실행 전 동작하게 수정 필요</p>
<ol>
<li><p>기존 코드</p>
<pre><code class="language-java">@After(&quot;execution(* org.example.expert.domain.user.controller.UserController.getUser(..))&quot;)
 public void logAfterChangeUserRole(JoinPoint joinPoint) {
     String userId = String.valueOf(request.getAttribute(&quot;userId&quot;));
     String requestUrl = request.getRequestURI();
     LocalDateTime requestTime = LocalDateTime.now();

     log.info(&quot;Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}&quot;,
             userId, requestTime, requestUrl, joinPoint.getSignature().getName());
 }</code></pre>
</li>
<li><p>수정 코드</p>
<pre><code>@Before(&quot;execution(* org.example.expert.domain.user.controller.UserController.getUser(..))&quot;)
 public void logAfterChangeUserRole(JoinPoint joinPoint) {
     String userId = String.valueOf(request.getAttribute(&quot;userId&quot;));
     String requestUrl = request.getRequestURI();
     LocalDateTime requestTime = LocalDateTime.now();

     log.info(&quot;Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}&quot;,
             userId, requestTime, requestUrl, joinPoint.getSignature().getName());
 }</code></pre></li>
</ol>
<ul>
<li>어노테이션 교체 <code>@After</code> -&gt; <code>@Before</code></li>
</ul>
<hr>
<h2 id="lv-2">Lv. 2</h2>
<h3 id="1-jpa---cascade">1. JPA - Cascade</h3>
<ul>
<li>할 일을 새로 저장할 시, 할 일을 생성한 유저는 담당자로 자동 등록<h4 id="기존-코드">기존 코드</h4>
<pre><code class="language-java">@OneToMany(mappedBy = &quot;todo&quot;)
  private List&lt;Manager&gt; managers = new ArrayList&lt;&gt;();</code></pre>
<h4 id="수정-코드">수정 코드</h4>
<pre><code class="language-java">@OneToMany(mappedBy = &quot;todo&quot;, cascade = CascadeType.ALL, orphanRemoval = true)
  private List&lt;Manager&gt; managers = new ArrayList&lt;&gt;();</code></pre>
</li>
</ul>
<ol>
<li><code>OneToMany</code>: Todo 1개가 Manager 여러 개를 가질 수 있음.(1:N 관계)</li>
<li><code>mappedBy = &quot;todo&quot;</code>: 양방향 매핑 기준. Manager 쪽의 <code>todo</code> 필드가 주인</li>
<li><code>cascade = CascadeType.ALL</code>: <code>Todo</code> 저장/삭제 시, 관련된 <code>Manager</code>도 자동으로 저장/삭제</li>
<li><code>orphanRemoval = true</code>: <code>Todo.managers</code> 리스트에서 제거된 <code>Manager</code>는 DB에서도 삭제<ul>
<li><code>todo.getManagers().remove(manager);</code> 선언시 DB에서 삭제</li>
</ul>
</li>
</ol>
<hr>
<h3 id="2-n--1">2. N + 1</h3>
<ul>
<li>댓글 조회 시 N+1문제 발생<pre><code>Hibernate: 
  select
      c1_0.id,
      c1_0.contents,
      c1_0.created_at,
      c1_0.modified_at,
      c1_0.todo_id,
      c1_0.user_id 
  from
      comments c1_0 
  where
      c1_0.todo_id=?
Hibernate: 
  select
      u1_0.id,
      u1_0.created_at,
      u1_0.email,
      u1_0.modified_at,
      u1_0.nickname,
      u1_0.password,
      u1_0.user_role 
  from
      users u1_0 
  where
      u1_0.id=?
Hibernate: 
  select
      u1_0.id,
      u1_0.created_at,
      u1_0.email,
      u1_0.modified_at,
      u1_0.nickname,
      u1_0.password,
      u1_0.user_role 
  from
      users u1_0 
  where
      u1_0.id=?
Hibernate: 
  select
      u1_0.id,
      u1_0.created_at,
      u1_0.email,
      u1_0.modified_at,
      u1_0.nickname,
      u1_0.password,
      u1_0.user_role 
  from
      users u1_0 
  where
      u1_0.id=?</code></pre></li>
<li>게시글에 댓글을 단 ID를 한번씩 조회함.</li>
</ul>
<h4 id="1-commentrepository-코드-수정-필요">1. <code>CommentRepository</code> 코드 수정 필요</h4>
<pre><code class="language-java">@Query(&quot;SELECT c FROM Comment c JOIN c.user WHERE c.todo.id = :todoId&quot;)
    List&lt;Comment&gt; findByTodoIdWithUser(@Param(&quot;todoId&quot;) Long todoId);</code></pre>
<ul>
<li><code>JOIN</code>만 사용함을 확인 할 수 있음</li>
<li><blockquote>
<p><code>USER</code>가 여전히 지연(<code>LAZY</code>) 로딩 대상</p>
</blockquote>
</li>
<li><code>JOIN FETCH</code>으로 변경 하여 N + 1 문제 해결<pre><code class="language-java">@Query(&quot;SELECT c FROM Comment c JOIN FETCH c.user WHERE c.todo.id = :todoId&quot;)
  List&lt;Comment&gt; findByTodoIdWithUser(@Param(&quot;todoId&quot;) Long todoId);</code></pre>
</li>
</ul>
<hr>
<h3 id="3-querydsl">3. QueryDSL</h3>
<h4 id="querydsl-란">QueryDSL 란?</h4>
<blockquote>
<p>QueryDSL은 Java 기반 ORM(Query DSL: Domain Specific Language) 중 하나로, 타입 안정성 있는 SQL-like 쿼리를 자바 코드로 작성할 수 있도록 도와주는 프레임워크</p>
</blockquote>
<h4 id="왜-쓰는가">왜 쓰는가?</h4>
<blockquote>
<p>기존 JPQL이나 native query는 문자열 기반이라 컴파일 타임에 에러를 잡을 수 없지만, QueryDSL은 자바 코드로 쿼리를 작성함으로 오타나 잘못된 필드를 컴파일 타임에 체크 가능</p>
</blockquote>
<h4 id="사용-전-설정">사용 전 설정</h4>
<ol>
<li><code>build.gradle</code>에 의존성 추가<pre><code class="language-java">plugins {
 id &#39;java&#39;
 id &#39;org.springframework.boot&#39; version &#39;3.3.3&#39;
 id &#39;io.spring.dependency-management&#39; version &#39;1.1.6&#39;
 id &#39;idea&#39;
}
</code></pre>
</li>
</ol>
<p>group = &#39;org.example&#39;
version = &#39;0.0.1-SNAPSHOT&#39;</p>
<p>java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}</p>
<p>def querydslDir = &quot;$buildDir/generated/querydsl&quot;</p>
<p>sourceSets {
    main {
        java {
            srcDirs += querydslDir
        }
    }
}</p>
<p>idea {
    module {
        generatedSourceDirs += file(querydslDir)
    }
}</p>
<p>configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}</p>
<p>repositories {
    mavenCentral()
}</p>
<p>dependencies {
    // Spring Boot
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;</p>
<pre><code>// QueryDSL
implementation &#39;com.querydsl:querydsl-jpa:5.0.0:jakarta&#39;
annotationProcessor &#39;com.querydsl:querydsl-apt:5.0.0:jakarta&#39;
annotationProcessor &#39;jakarta.persistence:jakarta.persistence-api:3.1.0&#39;
annotationProcessor &#39;jakarta.annotation:jakarta.annotation-api:2.1.1&#39;
implementation &#39;jakarta.persistence:jakarta.persistence-api:3.1.0&#39;

// Lombok
compileOnly &#39;org.projectlombok:lombok&#39;
annotationProcessor &#39;org.projectlombok:lombok&#39;

// Database
runtimeOnly &#39;com.h2database:h2&#39;
runtimeOnly &#39;com.mysql:mysql-connector-j&#39;

// JWT
compileOnly &#39;io.jsonwebtoken:jjwt-api:0.11.5&#39;
runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.11.5&#39;
runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.11.5&#39;

// Test
testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
testRuntimeOnly &#39;org.junit.platform:junit-platform-launcher&#39;</code></pre><p>}</p>
<p>tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}</p>
<p>tasks.withType(JavaCompile) {
    options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
    options.compilerArgs += &quot;-XprintRounds&quot;
    options.compilerArgs += &quot;-XprintProcessorInfo&quot;
}</p>
<pre><code>&gt;- Gradle 이 설치 되어 있어야 한다. 만약 없다면
터미널에 아래 문구를 순차적으로 입력하여 설치 진행
1. Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
2. irm get.scoop.sh | iex
3. scoop install gradle
4. gradle -v // 버전 체크
5. gradle wrapper // 실행

&gt; - 설치를 완료 했다면 터미널에
./gradlew clean build // H2 등 DB 설정 필요 없으면 실패
./gradlew clean build -x test // 테스트 스킵 빌딩만 함

![](https://velog.velcdn.com/images/giwon_wg/post/bc51048b-be1b-42e5-96bf-bf6dce5f7582/image.png)
Qentity 가 생겼다면 성공!

#### QueryDslConfig 설계
```java
@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}</code></pre><h4 id="변경-대상---todorepositoryfindbyidwithuser">변경 대상 - TodoRepository.findByIdWithUser</h4>
<pre><code class="language-java">@Query(&quot;SELECT t FROM Todo t &quot; +
            &quot;LEFT JOIN t.user &quot; +
            &quot;WHERE t.id = :todoId&quot;)
    Optional&lt;Todo&gt; findByIdWithUser(@Param(&quot;todoId&quot;) Long todoId);</code></pre>
<h4 id="querydsl-변경-후">QueryDSL 변경 후</h4>
<p>위 코드 -&gt; 주석 처리</p>
<pre><code class="language-java">public interface TodoRepository extends JpaRepository&lt;Todo, Long&gt;, TodoQueryRepository {</code></pre>
<ul>
<li><code>TodoQueryRepository</code> -&gt; 추가</li>
</ul>
<pre><code class="language-java">public interface TodoQueryRepository {
    Optional&lt;Todo&gt; findByIdWithUser(Long todoId);
}</code></pre>
<ul>
<li>클래스 추가</li>
</ul>
<pre><code class="language-java">@RequiredArgsConstructor
public class TodoQueryRepositoryImpl implements TodoQueryRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Optional&lt;Todo&gt; findByIdWithUser(Long todoId) {
        QTodo todo = QTodo.todo;
        QUser user = QUser.user;

        return Optional.ofNullable(
            queryFactory
                .selectFrom(todo)
                .leftJoin(todo.user, user).fetchJoin()
                .where(todo.id.eq(todoId))
                .fetchOne()
        );
    }
}</code></pre>
<ul>
<li><code>TodoQueryRepositoryImpl</code> 클래스 추가</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.04.29]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.04.29</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.04.29</guid>
            <pubDate>Tue, 29 Apr 2025 09:00:12 GMT</pubDate>
            <description><![CDATA[<h2 id="1-구글-oauth-20-소셜-로그인-무작정-따라하기">1. 구글 Oauth 2.0 소셜 로그인 무작정 따라하기!</h2>
<hr>
<h3 id="1-user-entity-작성">1. User Entity 작성</h3>
<blockquote>
<p>가장 먼저 회원 <strong>정보를 저장할 Usuer 엔티티</strong>를 만들어야 겠죠? 
우리는 <strong>엔티티</strong>를 수 없이 만들어 보았으니, 금방 작성 하실거에요!</p>
</blockquote>
<pre><code class="language-java">@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = &quot;users&quot;)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email; // 구글로부터 받은 이메일

    @Column(nullable = false)
    private String password; // 소셜 로그인은 고정 패스워드 사용

    @Column(nullable = false)
    private String nickname; // 닉네임 (랜덤 생성)
}</code></pre>
<hr>
<h3 id="2-repository-작성">2. Repository 작성</h3>
<blockquote>
<p>스탭 2!
이메일로 회원을 조회하거나 저장할 인터페이스 설계!</p>
</blockquote>
<pre><code class="language-java">public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
    Optional&lt;User&gt; findByEmail(String email); // 이메일로 회원 조회
}</code></pre>
<hr>
<h3 id="3-service-작성">3. Service 작성</h3>
<blockquote>
<p>이메일로 기존 회원을 찾고, 없으면? 자동가입을 시켜준다~</p>
</blockquote>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class UserServiceImpl {

    private final UserRepository userRepository;

    // 이메일로 회원을 찾고, 없으면 새로 가입시킴
    public User registerIfNeed(String email) {
        return userRepository.findByEmail(email)
                .orElseGet(() -&gt; {
                    User newUser = User.builder()
                            .email(email)
                            .password(&quot;SOCIAL_LOGIN&quot;) // 고정 패스워드
                            .nickname(&quot;구글유저_&quot; + UUID.randomUUID().toString().substring(0, 8))
                            .build();
                    return userRepository.save(newUser);
                });
    }
}</code></pre>
<hr>
<h3 id="4-controller-작성">4. Controller 작성</h3>
<blockquote>
<p>로그인 성공 시 이탈할 Controller도 작성합니다.</p>
</blockquote>
<pre><code class="language-java">@Controller
@RequiredArgsConstructor
public class LoginSuccessController {

    private final UserServiceImpl userService;

    @GetMapping(&quot;/login/success&quot;)
    public String oauthSuccess(@AuthenticationPrincipal OAuth2User oAuth2User) {
        // 1. 구글 로그인 완료 후 사용자 정보 가져오기
        String email = oAuth2User.getAttribute(&quot;email&quot;);

        // 2. 이메일로 회원가입 or 로그인 처리
        User user = userService.registerIfNeed(email);

        // 3. 로그인 성공 후 리다이렉트 (프론트 화면 경로)
        return &quot;redirect:/welcome.html&quot;; // 원하는 경로로 변경 가능
    }
}</code></pre>
<hr>
<h3 id="5-securityconfig-작성">5. SecurityConfig 작성</h3>
<blockquote>
<p>Config 설정은 어려울 수 있어요! 차근차근 진행 해 보자구요?</p>
</blockquote>
<pre><code class="language-java">@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeHttpRequests(authorize -&gt; authorize
                .requestMatchers(&quot;/&quot;, &quot;/login/**&quot;).permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -&gt; oauth2
                .defaultSuccessUrl(&quot;/login/success&quot;) // 로그인 성공 시 이동할 URL
            );

        return http.build();
    }
}</code></pre>
<hr>
<h3 id="6-applicationproperties-작성">6. application.properties 작성</h3>
<blockquote>
<p>이제 코드 작성은 끝! <code>properties</code>에 아래 내용을 붙여 넣어주세요!</p>
</blockquote>
<pre><code>spring.security.oauth2.client.registration.google.client-id=YOUR_GOOGLE_CLIENT_ID
spring.security.oauth2.client.registration.google.client-secret=YOUR_GOOGLE_CLIENT_SECRET
spring.security.oauth2.client.registration.google.scope=email,profile

spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth
spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token
spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v2/userinfo</code></pre><p><code>YOUR_GOOGLE_CLIENT_ID</code>와 <code>YOUR_GOOGLE_CLIENT_SECRET</code>이 뭐냐구요?
<br>
그렇군요. 여기 까지 온 당신! 이제 인터넷을 켜세요!</p>
<hr>
<h3 id="7-google-oauth-설정">7. Google Oauth 설정</h3>
<blockquote>
<p>이제 구글 서버에 우리 서비스를 등록해서, <strong>인증</strong>기능을 사용하기 위한 설정을 진행합니다!</p>
</blockquote>
<ol>
<li><p>구글 클라우드 접속 → <a href="https://console.cloud.google.com/">https://console.cloud.google.com/</a></p>
</li>
<li><p>좌 상단의
<img src="https://velog.velcdn.com/images/giwon_wg/post/e9656921-eb1c-4d35-9172-fe9aca92b502/image.png" alt=""></p>
</li>
</ol>
<p><code>프로젝트 선택</code> 클릭</p>
<ol start="3">
<li><p>우측의 <code>새 프로젝트</code> 클릭
<img src="https://velog.velcdn.com/images/giwon_wg/post/5a5db576-8944-4b02-86e7-16a1abd31368/image.png" alt=""></p>
</li>
<li><p>프로젝트 이름 입력 후 <code>만들기</code>
<img src="https://velog.velcdn.com/images/giwon_wg/post/fab9d4eb-848d-44bd-bc2d-95e80f127a0a/image.png" alt=""></p>
</li>
<li><p>2번 <code>프로젝트 선텍</code> 클릭 후 만든 프로젝트로 진입</p>
</li>
<li><p>좌 상단의 <code>햄버거 버튼</code> 클릭 → <code>API 및 서비스</code> -&gt; <code>OAuth</code> 클릭
<img src="https://velog.velcdn.com/images/giwon_wg/post/e444a6d7-fbab-4a9a-b3c6-735db4dfb583/image.png" alt=""></p>
</li>
<li><p>시작 하기
<img src="https://velog.velcdn.com/images/giwon_wg/post/e46527c0-cf6c-45a3-8cbe-b147f317b93f/image.png" alt=""></p>
</li>
<li><p>앱 이름, 이메일 작성 후 <code>다음</code>
<img src="https://velog.velcdn.com/images/giwon_wg/post/50ce33a2-2d35-40ae-a56b-60dd8854f361/image.png" alt=""></p>
</li>
<li><p>대상 <code>외부</code>
<img src="https://velog.velcdn.com/images/giwon_wg/post/23242fa9-faab-4c66-9755-4bfa43136c8e/image.png" alt=""></p>
</li>
<li><p>나머지 정보 작성 후 <code>만들기</code>
<img src="https://velog.velcdn.com/images/giwon_wg/post/6c6887b9-14e7-4f07-8c48-d4a9f1f14f33/image.png" alt=""></p>
</li>
<li><p><code>클라이언트</code> → <code>클라이언트 만들기</code>
<img src="https://velog.velcdn.com/images/giwon_wg/post/dec7060a-76a8-439e-b701-2778bb214d91/image.png" alt=""></p>
</li>
<li><p>내용 작성합니다!</p>
<br>
</li>
<li><p>만든 클라이언트 클릭!
<img src="https://velog.velcdn.com/images/giwon_wg/post/dec7060a-76a8-439e-b701-2778bb214d91/image.png" alt=""></p>
</li>
<li><p><code>클라이언트 ID</code>와 <code>보안 비밀번호 복사</code>
<img src="https://velog.velcdn.com/images/giwon_wg/post/d34f7a8f-3d26-4145-9fc4-9a93df78fcb1/image.png" alt=""></p>
</li>
<li><p>application.properties에 ID와 비밀번호 작성</p>
</li>
</ol>
<ul>
<li>클라이언트 ID<pre><code>spring.security.oauth2.client.registration.google.client-id=YOUR_GOOGLE_CLIENT_ID</code></pre></li>
<li>보안 비밀번호<pre><code>spring.security.oauth2.client.registration.google.client-secret=YOUR_GOOGLE_CLIENT_SECRET</code></pre></li>
</ul>
<h2 id="2-위치기반-api---kakao-map-무작정-따라하기">2. 위치기반 API - Kakao MAP 무작정 따라하기!</h2>
<p><strong>위치 정보를 가져오기 위해 외부 API(카카오맵 API)를 사용합니다.</strong></p>
<hr>
<h3 id="1-resttemplate-설정">1. RestTemplate 설정</h3>
<blockquote>
<p>외부 API 서버(카카오)에 요청을 보내기 위한 기본 설정!</p>
</blockquote>
<pre><code class="language-java">@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}</code></pre>
<hr>
<h3 id="2-kakaoaddressclient-작성">2. KakaoAddressClient 작성</h3>
<blockquote>
<p>카카오맵 서버에 좌표(x, y)를 보내고, 주소를 받아오는 역할이에요</p>
</blockquote>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class KakaoAddressClient {

    private final RestTemplate restTemplate;

    @Value(&quot;${kakao.rest-api-key}&quot;)
    private String kakaoApiKey;

    // 좌표(x, y)를 받아 주소를 요청하는 메서드
    public String getAddress(double longitude, double latitude) {
        String url = &quot;https://dapi.kakao.com/v2/local/geo/coord2address.json?x=&quot; + longitude + &quot;&amp;y=&quot; + latitude;

        HttpHeaders headers = new HttpHeaders();
        headers.set(&quot;Authorization&quot;, &quot;KakaoAK &quot; + kakaoApiKey);

        HttpEntity&lt;Void&gt; request = new HttpEntity&lt;&gt;(headers);

        ResponseEntity&lt;String&gt; response = restTemplate.exchange(
                url,
                HttpMethod.GET,
                request,
                String.class
        );

        return response.getBody(); // 그대로 JSON String 리턴
    }
}</code></pre>
<hr>
<h3 id="3-regionservice-작성">3. RegionService 작성</h3>
<blockquote>
<p>KakaoAddressClient를 이용해 비즈니스 로직을 처리합니다.</p>
</blockquote>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class RegionService {

    private final KakaoAddressClient kakaoAddressClient;

    // 위도, 경도를 받아서 주소 문자열을 반환하는 메서드
    public String getAddress(double longitude, double latitude) {
        return kakaoAddressClient.getAddress(longitude, latitude);
    }
}</code></pre>
<hr>
<h3 id="4-regioncontroller-작성">4. RegionController 작성</h3>
<blockquote>
<p>API 엔드포인트를 만들어, 프론트 요청을 받습니다.</p>
</blockquote>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/region&quot;)
public class RegionController {

    private final RegionService regionService;

    @GetMapping(&quot;/address&quot;)
    public ResponseEntity&lt;String&gt; getAddress(@RequestParam double longitude, @RequestParam double latitude) {
        // ex) /api/region/address?longitude=126.9780&amp;latitude=37.5665
        String address = regionService.getAddress(longitude, latitude);
        return ResponseEntity.ok(address);
    }
}</code></pre>
<hr>
<h3 id="5-applicationproperties-작성">5. application.properties 작성</h3>
<blockquote>
<p>카카오 REST API 키를 등록합니다.</p>
</blockquote>
<pre><code class="language-properties"># 카카오 REST API 키 입력
kakao.rest-api-key=YOUR_KAKAO_REST_API_KEY</code></pre>
<hr>
<h3 id="6-카카오-rest-api-키-발급-방법">6. 카카오 REST API 키 발급 방법</h3>
<blockquote>
<p><strong>카카오맵 API를 사용하려면 키를 발급받아야 합니다.</strong></p>
</blockquote>
<p><strong>[진행 순서]</strong></p>
<ol>
<li><p>카카오 개발자 사이트 접속 → <a href="https://developers.kakao.com/">https://developers.kakao.com/</a></p>
</li>
<li><p>로그인 후, 상단 <code>시작하기</code> 클릭!
<img src="https://velog.velcdn.com/images/giwon_wg/post/e70eba10-dff3-4be8-80ca-79f8f89f9f2a/image.png" alt=""></p>
</li>
<li><p>&quot;애플리케이션 추가하기&quot; 클릭
<img src="https://velog.velcdn.com/images/giwon_wg/post/329fd48f-99e8-415c-b81b-f6dafaba8f96/image.png" alt=""></p>
</li>
<li><p>내용을 작성하고, 저장!
<img src="https://velog.velcdn.com/images/giwon_wg/post/c4748fc1-7889-47f7-8810-0fc73aecca34/image.png" alt=""></p>
</li>
<li><p>좌측 메뉴 → 플랫폼 → 웹(Web) 등록
(<a href="http://localhost:8080">http://localhost:8080</a>)</p>
<blockquote>
<p><strong>주의:</strong> 플랫폼(Web) 등록을 안 하면 CORS 오류나 인증 오류가 발생할 수 있습니다.</p>
</blockquote>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/giwon_wg/post/10921452-80ed-4004-9e92-6071a04c98dc/image.png" alt=""></p>
<ol start="6">
<li><p>좌측 메뉴 → 카카오맵 → 활성화 <code>ON</code>
<img src="blob:https://velog.io/777ca11f-e930-42a5-9bd7-c53ead092202" alt="업로드중.."></p>
</li>
<li><p>&quot;앱 키&quot; 탭에서 &quot;REST API 키&quot; 복사</p>
</li>
<li><p><code>application.properties</code>에 복사한 키 붙여넣기</p>
</li>
</ol>
<p>여기까지 왔다면...</p>
<p>축하합니다!</p>
<p>여러분은 혼자서도 소셜로그인와 위치기반 API를 사용 할 수 있게 되었습니다!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.04.23]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.04.23</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.04.23</guid>
            <pubDate>Wed, 23 Apr 2025 12:18:01 GMT</pubDate>
            <description><![CDATA[<h3 id="til---20250423"><strong>TIL - 2025.04.23</strong></h3>
<h2 id="오늘-배운-것-개요"><strong>오늘 배운 것 개요</strong></h2>
<ul>
<li><strong>RefreshToken Redis 저장 실패 이슈 원인 분석 및 해결</strong></li>
<li><strong><code>@RequestBody</code> 바인딩 실패 원인 분석</strong></li>
<li><strong>Redis 키 문자열 공백 문제 해결</strong></li>
<li><strong>정확한 Redis 직렬화 설정 방법 학습</strong></li>
</ul>
<hr>
<h2 id="1-refreshtoken-redis-저장-실패-트러블슈팅"><strong>1. RefreshToken Redis 저장 실패 트러블슈팅</strong></h2>
<h3 id="1-문제-상황"><strong>1. 문제 상황</strong></h3>
<ul>
<li>로그인 시 <code>RefreshToken</code>을 Redis에 저장하고, 이후 이를 통해 <code>AccessToken</code>을 재발급받는 구조를 설계함.</li>
<li><code>/reissue</code> API 호출 시 다음과 같은 예외 발생:</li>
</ul>
<pre><code class="language-text">java.lang.IllegalArgumentException: RefreshToken이 비여 있거나, null입니다.</code></pre>
<hr>
<h3 id="2-원인-분석"><strong>2. 원인 분석</strong></h3>
<h4 id="1-redis-저장-여부-확인"><strong>1) Redis 저장 여부 확인</strong></h4>
<pre><code class="language-shell">127.0.0.1:6379&gt; keys *
1) &quot;RefreshToken&quot;
2) &quot;BlackList&quot;
3) &quot;testKey&quot;
127.0.0.1:6379&gt; get RT:1
(nil)</code></pre>
<ul>
<li>예상했던 <code>RT:1</code> 키가 존재하지 않거나 <code>null</code>로 조회됨.</li>
<li><code>LoginService</code> 내부 확인 결과, <strong>Redis 저장 로직이 빠져 있었음</strong>.</li>
</ul>
<h4 id="2-저장-로직-추가-후에도-이상한-직렬화-결과-발생"><strong>2) 저장 로직 추가 후에도 이상한 직렬화 결과 발생</strong></h4>
<pre><code class="language-shell">1) &quot;\xac\xed\x00\x05t\x00\x04RT:1&quot;</code></pre>
<ul>
<li>이는 <strong>Java 기본 직렬화 방식</strong>으로 저장된 결과. 사람이 읽을 수 없음.</li>
</ul>
<hr>
<h3 id="3-해결-방법"><strong>3. 해결 방법</strong></h3>
<h4 id="redis-직렬화-설정-추가"><strong>Redis 직렬화 설정 추가</strong></h4>
<pre><code class="language-java">StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();</code></pre>
<h4 id="정상-저장-확인"><strong>정상 저장 확인</strong></h4>
<pre><code class="language-shell">127.0.0.1:6379&gt; keys *
1) &quot;RT:1&quot;
2) &quot;BlackList&quot;
3) &quot;RefreshToken&quot;
127.0.0.1:6379&gt; get RT:1
&quot;eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxI...&quot;</code></pre>
<hr>
<h2 id="2-refreshtoken-재발급-과정에서-바인딩-실패"><strong>2. RefreshToken 재발급 과정에서 바인딩 실패</strong></h2>
<h3 id="1-문제-상황-1"><strong>1. 문제 상황</strong></h3>
<ul>
<li>Redis에는 잘 저장되었지만, <code>/reissue</code> 요청 시 다음 예외 발생:</li>
</ul>
<pre><code class="language-text">java.lang.IllegalArgumentException: RefreshToken이 비여 있거나, null입니다.</code></pre>
<hr>
<h3 id="2-원인-분석-1"><strong>2. 원인 분석</strong></h3>
<ul>
<li><p><code>TokenRefreshRequest</code> DTO가 다음과 같이 구성되어 있었음:</p>
<pre><code class="language-java">@Getter
@NoArgsConstructor
public class TokenRefreshRequest {
  private String refreshToken;
}</code></pre>
</li>
<li><p>하지만 바인딩이 실패하여 값이 null로 전달됨.</p>
</li>
</ul>
<hr>
<h3 id="3-해결-방법-1"><strong>3. 해결 방법</strong></h3>
<pre><code class="language-java">@Getter
@NoArgsConstructor
public class TokenRefreshRequest {

    @JsonProperty(&quot;refreshToken&quot;)
    private String refreshToken;
}</code></pre>
<ul>
<li><code>@JsonProperty</code>를 명시하여 JSON 필드와 정확하게 매핑되도록 수정함.</li>
</ul>
<hr>
<h2 id="3-redis-키-비교-오류"><strong>3. Redis 키 비교 오류</strong></h2>
<h3 id="1-문제-상황-2"><strong>1. 문제 상황</strong></h3>
<ul>
<li>다음과 같은 예외 발생:</li>
</ul>
<pre><code class="language-text">java.lang.IllegalArgumentException: 서버에 저장된 RefreshToken 과 다릅니다.</code></pre>
<h3 id="2-원인-분석-2"><strong>2. 원인 분석</strong></h3>
<pre><code class="language-java">String storedToken = redisTemplate.opsForValue().get(&quot;RT: &quot; + userId);</code></pre>
<ul>
<li>Redis에는 <code>&quot;RT:1&quot;</code>로 저장되어 있었고, 코드에는 <code>&quot;RT: 1&quot;</code>처럼 <strong>공백이 포함</strong>된 상태로 조회하고 있었음.</li>
</ul>
<h3 id="3-해결-방법-2"><strong>3. 해결 방법</strong></h3>
<pre><code class="language-java">redisTemplate.opsForValue().get(&quot;RT:&quot; + userId);</code></pre>
<ul>
<li>공백 제거하여 일치하도록 수정</li>
</ul>
<hr>
<h2 id="오늘의-결론"><strong>오늘의 결론</strong></h2>
<ol>
<li>Redis 직렬화 설정은 기본이지만 놓치기 쉬운 핵심 포인트다.</li>
<li><code>@JsonProperty</code>는 DTO 매핑에서 중요한 역할을 하며, 필드명이 일치해도 명시해주는 게 안전하다.</li>
<li>Redis 키 문자열 비교는 공백 여부까지도 정확하게 신경 써야 한다.</li>
</ol>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[번외편] - DTO 하나로 처리하는 CLI 기반 CRUD 실습]]></title>
            <link>https://velog.io/@giwon_wg/%EB%B2%88%EC%99%B8%ED%8E%B8-DTO-%ED%95%98%EB%82%98%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-CLI-%EA%B8%B0%EB%B0%98-CRUD-%EC%8B%A4%EC%8A%B5</link>
            <guid>https://velog.io/@giwon_wg/%EB%B2%88%EC%99%B8%ED%8E%B8-DTO-%ED%95%98%EB%82%98%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-CLI-%EA%B8%B0%EB%B0%98-CRUD-%EC%8B%A4%EC%8A%B5</guid>
            <pubDate>Mon, 21 Apr 2025 13:04:10 GMT</pubDate>
            <description><![CDATA[<blockquote>
<h2 id="java를-spring처럼---번외편dto-하나로-처리하는-cli-기반-crud-실습">Java를 Spring처럼 - [번외편]DTO 하나로 처리하는 CLI 기반 CRUD 실습</h2>
</blockquote>
<p>이번 편에서는 우리가 CLI 환경에서 구현한 CRUD 흐름을 스프링스럽게 리팩토링해보면서,
<strong>URI 기반 요청 처리</strong>와 <strong>DTO 통합 설계</strong>를 직접 해보았습니다.<br>특히 <code>PostRequest</code>라는 <strong>하나의 DTO로 Create와 Update 요청을 동시에 처리</strong>하는 전략을 적용해봤어요.
저는 귀찮아서 하나로 작성했지만 <strong>Create</strong> 와 <strong>Update</strong>를 나누는게 더 재미 있겠죠?</p>
<hr>
<h2 id="직관적인-crud-설계-step-1---entity">직관적인 CRUD 설계 Step. 1 - Entity</h2>
<p>Entity 부터 설계 해 볼까요??</p>
<pre><code class="language-java">package Entity;

/**
 *  Entity 구현 부 Class명은 Post!
 */
public class Post {
    private Long id;
    private String title;
    private String content;
    private String author;

    public Post() {
    }

    public Post(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    // Getter 와 Setter
    public Long getId() {
        return id;
    }
    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }
    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }
    public void setContent(String content) {
        this.content = content;
    }

    public String getAuthor() {
        return author;
    }
    public void setAuthor(String author) {
        this.author = author;
    }
}</code></pre>
<p>뭐가 이렇게 기냐고요?? 왜냐면 저희는 뉴비이기 때문에 Lombok을 안쓰고
한땀한땀 Getter / Setter를 작성했어요!</p>
<hr>
<h2 id="직관적인-crud-설계-step-2---repository">직관적인 CRUD 설계 Step. 2 - Repository</h2>
<h4 id="repository-인터페이스">Repository 인터페이스</h4>
<pre><code class="language-java">package Repository;

import java.util.List;
import java.util.Optional;

import Entity.Post;

public interface PostRepository {
    //C
    Post save(Post post);

    //R
    Optional&lt;Post&gt; findById(Long id);
    List&lt;Post&gt; findAll();

    //U - id 기반 검색
    void update(Long id, Post post);

    //D - id 기반 검색
    void delete(Long id);
}</code></pre>
<h4 id="repository-구현체">Repository 구현체</h4>
<pre><code class="language-java">package Repository;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import Entity.Post;

public class PostRepositoryImpl implements PostRepository {

    private final Map&lt;Long, Post&gt; store = new HashMap&lt;&gt;();
    private Long sequence = 0L;

    @Override
    public Post save(Post post) {
        // 글이 작성될때 id에 +1
        post.setId(++sequence);
        store.put(post.getId(), post);
        return post;
    }

    @Override
    public Optional&lt;Post&gt; findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List&lt;Post&gt; findAll() {
        return new ArrayList&lt;&gt;(store.values());
    }

    @Override
    public void update(Long id, Post updatePost) {
        Post post = store.get(id);
        if(post != null) {
            post.setTitle(updatePost.getTitle());
            post.setContent(updatePost.getContent());
        }
    }

    @Override
    public void delete(Long id) {
        store.remove(id);
    }
}</code></pre>
<p><code>Repository</code>는 인터페이스와 구현체로 나뉩니다!
선언은 인터페이스, 구현은 구현체</p>
<hr>
<h2 id="직관적인-crud-설계-step-3---service">직관적인 CRUD 설계 Step. 3 - Service</h2>
<h4 id="service비즈니스-로직">Service(비즈니스 로직)</h4>
<pre><code class="language-java">public class PostService {
    private final PostRepository repository = new PostRepository();

    public Post createPost(String title, String content, String author) {
        Post post = new Post();
        post.setTitle(title);
        post.setContent(content);
        post.setAuthor(author);
        return repository.save(post);
    }

    public List&lt;Post&gt; getAllPosts() {
        return repository.findAll();
    }

    public Optional&lt;Post&gt; getPostById(Long id) {
        return repository.findById(id);
    }

    public void updatePost(Long id, String newTitle, String newContent) {
        Post update = new Post();
        update.setTitle(newTitle);
        update.setContent(newContent);
        repository.update(id, update);
    }

    public void deletePost(Long id) {
        repository.delete(id);
    }
}</code></pre>
<hr>
<h2 id="직관적인-crud-설계-step-4---controller">직관적인 CRUD 설계 Step. 4 - Controller</h2>
<pre><code class="language-java">package controller;

import java.util.List;
import java.util.Optional;

import Entity.Post;
import Service.PostService;
import dto.PostRequestDto;

public class PostController {
    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    public Post creat(PostRequestDto requestDto) {
        return postService.createPost(requestDto.getTitle(), requestDto.getContent(), requestDto.getAuthor());
    }

    public List&lt;Post&gt; getAll() {
        return postService.getAllPosts();
    }

    public Optional&lt;Post&gt; getPostById(Long id) {
        return postService.getPostById(id);
    }

    public void update(Long id, PostRequestDto requestDto) {
        postService.updatePost(id, requestDto.getTitle(), requestDto.getContent());
    }

    public void delete(Long id) {
        postService.deletePost(id);
    }
}</code></pre>
<hr>
<h2 id="직관적인-crud-설계-step-5---main">직관적인 CRUD 설계 Step. 5 - main</h2>
<pre><code class="language-java">import java.util.Scanner;

import Entity.Post;
import Repository.PostRepository;
import Repository.PostRepositoryImpl;
import Service.PostService;
import controller.PostController;
import dto.PostRequestDto;

public class PostClient {
    /**
     * 제목 내용 저자 스캐너 활용
     * URI와 유사한 기능 적용
     * @param args
     */
    public static void main(String[] args) {

        //의존성 주입
        PostRepository postRepository = new PostRepositoryImpl();
        PostService postService = new PostService(postRepository);
        PostController postController = new PostController(postService);
        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println(&quot;\nURI &gt; :&quot;);
            String URI = scanner.nextLine().trim();

            switch (URI) {
                case &quot;/posts/create&quot;:
                    String title = scanner.nextLine();
                    String content = scanner.nextLine();
                    String author = scanner.nextLine();

                    PostRequestDto requestDto = new PostRequestDto(title, content, author);
                    Post post = postController.creat(requestDto);
                    break;

                case &quot;/posts&quot;:
                    postController.getAll().forEach(p -&gt; System.out.println(&quot;Id: &quot; + p.getId() + &quot;, Title: &quot; + p.getTitle() + &quot;, content: &quot; + p.getContent() + &quot;, author: &quot; + p.getAuthor()));
                    break;

                case &quot;exit&quot;:
                    return;

                default:
                    //동적 매칭 구현
                    if (URI.matches(&quot;/posts/\\d+&quot;)) {
                        Long id = Long.parseLong(URI.split(&quot;/&quot;)[2]);
                        postController.getPostById(id).ifPresent(p -&gt; System.out.println(&quot;Id: &quot; + p.getId() + &quot;, Title: &quot; + p.getTitle() + &quot;, content: &quot; + p.getContent() + &quot;, author: &quot; + p.getAuthor()));
                    } else if (URI.matches(&quot;/posts/\\d+/update&quot;)) {
                        Long id = Long.parseLong(URI.split(&quot;/&quot;)[2]);
                        String newTitle = scanner.nextLine();
                        String newContent = scanner.nextLine();
                        PostRequestDto updateRequestDto = new PostRequestDto(newTitle, newContent, null);
                        postController.update(id, updateRequestDto);
                    } else if (URI.matches(&quot;/posts/\\d+/delete&quot;)) {
                        Long id = Long.parseLong(URI.split(&quot;/&quot;)[2]);
                        postController.delete(id);
                    } else {
                        System.out.println(&quot;URI 확인 필요&quot;);
                    }
            }
        }
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Interceptor + AOP ]]></title>
            <link>https://velog.io/@giwon_wg/Interceptor-AOP</link>
            <guid>https://velog.io/@giwon_wg/Interceptor-AOP</guid>
            <pubDate>Sun, 20 Apr 2025 13:40:51 GMT</pubDate>
            <description><![CDATA[<h1 id="긴급코딩---interceptor--aop">긴급코딩! - Interceptor + AOP</h1>
<h2 id="💡-우리의-목표는">💡 우리의 목표는?</h2>
<blockquote>
<h3 id="admin-api-접근-시-인터셉터에서-인증-aop에서-로깅-처리">Admin API 접근 시 인터셉터에서 인증, AOP에서 로깅 처리</h3>
</blockquote>
<h3 id="목표-시나리오">목표 시나리오</h3>
<ul>
<li><code>/admin/**</code> 경로에 접근 할때:<ol>
<li>인터셉터가 JWT 토큰을 검사하고, <code>ADMIN</code> 관한이 아니면 거부</li>
<li>AOP가 요청 및 응답 내용을 로깅</li>
</ol>
</li>
</ul>
<h3 id="step-1-interceptor---인증-처리">Step 1. Interceptor - 인증 처리</h3>
<h4 id="1-admininterceptorjava">1. AdminInterceptor.java</h4>
<pre><code class="language-java">
@Component
public class AdminInterceptor implements HandlerInterceptor {


    private final JwtProvider jwtProvider;

    // 생성자 주입 방식으로 JwtProvider 의존성 주입!
    public AdminInterceptor(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider; 
    }

    // 컨트롤러 실행 전 요청을 가로채는 메서드(인터셉트!)
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws Exception {

        // 요청에서 토큰 추출
        String token = jwtProvider.resolveToken(request);

        // 토큰이 없거나 유효하지 않으면 401 Unauthorized 응답
        if (token == null || !jwtProvider.validateToken(token)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value()); // 인증 실패
            return false; // 컨트롤러로 진입하지 않음
        }

        // 토큰에서 역할 정보 추출
        String role = jwtProvider.getRole(token);

        // 역할이 ADMIN이 아니면 403 Forbidden
        if (!&quot;ADMIN&quot;.equals(role)) {
            response.setStatus(HttpStatus.FORBIDDEN.value()); // 인가 실패
            return false;
        }

        // 모든 조건을 통과하면 true 반환 → 컨트롤러 실행 허용
        return true;
    }
}</code></pre>
<h4 id="2-webconfigjava">2. WebConfig.java</h4>
<pre><code class="language-java">@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final AdminInterceptor adminInterceptor;

    public WebConfig(AdminInterceptor adminInterceptor) {
        this.adminInterceptor = adminInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // &quot;/admin/**&quot; 경로에 대해서만 adminInterceptor를 적용
        registry.addInterceptor(adminInterceptor)
                .addPathPatterns(&quot;/admin/**&quot;);
    }
}</code></pre>
<br>

<h3 id="step-2-aop---요청응답-로깅">Step 2. AOP - 요청/응답 로깅</h3>
<h4 id="1-adminloggingaspectjava">1. AdminLoggingAspect.java</h4>
<pre><code class="language-java">@Aspect // AOP 클래스임을 나타냄
@Component
@Slf4j // 로그 출력용 롬복 어노테이션 (log.info 사용 가능)
public class AdminLoggingAspect {

    // Pointcut 설정: admin 하위 패키지의 모든 메서드에 적용
    @Pointcut(&quot;execution(* com.example.controller.admin..*(..))&quot;)
    public void adminMethods() {}

    // 어드민 메서드 실행 전후로 로그를 남김
    @Around(&quot;adminMethods()&quot;)
    public Object logAdminRequest(ProceedingJoinPoint joinPoint) throws Throwable {

        // 현재 요청 객체 가져오기 (RequestContextHolder는 AOP 내에서 HttpServletRequest 접근 가능하게 함)
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        // URI 정보 및 요청자 ID 추출
        String uri = request.getRequestURI();
        String userId = request.getHeader(&quot;userId&quot;); // 예: 헤더에서 userId 가져오기

        // 요청 로그 출력 (args는 메서드 파라미터 배열)
        log.info(&quot;어드민 요청 - userId: {}, URI: {}, request: {}&quot;, userId, uri, Arrays.toString(joinPoint.getArgs()));

        // 원래의 컨트롤러 메서드 실행
        Object result = joinPoint.proceed();

        // 응답 로그 출력
        log.info(&quot;어드민 응답 - userId: {}, URI: {}, response: {}&quot;, userId, uri, result);

        return result;
    }
}
</code></pre>
<br>

<h3 id="step-3-테스트용-admin-controller">Step 3. 테스트용 Admin Controller</h3>
<h4 id="1-useradmincontrollerjava">1. UserAdminController.java</h4>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/admin/users&quot;)
public class UserAdminController {

    // 어드민 권한이 있어야만 접근 가능한 API
    @PatchMapping(&quot;/{userId}&quot;)
    public ResponseEntity&lt;String&gt; changeUserRole(@PathVariable Long userId, @RequestBody RoleUpdateRequest request) {

        return ResponseEntity.ok(&quot;변경 완료&quot;);
    }
}</code></pre>
<hr>
<h3 id="마무리">마무리</h3>
<pre><code class="language-scss">[요청] → Interceptor (인증 확인)
               ↓
     통과하면 AOP 진입 (로깅)
               ↓
     컨트롤러 실행 (ex. changeUserRole)
               ↓
     AOP 종료 로그
               ↓
[응답 반환]
</code></pre>
<table>
<thead>
<tr>
<th align="left">기능</th>
<th align="left">구현 위치</th>
<th align="left">역할</th>
</tr>
</thead>
<tbody><tr>
<td align="left">권한 검사</td>
<td align="left">Interceptor</td>
<td align="left">인증 (사전 처리)</td>
</tr>
<tr>
<td align="left">로그 기록</td>
<td align="left">AOP</td>
<td align="left">로깅 (사후 처리 포함)</td>
</tr>
<tr>
<td align="left">관리자 API 예시</td>
<td align="left">UserAdminController</td>
<td align="left">테스트용 컨트롤러</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[의존성 주입(DI)과 IoC 컨테이너의 모방]]></title>
            <link>https://velog.io/@giwon_wg/%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85DI%EA%B3%BC-IoC-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%9D%98-%EB%AA%A8%EB%B0%A9</link>
            <guid>https://velog.io/@giwon_wg/%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85DI%EA%B3%BC-IoC-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88%EC%9D%98-%EB%AA%A8%EB%B0%A9</guid>
            <pubDate>Fri, 18 Apr 2025 09:58:23 GMT</pubDate>
            <description><![CDATA[<h1 id="java를-spring-처럼---의존성-주입di과-ioc-컨테이너-흉내내기">Java를 Spring 처럼! - 의존성 주입(DI)과 IoC 컨테이너 흉내내기</h1>
<hr>
<h2 id="1-도입---객체는-누가-만들어야-할까">1. 도입 - &quot;객체는 누가 만들어야 할까?&quot;</h2>
<p>2편에서는 <code>인터페이스</code>를 통해 다양한 구현체를 갈아끼우며 <strong>유연한 구조</strong>를 만들었죠.
그런데 이런 질문이 생깁니다.</p>
<blockquote>
<p><code>UserService userService = new UserService(new EmailService());</code><br>→ <strong>이걸 누가 만들고 주입할까?</strong></p>
</blockquote>
<p>하드코딩 대신, 객체 생성을 <strong>한 곳에서 관리</strong>할 수는 없을까요?</p>
<hr>
<h2 id="2-의존성-주입dependency-injection의-개념">2. 의존성 주입(Dependency Injection)의 개념</h2>
<p>의존성 주입은 말 그대로 <strong>필요한 객체를 외부에서 주입해주는 방식</strong>입니다.</p>
<h3 id="생성자-주입">생성자 주입</h3>
<pre><code class="language-java">public class UserService {
    private final NotificationService notificationService;

    // 생성자 주입
    public UserService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}</code></pre>
<h3 id="필드-주입">필드 주입</h3>
<pre><code class="language-java">public class UserService {
    @Autowired
    private NotificationService notificationService;
}</code></pre>
<h3 id="세터-주입">세터 주입</h3>
<pre><code class="language-java">public class UserService {
    private NotificationService notificationService;

    @Autowired
    public void setNotificationService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
}</code></pre>
<blockquote>
<p>생성자 주입은 불변성과 테스트 편의성 측면에서 가장 권장됩니다.</p>
</blockquote>
<hr>
<h2 id="3-ioc란-무엇인가">3. IoC란 무엇인가?</h2>
<h3 id="ioc-inversion-of-control--제어의-역전">IoC (Inversion of Control) = 제어의 역전</h3>
<ul>
<li>기존: 객체를 <strong>직접 생성</strong>하고 사용하는 구조</li>
<li>IoC: 객체 생성/관리 주체를 <strong>개발자가 아닌 외부</strong>로 전환</li>
</ul>
<h3 id="코드-비교">코드 비교</h3>
<h4 id="ioc-적용-전">IoC 적용 전</h4>
<pre><code class="language-java">UserService userService = new UserService(new EmailNotificationService());</code></pre>
<h4 id="ioc-적용-후">IoC 적용 후</h4>
<pre><code class="language-java">AppConfig config = new AppConfig();
UserService userService = config.userService();</code></pre>
<p>객체 생성을 외부에 맡기면서, 구현체를 바꾸고 테스트하기 쉬워집니다.</p>
<hr>
<h2 id="4-자바로-ioc-컨테이너-흉내내기">4. 자바로 IoC 컨테이너 흉내내기</h2>
<h3 id="직접-객체를-조립하는-설정-클래스-만들기">직접 객체를 조립하는 설정 클래스 만들기</h3>
<pre><code class="language-java">public class AppConfig {

    public UserService userService() {
        return new UserService(notificationService());
    }

    public NotificationService notificationService() {
        return new EmailNotificationService();
    }
}</code></pre>
<h3 id="사용-예">사용 예</h3>
<pre><code class="language-java">public class Main {
    public static void main(String[] args) {
        AppConfig config = new AppConfig();
        UserService userService = config.userService();
        userService.process();
    }
}</code></pre>
<p>이 구조 자체가 <strong>스프링의 IoC 컨테이너를 흉내낸 것</strong>입니다!  
<code>AppConfig</code>가 모든 객체를 만들고 연결해주죠.</p>
<blockquote>
<p>📌 예를 들어 테스트 환경에서 <code>FakeNotificationService</code>를 만들어 <code>AppConfig</code>에서 주입하면 쉽게 테스트가 가능합니다.<br>또는 Slack 연동 구현체를 교체하더라도 <code>AppConfig</code>의 return 값만 바꾸면 UserService 코드는 수정할 필요가 없습니다.</p>
</blockquote>
<hr>
<h2 id="5-di-방식-비교">5. DI 방식 비교</h2>
<table>
<thead>
<tr>
<th>주입 방식</th>
<th>특징</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>생성자 주입</td>
<td>가장 명확하고 불변성 보장</td>
<td>순환 참조 시 주의 필요</td>
</tr>
<tr>
<td>필드 주입</td>
<td>코드 간결</td>
<td>테스트 어려움, final 사용 불가</td>
</tr>
<tr>
<td>세터 주입</td>
<td>선택적 의존성 가능</td>
<td>객체 불완전 상태로 존재 가능</td>
</tr>
</tbody></table>
<blockquote>
<p>스프링은 생성자 주입을 가장 권장합니다.</p>
</blockquote>
<hr>
<h2 id="6-왜-이렇게까지-해야-하나요">6. 왜 이렇게까지 해야 하나요?</h2>
<p>다음과 같은 실무 경험이 있습니다:</p>
<blockquote>
<p>실무 프로젝트에서 처음에는 이메일만 지원하던 알림 서비스에 카카오 알림톡과 슬랙 연동이 추가되었습니다.<br>기존 코드가 new로 직접 구현체를 생성하고 있어 확장 시 많은 수정이 필요했고, 테스트 코드도 함께 깨졌습니다.<br>이후 DI 구조로 바꾼 뒤에는 <code>NotificationService</code> 인터페이스 기반으로 주입만 바꿔서 Slack, Kakao 구현체를 적용할 수 있었고,<br>테스트도 <code>FakeNotificationService</code>를 주입하는 방식으로 간단하게 처리할 수 있었습니다.</p>
</blockquote>
<blockquote>
<p>DI는 단순한 설계 원칙이 아니라, 협업과 유지보수에 큰 차이를 만들어냅니다.</p>
</blockquote>
<hr>
<h2 id="7-스프링에서는-어떻게-처리할까">7. 스프링에서는 어떻게 처리할까?</h2>
<pre><code class="language-java">@Configuration
public class AppConfig {

    @Bean
    public NotificationService notificationService() {
        return new EmailNotificationService();
    }

    @Bean
    public UserService userService() {
        return new UserService(notificationService());
    }
}</code></pre>
<p>그리고 실행 시 아래처럼 <code>ApplicationContext</code>로부터 객체를 받아옵니다:</p>
<pre><code class="language-java">ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = context.getBean(UserService.class);</code></pre>
<p>스프링이 객체를 생성하고 의존성을 주입해줍니다. 우리는 그저 &quot;필요한 걸 꺼내 쓰면&quot; 됩니다.</p>
<hr>
<h2 id="8-실무에서는-어떻게-사용되나">8. 실무에서는 어떻게 사용되나?</h2>
<pre><code class="language-java">@Service
public class EmailNotificationService implements NotificationService { ... }

@RequiredArgsConstructor
@RestController
public class UserController {

    private final NotificationService notificationService;

    @PostMapping(&quot;/notify&quot;)
    public void send() {
        notificationService.send(&quot;API 호출됨&quot;);
    }
}</code></pre>
<ul>
<li><code>@Component</code>, <code>@Service</code>, <code>@Repository</code> 등으로 등록된 빈은</li>
<li><code>@Autowired</code> 또는 <code>@RequiredArgsConstructor</code>를 통해 주입됩니다.</li>
</ul>
<p>또한 실무에서는 다음과 같은 기술도 자주 사용됩니다:</p>
<ul>
<li><code>@Qualifier(&quot;emailService&quot;)</code> → 여러 구현체 중 특정 이름을 지정</li>
<li><code>@Primary</code> → 기본 구현체 지정</li>
<li><code>@Profile(&quot;test&quot;)</code> → 환경에 따라 다른 빈 등록</li>
</ul>
<p>이러한 방법들은 인터페이스 기반 설계를 더욱 유연하게 만들어줍니다.</p>
<hr>
<h2 id="9-오늘의-정리">9. 오늘의 정리</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>DI</td>
<td>객체를 외부에서 주입하는 방식</td>
</tr>
<tr>
<td>IoC</td>
<td>객체 제어권이 프레임워크(컨테이너)로 넘어감</td>
</tr>
<tr>
<td>AppConfig</td>
<td>객체를 생성하고 연결하는 설정 역할</td>
</tr>
<tr>
<td>스프링 DI</td>
<td><code>@Configuration</code> + <code>@Bean</code> + <code>@Autowired</code>로 구현</td>
</tr>
<tr>
<td>이점</td>
<td>유연성, 확장성, 테스트 편의성, 스프링과 찰떡</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL]2025.04.18]]></title>
            <link>https://velog.io/@giwon_wg/TIL2025.04.18</link>
            <guid>https://velog.io/@giwon_wg/TIL2025.04.18</guid>
            <pubDate>Fri, 18 Apr 2025 09:07:45 GMT</pubDate>
            <description><![CDATA[<h3 id="til---20250418"><strong>TIL - 2025.04.18</strong></h3>
<h2 id="오늘-배운-것-개요"><strong>오늘 배운 것 개요</strong></h2>
<ul>
<li><strong>내용</strong>: AOP 기반 어드민 API 접근 로깅 구현 방법 학습</li>
<li><strong>내용</strong>: <code>@Around</code> 어노테이션을 활용한 요청/응답 시점 로그 처리</li>
<li><strong>내용</strong>: <code>joinPoint.getArgs()</code>를 활용한 RequestBody 추출과 로깅 방법 확인</li>
</ul>
<hr>
<h2 id="1-aop-기반-어드민-접근-로깅"><strong>1. AOP 기반 어드민 접근 로깅</strong></h2>
<h3 id="1-adminlogging-어노테이션"><strong>1. <code>@AdminLogging</code> 어노테이션</strong></h3>
<ul>
<li>어드민 API 접근 시 AOP 적용 대상을 명시하기 위해 사용</li>
<li><code>@Retention(RetentionPolicy.RUNTIME)</code>, <code>@Target(ElementType.METHOD)</code>으로 설정</li>
</ul>
<h3 id="2-adminlogginaspect-클래스-구조"><strong>2. <code>AdminLogginAspect</code> 클래스 구조</strong></h3>
<ul>
<li><code>@Aspect</code>, <code>@Component</code>로 정의</li>
<li><code>@Around(&quot;@annotation(...AdminLogging)&quot;)</code>을 통해 AOP 진입</li>
<li><code>HttpServletRequest</code>에서 사용자 정보(userId)와 요청 URI 추출</li>
<li><code>joinPoint.getArgs()</code>를 통해 요청 파라미터(JSON 직렬화) 추출</li>
<li><code>joinPoint.proceed()</code> 이후 응답 또는 예외를 처리</li>
</ul>
<h4 id="코드-개선"><strong>코드 개선</strong></h4>
<pre><code class="language-java">@Around(&quot;@annotation(org.example.expert.logging.AdminLogging)&quot;)
public Object logAdminAccess(ProceedingJoinPoint joinPoint) throws Throwable {
    Long userId = (Long) request.getAttribute(&quot;userId&quot;);
    String uri = request.getRequestURI();
    LocalDateTime time = LocalDateTime.now();
    String requestBody = objectMapper.writeValueAsString(joinPoint.getArgs());

    log.info(&quot;어드민 요청 - userId: {}, URI: {}, time: {}, request: {}&quot;, userId, uri, time, requestBody);

    Object result = null;
    try {
        result = joinPoint.proceed();
        return result;
    } catch (Throwable e) {
        log.warn(&quot;어드민 응답 실패 - userId: {}, URI: {}, error: {}&quot;, userId, uri, e.getMessage());
        throw e;
    } finally {
        if (result != null) {
            String responseJson = objectMapper.writeValueAsString(result);
            log.info(&quot;어드민 응답 - userId: {}, URI: {}, response: {}&quot;, userId, uri, responseJson);
        } else {
            log.info(&quot;어드민 응답 - userId: {}, URI: {}, response: 예외 발생 또는 반환값 없음&quot;);
        }
    }
}</code></pre>
<p><strong>개선된 점</strong></p>
<ul>
<li><strong>요청 파라미터</strong>를 통째로 JSON 직렬화하여 request에 기록</li>
<li><strong>응답 또는 예외 발생 여부에 따라 finally에서 로깅 분기 처리</strong></li>
<li><strong>테스트에서도 요청과 응답 로그를 CapturedOutput으로 검증 가능</strong></li>
</ul>
<hr>
<h2 id="오늘의-결론"><strong>오늘의 결론</strong></h2>
<ol>
<li><code>@Around</code>는 메서드 실행 전후를 포괄적으로 제어할 수 있는 강력한 도구</li>
<li><code>joinPoint.getArgs()</code>는 RequestBody와 PathVariable 모두 확인 가능</li>
<li>예외가 발생해도 finally 블록을 활용해 응답 로그를 일관되게 남길 수 있다</li>
</ol>
]]></description>
        </item>
    </channel>
</rss>