<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>_mung.log</title>
        <link>https://velog.io/</link>
        <description>💻 💻 💻</description>
        <lastBuildDate>Wed, 26 Mar 2025 09:20:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>_mung.log</title>
            <url>https://velog.velcdn.com/images/_mung/profile/1856a22d-96af-4baf-98b9-23d2c55e1c92/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. _mung.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/_mung" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[✨성능 개선 - MoodBuddy✨] 32명의 사용자 피드백 "일기 저장 시간이 너무 길어요!!"]]></title>
            <link>https://velog.io/@_mung/%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-MoodBuddy-32%EB%AA%85%EC%9D%98-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%94%BC%EB%93%9C%EB%B0%B1-%EC%9D%BC%EA%B8%B0-%EC%A0%80%EC%9E%A5-%EC%8B%9C%EA%B0%84%EC%9D%B4-%EB%84%88%EB%AC%B4-%EA%B8%B8%EC%96%B4%EC%9A%94</link>
            <guid>https://velog.io/@_mung/%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-MoodBuddy-32%EB%AA%85%EC%9D%98-%EC%82%AC%EC%9A%A9%EC%9E%90-%ED%94%BC%EB%93%9C%EB%B0%B1-%EC%9D%BC%EA%B8%B0-%EC%A0%80%EC%9E%A5-%EC%8B%9C%EA%B0%84%EC%9D%B4-%EB%84%88%EB%AC%B4-%EA%B8%B8%EC%96%B4%EC%9A%94</guid>
            <pubDate>Wed, 26 Mar 2025 09:20:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/431ef6c4-dbf6-4c01-a42b-87d34bd85426/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="✨성능-개선✨">✨성능 개선✨</h3>
<h4 id="before">Before</h4>
<p>서비스 배포 후 운영을 하면서 32명의 사용자로부터 &quot;일기 저장 시간이 너무 길어요!!&quot;라는 피드백을 공통적으로 받았다.
일기 서비스에서 일기 저장은 핵심 기능이기에 이를 주도적으로 해결하기로 했다.</p>
<p>처음에는 일기 저장 API 호출할 때, 아래 3가지 기능이 하나의 트랜잭션으로 묶여 동작했다. </p>
<ol>
<li>OpenAI를 호출하여 일기 분석.</li>
<li>S3에 일기 이미지 업로드.</li>
<li>일기 데이터 DB에 저장.
이로 인해, 일기 저장 시 발생하는 3.78초의 시간 지연으로 병목 현상이 발생했다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/_mung/post/9fa84b90-7d22-46f7-b357-6a45b574d369/image.png" alt=""></p>
<p>이를 해결하기 위해 일기 저장 API를 본연의 역할에 집중하도록 개선하고자 했다.</p>
<hr>
<h4 id="process">Process</h4>
<p><strong>1. 클라이언트가 직접 S3에 이미지를 업로드하도록 Presigned URL 적용</strong></p>
<p>기존 서버에서 직접 S3에 이미지를 업로드하고, DB에 저장하는 설계에서 Presigned URL 방식을 적용해서 개선했다.
이로 인해 클라이언트에서 직접 S3에 이미지를 업로드하여, 서버의 부담을 줄일 수 있도록 개선했다.</p>
<p><strong>2. 일기 저장과 일기 분석 API 분리</strong>
기존에는 일기 저장과 분석을 하나의 API에서 처리한 설계에서 일기 저장 API와 일기 분석 API를 분리하여 각각 독립적으로 동작하도록 개선했다.</p>
<p><strong>3. 일기 저장 API 호출 후, 반환되는 데이터 최소화</strong>
기존에는 일기 저장 API 호출 후, 저장된 일기에 대한 모든 데이터를 반환했다. 하지만 이는 불필요하다고 판단해 id 값만 반환하도록 개선했다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/b10b021b-e5ea-4692-b214-de50b51dc2f2/image.png" alt=""></p>
<hr>
<h4 id="result-analysis">Result Analysis</h4>
<p>개선 결과는 아래와 같다.
<strong>1. 일기 저장 속도 3.78s → 233ms, 약 93.9% 응답 시간 단축.</strong>
<strong>2. 하나의 트랜잭션에서 독립적인 트랜잭션으로 분리.</strong></p>
<p><strong>📍 개전 선, 일기 저장</strong>
<img src="https://velog.velcdn.com/images/_mung/post/0e60829e-8689-4000-9652-f5d958f97d5f/image.png" alt=""></p>
<p><strong>📍 개전 후, 일기 저장</strong>
<img src="https://velog.velcdn.com/images/_mung/post/6e4daeaf-7274-434c-a683-8887fe9d7af0/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[✨성능 개선 - MoodBuddy✨] 90,000 쿼리 발생을 줄이다📉]]></title>
            <link>https://velog.io/@_mung/%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-MoodBuddy-90000-%EC%BF%BC%EB%A6%AC-%EB%B0%9C%EC%83%9D%EC%9D%84-%EC%A4%84%EC%9D%B4%EB%8B%A4</link>
            <guid>https://velog.io/@_mung/%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-MoodBuddy-90000-%EC%BF%BC%EB%A6%AC-%EB%B0%9C%EC%83%9D%EC%9D%84-%EC%A4%84%EC%9D%B4%EB%8B%A4</guid>
            <pubDate>Sun, 23 Mar 2025 12:55:50 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/a655d72b-d62e-42cb-82e2-000c5dec9bc1/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="✨성능-개선✨">✨성능 개선✨</h3>
<h4 id="before">Before</h4>
<p><strong>개선 전 -&gt; Spring Batch를 활용해 QuddyTI 분석 (매달 말일 + 매달 첫날)</strong></p>
<p>하지만 사용자가 한 달간 작성한 일기의 감정과 주제를 분석하는 과정에서 유저 한 명 당 11번의 쿼리가 발생한다는 걸 알았다.</p>
<p>그 이유는 기존에 사용자마다 각 감정, 각 주제 별로 한 번씩 조회해 N+1 문제가 발생했다.</p>
<p><strong>📍 DiaryCountServiceImpl.java</strong></p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DiaryCountServiceImpl implements DiaryCountService {
    private final QuddyTIBatchJDBCRepository quddyTIBatchJDBCRepository;

    @Override
    public Map&lt;DiaryEmotion, Long&gt; getEmotionCountsByDate(final Long userId, LocalDate[] dates) {
        EnumMap&lt;DiaryEmotion, Long&gt; emotionCounts = new EnumMap&lt;&gt;(DiaryEmotion.class);
        for (DiaryEmotion emotion : DiaryEmotion.values()) {
            long count = quddyTIBatchJDBCRepository.findEmotionCountsByUserIdAndDate(userId, emotion, dates[0], dates[1]);
            emotionCounts.put(emotion, count);
        }
        return emotionCounts;
    }

    @Override
    public Map&lt;DiarySubject, Long&gt; getSubjectCountsByDate(final Long userId, LocalDate[] dates) {
        EnumMap&lt;DiarySubject, Long&gt; subjectCounts = new EnumMap&lt;&gt;(DiarySubject.class);
        for (DiarySubject subject : DiarySubject.values()) {
            long count = quddyTIBatchJDBCRepository.findSubjectCountsByUserIdAndDate(userId, subject, dates[0], dates[1]);
            subjectCounts.put(subject, count);
        }
        return subjectCounts;
    }
}</code></pre>
<p><strong>📍 QuddyTIBatchJDBCRepository.java</strong></p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class QuddyTIBatchJDBCRepository {
    private final JdbcTemplate jdbcTemplate;

    public long findEmotionCountsByUserIdAndDate(Long userId, DiaryEmotion emotion, LocalDate start, LocalDate end) {
        String sql = &quot;SELECT COUNT(*) FROM diary WHERE user_id = ? AND date BETWEEN ? AND ? AND emotion = ?&quot;;
        return jdbcTemplate.queryForObject(sql, Long.class, userId, start, end, emotion.name());
    }

    public long findSubjectCountsByUserIdAndDate(Long userId, DiarySubject subject, LocalDate start, LocalDate end) {
        String sql = &quot;SELECT COUNT(*) FROM diary WHERE user_id = ? AND date BETWEEN ? AND ? AND subject = ?&quot;;
        return jdbcTemplate.queryForObject(sql, Long.class, userId, start, end, subject.name());
    }
}</code></pre>
<p>만약 10,000명의 사용자라면, 10,000 * 11 = 110,000번 쿼리가 발생한다.
그리고 사용자 10,000명 기준, 쿼디티아이 생성 평균 약 919ms, 수정 평균 약 14,528ms 발생했다.</p>
<hr>
<h4 id="process">Process</h4>
<p><strong>📍 DiaryCountServiceImpl.java</strong></p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DiaryCountServiceImpl implements DiaryCountService {
    private final QuddyTIBatchJDBCRepository quddyTIBatchJDBCRepository;

    @Override
    public Map&lt;DiaryEmotion, Long&gt; getEmotionCountsByDate(Long userId, LocalDate[] dates) {
        return quddyTIBatchJDBCRepository.findEmotionGroupCountsByUserIdAndDate(userId, dates[0], dates[1]);
    }

    @Override
    public Map&lt;DiarySubject, Long&gt; getSubjectCountsByDate(Long userId, LocalDate[] dates) {
        return quddyTIBatchJDBCRepository.findSubjectGroupCountsByUserIdAndDate(userId, dates[0], dates[1]);
    }
}</code></pre>
<p><strong>📍 QuddyTIBatchJDBCRepository.java</strong></p>
<pre><code class="language-java">@Repository
@RequiredArgsConstructor
public class QuddyTIBatchJDBCRepository {
    private final JdbcTemplate jdbcTemplate;

    public Map&lt;DiaryEmotion, Long&gt; findEmotionGroupCountsByUserIdAndDate(Long userId, LocalDate start, LocalDate end) {
        String sql = &quot;SELECT emotion, COUNT(*) FROM diary WHERE user_id = ? AND date BETWEEN ? AND ? GROUP BY emotion&quot;;

        return jdbcTemplate.query(sql, rs -&gt; {
            EnumMap&lt;DiaryEmotion, Long&gt; map = new EnumMap&lt;&gt;(DiaryEmotion.class);
            while (rs.next()) {
                String emotionStr = rs.getString(&quot;emotion&quot;);
                long count = rs.getLong(&quot;COUNT(*)&quot;);
                map.put(DiaryEmotion.valueOf(emotionStr), count);
            }
            for (DiaryEmotion e : DiaryEmotion.values()) {
                map.putIfAbsent(e, 0L);
            }
            return map;
        }, userId, start, end);
    }

    public Map&lt;DiarySubject, Long&gt; findSubjectGroupCountsByUserIdAndDate(Long userId, LocalDate start, LocalDate end) {
        String sql = &quot;SELECT subject, COUNT(*) FROM diary WHERE user_id = ? AND date BETWEEN ? AND ? GROUP BY subject&quot;;

        return jdbcTemplate.query(sql, rs -&gt; {
            EnumMap&lt;DiarySubject, Long&gt; map = new EnumMap&lt;&gt;(DiarySubject.class);
            while (rs.next()) {
                String subjectStr = rs.getString(&quot;subject&quot;);
                long count = rs.getLong(&quot;COUNT(*)&quot;);
                map.put(DiarySubject.valueOf(subjectStr), count);
            }
            for (DiarySubject s : DiarySubject.values()) {
                map.putIfAbsent(s, 0L);
            }
            return map;
        }, userId, start, end);
    }
}</code></pre>
<p>사용자의 한 달간의 일기 감정+주제를 모두 가져온 후 Map을 활용해 분리하는 방법으로 변경했다.</p>
<hr>
<h4 id="result-analysis">Result Analysis</h4>
<p>개선 결과는 아래와 같다.
<strong>1. 사용자 10,000명 기준, 감정+주제 통계 조회 쿼리를 110,000 → 20,000건으로 최적화.</strong>
<strong>2. 감정+주제 통계를 루프 기반 N+1 쿼리에서 GROUP BY 단일 쿼리로 개선, 쿼리 수 90,000건 절감</strong>
<strong>3. 사용자 10,000명 기준, 쿼디티아이 수정 작업을 평균 약 14,528ms → 평균 약 4,003ms로 최적화하여 약 72% 성능 개선.</strong></p>
<hr>
<h4 id="관련-글">관련 글</h4>
<p>[[🔥TroubleShooting - MoodBuddy🔥] 너의 쿼디티아이는 뭐니?!]
<a href="https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EB%84%88%EC%9D%98-%EC%BF%BC%EB%94%94%ED%8B%B0%EC%95%84%EC%9D%B4%EB%8A%94-%EB%AD%90%EB%8B%88">https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EB%84%88%EC%9D%98-%EC%BF%BC%EB%94%94%ED%8B%B0%EC%95%84%EC%9D%B4%EB%8A%94-%EB%AD%90%EB%8B%88</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[✨성능 개선 - MoodBuddy✨] 일기 조회 시간 약 95.9% 단축 ♨️]]></title>
            <link>https://velog.io/@_mung/%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8-MoodBuddy</link>
            <guid>https://velog.io/@_mung/%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8-MoodBuddy</guid>
            <pubDate>Fri, 14 Mar 2025 10:01:39 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/89f7caab-0166-4f56-b669-4699c8b8e956/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="✨성능-개선✨">✨성능 개선✨</h3>
<h4 id="test-environment">Test Environment</h4>
<p>일기 조회 기능을 성능 테스트하려고 한다.</p>
<p><strong>📌 무드버디 v1 시스템 구성</strong></p>
<ul>
<li>애플리케이션: Spring Boot (Docker)</li>
<li>데이터베이스: MySQL (Local)</li>
<li>테스트 툴: K6</li>
</ul>
<p><strong>📌 무드버디 v2 시스템 구성</strong></p>
<ul>
<li>애플리케이션: Spring Boot (Docker)</li>
<li>데이터베이스: MySQL (Local)</li>
<li>캐시 시스템: Redis (Docker)</li>
<li>테스트 툴: K6</li>
</ul>
<blockquote>
<p><strong>무드버디 v1은 QueryDSL을 활용해 DB에서 일기들을 조회하는 방식으로 구현했다.</strong>
<strong>무드버디 v2는 QueryDSL로 DB에서 조회하는 것은 똑같지만 몇 가지 추가를 했다.</strong></p>
<p>1️⃣ Redis 캐싱을 적용했다.
2️⃣ 페이징 조회를 할 때, 쿼리 수를 단축하고자 Count 조회를 하는 걸 첫 페이지에서 하도록 했다.
3️⃣ ResponseDTO에서 불필요한 응답 데이터는 제거했다.</p>
</blockquote>
<hr>
<h4 id="test-scenario">Test Scenario</h4>
<p><strong>📌 부하 증가 단계</strong></p>
<ul>
<li>2분 동안 500명의 사용자가 증가</li>
<li>2분 동안 1000명의 사용자가 증가</li>
<li>2분 동안 1000명 유지 (최대 부하)</li>
<li>2분 동안 500명으로 감소</li>
<li>2분 동안 0명으로 감소</li>
<li>총 10분 테스트 진행</li>
</ul>
<p><strong>📌 테스트 요청</strong></p>
<ul>
<li>1000명의 사용자 중 랜덤으로 로그인 시도</li>
<li>응답에서 accessToken을 추출</li>
<li>accessToken을 가지고 getDiaries API 요청</li>
<li>총 50개의 일기를 가져오며, 페이지당 20개씩 요청</li>
<li>각 요청 후 1초 동안 sleep</li>
</ul>
<p><strong>📌 테스트 통과 기준</strong></p>
<ul>
<li>응답 속도: 95% 이상의 요청(p(95))이 100ms 이하로 처리되야 함.</li>
<li>에러율: 전체 요청 중 1% 미만만 실패</li>
</ul>
<hr>
<h4 id="result-analysis">Result Analysis</h4>
<p><strong>📍 무드버디 v1 테스트 결과</strong>
<img src="https://velog.velcdn.com/images/_mung/post/92429268-2ebb-4a13-9d2d-d80356f94220/image.png" alt=""></p>
<p><strong>📍 무드버디 v2 테스트 결과</strong>
<img src="https://velog.velcdn.com/images/_mung/post/8869ec8d-f39c-460f-b57a-4b7a46131fb7/image.png" alt=""></p>
<p>*<em>📍 주요 성능 지표 비교 *</em></p>
<table>
<thead>
<tr>
<th>지표</th>
<th>개선 전</th>
<th>개선 후</th>
<th>개선율</th>
</tr>
</thead>
<tbody><tr>
<td><strong>총 요청 수 (<code>http_reqs</code>)</strong></td>
<td>366,040</td>
<td>474,188</td>
<td><strong>29.5% 증가</strong></td>
</tr>
<tr>
<td><strong>응답 시간 평균 (<code>http_req_duration avg</code>)</strong></td>
<td>238.6ms</td>
<td>12.33ms</td>
<td><strong>94.8% 감소</strong></td>
</tr>
<tr>
<td><strong>응답 시간 90퍼센트 (<code>p(90)</code>)</strong></td>
<td>577.36ms</td>
<td>23.89ms</td>
<td><strong>95.9% 감소</strong></td>
</tr>
<tr>
<td><strong>응답 시간 95퍼센트 (<code>p(95)</code>)</strong></td>
<td>734.42ms</td>
<td>33.22ms</td>
<td><strong>95.5% 감소</strong></td>
</tr>
<tr>
<td><strong>대기 시간 평균 (<code>http_req_waiting avg</code>)</strong></td>
<td>238.45ms</td>
<td>12.26ms</td>
<td><strong>94.9% 감소</strong></td>
</tr>
<tr>
<td><strong>최대 응답 시간 (<code>http_req_duration max</code>)</strong></td>
<td>5.27s</td>
<td>979.22ms</td>
<td><strong>81.4% 감소</strong></td>
</tr>
</tbody></table>
<p><strong>🔥 응답 시간 대폭 개선</strong></p>
<ul>
<li>평균 응답 시간 238.6ms → 12.33ms로 94.8% 감소</li>
<li>90% 이상 요청의 응답 시간 577.36ms → 23.89ms 95.9% 단축</li>
<li>최대 응답 시간 5.27s → 979.22ms 81.4% 감소</li>
</ul>
<p><strong>🔥 서버 처리량 증가</strong></p>
<ul>
<li>총 처리 요청 수가 29.5% 증가하면서도 응답 시간이 단축됨 → 서버의 부하 대응력 개선</li>
</ul>
<p><strong>🔥 대기 시간 최적화</strong></p>
<ul>
<li>요청 대기 시간이 238.45ms → 12.26ms로 감소</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - MoodBuddy🔥] Redis 캐시 전략 너무 오버스팩...?]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-MoodBuddy-Redis-%EC%BA%90%EC%8B%9C-%EC%A0%84%EB%9E%B5-%EB%84%88%EB%AC%B4-%EC%98%A4%EB%B2%84%EC%8A%A4%ED%8C%A9</link>
            <guid>https://velog.io/@_mung/TroubleShooting-MoodBuddy-Redis-%EC%BA%90%EC%8B%9C-%EC%A0%84%EB%9E%B5-%EB%84%88%EB%AC%B4-%EC%98%A4%EB%B2%84%EC%8A%A4%ED%8C%A9</guid>
            <pubDate>Thu, 13 Mar 2025 17:37:47 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/2c02573b-bc6b-46f5-9981-1ad1dcc8518a/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>일기 목록 조회 속도를 높이기 위해서 Redis 캐시를 적용했다. 하지만 단순히 응답 조회를 높이기 위해 Redis를 적용하여, 오히려 관리 측면에서 부담을 느꼈다.</p>
<p>또한, 사용자 id와 페이지 번호 조합으로 캐시 키를 생성하다 보니, 사용자 수와 페이지 수에 비례해 캐시 키가 급격히 증가하게 되었다. 이 역시... 메모리 자원을 지속적으로 소비하는 원인이 되었다.</p>
<p>결국 사용자가 대량으로 몰릴 가능성이 낮은 기능에 Redis를 연결하면서 오히려 개발 및 운영 비용을 높이는 요인이 되었다.</p>
<p>아래 k6 결과는 Redis 캐시를 적용했을 때의 결과이다.</p>
<p><strong>📍 일기 목록 조회 테스트 결과 (Redis Cache 적용)</strong>
<img src="https://velog.velcdn.com/images/_mung/post/8869ec8d-f39c-460f-b57a-4b7a46131fb7/image.png" alt=""></p>
<hr>
<h4 id="how">How</h4>
<ol>
<li>Redis 캐시 설정을 제거하고, Spring Cache를 적용. </li>
<li>일기 목록 조회용 테이블을 따로 만들어서 조회를 할 때 해당 테이블에만 접근하도록 구현.</li>
<li>일기 목록 조회용 테이블에 자주 조회되는 인덱스 적용.</li>
</ol>
<hr>
<h4 id="process">Process</h4>
<p>아래와 같이 일기 목록 조회에만 필요한 테이블을 따로 만들었다.</p>
<pre><code class="language-java">Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
        name = &quot;diary_query&quot;,
        uniqueConstraints = @UniqueConstraint(columnNames = {&quot;userId&quot;, &quot;date&quot;}),
        indexes = {
                @Index(name = &quot;idx_id_user_Id_mood_buddy_status&quot;, columnList = &quot;diary_id, user_id, mood_buddy_status&quot;),
                @Index(name = &quot;idx_user_Id_mood_buddy_status_date&quot;, columnList = &quot;user_id, mood_buddy_status, date&quot;)
        }
)
public class DiaryQuery {
    @Id
    @Column(name = &quot;diary_id&quot;)
    private Long diaryId;

    @Column(name = &quot;title&quot;, nullable = false)
    private String title;

    @Column(name = &quot;date&quot;, nullable = false)
    private LocalDate date;

    @Column(name = &quot;content&quot;, nullable = false, columnDefinition = &quot;text&quot;)
    private String content;

    @Column(name = &quot;user_id&quot;, nullable = false, columnDefinition = &quot;bigint&quot;)
    private Long userId;

    @Column(name = &quot;thumbnail&quot;, columnDefinition = &quot;text&quot;)
    private String thumbnail;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;emotion&quot;)
    private DiaryEmotion emotion;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;subject&quot;)
    private DiarySubject subject;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;mood_buddy_status&quot;)
    private MoodBuddyStatus moodBuddyStatus;
}</code></pre>
<p>그리고 yml 설정을 아래와 같이 변경하여, Spring Cache로 변경했다.</p>
<pre><code class="language-yml">spring:
  threads:
    virtual:
      enabled: true
  cache:
    type: simple</code></pre>
<hr>
<h4 id="test-scenario">Test Scenario</h4>
<p><strong>📌 부하 증가 단계</strong></p>
<ul>
<li>2분 동안 500명의 사용자가 증가</li>
<li>2분 동안 1000명의 사용자가 증가</li>
<li>2분 동안 1000명 유지 (최대 부하)</li>
<li>2분 동안 500명으로 감소</li>
<li>2분 동안 0명으로 감소</li>
<li>총 10분 테스트 진행</li>
</ul>
<p><strong>📌 테스트 요청</strong></p>
<ul>
<li>1000명의 사용자 중 랜덤으로 로그인 시도</li>
<li>응답에서 accessToken을 추출</li>
<li>accessToken을 가지고 getDiaries API 요청</li>
<li>총 50개의 일기를 가져오며, 페이지당 20개씩 요청</li>
<li>각 요청 후 1초 동안 sleep</li>
</ul>
<p><strong>📌 테스트 통과 기준</strong></p>
<ul>
<li>응답 속도: 95% 이상의 요청(p(95))이 100ms 이하로 처리되야 함.</li>
<li>에러율: 전체 요청 중 1% 미만만 실패</li>
</ul>
<hr>
<h4 id="result-analysis">Result Analysis</h4>
<p><strong>📍 일기 목록 조회 테스트 결과 (Spring Cache 적용)</strong>
<img src="https://velog.velcdn.com/images/_mung/post/d9a8d01a-7bb2-404a-823b-a823871ec902/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>지표</th>
<th>Redis Cache 적용</th>
<th>Spring Cache 적용</th>
<th>개선율</th>
</tr>
</thead>
<tbody><tr>
<td><strong>총 요청 수 (<code>http_reqs</code>)</strong></td>
<td>474,188</td>
<td>473,652</td>
<td><strong>0.11% 감소</strong></td>
</tr>
<tr>
<td><strong>응답 시간 평균 (<code>http_req_duration avg</code>)</strong></td>
<td>12.33ms</td>
<td>13.2ms</td>
<td><strong>7.04% 증가</strong></td>
</tr>
<tr>
<td><strong>응답 시간 90퍼센트 (<code>p(90)</code>)</strong></td>
<td>23.89ms</td>
<td>27.01ms</td>
<td><strong>13.04% 증가</strong></td>
</tr>
<tr>
<td><strong>응답 시간 95퍼센트 (<code>p(95)</code>)</strong></td>
<td>33.22ms</td>
<td>38.22ms</td>
<td><strong>15.05% 증가</strong></td>
</tr>
<tr>
<td><strong>대기 시간 평균 (<code>http_req_waiting avg</code>)</strong></td>
<td>12.26ms</td>
<td>13.13ms</td>
<td><strong>7.1% 증가</strong></td>
</tr>
<tr>
<td><strong>최대 응답 시간 (<code>http_req_duration max</code>)</strong></td>
<td>979.22ms</td>
<td>2.08s</td>
<td><strong>112.4% 증가</strong></td>
</tr>
</tbody></table>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>이번 테스트는 현실보다 훨씬 높은 부하인 1000명의 동시 사용자(VUs)를 가정한 환경에서 진행되었다.<br>이러한 조건은 실서비스에서 자주 발생하진 않지만, 최악의 상황을 가정한 성능 안정성 평가로는 의미가 있다.</p>
<p>Spring Cache는 Redis Cache에 비해 평균 응답 시간과 최대 응답 시간에서 다소 느린 결과를 보였지만,  극단적인 부하 환경에서 나타난 차이이며, 실제 운영 상황에서는 그 차이가 체감될 수준은 아니라고 생각한다.</p>
<p>*<em>📍 Spring Cache 적용으로 얻은 이점 *</em></p>
<ul>
<li>Redis 인프라 운영 부담 제거  </li>
<li>네트워크 I/O/직렬화 비용 감소  </li>
<li>설정 간소화  </li>
</ul>
<blockquote>
<p>결과적으로 속도는 약간 낮아졌지만, 운영 비용을 줄였다는 점에서 충분히 좋은 선택이라고 생각한다.</p>
</blockquote>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - MoodBuddy🔥] 조회 속도를 높이기 위한 캐싱,  과연 정답일까?]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EA%B0%9C%EC%9D%B8-%EA%B3%B5%EA%B0%84%EC%97%90%EC%84%9C%EC%9D%98-%EC%A1%B0%ED%9A%8C-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EA%B0%9C%EC%9D%B8-%EA%B3%B5%EA%B0%84%EC%97%90%EC%84%9C%EC%9D%98-%EC%A1%B0%ED%9A%8C-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 13 Mar 2025 12:27:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/0beaedb0-4b4f-421e-8b17-b930d2a43b8d/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>처음 1차 개발 단계에서 서비스에 있어서 조회 기능들은 모두 Spring Data JPA를 활용해 DB에 접근하여 조회하는 방식으로 구현했다.</p>
<p><strong>📍 일기 목록 조회 기능 (캐싱 X)</strong></p>
<pre><code class="language-java">    @Override
    public PageCustom&lt;DiaryResQueryDTO&gt; getDiaries(final Long userId, Pageable pageable) {
        return diaryQueryRepository.findDiariesWithPageable(userId, pageable);
    }</code></pre>
<p>하지만 1차 개발 이후, 단순히 조회 속도를 높이고 싶은 마음에 Redis 캐싱을 모든 조회 기능에 적용해서 리팩토링했다.</p>
<p><strong>📍 일기 목록 조회 기능 (캐싱 O)</strong></p>
<pre><code class="language-java">    @Override
    @Cacheable(
            cacheNames = &quot;getDiaries&quot;,
            key = &quot;&#39;userId:&#39;+#userId+&#39;_&#39;+&#39;pageable.offset:&#39;+#pageable.offset+&#39;_&#39;+&#39;pageable.pageSize:&#39;+#pageable.pageSize&quot;,
            unless = &quot;#result == null&quot;
    )
    public PageCustom&lt;DiaryResQueryDTO&gt; getDiaries(final Long userId, Pageable pageable) {
        return diaryQueryRepository.findDiariesWithPageable(userId, pageable);
    }</code></pre>
<p>Redis 캐싱을 적용한 덕분에 조회 속도를 전보다 훨씬 향상시킬 수 있었다. 하지만 구현을 한 후 생각해 보니 아래와 같은 의문점들이 생겨났다.</p>
<p><strong>1. 기획 특성상 무드버디는 개인적인 공간이다. 그런데 모든 조회 기능을 캐싱할 필요가 있을까?
2. 필터링 조회는 사용자가 입력한 조건이 매번 다르다. 그럼 캐싱이 불필요하지 않을까?
3. 현재 Redis의 TTL를 사용자마다 같게 해둔 상태이다. 그렇다면 캐시 스탬피드 현상이 발생하지 않을까?</strong></p>
<hr>
<h4 id="how">How</h4>
<p><strong>1. 기획 특성상 무드버디는 개인적인 공간이다. 그런데 모든 조회 기능을 캐싱할 필요가 있을까?</strong>
전체 일기 조회 기능은 캐싱을 적용하지만, 나머지 감정 일기 조회, 필터링 일기 조회와 같이 매번 다르게 조회되는 경우는 캐싱을 제거할 예정이다.</p>
<p><strong>2. 필터링 조회는 사용자가 입력한 조건이 매번 다르다. 그럼 캐싱이 불필요하지 않을까?</strong>
필터링 같은 경우는 사용자가 매번 다르게 조회할 가능성이 매우 크다. 그렇기 때문에 캐싱은 불필요하다고 판단된다.</p>
<p><strong>3. 현재 Redis의 TTL를 사용자마다 같게 해둔 상태이다. 그렇다면 캐시 스탬피드 현상이 발생하지 않을까?</strong>
TTL을 사용자마다 같에 설정하여 캐시 스탬피드 현상이 발생할 가능성이 크다.</p>
<blockquote>
<p><strong><em>캐시 스탬피드 현상이란?</em></strong>
캐시 스탬피드는 캐시가 만료될 때, 여러 요청이 동시에 DB로 몰리면서 부하가 발생하는 현상을 말한다. 
즉 쉽게 말하면, 캐시 만료 후 수많은 요청이 동시에 DB로 몰려 서버가 과부하되는 문제이다.</p>
<p><strong>캐시 스탬피드 현상의 시나리오는 아래와 같다.</strong>
<strong>1. 캐시가 만료된다.</strong>
<strong>2. 모든 요청이 캐시를 찾지만, 데이터가 없다.</strong>
<strong>3. 동시에 모든 요청이 DB에서 데이터를 가져오려 한다.</strong>
<strong>4. DB 부하 급증한다.</strong></p>
</blockquote>
<p>이를 해결하기 위해 아래와 같은 방법들을 고려했다.</p>
<p><strong>1. 스케줄러를 통해 캐시된 테이터가 제거될 때쯤에 갱신하는 방법</strong>
<strong>2. 캐시 만료 시간을 사용자마다 랜덤으로 배정하는 방법</strong></p>
<p><strong>1. 스케줄러를 통해 캐시된 테이터가 제거될 때쯤에 갱신하는 방법</strong>
스케줄러를 통해 매번 캐시된 데이터가 제거될 때쯤에 갱신하는 방식은 과한 방식이라고 판단했다. 
그 이유는 만약 사용자가 장기간 접속하지 않은 사용자임에도 불구하고 계속 캐싱을 갱신해 주는건 불필요하다고 생각했다.</p>
<p><strong>2. 캐시 만료 시간을 사용자마다 랜덤으로 배정하는 방법</strong>
캐시 만료 시간을 사용자마다 랜덤으로 배정해 주는건 좋은 방안이라고 생각했다. 이렇게 적용한다면 동시에 캐시 데이터를 갱싱할 일을 방지할 수 있기 때문이다.</p>
<p><strong>✨ 새로운 방안 ✨</strong>
무드버디는 사용자만의 공간이다. 그렇기 때문에 사용자가 일기를 작성, 수정, 삭제하는 일이 없다면 일기 목록들은 항상 똑같다. 그렇기 때문에 스케줄러로 캐시 데이터를 갱신하지 않고 TTL을 길게 유지한 후 일기 저장, 수정, 삭제할 때마다 캐시 데이터를 갱신하는 방법이다.
또 혹시라도 발생할 수 있는 캐시 스탬피드 현상을 방지하고자 TTL 시간을 랜덤으로 배정하는 것이다.</p>
<hr>
<h4 id="process">Process</h4>
<p>먼저 매번 조건이 달라지는 일기 조회 기능에는 캐싱 적용을 제거했다.</p>
<pre><code class="language-java">    @Override
    public PageCustom&lt;DiaryResQueryDTO&gt; getDiariesByEmotion(final Long userId, DiaryEmotion diaryEmotion, Pageable pageable) {
        return diaryQueryRepository.findDiariesByEmotionWithPageable(userId, diaryEmotion, pageable);
    }

    @Override
    public PageCustom&lt;DiaryResQueryDTO&gt; getDiariesByFilter(final Long userId, DiaryReqFilterDTO requestDTO, Pageable pageable) {
        return diaryQueryRepository.findDiariesByFilterWithPageable(userId, requestDTO, pageable);
    }</code></pre>
<p>그리고 날짜 순으로 오래된 순, 최신 순으로 일기가 캐싱 조회될 수 있게 아래와 같이 구현했다.</p>
<pre><code class="language-java">    @Override
    @Cacheable(
            cacheNames = &quot;diaries&quot;,
            key = &quot;&#39;userId:&#39; + #userId + &#39;_sort:&#39; + #isAscending + &#39;_page:&#39; + #pageable.pageNumber + &#39;_size:&#39; + #pageable.pageSize&quot;,
            unless = &quot;#result == null&quot;
    )
    public PageCustom&lt;DiaryResQueryDTO&gt; getDiaries(final Long userId, boolean isAscending, Pageable pageable) {
        return diaryQueryRepository.findDiariesWithPageable(userId, isAscending, pageable);
    }</code></pre>
<p>그리고 일기 저장, 수정, 삭제 시 캐시 데이터를 초기화하고 첫 페이지만 캐시에 갱신될 수 있도록 구현했다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class RedisServiceImpl implements RedisService {
    private final RedisTemplate&lt;String, String&gt; redisTemplate;
    private final DiaryQueryService diaryQueryService;
    private static final String DIARIES_CACHE_PREFIX = &quot;diaries::userId:&quot;;
    private static final String DIARY_COUNT_CACHE_PREFIX = &quot;diary_count:userId:&quot;;
    private static final int PAGE_SIZE = 20;

    @Override
    public void deleteDiaryCaches(Long userId) {
        deleteCacheByUserIdAndCacheName(userId);
        deleteCountCacheByUserId(userId);
        cacheFirstPage(userId);
    }

    private void deleteCacheByUserIdAndCacheName(Long userId) {
        var pattern = DIARIES_CACHE_PREFIX + userId + &quot;*&quot;;
        var keys = redisTemplate.keys(pattern);
        if (keys != null &amp;&amp; !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }

    private void deleteCountCacheByUserId(Long userId) {
        String pattern = DIARY_COUNT_CACHE_PREFIX + userId;
        var keys = redisTemplate.keys(pattern);
        if (keys != null &amp;&amp; !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }

    private void cacheFirstPage(Long userId) {
        diaryQueryService.refreshDiariesCache(userId, false, PageRequest.of(0, PAGE_SIZE));
    }
}</code></pre>
<p>또 모든 사용자가 TTL이 같으면 캐시 스탬피드 현상이 일어날 수 있기 때문에, 캐시 데이터를 저장할 때 TTL을 24시간을 기준으로 랜덤 저장할 수 있게 했다.</p>
<pre><code class="language-java">    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
        Map&lt;String, RedisCacheConfiguration&gt; cacheConfigurations = new HashMap&lt;&gt;();
        int randomTTL = 24 + (int) (Math.random() * 5);
        cacheConfigurations.put(&quot;diaries&quot;, defaultCacheConfiguration().entryTtl(Duration.ofHours(randomTTL)));
        return RedisCacheManager.builder(cf)
                .cacheDefaults(defaultCacheConfiguration())
                .withInitialCacheConfigurations(cacheConfigurations)
                .build();
    }</code></pre>
<hr>
<h4 id="result">Result</h4>
<p><img src="https://velog.velcdn.com/images/_mung/post/76ca17b5-621c-4f53-a763-5de1dafdeed9/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/c9875134-cae3-417e-b364-beee4fa2c11a/image.png" alt=""></p>
<p>정상적으로 조회 및 캐시에 저장되는 걸 확인할 수 있다.</p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>캐싱 조회만 하면 속도가 빨라지고 속도를 개선만 한다면 이것이 정답이라고 생각했다.</p>
<p>하지만 단순히 캐시에 데이터를 저장하고 조회하는 건 &quot;캐시 스탬피드 현상&quot;과 같은 문제를 초례할 수 있기 때문에 캐시 데이터를 저장할 때 신중해야 한다는 걸 배울 수 있었다.</p>
<p>또 모든 데이터를 캐시에 저장하기보단 상황에 따라 캐싱 적용이 필요할지 안 할지 판단하는 것이 중요하다는 걸 배웠다.</p>
<p>성능만 중요시하는 개발자가 아닌, 상황에 따라 설계를 할 수 있는 개발자로 성장해야겠다고 다짐했다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - TicToc🔥] 사용자 로그인 기록 관리 어떻게 해야 할까‼️❓‼️❓]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-TicToc-%EC%82%AC%EC%9A%A9%EC%9E%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%A1%9D-%EA%B4%80%EB%A6%AC-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@_mung/TroubleShooting-TicToc-%EC%82%AC%EC%9A%A9%EC%9E%90-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%A1%9D-%EA%B4%80%EB%A6%AC-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sun, 09 Mar 2025 07:56:00 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/70af0f10-659c-4f12-9f1e-18050d727bf7/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : 개인 프로젝트
<strong>기간</strong> : 2025.01 ~ 진행 중
<strong>링크</strong> : <a href="https://github.com/M-ung/TicToc_Server">https://github.com/M-ung/TicToc_Server</a>
<strong>서비스 내용</strong> : 당신의 시간에 가치를 매기다, 시간 거래 경매 플랫폼</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>&quot;TicToc&quot; 서비스를 개발하면서 실제 배포를 목적으로 하기 때문에 서비스를 운영하는 입장에서 설계를 하게 됐다. 그래서 사용자 로그인 기록을 갖고 있는게 좋다고 판단하였기에 사용자 로그인 기록(UserLoginHistory) 테이블을 따로 생성했다.</p>
<p>로그인 기록은 사용자가 로그인 한 시점에 즉 JWT 토큰 발급 시점에 저장을 하도록 기능을 구현했다.</p>
<p>하지만 여기서 문제는 사용자 로그인 기록을 단순히 DB에 저장하기에는 한 사용자가 하루에 로그인을 10번만 해도 10개가 쌓이는 상황이 발생한다. 그렇지만 하루에 한 명만 접속할 일이 없다. 10명의 사용자가 10번 로그인을 하면 테이블에는 하루에만 100개가 넘게 쌓이게 된다.</p>
<hr>
<h4 id="how">How</h4>
<p>그래서 위 문제를 해결하기 위해 단순히 DB에 계속 저장하기 보단 시간을 두고 특정 기간의 데이터를 지울 필요가 있다고 판단했다. 그래서 Spring Batch를 적용해 일주일마다 일주일 치 데이터를 지우는 설계를 하기로 했다.</p>
<p>하지만 단순히 또 데이터를 시간을 두고 지우기에는 서비스를 운영하는 입장에서 좋지 않은 방향일 수 있다. 그렇기 때문에 모든 로그인 기록을 저장할 공간이 필요했다. 그래서 DB가 아닌, UserLoginHistory.log라는 파일을 만들어 로그인 기록을 DB와 log 파일에 동시에 저장하기로 했다.</p>
<hr>
<h4 id="process">Process</h4>
<p>Spring Batch를 활용한 시나리오는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/2d5acbf3-8f32-4d4e-b110-0c0831d030d4/image.png" alt=""></p>
<p>Spring Batch를 적용하다가, 만약 Spring 서버가 다운되면 Spring Batch 또한 동작이 멈출 것이라는 생각이 들었다. 그렇기 때문에 Spring Batch를 동작할 서버와 API 서버를 분리해서 배포해야겠다고 판단했다.</p>
<p>다행히 우리 프로젝트는 멀티모듈로 구성되어 있기 때문에 분리하기 어렵지 않았다. </p>
<p>하지만 여기서 문제는 사용자 로그인 기록을 DB와 log 파일에 저장하는 로직을 Spring Batch 서버에서 관리하려고 했지만, API 서버와 Spring Batch 서버를 이어줄 &quot;무언가&quot; 가 필요했다.</p>
<p>처음에는 WebFlux를 적용해 관리하려 했지만, 사용자 로그인 기록은 순식간에 많은 요청이 들어올 수 있고 끊임없이 요청이 들어올 수 있기 때문에 대용량을 관리할 필요가 있었다.</p>
<p>그래서 카프카를 적용해 보기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/4083321d-26bf-4039-98a5-9fb897058d56/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/3b0cf8db-ffed-4052-860c-337346422bc0/image.png" alt=""></p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. Kafka로 이벤트 비동기 처리 → 응답 속도 향상, DB 부하 감소.</strong>
<strong>2. Spring Batch 서버에서 주기적 DB 삭제 및 로그 파일 백업 → 데이터 유실 방지.</strong>
<strong>3. API 서버와 Batch 서버를 분리하여, 확장성과 유지보수 용이성 확보.</strong></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>이번 경험을 통해 실제 운영 환경을 고려한 사용자 로그인 기록 저장 방식을 고민하며 여러 가지 문제를 해결해 나갈 수 있었다.</p>
<p>처음에는 단순히 DB에 로그인 기록을 저장하는 방식을 사용하려 했지만, 로그인 요청이 많아질수록 데이터가 기하급수적으로 증가하는 문제를 처음 마주했다.
이를 해결하기 위해 Spring Batch를 활용해 주기적 데이터 삭제를 적용하려 했지만, 로그인 기록을 모두 삭제하는 것은 운영적인 측면에서 문제될 것 같았다.</p>
<p>그래서 DB뿐만 아니라 로그 파일에도 저장하는 방식을 채택했다.
하지만 API 서버에서 직접 Spring Batch 서버로 데이터를 저장하려면 서버 간 통신 비용이 증가하고, 서버 장애 발생 시 데이터 유실 위험이 있었다.</p>
<p>이를 해결하기 위해 Kafka를 활용하여 API 서버와 Spring Batch 서버를 분리하였다.
Kafka를 도입함으로써 로그인 기록을 비동기적으로 처리할 수 있었으며, 데이터 유실을 방지할 수 있었다.</p>
<p>또 Spring Batch 서버가 API 서버와 별도로 운영되도록 구성하여, API 서버의 부하를 줄이고 안정적인 데이터 처리를 보장할 수 있도록 했다.</p>
<p>이번 경험에서 단순한 기능 구현이 아니라, 실제 서비스 운영을 고려한 아키텍처 설계의 중요성을 다시 한 번 느낄 수 있었다.</p>
<p>앞으로도 운영 측면에서 유지보수하기 좋은 설계를 고민하며 개발하는 개발자로 성장할 것이다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - MoodBuddy🔥] 너의 쿼디티아이는 뭐니?!]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EB%84%88%EC%9D%98-%EC%BF%BC%EB%94%94%ED%8B%B0%EC%95%84%EC%9D%B4%EB%8A%94-%EB%AD%90%EB%8B%88</link>
            <guid>https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EB%84%88%EC%9D%98-%EC%BF%BC%EB%94%94%ED%8B%B0%EC%95%84%EC%9D%B4%EB%8A%94-%EB%AD%90%EB%8B%88</guid>
            <pubDate>Sun, 09 Mar 2025 05:49:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/bb08d076-799a-4e1f-8d68-180ad76ab4c1/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>우리 서비스 &#39;MoodBuddy&quot; 에는 특별한 기능이 있다. 바로 &quot;쿼디티아이(QuddyTI)&quot; 이다. </p>
<blockquote>
<p><em><strong>쿼디티아이(QuddyTI)란?</strong></em>
단순히 MBTI를 생각하면 된다. 
사용자가 한 달 동안 작성한 일기를 기반으로 &quot;일기 작성 횟수, 일기 분석을 통해 나온 감정, 주제&quot; 를 기반으로 일기 성격 유형을 판별해주는 서비스이다.
이는 한 달 기반으로 결과가 나와야 하기 때문에 매달 00시에 쿼디티아이(QuddyTI) 생성(현재 달) 및 수정(지난 달)이 발생한다.</p>
</blockquote>
<p>처음 1차 배포 때는 단순히 스케줄러를 사용해서 아래와 같이 구현했다.</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class QuddyTIScheduler {
    private final QuddyTIFacade quddyTIFacade;

    @Scheduled(cron = &quot;0 0 0 1 * ?&quot;)
    @Transactional
    public void aggregateAndSaveDiaryData() {
        quddyTIFacade.createAndUpadteQuddyTI();
    }
}</code></pre>
<p>스케줄러를 돌려 매달 00시에 동작하도록 구현했다.</p>
<p>하지만 아래와 같은 문제점들이 발생했다.</p>
<p><strong>1. 한 번에 모든 사용자의 데이터를 처리하려다 보니 데이터가 많아질수록 쿼리 부하  증가</strong>
<strong>2. 트랜잭션이 길어지고, 전체 데이터 처리 시간이 늘어나면서 서버 부하 발생</strong>
<strong>3. 사용자가 많아질수록 데이터 증가로 인해 하나의 스케줄러에서 처리하는 방식이 점점 부담스러워짐</strong></p>
<hr>
<h4 id="how">How</h4>
<p>그래서 위 문제들을 해결하기 위해 &quot;Spring Batch&quot; 를 우리 서비스에 도입하기로 했다.</p>
<blockquote>
<p><em><strong>Spring Batch란?</strong></em>
Spring Batch는 대량의 데이터를 효과적으로 처리할 수 있도록 설계된 배치 처리 프레임워크다.
스케줄러가 단순 반복 실행을 하는 것과 달리, 대량 데이터를 효율적으로 나누어 처리하고, 재시도 및 실패 복구 기능을 제공한다.</p>
</blockquote>
<p><strong>Spring Batch의 핵심 개념</strong></p>
<ol>
<li>Job: 하나의 배치 작업 단위</li>
<li>Step: Job을 구성하는 개별 단계 </li>
<li>ItemReader: 데이터를 읽는 역할 </li>
<li>ItemProcessor: 데이터를 가공하는 역할 </li>
<li>ItemWriter: 데이터를 저장하는 역할 </li>
</ol>
<p>위 &quot;Spring Batch&quot; 를 적용하게 되면 아래와 같은 기대 효과를 얻을 수 있다.</p>
<p><strong>1. 성능 개선: 작은 단위로 데이터를 처리하여 DB 부하 감소</strong>
<strong>2. 안정성 향상: 실패한 작업을 자동으로 재시도 가능</strong></p>
<hr>
<h4 id="process">Process</h4>
<p>먼저 기존 스케줄러는 Spring Batch의 트리거 역할을 할 수 있게 변경한 후 아래와 같이 설계했다. 그리고 Spring Data JPA를 활용해서 생성, 수정, 조회를 해결하려고 했다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/56cf52b9-eebd-45e6-ad7f-e5b58c79a4d4/image.jpg" alt=""> </p>
<p>하지만 Spring Data JPA를 활용한 방식에는 아래와 같은 문제점들이 있었다.</p>
<p><strong>1. 대량 데이터 처리 시 성능 저하</strong>
<strong>2. JPA의 기본적인 Bulk Update/Insert의 한계</strong></p>
<p>그래서 Spring Data JPA 보다 JDBC 방식으로 생성, 수정, 조회를 구현하였다.
코드는 아래와 같다.
또한, 쿼디티아이 작업을 하나로 하는 것이 아닌, 생성과 수정 작업을 두 개로 분리하여 DB 부하를 분산하기로 했다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/349343a3-09d0-48fc-bc8d-665fbdd6ba0d/image.png" alt=""></p>
<p>스케줄러는 두 개로 나눴더라도 같은 시간에 실행하면 위와 같은 문제가 발생할 수 있기 때문에 생성과 수정 스케줄러를 각각 다른 매달 말일과 1일로 분리해서 실행하도록 설계했다.</p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. 사용자 10,000명 기준, 쿼디티아이 생성 평균 약 919ms, 수정 평균 약 14,528ms로 성능 개선.</strong>
<strong>2. 스케줄러 → Spring Batch 트리거 방식으로 전환하여 유지보수성을 향상.</strong>
<strong>3. JPA 대신 JDBC 사용으로 더티 체킹 오버헤드를 제거하고 batch 성능 최적화.</strong>
<strong>4. 쿼디티아이 생성과 수정 작업을 분리하여 DB 부하를 분산</strong></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>이번 Spring Batch 도입을 통해 스케줄러 기반의 대량 데이터 처리를 개선하는 경험을 할 수 있었다.</p>
<p>처음 적용한 스케줄러 방식에서는 대량 데이터 조회 및 업데이트로 인해 트랜잭션이 길어지고 성능 저하 문제가 발생했지만, Spring Batch를 활용하여 작은 단위로 나누어 처리할 수 있었으며 성능을 개선하고 안정성을 확보할 수 있었다.</p>
<p>위 경험들을 통해 대량 데이터 처리는 단순히 스케줄러로 적용해야겠다는 생각보단 Spring Batch와 같은 프레임워크를 적용하는 것이 필수적이라는 생각을 했다.</p>
<p>또 Spring Data JPA의 편리성으로 인해 이에 의존하기보단 알고 쓰는 것이 중요하다는 것을 깨달았다. 불편하더라도 성능적으로 떨어지면 JDBC와 같은 기술을 적용할 수 있는 설계 능력을 갖춘 개발자가 되어야겠다고 생각했다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - MoodBuddy🔥] 동시에 여러 기기로 일기에 접근한다면..?]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EB%8F%99%EC%8B%9C%EC%97%90-%EB%8B%A4%EB%A5%B8-%EA%B8%B0%EA%B8%B0-2%EB%8C%80%EB%A1%9C-%EC%9D%BC%EA%B8%B0%EC%97%90-%EC%A0%91%EA%B7%BC%ED%95%9C%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@_mung/TroubleShooting-MoodBuddy-%EB%8F%99%EC%8B%9C%EC%97%90-%EB%8B%A4%EB%A5%B8-%EA%B8%B0%EA%B8%B0-2%EB%8C%80%EB%A1%9C-%EC%9D%BC%EA%B8%B0%EC%97%90-%EC%A0%91%EA%B7%BC%ED%95%9C%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Sun, 09 Mar 2025 03:45:04 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/bfa1eb39-1436-4fe8-8701-18f98dad7eba/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>우리 서비스 &quot;MoodBuddy&quot; 는 사용자 혼자만의 공간이고 다른 사용자가 접근할 수 없는 공간이기 때문에 동시성을 크게 고려하지 않았다. 그 이유는 만약 일기 수정, 일기 삭제, 북마크, 등의 기능을 하나의 일기를 가지고 동시에 호출할 일이 없기 때문이다.</p>
<p>그래서 동시성을 고려하지 않고 기능 구현에 몰두했다.</p>
<p>하.지.만 만약에 &#39;동시에 여러 기기로 일기에 접근한다면..?&#39; 라는 생각을 했다. 예를 들어 사용자가 노트북, 핸드폰으로 동시에 일기 수정 또는 삭제를 한다면 동시성 문제가 발생하게 된다. </p>
<p>하지만 위 같은 경우는 정말 정말 정말 적은 일이라고 생각한다. 그래도 개발자라면 위 같은 경우도 생각해서 개발을 해야 한다고 생각하기 때문에 동시성 문제를 해결하기로 했다.</p>
<hr>
<h4 id="how">How</h4>
<p>위에서 말했듯이 위 같은 경우는 자주 일어날 일이 아니라고 판단하였기에 최대한 간단하고 가볍게 동시성 문제를 해결하기로 했다. 그래서 선택한 방안은 &quot;낙관적 락&quot;이다.</p>
<p>그 이유는 아래와 같다.</p>
<p><strong>낙관적 락은 동시 수정이 적은 경우 효율적이다.</strong>
<strong>트랜잭션 충돌이 발생할 때만 감지하여 성능 저하가 적다.</strong>
<strong>비관적 락보다 데이터베이스 락을 최소화하여 부하를 줄인다.</strong></p>
<p>그렇기 때문에 자주 발생하지 않는 동시성 문제를 해결하기 적합하다고 판단했다.</p>
<hr>
<h4 id="process">Process</h4>
<p>먼저 일기(Diary) 엔티티데 <code>version</code> 을 추가해 주었다.</p>
<p><strong>📍 Diary.java</strong></p>
<pre><code class="language-java">@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;diary&quot;)
public class Diary extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;id&quot;)
    private Long id;

    @Column(name = &quot;title&quot;, nullable = false)
    private String title;

    @Column(name = &quot;date&quot;, nullable = false)
    private LocalDate date;

    @Column(name = &quot;content&quot;, nullable = false, columnDefinition = &quot;text&quot;)
    private String content;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;weather&quot;, nullable = false)
    private DiaryWeather weather;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;emotion&quot;)
    private DiaryEmotion emotion;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;subject&quot;)
    private DiarySubject subject;

    @Column(name = &quot;summary&quot;, columnDefinition = &quot;varchar(255)&quot;)
    private String summary;

    @Column(name = &quot;user_id&quot;, nullable = false, columnDefinition = &quot;bigint&quot;)
    private Long userId;

    @Column(name = &quot;book_mark&quot;)
    private Boolean bookMark;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;font&quot;)
    private DiaryFont font;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;font_size&quot;)
    private DiaryFontSize fontSize;

    @Column(name = &quot;thumbnail&quot;, columnDefinition = &quot;text&quot;)
    private String thumbnail;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;mood_buddy_status&quot;)
    private MoodBuddyStatus moodBuddyStatus;

    @Version
    @Column(name = &quot;version&quot;, nullable = false)
    private Long version;
 }</code></pre>
<p>다음으로 일기 수정과 일기 삭제 기능을 아래와 같이 version 충돌이 일어나는지 감지할 수 있도록 구현해 주었다.</p>
<p><strong>📍 DiaryServiceImpl.java</strong></p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DiaryServiceImpl implements DiaryService {
    private final DiaryRepository diaryRepository;

    @Override
    @Transactional
    public Long updateDiary(final Long userId,  DiaryReqUpdateDTO requestDTO) {
        try {
            var findDiary = findDiaryById(userId, requestDTO.diaryId());
            findDiary.updateDiary(requestDTO);
            return findDiary.getId();
        } catch (ObjectOptimisticLockingFailureException ex) {
            throw new DiaryConcurrentUpdateException(ErrorCode.DIARY_CONCURRENT_UPDATE);
        }
    }

    @Override
    @Transactional
    public LocalDate deleteDiary(final Long userId,  final Long diaryId) {
        try {
            final var findDiary = findDiaryById(userId, diaryId);
            findDiary.updateMoodBuddyStatus(MoodBuddyStatus.DIS_ACTIVE);
            return findDiary.getDate();
        } catch (OptimisticLockException ex) {
            throw new DiaryConcurrentUpdateException(ErrorCode.DIARY_CONCURRENT_DELETE);
        }
    }
}</code></pre>
<p><strong>📍 DiaryRepository.java</strong></p>
<pre><code class="language-java">public interface DiaryRepository extends JpaRepository&lt;Diary, Long&gt;, DiaryRepositoryCustom {
    boolean existsByUserIdAndDate(Long userId, LocalDate date);

    @Lock(LockModeType.OPTIMISTIC)
    Optional&lt;Diary&gt; findByUserIdAndIdAndMoodBuddyStatus(final Long userId, final Long diaryId, MoodBuddyStatus moodBuddyStatus);
}</code></pre>
<p>또 위 일기 수정과 일기 삭제 기능을 구현하면서 추가적으로 든 고민이 &quot;일기 저장&quot; 또한 동시성 문제를 해결할 필요가 있다고 생각했다. 그 이유는 우리 &quot;MoodBuddy&quot; 서비스는 하루에 한 번 일기를 작성할 수 있기 때문에 하나의 날짜에 일기가 2번 이상 작성되면 안 되기 때문이다. 그래서 이 문제는 테이블에 제약 조건을 걸어서 간단히 해결했다.</p>
<pre><code class="language-java">@Table(name = &quot;diary&quot;, 
uniqueConstraints = @UniqueConstraint(columnNames = {&quot;userId&quot;, &quot;date&quot;}))</code></pre>
<p><img src="https://velog.velcdn.com/images/_mung/post/09f7c25f-d2e6-4ff3-9d1d-a13b3a681c64/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/3f4d44f8-5f44-43de-a8ec-9d20f1ceaffa/image.png" alt=""></p>
<p>테스트 모두 통과하는 걸 볼 수 있다.</p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. 희박한 동시성 충돌 상황을 고려해 낙관적 락으로 처리.</strong>
<strong>2. 10대 기기 기준, 동시에 요청 중 1대 성공, 9대 실패.</strong></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>이번 경험을 통해 동시성 문제는 예상하지 못한 곳에서도 발생할 수 있다는 점을 다시 한번 깨달았다. 처음에는 사용자가 같은 일기를 여러 기기를 가지고 동시에 수정하거나 삭제하는 일이 극히 드물다고 생각했지만, 개발자로서 극히 드문 경우라도 대비하는 것이 좋은 서비스의 기본이라는 점을 느끼고 배울 수 있었다.</p>
<p>특히 낙관적 락을 적용하면서 동시성 문제를 단순하고 가볍게 해결할 수 있었고, JPA의 <code>@Version</code> 기능과 <code>@UniqueConstraint</code> 를 활용해 불필요한 데이터베이스 락을 최소화하면서도 안정적인 데이터 무결성을 보장할 수 있었다.</p>
<p>위 문제를 해결하면서 여러 동시성 문제 해결 방법을 알게 되었지만, 동시성 문제를 해결하는 방법은 서비스의 특성에 맞춰 신중하게 선택해야 한다는 점도 깨달았다. 
만약 일기의 동시 수정과 삭제가 잦거나 사용자가 다른 사용자와 일기를 공유하는 서비스였다면 낙관적 락보다는 비관적 락이나 분산 락을 고려했을 것이다.</p>
<p>이번 경험을 통해 서비스의 특징에 따라 적절한 동시성 제어 방법을 적용하는 것이 중요하며, 성능과 안정성을 모두 고려해야 한다는 점을 몸소 배울 수 있었다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - MoodBuddy🔥] Service 계층이 무겁다면, Facade 패턴 어때?]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-MoodBuddy-Service-%EA%B3%84%EC%B8%B5%EC%9D%B4-%EB%AC%B4%EA%B2%81%EB%8B%A4%EB%A9%B4-Facade-%ED%8C%A8%ED%84%B4-%EC%96%B4%EB%95%8C</link>
            <guid>https://velog.io/@_mung/TroubleShooting-MoodBuddy-Service-%EA%B3%84%EC%B8%B5%EC%9D%B4-%EB%AC%B4%EA%B2%81%EB%8B%A4%EB%A9%B4-Facade-%ED%8C%A8%ED%84%B4-%EC%96%B4%EB%95%8C</guid>
            <pubDate>Sat, 08 Mar 2025 16:13:32 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/a0cf4445-eebc-46cc-a3f6-b57f33042f63/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : PM(1) / Design(1) / Frontend(2) / Backend(3)
<strong>기간</strong> : 2024.03 ~ 2025.03
<strong>링크</strong> : <a href="https://github.com/M-ung/MoodBuddy_Server">https://github.com/M-ung/MoodBuddy_Server</a>
<strong>서비스 내용</strong> : 사용자가 작성한 일기를 바탕으로 감정 분석하는 웹 서비스
<strong>소통</strong> : GitHub, Slack, Notion, Discord</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>항상 개발을 하면서 드는 고민 중 하나가 <code>Service</code> 계층에 여러 <code>Repository</code> 를 주입해야 할지, 아니면 여러 <code>Service</code> 를 주입해야 할지 고민이 든다.</p>
<p><strong>📍 여러 Repository 주입한 경우</strong></p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DiaryServiceImpl implements DiaryService {
    private final UserRepository userRepository;
    private final ProfileRepository profileRepository;
    private final ProfileImageRepository profileImageRepository;
    private final DiaryRepository diaryRepository;
    private final DiaryImageRepository diaryImageRepository;

}</code></pre>
<p><strong>📍 여러 Service 주입한 경우</strong></p>
<pre><code class="language-java">@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DiaryServiceImpl implements DiaryService {
    private final UserService userService;
    private final ProfileService profileService;
    private final ProfileImageService profileImageService;
    private final DiaryImageService diaryImageService;

}</code></pre>
<p>두 가지 방법을 다 적용해 보았지만, &quot;여러 Repository 주입한 경우&quot;는 구현하는 데에 있어서 추가 코드 없이 각 도메인에 <code>Repository</code> 를 통해 접근할 수 있어서 간편했다. 하지만 중복되는 메서드가 각 객체마다 생겨난다는 단점이 있었다.
반대로 &quot;여러 Service 주입한 경우&quot;는 중복되는 코드를 줄일 수 있어서 좋았지만, 코드의 복잡성이 늘어났다.</p>
<hr>
<h4 id="how">How</h4>
<p>결국에는 두 방법 중 어떤 걸 쓰든, 코드를 봤을 때 <code>Service</code> 계층이 너무 무겁게 느껴졌다.</p>
<p>그래서 또 다른 방법은 찾는 도중 &quot;퍼사드 패턴&quot;을 알게 되었다.</p>
<blockquote>
<p><em><strong>퍼사드 패턴이란?</strong></em>
퍼사드 패턴은 복잡한 내부 시스템을 단순화하여 클라이언트가 쉽게 사용할 수 있도록 하는 디자인 패턴이다.</p>
</blockquote>
<p>퍼사드 계층을 추가하여 <code>Controller</code> - <code>Facade</code> - <code>Service</code> - <code>Repository</code> 로 구조화하는 것이다.</p>
<p>그래서 위 &quot;퍼사드 패턴&quot; 방식을 적용해 보기로 했다.</p>
<hr>
<h4 id="process">Process</h4>
<p>먼저 큰 틀은 &quot;퍼사드 패턴&quot;을 가져오지만 우리 팀만의 규칙이 추가적으로 필요하다고 생각했다. 그래서 아래와 같은 규칙을 만들었다.</p>
<p><strong>1. 계층 순서는 <code>Controller</code> - <code>Facade</code> - <code>Service</code> - <code>Repository</code> 순으로 유지한다.</strong>
<strong>2. 하나의 <code>Service</code> 는 최대한 역할에 맞게 하나의 Repository를 갖는다.</strong>
<strong>3. 하나의 <code>Facade</code> 는 해당 도메인 안에서 여러 Service를 가질 수 있다.</strong></p>
<p>즉 구조는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/d3a13eab-e5a8-43b1-bdf2-dd9df899dd82/image.png" alt=""></p>
<p>그럼 구조에 맞게 디렉토리 구조와 코드를 작성해 보겠다.</p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. Service 계층의 메서드 수 14 → 7, 50% 감소.</strong>
<strong>2. Service 계층의 코드 라인 수 210 → 76, 약 3배 감소.</strong>
<strong>3. Controller 계층에서 여러 Service를 직접 다루지 않고, 하나의 Facade를 통해 접근 가능.</strong>
<strong>4. 인터페이스와 구현체 구조로 유연한 확장성 증가.</strong></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>처음으로 퍼사드 패턴을 적용해 프로젝트를 경험해 보았다. 매번 고민이었던 Service 계층에 <code>Service</code> 를 많이 주입할지, <code>Repository</code> 를 많이 주입할지를 이번 기회에 해소할 수 있었다. 
퍼사드 패턴을 적용해 각 <code>Service</code> 와 각 <code>Repository</code> 는 각자의 역할을 유지하면서 코드를 깔끔하게 퍼사드 안에 숨길 수 있었다. </p>
<p>나는 개발을 할 때 가장 먼저 고민을 하는 건 &#39;어떻게 해야 깔끔할까?&#39; &#39;어떻게 해야 중복성이 적어질까?&#39; 이다. 하지만 이번 퍼사드 패턴 경험 덕분에 이 고민에 대해 다른 관점을 가질 수 있는 기회가 되었다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - TicToc🔥] 돈이 걸린 문제‼️ 1명만 입찰 성공 ✅, 4999명 입찰 실패 ❌]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-TicToc-%EC%8B%9C%EA%B0%84%EC%9D%B4-%EC%83%9D%EB%AA%85%EC%9D%B4%EB%8B%A4-%EC%9E%85%EC%B0%B0-%EB%88%84%EA%B0%80-%EB%A8%BC%EC%A0%80-%ED%96%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@_mung/TroubleShooting-TicToc-%EC%8B%9C%EA%B0%84%EC%9D%B4-%EC%83%9D%EB%AA%85%EC%9D%B4%EB%8B%A4-%EC%9E%85%EC%B0%B0-%EB%88%84%EA%B0%80-%EB%A8%BC%EC%A0%80-%ED%96%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Sat, 08 Mar 2025 15:23:37 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/4122e137-7774-469b-8629-fa04ab2e373f/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : 개인 프로젝트
<strong>기간</strong> : 2025.01 ~ 진행 중
<strong>링크</strong> : <a href="https://github.com/M-ung/TicToc_Server">https://github.com/M-ung/TicToc_Server</a>
<strong>서비스 내용</strong> : 당신의 시간에 가치를 매기다, 시간 거래 경매 플랫폼</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>우리 서비스 &quot;TicToc&quot;의 가장 중요한 핵심 기능에 대해서 고민할 시간이다. 바로 <strong><em>&quot;입찰 기능&quot;</em></strong> 이다.
단순히 Bid 엔티티를 저장해서 입찰을 하는 방식은 동시성 문제가 발생할 수 있다.</p>
<blockquote>
<p><em>*<em>여기서 동시성 문제란? *</em></em>
동시성 문제는 여러 개의 트랜잭션이 동일한 데이터에 동시 접근하여 예기치 않은 결과가 발생하는 문제를 의미한다. 
예를 들어 우리 TicToc 서비스의 입찰 기능은 여러 사용자가 같은 경매에 동시에 입찰할 때, 입찰 결과가 예상과 다르게 반영되거나 데이터 충돌이 발생할 수 있다.</p>
</blockquote>
<p>우리 서비스는 돈으로 입찰을 하기 때문에 정확히 입찰 결과를 알려야 한다. 그렇기 때문에 &quot;입찰 기능&quot;을 구현하면서 오랜 고민을 하게 되었다.</p>
<p>궁금적으로 문제는 아래와 같다.</p>
<p><strong>1. 동일한 데이터에 동시 접근하는 동시성 문제 해결</strong>
<strong>2. 여러 접근 중에 딱 하나의 접근만 가져오고 나머지는 롤백</strong></p>
<hr>
<h4 id="how">How</h4>
<p>위 문제들을 해결하기 위해 분산 락을 적용하여 하나만의 트랜잭션와 제어하도록 하고자 했다. 그리고 &quot;입찰 기능&quot;은 금전이 오고 가는 기능이기에 분산 락 외에 추가적인 안전 장치를 더하기로 했다.</p>
<hr>
<h4 id="process">Process</h4>
<p>먼저 분산 락을 활용해서 단 하나의 입찰만 성공할 수 있게 구현했다.</p>
<p>또 단순히 분산 락만 적용하는 것이 아니라 락을 획득했을 때, 원자적 연산을 통해 바로 currentPrice를 즉시 DB에 업데이트해 주었다. </p>
<p>그 후 입찰 로직을 순차적으로 진행했고, 바로 락을 해제했을 때 대기 중이었던 입찰이 성공될 경우를 대비해 <code>Thread.sleep(RedisConstants.LOCK_LEASE_TIME * 1000);</code> 를 추가하여 딜레이 전략을 적용했다.</p>
<p>마지막으로 Bid 테이블에 <code>beforePrice</code> 라는 입찰 성공 전 가격을 저장하고, <code>@Table(name = &quot;bid&quot;, uniqueConstraints = @UniqueConstraint(columnNames = {&quot;auctionId&quot;, &quot;beforePrice&quot;}))</code> 유니크 제약 조건을 추가했다.</p>
<p>유니크 제약 조건으로 인해 Bid 테이블에는 하나의 auctionId에는 중복되는 beforePrice가 올 수 없기에 같은 경매가에 입찰을 성공하는 Bid가 2개 이상 존재할 수 없게 된다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/239a6c2f-e7da-4dc8-a1a4-a2297ccfbf55/image.png" alt=""></p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. “원자적 연산 + 분산 락 + 딜레이 + 유니크 제약 조건” → 동시성 문제 해결. **
**2. 5,000건 기준, 동시 입찰 요청 중 1건 성공, 4,999건 실패.</strong>
<strong>3. 데이터 일관성을 100% 보장.</strong>
<strong>4. 서비스의 핵심 기능 “입찰” 을 매우 안전하게 운영 가능.</strong></p>
<p>최종적으로 <em><strong>&quot;원자적 연산 + 분산 락 + 딜레이 + 유니크 제약 조건&quot;</strong></em> 을 활용하여 <em><strong>5000명</strong></em> 이 동시에 입찰 시도를 했을 때, <em><strong>&quot;1명만 입찰 성공 ✅, 4999명 입찰 실패 ❌&quot;</strong></em> 테스트를 통과할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/5d96937e-5397-42ae-b5d2-9bae958a88c8/image.png" alt=""></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>&quot;입찰 기능&quot; 하나를 구현하는 데에 많은 시간 동안 많은 공부를 할 수 있었다. 단순히 어노테이션을 붙이 동시성 제어에서 끝내는 것이 아닌 Redis를 활용하여 분산 락을 활용했고, 단순히 분산 락에서 끝내는 것이 아닌 딜레이 전략을 활용해 안전성을 보장했다. 또 이에서 끝내는 것이 아닌, 원자적 연산과 유니크 제약 조건을 통해 더욱 안전성을 보강했다.</p>
<p>이번 경험을 통해 동시성 제어의 중요성을 다시 깨달았고, 테스트 코드의 신뢰성이 서비스 안정성에 직접적인 영향을 미친다는 점을 배울 수 있었다. 앞으로 테스트 코드를 꾸준히 작성하여 더욱 안정성과 신뢰성을 보장하는 개발자로 성장해야겠다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - TicToc🔥] 1초마다 스케줄러 돌려서 경매 종료 확인..❓ 이거 괜찮을까..❓]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-TicToc-1%EC%B4%88-%EB%A7%88%EB%8B%A4-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EB%8F%8C%EB%A0%A4%EC%84%9C-%EA%B2%BD%EB%A7%A4-%EC%A2%85%EB%A3%8C-%ED%99%95%EC%9D%B8..-%EC%9D%B4%EA%B1%B0-%EA%B4%9C%EC%B0%AE%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@_mung/TroubleShooting-TicToc-1%EC%B4%88-%EB%A7%88%EB%8B%A4-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EB%8F%8C%EB%A0%A4%EC%84%9C-%EA%B2%BD%EB%A7%A4-%EC%A2%85%EB%A3%8C-%ED%99%95%EC%9D%B8..-%EC%9D%B4%EA%B1%B0-%EA%B4%9C%EC%B0%AE%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Sat, 08 Mar 2025 09:51:12 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/db5b24b4-89cc-4ce2-b251-33e35bdc70fe/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : 개인 프로젝트
<strong>기간</strong> : 2025.01 ~ 진행 중
<strong>링크</strong> : <a href="https://github.com/M-ung/TicToc_Server">https://github.com/M-ung/TicToc_Server</a>
<strong>서비스 내용</strong> : 당신의 시간에 가치를 매기다, 시간 거래 경매 플랫폼</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>경매 등록을 구현했다면 경매 종료 시간에 맞게 경매 종료도 구현해야 한다.
처음 경매 종료 구현은 스케줄러를 1분마다 돌리고 1분마다 현재 시간과 경매 종료 시간이 일치하는 경매들을 찾아 종료 처리했다.</p>
<p>코드는 아래와 같다.</p>
<p><strong>📍 AuctionProgressScheduler.java</strong></p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class AuctionProgressScheduler {

    private final AuctionRepository auctionRepository;

    @Scheduled(fixedRate = 60000) // 1분 간격으로 실행
    @Transactional
    public void updateAuctionProgress() {
        LocalDateTime now = LocalDateTime.now();

        List&lt;Auction&gt; finishedAuctions = auctionRepository.findByProgressNotAndAuctionCloseTime(AuctionProgress.FINISHED, now);

        finishedAuctions.forEach(Auction::finished);
    }
}</code></pre>
<p>하지만 1분마다 스케줄러를 돌리고 DB 접근 및 조회를 하는 건 너무 비효율적이라는 판단하에 해결하기로 했다.</p>
<hr>
<h4 id="how">How</h4>
<p>처음 초점을 둔 건 &quot;DB 접근 및 조회&quot;를 최소화하자였다.
최소화하기 위해서는 DB 말고 경매 종료 시간을 저장하고 조회할 &quot;무언가&quot;가 필요했다..
그래서 생각난 건 &quot;Redis&quot;였고, 이를 활용해 풀어나갈 생각이다.</p>
<hr>
<h4 id="process">Process</h4>
<p>처음에는 단순하게 Auction를 등록할 때 DB 뿐만 아니라 Redis에도 그대로 저장하여 관리할 생각이었다. 하지만 이 방식은 DB 접근 및 조회를 줄이긴 하지만, Redis에 부하가 커지고 결국엔 스케줄러를 돌려서 매번 Redis에 접근 및 조회를 해야했다. </p>
<p>그래서 Redis의 TTL과 이벤트를 활용하기로 했다.</p>
<p>Redis에 Auction의 id와 경매 종료 시간을 저장할 때, TTL을 경매 종료 시간까지 유지하게 설정했다.
이 결과 TTL이 만료되면 Redis에서 자동으로 삭제되며 이벤트가 발생하게 된다. 이 발생을 통해 경매 종료 로직이 발생하도록 구현했다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/63c8a35f-8d83-4766-b2fb-63db47dc233f/image.png" alt=""></p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. 기존 : 1분마다 스케줄러 실행 →  하루 약 1,440 쿼리 발생.</strong>
<strong>2. 개선 후 : TTL 기반 실시간 이벤트 처리  → 종료 경매 수만큼 쿼리 발생.</strong>
*<em>3. 하루 100건 기준, 종료한다면 약 1,340 쿼리 감소.  *</em></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>처음에는 Redis를 단순히 캐싱을 위한 용도로만 생각했지만, 이번 구현을 통해 TTL를 활용한 이벤트 감지 기능을 구현하는 경험을 할 수 있었다. 이를 통해 DB 부하를 최소화하면서도 실시간으로 만료 이벤트를 감지하는 방식을 구현할 수 있었다.</p>
<p>특히, 기존의 스케줄링 방식을 사용하면 불필요한 조회와 DB 부하가 발생할 가능성이 컸지만, Redis TTL 기반 이벤트 감지 방식은 정확한 시점에 필요한 로직만 실행할 수 있어 더욱 효율적이었다.</p>
<p>이번 과정을 겪으면서 부하를 최대한 덜 주는 설계를 생각할 수 있는 개발자로 성장해야겠다고 다짐했다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - TicToc🔥] 경매 관리 중에 입찰이⁉️ 동시성 충돌을 막아라‼️‼️]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-TicToc-%EA%B2%BD%EB%A7%A4-%EA%B4%80%EB%A6%AC-%EC%A4%91%EC%97%90-%EC%9E%85%EC%B0%B0%EC%9D%B4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%B6%A9%EB%8F%8C%EC%9D%84-%EB%A7%89%EC%95%84%EB%9D%BC</link>
            <guid>https://velog.io/@_mung/TroubleShooting-TicToc-%EA%B2%BD%EB%A7%A4-%EA%B4%80%EB%A6%AC-%EC%A4%91%EC%97%90-%EC%9E%85%EC%B0%B0%EC%9D%B4-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%B6%A9%EB%8F%8C%EC%9D%84-%EB%A7%89%EC%95%84%EB%9D%BC</guid>
            <pubDate>Sat, 08 Mar 2025 09:43:26 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/c616666e-acc8-446c-b802-afc1e2bfd0a2/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : 개인 프로젝트
<strong>기간</strong> : 2025.01 ~ 진행 중
<strong>링크</strong> : <a href="https://github.com/M-ung/TicToc_Server">https://github.com/M-ung/TicToc_Server</a>
<strong>서비스 내용</strong> : 당신의 시간에 가치를 매기다, 시간 거래 경매 플랫폼</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>경매(Auction)의 현재 진행 상태를 <code>progress</code> 라는 칼럼을 두고 관리를 하기로 했다. 
이 칼럼은 아래와 같은 상태를 가진다.</p>
<p>• <code>NOT_STARTED</code> : 아직 경매가 시작되지 않음
• <code>IN_PROGRESS</code> : 경매가 진행 중
• <code>BID</code> : 경매 종료 및 입찰 완료
• <code>NOT_BID</code> : 경매 종료 및 입찰 실패</p>
<p>경매 수정와 삭제 기능을 구현하면서 고민이 생겼다. 이미 경매가 시작된 상태에서 수정/삭제를 하는 건 잘못된 기획이라고 생각해서 아래와 같이 수정/삭제 전에 유효성 검사를 실행했다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/f19f58f1-ba7f-45c9-aa03-2670ee28877f/image.png" alt=""></p>
<p>하지만 경매 수정/삭제 과정에서 입찰이 들어오면 어떻게 해야 할까..?</p>
<p>기획은 아래와 같이 정의했다.</p>
<p><strong>1. 경매 수정 중 입찰이 들어오면 입찰 성공 ✅, 수정 실패 ❌</strong>
<strong>2. 경매 삭제 중 입찰이 들어오면 입찰 성공 ✅, 수정 실패 ❌</strong></p>
<hr>
<h4 id="how">How</h4>
<p>위 기획대로 기능을 구현하기 위해 아래와 같은 해결책을 고려했다.</p>
<p><strong>수정 및 삭제 로직이 경매(Auction)의 변경을 감지해야 한다.</strong></p>
<p>감지하기 위해서 &quot;비관적 락&quot;을 적용하기로 했다.</p>
<hr>
<h4 id="process">Process</h4>
<p>비관적 락을 적용하기 위해 <code>delete</code> 와 <code>update</code> 를 아래와 같이 구현했다.</p>
<p><strong>📍 AuctionCommandService.java - update</strong>
<img src="https://velog.velcdn.com/images/_mung/post/dcd47e76-6724-4ea5-863b-c8ea1c6a204d/image.png" alt=""></p>
<p><strong>📍 AuctionCommandService.java - delete</strong>
<img src="https://velog.velcdn.com/images/_mung/post/c7a1c681-5a26-47b4-804f-85898c6e4983/image.png" alt=""></p>
<p>그리고 위 메서드 안에 존재하는 <code>findAuctionByIdForUpdate</code> 는 아래와 같이 비관적 락을 적용해서 구현했다.</p>
<pre><code class="language-java">    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;SELECT a FROM Auction a WHERE a.id = :auctionId and a.status = :status&quot;)
    Optional&lt;Auction&gt; findByIdAndStatusForUpdate(@Param(&quot;auctionId&quot;) Long auctionId, @Param(&quot;status&quot;) TicTocStatus status);</code></pre>
<p>내가 생각한 기획 시나리오에 맞게 아래 테스트를 작성했다.</p>
<p><strong>📍 경매 수정 동시성 테스트</strong></p>
<pre><code class="language-java">    @Test
    @DisplayName(&quot;경매 수정 시 동시성 이슈 발생에 대한 테스트&quot;)
    public void 경매_수정_시_동시성_이슈_발생에_대한_테스트() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(2);
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        executorService.submit(() -&gt; {
            try {
                bidCommandUseCase.bid(2L, new BidUseCaseReqDTO.Bid(auction.getId(), BID_PRICE));
            } catch (Exception e) {
                assertThat(e.getMessage()).isEqualTo(ErrorCode.BID_FAIL.getMessage());
            } finally {
                latch.countDown();
            }
        });

        executorService.submit(() -&gt; {
            try {
                AuctionUseCaseReqDTO.Update updateDTO = new AuctionUseCaseReqDTO.Update(
                        &quot;Updated Auction Title&quot;,
                        &quot;Updated Auction Description&quot;,
                        1200,
                        LocalDateTime.now().plusMinutes(15),
                        LocalDateTime.now().plusHours(1),
                        LocalDateTime.now().plusHours(2),
                        Collections.emptyList(),
                        AuctionType.ONLINE
                );
                auctionCommandUseCase.update(1L, auction.getId(), updateDTO);
            } catch (Exception e) {
                assertThat(e).isInstanceOf(ConflictAuctionUpdateException.class);
            } finally {
                latch.countDown();
            }
        });

        latch.await();
        executorService.shutdown();

        Auction afterAuction = auctionRepositoryPort.findAuctionById(auction.getId());
        assertThat(afterAuction.getTitle()).isEqualTo(&quot;Test Auction&quot;);
        assertThat(afterAuction.getProgress()).isEqualTo(AuctionProgress.IN_PROGRESS);
        assertThat(afterAuction.getCurrentPrice()).isEqualTo(BID_PRICE);
    }</code></pre>
<p><strong>📍 경매 삭제 동시성 테스트</strong></p>
<pre><code class="language-java">    @Test
    @DisplayName(&quot;경매 삭제 시 동시성 이슈 발생에 대한 테스트&quot;)
    public void 경매_삭제_시_동시성_이슈_발생에_대한_테스트() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(2);
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        executorService.submit(() -&gt; {
            try {
                bidCommandUseCase.bid(2L, new BidUseCaseReqDTO.Bid(auction.getId(), BID_PRICE));
            } catch (Exception e) {
                assertThat(e.getMessage()).isEqualTo(ErrorCode.BID_FAIL.getMessage());
            } finally {
                latch.countDown();
            }
        });

        executorService.submit(() -&gt; {
            try {
                auctionCommandUseCase.delete(1L, auction.getId());
            } catch (Exception e) {
                assertThat(e).isInstanceOf(ConflictAuctionDeleteException.class);
            } finally {
                latch.countDown();
            }
        });

        latch.await();
        executorService.shutdown();

        Auction deletedAuction = auctionRepositoryPort.findAuctionById(auction.getId());
        assertThat(deletedAuction.getStatus()).isEqualTo(TicTocStatus.ACTIVE);
        assertThat(deletedAuction.getProgress()).isEqualTo(AuctionProgress.IN_PROGRESS);
        assertThat(deletedAuction.getCurrentPrice()).isEqualTo(BID_PRICE);
    }</code></pre>
<p>그치만... 테스트가 실패했다..😭😭
<img src="https://velog.velcdn.com/images/_mung/post/2e24a17c-73fd-4167-843e-5af75830459c/image.png" alt=""></p>
<p>위 코드에 대한 시나리오는 아래 사진과 같다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/baf5ca7a-d317-4183-9be9-68e0322d3fe6/image.png" alt=""></p>
<p>먼저 수정/삭제 시 해당 경매를 조회하고 이를 Lock 한다. 그 후 입찰을 시도하게 되면 잠겨 있기 때문에 입찰에 실패하게 된다.</p>
<p>그럼 코드를 다른 방향으로 생각해 보아야 한다.</p>
<p>비관적 락이 아닌, 낙관적 락을 적용해 보기로 했다.</p>
<p><strong>📍 Auction.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/c1355e52-b528-4be9-80bc-e52d6671d737/image.png" alt=""></p>
<p>먼저 Auction 엔티티에 <code>@Verison</code> 을 추가한다.</p>
<p>그 후 update, delete 메서드에 낙관적 락 로직을 적용한다.</p>
<p><strong>📍 AuctionCommandService.java - update</strong>
<img src="https://velog.velcdn.com/images/_mung/post/5849b922-2ae6-418d-bd6c-5c8a01adf5e6/image.png" alt=""></p>
<p><strong>📍 AuctionCommandService.java - delete</strong>
<img src="https://velog.velcdn.com/images/_mung/post/560b9310-3dda-48f7-8342-448bb5bc2146/image.png" alt=""></p>
<p><strong>📍 AuctionRepository.java - findByIdAndStatusForUpdate</strong>
<img src="https://velog.velcdn.com/images/_mung/post/d4290981-7cf2-4b34-8566-cf3544bbfcf0/image.png" alt=""></p>
<p>위 구현의 시나리오는 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/cccbee97-7029-4aa8-ad15-c233be95d2d5/image.png" alt=""></p>
<hr>
<h4 id="result">Result</h4>
<p>위 구현 결과, 위에서 실패했던 테스트 코드를 통과할 수 있었다.
즉, 문제에서 제기한 기획대로 구현한 것이다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/648bcfce-c137-4a37-8235-105d55115bb0/image.png" alt=""></p>
<p><strong>1. 경매 수정 중 입찰이 들어오면 입찰 성공 ✅, 수정 실패 ❌</strong>
<strong>2. 경매 삭제 중 입찰이 들어오면 입찰 성공 ✅, 수정 실패 ❌</strong></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>이번 경험을 통해 무조건 비관적 락이 좋다! 낙관적 락이 좋다! 보단 언제 어떤 상황에서 어떤 방식으로 풀어나가는 게 맞는지를 배울 수 있었다.</p>
<p>단순히 동시성을 피하기 위해 비관적 락을 적용해 DB를 잠궈버리려 했지만, 이는 입찰 요청을 방해하는 일이 되어버렸다. </p>
<p>동시성 문제를 해결하기 위해서 낙관적 락, 비관적 락, 분산 락, 등 어떤 설계를 적용하든 잘 알고 상황에 맞게 쓰는 것이 중요하다는 것을 배울 수 있었다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - TicToc🔥] Service 계층 이거.. 너무 무거운데..❓❓ 🏋🏋]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-TicToc-%EB%B9%84%EC%A7%80%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81-%EB%84%88%EB%AC%B4-%EB%AC%B4%EA%B1%B0%EC%9A%B4%EB%8D%B0-kds50uem</link>
            <guid>https://velog.io/@_mung/TroubleShooting-TicToc-%EB%B9%84%EC%A7%80%EB%8B%88%EC%8A%A4-%EB%A1%9C%EC%A7%81-%EB%84%88%EB%AC%B4-%EB%AC%B4%EA%B1%B0%EC%9A%B4%EB%8D%B0-kds50uem</guid>
            <pubDate>Fri, 07 Mar 2025 15:43:33 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/583a3716-5bcc-48b6-a9c4-611d5e1e992d/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : 개인 프로젝트
<strong>기간</strong> : 2025.01 ~ 진행 중
<strong>링크</strong> : <a href="https://github.com/M-ung/TicToc_Server">https://github.com/M-ung/TicToc_Server</a>
<strong>서비스 내용</strong> : 당신의 시간에 가치를 매기다, 시간 거래 경매 플랫폼</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>경매 관련 비지니스 로직을 구현하면서 고민이 생겼다. 예를 들어 &quot;경매 수정&quot; 기능을 구현한다고 가정해 보자. 그럼 &quot;경매 수정&quot; 전에 사용자의 userId와 경매의 userId가 동일한 지 확인해야 하는 로직이 필요했다. 이 외에도 유효성 검사, 객체 수정과 같은 여러 기능들이 필요할 때 Service 계층에 메서드를 계속 만들어왔다. 
그렇게 되면 Service 코드는 아래와 같다. (아래 코드는 멀티모듈 + 헥사고날 아키텍처 적용 전 코드이다.)</p>
<pre><code class="language-java">@Service
@Transactional
@RequiredArgsConstructor
public class AuctionCommandServiceImpl implements AuctionCommandService {
    private final AuctionRepository auctionRepository;
    private final AuctionHistoryRepository auctionHistoryRepository;

    @Override
    public void register(final Long userId, AuctionRequestDTO.Register requestDTO) {
        checkAuctionTimeRange(userId, requestDTO.sellStartTime(), requestDTO.sellEndTime());
        auctionRepository.save(Auction.of(userId, requestDTO));
    }

    @Override
    public void update(final Long userId, final Long auctionId, AuctionRequestDTO.Update requestDTO) {
        validateAuctionAccess(userId, auctionId);
        checkAuctionTimeRange(userId, requestDTO.sellStartTime(), requestDTO.sellEndTime());
        try {
            findAuctionById(auctionId).update(requestDTO);
        } catch (OptimisticLockingFailureException e) {
            throw new ConflictAuctionUpdateException(CONFLICT_AUCTION_UPDATE);
        }
    }

    @Override
    public void delete(final Long userId, final Long auctionId) {
        validateAuctionAccess(userId, auctionId);
        try {
            findAuctionById(auctionId).deactivate();
        } catch (OptimisticLockingFailureException e) {
            throw new ConflictAuctionDeleteException(CONFLICT_AUCTION_DELETE);
        }
    }

    private Auction findAuctionById(final Long auctionId) {
        return auctionRepository.findById(auctionId)
                .orElseThrow(() -&gt; new AuctionNotFoundException(AUCTION_NOT_FOUND));
    }

    private void validateAuctionAccess(final Long userId, final Long auctioneerId) {
        if(!userId.equals(auctioneerId)) {
            throw new AuctionNoAccessException(AUCTION_NO_ACCESS);
        }
    }

    private void checkAuctionTimeRange(Long userId, LocalDateTime sellStartTime, LocalDateTime sellEndTime) {
        if(auctionRepository.existsAuctionInTimeRange(userId, sellStartTime, sellEndTime)) {
            throw new DuplicateAuctionDateException(DUPLICATE_AUCTION_DATE);
        }
    }
}</code></pre>
<p>위 코드는 개발이 끝난 상태가 아닌, 개발 중인 상태이다. 그럼에도 불구하고 <code>validateAuctionAccess</code> 와 같은 메서드가 복잡성을 증가시키고 있으며 해당 메서드는 다른 Service 계층에서도 필요하기에 코드 중복성 또한 일으켰다.</p>
<p>문제를 정리하면 아래와 같다.</p>
<p><strong>1. Service 계층의 책임 증가</strong>
<strong>2. 코드 복잡성과 중복성 증가</strong></p>
<hr>
<h4 id="how">How</h4>
<p>위 문제들을 해결하기 위해서 <code>findAuctionById</code> 와 같이 DB를 접근해야 하는 메서드가 아니라면 도메인에 메서드를 작성하는 &quot;캡슐화&quot; 방식을 택했다.</p>
<p><strong>1. 도메인 캡슐화 메서드 적용</strong>
<strong>2. Service 계층을 단순화</strong></p>
<hr>
<h4 id="process">Process</h4>
<p><strong>1. 도메인 캡슐화 메서드 적용</strong>
DB 접근이 필요하지 않은 도메인의 비지니스 로직은 도메인 계층에 캡슐화하여 메서드를 작성했다.
이전에는 Service 계층에서 직접 검증 로직과 상태 변경을 수행했지만, 객체 지향적인 설계하여 복잡성과 중복성을 줄이기 위해 도메인 객체가 스스로 비즈니스 로직을 수행하도록 변경했다.</p>
<pre><code class="language-java">@Getter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = &quot;auction&quot;)
public class Auction extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long auctioneerId;
    private String title;
    @Column(nullable = false, columnDefinition = &quot;TEXT&quot;)
    private String content;
    private Integer startPrice;
    private Integer currentPrice;
    private Integer finalPrice;
    @JsonFormat(pattern = &quot;yyyy-MM-dd&#39;T&#39;HH:mm:ss&quot;)
    private LocalDateTime sellStartTime; // 판매하고 싶은 시간(시작)
    @JsonFormat(pattern = &quot;yyyy-MM-dd&#39;T&#39;HH:mm:ss&quot;)
    private LocalDateTime sellEndTime; // 판매하고 싶은 시간(종료)
    @JsonFormat(pattern = &quot;yyyy-MM-dd&#39;T&#39;HH:mm:ss&quot;)
    private LocalDateTime auctionOpenTime; // 경매 시작 시간
    @JsonFormat(pattern = &quot;yyyy-MM-dd&#39;T&#39;HH:mm:ss&quot;)
    private LocalDateTime auctionCloseTime; // 경매 종료 시간
    @Enumerated(EnumType.STRING)
    private AuctionProgress progress;
    @Enumerated(EnumType.STRING)
    private AuctionType type;
    @Enumerated(EnumType.STRING)
    private TicTocStatus status;

    public static Auction of(final Long userId, AuctionUseCaseReqDTO.Register requestDTO) {
        return Auction.builder()
                .auctioneerId(userId)
                .title(requestDTO.title())
                .content(requestDTO.content())
                .startPrice(requestDTO.startPrice())
                .currentPrice(requestDTO.startPrice())
                .finalPrice(requestDTO.startPrice())
                .sellStartTime(requestDTO.sellStartTime())
                .sellEndTime(requestDTO.sellEndTime())
                .auctionOpenTime(LocalDateTime.now())
                .auctionCloseTime(requestDTO.auctionCloseTime())
                .progress(NOT_STARTED)
                .type(requestDTO.type())
                .status(TicTocStatus.ACTIVE)
                .build();
    }

    public void update(AuctionUseCaseReqDTO.Update requestDTO) {
        validateAuctionAlreadyStarted();
        this.title = requestDTO.title();
        this.content = requestDTO.content();
        this.startPrice = requestDTO.startPrice();
        this.currentPrice = requestDTO.startPrice();
        this.finalPrice = requestDTO.startPrice();
        this.sellStartTime = requestDTO.sellStartTime();
        this.sellEndTime = requestDTO.sellEndTime();
        this.auctionCloseTime = requestDTO.auctionCloseTime();
        this.type = requestDTO.type();
    }

    public void deactivate(final Long userId) {
        validateAuctionAccess(userId);
        validateAuctionAlreadyStarted();
        this.status = TicTocStatus.DISACTIVE;
    }

    public void validateAuctionAccess(final Long userId) {
        if(!userId.equals(this.auctioneerId)) {
            throw new BidNoAccessException(BID_NO_ACCESS);
        }
    }

    private void validateAuctionAlreadyStarted() {
        if(!this.getProgress().equals(NOT_STARTED)) {
            throw new AuctionAlreadyStartedException(AUCTION_ALREADY_STARTED);
        }
    }
}</code></pre>
<p><strong>2. Service 계층을 단순화</strong>
Service 계층에서는 DB를 접근하는 로직만 처리하고, 비즈니스 로직은 도메인 객체에서 수행하도록 변경하여 도메인 객체를 가져와 메서드를 호출하는 역할만 담당하도록 구현했다.</p>
<pre><code class="language-java">@Service
@Transactional
@RequiredArgsConstructor
public class AuctionCommandService implements AuctionCommandUseCase {
    private final LocationCommandUseCase locationCommandUseCase;
    private final AuctionRepositoryPort auctionRepositoryPort;
    private final CloseAuctionUseCase closeAuctionUseCase;

    @Override
    public void register(final Long userId, AuctionUseCaseReqDTO.Register requestDTO) {
        auctionRepositoryPort.validateAuctionTimeRangeForSave(userId, requestDTO.sellStartTime(), requestDTO.sellEndTime());
        var auction = auctionRepositoryPort.saveAuction(Auction.of(userId, requestDTO));
        var auctionId = auction.getId();
        if (!requestDTO.type().equals(AuctionType.ONLINE)) {
            locationCommandUseCase.saveAuctionLocations(auctionId, requestDTO.locations());
        }
        closeAuctionUseCase.save(auctionId, auction.getAuctionCloseTime());
    }

    @Override
    public void update(final Long userId, final Long auctionId, AuctionUseCaseReqDTO.Update requestDTO) {
        var findAuction = auctionRepositoryPort.findAuctionByIdForUpdate(auctionId);
        findAuction.validateAuctionAccess(userId);
        auctionRepositoryPort.validateAuctionTimeRangeForUpdate(userId, auctionId, findAuction.getSellStartTime(), findAuction.getSellEndTime());
        try {
            findAuction.update(requestDTO);
            if(!findAuction.getType().equals(AuctionType.ONLINE)) {
                locationCommandUseCase.deleteAuctionLocations(auctionId);
            }
            if (!requestDTO.type().equals(AuctionType.ONLINE)) {
                locationCommandUseCase.saveAuctionLocations(auctionId, requestDTO.locations());
            }
            closeAuctionUseCase.delete(auctionId);
            closeAuctionUseCase.save(auctionId, findAuction.getAuctionCloseTime());
        } catch (OptimisticLockingFailureException e) {
            throw new ConflictAuctionUpdateException(CONFLICT_AUCTION_UPDATE);
        }
    }

    @Override
    public void delete(final Long userId, final Long auctionId) {
        var findAuction = auctionRepositoryPort.findAuctionByIdForUpdate(auctionId);
        try {
            findAuction.deactivate(userId);
            closeAuctionUseCase.delete(auctionId);
        } catch (OptimisticLockingFailureException e) {
            throw new ConflictAuctionDeleteException(CONFLICT_AUCTION_DELETE);
        }
    }
}</code></pre>
<hr>
<h4 id="result">Result</h4>
<p>이 결과 Auction의 유효성 검사를 <code>AuctionCommandService</code> 뿐만 아니라 <code>UserCommandService</code>, <code>BidCommandService</code> 에서 필요할 때 <code>validateAuctionAccess</code> 메서드를 중복적으로 구현할 필요 없이 도메인에서 메서드를 호출하여 처리가 가능해졌다.</p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>항상 프로젝트를 경험할 때마다 갖게 되는 큰 고민이 코드의 중복성을 어떻게 하면 줄일 수 있을까였다. 하지만 이번 기회에 도메인에 캡슐화해서 메서드를 관리하는 방식을 적용하여 코드의 중복성과 복잡성을 줄일 수 있었다. </p>
<p>하지만 위 방법으로 Service 계층의 부담은 줄었지만, 도메인의 부담이 반대로 커지게 되었다. 이 방법에 대해서는 꾸준히 프로젝트 하면서 고민하며 개선해 나가야 할 부분이다. </p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[🔥TroubleShooting - TicToc🔥] 장기 프로젝트 이대로.. 괜찮을까..❓💦💦]]></title>
            <link>https://velog.io/@_mung/TroubleShooting-TicToc-%EC%9E%A5%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B4%EB%8C%80%EB%A1%9C..-%EA%B4%9C%EC%B0%AE%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@_mung/TroubleShooting-TicToc-%EC%9E%A5%EA%B8%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9D%B4%EB%8C%80%EB%A1%9C..-%EA%B4%9C%EC%B0%AE%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Fri, 07 Mar 2025 14:43:25 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/_mung/post/f794a068-4a1f-4b79-916c-b6508c03c513/image.png" alt=""></p>
<h2 id="📌-프로젝트-소개">📌 프로젝트 소개</h2>
<p><strong>팀원</strong> : 개인 프로젝트
<strong>기간</strong> : 2025.01 ~ 진행 중
<strong>링크</strong> : <a href="https://github.com/M-ung/TicToc_Server">https://github.com/M-ung/TicToc_Server</a>
<strong>서비스 내용</strong> : 당신의 시간에 가치를 매기다, 시간 거래 경매 플랫폼</p>
<hr>
<h3 id="🔥troubleshooting🔥">🔥TroubleShooting🔥</h3>
<h4 id="problems">Problems</h4>
<p>초기 개발 단계에서 레이어드 아키텍처 기반 단일모듈로 프로젝트를 구성했다. 하지만 개인 프로젝트로서 장기적으로 지속적인 개발이 필요했기 때문에 확장성을 고려한 설계가 필요했다.</p>
<hr>
<h4 id="how">How</h4>
<p>헥사고날 아키텍처를 적용하여 비즈니스 로직과 데이터 접근 로직을 분리하고, 멀티모듈로 변경하여 하나의 모듈에 코드가 쌓이는 것을 방지하고자 했습니다. </p>
<hr>
<h4 id="process">Process</h4>
<p><img src="https://velog.velcdn.com/images/_mung/post/c3fad0de-c786-45aa-b763-730bf627d752/image.png" alt=""></p>
<p>클라이언트가 요청을 보내면, 먼저 tictoc-api 모듈의 Controller 계층에서 이를 수신한다. 그 다음, 비즈니스 로직의 인터페이스 계층인 UseCase를 통해 요청이 전달되며, 해당 요청은 구현체인 Service 계층에서 처리된다.
비즈니스 로직 과정에서 도메인 객체 및 DB 접근이 필요할 경우, tictoc-domain 모듈의 Port 인터페이스를 통해 데이터를 요청한다. 그 다음, Port 인터페이스의 구현체인 Adaptor는 Repository를 활용하여 DB에 접근하여 수행하는 흐름이다.</p>
<p>위 사진을 보면 DB가 Master와 Slave로 나눠져 있고,  Query와 Command라는 단어가 보인다. 이는 다음 글에서 자세히 다루겠지만, 이해를 돕기 위해 간단히 설명하겠다.</p>
<p>Query와 Command를 나누는 기준은 크게 역할이 &quot;쓰기&quot;냐 &quot;읽기&quot;냐에 따라 결정한다. 이렇게 하는 이유는 CQRS패턴, DB분리, 속도 개선, 등 여러 이유가 있겠지만, 저는 프로젝트를 최대한 모듈화하고 싶고 객체를 역할 별로 깔끔하게 관리하고 싶어서 위 네이밍 법칙을 가져오고 있다.
그리고 이번 프로젝트에서는 DB를 Mysql을 사용하고 있는데, 이를 Master와 Slave로 나눠서 위 위 사진대로 관리할 예정이다.</p>
<hr>
<h4 id="result">Result</h4>
<p>문제 해결로 아래와 같은 결과를 얻었다.
<strong>1. 유지보수성 및 확장성 향상.</strong>
<strong>2. 비즈니스 로직과 데이터 접근 로직 분리.</strong></p>
<p>최종 프로젝트 모듈 형태는 아래와 같다.
<img src="https://velog.velcdn.com/images/_mung/post/5d87c749-5eb7-44d3-afa8-95a1dd2fc8fb/image.png" alt=""></p>
<p>또 &quot;경매 등록&quot; 기능을 아래와 같이 헥사고날 아키텍처로 구현했다.</p>
<p><strong>📍 AuctionCommandController.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/187f466c-dec7-4cce-8e6c-acef2a743226/image.png" alt=""></p>
<p><strong>📍 AuctionCommandUseCase.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/0fdf6315-2108-4da4-b544-7dfd245bde64/image.png" alt=""></p>
<p><strong>📍 AuctionCommandService.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/79767a55-e1f3-4895-b2b9-463db56f715d/image.png" alt=""></p>
<p><strong>📍 AuctionRepositoryPort.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/f1bf696b-c9dd-47a9-ad0f-ee36ca05a93e/image.png" alt=""></p>
<p><strong>📍 AuctionRepositoryAdapter.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/880f0d57-1a3a-478a-9613-d21c71626e03/image.png" alt=""></p>
<p><strong>📍 AuctionRepository.java</strong>
<img src="https://velog.velcdn.com/images/_mung/post/f6b942bc-fca9-46c3-be60-471e028d7083/image.png" alt=""></p>
<hr>
<h4 id="thoughts">Thoughts</h4>
<p>이전까지 단일모듈로 개발을 해왔었고 멀티모듈이 있다는 사실을 이번에 처음 알았다. 이전까지 단일모듈로 개발을 하면서 디렉토리와 클래스의 개수가 늘어날 때마다 관리하고 원하는 파일 찾기가 어려웠었다. 
이번 멀티모듈로 변경한 후 개발을 쭉 대략 2개월 가까이 진행해 보았는데, 확실히 원하는 파일 찾기가 단일모듈일 때보다 훨씬 수월해졌다. </p>
<p>헥사고날 아키텍처라는 단어 또한 이번에 처음 들어봤다. 처음에는 클린 아키텍처를 먼저 알게 되었어서 이를 적용해 보려고 했는데, 구글에서 래퍼런스를 찾기가 쉽지 않았다. 그래서 클린 아키텍처에서 파생된 헥사고날 아키텍처가 있다는 사실을 알고 알아보게 되었고, 클린 아키텍처보다 래퍼런스도 많이 있기에 이를 택했다.
적용한 결과, 비즈니스 로직에서 데이터베이스나 외부 API에 직접 의존하는 것이 아니라,
UseCase, Port와 같은 인터페이스를 통해서만 접근할 수 있도록 제한할 수 있었다. 이 덕분에 DB 내용을 변경할 때, 비즈니스 로직을 변경할 필요가 없어지는 장점과 코드가 훨씬 깔끔해지는 효과를 얻을 수 있었다.</p>
<p>마지막으로 헥사고날 아키텍처 중 &quot;도메인과 엔티티&quot;를 분리하는 내용이 있었다. 도메인과 엔티티를 분리하면 비즈니스 로직과 데이터 저장 로직을 명확하게 구분할 수 있어서 유지보수성과 확장성이 향상한다는 장점이 있다.
이를 처음에 적용해 보았지만, 사실 이를 분리해서 얻는 이점보다 코드의 복잡성만 늘어나서 얻는 단점이 크다고 생각했다.
예를 들어 Update 관련 기능을 구현할 때, 도메인과 엔티티를 분리하지 않는다면 &quot;더티 체킹&quot;을 통해 굳이 Save를 다시 할 필요가 없었다. 하지만 도메인과 엔티티를 분리한다면, 해당 엔티티를 JPA를 통해 찾고, 찾은 엔티티를 도메인으로 변경, 도메인을 수정, 도메인을 엔티티로 변경, 엔티티를 JPA를 통해 다시 저장하는 과정을 거쳐야 했다. 
그래서 &quot;도메인과 엔티티&quot; 분리는 적용하지 않고 이 글을 마치겠다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docker] 도커 숙제 #2]]></title>
            <link>https://velog.io/@_mung/Docker-%EB%8F%84%EC%BB%A4-%EC%88%99%EC%A0%9C-2</link>
            <guid>https://velog.io/@_mung/Docker-%EB%8F%84%EC%BB%A4-%EC%88%99%EC%A0%9C-2</guid>
            <pubDate>Mon, 26 Aug 2024 17:02:18 GMT</pubDate>
            <description><![CDATA[<h3 id="📌-숙제-내용">📌 숙제 내용</h3>
<blockquote>
<ol>
<li>Mysql 띄우기 패스워드는 1234로 하기</li>
<li>패스워드 변경 sql로</li>
<li>새로운 유저 생성 권한은 DML만</li>
<li>외부 접속 설정 파일 위치</li>
<li>볼륨 지정</li>
<li>컨테이너 삭제 후 재생성 시 데이터가 남아있어야 한다.</li>
<li>두 개의 컨테이너가 같은 데이터 가지고 있기(인서트 하면 다른 디비에도 데이터 들어가게)</li>
<li>외부 접속 허용</li>
<li>DML DDL  차이</li>
<li>COMMIT 이란?</li>
</ol>
</blockquote>
<h3 id="📌-숙제-과정">📌 숙제 과정</h3>
<p><strong>* 1. Mysql 띄우기 패스워드는 1234로 하기 *</strong>
먼저 Mysql 이미지를 받아온다.</p>
<pre><code>docker pull mysql</code></pre><p>그리고 Mysql 이미지를 컨테이너에 실행시킨다. 실행을 시키면서 동시에 패스워드도 설정할 것이다.</p>
<pre><code>docker run --name mysql-container -e MYSQL_ROOT_PASSWORD=&lt;password&gt; -d -p 3306:3306 mysql:latest</code></pre><p>위 명령어를 풀어서 해석하자면 아래와 같다.</p>
<blockquote>
<p>--name <container_name> : <container_name> 이름의 컨테이너를 실행한다.
 -e : 컨테이너 내에서 사용할 환경변수를 설정
 -e MYSQL_ROOT_PASSWORD=<password> : MySQL의 root 권한의 비밀번호를 <password>로 설정한다.
 -d : detach 모드로 컨테이너가 실행된다. 컨테이너가 백그라운드로 실행된다고 보면 된다.
 -p &lt;호스트 포트&gt; &lt;컨테이너 포트&gt; : 호스트와 컨테이너의 포트를 연결한다
 mysql:latest : 컨테이너에 사용할 이미지</p>
</blockquote>
<p><strong>* 2. 패스워드 변경 sql로 *</strong>
패스워드는 도커 컨테이너에 들어가서 변경이 가능하다. </p>
<pre><code>docker exec -it mysql-container mysql -u root -p</code></pre><p>접속을 완료한다면, 아래와 같은 sql을 통해 비밀번호 변경이 가능하다.</p>
<pre><code>ALTER USER &#39;root&#39;@&#39;localhost&#39; IDENTIFIED BY &#39;new_password&#39;; </code></pre><p><strong>* 3. 새로운 유저 생성 권한은 DML만 *</strong>
새로운 유저를 생성하는 명령어 후 권한은 DML만 주도록 하겠다.</p>
<pre><code>CREATE USER &#39;new_user&#39;@&#39;%&#39; IDENTIFIED BY &#39;1234&#39;;
GRANT SELECT, INSERT, UPDATE, DELETE ON *.* TO &#39;new_user&#39;@&#39;%&#39;;</code></pre><p>여기서 DML이랑 &#39;SELECT&#39;, &#39;INSERT&#39;, &#39;UPDATE&#39;, &#39;DELETE&#39; 를 뜻한다.</p>
<p><strong>* 4. 외부 접속 설정 파일 위치 *</strong>
외부에서 Mysql을 접속하려면 my.cnf 또는 mysqld.cnf을 수정해야 한다.</p>
<p>컨테이너를 들어간 상태에서 아래와 같은 명령어로 파일을 열 수 있다.</p>
<pre><code>docker exec -it mysql-container bash</code></pre><p>그런데 여기서 문제가 발생했다.
파일을 생성해서 수정하려고 하는데, vi vim 모두 &#39;not found&#39; 에러가 발생했으며, apt-get yum 또한 &#39;not found&#39; 에러가 발생했다.</p>
<p>그럼 어떤 방식으로 생성하라는 걸까...?!?!??!?!</p>
<pre><code>microdnf install -y vim</code></pre><p>구글링이 역시 답이다.. 위 명령을 작성하니 vim 설치가 되었다 ㅎㅎ</p>
<pre><code>vim /etc/my.cnf  
</code></pre><p>위와 같이 명령어를 치면 아래와 같이 파일 내용이 나온다.</p>
<pre><code class="language-bash"># For advice on how to change settings please see
# http://dev.mysql.com/doc/refman/9.0/en/server-configuration-defaults.html

[mysqld]
#
# Remove leading # and set to the amount of RAM for the most important data
# cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%.
# innodb_buffer_pool_size = 128M
#
# Remove leading # to turn on a very important data integrity option: logging
# changes to the binary log between backups.
# log_bin
#
# Remove leading # to set options mainly useful for reporting servers.
# The server defaults are faster for transactions and fast SELECTs.
# Adjust sizes as needed, experiment to find the optimal values.
# join_buffer_size = 128M
# sort_buffer_size = 2M
# read_rnd_buffer_size = 2M

host-cache-size=0
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql

pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock

!includedir /etc/mysql/conf.d/</code></pre>
<p><strong>* 5. 볼륨 지정 *</strong>
볼륨을 생성하고 컨테이너를 run하려면 아래와 같은 명령어를 사용한다.</p>
<pre><code>docker volume create mysql_volume</code></pre><pre><code>docker run --name mysql_container -e MYSQL_ROOT_PASSWORD=1234 -p 3307:3306 -v mysql_volume:/var/lib/mysql -d mysql:latest</code></pre><p>-v mysql_volume:/var/lib/mysql: 볼륨을 mysql_volume로 지정하여 컨테이너 삭제 후 재생성 시에도 데이터가 유지되도록 합니다.</p>
<p><strong>* 6. 컨테이너 삭제 후 재생성 시 데이터가 남아있어야 한다. *</strong>
데이터를 임의로 넣어주고 컨테이너 삭제 후 재생성 시 데이터가 남아있는 지 확인해 볼 예정이다.</p>
<pre><code>docker exec -it mysql_test_container mysql -uroot -p1234</code></pre><pre><code class="language-sql">-- 데이터베이스 생성
CREATE DATABASE testdb;

-- 데이터베이스 사용
USE testdb;

-- 테이블 생성
CREATE TABLE test_table (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL
);

-- 데이터 삽입
INSERT INTO test_table (name) VALUES (&#39;Hello, World!&#39;);</code></pre>
<p>위와 같이 데이터를 넣으면 아래와 같이 데이터가 들어간 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/6def2c6a-bcd4-4486-bb74-d145f68fc079/image.png" alt=""></p>
<p>이제 삭제 후 다시 데이터가 존재하는 지 확인해 보겠다.</p>
<pre><code># 컨테이너 정지
docker stop mysql_container

# 컨테이너 삭제
docker rm mysql_container</code></pre><pre><code># 동일한 볼륨을 사용하여 MySQL 컨테이너 재생성
docker run --name mysql_container_2 -e MYSQL_ROOT_PASSWORD=1234 -p 3307:3306 -v mysql_volume:/var/lib/mysql -d mysql:latest</code></pre><p>mysql_container_2 안에 들어가서 테이블 및 데이터를 확인한 결과 아래와 같이 잘 저장되어 있는 것을 확인할 수 있다!!</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/7aa39b5c-6c54-4243-9c85-b11122c1b7d1/image.png" alt=""></p>
<p><strong>* 7. 두 개의 컨테이너가 같은 데이터 가지고 있기(인서트 하면 다른 디비에도 데이터 들어가게) *</strong>
일단 두 개의 컨테이너를 띄워줄 예정인데, 이게 다른 방법이 있을 지는 모르겠지만 내가 실행해 본 결과 두 컨테이너가 동시에 같은 하나의 볼륨을 갖고 있는게 불가능한 것 같았다. </p>
<p>그래서 &#39;Replication&#39; 을 사용해서 위 문제를 해결할 예정이다.</p>
<pre><code># 마스터 컨테이너 실행
docker run --name mysql_master -e MYSQL_ROOT_PASSWORD=1234 -p 3307:3306 -v mysql_master_data:/var/lib/mysql -d mysql:latest

# 슬레이브 컨테이너 실행
docker run --name mysql_slave -e MYSQL_ROOT_PASSWORD=1234 -p 3308:3306 -v mysql_slave_data:/var/lib/mysql -d mysql:latest</code></pre><p>이제 마스터 컨테이너에 접속하여 &#39;Replication&#39; 설정을 구성할 것이다.</p>
<pre><code>docker exec -it mysql_master mysql -uroot -p1234</code></pre><pre><code class="language-sql">-- 마스터 설정
CREATE USER &#39;repl&#39;@&#39;%&#39; IDENTIFIED BY &#39;replica_password&#39;;
GRANT REPLICATION SLAVE ON *.* TO &#39;repl&#39;@&#39;%&#39;;
FLUSH PRIVILEGES;

-- 마스터의 상태 확인
SHOW MASTER STATUS;  </code></pre>
<p><strong>* 8. 외부 접속 허용 *</strong></p>
<pre><code>bind-address = 0.0.0.0 // my.cnf 파일 안에 이러한 내용을 넣었다.</code></pre><p>그래서 아래와 같이 완성이 되었다.</p>
<pre><code class="language-bash"># For advice on how to change settings please see
# http://dev.mysql.com/doc/refman/9.0/en/server-configuration-defaults.html

[mysqld]
#
# Remove leading # and set to the amount of RAM for the most important data
# cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%.
# innodb_buffer_pool_size = 128M
#
# Remove leading # to turn on a very important data integrity option: logging
# changes to the binary log between backups.
# log_bin
#
# Remove leading # to set options mainly useful for reporting servers.
# The server defaults are faster for transactions and fast SELECTs.
# Adjust sizes as needed, experiment to find the optimal values.
# join_buffer_size = 128M
# sort_buffer_size = 2M
# read_rnd_buffer_size = 2M

bind-address = 0.0.0.0
host-cache-size=0
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql

pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock

!includedir /etc/mysql/conf.d/</code></pre>
<p>bind-address를 0.0.0.0으로 설정하여 모든 IP로부터의 접속을 허용할 수 있다.</p>
<p><strong>* 9. DML DDL  차이 *</strong>
✅ DML (Data Manipulation Language): 데이터베이스의 데이터를 조작하는 명령어들을 말한다. 
  ex) SELECT, INSERT, UPDATE, DELETE
✅ DDL (Data Definition Language): 데이터베이스의 구조를 정의하는 명령어들을 말한다. 
  ex) CREATE, ALTER, DROP</p>
<p><strong>* 10. COMMIT 이란? *</strong>
✅ COMMIT : 트랜잭션을 종료하고, 해당 트랜잭션에서 수행된 모든 변경사항을 데이터베이스에 영구적으로 반영하는 SQL 명령어를 말한다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docker] 도커 숙제 #1]]></title>
            <link>https://velog.io/@_mung/Docker-%EB%8F%84%EC%BB%A4-%EC%88%99%EC%A0%9C-1</link>
            <guid>https://velog.io/@_mung/Docker-%EB%8F%84%EC%BB%A4-%EC%88%99%EC%A0%9C-1</guid>
            <pubDate>Mon, 26 Aug 2024 16:59:51 GMT</pubDate>
            <description><![CDATA[<p>요즘 도커 스터디를 하고 있는데, 도커 스터디에서 받은 숙제를 벨로그에 정리를 해보려 한다.</p>
<h3 id="📌-숙제-내용">📌 숙제 내용</h3>
<blockquote>
<p>숙제의 큰 틀은 nginx를 도커로 띄우는 거다.</p>
</blockquote>
<ol>
<li>Dockerfile 생성</li>
<li>docker로 nginx 실행</li>
<li>default.conf proxy_pass 설정</li>
<li>포트포워딩(ping pong 테스트)
&nbsp;&nbsp;&nbsp;&nbsp;- request(ping) -&gt; container -&gt; response(pong)</li>
</ol>
<h3 id="📌-숙제-과정">📌 숙제 과정</h3>
<p>일단 nginx를 띄우기 위해 프로젝트를 하나 만들었다. 그 후 프로젝트 안에 dockerfile을 만들어서 넣어주었다.</p>
<p><strong>* 1. Dockerfile 생성 *</strong></p>
<pre><code class="language-java"># jdk21 Image Start
FROM openjdk:21

# 작업 디렉토리 설정
WORKDIR /app

# 인자 설정 - JAR_File
ARG JAR_FILE=build/libs/nginx_study-0.0.1-SNAPSHOT.jar

# JAR 파일을 작업 디렉토리로 복사
COPY ${JAR_FILE} /app/mung.jar

# 실행 명령어
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;/app/mung.jar&quot;]</code></pre>
<p><strong>* 2. docker로 nginx 실행 *</strong>
nginx를 실행하기 위해 일단 스프링 프로젝트를 jar로 빌드 후 도커에 띄워줬다.</p>
<pre><code>docker build -t mung . </code></pre><pre><code>docker run --name spring -d -p 8081:8080 mung</code></pre><p>그 후 nginx 또한 도커로 띄워줬다.</p>
<pre><code>docker run --name nginx -d -p 80:80 nginx</code></pre><p>그 다음 둘을 이어주기 위해 nginx-network라는 docker network를 생성하여 두 컨테이너를 이어줬다. </p>
<pre><code>docker network connect nginx-network spring</code></pre><pre><code>docker network connect nginx-network nginx</code></pre><p><strong>* 3. default.conf proxy_pass 설정 *</strong></p>
<pre><code class="language-java">server {
    listen 80;

    location / {
        proxy_pass http://spring:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}</code></pre>
<p>위 설정을 통해 80포트로 오는 요청을 spring:8080으로 요청을 보낼 수 있게 했습니다.</p>
<pre><code class="language-java">@RestController
public class HealthCheckController {
    @GetMapping(&quot;/ping&quot;)
    public String ping() {
        return &quot;pong&quot;;
    }
}</code></pre>
<p>ping - pong 테스트를 위해 위와 같은 코드를 스프링 프로젝트에 작성했습니다.</p>
<p><strong>* 4. 포트포워딩(ping pong 테스트) *</strong>
이제 <code>localhost/ping</code> 으로 요청을 보내면 pong이라는 응답을 받는 것을 확인할 수 있습니다.
localhost 80포트로 요청을 보냈는데, localhost 8080의 spring에게 전달되어 ping이라는 응답을 받을 수 있습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[알고리즘] 1516번]]></title>
            <link>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-1516%EB%B2%88</link>
            <guid>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-1516%EB%B2%88</guid>
            <pubDate>Wed, 03 Jul 2024 13:19:12 GMT</pubDate>
            <description><![CDATA[<p>오늘 풀어볼 문제는 백준 1516번 문제이다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/fc761390-384a-4d20-bb41-c645ac06e749/image.png" alt=""></p>
<p><strong>📌 도전 📌</strong>
앞에서 푼 2252번하고 비슷하게 문제를 풀었다. 약간 추가된 점은 시간을 추가했다는 점이다. </p>
<pre><code class="language-java">ans[next] = Math.max(ans[next], ans[now] + time[now]);</code></pre>
<p>이 코드를 추가하여 최대인 값을 넣는 것이다.</p>
<pre><code class="language-java">public class Main {
    static int n;
    static ArrayList&lt;ArrayList&lt;Integer&gt;&gt; arr;
    static int[] count;
    static int[] time;
    static int[] ans;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        n = Integer.parseInt(st.nextToken());
        arr = new ArrayList&lt;&gt;();
        for(int i=0; i &lt;= n; i++) {
            arr.add(new ArrayList&lt;&gt;());
        }
        count = new int[n+1];
        time = new int[n+1];
        ans = new int[n+1];

        for(int i=1; i &lt;= n; i++) {
            st = new StringTokenizer(br.readLine());
            time[i] = Integer.parseInt(st.nextToken());
            while (true) {
                int e = Integer.parseInt(st.nextToken());
                if(e == -1) {
                    break;
                }
                arr.get(e).add(i);
                count[i]++;
            }
        }

        Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
        for(int i=1; i&lt;=n; i++) {
            if(count[i] == 0) {
                queue.offer(i);
            }
        }

        while (!queue.isEmpty()) {
            int now = queue.poll();
            for(int next : arr.get(now)) {
                count[next]--;
                ans[next] = Math.max(ans[next], ans[now] + time[now]);
                if(count[next] == 0) {
                    queue.offer(next);
                }
            }
        }

        for(int i=1; i&lt;=n; i++) {
            System.out.println(ans[i] + time[i]);
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_mung/post/330afddd-f781-4de1-874d-687435567d34/image.jpeg" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/04315489-97df-4ea6-87e1-32433c68dc7d/image.png" alt=""></p>
<p>[문제 출처] : <a href="https://www.acmicpc.net/problem/1516">https://www.acmicpc.net/problem/1516</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[알고리즘] 2252번]]></title>
            <link>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-2252%EB%B2%88</link>
            <guid>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-2252%EB%B2%88</guid>
            <pubDate>Tue, 02 Jul 2024 13:39:52 GMT</pubDate>
            <description><![CDATA[<p>오늘 풀어볼 문제는 백준 2252번 문제이다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/8f4f438d-989f-4b6b-9a6c-deeb1293135a/image.png" alt=""></p>
<p><strong>📌 도전 📌</strong>
이 문제는 위상 정렬을 활용해서 풀었다. 연결된 노드가 있을 경우 연결을 당하는 쪽에서 카운트 +1을 하였다. 그리고 노드 정리가 끝난 후 카운트가 0인 값들은 큐에 바로 넣고 큐가 빌 때까지 반복문을 돌리며 같은 행위를 반복하였다.</p>
<pre><code class="language-java">public class Main {
    static int n;
    static int m;
    static ArrayList&lt;ArrayList&lt;Integer&gt;&gt; arr;
    static int[] ans;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        n = Integer.parseInt(st.nextToken());
        m = Integer.parseInt(st.nextToken());

        arr = new ArrayList&lt;&gt;();
        for(int i=0; i &lt;= n; i++) {
            arr.add(new ArrayList&lt;&gt;());
        }
        ans = new int[n+1];

        for(int i=0; i&lt;m; i++) {
            st = new StringTokenizer(br.readLine());
            int s = Integer.parseInt(st.nextToken());
            int e = Integer.parseInt(st.nextToken());
            arr.get(s).add(e);
            ans[e]++;
        }

        Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
        for(int i=1; i&lt;=n; i++) {
            if(ans[i] == 0) {
                queue.offer(i);
            }
        }

        while (!queue.isEmpty()) {
            int now = queue.poll();
            System.out.println(now + &quot; &quot;);
            for(int next : arr.get(now)) {
                ans[next]--;
                if(ans[next] == 0) {
                    queue.offer(next);
                }
            }
        }
    }
}
</code></pre>
<p><img src="https://velog.velcdn.com/images/_mung/post/31466f1c-f92d-4cae-b3c8-07a75bd0a643/image.jpeg" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/eab07e94-4d70-4775-ac63-129073c1e889/image.png" alt=""></p>
<p>[문제 출처] : <a href="https://www.acmicpc.net/problem/2252">https://www.acmicpc.net/problem/2252</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[알고리즘] 1043번]]></title>
            <link>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-1043%EB%B2%88</link>
            <guid>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-1043%EB%B2%88</guid>
            <pubDate>Mon, 01 Jul 2024 13:41:41 GMT</pubDate>
            <description><![CDATA[<p>오늘 풀어볼 문제는 백준 1043번 문제이다.</p>
<p><img src="https://velog.velcdn.com/images/_mung/post/54106192-fe20-4186-bac1-a28f8c821fb2/image.png" alt=""></p>
<p><strong>📌 도전 📌</strong>
앞에서 계속 풀었던 union, find 방법으로 함수는 앞에서 사용했던 것과 별 다를게 없이 사용했다. 문제가 조금 달랐던 건 party라는 배열을 생성해서 arrayList로 이어 파티에 참가한 사람들을 모두 저장하는 방식을 이용했다는 거...? 이 정도이다.</p>
<pre><code class="language-java">public class Main {
    static int n;
    static int m;
    static int[] answer;
    static int[] parent;
    public static ArrayList&lt;Integer&gt;[] party;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        n = Integer.parseInt(st.nextToken());
        m = Integer.parseInt(st.nextToken());

        st = new StringTokenizer(br.readLine());

        int count_know = Integer.parseInt(st.nextToken());

        if(count_know == 0) {
            System.out.println(m);
            return;
        }

        answer = new int[count_know];

        for(int i=0; i&lt;count_know; i++) {
            answer[i] = Integer.parseInt(st.nextToken());
        }

        parent = new int[n + 1];
        party = new ArrayList[m];

        for (int i = 1; i &lt;= n; i++) {
            parent[i] = i;
        }

        for (int i = 0; i &lt; m; i++) {
            party[i] = new ArrayList&lt;&gt;();
            st = new StringTokenizer(br.readLine());
            int party_size = Integer.parseInt(st.nextToken());
            for (int j = 0; j &lt; party_size; j++) {
                party[i].add(Integer.parseInt(st.nextToken()));
            }
        }
        for (int i = 0; i &lt; m; i++) {
            int first_man = party[i].get(0);
            for (int j = 1; j &lt; party[i].size(); j++) {
                union(first_man, party[i].get(j));
            }
        }

        int cnt = 0;
        for (int i = 0; i &lt; m; i++) {
            int leader = party[i].get(0);
            boolean flag = true;
            for (int j = 0; j &lt; count_know; j++) {
                if (check(leader, answer[j])) {
                    flag = false;
                    break;
                }
            }
            if (flag) {
                cnt++;
            }
        }
        System.out.println(cnt);
    }
    public static int find(int x) {
        if (x == parent[x]) {
            return x;
        }
        return parent[x] = find(parent[x]);
    }

    public static void union(int x, int y) {
        x = find(x);
        y = find(y);

        if(x != y) {
            parent[y] = x;
        }
    }
    public static boolean check(int a, int b) {
        if (find(a) == find(b)) { 
            return true;
        } else return false;
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_mung/post/64f4dcb8-446b-47c7-a1bb-aa7b2f51e31c/image.jpeg" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/e5ed6c13-c170-491c-a20f-84c95917e9fd/image.png" alt=""></p>
<p>[문제 출처] : <a href="https://www.acmicpc.net/problem/1043">https://www.acmicpc.net/problem/1043</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[알고리즘] 1976번]]></title>
            <link>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-1976%EB%B2%88</link>
            <guid>https://velog.io/@_mung/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-1976%EB%B2%88</guid>
            <pubDate>Fri, 28 Jun 2024 16:51:09 GMT</pubDate>
            <description><![CDATA[<p>오늘 풀어볼 문제는 백준 1976번 문제이다.</p>
<p><strong>문제</strong></p>
<pre><code>동혁이는 친구들과 함께 여행을 가려고 한다. 한국에는 도시가 N개 있고 임의의 두 도시 사이에 길이 있을 수도, 없을 수도 있다. 
동혁이의 여행 일정이 주어졌을 때, 이 여행 경로가 가능한 것인지 알아보자. 
물론 중간에 다른 도시를 경유해서 여행을 할 수도 있다. 예를 들어 도시가 5개 있고, A-B, B-C, A-D, B-D, E-A의 길이 있고, 동혁이의 여행 계획이 E C B C D 라면 E-A-B-C-B-C-B-D라는 여행경로를 통해 목적을 달성할 수 있다.

도시들의 개수와 도시들 간의 연결 여부가 주어져 있고, 동혁이의 여행 계획에 속한 도시들이 순서대로 주어졌을 때 가능한지 여부를 판별하는 프로그램을 작성하시오. 
같은 도시를 여러 번 방문하는 것도 가능하다.</code></pre><p><strong>입력</strong></p>
<pre><code>첫 줄에 도시의 수 N이 주어진다. N은 200이하이다. 
둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. M은 1000이하이다. 다음 N개의 줄에는 N개의 정수가 주어진다. 
i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. 
1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. 
A와 B가 연결되었으면 B와 A도 연결되어 있다. 
마지막 줄에는 여행 계획이 주어진다. 도시의 번호는 1부터 N까지 차례대로 매겨져 있다.</code></pre><p><strong>출력</strong></p>
<pre><code>첫 줄에 가능하면 YES 불가능하면 NO를 출력한다.</code></pre><p><strong>📌 도전 📌</strong>
union과 find 함수를 만들어서 구현했다.</p>
<pre><code class="language-java">public class Main {
    static int n;
    static int m;
    static int[] parent;
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
        StringTokenizer st = new StringTokenizer(br.readLine());
        n = Integer.parseInt(st.nextToken());
        st = new StringTokenizer(br.readLine());
        m = Integer.parseInt(st.nextToken());

        parent = new int[n + 1];

        for (int i = 1; i &lt;= n; i++) {
            parent[i] = i;
        }

        for (int i = 1; i &lt;= n; i++) {
            st = new StringTokenizer(br.readLine());
            for (int j = 1; j &lt;= n; j++) {
                int temp = Integer.parseInt(st.nextToken());
                if (temp == 1) {
                    union(i, j);
                }
            }
        }

        st = new StringTokenizer(br.readLine());
        int start = find(Integer.parseInt(st.nextToken()));
        for (int i = 1; i &lt; m; i++) {
            int now = Integer.parseInt(st.nextToken());
            if (start != find(now)) {
                bw.write(&quot;NO\n&quot;);
                bw.flush();
                bw.close();
                br.close();
                return;
            }
        }
        bw.write(&quot;YES\n&quot;);
        bw.flush();
        bw.close();
        br.close();
    }
    public static int find(int x) {
        if (x == parent[x]) {
            return x;
        }
        return parent[x] = find(parent[x]);
    }

    public static void union(int x, int y) {
        x = find(x);
        y = find(y);
        if (x != y) {
            if (x &lt; y) {
                parent[y] = x;
            } else {
                parent[x] = y;
            }
        }
    }
}</code></pre>
<p><img src="https://velog.velcdn.com/images/_mung/post/decda33b-9b1e-4684-9dc9-544abb2639eb/image.jpeg" alt=""></p>
<p><img src="https://velog.velcdn.com/images/_mung/post/cb135ff0-2848-4846-a46d-7d41f1243093/image.png" alt=""></p>
<p>[문제 출처] : <a href="https://www.acmicpc.net/problem/1976">https://www.acmicpc.net/problem/1976</a></p>
]]></description>
        </item>
    </channel>
</rss>