<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>p-q</title>
        <link>https://velog.io/</link>
        <description>ppppqqqq</description>
        <lastBuildDate>Thu, 16 Apr 2026 04:57:12 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>p-q</title>
            <url>https://velog.velcdn.com/images/co-vol/profile/b456bd0c-cfa1-4354-9b51-8ac2e7a67742/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. p-q. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/co-vol" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Redis Lua로 선착순 쿠폰 발급 만들기]]></title>
            <link>https://velog.io/@co-vol/Redis-Lua%EB%A1%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Redis-Lua%EB%A1%9C-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%BF%A0%ED%8F%B0-%EB%B0%9C%EA%B8%89-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Thu, 16 Apr 2026 04:57:12 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-db-락이-안-되는가">왜 DB 락이 안 되는가</h2>
<p>가장 직관적인 구현은 이렇다:</p>
<pre><code class="language-sql">SELECT remaining FROM event WHERE id = ? FOR UPDATE;
-- remaining &gt; 0이면 발급
UPDATE event SET remaining = remaining - 1 WHERE id = ?;
INSERT INTO coupon_issue (event_id, user_id) VALUES (?, ?);</code></pre>
<p>문제는 <code>FOR UPDATE</code>가 행 단위 락을 건다는 것. 1,000명이 동시에 쿠폰을 요청하면 999명이 한 줄로 대기한다. HikariCP 기본 풀이 10개니까, 10번째 요청부터는 커넥션 대기 큐에 쌓인다. 요청이 많아지면 타임아웃, 데드락, 커넥션 풀 고갈이 순서대로 찾아온다.</p>
<p>p99 응답시간이 500ms를 넘기기 시작하면 사실상 서비스 장애다.</p>
<h2 id="redis-명령-하나하나는-원자적이다-그런데-조합은-아니다">Redis 명령 하나하나는 원자적이다. 그런데 조합은 아니다</h2>
<p>&quot;그러면 Redis DECR로 재고 차감하면 되지 않나?&quot; 맞다, 절반만.</p>
<p>Redis의 개별 명령(GET, DECR, SADD)은 싱글 스레드라서 각각은 원자적이다. 하지만 이걸 순차적으로 호출하면:</p>
<pre><code>Thread A: GET stock → 1 (재고 있음)
Thread B: GET stock → 1 (재고 있음)
Thread A: DECR stock → 0
Thread B: DECR stock → -1  ← 초과 발급</code></pre><p>GET과 DECR 사이에 다른 스레드가 끼어들 수 있다. 이게 &quot;개별 명령은 원자적이지만 조합은 아니다&quot;의 핵심이다.</p>
<h2 id="lua-스크립트-3개-명령을-1개-원자-블록으로">Lua 스크립트: 3개 명령을 1개 원자 블록으로</h2>
<p>Redis는 Lua 스크립트를 실행할 때 다른 명령을 끼워넣지 않는다. 서버 사이드에서 단일 블록으로 실행된다. 이걸 이용하면 GET + DECR + SADD를 하나의 원자적 연산으로 묶을 수 있다.</p>
<pre><code class="language-lua">-- issue_coupon.lua
-- KEYS[1] = coupon:stock:{eventId}
-- KEYS[2] = coupon:issued:{eventId}
-- ARGV[1] = userId
-- ARGV[2] = ttlSeconds

-- 1) 재고 확인 — 매진이 전체 트래픽의 99%+ → 읽기 1회로 즉시 탈출
local stock = tonumber(redis.call(&#39;GET&#39;, KEYS[1]))
if stock == nil or stock &lt;= 0 then
    return 0   -- 매진
end

-- 2) 중복 확인 — 읽기 1회 추가
if redis.call(&#39;SISMEMBER&#39;, KEYS[2], ARGV[1]) == 1 then
    return -1  -- 이미 발급됨
end

-- 3) 성공 경로에서만 쓰기 발생
redis.call(&#39;DECR&#39;, KEYS[1])
redis.call(&#39;SADD&#39;, KEYS[2], ARGV[1])

-- 4) issued Set에 TTL 없으면 설정(최초 발급 시점 기준)
if redis.call(&#39;TTL&#39;, KEYS[2]) == -1 then
    redis.call(&#39;EXPIRE&#39;, KEYS[2], ARGV[2])
end

return 1       -- 발급 성공</code></pre>
<p>27줄. 이 안에 선착순 발급의 핵심 로직이 다 들어있다.</p>
<h3 id="check-then-act-매진-경로를-먼저-최적화하는-이유">Check-then-Act: 매진 경로를 먼저 최적화하는 이유</h3>
<p>선착순 이벤트의 트래픽 분포를 생각해보자. 쿠폰 1,000개에 요청 10,000건이면, 성공하는 건 1,000건(10%)이고 매진으로 튕기는 건 9,000건(90%)이다. 실제 운영에서는 이 비율이 99% 이상이다.</p>
<p>그래서 Lua 스크립트의 첫 번째 동작이 <code>GET stock</code>이다. 매진이면 읽기 1회로 바로 리턴하고, DECR이나 SADD 같은 쓰기 연산을 아예 안 한다. 90%+ 트래픽이 읽기 한 번만 하고 빠지는 구조다.</p>
<h2 id="서비스-레이어-transactional을-어디에-걸-것인가">서비스 레이어: @Transactional을 어디에 걸 것인가</h2>
<p>Lua 스크립트가 성공하면 DB에 발급 이력을 저장해야 한다. 여기서 <code>@Transactional</code> 배치가 중요하다.</p>
<p>처음에는 CouponIssueService 전체에 <code>@Transactional</code>을 걸었다. 코드 리뷰에서 이 문제가 잡혔다:</p>
<pre><code>CouponIssueService.issue() {
    @Transactional  ← DB 커넥션 획득
    eventRepository.findById()        // DB 읽기
    redisStockRepository.initStock()  // ← Redis 호출인데 DB 커넥션 점유 중
    redisStockRepository.tryIssue()   // ← Redis 호출인데 DB 커넥션 점유 중
    couponIssueRepository.save()      // DB 쓰기
}                                     // ← 여기서 커넥션 반환</code></pre><p>Redis 호출하는 동안에도 DB 커넥션을 붙잡고 있다. HikariCP 기본 풀이 10개인데, 200개 스레드가 동시에 들어오면 190개가 커넥션 대기에 걸린다. DB 락을 피하려고 Redis를 도입했는데 결국 커넥션 풀에서 병목이 생기는 거다.</p>
<p>해결책은 트랜잭션 범위를 쪼개는 것이다:</p>
<pre><code class="language-kotlin">// CouponIssueService — @Transactional 없음
@Service
class CouponIssueService(
    private val eventRepository: EventRepository,
    private val redisStockRepository: RedisStockRepository,
    private val couponIssueTxService: CouponIssueTxService,
) {
    fun issue(eventId: UUID, userId: UUID): CouponIssueResponse {
        val event = eventRepository.findByIdOrNull(eventId)
            ?: throw BusinessException(ErrorCode.EVENT_NOT_FOUND)

        // 기간 검증
        if (!event.period.contains(Instant.now())) {
            throw BusinessException(ErrorCode.EVENT_NOT_OPEN)
        }

        // Redis 호출 — DB 커넥션 미점유
        val ttlSeconds = Duration.between(Instant.now(), event.period.endedAt)
            .plusHours(1).toSeconds()
        redisStockRepository.initStockIfAbsent(eventId, event.totalQuantity, ttlSeconds)

        val result = redisStockRepository.tryIssueCoupon(eventId, userId, ttlSeconds)
        when (result) {
            IssueResult.ALREADY_ISSUED -&gt; throw BusinessException(ErrorCode.COUPON_ALREADY_ISSUED)
            IssueResult.SOLD_OUT -&gt; throw BusinessException(ErrorCode.EVENT_SOLD_OUT)
            IssueResult.SUCCESS -&gt; Unit
        }

        // DB 저장만 트랜잭션 — 커넥션 점유 최소화
        val couponIssue = couponIssueTxService.saveOrCompensate(eventId, userId)
        return CouponIssueResponse.from(couponIssue)
    }
}</code></pre>
<pre><code class="language-kotlin">// CouponIssueTxService — DB 저장 구간만 @Transactional
@Service
class CouponIssueTxService(
    private val couponIssueRepository: CouponIssueRepository,
    private val redisStockRepository: RedisStockRepository,
) {
    @Transactional
    fun saveOrCompensate(eventId: UUID, userId: UUID): CouponIssue {
        try {
            return couponIssueRepository.saveAndFlush(
                CouponIssue(eventId = eventId, userId = userId),
            )
        } catch (e: DataIntegrityViolationException) {
            // UK 위반: Redis는 정확, DB에 이미 존재 → stock만 복원
            redisStockRepository.restoreStock(eventId)
            throw BusinessException(ErrorCode.COUPON_ALREADY_ISSUED)
        } catch (e: DataAccessException) {
            // 기타 DB 오류: Redis 상태를 완전 롤백
            redisStockRepository.compensate(eventId, userId)
            throw e
        }
    }
}</code></pre>
<p>Redis 호출 구간에서는 DB 커넥션을 잡지 않고, <code>saveOrCompensate</code> 안에서만 짧게 잡았다가 놓는다.</p>
<h2 id="보상-처리-redis는-성공했는데-db가-실패하면">보상 처리: Redis는 성공했는데 DB가 실패하면?</h2>
<p>Lua 스크립트가 성공(DECR + SADD)한 뒤 DB INSERT가 실패하는 경우가 있다. 이때 Redis 상태를 되돌려야 정합성이 유지된다.</p>
<p>여기서 중요한 건 <strong>실패 유형에 따라 보상 전략이 다르다</strong>는 점이다:</p>
<p><strong>UK 위반 (DataIntegrityViolationException)</strong>:
Lua의 SISMEMBER를 통과했지만 극히 드문 타이밍에 DB에 먼저 기록된 경우. 이때 issued Set에는 userId가 이미 들어가 있으니 그건 그대로 두고, stock만 <code>INCR</code>로 복원한다. SREM까지 하면 같은 유저가 다시 발급받을 수 있다.</p>
<p><strong>기타 DB 오류 (DataAccessException)</strong>:
네트워크 장애, 타임아웃 등. 이 경우는 발급 자체가 없었던 걸로 만들어야 하니까 <code>SREM</code>(issued Set에서 제거) + <code>INCR</code>(stock 복원)을 둘 다 한다.</p>
<pre><code>UK 위반  → restoreStock(INCR만)   — &quot;발급은 됐으니 기록은 유지, 재고만 복원&quot;
기타 오류 → compensate(SREM+INCR) — &quot;아예 없었던 일로&quot;</code></pre><p>이 구분을 안 하면 동일 유저가 재발급받거나, 재고가 맞지 않는 상황이 생긴다.</p>
<h2 id="코드-리뷰에서-잡힌-버그들">코드 리뷰에서 잡힌 버그들</h2>
<p>초기 구현 후 PR 리뷰에서 꽤 미묘한 버그들이 나왔다:</p>
<p><strong>WRONGTYPE 버그</strong>: Redis에서 <code>coupon:issued:{eventId}</code> 키를 String으로 초기화했는데 Lua 스크립트에서는 Set 연산(SISMEMBER, SADD)을 했다. Redis는 키의 타입이 생성 시점에 고정되기 때문에 WRONGTYPE 에러가 발생했다. 초기화 코드에서 해당 키를 SET 명령 대신 EXPIRE만 거는 것으로 수정했다.</p>
<p><strong>HTTP 상태코드 혼동</strong>: 매진을 처음에 409 Conflict로 리턴했다. 409는 &quot;재시도하면 될 수 있음&quot;이고, 410 Gone은 &quot;리소스가 소진됨, 재시도 의미 없음&quot;이다. 선착순 매진은 410이 맞다. 클라이언트가 불필요한 재시도를 하지 않도록 의미를 정확히 전달해야 한다.</p>
<p><strong>Lua ARGV 타입</strong>: <code>Duration.ofSeconds(3600)</code>을 그대로 넘기면 Lua에 <code>&quot;PT3600S&quot;</code>라는 문자열이 도착한다. <code>ttlSeconds.toString()</code>으로 <code>&quot;3600&quot;</code>을 넘겨야 한다.</p>
<p>이런 건 테스트가 아니라 코드 리뷰에서만 잡히는 종류의 버그다.</p>
<h2 id="동시성-테스트-정말로-안전한가를-증명하기">동시성 테스트: &quot;정말로 안전한가?&quot;를 증명하기</h2>
<p>코드 리뷰를 통과했으니 이제 실제로 동시에 수천 건을 쏴서 검증해야 한다. Kotest + Testcontainers로 4종의 테스트를 작성했다.</p>
<p>핵심은 <strong>이중 래치(Double Latch) 패턴</strong>이다:</p>
<pre><code class="language-kotlin">fun concurrentExecute(
    taskCount: Int,
    poolSize: Int = minOf(taskCount, 200),
    action: (index: Int) -&gt; Unit,
) {
    val executor = Executors.newFixedThreadPool(poolSize)
    val startLatch = CountDownLatch(1)    // 동시 출발 신호
    val doneLatch = CountDownLatch(taskCount)  // 전체 완료 대기

    repeat(taskCount) { i -&gt;
        executor.submit {
            startLatch.await()  // 모든 스레드가 여기서 대기
            try {
                action(i)
            } finally {
                doneLatch.countDown()
            }
        }
    }

    startLatch.countDown()  // 한 번에 출발
    try {
        val completed = doneLatch.await(120, TimeUnit.SECONDS)
        check(completed) { &quot;timed out&quot; }
    } finally {
        executor.shutdownNow()  // 실패해도 스레드 정리 보장
        executor.awaitTermination(10, TimeUnit.SECONDS)
    }
}</code></pre>
<p>이 패턴이 없으면 먼저 생성된 스레드부터 실행해서 &quot;순차 요청&quot;에 가까워진다. <code>startLatch</code>로 200개 스레드를 대기시킨 뒤 <code>countDown()</code> 한 번으로 동시에 출발시킨다.</p>
<h3 id="tc-01-초과-발급-검증">TC-01: 초과 발급 검증</h3>
<pre><code class="language-kotlin">test(&quot;1,000개 쿠폰에 3,000건 동시 요청 시 정확히 1,000건만 발급된다&quot;) {
    val totalQuantity = 1_000
    val taskCount = 3_000
    val event = createOpenEvent(totalQuantity)
    val successCount = AtomicInteger(0)
    val soldOutCount = AtomicInteger(0)

    concurrentExecute(taskCount) { _ -&gt;
        try {
            couponIssueService.issue(event.id, UUID.randomUUID())
            successCount.incrementAndGet()
        } catch (e: BusinessException) {
            when (e.errorCode) {
                ErrorCode.EVENT_SOLD_OUT -&gt; soldOutCount.incrementAndGet()
                else -&gt; throw e
            }
        }
    }

    // 3중 검증
    successCount.get() shouldBe totalQuantity        // 성공 1,000건
    soldOutCount.get() shouldBe (taskCount - totalQuantity)  // 매진 2,000건
    couponIssueRepository.count() shouldBe totalQuantity.toLong()  // DB도 1,000건
}</code></pre>
<p>3,000건 요청에 정확히 1,000건만 발급. 1,001건도 아니고 999건도 아니다.</p>
<h3 id="tc-02-중복-발급-검증">TC-02: 중복 발급 검증</h3>
<p>동일한 userId로 100건을 동시에 쏜다. Lua의 SISMEMBER와 DB의 Unique Key가 이중으로 방어하므로 딱 1건만 발급된다.</p>
<h3 id="tc-03-매진-후-무결성">TC-03: 매진 후 무결성</h3>
<p>5개 쿠폰을 순차 발급으로 매진시킨 뒤 1,000건을 동시에 쏜다. 추가 발급 0건.</p>
<h3 id="tc-04-redis-db-정합성">TC-04: Redis-DB 정합성</h3>
<p>Redis의 <code>coupon:issued:{eventId}</code> Set 크기와 DB의 <code>coupon_issue</code> 테이블 row count가 일치하는지 검증.</p>
<p>4개 테스트 전부 통과. Lua 스크립트의 원자성이 실제 동시 환경에서 검증됐다.</p>
<h3 id="테스트하면서-겪은-삽질">테스트하면서 겪은 삽질</h3>
<p>처음에 TC-01의 <code>taskCount</code>를 10,000으로 잡았다. 그런데 WSL2 + Testcontainers + HikariCP(기본 10개) 조합에서 120초 안에 안 끝났다. 200개 스레드가 10개 커넥션을 놓고 경쟁하니 커넥션 대기 시간이 누적되는 거다.</p>
<p>3,000건(재고의 3배)으로 줄였다. 동시성 안전성 증명에는 3배 과부하면 충분하고, CI 환경에서도 안정적으로 통과한다.</p>
<p>더 큰 문제도 있었다. TC-01이 타임아웃으로 실패한 뒤 TC-02에서 <code>couponIssueRepository.count()</code>가 224를 반환했다. TC-02는 동일 userId 100건이니 최대 1건이어야 하는데.</p>
<p>원인은 <strong>TC-01에서 executor.shutdown()에 도달하지 못한 것</strong>이다. <code>check(completed)</code>가 예외를 던지면서 200개 스레드가 정리되지 않고 백그라운드에서 계속 DB에 쓰고 있었다. <code>beforeTest</code>에서 <code>deleteAllInBatch()</code>를 해도 그 뒤에 잔여 스레드가 쓰니까 오염되는 거다.</p>
<p><code>try-finally</code>로 <code>executor.shutdownNow()</code>를 감싸서 해결했다. 테스트가 실패해도 스레드 풀이 확실히 정리된다.</p>
<h2 id="전체-아키텍처-흐름">전체 아키텍처 흐름</h2>
<pre><code>Client → POST /api/v1/events/{eventId}/issue

CouponIssueService.issue(eventId, userId)
  ├── 1. eventRepository.findById()          ← DB Read (커넥션 잠깐 잡고 놓음)
  ├── 2. event.period.contains(now)          ← 기간 검증
  ├── 3. initStockIfAbsent(SET NX EX)       ← Redis (DB 커넥션 미점유)
  ├── 4. tryIssueCoupon(Lua 스크립트)         ← Redis (DB 커넥션 미점유)
  │     ├── SOLD_OUT(0)  → 410 Gone
  │     ├── ALREADY(-1)  → 409 Conflict
  │     └── SUCCESS(1)   → continue
  └── 5. CouponIssueTxService.saveOrCompensate()  ← @Transactional (여기만)
        ├── DB INSERT 성공 → 201 Created
        ├── UK 위반 → restoreStock(INCR) + 409
        └── DB 오류 → compensate(SREM+INCR) + re-throw</code></pre><p>Redis 호출 구간(3~4)에서 DB 커넥션을 안 잡는다. DB 쓰기(5)에서만 짧게 잡았다가 놓는다.</p>
<h2 id="배운-것들">배운 것들</h2>
<p><strong>&quot;개별 명령은 원자적이지만 조합은 아니다.&quot;</strong> Redis를 쓰면서 가장 먼저 부딪히는 함정. Lua 스크립트가 이 문제의 정석적인 해법이다.</p>
<p><strong>트랜잭션 범위는 최소화해야 한다.</strong> 특히 Redis 같은 외부 I/O를 포함하는 메서드에 <code>@Transactional</code>을 걸면 DB 커넥션이 Redis 응답을 기다리는 동안 낭비된다. 고동시성에서는 이게 전체 시스템을 죽인다.</p>
<p><strong>보상 전략은 실패 유형별로 달라야 한다.</strong> UK 위반과 네트워크 오류를 같은 방식으로 롤백하면 데이터 불일치가 생긴다. 예외 타입을 구분해서 처리해야 한다.</p>
<p><strong>테스트에서 스레드 풀은 반드시 정리해야 한다.</strong> <code>executor.shutdown()</code>이 실행 보장 안 되면 다음 테스트를 오염시킨다. 이건 코드 리뷰에서도 잡기 어렵고, 실제로 터져봐야 안다.</p>
<p><strong>동시성 테스트에는 이중 래치가 필수다.</strong> 스레드를 만드는 것과 동시에 실행하는 것은 다르다. <code>startLatch</code>로 대기시킨 뒤 한 번에 출발시켜야 &quot;진짜&quot; 동시 요청이다.</p>
<hr>
<p><em>이 프로젝트의 전체 코드는 <a href="https://github.com/Get-bot/spring-event-lab">spring-event-lab</a>에서 볼 수 있다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude Code 플러그인 18개 깔아보고 정리한 후기]]></title>
            <link>https://velog.io/@co-vol/Claude-Code-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-18%EA%B0%9C-%EA%B9%94%EC%95%84%EB%B3%B4%EA%B3%A0-%EC%A0%95%EB%A6%AC%ED%95%9C-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Claude-Code-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-18%EA%B0%9C-%EA%B9%94%EC%95%84%EB%B3%B4%EA%B3%A0-%EC%A0%95%EB%A6%AC%ED%95%9C-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Tue, 14 Apr 2026 01:37:37 GMT</pubDate>
            <description><![CDATA[<h2 id="내-프로젝트-맥락">내 프로젝트 맥락</h2>
<ul>
<li>Spring Boot 4 + Kotlin + PostgreSQL + Redis + Kafka</li>
<li>DDD 기반 설계 (Aggregate 분리, Value Object, Rich Domain Model)</li>
<li>Flyway 마이그레이션, QueryDSL (KSP), Testcontainers</li>
<li>혼자 개발하지만 AI와 페어 프로그래밍하는 느낌으로 진행</li>
</ul>
<p>이 맥락에서 &quot;나한테 효과적이었는가&quot;를 기준으로 평가한다. 프론트엔드 프로젝트나 팀 프로젝트라면 결론이 달라질 수 있다.</p>
<hr>
<h2 id="핵심-플러그인-이것만-있어도-된다">핵심 플러그인: 이것만 있어도 된다</h2>
<h3 id="bkit-pdca-워크플로우">bkit (PDCA 워크플로우)</h3>
<p>내 개발 흐름을 가장 많이 바꾼 플러그인이다.</p>
<p><code>/pdca plan event-crud</code> → <code>/pdca design event-crud</code> → 구현 → <code>/pdca analyze</code> 순서로 진행하니까, &quot;그냥 코드부터 치는&quot; 습관이 사라졌다. Plan 문서에서 API 스펙과 에러코드를 먼저 정의하고, Design 문서에서 패키지 구조와 엔티티 설계를 잡고, 그 다음에 코드를 쓴다.</p>
<p>실제로 Event CRUD API를 구현할 때 Plan 단계에서 <code>DateRange</code> Value Object를 별도 설계하기로 결정했고, 이게 나중에 Coupon 유효기간에도 재사용됐다. 코드부터 쳤으면 Entity 안에 startedAt/endedAt을 날것으로 박아뒀을 거다.</p>
<p>단점도 있다. 문서 템플릿이 상당히 무겁다. feature 하나에 Plan + Design 문서가 각각 수백 줄이고, 에이전트가 18개 이상 정의되어 있어서 시스템 프롬프트가 길다. 간단한 버그 수정에도 &quot;PDCA 사이클을 시작하시겠습니까?&quot;라고 물어올 때가 있다. 토큰 소모가 체감된다.</p>
<p><strong>평점: 4/5</strong> — 문서 주도 개발 습관을 만들어주지만, 가벼운 작업에는 과하다.</p>
<h3 id="commit-commands">commit-commands</h3>
<p><code>/commit</code> 한 번이면 변경 사항을 분석하고, 최근 커밋 스타일에 맞춰서 메시지를 만들어준다. 내 프로젝트 커밋 로그를 보면 <code>feat:</code>, <code>refactor:</code>, <code>fix:</code> 접두사가 일관되게 붙어 있는데, 이게 이 플러그인 덕이다.</p>
<p><code>/commit-push-pr</code>도 있는데 이건 브랜치 생성 → 커밋 → 푸시 → PR 생성까지 원스톱이다. 단독 개발이라 PR을 자주 만들진 않지만, 가끔 코드 리뷰를 받을 때 유용했다.</p>
<p>단점은 특별히 없다. 단순하고, 하는 일이 명확하다.</p>
<p><strong>평점: 5/5</strong> — 모든 Claude Code 사용자에게 추천.</p>
<h3 id="kotlin-lsp">kotlin-lsp</h3>
<p>Kotlin 프로젝트면 필수다. go-to-definition, 참조 찾기, 리팩토링 지원이 된다. Claude가 코드를 수정할 때 LSP 정보를 참고하니까 &quot;이 메서드 어디서 호출되지?&quot; 같은 질문에 정확한 답을 준다.</p>
<p>설치에 <code>brew install JetBrains/utils/kotlin-lsp</code>가 필요한데, Linux/WSL 환경이면 별도 설정이 필요할 수 있다.</p>
<p><strong>평점: 4/5</strong> — Kotlin 프로젝트라면 무조건. 아니면 무시.</p>
<hr>
<h2 id="유용하지만-선택적인-플러그인">유용하지만 선택적인 플러그인</h2>
<h3 id="code-review--code-simplifier">code-review + code-simplifier</h3>
<p><code>/code-review</code>는 4개 에이전트가 병렬로 PR을 분석한다. CLAUDE.md 준수 여부, 버그 스캔, git blame 기반 히스토리 분석까지. 신뢰도 80점 이하는 필터링되니까 &quot;이건 아닌데...&quot; 싶은 지적이 적다.</p>
<p><code>/simplify</code>는 코드 중복, 불필요한 복잡성을 찾아서 직접 수정해준다. Event CRUD 구현 후 돌렸더니 QueryDSL의 where 표현식 중복을 <code>EventQuery</code> object로 추출하라고 제안했다.</p>
<p>다만 두 플러그인 모두 PR이 있어야 제대로 동작한다. 로컬에서 커밋 없이 작업 중이면 분석 대상이 제한적이다.</p>
<p><strong>평점: 각각 3.5/5</strong> — PR 기반 워크플로우라면 효과적. 혼자 개발하면 가끔 쓰는 정도.</p>
<h3 id="pr-review-toolkit">pr-review-toolkit</h3>
<p>code-review의 상위 호환 같은 플러그인인데, 에이전트가 6개다. 주석 정확도, 테스트 커버리지, 에러 핸들링, 타입 설계, 코드 품질, 코드 간소화를 각각 전문 에이전트가 본다.</p>
<p>솔직히 code-review와 겹치는 부분이 많다. 둘 다 깔아놓으면 비슷한 리뷰를 두 번 받는 느낌이다. 하나만 고르라면 pr-review-toolkit이 더 세분화되어 있어서 이걸 추천한다.</p>
<p><strong>평점: 3.5/5</strong> — code-review랑 중복. 하나만 골라라.</p>
<h3 id="context7-mcp--plugin">context7 (MCP + Plugin)</h3>
<p>라이브러리 문서 조회 도구다. &quot;Spring Boot 4에서 Flyway 설정이 어떻게 바뀌었지?&quot; 같은 질문에 최신 공식 문서를 기반으로 답해준다. 학습 데이터에 없는 최신 변경사항을 잡아준다는 점에서 가치가 있다.</p>
<p>여기서 삽질 에피소드가 하나 있다. <strong>MCP 서버와 Plugin이 별개라는 걸 모르고 둘 다 설치했다.</strong> MCP 서버(<code>npx -y @upstash/context7-mcp</code>)가 실제 문서 조회 엔진이고, Plugin은 &quot;라이브러리 질문이 들어오면 context7 MCP를 써라&quot;라는 트리거 지침일 뿐이다. Plugin만 깔고 MCP를 안 깔면 아무것도 안 된다. 반대로 MCP만 있어도 잘 동작한다.</p>
<p><strong>평점: MCP 서버 4/5, Plugin 2/5</strong> — MCP 서버가 본체. Plugin은 있으면 좋지만 필수는 아니다.</p>
<h3 id="superpowers">superpowers</h3>
<p>개발 워크플로우 전체를 관리하는 야심찬 플러그인이다. 브레인스토밍 → 스펙 → 구현 계획 → 서브에이전트 병렬 실행까지 자동화한다는 콘셉트인데, bkit와 역할이 겹친다.</p>
<p>bkit가 PDCA라는 명확한 프레임워크를 제공하는 반면, superpowers는 TDD와 YAGNI 원칙을 강조하면서 좀 더 유연하게 접근한다. 둘 다 &quot;코드부터 치지 마라&quot;라는 철학은 같다.</p>
<p>내 경우 bkit를 먼저 도입해서 PDCA에 익숙해진 상태라 superpowers는 거의 안 썼다. 그런데 bkit 없이 시작하는 사람이라면 superpowers가 진입장벽이 낮을 수 있다.</p>
<p><strong>평점: 3/5</strong> — bkit와 양자택일. 둘 다 쓸 필요는 없다.</p>
<h3 id="claude-md-management">claude-md-management</h3>
<p><code>/revise-claude-md</code>로 세션 중 배운 내용을 CLAUDE.md에 반영할 수 있다. 프로젝트가 커지면서 컨벤션이 쌓이는데, 이걸 수동으로 관리하는 건 귀찮다.</p>
<p>내 CLAUDE.md에 &quot;Kotlin <code>@field:</code> target 필수&quot;, &quot;QueryDSL 정렬 필드는 화이트리스트 Map으로 관리&quot; 같은 항목이 들어간 게 이 플러그인 덕이다.</p>
<p><strong>평점: 3.5/5</strong> — 장기 프로젝트에서 CLAUDE.md를 관리하고 있다면 가치 있다.</p>
<hr>
<h2 id="안-써도-됐던-것들">안 써도 됐던 것들</h2>
<h3 id="불필요했던-lsp-플러그인-3개">불필요했던 LSP 플러그인 3개</h3>
<p>jdtls-lsp (Java), typescript-lsp, gopls-lsp (Go)를 전부 깔았다. Kotlin 프로젝트인데.</p>
<p>플러그인 마켓에서 &quot;LSP? 좋은 거 아니야?&quot; 하고 다 깔았는데, 이 프로젝트에 Java/TypeScript/Go 파일이 한 개도 없다. 세션 시작할 때 불필요한 LSP 프로세스가 뜨고, 시스템 프롬프트에 쓸데없는 지침이 들어간다.</p>
<p><strong>교훈: 자기 프로젝트 기술 스택에 맞는 LSP만 설치하라.</strong></p>
<h3 id="figma-atlassian-gitlab">figma, atlassian, gitlab</h3>
<p>figma는 디자인 파일이 없으면 쓸 일이 없다. atlassian은 Jira/Confluence 연동인데, 인증 설정을 안 해서 매 세션마다 &quot;Needs authentication&quot; 경고가 뜬다. gitlab은 아예 연결 실패 상태.</p>
<p>이런 플러그인들이 문제인 게, 설치만 해도 시스템 프롬프트에 도구 설명이 추가된다. figma 하나만 봐도 도구 20개 이상의 설명이 주입된다. 안 쓰는 플러그인이 토큰을 잡아먹는 구조다.</p>
<p>여기서 느낀 건, 플러그인은 프로젝트 단위로 다르게 가져가야 한다는 거다. figma나 atlassian이 쓸모없는 플러그인이 아니라, <strong>이 프로젝트에서</strong> 쓸모없는 거다. 프론트엔드 프로젝트라면 figma가 핵심일 수 있고, 팀 프로젝트에서는 atlassian이 Jira 티켓 → 코드 흐름을 이어주는 허브가 된다. <code>.claude/settings.local.json</code>이 프로젝트별로 존재하는 이유가 이거다. 글로벌에 다 깔아두지 말고, 프로젝트 설정에서 필요한 것만 켜라.</p>
<p><strong>교훈: &quot;나중에 쓸지도 몰라&quot;로 깔지 마라. 필요할 때 깔아도 30초면 된다.</strong></p>
<h3 id="explanatory-output-style">explanatory-output-style</h3>
<p>코드 작성 전후에 <code>★ Insight</code> 블록으로 교육적 설명을 붙여주는 기능이다. 단순히 코드를 고쳐주는 게 아니라, 왜 이렇게 고치는 게 나은지 원리와 베스트 프랙티스를 같이 설명해준다.</p>
<p>새로운 언어나 프레임워크를 학습하면서 개발하는 상황이라면 꽤 쓸 만하다. 나도 Kotlin QueryDSL을 처음 잡았을 때 &quot;왜 where 표현식을 이렇게 분리하는 게 좋은지&quot; 설명을 보면서 감을 잡은 적이 있다. 코드 리뷰를 받는 것과 비슷한 효과라서, 주니어 개발자나 팀에 새로 합류한 사람이 코드베이스를 파악하면서 쓰기에 좋다.</p>
<p>문제는 토큰이다. 매 응답에 Insight가 붙으니까, 같은 작업에 대한 응답이 체감상 2-3배 길어진다. README에도 &quot;토큰 비용 증가 주의&quot;라고 적혀 있다. 이미 익숙한 코드를 수정할 때는 &quot;이거 아는데...&quot;하면서 스크롤하게 된다.</p>
<p>끄고 켜면서 학습 모드와 작업 모드를 오가는 게 이상적이긴 하다. 다만 현실적으로는 몇 번 읽다가 결국 끄게 되는 종류의 기능이다. 학습이 목적이면 켜두고, 속도가 목적이면 끄자.</p>
<hr>
<h2 id="삽질에서-배운-것들">삽질에서 배운 것들</h2>
<h3 id="권한-설정이-하나씩-쌓이는-문제">권한 설정이 하나씩 쌓이는 문제</h3>
<p><code>.claude/settings.local.json</code>을 열어보니 <code>permissions.allow</code> 배열에 46개 항목이 있었다. <code>Bash(./gradlew compileKotlin)</code>, <code>Bash(curl -s &quot;https://search.maven.org/...&quot;)</code> 같은 구체적인 명령어가 하나하나 들어가 있다.</p>
<p>이건 매번 Claude가 명령어를 실행할 때마다 &quot;허용&quot;을 누른 결과다. 처음에는 보안상 좋다고 생각했는데, 실제로는 <code>ls</code> 한 번 치는 것도 허가를 받아야 했다.</p>
<p>해결은 글로벌 설정에 읽기 전용 명령어를 일괄 허용하는 것이었다:</p>
<pre><code class="language-json">{
  &quot;permissions&quot;: {
    &quot;allow&quot;: [
      &quot;Bash(ls:*)&quot;,
      &quot;Bash(pwd:*)&quot;,
      &quot;Bash(which:*)&quot;,
      &quot;Bash(tree:*)&quot;
      // ... 등등
    ]
  }
}</code></pre>
<p><strong>교훈: 프로젝트 시작할 때 안전한 명령어 화이트리스트를 먼저 설정하라.</strong> 나중에 하면 이미 수십 개가 쌓여 있다.</p>
<h3 id="mcp-서버와-plugin의-관계">MCP 서버와 Plugin의 관계</h3>
<p>context7에서 겪은 건데, 같은 이름의 MCP 서버와 Plugin이 있으면 뭐가 뭔지 헷갈린다. 정리하면:</p>
<ul>
<li><strong>MCP 서버</strong> = 실제 동작하는 엔진. 프로세스가 떠서 도구(tool)를 제공한다.</li>
<li><strong>Plugin</strong> = 시스템 프롬프트에 지침을 주입하는 래퍼. &quot;이런 상황에서 이 MCP 도구를 써라&quot;라고 알려준다.</li>
</ul>
<p>MCP 서버 없이 Plugin만 있으면 껍데기다. Plugin 없이 MCP 서버만 있으면 잘 돌아가지만, 자동 트리거가 약해질 수 있다.</p>
<h3 id="bkit-vs-superpowers-충돌">bkit vs superpowers 충돌</h3>
<p>둘 다 &quot;코딩 전에 생각하라&quot;는 워크플로우 플러그인이다. 동시에 활성화하면 세션 시작할 때 두 플러그인이 각각 지침을 주입한다. 토큰 낭비인 데다가, 가끔 superpowers가 브레인스토밍을 제안하는데 bkit은 PDCA를 제안하는 상황이 생긴다.</p>
<p><strong>교훈: 워크플로우 플러그인은 하나만 쓰자.</strong></p>
<hr>
<h2 id="내-추천-조합">내 추천 조합</h2>
<p>Kotlin/Spring Boot 서버 개발 기준으로, 이 조합이면 충분하다:</p>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>플러그인</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>워크플로우</td>
<td>bkit <strong>또는</strong> superpowers</td>
<td>PDCA 기반이면 bkit, TDD 기반이면 superpowers</td>
</tr>
<tr>
<td>커밋</td>
<td>commit-commands</td>
<td>커밋 메시지 자동화</td>
</tr>
<tr>
<td>LSP</td>
<td>kotlin-lsp</td>
<td>기술 스택에 맞는 LSP만</td>
</tr>
<tr>
<td>문서 조회</td>
<td>context7 (MCP 서버)</td>
<td>Plugin은 선택</td>
</tr>
<tr>
<td>코드 리뷰</td>
<td>pr-review-toolkit <strong>또는</strong> code-review</td>
<td>하나만</td>
</tr>
<tr>
<td>프로젝트 관리</td>
<td>claude-md-management</td>
<td>CLAUDE.md 유지보수</td>
</tr>
</tbody></table>
<p>총 5-6개. 나머지는 필요할 때 추가하면 된다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>18개 깔아보고 느낀 건, 플러그인은 많다고 좋은 게 아니라는 거다. 각 플러그인이 시스템 프롬프트에 지침을 추가하고, MCP 서버는 프로세스를 띄운다. 안 쓰는 플러그인이 토큰과 리소스를 잡아먹는 구조다.</p>
<p>시작은 커밋 플러그인 하나로 충분하다. 거기서 &quot;문서화가 아쉽네&quot; 싶으면 bkit를, &quot;최신 라이브러리 문법을 모르겠네&quot; 싶으면 context7을 추가하면 된다. 한 번에 다 깔지 말고, 불편함이 생길 때 그걸 해결하는 플러그인을 찾아라.</p>
<pre><code class="language-bash"># 최소 시작 세트
/plugin install commit-commands@claude-plugins-official
/plugin install kotlin-lsp@claude-plugins-official  # 본인 언어에 맞는 LSP</code></pre>
<p>나머지는 진짜 필요해질 때 설치해도 늦지 않다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude한테 TIL 쓰라고 시키는 스킬을 직접 만들어봤다]]></title>
            <link>https://velog.io/@co-vol/Claude%ED%95%9C%ED%85%8C-TIL-%EC%93%B0%EB%9D%BC%EA%B3%A0-%EC%8B%9C%ED%82%A4%EB%8A%94-%EC%8A%A4%ED%82%AC%EC%9D%84-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B4%A4%EB%8B%A4</link>
            <guid>https://velog.io/@co-vol/Claude%ED%95%9C%ED%85%8C-TIL-%EC%93%B0%EB%9D%BC%EA%B3%A0-%EC%8B%9C%ED%82%A4%EB%8A%94-%EC%8A%A4%ED%82%AC%EC%9D%84-%EC%A7%81%EC%A0%91-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B4%A4%EB%8B%A4</guid>
            <pubDate>Thu, 09 Apr 2026 04:46:13 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-만들었나">왜 만들었나</h2>
<p>TIL을 꾸준히 쓰려고 Notion에 데이터베이스를 만들어뒀다. 템플릿도 있다. 잘한 점, 개선점, 배운 점 — 세 섹션으로 나눠서 쓰는 구조.</p>
<p>문제는 매번 Claude한테 이걸 설명해야 한다는 거다.</p>
<blockquote>
<p>&quot;오늘 Redis 캐시 적용했는데 무효화 전략을 안 세워서 터졌어. 이거 TIL로 만들어줘. 포맷은 이거고, 잘한 점에는 이걸 넣고, 개선점에는 저걸 넣고...&quot;</p>
</blockquote>
<p>한두 번이면 괜찮은데, 매일 하니까 <strong>TIL 쓰는 것보다 TIL 포맷 설명하는 데 시간이 더 걸린다</strong>. 그래서 스킬로 만들기로 했다.</p>
<hr>
<h2 id="claude-스킬이-뭔데">Claude 스킬이 뭔데</h2>
<p>Claude에는 &quot;스킬&quot;이라는 개념이 있다. 쉽게 말하면 <strong>Claude한테 미리 주는 매뉴얼</strong>이다.</p>
<p>&quot;TIL 써달라고 하면 이 템플릿 쓰고, Notion의 이 DB에 올리고, 이런 톤으로 써라&quot; — 이걸 파일로 만들어서 Claude에 설치하면, 다음부터는 &quot;TIL 써줘&quot;라고만 해도 알아서 한다.</p>
<p>구조는 이렇게 생겼다:</p>
<pre><code>til-manager/
├── SKILL.md              ← 메인 매뉴얼
└── references/
    ├── til-template.md    ← TIL 템플릿 + 예시
    ├── frontmatter-spec.md ← Git 푸시용 포맷
    ├── notion-setup.md    ← Notion DB 연결 가이드
    └── human-writing-guide.md ← &quot;사람처럼 써라&quot; 가이드</code></pre><p><code>SKILL.md</code>가 핵심이고, <code>references/</code>에 상세 가이드를 분리해둔다. Claude는 스킬이 트리거되면 SKILL.md를 읽고, 필요할 때만 reference 파일을 추가로 읽는다. Anthropic에서는 이걸 <strong>Progressive Disclosure</strong>라고 부른다.</p>
<hr>
<h2 id="만드는-과정">만드는 과정</h2>
<h3 id="1단계-뭘-만들지-정하기">1단계: 뭘 만들지 정하기</h3>
<p>skill-creator라는 내장 스킬이 있어서, &quot;스킬 만들어줘&quot;라고 하면 인터뷰를 해준다. 내가 답한 것들:</p>
<ul>
<li><strong>트리거</strong>: &quot;TIL 작성해줘&quot;, &quot;커밋 정리해줘&quot;, &quot;오늘 회고&quot; 같은 요청</li>
<li><strong>입력</strong>: 커밋 로그 or 자유 텍스트</li>
<li><strong>출력</strong>: Notion DB에 페이지 생성</li>
<li><strong>추가 기능</strong>: 나중에 &quot;TIL 푸시해줘&quot;하면 Git에 Markdown으로 올리기</li>
</ul>
<h3 id="2단계-템플릿-설계">2단계: 템플릿 설계</h3>
<p>기존에 쓰던 TIL 템플릿을 기반으로 했다:</p>
<pre><code>## 잘한 점
  - 상황 → 액션 → 칭찬

## 개선점
  - 문제 → 원인 → 액션플랜

## 배운 점
  - 배움 → 의미</code></pre><p>여기서 두 가지를 추가했다.</p>
<p><strong>하나, &quot;핵심 내용&quot; 섹션.</strong> AI가 만드는 거니까 기술적 핵심을 자동으로 뽑아주면 좋겠다고 생각했다. &quot;배운 점&quot;이 회고라면, &quot;핵심 내용&quot;은 기술 노트다. 키워드, 개념 요약, 코드 스니펫, 참고 링크까지.</p>
<pre><code>## 핵심 내용
  - 키워드: Cache-Aside, @CacheEvict, TTL
  - 요약: Cache-Aside는 읽기 중심 워크로드에 적합...
  - 코드: @Cacheable(value = &quot;products&quot;, key = &quot;#id&quot;)
  - 참고: Spring Cache 공식 문서 링크</code></pre><p>6개월 뒤에 &quot;Redis 캐시 어떻게 했더라?&quot;하고 검색하면 바로 쓸 수 있는 레퍼런스가 된다.</p>
<p><strong>둘, 하루 여러 개 TIL.</strong> 원래는 날짜당 하나였는데, 주제별로 나누는 게 더 나았다. <code>[TIL-260409] Redis 캐시 전략</code>, <code>[TIL-260409] OAuth2 카카오 로그인</code> — 이런 식으로. 검색할 때 주제로 바로 찾을 수 있다.</p>
<h3 id="3단계-notion-연동">3단계: Notion 연동</h3>
<p>Claude.ai에서 Notion MCP(Model Context Protocol)를 연결하면 Notion을 직접 읽고 쓸 수 있다.</p>
<p>먼저 내 TIL 데이터베이스 구조를 확인했다:</p>
<pre><code>Notion:notion-fetch(id: &quot;https://www.notion.so/TIL-xxxxx&quot;)</code></pre><p>결과: DB에 속성이 두 개 — <code>[1/4]</code>(제목)과 <code>작성일</code>(날짜). 이걸 스킬에 넣어뒀다. 매번 &quot;DB URL이 뭐예요?&quot;하고 물어볼 필요가 없어진다.</p>
<pre><code>Notion:notion-create-pages(
  parent: { data_source_id: &quot;2f672cc5-d225-...&quot; },
  pages: [{
    properties: {
      &quot;[1/4]&quot;: &quot;[TIL-260409] Redis 캐시 전략&quot;,
      &quot;date:작성일:start&quot;: &quot;2026-04-09&quot;
    },
    content: &quot;## 잘한 점\n...&quot;
  }]
)</code></pre><p>근데 여기서 한번 삽질했다. <strong>Notion MCP에 읽기 권한만 있으면 페이지 생성이 안 된다.</strong> &quot;This connector requires additional permissions&quot; 에러가 나온다. Notion 연결을 끊고 다시 연결해서 쓰기 권한까지 줘야 한다. 이것도 &quot;개선점&quot;이었다 — MCP 연동할 때 권한부터 체크하자.</p>
<h3 id="4단계-사람처럼-써라-가이드">4단계: &quot;사람처럼 써라&quot; 가이드</h3>
<p>여기가 좀 재밌었다. Claude가 TIL을 써주면 기본적으로 이런 톤이 나온다:</p>
<blockquote>
<p>&quot;Redis 캐시를 적용하여 API 응답 속도를 유의미하게 개선하였습니다. 이를 통해 성능 최적화의 중요성을 인식하게 되었습니다.&quot;</p>
</blockquote>
<p>이건 <strong>내 TIL이 아니라 보고서</strong>다. 내가 쓰면 이렇게 쓴다:</p>
<blockquote>
<p>&quot;Redis 캐시 붙이니까 응답이 확 빨라졌다. 근데 무효화 전략을 안 세우고 넣어서 한번 터졌음...&quot;</p>
</blockquote>
<p>그래서 <code>human-writing-guide.md</code>를 만들었다. 핵심 규칙:</p>
<ol>
<li><strong>입력 톤 미러링</strong> — 내가 &quot;삽질했음&quot;이라고 쓰면, TIL도 &quot;삽질했다&quot;로 써라. &quot;시행착오를 겪었다&quot;로 바꾸지 마라.</li>
<li><strong>구체적 숫자</strong> — &quot;유의미하게 개선&quot;(X) → &quot;300ms에서 20ms로 줄었다&quot;(O)</li>
<li><strong>솔직한 한계</strong> — &quot;완벽하게 해결했다&quot;(X) → &quot;일단 TTL로 땜빵했는데 근본적 해결은 아닌 것 같다&quot;(O)</li>
<li><strong>핵심 내용만 예외</strong> — 여기는 기술 문서 톤으로 딱딱하게 써도 OK. 나중에 레퍼런스로 찾아볼 때는 간결하고 정확한 게 낫다.</li>
</ol>
<p>human-writing이라는 별도의 스킬 가이드가 있어서, 그 원칙들을 TIL 한국어 맥락에 맞게 어댑트했다. Self-Check 질문 7개, Quick Fixes, 감정 표현 사전까지 포함.</p>
<h3 id="5단계-패키징">5단계: 패키징</h3>
<pre><code class="language-bash">cd /mnt/skills/examples/skill-creator
python -m scripts.package_skill /home/claude/til-manager /output/
# → til-manager.skill 생성</code></pre>
<p><code>.skill</code> 파일 하나가 나온다. 이걸 Claude에 설치하면 끝.</p>
<hr>
<h2 id="실제로-쓰면-이런-느낌">실제로 쓰면 이런 느낌</h2>
<p><strong>입력:</strong></p>
<pre><code>오늘 Redis 캐시 적용해서 API 응답 속도 많이 개선함.
근데 캐시 무효화 전략을 제대로 안 세워서 데이터 정합성 이슈가 한번 터졌음.
TTL로 임시 처리했는데 근본적인 해결은 아닌 것 같음.
Cache-Aside 패턴이랑 Write-Through 패턴 차이를 확실히 이해하게 됨.</code></pre><p><strong>출력 (Notion에 자동 생성):</strong></p>
<p>제목: <code>[TIL-260409] Redis 캐시 전략</code></p>
<pre><code>## 잘한 점
### 상황 1
- API 응답이 느려서 Redis 캐시를 적용하기로 했다.
### 액션 1
- Redis 캐시 붙여서 API 응답 속도를 많이 개선했다.
### 칭찬 1
- 성능 문제를 직접 캡치해서 캐시까지 적용한 것. 좋았다.

## 개선점
### 문제 1
- 캐시 무효화 전략을 안 세우고 넣어서 데이터 정합성 이슈가 한번 터졌다.
### 원인 1
- 급하게 넣다 보니 무효화 전략을 미리 안 짰다. TTL로 일단 땜빵.
### 액션플랜 1
- Cache-Aside vs Write-Through 비교해보고 우리 서비스에 맞는 전략 확정하자.

## 배운 점
### 배움 1
- Cache-Aside는 읽기 중심일 때, Write-Through는 쓰기 정합성이 중요할 때.
### 의미 1
- 캐시는 &quot;붙이면 빨라지는 것&quot;이 아니라 &quot;무효화까지 세트&quot;라는 걸 몸으로 배웠다.

## 핵심 내용
### 키워드
- Cache-Aside, Write-Through, TTL, @Cacheable, @CacheEvict
### 요약
- Cache-Aside: 읽기 시 캐시 miss → DB 조회 → 캐시 저장. 쓰기 시 DB 업데이트 → 캐시 삭제.
- Write-Through: 쓰기 시 캐시와 DB 동시 업데이트. 정합성 중요할 때.
### 코드
@Cacheable(value = &quot;products&quot;, key = &quot;#id&quot;)
public Product getProduct(Long id) { ... }

@CacheEvict(value = &quot;products&quot;, key = &quot;#id&quot;)
public void updateProduct(Long id, ProductDto dto) { ... }</code></pre><p>입력 톤이 캐주얼하면 출력도 캐주얼하게 나온다. &quot;유의미한 개선&quot; 같은 표현 대신 &quot;많이 개선했다&quot;, &quot;한번 터졌다&quot; 같은 표현을 쓴다.</p>
<hr>
<h2 id="삽질한-것들">삽질한 것들</h2>
<p><strong>1. human-writing 스킬 원본을 안 보고 추측으로 가이드를 만들었다</strong></p>
<p>별도의 human-writing 스킬이 있는데, 파일이 미설치 상태였다. &quot;대충 이런 내용이겠지&quot; 하고 내 판단으로 가이드를 작성했는데, 나중에 원본을 받아보니 빠진 원칙들이 꽤 있었다. Self-Check 질문 7개, Quick Fixes 같은 것들.</p>
<p>교훈: 외부 참조를 통합할 때는 <strong>원본부터 확보</strong>하자.</p>
<p><strong>2. Notion MCP 권한 문제</strong></p>
<p>읽기는 되는데 쓰기가 안 돼서 페이지 생성을 못 했다. Notion MCP를 연결할 때 &quot;Insert content&quot; 권한이 별도로 필요한데, 기본 연결로는 읽기만 된다. 연결을 끊고 다시 연결해야 한다.</p>
<p>교훈: MCP 연동할 때 <strong>읽기/쓰기 권한을 미리 체크</strong>하자.</p>
<p><strong>3. 데이터베이스 ID vs Data Source ID</strong></p>
<p>Notion MCP에서 DB에 페이지를 추가하려면 <code>database_id</code>가 아니라 <code>data_source_id</code>를 써야 한다. DB URL에서 바로 보이는 ID는 페이지 ID고, 실제 데이터소스 ID는 <code>notion-fetch</code>로 조회해야 나온다. 이거 헷갈려서 좀 삽질했다.</p>
<hr>
<h2 id="다른-사람도-쓸-수-있게-만들기">다른 사람도 쓸 수 있게 만들기</h2>
<p>여기까지 만들고 보니 문제가 하나 있었다. <strong>내 Notion DB 정보가 스킬에 직접 박혀있다.</strong></p>
<pre><code># notion-setup.md (초기 버전)
Data Source ID: 2f672cc5-d225-81a6-a131-000b2b6e5f02
Title 속성: [1/4]
Date 속성: 작성일
Git repo: https://github.com/Get-bot/TIL</code></pre><p>나 혼자 쓸 때는 문제없다. 근데 이걸 GitHub에 올려서 다른 사람도 쓰게 하려면? 다른 사람의 Notion DB는 ID도 다르고, 속성 이름도 다를 수 있다. &quot;Name&quot;일 수도 있고 &quot;제목&quot;일 수도 있고. 스킬 파일을 직접 열어서 ID를 바꾸라고 하면... 그건 좀 아닌 것 같다.</p>
<p>그래서 <strong>자동 설정(Phase 0)</strong>을 추가했다.</p>
<h3 id="아이디어-첫-실행-시-알아서-세팅">아이디어: 첫 실행 시 알아서 세팅</h3>
<p>원리는 간단하다. 스킬이 처음 트리거될 때 Claude의 memory를 확인한다. TIL 관련 설정이 없으면 setup 모드로 들어간다.</p>
<pre><code>[TIL 요청 진입]
  ↓
memory에 til_notion_data_source_id 있나?
  ├─ YES → 바로 TIL 작성 시작
  └─ NO  → &quot;TIL DB가 아직 없네요. 새로 만들까요, 기존 거 연결할까요?&quot;</code></pre><p>사용자가 &quot;새로 만들어줘&quot;라고 하면 <code>Notion:notion-create-database</code>로 DB를 생성하고, &quot;기존 거 연결해줘&quot;라고 하면 URL을 받아서 스키마를 자동 감지한다. 그리고 설정값을 memory에 저장한다.</p>
<pre><code>memory_user_edits(command=&quot;add&quot;, control=&quot;TIL Notion data_source_id: abc123...&quot;)
memory_user_edits(command=&quot;add&quot;, control=&quot;TIL Notion title 속성: Name&quot;)
memory_user_edits(command=&quot;add&quot;, control=&quot;TIL Notion date 속성: 작성일&quot;)</code></pre><p>다음부터는 memory에서 읽어오니까 다시 물어볼 필요가 없다.</p>
<h3 id="바꾼-것들">바꾼 것들</h3>
<ol>
<li><strong>SKILL.md</strong> — Phase 0 (Setup) 섹션 추가. 모든 TIL 작업 전에 memory 확인하는 로직.</li>
<li><strong>notion-setup.md</strong> — 하드코딩된 DB 정보 전부 제거. &quot;memory에서 읽어온다&quot;로 변경.</li>
<li><strong>frontmatter-spec.md</strong> — Git repo URL 하드코딩 제거. <code>{til_git_repo}</code> 플레이스홀더로.</li>
</ol>
<p>Git 설정은 optional로 뺐다. Notion만 쓰고 Git은 안 쓰는 사람도 있을 수 있으니까. &quot;TIL 깃에 올려줘&quot;라고 처음 말할 때 그때 설정하면 된다.</p>
<h3 id="이게-왜-필요했냐면">이게 왜 필요했냐면</h3>
<p>스킬은 결국 <strong>재사용 가능한 워크플로우</strong>다. 나만 쓸 수 있는 스킬은 스크립트랑 다를 게 없다. 설정을 스킬 코드에서 분리해야 다른 사람도 &quot;설치하고 바로 쓸 수 있다&quot;가 된다. 환경 변수를 하드코딩하지 않는 것과 같은 원리다.</p>
<hr>
<h2 id="배운-것-정리">배운 것 정리</h2>
<h3 id="스킬-구조는-단순하다">스킬 구조는 단순하다</h3>
<p><code>SKILL.md</code> 하나 + 필요하면 <code>references/</code>. 끝이다. YAML frontmatter에 description을 쓰면 그게 트리거 역할을 한다. 사용자가 &quot;TIL 써줘&quot;라고 하면 Claude가 description을 보고 &quot;아 이 스킬을 쓸 때구나&quot; 하고 SKILL.md를 읽는다.</p>
<h3 id="트리거-설계가-중요하다">트리거 설계가 중요하다</h3>
<p>skill-creator 문서에 이런 말이 있다: &quot;Claude는 undertrigger 경향이 있다.&quot; 즉, 스킬을 안 쓸 때보다 <strong>안 쓰는 쪽으로 실수</strong>하기 쉽다. 그래서 description에 트리거 문구를 좀 과하게 넣는 게 낫다.</p>
<pre><code class="language-yaml">description: &gt;
  &quot;TIL 작성&quot;, &quot;오늘 회고&quot;, &quot;TIL 푸시&quot;, &quot;오늘 뭐했는지 정리&quot;,
  &quot;커밋 정리해줘&quot;, &quot;TIL 노션에 올려줘&quot;, &quot;Redis 공부한 거 TIL로 만들어줘&quot;
  같은 요청이 들어오면 이 스킬을 사용한다.</code></pre>
<p>이건 API 설계랑 비슷한 마인드셋이다. &quot;내가 만들고 싶은 것&quot;이 아니라 <strong>&quot;사용자가 실제로 뭐라고 말할까&quot;</strong>부터 생각하는 것.</p>
<h3 id="ai한테-사람처럼-써라고-하려면-구체적이어야-한다">AI한테 &quot;사람처럼 써라&quot;고 하려면 구체적이어야 한다</h3>
<p>&quot;자연스럽게 써줘&quot;는 너무 막연하다. &quot;~하였습니다를 ~했다로 바꿔라&quot;, &quot;유의미한 대신 구체적 숫자를 써라&quot;, &quot;입력이 캐주얼하면 출력도 캐주얼하게&quot; — 이렇게 규칙을 명시해야 한다. 감정 표현 사전(&quot;뿌듯하다&quot;, &quot;찝찝하다&quot;, &quot;아직 갈 길이 멀다&quot;)까지 넣으니까 확실히 톤이 달라졌다.</p>
<h3 id="설정은-코드에서-분리하자">설정은 코드에서 분리하자</h3>
<p>처음에는 내 Notion DB ID를 스킬에 직접 넣었다. 나 혼자 쓸 때는 편하다. 근데 다른 사람이 쓰려면 스킬 파일을 열어서 수정해야 한다. 자동 설정(Phase 0)을 추가하니까 설치만 하면 바로 사용 가능한 형태가 됐다. 환경 변수를 <code>.env</code>로 빼는 것과 같은 원리인데, 스킬에서는 memory가 그 역할을 한다.</p>
<hr>
<h2 id="최종-구성">최종 구성</h2>
<pre><code>til-manager/
├── SKILL.md                          # 메인: Phase 0~2, 워크플로우, 입력 분석 가이드
└── references/
    ├── til-template.md               # TIL 템플릿 + 변환 예시
    ├── frontmatter-spec.md           # Git 푸시용 Markdown 포맷
    ├── notion-setup.md               # Notion DB 연결 가이드 (memory 기반)
    └── human-writing-guide.md        # 사람 톤 가이드, Self-Check, Quick Fixes</code></pre><p><strong>지원하는 워크플로우:</strong></p>
<ol>
<li>(첫 실행) 스킬이 알아서 Notion DB 생성/연결 + memory에 설정 저장</li>
<li><code>&quot;오늘 TIL 써줘&quot;</code> + 커밋 로그 or 자유 텍스트 → Notion DB에 TIL 생성</li>
<li>Notion에서 리뷰/수정</li>
<li><code>&quot;TIL 깃에 올려줘&quot;</code> → Markdown + frontmatter로 변환 → Git push</li>
</ol>
<p>하루에 여러 주제로 나눠서 쓸 수 있고, 핵심 내용 섹션에서 기술 레퍼런스를 자동으로 뽑아준다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>솔직히 스킬 만드는 데 생각보다 시간이 걸렸다. 구조 자체는 단순한데, 템플릿 설계 + 톤 가이드 + Notion 연동 + 권한 삽질 + 범용화까지 하니까 반나절은 쓴 것 같다.</p>
<p>근데 한번 만들어두니까 확실히 편하다. &quot;Redis 공부한 거 TIL로 만들어줘&quot;라고 하면 끝이다. 포맷 설명할 필요 없고, Notion DB 구조 알려줄 필요 없고, &quot;사람처럼 써달라&quot;고 매번 부탁할 필요도 없다.</p>
<p>자주 반복하는 워크플로우가 있다면 스킬로 만들어보는 걸 추천한다. TIL 말고도 코드 리뷰 체크리스트, 장애 보고서, 회의록 정리 같은 것도 가능하다. 스킬 파일은 <a href="https://github.com/Get-bot/claude-toolkit">GitHub</a>에 올려뒀고, npx로 바로 설치할 수 있다.</p>
<pre><code class="language-bash">  npx skills add Get-bot/claude-toolkit/til-manager</code></pre>
<p>  Notion MCP만 연결하면 바로 쓸 수 있다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[if-else 지옥 탈출기: 이상거래 감지 시스템을 Chain of Responsibility로 구원하기]]></title>
            <link>https://velog.io/@co-vol/if-else-%EC%A7%80%EC%98%A5-%ED%83%88%EC%B6%9C%EA%B8%B0-%EC%9D%B4%EC%83%81%EA%B1%B0%EB%9E%98-%EA%B0%90%EC%A7%80-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%84-Chain-of-Responsibility%EB%A1%9C-%EA%B5%AC%EC%9B%90%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/if-else-%EC%A7%80%EC%98%A5-%ED%83%88%EC%B6%9C%EA%B8%B0-%EC%9D%B4%EC%83%81%EA%B1%B0%EB%9E%98-%EA%B0%90%EC%A7%80-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%84-Chain-of-Responsibility%EB%A1%9C-%EA%B5%AC%EC%9B%90%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 20 Jan 2026 05:45:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>복잡하게 얽힌 if-else 로직을 Chain of Responsibility 패턴으로 리팩토링하여 유연성과 확장성을 확보한 경험을 공유합니다.</p>
</blockquote>
<h2 id="문제-상황-증식하는-if-else와-유지보수의-한계">문제 상황: 증식하는 if-else와 유지보수의 한계</h2>
<p>담당하고 있는 <strong>이상거래 모니터링 시스템</strong>은 출금 요청 발생 시 다양한 조건을 검사하여 이상 징후를 포착해야 한다. 서비스 초기에는 조건이 단순했으나, 비즈니스 성장에 따라 초과 출금, 집중 출금, 실패 출금, 시간대 제한, 휴일 제한 등 감지해야 할 조건이 지속적으로 증가했다.</p>
<p>가장 큰 문제는 <strong>코드의 구조</strong>였다. 새로운 감지 조건이 추가될 때마다 서비스 클래스 내의 비대해진 <code>if-else</code> 블록을 수정해야 했다.</p>
<pre><code>// [기존 코드] 여러 서비스에 분산되고 중복된 감지 로직
public AppValidateWithdrawBlockDto validateWithdrawBlockByCond(
        AppWithdrawDetectReqDto reqDto,
        AppWithdrawBlockDetectCondDto detectCondDto) {

    List&lt;WithdrawLogStatusDto&gt; logStatusDtos = new ArrayList&lt;&gt;();

    // 1. 초과 출금 감지
    if (detectCondDto.overWithdrawBlock() != null &amp;&amp; detectCondDto.overWithdrawBlock().isActive()) {
        int count = withdrawRepository.countByOverBlock(reqDto.storeCode(), ...);
        if (count &gt;= threshold) {
            logStatusDtos.add(new WithdrawLogStatusDto(DetectStatus.DETECT, ...));
        }
    }

    // 2. 집중 출금 감지
    if (detectCondDto.focusWithdrawBlock() != null &amp;&amp; detectCondDto.focusWithdrawBlock().isActive()) {
        int count = withdrawRepository.countByFocusBlock(reqDto.storeCode(), ...);
        if (count &gt;= threshold) {
            logStatusDtos.add(new WithdrawLogStatusDto(DetectStatus.DETECT, ...));
        }
    }

    // ... 끝없는 if-else의 반복 ...
}</code></pre><blockquote>
<p>&quot;새로운 감지 조건 하나를 추가하기 위해 수정해야 할 포인트가 너무 많다.&quot;</p>
</blockquote>
<p>단순한 코드량 증가를 넘어 구조적인 한계가 명확했다.</p>
<ol>
<li><strong>로직의 파편화:</strong> 유사한 검증 로직이 여러 서비스 메서드에 흩어져 있어, 수정 시 누락으로 인한 버그 발생 위험이 높았다.</li>
<li><strong>경직된 유연성:</strong> &quot;결제 앱(PayApp)별로 감지 순서를 다르게 설정하고 싶다&quot;는 운영팀의 요구사항을 수용하기에는 <code>if-else</code> 기반의 하드코딩된 순서를 변경하기 어려웠다.</li>
</ol>
<p>운영 편의성과 시스템 확장성, 두 마리 토끼를 잡기 위해 근본적인 구조 변경이 필요했다.</p>
<h2 id="해결책-chain-of-responsibility-패턴">해결책: Chain of Responsibility 패턴</h2>
<p>GoF의 디자인 패턴 중 <strong>Chain of Responsibility(책임 연쇄)</strong> 패턴을 도입하기로 결정했다. 요청을 처리할 수 있는 핸들러들을 체인(사슬)으로 연결하고, 요청을 체인을 따라 전달하면서 적절한 핸들러가 처리하도록 하는 방식이다.</p>
<blockquote>
<p>Why Not Strategy Pattern?
Strategy 패턴도 고려했으나, 현재 요구사항은 &quot;단일 전략 선택&quot;이 아닌 <strong>&quot;순차적으로 여러 조건을 검사하다가, 하나라도 걸리면 즉시 탐지(중단)&quot;</strong>하는 파이프라인 구조였다. 따라서 핸들러의 순서를 제어하고 책임을 다음 객체로 전파하는 Chain of Responsibility가 가장 적합했다.</p>
</blockquote>
<h3 id="설계-아키텍처">설계 아키텍처</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/356a356c-0618-4675-aa53-0508e9be46fc/image.jpg" alt=""></p>
<p><strong>핵심 설계 원칙:</strong></p>
<ol>
<li><strong>단일 책임 원칙 (SRP):</strong> 각 핸들러는 오직 하나의 감지 조건(초과, 집중, 시간대 등)만 담당한다.</li>
<li><strong>개방-폐쇄 원칙 (OCP):</strong> 새 조건 추가 시 기존 코드를 수정하지 않고, 새로운 핸들러 클래스만 추가하여 확장한다.</li>
<li><strong>동적 우선순위 관리:</strong> 감지 순서를 코드가 아닌 DB에서 관리하여, <strong>배포 없이 운영팀이 제어</strong>할 수 있게 한다.</li>
</ol>
<h2 id="구현-step-by-step">구현: Step by Step</h2>
<h3 id="step-1-추상-핸들러와-템플릿-메서드">Step 1. 추상 핸들러와 템플릿 메서드</h3>
<p>모든 감지 핸들러가 상속받을 추상 클래스다. <code>handle()</code> 메서드를 <code>final</code>로 선언하여 <strong>Template Method 패턴</strong>을 적용, 전체적인 처리 흐름을 하위 클래스에서 변경할 수 없도록 강제했다.</p>
<pre><code>// [WithdrawDetectHandler.java]
public abstract class WithdrawDetectHandler&lt;T extends WithdrawBlockDetectCondBase&gt; {

    // 처리 흐름의 골격 정의 (Template Method)
    public final ValidateDetectDto handle(
            T detectCondDto,
            List&lt;WithdrawLogType&gt; allowedLogTypes,
            ValidateParamDto paramDto
    ) {
        // 1. 처리 가능 여부 확인
        if (!canHandle(detectCondDto, allowedLogTypes)) {
            return null; // Pass -&gt; 다음 핸들러로
        }

        // 2. 실제 감지 로직 수행 (추상 메서드 위임)
        DetectStatus detectStatus = doDetect(detectCondDto, paramDto);

        // 3. 감지된 경우 결과 반환 (체인 중단)
        if (DetectStatus.DETECT == detectStatus) {
            return ValidateDetectDto.builder()
                    .detectStatus(detectStatus)
                    .logType(getLogType())
                    .build();
        }

        return null; // Pass
    }

    protected abstract boolean canHandle(T detectCondDto, List&lt;WithdrawLogType&gt; allowedLogTypes);
    protected abstract DetectStatus doDetect(T detectCondDto, ValidateParamDto paramDto);
    // ... 기타 메타데이터 메서드
}</code></pre><p>제네릭 타입 <code>T</code>를 활용해 앱 기반 감지(<code>App...Dto</code>)와 매장 기반 감지(<code>Store...Dto</code>)를 유연하게 처리할 수 있도록 설계하여 타입 안정성을 확보했다.</p>
<h3 id="step-2-구체-핸들러-구현-단일-책임">Step 2. 구체 핸들러 구현 (단일 책임)</h3>
<p>이제 각 감지 로직은 독립된 클래스로 분리된다. 아래는 &#39;초과 출금&#39;을 감지하는 핸들러의 예시다.</p>
<pre><code>// [AppOverWithdrawBlockHandler.java]
@Component
@RequiredArgsConstructor
public class AppOverWithdrawBlockHandler extends WithdrawDetectHandler&lt;AppWithdrawBlockDetectCondDto&gt; {

    private final WithdrawRepository withdrawRepository;

    @Override
    protected boolean canHandle(AppWithdrawBlockDetectCondDto detectCondDto, List&lt;WithdrawLogType&gt; allowedLogTypes) {
        // 예외 허용 목록에 있거나, 조건이 비활성화된 경우 스킵
        if (allowedLogTypes != null &amp;&amp; allowedLogTypes.contains(getLogType())) return false;

        AppOverWithdrawBlockDto overBlock = detectCondDto.overWithdrawBlock();
        return overBlock != null &amp;&amp; overBlock.isActive();
    }

    @Override
    protected DetectStatus doDetect(AppWithdrawBlockDetectCondDto detectCondDto, ValidateParamDto paramDto) {
        // 순수한 비즈니스 로직에만 집중
        AppOverWithdrawBlockDto overBlock = detectCondDto.overWithdrawBlock();
        int detectCount = withdrawRepository.countAppWithdrawByOverBlock(paramDto.storeCode(), overBlock);

        return detectCount &gt;= overBlock.thresholdCount()
                ? DetectStatus.DETECT
                : DetectStatus.PASS;
    }

    @Override
    protected WithdrawLogType getLogType() { return WithdrawLogType.OVER_WITHDRAW_BLOCK_LOG; }

    // ...
}</code></pre><h3 id="step-3-spring-di를-활용한-핸들러-자동-등록">Step 3. Spring DI를 활용한 핸들러 자동 등록</h3>
<p>새로운 핸들러를 추가할 때마다 등록 코드를 수동으로 수정해야 한다면 OCP 위반이다. Spring의 <code>List</code> 주입 기능을 활용해 이를 해결했다.</p>
<pre><code>// [WithdrawHandlerRegistry.java]
@Component
@RequiredArgsConstructor
public class WithdrawHandlerRegistry {

    // Spring이 WithdrawDetectHandler 타입을 상속받은 모든 빈(Bean)을 자동으로 주입해준다.
    private final List&lt;WithdrawDetectHandler&lt;?&gt;&gt; handlers;

    // 조회 성능을 위한 캐싱 맵
    private final Map&lt;WithdrawHandlerKey, WithdrawDetectHandler&lt;?&gt;&gt; handlerIndex = new ConcurrentHashMap&lt;&gt;();

    @PostConstruct
    public void init() {
        // 주입받은 핸들러들을 메타데이터 기반으로 인덱싱하여 빠른 조회 지원
        for (WithdrawDetectHandler&lt;?&gt; handler : handlers) {
            // ... 인덱싱 로직 ...
        }
    }

    // ...
}</code></pre><p>이 구조 덕분에 개발자는 핸들러 클래스를 만들고 <code>@Component</code>만 붙이면, 별도의 설정 변경 없이 자동으로 시스템에 통합된다.</p>
<h3 id="step-4-thread-safety를-위한-불변-체인immutable-chain">Step 4. Thread-Safety를 위한 불변 체인(Immutable Chain)</h3>
<p>웹 애플리케이션은 멀티스레드 환경이므로, 체인을 구성하는 리스트가 요청 처리 도중 변경되어서는 안 된다. 이를 방지하기 위해 체인을 <strong>불변(Immutable)</strong> 객체로 설계했다.</p>
<pre><code>// [ImmutableHandlerChain.java]
public class ImmutableHandlerChain&lt;T extends WithdrawBlockDetectCondBase&gt; {

    private final List&lt;WithdrawDetectHandler&lt;T&gt;&gt; handlers;

    public ImmutableHandlerChain(List&lt;WithdrawDetectHandler&lt;T&gt;&gt; handlers) {
        // 생성 시점에 방어적 복사(Defensive Copy) 수행
        this.handlers = List.copyOf(handlers);
    }

    public ValidateDetectDto execute(T detectCondDto, ...) {
        for (WithdrawDetectHandler&lt;T&gt; handler : handlers) {
            try {
                ValidateDetectDto result = handler.handle(...);
                if (result != null) return result; // 감지됨!
            } catch (Exception e) {
                // 하나의 핸들러가 실패해도 전체 프로세스는 멈추지 않도록 예외 격리
                log.error(&quot;Handler failed&quot;, e);
            }
        }
        return ValidateDetectDto.pass();
    }
}</code></pre><p><code>List.copyOf()</code>를 사용해 불변 리스트를 생성함으로써, 원본 리스트가 외부에서 변경되어도 실행 중인 체인은 영향을 받지 않아 Thread-Safety가 보장된다.</p>
<h3 id="step-5-db-기반의-동적-체인-구성">Step 5. DB 기반의 동적 체인 구성</h3>
<p>마지막 단계는 &#39;순서의 제어&#39;다. DB에 저장된 우선순위 설정(<code>PriorityConfig</code>)을 읽어 체인을 동적으로 조립한다.</p>
<pre><code>// [WithdrawDetectChainBuilder.java]
public ImmutableHandlerChain&lt;...&gt; buildChain(UUID detectCondId) {
    // 1. DB에서 설정된 순서 조회 (예: 초과 -&gt; 시간대 -&gt; 휴일)
    List&lt;WithdrawBlockType&gt; orderedTypes = priorityConfigRepository.findOrderedTypes(...);

    // 2. 순서에 맞춰 핸들러 매핑
    List&lt;WithdrawDetectHandler&lt;T&gt;&gt; orderedHandlers = orderedTypes.stream()
            .map(handlerRegistry::getHandler)
            .filter(Objects::nonNull)
            .collect(Collectors.toList());

    // 3. 불변 체인 생성 후 반환
    return new ImmutableHandlerChain&lt;&gt;(orderedHandlers);
}</code></pre><p>이제 운영팀이 관리자 페이지에서 감지 순서를 변경하면, 다음 요청부터 즉시 반영된다. <strong>서버 재배포는 필요 없다.</strong></p>
<h2 id="운영-유연성-공용-조건과-개별-조건의-조화">운영 유연성: 공용 조건과 개별 조건의 조화</h2>
<p>시스템은 두 가지 층위의 규칙을 소화해야 했다.</p>
<ol>
<li><strong>공용 규칙:</strong> 결제앱(PayApp)마다 정해진 기본 감지 순서.</li>
<li><strong>개별 예외:</strong> 특정 VIP 회원이나 매장에 대한 예외 처리.</li>
</ol>
<p>공용 규칙은 위에서 설명한 <code>PriorityConfig</code>로 해결했고, 개별 예외는 <code>allowedLogTypes</code> 파라미터를 통해 구현했다. 각 핸들러의 <code>canHandle</code> 메서드에서 이 목록을 체크하여, 특정 조건 검사를 건너뛰도록(Skip) 처리했다. 이를 통해 <strong>일관성 있는 정책</strong> 위에 <strong>유연한 예외 처리</strong>를 얹을 수 있었다.</p>
<h2 id="결과-및-회고">결과 및 회고</h2>
<p>이 리팩토링을 통해 얻은 성과는 다음과 같다.</p>
<ol>
<li><strong>확장성 확보:</strong> 새로운 감지 조건이 필요하면 핸들러 클래스(<code>@Component</code>) 하나만 추가하면 된다. 기존 코드를 수정할 위험(Side Effect)이 사라졌다.</li>
<li><strong>운영 효율성 증대:</strong> 개발자의 개입 없이 운영팀이 직접 감지 우선순위를 조정할 수 있게 되어 업무 효율이 높아졌다.</li>
<li><strong>안정성 강화:</strong> 각 핸들러가 격리되어 있고, 불변 체인을 통해 동시성 문제로부터 안전하다. 또한 단위 테스트 작성이 훨씬 용이해졌다.</li>
</ol>
<blockquote>
<p>&quot;코드가 고통스러워질 때가 바로 리팩토링의 적기다.&quot;</p>
</blockquote>
<p>초기에는 단순했던 요구사항이 복잡해지면서 코드는 점차 유지보수하기 힘든 상태가 되었다. 하지만 적절한 시점에 디자인 패턴을 도입함으로써, 기술적 부채를 해결하고 비즈니스의 빠른 변화를 뒷받침할 수 있는 견고한 구조를 구축할 수 있었다.</p>
<p><strong>참고 자료</strong></p>
<ul>
<li>GoF Design Patterns - Chain of Responsibility</li>
<li>Spring Framework Reference - IOC &amp; DI</li>
<li>Java Concurrency - Immutable Objects</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring OAuth2 Success/Failure Handler - JWT 발급과 에러 처리 전략]]></title>
            <link>https://velog.io/@co-vol/Spring-OAuth2-SuccessFailure-Handler-JWT-%EB%B0%9C%EA%B8%89%EA%B3%BC-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@co-vol/Spring-OAuth2-SuccessFailure-Handler-JWT-%EB%B0%9C%EA%B8%89%EA%B3%BC-%EC%97%90%EB%9F%AC-%EC%B2%98%EB%A6%AC-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Wed, 14 Jan 2026 03:09:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>OAuth2 인증 성공 후 자체 JWT를 발급하고, 실패 시 프론트엔드 친화적인 에러 응답을 반환합니다. 사용자 경험을 해치지 않으면서 보안을 유지하는 핸들러 설계를 다룹니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@co-vol/Spring-OAuth2-%EC%BF%A0%ED%82%A4-%EA%B8%B0%EB%B0%98-%EC%9D%B8%EC%A6%9D-CSRF-%EB%B3%B4%ED%98%B8%EC%99%80-SPA-%ED%98%B8%ED%99%98%EC%84%B1-%EB%8B%AC%EC%84%B1%ED%95%98%EA%B8%B0">이전 글</a>에서 쿠키 기반 인증 상태 관리를 구현했습니다. 이번에는 OAuth2 인증의 <strong>마지막 단계</strong>인 핸들러를 다룹니다.</p>
<h3 id="핸들러의-역할">핸들러의 역할</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/d245404a-8015-46b8-b414-ba0ca619986a/image.png" alt=""></p>
<hr>
<h2 id="1-oauth2authenticationsuccesshandler-구현">1. OAuth2AuthenticationSuccessHandler 구현</h2>
<h3 id="전체-구조">전체 구조</h3>
<pre><code class="language-java">package com.example.oauth2.handler;

import com.example.global.exception.CustomException;
import com.example.global.exception.ErrorCode;
import com.example.global.properties.Oauth2Properties;
import com.example.global.util.RefreshTokenCookieUtils;
import com.example.member.dto.Oauth2LoginResultDto;
import com.example.member.service.MemberOAuth2LinkService;
import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.userinfo.CustomOAuth2User;
import com.example.oauth2.client.core.userinfo.CustomOidcUser;
import com.example.oauth2.repository.HttpCookieOAuth2AuthorizationRequestRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationSuccessHandler
        extends SimpleUrlAuthenticationSuccessHandler {

    private final HttpCookieOAuth2AuthorizationRequestRepository cookieRepository;
    private final MemberOAuth2LinkService memberLinkService;
    private final Oauth2Properties oauth2Properties;
    private final RefreshTokenCookieUtils refreshTokenCookieUtils;

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException {

        try {
            // 1. 프론트엔드 redirect_uri 쿠키에서 조회
            String loginRedirectUri = cookieRepository
                    .getRedirectUriFromCookie(request, response);

            // 2. OAuth2 사용자 정보 추출
            OAuth2UserInfo userInfo = extractUserInfo(authentication);

            log.info(&quot;OAuth2 login success: provider={}, providerId={}&quot;,
                    userInfo.provider(), userInfo.providerId());

            // 3. 회원 매핑/생성 + JWT 발급
            Oauth2LoginResultDto result = memberLinkService
                    .processOAuth2Login(userInfo);

            // 4. Refresh Token을 HttpOnly 쿠키에 저장
            refreshTokenCookieUtils.addRefreshTokenCookie(
                    response, result.refreshToken()
            );

            // 5. 프론트엔드 콜백 URL로 리다이렉트
            String targetUrl = buildSuccessUrl(loginRedirectUri);
            getRedirectStrategy().sendRedirect(request, response, targetUrl);

        } catch (Exception e) {
            log.error(&quot;OAuth2 success handler failed&quot;, e);
            handleFailure(request, response, e);
        }
    }

    /**
     * 성공 리다이렉트 URL 빌드
     */
    private String buildSuccessUrl(String callbackUrl) {
        return UriComponentsBuilder
                .fromUriString(oauth2Properties.authorizedRedirectUri())
                .queryParam(
                        oauth2Properties.cookie().redirectUriName(),
                        callbackUrl
                )
                .encode(StandardCharsets.UTF_8)
                .build()
                .toUriString();
    }

    /**
     * 핸들러 내 예외 발생 시 실패 처리
     */
    private void handleFailure(
            HttpServletRequest request,
            HttpServletResponse response,
            Exception e
    ) throws IOException {
        String targetUrl = UriComponentsBuilder
                .fromUriString(oauth2Properties.authorizedRedirectUri())
                .queryParam(&quot;error&quot;, &quot;login_failed&quot;)
                .encode(StandardCharsets.UTF_8)
                .build()
                .toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    /**
     * Authentication에서 OAuth2UserInfo 추출
     */
    private OAuth2UserInfo extractUserInfo(Authentication authentication) {
        Object principal = authentication.getPrincipal();

        // Java 21 Pattern Matching for switch
        return switch (principal) {
            case CustomOidcUser oidc -&gt; oidc.oauth2UserInfo();
            case CustomOAuth2User oauth2 -&gt; oauth2.oauth2UserInfo();
            default -&gt; throw CustomException.withDetails(
                    ErrorCode.BAD_REQUEST,
                    &quot;Unexpected principal type: &quot; + principal.getClass().getSimpleName()
            );
        };
    }
}</code></pre>
<h3 id="핵심-포인트">핵심 포인트</h3>
<ol>
<li><strong>extractUserInfo()</strong>: CustomOidcUser/CustomOAuth2User에서 통합 DTO 추출</li>
<li><strong>processOAuth2Login()</strong>: 회원 매핑/생성 + JWT 발급을 서비스에 위임</li>
<li><strong>Refresh Token 쿠키</strong>: HttpOnly 쿠키로 안전하게 저장</li>
<li><strong>예외 처리</strong>: 핸들러 내 예외도 프론트엔드 친화적으로 응답</li>
</ol>
<hr>
<h2 id="2-회원-매핑-서비스">2. 회원 매핑 서비스</h2>
<p>OAuth2 인증 후 기존 회원과 매핑하거나 새 회원을 생성합니다.</p>
<pre><code class="language-java">package com.example.member.service;

import com.example.auth.service.JwtTokenService;
import com.example.member.domain.Member;
import com.example.member.domain.MemberOAuth2Link;
import com.example.member.dto.Oauth2LoginResultDto;
import com.example.member.repository.MemberOAuth2LinkRepository;
import com.example.member.repository.MemberRepository;
import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class MemberOAuth2LinkService {

    private final MemberRepository memberRepository;
    private final MemberOAuth2LinkRepository linkRepository;
    private final JwtTokenService jwtTokenService;

    /**
     * OAuth2 로그인 처리
     * 1. 기존 연동 확인 → 있으면 해당 회원으로 로그인
     * 2. 이메일로 기존 회원 확인 → 있으면 연동 추가
     * 3. 없으면 새 회원 생성 + 연동
     */
    @Transactional
    public Oauth2LoginResultDto processOAuth2Login(OAuth2UserInfo userInfo) {
        // 1. 기존 OAuth2 연동 확인
        Optional&lt;MemberOAuth2Link&gt; existingLink = linkRepository
                .findByProviderAndProviderId(
                        userInfo.provider(),
                        userInfo.providerId()
                );

        Member member;

        if (existingLink.isPresent()) {
            // 기존 연동이 있으면 해당 회원 사용
            member = existingLink.get().getMember();
            // 프로필 정보 업데이트 (선택적)
            updateMemberProfile(member, userInfo);
        } else {
            // 이메일로 기존 회원 찾기
            member = findOrCreateMember(userInfo);
            // OAuth2 연동 추가
            createOAuth2Link(member, userInfo);
        }

        // JWT 토큰 발급
        String accessToken = jwtTokenService.createAccessToken(member);
        String refreshToken = jwtTokenService.createRefreshToken(member);

        return new Oauth2LoginResultDto(accessToken, refreshToken, member.getId());
    }

    private Member findOrCreateMember(OAuth2UserInfo userInfo) {
        // 이메일로 기존 회원 찾기
        if (userInfo.email() != null) {
            Optional&lt;Member&gt; existingMember = memberRepository
                    .findByEmail(userInfo.email());

            if (existingMember.isPresent()) {
                return existingMember.get();
            }
        }

        // 새 회원 생성
        Member newMember = Member.builder()
                .email(userInfo.email())
                .nickname(userInfo.getDisplayName())
                .profileImageUrl(userInfo.profileImageUrl())
                .phoneNumber(userInfo.getNormalizedPhoneNumber())
                .build();

        return memberRepository.save(newMember);
    }

    private void createOAuth2Link(Member member, OAuth2UserInfo userInfo) {
        MemberOAuth2Link link = MemberOAuth2Link.builder()
                .member(member)
                .provider(userInfo.provider())
                .providerId(userInfo.providerId())
                .email(userInfo.email())
                .build();

        linkRepository.save(link);
    }

    private void updateMemberProfile(Member member, OAuth2UserInfo userInfo) {
        // 프로필 이미지, 닉네임 등 업데이트 (선택적)
        if (userInfo.profileImageUrl() != null &amp;&amp; member.getProfileImageUrl() == null) {
            member.updateProfileImage(userInfo.profileImageUrl());
        }
    }
}</code></pre>
<h3 id="oauth2loginresultdto">Oauth2LoginResultDto</h3>
<pre><code class="language-java">package com.example.member.dto;

public record Oauth2LoginResultDto(
        String accessToken,
        String refreshToken,
        Long memberId
) {

}</code></pre>
<hr>
<h2 id="3-refresh-token-쿠키-유틸리티">3. Refresh Token 쿠키 유틸리티</h2>
<pre><code class="language-java">package com.example.global.util;

import com.example.global.properties.JwtProperties;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class RefreshTokenCookieUtils {

    private final JwtProperties jwtProperties;

    /**
     * Refresh Token을 HttpOnly 쿠키에 저장
     */
    public void addRefreshTokenCookie(
            HttpServletResponse response,
            String refreshToken
    ) {
        ResponseCookie cookie = ResponseCookie
                .from(&quot;refresh_token&quot;, refreshToken)
                .path(&quot;/&quot;)
                .httpOnly(true)                    // JavaScript 접근 차단
                .secure(jwtProperties.cookie().secure())  // HTTPS 전용
                .maxAge(jwtProperties.refreshTokenExpiration())  // 7일 등
                .sameSite(&quot;Lax&quot;)                   // CSRF 방어
                .build();

        response.addHeader(&quot;Set-Cookie&quot;, cookie.toString());
    }

    /**
     * Refresh Token 쿠키 삭제 (로그아웃 시)
     */
    public void deleteRefreshTokenCookie(HttpServletResponse response) {
        ResponseCookie cookie = ResponseCookie
                .from(&quot;refresh_token&quot;, &quot;&quot;)
                .path(&quot;/&quot;)
                .httpOnly(true)
                .secure(jwtProperties.cookie().secure())
                .maxAge(0)  // 즉시 만료
                .sameSite(&quot;Lax&quot;)
                .build();

        response.addHeader(&quot;Set-Cookie&quot;, cookie.toString());
    }
}</code></pre>
<hr>
<h2 id="4-oauth2authenticationfailurehandler-구현">4. OAuth2AuthenticationFailureHandler 구현</h2>
<h3 id="에러-분류-전략">에러 분류 전략</h3>
<pre><code class="language-java">package com.example.oauth2.handler;

import com.example.global.properties.Oauth2Properties;
import com.example.oauth2.repository.HttpCookieOAuth2AuthorizationRequestRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationFailureHandler
        extends SimpleUrlAuthenticationFailureHandler {

    /**
     * OAuth2 에러 코드 → 사용자 친화적 에러 정보 매핑
     */
    private static final Map&lt;String, ErrorInfo&gt; ERROR_MAPPINGS = Map.of(
            // 사용자 취소
            &quot;access_denied&quot;,
            new ErrorInfo(&quot;USER_CANCELLED&quot;, &quot;로그인이 취소되었습니다&quot;, false),

            // 토큰 오류
            &quot;invalid_token&quot;,
            new ErrorInfo(&quot;INVALID_TOKEN&quot;, &quot;인증 토큰이 유효하지 않습니다&quot;, true),

            // Provider 서버 오류
            &quot;server_error&quot;,
            new ErrorInfo(&quot;PROVIDER_ERROR&quot;, &quot;소셜 로그인 서비스에 문제가 발생했습니다&quot;, true),

            // 권한 오류
            &quot;insufficient_scope&quot;,
            new ErrorInfo(&quot;INSUFFICIENT_SCOPE&quot;, &quot;필요한 권한이 부족합니다&quot;, true),

            // 네트워크 오류
            &quot;temporarily_unavailable&quot;,
            new ErrorInfo(&quot;TEMPORARILY_UNAVAILABLE&quot;, &quot;일시적으로 서비스를 이용할 수 없습니다&quot;, true)
    );

    private static final ErrorInfo DEFAULT_ERROR =
            new ErrorInfo(&quot;OAUTH2_ERROR&quot;, &quot;소셜 로그인 중 오류가 발생했습니다&quot;, true);

    private final HttpCookieOAuth2AuthorizationRequestRepository cookieRepository;
    private final Oauth2Properties oauth2Properties;

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception
    ) throws IOException {

        // 1. 모든 OAuth2 쿠키 정리
        cookieRepository.clearAllCookies(response);

        // 2. 에러 분류
        ErrorInfo errorInfo = resolveError(exception);

        // 3. 로깅 (심각도별)
        if (errorInfo.shouldAlert()) {
            // 시스템 오류: 개발팀 알림 필요
            log.error(&quot;OAuth2 authentication failed - code: {}, message: {}&quot;,
                    errorInfo.code(), exception.getMessage(), exception);
        } else {
            // 사용자 행동 (취소 등): 정보 로깅만
            log.info(&quot;OAuth2 authentication cancelled by user&quot;);
        }

        // 4. 프론트엔드로 에러 리다이렉트
        String targetUrl = UriComponentsBuilder
                .fromUriString(oauth2Properties.authorizedRedirectUri())
                .queryParam(&quot;error&quot;, errorInfo.code())
                .queryParam(&quot;message&quot;, errorInfo.message())
                .encode(StandardCharsets.UTF_8)
                .build()
                .toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    /**
     * 예외에서 에러 정보 추출
     */
    private ErrorInfo resolveError(AuthenticationException exception) {
        if (exception instanceof OAuth2AuthenticationException oauth2Ex) {
            String errorCode = oauth2Ex.getError().getErrorCode();
            return ERROR_MAPPINGS.getOrDefault(errorCode, DEFAULT_ERROR);
        }
        return DEFAULT_ERROR;
    }

    /**
     * 에러 정보 레코드
     * @param code 프론트엔드용 에러 코드
     * @param message 사용자용 메시지
     * @param shouldAlert 개발팀 알림 필요 여부
     */
    private record ErrorInfo(
            String code,
            String message,
            boolean shouldAlert
    ) {

    }
}</code></pre>
<h3 id="에러-분류-기준">에러 분류 기준</h3>
<table>
<thead>
<tr>
<th>OAuth2 에러 코드</th>
<th>내부 코드</th>
<th>사용자 메시지</th>
<th>알림 필요</th>
</tr>
</thead>
<tbody><tr>
<td><code>access_denied</code></td>
<td><code>USER_CANCELLED</code></td>
<td>로그인이 취소되었습니다</td>
<td>❌</td>
</tr>
<tr>
<td><code>invalid_token</code></td>
<td><code>INVALID_TOKEN</code></td>
<td>인증 토큰이 유효하지 않습니다</td>
<td>✅</td>
</tr>
<tr>
<td><code>server_error</code></td>
<td><code>PROVIDER_ERROR</code></td>
<td>소셜 로그인 서비스에 문제가 발생했습니다</td>
<td>✅</td>
</tr>
<tr>
<td><code>insufficient_scope</code></td>
<td><code>INSUFFICIENT_SCOPE</code></td>
<td>필요한 권한이 부족합니다</td>
<td>✅</td>
</tr>
</tbody></table>
<h3 id="프론트엔드-에러-처리">프론트엔드 에러 처리</h3>
<pre><code class="language-jsx">// /oauth2/callback 페이지
function OAuth2Callback() {
    const [searchParams] = useSearchParams();
    const navigate = useNavigate();

    useEffect(() =&gt; {
        const error = searchParams.get(&#39;error&#39;);
        const message = searchParams.get(&#39;message&#39;);

        if (error) {
            switch (error) {
                case &#39;USER_CANCELLED&#39;:
                    // 사용자 취소: 조용히 로그인 페이지로
                    navigate(&#39;/login&#39;);
                    break;

                case &#39;INVALID_TOKEN&#39;:
                case &#39;PROVIDER_ERROR&#39;:
                    // 시스템 오류: 에러 메시지 표시
                    alert(message || &#39;로그인 중 오류가 발생했습니다.&#39;);
                    navigate(&#39;/login&#39;);
                    break;

                default:
                    alert(message || &#39;알 수 없는 오류가 발생했습니다.&#39;);
                    navigate(&#39;/login&#39;);
            }
            return;
        }

        // 성공 처리...
    }, [searchParams, navigate]);

    return &lt;div&gt;로그인 처리 중...&lt;/div&gt;;
}</code></pre>
<hr>
<h2 id="5-security-configuration에-핸들러-등록">5. Security Configuration에 핸들러 등록</h2>
<pre><code class="language-java">package com.example.global.config;

import com.example.oauth2.handler.OAuth2AuthenticationFailureHandler;
import com.example.oauth2.handler.OAuth2AuthenticationSuccessHandler;
import com.example.oauth2.repository.HttpCookieOAuth2AuthorizationRequestRepository;
import com.example.oauth2.service.CustomOAuth2UserService;
import com.example.oauth2.service.CustomOidcUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomOidcUserService customOidcUserService;
    private final OAuth2AuthenticationSuccessHandler successHandler;
    private final OAuth2AuthenticationFailureHandler failureHandler;
    private final HttpCookieOAuth2AuthorizationRequestRepository cookieRepository;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // ... 기타 설정

                .oauth2Login(oauth2 -&gt; oauth2
                        // 쿠키 기반 AuthorizationRequest 저장소
                        .authorizationEndpoint(authorization -&gt; authorization
                                .authorizationRequestRepository(cookieRepository)
                        )

                        // 커스텀 UserService
                        .userInfoEndpoint(userInfo -&gt; userInfo
                                .userService(customOAuth2UserService)
                                .oidcUserService(customOidcUserService)
                        )

                        // 커스텀 핸들러
                        .successHandler(successHandler)
                        .failureHandler(failureHandler)
                );

        return http.build();
    }
}</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>OAuth2 인증 핸들러를 구현하여 다음을 달성했습니다:</p>
<ol>
<li><strong>JWT 발급</strong>: OAuth2 인증 성공 후 자체 JWT 토큰 발급</li>
<li><strong>회원 매핑</strong>: 기존 회원 연동 또는 새 회원 생성</li>
<li><strong>보안 쿠키</strong>: Refresh Token을 HttpOnly 쿠키에 안전하게 저장</li>
<li><strong>에러 분류</strong>: 사용자 행동 vs 시스템 오류 구분</li>
<li><strong>프론트엔드 친화적</strong>: 에러 코드와 메시지를 URL 파라미터로 전달</li>
</ol>
<h3 id="전체-아키텍처-요약">전체 아키텍처 요약</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/afac8893-045e-4764-9a9b-aa97968d3946/image.png" alt=""></p>
<h3 id="시리즈-정리">시리즈 정리</h3>
<p>이 시리즈에서 구현한 내용:</p>
<table>
<thead>
<tr>
<th>시리즈</th>
<th>주제</th>
<th>핵심 패턴/기술</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>OAuth2 기초</td>
<td>OAuth2 vs OIDC, Spring Security 설정</td>
</tr>
<tr>
<td>2</td>
<td>Strategy Pattern</td>
<td>Provider별 로직 캡슐화</td>
</tr>
<tr>
<td>3</td>
<td>Factory Pattern</td>
<td>Provider별 UserInfo 추출기 관리</td>
</tr>
<tr>
<td>4</td>
<td>쿠키 인증</td>
<td>Stateless 상태 관리, CSRF 방어</td>
</tr>
<tr>
<td>5</td>
<td>핸들러</td>
<td>JWT 발급, 에러 처리 전략</td>
</tr>
</tbody></table>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html">Spring Security OAuth2 공식 문서</a></li>
<li><a href="https://developers.kakao.com/docs/latest/ko/kakaologin/common">카카오 로그인 개발 가이드</a></li>
<li><a href="https://jwt.io/">JWT.io</a> - JWT 디버깅 도구</li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP 세션 관리 치트시트</a></li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot + jOOQ에서 ThreadLocal 기반 멀티테넌시 접근 제어 구현하기]]></title>
            <link>https://velog.io/@co-vol/Spring-Boot-jOOQ%EC%97%90%EC%84%9C-ThreadLocal-%EA%B8%B0%EB%B0%98-%EB%A9%80%ED%8B%B0%ED%85%8C%EB%84%8C%EC%8B%9C-%EC%A0%91%EA%B7%BC-%EC%A0%9C%EC%96%B4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Spring-Boot-jOOQ%EC%97%90%EC%84%9C-ThreadLocal-%EA%B8%B0%EB%B0%98-%EB%A9%80%ED%8B%B0%ED%85%8C%EB%84%8C%EC%8B%9C-%EC%A0%91%EA%B7%BC-%EC%A0%9C%EC%96%B4-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 13 Jan 2026 08:37:58 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이 글에서는 Filter, ThreadLocal, 그리고 jOOQ Condition을 조합하여, 기존 쿼리의 수정 없이 우아하게 데이터 격리를 구현한 경험을 공유합니다.</p>
</blockquote>
<h2 id="문제-상황">문제 상황</h2>
<p>최근 이상거래 모니터링 시스템을 개발하며 <strong>&#39;데이터 격리&#39;</strong> 문제에 직면했습니다. 시스템 내에 다수의 <strong>결제 앱(PayApp)</strong>이 공존하는 환경이라, 관리자 권한에 따라 담당 앱 데이터만 엄격하게 <strong>격리하여</strong> 보여주는 구조가 필요했습니다.</p>
<blockquote>
<p>결국 멀티테넌시(Multi-tenancy) 문제구나.</p>
</blockquote>
<p>처음에는 단순하게 생각했습니다. API 엔드포인트마다 권한 검증 로직을 넣으면 되지 않을까? 하지만 이 시스템에는 결제 앱 ID를 참조하는 테이블이 수십 개에 달했습니다. 감지 조건, 감지 로그, 알림 설정, 수신자 관리 등 모든 도메인이 결제 앱과 연결되어 있었습니다.</p>
<blockquote>
<p>모든 Repository 메서드에 memberId를 파라미터로 넘기고, 매번 권한 체크를 하라고?</p>
</blockquote>
<p>이는 지나치게 비효율적이었습니다. 기존 코드를 전부 수정해야 할 뿐만 아니라, <strong>실수로 누락될 경우 데이터 유출이라는 치명적인 보안 사고</strong>로 이어질 수 있기 때문입니다. 저는 더 안전하고 우아한 방법이 필요했습니다.</p>
<h2 id="설계-고민">설계 고민</h2>
<h3 id="aop-vs-filter">AOP vs Filter</h3>
<p>처음 떠오른 방법은 AOP였습니다. @PreAuthorize나 커스텀 어노테이션으로 메서드 레벨에서 권한을 체크하는 방식입니다.</p>
<pre><code class="language-java">// AOP 방식 (고려했지만 선택하지 않음)
@AuthorizePayApp
public PayAppInfo getPayAppInfo(UUID payAppId) {
    // ...
}</code></pre>
<p>하지만 목록 조회 API에서 문제가 발생했습니다. 결과를 가져온 후 애플리케이션 레벨에서 필터링하면 페이지네이션(Pagination)이 깨집니다. 즉, <strong>쿼리 레벨에서 조건을 걸어야 했습니다.</strong></p>
<blockquote>
<p>Filter에서 컨텍스트를 설정하고, Repository에서 조건을 읽어오면 되지 않을까?</p>
</blockquote>
<p>Filter는 모든 요청의 진입점입니다. 여기서 사용자 정보를 기반으로 접근 가능한 앱 목록을 조회하고, 어딘가에 저장해두면 Repository 계층에서 꺼내 쓸 수 있을 것입니다.</p>
<h3 id="파라미터-전파-vs-threadlocal">파라미터 전파 vs ThreadLocal</h3>
<p>그렇다면 &quot;어딘가&quot;는 어디일까요? 두 가지 선택지가 있었습니다.</p>
<p><strong>방법 1: 파라미터 전파</strong></p>
<pre><code class="language-java">// Controller → Service → Repository로 계속 넘겨야 함
public List&lt;DetectCond&gt; getList(UUID memberId, Set&lt;UUID&gt; accessibleAppIds) {
    return repository.findAll(memberId, accessibleAppIds);
}</code></pre>
<p>이 방법을 시도해 보려 했으나, <del>5분 만에 포기...</del> 구현을 시작하자마자 한계를 직감했습니다. 모든 메서드 시그니처를 변경해야 했고, 파라미터가 누락되어도 컴파일 에러가 발생하지 않아 전체 데이터가 노출될 위험이 컸습니다.</p>
<p><strong>방법 2: ThreadLocal</strong></p>
<pre><code class="language-java">// 어디서든 현재 요청의 컨텍스트에 접근
Set&lt;UUID&gt; appIds = AppAccessContext.getAccessibleAppIds();</code></pre>
<p>ThreadLocal은 스레드별로 독립된 저장소를 제공합니다. HTTP 요청은 하나의 스레드에서 처리되므로, 요청 시작 시 설정하고 끝날 때 정리하면 완벽한 &#39;요청 스코프&#39; 저장소가 됩니다.</p>
<blockquote>
<p>이거다. 기존 코드를 거의 건드리지 않고 접근 제어를 적용할 수 있겠다.</p>
</blockquote>
<h2 id="구현">구현</h2>
<h3 id="전체-구조">전체 구조</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/81e6cf16-19e4-46f1-84c3-479fccc92ef9/image.png" alt=""></p>
<h3 id="step-1-threadlocal-컨텍스트">Step 1: ThreadLocal 컨텍스트</h3>
<p>먼저 요청 스코프의 접근 정보를 저장할 컨텍스트를 만들었습니다.</p>
<pre><code class="language-java">public class AppAccessContext {

    private static final ThreadLocal&lt;AppAccessInfo&gt; CONTEXT = new ThreadLocal&lt;&gt;();

    // Filter에서 호출
    public static void set(UUID memberId, Set&lt;UUID&gt; accessibleAppIds, boolean isSuperAdmin) {
        CONTEXT.set(new AppAccessInfo(memberId, accessibleAppIds, isSuperAdmin));
    }

    // Repository에서 호출
    public static Set&lt;UUID&gt; getAccessibleAppIds() {
        AppAccessInfo info = CONTEXT.get();
        return info != null ? info.accessibleAppIds() : null;
    }

    // 슈퍼관리자 여부 확인
    public static boolean isSuperAdmin() {
        AppAccessInfo info = CONTEXT.get();
        return info != null &amp;&amp; info.isSuperAdmin();
    }

    // 필터링이 필요한지 확인
    public static boolean requiresFiltering() {
        AppAccessInfo info = CONTEXT.get();
        if (info == null) {
            return false; // 컨텍스트 없음 (public API 등)
        }
        return !info.isSuperAdmin() &amp;&amp; info.accessibleAppIds() != null;
    }

    // Filter의 finally에서 반드시 호출
    public static void clear() {
        CONTEXT.remove();
    }

    private record AppAccessInfo(
            UUID memberId,
            Set&lt;UUID&gt; accessibleAppIds,
            boolean isSuperAdmin
    ) {
        AppAccessInfo {
            // 불변성 보장
            accessibleAppIds = accessibleAppIds != null
                    ? Collections.unmodifiableSet(accessibleAppIds)
                    : Collections.emptySet();
        }
    }
}</code></pre>
<p>핵심은 <code>requiresFiltering()</code> 메서드입니다. 슈퍼관리자이거나 컨텍스트가 설정되지 않은 경우(외부 API 등)에는 필터링 로직을 건너뛰게 설계했습니다.</p>
<h3 id="step-2-filter-구현">Step 2: Filter 구현</h3>
<p>Spring Security의 필터 체인에 위치할 커스텀 필터입니다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class AppAccessFilter extends OncePerRequestFilter {

    private final MemberPayAppAccessRepository memberPayAppAccessRepository;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            setupAppAccessContext();
            filterChain.doFilter(request, response);
        } finally {
            // 반드시 정리해야 함 - 스레드 풀 재사용 시 오염 방지
            AppAccessContext.clear();
        }
    }

    private void setupAppAccessContext() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !authentication.isAuthenticated()) {
            return; // 인증되지 않은 요청
        }

        if (!(authentication.getPrincipal() instanceof PrincipalDetails principal)) {
            return;
        }

        UUID memberId = UUID.fromString(principal.id());
        List&lt;String&gt; roles = principal.getRole(principal.role());
        boolean isSuperAdmin = roles.contains(MemberRole.SUPER_ADMIN.getValue());

        if (isSuperAdmin) {
            // 슈퍼관리자는 DB 조회 없이 바로 설정
            AppAccessContext.set(memberId, null, true);
        } else {
            // 일반 관리자는 할당된 앱 목록 조회
            Set&lt;UUID&gt; accessibleAppIds = memberPayAppAccessRepository
                    .findPayAppIdsByMemberId(memberId);
            AppAccessContext.set(memberId, accessibleAppIds, false);
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 외부 API 경로는 필터 제외
        String path = request.getRequestURI();
        return path.startsWith(&quot;/api/v1/external/&quot;)
                || path.startsWith(&quot;/api/v1/payment/result/&quot;);
    }
}</code></pre>
<blockquote>
<p><code>finally</code> 블록에서 <code>clear()</code>를 호출하는 것이 매우 중요합니다.</p>
</blockquote>
<p>Tomcat과 같은 WAS는 스레드 풀을 사용합니다. 이전 요청에서 설정된 <code>ThreadLocal</code> 값이 다음 요청에 남아있을 경우, <strong>다른 사용자의 데이터가 노출되는 치명적인 보안 사고</strong>가 발생할 수 있습니다.</p>
<h3 id="step-3-repository-구현">Step 3: Repository 구현</h3>
<p>멤버-앱 접근 권한을 관리하는 Repository입니다.</p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class MemberPayAppAccessRepository extends MemberPayAppAccessDao {

    private final DSLContext dslContext;

    // 멤버가 접근 가능한 앱 ID 목록 조회
    public Set&lt;UUID&gt; findPayAppIdsByMemberId(UUID memberId) {
        return new HashSet&lt;&gt;(
                dslContext
                        .select(MEMBER_PAY_APP_ACCESS.PAY_APP_ID)
                        .from(MEMBER_PAY_APP_ACCESS)
                        .where(MEMBER_PAY_APP_ACCESS.MEMBER_ID.eq(memberId))
                        .fetchInto(UUID.class)
        );
    }

    // 권한 부여
    public void grantAccess(UUID memberId, UUID payAppId, LocalDateTime createdAt) {
        dslContext
                .insertInto(MEMBER_PAY_APP_ACCESS)
                .set(MEMBER_PAY_APP_ACCESS.MEMBER_ID, memberId)
                .set(MEMBER_PAY_APP_ACCESS.PAY_APP_ID, payAppId)
                .set(MEMBER_PAY_APP_ACCESS.REG_DATE, createdAt)
                .onDuplicateKeyIgnore() // 중복 무시
                .execute();
    }

    // 권한 회수
    public void revokeAccess(UUID memberId, UUID payAppId) {
        dslContext
                .deleteFrom(MEMBER_PAY_APP_ACCESS)
                .where(MEMBER_PAY_APP_ACCESS.MEMBER_ID.eq(memberId))
                .and(MEMBER_PAY_APP_ACCESS.PAY_APP_ID.eq(payAppId))
                .execute();
    }
}</code></pre>
<h3 id="step-4-jooq-조건-유틸리티">Step 4: jOOQ 조건 유틸리티</h3>
<p>가장 중요한 부분입니다. 동적 쿼리의 복잡성을 캡슐화하여, 기존 쿼리에 접근 제어 조건을 손쉽게 끼워 넣을 유틸리티 클래스입니다.</p>
<pre><code class="language-java">@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class PayAppAccess {

    /**
     * PAY_APP_ID 필드를 가진 테이블 조회 시 사용
     * 예: .where(PayAppAccess.restrictByAppId(TABLE.PAY_APP_ID))
     */
    public static Condition restrictByAppId(Field&lt;UUID&gt; payAppIdField) {
        if (!AppAccessContext.requiresFiltering()) {
            // 슈퍼관리자 또는 컨텍스트 미설정
            return DSL.trueCondition();
        }

        Set&lt;UUID&gt; accessibleAppIds = AppAccessContext.getAccessibleAppIds();

        if (accessibleAppIds == null || accessibleAppIds.isEmpty()) {
            // 접근 가능한 앱이 없으면 결과도 없음
            return DSL.falseCondition();
        }

        return payAppIdField.in(accessibleAppIds);
    }

    /**
     * 단일 리소스 접근 검증 (상세 조회, 수정, 삭제 시)
     */
    public static void validateAccess(UUID payAppId) {
        if (!AppAccessContext.hasAccessTo(payAppId)) {
            throw new CustomException(ErrorCode.FORBIDDEN, &quot;접근 권한이 없는 결제 앱입니다.&quot;);
        }
    }
}</code></pre>
<p>초기에는 <code>boolean</code>을 반환하도록 설계했으나, 사용하는 곳마다 <code>if</code>문을 작성해야 하는 불편함이 있었습니다.</p>
<pre><code class="language-java">// 처음 시도 (불편함)
if (PayAppAccess.hasAccess(payAppId)) {
    conditions.add(TABLE.PAY_APP_ID.in(accessibleAppIds));
}</code></pre>
<blockquote>
<p>jOOQ의 <code>Condition</code> 객체를 직접 반환하면 훨씬 깔끔해지지 않을까?</p>
</blockquote>
<p><code>restrictByAppId</code> 메서드는 상황에 따라 <code>trueCondition()</code>, <code>falseCondition()</code>, 또는 <code>in</code> 조건을 반환합니다. 덕분에 비즈니스 로직에서는 복잡한 분기 처리 없이 이 메서드 하나만 호출하면 됩니다.</p>
<h2 id="적용-예시">적용 예시</h2>
<p>기존 쿼리에 <strong>단 한 줄</strong>만 추가하면 데이터 격리가 적용됩니다.</p>
<pre><code class="language-java">// 기존 코드
public List&lt;DetectCondDto&gt; getList(DetectCondListReqDto reqDto) {
    return dslContext
            .select(/* ... */)
            .from(APP_WITHDRAW_DETECT_COND)
            .where(APP_WITHDRAW_DETECT_COND.DEL_YN.eq(&quot;N&quot;))
            .and(/* 기존 검색 조건 */)
            .fetchInto(DetectCondDto.class);
}

// 접근 제어 적용 후
public List&lt;DetectCondDto&gt; getList(DetectCondListReqDto reqDto) {
    return dslContext
            .select(/* ... */)
            .from(APP_WITHDRAW_DETECT_COND)
            .where(APP_WITHDRAW_DETECT_COND.DEL_YN.eq(&quot;N&quot;))
            .and(PayAppAccess.restrictByAppId(APP_WITHDRAW_DETECT_COND.PAY_APP_ID)) // 이 한 줄 추가
            .and(/* 기존 검색 조건 */)
            .fetchInto(DetectCondDto.class);
}</code></pre>
<p>단일 리소스 접근 시에는 검증 메서드를 사용한다.</p>
<pre><code class="language-java">public DetectCondDto getById(UUID id) {
    DetectCondDto dto = repository.findById(id);

    // 접근 권한 검증
    PayAppAccess.validateAccess(dto.payAppId());

    return dto;
}</code></pre>
<h2 id="장단점-분석">장단점 분석</h2>
<h3 id="장점">장점</h3>
<ul>
<li><strong>기존 코드 수정 최소화</strong>: 쿼리에 한 줄만 추가하면 됨</li>
<li><strong>일관성</strong>: Filter에서 한 번 설정하면 모든 곳에서 동일한 컨텍스트 사용</li>
<li><strong>성능</strong>: 슈퍼관리자는 DB 조회 없이 바로 통과</li>
<li><strong>테스트 용이</strong>: 테스트 시 <code>AppAccessContext.set()</code>으로 컨텍스트 주입 가능</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li><strong>ThreadLocal 관리 필수</strong>: <code>clear()</code> 누락 시 메모리 누수 또는 보안 사고</li>
<li><strong>비동기 처리 주의</strong>: <code>@Async</code> 메서드에서는 컨텍스트가 전파되지 않음</li>
<li><strong>IDE 지원 부족</strong>: 호출 관계가 명시적이지 않아 추적이 어려울 수 있음</li>
</ul>
<h3 id="개선-방향">개선 방향</h3>
<ol>
<li><strong>비동기 지원</strong>: <code>TaskDecorator</code>를 구현하여 비동기 스레드에도 컨텍스트 전파</li>
<li><strong>캐싱</strong>: 동일 요청 내 반복 조회 시 캐시 활용</li>
<li><strong>감사 로그</strong>: 접근 시도 기록으로 보안 모니터링 강화</li>
</ol>
<h2 id="마무리">마무리</h2>
<p>멀티테넌시 접근 제어를 구현하면서 중요한 점을 배웠습니다.</p>
<blockquote>
<p>모든 메서드에 파라미터를 추가하는 건 확장 가능한 해결책이 아니다.</p>
</blockquote>
<p>ThreadLocal은 &#39;요청 스코프&#39;라는 개념을 깔끔하게 추상화해 줍니다. 또한, <strong>jOOQ의 Condition은 조립이 가능(Composable)합니다.</strong> <code>rueCondition()</code> 과 <code>falseCondition()</code>을 활용하면 조건부 로직을 아주 우아하게 처리할 수 있습니다.</p>
<blockquote>
<p>jOOQ의 Condition은 합성이 자유롭다.</p>
</blockquote>
<p>이 패턴은 멀티테넌시뿐만 아니라, <strong>논리적 삭제(Soft Delete)</strong>나 <strong>활성 상태 필터링</strong> 등 &quot;전역적인 데이터 필터링이 필요한 상황&quot;에서 범용적으로 활용할 수 있을 것입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring OAuth2 쿠키 기반 인증 - CSRF 보호와 SPA 호환성 달성하기]]></title>
            <link>https://velog.io/@co-vol/Spring-OAuth2-%EC%BF%A0%ED%82%A4-%EA%B8%B0%EB%B0%98-%EC%9D%B8%EC%A6%9D-CSRF-%EB%B3%B4%ED%98%B8%EC%99%80-SPA-%ED%98%B8%ED%99%98%EC%84%B1-%EB%8B%AC%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Spring-OAuth2-%EC%BF%A0%ED%82%A4-%EA%B8%B0%EB%B0%98-%EC%9D%B8%EC%A6%9D-CSRF-%EB%B3%B4%ED%98%B8%EC%99%80-SPA-%ED%98%B8%ED%99%98%EC%84%B1-%EB%8B%AC%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 13 Jan 2026 06:57:22 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>SPA(Single Page Application)와 OAuth2를 연동할 때 발생하는 상태 관리 문제를 쿠키 기반으로 해결합니다. CSRF 공격을 방어하면서 프론트엔드와 안전하게 통신하는 방법을 다룹니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@co-vol/Spring-OAuth2-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%ED%86%B5%ED%95%A9-Factory-Pattern%EC%9C%BC%EB%A1%9C-Provider%EB%B3%84-%EC%9D%91%EB%8B%B5-%ED%8C%8C%EC%8B%B1%ED%95%98%EA%B8%B0">이전 글</a>에서 Factory Pattern으로 Provider별 응답을 통합했습니다. 이번에는 OAuth2 인증 과정에서 <strong>상태를 어떻게 관리</strong>하는지 다룹니다.</p>
<h3 id="문제-oauth2-인증-상태-관리">문제: OAuth2 인증 상태 관리</h3>
<p>OAuth2 인증은 여러 단계의 리다이렉트를 거칩니다:</p>
<pre><code>[프론트엔드] → [백엔드] → [카카오] → [백엔드] → [프론트엔드]
    (1)         (2)        (3)        (4)          (5)</code></pre><p>문제는 (2)와 (4) 사이에 <strong>상태를 어디에 저장할 것인가</strong>입니다:</p>
<ol>
<li><strong>AuthorizationRequest</strong>: CSRF 방어용 <code>state</code> 파라미터 검증에 필요</li>
<li><strong>redirect_uri</strong>: 인증 성공 후 프론트엔드의 어느 페이지로 돌려보낼지</li>
</ol>
<h3 id="세션-vs-쿠키">세션 vs 쿠키</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>세션</strong></td>
<td>서버에서 안전하게 관리</td>
<td>스케일아웃 시 세션 공유 필요</td>
</tr>
<tr>
<td><strong>쿠키</strong></td>
<td>Stateless, 스케일아웃 용이</td>
<td>쿠키 보안 설정 필수</td>
</tr>
</tbody></table>
<p>SPA + 마이크로서비스 환경에서는 <strong>쿠키 기반</strong>이 더 적합합니다.</p>
<hr>
<h2 id="1-httpcookieoauth2authorizationrequestrepository-구현">1. HttpCookieOAuth2AuthorizationRequestRepository 구현</h2>
<p>Spring Security의 <code>AuthorizationRequestRepository</code> 인터페이스를 구현하여 쿠키 기반 저장소를 만듭니다.</p>
<h3 id="전체-구조">전체 구조</h3>
<pre><code class="language-java">package com.example.oauth2.repository;

import com.example.oauth2.client.core.dto.OAuth2AuthorizationRequestDto;
import com.example.oauth2.global.properties.Oauth2Properties;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseCookie;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Optional;

@Component
@RequiredArgsConstructor
@Slf4j
public class HttpCookieOAuth2AuthorizationRequestRepository
        implements AuthorizationRequestRepository&lt;OAuth2AuthorizationRequest&gt; {

    private final Oauth2Properties oauth2Properties;
    private final ObjectMapper objectMapper;

    // ==================== Repository Interface ====================

    /**
     * 쿠키에서 AuthorizationRequest를 로드합니다.
     */
    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return getCookieValue(request, oauth2Properties.cookie().authName())
                .map(this::deserialize)
                .orElse(null);
    }

    /**
     * AuthorizationRequest를 쿠키에 저장합니다.
     */
    @Override
    public void saveAuthorizationRequest(
            OAuth2AuthorizationRequest authorizationRequest,
            HttpServletRequest request,
            HttpServletResponse response
    ) {
        if (authorizationRequest == null) {
            deleteCookie(response, oauth2Properties.cookie().authName());
            return;
        }

        String serialized = serialize(authorizationRequest);
        if (serialized != null) {
            // 1. redirect_uri 쿠키 저장 (프론트엔드가 보낸 콜백 URL)
            String loginRedirectUri = request.getParameter(
                    oauth2Properties.cookie().redirectUriName()
            );
            if (loginRedirectUri != null &amp;&amp; !loginRedirectUri.isBlank()) {
                addCookie(response, oauth2Properties.cookie().redirectUriName(),
                        loginRedirectUri);
            }

            // 2. AuthorizationRequest 쿠키 저장
            addCookie(response, oauth2Properties.cookie().authName(), serialized);
        }
    }

    /**
     * 쿠키에서 AuthorizationRequest를 제거하고 반환합니다.
     */
    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(
            HttpServletRequest request,
            HttpServletResponse response
    ) {
        OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request);
        deleteCookie(response, oauth2Properties.cookie().authName());
        return authorizationRequest;
    }

    // ==================== 추가 메서드 ====================

    /**
     * 프론트엔드 리다이렉트 URI를 쿠키에서 가져오고 삭제합니다.
     */
    public String getRedirectUriFromCookie(
            HttpServletRequest request,
            HttpServletResponse response
    ) {
        String loginRedirectUri = getCookieValue(
                request, oauth2Properties.cookie().redirectUriName()
        ).orElse(&quot;/&quot;);

        deleteCookie(response, oauth2Properties.cookie().redirectUriName());
        return loginRedirectUri;
    }

    /**
     * 모든 OAuth2 관련 쿠키를 삭제합니다.
     */
    public void clearAllCookies(HttpServletResponse response) {
        deleteCookie(response, oauth2Properties.cookie().authName());
        deleteCookie(response, oauth2Properties.cookie().redirectUriName());
    }

    // ==================== Cookie Operations ====================

    private Optional&lt;String&gt; getCookieValue(HttpServletRequest request, String cookieName) {
        if (request.getCookies() == null) {
            return Optional.empty();
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -&gt; cookieName.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findFirst();
    }

    private void addCookie(HttpServletResponse response, String name, String value) {
        ResponseCookie cookie = buildCookie(name, value,
                oauth2Properties.cookie().maxAgeSeconds());
        response.addHeader(&quot;Set-Cookie&quot;, cookie.toString());
    }

    private void deleteCookie(HttpServletResponse response, String name) {
        ResponseCookie cookie = buildCookie(name, &quot;&quot;, 0);
        response.addHeader(&quot;Set-Cookie&quot;, cookie.toString());
    }

    private ResponseCookie buildCookie(String name, String value, int maxAge) {
        ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value)
                .path(&quot;/&quot;)
                .httpOnly(true)
                .secure(oauth2Properties.cookie().secure())
                .maxAge(maxAge)
                .sameSite(&quot;Lax&quot;);

        String domain = oauth2Properties.getCookieDomain();
        if (domain != null &amp;&amp; !domain.isBlank()) {
            builder.domain(domain);
        }

        return builder.build();
    }

    // ==================== Serialization ====================

    private String serialize(OAuth2AuthorizationRequest request) {
        try {
            OAuth2AuthorizationRequestDto dto = OAuth2AuthorizationRequestDto.from(request);
            String json = objectMapper.writeValueAsString(dto);
            return Base64.getUrlEncoder().encodeToString(
                    json.getBytes(StandardCharsets.UTF_8)
            );
        } catch (JsonProcessingException e) {
            log.error(&quot;Failed to serialize OAuth2AuthorizationRequest&quot;, e);
            return null;
        }
    }

    private OAuth2AuthorizationRequest deserialize(String serialized) {
        try {
            String json = new String(
                    Base64.getUrlDecoder().decode(serialized),
                    StandardCharsets.UTF_8
            );
            OAuth2AuthorizationRequestDto dto = objectMapper.readValue(
                    json, OAuth2AuthorizationRequestDto.class
            );
            return dto.toOAuth2AuthorizationRequest();
        } catch (Exception e) {
            log.error(&quot;Failed to deserialize OAuth2AuthorizationRequest&quot;, e);
            return null;
        }
    }
}</code></pre>
<hr>
<h2 id="2-oauth2authorizationrequestdto-직렬화용-dto">2. OAuth2AuthorizationRequestDto: 직렬화용 DTO</h2>
<p><code>OAuth2AuthorizationRequest</code>는 직렬화가 어렵습니다. 직렬화 가능한 DTO로 변환합니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import java.util.Map;
import java.util.Set;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OAuth2AuthorizationRequestDto {

    private String authorizationUri;
    private String clientId;
    private String redirectUri;
    private String state;
    private Set&lt;String&gt; scopes;
    private Map&lt;String, Object&gt; additionalParameters;
    private String authorizationRequestUri;
    private Map&lt;String, Object&gt; attributes;
    private String responseType;

    /**
     * OAuth2AuthorizationRequest → DTO 변환
     */
    public static OAuth2AuthorizationRequestDto from(OAuth2AuthorizationRequest request) {
        return OAuth2AuthorizationRequestDto.builder()
                .authorizationUri(request.getAuthorizationUri())
                .clientId(request.getClientId())
                .redirectUri(request.getRedirectUri())
                .state(request.getState())
                .scopes(request.getScopes())
                .additionalParameters(request.getAdditionalParameters())
                .authorizationRequestUri(request.getAuthorizationRequestUri())
                .attributes(request.getAttributes())
                .responseType(request.getResponseType().getValue())
                .build();
    }

    /**
     * DTO → OAuth2AuthorizationRequest 변환
     */
    public OAuth2AuthorizationRequest toOAuth2AuthorizationRequest() {
        return OAuth2AuthorizationRequest.authorizationCode()
                .authorizationUri(this.authorizationUri)
                .clientId(this.clientId)
                .redirectUri(this.redirectUri)
                .state(this.state)
                .scopes(this.scopes)
                .additionalParameters(this.additionalParameters)
                .authorizationRequestUri(this.authorizationRequestUri)
                .attributes(attrs -&gt; attrs.putAll(this.attributes))
                .build();
    }
}</code></pre>
<hr>
<h2 id="3-보안-쿠키-설정">3. 보안 쿠키 설정</h2>
<h3 id="쿠키-보안-옵션">쿠키 보안 옵션</h3>
<pre><code class="language-java">ResponseCookie cookie = ResponseCookie.from(name, value)
        .path(&quot;/&quot;)              // 모든 경로에서 접근 가능
        .httpOnly(true)         // JavaScript 접근 차단 (XSS 방어)
        .secure(true)           // HTTPS에서만 전송
        .maxAge(180)            // 3분 (인증 과정 동안만 유효)
        .sameSite(&quot;Lax&quot;)        // Cross-Site 요청 제한 (CSRF 방어)
        .build();</code></pre>
<h3 id="각-옵션-설명">각 옵션 설명</h3>
<table>
<thead>
<tr>
<th>옵션</th>
<th>값</th>
<th>목적</th>
</tr>
</thead>
<tbody><tr>
<td><code>httpOnly</code></td>
<td><code>true</code></td>
<td>JavaScript에서 쿠키 접근 차단 → XSS 공격으로 쿠키 탈취 방지</td>
</tr>
<tr>
<td><code>secure</code></td>
<td><code>true</code></td>
<td>HTTPS에서만 쿠키 전송 → 네트워크 스니핑 방지</td>
</tr>
<tr>
<td><code>sameSite</code></td>
<td><code>Lax</code></td>
<td>일반 링크에서만 쿠키 전송 → CSRF 공격 방지</td>
</tr>
<tr>
<td><code>maxAge</code></td>
<td><code>180</code></td>
<td>3분 후 자동 만료 → 인증 완료 후 자동 정리</td>
</tr>
<tr>
<td><code>path</code></td>
<td><code>/</code></td>
<td>모든 경로에서 쿠키 접근 가능</td>
</tr>
</tbody></table>
<h3 id="samesite-옵션-비교">SameSite 옵션 비교</h3>
<table>
<thead>
<tr>
<th><strong>SameSite 옵션</strong></th>
<th><strong>특징 및 동작 방식</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Strict</strong></td>
<td>• 같은 사이트 요청에서만 쿠키 전송</td>
</tr>
<tr>
<td>• OAuth2 리다이렉트에서 쿠키 전송 안 됨 ❌</td>
<td></td>
</tr>
<tr>
<td><strong>Lax</strong></td>
<td>• 일반 링크 클릭 시 쿠키 전송 (GET 요청)</td>
</tr>
<tr>
<td>• OAuth2 리다이렉트에서 쿠키 전송 됨 ✅</td>
<td></td>
</tr>
<tr>
<td>• POST Form 제출 시 쿠키 전송 안 됨 (CSRF 방어)</td>
<td></td>
</tr>
<tr>
<td><strong>None</strong></td>
<td>• 모든 요청에서 쿠키 전송</td>
</tr>
<tr>
<td>• CSRF 공격에 취약 ⚠️</td>
<td></td>
</tr>
<tr>
<td>• <code>Secure=true</code> 필수</td>
<td></td>
</tr>
</tbody></table>
<p>OAuth2 인증에서는 <strong>Lax</strong>가 최적입니다:</p>
<ul>
<li>카카오에서 리다이렉트로 돌아올 때 쿠키가 전송됨 (GET 요청)</li>
<li>CSRF 공격(POST Form 제출)은 차단됨</li>
</ul>
<hr>
<h2 id="4-properties-설정">4. Properties 설정</h2>
<h3 id="oauth2properties-클래스">Oauth2Properties 클래스</h3>
<pre><code class="language-java">package com.example.global.properties;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = &quot;app.security.oauth2&quot;)
public record Oauth2Properties(
        String authorizedRedirectUri,  // 인증 성공 후 프론트엔드 콜백 URL
        CookieProperties cookie
) {

    public record CookieProperties(
            String authName,          // AuthorizationRequest 쿠키 이름
            String redirectUriName,   // redirect_uri 쿠키 이름
            int maxAgeSeconds,        // 쿠키 만료 시간 (초)
            boolean secure            // HTTPS 전용 여부
    ) {

    }

    /**
     * 쿠키 도메인 추출 (예: &quot;example.com&quot;)
     */
    public String getCookieDomain() {
        try {
            java.net.URI uri = new java.net.URI(authorizedRedirectUri);
            String host = uri.getHost();

            // localhost는 도메인 설정 불필요
            if (host.equals(&quot;localhost&quot;) || host.equals(&quot;127.0.0.1&quot;)) {
                return null;
            }

            // 서브도메인 공유를 위해 메인 도메인 반환
            // api.example.com → example.com
            String[] parts = host.split(&quot;\\\\.&quot;);
            if (parts.length &gt;= 2) {
                return parts[parts.length - 2] + &quot;.&quot; + parts[parts.length - 1];
            }
            return host;
        } catch (Exception e) {
            return null;
        }
    }
}</code></pre>
<h3 id="applicationyml-설정">application.yml 설정</h3>
<pre><code class="language-yaml">app:
  security:
    oauth2:
      # 인증 성공 후 프론트엔드 콜백 URL
      authorized-redirect-uri: https://example.com/oauth2/callback

      cookie:
        auth-name: oauth2_auth_request         # AuthorizationRequest 쿠키 이름
        redirect-uri-name: oauth2_redirect_uri # redirect_uri 쿠키 이름
        max-age-seconds: 180                   # 3분
        secure: true                           # HTTPS 환경 (Prod)

# ==========================================
# 개발 환경 (Local)
# ==========================================
---
spring:
  config:
    activate:
      on-profile: local

app:
  security:
    oauth2:
      authorized-redirect-uri: http://localhost:3000/oauth2/callback
      cookie:
        secure: false  # 로컬 HTTP 환경</code></pre>
<hr>
<h2 id="5-프론트엔드-연동">5. 프론트엔드 연동</h2>
<h3 id="로그인-요청">로그인 요청</h3>
<p>프론트엔드에서 소셜 로그인 버튼 클릭 시:</p>
<pre><code class="language-jsx">// React 예시
const handleKakaoLogin = () =&gt; {
    // 현재 페이지 URL을 redirect_uri로 전달
    const currentPath = window.location.pathname;
    const loginUrl = `${API_BASE_URL}/oauth2/authorization/kakao?redirect_uri=${encodeURIComponent(currentPath)}`;

    window.location.href = loginUrl;
};</code></pre>
<h3 id="콜백-처리">콜백 처리</h3>
<p>인증 성공 후 프론트엔드 콜백 페이지:</p>
<pre><code class="language-jsx">// /oauth2/callback 페이지
import {useEffect} from &#39;react&#39;;
import {useSearchParams, useNavigate} from &#39;react-router-dom&#39;;

function OAuth2Callback() {
    const [searchParams] = useSearchParams();
    const navigate = useNavigate();

    useEffect(() =&gt; {
        const error = searchParams.get(&#39;error&#39;);
        const redirectUri = searchParams.get(&#39;redirect_uri&#39;);

        if (error) {
            // 에러 처리
            const message = searchParams.get(&#39;message&#39;);
            alert(`로그인 실패: ${message}`);
            navigate(&#39;/login&#39;);
            return;
        }

        // 성공 시 원래 페이지로 이동
        // (Refresh Token은 HttpOnly 쿠키로 자동 설정됨)
        navigate(redirectUri || &#39;/&#39;);
    }, [searchParams, navigate]);

    return &lt;div&gt;로그인 처리 중...&lt;/div&gt;;
}</code></pre>
<h3 id="인증-플로우-다이어그램">인증 플로우 다이어그램</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/ce3a7cca-b741-4069-8475-c02da0ae7ebc/image.png" alt=""></p>
<hr>
<h2 id="마치며">마치며</h2>
<p>쿠키 기반 인증 상태 관리를 구현하여 다음을 달성했습니다:</p>
<ol>
<li><strong>Stateless 아키텍처</strong>: 서버 세션 없이 상태 관리</li>
<li><strong>CSRF 방어</strong>: <code>SameSite=Lax</code> + <code>state</code> 파라미터 검증</li>
<li><strong>XSS 방어</strong>: <code>HttpOnly</code> 쿠키로 JavaScript 접근 차단</li>
<li><strong>SPA 호환</strong>: 프론트엔드 redirect_uri 유지</li>
</ol>
<h3 id="보안-체크리스트">보안 체크리스트</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설정</th>
<th>목적</th>
</tr>
</thead>
<tbody><tr>
<td><code>HttpOnly</code></td>
<td><code>true</code></td>
<td>XSS 공격 방어</td>
</tr>
<tr>
<td><code>Secure</code></td>
<td><code>true</code> (운영)</td>
<td>HTTPS 강제</td>
</tr>
<tr>
<td><code>SameSite</code></td>
<td><code>Lax</code></td>
<td>CSRF 공격 방어</td>
</tr>
<tr>
<td><code>maxAge</code></td>
<td><code>180초</code></td>
<td>불필요한 쿠키 자동 정리</td>
</tr>
<tr>
<td><code>state</code> 검증</td>
<td>Spring Security 자동</td>
<td>CSRF 공격 방어</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring OAuth2 사용자 정보 통합 - Factory Pattern으로 Provider별 응답 파싱하기]]></title>
            <link>https://velog.io/@co-vol/Spring-OAuth2-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%ED%86%B5%ED%95%A9-Factory-Pattern%EC%9C%BC%EB%A1%9C-Provider%EB%B3%84-%EC%9D%91%EB%8B%B5-%ED%8C%8C%EC%8B%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Spring-OAuth2-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EB%B3%B4-%ED%86%B5%ED%95%A9-Factory-Pattern%EC%9C%BC%EB%A1%9C-Provider%EB%B3%84-%EC%9D%91%EB%8B%B5-%ED%8C%8C%EC%8B%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 12 Jan 2026 01:04:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>카카오, 네이버, 구글은 각각 다른 JSON 응답 구조를 가집니다. Factory Pattern을 활용하여 Provider별 응답을 통합된 DTO로 변환하는 시스템을 구축합니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@co-vol/Spring-OAuth2-%EB%A9%80%ED%8B%B0-Provider-%EC%A7%80%EC%9B%90-Strategy-Pattern%EC%9C%BC%EB%A1%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%84%A4%EC%9D%B4%EB%B2%84-%EA%B5%AC%EA%B8%80-%ED%86%B5%ED%95%A9">이전 글</a>에서 Strategy Pattern으로 Provider별 로직을 캡슐화했습니다. 이번에는 각 Provider의 <strong>서로 다른 응답 구조</strong>를 어떻게 통합하는지 다룹니다.</p>
<h3 id="문제-provider마다-다른-응답-구조">문제: Provider마다 다른 응답 구조</h3>
<pre><code class="language-json">// 카카오 응답 - 중첩 구조
{
  &quot;id&quot;: 123456789,
  &quot;kakao_account&quot;: {
    &quot;email&quot;: &quot;user@kakao.com&quot;,
    &quot;profile&quot;: {
      &quot;nickname&quot;: &quot;홍길동&quot;,
      &quot;profile_image_url&quot;: &quot;https://...&quot;
    }
  }
}

// 네이버 응답 - response 래퍼
{
  &quot;resultcode&quot;: &quot;00&quot;,
  &quot;message&quot;: &quot;success&quot;,
  &quot;response&quot;: {
    &quot;id&quot;: &quot;abcd1234&quot;,
    &quot;email&quot;: &quot;user@naver.com&quot;,
    &quot;nickname&quot;: &quot;홍길동&quot;
  }
}

// 구글 응답 - 플랫 구조
{
  &quot;sub&quot;: &quot;1234567890&quot;,
  &quot;email&quot;: &quot;user@gmail.com&quot;,
  &quot;name&quot;: &quot;홍길동&quot;,
  &quot;picture&quot;: &quot;https://...&quot;
}</code></pre>
<p>이 세 가지를 하나의 통합된 형태로 변환해야 합니다:</p>
<pre><code class="language-java">OAuth2UserInfo {
    providerId: &quot;123456789&quot;
    provider: KAKAO
    email: &quot;user@kakao.com&quot;
    nickname: &quot;홍길동&quot;
    profileImageUrl: &quot;https://...&quot;
}</code></pre>
<hr>
<h2 id="1-oauth2userinfo-통합-dto-설계">1. OAuth2UserInfo: 통합 DTO 설계</h2>
<p>모든 Provider의 사용자 정보를 담는 통합 DTO를 정의합니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.dto;

import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.Builder;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.regex.Pattern;

@Builder
public record OAuth2UserInfo(
    String providerId,           // Provider 내 고유 ID
    OAuth2ProviderType provider, // Provider 타입
    String email,
    String name,                 // 실명 (비즈앱 전용)
    String nickname,
    String phoneNumber,
    String profileImageUrl,
    Map&lt;String, Object&gt; attributes  // 원본 응답 전체
) {
    // 한국 국가코드 패턴
    private static final Pattern KOREA_CODE = Pattern.compile(&quot;^82&quot;);
    private static final Pattern NON_DIGIT = Pattern.compile(&quot;\\\\D&quot;);

    /**
     * 표시 이름을 반환합니다.
     * 우선순위: nickname → name → email 로컬파트 → username
     */
    public String getDisplayName() {
        if (StringUtils.hasText(nickname)) {
            return nickname;
        }
        if (StringUtils.hasText(name)) {
            return name;
        }
        if (StringUtils.hasText(email) &amp;&amp; email.contains(&quot;@&quot;)) {
            return email.substring(0, email.indexOf(&#39;@&#39;));
        }
        return getUsername();
    }

    /**
     * 시스템 내 고유 username을 반환합니다.
     * 형식: &quot;PROVIDER_providerId&quot; (예: &quot;KAKAO_123456&quot;)
     */
    public String getUsername() {
        return provider.name() + &quot;_&quot; + providerId;
    }

    /**
     * 전화번호를 정규화합니다.
     * - 숫자만 추출
     * - 한국 국가코드(82) → 0으로 변환
     * 예: &quot;+82 10-1234-5678&quot; → &quot;01012345678&quot;
     */
    public String getNormalizedPhoneNumber() {
        if (!StringUtils.hasText(phoneNumber)) {
            return null;
        }

        String digitsOnly = NON_DIGIT.matcher(phoneNumber).replaceAll(&quot;&quot;);
        if (digitsOnly.isEmpty()) {
            return null;
        }

        return KOREA_CODE.matcher(digitsOnly).replaceFirst(&quot;0&quot;);
    }
}</code></pre>
<h3 id="설계-포인트">설계 포인트</h3>
<table>
<thead>
<tr>
<th>필드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>providerId</code></td>
<td>Provider 내 고유 ID (카카오 id, 네이버 id 등)</td>
</tr>
<tr>
<td><code>provider</code></td>
<td>Provider 타입 열거형</td>
</tr>
<tr>
<td><code>attributes</code></td>
<td>원본 응답 전체 (디버깅/확장용)</td>
</tr>
<tr>
<td><code>getUsername()</code></td>
<td>시스템 내 고유 식별자 생성</td>
</tr>
<tr>
<td><code>getNormalizedPhoneNumber()</code></td>
<td>전화번호 정규화</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-oauth2userinfoextractor-인터페이스">2. OAuth2UserInfoExtractor 인터페이스</h2>
<p>Provider별 추출 로직을 추상화하는 인터페이스입니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.userinfo;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;

public interface OAuth2UserInfoExtractor {

    /**
     * 이 Extractor가 담당하는 Provider 타입
     */
    OAuth2ProviderType getProviderType();

    /**
     * OIDC 모드: ID Token + UserInfo에서 추출
     */
    OAuth2UserInfo extract(OidcUser oidcUser);

    /**
     * OAuth2 모드: UserInfo Endpoint 응답에서만 추출
     */
    OAuth2UserInfo extract(OAuth2User oauth2User);

    /**
     * 지원 여부 확인
     */
    default boolean supports(String registrationId) {
        return getProviderType().getRegistrationId().equals(registrationId);
    }
}</code></pre>
<h3 id="oidc-vs-oauth2-모드">OIDC vs OAuth2 모드</h3>
<ul>
<li><strong>OIDC 모드</strong>: ID Token claims와 UserInfo를 조합 (더 신뢰성 높음)</li>
<li><strong>OAuth2 모드</strong>: UserInfo Endpoint 응답만 사용 (네이버 등 OIDC 미지원 Provider용)</li>
</ul>
<hr>
<h2 id="3-oauth2attributes-중첩-map-탐색-유틸리티">3. OAuth2Attributes: 중첩 Map 탐색 유틸리티</h2>
<p>OAuth2 응답은 대부분 중첩된 Map 구조입니다. 이를 쉽게 탐색하는 유틸리티를 만듭니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.userinfo;

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.Map;
import java.util.function.Function;

@Slf4j
public record OAuth2Attributes(Map&lt;String, Object&gt; attributes) {

    public OAuth2Attributes(Map&lt;String, Object&gt; attributes) {
        this.attributes = attributes != null ? attributes : Collections.emptyMap();
    }

    // ==================== Factory Methods ====================

    public static OAuth2Attributes of(Map&lt;String, Object&gt; attributes) {
        return new OAuth2Attributes(attributes);
    }

    public static OAuth2Attributes empty() {
        return new OAuth2Attributes(Collections.emptyMap());
    }

    // ==================== Navigation ====================

    /**
     * 중첩된 Map을 OAuth2Attributes로 래핑하여 반환
     * 존재하지 않으면 빈 OAuth2Attributes 반환
     *
     * 예: getChild(&quot;kakao_account&quot;).getChild(&quot;profile&quot;)
     */
    public OAuth2Attributes getChild(String key) {
        return new OAuth2Attributes(getMapInternal(key));
    }

    /**
     * 점(.) 표기법으로 깊은 탐색
     *
     * 예: getPath(&quot;kakao_account.profile.nickname&quot;)
     */
    public OAuth2Attributes getPath(String path) {
        if (!StringUtils.hasText(path)) {
            return this;
        }

        OAuth2Attributes current = this;
        for (String key : path.split(&quot;\\\\.&quot;)) {
            current = current.getChild(key);
            if (current.isEmpty()) {
                return OAuth2Attributes.empty();
            }
        }
        return current;
    }

    // ==================== Primitive Getters ====================

    public String getString(String key) {
        Object value = attributes.get(key);
        return value != null ? String.valueOf(value) : null;
    }

    public Long getLong(String key) {
        Object value = attributes.get(key);
        return switch (value) {
            case null -&gt; null;
            case Long l -&gt; l;
            case Number n -&gt; n.longValue();
            default -&gt; parseLongSafely(String.valueOf(value));
        };
    }

    public Boolean getBoolean(String key) {
        Object value = attributes.get(key);
        return switch (value) {
            case null -&gt; null;
            case Boolean b -&gt; b;
            default -&gt; Boolean.parseBoolean(String.valueOf(value));
        };
    }

    // ==================== Custom Mapping ====================

    /**
     * 값을 커스텀 함수로 변환
     */
    public &lt;T&gt; T map(String key, Function&lt;String, T&gt; mapper) {
        String value = getString(key);
        if (!StringUtils.hasText(value)) {
            return null;
        }

        try {
            return mapper.apply(value);
        } catch (Exception e) {
            log.debug(&quot;Failed to map value &#39;{}&#39; for key &#39;{}&#39;&quot;, value, key);
            return null;
        }
    }

    // ==================== Utility ====================

    public boolean has(String key) {
        return attributes.containsKey(key) &amp;&amp; attributes.get(key) != null;
    }

    public boolean isEmpty() {
        return attributes.isEmpty();
    }

    // ==================== Internal ====================

    @SuppressWarnings(&quot;unchecked&quot;)
    private Map&lt;String, Object&gt; getMapInternal(String key) {
        Object value = attributes.get(key);
        if (value instanceof Map) {
            return (Map&lt;String, Object&gt;) value;
        }
        return Collections.emptyMap();
    }

    private Long parseLongSafely(String value) {
        try {
            return Long.parseLong(value);
        } catch (NumberFormatException e) {
            log.debug(&quot;Failed to parse Long: &#39;{}&#39;&quot;, value);
            return null;
        }
    }
}</code></pre>
<h3 id="사용-예시">사용 예시</h3>
<pre><code class="language-java">// Before: 전통적인 방식 (NullPointerException 위험!)
Map&lt;String, Object&gt; account = (Map&lt;String, Object&gt;) attributes.get(&quot;kakao_account&quot;);
Map&lt;String, Object&gt; profile = (Map&lt;String, Object&gt;) account.get(&quot;profile&quot;);
String nickname = (String) profile.get(&quot;nickname&quot;);

// After: OAuth2Attributes 사용 (안전하고 깔끔!)
OAuth2Attributes attrs = OAuth2Attributes.of(attributes);
String nickname = attrs.getChild(&quot;kakao_account&quot;)
                       .getChild(&quot;profile&quot;)
                       .getString(&quot;nickname&quot;);

// 또는 점 표기법으로 한 줄에
String nickname = attrs.getPath(&quot;kakao_account.profile.nickname&quot;)
                       .getString(&quot;nickname&quot;);</code></pre>
<hr>
<h2 id="4-factory-pattern으로-extractor-관리">4. Factory Pattern으로 Extractor 관리</h2>
<p>Spring DI를 활용하여 모든 Extractor를 자동으로 수집하고 관리합니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.userinfo;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Component
public class OAuth2UserInfoExtractorFactory {

    private final Map&lt;OAuth2ProviderType, OAuth2UserInfoExtractor&gt; extractorMap;

    /**
     * 생성자에서 모든 Extractor를 수집하여 Map으로 변환
     * Spring이 OAuth2UserInfoExtractor 구현체를 모두 주입
     */
    public OAuth2UserInfoExtractorFactory(List&lt;OAuth2UserInfoExtractor&gt; extractors) {
        this.extractorMap = extractors.stream()
                .collect(Collectors.toMap(
                        OAuth2UserInfoExtractor::getProviderType,
                        Function.identity()
                ));
    }

    /**
     * Provider 타입으로 Extractor 조회
     */
    public OAuth2UserInfoExtractor getExtractor(OAuth2ProviderType providerType) {
        OAuth2UserInfoExtractor extractor = extractorMap.get(providerType);
        if (extractor == null) {
            throw new IllegalArgumentException(
                &quot;No extractor found for: &quot; + providerType
            );
        }
        return extractor;
    }

    /**
     * registrationId로 Extractor 조회
     */
    public OAuth2UserInfoExtractor getExtractor(String registrationId) {
        return getExtractor(OAuth2ProviderType.from(registrationId));
    }

    /**
     * OAuth2User에서 UserInfo 추출 (OIDC/OAuth2 자동 감지)
     */
    public OAuth2UserInfo extract(String registrationId, OAuth2User oauth2User) {
        OAuth2UserInfoExtractor extractor = getExtractor(registrationId);

        // OIDC User인 경우 OIDC 모드로 추출
        if (oauth2User instanceof OidcUser oidcUser) {
            return extractor.extract(oidcUser);
        }

        // 일반 OAuth2 User인 경우
        return extractor.extract(oauth2User);
    }
}</code></pre>
<h3 id="factory-pattern의-장점">Factory Pattern의 장점</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/dc6d996a-9b7b-4bb4-8cbe-edcff0b8e59d/image.png" alt=""></p>
<ol>
<li><strong>자동 수집</strong>: <code>@Component</code> 붙은 Extractor가 자동으로 등록됨</li>
<li><strong>느슨한 결합</strong>: Factory는 구체 클래스를 모름</li>
<li><strong>확장 용이</strong>: 새 Extractor 추가해도 Factory 수정 불필요</li>
</ol>
<hr>
<h2 id="5-provider별-extractor-구현">5. Provider별 Extractor 구현</h2>
<h3 id="kakaouserinfoextractor">KakaoUserInfoExtractor</h3>
<pre><code class="language-java">package com.example.oauth2.client.kakao;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import com.example.oauth2.client.core.userinfo.OAuth2Attributes;
import com.example.oauth2.client.core.userinfo.OAuth2UserInfoExtractor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Map;

import static org.apache.commons.lang3.ObjectUtils.firstNonNull;

@Component
@Slf4j
public class KakaoUserInfoExtractor implements OAuth2UserInfoExtractor {

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.KAKAO;
    }

    /**
     * OIDC 모드: ID Token claims + attributes 조합
     */
    @Override
    public OAuth2UserInfo extract(OidcUser oidcUser) {
        OidcIdToken idToken = oidcUser.getIdToken();
        Map&lt;String, Object&gt; attributes = oidcUser.getAttributes();
        KakaoUserDetail detail = parseKakaoResponse(attributes);

        return OAuth2UserInfo.builder()
                // ID Token의 sub가 더 신뢰성 있음
                .providerId(firstNonNull(idToken.getSubject(), detail.getProviderId()))
                .provider(OAuth2ProviderType.KAKAO)
                // ID Token claims 우선, 없으면 attributes에서
                .email(firstNonNull(idToken.getEmail(), detail.email()))
                .name(detail.name())
                .nickname(firstNonNull(idToken.getNickName(), detail.nickname()))
                .phoneNumber(detail.phoneNumber())
                .profileImageUrl(firstNonNull(idToken.getPicture(), detail.profileImageUrl()))
                .attributes(attributes)
                .build();
    }

    /**
     * OAuth2 모드: UserInfo Endpoint 응답만 사용
     */
    @Override
    public OAuth2UserInfo extract(OAuth2User oauth2User) {
        Map&lt;String, Object&gt; attributes = oauth2User.getAttributes();
        KakaoUserDetail detail = parseKakaoResponse(attributes);

        return OAuth2UserInfo.builder()
                .providerId(detail.getProviderId())
                .provider(OAuth2ProviderType.KAKAO)
                .email(detail.email())
                .name(detail.name())
                .nickname(detail.nickname())
                .phoneNumber(detail.phoneNumber())
                .profileImageUrl(detail.profileImageUrl())
                .attributes(attributes)
                .build();
    }

    /**
     * 카카오 응답 파싱
     */
    private KakaoUserDetail parseKakaoResponse(Map&lt;String, Object&gt; attributes) {
        OAuth2Attributes root = OAuth2Attributes.of(attributes);
        OAuth2Attributes account = root.getChild(&quot;kakao_account&quot;);
        OAuth2Attributes profile = account.getChild(&quot;profile&quot;);

        return KakaoUserDetail.builder()
                .id(root.getLong(&quot;id&quot;))
                .nickname(profile.getString(&quot;nickname&quot;))
                .profileImageUrl(profile.getString(&quot;profile_image_url&quot;))
                .name(account.getString(&quot;name&quot;))
                .email(account.getString(&quot;email&quot;))
                .phoneNumber(account.getString(&quot;phone_number&quot;))
                .build();
    }
}</code></pre>
<h3 id="kakaouserdetail-응답-dto">KakaoUserDetail (응답 DTO)</h3>
<pre><code class="language-java">package com.example.oauth2.client.kakao;

import lombok.Builder;

@Builder
public record KakaoUserDetail(
    Long id,
    String nickname,
    String profileImageUrl,
    String name,        // 실명 (비즈앱 전용)
    String email,
    String phoneNumber  // 비즈앱 전용
) {
    public String getProviderId() {
        return id != null ? String.valueOf(id) : null;
    }
}</code></pre>
<h3 id="naveruserinfoextractor">NaverUserInfoExtractor</h3>
<pre><code class="language-java">package com.example.oauth2.client.naver;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import com.example.oauth2.client.core.userinfo.OAuth2Attributes;
import com.example.oauth2.client.core.userinfo.OAuth2UserInfoExtractor;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Map;

@Component
public class NaverUserInfoExtractor implements OAuth2UserInfoExtractor {

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.NAVER;
    }

    @Override
    public OAuth2UserInfo extract(OidcUser oidcUser) {
        // 네이버는 OIDC 미지원, OAuth2 모드로 폴백
        return extract((OAuth2User) oidcUser);
    }

    @Override
    public OAuth2UserInfo extract(OAuth2User oauth2User) {
        Map&lt;String, Object&gt; attributes = oauth2User.getAttributes();

        // 네이버 응답은 &quot;response&quot; 객체 안에 실제 데이터가 있음
        OAuth2Attributes response = OAuth2Attributes.of(attributes)
                .getChild(&quot;response&quot;);

        return OAuth2UserInfo.builder()
                .providerId(response.getString(&quot;id&quot;))
                .provider(OAuth2ProviderType.NAVER)
                .email(response.getString(&quot;email&quot;))
                .name(response.getString(&quot;name&quot;))
                .nickname(response.getString(&quot;nickname&quot;))
                .phoneNumber(response.getString(&quot;mobile&quot;))
                .profileImageUrl(response.getString(&quot;profile_image&quot;))
                .attributes(attributes)
                .build();
    }
}</code></pre>
<h3 id="googleuserinfoextractor">GoogleUserInfoExtractor</h3>
<pre><code class="language-java">package com.example.oauth2.client.google;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import com.example.oauth2.client.core.userinfo.OAuth2Attributes;
import com.example.oauth2.client.core.userinfo.OAuth2UserInfoExtractor;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Map;

@Component
public class GoogleUserInfoExtractor implements OAuth2UserInfoExtractor {

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.GOOGLE;
    }

    @Override
    public OAuth2UserInfo extract(OidcUser oidcUser) {
        OidcIdToken idToken = oidcUser.getIdToken();

        return OAuth2UserInfo.builder()
                .providerId(idToken.getSubject())
                .provider(OAuth2ProviderType.GOOGLE)
                .email(idToken.getEmail())
                .name(idToken.getFullName())
                .nickname(idToken.getGivenName())
                .profileImageUrl(idToken.getPicture())
                .attributes(oidcUser.getAttributes())
                .build();
    }

    @Override
    public OAuth2UserInfo extract(OAuth2User oauth2User) {
        OAuth2Attributes attrs = OAuth2Attributes.of(oauth2User.getAttributes());

        return OAuth2UserInfo.builder()
                .providerId(attrs.getString(&quot;sub&quot;))
                .provider(OAuth2ProviderType.GOOGLE)
                .email(attrs.getString(&quot;email&quot;))
                .name(attrs.getString(&quot;name&quot;))
                .nickname(attrs.getString(&quot;given_name&quot;))
                .profileImageUrl(attrs.getString(&quot;picture&quot;))
                .attributes(oauth2User.getAttributes())
                .build();
    }
}</code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p>Factory Pattern을 적용하여 다음을 달성했습니다:</p>
<ol>
<li><strong>통합 DTO</strong>: 모든 Provider의 응답을 <code>OAuth2UserInfo</code>로 통합</li>
<li><strong>안전한 파싱</strong>: <code>OAuth2Attributes</code>로 NullPointerException 방지</li>
<li><strong>자동 확장</strong>: Spring DI로 새 Extractor 자동 등록</li>
<li><strong>OIDC/OAuth2 자동 감지</strong>: Factory가 적절한 추출 모드 선택</li>
</ol>
<h3 id="아키텍처-요약">아키텍처 요약</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/a56ad468-583d-4537-84af-1f53b90bcb10/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring OAuth2 멀티 Provider 지원 - Strategy Pattern으로 카카오, 네이버, 구글 통합]]></title>
            <link>https://velog.io/@co-vol/Spring-OAuth2-%EB%A9%80%ED%8B%B0-Provider-%EC%A7%80%EC%9B%90-Strategy-Pattern%EC%9C%BC%EB%A1%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%84%A4%EC%9D%B4%EB%B2%84-%EA%B5%AC%EA%B8%80-%ED%86%B5%ED%95%A9</link>
            <guid>https://velog.io/@co-vol/Spring-OAuth2-%EB%A9%80%ED%8B%B0-Provider-%EC%A7%80%EC%9B%90-Strategy-Pattern%EC%9C%BC%EB%A1%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%84%A4%EC%9D%B4%EB%B2%84-%EA%B5%AC%EA%B8%80-%ED%86%B5%ED%95%A9</guid>
            <pubDate>Thu, 08 Jan 2026 04:34:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>카카오, 네이버, 구글 소셜 로그인을 하나의 아키텍처로 통합합니다. Strategy Pattern을 활용하여 새로운 Provider를 추가할 때 기존 코드 수정 없이 확장할 수 있는 구조를 설계합니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p><a href="https://velog.io/@co-vol/Spring-Boot-3-OAuth2-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84">이전 글</a>에서 카카오 OAuth2 로그인의 기초를 다뤘습니다. 하지만 실제 서비스에서는 네이버, 구글, 애플 등 여러 소셜 로그인을 지원해야 합니다.</p>
<p>문제는 각 Provider마다 <strong>응답 구조가 다르다</strong>는 것입니다:</p>
<pre><code class="language-java">// 카카오 응답
{
  &quot;id&quot;:123456789,
  &quot;kakao_account&quot;:{
      &quot;email&quot;:&quot;user@kakao.com&quot;,
      &quot;profile&quot;:{&quot;nickname&quot;:&quot;홍길동&quot;}
  }
}

// 네이버 응답
{
    &quot;response&quot;:{
        &quot;id&quot;:&quot;abcd1234&quot;,
        &quot;email&quot;:&quot;user@naver.com&quot;,
        &quot;nickname&quot;:&quot;홍길동&quot;
    }
}

// 구글 응답
{
    &quot;sub&quot;:&quot;1234567890&quot;,
    &quot;email&quot;:&quot;user@gmail.com&quot;,
    &quot;name&quot;:&quot;홍길동&quot;
}</code></pre>
<p>이런 상황에서 <code>if-else</code> 분기문으로 처리하면 어떻게 될까요?</p>
<pre><code class="language-java">// 안티패턴: if-else 지옥
if(provider.equals(&quot;kakao&quot;)){
// 카카오 파싱 로직
}else if(provider.equals(&quot;naver&quot;)){
// 네이버 파싱 로직
}else if(provider.equals(&quot;google&quot;)){
// 구글 파싱 로직
}else if(provider.equals(&quot;apple&quot;)){
// ... 끝없이 증가
}</code></pre>
<p>새 Provider가 추가될 때마다 기존 코드를 수정해야 하고, 한 파일이 비대해집니다. <strong>OCP(Open-Closed Principle)</strong> 를 위반하는 대표적인 사례입니다.</p>
<p>이 글에서는 <strong>Strategy Pattern</strong>을 활용하여 이 문제를 해결합니다.</p>
<hr>
<h2 id="1-왜-strategy-pattern인가">1. 왜 Strategy Pattern인가?</h2>
<h3 id="strategy-pattern이란">Strategy Pattern이란?</h3>
<p>Strategy Pattern은 <strong>행위(알고리즘)를 캡슐화</strong>하여 런타임에 교체할 수 있게 하는 디자인 패턴입니다.</p>
<p><img src="https://velog.velcdn.com/images/co-vol/post/69112eb6-cb90-4c34-a1a1-7bd522124639/image.png" alt=""></p>
<h3 id="적용-시-장점">적용 시 장점</h3>
<table>
<thead>
<tr>
<th>장점</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>OCP 준수</strong></td>
<td>새 Provider 추가 시 기존 코드 수정 불필요</td>
</tr>
<tr>
<td><strong>단일 책임</strong></td>
<td>각 Strategy가 하나의 Provider만 담당</td>
</tr>
<tr>
<td><strong>테스트 용이</strong></td>
<td>Provider별 독립적인 단위 테스트 가능</td>
</tr>
<tr>
<td><strong>런타임 교체</strong></td>
<td>동적으로 Strategy 선택 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-oauth2providertype-열거형-설계">2. OAuth2ProviderType 열거형 설계</h2>
<p>먼저 지원하는 Provider를 열거형으로 정의합니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.provider;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;

@Getter
@RequiredArgsConstructor
public enum OAuth2ProviderType {
    KAKAO(&quot;kakao&quot;, true, &quot;&lt;https://kauth.kakao.com&gt;&quot;),
    NAVER(&quot;naver&quot;, false, null),
    GOOGLE(&quot;google&quot;, true, &quot;&lt;https://accounts.google.com&gt;&quot;);

    private final String registrationId;  // application.yml의 registration 이름
    private final boolean oidcSupported;  // OIDC 지원 여부
    private final String issuerUri;       // OIDC issuer URI

    /**
     * registrationId로 Provider 타입을 찾습니다.
     */
    public static OAuth2ProviderType from(String registrationId) {
        return Arrays.stream(values())
                .filter(type -&gt; type.registrationId.equals(registrationId))
                .findFirst()
                .orElseThrow(() -&gt; new IllegalArgumentException(
                        &quot;Unsupported provider: &quot; + registrationId
                ));
    }
}</code></pre>
<h3 id="설계-포인트">설계 포인트</h3>
<ol>
<li><strong>oidcSupported</strong>: OIDC 지원 여부를 명시합니다. 카카오/구글은 OIDC를 지원하지만, 네이버는 OAuth2만 지원한다고 가정합니다.</li>
<li><strong>issuerUri</strong>: OIDC ID Token의 issuer 검증에 사용합니다.</li>
<li><strong>from() 메서드</strong>: Spring Security의 registrationId로 열거형을 찾습니다.</li>
</ol>
<hr>
<h2 id="3-oauth2providerstrategy-인터페이스">3. OAuth2ProviderStrategy 인터페이스</h2>
<p>Provider별 로직을 추상화하는 Strategy 인터페이스를 정의합니다.</p>
<pre><code class="language-java">package com.example.oauth2.client.core.provider;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Set;

public interface OAuth2ProviderStrategy {

    /**
     * 이 Strategy가 담당하는 Provider 타입
     */
    OAuth2ProviderType getProviderType();

    /**
     * OAuth2User에서 사용자 정보를 추출합니다.
     * @param oauth2User OAuth2/OIDC 인증된 사용자
     * @param idToken OIDC ID Token (OIDC 모드일 때만 non-null)
     * @return 정규화된 사용자 정보
     */
    OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken);

    /**
     * ID Token 검증 (OIDC Provider만 구현)
     * 기본 구현은 아무것도 하지 않음
     */
    default void validateToken(OidcIdToken idToken) {
        // 기본값: 검증 없음 (OAuth2 전용 Provider용)
    }

    /**
     * 이 Provider에 필요한 scope 목록
     */
    Set&lt;String&gt; getRequiredScopes();
}</code></pre>
<h3 id="인터페이스-설계-원칙">인터페이스 설계 원칙</h3>
<ol>
<li><strong>최소 인터페이스</strong>: 모든 Provider가 구현해야 할 최소한의 메서드만 정의</li>
<li><strong>기본 구현 제공</strong>: <code>validateToken()</code>은 OIDC Provider만 필요하므로 기본 구현 제공</li>
<li><strong>타입 안전성</strong>: <code>OAuth2ProviderType</code>으로 Provider를 식별</li>
</ol>
<hr>
<h2 id="4-provider별-구체-클래스-구현">4. Provider별 구체 클래스 구현</h2>
<h3 id="kakaooauth2strategy-oidc-지원">KakaoOAuth2Strategy (OIDC 지원)</h3>
<pre><code class="language-java">package com.example.oauth2.client.kakao;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderStrategy;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Set;

@Component
@RequiredArgsConstructor
public class KakaoOAuth2Strategy implements OAuth2ProviderStrategy {

    private final KakaoUserInfoExtractor userInfoExtractor;
    private final OAuth2ClientProperties oauth2Properties;

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.KAKAO;
    }

    @Override
    public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
        // OIDC 모드 (ID Token이 있는 경우)
        if (idToken != null &amp;&amp; oauth2User instanceof OidcUser oidcUser) {
            return userInfoExtractor.extract(oidcUser);
        }
        // OAuth2 모드 (fallback)
        return userInfoExtractor.extract(oauth2User);
    }

    @Override
    public void validateToken(OidcIdToken idToken) {
        if (idToken == null) return;

        // 1. Issuer 검증
        String issuer = idToken.getIssuer().toString();
        String expectedIssuer = oauth2Properties.getProvider()
                .get(&quot;kakao&quot;).getIssuerUri();

        if (!issuer.equals(expectedIssuer)) {
            throw new OAuth2AuthenticationException(
                    new OAuth2Error(&quot;invalid_token&quot;,
                            &quot;Invalid Kakao issuer: &quot; + issuer, null)
            );
        }

        // 2. Audience 검증 (client_id와 일치해야 함)
        String clientId = oauth2Properties.getRegistration()
                .get(&quot;kakao&quot;).getClientId();

        if (!idToken.getAudience().contains(clientId)) {
            throw new OAuth2AuthenticationException(
                    new OAuth2Error(&quot;invalid_token&quot;,
                            &quot;Invalid Kakao audience&quot;, null)
            );
        }
    }

    @Override
    public Set&lt;String&gt; getRequiredScopes() {
        return Set.of(
                &quot;openid&quot;,           // OIDC 필수
                &quot;profile_nickname&quot;,
                &quot;profile_image&quot;,
                &quot;account_email&quot;,
                &quot;phone_number&quot;
        );
    }
}</code></pre>
<h3 id="naveroauth2strategy-oauth2만-지원">NaverOAuth2Strategy (OAuth2만 지원)</h3>
<pre><code class="language-java">package com.example.oauth2.client.naver;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderStrategy;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Set;

@Component
@RequiredArgsConstructor
public class NaverOAuth2Strategy implements OAuth2ProviderStrategy {

    private final NaverUserInfoExtractor userInfoExtractor;

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.NAVER;
    }

    @Override
    public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
        // 네이버는 OIDC 미지원, OAuth2 모드만 사용
        return userInfoExtractor.extract(oauth2User);
    }

    // validateToken()은 기본 구현 사용 (아무것도 안 함)

    @Override
    public Set&lt;String&gt; getRequiredScopes() {
        return Set.of(&quot;name&quot;, &quot;email&quot;, &quot;profile_image&quot;);
    }
}</code></pre>
<h3 id="googleoauth2strategy-oidc-지원">GoogleOAuth2Strategy (OIDC 지원)</h3>
<pre><code class="language-java">package com.example.oauth2.client.google;

import com.example.oauth2.client.core.dto.OAuth2UserInfo;
import com.example.oauth2.client.core.provider.OAuth2ProviderStrategy;
import com.example.oauth2.client.core.provider.OAuth2ProviderType;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import java.util.Set;

@Component
@RequiredArgsConstructor
public class GoogleOAuth2Strategy implements OAuth2ProviderStrategy {

    private final GoogleUserInfoExtractor userInfoExtractor;

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.GOOGLE;
    }

    @Override
    public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
        if (idToken != null &amp;&amp; oauth2User instanceof OidcUser oidcUser) {
            return userInfoExtractor.extract(oidcUser);
        }
        return userInfoExtractor.extract(oauth2User);
    }

    @Override
    public Set&lt;String&gt; getRequiredScopes() {
        return Set.of(&quot;openid&quot;, &quot;profile&quot;, &quot;email&quot;);
    }
}</code></pre>
<hr>
<h2 id="5-새로운-provider-추가하기-확장-예제">5. 새로운 Provider 추가하기 (확장 예제)</h2>
<p>Apple 로그인을 추가한다고 가정해봅시다. 기존 코드를 <strong>전혀 수정하지 않고</strong> 다음 단계만 진행하면 됩니다.</p>
<h3 id="step-1-oauth2providertype에-열거값-추가">Step 1: OAuth2ProviderType에 열거값 추가</h3>
<pre><code class="language-java">public enum OAuth2ProviderType {
    KAKAO(&quot;kakao&quot;, true, &quot;&lt;https://kauth.kakao.com&gt;&quot;),
    NAVER(&quot;naver&quot;, false, null),
    GOOGLE(&quot;google&quot;, true, &quot;&lt;https://accounts.google.com&gt;&quot;),
    APPLE(&quot;apple&quot;, true, &quot;&lt;https://appleid.apple.com&gt;&quot;);  // 추가!

    // ... 나머지 동일
}</code></pre>
<h3 id="step-2-appleuserinfoextractor-구현">Step 2: AppleUserInfoExtractor 구현</h3>
<pre><code class="language-java">
@Component
public class AppleUserInfoExtractor implements OAuth2UserInfoExtractor {

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.APPLE;
    }

    @Override
    public OAuth2UserInfo extract(OidcUser oidcUser) {
        OidcIdToken idToken = oidcUser.getIdToken();

        return OAuth2UserInfo.builder()
                .providerId(idToken.getSubject())
                .provider(OAuth2ProviderType.APPLE)
                .email(idToken.getEmail())
                .name(oidcUser.getAttribute(&quot;name&quot;))
                .attributes(oidcUser.getAttributes())
                .build();
    }

    @Override
    public OAuth2UserInfo extract(OAuth2User oauth2User) {
        // Apple은 OIDC 전용
        throw new UnsupportedOperationException(&quot;Apple requires OIDC&quot;);
    }
}</code></pre>
<h3 id="step-3-appleoauth2strategy-구현">Step 3: AppleOAuth2Strategy 구현</h3>
<pre><code class="language-java">
@Component
@RequiredArgsConstructor
public class AppleOAuth2Strategy implements OAuth2ProviderStrategy {

    private final AppleUserInfoExtractor userInfoExtractor;

    @Override
    public OAuth2ProviderType getProviderType() {
        return OAuth2ProviderType.APPLE;
    }

    @Override
    public OAuth2UserInfo extractUserInfo(OAuth2User oauth2User, OidcIdToken idToken) {
        if (!(oauth2User instanceof OidcUser oidcUser)) {
            throw new OAuth2AuthenticationException(
                    new OAuth2Error(&quot;invalid_request&quot;, &quot;Apple requires OIDC&quot;, null)
            );
        }
        return userInfoExtractor.extract(oidcUser);
    }

    @Override
    public Set&lt;String&gt; getRequiredScopes() {
        return Set.of(&quot;openid&quot;, &quot;name&quot;, &quot;email&quot;);
    }
}</code></pre>
<h3 id="step-4-applicationyml-설정">Step 4: application.yml 설정</h3>
<pre><code class="language-yaml">spring:
    security:
        oauth2:
            client:
                registration:
                    apple:
                        client-id: ${APPLE_CLIENT_ID}
                        client-secret: ${APPLE_CLIENT_SECRET}
                        redirect-uri: &quot;{baseUrl}/login/oauth2/code/{registrationId}&quot;
                        authorization-grant-type: authorization_code
                        scope: openid, name, email
                provider:
                    apple:
                        issuer-uri: &lt;https://appleid.apple.com&gt;
                        authorization-uri: &lt;https://appleid.apple.com/auth/authorize&gt;
                        token-uri: &lt;https://appleid.apple.com/auth/token&gt;</code></pre>
<p><strong>끝입니다!</strong> 기존의 <code>OAuth2UserService</code>, <code>SuccessHandler</code> 등은 전혀 수정할 필요가 없습니다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>Strategy Pattern을 적용하여 다음을 달성했습니다:</p>
<ol>
<li><strong>OCP 준수</strong>: 새 Provider 추가 시 기존 코드 수정 불필요</li>
<li><strong>단일 책임</strong>: 각 Strategy가 하나의 Provider만 담당</li>
<li><strong>테스트 용이</strong>: Provider별 독립적인 단위 테스트 가능</li>
<li><strong>확장성</strong>: Apple, Facebook 등 새 Provider를 쉽게 추가</li>
</ol>
<h3 id="핵심-정리">핵심 정리</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/a0676b90-0072-4243-8f4b-a411ff6cabc2/image.png" alt=""></p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 3 OAuth2 소셜 로그인 가이드 - 카카오 로그인 구현]]></title>
            <link>https://velog.io/@co-vol/Spring-Boot-3-OAuth2-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@co-vol/Spring-Boot-3-OAuth2-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Wed, 07 Jan 2026 05:35:53 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Spring Boot 3와 Spring Security 6를 활용한 카카오 OAuth2 소셜 로그인 구현 방법을 단계별로 알아봅니다. OAuth2와 OIDC의 차이점부터 실제 구현까지 다룹니다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>최근 대부분의 서비스에서 소셜 로그인은 필수가 되었습니다. 사용자는 복잡한 회원가입 과정 없이 카카오, 네이버, 구글 계정으로 간편하게 로그인할 수 있고, 서비스 제공자는 인증 보안을 OAuth2 Provider에게 위임할 수 있습니다.</p>
<p>이 글에서는 <strong>Spring Boot 3</strong>와 <strong>Spring Security 6</strong>를 사용하여 카카오 소셜 로그인을 구현하는 방법을 다룹니다. 특히 OAuth2와 OIDC의 차이점, 그리고 왜 OIDC를 선택해야 하는지까지 설명합니다.</p>
<hr>
<h2 id="1-oauth2와-oidc의-차이점-이해하기">1. OAuth2와 OIDC의 차이점 이해하기</h2>
<h3 id="oauth2란">OAuth2란?</h3>
<p><strong>OAuth2</strong>(Open Authorization 2.0)는 <strong>권한 부여(Authorization)</strong> 프로토콜입니다. 사용자가 자신의 리소스(프로필, 이메일 등)에 대한 접근 권한을 제3자 애플리케이션에 부여할 수 있게 해줍니다.</p>
<pre><code>[사용자] → [우리 서비스] → [카카오]
          &quot;카카오 프로필 정보 좀 볼게요&quot;
                    ↓
          [카카오가 Access Token 발급]
                    ↓
          [Access Token으로 UserInfo API 호출]</code></pre><h3 id="oidc란">OIDC란?</h3>
<p><strong>OIDC</strong>(OpenID Connect)는 OAuth2 위에 구축된 <strong>인증(Authentication)</strong> 레이어입니다. OAuth2가 &quot;이 사용자가 권한을 부여했다&quot;만 알려준다면, OIDC는 &quot;이 사용자가 누구인지&quot;까지 표준화된 방식으로 알려줍니다.</p>
<pre><code>[사용자] → [우리 서비스] → [카카오]
          &quot;카카오로 로그인할게요&quot;
                    ↓
          [카카오가 ID Token + Access Token 발급]
                    ↓
          [ID Token에서 바로 사용자 정보 추출]</code></pre><h3 id="핵심-차이점">핵심 차이점</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>OAuth2</th>
<th>OIDC</th>
</tr>
</thead>
<tbody><tr>
<td><strong>목적</strong></td>
<td>권한 부여 (Authorization)</td>
<td>인증 (Authentication)</td>
</tr>
<tr>
<td><strong>토큰</strong></td>
<td>Access Token만 발급</td>
<td>ID Token + Access Token</td>
</tr>
<tr>
<td><strong>사용자 정보</strong></td>
<td>UserInfo API 별도 호출 필요</td>
<td>ID Token에 포함</td>
</tr>
<tr>
<td><strong>표준화</strong></td>
<td>사용자 정보 포맷 비표준</td>
<td>표준화된 Claims (sub, email, name)</td>
</tr>
<tr>
<td><strong>보안</strong></td>
<td>토큰 검증 어려움</td>
<td>JWT 서명 검증 가능</td>
</tr>
</tbody></table>
<h3 id="왜-oidc를-선택해야-할까">왜 OIDC를 선택해야 할까?</h3>
<ol>
<li><strong>API 호출 감소</strong>: ID Token에서 바로 사용자 정보를 가져올 수 있어 UserInfo API 호출이 줄어듭니다.</li>
<li><strong>보안 강화</strong>: ID Token은 JWT이므로 서명 검증으로 위변조를 방지할 수 있습니다.</li>
<li><strong>표준화된 Claims</strong>: <code>sub</code>, <code>email</code>, <code>name</code> 등 표준화된 필드로 Provider 간 일관성을 유지합니다.</li>
</ol>
<blockquote>
<p><strong>Tip</strong>: 카카오는 OIDC를 지원하므로, 반드시 <code>openid</code> scope를 추가하여 ID Token을 받아야 합니다.</p>
</blockquote>
<hr>
<h2 id="2-spring-security-oauth2-client-의존성-설정">2. Spring Security OAuth2 Client 의존성 설정</h2>
<h3 id="buildgradle">build.gradle</h3>
<pre><code class="language-gradle">dependencies {
    // Spring Boot 3.x
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;

    // OAuth2 Client (필수)
    implementation &#39;org.springframework.boot:spring-boot-starter-oauth2-client&#39;

    // OAuth2 Resource Server (JWT 검증용, 선택)
    implementation &#39;org.springframework.boot:spring-boot-starter-oauth2-resource-server&#39;
}</code></pre>
<h3 id="의존성-설명">의존성 설명</h3>
<ul>
<li><strong>spring-boot-starter-oauth2-client</strong>: OAuth2/OIDC 클라이언트 기능을 제공합니다. 카카오, 네이버, 구글 등의 Provider와 통신합니다.</li>
<li><strong>spring-boot-starter-oauth2-resource-server</strong>: JWT 토큰 검증 기능을 제공합니다. 자체 JWT를 발급하는 경우 필요합니다.</li>
</ul>
<hr>
<h2 id="3-applicationyml-provider-설정">3. application.yml Provider 설정</h2>
<h3 id="카카오-oidc-설정">카카오 OIDC 설정</h3>
<pre><code class="language-yaml">spring:
    security:
        oauth2:
            client:
                registration:
                    kakao:
                        client-id: ${KAKAO_CLIENT_ID}  # 카카오 REST API 키
                        client-secret: ${KAKAO_CLIENT_SECRET}  # 보안 → Client Secret
                        redirect-uri: &quot;{baseUrl}/login/oauth2/code/{registrationId}&quot;
                        authorization-grant-type: authorization_code
                        client-authentication-method: client_secret_post
                        scope:
                            - openid          # OIDC 필수!
                            - profile_nickname
                            - profile_image
                            - account_email
                            - phone_number
                        client-name: Kakao
                provider:
                    kakao:
                        issuer-uri: https://kauth.kakao.com
                        authorization-uri: https://kauth.kakao.com/oauth/authorize
                        token-uri: https://kauth.kakao.com/oauth/token
                        user-info-uri: https://kapi.kakao.com/v2/user/me
                        user-name-attribute: id</code></pre>
<h3 id="설정-항목-설명">설정 항목 설명</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>client-id</code></td>
<td>카카오 개발자센터의 REST API 키</td>
</tr>
<tr>
<td><code>client-secret</code></td>
<td>카카오 보안 설정의 Client Secret 코드</td>
</tr>
<tr>
<td><code>redirect-uri</code></td>
<td>인증 후 콜백 URL (Spring 기본값 사용)</td>
</tr>
<tr>
<td><code>scope</code></td>
<td>요청할 권한 범위 (<code>openid</code> 필수!)</td>
</tr>
<tr>
<td><code>issuer-uri</code></td>
<td>OIDC Provider의 발급자 URI</td>
</tr>
<tr>
<td><code>user-name-attribute</code></td>
<td>Principal name으로 사용할 속성</td>
</tr>
</tbody></table>
<h3 id="scope-종류">Scope 종류</h3>
<pre><code class="language-yaml">scope:
    - openid            # OIDC ID Token 발급 (필수)
    - profile_nickname  # 닉네임
    - profile_image     # 프로필 이미지
    - account_email     # 이메일
    - phone_number      # 전화번호 (비즈앱 필요)
    - name              # 실명 (비즈앱 필요)</code></pre>
<blockquote>
<p><strong>주의</strong>: <code>phone_number</code>와 <code>name</code> scope는 카카오 비즈니스 앱에서만 사용 가능합니다.</p>
</blockquote>
<hr>
<h2 id="4-카카오-개발자센터-앱-설정">4. 카카오 개발자센터 앱 설정</h2>
<h3 id="step-1-애플리케이션-생성">Step 1: 애플리케이션 생성</h3>
<ol>
<li><a href="https://developers.kakao.com/">카카오 개발자센터</a>에 접속합니다.</li>
<li><strong>내 애플리케이션</strong> → <strong>애플리케이션 추가하기</strong>를 클릭합니다.</li>
<li>앱 이름과 사업자명을 입력합니다.</li>
</ol>
<h3 id="step-2-rest-api-키-확인">Step 2: REST API 키 확인</h3>
<p><strong>앱 설정</strong> → <strong>앱 키</strong>에서 <strong>REST API 키</strong>를 복사합니다. 이것이 <code>client-id</code>입니다.</p>
<h3 id="step-3-보안-설정">Step 3: 보안 설정</h3>
<p><strong>보안</strong> 메뉴에서:</p>
<ol>
<li><strong>Client Secret</strong> 코드를 생성합니다.</li>
<li><strong>활성화 상태</strong>를 ON으로 변경합니다.</li>
</ol>
<h3 id="step-4-카카오-로그인-활성화">Step 4: 카카오 로그인 활성화</h3>
<p><strong>카카오 로그인</strong> 메뉴에서:</p>
<ol>
<li><strong>활성화 설정</strong>을 ON으로 변경합니다.</li>
<li><strong>OpenID Connect 활성화 설정</strong>을 ON으로 변경합니다. (OIDC 사용 시 필수!)</li>
</ol>
<h3 id="step-5-redirect-uri-등록">Step 5: Redirect URI 등록</h3>
<p><strong>카카오 로그인</strong> → <strong>Redirect URI</strong>에 다음을 등록합니다:</p>
<pre><code>http://localhost:8080/login/oauth2/code/kakao
https://your-domain.com/login/oauth2/code/kakao</code></pre><h3 id="step-6-동의항목-설정">Step 6: 동의항목 설정</h3>
<p><strong>동의항목</strong> 메뉴에서 필요한 정보의 동의 수준을 설정합니다:</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>권장 설정</th>
</tr>
</thead>
<tbody><tr>
<td>닉네임</td>
<td>필수 동의</td>
</tr>
<tr>
<td>프로필 사진</td>
<td>선택 동의</td>
</tr>
<tr>
<td>카카오계정(이메일)</td>
<td>선택 동의</td>
</tr>
<tr>
<td>카카오계정(전화번호)</td>
<td>비즈앱 전용</td>
</tr>
</tbody></table>
<hr>
<h2 id="5-oauth2-인증-플로우-이해하기">5. OAuth2 인증 플로우 이해하기</h2>
<h3 id="authorization-code-grant-플로우">Authorization Code Grant 플로우</h3>
<p><img src="https://velog.velcdn.com/images/co-vol/post/57ceab11-d270-4278-8810-7c619a11ebc5/image.png" alt=""></p>
<h3 id="spring-security-기본-동작">Spring Security 기본 동작</h3>
<p>Spring Security OAuth2 Client는 다음 엔드포인트를 자동으로 생성합니다:</p>
<table>
<thead>
<tr>
<th>엔드포인트</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>/oauth2/authorization/{registrationId}</code></td>
<td>OAuth2 인증 시작</td>
</tr>
<tr>
<td><code>/login/oauth2/code/{registrationId}</code></td>
<td>OAuth2 콜백 (Authorization Code 수신)</td>
</tr>
</tbody></table>
<h3 id="실제-요청-url-예시">실제 요청 URL 예시</h3>
<pre><code># 1. 프론트엔드에서 로그인 버튼 클릭 시
GET /oauth2/authorization/kakao?redirect_uri=/mypage

# 2. 카카오 인증 후 콜백
GET /login/oauth2/code/kakao?code=xxx&amp;state=yyy

# 3. 인증 성공 후 프론트엔드로 리다이렉트
GET /oauth2/callback?redirect_uri=/mypage</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>이번 글에서는 Spring Boot 3에서 카카오 OAuth2 소셜 로그인을 구현하기 위한 기초를 다뤘습니다. OAuth2와 OIDC의 차이점을 이해하고, 의존성 설정부터 카카오 개발자센터 설정까지 단계별로 진행했습니다.</p>
<h3 id="핵심-정리">핵심 정리</h3>
<ol>
<li><p><strong>가능하면 OIDC를 선택하세요</strong><code>openid</code> 스코프를 추가하면 ID Token을 통해 사용자 정보를 안전하고 빠르게 얻을 수 있습니다 (JWT 서명 지원).</p>
</li>
<li><p><strong>보안 설정은 꼼꼼하게</strong></p>
<ul>
<li><strong>Client Secret:</strong> 보안을 위해 활성화는 필수입니다. 특히 카카오는 <code>client-secret-post</code> (본문 포함), 그 외는 보통 <code>client-secret-basic</code> (헤더 포함) 방식을 사용하므로 설정 시 주의가 필요합니다.</li>
<li><strong>Redirect URI:</strong> 오타 하나로도 인증이 실패할 수 있으니 정확하게 등록되었는지 확인하세요.</li>
</ul>
</li>
<li><p><strong>권한(Scope)은 최소한으로</strong>
사용자가 거부감을 느끼지 않도록 꼭 필요한 권한만 요청하세요. 단, OIDC를 사용한다면 <code>openid</code>는 필수입니다.</p>
</li>
</ol>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html">Spring Security OAuth2 공식 문서</a></li>
<li><a href="https://developers.kakao.com/docs/latest/ko/kakaologin/common">카카오 로그인 개발 가이드</a></li>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect 스펙</a></li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] DB 리플리케이션 지연(Lag)을 해결하는 방법: Write-Concern 패턴 적용기]]></title>
            <link>https://velog.io/@co-vol/Spring-Boot-DB-%EB%A6%AC%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EC%A7%80%EC%97%B0Lag%EC%9D%84-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-Write-Concern-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Spring-Boot-DB-%EB%A6%AC%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%EC%A7%80%EC%97%B0Lag%EC%9D%84-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-Write-Concern-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Tue, 06 Jan 2026 08:52:38 GMT</pubDate>
            <description><![CDATA[<p>대규모 트래픽 처리를 위해 데이터베이스를 <strong>Master(Write)</strong>와 <strong>Slave(Read)</strong>로 분리하는 것은 백엔드 개발에서 흔한 패턴입니다. 하지만 이 구조를 도입하자마자 마주치는 고질적인 문제가 하나 있습니다. 바로 <strong>&#39;리플리케이션 지연(Replication Lag)&#39;</strong>입니다.</p>
<p>오늘은 Master/Slave 구조에서 발생한 데이터 불일치 문제를 해결하기 위해, <strong>사용자의 최근 쓰기 이력을 추적하여 동적으로 DB를 라우팅한 경험</strong>을 공유하려 합니다.</p>
<h2 id="1-문제-상황-방금-쓴-글이-안-보여요">1. 문제 상황: &quot;방금 쓴 글이 안 보여요&quot;</h2>
<p>저희 서비스는 읽기 성능을 높이기 위해 <code>AbstractRoutingDataSource</code>를 사용하여 트랜잭션의 <code>@Transactional(readOnly = true)</code> 여부에 따라 쿼리를 분기 처리하고 있습니다.</p>
<ul>
<li><strong>쓰기(Write):</strong> Master DB</li>
<li><strong>읽기(Read):</strong> Slave DB</li>
</ul>
<p>하지만 Master에 저장된 데이터가 Slave로 복제되기까지는 아주 짧지만 물리적인 시간(Lag)이 소요됩니다. 이로 인해 사용자가 데이터를 수정하고 즉시 목록 페이지로 리다이렉트되었을 때, <strong>Slave DB에는 아직 데이터가 도달하지 않아 수정 전의 내용이 노출되는 문제</strong>가 발생했습니다. 이는 사용자 경험(UX)에 치명적이었습니다.</p>
<h2 id="2-해결-전략-방금-쓴-사람은-master를-보게-하자">2. 해결 전략: &quot;방금 쓴 사람은 Master를 보게 하자&quot;</h2>
<p>이 문제를 해결하기 위해 <strong>&quot;최근에 쓰기 작업을 수행한 사용자는 일정 시간 동안 강제로 Master DB에서 읽게 한다&quot;</strong>는 전략을 세웠습니다. 이를 구현하기 위해 세 가지 범위(Scope)에서의 추적이 필요했습니다.</p>
<ol>
<li><strong>현재 스레드(Thread):</strong> 한 트랜잭션 내에서 쓰기 후 바로 읽는 경우.</li>
<li><strong>현재 요청(Request):</strong> 하나의 HTTP 요청 안에서 쓰기 로직 수행 후, 다른 로직에서 읽기를 수행하는 경우.</li>
<li><strong>사용자 세션(Client):</strong> 쓰기 요청이 끝나고 <strong>다음 요청(새로고침 등)</strong>으로 넘어왔을 때.</li>
</ol>
<p>저는 이 상태를 관리하기 위해 <code>WriteTracker</code>라는 컴포넌트를 설계했고, 다음과 같은 흐름을 만들었습니다.</p>
<ul>
<li><strong>쓰기 발생 시:</strong> 타임스탬프를 기록하고, 응답 쿠키(Cookie)에 마지막 쓰기 시간을 구워줍니다.</li>
<li><strong>읽기 발생 시:</strong> 쿠키나 내부 상태를 확인해 <code>현재 시간 - 마지막 쓰기 시간 &lt; 설정된 Lag 시간</code>이라면 <strong>강제로 Master DB</strong>를 바라보게 합니다.</li>
</ul>
<h2 id="3-구현-상세">3. 구현 상세</h2>
<h3 id="31-핵심-로직-writetracker와-범위별-추적">3.1. 핵심 로직: <code>WriteTracker</code>와 범위별 추적</h3>
<p>가장 먼저 <code>WriteTracker</code> 클래스를 통해 쓰기 상태를 판단하는 로직을 중앙화했습니다.</p>
<pre><code class="language-java">// WriteTracker.java (요약)
public boolean shouldForceReadFromMaster() {
    // 1. ThreadLocal 체크 (현재 스레드 내 쓰기)
    if (isWithinLagWindow(WRITE_TIMESTAMP.get())) return true;

    // 2. Request Attribute 체크 (현재 요청 내 쓰기)
    if (request.getAttribute(txCommitFlag) != null) return true;

    // 3. Cookie 체크 (이전 요청에서의 쓰기 - 리다이렉트 등)
    Long lastWrite = extractFromCookie(request);
    return lastWrite != null &amp;&amp; isWithinLagWindow(lastWrite);
}</code></pre>
<p>여기서 중요한 점은 <strong>ThreadLocal, Request Attribute, Cookie</strong>를 순차적으로 확인하여, 서버 내부의 로직 흐름뿐만 아니라 클라이언트의 재요청 시나리오까지 커버했다는 점입니다.</p>
<h3 id="32-aop를-통한-투명한-추적-writetrackingaspect">3.2. AOP를 통한 투명한 추적 (<code>WriteTrackingAspect</code>)</h3>
<p>개발자가 비즈니스 로직마다 <code>tracker.markWrite()</code>를 호출하는 것은 실수할 여지가 많습니다. 그래서 AOP를 사용하여 <code>@Transactional</code> 어노테이션이 붙은 메서드를 가로챘습니다.</p>
<pre><code class="language-java">// WriteTrackingAspect.java
@Around(&quot;@annotation(transactional)&quot;)
public Object trackWriteTransaction(ProceedingJoinPoint joinPoint, Transactional transactional) {
    if (transactional.readOnly()) {
        return joinPoint.proceed();
    }

    // 1. 쓰기 시작 마킹
    writeTracker.markWriteStarted();

    Object result = joinPoint.proceed();

    // 2. 트랜잭션 동기화 매니저에 핸들러 등록
    if (TransactionSynchronizationManager.isActualTransactionActive()) {
        TransactionSynchronizationManager.registerSynchronization(new WriteSyncHandler(...));
    }
    return result;
}</code></pre>
<p>트랜잭션이 성공적으로 커밋된 시점(<code>afterCommit</code>)에 쿠키를 생성하여 클라이언트에게 내려줌으로써, 다음 요청부터는 이 쿠키를 들고 오게 만들었습니다.</p>
<h3 id="33-동적-라우팅-transactionroutingdatasource">3.3. 동적 라우팅 (<code>TransactionRoutingDataSource</code>)</h3>
<p>마지막으로 <code>AbstractRoutingDataSource</code>를 상속받은 라우팅 소스에서 <code>WriteTracker</code>의 판단 결과를 반영했습니다.</p>
<pre><code class="language-java">// TransactionRoutingDataSource.java
@Override
protected Object determineCurrentLookupKey() {
    boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

    // 1. 쓰기 트랜잭션이면 무조건 Master
    if (!isReadOnly) return DataSourceType.MASTER;

    // 2. 읽기 트랜잭션이지만, 최근 쓰기 이력이 있다면 Master (Lag 방지)
    if (writeTracker.shouldForceReadFromMaster()) {
        log.debug(&quot;Routing to MASTER (avoiding replication lag)&quot;);
        return DataSourceType.MASTER;
    }

    return DataSourceType.SLAVE;
}</code></pre>
<h2 id="4-트러블-슈팅-및-주의할-점">4. 트러블 슈팅 및 주의할 점</h2>
<h3 id="threadlocal-메모리-누수-방지">ThreadLocal 메모리 누수 방지</h3>
<p><code>ThreadLocal</code>을 사용할 때는 반드시 사용 후 정리가 필요합니다. 톰캣과 같은 스레드 풀 환경에서는 스레드가 재사용되기 때문입니다. 이를 위해 <code>WriteTrackerCleanupFilter</code>를 구현하여 요청이 끝나는 시점에 <code>writeTracker.clear()</code>를 호출하도록 <code>HighestPrecedence</code>로 설정했습니다.</p>
<h3 id="lazyconnectiondatasourceproxy의-필수성"><code>LazyConnectionDataSourceProxy</code>의 필수성</h3>
<p>Spring의 트랜잭션 처리는 트랜잭션 시작 시점에 Connection을 확보하려 합니다. 라우팅 로직이 동작하기도 전에 이미 커넥션을 잡아버리는 문제를 방지하기 위해 <code>LazyConnectionDataSourceProxy</code>를 사용하여 <strong>실제 쿼리가 실행되는 시점까지 커넥션 획득을 지연</strong>시켰습니다.</p>
<h3 id="p6spy와-무조건적인-master-라우팅-이슈">P6Spy와 무조건적인 Master 라우팅 이슈</h3>
<p>개발 과정에서 쿼리 파라미터를 편하게 확인하기 위해 <strong>P6Spy (v1.21.1)</strong> 라이브러리를 적용했습니다. 그런데 P6Spy 적용 직후, <strong>분명히 Slave로 가야 할 읽기 전용 트랜잭션들까지 전부 Master DB로 쏠리는 현상</strong>이 발견되었습니다.</p>
<h3 id="원인-분석">원인 분석</h3>
<p>원인은 P6Spy의 작동 방식에 있었습니다. P6Spy(정확히는 관련 Spring Boot Starter)는 빈 후처리기(BeanPostProcessor)를 통해 애플리케이션의 <code>DataSource</code> 빈을 감싸서(Decorate) 프록시 객체를 만듭니다.</p>
<p>문제는 이 과정에서 P6Spy가 <strong><code>LazyConnectionDataSourceProxy</code>나 <code>RoutingDataSource</code>보다 상위에서 감싸버리거나, 커넥션 객체를 미리 요청</strong>해버린다는 점입니다. 이로 인해 트랜잭션의 속성(<code>readOnly</code>)을 확인하고 분기 처리를 하기도 전에 이미 커넥션이 맺어져 버렸고, 결과적으로 기본값인 Master DB 커넥션만 계속 사용하게 된 것입니다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p>이 문제를 해결하기 위해 P6Spy의 데코레이터 설정에서 <strong>라우팅 로직이 포함된 <code>dataSource</code> 빈은 감싸지 않도록 제외(exclude)</strong> 처리했습니다.</p>
<pre><code class="language-yaml"># application.yml

decorator:
  datasource:
    # RoutingDataSource는 이미 내부적으로 분기 처리를 하므로 P6Spy가 감싸지 않도록 설정
    ignore-routing-data-sources: true
    # 우리가 직접 만든(Lazy+Routing) 최종 dataSource 빈은 P6Spy 적용 제외
    exclude-beans: dataSource</code></pre>
<p>위와 같이 <code>exclude-beans: dataSource</code> 설정을 추가하여 P6Spy가 최상단 <code>dataSource</code> 빈을 건드리지 않게 하자, <code>LazyConnectionDataSourceProxy</code>가 정상적으로 지연 로딩을 수행하면서 라우팅이 다시 올바르게 작동했습니다.</p>
<h2 id="5-마치며">5. 마치며</h2>
<p>이 구조를 도입한 후, 사용자가 &quot;수정했습니다&quot;라는 메시지를 보고 목록으로 돌아갔을 때 데이터가 반영되지 않는 문제는 완벽하게 사라졌습니다.</p>
<p>물론, &quot;최근 글을 쓴 사용자&quot;의 트래픽이 일시적으로 Master DB로 몰릴 수 있다는 트레이드오프가 있습니다. 하지만 데이터의 정합성이 UX에 미치는 영향이 훨씬 크다고 판단했으며, <code>replicationLagMs</code> 시간을 적절히 조절(예: 2초)하여 Master 부하를 최소화했습니다.</p>
<p>이번 개발을 통해 <strong>데이터베이스 아키텍처는 단순히 인프라 설정뿐만 아니라, 애플리케이션 레벨에서의 정교한 핸들링이 더해졌을 때 비로소 완성된다</strong>는 것을 배웠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot에서 Jooq와 AOP 사용 시 실행 속도 저하 문제 해결]]></title>
            <link>https://velog.io/@co-vol/Spring-Boot%EC%97%90%EC%84%9C-Jooq%EC%99%80-AOP-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EC%8B%A4%ED%96%89-%EC%86%8D%EB%8F%84-%EC%A0%80%ED%95%98-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@co-vol/Spring-Boot%EC%97%90%EC%84%9C-Jooq%EC%99%80-AOP-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EC%8B%A4%ED%96%89-%EC%86%8D%EB%8F%84-%EC%A0%80%ED%95%98-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Sun, 07 Apr 2024 15:42:28 GMT</pubDate>
            <description><![CDATA[<h3 id="jooq-소개">Jooq 소개</h3>
<p>Jooq는 Java 객체 지향 쿼리를 위한 프레임워크로, SQL을 타입 안전하게 작성할 수 있게 해준다. 이는 복잡한 SQL 쿼리를 간편하게 작성하고 관리할 수 있게 해준다.</p>
<h3 id="aopaspect-oriented-programming-이해">AOP(Aspect-Oriented Programming) 이해</h3>
<p>AOP는 프로그래밍에서 공통적으로 사용되는 기능(예: 로깅, 보안)을 모듈화하는 프로그래밍 패러다임이다. 이는 코드의 재사용성과 가독성을 향상시키는 데 도움을 준다.</p>
<h3 id="발생한-문제">발생한 문제</h3>
<p>Spring Boot, Jooq, 그리고 전역 오류 로깅을 위한 AOP 설정 후 스프링 실행 속도가 1분 이상 걸리는 문제가 발생하였다. 문제 해결을 위한 디버깅 과정에서 AOP를 제거하고 실행하니 문제 없이 실행되었다. 이로 인해 AOP의 문제인가?? 라고 생각하게 되었다.</p>
<h2 id="문제-진단">문제 진단</h2>
<h3 id="aop-설정의-영향">AOP 설정의 영향</h3>
<p>AOP를 제거한 후 애플리케이션의 실행 속도가 정상으로 돌아온 것으로 보아, AOP 설정이 실행 속도 저하의 직접적인 원인임을 확인할 수 있었다.</p>
<h3 id="실행-속도-저하의-원인-분석">실행 속도 저하의 원인 분석</h3>
<h4 id="aop-추적">AOP 추적</h4>
<p>디버깅 과정에서 org.aspectj.weaver.internal.tools.PointcutExpressionImpl의 canMatchJoinPointsInType 메서드가 항상 true를 반환하고, 이로 인해 DefaultDSLContext의 모든 메서드에 포인트 컷을 적용할 수 있는지 확인하는 과정에서 성능 저하가 발생하는 것을 발견하였다.</p>
<h4 id="pointcut-표현식의-영향">Pointcut 표현식의 영향</h4>
<p>execution 대신 within을 사용하였을 때 문제가 해결되었다는 사실로 미루어 볼 때, Pointcut 표현식의 선택이 성능에 큰 영향을 미칠 수 있음을 알 수 있었다.</p>
<h3 id="해결-방안">해결 방안</h3>
<h4 id="execution-대신-within-사용하기">execution 대신 within 사용하기</h4>
<p>Pointcut 표현식에서 execution 대신 within을 사용함으로써, 포인트컷의 적용 범위를 좁히고, 이를 통해 DefaultDSLContext의 모든 메서드에 대한 포인트컷 적용을 피함으로써 성능 문제를 해결할 수 있었다.</p>
<h4 id="execution">execution</h4>
<pre><code class="language-java">import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class LoggingAspect {

    @Pointcut(&quot;execution(* com.example.service.*.*(..))&quot;)
    public void serviceLayerExecution() {
        // Pointcut body, usually empty
    }
}</code></pre>
<h4 id="within">within</h4>
<pre><code class="language-java">import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class LoggingAspect {

    @Pointcut(&quot;within(com.example.service..*)&quot;)
    public void serviceLayerWithin() {
        // Pointcut body, usually empty
    }
}</code></pre>
<h4 id="execution과-within의-차이점">execution과 within의 차이점</h4>
<p>execution과 within은 AspectJ에서 사용되는 Pointcut 지시자들로, 조인 포인트(join points)를 지정하는 방식에서 중요한 차이를 가집니다. 이 차이는 어떤 메서드나 객체에 대한 관점(Aspect)의 적용 범위를 결정하는 데 큰 영향을 미칩니다.</p>
<p>execution 지시자는 메소드 실행 시의 조인 포인트를 지정합니다. 이는 특정 메소드가 실행될 때 해당 메소드에 관점을 적용하고자 할 때 사용됩니다. execution 지시자는 메소드의 시그니처를 기반으로 매우 구체적인 타겟을 정할 수 있으며, 이를 통해 특정 메소드나 메소드 그룹에 대해 세밀한 관점 적용이 가능합니다. 예를 들어, execution(public String com.example.MyClass.myMethod(..))는 com.example.MyClass에 있는 myMethod 메소드의 실행 시점에만 관점을 적용하도록 지정합니다.</p>
<p>within 지시자는 특정 타입 내의 모든 조인 포인트를 지정하여 사용 범위가 execution보다 제한적일 수 있습니다. within은 특정 클래스나 패키지 내의 모든 메소드 실행을 포인트컷의 대상으로 지정합니다. 이는 해당 타입 내에서 실행되는 모든 메소드에 관점을 일괄적으로 적용하고자 할 때 유용합니다. 예를 들어, within(com.example.MyClass)는 com.example.MyClass 내의 모든 메소드에 대해 관점을 적용합니다.</p>
<p>이러한 차이는 포인트컷의 적용 범위와 성능 최적화에 중요한 영향을 미칩니다. execution 지시자는 매우 구체적인 적용 범위를 제공하지만, 많은 메소드에 대해 개별적으로 적용될 때 성능 저하의 원인이 될 수 있습니다. 반면, within 지시자는 특정 클래스나 패키지 내의 모든 조인 포인트에 대한 일괄적인 적용을 가능하게 하여, 성능 최적화에 도움을 줄 수 있습니다. 그러나 이는 적용 범위의 정밀도가 다소 떨어질 수 있다는 점을 의미하기도 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot RESTFulAPI 샘플]]></title>
            <link>https://velog.io/@co-vol/Spring-Boot-RESTFulAPI-%EC%83%98%ED%94%8C</link>
            <guid>https://velog.io/@co-vol/Spring-Boot-RESTFulAPI-%EC%83%98%ED%94%8C</guid>
            <pubDate>Tue, 21 Nov 2023 02:28:05 GMT</pubDate>
            <description><![CDATA[<h2 id="1-restful-api-란">1. RESTful API 란?</h2>
<p>RESTful API는 두 컴퓨터 시스템이 인터넷을 통해 정보를 안전하게 교환하기 위해 사용하는 인터페이스입니다. 대부분의 비즈니스 애플리케이션은 다양한 태스크를 수행하기 위해 다른 내부 애플리케이션 및 서드 파티 애플리케이션과 통신해야 합니다. 예를 들어 월간 급여 명세서를 생성하려면 인보이스 발행을 자동화하고 내부의 근무 시간 기록 애플리케이션과 통신하기 위해 내부 계정 시스템이 데이터를 고객의 뱅킹 시스템과 공유해야 합니다. RESTful API는 안전하고 신뢰할 수 있으며 효율적인 소프트웨어 통신 표준을 따르므로 이러한 정보 교환을 지원합니다.</p>
<h2 id="2-샘플-설명">2. 샘플 설명</h2>
<p>Spring Boot, Spring MVC, Spring Test, JUnit, Mockito 등 을 사용하여 User 에 관한 기본적인 REST API 와 단위테스트에 대한 방법을 설명합니다.</p>
<h2 id="3-rest-api">3. REST API</h2>
<h3 id="git--httpsgithubcomget-botspringsameplecode">Git : <a href="https://github.com/Get-bot/springSamepleCode">https://github.com/Get-bot/springSamepleCode</a></h3>
<h3 id="1-controller">1. Controller</h3>
<p><strong>UserApiController</strong></p>
<pre><code class="language-java">@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/api/v1/users&quot;)
public class UserApiController {

  private final UserService userService;

  @GetMapping(&quot;/{id}&quot;)
  public ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; getUser(@NotNull @PathVariable(&quot;id&quot;) Long id) {
    return userService.get(id);
  }

  @GetMapping
  public ResponseEntity&lt;ApiResponse&lt;List&lt;UserDTO&gt;&gt;&gt; getUserList() {
    return userService.getList();
  }

  @PostMapping
  public ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; registerUser(@RequestBody @Valid UserRegisterDTO userRegisterDTO) {
    return userService.register(userRegisterDTO);
  }

  @PutMapping(&quot;/{id}&quot;)
  public ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; updateUser(@NotNull @PathVariable(&quot;id&quot;) Long id, @RequestBody @Valid UserUpdateDTO userUpdateDTO) {
    return userService.update(id, userUpdateDTO);
  }

  @DeleteMapping(&quot;/{id}&quot;)
  public ResponseEntity&lt;ApiResponse&lt;?&gt;&gt; deleteUser(@NotNull @PathVariable(&quot;id&quot;) Long id) {
    return userService.delete(id);
  }

}</code></pre>
<ul>
<li><strong>@RestController</strong> :  @Controller에 @ResponseBody가 결합된 어노테이션 으로 컨트롤러 클래스 하위 메서드에 @ResponseBody 어노테이션을 붙이지 않아도 문자열과 JSON 등을 전송할 수 있습니다.</li>
</ul>
<h3 id="2-entity-and-dto">2. Entity and DTO</h3>
<p><strong>User</strong></p>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;users&quot;)
public class User {

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

  private String username;

  private String email;

  private String password;

  public static User setUser(Long id, String username, String email, String password) {
    User user = new User();
    user.id = id;
    user.username = username;
    user.email = email;
    user.password = password;
    return user;
  }

  public static User setRegisterUser(UserRegisterDTO userRegisterDTO) {
    User user = new User();
    user.username = userRegisterDTO.getUsername();
    user.email = userRegisterDTO.getEmail();
    user.password = userRegisterDTO.getPassword();
    return user;
  }

  public void updateWithUpdateDTO(UserUpdateDTO userUpdateDTO) {
    this.email = userUpdateDTO.getEmail();
    if (userUpdateDTO.getPassword() != null &amp;&amp; !userUpdateDTO.getPassword().equals(this.password)) {
      this.password = userUpdateDTO.getPassword();
    }
  }
}</code></pre>
<p>Entity 내부에 정적 메소드를 선언하여 캡슐화 하고 팩토리 메소드 역할을 하게하여 <code>User</code> 인스턴스를 생성하는 방법을 명확하게 하였습니다.</p>
<p><strong><em>UserDTO</em></strong> </p>
<pre><code class="language-java">@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
  private Long id;
  private String username;
  private String email;

  public static UserDTO setUserDTO(User user) {
    return new UserDTO(user.getId(), user.getUsername(), user.getEmail());
  }

}</code></pre>
<p><strong><em>UserRegisterDTO</em></strong></p>
<pre><code class="language-java">@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserRegisterDTO {

  @NotEmpty
  private String username;

  @Email
  @NotEmpty
  private String email;

  @NotEmpty
  private String password;

  public static UserRegisterDTO setUserRegisterDTO(String username, String email, String password) {
    UserRegisterDTO user = new UserRegisterDTO();
    user.setUsername(username);
    user.setEmail(email);
    user.setPassword(password);
    return user;
  }
}</code></pre>
<p><strong><em>UserUpdateDTO</em></strong></p>
<pre><code class="language-java">@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserUpdateDTO {

  @NotEmpty
  private String email;
  private String password;
}</code></pre>
<p>Responce 할 데이터에 Entity 그대로를 사용하지 않고 DTO 를 사용 하는것은 데이터를 캡슐화하여 필요한 정보만 클라이언트에 전송되도록 하는 API 설계에 필수적인 방법입니다. 이 접근 방식은 개인정보 보호와 보안을 유지하고, 내부 데이터 구조와 외부 표현을 분리하며, 맞춤형 API 응답을 허용합니다. 또한 DTO는 데이터 전송을 필수적인 데이터로 제한하여 대역폭 사용량을 줄이고 데이터 직렬화의 유연성을 제공합니다. 전반적으로 DTO는 보다 효율적이고 유연하며 강력한 API 설계에 필요한 요소입니다.</p>
<h3 id="3-repository">3. Repository</h3>
<p>** JPA UserRepository **</p>
<pre><code class="language-java">@Repository
public interface UserRepository extends JpaRepository&lt;User, Long&gt; {

  boolean existsByUsername(String username);
  boolean existsByEmail(String email);

}</code></pre>
<h3 id="4-serivce">4. Serivce</h3>
<p><strong>* UserService Interface</strong></p>
<pre><code class="language-java">public interface UserService {

  ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; get(Long id);

  ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; register(UserRegisterDTO userRegisterDTO);

  ResponseEntity&lt;ApiResponse&lt;List&lt;UserDTO&gt;&gt;&gt; getList();

  ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; update(Long id, UserUpdateDTO userUpdateDTO);

  ResponseEntity&lt;ApiResponse&lt;?&gt;&gt; delete(Long id);

}</code></pre>
<p><strong>* UserServiceImpl *</strong></p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{

  private final UserRepository userRepository;

  @Override
  public ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; get(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(this::userNotFoundException);

    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;get user&quot;, UserDTO.setUserDTO(user)));
  }

  @Override
  @Transactional
  public ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; register(UserRegisterDTO userRegisterDTO) {
    checkIfUsernameExists(userRegisterDTO.getUsername());

    User user = userRepository.save(User.setRegisterUser(userRegisterDTO));
    URI url = URI.create(&quot;/api/v1/users/&quot; + user.getId());
    return ResponseEntity.created(url).body(ApiResponse.setApiResponse(true, &quot;add user&quot;, UserDTO.setUserDTO(user)));
  }

  @Override
  public ResponseEntity&lt;ApiResponse&lt;List&lt;UserDTO&gt;&gt;&gt; getList() {
    List&lt;User&gt; userList = userRepository.findAll();
    List&lt;UserDTO&gt; userDTOList = userList.stream()
        .map(UserDTO::setUserDTO)
        .collect(Collectors.toList());

    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;userList find&quot;, userDTOList));
  }

  @Override
  @Transactional
  public ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; update(Long id, UserUpdateDTO userUpdateDTO)  {

    User user = userRepository.findById(id)
        .orElseThrow(this::userNotFoundException);

    if (!user.getEmail().equals(userUpdateDTO.getEmail())) {
      checkIfEmailExists(userUpdateDTO.getEmail());
    }

    user.updateWithUpdateDTO(userUpdateDTO);
    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;user update&quot;, UserDTO.setUserDTO(user)));
  }

  @Override
  @Transactional
  public ResponseEntity&lt;ApiResponse&lt;?&gt;&gt; delete(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(this::userNotFoundException);

    userRepository.delete(user);

    return ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;delete user&quot;, null));
  }

  private void checkIfUsernameExists(String username) {
    if (userRepository.existsByUsername(username)) {
      throw new IllegalArgumentException(&quot;이미 존재하는 유저 이름입니다.&quot;);
    }
  }

  private void checkIfEmailExists(String email) {
    if (userRepository.existsByEmail(email)) {
      throw new IllegalArgumentException(&quot;이미 존재하는 이메일 입니다.&quot;);
    }
  }

  private NotFoundException userNotFoundException() {
    return new NotFoundException(&quot;존재하지 않는 유저입니다.&quot;);
  }</code></pre>
<h3 id="5-exception-handler">5. Exception Handler</h3>
<h4 id="1-api-paylod">1. API paylod</h4>
<p><strong>APIErrorResponse</strong></p>
<pre><code class="language-java">@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class ApiError {
  private HttpStatus status;
  private String message;
  private String debugMessage;
  private LocalDateTime timestamp;

  public static ApiError setApiError(HttpStatus status, String message, Throwable ex) {
    ApiError apiError = new ApiError();
    apiError.status = status;
    apiError.message = message;
    apiError.debugMessage = ex.getLocalizedMessage();
    apiError.timestamp = LocalDateTime.now();
    return apiError;
  }
}</code></pre>
<p>** NotFoundExceptin **</p>
<pre><code class="language-java">public class NotFoundException extends RuntimeException{
  public NotFoundException(String message) {
    super(message);
  }
}</code></pre>
<p><strong>GlobalExceptionHandler</strong></p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {
  @ExceptionHandler(IllegalArgumentException.class)
  protected ResponseEntity&lt;Object&gt; handleIllegalArgumentException(IllegalArgumentException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.BAD_REQUEST, ex.getMessage(), ex);
    return buildResponseEntity(apiError);
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  protected ResponseEntity&lt;Object&gt; handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.BAD_REQUEST, ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(), ex);
    return buildResponseEntity(apiError);
  }

  @ExceptionHandler(NotFoundException.class)
  protected ResponseEntity&lt;Object&gt; handleUsernameNotFoundException(NotFoundException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.NOT_FOUND, ex.getMessage(), ex);
    return buildResponseEntity(apiError);
  }

  @ExceptionHandler(NoHandlerFoundException.class)
  protected ResponseEntity&lt;Object&gt; handleNoHandlerFoundException(NoHandlerFoundException ex) {
    APIErrorResponse apiError = APIErrorResponse.setApiError(HttpStatus.NOT_FOUND, ex.getMessage(), ex);
    return buildResponseEntity(apiError);
  }

  private ResponseEntity&lt;Object&gt; buildResponseEntity(APIErrorResponse apiError) {
    return new ResponseEntity&lt;&gt;(apiError, apiError.getStatus());
  }
}</code></pre>
<p>GlobalExceptionHandler 를 추가하여 Exception 시 해당하는 오류에 맞게 HttpStatus를 구분하여 Error 응답을 사용자에게 전송합니다.</p>
<h2 id="4-unittest">4. UnitTest</h2>
<p><strong>UserApiControllerTest</strong></p>
<pre><code class="language-java">@WebMvcTest(UserApiController.class)
@WithMockUser(username = &quot;테스트_최고관리자&quot;, roles = {&quot;SUPER&quot;})
@DisplayName(&quot;유저 API 테스트&quot;)
class UserApiControllerTest {
  private static final String API_URL = &quot;/api/v1/users&quot;;

  @Autowired
  private MockMvc mockMvc;
  @Autowired
  private ObjectMapper objectMapper;
  @MockBean
  private UserService userService;

  @Test
  @DisplayName(&quot;유저 추가 API 실패 테스트&quot;)
  public void testAddShouldReturn400BadRequest() throws Exception {

    UserRegisterDTO userRegisterDTO = new UserRegisterDTO();

    userRegisterDTO.setUsername(&quot;&quot;);
    userRegisterDTO.setPassword(&quot;test1234&quot;);

    String requestBody = objectMapper.writeValueAsString(userRegisterDTO);

    mockMvc.perform(
            post(API_URL)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody)
        )
        .andExpect(status().isBadRequest())
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;유저 추가 API 성공 테스트&quot;)
  public void testAddShouldReturn200Request() throws Exception {

    UserRegisterDTO userRegisterDTO = new UserRegisterDTO();

    userRegisterDTO.setUsername(&quot;test&quot;);
    userRegisterDTO.setEmail(&quot;test1234@naver.com&quot;);
    userRegisterDTO.setPassword(&quot;test1234&quot;);

    String requestBody = objectMapper.writeValueAsString(userRegisterDTO);

    Mockito.when(userService.register(any(UserRegisterDTO.class)))
        .thenReturn(ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;add user&quot;, UserDTO.setUserDTO(User.setRegisterUser(userRegisterDTO)))));

    mockMvc.perform(
            post(API_URL)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody)
        )
        .andExpect(status().is2xxSuccessful())
        .andExpect(jsonPath(&quot;$.data.email&quot;).value(userRegisterDTO.getEmail()))
        .andExpect(jsonPath(&quot;$.data.username&quot;).value(userRegisterDTO.getUsername()))
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;유저 조회 API 실패 테스트&quot;)
  void getUserTestShouldReturn404NotFound() throws Exception {
    Long userId = 1L;
    String requestUrl = API_URL + &quot;/&quot; + userId;

    when(userService.get(userId)).thenThrow(new NotFoundException(&quot;존재하지 않는 유저입니다.&quot;));

    mockMvc.perform(
            get(requestUrl)
                .with(csrf())
        )
        .andExpect(status().isNotFound())
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;유저 조회 API 성공 테스트&quot;)
  void getUserTestShouldReturn200Request() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + &quot;/&quot; + userId;
    String email = &quot;test@naver.com&quot;;

    User user = User.setUser(userId, &quot;test&quot;, email, &quot;test1234&quot;);
    ResponseEntity&lt;ApiResponse&lt;UserDTO&gt;&gt; responseEntity = ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;get user&quot;, UserDTO.setUserDTO(user)));

    when(userService.get(userId)).thenReturn(responseEntity);

    mockMvc.perform(
            get(requestURI)
                .with(csrf())
        )
        .andExpect(status().isOk())
        .andExpect(jsonPath(&quot;$.data.email&quot;).value(email))
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;사용자 리스트 조회 API 성공 테스트&quot;)
  void getUserListShouldReturn200Request() throws Exception {
    String user1Email = &quot;test1@naver.com&quot;;
    String user2Email = &quot;test2@naver.com&quot;;
    User user1 = User.setUser(1L, &quot;test1&quot;, user1Email, &quot;test1234&quot;);
    User user2 = User.setUser(2L, &quot;test2&quot;, user2Email, &quot;test1234&quot;);

    List&lt;User&gt; userList = List.of(user1, user2);
    ResponseEntity&lt;ApiResponse&lt;List&lt;UserDTO&gt;&gt;&gt; responseEntity = ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;userList find&quot;, userList.stream()
        .map(UserDTO::setUserDTO)
        .collect(Collectors.toList())));

    when(userService.getList()).thenReturn(responseEntity);

    mockMvc.perform(
            get(API_URL)
                .with(csrf())
        )
        .andExpect(status().isOk())
        .andExpect(content().contentType(&quot;application/json&quot;))
        .andExpect(jsonPath(&quot;$.data[0].email&quot;).value(user1Email))
        .andExpect(jsonPath(&quot;$.data[1].email&quot;).value(user2Email))
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;사용자 업데이트 API 실패 NotFound 테스트&quot;)
  void updateUserShouldReturn404NotFound() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + &quot;/&quot; + userId;

    UserUpdateDTO userUpdateDTO = new UserUpdateDTO();
    userUpdateDTO.setEmail(&quot;test1@naver.com&quot;);
    userUpdateDTO.setPassword(&quot;test1234&quot;);

    String requestBody = objectMapper.writeValueAsString(userUpdateDTO);

    when(userService.update(eq(userId), any(UserUpdateDTO.class)))
        .thenThrow(new NotFoundException(&quot;해당하는 유저가 없습니다.&quot;));

    mockMvc.perform(
            put(requestURI)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
        .andExpect(status().isNotFound())
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;사용자 업데이트 API 실패 BadRequest 테스트&quot;)
  void updateUserShouldReturn400BadRequest() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + &quot;/&quot; + userId;

    UserUpdateDTO userUpdateDTO = new UserUpdateDTO();

    String requestBody = objectMapper.writeValueAsString(userUpdateDTO);

    mockMvc.perform(
            put(requestURI)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
        .andExpect(status().isBadRequest())
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;사용자 업데이트 API 성공 테스트&quot;)
  void updateUserShouldReturn200Ok() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + &quot;/&quot; + userId;

    User user = User.setUser(userId, &quot;test&quot;, &quot;test@naver.com&quot;, &quot;test1234&quot;);

    UserUpdateDTO userUpdateDTO = new UserUpdateDTO();
    userUpdateDTO.setEmail(&quot;test@naver.com&quot;);
    userUpdateDTO.setPassword(&quot;test1234&quot;);

    Mockito.when(userService.update(eq(userId), any(UserUpdateDTO.class)))
        .thenReturn(ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;user update&quot;, UserDTO.setUserDTO(user))));

    String requestBody = objectMapper.writeValueAsString(userUpdateDTO);

    mockMvc.perform(
            put(requestURI)
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
        .andExpect(status().isOk())
        .andExpect(jsonPath(&quot;$.data.email&quot;).value(user.getEmail()))
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;사용자 업데이트 API 실패 NotFound 테스트&quot;)
  void deleteUserShouldReturn404NotFound() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + &quot;/&quot; + userId;

    when(userService.delete(userId))
        .thenThrow(new NotFoundException(&quot;해당하는 유저가 없습니다.&quot;));

    mockMvc.perform(
            delete(requestURI)
                .with(csrf())
        )
        .andExpect(status().isNotFound())
        .andDo(print());
  }

  @Test
  @DisplayName(&quot;사용자 삭제 API 성공 테스트&quot;)
  void deleteUserShouldReturn200Ok() throws Exception {
    Long userId = 1L;
    String requestURI = API_URL + &quot;/&quot; + userId;

    Mockito.when(userService.delete(userId))
        .thenReturn(ResponseEntity.ok().body(ApiResponse.setApiResponse(true, &quot;delete user&quot;, null)));

    mockMvc.perform(
            delete(requestURI)
                .with(csrf())
        )
        .andExpect(status().isOk())
        .andDo(print());
  }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/co-vol/post/b2dc3f66-c5a5-4763-9192-a3e392682445/image.png" alt=""></p>
<h2 id="5-유닛테스트-문제-사항--의문">5. 유닛테스트 문제 사항 / 의문</h2>
<h3 id="1-mockmvc-인자-매칭-오류-해결하기">1. <a href="https://velog.io/@co-vol/Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-MockMvc-%EC%9D%B8%EC%9E%90-%EB%A7%A4%EC%B9%AD-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0">MockMvc 인자 매칭 오류 해결하기</a></h3>
<h3 id="2-webmvctest-작성-시-spring-security-유의사항">2. <a href="https://joomn11.tistory.com/87">@WebMvcTest 작성 시 Spring Security 유의사항</a></h3>
<h3 id="3-transactional-내부에서의-exception">3. <a href="https://woowabros.github.io/experience/2019/01/29/exception-in-transaction.html">@Transactional 내부에서의 Exception</a></h3>
<h3 id="4-rest-api-의-모범적인-오류처리">4. <a href="https://www.baeldung.com/rest-api-error-handling-best-practices">REST API 의 모범적인 오류처리</a></h3>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 테스트에서 발생한 MockMvc 인자 매칭 오류 해결기]]></title>
            <link>https://velog.io/@co-vol/Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-MockMvc-%EC%9D%B8%EC%9E%90-%EB%A7%A4%EC%B9%AD-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/Spring-Boot-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C-%EB%B0%9C%EC%83%9D%ED%95%9C-MockMvc-%EC%9D%B8%EC%9E%90-%EB%A7%A4%EC%B9%AD-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Tue, 21 Nov 2023 01:17:54 GMT</pubDate>
            <description><![CDATA[<h2 id="1-문제-상황">1. 문제 상황</h2>
<p>최근에 Spring Boot 애플리케이션에서 REST API의 단위 테스트를 진행하던 중, <code>MockMvc</code>를 사용한 테스트 케이스에서 예상치 못한 결과를 마주했습니다. 특히, <code>updateUser API</code>를 테스트할 때, <code>NotFoundException</code>을 발생시켜야 하는 상황에서 계속해서 200 OK 응답이 반환되는 문제가 발생했습니다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;사용자 업데이트 API 실패 UserNotFound 테스트&quot;)
void updateUserShouldReturn404NotFound() throws Exception {
    // ... 코드 생략 ...
    Mockito.when(userService.update(userId, userUpdateDTO))
        .thenThrow(new NotFoundException(&quot;해당하는 유저가 없습니다.&quot;));
    // ... 코드 생략 ...
}</code></pre>
<h2 id="2-문제-진단">2. 문제 진단</h2>
<p>원인 파악을 위해 다음과 같은 점들을 검토했습니다:</p>
<ol>
<li><strong>Mockito 설정 검증</strong>: <code>Mockito.when(...).thenThrow(...)</code> 구문이 정확히 설정되었는지 확인했습니다.</li>
<li><strong>MockMvc 로그 분석</strong>: <code>andDo(print())</code>를 통해 상세한 실행 결과를 로깅하여 검토했습니다.</li>
<li><strong>MockMvc와 실제 서버 동작의 차이</strong>: MockMvc는 실제 HTTP 요청을 보내지 않고 서버의 디스패처 서블릿을 모의로 사용한다는 점을 고려했습니다.</li>
</ol>
<h2 id="3-해결-과정">3. 해결 과정</h2>
<p>문제의 원인은 <code>Mockito</code>의 인자 매칭이 정확히 이루어지지 않은 것으로 밝혀졌습니다. <code>userUpdateDTO</code> 객체가 테스트 중의 <code>userService.update</code> 메서드 호출과 정확히 일치하지 않아 예외가 발생하지 않았던 것입니다.</p>
<h2 id="4-해결-방법">4. 해결 방법</h2>
<p>ArgumentMatchers의 <code>any()</code> 메서드를 사용하여 타입만 일치하면 어떤 객체든 받아들일 수 있도록 설정을 변경했습니다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;사용자 업데이트 API 실패 UserNotFound 테스트&quot;)
void updateUserShouldReturn404NotFound() throws Exception {
    // ... 코드 생략 ...
    Mockito.when(userService.update(eq(userId), any(UserUpdateDTO.class)))
        .thenThrow(new NotFoundException(&quot;해당하는 유저가 없습니다.&quot;));
    // ... 코드 생략 ...
}</code></pre>
<p>이 변경 후, 테스트는 정상적으로 <code>NotFoundException</code>을 발생시키고, 404 Not Found 응답을 반환했습니다.</p>
<h2 id="5-결론">5. 결론</h2>
<p>이 경험을 통해 테스트 중에 <code>Mockito</code>인자 매칭의 정확성이 얼마나 중요한지 다시 한 번 깨달았습니다. 또한, <code>ArgumentMatchers</code>를 효과적으로 사용하여 테스트의 유연성과 견고성을 높일 수 있음을 배웠습니다. 테스트 중 예상치 못한 문제에 직면했을 때, 구체적인 로깅과 세심한 디버깅이 문제 해결의 열쇠가 될 수 있음을 잊지 말아야겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring's IoC and DI]]></title>
            <link>https://velog.io/@co-vol/Springs-IoC-and-DI</link>
            <guid>https://velog.io/@co-vol/Springs-IoC-and-DI</guid>
            <pubDate>Wed, 15 Nov 2023 01:01:15 GMT</pubDate>
            <description><![CDATA[<h2 id="1-iocinversion-of-control-제어의-역전">1. IoC(Inversion of Control) 제어의 역전</h2>
<h3 id="1-개요">1. 개요</h3>
<p>프로그램의 객체 또는 일부에 대한 제어를 컨테이너나 프레임워크로 이전하는 소프트웨어 엔지니어링의 원칙입니다. 예를들어 기존 프로그래밍에서는 애플리케이션의 비지니스 로직이 실행 흐름을 제어하고 라이브러리 함수나 객체를 호출합니다. 그러나 Ioc에서는 이러한 관게가 역전되어 프레임워크가 실행 흐름을 제어하고 애플리케이션 코드는 프레임워크에 의해 호출됩니다. 객체 지향 프로그래밍에서 가장 자주 사용되며 보다 모듈적이고 유연하며 쉽게 테스트할 수 있는 코드를 가능하게 합니다.</p>
<h3 id="2-작동방식">2. 작동방식</h3>
<p>소프트웨어 개발에서 <code>할리우드 원칙</code>은 애플리케이션 코드가 프레임워크나 라이브러리 코드를 호출하는 대신 프레임워크가 애플리케이션 코드를 호출하는 것을 의미합니다. 이는 의존성 주입, 이벤트 중심 프로그래밍 또는 템플릿 메서드와 같은 다양한 기술을 통해 이루어집니다. 프레임워크는 객체의 수명 주기를 관리하고, 필요에 따라 인스턴스를 생성하거나 폐기하며, 객체가 생성될 때 필요한 종속성을 제공하는 역할을 담당합니다.</p>
<blockquote>
<p><strong>헐리우드 원칙</strong>
&#39;당신이 할 일 중에서 내가 필요할 때 불러주면, 요청한 사항에 맞춰서 행동하겠다. &#39; 라고 정의한다. 즉, 내가 언제 어떻게 해야 할 지를 알고, 스스로 제어 할 수 있는 일이면 하되, 그밖에 영역의 일은 정의해서 알려주면 행동하겠다는 것이다.</p>
</blockquote>
<h3 id="3-사용의-장점">3. 사용의 장점</h3>
<ul>
<li><p><strong>커플링 감소</strong>: IoC를 사용하면 컴포넌트가 하드코딩된 종속성을 가질 필요가 없으므로 애플리케이션의 여러 부분 간의 결합이 줄어듭니다.</p>
</li>
<li><p><strong>모듈성 향상</strong>: 구성 요소가 더 느슨하게 결합되므로 애플리케이션이 더 모듈화되어 이해, 유지 관리 및 확장이 더 쉬워집니다.</p>
</li>
<li><p><strong>향상된 테스트</strong>: IoC를 사용하면 종속성을 모의 또는 스텁으로 쉽게 대체할 수 있으므로 단위 테스트가 더 간단해집니다.</p>
</li>
<li><p><strong>유연성 및 재사용성 향상</strong>: 컴포넌트가 특정 구현에 엄격하게 종속되지 않으므로 재사용성과 유연성이 향상됩니다.</p>
</li>
<li><p><strong>더 쉬운 구성 및 통합</strong>: 비즈니스 로직의 변경이나 다른 시스템과의 통합은 주로 구성을 통해 최소한의 코드 변경으로 이루어질 수 있습니다.</p>
</li>
</ul>
<p>전략 설계 패턴, 서비스 로케이터 패턴, 팩토리 패턴, 의존성 주입(DI)과 같은 다양한 메커니즘을 통해 IoC를 구현할 수 있으며 애플리케이션을 설계하고 개발하는 방식에 큰 변화를 가져와 <strong>더욱 견고하고 유지 관리가 용이하며 확장 가능한 시스템</strong>으로 이어집니다.</p>
<h2 id="2-didependency-injection-의존성-주입">2. DI(Dependency Injection) 의존성 주입</h2>
<p>DI의 정의 종속성 주입(DI)은 제어의 역전(IoC)을 구현하는 데 중요한 디자인 패턴입니다. 이는 객체가 직접 종속성을 생성하는 것이 아니라 객체에 외부 종속성을 제공하는 프로세스를 포함합니다. 이 패턴은 종속 객체의 구성과 사용법을 분리하여 보다 유연하고 유지 관리가 용이하며 테스트 가능한 코드를 만드는 데 유용합니다. DI에서 객체는 생성 시 종속성을 전달받으며, 일반적으로 객체와 종속성을 함께 어셈블하는 역할을 하는 &#39;인젝터&#39; 또는 &#39;컨테이너&#39;를 통해 종속성을 전달받습니다.</p>
<h3 id="1-유형">1. 유형</h3>
<h4 id="1-constructor-injection생성자-주입">1. Constructor Injection(생성자 주입)</h4>
<p>생성자 주입은 클래스 생성자를 통해 종속성이 제공됩니다. 인스턴스화할 때 필요한 종속성을 생성자에 매개변수로 전달하는 일반적인 기법입니다. 이 방법을 사용하면 객체가 항상 종속성과 함께 생성되므로 완전히 생성된 후에는 변경할 수 없습니다.</p>
<pre><code class="language-java">@Service
public class SomeService {
    private final UserRepository userRepository;
    private final SomeRepository someRepository;

    @Autowired
    public CoffeeServiceImpl(UserRepository userRepository, SomeRepository someRepository) {
        this.userRepository = userRepository;
        this.someRepository = someRepository;
    }
}
</code></pre>
<ul>
<li>생성자 호출 시점에 1번만 호출되는 것을 보장합니다.</li>
<li>불변과 필수 의존 관계에 사용합니다.</li>
<li>생성자가 1개만 존재하는 경우 @AutoWired를 생략해도 자동 주입됩니다.</li>
<li>NPE(NullPointerException)을 방지할 수 있습니다.</li>
<li>주입받을 필드를 final로 선언 가능합니다.<blockquote>
<p><strong>@Autowired</strong>
필요한 의존 객체의 “타입&quot;에 해당하는 빈을 찾아 주입한다.
생성자, setter, 필드
위의 3가지의 경우에 Autowired를 사용할 수 있다. 그리고 Autowired는 기본값이 true이기 때문에 의존성 주입을 할 대상을 찾지 못한다면 애플리케이션 구동에 실패한다.</p>
</blockquote>
</li>
</ul>
<h4 id="2-setter-injection수정자-주입">2. Setter Injection(수정자 주입)</h4>
<p>수정자 주입은 클래스의 세터 메서드를 통해 종속성을 주입하는 것입니다. 이 방법을 사용하면 객체의 인스턴스화 후에 종속성을 설정하거나 변경할 수 있으므로 유연성이 향상됩니다. 그러나 세터가 호출되지 않으면 객체가 불완전한 상태가 될 수 있습니다.</p>
<pre><code class="language-java">@Service
public class SomeService {
    private final UserRepository userRepository;
    private final SomeRepository someRepository;

    @Autowired
    public void setUserRepository(UserRepository userRepository) {
        this.userRepository = userRepository
    }

    @Autowired
    public void setSomeRepository(SomeRepository someRepository) {
        this.someRepository = someRepository
    }
}</code></pre>
<ul>
<li>선택과 변경 가능성이 있는 의존 관계에 사용됩니다.</li>
<li>set필드명 메서드를 생성하여 의존 관계를 주입합니다.</li>
<li>@Autowired를 입력하지 않으면 실행이 되지 않습니다.</li>
</ul>
<h4 id="3-field-injection필드-주입">3. Field Injection(필드 주입)</h4>
<p>필드 주입은 클래스의 필드에 종속성을 직접 주입합니다. 가장 간단한 형태이고 코드가 덜 필요하지만 클래스의 공용 API를 우회하기 때문에 객체 상태를 테스트하고 관리하기가 어렵기 때문에 일반적으로 선호되지 않습니다.</p>
<pre><code class="language-java">@Service
public class SomeService {
    @Autowired
    private final UserRepository userRepository;
    @Autowired
    private final UserRepository userRepository;
}
</code></pre>
<ul>
<li>외부에서 변경이 불가능하여 테스트하기 어려운 단점이 있습니다.</li>
<li>DI 프레임워크가 존재하지 않는다면 아무것도 할 수 없습니다.</li>
<li>주로 애플리케이션의 실제 코드와 상관없는 특정 테스트 진행시 사용합니다.</li>
<li>정상적으로 사용하려면 결국 Setter 가 필요합니다.</li>
<li>만약 의존관계를 필수적으로 넣지 않으려면 @Autowired(required=false) 옵션 통해 처리 가능합니다.</li>
</ul>
<p><strong>필드 주입을 일반적으로 선호하지 않는 이유</strong>는 DI 컨테이너 내부에서만 작동하며, 순수 자바 코드로 테스트 하기 어러움이 있습니다. 그리고 final 키워드를 통해 불변 속성이라고 볼 수도 없고, Setter로 가변 속성이라고 볼 수도 없는 애매한 상황이 발생합니다.</p>
<h3 id="2-장점">2. 장점</h3>
<ul>
<li><strong>느슨한 결합</strong>: 종속성 주입(DI)은 <code>소비자 클래스</code>의 종속성을 핵심 동작으로부터 분리하는 역할을 합니다. 이러한 분리는 종속성의 변경이나 구현 방식이 이러한 종속성을 사용하는 클래스에 거의 또는 전혀 영향을 미치지 않음을 의미합니다. 즉, 클래스가 종속성의 세부 사항에 얽매이지 않으므로 보다 모듈화되고 적응력이 뛰어난 코드 구조가 만들어집니다.</li>
</ul>
<blockquote>
<p><strong>소비자 클래스</strong>
애플리케이션에서 다른 컴포넌트나 서비스에 종속되어 작업을 수행하는 클래스입니다. 예를 들어 전자상거래 애플리케이션에 OrderProcessor 클래스가 있는 경우 이 클래스는 결제 처리를 처리하기 위해 PaymentService 클래스에 종속되고 배송 물류를 처리하기 위해 ShippingService 클래스에 종속될 수 있습니다.</p>
</blockquote>
<ul>
<li><p><strong>테스트의 간소화</strong>: 테스트 중에 클래스에 모의 또는 더미 버전의 종속성을 주입하면 단위 테스트를 더 간단하게 수행할 수 있습니다. 이 접근 방식은 종속성의 실제 구현에 의존하지 않기 때문에 테스트에 집중하고, 관리하기 쉬우며, 안정성을 높일 수 있습니다.</p>
</li>
<li><p><strong>코드 유지 관리 개선</strong>: 클래스 내에서 종속성을 생성하는 대신 종속성을 주입하면 코드가 더 깔끔하고 가독성이 높아집니다. 이 접근 방식은 클래스가 의존하는 구성 요소와 사용 방법을 명확히 하기 때문에 코드베이스를 더 쉽게 탐색하고 유지 관리할 수 있습니다.</p>
</li>
<li><p><strong>유연성 및 확장성</strong>: 의존성 주입은 애플리케이션의 유연성과 확장성에 기여합니다. 전체 코드에서 필요한 최소한의 조정만으로 개별 컴포넌트를 쉽게 교체하거나 업그레이드할 수 있습니다. 이러한 적응성은 애플리케이션의 유지보수 및 확장에 매우 중요한 역할을 합니다.</p>
</li>
</ul>
<p>DI를 사용하는 것은 최신 소프트웨어 개발 관행에 따라 확장 가능하고 유지 관리 및 테스트가 가능한 시스템을 구축하는 데 중요한 역할을 합니다.</p>
<h2 id="spring-프레임워크의-ioc와-di">Spring 프레임워크의 IoC와 DI</h2>
<p>Spring에서 IoC 및 DI 구현 Spring 프레임워크는 IoC 및 DI를 강력하게 구현하여 아키텍처의 초석으로 삼습니다. 이 프레임워크는 Bean(Spring의 객체)의 생성 및 <code>wiring</code>을 관리하고 사용하기 전에 모든 종속성이 충족되는지 확인합니다. 이는 빈의 인스턴스화, 구성, 어셈블리를 담당하는 IoC 컨테이너를 통해 이루어집니다.</p>
<blockquote>
<p><strong>빈 와이어링(wiring)</strong>
스프링을 사용하는 애플리케이션에서 각 객체가 필요한 다른 객체를 직접 찾거나 생성할 필요가 없다. 컨테이너가 협업할 객체에 대한 정보를 주기 때문이다. 애플리케이션 객체 간의 이러한 연관 관계 형성 작업이 바로 의존성 주입(Dependency Injection)의 핵심이며, 이를 보통 와이어링(wiring)이라 한다.</p>
</blockquote>
<p>이 프로세스에는 일반적으로 구성 파일이나 어노테이션에 빈과 해당 종속성을 정의하는 작업이 포함되며, Spring 컨테이너는 빈이 생성될 때 이러한 종속성을 생성하고 주입하는 작업을 처리합니다. 이 접근 방식은 애플리케이션의 구성 및 종속성 사양을 실제 애플리케이션 코드와 분리하여 보다 깔끔하고 모듈화된 코드를 생성합니다.</p>
<h3 id="1-spring의-ioc-컨테이너">1. Spring의 IoC 컨테이너</h3>
<p>Spring의 IoC 컨테이너는 기본적으로 객체 생성, 수명 주기 관리, 구성 및 종속성 관리를 담당하는 정교한 팩토리입니다. 컨테이너는 XML 파일, 어노테이션 또는 Java 기반 구성으로 구성할 수 있습니다. 싱글톤(컨테이너당 하나의 인스턴스), 프로토타입(매번 새로운 인스턴스), 요청, 세션 등 웹 애플리케이션을 위한 여러 가지 범위의 빈을 지원하므로 빈의 수명 주기를 유연하게 관리할 수 있습니다.</p>
<h3 id="2-구성">2. 구성</h3>
<ul>
<li><p><strong>XML 구성</strong>: Spring 초창기에는 XML 파일이 구성에 광범위하게 사용되었습니다. 여기에는 XML 파일에 빈과 그 종속성을 정의하는 것이 포함됩니다. Spring 컨테이너는 이 파일을 읽고 그에 따라 빈을 생성합니다. XML 구성은 빈을 관리하기 위한 중앙 집중식 장소를 제공하지만, 대규모 애플리케이션에서는 장황하고 관리하기 어려울 수 있습니다.</p>
</li>
<li><p><strong>어노테이션 기반 구성</strong>: Spring 프레임워크의 발전과 함께, 어노테이션 기반 구성이 더욱 널리 사용되고 있습니다. autowiring 종속성을 위한 @Autowired, 빈을 선언하기 위한 @Component, Java 기반 구성을 위한 @Configuration, @Bean과 같은 어노테이션이 사용됩니다. 어노테이션 기반 구성은 XML 파일의 필요성을 줄이고 코드를 더 읽기 쉽고 유지 관리하기 쉽게 만듭니다. 또한 빈 구성을 보다 세밀하게 제어할 수 있으며 최신 Java 에서 주로 사용됩니다.</p>
</li>
</ul>
<p>전반적으로 Spring 프레임워크에서 IoC와 DI를 구현하면 Java 애플리케이션 개발이 크게 간소화됩니다. 느슨한 결합과 높은 응집력과 같은 모범 사례를 장려하여 애플리케이션의 확장성, 유지보수성, 테스트 가능성을 높입니다.</p>
<h2 id="참고자료">참고자료</h2>
<p><a href="https://www.baeldung.com/inversion-control-and-dependency-injection-in-spring">baeldung-Intro to Inversion of Control and Dependency Injection with Spring</a>
<a href="https://jobc.tistory.com/30">[Spring] IoC, DI 란?</a>
<a href="https://ittrue.tistory.com/227">[Spring] 스프링 의존성 주입</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT + Spring Security]]></title>
            <link>https://velog.io/@co-vol/JWT-Spring-Security</link>
            <guid>https://velog.io/@co-vol/JWT-Spring-Security</guid>
            <pubDate>Tue, 14 Nov 2023 07:03:27 GMT</pubDate>
            <description><![CDATA[<h3 id="넷플릭스-클론">넷플릭스 클론</h3>
<p>넷플릭스 클론 코딩을 진행하며 JWT로그인을 시도한 기록입니다.</p>
<h2 id="1-application-architecture">1. Application Architecture</h2>
<h3 id="1-개발환경">1. 개발환경</h3>
<pre><code>java: 17
springBoot: 3.1.5
springSecurity: 6.1.2
jjwt: 0.12.3
gradle: 8.3</code></pre><h3 id="2-아키텍쳐">2. 아키텍쳐</h3>
<h4 id="시나리오">시나리오</h4>
<p><img src="https://velog.velcdn.com/images/co-vol/post/37cc083b-a154-435a-8eed-a8e8f5dcbbe2/image.png" alt=""></p>
<ul>
<li>사용자가 서비스 측에 계정 생성을 요청합니다.</li>
<li>사용자가 서비스에 계정 인증 요청을 제출합니다.</li>
<li>인증된 사용자가 리소스 액세스 요청을 보냅니다.<h4 id="sign-up">Sign Up</h4>
<img src="https://velog.velcdn.com/images/co-vol/post/8a906718-6363-457c-bcaa-9662af2f04bb/image.png" alt=""></li>
</ul>
<ol>
<li>사용자가 서비스에 가입요청을 제출하면 요청 데이터에서 사용자 객체가 생성됩니다.</li>
<li>userService가 호출되어 사용자 객체 유효성 검사와 JPA를 활용해 사용자가 데이터베이스에 저장 됩니다.</li>
<li>JwtUtils가 호출되어 사용자 객체에 대한 JWT를 반환합니다</li>
<li>JWT는 JSON 응답 내부에서 캡슐화되어 사용자에게 반환됩니다. <h4 id="sign-in">Sign In</h4>
<img src="https://velog.velcdn.com/images/co-vol/post/484372ac-35c9-4da0-acd5-6225845e0764/image.png" alt=""></li>
<li>사용자가 서비스에 로그인 요청을 보내면 제공된 사용자 이름과 비밀번호를 사용해<code>UsernamePasswordAuthenticationToken</code>이라는 인증 객체가 생성됩니다.</li>
<li>사용자의 이름이나 비밀번호가 올바르지 않으면 예외가 발생하고 HTTP 4xx 응답이 사용자에게 반환 됩니다.</li>
<li>인증에 성공하면 데이터베이스에서 사용자를 검색하고 사용자가 존재하지 않으면 HTTP 4xx 응답이 사용자에게 전송됩니다.</li>
<li>사용자 정보가 확보되면 JwtUtils를 호출하여 JWT를 생성하고 JSON 응답 내부에서 캡슐화되어 사용자에게 반환 됩니다</li>
</ol>
<h3 id="resources-test">resources test</h3>
<h2 id="2-gradle-설정">2. Gradle 설정</h2>
<pre><code>    implementation &#39;io.jsonwebtoken:jjwt-api:0.12.3&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.12.3&#39;
    runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.12.3&#39;</code></pre><h2 id="3-소스코드">3. 소스코드</h2>
<h3 id="github">GitHub</h3>
<p><a href="https://github.com/Get-bot/netflixCloneServer">https://github.com/Get-bot/netflixCloneServer</a></p>
<h3 id="1entity-구현">1.Entity 구현</h3>
<h4 id="userentity">userEntity</h4>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = &quot;users&quot;)
public class User {

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

  private String email;

  private String username;

  private String password;

  @CreatedDate
  private LocalDateTime createdAt;

  @OneToMany(mappedBy = &quot;user&quot;, orphanRemoval = true, cascade = CascadeType.ALL)
  private Set&lt;UserRoles&gt; roles = new HashSet&lt;&gt;();

  private Integer status;

  public static User registerUser(String email, String username, String password, Set&lt;Role&gt; roles) {
    User user = new User();
    user.email = email;
    user.username = username;
    user.password = password;
    user.status = UserState.ACTIVE.getValue();

    for (Role role : roles) {
      UserRoles userRoles = UserRoles.createUserRoles(user, role);
      user.getRoles().add(userRoles);
    }

    return user;
  }

}</code></pre>
<h4 id="roleentity">RoleEntity</h4>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;roles&quot;)
public class Role {

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

  @Enumerated(EnumType.STRING)
  private ERole name;

  private String description;

}
</code></pre>
<h4 id="userrolesjuntionentity">UserRolesJuntionEntity</h4>
<pre><code class="language-java">@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;user_roles&quot;)
public class UserRoles {

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

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = &quot;user_id&quot;)
  private User user;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = &quot;role_id&quot;)
  private Role role;


  public static UserRoles createUserRoles(User user, Role role) {
    UserRoles userRoles = new UserRoles();
    userRoles.user = user;
    userRoles.role = role;
    return userRoles;
  }

}</code></pre>
<h4 id="erole">ERole</h4>
<pre><code class="language-java">public enum ERole {
  ROLE_USER,
  ROLE_MODERATOR,
  ROLE_ADMIN
}
</code></pre>
<h3 id="2-저장소구현">2. 저장소구현</h3>
<h4 id="userrepository">UserRepository</h4>
<pre><code class="language-java">@Repository
public interface UserRepository extends JpaRepository&lt;User, Long&gt; {
  Optional&lt;User&gt; findByEmail(String email);
  Boolean existsByEmail(String email);
}</code></pre>
<h4 id="rolerepository">RoleRepository</h4>
<pre><code class="language-java">@Repository
public interface RoleRepository extends JpaRepository&lt;Role, Long&gt; {
  Optional&lt;Role&gt; findByName(ERole name);
}
</code></pre>
<h3 id="3-스프링-보안-구성">3. 스프링 보안 구성</h3>
<h4 id="websecurityconfig">WebSecurityConfig</h4>
<pre><code class="language-java">@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@AllArgsConstructor
public class WebSecurityConfig {

  private final UserDetailsServiceImpl userDetailsService;

  private final AuthEntryPointJwt unauthorizedHandler;

  @Bean
  public AuthTokenFilter authenticationJwtTokenFilter() {
    return new AuthTokenFilter();
  }

  @Bean
  public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    authProvider.setUserDetailsService(userDetailsService);
    authProvider.setPasswordEncoder(passwordEncoder());
    return authProvider;
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
    return authConfig.getAuthenticationManager();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(AbstractHttpConfigurer::disable)
        .exceptionHandling(exception -&gt; exception.authenticationEntryPoint(unauthorizedHandler))
        .sessionManagement(session -&gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -&gt;
            auth.requestMatchers(&quot;/api/auth/**&quot;).permitAll()
                .requestMatchers(&quot;/api/test/**&quot;).permitAll()
                .anyRequest().authenticated()
        );

    http.authenticationProvider(authenticationProvider());

    http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }
}</code></pre>
<ul>
<li><p><code>@EnableWebSecurity</code> 주석은 Spring 보안의 웹 보안 지원을 활성화합니다. 이것은 Spring Security가 웹 기반 보안을 제공하도록 설정하는 것을 의미합니다. 이 주석이 적용된 설정 클래스는 웹 보안을 위한 다양한 보안 규칙과 구성을 정의합니다. 예를 들어, URL 패턴에 따른 접근 제어, 로그인 페이지의 구성, 세션 관리 등이 이에 해당합니다. <code>@EnableWebSecurity</code>를 사용하면 Spring Security가 이러한 설정을 찾아서 전체 애플리케이션의 글로벌 웹 보안 설정으로 자동 적용합니다.</p>
</li>
<li><p><code>@EnableMethodSecurity</code> 주석은 메소드 수준의 보안을 활성화합니다. 이 주석은 클래스 또는 메소드 수준에서 보안 정책을 세밀하게 정의할 수 있게 해 줍니다. 예를 들어, 특정 역할을 가진 사용자만이 특정 메소드를 호출할 수 있도록 제한하거나, 메소드 실행 전 후에 특정 보안 규칙을 적용할 수 있습니다. <code>@PreAuthorize</code>, <code>@PostAuthorize</code>, <code>@Secured</code> 등의 주석을 메소드에 적용하여 이러한 보안 정책을 구현할 수 있습니다</p>
</li>
<li><p>WebSecurityConfigurerAdapter 인터페이스에서 configure(HttpSecurity http) 메서드를 재정의합니다. 이 메서드는 모든 사용자에게 인증을 요구할지 여부, 모든 사용자에게 인증을 요구할지 여부, 어떤 필터(AuthTokenFilter)를 사용할지 여부, 언제 작동할지 여부(UsernamePasswordAuthenticationFilter 앞에 필터링), 어떤 예외 처리기(AuthEntryPointJwt)를 선택할지 여부 등을 Spring Security에 알려줍니다.</p>
</li>
<li><p>Spring Security는 인증 및 권한 부여를 수행하기 위해 사용자 세부 정보를 로드합니다. 따라서 구현해야 하는 UserDetailsService 인터페이스가 있습니다.</p>
</li>
<li><p>UserDetailsService의 구현은 AuthenticationManagerBuilder.userDetailsService() 메서드에 의해 DaoAuthenticationProvider를 구성하는 데 사용됩니다.</p>
</li>
<li><p>또한 DaoAuthenticationProvider에 대한 PasswordEncoder가 필요합니다. 지정하지 않으면 일반 텍스트를 사용합니다.</p>
</li>
</ul>
<h3 id="4-userdetails-구현">4. userDetails 구현</h3>
<p>애플리케이션에서 인증 및 권한 부여를 위해 Spring Security를 사용하는 경우, 사용자별 데이터를 Spring Security API에 제공하고 인증 프로세스 중에 사용해야 합니다. 이 사용자별 데이터는 UserDetails 객체에 캡슐화됩니다. UserDetails는 다양한 메서드를 포함하는 인터페이스입니다.</p>
<p>자세한 정보 : <a href="https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/core/userdetails/UserDetails.html">UserDetails</a></p>
<h4 id="userdetails">userDetails</h4>
<p>인증 프로세스가 성공하면 인증 객체에서 사용자 이름, 비밀번호, 권한과 같은 사용자 정보를 가져올 수 있습니다.</p>
<pre><code class="language-java">public class UserDetailsImpl implements UserDetails {
  private static final long serialVersionUID = 1L;

  private final Long id;

  private final String email;

  private final String username;

  @JsonIgnore // 이 어노테이션은 JSON으로 변환될 때, password를 제외시킴
  private final String password;

  private final Collection&lt;? extends GrantedAuthority&gt; authorities;

  public UserDetailsImpl(Long id, String username, String email, String password,
                         Collection&lt;? extends GrantedAuthority&gt; authorities) {
    this.id = id;
    this.email = email;
    this.username = username;
    this.password = password;
    this.authorities = authorities;
  }

  public static UserDetailsImpl build(User user) {
    List&lt;GrantedAuthority&gt; authorities = user.getRoles().stream()
        .map(role -&gt; new SimpleGrantedAuthority(role.getRole().getName().name()))
        .collect(Collectors.toList());

    return new UserDetailsImpl(
        user.getId(),
        user.getUsername(),
        user.getEmail(),
        user.getPassword(),
        authorities);
  }

  @Override
  public Collection&lt;? extends GrantedAuthority&gt; getAuthorities() {
    return authorities;
  }

  public Long getId() {
    return id;
  }

  public String getEmail() {
    return email;
  }

  @Override
  public String getPassword() {
    return password;
  }

  @Override
  public String getUsername() {
    return username;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o)
      return true;
    if (o == null || getClass() != o.getClass())
      return false;
    UserDetailsImpl user = (UserDetailsImpl) o;
    return Objects.equals(id, user.id);
  }

}</code></pre>
<p>위의 코드를 보면 <code>Set&lt;Role&gt;</code>을 <code>List&lt;GrantedAuthority&gt;</code>로 변환한 것을 알 수 있습니다. 나중에 사용할 Spring 보안 및 인증 객체로 작업하는 것이 중요합니다.</p>
<h4 id="userdetailsimpl">userDetailsImpl</h4>
<p>loadUserByUsername() 메서드를 재정의합니다.</p>
<pre><code class="language-java">@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  @Transactional(readOnly = true)
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    User user = userRepository.findByEmail(email)
        .orElseThrow(() -&gt; new UsernameNotFoundException(&quot;회원의 이메일을 찾을 수 없습니다: &quot; + email));
    return UserDetailsImpl.build(user);
  }
}</code></pre>
<p>UserRepository를 사용하여 전체 사용자 정의 User 객체를 가져온 다음 정적 build() 메서드를 사용하여 UserDetails 객체를 빌드합니다.</p>
<h3 id="5-요청-필터링">5. 요청 필터링</h3>
<h4 id="authtokenfilter">AuthTokenFilter</h4>
<pre><code class="language-java">@RequiredArgsConstructor
public class AuthTokenFilter extends OncePerRequestFilter {

  private static final Logger logger = Logger.getLogger(AuthTokenFilter.class.getName());
  private final JwtUtils jwtUtils;
  private final UserDetailsServiceImpl userDetailsService;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    try {
      String jwt = parseJwt(request);
      if (jwt != null &amp;&amp; jwtUtils.vaildateJwtToken(jwt)) {
        String email = jwtUtils.getUserNameFromJwtToken(jwt);

        var userDetails = userDetailsService.loadUserByUsername(email);
        UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    } catch (Exception e) {
      logger.severe(&quot;Cannot set user authentication: &quot; + e);
    }

    filterChain.doFilter(request, response);
  }

  private String parseJwt(HttpServletRequest request) {
    String headerAuth = request.getHeader(&quot;Authorization&quot;);

    if (StringUtils.hasText(headerAuth) &amp;&amp; headerAuth.startsWith(&quot;Bearer &quot;)) {
      return headerAuth.substring(7);
    }

    return null;
  }
}</code></pre>
<p>doFilterInternal() 내부에서 수행하는 작업:</p>
<ul>
<li><p>Authorization 헤더에서 JWT를 가져옵니다(Bearer 접두사를 제거).</p>
</li>
<li><p>요청에 JWT가 있으면 유효성을 검사하고 <code>email</code>을 구문 분석합니다.</p>
</li>
<li><p><code>email</code>에서 <code>UserDetails</code>를 가져와 인증 객체를 만듭니다.</p>
</li>
<li><p><code>setAuthentication(authentication)</code> 메서드를 사용하여 SecurityContext에서 현재 <code>UserDetails</code>를 설정합니다.</p>
<h3 id="6-jwt-유틸리티-클래스-생성">6. JWT 유틸리티 클래스 생성</h3>
</li>
<li><p>사용자 이름, 날짜, 만료일, 비밀번호에서 JWT 생성하기</p>
</li>
<li><p>JWT에서 사용자 이름 가져오기</p>
</li>
<li><p>JWT 유효성 검사</p>
<pre><code class="language-java">@Component
public class JwtUtils {
private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);

private final SecretKey key;
@Value(&quot;${netflixclone.app.jwtExpirationMs}&quot;)
private int jwtExpirationMs;

public JwtUtils(@Value(&quot;${netflixclone.app.jwtSecret}&quot;) String jwtSecret) {
  this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret));
}

public String generateJwtToken(Authentication authentication) {

  UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();

  return Jwts.builder()
      .subject(userPrincipal.getEmail())
      .claim(&quot;roles&quot;, userPrincipal.getAuthorities())
      .issuedAt(new Date())
      .expiration(new Date((new Date()).getTime() + jwtExpirationMs))
      .signWith(key)
      .compact();
}

public String getUserNameFromJwtToken(String token) {
  return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject();
}

public boolean vaildateJwtToken(String token) {
  try {
    Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
    return true;
  } catch (MalformedJwtException e) {
    logger.error(&quot;Invalid JWT token: {}&quot;, e.getMessage());
  } catch (ExpiredJwtException e) {
    logger.error(&quot;JWT token is expired: {}&quot;, e.getMessage());
  } catch (UnsupportedJwtException e) {
    logger.error(&quot;JWT token is unsupported: {}&quot;, e.getMessage());
  } catch (IllegalArgumentException e) {
    logger.error(&quot;JWT claims string is empty: {}&quot;, e.getMessage());
  }
  return false;
}
</code></pre>
</li>
</ul>
<p>}</p>
<pre><code>### 7. 인증 예외처리
이제 AuthenticationEntryPoint 인터페이스를 구현하는 AuthEntryPointJwt 클래스를 생성합니다. 그런 다음 commence() 메서드를 재정의합니다. 이 메서드는 인증되지 않은 사용자가 보안 HTTP 리소스를 요청하고 AuthenticationException이 발생할 때마다 트리거됩니다.
#### AuthEntryPointJwt
```java
@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
  private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
      throws IOException, ServletException {
    logger.error(&quot;Unauthorized error: {}&quot;, authException.getMessage());
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    final Map&lt;String, Object&gt; body = new HashMap&lt;&gt;();
    body.put(&quot;status&quot;, HttpServletResponse.SC_UNAUTHORIZED);
    body.put(&quot;error&quot;, &quot;Unauthorized&quot;);
    body.put(&quot;message&quot;, authException.getMessage());
    body.put(&quot;path&quot;, request.getServletPath());

    final ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(response.getOutputStream(), body);
  }
}</code></pre><h3 id="8-spring-restapi-컨트롤러-생성">8. Spring RestAPI 컨트롤러 생성</h3>
<h4 id="authcontroller">AuthController</h4>
<pre><code class="language-java">@CrossOrigin(origins = &quot;*&quot;, maxAge = 3600)
@RestController
@RequestMapping(&quot;/api/auth&quot;)
@RequiredArgsConstructor
public class AuthController {
  private final AuthenticationManager authenticationManager;

  private final UserRepository userRepository;

  private final UserService userService;

  private final JwtUtils jwtUtils;

  @PostMapping(&quot;/signin&quot;)
  public ResponseEntity&lt;?&gt; authenticateUser(@RequestBody LoginRequest loginRequest) {
    return ResponseEntity.ok().body(authenticateAndGenerateJWT(loginRequest.getEmail(), loginRequest.getPassword()));
  }

  @PostMapping(&quot;/signup&quot;)
  public ResponseEntity&lt;?&gt; registerAndAuthenticateUser(@RequestBody SignupRequest signupRequest) throws CustomException {

    // 유저 등록
    userService.registerUser(signupRequest);

    JwtResponse jwtResponse = authenticateAndGenerateJWT(signupRequest.getEmail(), signupRequest.getPassword());
    ApiResponse&lt;JwtResponse&gt; response = ApiResponse.setApiResponse(true, &quot;회원 가입이 완료 되었습니다!&quot;, jwtResponse);

    return ResponseEntity.ok().body(response);
  }

  private JwtResponse authenticateAndGenerateJWT(String email, String password) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(email, password));
    SecurityContextHolder.getContext().setAuthentication(authentication);

    String jwt = jwtUtils.generateJwtToken(authentication);
    UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
    List&lt;String&gt; roleNames = userDetails.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .toList();

    return JwtResponse.setJwtResponse(jwt, userDetails.getId(), userDetails.getUsername(), userDetails.getEmail(), roleNames);
  }

}</code></pre>
<h4 id="apicontroller">ApiController</h4>
<pre><code class="language-java">@CrossOrigin(origins = &quot;*&quot;, maxAge = 3600)
@RestController
@RequestMapping(&quot;/api/test&quot;)
public class ApiController {

  @GetMapping(&quot;/all&quot;)
  public String allAccess() {
    return &quot;Public Content.&quot;;
  }

  @GetMapping(&quot;/user&quot;)
  @PreAuthorize(&quot;hasRole(&#39;USER&#39;) or hasRole(&#39;MODERATOR&#39;) or hasRole(&#39;ADMIN&#39;)&quot;)
  public String userAccess() {
    return &quot;User Content.&quot;;
  }

  @GetMapping(&quot;/mod&quot;)
  @PreAuthorize(&quot;hasRole(&#39;MODERATOR&#39;)&quot;)
  public String moderatorAccess() {
    return &quot;Moderator Board.&quot;;
  }

  @GetMapping(&quot;/admin&quot;)
  @PreAuthorize(&quot;hasRole(&#39;ADMIN&#39;)&quot;)
  public String adminAccess() {
    return &quot;Admin Board.&quot;;
  }

}</code></pre>
<h2 id="4-실행-테스트-해보기">4. 실행 테스트 해보기</h2>
<h3 id="sign-in-1">Sign in</h3>
<h4 id="success">success</h4>
<p><img src="https://velog.velcdn.com/images/co-vol/post/39298d62-8852-4dff-82ba-f811abf0b8e5/image.png" alt=""></p>
<h4 id="fail">fail</h4>
<p><img src="https://velog.velcdn.com/images/co-vol/post/1ebde492-b02d-4b92-969f-654b4963983c/image.png" alt=""></p>
<h3 id="test">Test</h3>
<h4 id="success-1">success</h4>
<p><img src="https://velog.velcdn.com/images/co-vol/post/bfc374fb-6835-427e-8bcd-f77d2e99c88e/image.png" alt=""></p>
<h4 id="fail-1">fail</h4>
<p><img src="https://velog.velcdn.com/images/co-vol/post/998543ea-bdd0-4fc1-8a3c-abb4dcf2a911/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT란?]]></title>
            <link>https://velog.io/@co-vol/JWT-%EC%95%88%EB%82%B4%EB%AC%B8</link>
            <guid>https://velog.io/@co-vol/JWT-%EC%95%88%EB%82%B4%EB%AC%B8</guid>
            <pubDate>Tue, 14 Nov 2023 02:21:11 GMT</pubDate>
            <description><![CDATA[<p>JWT를 사용하며 JWT에 대해 정리한 글입니다.</p>
<h3 id="1-jwt란">1. <strong>JWT란?</strong></h3>
<p>JWT(JSON WEB TOKEN)는 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준입니다.</p>
<p>간단히 말해서 서로 다른 서비스 간에 발신자를 확인할 수 있는 민감한 정보가 포함된 json 형식의 암호화된 문자열입니다.</p>
<h3 id="2-jwt의-구조">2. <strong>JWT의 구조</strong></h3>
<p>JWT는 헤더, 페이로드, 서명의 세 부분으로 구성됩니다. 각 부분은 토큰의 보안과 무결성을 보장하는 데 중요한 역할을 합니다.</p>
<p>따라서 JWT는 일반적으로 다음과 같은 모양을 취합니다.</p>
<p><span style="color:red"><code>xxxxx.yyyyy.zzzzz</code></span></p>
<h4 id="1-header">1. Header</h4>
<p>헤더는 일반적으로 두 부분으로 구성됩니다. 토큰 유형(JWT)과 사용 중인 서명 알고리즘(예: HMAC SHA256 또는 RSA)이 그것입니다.</p>
<pre><code class="language-json">{
  &quot;alg&quot;: &quot;HS256&quot;,
  &quot;typ&quot;: &quot;JWT&quot;
}</code></pre>
<p>그런 다음 이 JSON은 Base64Url로 인코딩되어 JWT의 첫 번째 부분이 됩니다.</p>
<h4 id="2-payload">2. Payload</h4>
<p>토큰의 두 번째 부분은 페이로드이며, 여기에는 클레임이 포함됩니다. 클레임은 엔티티(일반적으로 사용자)와 추가 데이터에 대한 표현입니다. 클레임에는 등록 클레임, 공개 클레임, 비공개 클레임의 세 가지 유형이 있습니다.</p>
<ul>
<li><p><strong>등록 클레임(Registered claims)</strong> 
토큰에 대한 정보들을 담고있는 필수는 아니지만 권장되는 사전 정의된 클레임 세트입니다. </p>
<p><strong>iss</strong> (Issuer) : JWT 발급자
발행한 주체를 식별합니다.
<strong>sub</strong> (Subject) : JWT의 주체(사용자)
<strong>aud</strong> (Audience) : JWT가 대상인 수신자
<strong>exp</strong> (Expiration Time) : JWT가 만료되는 시간
NumericDate 형식으로 되어있어야 하며 언제나 현재 시간보다 이후로 설정되어 있어야 합니다.
<strong>nbf</strong> (Not Before) : 처리를 위해 JWT를 수락해서는 안 되는 시간
NumericDate 형식으로 되어있어야 하며 클레임을 처리하려면 현재 시간과 같거나 이전 이어야 합니다. 
<strong>iat</strong> (Issued At) :  JWT가 발급된 시간으로, JWT의 연령을 결정하는 데 사용할 수 있습니다.
<strong>jti</strong> (JWT ID) : 고유 식별자, JWT가 반복되는 것을 방지하는 데 사용할 수 있습니다(토큰을 한 번만 사용할 수 있음).</p>
</li>
<li><p><strong>공개 클레임(public claims)</strong>
이름 및 이메일과 같은 일반 정보를 포함할 수 있는 공개용 사용자 지정 클레임을 만들 수 있습니다. 공개 클레임을 생성하는 경우 등록하거나 네임스페이스를 통해 충돌 방지 이름을 사용해야 하며, 사용하는 네임스페이스를 통제할 수 있도록 적절한 예방 조치를 취해야 합니다.</p>
</li>
<li><p><strong>비공개 클레임(Private claims)</strong>
비공개 사용자 지정 클레임을 만들어 애플리케이션과 관련된 정보를 공유할 수 있습니다. 예를 들어 공개 클레임에는 이름, 이메일과 같은 일반적인 정보가 포함될 수 있지만 비공개 클레임에는 직원 ID, 부서 이름과 같은 보다 구체적인 정보가 포함될 수 있습니다.</p>
</li>
</ul>
<h4 id="3-signature">3. signature</h4>
<p>서명은 JWT 발신자가 실제 발신자인지 확인하고 메시지가 중간에 변경되지 않았는지 확인하는 데 사용됩니다.</p>
<p>서명을 생성하기 위해 Base64로 인코딩된 헤더와 페이로드를 비밀과 함께 가져와서 헤더에 지정된 알고리즘으로 서명합니다.</p>
<p>예를 들어 HMAC SHA256 알고리즘을 사용하여 토큰에 대한 서명을 만드는 경우 다음과 같이 하면 됩니다:</p>
<pre><code>HMACSHA256(
      base64UrlEncode(header) + &quot;.&quot; +
      base64UrlEncode(payload),
      secret)</code></pre><h3 id="3-jwt-작동-방식">3. <strong>JWT 작동 방식</strong></h3>
<h4 id="1-토큰-생성-과정">1. 토큰 생성 과정</h4>
<ul>
<li><p><strong>헤더와 페이로드 설정</strong>: JWT는 먼저 헤더(header)와 페이로드(payload)로 구성됩니다. 헤더는 토큰의 유형(JWT)과 사용된 서명 알고리즘을 명시합니다. 페이로드는 클레임(claim)이라고 불리는 정보 조각을 포함하며, 이는 사용자 식별 정보나 토큰에 대한 다른 데이터를 담을 수 있습니다.</p>
</li>
<li><p><strong>서명 생성</strong>: 헤더와 페이로드는 각각 Base64로 인코딩되고, 이 둘을 합친 다음 서버의 비밀 키 또는 공개/개인 키 쌍을 사용하여 서명(sign)합니다. 이 서명 과정은 토큰이 중간에 변경되지 않았음을 보장하는 데 중요합니다.</p>
<h4 id="2-토큰-검증-과정">2. <strong>토큰 검증 과정</strong></h4>
</li>
<li><p><strong>토큰의 서명 검증</strong> : 클라이언트로부터 받은 JWT는 서버에서 서명을 검증합니다. 이는 서버가 가지고 있는 비밀 키 또는 공개 키를 이용하여 이루어집니다. 서명이 유효하면 토큰이 안전하게 생성되었으며, 중간에 변경되지 않았음을 의미합니다.</p>
</li>
<li><p><strong>페이로드의 정보 사용</strong>: 서명이 유효하다고 확인되면, 페이로드에서 정보를 추출하여 사용합니다. 이 정보는 사용자 인증, 권한 부여 등에 활용될 수 있습니다.</p>
</li>
</ul>
<p>이러한 과정을 통해 JWT는 웹 애플리케이션과 API에서 안전하고 효율적인 방법으로 사용자 인증 및 정보 교환을 가능하게 합니다. JWT의 구조와 이 두 가지 핵심 프로세스의 조합은 웹 보안에서 중요한 역할을 합니다.</p>
<h3 id="4-jwt-사용의-장점">4. <strong>JWT 사용의 장점</strong></h3>
<p>단순 웹 토큰(SWT) 및 SAML 토큰과 비교할 때 JWT를 사용하면 여러 가지 이점이 있습니다.</p>
<ul>
<li><p><strong>더 컴팩트합니다</strong>: JSON은 XML보다 덜 장황하므로 인코딩할 때 JWT는 SAML 토큰보다 작습니다. 따라서 JWT는 HTML 및 HTTP 환경에서 전달하기에 좋은 선택입니다.
<img src="https://velog.velcdn.com/images/co-vol/post/ec2998e9-e6ee-4396-867c-a9533fbce4a9/image.png" alt=""></p>
</li>
<li><p><strong>더 안전하게</strong>: JWT는 서명을 위해 X.509 인증서 형식의 공개/개인 키 쌍을 사용할 수 있습니다. 또한 JWT는 HMAC 알고리즘을 사용하여 공유 비밀로 대칭적으로 서명할 수도 있습니다. SAML 토큰은 JWT와 같은 공개/개인 키 쌍을 사용할 수 있지만, 모호한 보안 허점을 도입하지 않고 XML 디지털 서명을 사용하여 XML에 서명하는 것은 JSON에 서명하는 단순성에 비해 매우 어렵습니다.</p>
</li>
<li><p><strong>더 일반적인</strong>: JSON 파서는 객체에 직접 매핑되기 때문에 대부분의 프로그래밍 언어에서 일반적입니다. 반대로 XML에는 자연스러운 문서와 객체 간 매핑이 없습니다. 따라서 SAML 어설션보다 JWT로 작업하기가 더 쉽습니다.</p>
</li>
<li><p><strong>처리하기 쉽습니다</strong>: JWT는 인터넷 규모에서 사용됩니다. 즉, 사용자의 디바이스, 특히 모바일에서 처리하기가 더 쉽습니다.</p>
</li>
</ul>
<h3 id="5-jwt-사용예시">5. <strong>JWT 사용예시</strong></h3>
<p>JWT는 다양한 방법으로 사용할 수 있습니다:</p>
<ul>
<li><p><strong>인증</strong>: 사용자가 자격 증명을 사용하여 로그인에 성공하면 ID 토큰이 반환됩니다. OIDC(OpenID Connect) 사양에 따르면 ID 토큰은 항상 JWT입니다.</p>
</li>
<li><p><strong>권한 부여</strong>: 사용자가 로그인에 성공하면 애플리케이션이 해당 사용자를 대신하여 경로, 서비스 또는 리소스(예: API)에 액세스하도록 요청할 수 있습니다. 이렇게 하려면 모든 요청에서 액세스 토큰을 전달해야 하며, 이 토큰은 JWT 형태일 수 있습니다. 싱글 사인온(SSO)은 형식의 오버헤드가 적고 여러 도메인에서 쉽게 사용할 수 있다는 점 때문에 JWT를 널리 사용합니다.</p>
</li>
<li><p><strong>정보 교환</strong>: JWT는 서명이 가능하므로 발신자가 본인이 맞는지 확인할 수 있으므로 당사자 간에 정보를 안전하게 전송하는 좋은 방법입니다. 또한 JWT의 구조를 통해 콘텐츠가 변조되지 않았는지 확인할 수 있습니다.</p>
</li>
</ul>
<h3 id="6-jwt의-보안-문제">6. <strong>JWT의 보안 문제</strong></h3>
<p>JSON Web Token (JWT)은 웹 애플리케이션과 API에 널리 사용되고 있지만, 몇 가지 잠재적인 보안 취약점이 있습니다. 이러한 취약점을 이해하는 것은 JWT를 안전하게 사용하는 데 매우 중요합니다.</p>
<ol>
<li><strong>서명 알고리즘의 취약성</strong>: JWT는 서명을 통해 데이터의 무결성을 보장합니다. 그러나, 사용하는 알고리즘이 취약하거나 구현에 오류가 있을 경우, 공격자가 토큰을 위조하거나 변조할 수 있습니다. 예를 들어, &#39;None&#39; 알고리즘을 사용하는 경우 서명 검증이 생략될 수 있으며, 이는 보안 위험을 증가시킵니다.</li>
<li><strong>키 관리의 어려움</strong>: JWT는 비밀 키 또는 공개/개인 키 쌍을 사용해 서명합니다. 이 키들의 안전한 관리가 이루어지지 않을 경우, 토큰의 보안이 크게 저하될 수 있습니다. 키가 노출되거나 잘못 관리되면, 공격자가 유효한 토큰을 생성할 수 있게 됩니다.</li>
<li><strong>교차 사이트 스크립팅(XSS) 공격</strong>: 웹 애플리케이션에서 JWT를 클라이언트 측에 저장할 경우 (예: 쿠키, 로컬 스토리지), XSS 공격에 취약해질 수 있습니다. 공격자가 악의적인 스크립트를 주입하여 사용자의 JWT를 탈취할 수 있으며, 이를 통해 사용자의 세션을 탈취하거나 악의적인 행동을 할 수 있습니다.</li>
<li><strong>토큰 만료 관리</strong>: JWT는 만료 시간을 포함하고 있지만, 이를 적절히 관리하지 못하면 보안 문제가 발생할 수 있습니다. 오래된 토큰이 계속 사용되거나, 만료된 토큰이 재사용되는 경우 보안에 구멍이 생길 수 있습니다.</li>
<li><strong>안전하지 않은 토큰 전송</strong>: JWT는 텍스트 형태로 전송되므로, 전송 과정에서 보안이 중요합니다. HTTPS와 같은 안전한 연결을 사용하지 않으면, 중간자 공격을 통해 토큰이 노출될 수 있습니다.</li>
</ol>
<p>이러한 잠재적 취약점을 고려하여, JWT를 사용할 때는 안전한 알고리즘 선택, 키의 안전한 관리, XSS 공격 방지를 위한 적절한 저장 방법 선택, 토큰의 만료 및 재사용 관리, 그리고 데이터 전송의 보안을 강화하는 것이 중요합니다.</p>
<h3 id="7-jwt-관리-모범-사례">7. <strong>JWT 관리 모범 사례</strong></h3>
<p>JWT (JSON Web Token) 관리의 모범 사례를 알고 있으면, 웹 애플리케이션과 API의 보안을 크게 향상시킬 수 있습니다. 여기에 안전한 JWT 사용을 위한 중요한 팁과 요령을 소개합니다.</p>
<ol>
<li><strong>강력한 서명 알고리즘 사용</strong>: JWT의 보안은 대부분 서명에 의존합니다. HS256(최소한의 표준) 이상의 강력한 서명 알고리즘을 사용하세요. 가능하다면, RSA나 ECDSA 같은 비대칭 서명 알고리즘을 고려하는 것이 좋습니다.</li>
<li><strong>키 관리 강화</strong>: 비밀 키와 공개/개인 키 쌍은 안전하게 보관하고 정기적으로 갱신해야 합니다. 키가 유출되는 것을 방지하기 위해, 안전한 환경에 키를 저장하고 접근 권한을 엄격히 제한해야 합니다.</li>
<li><strong>최소한의 클레임 사용</strong>: JWT는 필요한 최소한의 정보만을 포함해야 합니다. 민감한 개인 정보는 토큰에 포함하지 않는 것이 좋습니다. 클레임은 명확하고, 간결해야 하며, 해석이 모호하지 않아야 합니다.</li>
<li><strong>토큰 만료 시간 설정</strong>: JWT는 만료 시간을 명시적으로 설정해야 합니다. 짧은 만료 시간은 보안을 강화하지만, 사용자 경험에 영향을 줄 수 있으므로 적절한 균형을 찾아야 합니다.</li>
<li><strong>토큰 취급 주의</strong>: 클라이언트 측에서 JWT를 저장할 때는 XSS 공격을 방지하기 위해 쿠키보다는 HTTPOnly와 Secure 플래그가 설정된 쿠키를 사용하거나, 가능한 한 서버 측에서 관리하는 것이 좋습니다.</li>
<li><strong>안전한 토큰 전송</strong>: JWT는 항상 HTTPS와 같은 안전한 채널을 통해 전송되어야 합니다. 이렇게 하면 중간자 공격에 대한 위험을 줄일 수 있습니다.</li>
</ol>
<h3 id="8-최신-웹-보안에서-jwt의-역할">8.** 최신 웹 보안에서 JWT의 역할**</h3>
<p>JWT는 웹 기반의 인증과 권한 부여 과정에서 핵심적인 요소로 자리 잡았습니다. 이 토큰은 안전하고 효율적인 방법으로 사용자 신원을 확인하고, 서버와 클라이언트 간의 정보 교환을 쉽게 만듭니다. 특히, 단일 페이지 애플리케이션(SPA), 모바일 애플리케이션, 그리고 마이크로서비스 아키텍처에서 JWT의 중요성은 더욱 부각됩니다.</p>
<p>보안 측면에서, JWT는 사용자 인증 정보를 안전하게 전송할 수 있게 해주며, 이를 통해 사용자와 시스템 간의 신뢰를 구축합니다. 또한, JWT는 확장성이 뛰어나고 다양한 플랫폼과 언어에서 쉽게 구현할 수 있어, 현대의 다양한 웹 애플리케이션 개발 요구사항을 충족시킵니다.</p>
<p>하지만, 모든 기술과 마찬가지로, JWT도 적절히 관리되고 보안이 유지되어야 합니다. 잘못된 관리나 구현은 보안 취약점을 초래할 수 있으므로, JWT를 사용할 때는 모범 사례를 따르는 것이 중요합니다.</p>
<p>요약하자면, JWT는 웹 보안의 현재와 미래에서 중요한 역할을 하고 있습니다. 그것은 간편하고, 유연하며, 강력한 인증 수단으로서 웹 애플리케이션의 보안을 강화하는 데 필수적인 요소입니다</p>
<h3 id="참고-자료">참고 자료</h3>
<p><a href="https://jwt.io/introduction">JWT-introduce</a>
<a href="https://datatracker.ietf.org/doc/html/rfc7519#">JWT-doc</a>
<a href="https://auth0.com/docs/secure/tokens">auth0-Tokens</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 인증토큰 활용하기]]></title>
            <link>https://velog.io/@co-vol/React-%EC%9D%B8%EC%A6%9D%ED%86%A0%ED%81%B0-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@co-vol/React-%EC%9D%B8%EC%A6%9D%ED%86%A0%ED%81%B0-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 16 Apr 2023 16:03:20 GMT</pubDate>
            <description><![CDATA[<h2 id="jwtjson-web-token-소개">JWT(JSON web token) 소개</h2>
<p>JWT는 토큰 인증 방식 중 하나로, JSON 객체를 사용하여 토큰을 생성하고 인증하는 방식입니다. 이 방식은 상대적으로 가벼운 구조를 가지고 있어 많은 웹 서비스에서 사용되고 있습니다.</p>
<h3 id="bearer">Bearer</h3>
<p>Bearer는 &#39;소지자&#39; 또는 &#39;착용자&#39;라는 뜻이며, 인증 토큰을 전달할 때 사용되는 방식입니다. 이 방식은 토큰 소지자가 해당 토큰을 사용하여 인증을 받을 수 있다는 의미로 사용됩니다.</p>
<h3 id="토큰의-이해와-필요성">토큰의 이해와 필요성</h3>
<p>웹 서비스에서 사용자 인증을 처리하기 위해 토큰을 사용하는 방식이 널리 채택되고 있습니다. 토큰은 사용자의 정보와 권한을 저장하고 있는 일종의 문자열로, 서버와 클라이언트 간의 통신에 사용됩니다.</p>
<h3 id="토큰-인증-방식">토큰 인증 방식</h3>
<p>토큰 인증 방식은 사용자 인증 정보를 토큰으로 변환하여 전달하는 방식입니다. 인증 토큰은 일종의 문자열로, 서버와 클라이언트 간의 통신에 사용됩니다.</p>
<h4 id="jwt-구성요소">JWT 구성요소</h4>
<p>JWT는 세 가지 구성요소로 이루어져 있습니다: 헤더(header), 페이로드(payload), 시그니처(signature). 헤더에는 토큰의 유형과 알고리즘 정보가 포함되어 있으며, 페이로드에는 사용자 정보와 권한 등이 담겨 있습니다. 시그니처는 서버에서 생성되어 토큰의 유효성을 검증하는 데 사용됩니다.</p>
<h4 id="jwt-동작-방식">JWT 동작 방식</h4>
<p>사용자가 로그인 또는 회원가입을 요청합니다.
서버에서 사용자 정보를 확인한 후 JWT를 생성합니다.
생성된 JWT를 클라이언트에게 전달합니다.
클라이언트는 전달받은 JWT를 저장하고, 인증이 필요한 요청에 함께 보냅니다.
서버는 전달받은 JWT를 검증하여 사용자 인증을 확인합니다.</p>
<h2 id="내보내는-요청에-토큰-첨부하기">내보내는 요청에 토큰 첨부하기</h2>
<h3 id="요청후-토큰-저장하기">요청후 토큰 저장하기</h3>
<pre><code class="language-javascript">export const action = async ({request, params}) =&gt; {
  const mode = new URL(request.url).searchParams.get(&#39;mode&#39;) || &#39;login&#39;;

  if(mode !== &#39;login&#39; &amp;&amp; mode !== &#39;signup&#39;){
    throw json({message: &#39;Invalid mode&#39;}, {status: &#39;422&#39;});
  }

  const data = await request.formData();
  const authData = {
    email: data.get(&#39;email&#39;),
    password: data.get(&#39;password&#39;),
  }

  const response = await fetch(`http://localhost:8080/${mode}`,{
    method: &#39;POST&#39;,
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
    },
    body: JSON.stringify(authData),
  });

  if(response.status === 422 || response.status === 401){
    return response;
  }

  if(!response.ok){
    throw json({message: &#39;Could not authenticate user.&#39;}, {status: 500});
  }

  const resData = await response.json();
  const token = resData.token;

  localStorage.setItem(&#39;token&#39;, token);

  return redirect(&#39;/&#39;);

}</code></pre>
<p>요청 후 토큰을 저장하는 과정에서는 사용자의 로그인 또는 회원가입 요청을 받고, 인증에 성공하면 토큰을 생성하여 로컬 스토리지에 저장합니다. 이 과정은 위 코드에서 확인할 수 있습니다.</p>
<h3 id="요청시-토큰-보내기">요청시 토큰 보내기</h3>
<pre><code class="language-javascript">export const action = async ({ params, request }) =&gt; {
  const eventId = params.eventId;
  const token = getAuthTokens();
  const response = await fetch(&#39;http://localhost:8080/events/&#39; + eventId, {
    method: request.method,
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });

  if (!response.ok) {
    throw json(
      { message: &#39;Could not delete event.&#39; },
      {
        status: 500,
      }
    );
  }
  return redirect(&#39;/events&#39;);
}
</code></pre>
<p>인증된 사용자가 특정 요청을 할 때, 예를 들어 이벤트를 삭제하고자 할 경우, 토큰을 함께 보내어 서버에서 사용자 인증을 확인합니다. 위 코드에서는 이벤트 삭제 요청에 토큰을 함께 보내는 과정을 확인할 수 있습니다.</p>
<h2 id="사용자-로그아웃-처리">사용자 로그아웃 처리</h2>
<h3 id="토큰제거">토큰제거</h3>
<p>사용자 로그아웃을 처리하려면 먼저 사용자의 인증 토큰을 제거해야 합니다. 이를 위해 로그아웃 컴포넌트를 생성해봅시다.</p>
<h3 id="로그아웃-컴포넌트-생성">로그아웃 컴포넌트 생성</h3>
<pre><code class="language-javascript">import { redirect } from &quot;react-router-dom&quot;;

export const action = () =&gt; {
  localStorage.removeItem(&#39;token&#39;);
  return redirect(&#39;/&#39;);
}</code></pre>
<p>위 코드는 localStorage에 저장된 토큰을 제거한 뒤, 사용자를 홈페이지로 리다이렉트합니다.</p>
<h3 id="인증상태에-따라-ui-상태-업데이트">인증상태에 따라 UI 상태 업데이트</h3>
<h4 id="auth-컴포넌트에-추가">auth 컴포넌트에 추가</h4>
<pre><code class="language-javascript">export const getAuthTokens = () =&gt; {
  const token = localStorage.getItem(&#39;token&#39;);
  return token;
};

export const tokenLoader = () =&gt; {
  return getAuthTokens();
}</code></pre>
<p>그리고 라우터 객체에 로더를 추가합니다.</p>
<h4 id="라우터-객체에-추가">라우터 객체에 추가</h4>
<pre><code class="language-javascript">import { loader as tokenLoader } from &#39;./util/auth&#39;;
const router = createBrowserRouter([
  {
    path: &#39;/&#39;,
    element: &lt;RootLayout /&gt;,
    errorElement: &lt;ErrorPage /&gt;,
    id: &#39;root&#39;,
    loader: tokenLoader,
    children: [
      { index: true, element: &lt;HomePage /&gt; },
        //...
    ],
  },
]);
</code></pre>
<p>위 코드는 loader()를 통해 데이터를 가져옵니다.</p>
<pre><code class="language-javascript">import { NavLink, Form, useRouteLoaderData } from &quot;react-router-dom&quot;;

import classes from &quot;./MainNavigation.module.css&quot;;
import NewsletterSignup from &quot;./NewsletterSignup&quot;;

const MainNavigation = () =&gt; {
  const token = useRouteLoaderData(&quot;root&quot;);

  return (
    &lt;header className={classes.header}&gt;
      &lt;nav&gt;
        &lt;ul className={classes.list}&gt;
          &lt;li&gt;
            &lt;NavLink to=&quot;/&quot; className={({ isActive }) =&gt; (isActive ? classes.active : undefined)} end&gt;
              Home
            &lt;/NavLink&gt;
          &lt;/li&gt;
          &lt;li&gt;
            &lt;NavLink to=&quot;/events&quot; className={({ isActive }) =&gt; (isActive ? classes.active : undefined)}&gt;
              Events
            &lt;/NavLink&gt;
          &lt;/li&gt;
          &lt;li&gt;
            &lt;NavLink to=&quot;/newsletter&quot; className={({ isActive }) =&gt; (isActive ? classes.active : undefined)}&gt;
              Newsletter
            &lt;/NavLink&gt;
          &lt;/li&gt;
          {!token &amp;&amp; (
            &lt;li&gt;
              &lt;NavLink to=&quot;/auth&quot; className={({ isActive }) =&gt; (isActive ? classes.active : undefined)}&gt;
                Authentication
              &lt;/NavLink&gt;
            &lt;/li&gt;
          )}
          {token &amp;&amp; (
            &lt;li&gt;
              &lt;Form action=&quot;/logout&quot; method=&quot;post&quot;&gt;
                &lt;button&gt;Logout&lt;/button&gt;
              &lt;/Form&gt;
            &lt;/li&gt;
          )}
        &lt;/ul&gt;
      &lt;/nav&gt;
      &lt;NewsletterSignup /&gt;
    &lt;/header&gt;
  );
};

export default MainNavigation;</code></pre>
<p>이제 인증 상태에 따라 사용자 인터페이스가 업데이트되어 로그아웃 버튼이 보이게 됩니다.</p>
<h2 id="라우트-보호">라우트 보호</h2>
<p>리액트에서 라우트 보호는 사용자 인증을 확인하여 특정 페이지에 대한 접근 권한을 관리하는 것을 의미합니다. 이 글에서는 라우트 보호를 구현하는 방법에 대해 설명하겠습니다.</p>
<pre><code class="language-javascript">import { redirect } from &#39;react-router-dom&#39;;

export const getAuthTokens = () =&gt; {
  const token = localStorage.getItem(&#39;token&#39;);
  return token;
};

export const tokenLoader = () =&gt; {
  return getAuthTokens();
}

export const checkAuthLoader = () =&gt; {
  const token = getAuthTokens();

  if (!token) {
    return redirect(&#39;/login&#39;);
  }

  return null;
}</code></pre>
<p>checkAuthLoader 함수는 토큰이 없을 경우 리다이렉트 처리를 해주는 함수입니다. 토큰이 없으면 로그인 페이지로 리다이렉트합니다.</p>
<pre><code class="language-javascript">import { tokenLoader, checkAuthLoader } from &quot;./util/auth&quot;;

const router = createBrowserRouter([
  {
    path: &quot;/&quot;,
    element: &lt;RootLayout /&gt;,
    errorElement: &lt;ErrorPage /&gt;,
    id: &quot;root&quot;,
    loader: tokenLoader,
    children: [
      { index: true, element: &lt;HomePage /&gt; },
      {
        path: &quot;events&quot;,
        element: &lt;EventsRootLayout /&gt;,
        children: [
            //..
          {
            path: &quot;:eventId&quot;,
            id: &quot;event-detail&quot;,
            loader: eventDetailLoader,
            children: [
              {
                index: true,
                element: &lt;EventDetailPage /&gt;,
                action: deleteEventAction,
              },
              {
                path: &quot;edit&quot;,
                element: &lt;EditEventPage /&gt;,
                action: manipulateEventAction,
                loader: checkAuthLoader,
              },
            ],
          },
          {
            path: &quot;new&quot;,
            element: &lt;NewEventPage /&gt;,
            action: manipulateEventAction,
            loader: checkAuthLoader,
          },
        ],
      },
     //..
    ],
  },
]);
</code></pre>
<p>checkAuthLoader를 사용하여 라우트 보호를 적용합니다.</p>
<h2 id="자동-로그아웃">자동 로그아웃</h2>
<p>인터넷에서 로그인 한 후, 일정 시간 동안 활동하지 않으면 자동으로 로그아웃되는 기능은 매우 중요합니다. 이것은 사용자의 개인 정보와 보안을 보호하며, 불필요한 서버 부하를 줄일 수 있습니다. React에서는 이러한 자동 로그아웃 기능을 구현하는 데 useEffect를 사용할 수 있습니다. 이 기능을 구현하는 방법에 대해 알아보겠습니다.</p>
<pre><code class="language-javascript">import { Outlet, useLoaderData, useNavigation, useSubmit } from &quot;react-router-dom&quot;;

import MainNavigation from &quot;../components/MainNavigation&quot;;
import { useEffect } from &quot;react&quot;;

function RootLayout() {
  // const navigation = useNavigation();
  const token = useLoaderData(&quot;root&quot;);
  const submit = useSubmit();

  useEffect(() =&gt; {
    if (!token) {
      return;
    }

    setTimeout(() =&gt; {
      submit(null, { action: &quot;/logout&quot;, method: &quot;post&quot; });
    }, 1 * 60 * 60 * 1000);
  }, [token, submit]);

  return (
    &lt;&gt;
      &lt;MainNavigation /&gt;
      &lt;main&gt;
        {/* {navigation.state === &#39;loading&#39; &amp;&amp; &lt;p&gt;Loading...&lt;/p&gt;} */}
        &lt;Outlet /&gt;
      &lt;/main&gt;
    &lt;/&gt;
  );
}

export default RootLayout;
</code></pre>
<p>위 코드에서는 RootLayout 컴포넌트에서 useEffect를 사용하여 토큰의 만료 시간을 설정합니다. 코드를 자세히 살펴보면, 토큰이 존재하는 경우 setTimeout 함수를 사용하여 1시간 후에 로그아웃 요청을 전송합니다.</p>
<h3 id="토큰만료-개선">토큰만료 개선</h3>
<p>토큰 만료 시간을 추가하여 사용자 인증을 더 안전하게 만드는 방법에 대해 알아보겠습니다. 이를 위해 Authentication 컴포넌트와 관련 기능을 수정하고, 만료 시간을 이용해 자동 로그아웃 기능을 구현합니다.</p>
<h4 id="authentication-컴포넌트에서-token-expiration-추가">Authentication 컴포넌트에서 token expiration 추가</h4>
<p>먼저, Authentication 컴포넌트에 토큰 만료 시간을 추가해봅시다.</p>
<pre><code class="language-javascript">  localStorage.setItem(&#39;token&#39;, token);
  const expiration = new Date();
  expiration.setHours(expiration.getHours() + 1);
  localStorage.setItem(&#39;expiration&#39;, expiration.toISOString());
</code></pre>
<p>위 코드를 사용하여 토큰과 함께 만료 시간을 저장합니다.</p>
<h4 id="auth-컴포넌트-수정">auth 컴포넌트 수정</h4>
<p>auth 컴포넌트를 수정하여 expiration을 사용하도록 합니다.</p>
<pre><code class="language-javascript">import { redirect } from &quot;react-router-dom&quot;;

export const getTokenDuration = () =&gt; {
  const storedExpirationDate = localStorage.getItem(&quot;expiration&quot;);
  const expirationDate = new Date(storedExpirationDate);
  const now = new Date();
  const duration = expirationDate.getTime() - now.getTime();
  return duration;
};

export const getAuthTokens = () =&gt; {
  const token = localStorage.getItem(&quot;token&quot;);

  if(!token) {
    return null;
  }

  const tokenDuration = getTokenDuration();

  if (tokenDuration &lt;= 0) {
    return &quot;EXPIRED&quot;;
  }

  return token;
};

export const tokenLoader = () =&gt; {
  return getAuthTokens();
};

export const checkAuthLoader = () =&gt; {
  const token = getAuthTokens();

  if (!token) {
    return redirect(&quot;/login&quot;);
  }

  return null;
};
</code></pre>
<p>위 코드에서는 만료 시간을 계산하는 getTokenDuration() 함수를 사용하여 토큰이 만료되었는지 확인합니다.</p>
<h4 id="root-컴포넌트에서-useeffect-수정">root 컴포넌트에서 useEffect 수정</h4>
<p>root 컴포넌트의 useEffect를 수정하여 expiration을 사용하도록 합니다.</p>
<pre><code class="language-javascript">  useEffect(() =&gt; {
    if (!token) {
      return;
    }

    if(token === &quot;EXPIRED&quot;) {
      submit(null, { action: &quot;/logout&quot;, method: &quot;post&quot; });
    }

    const tokenDuration = getTokenDuration();

    setTimeout(() =&gt; {
      submit(null, { action: &quot;/logout&quot;, method: &quot;post&quot; });
    }, tokenDuration);
  }, [token, submit]);</code></pre>
<p>이 코드를 사용하면 토큰이 만료되면 자동으로 로그아웃되도록 설정할 수 있습니다.</p>
<h4 id="logout-컴포넌트에서-expiration-제거-추가">logout 컴포넌트에서 expiration 제거 추가</h4>
<p>마지막으로, 로그아웃 컴포넌트에서 expiration을 제거하도록 수정합니다.</p>
<pre><code class="language-javascript">import { redirect } from &quot;react-router-dom&quot;;

export const action = () =&gt; {
  localStorage.removeItem(&#39;token&#39;);
  localStorage.removeItem(&#39;expiration&#39;);
  return redirect(&#39;/&#39;);
}</code></pre>
<p>이렇게 토큰 만료 시간을 추가하고 관련 기능을 수정하면 사용자 인증을 더 안전하게 만들 수 있습니다. 만료 시간을 이용해 자동으로 로그아웃되도록 설정하여 편리하게 사용할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 인증 작업 생성과 사용자 유효성 검증]]></title>
            <link>https://velog.io/@co-vol/React-%EC%9D%B8%EC%A6%9D-%EC%9E%91%EC%97%85-%EC%83%9D%EC%84%B1%EA%B3%BC-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%A6%9D</link>
            <guid>https://velog.io/@co-vol/React-%EC%9D%B8%EC%A6%9D-%EC%9E%91%EC%97%85-%EC%83%9D%EC%84%B1%EA%B3%BC-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%A6%9D</guid>
            <pubDate>Sun, 16 Apr 2023 08:00:28 GMT</pubDate>
            <description><![CDATA[<p>본 글에서는 인증 작업 생성과 사용자 유효성 검증에 대한 코드를 소개하고, 해당 코드를 구성하는 각 요소와 구현 방법을 설명하겠습니다.</p>
<h2 id="인증작업-생성을-위한-코드-설명">인증작업 생성을 위한 코드 설명</h2>
<h3 id="authform-컴포넌트와-action-함수">AuthForm 컴포넌트와 action 함수</h3>
<p>인증 작업 생성을 위한 코드는 주로 AuthForm 컴포넌트와 action 함수를 포함합니다. AuthForm 컴포넌트는 사용자 인터페이스(UI)를 담당하며, action 함수는 인증 작업에 대한 로직을 처리합니다.</p>
<h3 id="코드의-주요-부분에-대한-설명">코드의 주요 부분에 대한 설명</h3>
<ol>
<li>AuthForm 컴포넌트에서, 이메일과 비밀번호를 입력 받는 폼을 구성합니다.</li>
<li>action 함수에서, 폼 데이터를 추출하고 서버로 전송하여 인증 작업을 진행합니다.</li>
<li>서버로부터 인증 결과를 받아 처리한 후, 필요한 작업을 수행합니다.<pre><code class="language-javascript">import AuthForm from &#39;../components/AuthForm&#39;;
import { json, redirect } from &#39;react-router-dom&#39;;
</code></pre>
</li>
</ol>
<p>function AuthenticationPage() {
  return <AuthForm />;
}</p>
<p>export default AuthenticationPage;</p>
<p>export const action = async ({request, params}) =&gt; {
  const mode = new URL(request.url).searchParams.get(&#39;mode&#39;) || &#39;login&#39;;</p>
<p>  if(mode !== &#39;login&#39; &amp;&amp; mode !== &#39;signup&#39;){
    throw json({message: &#39;Invalid mode&#39;}, {status: &#39;422&#39;});
  }</p>
<p>  const data = await request.formData();
  const authData = {
    email: data.get(&#39;email&#39;),
    password: data.get(&#39;password&#39;),
  }</p>
<p>  const response = await fetch(<code>http://localhost:8080/${mode}</code>,{
    method: &#39;POST&#39;,
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
    },
    body: JSON.stringify(authData),
  });</p>
<p>  if(response.status === 422 || response.status === 401){
    return response;
  }</p>
<p>  if(!response.ok){
    throw json({message: &#39;Could not authenticate user.&#39;}, {status: 500});
  }</p>
<p>  return redirect(&#39;/&#39;);</p>
<p>}</p>
<pre><code>
## 사용자 유효성 검증을 위한 코드 설명
### AuthForm 함수와 React hooks 사용
사용자 유효성 검증을 위한 코드는 AuthForm 함수 내에서 처리됩니다. 이 함수는 React hooks를 사용하여 검증 결과를 저장하고, 검증 에러와 메시지를 출력합니다.
### 검증 에러와 메시지 출력
검증 에러와 메시지는 `&lt;ul&gt;` 태그와 `&lt;p&gt;` 태그를 사용하여 출력합니다. 각 에러는 `&lt;li&gt;` 태그로 나타내며, 전체 메시지는 `&lt;p&gt;` 태그 안에 표시됩니다.
### 이메일과 비밀번호 입력폼
이메일과 비밀번호를 입력받기 위해 `&lt;input&gt;` 태그를 사용합니다. 각각의 태그에는 id, type, name, 그리고 required 속성이 포함되어 있습니다.
### 로그인 및 회원가입 버튼 구현
로그인 및 회원가입 버튼은 `&lt;Link&gt;` 컴포넌트와 `&lt;button&gt;` 태그를 사용하여 구현합니다. `&lt;Link&gt;` 컴포넌트는 현재 페이지의 mode에 따라 로그인 또는 회원가입 페이지로 이동하며, `&lt;button&gt;` 태그는 폼 제출을 담당합니다
```javascript
import { React } from &quot;react&quot;;
import { Form, Link, useSearchParams, useActionData, useNavigation } from &quot;react-router-dom&quot;;

import classes from &quot;./AuthForm.module.css&quot;;

function AuthForm() {
  const data = useActionData();
  const navigation = useNavigation();

  const [searchParams] = useSearchParams();

  const isLogin = searchParams.get(&quot;mode&quot;) === &quot;login&quot;;
  const isSubmmiting = navigation.state === &quot;submitting&quot;;

  return (
    &lt;&gt;
      &lt;Form method=&quot;post&quot; className={classes.form}&gt;
        &lt;h1&gt;{isLogin ? &quot;Log in&quot; : &quot;Create a new user&quot;}&lt;/h1&gt;
        {data &amp;&amp; data.errors &amp;&amp; (
          &lt;ul&gt;
            {Object.values(data.errors).map((error, index) =&gt; (
              &lt;li key={index}&gt;{error}&lt;/li&gt;
            ))}
          &lt;/ul&gt;
        )}
        {data &amp;&amp; data.message &amp;&amp; &lt;p&gt;{data.message}&lt;/p&gt;}
        &lt;p&gt;
          &lt;label htmlFor=&quot;email&quot;&gt;Email&lt;/label&gt;
          &lt;input id=&quot;email&quot; type=&quot;email&quot; name=&quot;email&quot; required /&gt;
        &lt;/p&gt;
        &lt;p&gt;
          &lt;label htmlFor=&quot;image&quot;&gt;Password&lt;/label&gt;
          &lt;input id=&quot;password&quot; type=&quot;password&quot; name=&quot;password&quot; required /&gt;
        &lt;/p&gt;
        &lt;div className={classes.actions}&gt;
          &lt;Link to={`?mode=${isLogin ? &quot;signup&quot; : &quot;login&quot;}`}&gt;{isLogin ? &quot;Create new user&quot; : &quot;Login&quot;}&lt;/Link&gt;
          &lt;button disabled={isSubmmiting}&gt;{isSubmmiting ? &quot;Submitting&quot; : &quot;Save&quot;}&lt;/button&gt;
        &lt;/div&gt;
      &lt;/Form&gt;
    &lt;/&gt;
  );
}

export default AuthForm;
</code></pre><h3 id="서버와-통신">서버와 통신</h3>
<p>서버와 통신하는 과정은 action 함수에서 처리됩니다. fetch 함수를 사용하여 서버와 통신하며, 서버로부터 받은 응답을 처리한 후 필요한 작업을 수행합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] 인증의 원리]]></title>
            <link>https://velog.io/@co-vol/React-%EC%9D%B8%EC%A6%9D%EC%9D%98-%EC%9B%90%EB%A6%AC</link>
            <guid>https://velog.io/@co-vol/React-%EC%9D%B8%EC%A6%9D%EC%9D%98-%EC%9B%90%EB%A6%AC</guid>
            <pubDate>Sun, 16 Apr 2023 06:41:51 GMT</pubDate>
            <description><![CDATA[<p>인증은 현대의 디지털 시대에서 매우 중요한 개념 중 하나입니다. 인증이란, 어떤 사용자가 자신이 주장하는 것이 사실인지 확인하고, 해당 사용자에 대한 신뢰를 구축하는 과정입니다. 이번 글에서는 인증의 원리와 인증 방식에 대해 알아보겠습니다.</p>
<h2 id="인증-방식">인증 방식</h2>
<h3 id="servet-side-session">Servet-side session</h3>
<p>서버-사이드 세션은 인증을 위한 가장 전통적인 방법 중 하나입니다. 이 방식에서는 사용자가 로그인을 하면, 서버는 해당 사용자에 대한 세션 정보를 생성합니다. 이 세션 정보는 서버에 저장되며, 사용자가 웹 사이트 내에서 다른 페이지로 이동하거나 어떤 작업을 수행할 때마다 이 세션 정보가 사용됩니다.</p>
<p>서버-사이트 세션은 매우 안전한 방식이지만, 최근에는 react와 같은 프레임워크에서 맞지 않는 경우가 있습니다. 이는 react와 같은 프레임워크에서는 세션 정보를 다루기가 어렵기 때문입니다.</p>
<h3 id="authentication-tokens">Authentication Tokens</h3>
<p>Authentication 토큰 방식은 인증을 위한 현재 가장 인기 있는 방법 중 하나입니다. 이 방식에서는 사용자가 로그인을 하면, 서버는 해당 사용자에 대한 토큰 정보를 생성합니다. 이 토큰 정보는 사용자의 브라우저에 저장되며, 사용자가 다른 페이지로 이동하거나 어떤 작업을 수행할 때마다 이 토큰 정보가 사용됩니다.</p>
<p>Authentication 토큰 방식은 매우 유연하며, react와 같은 프레임워크에서도 잘 작동합니다. 또한, 이 방식은 서버-사이트 세션 방식보다 더욱 안전하다고 알려져 있습니다.</p>
<h3 id="인증의-중요성">인증의 중요성</h3>
<p>인증은 현대의 디지털 시대에서 굉장히 중요한 개념입니다. 이번 글에서는 인증의 원리와 인증 방식에 대해 알아보았습니다. 서버-사이트 세션 방식은 전통적이고 안전하지만, react와 같은 프레임워크에서는 맞지 않는 경우가 있습니다. Authentication 토큰 방식은 매우 유연하며, react와 같은 프레임워크에서도 잘 작동합니다. 또한, 이 방식은 서버-사이트 세션 방식보다 더욱 안전하다고 알려져 있습니다.</p>
]]></description>
        </item>
    </channel>
</rss>