<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>thezz9</title>
        <link>https://velog.io/</link>
        <description>개발 취준생</description>
        <lastBuildDate>Sat, 26 Jul 2025 09:44:06 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>thezz9</title>
            <url>https://velog.velcdn.com/images/harvard--/profile/290b4170-b852-48a4-9740-d9dff9be1f65/image.webp</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. thezz9. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/harvard--" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[Spring] 채점 시스템 3차 고도화: DeadLock]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-3%EC%B0%A8-%EA%B3%A0%EB%8F%84%ED%99%94-DeadLock</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-3%EC%B0%A8-%EA%B3%A0%EB%8F%84%ED%99%94-DeadLock</guid>
            <pubDate>Sat, 26 Jul 2025 09:44:06 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이번 글에서는 성능테스트 도중 발생한 <code>DeadLock</code> 문제에 대해, 개념부터 해결 과정까지 정리해보려고 한다. </p>
<p>기능 개발 단계에서 진행한 간단한 테스트에서는 데드락의 위험성이 존재했음에도 불구하고 실제로 발생하지 않았는데, 그 이유 또한 함께 짚어보려 한다.</p>
<hr>
<h2 id="1-데드락deadlock이란">1. 데드락(Deadlock)이란?</h2>
<p><strong>데드락(Deadlock)</strong>은 둘 이상의 프로세스나 스레드가 서로가 가진 자원을 기다리며, 무한정 멈춰버린 상태를 말한다. 
이 상태에 빠지면 외부 개입 없이는 어떤 작업도 더 이상 진행될 수 없다.</p>
<p>데드락은 다음의 <strong>4가지 조건</strong>이 모두 만족될 때 발생한다. </p>
<ul>
<li><strong>상호 배제(Mutual Exclusion)</strong>: 자원은 한 번에 하나의 프로세스만 사용할 수 있어야 한다.</li>
<li><strong>점유 대기(Hold and Wait)</strong>: 자원을 점유한 상태에서 다른 자원을 기다리는 상황</li>
<li><strong>비선점(No Preemption)</strong>: 점유한 자원을 강제로 뺏을 수 없다.</li>
<li><strong>순환 대기(Circular Wait)</strong>: 프로세스 간 자원을 순환하며 기다리는 상태 (<code>A → B → C → A</code>)</li>
</ul>
<p>이 중 하나라도 깨지면, 데드락은 원천적으로 방지할 수 있다.</p>
<p>데드락은 보통 멀티스레드 프로그래밍, 데이터베이스 트랜잭션, 분산 시스템, 락 기반의 동시성 제어 상황에서 자주 발생한다.
내 경우에는, 채점 시스템이 병렬로 동작하는 <strong>멀티스레드 환경</strong>이었기 때문에 데드락이 발생했다.</p>
<hr>
<h2 id="2-데드락-발생-지점">2. 데드락 발생 지점</h2>
<p><img src="https://velog.velcdn.com/images/harvard--/post/ddc9f262-fdb0-47b5-9d23-9be98d15b628/image.png" alt=""></p>
<p>제출 이후 수행되는 DB 관련 작업은 다음과 같다.</p>
<ul>
<li><p><strong><code>Submission</code> 제출 테이블</strong></p>
<ul>
<li>제출된 코드의 컴파일 결과, 채점 결과 등 세부 정보를 저장한다.</li>
</ul>
</li>
<li><p><strong><code>UserProblemResult</code> 문제 해결 여부 테이블</strong></p>
<ul>
<li>랭킹 산정 등에서 성능 최적화를 위해 사용된다.</li>
<li>하나의 <code>User</code>는 하나의 <code>Problem</code>에 대해 <strong>오직 하나의 결과(row)</strong> 만 가진다.</li>
</ul>
</li>
<li><p><strong><code>Problem</code> 문제 테이블</strong></p>
<ul>
<li>채점 결과에 따라 제출 횟수(<code>totalSubmission</code>) 또는 
정답 횟수(<code>correctSubmission</code>)가 +1씩 증가한다.</li>
</ul>
</li>
</ul>
<p>이 중 실제로 데드락이 발생하는 지점은 <code>Problem</code> 테이블의 통계 업데이트 과정이다.</p>
<p>하지만 더 정확히 말하면, 단순히 여러 스레드가 동시에 <code>Problem</code> 테이블을 업데이트했기 때문만은 아니다.
문제의 본질은 모든 작업이 <code>Problem</code> 테이블을 외래 키로 참조하고 있다는 점에 있다.</p>
<p>예를 들어, <code>Submission</code>과 <code>UserProblemResult</code>에 데이터를 insert하거나 update할 때, MySQL은 외래 키 무결성 검사를 위해 해당 <code>Problem</code> 레코드에 <strong>공유 락(Shared Lock, S Lock)</strong>을 건다. 이 락은 단순히 참조 무결성을 확인하는 데 그치지 않고, 트랜잭션이 종료될 때까지 유지된다.</p>
<p>이 상태에서 마지막 단계로 <code>Problem</code>의 제출 수치를 update하면, 해당 row에 대해 <strong>배타 락(Exclusive Lock, X Lock)</strong>이 필요해진다. 그런데 이미 다른 트랜잭션들이 공유 락을 보유하고 있는 상태라면, 배타 락으로의 <strong>락 승격(lock upgrade)</strong>이 불가능해지며 대기 상태에 들어간다.</p>
<p>문제는 이 상황이 여러 스레드에서 동시에 벌어지면, 각 트랜잭션이 공유 락을 쥔 채로 서로의 배타 락 획득을 기다리는 순환 대기에 빠진다는 것이다. 즉, 데드락의 4가지 조건이 모두 충족되면서 교착 상태가 발생한다.</p>
<p>결국, 이 현상은 단순한 동시 업데이트 충돌이 아니라, 외래 키 참조로 인해 의도치 않게 잡힌 공유 락이 원인이 되어 발생한 락 업그레이드 충돌이라고 보는 것이 정확하다.</p>
<hr>
<h2 id="3-왜-개발-단계에서는-발생하지-않았는가">3. 왜 개발 단계에서는 발생하지 않았는가</h2>
<p>데드락이 발생할 수 있는 지점, 즉 <strong>동시성 문제가 생길 수 있는 위험</strong>은 인지하고 있었다.
그렇다면 왜 그때는 데드락이 발생하지 않았을까?</p>
<p>개발 단계에서는 <strong>완전한 동시 요청</strong>을 보내는 테스트를 하지 않았기 때문이다.
그 당시엔 &quot;여러 스레드가 동시에 돌아가며, 서로 다른 채점 로직을 잘 수행하는가&quot;에만 집중했었다.</p>
<p>무엇보다 개발 환경에서 사용하던 컴파일 서버의 사양이 낮았기 때문에,
요청 대부분이 <strong>컴파일 단계에서 병목</strong>이 생겨 DB까지 도달하는 속도가 느렸다.</p>
<p>하지만 성능 테스트 단계에서는 이야기가 달랐다.
테스트를 위해 <strong>컴파일 서버의 스펙을 상향</strong>하자, 컴파일이 빠르게 완료되고 채점 속도도 크게 빨라지면서 결과적으로 <strong>DB 접근까지의 시간도 비약적으로 짧아졌다.</strong></p>
<p>이로 인해 DB에 동시에 접근하는 스레드가 급격히 많아졌고, 그제야 데드락 문제가 발생하기 시작한 것이다.</p>
<p>따라서 개발 단계에서는 컴파일 서버의 병목으로 인해 DB 접근이 자연스럽게 순차 처리되면서 데드락이 발생하지 않았고, 미리 데드락 방지 로직을 짜더라도 그것이 실제로 잘 작동하는지 확인하긴 어려운 상황이었다.</p>
<hr>
<h2 id="4-데드락을-해결할-수-있는-방법들">4. 데드락을 해결할 수 있는 방법들</h2>
<p>데드락을 방지하거나 해결하는 방법은 여러 가지가 있다. 대표적으로는 다음과 같은 전략들이 존재한다.</p>
<h3 id="41-락-순서-강제">4.1. 락 순서 강제</h3>
<p>모든 트랜잭션이 자원을 고정된 순서로 획득하도록 강제하여, 
데드락 발생 조건 중 하나인 <strong>순환 대기(Circular Wait)</strong>를 원천적으로 방지하는 방식이다.
다만 서비스 규모가 커질수록 흐름마다 락 순서를 일관되게 유지하기 어려우며,
도메인이 복잡해질수록 <strong>코드 유지보수 부담이 커진다.</strong></p>
<h3 id="42-낙관적-락--비관적-락-사용">4.2. 낙관적 락 / 비관적 락 사용</h3>
<ul>
<li><p><strong>낙관적 락</strong>: JPA의 <code>@Version</code> 필드를 활용해 커밋 시점에 버전 충돌을 감지한다.
충돌 발생 시 롤백 후 재시도해야 하며, 이 과정에서 <strong>DB 접근 횟수 증가 및 재처리 로직</strong> 필요성이 생긴다.</p>
</li>
<li><p><strong>비관적 락</strong>: <code>SELECT ... FOR UPDATE</code>처럼 조회 시점에 락을 선점하여 다른 트랜잭션의 접근을 차단한다.
이 경우 락 경합으로 인한 <strong>DB 자원 소모와 성능 저하</strong>가 발생할 수 있다.</p>
</li>
</ul>
<p>두 방식 모두 트랜잭션 충돌을 방지하는 데 효과적이지만, <strong>충돌 시 전체 트랜잭션이 롤백</strong>되기 때문에, 앞에서 수행한 <code>Submission</code>, <code>UserProblemResult</code> 등의 DB 작업도 함께 되돌아가게 된다.</p>
<h3 id="43-직렬화-트랜잭션--격리-수준-조정">4.3. <strong>직렬화 트랜잭션 / 격리 수준 조정</strong></h3>
<p>DB의 트랜잭션 격리 수준을 <code>SERIALIZABLE</code>로 높여, 트랜잭션끼리 <strong>하나씩 순차 실행</strong>되도록 제한하는 방식이다.
내부적으로는 DB가 트랜잭션을 큐처럼 직렬화하여 처리하게 되며,
이로 인해 병렬성이 크게 제한되고 <strong>전체 처리량이 급감</strong>하는 단점이 있다.</p>
<h3 id="44-이벤트-발행--트랜잭션-분리">4.4. <strong>이벤트 발행 + 트랜잭션 분리</strong></h3>
<p>하나의 트랜잭션 안에서 모든 작업을 처리하는 대신, 핵심 로직 종료 후<code>@TransactionalEventListener(phase = AFTER_COMMIT)</code>로 이벤트를 발행하고, 이를 <code>@Transactional(propagation = REQUIRES_NEW)</code>로 완전히 분리된 트랜잭션에서 처리하는 방식이다.
단점은, 후속 트랜잭션이 실패할 경우 <strong>일부 데이터 정합성이 깨질 수 있어</strong>, 별도의 재처리 로직이 필요하다는 점이다.</p>
<p>나는 이번 문제를 해결하기 위해 <strong>이벤트 기반 처리 방식</strong>을 채택했다. 
이 방법을 선택한 이유는 다음과 같다.</p>
<ul>
<li><p><strong>DB 부하를 늘리는 방식은 확장성이 떨어진다고 판단했다.</strong>
서버는 scale-out이 비교적 쉬운 반면, DB는 구조적으로 확장에 제약이 있다.
따라서 복잡한 충돌 처리나 정합성 보장을 DB 레벨에서 감당하기보다는,
애플리케이션 레벨에서 분산 처리하는 방식이 더 유연하다고 판단했다.</p>
</li>
<li><p><strong>운영 유연성과 장애 대응에도 적합하다.</strong>
제출 횟수나 정답 횟수는 핵심 비즈니스 데이터는 아니기 때문에, 일부 처리 실패가 전체 정합성에 큰 영향을 주지는 않는다. 
이러한 이유로 현재는 별도의 재처리 로직은 없지만, 이벤트 기반 구조는 필요 시 큐잉, 로그 기반 재처리 등으로 복구가 가능해 운영 측면에서도 유리하다.</p>
</li>
</ul>
<hr>
<h2 id="5-트랜잭션-분리-구조-적용">5. 트랜잭션 분리 구조 적용</h2>
<p>데드락 문제를 해결하기 위해, <strong>문제 통계</strong>(<code>totalSubmissions</code>, <code>correctSubmissions</code>)를 업데이트하는 로직을 핵심 비즈니스 로직과는 <strong>완전히 분리된 트랜잭션</strong>으로 처리하도록 구조를 변경했다.</p>
<h3 id="51-메인-트랜잭션-채점-결과-확정--이벤트-발행">5.1. 메인 트랜잭션: 채점 결과 확정 + 이벤트 발행</h3>
<p>채점이 완료되면 <code>SubmissionContext</code>를 기반으로 결과를 확정하고,
이후 다양한 후속 이벤트를 발행하게 된다.</p>
<pre><code class="language-java">@Transactional
public void finalizeAndPublish(SubmissionContext ctx) {
    SubmissionResult submissionResult = submissionDomainService.finalizeSubmission(ctx);

    publishFinalResult(ctx);
    publishProblemSolve(submissionResult);

    // 문제 통계 반영 이벤트 발행
    publishProblemCountAdjustment(ctx, submissionResult);
}</code></pre>
<p>여기서 <code>publishProblemCountAdjustment()</code>는 통계 업데이트에 필요한 핵심 정보들이 담긴 이벤트 객체를 생성해 발행하는 역할을 한다.</p>
<br>

<h3 id="52-after_commit-트랜잭션-종료-후-이벤트-수신">5.2. AFTER_COMMIT: 트랜잭션 종료 후 이벤트 수신</h3>
<p>이벤트는 트랜잭션 커밋 이후에 실행되도록 <code>AFTER_COMMIT</code> 설정이 되어 있다.</p>
<pre><code class="language-java">@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProblemCountAdjustment(ProblemCountAdjustmentEvent event) {
    problemService.problemCountAdjustment(event.problemId(), event.isSolved());
}</code></pre>
<p>이 설정 덕분에, 메인 로직이 정상적으로 커밋된 이후에만 통계 업데이트가 실행되며,
만약 앞의 채점 로직에서 문제가 생긴다면 이 로직은 아예 실행되지 않는다.</p>
<br>

<h3 id="53-requires_new-완전히-분리된-트랜잭션으로-통계-반영">5.3. REQUIRES_NEW: 완전히 분리된 트랜잭션으로 통계 반영</h3>
<p>실제 통계는 <code>REQUIRES_NEW</code> 트랜잭션으로 처리되므로,
<strong>기존 트랜잭션과 격리된 환경에서 안전하게 실행</strong>된다.</p>
<pre><code class="language-java">@Transactional(propagation = Propagation.REQUIRES_NEW)
public void problemCountAdjustment(Long problemId, boolean isSolved) {
    int correctInc = isSolved ? 1 : 0;
    problemDomainService.problemCountAdjustment(problemId, correctInc);
}</code></pre>
<p>이 메서드는 엔티티를 조회하지 않고 직접 쿼리를 실행하여, 문제의 제출 횟수와 정답 횟수를 증가시킨다.</p>
<pre><code class="language-java">@Modifying(clearAutomatically = true)
@Query(&quot;&quot;&quot;
    UPDATE Problem p
    SET p.totalSubmissions = p.totalSubmissions + 1,
        p.correctSubmissions = p.correctSubmissions + :correctInc
    WHERE p.id = :problemId
&quot;&quot;&quot;)
void incrementCount(@Param(&quot;problemId&quot;) Long problemId, @Param(&quot;correctInc&quot;) int correctInc);</code></pre>
<br>

<p>이 구조는 핵심 로직 커밋 이후, <code>@TransactionalEventListener(AFTER_COMMIT)</code>를 통해 이벤트가 실행된다.
각 이벤트는 <code>REQUIRES_NEW</code> 트랜잭션으로 분리되어 처리되기 때문에, 락이 걸리는 시점이 자연스럽게 분산된다.</p>
<p>즉, 채점 결과 확정 → 커밋 → 이후에 통계 업데이트가 실행되므로, 여러 스레드가 동시에 접근해도 통계 업데이트의 타이밍 자체가 분산되어 락 충돌 없이 자연스럽게 순차 처리되는 효과를 기대할 수 있다</p>
<p>또한, <code>problemId</code>만 전달해 직접 쿼리로 수정하기 때문에,
JPA의 더티 체킹이나 엔티티 락 없이도 안전하게 업데이트할 수 있다.</p>
<p>결과적으로, 트랜잭션 분리와 실행 시점 분산을 통해
락 충돌을 최소화하고 데드락을 구조적으로 예방하는 설계로 리팩토링할 수 있었다.</p>
<blockquote>
<p>실제 성능 테스트에서 구조 개선 효과는 확실하게 드러났다.
기존에는 <strong>10명의 동시 요청만으로도 데드락이 반복적으로 발생</strong>했지만, 
<strong>구조 개선 이후 80명 동시 요청 수준까지 테스트해도 단 한 차례의 데드락도 발생하지 않았다</strong>.</p>
</blockquote>
<hr>
<h2 id="6-마무리">6. 마무리</h2>
<p>이번 경험을 통해 단순한 예매 시스템처럼 1명만 점유하면 되는 구조가 아닌,
다중 스레드가 공유 자원에 접근할 때 발생하는 락 경합과 트랜잭션 충돌을 실제로 분석하고 대응하는 구조를 설계해볼 수 있었다.</p>
<p>Ezcode 프로젝트를 진행하면서 문제를 분석하고 구조를 설계하는 나만의 사고 흐름이 생겼다는 점에서 큰 성장이 있었다고 느낀다.</p>
<p>이제는 본격적으로 취업 준비에 집중할 시기이기에 기능 고도화는 잠시 멈추려고 한다.
현재 구조로도 안정적으로 잘 돌아가길 바란다...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 채점 시스템 2차 고도화: WebSocket, GitHub 자동 푸시, 성능 테스트]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B3%A0%EB%8F%84%ED%99%94-v2</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B3%A0%EB%8F%84%ED%99%94-v2</guid>
            <pubDate>Sat, 05 Jul 2025 12:07:24 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이번 <strong>2차 고도화</strong>는 지난 1차 고도화에서 발견된 문제를 해결, 실제 운영 환경에서도 안정적으로 동작할 수 있는 구조로 개선을 최우선 목표로 진행되었다.</p>
<ul>
<li><strong>SSE 구조의 한계를 극복하기 위해 WebSocket 기반 구조로 전환</strong></li>
<li><strong>정답 제출 시 GitHub 저장소로 자동 커밋 &amp; 푸시되는 기능 구현</strong></li>
<li><strong>채점 관련 컴포넌트에 대해 단위 테스트를 작성하여 커버리지 100% 달성</strong></li>
</ul>
<p>또한, 실제 운영 환경에서의 성능을 확인하기 위해 동시 사용자 수에 따른 채점 처리량, 제출 언어별 성능 차이를 측정하고, 그 결과를 그래프 형태로 시각화하여 기록할 계획이다.</p>
<hr>
<h2 id="1-websocket-기반으로-전면-교체">1. WebSocket 기반으로 전면 교체</h2>
<p>지난 <a href="https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B3%A0%EB%8F%84%ED%99%94-v1"><strong>1차 고도화</strong></a> 과정에서는 <strong>SSE(Server-Sent Events)의 근본적인 한계</strong>를 마주하게 되었다.</p>
<p>SSE는 HTTP 기반 단방향 통신 방식으로, 서버가 클라이언트에게 데이터를 푸시할 수 있는 방식 중 비교적 쉽고 구현이 빠르다는 장점이 있다. 하지만 <strong>Spring Security</strong>와 <strong>Redis Stream</strong>을 함께 사용하는 현재 시스템 구조에서는, 다음과 같은 이유로 맞지 않았다.</p>
<ul>
<li><p><strong>Spring Security와의 호환 문제</strong>
SSE는 SseEmitter 객체를 반환해 연결을 유지하는 방식인데, 이 객체를 반환하는 과정에서 시큐리티 핵심 필터 중 하나가 <code>clearContext()</code>를 호출, 비동기 흐름 (<code>DispatcherType.ASYNC</code>)에서 <strong>SecurityContext가 전파될 수 없는 구조</strong>였다. </p>
</li>
<li><p><strong>Redis Stream과의 부조화</strong>: 
멀티 서버에서 메시지를 병렬로 소비 가능한 (Kafka-like) 구조상, <strong>클라이언트와 연결되지 않은 서버가 메시지를 가져가 유실되는 문제</strong>가 발생할 수 있었다.</p>
</li>
</ul>
<p>이러한 문제들로 인해 기존 SSE 구조는 <strong>실시간성, 안정성, 인증 유지</strong>라는 핵심 요건을 만족시키지 못했다.</p>
<br>

<h3 id="11-websocket-vs-polling">1.1. WebSocket vs Polling</h3>
<p>1차 고도화 당시에는 SSE를 대체할 기술로 <strong>WebSocket과 Polling 중 어떤 것을 선택할지</strong>에 대해 확답을 내리지 못한 상태였다.</p>
<p>일반적으로 코딩 테스트 사이트를 사용해보면, <strong>하나의 문제에 대한 모든 테스트케이스가 한 번에 화면에 나타나고</strong>, 각 케이스의 채점 결과가 <strong>실시간으로 하나씩 갱신되는 UI</strong>를 볼 수 있다.
이러한 방식은 사용자에게 병렬 채점이 진행되고 있다는 인식을 직관적으로 주며, <strong>UX 측면에서 매우 중요한 역할</strong>을 한다고 판단했다.</p>
<p>이를 고려해 WebSocket과 Polling을 비교해보면 다음과 같다.</p>
<ul>
<li>WebSocket은 서버에서 <strong>테스트케이스 결과가 나오는 즉시 메시지를 발행</strong>하고, 클라이언트가 이를 <strong>바로 수신</strong>한다.</li>
<li>Polling은 클라이언트가 <strong>주기적으로 서버에 요청을 보내 최신 상태를 확인</strong>한다.</li>
</ul>
<p>예를 들어, 5초 동안 5개의 테스트케이스가 채점되고, Polling 주기가 2초라고 가정하면 다음과 같은 차이가 발생한다.</p>
<ul>
<li>WebSocket: 사용자는 <strong>5건의 실시간 갱신을 모두 확인</strong>할 수 있다.</li>
<li>Polling: <strong>2~3번의 갱신</strong>만 보이며, <strong>타이밍에 따라 일부 채점 결과는 묶여서</strong> 한 번에 반영된다.</li>
</ul>
<p>이처럼 실시간성과 체감 속도 측면에서 Polling은 불리하다고 판단했으며, <strong>서버에 약간의 부하가 있더라도 사용자 경험(UX)을 최우선으로 고려해 WebSocket을 선택</strong>하게 되었다.</p>
<hr>
<h3 id="12-실시간-채점-구조와-websocket-메시지-흐름">1.2. 실시간 채점 구조와 WebSocket 메시지 흐름</h3>
<p>WebSocket을 도입하면서 가장 중요하게 설계한 부분은 <strong>사용자별로 채점 결과를 정확히 전달하고, 메시지 유실 없이 실시간 처리하는 구조</strong>를 만드는 것이었다.</p>
<p>전체 흐름은 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/eab1a216-59d4-49a4-b373-07377c93364f/image.png" alt=""></p>
<p>사용자가 문제를 제출하면 서버로 채점 요청이 들어오고, 요청을 받은 서버는 해당 정보를 <strong>Redis Stream에 메시지로 발행</strong>하며, 동시에 클라이언트에게 <code>sessionKey</code>를 발급해 응답한다.</p>
<p>이후 클라이언트는 발급받은 <code>sessionKey</code>를 기반으로 <strong>WebSocket 특정 구독 경로에 연결</strong>해 결과 수신을 준비한다.
한편, 서버 측에서는 <strong>Redis Stream을 구독 중인 채점 서버가 메시지를 소비</strong>하면서 채점 로직이 본격적으로 시작된다.</p>
<p>채점이 시작되면, 가장 먼저 <strong>테스트케이스 목록(init)</strong> 을 클라이언트에게 전송하는 메시지가 발행된다.
이 작업은 사용자가 채점 요청을 보낸 시점으로부터 평균 <strong>40ms 내외</strong>로 발생하며, 사용자는 빠르게 UI 상에서 테스트케이스 목록이 갱신되는 것을 확인할 수 있다.</p>
<p>그 다음 채점 서버는 모든 테스트케이스에 대해 <strong>병렬로 컴파일 요청을 컴파일 서버(Judge0)에 전송</strong>하고, 컴파일 서버는 각 요청에 대해 개별적으로 컴파일 결과를 응답한다.
응답을 수신함과 동시에 채점 서버는 이를 바탕으로 검증을 실시, <strong>테스트케이스별 채점 결과(case)를 실시간으로 발행</strong>하며, 모든 케이스의 <strong>채점이 완료되면 최종 결과(final)</strong> 도 함께 전송된다.</p>
<p>이러한 구조 덕분에 하나의 세션이 여러 테스트케이스 결과를 <strong>유실 없이 실시간 수신</strong>할 수 있게 되었고,
기존 SSE 구조에서 발생하던 <strong>멀티 서버 환경에서의 문제</strong> 또한 근본적으로 해결할 수 있었다.</p>
<p>아래는 테스트용 UI 화면에서 테스트케이스 실행 결과가 WebSocket을 통해 실시간으로 갱신되는 모습이다.
<img src="https://velog.velcdn.com/images/harvard--/post/c00a3832-abff-4fbd-a867-1f72c665a40f/image.gif" alt="WebSocket 채점 UI 동작 예시"></p>
<hr>
<h3 id="13-트러블슈팅-init-메시지-누락-문제">1.3. 트러블슈팅: init 메시지 누락 문제</h3>
<p>앞 문단에서는 완성된 WebSocket 구조와 메시지 흐름을 설명했지만, 이 구조를 구현하는 과정에서 예상치 못한 트러블도 있었다.
<strong>가장 대표적인 문제가 바로 <code>init</code> 메시지의 간헐적인 누락이었다.</strong></p>
<p>서버 로그를 출력해보면 <code>init</code> 메시지는 분명 정상적으로 발행되고 있었고,
<code>SimpleMessageTemplate.convertAndSendToUser()</code>를 통해 ActiveMQ 브로커로도 잘 전달되고 있었다.
ActiveMQ는 구독이 아직 완료되지 않은 경로로 이벤트가 발행되더라도 큐에 쌓아두었다가, 구독이 시작되면 자동으로 메시지를 전달해주는 구조이기 때문에, 메시지가 유실될 거라고는 생각하지 못했다.</p>
<p>그러나 실제로는 클라이언트 측에서 <code>init</code> 메시지만 종종 수신되지 않는 현상이 발생했다.
이를 확인하기 위해 브라우저의 <strong>개발자 도구 → Network 탭 → WebSocket 프레임 로그</strong>를 확인해보니, <strong>WebSocket 구독이 완료되기 직전에 <code>init</code> 메시지가 먼저 발행되는 상황</strong>이 포착되었다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/5c9851ed-941b-4b33-8509-624289554a0b/image.png" alt=""></p>
<p>구독 메시지가 전송된 시각은 <code>17:56:16.430 ~ .431</code>이고, <code>init</code> 메시지 수신은 바로 이어진 <code>17:56:16.486</code> 전에 발생해야 했지만 이 메시지가 도착하지 않았다.
결국 문제는 <strong>메시지를 소비하고 전달하는 속도 자체가 너무 빨랐던 것</strong>이었다.</p>
<p>앞서 설명한 대로 <code>init</code> 메시지는 <strong>채점 요청 후 평균 40ms 내외</strong>로 발행되는데, <strong>WebSocket 구독 완료 시점과 발행 시점이 수 밀리초 차이로 엇갈릴 경우</strong> 메시지가 클라이언트로 전달되지 않을 수 있다.
이 사실을 깨달은 후, 서버에서 <code>init</code> 메시지를 발행하기 전 잠깐의 <code>Thread.sleep()</code> 을 주는 방식으로 급한 불을 끄긴 했지만, 이는 어디까지나 임시방편이다.</p>
<p>WebSocket 구조는 실시간성과 응답 속도가 생명인데, sleep을 넣는 순간 <strong>최대 수용 가능한 사용자 수가 감소</strong>하게 된다.
따라서 이 문제는 서버 측에서 delay로 해결하기보다는, 클라이언트 쪽에서 먼저 WebSocket 구독을 확실히 완료한 후 채점 요청을 보내는 방향으로 구조를 재정비할 계획이다.</p>
<blockquote>
<p>이 경험을 통해, 로그 확인의 중요성과 <strong>&quot;메시지 발행 타이밍&quot;</strong>이라는 아주 짧은 시간의 이슈가 얼마나 치명적일 수 있는지를 직접 체감할 수 있었다.</p>
</blockquote>
<hr>
<h2 id="2-github-자동-푸시-개발자스럽게-풀이-기록-남기기">2. GitHub 자동 푸시: 개발자스럽게 풀이 기록 남기기</h2>
<p>이번 고도화에서 가장 재미있고도 의미 있는 기능 중 하나는 바로, <strong>문제 풀이 결과가 정답일 경우, GitHub 저장소에 풀이 기록을 자동으로 커밋 &amp; 푸시해주는 기능</strong>이다.</p>
<p>단순히 서버에만 저장하는 것이 아니라, &quot;한 문제를 풀었다&quot;는 사실을 내 GitHub 저장소에 기록으로 남기고, 잔디와 커밋 로그를 통해 성장 히스토리를 시각화할 수 있다.</p>
<br>

<h3 id="21-기능-개요">2.1. 기능 개요</h3>
<p>이 기능은 다음 세 가지 조건을 모두 만족해야 작동한다.</p>
<ul>
<li>GitHub OAuth 연동이 완료된 사용자</li>
<li>채점 결과가 정답(<code>isPassed == true</code>)</li>
<li>GitHub Push 기능이 ON 상태(<code>isGitPushStatus == true</code>)</li>
</ul>
<p>위 조건을 만족할 경우 내부적으로 <code>GitHubPushService</code>가 동작한다. 암호화되어 저장된 GitHub 토큰은 <code>aesUtil</code>을 통해 복호화되고, 이후 <code>GitHubClient</code>를 통해 API 기반 커밋 &amp; 푸시가 진행된다.</p>
<pre><code class="language-java">if (!ctx.isGitPushStatus() || !ctx.isPassed()) {
    return;
}

String decryptedToken = aesUtil.decrypt(info.getGithubAccessToken());
gitHubClient.commitAndPushToRepo(GitHubPushRequest.of(ctx, info, decryptedToken));</code></pre>
<hr>
<h3 id="22-처리-흐름">2.2. 처리 흐름</h3>
<p>동일한 문제에 대해 같은 코드로 제출할 경우 <strong>불필요한 커밋이 쌓이지 않도록</strong> SHA 비교를 통해 변경 여부를 감지한다.</p>
<pre><code class="language-java">String codeBlobSha = blobCalculator.calculateBlobSha(req.sourceCode());
Optional&lt;String&gt; existingSha = gitHubApiClient.fetchSourceBlobSha(req);

if (existingSha.map(codeBlobSha::equals).orElse(false)) {
    return; // 동일 코드이므로 커밋 생략
}</code></pre>
<ul>
<li>현재 제출된 코드의 <strong>SHA-1 해시</strong>를 계산</li>
<li>저장소에 이미 동일한 내용의 blob이 존재하는지 확인</li>
<li>동일하다면 <strong>커밋을 생략</strong></li>
</ul>
<p>이후 실제 커밋 과정을 GitHub API로 수행하는데, 일반적인 <code>git commit → git push</code> 명령어처럼 단순하지 않고 다음과 같은 순서로 진행한다.</p>
<ol>
<li>현재 브랜치의 최신 커밋 SHA 조회</li>
<li>해당 커밋의 base tree SHA 조회</li>
<li>커밋할 파일 목록 기반으로 새로운 blob 및 tree 객체 생성</li>
<li>새 커밋 생성 및 브랜치 HEAD 업데이트</li>
</ol>
<pre><code class="language-java">String headCommitSha = fetchHeadCommitSha(req);
String baseTreeSha = fetchBaseTreeSha(req, headCommitSha);
String newTreeSha = createTree(req, baseTreeSha, entries);
String commitSha = createCommit(req, parentSha, treeSha);
updateBranchReference(req, commitSha);</code></pre>
<p>이 과정을 통해 실제 Git 내부 구조와 동일하게 <code>commit → tree → blob</code> 흐름이 구성되며, 모든 요청은 <code>WebClient</code> 기반으로 수행된다.
현재는 사용자의 저장소 <strong>디폴트 브랜치 기준</strong>으로 커밋이 이루어지며, 향후에는 <strong>브랜치 선택 기능</strong>도 도입할 예정이다.</p>
<hr>
<h3 id="23-커밋-구조-및-파일-경로">2.3. 커밋 구조 및 파일 경로</h3>
<p>커밋에는 단순한 소스코드뿐 아니라 <strong>문제 제목, 설명, 제출 시간, 메모리/시간 사용량</strong> 등이 포함된 <code>README_언어이름.md</code> 파일도 함께 저장된다.</p>
<p><strong>예시: <code>README_python.md</code></strong></p>
<pre><code># 68. 특정 달의 일수 구하기
- 제출 언어: Python (3.8.1)
- 제출 일자: 2025-07-03 18:21:34

## 문제 설명

년도 , 달을 입력으로 받아 이 달의 날 수를 구하는 프로그램을 작성하시오.
...

### 제출 요약
- 메모리: 3310KB
- 실행 시간: 23ms

&gt; EzCode</code></pre><p>파일 저장 경로는 다음과 같은 구조로 구성된다.</p>
<pre><code>/&lt;루트&gt;/&lt;난이도&gt;/&lt;문제번호. 문제제목&gt;/README_언어이름.md
/&lt;루트&gt;/&lt;난이도&gt;/&lt;문제번호. 문제제목&gt;/문제제목.언어별 확장자</code></pre><p>예를 들어 Java로 제출했다면 아래와 같이 저장된다.</p>
<pre><code>/ezcode/레벨1/1000. A+B/README_java.md
/ezcode/레벨1/1000. A+B/A+B.java</code></pre><p>해당 파일명 처리는 <code>FileType</code> enum을 통해 처리되며, 언어에 따라 확장자가 자동 결정된다.</p>
<pre><code class="language-java">public enum FileType {
    SOURCE, README;

    public String resolveFilename(GitHubPushRequest req) {
        return switch (this) {
            case SOURCE -&gt; String.format(&quot;%s.%s&quot;,
                req.problemTitle(), Extensions.getExtensionByLanguage(req.languageName())
            );
            case README -&gt; String.format(&quot;README_%s.md&quot;,
                req.languageName().toLowerCase()
            );
        };
    }
}</code></pre>
<blockquote>
<p>동일한 문제를 다른 언어로 제출하면 해당 폴더 내에 각 언어별 코드와 README 파일이 누적되어 커밋된다. 이 구조 덕분에 GitHub 커밋 로그를 통해 문제 풀이 히스토리를 시계열로 추적할 수 있다.</p>
</blockquote>
<hr>
<h3 id="24-예외-처리-및-사용자-알림">2.4. 예외 처리 및 사용자 알림</h3>
<p>커밋 한 건을 푸시하기 위해서는 내부적으로 <strong>총 6번의 GitHub API 호출</strong>이 이루어진다. 
단계가 여러 번으로 나뉘기 때문에, 이 중 하나라도 실패하면 전체 커밋 흐름이 그대로 무너질 수 있다. 이를 방지하기 위해, 실제 구현에서는 <strong>API별로 발생 가능한 예외를 명확하게 분리</strong>해 처리하고 있다.</p>
<pre><code class="language-java">    protected Optional&lt;String&gt; fetchSourceBlobSha(GitHubPushRequest req) {
        String fileName = FileType.SOURCE.resolveFilename(req);
        String path = String.format(&quot;%s/%s/%s/%s&quot;, repoRootFolder, req.difficulty(), req.getProblemInfo(), fileName);

        return baseBuilder(req.accessToken())
            .get()
            .uri(uriBuilder -&gt; uriBuilder
                .path(CONTENTS_PATH)
                .queryParam(&quot;ref&quot;, req.branch())
                .build(req.owner(), req.repo(), path)
            )
            .retrieve()
            .onStatus(status -&gt; status == HttpStatus.UNAUTHORIZED,
                resp -&gt; Mono.error(new GitHubClientException(GitHubExceptionCode.INVALID_ACCESS_TOKEN)))
            .onStatus(status -&gt; status == HttpStatus.FORBIDDEN,
                resp -&gt; Mono.error(new GitHubClientException(GitHubExceptionCode.PERMISSION_DENIED)))
            .onStatus(status -&gt; status == HttpStatus.TOO_MANY_REQUESTS,
                resp -&gt; Mono.error(new GitHubClientException(GitHubExceptionCode.RATE_LIMIT_EXCEEDED)))
            .onStatus(HttpStatusCode::is5xxServerError,
                resp -&gt; Mono.error(new GitHubClientException(GitHubExceptionCode.NETWORK_ERROR)))
            .bodyToMono(JsonNode.class)
            .map(node -&gt; node.get(&quot;sha&quot;).asText())
            .onErrorResume(WebClientResponseException.NotFound.class, e -&gt; Mono.empty())
            .onErrorMap(e -&gt; e instanceof GitHubClientException
                ? e
                : new GitHubClientException(GitHubExceptionCode.UNKNOWN_ERROR))
            .blockOptional();
    }</code></pre>
<p>이 외에도 모든 GitHub API 호출에는 상황별로 커스텀 예외 코드가 적용되어 있어, 단순히 실패 여부만 판단하는 것이 아니라, 어디서 어떤 문제가 발생했는지 정확히 추적할 수 있도록 예외 코드를 세분화해 관리하고 있다.</p>
<p>예외가 발생한 경우에는 다음과 같은 방식으로 사용자와 운영자 모두에게 즉시 알림이 전파된다.</p>
<ul>
<li>내부 <code>ExceptionNotifier</code>를 통해 디스코드 등의 운영 채널에 오류 메시지 전송</li>
<li>프론트엔드에는 상태 이벤트 (<code>Started</code>, <code>Succeeded</code>, <code>Failed</code>) 전송</li>
<li>사용자는 UI 상에서 GitHub 푸시 상태를 실시간으로 확인 가능</li>
</ul>
<hr>
<h3 id="25-실제-사용-예시-및-마무리">2.5. 실제 사용 예시 및 마무리</h3>
<h4 id="실제-github에-반영된-커밋-모습">실제 GitHub에 반영된 커밋 모습</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/ab0447b6-e325-42d1-b50c-d50c6a512685/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/83d7d458-d378-4e9a-b215-f802ce2d3cc0/image.png" alt=""></p>
<p>다른 소스 코드로 다시 제출했을 땐 이렇게 변경 사항도 기록이 된다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/8a8fe3e5-f3ff-4f8a-9d7d-cec775e59d74/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/c0cc3435-4d23-4cb4-98a5-5a19a494594b/image.png" alt=""></p>
<p>GitHub 자동 푸시 기능은 단순히 소스코드를 저장소에 올리는 기능을 넘어, <strong>개발자의 학습 과정을 자연스럽게 기록으로 전환해주는 역할</strong>을 한다.</p>
<p>문제를 풀고 제출하는 순간, 해당 풀이가 GitHub에 커밋되고 히스토리로 남는다는 것은 단지 기술적 편의를 넘어서, 매일의 성장 과정을 시각적으로 축적해 나갈 수 있다는 뜻이다.</p>
<p>이처럼 자동 푸시 기능은 사용자의 풀이 경험을 일회성 활동으로 소비하지 않고, 꾸준히 이어지는 학습 과정의 일부로 만들어준다. 결국 &quot;정답을 맞히는 것&quot;에 그치지 않고, 그 과정을 기억하고 쌓아갈 수 있도록 돕는 구조를 만들어가는 데 목적이 있다.</p>
<hr>
<h2 id="3-채점-성능-테스트">3. 채점 성능 테스트</h2>
<p>프로젝트 구현 과정에서 언어별로 채점 요청을 동시에 보냈을 때, 응답 시간과 최대 채점 수용량에서 큰 차이를 체감한 바 있다. 
따라서 <strong>언어별 응답 시간(<code>avg_time_ms</code>) 증가 추이와 사용자 수(<code>user_count</code>)에 따른 처리 성능 차이를 측정</strong>함으로써,
<strong>컴파일 서버 스펙 선정의 기준을 확보</strong>하는 것을 목표로 성능 테스트를 진행하였다.</p>
<p>테스트 도구로는 <strong>Node.js 기반 사용자 시뮬레이션 스크립트</strong>를 직접 작성하였으며, JWT 기반 인증과 WebSocket 수신을 활용해 다중 사용자 채점 요청을 수행하였다.</p>
<p>해당 테스트 스크립트는 <a href="https://github.com/thezz9/submit-test"><strong>GitHub 저장소</strong></a>에서 확인할 수 있다.</p>
<hr>
<h3 id="31-테스트-시나리오">3.1 테스트 시나리오</h3>
<p>다음과 같은 조건과 절차에 따라 성능 테스트를 설계하였다.</p>
<h4 id="테스트-대상-문제">테스트 대상 문제</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/42f8e13b-d9f3-44d6-bce7-915570347326/image.png" alt=""></p>
<ul>
<li>문제 이름: <strong>변경 가능한 구간 합</strong></li>
<li>분류: 세그먼트 트리, 자료구조</li>
<li>핵심 연산: &quot;원소 변경&quot; + &quot;구간 합 조회&quot;가 반복적으로 혼합</li>
<li>테스트케이스 수: <strong>총 8개</strong></li>
<li>입력 사이즈: <strong>최대 1,000,000개의 원소</strong>, <strong>최대 10,000회의 연산</strong>
→ 메모리와 시간 복잡도가 높은 구조로, <strong>서버 부하 유발에 효과적</strong></li>
</ul>
<blockquote>
<p>테스트 목적에 적합하도록, 고부하 문제로 선택했으며 실제 컴파일 서버에서 메모리와 연산량을 강하게 요구한다.</p>
</blockquote>
<h4 id="테스트-조건">테스트 조건</h4>
<ul>
<li>컴파일 서버 사양: 8 vCPUs, 32 GB RAM, 640 GB SSD</li>
<li>테스트 언어: Java (1), C (2), C++ (3), Python (4)</li>
<li>동시 요청 수: 5명 / 10명 / 20명 / 30명 / 40명</li>
<li>반복 횟수: 각 조합당 5회 이상 실행</li>
<li>측정 지표: <code>final_receive_ms</code>, <code>testcase_received_count</code>, <code>loss_rate</code></li>
</ul>
<blockquote>
<p><code>loss_rate</code>: 일부 테스트케이스가 수신되지 않은 비율
→ 정상 수신률이 <strong>100% 미만이면 동시성 처리에 문제 발생</strong>으로 간주</p>
</blockquote>
<h4 id="데이터-수집-방식">데이터 수집 방식</h4>
<ul>
<li>저장 위치: <code>MySQL</code> 테이블 <code>test_result_table</code></li>
<li>구분 방식:<ul>
<li><code>language_id</code> → 1~4 (Java, C, C++, Python)</li>
<li><code>test_type</code> → <code>concurrency_5_users</code>, <code>concurrency_10_users</code> 등</li>
</ul>
</li>
<li>단위: <strong>밀리초(ms)</strong> 기준</li>
</ul>
<hr>
<h3 id="32-테스트-결과">3.2. 테스트 결과</h3>
<h4 id="응답-시간-테이블">응답 시간 테이블</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/6889e75e-a328-49fd-b809-4865fe64216c/image.png" alt=""></p>
<ul>
<li>Java는 동시 요청 수가 증가할수록 <strong>응답 시간이 급격히 상승</strong></li>
<li>반면 C, Python은 비교적 완만한 상승을 보였으며, <strong>낮은 사용자 수 기준 성능은 우수</strong></li>
<li>C++은 중간 성능이나, 사용자 수가 많아질수록 <strong>Java 다음으로 높은 응답 시간</strong>을 기록</li>
</ul>
<br>

<h4 id="전체-성능-그래프-minavgmax">전체 성능 그래프 (min/avg/max)</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/e59b12be-f554-49d5-bec4-8ad1e935b7b0/image.png" alt=""></p>
<ul>
<li>Java는 일관되게 가장 높은 응답 시간<ul>
<li>특히 <strong>20명 이상부터 급격한 증가</strong>, 최대 60초 근접 → <strong>측정 불가 수준</strong></li>
</ul>
</li>
<li>Python은 거의 모든 케이스에서 <strong>안정적인 성능 유지</strong></li>
</ul>
<br>

<h4 id="응답-시간-분포-선-그래프">응답 시간 분포 선 그래프</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/d2d2ec15-1fda-4bd1-95f4-9d6a0d4fd83f/image.png" alt=""></p>
<ul>
<li><strong>x축</strong>: 사용자 수, <strong>y축</strong>: 응답 시간(ms)</li>
<li>각 언어별 <strong>평균(Avg)</strong> 과 <strong>최대(Max)</strong> 응답 시간을 선과 그림자로 시각화</li>
<li><strong>Java / C++</strong>: 사용자 수가 늘어날수록 응답 시간이 급격히 증가, 성능 저하 뚜렷</li>
<li><strong>Python / C</strong>는 성능이 <strong>안정적이고 일정하게 유지</strong></li>
</ul>
<br>

<h4 id="메세지-손실율-테이블">메세지 손실율 테이블</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/de107587-acce-436b-9d93-c33c2563dd0b/image.png" alt=""></p>
<ul>
<li>모든 언어 및 모든 동시 사용자 수 조합에서 <strong>손실률은 0%로 나타났다.</strong></li>
<li>즉, 최대 40명의 동시 제출 환경에서도 <strong>단 하나의 테스트 케이스 채점 결과도 누락되지 않고 정확히 수신되었음을 의미</strong>한다.</li>
</ul>
<hr>
<h3 id="33-결과-해석-및-인사이트">3.3. 결과 해석 및 인사이트</h3>
<h4 id="1-java의-느린-응답-속도">1. Java의 느린 응답 속도</h4>
<p>테스트 결과를 살펴보면, Java는 다른 언어들에 비해 상대적으로 <strong>응답 속도가 가장 느린 편</strong>이었다.<br>그 배경에는 JVM 부팅 시간, GC(Garbage Collection)과 같은 Java 특유의 런타임 환경 구조가 영향을 미친 것으로 보인다.
특히, 사용자 수가 많아지면서 동시에 채점 요청이 몰리는 상황에서는 <strong>Java의 성능 저하가 급격히 발생</strong>했다. 
실제로 20명 이상의 사용자가 동시에 제출한 경우, 일부 요청에서는 응답 시간이 <strong>60초에 근접</strong>하는 현상도 관찰되었다.</p>
<h4 id="2-python의-예상-밖-우수한-응답">2. Python의 예상 밖 우수한 응답</h4>
<p>반면 Python은 테스트 전 예상보다 <strong>훨씬 더 안정적이고 빠른 응답</strong>을 보였다.
이유를 분석해보면, Python은 기본적으로 <strong>CPython 기반의 경량 실행 환경</strong>을 가지며, Java처럼 무거운 VM 부팅이나 GC가 없기 때문에 초기 실행이 빠른 것이라 예상된다.</p>
<h4 id="3-시스템-확장성-분석-지표로-활용-가능">3. 시스템 확장성 분석 지표로 활용 가능</h4>
<p>이번 성능 테스트의 목적은 단순히 어떤 언어가 빠르냐를 측정하는 데에 그치지 않는다.
실제 운영 환경에서 <strong>발생할 수 있는 다양한 상황에 대해</strong>, 서버가 얼마나 확장성 있게 대응할 수 있는지를 확인하는 데 더 큰 의미가 있다.
사용자 수가 증가하거나 고성능 문제에 대한 제출이 많아질 경우, 어떤 언어가 병목을 유발할 수 있는지, 또는 어떤 방식으로 부하를 분산하면 가장 효율적인지를 <strong>사전에 예측할 수 있다.</strong></p>
<p>결국 이번 테스트 결과는, 향후 서버 스펙 업그레이드나 실행 제한 정책 설계 등 <strong>운영 전략을 수립하는 데 있어 매우 중요한 참고 자료</strong>가 될 수 있다.</p>
<h4 id="4-실제-서비스-기준-10명-이상-동시-제출-빈도는-낮음">4. 실제 서비스 기준, 10명 이상 동시 제출 빈도는 낮음</h4>
<p>실제 운영 중인 백준(BOJ) 등의 사례를 참고하면, <strong>10명 이상의 동시 제출이 발생하는 빈도는 매우 낮다.</strong>
또한 모든 사용자가 동일한 언어(Java)로 제출하는 것도 아니기 때문에, <strong>자바 기반 부하가 집중될 가능성 자체</strong>도 낮다.</p>
<p>따라서 이번 테스트 결과를 바탕으로 보면, <strong>실제 서비스 환경에서는 충분히 여유 있는 구조</strong>로 볼 수 있다.
특히, 현재 시스템 구조에서는 4코어 또는 2코어 환경에서도 안정적으로 운영이 가능하다는 점에서, <strong>서버 사양에 대한 가성비 최적화 측면에서도 긍정적인 신호</strong>로 해석된다.</p>
<h4 id="5-webclient-pool-한계-인식-및-해결">5. WebClient Pool 한계 인식 및 해결</h4>
<p>채점 로직에서는 하나의 제출에 대해 <strong>여러 개의 테스트케이스를 병렬로 처리</strong>하기 때문에, 내부적으로는 상당히 많은 HTTP 요청이 발생한다.</p>
<p>예를 들어 테스트케이스가 8개이고, 동시 제출 사용자 수가 20명이라면 단순 계산만 해도 <strong>160개의 HTTP 요청이 한꺼번에 발생</strong>하게 된다. 여기에 각 요청 결과를 <strong>Polling 방식</strong>으로 다시 가져오게 되면, 전체 커넥션 수는 <strong>최대 320개 이상으로 증가</strong>한다.</p>
<p>이 상황에서 WebClient의 커넥션 풀 크기가 부족하면, <strong>커넥션 병목이나 타임아웃 등의 문제</strong>가 발생할 수 있고, 실제로 발생했다.</p>
<p>이 문제를 해결하기 위해, <strong>내부적으로 비동기 작업을 실행하는 쓰레드 풀의 크기를 제한하고, 동시에 실행되는 요청 수를 제어</strong>하는 구조로 리팩토링했다.</p>
<h4 id="6-deadlock-문제-발견">6. DeadLock 문제 발견</h4>
<p>정답 여부를 판단한 뒤, 해당 문제(<code>Problem</code>)의 제출 횟수, 정답 횟수를 갱신하는 로직이 존재하는데, <strong>여러 쓰레드가 접근하는 과정에서 발생한 데드락(Deadlock)</strong> 문제였다.
동시성 이슈에 매우 민감한 지점이라는 것을 테스트 과정에서 발견하게 되었고, 현재는 해결을 한 상태다. <strong>해결 과정은 이후 3차 고도화 글에서 더 구체적으로 다룰 예정이다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 채점 시스템 1차 고도화: 병렬 처리, 프롬프트 정형화, 동시성 제어]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B3%A0%EB%8F%84%ED%99%94-v1</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B3%A0%EB%8F%84%ED%99%94-v1</guid>
            <pubDate>Sun, 22 Jun 2025 19:08:39 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이전 글에서 정리했던 기존의 채점 시스템은 동시 처리 부재로 여러 사용자를 동시에 처리하지 못하고 다중 제출 시 불필요한 서버 리소스 낭비와 일관된 결과 제공이 되지 않는 한계를 가지고 있었다.</p>
<p>2주간의 고도화 과정을 통해</p>
<ul>
<li><strong>비동기 병렬 처리</strong>로 여러 제출을 순차가 아닌 동시에 처리</li>
<li><strong>AI 코드 리뷰 프롬프트 정형화</strong>로 리뷰 일관성 확보</li>
<li><strong>간단한 락</strong>으로 중복 채점 &quot;따닥&quot; 이슈 제어</li>
</ul>
<p>등의 주요 개선을 적용했고, 실제로 짧은 시간 내에 <strong>4명 동시 제출</strong>까지 단일 서버에서 안정적으로 처리함을 확인했다. </p>
<p>이번 포스팅에서는 구현 과정과 거기서 마주한 이슈, 해결 방법을 정리하고 2차로 계획 중인 고도화 작업까지 기록해보려고 한다.</p>
<hr>
<h2 id="1-비동기-병렬-처리">1. 비동기 병렬 처리</h2>
<p>기존 채점 시스템은 다음과 같은 흐름을 따라 동작했다.</p>
<blockquote>
<p>사용자 제출 요청 → 외부 컴파일 서버(Judge0)에 소스 코드 전송 → 컴파일 결과 수신 → 테스트케이스마다 채점 → SSE로 결과 전송 → 최종 채점 결과 저장 및 Final SSE 전송</p>
</blockquote>
<p>하지만 이 전체 과정이 <strong>동기식(Synchronous)</strong>으로 처리되고, 내부적으로는 <code>new Thread()</code>로 직접 쓰레드를 생성해 채점 로직을 실행하고 있었기 때문에 다음과 같은 문제들이 발생했다.</p>
<ul>
<li><p>요청 병목
하나의 요청이 끝나야 다음 요청이 처리되는 구조였기 때문에, 여러 사용자가 동시에 제출하면 <strong>응답 속도가 급격히 저하</strong>되었다.</p>
</li>
<li><p>테스트케이스 순차 처리
하나의 제출이 여러 테스트케이스를 포함할 경우, <strong>순차적으로 처리</strong>되어 전체 채점 시간이 길어졌다.</p>
</li>
<li><p>스레드 폭주로 인한 서버 리스크
<code>new Thread()</code> 방식으로 채점 쓰레드를 직접 생성하다 보니, 사용자가 많아지면 JVM 스레드 수가 기하급수적으로 증가하면서 <strong>OOM(Out Of Memory) 또는 서버 다운 위험</strong>이 있었다. <strong>제어할 수 있는 장치가 없었다.</strong></p>
</li>
</ul>
<p>이런 구조에서는 실제 테스트 환경에서 <strong>단 2~3명의 사용자 동시 제출</strong>만으로도 전체 시스템 응답이 지연되는 현상이 나타났다.
이를 해결하기 위해 채점 시스템을 역할별로 분리하고, 각 단계를 <strong>비동기 &amp; 병렬 처리 구조</strong>로 전환했다.</p>
<p>그 첫 단계로 도입한 것이 <strong>Executor 기반 스레드 풀 구성</strong>이다.</p>
<br>

<h3 id="11-executorconfig-기반-스레드-풀-설계">1.1. ExecutorConfig 기반 스레드 풀 설계</h3>
<p><code>ThreadPoolTaskExecutor()</code>를 이용해, 서로 다른 역할(메시지 소비, 제출 처리, 테스트케이스 처리)별로 <strong>별도의 스레드 풀</strong>을 정의했다.
이렇게 하면 각 작업 종류에 맞춘 동시성 제어가 가능하고, 하나의 풀에 부하가 몰려 전체 시스템이 정체되는 상황을 방지할 수 있다.</p>
<h4 id="executorconfig">ExecutorConfig</h4>
<pre><code class="language-java">@EnableAsync
@Configuration
public class ExecutorConfig {

    @Bean(name = &quot;consumerExecutor&quot;)
    public Executor consumerExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix(&quot;consumer-&quot;);
        executor.initialize();
        return executor;
    }

    @Bean(name = &quot;judgeSubmissionExecutor&quot;)
    public Executor judgeSubmissionExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix(&quot;submission-&quot;);
        executor.initialize();
        return executor;
    }

    @Bean(name = &quot;judgeTestcaseExecutor&quot;)
    public Executor judgeTestcaseExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(25);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix(&quot;testcase-&quot;);
        executor.initialize();
        return executor;
    }
}</code></pre>
<ul>
<li><code>@EnableAsync</code><ul>
<li><code>@Async</code> 어노테이션이 붙은 메서드를 별도의 스레드 풀에서 실행할 수 있도록 <strong>활성화</strong></li>
</ul>
</li>
<li><code>corePoolSize</code><ul>
<li>애플리케이션 시작 시 미리 생성해 대기시키는 <strong>기본 스레드 개수</strong></li>
</ul>
</li>
<li><code>maxPoolSize</code><ul>
<li>처리량이 급증할 때 확장 가능한 <strong>최대 스레드 수</strong></li>
</ul>
</li>
<li><code>queueCapacity</code><ul>
<li>스레드가 모두 사용 중일 때, 대기열에 버퍼링할 수 있는 작업 수</li>
<li>일정 수준까지만 <strong>대기열에 쌓이도록 제한</strong></li>
</ul>
</li>
<li><code>threadNamePrefix</code><ul>
<li>로그에 찍히는 스레드 이름 접두사</li>
<li><code>consumer-1</code>, <code>submission-3</code> 같은 이름으로 구분되어 <strong>디버깅, 모니터링에 유용</strong></li>
</ul>
</li>
</ul>
<h4 id="사용-예시">사용 예시</h4>
<pre><code class="language-java">@Slf4j
@Service
@RequiredArgsConstructor
public class SubmissionService {
    @Async(&quot;judgeSubmissionExecutor&quot;)
    public void submitCodeStream(SubmissionMessage msg) {
        // judgeSubmissionExecutor 풀에서 실행됨
    }
}</code></pre>
<br>

<h3 id="12-redis-stream-도입">1.2. Redis Stream 도입</h3>
<p>기존에는 사용자 제출 요청이 들어오면 애플리케이션이 <strong>즉시 채점 로직을 처리</strong>했다. 
이 구조는 동기 처리 방식이었고, 다수 요청이 들어오면 순차적으로 밀리게 되어 전체 시스템이 쉽게 병목에 걸렸다. 
이런 문제를 해결하고 추후에 확장 가능성을 고려해 <strong>Redis Stream</strong>을 도입하여 <strong>비동기 이벤트 기반 처리 구조</strong>로 전환했다.</p>
<p>Redis Stream은 Kafka처럼 <strong>메시지를 순서대로 쌓고, 컨슈머 그룹을 통해 메시지를 분산 처리</strong>할 수 있는 구조를 지원한다. 
이를 통해 요청이 들어오면 <strong>Stream에 메시지를 발행(Publish)</strong> 하고, 비동기적으로 <strong>여러 스레드가 동시에 메시지를 소비(Consume)</strong> 하도록 만들었다.</p>
<blockquote>
<p>메시지 발행 → judge-queue Stream → consumer 그룹 → 멀티 스레드 분산 처리 → 채점 로직 실행</p>
</blockquote>
<h4 id="적용-구조-요약">적용 구조 요약</h4>
<ul>
<li>Stream 이름: <code>judge-queue</code></li>
<li>Consumer Group: <code>judge-group</code></li>
<li>Consumer 수: 현재는 단일</li>
<li>메시지 내용: <code>SubmissionMessage(emitterKey, problemId, languageId, userId...)</code></li>
</ul>
<h4 id="redisjudgequeueconsumer">RedisJudgeQueueConsumer</h4>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class RedisJudgeQueueConsumer implements StreamListener&lt;String, MapRecord&lt;String, String, String&gt;&gt; {

    private final SubmissionService submissionService;
    private final StringRedisTemplate redisTemplate;

    @Override
    public void onMessage(MapRecord&lt;String, String, String&gt; message) {
        Map&lt;String, String&gt; values = message.getValue();

        SubmissionMessage msg = new SubmissionMessage(
            values.get(&quot;emitterKey&quot;),
            Long.valueOf(values.get(&quot;problemId&quot;)),
            Long.valueOf(values.get(&quot;languageId&quot;)),
            Long.valueOf(values.get(&quot;userId&quot;)),
            values.get(&quot;sourceCode&quot;)
        );

        try {
            log.info(&quot;[컨슈머 수신] {}&quot;, msg.emitterKey());
            submissionService.submitCodeStream(msg);

            log.info(&quot;[컨슈머 ACK] messageId={}&quot;, message.getId());
            redisTemplate.opsForStream().acknowledge(&quot;judge-group&quot;, message);
        } catch (Exception e) {
            log.error(&quot;채점 메시지 처리 실패: {}&quot;, message.getId(), e);
            throw new SubmissionException(SubmissionExceptionCode.REDIS_SERVER_ERROR);
        }
    }</code></pre>
<p>이 <code>RedisJudgeQueueConsumer</code>는 <code>consumerExecutor</code> 스레드 풀에서 실행되며, 실제 채점 로직은 <code>judgeSubmissionExecutor</code>로 위임된다. 이렇게 계층적으로 스레드를 분산함으로써, 메시지 수신과 채점 실행의 책임을 분리했다.</p>
<h3 id="하지만-여기서-해결해야할-문제점이-두-가지-있다">하지만 여기서 해결해야할 문제점이 두 가지 있다.</h3>
<h4 id="1-ack처리-시점-문제">1. ACK처리 시점 문제</h4>
<p>현재는 <code>submitCodeStream()</code> 호출 이후, <strong>콜백이나 실행 결과를 확인하지 않고 바로 ACK</strong>를 날리는 구조다. 이렇게 되면 내부에서 예외가 발생해도 Redis 입장에서는 &quot;정상 처리됨&quot;으로 간주되기 때문에, <strong>재시도 메커니즘을 활용하지 못한다.</strong> Stream을 사용하는 의미가 퇴색되는 구조다.</p>
<p>추후 <code>ACK</code> 시점을 <code>submitCodeStream()</code>이 정상적으로 끝났을 때 명시적으로 호출하도록 개선이 필요하다.</p>
<h4 id="2-sse와-redis-stream-구조의-충돌">2. SSE와 Redis Stream 구조의 충돌</h4>
<p>테스트 도중 <strong>Redis Stream이 SSE 구조와 맞지 않다는 점</strong>을 명확히 깨달았다.
로컬 개발 환경에서는 팀원 모두 같은 코드를 실행하기 때문에, <code>StreamMessageListenerContainer</code>가 각자 등록되며, <strong>같은 컨슈머 그룹 안에서 5개의 인스턴스가 메시지를 소비</strong>하게 된다.</p>
<p>이때 문제가 발생한다.</p>
<p>예를 들어 내가 제출한 코드에 대한 SSE 연결이 내 서버에 활성화되어 있어도, 다른 팀원의 리스너가 메시지를 가져가면, <strong>그쪽에서는 <code>submitCodeStream()</code> 내부에서 SSE emitter를 찾지 못하고 예외가 발생한다.</strong></p>
<p>즉, <strong>SSE가 연결된 인스턴스와 메시지를 소비하는 인스턴스가 일치하지 않을 수 있다는 점</strong>이 Redis Stream 구조와 치명적으로 맞지 않았다.</p>
<p> 이 문제는 단순 로컬 개발뿐 아니라 <strong>서버 scale-out</strong> 상황에서도 마찬가지로 발생한다.
여러 서버 인스턴스가 컨슈머로 참여하게 되면, <strong>SSE emitter가 연결된 인스턴스와 관계없이 메시지가 소비</strong>되기 때문에, <strong>&quot;SSE 연결된 서버가 메시지를 반드시 소비한다&quot;</strong>는 전제가 깨진다.</p>
<p> 물론 Redis Stream의 장점인 <strong>재시도</strong>를 활용할 수도 있지만, 이건 <strong>&quot;운에 맡기는 구조&quot;</strong>가 되어버리고, 시스템 신뢰도를 떨어뜨리게 된다. </p>
<blockquote>
<p>이 문제 때문에 현재 구조에서는 <strong>Redis Stream의 장점을 온전히 사용하기 어렵다</strong>고 판단했고, <strong>SSE를 대체할 필요성</strong>을 느끼게 된 첫 번째 계기가 되었다.</p>
</blockquote>
<br>

<h3 id="13-judge0-컴파일-서버-비동기-요청-확장">1.3. Judge0 컴파일 서버 비동기 요청 확장</h3>
<p>Redis Stream과 스레드 풀 기반 구조를 통해 <strong>요청 분산 및 비동기 분기까지는 가능</strong>해졌지만, 채점 시스템 내부에서는 <strong>여전히 병목이 존재했다.</strong> 바로 외부 컴파일 서버에 대한 API 호출 부분이 <strong>동기적으로 처리되고 있었다는 점</strong>이다.</p>
<ul>
<li>사용자가 제출한 코드를 컴파일 서버로 전송</li>
<li>컴파일 결과가 오면 내부 채점 로직 수행 및 SSE로 응답 전송</li>
</ul>
<p>이 모든 과정을 한 쓰레드에서 순차적으로 처리하면, 컴파일 서버 응답이 지연될 경우 전체 채점 흐름이 막히게 된다. </p>
<p>사용자 경험 측면에서 <strong>하나라도 빠르게 결과를 보여주는 것</strong>이 중요하다고 판단해 <code>CompletableFuture.runAsync()</code>와 <code>ThreadPoolTaskExecutor</code>를 직접 조합해 각 테스트케이스의 채점 로직을 <strong>완전히 분리된 스레드에서 병렬 실행하도록 개선</strong>했다.</p>
<h4 id="runtestcaseasync">runTestcaseAsync</h4>
<pre><code class="language-java">private void runTestcaseAsync(
        Testcase tc, SubmissionMessage msg, Long judge0Id,
        ProblemInfo problemInfo, SubmissionContext context, SseEmitter emitter
    ) {
        CompletableFuture.runAsync(() -&gt; {
            try {
                log.info(&quot;[Judge RUN] Thread = {}&quot;, Thread.currentThread().getName());

                // 1. Judge0 제출 및 응답 대기
                String token = judgeClient.submitAndGetToken(
                    new CodeCompileRequest(msg.sourceCode(), judge0Id, tc.getInput())
                );
                JudgeResult result = judgeClient.pollUntilDone(token);

                // 2. 평가 및 채점 통계 업데이트
                AnswerEvaluation evaluation = submissionDomainService.handleEvaluationAndUpdateStats(
                    TestcaseEvaluationInput.from(tc, result), problemInfo, context
                );

                // 3. SSE로 전송
                 emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation));
            } catch (Exception e) {
                // 예외 발생 시 최초 한 번만 처리
                if (context.notified().compareAndSet(false, true)) {
                    emitter.completeWithError(e);
                    emitterStore.remove(msg.emitterKey());
                    exceptionNotificationHelper(e);
                }
            } finally {
                context.countDown();
            }
        }, judgeTestcaseExecutor);
    }</code></pre>
<h4 id="이런-장점을-얻을-수-있었다">이런 장점을 얻을 수 있었다.</h4>
<ul>
<li>테스트케이스별 채점이 완전 병렬화 → <strong>전체 채점 속도 개선</strong></li>
<li>각 채점 결과를 <strong>개별적으로 SSE 스트리밍</strong> 응답 가능</li>
<li><code>CountDownLatch</code>로 <strong>모든 테스트케이스 완료 여부 체크 가능</strong></li>
</ul>
<blockquote>
<p>이전 구조에서는 5개 테스트케이스가 순차적으로 채점되며 지연이 있었다면 이제는 <strong>동시에 컴파일 서버에 요청이 들어가고,</strong> 먼저 컴파일 결과가 반환되는 것부터 채점이 진행되어 사용자가 <strong>채점 사이사이 기다리는 시간이 줄어들었다.</strong></p>
</blockquote>
<br>

<h3 id="14-여전히-남은-병목-현상">1.4. 여전히 남은 병목 현상</h3>
<p>앞서 개요에서 언급했듯이, 짧은 시간 내에 <strong>4명 동시 제출</strong>까지 안정적으로 처리되는 구조를 만들었다. 하지만 이 수치만 보고 &quot;대단한 개선&quot;이라고 보기는 어렵다.</p>
<p>실제로도 <strong>병렬 + 병렬</strong> 구조를 사용하면서 병목이 완전히 해결되리라 기대했지만, 드라마틱한 변화는 없었다.</p>
<p>그렇다면, <strong>무엇이 병목을 발생시키고 있을까?</strong></p>
<h4 id="병목의-원인은-바로-judge0-컴파일-서버의-물리-리소스였다">병목의 원인은 바로 Judge0 컴파일 서버의 물리 리소스였다.</h4>
<p>Judge0는 <strong>비동기 요청을 받으면 내부 Redis 큐에 요청을 쌓고, worker가 이를 처리하는 구조</strong>를 가지고 있다.</p>
<p>이를 실제로 확인하기 위해 <code>docker stats</code> 명령어로 Judge0 컨테이너의 리소스 사용률을 모니터링했다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/94ba1609-b4e8-4d7b-b445-e6d06d5545ce/image.png" alt=""></p>
<p>당시 테스트는 아래와 같은 상황에서 진행되었다.</p>
<ul>
<li>클라이언트 1, 2, 3이 각각 <strong>7개의 테스트케이스가 있는 문제</strong>를 제출 → 총 21개의 컴파일 요청</li>
</ul>
<p>이때 관찰된 현상은,</p>
<ul>
<li><code>judge0_server</code>는 안정적이고 낮은 CPU 사용률을 유지</li>
<li><code>judge0_worker</code>는 CPU 사용률이 <strong>190~200%</strong>까지 치솟음
→ <strong>Redis 큐가 정상적으로 작동하며 worker가 병렬로 컴파일 요청을 처리 중</strong>이라는 뜻</li>
</ul>
<p>여기서 중요한 사실 하나가 있다.</p>
<blockquote>
<p>Judge0는 <strong>서버의 코어 수에 따라 worker 수가 결정</strong>되며, 병렬 처리 성능 역시 코어 수에 직접적으로 비례한다.</p>
</blockquote>
<p>예를 들어 같은 21개의 요청을 처리할 때,</p>
<ul>
<li>2코어    2 worker → worker당 <strong>10.5회</strong> 수행</li>
<li>4코어    4 worker → worker당 <strong>5.25회</strong> 수행</li>
<li>8코어    8 worker → worker당 <strong>2.625회</strong> 수행</li>
</ul>
<p><strong>즉, 병렬 처리 로직을 아무리 정교하게 짜도, 
기저에 깔린 물리적 리소스가 병목이면 성능은 그 이상 올라가지 않는다.</strong></p>
<p>현재 내가 사용하는 컴파일 서버는 <strong>2코어</strong>다.</p>
<p>이 상황에서 병렬 병렬 구조를 아무리 최적화하더라도, worker 자체가 감당해야 할 작업량이 많아 <strong>CPU 한계에 부딪힐 수밖에 없었다.</strong></p>
<p>성능은 분명히 조금 향상되었지만, <strong>채점 시스템의 로직 효율성을 온전히 평가하려면 최소한의 서버 사양이 먼저 받쳐줘야 한다는 점을 실감했다.</strong></p>
<hr>
<h2 id="2-트러블슈팅-시큐리티-컨텍스트-전파-이슈">2. 트러블슈팅: 시큐리티 컨텍스트 전파 이슈</h2>
<p>모든 채점 결과가 정상적으로 클라이언트에게 전달됐음에도 불구하고,
애플리케이션 로그에는 아래와 같은 예외가 반복적으로 남았다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/05b24eee-19a1-44e2-ab29-6c06d529df24/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/e528c3fd-08df-4299-b11d-74cf23d323e1/image.png" alt=""></p>
<ul>
<li><code>AccessDeniedException</code> 3건</li>
<li><code>AlreadyCommittedException</code> 1건</li>
</ul>
<p>이 로그들은 채점 결과 자체에는 전혀 영향을 주지 않았지만,
<strong>스택트레이스가 너무 길게 출력되면서 로그를 심각하게 오염시키는 문제가 있었다.</strong></p>
<p>문제를 추적하기 위해 관련 키워드로 로그 분석과 검색을 반복한 끝에, Spring Security는 인증 정보를 <code>SecurityContext</code>라는 <strong>ThreadLocal 기반 객체에 저장</strong>하고 있으며, 
이 <code>SecurityContext</code>는 기본적으로 <strong>비동기 스레드에 자동 전파되지 않는다는 점</strong>을 확인할 수 있었다.</p>
<p>결국 <strong>SecurityContext가 존재하지 않아서 생기는 문제</strong>였던 것이다.</p>
<br>

<h3 id="21-첫-번째-접근-delegatingsecuritycontextasynctaskexecutor">2.1. 첫 번째 접근: DelegatingSecurityContextAsyncTaskExecutor</h3>
<p>비동기 메서드에서 <code>SecurityContext</code>가 전파되지 않는 문제를 해결하기 위해 Spring에서 공식적으로 제공하는 방식 중 하나가 <code>DelegatingSecurityContextAsyncTaskExecutor</code>이다.</p>
<p>공식 문서와 여러 블로그에서,</p>
<blockquote>
<p><strong>&quot;<code>@Async</code>로 실행되는 메서드에 컨텍스트를 자동으로 전파하려면 이 Executor로 래핑해야 한다.&quot;</strong></p>
</blockquote>
<p>라고 설명하고 있어 아래와 같이 적용해봤다.</p>
<pre><code class="language-java">@Bean(name = &quot;judgeSubmissionExecutor&quot;)
public Executor judgeSubmissionExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix(&quot;submission-&quot;);
    executor.initialize();
    return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}</code></pre>
<p>이제 이 Executor를 사용하는 비동기 메서드에서 <code>SecurityContextHolder.getContext()</code>를 호출하면 인증 정보가 담겨 있어야 정상이다.</p>
<p>하지만 실제 테스트 결과, <code>@Async(&quot;judgeSubmissionExecutor&quot;)</code>가 붙은 메서드 내부에서
<code>SecurityContextHolder.getContext().getAuthentication()</code>은 여전히 <code>null</code>로 출력되었다.</p>
<p>즉, 이 방식은 실패했다.</p>
<br>

<h3 id="22-두-번째-접근-securitycontext-수동-전파">2.2. 두 번째 접근: SecurityContext 수동 전파</h3>
<p>자, 다시 구조를 살펴보자.
처음에는 컨트롤러에서 다음과 같은 메서드가 호출된다.</p>
<pre><code class="language-java"> public SseEmitter enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) {
    // 큐에 담는 부분
    return emitter;
}</code></pre>
<p>여기까지는 일반적인 Spring MVC의 로컬 스레드 흐름이다.
문제는 그 이후 <strong>Redis Stream을 거치면서 흐름이 완전히 바뀐다는 점</strong>이다.</p>
<h4 id="전체-호출-흐름">전체 호출 흐름</h4>
<ol>
<li><code>enqueueCodeSubmission()</code> → 요청 수신 <strong>(로컬 스레드)</strong></li>
<li>Redis Stream에 메시지 발행 →  <strong>(외부 시스템)</strong></li>
<li>컨슈머 리스너에서 메시지 수신 및 비동기 메서드 실행 → <strong>(별도 스레드 풀)</strong></li>
<li><code>submitCodeStream()</code> → <strong>(별도 스레드 풀)</strong></li>
</ol>
<p>이제야 살짝 감이 잡히기 시작한다.</p>
<blockquote>
<p><strong><a href="#21-%EC%B2%AB-%EB%B2%88%EC%A7%B8-%EC%A0%91%EA%B7%BC-delegatingsecuritycontextasynctaskexecutor-%EC%82%AC%EC%9A%A9">Spring에서 제공한 비동기 메서드 컨텍스트 전파</a>는 기본적으로 &quot;로컬 스레드 → 비동기 스레드&quot;</strong> 간 흐름을 기준으로 동작한다. 
하지만 여기는 <strong>외부 시스템(Redis)</strong> 을 경유한 완전히 분리된 실행 경로이기 때문에, <code>DelegatingSecurityContextAsyncTaskExecutor</code>를 사용해도 <strong>적용이 되지 않는 것이었다.</strong></p>
</blockquote>
<p>그래서 다음과 같은 대안을 생각했다.
<strong>&quot;차라리 인증된 유저 정보를 메시지에 직접 담아서, 리스너 쪽에서 다시 Context를 구성하면 어떨까?&quot;</strong></p>
<p>실제로 <code>userId</code>, <code>username</code>, <code>role</code>, <code>tier</code> 등을 메시지에 포함시킨 뒤, 리스너에서 <code>AuthUser</code>를 다시 생성하고, 이를 기반으로 <code>SecurityContextHolder.setContext(...)</code> 를 수동으로 설정했다.</p>
<p>예상대로 <code>SecurityContext</code> 자체는 <strong>정상적으로 구성되었다.</strong>
하지만 여전히 동일한 예외가 발생해서, 
<strong>&quot;수동으로 만든 <code>Authentication</code> 객체가 이전과 다르니까, 내부적으로 <code>equals()</code> 비교에 실패해서 생기는 문제일 수도 있겠다.&quot;</strong>
라고 생각했다.</p>
<p>그런데 다시 생각해보니, <strong>지금 발생하고 있는 예외는 &quot;사용자가 일치하지 않는다&quot;는 예외가 아니라, &quot;인증 정보가 없다&quot;는 예외였다.</strong> 그렇다면 유저 정보가 일치하지 않더라도, <strong>Context가 존재하기만 하면 에러는 발생하지 않아야 한다.</strong></p>
<p>왜 Context를 분명히 구성했는데도, Spring Security는 그것을 &quot;없다&quot;라고 판단하는 것일까?
원인을 더 깊이 파고들기 시작했다.</p>
<br>

<h3 id="23-sse-스트리밍과-컨텍스트-충돌-원인-분석">2.3. SSE 스트리밍과 컨텍스트 충돌 원인 분석</h3>
<p>앞선 <strong><a href="#22-%EB%91%90-%EB%B2%88%EC%A7%B8-%EC%A0%91%EA%B7%BC-securitycontext-%EC%88%98%EB%8F%99-%EC%A0%84%ED%8C%8C">2.2</a></strong>에서의 코드를 참고 해보면, <code>SseEmitter</code> 객체를 컨트롤러에서 즉시 반환하는 구조를 사용하고 있었다. </p>
<p><code>SseEmitter</code>는 HTTP 연결을 유지하면서 서버가 클라이언트에게 <strong>데이터를 스트리밍으로 보내기 위해 사용하는 객체</strong>다. 컨트롤러에서 <code>SseEmitter</code>를 반환하면, 해당 HTTP 요청은 완료되지 않고 오랫동안 열려 있는 상태로 유지된다.</p>
<p>아래의 Spring Security의 핵심 필터 중 하나를 보면,
<img src="https://velog.velcdn.com/images/harvard--/post/b02d69ca-b426-473f-b758-f781042516bf/image.png" alt=""></p>
<p>문제는 바로 <code>finally</code> 블록의 <code>this.securityContextHolderStrategy.clearContext()</code> 호출에 있었다.</p>
<p>이 구문은 <strong>HTTP 요청이 종료되는 시점에 SecurityContext를 초기화(제거)</strong> 하는 동작이며, 일반적인 요청-응답 처리에서는 문제가 없다.</p>
<p>그러나 현재 구조는 <code>SseEmitter</code>를 반환하면서 HTTP 연결을 끊지 않고 유지하는 구조인데, 컨트롤러에서 <code>SseEmitter</code>를 반환하는 순간에도 Spring Security는 해당 요청이 종료되었다고 판단하고 <strong>SecurityContext를 비워버린다.</strong></p>
<p>결국 이 구조에서는 <strong>요청 스레드(Request Thread)가 종료됨과 동시에 SecurityContext도 함께 사라지게 되는 것</strong>이다.</p>
<p>이전 <strong><a href="#22-%EB%91%90-%EB%B2%88%EC%A7%B8-%EC%A0%91%EA%B7%BC-securitycontext-%EC%88%98%EB%8F%99-%EC%A0%84%ED%8C%8C">2.2</a></strong>의 비동기 메서드 안에서 수동으로 SecurityContext를 구성했지만, 문제는 <strong>서블릿 요청 스레드가 이미 종료되었기 때문에</strong> Spring Security 관점에서는 <strong>&quot;이미 SecurityContext는 클리어된 상태&quot;</strong>가 된다.</p>
<p>Spring Security의 내부적인 설계 때문에 수동으로 <code>SecurityContextHolder.setContext(...)</code>를 해도, <strong>SseEmitter가 연결된 요청 스레드와 컨텍스트를 연결할 방법이 사라져버린 것</strong>이 근본적인 원인이었다.</p>
<br>

<h3 id="24-세-번째-접근-인증-url-범위-설정">2.4. 세 번째 접근: 인증 URL 범위 설정</h3>
<p>다시 한 번 정리 해보자면,
<code>SseEmitter</code>는 클라이언트와 서버 간의 연결을 장시간 유지해야 하기 때문에, 일반적인 HTTP 요청과는 달리 <strong>컨트롤러가 종료되어도 응답 흐름이 살아 있는 구조</strong>다.</p>
<p>그러나 Spring Security는 <strong>컨트롤러가 SseEmitter를 반환한 순간, 요청 처리가 끝났다고 판단</strong>하여 필터 체인의 <code>clearContext()</code>를 통해 <code>SecurityContext</code>를 비워버린다.</p>
<p>그 결과, 이후 실행되는 모든 비동기 로직은 이미 비워진 <code>SecurityContext</code> 상태에서 실행되며, 이때 <code>SseEmitter.send()</code>나 <code>complete()</code> 등이 서블릿 응답을 한 번 더 커밋하려고 하면, Spring Security는 이를 인증되지 않은 요청으로 간주하여 <code>AccessDeniedException</code>을 발생시킨다.</p>
<p>이미 응답이 커밋된 상태에서의 응답 시도는 <code>AlreadyCommittedException</code>까지 유발한다.</p>
<p>이 문제는 결국 <strong>&quot;비동기 흐름에서 Security를 무시할 수 있게 설정하는 방식&quot;</strong>으로 해결 가능했다. Spring Security의 설정에 다음 구문을 추가했다.</p>
<pre><code class="language-java">.requestMatchers(new DispatcherTypeRequestMatcher(DispatcherType.ASYNC)).permitAll()</code></pre>
<p>이 설정은 <code>DispatcherType</code>이 <code>ASYNC</code>인 요청 <strong>(즉, 비동기 서블릿 흐름으로 전환된 요청)을 인증 없이 모두 허용하겠다는 의미</strong>다. 이를 통해 <code>SecurityContext</code>가 초기화되었더라도 추가적인 인증 예외가 발생하지 않도록 방지할 수 있다.</p>
<p>즉, <strong>비동기 흐름 안에서는 인증을 검사하지 않도록 우회함</strong>으로써, 이 문제를 해결할 수 있었다.</p>
<p>이번 이슈를 통해 단순한 컨텍스트 전파 문제로 보였던 오류가, 사실은 <strong>Spring Security와 SSE 구조 간의 근본적인 설계 충돌</strong>에서 비롯되었음을 알 수 있었다.</p>
<p>이는 단순한 설정의 문제가 아닌, <strong>&quot;SSE를 사용하는 설계가 인증 보안과 충돌할 수 있음&quot;</strong>을 체감하게 한 사례였다.</p>
<blockquote>
<p>앞선 <strong><a href="#2-sse%EC%99%80-redis-stream-%EA%B5%AC%EC%A1%B0%EC%9D%98-%EC%B6%A9%EB%8F%8C">Redis Stream</a></strong>에서의 구조적 한계에 이어, 이번 SSE와 Spring Security의 충돌 문제까지 경험하며 확신할 수 있었다.
SSE는 본질적으로 <strong>단일 인스턴스 환경에 적합한 구조</strong>이며, 확장성과 인증 흐름 보장을 동시에 요구하는 분산 아키텍처로의 확장을 고려하면 <strong>대체가 불가피하다는 것</strong>이다.</p>
</blockquote>
<p>아직 완전한 구조 전환을 이루진 않았지만, <strong>WebSocket</strong> 또는 <strong>Polling</strong> 기반으로의 <strong>전환 필요성은 분명</strong>해졌으며, <strong>이후 고도화 작업의 핵심 방향으로 고려</strong>되고 있다.</p>
<hr>
<h2 id="3-ai-코드-리뷰-프롬프트-정형화">3. AI 코드 리뷰 프롬프트 정형화</h2>
<p>MVP 단계에서 급하게 만들어둔 코드 리뷰 기능은 실행할 때마다 <strong>출력 포맷도 들쭉날쭉하고, 일관성 없는 답변</strong>이 나오는 문제가 있었다. </p>
<p>가장 큰 이유는 <strong>프롬프트 구조가 정형화되어 있지 않았고,</strong> 모델이 스스로 정답 여부를 판단하려 들기도 했기 때문이다.</p>
<p>이번 고도화 작업에서는 다음 세 가지 원칙을 중심으로 프롬프트를 정비했다.</p>
<ul>
<li><strong>절대 코드의 일부분을 출력하지 말 것</strong></li>
<li><strong>정오답 여부는 모델이 판단하지 않도록 할 것</strong> (→ 채점 결과 기반)</li>
<li><strong>리뷰는 반드시 지정한 템플릿에 맞게 출력할 것</strong></li>
</ul>
<br>

<h3 id="31-프롬프트-템플릿-구조-설계">3.1. 프롬프트 템플릿 구조 설계</h3>
<p>우선 <strong>시스템 프롬프트</strong>와 <strong>유저 프롬프트</strong>를 명확히 분리했다.</p>
<ul>
<li>시스템 프롬프트는 AI에게 리뷰 기준, 출력 형식 등을 지정하는 설정 역할</li>
<li>유저 프롬프트는 실제 코드, 문제 설명, 언어 정보 등을 포함하는 질의</li>
</ul>
<h4 id="유저-프롬프트">유저 프롬프트</h4>
<pre><code class="language-java">private String buildUserPrompt(ReviewPayload request) {
    return &quot;문제: &quot;
        + request.problemDescription()
        + &quot;\n&quot;
        + &quot;언어: &quot;
        + request.languageName()
        + &quot;\n&quot;
        + &quot;정답 여부: &quot;
        + (request.isCorrect() ? &quot;정답&quot; : &quot;오답&quot;)
        + &quot;\n&quot;
        + &quot;```&quot;
        + request.languageName().toLowerCase()
        + &quot;\n&quot;
        + request.sourceCode() + &quot;```&quot;;
}</code></pre>
<h4 id="시스템-프롬프트">시스템 프롬프트</h4>
<pre><code class="language-java">private String buildSystemPrompt(boolean isCorrect) {

    String body;
    if (isCorrect) {
        body = &quot;&quot;&quot;
            &lt;정답일 경우&gt;
            - 시간 복잡도: Big-O 표기법으로만 답하세요. **단, N과 M을 같다고 가정하고 n으로 표기하세요.**
            - 코드에 포함된 중첩 루프(depth)에 따라 O(N^k) 형태로 정확히 표기해주세요.
            **for 루프뿐만 아니라 while 루프도 모두 중첩(depth)에 포함**하여, 코드에 실제로 있는 루프 개수만큼 exponent를 세십시오.
            예) for-for-for ⇒ O(n³), for-for-while ⇒ O(n³), for-for-for-for-while ⇒ O(n⁵)

            - 코드 총평:
            각 문장은 한 탭(\t) 들여쓰기 + &#39;- &#39; 로 시작.
            문장 끝에만 마침표를 붙이세요.
            - 조금 더 개선할 수 있는 방안:
            각 문장은 한 탭(\t) 들여쓰기 + &#39;- &#39; 로 시작.
            문장 끝에만 마침표를 붙이세요.
            &quot;&quot;&quot;.stripIndent();
    } else {
        body = &quot;&quot;&quot;
            &lt;오답일 경우&gt;
            코드 총평:
            각 문장은 한 탭(\t) 들여쓰기 + &#39;- &#39; 로 시작.
            문장 끝에만 마침표를 붙이세요.
            - 공부하면 좋은 키워드:
            1. 첫 번째 키워드
            2. 두 번째 키워드
            3. 세 번째 키워드
            … 필요한 만큼 번호를 늘려주세요.
            &quot;&quot;&quot;.stripIndent();
    }

    return PREFIX + &quot;\n&quot; + body + &quot;\n&quot; + SUFFIX;
}</code></pre>
<p>이외에도 추가적으로 붙는 시스템 지시 프롬프트는 있지만, 내용이 길어져 생략한다.</p>
<blockquote>
<p>참고로, 처음에는 <code>StringBuilder</code>를 써야 하나 고민했지만 
Java 버전이 올라가면서 문자열 <code>+</code> 연결은 내부적으로 <code>StringBuilder</code>로 최적화되고, 
<strong>텍스트 블록(<code>&quot;&quot;&quot;</code>) 사용 시 불필요한 공백 문자도 제거되어 오히려 더 효율적</strong>이라는 점을 확인했다.</p>
</blockquote>
<br>

<h3 id="32-ai-응답-포맷-검증-및-재시도-처리">3.2. AI 응답 포맷 검증 및 재시도 처리</h3>
<p>AI 리뷰 응답은 항상 동일한 템플릿 구조로 출력되어야 한다.
하지만 AI는 때때로 엉뚱한 응답을 하기도 하고, 우리가 기대한 형식이 아닌 경우가 많았다.</p>
<p>이에 따라 <strong>프롬프트 응답이 명확한 기준을 만족하지 않을 경우</strong>, 최대 3회까지 재요청한 뒤,
끝내 실패할 경우 <strong>사용자에게는 오류 메시지를 전송하고 트랜잭션을 롤백</strong>하도록 처리했다.</p>
<blockquote>
<p>만약 <strong>AI 서버 자체가 응답을 보내지 못하는 장애 상황</strong>에서는, 아래 <strong><a href="#6-%EC%84%9C%EB%B2%84-%EC%9E%A5%EC%95%A0-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-%EB%B0%8F-%EB%8C%80%EC%9D%91-%EC%A0%84%EB%9E%B5">6. 서버 장애 예외 처리 및 대응 전략</a>에서 설명할</strong> <code>Retry</code> 정책이 함께 적용된다.</p>
</blockquote>
<h4 id="응답-포맷-검증-코드">응답 포맷 검증 코드</h4>
<pre><code class="language-java">@Component
class OpenAIResponseValidator {
    protected boolean isValidFormat(String content, boolean isCorrect) {
        if (content == null)
            return false;

        if (isCorrect) {
            return content.contains(&quot;시간 복잡도:&quot;) &amp;&amp;
                content.contains(&quot;코드 총평:&quot;) &amp;&amp;
                content.contains(&quot;조금 더 개선할 수 있는 방안:&quot;);
        }

        return content.contains(&quot;코드 총평:&quot;) &amp;&amp;
            content.contains(&quot;공부하면 좋은 키워드:&quot;);
    }
}</code></pre>
<h4 id="전체-흐름">전체 흐름</h4>
<pre><code class="language-java">@Override
public ReviewResult requestReview(ReviewPayload reviewPayload) {
    Map&lt;String, Object&gt; requestBody = openAiMessageBuilder.buildRequestBody(reviewPayload);

    String content;
    int maxAttempts = 3;

    for (int attempt = 1; attempt &lt;= maxAttempts; attempt++) {
        try {
            content = callChatApi(requestBody);
        } catch (CodeReviewException e) {
            log.error(&quot;OpenAI API 호출 실패: {}, {}&quot;, e.getHttpStatus(), e.getMessage());
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            return new ReviewResult(openAiMessageBuilder.buildErrorMessage());
        }

        if (openAiResponseValidator.isValidFormat(content, reviewPayload.isCorrect())) {
            return new ReviewResult(content);
        }

        log.warn(&quot;[{}/{}][isCorrect={}] 포맷 검증 실패:\n{}&quot;, attempt, maxAttempts, reviewPayload.isCorrect(), content);
    }

    // 최종 실패
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    return new ReviewResult(openAiMessageBuilder.buildErrorMessage());
}</code></pre>
<h4 id="실패-시-반환-메시지">실패 시 반환 메시지</h4>
<pre><code class="language-java">protected String buildErrorMessage() {
    return &quot;현재 리뷰 생성에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.&quot;;
}</code></pre>
<blockquote>
<p>이 과정을 통해 모델이 임의로 형식을 깨뜨리거나 잘못된 응답을 주더라도, 사용자에게는 안정적인 출력만 전달되도록 방어 로직을 갖추었다.</p>
</blockquote>
<p>아래는 실제 로그에서 확인된 AI 응답의 포맷 검증 실패 예시이다. 
<img src="https://velog.velcdn.com/images/harvard--/post/58090b56-3996-42e0-b97e-7b7b8cae9283/image.png" alt=""></p>
<p>사용자는 아래와 같이 통일된 템플릿 구조로 AI 리뷰 결과를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/e265d494-f446-48b2-a7b4-5a51f9546edf/image.png" alt=""></p>
<br>

<h3 id="33-setrollbackonly를-활용한-트랜잭션-제어-전략">3.3. <code>setRollbackOnly()</code>를 활용한 트랜잭션 제어 전략</h3>
<p>바로 다음 장에서 얘기할 토큰 부여 시나리오에 관련한 얘기인데, 코드가 나온 김에 살짝 얘기하고 넘어가려고 한다.</p>
<p>OpenAI API 호출이 실패하거나, AI 응답 포맷이 유효하지 않을 경우에는 전체 트랜잭션을 롤백시키도록 <code>setRollbackOnly()</code>를 사용했다.</p>
<p>이 트랜잭션은 사용자 리뷰 토큰 차감과 연관되기 때문에, AI 응답이 실패했음에도 불구하고 <strong>토큰이 차감되는 부작용을 막는 것이 목적이다.</strong></p>
<pre><code class="language-java">@Transactional
@CodeReviewLock(prefix = &quot;review&quot;)
public CodeReviewResponse getCodeReview(Long problemId, CodeReviewRequest request, AuthUser authUser) {
    User user = userDomainService.getUserById(authUser.getId());
    userDomainService.decreaseReviewToken(user);

    Problem problem = problemDomainService.getProblem(problemId);
    Language language = languageDomainService.getLanguage(request.languageId());

    ReviewResult reviewResult = reviewClient.requestReview(ReviewPayload.of(problem, language, request));

    // userDomainService.decreaseReviewToken(user); &lt; 여기다 하면 안 되나?
    return new CodeReviewResponse(reviewResult.reviewContent());
}</code></pre>
<p><strong>&quot;토큰 차감을 아래에 두면 <code>setRollbackOnly()</code> 필요가 없지 않나&quot;</strong>라고 생각할 수 있다.</p>
<p>하지만 이 구조는 사실상 <strong>&quot;OpenAI 서버가 장애일 확률&quot;과 &quot;토큰이 부족할 확률&quot; 중 어떤 쪽이 높은가&quot;</strong>를 기준으로 생각한 것이다.
유저의 리뷰 토큰 부족은 자주 발생하지만, OpenAI 서버 장애는 드물다.</p>
<p>따라서 토큰 차감을 <strong>가장 먼저 실행</strong>하고, 그 이후 단계에서 문제가 발생하면 <code>setRollbackOnly()</code>를 호출해 전체 트랜잭션을 명시적으로 롤백하도록 설계했다.</p>
<p>이 방식이 <strong>실제 운영 환경에서의 리소스 낭비를 최소화</strong>하는 데 더 유리하다고 판단했다.</p>
<hr>
<h2 id="4-리뷰-요청-토큰-부여-시나리오">4. 리뷰 요청 토큰 부여 시나리오</h2>
<p>OpenAI API는 사용량 기반으로 비용이 청구되는 구조다.
AI 코드 리뷰는 사용자 입장에선 클릭 한 번으로 편하게 실행할 수 있지만, 요청이 많아질수록 서비스 운영 비용이 기하급수적으로 증가한다.</p>
<p>현재는 별도의 수익 구조 없이 MVP 기능을 제공 중이기 때문에, <strong>무제한 요청을 허용하기엔 감당할 수 없는 수준의 비용 문제</strong>가 발생할 수 있다.</p>
<p>이에 따라 모든 사용자에게 <strong>리뷰 요청 토큰 개수를 제한하는 정책</strong>을 도입했다.</p>
<br>

<h3 id="41-주간-토큰-지급-조건">4.1. 주간 토큰 지급 조건</h3>
<p>리뷰 토큰은 <strong>매주 월요일 00시</strong>에 자동으로 지급된다.
다만, 무조건 동일한 수량을 지급하는 것이 아니라, <strong>사용자의 지난 일주일간 학습 활동을 기준</strong>으로 토큰 수량이 달라진다.</p>
<ul>
<li>일주일 내내, 즉 월요일부터 일요일까지 <strong>매일 최소 1문제 이상 문제 풀이 기록이 있는 사용자</strong>는 다음 주에 리뷰 토큰 <strong>40개</strong>를 받는다.</li>
<li>반면, <strong>하루라도 문제 풀이 기록이 없는 날이 있었다면</strong> 다음 주에는 리뷰 토큰 <strong>20개</strong>만 지급된다.</li>
</ul>
<p>이렇게 설계한 이유는 단순한 사용 제한을 넘어서, <strong>꾸준한 학습 습관을 형성할 수 있도록 유도</strong>하기 위함이다. 사용자는 매일 최소 한 문제만 풀어도 더 많은 리뷰 혜택을 받을 수 있기 때문에, <strong>자연스럽게 학습 동기를 유도할 수 있는 구조</strong>가 될 수 있다.</p>
<br>

<h3 id="42-리뷰-토큰-지급을-위한-스케줄러-설계">4.2. 리뷰 토큰 지급을 위한 스케줄러 설계</h3>
<p>리뷰 토큰은 <strong>매주 월요일 00시</strong>, 전 주의 학습 활동을 기반으로 일괄 지급된다.
이 기능은 <code>WeeklyTokenResetScheduler</code> 클래스에 구현된 스케줄러에서 실행된다.</p>
<pre><code class="language-java">CronTrigger trigger = new CronTrigger(
    &quot;0 0 0 * * MON&quot;,
    TimeZone.getTimeZone(&quot;Asia/Seoul&quot;)
);</code></pre>
<p>이 트리거는 서울 시간대 기준으로 <strong>매주 월요일 자정</strong>에 동작하며,
그 주 <strong>월요일~일요일(7일간)의 기록을 기준</strong>으로 토큰 지급 여부를 결정한다.</p>
<p>예를 들어, 6월 17일(월)에 실행되는 스케줄러는 6월 10일(월)부터 6월 16일(일)까지의 기록을 분석하여 토큰을 지급한다.</p>
<p>이때 고려되는 조건은 다음과 같다.</p>
<ul>
<li>오직 <code>testcase_passed_count == testcase_total_count</code>인 제출만 인정된다.</li>
<li>즉, 정답으로 채점된 문제만 학습 기록으로 간주한다.</li>
</ul>
<p>스케줄러 내부에서는 <code>UserService.resetAllUsersTokensWeekly()</code>를 호출해 토큰 로직을 수행하고, 기간 계산은 다음과 같이 안전하게 처리한다.</p>
<pre><code class="language-java">LocalDate lastMonday = LocalDate.now(ZoneId.of(&quot;Asia/Seoul&quot;))
    .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
LocalDateTime startDateTime = lastMonday.atStartOfDay();
LocalDateTime endDateTime = lastMonday.plusDays(7).atStartOfDay();</code></pre>
<br>

<h3 id="43-querydsl--bulk-update로-db-io-최소화">4.3. QueryDSL + Bulk Update로 DB I/O 최소화</h3>
<p>유저 수가 많아질수록 매 사용자마다 DB를 개별적으로 읽고 쓰는 구조는 <strong>I/O 병목의 가장 큰 원인</strong>이 된다. 단순히 수천 건의 업데이트라도 트랜잭션 내에서 순차적으로 처리되면, 데이터베이스 입장에서는 <strong>수천 번의 I/O 요청을 처리해야 하는 상황</strong>이 된다.</p>
<p>따라서 이번 구조에서는 애초에 필요한 데이터를 <strong>한 번에 계산</strong>하고, 단 <strong>2번의 Bulk Update만으로 처리하는 전략</strong>을 선택했다.</p>
<h4 id="querydsl">QueryDSL</h4>
<p>주간 토큰 지급 로직에서는 유저가 일주일 동안 하루도 빠짐없이 문제를 풀었는지를 판단하기 위해, <code>Submission</code> 테이블에서 <strong>정답 제출 기록</strong>을 기준으로 분석한다.</p>
<pre><code class="language-java">QSubmission s = QSubmission.submission;

var dateOnly = Expressions.dateTemplate(
    java.sql.Date.class, &quot;function(&#39;date&#39;,{0})&quot;, s.createdAt
);
var cntExpr = dateOnly.countDistinct();</code></pre>
<p>위와 같이 <code>submission.createdAt</code> 컬럼에 <code>date()</code> 함수를 적용하여 <strong>시간 정보를 제거한 날짜 기준</strong>으로 처리하고, <code>countDistinct()</code>를 사용해 유저별로 정답 제출이 있었던 <strong>날짜의 수를 계산</strong>한다.</p>
<pre><code class="language-java">return jpaQueryFactory
            .select(constructor(
                WeeklySolveCount.class,
                s.user.id,
                cntExpr
            ))
            .from(s)
            .where(
                s.createdAt.goe(startDateTime),
                s.createdAt.lt(endDateTime),
                s.testCasePassedCount.eq(s.testCaseTotalCount)
            )
            .groupBy(s.user.id)
            .fetch();</code></pre>
<p>여기서 중요한 점은 단순한 제출 기록이 아닌, <strong>테스트케이스를 모두 통과한 정답 제출만을 기준으로 집계</strong>한다는 것이다. 오답 제출만 있던 날은 인정되지 않으며, <strong>정답 제출이 한 건이라도 있었던 날짜만 유효 날짜</strong>로 간주된다.</p>
<p>이렇게 해서, <strong>단 한 번의 쿼리로 각 유저가 일주일 동안 며칠간 정답을 제출했는지를 계산</strong>할 수 있게 된다.</p>
<h4 id="bulk-update">Bulk Update</h4>
<p>이전 단계에서 QueryDSL을 통해 <strong>각 사용자별로 정답 제출이 있었던 날짜 수</strong>를 구했다면, 이제 이 정보를 활용해 <strong>리뷰 토큰 지급 대상자를 분류하고 일괄적으로 DB 업데이트</strong>를 수행해야 한다.</p>
<p>우선 <code>WeeklySolveCount</code> 리스트를 기반으로, 사용자들을 아래와 같이 두 그룹으로 나눈다.</p>
<ul>
<li>일주일 동안 매일 정답을 제출한 사용자</li>
<li>하루라도 빠진 사용자</li>
</ul>
<pre><code class="language-java">public static UsersByWeek from(List&lt;WeeklySolveCount&gt; counts, long weekLength) {
    List&lt;Long&gt; fullWeek = counts.stream()
        .filter(c -&gt; c.solveDayCount() == weekLength)
        .map(WeeklySolveCount::userId)
        .toList();
    List&lt;Long&gt; partialWeek = counts.stream()
        .filter(c -&gt; c.solveDayCount() != weekLength)
        .map(WeeklySolveCount::userId)
        .toList();
    return new UsersByWeek(fullWeek, partialWeek);
}</code></pre>
<p>이렇게 분류된 사용자 ID 리스트를 가지고, 다음과 같이 두 번의 Bulk Update를 수행한다.</p>
<pre><code class="language-java">public void resetReviewTokensForUsers(UsersByWeek users) {
    userRepository.updateReviewTokens(users.fullWeek(), 40);
    userRepository.updateReviewTokens(users.partialWeek(), 20);
}</code></pre>
<p>이 방식은 전체 사용자의 토큰 초기화를 단 2개의 쿼리로 처리할 수 있기 때문에, <strong>데이터베이스 자원을 효율적으로 사용</strong>하면서도 <strong>안정적으로 주간 토큰 리셋 작업을 완료</strong>할 수 있다.</p>
<hr>
<h2 id="5-동시성-문제-따닥-이슈-처리">5. 동시성 문제, &quot;따닥&quot; 이슈 처리</h2>
<p>테스트 도중, 코드 제출 버튼을 빠르게 두 번 클릭했을 때 동일한 코드가 두 번 제출되는 현상이 발생했다. 소위 <strong>&quot;따닥 이슈&quot;</strong>라고 불리는 현상이다.</p>
<p>이는 실제 사용자와 서버에 다음과 같은 문제를 일으킨다.</p>
<ul>
<li>동일한 코드가 중복으로 채점되며 서버 리소스를 <strong>이중 소비</strong></li>
<li>리뷰 토큰이 <strong>두 번 소모</strong>되거나, 시스템에 불필요한 중복 데이터가 저장</li>
<li>응답 지연 시 사용자가 다시 클릭할 수밖에 없는 UI 흐름과 맞물려 결과가 <strong>이중으로 수신</strong></li>
</ul>
<p>이런 문제를 해결하기 위해 서버 측에서 <strong>간단한 락 처리</strong>를 도입했고, 이후에는 AOP로 추상화하여 <strong>동기 메서드에는 AOP를 사용한 락을 적용</strong>할 수 있는 구조로 개선했다.</p>
<br>

<h3 id="51-redis-기반-간단-락-구현">5.1. Redis 기반 간단 락 구현</h3>
<pre><code class="language-java">Component
@RequiredArgsConstructor
public class RedisLockManager implements LockManager {

    private final StringRedisTemplate redisTemplate;

    private static final String LOCK_KEY_FORMAT = &quot;%s-lock:user:%d:problem:%d&quot;;
    private static final Duration LOCK_DURATION = Duration.ofMinutes(5);

    @Override
    public boolean tryLock(String prefix, Long userId, Long problemId) {
        String key = getKey(prefix, userId, problemId);
        Boolean success = redisTemplate.opsForValue().setIfAbsent(key, &quot;LOCKED&quot;, LOCK_DURATION);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void releaseLock(String prefix, Long userId, Long problemId) {
        redisTemplate.delete(getKey(prefix, userId, problemId));
    }

    private String getKey(String prefix, Long userId, Long problemId) {
        return LOCK_KEY_FORMAT.formatted(prefix, userId, problemId);
    }
}</code></pre>
<p>해당 구조는 Redis의 <code>SETNX (set if not exists)</code> 명령어를 활용하여, <strong>같은 유저가 동일한 문제에 대해 중복 요청을 보내는 경우 첫 요청만 처리</strong>되도록 제어하는 방식이다.</p>
<p>Redisson이나 Redlock 같은 복잡한 분산락 프레임워크를 사용하지 않은 이유는 다음과 같다.</p>
<ul>
<li><p><strong>락의 정합성이 완벽하지 않아도 되는 상황이다.</strong></p>
<p>채점 요청이나 리뷰 요청이 중복되더라도 시스템 전체에 치명적인 부작용은 없다.</p>
</li>
<li><p><strong>비교적 가벼운 로직이며, 락 점유 시간이 매우 짧다.</strong></p>
<p>별도의 Watchdog, LeaseTime 등을 관리할 필요 없이 단순 TTL 기반 락으로 충분하다.</p>
</li>
<li><p><strong>Redisson은 스레드 풀을 다르게 설정한 환경에서는 락이 전파되지 않는 이슈</strong>가 있다.</p>
<p>실제로 <code>@Async</code>나 TaskExecutor로 동작하는 비동기 로직에서는 동일한 Redisson 객체를 공유하지 않아 락 충돌이 발생하지 않는 경우가 있다. 
따라서 <strong>단일 Redis 인스턴스를 기준으로 Key 기반으로만 처리</strong>하는 것이 더 명확하고 안정적인 선택이 될 수 있다고 판단했다.
<strong><a href="https://redisson.pro/docs/data-and-services/locks-and-synchronizers/">Redisson 공식 문서: Locks and synchronizers</a></strong></p>
</li>
</ul>
<p>또한 이 락 구조는 <code>prefix</code>를 받아 사용하므로, 채점 요청(<code>submission</code>)과 코드 리뷰 요청(<code>review</code>) 등 락의 사용 목적에 따라 Key를 구분할 수 있는 <strong>유연함</strong>도 갖추고 있다.</p>
<blockquote>
<p><strong><code>submission-lock:user:{userId}:problem:{problemId}</code>
<code>review-lock:user:{userId}:problem:{problemId}</code></strong></p>
</blockquote>
<p>이후 AOP를 통해 이 락을 공통 관심사로 추상화하면, 서비스 코드에서는 락을 직접 다루지 않고 선언적으로 적용할 수 있다.</p>
<br>

<h3 id="52-aop-추상화를-통한-락-적용">5.2. AOP 추상화를 통한 락 적용</h3>
<p>Redis를 기반으로 락을 직접 거는 구조는 간단하지만, 채점 요청 또는 코드 리뷰 요청마다
<code>tryLock()</code> <code>releaseLock()</code>을 일일이 작성하게 되면 <strong>비즈니스 로직과 락 처리 로직</strong>이 얽히게 된다.</p>
<p>그래서 AOP(Aspect-Oriented Programming) 를 활용해 &quot;<strong>선언적으로 락을 걸 수 있는 구조&quot;</strong> 를 설계했다. 락을 걸고 싶은 메서드에 어노테이션만 붙이면 내부에서 자동으로 락을 시도하고 해제하도록 추상화한 것이다.</p>
<pre><code class="language-java">@Aspect
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CodeReviewLockAspect {

    private final LockManager lockManager;

    @Around(&quot;@annotation(org.ezcode.codetest.application.submission.aop.CodeReviewLock)&quot;)
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        String prefix = signature.getMethod().getAnnotation(CodeReviewLock.class).prefix();
        Object[] args = joinPoint.getArgs();

        Long problemId = null;
        Long userId = null;

        for (Object arg : args) {
            if (arg instanceof Long) {
                problemId = (Long)arg;
            } else if (arg instanceof AuthUser) {
                userId = ((AuthUser)arg).getId();
            }
        }

        if (problemId == null || userId == null) {
            throw new CodeReviewException(CodeReviewExceptionCode.REQUIRED_ARGS_NOT_FOUND);
        }

        boolean locked = lockManager.tryLock(prefix, userId, problemId);
        if (!locked) {
            throw new CodeReviewException(CodeReviewExceptionCode.ALREADY_REVIEWING);
        }

        try {
            return joinPoint.proceed();
        } finally {
            lockManager.releaseLock(prefix, userId, problemId);
        }
    }
}</code></pre>
<ul>
<li><code>@CodeReviewLock</code>라는 커스텀 어노테이션만 붙이면 해당 메서드에 락이 자동으로 적용된다.</li>
<li><code>prefix</code>는 락의 용도 구분을 위해 사용되며, 락 키는 <code>&quot;review-lock:user:{userId}:problem:{problemId}&quot;</code> 형태로 구성된다.</li>
<li>메서드 인자 중에서 <code>AuthUser</code>와 <code>Long</code> 타입을 찾아 <code>userId</code>, <code>problemId</code>를 추출한다.</li>
</ul>
<h4 id="왜-채점-요청에는-aop를-적용하지-않았을까">왜 채점 요청에는 AOP를 적용하지 않았을까?</h4>
<p>채점 요청 로직은 Redis Stream 기반으로 큐에 메시지를 보내고, 수신 Consumer에서 비동기적으로 채점을 진행하는 구조를 가지고 있다.
즉, 요청을 보내는 메서드와 실제 로직을 실행하는 메서드가 <strong>스레드, 실행 시점 모두 다르기 때문에 AOP로 묶어서 처리하기 어려운 구조</strong>이다.
반면 코드 리뷰는 단일 스레드에서 동기적으로 진행되기 때문에 AOP 방식이 적합했다.</p>
<hr>
<h2 id="6-서버-장애-예외-처리-및-대응-전략">6. 서버 장애 예외 처리 및 대응 전략</h2>
<p>코드 리뷰나 채점 요청은 <strong>외부 서버(OpenAI, 채점 서버 등)와의 통신을 기반으로 작동</strong>하기 때문에, 네트워크 지연이나 서버 다운과 같은 외부 장애가 발생할 경우 사용자 경험에 영향을 줄 수 있다. </p>
<p>특히 채점 시스템은 Redis Stream 기반의 <strong>비동기 큐 처리 구조</strong>로 되어 있기 때문에, 예외 발생 시 운영자가 <strong>즉시 인지하고 대응할 수 있는 체계</strong>가 필요하다.</p>
<br>

<h3 id="61-장애-유형별-대응-전략-타임아웃-서버-다운">6.1. 장애 유형별 대응 전략 (타임아웃, 서버 다운)</h3>
<p>HTTP 요청은 네트워크 불안정, 서버 다운, 응답 지연 등 여러 원인으로 실패할 수 있다. 
HTTP 통신에서 발생 가능한 대표적인 장애는 다음과 같다.</p>
<ul>
<li><code>TimeoutException</code> → 일정 시간 동안 응답이 없을 때 발생</li>
<li><code>WebClientResponseException</code> → 서버가 4xx/5xx 응답을 반환했을 때 발생</li>
</ul>
<p>이러한 장애를 구분하고 상황별로 처리하기 위해 <code>Retry</code>와 <code>onErrorMap</code>을 사용하여 다음과 같이 구성했다.</p>
<pre><code class="language-java">.retryWhen(
    Retry.backoff(3, Duration.ofSeconds(1))
        .maxBackoff(Duration.ofSeconds(5))
        .filter(ex -&gt; ex instanceof WebClientResponseException
            || ex instanceof TimeoutException)
        .onRetryExhaustedThrow((spec, signal) -&gt; signal.failure())
)
.onErrorMap(WebClientResponseException.class,
    ex -&gt; new CodeReviewException(CodeReviewExceptionCode.REVIEW_SERVER_ERROR))
.onErrorMap(TimeoutException.class,
    ex -&gt; new CodeReviewException(CodeReviewExceptionCode.REVIEW_TIMEOUT))</code></pre>
<ul>
<li><strong>최대 3회</strong>까지 재시도를 수행하고, 중간에 응답이 오면 즉시 종료된다.</li>
<li>반복 실패 시에는 원인에 따라 <strong>커스텀 예외를 발생</strong>시켜 이후 처리 흐름에서 활용한다.</li>
</ul>
<p>이렇게 분리된 예외는 내부 로깅 또는 알림 시스템에서 구체적인 원인 파악에 활용된다.</p>
<br>

<h3 id="62-장애-발생-시-실시간-알림-처리">6.2. 장애 발생 시 실시간 알림 처리</h3>
<p>채점이나 코드 리뷰 요청 과정에서 장애가 발생하더라도, 사용자에게는 <strong>&quot;요청이 실패했다&quot;</strong>는 결과만 알려주고, 그 상세한 원인을 노출할 필요는 없다. 
예외 메시지나 서버 상태 같은 내부 시스템 정보는 <strong>보안 및 사용자 경험(UX)</strong> 측면에서 숨기는 것이 더 바람직하다.</p>
<p>대신, 백엔드에서 어떤 문제가 발생했는지를 <strong>운영자가 실시간으로 인지할 수 있어야 한다.</strong> 
이를 위해 <strong>Discord 웹훅 기반의 서버 알림 시스템</strong>을 도입했다.
<strong>채점 중 예외가 발생하면</strong>, 다음과 같이 알림이 전송된다.</p>
<pre><code class="language-java">exceptionNotifier.sendEmbed(
    &quot;채점 예외&quot;,
    &quot;채점 중 SubmissionException 발생&quot;,
    &quot;&quot;&quot;
        • 성공 여부: %s
        • 상태코드: %s
        • 메시지: %s
        &quot;&quot;&quot;.formatted(code.isSuccess(), code.getStatus(), code.getMessage()),
    &quot;submitCodeStream&quot;
);</code></pre>
<ul>
<li>알림은 <code>Discord Embed</code> 형태로 발송되며, 예외 메시지, 메서드명, 발생 시각 등의 정보를 함께 담아 문제 추적에 활용할 수 있다.</li>
</ul>
<blockquote>
<p>Slack등의 대안도 존재하지만, 현재 팀 상황이나 채점 시스템 담당인 내 입장에서는 <strong>Discord 채널이 가장 빠르고 즉각적인 커뮤니케이션 수단</strong>이었기 때문에 선택했다.</p>
</blockquote>
<h4 id="실제-알림-예시-discord-embed">실제 알림 예시 (Discord Embed)</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/1b91e722-d5cb-40d6-9969-7838b085c737/image.png" alt=""></p>
<p>위 메시지는<code>&quot;emitter를 찾을 수 없습니다.&quot;</code> 라는 예외가 발생했을 때 Discord로 전송된 실제 알림이다. 운영자는 이 알림을 통해 실시간으로 이슈를 인지하고, 빠르게 복구나 조치를 수행할 수 있다.</p>
<blockquote>
<p>참고로, 이 알림을 통해 <strong><a href="#2-sse%EC%99%80-redis-stream-%EA%B5%AC%EC%A1%B0%EC%9D%98-%EC%B6%A9%EB%8F%8C">SSE와 Redis Stream 간의 구조적 충돌</a></strong> 가능성을 인지하게 되었다.</p>
</blockquote>
<hr>
<h2 id="7-다음-고도화-계획">7. 다음 고도화 계획</h2>
<p>지금까지 SSE 기반의 채점 시스템, 분산 락 처리, 장애 대응 체계 등 다양한 기능들을 구현하며 안정적인 서비스 기반을 마련했다. 하지만 실제 테스트 과정에서 발견된 한계점과 더 나은 사용자 경험을 위해 다음과 같은 고도화를 계획하고 있다.</p>
<br>

<h3 id="71-sse-대체-방안-websocket-또는-polling-검토">7.1. SSE 대체 방안 (WebSocket 또는 Polling 검토)</h3>
<p>현재 채점 결과는 <strong>Server-Sent Events(SSE)</strong>를 이용해 스트리밍 방식으로 전달하고 있다.
하지만 앞서 설명했던 <strong><a href="#2-sse%EC%99%80-redis-stream-%EA%B5%AC%EC%A1%B0%EC%9D%98-%EC%B6%A9%EB%8F%8C">SSE와 Redis Stream 간의 구조적 충돌</a></strong>과 <strong><a href="#24-%EC%84%B8-%EB%B2%88%EC%A7%B8-%EC%A0%91%EA%B7%BC-%EC%9D%B8%EC%A6%9D-url-%EB%B2%94%EC%9C%84-%EC%84%A4%EC%A0%95">시큐리티 컨텍스트 전파 문제</a></strong>로 인해 SSE를 대체할 수 있는 통신 방식에 대한 검토가 필요해졌다.</p>
<ul>
<li><p><strong>WebSocket</strong></p>
<ul>
<li><strong>양방향 통신이 가능</strong>하고, 메시지 기반 구조에 적합</li>
<li>클라이언트가 <code>subscribe()</code>만 해두면, 서버는 각 테스트케이스의 결과를 <strong>완료되는 즉시 실시간 푸시</strong> 가능</li>
<li>인증 처리 구조에 대한 보완은 필요하지만, <strong>성능, 실시간성, 확장성</strong> 측면에서 강점</li>
</ul>
</li>
<li><p><strong>Polling</strong></p>
<ul>
<li>SSE나 WebSocket 대비 <strong>구현이 단순</strong>하고, <strong>브라우저 호환성이 뛰어남</strong></li>
<li><strong>REST API 기반 주기적 조회(GET)</strong> 방식으로 서버 제어가 쉬움</li>
<li>하지만 채점 결과가 바뀌지 않아도 계속 요청이 발생하고, <strong>실시간성이 떨어지는 한계</strong>가 있음</li>
</ul>
</li>
</ul>
<p>현재 SSE 구조에서도 각 테스트케이스 결과를 서버에서 클라이언트로 <strong>개별 전송</strong>하는 방식은 구현돼 있다. 하지만 클라이언트는 이를 단순히 순차적으로 수신만 할 뿐, <strong>개별 테스트케이스의 상태를 실시간으로 UI에 반영하지는 않는 구조</strong>다.</p>
<p>이에 따라, 앞서 제기된 구조적 문제를 해결하는 동시에, <strong>테스트케이스별 채점 결과를 UI에 동적으로 반영</strong>하고, 사용자가 <strong>실시간으로 처리 상태를 확인</strong>할 수 있는 구조로 확장하기 위해 WebSocket 기반 구조로의 전환을 적극 검토하고 있다.</p>
<br>

<h3 id="72-github-자동-푸시-연동-기능">7.2. GitHub 자동 푸시 연동 기능</h3>
<p>내가 예전에 포스팅한 <strong><a href="https://velog.io/@harvard--/%EC%BD%94%ED%85%8C-%EB%B0%B1%EC%A4%80%ED%97%88%EB%B8%8C%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%B4-Github-%EC%97%B0%EB%8F%99">BaekjoonHub</a></strong>를 사용해 본 사람이라면, 문제 풀이가 정답 처리되었을 때 <strong>자동으로 GitHub 활동에 기록</strong>되는 것이 얼마나 편리한지 알 것이다.
그와 유사한 방식으로, 현재 서비스 내에서도 <strong>문제 풀이 결과를 GitHub에 자동 커밋/푸시</strong>할 수 있도록 기능 구현을 계획 중이다.</p>
<h4 id="계획-중인-기능-흐름">계획 중인 기능 흐름</h4>
<ol>
<li>사용자가 GitHub 계정을 연동하면, <code>GitHub OAuth</code>를 통해 <strong>토큰 저장</strong></li>
<li>문제 제출 시, 소스코드와 채점 결과를 기반으로 <strong>Markdown</strong> 형식의 <code>.md</code> 또는 <code>.java</code> 파일 생성</li>
<li><code>GitHub API</code>를 이용해 해당 파일을 사용자의 저장소에 <strong>자동 커밋 및 푸시</strong></li>
</ol>
<h4 id="고려-중인-이슈">고려 중인 이슈</h4>
<ul>
<li>GitHub 토큰 암호화 저장 및 관리</li>
<li>OAuth 연결 해제 시, 연동 해제 및 토큰 폐기 처리</li>
<li>사용자가 푸시 기능을 직접 ON/OFF할 수 있도록 옵션 제공</li>
<li>커밋/푸시 실패 시 재처리 또는 사용자 알림 방식</li>
</ul>
<p>이 기능을 통해, 사용자는 자신의 문제 풀이 기록을 손쉽게 GitHub에 관리할 수 있게 되며, 이후 포트폴리오나 학습 이력 관리에도 활용할 수 있을 것으로 기대하고 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 코딩 테스트 플랫폼 아키텍처 설계부터 채점 시스템 구현까지]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%BD%94%EB%94%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%BD%94%EB%94%A9-%ED%85%8C%EC%8A%A4%ED%8A%B8-%ED%94%8C%EB%9E%AB%ED%8F%BC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%84%A4%EA%B3%84%EB%B6%80%ED%84%B0-%EC%B1%84%EC%A0%90-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Sun, 08 Jun 2025 09:31:57 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이번 프로젝트의 목표는 <strong>팀원 모두가 새로운 기술과 도메인에 도전해볼 수 있는 서비스 개발</strong>이었다.<br>여러 아이디어를 논의한 끝에, <strong>코딩 테스트 사이트</strong>를 최종 주제로 선정하게 되었다.</p>
<p>이 주제는 대부분의 개발자에게 익숙한 서비스이기 때문에, <strong>핵심 기능(MVP)을 빠르게 정의</strong>할 수 있었고, 실무에서 자주 사용되는 기술 스택과 설계 방식을 적용해보기에도 적절했다.<br>무엇보다도 <strong>주변 지인들을 통해 사용자 테스트가 용이하고, 프로젝트 종료 이후에도 지속적인 운영과 개선이 가능하다는 점</strong>이 큰 장점으로 작용했다.</p>
<h3 id="내가-맡은-역할-채점-시스템-설계-및-구현">내가 맡은 역할: 채점 시스템 설계 및 구현</h3>
<p>최종적으로 주제가 <strong>코딩 테스트 사이트</strong>로 결정되었고, 나는 그중에서도 <strong>사용자의 코드 제출을 자동으로 채점하는 &quot;채점 시스템&quot;의 설계와 구현</strong>을 맡았다.
다른 기능 개발에도 여러 부분 참여했지만, 채점 시스템은 프로젝트의 가장 핵심적인 기능이자, <strong>내가 가장 많은 시간과 고민을 쏟아부은 영역</strong>이었다.
단순히 실행 결과만 확인하는 수준을 넘어서, <strong>다양한 언어 지원, 다건 테스트케이스 처리, 동시 요청 처리, 채점 결과의 실시간 전달</strong> 등 고도화된 기능까지 고려해야 했기 때문에 설계와 구현 모두 쉽지 않았다.</p>
<p>이 글에서는 <strong>기본적인 채점 기능을 완성하고, 고도화를 앞두기까지의 과정</strong>을 중심으로 정리해보려고 한다.</p>
<hr>
<h2 id="1-애플리케이션-아키텍처-설계">1. 애플리케이션 아키텍처 설계</h2>
<p>이번 프로젝트에서는 기존의 <strong>3계층(3-Layer) 아키텍처</strong>에서 벗어나, <strong>4계층 구조를 기반으로 DDD 스타일의 아키텍처</strong>를 도입해보고자 했다.
가장 큰 이유는 <strong>도메인 로직과 애플리케이션 유스케이스를 명확히 분리</strong>하여, 구조적으로 더 유연하고 변경에 강한 시스템을 만들기 위함이었다.</p>
<br>

<h3 id="11-왜-4-layer인가">1.1. 왜 4-Layer인가?</h3>
<p>기존의 3Layer (Controller → Service → Repository) 구조에서는 서비스 계층(Service Layer)에 <strong>도메인 로직, 비즈니스 유스케이스, 인프라 의존 코드</strong>가 섞여 들어가는 경향이 있다.
이를 분리하지 않으면 다음과 같은 문제가 발생할 수 있다.</p>
<ul>
<li>변경 전파가 넓어진다 (한 영역의 변화가 다른 계층에 쉽게 영향을 준다)</li>
<li>테스트 코드 작성이 어려워진다 (순수한 도메인 로직만 분리하기 어려움)</li>
<li>도메인 중심 설계가 어려워진다</li>
</ul>
<p>이러한 문제를 줄이기 위해, 다음과 같은 초안이 나왔다.</p>
<pre><code class="language-text">Presentation → Application → Domain ← Infrastructure</code></pre>
<ul>
<li><strong>Presentation</strong>: 외부 요청을 수신하고, Application 계층에 전달</li>
<li><strong>Application</strong>: 유스케이스(사용자 시나리오) 수행</li>
<li><strong>Domain</strong>: 순수한 비즈니스 로직을 담당</li>
<li><strong>Infrastructure</strong>: 외부 시스템(JPA, Redis 등)과 실제로 통신하는 구현체</li>
</ul>
<br>

<h3 id="12-헥사고날-아키텍처">1.2. 헥사고날 아키텍처?</h3>
<p>아키텍처 토론 중, <strong>외부 API나 써드파티 연동이 많을 것</strong>이라는 점을 고려해 <strong>헥사고날 아키텍처(Hexagonal Architecture, Ports &amp; Adapters)</strong> 적용 의견도 나왔다.</p>
<p>헥사고날 아키텍처는 위 구조에 <strong>&quot;포트(Port)&quot;와 &quot;어댑터(Adapter)&quot;</strong>라는 개념을 도입한 것이다.</p>
<ul>
<li><strong>Port</strong>: 내부에서 외부로 의존성을 열어둔 <strong>인터페이스</strong>. 주로 도메인이나 애플리케이션 계층에서 정의</li>
<li><strong>Adapter</strong>: 외부 시스템과 실제 연결되는 <strong>구현체</strong>. 외부 API, JPA, 메시지 큐 등</li>
</ul>
<p>예를 들어, <strong>Redis를 이용한 캐시 저장 기능</strong>을 생각해보자.
Application 계층에서는 캐시 저장이라는 기능이 필요하지만, Redis를 직접 사용할 필요는 없다.
대신 다음과 같이 포트와 어댑터를 분리하여 구현할 수 있다.</p>
<pre><code class="language-text">ApplicationPort
└─ CacheStorePort (Interface)

InfrastructureAdapter
└─ RedisCacheAdapter (implements CacheStorePort)</code></pre>
<p>이렇게 하면 나중에 Redis 대신 <strong>Memcached</strong>나 <strong>로컬 캐시 구현체</strong>로 변경하더라도, <code>RedisCacheAdapter</code>만 교체하면 되므로, 애플리케이션 로직에는 전혀 영향을 주지 않는다.
이런 구조 덕분에 <strong>변경에 강하고 테스트하기 쉬운 구조</strong>를 만들 수 있다.</p>
<blockquote>
<p>포트는 항상 내부 계층에서 정의하고, 외부 계층에서 구현한다는 점에서 <strong>의존성 역전 원칙(DIP)을 실현하는 핵심 수단</strong>이다.</p>
</blockquote>
<br>

<h3 id="13-최종-구조와-의사-결정">1.3. 최종 구조와 의사 결정</h3>
<p>아키텍처 논의는 무려 <strong>3일 동안</strong> 이어졌고, 최종적으로는 다음과 같은 기준으로 정리되었다.</p>
<ul>
<li><strong>Application Port</strong>: 외부 API, Redis 등 비즈니스 로직과 직접 관련은 없지만, <strong>유스케이스 실행에 필요한 기술 의존성</strong>을 분리하기 위해 사용</li>
<li><strong>Domain Port</strong>: JPA를 사용하는 Repository를 도메인에서 분리하기 위해 사용<ul>
<li>완벽한 헥사고날 분리는 어렵지만, <strong>도메인의 JPA 의존도를 최소화하는 방식</strong>으로 구현</li>
</ul>
</li>
</ul>
<p>구조는 다음과 같이 정리되었다.</p>
<pre><code class="language-text">Client 
  ↓ 
Presentation  
  ↓ 
Application 
  ↓ 
Domain 
  ↓
(Domain Port) ← Infrastructure (예: UserJpaRepository)

Application 
  ↓
(Application Port) ← Infrastructure (예: RedisCacheAdapter)
</code></pre>
<blockquote>
<p>즉, 이번 프로젝트에서의 구조는 <strong>도메인과 애플리케이션이 외부 기술 구현체와의 의존을 완전히 분리할 수 있도록</strong> 하는 데에 중점을 두었다.<br>이를 위해 Port를 계층별로 구분해 정의했고, 외부 구현체는 Adapter로써 주입받아 사용하는 방식으로 설계했다.</p>
</blockquote>
<p>하지만 여기서 하나의 근본적인 의문이 남는다. 
<strong>과연 JPA를 사용하면서도 완벽한 헥사고날 아키텍처를 구현할 수 있을까?</strong><br><code>JpaRepository</code>는 도메인 포트(Domain Port)로 주입받아 사용하고 있지만, 사실상 <strong>엔티티 설계 시점부터 이미 JPA에 깊이 의존하는 구조</strong>가 되어 버린다.<br>예를 들어, JPA의 어노테이션이나 영속성 컨텍스트의 생명주기에 도메인이 영향을 받는 구조라면, 그것을 과연 <strong>순수한 도메인</strong>이라 부를 수 있을까?<br>DDD 관점에서는 여전히 고민이 필요한 지점이다.</p>
<hr>
<h2 id="2-자료-조사부터-맞닥뜨린-문제들">2. 자료 조사부터 맞닥뜨린 문제들</h2>
<p>프로젝트는 정해진 MVP 기한 내에 완성해야 했기 때문에, 초기 기획과 자료 조사 단계에서부터 여러 현실적인 제약과 마주해야 했다. 그 중 두 가지 가장 큰 문제가 있었다.</p>
<h3 id="21-문제-콘텐츠-확보">2.1. 문제 콘텐츠 확보</h3>
<p><strong>첫 번째 문제는, 다양한 유형의 코딩 테스트 문제가 필요하다는 점이었다.</strong>
하지만 직접 문제를 만들기엔 시간과 리소스가 부족했고, 검증된 문제를 구성하기엔 한계가 명확했다.</p>
<p>이에 따라 국내에서 코딩 테스트 문제를 제공하는 플랫폼 7군데에 메일을 보냈다.
메일에는 다음과 같은 내용을 담았다.</p>
<ul>
<li>해당 플랫폼의 문제를 학습용으로 활용할 수 있는지 여부</li>
<li>수익 목적이 없으며, 부트캠프 과제로 학습 경험을 위한 비영리 프로젝트임</li>
<li>팀 Notion 링크와 내 GitHub 주소 첨부</li>
</ul>
<p>결과적으로 2곳에서 회신을 받았고,
한 곳은 &quot;문제 저작권이 출제자에게 있어서 제공이 어렵다&quot;고 답변했다.
다른 한 곳에서는 조건부로 문제 사용을 허가해주셔서, 큰 난관이었던 문제 확보는 해결되었다.</p>
<p>단, 테스트케이스까지는 따로 제공받을 수 없었기 때문에, <strong>직접 문제를 풀거나 AI의 도움을 받아 문제당 3~5개의 테스트케이스를 직접 구성</strong>하기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/1b4b6d51-29dd-4ac9-bebb-d0b0dbfd4c8c/image.png" alt=""></p>
<blockquote>
<p>실제로 문제 사용 요청 메일을 보낸 뒤 받은 답변입니다.<br>출처를 명시하는 조건 하에 문제 사용을 허락받았습니다. 정말 감사드립니다.</p>
</blockquote>
<br>

<h3 id="22-컴파일러-구현-vs-외부-api-활용">2.2. 컴파일러 구현 vs 외부 API 활용</h3>
<p><strong>두 번째 문제는, 채점 시스템에서 사용할 컴파일러를 직접 구현하기에는 현실적인 제약이 너무 많았다는 점이다.</strong>
난이도, 실행 환경 격리, 언어별 런타임, 보안, 리소스 제한 등 고려해야 할 요소들이 너무 많았고, 무엇보다 시간도 부족했다.
그래서 <strong>외부 코드 실행 API</strong>나 <strong>라이브러리</strong> 도입을 검토하게 되었고, 다음과 같은 서비스들을 조사했다.</p>
<ul>
<li><strong>JDoodle</strong>: <ul>
<li>다양한 언어 지원, 간단한 API</li>
<li>무료 플랜의 호출 수 제한이 큼</li>
</ul>
</li>
<li><strong>Sphere Engine</strong>: <ul>
<li>안정적인 플랫폼, 고급 기능 제공</li>
<li>가격이 비싸고, 실시간성이 다소 떨어짐</li>
</ul>
</li>
<li><strong>Paiza.IO</strong>: <ul>
<li>클라이언트에서 직접 실행 가능</li>
<li>언어/기능 제한, 일본어 기반 문서</li>
</ul>
</li>
<li><strong>Judge0</strong>: <ul>
<li>오픈소스로 제공되며 self-hosted 가능</li>
<li>Docker 기반 초기 셋업 필요</li>
</ul>
</li>
</ul>
<p>이 중에서 우리는 <strong>Judge0</strong>를 선택했다.
가장 큰 장점은 오픈소스로 제공되어 <strong>self-hosted 환경에서 무료로 사용할 수 있다는 점</strong>이었다.
SaaS 방식도 제공되지만 사용량에 따라 요금이 부과되며, <strong>공식 문서에서는 Docker 기반으로 온프레미스 설치가 가능</strong>하다는 점을 안내하고 있었다.</p>
<p>이에 따라 우리는 AWS EC2 인스턴스에 Docker 컨테이너를 띄워, 내부적으로 Judge0를 실행할 수 있는 <strong>컴파일 서버를 직접 구축</strong>했다.</p>
<ul>
<li><code>docker-compose</code>로 API 서버 및 worker 구성</li>
<li>PostgreSQL, Redis 등 Judge0 의존 DB도 함께 구성</li>
<li>기본 언어 세트는 Java, Python, C, C++ 등으로 설정</li>
</ul>
<p>결과적으로, 외부 API에 종속되지 않으면서도 확장 가능한 <strong>온라인 컴파일 인프라</strong>를 안정적으로 구성할 수 있었다.</p>
<hr>
<h2 id="3-judge0란">3. Judge0란?</h2>
<p><strong>Judge0는 오픈소스 기반의 온라인 코드 실행 엔진(Online Judge Engine)이다.</strong><br>REST API를 통해 소스코드, 입력값(stdin), 언어 ID 등을 보내면, 실제로 컴파일 및 실행 결과(stdout, stderr, memory, time 등)를 반환해준다.</p>
<ul>
<li>약 50개 이상의 프로그래밍 언어 지원</li>
<li>비동기 API 기반</li>
<li>Docker 컨테이너 격리 환경</li>
<li>Self-hosted 형태로도 운영 가능</li>
</ul>
<p>현재 프로젝트에서는 이 Judge0의 Self-hosted 버전을 채점 시스템의 핵심 컴파일러로 활용하고 있으며, 실제 요청 흐름, 응답 구조 처리 방식은 다음 장에서 자세히 설명할 예정이다.</p>
<p><strong>공식 문서 및 저장소 링크</strong></p>
<ul>
<li><strong><a href="https://judge0.com">Judge0 공식 사이트</a></strong></li>
<li><strong><a href="https://ce.judge0.com">Judge0 API 문서</a></strong></li>
<li><strong><a href="https://github.com/judge0/judge0">Judge0 GitHub 저장소</a></strong></li>
</ul>
<hr>
<h2 id="4-judge0-컴파일-요청">4. Judge0 컴파일 요청</h2>
<p>Judge0에서는 코드 실행 요청을 다음과 같이 <strong>두 단계로 나누어 처리</strong>할 수 있다.</p>
<ol>
<li><p><code>POST /submissions</code>
→ 소스코드, 언어 ID, 입력값(stdin) 등을 담아 Judge0에 <strong>실행 요청을 전송</strong>한다.
→ 이때 <code>wait=true</code> 또는 <code>wait=false</code> 옵션을 통해 동기/비동기 처리 방식을 선택할 수 있다.</p>
</li>
<li><p><code>GET /submissions/{token}</code>
→ <code>wait=false</code>로 요청한 경우, 응답으로 받은 <code>token</code>을 이용해 <strong>결과를 나중에 조회(polling)</strong> 한다.</p>
</li>
</ol>
<blockquote>
<p>현재 프로젝트에서는 <code>wait=true</code>를 사용하여, <strong>요청과 동시에 결과를 기다리는 구조</strong>로 구현했다.
이 방식은 MVP 단계에서는 구현이 간단하고 빠른 응답 흐름을 설계하기에 유리하다.
하지만 테스트케이스 수가 많아지거나 동시 사용자가 증가하면, <code>wait=false</code>를 활용한 <strong>비동기 polling 구조</strong>로의 전환을 고려하고 있다.</p>
</blockquote>
<br>

<h3 id="41-judge0-포트-인터페이스와-어댑터-구현체">4.1. Judge0 포트 인터페이스와 어댑터 구현체</h3>
<p>Hexagonal Architecture(헥사고날 아키텍처) 스타일을 따르기 위해,
<strong>애플리케이션 계층은 Judge0 클라이언트를 직접 의존하지 않고 인터페이스(Port)</strong>만 알고 있도록 설계했다.</p>
<h4 id="judgeclient-application-port">judgeClient (Application Port)</h4>
<pre><code class="language-java">public interface JudgeClient {
    JudgeResult execute(CodeCompileRequest request);
}</code></pre>
<ul>
<li>비즈니스 로직은 <code>JudgeClient</code> 인터페이스만 알고 있음</li>
<li>실제 Judge0와 통신하는 구현체는 Infrastructure 계층에 있음</li>
</ul>
<h4 id="judge0client-infrastructure-adapter">judge0Client (Infrastructure Adapter)</h4>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class Judge0Client implements JudgeClient {

    @Value(&quot;${external.judge0.url}&quot;)
    private String judge0ApiUrl;
    private WebClient webClient;
    private final Judge0ResponseMapper interpreter;

    @PostConstruct
    private void init() {
        this.webClient = WebClient.create(judge0ApiUrl);
    }

    public JudgeResult execute(CodeCompileRequest request) {
        ExecutionResultResponse executionResultResponse = webClient.post()
            .uri(&quot;/submissions?base64_encoded=false&amp;wait=true&quot;)
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(request)
            .retrieve()
            .bodyToMono(ExecutionResultResponse.class)
            .block();

        return interpreter.toJudgeResult(executionResultResponse);
    }
}</code></pre>
<ul>
<li><code>WebClient</code>로 HTTP 요청을 보냄</li>
<li><code>ExecutionResultResponse</code>는 Judge0의 응답 DTO</li>
<li><code>Judge0ResponseMapper</code>를 통해 <strong>JudgeResult로 변환</strong></li>
</ul>
<br>

<h3 id="42-레이어별-dto-분리">4.2. 레이어별 DTO 분리</h3>
<p>Judge0는 외부 시스템이기 때문에, 해당 응답 구조를 애플리케이션 내부에 직접 노출하지 않았다.
외부 응답을 내부 로직에 그대로 연결할 경우, <strong>API 구조 변경이나 구현체(Judge0 등)의 교체가 상위 계층까지 영향을 미칠 수 있기 때문</strong>이다.
이를 방지하기 위해, <strong>Infrastructure 계층에서 받은 응답을 내부용 DTO로 변환하여 처리</strong>하는 방식으로 계층 간 의존성을 분리했다.</p>
<p>애플리케이션 레이어는 <code>stdout</code>, <code>status.id</code> 같은 외부 필드를 직접 알지 않고,
<code>actualOutput</code>, <code>success</code>와 같은 <strong>의미 중심의 필드</strong>만 참조하게 된다.
이 구조는 외부 API 변경 시 내부 로직이 영향을 받지 않도록 돕는다.</p>
<h4 id="request-계층-흐름">Request 계층 흐름</h4>
<pre><code class="language-java">// Presentation → Application
public record CodeSubmitRequest(

    @NotNull(message = &quot;언어 번호는 필수 입력 값입니다.&quot;)
    Long languageId,

    @NotBlank(message = &quot;소스 코드는 필수 입력 값입니다.&quot;)
    String sourceCode

) {}

// Application → Infrastructure
public record CodeCompileRequest(

    String source_code,

    Long language_id,

    String stdin

) {}

// 흐름
[Presentation] CodeSubmitRequest
       ↓ 변환
[Application → Infra] CodeCompileRequest</code></pre>
<h4 id="response-계층-흐름">Response 계층 흐름</h4>
<pre><code class="language-java">// Third-party → Infrastructure
public record ExecutionResultResponse(

    String stdout,

    Double time,

    Long memory,

    String stderr,

    String token,

    String compile_output,

    int exit_code,

    ExecutionStatus status

) {

    public long getMemory() {
        return this.memory == null ? 0L : memory;
    }

    public double getTime() {
        return this.time == null ? 0.0 : time;
    }

    public record ExecutionStatus(

        int id,

        String description

    ) {}
}

// Infrastructure → Application
@Builder
public record JudgeResult(

    String actualOutput,

    double executionTime,

    long memoryUsage,

    boolean success,

    String message

) {
}

// 흐름
[Infra] ExecutionResultResponse (Judge0 원본 응답)
       ↓ 매핑
[Application] JudgeResult</code></pre>
<br>

<h3 id="43-judge0-→-judgeresult-변환-로직-mapper">4.3. Judge0 → JudgeResult 변환 로직 (Mapper)</h3>
<pre><code class="language-java">@Component
public class Judge0ResponseMapper {

    public JudgeResult toJudgeResult(ExecutionResultResponse executionResultResponse) {
        String output = extractActualOutput(executionResultResponse);
        boolean success = isSuccessful(executionResultResponse);
        return JudgeResult.builder()
            .actualOutput(output)
            .executionTime(executionResultResponse.getTime())
            .memoryUsage(executionResultResponse.getMemory())
            .success(success)
            .message(executionResultResponse.status().description())
            .build();
    }

    private String extractActualOutput(ExecutionResultResponse executionResultResponse) {
        if (executionResultResponse.stdout() != null) return executionResultResponse.stdout();
        if (executionResultResponse.compile_output() != null) return executionResultResponse.compile_output();
        if (executionResultResponse.stderr() != null) return executionResultResponse.stderr();
        return &quot;(No output)&quot;;
    }

    private boolean isSuccessful(ExecutionResultResponse executionResultResponse) {
        return executionResultResponse.stdout() != null &amp;&amp; executionResultResponse.status().id() == 3;
    }
}</code></pre>
<blockquote>
<p>위에서 설명한 것처럼, JudgeResult는 Judge0 응답을 내부적으로 해석한 DTO다.
이 Mapper 클래스는 그 변환 과정을 실제로 담당하며, <strong>외부 구조를 몰라도 되는 의미 중심의 필드만을 Application에 전달할 수 있게 한다.</strong></p>
</blockquote>
<p>전체 흐름으로 보면 이렇게 된다.</p>
<pre><code class="language-text">[Client]
   ↓ CodeSubmitRequest
[Controller]
   ↓
[ApplicationService]
   ↓ CodeCompileRequest
[JudgeClient (Interface)]
   ↓
[Judge0Client (WebClient)]
   ↓ HTTP POST to Judge0 (/submissions)
   ↓ Judge0 응답 (ExecutionResultResponse)
   ↓ Judge0ResponseMapper
   ↓ JudgeResult
[ApplicationService]
   ↓ 테스트케이스의 기대값과 실제 출력값 비교
   ↓ SseEmitter.send()로 클라이언트에 결과 전송</code></pre>
<p>아래는 Judge0로부터 실제로 수신한 응답 캡처 화면이다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/f524a697-3849-4d3e-9ccc-bc607201644c/image.png" alt=""></p>
<p>위 응답은 <code>status.id == 3</code>이므로 성공 판정,
<code>stdout</code>이 있으므로 <code>actualOutput</code>은 <code>&quot;42&quot;</code>가 된다.</p>
<br>

<h3 id="44-트러블슈팅-box-디렉토리-접근-불가-이슈">4.4. 트러블슈팅: /box 디렉토리 접근 불가 이슈</h3>
<p>Judge0를 self-hosted로 설치해 사용하면서 가장 당황했던 순간이 있었다.
정상적으로 소스코드와 언어 ID를 담아 요청을 보냈음에도, 다음과 같은 알 수 없는 에러가 발생했다.</p>
<pre><code class="language-text">No such file or directory @ rb_sysopen - /box/main.c - Internal Error</code></pre>
<p>처음에는 단순히 경로 설정이 잘못된 건가 싶었지만, 공식 GitHub 이슈나 StackOverflow에서도 동일한 현상이 반복적으로 언급되고 있었다.</p>
<br>

<h4 id="441-judge0는-왜-box-디렉토리를-사용하는가">4.4.1. Judge0는 왜 /box 디렉토리를 사용하는가?</h4>
<p>Judge0는 내부적으로 <a href="https://github.com/ioi/isolate"><strong>isolate</strong></a>라는 샌드박스 도구를 사용한다.
이 <code>isolate</code>는 제출된 코드를 완전히 격리된 환경에서 실행하기 위해, 매번 /box라는 실행 전용 디렉토리를 생성하고 그 안에서 컴파일과 실행을 진행한다.</p>
<p>이 디렉토리는 매우 제한된 권한과 리소스 안에서 동작하며, 사용자의 악의적인 코드로부터 시스템을 보호하는 중요한 역할을 한다.</p>
<p>즉, <code>/box/main.c</code> 같은 경로는 isolate가 직접 만든 &quot;가상 격리 공간&quot;이며, Judge0가 코드를 안전하게 실행하기 위해 꼭 필요한 구조이다.</p>
<br>

<h4 id="442-원인은-무엇이었나">4.4.2. 원인은 무엇이었나?</h4>
<p>내가 사용한 EC2 인스턴스는 <strong>Ubuntu 24.04 (Noble Numbat)</strong> 버전이었는데,
이 버전부터는 리눅스 시스템에서 기본적으로 <strong><code>cgroup v2</code></strong>를 사용한다.</p>
<p>하지만 문제는 <code>isolate</code>가 아직 완전하게 <code>cgroup v2</code>를 지원하지 않는다는 점이다.
이로 인해 샌드박스 디렉토리(<code>/box</code>)가 <strong>제대로 생성되지 않거나 접근이 불가능해지는 현상이 발생</strong>한다.</p>
<p>결국 <code>main.c</code> 파일을 생성하려다 실패하고, 위와 같은 <code>rb_sysopen</code> 오류를 발생시키는 것이다.</p>
<blockquote>
<p>내부적으로 Ruby 스크립트를 통해 <code>/box/main.c</code> 등 파일을 생성하는데, cgroup v2 환경에서는 해당 위치가 제대로 마운트되지 않아 <code>No such file or directory</code> 에러가 발생한다.</p>
</blockquote>
<br>

<h4 id="443-해결-방법-cgroup-v1로-전환">4.4.3. 해결 방법: cgroup v1로 전환</h4>
<p>해결은 의외로 단순했다.
Ubuntu에서 <code>cgroup</code>을 v1 모드로 되돌리기 위해, 아래 설정을 추가하고 시스템을 재부팅했다.</p>
<pre><code class="language-bash">sudo vim /etc/default/grub</code></pre>
<pre><code class="language-text">GRUB_CMDLINE_LINUX=&quot;systemd.unified_cgroup_hierarchy=0&quot;</code></pre>
<p>그리고 나서 <code>grub</code> 설정을 반영하고 재부팅한다.</p>
<pre><code class="language-bash">sudo update-grub
sudo reboot</code></pre>
<p>재부팅 이후, 기존에 발생하던 <code>/box</code> 디렉토리 관련 에러는 완전히 사라졌다.
이제 Judge0는 정상적으로 코드 파일을 생성하고 컴파일 결과를 반환할 수 있게 되었다.</p>
<p>Ubuntu 24.04는 기본적으로 <code>cgroup v2</code>를 사용하지만, <code>systemd.unified_cgroup_hierarchy=0</code> 옵션을 통해 <code>v1</code>으로 전환해도 현재 시점에서는 정상 동작하며 별다른 부작용은 없다.
다만 장기적으로는 <code>v2</code>가 표준으로 자리잡는 추세이므로, <strong>Judge0와 같은 시스템의 업데이트 여부를 주기적으로 확인하고 v2 호환성을 점검하는 것이 바람직하다.</strong></p>
<br>

<h4 id="444-트러블슈팅을-통해-느낀-점">4.4.4. 트러블슈팅을 통해 느낀 점</h4>
<p>이번 이슈를 겪으며 가장 크게 느낀 점은, 오픈소스를 직접 self-hosted로 운영할 때는 단순히 잘 설치된다는 것만으로 끝나는 게 아니라는 사실이었다.
Judge0처럼 시스템 리소스와 맞물려 돌아가는 도구는, <strong>운영체제의 커널 수준 설정(cgroup 등)</strong>과도 밀접하게 연결돼 있다는 점을 체감했다.</p>
<p>특히 Ubuntu 24.04처럼 최신 버전 환경을 사용할 경우, <strong>공식 문서에서 명시되지 않은 의존성 문제나 호환성 이슈가 발생할 수 있으며,</strong> 이럴 때는 단순히 에러 메시지만으로는 원인을 알기 어렵기 때문에, 관련 오픈소스 프로젝트의 이슈 트래커와 커뮤니티를 탐색하는 습관이 매우 중요하다는 걸 배웠다.</p>
<blockquote>
<p>참고: <a href="https://github.com/judge0/judge0/issues/325">Judge0 GitHub 이슈 #325</a>,
<a href="https://stackoverflow.com/questions/75551174/no-such-file-or-directory-rb-sysopen-box-main-c-internal-error">StackOverflow - rb_sysopen /box/main.c 오류</a></p>
</blockquote>
<hr>
<h2 id="5-다건-테스트-케이스-채점-로직">5. 다건 테스트 케이스 채점 로직</h2>
<p>이번 목차에서는 앞서 생성된 <code>JudgeResult</code>를 기반으로, 각 테스트 케이스의 기대값과 비교하여 <strong>개별 채점 결과를 구성하고</strong>, 이를 클라이언트에게 <strong>실시간으로 전송하는 구조</strong>를 설명하려고 한다.</p>
<p>모든 테스트 케이스에 대한 채점이 완료되면, <strong>최종 결과(전체 통과 여부, 평균 메모리 사용량, 평균 실행 시간)</strong>를 종합하여 응답으로 반환한다.</p>
<p><strong>아래는 전체 채점 흐름을 담당하는 메서드의 실제 구현 코드다.</strong>
아래에서 각 컴포넌트 설명과 함께 순차적으로 설명할 예정이다.</p>
<pre><code class="language-java">public SseEmitter submitCodeStream(Long problemId, CodeSubmitRequest request, AuthUser authUser) {

    SseEmitter emitter = new SseEmitter();

    new Thread(() -&gt; {
        try {
            SubmissionAggregator aggregator = new SubmissionAggregator();
            User user = userDomainService.getUserById(authUser.getId());
            Language language = languageDomainService.getLanguage(request.languageId());
            ProblemInfo problemInfo = problemDomainService.getProblemInfo(problemId);

            int passedCount = 0;
            String message = COMPILE_MESSAGE;

            for (Testcase tc : problemInfo.testcaseList()) {

                JudgeResult result = judgeClient.execute(
                    new CodeCompileRequest(
                        request.sourceCode(),
                        language.getJudge0Id(),
                        tc.getInput()
                    )
                );

                AnswerEvaluation evaluation = submissionDomainService.evaluate(
                    tc.getOutput(),
                    result.actualOutput(),
                    result.success(),
                    result.executionTime(),
                    result.memoryUsage(),
                    problemInfo
                );

                if (evaluation.isPassed()) {
                    passedCount++;
                } else {
                    message = result.message();
                }

                submissionDomainService.collectStatistics(
                    aggregator,
                    result.executionTime(),
                    result.memoryUsage()
                );

                emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation));
            }

            SubmissionData submissionData = SubmissionData.base(
                user,
                problemInfo,
                language,
                request.sourceCode(),
                message
            );

            submissionDomainService.finalizeSubmission(submissionData, aggregator, passedCount);

            emitter.send(SseEmitter.event()
                .name(&quot;final&quot;)
                .data(new FinalResultResponse(
                    problemInfo.getTestcaseCount(),
                    passedCount,
                    message
                ))
            );

            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    }).start();

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

<h3 id="51-전체-흐름-개요">5.1. 전체 흐름 개요</h3>
<p>채점 요청이 들어왔을 때, 하나의 제출은 다음과 같은 흐름을 따라 처리된다.</p>
<blockquote>
<ol>
<li>사용자/문제/언어 등 채점에 필요한 정보 조회</li>
<li>각 테스트 케이스 별로 Judge0 실행 및 결과 수신</li>
<li>결과 비교 및 통계 수집</li>
<li>테스트 케이스 별 응답 실시간 전송 (SSE)</li>
<li>최종 채점 결과 집계 및 제출 기록 저장</li>
<li>최종 결과 클라이언트 전송</li>
</ol>
</blockquote>
<br>

<h3 id="52-핵심-컴포넌트-설명">5.2. 핵심 컴포넌트 설명</h3>
<p>아래에서는 각 단계에서 활용된 주요 컴포넌트의 <strong>역할과 책임</strong>을 설명한다.
코드의 양이 많아 스니펫은 생략하고, 핵심 기능 위주로 정리했다.</p>
<h4 id="521-submissionaggregator-통계-수집-객체">5.2.1. SubmissionAggregator: 통계 수집 객체</h4>
<ul>
<li>테스트 케이스 단위의 실행 시간, 메모리 사용량을 누적 집계하여, 통계 정보로 활용</li>
<li><code>finalizeSubmission()</code>에서 평균 계산에 사용됨</li>
</ul>
<h4 id="522-userdomainservice-languagedomainservice-problemdomainservice">5.2.2. UserDomainService, LanguageDomainService, ProblemDomainService</h4>
<ul>
<li>각각 사용자, 언어, 문제에 대한 정보를 도메인 레벨에서 조회</li>
<li>ApplicationService에서는 DB 직접 접근 없이 도메인 서비스만 사용</li>
</ul>
<h4 id="523-judgeclient와-codecompilerequest">5.2.3. JudgeClient와 CodeCompileRequest</h4>
<ul>
<li>외부 시스템(Judge0) 호출을 위한 인터페이스/DTO</li>
<li>테스트 케이스 단위로 요청하여 결과를 개별 수신</li>
</ul>
<h4 id="524-answerevaluation">5.2.4. AnswerEvaluation</h4>
<ul>
<li>사용자가 제출한 소스 코드의 출력값과 테스트 케이스에 등록된 기대값을 비교</li>
<li>실행 성공 여부, 문제 제한 사항 준수 여부 판단</li>
</ul>
<h4 id="525-submissiondomainservice">5.2.5. SubmissionDomainService</h4>
<ul>
<li><code>evaluate()</code>: 기준에 맞춰 채점</li>
<li><code>collectStatistics()</code>: 통계 수집</li>
<li><code>finalizeSubmission()</code>: 최종 제출 처리</li>
</ul>
<h4 id="526-sseemitter-사용-방식">5.2.6. SseEmitter 사용 방식</h4>
<ul>
<li>테스트케이스별 결과 → <code>emitter.send()</code></li>
<li>최종 결과 → <code>.name(&quot;final&quot;)</code> 이벤트로 별도 전송</li>
<li>예외 발생 시 <code>completeWithError()</code></li>
</ul>
<br>

<h3 id="53-실시간-채점-결과-예시-테스트용-ui-캡처">5.3. 실시간 채점 결과 예시 (테스트용 UI 캡처)</h3>
<p>아래는 클라이언트 측에서 실시간으로 채점 결과를 수신하고 렌더링하는 모습을 보여주는 캡처 예시다.<br>테스트 케이스별로 응답이 도착할 때마다 바로바로 결과가 반영되며, 마지막에 최종 결과도 함께 표시된다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/4ced5bb5-ea60-4cb7-a792-0dc038ef6f03/image.gif" alt=""></p>
<blockquote>
<p>단일 요청에 대해 여러 개의 테스트 결과를 순차적으로 전송하기 위해 SSE(Streamed Event) 방식이 사용되었다. 
이는 사용자에게 <strong>채점 진행 상황을 실시간으로 보여주는 경험</strong>을 제공하기 위한 설계적 선택이다.</p>
</blockquote>
<br>

<h3 id="54-왜-sse를-선택했는가">5.4. 왜 SSE를 선택했는가?</h3>
<p>채점 결과를 사용자에게 실시간으로 보여주기 위해 여러 통신 방식이 고려되었다.<br>이 프로젝트에서는 그중 <strong>SSE(Server-Sent Events)</strong> 방식을 선택했다.</p>
<p>총 세 가지 후보가 있었다.</p>
<ul>
<li><strong>Polling</strong><ul>
<li>일정 시간마다 서버에 요청, 구현 단순</li>
<li>서버에 부하, 실시간성 부족</li>
</ul>
</li>
<li><strong>WebSocket</strong><ul>
<li>양방향 통신 (Full Duplex) 가능, 높은 실시간성, 제어 유연 </li>
<li>복잡한 설정, 서버 자원 소모 큼</li>
</ul>
</li>
<li><strong>SSE</strong><ul>
<li>서버 → 클라이언트 단방향 지속 전송, 브라우저 지원 좋음 </li>
<li>단방향만 가능, IE 미지원</li>
</ul>
</li>
</ul>
<h4 id="선택-이유">선택 이유</h4>
<ul>
<li>채점 결과는 <strong>서버 → 클라이언트 방향의 일방향 흐름</strong>이기 때문에 WebSocket처럼 양방향 통신은 과한 스펙이었다.</li>
<li>SSE는 <strong>HTTP 기반 프로토콜이기 때문에 방화벽이나 프록시 환경에서도 잘 작동</strong>하고, 추가 설정이 필요 없다.</li>
<li>대부분의 최신 브라우저(Chrome, Firefox, Edge, Safari)에서 <code>EventSource</code>라는 내장 API로 쉽게 구현 가능하다.<ul>
<li>예를 들어, 클라이언트는 <code>new EventSource(&quot;/채점&quot;)</code>으로 손쉽게 구독할 수 있고,</li>
<li>서버는 Spring의 <code>SseEmitter.send()</code>로 응답을 전송하는 간단한 구조다.</li>
</ul>
</li>
<li>사용자에게 채점 결과를 <strong>한꺼번에가 아니라, 테스트 케이스별로 순차적으로 보여주는</strong> 실시간 UX에 매우 적합하다.</li>
<li>Internet Explorer는 미지원이지만, 실사용 환경에서는 큰 제약이 되지 않는다.</li>
</ul>
<blockquote>
<p>물론 향후 실시간 피드백이 양방향성을 필요로 하거나, 제어 흐름이 더 복잡해진다면 WebSocket으로의 전환도 고려해볼 생각이다.</p>
</blockquote>
<hr>
<h2 id="6-사용자-제출-코드-ai가-리뷰해준다">6. 사용자 제출 코드, AI가 리뷰해준다!</h2>
<p>단순히 통과 여부만 알려주는 채점 시스템에서 한 걸음 더 나아가, 
<strong>&quot;사용자가 제출한 코드에 대해 AI가 직접 코드 리뷰를 제공하는 기능&quot;</strong>도 함께 기획하고 있다.</p>
<p>이 기능은 코드 제출 이후, 사용자가 원할 경우에만 AI 리뷰를 요청할 수 있도록 설계되어 있다. 기본적으로 자동 실행되지 않으며, 리뷰는 사용자의 선택에 따라 요청된다.
현재는 OpenAI의 GPT 모델에게 사용자 코드를 전달하여, 간단한 피드백을 생성하는 구조로 구현되어 있다.</p>
<p>기본적인 동작 흐름은 기존 Judge0 채점 로직과 거의 유사하며, 다음과 같은 순서로 진행된다.</p>
<ol>
<li>사용자가 문제에 대해 코드를 제출한다.</li>
<li>Judge0를 통해 실행 결과 및 채점이 완료된다. (필수)</li>
<li><strong>제출한 소스코드를 AI에게 전달</strong>하여 다음 내용을 분석하게 한다.<ul>
<li>코드 스타일, 중복, 복잡도 등 품질 피드백</li>
<li>더 나은 알고리즘 제안</li>
<li>시간/공간 복잡도 분석</li>
</ul>
</li>
<li>분석 결과는 <strong>별도 UI 영역에 리뷰 형태로 표시</strong>된다.</li>
</ol>
<p>예를 들어, 사용자가 반복문을 여러 번 중첩해 단순하게 문제를 해결했다면, 
AI는 <strong>&quot;반복문을 여러 번 사용하는 대신, 정렬 후 한 번의 순회로 해결하는 방식이 더 효율적일 수 있어요. 또는, HashMap을 이용하면 시간 복잡도를 줄일 수 있습니다.&quot;</strong> 와 같은 피드백을 줄 수 있다.</p>
<p>현재는 아래와 같이 OpenAI에 요청을 보내는 간단한 클라이언트 형태로 구현되어 있다.
기본적인 구조는 앞서 소개한 Judge0 호출 방식과 거의 동일하다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class OpenAiClient implements ReviewClient {

    @Value(&quot;${OPEN_API_URL}&quot;)
    private String openApiUrl;

    @Value(&quot;${OPEN_API_KEY}&quot;)
    private String openApiKey;
    private WebClient webClient;


    @PostConstruct
    private void init() {
        this.webClient = WebClient.create(openApiUrl);
    }

    @Override
    public ReviewResult requestReview(ReviewPayload request) {
        String userPrompt = buildPrompt(request);

        Map&lt;String, Object&gt; requestBody = Map.of(
            &quot;model&quot;, &quot;gpt-3.5-turbo&quot;,
            &quot;messages&quot;, List.of(
                Map.of(&quot;role&quot;, &quot;system&quot;, &quot;content&quot;, &quot;코딩 테스트 사이트의 코드 리뷰를 담당하는 역할을 해주세요.&quot;),
                Map.of(&quot;role&quot;, &quot;user&quot;, &quot;content&quot;, userPrompt)
            )
        );

        return webClient.post()
            .uri(&quot;/v1/chat/completions&quot;)
            .header(&quot;Authorization&quot;, &quot;Bearer &quot; + openApiKey)
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(requestBody)
            .retrieve()
            .bodyToMono(OpenAiResponse.class)
            .map(response -&gt; new ReviewResult(response.getReviewContent()))
            .block();
    }

    private String buildPrompt(ReviewPayload request) {
        String status = request.isCorrect() ? &quot;정답&quot; : &quot;오답&quot;;
        return &quot;&quot;&quot;
            문제: %s

            아래는 %s 언어로 작성된 소스코드입니다.
            사용자가 제출한 코드이고, %s 입니다.

            ```
            %s
            ```
            - 정답일 경우: 시간 복잡도와 가독성, 더 나은 방법이 있다면 조언을 주세요. (코드를 보여주는 것 제외)
            - 오답일 경우: 오답 코드 부분과 오답 원인과 관련된 키워드 (예: 메서드 이름, 알고리즘 종류, 자료구조 등)를 알려주세요.
            &quot;&quot;&quot;.formatted(request.problemDescription(), request.languageName(), status, request.sourceCode());
    }
}</code></pre>
<blockquote>
<p>외부 API(OpenAI)와의 직접적인 의존을 피하기 위해, ReviewClient라는 Application Port 인터페이스를 정의하고, 실제 API 호출은 Infrastructure 계층의 Adapter에서 구현하는 방식으로 분리하였다.</p>
</blockquote>
<p>이 기능은 아직 MVP 수준의 단순한 구조지만, 향후 개선과 확장을 고려하고 있으며, 그 내용은 다음 장에서 소개할 예정이다.</p>
<hr>
<h2 id="7-예정하고-있는-고도화-목록">7. 예정하고 있는 고도화 목록</h2>
<p>채점 시스템의 기본적인 동작은 구현을 완료했지만, 실제 서비스 수준으로 끌어올리기 위해서는 다양한 고도화와 안정화 작업이 필요하다. 현재까지 고민 중인 부분은 다음과 같다.</p>
<p>물론 이 모든 기능을 당장 구현하긴 어렵지만, 일부는 구현 이후 블로그 포스팅으로 기록을 계속 남길 예정이다.</p>
<h3 id="71-시스템-부하와-통신-최적화">7.1. 시스템 부하와 통신 최적화</h3>
<ul>
<li>사용자 요청이 몰리거나 컴파일 서버의 scale-out이 어려운 상황에 대비해, <strong>성능 최적화 및 대기열 기반 처리 방식</strong>도 필요할 수 있다.</li>
<li>예를 들어, 메시지 큐(RabbitMQ, Kafka 등)를 활용한 <strong>비동기 처리 구조</strong>로 전환하거나, <strong>채점 요청 우선순위</strong> 설정 방식도 가능하다.</li>
<li>현재는 <code>wait=true</code> 방식으로 동기 요청을 처리하고 있으나, 추후에는 <strong><code>wait=false</code> + polling</strong> 구조나 <strong>비동기 알림 기반 응답 구조</strong>로 전환할 수 있다.</li>
</ul>
<h3 id="72-장애-대응-및-예외-처리-강화">7.2. 장애 대응 및 예외 처리 강화</h3>
<ul>
<li>채점은 서비스의 핵심 기능인 만큼, <strong>세분화된 예외 처리와 장애 발생 시 복구 전략</strong>이 필수적이다.</li>
<li>예를 들어, Judge0가 특정 요청에 대해 응답하지 않거나 오작동할 경우, 자동 재시도 및 대체 응답 제공 등의 로직이 필요하다.</li>
<li>해당 영역만으로도 <strong>단일 포스트로 분리해 다룰 만큼 많은 고민거리</strong>가 나올 것 같다.</li>
</ul>
<h3 id="73-ai-리뷰-정밀도-향상-프롬프트-정형화">7.3. AI 리뷰 정밀도 향상 (프롬프트 정형화)</h3>
<ul>
<li>현재는 간단한 형태의 프롬프트를 사용하고 있지만, 향후 <strong>문제 유형, 정답/오답 여부, 언어 특성</strong> 등을 반영한 <strong>정형화된 프롬프트 템플릿</strong>을 개발할 계획이다.</li>
<li>코드에 포함된 주요 키워드, 메서드, 시간복잡도 등의 정보도 함께 추출해 <strong>AI가 보다 정확한 리뷰를 제공할 수 있도록 개선</strong>할 수 있다.</li>
<li>프롬프트의 품질을 개선하는 것만으로도 AI 리뷰 결과의 질이 크게 향상된다.</li>
<li><em>이는 곧 사용자의 학습 효과를 높이는 결과*</em>로 이어질 수 있기 때문에, 매우 중요한 개선 항목이다.</li>
</ul>
<h3 id="74-유사-제출-캐싱-및-중복-방지">7.4. 유사 제출 캐싱 및 중복 방지</h3>
<ul>
<li>동일한 문제에 같은 코드를 반복 제출할 경우, 매번 채점 요청을 보내는 것은 <strong>불필요한 리소스 낭비</strong>다.</li>
<li>이를 방지하기 위해, <strong>문제 ID + 소스 코드 해시</strong> 기준의 캐싱 전략을 도입하면 일부 요청을 빠르게 응답할 수 있다.</li>
<li>이와 함께 <strong>연속 클릭 방지(디바운싱)</strong> 기능도 프론트엔드/백엔드 양쪽에서 고려 중이다.</li>
</ul>
<h3 id="75-코드-악의적-제출-방어">7.5. 코드 악의적 제출 방어</h3>
<ul>
<li>무한 루프, 메모리 폭주 코드, 시스템 자원 고갈을 유도하는 악성 코드가 제출될 수 있다.</li>
<li>현재 Judge0에서 메모리/시간 제한을 설정하고 있으나, <strong>사용자 입력을 분석해 사전 차단하는 방법</strong>도 검토할 필요가 있다.</li>
<li>예를 들어, 특정 패턴(예: <code>while(true)</code>)에 대해 선제적 경고 메시지를 제공할 수도 있다.</li>
</ul>
<h3 id="76-성능-및-로그-모니터링-체계">7.6. 성능 및 로그 모니터링 체계</h3>
<ul>
<li>실시간 사용자 수가 늘어나면 <strong>서버 성능, 응답 시간, 오류율 등을 시각화하여 지속적으로 개선할 필요</strong>가 있다.</li>
<li>간단한 수준의 Application 로그 외에도, <strong>JMeter 또는 k6를 활용한 부하 테스트 및 지표 기반 모니터링</strong>을 도입할 계획이다.</li>
<li>이 부분은 인프라 담당과 협업하여 <strong>역할을 분리하고 관리체계를 정립할 필요</strong>가 있다.</li>
</ul>
<h3 id="77-문제-정답-시-github-레포-자동-커밋-기능">7.7. 문제 정답 시 GitHub 레포 자동 커밋 기능</h3>
<ul>
<li>사용자가 문제를 통과하면, <strong>해당 문제 ID와 소스코드를 자동으로 사용자의 GitHub 레포지토리에 커밋하는 기능</strong>도 고려 중이다.</li>
<li>이는 &quot;문제 풀이 히스토리 관리&quot;를 자동화하여, 사용자가 별도로 정리하지 않아도 개인 코딩 히스토리를 남길 수 있도록 돕는다.</li>
<li>GitHub Personal Access Token 기반 인증 방식으로 구현하며, 사용자가 <strong>원할 경우에만 활성화</strong>되도록 옵션을 제공할 예정이다.</li>
</ul>
<h3 id="78-ai-입력-이슈-및-분석기-도입-실험-ai-가드">7.8. AI 입력 이슈 및 분석기 도입 실험 (AI 가드)</h3>
<ul>
<li>조사 중 확인한 내용에 따르면, AI가 사용하는 <strong>공백 문자(스페이스)</strong>는 키보드 입력과** 유니코드 레벨에서 다를 수 있다**는 정보가 있다.</li>
<li>단순한 복사 붙여넣기는 사용자의 학습에 방해가 되기 때문에, 이를 탐지하고 방지하는 간단한 <strong>AI 입력 분석기</strong>도 실험해볼 계획이다.</li>
</ul>
<blockquote>
<p>이 외에도 향후 확장 가능성은 매우 크고, 일부 기능은 <strong>사이트를 지속적으로 운영하며 발전시킬 계획</strong>이다.
일단은 핵심 기능을 MVP 수준으로 구현한 데에 의의를 두고, 나머지는 개선 여지를 충분히 남겨두었다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 동시성 제어 개념부터 Redisson 분산 락 구현까지]]></title>
            <link>https://velog.io/@harvard--/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@harvard--/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-Redisson-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Sat, 24 May 2025 17:30:51 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>백엔드 개발자에게 데이터 정합성은 <strong>선택이 아닌 필수다.</strong>
트래픽이 몰리는 상황에서 하나의 자원을 동시에 여러 사용자가 요청할 경우, 정합성이 깨지면 곧바로 운영 리스크로 이어진다.</p>
<p>이번 글에서는 동시성 문제란 무엇인지, 이를 해결하기 위해 어떤 방식들이 존재하는지 살펴보고, 실제 예매 시스템에 Redisson 기반의 분산 락과 AOP를 적용해 구현한 과정과 결과를 정리했다.</p>
<p>단순히 개념 소개에 그치지 않고, JMeter로 실전 부하 테스트를 진행하여 락 적용 전후의 차이를 명확히 검증해보았다.</p>
<blockquote>
<p>글이 꽤 길고 내용이 많을 수 있다.
또한 일부 코드나 설명에는 실수가 있을 수도 있다.
그럼에도 분산 락을 사용해보고자 하는 사람들에게 도움이 되길 바라며, 
실제로 어떻게 적용했는지를 가능한 자세히 기록해두려 한다.</p>
</blockquote>
<p><strong>이제 본격적으로 동시성 제어란 무엇인지부터 살펴보자.</strong></p>
<hr>
<h2 id="1-동시성-제어란-무엇인가">1. 동시성 제어란 무엇인가?</h2>
<blockquote>
<p><strong>둘 이상의 작업(요청, 트랜잭션, 스레드 등)이 동시에 같은 자원(데이터, 파일, 메모리 등)에 접근할 때, 데이터의 정합성(무결성)을 보장하기 위한 제어 기법이다.</strong></p>
</blockquote>
<h3 id="예-콘서트-티켓-예매-상황">예: 콘서트 티켓 예매 상황</h3>
<p>내가 세계적으로 유명한 아이돌 그룹 콘서트의 예매 사이트를 운영하는 개발자라고 생각해보자.
지금부터 티켓 예매가 시작되고 시간이 지나, 티켓이 <strong>단 1장만</strong> 남아있는 상황이다.</p>
<p>동시성 제어가 적용되지 않은 서버의 처리 방식을 생각해보면,</p>
<ul>
<li>사용자 A가 예매 요청을 보냄</li>
<li>거의 동시에 사용자 B도 예매 요청을 보냄</li>
<li>서버는 두 요청을 동시에 받아들임</li>
<li>A와 B 둘 다 &quot;티켓 있음!&quot;이라는 응답을 받음</li>
<li>둘 다 결제 성공 → 티켓은 1장인데 2장이 팔려버림</li>
</ul>
<p>이러면 좌석 충돌, 예매 기록 중복, 환불 처리 등 운영 리스크가 발생하고 팬들의 불만이 폭주할 것이다.</p>
<p>또 다른 예로 코레일 예매 사이트가 이러한 동시성 문제를 해결하지 않은 채 개발이 되었다면 기차에 탑승은 이미 했는데 같은 좌석을 두고 승객 여러 명이 이러지도 저러지도 못하는 곤란한 상황이 발생할 것이다.</p>
<p>이런 문제를 <strong>&#39;동시성 문제&#39;</strong>라고 부르며, 이를 해결하기 위한 기술이 바로 <strong>&#39;동시성 제어&#39;</strong>다.</p>
<blockquote>
<p>이런 동시성 문제는 단순히 &quot;티켓이 동시에 두 명에게 팔렸다&quot;는 현상에서 끝나지 않는다.<br><strong>시스템 내부적으로는 데이터베이스나 애플리케이션에서 발생하는 데이터 읽기/쓰기 충돌</strong>로 나타나며, 구체적으로는 다음과 같은 유형으로 분류된다.</p>
</blockquote>
<p>** 1. Dirty Read (더티 리드) **
커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상이다.
예를 들어 A 사용자의 예매 트랜잭션이 아직 끝나지 않았는데, 
B 사용자가 그 중간 상태의 데이터를 읽고 &quot;아직 티켓이 남아 있다&quot;고 잘못 판단하는 상황이다.</p>
<p>** 2. Lost Update (업데이트 손실) **
두 사용자가 거의 동시에 같은 데이터를 수정하고, 한 쪽의 수정 결과가 덮어씌워지는 경우다.
A, B 둘 다 예매를 성공했다고 판단되지만, 마지막에 처리된 B의 요청이 A의 예매 정보를 덮어버리는 식이다.</p>
<p>** 3. Non-Repeatable Read (반복 불일치 읽기) **
같은 쿼리를 두 번 수행했는데 결과가 달라지는 상황이다.
A 사용자가 티켓을 조회했을 땐 남아 있었지만, 결제를 진행하는 동안 B 사용자가 예매를 완료해서 A가 다시 조회했을 때는 티켓이 사라진 경우다.</p>
<p>** 4. Phantom Read (팬텀 리드) **
같은 조건으로 데이터를 조회했는데, 중간에 데이터가 추가되거나 삭제되어 결과가 바뀌는 현상이다.
예를 들어 예매 가능한 좌석 리스트를 조회했는데, 누군가가 그 사이에 좌석을 예매하거나 추가해서 리스트가 달라지는 경우가 여기에 해당한다.</p>
<h3 id="이런-동시성-문제는-어디에서-발생할까">이런 동시성 문제는 어디에서 발생할까?</h3>
<p>동시성 문제는 단순히 한 지점에서만 생기는 것이 아니다. 애플리케이션의 구조와 동작 방식에 따라 다양한 곳에서 발생할 수 있다.</p>
<p><strong>첫째,</strong> 자바 애플리케이션 내부에서 발생할 수 있다. 예를 들어, 여러 스레드가 동시에 같은 예매 메서드를 호출하게 되면, 객체의 상태가 충돌하는 문제가 생길 수 있다.</p>
<p><strong>둘째,</strong> 데이터베이스 수준에서도 문제는 발생한다. 여러 트랜잭션이 동시에 같은 데이터를 수정하려고 하면, 트랜잭션 충돌이나 데이터 손실이 생길 수 있다.</p>
<p><strong>셋째,</strong> 시스템이 여러 대의 서버로 구성된 멀티 인스턴스 환경이라면, 서버 A와 서버 B가 동시에 같은 좌석을 처리하려고 하면 충돌이 발생할 수 있다. 이때는 애플리케이션 내부의 락으로는 부족하며, 모든 서버가 함께 공유할 수 있는 <strong>외부 자원 기반의 락</strong>, 즉 <strong>분산 락(distributed lock)</strong>과 같은 기술이 필요하다.</p>
<p>이처럼 동시성 문제는 다양한 지점에서 발생하며, <strong>상황에 맞는 제어 기법을 선택하는 것이 매우 중요하다.</strong> 그렇다면 이러한 문제들을 해결하기 위해 어떤 방법들이 존재할까?</p>
<hr>
<h2 id="2-동시성-문제-해결-방법">2. 동시성 문제 해결 방법</h2>
<p>앞서 동시성 문제는 다양한 위치(애플리케이션, DB, 서버 간)에 걸쳐 발생한다고 했다.
그렇기에 해결 방법은 상황과 시스템 구조에 따라 다양하지만, <strong>크게 다음 세 가지 범주로 나눌 수 있다.</strong></p>
<h3 id="1-애플리케이션자바-단에서의-락-제어">1. 애플리케이션(자바) 단에서의 락 제어</h3>
<blockquote>
<p>멀티스레드 환경에서 하나의 자원을 동시에 접근하지 못하도록 JVM 내부에서 락을 거는 방식이다. <strong>주로 단일 서버 환경 또는 로컬 테스트에서 사용된다.</strong></p>
</blockquote>
<h4 id="11-synchronized">1.1. synchronized</h4>
<p>가장 기본적인 자바 락 메커니즘.
<strong>한 번에 하나의 스레드만 해당 메서드나 코드 블록에 접근할 수 있도록 한다.</strong></p>
<pre><code class="language-java">public class TicketService {
    private int ticketCount = 1;

    public synchronized boolean bookTicket(String user) {
        if (ticketCount &lt;= 0) return false;
        ticketCount--;
        System.out.println(user + &quot; 예매 성공!&quot;);
        return true;
    }
}</code></pre>
<ul>
<li><strong>장점:</strong> 사용이 간단함</li>
<li><strong>단점:</strong> JVM 수준에서만 통용되며, 서버가 여러 대면 효과 없음</li>
</ul>
<hr>
<h4 id="12-reentrantlock">1.2. ReentrantLock</h4>
<p>synchronized보다 더 유연하고 강력한 락 제어 도구.
<strong>락 획득, 해제를 명시적으로 제어할 수 있으며 타임아웃, 공정성 설정도 가능하다.</strong></p>
<pre><code class="language-java">private final Lock lock = new ReentrantLock();

public boolean bookTicket(String user) {
    lock.lock();
    try {
        if (ticketCount &lt;= 0) return false;
        ticketCount--;
        return true;
    } finally {
        lock.unlock(); // 반드시 해제!
    }
}</code></pre>
<ul>
<li><code>tryLock()</code>으로 대기 없이 락 획득 시도 가능</li>
<li><code>lockInterruptibly()</code>로 인터럽트 대응 가능</li>
</ul>
<hr>
<h4 id="13-atomicinteger--atomiclong">1.3. AtomicInteger / AtomicLong</h4>
<p>락 없이도 동시성 안전한 연산을 제공하는 클래스.
내부적으로 CAS (Compare-And-Swap) 알고리즘을 사용한다.</p>
<pre><code class="language-java">private final AtomicInteger ticketCount = new AtomicInteger(1);

public boolean bookTicket(String user) {
    int remain = ticketCount.get();
    if (remain &lt;= 0) return false;
    int updated = ticketCount.decrementAndGet();
    return updated &gt;= 0;
}</code></pre>
<ul>
<li><strong>장점:</strong> 빠르고 간단</li>
<li><strong>단점:</strong> 조건이 복잡한 로직에는 적합하지 않음</li>
</ul>
<hr>
<h4 id="14-key별-락-concurrenthashmap-활용">1.4. Key별 락 (ConcurrentHashMap 활용)</h4>
<p>좌석 ID 같은 특정 자원마다 개별 락을 걸어 제어하는 방식</p>
<pre><code class="language-java">private final ConcurrentHashMap&lt;String, ReentrantLock&gt; lockMap = new ConcurrentHashMap&lt;&gt;();

public void lock(String key) {
    lockMap.computeIfAbsent(key, k -&gt; new ReentrantLock()).lock();
}

public void unlock(String key) {
    lockMap.get(key).unlock();
}</code></pre>
<ul>
<li><strong>장점:</strong> 리소스 단위로 미세한 락 제어 가능</li>
<li><strong>단점:</strong> JVM 내부에서만 작동 → 분산 환경에서는 무효</li>
</ul>
<hr>
<h3 id="2-데이터베이스에서의-락-제어">2. 데이터베이스에서의 락 제어</h3>
<blockquote>
<p>DB 트랜잭션 수준에서 락을 걸어 동시에 동일한 데이터를 조작하지 못하게 막는 방식이다.</p>
</blockquote>
<h4 id="21-비관적-락-pessimistic-lock">2.1. 비관적 락 (Pessimistic Lock)</h4>
<p>“어차피 충돌 날 거야, 미리 잠가두자”는 방식</p>
<pre><code class="language-sql">SELECT * FROM seat WHERE id = 1 FOR UPDATE;</code></pre>
<p>트랜잭션 범위 내에서 해당 데이터를 다른 트랜잭션이 수정하지 못하게 락을 건다
JPA에서는 <code>@Lock(LockModeType.PESSIMISTIC_WRITE)</code>로 사용 가능</p>
<ul>
<li><strong>단점:</strong> 데드락 위험, 성능 저하 가능성</li>
</ul>
<hr>
<h4 id="22-낙관적-락-optimistic-lock">2.2. 낙관적 락 (Optimistic Lock)</h4>
<p>“충돌 안 날 거야. 대신 나중에 충돌했는지 확인하자”</p>
<p>버전 번호를 두고, 충돌 발생 시 롤백한다.
JPA에서는 <code>@Version</code>을 사용한다.</p>
<pre><code class="language-java">@Version
private Long version;</code></pre>
<ul>
<li><strong>장점:</strong> 충돌 가능성 낮은 경우 성능 우수</li>
<li><strong>단점:</strong> 충돌 시 재시도 필요</li>
</ul>
<hr>
<h3 id="3-분산-환경에서의-락-제어">3. 분산 환경에서의 락 제어</h3>
<blockquote>
<p>서버가 여러 대인 분산 환경에서는, JVM이나 DB 수준의 락만으로는 자원 충돌을 막기에 한계가 있다. 
이럴 땐 모든 서버가 함께 접근 가능한 공통 저장소(예: Redis, ZooKeeper 등)를 기반으로 락을 제어해야 한다. 
<strong>여기서는 실무에서 가장 널리 쓰이는 Redis를 중심으로 분산 락 구현 방법을 정리해보겠다.</strong></p>
</blockquote>
<h4 id="31-redis-명령어-기반-락">3.1. Redis 명령어 기반 락</h4>
<p>Redis는 빠르고 경량화된 인메모리 데이터베이스로, 분산 시스템에서 락을 구현하기 위한 중앙 저장소 역할을 한다.
가장 기본적인 락 구현 방식은 Redis의 <code>SETNX</code>와 <code>EXPIRE</code> 명령어를 조합하는 것이다.</p>
<pre><code class="language-bash">SETNX lock:seat:1 &lt;UUID&gt;
EXPIRE lock:seat:1 5</code></pre>
<ul>
<li><code>SETNX</code> (SET if Not Exists): 해당 키가 존재하지 않을 때만 값을 설정 → <strong>락 획득</strong></li>
<li><code>EXPIRE</code> (Expire Time): TTL(Time To Live) 설정 → <strong>락이 영구적으로 점유되지 않도록 제한</strong><br>
#### 락 획득 예시 흐름
Redis를 이용한 분산 락의 기본 흐름은 다음과 같다.
</li>
</ul>
<ol>
<li><p>클라이언트 A가 <code>SETNX lock:seat:1 UUID_A</code> 명령을 보낸다.
→ <strong>해당 키가 없으면 락을 획득하고, 이미 존재하면 실패한다</strong>.</p>
</li>
<li><p>락을 획득한 경우, 이어서 <code>EXPIRE lock:seat:1 5</code> 명령을 실행해
→ <strong>락의 유효 시간을 5초로 설정한다. (TTL)</strong></p>
</li>
<li><p>클라이언트 A는 예매 처리와 같은 핵심 비즈니스 로직을 수행한다.</p>
</li>
<li><p>처리가 완료되면 <code>DEL lock:seat:1</code> 명령으로 락을 해제한다.</p>
</li>
</ol>
<hr>
<h4 id="32-redis-직접-구현의-위험성">3.2. Redis 직접 구현의 위험성</h4>
<p>위 방식은 단순해 보이지만, 실제 서비스 환경에서는 아래와 같은 문제가 발생할 수 있다.</p>
<h4 id="1-락-획득과-ttl-설정-사이-장애-발생">1. 락 획득과 TTL 설정 사이 장애 발생</h4>
<pre><code class="language-bash">SETNX lock:seat:1 UUID_A  # 성공
# EXPIRE 실행 전에 서버 다운
EXPIRE lock:seat:1 5      # 실행되지 않음</code></pre>
<p>→ TTL 설정이 빠지면 락이 영구히 유지되어 <strong>데드락 발생</strong></p>
<blockquote>
<p><strong>해결책:</strong> <code>SET key value NX EX 5</code> 명령어를 사용하면 원자적으로 설정 가능 (Redis 2.6.12 이상)</p>
</blockquote>
<pre><code class="language-bash">SET lock:seat:1 UUID_A NX EX 5</code></pre>
<h4 id="2-락-해제-시-다른-사용자의-락을-잘못-삭제">2. 락 해제 시, 다른 사용자의 락을 잘못 삭제</h4>
<p>락을 DEL로 해제할 때 단순히 키만 보고 지우면,
락이 만료되어 다른 사용자가 새로 잡은 락도 지워버릴 수 있다.</p>
<p>예를 들어 다음과 같은 상황을 생각해보자.</p>
<p>A가 <code>UUID_A</code>로 락을 획득했지만 TTL이 만료되었고, 그 직후 B가 <code>UUID_B</code>로 락을 재획득했다.
그런데 A가 뒤늦게 작업을 마치고 <code>DEL lock:seat:1</code>을 실행하면,
<strong>B가 획득한 락까지 삭제되어버리는 심각한 문제가 발생할 수 있다.</strong></p>
<blockquote>
<p><strong>해결책:</strong> 락 해제 전에 <code>value(UUID)</code> 비교 → Lua 스크립트를 사용하여 안전하게 해제</p>
</blockquote>
<pre><code class="language-java">if redis.call(&quot;GET&quot;, KEYS[1]) == ARGV[1] then
    return redis.call(&quot;DEL&quot;, KEYS[1])
else
    return 0
end</code></pre>
<hr>
<h4 id="33-그래서-redisson을-사용한다">3.3. 그래서 Redisson을 사용한다</h4>
<p>이처럼 직접 Redis 명령어로 락을 구현하면 위험 요소가 많고 복잡하다.
그래서 실무에서는 <strong>Redisson, Lettuce</strong> 같은 Redis 클라이언트 라이브러리를 사용한다.</p>
<p>그 중에서도 Redisson은</p>
<ul>
<li>분산 락 구현에 필요한 기능을 완비하고 있으며</li>
<li>락 획득, 자동 해제, 예외 처리, 공정 락, 멀티 락 등 고급 기능까지 지원</li>
<li>내부적으로 Lua 스크립트를 사용하여 락 해제 안전성까지 보장</li>
</ul>
<p>그렇다면 Redisson을 실제로 어떻게 적용할 수 있을까?  </p>
<hr>
<h2 id="3-redisson을-활용한-분산-락-구현기">3. Redisson을 활용한 분산 락 구현기</h2>
<p>Redisson은 락 획득/해제의 안정성과 다양한 락 유형 지원 덕분에<br>멀티 서버 환경에서도 신뢰할 수 있는 동시성 제어 수단으로 널리 사용된다.</p>
<p>이번 파트에서는 <strong>AOP를 이용해 락 처리 로직을 비즈니스 코드에서 분리하고 LockService 구조를 통해 재사용성과 유연성까지 확보한 구현 방식</strong>을 소개하며, 어떻게 테스트하고 검증했는지 단계별로 설명한다.
<br></p>
<h3 id="1-redisson-설정">1. Redisson 설정</h3>
<h4 id="11-buildgradle">1.1. build.gradle</h4>
<pre><code class="language-java">// aop
implementation &quot;org.springframework.boot:spring-boot-starter-aop&quot;

// redisson
implementation &#39;org.redisson:redisson-spring-boot-starter:3.46.0&#39;</code></pre>
<hr>
<h4 id="12-applicationyml">1.2. application.yml</h4>
<pre><code class="language-java">data:
  redis:
    host: localhost
    port: 6379</code></pre>
<hr>
<h4 id="13-redisconfig">1.3. RedisConfig</h4>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Value(&quot;${data.redis.host}&quot;)
    private String redisHost;

    @Value(&quot;${data.redis.port}&quot;)
    private int redisPort;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress(&quot;redis://&quot; + redisHost + &quot;:&quot; + redisPort);
        return Redisson.create(config);
    }
}</code></pre>
<hr>
<h3 id="2-lockservice-추상화">2. LockService 추상화</h3>
<p>Redisson을 사용할 때 가장 흔히 할 수 있는 실수 중 하나는,
서비스 로직 곳곳에 <code>lock.tryLock()</code>과 <code>unlock()</code>을 매번 직접 작성한다는 점이다.</p>
<p>이렇게 하면 코드 중복도 많아지고, <strong>예외 처리나 락 해제 누락 같은 실수도 쉽게 발생한다.</strong>
그래서 나는 락 처리 흐름을 재사용 가능한 구조로 추상화하기 위해
<code>LockRedisService</code> 인터페이스와 이를 구현한 <code>RedissonLockRedisService</code> 클래스를 도입했다.</p>
<h4 id="공통-구조로-감싸는-이유">공통 구조로 감싸는 이유</h4>
<p>락은 보통 다음과 같은 흐름으로 사용된다.</p>
<ol>
<li><code>tryLock()</code>으로 락 획득 시도</li>
<li>락을 얻었다면 비즈니스 로직 실행</li>
<li>종료 후 <code>unlock()</code> 호출</li>
<li>예외 상황도 고려해야 함 <strong>(락 점유 실패, 로직 수행 중 예외 등)</strong></li>
</ol>
<p>이 전체 과정을 매번 작성하는 대신, 다음처럼 공통 메서드에 람다 형태로 위임하면 로직은 깔끔해지고, 실수 가능성도 줄일 수 있다. 
다만 람다 내부에 복잡한 로직이 들어가면 오히려 가독성이 떨어질 수 있으므로, 적절한 분리와 단순화를 함께 고려해야 한다.</p>
<hr>
<h4 id="21-lockredisservice">2.1. LockRedisService</h4>
<pre><code class="language-java">public interface LockRedisService {
    &lt;T&gt; T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier&lt;T&gt; action);

    &lt;T&gt; T executeWithMultiLock(List&lt;String&gt; key, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier&lt;T&gt; action);
}</code></pre>
<ul>
<li><code>executeWithLock()</code>: 단일 리소스 락 처리</li>
<li><code>executeWithMultiLock()</code>: 여러 리소스에 대해 동시에 락을 걸어야 할 경우</li>
</ul>
<blockquote>
<p>모든 메서드는 <code>ThrowingSupplier&lt;T&gt;</code>를 받아 예외가 있는 경우도 처리 가능하게 했다.</p>
</blockquote>
<hr>
<h4 id="22-redissonlockredisservice">2.2. RedissonLockRedisService</h4>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class RedissonLockRedisService implements LockRedisService {

    private final LockRedisRepository lockRedisRepository;

    @Override
    public &lt;T&gt; T executeWithLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier&lt;T&gt; action) {
        boolean locked = lockRedisRepository.lock(key, waitTime, leaseTime, timeUnit);
        if (!locked) {
            throw new LockRedisException(LockRedisExceptionCode.LOCK_TIMEOUT);
        }
        try {
            return action.get();
        } catch (Throwable t) {
            throw wrapThrowable(t);
        } finally {
            lockRedisRepository.unlock(key);
        }
    }

    @Override
    public &lt;T&gt; T executeWithMultiLock(List&lt;String&gt; keys, long waitTime, long leaseTime, TimeUnit timeUnit, ThrowingSupplier&lt;T&gt; action) {
        List&lt;String&gt; lockedKeys = new ArrayList&lt;&gt;();
        try {
            for (String key : keys) {
                if (!lockRedisRepository.lock(key, waitTime, leaseTime, timeUnit)) {
                    throw new LockRedisException(LockRedisExceptionCode.LOCK_TIMEOUT);
                }
                lockedKeys.add(key);
            }
            return action.get();
        } catch (Throwable t) {
            throw wrapThrowable(t);
        } finally {
            for (String key : lockedKeys) {
                lockRedisRepository.unlock(key);
            }
        }
    }

    private RuntimeException wrapThrowable(Throwable t) {
        if (t instanceof LockRedisException e) return e;
        if (t instanceof RuntimeException e) return e;
        if (t instanceof Error e) throw e;
        return new LockRedisException(LockRedisExceptionCode.LOCK_PROCEED_FAIL);
    }
}</code></pre>
<ul>
<li><strong>락 획득 실패 시 예외 처리</strong>: <code>lock()</code>이 false를 반환하면 즉시 <code>LOCK_TIMEOUT</code> 예외를 던져, 실패 여부를 명확히 판단할 수 있다.</li>
<li><strong>예외 일관성 확보</strong>: 람다 내부에서 발생한 <code>Throwable</code>은 <code>wrapThrowable()</code>을 통해 RuntimeException 또는 커스텀 예외로 안전하게 감싸 예외 흐름을 통일한다.</li>
<li><strong>unlock 보장</strong>: <code>finally</code> 블록에서 무조건 <code>unlock()</code>을 호출하며, 락 소유 여부 <code>isHeldByCurrentThread()</code> 확인은 내부적으로 Repository 레벨에서 처리하도록 위임했다.</li>
</ul>
<hr>
<h4 id="23-실제-사용-예시">2.3. 실제 사용 예시</h4>
<pre><code class="language-java">lockRedisService.executeWithLock(&quot;lock:seat:&quot; + seatId, 3, 5, TimeUnit.SECONDS, () -&gt; {
    // 좌석 예매 처리 로직
    return bookingService.createBooking(user, requestDto);
});</code></pre>
<p>이렇게 작성하면 락 관련 로직은 공통으로 처리되고, 비즈니스 로직만 깔끔하게 남는다.</p>
<hr>
<h3 id="3-aop를-활용한-자동-락-적용">3. AOP를 활용한 자동 락 적용</h3>
<p><code>LockRedisService</code>로 공통 락 처리 흐름을 만들었다면,
이제는 이를 더 간결하고 일관성 있게 적용하기 위해 <strong>AOP(관점 지향 프로그래밍)</strong>를 활용할 수 있다.</p>
<blockquote>
<p><strong>핵심 아이디어는 비즈니스 메서드에 락 로직을 직접 작성하지 않고, 
어노테이션만 붙이면 락을 자동으로 적용하도록 만드는 것이다.</strong></p>
</blockquote>
<h4 id="31-커스텀-어노테이션-정의">3.1. 커스텀 어노테이션 정의</h4>
<p>우선 메서드에 락을 적용하기 위한 전용 어노테이션을 만든다.</p>
<pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonMultiLock {
    String key();

    String group() default &quot;&quot;;

    long waitTime() default 3L;

    long leaseTime() default 5L;

    TimeUnit timeUnit() default TimeUnit.SECONDS;
}</code></pre>
<ul>
<li><code>key</code>: 락을 걸 리소스의 식별자 리스트를 추출하기 위한 SpEL 표현식</li>
<li><code>group</code>: 락 키를 구분짓기 위한 접두사/이름 공간(namespace) 역할</li>
<li><code>waitTime</code>: 락을 획득하기 위해 기다리는 최대 시간</li>
<li><code>leaseTime</code>: 락을 획득한 후 유지되는 시간 (이후 자동 해제됨)</li>
<li><code>timeUnit</code>: 시간 단위 (기본: 초)</li>
</ul>
<hr>
<h4 id="32-aop-구현-redissonmultilockaspect">3.2. AOP 구현: RedissonMultiLockAspect</h4>
<pre><code class="language-java">@Aspect
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RedissonMultiLockAspect {

    private final LockRedisService lockRedisService;
    private final ExpressionParser parser = new SpelExpressionParser();

    @Around(&quot;@annotation(nbc.ticketing.ticket911.common.annotation.RedissonMultiLock)&quot;)
    public Object lock(ProceedingJoinPoint joinPoint) {
        Method method = ((MethodSignature)joinPoint.getSignature()).getMethod();
        RedissonMultiLock annotation = method.getAnnotation(RedissonMultiLock.class);

        List&lt;String&gt; lockKeys;
        List&lt;String&gt; dynamicKeys = parseKeyList(annotation.key(), joinPoint);
        lockKeys = dynamicKeys.stream()
            .map(key -&gt; buildLockKey(annotation.group(), key))
            .toList();
        return lockRedisService.executeWithMultiLock(
            lockKeys,
            annotation.waitTime(),
            annotation.leaseTime(),
            annotation.timeUnit(),
            joinPoint::proceed
        );
    }

    private List&lt;String&gt; parseKeyList(String keyExpression, ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        String[] paramNames = signature.getParameterNames();
        Object[] args = joinPoint.getArgs();

        EvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i &lt; Objects.requireNonNull(paramNames).length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }

        Object result = parser.parseExpression(keyExpression).getValue(context);
        if (result instanceof List&lt;?&gt; list) {
            return list.stream().map(String::valueOf).toList();
        }
        throw new LockRedisException(LockRedisExceptionCode.LOCK_KEY_EVALUATION_FAIL);
    }

    private String buildLockKey(String group, String key) {
        return String.format(&quot;lock:%s:%s&quot;, group, key);
    }
}</code></pre>
<ul>
<li><code>@Around</code>: AOP를 통해 락이 필요한 메서드를 감싼다</li>
<li><code>SpEL</code>: 파라미터 기반으로 동적인 락 키 리스트를 추출한다 (<code>#bookingRequestDto.seatIds</code>)</li>
<li><code>buildLockKey()</code>: group 값을 포함한 실제 Redis 키로 변환한다 (<code>lock:concertSeat:{seatId}</code>)</li>
<li><code>executeWithMultiLock()</code>: 모든 락을 획득한 경우에만 메서드를 실행하며, 실패 시 예외 발생</li>
</ul>
<br>

<hr>
<h4 id="33-트러블슈팅-orderorderedhighest_precedence가-무슨-역할인가">3.3. 트러블슈팅: <code>@Order(Ordered.HIGHEST_PRECEDENCE)</code>가 무슨 역할인가?</h4>
<p>Redisson 락은 단순히 트랜잭션과 병렬로 실행된다고 해서 안전한 것이 아니다.
<strong>락은 트랜잭션보다 먼저 시작되고, 트랜잭션 커밋이 끝날 때까지 유지되어야만 동시성 문제가 발생하지 않는다.</strong></p>
<p>그런데 <code>@Transactional</code>도 사실은 Spring AOP 기반으로 동작하기 때문에 락 AOP와 트랜잭션 AOP는 <strong>동일한 AOP 체인 상에서 실행 순서가 결정되며,</strong> <code>@Order</code>를 명시하지 않으면 트랜잭션보다 늦게 실행된 락이 트랜잭션 커밋 전에 먼저 해제되는 시나리오가 발생할 수 있다.
즉, 트랜잭션이 아직 커밋되지 않았는데 락이 먼저 풀리면서, 다른 사용자가 락을 획득하고 트랜잭션을 시작해 버리는 <strong>Race Condition이 발생할 수 있는 것이다.</strong></p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/4453b6a2-6f03-4875-bd57-3e7c3206ee6a/image.png" alt=""></p>
<p>위 시퀀스 다이어그램에서도 볼 수 있듯,
User A는 트랜잭션 커밋 전에 락을 해제했고, 그 사이 User B가 락을 획득해 트랜잭션을 시작해 버렸다.
그 결과, User B는 아직 커밋되지 않은 상태의 데이터를 기반으로 예매를 진행했고, 중복 예매가 발생했다.</p>
<p>이 문제는 <code>@Order(Ordered.HIGHEST_PRECEDENCE)</code>를 설정해
<strong>Redisson 락 AOP가 트랜잭션보다 먼저 실행되도록 보장함으로써 해결</strong>할 수 있었다.
즉, 락은 트랜잭션 전체를 감싸고, 트랜잭션 커밋이 완료된 후에야 락이 해제되므로
다른 사용자 요청이 그 사이 끼어들 여지가 완전히 사라진다.</p>
<p>실제로 JMeter를 이용해 100건 이상의 동시 요청을 테스트했을 때,
<code>@Order</code>를 설정하지 않은 경우에는 <strong>간헐적으로 2~3건의 중복 예매가 발생했다.</strong>
반면 <code>@Order(Ordered.HIGHEST_PRECEDENCE)</code>를 설정한 이후에는
1000건 이상의 요청 중에도 항상 단 1건만 성공하며 완벽한 동시성 제어가 가능함을 확인할 수 있었다.</p>
<hr>
<h4 id="34-실제-사용-예시">3.4. 실제 사용 예시</h4>
<pre><code class="language-java">@RedissonMultiLock(key = &quot;#bookingRequestDto.seatIds&quot;, group = &quot;concertSeat&quot;)
@Transactional
public BookingResponseDto createBookingByRedisson(
    AuthUser authUser, 
    BookingRequestDto bookingRequestDto) {
    // 좌석 예매 처리 로직
    return bookingService.createBooking(user, requestDto);
}</code></pre>
<p>락을 어떤 리소스(여기선 좌석 ID 리스트)를 기준으로 걸지 key에 명시하면 된다.
락 획득 여부나 충돌 방지는 모두 <code>AOP</code>와 <code>LockRedisService</code>가 책임지며,
비즈니스 로직은 락 처리 로직과 완전히 분리되어 깔끔하게 유지된다.</p>
<hr>
<h2 id="4-jmeter를-활용해-테스트">4. JMeter를 활용해 테스트</h2>
<p>Redisson 분산 락이 실제 서비스 환경에서 동시성 문제를 제대로 방어할 수 있는지 확인하기 위해, 단위 테스트가 아닌 <strong>JMeter를 활용한 부하 테스트(Load Test)</strong>를 진행했다.</p>
<p>특히 동일한 좌석에 대해 동시에 여러 사용자가 예매 요청을 보내는 시나리오를 구성하고,<br>Redisson 락이 적용된 API와 적용되지 않은 API를 비교 테스트했다.</p>
<h3 id="1-테스트-시나리오">1. 테스트 시나리오</h3>
<ul>
<li><strong>API:</strong> <code>POST /bookings</code> <strong>(Redisson 적용 vs 미적용 비교)</strong></li>
<li><strong>요청 본문:</strong> 같은 좌석 ID를 포함한 예매 요청 → 예: <code>{ &quot;seatIds&quot;: [1] }</code></li>
<li><strong>동시 요청 수:</strong> <code>2000건</code></li>
<li><strong>목적: 중복 예매 발생 여부 확인</strong></li>
</ul>
<hr>
<h3 id="2-jmeter-테스트-설정">2. JMeter 테스트 설정</h3>
<h4 id="21-thread-group-설정">2.1. Thread Group 설정</h4>
<ul>
<li><strong>Threads:</strong> <code>2000</code></li>
<li><strong>Ramp-Up:</strong> <code>0초</code></li>
<li><strong>Loop:</strong> <code>1회</code>
<img src="https://velog.velcdn.com/images/harvard--/post/83ab83ef-f947-40f4-839b-8bcb27de1693/image.png" alt=""></li>
</ul>
<h4 id="22-http-request-설정">2.2. HTTP Request 설정</h4>
<ul>
<li><strong>Method:</strong> <code>POST</code></li>
<li><strong>URL:</strong> <code>http://localhost:8080/bookings</code></li>
<li><strong>Body Data:</strong> <code>{ &quot;seatIds&quot;: [1] }</code> &amp; <code>{ &quot;seatIds&quot;: [2] }</code></li>
<li><strong>Header:</strong> <code>Authorization: Bearer {token}</code></li>
</ul>
<hr>
<h3 id="3-테스트-결과">3. 테스트 결과</h3>
<h4 id="31-락-미적용-api">3.1. 락 미적용 API</h4>
<ul>
<li><strong>응답 상태:</strong> <code>200 OK</code>가 2건 이상 존재 → <strong>중복 예매 발생</strong>
<img src="https://velog.velcdn.com/images/harvard--/post/cc2bed5b-955a-4c96-aefe-be3a8500d339/image.png" alt=""><br>
#### 3.2. Redisson 락 적용 API
- **응답 상태:** `200 OK`가 1건만 존재 → **동시성 문제 방지 성공**
![](https://velog.velcdn.com/images/harvard--/post/1885aa88-568e-405c-bcb6-8626521d7cc4/image.png)


</li>
</ul>
<blockquote>
<p>Redisson 락을 통해 실제 트래픽 상황에서도 중복 예매와 데이터 충돌 없이 
<strong>단 1건의 성공 요청만 처리되었음을 확인할 수 있었다.</strong>
즉, Redisson 기반의 분산 락이 실서비스 환경에서도 충분히 효과적인 동시성 제어 수단이 될 수 있음을 증명한 셈이다.</p>
</blockquote>
<hr>
<h2 id="5-마무리-및-실무-적용-시-고려사항">5. 마무리 및 실무 적용 시 고려사항</h2>
<h3 id="1-실무-적용-시-주의사항">1. 실무 적용 시 주의사항</h3>
<ul>
<li><p>락은 반드시 <strong>꼭 필요한 핵심 로직에만 최소한으로 적용</strong>해야 한다.</p>
</li>
<li><p><code>leaseTime</code> 과 <code>waitTime</code>은 상황에 맞게 조절할 것
→ 너무 짧으면 조기 해제, 너무 길면 자원 낭비</p>
</li>
<li><p>락 키는 자원을 <strong>명확히 식별할 수 있는 고유값</strong>을 기준으로 설계 (예: <code>seat:123</code>)</p>
</li>
<li><p>멀티 락을 사용하는 경우 락 순서를 고정해 데드락을 예방해야 함 (예: 키 정렬)</p>
</li>
</ul>
<h3 id="2-redisson의-한계-및-대안-고려">2. Redisson의 한계 및 대안 고려</h3>
<ul>
<li><p>Redis 장애 발생 시 락 자체도 불안정해질 수 있음 → <strong>Redis Sentinel / Cluster 등 HA 구성 필수</strong></p>
</li>
<li><p><strong>글로벌 락(분산 시스템 전체를 아우르는 락)</strong>이 필요한 경우엔 <code>DB 락</code>, <code>Kafka</code>, <code>SQS</code>, <code>Zookeeper</code>와 같은 대안도 검토</p>
</li>
<li><p>모든 상황에 락이 필요한 건 아님 
→ 캐시 + 조건 쿼리, 낙관적 락(<code>@Version</code>) 등 <strong>가벼운 방법</strong>으로 대체할 수도 있음</p>
</li>
</ul>
<p><strong>(+추가) Redisson은 기본적으로 정합성을 중요시하기 때문에 느리다!</strong>
실제로 JMeter를 이용해 10,000건의 동시 요청을 보낸 결과, 다음과 같은 수치가 확인되었다.</p>
<ul>
<li><strong>Basic(락 없음)</strong>: 평균 응답 시간 785ms, TPS 1659.8/sec</li>
<li><strong>Lettuce 기반 Redis 락</strong>: 평균 응답 시간 370ms, TPS 1290.3/sec</li>
<li><strong>MySQL 기반 DB 락</strong>: 평균 응답 시간 1806ms, TPS 829.1/sec</li>
<li><strong>Redisson 분산 락</strong>: 평균 응답 시간 436ms, TPS 912.7/sec</li>
</ul>
<p><strong>락 해제 안전성, 멀티 락 처리, 분산 환경에서의 신뢰성 면에서 Redisson은 높은 수준의 안정성을 제공한다.</strong>
하지만 이러한 정합성 보호 기능들은 필연적으로 네트워크 오버헤드와 내부 연산 비용을 발생시킨다. </p>
<blockquote>
<p><strong>즉, Redisson의 도입은 정합성과 성능 사이의 trade-off를 고려한 선택이어야 한다.</strong></p>
</blockquote>
<h3 id="3-지속적인-모니터링과-실험-필요">3. 지속적인 모니터링과 실험 필요</h3>
<ul>
<li><p>운영 환경은 계속 변하고, 트래픽이나 예외 발생 패턴도 시간에 따라 달라질 수 있기 때문에 <strong>지속적인 모니터링과 조정이 필요하다.</strong></p>
</li>
<li><p>예를 들어 락 획득 실패가 자주 발생한다면, <code>waitTime</code>을 늘리거나 락의 범위를 좁히는 등의 개선이 필요할 수 있다.</p>
</li>
</ul>
<hr>
<blockquote>
<p><strong>지금까지 Redisson을 활용해 동시성 문제를 어떻게 해결할 수 있는지,
그리고 그것이 어떻게 효과를 발휘하는지를 단계별로 정리해보았다.</strong></p>
</blockquote>
<p>락은 단순한 기술 도입이 아니라 시스템의 안정성을 위한 선택이다.
특히 사용자 요청이 동시에 몰리는 상황에서는, <strong>데이터 정합성을 지키는 최소한의 안전장치가 된다.</strong></p>
<p>하지만 모든 로직에 락을 적용할 필요는 없다.
<strong>락은 신뢰를 위한 비용인 만큼, 충분히 충돌 가능성이 있는 핵심 영역에만 신중하게 적용해야 한다.</strong></p>
<p>그래서 도입할 땐 단순히 &quot;걸면 안전하겠지&quot;가 아니라, 언제, 어떻게, 어디에 최소한으로 적용할지를 고민하는 태도가 더욱 중요하다고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] static 메서드 도배 → 전략 패턴 적용기 (feat. 알라딘 OpenAPI)]]></title>
            <link>https://velog.io/@harvard--/Spring-static-%EB%A9%94%EC%84%9C%EB%93%9C-%EB%8F%84%EB%B0%B0-%EC%A0%84%EB%9E%B5-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%EA%B8%B0-feat-%EC%95%8C%EB%9D%BC%EB%94%98-OpenAPI</link>
            <guid>https://velog.io/@harvard--/Spring-static-%EB%A9%94%EC%84%9C%EB%93%9C-%EB%8F%84%EB%B0%B0-%EC%A0%84%EB%9E%B5-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9%EA%B8%B0-feat-%EC%95%8C%EB%9D%BC%EB%94%98-OpenAPI</guid>
            <pubDate>Sat, 10 May 2025 13:09:18 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>알라딘 OpenAPI를 통해 XML 응답을 받아 도서 정보를 추출하는 기능을 구현하면서, 처음에는 가독성과 쉬운 호출을 위해 간단한 <code>static</code> 유틸리티 메서드를 중심으로 로직을 구성했었다. <strong>코드리뷰도 받아보고, 추후 확장성을 생각해보니 구조적인 한계를 느꼈다.</strong></p>
<p>그 과정에서 전략 패턴을 적용해 구조를 리팩토링했고, 파싱 방식과 데이터 변환 책임을 분리해 더 유연하고 확장 가능한 구조로 개선할 수 있었다.</p>
<p>이 글은 그 과정을 정리해 둔 기록이며, 공부하면서 직접 구조를 바꿔 본 경험을 바탕으로 작성했기 때문에 내용이 길고 다소 가독성이 떨어질 수도 있다.</p>
<hr>
<h2 id="1-기존-코드의-문제점-static-기반-파싱-유틸">1. 기존 코드의 문제점: static 기반 파싱 유틸</h2>
<h4 id="bookxmlparser-초기-구조">BookXmlParser (초기 구조)</h4>
<pre><code class="language-java">public class BookXmlParser {
    public static BookDetailResponse parseBookDetail(Document document) { ... }
    public static List&lt;BookSearchItemResponse&gt; parseBookList(Document document) { ... }
}</code></pre>
<h4 id="xmlutils">XmlUtils</h4>
<pre><code class="language-java">public class XmlUtils {
    public static Document parseXmlFromUrl(URL url) { ... }
    public static String getTagValue(String tag, Element element) { ... }
    public static int parseIntOrZero(String value) { ... }
}</code></pre>
<p>초기에는 <code>BookXmlParser</code>, <code>XmlUtils</code>와 같이 모든 파싱 로직을 <code>static</code> 메서드로 구성한 유틸리티 클래스를 사용했다.
표면적으로는 간단하고 빠르게 동작하는 구조처럼 보였지만, 기능을 추가하거나 유지보수하는 상황을 가정해보면 다양한 구조적 한계가 드러났다.</p>
<p>가장 본질적인 문제는 <strong>책임이 명확히 분리되어 있지 않고, 여러 계층이 강하게 결합되어 있다는 점이다.</strong>
<code>BookXmlParser</code>는 다음과 같은 로직으로 구성되어 있었다.</p>
<ul>
<li><p><strong>XML DOM 객체(Document)에서 데이터를 추출하는 로직</strong>
→ 즉, <code>Document</code>로부터 특정 태그(<code>&lt;item&gt;</code>)를 찾아 <code>Element</code>로 접근하는 DOM 탐색 책임</p>
</li>
<li><p><strong>추출한 데이터를 DTO 객체로 변환하는 메서드를 호출하는 로직</strong>
→ <code>Element</code>를 어떻게 해석해 어떤 객체로 변환할지는 <code>from(Element)</code> 메서드에 위임되어 있지만, 그 메서드가 속한 <strong>구체 DTO 타입에 직접 의존하고 있기 때문에, <code>BookXmlParser</code>는 해당 DTO 클래스들과 구조적으로 강하게 결합된 상태다.</strong></p>
</li>
<li><p><strong>노드 탐색, 문자열 파싱 등 유틸성 기능은 DTO 내부에서 호출되고 있기 때문에,</strong></p>
</li>
<li><p><em><code>BookXmlParser → DTO 변환 메서드 → 유틸</code>*</em>로 이어지는 간접적인 호출 관계에 의해
유틸 내부 구현이 변경되면 <code>BookXmlParser</code>까지 영향을 받을 수 있는 구조다.</p>
</li>
</ul>
<p>예를 들어 다음과 같은 상황을 가정해보자.</p>
<pre><code class="language-java">// BookXmlParser 내부
BookDetailResponse.from((Element) node)

// BookDetailResponse 내부
.title(getTagValue(&quot;title&quot;, element))

// XmlUtils 내부
public static String getTagValue(String tag, Element element) {
    NodeList nodeList = element.getElementsByTagName(tag);
    if (nodeList.getLength() == 0) return &quot;&quot;; // 어느날 이 부분이 변경됨: &quot;&quot; → null
    return nodeList.item(0).getTextContent();
}

// BookService 내부
BookDetailResponse response = BookXmlParser.parseBookDetail(document);
log.info(&quot;도서 제목: {}&quot;, response.getTitle().toUpperCase()); // NullPointerException 발생 가능</code></pre>
<blockquote>
<p><strong>이처럼 BookXmlParser는 XmlUtils를 직접 호출하지 않더라도,
그걸 사용하는 DTO(BookDetailResponse)를 통해 간접적으로 영향을 받는 구조다.
유틸 내부 구현이 바뀌면, 그 결과가 BookXmlParser → Service → Controller까지도 전파될 수 있다.</strong></p>
</blockquote>
<hr>
<p>또한 <strong>모든 메서드가 static으로 구성되어 있었기 때문에</strong>, 다음과 같은 문제가 있다.</p>
<ul>
<li><p><strong>단위 테스트가 거의 불가능하다.</strong>
static 메서드는 DI(의존성 주입)의 대상이 아니기 때문에 mocking이나 대체가 불가능하고, 테스트 환경에서도 격리된 테스트가 어렵다.</p>
</li>
<li><p><strong>확장성도 매우 낮다.</strong>
예를 들어, 새로운 DTO 타입으로 XML을 파싱해야 할 경우 <code>BookXmlParser</code>에 또 다른 static 메서드를 추가해야 하며, 파싱 방식이 다양해질수록 <code>parseBookList()</code>, <code>parseBookDetail()</code>, <code>parseSpecialItemList()</code> 등과 같이 메서드 수가 폭발적으로 증가한다. 이는 <strong>OCP(Open-Closed Principle)</strong>를 위반하는 구조다.</p>
</li>
<li><p><strong>재사용성도 사실상 없다시피 하다.</strong>
각 메서드는 특정 DTO에 강하게 결합되어 있으며, 다른 타입의 객체로의 재사용이 거의 불가능하다. 공통 파싱 흐름 없이, 개별 상황에 맞게 다른 static 메서드를 직접 구현해야 했다.</p>
</li>
</ul>
<p>결국 이 구조는 <strong>테스트 불가</strong>, <strong>확장 어려움</strong>, <strong>유지보수 부담 증가</strong>, <strong>높은 결합도와 낮은 재사용성</strong>이라는 여러 문제가 있었다.</p>
<p>구조 개선의 필요성을 느꼈고, 이를 해결하기 위한 대안으로 전략 패턴을 도입해 리팩토링을 진행했다.</p>
<hr>
<h2 id="2-전략-패턴-적용을-통한-구조-개선">2. 전략 패턴 적용을 통한 구조 개선</h2>
<blockquote>
<p><strong>XML 파싱 방식(단건 vs 리스트)과 데이터 변환 책임을 명확히 분리한다.</strong>
<strong>각 책임을 인터페이스로 추상화하고, 조합 가능한 전략 객체로 만들어 유연하게 구성한다.</strong></p>
</blockquote>
<p>이를 위해 <strong>전략 패턴(Strategy Pattern)</strong>을 적용했고, 크게 세 가지 핵심 구조로 나눴다.</p>
<p><strong>전략 패턴(Strategy Pattern)이란?</strong>
전략 패턴은 객체의 행위를 동적으로 바꿀 수 있도록 해주는 디자인 패턴이다. 알고리즘을 정의하고 캡슐화하여 각각을 교체 가능하게 만든다. 이를 통해 로직 변경에 유연하게 대응하고, 코드의 재사용성을 높일 수 있다.</p>
<hr>
<h3 id="1-xmlelementmappert--엘리먼트-변환-전략">1. <code>XmlElementMapper&lt;T&gt;</code> – 엘리먼트 변환 전략</h3>
<pre><code class="language-java">public interface XmlElementMapper&lt;T&gt; {
    T fromElement(Element element);
}</code></pre>
<ul>
<li>이 인터페이스는 <strong>XML의 <code>&lt;item&gt;</code> 엘리먼트를 도메인 객체 T</strong>로 변환하는 전략을 정의한다.</li>
<li>구체 구현체는 예를 들어 <code>BookInfoMapper</code>와 같이 도메인별로 존재할 수 있다.</li>
<li>기존처럼 DTO에 직접 의존하지 않고도 유연하게 확장할 수 있는 구조를 만들 수 있다.
또한 각 변환 전략은 독립적으로 테스트가 가능하다.</li>
</ul>
<pre><code class="language-java">@Component
public class BookInfoMapper implements XmlElementMapper&lt;BookInfo&gt; {
    @Override
    public BookInfo fromElement(Element element) {
        return new BookInfo(
            getText(element, &quot;title&quot;),
            getText(element, &quot;author&quot;),
            ...
        );
    }
}</code></pre>
<h4 id="시도했지만-실패한-부분">시도했지만 실패한 부분</h4>
<p>매퍼는 XML 엘리먼트를 파싱하여 객체를 반환하는 책임을 갖는다.
<strong>여기서 “어떤 태그를 추출하느냐”는 객체의 필드 구조에 따라 달라진다.</strong>
<code>&quot;title&quot;</code>, <code>&quot;author&quot;</code>와 같이 하드코딩된 문자열을 해당 객체의 필드 구조에 따라 자동으로 매핑될 수 있다면, 보다 확장성과 유지보수성이 높은 구조가 될 수 있다고 판단했다.</p>
<p>이런 자동 매핑 구조를 고민해보았지만, 현재 시점에서는 실패했다.
객체 구조를 동적으로 반영하는 방식에 대해 더 공부를 해보려고 한다.</p>
<hr>
<h3 id="2-xmlparsestrategyt--파싱-방식-전략">2. <code>XmlParseStrategy&lt;T&gt;</code> – 파싱 방식 전략</h3>
<pre><code class="language-java">public interface XmlParseStrategy&lt;T&gt; {
    T parse(Document document);
}</code></pre>
<p>이 인터페이스는 파싱 방식 자체를 전략화한다.<br>예를 들어, 단일 <code>&lt;item&gt;</code>만 파싱하는 전략과 여러 개를 파싱하는 전략을 각각 별도 클래스로 구현할 수 있다.</p>
<h4 id="리스트-파싱-예시">리스트 파싱 예시</h4>
<pre><code class="language-java">public class ListParseStrategy&lt;T&gt; implements XmlParseStrategy&lt;List&lt;T&gt;&gt; {
    private final XmlElementMapper&lt;T&gt; mapper;

    @Override
    public List&lt;T&gt; parse(Document document) {
        NodeList nodes = document.getElementsByTagName(&quot;item&quot;);
        List&lt;T&gt; list = new ArrayList&lt;&gt;();
        for (int i = 0; i &lt; nodes.getLength(); i++) {
            ...
            list.add(mapper.fromElement((Element) node));
        }
        return list;
    }
}</code></pre>
<p>기존에는 <code>BookXmlParser</code>가 이 파싱 로직을 직접 수행했지만, 이제는 <strong>파싱 책임을 완전히 전략 객체로 분리</strong>함으로써 재사용성과 테스트 가능성이 모두 향상되었다.</p>
<h4 id="시도했지만-실패한-부분-1">시도했지만 실패한 부분</h4>
<pre><code class="language-java">NodeList nodes = document.getElementsByTagName(&quot;item&quot;);</code></pre>
<p>현재는 <code>&lt;item&gt;</code> 태그를 기준으로 파싱하고 있지만, 실제 API 명세에 따라 다른 루트나 구조를 파싱해야 할 경우 이 전략을 일반화하기 어려운 한계가 있었다.</p>
<p>예를 들어, <code>&lt;channel&gt;&lt;items&gt;&lt;item&gt;</code> 구조처럼 중첩된 경우,
혹은 리스트가 아닌 <code>&lt;book&gt;&lt;title&gt;</code>, <code>&lt;book&gt;&lt;author&gt;</code>처럼 하나의 루트 아래 여러 항목이 펼쳐진 구조는 지금 방식으로 대응하기 어렵다.</p>
<p>이를 해결하기 위해 범용적으로 사용할 수 있는 <strong>Element 추출 기준</strong>을 외부에서 주입해보는 것도 고려했지만, 현재 나의 기술 수준과 요구사항 범위를 고려해 적용하지는 못했다.
<strong>웹 API의 XML 구조를 범용적으로 추상화하는 건 생각보다 복잡한 문제</strong>인 것 같아 XML 구조에 더 유연하게 대응할 수 있는 방법을 공부해 볼 예정이다.</p>
<hr>
<h3 id="3-xmlparser--전략-실행-컨텍스트">3. XmlParser – 전략 실행 컨텍스트</h3>
<pre><code class="language-java">@Component
public class XmlParser {
    public &lt;T&gt; T parse(Document document, XmlParseStrategy&lt;T&gt; strategy) {
        return strategy.parse(document);
    }
}</code></pre>
<p>실제 파싱을 실행하는 XmlParser는 아무런 파싱 로직을 직접 갖고 있지 않다.
<code>XmlParseStrategy&lt;T&gt;</code>를 실행하는 전략 실행 컨텍스트의 역할만을 수행한다.
변화하는 로직은 전략 객체가 담당하고, 컨텍스트는 고정된 흐름(위임 방식)만 유지한다.</p>
<hr>
<h3 id="4-실제-서비스에서의-사용-방식">4. 실제 서비스에서의 사용 방식</h3>
<p>서비스 단에서는 다음과 같이 파싱 전략과 매퍼를 조합하여 사용한다.</p>
<pre><code class="language-java">// 리스트 파싱 전략
List&lt;BookInfo&gt; bookInfos = xmlParser.parse(document, new ListParseStrategy&lt;&gt;(mapper));

// 단건 파싱 전략
BookInfo bookInfo = xmlParser.parse(document, new SingleParseStrategy&lt;&gt;(mapper));</code></pre>
<ul>
<li>파싱 방식은 <code>ListParseStrategy</code> 또는 <code>SingleParseStrategy</code>로 선택</li>
<li>매핑 대상은 <code>XmlElementMapper&lt;BookInfo&gt;</code>의 구현체인 <code>BookInfoMapper</code></li>
</ul>
<p>기존에 <code>parseBookList()</code>, <code>parseBookDetail()</code> 등 각각의 static 메서드를 개별적으로 작성해야 했던 코드를 하나의 공통된 흐름으로 만들 수 있었다.</p>
<p>이 구조를 통해 책임은 명확하게 분리되고, 전략 조합만 바꾸면 다양한 XML 파싱 시나리오를 유연하게 처리할 수 있게 되었다. 또한 각 전략은 단위 테스트도 별도로 작성할 수 있게 되었다.</p>
<hr>
<h2 id="3-전략-패턴을-도입할-때-주의할-점">3. 전략 패턴을 도입할 때 주의할 점</h2>
<p>전략 패턴은 확실히 <strong>확장성과 테스트 용이성 면에서는 많은 장점이 있다.</strong>
파싱 방식이 다양해지고, 매핑 대상이 바뀌어도 전략 객체만 교체하면 되므로, 기존 구조보다 훨씬 유연하고 재사용성이 높다.</p>
<p><strong>하지만 전략 객체 구조가 무조건 옳거나 항상 효율적인 건 아니라는 점도 분명히 존재한다.</strong></p>
<h3 id="1-단순한-기능에는-과할-수-있다">1. 단순한 기능에는 과할 수 있다</h3>
<p>처음부터 구조를 잘게 나누고 객체로 쪼개다 보면, 오히려 전체 흐름을 파악하기 어려워질 수 있다.
XML을 한두 번 파싱해서 DTO 하나만 만들면 되는 간단한 기능에까지 전략 패턴을 도입하면,</p>
<ul>
<li><p><strong>구현체가 불필요하게 많아지고</strong></p>
</li>
<li><p><strong>로직이 여러 파일에 흩어져서</strong></p>
</li>
<li><p><strong>코드의 단순성이 사라지는 문제가 생긴다.</strong></p>
</li>
</ul>
<p>&quot;기능은 간단한데 구조만 복잡해 보인다&quot;는 느낌을 줄 수 있다.</p>
<hr>
<h3 id="2-추상화가-늘어나면-디버깅이-어려워진다">2. 추상화가 늘어나면 디버깅이 어려워진다</h3>
<p>전략 객체는 장점만큼 간접 호출과 추상화가 많다.
로직 흐름이 인터페이스 → 구현체 → 실행 컨텍스트로 흩어져 있기 때문에,</p>
<ul>
<li><strong>직접 메서드를 타고 들어가면서 디버깅하기 어려운 상황도 종종 생긴다.</strong></li>
</ul>
<p>이것이 러닝 커브를 높이는 원인이 되기도 하는 것 같다.
실제로 익숙하지 않은 팀원에게 코드를 보여줬을 때 구조를 이해하는 데 시간이 걸렸고 나 또한 처음 접하는 패턴이라 명확하게 설명하지 못한 경험이 있었다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음엔 기능만 동작하면 된다고 생각했던 파싱 로직에서 책임 분리, 결합도, 테스트 가능성 같은 구조적인 요소들이 얼마나 중요한지 아직까지는 추상적이지만 어느정도 체감할 수 있는 기회였다.</p>
<p>전략 패턴을 적용하면서 구조가 유연해지고 확장 가능해졌지만, 동시에 &quot;모든 기능에 패턴을 적용하는 것이 정말 필요한가?&quot;를 고민하게 되기도 했다. 지금 돌아보면, 이번 구조는 나에게 조금 과한 설계였다는 점도 분명히 느낀다.</p>
<p>그래서 다음부터는 패턴을 적용하기 전에 
<strong>&quot;이 기능이 충분히 바뀌고 확장될 가능성이 있을까?&quot;</strong> 아니면 
<strong>&quot;한 번 구현되고 끝나는 단순한 기능인가?&quot;</strong>를 먼저 판단해보는 습관을 가지려고 한다.</p>
<p><strong>그리고 개발은 정말 trade-off의 연속인 것 같다.</strong>
본인이 주관을 가지고 판단하려면 도대체 얼마나 많은 걸 알고 있어야 하는 것인지, 눈앞이 캄캄하다고 느껴지는 날이 요즘 특히 많다.</p>
<p>이 글은 언젠가 그런 판단을 내려야 할 나에게 도움이 되기 위해 정리한 글이다. 말로만 듣던 전략 패턴을 적용해 보니 뿌듯하긴 하다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 알라딘 검색 OpenAPI 연동]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%95%8C%EB%9D%BC%EB%94%98-%EA%B2%80%EC%83%89-OpenAPI-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%95%8C%EB%9D%BC%EB%94%98-%EA%B2%80%EC%83%89-OpenAPI-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Thu, 01 May 2025 14:30:45 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>현재 진행하고 있는 사이드 프로젝트에는 책 정보가 필요하다.
크롤링해서 DB에 직접 저장해두기엔 세상에 책이 너무 많아 <strong>효율도 안 나오고 무리다.</strong> 
입맛에 맞게 재가공한 책 데이터만 내 DB에 따로 보관하고 싶은데.. 라고 생각하던 찰나
<strong>알라딘과 같은 대형 서점들은 OpenAPI를 오래전부터 제공하고 있다는 걸 알게 됐다!</strong>
본인들의 소중한 데이터를 제공하면서 얻는 이득이 얼마나 되는진 모르겠지만.. 
<strong>감사히 잘 쓰려고 한다.</strong></p>
<hr>
<h2 id="1-키-발급">1. 키 발급</h2>
<p><strong><a href="https://blog.aladin.co.kr/openapi/category/29154404?start=we">알라딘 OpenAPI</a></strong></p>
<p>제일 위에 있는 포스팅에서 <strong>&quot;API 키 발급 및 URL 등록하기 ☞&quot;</strong> 를 클릭하면 이런 화면이 뜬다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/8879d593-3cab-4af2-84fc-f44c6d586959/image.png" alt=""></p>
<p>로그인 창이 뜰텐데, <strong>OAuth로 연동된 계정으로는 키 발급을 받을 수 없으니</strong> 간편 회원가입을 하자.
다음은 주소를 추가하는 부분에 본인 서버의 도메인을 넣은 뒤 추가 버튼을 클릭하면 된다.
나는 도메인이 따로 없어서 내 블로크 링크를 넣었다. 발급은 바로 된다.</p>
<p>인증 키는 어디에도 노출시키지 않는 습관을 들이자. 
<strong>그러다 AWS 같은 곳의 키를 유출하는 순간... 큰일난다.</strong>
Github 같은 곳에 올려놨다가 과금 폭탄 맞는 경우도 있다.</p>
<hr>
<h2 id="2-이용-매뉴얼-확인">2. 이용 매뉴얼 확인</h2>
<p><strong><a href="https://docs.google.com/document/d/1mX-WxuoGs8Hy-QalhHcvuV17n50uGI2Sg_GHofgiePE/edit?tab=t.0">알라딘 OpenAPI 매뉴얼</a></strong></p>
<p>*<em>여기서는 API 명세서를 확인할 수 있다. *</em>
내가 어떤 형식으로 요청을 보내야 하는지, 응답 값은 어떤 형식으로 오는지 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/70d2e840-0835-49d3-b8ab-8bb1d4d06ad1/image.png" alt=""></p>
<p>입맛에 맞게 URL을 수정해서 요청하면 된다.</p>
<hr>
<h2 id="3-구현-코드">3. 구현 코드</h2>
<pre><code class="language-java">public List&lt;BookInfoResponse&gt; searchBooks(String query, String queryType) {

    List&lt;BookInfoResponse&gt; bookList = new ArrayList&lt;&gt;();

    try {
        if (query == null || query.isBlank() || queryType == null || queryType.isBlank()) {
            return Collections.emptyList();
        }

        String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
        StringBuilder sb = new StringBuilder(&quot;http://www.aladin.co.kr/ttb/api/ItemSearch.aspx&quot;);
        sb.append(&quot;?ttbkey=&quot;).append(ttbKey);
        sb.append(&quot;&amp;Query=&quot;).append(encodedQuery);
        sb.append(&quot;&amp;QueryType=&quot;).append(queryType);
        sb.append(&quot;&amp;MaxResults=20&amp;start=1&amp;output=xml&amp;Version=20131101&quot;);

        URL url = new URL(sb.toString());

        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        Document document = builder.parse(url.openStream());
        document.getDocumentElement().normalize();

        NodeList itemList = document.getElementsByTagName(&quot;item&quot;);

        for (int i = 0; i &lt; itemList.getLength(); i++) {
            Node node = itemList.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE) {
                Element element = (Element) node;

                BookInfoResponse book = BookInfoResponse.of(
                        getTagValue(&quot;title&quot;, element),
                        getTagValue(&quot;author&quot;, element),
                        getTagValue(&quot;description&quot;, element),
                        getTagValue(&quot;publisher&quot;, element),
                        getTagValue(&quot;isbn13&quot;, element),
                        getTagValue(&quot;cover&quot;, element),
                        parseReviewRank(getTagValue(&quot;customerReviewRank&quot;, element))
                );

                bookList.add(book);
            }
        }

    } catch (IOException | ParserConfigurationException | SAXException e) {
        log.error(&quot;알라딘 API 호출 또는 XML 파싱 중 오류 발생&quot;, e);
    }

    return bookList;
}</code></pre>
<p><strong>책 검색 요청을 처리하는 비즈니스 로직이다.</strong>
프론트 단에서 사용자가 검색 조건을 하나 선택하고 검색어를 입력하면, 그 정보를 기반으로 알라딘 OpenAPI에 요청을 보낸다.</p>
<p>검색 조건은 <code>QueryType</code>(예: 제목, 저자, 출판사 등), 검색어는 <code>Query</code>로 URL에 붙는데, <strong>알라딘 명세에 따라 <code>&amp;Query=검색어&amp;QueryType=조건</code> 형태로 조합한다.</strong></p>
<p><strong>응답 XML에서 <code>&lt;item&gt;</code> 태그를 순회하면서 필요한 정보들을 추출</strong>하고, <code>BookInfoResponse</code> 객체로 변환해서 리스트로 담아 반환하는 구조다.</p>
<hr>
<p><strong><code>getTagValue()</code></strong></p>
<pre><code class="language-java">private String getTagValue(String tag, Element element) {
    NodeList nodeList = element.getElementsByTagName(tag);
    if (nodeList.getLength() == 0) return &quot;&quot;;
    String text = nodeList.item(0).getTextContent();
    return text != null ? text : &quot;&quot;;
}</code></pre>
<p>주어진 태그 이름에 해당하는 XML 요소에서 텍스트 값을 추출한다.
태그가 존재하지 않거나 값이 비어 있는 경우를 대비해 빈 문자열을 반환하도록 했다.
이부분은 크롤링을 해봤다면 이해하기 쉬울 거라 생각한다.</p>
<p><strong><code>parseReviewRank()</code></strong></p>
<pre><code class="language-java">private int parseReviewRank(String value) {
    try {
        return Integer.parseInt(value);
    } catch (NumberFormatException e) {
        return 0;
    }
}</code></pre>
<p>XML에서 전달되는 리뷰 평점(customerReviewRank)을 정수로 변환하며, 변환에 실패할 경우 기본값 0을 반환하도록 했다.</p>
<hr>
<p><strong>(💡+추가) 키 값 관리하는 법</strong></p>
<p><strong>1. application.properties 파일에 내가 원하는 변수명으로 선언</strong></p>
<pre><code class="language-java">aladin.ttb.key=your-key</code></pre>
<p><strong>2. 키 값을 사용해야하는 클래스에 <code>@Value</code> 어노테이션과 함께 필드를 선언</strong></p>
<pre><code class="language-java">@Value(&quot;${aladin.ttb.key}&quot;)
private String ttbKey;</code></pre>
<p><strong>3. properties 파일을 .gitignore 파일에 추가</strong></p>
<pre><code class="language-java">*.properties</code></pre>
<p>로컬의 .properties 파일에 정의한 키 값이 자동으로 변수(ttbKey)에 주입된다.
<br>
<strong>application.yml 예시</strong></p>
<pre><code class="language-yaml">aladin:
  ttb:
    key: your-key
</code></pre>
<hr>
<h2 id="4-결과">4. 결과</h2>
<p><strong>제목에 &#39;Java&#39;를 포함한 검색 결과는 아래와 같다.</strong>
<img src="https://velog.velcdn.com/images/harvard--/post/3c61d052-271a-4666-b44b-1b15d5e263da/image.png" alt=""></p>
<p>이렇게 데이터를 재가공해서 뿌려줄 수 있다!</p>
<blockquote>
<p><strong>OpenAPI들을 잘 활용하면 개발에 필요한 더미 데이터들도 쉽게 구할 수 있다.</strong></p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 도메인 주도 개발(DDD)을 알아보자]]></title>
            <link>https://velog.io/@harvard--/Spring-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9CDDD</link>
            <guid>https://velog.io/@harvard--/Spring-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9CDDD</guid>
            <pubDate>Tue, 29 Apr 2025 02:22:47 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>소프트웨어를 만들다 보면 이런 경험을 하게 된다.
<strong>코드는 잘 돌아가는데, 비즈니스 로직은 점점 이해하기 어려워진다.</strong></p>
<p>특히 시스템이 커질수록,
&quot;이 버튼이 왜 이렇게 동작하는 거야?&quot;
&quot;이 데이터는 어디서 관리돼?&quot;
&quot;왜 주문 취소가 이렇게 복잡하지?&quot;
같은 질문들이 생길 수 있다.</p>
<p>이런 복잡성은 기술 문제라기 보다는 <strong>비즈니스 자체가 복잡한 탓이 크다.</strong>
<strong>도메인 주도 개발(DDD)</strong>은 이 문제를 해결하고자 나온 방법론 중 하나이다.</p>
<hr>
<h2 id="1-ddd란-무엇인가">1. DDD란 무엇인가?</h2>
<p><strong>DDD(Domain-Driven Design)는 비즈니스 문제를 소프트웨어 안에 반영하기 위한 설계 방법론이다.</strong></p>
<blockquote>
<p><strong>&quot;문제를 제대로 이해하고, 그 문제를 코드에 반영하자.&quot;</strong></p>
</blockquote>
<p>핵심에서 말하는 &quot;문제&quot;란 기술이 아니라 <strong>비즈니스</strong>다.
주문, 결제, 배송 같은 <strong>비즈니스 도메인</strong>을 제대로 이해하고 그걸 기반으로 <strong>모델</strong>을 만든다.
그리고 개발자와 기획자가 <strong>같은 언어(유비쿼터스 언어)</strong>를 쓰며 시스템을 만들어 나간다.</p>
<hr>
<h2 id="2-ddd가-어디서-필요한가">2. DDD가 어디서 필요한가?</h2>
<p><strong>온라인 쇼핑몰</strong>을 예로 들어보자면 처음에는 상품 등록, 주문하기와 같은 기본적인 기능만 있으면 된다.
<strong>하지만 시간이 지나면 요구사항이 디테일하게 늘어난다.</strong></p>
<ul>
<li>쿠폰 할인</li>
<li>적립금 사용</li>
<li>부분 취소, 전체 환불</li>
<li>상품 옵션 (색상, 사이즈)</li>
<li>주문 상태별 배송 처리</li>
</ul>
<p>이때 비즈니스 복잡성을 제대로 모델링하지 않으면 코드는 점점 꼬인다.
처음에는 <code>Order</code> 엔티티 하나로 해결됐지만, 나중에는
<code>Order</code>, <code>OrderItem</code>, <code>Coupon</code>, <code>Refund</code>, <code>Delivery</code> 등이 얽히고 설킨다.
이걸 그냥 테이블 맞춰서 CRUD만 하면?
<strong>나중에 코드가 스파게티처럼 꼬여서 아무도 고칠 수 없게 된다.</strong>
이런 문제를 해결하고자 할 때 DDD를 적용할 수 있다.</p>
<p><strong>(💡+번외) 스파게티 코드??</strong>
현재 알바하고 있는 마트의 포스 프로그램이 떠오른다.
앱카드 결제, 서울페이와 같은 기능들은 <strong>요구사항이 늘어남에 따라 추가된 기능</strong>일 것이다.
그런데 기능을 단순히 붙여만 놓고, 적절한 예외처리가 되지 않아 <strong>그 메뉴에서는 유독 오류 발생이 자주 일어난다.</strong> 프로그램이 먹통이 된다거나 갑자기 꺼진다거나..
이런 경우도 스파게티 코드로 인해 발생하는 에러에 대한 적절한 처리를 못 하고 있는 것이 아닐까?</p>
<hr>
<h2 id="3-ddd의-핵심-개념">3. DDD의 핵심 개념</h2>
<h3 id="1-도메인-domain">1. 도메인 (Domain)</h3>
<blockquote>
<p><strong>해결하려는 비즈니스 문제 자체</strong></p>
</blockquote>
<p>쇼핑몰에서는 상품 등록, 주문, 결제, 배송이 <strong>각각 하나의 도메인</strong>이다.</p>
<hr>
<h3 id="2-모델-model">2. 모델 (Model)</h3>
<blockquote>
<p><strong>도메인을 코드로 표현한 것</strong></p>
</blockquote>
<p>예를 들어, &quot;주문&quot;이라는 개념을 다루기 위해 <code>Order</code> 엔티티, <code>OrderItem</code> 엔티티를 만든다.
이때 중요한 것은 단순한 데이터 덩어리가 아니라 <strong>비즈니스 규칙을 반영하는 살아있는 객체</strong>여야 한다.</p>
<p><strong>예시:</strong></p>
<pre><code class="language-java">public class Order {
    private List&lt;OrderItem&gt; orderItems;
    private OrderStatus status;

    public void cancel() {
        if (status.isShipped()) {
            throw new IllegalStateException(&quot;배송된 주문은 취소할 수 없습니다.&quot;);
        }
        this.status = OrderStatus.CANCELLED;
    }
}</code></pre>
<p>데이터만 들고 있는 객체가 아니라, <strong>비즈니스 규칙</strong>도 포함한다.</p>
<hr>
<pre><code class="language-java">public boolean checkStatus(OrderStatus orderStatus) {
    return this.status == orderStatus;
}</code></pre>
<p>이게 내가 만들었던 코드였는데, 위 예시를 봤을 때 예외처리도 해당 도메인 엔티티 내에서 해야하는 것 같다. <strong>DDD에 어긋나는 코드다.</strong></p>
<hr>
<h3 id="3-유비쿼터스-언어-ubiquitous-language">3. 유비쿼터스 언어 (Ubiquitous Language)</h3>
<blockquote>
<p><strong>모두가 사용하는 통일된 언어</strong></p>
</blockquote>
<p>&quot;상품&quot;, &quot;주문&quot;, &quot;결제&quot;, &quot;취소&quot; 같은 단어를 개발자, 기획자, 심지어 고객 서비스팀까지 <strong>모두 현업에서의 의미로 동일하게 사용</strong>해야 한다.
<strong>&quot;주문&quot;이란 무엇인가?</strong></p>
<ul>
<li>상품을 장바구니에 담는 것인가?</li>
<li>결제를 완료한 것인가?
이 정의가 다르면, 시스템도 꼬인다.</li>
<li><em>DDD에선 이 언어를 공통으로 정의하고, 코드에도 똑같이 반영*</em>해야 한다.</li>
</ul>
<p><strong>이 부분은 DDD를 떠나 개발에서 매우 중요한 부분인 것 같다.</strong>
심지어 작은 팀 프로젝트에서도 각자의 기능 구현 뒤에 문제가 되는 경우가 있다.
같은 도메인을 서로 다르게 이해하고 있는 경우가 많아 그런 경우가 매우 많은 것 같다.</p>
<hr>
<h3 id="4-바운디드-컨텍스트-bounded-context">4. 바운디드 컨텍스트 (Bounded Context)</h3>
<blockquote>
<p><strong>모델이 유효한 경계</strong></p>
</blockquote>
<p>&quot;주문(Order)&quot;이라는 개념도</p>
<ul>
<li>주문 관리 시스템에서는 &quot;결제 완료된 것&quot;</li>
<li>배송 시스템에서는 &quot;배송을 준비해야 하는 것&quot;
이렇게 다를 수 있다.
그래서 <strong>모델이 적용되는 범위를 명확히 나눈다.</strong></li>
<li><em>컨텍스트마다 독립적으로 모델을 설계*</em>하고, 필요할 때는 명시적으로 통신(API, 이벤트)한다.</li>
</ul>
<p>위에서 말한 유비쿼터스 언어가 제대로 정립되지 않으면, 이 부분에서도 문제가 생기는 것 같다.
특히 <strong>협업 과정에서 깃 병합 충돌이 난다거나 하는 경우</strong>가 이 바운디드 컨텍스트를 제대로 정하지 않아서 그런 것 같다.</p>
<hr>
<h2 id="4-ddd를-적용할-때-주의할-점">4. DDD를 적용할 때 주의할 점</h2>
<h3 id="1-처음부터-모든-곳에-ddd를-적용하려고-하지-말자">1. 처음부터 모든 곳에 DDD를 적용하려고 하지 말자</h3>
<p>DDD는 장점이 많은 방법론이긴 하지만, <strong>모든 프로젝트에 처음부터 적용해야 하는 것은 아니다.</strong>
<strong>요구사항이 단순하거나 CRUD만 필요한 시스템</strong>이라면 굳이 복잡한 비즈니스 로직을 다루기 위한 DDD의 복잡한 모델링을 도입할 필요가 없다. </p>
<p>처음부터 모든 계층을 나누고, 모든 도메인을 정의하려고 하면 오히려 실질적인 개발 속도가 느려진다. <strong>복잡성이 나타나는 순간부터 DDD를 적용하는 것</strong>이 가장 현실적이다.</p>
<h3 id="2-유비쿼터스-언어를-끝까지-지켜야-한다">2. 유비쿼터스 언어를 끝까지 지켜야 한다</h3>
<p>DDD에서 가장 중요한 개념 중 하나가 바로 <strong>유비쿼터스 언어(Ubiquitous Language)</strong>다.
개발자, 기획자, 비즈니스 담당자가 모두 같은 단어를 사용하고, 그 단어의 의미를 정확히 공유하는 것이 핵심이다.
만약 용어가 혼란스럽게 사용되면, </p>
<ul>
<li>개발자는 &#39;주문&#39;이라고 생각했는데,</li>
<li>기획자는 &#39;장바구니에 담은 것&#39;을 말하고 있었다...</li>
</ul>
<p>이런 식으로 <strong>의사소통 오류</strong>가 발생하고,
결국 시스템도 엉망이 된다.</p>
<p><strong>모든 코드, 문서, 대화에서 같은 단어를 같은 의미로 사용해야 한다.</strong>
모호함을 없애야 진짜 비즈니스 로직을 제대로 모델링할 수 있다.</p>
<h3 id="3-도메인-로직은-엔티티-안에-담자">3. 도메인 로직은 엔티티 안에 담자</h3>
<p>DDD에서는 <strong>비즈니스 규칙은 엔티티(Entity)나 밸류 오브젝트(Value Object) 안에 포함</strong>시킨다.
Service 클래스가 비즈니스 로직을 모두 처리하는 구조는 &quot;Transaction Script&quot; 스타일에 가깝고, DDD에서는 <strong>풍부한 도메인 모델(Rich Domain Model)</strong> 을 지향한다.</p>
<p>예를 들어, 주문을 취소하는 로직을 <code>OrderService</code>가 직접 하지 않고, <code>Order</code> 엔티티가 스스로 처리해야 한다.</p>
<pre><code class="language-java">order.cancel();</code></pre>
<p>이렇게 호출했을 때 <code>Order</code> 내부에서 &quot;이미 배송이 시작된 주문은 취소할 수 없다&quot; 같은 비즈니스 규칙을 검사하는 식이다.
<strong>Service는 최대한 얇게 유지하고, 핵심 로직은 엔티티에 몰아야 한다.</strong>
이렇게 해야 도메인이 살아 움직이는 것처럼 관리할 수 있다.</p>
<h3 id="4-바운디드-컨텍스트-간에는-명확하게-통신해야-한다">4. 바운디드 컨텍스트 간에는 명확하게 통신해야 한다</h3>
<p>바운디드 컨텍스트(Bounded Context)란, &quot;이 모델은 이 경계 안에서만 유효하다&quot;는 뜻이다.</p>
<p>예를 들어,</p>
<ul>
<li>주문(Order) 컨텍스트에서는 &quot;결제 대기 중&quot; 상태</li>
<li>결제(Payment) 컨텍스트에서는 &quot;결제 승인&quot; 상태</li>
</ul>
<p>이렇게 서로 다른 문맥(Context)에서 주문을 다르게 해석할 수 있다.</p>
<p>이때 중요한 것은 <strong>컨텍스트 간의 통신은 반드시 명확하게 이루어져야 한다는 것</strong>이다.</p>
<ul>
<li>API 호출</li>
<li>이벤트 발행</li>
<li>메시지 큐 사용</li>
</ul>
<p>이런 명시적인 방법을 통해
서로의 경계를 존중하며 정보를 주고받아야 한다.</p>
<p>컨텍스트를 무시하고 &quot;그냥 DB 테이블 같이 쓰자&quot; 같은 방식으로 통합하면, 곧 <strong>경계가 무너지고 시스템이 혼란에 빠진다.</strong>
<strong>각 바운디드 컨텍스트는 독립적으로 설계하고, 명확한 계약(Contract)을 통해 통신해야 한다.</strong></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>어떠한 설계 방법론이나 모델링 기법도 비즈니스의 복잡함 자체를 마법처럼 없애주진 않을 거라고 생각한다. 비즈니스가 존재하는 한, 복잡성은 항상 따라올 것이다. 그리고 <strong>개발에는 늘 trade-off가 존재하기 때문에 모든 방법론에는 장단점이 분명히 존재한다.</strong></p>
<p>다만, 좋은 모델을 만들면 이 복잡함을 제어 가능한 형태로 다듬을 수는 있을 것이다. 이를 위해 많은 선배 개발자들이 고민하고 만들어낸 방법들이 지금 내가 정리하고 있는 DDD 같은 것들 아닐까?</p>
<p><strong>이론적인 개발 공부를 하다보면 사실 추상적인 얘기가 많다.</strong> 아직 큰 규모의 프로젝트를 직접 경험하기 어려운 입장에서는 이런 모델이나 개념들을 꼬리에 꼬리를 물며 생각해보기 쉽지 않다. <strong>거의 상상의 영역에 가깝다.</strong>
그럼에도 이런 지식들을 계속 공부하는 이유는, 언젠간 나도 이 지식들을 진짜로 써먹어야 할 순간이 왔을 때 <strong>득과 실을 스스로 판단할 수 있는 힘을 기르기 위함이다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 비즈니스 로직에서 원하는 JSON 응답 형식 구현]]></title>
            <link>https://velog.io/@harvard--/%EB%B9%84%EC%A6%88%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%97%90%EC%84%9C-%EC%9B%90%ED%95%98%EB%8A%94-JSON-%EC%9D%91%EB%8B%B5-%ED%98%95%EC%8B%9D-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@harvard--/%EB%B9%84%EC%A6%88%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81%EC%97%90%EC%84%9C-%EC%9B%90%ED%95%98%EB%8A%94-JSON-%EC%9D%91%EB%8B%B5-%ED%98%95%EC%8B%9D-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 28 Apr 2025 07:16:36 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>배달 앱을 구현하는 팀 프로젝트에서 메뉴, 장바구니, 주문 API를 맡게 되었다.
JPA를 사용해 구현해야 한다는 기본 요구사항이 있었는데, <strong>쿼리문을 직접 쓰지 않고 구현해보자!</strong> 라는 것이 개인적으로 가진 추가 목표였다.
왜 굳이 쿼리를 통해 그룹핑하지 않고 비즈니스 로직에서 그룹핑을 적용해보자는 생각이 나왔는지, 생각의 흐름을 기록해보려고 한다.</p>
<hr>
<h2 id="1-쿼리-사용-없이-구현을-시도한-이유">1. 쿼리 사용 없이 구현을 시도한 이유?</h2>
<p><strong>Spring Data JPA</strong>는 정해진 규칙 내에서 메서드 명만으로 쿼리를 자동 생성할 수 있게 해준다. 하지만 필드 명이 길거나 조건이 복잡해지면 메서드 명도 따라서 엄청나게 길어질 수 밖에 없고, 결국 <code>@Query</code> 어노테이션을 사용해 JPQL을 직접 작성하게 된다.
이때 JPQL은 다음과 같은 문제를 갖는다.</p>
<ul>
<li><p><strong>컴파일 타입 안정성 부족:</strong> 
JPQL은 문자열로 작성되어 문법 오류를 컴파일 시점에 잡을 수 없다.</p>
</li>
<li><p><strong>복잡한 쿼리 관리 어려움</strong>: 
조건이 많아지면 쿼리 가독성이 떨어지고 유지보수성이 떨어진다.</p>
</li>
<li><p><strong>테이블 구조 변경 시 수정 필요:</strong> 
DB 구조 변경 시 JPQL도 수정해야 한다.</p>
</li>
</ul>
<p>특히 <strong>기본적인 CRUD 작업</strong>이나 <strong>복잡하지 않은 조건의 조회</strong>라면 쿼리를 직접 쓰지 않고 해결하는 게 더 깔끔하고 유지보수에 유리하다고 판단했다.
또한 도커 같은 컨테이너들이 보편화되면서, <strong>서버의 Scale-out이 DB보다 상대적으로 쉽기 때문에,</strong> 서버 단에서 비교적 무거운 로직을 처리해보는 것도 괜찮은 경험일 거라 생각했다.</p>
<hr>
<h2 id="2-그룹핑-처리가-필요했던-부분">2. 그룹핑 처리가 필요했던 부분</h2>
<p>주문과 관련된 엔티티는 두 가지였다.
각 엔티티의 구조는 아래와 같이 되어있다.</p>
<h4 id="order-엔티티">Order 엔티티</h4>
<pre><code class="language-java">@Entity
@Table(name = &quot;orders&quot;)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Order extends BaseEntity {

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

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status;

    @Column(nullable = false)
    private Integer totalPrice;

    @Column(nullable = false)
    private String phoneNumber;

    @Column(nullable = false)
    private String deliveryAddress;

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;store_id&quot;, nullable = false)
    private Store store;

    public void updateOrder(OrderStatus status) {
        this.status = status;
    }

    public void canceledOrder(OrderStatus status) {
        this.status = status;
    }
}</code></pre>
<hr>
<h4 id="orderitem-엔티티">OrderItem 엔티티</h4>
<pre><code class="language-java">@Entity
@Table(name = &quot;orderItem&quot;)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderItem {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;order_id&quot;, nullable = false)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;menu_id&quot;, nullable = false)
    private Menu menu;

    @Column(nullable = false)
    private Integer quantity;

    @Column(nullable = false)
    private Integer price;
}</code></pre>
<hr>
<h4 id="내가-원하던-json-응답-형식">내가 원하던 JSON 응답 형식</h4>
<pre><code class="language-java">[
  {
    &quot;orderId&quot;: 1,
    &quot;orderItems&quot;: [
      { &quot;menuId&quot;: 101, &quot;menuName&quot;: &quot;메뉴1&quot; },
      { &quot;menuId&quot;: 102, &quot;menuName&quot;: &quot;메뉴2&quot; },
      { &quot;menuId&quot;: 103, &quot;menuName&quot;: &quot;메뉴3&quot; }
    ]
  },
  {
    &quot;orderId&quot;: 2,
    &quot;orderItems&quot;: [
      { &quot;menuId&quot;: 201, &quot;menuName&quot;: &quot;메뉴1&quot; },
      { &quot;menuId&quot;: 202, &quot;menuName&quot;: &quot;메뉴2&quot; }
    ]
  }
]</code></pre>
<p>이렇게 <strong>주문 건 하나에 포함되는 메뉴들을 묶어서 매핑</strong>하고 싶었다.
여기서 내가 스스로 정한 세부적인 요구사항은 <strong>OrderItem을 출력할 때 외래키인 주문 아이디를 중복 출력하고 싶지 않았다.</strong> 
상위 <strong>Order</strong> 객체에 이미 아이디가 있기 때문에 굳이 아이템에도 가독성 떨어지게 중복해서 여러 번 찍을 이유가 없다고 생각했다.</p>
<hr>
<h2 id="3-내가-구현한-방법-메모리-연산">3. 내가 구현한 방법 (메모리 연산)</h2>
<h3 id="스트림--groupingby-사용">스트림 + <code>groupingBy</code> 사용</h3>
<p>어려운 방법이지만 <strong>실무에서도 많이 쓰이는 방법</strong>이라고 하니 익혀두는 것이 좋을 것 같다.</p>
<blockquote>
<p><strong>&quot;쿼리를 단순화하고, 복잡한 응답 구조는 자바 코드로 조립한다&quot;</strong>가 핵심이다.</p>
</blockquote>
<p><strong>OrderItemResponse DTO</strong>와 <strong>OrderListResponse DTO</strong>를 사용하여, 각 주문에 대한 메뉴 리스트를 포함하는 응답 형식을 만들었다.</p>
<h4 id="orderitemresponse">OrderItemResponse</h4>
<pre><code class="language-java">@Builder
@Schema(description = &quot;주문 아이템 응답 DTO&quot;)
public record OrderItemResponse (

        @Schema(description = &quot;주문 아이템 ID&quot;)
        Long id,

        @Schema(description = &quot;메뉴 이름&quot;)
        String name,

        @Schema(description = &quot;주문 수량&quot;)
        Integer quantity,

        @Schema(description = &quot;메뉴 가격&quot;)
        Integer price

) {
    public static OrderItemResponse from(OrderItem orderItem) {
        return OrderItemResponse.builder()
                .id(orderItem.getId())
                .name(orderItem.getMenu().getName())
                .quantity(orderItem.getQuantity())
                .price(orderItem.getPrice())
                .build();
    }
}
</code></pre>
<hr>
<h4 id="orderlistresponse">OrderListResponse</h4>
<pre><code class="language-java">@JsonInclude(JsonInclude.Include.NON_NULL)
@Getter
@Schema(description = &quot;주문 목록 응답 DTO&quot;)
public class OrderListResponse {

    @Schema(description = &quot;주문 ID&quot;)
    private final Long orderId;

    @Schema(description = &quot;가게 ID&quot;)
    private final Long storeId;

    @Schema(description = &quot;가게 이름&quot;)
    private final String storeName;

    @Schema(description = &quot;주문자 이름&quot;)
    private final String userName;

    @Schema(description = &quot;전화번호&quot;)
    private final String phoneNumber;

    @Schema(description = &quot;배달 주소&quot;)
    private final String deliveryAddress;

    @Schema(description = &quot;총 가격&quot;)
    private final Integer totalPrice;

    @Schema(description = &quot;주문 상태&quot;)
    private final String orderStatus;

    @Schema(description = &quot;주문 아이템 리스트&quot;)
    private final List&lt;OrderItemResponse&gt; orderItems;

    public OrderListResponse(Order order, List&lt;OrderItem&gt; orderItems) {
        this.orderId = order.getId();
        this.storeId = order.getStore().getId();
        this.storeName = order.getStore().getName();
        this.userName = order.getUser().getName();
        this.phoneNumber = order.getPhoneNumber();
        this.deliveryAddress = order.getDeliveryAddress();
        this.totalPrice = order.getTotalPrice();
        this.orderStatus = order.getStatus().getDescription();
        this.orderItems = orderItems.stream()
                .map(OrderItemResponse::from)
                .toList();
    }

}</code></pre>
<hr>
<h4 id="orderservice">OrderService</h4>
<pre><code class="language-java">@Transactional(readOnly = true)
public List&lt;OrderListResponse&gt; getStoreOrders(Long userId, Long storeId) {

    Store store = storeRepository.findById(storeId)
            .orElseThrow(() -&gt; new StoreException(StoreExceptionCode.STORE_NOT_FOUND));

    if (!store.isOwner(userId)) {
        throw new OrderException(OrderExceptionCode.OWN_STORE_ONLY);
    }

    List&lt;Order&gt; orders = orderRepository.findAllByStore(store);

    return orderItemService.getOrderItemList(orders);
}</code></pre>
<hr>
<h4 id="orderitemservice">OrderItemService</h4>
<pre><code class="language-java">public List&lt;OrderListResponse&gt; getOrderItemList(List&lt;Order&gt; orders) {

    return orderItemRepository.findAllByOrderIn(orders).stream()
        .collect(Collectors.groupingBy(OrderItem::getOrder))
        .entrySet()
        .stream()
        .map(entry -&gt; new OrderListResponse(entry.getKey(), entry.getValue()))
        .toList();
}</code></pre>
<p>주문과 관련된 아이템들을 가져온 후, 스트림을 사용하여 <strong>Order</strong> 별로 그룹핑을 한다. 이때 <code>Collectors.groupingBy()</code>를 사용해 <strong>Order</strong> 객체를 키로 하고 <strong>OrderItem</strong> 리스트를 값으로 갖는 맵을 생성한다.</p>
<hr>
<h4 id="결과">결과</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/6ea9c69f-6b6e-401b-9cbe-2591e140ce4f/image.png" alt=""></p>
<h4 id="장점">장점</h4>
<ul>
<li><p><strong>단일 책임 원칙(SRP):</strong> 
각 클래스와 메서드가 하나의 책임만을 가진다. <code>OrderItemService</code>는 <strong>OrderItem</strong>을 그룹핑해서 <strong>Order</strong> 별로 묶어주는 역할만 하고, <code>OrderService</code>는 인증, 권한 검증 후 필요한 주문 목록을 가져오는 역할만 맡는다.
이렇게 책임을 분리하면 코드를 이해하기 쉬워지고, 변경이 필요할 때 수정 범위가 좁아져 유지보수가 훨씬 쉬워진다.</p>
</li>
<li><p><strong>응집도 높은 코드:</strong> 
응답 형식(<code>OrderListResponse</code>, <code>OrderItemResponse</code>)을 별도의 DTO로 분리해 잘 캡슐화했기 때문에, <strong>추후에 새로운 요구사항이 생기더라도 기존 로직을 크게 수정하지 않고 대응할 수 있다.</strong>
예를 들어, 주문 상세 화면에서는 <strong>OrderListResponse</strong>를 그대로 가져다가 사용하고, 관리자 화면에서는 새로운 DTO에 추가 필드를 붙여 확장하는 것도 자연스럽게 가능하다.</p>
</li>
</ul>
<blockquote>
<p><strong>특히 실무에서는 변경 요구가 빈번하기 때문에, 이런 설계를 익히는 것은 좋은 경험이라고 생각한다.</strong></p>
</blockquote>
<h4 id="단점">단점</h4>
<ul>
<li><p><strong>성능 저하 가능성:</strong> 
데이터셋이 작을 때는 문제되지 않지만, <strong>수천~수만 건 이상의 데이터를 처리할 경우</strong>, 자바 스트림을 이용한 그룹핑 과정에서 성능이 저하될 수 있다.</p>
</li>
<li><p><strong>메모리 사용량 증가:</strong> </p>
</li>
<li><p><em>그룹핑 과정은 가져온 모든 데이터를 메모리에 올려놓고 연산을 수행한다.*</em> 따라서 데이터량이 많을수록 메모리 점유율이 급격히 증가할 수 있다.
메모리 한계를 초과하면 <strong>GC 지연이나 OutOfMemoryError</strong> 같은 문제가 발생할 위험도 생긴다.</p>
</li>
<li><p><strong>복잡성 증가:</strong> 
스트림과 그룹핑 로직이 여러 번 중첩되거나, 변환 과정이 많아질 경우 코드가 길어지고 복잡해진다.
특히 다양한 조건에 따라 그룹핑하거나, 중첩 구조를 만들 때 가독성이 떨어지고 유지보수가 어려워질 수 있다.</p>
</li>
</ul>
<p>쿼리에서 복잡한 조인이나 변환 로직을 수행하는 대신, 필요한 데이터만 단순하게 가져오고, 그 이후의 구조화 작업은 전부 자바 코드로 처리하는 흐름이다.</p>
<p>쿼리 복잡도를 낮추고 코드로 데이터를 자유롭게 다룰 수 있지만, <strong>연산 횟수가 많을수록 메모리 사용량이 늘어날 수 있다는 점을 주의</strong>해야 한다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 프로젝트에서는 <strong>쿼리를 직접 작성하기보다는 서비스 레이어에서 그룹핑하는 방식이 더 적합</strong>하다고 판단했다.</p>
<p><strong>그 이유는 다음과 같다.</strong></p>
<ul>
<li>요구하는 응답 구조가 명확했고, 복잡한 쿼리를 작성할 필요가 없었다.</li>
<li>더미 데이터가 몇십 건 수준이라, 메모리 기반 그룹핑으로 충분히 감당할 수 있었다.</li>
<li>쿼리 작성 없이 깔끔하게 책임 분리된 코드를 유지할 수 있었다.</li>
<li>유지보수성과 확장성을 고려할 때, <strong>자바 코드로 조립하는 방식</strong>이 오히려 더 유리했다.</li>
</ul>
<p><strong>단, 이 접근 방식이 항상 좋은 것은 아니다.</strong></p>
<ul>
<li>데이터 양이 많아지면 오히려 DB 쿼리로 그룹핑해서 가져오는 편이 성능상 유리할 수 있다.</li>
<li>서비스 규모가 커질수록 메모리 사용량과 트랜잭션 시간 등을 반드시 고려해야 한다.</li>
</ul>
<blockquote>
<p>요약하면, <strong>작은 데이터셋 → 자바 그룹핑, 큰 데이터셋 → 쿼리 그룹핑</strong>을 기본으로 생각하고, 특성과 상황에 맞게 융통성 있게 선택하는 것이 중요하다고 느꼈다.</p>
</blockquote>
<p>이번 경험을 통해 &quot;쿼리는 최소한으로, 복잡한 조립은 코드로&quot; 라는 하나의 방법을 직접 적용해 볼 수 있었다.</p>
<p>또한 JPA의 기본 철학인 <strong>&quot;엔티티 중심 데이터 관리&quot;</strong>를 제대로 활용하는 좋은 기회가 되었다고 생각한다.</p>
<p>다음에는 데이터 양이 훨씬 많을 때,</p>
<ul>
<li>DB에서 <code>GROUP BY</code>로 직접 묶어오는 경우</li>
<li>메모리로 묶는 경우</li>
</ul>
<p>두 방식의 성능 차이도 벤치마킹해보고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Spring AOP로 관리자 API 로그 자동화하기]]></title>
            <link>https://velog.io/@harvard--/Spring-AOP%EB%A1%9C-%EA%B4%80%EB%A6%AC%EC%9E%90-API-%EB%A1%9C%EA%B7%B8-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@harvard--/Spring-AOP%EB%A1%9C-%EA%B4%80%EB%A6%AC%EC%9E%90-API-%EB%A1%9C%EA%B7%B8-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 17 Apr 2025 14:34:28 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>이미 만들어진 프로젝트를 리팩토링하는 과제를 하는 중이다.</p>
<blockquote>
<p><strong>어드민 사용자만 접근할 수 있는 특정 API에 대해 접근 로그를 기록하라는 요구사항이 주어졌다.</strong></p>
</blockquote>
<p>처음엔 로그 코드를 직접 넣는 방식도 괜찮지만, <strong>API가 많아질수록 같은 코드가 반복되고 유지보수가 어려워진다.</strong> 이런 상황에서는 공통 기능을 한 곳에 모아두고, 자동으로 실행되게 하는 방식이 훨씬 효율적이다.</p>
<p>이때, 공통 기능을 모듈화하고 필요한 시점에 자동으로 적용하는 방법인 <strong>AOP</strong>를 선택할 수 있다.
<strong>AOP</strong>의 배경지식이 전무한 상태였기에, 공부하면서 구현한 내용을 상세하게 기록해보려고 한다.</p>
<hr>
<h2 id="1-aop란">1. AOP란?</h2>
<p><strong>AOP</strong>는 <strong>Aspect-Oriented Programming</strong>의 약자로, <strong>관점 지향 프로그래밍</strong>이라고도 불린다.
어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나눠본다는 말이다.</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/05e3b7b6-e222-4001-bf07-e3009cd25182/image.png" alt=""></p>
<p>위 사진을 기준으로 설명해보면</p>
<ul>
<li><p><strong>핵심적인 관점</strong> 
개발자가 적용하고자 하는 핵심 비즈니스 로직 (계좌이체, 대출승인, 이자계산 등)</p>
</li>
<li><p><strong>부가적인 관점</strong> 
핵심 비즈니스 로직을 수행하기 위해 필요한 부가적인 기능 (로깅, 보안, 트랜잭션 등)</p>
</li>
</ul>
<p>AOP는 비즈니스 로직의 핵심 부분과 부가적인 기능을 분리함으로써, <strong>공통 기능을 효율적으로 처리</strong>할 수 있도록 한다.</p>
<hr>
<h2 id="2-aop가-등장하게-된-배경">2. AOP가 등장하게 된 배경</h2>
<h3 id="1-객체지향oop의-한계">1. 객체지향(OOP)의 한계</h3>
<p>객체지향은 관심사를 객체 단위로 분리하면서 복잡한 시스템을 구조적으로 잘 다룰 수 있게 해줬다. 하지만 시간이 지나고 시스템이 커질수록, 다음과 같은 문제가 드러났다.</p>
<h4 id="횡단-관심사cross-cutting-concern의-문제">횡단 관심사(Cross-Cutting Concern)의 문제</h4>
<ul>
<li>로깅, 트랜잭션, 인증/인가, 예외 처리, 성능 측정 등은 많은 클래스에서 필요한 공통 기능이다.</li>
<li>이런 공통 기능은 여러 클래스에 중복 코드로 반복되면서 <strong>비즈니스 로직을 흐리게 만든다.</strong></li>
</ul>
<blockquote>
<p><strong>객체지향은 핵심 비즈니스 로직을 잘 나누지만, 공통 기능(로깅, 트랜잭션 등)은 여전히 여러 클래스에 중복되어 반복된다.</strong>
이는 유지보수를 어렵게 만들고, 각 클래스에 공통 로직을 추가할 때마다 코드가 복잡해진다.</p>
</blockquote>
<h3 id="2-aop가-이걸-어떻게-해결할-수-있나">2. AOP가 이걸 어떻게 해결할 수 있나?</h3>
<p>AOP는 <strong>&quot;공통 기능은 따로 모아두고, 필요한 시점에 끼워 넣자&quot;</strong>는 접근 방식이다.
즉, 공통 기능을 일일이 코드에 작성하지 않아도 되고, 핵심 로직과 분리되어 코드의 순수성과 유지보수성이 높아진다.</p>
<ul>
<li>공통 기능(횡단 관심사)은 별도 모듈로 분리</li>
<li>필요한 지점에 자동으로 적용 (Weaving)</li>
<li>핵심 로직은 순수하게 유지</li>
</ul>
<p><strong>OOP의 장점은 유지하면서, 단점은 보완할 수 있는 방법</strong>이라고 할 수 있다.
Spring framework에서도 Spring AOP를 지원해서 트랜잭션 관리, 로깅 등 다양한 공통 기능을 효율적으로 처리할 수 있다.</p>
<hr>
<h2 id="3-spring-aop">3. Spring AOP</h2>
<h3 id="1-spring-aop-주요-특징">1. Spring AOP: 주요 특징</h3>
<ul>
<li><p><strong>선언적 방식</strong>: 스프링 AOP는 어노테이션이나 XML 설정을 통해 공통 기능을 적용할 수 있어, 코드 변경 없이 비즈니스 로직에 공통 기능을 끼워 넣을 수 있다.</p>
</li>
<li><p><strong>런타임 기반</strong>: 스프링 AOP는 런타임 시 동적으로 프록시 객체를 생성하여, 실제 비즈니스 메서드를 감싸고 AOP 기능을 적용한다.</p>
</li>
</ul>
<hr>
<h3 id="2-spring-aop-핵심-용어">2. Spring AOP: 핵심 용어</h3>
<ul>
<li><p><strong>Aspect:</strong> </p>
</li>
<li><p><em>횡단 관심사를 모듈화한 단위.*</em> 예를 들어, 로깅이나 트랜잭션 처리 같은 공통 기능을 하나의 Aspect로 모을 수 있다.</p>
</li>
<li><p><strong>Join Point:</strong> 
AOP에서 <strong>Advice가 적용될 수 있는 지점</strong>. 메서드 실행이나 예외 발생 등 다양한 지점이 될 수 있다. 스프링 AOP에서는 <strong>메서드 실행을 Join Point</strong>로 지원한다.</p>
</li>
<li><p><strong>Pointcut:</strong>
실제로 Advice를 적용할 Join Point를 선별하는 조건. <strong>어떤 메서드에 Advice를 적용할지 선택하는 역할</strong>을 한다. 예를 들어, <code>@Before(&quot;execution(* com.example.service.*.*(..))&quot;)</code>와 같이 표현할 수 있다.</p>
</li>
<li><p><strong>Advice:</strong>
공통 기능을 실행하는 코드. 언제 실행될지에 따라 Before, After, Around 등의 종류로 나뉜다.</p>
<ul>
<li><strong>Before:</strong> 메서드 실행 전에 수행</li>
<li><strong>After:</strong> 메서드 실행 후에 수행</li>
<li><strong>Around:</strong> 메서드 실행 전후에 모두 수행, 메서드를 실행하는지 여부를 제어할 수도 있다.
<strong>(실행 시<code>ProceedingJoinPoint</code> 객체의 <code>proceed()</code> 메서드 사용)</strong></li>
</ul>
</li>
<li><p><strong>Weaving:</strong></p>
</li>
<li><p><em>Advice와 실제 비즈니스 코드(타깃 객체)를 결합하는 과정*</em>. 이 과정은 런타임에 프록시 객체를 생성하고, 해당 프록시 객체가 실제 메서드 실행 전후에 공통 기능을 적용하는 방식으로 이루어진다.</p>
</li>
</ul>
<hr>
<h3 id="3-spring-aop-적용-사례">3. Spring AOP: 적용 사례</h3>
<h4 id="1-트랜잭션-관리-transactional">1. 트랜잭션 관리 (@Transactional)</h4>
<ul>
<li>가장 대표적인 AOP 적용 사례</li>
<li><code>@Transactional</code> 어노테이션을 메서드나 클래스에 붙이면, Spring AOP가 해당 메서드 호출을 감싸서 트랜잭션 시작/커밋/롤백을 처리</li>
<li>내부적으로는 <code>TransactionInterceptor</code>라는 AOP 어드바이스가 동작</li>
</ul>
<hr>
<h4 id="2-비동기-처리-async">2. 비동기 처리 (@Async)</h4>
<ul>
<li>해당 메서드는 별도의 쓰레드에서 실행되고, 이걸 AOP가 프록시로 감싸서 처리</li>
<li>내부적으로는 <code>AsyncAnnotationBeanPostProcessor</code>가 AOP 프록시를 만들어주고, <code>AsyncExecutionInterceptor</code>가 어드바이스 역할</li>
</ul>
<hr>
<h4 id="3-보안-처리-preauthorize-secured">3. 보안 처리 (@PreAuthorize, @Secured)</h4>
<ul>
<li>Spring Security도 AOP 기반</li>
<li><code>@PreAuthorize(&quot;hasRole(&#39;ADMIN&#39;)&quot;)</code> 같은 어노테이션이 붙으면, 메서드 실행 전에 권한 체크를 수행하고, 권한이 없으면 예외를 던짐</li>
<li>내부적으로는 <code>MethodSecurityInterceptor</code>라는 어드바이스가 작동</li>
</ul>
<hr>
<h4 id="4-캐싱-처리-cacheable-cacheevict">4. 캐싱 처리 (@Cacheable, @CacheEvict)</h4>
<ul>
<li><code>@Cacheable</code>은 메서드 실행 전 캐시에 값이 있는지 확인하고, 있으면 실행을 생략</li>
<li><code>@CacheEvict</code>는 실행 후 캐시를 비우는 동작</li>
<li>내부적으로는 <code>CacheInterceptor</code>라는 어드바이스가 작동</li>
</ul>
<hr>
<h3 id="4-spring-aop-구현---로깅">4. Spring AOP: 구현 - 로깅</h3>
<h4 id="1-buildgradle-의존성-추가">1. build.gradle 의존성 추가</h4>
<pre><code class="language-java">implementation &#39;org.springframework.boot:spring-boot-starter-aop&#39;</code></pre>
<hr>
<h4 id="2-aspect-클래스-생성">2. Aspect 클래스 생성</h4>
<pre><code class="language-java">@Slf4j
@Aspect
@Component
public class Logger {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Pointcut(&quot;@annotation(org.example.expert.domain.common.aop.AdminLoggingTarget)&quot;)
    private void adminApi() {}

    @Around(&quot;adminApi()&quot;)
    public Object doAdminLog(ProceedingJoinPoint joinPoint) throws Throwable {
        // 요청 정보
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String requestUrl = request.getRequestURL().toString();
        Long userId = (Long) request.getAttribute(&quot;userId&quot;);

        // 요청 본문
        String requestBody = getRequestBody(joinPoint);

        // API 요청 시각
        LocalDateTime requestTime = LocalDateTime.now();

        // 요청 로그 출력
        logRequest(userId, requestTime, requestUrl, requestBody);

        // 메서드 실행 후 응답 본문 받기
        Object result = joinPoint.proceed();

        // 응답 본문
        String responseBody = objectMapper.writeValueAsString(result);

        // 응답 로그 출력
        logResponse(userId, requestTime, responseBody);

        return result;
    }

    private String getRequestBody(ProceedingJoinPoint joinPoint) {
        try {
            for (Object arg : joinPoint.getArgs()) {
                if (arg != null &amp;&amp; arg.getClass().getPackageName().contains(&quot;dto&quot;)) {
                    return objectMapper.writeValueAsString(arg);
                }
            }
        } catch (IOException e) {
            log.error(&quot;Request body parsing failed&quot;, e);
        }
        return &quot;No RequestBody&quot;;
    }

    private void logRequest(Long userId, LocalDateTime requestTime, String requestUrl, String requestBody) {
        log.info(&quot;Request: UserID: {}, Time: {}, URL: {}, RequestBody: {}&quot;, userId, requestTime, requestUrl, requestBody);
    }

    private void logResponse(Long userId, LocalDateTime requestTime, String responseBody) {
        log.info(&quot;Response: UserID: {}, Time: {}, ResponseBody: {}&quot;, userId, requestTime, responseBody);
    }

}</code></pre>
<hr>
<h4 id="3-사용자-정의-어노테이션-생성">3. 사용자 정의 어노테이션 생성</h4>
<pre><code class="language-java">@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminLoggingTarget {
}</code></pre>
<hr>
<h4 id="4-실제-사용">4. 실제 사용</h4>
<pre><code class="language-java">@AdminLoggingTarget
@DeleteMapping(&quot;/admin/comments/{commentId}&quot;)
public ResponseEntity&lt;Void&gt; deleteComment(@PathVariable long commentId) {
    commentAdminService.deleteComment(commentId);
    return ResponseEntity.noContent().build();
}</code></pre>
<hr>
<p><strong>(💡+추가) 사용자 정의 어노테이션을 만든 이유?</strong></p>
<pre><code class="language-java">@Pointcut(&quot;execution(* org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))&quot;)</code></pre>
<p>위와 같이 사용할 수도 있다. 
하지만 대상 API가 추가될 때마다 저 부분을 수정해줘야 하는 상황이 발생한다. 이는 코드 변경이 필요할 때마다 <strong>변경 사항이 전파되는 문제</strong>를 일으킨다.
따라서 사용자 정의 어노테이션을 사용하면 기존 코드를 수정하지 않고 어노테이션 추가만으로 기능을 확장하고 유지보수를 용이하게 할 수 있다.</p>
<hr>
<h4 id="💡추가-aop-적용-시-유의사항">(💡++추가) AOP 적용 시 유의사항</h4>
<p>예외 발생 시 AOP에서 예외를 <strong>&quot;먹는&quot;</strong> 현상이 나타날 수 있다.
<code>joinPoint.proceed()</code> 중 예외 발생 시, catch 블록에서 예외를 삼켜버리면 클라이언트는 오류 메시지도 못 받고, 응답조차 못 받을 수 있다.</p>
<pre><code class="language-java">try {
    return joinPoint.proceed();
} catch (Throwable ex) {
    log.error(&quot;예외 발생&quot;); // 예외 처리 후 리턴하면, 비즈니스 예외가 무시됨
    return null;           // 클라이언트는 응답도 못 받음
}</code></pre>
<blockquote>
<p>반드시 예외는 다시 던져야 한다. <code>throw ex;</code></p>
</blockquote>
<hr>
<h3 id="5-spring-aop-한계">5. Spring AOP: 한계</h3>
<h4 id="1-메서드-실행-기준으로-동작">1. 메서드 실행 기준으로 동작</h4>
<p>Spring AOP는 메서드 실행을 기준으로 동작한다. 즉, 메서드 호출 전, 후 또는 예외 처리 등을 AOP로 관리할 수 있지만, <strong>메서드 외의 이벤트에는 적용할 수 없다.</strong> </p>
<p>예를 들어,</p>
<ul>
<li><p><strong>필드 접근</strong> 
클래스 내부의 필드를 읽거나 수정하는 작업에 대해서는 AOP가 적용되지 않는다.
(위와 같은 작업은 메서드가 실행되지 않기 때문에 AOP의 Advice가 적용되지 않음.)</p>
</li>
<li><p><strong>생성자 호출</strong> 
객체 생성 시 생성자에 대한 AOP는 지원하지 않는다.
(AOP가 동작하는 메서드 실행 시점에 해당하지 않음.)</p>
</li>
</ul>
<h4 id="2-proxy-기반으로-동작">2. Proxy 기반으로 동작</h4>
<p>Spring AOP는 프록시 기반으로 동작한다. 이로 인해 몇 가지 한계가 발생한다.</p>
<ul>
<li><p><strong>인터페이스 기반 메서드</strong> 
클래스가 인터페이스를 구현하는 경우, JDK 동적 프록시를 사용하여 AOP가 적용된다. 이 경우, 인터페이스를 구현한 메서드만 AOP 대상이 되고 <strong>인터페이스에 정의되지 않은 메서드에는 AOP가 적용되지 않는다.</strong> (동적 바인딩이랑 관련이 있다.)</p>
</li>
<li><p><strong>구체 클래스에서 AOP 적용:</strong> 인터페이스 없이 구체적인 클래스에서만 메서드가 실행되는 경우 CGLIB 프록시가 사용된다. 만약 메서드가 <strong>final로 선언된 경우 오버라이드 할 수 없기 때문</strong>에 AOP가 적용되지 않는다.</p>
</li>
</ul>
<hr>
<h4 id="💡추가-더욱-폭-넓게-적용하려면">(💡+추가) 더욱 폭 넓게 적용하려면?</h4>
<p><strong>AspectJ</strong>라는 AOP를 구현하기 위한 전용 프레임워크가 있다.</p>
<ul>
<li>런타임에만 적용하는 스프링 AOP와는 달리 <strong>컴파일 타임 또는 로드 타임에서도 AOP를 적용</strong>할 수 있다.</li>
<li>메서드 실행 외에도 생성자 호출, 필드 접근 등 <strong>더 다양한 Join Point에 적용</strong>할 수 있다.</li>
</ul>
<hr>
<h2 id="4-과연-aop에-장점만-있을까-아니다">4. 과연 AOP에 장점만 있을까? 아니다!</h2>
<p><strong>1. 성능 문제</strong></p>
<ul>
<li>AOP는 프록시 객체를 사용하여 메서드를 감싸기 때문에, 메서드를 호출할 때마다 <strong>추가적인 오버헤드가 발생</strong>한다. 특히 Around Advice가 적용되는 경우, 메서드 실행 전후로 추가적인 작업이 들어가므로 성능에 미치는 영향이 클 수 있다.</li>
<li><em>컴파일 타임 AOP*</em>는 프록시 객체를 생성하는 비용은 발생하지 않지만, 대신 AOP를 적용하기 위한 추가 작업이 컴파일 과정에서 이루어진다. 따라서 <strong>빌드 시간이 증가</strong>할 수 있다는 단점을 가지고 있다.</li>
</ul>
<p><strong>2. 코드 흐름의 복잡성 증가</strong></p>
<ul>
<li>AOP는 코드의 흐름을 변경하는 방식으로 동작하기 때문에, 코드의 실행 흐름을 이해하기 어려울 수 있다. 특히, <strong>많은 AOP가 적용된 시스템에서는 전체적인 흐름을 파악하는 데 어려움이 있을 수 있고, 유지보수 시 예상치 못한 부작용이 발생할 수 있다.</strong></li>
</ul>
<p><strong>3. 학습 곡선</strong></p>
<ul>
<li>AOP는 처음 사용하는 개발자에게는 개념이 낯설고, <strong>어떻게 활용할지에 대한 충분한 학습이 필요하다.</strong> 잘못 사용하면 코드의 가독성이 떨어지고, 불필요한 복잡성을 초래해 유지보수가 어려워질 수 있다.</li>
</ul>
<p><strong>4. 디자인 패턴과의 충돌</strong></p>
<ul>
<li>AOP는 특별한 설계 패턴을 따르지 않는 경우가 많기 때문에 AOP를 적용하면서 <strong>기존의 디자인 패턴이나 설계 방식과 충돌할 가능성이 있다.</strong></li>
</ul>
<p><strong>5. 테스트 어려움</strong></p>
<ul>
<li>AOP가 적용된 메서드의 테스트를 작성할 때, AOP의 영향으로 코드 실행 흐름을 정확히 예측하기 어려워 <strong>테스트가 복잡해지고, AOP가 적용된 부분의 독립적인 테스트가 어려워질 수 있다.</strong></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] JWT 기반 인증 리팩토링, 만료되지 않은 토큰 관리를 어떻게 해야할까?]]></title>
            <link>https://velog.io/@harvard--/Spring-JWT-%EA%B8%B0%EB%B0%98-%EC%9D%B8%EC%A6%9D-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EB%A7%8C%EB%A3%8C%EB%90%98%EC%A7%80-%EC%95%8A%EC%9D%80-%ED%86%A0%ED%81%B0-%EA%B4%80%EB%A6%AC%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@harvard--/Spring-JWT-%EA%B8%B0%EB%B0%98-%EC%9D%B8%EC%A6%9D-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EB%A7%8C%EB%A3%8C%EB%90%98%EC%A7%80-%EC%95%8A%EC%9D%80-%ED%86%A0%ED%81%B0-%EA%B4%80%EB%A6%AC%EB%A5%BC-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sun, 13 Apr 2025 13:00:22 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>팀 프로젝트로 진행한 소셜 네트워크 서비스(SNS)와 유사한 뉴스피드 API 서버 구현에서, <strong>회원 기능과 인증/인가 로직</strong>을 담당했다. 초기에는 세션 기반 인증으로 구성했지만, 이후 JWT 기반 인증 방식으로 리팩토링 하는 과정에서 문제를 마주하게 됐다.
JWT에 대한 사전 지식이 부족한 상태에서 공부함과 동시에 실시간으로 구현을 하면서, 세션과 다른 토큰의 특성때문에 어쩔 수 없는 한계가 있다는 걸 깨닫게 되었고 그 한계점 해결 과정을 정리해보려 한다.</p>
<hr>
<h2 id="만료되지-않은-토큰의-위험">만료되지 않은 토큰의 위험</h2>
<h3 id="문제-발생">문제 발생</h3>
<p>JWT 기반으로 로그아웃을 처리한 후에도, 여전히 이전의 토큰 값만 알고 있다면 API 요청이 정상적으로 처리되는 현상이 발생했다.</p>
<h3 id="문제-분석">문제 분석</h3>
<p>JWT는 <strong>무상태(stateless)</strong> 방식이다. 즉, 사용자 인증 정보를 클라이언트 쪽에서 자체적으로 보관한다.
반면, 세션 기반 인증은 서버가 사용자 상태를 유지하기 때문에 언제든지 무효화할 수 있는 <strong>상태 유지(stateful)</strong> 방식이다.
JWT는 한 번 발급되면 서버가 일방적으로 만료시키기 어렵다. 이게 바로 내가 고민한 <strong>&quot;JWT 로그아웃 문제&quot;</strong>였다.</p>
<h3 id="문제-해결-redis-기반-블랙리스트">문제 해결: Redis 기반 블랙리스트</h3>
<p>이 문제를 해결하기 위해, <code>Redis</code>를 활용한 <strong>토큰 블랙리스트 등록 방식</strong>을 선택했다.</p>
<h4 id="왜-redis인가">왜 Redis인가?</h4>
<p><code>Redis</code>는 <strong>메모리(RAM) 기반 저장소</strong>라서 일반 관계형 데이터베이스보다 읽고 쓰는 속도가 훨씬 빠르다.</p>
<p>*<em>평균 응답 속도: *</em></p>
<ul>
<li><code>Redis</code>: <strong>평균 0.1~1ms 이하</strong></li>
<li><code>RDB</code>: <strong>평균 10~100ms 이상</strong>(쿼리, 인덱스에 따라 다름)</li>
</ul>
<p>하지만 메모리에 데이터를 저장하는 구조이기 때문에 <strong>대량의 데이터가 쌓이면 메모리 부족 이슈가 발생할 수 있다.</strong> 따라서 데이터 저장 시 보통 <strong>TTL(Time To Live)</strong>을 설정해 일정 시간이 지나면 자동으로 데이터를 삭제하도록 구성한다. 
블랙리스트는 <strong>&quot;토큰이 만료되기 전까지만 유효하면 된다&quot;</strong>는 특성이 있고, 복잡한 데이터 구조가 필요하지 않다. 따라서 간단한 <strong>키-값 구조</strong>를 사용하고 <strong>TTL 설정</strong> 기능을 제공하는 <code>Redis</code>를 사용하는 것이 가장 실용적인 방식이라고 생각했다.</p>
<h3 id="로그아웃-처리-로직">로그아웃 처리 로직</h3>
<h4 id="서비스-코드">서비스 코드</h4>
<pre><code class="language-java">// 1. 액세스 토큰 유효성 검사
if (!jwtProvider.validate(accessToken)) {
    throw new CustomException(ExceptionCode.INVALID_ACCESS_TOKEN);
}

// 2. 사용자 ID 추출 및 사용자 조회
Long userId = jwtProvider.getUserId(accessToken);
User user = userRepository.findUserByIdOrElseThrow(userId);

// 3. DB에 저장된 리프레시 토큰
String refreshToken = user.getRefreshToken();

// 4. 액세스 토큰 블랙리스트 등록
blackListService.addAccessTokenToBlacklist(accessToken);

// 5. 리프레시 토큰 블랙리스트 등록
blackListService.addRefreshTokenToBlacklist(refreshToken);

// 6. 사용자 엔티티에서 리프레시 토큰 제거
user.deleteRefreshToken();</code></pre>
<h4 id="블랙리스트-등록-메서드-access-token">블랙리스트 등록 메서드 (Access Token)</h4>
<pre><code class="language-java">/**
* Access Token 블랙리스트 등록
* @param accessToken 등록할 accessToken
*/
protected void addAccessTokenToBlacklist(String accessToken) {

    // 1. 액세스 토큰 만료 시간 계산
    long expiration = jwtProvider.getExpiration(accessToken);

    // 2. 액세스 토큰 블랙리스트 등록
    redisTemplate.opsForValue().set(
        &quot;blacklist:access:&quot; + accessToken,
        &quot;logout&quot;,
        expiration,
        TimeUnit.MILLISECONDS
    );
}</code></pre>
<h4 id="로직-흐름">로직 흐름</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/c2b7ac54-a7fb-46b9-910e-86c163aa1173/image.png" alt=""></p>
<p><strong>1. 액세스 토큰 유효성 검사</strong></p>
<ul>
<li>클라이언트 요청의 헤더에서 액세스 토큰을 추출하고, 서명이 유효한지 확인한다.</li>
</ul>
<p><strong>2. 사용자 식별 및 조회</strong></p>
<ul>
<li>액세스 토큰의 Payload에서 유저 ID를 파싱하고, 해당 ID로 DB에서 사용자를 조회한다.</li>
</ul>
<p><strong>3. DB에 저장된 리프레시 토큰 조회</strong></p>
<ul>
<li>사용자의 리프레시 토큰을 <code>MySQL</code>에서 조회한다.</li>
</ul>
<p><strong>4. 토큰 TTL 계산</strong></p>
<ul>
<li><code>토큰 만료 시각 - 현재 시각</code> 으로 남은 유효 시간(Time To Live, TTL)을 계산한다.</li>
</ul>
<p><strong>5. Redis 블랙리스트 등록</strong></p>
<ul>
<li>액세스 토큰과 리프레시 토큰을 각각 Redis에 등록하며, TTL을 설정한다.</li>
</ul>
<p><strong>6. 토큰 블랙리스트 필터링</strong></p>
<ul>
<li>이후 요청에서 해당 토큰이 Redis에 블랙리스트로 등록되어 있다면, 인증 예외를 발생시켜 접근을 차단한다.</li>
</ul>
<hr>
<h4 id="💡추가-꼭-redis를-써야하는-건-아님">(💡+추가) 꼭 Redis를 써야하는 건 아님.</h4>
<p>관계형 데이터 베이스로도 블랙리스트 기능 구현은 할 수 있지만 <strong>속도, 관리 측면에서 효율성이 떨어진다.</strong></p>
<p><strong><code>Redis</code> 서버가 죽으면 블랙리스트도 다 사라지지 않나?</strong>
맞다. <code>Redis</code>는 메모리 기반이라 휘발성이다. 
그래서 운영 환경에서는 RDB 스냅샷 방식, AOF 방식등을 사용해 영속성을 확보한다고 한다.</p>
<p><strong>RDB 스냅샷</strong>: 일정 시점의 <code>Redis</code> 메모리 데이터를 통째로 덤프</p>
<ul>
<li>장점 - 빠르게 복원 가능, 파일 크기가 작다.</li>
<li>단점 - 설정에 따라 <strong>데이터 손실</strong>이 발생할 수도 있다.</li>
</ul>
<p><strong>AOF</strong>: <code>Redis</code>에서 수행된 모든 쓰기 명령을 파일에 기록, 
서버가 재시작되면 파일을 순서대로 다시 실행해서 복원한다.</p>
<ul>
<li>장점 - 데이터 손실 거의 없다, 명령어 기반이라 사람이 읽기 쉽다.</li>
<li>단점 - 시간이 지날수록 <strong>파일이 커짐</strong>, 복원 시 오래 걸릴 수 있다.</li>
</ul>
<hr>
<h4 id="💡추가-꼬리를-물고-들어가자면-블랙리스트는-영속성이-필요할까">(💡++추가) 꼬리를 물고 들어가자면, 블랙리스트는 영속성이 필요할까?</h4>
<p>이건 운영 환경에 따라 달라질 수 있는 문제다.
블랙리스트는 어차피 <strong>&quot;토큰 만료 시점까지만 유효&quot;</strong> 하면 되기 때문에,
<strong>장기 보관보다는 TTL을 기반으로 한 자동 만료</strong>가 더 실용적일 수 있다.
대부분의 경우에는 <code>Redis</code>에서 제공하는 <strong>기본적인 TTL 관리만으로도 충분</strong>하다.
나는 이런 특성 때문에, <strong>블랙리스트에 한해서는 영속성 확보가 반드시 필요한 건 아니라고 생각한다.</strong></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>JWT는 분산 시스템에서 유용한 인증 수단이지만, 한 번 발급되면 서버가 제어할 수 없다는 점에서 관리의 어려움이 있다.
<code>Redis</code>를 활용한 블랙리스트 방식으로 토큰 무효화를 처리한 경험은 보안적인 관점과 서버 인증 관련 역량을 한 단계 성장시켜줬다는 생각이 든다.</p>
<p><del>Redis는 하루 빨리 윈도우를 지원하도록 하라...</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 객체지향의 핵심을 이해하려면, 동적 바인딩부터 봐야 한다]]></title>
            <link>https://velog.io/@harvard--/Java-%EB%8F%99%EC%A0%81-%EB%B0%94%EC%9D%B8%EB%94%A9%EA%B3%BC-%EC%9D%98%EC%A1%B4%EC%84%B1</link>
            <guid>https://velog.io/@harvard--/Java-%EB%8F%99%EC%A0%81-%EB%B0%94%EC%9D%B8%EB%94%A9%EA%B3%BC-%EC%9D%98%EC%A1%B4%EC%84%B1</guid>
            <pubDate>Sun, 06 Apr 2025 17:45:02 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong><em>– 런타임에 결정되는 구조를 이해해야 객체지향의 핵심이 보인다 –</em></strong></p>
</blockquote>
<hr>
<h2 id="객체지향-이해를-위한-출발점-다형성과-동적-바인딩">객체지향 이해를 위한 출발점, 다형성과 동적 바인딩</h2>
<p>Java나 Kotlin 같은 객체지향 언어에서 왜 인터페이스나 추상 클래스를 자주 쓸까?</p>
<p>바로 <strong>다형성(polymorphism)</strong> 때문이다.</p>
<blockquote>
<p><strong>다형성</strong>이란, 하나의 추상 타입(예: 인터페이스 또는 부모 클래스)으로 여러 구현체(Dog, Cat 등)를 다룰 수 있는 성질이다.</p>
</blockquote>
<p>즉, 코드는 상위 타입(예: <code>Animal</code>)에만 의존하면서도, 실제 동작은 하위 클래스에 따라 다양하게 바뀔 수 있다.</p>
<pre><code class="language-java">Animal animal = new Dog();
animal.speak(); // &quot;멍멍&quot;</code></pre>
<p><code>animal.speak()</code>가 어떤 메서드를 호출할지는 <strong>컴파일 시점이 아닌 런타임에 결정</strong>된다.
즉, <strong>실제 객체인 Dog의 메서드가 동적으로 호출</strong>되며, 이를 <strong>동적 바인딩(dynamic binding)</strong> 이라고 한다.</p>
<p>이러한 구조 덕분에 클라이언트는 구체 클래스가 아닌 상위 타입만 알고 있어도, <strong>실제 동작은 구현체가 알아서 처리한다.</strong></p>
<p>여기서 중요한 점은, 이렇게 <strong>객체의 내부 구현을 감추고, 외부에서는 추상 타입을 통해 일관되게 접근하도록 동작하는 구조</strong>가 객체지향의 또 다른 핵심 개념인 <strong>캡슐화(encapsulation)</strong> 와 깊이 연결된다는 것이다.</p>
<blockquote>
<p><strong>캡슐화</strong>란, 객체 내부의 구현 세부사항을 외부로부터 숨기고
외부에는 필요한 인터페이스만 노출시키는 것을 말한다.</p>
</blockquote>
<p><strong>다형성은 추상 타입을 통해 메시지를 전달하고, 이로써 내부 구현을 감추는 방식으로 캡슐화를 실현한다.</strong></p>
<pre><code class="language-java">void makeAnimalSpeak(Animal animal) {
    animal.speak();
}</code></pre>
<p>이 <code>makeAnimalSpeak</code> 메서드는 <code>Dog</code>든 <code>Cat</code>이든 상관없이 <code>Animal</code>만 알면 된다.
<strong>구체적인 구현은 감춰진 채, 외부에선 통일된 인터페이스로 접근할 수 있다.</strong></p>
<blockquote>
<p>결국 객체지향의 4대 특징(추상화, 상속, 다형성, 캡슐화)은 서로 분리된 개념이 아니라,
<strong>강력한 캡슐화를 실현하기 위한 도구</strong>들이다.</p>
</blockquote>
<hr>
<h2 id="컴파일-의존성과-런타임-의존성의-차이-이해">컴파일 의존성과 런타임 의존성의 차이 이해</h2>
<p>앞에서 본 동적 바인딩은 메서드 호출을 런타임에 결정하게 해준다.<br>그런데 여기서 더 중요한 포인트가 하나 있다.</p>
<p>바로 <strong>&quot;컴파일 타임에 우리가 어떤 타입에 의존하고 있는가?&quot;</strong> 하는 점이다.</p>
<pre><code class="language-java">Animal animal = new Dog();</code></pre>
<ul>
<li><strong>컴파일 시점에는 Animal만 알고 있다.</strong></li>
<li><strong>실제 실행(런타임) 시점에는 동적 바인딩에 의해 Dog가 메모리에 올라간다.</strong><ul>
<li><strong>컴파일 의존성</strong>: <code>Animal</code>  </li>
<li><strong>런타임 의존성</strong>: <code>Dog</code></li>
</ul>
</li>
</ul>
<p>즉, 컴파일 타임에는 추상 타입만 의존하고, 런타임에 실제 구현체가 주입된다. 
이 차이가 왜 중요하고 어떤 효과를 불러 일으킬까?</p>
<blockquote>
<p>컴파일 시점에는 의존성을 느슨하게 가져가되,<br>런타임 시점에는 실제 구현체를 주입받음으로써 <strong>유연한 시스템</strong>을 만들 수 있게 된다.</p>
</blockquote>
<hr>
<h2 id="변경에-강한-구조는-느슨한-결합에서-나온다">변경에 강한 구조는 느슨한 결합에서 나온다</h2>
<p>위에서 말한 유연한 시스템을 가진 구조는 변경에 강한 구조라는 말이고
이는 곧 <strong>느슨한 결합(Loose Coupling)</strong> 으로 이어진다.
이것에 대해 한 호흡으로 정리를 해보자면,</p>
<p>객체지향 프로그래밍은 객체 간 협력으로 시스템을 완성해나간다.
이 과정에서 의존성은 반드시 생길 수밖에 없지만, 의존성은 변경을 전파시키는 주범이 된다.
그래서 우리는 변경의 전파를 최소화하는 것에 집중해야 한다.</p>
<blockquote>
<p>변경의 전파를 줄이려면, 자주 바뀌는 것에 의존하면 안 된다.</p>
</blockquote>
<p>즉, 클라이언트 객체는 구체적인 클래스가 아니라 변경 가능성이 낮은 추상 타입에 의존해야 한다.
이 추상 타입은 상위 계층으로 캡슐화할 수 있으며, 이를 가능하게 해주는 게
인터페이스와 추상 클래스, 그리고 타입 계층이다.</p>
<ul>
<li><p>타입 계층은 추상화를 구성하는 문법적 도구이자,</p>
</li>
<li><p>다형성의 기반이며,</p>
</li>
<li><p>캡슐화의 핵심 도구다.</p>
</li>
</ul>
<p>결국 이 글에서 설명하고 있는 모든 것들이 <strong>강력한 캡슐화</strong>를 위한 것이고, 강력한 캡슐화는 곧 변경에 유연한 시스템이라는 말이기 때문에 객체지향 프로그래밍의 <strong>핵심</strong>은 캡슐화로 귀결된다고 볼 수 있다.</p>
<hr>
<blockquote>
<p><strong>💡 동적 바인딩의 실제 메커니즘 (JVM 관점)</strong></p>
<p>JVM은 오버라이딩된 메서드를 호출할 때 <strong>가상 메서드 테이블(Virtual Method Table)</strong> 을 사용한다.</p>
<ul>
<li>클래스가 로딩되면, JVM은 각 클래스별로 메서드 테이블을 구성한다.</li>
</ul>
</blockquote>
<ul>
<li>오버라이딩된 메서드는 해당 구현체의 메서드 테이블을 따라 호출된다.</li>
<li>이 덕분에 런타임에 메서드 호출이 결정되는 유연성이 확보된다.<blockquote>
<p>이 구조 덕분에 우리는 인터페이스를 통해 여러 구현체를 주입하고,
런타임에 어떤 동작을 할지 자유롭게 바꿀 수 있다.</p>
</blockquote>
</li>
</ul>
<hr>
<h2 id="동적-바인딩을-사용할-때-주의할-점">동적 바인딩을 사용할 때 주의할 점</h2>
<p>동적 바인딩은 코드의 유연성과 확장성을 높여주는 강력한 도구지만, <strong>모든 기술이 그렇듯 사용할 때 고려해야 할 몇 가지 주의점</strong>이 있다.</p>
<h3 id="호출-흐름이-복잡해질-수-있다">호출 흐름이 복잡해질 수 있다</h3>
<p>메서드 호출이 런타임에 결정되기 때문에, 코드만 봐서는 <strong>실제로 어떤 구현이 실행되는지 파악하기 어렵다.</strong><br>IDE나 디버거의 도움 없이 추적하려면 전체 타입 계층을 파악해야 해서, <strong>디버깅이나 흐름 이해에 시간이 더 걸릴 수 있다.</strong></p>
<h3 id="리플렉션-프록시와-결합되면-추적이-더-어려워진다">리플렉션, 프록시와 결합되면 추적이 더 어려워진다</h3>
<p>동적 바인딩은 프록시, AOP, DI 프레임워크 같은 <strong>런타임 기반 기술과 자주 함께 사용된다.</strong><br>이럴 경우 메서드 호출이 실제 구현까지 도달하는 과정이 한 단계 더 늘어나며,<br><strong>디버깅 시 실제 호출 지점을 추적하는 데 혼란을 줄 수 있다.</strong></p>
<h3 id="책임을-명확히-분리해야-한다">책임을 명확히 분리해야 한다</h3>
<p>동적 바인딩은 추상화 위에 동작하기 때문에, <strong>과도한 추상화는 오히려 구조를 흐리게 만든다.</strong><br>책임이 명확히 나뉘지 않은 상태에서 여러 계층을 만들면, <strong>무슨 일이 어디서 일어나는지 알기 어려운 코드</strong>가 된다.<br><strong>&quot;유연하게 만들기 위해 추상화를 도입한다&quot;는 목표가, 오히려 가독성과 유지보수성을 해치는 결과가 될 수 있다.</strong></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>정리해보면,</p>
<ul>
<li>동적 바인딩은 <strong>실행 시점에 메서드를 결정</strong>하는 객체지향 언어의 핵심 메커니즘이다.</li>
<li>이를 통해 우리는 <strong>인터페이스 기반의 느슨한 결합</strong>을 설계할 수 있다.</li>
<li><strong>컴파일 타임에는 추상화에 의존</strong>, 런타임에는 실제 구현체로 동작을 유연하게 바꿀 수 있고,</li>
<li>이 유연함은 SOLID 원칙 중 <strong>OCP(변경에 닫히고 확장에 열림), DIP(의존성 역전), DI(의존성 주입)</strong> 같은 객체지향 설계의 핵심 원칙들을 가능하게 만든다.</li>
</ul>
<blockquote>
<p>유연함을 가능하게 만드는 핵심이 바로 <strong>동적 바인딩</strong>, 그리고 그걸 중심으로 하는 <strong>다형성</strong>이고,
이 모든 것들을 따라가다 보면, 마지막엔 <strong>캡슐화</strong>로 귀결될 수 밖에 없다.
결국 객체지향의 진짜 핵심은 <strong>&quot;캡슐화&quot;</strong>라고 할 수 있다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 클린 아키텍처란 무엇일까?]]></title>
            <link>https://velog.io/@harvard--/Spring-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C</link>
            <guid>https://velog.io/@harvard--/Spring-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C</guid>
            <pubDate>Fri, 04 Apr 2025 05:24:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;The database is a detail. The web is a detail. The frameworks are details.&quot;<br>— <em>Robert C. Martin (Uncle Bob)</em></p>
</blockquote>
<hr>
<h2 id="클린-아키텍처란">클린 아키텍처란?</h2>
<p><strong>클린 아키텍처(Clean Architecture)</strong>는 시스템의 핵심 규칙과 비즈니스 로직을 외부로부터 <strong>분리하고 보호하는 것</strong>에 집중하는 아키텍처 설계 패턴이다.</p>
<p>클린 아키텍처는 다음과 같은 문제를 해결하는 데 초점을 맞춘다.</p>
<ul>
<li>프레임워크에 종속적인 코드</li>
<li>UI/DB와 강하게 결합된 로직</li>
<li>테스트하기 어려운 구조</li>
<li>유지보수가 점점 어려워지는 코드베이스</li>
</ul>
<p>Uncle Bob이 제시한 이 아키텍처는 <strong>의존성 규칙</strong>을 중심으로, <strong>책임의 분리(SRP)</strong>와 <strong>의존성 역전(DIP)</strong>을 실천하는 구조다.</p>
<hr>
<h2 id="구조-개요-계층과-방향성">구조 개요: 계층과 방향성</h2>
<p><img src="https://velog.velcdn.com/images/harvard--/post/0bafa5ab-cfba-4f01-8449-9cb8763da268/image.png" alt=""></p>
<p>클린 아키텍처는 중심을 향하는 동심원 구조로 표현된다. 각 계층은 <strong>안쪽 계층에만 의존 가능</strong>하고, <strong>밖에서 안으로만 의존성이 흐른다.</strong></p>
<h3 id="의존성-규칙-dependency-rule">의존성 규칙 (Dependency Rule)</h3>
<blockquote>
<p><strong>&quot;안쪽 계층은 바깥 계층을 모른다.&quot;</strong></p>
</blockquote>
<hr>
<h2 id="계층별-상세-설명">계층별 상세 설명</h2>
<p><img src="https://velog.velcdn.com/images/harvard--/post/cf4c2422-5e5e-4db1-b0d6-4c73b93c0f9f/image.png" alt=""></p>
<hr>
<h3 id="1-entities-엔터티">1. <strong>Entities (엔터티)</strong></h3>
<ul>
<li><strong>가장 핵심적인 비즈니스 규칙</strong></li>
<li>도메인 개념, 도메인 메서드 (비즈니스 규칙을 스스로 수행하는 메서드) 포함</li>
<li>어떤 외부 환경에도 의존하지 않음</li>
<li>수명이 가장 길고, 시스템 전반에서 재사용됨</li>
</ul>
<blockquote>
<p>예: <code>User</code>, <code>Order</code>, <code>Policy</code>, <code>Money</code>, <code>Validator</code> 등</p>
</blockquote>
<pre><code class="language-java">public class User {
    private final String email;
    private final String password;

    public User(String email, String rawPassword) {
        this.email = email;
        this.password = hash(rawPassword); // 도메인 레벨에서 비밀번호 해싱
    }

    public boolean isValidPassword(String rawPassword) {
        return this.password.equals(hash(rawPassword));
    }

    private String hash(String rawPassword) {
        // 해싱 로직은 단순화
        return &quot;hashed:&quot; + rawPassword;
    }
}
</code></pre>
<hr>
<h3 id="2-use-cases-유스케이스--애플리케이션-서비스">2. <strong>Use Cases (유스케이스 / 애플리케이션 서비스)</strong></h3>
<ul>
<li>애플리케이션의 <strong>구체적인 동작 흐름</strong></li>
<li>엔터티를 조작해 특정 기능을 수행</li>
<li>외부 시스템과 직접 소통하지 않고, <strong>포트를 통해 간접 연결</strong></li>
</ul>
<blockquote>
<p>예: 회원 가입, 주문 생성, 결제 처리 등</p>
</blockquote>
<pre><code class="language-java">public class SignUpUseCase {
    private final UserRepository userRepository;

    public SignUpUseCase(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void execute(SignUpCommand command) {
        if (userRepository.existsByEmail(command.email())) {
            throw new DuplicateEmailException();
        }
        User user = new User(command.email(), command.password());
        userRepository.save(user);
    }
}
</code></pre>
<hr>
<h3 id="3-interface-adapters-인터페이스-어댑터">3. <strong>Interface Adapters (인터페이스 어댑터)</strong></h3>
<ul>
<li>외부와 내부 간의 <strong>형식 변환을 담당</strong></li>
<li>REST Controller, DTO, ViewModel, Repository 구현체 등이 위치</li>
<li>유스케이스 ↔ UI/DB/네트워크 계층을 연결하는 ‘어댑터’ 역할</li>
</ul>
<blockquote>
<p>예: <code>UserController</code>, <code>UserRequestDto</code>, <code>JpaUserRepository</code></p>
</blockquote>
<pre><code class="language-java">@RestController
public class UserController {
    private final SignUpUseCase signUpUseCase;

    public UserController(SignUpUseCase signUpUseCase) {
        this.signUpUseCase = signUpUseCase;
    }

    @PostMapping(&quot;/signup&quot;)
    public ResponseEntity&lt;Void&gt; signup(@RequestBody SignUpRequest request) {
        SignUpCommand command = new SignUpCommand(request.email(), request.password());
        signUpUseCase.execute(command);
        return ResponseEntity.ok().build();
    }
}</code></pre>
<hr>
<h3 id="4-frameworks--drivers-프레임워크와-드라이버">4. <strong>Frameworks &amp; Drivers (프레임워크와 드라이버)</strong></h3>
<ul>
<li>가장 바깥 계층, <strong>외부 기술 요소</strong>들로 구성</li>
<li>교체 가능한 기술로 취급하며, 안쪽 계층에서는 존재 자체를 알지 못함</li>
</ul>
<blockquote>
<p>예: <code>Spring</code>, <code>MySQL</code>, <code>Redis</code>, <code>Kafka</code>, <code>SMTP</code>, <code>React</code> 등</p>
</blockquote>
<pre><code class="language-java">// 실제 DB와 매핑되는 구조. 도메인 객체인 User와는 구분됨.
public interface SpringDataUserRepository extends JpaRepository&lt;UserEntity, Long&gt; {
    boolean existsByEmail(String email);
}
</code></pre>
<p>이 계층에선 어떤 프레임워크를 쓰든 상관없다. 클린 아키텍처에서는 이런 기술들이 <strong>‘디테일’</strong>이기 때문이다.</p>
<hr>
<h2 id="의존성-역전-원칙-dependency-inversion-principle-dip">의존성 역전 원칙 (Dependency Inversion Principle, DIP)</h2>
<p>이 모든 계층 구조의 핵심을 관통하는 원칙은 바로 <strong>의존성 역전 원칙(DIP)</strong>이다.</p>
<ul>
<li><strong>상위 수준 모듈은 하위 수준 모듈에 의존하지 않는다.</strong></li>
<li>둘 다 <strong>추상화에 의존</strong>해야 한다.</li>
<li><strong>추상화는 세부사항에 의존하지 않는다.</strong> 세부사항이 추상화에 의존해야 한다.</li>
</ul>
<p>유스케이스는 오직 <strong>Repository 인터페이스(추상화)</strong>에만 의존하며, 실제 구현은 인터페이스 어댑터 계층에서 주입된다.
이로 인해 유스케이스는 Spring이나 DB와 같은 인프라 기술의 변화에 영향을 받지 않는다.</p>
<h4 id="예시-다이어그램">예시 다이어그램</h4>
<p><img src="https://velog.velcdn.com/images/harvard--/post/21ce98c2-fb23-45dd-8650-1545e50ee1d2/image.png" alt=""></p>
<h4 id="예시-코드">예시 코드</h4>
<pre><code class="language-java">// UseCase 계층 - 추상화만 의존
public interface UserRepository {
    void save(User user);
    boolean existsByEmail(String email);
}

// Interface Adapter 계층 - 세부 구현
@Repository
public class JpaUserRepository implements UserRepository {
    private final SpringDataUserRepository jpaRepo;

    public JpaUserRepository(SpringDataUserRepository jpaRepo) {
        this.jpaRepo = jpaRepo;
    }

    public void save(User user) {
        jpaRepo.save(UserEntity.from(user));
    }

    public boolean existsByEmail(String email) {
        return jpaRepo.existsByEmail(email);
    }
}
</code></pre>
<hr>
<h2 id="dip가-만들어내는-클린-아키텍처의-4가지-특징">DIP가 만들어내는 클린 아키텍처의 4가지 특징</h2>
<h3 id="framework-독립성"><strong>Framework 독립성</strong></h3>
<blockquote>
<p>어떤 프레임워크도 내부 로직을 지배하지 않는다.</p>
</blockquote>
<h3 id="ui-독립성"><strong>UI 독립성</strong></h3>
<blockquote>
<p>웹이든 앱이든 CLI든 상관없다. UI는 교체 가능한 껍데기일 뿐이다.</p>
</blockquote>
<h3 id="db-독립성"><strong>DB 독립성</strong></h3>
<blockquote>
<p>RDB, NoSQL, 파일 시스템, 메시징 시스템과 독립적인 도메인 로직.</p>
</blockquote>
<h3 id="테스트-용이성"><strong>테스트 용이성</strong></h3>
<blockquote>
<p>외부 환경이 없어도, 순수 자바 코드로 유닛 테스트 가능.</p>
</blockquote>
<hr>
<h2 id="클린-아키텍처의-장점">클린 아키텍처의 장점</h2>
<ul>
<li>비즈니스 로직의 <strong>장기적 생존 가능성</strong> 확보</li>
<li>변화에 유연한 구조 (DB, 프레임워크 교체 시 최소 영향)</li>
<li>고립된 테스트, 유연한 배포 전략 가능</li>
<li>유지보수성과 협업 효율 ↑</li>
</ul>
<hr>
<h2 id="클린-아키텍처의-단점">클린 아키텍처의 단점</h2>
<ul>
<li>초반 설계와 코드량이 많아짐</li>
<li>작은 팀/프로젝트에서는 <strong>오버엔지니어링</strong>이 될 수 있음</li>
<li>팀원 간 <strong>합의된 규칙</strong> 없이 구조만 흉내 내면 오히려 코드가 복잡해질 수 있음</li>
</ul>
<hr>
<h2 id="실제-적용-시-고려사항">실제 적용 시 고려사항</h2>
<ul>
<li><strong>패키지 구조</strong>도 의존성 방향을 반영해야 함</li>
<li><strong>모듈 분리(멀티모듈)</strong>와 함께 쓰면 더 강력해짐</li>
<li>각 계층마다 <strong>명확한 책임 분리와 테스트 전략</strong> 수립 필요</li>
<li><strong>인터페이스(포트)</strong>를 정의할 때, 추상화 범위를 잘 조절할 것<ul>
<li><strong>포트를 너무 세분화하면 오히려 추상화 비용이 커질 수 있음</strong></li>
</ul>
</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>클린 아키텍처는 단지 &quot;겹겹이 나눈 계층&quot;이 아니다.<br><strong>비즈니스 로직을 보호하고, 기술 변화에 유연하게 대처할 수 있도록 설계하는 사고방식</strong>이다.</p>
<p><strong><a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html">The Clean Code Blog - The Clean Architecture
by <em>Robert C. Martin (Uncle Bob)</em></a></strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 일정 관리 API with JPA]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-API-with-JPA</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-API-with-JPA</guid>
            <pubDate>Thu, 03 Apr 2025 08:33:02 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>지난 번에 구현했던 일정 관리 API 과제의 심화 버전이다.</p>
<p>이전과 다른 요구 사항 중 큰 것들만 나열한다면 다음과 같다.</p>
<blockquote>
<ul>
<li><strong>JPA 사용</strong></li>
</ul>
</blockquote>
<ul>
<li><strong>Filter 사용</strong></li>
<li><strong>댓글 게시판 추가</strong></li>
<li><strong>쿠키/세션을 활용한 로그인 로직 구현</strong></li>
<li><strong>Bcrypt를 활용한 비밀번호 암호화 구현</strong></li>
</ul>
<p>이번 구현에서는 여러 관점에서 어떤 방법이 더 나을지 고민해서 설계하는 데 초점을 맞췄다. 기술적인 어려움은 크게 없었기에 트러블 슈팅보다는 <strong>튜터님들께 받은 피드백을 토대로 리팩토링한 과정</strong>을 중심으로 정리해보려고 한다.</p>
<hr>
<h2 id="1-예외-처리는-어느-레이어에서-해야-할까">1. 예외 처리는 어느 레이어에서 해야 할까?</h2>
<h4 id="기존-코드">기존 코드</h4>
<pre><code class="language-java">// UserRepository
User findUserByEmailOrElseThrow(String email);

default User findUserByEmailOrElseThrow(String email) {
    return findUserByEmail(email)
        .orElseThrow(() -&gt; new ResponseStatusException(HttpStatus.NOT_FOUND,
        &quot;이메일 &quot; + email + &quot;에 해당하는 사용자가 존재하지 않습니다.&quot;));
}</code></pre>
<h4 id="수정-코드">수정 코드</h4>
<pre><code class="language-java">// LoginService
Optional&lt;User&gt; user = userRepository.findUserByEmail(dto.getEmail());

if (user.isEmpty() || passwordEncoder.matches(dto.getPassword(), user.get().getPassword())) {
    throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
    &quot;아이디 또는 비밀번호가 일치하지 않습니다.&quot;);
}</code></pre>
<p>로그인 로직에서 사용자를 조회하는 과정의 예외 처리를 어느 레이어에서 할지 고민이 있었다. 
물론 로그인 로직은 비즈니스 로직이라는 것을 알고 있었지만 코드의 가독성을 고려해 예외 처리를 어디서 할지 고민했다.</p>
<p><strong>1. Repository 레이어에서 예외 처리</strong></p>
<ul>
<li>메서드 한 줄만 추가하면 돼서 코드가 간결해진다.</li>
<li>여러 곳에서 동일한 예외 처리를 해야 할 경우 중복을 줄일 수 있다.</li>
</ul>
<p><strong>2. Service 레이어에서 예외 처리</strong></p>
<ul>
<li>여러 곳에서 동일한 예외 처리를 해야 할 경우 중복이 많아져서 가독성이 떨어질 수 있다.</li>
</ul>
<p>이번 과제에서는 사용할 곳이 한 곳뿐이었지만, 추후에 많아질 것을 가정하고 <strong>Repository</strong> 레이어에서 예외 처리를 하는 방식으로 구현했었다.</p>
<h4 id="로그인-로직의-흐름을-정리해보면">로그인 로직의 흐름을 정리해보면</h4>
<blockquote>
<p>1️⃣ 사용자가 여러 기능을 사용하려면 로그인이 필요하다.
2️⃣ 로그인을 위해 이메일과 비밀번호를 입력받는다.
3️⃣ 입력받은 이메일로 사용자 정보를 조회한다. <strong>(이 단계의 예외 처리 고민)</strong>
4️⃣ 조회한 정보의 비밀번호와 사용자가 입력한 비밀번호를 비교한다.
5️⃣ 검증에 성공하면 세션을 설정하고 실패하면 예외를 던진다.</p>
</blockquote>
<p>로그인 로직의 핵심은 <strong>&quot;사용자 조회와 인증 수행&quot;</strong>이다. 즉, 비즈니스 로직이다.
그래서 <strong>Service</strong> 레이어에서 예외 처리를 하는 방식으로 수정했다.
또, 지금 생각해보니 로그인 과정 중에 발생하는 예외를 <code>UserRepository</code>에서 처리하는 것도 이상하긴 하다.</p>
<hr>
<h2 id="2-세션-정보-출력-메서드-static-사용">2. 세션 정보 출력 메서드 static 사용</h2>
<h4 id="로그-출력-메서드">로그 출력 메서드</h4>
<pre><code class="language-java">@Slf4j
public class SessionLogger {

    public static void logSessionInfo(HttpSession session) {
        log.info(&quot;session.getId()={}&quot;, session.getId());
        log.info(&quot;session.getMaxInactiveInterval()={}&quot;, session.getMaxInactiveInterval());
        log.info(&quot;session.getCreationTime()={}&quot;, session.getCreationTime());
        log.info(&quot;session.getLastAccessedTime()={}&quot;, session.getLastAccessedTime());
        log.info(&quot;session.isNew()={}&quot;, session.isNew());
    }</code></pre>
<h4 id="로그인-필터">로그인 필터</h4>
<pre><code class="language-java">@Slf4j
public class LoginFilter implements Filter {

    private static final String[] ALLOWED_PATHS = {&quot;/&quot;, &quot;/api/users/signup&quot;, &quot;/api/login&quot;, &quot;/api/logout&quot;};

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();

        if (isAllowedPath(requestURI)) {
            chain.doFilter(request, response);
            return;
        }

        log.info(&quot;로그인 필터 로직 실행&quot;);

        HttpSession session = httpRequest.getSession(false);

        if (session == null || session.getAttribute(&quot;userId&quot;) == null) {
            log.warn(&quot;로그인되지 않은 사용자 요청: {}&quot;, requestURI);
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, &quot;로그인 해주세요.&quot;);
        }

        // 세션 정보 출력
        SessionLogger.logSessionInfo(session);

        chain.doFilter(request, response);
    }

    private boolean isAllowedPath(String requestURI) {
        return PatternMatchUtils.simpleMatch(ALLOWED_PATHS, requestURI);
    }
</code></pre>
<p>로그인 필터에서 세션 정보를 출력하는 메서드를 <code>static</code>으로 사용할지 고민했다.
성능과 보안, 두 가지 관점에서 했던 고민한 내용은 다음과 같다.</p>
<blockquote>
<p>1️⃣ <code>new</code> 키워드로 객체를 생성해서 사용을 하면 메모리 낭비가 너무 심하지 않을까? <strong>(성능)</strong>
2️⃣ 정적 메서드로 사용하면 혹시 세션 정보 탈취 가능성이 있을까? <strong>(보안)</strong></p>
</blockquote>
<p>1번 고민을 했던 이유는 다음과 같다. 
지정한 URI를 제외한 모든 경로에 접속하면 로그인 필터가 실행되고, 실행될 때마다 세션 정보 출력 메서드가 호출된다. 
즉, 접근 및 실행 횟수가 많을 가능성이 크다. </p>
<p>만약 <code>new</code> 키워드를 사용해 매번 <code>SessionLogger</code> 객체를 생성하고 <code>logSessionInfo()</code> 메서드를 사용한다면, GC 모델에 따라 메모리 해제 시간이 달라지겠지만 메모리 낭비가 발생할 수 있다고 생각했다. 
그래서 <code>static</code>으로 구현했는데, 이 부분이 2번 고민과 연결된다.</p>
<p>2번 고민의 결론부터 말하자면 <code>jar</code>로 배포된 파일 내부까지 침투해 세션 정보를 탈취하는 건 거의 불가능이라고 한다. 대부분의 보안 관련 이슈들은 단순한 구조에서 발생한다고 한다.
보안쪽은 잘 몰라서 했던 고민이였는데, 새로운 사실들을 알게 됐으니 의미 없는 고민은 아니었던 거 같다.</p>
<hr>
<h2 id="3-높은-수준이-뭐고-낮은-수준이-무엇인가">3. 높은 수준이 뭐고 낮은 수준이 무엇인가?</h2>
<h4 id="기존-코드-1">기존 코드</h4>
<pre><code class="language-java">public void updateUser(UserUpdateRequestDto dto) {
    this.username = dto.getUsername();
}

@Transactional
@Override
public UserResponseDto updateUser(Long id, UserUpdateRequestDto dto) {
    User user = userRepository.findUserByIdOrElseThrow(id);

    if (passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
        throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, &quot;비밀번호가 일치하지 않습니다.&quot;);
    }

    user.updateUser(dto);
    return new UserResponseDto(user);
    }</code></pre>
<p>기존 코드의 문제점은 코드 가독성을 중요하게 생각하고 작성한 탓에 <strong><code>UserUpdateRequestDto</code></strong>에서 값을 개별적으로 꺼내지 않고 <code>dto</code> 객체 자체를 통째로 넘긴다는 것이다. 이게 왜 문제가 되냐면, 낮은 수준의 객체를 높은 수준의 객체 내부에서 직접 참조하기 때문이다.</p>
<p>그렇다면 높은 수준과 낮은 수준이 무엇인지 알아야 한다.
<strong>&quot;클린 아키텍처 의존성 규칙&quot;</strong>이라는 키워드로 공부하면 도움이 된다. 
클린 아키텍처에선 객체를 4계층으로 분류하지만, 이 코드에선 두 가지 객체만 비교하는 것이기 때문에 <strong>높고 낮음</strong>이라는 표현을 쓰는 것이다.</p>
<blockquote>
<ul>
<li><strong>높은 수준</strong> : 도메인의 핵심 비즈니스 로직을 포함하는 객체</li>
<li><strong>낮은 수준</strong> : 단순히 데이터를 전달하는 역할을 하는 객체</li>
</ul>
</blockquote>
<p>이 코드에서 높은 수준의 객체는 <strong><code>User</code></strong> 낮은 수준의 객체는 <strong><code>UserUpdateRequestDto</code></strong>라고 보면 된다. 만약 <strong><code>UserUpdateRequestDto</code></strong>의 구조가 변경되면, 이 변경이 <strong><code>User</code></strong> 객체까지 전파될 가능성이 있다.</p>
<h4 id="수정-코드-1">수정 코드</h4>
<pre><code class="language-java">public void updateUser(String username) {
    this.username = username;
}

@Transactional
@Override
public UserResponseDto updateUser(Long id, UserUpdateRequestDto dto) {
    User user = userRepository.findUserByIdOrElseThrow(id);

    if (passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
        throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, &quot;비밀번호가 일치하지 않습니다.&quot;);
    }

    user.updateUser(dto.getUsername());
    return new UserResponseDto(user);
    }</code></pre>
<p>그럼 <code>String</code>을 사용하는 것은 괜찮나? 라는 의문이 들 수도 있다. 
<code>String</code>, <code>Long</code>과 같은 기본적인 값 객체들은 사실상 변경 가능성이 없고, 모든 도메인에서 공통적으로 사용하는 가장 기본적인 타입이기 때문에 개발자가 직접 작성하는 엔터티보다 <strong>훨씬 안정적인 타입</strong>이다. 
엔터티가 DTO 같은 특정한 요청 객체를 직접 참조하는 것보다, 기본 값 타입을 사용하는 것이 더 유연하고 안정적인 설계다.
이렇게 작은 수정만으로도 의존성 방향을 올바르게 유지하면서 <strong>더 객체지향적인 코드</strong>로 개선할 수 있다.</p>
<hr>
<h2 id="느낀점">느낀점</h2>
<p>객체지향적으로 모든 부분을 완벽하게 설계하고 구현하면 좋겠지만, 코드의 가독성을 중요하게 생각하고 구현하다 보니 객체지향적인 부분을 놓치게 된다. 
적당한 중간 지점을 찾는 게 쉽지 않다.
그래도 확실히 이전보다는 객체지향적 사고가 아주 조금 트인 것 같다.</p>
<p>이번 과제를 하면서 블로그에 정리할 몇 가지 키워드들도 얻었다.
영속성 컨텍스트, 클린 아키텍처, <code>JPQL</code>과 <code>nativeQuery</code>의 장단점 등.
이것들도 하나하나 부지런히 정리하면서 정진해 나가야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 가비지 컬렉터 (Garbage Collector, GC)]]></title>
            <link>https://velog.io/@harvard--/Java-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%ED%84%B0-Garbage-Collector-GC</link>
            <guid>https://velog.io/@harvard--/Java-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%ED%84%B0-Garbage-Collector-GC</guid>
            <pubDate>Sun, 30 Mar 2025 06:11:35 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>가비지 컬렉터는 메모리 관리 기법 중의 하나로, 프로그램이 동적으로 할당했던 메모리 영역 중에서 필요없게 된 영역을 해제하는 기능이다. 힙(heap) 메모리에서 더 이상 참조되지 않는 객체가 차지하는 메모리를 자동으로 수집하고 해제하는 방식으로 메모리 관리를 수행하는 기능이다.</p>
<p>자바 버전마다 사용되는 GC 방식이 다르며, 시간이 지남에 따라 GC의 성능과 효율성이 향상되었다. 각 GC의 특징과 장단점을 정리해보려고 한다.</p>
<hr>
<h2 id="1-serial-gc-단일-스레드-방식">1. <strong>Serial GC (단일 스레드 방식)</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 1.3</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>Serial GC는 <strong>단일 스레드</strong>로 동작하여, 애플리케이션에서 모든 GC 작업을 <strong>순차적으로 처리</strong>한다.</li>
<li>가비지 컬렉션이 진행될 때 애플리케이션 스레드를 <strong>모두 멈추는</strong> <code>Stop-the-world</code>(STW) 방식을 사용한다.</li>
<li>전체 힙을 <strong>단일 스레드</strong>로 순차적으로 스캔하고, 더 이상 사용되지 않는 객체를 제거한다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li>구현이 간단하고, <strong>소형 시스템</strong>이나 <strong>메모리가 작은 환경</strong>에서 효과적이다.</li>
<li>단일 스레드로 동작하기 때문에 멀티스레드 환경에서의 경쟁 상태를 고려할 필요가 없다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li><code>Stop-the-world</code> 방식으로 애플리케이션이 멈추기 때문에 대규모 시스템에서 <strong>성능</strong> 문제가 발생할 수 있다.</li>
<li>멀티코어 환경에서 비효율적이며, 멀티스레드 기반으로 최적화된 시스템에서는 성능이 떨어질 수 있다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-parallel-gc-병렬-방식">2. <strong>Parallel GC (병렬 방식)</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 1.4</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>Parallel GC는 여러 개의 <strong>GC 스레드</strong>를 사용하여 가비지 컬렉션을 <strong>병렬 처리</strong>한다.</li>
<li>힙을 <strong>Young Generation</strong>과 <strong>Old Generation</strong>으로 나누어 각각 독립적으로 처리한다.</li>
<li><code>Stop-the-world</code> 방식이지만, 여러 스레드가 병렬로 처리하여 시간을 단축시킨다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li>멀티코어 시스템에서 성능이 크게 향상된다.</li>
<li><strong>대규모 시스템</strong>에서 많은 객체를 처리할 때 효율적이다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li>여전히 <code>Stop-the-world</code>가 발생하여, <strong>긴 GC 시간</strong>이 발생할 수 있다.</li>
<li><strong>응답 시간이 중요한 시스템</strong>에서는 부적합할 수 있다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-cms-concurrent-mark-sweep-gc">3. <strong>CMS (Concurrent Mark-Sweep) GC</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 1.5</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>CMS GC는 <strong>병렬</strong> 및 <strong>동시</strong> 방식으로 가비지 컬렉션을 수행한다.</li>
<li><strong>Initial Mark</strong> 단계에서 잠깐 애플리케이션을 멈추고, 그 이후는 대부분의 작업을 <strong>애플리케이션과 동시에 수행</strong>한다.</li>
<li><code>Mark</code>와 <code>Sweep</code> 단계는 애플리케이션 스레드와 동시에 작업을 진행하며, 최종적으로 메모리를 청소한다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li><strong>STW 시간</strong>을 최소화할 수 있어, <strong>응답 속도가 중요한 시스템</strong>에 적합하다.</li>
<li><strong>웹 서버</strong>나 <strong>데이터베이스 서버</strong>처럼 <strong>빠른 응답 시간</strong>을 요구하는 애플리케이션에 효과적이다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li>메모리 <strong>단편화</strong> 문제가 발생할 수 있다.</li>
<li>CPU 자원을 많이 소모하므로, <strong>자원 관리</strong>에 유의해야 한다.</li>
<li>최신 버전에서는 <strong>Garbage First(G1)</strong>로 대체되는 경향이 있다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-g1-garbage-first-gc">4. <strong>G1 (Garbage First) GC</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 7 (JDK 9부터 기본)</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>G1 GC는 <strong>힙 메모리</strong>를 여러 개의 <strong>Region</strong>으로 나누고, 각 Region의 <strong>우선순위</strong>에 따라 가비지 컬렉션을 수행한다.</li>
<li><strong>Young Generation</strong>과 <strong>Old Generation</strong>을 통합적으로 관리하며, <strong>우선순위가 높은 영역</strong>부터 GC를 수행한다.</li>
<li>G1은 <strong>STW 시간을 예측 가능하게 조절</strong>할 수 있도록 설계되었다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li>GC의 성능을 <strong>예측 가능</strong>하게 관리할 수 있다.</li>
<li><strong>대규모 애플리케이션</strong>에서 <strong>메모리 단편화</strong> 문제를 개선할 수 있다.</li>
<li>GC 시간의 <strong>최대 제한</strong>을 설정하여 일정한 성능을 보장한다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li>G1은 <strong>CMS</strong>보다 <strong>CPU 사용량</strong>이 많고, <strong>소형 힙</strong>에서는 성능 향상이 적을 수 있다.</li>
<li><strong>초기화가 느리고</strong>, 설정이 복잡할 수 있다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="5-zgc-z-garbage-collector">5. <strong>ZGC (Z Garbage Collector)</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 11</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>ZGC는 <strong>STW 시간을 10ms 이하로 유지</strong>하는 것을 목표로 설계되었다.</li>
<li><strong>배경에서 대부분의 작업을 수행</strong>하며, 메모리 회수 과정에서 애플리케이션 스레드를 최소화한다.</li>
<li>ZGC는 힙 크기가 <strong>8MB~16TB</strong>까지 확장 가능한 <strong>대규모 시스템</strong>에서 잘 동작한다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li><strong>실시간 시스템</strong>에서 중요한 <strong>저지연 처리</strong>가 가능하다.</li>
<li><strong>대용량 힙</strong>에서 효율적으로 동작하며, <strong>응답 시간이 매우 짧다</strong>.</li>
<li><code>Stop-the-world</code> 시간이 <strong>10ms 이하</strong>로 매우 짧아 시스템 성능에 미치는 영향이 적다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li><strong>CPU 사용량</strong>이 상대적으로 많다.</li>
<li>Windows 지원이 <strong>JDK 15</strong>부터 가능해지며, <strong>초기 버전에서는 지원이 부족</strong>하다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="6-shenandoah-gc">6. <strong>Shenandoah GC</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 12</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>Shenandoah는 <strong>STW 시간을 1~10ms로 최소화</strong>하는 것을 목표로 설계되었다.</li>
<li><strong>배경에서 대부분의 가비지 컬렉션</strong>을 수행하며, 애플리케이션의 멈춤 시간은 거의 <strong>없다</strong>.</li>
<li>병렬로 메모리를 회수하며, 대규모 시스템에서 잘 동작한다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li><strong>실시간</strong>에 가까운 성능을 제공하며, <strong>STW 시간이 짧다</strong>.</li>
<li><strong>멀티코어 환경</strong>에서 효율적이고 <strong>대규모 힙</strong>을 처리하는 데 유리하다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li><strong>CPU 사용량</strong>이 높을 수 있으며, <strong>메모리 공간</strong>을 충분히 확보해야 한다.</li>
<li><strong>메모리 관리</strong>가 복잡할 수 있어 작은 힙에서 성능이 크게 개선되지 않을 수 있다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="7-generational-zgc-jdk-21">7. <strong>Generational ZGC (JDK 21)</strong></h2>
<ul>
<li><p><strong>JDK 버전</strong>: JDK 21</p>
</li>
<li><p><strong>동작 방식</strong>:  </p>
<ul>
<li>기존 ZGC를 개선하여 <strong>Young Generation과 Old Generation을 구분</strong>하여 처리한다.</li>
<li><strong>G1의 장점</strong>을 그대로 가져가면서, ZGC의 <strong>저지연 성능</strong>을 더욱 개선한다.</li>
</ul>
</li>
<li><p><strong>장점</strong>:  </p>
<ul>
<li><strong>STW 시간을 더 줄일 수</strong> 있어 <strong>대규모 실시간 시스템</strong>에서 매우 유용하다.</li>
<li>기존 ZGC보다 <strong>성능 향상</strong>을 제공한다.</li>
</ul>
</li>
<li><p><strong>단점</strong>:  </p>
<ul>
<li>최신 기술로 <strong>실사용 사례</strong>가 적다.</li>
<li><strong>구성 및 설정이 복잡</strong>할 수 있다.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="gc-종류-비교">GC 종류 비교</h2>
<table>
<thead>
<tr>
<th>GC 종류</th>
<th>출시 연도</th>
<th>STW 시간</th>
<th>멀티코어 지원</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Serial GC</strong></td>
<td>JDK 1.3</td>
<td>김</td>
<td>✗</td>
<td>단일 스레드, 소형 시스템 적합</td>
</tr>
<tr>
<td><strong>Parallel GC</strong></td>
<td>JDK 1.4</td>
<td>중간</td>
<td>✔</td>
<td>멀티코어, 높은 처리량</td>
</tr>
<tr>
<td><strong>CMS GC</strong></td>
<td>JDK 1.5</td>
<td>짧음</td>
<td>✔</td>
<td>STW 최소화, 메모리 단편화 문제</td>
</tr>
<tr>
<td><strong>G1 GC</strong></td>
<td>JDK 7 (JDK 9 기본)</td>
<td>예측 가능</td>
<td>✔</td>
<td>Region 기반, 단편화 해결</td>
</tr>
<tr>
<td><strong>ZGC</strong></td>
<td>JDK 11</td>
<td>10ms 이하</td>
<td>✔</td>
<td>초저지연, 대용량 메모리</td>
</tr>
<tr>
<td><strong>Shenandoah</strong></td>
<td>JDK 12</td>
<td>1~10ms</td>
<td>✔</td>
<td>STW 최소화, 높은 CPU 사용량</td>
</tr>
<tr>
<td><strong>Gen ZGC</strong></td>
<td>JDK 21</td>
<td>매우 짧음</td>
<td>✔</td>
<td>ZGC 최적화 버전</td>
</tr>
</tbody></table>
<hr>
<h2 id="결론">결론</h2>
<p>다음은 실무에서 채택한 JDK 버전별 사용 비율과 미사용 비율을 나타낸 표다.</p>
<table>
<thead>
<tr>
<th>JDK 버전</th>
<th>사용 비율 (%)</th>
<th>미사용 비율 (%)</th>
</tr>
</thead>
<tbody><tr>
<td>Java 8</td>
<td>42.9 %</td>
<td>57.1 %</td>
</tr>
<tr>
<td>Java 11</td>
<td>28.6 %</td>
<td>71.4 %</td>
</tr>
<tr>
<td>Java 17</td>
<td>35.7 %</td>
<td>64.3 %</td>
</tr>
<tr>
<td>Java 21</td>
<td>35.7 %</td>
<td>64.3 %</td>
</tr>
<tr>
<td>Java 24</td>
<td>0.0 %</td>
<td>100.0 %</td>
</tr>
</tbody></table>
<p>결론적으로, 실무에서는 <strong>JDK 7 미만</strong>을 쓰는 경우가 많지 않고, 대부분 <strong>JDK 8 이상</strong> 버전이 사용된다. 이에 따라 CMS GC는 점차 사용되지 않고, G1 GC, ZGC, Shenandoah GC가 주요 선택지가 된다. 특히, 응답 시간이 중요한 웹 애플리케이션에서는 G1 GC를 선택하는 경우가 많고, 대규모 실시간 시스템에서는 ZGC나 Shenandoah GC가 많이 사용된다고 한다.</p>
<p>최신 JDK에서는 JVM 옵션을 통해 GC를 설정하거나 변경할 수 있어, 각 GC 방식의 특성을 잘 이해하고 애플리케이션의 규모, 성능 요구 사항, 메모리 사용 패턴에 맞춰 적절한 GC를 선택하고 활용하는 것이 중요한 성능 최적화 전략이 된다고 볼 수 있겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] DTO & VO]]></title>
            <link>https://velog.io/@harvard--/Spring-DTO-VO</link>
            <guid>https://velog.io/@harvard--/Spring-DTO-VO</guid>
            <pubDate>Fri, 28 Mar 2025 10:22:40 GMT</pubDate>
            <description><![CDATA[<h2 id="개요"><strong>개요</strong></h2>
<p><strong>DTO와 VO</strong>의 개념은 Java와 Spring 애플리케이션에서 자주 혼동되는 부분이다. 나 또한 두 객체의 차이점을 구별하기 힘들어 이 글을 작성하면서 공부해보려고 한다. 여러 자료를 찾아보니 나처럼 헷갈려하는 사람들이 많더라..  </p>
<p>더 이상 헷갈려하지 않기 위해 <strong>DTO(Data Transfer Object)</strong>와 <strong>VO(Value Object)</strong>의 차이를 명확히 구분하고, 각각이 어떻게 사용되는지에 대해 정리해보려고 한다.</p>
<hr>
<h2 id="1-dto-data-transfer-object">1. <strong>DTO (Data Transfer Object)</strong></h2>
<p><strong>DTO</strong>는 계층 간 데이터를 전달하기 위한 객체<br>Spring 애플리케이션에서 주로 <strong>Controller ↔ Service ↔ Repository</strong> 간에 데이터를 주고받을 때 사용</p>
<h3 id="dto-특징"><strong>DTO 특징</strong></h3>
<ul>
<li><strong>데이터 전달용</strong> 객체로, 서비스 계층 또는 컨트롤러에서 필요한 데이터를 <strong>필드로 담아서</strong> 전달.</li>
<li><strong>가변 객체</strong>로 설계될 수 있으며, <strong>직렬화(Serialization)</strong>가 가능.</li>
<li>주로 <strong>HTTP 요청/응답에 사용</strong>되며, <strong>API 응답</strong>을 위한 JSON 형태로 변환될 수 있음.</li>
<li><strong>Spring의 <code>@RestController</code></strong>에서 API 응답으로 활용하거나, <strong><code>@RequestBody</code></strong>로 API 요청을 처리하는 데 사용됨.</li>
</ul>
<h3 id="dto-예시-spring-controller-사용"><strong>DTO 예시 (Spring Controller 사용)</strong></h3>
<pre><code class="language-java">public class UserDTO {
    private String name;
    private int age;

    public UserDTO(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getter
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}</code></pre>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/users&quot;)
public class UserController {

    @PostMapping
    public ResponseEntity&lt;UserDTO&gt; createUser(@RequestBody UserDTO userDTO) {
        // DTO를 받아서 처리 후, 다시 DTO로 응답
        return ResponseEntity.ok(userDTO);
    }
}</code></pre>
<blockquote>
<p><strong>DTO</strong>는 <strong>HTTP 요청을 처리</strong>할 때 <strong><code>@RequestBody</code></strong>로 요청 데이터를 받거나, <strong><code>@ResponseBody</code></strong>로 JSON 응답을 보낼 때 사용된다.</p>
</blockquote>
<h3 id="dto-사용-목적"><strong>DTO 사용 목적</strong></h3>
<ul>
<li>계층 간 데이터 전달 (Service ↔ Controller 등)</li>
<li>HTTP 요청/응답 처리 (주로 API 데이터 전송)</li>
<li>필요한 데이터만 포함, 불필요한 데이터 제외 가능</li>
</ul>
<hr>
<h4 id="💡추가-dto를-불변-객체로-선언하는-방법과-장점">(💡+추가) DTO를 불변 객체로 선언하는 방법과 장점</h4>
<p>DTO를 불변 객체로 설계하면, <strong>객체가 생성된 후 데이터 변경을 방지</strong>할 수 있어 데이터의 무결성을 보장한다.<br><strong><code>final</code> 필드와 생성자</strong>를 사용하여 불변 객체를 선언할 수 있다.</p>
<h4 id="dto-불변-객체-예시">DTO 불변 객체 예시</h4>
<pre><code class="language-java">public class UserDTO {
    private final String name;
    private final int age;

    public UserDTO(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}</code></pre>
<h4 id="불변-dto의-장점"><strong>불변 DTO의 장점</strong></h4>
<ol>
<li><strong>무결성 유지</strong>: 객체의 상태가 변경되지 않기 때문에 데이터의 <strong>무결성</strong>을 보장.</li>
<li><strong>스레드 안전성</strong>: 불변 객체는 <strong>멀티스레드 환경</strong>에서도 안전하게 사용될 수 있음.</li>
<li><strong>의도 명확화</strong>: 불변 객체를 사용하면 <strong>값 변경을 방지</strong>할 수 있어, 다른 개발자에게 의도를 명확히 전달할 수 있음.</li>
</ol>
<hr>
<h2 id="2-vo-value-object">2. <strong>VO (Value Object)</strong></h2>
<p><strong>VO</strong>는 값 객체로, <strong>값</strong>에 의미를 두고 <strong>불변(Immutable)</strong>하도록 설계<br>Spring에서 VO는 주로 <strong>비즈니스 로직</strong>에서 <strong>값을 표현하는 객체</strong>로 사용</p>
<h3 id="vo-특징"><strong>VO 특징</strong></h3>
<ul>
<li><strong>불변(Immutable)</strong> 객체로 설계되며, 값 객체의 <strong>동등성은 값</strong>으로 판단함.</li>
<li><strong>동일한 값</strong>이면 동일한 객체로 취급되며, 값이 <strong>변경될 수 없도록 설계</strong>함.</li>
<li><strong>Spring JPA</strong>에서 <strong><code>@Embeddable</code></strong>로 다른 엔티티에 포함되거나, <strong><code>@Value</code></strong> 어노테이션으로 다룰 수 있음.</li>
<li><strong>엔티티(Entity)</strong>와 달리 <strong>식별자(ID)</strong>가 없고, <strong>값 자체</strong>가 중요.</li>
</ul>
<h3 id="vo-예시-spring-jpa에서-사용"><strong>VO 예시 (Spring JPA에서 사용)</strong></h3>
<pre><code class="language-java">@Embeddable
public class Address {
    private final String city;
    private final String street;

    protected Address() {}  // JPA에서 VO를 사용할 때 기본 생성자 필요

    public Address(String city, String street) {
        this.city = city;
        this.street = street;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Address address = (Address) obj;
        return Objects.equals(city, address.city) &amp;&amp;
               Objects.equals(street, address.street);
    }

    @Override
    public int hashCode() {
        return Objects.hash(city, street);
    }
}</code></pre>
<pre><code class="language-java">@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Embedded
    private Address address;  // VO 사용

    public User(String name, Address address) {
        this.name = name;
        this.address = address;
    }
}</code></pre>
<blockquote>
<p><strong>VO</strong>는 <strong>비즈니스 로직</strong>에서 <strong>값 객체</strong>를 표현하는 데 사용되며, <strong>값이 동일하면 동일한 객체</strong>로 취급된다.</p>
</blockquote>
<h3 id="vo-사용-목적"><strong>VO 사용 목적</strong></h3>
<ul>
<li><strong>불변 값</strong>을 표현하는 객체로 사용 (ex. <strong><code>Address</code>, <code>Money</code></strong> 등)</li>
<li><strong>Entity</strong>의 일부로 포함되어 <strong>값을 의미 있게 관리</strong></li>
<li>값이 <strong>동일하면 동일한 객체</strong>로 취급</li>
</ul>
<hr>
<h2 id="3-dto와-vo의-차이점-spring-기준">3. <strong>DTO와 VO의 차이점 (Spring 기준)</strong></h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>DTO (Data Transfer Object)</th>
<th>VO (Value Object)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>주요 목적</strong></td>
<td>계층 간 데이터 전달</td>
<td>값 객체 표현</td>
</tr>
<tr>
<td><strong>불변성</strong></td>
<td>가변/불변 가능</td>
<td>불변 (Immutable)</td>
</tr>
<tr>
<td><strong>동등성 판단</strong></td>
<td>객체 참조 비교 (ID 기준)</td>
<td>값 비교 (<code>equals()</code>와 <code>hashCode()</code> 기준)</td>
</tr>
<tr>
<td><strong>식별자</strong></td>
<td>없음 (주로 전달용)</td>
<td>없음 (값 자체가 중요)</td>
</tr>
<tr>
<td><strong>Spring에서 사용</strong></td>
<td>요청/응답 데이터 처리</td>
<td>엔티티의 일부로 사용 (값 객체)</td>
</tr>
<tr>
<td><strong>저장 여부</strong></td>
<td>데이터베이스 저장 안 함</td>
<td>엔티티 내부에서 사용, DB에 저장되지 않음</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-dto와-vo-사용-예시-spring-애플리케이션">4. <strong>DTO와 VO 사용 예시 (Spring 애플리케이션)</strong></h2>
<h4 id="1️⃣-dto-예시-api-요청응답-처리">1️⃣ <strong>DTO 예시</strong> (API 요청/응답 처리)</h4>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/orders&quot;)
public class OrderController {

    @PostMapping
    public ResponseEntity&lt;OrderDTO&gt; createOrder(@RequestBody OrderDTO orderDTO) {
        // OrderDTO를 받아서 주문 생성 로직 처리 후 응답
        return ResponseEntity.ok(orderDTO);
    }
}</code></pre>
<blockquote>
<p><strong><code>OrderDTO</code></strong>는 <strong>API 요청/응답을 처리</strong>하는 데 사용된다. <strong><code>@RequestBody</code></strong>로 요청을 받고, <strong><code>@ResponseBody</code></strong>로 응답을 보낸다.</p>
</blockquote>
<h4 id="2️⃣-vo-예시-비즈니스-로직에서-사용">2️⃣ <strong>VO 예시</strong> (비즈니스 로직에서 사용)</h4>
<pre><code class="language-java">@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Embedded
    private Money totalPrice;  // VO 사용

    public Order(Money totalPrice) {
        this.totalPrice = totalPrice;
    }
}

@Embeddable
public class Money {
    private final int amount;

    public Money(int amount) {
        this.amount = amount;
    }

    public int getAmount() {
        return amount;
    }
}</code></pre>
<blockquote>
<p><strong><code>MoneyVO</code></strong>는 <strong><code>Order</code></strong> 엔티티의 일부로 <strong><code>@Embedded</code></strong> 어노테이션을 통해 <strong>값 객체</strong>로 사용되며, 엔티티의 비즈니스 로직을 깔끔하게 처리한다.</p>
</blockquote>
<hr>
<h2 id="5-dto와-vo를-언제-사용하면-좋을까">5. <strong>DTO와 VO를 언제 사용하면 좋을까?</strong></h2>
<table>
<thead>
<tr>
<th>사용처</th>
<th>DTO</th>
<th>VO</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Controller ↔ Service</strong></td>
<td>✅ 사용 (요청/응답)</td>
<td>❌ 사용 안 함</td>
</tr>
<tr>
<td><strong>Service ↔ Repository</strong></td>
<td>✅ 사용 가능</td>
<td>❌ 잘 사용 안 함</td>
</tr>
<tr>
<td><strong>도메인 모델 (Entity 내부)</strong></td>
<td>❌ 사용 안 함</td>
<td>✅ 사용 (값 객체)</td>
</tr>
<tr>
<td><strong>API 응답용 데이터</strong></td>
<td>✅ 사용</td>
<td>❌ 사용 안 함</td>
</tr>
</tbody></table>
<hr>
<h2 id="결론"><strong>결론</strong></h2>
<p><strong>DTO</strong>는 <strong>HTTP 요청/응답</strong> 처리와 계층 간 데이터 전달을 위한 객체로, <strong>가변일 수 있지만 불변으로 설계하는 것이 좋다.</strong>  </p>
<ul>
<li><strong>DTO</strong>는 서비스 계층과 컨트롤러 간에 데이터를 전송하는 데 사용되며, <strong><code>@RequestBody</code></strong>나 <strong><code>@ResponseBody</code></strong>와 함께 <strong>JSON 형식</strong>으로 데이터를 주고받을 때 유용하다.</li>
<li><strong>불변 DTO</strong>를 사용하면 데이터 변경을 방지하고, 데이터의 무결성을 보장할 수 있다. 멀티스레드 환경에서도 안정적으로 사용할 수 있으며, 객체가 불변임으로써 개발자의 의도를 명확히 전달할 수 있다.</li>
</ul>
<p><strong>VO</strong>는 <strong>불변 객체</strong>로 설계되어 <strong>값을 표현</strong>하는 데 사용되며, <strong>동일한 값이면 동일한 객체</strong>로 취급한다.  </p>
<ul>
<li><strong>VO</strong>는 주로 <strong>비즈니스 로직</strong>에서 값의 의미를 표현하는 데 사용된다. 예를 들어, <strong><code>Address</code></strong>나 <strong><code>Money</code></strong>와 같은 값 객체는 <strong>불변</strong>해야 하며, 값이 동일하면 동일한 객체로 취급된다.</li>
<li><strong>VO</strong>는 <strong><code>@Embeddable</code></strong> 어노테이션을 통해 엔티티에 포함될 수 있으며, 값 객체가 변경되지 않도록 보장된다. 또한, <strong>값을 비교하는 <code>equals()</code>와 <code>hashCode()</code></strong> 메서드를 통해 동등성을 판단한다.</li>
</ul>
<p><strong>DTO는 계층 간 데이터 전송 용도</strong>, <strong>VO는 도메인 모델의 값 표현 용도</strong>  </p>
<ul>
<li><strong>DTO</strong>는 계층 간 데이터 전달을 위해 주로 사용되며, API 요청 및 응답 처리에 적합하다. 불필요한 데이터는 제외하고 필요한 정보만 전달하는 방식으로, 데이터 처리 과정에서 편리하게 사용될 수 있다.</li>
<li><strong>VO</strong>는 도메인 모델 내에서 중요한 값들을 표현하며, <strong>불변성</strong>을 유지하면서 데이터를 처리할 때 주로 사용된다. VO는 값 객체로, 변경되지 않는 값들을 안전하게 다룰 수 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 일정 관리 API 구현]]></title>
            <link>https://velog.io/@harvard--/Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-API-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@harvard--/Spring-%EC%9D%BC%EC%A0%95-%EA%B4%80%EB%A6%AC-API-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Tue, 25 Mar 2025 06:10:01 GMT</pubDate>
            <description><![CDATA[<h2 id="1-개요">1. 개요</h2>
<p>이번 과제는 일정 관리 기능을 제공하는 API를 <code>Postman</code>을 사용해 테스트하며 구현하는 것이 목표다. 일정 조회, 생성, 수정, 삭제 기능을 제공하며, <strong>3계층 구조(3 Layer Architecture)</strong>를 적용하고, <code>JDBC</code>를 통해 MySQL 데이터베이스와 연동하는 것이 기본 요구사항이다.</p>
<p>구현하면서 있었던 트러블슈팅과 리팩토링 과정을 작성해보려고 한다.</p>
<hr>
<h2 id="2-트러블슈팅">2. 트러블슈팅</h2>
<h3 id="1-db-default-값이-적용되지-않는-오류">1) DB DEFAULT 값이 적용되지 않는 오류</h3>
<pre><code class="language-sql">    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP</code></pre>
<p>생성일과 수정일을 DB에서 처리하도록 위와 같이 작성한 쿼리문을 기준으로 로직을 구현했다. 그래서 <code>created_at</code>, <code>updated_at</code>을 제외하고 DB에 Insert를 하면 <code>null</code> 값이 들어가는 문제가 발생했다.</p>
<h3 id="해결-과정">해결 과정</h3>
<p><strong>기존 코드</strong></p>
<pre><code class="language-java">    @Override
    public ScheduleResponseDto createSchedule(Schedule schedule) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName(&quot;schedule&quot;).usingGeneratedKeyColumns(&quot;schedule_id&quot;);

        Map&lt;String, Object&gt; parameters = new HashMap&lt;&gt;();
        parameters.put(&quot;writer_id&quot;, schedule.getWriter().getWriterId());
        parameters.put(&quot;task&quot;, schedule.getTask());
        parameters.put(&quot;password&quot;, schedule.getPassword());

        Number key = jdbcInsert.executeAndReturnKey(parameters);

        return getScheduleByIdOrElseThrow(key.longValue());
    }</code></pre>
<p><strong>수정 코드</strong></p>
<pre><code class="language-java">    @Override
    public Schedule createSchedule(Schedule schedule) {
        String sql = &quot;INSERT INTO schedule (writer_id, task, password) VALUES (?, ?, ?)&quot;;

        // SQL 쿼리 실행 후 DB에서 자동으로 생성된 기본 키 값을 가져오는 데 사용
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -&gt; {
            PreparedStatement ps = connection.prepareStatement(sql, new String[] {&quot;schedule_id&quot;});
            ps.setLong(1, schedule.getWriter().getWriterId());
            ps.setString(2, schedule.getTask());
            ps.setString(3, schedule.getPassword());
            return ps;
        }, keyHolder);

        // 삽입된 id를 가지고 단건 조회
        Long key = keyHolder.getKey().longValue();
        return getScheduleByIdOrElseThrow(key);
    }</code></pre>
<p>이렇게 하니 정상적으로 <code>DEFAULT</code> 적용된다.</p>
<p><strong>💡 왜 이런 문제가 발생했을까?</strong>
이 이유를 몇 시간 째 찾고 있었는데, 준모님이 한 방에 찾아주셨다....</p>
<p><img src="https://velog.velcdn.com/images/harvard--/post/83261a4f-6eec-4d95-8d0e-f23afb8ffbd5/image.png" alt=""></p>
<p>실행 메소드를 타고 내부로 가면 <code>values.add((Object) null);</code> 부분이 있다. 이게 문제였다.
<code>SimpleJdbcInsert</code>는 명시적으로 모든 값을 넣기 때문에 DB의 <code>DEFAULT</code> 값을 자동으로 처리하지 못한다.</p>
<p>해결 방법으로는 세 가지를 고려할 수 있었다.</p>
<ol>
<li>시간을 <code>LocalDateTime.now()</code> 로 삽입 (DB의 값과 일관성이 깨질 수 있는 위험)</li>
<li>테이블을 <code>DEFAULT</code> 옵션 없이 새로 만들기</li>
<li><code>jdbcTemplate</code> 로직으로 대체</li>
</ol>
<p>나는 세 번째 방법을 선택했고, 위에 작성한 수정 코드로 변경했다.</p>
<hr>
<h3 id="2-유효성-검사가-이루어지지-않는-오류">2) 유효성 검사가 이루어지지 않는 오류</h3>
<pre><code class="language-java">    @DeleteMapping(&quot;/{scheduleId}&quot;)
    public ResponseEntity&lt;Void&gt; deleteSchedule(@PathVariable Long scheduleId,
                                               @RequestParam @Valid String password) {
        scheduleService.deleteSchedule(scheduleId, password);
        return new ResponseEntity&lt;&gt;(HttpStatus.NO_CONTENT);
    }</code></pre>
<p>파라미터로 값을 받는 <code>password</code>의 유효성 검사가 제대로 이루어지지 않았다.
분명 유효성 검증 예외 처리를 하는 핸들러를 만들었는데도 말이다.</p>
<h3 id="해결-과정-1">해결 과정</h3>
<p>콘솔 창을 잘 살펴보니 <code>MissingServletRequestParameterException</code> 예외가 발생한다.
이 예외가 무엇인가 알아보니 <code>@RequestParam</code>에 필수 파라미터인 <code>password</code>가 요청에 포함되지 않아서 발생하는 오류이다.
기존에 만든 핸들러는 <code>MethodArgumentNotValidException</code>을 처리하는 핸들러였다.</p>
<pre><code class="language-java">    // MissingServletRequestParameterException 처리
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity&lt;String&gt; handleMissingParamExceptions(MissingServletRequestParameterException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(&quot;필수 값 &quot; + ex.getParameterName() + &quot;을(를) 입력하세요.&quot;);
}</code></pre>
<p>그래서 위와 같이 <code>MissingServletRequestParameterException</code>을 처리하는 핸들러를 추가해 해결했다.</p>
<hr>
<h2 id="3-리팩토링">3. 리팩토링</h2>
<h3 id="1-db-soft-delete">1) DB Soft Delete</h3>
<pre><code class="language-java">    /** 
     *  일정 삭제 API (소프트 딜리트 적용)
     *  일정 ID를 받아 해당 일정 삭제
     *  비밀번호가 일치해야 삭제 가능
     *  &#39;deleted_at&#39; 컬럼을 업데이트하여 논리적으로 삭제 처리
     *  향후 복구 가능하도록 데이터는 유지
     */
    @DeleteMapping(&quot;/{scheduleId}&quot;)
    public ResponseEntity&lt;Void&gt; deleteSchedule(@PathVariable Long scheduleId,
                                               @RequestParam @Valid String password) {
        scheduleService.deleteSchedule(scheduleId, password);
        return new ResponseEntity&lt;&gt;(HttpStatus.NO_CONTENT);
    }

    @Transactional
    @Override
    public void deleteSchedule(Long scheduleId, String password) {
        Schedule schedule = scheduleRepository.getScheduleByIdOrElseThrow(scheduleId);

        // 입력된 비밀번호와 실제 비밀번호가 일치하지 않을 경우 예외 발생
        if (schedule.verifyPassword(password)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, &quot;비밀번호가 일치하지 않습니다.&quot;);
        }

        // 일정 삭제
        scheduleRepository.deleteSchedule(scheduleId);
    }

    @Override
    public void deleteSchedule(Long scheduleId) {
        String sql = &quot;UPDATE schedule SET deleted_at = ? WHERE schedule_id = ?&quot;;
        jdbcTemplate.update(sql, LocalDateTime.now(), scheduleId);
    }
</code></pre>
<p>소프트 딜리트라는 개념을 알게 돼서 적용해봤다.
실생활 예시로는 <strong>회원 탈퇴 후 7일 이내 취소 가능한 경우</strong>에 소프트 딜리트를 사용한다.</p>
<hr>
<h3 id="2-schedule-생성자-종속성-해결">2) Schedule 생성자 종속성 해결</h3>
<p><strong>기존 코드</strong></p>
<pre><code class="language-java">    public Schedule(Long scheduleId, String task, String password, Long writerId, 
        String name, String email, LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.scheduleId = scheduleId;
        this.task = task;
        this.password = password;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
        this.writer = new Writer(writerId, name, email);
    }

    @Override
    public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Schedule(
                rs.getLong(&quot;schedule_id&quot;),
                rs.getString(&quot;task&quot;),
                rs.getString(&quot;password&quot;),
                rs.getLong(&quot;writer_id&quot;),
                rs.getString(&quot;name&quot;),
                rs.getString(&quot;email&quot;),
                rs.getTimestamp(&quot;a.created_at&quot;).toLocalDateTime(),
                rs.getTimestamp(&quot;a.updated_at&quot;).toLocalDateTime()
        );
    }</code></pre>
<p>기존 코드에서는 <code>Schedule</code> 클래스의 생성자 내에서 <code>Writer</code> 객체를 직접 생성하고 있었다.
이로 인해 <code>Schedule</code> 클래스는 <code>Writer</code> 객체 생성에 대한 직접적인 책임을 가지고 있다는 문제점이 있었다.</p>
<p><strong>수정 코드</strong></p>
<pre><code class="language-java">    public Schedule(Long scheduleId, String task, String password, LocalDateTime createdAt,
                    LocalDateTime updatedAt, Writer writer) {
        this.scheduleId = scheduleId;
        this.task = task;
        this.password = password;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
        this.writer = writer;
    }

    @Override
    public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
        // Writer 객체는 외부에서 주입받도록 수정
        Writer writer = new Writer(rs.getLong(&quot;writer_id&quot;), rs.getString(&quot;name&quot;), rs.getString(&quot;email&quot;));
        return new Schedule(
                rs.getLong(&quot;schedule_id&quot;),
                rs.getString(&quot;task&quot;),
                rs.getString(&quot;password&quot;),
                rs.getTimestamp(&quot;a.created_at&quot;).toLocalDateTime(),
                rs.getTimestamp(&quot;a.updated_at&quot;).toLocalDateTime(),
                writer // 외부에서 생성된 Writer 객체를 사용
    );</code></pre>
<p><code>Schedule</code> 클래스는 <code>Writer</code> 객체 생성에 대한 책임을 지지 않게 되었고, 테스트와 유지보수 측면에서도 더 유연한 구조가 되었다.</p>
<hr>
<h3 id="3-예외-처리-시-null-값-반환-삭제하고-예외-바로-처리">3) 예외 처리 시 null 값 반환 삭제하고 예외 바로 처리</h3>
<p><strong>기존 코드</strong></p>
<pre><code class="language-java">    @Override
    public Schedule getScheduleByIdOrElseThrow(Long scheduleId) {
        String sql = &quot;SELECT schedule_id, a.writer_id, name, email, task, password, a.created_at,a.updated_at &quot;
            + &quot;FROM schedule a JOIN writer b ON a.writer_id = b.writer_id WHERE schedule_id = ? &quot;
            + &quot;AND a.deleted_at IS NULL&quot;;
        List&lt;Schedule&gt; result = jdbcTemplate.query(sql, new ScheduleRowMapper(), scheduleId);
        return result.stream().findAny().orElse(null);
    }</code></pre>
<p>기존 코드에서는 <code>getScheduleByIdOrElseThrow</code> 메서드가 <code>Schedule</code>을 조회한 후, 결과가 없으면 <code>null</code>을 반환했다. 이 경우 서비스 레벨에서 <code>null</code> 체크를 해야 하고, <code>null</code> 값에 대한 예외 처리가 따로 필요했다. 즉, 예외 상황을 메서드가 아닌 서비스 계층에서 처리하도록 하는 방식이였다.</p>
<p><strong>수정 코드</strong></p>
<pre><code class="language-java">    @Override
    public Schedule getScheduleByIdOrElseThrow(Long scheduleId) {
        String sql = &quot;SELECT schedule_id, a.writer_id, name, email, task, password, a.created_at,a.updated_at &quot;
            + &quot;FROM schedule a JOIN writer b ON a.writer_id = b.writer_id WHERE schedule_id = ? &quot;
            + &quot;AND a.deleted_at IS NULL&quot;;
        try {
            return jdbcTemplate.queryForObject(sql, new ScheduleRowMapper(), scheduleId);
        } catch (EmptyResultDataAccessException e) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, &quot;id가 &quot; + scheduleId + &quot;인 일정이 존재하지 않습니다.&quot;);
        }
    }</code></pre>
<p>수정된 코드에서는 <code>queryForObject</code>를 사용해 결과를 바로 반환하며, 결과가 없을 경우 <code>EmptyResultDataAccessException</code>을 발생시킨다. 이를 <code>catch</code>하여, 해당 일정이 존재하지 않으면 <code>ResponseStatusException</code>을 던져서 클라이언트에게 <code>404 Not Found</code> 응답을 반환하게 변경했다. 예외가 발생하면 바로 처리되기 때문에, 서비스 레벨에서 추가적인 예외 처리가 필요하지 않게 된다.</p>
<hr>
<h3 id="4-getpassword-제거하고-비밀번호-검증-메서드로-대체">4) getPassword() 제거하고 비밀번호 검증 메서드로 대체</h3>
<pre><code class="language-java">    /**
      *  검증을 위해 scheduleId 값으로 단건 조회를 할 경우 생성자가 초기화
      *  그 값을 이용해 입력된 패스워드와 비교하는 메서드
      */
    public boolean verifyPassword(String password) {
        return !this.password.equals(password);
    }    

    Schedule schedule = scheduleRepository.getScheduleByIdOrElseThrow(scheduleId);

    if (schedule.verifyPassword(password)) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, &quot;비밀번호가 일치하지 않습니다.&quot;);
    }</code></pre>
<p>기존 코드에서는 <code>getPassword()</code> 메서드를 통해 비밀번호를 불러와서 비교하는 방식이었다. 이 방식은 비밀번호를 외부로 노출시키고, 객체에 대해 불필요한 메서드를 호출하는 방식으로 코드가 복잡하고 비효율적이였다.
수정된 코드에서는 <code>Schedule</code> 객체 내에 직접 비밀번호 검증 로직을 추가해 코드의 복잡성을 줄이고, 불필요한 메서드 호출을 제거해 메모리 낭비를 방지하도록 개선했다.</p>
<hr>
<h2 id="4-느낀점">4. 느낀점</h2>
<p>객체지향에 대해 깊이 들어갈수록 어렵다 ㅠㅠ
객체가 가지는 역할, 의존성, 불필요한 호출 등에 초점을 맞춰 리팩토링을 진행했는데
튜터님의 코드리뷰가 없었다면 발견하지도 못 했을 문제점들이였다.
예외 처리가 세분화되면서 신경 써야 할 부분이 많아져 한 번에 기능을 구현하는 것이 힘들었지만, 
계속 수정하고 개선하다 보면 한결 더 나은 나만의 코드 스타일을 완성할 수 있을 거라고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] 예외(Exception)의 개념과 적절한 예외 처리]]></title>
            <link>https://velog.io/@harvard--/Java-%EC%98%88%EC%99%B8Exception%EC%9D%98-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%A0%81%EC%A0%88%ED%95%9C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@harvard--/Java-%EC%98%88%EC%99%B8Exception%EC%9D%98-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%A0%81%EC%A0%88%ED%95%9C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Sun, 23 Mar 2025 22:27:01 GMT</pubDate>
            <description><![CDATA[<h2 id="서론-예외-처리가-부족할-때-생기는-문제"><strong>서론: 예외 처리가 부족할 때 생기는 문제</strong></h2>
<p>최근 마트 카운터 아르바이트를 시작하면서, <strong>적절한 예외 처리가 되지 않아 시스템이 멈추는 문제</strong>를 경험했다.  </p>
<p>편의점이 아닌 일반 마트의 포스(POS) 프로그램은 오래전에 개발된 경우가 많고, 이후 추가된 기능이 기존 시스템에 억지로 덧붙여지는 경우도 많다.  </p>
<p>예를 들어, 물품을 스캔한 후 취소하면 수량이 0개가 되고, 결제 금액도 0원으로 변경된다.<br>이 상태에서 카카오페이, 네이버페이, 앱카드 등 &quot;기타결제&quot; 메뉴를 누르면 <strong>모든 버튼이 먹통이 되고 프로그램을 재시작해야 하는 문제</strong>가 발생한다.  </p>
<p>이 문제를 보고 바로 떠올랐던 것은 <strong>예외 처리가 부족하다는 점</strong>이었다.<br>포스 프로그램이 개발될 당시에는 지금처럼 다양한 결제 방식이 없었을 것이고, 이후 기능이 추가되면서 적절한 예외 처리가 되지 않아 이런 문제가 발생한 것으로 보인다.  </p>
<p>이처럼 예외 처리가 제대로 이루어지지 않으면 <strong>프로그램이 비정상적으로 종료되거나, 기능이 멈추는 치명적인 문제</strong>가 발생할 수 있다.  </p>
<p>이번 글에서는 Java에서 예외(Exception)의 개념과, 적절한 예외 처리가 왜 중요한지 살펴보겠다.  </p>
<hr>
<h2 id="1-예외exception란"><strong>1. 예외(Exception)란?</strong></h2>
<p>예외(Exception)는 <strong>프로그램 실행 중 예상하지 못한 상황이 발생하여 정상적인 흐름을 방해하는 사건</strong>을 의미한다.
일반적으로 프로그램은 <strong>개발자가 의도한 대로 순차적으로 실행</strong>되지만, 실행 중 다양한 원인으로 인해 오류가 발생할 수 있다.</p>
<p>예를 들어, 사용자가 입력해야 할 값을 입력하지 않거나, 존재하지 않는 파일을 열려고 하거나, 배열의 범위를 초과하는 인덱스에 접근하는 등의 상황이 있을 수 있다.
이러한 문제가 발생하면 <strong>프로그램이 갑자기 종료되거나, 예상치 못한 동작을 수행할 가능성이 높아진다.</strong></p>
<p>Java에서는 이러한 예외를 <strong>자동으로 감지하고 개발자가 적절히 처리할 수 있도록 지원하는 예외 처리(Exception Handling) 기능</strong>을 제공한다.
예외 처리를 올바르게 하면, <strong>프로그램이 강제 종료되지 않고 원하는 대로 동작하도록 유도할 수 있다.</strong></p>
<p>즉, 예외 처리는 단순히 오류를 피하는 것이 아니라, <strong>프로그램의 안정성을 높이고, 사용자 경험을 개선하는 중요한 요소</strong>라고 할 수 있다.</p>
<hr>
<h2 id="2-예외의-종류"><strong>2. 예외의 종류</strong></h2>
<p>Java에서 예외는 크게 <strong>Checked Exception</strong>과 <strong>Unchecked Exception</strong> 두 가지로 나뉜다.<br>이 둘을 구분하는 핵심 기준은 <strong>예외 처리가 필수인지 여부</strong>다.  </p>
<table>
<thead>
<tr>
<th>예외 유형</th>
<th>상속 구조</th>
<th>필수 처리 여부</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Checked Exception</strong></td>
<td><code>Throwable</code> → <code>Exception</code></td>
<td>예외 처리가 필수 (<code>try-catch</code> 또는 <code>throws</code>)</td>
<td><code>IOException</code>, <code>SQLException</code></td>
</tr>
<tr>
<td><strong>Unchecked Exception</strong></td>
<td><code>Throwable</code> → <code>Exception</code> → <code>RuntimeException</code></td>
<td>예외 처리가 필수가 아님</td>
<td><code>NullPointerException</code>, <code>IllegalArgumentException</code></td>
</tr>
<tr>
<td><strong>RuntimeException</strong></td>
<td><code>Unchecked Exception</code>의 하위 개념</td>
<td><code>RuntimeException</code>을 직접 상속받은 예외</td>
<td><code>ArithmeticException</code>, <code>IndexOutOfBoundsException</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="3-checked-exception-vs-unchecked-exception-차이점"><strong>3. Checked Exception vs Unchecked Exception 차이점</strong></h2>
<h3 id="checked-exception-확인된-예외"><strong>Checked Exception</strong> (확인된 예외)</h3>
<ul>
<li>예외 처리가 <strong>필수</strong>  </li>
<li><code>Exception</code>을 상속받지만, <code>RuntimeException</code>을 제외한 예외  </li>
<li>주로 <strong>외부 리소스(파일, 네트워크, DB 등) 사용 시 발생</strong>  </li>
<li><code>try-catch</code> 또는 <code>throws</code>로 반드시 처리해야 함  </li>
</ul>
<h4 id="파일-읽기-ioexception"><strong>파일 읽기 (<code>IOException</code>)</strong></h4>
<pre><code class="language-java">import java.io.*;

public class CheckedExceptionExample {
    public static void main(String[] args) {
        try {
            FileReader file = new FileReader(&quot;test.txt&quot;); // 파일이 없으면 FileNotFoundException 발생
            file.read();
            file.close();
        } catch (IOException e) { // 반드시 예외 처리해야 함
            System.out.println(&quot;파일을 읽는 중 오류 발생: &quot; + e.getMessage());
        }
    }
}</code></pre>
<p>💡 <code>IOException</code>은 <strong>컴파일 타임에 체크되는 예외</strong>라서 예외 처리를 강제한다.<br>(파일이 없을 가능성이 있으므로 대비가 필요함)  </p>
<hr>
<h3 id="unchecked-exception-확인되지-않은-예외"><strong>Unchecked Exception</strong> (확인되지 않은 예외)</h3>
<ul>
<li>예외 처리가 <strong>선택 사항</strong>  </li>
<li><code>RuntimeException</code>을 상속받은 예외  </li>
<li><strong>주로 개발자의 코드 실수로 인해 발생</strong>  </li>
<li><code>try-catch</code>로 잡을 수도 있지만, <strong>근본적으로 코드 수정이 필요함</strong>  </li>
</ul>
<h4 id="nullpointerexception"><strong><code>NullPointerException</code></strong></h4>
<pre><code class="language-java">public class UncheckedExceptionExample {
    public static void main(String[] args) {
        String text = null;
        System.out.println(text.length()); // NullPointerException 발생
    }
}</code></pre>
<p>💡 <code>NullPointerException</code>은 <code>RuntimeException</code>이므로 <strong>예외 처리가 필수가 아님</strong><br>(try-catch로 잡는 것이 아니라, NPE 발생을 예방하는 것이 중요)  </p>
<hr>
<h3 id="runtimeexception-런타임-예외"><strong>RuntimeException (런타임 예외)</strong></h3>
<ul>
<li><code>Unchecked Exception</code>의 하위 개념  </li>
<li><code>RuntimeException</code>을 직접 상속받은 예외들  </li>
<li>대표적인 예시:  <ul>
<li><code>ArithmeticException</code> (0으로 나누기)  </li>
<li><code>NullPointerException</code> (null 접근)  </li>
<li><code>ArrayIndexOutOfBoundsException</code> (배열 인덱스 초과)  </li>
</ul>
</li>
</ul>
<h4 id="arrayindexoutofboundsexception"><strong><code>ArrayIndexOutOfBoundsException</code></strong></h4>
<pre><code class="language-java">public class RuntimeExceptionExample {
    public static void main(String[] args) {
        int[] numbers = {1, 2, 3};
        System.out.println(numbers[5]); // ArrayIndexOutOfBoundsException 발생
    }
}</code></pre>
<p>💡 런타임 예외는 <code>try-catch</code>로 잡는 것이 아니라, <strong>올바른 코드 작성이 우선</strong>이다.<br>(배열 범위를 초과하지 않도록 미리 체크해야 함)  </p>
<p><strong>(⭐+추가) <code>Unchecked Exception</code>과 <code>RuntimeException</code>의 차이를 잘 모르겠다?</strong></p>
<ul>
<li><strong><code>Unchecked Exception</code></strong>: <code>RuntimeException</code>과 그 자식 예외들 + <code>Error</code>까지 포함하는 개념이다.<br>즉, 프로그램 실행 중 개발자가 예측하지 못한 오류들이 발생할 수 있는 모든 예외를 포함한다.  </li>
<li><strong><code>RuntimeException</code></strong>: <code>Unchecked Exception</code>의 일부로, <strong>개발자의 실수로 인해 발생하는 논리적인 오류</strong>를 뜻한다.<br>(<code>NullPointerException</code>, <code>IndexOutOfBoundsException</code> 등)  </li>
</ul>
<p>쉽게 말해, <strong>모든 <code>RuntimeException</code>은 <code>Unchecked Exception</code>이지만, 모든 <code>Unchecked Exception</code>이 <code>RuntimeException</code>은 아니다.</strong><br>(<code>Error</code> 같은 시스템적 오류도 <code>Unchecked Exception</code>에 포함되기 때문)</p>
<hr>
<h2 id="4-예외-처리-방법-try-catch-throws-if문-활용"><strong>4. 예외 처리 방법 (try-catch, throws, if문 활용)</strong></h2>
<h3 id="try-catch-문으로-예외-처리"><strong>try-catch 문으로 예외 처리</strong></h3>
<p>예외가 발생할 가능성이 있는 코드를 <code>try</code> 블록 안에 넣고, <code>catch</code>에서 처리한다.  </p>
<pre><code class="language-java">try {
    int result = 10 / 0; // ArithmeticException 발생
} catch (ArithmeticException e) {
    System.out.println(&quot;0으로 나눌 수 없습니다.&quot;);
    e.printStackTrace();  // 예외 발생 위치와 세부 정보 출력
}</code></pre>
<p>💡 예외를 잡아서 프로그램이 <strong>강제 종료되지 않도록</strong> 할 수 있다.  </p>
<hr>
<h3 id="throws-키워드로-예외-전가"><strong>throws 키워드로 예외 전가</strong></h3>
<p>메서드에서 예외를 직접 처리하지 않고, <strong>호출한 쪽에서 처리하도록 넘길 수 있음</strong>  </p>
<pre><code class="language-java">public void readFile() throws IOException {
    FileReader file = new FileReader(&quot;test.txt&quot;); // FileNotFoundException 발생 가능
    file.read();
}</code></pre>
<p>💡 이 메서드를 호출하는 쪽에서 <code>try-catch</code>를 사용해야 한다.  </p>
<pre><code class="language-java">try {
    readFile();
} catch (IOException e) {
    System.out.println(&quot;파일을 읽는 중 오류 발생&quot;);
    e.printStackTrace();  // 예외 발생 위치와 세부 정보 출력
}</code></pre>
<p><strong>(⭐+추가) <code>e.printStackTrace()</code></strong>는 예외가 발생한 위치와 세부 정보를 출력하여 디버깅에 유용하다. 하지만 운영 환경에서는 불필요하게 예외 세부 정보를 노출하지 않도록 주의해야 한다. 예외 로그는 개발 환경에서 디버깅 목적으로 사용하고, 운영 환경에서는 적절한 예외 메시지만 출력하는 것이 좋다.</p>
<hr>
<h3 id="if-문으로-예외-예방-사전-방어-코드"><strong>if 문으로 예외 예방 (사전 방어 코드)</strong></h3>
<p>예외가 발생하기 전에 <strong>조건문을 이용해 사전 방어</strong>할 수 있다.  </p>
<pre><code class="language-java">public void divide(int a, int b) {
    if (b == 0) {
        System.out.println(&quot;0으로 나눌 수 없습니다.&quot;);
        return;
    }
    System.out.println(a / b);
}</code></pre>
<p>💡 <code>if</code> 문으로 예외 발생을 막으면 <strong>불필요한 try-catch를 줄일 수 있다.</strong>  </p>
<hr>
<h2 id="5-runtimeexception이-필수-처리가-아닌-이유"><strong>5. RuntimeException이 필수 처리가 아닌 이유</strong></h2>
<h3 id="runtimeexception은-개발자의-실수로-발생"><strong>RuntimeException은 개발자의 실수로 발생</strong></h3>
<ul>
<li><code>NullPointerException</code>, <code>ArrayIndexOutOfBoundsException</code> 같은 예외는 <strong>버그</strong>로 인한 것이므로,<code>try-catch</code>로 잡기보다는 <strong>코드를 수정해서 해결해야 한다.</strong>  </li>
</ul>
<p><strong>잘못된 예시</strong></p>
<pre><code class="language-java">try {
    String text = null;
    System.out.println(text.length()); // NullPointerException 발생
} catch (NullPointerException e) {
    System.out.println(&quot;NPE 발생!&quot;);
}</code></pre>
<p><strong>올바른 예시</strong></p>
<pre><code class="language-java">if (text != null) {
    System.out.println(text.length());
} else {
    System.out.println(&quot;문자열이 null입니다.&quot;);
}</code></pre>
<h3 id="checkedexception은-외부-리소스-문제이므로-대비가-필요"><strong>CheckedException은 외부 리소스 문제이므로 대비가 필요</strong></h3>
<ul>
<li><code>IOException</code>, <code>SQLException</code> 등은 <strong>외부 환경 문제(DB, 파일, 네트워크 등)</strong>로 인해 발생할 수 있으므로, 개발자가 대비해야 한다.  </li>
</ul>
<pre><code class="language-java">try {
    FileReader file = new FileReader(&quot;test.txt&quot;);
} catch (FileNotFoundException e) {
    System.out.println(&quot;파일이 존재하지 않습니다.&quot;);
}</code></pre>
<p>💡 <strong>외부 리소스는 항상 존재 여부를 체크해야 한다!</strong>  </p>
<hr>
<h2 id="6-결론-언제-try-catch를-사용할까"><strong>6. 결론: 언제 try-catch를 사용할까?</strong></h2>
<p><strong>Checked Exception (예외 처리 필수)</strong></p>
<ul>
<li><code>IOException</code>, <code>SQLException</code> 등 외부 환경과 관련된 예외  </li>
</ul>
<p><strong>Unchecked Exception (예외 처리 선택)</strong></p>
<ul>
<li><code>NullPointerException</code>, <code>ArrayIndexOutOfBoundsException</code> 등은 <strong>코드를 수정하는 것이 우선</strong>  </li>
</ul>
<p><strong>try-catch보다는 사전 방어 코드(if문) 활용이 더 중요</strong>  </p>
<ul>
<li>예외가 발생하기 전에 조건문을 활용해 방어하면 <strong>더 안전한 코드 작성 가능</strong>  </li>
</ul>
<p><strong>즉, <code>RuntimeException</code>은 try-catch로 해결하는 것이 아니라, 발생하지 않도록 예방하는 것이 핵심이다.</strong></p>
<hr>
<p><strong>느낀점</strong></p>
<p>예외 처리에 대해 알아보면서, IDE의 자동 완성이나 경고 메시지에만 너무 의존했던 것은 아닐까 하는 생각이 들었다.  </p>
<p>특히 <code>Checked Exception</code>의 경우, 컴파일러가 <code>try-catch</code>문을 강제하기 때문에 별 생각 없이 사용했던 경향이 있었다.  </p>
<p>또한, <code>try-catch</code>나 <code>throws</code>를 피하려는 마음에 조건문을 활용한 사전 방어 코드를 우선적으로 작성해 왔는데, 결과적으로 최악의 선택은 아니었다는 점도 깨달았다.  </p>
<p>하지만 예외 처리는 단순히 피하는 것이 아니라, 적절한 방식으로 다루는 것이 더 중요한 요소라는 생각이 든다. </p>
<p>앞으로는 <code>try-catch</code>나 <code>throws</code>도 사용해 보면서, 상황에 맞는 최적의 예외 처리 방법을 익히도록 노력해봐야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Web] Web Server & Web Application Server]]></title>
            <link>https://velog.io/@harvard--/Web-Web-Server-Web-Application-Server</link>
            <guid>https://velog.io/@harvard--/Web-Web-Server-Web-Application-Server</guid>
            <pubDate>Thu, 20 Mar 2025 17:58:47 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p><strong>웹 서버(Web Server)</strong>와 <strong>WAS(Web Application Server)</strong>는 웹 애플리케이션을 운영하는 데 핵심적인 역할을 한다. 하지만 두 기술은 <strong>서로 다른 기능</strong>을 수행한다.<br>이 글에서 웹 서버와 WAS의 차이점, 특징, 장단점과 함께 <strong>두 기술을 어떻게 효과적으로 조합할 수 있는지</strong>에 대해 쉽고 명확하게 정리하려고 한다.  </p>
<hr>
<h2 id="1-웹-서버란-무엇인가">1. 웹 서버란 무엇인가?</h2>
<h3 id="웹-서버의-정의">웹 서버의 정의</h3>
<blockquote>
<p><strong>웹 서버</strong>는 사용자가 요청하는 <strong>HTML, CSS, 이미지 같은 정적인 콘텐츠</strong>를 웹 브라우저에 전달하는 서버다. 
쉽게 말해, <strong>웹 서버는 인터넷에서 필요한 파일을 찾아 사용자에게 보내는 역할</strong>을 한다. </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/harvard--/post/27a51aa4-8e08-43d0-b938-b0318c676986/image.png" alt=""></p>
<h3 id="웹-서버의-주요-특징">웹 서버의 주요 특징</h3>
<ul>
<li><strong>정적 콘텐츠 제공</strong> → HTML, 이미지, CSS, JavaScript 파일을 클라이언트(웹 브라우저)에 전달  </li>
<li><strong>빠른 응답 속도</strong> → 정적 파일만 처리하므로 요청에 즉시 응답 가능  </li>
<li><strong>간단한 설정</strong> → 단순한 요청-응답 처리 방식으로 운영이 용이  </li>
</ul>
<h3 id="대표적인-웹-서버">대표적인 웹 서버</h3>
<ul>
<li><strong>Apache HTTP Server</strong> → 가장 널리 사용되는 오픈소스 웹 서버, 안정적이고 확장성이 뛰어남  </li>
<li><strong>Nginx</strong> → 높은 성능과 가벼운 구조, 많은 트래픽을 효율적으로 처리 가능<br><img src="https://velog.velcdn.com/images/harvard--/post/2e31cde5-a19f-48b0-911f-ad1f9d492c32/image.png" alt=""></li>
</ul>
<hr>
<h2 id="2-wasweb-application-server란-무엇인가">2. WAS(Web Application Server)란 무엇인가?</h2>
<h3 id="was의-정의">WAS의 정의</h3>
<blockquote>
<p><strong>WAS(Web Application Server)</strong>는 웹 애플리케이션이 실행되는 서버다.<br>웹 서버가 <strong>정적 콘텐츠(HTML, 이미지 등)</strong>를 제공하는 반면, <strong>WAS는 데이터베이스와 연동하여 동적 콘텐츠를 생성</strong>하는 역할을 한다.  </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/harvard--/post/4835c858-eecb-4501-aac3-193a22ed9cfd/image.png" alt=""></p>
<p><strong>(⭐+정보) 대부분의 WAS는 웹 서버를 내장하고 있다.</strong></p>
<h3 id="was의-주요-특징">WAS의 주요 특징</h3>
<ul>
<li><strong>동적 콘텐츠 처리</strong> → 사용자의 요청을 받아 <strong>로그인, 게시판 기능 등 서버 로직을 실행</strong>  </li>
<li><strong>비즈니스 로직 수행</strong> → 데이터베이스와 연동하여 사용자 맞춤형 응답 생성  </li>
<li><strong>세션 관리</strong> → 로그인 유지, 사용자 정보 저장 등  </li>
</ul>
<h3 id="대표적인-was">대표적인 WAS</h3>
<ul>
<li><strong>Tomcat</strong> → Java 기반의 가벼운 WAS, 설정이 간단하고 빠름  </li>
<li><strong>JBoss</strong> → 기업용 애플리케이션에 최적화된 강력한 WAS  </li>
<li><strong>WebLogic</strong> → 대형 시스템에 사용되는 엔터프라이즈급 WAS  </li>
</ul>
<p><img src="https://velog.velcdn.com/images/harvard--/post/d9c1140b-798e-4742-bf62-533cbe94bfdd/image.png" alt=""></p>
<hr>
<h2 id="3-웹-서버와-was의-차이점--장단점">3. 웹 서버와 WAS의 차이점 &amp; 장단점</h2>
<p>웹 서버와 WAS는 역할이 다르다. 웹 서버는 <strong>정적인 콘텐츠(HTML, CSS, 이미지 등)</strong> 를 제공하는 반면, WAS는 <strong>동적인 콘텐츠(사용자 요청 처리, 데이터베이스 연동 등)</strong> 를 담당한다.  </p>
<table>
<thead>
<tr>
<th><strong>구분</strong></th>
<th><strong>웹 서버(Web Server)</strong></th>
<th><strong>WAS(Web Application Server)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>주요 역할</strong></td>
<td><strong>정적 콘텐츠 제공</strong></td>
<td><strong>동적 콘텐츠 처리, 비즈니스 로직 실행</strong></td>
</tr>
<tr>
<td><strong>처리 방식</strong></td>
<td>클라이언트 요청에 맞는 파일(HTML, CSS 등) 전송</td>
<td>서버에서 프로그램을 실행해 결과 반환</td>
</tr>
<tr>
<td><strong>사용 기술</strong></td>
<td>HTML, CSS, 이미지 파일</td>
<td>Java, Python, PHP, 데이터베이스 연동</td>
</tr>
<tr>
<td><strong>서버 자원</strong></td>
<td>가벼움 (적은 자원 사용)</td>
<td>무거움 (더 많은 리소스 필요)</td>
</tr>
</tbody></table>
<p><strong>즉, 웹 서버는 빠르고 가볍지만 동적인 처리를 못 하고, WAS는 강력한 기능을 제공하지만 더 많은 자원을 필요로 한다.</strong></p>
<h3 id="장점과-단점-비교"><strong>장점과 단점 비교</strong></h3>
<p>이제, 두 기술의 강점과 한계를 한눈에 정리해 보자.  </p>
<table>
<thead>
<tr>
<th></th>
<th><strong>웹 서버</strong></th>
<th><strong>WAS</strong></th>
</tr>
</thead>
<tbody><tr>
<td>✔️ <strong>장점</strong></td>
<td>✅ <strong>빠른 속도</strong> – 정적 파일 제공 최적화</td>
<td>✅ <strong>동적 콘텐츠 처리 가능</strong> – 로그인, 데이터 연동 가능</td>
</tr>
<tr>
<td></td>
<td>✅ <strong>설정 간단</strong> – 비교적 쉽게 운영 가능</td>
<td>✅ <strong>확장성</strong> – 복잡한 애플리케이션도 구현 가능</td>
</tr>
<tr>
<td></td>
<td>✅ <strong>적은 자원 사용</strong> – 가볍고 효율적</td>
<td></td>
</tr>
<tr>
<td>❌ <strong>단점</strong></td>
<td>❌ <strong>동적 콘텐츠 불가</strong> – 데이터베이스 연동 X</td>
<td>❌ <strong>자원 소모 큼</strong> – CPU, 메모리 많이 사용</td>
</tr>
<tr>
<td></td>
<td></td>
<td>❌ <strong>설정 복잡</strong> – 웹 서버보다 운영 난이도 높음</td>
</tr>
</tbody></table>
<p><strong>웹 서버는 가볍고 빠르지만, 동적 처리가 필요하면 한계가 있다.</strong><br><strong>WAS는 강력한 기능을 제공하지만, 무겁고 설정이 복잡할 수 있다.</strong>  </p>
<hr>
<h2 id="4-웹-서버와-was를-함께-활용하는-기술">4. 웹 서버와 WAS를 함께 활용하는 기술</h2>
<p>지금까지 본 것처럼 웹 서버와 WAS는 각자 역할이 다르다. <strong>웹 서버는 빠르고 가볍지만 동적 처리를 못 하고, WAS는 강력한 기능을 제공하지만 더 많은 자원을 필요로 한다.</strong><br>따라서 두 기술을 효과적으로 조합해 활용하는 것이 중요하다.  </p>
<p>이제 <strong>웹 서버와 WAS를 함께 배치하는 다양한 방식과 그 장단점</strong>을 알아보자.  </p>
<h3 id="1-단순-웹-서버--was-구조"><strong>1. 단순 웹 서버 + WAS 구조</strong></h3>
<p><img src="https://velog.velcdn.com/images/harvard--/post/9bb721d9-b4ec-4570-91ff-652272d818b5/image.png" alt=""></p>
<h4 id="특징">특징</h4>
<ul>
<li>웹 서버가 정적 콘텐츠(HTML, CSS, JS, 이미지 등)를 처리 </li>
<li>WAS가 동적 콘텐츠(로그인, 데이터베이스 연동 등)를 담당  </li>
<li>트래픽이 많지 않은 소규모 시스템에서 많이 사용됨  </li>
</ul>
<h4 id="장점">장점</h4>
<ul>
<li>구조가 단순해 설정 및 관리가 쉬움  </li>
<li>웹 서버가 정적 콘텐츠를 처리해 WAS의 부담이 줄어듦  </li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li>트래픽이 증가하면 WAS에 부하가 집중될 수 있음  </li>
<li>하나의 WAS만 사용하면 장애 발생 시 대응이 어려움  </li>
</ul>
<hr>
<h3 id="2-로드-밸런싱-방식-다중-was-배치"><strong>2. 로드 밸런싱 방식 (다중 WAS 배치)</strong></h3>
<p><img src="https://velog.velcdn.com/images/harvard--/post/171e4353-ba24-4949-af7a-eaeeacc5c528/image.png" alt=""></p>
<h4 id="특징-1">특징</h4>
<ul>
<li>여러 개의 WAS를 두고 <strong>부하를 분산(로드 밸런싱)</strong>  </li>
<li>웹 서버가 요청을 적절히 나누어 각 WAS로 전달  </li>
<li>WAS 중 하나가 다운되더라도 나머지가 동작 가능  </li>
</ul>
<h4 id="장점-1">장점</h4>
<ul>
<li>높은 트래픽을 효과적으로 처리 가능  </li>
<li>특정 WAS에 장애가 발생해도 서비스가 계속 운영됨 (고가용성)  </li>
</ul>
<h4 id="단점-1">단점</h4>
<ul>
<li>WAS가 여러 개 필요해 서버 비용 증가  </li>
<li>로드 밸런서를 추가로 설정해야 하므로 초기 설정이 복잡  </li>
</ul>
<hr>
<h3 id="3-리버스-프록시-방식-웹-서버가-프록시-역할-수행"><strong>3. 리버스 프록시 방식 (웹 서버가 프록시 역할 수행)</strong></h3>
<p><img src="https://velog.velcdn.com/images/harvard--/post/ef714630-c264-48af-8d40-9d810d8a1988/image.png" alt=""></p>
<h4 id="특징-2">특징</h4>
<ul>
<li>웹 서버가 단순 정적 콘텐츠 제공을 넘어 <strong>리버스 프록시 역할</strong> 수행  </li>
<li>클라이언트의 요청을 필터링하고 적절한 WAS로 전달  </li>
<li>보안 기능 강화 (SSL 암호화, 방화벽 역할 등 가능)  </li>
</ul>
<h4 id="장점-2">장점</h4>
<ul>
<li>웹 서버가 보안과 트래픽 제어를 담당해 WAS 부담 감소  </li>
<li>외부와 WAS 간의 직접 연결을 차단해 보안 강화  </li>
</ul>
<h4 id="단점-2">단점</h4>
<ul>
<li>웹 서버가 과부하 상태가 되면 전체 성능 저하 가능  </li>
<li>설정이 다소 복잡할 수 있음  </li>
</ul>
<hr>
<h3 id="어떤-방식을-선택해야-할까"><strong>어떤 방식을 선택해야 할까?</strong></h3>
<ul>
<li><strong>👨‍👦 소규모 프로젝트</strong> → <strong>단순 웹 서버 + WAS</strong> 구조  </li>
<li><strong>👨‍👨‍👧‍👦 트래픽이 많거나 고가용성이 중요</strong> → <strong>로드 밸런싱 방식</strong>  </li>
<li><strong>⛔ 보안이 중요한 서비스</strong> → <strong>리버스 프록시 방식</strong>  </li>
</ul>
<p><strong>웹 서버와 WAS를 조합하는 방식은 서비스의 규모와 요구사항에 따라 달라진다.</strong><br>가장 적절한 아키텍처를 선택해 <strong>성능, 확장성, 보안</strong>을 최적화하는 것이 중요하다.</p>
<hr>
<h2 id="결론">결론</h2>
<p><strong>웹 서버와 WAS는 서로 보완적인 역할을 한다.</strong><br><strong>웹 서버</strong>는 빠르고 효율적으로 <strong>정적 콘텐츠 제공</strong><br><strong>WAS</strong>는 복잡한 <strong>비즈니스 로직과 동적 콘텐츠 처리</strong>  </p>
<p><strong>결론적으로</strong>, 웹 서버와 WAS를 적절히 조합하면 <strong>더 빠르고 안정적인 웹 서비스를 구축</strong>할 수 있다.<br>웹 서비스 성능을 극대화하려면 <strong>각각의 역할을 정확히 이해하고, 서비스의 요구사항에 맞는 기술을 선택하는 것</strong>이 중요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Web] HTTP(HyperText Transfer Protocol)]]></title>
            <link>https://velog.io/@harvard--/Web-HTTPHyperText-Transfer-Protocol</link>
            <guid>https://velog.io/@harvard--/Web-HTTPHyperText-Transfer-Protocol</guid>
            <pubDate>Thu, 20 Mar 2025 15:46:18 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>HTTP는 클라이언트와 서버 간에 이루어지는 <strong>요청/응답(request/response)</strong> 프로토콜이다. <code>TEXT</code>, <code>IMAGE</code>, <code>FILE</code>, <code>HTML</code>, <code>JSON</code> 등 다양한 형태의 데이터가 HTTP를 통해 전송된다.</p>
<p>HTTP에는 여러 버전이 존재하며, 현재 가장 널리 사용되는 버전은 <strong><code>HTTP/1.1(TCP)</code></strong> 이다. 최근에는 <strong><code>HTTP/2</code></strong>와 <strong><code>HTTP/3(UDP)</code></strong> 의 사용량이 증가하고 있다. 이 글에서는 HTTP의 가장 중요한 버전인 <strong>1.1</strong>을 중심으로 정리해 보려고 한다.</p>
<hr>
<h2 id="http의-특징">HTTP의 특징</h2>
<h3 id="1-클라이언트-서버-구조">1. <strong>클라이언트-서버 구조</strong></h3>
<p>초기에는 클라이언트와 서버의 구분이 명확하지 않았다. 그러나 HTTP를 사용하면서 <strong>클라이언트와 서버가 명확히 분리</strong>되었고, 클라이언트는 <strong>UI와 사용자 경험(UX)</strong> 에 집중하고, 서버는 <strong>데이터 처리 및 비즈니스 로직</strong> 을 담당하도록 발전하였다. 이를 통해 <strong>클라이언트와 서버가 각각 독립적으로 개선될 수 있는 구조</strong>가 되었다.</p>
<h3 id="2-무상태stateless">2. <strong>무상태(Stateless)</strong></h3>
<p>서버는 <strong>클라이언트의 상태를 유지하지 않는 특징</strong>이 있다.</p>
<h4 id="장점">장점</h4>
<ul>
<li><strong>수평 확장(Scale Out)</strong> 이 용이하다.</li>
<li>요청량이 갑자기 증가하더라도 서버를 쉽게 증설할 수 있다.</li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li>클라이언트가 매 요청마다 필요한 데이터를 추가로 전송해야 한다.</li>
</ul>
<h4 id="한계점">한계점</h4>
<ul>
<li>모든 시스템이 무상태로 설계될 수는 없다.</li>
<li>로그인과 같이 상태 유지를 필요로 하는 경우에는 <strong>쿠키, 세션, 토큰</strong> 등을 활용해야 한다.</li>
</ul>
<h3 id="3-비연결-connectionless">3. <strong>비연결 (Connectionless)</strong></h3>
<p>HTTP는 기본적으로 <strong>연결을 유지하지 않는 모델</strong>이다. 즉, 클라이언트와 서버는 요청을 보낸 후 연결을 끊는다.</p>
<h4 id="장점-1">장점</h4>
<ul>
<li><strong>서버 자원을 효율적으로 사용할 수 있다.</strong> 각 요청마다 새로운 연결을 만들지 않으므로 서버 자원이 낭비되지 않는다.</li>
</ul>
<h4 id="단점-1">단점</h4>
<ul>
<li>요청이 새로 발생할 때마다 <strong>새로운 TCP 연결을 설정</strong>해야 하므로 <strong>연결 과정(3-way handshake)으로 인해 응답 시간이 증가</strong>할 수 있다.</li>
<li>웹 사이트의 정적 자원(<strong>HTML, CSS, JS, 이미지 등</strong>)을 다시 다운로드해야 할 수도 있다.<ul>
<li>이를 해결하기 위해 <strong>캐시(Caching)</strong> 및 <strong>브라우저 캐싱</strong> 기법이 활용된다.</li>
</ul>
</li>
</ul>
<h3 id="4-http-지속-연결persistent-connections">4. <strong>HTTP 지속 연결(Persistent Connections)</strong></h3>
<p>기본적인 HTTP는 <strong>단기 연결 방식</strong>이지만, <strong>HTTP/1.1부터는 지속 연결(Persistent Connection)</strong> 을 지원하여 단점을 보완하였다.</p>
<ul>
<li>하나의 연결을 유지하면서 <strong>여러 요청과 응답을 주고받을 수 있다</strong>.</li>
<li>연결을 한 번만 맺고 유지하므로 <strong>성능이 향상된다</strong>.</li>
<li><strong>HTTP/1.1에서는 기본적으로 <code>Connection: keep-alive</code>가 활성화</strong> 되어 있다.<ul>
<li>명시적으로 <code>Connection: close</code>를 설정하지 않는 한 동일한 TCP 연결이 유지된다.</li>
</ul>
</li>
<li><strong>예시</strong>: <code>HTML</code> 요청 → <code>CSS</code> 요청 → <code>JS</code> 요청 → <code>IMAGE</code> 요청을 하나의 연결에서 처리.</li>
</ul>
<hr>
<h2 id="http-메시지-구조">HTTP 메시지 구조</h2>
<p>HTTP 메시지는 <strong>Start Line(시작 줄), Message Header(메시지 헤더), Message Body(메시지 본문)</strong> 의 세 부분으로 구성된다. 이 구조는 클라이언트의 요청과 서버의 응답에 동일하게 적용된다.</p>
<table>
<thead>
<tr>
<th><strong>구성 요소</strong></th>
<th><strong>설명</strong></th>
<th><strong>예시</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Start Line (시작 줄)</strong></td>
<td>요청 메서드 및 URL 또는 상태 코드 포함</td>
<td><strong>GET /index.html HTTP/1.1 <br> HTTP/1.1 200 OK</strong></td>
</tr>
<tr>
<td><strong>Message Header (메시지 헤더)</strong></td>
<td>요청/응답에 대한 추가 정보 제공</td>
<td><strong>Content-Type: text/html</strong> <br><strong>User-Agent: Mozilla/5.0</strong></td>
</tr>
<tr>
<td><strong>Message Body (메시지 본문)</strong></td>
<td>요청 또는 응답 데이터를 포함</td>
<td><strong>{ &quot;name&quot;: &quot;John&quot; }</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="http-메서드method">HTTP 메서드(Method)</h2>
<p>HTTP는 클라이언트가 서버에 요청을 보낼 때 사용하는 <strong>다양한 메서드</strong>를 제공한다.</p>
<h3 id="주요-메서드">주요 메서드</h3>
<table>
<thead>
<tr>
<th><strong>메서드</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>GET</strong></td>
<td>리소스 조회</td>
</tr>
<tr>
<td><strong>POST</strong></td>
<td>리소스 생성</td>
</tr>
<tr>
<td><strong>PUT</strong></td>
<td>리소스 전체 수정</td>
</tr>
<tr>
<td><strong>PATCH</strong></td>
<td>리소스 일부 수정</td>
</tr>
<tr>
<td><strong>DELETE</strong></td>
<td>리소스 삭제</td>
</tr>
</tbody></table>
<h3 id="기타-메서드">기타 메서드</h3>
<table>
<thead>
<tr>
<th><strong>메서드</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>HEAD</strong></td>
<td>GET과 동일하지만 응답 본문 제외</td>
</tr>
<tr>
<td><strong>OPTIONS</strong></td>
<td>서버가 지원하는 메서드 조회</td>
</tr>
<tr>
<td><strong>CONNECT</strong></td>
<td>프록시 터널링 요청</td>
</tr>
<tr>
<td><strong>TRACE</strong></td>
<td>요청이 서버를 통해 어떻게 전달되는지 추적</td>
</tr>
</tbody></table>
<hr>
<h2 id="http-메서드의-속성">HTTP 메서드의 속성</h2>
<p><img src="https://velog.velcdn.com/images/harvard--/post/fc510ec4-dae7-4e12-b1fd-e98748a320c5/image.png" alt=""></p>
<p><strong>1. 안전성 (Safety)</strong></p>
<ul>
<li><strong>GET, HEAD</strong>는 서버의 상태를 변경하지 않으므로 <strong>안전한 메서드</strong>이다.</li>
</ul>
<p><strong>2. 멱등성 (Idempotency)</strong></p>
<ul>
<li>여러 번 실행해도 <strong>같은 결과를 보장하는지 여부</strong>.</li>
<li><strong>GET, PUT, DELETE</strong>는 동일한 요청을 여러 번 전송해도 같은 결과를 보장하지만, <strong>POST</strong>는 서버의 상태를 변경할 수 있어 <strong>멱등하지 않다</strong>.</li>
</ul>
<p><strong>3. 캐시 가능성 (Cacheability)</strong></p>
<ul>
<li><strong>GET, HEAD</strong>는 <strong>캐싱이 가능</strong>하지만, <strong>POST는 기본적으로 캐싱되지 않는다</strong>.</li>
</ul>
<hr>
<h2 id="http-상태-코드status-code">HTTP 상태 코드(Status Code)</h2>
<p>HTTP 응답은 <strong>상태 코드(Status Code)</strong> 를 포함하여 클라이언트에게 <strong>요청 처리 결과</strong>를 전달한다.</p>
<table>
<thead>
<tr>
<th><strong>상태 코드</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1xx</strong></td>
<td>정보 응답 (Informational)</td>
</tr>
<tr>
<td><strong>2xx</strong></td>
<td>성공 (Success)</td>
</tr>
<tr>
<td><strong>3xx</strong></td>
<td>리다이렉션 (Redirection)</td>
</tr>
<tr>
<td><strong>4xx</strong></td>
<td>클라이언트 오류 (Client Error)</td>
</tr>
<tr>
<td><strong>5xx</strong></td>
<td>서버 오류 (Server Error)</td>
</tr>
</tbody></table>
<h3 id="주요-상태-코드-예시">주요 상태 코드 예시</h3>
<ul>
<li><strong>200 OK</strong>: 요청이 성공적으로 처리됨.</li>
<li><strong>301 Moved Permanently</strong>: 리소스가 영구적으로 이동됨.</li>
<li><strong>400 Bad Request</strong>: 잘못된 요청.</li>
<li><strong>401 Unauthorized</strong>: 인증이 필요함.</li>
<li><strong>403 Forbidden</strong>: 접근 권한 없음.</li>
<li><strong>404 Not Found</strong>: 요청한 리소스를 찾을 수 없음.</li>
<li><strong>500 Internal Server Error</strong>: 서버 내부 오류.</li>
</ul>
<p><strong>(💡+자주 보이는 코드 보충 설명)</strong>
<strong>400 Bad Request:</strong>
클라이언트가 보낸 요청이 잘못되었거나 서버에서 이해할 수 없는 상태를 나타낸다.
예를 들어, 요청 형식이 잘못되었거나 필수 파라미터가 누락된 경우 발생한다.
클라이언트는 요청을 수정하여 다시 보내야 한다.</p>
<p><strong>404 Not Found:</strong>
클라이언트가 요청한 리소스를 서버에서 찾을 수 없는 상태를 나타낸다.
예를 들어, URL이 잘못되었거나 리소스가 삭제된 경우 발생한다.
클라이언트는 올바른 경로를 확인해야 한다.</p>
<p><strong>500 Internal Server Error:</strong>
서버 내부의 오류로 인해 요청을 처리할 수 없는 상태를 나타낸다. 
예를 들어, 서버의 설정 오류나 예기치 못한 서버 상태로 인해 응답을 처리할 수 없을 때 발생한다. 
이는 서버 개발자나 시스템 관리자에게 문제가 있음을 알려주는 중요한 상태 코드다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>HTTP는 <strong>웹 통신의 핵심 프로토콜</strong>로, 요청/응답 구조를 기반으로 동작하며 다양한 특징과 메서드를 갖고 있다. <strong>HTTP/1.1에서 등장한 지속 연결</strong>을 통해 성능이 개선되었으며, <strong>HTTP/2와 HTTP/3에서는 더 나은 최적화가 이루어지고 있다</strong>. HTTP의 특징과 구조를 이해하면 <strong>웹 개발 및 네트워크 설계에 큰 도움이 될 뿐만 아니라, RESTful API 설계에도 필수적인 개념으로 활용된다</strong>.</p>
]]></description>
        </item>
    </channel>
</rss>