<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>heeun_98.log</title>
        <link>https://velog.io/</link>
        <description>기록하는 공간</description>
        <lastBuildDate>Thu, 23 Apr 2026 13:10:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>heeun_98.log</title>
            <url>https://velog.velcdn.com/images/heeun_98/profile/54311054-48a9-4e74-802e-1d8147026981/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. heeun_98.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/heeun_98" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[학교 API에 커넥션 재사용하기]]></title>
            <link>https://velog.io/@heeun_98/%ED%95%99%EA%B5%90-API%EC%97%90-%EC%BB%A4%EB%84%A5%EC%85%98-%EC%9E%AC%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/%ED%95%99%EA%B5%90-API%EC%97%90-%EC%BB%A4%EB%84%A5%EC%85%98-%EC%9E%AC%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 23 Apr 2026 13:10:43 GMT</pubDate>
            <description><![CDATA[<p>기존 미팀 프로젝트에서는 미팀 자체 로그인과 소셜 로그인을 사용했지만 대학생 중심으로 페르소나를 좁히기 위해 세종대학교 포털 로그인을 통해서 학생인증을 하기로했다. 사용자는 미팀을 통해서 로그인을 하면 학교 API에 요청을 보내 학교 학생인지를 검증한다. 학교 API에 요청을 보낼 경우 응답시간을 측정하고 TCP 커넥션 응답시간에 얼만큼 차지하지는 알고싶었다.</p>
<h3 id="학교-api-응답시간-분석">학교 API 응답시간 분석</h3>
<p>운영서버에서 학교 포털 서버로 curl 명령어를 통해 응답시간을 분석해보았다.</p>
<pre><code>{
  &quot;DNS 조회 시간&quot;: 0.001779s,
  &quot;TCP 연결 시간&quot;: 0.012955s,
  &quot;SSL/TLS 핸드쉐이크&quot;: 0.030608s,
  &quot;첫 데이터 수신(TTFB)&quot;: 0.039398s,
  &quot;-----------------&quot;: &quot;-----------------&quot;,
  &quot;전체 소요 시간&quot;: 0.039451s
}</code></pre><p>위의 결과는 누적 시간이므로 이전 결과까지 걸린 시간을 제외시키고 전체 소요시간에서 비중하는 비율을 계산해 보았다.</p>
<table>
<thead>
<tr>
<th align="left">분석 구간</th>
<th align="left">누적 시간 (Cumulative)</th>
<th align="left">순수 소요 시간 (Duration)</th>
<th align="left">비중 (%)</th>
<th align="left">비고</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>DNS 조회</strong></td>
<td align="left">0.0017s</td>
<td align="left"><strong>1.7ms</strong></td>
<td align="left">4.3%</td>
<td align="left">도메인 IP 변환</td>
</tr>
<tr>
<td align="left"><strong>순수 TCP 연결</strong></td>
<td align="left">0.0129s</td>
<td align="left"><strong>11.2ms</strong></td>
<td align="left">28.4%</td>
<td align="left">3-way Handshake</td>
</tr>
<tr>
<td align="left"><strong>순수 SSL 핸드쉐이크</strong></td>
<td align="left">0.0306s</td>
<td align="left"><strong>17.7ms</strong></td>
<td align="left">44.9%</td>
<td align="left">보안 터널 구축 (가장 무거운 작업)</td>
</tr>
<tr>
<td align="left"><strong>순수 서버 처리 (TTFB)</strong></td>
<td align="left">0.0393s</td>
<td align="left"><strong>8.7ms</strong></td>
<td align="left">22.1%</td>
<td align="left">API 로직 및 DB 조회</td>
</tr>
<tr>
<td align="left"><strong>전체 합계</strong></td>
<td align="left"><strong>0.0394s</strong></td>
<td align="left"><strong>39.4ms</strong></td>
<td align="left"><strong>100%</strong></td>
<td align="left">-</td>
</tr>
</tbody></table>
<p>전체 시간 39.4ms 중 데이터를 주고받기 위한 준비 단계(DNS + TCP + SSL)에만 <strong>30.6ms</strong>가 소요되었다. 즉, 전체 요청 시간의 <strong>약 77%가 네트워크 연결을 맺는 데 사용</strong>되고 있다. 실제 서버가 비즈니스 로직을 처리하는 시간(8.7ms)보다 연결을 만드는 시간이 약 3.5배 더 길다는 것을 알 수 있다.
물론 순수 서버 처리시간에 실제 데이터를 보내는 네트워크 시간이 포함되어 있지만 이 부분은 생략해도 대부분의 시간을 커넥션을 맺는데 많은 시간을 차지하는걸 알 수 있다.</p>
<h3 id="학교-서버는-커넥션을-유지할까">학교 서버는 커넥션을 유지할까?</h3>
<p>HTTP/1.1 부터는 지속적인 연결을 지원한다.
Connetion : keep-alive 헤더를 통해 커넥션을 재사용 가능하다는것을 알 수 있다.
근데 HTTP/1.1 부터는 default 가 keep-alive 이므로 헤더를 생략한다고 하는데
왜 학교 서버는 명시적으로 헤더에 넣을까? 찾아보니 서버 앞단에 보안장비나 로드밸런서에서 명시적으로 헤더에 넣어주는 경우가 있다고 한다.</p>
<pre><code>curl -i -s -A &quot;Mozilla/5.0&quot; \
-e &quot;https://portal.sej****.ac.kr&quot; \
--data-urlencode &quot;mainLogin=N&quot; \
--data-urlencode &quot;id=*******&quot; \
--data-urlencode &quot;password=*********&quot; \
&quot;https://***********************&quot; | head -n 25
HTTP/1.1 200 OK
Server: Apache
Date: Mon, 27 Apr 2026 10:27:11 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 2060
Connection: keep-alive
X-ORACLE-DMS-ECID: aa7c22e1-262d-48bd-abe4-24bf5a67be31-0097724e
X-ORACLE-DMS-RID: 0</code></pre><p>그럼 학교 서버는 커넥션을 유지한다는것을 알 수 있지만 커넥션을 몇초 동안 유지하는지는 알 수 없다. 만약에 커넥션 유지를 1초 유지한다고 가정해보면 커넥션을 재사용해서 요청을 보낼일은 적을것이다. 트래픽이 많은 경우에는 1초 동안 커넥션을 유지해도 많은 요청이 커넥션을 재사용하겠지만 현재 미팀 서비스에서는 무의미한 커넥션 유지 시간이라고 판단된다.</p>
<p><code>Keep-Alive: timeout=5, max=100</code> 와 같이 헤더에 명시적으로 제공해 주는 경우도 존재하지만 서버쪽에서 헤더를 생략했거나, 명시적으로 공개하지 않았을 것이다. 현재 우리 미팀 서버에서는 학교 서버에서 제공하는 커넥션 timeout 시간이 어느정도 유지되는지 알아야 커넥션을 재사용하여 유의미한 성능개선을 할 수 있다고 판단했다.</p>
<h3 id="학교-서버의-timeout-측정하기">학교 서버의 timeout 측정하기</h3>
<p>학교 서버에 터미널1에서는 <code>openssl</code> 명령어를 사용하여 프로세스에 연결을 유지하고, 다른 터미널2 에서는 <code>while</code> 스크립트를 통해서 연결 상태를 1초 마다 확인해 보았다.</p>
<pre><code>
while true; do
  # openssl이 443 포트로 연결을 맺고 있는지 확인
  STATUS=$(lsof -i :443 | grep openssl)

  if [ -z &quot;$STATUS&quot; ]; then
    echo &quot;---------------------------------------&quot;
    echo &quot;X [$(date +%T)] 커넥션이 끊겼습니다. (실험 종료)&quot;
    echo &quot;---------------------------------------&quot;
    break
  fi

  echo &quot;O [$(date +%T)] 연결 유지 중...&quot;
  sleep 1
done
O [19:50:33] 연결 유지 중...
O [19:50:34] 연결 유지 중...
O [19:50:35] 연결 유지 중...
O [19:50:36] 연결 유지 중...
O [19:50:38] 연결 유지 중...
--- 생략
O [19:55:49] 연결 유지 중...
O [19:55:50] 연결 유지 중...
---------------------------------------
X [19:55:51] 커넥션이 끊겼습니다. (실험 종료)
---------------------------------------</code></pre><p>대량 5분 20초(320) 초 동안 커넥션이 유지되는걸 알 수 있었다.
학교 서버에서는 생각보다 오랜시간 동안 커넥션을 유지하도록 되어있다.
학교 서버로 요청의 많이 가더라도 재사용 시간이 길기 때문에 많은 요청에서도 커넥션 시간을 줄일수 있다고 판단된다.</p>
<h3 id="짧은-시간-안에-요청이-많을-경우">짧은 시간 안에 요청이 많을 경우</h3>
<p>짧은 시간안에 많은 로그인 요청이 발생 경우 어떻게 될까?
만약 <strong>요청 속도 &gt; 커넥션 반환 속도</strong> 인 경우 커넥션 재사용이 불가능하다.
이와 같은 상황은 아래와 같은 상황에서 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/ff10347c-c852-45c6-a949-97caa47ee081/image.png" alt=""></p>
<p>위와 같은 상황이 발생할 경우 커넥션을 재사용하지 못하고 매번 새로운 커넥션을 생성해서 사용해서 요청을 보낼 것이다.
물론 현재 커넥션 사용 시간이 짧은 상황이라 위와 같은 상황이 낮은 확률로 발생할것이다.
위의 그림을 보면 요청1, 요청2, 요청3과 같은 상황에서는 동시 요청 수만큼 외부 서버와 커넥션을 맺는 것을 확인할 수 있다. 이는 문제가 될 수 있는데, 많은 커넥션 생성과 요청은 외부 API 서버에 DDOS 공격을 초래할 수 있으며, 커넥션의 TIME_WAIT 또한 문제가 발생 할 수 있다. 따라서, 외부 서버와 소통하는 커넥션을 잘 관리하는 것이 중요할 것이다.</p>
<p>실제로 동시 요청이 발생했을 때 서버가 새로운 커넥션을 생성하여 병렬로 처리하는지 확인하기 위해 실험을 진행했다. Apache Bench를 사용하여 10명의 사용자가 동시에 로그인을 시도하는 상황을 시뮬레이션했으며, netstat 명령어를 통해 실시간으로 확립된(ESTABLISHED) TCP 커넥션의 개수를 확인했다.</p>
<pre><code>ab -n 200 -c 10 -k -p post_data.txt -T &quot;application/json&quot; http://localhost:8080/api/v1/auth/login/sejong &amp; sleep 0.2; netstat -an | grep 8080 | grep ESTABLISHED</code></pre><pre><code>Benchmarking localhost (be patient)
tcp6       0      0  ::1.8080               ::1.53425              ESTABLISHED
tcp6       0      0  ::1.53425              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53424              ESTABLISHED
tcp6       0      0  ::1.53424              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53423              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53422              ESTABLISHED
tcp6       0      0  ::1.53423              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53422              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53421              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53420              ESTABLISHED
tcp6       0      0  ::1.53421              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53420              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53419              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53418              ESTABLISHED
tcp6       0      0  ::1.53419              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53418              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53417              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53416              ESTABLISHED
tcp6       0      0  ::1.53417              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53416              ::1.8080               ESTABLISHED</code></pre><p>예상대로 동시 요청을 보낼 경우 새로운 커넥션을 생성해서 학교 로그인 API 로 요청을 보낸다.
이렇게** 동시 요청이 많을 경우 학교 서버쪽에서 DDOS 공격으로 인지할수 있기 때문에 학교 서버와 커넥션을 제한해서 재사용하는게 좋을것이다.**</p>
<h3 id="커넥션-풀을-사용하자">커넥션 풀을 사용하자</h3>
<p>학교 서버는 대략 320초 동안 커넥션이 유지되는것을 알수 있었다.
그럼 클라이언트(미팀 서버)에서 320초 이상 커넥션을 유지하려고 하면 어떻게 될까? 이미 죽은 커넥션을 가지고 학교 서버로 요청을 보내봤자 오류가 발생할 것이다.
즉, 클라이언트만 열러있다고 판단하게 되고 결국 사용자에게 오류 응답을 제공할것이다. 또한 <strong>위의 그림과 같이 커넥션 풀 없이 동시 요청이 들어온다면 keep-alive 의 이점을 누릴수가 없다.</strong></p>
<p>커넥션을 유지하기 위해 2가지 방법을 생각해 보았다.</p>
<ol>
<li>keep-alive 가 끝나기 전(약 320초)에 주기적으로 API 재요청</li>
<li>커넥션 풀을 미리 생성해 두고 320초 전에 새로운 커넥션 생성</li>
</ol>
<p>1번으로 주기적으로 API 요청을 보낼경우 커넥션을 지속적으로 유지 가능하기 때문에 
API 를 재요청 하려고 했지만 학교 서버의 경우 keep-alive 의 max request 값을 알 수 없기 때문에, 계속해서 요청을 보낼경우 요청 횟수가 초과되어 연결이 끊겨 버리는 경우가 발생할수 있다. 또한 학교 서버로 320초 전에 주기적으로 요청을 보내야 한다.</p>
<p>학교 서버는 keep-alive 헤더를 보내지 않기 때문에 max 값도 알수 없다.
timeout은 직접 실험하였을때 320초 가량 유지되었다.
이를 바탕으로 파라미터를 설정해야한다.
HikariCp 에서 idle Timeout 은 유휴 커넥션이 사용되지 않을 경우 제거하기 까지의 시간을 뜻한다.
같은 의미로 Apache HttpClient 에서는  <code>evictIdleConnections()</code> 파라미터를 설정하여 유휴 커넥션 타임아웃을 지정할 수 있다.
<strong>현재 학교 서버의 keep-alive timeout 은 대략 320초 정도로 측정되었기 때문에 이보다 짧은 시간 안에 미팀 서버(클라이언트)에서 먼저 커넥션을 끊어줘야 한다.</strong>
또한, DDOS 방지를 위해서 최대 커넥션의 개수를 5개로 제한하였다. 학교 서버로의 로그인 요청의 평균 응답시간은 대략 200ms 정도로 측정되었고. 초당 로그인 요청 5 * (1000 / 200) 정도로, <strong>1초에 25개의 로그인 요청</strong> 정도면 현재로써 충분하다고 판단되었기 때문이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[원자적 쿼리로 동시성 문제 해결해 보기]]></title>
            <link>https://velog.io/@heeun_98/%EC%9B%90%EC%9E%90%EC%A0%81-%EC%BF%BC%EB%A6%AC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B0%80%EB%8A%A5%ED%95%9C%EC%A7%80</link>
            <guid>https://velog.io/@heeun_98/%EC%9B%90%EC%9E%90%EC%A0%81-%EC%BF%BC%EB%A6%AC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B0%80%EB%8A%A5%ED%95%9C%EC%A7%80</guid>
            <pubDate>Tue, 07 Apr 2026 15:40:25 GMT</pubDate>
            <description><![CDATA[<h3 id="문제상황">문제상황</h3>
<p>미팀 프로젝트에서는 사용자가 프로젝트에 좋아요를 토글 형식으로 누르는 상황이다.
좋아요는 한번만 누를수 있고, 한번더 누를 경우 좋아요가 취소가 된다.
하나의 프로젝트에 대해서 좋아요 요청시 동시성 문제가 발생한다고 판단했고, 이를 해결하기 위해 낙관적 락, 비관적 락의 성능비교를 통해 비관적 락을 선택하였다.
충돌이 적은 상황에서는 당연하게도 무의미한 성능차이가 발생하였고,
충돌이 많은 상황에서는 비관적 락 81.03ms, 낙관적 락 103.99ms로 측정되어 비관적 락이 더 안정적인 성능을 보였다.
하지만 여기서 좋아요 post 요청시 동시성 문제가 발생한 이유는 조회 시점과 업데이트 시점의 차이가 존재하여 발생하는 문제였고, <strong>굳이 조회를 하지않고 DB 에서 원자적 쿼리를 통해 해결할 수 있지 않을까?</strong> 라는 생각이 들었다. <strong>update 시점에 where절을 사용하여 조회와 동시에 update를 진행하면, select for update 시점에 얻는 LOCK 보다 LOCK 소유시간이 적기 때문에 처리량이 올라가는것은 당연할 것이다.</strong>  </p>
<h4 id="db-배타적-락x락-은-언제-걸까">DB 배타적 락(X락) 은 언제 걸까</h4>
<p><strong>write 쿼리(INSERT, UPDATE, DELETE)는 기본적으로 X락(Exclusive Lock)을 획득한다.</strong>데이터베이스에서 write 를 한 시점에 해당 row(레코드)에 대한 X Lock 을 획득한다. 이후 commit 일이 일어나기 전까지 소유하고 있다가 commit 이후에 Lock을 반납한다.</p>
<p>만약 트랜잭션A 가 Lock을 획득하면 commit 이 일어나기 전까지 다른 트랜잭션B는 Lock 을 획득하기 위해 대기한다. 트랜잭션 B는 설정한 LOCK_TIMEOUT    동안에 Lock 을 소유하기 위해 대기하고, LOCK_TIMEOUT 이후에는 타임아웃 오류가 발생한다.</p>
<p>조회시점에 X락을 획득하는 쿼리도 존재한다.
SELECT FROM UPDATE 쿼리가 X락을 획득하는 쿼리이다.</p>
<pre><code class="language-java">// ProjectLikeService.java (현재)

@Transactional
public ToggleLikeResponse toggleWithPessimistic(Long projectId, String email) {

    // 1. Project 조회 (SELECT FOR UPDATE)
    Project project = projectRepository.findByIdForUpdate(projectId)  // ← X Lock 획득
            .orElseThrow(...);

    Member member = memberRepository.findByEmail(email)
            .orElseThrow(...);

    Optional&lt;ProjectLike&gt; existing =
            projectLikeRepository.findByMemberAndProject(member, project);

    if (existing.isPresent()) {
        projectLikeRepository.delete(existing.get());
        project.decreaseLike();  // like_count - 1

        return new ToggleLikeResponse(
                projectId,
                false,
                project.getLikeCount()
        );
    }

    projectLikeRepository.save(ProjectLike.create(member, project));
    project.increaseLike();  // like_count + 1

    return new ToggleLikeResponse(
            projectId,
            true,
            project.getLikeCount()
    );
}

// COMMIT 시점에 X Lock 해제
</code></pre>
<p>여기서 그럼 원자적 쿼리 방식으로 Race Condition을 해결할 수 있을것 같다는 생각을 했다. 이렇게 할 경우 X Lock 의 소유 시점이 위의 select 시점에 X Lock 의 소유시점보다 짧기 때문에 DB에서의 처리량이 올라갈 것이라고 판단했다.
아래의 코드는 위의 비관적 락에서 원자적 쿼리 방식으로 진행한 코드이다.</p>
<pre><code class="language-java">
// ProjectLikeService.java (원자적 쿼리 방식)

@Transactional
public ToggleLikeResponse toggleWithAtomicQuery(Long projectId, String email) {

    // 1. Project 존재 확인 (Lock 없음)
    if (!projectRepository.existsById(projectId)) {
        throw new CustomException(ErrorCode.PROJECT_NOT_FOUND);
    }

    Member member = memberRepository.findByEmail(email)
            .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));

    // 2. 좋아요 존재 여부 확인
    boolean exists =
         projectLikeRepository.existsByMemberIdAndProjectId(
                    member.getId(),
                    projectId
            );

    if (exists) {
        // 삭제 + 원자적 감소
        projectLikeRepository.deleteByMemberIdAndProjectId(
                member.getId(),
                projectId
        );

        projectRepository.decreaseLikeCount(projectId);  // X Lock 최소

        int likeCount = projectRepository.countLikeById(projectId);

        return new ToggleLikeResponse(
                projectId,
                false,
                likeCount
        );
    }

    // 삽입 + 원자적 증가
    projectLikeRepository.save(
            ProjectLike.create(member.getId(), projectId)
    );

    projectRepository.increaseLikeCount(projectId);  // X Lock 최소

    int likeCount = projectRepository.countLikeById(projectId);

    return new ToggleLikeResponse(
            projectId,
            true,
            likeCount
    );
}

</code></pre>
<p>원자적 쿼리를 사용함으로 Project를 조회하는 쿼리는 존재하지 않는다.
countLikeById(projectId) 를 통해서 DB 내에서 조회와 업데이트를 진행할수 있다. 아래 코드는 원자적 쿼리이다.</p>
<pre><code class="language-java">// ProjectRepository.java

public interface ProjectRepository extends JpaRepository&lt;Project, Long&gt; {

    // 원자적 증가 (like_count + 1)
    @Modifying
    @Query(&quot;&quot;&quot;
        UPDATE Project p
        SET p.likeCount = p.likeCount + 1
        WHERE p.id = :projectId
    &quot;&quot;&quot;)
    void increaseLikeCount(@Param(&quot;projectId&quot;) Long projectId);

    // 원자적 감소 (like_count - 1)
    @Modifying
    @Query(&quot;&quot;&quot;
        UPDATE Project p
        SET p.likeCount = p.likeCount - 1
        WHERE p.id = :projectId
    &quot;&quot;&quot;)
    void decreaseLikeCount(@Param(&quot;projectId&quot;) Long projectId);
}
</code></pre>
<h3 id="원자적-쿼리에서-발생할-수-있는-문제점">원자적 쿼리에서 발생할 수 있는 문제점</h3>
<p>사용자가 매우 빠르게 좋아요 요청을 두번을 보내거나, 네트워크 끊김으로 인해 좋아요가 빠르게 2번 요청이 왔다고 가정하자.
<strong>실수로 중복 요청(일명 ‘따닥’)이 발생했을때를 말한다.</strong>
이미 좋아요를 한 상태로 좋아요를 취소하려고 POST 요청을 보냈지만 2번의 요청이 빠른 찰나에 온 상황이다.</p>
<p>먼저 온 요청을 <strong>요청1</strong>, 두번째로 온 요청을 <strong>요청2</strong>라고 하자.</p>
<p>먼저 요청1은 아래와 같이 좋아요 존재 여부를 ProjectLike 테이블에서 확인할것이다.
( 해당 ROW 가 존재 유무 확인)</p>
<pre><code class="language-java"> // 2. 좋아요 존재 여부 확인
    boolean exists =
         projectLikeRepository.existsByMemberIdAndProjectId(
                    member.getId(),
                    projectId
            );</code></pre>
<p>요구사항은 좋아요를 취소하는(토글 방식) 상황이므로 TRUE 를 return 할 것이다.
TRUE이므로 아래와 같이 if 문에 진입을 하고 DELETE 를 통해서 해당 ROW을 삭제를 할것이다. 그 후 원자적 쿼리를 수행한다.( X LOCK 획득)</p>
<pre><code class="language-java">if (exists) {
        // 삭제 + 원자적 감소
        projectLikeRepository.deleteByMemberIdAndProjectId(
                member.getId(),
                projectId
        );
        projectRepository.decreaseLikeCount(projectId);  // X Lock 최소

        int likeCount = projectRepository.countLikeById(projectId);
        return new ToggleLikeResponse(
                projectId,
                false,
                likeCount
        );
    }
</code></pre>
<p>여기서 요청1이 DELETE 를 수행하기 전에 요청2가 좋아요 존재 여부 확인을 한다고 가정해보자. 빠른 2번의 요청이 올 경우 충분히 발생 가능한 상황이라고 판단했기 때문이다.</p>
<pre><code class="language-java"> // 2. 좋아요 존재 여부 확인
    boolean exists =
         projectLikeRepository.existsByMemberIdAndProjectId(
                    member.getId(),
                    projectId
            );</code></pre>
<p>요청 1이 DELETE 를 하기 전 이므로 요청 2는 LIKE 테이블에 ROW가 존재한다고 판단하고 TRUE를 return 할 것이다.
그 후, 요청 2 역시 if (exists) 절에 진입을 할것이다.
여기서 요청1의 DELETE 여부와 상관없이 요청2 는 <code>decreaseLikeCount</code> 쿼리를 날릴것이다.
이 상황에서 원자적 쿼리로 count = count - 1 을 총 2번 수행을 하기 때문에
총 count 값이 2가 감소할것이다. 요청을 2번 보내면, 좋아요 수가 -1 이 되고, 한번 더 누를경우 다시 +1 이 되어 결과론 적으로는 좋아요 수 변화가 있으면 안되지만, 빠른 요청이 2번 올 경우 위 요구사항을 만족하지 못한다.
비록 좋아요 수가 비니지스 적으로 정확하게 일치하지 않아도 사용자 경험이 저하되지 않지만, 이러한 문제는 좋아요 뿐만 아니라 사용자에게 보상을 주거나 포인트가 차감될 경우 충분히 발생할수 있는 문제라고 생각했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[절기 미션 추천 아키텍처]]></title>
            <link>https://velog.io/@heeun_98/%EC%A0%88%EA%B8%B0-%EB%AF%B8%EC%85%98-%EC%B6%94%EC%B2%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@heeun_98/%EC%A0%88%EA%B8%B0-%EB%AF%B8%EC%85%98-%EC%B6%94%EC%B2%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Mon, 06 Apr 2026 05:03:02 GMT</pubDate>
            <description><![CDATA[<h1 id="절기별-미션-추천-서비스---시스템-아키텍처">절기별 미션 추천 서비스 - 시스템 아키텍처</h1>
<h2 id="1-시스템-개요">1. 시스템 개요</h2>
<p>절기가 변경될 때마다 사용자에게 미션을 제공하고 알림을 발송하는 시스템</p>
<h3 id="11-미션-유형">1.1 미션 유형</h3>
<table>
<thead>
<tr>
<th>유형</th>
<th>설명</th>
<th>대상</th>
</tr>
</thead>
<tbody><tr>
<td><strong>오늘의 미션</strong></td>
<td>큐레이터 픽, 관리자가 미리 지정</td>
<td>모든 유저 공통</td>
</tr>
<tr>
<td><strong>개인화 미션</strong></td>
<td>온보딩 데이터 기반 추천</td>
<td>유저별 맞춤</td>
</tr>
</tbody></table>
<h3 id="12-핵심-설계-원칙-mvp">1.2 핵심 설계 원칙 (MVP)</h3>
<ul>
<li><strong>오늘의 미션</strong>: 관리자가 절기별 15일치 사전 지정 (수동 큐레이션)</li>
<li><strong>개인화 미션</strong>: 태그 기반 매칭 (45개 미션 풀)</li>
<li><strong>LLM</strong>: 미션 생성 보조 (LLM이 리스트 생성 → 관리자가 선별)</li>
<li><strong>날씨 API</strong>: MVP 제외 → 2차 고도화</li>
</ul>
<hr>
<h2 id="2-전체-아키텍처">2. 전체 아키텍처</h2>
<h3 id="21-mvp-아키텍처">2.1 MVP 아키텍처</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│                        ┌─────────────────────┐                              │
│                        │      Client App     │                              │
│                        │   (iOS / Android)   │                              │
│                        └──────────┬──────────┘                              │
│                                   │                                         │
│                                   ▼                                         │
│                        ┌─────────────────────┐                              │
│                        │    Load Balancer    │                              │
│                        │      (Nginx)        │                              │
│                        └──────────┬──────────┘                              │
│                                   │                                         │
│                 ┌─────────────────┼─────────────────┐                       │
│                 ▼                 ▼                 ▼                       │
│       ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                  │
│       │  API Server  │  │  API Server  │  │ Admin Server │                  │
│       │  (Spring)    │  │  (Spring)    │  │  (Spring)    │                  │
│       └───────┬──────┘  └───────┬──────┘  └───────┬──────┘                  │
│               │                 │                 │                         │
│               └─────────────────┼─────────────────┘                         │
│                                 │                                           │
│         ┌───────────────────────┼───────────────────────┐                   │
│         ▼                       ▼                       ▼                   │
│  ┌─────────────┐        ┌─────────────┐        ┌──────────────┐             │
│  │    MySQL    │        │    Redis    │        │   LLM API    │             │
│  │  (Main DB)  │        │  (Cache)    │        │ (Claude/GPT) │             │
│  └─────────────┘        └─────────────┘        └──────────────┘             │
│                                                       ↑                     │
│                                                       │                     │
│                                              Admin Server에서 호출           │
│                                              (미션 생성 보조용)              │
│                                                                             │
│  ┌──────────────────────────────────────────────────────────────┐           │
│  │                      Scheduler (Batch)                       │           │
│  │                                                              │           │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          │           │
│  │  │ 오늘의미션  │  │ 절기 체크   │  │ 알림 발송   │          │           │
│  │  │ 활성화 Job  │  │ Job        │  │ Job        │          │           │
│  │  └─────────────┘  └─────────────┘  └─────────────┘          │           │
│  └──────────────────────────────────────────────────────────────┘           │
│                                 │                                           │
│                                 ▼                                           │
│                        ┌──────────────┐                                     │
│                        │  FCM / APNs  │                                     │
│                        │ (Push 알림)  │                                     │
│                        └──────────────┘                                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘</code></pre><h3 id="22-2차-고도화-아키텍처-추가-요소">2.2 2차 고도화 아키텍처 (추가 요소)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                     2차 고도화 시 추가                           │
│                                                                 │
│   ┌─────────────┐                                               │
│   │ Weather API │                                               │
│   │ (날씨 조회) │                                               │
│   └──────┬──────┘                                               │
│          │                                                      │
│          ▼                                                      │
│   ┌─────────────────────────────────────────┐                  │
│   │            Scheduler 추가 Job            │                  │
│   │  - WeatherCheckJob (06:00)              │                  │
│   │  - 날씨 기반 미션 자동 교체              │                  │
│   └─────────────────────────────────────────┘                  │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="3-핵심-프로세스">3. 핵심 프로세스</h2>
<h3 id="31-phase-1-미션-사전-준비-관리자-작업">3.1 Phase 1: 미션 사전 준비 (관리자 작업)</h3>
<p><strong>LLM으로 미션 리스트 생성 → 관리자가 선별/검수 → DB 저장</strong></p>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                      Admin Server Flow                          │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                 1. LLM으로 미션 리스트 생성               │   │
│  │                                                          │   │
│  │   관리자가 절기 + 조건 입력                               │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │ &quot;청명 절기에 맞는 미션 50개 생성해줘&quot;            │   │   │
│  │   │ &quot;조건: 날씨에 덜 민감하고, 감성적인 미션&quot;        │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  │                          │                               │   │
│  │                          ▼                               │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │              LLM API 호출                        │   │   │
│  │   │         (Claude / GPT 등)                       │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  │                          │                               │   │
│  │                          ▼                               │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │           미션 후보 리스트 50개 반환             │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                  2. 관리자 선별/검수                     │   │
│  │                                                          │   │
│  │   LLM이 생성한 50개 중 45개 선별                         │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │ [✓] 맑은 하늘 아래 심호흡하기                    │   │   │
│  │   │ [✓] 봄 향기 느끼기                              │   │   │
│  │   │ [✗] 벚꽃 축제 가기        ← 날씨 민감, 제외     │   │   │
│  │   │ [✓] 제철 음식 먹어보기                          │   │   │
│  │   │ ...                                             │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  │                                                          │   │
│  │   - 부적절한 미션 삭제                                   │   │
│  │   - 미션 문구 수정                                       │   │
│  │   - 태그 부여 (장소/활동/강도)                           │   │
│  └─────────────────────────────────────────────────────────┘   │
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │               3. 오늘의 미션 스케줄 지정                 │   │
│  │                                                          │   │
│  │   선별된 45개 중 15개를 오늘의 미션으로 지정             │   │
│  │   ┌──────┬─────────────────────────┐                    │   │
│  │   │ 날짜 │ 오늘의 미션              │                    │   │
│  │   ├──────┼─────────────────────────┤                    │   │
│  │   │ 4/4  │ 맑은 하늘 아래 심호흡    │                    │   │
│  │   │ 4/5  │ 봄 향기 느끼기           │                    │   │
│  │   │ ...  │ ...                     │                    │   │
│  │   └──────┴─────────────────────────┘                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                     4. DB 저장                           │   │
│  │                                                          │   │
│  │   - Mission 테이블: 미션 풀 (45개)                       │   │
│  │   - MissionTag 테이블: 태그 정보                         │   │
│  │   - DailyMissionSchedule 테이블: 오늘의 미션 스케줄      │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘</code></pre><p><strong>미션 선별 가이드라인:</strong></p>
<table>
<thead>
<tr>
<th>원칙</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>날씨 무관</td>
<td>실내/실외 모두 가능</td>
<td>&quot;봄 향기 느끼기&quot; (창문 열어도 OK)</td>
</tr>
<tr>
<td>감성 중심</td>
<td>행위보다 감각/경험</td>
<td>&quot;초록색 찾아 사진 찍기&quot;</td>
</tr>
<tr>
<td>재사용 가능</td>
<td>매년 사용 가능한 형태</td>
<td>&quot;제철 음식 먹어보기&quot;</td>
</tr>
</tbody></table>
<hr>
<h3 id="32-phase-2-오늘의-미션-활성화-매일-0000">3.2 Phase 2: 오늘의 미션 활성화 (매일 00:00)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                  Daily Mission Scheduler Flow                   │
│                                                                 │
│  ┌───────────────┐                                              │
│  │ 매일 00:00    │                                              │
│  │ DailyMission  │                                              │
│  │ ActivateJob   │                                              │
│  └───────┬───────┘                                              │
│          │                                                      │
│          ▼                                                      │
│  ┌───────────────────────────────────────────┐                  │
│  │ 1. DailyMissionSchedule에서 오늘 날짜 조회 │                  │
│  │ 2. 해당 미션을 &quot;오늘의 미션&quot;으로 활성화    │                  │
│  │ 3. Redis 캐시 갱신                        │                  │
│  └───────────────────────────────────────────┘                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h3 id="33-phase-3-절기-변경-감지-및-알림-발송">3.3 Phase 3: 절기 변경 감지 및 알림 발송</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                   Solar Term Scheduler Flow                     │
│                                                                 │
│  ┌───────────────┐                                              │
│  │ 매일 00:00    │                                              │
│  │ SolarTermCheck│                                              │
│  │ Job           │                                              │
│  └───────┬───────┘                                              │
│          │                                                      │
│          ▼                                                      │
│  ┌───────────────┐     NO                                       │
│  │ 오늘 = 절기   │ ──────────→ 종료                             │
│  │ 시작일?      │                                               │
│  └───────┬───────┘                                              │
│          │ YES                                                  │
│          ▼                                                      │
│  ┌───────────────────────────────────────────┐                  │
│  │ 절기 변경 알림 발송                        │                  │
│  │                                           │                  │
│  │ &quot;청명이 시작됐어요! 새로운 미션을 확인하세요&quot; │                  │
│  └───────────────────────────────────────────┘                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h3 id="34-phase-4-개인화-미션-조회-실시간">3.4 Phase 4: 개인화 미션 조회 (실시간)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│              Personalized Mission Recommendation                │
│                                                                 │
│  ┌─────────┐         ┌─────────────────────────────────────┐   │
│  │ Client  │ ──────→ │           API Server                │   │
│  │ 요청    │ GET     │                                     │   │
│  │         │/missions│  1. 사용자 온보딩 태그 조회          │   │
│  └─────────┘         │  2. 현재 절기 미션 풀 조회           │   │
│                      │  3. 태그 매칭 점수 계산              │   │
│                      │  4. 이미 완료/스킵한 미션 제외       │   │
│                      │  5. 상위 N개 반환                   │   │
│                      └─────────────────────────────────────┘   │
│                                                                 │
│  [태그 매칭 로직]                                               │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ score = 0                                               │   │
│  │ if (user.location == mission.location) score += 2       │   │
│  │ if (user.activity == mission.activity) score += 3       │   │
│  │ if (user.intensity == mission.intensity) score += 1     │   │
│  │ return missions.sortByScore().limit(N)                  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="4-미션-제공-구조">4. 미션 제공 구조</h2>
<h3 id="41-화면별-미션-구성">4.1 화면별 미션 구성</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                        메인 화면                                │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │              오늘의 미션 (큐레이터 픽)                    │   │
│  │                                                          │   │
│  │   &quot;맑은 하늘 아래 심호흡하기&quot;                             │   │
│  │                                                          │   │
│  │   - 모든 유저 동일                                       │   │
│  │   - 관리자가 미리 지정한 미션                            │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │              개인화 미션 (A/B 테스트)                     │   │
│  │                                                          │   │
│  │   [A안] 리스트로 여러 개                                  │   │
│  │   ┌─────────────────────────────────────┐               │   │
│  │   │ - 봄나물 비빔밥 만들기               │               │   │
│  │   │ - 창문 열고 환기하기                 │               │   │
│  │   │ - 산책하며 새소리 듣기               │               │   │
│  │   └─────────────────────────────────────┘               │   │
│  │                                                          │   │
│  │   [B안] 1개 + 새로고침                                   │   │
│  │   ┌─────────────────────────────────────┐               │   │
│  │   │ 봄나물 비빔밥 만들기          🔄    │               │   │
│  │   └─────────────────────────────────────┘               │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="42-미션-데이터-흐름">4.2 미션 데이터 흐름</h3>
<pre><code>┌───────────────────────────────────────────────────────────────────────────┐
│                           미션 데이터 흐름                                 │
│                                                                           │
│  [사전 준비]                              [실시간]                         │
│                                                                           │
│  ┌──────────┐                                                            │
│  │  LLM     │                                                            │
│  │  API     │                                                            │
│  └────┬─────┘                                                            │
│       │ 미션 후보 50개 생성                                               │
│       ▼                                                                   │
│  ┌──────────┐                                                            │
│  │ 관리자   │                                                            │
│  │ 선별/검수│                                                            │
│  └────┬─────┘                                                            │
│       │ 45개 선정 + 태그 부여                                             │
│       ▼                                                                   │
│  ┌──────────┐                                                            │
│  │ Mission  │                                                            │
│  │ 테이블   │                                                            │
│  │ (풀)     │                                                            │
│  └────┬─────┘                                                            │
│       │                                                                   │
│       ├────────────────┐                                                  │
│       │                ▼                                                  │
│       │         ┌───────────────┐                                        │
│       │         │ DailyMission  │                                        │
│       │         │ Schedule      │                                        │
│       │         │ (15일치 지정) │                                        │
│       │         └───────┬───────┘                                        │
│       │                 │                                                 │
│       │                 │ 매일 00:00                                      │
│       │                 ▼                                                 │
│       │         ┌───────────────┐      ┌──────────┐                      │
│       │         │ 오늘의 미션   │ ───→ │ 모든유저 │                      │
│       │         │ 활성화        │      └──────────┘                      │
│       │         └───────────────┘                                        │
│       │                                                                   │
│       │ 사용자 요청 시                                                    │
│       ▼                                                                   │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────┐                    │
│  │ 태그 매칭    │ ─→ │ 개인화 미션  │ ─→ │ 해당유저 │                    │
│  │ (실시간)     │    │ 추천         │    └──────────┘                    │
│  └──────────────┘    └──────────────┘                                    │
│                                                                           │
└───────────────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="5-서버-구성">5. 서버 구성</h2>
<h3 id="51-api-server">5.1 API Server</h3>
<table>
<thead>
<tr>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>사용자 인증</td>
<td>JWT 기반 인증/인가</td>
</tr>
<tr>
<td>오늘의 미션 조회</td>
<td>당일 활성화된 미션 반환</td>
</tr>
<tr>
<td>개인화 미션 조회</td>
<td>태그 매칭 기반 실시간 추천</td>
</tr>
<tr>
<td>미션 완료/스킵</td>
<td>상태 업데이트</td>
</tr>
<tr>
<td>알림 조회</td>
<td>인앱 알림 목록</td>
</tr>
</tbody></table>
<h3 id="52-admin-server">5.2 Admin Server</h3>
<table>
<thead>
<tr>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>LLM 연동</strong></td>
<td>미션 후보 리스트 생성 요청</td>
</tr>
<tr>
<td>미션 관리</td>
<td>미션 선별/검수, CRUD</td>
</tr>
<tr>
<td>태그 관리</td>
<td>미션별 태그 설정</td>
</tr>
<tr>
<td>스케줄 관리</td>
<td>오늘의 미션 일정 지정</td>
</tr>
<tr>
<td>통계 조회</td>
<td>미션 완료율 등</td>
</tr>
</tbody></table>
<h3 id="53-scheduler-batch-server">5.3 Scheduler (Batch Server)</h3>
<table>
<thead>
<tr>
<th>Job</th>
<th>실행 시점</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>DailyMissionActivateJob</strong></td>
<td>매일 00:00</td>
<td>오늘의 미션 활성화</td>
</tr>
<tr>
<td><strong>SolarTermCheckJob</strong></td>
<td>매일 00:00</td>
<td>절기 변경 여부 확인</td>
</tr>
<tr>
<td><strong>NotificationJob</strong></td>
<td>절기 변경 시</td>
<td>절기 시작 알림 발송</td>
</tr>
<tr>
<td><strong>MissionReminderJob</strong></td>
<td>매일 09:00</td>
<td>미완료 미션 리마인드 (선택)</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-데이터-모델">6. 데이터 모델</h2>
<h3 id="61-핵심-테이블">6.1 핵심 테이블</h3>
<pre><code class="language-sql">-- 미션 풀
CREATE TABLE mission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    solar_term VARCHAR(20),          -- 절기 (청명, 곡우 등)
    season VARCHAR(20),              -- 계절 (봄, 여름 등)
    status ENUM(&#39;DRAFT&#39;, &#39;ACTIVE&#39;) DEFAULT &#39;DRAFT&#39;,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 미션 태그 (개인화 매칭용)
CREATE TABLE mission_tag (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    mission_id BIGINT NOT NULL,
    tag_category VARCHAR(50),        -- location, activity, intensity
    tag_value VARCHAR(50),           -- indoor/outdoor, cooking/exercise, light/active
    FOREIGN KEY (mission_id) REFERENCES mission(id)
);

-- 오늘의 미션 스케줄 (관리자 지정)
CREATE TABLE daily_mission_schedule (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    solar_term VARCHAR(20) NOT NULL,
    target_date DATE NOT NULL,       -- 노출 날짜
    mission_id BIGINT NOT NULL,
    created_by BIGINT,               -- 지정한 관리자
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (mission_id) REFERENCES mission(id),
    UNIQUE KEY (target_date)
);

-- 사용자 온보딩 프로필
CREATE TABLE user_profile (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    location_pref VARCHAR(20),       -- indoor / outdoor / both
    activity_pref VARCHAR(50),       -- cooking / exercise / culture / rest
    intensity_pref VARCHAR(20),      -- light / moderate / active
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES user(id)
);

-- 사용자 미션 이력
CREATE TABLE user_mission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    mission_id BIGINT NOT NULL,
    mission_type ENUM(&#39;DAILY&#39;, &#39;PERSONALIZED&#39;),
    status ENUM(&#39;RECOMMENDED&#39;, &#39;COMPLETED&#39;, &#39;SKIPPED&#39;),
    recommended_at TIMESTAMP,
    completed_at TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES user(id),
    FOREIGN KEY (mission_id) REFERENCES mission(id)
);

-- 절기 정보
CREATE TABLE solar_term (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20) NOT NULL,       -- 청명, 곡우 등
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    season VARCHAR(20),              -- 봄, 여름 등
    year INT NOT NULL
);</code></pre>
<h3 id="62-태그-체계">6.2 태그 체계</h3>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>location</strong></td>
<td>indoor</td>
<td>실내</td>
</tr>
<tr>
<td></td>
<td>outdoor</td>
<td>실외</td>
</tr>
<tr>
<td></td>
<td>both</td>
<td>무관</td>
</tr>
<tr>
<td><strong>activity</strong></td>
<td>food</td>
<td>음식/요리</td>
</tr>
<tr>
<td></td>
<td>nature</td>
<td>자연/산책</td>
</tr>
<tr>
<td></td>
<td>culture</td>
<td>문화/감상</td>
</tr>
<tr>
<td></td>
<td>exercise</td>
<td>운동</td>
</tr>
<tr>
<td></td>
<td>rest</td>
<td>휴식</td>
</tr>
<tr>
<td><strong>intensity</strong></td>
<td>light</td>
<td>가벼움</td>
</tr>
<tr>
<td></td>
<td>moderate</td>
<td>보통</td>
</tr>
<tr>
<td></td>
<td>active</td>
<td>활발</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-api-목록">7. API 목록</h2>
<h3 id="71-client-api">7.1 Client API</h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>GET</td>
<td><code>/api/missions/today</code></td>
<td>오늘의 미션 조회</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/missions/personalized</code></td>
<td>개인화 미션 목록</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/missions/personalized/refresh</code></td>
<td>개인화 미션 새로고침 (B안)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/missions/{id}/complete</code></td>
<td>미션 완료</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/missions/{id}/skip</code></td>
<td>미션 스킵</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/solar-terms/current</code></td>
<td>현재 절기 정보</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/notifications</code></td>
<td>알림 목록</td>
</tr>
</tbody></table>
<h3 id="72-admin-api">7.2 Admin API</h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>POST</td>
<td><code>/admin/missions/generate</code></td>
<td>LLM으로 미션 후보 생성</td>
</tr>
<tr>
<td>GET</td>
<td><code>/admin/missions/candidates</code></td>
<td>생성된 미션 후보 목록</td>
</tr>
<tr>
<td>POST</td>
<td><code>/admin/missions/approve</code></td>
<td>미션 선별/승인</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/admin/missions/{id}</code></td>
<td>미션 수정</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/admin/missions/{id}</code></td>
<td>미션 삭제</td>
</tr>
<tr>
<td>POST</td>
<td><code>/admin/daily-schedule</code></td>
<td>오늘의 미션 스케줄 지정</td>
</tr>
</tbody></table>
<hr>
<h2 id="8-타임라인">8. 타임라인</h2>
<pre><code>┌───────────────────────────────────────────────────────────────────────────┐
│                              운영 타임라인                                 │
│                                                                           │
│  [절기 시작 전 - 관리자 작업]                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐ │
│  │ 1. Admin에서 LLM 호출 → 미션 후보 50개 생성                         │ │
│  │ 2. 관리자가 선별/검수 → 45개 확정                                   │ │
│  │ 3. 태그 부여 (장소/활동/강도)                                       │ │
│  │ 4. 오늘의 미션 15일치 스케줄 지정                                   │ │
│  │                                                                     │ │
│  │ ※ 1년치 한번에 작업 가능 (24절기 × 45개 = 1,080개)                 │ │
│  │ ※ 이후 매년 재사용 (필요시 일부 수정)                               │ │
│  └─────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
│  [매일 00:00]                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐ │
│  │ 1. 오늘의 미션 활성화 (DailyMissionActivateJob)                     │ │
│  │ 2. 절기 변경 체크 (SolarTermCheckJob)                               │ │
│  │    → 변경 시 푸시 알림 발송                                         │ │
│  └─────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
│  [사용자 앱 실행 시]                                                      │
│  ┌─────────────────────────────────────────────────────────────────────┐ │
│  │ 1. 오늘의 미션 조회 (캐시)                                          │ │
│  │ 2. 개인화 미션 조회 (실시간 태그 매칭)                               │ │
│  └─────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
└───────────────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="9-기술-스택">9. 기술 스택</h2>
<table>
<thead>
<tr>
<th>영역</th>
<th>기술</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><strong>API Server</strong></td>
<td>Spring Boot 3.x</td>
<td>REST API</td>
</tr>
<tr>
<td><strong>Admin Server</strong></td>
<td>Spring Boot 3.x</td>
<td>관리자 기능</td>
</tr>
<tr>
<td><strong>Scheduler</strong></td>
<td>Spring Scheduler</td>
<td>배치 처리</td>
</tr>
<tr>
<td><strong>Database</strong></td>
<td>MySQL 8.0</td>
<td>메인 데이터 저장</td>
</tr>
<tr>
<td><strong>Cache</strong></td>
<td>Redis</td>
<td>오늘의 미션 캐싱, 세션</td>
</tr>
<tr>
<td><strong>LLM</strong></td>
<td>Claude API / OpenAI</td>
<td>미션 생성 보조 (Admin에서 사용)</td>
</tr>
<tr>
<td><strong>Push</strong></td>
<td>FCM / APNs</td>
<td>모바일 푸시 알림</td>
</tr>
<tr>
<td><strong>Infra</strong></td>
<td>AWS (EC2, RDS, ElastiCache)</td>
<td>클라우드</td>
</tr>
<tr>
<td><strong>CI/CD</strong></td>
<td>GitHub Actions + Docker</td>
<td>배포 자동화</td>
</tr>
<tr>
<td><strong>Monitoring</strong></td>
<td>Grafana + Prometheus + Loki</td>
<td>모니터링</td>
</tr>
</tbody></table>
<hr>
<h2 id="10-장애-대응">10. 장애 대응</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>대응 방안</th>
</tr>
</thead>
<tbody><tr>
<td><strong>스케줄러 장애</strong></td>
<td>오늘의 미션 활성화 안 됨 → 수동 트리거 API 제공</td>
</tr>
<tr>
<td><strong>오늘의 미션 없음</strong></td>
<td>스케줄 미지정 → 전날 미션 유지 or 기본 미션</td>
</tr>
<tr>
<td><strong>LLM API 장애</strong></td>
<td>미션 생성 불가 → 이전 절기 미션 복사 후 수정</td>
</tr>
<tr>
<td><strong>FCM 장애</strong></td>
<td>실패 건 별도 저장 → 재발송 배치</td>
</tr>
<tr>
<td><strong>DB 장애</strong></td>
<td>Read Replica 활용, Redis 캐시</td>
</tr>
</tbody></table>
<hr>
<h2 id="11-2차-고도화-계획">11. 2차 고도화 계획</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
<th>우선순위</th>
</tr>
</thead>
<tbody><tr>
<td><strong>날씨 API 연동</strong></td>
<td>비 오면 실내 미션으로 자동 교체</td>
<td>높음</td>
</tr>
<tr>
<td><strong>A/B 테스트 결과 반영</strong></td>
<td>개인화 미션 노출 방식 확정</td>
<td>높음</td>
</tr>
<tr>
<td><strong>미션 이력 기반 추천</strong></td>
<td>완료/스킵 패턴 학습</td>
<td>낮음</td>
</tr>
</tbody></table>
<h3 id="111-날씨-연동-시-변경-사항">11.1 날씨 연동 시 변경 사항</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                      날씨 연동 추가 시                           │
│                                                                 │
│  [테이블 추가]                                                   │
│  - mission_tag에 weather 카테고리 추가 (sunny/rainy/all)        │
│  - daily_mission_schedule에 fallback_mission_id 추가           │
│                                                                 │
│  [스케줄러 추가]                                                 │
│  - WeatherCheckJob (06:00)                                     │
│    → 날씨 API 조회 → Redis 캐싱                                 │
│    → 비 예보 시 오늘의 미션 자동 교체                            │
│                                                                 │
│  [개인화 미션 로직 수정]                                         │
│  - 태그 매칭 시 날씨 태그도 고려                                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="12-결정된-사항">12. 결정된 사항</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>결정</th>
</tr>
</thead>
<tbody><tr>
<td>오늘의 미션 방식</td>
<td>LLM 생성 → 관리자 선별 (수동 큐레이션)</td>
</tr>
<tr>
<td>LLM 활용</td>
<td>MVP에서 사용 (미션 생성 보조)</td>
</tr>
<tr>
<td>날씨 API</td>
<td>MVP 제외 → 2차</td>
</tr>
<tr>
<td>개인화 미션 노출</td>
<td>A/B 테스트로 결정</td>
</tr>
<tr>
<td>미션 풀 크기</td>
<td>절기당 45개 (LLM 50개 생성 → 45개 선별)</td>
</tr>
</tbody></table>
<hr>
<h2 id="13-미결-사항-tbd">13. 미결 사항 (TBD)</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>선택지</th>
<th>현재 상태</th>
</tr>
</thead>
<tbody><tr>
<td>예상 사용자 수</td>
<td>1천 / 1만 / 10만+</td>
<td>미정</td>
</tr>
<tr>
<td>알림 발송 시점</td>
<td>오전 / 점심 / 사용자 설정</td>
<td>미정</td>
</tr>
<tr>
<td>개인화 미션 개수</td>
<td>A안 3~5개 / B안 1개</td>
<td>A/B 테스트</td>
</tr>
<tr>
<td>A/B 테스트 비율</td>
<td>50:50 / 70:30</td>
<td>미정</td>
</tr>
<tr>
<td>LLM 서비스 선택</td>
<td>Claude / GPT / 기타</td>
<td>미정</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CI/CD] 무중단 배포로 전환하기]]></title>
            <link>https://velog.io/@heeun_98/CICD-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/CICD-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 25 Mar 2026 16:07:27 GMT</pubDate>
            <description><![CDATA[<h3 id="다운-타임이-존재">다운 타임이 존재</h3>
<p>기존 workflow는 기존 도커 컨테이너를 중단 후 ECR로 부터 pull 을 받아서 재실행하는 구조였다. workflow 는 다음과 같다.</p>
<pre><code class="language-shell">
  name: Deploy to EC2                                                                                                                            

  on:                                                                                                                                            
    push:                                                                                                                                        
      branches:                                                                                                                                  
        - master                                                                                                                                 
  jobs:                                                                                                                                          
    Deploy:                                                                                                                                      
      runs-on: ubuntu-latest                                                                                                                     
      steps:                                                                                                                                     
        - name: Github Repository 파일 불러오기                                                                                                  
          uses: actions/checkout@v4                                                                                                              

        - name: JDK 17버전 설치                                                                                                                  
          uses: actions/setup-java@v4                                                                                                            
          with:                                                                                                                                  
            distribution: temurin                                                                                                                
            java-version: 17                                                                                                                     

        - name: Grant execute permission for gradlew                                                                                             
          run: chmod +x ./gradlew                                                                                                                

        - name: 테스트 및 빌드하기                                                                                                               
          run: ./gradlew clean build                                                                                                             

        - name: AWS Resource 에 접근할 수 있게 AWS credentials 설정                                                                              
          uses: aws-actions/configure-aws-credentials@v4                                                                                         
          with:                                                                                                                                  
            aws-region: ap-northeast-2                                                                                                           
            aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}                                                                                  
            aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}                                                                          

        - name: ECR에 로그인                                                                                                                     
          id: login-ecr                                                                                                                          
          uses: aws-actions/amazon-ecr-login@v2                                                                                                  

        - name: Docker 이미지 생성                                                                                                               
          run: docker build -t meeteam-server .                                                                                                  

        - name: Docker 이미지에 Tag 붙이기                                                                                                       
          run: docker tag meeteam-server ${{ steps.login-ecr.outputs.registry }}/meeteam-server:latest                                           

        - name: ECR에 Docker 이미지 Push                                                                                                         
          run: docker push ${{ steps.login-ecr.outputs.registry }}/meeteam-server:latest                                                         

        - name: SSH 로 EC2 에 접속하기                                                                                                           
          uses: appleboy/ssh-action@v1.0.3                                                                                                       
          with:                                                                                                                                  
            host: ${{ secrets.EC2_HOST }}                                                                                                        
            username: ${{ secrets.EC2_USERNAME }}                                                                                                
            key: ${{ secrets.EC2_PRIVATE_KEY }}                                                                                                  
            script_stop: true                                                                                                                    
            script: |                                                                                                                            
              set -e                                                                                                                             
              cd /home/ubuntu/meeteam                                                                                                            
              docker stop meeteam-server || true                                                                                                 
              docker rm meeteam-server || true                                                                                                   
              docker compose pull meeteam-server                                                                                                 
              docker compose up -d meeteam-server 


</code></pre>
<p> <code>docker stop meeteam-server || true</code>
 여기서 서비스가 중단되고
<code>docker compose up -d meeteam-server</code>
 에서 서비스가 다시 시작되는 구조이다.</p>
<p> 해당 타임에는 중단타임이 존재하여 사용자는 서비스 이용을 못하는 구조였다.
 중단타임을 측정해보니 대략 5분 정도의 시간 동안은 서버에 내려가 있다.</p>
<h3 id="blue-green-무중단-배포로-전환">Blue/ Green 무중단 배포로 전환</h3>
 <p align="center">
  <img src="https://velog.velcdn.com/images/heeun_98/post/3a985a17-c1db-4db5-998a-9999cb98ab7f/image.png" width="70%">
</p>



<p> 무중단 배포 방식에는 여러 방식이 존재하지만, 미팀에서는 단일 인스턴스를 사용하므로
 Blue / Green 배포를 통해 무중단 배포로 전환을 하기로 했다.</p>
<p> 배포 전에는 443포트에 바인딩된 Nginx 프로세스와 8080 포트에 바인딩된 톰캣 프로세스가 실행중이다.
 Nginx로 들어온 요청은 8080 포트로 포트포워딩 한다. 배포가 시작되고, 새로운 톰캣 프로세스를 실행하고 
 8081로 바인딩한다. 이후 Nginx 는 포트포워딩을 8080 포트에서 8081 포트로 전환한다.</p>
<p> Nginx 가 포트전환을 위해서는 설정 변경이 필요하다. 설정 변경을 위해서 Nginx 가 Reload를 해야한다.
 Reload는 ReStart 와 다르게 설정만 다시 읽는 과정으로 서비스는 끊기지 않고 설정만 바뀐다.</p>
<p> Reload 하는 과정에서는 프로세스 내부에서</p>
<p> 기존 worker ---- 요청 처리 중
        ↓
새 설정 로드
        ↓
새 worker 생성
        ↓
기존 worker 종료 (요청 끝나면)</p>
<p>다음과 같은 과정이 일어난다. 여기서 worker 교체 순간이 완전 0ms 는 아니다. 즉 아주 짧은 타이밍에 실패가 발생할 수 있다. 아주 찰나에 순간이지만 다운 타임이 얼마나 발생하고, 사용자의 요청이 얼마나 실패하는지 확인하기 위해 테스트를 진행해 보았다.
테스트는 Jmeter 를 이용하여 300개의 동시 요청을 배포 전부터 배포 후까지 무한히 반복했다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/heeun_98/post/94efd2d8-eb26-438e-95cb-3c615f78e2bd/image.png" width="500">
</p>


<p>빨간색 점은 성공한 요청이고 초록색은 실패한 요청이다.
아주 짧은 찰나의 순간에 하나의 요청만 실패했다. 이는 사용자 경험에 크게 영향을 미치지 않을것이라고 생각했다.
단 하나의 요청만 실패한 요청이였기에, 언제 사용자의 요청을 실패하는지 찾아보았지만, 너무 많은 요청이 존재해서 찾지 못했다.</p>
<h3 id="추가적인-고민">추가적인 고민</h3>
<p>프로세스가 전환되는 과정에서 새로운 톰캣 프로세스가 올라가고 기존 프로세스는 종료 될때, 순간적으로 N개 의 톰캣이 올라가 있다.
여기서 DB 의 Connection 이 부족해 새로운 프로세스가 올라갈때
커넥션풀을 생성하지 못할수도 있을것이다.</p>
<p>예를들어, DB의 Max connection 이 40 일때, 각각의 커넥션 풀의 기본값이 30이면 2개의 프로세스가 올라가 있는 동안, 일시적인 순간에는 60개의 커넥션이 필요할텐데, 그럼 30 * 2 &lt; 50 이므로 새로운 프로세스가 커넥션 풀 생성에 실패할수도 있을것이다.
미팀 프로젝트에서는 minimum pool size 가 크지 않고, 배포시에 2개의 프로세스 밖에 존재하지 않으므로 문제가 없을것이지만, 많은 프로세스가 올라갈 경우에는
스레드풀의 개수또한 신경써야할것이라고 생각한다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Virtaul Thread 알아보기]]></title>
            <link>https://velog.io/@heeun_98/Virtaul-Thread-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/Virtaul-Thread-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Wed, 25 Feb 2026 07:45:31 GMT</pubDate>
            <description><![CDATA[<h1 id="virtual-thread-톺아보기">Virtual Thread 톺아보기</h1>
<blockquote>
<p>Java 21에서 도입된 Virtual Thread의 구조와 동작 원리를 이해하기 쉽게 정리한 문서입니다.</p>
</blockquote>
<h2 id="목차">목차</h2>
<ol>
<li><a href="#1-%EB%A8%BC%EC%A0%80-%EC%95%8C%EC%95%84%EC%95%BC-%ED%95%A0-%EC%9A%A9%EC%96%B4">먼저 알아야 할 용어</a></li>
<li><a href="#2-%EA%B8%B0%EC%A1%B4-thread-%EB%AA%A8%EB%8D%B8%EC%9D%98-%EB%AC%B8%EC%A0%9C%EC%A0%90">기존 Thread 모델의 문제점</a></li>
<li><a href="#3-virtual-thread-%EA%B5%AC%EC%A1%B0">Virtual Thread 구조</a></li>
<li><a href="#4-virtual-thread-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC">Virtual Thread 동작 원리</a></li>
<li><a href="#5-parkunpark-%EB%8F%99%EC%9E%91-%EC%83%81%EC%84%B8">park/unpark 동작 상세</a></li>
<li><a href="#6-%EA%B8%B0%EC%A1%B4-thread-%EB%AA%A8%EB%8D%B8%EA%B3%BC%EC%9D%98-%ED%98%B8%ED%99%98%EC%84%B1">기존 Thread 모델과의 호환성</a></li>
<li><a href="#7-%ED%95%B5%EC%8B%AC-%EC%9A%94%EC%95%BD">핵심 요약</a></li>
</ol>
<hr>
<h2 id="1-먼저-알아야-할-용어">1. 먼저 알아야 할 용어</h2>
<h3 id="11-platform-thread-플랫폼-스레드">1.1 Platform Thread (플랫폼 스레드)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Platform Thread = 기존의 Java Thread                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  • OS 스레드와 1:1로 매핑되는 &quot;무거운&quot; 스레드                   │
│  • 생성 비용: 약 1MB 스택 메모리                                │
│  • OS 커널이 스케줄링 담당                                      │
│  • 컨텍스트 스위칭 = OS 레벨에서 발생 (비용 높음)               │
│                                                                 │
│  ┌─────────────┐         ┌─────────────┐                        │
│  │ Java Thread │ ──1:1── │ OS Thread   │                        │
│  └─────────────┘         └─────────────┘                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="12-virtual-thread-가상-스레드">1.2 Virtual Thread (가상 스레드)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Virtual Thread = JVM이 관리하는 &quot;경량&quot; 스레드                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  • OS 스레드와 직접 매핑되지 않음                               │
│  • 생성 비용: 수백 바이트 ~ 수 KB                               │
│  • JVM이 스케줄링 담당                                          │
│  • 컨텍스트 스위칭 = JVM 레벨에서 발생 (비용 낮음)              │
│                                                                 │
│  ┌─────────────┐                                                │
│  │ Virtual     │ ──┐                                            │
│  │ Thread 1    │   │      ┌─────────────┐     ┌─────────────┐   │
│  ├─────────────┤   ├──N:1─│ Platform    │─1:1─│ OS Thread   │   │
│  │ Virtual     │   │      │ Thread      │     └─────────────┘   │
│  │ Thread 2    │ ──┤      └─────────────┘                       │
│  ├─────────────┤   │       (Carrier)                            │
│  │ Virtual     │   │                                            │
│  │ Thread 3    │ ──┘                                            │
│  └─────────────┘                                                │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="13-carrier-thread-캐리어-스레드">1.3 Carrier Thread (캐리어 스레드)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Carrier Thread = Virtual Thread를 &quot;태워서&quot; 실행하는 스레드     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  • 실제로는 Platform Thread                                     │
│  • Virtual Thread의 작업을 대신 실행해줌                        │
│  • &quot;캐리어(운반자)&quot;라는 이름의 의미:                            │
│    - Virtual Thread를 &quot;태워서&quot; CPU까지 운반                     │
│    - 실제 OS 스레드 위에서 실행되게 해줌                        │
│                                                                 │
│  비유:                                                          │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Virtual Thread = 승객 (가볍고 많을 수 있음)                ││
│  │  Carrier Thread = 버스 (무겁지만 승객을 태워서 이동)        ││
│  │  OS Thread = 도로 (실제 이동이 일어나는 곳)                 ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="14-forkjoinpool-스케줄러">1.4 ForkJoinPool (스케줄러)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  ForkJoinPool = Virtual Thread들의 스케줄러                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  역할:                                                          │
│  • Carrier Thread Pool을 관리                                   │
│  • Virtual Thread 작업을 Carrier Thread에 분배                  │
│  • Work Stealing 알고리즘으로 부하 분산                         │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                    ForkJoinPool                             ││
│  │  ┌──────────────┐ ┌──────────────┐ ┌──────────────┐         ││
│  │  │ Carrier      │ │ Carrier      │ │ Carrier      │         ││
│  │  │ Thread 1     │ │ Thread 2     │ │ Thread 3     │         ││
│  │  │ [workQueue]  │ │ [workQueue]  │ │ [workQueue]  │         ││
│  │  └──────────────┘ └──────────────┘ └──────────────┘         ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="15-work-queue-작업-큐">1.5 Work Queue (작업 큐)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Work Queue = 각 Carrier Thread가 가진 작업 대기열              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  • 각 Carrier Thread마다 하나씩 보유                            │
│  • Virtual Thread의 작업(runContinuation)들이 대기              │
│  • Deque(양방향 큐) 구조                                        │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Carrier Thread의 Work Queue                                ││
│  │                                                             ││
│  │  ┌────────┬────────┬────────┬────────┐                      ││
│  │  │ Task 1 │ Task 2 │ Task 3 │ Task 4 │ ← push (새 작업)     ││
│  │  └────────┴────────┴────────┴────────┘                      ││
│  │      ↑                                                      ││
│  │      pop (실행할 작업)                                      ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="16-runcontinuation-실행-컨티뉴에이션">1.6 runContinuation (실행 컨티뉴에이션)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  runContinuation = Virtual Thread의 &quot;실제 작업 내용&quot;            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  • Virtual Thread가 실행해야 할 코드 (Runnable)                 │
│  • 중단점(suspension point)에서 상태 저장 가능                  │
│  • 재개 시 저장된 지점부터 계속 실행                            │
│                                                                 │
│  비유:                                                          │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  runContinuation = 책갈피가 있는 책                         ││
│  │                                                             ││
│  │  • 읽다가 중단 → 책갈피 끼움 (상태 저장)                    ││
│  │  • 나중에 재개 → 책갈피 위치부터 계속 (상태 복원)           ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="17-work-stealing-작업-훔치기">1.7 Work Stealing (작업 훔치기)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Work Stealing = 유휴 스레드가 바쁜 스레드의 작업을 가져옴      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  상황: Carrier-1은 바쁘고, Carrier-3은 할 일이 없음             │
│                                                                 │
│  Carrier-1: [Task1, Task2, Task3, Task4]  ← 작업 많음           │
│  Carrier-2: [Task5, Task6]                                      │
│  Carrier-3: []                            ← 유휴 상태           │
│                                                                 │
│  Work Stealing 발생:                                            │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Carrier-3이 Carrier-1의 큐 뒤쪽에서 Task4를 &quot;훔쳐옴&quot;       ││
│  │                                                             ││
│  │  Carrier-1: [Task1, Task2, Task3]                           ││
│  │  Carrier-3: [Task4] ← 훔쳐온 작업 실행                      ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  효과: 모든 Carrier Thread가 균등하게 일함 → 부하 분산          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="18-park--unpark">1.8 park / unpark</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  park = 스레드를 일시 정지 (대기 상태로 전환)                   │
│  unpark = 스레드를 깨움 (실행 가능 상태로 전환)                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Platform Thread의 park/unpark:                                 │
│  • OS 커널에 요청 → OS가 스레드 스케줄링                        │
│  • 비용 높음 (시스템 콜 필요)                                   │
│                                                                 │
│  Virtual Thread의 park/unpark:                                  │
│  • JVM 내부에서 처리 → OS 개입 없음                             │
│  • 비용 낮음 (단순히 Work Queue에서 제거/추가)                  │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  park (대기):                                               ││
│  │  • Virtual Thread를 Carrier에서 분리 (unmount)              ││
│  │  • 상태를 힙 메모리에 저장                                  ││
│  │  • Carrier Thread는 다른 Virtual Thread 실행                ││
│  │                                                             ││
│  │  unpark (깨움):                                             ││
│  │  • 힙 메모리에서 상태 복원                                  ││
│  │  • Work Queue에 다시 추가                                   ││
│  │  • Carrier Thread에 재할당 (mount)                          ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="19-mount--unmount">1.9 mount / unmount</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  mount = Virtual Thread가 Carrier Thread에 &quot;탑승&quot;              │
│  unmount = Virtual Thread가 Carrier Thread에서 &quot;하차&quot;          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  mount (탑승):                                                  │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Virtual Thread가 Carrier Thread 위에서 실행 시작           ││
│  │                                                             ││
│  │  ┌──────────────┐                                           ││
│  │  │ Virtual      │ ──mount──▶ ┌──────────────┐               ││
│  │  │ Thread       │            │ Carrier      │               ││
│  │  │ (힙 메모리)  │            │ Thread       │               ││
│  │  └──────────────┘            │ (실행 중)    │               ││
│  │                              └──────────────┘               ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  unmount (하차):                                                │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  I/O 대기 등으로 Virtual Thread가 Carrier에서 분리          ││
│  │                                                             ││
│  │  ┌──────────────┐            ┌──────────────┐               ││
│  │  │ Virtual      │ ◀──unmount─│ Carrier      │               ││
│  │  │ Thread       │            │ Thread       │               ││
│  │  │ (힙에 저장)  │            │ (다른 VT 실행)│              ││
│  │  └──────────────┘            └──────────────┘               ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="2-기존-thread-모델의-문제점">2. 기존 Thread 모델의 문제점</h2>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Platform Thread의 한계                                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 생성 비용이 높음                                            │
│     • 스레드당 약 1MB 스택 메모리                               │
│     • 1000개 스레드 = 1GB 메모리                                │
│                                                                 │
│  2. OS 스레드와 1:1 매핑                                        │
│     • OS 스레드 수에 제한 있음                                  │
│     • 수천 개 이상 생성 어려움                                  │
│                                                                 │
│  3. Context Switching 비용 높음                                 │
│     • OS 커널 개입 필요 (시스템 콜)                             │
│     • 레지스터 저장/복원, 캐시 무효화                           │
│                                                                 │
│  4. I/O 대기 시 비효율                                          │
│     ┌─────────────────────────────────────────────────────────┐ │
│     │  Platform Thread가 I/O 대기 시:                         │ │
│     │  • OS 스레드가 블로킹됨                                 │ │
│     │  • 아무것도 안 하면서 리소스 점유                       │ │
│     │  • 다른 작업을 처리할 수 없음                           │ │
│     └─────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="3-virtual-thread-구조">3. Virtual Thread 구조</h2>
<h3 id="31-virtual-thread-내부-구성요소">3.1 Virtual Thread 내부 구성요소</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  VirtualThread 객체의 내부 구조                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  VirtualThread {                                                │
│      │                                                          │
│      ├── carrierThread ──────────────────────────────────────┐  │
│      │   • 실제 작업을 수행하는 Platform Thread              │  │
│      │   • Virtual Thread가 mount될 대상                     │  │
│      │   • workQueue를 보유                                  │  │
│      │                                                       │  │
│      ├── scheduler (ForkJoinPool) ───────────────────────────┤  │
│      │   • Carrier Thread Pool 관리                          │  │
│      │   • Virtual Thread 작업 스케줄링                      │  │
│      │   • Work Stealing으로 부하 분산                       │  │
│      │                                                       │  │
│      └── runContinuation ────────────────────────────────────┘  │
│          • Virtual Thread의 실제 작업 내용 (Runnable)           │
│          • 중단/재개 가능한 실행 단위                           │
│  }                                                              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="32-전체-구조-시각화">3.2 전체 구조 시각화</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                      Virtual Thread 아키텍처                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                    JVM (User Space)                       │  │
│  │                                                           │  │
│  │   ┌─────────────────────────────────────────────────────┐ │  │
│  │   │              ForkJoinPool (Scheduler)               │ │  │
│  │   │                                                     │ │  │
│  │   │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐   │ │  │
│  │   │  │ Carrier-1   │ │ Carrier-2   │ │ Carrier-3   │   │ │  │
│  │   │  │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │   │ │  │
│  │   │  │ │workQueue│ │ │ │workQueue│ │ │ │workQueue│ │   │ │  │
│  │   │  │ │[VT1,VT4]│ │ │ │[VT2,VT5]│ │ │ │[VT3]    │ │   │ │  │
│  │   │  │ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │   │ │  │
│  │   │  └──────┬──────┘ └──────┬──────┘ └──────┬──────┘   │ │  │
│  │   │         │               │               │          │ │  │
│  │   └─────────┼───────────────┼───────────────┼──────────┘ │  │
│  │             │               │               │            │  │
│  │   ┌─────────▼───────────────▼───────────────▼──────────┐ │  │
│  │   │                    힙 메모리                        │ │  │
│  │   │  ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐               │ │  │
│  │   │  │VT1 │ │VT2 │ │VT3 │ │VT4 │ │VT5 │ ... (경량)   │ │  │
│  │   │  └────┘ └────┘ └────┘ └────┘ └────┘               │ │  │
│  │   └────────────────────────────────────────────────────┘ │  │
│  │                                                           │  │
│  └───────────────────────────────────────────────────────────┘  │
│                              │                                  │
│                              │ 1:1 매핑                         │
│                              ▼                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                   OS (Kernel Space)                       │  │
│  │   ┌─────────────┐ ┌─────────────┐ ┌─────────────┐         │  │
│  │   │ OS Thread-1 │ │ OS Thread-2 │ │ OS Thread-3 │         │  │
│  │   └─────────────┘ └─────────────┘ └─────────────┘         │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="4-virtual-thread-동작-원리">4. Virtual Thread 동작 원리</h2>
<h3 id="41-실행-흐름-단계별-설명">4.1 실행 흐름 단계별 설명</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Virtual Thread 실행 흐름                                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  STEP 1: 작업 제출 (Submit)                                     │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Virtual Thread 생성 시, runContinuation이                  ││
│  │  Carrier Thread의 workQueue에 push됨                        ││
│  │                                                             ││
│  │  VirtualThread.start()                                      ││
│  │         │                                                   ││
│  │         ▼                                                   ││
│  │  scheduler.submit(runContinuation)                          ││
│  │         │                                                   ││
│  │         ▼                                                   ││
│  │  carrierThread.workQueue.push(runContinuation)              ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  STEP 2: 작업 실행 (Execute)                                    │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  ForkJoinPool이 Work Stealing 방식으로                      ││
│  │  Carrier Thread에 작업 분배                                 ││
│  │                                                             ││
│  │  Carrier Thread:                                            ││
│  │  1. workQueue에서 runContinuation pop                       ││
│  │  2. Virtual Thread를 mount (탑승)                           ││
│  │  3. runContinuation 실행                                    ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  STEP 3: I/O 발생 시 park (일시정지)                            │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  I/O, Sleep, Lock 대기 시:                                  ││
│  │                                                             ││
│  │  1. Virtual Thread가 park 호출                              ││
│  │  2. runContinuation 상태를 힙 메모리에 저장                 ││
│  │  3. workQueue에서 pop (제거)                                ││
│  │  4. Carrier Thread에서 unmount (하차)                       ││
│  │  5. Carrier Thread는 다른 Virtual Thread 실행               ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  STEP 4: I/O 완료 시 unpark (재개)                              │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  I/O 완료, Sleep 종료, Lock 획득 시:                        ││
│  │                                                             ││
│  │  1. Virtual Thread가 unpark 호출                            ││
│  │  2. scheduler가 runContinuation을 다시 스케줄링             ││
│  │  3. 어떤 Carrier Thread의 workQueue에 push                  ││
│  │  4. Carrier Thread에 mount되어 실행 재개                    ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="42-시각적-타임라인">4.2 시각적 타임라인</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Virtual Thread 생명주기 타임라인                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  시간 →                                                         │
│  ════════════════════════════════════════════════════════════   │
│                                                                 │
│  Virtual Thread:                                                │
│  ┌────────┐         ┌─────────────┐         ┌────────┐          │
│  │ 실행   │         │   WAITING   │         │ 실행   │          │
│  │(mount) │         │  (unmount)  │         │(mount) │          │
│  └───┬────┘         └──────┬──────┘         └───┬────┘          │
│      │                     │                    │               │
│      │ I/O 요청            │ I/O 완료           │               │
│      │ (park)              │ (unpark)           │               │
│      ▼                     ▼                    ▼               │
│                                                                 │
│  Carrier Thread:                                                │
│  ┌────────┐ ┌──────────────────────────┐ ┌────────┐             │
│  │ VT-1   │ │ VT-2 (다른 Virtual)      │ │ VT-1   │             │
│  │ 실행중 │ │ 실행중                    │ │ 재개   │             │
│  └────────┘ └──────────────────────────┘ └────────┘             │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ 핵심: Carrier Thread는 놀지 않는다!                         ││
│  │       VT-1이 대기하는 동안 VT-2를 실행                      ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="5-parkunpark-동작-상세">5. park/unpark 동작 상세</h2>
<h3 id="51-virtual-thread의-unpark-코드-분석">5.1 Virtual Thread의 unpark 코드 분석</h3>
<pre><code class="language-java">// VirtualThread.unpark() 내부 동작 (단순화)

void unpark() {
    // 1. 현재 상태 확인
    if (state == WAITING) {
        // 2. 상태 변경: WAITING → RUNNABLE
        state = RUNNABLE;

        // 3. 스케줄러에 작업 제출
        //    → Carrier Thread의 workQueue에 추가됨
        submitRunContinuation();
    }
}

void submitRunContinuation() {
    // ForkJoinPool에 작업 제출
    scheduler.execute(runContinuation);
    // → 내부적으로 어떤 Carrier Thread의 workQueue에 push됨
}</code></pre>
<h3 id="52-io-발생-시-park-동작">5.2 I/O 발생 시 park 동작</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  I/O 발생 시 Virtual Thread park 흐름                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  예: Socket 통신에서 응답 대기                                  │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  1. VirtualThread에서 socket.read() 호출                 │   │
│  │                      │                                   │   │
│  │                      ▼                                   │   │
│  │  2. NIOSocketImpl.park() 호출                            │   │
│  │                      │                                   │   │
│  │                      ▼                                   │   │
│  │  3. 현재 스레드가 Virtual Thread인지 확인                │   │
│  │                      │                                   │   │
│  │          ┌───────────┴───────────┐                       │   │
│  │          │                       │                       │   │
│  │          ▼                       ▼                       │   │
│  │  [Platform Thread]       [Virtual Thread]                │   │
│  │  Net.poll() 호출         Poller.poll() 호출              │   │
│  │  (OS 커널에 요청)        (JVM 내부 처리)                 │   │
│  │  (비용 높음)             (비용 낮음)                     │   │
│  │                                   │                      │   │
│  │                                   ▼                      │   │
│  │                          Virtual Thread park             │   │
│  │                          • unmount from Carrier          │   │
│  │                          • 상태를 힙에 저장              │   │
│  │                          • Carrier는 다른 VT 실행        │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="53-threadsleep-동작">5.3 Thread.sleep() 동작</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Thread.sleep()에서의 Virtual Thread 분기                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  // JDK 21 Thread.sleep() 내부 (단순화)                         │
│                                                                 │
│  public static void sleep(long millis) {                        │
│      if (currentThread() instanceof VirtualThread vt) {         │
│          // Virtual Thread인 경우:                              │
│          // → JVM 내부에서 가볍게 park                          │
│          vt.sleepNanos(millis * 1_000_000L);                    │
│      } else {                                                   │
│          // Platform Thread인 경우:                             │
│          // → OS 커널에 요청 (기존 방식)                        │
│          sleep0(millis);  // native method                      │
│      }                                                          │
│  }                                                              │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Virtual Thread sleep:                                      ││
│  │  • park → 힙에 저장 → Carrier 해제                          ││
│  │  • 타이머 완료 시 → unpark → 재스케줄링                     ││
│  │  • OS 개입 없음!                                            ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="6-기존-thread-모델과의-호환성">6. 기존 Thread 모델과의 호환성</h2>
<h3 id="61-jdk-17-vs-jdk-21-비교">6.1 JDK 17 vs JDK 21 비교</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  LockSupport.park() 변화                                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  JDK 17 (기존):                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  public static void park() {                                ││
│  │      UNSAFE.park(false, 0L);  // 항상 native 호출           ││
│  │  }                                                          ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  JDK 21 (Virtual Thread 지원):                                  │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  public static void park() {                                ││
│  │      Thread t = Thread.currentThread();                     ││
│  │      if (t.isVirtual()) {                                   ││
│  │          VirtualThreads.park();  // JVM 내부 처리           ││
│  │      } else {                                               ││
│  │          UNSAFE.park(false, 0L); // 기존 native 호출        ││
│  │      }                                                      ││
│  │  }                                                          ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  핵심: 기존 코드 수정 없이 Virtual Thread 지원!             ││
│  │        Thread.sleep(), Lock, I/O 모두 자동으로 동작         ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="62-niosocketimplpark-변화">6.2 NIOSocketImpl.park() 변화</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  Socket I/O에서의 변화                                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  JDK 17:                                                        │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  private void park(FileDescriptor fd, int event) {          ││
│  │      Net.poll(fd, event, -1);  // 항상 커널 poll            ││
│  │  }                                                          ││
│  │                                                             ││
│  │  → OS 스레드가 블로킹됨                                     ││
│  │  → 다른 작업 처리 불가                                      ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  JDK 21:                                                        │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  private void park(FileDescriptor fd, int event) {          ││
│  │      Thread t = Thread.currentThread();                     ││
│  │      if (t.isVirtual()) {                                   ││
│  │          Poller.poll(fd, event, ...);  // JVM 내부 처리     ││
│  │          // → Virtual Thread만 park                         ││
│  │          // → Carrier Thread는 다른 VT 실행                 ││
│  │      } else {                                               ││
│  │          Net.poll(fd, event, -1);  // 기존 방식             ││
│  │      }                                                      ││
│  │  }                                                          ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="7-핵심-요약">7. 핵심 요약</h2>
<h3 id="71-한-장으로-보는-virtual-thread">7.1 한 장으로 보는 Virtual Thread</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                   Virtual Thread 핵심 요약                      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 구조                                                        │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  VirtualThread                                              ││
│  │    ├── carrierThread: 실제 실행을 담당하는 Platform Thread  ││
│  │    ├── scheduler: ForkJoinPool (Carrier Thread Pool 관리)   ││
│  │    └── runContinuation: 실행할 작업 (중단/재개 가능)        ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  2. 동작 원리                                                   │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  시작: runContinuation → workQueue에 push                   ││
│  │  실행: Carrier Thread가 workQueue에서 pop → 실행            ││
│  │  대기: I/O 발생 → park → unmount → 힙에 저장                ││
│  │  재개: I/O 완료 → unpark → workQueue에 push → 재실행        ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  3. Platform Thread와의 차이                                    │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │           │ Platform Thread    │ Virtual Thread             ││
│  │  ─────────┼────────────────────┼────────────────────────    ││
│  │  매핑     │ OS 스레드 1:1      │ Carrier Thread N:1         ││
│  │  생성비용 │ ~1MB               │ 수백 바이트                ││
│  │  스케줄링 │ OS 커널            │ JVM (ForkJoinPool)         ││
│  │  I/O 대기 │ OS 스레드 블로킹   │ unmount (Carrier 해제)     ││
│  │  CS 비용  │ 높음 (시스템 콜)   │ 낮음 (JVM 내부)            ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  4. 호환성                                                      │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  • 기존 Thread API 그대로 사용 가능                         ││
│  │  • sleep(), Lock, I/O 모두 자동으로 Virtual Thread 지원     ││
│  │  • JDK가 내부적으로 Virtual Thread 분기 처리                ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  5. 핵심 이점                                                   │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  • I/O 대기 중 Carrier Thread가 다른 작업 처리              ││
│  │  • 수백만 개 Virtual Thread 생성 가능                       ││
│  │  • 컨텍스트 스위칭 비용 대폭 감소                           ││
│  │  • 기존 코드 수정 없이 성능 향상                            ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="72-pr-리뷰-시스템에서의-적용">7.2 PR 리뷰 시스템에서의 적용</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│  GPT API 호출에 Virtual Thread 적용 효과                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  기존 (Platform Thread):                                        │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  Thread-1: [요청] ─────────────────────────── [응답처리]    ││
│  │                   │← 1~2초 대기 (스레드 점유) →│             ││
│  │                                                             ││
│  │  문제: 대기 중에도 ~1MB 메모리 점유                         ││
│  │        OS 스레드 낭비                                       ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
│  Virtual Thread 적용 후:                                        │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  VT-1: [요청] ─park─▶ (힙에 저장, 수백 바이트)               ││
│  │                 │                                           ││
│  │  Carrier: ──────┼─▶ VT-2, VT-3, ... 실행 (놀지 않음)        ││
│  │                 │                                           ││
│  │  VT-1: ◀─unpark─ [응답처리]                                 ││
│  │                                                             ││
│  │  효과: 대기 중 Carrier 해제 → 다른 작업 처리                ││
│  │        수백만 개 동시 요청 가능                             ││
│  └─────────────────────────────────────────────────────────────┘│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[인수테스트를 중심으로 안정망 구축하기 ]]></title>
            <link>https://velog.io/@heeun_98/%EC%9D%B8%EC%88%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91%EC%8B%AC%EC%9C%BC%EB%A1%9C-%EC%95%88%EC%A0%95%EB%A7%9D-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/%EC%9D%B8%EC%88%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91%EC%8B%AC%EC%9C%BC%EB%A1%9C-%EC%95%88%EC%A0%95%EB%A7%9D-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 05:56:43 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황-및-배경">문제 상황 및 배경</h3>
<p>몇개월 전 진행했던 프로젝트인 미팀을 몇개월 만에 다시 살려보고자 남아있는 팀원들이랑 방향성에 대해 논의해 보았다.
우선적으로 테스트 코드가 전무하니, 테스트를 구축과 동시에 프로덕션 코드를 리펙토링 하기로 하였다.
팀원이 많이 이탈했기 때문에, 팀원들이 작성한 코드마저 현재 남은 인원으로 리펙토링을 진행해야 했고 이 방대한 양의 프로덕션 코드를 리펙토링을 할 생각을 하니 시작하기도 전에 막막함과 피로감이 스멀스멀 올라왔다. 
현재 우리 서비스는 전혀 예상하지 못한 곳에서 장애가 존재했고, 프로젝트 기간중 상당한 시간을 예상하지 못한 장애를 해결하는데 시간을 보냈다.
예상하지 못한 장애를 미리 예방하고 싶고, 그러기 위해서는 단위테스트와 통합테스트가 구축이 되어야 한다고 판단했다.
하지만 현재 미팀 서비스의 프로덕션 코드에 단위테스트와 통합테스트를 도입하기에는, 클래스마다 역할과 책임은 분리 되어있지 않았기에, 테스트를 구축하는 작업 역시 많이 리소스가 필요했다.</p>
<h3 id="인수테스트를-통한-안정망-구축">인수테스트를 통한 안정망 구축</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/2360e538-d95f-4082-a3f3-747eded84f32/image.png" alt=""></p>
<p>이를 해결하기위해 먼저 <strong>API 기반의 E2E 인수테스트</strong>를 우선적으로 구축하고, 구축한 안정망을 통하여 프로덕션 코드를 리펙토링을 진행하기로 결심했다.
<strong>현재 상황에서는 프로덕션 코드와 관계 없이 테스트를 구축할수 있고</strong> 이 구축한 테스트의 보호 안에서 하나의 모듈의 대한 리펙토링을 진행후에 수시로 테스트를 진행하여 회귀 버그 또한 테스트를 통해 확인할 수 있다고 판단했다.
위의 사진과 같이 우선적으로 테스트를 통과를 목표를 잡고 <strong>모든 인수테스트가 통과하면 그 이후로는 자신감있게 리펙토링을 진행할 수 있다.</strong> 내가 생각한 작업 순서를 다음과 같다</p>
<ul>
<li>현재 프로젝트 시스템 분석</li>
<li>시스템 분석을 통한 리스크 분석 및 리스크 매트릭스 작성</li>
<li>리스크 매트릭스를 바탕으로 인수조건 도출</li>
<li>인수조건을 바탕으로 Gherkin 시나리오 작성</li>
<li>Gherkin 시나리오를 바탕으로 인수테스트 구축</li>
<li>인수테스트 통과를 목표로 프로덕션 코드 수정</li>
<li>프로덕션 코드 리펙토링</li>
</ul>
<hr>
<h3 id="시스템-분석--리스크-분석">시스템 분석 &amp; 리스크 분석</h3>
<p>프로젝트를 진행하지 몇개월의 시간이 흘렀고, AI 도구 또한 많이 발전되었다고 생각해 AI 에이전트의 도움을 받아 시스템을 분석하고, 이를 바탕을 리스크를 분석해 보았다.
먼저 리스크 평가 기준으로 <strong>비즈니스 임팩트, 변경 빈도, 버그 발생 가능성</strong> 등을 종합적으로 고려하여 리스크 매트릭스를 작성하였다.</p>
<table>
  <tr>
    <td width="50%">
      <img src="https://velog.velcdn.com/images/heeun_98/post/62789454-c7a9-4a79-bafb-2c95da08a6c4/image.png" width="100%"/>
    </td>
    <td width="50%">
      <img src="https://velog.velcdn.com/images/heeun_98/post/589b3613-d4b1-43b1-b238-cc6c5d89ad38/image.png" width="100%"/>
    </td>
  </tr>
</table>
AI 도구에게 우선순위를 받기 전, 현재 프로젝트에서 종합적으로 먼저 수정과 리펙토링을 해야되는 부분은 인증/인가와 관련된 부분이라고 판단했다.
AI 에이전트 역시 내가 생각한것과 같이 가장 우선순위가 높은 시나리오로 평가했다.


<h3 id="인수-조건-도출하기--gherkin-시나리오-작성하기">인수 조건 도출하기 &amp; Gherkin 시나리오 작성하기</h3>
<p>먼저 우선순위가 높은 순서에 맞게 인수조건을 도출하였다.
인수조건을 바탕으로 Gherkin 시나리오를 작성할 계획이라 인수조건을 명확하게 작성 해야한다고 판단했다.
인수조건들은 <strong>5W1H(누가, 무엇을, 언제, 어디서, 왜, 어떻게)</strong> 원칙으로 구체화하여 명확한 인수 조건을 정의했다.</p>
<p>이 정의한 인수조건을 바탕으로 Gherkin 시나리오를 작성했다.</p>
<img src="https://velog.velcdn.com/images/heeun_98/post/bbb49ce0-9d3a-41ee-9863-c72b0c397606/image.png" width="70%"/>
<p align="center">
  <small>feature 파일</small>
</p>


<br>
위의 사진은 feature 파일에 Gherkin 시나리오를 작성한 결과물이다.
하나의 시나리오에 맞는 테스트 코드를 인수테스트로 작성하였다.<br>
이 또한 AI 에이전트를 이용하여 작성하였다. 위의 feature 파일은 최종 시나리오지만, 처음에 작성한 시나리오에는 다음과 같은 문제가 있었다.

<ul>
<li>내부 구현과 관련된 용어</li>
<li>비개발 용어</li>
<li>시나리오 자체가 지나치게 세부적</li>
</ul>
<p>feature 파일은 목적에 따라 사용 범위가 다르겠지만, 이 문서는 팀 전체가 공유할 수 있는 팀 컨벤션으로서 역할을 할 수도 있다고 생각한다. 나는 이 문서를 통해 프론트엔드 개발자와 요구사항에 대한 싱크를 맞추는데 사용하였다.
위에 작성한 것이 항상 문제가 되는건 아니지만, feature 파일의 역할에 따라 문제가 될수도 있다고 생각한다.
또한, 지나치게 세부적인 시나리오를 만들어서 테스트를 작성해야하는지도 의문이다.
인수테스트의 목적은 결국 사용자 시나리오에 맞게 검증하는 용도라는 생각이 드는데, 모든 예외케이스에 대해서 작성한다거나 동시성 문제와 같은 문제는 오히려 통합테스트 or 단위테스트 or 슬라이스 테스트가 더 역할에 부합할것이다.</p>
<p><strong>Cucumber 라이브러리</strong>를 통해서 시나리오의 하나의 스텝마다 메서드를 1대1 대응 시켜서 테스트 코드를 작성할수 있었다. 이 라이브러리는 feature 에서 작성한 &quot;값&quot;들을 메서드 파라미터로 넘겨서 사용할수 있고, feature 파일 내부에서 곧 바로 테스트를 실행시킬수 있다.(플러인 설치)</p>
<p>시나리오를 작성할때는 비개발 용어와, 내부 구현과 관련없는 시나리오로 작성하려고 노력하였다.</p>
<p><strong>&quot;인증 토큰을 발급받는다&quot;</strong> 와 같은 스텝은 개발을 모르는 사람이라면 이해하기 어렵지만, 현재 미팀에서는 인가/인증과 관련된 로직에 알수 없는 버그들이 존재하기 때문에 해당 도메인에는 최대한 구체적으로 시나리오를 작성하려고 노력하였다.
또한, 내부 개발과 관련된 용어를 사용할경우에 프로덕션 코드의 수정이 발생할 경우
시나리오의 수정 또한 발생할수 있기 때문에 최대한 <strong>비지니스 용어를 이용하여 시나리오를 작성</strong>하는게 좋을것이다.</p>
<h3 id="인수테스트-작성하기">인수테스트 작성하기</h3>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/45df0106-e92f-4764-946e-3d1b3e58d79f/image.png" alt=""></p>
<p>인수테스트를 작성할때는 RestAssured 를 이용하여 테스트를 작성하였다.
추후에 프로덕션 코드를 리펙토링을 해야하기 때문에 E2E 기반 블랙박스 테스트를 하는것이 좋다고 판단하였고, 리펙토링을 하는 과정에서 코드가 수정되어도 영향을 받지 않기 때문에 이 방법을 선택하였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/545ba76a-5b7b-485f-a94a-6af0654d96e9/image.png" alt=""></p>
<p>위의 사진과 같은 API 호출을 통한 부분은 별도의 도메인과 관련된 API 클래스에서 호출할수 있도록 위임을 하였다.
API 호출에 대한 부분을 스텝 메서드에 둘 경우 가독성 또한 떨어진다고 판단하였고, 테스트 코드를 보는 입장에서는 로그인 요청 구현방식 보다는 로그인 요청을 한다는 사실이 더 중요할것이다.
즉, 스텝에서는 한글 메서드명을 통해서 해당 메서드가 로그인을 요청한다는 사실만 알도록 하였다.</p>
<h3 id="testcontext-이용하기">TestContext 이용하기</h3>
<hr>
<p>Cucumber 를 사용하지 않고 하나의 테스트 메서드에 인수 테스트를 작성할 경우 지역변수를 사용하여 값을 공유하면 될것이다. 하지만 Cucumber 를 사용할 경우 스텝 별로 메서드를 작성하기 때문에 값을 공유할 무언가가 필요하다.
Context 라는 개념은 무엇인가를 담아서 저장하는 개념으로 사용한다.
나는 TestContext 를 이용하여 스텝 간의 값을 전달거나 공유하도록 하였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/04c806e4-dbe7-48db-bd9a-3b620779de44/image.png" alt=""></p>
<p>Cucumber 에서는 @ScenarioScope 를 이용하여 하나의 시나리오가 끝나면
TestContext 를 초기화를 해준다. 별도의 clear 해주는 작업없이 이용가능한다.
도메인 별로 공유하는 값들은 이너 클래스를 이용하여 내부적으로 공유하도록 해주었다.</p>
<p>도메인 별로 inner 클래스를 이용하여 값을 저장하도록 구현하였지만, 도메인 별로 저장해야할 값이 많아질 경우에는 별로의 클래스로 분리하는것이 좋아보인다.</p>
<h3 id="최소한의-수정으로-인수테스트-통과하기">최소한의 수정으로 인수테스트 통과하기</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/f59c10de-5fa4-447a-bd50-2a72a4d30e2e/image.png" alt=""></p>
<p>프로젝트 지원 기능은 서비스의 핵심 기능이기 때문에, 비교적 다양한 시나리오를 작성했다.
다만 처음부터 모든 시나리오를 검증하기보다는,
Happy Case 2<del>3개, Error Case 2</del>3개, Edge Case 2~3개 정도로 시작하는 것이 적절하다고 판단했다.</p>
<pre><code class="language-java">
    @Counted(&quot;project.apply&quot;)
    @Override
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));

        validateProjectNotCompleted(project);

        // 프로젝트 리더는 자신의 프로젝트에 지원 불가
        if (project.getCreator().getId().equals(memberId)) {
            throw new CustomException(ErrorCode.APPLICATION_SELF_PROJECT_FORBIDDEN);
        }

        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));

        if (applicationRepository.existsByProjectAndApplicant(project, member)) {
            throw new CustomException(ErrorCode.PROJECT_APPLICATION_ALREADY_EXISTS);
        }

        if (projectMemberRepository.existsByProjectIdAndMemberId(projectId, memberId)) {
            throw new CustomException(ErrorCode.PROJECT_MEMBER_ALREADY_EXISTS);
        }

        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 해당 프로젝트에서 해당 포지션으로 모집 중인지 확인
        recruitmentStateRepository.findAvailableByProjectIdAndJobPosition(projectId, jobPosition)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.RECRUITMENT_POSITION_NOT_AVAILABLE));

        ProjectApplication application = ProjectApplication.create(
                project,
                member,
                jobPosition,
                request.motivation()
        );

        ProjectApplication savedApplication = applicationRepository.save(application);

        Long receiverId = project.getCreator().getId();
        Long actorId = member.getId();

        // 1. 팀장에게 지원 알림 저장
        Notification applyNotification = createNotification(
                project.getCreator(), project, actorId, NotificationType.PROJECT_APPLY, savedApplication.getId()
        );
        notificationRepository.save(applyNotification);

        // 2. 지원자에게 지원 완료 알림 저장
        Notification myApplyNotification = createNotification(
                member, project, actorId, NotificationType.PROJECT_MY_APPLY, savedApplication.getId()
        );
        notificationRepository.save(myApplyNotification);

        // 프로젝트 리더 알림 발행 (SSE)
        eventPublisher.publishEvent(new NotificationEvent(
                receiverId,
                project.getId(),
                actorId,
                NotificationType.PROJECT_APPLY,
                savedApplication.getId()
        ));

        // 지원자 알림 발행 (SSE)
        eventPublisher.publishEvent(new NotificationEvent(
                actorId,
                project.getId(),
                actorId,
                NotificationType.PROJECT_MY_APPLY
        ));

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}&quot;,
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
</code></pre>
<p>프로젝트 지원 메서드를 보면 하나의 메서드에서 너무 많은 역할을 수행하고 있다.
프로젝트 지원 처리뿐만 아니라, 검증 로직과 알림 처리까지 함께 포함되어 있어 책임이 과도하게 집중된 상태였다.</p>
<p>이러한 구조를 안정망 없이 리팩토링한다고 생각하면 상당히 부담스러운 작업이다.
하지만 인수 테스트라는 안정망을 구축해두었기 때문에,
기능이 깨지더라도 빠르게 버그를 탐지하고 수정할 수 있는 환경을 만들 수 있었다.
AI 에이전트에게 리팩토링을 맡기기 전에,
먼저 <strong>내가 원하는 방향을 정리한 컨벤션 문서를 작성</strong>하고 이를 Claude.md에 포함시켰다.
Claude Code에서는 @Convention.md와 같은 방식으로
컨텍스트에 포함된 문서를 참조할 수 있도록 구성했다.
처음에는 하나의 endpoint를 직접 리팩토링한 뒤,그 결과를 문서화하고 이를 기반으로 전체 리팩토링을 진행했다.
컨벤션 문서를 작성할 때는 단순한 설명보다 원하는 구조를 담은 예시 코드를 함께 제공하는 것이 가장 중요하다고 생각한다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/d9c3bcb9-6417-4c09-9d69-e767e9aac351/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/eac250af-dfeb-42b6-be6d-ad60388c545f/image.png" alt=""></p>
<p>이후 해당 컨벤션을 기반으로 리팩토링을 진행했다.
아래는 프로젝트 지원 메서드를 리팩토링한 결과이다.</p>
<pre><code class="language-java">


    @Counted(&quot;project.apply&quot;)
    @Override
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 검증
        validateNotSelfApplication(project, memberId);

        // 3단계: 비즈니스 규칙 검증
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 4단계: 지원서 생성 및 저장
        ProjectApplication savedApplication = createAndSaveApplication(project, member, jobPosition, request.motivation());

        // 5단계: 알림 발행 및 응답
        publishApplyNotifications(project, member, savedApplication);

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}&quot;,
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
</code></pre>
<p>역할을 분리하고 각 책임을 위임하면서,기존보다 훨씬 가독성이 좋아지고 구조가 명확해졌다.다만 이 과정에서 private 메서드가 많이 생성되었는데,
이는 하나의 클래스가 너무 많은 책임을 가지고 있다는 신호일 수도 있다.</p>
<p><strong>따라서 메서드가 더 늘어난다면,관련된 책임을 별도의 클래스로 분리하는 방향도 고려해야 한다.</strong>
리펙토링을 완료했으니, 프로젝트 지원과 관련된 인수테스트를 돌려보자.
<img src="https://velog.velcdn.com/images/heeun_98/post/4c5f431c-a445-4c42-bea8-b3c8b9cd3451/image.png" alt=""></p>
<p>모든 테스트가 성공적으로 통과하는 것을 확인할 수 있었다.만약 인수 테스트가 없었다면,Swagger를 통해 다음과 같은 과정을 모두 수동으로 검증해야 했을 것이다.</p>
<ul>
<li>지원 요청</li>
<li>지원 결과 확인</li>
<li>프로젝트 리더 알림 확인</li>
<li>지원자 알림 확인</li>
<li>프로젝트 인원 변화 확인</li>
</ul>
<p>이 과정을 매번 반복하는 것은 상당한 시간과 비용이 드는 작업이며,생각만 해도 부담스러운 과정이다.이전까지는 배포 전 수동 테스트에 많은 시간을 투자했지만,인수 테스트를 도입하면서 검증 시간을 크게 줄일 수 있었다.이번 작업의 목적은 프로덕션 코드 리팩토링이었지만,테스트 코드 또한 하나의 코드이기 때문에 유지보수가 가능한 형태로 작성하는 것이 매우 중요하다.그렇지 않으면 테스트 코드 역시 결국 방치되고 버려질 수 있기 때문에,테스트 코드 작성에도 많은 시간을 투자했다.테스트 코드 작성 과정은 다음 글에서 정리해보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[test] - Mockito로 stubbing 하기]]></title>
            <link>https://velog.io/@heeun_98/test-Mockito%EB%A1%9C-stubbing-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/test-Mockito%EB%A1%9C-stubbing-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 19 Dec 2025 13:20:53 GMT</pubDate>
            <description><![CDATA[<h3 id="mockitobean">@MockitoBean</h3>
<p>MockitoBean 은 Application Context 
이미 존재하는 빈이면 <strong>Mock 빈</strong> 으로 대체한다.
존재하지 않으면 <strong>Mock 빈</strong> 으로 새로 등록한다.</p>
<p>가짜 객체를 빈으로 등록하고,
그 빈이 어떤 상태를 반환할지 정하면 된다.</p>
<p>위와 같이 Mock 빈과 같은 Mock 객체는 언제 사용하는것일까?</p>
<pre><code class="language-java">
@Transactional
@SpringBootTest
@ActiveProfiles(value = &quot;test&quot;)
class OrderStatisticsServiceTest {


    @Autowired
    private OrderStatisticsService orderStatisticsService;

    @Autowired
    private OrderProductRepository orderProductRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private MailSendHistoryRepository mailSendHistoryRepository;

    @MockitoBean
    private MailSendClient mailSendClient;


    @AfterEach
    void tearDown() {
        orderProductRepository.deleteAllInBatch();
        orderRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch();
        mailSendHistoryRepository.deleteAllInBatch();
    }


    @DisplayName(&quot;결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.&quot;)
    @Test
    void sendOrderStatisticsMail() {
        //given
        LocalDateTime now = LocalDateTime.of(2023, 3, 5, 0, 0);
        Product product1 = createProduct(HANDMADE, &quot;001&quot;, 1000);
        Product product2 = createProduct(HANDMADE, &quot;002&quot;, 2000);
        Product product3 = createProduct(HANDMADE, &quot;003&quot;, 3000);
        List&lt;Product&gt; products = List.of(product1, product2, product3);
        productRepository.saveAll(products);


        Order order1 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 4, 23, 59, 59), products);
        Order order2 = createPaymentCompletedOrder(now, products);
        Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 5, 23, 59, 59), products);
        Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 6, 0, 0), products);

        // stubbing : 목 객체에 원하는 행위를 정의하는 것
        when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
                .thenReturn(true);

        // when
        boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2023, 3, 5), &quot;test@test.com&quot;);

        //then
        assertThat(result).isTrue();

        List&lt;MailSendHistory&gt; histories = mailSendHistoryRepository.findAll();
        assertThat(histories).hasSize(1)
                .extracting(&quot;content&quot;)
                .contains(&quot;총 매출 합계는 12000원입니다.&quot;);
    }

    private Order createPaymentCompletedOrder(LocalDateTime now, List&lt;Product&gt; products) {
        Order order = Order.builder()
                .products(products)
                .orderStatus(PAYMENT_COMPLETED)
                .registeredDateTime(now)
                .build();
        orderRepository.save(order);

        return order;
    }


    private Product createProduct(ProductType type, String productNumber, int price) {
        return Product.builder()
                .type(type)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name(&quot;메뉴 이름&quot;)
                .build();
    }

}


</code></pre>
<p>테스트 클래스의 필드를 보면 @Autowired 를 통해서 의존관계 주입을 해주었다.</p>
<p>하지만 하나의 클래스는 @Autowired 를 사용하지 않은것을 확인할 수 있다.</p>
<pre><code class="language-java">    @MockitoBean
    private MailSendClient mailSendClient;</code></pre>
<p>이건 왜 @Autowired 를 통해서 DI 를 하는것이 아닌 @MockitoBean 으로 가짜 빈을 Application Context 에 등록하는것일까</p>
<pre><code class="language-java">public boolean sendOrderStatisticsMail(LocalDate orderDate, String email) {

        // 해당 일자에 결제완료된 주문들을 가져와서
        List&lt;Order&gt; orders = orderRepository.findOrdersBy(
                orderDate.atStartOfDay(),
                orderDate.plusDays(1).atStartOfDay(),
                PAYMENT_COMPLETED
        );

        // 총 매출 합계를 계산하고

        int totalPrice = orders.stream()
                .mapToInt(order -&gt; order.getTotalPrice())
                .sum();

        // 메일 전송
        boolean result = mailService.sendMail(
                &quot;no-reply@cafekiosk.com&quot;,
                email,
                String.format(&quot;[매출통계] %s&quot;, orderDate),
                String.format(&quot;총 매출 합계는 %s원입니다.&quot;, totalPrice)
        );

        if (!result) {
            throw new IllegalArgumentException(&quot;매출 통계 메일 전송에 실패했습니다.&quot;);
        }

        return true;
    }
</code></pre>
<p>sendEmailService 내부에서 메일 전송을 한다.</p>
<pre><code class="language-java">
package sample.cafekiosk.spring.api.service.mail;


import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.MailSendHistory;
import sample.cafekiosk.spring.domain.history.MailSendHistoryRepository;

@RequiredArgsConstructor
@Service
public class MailService {

    private final MailSendClient mailSendClient;
    private final MailSendHistoryRepository mailSendHistoryRepository;


    public boolean sendMail(String fromMail, String toEmail, String subject, String content) {

        boolean result = mailSendClient.sendEmail(fromMail, toEmail, subject, content);

        if (result) {
            mailSendHistoryRepository.save(MailSendHistory.builder()
                    .fromEmail(fromMail)
                    .toEmail(toEmail)
                    .subject(subject)
                    .content(content)
                    .build()
            );
            return true;
        }

        return false;
    }
}
</code></pre>
<p>여기서 실제로 메일을 전송하는 객체는 MailSendClient 가 담당한다.</p>
<p>그럼 MailSendClient 에서 외부 네트워크를 사용해서 매번 테스트를 진행하는건 매우 비효율적일것이다. 테스트를 할때마다 실제로 메일을 전송하는건 바람직하지 않다.</p>
<p>이런 경우에 Mock 객체를 사용해서 메일 전송 로직을 작동한다고 가정하고 테스트를 진행할 수 있다.</p>
<p>테스트 코드에서는 실제 객체가 아닌 가짜 객체를 등록해 사용하기 위해서 </p>
<p><code>@MockitoBean
    private MailSendClient mailSendClient;</code></p>
<p>   으로 Mock 빈으로 등록하는것이다.</p>
<p>Mock 빈을 이용해서 반환하도록 하는것을 Stubbing 이라고 한다.</p>
<pre><code class="language-java">
// stubbing : 목 객체에 원하는 행위를 정의하는 것
 when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
                .thenReturn(true);
</code></pre>
<p>원하는 행위를 정의하고 사용하면 된다.</p>
<p>참고로 위와 같은 과정은 Given 에서 진행하는것이 맞다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[미팀 - 지원은 실패, 알림은 성공? 트랜잭션 이벤트 정합성 해결]]></title>
            <link>https://velog.io/@heeun_98/%EB%AF%B8%ED%8C%80-%EC%95%8C%EB%A6%BC-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@heeun_98/%EB%AF%B8%ED%8C%80-%EC%95%8C%EB%A6%BC-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Fri, 05 Dec 2025 15:12:59 GMT</pubDate>
            <description><![CDATA[<p>미팀 프로젝트에서는 프로젝트 지원 후 지원자에게 SSE를 통한 알림을 전송해야 한다.
처음에 단순히, 아래와 같이 기능을 개발을 위해서 자연스럽게 다음과 같은 형태의 코드를 떠올렸다.</p>
<pre><code class="language-java">
@Service
@RequiredArgsConstructor
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final MemberRepository memberRepository;
    private final JobPositionRepository jobPositionRepository;
    private final ApplicationRepository applicationRepository;
    // 이벤트를 발행하는 대신, 알림 서비스를 직접 의존합니다.
    private final NotificationService notificationService; 

    @Transactional
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 및 비즈니스 검증
        validateNotSelfApplication(project, memberId);
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 3단계: 지원서 생성 및 저장
        ProjectApplication application = ProjectApplication.builder()
                .project(project)
                .member(member)
                .jobPosition(jobPosition)
                .motivation(request.motivation())
                .build();
        ProjectApplication savedApplication = applicationRepository.save(application);

        // 4단계: 알림 서비스 직접 호출 (동기 방식의 핵심)
        // 알림 전송이 완료될 때까지 아래 로그와 리턴문은 실행되지 않고 대기합니다.
        notificationService.sendApplyNotification(project.getOwner(), member, savedApplication);

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}&quot;,
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
}

</code></pre>
<p>이 코드는 전형적인 동기 방식을 따른다. 동기 방식은 프로그램의 흐름을 직관적으로 이해할 수 있다.
하지만 동기 방식에서, 알림 전송과 같은 부가적인 기능을 만나면 고려할 부분이 있다.
먼저, 위의 요구사항에서 사용자가 지원을 완료하고 <strong>알림 전송</strong>을 받을때, 알림 전송을 하는 과정에서 Exception 이 발생하면 프로젝트 지원절차 역시 실패한다.
이게 맞는걸까? 생각을 해보면, 알림 전송을 실패했다고 지원까지 실패하는건 사용자가 원하지 않은 결과일것이다. 또한, 알림 전송을 완료하고 사용자에게 프로젝트 지원에 대한 응답을 보낼경우 응답시간 또한 지연될 것이다.
위와 같은 이유로 반드시 알림이 전송되고 지원이 완료되어야 하는게 아니라면, 동기 방식 대신 비동기 방식으로 연동하는 것을 고민해 볼 필요가 있다. 현재 구조에서 비동기 방식으로 구현할 경우, 알림전송에 대한 로직이 끝날때까지 기다리지 않고, 바로 프로젝트 지원 성공을 사용자에게 응답할 수 있다.</p>
<h3 id="알림-발송-로직을-비동기로-호출">알림 발송 로직을 비동기로 호출</h3>
<pre><code class="language-java">
@Service
@RequiredArgsConstructor
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final MemberRepository memberRepository;
    private final JobPositionRepository jobPositionRepository;
    private final ApplicationRepository applicationRepository;

    // 알림 서비스를 직접 의존 (결합 발생)
    private final NotificationService notificationService; 

    @Transactional
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 및 비즈니스 검증
        validateNotSelfApplication(project, memberId);
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 3단계: 지원서 생성 및 저장
        ProjectApplication application = ProjectApplication.builder()
                .project(project)
                .member(member)
                .jobPosition(jobPosition)
                .motivation(request.motivation())
                .build();
        ProjectApplication savedApplication = applicationRepository.save(application);

        // 4단계: 단순 비동기 호출 (Direct Async Call)
        // @Async가 붙은 메서드를 직접 호출합니다. 
        // 이 시점에 별도의 스레드에서 알림 발송 로직이 즉시 시작됩니다.
        notificationService.sendApplyNotification(project.getOwner(), member, savedApplication);

        // 5단계: 의도적인 예외 발생 (블로그 예시용)
        // 만약 여기서 알 수 없는 에러가 발생하여 트랜잭션이 롤백된다면?
        if (someErrorCondition) {
            throw new RuntimeException(&quot;DB 커밋 직전 에러 발생!&quot;);
        }

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}&quot;, projectId, memberId);
        return ApplicationResponse.from(savedApplication);
    }
}

</code></pre>
<p>@Async 를 이용하여 non-blocking 비동기로 알림 발송 로직을 실행했다.
하지만 이 과정에서는 전의 상황보다 더 심각한 상황이 발생할것이다.
비동기 처리를 하기 위해 스레드풀에서 스레드를 할당받고 알림을 보낸다.
하지만, 핵심 로직인 프로젝트 지원 스레드가 예외가 터지면, 롤백이 발생할것이고 결국 지원은 실패한다.
결과적으로 사용자는 <strong>&quot;지원이 실패했는데 지원 완료 알림을 받는&quot;</strong> 당황스러운 경험을 하게 된다.
이 문제를 어떻게 해야할까? 쉽게 생각하면 프로젝트 지원이 완료되고 알림 발송 로직이 실행되면 된다.
프로젝트 지원후 commit이 완료 되고 나서, 알림전송을 하기 위해서 다음과 같은 방식으로 구현을 하였다.</p>
<h3 id="facade-클래스로-commit-이후에-비동기-호출하기">Facade 클래스로 commit 이후에 비동기 호출하기</h3>
<pre><code class="language-java">// 상위 Facade 클래스
public void applyAndNotify(...) {
    applicationService.apply(...); // 여기서 트랜잭션 커밋 완료
    notificationService.sendAsync(...); // 커밋 완료 후 비동기 호출
}
</code></pre>
<p>Fascade 상위 클래스에서는 <code>apply</code> 이후에 commit 이 완료되고 나서 비동기로 비동기 호출을 하면 된다. 위에서 발생할수 있는 문제를 해결할수 있다.
하지만 나는 미팀에서는 Facade 클래스를 이용하지 않고, Spring Event 를 이용하여 비동기를 호출하였다. 현재 요구사항에서는 지원이 완료시에 알림전송만 하면된다. 하지만 추후에 지원후 외부 시스템을 이용하여 알림톡 전송을 하는등, 추가적인 요구사항이 생길경우 Facade 클래스가 의존하는 클래스가 많아질 것이다.
또한, 다른 개발자가 Facade 를 거치지 않고 applicationService.apply()를 직접 호출하면? 알림이 영영 가지 않을수 있다.
현재 상황에서는 위의 방식이 전혀 문제는 없다. 하지만 Spring Event에서의 TransactionEventListener는 다양한 케이스들에서 동작할수 있도록 제어가 가능하다.
After commit만 있는게 아니라 beforeCommit, beforeCompletion, AflterCompletion도 가능하고, 나는 commit 이후에 알림 발송을 하면 되었기에 아래와 같이 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)와 @Async 를 이용했다.</p>
<pre><code class="language-java">
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(NotificationEvent e) {
      // 엔티티 조회를 SSENotificationService로 위임
      notificationService.notify(e);
}
</code></pre>
<p>여기서 <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>
를 사용할때 조심해야한다.
AFTER_COMMIT 을 보고 COMMIT 이 완료되었으니까, 새로운 @Transaction을 열면 된다고 생각하는 순간 commit 이 되지 않는다.
위의 코드는 현재 비동기로 처리되기 때문에 기존 트랜잭션과 다른 스레드를 사용하기 때문에 문제가 발생하지 않지만, 만약 동기적으로 실행할 경우에는 하나의 스레드를 계속해서 사용하기 때문에 commit이 안된다.
commit 이 왜 발생하지 않을까 ?
AFTER_COMMIT 이면 COMMIT 이후에 실행되기 때문에, 추가적으로 물리 트랜잭션을 새로 연다고 해도 COMMIT은 되어야 한다고 생각했다.
하지만 여기서 AOP를 통해서 <code>connection.commit()</code> 을 호출하여 물리적인 DB commit 을 하더라도
아직 DataSource 는 살아있고, 아직 connection을 release() 한 상태는 아니다.
즉, 자원정리는 아직 하지 않은 상태이고, DB에 commit을 보낸상태로 <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code> 를 호출하는것이다. 이 어노테이션 이후에 상황은 다음과 같다</p>
<ul>
<li>기존 커넥션은 살아있음</li>
<li>DB에 commit 호출</li>
</ul>
<p>그럼 알림서비스에서  @Transaction을 열고 write 연산을 진행했다고 가정하자. 그럼 이 상황에서 기존 커넥션을 이용할것이다. 메서드 종료 시점에 commit 을 하겠지만, 여기서 장애가 발생하고 commit을 하지 못한다.
그 이유는 이미 기존에 commit을 호출하였기 때문에 write 연산을 해도 의미가 없기 때문이다.
반면 기존 커넥션을 살아있으므로 read 연산을 해도 에러가 발생하지 않는다.
<strong>즉, <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code> 는 동기적으로 실행될 경우, DB에 commit 호출 이후, 그리고 커넥션을 커넥션풀에 반납하기 전에 실행된다</strong></p>
<p>물론 미팀에서 @Async 를 통해 비동기로 실행되기 때문에 별도의 스레드에서 실행되므로 문제가 발생하지 않지만, 해당 어노테이션이 일어나는 시점을 명확하게 인지할 필요가 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[자바 - 파라미터 테스트 작성법]]></title>
            <link>https://velog.io/@heeun_98/%EC%9E%90%EB%B0%94-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95</link>
            <guid>https://velog.io/@heeun_98/%EC%9E%90%EB%B0%94-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%91%EC%84%B1%EB%B2%95</guid>
            <pubDate>Tue, 02 Dec 2025 09:03:13 GMT</pubDate>
            <description><![CDATA[<p>JUnit5 <strong>@ParameterizedTest</strong> 에서 자주 쓰는
<code>@MethodSource</code>, <code>@ValueSource</code>, <code>@CsvSource</code> 의 차이</p>
<h3 id="1-valuesource--단일-타입값-하나씩-테스트">1) @ValueSource — 단일 타입(값 하나씩) 테스트</h3>
<p>가장 간단한 파라미터 제공 방식.</p>
<ul>
<li><strong>문자열, 숫자, boolean 등 하나의 값만 제공 가능</strong></li>
<li><strong>여러 파라미터가 필요한 테스트에는 사용 불가</strong></li>
</ul>
<p>예)</p>
<pre><code class="language-java">@ParameterizedTest
@ValueSource(strings = {&quot;a&quot;, &quot;b&quot;, &quot;c&quot;})
void test(String input) {
    assertThat(input).isNotBlank();
}</code></pre>
<p>✔ 장점: 매우 간단
✘ 단점: 여러 인자를 테스트할 수 없음</p>
<hr>
<h3 id="2-csvsource--여러-인자를-한-줄에서-받는-테스트">2) @CsvSource — 여러 인자를 한 줄에서 받는 테스트</h3>
<p>CSV(쉼표로 구분된 값)를 사용해
<strong>테스트 메서드 파라미터 여러 개</strong> 전달 가능.</p>
<p>예)</p>
<pre><code class="language-java">@ParameterizedTest
@CsvSource({
    &quot;1, 2, 3&quot;,
    &quot;4, 6, 10&quot;,
})
void plus_test(int a, int b, int expected) {
    assertThat(a + b).isEqualTo(expected);
}</code></pre>
<p>✔ 장점: 여러 인자를 손쉽게 넣을 수 있음
✔ 문자열도 가능
✘ 단점: 복잡한 데이터, 콤마 포함 문자열은 다루기 조금 귀찮음</p>
<hr>
<h3 id="3-methodsource--가장-강력하고-유연한-방식">3) @MethodSource — 가장 강력하고 유연한 방식</h3>
<p>메서드로부터 <strong>복잡한 객체, 리스트, 여러 인자 등 어떤 형태든 전달</strong> 가능.</p>
<pre><code class="language-java">@ParameterizedTest
@MethodSource(&quot;numberProvider&quot;)
void test(List&lt;Integer&gt; numbers, int expected) {
    assertThat(numbers.stream().mapToInt(i -&gt; i).sum())
            .isEqualTo(expected);
}

static Stream&lt;Arguments&gt; numberProvider() {
    return Stream.of(
        Arguments.of(List.of(1,2,3), 6),
        Arguments.of(List.of(4,4,2), 10)
    );
}</code></pre>
<p>✔ 장점:</p>
<ul>
<li>복잡한 타입 전달 가능</li>
<li>객체, 리스트, Map, DTO 등 전부 가능</li>
<li>자유도가 가장 높음</li>
</ul>
<p>✘ 단점:</p>
<ul>
<li>메서드를 따로 작성해야 해서 가장 길다</li>
</ul>
<h3 id="언제-무엇을-쓰면-될까">언제 무엇을 쓰면 될까?</h3>
<h3 id="✔-값-하나만-바꿔서-테스트-→-valuesource">✔ 값 하나만 바꿔서 테스트 → <code>@ValueSource</code></h3>
<p>예) 문자열이 유효한지 검사
예) 길이 체크, 숫자만 체크</p>
<hr>
<h3 id="✔-파라미터-여러-개-있는-함수-테스트-→-csvsource">✔ 파라미터 여러 개 있는 함수 테스트 → <code>@CsvSource</code></h3>
<p>예)</p>
<ul>
<li>a + b = expected</li>
<li>입력/출력 구조 명확할 때</li>
</ul>
<hr>
<h3 id="✔-리스트-객체-컬렉션-복잡한-입력이-필요-→-methodsource">✔ 리스트, 객체, 컬렉션, 복잡한 입력이 필요 → <code>@MethodSource</code></h3>
<p>예)</p>
<ul>
<li>List<Integer></li>
<li>Map</li>
<li>DTO</li>
<li>커스텀 클래스 조합</li>
</ul>
<p>너가 만든 NumberParser 테스트는 “리스트 반환 + 다양한 문자열 입력”이라서
<strong>@MethodSource가 최적</strong>이야.</p>
<hr>
<blockquote>
<p><code>@ValueSource</code> 는 단일 값,
<code>@CsvSource</code> 는 여러 파라미터,
<code>@MethodSource</code> 는 가장 자유도가 높아 복잡한 입력을 테스트할 때 사용한다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[우테코 - 1주차 피드백]]></title>
            <link>https://velog.io/@heeun_98/%EC%9A%B0%ED%85%8C%EC%BD%94-1%EC%A3%BC%EC%B0%A8-%ED%94%BC%EB%93%9C%EB%B0%B1</link>
            <guid>https://velog.io/@heeun_98/%EC%9A%B0%ED%85%8C%EC%BD%94-1%EC%A3%BC%EC%B0%A8-%ED%94%BC%EB%93%9C%EB%B0%B1</guid>
            <pubDate>Tue, 02 Dec 2025 07:55:51 GMT</pubDate>
            <description><![CDATA[<h1 id="1주-차-피드백">1주 차 피드백</h1>
<p>1주 차 미션의 학습 목표는 <strong>개발 환경과 프로그래밍 언어에 익숙해지는 것</strong>이었습니다. 우아한테크코스가 어떻게 진행되는지 맛볼 수 있도록 과제를 내주겠다고 말씀드렸는데, 여러분은 어땠을지 궁금합니다.</p>
<p>프로그래밍을 처음 접하고 아직 개발 환경이 낯설게 느껴지는 분들을 위해 우아한테크코스의 테코톡 동영상을 공통 피드백에 첨부합니다. <em>테코톡은 우아한테크코스 크루들이 관심 있는 기술 주제를 스스로 공부하고 공유하는 문화</em>인데요. 앞으로 미션을 수행하면서 어려운 점이 있다면 테코톡을 통해 학습하고 구현해 보는 것을 추천합니다.</p>
<p>우아한테크코스에서는 <strong>&#39;완벽하게 해내는 것&#39;보다 &#39;새로운 시도를 멈추지 않는 것&#39;을 더 중요하게 생각</strong>합니다.
도전은 단순히 어려운 문제를 푸는 행위가 아니라, 자신이 아직 모르는 영역에 한 발 더 내딛는 태도입니다.</p>
<p>미션을 수행하다 보면 예상치 못한 오류나 막히는 순간을 여러 번 겪게 될 것입니다.
그럴 때 포기하지 않고 문제를 끝까지 탐색해 보는 경험이 바로 성장의 출발점입니다.
하지만 얼마나 탐색해야할지, 얼마나 도전해야할지 막연할 수 있습니다. 그래서 <strong>작은 도전부터 시작</strong>해 보세요.</p>
<p>예를 들어 이런 작은 도전을 해볼 수 있습니다.</p>
<ul>
<li>아직 익숙하지 않은 메서드나 문법을 하나 골라 적용해서 리팩터링해보기</li>
<li>에러를 만나면 바로 검색하지 말고 5분간 스스로 원인을 추측해 보기</li>
<li>디스코드에 ‘오늘 처음 시도한 것’ 한 줄 남기기</li>
</ul>
<p>우테코는 여러분이 <strong>&quot;할 수 있는 것만 하는 사람&quot;이 아니라, &quot;해본 적 없던 일에도 하나씩 도전하는 사람&quot;</strong>이 되길 기대합니다.</p>
<h2 id="공통-피드백">공통 피드백</h2>
<h3 id="요구-사항을-정확하게-준수한다">요구 사항을 정확하게 준수한다</h3>
<p>과제를 제출하기 전에 과제 진행 요구 사항, 기능 요구 사항, 프로그래밍 요구 사항을 모두 충족하였는지 다시 한번 확인한다. 이러한 요구 사항들은 미션마다 다르므로 항상 주의 깊게 읽어 본다.</p>
<h3 id="기본적인-git-명령어를-숙지한다">기본적인 Git 명령어를 숙지한다</h3>
<p>Git은 개발자 간의 협업을 위한 가장 기본적인 프로그램으로, 컴퓨터 파일의 변경 사항을 추적하고 여러 사용자 간의 해당 파일에 대한 작업을 조정한다. 지금은 <strong>add, commit, push</strong> 등의 간단한 명령어만 배워도 충분하지만, Git에 대해 미리 알아두어도 나쁠 것은 없다.</p>
<ul>
<li><a href="https://youtu.be/JsRD2AWxxFg">[10분 깃코톡] 와일더의 Git Commands</a></li>
<li><a href="https://youtu.be/6hdr9PI-3Mg">[10분 테코톡] 주노의 git commands</a></li>
<li><a href="https://youtu.be/jXtUUm92RiQ">[10분 테코톡] 망쵸의 유용한 Git 명령어</a></li>
<li><a href="https://youtu.be/N4hIR6XDKQo">[10분 테코톡] 해시, 다르의 깃 명령어 동작 원리</a></li>
</ul>
<h3 id="git으로-관리할-자원을-고려한다">Git으로 관리할 자원을 고려한다</h3>
<p>Java 코드만 있으면 <strong>.class</strong> 파일을 생성할 수 있다. 따라서 Git을 통해 <strong>.class</strong> 파일을 관리할 필요가 없다. IntelliJ IDEA의 <strong>.idea</strong> 폴더와 Eclipse의 <strong>.metadata</strong> 폴더도 IDE에서 자동으로 생성하는 폴더이므로 Git으로 관리할 필요가 없다. Git에 코드를 추가할 때는 Git을 통해 형상 관리해야 하는 코드인지 고려하는 것이 좋다. 또한 <strong>.gitignore</strong>에 대해서도 알아본다.</p>
<h3 id="커밋-메시지를-의미-있게-작성한다">커밋 메시지를 의미 있게 작성한다</h3>
<p>해당 커밋에서 수행된 작업을 이해할 수 있도록 커밋 메시지를 작성한다. 또한 팀의 좋은 코드 리뷰 문화는 작은 커밋을 작성하는 것에서 비롯된다.</p>
<ul>
<li><a href="https://meetup.toast.com/posts/106">좋은 git 커밋 메시지를 작성하기 위한 7가지 약속</a></li>
</ul>
<h3 id="커밋-메시지에-이슈-또는-풀-리퀘스트-번호를-포함하지-않는다">커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하지 않는다</h3>
<p>일부 프로젝트에서는 작업을 이슈 또는 풀 리퀘스트와 연결하기 위해 커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하기도 한다. 그러나 이 접근 방식은 원본 저장소의 관련 없는 이슈 또는 풀 리퀘스트에 영향을 미칠 수 있다. 따라서 이 과정에서는 커밋 메시지에 이슈 또는 풀 리퀘스트 번호를 포함하지 않는다.</p>
<h3 id="풀-리퀘스트를-만든-후에는-닫지-말고-추가-커밋을-한다">풀 리퀘스트를 만든 후에는 닫지 말고 추가 커밋을 한다</h3>
<p>이미 풀 리퀘스트를 생성하면 변경을 위해 새 풀 리퀘스트를 만들 필요가 없다. 변경이 필요한 경우 추가 커밋을 하면 자동으로 반영된다. 그러므로 미션 제출 기간 이후에는 추가 커밋을 금지한다.</p>
<h3 id="오류를-찾을-때-출력-함수-대신-디버거를-사용한다">오류를 찾을 때 출력 함수 대신 디버거를 사용한다</h3>
<p>디버깅은 프로그램 오류를 감지하고 수정하는 과정이다. 문법 오류와 같이 컴파일러가 처리하기 때문에 쉽게 발견할 수 있는 오류도 있지만, 어느 지점에서 오류가 발생했는지 파악하기 어려운 경우도 있다. 이때 코드 중간에 <strong>System.out.println()</strong>를 사용하여 매번 코드를 실행하여 문제를 파악할 수 있으나, 이는 비효율적이며 불필요한 코드가 남을 수 있다. 하지만 디버거를 이용하면 코드 내부의 상태 값이 어떻게 변하는지, 어떤 흐름으로 프로그램이 실행되는지 이해할 수 있다. 현재 사용 중인 IDE에서 애플리케이션을 디버깅하는 방법을 학습한다.</p>
<ul>
<li><a href="https://youtu.be/gkutTlwi70s">[10분 테코톡] 웨지의 인텔리제이 디버깅</a></li>
<li><a href="https://youtu.be/JSVvhwwOvAY">[10분 테코톡] 오리의 Intellij Debugging</a></li>
<li><a href="https://youtu.be/leIwlemLWNc">[10분 테코톡] 몰리의 디버깅</a></li>
<li><a href="https://code.visualstudio.com/docs/editor/debugging">Debugging in Visual Studio Code</a></li>
</ul>
<h3 id="이름을-통해-의도를-드러낸다">이름을 통해 의도를 드러낸다</h3>
<p>나 자신, 다른 개발자와의 소통을 위해 중요한 활동 중의 하나가 좋은 이름 짓기이다. 변수 이름, 함수(메서드) 이름, 클래스 이름을 짓는데 시간을 투자하라. 이름을 통해 변수의 역할, 함수의 역할, 클래스의 역할에 대한 의도를 드러내기 위해 노력하라. 연속된 숫자를 덧붙이거나(<strong>a1, a2, ..., aN</strong>), 불용어(<strong>Info, Data, a, an, the</strong>)를 추가하는 방식은 적절하지 못하다.</p>
<h3 id="축약하지-않는다">축약하지 않는다</h3>
<p>의도를 드러낼 수 있다면 이름이 길어져도 괜찮다.</p>
<p>누구나 실은 클래스, 메서드, 또는 변수의 이름을 줄이려는 유혹에 곧잘 빠지곤 한다. 그런 유혹을 뿌리쳐라. 축약은 혼란을 야기하며, 더 큰 문제를 숨기는 경향이 있다. 클래스와 메서드 이름을 한 두 단어로 유지하려고 노력하고 문맥을 중복하는 이름을 자제하자. 클래스 이름이 <strong>Order</strong>라면 <strong>shipOrder</strong>라고 메서드 이름을 지을 필요가 없다. 짧게 <strong>ship</strong>이라고 하면 클라이언트에서는 <strong>order.ship()</strong>라고 호출하며, 간결한 호출의 표현이 된다.</p>
<ul>
<li>객체 지향 생활 체조 원칙 5: 줄여쓰지 않는다(축약 금지)</li>
</ul>
<h3 id="공백도-코딩-컨벤션이다">공백도 코딩 컨벤션이다</h3>
<p>if, for, while문 사이의 공백도 코딩 컨벤션이다.</p>
<h3 id="공백-라인을-의미-있게-사용한다">공백 라인을 의미 있게 사용한다</h3>
<p>공백 라인을 의미 있게 사용하는 것이 좋아 보이며, 문맥을 분리하는 부분에 사용하는 것이 좋다. 과도한 공백은 다른 개발자에게 의문을 줄 수 있다.</p>
<h3 id="스페이스와-탭을-혼용하지-않는다">스페이스와 탭을 혼용하지 않는다</h3>
<p>들여쓰기에 스페이스(space)와 탭(tab)을 혼용하지 않는다. 둘 중의 하나만 사용한다. 확신이 서지 않으면 풀 리퀘스트를 작성한 후 들여쓰기가 잘 되어 있는지 확인하는 습관을 들인다.</p>
<h3 id="의미-없는-주석을-달지-않는다">의미 없는 주석을 달지 않는다</h3>
<p>변수 이름, 함수(메서드) 이름을 통해 어떤 의도인지가 드러난다면 굳이 주석을 달지 않는다. 모든 변수와 함수에 주석을 달기보다 가능하면 이름을 통해 의도를 드러내고, 의도를 드러내기 힘든 경우 주석을 다는 연습을 한다.</p>
<h3 id="코드-포매팅을-사용한다">코드 포매팅을 사용한다</h3>
<p>코드 포매팅과 구조화는 클린 코드를 위한 최소한의 요구 사항이다. IDE의 코드 자동 정렬 기능을 사용하면 더 깔끔한 코드를 볼 수 있다.</p>
<ul>
<li>IntelliJ IDEA: ⌥⌘L, Ctrl+Alt+L</li>
<li>Eclipse: ⇧⌘F, Ctrl+Shift+F</li>
<li>Visual Studio Code: ⇧⌥F, Shift+Alt+F</li>
</ul>
<h3 id="java에서-제공하는-api를-적극-활용한다">Java에서 제공하는 API를 적극 활용한다</h3>
<p>함수(메서드)를 직접 구현하기 전에 API에서 해당 함수를 제공하는지 확인한다. 예를 들어 사용자를 출력할 때 사용자가 둘 이상인 경우 쉼표(,) 기반 문자열을 출력하도록 다음과 같이 구현할 수 있다.</p>
<pre><code>var members = List.of(&quot;pobi&quot;, &quot;jason&quot;);
var result = String.join(&quot;,&quot;, members); // pobi,jason</code></pre><h3 id="배열-대신-컬렉션을-사용한다">배열 대신 컬렉션을 사용한다</h3>
<p>컬렉션(<strong>List, Set, Map</strong> 등)을 사용하면 다양한 API를 사용하여 데이터를 조작할 수 있다. 예를 들어 <strong>List<String></strong>에 &quot;pobi&quot; 값이 있는지 다음과 같이 확인할 수 있다.</p>
<pre><code>var members = List.of(&quot;pobi&quot;, &quot;jason&quot;);
var result = members.contains(&quot;pobi&quot;); // true</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[자바] - 날짜와 시간]]></title>
            <link>https://velog.io/@heeun_98/%EC%9E%90%EB%B0%94-%EB%82%A0%EC%A7%9C%EC%99%80-%EC%8B%9C%EA%B0%84</link>
            <guid>https://velog.io/@heeun_98/%EC%9E%90%EB%B0%94-%EB%82%A0%EC%A7%9C%EC%99%80-%EC%8B%9C%EA%B0%84</guid>
            <pubDate>Sat, 29 Nov 2025 09:38:17 GMT</pubDate>
            <description><![CDATA[<h3 id="localdatetime">LocalDateTime</h3>
<ul>
<li><p>LocalDate: 날짜만 표현할 때 사용한다 
ex) <code>2013-11-21</code></p>
</li>
<li><p>LocalTime: 시간만을 표현할 때 사용한다. 시, 분, 초를 다룬다. 
ex) <code>08:20:30.213</code></p>
</li>
<li><p>LocalDateTime: LocalDate 와 LocalTime 을 합한 개념 
ex) <code>2013-11-21T08:20:30.213</code></p>
</li>
</ul>
<pre><code class="language-java">
public class LocalDateMain {
public static void main(String[] args) {

    LocalDate nowDate = LocalDate.now();
    LocalDate ofDate = LocalDate.of(2013, 11, 21);     
    System.out.println(&quot;오늘 날짜 = &quot; + nowDate);     
    System.out.println(&quot;지정 날짜 = &quot; + ofDate);
    //계산(불변)
    LocalDate plusDays = ofDate.plusDays(10); 
    System.out.println(&quot;지정 날짜+10d = &quot; + plusDays);
    } 
}
</code></pre>
<p><strong>실행결과</strong></p>
<pre><code>오늘 날짜 = 2024-02-09
지정 날짜 = 2013-11-21
지정 날짜+10d = 2013-12-01
</code></pre><p><strong>생성</strong></p>
<ul>
<li>now() : 현재 시간을 기준으로 생성한다.</li>
<li>of(. . .) : 특정 날짜를 기준으로 생성한다. 년, 월, 일을 입력할 수 있다.</li>
</ul>
<p><strong>계산</strong></p>
<ul>
<li>plusDays(): 특정 일을 더한다. 다양한 plusXxx() 메서드가 존재하다.</li>
</ul>
<p><strong>모든 날짜는 불변이므로, 변경이 발생하면 새로운 객체를 생성해서 반환한다.</strong></p>
<h3 id="localtime">LocalTime</h3>
<pre><code class="language-java">public class LocalTimeMain {
      public static void main(String[] args) {

        LocalTime nowTime = LocalTime.now();
        LocalTime ofTime = LocalTime.of(9, 10, 30);
        System.out.println(&quot;현재 시간 = &quot; + nowTime); 
        System.out.println(&quot;지정 시간 = &quot; + ofTime);

         LocalTime ofTimePlus = ofTime.plusSeconds(30);
        System.out.println(&quot;지정 시간+30s = &quot; + ofTimePlus);

    } 
}
</code></pre>
<p><strong>실행결과</strong></p>
<pre><code>현재 시간 = 11:52:51.219602 
지정 시간 = 09:10:30
지정 시간+30s = 09:11:00
</code></pre><p><img src="https://velog.velcdn.com/images/heeun_98/post/2cec057f-c588-4631-a9d6-47b3ceb54a10/image.png" alt=""></p>
<p><strong>LocalTime</strong>은 <strong>초</strong>가 항상 나와야 하는건 아니다</p>
<h3 id="localdatetime-1">LocalDateTime</h3>
<p><strong>LocalDateTime 클래스</strong></p>
<pre><code class="language-java">
public class LocalDateTime {
    private final LocalDate date;
    private final LocalTime time;
    ...
}</code></pre>
<pre><code class="language-java">package time;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
public class LocalDateTimeMain {
public static void main(String[] args) {

    LocalDateTime nowDt = LocalDateTime.now();

    LocalDateTime ofDt = LocalDateTime.of(2016, 8, 16, 8, 10, 1); 
    System.out.println(&quot;현재 날짜시간 = &quot; + nowDt); 
    System.out.println(&quot;지정 날짜시간 = &quot; + ofDt);
    //날짜와 시간 분리
    LocalDate localDate = ofDt.toLocalDate(); 
    LocalTime localTime = ofDt.toLocalTime(); 
    System.out.println(&quot;localDate = &quot; + localDate); 
    System.out.println(&quot;localTime = &quot; + localTime);

    //날짜와 시간 합체
    LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);
    System.out.println(&quot;localDateTime = &quot; + localDateTime);
    //계산(불변)
    LocalDateTime ofDtPlus = ofDt.plusDays(1000); 
    System.out.println(&quot;지정 날짜시간+1000d = &quot; + ofDtPlus);         
    LocalDateTime ofDtPlus1Year = ofDt.plusYears(1);             
    System.out.println(&quot;지정 날짜시간+1년 = &quot; + ofDtPlus1Year);
    //비교
    System.out.println(&quot;현재 날짜시간이 지정 날짜시간보다 이전인가? &quot; + nowDt.isBefore(ofDt));
    System.out.println(&quot;현재 날짜시간이 지정 날짜시간보다 이후인가? &quot; +
    nowDt.isAfter(ofDt));

    System.out.println(&quot;현재 날짜시간과 지정 날짜시간이 같은가? &quot; + nowDt.isEqual(ofDt));


}

</code></pre>
<p><strong>실행 결과</strong></p>
<pre><code>현재 날짜시간 = 2024-02-09T11:54:54.389163 
지정 날짜시간 = 2016-08-16T08:10:01 localDate = 2016-08-16
localTime = 08:10:01
localDateTime = 2016-08-16T08:10:01
지정 날짜시간+1000d = 2019-05-13T08:10:01 
지정 날짜시간+1년 = 2017-08-16T08:10:01 
현재 날짜시간이 지정 날짜시간보다 이전인가? false 
현재 날짜시간이 지정 날짜시간보다 이후인가? true 
현재 날짜시간과 지정 날짜시간이 같은가? false</code></pre><h2 id="기간-시간의-간격---duration-period">기간, 시간의 간격 - Duration, Period</h2>
<p>시간의 개념은 크게 2가지</p>
<ul>
<li><p>특정 시간의 시간(시각)</p>
<ul>
<li>이 프로젝트는 2013년 8월 16일 까지 완료해야해</li>
<li>다음 회의는 11시 30분에 진행한다.</li>
</ul>
</li>
<li><p>시간의 간격(기간)</p>
<ul>
<li>앞으로 4년은 더 공부해야 해</li>
<li>이 프로젝트는 3개월 남았어</li>
<li>라면은 3분 동안 끓어야 해</li>
</ul>
</li>
</ul>
<p>Period, Duration 은 시간의 <strong>간격</strong> 을 표현하는데 사용된다.
시간의 간격은 영어로 amount of time(시간의 양)으로 불린다.</p>
<p><strong>Period</strong>
두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다.</p>
<ul>
<li>이 프로젝트는 3개월 정도 걸릴 것 같아</li>
<li>기념일이 183일 남았어</li>
</ul>
<p><strong>Duration</strong>
두 시간 사이의 간격을 시, 분, 초(나노초) 단위로 나타낸다.</p>
<ul>
<li>라면을 끓이는 시간은 3분이야</li>
<li>영화 상영 시간은 2시간 30분이야</li>
<li>서울에서 부산까지는 4시간이 걸려</li>
</ul>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/627524e1-15a7-4b89-8ef0-f8de9ce19851/image.png" alt=""></p>
<p><strong>Period</strong></p>
<p>두 날짜 사이의 간격을 년,월,일 단위로 나타낸다.</p>
<pre><code class="language-java">
 public class Period {
      private final int years;
      private final int months;
      private final int days;
}
</code></pre>
<pre><code class="language-java"> package time;
  import java.time.LocalDate;
  import java.time.Period;
  public class PeriodMain {
public static void main(String[] args) { 

    //시간, 분, 초, 나노초 toHours(), toMinutes(), getSeconds(), getNano()
    //생성
    Period period = Period.ofDays(10);
    System.out.println(&quot;period = &quot; + period);
    //계산에 사용
    LocalDate currentDate = LocalDate.of(2030, 1, 1);
    LocalDate plusDate = currentDate.plus(period);
    System.out.println(&quot;현재 날짜: &quot; + currentDate);
    System.out.println(&quot;더한 날짜: &quot; + plusDate);
    //기간 차이
    LocalDate startDate = LocalDate.of(2023, 1, 1);
    LocalDate endDate = LocalDate.of(2023, 4, 2);
    Period between = Period.between(startDate, endDate);
    System.out.println(&quot;기간: &quot; + between.getMonths() + &quot;개월 &quot; + between.getDays() +     &quot;일&quot;); 
    }
}
</code></pre>
<p><strong>Period</strong> 생성 방법</p>
<ul>
<li><code>of()</code>: 특정 기간을 지정해서 <code>Period</code> 를 생성한다.<ul>
<li><code>of(년, 월, 일)</code></li>
<li><code>ofDays()</code></li>
<li><code>ofMonths()</code></li>
<li><code>ofYears()</code></li>
</ul>
</li>
</ul>
<p><strong>계산에 사용</strong></p>
<ul>
<li>2030 1월 1일에 10일을 더하면 2030년 1월 11일이 된다.라고 표현할 때 특정 날짜에 10일 이라는 기간을 더할수 있다.</li>
</ul>
<p><strong>기간 차이</strong></p>
<ul>
<li>2032년 1월 1일과 2023년 4월 2일간의 차이는 3개월 1일이다. 라고 표현할 때 특정 날짜의 차이를 구하면 기간이 된다.</li>
<li><code>Period.between(startDate, endDate)</code> 와 같이 특정 날짜의 차이를 구하면 Period 가 반환된다.</li>
</ul>
<h3 id="duration">Duration</h3>
<p>두 시간 사이의 간격을 시, 분, 초 단위로 나타낸다.</p>
<pre><code class="language-java">
public class Duration {
      private final long seconds;
      private final int nanos;
 }
</code></pre>
<p>내부에서 초를 기반으로 시, 분, 초를 계산해서 사용한다.</p>
<ul>
<li>1분 = 60초</li>
<li>1시간 = 3600초</li>
</ul>
<pre><code class="language-java">
package time;
  import java.time.Duration;
  import java.time.LocalTime;
  public class DurationMain {
    public static void main(String[] args) { 
    //생성
    Duration duration = Duration.ofMinutes(30);
    System.out.println(&quot;duration = &quot; + duration);
    LocalTime lt = LocalTime.of(1, 0);
    System.out.println(&quot;기준 시간 = &quot; + lt);
    //계산에 사용
    LocalTime plusTime = lt.plus(duration); 
    System.out.println(&quot;더한 시간 = &quot; + plusTime);
    //시간 차이
    LocalTime start = LocalTime.of(9, 0);
    LocalTime end = LocalTime.of(10, 0);
    Duration between = Duration.between(start, end);
    System.out.println(&quot;차이: &quot; + between.getSeconds() + &quot;초&quot;);
    System.out.println(&quot;근무 시간: &quot; + between.toHours() + &quot;시간 &quot; +
    between.toMinutesPart() + &quot;분&quot;); 
    }
}
</code></pre>
<p><strong>실행 결과</strong></p>
<pre><code>duration = PT30M
기준 시간 = 01:00
더한 시간 = 01:30
차이: 3600초
근무 시간: 1시간 0분</code></pre><p><strong>Duration 생성 방법</strong></p>
<ul>
<li>of(): 특정 시간을 지정해서 Duration을 생성한다.<ul>
<li>of(지정)</li>
<li>ofSeconds()</li>
<li>ofMinutes()</li>
<li>ofHours()</li>
</ul>
</li>
</ul>
<p><strong>계산에 사용</strong></p>
<ul>
<li>1:00에 30분을 더하면 1:30이 된다.라고 표현할 때 특정 시간에 30분이라는 시간(시간의 간격)을 더할 수 있다.</li>
</ul>
<p><strong>시간 차이</strong></p>
<ul>
<li>9시와 10시의 차이는 1시간이라고 표현할 때 시간의 차이를 구할 수 있다.</li>
<li>Duration.between(start, end)와 같이 특정 시간의 차이를 구하면 Duration이 반환된다.</li>
</ul>
<hr>
<h3 id="chronounit-vs-chrnonofield">ChronoUnit VS ChrnonoField</h3>
<ul>
<li><p>ChronoUnit → 시간의 간격(기간, 단위) 을 의미.</p>
<ul>
<li><p>예: 3일 차이 계산, 2시간 더하기</p>
</li>
<li><p>즉, “기간 단위”</p>
</li>
</ul>
</li>
<li><p>ChronoField → 시간 필드의 값 을 읽고 쓸 때 사용.</p>
<ul>
<li><p>예: 연도 읽기, 월 읽기, 분 읽기</p>
</li>
<li><p>즉, “연도/월/일/시/분 같은 필드”</p>
</li>
</ul>
</li>
</ul>
<p><strong>ChronoUnit은 단순히 ‘시간의 단위’를 의미하는 것이고,
날짜·시간의 ‘구체적인 필드 값’을 조회하려면 ChronoField를 써야 한다.</strong></p>
<h4 id="1-chronounit--단위기간을-나타낸다">1. ChronoUnit — 단위(기간)을 나타낸다</h4>
<pre><code class="language-java">long days = ChronoUnit.DAYS.between(date1, date2);
long hours = ChronoUnit.HOURS.between(time1, time2);

//차이 구하기
LocalTime lt1 = LocalTime.of(1, 10, 0);
LocalTime lt2 = LocalTime.of(1, 20, 0);


//중요
long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
System.out.println(&quot;secondsBetween = &quot; + secondsBetween);
long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);
System.out.println(&quot;minutesBetween = &quot; + minutesBetween);</code></pre>
<p>여기서 DAYS, HOURS 는 필드 값이 아니라 시간 간격 단위일 뿐이다.</p>
<ul>
<li><code>long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);</code> <ul>
<li>이 방식은 거의 암기를 할 정도로 잘 알고 있자</li>
<li>몇 분 차이가 나는지 확인하는 API</li>
</ul>
</li>
</ul>
<ul>
<li><p>“며칠 차이인지”</p>
</li>
<li><p>“몇 시간 차이인지”</p>
</li>
<li><p>“몇 분 차이인지”</p>
</li>
<li><p>“plusDays(3)”이나 “plusHours(2)” 같은 연산</p>
</li>
</ul>
<p>에 쓰인다.</p>
<p>✔️ 즉, <strong>ChronoUnit은 날짜·시간의 &quot;양(수량)&quot;</strong>을 다루는 것</p>
<h4 id="2-chronofield--날짜·시간-필드를-읽고-쓸-때-사용">2. ChronoField — 날짜·시간 &quot;필드&quot;를 읽고 쓸 때 사용</h4>
<pre><code class="language-java">LocalDateTime now = LocalDateTime.now();

int year = now.get(ChronoField.YEAR);
int month = now.get(ChronoField.MONTH_OF_YEAR);
int minute = now.get(ChronoField.MINUTE_OF_HOUR);
</code></pre>
<ul>
<li><p>올해가 몇 년인지</p>
</li>
<li><p>이번 달이 몇 월인지</p>
</li>
<li><p>현재 분(minute)이 몇 분인지</p>
</li>
<li><p>요일이 몇 번째 요일인지</p>
</li>
</ul>
<p>이런 “필드 값”을 읽을 때 쓰는 게 ChronoField야.</p>
<p>✔️ 즉, ChronoField는 날짜·시간을 구성하는 <strong>각 조각(필드)</strong>을 의미하는 것.</p>
<hr>
<p>날짜와 시간 문자열 파싱과 포맷팅</p>
<ul>
<li><p>포맷팅 : 날짜와 시간 데이터를 원하는 포맷의 문자열로 변경 (Date -&gt; String)</p>
</li>
<li><p>파싱: 문자열을 날짜와 시간 데이터로 변경하는 것, (String -&gt; Date)</p>
</li>
</ul>
<pre><code class="language-java">
package time.ex;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class FormattingMain2 {

    public static void main(String[] args) {

        //포맷팅 : 날짜와 시간을 문자로
        LocalDateTime now = LocalDateTime.of(2024, 12, 31, 13, 30, 59);

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy년MM월dd일 HH시mm분ss초&quot;);

        String format = now.format(formatter);

        System.out.println(format);



        //파싱
        String date = &quot;2025년11월29일 09시49분32초&quot;;

        DateTimeFormatter formatter1 = DateTimeFormatter.ofPattern(&quot;yyyy년MM월dd일 HH시mm분ss초&quot;);

        LocalDateTime dateTime = LocalDateTime.parse(date, formatter1);

        LocalTime time = dateTime.toLocalTime();

        System.out.println(time);

    }

}




</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[우테코 - [회고] 3주차]]></title>
            <link>https://velog.io/@heeun_98/%EC%9A%B0%ED%85%8C%EC%BD%94-%ED%9A%8C%EA%B3%A0-3%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@heeun_98/%EC%9A%B0%ED%85%8C%EC%BD%94-%ED%9A%8C%EA%B3%A0-3%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Mon, 17 Nov 2025 07:42:34 GMT</pubDate>
            <description><![CDATA[<h2 id="3주차-미션-회고">3주차 미션 회고</h2>
<p>3주차를 마치고 회고록을 써야겠다고 다짐했지만 정처기와, 오픈 미션에 하느라 늦게 작성하게 되었습니다.(핑계)
2주차 미션을 진행할 때부터 테스트 코드 작성 시점에 대한 고민이 많았습니다.
하나의 기능을 구현하고 곧바로 테스트 코드를 작성할 경우, 리팩토링이 많은 저에게는 테스트 코드와 패키지 구조 또한 함께 바뀔 것으로 예상되어 리팩토링 시간이 두 배로 늘어날 것 같았기 때문입니다.
그래서 2주차 미션 때는 이런 이유로 기능 구현 완료 후 테스트 코드를 작성했지만, 3주차 미션에서는 새로운 방식을 시도해 보기로 다짐했습니다.</p>
<h4 id="테스트-코드의-의도에-대한-재고찰">테스트 코드의 의도에 대한 재고찰</h4>
<p>3주차 미션을 수행하면서 저는 테스트 코드의 의도와 의미에 대해 다시 생각해 보았습니다.
기능을 한 단계씩 구현하면서 <strong>“내가 잘 구현한 걸까?”라는 불안</strong>을 테스트 코드를 통과함으로써 바로 확인할 수 있다는 점이 테스트 코드의 가장 큰 장점이라고 느꼈습니다.
그래서 이번에는 테스트 코드의 의도를 직접 느끼기 위해 <strong>하나의 기능을 완성할 때마다 곧바로 테스트 코드를 작성하는 방식</strong>을 적용했습니다.</p>
<h4 id="작게-나누어-구현하며-느낀-변화">작게 나누어 구현하며 느낀 변화</h4>
<p>요구사항을 읽고 리팩토링 시 변경 가능성이 적은 작은 단위부터 구현을 시작했습니다.
처음에는 기능을 하나씩 만들고 테스트 코드를 작성하니 시간이 두 배 이상 걸렸습니다.
하지만 이 방식을 계속 적용하다 보니 테스트 코드를 작성하기 편한 구조로 기능을 구현하게 되었고,</p>
<p> “이렇게 구현하면 테스트 코드 작성이 더 편하겠지?” 는 질문을 자연스럽게 던지게 되었습니다.</p>
<p>예를 들어,</p>
<ul>
<li>메서드 내부에서 다른 클래스의 메서드를 통해 값을 가져오는 대신</li>
<li><strong>메서드 매개변수로 값을 전달받는 구조</strong>로 변경했습니다.</li>
</ul>
<p>그 결과, 테스트 코드에서 원하는 인자를 직접 넘겨 테스트할 수 있었습니다.</p>
<p>물론 mock을 사용할 수도 있었지만, 저는 <strong>순수 자바만으로 검증하는 과정</strong>이 클린 코드 원칙을 지키고 있는지 확인할 수 있는 좋은 지표라고 생각했습니다.</p>
<hr>
<h3 id="테스트-작성-시점에-대한-스터디-의견">테스트 작성 시점에 대한 스터디 의견</h3>
<p>3주차 미션을 수행하기 전, 다른 분들은 테스트 코드를 언제 작성하는지 궁금해 스터디원들과 의견을 나누었습니다.</p>
<ul>
<li>어떤 분은 <strong>기능 구현 전에 테스트 코드를 작성</strong>하는 분도 있었고</li>
<li>또 다른 분은 저처럼 <strong>기능 완성 후 안정화 과정에서 테스트를 작성</strong>한다고 했습니다.</li>
</ul>
<p>이야기를 나누며 느낀 점은,</p>
<blockquote>
<p><strong>테스트 작성 시점에는 정답이 없다</strong></p>
</blockquote>
<p>는 것이었습니다.</p>
<p>중요한 것은 ‘언제 작성하느냐’가 아니라,
<strong>테스트를 통해 무엇을 확인하고 싶은지 명확히 아는 것</strong>이라고 생각했습니다.</p>
<hr>
<h3 id="단위-테스트에-대한-깨달음">단위 테스트에 대한 깨달음</h3>
<p>2주차에 이어 “큰 테스트보다 단위 테스트를 작성하라”는 피드백에 대해 생각해보았습니다.</p>
<p>제가 느낀 단위 테스트의 강점은 다음과 같았습니다.</p>
<ul>
<li>변경에 유연하다</li>
<li>리팩터링 후 수정할 가능성이 적다</li>
</ul>
<p>실제로 리팩토링 중</p>
<ul>
<li>작은 단위 테스트는 거의 수정이 필요 없었고</li>
<li>반면 많은 기능을 포괄하는 테스트는 자주 깨졌습니다.</li>
</ul>
<p>또한,</p>
<blockquote>
<p>하나의 메서드에 단위 테스트가 3개 이상 붙는다는 것은
<strong>그 메서드가 너무 많은 일을 하고 있다는 신호</strong></p>
</blockquote>
<p>라고 느꼈습니다.</p>
<p>그럴 때마다 자연스럽게 리팩터링을 진행했습니다.
그리고 모든 테스트가 통과해 초록불이 켜지는 순간,
콘솔 입력 테스트를 하던 때와 비교할 수 없을 만큼 <strong>코드에 대한 확신이 생겼습니다.</strong></p>
<hr>
<h3 id="가장-많은-시간을-들인-부분--잘못된-입력-재시도-처리">가장 많은 시간을 들인 부분 — 잘못된 입력 재시도 처리</h3>
<p>이번 미션에서 가장 시간을 많이 들인 부분은 <strong>입력 오류 발생 시 재시도 처리</strong>였습니다.</p>
<p>처음에는</p>
<ul>
<li>금액</li>
<li>로또 번호</li>
<li>보너스 번호</li>
</ul>
<p>각각에서 예외 발생 시 try-catch로 처리하는 코드를 작성했습니다.
하지만 이는 <strong>중복된 로직이 3곳이나 존재하는 문제</strong>가 있었습니다.</p>
<p>그래서 “재시도 코드를 한 곳에서 관리할 수는 없을까?”라는 고민을 시작했습니다.</p>
<p>입력 전체를 try문으로 감싸 while문으로 반복하는 방식도 시도했지만,
이 경우 보너스 번호에서 예외가 발생하면 <strong>금액부터 다시 입력해야 하는 문제</strong>가 있었습니다.
요구사항은 <strong>“잘못된 입력만 다시 받기”</strong>였기 때문에 이 방식은 적절하지 않았습니다.</p>
<p>그래서 입력별로(금액, 로또 번호, 보너스 번호) 클래스를 분리하고,
<strong>다형성과 오버라이딩을 이용해 재시도 로직을 하나의 흐름에서 처리할 수 있는 구조</strong>를 고민했습니다.</p>
<p>그리고 재시도 전용 객체를 만들어,
그 안에서 구체적인 입력 클래스를 호출하도록 구조를 개선했습니다.</p>
<p>이 과정 덕분에</p>
<ul>
<li>중복 코드가 크게 줄었고</li>
<li>다형성과 추상화의 진짜 의미를 체감할 수 있었습니다.</li>
</ul>
<hr>
<h3 id="객체-내부에서-검증하는-구조에-대한-인사이트">객체 내부에서 검증하는 구조에 대한 인사이트</h3>
<p>3주차를 시작하기 전, 저는 검증 로직을 Validator 클래스에 따로 두는 것이 맞다고 생각했습니다.
실제로 다른 사람의 코드 리뷰에서도 그렇게 피드백하곤 했습니다.</p>
<p>하지만 이번 미션에서 제공된 <strong>Lotto 클래스를 보고 생각이 완전히 바뀌었습니다.</strong></p>
<ul>
<li>Lotto 내부에 validate()가 존재하고</li>
<li>Lotto가 스스로 가진 numbers 리스트에 대한 검증을 수행하는 구조였기 때문입니다.</li>
</ul>
<p>이를 보며 느꼈습니다.</p>
<blockquote>
<p><strong>데이터를 가장 잘 아는 객체가 스스로를 검증하는 구조가
응집도가 높고 의도가 명확하다.</strong></p>
</blockquote>
<p>또한 이 구조가 프리코스 1주차의 체크리스트에 있던
<strong>‘일급 컬렉션’</strong> 개념과 동일하다는 것도 깨달았습니다.</p>
<p>이 경험을 통해 <strong>일급 컬렉션이 왜 중요한지, 언제 쓰는지, 어떤 장점이 있는지</strong> 직접 이해할 수 있었습니다.</p>
<hr>
<h3 id="문서화에-대한-어려움과-앞으로의-목표">문서화에 대한 어려움과 앞으로의 목표</h3>
<p>2주차 공통 피드백이었던 README.md 작성은 아직 익숙하지 않았습니다.
마크다운 형식 자체도 처음이라, 가독성 있게 정리하는 방법을 배우기 위해 여러 사람의 README를 참고했습니다.</p>
<p>특히 문서화를 어려워하는 저에게는 더더욱 도전이었지만,
남은 프리코스 기간에는 시간이 더 걸리더라도 <strong>꾸준히 문서화 연습을 하고 싶습니다.</strong></p>
<p>README와 Pull Request에 글을 꾸준히 작성하며,</p>
<ul>
<li>남에게 읽히는 글쓰기</li>
<li>코드 의도를 설명하는 글쓰기</li>
<li>문서화에 대한 자신감</li>
</ul>
<p>을 개발자로서의 목표로 삼고 싶습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AOP] 하나의 프록시, 여러 Advisor]]></title>
            <link>https://velog.io/@heeun_98/Spring-AOP-%ED%95%98%EB%82%98%EC%9D%98-%ED%94%84%EB%A1%9D%EC%8B%9C-%EC%97%AC%EB%9F%AC-Advisor</link>
            <guid>https://velog.io/@heeun_98/Spring-AOP-%ED%95%98%EB%82%98%EC%9D%98-%ED%94%84%EB%A1%9D%EC%8B%9C-%EC%97%AC%EB%9F%AC-Advisor</guid>
            <pubDate>Mon, 20 Oct 2025 16:59:37 GMT</pubDate>
            <description><![CDATA[<p><strong>어떤 스프링 빈이 advisor1, advisor2 가 제공하는 포인트컷의 조건을 모두 만족하면 프록시 자동 생성기는 프록시를 몇개 생성할까?</strong></p>
<ul>
<li>프록시 자동 생성기는 프록시를 하나만 생성한다.
프록시 팩터리가 생성하는 프록시는 내부에 여러 Advisor등을 포함 할 수 있기 때문이다.</li>
</ul>
<p><strong>&lt;여러 프록시를 사용했을때&gt;</strong></p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/20196790-3d70-4c6b-9b98-b2c3360b601f/image.png" alt=""></p>
<p>만약에, 적용해야할 advisor가 10개라면 프록시를 10개 생성해야한다.
이 방법은 매우 비효율적이다.</p>
<p>Spring은 이를 해결하기위해 &#39;<strong>프록시 팩토리</strong>&#39; 객체를 지원한다.</p>
<p>아래의 코드를 보자</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;하나의 프록시, 여러 어드바이저&quot;) void multiAdvisorTest2() {
     //proxy -&gt; advisor2 -&gt; advisor1 -&gt; target
     DefaultPointcutAdvisor advisor2 
     = new DefaultPointcutAdvisor(Pointcut.TRUE,
 new Advice2());
     DefaultPointcutAdvisor advisor1 
     = new DefaultPointcutAdvisor(Pointcut.TRUE,
 new Advice1());
     ServiceInterface target = new ServiceImpl();

     ProxyFactory proxyFactory1 = new ProxyFactory(target);
     proxyFactory1.addAdvisor(advisor2);
     proxyFactory1.addAdvisor(advisor1);

     ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();
//실행
     proxy.save();
}
</code></pre>
<p>코드를 보면 <code>proxyFactory.addAdvisor(adviosr1);</code>   , <code>proxyFactory.addAdvisor(adviosr2);</code> 를 통해 두개의 advisor 를 추가하는 코드를 확인할 수 있다. </p>
<p>자동 프록시 생성기는 모든 Advisor 빈들을 조회하고 pointcut의 프록시 적용 대상을 체크한다.</p>
<ul>
<li><code>advisor1</code> 의 포인트컷만 만족 프록시1개 생성, 프록시에 <code>advisor1</code> 만 포함</li>
<li><code>advisor1</code> , <code>advisor2</code> 의 포인트컷을 모두 만족 프록시1개 생성, 프록시에 <code>advisor1</code>,<code>advisor2</code> 모두 포함</li>
<li><code>advisor1</code> , <code>advisor2</code> 의 포인트컷을 모두 만족하지 않음 프록시가 생성되지 않음</li>
</ul>
<p><strong>자동 프록시 생성기</strong>
<img src="https://velog.velcdn.com/images/heeun_98/post/97c49bd7-f2b8-40dd-821d-6a065e56f668/image.png" alt=""></p>
<p>여기서 가장 중요한것은 &#39;빈&#39;으로 등록되는것은 real객체가 아니라 &#39;<strong>프록시 객체</strong>&#39; 라는점이다.</p>
<p>두개의 advisor의 포인트컷을 만족 시킬 경우 다음 그림과 같다
<img src="https://velog.velcdn.com/images/heeun_98/post/cb2d0ca2-1846-4cf4-ac1b-824934cfea4d/image.png" alt=""></p>
<p>프록시를 생성하는 &#39;주체&#39;는 프록시 팩토리이고
자동 프록시 생성기는 <strong>프록시 팩토리를</strong> 이용해서 프록시를 생성한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JVM21에 등장한 Virtual Thread (Context Switching Test)]]></title>
            <link>https://velog.io/@heeun_98/JVM21%EC%97%90-%EB%93%B1%EC%9E%A5%ED%95%9C-Virtual-Thread-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/JVM21%EC%97%90-%EB%93%B1%EC%9E%A5%ED%95%9C-Virtual-Thread-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 07 Oct 2025 06:55:13 GMT</pubDate>
            <description><![CDATA[<p>Virtual Thread 를 알아보기전에 기존의 Native Thread 의 context switching 이 발생하는 원리에 대해 알아보자</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/96766976-fb7f-4b8e-90e8-a1d54658aa1d/image.png" alt=""></p>
<p>기존의 JVM 에 존재하는 톰캣 스레드는 커널스레드와 1대1로 매핑되어 실행이 되는 구조이다.
지금까지 CPU 코어가 톰캣 스레드를 context switching 하여 스케줄링을 하는 구조라고 알고있었지만 , 그것은 커널스레드와 Native Thread가 1대1 대응이 되기때문에 중간과정이 생력된 것이였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/12048cd0-ce69-4b86-b6f7-79f6347e923d/image.png" alt=""></p>
<p>운영체제 시간에 배웠듯이 context switching 이란
스레드가 sleep, I/O 처리 등을 진행할 동안 다른 OS 커널 스레드는 다른 Thread를 점유하여 작업하는 것을 말한다.</p>
<p>스레드는 프로세스의 공통영역을 제외하고 만들어지기 때문에, 프로세스에 비해 크기가 작아서 생성 비용이 적고, 컨텍스트 스위칭 비용이 저렴했기 때문에 주목받아 왔다.</p>
<p>그러나, 요청량이 급격하게 증가하는 서버 환경에서는 갈수록 더 많은 스레드 수를 요구하게 되었다. 스레드의 사이즈가 프로세스에 비해 작다고 해도, 스레드 1개당 1MB 사이즈라고 가정하면, 4GB 메모리 환경에서도 많아야 4,000개의 스레드를 가질 수 있다. 이처럼 메모리가 제한된 환경에서는 생성할 수 있는 스레드 수에 한계가 있었고, 스레드가 많아지면서 컨텍스트 스위칭 비용도 기하급수적으로 늘어나게 되었다.</p>
<p>Context Switching 과정에서 발생하는 과정은 생력하겠지만,간단하게 말하면</p>
<p>1.커널이 개입해서 현재 스레드의 상태를 <strong>PCB(Process Control Block) 에 저장</strong></p>
<p>2.새 스레드의 PCB를 불러와서 <strong>레지스터, 스택 포인터, 프로그램 카운터(PC)</strong> 복원</p>
<p>3.<strong>TLB flush, CPU 캐시 무효화, 유저↔커널 모드 전환</strong></p>
<p>이런 전체 과정이 전부 커널 모드에서 수행되기 때문에
CPU 캐시, TLB, 커널 진입 비용이 발생합니다.
결과적으로 스위칭 하나당 수 µs 이상 걸립니다.</p>
<p>서버는 더 많은 요청 처리량과 컨텍스트 스위칭 비용을 줄여야 했는데, 이를 위해 나타난 스레드 모델이 <strong>경량 스레드 모델인 Virtual Thread</strong>입니다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/32cfe4d1-3cc7-49dd-8b0a-1b05f8907464/image.png" alt=""></p>
<p>Virtual Thread는 기존 Java의 스레드 모델과 달리, 플랫폼 스레드와 가상 스레드로 나뉜다. 플랫폼 스레드 위에서 여러 Virtual Thread가 번갈아 가며 실행되는 형태로 동작합니다. 마치 커널 스레드와 유저 스레드가 매핑되는 형태랑 비슷하다.
가장 <strong>큰 특징은 Virtual Thread는 컨텍스트 스위칭 비용이 저렴</strong>하다는 것이다.</p>
<p>Thread는 기본적으로 최대 2MB의 스택 메모리 사이즈를 가지기 때문에, 컨텍스트 스위칭 시 메모리 이동량이 큽니다. 또한 생성을 위해선 커널과 통신하여 스케줄링해야 하므로, 시스템 콜을 이용하기 때문에 생성 비용도 적지 않다</p>
<p>하지만 Virtual Thread는 JVM에 의해 생성되기 때문에 시스템 콜과 같은 커널 영역의 호출이 적고, 메모리 크기가 일반 스레드의 1%에 불과합니다. 따라서 Thread에 비해 컨텍스트 스위칭 비용이 적다</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/7a6c5607-9cca-4c3c-b281-4b7c95c54308/image.png" alt=""></p>
<ol>
<li>실행될 virtual thread의 작업인 runContinuation을 carrier thread의 workQueue에 push 합니다.</li>
<li>Work queue에 있는 runContinuation들은 forkJoinPool에 의해 work stealing 방식으로 carrier thread에 의해 처리됩니다.</li>
<li>처리되던 runContinuation들은 I/O, Sleep으로 인한 interrupt나 작업 완료 시, work queue에서 pop되어 park과정에 의해 다시 힙 메모리로 되돌아갑니다.</li>
</ol>
<p>*<em>용어 정리 *</em>:</p>
<p><strong>Platfrom Thread</strong> : 톰캣 스레드, new Thread()로 만든 스레드, <strong>즉  OS 커널스레드와 1대1 대응하는 스레드는 모두 Platfrom Thread이다.</strong>
<strong>Carrier Thread</strong>: Platfrom Thread 의 한종류, Virtual Thread를 실행시키는 스레드이다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/f02bb0ce-8d5a-42c5-ab6b-2d058250ef39/image.png" alt="">
JDK21 에서의 Park() 하는 과정이다.
기존의 Thread가 Virtual 인지 일반 Thread 인지 확인을 한다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/6e27f242-ff51-4c28-a48f-f893dd14b7b3/image.png" alt=""></p>
<p>unpark()를 하는 과정을 보면
<code>vthread.switchToCarrierThread()</code> 함수를 볼수있다.
이 과정에서 UNPARKED 된 Virtual Thread 를 unmound()하고 다른 기존의 Carrier 스레드에게 mount()하는 과정이 발생한다.</p>
<hr>
<h3 id="platform-thread-vs-virtual-thread-성능-비교">Platform Thread VS Virtual Thread 성능 비교</h3>
<p>context switching 비용에서 발생하는 성능테스트를 위해서
docker compose 를 비용해 <strong>CPU 코어수 1개</strong>, 스레드당 <strong>sleep(30ms)</strong> 을 가정하고 테스트를 진행하였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/cdb9f9cc-0df3-4a12-88f9-03b7d4a870c6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/11396cba-80e2-4b01-a205-83e3bf1b469c/image.png" alt=""></p>
<p><strong>&lt;기존 PlatFrom Thread인 경우&gt;</strong>
<img src="https://velog.velcdn.com/images/heeun_98/post/c63c5fc0-8d56-40c9-8720-58a389ef7db6/image.png" alt=""></p>
<p><strong>&lt;기존 Virtaul Thread인 경우&gt;</strong>
<img src="https://velog.velcdn.com/images/heeun_98/post/689c7569-2999-4b73-bb26-ae14428b09a6/image.png" alt=""></p>
<p>가상의 유저를 500으로 하였을경우 위와 같은 결과가 발생하였지만
더 많은 트래픽이 발생할수록(Context Switching이 많이 발생) 할수록 성능차이가 많이 날것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE 를 이용한 이벤트 전송]]></title>
            <link>https://velog.io/@heeun_98/SSE-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%A0%84%EC%86%A1</link>
            <guid>https://velog.io/@heeun_98/SSE-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%A0%84%EC%86%A1</guid>
            <pubDate>Mon, 29 Sep 2025 08:48:57 GMT</pubDate>
            <description><![CDATA[<p>SSE 는 그냥 단순하게 단향방 통신에서 사용된다 이정도만 알고 사용하였다.
그저 HTTP에서 지원해는 기능? 정도 구나라고 생각했지만
<code>HTTP가 지원하는 기능</code>이라기보다는
<code>HTTP 위에 정의된 이벤트 전송 규약(HTML5 표준)</code>이라고 한다.
<strong>SSE는 HTTP 위에서 동작하는 표준화된 이벤트 스트리밍 방식이다.</strong></p>
<p>그래서 별도의 라이브러리 없이 사용할 수 있나보다....</p>
<h3 id="클라이언트-→-서버-구독-요청">클라이언트 → 서버 구독 요청</h3>
<pre><code class="language-javascript">const eventSource = new EventSource(&quot;/subscribe&quot;);
</code></pre>
<pre><code>
GET /subscribe HTTP/1.1
Host: api.example.com
Accept: text/event-stream

</code></pre><p>브라우저는 /subscribe에 HTTP GET 요청을 보낸다.
client 가 보내는 단순 GET 요청에 Accept: <code>text/event-stream</code> 헤더 포함만 하는것이다. <strong>(단순 요청)</strong></p>
<h3 id="서버-→-클라이언트-응답-sseemitter-반환">서버 → 클라이언트 응답 (SseEmitter 반환)</h3>
<pre><code class="language-java">    @GetMapping(value = &quot;/api/subscribe&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public SseEmitter subscribe(@AuthenticationPrincipal UserDetails userDetails,
                                // Last-Event-ID 헤더는 마지막으로 받은 이벤트부터 이벤트 스트리밍을 재개하는 데 사용됩니다.
                                @RequestHeader(value = &quot;Last-Event-ID&quot;, required = false, defaultValue = &quot;&quot;)
                                String lastEventId) {

        return sseNotificationService.subscribe(userDetails.getUsername(), lastEventId);

    }
</code></pre>
<pre><code>
HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
</code></pre><p>이때 컨트롤러가 <strong>SseEmitter</strong>를 반환하면
Spring MVC가 HTTP 응답을 끊지 않고 열린 스트림으로 바꿔줍니다.</p>
<ul>
<li>SseEmitter 는 간단하게 말하면 <strong>Event-stream 의 파이프역할(통로)</strong> 을 한다고 생각하면 쉽다.</li>
<li>Client 는 최초에 받은 <strong>SseEmitter</strong>(파이프) 를 통해서 이벤트를 통해서 받으면 된다.</li>
</ul>
<p>여기서 궁금한게 컨트롤러에서 단순하게 SSeEmitter 를 반환했을 뿐인데
헤더에 <code>Content-Type: text/event-stream</code>을 반환 하는것일까 ?</p>
<h4 id="spring-mvc-가-요청응답의-헤더를-추가해주는-과정을-찾아보았다">Spring MVC 가 요청응답의 헤더를 추가해주는 과정을 찾아보았다.</h4>
<p><strong>1. DispatcherServlet</strong></p>
<pre><code class="language-java">
// DispatcherServlet 내부
returnValueHandler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
</code></pre>
<p><strong>2. Handler 선택</strong></p>
<ul>
<li><code>SseEmitter</code>는 <code>ResponseBodyEmitter</code>를 상속</li>
<li>그래서 <code>ResponseBodyEmitterReturnValueHandler</code>가 선택됨</li>
</ul>
<pre><code class="language-java">public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodReturnValueHandler {
    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return ResponseBodyEmitter.class.isAssignableFrom(returnType.getParameterType());
    }
}</code></pre>
<p><strong>3. ResponseBodyEmitterReturnValueHandler 실행</strong></p>
<pre><code class="language-java">
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
        ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {

    mavContainer.setRequestHandled(true); // DispatcherServlet이 view 렌더링 안 함

    HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
    response.setHeader(&quot;Cache-Control&quot;, &quot;no-store&quot;);
    response.setContentType(&quot;text/event-stream;charset=UTF-8&quot;); // 🔹 여기서 헤더 세팅

    ResponseBodyEmitter emitter = (ResponseBodyEmitter) returnValue;
    emitter.initialize(response); // Emitter와 응답 OutputStream 연결
}

</code></pre>
<p><strong>4. Emitter 동작</strong></p>
<ul>
<li><p>이제 컨트롤러에서 반환한 <code>SseEmitter</code> 객체는 응답 <code>OutputStream</code>에 바인딩됨</p>
</li>
<li><p>이후 다른 스레드에서 <code>emitter.send(...)</code> 하면 → <code>response stream</code>에 바로 쓰여 나갑니다.</p>
</li>
<li><p>HTTP 응답은 <code>200 OK</code> 상태로 열려 있고, 끊어지지 않음</p>
</li>
</ul>
<p><code>SseEmitter.send()</code>가 내부적으로 <code>ServletResponse.getOutputStream().write(...)</code> 까지 이어지는 실제 I/O 플로우는 나중에 자바 입출력을 공부하고 복습해보자.</p>
<p>이제 이벤트가 발생했을때 (emitter.send를 했을때) Client 에 어떤 형태로 응답이 보내질까?
SSE 는 서버 -&gt; Client 니까 매번 새로운 HTTP 응답이 발생하는것인줄 알았다.</p>
<p>하지만 그게 아니라 최초의 응답의 헤더를 그대로 사용하는것이였다.</p>
<pre><code>HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
</code></pre><p>응답 body (계속 append 됨)</p>
<pre><code>data: hello world
id: 1

event: notification
data: {&quot;msg&quot;:&quot;새 알림&quot;}
id: 2

data: ping</code></pre><p>정리하면</p>
<ul>
<li><p>헤더는 최초 한 번 내려가고 그 상태에서 <strong>연결 유지됨</strong>.</p>
</li>
<li><p>이벤트가 생길 때마다 body에 줄 단위로 <strong>append</strong>.</p>
</li>
<li><p>클라이언트(EventSource)는 이 body 스트림을 읽어서 이벤트로 파싱.</p>
</li>
</ul>
<hr>
<h3 id="sseemittersend--를-통한-event-전달-과정">SseEmitter.send()  를 통한 Event 전달 과정</h3>
<ol>
<li><strong>Emitter와 HTTP 응답 OutputStream 연결</strong></li>
</ol>
<ul>
<li>컨트롤러에서 return new SseEmitter() 하면
Spring MVC(ResponseBodyEmitterReturnValueHandler)가
이 emitter를 현재 HTTP 응답 스트림(ServletResponse.getOutputStream())과 연결해둡니다.
다시 말하면, <strong>HttpServletResponse.getOutputStream()을 얻어와서 emitter와 연결해둡니다.</strong></li>
<li><blockquote>
<p>즉, emitter 객체가 내부적으로 response outputStream을 가지고 있고, 그걸 통해 write 가능한 상태가 됩니다.</p>
</blockquote>
</li>
</ul>
<p>즉, emitter 객체가 내부적으로 response outputStream을 가지고 있고, 그걸 통해 write 가능한 상태가 됩니다.</p>
<ol start="2">
<li>**emitter.send() 호출 순간</li>
</ol>
<p>**</p>
<ul>
<li>내부적으로는 이렇게 처리돼요:<pre><code class="language-java">outputStream.write(&quot;data: ...\n\n&quot;.getBytes());
outputStream.flush();</code></pre>
즉, 호출 즉시 응답 body에 write + flush가 일어납니다.
메서드 종료와 무관
일반적인 @ResponseBody 응답은 메서드가 return 할 때 한 번에 body를 만들어 내려주죠.
SSE는 다릅니다. 
SseEmitter가 응답을 열어둔 상태라, 메서드가 끝났든 말든 send() 하는 순간 body에 즉시 append됩니다.</li>
</ul>
<ol start="3">
<li><strong>클라이언트 수신 시점</strong></li>
</ol>
<ul>
<li>서버에서 flush()까지 호출되면 TCP 버퍼를 통해 바로 클라이언트로 흘러갑니다.
클라이언트는 이걸 이벤트 블록 단위로 파싱해서 onmessage 또는 addEventListener로 전달받죠.</li>
</ul>
<h3 id="정리">정리</h3>
<p><strong>1.SseEmitter.send() 호출</strong></p>
<ul>
<li>서버 쪽에서 이벤트 데이터를 준비 (data: ..., id: ..., event: ... 같은 문자열)
이걸 Spring 내부가 비동기 TaskExecutor를 통해 HTTP 응답 body에 write + flush</li>
</ul>
<p><strong>2.응답 body는 이미 열려 있는 HTTP 스트림 (Content-Type: text/event-stream)</strong></p>
<ul>
<li>즉, send()가 호출될 때마다 body에 append 됨
헤더는 최초 한 번만 내려갔기 때문에 이후엔 body만 계속 이어짐</li>
</ul>
<p><strong>3.네트워크 전송</strong></p>
<ul>
<li>flush 된 데이터는 OS의 TCP 버퍼로 흘러가고
연결이 살아있는 클라이언트(EventSource)는 즉시 그 데이터를 수신</li>
</ul>
<p><strong>4.클라이언트 쪽 이벤트 발생</strong></p>
<ul>
<li>브라우저의 EventSource는 body에 새 블록이 append 될 때마다 파싱 → 이벤트 발생</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA - 페이징과 한계 돌파]]></title>
            <link>https://velog.io/@heeun_98/JPA-%ED%8E%98%EC%9D%B4%EC%A7%95%EA%B3%BC-%ED%95%9C%EA%B3%84-%EB%8F%8C%ED%8C%8C</link>
            <guid>https://velog.io/@heeun_98/JPA-%ED%8E%98%EC%9D%B4%EC%A7%95%EA%B3%BC-%ED%95%9C%EA%B3%84-%EB%8F%8C%ED%8C%8C</guid>
            <pubDate>Wed, 30 Jul 2025 10:03:05 GMT</pubDate>
            <description><![CDATA[<p>페이징 + 컬렉션 엔티티를 조회하려면 어떻게 해야할까</p>
<p>데이터가 뻥튀기 되지 않게 할수 없을까???</p>
<pre><code class="language-json">[
    {
        &quot;orderId&quot;: 1,
        &quot;name&quot;: &quot;userA&quot;,
        &quot;orderDate&quot;: &quot;2025-07-30T18:23:28.899788&quot;,
        &quot;orderStatus&quot;: &quot;ORDER&quot;,
        &quot;address&quot;: {
            &quot;city&quot;: &quot;서울&quot;,
            &quot;street&quot;: &quot;1&quot;,
            &quot;zipcode&quot;: &quot;1111&quot;
        },
        &quot;orderItems&quot;: [
            {
                &quot;itemName&quot;: &quot;JPA1 BOOK&quot;,
                &quot;orderPrice&quot;: 10000,
                &quot;count&quot;: 1
            },
            {
                &quot;itemName&quot;: &quot;JPA2 BOOK&quot;,
                &quot;orderPrice&quot;: 20000,
                &quot;count&quot;: 2
            }
        ]
    },
    {
        &quot;orderId&quot;: 2,
        &quot;name&quot;: &quot;userB&quot;,
        &quot;orderDate&quot;: &quot;2025-07-30T18:23:28.945129&quot;,
        &quot;orderStatus&quot;: &quot;ORDER&quot;,
        &quot;address&quot;: {
            &quot;city&quot;: &quot;진주&quot;,
            &quot;street&quot;: &quot;2&quot;,
            &quot;zipcode&quot;: &quot;2222&quot;
        },
        &quot;orderItems&quot;: [
            {
                &quot;itemName&quot;: &quot;SPRING1 BOOK&quot;,
                &quot;orderPrice&quot;: 20000,
                &quot;count&quot;: 3
            },
            {
                &quot;itemName&quot;: &quot;SPRING2 BOOK&quot;,
                &quot;orderPrice&quot;: 40000,
                &quot;count&quot;: 4
            }
        ]
    }
]
</code></pre>
<p>이렇게 order 를 기준으로 페이징을 할 경우는 어떻게 해야할까
쉽게 말해서 Order 엔티티는 OrderItem 과 일대다 관계이기 때문에
fetch join 을하고 페이징이 불가능하다.</p>
<ul>
<li>먼저 <strong>ToOne</strong>(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않 으므로 페이징 쿼리에 영향을 주지 않는다.</li>
<li>컬렉션은 지연 로딩으로 조회한다</li>
<li>지연 로딩 성능 최적화를 위해 <code>hibernate.default_batch_fetch_size</code> , <code>@BatchSize</code> 를      적용한다.
  <strong>-hibernate.default_batch_fetch_size: 글로벌 설정</strong><ul>
<li>@BatchSize: 개별 최적화</li>
<li>이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다</li>
</ul>
</li>
</ul>
<pre><code class="language-java">spring:
   jpa:
     properties:
       hibernate:
         default_batch_fetch_size: 1000
</code></pre>
<p> *<em>장점 *</em></p>
<ul>
<li><p>쿼리 호출 수가 <code>1 + N</code> <code>1 + 1</code> 로 최적화 된다</p>
</li>
<li><p>조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)</p>
<ul>
<li>페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.</li>
<li>컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.</li>
</ul>
</li>
<li><p><em>결론*</em></p>
</li>
<li><p>ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수 를 줄이고 해결하고, 나머지는 <code>hibernate.default_batch_fetch_size</code> 로 최적화 하자.
객체 그래프 탐색을 하다보면 알아서 batch fetch size 에 따라서 쿼리가 in 쿼리로 한꺼번에 가져올 것이다.</p>
</li>
</ul>
<p> XXToOne -&gt; DTO로 바로 조회, Fetch Join
 XXToMany -&gt; 페이징 필요없고 하나만 하는거면 컬렉션 페치조인 (최소한개만), 페이징할꺼면 다 XXXToOne Fetch 로 다가져오고, 나머지는 그냥 객체 그래프 탐색으로 지연로딩 + batch fetch size</p>
<p>참고로 컬렉션 페치 조인일때는 DTO 로 해결하지말자.
그리고 DTO 로 조회역시 SQL 을 짜는것과 유사하기 때문에 select 절에 컬렉션이 오는것 자체가 불가능해서 다른 방법을 해야한다.</p>
<p>대부분의 문제는 위에서 말한 엔티티를 직접 조회해서 DTO 로 변환하자.
그리고 컬렉션 fetch join 은 페이징을 안할꺼면 상관이 없지만 , 1개만 사용하자.
그리고 2개 이상사용해야하거나, 페이징을 해야되는 상황이면
ToOne 만 fetch join 하고 ToMany 는 default_batch_fetch_size 로 해결하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA - 컬렉션 fetch join]]></title>
            <link>https://velog.io/@heeun_98/JPA-%EC%BB%AC%EB%A0%89%EC%85%98-fetch-join</link>
            <guid>https://velog.io/@heeun_98/JPA-%EC%BB%AC%EB%A0%89%EC%85%98-fetch-join</guid>
            <pubDate>Wed, 30 Jul 2025 08:40:55 GMT</pubDate>
            <description><![CDATA[<h3 id="fetch-join-을-하지-않았을때-발생하는-문제점">Fetch join 을 하지 않았을때 발생하는 문제점</h3>
<p>먼저 Fetch join 을 하지 않을때 어떤 문제점이 발생하는지 알아야 Fetch join 을 잘 사용할 수 있다</p>
<pre><code class="language-java">
@GetMapping(&quot;/api/v2/orders&quot;)
    public List&lt;OrderDto&gt; ordersV2() {
        List&lt;Order&gt; orders = orderRepository.findAllByString(new OrderSearch());
        List&lt;OrderDto&gt; result = orders.stream()
                .map(o -&gt; new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }
</code></pre>
<pre><code class="language-java">
@Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        //속에 있는것도 DTO로 감싸자..!
        private List&lt;OrderItemDto&gt; orderItems;//DTO 안에도 엔티티 관련된 리스트가 있으면 안되고 모든걸 DTO
        //private List&lt;OrderItem&gt; orderItems -&gt; 이렇게 하지 말아라 , OrderItem이 엔티티라서 이렇게 하면 안됨

        public OrderDto(Order order) { // order 의 개수만큼 호출된다.
            orderId = order.getId();
            name = order.getMember().getName();//lazy 로딩 초기화 -&gt; Order 객체의 Member 자체가 실제 엔티티를 가진다.
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            //여기서 루프를 돌면서 orderItems 의 select 문이 나간다
            System.out.println(&quot;===== OrderItems 루프 시작 ======&quot;);
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -&gt; new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
</code></pre>
<pre><code class="language-java">
@Data
    static class OrderItemDto {
        //프론트(client)가 요구하는 api 스펙
        private String itemName;//상품명
        private int orderPrice;//주문 가격
        private int count;//주문 수량

        //private Item item -&gt; 그냥 OrderItem 인 경우 이런 엔티티까지 있었다. 필요한것만 가져와서 사용

        public OrderItemDto(OrderItem orderItem) {
            System.out.println(&quot;=======Item ====== &quot;);
            itemName = orderItem.getItem().getName();// depth 가 줄어든다.
            System.out.println(&quot;=====Item 끝 =====&quot;);
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }

</code></pre>
<p>여기서 먼저 Lazy 로딩이 뭔지 알아보자.</p>
<p>엔티티를 접근해서 특정 필드에 접근하면 그때 연관관계에 있는 엔티티가 실제로 주입이 된다.
즉 order.getMember().getName() 다른 필드도 상관없음, 필드에 접근 getName() 을 한 경우 Order 의 Member 가 실제 엔티티가 주입인된다. 이렇게 주입이 어떻게 되는것이가?</p>
<p>기존에 Order 의 Member 은 프록시로 존재한다. 하지만 위와 같이 접근시 Member 엔티티를 select 하는 조회 쿼리가 추가로 발생한다.</p>
<p>쉽게 말하면, 객체 그래프 탐색을 하면 프록시로 객체가 존재하기 때문에 필드에 접근시 추가 쿼리가 생긴다는 것이다.
이건 매우 비효율적이다.</p>
<p>그럼 어떻게 하면 좋을까??
복잡한거 없다.  그냥 쿼리 조회할때 내가 필요한 엔티티 즉, client 에게 반환할 정보... 등 요구 사항에 맞춰서 필요한 데이터를 전부 쿼리를 통해서 한번에 가져오면 된다.
이렇게 가져오게 되면 Order 입장에서 Member 는 진짜 엔티티 Member 입장에서 Item 은 진짜 엔티티 이다.
이러면 추가 쿼리가 안생긴다.</p>
<pre><code class="language-java">
public List&lt;Order&gt; findAllWithItem() {
        return em.createQuery(
                &quot;select o from Order o&quot; +
                        &quot; join fetch o.member m&quot; + // member : xxToOne
                        &quot; join fetch o.delivery d&quot; + // delivery : xxToOne
                        &quot; join fetch o.orderItems oi&quot; +// -&gt; 데이터 뻥튀기 : xxToMany
                        &quot; join fetch oi.item i&quot;, Order.class)
                //.setFirstResult(1)
                //.setMaxResults(100) -&gt; 페이징 쿼리가 sql 이 발생하지 않고 메모리에서 처리하게 된다.
                .getResultList();//
    }</code></pre>
<p>다 가져오기 위해 fetch join 으로 다 가져왔다.
매우 간단하다.
이렇게 가져온 엔티티를 객체 그래프 탐색을 그냥 하면된다. </p>
<p>여기서 중요한것이 있다.
컬렉션 fetch join 을 할 경우 데이터가 row 에 중복이 생긴다는것이다.
즉 . Order 기준으로 OrderItem 은 List 로 존재한다. 그럼 둘이 join 을 해서 내가 Order 를 가져오면 어떻게 될까??
데이터가 뻥튀기 된다. 직접 확인해 보자.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/173c02e0-db02-4be5-81db-b7c36e0d15e1/image.png" alt=""></p>
<p>위의 findAllWithItem 을 컬렉션 fetch join을 통해서 한 결과이다ㅏ.
OrderId 를 보면 2개가 아니라 4개이다. 역시 뻥튀기 되었다.</p>
<p>그럼 어플리케이션 단에서 <strong>List</strong>&lt;<strong>Order</strong>&gt; <strong>order</strong> 으로 받을때도 중복 Order 가 생기는지 직접 확인해 보았다</p>
<pre><code class="language-java">
@GetMapping(&quot;/api/v3/orders&quot;)
    public List&lt;OrderDto&gt; ordersV3() {
        List&lt;Order&gt; orders = orderRepository.findAllWithItem();
        for (Order order : orders) {
            System.out.println(&quot;order ref = &quot; + order + &quot; id = &quot; + order.getId() );
        }
        List&lt;OrderDto&gt; result = orders.stream()
                .map(o -&gt; new OrderDto(o))
                .collect(Collectors.toList());

        return result;

    }
</code></pre>
<p>결과는 이상하게 2개만 나왔따.</p>
<pre><code>order ref = Order{id=1, member=jpabook.jpashop.domain.Member@765d8449, orderItems=[jpabook.jpashop.domain.OrderItem@610ff2f9, jpabook.jpashop.domain.OrderItem@7ca043fb], delivery=jpabook.jpashop.domain.Delivery@c07844, orderDate=2025-07-30T17:30:30.501906, status=ORDER} id = 1
order ref = Order{id=2, member=jpabook.jpashop.domain.Member@49dbc5cb, orderItems=[jpabook.jpashop.domain.OrderItem@3569594c, jpabook.jpashop.domain.OrderItem@2688a90d], delivery=jpabook.jpashop.domain.Delivery@682a17f8, orderDate=2025-07-30T17:30:30.576967, status=ORDER} id = 2</code></pre><p>이게 어떻게 된것인가.
하이버네이버 6이전에는 이렇게 중복된 Order 와 같이 데이터가 뻥튀기 되었을때 중복을 제거하기 위해서 
<code>distinct</code> 를 select 절 뒤에 추가하였다. 즉</p>
<ol>
<li>DB SQL -&gt; distinct 쿼리 발생</li>
<li>어플리케이션 단에서 중복 루트 엔티티 제거
두가지 기능을 지원하였다.</li>
</ol>
<p>하지만 지금은 distinct 없이도 컬렉션 fetch join 시에 알아서 distinct 를 추가해서 중복을 제거해준다고 한다. 매우 편해졌다.</p>
<hr>
<h3 id="컬렉션-fetch-join-의-문제점">컬렉션 Fetch join 의 문제점</h3>
<p>컬렉션 페치 조인을 하면 1 대 다 관계로 인해 데이터가 뻥튀기 된다( row 수 증가)
위에서 본것처럼 row 가 여러개 발생하기 때문에 페이징 쿼리가 발생하지 않는다
<strong>내가 Order 를 OrderItem 과 fetch join 을 하면 OrderItem 의 개수만큼 row 가 생긴다.</strong>
아래 코드를 보자</p>
<pre><code class="language-java">

 public List&lt;Order&gt; findAllWithItem() {
        return em.createQuery(
                &quot;select o from Order o&quot; +
                        &quot; join fetch o.member m&quot; + // member : xxToOne
                        &quot; join fetch o.delivery d&quot; + // delivery : xxToOne
                        &quot; join fetch o.orderItems oi&quot; +// -&gt; 데이터 뻥튀기 : xxToMany
                        &quot; join fetch oi.item i&quot;, Order.class)
                .setFirstResult(1)
                .setMaxResults(100) //-&gt; 페이징 쿼리가 sql 이 발생하지 않고 메모리에서 처리하게 된다.
                .getResultList();//
    }
</code></pre>
<p>아래가 발생한 실제 sql</p>
<pre><code class="language-mysql"> select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_i
</code></pre>
<p><strong>나는 페이징 쿼리를 날렸는데 페이징 쿼리는 전혀 발생하지 않았다.</strong></p>
<p>즉 jpa 는 데이터 뻥튀기가 발생한것을 알고 페이징 쿼리 자체를 db 로 보내지 않는다. 
만약 보낸다고 해도 큰일이 발생한다.페이징이 안될것이다. 데이터 뻥튀기로인해서</p>
<p>여기서 어플리케이션 단에서 메모리위에서 페이징을 하지만 데이터가 엄청 많으면 메모리가 터질수도 있기때문에</p>
<p><strong>절대 , 일대다 fetch join 에서는 페이징을 하지말자.
페이징이 없으면 그냥 사용해도 된다 어차피 중복을 제거해주니까</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티 스레드 - Lock , CAS 비교]]></title>
            <link>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-Lock-CAS-%EB%B9%84%EA%B5%90</link>
            <guid>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-Lock-CAS-%EB%B9%84%EA%B5%90</guid>
            <pubDate>Fri, 25 Jul 2025 07:35:58 GMT</pubDate>
            <description><![CDATA[<p>CAS 연산을 사용하면 Lock 을 사용했을 때보다 성능상 좋았다.</p>
<pre><code>
  BasicInteger: ms=39
  VolatileInteger: ms=455
  SyncInteger: ms=625
  MyAtomicInteger: ms=367
</code></pre><p>이런 결과가 왜 이러난 것일까?</p>
<p>예를 들어 스레드 100개를 동시에 실행했을때 한 쓰레드가 Lock 을 소유하게 되면
다른 스레드 99개는 <strong>전부 CPU 자원을 사용하지 않는 상태</strong>가 된다.(BLOCKING , WAITING)</p>
<p>하지만 <code>AtomicInteger</code> 와 같은  클래스는 내부적으로 CAS( Compare and swap) 을 사용한다.
다음 코드를 보자. 실제 AtomicInteger 클래스는 아니고 같은 원리로 구현해 보았다.</p>
<pre><code class="language-java">package thread.cas;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class CasMainV3 {

    private static final int THREAD_COUNT = 2;

    public static void main(String[] args) throws InterruptedException {

        AtomicInteger atomicInteger = new AtomicInteger(0);

        System.out.println(&quot;start value = &quot; + atomicInteger.get());


        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                incrementAndGet(atomicInteger);
            }
        };

        List&lt;Thread&gt; threads = new ArrayList&lt;&gt;();

        for (int i = 0;i &lt; THREAD_COUNT; i++) {
            Thread thread = new Thread(runnable);
            threads.add(thread);
            thread.start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        int result = atomicInteger.get();
        System.out.println(atomicInteger.getClass().getName() + &quot; result = &quot; + result);


    }


    private static int incrementAndGet(AtomicInteger atomicInteger) {

        int getValue;
        boolean result;

        do {
            getValue = atomicInteger.get();
            log(&quot;getValue&quot; + getValue);
            sleep(100);
            //  (CAS 연산의 핵심은 값이 바뀌었어(값이 읽은거랑 다르네)? -&gt; 그럼 난 값을 바꾸지 않을꺼야 )
            //  ( 다르면 다시 시도)
            // 결국 내가 읽은 값과 같으면 값을 바꿔라 ㅎㅎ
            result = atomicInteger.compareAndSet(getValue, getValue + 1);
            log(&quot;result: &quot; + result);

        } while (!result);

        return getValue + 1; // atomicInteger.get() 을 하면 다른 쓰레드가 값을 덮어 씌어버릴수도있어서 그러지마.
    }
}


</code></pre>
<p>sleep(100) 아래 주석을 달았지만, CAS 연산의 핵심은
<code>result = atomicInteger.compareAndSet(getValue, getValue + 1);</code>  이 부분이다.</p>
<p><strong>읽은 값이랑 같으면 값을 바꾸고, 다르면 do-while 문을 반복한다.(CPU자원소모)</strong></p>
<p><code>CompareAndSet</code>은 참고로 비교하고 값을 연산하고 저장까지 하지만
CPU 하드웨어적으로 원자적 연산으로 취급한다.
여기서 동시성을 제어하기 위해서 CPU 자원을 소모하면 안좋은거 아닌가? 라는 생각이 들지만, 동시문 문제( 스레드 충돌) 은 간단한 원자적 연산 ( value ++ ) 일때는 CPU 의 연산속도가 매우매우 빠르게 때문에 충돌은 드물게 발생한다.</p>
<p>즉, 충돌이 드물게 발생을 하는데 LOCK 을 걸어서 싱글 스레드만 접근이 가능하게 하면 성능상 CAS 연산보다 안좋을 수 밖에 없다. 또한 LOCK 을 걸면 스레드의 상태가 변하기 때문에 거기서 발생하는 오버헤드 또한 크기 때문에 아무래도 성능상 좋지 않다.</p>
<p>하지만 위에서 말한것 처럼 CAS 연산은 , 스레드의 상태 변화가 없다.
100개의 스레드가 동시에 실행한다고 가정하면, 100개의 스레드가 모두 멀티스레드로 CPU 를 사용해 연산을 한다.</p>
<p>정리해보면
<strong>CAS(Compare-And-Swap)와 락(Lock) 방식의 비교</strong></p>
<p><strong>락(Lock) 방식</strong></p>
<ul>
<li>비관적(pessimistic) 접근법</li>
<li>데이터에 접근하기 전에 항상 락을 획득 다른 스레드의 접근을 막음</li>
<li>&quot;다른 스레드가 방해할 것이다&quot;라고 가정</li>
</ul>
<p><strong>CAS(Compare-And-Swap) 방식</strong> </p>
<ul>
<li>낙관적(optimistic) 접근법</li>
<li>락을 사용하지 않고 데이터에 바로 접근 충돌이 발생하면 그때 재시도</li>
<li>&quot;대부분의 경우 충돌이 없을 것이다&quot;라고 가정</li>
</ul>
<p>이를 학습한 경험으로 스레드간 충돌이 적으면(ex: value++) CAS 방식 즉 낙관적 락을 사용하는것이 좋을것이고, 충돌이 많이 발생하게 된다면, 락 방식을 고민해 볼 필요가 있다.</p>
<p><strong>아래는 Thread 1000개 를 1를 증가하는 연산을 동시에 실행했을때 결과</strong></p>
<pre><code class="language-java">
BasicIntegerresult: 973 //충돌횟수 여기서는 37회정도발생
VolatileIntegerresult: 984 // 충돌 발생
SyncIntegerresult: 1000 //충돌 x
MyAtomicIntegerresult: 1000 // 충돌 x</code></pre>
<p>앞서 스레드를 1000개를 동시에 실행했을때 중간에 sleep(100)을 줬음에도 불구하고 충돌이 1000번중 대략50번 밖에 발생하지 않았다.</p>
<p><strong>정리하자면 , 간단한 cpu 연산에서는 lock 보다는 CAS 연산을 사용하는것이 효과적이다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java - 멀티스레드 CAS , LOCK, VOLATILE 성능 비교]]></title>
            <link>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0%EC%93%B0%EB%A0%88%EB%93%9C-CAS-LOCK-VOLATILE-%EC%84%B1%EB%8A%A5%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0%EC%93%B0%EB%A0%88%EB%93%9C-CAS-LOCK-VOLATILE-%EC%84%B1%EB%8A%A5%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Thu, 24 Jul 2025 17:38:27 GMT</pubDate>
            <description><![CDATA[<h2 id="cascompare-and-swap-vs-lock-성능-차이의-근본-원인">CAS(Compare-And-Swap) vs. Lock: 성능 차이의 근본 원인</h2>
<h3 id="1-비차단nonblocking-알고리즘">1. 비차단(Non‑Blocking) 알고리즘</h3>
<p>CAS는 <strong>비차단</strong> 알고리즘의 대표격입니다.  </p>
<ul>
<li>스레드는 공유 변수의 예상 값(<code>expected</code>)과 실제 메모리 값을 비교하고, 같을 때만 새로운 값으로 교체합니다.  </li>
<li>다른 스레드가 변수 값을 바꾸고 있더라도 해당 스레드는 <strong>대기(block)</strong>하지 않고 즉시 재시도(retry)할 뿐입니다.  </li>
<li>반면 <code>synchronized</code>나 <code>ReentrantLock</code>은 <strong>차단(blocking)</strong> 방식으로, 락을 얻지 못하면 스레드를 대기 큐에 넣고 문맥 전환(context switch)이 발생합니다.  </li>
</ul>
<h3 id="2-커널-모드-전환-및-스케줄링-오버헤드-제거">2. 커널 모드 전환 및 스케줄링 오버헤드 제거</h3>
<p>락(<code>synchronized</code>/<code>Lock</code>)을 획득·해제할 때는 JVM 내부에서  </p>
<ol>
<li>스핀 혹은 OS <strong>futex</strong>(fast userspace mutex) 호출  </li>
<li>대기 스레드를 OS 스케줄러에 등록  </li>
<li>컨텍스트 전환 및 스케줄링<br>과 같은 일이 일어납니다.<br>이 과정에서 발생하는 <strong>수백~수천 사이클</strong>의 오버헤드는,<br>CAS의 <strong>한두 개의 기계어 명령</strong>다.</li>
</ol>
<h3 id="3-캐시-일관성-프로토콜-최적화">3. 캐시 일관성 프로토콜 최적화</h3>
<p>모던 멀티코어 시스템은 캐시 일관성 프로토콜(MESI, MOESI 등)을 통해 캐시라인 단위로 동기화합니다.  </p>
<ul>
<li>CAS는 <strong>한 캐시라인</strong>에서 해당 필드만 교체하며, 다른 라인에는 영향을 주지 않습니다.  </li>
<li>락 해제/획득 시 JVM과 OS는 관련 캐시라인을 플러시(flush) 또는 무효화(invalidate)해야 하는데,<br>이는 불필요한 메모리 대역폭 및 추가 지연을 초래합니다.</li>
</ul>
<h3 id="4-스케일-아웃scale-out-시-컨텐션contention-감소">4. 스케일 아웃(Scale-Out) 시 컨텐션(Contention) 감소</h3>
<ul>
<li><strong>낮은 컨텐션</strong>: 스레드가 짧은 주기로 재시도만 하기 때문에, 다른 스레드와 경쟁이 발생해도 전체 처리량이 급격히 떨어지지 않습니다.  </li>
<li><strong>높은 컨텐션</strong>: 락의 경우 한 번 락 보유자가 길게 작업 중이면, 다른 모든 스레드는 대기상태로 빠지며 CPU 자원을 활용하지 못합니다.  </li>
</ul>
<p>예를 들어 100개의 스레드가 동시에 <code>increment()</code>를 호출하는 상황에서,  </p>
<ul>
<li>CAS는 각 스레드가 독립적으로 <code>compareAndSet()</code>을 재시도하며 진행  </li>
<li>락은 1개의 스레드만 진입 후 나머지는 전부 블록 → CPU 유휴 증가  </li>
</ul>
<h3 id="5-jvm과-하드웨어의-협업">5. JVM과 하드웨어의 협업</h3>
<ul>
<li><code>java.util.concurrent.atomic</code> 패키지의 <code>AtomicInteger</code>나 <code>AtomicReference</code>는 <strong>Unsafe</strong> API를 통해 네이티브 CAS 명령어와 직접 연동합니다.  </li>
<li>JVM은 메모리 배리어(memory barrier)를 적절히 삽입해, <strong>메모리 가시성</strong>을 확보하면서도 <strong>불필요한 배리어 비용</strong>은 최소화합니다.</li>
</ul>
<h3 id="결론-및-실제-벤치마크-결과">결론 및 실제 벤치마크 결과</h3>
<p>본 벤치마크(<code>COUNT = 100_000_000</code>) 환경에서 측정된 실행 시간:  </p>
<table>
<thead>
<tr>
<th>구현체</th>
<th>걸린 시간(ms)</th>
</tr>
</thead>
<tbody><tr>
<td>BasicInteger</td>
<td>8</td>
</tr>
<tr>
<td>VolatileInteger</td>
<td>327</td>
</tr>
<tr>
<td>SyncInteger</td>
<td>765</td>
</tr>
<tr>
<td>MyAtomicInteger</td>
<td>342</td>
</tr>
</tbody></table>
<ul>
<li><strong>BasicInteger</strong>(락/동기화 없이 순수 연산): 최상의 성능  </li>
<li><strong>MyAtomicInteger</strong>(CAS): 락 기반 방식보다 약 2배 빠른 처리  </li>
<li><strong>SyncInteger/ReentrantLock</strong>(락): 커널 전환과 스케줄링 오버헤드로 크게 느림  </li>
<li><strong>VolatileInteger</strong>(<code>volatile</code>만): 메모리 배리어 비용으로 CAS보다 빠르지만 쓰기 비용 증가  </li>
</ul>
<p>CAS 연산이 빠른 이유는 “작고 단일한 하드웨어 원자 명령” + “락을 얻기 위한 시스템 콜 불필요” + “캐시 일관성만 최소한으로 유지”하기 때문입니다.<br>락 기반 동기화가 필요한 복잡한 임계영역이 아니라, <strong>단일 변수 증감</strong>처럼 간단한 동시성 제어에는 CAS가 훨씬 더 적합합니다. </p>
<hr>
<p>아래 코드는 실제 테스트해본 코드입니다.</p>
<pre><code class="language-java">package thread.cas.increment;


public class IncrementPerformanceMain {

    public static final long COUNT = 100_000_000;

    public static void main(String[] args) {

        test(new BasicInteger());
        test(new VolatileInteger());
        test(new SyncInteger());
        test(new MyAtomicInteger());

    }

    private static void test(IncrementInteger incrementInteger) {
        long startMs = System.currentTimeMillis();

        for (int i = 0; i &lt; COUNT; i++) {
            incrementInteger.increment();
        }

        long endMs = System.currentTimeMillis();

        System.out.println(incrementInteger.getClass().getSimpleName() + &quot;: ms=&quot; + (endMs - startMs));

    }
}


</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis - 사용, 실습]]></title>
            <link>https://velog.io/@heeun_98/Redis-%EC%82%AC%EC%9A%A9-%EC%8B%A4%EC%8A%B5</link>
            <guid>https://velog.io/@heeun_98/Redis-%EC%82%AC%EC%9A%A9-%EC%8B%A4%EC%8A%B5</guid>
            <pubDate>Thu, 24 Jul 2025 17:32:25 GMT</pubDate>
            <description><![CDATA[<p>Redis <strong>단일 쓰레드</strong>에서 실행이 되기 때문에 동시성 문제에 대해 고민할 필요 조차 없다.</p>
<ul>
<li>레디스 주요 특징<ul>
<li>key-value로 구성된 단순화된 데이터 구조로 sql 쿼리 사용 불필요</li>
<li>빠른 성능<ul>
<li>인메모리 NoSQL 데이터베이스로서 빠른 성능<ul>
<li>rdb는 기본적으로 disk에 저장이고 필요시에 메모리에 캐싱하는 것이므로, rdb보다 훨씬 빠른 성능</li>
<li>redis의 메모리상의 데이터는 주기적으로 스냅샷 disk에 저장</li>
</ul>
</li>
<li>key-value는 구조적으로 해시 테이블을 사용함으로서 매우 빠른 속도로 데이터 검색 가능</li>
</ul>
</li>
<li>Single Thread 구조로 동시성 이슈 발생X</li>
<li>윈도우 서버에서는 지원하지 않고, linux서버 및 macOS등에서 사용 가능</li>
</ul>
</li>
</ul>
<p>레디스 명령어를 직접 이용할 일은 없지만 한번 쯤 사용해 보자</p>
<pre><code class="language-bash"># redis설치(linux)
sudo apt-get install redis-server
# redis접속
redis-cli

# redis도커설치(윈도우, mac)
docker run --name redis-container -d -p 6379:6379 redis
# docker 컨네이너 조회
docker ps
# redis도커 접속
docker exec -it &lt;containerID&gt; redis-cli

# redis는 0~15번까지의 database로 구성(default는 0번 db)
# 데이터베이스 선택
select db번호

# 데이터베이스내 모든 키 조회
keys *

# 일반적인 String 자료구조

# set을 통해 key:value 세팅.
set user:email:1 hong1@naver.com
set user:email:2 &quot;hong2@naver.com&quot;
# nx : 이미존재하면 pass, 없으면 set 
set user:email:1 hong1@naver.com nx
# ex : 만료시간(초단위) - ttl(time to live)
set user:email:1 hong1@naver.com ex 10
# redis활용 : refresh토큰등 사용자 인증정보 저장
set user:1:refresh_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ex 10000
# get을 통해 value값 얻기
get user:1:refresh_token

# 특정 key삭제
del user:email:1
# 현재 DB내 모든 key삭제
flushdb

# redis활용 : 좋아요기능 구현
set likes:posting:1 0
incr likes:posting:1 #특정 key값의 value를 1만큼 증가
decr likes:posting:1 #특정 key값의 value를 1만큼 감소
get likes:posting:1
# redis활용 : 재고관리(동시성이슈 해결)
set stocks:product:1 100
decr stocks:product:1
get stocks:product:1

# redis활용 : 캐싱 기능 구현
# 1번 member 회원 정보 조회
# select name, email, age from member where id=1;
# 위 데이터의 결과값을 redis로 캐싱 -&gt; json형식으로 저장 {&quot;name&quot;:&quot;hong&quot;, &quot;email&quot;:&quot;hong@daum.net&quot;, &quot;age&quot;:30}
set member:info:1 &quot;{\&quot;name\&quot;:\&quot;hong\&quot;, \&quot;email\&quot;:\&quot;hong@daum.net\&quot;, \&quot;age\&quot;:30}&quot; ex 20

# list자료구조
# redis의 list는 deque와 같은 자료구조, 즉 doueble-ended queue구조

# lpush : 데이터를 왼쪽에 삽입
# rpush : 데이터를 오른쪽에 삽입
# lpop : 데이터를 왼쪽에서 꺼내기
# rpop : 데이터를 오른쪽에서 꺼내기
lpush hongildongs hong1
lpush hongildongs hong2
rpush hongildongs hong3
rpop hongildongs
lpop hongildongs

# list조회
# -1은 리스트의 끝자리를 의미. -2는 끝에서 2번째를 의미.
lrange hongildongs 0 0 #첫번째값
lrange hongildongs -1 -1 #마지막값
lrange hongildongs 0 -1 #처음부터마지막
lrange hongildongs -3 -1 #마지막3번째부터 마지막까지
lrange hongildongs 0 2

# 데이터 개수 조회
llen hongildongs
# ttl 적용
expire hongildongs 20
# ttl 조회
ttl hongildongs
# pop과 push를 동시에
# A리스트에서 POP하여 B리스트로 PUSH
rpoplpush A리스트 B리스트

# redis활용 : 최근 방문한 페이지
# 5개정도 데이터 push
# 최근방문한 페이지 3개만 보여주는
rpush mypages www.naver.com
rpush mypages www.google.com
rpush mypages www.daum.net
rpush mypages www.chatgpt.com
rpush mypages www.daum.net
lrange mypages -3 -1

# set자료구조 : 중복없음. 순서없음.
sadd memberlist member1
sadd memberlist member2
sadd memberlist member1

# set 조회
smembers memberlist
# set멤버 개수 조회
scard memberlist 
# set에서 멤버 삭제
srem memberlist member2
# 특정 멤버가 set안에 있는지 존재여부 확인
sismember memberlist member1

# redis활용 : 좋아요 구현
sadd likes:posting:1 member1
sadd likes:posting:1 member2
sadd likes:posting:1 member1
scard likes:posting:1
sismember likes:posting:1 member1

# zset : sorted set
# 사이에 숫자는 score라고 불리고, score를 기준으로 정렬
zadd memberlist 3 member1
zadd memberlist 4 member2
zadd memberlist 1 member3
zadd memberlist 2 member4

# 조회방법
# score기준 오름차순 정렬
zrange memberlist 0 -1
# score기준 내림차순 정렬
zrevrange memberlist 0 -1

# zset삭제
zrem memberlist member4

# zrank : 특정 멤버가 몇번째(index 기준) 순서인지 출력
zrank memberlist member4

# redis 활용 : 최근 본 상품목록
# zset을 활용해서 최근시간순으로 정렬
# zset도 set이므로 같은 상품을 add할 경우에 시간만 업데이트되고 중복이 제거
# 같은 상품을 더할경우 시간만 마지막에 넣은 값으로 업데이트(중복제거)
zadd recent:products 151930 pineapple
zadd recent:products 152030 banana
zadd recent:products 152130 orange
zadd recent:products 152230 apple
zadd recent:products 152330 apple
# 최근본 상품목록 3개 조회
zrevrange recent:products 0 2
zrevrange recent:products 0 2 withscores

# redis활용사례 : 주식시세저장
# 종목명: 삼성전자, 시세: 72000원, 시간: 1672527600 (유닉스 타임스탬프) -&gt; 년월일시간을 초단위로 변환한것.(밀리초 단위도 가능능)
zadd stock:prices:samsung 1672527600 &quot;53000&quot;
# 종목명: LG전자, 시세: 95000원, 시간: 1672527660
zadd stock:prices:lg 1672527660 &quot;95000&quot;
# 종목명: 삼성전자, 시세: 72500원, 시간: 1672527720
zadd stock:prices:samsung 1672527720 &quot;72500&quot;
# 종목명: LG전자, 시세: 94500원, 시간: 1672527780
zadd stock:prices:lg 1672527780 &quot;94500&quot;
# 삼성전자의 최신 시세 조회 (최대 1개)
zrevrange stock:prices:samsung 0 0 withscores

# hashes : map형태의 자료구조(key:value key:value ... 형태의 자료구조)
hset author:info:1 name hong email hong@naver.com age 30
# 특정값 조회
hget author:info:1 name
# 모든 객체값 조회
hgetall author:info:1
# 특정 요소값 수정
hset author:info:1 name kim

# 

</code></pre>
<p>Redis 를 공부하고 나서 실제로 Redis 를 구현했을때 얼마나 더 빠른지 직접 확인해보자.</p>
<pre><code class="language-java">
package com.example.redisTest.service;

import com.example.redisTest.ArticleCacheDto;
import com.example.redisTest.RequestDto;
import com.example.redisTest.entity.Article;
import com.example.redisTest.repository.ArticleRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@CacheConfig(cacheNames = &quot;article&quot;)
public class ArticleService {

    public final ArticleRepository repo;


    @Transactional(readOnly = true)
    @Cacheable(key = &quot;&#39;all&#39;&quot;)
    public List&lt;ArticleCacheDto&gt; findAll() {
        return repo.findAll()
                .stream()
                .map(a -&gt; new ArticleCacheDto(a.getId(), a.getTitle(), a.getBody()))
                .collect(Collectors.toList());
    }

    /** 조회: @Cacheable → 캐시에 있으면 캐시에서 가져오고 없으면 DB에서 가져옴과 동시에 캐시에 올려둔다.(만료시간) */
    @Transactional
    @Cacheable(key = &quot;#id&quot;)
    public ArticleCacheDto getById(Long id) {

        long start = System.currentTimeMillis();
        Article article = repo.findById(id)
                .orElseThrow(() -&gt; new RuntimeException());

        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());

    }

    @Transactional
    public ArticleCacheDto getById2(Long id) {

        long start = System.currentTimeMillis();
        Article article = repo.findById(id)
                .orElseThrow(() -&gt; new RuntimeException());

        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());

    }

    /** DB 반영: 메서드 로직(repo.save())으로 처리
     캐시 반영: 메서드 반환값을 무조건 캐시에 저장 */
    @Transactional
    @CachePut(key = &quot;#result.id&quot;)
    @CacheEvict(key = &quot;&#39;all&#39;&quot;) // -&gt; redis-cli : DEL article::all
    public ArticleCacheDto create(RequestDto articleDto) {

        Article article = new Article(articleDto.getTitle(), articleDto.getBody());
        repo.save(article);

        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());
    }


    @Transactional
    @CachePut(key = &quot;#articleDto.id&quot;)
    @CacheEvict(key = &quot;&#39;all&#39;&quot;)
    public ArticleCacheDto update(RequestDto articleDto) {
        Article article = repo.findById(articleDto.getId())
                .orElseThrow(() -&gt; new EntityNotFoundException(&quot;not found&quot;));
        article.setTitle(articleDto.getTitle());
        article.setBody(articleDto.getBody());
        // 변경 감지 → save 호출 안 해도 됨
        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());
    }

    /** 삭제: @CacheEvict → DB 삭제 후 캐시에서 해당 키 제거 */
    @Transactional
    @Caching(evict = {
            @CacheEvict(key = &quot;#id&quot;),       // article::{id} 삭제
            @CacheEvict(key = &quot;&#39;all&#39;&quot;)      // article::all 삭제
    })
    public void delete(Long id) {
        repo.deleteById(id);
    }


}

</code></pre>
<p><strong>여기서 중요한게 Spring AOP 이다.</strong>
저 서비스 빈 클래스 내부에서는 해당 @Cacheable 과 같은 내부 메서드를 호출해도 캐싱이 안된다.
이것 마치 @Transactional 과 같이 Spring AOP 때문이다.</p>
<p>결국 캐시관련 서비스 클래스나 따로 빈을 만들어서
도메인 서비스 빈 -&gt; 캐시관련 빈 클래스를 호출해서 사용하자.</p>
]]></description>
        </item>
    </channel>
</rss>