<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>fever-max.log</title>
        <link>https://velog.io/</link>
        <description>선명한 삶을 살기 위하여</description>
        <lastBuildDate>Mon, 31 Aug 2026 02:54:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>fever-max.log</title>
            <url>https://velog.velcdn.com/images/fever-max/profile/c608eee7-bec6-46e1-8f2e-c9682f734cb5/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. fever-max.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/fever-max" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Tomcat 스레드는 200개인데 DB 커넥션은 10개면 어떻게 될까?]]></title>
            <link>https://velog.io/@fever-max/Tomcat-%EC%8A%A4%EB%A0%88%EB%93%9C%EB%8A%94-200%EA%B0%9C%EC%9D%B8%EB%8D%B0-DB-%EC%BB%A4%EB%84%A5%EC%85%98%EC%9D%80-10%EA%B0%9C%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%90%A0%EA%B9%8C</link>
            <guid>https://velog.io/@fever-max/Tomcat-%EC%8A%A4%EB%A0%88%EB%93%9C%EB%8A%94-200%EA%B0%9C%EC%9D%B8%EB%8D%B0-DB-%EC%BB%A4%EB%84%A5%EC%85%98%EC%9D%80-10%EA%B0%9C%EB%A9%B4-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%90%A0%EA%B9%8C</guid>
            <pubDate>Mon, 31 Aug 2026 02:54:17 GMT</pubDate>
            <description><![CDATA[<h1 id="tomcat-스레드는-200개인데-db-커넥션은-10개면-어떻게-될까">Tomcat 스레드는 200개인데 DB 커넥션은 10개면 어떻게 될까?</h1>
<p>Spring Boot 애플리케이션의 설정을 보다 보면 서로 꽤 차이 나는 숫자를 발견할 때가 있다.</p>
<p>예를 들어 Tomcat Thread는 200개인데 HikariCP의 Connection은 10개인 경우다.</p>
<pre><code class="language-text">Tomcat Thread Pool
max = 200

HikariCP
maximumPoolSize = 10</code></pre>
<p>처음에는 조금 이상하게 느껴졌다.</p>
<blockquote>
<p>요청이 동시에 100개 들어오고 모든 요청이 DB를 사용한다면 어떻게 되는 걸까?</p>
</blockquote>
<p>Tomcat에는 요청을 처리할 Thread가 충분하다. 그런데 DB Connection은 10개밖에 없다.</p>
<p>그렇다면 나머지 90개의 Thread는 무엇을 하고 있을까?</p>
<p>Connection이 부족한 거라면 그냥 Thread 수만큼 Connection Pool을 늘리면 되는 것은 아닐까?</p>
<p>이걸 이해하려면 Thread Pool과 Connection Pool을 각각 따로 보기보다 하나의 요청이 서버 안에서 어떻게 처리되는지를 먼저 볼 필요가 있다.</p>
<hr>
<h2 id="요청-하나는-어떻게-처리될까">요청 하나는 어떻게 처리될까?</h2>
<p>Spring Boot에서 Tomcat을 사용하고 있다면 HTTP 요청은 대략 다음과 같은 흐름으로 처리된다.</p>
<pre><code class="language-text">Client
  ↓
Tomcat
  ↓
Thread
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
Database</code></pre>
<p>Tomcat이 요청을 받으면 해당 요청을 처리할 Thread를 할당한다.</p>
<h3 id="tomcat-thread-pool">Tomcat Thread Pool</h3>
<p>Tomcat은 여러 요청을 동시에 처리하기 위해 Thread를 미리 Pool로 관리한다.</p>
<pre><code class="language-text">Tomcat Thread Pool

Thread-1
Thread-2
Thread-3
...
Thread-200</code></pre>
<p>요청이 여러 개 들어오면 각각 사용 가능한 Thread가 요청을 맡는다.</p>
<pre><code class="language-text">Request A → Thread-1
Request B → Thread-2
Request C → Thread-3</code></pre>
<p>할당된 Thread는 Controller만 실행하고 끝나는 것이 아니다.</p>
<pre><code class="language-text">Thread-1

Controller
   ↓
Service
   ↓
Repository
   ↓
Response</code></pre>
<p>하나의 요청을 처리하는 동안 같은 Thread가 애플리케이션 코드를 계속 실행하고, 요청 처리가 끝나면 다시 Pool로 돌아간다.</p>
<p>따라서 Thread가 200개라는 것은 여러 요청을 동시에 처리할 수 있는 실행 자원이 그만큼 존재한다는 의미에 가깝다.</p>
<p>하지만 Thread가 200개 있다고 해서 DB 작업도 200개를 동시에 처리할 수 있다는 의미는 아니다.</p>
<h3 id="db-작업에는-connection이-필요하다">DB 작업에는 Connection이 필요하다</h3>
<p>예를 들어 Repository에서 다음과 같이 데이터를 조회한다고 해보자.</p>
<pre><code class="language-java">public User getUser(Long id) {
    return userRepository.findById(id)
            .orElseThrow();
}</code></pre>
<p>Thread는 Service를 거쳐 Repository까지 코드를 실행할 수 있다.</p>
<p>하지만 실제 SQL을 실행하려면 DB와 연결되어 있어야 한다.</p>
<pre><code class="language-text">Thread
  ↓
Repository
  ↓
Connection
  ↓
Database</code></pre>
<p>이때 필요한 것이 DB Connection이다.</p>
<p>즉, Thread는 요청을 실행하는 자원이고 Connection은 그 요청에서 DB 작업을 수행하기 위해 필요한 또 다른 자원이다.</p>
<hr>
<h2 id="hikaricp-connection-pool">HikariCP Connection Pool</h2>
<p>DB Connection도 요청이 들어올 때마다 새로 만들 수는 있다.</p>
<pre><code class="language-text">요청
 ↓
Connection 생성
 ↓
SQL 실행
 ↓
Connection 종료</code></pre>
<p>하지만 Connection 생성에는 네트워크 연결과 인증 등의 과정이 필요하다.</p>
<p>매번 생성하고 제거하는 것은 비효율적이기 때문에 일정 수의 Connection을 미리 만들어두고 재사용한다.</p>
<pre><code class="language-text">Connection Pool

Connection-1
Connection-2
Connection-3
...
Connection-10</code></pre>
<p>Spring Boot에서 이 Connection Pool을 관리하는 대표적인 구현체가 HikariCP다.</p>
<h3 id="connection을-빌리고-반환하기">Connection을 빌리고 반환하기</h3>
<p>DB 작업이 필요한 Thread는 HikariCP에서 Connection을 빌린다.</p>
<pre><code class="language-text">Thread-1
   ↓
HikariCP
   ↓
Connection-1
   ↓
Database</code></pre>
<p>SQL 실행이 끝나면 Connection을 제거하는 것이 아니라 다시 Pool에 반환한다.</p>
<pre><code class="language-text">Connection 획득
      ↓
   SQL 실행
      ↓
Connection 반환</code></pre>
<p>반환된 Connection은 다른 요청에서 다시 사용할 수 있다.</p>
<pre><code class="language-text">Thread-1
   ↓
Connection-1
   ↓
SQL 실행
   ↓
반환

Thread-2
   ↓
Connection-1 재사용</code></pre>
<p>제한된 수의 Connection을 여러 요청이 빌리고 반환하면서 사용하는 구조다.</p>
<h3 id="thread와-connection의-차이">Thread와 Connection의 차이</h3>
<p>Tomcat Thread Pool과 HikariCP Connection Pool은 둘 다 <code>Pool</code>이라는 이름을 사용하지만 관리하는 대상이 다르다.</p>
<pre><code class="language-text">Tomcat Thread Pool
= 요청을 실행할 Thread 관리

HikariCP Connection Pool
= DB와 연결된 Connection 관리</code></pre>
<p>Thread 하나마다 Connection 하나를 가지고 있는 것도 아니다.</p>
<pre><code class="language-text">Thread-1   ─┐
Thread-2   ─┤
Thread-3   ─┤
...         ├─→ HikariCP → Connection 10개 → DB
Thread-200 ─┘</code></pre>
<p>Thread는 요청 전체를 처리하지만 Connection은 DB 작업이 필요한 순간에 사용된다.</p>
<pre><code class="language-text">Request
   ↓
Thread 획득
   ↓
애플리케이션 로직
   ↓
DB 작업 필요
   ↓
Connection 획득
   ↓
SQL 실행
   ↓
Connection 반환
   ↓
나머지 로직
   ↓
Response</code></pre>
<p>따라서 Thread Pool과 Connection Pool의 크기가 서로 다른 것은 이상한 구조가 아니다.</p>
<hr>
<h2 id="thread-200개-connection-10개라면">Thread 200개, Connection 10개라면?</h2>
<p>이제 처음 궁금했던 상황으로 돌아가 보자.</p>
<pre><code class="language-text">Tomcat Thread Pool = 200
HikariCP Connection Pool = 10</code></pre>
<p>이 상태에서 요청 100개가 동시에 들어왔다고 해보자.</p>
<p>Tomcat에는 Thread가 충분하기 때문에 각각의 요청이 Thread를 할당받을 수 있다.</p>
<pre><code class="language-text">Request 1   → Thread-1
Request 2   → Thread-2
Request 3   → Thread-3
...
Request 100 → Thread-100</code></pre>
<p>문제는 이 100개의 요청이 거의 동시에 DB 작업을 수행하려고 할 때다.</p>
<h3 id="요청-100개가-동시에-db를-사용한다면">요청 100개가 동시에 DB를 사용한다면?</h3>
<p>사용할 수 있는 Connection은 10개뿐이다.</p>
<p>먼저 10개의 Thread가 Connection을 얻는다.</p>
<pre><code class="language-text">Thread-1  → Connection-1
Thread-2  → Connection-2
Thread-3  → Connection-3
...
Thread-10 → Connection-10</code></pre>
<p>이제 Pool에 사용 가능한 Connection이 없다.</p>
<p>그 상태에서 Thread-11이 Connection을 요청한다.</p>
<pre><code class="language-text">Thread-11
   ↓
HikariCP
   ↓
Connection 요청
   ↓
사용 가능한 Connection 없음</code></pre>
<p><code>maximumPoolSize</code>가 10이라면 HikariCP가 Connection을 무한정 새로 생성하지 않는다.</p>
<p>결국 상황은 다음과 같이 된다.</p>
<pre><code class="language-text">Thread 1 ~ 10
   ↓
Connection 사용
   ↓
DB 작업


Thread 11 ~ 100
   ↓
Connection 요청
   ↓
대기</code></pre>
<p>DB 작업을 마친 Thread가 Connection을 반환하면 기다리고 있던 다른 Thread가 그 Connection을 가져간다.</p>
<pre><code class="language-text">Connection 반환
      ↓
대기하던 Thread가 획득
      ↓
SQL 실행
      ↓
Connection 반환</code></pre>
<p>즉, Thread가 있다고 해서 DB 작업까지 모두 동시에 실행되는 것은 아니다.</p>
<h3 id="connection이-부족하면-thread도-기다린다">Connection이 부족하면 Thread도 기다린다</h3>
<p>여기서 중요한 것은 Connection을 기다리는 Thread가 다른 요청을 처리하는 것이 아니라는 점이다.</p>
<pre><code class="language-text">Thread-11
   ↓
Connection 요청
   ↓
사용 가능한 Connection 없음
   ↓
대기</code></pre>
<p>Thread-11은 이미 하나의 요청을 처리하고 있다.</p>
<p>따라서 Connection을 얻을 때까지 해당 요청과 함께 기다린다.</p>
<pre><code class="language-text">Connection 부족
      ↓
Connection 대기
      ↓
대기하는 Thread 증가
      ↓
사용 가능한 Thread 감소</code></pre>
<p>물론 무한정 기다리지는 않는다.</p>
<p>HikariCP에는 Connection을 얻기 위해 기다릴 수 있는 <code>connectionTimeout</code>이 있다.</p>
<pre><code class="language-yaml">spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      connection-timeout: 30000</code></pre>
<p>사용 가능한 Connection이 없다면 반환될 때까지 기다리고, 설정된 시간까지 Connection을 얻지 못하면 Timeout이 발생한다.</p>
<pre><code class="language-text">Connection 요청
     ↓
사용 가능한 Connection 없음
     ↓
대기
     ↓
Connection 반환됨?
  ├─ YES → Connection 획득
  └─ NO  → Timeout</code></pre>
<p>결국 Connection Pool이 부족해지면 DB를 사용하는 요청뿐만 아니라 그 요청을 처리하고 있는 Thread까지 영향을 받는다.</p>
<hr>
<h2 id="db가-느려지면-왜-서버-전체가-느려질까">DB가 느려지면 왜 서버 전체가 느려질까?</h2>
<p>평소 SQL 하나를 처리하는 데 50ms가 걸린다고 해보자.</p>
<pre><code class="language-text">Connection
   ↓
SQL 실행
   ↓
50ms
   ↓
Connection 반환</code></pre>
<p>Connection이 빠르게 Pool로 돌아오기 때문에 기다리고 있는 다른 Thread가 바로 사용할 수 있다.</p>
<p>그런데 특정 문제로 SQL 실행 시간이 5초가 됐다.</p>
<pre><code class="language-text">Connection
   ↓
Slow Query
   ↓
5초
   ↓
Connection 반환</code></pre>
<p>Connection 하나를 점유하는 시간이 크게 증가한다.</p>
<p>10개의 Connection이 모두 느린 Query를 실행하고 있다면 새로운 Thread는 Connection을 얻지 못한다.</p>
<h3 id="connection-pool의-대기">Connection Pool의 대기</h3>
<pre><code class="language-text">Connection-1  → Slow Query
Connection-2  → Slow Query
Connection-3  → Slow Query
...
Connection-10 → Slow Query</code></pre>
<p>새로운 요청에서는 계속 Connection이 필요하다.</p>
<pre><code class="language-text">Thread-11 → 대기
Thread-12 → 대기
Thread-13 → 대기
...</code></pre>
<p>결국 DB에서 시작한 문제가 Connection Pool까지 올라온다.</p>
<pre><code class="language-text">Slow Query
    ↓
Connection 점유 시간 증가
    ↓
Connection 반환 지연
    ↓
Connection Pool 대기 증가</code></pre>
<h3 id="thread-pool까지-이어지는-병목">Thread Pool까지 이어지는 병목</h3>
<p>Connection을 기다리는 동안 Thread도 해당 요청에 묶여 있다.</p>
<p>따라서 대기하는 요청이 계속 증가하면 Tomcat Thread도 점점 사용 중인 상태가 된다.</p>
<pre><code class="language-text">Slow Query
    ↓
Connection 점유
    ↓
Connection Pool 대기
    ↓
Thread 대기
    ↓
사용 가능한 Thread 감소
    ↓
API 응답 지연</code></pre>
<p>사용자 입장에서는 단순히 Query 하나가 느려진 것이 아니라 서버 전체가 느려진 것처럼 보일 수 있다.</p>
<p>처음에는 DB와 Tomcat Thread가 별개의 문제처럼 보였지만 실제 요청 처리 과정에서는 하나의 병목이 다른 영역으로 전파될 수 있다.</p>
<hr>
<h2 id="pool을-크게-만들면-해결될까">Pool을 크게 만들면 해결될까?</h2>
<p>여기까지 보면 자연스럽게 이런 생각이 든다.</p>
<pre><code class="language-text">Tomcat Thread = 200</code></pre>
<p>이라면 Connection도 똑같이 맞추면 되지 않을까?</p>
<pre><code class="language-text">HikariCP Connection = 200</code></pre>
<p>하지만 Connection이 많다고 무조건 좋은 것은 아니다.</p>
<h3 id="connection이-많다고-좋은-것은-아니다">Connection이 많다고 좋은 것은 아니다</h3>
<p>Connection은 결국 Database가 처리해야 하는 작업과 연결된다.</p>
<pre><code class="language-text">Application
     ↓
많은 Connection
     ↓
많은 Query 동시 실행
     ↓
Database</code></pre>
<p>Connection을 많이 만들었다고 해서 DB의 CPU, Memory, Disk I/O 같은 처리 능력까지 증가하는 것은 아니다.</p>
<p>오히려 동시에 실행되는 Query가 지나치게 많아지면 DB의 부하와 경합이 증가할 수 있다.</p>
<pre><code class="language-text">Connection 증가
      ↓
동시 Query 증가
      ↓
DB 부하 증가
      ↓
처리 시간 증가</code></pre>
<p>Thread Pool도 마찬가지다.</p>
<p>Thread를 늘리면 동시에 더 많은 요청을 처리할 기회는 생기지만 Thread 자체도 메모리를 사용하고 Context Switching 등의 비용이 발생한다.</p>
<p>결국 Thread와 Connection 모두 단순히 많을수록 좋은 자원은 아니다.</p>
<h3 id="서버가-여러-대라면">서버가 여러 대라면?</h3>
<p>Connection Pool을 설정할 때 애플리케이션 서버 수도 생각해야 한다.</p>
<p>서버 한 대의 HikariCP가 다음과 같다고 해보자.</p>
<pre><code class="language-text">Server-1
HikariCP = 10</code></pre>
<p>서버가 한 대라면 애플리케이션에서 사용할 수 있는 Connection은 최대 10개다.</p>
<p>그런데 서버를 5대로 Scale-out하면 상황이 달라진다.</p>
<pre><code class="language-text">Server-1 → 10
Server-2 → 10
Server-3 → 10
Server-4 → 10
Server-5 → 10

        ↓

최대 50 Connection</code></pre>
<p>HikariCP의 <code>maximumPoolSize</code>는 모든 서버가 공유하는 하나의 Pool 크기가 아니다.</p>
<p>각 애플리케이션 인스턴스가 자신의 Pool을 가지고 있다.</p>
<p>따라서 실제 DB에 연결될 수 있는 Connection 수를 생각할 때는 다음 구조를 함께 봐야 한다.</p>
<pre><code class="language-text">인스턴스당 Connection Pool
          ×
애플리케이션 인스턴스 수
          ↓
전체 Connection</code></pre>
<p>서버 한 대에서는 괜찮았던 설정이 Scale-out 이후에는 DB에 부담을 줄 수도 있다.</p>
<hr>
<h2 id="결국-요청은-어디에서-기다리고-있을까">결국 요청은 어디에서 기다리고 있을까?</h2>
<p>처음에는 HTTP 요청이 들어오면 Spring이 코드를 실행하고 DB에서 데이터를 가져와 응답한다고 단순하게 생각했다.</p>
<pre><code class="language-text">Request → Spring → DB → Response</code></pre>
<p>하지만 실제로 조금 더 자세히 보면 하나의 요청은 여러 자원을 거친다.</p>
<pre><code class="language-text">Client
  ↓
Tomcat
  ↓
Thread Pool
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
HikariCP
  ↓
Connection Pool
  ↓
Database</code></pre>
<p>그리고 각각의 구간에는 처리할 수 있는 양에 한계가 있다.</p>
<pre><code class="language-text">Tomcat Thread Pool
→ 요청을 실행할 수 있는 Thread 수

HikariCP Connection Pool
→ DB 작업에 사용할 수 있는 Connection 수

Database
→ 실제 Query를 처리할 수 있는 능력</code></pre>
<p>따라서 서버가 느려졌다고 해서 바로 Thread나 Connection을 늘리는 것으로 해결할 수는 없다.</p>
<p>Connection Pool에서 병목이 발생하고 있는데 Thread를 늘린다면 Connection을 기다리는 Thread만 더 많아질 수 있다.</p>
<pre><code class="language-text">Thread 증가
   ↓
Connection 대기 Thread 증가
   ↓
문제 해결 X</code></pre>
<p>DB 자체가 이미 한계인데 Connection을 늘린다면 오히려 더 많은 Query가 동시에 들어가면서 DB 부하를 증가시킬 수도 있다.</p>
<pre><code class="language-text">Connection 증가
   ↓
동시 Query 증가
   ↓
DB 부하 증가
   ↓
오히려 더 느려질 수 있음</code></pre>
<p>결국 설정값을 보기 전에 먼저 확인해야 하는 것이 있다.</p>
<blockquote>
<p>지금 요청은 어디에서 기다리고 있는가?</p>
</blockquote>
<hr>
<h2 id="정리하면서">정리하면서</h2>
<p>처음에는 단순하게 생각했다.</p>
<pre><code class="language-text">Thread 200개
Connection 10개

→ Connection이 부족한 것 아닌가?</code></pre>
<p>하지만 요청의 전체 흐름을 따라가 보니 둘은 서로 다른 역할을 하고 있었다.</p>
<p>Thread는 요청을 실행하는 자원이고 Connection은 그 요청에서 DB 작업이 필요한 순간에 사용하는 자원이다.</p>
<pre><code class="language-text">Request
   ↓
Thread 획득
   ↓
애플리케이션 로직
   ↓
DB 작업 필요
   ↓
Connection 획득
   ↓
SQL 실행
   ↓
Connection 반환
   ↓
나머지 로직
   ↓
Response
   ↓
Thread 반환</code></pre>
<p>그래서 Tomcat Thread가 200개이고 HikariCP Connection이 10개라고 해서 이상한 구조인 것은 아니었다.</p>
<p>오히려 더 중요하게 느낀 것은 서버 성능 문제를 볼 때 하나의 설정값만 보면 안 된다는 점이었다.</p>
<p>DB의 Slow Query에서 시작한 문제가 Connection Pool을 거쳐 Tomcat Thread까지 영향을 줄 수 있다.</p>
<pre><code class="language-text">Slow Query
    ↓
Connection 점유 시간 증가
    ↓
Connection Pool 대기
    ↓
대기하는 Thread 증가
    ↓
Tomcat Thread Pool 압박
    ↓
API 응답 지연</code></pre>
<p>처음 보이는 현상은 단순히 API가 느리다는 것이지만 실제 병목은 Database일 수도 있고, Connection Pool일 수도 있고, Thread Pool일 수도 있다.</p>
<p>결국 서버 성능을 이해한다는 것은 <code>maxThreads</code>나 <code>maximumPoolSize</code> 같은 설정값을 외우는 것보다 하나의 요청이 어떤 자원을 거쳐 처리되는지 이해하는 것에 더 가까웠다.</p>
<p>그리고 문제가 생겼을 때 가장 먼저 봐야 할 것도 조금 명확해졌다.</p>
<blockquote>
<p>얼마나 크게 설정할 것인가보다, 지금 어디에서 기다리고 있는가.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI에게 복잡한 개발 작업을 어떻게 맡겨야 할까?]]></title>
            <link>https://velog.io/@fever-max/AI%EC%97%90%EA%B2%8C-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EA%B0%9C%EB%B0%9C-%EC%9E%91%EC%97%85%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%A1%EA%B2%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@fever-max/AI%EC%97%90%EA%B2%8C-%EB%B3%B5%EC%9E%A1%ED%95%9C-%EA%B0%9C%EB%B0%9C-%EC%9E%91%EC%97%85%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%A7%A1%EA%B2%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Tue, 18 Aug 2026 00:33:10 GMT</pubDate>
            <description><![CDATA[<h1 id="ai에게-복잡한-개발-작업을-어떻게-맡겨야-할까">AI에게 복잡한 개발 작업을 어떻게 맡겨야 할까?</h1>
<p>AI에게 간단한 개발 작업을 맡기는 것은 어렵지 않다. 수정할 위치와 해야 할 일이 명확하다면 한두 줄의 요청만으로도 꽤 정확하게 작업한다.</p>
<pre><code class="language-text">OrderService의 중복 검증 로직 수정해줘.</code></pre>
<p>문제는 기능 단위의 개발을 맡기기 시작하면서 발생했다.</p>
<pre><code class="language-text">회원 탈퇴 기능 만들어줘.</code></pre>
<p>사람이 보기에는 별문제 없는 요구사항처럼 보이지만 실제로 구현하려면 결정해야 할 것이 많다. 회원 데이터를 실제로 삭제할 것인지, 상태값만 변경할 것인지, 탈퇴 일시는 저장할 것인지, 재가입은 허용할 것인지, 기존 게시글과 소셜 계정은 어떻게 처리할 것인지 같은 것들이다.</p>
<p>이런 내용을 알려주지 않으면 AI가 알아서 판단해야 한다. 그리고 AI가 판단해야 하는 부분이 많아질수록 내가 생각했던 결과와 멀어질 가능성도 커진다.</p>
<p>결국 AI에게 복잡한 개발 작업을 맡기려면 두 가지가 필요했다.</p>
<blockquote>
<p>정확한 지시와 그 지시를 유지할 수 있는 기록</p>
</blockquote>
<p>단순히 프롬프트를 잘 작성하는 것만으로는 부족했다.</p>
<hr>
<h2 id="정확한-지시만으로는-부족했다">정확한 지시만으로는 부족했다</h2>
<p>처음에는 해결 방법이 간단하다고 생각했다. AI에게 더 자세하게 설명하면 된다.</p>
<h3 id="요구사항을-자세하게-전달하기">요구사항을 자세하게 전달하기</h3>
<pre><code class="language-text">회원 탈퇴 기능을 구현해줘.

회원 데이터는 삭제하지 않고 상태를 WITHDRAW로 변경한다.
탈퇴 일시를 저장한다.
기존 게시글은 유지한다.
소셜 계정 연결 정보는 비활성화한다.
관리자에서는 탈퇴 회원을 조회할 수 있어야 한다.</code></pre>
<p>처음보다 결과는 확실히 좋아진다. AI가 임의로 판단해야 하는 부분이 줄어들기 때문이다.</p>
<p>하지만 실제 개발에서 요구사항은 한 번에 완성되지 않는다. 개발하다 보면 &quot;탈퇴 사유도 저장해야 할 것 같은데?&quot;, &quot;관리자가 계정을 복구할 수 있어야 하지 않을까?&quot;, &quot;탈퇴 이력은 별도 테이블로 관리하는 게 낫겠다.&quot; 같은 요구사항이 계속 추가된다. 기존에 정했던 내용이 취소되거나 완전히 다른 방향으로 변경되는 경우도 있다.</p>
<pre><code class="language-text">최초 요구사항
    ↓
추가 요구사항
    ↓
설계 변경
    ↓
기존 요구사항 수정
    ↓
추가 구현</code></pre>
<p>이런 과정이 반복되면 최초 요구사항부터 추가 요구사항, 설계 변경까지 모든 결정이 대화 안에 쌓이게 된다.</p>
<p>처음에는 AI가 이전 대화를 참고하면 된다고 생각했다. 하지만 작업이 길어질수록 어떤 내용이 아직 유효한지 확인하기 어려워지고, 새로운 세션에서 작업을 이어가려면 현재까지 결정한 내용을 다시 설명해야 했다.</p>
<p>결국 문제가 하나 보이기 시작했다.</p>
<blockquote>
<p>우리가 무엇을 만들기로 했는지가 대화에만 남아 있다.</p>
</blockquote>
<hr>
<h2 id="결국-문서가-필요했다">결국 문서가 필요했다</h2>
<p>복잡한 개발일수록 요구사항을 정확하게 전달해야 한다. 그리고 그 요구사항은 지금 이 대화에서만 유효한 것이 아니라 다음 작업에서도 다시 사용할 수 있어야 한다.</p>
<p>가장 먼저 생각할 수 있는 방법은 기능별 Markdown 문서를 만드는 것이다.</p>
<pre><code class="language-text">docs/
├── login.md
├── member-withdraw.md
├── social-login.md
└── admin-member.md</code></pre>
<p><code>member-withdraw.md</code>에 현재까지 결정된 요구사항을 정리하고 AI에게 다음처럼 요청할 수 있다.</p>
<pre><code class="language-text">docs/member-withdraw.md를 기준으로 구현해줘.</code></pre>
<p>대화로만 작업할 때보다 훨씬 안정적이다. 세션이 바뀌어도 같은 문서를 다시 읽히면 되고, 사람도 현재까지 결정된 내용을 다시 확인할 수 있다.</p>
<p>그런데 문서가 하나둘 늘어나면서 다른 문제가 생긴다. 어떤 문서는 현재 시스템을 설명하고 있고, 어떤 문서는 앞으로 구현할 기능을 설명하고 있다. 이미 개발이 끝났는데 문서에는 여전히 앞으로 개발해야 할 기능처럼 적혀 있을 수도 있다.</p>
<p>예를 들어 다음 두 문장은 비슷해 보이지만 의미가 전혀 다르다.</p>
<pre><code class="language-text">현재 로그인 실패 제한은 5회다.

로그인 실패 제한을 5회에서 10회로 변경한다.</code></pre>
<p>첫 번째는 현재 시스템의 상태이고 두 번째는 앞으로 적용해야 할 변경사항이다.</p>
<p>단순히 Markdown 파일을 만들어 쌓아두는 것만으로는 이런 상태와 변경 과정을 관리하기 어렵다. 문서가 필요한 것은 맞았지만, 문서를 남기는 것만으로는 부족했다.</p>
<p>필요했던 것은 <strong>현재 시스템의 상태와 앞으로 적용할 변경사항을 구분해서 관리하는 방법</strong>이었다.</p>
<hr>
<h2 id="spec을-기준으로-개발하기">Spec을 기준으로 개발하기</h2>
<p>이런 문제를 해결하는 접근 중 하나가 Spec-Driven Development, SDD다.</p>
<h3 id="spec-driven-development">Spec-Driven Development</h3>
<p>일반적인 AI 개발 흐름을 단순하게 표현하면 다음과 같다.</p>
<pre><code class="language-text">요구사항 → AI → Code</code></pre>
<p>SDD에서는 중간에 Spec이 들어간다.</p>
<pre><code class="language-text">요구사항 → Spec → AI → Code</code></pre>
<p>단순히 문서 하나가 추가된 것처럼 보이지만 중요한 차이가 있다. AI가 지금까지 나눈 대화를 기준으로 구현하는 것이 아니라 명시적으로 정의된 Spec을 기준으로 구현한다는 것이다.</p>
<p>&quot;내가 지금까지 AI에게 뭐라고 했지?&quot;를 찾아가는 대신 &quot;현재 정의된 요구사항이 무엇이지?&quot;를 확인할 수 있게 된다.</p>
<h3 id="spec은-개발이-끝난-뒤-작성하는-문서가-아니다">Spec은 개발이 끝난 뒤 작성하는 문서가 아니다</h3>
<p>여기서 말하는 Spec은 개발이 끝난 뒤 정리하는 문서와는 조금 다르다.</p>
<p>일반적인 문서화가 다음과 같다면,</p>
<pre><code class="language-text">구현 → 문서 작성</code></pre>
<p>Spec-Driven Development에서는 반대 방향으로 사용한다.</p>
<pre><code class="language-text">Spec 작성 → 구현 → 검증</code></pre>
<p>Spec이 구현의 입력값이 되고, 구현이 끝난 뒤에는 결과가 올바른지를 판단하는 기준이 된다.</p>
<p>이런 방식으로 AI 개발을 해볼 방법을 찾다가 알게 된 도구가 OpenSpec이었다.</p>
<hr>
<h2 id="openspec은-무엇일까">OpenSpec은 무엇일까?</h2>
<p>OpenSpec은 AI 코딩 에이전트와 함께 Spec-Driven Development 방식으로 개발할 수 있도록 도와주는 도구다.</p>
<p>처음 보면 여러 Markdown 파일을 만들어주는 도구처럼 보이지만 핵심은 파일 생성 자체가 아니다. 현재 시스템의 상태와 앞으로 적용할 변경사항을 분리하고, AI가 그 정보를 기준으로 작업할 수 있도록 개발 과정을 구조화한다.</p>
<p>OpenSpec을 이해하기 위해서는 먼저 <code>Specs</code>와 <code>Changes</code>의 차이를 알아야 한다.</p>
<h3 id="specs와-changes">Specs와 Changes</h3>
<p>OpenSpec의 구조를 단순하게 보면 다음과 같다.</p>
<pre><code class="language-text">openspec/
├── specs/
└── changes/</code></pre>
<p>둘의 역할은 명확하다.</p>
<pre><code class="language-text">Specs   = 현재 시스템은 어떻게 동작해야 하는가?
Changes = 이번 작업에서 무엇을 변경할 것인가?</code></pre>
<p>예를 들어 현재 인증 시스템이 이메일 로그인을 지원하고 있다고 해보자.</p>
<pre><code class="language-text">openspec/
└── specs/
    └── auth/
        └── spec.md</code></pre>
<p><code>auth/spec.md</code>에는 현재 인증 시스템이 만족해야 하는 요구사항이 정의되어 있다.</p>
<p>여기에 소셜 로그인을 추가한다고 해서 기존 Spec을 바로 수정하지 않는다. 먼저 새로운 Change를 만든다.</p>
<pre><code class="language-text">openspec/
├── specs/
│   └── auth/
│       └── spec.md
│
└── changes/
    └── add-social-login/</code></pre>
<p>현재 인증 시스템 전체를 다시 정의하는 것이 아니라 <code>add-social-login</code>이라는 변경 단위에서 이번 작업을 별도로 관리하는 것이다.</p>
<p>이 구조를 이해하면 OpenSpec이 단순한 요구사항 문서와 무엇이 다른지 조금씩 보이기 시작한다.</p>
<hr>
<h2 id="openspec의-핵심-개념">OpenSpec의 핵심 개념</h2>
<p>OpenSpec을 제대로 사용하려면 <code>Capability</code>, <code>Spec</code>, <code>Change</code>, <code>Delta</code>, <code>Artifact</code>의 관계를 이해하는 것이 좋다. 처음에는 용어가 많아 보이지만 각각의 역할을 나누어 보면 어렵지 않다.</p>
<h3 id="capability">Capability</h3>
<p>Capability는 시스템이 제공하는 기능 단위다.</p>
<p>예를 들어 인증, 회원, 주문, 결제 등을 각각 하나의 Capability로 볼 수 있다.</p>
<pre><code class="language-text">specs/
├── authentication/
│   └── spec.md
├── member/
│   └── spec.md
└── order/
    └── spec.md</code></pre>
<p>Java 패키지나 클래스 구조를 그대로 옮기는 개념이라기보다 시스템이 어떤 기능을 제공하는지를 기준으로 나누는 것이 핵심이다.</p>
<h3 id="spec">Spec</h3>
<p>Spec은 특정 Capability가 현재 어떻게 동작해야 하는지를 정의한다.</p>
<p>예를 들어 <code>authentication</code> Spec에는 다음과 같은 요구사항이 들어갈 수 있다.</p>
<pre><code class="language-text">사용자는 이메일과 비밀번호로 로그인할 수 있다.

로그인에 5회 연속 실패하면 계정이 잠긴다.</code></pre>
<p>중요한 것은 Spec이 과거 작업 기록이 아니라는 점이다.</p>
<blockquote>
<p>현재 시스템이 만족해야 하는 기준</p>
</blockquote>
<p>이라고 생각하면 이해하기 쉽다.</p>
<h3 id="change">Change</h3>
<p>Change는 앞으로 시스템에 적용할 하나의 변경 단위다.</p>
<p>예를 들어 소셜 로그인을 추가한다면 다음과 같은 Change를 만들 수 있다.</p>
<pre><code class="language-text">changes/
└── add-social-login/</code></pre>
<p>현재 인증 시스템 전체를 다시 설명하는 것이 아니라 소셜 로그인 추가라는 이번 작업에 필요한 내용만 별도로 관리한다.</p>
<h3 id="delta">Delta</h3>
<p>그렇다면 기존 Spec에서 무엇이 달라지는지는 어떻게 표현할까? 이때 사용하는 개념이 Delta다.</p>
<p>현재 인증 Spec에 이메일 로그인만 정의되어 있고 이번 Change에서 Google 로그인을 추가한다고 해보자.</p>
<p>전체 인증 Spec을 다시 작성하는 대신 이번 변경으로 추가되는 부분을 Delta로 표현한다.</p>
<pre><code class="language-text">Current Spec
+
Delta
=
변경 후 Spec</code></pre>
<p>Delta를 사용하면 현재 시스템의 기준은 그대로 유지하면서 이번 작업에서 추가되거나 수정되거나 제거되는 부분만 표현할 수 있다.</p>
<h3 id="artifact">Artifact</h3>
<p>Change 안에는 실제 개발에 필요한 여러 산출물이 존재한다. 이런 산출물을 Artifact라고 한다.</p>
<p>대표적으로 변경의 목적과 범위를 정의하는 Proposal, 기술적인 결정을 정리하는 Design, 실제 구현 작업을 나누는 Tasks, 기존 Spec에서 무엇이 달라지는지를 나타내는 Spec Delta 등이 있다.</p>
<p>결국 하나의 Change 안에서 다음 내용이 연결된다.</p>
<pre><code class="language-text">왜 변경하는가
    ↓
무엇을 변경하는가
    ↓
어떻게 구현하는가
    ↓
무엇을 작업하는가
    ↓
기존 시스템에서 무엇이 달라지는가</code></pre>
<p>AI는 이 정보들을 기준으로 실제 구현을 진행한다.</p>
<hr>
<h2 id="openspec은-어떻게-사용할까">OpenSpec은 어떻게 사용할까?</h2>
<p>개념을 이해했다면 실제 프로젝트에서 OpenSpec이 어떻게 AI와 연결되는지도 알아야 한다.</p>
<p>여기서 중요한 점은 <strong>OpenSpec 자체가 코드를 구현하는 AI는 아니라는 것</strong>이다. Claude Code나 Codex 같은 AI 코딩 도구가 실제 프로젝트를 탐색하고 코드를 수정하며, OpenSpec은 그 AI가 따라갈 Spec과 Workflow를 제공한다.</p>
<h3 id="openspec-초기화">OpenSpec 초기화</h3>
<p>프로젝트에서 OpenSpec을 사용하려면 먼저 초기화한다.</p>
<pre><code class="language-bash">openspec init</code></pre>
<p>초기화를 진행하면 OpenSpec을 어떤 AI 코딩 도구와 함께 사용할 것인지 선택할 수 있다.</p>
<pre><code class="language-text">openspec init

↓

사용할 AI 도구 선택

↓

Claude Code
Codex
Cursor
...</code></pre>
<p>여기서 선택한 도구에 맞춰 OpenSpec Workflow를 사용할 수 있도록 필요한 Command나 Skill이 설정된다.</p>
<p>개념적으로 보면 다음과 같다.</p>
<pre><code class="language-text">OpenSpec 초기화
    ↓
사용할 AI 코딩 도구 선택
    ↓
Command / Skill 설정
    ↓
AI 코딩 도구에서 OpenSpec Workflow 실행</code></pre>
<p>따라서 OpenSpec은 Claude Code만을 위한 도구가 아니다. 사용하는 AI 도구에 따라 OpenSpec Workflow를 호출하는 방식이 달라질 수 있다.</p>
<h3 id="openspec-cli와-ai-명령은-다르다">OpenSpec CLI와 AI 명령은 다르다</h3>
<p>처음 OpenSpec을 사용할 때 헷갈렸던 부분 중 하나가 명령어였다.</p>
<p>다음과 같은 명령은 터미널에서 실행하는 OpenSpec CLI다.</p>
<pre><code class="language-bash">openspec init
openspec list
openspec validate</code></pre>
<p>반면 Claude Code를 선택했다면 다음과 같은 OpenSpec Workflow 명령을 Claude Code 안에서 사용할 수 있다.</p>
<pre><code class="language-text">/opsx:explore
/opsx:propose
/opsx:apply
/opsx:archive</code></pre>
<p>둘은 실행하는 위치와 역할이 다르다.</p>
<pre><code class="language-text">터미널
│
├── openspec init
├── openspec list
└── openspec validate


AI 코딩 도구
│
├── Explore
├── Propose
├── Apply
└── Archive</code></pre>
<p>예를 들어 Claude Code에서는 <code>/opsx:*</code> 형태의 Slash Command를 사용할 수 있다. 반면 Codex처럼 Skill을 통해 OpenSpec Workflow를 사용하는 도구도 있기 때문에 명령어 문법 자체보다 <strong>OpenSpec이 선택한 AI 도구에 Workflow를 연결해준다는 구조</strong>를 이해하는 것이 중요하다.</p>
<p>이 글에서는 이후 흐름을 이해하기 쉽도록 Claude Code의 명령을 기준으로 살펴본다.</p>
<hr>
<h2 id="openspec의-기본-workflow">OpenSpec의 기본 Workflow</h2>
<p>OpenSpec을 사용한다고 처음부터 복잡한 과정을 모두 거쳐야 하는 것은 아니다. 가장 기본적인 흐름은 비교적 단순하다.</p>
<pre><code class="language-text">Explore (선택)
    ↓
Propose
    ↓
Apply
    ↓
Archive</code></pre>
<p>각 단계에서 무엇을 하는지 살펴보자.</p>
<h3 id="explore---구현하기-전에-탐색하기">Explore - 구현하기 전에 탐색하기</h3>
<p>요구사항이 아직 명확하지 않다면 바로 코드를 수정하지 않고 먼저 탐색할 수 있다.</p>
<pre><code class="language-text">/opsx:explore</code></pre>
<p>Explore에서는 기존 프로젝트 구조와 관련 기능을 살펴보고 요구사항을 구체화한다. 예를 들어 소셜 로그인을 추가하려고 한다면 현재 인증 구조가 어떻게 되어 있는지, 기존 회원과 소셜 계정을 어떻게 연결할 수 있을지 등을 AI와 함께 살펴볼 수 있다.</p>
<p>중요한 점은 아직 구현 단계가 아니라는 것이다. 말 그대로 문제를 탐색하고 방향을 정하는 단계다.</p>
<p>또한 Explore는 반드시 거쳐야 하는 단계는 아니다. 이미 요구사항과 구현 범위가 명확하다면 바로 Change를 준비할 수 있다.</p>
<h3 id="propose---변경사항-정의하기">Propose - 변경사항 정의하기</h3>
<p>개발할 방향이 정해졌다면 Change를 준비한다.</p>
<pre><code class="language-text">/opsx:propose</code></pre>
<p>Propose에서는 이번 작업을 위한 Change와 필요한 Artifact를 준비한다. 왜 이 기능이 필요한지, 무엇을 변경할 것인지, 기존 시스템의 어떤 Capability가 영향을 받는지, 구현을 위해 어떤 작업이 필요한지 등을 정리한다.</p>
<pre><code class="language-text">요구사항
    ↓
Propose
    ↓
Change + Artifacts
    ↓
개발자 검토</code></pre>
<p>여기서 중요한 점은 바로 구현하지 않는다는 것이다.</p>
<p>AI가 요구사항을 잘못 이해했다면 코드가 만들어진 뒤 수정하는 것이 아니라 이 단계에서 먼저 방향을 바로잡을 수 있다.</p>
<h3 id="apply---정의된-내용을-구현하기">Apply - 정의된 내용을 구현하기</h3>
<p>Change의 내용에 문제가 없다면 실제 구현을 시작한다.</p>
<pre><code class="language-text">/opsx:apply</code></pre>
<p>이제 AI는 마지막 프롬프트 하나만 보고 코드를 작성하는 것이 아니라 Change에 정의된 요구사항과 설계, 작업 내용을 기준으로 프로젝트를 수정한다.</p>
<pre><code class="language-text">Change
    ↓
Artifacts
    ↓
Apply
    ↓
Code</code></pre>
<p>내가 OpenSpec을 사용하면서 가장 중요하다고 느낀 부분도 여기에 있다.</p>
<p>프롬프트 기반으로 작업할 때는 대화가 구현의 기준이었다면 OpenSpec에서는 현재 Change가 구현의 기준이 된다.</p>
<h3 id="archive---완료된-변경-정리하기">Archive - 완료된 변경 정리하기</h3>
<p>구현이 완료되고 변경사항에 문제가 없다면 Change를 완료된 작업으로 정리한다.</p>
<pre><code class="language-text">/opsx:archive</code></pre>
<p>작업이 완료되면 이번 변경사항은 더 이상 앞으로 구현해야 할 요구사항이 아니다. 현재 시스템의 일부가 된다.</p>
<p>OpenSpec은 완료된 Change를 정리하면서 변경된 요구사항을 현재 시스템의 Spec과 연결하고, Change 자체는 완료된 변경 기록으로 남길 수 있게 한다.</p>
<p>결국 기본 Workflow를 명령어가 아니라 개발 과정으로 표현하면 다음과 같다.</p>
<pre><code class="language-text">문제를 먼저 이해하고
    ↓
무엇을 변경할지 정의하고
    ↓
정의된 내용을 기준으로 구현하고
    ↓
완료된 변경을 정리한다</code></pre>
<hr>
<h2 id="더-세밀하게-작업하고-싶다면">더 세밀하게 작업하고 싶다면</h2>
<p>기본 Workflow만으로도 OpenSpec을 사용할 수 있지만, 모든 작업을 <code>Propose → Apply → Archive</code>로 처리해야 하는 것은 아니다.</p>
<p>조금 더 세밀하게 Change를 관리하고 싶다면 Expanded Workflow를 사용할 수 있다.</p>
<p>예를 들어 Change를 먼저 만들고,</p>
<pre><code class="language-text">/opsx:new</code></pre>
<p>Artifact를 하나씩 생성하면서 내용을 검토하거나,</p>
<pre><code class="language-text">/opsx:continue</code></pre>
<p>필요한 Artifact를 빠르게 준비할 수도 있다.</p>
<pre><code class="language-text">/opsx:ff</code></pre>
<p>구현 이후에는 실제 코드가 Spec과 일치하는지 확인할 수도 있다.</p>
<pre><code class="language-text">/opsx:verify</code></pre>
<p>따라서 OpenSpec은 하나의 고정된 개발 절차를 강제한다기보다 작업의 크기와 상황에 따라 Workflow를 선택할 수 있도록 구성되어 있다.</p>
<p>간단한 작업이라면 기본 흐름을 사용할 수 있다.</p>
<pre><code class="language-text">Propose → Apply → Archive</code></pre>
<p>반대로 요구사항을 하나씩 검토하면서 진행해야 하는 작업이라면 좀 더 세밀하게 접근할 수 있다.</p>
<pre><code class="language-text">New → Continue → Apply → Verify → Archive</code></pre>
<p>결국 중요한 것은 명령을 많이 사용하는 것이 아니라 <strong>현재 작업에 필요한 수준만큼 Spec을 만들고 검토하는 것</strong>이다.</p>
<hr>
<h2 id="validate와-verify는-무엇이-다를까"><code>validate</code>와 <code>verify</code>는 무엇이 다를까?</h2>
<p>OpenSpec을 보다 보면 비슷해 보이는 두 가지 검증 방법이 나온다.</p>
<pre><code class="language-bash">openspec validate</code></pre>
<p>그리고 Expanded Workflow에서 사용할 수 있는</p>
<pre><code class="language-text">/opsx:verify</code></pre>
<p>이다.</p>
<p>둘은 확인하는 대상이 다르다.</p>
<p><code>validate</code>는 OpenSpec의 Change나 Spec 자체가 올바르게 작성되었는지를 검사한다. 즉, 필요한 구조와 요구사항 형식이 제대로 작성되어 있는지를 확인하는 쪽에 가깝다.</p>
<p>반면 <code>verify</code>는 실제 구현 결과를 확인하는 단계다. Change에서 정의했던 요구사항과 Tasks를 기준으로 실제 코드가 의도한 대로 구현되었는지를 AI가 확인한다.</p>
<p>간단하게 구분하면 다음과 같다.</p>
<pre><code class="language-text">validate
Spec이 올바르게 작성되었는가?

verify
코드가 Spec대로 구현되었는가?</code></pre>
<p>문서를 검사하는 것과 구현 결과를 검사하는 것은 서로 다른 문제이기 때문에 둘의 역할도 나뉜다.</p>
<hr>
<h2 id="결국-달라지는-것은-개발의-기준이다">결국 달라지는 것은 개발의 기준이다</h2>
<p>OpenSpec을 사용한다고 AI의 코딩 능력이 갑자기 좋아지는 것은 아니다. 같은 Claude Code나 Codex를 사용하고 같은 모델이 코드를 작성한다.</p>
<p>달라지는 것은 <strong>AI가 무엇을 기준으로 개발하는가</strong>다.</p>
<p>프롬프트만으로 개발하면 작업의 기준이 대화에 존재한다.</p>
<pre><code class="language-text">대화 → AI → Code</code></pre>
<p>작업이 길어질수록 대화도 길어지고 요구사항도 여러 곳에 흩어진다. 어떤 요구사항이 아직 유효한지, 어떤 결정이 취소됐는지를 다시 해석해야 한다.</p>
<p>Spec을 중심으로 개발하면 그 기준을 프로젝트 안으로 옮길 수 있다.</p>
<pre><code class="language-text">대화
  ↓
Change / Spec
  ↓
AI
  ↓
Code</code></pre>
<p>그리고 구현이 끝나면 이번 변경은 현재 시스템의 새로운 기준이 된다.</p>
<pre><code class="language-text">요구사항
    ↓
Change
    ↓
구현
    ↓
현재 시스템의 Spec</code></pre>
<p>AI에게 일을 잘 시키려면 정확한 지시가 필요하다. 하지만 복잡한 개발에서는 그것만으로 부족했다.</p>
<p>오늘 결정한 내용을 내일도 사용할 수 있어야 하고, 요구사항이 변경되면 무엇이 바뀌었는지 알 수 있어야 한다. 새로운 세션에서도 같은 기준으로 작업할 수 있어야 하고, 구현이 끝난 뒤에는 처음 정의했던 내용과 실제 결과를 비교할 수도 있어야 한다.</p>
<p>결국 AI에게 복잡한 개발 작업을 맡기면서 필요했던 것은 더 길고 정교한 프롬프트가 아니었다.</p>
<p><strong>무엇을 만들 것인지 정확하게 정의하고, 그 정의를 기록으로 남기고, 구현이 끝날 때까지 같은 기준으로 관리하는 것.</strong></p>
<p>OpenSpec은 그 과정을 AI 개발 Workflow 안으로 가져오는 방법 중 하나다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI를 챗봇처럼 사용하면 안 되는 이유]]></title>
            <link>https://velog.io/@fever-max/AI%EB%A5%BC-%EC%B1%97%EB%B4%87%EC%B2%98%EB%9F%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@fever-max/AI%EB%A5%BC-%EC%B1%97%EB%B4%87%EC%B2%98%EB%9F%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%A9%B4-%EC%95%88-%EB%90%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Mon, 13 Jul 2026 01:31:40 GMT</pubDate>
            <description><![CDATA[<p>요즘 AI는 개발자의 필수 도구가 되었다.</p>
<p>새로운 기능을 구현하거나, 버그를 수정하거나, 프로젝트를 분석할 때도 AI를 먼저 실행하는 개발자가 많다.</p>
<p>나도 처음에는 ChatGPT를 사용하던 습관 그대로 AI를 사용했다.</p>
<p>&quot;API 수정해줘.&quot;
&quot;로그인 버그 고쳐줘.&quot;
&quot;리팩토링해줘.&quot;</p>
<p>가끔은 원하는 결과를 얻었지만, 대부분은 엉뚱한 파일을 수정하거나, 이미 존재하는 기능을 새로 만들거나, 전혀 다른 방향의 코드를 제안했다.</p>
<p>처음에는 AI가 아직 부족해서 그런 줄 알았다.
하지만 여러 AI 코딩 도구를 사용하면서 생각이 조금 바뀌었다.</p>
<blockquote>
<p>문제는 AI가 아니라 내가 AI를 사용하는 방식이었다.</p>
</blockquote>
<hr>
<h1 id="ai는-답변하는-모델이-아니다">AI는 답변하는 모델이 아니다.</h1>
<p>많은 개발자는 아직도 AI를 ChatGPT처럼 사용한다.</p>
<pre><code class="language-text">질문

↓

답변</code></pre>
<p>하지만 최근의 AI 코딩 도구는 조금 다르게 동작한다.</p>
<pre><code class="language-text">작업 요청

↓

계획 수립

↓

프로젝트 탐색

↓

파일 읽기

↓

코드 수정

↓

빌드 · 테스트

↓

필요하면 다시 반복</code></pre>
<p>여기서 중요한 점은 <strong>답변이 아니라 작업(Task)</strong> 이라는 것이다.</p>
<p>Anthropic은 Claude Code를 단순한 ChatBot이 아니라 <strong>Agentic Harness</strong>라고 표현한다.</p>
<p>Harness는 여러 도구를 하나로 연결해 실제 작업을 수행하는 실행 환경을 의미한다.</p>
<p>즉, AI는 단순히 답을 생성하는 것이 아니라</p>
<ul>
<li>파일 시스템을 탐색하고</li>
<li>필요한 파일을 읽고</li>
<li>터미널을 실행하고</li>
<li>Git 변경 사항을 확인하고</li>
<li>테스트를 수행하며</li>
</ul>
<p>필요한 작업을 반복하면서 목표를 해결한다.</p>
<p>이 순간부터 AI는 ChatBot이 아니라 하나의 개발 에이전트가 된다.</p>
<hr>
<h1 id="ai는-프로젝트를-어떻게-탐색할까">AI는 프로젝트를 어떻게 탐색할까?</h1>
<p>많은 사람들이 AI는 프로젝트 전체를 읽고 있다고 생각한다.
하지만 현실적으로 수천 개의 파일을 모두 읽고 기억하는 것은 불가능하다.</p>
<p>그래서 AI는 먼저 프로젝트를 탐색한다.</p>
<p>예를 들어</p>
<blockquote>
<p>로그인 API 수정해줘.</p>
</blockquote>
<p>라는 요청을 받으면 AI는 바로 코드를 생성하지 않는다.</p>
<p>먼저</p>
<ul>
<li>로그인과 관련된 파일을 찾고</li>
<li>관련 클래스와 메서드를 찾고</li>
<li>참조 관계를 따라가며</li>
<li>필요한 파일만 Context에 포함시킨다.</li>
</ul>
<p>간단히 표현하면 다음과 같은 과정이다.</p>
<pre><code class="language-text">작업 요청

↓

계획

↓

검색(Search)

↓

파일 읽기(Read)

↓

추론(Reason)

↓

수정(Edit)</code></pre>
<p>여기서 중요한 것은 <strong>생성보다 탐색이 먼저</strong>라는 점이다.</p>
<p>AI는 생각보다 많은 시간을 코드를 쓰는 것이 아니라 코드를 찾는 데 사용한다.</p>
<hr>
<h1 id="api-수정해줘가-좋은-요청이-아닌-이유">&quot;API 수정해줘&quot;가 좋은 요청이 아닌 이유</h1>
<p>이제 다시 처음 질문으로 돌아가 보자.</p>
<blockquote>
<p>API 수정해줘.</p>
</blockquote>
<p>사람에게는 자연스러운 요청이다.</p>
<p>하지만 AI에게는 너무 많은 경우의 수가 존재한다.</p>
<p>API는 무엇을 의미하는가?</p>
<ul>
<li>사용자 API인가?</li>
<li>관리자 API인가?</li>
<li>REST API인가?</li>
<li>OAuth 관련 API인가?</li>
</ul>
<p>프로젝트마다 구조도 모두 다르다.</p>
<p>결국 AI는 어떤 API인지 알기 위해 프로젝트를 계속 탐색해야 한다.</p>
<p>탐색 범위가 넓어질수록</p>
<ul>
<li>더 많은 파일을 읽고</li>
<li>더 많은 Context를 사용하고</li>
<li>불필요한 정보까지 함께 가져오게 된다.</li>
</ul>
<p>반대로</p>
<pre><code class="language-text">OrderController#createOrder()

재고 검증 로직 수정

관련 Service까지 함께 수정</code></pre>
<p>처럼 작업 범위를 명확하게 알려주면 AI는 훨씬 적은 탐색만으로 원하는 위치를 찾을 수 있다.</p>
<p>좋은 요청은 AI를 더 똑똑하게 만드는 것이 아니다.
<strong>AI가 해야 할 탐색을 줄여주는 것</strong>이다.</p>
<hr>
<h1 id="왜-ai는-점점-엉뚱한-답을-하기-시작할까">왜 AI는 점점 엉뚱한 답을 하기 시작할까?</h1>
<p>AI를 오래 사용하다 보면 이런 경험을 하게 된다.
처음에는 놀랄 만큼 정확하다.</p>
<p>하지만 작업을 계속 이어갈수록 갑자기 엉뚱한 파일을 수정하거나, 이미 수정한 내용을 다시 되돌리거나, 전혀 관계없는 코드를 제안하기도 한다. 이건 단순히 모델의 성능 때문만은 아니다.</p>
<p>작업이 길어질수록</p>
<ul>
<li>이전 대화</li>
<li>읽은 파일</li>
<li>검색 결과</li>
<li>터미널 출력</li>
<li>수정 내역</li>
</ul>
<p>같은 정보가 계속 누적된다.</p>
<p>AI는 이 정보들을 기반으로 계속 작업을 이어가는데, 누적된 정보가 많아질수록 현재 작업과 관련 없는 내용도 함께 영향을 주기 시작한다.</p>
<p>그래서 큰 작업일수록 하나의 긴 세션보다 기능 단위로 작업을 나누고, 필요한 정보만 다시 제공하는 편이 더 좋은 결과를 얻는 경우가 많다.</p>
<hr>
<h1 id="ai가-일하기-쉬운-프로젝트">AI가 일하기 쉬운 프로젝트</h1>
<p>AI를 사용하면서 가장 크게 느낀 점은 좋은 프로젝트일수록 AI도 더 좋은 결과를 만든다는 것이다.</p>
<p>역할이 명확하게 분리되어 있고, 파일 이름이 의미를 가지고 있으며, 프로젝트 규칙이 문서화되어 있다면 AI는 필요한 정보를 훨씬 빠르게 찾는다.</p>
<p>반대로 역할이 뒤섞여 있고, 비슷한 이름의 클래스가 많고, 규칙이 문서화되어 있지 않다면 사람뿐 아니라 AI도 쉽게 길을 잃는다. 예전에는 이런 구조가 사람을 위한 것이라고 생각했다.</p>
<p>하지만 이제는 AI를 위한 것이기도 하다.</p>
<hr>
<h1 id="마치며">마치며</h1>
<p>많은 개발자가 아직도 AI를 질문하는 대상으로 생각한다.</p>
<p>하지만 지금의 AI는 질문에 답하는 ChatBot이 아니라 프로젝트를 탐색하고, 계획을 세우고, 도구를 사용하며, 결과를 검증하는 개발 에이전트에 더 가깝다.</p>
<p>이 차이를 이해하는 순간 왜 &quot;API 수정해줘.&quot; 같은 요청은 원하는 결과를 만들기 어려운지, 왜 프로젝트 구조와 작업 단위가 중요한지, 왜 AI는 처음에는 잘하다가 점점 엉뚱한 답을 하기 시작하는지 자연스럽게 이해하게 된다.</p>
<p>AI를 잘 사용하는 방법은 특별한 프롬프트를 외우는 것이 아니다.</p>
<p><strong>AI가 어떻게 일하는지 이해하고, AI가 일하기 쉬운 환경을 만드는 것.</strong></p>
<p>아마 그것이 앞으로 개발자에게 더 중요한 역량이 될 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker에서 서브넷을 적용할 수 있을까?]]></title>
            <link>https://velog.io/@fever-max/Docker%EC%97%90%EC%84%9C-%EC%84%9C%EB%B8%8C%EB%84%B7%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@fever-max/Docker%EC%97%90%EC%84%9C-%EC%84%9C%EB%B8%8C%EB%84%B7%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%A0-%EC%88%98-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Fri, 24 Apr 2026 02:23:37 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>기존 서비스는 Windows 서버에서 JAR 파일을 직접 실행하는 방식으로 운영되고 있었다. 서버가 자주 불안정했고, Windows 환경 특성상 운영 자체가 불편한 점이 많았다. 결국 리뉴얼을 결정하면서 Linux + Docker 환경으로 전환하기로 했다.</p>
<p>전환을 준비하던 중 감사 일정이 잡혔다. 감사에서 가장 중요한 요구사항은 <strong>개인정보가 저장된 DB에 대한 접근 제한</strong>이었다.</p>
<p>요구사항을 정리하면:</p>
<ul>
<li>DB는 외부에서 직접 접속 불가</li>
<li>API 서버만 외부에 노출</li>
<li>나머지 서비스와 DB는 내부에서만 접근 가능</li>
<li>DB 접근은 반드시 서버 SSH 접속 후 컨테이너를 통해서만 가능</li>
</ul>
<hr>
<h2 id="서브넷subnet이란">서브넷(Subnet)이란?</h2>
<p>서브넷은 하나의 네트워크를 더 작은 단위로 나눈 것이다. 쉽게 말하면 <strong>같은 건물 안에서도 층별로 독립된 내선 전화망을 구성하는 것</strong>과 비슷하다. 같은 층(서브넷) 안에서는 자유롭게 통신할 수 있지만, 다른 층(서브넷)과는 별도의 경로를 통해야 한다.</p>
<p>예를 들어 <code>172.10.0.0/24</code> 라는 서브넷은:</p>
<ul>
<li><code>172.10.0.0</code> — 네트워크 주소</li>
<li><code>/24</code> — 앞 24비트가 네트워크 대역, 나머지 8비트가 호스트 대역</li>
<li>즉 <code>172.10.0.1</code> ~ <code>172.10.0.254</code> 범위의 IP를 사용할 수 있음</li>
</ul>
<hr>
<h2 id="docker-네트워크란">Docker 네트워크란?</h2>
<p>Docker는 컨테이너 간 통신을 위한 가상 네트워크를 직접 구성할 수 있다. 기본적으로 컨테이너를 실행하면 <code>bridge</code> 라는 기본 네트워크에 연결되는데, 이 경우 모든 컨테이너가 같은 네트워크에 있어 서로 통신이 가능하다.</p>
<p>커스텀 네트워크를 만들면:</p>
<ul>
<li>같은 네트워크 안의 컨테이너끼리만 통신 가능</li>
<li>컨테이너에 고정 IP 부여 가능</li>
<li>네트워크 단위로 격리 가능</li>
</ul>
<h3 id="서브넷을-docker에-적용할-수-있는-이유">서브넷을 Docker에 적용할 수 있는 이유</h3>
<p>전통적인 서버 환경에서 서브넷은 물리적인 네트워크 장비로 구성한다. 하지만 Docker는 소프트웨어적으로 가상 네트워크를 만들 수 있어서, 서브넷 개념을 컨테이너 레벨에서 그대로 구현할 수 있다.</p>
<p>즉 Docker 네트워크에 서브넷을 지정하면:</p>
<ul>
<li>같은 서브넷 안의 컨테이너끼리만 통신 가능</li>
<li>다른 서브넷의 컨테이너는 접근 불가</li>
<li>물리적인 네트워크 장비 없이 소프트웨어로 격리 가능</li>
</ul>
<p>이 특성을 활용해서 <strong>외부에 노출할 서비스(API)</strong> 와 <strong>내부에서만 동작할 서비스(Batch, DB 등)</strong> 를 분리했다.</p>
<hr>
<h2 id="적용">적용</h2>
<h3 id="네트워크-생성">네트워크 생성</h3>
<pre><code class="language-bash">docker network create \
  --driver bridge \
  --subnet 172.10.0.0/24 \
  --gateway 172.10.0.1 \
  app-network</code></pre>
<ul>
<li><code>--driver bridge</code> — 단일 호스트 내 컨테이너 간 통신</li>
<li><code>--subnet</code> — 네트워크 대역 지정</li>
<li><code>--gateway</code> — 게이트웨이 IP 지정</li>
</ul>
<h3 id="컨테이너-ip-고정">컨테이너 IP 고정</h3>
<p>각 컨테이너에 고정 IP를 부여했다. IP가 고정되어 있으면 컨테이너가 재시작되어도 IP가 유지되기 때문에 서비스 간 통신 설정이 흔들리지 않는다.</p>
<pre><code class="language-bash">docker run \
  --name app-api \
  --network app-network \
  --ip 172.10.0.11 \
  ...</code></pre>
<table>
<thead>
<tr>
<th>컨테이너</th>
<th>IP</th>
<th>외부 노출</th>
</tr>
</thead>
<tbody><tr>
<td>nginx</td>
<td>-</td>
<td>✅</td>
</tr>
<tr>
<td>app-api</td>
<td>172.10.0.11</td>
<td>❌ (nginx 통해서만)</td>
</tr>
<tr>
<td>app-a</td>
<td>172.10.0.12</td>
<td>❌</td>
</tr>
<tr>
<td>app-b</td>
<td>172.10.0.13</td>
<td>❌</td>
</tr>
<tr>
<td>app-mysql</td>
<td>172.10.0.10</td>
<td>❌</td>
</tr>
</tbody></table>
<h3 id="nginx를-통한-외부-노출">nginx를 통한 외부 노출</h3>
<p>기존 nginx 컨테이너가 있다면 app-network에 추가로 연결할 수 있다. nginx는 여러 네트워크에 동시에 연결될 수 있기 때문에 외부 네트워크와 app-network 사이의 리버스 프록시 역할을 할 수 있다.</p>
<pre><code class="language-bash">docker network connect app-network nginx</code></pre>
<pre><code>외부 요청 흐름
외부 → nginx → app-api (172.10.0.11)

내부에서만 접근 가능
app-a     (172.10.0.12)
app-b     (172.10.0.13)
app-mysql (172.10.0.10)</code></pre><h3 id="db-접근-제한">DB 접근 제한</h3>
<p>DB는 app-network 내부에서만 접근 가능하기 때문에 외부에서 직접 접속할 수 없다. 반드시 서버에 SSH로 접속한 후 컨테이너를 통해 접근해야 한다.</p>
<pre><code class="language-bash"># 서버 SSH 접속
ssh user@server-ip

# 컨테이너 진입 후 DB 접속
docker exec -it app-mysql bash
mysql -u root -p</code></pre>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음에는 네트워크를 별도로 구성해야 한다는 게 번거롭게 느껴졌다. 그냥 포트를 열고 닫는 것만으로도 충분하지 않을까 싶었는데, 직접 구성해보니 생각보다 훨씬 명확하게 서비스를 격리할 수 있었다.</p>
<p>서브넷이라는 개념이 물리적인 네트워크 장비에서만 가능한 줄 알았는데, Docker에서 소프트웨어적으로 동일하게 구현할 수 있다는 게 인상적이었다.</p>
<p>다만 이 방식은 <strong>단일 호스트에서의 격리에 한정</strong>된다는 점을 염두에 두어야 한다. 서비스가 커지거나 멀티 호스트 환경이 필요해진다면 Docker 네트워크만으로는 한계가 있다. Kubernetes는 이런 네트워크 격리와 서비스 디스커버리를 훨씬 정교하게 다룰 수 있고, 실제로 이 부분이 Docker와 Kubernetes의 가장 큰 차이 중 하나라고 생각한다.</p>
<p>Docker 네트워크를 직접 구성해보면서 네트워크 격리의 개념을 이해할 수 있었고, 이게 Kubernetes에서는 어떻게 추상화되어 동작하는지 더 공부해보고 싶어졌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[실무에서 모니터링 스택 구축하기 (Prometheus + Grafana + Loki + Tempo)]]></title>
            <link>https://velog.io/@fever-max/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8A%A4%ED%83%9D-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-Prometheus-Grafana-Loki-Tempo</link>
            <guid>https://velog.io/@fever-max/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EB%AA%A8%EB%8B%88%ED%84%B0%EB%A7%81-%EC%8A%A4%ED%83%9D-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-Prometheus-Grafana-Loki-Tempo</guid>
            <pubDate>Fri, 24 Apr 2026 02:00:08 GMT</pubDate>
            <description><![CDATA[<p>사내 서비스를 운영하면서 장애가 발생했을 때 원인을 파악하는 데 너무 많은 시간이 걸렸다. 로그를 일일이 뒤지고, 어느 시점에 문제가 생겼는지 추적하는 것 자체가 고역이었다. 그러던 중 모니터링 수업을 듣게 됐고, Prometheus, Grafana, Loki, Tempo 조합을 접하게 됐다.</p>
<p>각 도구의 역할을 간단히 정리하면:</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>역할</th>
<th>질문</th>
</tr>
</thead>
<tbody><tr>
<td>Prometheus</td>
<td>메트릭 수집</td>
<td>얼마나? (CPU, 메모리, 응답시간)</td>
</tr>
<tr>
<td>Grafana</td>
<td>시각화 대시보드</td>
<td>한눈에 보기</td>
</tr>
<tr>
<td>Loki</td>
<td>로그 수집</td>
<td>무슨 일이?</td>
</tr>
<tr>
<td>Tempo</td>
<td>트레이스 수집</td>
<td>어디서 느려?</td>
</tr>
</tbody></table>
<p>이 4개가 함께 동작하면 장애 발생 시 <strong>언제, 어디서, 왜</strong> 를 빠르게 파악할 수 있다.</p>
<hr>
<h2 id="왜-elk가-아닌가">왜 ELK가 아닌가?</h2>
<p>로그 수집 하면 보통 ELK (Elasticsearch + Logstash + Kibana) 스택을 먼저 떠올린다. 실제로 많은 회사에서 사용하는 검증된 스택이기도 하다.</p>
<p>하지만 우리 인프라는 <strong>Traditional 3-Tier Architecture</strong> (Web - WAS - DB) 였다. Kubernetes 환경이 아니다 보니 ELK가 제공하는 강점들이 크게 필요하지 않았다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>ELK</th>
<th>Loki</th>
</tr>
</thead>
<tbody><tr>
<td>리소스</td>
<td>Heavy (Elasticsearch 메모리 소모 큼)</td>
<td>Lightweight</td>
</tr>
<tr>
<td>로그 저장 방식</td>
<td>로그 내용을 인덱싱</td>
<td>레이블만 인덱싱</td>
</tr>
<tr>
<td>Grafana 연동</td>
<td>별도 플러그인 필요</td>
<td>기본 지원</td>
</tr>
<tr>
<td>적합한 환경</td>
<td>Kubernetes, 대규모 분산 환경</td>
<td>소규모, 단순 인프라</td>
</tr>
<tr>
<td>학습 곡선</td>
<td>높음</td>
<td>낮음</td>
</tr>
</tbody></table>
<p>Grafana와 자연스럽게 연동되는 Loki, 메트릭 수집의 Prometheus, 트레이스의 Tempo까지 하나의 Grafana 대시보드에서 모두 확인할 수 있다는 점이 매력적이었다. ELK는 강력하지만 Traditional 3-Tier 소규모 서비스에서 Elasticsearch의 리소스 부담은 오버스펙에 가까웠다.</p>
<hr>
<h2 id="테스트-서버-구축">테스트 서버 구축</h2>
<p>먼저 테스트 서버에 4개 스택을 전부 올려봤다. docker-compose로 구성했고, Spring Boot 애플리케이션에 actuator와 OpenTelemetry agent를 붙여서 메트릭, 로그, 트레이스를 각각 수집했다.</p>
<pre><code class="language-yaml">services:
  prometheus:
    image: prom/prometheus:v3.5.1
    ports:
      - 9090:9090
  grafana:
    image: grafana/grafana:12.4.2
    ports:
      - 3000:3000
  loki:
    image: grafana/loki:3.4.2
    ports:
      - 3100:3100
  tempo:
    image: grafana/tempo:2.7.2
    ports:
      - 3200:3200
      - 4317:4317</code></pre>
<p>Grafana 대시보드에서 메트릭을 보고, 이상한 구간을 발견하면 Loki로 로그를 확인하고, Tempo로 트레이스를 추적하는 흐름이 생각보다 훨씬 강력했다. 테스트 서버에서는 모두 정상 동작했다.</p>
<hr>
<h2 id="spring-boot-연동">Spring Boot 연동</h2>
<p>모니터링 스택을 구성했다고 끝이 아니다. 애플리케이션에서 메트릭을 노출해야 Prometheus가 수집할 수 있다.</p>
<h3 id="의존성-추가">의존성 추가</h3>
<p><em>build.gradle</em></p>
<pre><code class="language-groovy">implementation &#39;org.springframework.boot:spring-boot-starter-actuator&#39;
implementation &#39;io.micrometer:micrometer-registry-prometheus&#39;</code></pre>
<p><em>pom.xml</em></p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
    &lt;artifactId&gt;spring-boot-starter-actuator&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;io.micrometer&lt;/groupId&gt;
    &lt;artifactId&gt;micrometer-registry-prometheus&lt;/artifactId&gt;
&lt;/dependency&gt;</code></pre>
<h3 id="applicationyml-설정">application.yml 설정</h3>
<pre><code class="language-yaml">management:
  endpoints:
    web:
      exposure:
        include: prometheus, health, info
  endpoint:
    prometheus:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}</code></pre>
<h3 id="prometheusyml-scrape-설정">prometheus.yml scrape 설정</h3>
<p>Prometheus가 어떤 엔드포인트에서 메트릭을 가져올지 설정한다.</p>
<pre><code class="language-yaml">scrape_configs:
  - job_name: my-app
    metrics_path: /actuator/prometheus
    static_configs:
      - targets:
        - 192.168.0.1:8080
        labels:
          application: my-app</code></pre>
<hr>
<h2 id="production-서버-적용--내부망이라는-벽">Production 서버 적용 — 내부망이라는 벽</h2>
<p>문제는 Production 서버였다. Production 서버는 <strong>내부망</strong> 환경이었고, 외부와의 통신이 제한되어 있었다. VPN으로 접속하는 방식이었는데, Windows 서버 VPN 특성상 <strong>접속할 때마다 IP가 달라지는</strong> 문제가 있었다.</p>
<p>여기서 Prometheus와 Loki/Tempo의 근본적인 차이가 드러났다.</p>
<h3 id="pull-vs-push">Pull vs Push</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>도구</th>
<th>동작 방식</th>
</tr>
</thead>
<tbody><tr>
<td>Pull</td>
<td>Prometheus</td>
<td>모니터링 서버가 애플리케이션에서 메트릭을 <strong>가져옴</strong></td>
</tr>
<tr>
<td>Push</td>
<td>Loki, Tempo</td>
<td>애플리케이션이 수집 서버로 데이터를 <strong>보냄</strong></td>
</tr>
</tbody></table>
<p><strong>Prometheus</strong>는 Pull 방식이라 모니터링 서버에서 애플리케이션 엔드포인트로 주기적으로 요청을 보내서 메트릭을 가져온다. 즉 모니터링 서버가 애플리케이션 서버에 접근할 수 있으면 되기 때문에 내부망에서도 문제없이 동작했다.</p>
<p>반면 <strong>Loki와 Tempo</strong>는 Push 방식이라 애플리케이션이 직접 Loki/Tempo 서버로 데이터를 전송해야 한다. 내부망에서는 애플리케이션이 외부로 나갈 수가 없었고, 거기다 VPN IP가 매번 바뀌니 고정 엔드포인트 설정 자체가 불가능했다.</p>
<p>결국 Production 서버에는 <strong>Prometheus + Grafana + AlertManager</strong> 만 적용하기로 결정했다.</p>
<hr>
<h2 id="production-서버-구성--prometheus--grafana--alertmanager">Production 서버 구성 — Prometheus + Grafana + AlertManager</h2>
<h3 id="docker-compose-구성">docker-compose 구성</h3>
<pre><code class="language-yaml">services:
  prometheus:
    image: prom/prometheus:v3.5.1
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --web.enable-lifecycle
      - --storage.tsdb.retention.time=30d
      - --storage.tsdb.retention.size=10GB
      - --web.enable-remote-write-receiver
    restart: unless-stopped

  alertmanager:
    image: prom/alertmanager:v0.31.1
    command:
      - --config.file=/etc/alertmanager/alertmanager.yml
    restart: unless-stopped
    depends_on:
      - prometheus-msteams

  grafana:
    image: grafana/grafana:12.4.2
    restart: unless-stopped
    depends_on:
      - prometheus

  prometheus-msteams:
    image: quay.io/prometheusmsteams/prometheus-msteams:latest
    environment:
      - TEAMS_INCOMING_WEBHOOK_URL=https://your-teams-webhook-url
      - TEAMS_REQUEST_URI=alertmanager
    command:
      - -workflow-webhook
    restart: unless-stopped</code></pre>
<h3 id="alertmanager-teams-연동">AlertManager Teams 연동</h3>
<p>Teams webhook을 직접 AlertManager에 붙이는 건 지원이 안 돼서 <strong>prometheus-msteams</strong> 라는 중간 브릿지를 사용했다.</p>
<p><strong>흐름</strong></p>
<pre><code>Prometheus → AlertManager → prometheus-msteams → Teams</code></pre><p>처음에는 기존 Incoming Webhook 방식으로 연동하려 했는데, 2025년 이후 Teams Incoming Webhook 지원이 종료되면서 <strong>Power Automate Workflow Webhook</strong> 방식으로 변경해야 했다. Webhook URL을 Power Automate에서 생성하고 환경변수로 주입하면 된다.</p>
<p><em>alertmanager.yml</em></p>
<pre><code class="language-yaml">global:
  resolve_timeout: 5m
route:
  receiver: &#39;teams-notification&#39;
  group_by: [&#39;alertname&#39;, &#39;application&#39;]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 1h
receivers:
- name: &#39;teams-notification&#39;
  webhook_configs:
  - url: &#39;http://prometheus-msteams:2000/alertmanager&#39;
    send_resolved: true</code></pre>
<h3 id="알림-규칙-설정">알림 규칙 설정</h3>
<p>서비스 다운, CPU 과부하, JVM Heap 과부하 3가지 알림을 설정했다.</p>
<p><em>alert_rules.yml</em></p>
<pre><code class="language-yaml">groups:
  - name: 기본알람
    rules:
      - alert: ServiceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          description: &quot;[{{ $labels.application }}] 서비스 다운&quot;

      - alert: HighCpuUsage
        expr: process_cpu_usage * 100 &gt; 80
        for: 5m
        labels:
          severity: warning
        annotations:
          description: &quot;[{{ $labels.application }}] CPU {{ $value | printf \&quot;%.1f\&quot; }}% 초과&quot;

      - alert: HighHeapUsage
        expr: |
          (
            sum by(instance, application) (jvm_memory_used_bytes{area=&quot;heap&quot;})
            /
            sum by(instance, application) (jvm_memory_max_bytes{area=&quot;heap&quot;})
          ) * 100 &gt; 85
        for: 5m
        labels:
          severity: critical
        annotations:
          description: &quot;[{{ $labels.application }}] Heap {{ $value | printf \&quot;%.1f\&quot; }}% 초과&quot;</code></pre>
<hr>
<h2 id="test-vs-production-비교">Test vs Production 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Test 서버</th>
<th>Production 서버</th>
</tr>
</thead>
<tbody><tr>
<td>메트릭</td>
<td>Prometheus ✅</td>
<td>Prometheus ✅</td>
</tr>
<tr>
<td>로그</td>
<td>Loki ✅</td>
<td>❌ (내부망)</td>
</tr>
<tr>
<td>트레이스</td>
<td>Tempo ✅</td>
<td>❌ (내부망)</td>
</tr>
<tr>
<td>알림</td>
<td>AlertManager ✅</td>
<td>AlertManager ✅</td>
</tr>
<tr>
<td>시각화</td>
<td>Grafana ✅</td>
<td>Grafana ✅</td>
</tr>
</tbody></table>
<p>로그와 트레이스를 포기한 건 아쉬웠지만, Prometheus만으로도 CPU, 메모리, JVM Heap, 응답시간, 서비스 상태 등 핵심 메트릭은 충분히 수집할 수 있었다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>모니터링을 붙이고 나서 가장 크게 달라진 건 <strong>장애 대응 속도</strong>였다. 이전에는 VOC가 들어온 후에야 인지했다면, 이제는 알림으로 먼저 인지하고 Grafana 대시보드에서 어느 시점에 무슨 일이 있었는지 바로 확인할 수 있게 됐다.</p>
<p>완벽한 구성은 아니었지만, 제약된 환경에서도 할 수 있는 최선을 찾아서 적용한 경험이 됐다. 나중에 내부망 환경에서 Loki를 붙일 수 있는 방법을 더 찾아볼 생각이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[로그 파일이 두 군데 남는 이유 (Logback과 catalina.out의 관계)]]></title>
            <link>https://velog.io/@fever-max/%EB%A1%9C%EA%B7%B8-%ED%8C%8C%EC%9D%BC%EC%9D%B4-%EB%91%90-%EA%B5%B0%EB%8D%B0-%EB%82%A8%EB%8A%94-%EC%9D%B4%EC%9C%A0-Logback%EA%B3%BC-catalina.out%EC%9D%98-%EA%B4%80%EA%B3%84</link>
            <guid>https://velog.io/@fever-max/%EB%A1%9C%EA%B7%B8-%ED%8C%8C%EC%9D%BC%EC%9D%B4-%EB%91%90-%EA%B5%B0%EB%8D%B0-%EB%82%A8%EB%8A%94-%EC%9D%B4%EC%9C%A0-Logback%EA%B3%BC-catalina.out%EC%9D%98-%EA%B4%80%EA%B3%84</guid>
            <pubDate>Fri, 24 Apr 2026 01:41:18 GMT</pubDate>
            <description><![CDATA[<p>평소에 로그를 볼 때 항상 이렇게 봤다.</p>
<pre><code class="language-bash">tail -f /logs/tomcat/catalina.out</code></pre>
<p>그러다 Logback 설정을 열어봤는데 파일이 여러 개였다.</p>
<pre><code>app.log
error.log
business.log</code></pre><p>&quot;어? 그럼 catalina.out이랑 app.log가 같은 내용 아니야?&quot;</p>
<p>이 의문에서 시작했다.</p>
<hr>
<h1 id="tomcat-로그-종류부터-정리해야-한다">Tomcat 로그 종류부터 정리해야 한다</h1>
<p>로그 파일이 여러 개 생기는데, 각자 역할이 다르다.</p>
<p><strong>AccessLogValve</strong>는 HTTP 요청 기록이다.</p>
<p>server.xml에서 설정한다.</p>
<pre><code class="language-xml"></code></pre>
<p>찍히는 로그는 이렇다.</p>
<pre><code>192.168.1.10 - - [15/Jan/2024:10:23:45 +0900] &quot;GET /api/orders HTTP/1.1&quot; 200 1234</code></pre><p>누가 뭘 요청했고 응답코드가 뭔지만 찍힌다.
앱 내부에서 뭔 일이 있었는지는 모른다.</p>
<p><strong>catalina.out</strong>은 Tomcat 표준출력이다.</p>
<p>setenv.sh에서 경로를 지정한다.</p>
<pre><code class="language-bash">CATALINA_OUT=&quot;/data/tomcat/logs/catalina.out&quot;</code></pre>
<p>JVM이 콘솔에 찍는 것들이 전부 여기 쌓인다.</p>
<p><strong>Logback</strong>은 앱 내부 로직 기록이다.</p>
<p>개발자가 log.info(), log.error()로 직접 찍은 것들이다.</p>
<hr>
<h1 id="그래서-두-군데-찍히는-게-맞나">그래서 두 군데 찍히는 게 맞나</h1>
<p>맞다.</p>
<p>원인은 ConsoleAppender 때문이다.</p>
<pre><code class="language-xml">

    /data/tomcat/logs/app.log
</code></pre>
<p>흐름이 이렇다.</p>
<pre><code>log.info(&quot;주문 생성: orderId=1234&quot;)
         │
         ├──► STDOUT(ConsoleAppender) → catalina.out  ← 여기도 찍힘
         ├──► DEFAULT(FileAppender)  → app.log        ← 여기도 찍힘
         └──► ERROR(LevelFilter)     → error.log      ← ERROR만</code></pre><p>ConsoleAppender가 콘솔로 출력하면 Tomcat이 그걸 catalina.out으로 받아버린다.</p>
<p>catalina.out이랑 app.log는 같은 내용이 중복으로 쌓이고 있는 거다.</p>
<hr>
<h1 id="그럼-왜-굳이-파일을-따로-빼나">그럼 왜 굳이 파일을 따로 빼나</h1>
<p>catalina.out은 Tomcat이 관리하는 파일이라 Logback이 직접 rotate를 못 한다.</p>
<p>DEFAULT appender로 따로 빼면 Logback이 직접 관리할 수 있다.</p>
<pre><code class="language-xml">
    /data/tomcat/logs/backup/app.%d{yyyy-MM-dd}.%i.gz
    100MB
    90
    2GB
</code></pre>
<p>100MB 넘으면 자동으로 압축해서 backup 폴더로 보내고,
90일치 보관하고, 전체 2GB 넘으면 오래된 것부터 지운다.</p>
<p>catalina.out은 이런 관리가 안 된다.
그래서 별도로 logrotate를 써야 한다.</p>
<hr>
<h1 id="catalinaout은-logrotate로-관리한다">catalina.out은 logrotate로 관리한다</h1>
<p>Ubuntu 기준으로 logrotate는 cron이 아니라 systemd timer로 돌아간다.</p>
<pre><code class="language-bash"># /lib/systemd/system/logrotate.timer
OnCalendar=daily</code></pre>
<p>흐름은 이렇다.</p>
<pre><code>systemd timer (매일)
    │
    └──► /etc/logrotate.conf
              │
              └──► include /etc/logrotate.d/
                        │
                        └──► tomcat
                                  │
                                  └──► catalina.out rotate 실행</code></pre><p>/etc/logrotate.d/ 폴더에 파일만 넣으면 자동으로 포함된다.</p>
<p>설정은 이렇다.</p>
<pre><code class="language-bash">/data/tomcat/logs/catalina.out {
    copytruncate   # Tomcat 재시작 없이 rotate
    daily          # 매일
    rotate 60      # 60일치 보관
    compress       # gzip 압축
    delaycompress  # 가장 최근 파일은 압축 안함
    missingok      # 파일 없어도 에러 안남
    dateext        # 파일명에 날짜 붙임
}</code></pre>
<p>여기서 핵심은 copytruncate다.</p>
<p>일반 rotate는 파일을 옮기고 새 파일을 만드는데,
Tomcat은 파일 경로를 고정으로 잡고 있어서 파일이 바뀌면 못 찾는다.</p>
<p>copytruncate는 파일을 복사한 다음 원본을 비워버리는 방식이라
Tomcat 재시작 없이 rotate가 가능하다.</p>
<hr>
<h1 id="마무리">마무리</h1>
<p>로그 구조를 정리하면 이렇다.</p>
<pre><code>HTTP 요청
    │
    ├──► AccessLogValve → localhost_access_log.txt
    │
    └──► Spring App
              │
              ├──► ConsoleAppender → catalina.out  (logrotate)
              ├──► DEFAULT         → app.log       (Logback 관리)
              └──► ERROR           → error.log     (ERROR만)</code></pre><p>결국 catalina.out이랑 app.log는 중복이 맞다.</p>
<p>근데 각자 관리 주체가 다르다.</p>
<p>catalina.out은 Tomcat이 들고 있어서 logrotate로 밖에 못 건드리고,
app.log는 Logback이 직접 들고 있어서 세밀하게 관리할 수 있다.</p>
<p>같은 내용이 두 군데 찍히는 게 버그가 아니라
<strong>관리 주체가 다른 두 채널이 동시에 받고 있는 구조</strong>였던 거다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DB 커넥션 풀은 어떻게 동작하는가 (HikariCP 내부 구조)]]></title>
            <link>https://velog.io/@fever-max/DB-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%98%EB%8A%94%EA%B0%80-HikariCP-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@fever-max/DB-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%99%EC%9E%91%ED%95%98%EB%8A%94%EA%B0%80-HikariCP-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Wed, 01 Apr 2026 09:26:19 GMT</pubDate>
            <description><![CDATA[<p>백엔드 개발하다 보면 한 번쯤 이런 상황 겪는다.</p>
<p>“DB도 정상이고 CPU도 널널한데, 왜 API만 느리지?”</p>
<p>이럴 때 의외로 많이 걸리는 게 커넥션 풀이다.
나도 처음엔 maxPoolSize만 늘리면 해결되는 줄 알았는데, 구조를 이해하고 나니까 문제 보는 시선이 완전히 달라졌다.</p>
<p>이 글은 설정값 얘기 말고, <strong>HikariCP가 실제로 어떻게 동작하는지</strong>를 흐름 기준으로 정리한다.</p>
<hr>
<h1 id="커넥션-풀은-단순-재사용이-아니다">커넥션 풀은 단순 재사용이 아니다</h1>
<p>커넥션 풀을 흔히 “미리 만들어두고 재사용”이라고 생각하기 쉬운데, 실제 역할은 그보다 더 중요하다.</p>
<p>커넥션 풀은 결국 <strong>동시에 DB에 붙을 수 있는 수를 제한하는 장치</strong>다.</p>
<p>요청이 100개 들어와도 커넥션이 10개면, 동시에 처리 가능한 건 10개뿐이다.
나머지는 무조건 기다린다.</p>
<p>그래서 커넥션 풀은 성능을 올리는 도구라기보다
<strong>시스템이 감당 가능한 처리량을 강제로 맞추는 장치</strong>에 가깝다.</p>
<h1 id="hikaricp-구조를-보면-왜-빠른지-이해된다">HikariCP 구조를 보면 왜 빠른지 이해된다</h1>
<p>HikariCP 내부 구조는 단순한 큐 기반이 아니다.</p>
<pre><code class="language-text">Application Thread
    ↓
HikariPool
    ↓
ConcurrentBag
    ↓
Connection</code></pre>
<p>여기서 핵심은 ConcurrentBag이다.</p>
<p>일반적인 풀처럼 큐에 넣고 빼는 방식이 아니라,
각 커넥션이 상태를 가지고 있고 스레드가 그 상태를 바꾸면서 가져가는 구조다.</p>
<p>이 방식의 목적은 하나다.</p>
<p>락을 최대한 피하고, 동시에 여러 스레드가 접근해도 병목이 생기지 않게 만드는 것.</p>
<h1 id="왜-굳이-이런-구조를-썼을까">왜 굳이 이런 구조를 썼을까</h1>
<p>일반적으로는 BlockingQueue를 써서 풀을 만든다.
구현도 쉽고 직관적이다.</p>
<p>근데 트래픽이 늘어나면 문제가 생긴다.</p>
<p>스레드가 많아질수록 lock 경쟁이 심해지고,
대기와 컨텍스트 스위칭이 늘어나면서 전체 성능이 떨어진다.</p>
<p>HikariCP는 이 문제를 피하려고 아래 3가지를 중심으로 설계했다.</p>
<ul>
<li>가능한 한 lock을 쓰지 않는다</li>
<li>상태 변경은 CAS 방식으로 처리한다</li>
<li>같은 스레드는 같은 커넥션을 다시 쓰도록 유도한다</li>
</ul>
<p>이 세 가지가 합쳐지면서, 요청이 많아져도 성능이 크게 떨어지지 않는다.</p>
<h1 id="커넥션을-가져오는-실제-흐름">커넥션을 가져오는 실제 흐름</h1>
<p>HikariCP에서 커넥션을 얻는 과정은 생각보다 단순하다.</p>
<pre><code class="language-text">1. ThreadLocal에서 먼저 확인
2. 있으면 그대로 사용
3. 없으면 ConcurrentBag에서 탐색
4. 사용 가능한 커넥션이 있으면 가져옴
5. 없으면 새로 생성 (가능한 경우)
6. 최대치면 대기</code></pre>
<p>여기서 중요한 포인트는 ThreadLocal이다.</p>
<p>같은 스레드는 이전에 사용했던 커넥션을 우선적으로 다시 사용하려고 한다.
이 경우 풀 전체를 탐색하지 않기 때문에 거의 비용이 발생하지 않는다.</p>
<p>결과적으로 반복 요청이 많은 환경에서는
커넥션을 가져오는 비용 자체가 거의 사라진다.</p>
<h1 id="성능을-결정하는-건-커넥션-수가-아니다">성능을 결정하는 건 커넥션 수가 아니다</h1>
<p>많이 하는 실수가 하나 있다.</p>
<p>“느리니까 maxPoolSize를 늘리자”</p>
<p>근데 대부분 이걸로 해결 안 된다.</p>
<p>진짜 중요한 건 커넥션 개수가 아니라
<strong>커넥션을 얼마나 오래 잡고 있느냐</strong>다.</p>
<p>예를 들어 커넥션이 10개 있고, 각 요청이 1초씩 잡고 있으면
초당 최대 10개밖에 처리 못 한다.</p>
<p>여기서 트랜잭션 안에 외부 API 호출이 들어가면 상황이 더 나빠진다.</p>
<p>DB 작업이 끝났는데도 커넥션을 계속 잡고 있기 때문이다.</p>
<p>이러면 풀을 아무리 늘려도 계속 밀린다.</p>
<h1 id="커넥션-풀이-터지는-순간">커넥션 풀이 터지는 순간</h1>
<p>모든 커넥션이 사용 중이면 이후 요청은 전부 대기한다.</p>
<pre><code class="language-text">모든 커넥션 사용 중
→ 요청 대기
→ 응답 지연
→ timeout</code></pre>
<p>이 상태가 되면 특징이 있다.</p>
<p>CPU는 정상이고, DB도 정상인데 API만 느리다.</p>
<p>이럴 때 커넥션 풀을 의심해야 한다.</p>
<h1 id="maxpoolsize는-많다고-좋은-게-아니다">maxPoolSize는 많다고 좋은 게 아니다</h1>
<p>maxPoolSize를 늘리면 무조건 좋아질 것 같지만, 실제로는 그렇지 않다.</p>
<p>DB도 동시에 처리할 수 있는 한계가 있고,
커넥션이 많아질수록 스레드 간 경쟁과 컨텍스트 스위칭이 늘어난다.</p>
<p>결국 일정 수준을 넘으면 오히려 성능이 떨어진다.</p>
<p>중요한 건 단순히 늘리는 게 아니라
<strong>DB가 감당 가능한 수준에 맞추는 것</strong>이다.</p>
<h1 id="마무리">마무리</h1>
<p>처음에는 커넥션 풀을 그냥 설정값으로만 봤는데,
구조를 이해하고 나니까 문제를 보는 기준이 완전히 달라졌다.</p>
<p>이제는 API가 느려지면 먼저 본다.</p>
<p>“지금 커넥션을 누가 오래 잡고 있지?”</p>
<p>이 질문이 나오기 시작하면,
단순 튜닝이 아니라 구조를 이해한 상태다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[세션은 어떻게 유지되는가 (JSESSIONID의 역할)]]></title>
            <link>https://velog.io/@fever-max/%EC%84%B8%EC%85%98%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9C%A0%EC%A7%80%EB%90%98%EB%8A%94%EA%B0%80-JSESSIONID%EC%9D%98-%EC%97%AD%ED%95%A0</link>
            <guid>https://velog.io/@fever-max/%EC%84%B8%EC%85%98%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9C%A0%EC%A7%80%EB%90%98%EB%8A%94%EA%B0%80-JSESSIONID%EC%9D%98-%EC%97%AD%ED%95%A0</guid>
            <pubDate>Thu, 19 Mar 2026 08:26:54 GMT</pubDate>
            <description><![CDATA[<p>웹 개발을 하다 보면 세션은 너무 당연하게 사용된다.<br>로그인을 유지하고, 사용자 상태를 기억하는 데 필수적인 요소다.</p>
<p>하지만 실제로 세션이 어떻게 동작하는지,<br>특히 멀티 WAS 환경에서 어떤 문제가 발생하는지 이해하고 있는 경우는 많지 않다.</p>
<p>이 글에서는 JSESSIONID를 중심으로<br>세션의 내부 동작과 구조를 조금 더 깊게 정리한다.</p>
<hr>
<h2 id="세션의-본질">세션의 본질</h2>
<p>세션은 단순히 말하면 다음과 같다.</p>
<ul>
<li>서버에 저장된 사용자 상태</li>
<li>요청 간 상태를 유지하기 위한 장치</li>
</ul>
<p>HTTP는 stateless(무상태)이기 때문에<br>요청마다 사용자를 식별할 방법이 필요하다.</p>
<p>이때 사용하는 것이 세션이다.</p>
<hr>
<h2 id="jsessionid의-역할">JSESSIONID의 역할</h2>
<p>세션은 서버에 저장되지만,<br>클라이언트는 자신의 세션을 어떻게 식별할까?</p>
<p>그 역할을 하는 것이 JSESSIONID다.</p>
<h3 id="전체-흐름">전체 흐름</h3>
<ol>
<li>클라이언트 최초 요청</li>
<li>서버에서 세션 생성</li>
<li>세션 ID 생성 (예: ABC123)</li>
<li>응답에 쿠키로 전달</li>
</ol>
<pre><code>Set-Cookie: JSESSIONID=ABC123</code></pre><ol start="5">
<li>이후 요청마다 자동 포함</li>
</ol>
<pre><code>Cookie: JSESSIONID=ABC123</code></pre><ol start="6">
<li>서버는 해당 ID로 세션 조회</li>
</ol>
<hr>
<h2 id="세션-저장-위치">세션 저장 위치</h2>
<p>세션은 일반적으로 다음 위치에 저장된다.</p>
<h3 id="1-was-메모리-기본">1. WAS 메모리 (기본)</h3>
<ul>
<li>Tomcat, Tomee 등</li>
<li>가장 빠름</li>
<li>서버마다 따로 존재</li>
</ul>
<h3 id="2-외부-저장소">2. 외부 저장소</h3>
<ul>
<li>Redis</li>
<li>DB</li>
</ul>
<p>멀티 서버 환경에서 사용</p>
<hr>
<h2 id="중요한-오해">중요한 오해</h2>
<p>많이 하는 착각이 있다.</p>
<blockquote>
<p>JSESSIONID에 사용자 정보가 들어있다?</p>
</blockquote>
<p>아니다.</p>
<ul>
<li>JSESSIONID = 단순한 키</li>
<li>실제 데이터 = 서버 내부</li>
</ul>
<p>즉, 클라이언트는 아무 정보도 가지고 있지 않다.</p>
<hr>
<h2 id="단일-was-환경">단일 WAS 환경</h2>
<pre><code>[Client] → [WAS1]</code></pre><ul>
<li>세션 생성</li>
<li>JSESSIONID로 계속 접근</li>
</ul>
<p>문제 없음</p>
<hr>
<h2 id="멀티-was-환경-문제">멀티 WAS 환경 문제</h2>
<pre><code>[Client] → [LB] → [WAS1 / WAS2]</code></pre><h3 id="시나리오">시나리오</h3>
<ol>
<li>로그인 → WAS1 → 세션 생성</li>
<li>다음 요청 → WAS2</li>
</ol>
<p>결과:</p>
<ul>
<li>세션 없음</li>
<li>로그인 풀림</li>
</ul>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1-sticky-session">1. Sticky Session</h3>
<ul>
<li>LB가 특정 WAS로 고정</li>
</ul>
<p>장점:</p>
<ul>
<li>간단</li>
</ul>
<p>단점:</p>
<ul>
<li>확장성 낮음</li>
<li>장애 시 문제</li>
</ul>
<hr>
<h3 id="2-session-replication">2. Session Replication</h3>
<ul>
<li>WAS끼리 세션 공유</li>
</ul>
<p>단점:</p>
<ul>
<li>네트워크 비용 큼</li>
<li>성능 저하</li>
</ul>
<hr>
<h3 id="3-redis-기반-세션">3. Redis 기반 세션</h3>
<ul>
<li>외부 저장소 사용</li>
</ul>
<p>구조:</p>
<pre><code>Client → LB → WAS → Redis</code></pre><p>장점:</p>
<ul>
<li>확장성 좋음</li>
<li>안정적</li>
</ul>
<hr>
<h2 id="jsessionid--jvm-route">JSESSIONID + JVM Route</h2>
<p>클러스터 환경에서는 이런 형태도 사용한다.</p>
<pre><code>JSESSIONID=ABC123.node1</code></pre><p>여기서</p>
<ul>
<li>ABC123 → 세션 ID</li>
<li>node1 → 서버 식별자</li>
</ul>
<p>LB가 이 값을 보고 특정 서버로 라우팅</p>
<hr>
<h2 id="실무에서-자주-터지는-문제">실무에서 자주 터지는 문제</h2>
<h3 id="1-로그인-유지-안됨">1. 로그인 유지 안됨</h3>
<ul>
<li>멀티 WAS</li>
<li>세션 공유 없음</li>
</ul>
<hr>
<h3 id="2-특정-api에서만-세션-유실">2. 특정 API에서만 세션 유실</h3>
<ul>
<li>다른 서버로 라우팅됨</li>
</ul>
<hr>
<h3 id="3-외부-콜백-이후-세션-사라짐">3. 외부 콜백 이후 세션 사라짐</h3>
<ul>
<li>도메인 변경</li>
<li>쿠키 미전달</li>
</ul>
<hr>
<h2 id="쿠키와-보안">쿠키와 보안</h2>
<p>JSESSIONID는 쿠키이기 때문에<br>보안 설정이 중요하다.</p>
<ul>
<li>HttpOnly</li>
<li>Secure</li>
<li>SameSite</li>
</ul>
<p>설정에 따라</p>
<ul>
<li>XSS 방지</li>
<li>CSRF 대응</li>
</ul>
<p>가능</p>
<hr>
<h2 id="왜-이런-구조일까">왜 이런 구조일까?</h2>
<p>HTTP는 stateless이기 때문에</p>
<ul>
<li>서버가 상태를 기억하려면</li>
<li>클라이언트가 식별자를 보내야 한다</li>
</ul>
<p>그래서</p>
<ul>
<li>서버 → 세션 저장</li>
<li>클라이언트 → ID만 보관</li>
</ul>
<p>이 구조가 만들어졌다.</p>
<hr>
<h2 id="한-단계-더">한 단계 더</h2>
<p>세션 방식 외에도 다른 방식이 있다.</p>
<ul>
<li>JWT (Stateless)</li>
<li>Token 기반 인증</li>
</ul>
<p>세션은 상태 기반(stateful),<br>JWT는 무상태(stateless) 방식이다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>세션은 서버에 저장된다</li>
<li>JSESSIONID는 그 세션을 찾는 키다</li>
<li>단일 서버에서는 단순하다</li>
<li>멀티 서버에서는 전략이 필요하다</li>
</ul>
<hr>
<h2 id="한줄-요약">한줄 요약</h2>
<p>세션은 서버에 있고,<br>JSESSIONID는 그 세션을 찾기 위한 식별자다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[auto increment인데 duplicate key 나는 이유]]></title>
            <link>https://velog.io/@fever-max/auto-increment%EC%9D%B8%EB%8D%B0-duplicate-key-%EB%82%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@fever-max/auto-increment%EC%9D%B8%EB%8D%B0-duplicate-key-%EB%82%98%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 19 Mar 2026 08:21:11 GMT</pubDate>
            <description><![CDATA[<p>백엔드 개발을 하다 보면 한 번쯤 이런 에러를 보게 된다.</p>
<pre><code>ERROR: duplicate key value violates unique constraint
Key (id)=(4) already exists</code></pre><p>auto increment인데 왜 중복이 발생할까?</p>
<p>처음에는 DB가 자동으로 값을 잘 관리해줄 거라고 생각하기 쉽다.<br>하지만 실제로는 그렇지 않다.</p>
<p>결론부터 말하면<br><strong>시퀀스는 테이블 상태를 전혀 모른다.</strong></p>
<hr>
<h2 id="auto-increment의-정체">auto increment의 정체</h2>
<p>PostgreSQL에서 auto increment는 내부적으로 다음과 같이 동작한다.</p>
<pre><code class="language-sql">nextval(&#39;sequence_name&#39;)</code></pre>
<p>이 함수는 단순히 숫자를 하나씩 증가시키며 반환한다.</p>
<p>중요한 점은 다음이다.</p>
<ul>
<li>테이블을 조회하지 않는다</li>
<li>현재 PK 상태를 확인하지 않는다</li>
<li>단순히 숫자만 증가시킨다</li>
</ul>
<p>즉, auto increment는 “똑똑한 증가 기능”이 아니라<br><strong>독립적인 숫자 생성기</strong>에 가깝다.</p>
<hr>
<h2 id="duplicate-key가-발생하는-구조">duplicate key가 발생하는 구조</h2>
<p>다음과 같은 상황을 가정해보자.</p>
<h3 id="현재-상태">현재 상태</h3>
<p>테이블 데이터:
id = 1, 2, 3, 4, 5</p>
<p>시퀀스 상태:
nextval = 4</p>
<p>이 상태에서 insert를 수행하면</p>
<pre><code class="language-sql">INSERT INTO table (...) VALUES (...);</code></pre>
<p>실제로는 내부적으로 다음과 같이 처리된다.</p>
<pre><code class="language-sql">id = nextval(...)
→ 4</code></pre>
<p>이미 테이블에 존재하는 값이기 때문에<br>duplicate key 에러가 발생한다.</p>
<hr>
<h2 id="시퀀스가-꼬이는-이유">시퀀스가 꼬이는 이유</h2>
<p>이런 상황은 의외로 자주 발생한다.</p>
<h3 id="1-수동으로-pk를-넣은-경우">1. 수동으로 PK를 넣은 경우</h3>
<pre><code class="language-sql">INSERT INTO table(id, ...) VALUES (100, ...);</code></pre>
<p>테이블에는 100이 들어가지만<br>시퀀스는 이 사실을 모른다.</p>
<p>이후 시퀀스는 기존 값 기준으로 계속 증가하게 되고<br>결국 충돌이 발생한다.</p>
<hr>
<h3 id="2-dump--restore">2. dump / restore</h3>
<p>데이터를 백업했다가 복구할 때</p>
<ul>
<li>데이터는 정상적으로 들어오지만</li>
<li>시퀀스 값은 초기 상태로 남아있는 경우가 많다</li>
</ul>
<p>실무에서 가장 흔한 원인이다.</p>
<hr>
<h3 id="3-서버-간-데이터-이관">3. 서버 간 데이터 이관</h3>
<p>운영 환경의 데이터를 개발 환경으로 복사할 때</p>
<ul>
<li>테이블 데이터만 복사</li>
<li>시퀀스 값은 그대로</li>
</ul>
<p>이 경우에도 쉽게 문제가 발생한다.</p>
<hr>
<h3 id="4-truncate에-대한-오해">4. truncate에 대한 오해</h3>
<pre><code class="language-sql">TRUNCATE TABLE table;</code></pre>
<p>이 명령은 데이터를 삭제할 뿐<br>시퀀스 값은 초기화하지 않는다.</p>
<p>그래서 이후 insert 시 예상과 다른 값이 들어갈 수 있다.</p>
<hr>
<h3 id="5-트랜잭션-롤백">5. 트랜잭션 롤백</h3>
<pre><code class="language-sql">SELECT nextval(&#39;seq&#39;); -- 10
ROLLBACK;</code></pre>
<p>이 경우 많은 사람들이<br>값이 롤백될 것이라고 생각한다.</p>
<p>하지만 시퀀스는 트랜잭션과 무관하다.</p>
<ul>
<li>10은 이미 사용된 값</li>
<li>다시 되돌아가지 않는다</li>
</ul>
<p>그래서 중간 숫자가 비는 것은 정상적인 동작이다.</p>
<hr>
<h2 id="왜-이렇게-설계되어-있을까">왜 이렇게 설계되어 있을까</h2>
<p>시퀀스가 테이블과 독립적으로 동작하는 이유는 성능 때문이다.</p>
<p>만약 시퀀스가 테이블 상태를 매번 확인한다면</p>
<ul>
<li>락이 발생하고</li>
<li>동시성이 크게 떨어진다</li>
</ul>
<p>그래서 DB는 단순한 구조를 선택했다.</p>
<ul>
<li>숫자를 빠르게 발급</li>
<li>충돌 여부는 제약조건으로 처리</li>
</ul>
<hr>
<h2 id="해결-방법">해결 방법</h2>
<p>문제가 발생했다면 시퀀스를 테이블에 맞춰주면 된다.</p>
<pre><code class="language-sql">SELECT setval(
  &#39;sequence_name&#39;,
  (SELECT MAX(id) FROM table)
);</code></pre>
<p>이렇게 하면 시퀀스가 현재 데이터 기준으로 재정렬된다.</p>
<hr>
<h2 id="실무에서의-대응-방법">실무에서의 대응 방법</h2>
<h3 id="1-데이터-이관-후-시퀀스-정렬">1. 데이터 이관 후 시퀀스 정렬</h3>
<p>데이터를 복사하거나 복구했다면<br>반드시 시퀀스를 맞춰야 한다.</p>
<h3 id="2-pk-수동-입력-지양">2. PK 수동 입력 지양</h3>
<p>특별한 이유가 없다면<br>auto increment에 맡기는 것이 안전하다.</p>
<h3 id="3-duplicate-key-발생-시-우선-확인">3. duplicate key 발생 시 우선 확인</h3>
<p>코드 문제로 보기 전에<br>시퀀스 상태를 먼저 확인하는 것이 빠르다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>auto increment는 테이블을 기준으로 동작하는 기능이 아니다.</p>
<ul>
<li>테이블 상태를 모른다</li>
<li>트랜잭션과 무관하다</li>
<li>단순히 숫자만 증가시킨다</li>
</ul>
<p>이 구조를 이해하지 못하면<br>예상하지 못한 duplicate key 문제를 만나게 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring @Transactional이 안 먹는 이유 (self-invocation 때문에 2시간 날린 썰)]]></title>
            <link>https://velog.io/@fever-max/Spring-Transactional%EC%9D%B4-%EC%95%88-%EB%A8%B9%EB%8A%94-%EC%9D%B4%EC%9C%A0-self-invocation-%EB%95%8C%EB%AC%B8%EC%97%90-2%EC%8B%9C%EA%B0%84-%EB%82%A0%EB%A6%B0-%EC%8D%B0</link>
            <guid>https://velog.io/@fever-max/Spring-Transactional%EC%9D%B4-%EC%95%88-%EB%A8%B9%EB%8A%94-%EC%9D%B4%EC%9C%A0-self-invocation-%EB%95%8C%EB%AC%B8%EC%97%90-2%EC%8B%9C%EA%B0%84-%EB%82%A0%EB%A6%B0-%EC%8D%B0</guid>
            <pubDate>Fri, 20 Feb 2026 02:39:34 GMT</pubDate>
            <description><![CDATA[<p>Spring에서 @Transactional을 붙였는데
분명 예외가 발생했는데도 롤백이 되지 않는 경험을 한 적이 있다.</p>
<p>처음에는 MyBatis 문제인가 싶었고,
DB 설정 문제인가 싶었고,
격하게 삽질을 했다.</p>
<p>결론은 단순했다.</p>
<blockquote>
<p>트랜잭션이 안 먹은 게 아니라, 프록시를 타지 않았다.</p>
</blockquote>
<h1 id="문제-상황">문제 상황</h1>
<p>다음과 같은 코드였다. (예시 코드)</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class OrderApplication {

    private final OrderService orderService;
    private final OrderResultPublisher publisher;

    public void orderGenerateWithPublish(OrderVo orderVo) {
        OrderMessage message = generateOrder(orderVo);
        publisher.send(message);
    }

    @Transactional
    public OrderMessage generateOrder(OrderVo orderVo) {

        if (orderService.existsByOrderNumber(orderVo.getOrderNumber())) {
            orderService.deleteByOrderNumber(orderVo.getOrderNumber());
        }

        Order order = orderService.save(orderVo.toEntity());

        // 강제 예외
        if (true) {
            throw new RuntimeException(&quot;예외 발생&quot;);
        }

        return OrderMessage.from(order);
    }
}</code></pre>
<p>의도는 단순했다.</p>
<ul>
<li>주문 저장</li>
<li>예외 발생 시 롤백</li>
<li>트랜잭션 보장</li>
</ul>
<p>하지만 실제로는
삭제도 되고 insert도 되고 예외도 터지는데 롤백이 되지 않았다.</p>
<h1 id="원인">원인</h1>
<p>문제는 이 한 줄이었다.</p>
<pre><code class="language-java">OrderMessage message = generateOrder(orderVo);</code></pre>
<p>같은 클래스 내부에서 this 로 메서드를 호출했다.
이게 왜 문제일까?</p>
<blockquote>
<p>Spring 트랜잭션은 프록시 기반이다.</p>
</blockquote>
<p>Spring의 <code>@Transactional</code>은 AOP 기반으로 동작한다.</p>
<p>우리가 주입받는 Bean은 실제 객체가 아니라
트랜잭션 처리를 감싸고 있는 프록시 객체다.</p>
<p>구조를 단순화하면 다음과 같다.</p>
<blockquote>
<p>[Proxy 객체] → [실제 Bean]</p>
</blockquote>
<p>메서드를 호출하면 프록시가 먼저 실행되고
여기서 트랜잭션을 시작한다.</p>
<p>하지만 같은 클래스 내부에서 다른 메서드를 호출하면, </p>
<pre><code class="language-java">generateOrder();</code></pre>
<ul>
<li>프록시를 거치지 않는다</li>
<li>그냥 자기 자신 메서드를 호출한다</li>
<li>트랜잭션 AOP가 실행되지 않는다</li>
</ul>
<blockquote>
<p>즉, 트랜잭션이 안 먹은 게 아니라, 애초에 트랜잭션 로직이 실행되지 않은 것이다.</p>
</blockquote>
<h2 id="트랜잭션이-동작하지-않는-구조">트랜잭션이 동작하지 않는 구조</h2>
<pre><code class="language-java">public void outer() {
    this.inner(); // 프록시 안 거침
}

@Transactional
public void inner() {
}</code></pre>
<p>이 구조에서는 <code>@Transactional</code>이 적용되지 않는다.</p>
<h2 id="정상-동작-구조">정상 동작 구조</h2>
<p>다른 Bean을 통해 호출해야 한다.</p>
<pre><code class="language-java">@Service
public class OrderTxService {

    @Transactional
    public void generate() {
        // 트랜잭션 보장
    }
}</code></pre>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class OrderApplication {

    private final OrderTxService orderTxService;

    public void outer() {
        orderTxService.generate(); // 프록시 경유
    }
}</code></pre>
<p>이렇게 하면 프록시를 거치기 때문에 트랜잭션이 정상 동작한다.</p>
<h2 id="추가로-많이-헷갈리는-포인트">추가로 많이 헷갈리는 포인트</h2>
<h3 id="1-private과-transactional">1. private과 @Transactional</h3>
<pre><code class="language-java">@Transactional
private void save() {}</code></pre>
<p>동작하지 않는다.
프록시가 가로챌 수 없기 때문이다.</p>
<h3 id="2-예외를-try-catch로-삼켜버린-경우">2. 예외를 try-catch로 삼켜버린 경우</h3>
<pre><code class="language-java">@Transactional
public void save() {
    try {
        throw new RuntimeException();
    } catch (Exception e) {
        // 아무것도 안 함
    }
}</code></pre>
<p>예외가 밖으로 던져지지 않으면 롤백되지 않는다.</p>
<h3 id="3-checked-exception">3. Checked Exception</h3>
<pre><code class="language-java">@Transactional(rollbackFor = Exception.class)</code></pre>
<p>Checked Exception은 기본적으로 롤백되지 않는다.
명시적으로 설정해야 한다.</p>
<h1 id="정리-및-마무리">정리 및 마무리</h1>
<p>Spring에서 <code>@Transactional</code>이 안 먹는 대표적인 이유는 다음과 같다.</p>
<ol>
<li>같은 클래스 내부 호출 (Self Invocation)</li>
<li>private 메서드</li>
<li>예외를 삼켜버림</li>
<li>Checked Exception</li>
</ol>
<p>그중 가장 많이 터지는 원인은 Self Invocation이다.</p>
<blockquote>
<p>@Transactional이 안 먹는 게 아니라 프록시를 타지 못하고 있었던 것이다.
트랜잭션 문제는 코드 한 줄의 문제가 아니라 호출 구조의 문제인 경우가 많다.
실무에서 한 번쯤은 반드시 겪게 되는 이슈다.</p>
</blockquote>
<p>같은 삽질을 반복하지 않기를.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프로젝트 리뉴얼에서 DTO 설계 기준을 다시 잡은 이유]]></title>
            <link>https://velog.io/@fever-max/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC%EC%97%90%EC%84%9C-DTO-%EC%84%A4%EA%B3%84-%EA%B8%B0%EC%A4%80%EC%9D%84-%EB%8B%A4%EC%8B%9C-%EC%9E%A1%EC%9D%80-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@fever-max/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC%EC%97%90%EC%84%9C-DTO-%EC%84%A4%EA%B3%84-%EA%B8%B0%EC%A4%80%EC%9D%84-%EB%8B%A4%EC%8B%9C-%EC%9E%A1%EC%9D%80-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 03 Feb 2026 06:30:57 GMT</pubDate>
            <description><![CDATA[<p>리뉴얼 프로젝트에 들어가기 전까지는
DTO를 그저 DB 결과를 담는 그릇 정도로 생각했다.
그런데 레거시 시스템을 유지한 채 구조를 바꾸는 대규모 리뉴얼을 겪으면서,
DTO가 생각보다 훨씬 많은 역할을 하고 있다는 걸 체감하게 됐다.</p>
<blockquote>
<p>대규모 리뉴얼 프로젝트를 겪으면서 DTO 설계 기준을 다시 세우게 된 이유를 정리한 기록이다.</p>
</blockquote>
<h3 id="1-resultmap-중심-설계의-한계">1. resultMap 중심 설계의 한계</h3>
<p>기존 구조에서는 resultMap이 거의 모든 걸 책임지고 있었다. (진짜 보기 힘든 개쓰레기 코드들의 향연...)</p>
<blockquote>
<p>문제1. 컬럼명 ↔ 필드명 매핑이 각기 다름
문제2. 타입이 뭔지 알아 볼 수가 없음
문제3. 중첩 객체 구성 (맵안에서 맵을 쓰는 기괴한...)</p>
</blockquote>
<p>처음엔 유연해 보였지만, 시간이 지나면서 문제가 드러났다.
특히 아래 3가지 문제가 심각했다.</p>
<blockquote>
<p>심각한 문제1. resultMap 하나 수정하면 여러 쿼리에 영향
심각한 문제2. DTO 구조를 한눈에 파악하기 어려움
심각한 문제3. 실제로 어떤 데이터가 내려오는지 추적이 힘듦 </p>
</blockquote>
<p>특히 3번 때문에 봤던 코드를 몇번이나 다시 봤는지 모른다.
이렇게 지독한 코드 다시보기를 체험하니 마음이 단호해졌다.</p>
<p>매핑 로직이 많아질수록 DTO는 ‘보이지 않는 객체’가 된다.
그래서 resultMap 의존도를 줄이고, DTO를 중심으로 쿼리와 매핑을 맞추는 방향으로 기준을 바꾸기로 다짐했다. (제발)</p>
<h3 id="2-dto는-조회용과-행위용을-섞으면-안-된다">2. DTO는 “조회용”과 “행위용”을 섞으면 안 된다.</h3>
<p>리뉴얼 초기에 가장 많이 봤던 패턴이 이거였다.</p>
<blockquote>
<p>조회 결과를 담는 DTO
저장/수정 요청을 받는 DTO
계산 결과까지 담기 시작한 DTO</p>
</blockquote>
<p>이게 한 클래스에 섞이기 시작하면 DTO는 금방 비대해진다.
왜냐하면 a테이블, b테이블에거 갖고온 모든 데이터들이 짬뽕처럼 섞인 김치짬뽕우웩탕이 탄생되기 때문...</p>
<p>사실 제일 큰 문제는 크기보다 역할이 모호해진다는 점이었다.
그래서 기준을 단순하게 잡았다.</p>
<blockquote>
<p>조회 DTO: 화면이나 API에 내려줄 데이터만
요청 DTO: 입력값 검증 중심
내부 계산용 데이터는 별도 객체로 분리</p>
</blockquote>
<p>DTO는 상태를 담는 객체이지, 비즈니스 판단을 하는 객체가 아니니까! 절대로 db를 그대로 때려박지 않으려고 노력했다.</p>
<h3 id="3-날짜와-금액-타입은-타협하지-않기로-했다">3. 날짜와 금액 타입은 타협하지 않기로 했다.</h3>
<p>레거시에서는 이런 타입들이 섞여 있었다.</p>
<blockquote>
<p>날짜: String, Date, Timestamp 혼용
금액: int, long, double 혼재</p>
</blockquote>
<p>리뉴얼 과정에서 이걸 그대로 가져가면,
나중에 버그가 나는 지점을 예측하기가 어려웠다.
특히 가격 계산이 중요한 프로젝트인데 숫자들이 아주 지멋대로라 굉장히 곤란...</p>
<p>그래서 DTO 기준을 아예 고정했다.</p>
<blockquote>
<p>날짜/시간: LocalDateTime
금액/비율(정수, 소수): Integer, BigDecimal</p>
</blockquote>
<p>변환 시간이 조금 들더라도 의미가 명확한 타입을 쓰는 게 유지보수에서는 훨씬 싸다는 결론이었다. 그게 조금 더 확실한 계산이 가능하니까!</p>
<h3 id="5-dto가-바뀌면-로직도-같이-흔들린다">5. DTO가 바뀌면 로직도 같이 흔들린다.</h3>
<p>리뉴얼을 하면서 가장 크게 느끼고 걱정했던 점이 이거였다.</p>
<p>DTO는 단순하지만 서비스 구조를 나타내는 설계의 일부분이다.
DTO 필드가 하나 추가될 때마다 로직이 늘어나고 검증이 생기고 테스트를 해야한다.</p>
<p>그래서 DTO를 추가할 때마다 이 필드가 정말 있어야 하는지 고민을 많이많이많이 했다. 
내 선택에 후회가 없기를. (물론 이렇게 하고 다음날에 후회해서 몇백줄 바꾸고 했음;)</p>
<h3 id="마무리">마무리</h3>
<p>리뉴얼 프로젝트를 하면서 깨달은 건 하나다.</p>
<p>DTO는 빨리 만들수록 좋지만,
아무 기준 없이 만들면 가장 오래 발목을 잡는다.</p>
<p>이후로는 DTO를 만들 때 항상 이 질문을 먼저 던진다.</p>
<blockquote>
<p>이 DTO의 역할은 무엇인가
이 데이터는 언제까지 유효한가
서비스 로직과 얼마나 강하게 결합되는가</p>
</blockquote>
<p>DTO 설계 기준을 정리한 것만으로도
리뉴얼 이후 코드의 흔들림은 확실히 줄어들었다.
특히 도메인을 바꾼다던가 이상한 짓은 절대 하지 않기로 다짐했다. </p>
<p>이상... 프로젝트 8개를 한번에 리뉴얼한 개발자의 일기... 끝. (문제는 아직도 테스트 중이다.)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[insert에서 객체를 재사용해도 될까?]]></title>
            <link>https://velog.io/@fever-max/insert%EC%97%90%EC%84%9C-%EA%B0%9D%EC%B2%B4%EB%A5%BC-%EC%9E%AC%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EB%90%A0%EA%B9%8C</link>
            <guid>https://velog.io/@fever-max/insert%EC%97%90%EC%84%9C-%EA%B0%9D%EC%B2%B4%EB%A5%BC-%EC%9E%AC%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EB%90%A0%EA%B9%8C</guid>
            <pubDate>Tue, 06 Jan 2026 02:58:17 GMT</pubDate>
            <description><![CDATA[<p>실무에서 insert 로직을 작성하다 보면 이런 고민을 하게 된다.</p>
<blockquote>
<p>“어차피 값 몇 개만 바꿔서 한 번 더 insert 하는 건데<br>객체를 새로 만들지 않고 재사용해도 되지 않을까?”</p>
</blockquote>
<p>결론부터 말하면 <strong>insert에서 객체 재사용은 피하는 게 맞다</strong>.  
특히 이력성·동의성 데이터라면 더더욱 그렇다.</p>
<hr>
<h2 id="문제-상황">문제 상황</h2>
<p>아래는 하나의 요청에서 두 종류의 동의 데이터를 저장하는 상황을 단순화한 예시다.
먼저 기본 동의를 저장하고, 조건에 따라 추가 동의를 한 번 더 저장한다.</p>
<pre><code class="language-java">Agreement agre = new Agreement();

// 공통 값 세팅
agre.setOrderId(orderId);
agre.setUserType(&quot;MAIN&quot;);
agre.setCreatedAt(now);
// 기본 동의
agre.setAgreeItems(baseAgreeList);
baseAgre.setExtraYn(&quot;N&quot;);
mapper.insert(agre);

// 추가 동의
if (!extraAgreeList.isEmpty()) {
    agre.setAgreeItems(extraAgreeList);
    baseAgre.setExtraYn(&quot;Y&quot;);
    mapper.insert(agre);
}</code></pre>
<p>겉보기에는 문제가 없어 보이고, 실제로도 정상적으로 insert는 수행된다.
하지만 이 구조에는 몇 가지 근본적인 문제가 있다.</p>
<h2 id="문제점">문제점</h2>
<h3 id="1-insert인데-update처럼-보인다">1. insert인데 update처럼 보인다</h3>
<p>같은 객체를 수정해서 다시 insert 하면 코드를 읽는 입장에서는 자연스럽게 이런 의문이 든다.</p>
<blockquote>
<p>이 객체는 이미 DB에 들어간 상태 아닌가?
PK나 식별 값은 어디서 관리되는가?
update와 무엇이 다른가?</p>
</blockquote>
<p>의도가 코드에 드러나지 않아 이해 비용이 커진다.</p>
<h3 id="2-값-오염-가능성">2. 값 오염 가능성</h3>
<p>현재는 필드가 단순해서 괜찮아 보일 수 있다.
하지만 시간이 지나면서 다음과 같은 변화가 생긴다.</p>
<blockquote>
<p>컬럼 추가
insert 조건 분기 증가
기본값 세팅 로직 변경</p>
</blockquote>
<p>이 순간부터 이전 insert의 값이 다음 insert에 섞여 들어갈 위험이 생긴다.</p>
<h3 id="3-이력성-데이터에겐-최악">3. 이력성 데이터에겐 최악</h3>
<p>동의, 로그, 이력 테이블은 “언제, 무엇이, 왜 저장됐는지”가 중요하다.
객체 하나를 계속 수정해서 여러 row를 만드는 구조는
장애 분석과 디버깅 시 추적 난이도를 크게 높인다.</p>
<h2 id="권장-방식">권장 방식</h2>
<p>원칙은 단순하다.</p>
<blockquote>
<p>insert = 객체 1개 = row 1개</p>
</blockquote>
<p>공통 세팅은 메서드로 분리하고, 각 insert마다 새 객체를 생성한다.</p>
<pre><code class="language-java">private Agreement baseAgreement(String orderId, LocalDateTime now) {
    Agreement agre = new Agreement();
    agre.setOrderId(orderId);
    agre.setUserType(&quot;MAIN&quot;);
    agre.setCreatedAt(now);
    return agre;
}</code></pre>
<pre><code class="language-java">// 기본 동의
Agreement baseAgre = baseAgreement(orderId, now);
baseAgre.setAgreeItems(baseAgreeList);
baseAgre.setExtraYn(&quot;N&quot;);
mapper.insert(baseAgre);

// 추가 동의
if (!extraAgreeList.isEmpty()) {
    Agreement extraAgre = baseAgreement(orderId, now);
    extraAgre.setAgreeItems(extraAgreeList);
    extraAgre.setExtraYn(&quot;Y&quot;);
    mapper.insert(extraAgre);
}</code></pre>
<h2 id="정리">정리</h2>
<p>insert 로직에서 객체 재사용은 당장은 코드가 짧아 보일 수 있지만, 시간이 지날수록 유지보수 비용이 커진다. 특히 동의·로그·이력 데이터라면 객체를 아끼기보다 명확한 구조를 선택하는 게 맞다.</p>
<blockquote>
<p>insert는 항상 “이 객체는 하나의 row만 책임진다” 라는
원칙을 지키는 것이 가장 안전하다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Tomcat] war 중복 배포 방지 (스케줄러 중복 실행 문제)]]></title>
            <link>https://velog.io/@fever-max/Tomcat%EC%97%90%EC%84%9C-war-%EC%A4%91%EB%B3%B5-%EB%B0%B0%ED%8F%AC-%EB%B0%A9%EC%A7%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EC%A4%91%EB%B3%B5-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@fever-max/Tomcat%EC%97%90%EC%84%9C-war-%EC%A4%91%EB%B3%B5-%EB%B0%B0%ED%8F%AC-%EB%B0%A9%EC%A7%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EC%A4%91%EB%B3%B5-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Wed, 06 Aug 2025 01:11:18 GMT</pubDate>
            <description><![CDATA[<h2 id="문제">문제</h2>
<p>로컬에선 문제 없던 스케줄러가 톰캣에 올리면 중복 실행되는 문제 발생했다.
아래는 기존 server.xml 설정이다.</p>
<pre><code class="language-xml">&lt;Host name=&quot;localhost&quot; appBase=&quot;webapps&quot; unpackWARs=&quot;true&quot; autoDeploy=&quot;true&quot;&gt;  
&lt;Context docBase=&quot;test&quot; path=&quot;/&quot; reloadable=&quot;true&quot; /&gt;</code></pre>
<h2 id="원인">원인</h2>
<pre><code class="language-bash">/webapps/
├── test.war
├── test/          ← test.war가 자동으로 풀리면서 생성됨
├── ROOT/          ← 기본 context</code></pre>
<p>톰캣이 실행되면 webapps안에 test 폴더와 ROOT 폴더가 생기는데,
결과적으로 동일 WAR의 인스턴스가 중복 실행되어 @Scheduled 사용시 2번 실행된다.</p>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1-autodeploy-deployonstartup비활성화">1. <code>autoDeploy</code>, <code>deployOnStartup</code>비활성화</h3>
<pre><code class="language-xml">&lt;Host name=&quot;localhost&quot; appBase=&quot;webapps&quot; unpackWARs=&quot;true&quot; autoDeploy=&quot;false&quot; deployOnStartup=&quot;false&quot;&gt;  
&lt;Context docBase=&quot;test&quot; path=&quot;/&quot; reloadable=&quot;true&quot; /&gt;</code></pre>
<p>autoDeploy=&quot;false&quot;
→ WAR 파일이 있어도 자동 배포 안 함</p>
<p>deployOnStartup=&quot;false&quot;
→ 톰캣 기동 시 WAR 자동 전개 안 함</p>
<p><code>&lt;Context&gt;</code>에 명시된 docBase=&quot;test&quot;만 명확하게 실행되며 test.war가 자동으로 풀려서 test/ 폴더 생기는 것도 방지된다. </p>
<h3 id="2-appbase-자체를-별도로-설정">2. <code>appBase</code> 자체를 별도로 설정</h3>
<pre><code class="language-xml">&lt;Host name=&quot;localhost&quot; appBase=&quot;webapps/test&quot; unpackWARs=&quot;true&quot; autoDeploy=&quot;true&quot;&gt;  
&lt;Context docBase=&quot;test&quot; path=&quot;/&quot; reloadable=&quot;true&quot; /&gt;</code></pre>
<p>webapps/test라는 별도의 디렉토리를 톰캣 배포 루트(appBase)로 지정하여 해당 경로에 test.war만 존재하도록 관리한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Filebeat > Logstash > Elasticsearch> Kibana로그 수집 시스템 구축 가이드]]></title>
            <link>https://velog.io/@fever-max/Filebeat-Logstash-Elasticsearch-Kibana%EB%A1%9C%EA%B7%B8-%EC%88%98%EC%A7%91-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@fever-max/Filebeat-Logstash-Elasticsearch-Kibana%EB%A1%9C%EA%B7%B8-%EC%88%98%EC%A7%91-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Mon, 21 Jul 2025 05:05:54 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/fever-max/post/5f68a0fa-7407-48c3-a336-2193c5f2ee9b/image.png" alt=""></p>
<h2 id="📌-구성-요소">📌 구성 요소</h2>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할 설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Elasticsearch</strong></td>
<td>로그 데이터를 저장하고 검색할 수 있는 중앙 저장소</td>
</tr>
<tr>
<td><strong>Kibana</strong></td>
<td>Elasticsearch의 데이터를 시각화하는 웹 UI</td>
</tr>
<tr>
<td><strong>Logstash</strong></td>
<td>로그를 수신하고 가공한 후 Elasticsearch로 전달</td>
</tr>
<tr>
<td><strong>Filebeat</strong></td>
<td>서버의 로그 파일을 수집해 Logstash로 전달</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔁-가동-순서">🔁 가동 순서</h2>
<ol>
<li><strong>Elasticsearch</strong></li>
<li><strong>Kibana</strong></li>
<li><strong>Logstash</strong></li>
<li><strong>Filebeat</strong></li>
</ol>
<blockquote>
<p>⛔ 종료 시에는 반대 순서로: Filebeat → Logstash → Kibana → Elasticsearch</p>
</blockquote>
<hr>
<h2 id="elasticsearch">Elasticsearch</h2>
<h3 id="1-설치">1. 설치</h3>
<pre><code class="language-shell"># Elasticsearch 컨테이너 실행
docker run -d \
  --name elasticsearch \
  -p 9200:9200 \
  -e discovery.type=single-node \
  -e ELASTIC_PASSWORD=**** \
  -e xpack.security.enabled=true \
  -e xpack.security.authc.api_key.enabled=true \
  -e ES_JAVA_OPTS=&quot;-Xms512m -Xmx512m&quot; \
  -v esdata:/usr/share/elasticsearch/data \
  docker.elastic.co/elasticsearch/elasticsearch:9.0.3

# 접속 주소: http://localhost:9200
# 기본 관리자 계정: elastic
# 비밀번호: ELASTIC_PASSWORD 설정
# 실행 모드: 단일 노드 (discovery.type=single-node)
# Kibana, Logstash, Filebeat 모두 이 주소로 연결됨</code></pre>
<h2 id="kibana">Kibana</h2>
<h3 id="1-설치-1">1. 설치</h3>
<pre><code class="language-shell">docker run -d \
  --name kibana \
  -p 5601:5601 \
  -e ELASTICSEARCH_HOSTS=http://elasticsearch:9200 \
  -e ELASTICSEARCH_USERNAME=kibana_system \
  -e ELASTICSEARCH_PASSWORD=***** \
  -e xpack.security.encryptionKey=**** \
  -e xpack.encryptedSavedObjects.encryptionKey=**** \
  -e xpack.reporting.encryptionKey=**** \
  docker.elastic.co/kibana/kibana:9.0.3</code></pre>
<h2 id="logstash">Logstash</h2>
<h3 id="1-설치-2">1. 설치</h3>
<pre><code class="language-shell">docker run -d \
  --name logstash \
  -p 5044:5044 \
  -e xpack.monitoring.elasticsearch.username=elastic \
  -e xpack.monitoring.elasticsearch.password=**** \
  -e xpack.monitoring.elasticsearch.hosts=http://elasticsearch:9200 \
  docker.elastic.co/logstash/logstash:9.0.3</code></pre>
<h3 id="2-설정">2. 설정</h3>
<pre><code class="language-shell">docker exec -it logstash /bin/bash
cd /usr/share/logstash/pipeline
vi logstash.conf</code></pre>
<pre><code class="language-conf">input {
  beats {
    port =&gt; 5044
  }
}

filter {
  json {
    source =&gt; &quot;message&quot;
    remove_field =&gt; [&quot;message&quot;]
    tag_on_failure =&gt; [&quot;_jsonparsefailure&quot;]
  }
}

output {
  elasticsearch {
    hosts =&gt; [&quot;http://elasticsearch:9200&quot;]
    user =&gt; &quot;elastic&quot;
    password =&gt; &quot;****&quot;
    index =&gt; &quot;log-%{project}-%{+YYYY.MM.dd}&quot;
  }

  stdout {
    codec =&gt; rubydebug
  }
}</code></pre>
<h2 id="filebeat">Filebeat</h2>
<h3 id="1-설치-3">1. 설치</h3>
<pre><code class="language-shell">curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-8.18.3-amd64.deb
sudo dpkg -i filebeat-8.18.3-amd64.deb
cd /etc/filebeat
vi filebeat.yml</code></pre>
<h3 id="2-설정-1">2. 설정</h3>
<pre><code class="language-yml">filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /path/to/your/logs/app.log
    fields:
      project: your-project-name
    fields_under_root: true

output.logstash:
  hosts: [&quot;your-logstash-host:5044&quot;]</code></pre>
<pre><code class="language-shell">sudo chown root:root filebeat.yml
sudo chmod 644 filebeat.yml</code></pre>
<h3 id="3-실행">3. 실행</h3>
<pre><code class="language-shell"># 서비스 등록
sudo systemctl enable filebeat
# 시작
sudo systemctl start filebeat
# 시작
sudo systemctl stop filebeat
# 재시작 
sudo systemctl restart filebeat
# 로깅
sudo journalctl -u filebeat -f</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Docker] Nginx와 Tomcat 연결]]></title>
            <link>https://velog.io/@fever-max/Docker-Nginx%EC%99%80-Tomcat-%EC%97%B0%EA%B2%B0</link>
            <guid>https://velog.io/@fever-max/Docker-Nginx%EC%99%80-Tomcat-%EC%97%B0%EA%B2%B0</guid>
            <pubDate>Thu, 06 Mar 2025 07:34:08 GMT</pubDate>
            <description><![CDATA[<h2 id="1-docker에-nginx-컨테이너-올리기">1. Docker에 Nginx 컨테이너 올리기</h2>
<ul>
<li><p>Nginx 올리기
<code>docker run --name nginx -d -p 80:80 -p 443:443 nginx</code></p>
</li>
<li><p>접속
<code>docker exec -it nginx /bin/bash</code></p>
</li>
<li><p>conf 설정 (include 설정 추가)
<code>vi /etc/nginx/nginx.conf</code>
<img src="https://velog.velcdn.com/images/fever-max/post/29eff7ea-a0b3-485a-95f3-bd38d23a0415/image.png" alt=""></p>
</li>
</ul>
<h2 id="2-docker에-tomcat-java-이미지-설치">2. Docker에 tomcat, java 이미지 설치</h2>
<ul>
<li>원하는 설정에 맞춰서 톰캣과 자바를 설치
<code>docker pull tomcat:10.1.30-jdk17-temurin-noble</code></li>
</ul>
<h2 id="3-docker에-tomcat-컨테이너-올리기">3. Docker에 tomcat 컨테이너 올리기</h2>
<ul>
<li>호스트 포트 및 컨테이너 포트 설정 후 올리기
<code>docker run -d -p 8080:8080 --name tomcat-container tomcat:10.1.30-jdk17-temurin-noble</code></li>
</ul>
<h2 id="4-nginx-conf-설정">4. Nginx conf 설정</h2>
<ul>
<li>includ에 설정한 위치에 설정 conf 생성</li>
<li>포워딩 주소 및 도메인 설정
<img src="https://velog.velcdn.com/images/fever-max/post/89af52d2-da0b-474d-9fdc-3b524a3a085a/image.png" alt=""></li>
</ul>
<p><code>proxy_set_header X-Real-IP $remote_addr;</code>
클라이언트의 실제 IP 주소를 백엔드 서버로 전달
이 설정을 생략하면 백엔드 서버에서 클라이언트의 실제 IP를 알 수 없고, 대신 프록시 서버의 IP 주소만 보이게 되어, 원본 클라이언트의 IP를 추적할 수 없음.</p>
<p><code>proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for</code>
클라이언트의 IP 주소를 X-Forwarded-For 헤더에 추가
이 헤더를 설정하지 않으면, 백엔드 서버가 요청의 출처 IP를 알 수 없게 되어, 클라이언트의 실제 IP를 파악할 수 없음</p>
<p><code>proxy_set_header X-Forwarded-Port $server_port;</code>
요청을 받은 서버의 포트 번호를 X-Forwarded-Port 헤더로 전달
이 설정을 하지 않으면, 백엔드 서버에서 요청이 들어온 포트를 알 수 없어서, 요청이 어떤 포트로 들어왔는지 확인할 수 없음.</p>
<p><code>proxy_set_header X-Forwarded-Proto $scheme;</code>
요청이 HTTPS인지 HTTP인지 구분할 수 있도록 X-Forwarded-Proto 헤더를 전달
이 헤더가 없으면 백엔드 서버는 요청이 HTTP로 전달되었는지 HTTPS로 전달되었는지 알 수 없어서 보안이나 리디렉션 관련 로직에서 문제가 발생할 수 있음</p>
<p><code>proxy_set_header X-Forwarded-Host $Host;</code>
클라이언트가 요청한 원래의 호스트 이름을 X-Forwarded-Host 헤더로 전달
이 헤더가 없으면, 백엔드 서버가 원래 요청된 호스트 이름을 알 수 없어, 동적 도메인 기반 로직이 제대로 작동하지 않을 수 있음 </p>
<p><code>proxy_set_header Host $http_host;</code>
클라이언트가 요청한 호스트를 Host 헤더로 전달하고 $http_host는 클라이언트의 요청에 포함된 호스트 정보를 나타냄
이 설정을 하지 않으면, 백엔드 서버가 요청에 대한 정확한 호스트를 알 수 없고, 이는 다중 도메인을 사용하는 시스템에서 중요한 정보를 잃게 될 수 있음</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] 클러스터 (Cluster) 개념 및 종류]]></title>
            <link>https://velog.io/@fever-max/Linux-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-Cluster-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EC%A2%85%EB%A5%98</link>
            <guid>https://velog.io/@fever-max/Linux-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0-Cluster-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EC%A2%85%EB%A5%98</guid>
            <pubDate>Tue, 03 Sep 2024 14:17:37 GMT</pubDate>
            <description><![CDATA[<h2 id="💻-클러스터cluster">💻 클러스터(Cluster)</h2>
<p>여러 개의 독립적인 컴퓨터나 서버가 네트워크를 통해 연결되어 하나의 시스템처럼 작동하도록 구성된 시스템. 클러스터의 주요 목적은 성능 향상, 신뢰성 증대, 또는 두 가지 모두를 달성하는 것. 클러스터는 여러 형태가 있으며, 각 형태는 특정 용도와 요구 사항에 맞게 설계된다.</p>
<h2 id="📌-클러스터cluster-종류">📌 클러스터(Cluster) 종류</h2>
<table>
<thead>
<tr>
<th>클러스터 유형</th>
<th>목적</th>
<th>구성</th>
<th>주요 기술</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>고가용성 클러스터 (High-Availability Cluster)</td>
<td>시스템의 지속적인 가용성을 보장하고 장애 발생 시 자동 전환</td>
<td>여러 노드가 중복되어 있으며, 하나의 노드가 실패 시 다른 노드가 대체</td>
<td>페일오버(failover), 복제(redundancy)</td>
<td>웹 서버 클러스터, 데이터베이스 클러스터</td>
</tr>
<tr>
<td>베어울프 클러스터 (Beowulf Cluster)</td>
<td>고성능 컴퓨팅을 위한 클러스터, 연구 및 과학적 계산에 사용</td>
<td>일반 상용 하드웨어를 사용하여 네트워크로 연결된 저비용 클러스터</td>
<td>병렬 처리(parallel processing), 메시지 패싱(message passing)</td>
<td>연구 프로젝트, 데이터 분석</td>
</tr>
<tr>
<td>HPC 클러스터 (High-Performance Computing Cluster)</td>
<td>복잡한 계산 문제를 해결하기 위한 대규모 컴퓨팅 파워 제공</td>
<td>고성능 컴퓨팅 노드와 스토리지 시스템으로 구성, 고속 네트워크로 연결</td>
<td>병렬 컴퓨팅(parallel computing), 고속 네트워크(high-speed networking), 대용량 데이터 처리</td>
<td>기후 모델링, 유전자 분석</td>
</tr>
<tr>
<td>고계산용 클러스터 (High-Throughput Computing Cluster)</td>
<td>대량의 작업을 효율적으로 처리, 전체 작업량의 처리 속도 향상</td>
<td>많은 수의 컴퓨터 노드가 연결되어 있으며, 각 노드는 상대적으로 간단한 작업 처리</td>
<td>작업 스케줄링(job scheduling), 분산 처리(distributed processing)</td>
<td>데이터 처리, 시뮬레이션</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] 시그널(signal) 종류]]></title>
            <link>https://velog.io/@fever-max/Linux-%EC%8B%9C%EA%B7%B8%EB%84%90signal-%EC%A2%85%EB%A5%98</link>
            <guid>https://velog.io/@fever-max/Linux-%EC%8B%9C%EA%B7%B8%EB%84%90signal-%EC%A2%85%EB%A5%98</guid>
            <pubDate>Tue, 03 Sep 2024 13:25:40 GMT</pubDate>
            <description><![CDATA[<h2 id="💻-시그널signal">💻 시그널(signal)</h2>
<p>프로세스 간의 통신 방법 중 하나로 프로세스에 특정 이벤트가 발생했음을 알리기 위해 사용된다. 예를 들어, 시그널을 사용하여 프로세스에 종료, 중지, 일시 정지와 같은 다양한 명령을 전달할 수 있다.</p>
<h2 id="📌-주요-시그널과-명령어">📌 주요 시그널과 명령어</h2>
<table>
<thead>
<tr>
<th>시그널</th>
<th>번호</th>
<th>설명</th>
<th>명령어 및 발생 상황</th>
</tr>
</thead>
<tbody><tr>
<td>SIGHUP</td>
<td>1</td>
<td>터미널이 연결 종료되었음을 프로세스에 알림</td>
<td><code>nohup</code>, 터미널 종료 시</td>
</tr>
<tr>
<td>SIGINT</td>
<td>2</td>
<td>인터럽트 신호, 실행 중인 프로세스를 중지 요청</td>
<td><code>Ctrl+C</code></td>
</tr>
<tr>
<td>SIGKILL</td>
<td>9</td>
<td>강제 종료 신호, 즉시 프로세스 종료</td>
<td><code>kill -9</code></td>
</tr>
<tr>
<td>SIGTERM</td>
<td>15</td>
<td>종료 신호, 정상적으로 프로세스 종료 요청</td>
<td><code>kill</code> (기본), <code>kill -15</code></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] 프린트 명령어]]></title>
            <link>https://velog.io/@fever-max/Linux-%ED%94%84%EB%A6%B0%ED%8A%B8-%EB%AA%85%EB%A0%B9%EC%96%B4</link>
            <guid>https://velog.io/@fever-max/Linux-%ED%94%84%EB%A6%B0%ED%8A%B8-%EB%AA%85%EB%A0%B9%EC%96%B4</guid>
            <pubDate>Tue, 03 Sep 2024 13:21:54 GMT</pubDate>
            <description><![CDATA[<h2 id="💻-리눅스-프린터-명령어">💻 리눅스 프린터 명령어</h2>
<table>
<thead>
<tr>
<th>명령어</th>
<th>설명</th>
<th>사용 예시</th>
<th>주요 옵션</th>
</tr>
</thead>
<tbody><tr>
<td><code>lp</code></td>
<td>프린터에 문서를 인쇄함</td>
<td><code>lp document.txt</code></td>
<td><code>-d printer_name</code> : 프린터 지정</td>
</tr>
<tr>
<td><code>lpr</code></td>
<td>프린터에 문서를 제출함</td>
<td><code>lpr document.txt</code></td>
<td><code>-P printer_name</code> : 프린터 지정</td>
</tr>
<tr>
<td><code>lpstat</code></td>
<td>프린터 상태와 작업 큐를 확인함</td>
<td><code>lpstat -p</code> (프린터 상태 확인)</td>
<td><code>-p</code> : 프린터 상태, <code>-o</code> : 작업 큐 확인</td>
</tr>
<tr>
<td><code>cancel</code></td>
<td>프린터 작업을 취소함</td>
<td><code>cancel 123</code> (작업 ID로 취소)</td>
<td>-</td>
</tr>
<tr>
<td><code>lpadmin</code></td>
<td>프린터를 관리하고 설정을 변경함</td>
<td><code>lpadmin -p printer_name -E -v device_uri -m model</code> (프린터 추가)</td>
<td><code>-p</code> : 프린터 추가, <code>-x</code> : 프린터 삭제</td>
</tr>
<tr>
<td><code>lpq</code></td>
<td>프린터의 작업 큐를 확인함</td>
<td><code>lpq</code></td>
<td>-</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] 패키지 관리 도구]]></title>
            <link>https://velog.io/@fever-max/Linux-%ED%8C%A8%ED%82%A4%EC%A7%80-%EA%B4%80%EB%A6%AC-%EB%8F%84%EA%B5%AC</link>
            <guid>https://velog.io/@fever-max/Linux-%ED%8C%A8%ED%82%A4%EC%A7%80-%EA%B4%80%EB%A6%AC-%EB%8F%84%EA%B5%AC</guid>
            <pubDate>Mon, 02 Sep 2024 14:52:38 GMT</pubDate>
            <description><![CDATA[<h2 id="💻-온라인-패키지-관리-기법">💻 온라인 패키지 관리 기법</h2>
<p>인터넷을 통해 원격 저장소(리포지토리)에서 패키지를 다운로드하고 설치하는 방법</p>
<ul>
<li>원격 저장소 사용: 패키지 관리 도구가 인터넷에 연결된 저장소에서 최신 패키지 정보를 가져와 패키지를 설치, 업데이트, 제거한다.</li>
<li>의존성 해결: 패키지를 설치할 때 필요한 다른 패키지(의존성)를 자동으로 찾아서 함께 설치한다.</li>
</ul>
<blockquote>
<p><strong>명령어 예시</strong>
레드햇 계열: yum install &lt;패키지&gt;, dnf install &lt;패키지&gt;
데비안 계열: apt-get install &lt;패키지&gt;, apt install &lt;패키지&gt;
SUSE 계열: zypper install &lt;패키지&gt;
Arch Linux: pacman -S &lt;패키지&gt;</p>
</blockquote>
<h2 id="💻-오프라인-패키지-관리-기법">💻 오프라인 패키지 관리 기법</h2>
<p>인터넷 연결 없이 로컬에 저장된 패키지 파일을 직접 설치, 제거, 관리하는 방법</p>
<ul>
<li>로컬 패키지 사용: 이미 다운로드된 패키지 파일을 로컬에서 직접 설치하거나 관리한다.</li>
<li>의존성 해결 필요: 오프라인으로 패키지를 설치할 때는 의존성을 수동으로 해결할 수도 있음</li>
</ul>
<blockquote>
<p><strong>명령어 예시</strong>
레드햇 계열 및 SUSE: rpm -ivh &lt;패키지.rpm&gt;
데비안 계열: dpkg -i &lt;패키지.deb&gt;
Arch Linux: pacman -U &lt;패키지.tar.xz&gt;</p>
</blockquote>
<h2 id="📌-온라인오프라인-패키지-관리기법-구분표">📌 온라인/오프라인 패키지 관리기법 구분표</h2>
<table>
<thead>
<tr>
<th><strong>배포판 계열</strong></th>
<th><strong>온라인 패키지 관리 도구</strong></th>
<th><strong>오프라인 패키지 관리 도구</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>레드햇 계열 (RHEL, CentOS, Fedora)</strong></td>
<td><strong>YUM (Yellowdog Updater, Modified)</strong><br> <code>yum install &lt;패키지&gt;</code> <br> <strong>DNF (Dandified YUM)</strong><br> <code>dnf install &lt;패키지&gt;</code></td>
<td><strong>RPM (Red Hat Package Manager)</strong><br> <code>rpm -ivh &lt;패키지.rpm&gt;</code></td>
</tr>
<tr>
<td><strong>데비안 계열 (Debian, Ubuntu, Linux Mint)</strong></td>
<td><strong>APT (Advanced Package Tool)</strong><br> <code>apt-get install &lt;패키지&gt;</code> <br> <code>apt install &lt;패키지&gt;</code> (새로운 명령어)</td>
<td><strong>DPKG (Debian Package)</strong><br> <code>dpkg -i &lt;패키지.deb&gt;</code></td>
</tr>
<tr>
<td><strong>SUSE 계열 (openSUSE, SLES)</strong></td>
<td><strong>Zypper</strong><br> <code>zypper install &lt;패키지&gt;</code></td>
<td><strong>RPM (Red Hat Package Manager)</strong><br> <code>rpm -ivh &lt;패키지.rpm&gt;</code></td>
</tr>
<tr>
<td><strong>기타 (Arch Linux 등)</strong></td>
<td><strong>Pacman (Arch Linux)</strong><br> <code>pacman -S &lt;패키지&gt;</code></td>
<td><strong>Pacman (Arch Linux)</strong><br> <code>pacman -U &lt;패키지.tar.xz&gt;</code></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Linux] LVM 개념 및 구성 요소]]></title>
            <link>https://velog.io/@fever-max/Linux-LVM-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EA%B5%AC%EC%84%B1-%EC%9A%94%EC%86%8C</link>
            <guid>https://velog.io/@fever-max/Linux-LVM-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EA%B5%AC%EC%84%B1-%EC%9A%94%EC%86%8C</guid>
            <pubDate>Mon, 02 Sep 2024 14:41:55 GMT</pubDate>
            <description><![CDATA[<h2 id="💻-lvm-logical-volume-manager">💻 LVM (Logical Volume Manager)</h2>
<p>리눅스에서 디스크 관리의 유연성을 제공하는 시스템으로 물리적 디스크(Physical Volume, PV)를 그룹으로 묶어 하나의 논리적 볼륨(Logical Volume, LV)을 만들 수 있게 한다. 이 논리적 볼륨은 파일 시스템이 사용하는 공간으로, 물리적인 디스크의 제약에서 벗어나 크기를 동적으로 조절하거나, 디스크를 추가할 수 있는 장점이 있다.</p>
<h2 id="❗️-lvm-주요-구성-요소">❗️ LVM 주요 구성 요소</h2>
<p><strong>Physical Volume (PV)</strong>
실제 물리적인 디스크 또는 디스크 파티션을 의미하며 LVM의 가장 기본 단위다.</p>
<p><strong>Volume Group (VG)</strong>
여러 개의 Physical Volume(PV)을 묶어 하나의 Volume Group(VG)을 만들고, 논리적 볼륨을 생성할 수 있는 공간을 제공한다.</p>
<p><strong>Logical Volume (LV)</strong>
Volume Group(VG)에서 생성된 논리적인 파티션으로 실제 파일 시스템을 생성하고 데이터를 저장할 수 있는 공간으로 사용된다.</p>
<h2 id="⚙️-lvm-사용-순서">⚙️ LVM 사용 순서</h2>
<ul>
<li>PV &gt; VG &gt; LV</li>
</ul>
<table>
<thead>
<tr>
<th><strong>단계</strong></th>
<th><strong>설명</strong></th>
<th><strong>명령어 예시</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>1. Physical Volume (PV) 생성</strong></td>
<td>물리 디스크를 LVM의 PV로 초기화</td>
<td><code>pvcreate /dev/sdX</code></td>
</tr>
<tr>
<td><strong>2. Volume Group (VG) 생성</strong></td>
<td>여러 PV를 묶어 VG를 생성</td>
<td><code>vgcreate my_vg /dev/sda1 /dev/sdb1</code></td>
</tr>
<tr>
<td><strong>3. Logical Volume (LV) 생성</strong></td>
<td>VG에서 논리적 볼륨을 생성</td>
<td><code>lvcreate -L 20G -n my_lv my_vg</code></td>
</tr>
<tr>
<td><strong>4. 파일 시스템 생성</strong></td>
<td>LV에 파일 시스템을 생성</td>
<td><code>mkfs.ext4 /dev/my_vg/my_lv</code></td>
</tr>
<tr>
<td><strong>5. LV 마운트 및 사용</strong></td>
<td>LV를 마운트하여 사용</td>
<td><code>mount /dev/my_vg/my_lv /mnt/data</code></td>
</tr>
<tr>
<td><strong>6. LV 크기 조정</strong></td>
<td>LV의 크기를 조정 (필요시)</td>
<td><strong>확장</strong>: <code>lvextend -L +10G /dev/my_vg/my_lv</code> <br> <strong>축소</strong>: <code>lvreduce -L -5G /dev/my_vg/my_lv</code></td>
</tr>
<tr>
<td><strong>7. 스냅샷 생성</strong></td>
<td>LV의 특정 시점 스냅샷을 생성 (선택사항)</td>
<td><code>lvcreate -s -L 5G -n snap_lv /dev/my_vg/my_lv</code></td>
</tr>
<tr>
<td><strong>8. LV 삭제</strong></td>
<td>LV를 삭제 (필요시)</td>
<td><code>lvremove /dev/my_vg/my_lv</code></td>
</tr>
</tbody></table>
]]></description>
        </item>
    </channel>
</rss>