<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://la-coruna.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://la-coruna.github.io/" rel="alternate" type="text/html" /><updated>2026-07-29T06:59:17+00:00</updated><id>https://la-coruna.github.io/feed.xml</id><title type="html">정원일지: 삽질 후 꽃 하나🌼</title><subtitle>오늘도 화이팅</subtitle><author><name>🌵Lacoruna</name></author><entry><title type="html">[Programmers Lv.1] 모의고사</title><link href="https://la-coruna.github.io/ps/boj/programmers/Programmers-42840-%EB%AA%A8%EC%9D%98%EA%B3%A0%EC%82%AC/" rel="alternate" type="text/html" title="[Programmers Lv.1] 모의고사" /><published>2026-07-15T00:00:00+00:00</published><updated>2026-07-15T00:00:00+00:00</updated><id>https://la-coruna.github.io/ps/boj/programmers/Programmers-42840-%EB%AA%A8%EC%9D%98%EA%B3%A0%EC%82%AC</id><content type="html" xml:base="https://la-coruna.github.io/ps/boj/programmers/Programmers-42840-%EB%AA%A8%EC%9D%98%EA%B3%A0%EC%82%AC/"><![CDATA[<h2 class="no-top-margin" id="-link">🔗 Link</h2>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/42840">문제</a><br />
<a href="https://github.com/La-Coruna/PS/blob/main/programmers/42840.java">풀이</a></p>

<h2 id="-idea">💡 Idea</h2>

<p>완전 탐색을 하는 쉬운 문제이다.
그런데 애먹은 부분이 있다면, 점수 계산을 끝낸 뒤에, 가장 높은 점수인 학생들을 모으는 과정이었다.
이 부분이 이 문제의 핵심 부분이 아닌데, 아래와 같이 코드가 길어지는 게 싫었다.</p>
<pre><code class="language-Java">ArrayList&lt;Integer&gt; winners_list = new ArrayList&lt;&gt;();

int max_score = 0;

for(int i = 0; i &lt; 3; i++){
	max_score = Math.max(scores[i], max_score);
}

for(int i = 0; i &lt; 3; i++){
	if(scores[i] == max_score) winners_list.add(i+1);
}

int[] winners_array = new int[winners_list.size()];

for(int i = 0; i &lt; winners_list.size(); i++){
	winners_array[i] = winners_list.get(i);
}

return winners_array;
</code></pre>

<p>그래서 문제를 푼 이후에 stream을 이용하여 코드를 줄여보았다. 아직 stream으로 생각하는 방식이 익숙하지 않아 바로 떠올리기가 쉽지 않았다.</p>
<pre><code class="language-Java">int max_score = Arrays.stream(scores).max().orElse(0);
		
int[] winners = IntStream.range(0,3).filter(i -&gt; scores[i] == max_score).map(i -&gt; i+1).toArray();

return winners;
</code></pre>
<h2 id="-code">🔑 Code</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">java.util.*</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.stream.IntStream</span><span class="o">;</span>

<span class="kd">class</span> <span class="nc">Solution</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="kt">int</span><span class="o">[][]</span> <span class="n">rules</span> <span class="o">=</span> <span class="o">{</span>
        <span class="o">{</span><span class="mi">1</span><span class="o">,</span> <span class="mi">2</span><span class="o">,</span> <span class="mi">3</span><span class="o">,</span> <span class="mi">4</span><span class="o">,</span> <span class="mi">5</span><span class="o">},</span>
        <span class="o">{</span><span class="mi">2</span><span class="o">,</span> <span class="mi">1</span><span class="o">,</span> <span class="mi">2</span><span class="o">,</span> <span class="mi">3</span><span class="o">,</span> <span class="mi">2</span><span class="o">,</span> <span class="mi">4</span><span class="o">,</span> <span class="mi">2</span><span class="o">,</span> <span class="mi">5</span><span class="o">},</span>
        <span class="o">{</span><span class="mi">3</span><span class="o">,</span> <span class="mi">3</span><span class="o">,</span> <span class="mi">1</span><span class="o">,</span> <span class="mi">1</span><span class="o">,</span> <span class="mi">2</span><span class="o">,</span> <span class="mi">2</span><span class="o">,</span> <span class="mi">4</span><span class="o">,</span> <span class="mi">4</span><span class="o">,</span> <span class="mi">5</span><span class="o">,</span> <span class="mi">5</span><span class="o">}</span>
    <span class="o">};</span>
    
    <span class="kd">public</span> <span class="kt">int</span><span class="o">[]</span> <span class="nf">solution</span><span class="o">(</span><span class="kt">int</span><span class="o">[]</span> <span class="n">answers</span><span class="o">)</span> <span class="o">{</span>
        <span class="kt">int</span> <span class="n">scores</span><span class="o">[]</span> <span class="o">=</span> <span class="o">{</span><span class="mi">0</span><span class="o">,</span><span class="mi">0</span><span class="o">,</span><span class="mi">0</span><span class="o">};</span>
        <span class="k">for</span><span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">answers</span><span class="o">.</span><span class="na">length</span><span class="o">;</span> <span class="n">i</span><span class="o">++){</span>
            <span class="k">for</span><span class="o">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">j</span> <span class="o">&lt;</span> <span class="mi">3</span><span class="o">;</span> <span class="n">j</span><span class="o">++){</span>
                <span class="k">if</span><span class="o">(</span><span class="n">rules</span><span class="o">[</span><span class="n">j</span><span class="o">][</span><span class="n">i</span> <span class="o">%</span> <span class="n">rules</span><span class="o">[</span><span class="n">j</span><span class="o">].</span><span class="na">length</span><span class="o">]</span> <span class="o">==</span> <span class="n">answers</span><span class="o">[</span><span class="n">i</span><span class="o">])</span> <span class="n">scores</span><span class="o">[</span><span class="n">j</span><span class="o">]++;</span>
            <span class="o">}</span>
        <span class="o">}</span>

        <span class="kt">int</span> <span class="n">max_score</span> <span class="o">=</span> <span class="nc">Arrays</span><span class="o">.</span><span class="na">stream</span><span class="o">(</span><span class="n">scores</span><span class="o">).</span><span class="na">max</span><span class="o">().</span><span class="na">orElse</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
                
        <span class="kt">int</span><span class="o">[]</span> <span class="n">winners</span> <span class="o">=</span> <span class="nc">IntStream</span><span class="o">.</span><span class="na">range</span><span class="o">(</span><span class="mi">0</span><span class="o">,</span><span class="mi">3</span><span class="o">).</span><span class="na">filter</span><span class="o">(</span><span class="n">i</span> <span class="o">-&gt;</span> <span class="n">scores</span><span class="o">[</span><span class="n">i</span><span class="o">]</span> <span class="o">==</span> <span class="n">max_score</span><span class="o">).</span><span class="na">map</span><span class="o">(</span><span class="n">i</span> <span class="o">-&gt;</span> <span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="o">).</span><span class="na">toArray</span><span class="o">();</span>
        
        <span class="k">return</span> <span class="n">winners</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="️-side-notes">🗨️ Side Notes</h2>
<blockquote>
  <p>람다식 내부에서 지역 변수를 사용하려면 그 변수는 <code class="language-plaintext highlighter-rouge">final</code>이거나 <strong>effectively final</strong>이어야 한다.</p>
</blockquote>

<p>현재 코드는 가능하다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int maxScore = Arrays.stream(scores)
        .max()
        .orElse(0);

return IntStream.range(0, scores.length)
        .filter(i -&gt; scores[i] == maxScore)
        .toArray();
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">maxScore</code>를 선언한 이후 다시 바꾸지 않았기 때문이다.</p>

<p>이런 변수를 effectively final이라고 한다.</p>

<p>다음 코드는 컴파일되지 않는다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int maxScore = 0;

for (int score : scores) {
    maxScore = Math.max(maxScore, score);
}

return IntStream.range(0, scores.length)
        .filter(i -&gt; scores[i] == maxScore)
        .toArray();
</code></pre></div></div>

<p>왜냐하면 <code class="language-plaintext highlighter-rouge">maxScore</code>가 반복문에서 계속 변경되기 때문이다.</p>

<p>컴파일러는 대략 다음과 같은 오류를 낸다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Local variable maxScore defined in an enclosing scope
must be final or effectively final
</code></pre></div></div>

<p>이 경우 중간 변수를 하나 더 만들 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int max = 0;

for (int score : scores) {
    max = Math.max(max, score);
}

int maxScore = max;

return IntStream.range(0, scores.length)
        .filter(i -&gt; scores[i] == maxScore)
        .map(i -&gt; i + 1)
        .toArray();
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">maxScore</code>는 선언 후 변경되지 않으므로 람다에서 사용할 수 있다.</p>

<p>하지만 이 문제에서는 Stream으로 최댓값까지 구하면 더 간단하다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int maxScore = Arrays.stream(scores)
        .max()
        .orElse(0);
</code></pre></div></div>]]></content><author><name>🌵Lacoruna</name></author><category term="PS" /><category term="BOJ" /><category term="Programmers" /><category term="binary-search" /><category term="brute-force" /><category term="array" /><summary type="html"><![CDATA[🔗 Link 문제 풀이]]></summary></entry><entry><title type="html">[Programmers Lv.1] 최소직사각형</title><link href="https://la-coruna.github.io/ps/programmers/Programmers-86491-%EC%B5%9C%EC%86%8C%EC%A7%81%EC%82%AC%EA%B0%81%ED%98%95/" rel="alternate" type="text/html" title="[Programmers Lv.1] 최소직사각형" /><published>2026-07-15T00:00:00+00:00</published><updated>2026-07-15T00:00:00+00:00</updated><id>https://la-coruna.github.io/ps/programmers/Programmers-86491-%EC%B5%9C%EC%86%8C%EC%A7%81%EC%82%AC%EA%B0%81%ED%98%95</id><content type="html" xml:base="https://la-coruna.github.io/ps/programmers/Programmers-86491-%EC%B5%9C%EC%86%8C%EC%A7%81%EC%82%AC%EA%B0%81%ED%98%95/"><![CDATA[<h2 class="no-top-margin" id="-link">🔗 Link</h2>
<p><a href="https://school.programmers.co.kr/learn/courses/30/lessons/86491">문제</a><br />
<a href="https://github.com/La-Coruna/PS/blob/main/programmers/86491.java">풀이</a></p>

<h2 id="-problem-summary">📝 Problem Summary</h2>

<p>문제를 한두 문장으로 요약합니다.</p>

<h2 id="-idea">💡 Idea</h2>

<p>명함을 순회하며, 가장 작은 넓이가 되는 가로 세로 길이를 갱신해 가는 완전 탐색 문제이다.</p>

<p>처음에는 직관적으로 코드를 작성했다. 새로운 명함을 추가할 때 확장된 넓이가 작은 방향으로 추가를 해 갔다.</p>
<ul>
  <li>첫 번째 방식
    <pre><code class="language-Java">class Solution {
  public int solution(int[][] sizes) {
      int w = 0, h = 0;   
        
      for(int[] size : sizes){
          int w1 = Math.max(w, size[0]);
          int h1 = Math.max(h, size[1]);
          int area1 = w1 * h1;
            
          int w2 = Math.max(w, size[1]);
          int h2 = Math.max(h, size[0]);
          int area2 = w2 * h2;
            
          if(area1 &lt; area2){
              w = w1; h = h1;
          } else {
              w = w2; h = h2;
          }
      }

      return w*h;
  }
}
</code></pre>
  </li>
</ul>

<p>하지만 살짝 관점을 바꿔서, 각 명함들의 긴 쪽 방향과 짧은 쪽 방향을 일치 시켜 준다면 넓이를 비교하지 않고도 가장 작은 넓이의 지갑을 구할 수 있겠다.</p>
<ul>
  <li>두 번째 방식
    <pre><code class="language-Java">class Solution {
  public int solution(int[][] sizes) {
      int max_longSide = 0, max_shortSide = 0;   
        
      for(int[] size : sizes){
          int cur_longSide = Math.max(size[0],size[1]);
          int cur_shortSide = Math.min(size[0],size[1]);
            
          max_longSide = Math.max(max_longSide, cur_longSide);
          max_shortSide = Math.max(max_shortSide, cur_shortSide);
      }

      return max_longSide*max_shortSide;
  }
}
</code></pre>
    <h2 id="-code">🔑 Code</h2>
  </li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">Solution</span> <span class="o">{</span>
    <span class="kd">public</span> <span class="kt">int</span> <span class="nf">solution</span><span class="o">(</span><span class="kt">int</span><span class="o">[][]</span> <span class="n">sizes</span><span class="o">)</span> <span class="o">{</span>
        <span class="kt">int</span> <span class="n">max_longSide</span> <span class="o">=</span> <span class="mi">0</span><span class="o">,</span> <span class="n">max_shortSide</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>   
        
        <span class="k">for</span><span class="o">(</span><span class="kt">int</span><span class="o">[]</span> <span class="n">size</span> <span class="o">:</span> <span class="n">sizes</span><span class="o">){</span>
            <span class="kt">int</span> <span class="n">cur_longSide</span> <span class="o">=</span> <span class="nc">Math</span><span class="o">.</span><span class="na">max</span><span class="o">(</span><span class="n">size</span><span class="o">[</span><span class="mi">0</span><span class="o">],</span><span class="n">size</span><span class="o">[</span><span class="mi">1</span><span class="o">]);</span>
            <span class="kt">int</span> <span class="n">cur_shortSide</span> <span class="o">=</span> <span class="nc">Math</span><span class="o">.</span><span class="na">min</span><span class="o">(</span><span class="n">size</span><span class="o">[</span><span class="mi">0</span><span class="o">],</span><span class="n">size</span><span class="o">[</span><span class="mi">1</span><span class="o">]);</span>
            
            <span class="n">max_longSide</span> <span class="o">=</span> <span class="nc">Math</span><span class="o">.</span><span class="na">max</span><span class="o">(</span><span class="n">max_longSide</span><span class="o">,</span> <span class="n">cur_longSide</span><span class="o">);</span>
            <span class="n">max_shortSide</span> <span class="o">=</span> <span class="nc">Math</span><span class="o">.</span><span class="na">max</span><span class="o">(</span><span class="n">max_shortSide</span><span class="o">,</span> <span class="n">cur_shortSide</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">max_longSide</span><span class="o">*</span><span class="n">max_shortSide</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="️-side-notes">🗨️ Side Notes</h2>]]></content><author><name>🌵Lacoruna</name></author><category term="PS" /><category term="Programmers" /><category term="brute-force" /><summary type="html"><![CDATA[🔗 Link 문제 풀이]]></summary></entry><entry><title type="html">Kubernetes 기반 애플리케이션 배포·운영 플랫폼: 개발 편의성과 운영 표준화</title><link href="https://la-coruna.github.io/platform/kubernetes-application-platform/" rel="alternate" type="text/html" title="Kubernetes 기반 애플리케이션 배포·운영 플랫폼: 개발 편의성과 운영 표준화" /><published>2026-07-12T00:00:00+00:00</published><updated>2026-07-12T00:00:00+00:00</updated><id>https://la-coruna.github.io/platform/kubernetes-application-platform</id><content type="html" xml:base="https://la-coruna.github.io/platform/kubernetes-application-platform/"><![CDATA[<style>
.portfolio-link-panel {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-wrap: wrap;
  gap: 0.85rem;
  overflow: hidden;
  margin: 1.25rem 0 2.25rem;
  padding: 1.1rem;
  border: 1px solid rgba(0, 82, 136, 0.12);
  border-radius: 20px;
  background:
    linear-gradient(
      135deg,
      rgba(237, 248, 252, 0.9),
      rgba(226, 241, 249, 0.68)
    );
  box-shadow:
    0 18px 45px rgba(0, 44, 95, 0.1),
    inset 0 1px 0 rgba(255, 255, 255, 0.9);
  backdrop-filter: blur(18px) saturate(140%);
  -webkit-backdrop-filter: blur(18px) saturate(140%);
}

/* 패널 뒤쪽의 은은한 빛 */
.portfolio-link-panel::before,
.portfolio-link-panel::after {
  position: absolute;
  z-index: -1;
  width: 180px;
  height: 180px;
  border-radius: 50%;
  content: "";
  filter: blur(35px);
  opacity: 0.45;
  pointer-events: none;
}

.portfolio-link-panel::before {
  top: -100px;
  left: -45px;
  background: rgba(0, 170, 210, 0.38);
}

.portfolio-link-panel::after {
  right: -60px;
  bottom: -115px;
  background: rgba(0, 44, 95, 0.28);
}

.portfolio-link {
  position: relative;
  display: inline-flex;
  flex: 1 1 270px;
  align-items: center;
  justify-content: space-between;
  min-height: 58px;
  overflow: hidden;
  padding: 0.95rem 1.15rem;
  border-radius: 14px;
  font-size: 0.96rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.01em;
  text-decoration: none !important;
  transition:
    transform 180ms ease,
    box-shadow 180ms ease,
    border-color 180ms ease,
    background 180ms ease;
}

/* 유리 표면의 반사광 */
.portfolio-link::before {
  position: absolute;
  top: 0;
  left: -45%;
  width: 35%;
  height: 100%;
  content: "";
  background: linear-gradient(
    100deg,
    transparent,
    rgba(255, 255, 255, 0.28),
    transparent
  );
  transform: skewX(-18deg);
  transition: left 420ms ease;
}

.portfolio-link:hover::before {
  left: 120%;
}

.portfolio-link:hover {
  transform: translateY(-3px);
}

.portfolio-link__icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  margin-left: 1rem;
  border-radius: 50%;
  font-size: 1rem;
  transition: transform 180ms ease;
}

.portfolio-link:hover .portfolio-link__icon {
  transform: translate(2px, -2px);
}

/* 실행 중인 플랫폼 */
.portfolio-link--demo {
  color: #ffffff !important;
  border: 1px solid rgba(255, 255, 255, 0.3);
  background:
    linear-gradient(
      135deg,
      rgba(0, 44, 95, 0.98) 0%,
      rgba(0, 98, 154, 0.96) 58%,
      rgba(0, 170, 210, 0.92) 100%
    );
  box-shadow:
    0 12px 28px rgba(0, 75, 130, 0.26),
    inset 0 1px 0 rgba(255, 255, 255, 0.28);
}

.portfolio-link--demo:hover {
  box-shadow:
    0 17px 34px rgba(0, 75, 130, 0.34),
    inset 0 1px 0 rgba(255, 255, 255, 0.34);
}

.portfolio-link--demo .portfolio-link__icon {
  color: #ffffff;
  background: rgba(255, 255, 255, 0.17);
  border: 1px solid rgba(255, 255, 255, 0.24);
}

/* Platform Developer 글 모음 */
.portfolio-link--collection {
  color: #003b70 !important;
  border: 1px solid rgba(0, 113, 165, 0.2);
  background:
    linear-gradient(
      135deg,
      rgba(255, 255, 255, 0.83),
      rgba(223, 246, 252, 0.68)
    );
  box-shadow:
    0 10px 25px rgba(0, 74, 120, 0.12),
    inset 0 1px 0 rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}

.portfolio-link--collection:hover {
  border-color: rgba(0, 145, 190, 0.38);
  background:
    linear-gradient(
      135deg,
      rgba(255, 255, 255, 0.94),
      rgba(212, 244, 251, 0.82)
    );
  box-shadow:
    0 15px 30px rgba(0, 74, 120, 0.18),
    inset 0 1px 0 rgba(255, 255, 255, 1);
}

.portfolio-link--collection .portfolio-link__icon {
  color: #005b8f;
  background: rgba(0, 170, 210, 0.12);
  border: 1px solid rgba(0, 145, 190, 0.16);
}

.portfolio-link:focus-visible {
  outline: 3px solid rgba(0, 170, 210, 0.42);
  outline-offset: 4px;
}

@media (max-width: 640px) {
  .portfolio-link-panel {
    flex-direction: column;
    padding: 0.85rem;
    border-radius: 17px;
  }

  .portfolio-link {
    flex-basis: auto;
    width: 100%;
    min-height: 56px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .portfolio-link,
  .portfolio-link::before,
  .portfolio-link__icon {
    transition: none;
  }
}
</style>

<h1 id="주요-링크">주요 링크</h1>

<div class="portfolio-link-panel">
  <a href="http://portal.la-coruna.xyz/" class="portfolio-link portfolio-link--demo" target="_blank" rel="noopener noreferrer">
    <span>플랫폼 체험하기</span>
    <span class="portfolio-link__icon" aria-hidden="true">↗</span>
  </a>
<!--
  <a
    href="/platform/platform-developer/"
    class="portfolio-link portfolio-link--collection"
  >
    <span>Platform Developer 포트폴리오 보기</span>
    <span class="portfolio-link__icon" aria-hidden="true">→</span>
  </a>
-->
</div>
<h1 id="프로젝트-한눈에-보기">프로젝트 한눈에 보기</h1>

<h2 id="실행-화면">실행 화면</h2>

<figure class="third ">
  
    
      <a href="/assets/images/platform/application-platform/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%AA%A9%EB%A1%9D.png" title="프로젝트 목록">
          <img src="/assets/images/platform/application-platform/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%AA%A9%EB%A1%9D.png" alt="Kubernetes 애플리케이션 플랫폼 프로젝트 목록 화면" />
      </a>
    
  
    
      <a href="/assets/images/platform/application-platform/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EC%83%9D%EC%84%B1.png" title="프로젝트 생성">
          <img src="/assets/images/platform/application-platform/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EC%83%9D%EC%84%B1.png" alt="Kubernetes 애플리케이션 플랫폼 프로젝트 생성 화면" />
      </a>
    
  
    
      <a href="/assets/images/platform/application-platform/nginx%20pod%20%EC%98%88%EC%8B%9C.png" title="nginx 애플리케이션 Pod 상태">
          <img src="/assets/images/platform/application-platform/nginx%20pod%20%EC%98%88%EC%8B%9C.png" alt="플랫폼에서 생성한 nginx 애플리케이션의 Pod 상태 화면" />
      </a>
    
  
  
    <figcaption>프로젝트 목록, 프로젝트 생성, Pod 외부 접속
</figcaption>
  
</figure>

<h2 id="프로젝트-요약">프로젝트 요약</h2>

<blockquote>
  <p><strong>한 줄 소개</strong>
개발자가 서비스명, 컨테이너 이미지, 자원 정보와 외부 공개 여부만 입력하면, 플랫폼이 Namespace·ResourceQuota·Deployment·Service·Ingress를 표준 규칙에 따라 생성하고 Pod 상태·Kubernetes Event·작업 이력까지 보여주는 배포·운영 플랫폼입니다.</p>
</blockquote>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>프로젝트명</td>
      <td>Kubernetes 기반 애플리케이션 배포·운영 플랫폼</td>
    </tr>
    <tr>
      <td>프로젝트 목표</td>
      <td>개발자가 Kubernetes YAML을 직접 작성하지 않고 애플리케이션을 배포하고 운영 상태를 확인하도록 지원</td>
    </tr>
    <tr>
      <td>핵심 고민</td>
      <td>개발자가 Kubernetes YAML과 명령어를 직접 다뤄야 하는 부담을 줄이면서도, 운영 표준과 변경 이력을 유지하는 것</td>
    </tr>
    <tr>
      <td>주요 사용자</td>
      <td>애플리케이션 개발자, 플랫폼 운영자</td>
    </tr>
    <tr>
      <td>Backend</td>
      <td>Python, FastAPI, SQLAlchemy</td>
    </tr>
    <tr>
      <td>Frontend</td>
      <td>React, TypeScript, Vite</td>
    </tr>
    <tr>
      <td>Database</td>
      <td>MariaDB</td>
    </tr>
    <tr>
      <td>Container Platform</td>
      <td>Kubernetes, Docker</td>
    </tr>
    <tr>
      <td>개발 환경</td>
      <td>kind</td>
    </tr>
    <tr>
      <td>배포 환경</td>
      <td>GKE</td>
    </tr>
    <tr>
      <td>네트워크</td>
      <td>Service, NGINX Ingress Controller, DNS</td>
    </tr>
    <tr>
      <td>핵심 기능</td>
      <td>리소스 생성, Pod/Event 조회, 외부 공개, 삭제, Audit Log</td>
    </tr>
  </tbody>
</table>

<h2 id="플랫폼의-주요-흐름">플랫폼의 주요 흐름</h2>

<figure class="">
  <img src="/assets/images/platform/application-platform/%ED%94%8C%EB%9E%AB%ED%8F%BC%20%EC%A3%BC%EC%9A%94%20%ED%9D%90%EB%A6%84.png" alt="애플리케이션 배포 요청부터 Kubernetes 리소스 생성과 상태 확인까지의 플랫폼 흐름" /><figcaption>
      애플리케이션 배포 요청부터 Kubernetes 리소스 생성·상태 확인까지의 주요 흐름

    </figcaption></figure>

<hr />

<h1 id="개발자-편의와-운영-통제-사이-kubernetes-기반-애플리케이션-배포운영-플랫폼">개발자 편의와 운영 통제 사이: Kubernetes 기반 애플리케이션 배포·운영 플랫폼</h1>

<p>애플리케이션 개발자가 Kubernetes에 서비스를 배포하려면 컨테이너 이미지만 준비해서는 부족하다.</p>

<p>Namespace와 Deployment를 정의하고, CPU와 Memory를 설정해야 한다. 애플리케이션에 접근하려면 Service가 필요하고, 외부에 공개하려면 Ingress와 도메인도 구성해야 한다. 배포가 실패하면 Pod 상태와 Kubernetes Event를 직접 확인해야 한다.</p>

<p>Kubernetes를 잘 아는 개발자에게는 익숙한 과정일 수 있지만, 서비스를 개발하는 모든 사람이 이런 세부사항을 직접 다루어야 할 필요가 있을까?</p>

<p>반대로 모든 과정을 버튼 하나로 감추면 또 다른 문제가 생긴다.</p>

<p>개발자는 편리해지지만 운영자는 리소스 이름, 격리 기준, 자원 제한, 외부 공개 정책을 통제하기 어려워진다. 자동화 내부에서 실패가 발생했을 때 사용자는 무엇이 잘못되었는지 알 수 없고, 결국 다시 플랫폼 운영자에게 문의해야 한다.</p>

<p>이 프로젝트는 이 두 문제 사이에서 시작했다.</p>

<blockquote>
  <p><strong>개발자가 인프라의 복잡성을 덜 느끼면서도, 플랫폼 운영자는 표준과 통제 가능성을 유지하려면 어떻게 해야 할까?</strong></p>
</blockquote>

<p>이 질문을 풀기 위해 <strong>Kubernetes 기반 애플리케이션 배포·운영 플랫폼</strong>을 개발했다.</p>

<p>사용자가 서비스명, 컨테이너 이미지, 필요한 자원과 외부 공개 여부를 입력하면 플랫폼이 Kubernetes 리소스를 표준 규칙에 따라 생성한다. 생성 이후에는 Pod 상태와 Event를 조회해 배포 결과와 실패 원인을 확인할 수 있고, 삭제 과정과 작업 이력도 추적할 수 있도록 했다.</p>

<p>이 글에서는 프로젝트를 진행하며 가장 중요하게 고민했던 세 가지 문제를 중심으로 정리한다.</p>

<ol>
  <li>어디까지 숨기고 무엇을 사용자에게 입력받을 것인가?</li>
  <li>셀프서비스가 운영 표준을 무너뜨리지 않게 하려면 어떻게 해야 하는가?</li>
  <li>편리한 자동화가 블랙박스가 되지 않게 하려면 어떻게 해야 하는가?</li>
</ol>

<hr />

<h1 id="1-프로젝트의-출발점-배포-자동화만으로-충분한가">1. 프로젝트의 출발점: 배포 자동화만으로 충분한가</h1>

<p>처음에는 Kubernetes 리소스를 자동 생성하는 것이 프로젝트의 핵심이라고 생각했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>API 요청
→ Namespace 생성
→ Deployment 생성
→ Service 생성
</code></pre></div></div>

<p>하지만 실제 사용자의 흐름을 생각하자 생성 기능만으로는 부족했다.</p>

<p>개발자는 애플리케이션을 배포한 뒤 다음 질문을 하게 된다.</p>

<ul>
  <li>배포 요청이 정상적으로 접수되었는가?</li>
  <li>실제 Pod가 실행되고 있는가?</li>
  <li>외부에서 접근할 수 있는가?</li>
  <li>실패했다면 어느 단계에서 실패했는가?</li>
  <li>필요 없어졌을 때 모든 리소스가 정리되는가?</li>
</ul>

<p>운영자에게도 다른 질문이 생긴다.</p>

<ul>
  <li>모든 프로젝트가 동일한 이름 규칙을 따르는가?</li>
  <li>한 프로젝트가 자원을 과도하게 사용하지 않는가?</li>
  <li>어떤 리소스가 플랫폼에서 생성된 것인지 구분할 수 있는가?</li>
  <li>누가 언제 생성하고 삭제했는가?</li>
  <li>DB의 상태와 Kubernetes의 실제 상태가 다르면 어떻게 확인할 것인가?</li>
</ul>

<p>따라서 이 프로젝트의 범위를 단순한 배포 자동화에서 다음 전체 생명주기로 확장했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>신청
→ 입력 검증
→ 표준 리소스 생성
→ 상태 확인
→ 실패 원인 조회
→ 외부 접근
→ 삭제
→ 이력 보존
</code></pre></div></div>

<p>이 과정에서 프로젝트를 관통하는 세 가지 설계 문제가 구체화됐다.</p>

<hr />

<h1 id="2-고민-1-어디까지-숨기고-무엇을-사용자에게-입력받을-것인가">2. 고민 1: 어디까지 숨기고 무엇을 사용자에게 입력받을 것인가</h1>

<h2 id="모든-설정을-보여주면-플랫폼을-만드는-의미가-없다">모든 설정을 보여주면 플랫폼을 만드는 의미가 없다</h2>

<p>Kubernetes Deployment 하나에도 설정할 수 있는 항목은 많다.</p>

<ul>
  <li>Replica 수</li>
  <li>Image</li>
  <li>Port</li>
  <li>Label</li>
  <li>Selector</li>
  <li>Resource Request와 Limit</li>
  <li>Probe</li>
  <li>Affinity</li>
  <li>Toleration</li>
  <li>Security Context</li>
  <li>Update Strategy</li>
</ul>

<p>Service와 Ingress까지 포함하면 선택지는 더욱 많아진다.</p>

<p>사용자에게 Kubernetes의 모든 설정을 입력하도록 하면 자유도는 높아진다. 그러나 사용자가 결국 YAML 대신 긴 웹 양식을 작성하게 된다면 플랫폼을 사용하는 의미가 줄어든다.</p>

<p>반대로 모든 값을 고정하면 사용은 간단하지만 서비스마다 다른 요구를 수용할 수 없다.</p>

<p>따라서 먼저 다음 질문에 답해야 했다.</p>

<blockquote>
  <p><strong>사용자가 표현해야 하는 것은 Kubernetes 리소스인가, 아니면 실행하려는 애플리케이션의 의도인가?</strong></p>
</blockquote>

<h2 id="사용자는-인프라-구성이-아니라-실행-의도를-입력한다">사용자는 인프라 구성이 아니라 실행 의도를 입력한다</h2>

<p>사용자에게 필요한 것은 Namespace나 Deployment 자체가 아니다.</p>

<p>사용자가 표현하려는 의도는 다음에 가깝다.</p>

<blockquote>
  <p>이 이미지를 staging 환경에서 실행하고 싶다. Pod는 2개가 필요하고, 일정한 CPU와 Memory를 사용하며 외부에서도 접근할 수 있어야 한다.</p>
</blockquote>

<p>따라서 입력값을 애플리케이션 실행 의도를 표현하는 최소 정보로 제한했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>서비스명
실행 환경
컨테이너 이미지
Replica 수
CPU Request / Limit
Memory Request / Limit
컨테이너 포트
외부 공개 여부
</code></pre></div></div>

<p>반면 다음 항목은 플랫폼이 결정하도록 했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Namespace 이름
Deployment 이름
Service 이름
Ingress 이름
Label과 Selector
ResourceQuota 이름
Ingress Host 규칙
리소스 생성 순서
삭제 순서
</code></pre></div></div>

<p>이를 표로 정리하면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>사용자가 결정하는 것</th>
      <th>플랫폼이 결정하는 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>어떤 서비스를 실행할지</td>
      <td>리소스 이름 규칙</td>
    </tr>
    <tr>
      <td>어떤 이미지를 사용할지</td>
      <td>Namespace 구성</td>
    </tr>
    <tr>
      <td>몇 개의 Pod가 필요한지</td>
      <td>Label과 Selector</td>
    </tr>
    <tr>
      <td>어느 정도의 자원이 필요한지</td>
      <td>Service 유형</td>
    </tr>
    <tr>
      <td>외부 공개가 필요한지</td>
      <td>Ingress와 Host 규칙</td>
    </tr>
    <tr>
      <td>어느 환경에 배포할지</td>
      <td>리소스 생성·삭제 순서</td>
    </tr>
  </tbody>
</table>

<h2 id="입력을-줄이는-것이-아니라-책임을-재배치했다">입력을 줄이는 것이 아니라 책임을 재배치했다</h2>

<p>사용자 입력을 줄이는 목적은 단순히 화면을 간단하게 만드는 것이 아니었다.</p>

<p>Kubernetes 리소스 구조와 운영 규칙을 플랫폼의 책임으로 이동시킨 것이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 요청
“demo-api를 staging 환경에 배포하고 싶다.”

플랫폼 해석
- Namespace: demo-api-staging
- Deployment: demo-api-deployment
- Service: demo-api-service
- Ingress: demo-api-ingress
- Replica: 2
- 공통 Label 적용
- ResourceQuota 적용
</code></pre></div></div>

<p>이 구조를 통해 사용자는 자신의 애플리케이션에 필요한 값에 집중하고, 플랫폼은 인프라 구현 방식을 일관되게 관리할 수 있다.</p>

<h2 id="추상화에는-탈출구도-필요하다">추상화에는 탈출구도 필요하다</h2>

<p>모든 사용자에게 동일한 고정값만 강제하면 실제 활용도가 떨어질 수 있다.</p>

<p>그래서 다음 값은 기본값을 제공하되 사용자가 변경할 수 있도록 했다.</p>

<ul>
  <li>Replica 수</li>
  <li>CPU Request와 Limit</li>
  <li>Memory Request와 Limit</li>
  <li>Container Port</li>
  <li>외부 공개 여부</li>
</ul>

<p>반면 리소스 이름과 Label처럼 운영 추적에 필요한 규칙은 사용자가 변경할 수 없게 했다.</p>

<p>이를 통해 다음 기준을 세웠다.</p>

<blockquote>
  <p>서비스 특성에 따라 달라질 수 있는 값은 입력받고, 조직 전체에서 일관되어야 하는 값은 플랫폼이 관리한다.</p>
</blockquote>

<p>이것이 이 프로젝트에서 설정한 첫 번째 추상화 경계였다.</p>

<hr />

<h1 id="3-고민-2-셀프서비스가-운영-표준을-무너뜨리지-않게-하려면-어떻게-해야-하는가">3. 고민 2: 셀프서비스가 운영 표준을 무너뜨리지 않게 하려면 어떻게 해야 하는가</h1>

<h2 id="셀프서비스는-무제한-권한-제공이-아니다">셀프서비스는 무제한 권한 제공이 아니다</h2>

<p>개발자가 Kubernetes API에 직접 접근할 수 있다면 원하는 리소스를 자유롭게 만들 수 있다.</p>

<p>그러나 이런 방식은 장기적으로 다음 문제를 만들 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>team-a-service
team_a_service
TeamA-Service
service-staging
staging-service
</code></pre></div></div>

<p>리소스 이름만 달라지는 것이 아니다.</p>

<ul>
  <li>팀마다 다른 Label 사용</li>
  <li>Resource Request와 Limit 누락</li>
  <li>Service Selector 불일치</li>
  <li>외부 공개가 필요하지 않은 서비스까지 노출</li>
  <li>프로젝트 소유자를 알 수 없는 리소스 생성</li>
  <li>삭제 대상과 유지 대상 구분 어려움</li>
</ul>

<p>셀프서비스를 단순히 사용자가 직접 모든 것을 할 수 있게 만드는 것으로 이해하면, 편의성은 높아지지만 운영 통제는 약해진다.</p>

<p>따라서 이 프로젝트에서는 셀프서비스를 다음과 같이 정의했다.</p>

<blockquote>
  <p><strong>사용자가 필요한 자원을 스스로 요청할 수 있지만, 실제 생성은 플랫폼의 규칙과 제한 안에서 이루어지는 것.</strong></p>
</blockquote>

<h2 id="플랫폼이-guardrail을-제공하도록-설계했다">플랫폼이 Guardrail을 제공하도록 설계했다</h2>

<p>사용자에게 Kubernetes 리소스 생성 권한을 직접 주는 대신, 플랫폼이 요청을 받아 리소스를 생성한다.</p>

<p>이 과정에서 다음 운영 규칙을 자동으로 적용했다.</p>

<h3 id="프로젝트별-namespace">프로젝트별 Namespace</h3>

<p>서비스명과 환경을 조합해 Namespace를 생성했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{service-name}-{environment}
</code></pre></div></div>

<p>예시:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>demo-api-staging
order-api-dev
movie-service-prod
</code></pre></div></div>

<p>이를 통해 서비스와 환경 단위로 리소스를 격리하고, 조회와 삭제 범위도 명확하게 만들었다.</p>

<h3 id="공통-naming-convention">공통 Naming Convention</h3>

<p>리소스 이름은 플랫폼이 예측 가능한 규칙으로 생성한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Namespace       demo-api-staging
Deployment      demo-api-deployment
Service         demo-api-service
Ingress         demo-api-ingress
ResourceQuota   demo-api-quota
</code></pre></div></div>

<h3 id="공통-label">공통 Label</h3>

<p>모든 리소스에는 프로젝트와 플랫폼 관리 여부를 식별할 수 있는 Label을 적용했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">app.kubernetes.io/managed-by</span><span class="pi">:</span> <span class="s">application-platform</span>
<span class="na">app.kubernetes.io/name</span><span class="pi">:</span> <span class="s">demo-api</span>
<span class="na">app.kubernetes.io/instance</span><span class="pi">:</span> <span class="s">demo-api-staging</span>
<span class="na">platform.local/project-id</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>
<span class="na">platform.local/environment</span><span class="pi">:</span> <span class="s">staging</span>
</code></pre></div></div>

<p>Label은 단순한 메타데이터가 아니다.</p>

<ul>
  <li>Service와 Pod 연결</li>
  <li>프로젝트 리소스 조회</li>
  <li>플랫폼 관리 대상 식별</li>
  <li>삭제 대상 검증</li>
  <li>DB 프로젝트와 Kubernetes 리소스 연결</li>
</ul>

<p>에 활용했다.</p>

<h3 id="resourcequota">ResourceQuota</h3>

<p>사용자가 입력한 CPU와 Memory 값만으로는 Namespace 전체의 자원 소비를 통제할 수 없다.</p>

<p>따라서 Namespace에 ResourceQuota를 적용해 프로젝트 전체가 사용할 수 있는 자원의 상한을 설정했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>컨테이너 Request / Limit
= 개별 컨테이너의 자원 요청과 제한

ResourceQuota
= Namespace 전체의 자원 사용 상한
</code></pre></div></div>

<h3 id="외부-공개-정책">외부 공개 정책</h3>

<p>모든 Service를 외부에 공개하지 않았다.</p>

<p>Service는 <code class="language-plaintext highlighter-rouge">ClusterIP</code>로 생성하고, 사용자가 외부 공개를 선택한 경우에만 Ingress를 생성했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>expose_external = false
→ Cluster 내부에서만 접근

expose_external = true
→ Ingress 생성 및 외부 Host 제공
</code></pre></div></div>

<h2 id="편의성과-통제는-반대-개념이-아니었다">편의성과 통제는 반대 개념이 아니었다</h2>

<p>처음에는 사용자의 자유를 제한하면 편의성도 줄어든다고 생각할 수 있었다.</p>

<p>그러나 반복적으로 필요한 규칙을 플랫폼이 대신 적용하면 사용자는 오히려 더 적은 판단으로 안전한 결과를 얻을 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 편의
- YAML 작성 불필요
- 리소스 간 연결 규칙을 알 필요 없음
- 이름 규칙을 직접 관리할 필요 없음

운영 통제
- 동일한 Naming Convention
- 공통 Label
- ResourceQuota
- 외부 공개 정책
- 추적 가능한 리소스
</code></pre></div></div>

<p>즉, 플랫폼의 역할은 사용자에게 모든 선택지를 제공하는 것이 아니라 <strong>안전한 선택 경로를 제공하는 것</strong>이라고 판단했다.</p>

<hr />

<h1 id="4-고민-3-편리한-자동화가-블랙박스가-되지-않게-하려면-어떻게-해야-하는가">4. 고민 3: 편리한 자동화가 블랙박스가 되지 않게 하려면 어떻게 해야 하는가</h1>

<h2 id="api가-성공했다고-배포가-성공한-것은-아니다">API가 성공했다고 배포가 성공한 것은 아니다</h2>

<p>Kubernetes API가 Deployment 생성을 수락했다고 해서 애플리케이션이 정상적으로 실행된 것은 아니다.</p>

<p>다음 요청은 모두 HTTP 수준에서는 정상적으로 처리될 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Namespace 생성 성공
Deployment 생성 성공
Service 생성 성공
</code></pre></div></div>

<p>그 이후 Pod에서는 다음 문제가 발생할 수 있다.</p>

<ul>
  <li>존재하지 않는 이미지</li>
  <li>Private Registry 인증 실패</li>
  <li>ResourceQuota 초과</li>
  <li>Container 실행 실패</li>
  <li>잘못된 Port</li>
  <li>Readiness 실패</li>
  <li>Node 자원 부족</li>
</ul>

<p>따라서 <code class="language-plaintext highlighter-rouge">POST /projects</code>가 성공했다는 사실만 사용자에게 보여주면 자동화가 블랙박스가 된다.</p>

<p>사용자는 버튼을 눌렀지만 실제로 애플리케이션이 실행되었는지 알 수 없다.</p>

<h2 id="pod-phase만으로도-충분하지-않았다">Pod Phase만으로도 충분하지 않았다</h2>

<p>Pod의 상태가 <code class="language-plaintext highlighter-rouge">Pending</code>이라고 표시되더라도 그 이유는 여러 가지일 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Pending
├── 이미지 다운로드 중
├── ImagePullBackOff
├── 스케줄링 대기
├── ResourceQuota 초과
└── Volume 연결 대기
</code></pre></div></div>

<p>따라서 다음 정보를 함께 조회했다.</p>

<ul>
  <li>Pod Phase</li>
  <li>Ready 여부</li>
  <li>Container State</li>
  <li>Waiting Reason</li>
  <li>Restart Count</li>
  <li>Node</li>
  <li>Pod IP</li>
  <li>Kubernetes Event</li>
</ul>

<h2 id="실패-시나리오를-일부러-만들었다">실패 시나리오를 일부러 만들었다</h2>

<p>정상 이미지인 <code class="language-plaintext highlighter-rouge">nginx:latest</code>만 배포하면 Kubernetes 리소스가 생성되는 성공 경로만 확인할 수 있다.</p>

<p>하지만 플랫폼에서 더 중요한 것은 리소스 생성 요청이 수락된 이후, 실제 애플리케이션이 실행되지 않는 상황을 사용자가 이해할 수 있게 보여주는 것이다.</p>

<p>이를 검증하기 위해 서로 원인이 다른 두 가지 실패 시나리오를 의도적으로 만들었다.</p>

<h3 id="1-컨테이너-이미지를-가져오지-못하는-경우">1. 컨테이너 이미지를 가져오지 못하는 경우</h3>

<p>첫 번째 시나리오에서는 존재하지 않는 이미지 주소를 입력했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>does-not-exist.local/broken/nginx:missing
</code></pre></div></div>

<p>Kubernetes는 Deployment와 Pod를 생성했지만, 컨테이너 이미지를 찾을 수 없어 다음 상태로 전이됐다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Pod 생성
→ 이미지 Pull 시도
→ ErrImagePull
→ 재시도
→ ImagePullBackOff
</code></pre></div></div>

<p>플랫폼에서는 단순히 프로젝트를 <code class="language-plaintext highlighter-rouge">FAILED</code>로 표시하는 데 그치지 않고 다음 정보를 함께 제공했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Pod Phase       Pending
Container State waiting
Waiting Reason  ImagePullBackOff
Event Reason    Failed / BackOff
Event Message   이미지 조회 및 Pull 실패
</code></pre></div></div>

<p>화면의 Kubernetes Event를 통해 이미지 저장소의 이름이나 Tag가 잘못되었는지, Registry 접근 자체가 불가능한지 확인할 수 있다.</p>

<h3 id="2-이미지는-실행됐지만-컨테이너가-반복-종료되는-경우">2. 이미지는 실행됐지만 컨테이너가 반복 종료되는 경우</h3>

<p>두 번째 시나리오에서는 이미지 자체는 정상적으로 로드되지만, 컨테이너 프로세스가 실행 직후 종료되도록 구성했다.</p>

<p>이 경우 Kubernetes는 컨테이너를 다시 실행하려고 반복적으로 시도하며 <code class="language-plaintext highlighter-rouge">CrashLoopBackOff</code> 상태가 발생한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>이미지 로드 성공
→ 컨테이너 생성
→ 컨테이너 시작
→ 프로세스 종료
→ 재시작
→ CrashLoopBackOff
</code></pre></div></div>

<p>이 시나리오는 이미지 다운로드 실패와 달리 Pod의 Phase가 <code class="language-plaintext highlighter-rouge">Running</code>으로 보일 수 있다는 점도 확인할 수 있었다.</p>

<p>따라서 Pod Phase만으로 성공 여부를 판단하지 않고 다음 정보를 함께 분석해야 했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Pod Phase
Container State
Waiting Reason
Restart Count
Kubernetes Event
</code></pre></div></div>

<p>플랫폼은 <code class="language-plaintext highlighter-rouge">BackOff restarting failed container</code> Event와 <code class="language-plaintext highlighter-rouge">CrashLoopBackOff</code> 상태를 확인해 프로젝트 상태를 <code class="language-plaintext highlighter-rouge">FAILED</code>로 동기화했다.</p>

<figure class="half ">
  
    
      <a href="/assets/images/platform/application-platform/broken-image-pull.png" title="이미지 다운로드 실패: ImagePullBackOff">
          <img src="/assets/images/platform/application-platform/broken-image-pull.png" alt="존재하지 않는 컨테이너 이미지로 인해 ImagePullBackOff가 발생한 프로젝트 상세 화면" />
      </a>
    
  
    
      <a href="/assets/images/platform/application-platform/broken-crashloop.png" title="컨테이너 실행 실패: CrashLoopBackOff">
          <img src="/assets/images/platform/application-platform/broken-crashloop.png" alt="컨테이너가 반복 종료되어 CrashLoopBackOff가 발생한 프로젝트 상세 화면" />
      </a>
    
  
  
    <figcaption>이미지 다운로드 실패와 컨테이너 반복 종료를 재현해 Pod 상태, Kubernetes Event, Audit Log에서 원인을 확인한 화면
</figcaption>
  
</figure>

<p>두 실패는 사용자에게는 모두 “애플리케이션이 실행되지 않는다”는 동일한 결과로 보이지만, 원인과 대응 방법은 다르다.</p>

<table>
  <thead>
    <tr>
      <th>실패 유형</th>
      <th>주요 상태</th>
      <th>확인할 내용</th>
      <th>사용자의 다음 행동</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>이미지 다운로드 실패</td>
      <td><code class="language-plaintext highlighter-rouge">ErrImagePull</code>, <code class="language-plaintext highlighter-rouge">ImagePullBackOff</code></td>
      <td>이미지 주소, Tag, Registry 접근 권한</td>
      <td>이미지명과 Registry 설정 확인</td>
    </tr>
    <tr>
      <td>컨테이너 반복 종료</td>
      <td><code class="language-plaintext highlighter-rouge">CrashLoopBackOff</code></td>
      <td>실행 명령, 환경변수, 애플리케이션 오류</td>
      <td>컨테이너 로그와 실행 설정 확인</td>
    </tr>
  </tbody>
</table>

<p>또한 두 화면의 Audit Log에서는 Kubernetes 리소스 생성이 완료된 뒤에도 실제 워크로드 상태를 다시 조회해 프로젝트 상태가 <code class="language-plaintext highlighter-rouge">RUNNING</code> 또는 <code class="language-plaintext highlighter-rouge">PROVISIONING</code>에서 <code class="language-plaintext highlighter-rouge">FAILED</code>로 변경되는 과정을 확인할 수 있다.</p>

<p>이를 통해 다음 두 상태를 구분해야 한다는 점을 확인했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>리소스 생성 성공
≠
애플리케이션 실행 성공
</code></pre></div></div>

<p>따라서 플랫폼은 API 요청과 리소스 생성 결과만 보여주는 것이 아니라, Pod와 Container의 실제 상태를 지속적으로 확인하고 실패 원인을 사용자에게 전달하도록 설계했다.</p>

<h2 id="kubernetes의-실제-상태와-플랫폼-상태를-분리했다">Kubernetes의 실제 상태와 플랫폼 상태를 분리했다</h2>

<p>Kubernetes에는 Pod와 Deployment의 실제 상태가 있다.</p>

<p>플랫폼 DB에는 사용자의 요청과 업무 생명주기 상태가 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>플랫폼 상태
REQUESTED
PROVISIONING
RUNNING
FAILED
DELETING
DELETED

Kubernetes 상태
Pending
Running
ImagePullBackOff
CrashLoopBackOff
Terminated
</code></pre></div></div>

<p>두 상태는 같지 않다.</p>

<p>예를 들어 프로젝트가 <code class="language-plaintext highlighter-rouge">PROVISIONING</code>인 동안 Pod는 <code class="language-plaintext highlighter-rouge">Pending</code>일 수 있다. 모든 Pod가 Ready가 되면 플랫폼 상태를 <code class="language-plaintext highlighter-rouge">RUNNING</code>으로 변경할 수 있다. <code class="language-plaintext highlighter-rouge">ImagePullBackOff</code>가 확인되면 <code class="language-plaintext highlighter-rouge">FAILED</code>로 판단할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Kubernetes 상태 조회
→ Pod와 Container 상태 분석
→ 플랫폼 상태 판단
→ DB 상태 변경
→ 상태 이력 저장
</code></pre></div></div>

<h2 id="생성뿐-아니라-삭제와-이력까지-보여주었다">생성뿐 아니라 삭제와 이력까지 보여주었다</h2>

<p>자동화가 투명하려면 생성 이후의 과정도 확인할 수 있어야 한다.</p>

<p>프로젝트 삭제 시 다음 리소스를 정리한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Ingress
→ Service
→ Deployment
→ ResourceQuota
→ Namespace
</code></pre></div></div>

<p>삭제 후 DB 레코드를 완전히 제거하지 않고 상태를 <code class="language-plaintext highlighter-rouge">DELETED</code>로 변경했다.</p>

<p>또한 다음 작업을 Audit Log에 기록했다.</p>

<ul>
  <li>프로젝트 생성</li>
  <li>Namespace 생성</li>
  <li>Deployment 생성</li>
  <li>Service 생성</li>
  <li>Ingress 생성</li>
  <li>상태 변경</li>
  <li>오류 발생</li>
  <li>프로젝트 삭제</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>누가 무엇을 요청했는가
→ 어떤 리소스가 생성되었는가
→ 결과가 성공했는가
→ 언제 삭제되었는가
</code></pre></div></div>

<p>이력까지 남김으로써 자동화를 단순히 편리한 기능이 아니라 추적 가능한 운영 과정으로 만들고자 했다.</p>

<p>프로젝트 요구사항 역시 정상 배포뿐 아니라 실패 원인 확인, 리소스 삭제, Audit Log를 핵심 사용자 시나리오로 정의하고 있다.</p>

<hr />

<h1 id="5-세-가지-고민을-시스템-구조로-연결하기">5. 세 가지 고민을 시스템 구조로 연결하기</h1>

<p>세 가지 고민은 각각 독립된 기능이 아니라 전체 아키텍처에 반영됐다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 사용자의 의도만 입력받는다.
            |
            v
React 프로젝트 생성 화면
            |
            v
2. 플랫폼 규칙을 적용한다.
            |
            v
FastAPI 입력 검증 및 Kubernetes 리소스 생성
            |
            v
Namespace / Quota / Deployment / Service / Ingress
            |
            v
3. 결과와 실패를 다시 사용자에게 보여준다.
            |
            v
Pod / Event 조회, 상태 이력, Audit Log
</code></pre></div></div>

<h2 id="react-dashboard">React Dashboard</h2>

<p>사용자가 애플리케이션 실행에 필요한 값만 입력하도록 화면을 구성했다.</p>

<p>주요 화면은 다음과 같다.</p>

<ul>
  <li>프로젝트 목록</li>
  <li>프로젝트 생성</li>
  <li>프로젝트 상세</li>
  <li>Pod 상태</li>
  <li>Kubernetes Event</li>
  <li>삭제 결과</li>
  <li>Audit Log</li>
</ul>

<h2 id="fastapi-backend">FastAPI Backend</h2>

<p>FastAPI는 단순 CRUD 서버가 아니라 사용자 요청과 Kubernetes 사이의 제어 계층으로 동작한다.</p>

<p>주요 책임은 다음과 같다.</p>

<ul>
  <li>입력 검증</li>
  <li>Naming Convention 적용</li>
  <li>프로젝트 상태 관리</li>
  <li>Kubernetes 리소스 생성</li>
  <li>Pod와 Event 조회</li>
  <li>오류 변환</li>
  <li>리소스 삭제</li>
  <li>Audit Log 저장</li>
</ul>

<h2 id="mariadb">MariaDB</h2>

<p>DB에는 Kubernetes의 현재 상태만으로 알 수 없는 정보를 저장한다.</p>

<ul>
  <li>사용자가 요청한 값</li>
  <li>프로젝트 현재 상태</li>
  <li>상태 변경 이력</li>
  <li>생성된 Kubernetes 리소스 메타데이터</li>
  <li>최근 오류</li>
  <li>생성·삭제 Audit Log</li>
</ul>

<p>DB 모델은 프로젝트, 상태 이력, Kubernetes 리소스 메타데이터, Audit Log를 분리해 생명주기를 추적하도록 설계했다.</p>

<h2 id="kubernetes">Kubernetes</h2>

<p>Kubernetes는 실제 애플리케이션을 실행한다.</p>

<ul>
  <li>Namespace를 통한 격리</li>
  <li>ResourceQuota를 통한 자원 제한</li>
  <li>Deployment를 통한 Pod 관리</li>
  <li>Service를 통한 내부 접근</li>
  <li>Ingress를 통한 외부 접근</li>
  <li>Pod와 Event를 통한 실행 상태 제공</li>
</ul>

<hr />

<h1 id="6-구현-흐름">6. 구현 흐름</h1>

<h2 id="프로젝트-생성">프로젝트 생성</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 사용자가 서비스 정보를 입력한다.
2. FastAPI가 입력값을 검증한다.
3. MariaDB에 프로젝트를 REQUESTED 상태로 저장한다.
4. Namespace를 생성한다.
5. ResourceQuota를 생성한다.
6. Deployment를 생성한다.
7. Service를 생성한다.
8. 외부 공개 요청이면 Ingress를 생성한다.
9. 생성된 리소스 정보를 DB에 저장한다.
10. Audit Log를 기록한다.
</code></pre></div></div>

<h2 id="상태-조회">상태 조회</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 사용자가 프로젝트 상세 화면에 접근한다.
2. DB에서 신청 정보와 플랫폼 상태를 조회한다.
3. Kubernetes API에서 Pod를 조회한다.
4. Container State와 Waiting Reason을 분석한다.
5. Namespace의 최근 Event를 조회한다.
6. 결과를 프로젝트 상세 화면에 표시한다.
</code></pre></div></div>

<h2 id="삭제">삭제</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 사용자가 프로젝트 삭제를 요청한다.
2. 프로젝트 상태를 DELETING으로 변경한다.
3. 플랫폼이 생성한 리소스인지 Label로 확인한다.
4. Ingress부터 Namespace까지 순차적으로 삭제한다.
5. 프로젝트 상태를 DELETED로 변경한다.
6. 삭제 시각과 Audit Log를 저장한다.
</code></pre></div></div>

<hr />

<h1 id="7-로컬에서-검증한-뒤-gke로-확장하기">7. 로컬에서 검증한 뒤 GKE로 확장하기</h1>

<p>Kubernetes 연동 기능을 개발할 때마다 클라우드 환경을 사용하면 반복 테스트가 느리고 비용이 발생할 수 있다.</p>

<p>따라서 개발 초기에는 Docker 위에 kind 클러스터를 구성해 로컬에서 빠르게 검증했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Local Machine
├── Docker Desktop
│   ├── MariaDB
│   └── kind Kubernetes Cluster
├── FastAPI
└── React
</code></pre></div></div>

<p>kind 환경에서는 다음 기능을 실제로 검증했다.</p>

<ul>
  <li>Namespace 생성</li>
  <li>Deployment와 Pod 생성</li>
  <li>Service 연결</li>
  <li>ResourceQuota 적용</li>
  <li>Ingress 생성</li>
  <li>Pod 상태 조회</li>
  <li>ImagePullBackOff 재현</li>
  <li>Kubernetes Event 조회</li>
  <li>리소스 삭제</li>
</ul>

<figure class="">
  <img src="/assets/images/platform/application-platform/kind-resources.png" alt="kubectl로 조회한 kind Namespace, Pod, Service, Ingress, ResourceQuota 리소스" /><figcaption>
      로컬 kind 환경에서 생성된 Namespace, Pod, Service, Ingress, ResourceQuota 리소스

    </figcaption></figure>

<p>로컬에서 정상·실패·삭제 시나리오를 반복 검증한 뒤에는 플랫폼 자체를 GKE에 배포했다.</p>

<p>이때 단순히 애플리케이션을 클러스터에 올리는 데 그치지 않고, <strong>포털 접근 경로와 사용자가 생성한 애플리케이션의 외부 접근 경로를 분리</strong>했다.</p>

<h2 id="포털-접근-경로">포털 접근 경로</h2>

<p>포털은 <code class="language-plaintext highlighter-rouge">portal.la-coruna.xyz</code> 도메인으로 접근하도록 구성했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>External User
      |
      v
portal.la-coruna.xyz
      |
      v
GCE Ingress
      |
      v
portal-frontend / portal-backend
      |
      v
GKE Kubernetes API
      |
      v
사용자 애플리케이션 Namespace
</code></pre></div></div>

<p>포털 백엔드는 클러스터 내부에서 Kubernetes API를 호출해 Namespace, ResourceQuota, Deployment, Service와 Ingress를 생성한다.</p>

<h2 id="사용자-애플리케이션-접근-경로">사용자 애플리케이션 접근 경로</h2>

<p>사용자가 외부 공개를 선택한 애플리케이션에는 다음 도메인 규칙을 적용했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{service-name}-{environment}.apps.la-coruna.xyz
</code></pre></div></div>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">demo-api</code> 서비스를 <code class="language-plaintext highlighter-rouge">staging</code> 환경에 생성하면 다음 주소로 접근할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>demo-api-staging.apps.la-coruna.xyz
</code></pre></div></div>

<p>초기에는 프로젝트마다 GKE 기본 Ingress를 생성했다. 이 구조에서는 각 프로젝트 Ingress가 별도의 Load Balancer IP를 사용하므로, 테스트 프로젝트가 늘어날수록 DNS 설정과 비용 관리가 복잡해질 수 있었다.</p>

<p>이를 해결하기 위해 사용자 애플리케이션용 Ingress를 하나의 <strong>shared ingress-nginx controller</strong>로 통합했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>External User
      |
      v
*.apps.la-coruna.xyz
      |
      v
shared ingress-nginx LoadBalancer
      |
      v
Project Ingress
      |
      v
Service
      |
      v
Pod
</code></pre></div></div>

<p>접근 경로를 정리하면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>도메인</th>
      <th>외부 진입점</th>
      <th>대상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>플랫폼 포털</td>
      <td><code class="language-plaintext highlighter-rouge">portal.la-coruna.xyz</code></td>
      <td>GCE Ingress</td>
      <td>portal-frontend / portal-backend</td>
    </tr>
    <tr>
      <td>사용자 애플리케이션</td>
      <td><code class="language-plaintext highlighter-rouge">*.apps.la-coruna.xyz</code></td>
      <td>shared ingress-nginx LoadBalancer</td>
      <td>프로젝트별 Ingress / Service / Pod</td>
    </tr>
  </tbody>
</table>

<p>이 구조를 통해 포털 운영 트래픽과 사용자 애플리케이션 트래픽을 구분했다. 또한 프로젝트가 늘어나더라도 하나의 shared ingress-nginx 진입점과 Wildcard DNS를 통해 외부 접근을 일관되게 관리할 수 있게 했다.</p>

<blockquote>
  <p>단순히 GKE에 배포하는 데 그치지 않고, 도메인과 Ingress 구조를 운영 관점에서 다시 설계했다.</p>
</blockquote>

<hr />

<h1 id="8-검증한-시나리오">8. 검증한 시나리오</h1>

<h2 id="정상-배포">정상 배포</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>입력 이미지: nginx:latest
Replica: 1
외부 공개: true
</code></pre></div></div>

<p>확인한 결과:</p>

<ul>
  <li>Namespace 생성</li>
  <li>ResourceQuota 생성</li>
  <li>Deployment 생성</li>
  <li>Pod Running</li>
  <li>Container Ready</li>
  <li>Service 생성</li>
  <li>Ingress 생성</li>
  <li>외부 도메인 접근 성공</li>
</ul>

<h2 id="실패-배포">실패 배포</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>입력 이미지: 존재하지 않는 이미지
</code></pre></div></div>

<p>확인한 결과:</p>

<ul>
  <li>Deployment와 Pod 생성</li>
  <li>Pod Pending</li>
  <li>Container Waiting</li>
  <li>ImagePullBackOff 확인</li>
  <li>Kubernetes Event에서 이미지 Pull 실패 원인 확인</li>
  <li>플랫폼 화면에서 실패 정보 조회</li>
</ul>

<h2 id="삭제-1">삭제</h2>

<p>확인한 결과:</p>

<ul>
  <li>관련 Kubernetes 리소스 정리</li>
  <li>프로젝트 상태 <code class="language-plaintext highlighter-rouge">DELETED</code> 반영</li>
  <li>삭제 시각 보존</li>
  <li>Audit Log 기록</li>
  <li>중복 삭제 요청 방지</li>
</ul>

<h2 id="상태-추적">상태 추적</h2>

<p>확인한 결과:</p>

<ul>
  <li>신청 정보와 실제 Kubernetes 상태 분리</li>
  <li>Pod와 Event 정보 조회</li>
  <li>프로젝트 상태 변화 저장</li>
  <li>생성·실패·삭제 이력 확인</li>
</ul>

<hr />

<h1 id="10-현재-구조의-한계">10. 현재 구조의 한계</h1>

<p>이 프로젝트는 애플리케이션 배포·운영의 핵심 흐름을 검증하기 위한 MVP다.</p>

<p>실제 조직에서 사용하려면 다음 기능이 추가로 필요하다.</p>

<h2 id="인증과-권한">인증과 권한</h2>

<p>현재보다 사용자와 팀을 명확히 구분하고, 프로젝트별 조회·생성·삭제 권한을 적용해야 한다.</p>

<p>Kubernetes 접근도 운영 환경에서는 ServiceAccount와 RBAC 최소 권한으로 제한해야 한다.</p>

<h2 id="비동기-provisioning">비동기 Provisioning</h2>

<p>현재 구조보다 규모가 커지면 HTTP 요청 안에서 모든 리소스를 생성하는 대신 작업 Queue와 Worker를 이용해 비동기로 처리하는 것이 적합하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>생성 요청
→ REQUESTED 저장
→ Queue 등록
→ Worker Provisioning
→ 상태 갱신
</code></pre></div></div>

<h2 id="reconciliation">Reconciliation</h2>

<p>DB의 목표 상태와 Kubernetes의 실제 상태가 달라질 수 있다.</p>

<p>주기적으로 두 상태를 비교하고 누락되거나 변경된 리소스를 감지하는 Reconciliation 과정이 필요하다.</p>

<h2 id="gitops">GitOps</h2>

<p>현재는 FastAPI가 Kubernetes API에 직접 리소스를 반영한다.</p>

<p>운영 환경에서는 Helm Values를 Git에 저장하고 ArgoCD가 반영하는 구조로 확장할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 요청
→ FastAPI
→ Helm Values 생성
→ Git Commit
→ ArgoCD Sync
→ Kubernetes 반영
</code></pre></div></div>

<p>이를 통해 변경 이력, Diff, Rollback과 Drift 감지를 강화할 수 있다.</p>

<h2 id="관측성">관측성</h2>

<p>Provisioning 소요 시간, 실패율, 오류 유형과 API 상태를 수집하기 위해 Prometheus, Grafana와 중앙 로그 시스템을 연결할 수 있다.</p>

<p>현재 구현한 Direct Apply와 향후 GitOps 확장 구조는 아키텍처 단계에서 명확히 분리했다.</p>

<hr />

<h1 id="11-프로젝트를-통해-배운-점">11. 프로젝트를 통해 배운 점</h1>

<h2 id="플랫폼은-복잡성을-없애는-것이-아니라-적절한-곳으로-옮기는-일이다">플랫폼은 복잡성을 없애는 것이 아니라, 적절한 곳으로 옮기는 일이다</h2>

<p>플랫폼을 만든다고 해서 Kubernetes의 복잡성 자체가 사라지는 것은 아니었다.</p>

<p>기존에는 사용자가 직접 처리해야 했던 리소스 이름 규칙, Label과 Selector, ResourceQuota, 리소스 생성 순서 등을 플랫폼이 대신 책임지게 된 것이다.</p>

<p>좋은 플랫폼은 복잡성을 무조건 감추는 것이 아니라, 사용자가 알 필요 없는 부분은 내부에서 처리하고, 배포 상태와 실패 원인처럼 사용자가 판단에 필요한 정보는 다시 명확하게 전달해야 한다.</p>

<h2 id="셀프서비스와-운영-통제는-함께-설계할-수-있다">셀프서비스와 운영 통제는 함께 설계할 수 있다</h2>

<p>처음에는 사용자의 자유도를 높이는 것과 운영자의 통제를 유지하는 것이 서로 충돌할 수 있다고 생각했다.</p>

<p>하지만 플랫폼이 안전한 기본값과 명확한 제한을 제공하면 두 가지를 함께 만족시킬 수 있었다.</p>

<p>사용자는 복잡한 설정을 반복해서 판단하지 않아도 더 빠르고 안전하게 작업할 수 있고, 운영자는 일관된 규칙으로 생성된 리소스를 관리할 수 있다.</p>

<h2 id="정상-동작뿐-아니라-실패-상황을-검증하는-일이-중요했다">정상 동작뿐 아니라 실패 상황을 검증하는 일이 중요했다</h2>

<p>정상적인 이미지를 배포하는 기능만으로는 플랫폼의 운영 가치를 충분히 검증하기 어려웠다.</p>

<p>존재하지 않는 이미지, 컨테이너 반복 종료, 중복 요청, 삭제와 같은 실패·예외 상황을 직접 구현하고 검증하면서 상태 관리와 관측성의 중요성을 체감했다.</p>

<p>특히 단순히 실패했다는 결과만 보여주는 것이 아니라, 사용자가 원인을 이해하고 다음 행동을 결정할 수 있도록 Pod 상태와 Kubernetes Event를 함께 제공해야 한다는 점을 배웠다.</p>

<h2 id="리소스-생성보다-전체-생명주기를-관리하는-일이-더-중요하다">리소스 생성보다 전체 생명주기를 관리하는 일이 더 중요하다</h2>

<p>처음에는 Kubernetes 리소스를 생성하는 코드가 프로젝트의 핵심이라고 생각했다.</p>

<p>하지만 실제로는 생성 전후의 전체 과정을 관리하는 데 더 많은 고민이 필요했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>요청을 어떻게 검증할 것인가
→ 생성 과정을 어떤 상태로 표현할 것인가
→ 실패 원인을 어떻게 전달할 것인가
→ 플랫폼이 생성한 리소스만 어떻게 식별하고 삭제할 것인가
→ 삭제 이후의 이력을 어떻게 보존할 것인가
</code></pre></div></div>

<p>이 경험을 통해 플랫폼 개발은 단순히 인프라 명령을 대신 실행하는 일이 아니라, 사용자의 의도를 안전한 시스템 동작으로 변환하고 그 결과를 전체 생명주기에 걸쳐 관리하는 일이라는 점을 배웠다.</p>

<hr />

<h1 id="12-마치며">12. 마치며</h1>

<p>Kubernetes 기반 애플리케이션 배포·운영 플랫폼은 다음 질문에서 출발했다.</p>

<blockquote>
  <p>개발자가 Kubernetes의 모든 세부사항을 직접 다루지 않고도 애플리케이션을 배포할 수 있을까?</p>
</blockquote>

<p>그러나 프로젝트를 진행하며 질문은 다음과 같이 확장됐다.</p>

<blockquote>
  <p>개발자에게 편리한 경험을 제공하면서, 운영 표준과 실패에 대한 가시성까지 함께 보장할 수 있을까?</p>
</blockquote>

<p>이 질문에 답하기 위해 세 가지 원칙을 세웠다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 사용자는 인프라가 아니라 애플리케이션 실행 의도를 입력한다.
2. 플랫폼은 Naming, 격리, 자원과 네트워크 정책을 표준화한다.
3. 복잡성은 숨기되 상태와 실패 원인까지 숨기지는 않는다.
</code></pre></div></div>

<p>이 원칙을 바탕으로 신청부터 생성, 상태 확인, 실패 분석, 외부 공개, 삭제와 이력 보존까지 하나의 흐름으로 구현했다.</p>

<p>완성된 상용 플랫폼과 비교하면 인증, 멀티 클러스터, Reconciliation, GitOps와 관측성 등 보완할 부분이 남아 있다.</p>

<p>그럼에도 이번 프로젝트를 통해 Kubernetes를 사용하는 경험을 넘어, 복잡한 인프라를 개발자가 반복해서 사용할 수 있는 제품 인터페이스로 바꾸는 과정을 경험할 수 있었다.</p>

<blockquote>
  <p><strong>플랫폼 개발은 명령을 대신 실행하는 일이 아니라, 사용자 편의와 운영 통제 사이의 경계를 설계하는 일이다.</strong></p>
</blockquote>

<hr />

<h1 id="프로젝트-링크">프로젝트 링크</h1>

<ul>
  <li><a href="http://portal.la-coruna.xyz/projects">서비스</a></li>
  <li><a href="https://github.com/La-Coruna/private-cloud-self-service-portal">GitHub</a></li>
</ul>]]></content><author><name>🌵Lacoruna</name></author><category term="Cloud" /><category term="Platform Engineering" /><category term="Kubernetes" /><category term="Platform Engineering" /><category term="FastAPI" /><category term="React" /><category term="MariaDB" /><category term="Docker" /><category term="GKE" /><category term="Infrastructure Automation" /><summary type="html"><![CDATA[개발자는 인프라의 복잡성을 덜 느끼면서도 운영자는 표준과 추적성을 유지할 수 있도록, Kubernetes 기반 애플리케이션 배포·운영 플랫폼을 설계하고 구현한 과정]]></summary></entry><entry><title type="html">[StoreOps] 4. MVP 재고 시스템을 확장 가능한 Inventory 구조로 발전시켜보기</title><link href="https://la-coruna.github.io/posts/storeops-inventory-expansion/" rel="alternate" type="text/html" title="[StoreOps] 4. MVP 재고 시스템을 확장 가능한 Inventory 구조로 발전시켜보기" /><published>2026-07-04T00:00:00+00:00</published><updated>2026-07-04T00:00:00+00:00</updated><id>https://la-coruna.github.io/posts/storeops-inventory-expansion</id><content type="html" xml:base="https://la-coruna.github.io/posts/storeops-inventory-expansion/"><![CDATA[<blockquote>
  <p><strong>StoreOps Platform 시리즈</strong></p>
  <ul>
    <li><a href="/posts/storeops-project-background/">1편: 프로젝트 배경</a></li>
    <li><a href="/posts/storeops-domain-design/">2편: 도메인 설계</a></li>
    <li><a href="/posts/storeops-inventory-transaction/">3편: 재고 트랜잭션</a></li>
    <li>4편: 대규모 Inventory 확장</li>
    <li><a href="/storeops/">전체 글 목록</a></li>
  </ul>
</blockquote>

<h2 id="1-왜-이-글을-쓰는가">1. 왜 이 글을 쓰는가</h2>

<p>StoreOps Platform은 편의점 운영 흐름을 작게 구현해보기 위한 MVP 프로젝트다.</p>

<p>지금까지 이 프로젝트에서는 POS 판매, PDA 입고, 고객 재고조회, 관리자 재고/발주 기능을 하나의 재고 데이터를 중심으로 연결했다.</p>

<p>MVP에서의 핵심 구조는 단순하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매  → Inventory 감소
PDA 입고  → Inventory 증가
고객 조회 → Inventory 조회
관리자    → Inventory + Sale 이력 기반 운영 판단
</code></pre></div></div>

<p>이 구조는 학습용 프로젝트에서 장점이 분명하다. POS, PDA, 고객 앱, 관리자 WEB이 서로 다른 시스템처럼 보이지만 결국 <strong>점포별 상품 재고</strong>라는 같은 데이터를 기준으로 연결된다는 메시지를 보여주기 좋다.</p>

<p>하지만 프로젝트를 구현하면서 자연스럽게 다음 질문이 생겼다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>실제 대규모 운영 시스템에서도 Inventory 테이블 하나만으로 충분할까?
고객 앱 조회 트래픽이 많아지면 어떻게 해야 할까?
동시에 여러 POS 판매 요청이 들어오면 초과 판매를 어떻게 막을까?
입고와 판매 외에 폐기, 반품, 조정, 점포 간 이동은 어떻게 기록할까?
픽업 예약처럼 아직 판매 확정은 아니지만 재고를 잡아둬야 하는 상황은 어떻게 처리할까?
네트워크 장애로 POS 이벤트가 중복 전송되면 재고가 두 번 줄어들지 않을까?
</code></pre></div></div>

<p>물론 실제 편의점 기업의 내부 시스템 구조를 알 수는 없다. 따라서 이 글은 특정 회사의 실제 구조를 단정하는 글이 아니다.</p>

<p>대신 내가 구현한 MVP 재고 시스템을 출발점으로, <strong>대규모 운영 시스템으로 확장한다고 가정했을 때 어떤 문제를 고려할 수 있는지</strong> 정리해보려 한다.</p>

<p>이번 글의 핵심 메시지는 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MVP에서는 Inventory를 단일 기준 데이터로 단순화했다.
하지만 대규모 운영 시스템에서는 재고 정합성, 운영 추적성, 고객 조회 성능, 예약 재고, 장애 복구를 위해
StockMovement, InventoryBalance, Reservation, CustomerAvailabilityView 같은 역할 분리가 필요할 수 있다.
</code></pre></div></div>

<hr />

<h2 id="2-mvp-inventory-구조-다시-보기">2. MVP Inventory 구조 다시 보기</h2>

<p>먼저 현재 MVP 구조를 다시 정리해보자.</p>

<p>현재 프로젝트에서는 재고를 다음과 같이 단순하게 표현했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>inventories
- id
- store_id
- product_id
- quantity
- created_at
- updated_at
</code></pre></div></div>

<p>그리고 재고 변동 이력은 판매와 입고로 나누어 저장했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sales
- id
- store_id
- product_id
- quantity
- sold_at

receipts
- id
- store_id
- product_id
- quantity
- received_at
</code></pre></div></div>

<p>이 구조에서 주요 흐름은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매
→ inventories.quantity 감소
→ sales 이력 저장

PDA 입고
→ inventories.quantity 증가
→ receipts 이력 저장

고객 재고조회
→ inventories.quantity 조회

관리자 발주 추천
→ inventories.quantity + sales 이력 기반 계산
</code></pre></div></div>

<p>이 구조는 MVP 단계에서 충분히 의미가 있다.</p>

<p>첫째, 구조가 단순하다. 점포별 상품 재고를 <code class="language-plaintext highlighter-rouge">Inventory</code> 하나로 표현하기 때문에 프로젝트의 핵심 메시지가 명확하다.</p>

<p>둘째, 트랜잭션 처리가 쉽다. POS 판매 시 재고 차감과 판매 이력 저장을 하나의 <code class="language-plaintext highlighter-rouge">@Transactional</code> 안에서 처리할 수 있고, PDA 입고도 재고 증가와 입고 이력 저장을 하나의 트랜잭션으로 묶을 수 있다.</p>

<p>셋째, 구현 범위가 적절하다. 학습용 프로젝트에서 너무 복잡한 구조를 처음부터 적용하면 오히려 핵심 기능 완성도가 떨어질 수 있다. MVP에서는 재고 흐름을 끝까지 구현하는 것이 더 중요했다.</p>

<p>하지만 이 구조에는 한계도 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>재고 변동 사유가 Sale, Receipt로만 제한된다.
폐기, 반품, 재고 조정, 점포 간 이동을 표현하기 어렵다.
고객 조회 트래픽이 많아지면 Inventory 직접 조회가 부담될 수 있다.
예약 재고와 실제 보유 재고를 구분하지 않는다.
동시 판매 상황에서 초과 판매 문제가 발생할 수 있다.
오프라인 POS나 중복 이벤트 처리까지 고려하지 않는다.
</code></pre></div></div>

<p>따라서 실제 대규모 시스템으로 확장한다고 가정하면, Inventory를 단일 테이블로만 보기보다 여러 책임으로 나누어 생각할 필요가 있다.</p>

<hr />

<h2 id="3-대규모-inventory에서-새로-생기는-문제">3. 대규모 Inventory에서 새로 생기는 문제</h2>

<p>대규모 운영 환경에서 재고는 단순히 “현재 몇 개 남았는가”를 저장하는 데이터가 아니다.</p>

<p>재고는 고객 경험, 점포 운영, 물류, 발주, 정산, 장애 대응과 모두 연결된다.</p>

<p>MVP에서는 다음 정도의 흐름만 다루었다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>판매
입고
조회
발주 추천
</code></pre></div></div>

<p>하지만 실제 운영 규모를 가정하면 다음과 같은 상황들이 추가된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>고객 앱에서 특정 상품의 점포별 재고조회 요청이 많이 발생한다.
동일 상품에 대해 여러 POS 판매 요청이 동시에 들어온다.
PDA 입고 외에도 폐기, 반품, 재고 조정, 점포 간 이동이 발생한다.
픽업 예약이나 배달 주문으로 재고를 미리 잡아둬야 한다.
점포 POS가 네트워크 장애를 겪은 뒤 판매 이벤트를 재전송할 수 있다.
고객 앱에는 실제 재고 수량을 그대로 보여주기 어려울 수 있다.
운영자는 현재 재고뿐 아니라 재고가 왜 변했는지도 추적해야 한다.
</code></pre></div></div>

<p>이 문제들을 모두 <code class="language-plaintext highlighter-rouge">inventories.quantity</code> 하나로 처리하려고 하면 한계가 생긴다.</p>

<p>그래서 대규모 Inventory 설계에서는 다음 질문을 나누어 생각해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 재고가 왜 변했는가?
2. 현재 재고는 얼마인가?
3. 판매 가능한 재고는 얼마인가?
4. 고객에게 어떤 상태로 보여줄 것인가?
5. 조회 성능을 어떻게 확보할 것인가?
6. 중복 이벤트와 동시 요청을 어떻게 처리할 것인가?
</code></pre></div></div>

<p>이 질문에 맞춰 Inventory를 여러 층으로 나누면 다음과 같은 구조를 생각할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>StockMovement
→ 재고 변동 원장

InventoryBalance
→ 현재 재고 상태

Reservation
→ 예약/홀드 재고

CustomerAvailabilityView
→ 고객 앱 조회용 재고 상태

Cache/Search/Analytics
→ 빠른 조회와 분석을 위한 파생 데이터
</code></pre></div></div>

<p>각 층은 같은 재고를 다루지만 목적이 다르다.</p>

<p>StockMovement는 “왜 변했는가”를 기록한다. InventoryBalance는 “현재 얼마인가”를 빠르게 보여준다. Reservation은 “아직 판매 확정은 아니지만 잡혀 있는 재고”를 관리한다. CustomerAvailabilityView는 “고객에게 어떻게 보여줄 것인가”를 담당한다. Cache나 검색 인덱스는 “어떻게 빠르게 조회할 것인가”를 담당한다.</p>

<hr />

<h2 id="4-주요-확장-요소-간단히-정리">4. 주요 확장 요소 간단히 정리</h2>

<p>앞에서 정리한 구조를 실제 테이블 수준까지 자세히 풀어낼 수도 있지만, 이 글에서는 개념의 역할만 간단히 짚고 넘어가는 정도가 적절하다고 생각했다.</p>

<p>MVP에서 사용한 <code class="language-plaintext highlighter-rouge">Inventory</code>, <code class="language-plaintext highlighter-rouge">Sale</code>, <code class="language-plaintext highlighter-rouge">Receipt</code> 구조를 대규모 시스템으로 확장한다면 다음과 같이 역할을 나눠볼 수 있다.</p>

<table>
  <thead>
    <tr>
      <th>확장 요소</th>
      <th>역할</th>
      <th>MVP에서 대응되는 부분</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>StockMovement</td>
      <td>재고가 왜 변했는지 기록하는 원장</td>
      <td>Sale, Receipt 이력</td>
    </tr>
    <tr>
      <td>InventoryBalance</td>
      <td>현재 재고를 빠르게 조회하기 위한 상태 데이터</td>
      <td>Inventory.quantity</td>
    </tr>
    <tr>
      <td>Reservation</td>
      <td>픽업/주문 등으로 미리 잡아둔 예약 재고</td>
      <td>MVP에서는 미구현</td>
    </tr>
    <tr>
      <td>CustomerAvailabilityView</td>
      <td>고객 앱에 보여줄 재고 상태</td>
      <td>고객 재고조회 응답</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">StockMovement</code>는 판매, 입고뿐 아니라 폐기, 반품, 재고 조정, 점포 간 이동처럼 다양한 재고 변동 사유를 하나의 원장으로 남기기 위한 구조다. MVP에서는 Sale과 Receipt를 분리했지만, 규모가 커지면 모든 재고 변동을 하나의 기준으로 추적하는 편이 운영 분석에 더 유리할 수 있다.</p>

<p><code class="language-plaintext highlighter-rouge">InventoryBalance</code>는 현재 재고를 빠르게 조회하기 위한 상태 데이터다. 모든 변동 이력을 매번 합산해서 현재 재고를 계산하면 비용이 커질 수 있기 때문에, 현재 보유 재고와 판매 가능 재고를 별도로 관리하는 구조를 생각할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>available_quantity = on_hand_quantity - reserved_quantity - safety_stock
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Reservation</code>은 픽업 예약이나 배달 주문처럼 아직 판매가 확정되지는 않았지만 다른 고객에게 팔면 안 되는 수량을 관리한다. 이 개념이 없으면 앱에서 예약된 상품과 매장 판매가 겹쳐 초과 판매가 발생할 수 있다.</p>

<p><code class="language-plaintext highlighter-rouge">CustomerAvailabilityView</code>는 고객 앱 조회를 위한 가공 데이터다. 고객에게는 실제 수량을 그대로 보여주기보다 <code class="language-plaintext highlighter-rouge">AVAILABLE</code>, <code class="language-plaintext highlighter-rouge">LOW_STOCK</code>, <code class="language-plaintext highlighter-rouge">SOLD_OUT</code> 같은 상태값으로 제공하는 편이 더 안전할 수 있다.</p>

<p>즉, 대규모 Inventory 설계에서는 하나의 <code class="language-plaintext highlighter-rouge">quantity</code>만 보는 것이 아니라 다음 질문을 나누어 생각해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>재고가 왜 변했는가?
현재 시스템상 재고는 얼마인가?
그중 실제 판매 가능한 재고는 얼마인가?
고객에게는 어떤 상태로 보여줄 것인가?
</code></pre></div></div>

<p>MVP는 이 구조를 모두 구현하지는 않았지만, <code class="language-plaintext highlighter-rouge">Inventory</code>, <code class="language-plaintext highlighter-rouge">Sale</code>, <code class="language-plaintext highlighter-rouge">Receipt</code>, 고객 재고조회 API를 통해 이 확장 구조의 가장 작은 형태를 구현했다고 볼 수 있다.</p>

<hr />
<h2 id="5-redis-cache와-조회용-파생-데이터">5. Redis Cache와 조회용 파생 데이터</h2>

<p>고객 재고조회는 조회 트래픽이 많을 수 있다.</p>

<p>예를 들어 인기 상품이나 행사 상품의 경우, 많은 고객이 주변 점포 재고를 동시에 조회할 수 있다. 이때 매번 운영 DB의 InventoryBalance를 직접 조회하면 부담이 커질 수 있다.</p>

<p>그래서 Redis 캐시나 검색 인덱스를 사용할 수 있다.</p>

<p>예를 들어 Redis key를 다음과 같이 둘 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>availability:product:1001:region:seoul-hanyang
</code></pre></div></div>

<p>value는 다음과 같은 형태가 될 수 있다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"storeId"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
    </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AVAILABLE"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"displayQuantityRange"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3개 이상"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"storeId"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
    </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LOW_STOCK"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"displayQuantityRange"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1~2개"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"storeId"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
    </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SOLD_OUT"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"displayQuantityRange"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0개"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>하지만 재고 데이터 캐싱은 조심해야 한다. 재고는 POS 판매와 PDA 입고에 따라 자주 바뀐다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매 → 재고 감소
PDA 입고 → 재고 증가
예약 생성 → 판매 가능 재고 감소
예약 취소 → 판매 가능 재고 증가
폐기 처리 → 재고 감소
</code></pre></div></div>

<p>따라서 단순히 “Redis를 쓰면 빠르다”로 접근하면 위험하다. 캐시가 오래 남아 있으면 실제 재고와 고객 앱 조회 결과가 달라질 수 있기 때문이다.</p>

<p>그래서 캐시를 적용한다면 다음 전략을 함께 고려해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TTL을 짧게 둔다.
POS 판매나 PDA 입고 이벤트 발생 시 캐시를 무효화한다.
정확한 수량이 아니라 AVAILABLE/LOW_STOCK/SOLD_OUT 상태만 캐시한다.
고객에게 last_synced_at을 함께 제공한다.
재고 수량이 적은 상품은 더 보수적으로 표시한다.
</code></pre></div></div>

<p>대규모 구조에서는 다음과 같은 흐름을 생각할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>InventoryBalance 변경
→ InventoryUpdated 이벤트 발행
→ CustomerAvailabilityView 갱신
→ Redis 캐시 무효화 또는 갱신
→ 고객 앱 조회 결과 반영
</code></pre></div></div>

<p>이 구조에서 Redis는 기준 데이터가 아니다. 기준 데이터는 InventoryBalance와 StockMovement이고, Redis는 빠른 조회를 위한 파생 데이터다.</p>

<p>이 점이 중요하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Redis는 재고의 원본 데이터가 아니라, 고객 조회 성능을 높이기 위한 캐시다.
</code></pre></div></div>

<hr />

<h2 id="6-동시성-제어-초과-판매를-어떻게-막을-것인가">6. 동시성 제어: 초과 판매를 어떻게 막을 것인가</h2>

<p>재고 시스템에서 가장 중요한 문제 중 하나는 동시성이다.</p>

<p>예를 들어 현재 판매 가능 재고가 1개 남았다고 하자.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>available_quantity = 1
</code></pre></div></div>

<p>그런데 동시에 두 POS에서 판매 요청이 들어온다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>요청 A: 1개 판매
요청 B: 1개 판매
</code></pre></div></div>

<p>두 요청이 모두 현재 재고 1개를 보고 판매 가능하다고 판단하면, 총 2개가 판매되는 문제가 발생할 수 있다.</p>

<p>이를 막기 위한 방식은 여러 가지가 있다.</p>

<h3 id="61-조건부-update">6.1 조건부 UPDATE</h3>

<p>가장 직관적인 방식 중 하나는 조건부 UPDATE다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">UPDATE</span> <span class="n">inventory_balances</span>
<span class="k">SET</span> <span class="n">available_quantity</span> <span class="o">=</span> <span class="n">available_quantity</span> <span class="o">-</span> <span class="p">:</span><span class="n">quantity</span>
<span class="k">WHERE</span> <span class="n">store_id</span> <span class="o">=</span> <span class="p">:</span><span class="n">storeId</span>
  <span class="k">AND</span> <span class="n">product_id</span> <span class="o">=</span> <span class="p">:</span><span class="n">productId</span>
  <span class="k">AND</span> <span class="n">available_quantity</span> <span class="o">&gt;=</span> <span class="p">:</span><span class="n">quantity</span><span class="p">;</span>
</code></pre></div></div>

<p>이 쿼리는 <code class="language-plaintext highlighter-rouge">available_quantity</code>가 요청 수량 이상일 때만 차감된다.</p>

<p>업데이트된 row 수가 1이면 성공이고, 0이면 재고 부족으로 처리할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>updated row count = 1 → 판매 성공
updated row count = 0 → 재고 부족
</code></pre></div></div>

<p>이 방식의 장점은 재고 부족 검증과 차감을 DB의 단일 UPDATE로 처리할 수 있다는 점이다.</p>

<h3 id="62-낙관적-락">6.2 낙관적 락</h3>

<p>두 번째 방식은 낙관적 락이다.</p>

<p>InventoryBalance에 <code class="language-plaintext highlighter-rouge">version</code> 컬럼을 두고, 업데이트 시점에 내가 읽은 version과 현재 version이 같은지 확인한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>조회 시 version = 10
업데이트하려고 보니 version = 11
→ 누군가 먼저 수정함
→ 업데이트 실패 후 재시도 또는 실패 응답
</code></pre></div></div>

<p>JPA에서는 <code class="language-plaintext highlighter-rouge">@Version</code>을 사용할 수 있다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Version</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">version</span><span class="o">;</span>
</code></pre></div></div>

<p>낙관적 락은 충돌이 많지 않은 상황에서는 효율적이다. 하지만 인기 상품처럼 동시에 많은 요청이 몰리는 경우에는 충돌과 재시도가 많아질 수 있다.</p>

<h3 id="63-비관적-락">6.3 비관적 락</h3>

<p>세 번째 방식은 비관적 락이다.</p>

<p>재고 row를 먼저 잠근 뒤 처리하는 방식이다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">inventory_balances</span>
<span class="k">WHERE</span> <span class="n">store_id</span> <span class="o">=</span> <span class="p">:</span><span class="n">storeId</span>
  <span class="k">AND</span> <span class="n">product_id</span> <span class="o">=</span> <span class="p">:</span><span class="n">productId</span>
<span class="k">FOR</span> <span class="k">UPDATE</span><span class="p">;</span>
</code></pre></div></div>

<p>이 방식은 정합성을 강하게 보장할 수 있다.</p>

<p>하지만 동시에 요청이 많이 몰리면 대기 시간이 증가할 수 있다. 즉, 정확성은 높지만 성능 부담이 생길 수 있다.</p>

<h3 id="64-어떤-방식을-선택할-것인가">6.4 어떤 방식을 선택할 것인가</h3>

<p>정답은 상황에 따라 달라진다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>충돌이 적은 상품 → 낙관적 락
인기 상품처럼 충돌이 많은 상품 → 조건부 UPDATE 또는 비관적 락
강한 정합성이 필요한 예약/결제 흐름 → 더 엄격한 락 전략
조회 중심 기능 → 캐시와 read model 활용
</code></pre></div></div>

<p>MVP에서는 기본 트랜잭션과 재고 음수 방지까지만 구현했다. 하지만 대규모 시스템으로 확장한다면 조건부 UPDATE, 낙관적 락, 비관적 락 중 하나를 상황에 맞게 선택해야 한다.</p>

<p>이 지점에서 중요한 것은 “무조건 어떤 기술을 쓴다”가 아니라, <strong>재고 정합성과 처리량 사이의 균형을 어떻게 잡을 것인가</strong>다.</p>

<hr />

<h2 id="7-오프라인-pos와-idempotency-key">7. 오프라인 POS와 idempotency key</h2>

<p>편의점 점포 시스템에서는 네트워크가 항상 안정적이라고 가정하기 어렵다.</p>

<p>점포 POS가 일시적으로 서버와 연결되지 않을 수 있다. 하지만 네트워크가 잠깐 불안정하다고 해서 모든 판매를 중단하기는 어렵다.</p>

<p>그래서 POS는 판매 이벤트를 로컬에 저장했다가, 네트워크가 복구되면 서버로 다시 전송하는 구조를 가질 수 있다.</p>

<p>이때 문제가 되는 것은 중복 전송이다.</p>

<p>예를 들어 다음 상황을 생각해보자.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. POS가 판매 이벤트 S-1001을 서버로 전송한다.
2. 서버는 정상 처리했지만 응답 중 네트워크가 끊긴다.
3. POS는 실패했다고 판단하고 같은 이벤트 S-1001을 다시 전송한다.
4. 서버가 이를 새로운 이벤트로 처리하면 재고가 두 번 차감된다.
</code></pre></div></div>

<p>이 문제를 막기 위해 <code class="language-plaintext highlighter-rouge">idempotency_key</code>가 필요하다.</p>

<p>idempotency는 같은 요청이 여러 번 들어와도 결과가 한 번 처리된 것과 같도록 만드는 성질이다.</p>

<p>POS 판매 이벤트에서는 다음과 같은 값을 idempotency key로 사용할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pos_terminal_id + receipt_number + sold_at
</code></pre></div></div>

<p>또는 POS 시스템에서 생성한 고유 판매 이벤트 ID를 사용할 수도 있다.</p>

<p>StockMovement에 <code class="language-plaintext highlighter-rouge">idempotency_key</code>를 저장하면, 같은 이벤트가 다시 들어왔을 때 중복 처리를 막을 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>이미 처리된 idempotency_key
→ 재고 차감하지 않음
→ 기존 처리 결과 반환 또는 중복 이벤트로 무시
</code></pre></div></div>

<p>이 구조는 개발운영 관점에서 중요하다.</p>

<p>네트워크 장애나 재시도는 운영 환경에서 충분히 발생할 수 있기 때문이다. 따라서 대규모 재고 시스템에서는 “정상 요청”뿐 아니라 “중복 요청, 지연 요청, 재전송 요청”도 고려해야 한다.</p>

<hr />

<h2 id="8-재고-조정과-실사">8. 재고 조정과 실사</h2>

<p>실제 재고는 시스템 재고와 항상 일치하지 않을 수 있다.</p>

<p>이유는 다양하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>도난
파손
폐기 누락
입고 검수 오류
POS 처리 실수
수기 조정
네트워크 장애
점포 간 이동 오류
</code></pre></div></div>

<p>따라서 대규모 재고 시스템에서는 재고 실사와 조정 기능이 필요하다.</p>

<p>예를 들어 시스템상 재고는 10개인데 실제 매장에는 7개만 있다면, 관리자는 재고를 -3 조정해야 한다.</p>

<p>이때 단순히 InventoryBalance의 수량만 바꾸면 안 된다. 왜 조정했는지 기록해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stock_movements
- movement_type = ADJUSTMENT_MINUS
- quantity_delta = -3
- reason = "실사 결과 재고 불일치"
- occurred_at = 2026-07-08 22:00
</code></pre></div></div>

<p>이렇게 기록하면 나중에 재고가 왜 줄었는지 추적할 수 있다.</p>

<p>MVP에서는 판매와 입고만 다뤘지만, 실제 운영에서는 조정과 실사가 매우 중요하다.</p>

<p>재고 시스템은 결국 현실 세계의 상품 수량을 소프트웨어로 표현하는 시스템이다. 현실 세계에서는 분실, 파손, 폐기, 실수 같은 일이 발생하기 때문에, 시스템도 이를 보정할 수 있는 구조를 가져야 한다.</p>

<hr />

<h2 id="9-대규모-inventory-테이블-구조-예시">9. 대규모 Inventory 테이블 구조 예시</h2>

<p>지금까지의 내용을 바탕으로 대규모 Inventory 구조를 단순화해보면 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>products
- id
- name
- category
- status

stores
- id
- name
- region
- status

inventory_balances
- store_id
- product_id
- on_hand_quantity
- reserved_quantity
- available_quantity
- safety_stock
- version
- updated_at

stock_movements
- id
- store_id
- product_id
- movement_type
- quantity_delta
- source_type
- source_id
- idempotency_key
- occurred_at
- created_at

inventory_reservations
- id
- store_id
- product_id
- order_id
- quantity
- status
- expires_at
- created_at
- updated_at

customer_inventory_view
- store_id
- product_id
- availability_status
- display_quantity_range
- last_synced_at

inventory_snapshots
- store_id
- product_id
- snapshot_date
- opening_quantity
- closing_quantity
</code></pre></div></div>

<p>각 테이블의 역할은 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>테이블</th>
      <th>역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>products</td>
      <td>상품 기준 정보</td>
    </tr>
    <tr>
      <td>stores</td>
      <td>점포 기준 정보</td>
    </tr>
    <tr>
      <td>inventory_balances</td>
      <td>현재 재고 상태</td>
    </tr>
    <tr>
      <td>stock_movements</td>
      <td>재고 변동 이력</td>
    </tr>
    <tr>
      <td>inventory_reservations</td>
      <td>예약/홀드 재고</td>
    </tr>
    <tr>
      <td>customer_inventory_view</td>
      <td>고객 앱 조회용 재고 상태</td>
    </tr>
    <tr>
      <td>inventory_snapshots</td>
      <td>일자별 재고 스냅샷</td>
    </tr>
  </tbody>
</table>

<p>MVP에서는 이 중 일부만 구현했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MVP 구현
- stores
- products
- inventories
- sales
- receipts
- order_recommendations
</code></pre></div></div>

<p>대규모로 확장한다면 다음과 같이 바꿀 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>inventories → inventory_balances
sales/receipts → stock_movements로 일반화
customer API → customer_inventory_view 또는 Redis 캐시 사용
reservation → 픽업/예약 주문 기능과 연결
order recommendation → stock_movements와 inventory_snapshots 기반 분석
</code></pre></div></div>

<p>이렇게 보면 현재 MVP는 대규모 구조의 축소판이라고 볼 수 있다.</p>

<hr />

<h2 id="10-mvp와-대규모-설계-비교">10. MVP와 대규모 설계 비교</h2>

<p>현재 MVP와 대규모 확장 구조를 비교하면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>관점</th>
      <th>MVP 구조</th>
      <th>대규모 확장 구조</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>현재 재고</td>
      <td>inventories.quantity</td>
      <td>inventory_balances</td>
    </tr>
    <tr>
      <td>재고 이력</td>
      <td>sales, receipts</td>
      <td>stock_movements</td>
    </tr>
    <tr>
      <td>예약 재고</td>
      <td>없음</td>
      <td>inventory_reservations</td>
    </tr>
    <tr>
      <td>고객 조회</td>
      <td>Inventory 직접 조회</td>
      <td>customer_inventory_view + cache</td>
    </tr>
    <tr>
      <td>동시성 제어</td>
      <td>트랜잭션, 음수 방지</td>
      <td>조건부 UPDATE, 락, version</td>
    </tr>
    <tr>
      <td>중복 이벤트 처리</td>
      <td>없음</td>
      <td>idempotency_key</td>
    </tr>
    <tr>
      <td>실사/조정</td>
      <td>없음</td>
      <td>adjustment movement</td>
    </tr>
    <tr>
      <td>분석/발주</td>
      <td>Sale 이력 기반 단순 계산</td>
      <td>movement, snapshot, sales history 기반 분석</td>
    </tr>
  </tbody>
</table>

<p>이 비교를 통해 MVP가 어떤 점을 단순화했고, 대규모 시스템에서는 어떤 부분을 보강해야 하는지 명확히 볼 수 있다.</p>

<p>중요한 것은 MVP가 부족하다는 뜻이 아니다.</p>

<p>MVP는 의도적으로 단순화한 구조다. 프로젝트의 목적은 대규모 운영 시스템을 완전히 재현하는 것이 아니라, 핵심 흐름을 이해하고 구현하는 것이었다.</p>

<p>다만 MVP 이후의 확장 방향을 고민함으로써, 내가 만든 구조의 한계와 발전 가능성을 함께 이해할 수 있었다.</p>

<hr />

<h2 id="11-이번-고민을-통해-얻은-것">11. 이번 고민을 통해 얻은 것</h2>

<p>이 글을 정리하면서 Inventory 설계가 단순히 수량 컬럼 하나의 문제가 아니라는 것을 더 명확히 이해하게 되었다.</p>

<p>재고 시스템의 핵심은 다음 다섯 가지라고 생각한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 현재 재고를 빠르게 알 수 있어야 한다.
2. 재고가 왜 변했는지 추적할 수 있어야 한다.
3. 동시에 판매/예약이 들어와도 초과 판매가 발생하면 안 된다.
4. 고객 앱에는 빠르고 안전한 조회 결과를 제공해야 한다.
5. 장애나 중복 이벤트가 발생해도 원인을 추적하고 보정할 수 있어야 한다.
</code></pre></div></div>

<p>MVP에서는 Inventory를 단일 기준 데이터로 두고, Sale과 Receipt 이력을 통해 판매/입고 흐름을 추적했다.</p>

<p>하지만 대규모 시스템에서는 역할을 더 세분화할 필요가 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>StockMovement
→ 재고 변동의 이유와 이력

InventoryBalance
→ 현재 재고 상태

Reservation
→ 예약 또는 홀드된 재고

CustomerAvailabilityView
→ 고객 앱 조회용 가공 데이터

Cache/Search
→ 빠른 조회를 위한 파생 데이터
</code></pre></div></div>

<p>이 구조를 이해하면, 왜 실제 운영 시스템에서 단순 CRUD보다 데이터 정합성, 이벤트 처리, 캐시 무효화, 장애 복구가 중요한지 더 잘 보인다.</p>

<hr />

<h2 id="12-포트폴리오에-어떻게-정리할-수-있을까">12. 포트폴리오에 어떻게 정리할 수 있을까</h2>

<p>이 고민은 포트폴리오에서도 짧게 정리할 수 있다.</p>

<p>예를 들어 다음과 같이 쓸 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MVP에서는 Inventory를 단일 기준 데이터로 두어 POS/PDA/고객/관리자 기능이 하나의 재고 흐름으로 연결되도록 구현했습니다. 다만 실제 대규모 운영 시스템에서는 판매, 입고, 폐기, 반품, 조정 등 다양한 재고 변동 사유를 추적해야 하므로 StockMovement 원장과 InventoryBalance를 분리하는 구조로 확장할 수 있다고 정리했습니다. 또한 예약 재고, 고객 조회용 AvailabilityView, Redis 캐시 무효화, 동시 판매 제어, idempotency key까지 고려하며 MVP 이후의 확장 방향을 고민했습니다.
</code></pre></div></div>

<p>이 문장은 내가 구현한 프로젝트의 범위를 솔직하게 인정하면서도, 실제 규모에서 고려해야 할 문제를 이해하고 있다는 점을 보여준다.</p>

<p>학습용 프로젝트에서 중요한 것은 “내가 실제 기업 시스템을 그대로 만들었다”가 아니다. 중요한 것은 작은 프로젝트를 통해 업무 흐름을 이해했고, 그 흐름이 커졌을 때 어떤 문제가 생길 수 있는지 고민해봤다는 점이다.</p>

<hr />

<h2 id="13-정리">13. 정리</h2>

<p>이번 글에서는 StoreOps Platform의 MVP Inventory 구조를 출발점으로, 확장형 Inventory 설계를 어떻게 확장할 수 있을지 고민해보았다.</p>

<p>핵심 내용은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. MVP에서는 inventories.quantity를 단일 기준 데이터로 두었다.
2. POS 판매는 재고 감소와 Sale 이력 저장으로 처리했다.
3. PDA 입고는 재고 증가와 Receipt 이력 저장으로 처리했다.
4. 대규모 시스템에서는 재고 변동 사유가 많아지므로 StockMovement 원장이 필요할 수 있다.
5. 현재 재고를 빠르게 조회하기 위해 InventoryBalance를 둘 수 있다.
6. 픽업 예약이나 주문을 고려하면 Reservation과 reserved_quantity가 필요하다.
7. 고객 앱에는 정확한 수량보다 Availability 상태값을 제공하는 것이 더 안전할 수 있다.
8. 고객 조회 트래픽이 많아지면 CustomerAvailabilityView와 Redis 캐시를 활용할 수 있다.
9. 동시 판매를 막기 위해 조건부 UPDATE, 낙관적 락, 비관적 락을 고려할 수 있다.
10. 오프라인 POS나 재시도 상황을 위해 idempotency key가 필요할 수 있다.
11. 실사와 재고 조정을 위해 모든 변동은 원장에 남기는 것이 좋다.
</code></pre></div></div>

<p>결국 대규모 Inventory 설계의 핵심은 단순히 수량을 저장하는 것이 아니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>현재 재고를 빠르게 보여주는 것
재고 변경 이유를 추적하는 것
초과 판매를 막는 것
고객에게 안전한 조회 결과를 제공하는 것
장애나 중복 이벤트에도 복구 가능한 구조를 만드는 것
</code></pre></div></div>

<p>이 모든 것을 함께 고려해야 한다.</p>

<p>내가 만든 MVP는 이 구조의 가장 작은 버전이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Inventory = 현재 재고
Sale = 판매로 인한 재고 감소 이력
Receipt = 입고로 인한 재고 증가 이력
Customer API = 고객 조회
Admin API = 운영 조회
</code></pre></div></div>

<p>이번 프로젝트를 통해 재고 시스템의 기본 흐름을 구현했고, 이번 글을 통해 그 구조가 대규모 시스템에서는 어떻게 확장될 수 있을지 고민해볼 수 있었다.</p>

<p>앞으로 프로젝트를 더 발전시킨다면 가장 먼저 적용해보고 싶은 것은 다음 세 가지다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Sale/Receipt를 StockMovement로 일반화하기
2. Inventory에 @Version을 적용해 낙관적 락 실험하기
3. 고객 재고조회용 CustomerAvailabilityView와 캐시 무효화 전략 설계하기
</code></pre></div></div>

<h2 id="이-세-가지를-추가하면-현재-mvp는-단순-재고-crud를-넘어-실제-운영-시스템의-고민을-더-많이-담은-프로젝트로-발전할-수-있을-것이다">이 세 가지를 추가하면 현재 MVP는 단순 재고 CRUD를 넘어, 실제 운영 시스템의 고민을 더 많이 담은 프로젝트로 발전할 수 있을 것이다.</h2>

<p><strong>시리즈 이어보기</strong><br />
이전 글: <a href="/posts/storeops-inventory-transaction/">3. 재고 정합성을 위한 POS 판매·PDA 입고 트랜잭션 구현</a> | <a href="/storeops/">전체 글 목록</a></p>]]></content><author><name>🌵Lacoruna</name></author><category term="Project" /><category term="StoreOps" /><category term="StoreOps" /><category term="편의점 운영" /><category term="Spring Boot" /><category term="Inventory" /><category term="System Design" /><summary type="html"><![CDATA[MVP 재고 구조의 한계를 바탕으로 StockMovement, InventoryBalance, Reservation, CustomerAvailabilityView, 캐시, 동시성 제어를 고민합니다.]]></summary></entry><entry><title type="html">[StoreOps] 3. 재고 정합성을 위한 POS 판매·PDA 입고 트랜잭션 구현</title><link href="https://la-coruna.github.io/posts/storeops-inventory-transaction/" rel="alternate" type="text/html" title="[StoreOps] 3. 재고 정합성을 위한 POS 판매·PDA 입고 트랜잭션 구현" /><published>2026-07-03T00:00:00+00:00</published><updated>2026-07-03T00:00:00+00:00</updated><id>https://la-coruna.github.io/posts/storeops-inventory-transaction</id><content type="html" xml:base="https://la-coruna.github.io/posts/storeops-inventory-transaction/"><![CDATA[<blockquote>
  <p><strong>StoreOps Platform 시리즈</strong></p>
  <ul>
    <li><a href="/posts/storeops-project-background/">1편: 프로젝트 배경</a></li>
    <li><a href="/posts/storeops-domain-design/">2편: 도메인 설계</a></li>
    <li>3편: 재고 트랜잭션</li>
    <li><a href="/posts/storeops-inventory-expansion/">4편: 대규모 Inventory 확장</a></li>
    <li><a href="/storeops/">전체 글 목록</a></li>
  </ul>
</blockquote>

<h2 id="1-이번-글에서-다룰-내용">1. 이번 글에서 다룰 내용</h2>

<p>이전 글에서는 StoreOps Platform의 도메인 모델을 설계하면서, 이 프로젝트의 중심 도메인을 <strong>Inventory</strong>, 즉 점포별 상품 재고로 잡은 이유를 정리했다.</p>

<p>이번 글에서는 그 다음 단계로, 실제 재고가 변경되는 두 가지 핵심 흐름을 구현하면서 고민한 내용을 정리하려고 한다.</p>

<p>두 가지 핵심 흐름은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매 처리 → 재고 감소
PDA 입고 처리 → 재고 증가
</code></pre></div></div>

<p>처음 보기에는 단순히 숫자를 빼고 더하는 기능처럼 보일 수 있다. 하지만 편의점 운영 시스템에서 재고는 고객 앱, POS, PDA, 관리자 화면, 발주 추천이 모두 의존하는 핵심 데이터다.</p>

<p>따라서 재고 변경 로직을 단순한 수량 변경으로만 보면 안 된다고 판단했다.</p>

<p>이번 구현에서 가장 중요하게 본 질문은 다음과 같았다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>판매 이력은 저장됐는데 재고가 줄지 않으면 어떻게 될까?
재고는 줄었는데 판매 이력이 저장되지 않으면 어떻게 될까?
입고 이력은 저장됐는데 재고가 늘지 않으면 어떻게 될까?
재고가 음수가 되는 상황은 어디에서 막아야 할까?
</code></pre></div></div>

<p>이 질문들을 해결하기 위해 POS 판매와 PDA 입고 처리를 각각 하나의 트랜잭션으로 묶고, 재고 증감 규칙은 Inventory 도메인 객체 내부에 두는 방식으로 구현했다.</p>

<hr />

<h2 id="2-pos-판매와-pda-입고가-중요한-이유">2. POS 판매와 PDA 입고가 중요한 이유</h2>

<p>StoreOps Platform은 개념적으로 네 종류의 클라이언트를 가진다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS
PDA
고객 앱
관리자 WEB
</code></pre></div></div>

<p>이 중 POS와 PDA는 재고 데이터를 직접 변경하는 클라이언트다.</p>

<p>POS는 고객이 상품을 구매했을 때 판매 이벤트를 발생시킨다. 이때 해당 점포의 상품 재고는 줄어야 한다.</p>

<p>PDA는 물류 입고가 발생했을 때 입고 이벤트를 발생시킨다. 이때 해당 점포의 상품 재고는 늘어야 한다.</p>

<p>반면 고객 앱과 관리자 WEB은 재고 데이터를 조회하는 쪽에 가깝다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매 → Inventory 감소
PDA 입고 → Inventory 증가
고객 앱 → Inventory 조회
관리자 WEB → Inventory 조회
</code></pre></div></div>

<p>따라서 POS와 PDA에서 발생한 재고 변경이 정확하지 않으면, 고객 앱과 관리자 화면도 잘못된 정보를 보여주게 된다.</p>

<p>예를 들어 고객 앱에서 재고가 있다고 표시되었지만 실제 점포에는 재고가 없다면 고객은 헛걸음을 하게 된다. 반대로 실제 점포에는 상품이 있는데 앱에는 품절로 표시된다면 판매 기회를 잃게 된다.</p>

<p>이런 이유로 POS 판매와 PDA 입고는 이 프로젝트에서 단순 기능이 아니라, 전체 시스템의 신뢰도를 결정하는 핵심 흐름이라고 판단했다.</p>

<hr />

<h2 id="3-재고-정합성이-깨지는-상황">3. 재고 정합성이 깨지는 상황</h2>

<p>먼저 재고 정합성이 깨질 수 있는 상황을 정리했다.</p>

<h3 id="31-재고는-줄었는데-판매-이력이-저장되지-않는-경우">3.1 재고는 줄었는데 판매 이력이 저장되지 않는 경우</h3>

<p>POS 판매 처리에서 재고 차감은 성공했지만 판매 이력 저장에 실패하는 상황을 생각해볼 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>현재 재고: 10개
판매 요청: 2개
재고 차감 성공: 8개
판매 이력 저장 실패
</code></pre></div></div>

<p>이 경우 시스템에는 재고가 8개로 줄어든 결과만 남고, 왜 재고가 줄었는지 추적할 수 없다.</p>

<p>운영 중 관리자가 “왜 이 상품의 재고가 줄었는가?”를 확인하려고 할 때 판매 이력이 없다면 원인 파악이 어렵다.</p>

<h3 id="32-판매-이력은-저장됐는데-재고가-줄지-않는-경우">3.2 판매 이력은 저장됐는데 재고가 줄지 않는 경우</h3>

<p>반대로 판매 이력은 저장됐지만 재고 차감이 실패하는 경우도 문제가 된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>현재 재고: 10개
판매 요청: 2개
판매 이력 저장 성공
재고 차감 실패
현재 재고: 여전히 10개
</code></pre></div></div>

<p>이 경우 판매 데이터상으로는 상품이 팔렸지만, 재고는 줄지 않는다.</p>

<p>그러면 고객 앱에서는 실제보다 많은 재고가 표시될 수 있고, 관리자 화면에서도 잘못된 재고 현황을 보게 된다.</p>

<h3 id="33-입고-이력은-저장됐는데-재고가-늘지-않는-경우">3.3 입고 이력은 저장됐는데 재고가 늘지 않는 경우</h3>

<p>PDA 입고 처리에서도 비슷한 문제가 발생할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>현재 재고: 8개
입고 요청: 20개
입고 이력 저장 성공
재고 증가 실패
현재 재고: 여전히 8개
</code></pre></div></div>

<p>이 경우 실제로는 입고가 처리되었지만 시스템 재고에는 반영되지 않는다.</p>

<p>관리자 입장에서는 물류가 들어왔는데 재고가 늘지 않은 것처럼 보이고, 고객 앱에서도 재고 없음으로 표시될 수 있다.</p>

<h3 id="34-재고가-음수가-되는-경우">3.4 재고가 음수가 되는 경우</h3>

<p>가장 기본적이지만 중요한 문제는 재고가 음수가 되는 상황이다.</p>

<p>예를 들어 현재 재고가 1개인데 POS에서 2개 판매 요청이 들어오면, 시스템은 이 요청을 거부해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>현재 재고: 1개
판매 요청: 2개
결과: 판매 실패
</code></pre></div></div>

<p>만약 단순히 <code class="language-plaintext highlighter-rouge">현재 재고 - 판매 수량</code>으로 처리하면 재고가 <code class="language-plaintext highlighter-rouge">-1개</code>가 될 수 있다.</p>

<p>실제 운영 시스템에서 재고가 음수로 내려가는 것은 이후 고객 재고조회, 발주 추천, 관리자 재고 현황에 모두 영향을 줄 수 있다.</p>

<p>따라서 재고 음수 방지는 반드시 도메인 규칙으로 관리해야 한다고 판단했다.</p>

<hr />

<h2 id="4-트랜잭션으로-묶어야-하는-이유">4. 트랜잭션으로 묶어야 하는 이유</h2>

<p>위 문제들의 공통점은 “함께 성공해야 하는 작업이 따로 처리되었을 때” 발생한다는 것이다.</p>

<p>POS 판매 처리는 다음 두 작업이 함께 성공해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 재고 차감
2. 판매 이력 저장
</code></pre></div></div>

<p>PDA 입고 처리는 다음 두 작업이 함께 성공해야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 재고 증가
2. 입고 이력 저장
</code></pre></div></div>

<p>둘 중 하나만 성공하면 데이터 불일치가 발생한다.</p>

<p>그래서 POS 판매와 PDA 입고는 각각 하나의 트랜잭션으로 처리해야 한다고 판단했다.</p>

<p>Spring에서는 Service 계층의 메서드에 <code class="language-plaintext highlighter-rouge">@Transactional</code>을 적용해 하나의 작업 단위로 묶을 수 있다. 트랜잭션 안에서 예외가 발생하면, 그 안에서 수행된 DB 변경 작업이 롤백된다.</p>

<p>이 프로젝트에서는 다음 기준으로 트랜잭션 경계를 잡았다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매 API 1회 호출 = 하나의 트랜잭션
PDA 입고 API 1회 호출 = 하나의 트랜잭션
</code></pre></div></div>

<p>즉, 판매 요청이 들어오면 재고 차감과 판매 이력 저장이 함께 성공하거나 함께 실패해야 한다. 입고 요청도 마찬가지로 재고 증가와 입고 이력 저장이 함께 성공하거나 함께 실패해야 한다.</p>

<hr />

<h2 id="5-inventory에-재고-증감-규칙을-둔-이유">5. Inventory에 재고 증감 규칙을 둔 이유</h2>

<p>재고 차감 로직을 구현할 때 또 하나 고민한 부분은 “재고 증감 규칙을 어디에 둘 것인가”였다.</p>

<p>가장 단순한 방식은 Service에서 직접 수량을 수정하는 것이다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">inventory</span><span class="o">.</span><span class="na">setQuantity</span><span class="o">(</span><span class="n">inventory</span><span class="o">.</span><span class="na">getQuantity</span><span class="o">()</span> <span class="o">-</span> <span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">());</span>
</code></pre></div></div>

<p>하지만 이 방식은 좋지 않다고 판단했다.</p>

<p>이유는 재고가 음수가 되면 안 된다는 규칙이 Service마다 흩어질 수 있기 때문이다. POS 판매 Service에서도 검사해야 하고, 나중에 재고 조정 기능이 생기면 또 다른 Service에서도 검사해야 한다.</p>

<p>재고 수량은 Inventory의 상태이고, 재고가 음수가 되면 안 된다는 규칙 역시 Inventory의 핵심 규칙이다.</p>

<p>그래서 재고 증감은 Inventory 도메인 객체 내부의 메서드로 처리하기로 했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Getter</span>
<span class="nd">@Entity</span>
<span class="nd">@NoArgsConstructor</span><span class="o">(</span><span class="n">access</span> <span class="o">=</span> <span class="nc">AccessLevel</span><span class="o">.</span><span class="na">PROTECTED</span><span class="o">)</span>
<span class="nd">@Table</span><span class="o">(</span>
        <span class="n">name</span> <span class="o">=</span> <span class="s">"inventories"</span><span class="o">,</span>
        <span class="n">uniqueConstraints</span> <span class="o">=</span> <span class="o">{</span>
                <span class="nd">@UniqueConstraint</span><span class="o">(</span>
                        <span class="n">name</span> <span class="o">=</span> <span class="s">"uk_inventory_store_product"</span><span class="o">,</span>
                        <span class="n">columnNames</span> <span class="o">=</span> <span class="o">{</span><span class="s">"store_id"</span><span class="o">,</span> <span class="s">"product_id"</span><span class="o">}</span>
                <span class="o">)</span>
        <span class="o">}</span>
<span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">Inventory</span> <span class="o">{</span>

    <span class="nd">@Id</span>
    <span class="nd">@GeneratedValue</span><span class="o">(</span><span class="n">strategy</span> <span class="o">=</span> <span class="nc">GenerationType</span><span class="o">.</span><span class="na">IDENTITY</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">;</span>

    <span class="nd">@ManyToOne</span><span class="o">(</span><span class="n">fetch</span> <span class="o">=</span> <span class="nc">FetchType</span><span class="o">.</span><span class="na">LAZY</span><span class="o">,</span> <span class="n">optional</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="nd">@JoinColumn</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"store_id"</span><span class="o">,</span> <span class="n">nullable</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Store</span> <span class="n">store</span><span class="o">;</span>

    <span class="nd">@ManyToOne</span><span class="o">(</span><span class="n">fetch</span> <span class="o">=</span> <span class="nc">FetchType</span><span class="o">.</span><span class="na">LAZY</span><span class="o">,</span> <span class="n">optional</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="nd">@JoinColumn</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"product_id"</span><span class="o">,</span> <span class="n">nullable</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Product</span> <span class="n">product</span><span class="o">;</span>

    <span class="nd">@Column</span><span class="o">(</span><span class="n">nullable</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Integer</span> <span class="n">quantity</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">Inventory</span><span class="o">(</span><span class="nc">Store</span> <span class="n">store</span><span class="o">,</span> <span class="nc">Product</span> <span class="n">product</span><span class="o">,</span> <span class="nc">Integer</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">validateInitialQuantity</span><span class="o">(</span><span class="n">quantity</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">store</span> <span class="o">=</span> <span class="n">store</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">product</span> <span class="o">=</span> <span class="n">product</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">quantity</span> <span class="o">=</span> <span class="n">quantity</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">decrease</span><span class="o">(</span><span class="kt">int</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">validatePositiveQuantity</span><span class="o">(</span><span class="n">quantity</span><span class="o">);</span>

        <span class="k">if</span> <span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">quantity</span> <span class="o">&lt;</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INSUFFICIENT_STOCK</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="k">this</span><span class="o">.</span><span class="na">quantity</span> <span class="o">-=</span> <span class="n">quantity</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">increase</span><span class="o">(</span><span class="kt">int</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">validatePositiveQuantity</span><span class="o">(</span><span class="n">quantity</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">quantity</span> <span class="o">+=</span> <span class="n">quantity</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">validatePositiveQuantity</span><span class="o">(</span><span class="nc">Integer</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">quantity</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">quantity</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INVALID_QUANTITY</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">validateInitialQuantity</span><span class="o">(</span><span class="nc">Integer</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">quantity</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">quantity</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INVALID_INITIAL_QUANTITY</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이렇게 설계하면 Service는 “판매 흐름”이나 “입고 흐름”을 조율하고, Inventory는 “재고가 어떻게 변경될 수 있는지”를 책임진다.</p>

<p>즉, 역할이 다음과 같이 나뉜다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Service → 유스케이스 흐름 조율
Inventory → 재고 변경 규칙 관리
Repository → 데이터 조회와 저장
</code></pre></div></div>

<p>이 구조가 작은 프로젝트에서도 더 명확하다고 판단했다.</p>

<hr />

<h2 id="6-pos-판매-처리-구현">6. POS 판매 처리 구현</h2>

<p>POS 판매 API의 처리 흐름은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 판매 요청 수신
2. 점포 조회
3. 상품 조회
4. 점포별 상품 재고 조회
5. 재고 차감
6. 판매 이력 저장
7. 남은 재고와 판매 결과 응답
</code></pre></div></div>

<p>Service 코드는 다음과 같은 형태로 구현했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Service</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">PosSaleService</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">InventoryRepository</span> <span class="n">inventoryRepository</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">SaleRepository</span> <span class="n">saleRepository</span><span class="o">;</span>

    <span class="nd">@Transactional</span>
    <span class="kd">public</span> <span class="nc">PosSaleResponse</span> <span class="nf">sell</span><span class="o">(</span><span class="nc">PosSaleRequest</span> <span class="n">request</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Inventory</span> <span class="n">inventory</span> <span class="o">=</span> <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">findByStoreIdAndProductId</span><span class="o">(</span>
                        <span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">(),</span>
                        <span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">()</span>
                <span class="o">)</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">BusinessException</span><span class="o">(</span>
                        <span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INVENTORY_NOT_FOUND</span><span class="o">,</span>
                        <span class="s">"해당 점포에 등록된 상품 재고가 없습니다. storeId="</span>
                                <span class="o">+</span> <span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">()</span>
                                <span class="o">+</span> <span class="s">", productId="</span>
                                <span class="o">+</span> <span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">()</span>
                <span class="o">));</span>

        <span class="n">inventory</span><span class="o">.</span><span class="na">decrease</span><span class="o">(</span><span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">());</span>

        <span class="nc">Sale</span> <span class="n">sale</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Sale</span><span class="o">(</span>
                <span class="n">inventory</span><span class="o">.</span><span class="na">getStore</span><span class="o">(),</span>
                <span class="n">inventory</span><span class="o">.</span><span class="na">getProduct</span><span class="o">(),</span>
                <span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">()</span>
        <span class="o">);</span>
        <span class="nc">Sale</span> <span class="n">savedSale</span> <span class="o">=</span> <span class="n">saleRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">sale</span><span class="o">);</span>

        <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"[POS_SALE] storeId={} productId={} quantity={} remainingStock={}"</span><span class="o">,</span>
                <span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">(),</span>
                <span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">(),</span>
                <span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">(),</span>
                <span class="n">inventory</span><span class="o">.</span><span class="na">getQuantity</span><span class="o">()</span>
        <span class="o">);</span>

        <span class="k">return</span> <span class="nc">PosSaleResponse</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">savedSale</span><span class="o">,</span> <span class="n">inventory</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 중요한 부분은 두 가지다.</p>

<p>첫째, <code class="language-plaintext highlighter-rouge">@Transactional</code>을 적용했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="nc">PosSaleResponse</span> <span class="nf">sell</span><span class="o">(</span><span class="nc">PosSaleRequest</span> <span class="n">request</span><span class="o">)</span> <span class="o">{</span>
    <span class="o">...</span>
<span class="o">}</span>
</code></pre></div></div>

<p>둘째, Service에서 직접 수량을 빼지 않고 <code class="language-plaintext highlighter-rouge">inventory.decrease()</code>를 호출했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">inventory</span><span class="o">.</span><span class="na">decrease</span><span class="o">(</span><span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">());</span>
</code></pre></div></div>

<p>이렇게 하면 판매 수량이 잘못되었거나 재고가 부족한 경우 Inventory 내부에서 예외가 발생하고, 트랜잭션은 롤백된다.</p>

<p>즉, 판매 이력이 저장되기 전에 재고 검증이 실패하면 판매 이력도 저장되지 않는다.</p>

<hr />

<h2 id="7-pda-입고-처리-구현">7. PDA 입고 처리 구현</h2>

<p>PDA 입고 API의 처리 흐름은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 입고 요청 수신
2. 점포 조회
3. 상품 조회
4. 점포별 상품 재고 조회
5. 재고 증가
6. 입고 이력 저장
7. 현재 재고와 입고 결과 응답
</code></pre></div></div>

<p>Service 코드는 다음과 같은 형태로 구현했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Service</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">PdaReceiptService</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">StoreRepository</span> <span class="n">storeRepository</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ProductRepository</span> <span class="n">productRepository</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">InventoryRepository</span> <span class="n">inventoryRepository</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ReceiptRepository</span> <span class="n">receiptRepository</span><span class="o">;</span>

    <span class="nd">@Transactional</span>
    <span class="kd">public</span> <span class="nc">PdaReceiptResponse</span> <span class="nf">receive</span><span class="o">(</span><span class="nc">PdaReceiptRequest</span> <span class="n">request</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Store</span> <span class="n">store</span> <span class="o">=</span> <span class="n">storeRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">())</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">STORE_NOT_FOUND</span><span class="o">));</span>

        <span class="nc">Product</span> <span class="n">product</span> <span class="o">=</span> <span class="n">productRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">())</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">PRODUCT_NOT_FOUND</span><span class="o">));</span>

        <span class="nc">Inventory</span> <span class="n">inventory</span> <span class="o">=</span> <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">findByStoreIdAndProductId</span><span class="o">(</span>
                        <span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">(),</span>
                        <span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">()</span>
                <span class="o">)</span>
                <span class="o">.</span><span class="na">orElseGet</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">Inventory</span><span class="o">(</span><span class="n">store</span><span class="o">,</span> <span class="n">product</span><span class="o">,</span> <span class="mi">0</span><span class="o">));</span>

        <span class="n">inventory</span><span class="o">.</span><span class="na">increase</span><span class="o">(</span><span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">());</span>
        <span class="nc">Inventory</span> <span class="n">savedInventory</span> <span class="o">=</span> <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">inventory</span><span class="o">);</span>

        <span class="nc">Receipt</span> <span class="n">receipt</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Receipt</span><span class="o">(</span>
                <span class="n">store</span><span class="o">,</span>
                <span class="n">product</span><span class="o">,</span>
                <span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">()</span>
        <span class="o">);</span>
        <span class="nc">Receipt</span> <span class="n">savedReceipt</span> <span class="o">=</span> <span class="n">receiptRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">receipt</span><span class="o">);</span>

        <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"[PDA_RECEIPT] storeId={} productId={} quantity={} currentStock={}"</span><span class="o">,</span>
                <span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">(),</span>
                <span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">(),</span>
                <span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">(),</span>
                <span class="n">savedInventory</span><span class="o">.</span><span class="na">getQuantity</span><span class="o">()</span>
        <span class="o">);</span>

        <span class="k">return</span> <span class="nc">PdaReceiptResponse</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">savedReceipt</span><span class="o">,</span> <span class="n">savedInventory</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>PDA 입고에서는 POS 판매와 달리, 해당 점포에 아직 상품 재고가 없을 수도 있다고 보았다.</p>

<p>예를 들어 신규 상품이 처음 입고되는 경우라면 Inventory가 존재하지 않을 수 있다. 이 경우에는 초기 수량 0의 Inventory를 생성한 뒤 입고 수량만큼 증가시키도록 했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Inventory</span> <span class="n">inventory</span> <span class="o">=</span> <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">findByStoreIdAndProductId</span><span class="o">(</span>
                <span class="n">request</span><span class="o">.</span><span class="na">storeId</span><span class="o">(),</span>
                <span class="n">request</span><span class="o">.</span><span class="na">productId</span><span class="o">()</span>
        <span class="o">)</span>
        <span class="o">.</span><span class="na">orElseGet</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">Inventory</span><span class="o">(</span><span class="n">store</span><span class="o">,</span> <span class="n">product</span><span class="o">,</span> <span class="mi">0</span><span class="o">));</span>
</code></pre></div></div>

<p>이후 재고 증가는 Inventory의 <code class="language-plaintext highlighter-rouge">increase()</code> 메서드로 처리했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">inventory</span><span class="o">.</span><span class="na">increase</span><span class="o">(</span><span class="n">request</span><span class="o">.</span><span class="na">quantity</span><span class="o">());</span>
</code></pre></div></div>

<p>입고 처리 역시 <code class="language-plaintext highlighter-rouge">@Transactional</code> 안에서 수행되므로, 입고 이력 저장이나 재고 증가 중 하나가 실패하면 전체 작업이 롤백된다.</p>

<hr />

<h2 id="8-판매-이력과-입고-이력을-남기는-이유">8. 판매 이력과 입고 이력을 남기는 이유</h2>

<p>재고만 정확하면 되는 것처럼 보일 수 있지만, 운영 관점에서는 “재고가 왜 변했는지”도 중요하다.</p>

<p>현재 재고만 저장하면 다음과 같은 질문에 답하기 어렵다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>왜 재고가 30개에서 8개로 줄었는가?
오늘 입고가 정상적으로 반영되었는가?
특정 상품의 판매량은 어느 정도인가?
발주 추천은 어떤 판매 데이터를 기준으로 계산되었는가?
</code></pre></div></div>

<p>그래서 이번 프로젝트에서는 현재 재고는 Inventory에 저장하고, 재고가 변한 이유는 Sale과 Receipt 이력으로 남겼다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Inventory → 현재 재고 상태
Sale → POS 판매로 인한 재고 감소 이력
Receipt → PDA 입고로 인한 재고 증가 이력
</code></pre></div></div>

<p>이 구조는 발주 추천 기능을 구현할 때도 필요하다.</p>

<p>발주 추천은 최근 판매량을 기반으로 계산할 예정이기 때문에, Sale 이력이 있어야 최근 7일 평균 판매량을 구할 수 있다.</p>

<p>즉, 판매 이력은 단순 기록이 아니라 이후 관리자 운영 기능의 기반 데이터가 된다.</p>

<hr />

<h2 id="9-테스트-코드로-검증한-것">9. 테스트 코드로 검증한 것</h2>

<p>재고 변경 로직은 고객 재고조회와 발주 추천의 기반이 되기 때문에 테스트 코드로 검증할 필요가 있다고 판단했다.</p>

<p>이번 단계에서 최소한으로 검증해야 할 테스트는 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. POS 판매 성공 시 재고가 감소한다.
2. 재고보다 많은 수량을 판매하려 하면 예외가 발생한다.
3. 판매 실패 시 재고는 기존 수량으로 유지된다.
4. PDA 입고 성공 시 재고가 증가한다.
5. 잘못된 입고 수량은 예외가 발생한다.
</code></pre></div></div>

<p>예를 들어 POS 판매 성공 테스트는 다음과 같이 작성할 수 있다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">sellDecreasesInventoryQuantityAndSavesSale</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">Store</span> <span class="n">store</span> <span class="o">=</span> <span class="n">storeRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Store</span><span class="o">(</span>
            <span class="s">"서초점"</span><span class="o">,</span>
            <span class="s">"서울 서초구"</span><span class="o">,</span>
            <span class="nc">StoreStatus</span><span class="o">.</span><span class="na">OPEN</span>
    <span class="o">));</span>
    <span class="nc">Product</span> <span class="n">product</span> <span class="o">=</span> <span class="n">productRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Product</span><span class="o">(</span>
            <span class="s">"참치마요 삼각김밥"</span><span class="o">,</span>
            <span class="nc">ProductCategory</span><span class="o">.</span><span class="na">FOOD</span><span class="o">,</span>
            <span class="mi">1200</span><span class="o">,</span>
            <span class="nc">ProductStatus</span><span class="o">.</span><span class="na">ON_SALE</span>
    <span class="o">));</span>
    <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Inventory</span><span class="o">(</span><span class="n">store</span><span class="o">,</span> <span class="n">product</span><span class="o">,</span> <span class="mi">10</span><span class="o">));</span>
    <span class="nc">PosSaleRequest</span> <span class="n">request</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PosSaleRequest</span><span class="o">(</span><span class="n">store</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="n">product</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="mi">2</span><span class="o">);</span>

    <span class="nc">PosSaleResponse</span> <span class="n">response</span> <span class="o">=</span> <span class="n">posSaleService</span><span class="o">.</span><span class="na">sell</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>

    <span class="n">assertThat</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">saleId</span><span class="o">()).</span><span class="na">isNotNull</span><span class="o">();</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">soldQuantity</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">2</span><span class="o">);</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">remainingQuantity</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">8</span><span class="o">);</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">saleRepository</span><span class="o">.</span><span class="na">count</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">1</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>재고 부족 테스트는 다음과 같이 작성할 수 있다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">sellFailsWhenStockIsInsufficient</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">Store</span> <span class="n">store</span> <span class="o">=</span> <span class="n">storeRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Store</span><span class="o">(</span>
            <span class="s">"서초점"</span><span class="o">,</span>
            <span class="s">"서울 서초구"</span><span class="o">,</span>
            <span class="nc">StoreStatus</span><span class="o">.</span><span class="na">OPEN</span>
    <span class="o">));</span>
    <span class="nc">Product</span> <span class="n">product</span> <span class="o">=</span> <span class="n">productRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Product</span><span class="o">(</span>
            <span class="s">"참치마요 삼각김밥"</span><span class="o">,</span>
            <span class="nc">ProductCategory</span><span class="o">.</span><span class="na">FOOD</span><span class="o">,</span>
            <span class="mi">1200</span><span class="o">,</span>
            <span class="nc">ProductStatus</span><span class="o">.</span><span class="na">ON_SALE</span>
    <span class="o">));</span>
    <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Inventory</span><span class="o">(</span><span class="n">store</span><span class="o">,</span> <span class="n">product</span><span class="o">,</span> <span class="mi">1</span><span class="o">));</span>
    <span class="nc">PosSaleRequest</span> <span class="n">request</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PosSaleRequest</span><span class="o">(</span><span class="n">store</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="n">product</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="mi">2</span><span class="o">);</span>

    <span class="n">assertThatThrownBy</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="n">posSaleService</span><span class="o">.</span><span class="na">sell</span><span class="o">(</span><span class="n">request</span><span class="o">))</span>
            <span class="o">.</span><span class="na">isInstanceOf</span><span class="o">(</span><span class="nc">BusinessException</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
            <span class="o">.</span><span class="na">hasMessage</span><span class="o">(</span><span class="s">"재고가 부족합니다."</span><span class="o">);</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">saleRepository</span><span class="o">.</span><span class="na">count</span><span class="o">()).</span><span class="na">isZero</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>입고 성공 테스트는 다음과 같이 작성할 수 있다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">receiveIncreasesExistingInventoryQuantityAndSavesReceipt</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">Store</span> <span class="n">store</span> <span class="o">=</span> <span class="n">storeRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Store</span><span class="o">(</span><span class="s">"서초점"</span><span class="o">,</span> <span class="s">"서울 서초구"</span><span class="o">,</span> <span class="nc">StoreStatus</span><span class="o">.</span><span class="na">OPEN</span><span class="o">));</span>
    <span class="nc">Product</span> <span class="n">product</span> <span class="o">=</span> <span class="n">productRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Product</span><span class="o">(</span>
            <span class="s">"참치마요 삼각김밥"</span><span class="o">,</span>
            <span class="nc">ProductCategory</span><span class="o">.</span><span class="na">FOOD</span><span class="o">,</span>
            <span class="mi">1200</span><span class="o">,</span>
            <span class="nc">ProductStatus</span><span class="o">.</span><span class="na">ON_SALE</span>
    <span class="o">));</span>
    <span class="n">inventoryRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">Inventory</span><span class="o">(</span><span class="n">store</span><span class="o">,</span> <span class="n">product</span><span class="o">,</span> <span class="mi">8</span><span class="o">));</span>
    <span class="nc">PdaReceiptRequest</span> <span class="n">request</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PdaReceiptRequest</span><span class="o">(</span><span class="n">store</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="n">product</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="mi">20</span><span class="o">);</span>

    <span class="nc">PdaReceiptResponse</span> <span class="n">response</span> <span class="o">=</span> <span class="n">pdaReceiptService</span><span class="o">.</span><span class="na">receive</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>

    <span class="n">assertThat</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">receiptId</span><span class="o">()).</span><span class="na">isNotNull</span><span class="o">();</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">receivedQuantity</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">20</span><span class="o">);</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">response</span><span class="o">.</span><span class="na">currentQuantity</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">28</span><span class="o">);</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">receiptRepository</span><span class="o">.</span><span class="na">count</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">1</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이 테스트들은 단순히 코드가 동작하는지 확인하는 수준을 넘어서, 이 프로젝트에서 가장 중요한 도메인 규칙을 검증한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>재고는 음수가 되면 안 된다.
판매되면 재고가 줄어야 한다.
입고되면 재고가 늘어야 한다.
실패한 판매는 재고에 영향을 주면 안 된다.
</code></pre></div></div>

<hr />

<h2 id="10-동시-판매-문제는-어떻게-볼-것인가">10. 동시 판매 문제는 어떻게 볼 것인가</h2>

<p>이번 단계에서는 기본적인 트랜잭션과 재고 음수 방지를 우선 구현했다. 하지만 실제 운영 시스템이라면 동시 판매 문제도 고려해야 한다.</p>

<p>예를 들어 현재 재고가 1개 남았을 때, POS 두 대에서 동시에 같은 상품을 1개씩 판매 요청하는 상황을 생각해볼 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>현재 재고: 1개

요청 A: 1개 판매
요청 B: 1개 판매
</code></pre></div></div>

<p>두 요청이 동시에 현재 재고 1개를 조회하고 모두 판매 가능하다고 판단하면 재고 정합성이 깨질 수 있다.</p>

<p>이 문제를 해결하기 위한 선택지는 여러 가지다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 낙관적 락을 적용한다.
2. 비관적 락을 적용한다.
3. Redis 분산락을 적용한다.
4. 재고 변경 이벤트를 큐로 직렬화한다.
</code></pre></div></div>

<p>이번 MVP에서는 프로젝트 범위와 기간을 고려해 동시성 제어까지 구현 범위에 포함하지는 않았다. 대신 재고 변경 로직을 Inventory 도메인에 모으고, POS 판매와 PDA 입고를 트랜잭션으로 묶는 것을 우선순위로 두었다.</p>

<p>추후 개선한다면 Inventory에 <code class="language-plaintext highlighter-rouge">@Version</code>을 추가해 낙관적 락을 적용하는 방식을 먼저 검토할 수 있다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Version</span>
<span class="kd">private</span> <span class="nc">Long</span> <span class="n">version</span><span class="o">;</span>
</code></pre></div></div>

<p>이 방식은 동시에 같은 Inventory를 수정할 때 버전 충돌을 감지할 수 있어, 과판매 문제를 줄이는 데 도움이 될 수 있다.</p>

<p>이번 프로젝트에서는 동시성 제어를 완성 구현하지는 않았지만, POS 판매가 많은 환경에서는 반드시 고려해야 할 문제임을 설계 기록에 남기기로 했다.</p>

<hr />

<h2 id="11-이번-구현에서-얻은-설계-기준">11. 이번 구현에서 얻은 설계 기준</h2>

<p>POS 판매와 PDA 입고를 구현하면서, 단순히 API를 만드는 것보다 다음 기준이 더 중요하다는 것을 정리할 수 있었다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 재고는 여러 클라이언트가 공유하는 기준 데이터다.
2. 재고 변경과 이력 저장은 함께 성공하거나 함께 실패해야 한다.
3. 재고 음수 방지는 Inventory 도메인 자체의 규칙이다.
4. 판매/입고 이력은 운영 중 원인 추적과 발주 추천의 기반 데이터다.
5. 작은 프로젝트라도 트랜잭션 경계를 명확히 잡으면 운영 시스템에 가까운 설계를 할 수 있다.
</code></pre></div></div>

<p>이 기준을 바탕으로 앞으로 고객 재고조회와 관리자 발주 추천 기능을 구현할 수 있다.</p>

<p>고객 재고조회는 Inventory를 조회하고, 관리자 발주 추천은 Inventory와 Sale 이력을 함께 사용하게 된다.</p>

<p>즉, 이번 단계에서 구현한 POS/PDA 흐름은 이후 기능의 기반이 된다.</p>

<hr />

<h2 id="12-정리">12. 정리</h2>

<p>이번 글에서는 StoreOps Platform에서 POS 판매와 PDA 입고를 구현하면서, 재고 데이터 정합성을 어떻게 지키려고 했는지 정리했다.</p>

<p>핵심은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매 처리
→ 재고 차감 + 판매 이력 저장을 하나의 트랜잭션으로 처리

PDA 입고 처리
→ 재고 증가 + 입고 이력 저장을 하나의 트랜잭션으로 처리

Inventory 도메인
→ decrease(), increase() 메서드로 재고 증감 규칙 관리

테스트 코드
→ 판매 성공, 재고 부족 실패, 입고 성공 검증
</code></pre></div></div>

<p>이번 구현에서 가장 중요하게 생각한 점은, 재고 변경이 단순 숫자 변경이 아니라는 것이다.</p>

<p>편의점 운영 시스템에서 재고는 고객 앱, POS, PDA, 관리자 WEB을 연결하는 핵심 데이터다. 따라서 재고가 잘못 변경되면 고객 경험과 점포 운영 모두에 영향을 줄 수 있다.</p>

<p>그래서 판매와 입고를 구현할 때도 “일단 동작하는 API”가 아니라, 데이터 정합성과 운영 추적 가능성을 우선으로 고려했다.</p>

<h2 id="다음-글에서는-이-재고-데이터를-기반으로-고객이-상품별-점포-재고를-조회하는-기능과-관리자가-결품-위험-및-발주-추천을-확인하는-기능을-구현한-과정을-정리할-예정이다">다음 글에서는 이 재고 데이터를 기반으로 고객이 상품별 점포 재고를 조회하는 기능과, 관리자가 결품 위험 및 발주 추천을 확인하는 기능을 구현한 과정을 정리할 예정이다.</h2>

<p><strong>시리즈 이어보기</strong><br />
이전 글: <a href="/posts/storeops-domain-design/">2. POS/PDA/고객 앱/관리자 WEB을 잇는 도메인 설계</a> | <a href="/storeops/">전체 글 목록</a> | 다음 글: <a href="/posts/storeops-inventory-expansion/">4. MVP 재고 시스템을 확장 가능한 Inventory 구조로 발전시켜보기</a></p>]]></content><author><name>🌵Lacoruna</name></author><category term="Project" /><category term="StoreOps" /><category term="StoreOps" /><category term="편의점 운영" /><category term="Spring Boot" /><category term="Transaction" /><category term="Inventory" /><summary type="html"><![CDATA[POS 판매와 PDA 입고에서 재고 변경과 이력 저장을 하나의 트랜잭션으로 묶고 Inventory 도메인 규칙으로 정합성을 지키는 과정을 정리합니다.]]></summary></entry><entry><title type="html">[StoreOps] 2. POS/PDA/고객 앱/관리자 WEB을 잇는 도메인 설계</title><link href="https://la-coruna.github.io/posts/storeops-domain-design/" rel="alternate" type="text/html" title="[StoreOps] 2. POS/PDA/고객 앱/관리자 WEB을 잇는 도메인 설계" /><published>2026-07-02T00:00:00+00:00</published><updated>2026-07-02T00:00:00+00:00</updated><id>https://la-coruna.github.io/posts/storeops-domain-design</id><content type="html" xml:base="https://la-coruna.github.io/posts/storeops-domain-design/"><![CDATA[<blockquote>
  <p><strong>StoreOps Platform 시리즈</strong></p>
  <ul>
    <li><a href="/posts/storeops-project-background/">1편: 프로젝트 배경</a></li>
    <li>2편: 도메인 설계</li>
    <li><a href="/posts/storeops-inventory-transaction/">3편: 재고 트랜잭션</a></li>
    <li><a href="/posts/storeops-inventory-expansion/">4편: 대규모 Inventory 확장</a></li>
    <li><a href="/storeops/">전체 글 목록</a></li>
  </ul>
</blockquote>

<h2 id="1-설계를-시작하며">1. 설계를 시작하며</h2>

<p>이전 글에서는 편의점 운영 시스템를 분석하고, 그에 맞춰 <strong>StoreOps Platform</strong>이라는 프로젝트를 기획한 이유를 정리했다.</p>

<p>이번 글에서는 본격적인 구현에 들어가기 전, 편의점 운영 흐름을 어떻게 도메인 모델로 바꿨는지 정리하려고 한다.</p>

<p>처음에는 단순히 다음과 같은 API를 만들면 된다고 생각했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>상품 등록 API
점포 등록 API
재고 조회 API
판매 처리 API
입고 처리 API
</code></pre></div></div>

<p>하지만 이렇게 API 목록부터 나열하면 프로젝트가 단순 CRUD에 가까워질 수 있다. 내가 이 프로젝트에서 보여주고 싶은 것은 API 개수 자체가 아니라, <strong>편의점 운영에서 어떤 데이터가 중심이 되고, 여러 시스템이 그 데이터를 어떻게 공유하는지 이해했다는 점</strong>이었다.</p>

<p>그래서 먼저 편의점 운영 흐름을 단순화하고, 그 흐름을 도메인 모델로 옮기는 방식으로 설계를 시작했다.</p>

<p>이번 프로젝트에서 가장 중요하게 본 질문은 다음과 같았다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS에서 상품이 팔리면 어떤 데이터가 바뀌어야 할까?
PDA로 입고가 처리되면 어떤 데이터가 증가해야 할까?
고객이 앱에서 보는 재고는 어디에서 조회되어야 할까?
점주나 관리자는 어떤 데이터를 보고 발주를 판단할까?
</code></pre></div></div>

<p>이 질문들을 따라가다 보니, 결국 프로젝트의 중심에는 <strong>Inventory</strong>, 즉 점포별 상품 재고가 있어야 한다고 판단했다.</p>

<hr />

<h2 id="2-편의점-운영-흐름-단순화하기">2. 편의점 운영 흐름 단순화하기</h2>

<p>실제 편의점 운영은 매우 복잡하다. 상품 발주, 물류센터, 입고, 검수, 진열, 판매, 폐기, 정산, 회계, 프로모션, 멤버십, 모바일 앱, 배달/픽업 등 다양한 업무가 연결되어 있다.</p>

<p>하지만 학습용 미니 프로젝트에서 모든 흐름을 구현할 수는 없다. 따라서 이번 프로젝트에서는 편의점 운영과 가장 직접적으로 연결될 수 있는 흐름만 추렸다.</p>

<p>내가 단순화한 편의점 운영 흐름은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>상품 등록
→ 점포 등록
→ 점포별 재고 등록
→ PDA 입고 처리
→ POS 판매 처리
→ 고객 재고조회
→ 관리자 재고 확인
→ 결품 위험 판단
→ 발주 추천
</code></pre></div></div>

<p>이 흐름을 프로젝트 관점에서 다시 표현하면 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PDA 입고 → Inventory 증가
POS 판매 → Inventory 감소
고객 앱 → Inventory 조회
관리자 WEB → Inventory 조회 + Sale 이력 기반 분석
발주 추천 → Inventory + Sale 이력 기반 계산
</code></pre></div></div>

<p>이 구조에서 중요한 점은 POS, PDA, 고객 앱, 관리자 WEB이 서로 다른 클라이언트지만, 결국 모두 <strong>Inventory</strong>라는 같은 데이터를 중심으로 동작한다는 것이다.</p>

<hr />

<h2 id="3-핵심-도메인-도출하기">3. 핵심 도메인 도출하기</h2>

<p>사용자와 운영 흐름을 정리한 뒤, 핵심 도메인을 도출했다.</p>

<p>이번 프로젝트의 주요 도메인은 다음 여섯 가지다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Store
Product
Inventory
Sale
Receipt
OrderRecommendation
</code></pre></div></div>

<p>각 도메인의 역할은 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>도메인</th>
      <th>의미</th>
      <th>주요 역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Store</td>
      <td>점포</td>
      <td>점포명, 주소, 운영 상태 관리</td>
    </tr>
    <tr>
      <td>Product</td>
      <td>상품</td>
      <td>상품명, 카테고리, 가격, 판매 상태 관리</td>
    </tr>
    <tr>
      <td>Inventory</td>
      <td>점포별 상품 재고</td>
      <td>현재 재고 수량 관리, 재고 증가/감소</td>
    </tr>
    <tr>
      <td>Sale</td>
      <td>POS 판매 이력</td>
      <td>판매 수량, 판매 일시 기록</td>
    </tr>
    <tr>
      <td>Receipt</td>
      <td>PDA 입고 이력</td>
      <td>입고 수량, 입고 일시 기록</td>
    </tr>
    <tr>
      <td>OrderRecommendation</td>
      <td>발주 추천 결과</td>
      <td>현재 재고, 평균 판매량, 추천 발주 수량 기록</td>
    </tr>
  </tbody>
</table>

<p>이 중 가장 중요한 도메인은 <strong>Inventory</strong>다.</p>

<p>Store와 Product는 기본 정보에 가깝다. Sale과 Receipt는 재고가 변화한 이유를 기록하는 이력 데이터다. OrderRecommendation은 Inventory와 Sale 데이터를 바탕으로 계산되는 결과다.</p>

<p>반면 Inventory는 고객 재고조회, POS 판매, PDA 입고, 관리자 조회, 발주 추천이 모두 의존하는 중심 데이터다.</p>

<p>그래서 이번 프로젝트의 도메인 모델은 Inventory를 중심으로 구성했다.</p>

<hr />

<h2 id="4-현재-재고와-이력-데이터를-함께-둔-이유">4. 현재 재고와 이력 데이터를 함께 둔 이유</h2>

<p>재고 시스템을 설계하면서 또 하나 고민한 부분은 “현재 재고만 저장할 것인가, 판매/입고 이력도 함께 저장할 것인가”였다.</p>

<p>선택지는 다음과 같았다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Inventory에 현재 재고만 저장한다.
2. Inventory와 Sale 이력만 저장한다.
3. Inventory, Sale, Receipt 이력을 모두 저장한다.
4. 모든 재고 변동을 StockMovement라는 하나의 이력 테이블로 관리한다.
</code></pre></div></div>

<p>가장 단순한 방식은 1번이다. Inventory에 현재 재고 수량만 저장하면 고객 재고조회와 관리자 재고조회는 구현할 수 있다.</p>

<p>하지만 이 방식은 재고가 왜 변했는지 알기 어렵다.</p>

<p>예를 들어 재고가 30개에서 8개로 줄었다면, 그것이 실제 판매 때문인지, 데이터 오류 때문인지, 폐기 때문인지 추적하기 어렵다.</p>

<p>그래서 이번 프로젝트에서는 3번을 선택했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Inventory → 현재 재고 상태
Sale → POS 판매 이력
Receipt → PDA 입고 이력
</code></pre></div></div>

<p>이렇게 나누면 현재 재고를 빠르게 조회할 수 있고, 동시에 재고가 변한 이유도 추적할 수 있다.</p>

<p>완전한 재고 원장 방식으로 가려면 <code class="language-plaintext highlighter-rouge">StockMovement</code> 같은 통합 이력 모델을 두는 것이 더 적절할 수 있다. 예를 들어 판매, 입고, 폐기, 조정 등 모든 변동을 하나의 테이블에 기록하는 방식이다.</p>

<p>하지만 이번 프로젝트의 MVP 범위에서는 POS 판매와 PDA 입고가 핵심이므로, Sale과 Receipt를 분리해 명확하게 표현하는 방식을 선택했다.</p>

<p>이 결정은 개발운영 관점에서도 의미가 있다. 운영 중 재고가 예상과 다르게 변했을 때, 판매 이력과 입고 이력을 통해 원인을 추적할 수 있기 때문이다.</p>

<hr />

<h2 id="5-도메인-관계-설계">5. 도메인 관계 설계</h2>

<p>이번 프로젝트의 기본 관계는 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Store 1 ─ N Inventory
Product 1 ─ N Inventory

Store 1 ─ N Sale
Product 1 ─ N Sale

Store 1 ─ N Receipt
Product 1 ─ N Receipt

Store 1 ─ N OrderRecommendation
Product 1 ─ N OrderRecommendation
</code></pre></div></div>

<p>하나의 점포는 여러 상품 재고를 가질 수 있고, 하나의 상품은 여러 점포에 존재할 수 있다.</p>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">참치마요 삼각김밥</code>이라는 상품은 <code class="language-plaintext highlighter-rouge">한양대점</code>, <code class="language-plaintext highlighter-rouge">왕십리점</code>, <code class="language-plaintext highlighter-rouge">서울숲점</code>에 모두 존재할 수 있다. 하지만 각 점포의 재고 수량은 다르다.</p>

<p>그래서 Store와 Product 사이의 관계를 Inventory라는 도메인으로 풀었다.</p>

<p>여기서 중요한 규칙이 하나 있다.</p>

<blockquote>
  <p>하나의 점포에는 같은 상품의 재고가 중복으로 존재하면 안 된다.</p>
</blockquote>

<p>즉, <code class="language-plaintext highlighter-rouge">store_id</code>와 <code class="language-plaintext highlighter-rouge">product_id</code>의 조합은 유일해야 한다.</p>

<p>그래서 Inventory 테이블에는 다음과 같은 유니크 제약을 두었다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>unique(store_id, product_id)
</code></pre></div></div>

<p>이 제약을 두면 <code class="language-plaintext highlighter-rouge">한양대점 + 참치마요 삼각김밥</code> 재고가 중복으로 생성되는 문제를 막을 수 있다.</p>

<hr />

<h2 id="6-도메인-규칙-정리">6. 도메인 규칙 정리</h2>

<p>도메인을 설계하면서 함께 정리한 핵심 규칙은 다음과 같다.</p>

<h3 id="61-inventory-규칙">6.1 Inventory 규칙</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>재고 수량은 0 이상이어야 한다.
판매 시 재고는 판매 수량만큼 감소한다.
입고 시 재고는 입고 수량만큼 증가한다.
판매 수량이 현재 재고보다 크면 판매할 수 없다.
같은 점포와 같은 상품의 재고는 중복 생성될 수 없다.
</code></pre></div></div>

<p>이 규칙은 Inventory 도메인 안에서 최대한 관리하려고 했다.</p>

<p>예를 들어 재고 감소는 단순히 Service에서 <code class="language-plaintext highlighter-rouge">quantity</code> 값을 직접 빼는 것이 아니라, <code class="language-plaintext highlighter-rouge">Inventory.decrease()</code> 같은 메서드로 처리하도록 설계했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">void</span> <span class="nf">decrease</span><span class="o">(</span><span class="kt">int</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">validatePositiveQuantity</span><span class="o">(</span><span class="n">quantity</span><span class="o">);</span>

    <span class="k">if</span> <span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">quantity</span> <span class="o">&lt;</span> <span class="n">quantity</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INSUFFICIENT_STOCK</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="k">this</span><span class="o">.</span><span class="na">quantity</span> <span class="o">-=</span> <span class="n">quantity</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이렇게 하면 재고 음수 방지라는 규칙이 여러 Service에 흩어지지 않고 Inventory 안에 모일 수 있다.</p>

<h3 id="62-sale-규칙">6.2 Sale 규칙</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>판매 수량은 1 이상이어야 한다.
판매 처리는 반드시 재고 차감과 함께 이루어져야 한다.
판매 이력은 재고가 줄어든 이유를 추적하기 위해 저장한다.
</code></pre></div></div>

<p>POS 판매 처리에서는 판매 이력 저장과 재고 차감이 하나의 트랜잭션으로 처리되어야 한다. 둘 중 하나만 성공하면 운영 중 재고 정합성이 깨질 수 있기 때문이다.</p>

<h3 id="63-receipt-규칙">6.3 Receipt 규칙</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>입고 수량은 1 이상이어야 한다.
입고 처리는 반드시 재고 증가와 함께 이루어져야 한다.
입고 이력은 재고가 늘어난 이유를 추적하기 위해 저장한다.
</code></pre></div></div>

<p>PDA 입고도 POS 판매와 마찬가지로 트랜잭션 처리가 중요하다.</p>

<p>입고 이력만 저장되고 재고가 증가하지 않거나, 재고만 증가하고 입고 이력이 남지 않으면 운영 중 원인을 추적하기 어려워질 수 있다.</p>

<h3 id="64-orderrecommendation-규칙">6.4 OrderRecommendation 규칙</h3>

<p>발주 추천은 최근 판매량과 현재 재고를 기반으로 계산한다.</p>

<p>MVP에서는 다음과 같은 단순 규칙을 사용했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>최근 7일 평균 판매량 = avgDailySales
목표 재고 = avgDailySales * 3
안전 재고 = avgDailySales * 2
추천 발주량 = max(0, 목표 재고 + 안전 재고 - 현재 재고)
</code></pre></div></div>

<p>처음부터 AI 기반 수요 예측을 적용하지 않은 이유는, 이번 프로젝트의 목표가 예측 모델의 정확도 자체가 아니기 때문이다.</p>

<p>이 프로젝트에서 보여주고 싶은 것은 POS 판매 이력이 현재 재고와 결합되어 발주 의사결정으로 이어지는 운영 흐름이다. 따라서 설명 가능하고 테스트하기 쉬운 규칙 기반 로직을 우선 적용했다.</p>

<hr />

<h2 id="7-대규모-운영-시스템으로-확장한다면">7. 대규모 운영 시스템으로 확장한다면</h2>

<p>이번 프로젝트는 학습용 미니 프로젝트이기 때문에 범위를 작게 잡았다. 하지만 실제 대규모 운영 시스템이라면 추가로 고민해야 할 지점이 많다.</p>

<p>예를 들어 재고 변동을 Sale과 Receipt로만 나누는 방식은 MVP에서는 명확하지만, 실제 운영에서는 폐기, 조정, 반품, 이동, 행사 재고 등 더 다양한 변동 사유가 생길 수 있다.</p>

<p>이 경우에는 <code class="language-plaintext highlighter-rouge">StockMovement</code> 같은 재고 원장 모델을 두고, 모든 재고 변동을 하나의 기준으로 기록하는 방식이 더 적절할 수 있다.</p>

<p>또한 POS 판매가 동시에 많이 발생하는 환경에서는 동시성 제어도 필요하다. 같은 상품의 재고가 1개 남았는데 두 POS에서 동시에 판매 요청이 들어오는 상황을 고려해야 하기 때문이다.</p>

<p>이번 프로젝트에서는 우선순위를 고려해 재고 원장과 동시성 제어까지 완성 구현하지는 않았다. 대신 현재 범위 안에서 재고 정합성, 이력 추적, 도메인 규칙을 명확히 잡는 데 집중했다.</p>

<hr />

<h2 id="8-정리">8. 정리</h2>

<p>이번 글에서는 StoreOps Platform의 도메인 설계 과정을 정리했다.</p>

<p>핵심은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>편의점 운영 흐름을 Inventory 중심으로 단순화했다.
현재 재고는 Inventory에 저장하고, 변화 이유는 Sale과 Receipt 이력으로 남겼다.
Store와 Product의 관계는 Inventory로 연결했다.
재고 음수 방지와 재고 증감 규칙은 Inventory 도메인 안에 두었다.
발주 추천은 Inventory와 Sale 이력을 기반으로 계산하도록 설계했다.
</code></pre></div></div>

<h2 id="다음-글에서는-이-설계를-바탕으로-pos-판매와-pda-입고를-구현하면서-재고-데이터-정합성을-어떻게-지키려고-했는지-정리할-예정이다">다음 글에서는 이 설계를 바탕으로 POS 판매와 PDA 입고를 구현하면서, 재고 데이터 정합성을 어떻게 지키려고 했는지 정리할 예정이다.</h2>

<p><strong>시리즈 이어보기</strong><br />
이전 글: <a href="/posts/storeops-project-background/">1. 편의점 운영 시스템 프로젝트를 시작한 이유</a> | <a href="/storeops/">전체 글 목록</a> | 다음 글: <a href="/posts/storeops-inventory-transaction/">3. 재고 정합성을 위한 POS 판매·PDA 입고 트랜잭션 구현</a></p>]]></content><author><name>🌵Lacoruna</name></author><category term="Project" /><category term="StoreOps" /><category term="StoreOps" /><category term="편의점 운영" /><category term="Spring Boot" /><category term="Domain Modeling" /><category term="Inventory" /><summary type="html"><![CDATA[편의점 운영 흐름을 Inventory 중심의 도메인 모델로 단순화하고 Store, Product, Sale, Receipt 관계를 설계한 과정을 정리합니다.]]></summary></entry><entry><title type="html">[StoreOps] 1. 편의점 운영 시스템 프로젝트를 시작한 이유</title><link href="https://la-coruna.github.io/posts/storeops-project-background/" rel="alternate" type="text/html" title="[StoreOps] 1. 편의점 운영 시스템 프로젝트를 시작한 이유" /><published>2026-07-01T00:00:00+00:00</published><updated>2026-07-01T00:00:00+00:00</updated><id>https://la-coruna.github.io/posts/storeops-project-background</id><content type="html" xml:base="https://la-coruna.github.io/posts/storeops-project-background/"><![CDATA[<blockquote>
  <p><strong>StoreOps Platform 시리즈</strong></p>
  <ul>
    <li>1편: 프로젝트 배경</li>
    <li><a href="/posts/storeops-domain-design/">2편: 도메인 설계</a></li>
    <li><a href="/posts/storeops-inventory-transaction/">3편: 재고 트랜잭션</a></li>
    <li><a href="/posts/storeops-inventory-expansion/">4편: 대규모 Inventory 확장</a></li>
    <li><a href="/storeops/">전체 글 목록</a></li>
  </ul>
</blockquote>

<h2 id="1-왜-이-프로젝트를-시작했는가">1. 왜 이 프로젝트를 시작했는가</h2>

<p>편의점 운영 시스템을 주제로 백엔드 프로젝트를 정리하면서 가장 먼저 든 생각은 하나였다.</p>

<blockquote>
  <p>편의점 운영을 뒷받침하는 IT 시스템은 어떤 흐름으로 연결될까?</p>
</blockquote>

<p>처음에는 편의점 IT라고 하면 고객이 사용하는 앱이나 POS 정도를 먼저 떠올렸다. 하지만 운영 흐름을 정리해보니, 편의점 IT는 생각보다 넓은 범위의 시스템을 다루고 있었다.</p>

<p>편의점 IT 시스템의 역할은 크게 세 가지로 나눠볼 수 있었다.</p>

<p>첫째, 그룹웨어, 물류/회계 시스템, 통합정보, RPA, DBA 등과 관련된 <strong>WEB 사내 시스템 설계 및 운영</strong>이다.<br />
둘째, <strong>고객 모바일 시스템 개발 기획 및 운영</strong>이다.<br />
셋째, <strong>POS, OPC, PDA 등 점포설비 개발 및 운영</strong>이다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>시스템 영역</th>
      <th>내가 이해한 역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>WEB</td>
      <td>사내 시스템 설계 및 운영</td>
      <td>본사, 점주, 운영자가 사용하는 관리 시스템 개발 및 운영</td>
    </tr>
    <tr>
      <td>APP</td>
      <td>고객 모바일 시스템 개발 기획 및 운영</td>
      <td>고객이 사용하는 모바일 서비스와 점포 데이터 연결</td>
    </tr>
    <tr>
      <td>점포 시스템</td>
      <td>POS/OPC/PDA 등 점포설비 개발 및 운영</td>
      <td>실제 매장에서 판매, 입고, 재고 처리에 사용되는 시스템 운영</td>
    </tr>
  </tbody>
</table>

<p>이 관점에서 편의점 운영 시스템은 단순히 하나의 웹 서비스를 만드는 일이 아니라, 편의점 운영 전반에 필요한 여러 시스템을 개발하고 안정적으로 운영하는 역할에 가깝다고 이해했다.</p>

<p>그래서 프로젝트를 정리하면서, 단순한 CRUD 프로젝트보다는 편의점 운영 흐름을 작게라도 반영한 프로젝트를 만들어보고 싶었다.</p>

<p>프로젝트 범위를 현실적으로 제한해야 했기 때문에, 모든 기능을 크게 구현하기보다는 핵심 데이터 흐름을 잘 보여주는 방향으로 범위를 잡았다.</p>

<hr />

<h2 id="2-프로젝트-목표">2. 프로젝트 목표</h2>

<p>이번 프로젝트의 이름은 <strong>StoreOps Platform</strong>으로 정했다.</p>

<p>목표는 편의점 운영에서 발생하는 핵심 데이터 흐름을 직접 구현해보는 것이다. 이를 위해 Spring Boot 기반의 모놀리스 백엔드 서버를 만들고, 개념적으로 다음 네 가지 클라이언트가 같은 데이터를 바라보도록 구성했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>고객 앱
POS 판매 화면
PDA 입고 화면
관리자 대시보드
</code></pre></div></div>

<p>여기서 중요한 것은 클라이언트 화면의 완성도를 높이는 것이 아니었다. 실제로 보여주고 싶었던 것은 다음에 가까웠다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS에서 판매가 발생하면 재고가 줄어든다.
PDA에서 입고가 발생하면 재고가 늘어난다.
고객 앱은 변경된 재고를 조회한다.
관리자 화면은 재고 현황과 판매 이력을 바탕으로 결품 위험과 발주 추천을 확인한다.
</code></pre></div></div>

<p>즉, 여러 화면이 따로 움직이는 것처럼 보이지만 결국 같은 운영 데이터를 중심으로 연결되는 구조를 구현하고 싶었다.</p>

<p>완성도 높은 프론트엔드를 만드는 것보다, 작은 규모 안에서도 실제 운영 시스템의 데이터 흐름을 명확히 보여주는 것이 이번 프로젝트의 목표였다.</p>

<hr />

<h2 id="3-왜-재고-데이터를-중심으로-잡았는가">3. 왜 재고 데이터를 중심으로 잡았는가</h2>

<p>프로젝트 범위를 정하면서 가장 먼저 고민한 것은 “편의점 운영에서 어떤 데이터가 가장 중요할까?”였다.</p>

<p>상품, 점포, 판매, 입고, 발주 등 여러 데이터가 있지만, 이 프로젝트에서는 <strong>재고</strong>를 중심 데이터로 잡았다.</p>

<p>편의점에서 재고는 단순히 “상품이 몇 개 남았는가”를 나타내는 숫자가 아니다. 재고 데이터는 고객 경험과 점포 운영을 동시에 좌우한다.</p>

<p>고객 입장에서는 앱에서 재고가 있다고 보고 점포에 방문했는데 실제로 상품이 없다면 헛걸음을 하게 된다. 반대로 실제 점포에는 상품이 있는데 앱에는 품절로 표시된다면 구매 기회를 잃게 된다.</p>

<p>점주 입장에서도 재고는 중요하다. 재고가 너무 적으면 결품이 발생하고, 재고가 너무 많으면 폐기나 보관 부담이 커질 수 있다. 특히 편의점은 상품 회전이 빠르고, 도시락이나 삼각김밥처럼 판매 기한이 중요한 상품도 많기 때문에 재고 관리의 정확성이 운영 효율에 직접적인 영향을 준다.</p>

<p>그래서 이번 프로젝트에서는 재고 데이터를 중심으로 다음 흐름을 구현하기로 했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POS 판매 → 재고 감소
PDA 입고 → 재고 증가
고객 앱 → 재고 조회
관리자 WEB → 재고 현황 확인
판매 이력 → 결품 위험 판단
재고/판매 데이터 → 발주 추천
</code></pre></div></div>

<p>이 흐름을 작게 구현하면 WEB, APP, 점포 시스템이 어떻게 하나의 운영 데이터로 연결되는지 보여줄 수 있다고 판단했다.</p>

<hr />

<h2 id="4-프로젝트에서-중요하게-본-기술적-관점">4. 프로젝트에서 중요하게 본 기술적 관점</h2>

<p>이 프로젝트는 규모가 큰 시스템은 아니다. 그래서 무리하게 MSA나 복잡한 인프라 구조를 적용하기보다는, 작은 시스템 안에서도 운영 시스템다운 고민을 담는 것이 더 중요하다고 생각했다.</p>

<p>이번 프로젝트에서 중요하게 본 기술적 관점은 세 가지다.</p>

<h3 id="41-재고-데이터-정합성">4.1 재고 데이터 정합성</h3>

<p>POS 판매가 발생하면 재고가 줄어야 하고, PDA 입고가 발생하면 재고가 늘어야 한다. 이 과정에서 재고가 음수가 되거나, 판매 이력과 재고 수량이 서로 맞지 않으면 안 된다.</p>

<p>예를 들어 판매 이력은 저장됐는데 재고가 줄지 않거나, 재고는 줄었는데 판매 이력이 저장되지 않으면 운영 중 원인 추적이 어려워진다.</p>

<p>따라서 판매 처리와 입고 처리는 트랜잭션으로 묶어 데이터 정합성을 지키는 방향으로 구현했다.</p>

<h3 id="42-도메인-규칙-캡슐화">4.2 도메인 규칙 캡슐화</h3>

<p>재고가 음수가 되면 안 된다는 규칙은 특정 API의 부가 로직이 아니라 Inventory 도메인 자체의 규칙이라고 보았다.</p>

<p>그래서 Service에서 단순히 수량을 직접 수정하기보다, Inventory 객체 안에 <code class="language-plaintext highlighter-rouge">decrease()</code>와 <code class="language-plaintext highlighter-rouge">increase()</code> 같은 메서드를 두고 재고 변경 규칙을 관리하도록 설계했다.</p>

<h3 id="43-개발운영-관점">4.3 개발운영 관점</h3>

<p>운영 시스템을 다루는 프로젝트인 만큼, 기능 구현에서 끝내지 않고 운영 관점도 함께 고려하려고 했다.</p>

<p>예를 들어 다음 요소를 포함했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>표준 예외 응답
Swagger API 문서화
Actuator 헬스체크
핵심 이벤트 로그
테스트 코드
정적 클라이언트 화면
</code></pre></div></div>

<p>이를 통해 “로컬에서만 동작하는 코드”가 아니라, 외부에서 API 흐름을 확인하고 장애 상황도 어느 정도 파악할 수 있는 서비스 형태로 완성하고자 했다.</p>

<hr />

<h2 id="5-앞으로-정리할-내용">5. 앞으로 정리할 내용</h2>

<p>이번 글에서는 편의점 운영 시스템를 분석하면서 왜 StoreOps Platform을 만들게 되었는지, 그리고 왜 재고 데이터를 중심으로 잡았는지 정리했다.</p>

<p>다음 글에서는 이 운영 흐름을 실제 도메인 모델로 어떻게 바꿨는지 정리하려고 한다.</p>

<p>특히 다음 내용을 다룰 예정이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>편의점 운영 흐름 단순화
Store, Product, Inventory, Sale, Receipt 도메인 도출
현재 재고와 판매/입고 이력을 함께 저장한 이유
Inventory를 중심으로 한 관계 설계
</code></pre></div></div>

<h2 id="결국-이번-프로젝트에서-보여주고-싶은-것은-편의점-시스템을-완벽하게-구현했다는-것이-아니다-짧은-기간-안에서도-편의점-운영-흐름을-이해하고-그-흐름을-데이터와-도메인-모델로-옮겨보려-했다는-점이다">결국 이번 프로젝트에서 보여주고 싶은 것은 “편의점 시스템을 완벽하게 구현했다”는 것이 아니다. 짧은 기간 안에서도 편의점 운영 흐름을 이해하고, 그 흐름을 데이터와 도메인 모델로 옮겨보려 했다는 점이다.</h2>

<p><strong>시리즈 이어보기</strong><br />
<a href="/storeops/">전체 글 목록</a> | 다음 글: <a href="/posts/storeops-domain-design/">2. POS/PDA/고객 앱/관리자 WEB을 잇는 도메인 설계</a></p>]]></content><author><name>🌵Lacoruna</name></author><category term="Project" /><category term="StoreOps" /><category term="StoreOps" /><category term="편의점 운영" /><category term="Spring Boot" /><category term="Inventory" /><summary type="html"><![CDATA[편의점 운영 흐름을 WEB, APP, 점포 시스템 관점에서 이해하고 StoreOps Platform을 기획한 이유를 정리합니다.]]></summary></entry><entry><title type="html">[BOJ 2467] 용액</title><link href="https://la-coruna.github.io/ps/boj/BOJ-2467/" rel="alternate" type="text/html" title="[BOJ 2467] 용액" /><published>2026-01-20T00:00:00+00:00</published><updated>2026-01-20T00:00:00+00:00</updated><id>https://la-coruna.github.io/ps/boj/BOJ-2467</id><content type="html" xml:base="https://la-coruna.github.io/ps/boj/BOJ-2467/"><![CDATA[<h2 class="no-top-margin" id="link">🔗Link</h2>
<p><a href="https://boj.kr/2467">문제</a><br />
<a href="https://github.com/La-Coruna/PS/blob/main/baekjoon/2467.cpp">풀이</a></p>

<h2 id="idea">💡Idea</h2>
<ul>
  <li>용액을 for문 돌면서, 현재 용액의 값을 -1배 시킨 용액값을 lower_bound로 찾으면 된다.</li>
  <li>lowe_bound로 찾은 위치의 좌우 값을 확인해야 한다.</li>
</ul>

<h2 id="code">🔑Code</h2>
<div class="language-c++ highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;bits/stdc++.h&gt;</span><span class="cp">
</span><span class="k">using</span> <span class="k">namespace</span> <span class="n">std</span><span class="p">;</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">){</span>
    <span class="n">ios_base</span><span class="o">::</span><span class="n">sync_with_stdio</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
    <span class="n">cin</span><span class="p">.</span><span class="n">tie</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>

    <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="n">arr</span><span class="p">[</span><span class="mi">100'000</span><span class="p">];</span>
    <span class="n">cin</span> <span class="o">&gt;&gt;</span> <span class="n">N</span><span class="p">;</span>
    <span class="k">for</span><span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">N</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">){</span>
        <span class="n">cin</span> <span class="o">&gt;&gt;</span> <span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>
    <span class="p">}</span>

    <span class="c1">// 이미 오름차순으로 입력됨.</span>
    <span class="c1">// sort(arr,arr+N);</span>

    <span class="kt">int</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">;</span>
    <span class="kt">int</span> <span class="n">cur_abs_sum</span> <span class="o">=</span> <span class="mh">0x7f7f7f7f</span><span class="p">;</span>
    <span class="k">for</span><span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">N</span><span class="o">-</span><span class="mi">1</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">){</span>
        <span class="kt">int</span> <span class="n">target_value</span> <span class="o">=</span> <span class="o">-</span><span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>
        <span class="k">auto</span> <span class="n">idx</span> <span class="o">=</span> <span class="n">lower_bound</span><span class="p">(</span><span class="n">arr</span><span class="o">+</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="p">,</span> <span class="n">arr</span><span class="o">+</span><span class="n">N</span><span class="p">,</span> <span class="n">target_value</span><span class="p">);</span>
        
        <span class="c1">// 오른쪽 값</span>
        <span class="k">if</span><span class="p">(</span><span class="n">idx</span> <span class="o">&lt;</span> <span class="n">arr</span><span class="o">+</span><span class="n">N</span><span class="p">){</span>
            <span class="kt">int</span> <span class="n">r</span> <span class="o">=</span> <span class="o">*</span><span class="n">idx</span><span class="p">;</span>
            <span class="kt">int</span> <span class="n">new_abs_sum</span> <span class="o">=</span> <span class="n">abs</span><span class="p">(</span><span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">+</span> <span class="n">r</span><span class="p">);</span>
            <span class="k">if</span><span class="p">(</span><span class="n">new_abs_sum</span> <span class="o">&lt;</span> <span class="n">cur_abs_sum</span><span class="p">){</span>
                <span class="n">x</span> <span class="o">=</span> <span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>
                <span class="n">y</span> <span class="o">=</span> <span class="n">r</span><span class="p">;</span>
                <span class="n">cur_abs_sum</span> <span class="o">=</span> <span class="n">new_abs_sum</span><span class="p">;</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="c1">// 왼쪽 값</span>
        <span class="k">if</span><span class="p">(</span><span class="n">idx</span><span class="o">-</span><span class="mi">1</span> <span class="o">&gt;</span> <span class="n">arr</span><span class="o">+</span><span class="n">i</span><span class="p">){</span>
            <span class="kt">int</span> <span class="n">l</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="n">idx</span><span class="o">-</span><span class="mi">1</span><span class="p">);</span>
            <span class="kt">int</span> <span class="n">new_abs_sum</span> <span class="o">=</span> <span class="n">abs</span><span class="p">(</span><span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">+</span> <span class="n">l</span><span class="p">);</span>
            <span class="k">if</span><span class="p">(</span><span class="n">abs</span><span class="p">(</span><span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">+</span> <span class="n">l</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">cur_abs_sum</span><span class="p">){</span>
                <span class="n">x</span> <span class="o">=</span> <span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>
                <span class="n">y</span> <span class="o">=</span> <span class="n">l</span><span class="p">;</span>
                <span class="n">cur_abs_sum</span> <span class="o">=</span> <span class="n">new_abs_sum</span><span class="p">;</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="n">x</span> <span class="o">&lt;&lt;</span> <span class="sc">' '</span> <span class="o">&lt;&lt;</span> <span class="n">y</span><span class="p">;</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<h2 id="️-side-notes">🗨️ Side Notes</h2>]]></content><author><name>🌵Lacoruna</name></author><category term="PS" /><category term="BOJ" /><category term="binary-search" /><summary type="html"><![CDATA[🔗Link 문제 풀이]]></summary></entry><entry><title type="html">[BOJ 16928] 뱀과 사다리 게임</title><link href="https://la-coruna.github.io/ps/boj/BOJ-16928/" rel="alternate" type="text/html" title="[BOJ 16928] 뱀과 사다리 게임" /><published>2026-01-19T00:00:00+00:00</published><updated>2026-01-19T00:00:00+00:00</updated><id>https://la-coruna.github.io/ps/boj/BOJ-16928</id><content type="html" xml:base="https://la-coruna.github.io/ps/boj/BOJ-16928/"><![CDATA[<h2 class="no-top-margin" id="link">🔗Link</h2>
<p><a href="https://boj.kr/16928">문제</a><br />
<a href="https://github.com/La-Coruna/PS/blob/main/baekjoon/16928.cpp">풀이</a></p>

<h2 id="idea">💡Idea</h2>
<ul>
  <li>bfs 사용해서 풀이
  동일한 가중치의 간선에서 최단 거리를 구하는 상황이기 때문에 적절</li>
  <li>dp를 사용하면 안되는 이유:
    <ul>
      <li><code class="language-plaintext highlighter-rouge">dp[i]</code>가 <code class="language-plaintext highlighter-rouge">i</code> 이전의 상태로만 정의가 안됨.</li>
      <li>예를 들면, <code class="language-plaintext highlighter-rouge">i</code>보다 큰 값에서 뱀을 타고 내려오면 <code class="language-plaintext highlighter-rouge">dp[i]</code>는 <code class="language-plaintext highlighter-rouge">i</code>보다 큰 값에 영향을 받게 됨.
        <h2 id="code">🔑Code</h2>
        <p>```c++
#include &lt;bits/stdc++.h&gt;
using namespace std;</p>
      </li>
    </ul>
  </li>
</ul>

<p>#define INF 0x3f3f3f3f</p>

<p>int main(void){
    ios_base::sync_with_stdio(0);
    cin.tie(0);</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>int N, M, vis[101], ladder[101], snake[101];
fill(vis, vis+101, INF);
fill(ladder, ladder+101, -1);
fill(snake, snake+101, -1);

cin &gt;&gt; N &gt;&gt; M;
for(int i = 0; i &lt; N; i++){
    int x, y;
    cin &gt;&gt; x &gt;&gt; y;
    ladder[x] = y;
}
for(int i = 0; i &lt; M; i++){
    int u, v;
    cin &gt;&gt; u &gt;&gt; v;
    snake[u] = v;
}

queue&lt;int&gt; Q;
Q.emplace(1);
vis[1] = 0;

while(!Q.empty()){
    int cur = Q.front(); Q.pop();
    // 정직하게 한 칸
    for(int i = 1; i &lt;= 6; i++){
        int nxt = cur + i;
        int nxt_vis = vis[cur] + 1;
        if(nxt &lt;= 100){
            // 사다리
            if(ladder[nxt] != -1){
                if(nxt_vis &lt; vis[ladder[nxt]]){
                    Q.emplace(ladder[nxt]);
                    vis[ladder[nxt]] = nxt_vis;
                }
            }
            // 뱀
            else if(snake[nxt] != -1){
                if(nxt_vis &lt; vis[snake[nxt]]){
                    Q.emplace(snake[nxt]);
                    vis[snake[nxt]] = nxt_vis;
                }
            }
            // 일반 칸
            else if(nxt_vis &lt; vis[nxt]) {
                Q.emplace(nxt);
                vis[nxt] = nxt_vis;
            }
        }
    }
}

cout &lt;&lt; vis[100];

return 0; } ``` ## 🗨️ Side Notes
</code></pre></div></div>]]></content><author><name>🌵Lacoruna</name></author><category term="PS" /><category term="BOJ" /><category term="bfs" /><summary type="html"><![CDATA[🔗Link 문제 풀이]]></summary></entry><entry><title type="html">[BOJ 1976] 여행 가자</title><link href="https://la-coruna.github.io/ps/boj/BOJ-1976/" rel="alternate" type="text/html" title="[BOJ 1976] 여행 가자" /><published>2026-01-18T00:00:00+00:00</published><updated>2026-01-18T00:00:00+00:00</updated><id>https://la-coruna.github.io/ps/boj/BOJ-1976</id><content type="html" xml:base="https://la-coruna.github.io/ps/boj/BOJ-1976/"><![CDATA[<h2 class="no-top-margin" id="link">🔗Link</h2>
<p><a href="https://boj.kr/1976">문제</a><br />
<a href="https://github.com/La-Coruna/PS/blob/main/baekjoon/1976.cpp">풀이</a></p>

<h2 id="idea">💡Idea</h2>
<p>union find로 여행 가려는 곳이 연결되어 있는지 판단해주면 된다.</p>

<h2 id="code">🔑Code</h2>
<div class="language-c++ highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;bits/stdc++.h&gt;</span><span class="cp">
</span><span class="k">using</span> <span class="k">namespace</span> <span class="n">std</span><span class="p">;</span>

<span class="kt">int</span> <span class="n">p</span><span class="p">[</span><span class="mi">201</span><span class="p">];</span>

<span class="kt">int</span> <span class="nf">find</span><span class="p">(</span><span class="kt">int</span> <span class="n">x</span><span class="p">){</span>
    <span class="k">if</span><span class="p">(</span><span class="n">p</span><span class="p">[</span><span class="n">x</span><span class="p">]</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="n">x</span><span class="p">;</span>
    <span class="k">return</span> <span class="n">p</span><span class="p">[</span><span class="n">x</span><span class="p">]</span> <span class="o">=</span> <span class="n">find</span><span class="p">(</span><span class="n">p</span><span class="p">[</span><span class="n">x</span><span class="p">]);</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="n">uni</span><span class="p">(</span><span class="kt">int</span> <span class="n">u</span><span class="p">,</span> <span class="kt">int</span> <span class="n">v</span><span class="p">){</span>
    <span class="n">u</span> <span class="o">=</span> <span class="n">find</span><span class="p">(</span><span class="n">u</span><span class="p">),</span> <span class="n">v</span> <span class="o">=</span> <span class="n">find</span><span class="p">(</span><span class="n">v</span><span class="p">);</span>

    <span class="k">if</span><span class="p">(</span><span class="n">u</span> <span class="o">==</span> <span class="n">v</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>

    <span class="k">if</span><span class="p">(</span><span class="n">p</span><span class="p">[</span><span class="n">v</span><span class="p">]</span> <span class="o">&lt;</span> <span class="n">p</span><span class="p">[</span><span class="n">u</span><span class="p">])</span> <span class="n">swap</span><span class="p">(</span><span class="n">u</span><span class="p">,</span><span class="n">v</span><span class="p">);</span>
    <span class="k">else</span> <span class="k">if</span><span class="p">(</span><span class="n">p</span><span class="p">[</span><span class="n">v</span><span class="p">]</span> <span class="o">==</span> <span class="n">p</span><span class="p">[</span><span class="n">u</span><span class="p">])</span> <span class="n">p</span><span class="p">[</span><span class="n">u</span><span class="p">]</span><span class="o">--</span><span class="p">;</span>
    <span class="n">p</span><span class="p">[</span><span class="n">v</span><span class="p">]</span> <span class="o">=</span> <span class="n">u</span><span class="p">;</span>

    <span class="k">return</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">bool</span> <span class="n">isUni</span><span class="p">(</span><span class="kt">int</span> <span class="n">u</span><span class="p">,</span> <span class="kt">int</span> <span class="n">v</span><span class="p">){</span>
    <span class="k">return</span> <span class="n">find</span><span class="p">(</span><span class="n">u</span><span class="p">)</span> <span class="o">==</span> <span class="n">find</span><span class="p">(</span><span class="n">v</span><span class="p">);</span>
<span class="p">}</span>

<span class="kt">int</span> <span class="n">main</span><span class="p">(</span><span class="kt">void</span><span class="p">){</span>
    <span class="n">ios_base</span><span class="o">::</span><span class="n">sync_with_stdio</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
    <span class="n">cin</span><span class="p">.</span><span class="n">tie</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>

    <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="n">M</span><span class="p">;</span>
    <span class="n">cin</span> <span class="o">&gt;&gt;</span> <span class="n">N</span> <span class="o">&gt;&gt;</span> <span class="n">M</span><span class="p">;</span>

    <span class="n">fill</span><span class="p">(</span><span class="n">p</span><span class="o">+</span><span class="mi">1</span><span class="p">,</span> <span class="n">p</span><span class="o">+</span><span class="mi">1</span><span class="o">+</span><span class="n">N</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">);</span>

    <span class="k">for</span><span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;=</span> <span class="n">N</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">){</span>
        <span class="k">for</span><span class="p">(</span><span class="kt">int</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span> <span class="n">j</span> <span class="o">&lt;=</span> <span class="n">N</span><span class="p">;</span> <span class="n">j</span><span class="o">++</span><span class="p">){</span>
            <span class="kt">bool</span> <span class="n">isAdj</span><span class="p">;</span>
            <span class="n">cin</span> <span class="o">&gt;&gt;</span> <span class="n">isAdj</span><span class="p">;</span>
            <span class="k">if</span><span class="p">(</span><span class="n">isAdj</span><span class="p">)</span> <span class="n">uni</span><span class="p">(</span><span class="n">i</span><span class="p">,</span><span class="n">j</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kt">bool</span> <span class="n">canGo</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>
    <span class="kt">int</span> <span class="n">cur</span><span class="p">,</span> <span class="n">nxt</span><span class="p">;</span>
    <span class="n">cin</span> <span class="o">&gt;&gt;</span> <span class="n">cur</span><span class="p">;</span>
    <span class="k">while</span><span class="p">(</span><span class="n">cin</span> <span class="o">&gt;&gt;</span> <span class="n">nxt</span><span class="p">){</span>
        <span class="k">if</span><span class="p">(</span><span class="o">!</span><span class="n">isUni</span><span class="p">(</span><span class="n">cur</span><span class="p">,</span><span class="n">nxt</span><span class="p">)){</span>
            <span class="n">canGo</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>
            <span class="k">break</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="n">cur</span> <span class="o">=</span> <span class="n">nxt</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if</span><span class="p">(</span><span class="n">canGo</span><span class="p">)</span> <span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"YES"</span><span class="p">;</span>
    <span class="k">else</span> <span class="n">cout</span> <span class="o">&lt;&lt;</span> <span class="s">"NO"</span><span class="p">;</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<h2 id="️-side-notes">🗨️ Side Notes</h2>]]></content><author><name>🌵Lacoruna</name></author><category term="PS" /><category term="BOJ" /><category term="union-find" /><summary type="html"><![CDATA[🔗Link 문제 풀이]]></summary></entry></feed>