<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Rockernun.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Wed, 06 May 2026 02:37:53 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Rockernun.log</title>
            <url>https://velog.velcdn.com/images/rocker_nun/profile/f0c0bd50-74e7-4ccf-a1cf-9f8278565deb/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Rockernun.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/rocker_nun" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Spring Bean 순환 참조(Circular Dependency) 문제 해결하기]]></title>
            <link>https://velog.io/@rocker_nun/Spring-Bean-%EC%88%9C%ED%99%98-%EC%B0%B8%EC%A1%B0Circular-Dependency</link>
            <guid>https://velog.io/@rocker_nun/Spring-Bean-%EC%88%9C%ED%99%98-%EC%B0%B8%EC%A1%B0Circular-Dependency</guid>
            <pubDate>Wed, 06 May 2026 02:37:53 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-순환-참조circular-dependency란">🤔 순환 참조(Circular Dependency)란?</h1>
<p>Spring을 공부하다 보면 한 번쯤 이런 에러를 만나게 된다.</p>
<pre><code class="language-text">***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  AService defined in file [/Users/byeonguk/Desktop/egov-vscode2026/circular-reference-demo/target/classes/com/example/circular/bad/AService.class]
↑     ↓
|  BService defined in file [/Users/byeonguk/Desktop/egov-vscode2026/circular-reference-demo/target/classes/com/example/circular/bad/BService.class]
└─────┘

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name &#39;AService&#39;: Requested bean is currently in creation: Is there an unresolvable circular reference?</code></pre>
<p>또는 Spring Boot 2.6 이상 환경에서는 애플리케이션 실행 중 다음과 비슷한 메시지를 볼 수 있다.</p>
<pre><code class="language-text">The dependencies of some of the beans in the application context form a cycle</code></pre>
<p>처음 이 에러를 보면 단순히 <em>“Bean 주입이 꼬였나?”</em> 정도로 생각할 수 있다. 하지만 Spring Bean 순환 참조는 단순한 설정 오류가 아니다. 오히려 이 에러는 애플리케이션 내부의 객체들이 서로 너무 강하게 얽혀 있고, 책임 분리가 제대로 되어 있지 않다는 신호에 가깝다.</p>
<p>이번 글에서는 Spring Bean 순환 참조가 무엇인지, 왜 발생하는지, Spring 컨테이너 내부에서는 어떤 일이 일어나는지, 그리고 실무에서는 어떻게 해결해야 하는지 정리해보려고 한다.</p>
<p>&nbsp;</p>
<h2 id="🫛-spring-bean-이해하기">🫛 Spring Bean 이해하기</h2>
<p>순환 참조를 이해하려면 먼저 <strong>Spring Bean</strong>이 무엇인지부터 정리해야 한다. 알디시피 Spring에서는 개발자가 직접 객체를 생성하고 관리하기보다, Spring 컨테이너가 객체의 생성과 의존성 주입을 대신 관리한다.</p>
<p>예를 들어 다음과 같은 클래스가 있다고 하자.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}</code></pre>
<p><code>OrderService</code>는 직접 <code>new PaymentService()</code>를 호출하지 않는다. 대신 생성자를 통해 <code>PaymentService</code>를 주입받는다. 이때 <code>OrderService</code>와 <code>PaymentService</code> 같은 객체를 Spring 컨테이너가 관리하면 이를 <strong>Bean</strong>이라고 부른다.</p>
<p>Spring 컨테이너는 애플리케이션이 시작될 때 <code>@Component</code>, <code>@Service</code>, <code>@Repository</code>, <code>@Controller</code>, <code>@Configuration</code> 같은 어노테이션이 붙은 클래스를 스캔한다. 그리고 이 클래스들을 기반으로 Bean을 생성하고, 필요한 의존성을 자동으로 연결해준다. </p>
<p>이것이 바로 Spring의 핵심 개념인 <strong>IoC(Inversion of Control, 제어의 역전)</strong>와 <strong>DI(Dependency Injection, 의존성 주입)</strong>이다. 개발자가 객체 생성과 연결을 직접 제어하는 것이 아니라, Spring 컨테이너가 객체의 생명주기와 의존 관계를 관리하는 것이다.</p>
<p>&nbsp;</p>
<h2 id="🔄-순환-참조란-무엇인가">🔄 순환 참조란 무엇인가?</h2>
<p>순환 참조는 이름 그대로 <strong>의존성의 흐름이 원처럼 순환하는 구조</strong>를 말한다.</p>
<p>가장 단순한 예시는 다음과 같다.</p>
<pre><code class="language-text">A → B → A</code></pre>
<p>A는 B가 필요하고, B는 다시 A가 필요한 구조다.</p>
<p>&nbsp;</p>
<p>코드로 보면 다음과 같다.</p>
<pre><code class="language-java">@Service
public class AService {
    private final BService bService;

    public AService(BService bService) {
        this.bService = bService;
    }
}</code></pre>
<pre><code class="language-java">@Service
public class BService {
    private final AService aService;

    public BService(AService aService) {
        this.aService = aService;
    }
}</code></pre>
<p><code>AService</code>를 만들려면 <code>BService</code>가 필요하다. 그런데 <code>BService</code>를 만들려면 다시 <code>AService</code>가 필요하다.</p>
<p>&nbsp;</p>
<p>Spring 컨테이너 입장에서는 이런 상황이 된다.</p>
<ol>
<li>_&quot;AService를 만들려고 했더니 BService가 필요하네?&quot; _ </li>
<li><em>&quot;그럼 BService를 먼저 만들자.&quot;</em></li>
<li>_&quot;그런데 BService를 만들려고 했더니 AService가 필요하네?&quot;  _</li>
<li>_&quot;다시 AService를 만들어야 하나?&quot;  _</li>
<li><em>&quot;그런데 AService를 만들려면 BService가 필요한데?&quot;</em></li>
</ol>
<p>결국 누구를 먼저 만들어야 하는지 결정할 수 없는 상황, 이것이 바로 <strong>Spring Bean 순환 참조</strong>다. <em>“닭이 먼저냐, 달걀이 먼저냐”</em> 문제와 아주 비슷하다. A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하다. 둘 중 하나가 먼저 완성되어야 다른 하나를 만들 수 있는데, 둘 다 서로가 먼저 필요하다고 말하는 상황이다.</p>
<p>&nbsp;</p>
<h2 id="💣-생성자-주입에서-순환-참조가-더-치명적인-이유">💣 생성자 주입에서 순환 참조가 더 치명적인 이유</h2>
<p>Spring에서는 의존성을 주입하는 방식이 여러 가지 있다.</p>
<p>대표적으로 <strong>생성자 주입(Constructor Injection)</strong>, <strong>세터 주입(Setter Injection)</strong>, <strong>필드 주입(Field Injection)</strong>가 있다. 이 중 현대 Spring 개발에서는 생성자 주입이 가장 권장된다. 생성자 주입은 필수 의존성을 객체 생성 시점에 반드시 전달받도록 강제할 수 있다. 또한 필드를 <code>final</code>로 선언할 수 있어 객체의 불변성을 지키기 좋고, 테스트 코드 작성도 쉽다.</p>
<p>&nbsp;</p>
<p>하지만 순환 참조 상황에서는 생성자 주입이 가장 빠르게 문제를 드러낸다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) {
        this.userService = userService;
    }
}</code></pre>
<pre><code class="language-java">@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) {
        this.orderService = orderService;
    }
}</code></pre>
<p><code>OrderService</code> 객체를 생성하려면 생성자 인자로 <code>UserService</code>가 필요하다. 그런데 <code>UserService</code> 객체를 생성하려면 생성자 인자로 <code>OrderService</code>가 필요하다.</p>
<p>생성자 주입에서는 객체가 완성되기 전에 필요한 의존성이 모두 준비되어 있어야 한다. 따라서 서로가 서로의 생성자 인자로 필요한 상황에서는 객체를 하나도 완성할 수 없다.</p>
<p>그래서 생성자 주입 기반의 순환 참조는 Spring 컨테이너가 해결하기 어렵고, 애플리케이션 시작 단계에서 바로 예외가 발생한다. 불편해 보일 수 있지만, 사실 이건 좋은 신호이기도 하다. 생성자 주입은 잘못된 의존 관계를 애플리케이션 실행 초기에 명확히 드러내기 때문이다.</p>
<p>&nbsp;</p>
<h2 id="💉-세터-주입과-필드-주입">💉 세터 주입과 필드 주입</h2>
<p>과거 Spring Boot 2.5 이하 환경에서는 세터 주입이나 필드 주입을 사용하면 순환 참조가 동작하는 경우가 있었다.</p>
<p>예를 들어 다음과 같은 코드다.</p>
<pre><code class="language-java">@Service
public class AService {
    @Autowired
    private BService bService;
}</code></pre>
<pre><code class="language-java">@Service
public class BService {
    @Autowired
    private AService aService;
}</code></pre>
<p>생성자 주입과 달리 필드 주입은 객체를 먼저 만든 뒤 나중에 필드를 채운다.</p>
<p>&nbsp;</p>
<p>즉, Spring 컨테이너는 다음과 같은 방식으로 처리할 수 있다.</p>
<ol>
<li>AService 객체를 일단 만든다.</li>
<li>아직 bService 필드는 비어 있다.</li>
<li>BService 객체를 만든다.</li>
<li>BService에 AService의 미완성 참조를 넣는다.</li>
<li>BService 생성이 끝난다.</li>
<li>다시 AService로 돌아와 bService 필드를 채운다.</li>
</ol>
<p>이런 방식이 가능했던 이유는 Spring 내부에 <strong>3단계 캐시 메커니즘</strong>이 있었기 때문이다.</p>
<p>&nbsp;</p>
<h3 id="🏰-spring의-3단계-캐시-메커니즘">🏰 Spring의 3단계 캐시 메커니즘</h3>
<p>Spring은 싱글톤 Bean을 생성하고 관리하기 위해 내부적으로 여러 캐시를 사용한다. 순환 참조와 관련해서 자주 언급되는 것이 바로 <strong>3단계 캐시</strong>다.</p>
<table>
<thead>
<tr>
<th>캐시 단계</th>
<th>내부 변수명</th>
<th>저장 대상</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>1단계 캐시</td>
<td><code>singletonObjects</code></td>
<td>완전히 생성된 Bean</td>
<td>인스턴스화, 의존성 주입, 초기화까지 끝난 정상 Bean</td>
</tr>
<tr>
<td>2단계 캐시</td>
<td><code>earlySingletonObjects</code></td>
<td>조기 참조 Bean</td>
<td>아직 완전히 초기화되지는 않았지만 참조 가능한 미완성 Bean</td>
</tr>
<tr>
<td>3단계 캐시</td>
<td><code>singletonFactories</code></td>
<td>ObjectFactory</td>
<td>필요할 때 조기 참조 객체를 만들어 반환할 수 있는 팩토리</td>
</tr>
</tbody></table>
<p>이 구조를 아주 단순하게 설명하면 다음과 같다.</p>
<p>Spring은 A Bean을 만들기 시작하면, 아직 완전히 완성되지 않았더라도 “A라는 객체의 참조를 나중에 꺼낼 수 있는 통로”를 3단계 캐시에 등록해둔다.</p>
<p>이후 B Bean을 만들다가 A Bean이 필요해지면, Spring은 완성된 A Bean이 없더라도 3단계 캐시에서 A의 조기 참조를 꺼내 B에 주입한다.</p>
<p>그 후 B가 완성되면 다시 A로 돌아와 B를 주입하고 A도 완성한다.</p>
<p>즉, Spring은 Java 객체가 참조 타입이라는 특성을 이용해서 미완성 객체의 참조를 임시로 넘겨주는 방식으로 일부 순환 참조를 해결해왔다. 하지만 이 방식은 어디까지나 제한적인 우회 방식이다.</p>
<p>&nbsp;</p>
<h3 id="❓-aop가-끼어들면-왜-더-복잡해질까">❓ AOP가 끼어들면 왜 더 복잡해질까?</h3>
<p>Spring 애플리케이션에서는 <code>@Transactional</code>, <code>@Async</code> 같은 기능을 자주 사용한다. 이런 기능은 Spring AOP를 통해 동작한다. Spring은 원본 객체를 그대로 사용하는 것이 아니라, 원본 객체를 감싼 <strong>프록시 객체</strong>를 만들어 사용한다.</p>
<p>예를 들어 <code>OrderService</code>에 <code>@Transactional</code>이 붙어 있다면, Spring 컨테이너가 관리하는 실제 Bean은 순수한 <code>OrderService</code> 객체가 아니라 트랜잭션 기능이 적용된 프록시 객체일 수 있다.</p>
<p>문제는 순환 참조 상황에서 조기 참조로 노출된 객체와 최종적으로 컨테이너에 등록되는 객체가 달라질 수 있다는 점이다.</p>
<p>예를 들어 B Bean이 A Bean의 조기 참조를 주입받았는데, 그 시점의 A는 아직 프록시가 적용되기 전의 원본 객체라고 해보자. 이후 A의 초기화가 끝나면서 Spring이 A를 프록시 객체로 감싼다. 그러면 컨테이너가 관리하는 공식 A Bean은 프록시 객체인데, B 내부에 들어 있는 A는 원본 객체일 수 있다.</p>
<p>이렇게 되면 트랜잭션, 비동기 처리, 보안 같은 AOP 기능이 기대한 대로 동작하지 않을 수 있다. Spring이 내부적으로 이런 문제를 줄이기 위한 복잡한 방어 로직을 갖고 있지만, 모든 상황을 완벽히 안전하게 처리하기는 어렵다. 그래서 순환 참조는 단순히 Spring이 알아서 해결해주면 되는 문제로 보면 안 된다.</p>
<p>&nbsp;</p>
<h2 id="🚫-spring-boot-26부터-순환-참조가-기본적으로-금지된-이유">🚫 Spring Boot 2.6부터 순환 참조가 기본적으로 금지된 이유</h2>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/854b5848-9e6f-4084-9559-6691e6cbb289/image.png" alt=""></p>
<p>Spring Boot 2.6부터는 순환 참조가 기본적으로 금지되었다. 이전에는 Spring이 일부 순환 참조를 내부 캐시를 통해 해결해주었지만, 이제는 애플리케이션 시작 시점에 순환 참조를 발견하면 기본적으로 실패하도록 바뀌었다. 이 정책 변화는 매우 중요하다.</p>
<p>Spring이 순환 참조를 막는 이유는 개발자를 불편하게 만들기 위해서가 아니라 오히려 애플리케이션 구조의 문제를 더 빨리 발견하게 하기 위해서다. 순환 참조는 대부분 다음과 같은 설계 문제를 의미한다.</p>
<ul>
<li>두 객체의 책임이 명확히 분리되어 있지 않다.</li>
<li>서로 다른 도메인이 너무 강하게 결합되어 있다.</li>
<li>한 클래스가 너무 많은 일을 하고 있다.</li>
<li>의존성 방향이 정리되어 있지 않다.</li>
</ul>
<p>&nbsp;</p>
<p>즉, 순환 참조는 단순 에러라기보다 <strong>코드 스멜(Code Smell)</strong>에 가깝다.</p>
<p>Spring Boot 2.6의 기본 차단 정책은 이런 구조적 문제를 애플리케이션 실행 초기에 드러내는 <strong>Fail-fast 전략</strong>이라고 볼 수 있다. Fail-fast 전략이란 문제가 있다면 가능한 한 빨리 실패하게 해서, 더 큰 장애로 번지기 전에 개발자가 문제를 수정하도록 하는 방식을 말한다.</p>
<p>&nbsp;</p>
<h2 id="👀-allow-circular-referencestrue는-해결책일까">👀 allow-circular-references=true는 해결책일까?</h2>
<p>릴리즈 노트를 보면 알 수 있듯이 Spring Boot 2.6 이상에서도 설정을 통해 순환 참조를 다시 허용할 수 있다.</p>
<pre><code class="language-yaml">spring:
  main:
    allow-circular-references: true</code></pre>
<p>또는 <code>application.properties</code>에서는 다음처럼 설정할 수 있다.</p>
<pre><code class="language-properties">spring.main.allow-circular-references=true</code></pre>
<p>이 설정을 켜면 Spring은 예전처럼 순환 참조를 해결하려고 시도한다.</p>
<p>&nbsp;</p>
<p>하지만 이 설정은 근본 해결책이 아니다. 해당 설정은 일단 애플리케이션을 실행시키기 위한 임시방편이다. 레거시 프로젝트를 Spring Boot 2.6 이상으로 마이그레이션하는 상황이라면 일시적으로 사용할 수는 있다. 하지만 장기적으로는 순환 참조 구조를 제거하는 방향으로 리팩토링해야 한다.</p>
<p>이 설정에 계속 의존하면 시스템 내부의 잘못된 의존 관계가 숨겨진 채로 남게 된다. 시간이 지날수록 구조는 더 복잡해지고, 나중에는 작은 변경 하나에도 여러 서비스가 연쇄적으로 영향을 받는 상황이 될 수 있다.</p>
<p>따라서 <code>allow-circular-references=true</code>는 문제를 해결하는 스위치가 아니라, 리팩토링 시간을 벌기 위한 임시 안전장치로만 사용하는 것이 바람직하다.</p>
<p>&nbsp;</p>
<h2 id="📝-순환-참조가-발생하는-대표적인-설계-문제">📝 순환 참조가 발생하는 대표적인 설계 문제</h2>
<p>순환 참조는 우연히 발생하지 않는다. 대부분 객체 간 책임이 명확하지 않거나, 도메인 경계가 흐릿할 때 발생한다.</p>
<p>대표적인 예시를 보자.</p>
<pre><code class="language-java">@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void withdraw(Long userId) {
        if (orderService.hasActiveOrders(userId)) {
            throw new IllegalStateException(&quot;진행 중인 주문이 있어 탈퇴할 수 없습니다.&quot;);
        }
        // 회원 탈퇴 처리
    }
}</code></pre>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) {
        this.userService = userService;
    }

    public void createOrder(Long userId) {
        userService.validateUserStatus(userId);
        // 주문 생성 처리
    }
}</code></pre>
<p>위 구조에서 <code>UserService</code>는 회원 탈퇴를 위해 주문 정보를 확인해야 한다. 반대로 <code>OrderService</code>는 주문 생성을 위해 회원 상태를 검증해야 한다.</p>
<p>말만 들었을 때는 굉장히 자연스러워 보인다. 하지만 구조적으로 들여다 보면 두 서비스가 서로의 도메인 로직에 깊게 관여하고 있다. <code>UserService</code>는 회원 관리에 집중해야 하고, <code>OrderService</code>는 주문 관리에 집중해야 한다. 그런데 서로의 내부 기능을 직접 호출하면서 양방향 의존성이 생긴다. 이런 구조는 시간이 지날수록 유지보수가 어려워진다.</p>
<p>&nbsp;</p>
<h3 id="🚥-해결책-1-lazy-애노테이션-사용">🚥 해결책 1: @Lazy 애노테이션 사용</h3>
<p>가장 간단한 우회 방법 중 하나는 <code>@Lazy</code>를 사용하는 것이다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserService userService;

    public OrderService(@Lazy UserService userService) {
        this.userService = userService;
    }
}</code></pre>
<p><code>@Lazy</code>를 사용하면 Spring은 실제 <code>UserService</code> 객체를 즉시 주입하지 않고, 프록시 객체를 대신 주입한다. 실제 <code>UserService</code>는 해당 메서드가 처음 호출되는 시점에 생성된다. 이렇게 하면 애플리케이션 시작 시점의 순환 참조 문제를 우회할 수 있다.</p>
<p>하지만 <code>@Lazy</code> 애노테이션 역시 근본 해결책은 아니다. 문제 발생 시점이 애플리케이션 시작 단계에서 런타임으로 미뤄질 수 있다. 또한 실제 메서드가 처음 호출되는 순간 객체 초기화가 발생하면서 예상치 못한 지연이 생길 수도 있다.</p>
<p>따라서 <code>@Lazy</code>는 리팩토링이 당장 어려운 레거시 코드에서 제한적으로 사용하는 것이 좋다.</p>
<p>&nbsp;</p>
<h3 id="🚚-해결책-2-objectprovider-사용">🚚 해결책 2: ObjectProvider 사용</h3>
<p><code>ObjectProvider</code>를 사용하면 필요한 Bean을 즉시 주입받는 대신, 필요한 시점에 직접 조회할 수 있다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final ObjectProvider&lt;UserService&gt; userServiceProvider;

    public OrderService(ObjectProvider&lt;UserService&gt; userServiceProvider) {
        this.userServiceProvider = userServiceProvider;
    }

    public void createOrder(Long userId) {
        UserService userService = userServiceProvider.getIfAvailable();
        if (userService == null) {
            throw new IllegalStateException(&quot;UserService를 사용할 수 없습니다.&quot;);
        }

        userService.validateUserStatus(userId);
        // 주문 생성 처리
    }
}</code></pre>
<p>이 방식도 결국 의존성 주입 시점을 늦추는 것이다. <code>OrderService</code>를 만들 때 <code>UserService</code> 자체를 주입받는 것이 아니라, 나중에 <code>UserService</code>를 찾아올 수 있는 공급자 객체를 주입받는다.</p>
<p><code>@Lazy</code>보다 의존성을 가져오는 시점이 코드에 명시적으로 드러난다는 장점이 있지만 이 방식 역시 Spring의 <code>ObjectProvider</code>라는 프레임워크 API에 도메인 코드가 의존하게 된다. 따라서 근본적인 설계 개선보다는 전술적 우회 방법으로 보는 것이 좋다.</p>
<p>&nbsp;</p>
<h3 id="📡-해결책-3-세터-주입-또는-필드-주입으로-변경">📡 해결책 3: 세터 주입 또는 필드 주입으로 변경</h3>
<p>순환 참조를 우회하기 위해 생성자 주입을 세터 주입이나 필드 주입으로 바꾸는 방법도 있다.</p>
<pre><code class="language-java">@Service
public class UserService {
    private OrderService orderService;

    @Autowired
    public void setOrderService(OrderService orderService) {
        this.orderService = orderService;
    }
}</code></pre>
<p>이 방식은 객체를 먼저 생성한 뒤 나중에 의존성을 주입할 수 있기 때문에 일부 상황에서는 순환 참조를 우회할 수 있다.</p>
<p>하지만 이 방법도 권장하기 어렵다. 생성자 주입을 사용하면 필수 의존성을 객체 생성 시점에 강제할 수 있고, 필드를 <code>final</code>로 선언할 수 있다. 반면 세터 주입이나 필드 주입은 객체가 불완전한 상태로 존재할 가능성을 만든다.</p>
<p>또한 테스트 코드에서도 의존성이 명확하게 드러나지 않는다. 따라서 세터 주입이나 필드 주입은 순환 참조를 해결하기 위한 근본적인 방법이 아니라, 오히려 문제를 숨기는 방식에 가깝다.</p>
<p>&nbsp;</p>
<h3 id="👨🏻🔧-해결책-4-공통-책임을-제3의-서비스로-분리권장">👨🏻‍🔧 해결책 4: 공통 책임을 제3의 서비스로 분리(권장)</h3>
<p>순환 참조를 가장 건강하게 해결하는 방법은 <strong>책임을 다시 나누는 것</strong>이다. 앞에서 본 예시에서 <code>UserService</code>와 <code>OrderService</code>가 서로를 참조하는 이유는 아래와 같다.</p>
<ul>
<li>주문 생성 시 회원 상태 검증이 필요하다.</li>
<li>회원 탈퇴 시 진행 중인 주문 여부 확인이 필요하다.</li>
</ul>
<p>이때 두 서비스가 서로를 직접 호출하게 두는 대신, 공통으로 필요한 책임을 별도 서비스로 분리할 수 있다.</p>
<p>&nbsp;</p>
<p>예를 들어 회원 검증 로직을 <code>UserValidator</code>로 분리한다.</p>
<pre><code class="language-java">@Component
public class UserValidator {
    public void validateUserStatus(Long userId) {
        // 회원 상태 검증
    }
}</code></pre>
<p>&nbsp;</p>
<p>그리고 <code>OrderService</code>는 <code>UserService</code>가 아니라 <code>UserValidator</code>에 의존한다.</p>
<pre><code class="language-java">@Service
public class OrderService {
    private final UserValidator userValidator;

    public OrderService(UserValidator userValidator) {
        this.userValidator = userValidator;
    }

    public void createOrder(Long userId) {
        userValidator.validateUserStatus(userId);
        // 주문 생성 처리
    }
}</code></pre>
<p>또는 주문 상태 확인 로직을 별도의 <code>OrderReader</code>, <code>OrderValidator</code> 같은 컴포넌트로 분리할 수도 있다.</p>
<pre><code class="language-java">@Component
public class OrderValidator {
    public boolean hasActiveOrders(Long userId) {
        // 진행 중인 주문 여부 확인
        return false;
    }
}</code></pre>
<pre><code class="language-java">@Service
public class UserService {
    private final OrderValidator orderValidator;

    public UserService(OrderValidator orderValidator) {
        this.orderValidator = orderValidator;
    }

    public void withdraw(Long userId) {
        if (orderValidator.hasActiveOrders(userId)) {
            throw new IllegalStateException(&quot;진행 중인 주문이 있어 탈퇴할 수 없습니다.&quot;);
        }
        // 회원 탈퇴 처리
    }
}</code></pre>
<p>&nbsp;</p>
<p>이렇게 하면 <code>UserService</code>와 <code>OrderService</code>가 서로 직접 의존하지 않아도 된다.</p>
<pre><code class="language-text">Before
UserService → OrderService
OrderService → UserService

After
UserService → OrderValidator
OrderService → UserValidator</code></pre>
<p>순환 구조가 단방향 구조로 바뀐다.</p>
<p>&nbsp;</p>
<h3 id="📨-해결책-5-이벤트-기반으로-분리권장">📨 해결책 5: 이벤트 기반으로 분리(권장)</h3>
<p>두 서비스가 반드시 즉시 서로를 호출할 필요가 없다면, 이벤트 기반 구조를 사용할 수도 있다. 예를 들어 주문이 생성된 뒤 회원 관련 후처리가 필요하다고 하자.</p>
<p>이때 <code>OrderService</code>가 <code>UserService</code>를 직접 호출하는 대신, “주문이 생성되었다”는 이벤트를 발행할 수 있다.</p>
<pre><code class="language-java">public record OrderCreatedEvent(Long orderId, Long userId) {
}</code></pre>
<pre><code class="language-java">@Service
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    public OrderService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public void createOrder(Long userId) {
        // 주문 생성 처리
        Long orderId = 1L;

        eventPublisher.publishEvent(new OrderCreatedEvent(orderId, userId));
    }
}</code></pre>
<p>&nbsp;</p>
<p>그리고 <code>UserService</code> 또는 별도의 이벤트 핸들러가 해당 이벤트를 구독한다.</p>
<pre><code class="language-java">@Component
public class OrderEventHandler {

    @EventListener
    public void handle(OrderCreatedEvent event) {
        // 주문 생성 이후 필요한 후처리
    }
}</code></pre>
<p>이 구조에서는 <code>OrderService</code>가 <code>UserService</code>를 직접 알 필요가 없다. 단지 이벤트를 발행할 뿐이다. 이벤트를 처리하는 쪽도 <code>OrderService</code>를 직접 호출하지 않는다. 특정 이벤트가 발생하면 반응할 뿐이다.</p>
<p>따라서 두 객체 간의 결합도를 크게 낮출 수 있다. 다만 이벤트 기반 구조는 흐름이 눈에 바로 보이지 않을 수 있다. 메서드 호출처럼 <em>“어디서 어디로 호출되는지”</em> 가 명확하지 않기 때문에, 이벤트 이름과 핸들러 위치를 잘 관리해야 한다.</p>
<p>&nbsp;</p>
<h3 id="🔨-해결책-6-facade-또는-애플리케이션-서비스-도입권장">🔨 해결책 6: Facade 또는 애플리케이션 서비스 도입(권장)</h3>
<p>여러 서비스의 로직을 조합해야 하는 경우, 각 서비스가 서로를 직접 호출하게 만들기보다 상위 계층의 조합 서비스를 둘 수 있다. 예를 들어 회원 탈퇴를 처리할 때 회원 정보도 확인해야 하고, 주문 상태도 확인해야 한다고 하자.</p>
<p>이때 <code>UserService</code>와 <code>OrderService</code>가 서로를 직접 호출하게 만들지 않고, <code>UserWithdrawalFacade</code> 같은 클래스를 둘 수 있다.</p>
<pre><code class="language-java">@Service
public class UserWithdrawalFacade {
    private final UserService userService;
    private final OrderService orderService;

    public UserWithdrawalFacade(UserService userService, OrderService orderService) {
        this.userService = userService;
        this.orderService = orderService;
    }

    public void withdraw(Long userId) {
        if (orderService.hasActiveOrders(userId)) {
            throw new IllegalStateException(&quot;진행 중인 주문이 있어 탈퇴할 수 없습니다.&quot;);
        }

        userService.withdraw(userId);
    }
}</code></pre>
<p>파사드가 여러 서비스를 조합하고, 각 도메인 서비스는 자신의 책임에만 집중한다. 이 방식은 계층 간 의존성 방향을 정리하는 데 특히 유용하다.</p>
<p>&nbsp;</p>
<h2 id="👤-자기-자신을-주입받는-경우도-순환-참조일까">👤 자기 자신을 주입받는 경우도 순환 참조일까?</h2>
<p>특이한 경우로, Bean이 자기 자신을 주입받는 경우도 있다.</p>
<pre><code class="language-java">@Service
public class MyService {
    @Autowired
    private MyService self;

    public void methodA() {
        self.methodB();
    }

    @Transactional
    public void methodB() {
        // 트랜잭션 처리
    }
}</code></pre>
<p>이런 코드는 보통 같은 클래스 내부에서 <code>@Transactional</code> 같은 AOP 기능을 적용하기 위해 사용된다.</p>
<p>Spring AOP는 프록시 기반으로 동작하기 때문에, 같은 클래스 내부에서 <code>this.methodB()</code>를 호출하면 프록시를 거치지 않는다. 그래서 트랜잭션이 적용되지 않을 수 있다. 이를 피하려고 자기 자신의 프록시를 주입받는 방식이 사용되기도 한다.</p>
<p>하지만 이것 역시 좋은 설계라고 보기는 어렵다. 트랜잭션 경계가 달라야 하는 메서드라면 별도의 서비스로 분리하는 것이 더 명확하다.</p>
<pre><code class="language-java">@Service
public class MyService {
    private final MyTransactionService myTransactionService;

    public MyService(MyTransactionService myTransactionService) {
        this.myTransactionService = myTransactionService;
    }

    public void methodA() {
        myTransactionService.methodB();
    }
}</code></pre>
<pre><code class="language-java">@Service
public class MyTransactionService {

    @Transactional
    public void methodB() {
        // 트랜잭션 처리
    }
}</code></pre>
<p>이렇게 분리하면 자기 자신을 주입받지 않아도 되고, 트랜잭션 경계도 더 명확해진다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em></p>
<p><a href="https://www.alibabacloud.com/blog/spring-circular-dependency_600191">Spring Circular Dependency - Alibaba Cloud Community</a>
<a href="https://oneuptime.com/blog/post/2025-12-22-spring-circular-reference-errors/view">How to Handle Circular Reference Errors in Spring</a>
<a href="https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes">Spring Boot 2.6 Release Notes</a>
<a href="https://www.geeksforgeeks.org/java/circular-dependencies-in-spring/">Circular Dependencies in Spring - GeeksforGeeks</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[eGovFrame VSCode Initializr 프로젝트]]></title>
            <link>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8</link>
            <guid>https://velog.io/@rocker_nun/eGovFrame-VSCode-Initializr-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8</guid>
            <pubDate>Mon, 04 May 2026 07:29:32 GMT</pubDate>
            <description><![CDATA[<h2 id="🤔-egovframe-vscode-initializr란">🤔 eGovFrame VSCode Initializr란?</h2>
<p>eGovFrame VSCode Initializr는 <strong>VS Code 환경에서 전자정부 표준프레임워크 기반 프로젝트를 쉽게 생성하고, 필요한 코드와 설정 파일을 자동으로 만들어주는 개발 도구</strong>다. 조금 더 쉽게 말하면, 전자정부 표준프레임워크 프로젝트를 시작할 때 필요한 초기 작업을 도와주는 도구다.</p>
<p>개발자는 VSCode 안에서 몇 번의 선택만으로 프로젝트를 생성할 수 있고, 데이터베이스 테이블 정의를 기반으로 CRUD 코드도 자동으로 만들 수 있다. 여기서 <strong><em>“Initializr”</em></strong> 라는 이름이 중요한데, 말 그대로 <strong><em>“초기화 도구”</em></strong> 라는 의미에 가깝다.</p>
<p>Spring 진영에 Spring Initializr가 있는 것처럼, eGovFrame VSCode Initializr는 전자정부 표준프레임워크 프로젝트를 더 쉽게 시작할 수 있도록 도와주는 역할을 한다.</p>
<p>즉, 이 도구는 개발자가 처음부터 모든 설정과 코드를 직접 작성하지 않고, 표준화된 프로젝트 뼈대를 빠르게 만들 수 있도록 도와준다.</p>
<p>&nbsp;</p>
<h2 id="❓-왜-vscode-initializr가-필요할까">❓ 왜 VSCode Initializr가 필요할까?</h2>
<p>전자정부 표준프레임워크는 오랫동안 공공 정보시스템 개발에서 중요한 역할을 해왔다. 하지만 과거에는 개발 환경이 주로 Eclipse 중심이었다. Eclipse는 오랜 기간 Java 개발 환경의 대표적인 IDE였고, 전자정부 표준프레임워크 개발환경도 Eclipse 기반으로 제공되는 경우가 많았다.</p>
<p>하지만 최근 개발 환경은 많이 바뀌었다. 프론트엔드와 백엔드가 분리되고, React, Vue, Node.js, Spring Boot, MSA, 클라우드 네이티브 같은 기술이 함께 사용되면서 개발자는 더 가볍고 유연한 도구를 선호하게 되었다.</p>
<p>그 흐름 속에서 많은 개발자가 VSCode를 사용한다. VSCode는 가볍고 빠르며, 확장 프로그램 생태계가 풍부하다. 프론트엔드 개발뿐만 아니라 백엔드, DevOps, 문서 작성, Git 관리까지 폭넓게 사용할 수 있다. 전자정부 표준프레임워크도 이런 변화에 맞춰 VSCode 기반 개발 도구가 필요해졌다.</p>
<p>그 결과 등장한 것이 바로 <strong>eGovFrame VSCode Initializr</strong>이다. 이 도구는 전자정부 표준프레임워크가 과거의 무거운 개발 환경에만 머무르지 않고, 현대적인 개발자 경험으로 확장되고 있다는 것을 보여주는 중요한 프로젝트다.</p>
<p>&nbsp;</p>
<h2 id="📝-이-도구가-해결하는-문제">📝 이 도구가 해결하는 문제</h2>
<p>eGovFrame VSCode Initializr가 해결하려는 문제는 크게 세 가지로 볼 수 있다.</p>
<ol>
<li><p><strong>초기 프로젝트 생성의 복잡함:</strong> 전자정부 표준프레임워크 기반 프로젝트를 만들려면 정해진 구조와 설정을 따라야 한다. 하지만 초보 개발자나 처음 프로젝트에 참여한 개발자 입장에서는 어떤 구조가 표준인지, 어떤 파일이 필요한지, 어떤 의존성을 추가해야 하는지 헷갈릴 수 있다. VSCode Initializr는 검증된 템플릿을 기반으로 프로젝트 구조를 자동 생성해준다. 덕분에 개발자는 처음부터 구조를 고민하는 데 시간을 많이 쓰지 않아도 된다.</p>
</li>
<li><p><strong>반복적인 코드 작성</strong>: CRUD 기능을 만든다고 생각해보자. 게시글을 등록하고, 조회하고, 수정하고, 삭제하는 기능은 거의 모든 웹 서비스에서 필요하다. 그런데 이 기능을 만들려면 보통 여러 파일을 함께 작성해야 한다. VO, Service, ServiceImpl, Controller, Mapper Interface, Mapper XML, 목록 화면, 등록 화면 등이 필요하다. 이런 파일들은 구조가 반복되는 경우가 많다. 그래서 사람이 직접 작성하면 시간이 오래 걸리고, 오타나 경로 실수도 발생할 수 있다. VSCode Initializr는 DDL을 기반으로 이런 파일들을 자동 생성해준다.</p>
</li>
<li><p><strong>설정 파일 작성의 어려움</strong>: Spring 기반 프로젝트에서는 데이터베이스 설정, 트랜잭션 설정, 로깅 설정, 프로퍼티 설정 등 다양한 설정 파일이 필요하다. 특히 레거시 XML 방식과 최신 Java Config, YAML, Properties 방식이 함께 존재하면 초보자 입장에서는 더 헷갈릴 수 있다. VSCode Initializr는 이런 설정 파일 생성도 도와준다. </p>
</li>
</ol>
<p>즉, 이 도구의 핵심은 단순히 코드를 빨리 만들어주는 것이 아니라 <strong>표준화된 방식으로 프로젝트를 시작하게 해주고, 반복 작업을 줄이며, 사람의 실수를 줄여주는 도구</strong>라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-주요-기능-살펴보기">🛠️ 주요 기능 살펴보기</h2>
<p>eGovFrame VSCode Initializr의 핵심 기능은 크게 세 가지로 정리할 수 있다.</p>
<ol>
<li><p><strong>프로젝트 자동 생성:</strong> 개발자는 VS Code 안에서 원하는 프로젝트 유형과 빌드 도구를 선택할 수 있다. 예를 들어 Maven을 사용할지, Gradle을 사용할지 선택할 수 있고, 프로젝트 템플릿도 선택할 수 있다. </p>
<p> 이 과정은 개발자가 직접 압축 파일을 내려받고, 폴더 구조를 만들고, 설정 파일을 수정하는 과정을 줄여준다. 쉽게 비유하면, 빈 땅에 건물을 지으려고 할 때 기초 공사부터 직접 하는 것이 아니라, 기본 골조가 잡힌 상태에서 시작하는 것과 같다. </p>
<p> 프로젝트 생성 기능은 특히 처음 전자정부 표준프레임워크를 접하는 개발자에게 도움이 된다. 프로젝트 구조를 잘못 잡으면 이후 개발 과정에서 계속 문제가 생길 수 있다. 하지만 Initializr가 표준 템플릿을 기반으로 프로젝트를 만들어주면, 처음부터 어느 정도 검증된 구조에서 출발할 수 있다.</p>
</li>
</ol>
<ol start="2">
<li><p><strong>DDL 기반 CRUD 코드 자동 생성:</strong>  DDL(Data Definition Language)은 데이터베이스 테이블 구조를 정의하는 SQL 문이다. 예를 들어 다음과 같은 테이블 생성 구문이 있다고 해보자.</p>
<pre><code class="language-sql"> CREATETABLE USER_INFO (
     USER_IDVARCHAR(20) NOTNULL,
     USER_NAMEVARCHAR(50),
     EMAILVARCHAR(100),
 PRIMARYKEY (USER_ID)
 );</code></pre>
<p> 보통 개발자는 이 테이블을 기반으로 여러 계층의 코드를 작성해야 한다. 데이터를 담을 VO 클래스가 필요하고, 비즈니스 로직을 정의할 Service 인터페이스가 필요하고, 실제 로직을 구현할 ServiceImpl 클래스가 필요하고, HTTP 요청을 받을 Controller가 필요하고, DB 접근을 위한 Mapper Interface와 Mapper XML이 필요하고, 화면을 위한 JSP나 Thymeleaf 파일도 필요하다.</p>
<p> 이 작업은 구조가 반복적이지만, 작성해야 할 파일 수가 많다. VSCode Initializr는 DDL을 입력하면 이를 분석해서 필요한 코드를 자동으로 생성해준다.</p>
<p> 자동 생성 대상은 대표적으로 다음과 같다.</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>생성 파일</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 객체</td>
<td>VO, DefaultVO</td>
</tr>
<tr>
<td>비즈니스 로직</td>
<td>Service, ServiceImpl</td>
</tr>
<tr>
<td>웹 요청 처리</td>
<td>Controller</td>
</tr>
<tr>
<td>데이터 접근</td>
<td>Mapper Interface, Mapper XML</td>
</tr>
<tr>
<td>화면</td>
<td>JSP List, JSP Register</td>
</tr>
<tr>
<td>모던 뷰</td>
<td>Thymeleaf List, Thymeleaf Register</td>
</tr>
</tbody></table>
<p> 즉, 하나의 테이블 정의를 기반으로 백엔드 계층과 화면 계층의 기본 코드를 한 번에 만들 수 있다. 이 기능의 장점은 단순히 코드 작성 시간을 줄이는 것만이 아니다. 반복적인 코드에서 자주 발생하는 변수명 오타, 패키지 경로 실수, 파일명 불일치, Mapper 연결 오류 같은 문제를 줄일 수 있다. 개발자는 자동 생성된 기본 코드를 바탕으로 실제 비즈니스 요구사항에 맞는 로직을 추가하면 된다.</p>
</li>
<li><p><strong>설정 파일 자동 생성:</strong> Spring 기반 프로젝트에서는 설정 파일이 매우 중요하다. 데이터베이스 연결 정보, 트랜잭션 설정, 로깅 설정, 스케줄링 설정, 프로퍼티 설정 등이 잘못되면 애플리케이션이 제대로 실행되지 않는다.</p>
<p> 특히 전자정부 표준프레임워크는 레거시 프로젝트와 최신 프로젝트가 함께 존재할 수 있기 때문에, XML 설정, Java Config, YAML, Properties 등 다양한 설정 방식에 대한 이해가 필요하다. VSCode Initializr는 이런 설정 파일 생성을 도와준다.</p>
<p> 개발자는 복잡한 설정 문법을 처음부터 모두 외우지 않아도 된다. 도구가 제공하는 흐름에 따라 필요한 값을 입력하면, 설정 파일의 기본 구조를 생성할 수 있다. 이 기능은 초보 개발자에게 특히 유용하다. 설정 파일은 비즈니스 로직보다 눈에 잘 보이지 않지만, 한 글자만 잘못 작성해도 실행 오류가 발생할 수 있다. 따라서 설정 파일 자동 생성 기능은 프로젝트 초기 오류를 줄이는 데 큰 도움이 된다.</p>
</li>
</ol>
<p>&nbsp;    </p>
<h2 id="🔍-내부적으로는-어떻게-동작할까">🔍 내부적으로는 어떻게 동작할까?</h2>
<p>이 프로젝트는 단순히 템플릿 파일을 복사하는 수준의 도구가 아니다. eGovFrame VSCode Initializr는 <strong>TypeScript 기반으로 작성된 VSCode 확장 프로그램</strong>이며, 사용자 인터페이스는 <strong>React와 WebView API</strong>를 활용해 구성되어 있다.</p>
<p>여기서 WebView는 VSCode 확장 프로그램 안에서 웹 화면을 띄울 수 있게 해주는 기능이다. 즉, VSCode 안에 작은 웹 애플리케이션이 들어가 있는 것처럼 동작한다고 이해하면 쉽다.</p>
<p>개발자가 버튼을 클릭하거나 값을 입력하면 React 기반 UI가 상태를 관리하고, VSCode Extension API를 통해 로컬 파일 시스템에 프로젝트 파일이나 코드 파일을 생성한다. 또한 코드 생성에는 Handlebars라는 템플릿 엔진이 사용된다.</p>
<p>Handlebars는 미리 만들어둔 코드 템플릿에 테이블명, 컬럼명, 타입 같은 값을 끼워 넣어 실제 코드 파일을 만들어주는 역할을 한다. 예를 들어 템플릿에 다음과 같은 부분이 있다고 생각해보자.</p>
<pre><code>privateString {{fieldName}};</code></pre><p>DDL에서 <code>USER_NAME</code>이라는 컬럼을 읽어 <code>userName</code>이라는 필드명으로 변환하면, 최종 코드는 다음처럼 생성된다.</p>
<pre><code>privateStringuserName;</code></pre><p>이런 방식으로 DDL에서 추출한 메타데이터를 여러 코드 템플릿에 바인딩하여 VO, Service, Controller, Mapper 등을 생성한다. </p>
<p>또한 SQL 문법 분석에는 <code>monaco-sql-languages</code>가 활용된다. 이 라이브러리는 DDL 입력 내용을 분석하고, SQL 문법 오류를 감지하는 역할을 한다. 덕분에 잘못된 DDL을 기반으로 잘못된 코드가 생성되는 문제를 줄일 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🖼️-왜-react와-webview를-사용할까">🖼️ 왜 React와 WebView를 사용할까?</h2>
<p>VSCode 확장 프로그램은 단순한 명령어 기반 도구로 만들 수도 있다. 하지만 eGovFrame VSCode Initializr는 프로젝트 생성, 코드 생성, 설정 생성처럼 사용자가 선택하고 입력해야 하는 값이 많다. 만약 모든 기능을 명령어 입력 방식으로만 제공한다면 사용성이 떨어질 수 있다.</p>
<p>그래서 React 기반 UI와 WebView를 사용한다. React를 사용하면 복잡한 화면 상태를 컴포넌트 단위로 관리할 수 있다. 사용자가 선택한 빌드 도구, 프로젝트 유형, DDL 입력값, 생성 결과 미리보기 등을 UI에서 효율적으로 다룰 수 있다. 또한 VSCode의 테마와 자연스럽게 어울리는 UI를 제공할 수도 있다.</p>
<p>예를 들어 사용자가 VSCode를 다크 모드로 사용하면 확장 프로그램 화면도 다크 모드에 맞춰 보이고, 라이트 모드로 바꾸면 그에 맞춰 자연스럽게 전환된다. 이런 부분은 단순한 기능 구현을 넘어 개발자 경험을 좋게 만드는 요소다. 개발 도구는 기능만 많다고 좋은 것이 아니다. 사용자가 불편함 없이 자연스럽게 사용할 수 있어야 한다.</p>
<p>&nbsp;</p>
<h2 id="⚙️-코드-생성-성능을-위해-어떤-최적화가-필요할까">⚙️ 코드 생성 성능을 위해 어떤 최적화가 필요할까?</h2>
<p>DDL 기반 CRUD 코드 생성 기능은 매우 편리하지만, 내부적으로는 처리해야 할 일이 많다. DDL을 분석해야 하고, 테이블명과 컬럼명을 추출해야 하고, 타입을 변환해야 하고, 여러 템플릿에 값을 바인딩해야 하고, 생성 결과를 미리보기 화면에 보여줘야 한다.</p>
<p>문제는 생성해야 하는 파일이 하나가 아니라는 점이다. VO, DefaultVO, Service, ServiceImpl, Controller, Mapper Interface, Mapper XML, JSP List, JSP Register, Thymeleaf List, Thymeleaf Register 등 여러 파일이 동시에 생성될 수 있다.</p>
<p>만약 사용자가 DDL을 한 글자 입력할 때마다 모든 템플릿을 순차적으로 다시 렌더링한다면 어떻게 될까? VSCode 화면이 버벅일 수 있다. 사용자는 입력할 때마다 지연을 느끼게 되고, 도구를 사용하는 경험이 나빠진다. 그래서 이 프로젝트에서는 성능 최적화가 중요하다.</p>
<p>자료에서는 선택적 자동 업데이트와 비동기 병렬 렌더링 같은 최적화 전략이 언급된다. 쉽게 말하면, 사용자가 입력할 때마다 무조건 전체 코드를 다시 생성하지 않고, 필요한 시점에 효율적으로 생성하도록 제어하는 것이다. 또한 여러 템플릿은 서로 독립적이기 때문에 순서대로 하나씩 생성하기보다 병렬로 처리할 수 있다.</p>
<p>예를 들어 VO 생성과 Controller 생성은 서로 직접 의존하지 않는다. 그렇다면 두 작업을 동시에 처리하는 것이 더 효율적이다. 이런 방식으로 사용자는 더 빠른 미리보기와 코드 생성 경험을 얻을 수 있다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://github.com/eGovFramework/egovframe-vscode-initializr">egovframe-vscode-initializr 레포지토리</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[전자정부 표준프레임워크(eGovFrame) 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/eGovFrame-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/eGovFrame-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 04 May 2026 06:34:02 GMT</pubDate>
            <description><![CDATA[<h1 id="🔍-전자정부-표준프레임워크egovframe">🔍 전자정부 표준프레임워크(eGovFrame)</h1>
<p>이번 오픈소스 컨트리뷰션 아카데미에서 내가 맡은 프로젝트는 <code>egovframe-vscode-initializr</code>이다. 처음 이 프로젝트를 접했을 때 가장 먼저 든 생각은 이것이었다.</p>
<blockquote>
<p><strong><em>“전자정부 표준프레임워크가 정확히 뭐지?”</em></strong></p>
</blockquote>
<p>이름만 보면 조금 딱딱하고 어렵게 느껴진다. 전자정부, 표준, 프레임워크라는 단어가 한 번에 붙어 있기 때문이다.</p>
<p>하지만 핵심은 생각보다 단순하다. 전자정부 표준프레임워크는 <strong>공공기관 정보시스템을 더 빠르고, 안정적이고, 표준화된 방식으로 만들기 위해 제공되는 Java 기반 개발 표준 환경이다.</strong></p>
<p>조금 더 쉽게 말하면, 공공기관 웹 서비스를 만들 때 매번 로그인, 권한 관리, 게시판, 데이터베이스 연결, 예외 처리, 로그 처리 같은 공통 기능을 처음부터 다시 만들지 않도록 미리 준비해둔 개발 기반이다. 집을 지을 때마다 벽돌, 창문, 문, 전기 배선 규격을 매번 새로 만들지 않는 것처럼, 정보시스템을 만들 때도 반복적으로 필요한 기능들을 표준화해서 제공하는 것이다.</p>
<p>&nbsp;</p>
<h2 id="❓-프레임워크는-왜-등장했을까">❓ 프레임워크는 왜 등장했을까?</h2>
<p>전자정부 표준프레임워크를 이해하기 전에 먼저 프레임워크가 왜 필요한지 이해해야 한다.</p>
<p>소프트웨어 개발 환경은 시대에 따라 계속 변해왔다. 1970년대에는 메인프레임 중심의 중앙 집중형 컴퓨팅 환경이 주로 사용되었다. 하나의 큰 컴퓨터가 데이터 처리, 비즈니스 로직, 화면 처리까지 대부분의 일을 담당했다. 이 방식은 관리와 배포가 편리했지만, 장비 가격이 비싸고 시스템 확장이 어렵다는 단점이 있었다.</p>
<p>이후 1980년대에는 클라이언트-서버 환경이 등장했다. 서버는 데이터베이스를 관리하고, 사용자의 PC가 일부 비즈니스 로직과 화면 처리를 담당하는 방식이었다. 이전보다 비용은 낮아졌고 사용자 인터페이스도 좋아졌지만, 클라이언트 프로그램을 배포하고 관리하는 일이 어려웠다.</p>
<p>1990년대 이후 인터넷과 웹이 보편화되면서 시스템 구조는 다시 크게 바뀌었다. 사용자는 브라우저를 통해 서비스에 접속하고, 서버는 웹 서버, WAS, 데이터베이스 등으로 역할을 나누어 처리하게 되었다. 이 방식은 시스템 간 연동과 확장, 유지보수 측면에서 훨씬 유리했다.</p>
<p>하지만 웹 기반 시스템이 많아지면서 또 다른 문제가 생겼다. 만들어야 할 소프트웨어는 많아졌는데, 매번 비슷한 기능을 반복해서 개발해야 했던 것이다. 예를 들어 어떤 공공기관 시스템을 만든다고 생각해보자. 민원 시스템이든, 복지 시스템이든, 행정 정보 시스템이든 대부분 다음과 같은 기능이 필요하다.</p>
<ul>
<li>회원가입 및 로그인</li>
<li>회원 관리</li>
<li>권한 처리</li>
<li>게시판</li>
<li>파일 업로드</li>
<li>데이터베이스 연결</li>
<li>트랜잭션 처리</li>
<li>로그 기록</li>
<li>예외 처리</li>
<li>보안 처리</li>
</ul>
<p>이 기능들은 프로젝트마다 조금씩 다를 수는 있지만 기본 구조는 비슷하다. 그런데 매번 새 프로젝트를 할 때마다 이 기능을 처음부터 다시 만든다면 개발 비용이 커지고, 품질도 프로젝트마다 달라질 수밖에 없다. 그래서 소프트웨어 개발은 점점 <strong>재사용성</strong>을 높이는 방향으로 발전해왔다.</p>
<p>&nbsp;</p>
<h2 id="🔄-소프트웨어-재사용-방식의-발전">🔄 소프트웨어 재사용 방식의 발전</h2>
<p>소프트웨어를 재사용하는 방식은 여러 단계를 거쳐 발전했다.</p>
<h3 id="🖋️-1단계-소스-코드-재사용">🖋️ 1단계: 소스 코드 재사용</h3>
<p>가장 단순한 방식은 과거에 작성했던 코드를 복사해서 붙여넣는 것이다. </p>
<p>예를 들어 날짜를 문자열로 변환하는 코드가 필요할 때, 이전 프로젝트에서 사용했던 코드를 그대로 복사해오는 식이다.</p>
<p>처음에는 빠르고 간단해 보인다. 하지만 문제가 있다. 같은 코드가 여러 곳에 복사되어 있으면, 나중에 수정이 필요할 때 모든 복사본을 찾아서 고쳐야 한다. 즉, 코드 중복이 늘어나고 유지보수가 어려워진다.</p>
<p>&nbsp;</p>
<h3 id="📖-2단계-메서드-라이브러리-재사용">📖 2단계: 메서드 라이브러리 재사용</h3>
<p>복사 붙여넣기의 문제를 줄이기 위해 자주 사용하는 기능을 메소드나 라이브러리로 분리하기 시작했다. 예를 들어 날짜 변환 기능을 <code>DateUtil</code> 같은 유틸리티 클래스로 만들고, 여러 곳에서 해당 메소드를 호출하는 방식이다.</p>
<p>이렇게 하면 같은 코드를 여러 번 복사하지 않아도 된다. 기능을 수정해야 할 때도 라이브러리 내부만 고치면 된다.</p>
<p>하지만 이 방식도 한계가 있다. 특정 기능 단위의 재사용에는 좋지만, 전체 애플리케이션 구조를 잡아주지는 못한다.</p>
<p>&nbsp;</p>
<h3 id="🧩-3단계-객체지향-재사용">🧩 3단계: 객체지향 재사용</h3>
<p>객체지향 프로그래밍에서는 클래스를 통해 재사용을 할 수 있다.</p>
<p>예를 들어 <code>Person</code>이라는 부모 클래스에 이름, 생년월일, 출력 기능을 정의하고, <code>Client</code>, <code>Employee</code> 같은 자식 클래스가 이를 상속받아 사용할 수 있다. 상속을 활용하면 공통 속성과 기능을 재사용할 수 있다.</p>
<p>하지만 객체지향 재사용도 주로 수직적인 관계에서 효과적이다. 현실의 시스템은 단순히 부모-자식 구조만으로 해결되지 않는다. 데이터 저장 방식, 외부 시스템 연동, 화면 처리 방식처럼 상황에 따라 유연하게 바뀌어야 하는 문제들이 많다.</p>
<p>&nbsp;</p>
<h3 id="🎏-4단계-디자인-패턴-재사용">🎏 4단계: 디자인 패턴 재사용</h3>
<p>디자인 패턴은 자주 발생하는 설계 문제에 대한 검증된 해결 방법이다.</p>
<p>예를 들어 데이터 저장 방식이 관계형 데이터베이스일 수도 있고, 파일일 수도 있고, 외부 API일 수도 있다고 해보자. 이때 Adapter 패턴을 사용하면 서로 다른 저장 방식의 차이를 감추고 일관된 방식으로 사용할 수 있다.</p>
<p>디자인 패턴은 특정 코드 자체를 재사용한다기보다, 문제를 해결하는 구조와 아이디어를 재사용하는 방식이다.</p>
<p>하지만 디자인 패턴도 전체 애플리케이션을 구성하는 완성된 틀은 아니다. 시스템의 부분적인 문제를 해결하는 데는 좋지만, 애플리케이션 전체 구조를 표준화해주지는 못한다.</p>
<p>&nbsp;</p>
<h3 id="🏰-5단계-프레임워크-재사용">🏰 5단계: 프레임워크 재사용</h3>
<p>프레임워크는 앞선 재사용 방식들을 더 큰 단위로 통합한 것이다. 프레임워크는 단순한 코드 모음이 아니다. 애플리케이션을 어떤 구조로 만들지, 각 계층은 어떤 역할을 해야 하는지, 공통 기능은 어떻게 처리해야 하는지에 대한 기본 틀을 제공한다. 쉽게 말해 프레임워크는 <strong>반제품 형태의 소프트웨어</strong>다.</p>
<p>완제품은 아니지만, 기본 골격과 핵심 기능이 이미 준비되어 있다. 개발자는 그 위에 자신이 만들고자 하는 업무 기능을 추가하면 된다. 건축으로 비유하면, 프레임워크는 건물의 기본 설계도와 골조에 가깝다. 개발자는 그 골조 위에 각 건물의 목적에 맞는 방, 인테리어, 세부 기능을 추가하는 것이다.</p>
<p>&nbsp;</p>
<h2 id="⚙️-전자정부-표준프레임워크가-만들어진-이유">⚙️ 전자정부 표준프레임워크가 만들어진 이유</h2>
<p>그렇다면 왜 정부는 별도로 전자정부 표준프레임워크를 만들었을까? 이유는 공공 정보화 사업의 구조적인 문제 때문이다. 과거 공공기관 정보시스템은 특정 대형 SI 업체의 자체 프레임워크를 기반으로 구축되는 경우가 많았다. 예를 들어 삼성 SDS, LG CNS, SK C&amp;C 같은 대형 업체들이 각자 자체 프레임워크를 가지고 있었고, 많은 공공 시스템이 이런 특정 업체의 기술에 의존했다. 이 방식에는 몇 가지 문제가 있었다.</p>
<ol>
<li><p><strong>특정 업체에 대한 종속성</strong>: 어떤 시스템이 특정 업체의 자체 프레임워크로 만들어지면, 나중에 유지보수나 고도화 사업을 할 때 다른 업체가 참여하기 어려워진다. 내부 구조와 기술을 기존 업체만 잘 알고 있기 때문이다.</p>
</li>
<li><p><strong>중소기업의 참여가 어려움</strong>: 공공사업에 참여하려면 해당 업체의 프레임워크를 알아야 하는데, 중소기업 입장에서는 이런 폐쇄적인 프레임워크에 접근하기 어렵다. 결국 대형 업체 중심의 구조가 강화될 수밖에 없다.</p>
</li>
<li><p><strong>비슷한 기능이 계속 중복 개발</strong>: A 기관도 로그인 기능을 만들고, B 기관도 게시판을 만들고, C 기관도 권한 관리 기능을 새로 만든다. 이미 다른 프로젝트에서 만든 기능인데도 매번 다시 개발하는 일이 반복되었다.</p>
</li>
<li><p><strong>시스템 간 연계와 유지보수가 어려움:</strong> 각 기관의 시스템이 서로 다른 프레임워크와 구조로 만들어지면, 나중에 시스템을 통합하거나 연계할 때 많은 비용이 든다.</p>
</li>
</ol>
<p>위와 같은 문제를 해결하기 위해 정부는 2008년부터 전자정부 표준프레임워크 개발과 보급을 추진했다. 목적은 분명했다. <strong>공공 정보시스템 개발 기반을 표준화하고, 특정 업체 종속을 줄이며, 중복 개발을 방지하고, 중소기업도 공정하게 참여할 수 있는 개발 생태계를 만드는 것</strong>이다.</p>
<p>즉, 전자정부 표준프레임워크는 단순히 개발자를 편하게 해주는 도구가 아닌, 공공 소프트웨어 생태계의 구조를 개선하기 위해 만들어진 표준 개발 기반이다.</p>
<p>&nbsp;</p>
<h2 id="🤔-전자정부-표준프레임워크란-무엇인가">🤔 전자정부 표준프레임워크란 무엇인가?</h2>
<p>전자정부 표준프레임워크는 공공 정보시스템 개발에 필요한 공통 기반을 표준화해서 제공하는 프레임워크다. 조금 더 쉽게 말하면, 공공기관 웹 서비스를 만들 때 자주 필요한 기능과 구조를 미리 준비해둔 개발 플랫폼이다.</p>
<p>공공기관 시스템에는 보통 아래와 같은 기능이 필요하다고 위에서 언급했다.</p>
<ul>
<li>회원가입 및 로그인</li>
<li>회원 관리</li>
<li>권한 처리</li>
<li>게시판</li>
<li>파일 업로드</li>
<li>데이터베이스 연결</li>
<li>트랜잭션 처리</li>
<li>로그 기록</li>
<li>예외 처리</li>
<li>보안 처리</li>
</ul>
<p>전자정부 표준프레임워크는 이런 기능들을 공통적으로 사용할 수 있도록 제공한다. 개발자는 매번 바닥부터 구현하지 않고, 표준프레임워크가 제공하는 구조와 기능을 활용해 실제 업무 로직에 집중할 수 있다. 여기서 중요한 점은 전자정부 표준프레임워크가 단순히 하나의 라이브러리가 아니라는 것이다.</p>
<p>전자정부 표준프레임워크는 다음과 같은 요소들을 포함하는 개발 생태계에 가깝다.</p>
<ul>
<li>실행환경</li>
<li>개발환경</li>
<li>관리환경</li>
<li>운영환경</li>
<li>공통컴포넌트</li>
<li>개발 가이드</li>
<li>템플릿</li>
<li>예제 코드</li>
</ul>
<p>즉, 프로젝트를 만들고, 개발하고, 테스트하고, 운영하고, 유지보수하는 전체 과정을 지원하는 표준 기반이라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🎭-전자정부-표준프레임워크와-spring의-관계">🎭 전자정부 표준프레임워크와 Spring의 관계</h2>
<p>전자정부 표준프레임워크를 처음 접하면 이런 의문이 들 수 있다.</p>
<blockquote>
<p><em><strong>“전자정부 표준프레임워크는 Spring이랑 같은 건가?”</strong></em></p>
</blockquote>
<p>95% 정도만 같다. 전자정부 표준프레임워크는 Spring을 기반으로 하지만, Spring 그 자체는 아니다. Spring은 Java 애플리케이션 개발을 도와주는 대표적인 프레임워크다. 전자정부 표준프레임워크는 Spring을 포함한 여러 오픈소스 기술을 공공 정보시스템 개발에 맞게 선별하고, 검증하고, 표준화해서 제공한다. 즉, Spring이 핵심 엔진이라면, 전자정부 표준프레임워크는 그 엔진 위에 공공 시스템 개발에 필요한 표준 구조, 공통 기능, 개발 도구, 운영 도구, 가이드까지 함께 묶어 제공하는 패키지라고 볼 수 있다.</p>
<p>비유하자면 Spring은 자동차의 엔진이고, 전자정부 표준프레임워크는 그 엔진을 기반으로 실제 도로에서 안전하게 달릴 수 있도록 차체, 운전석, 안전장치, 내비게이션, 정비 매뉴얼까지 갖춘 완성형 플랫폼에 가깝다.</p>
<p>&nbsp;</p>
<h2 id="🗂️-전자정부-표준프레임워크의-구성">🗂️ 전자정부 표준프레임워크의 구성</h2>
<p>전자정부 표준프레임워크는 크게 실행환경, 개발환경, 관리환경, 운영환경, 공통 컴포넌트로 구성된다.</p>
<h2 id="▶️-실행환경">▶️ 실행환경</h2>
<p>실행환경은 애플리케이션이 실제 서버에서 동작할 때 필요한 기반 기능을 제공한다. 쉽게 말해 시스템이 돌아가기 위한 엔진룸이다. 실행환경에는 다음과 같은 기능들이 포함된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/daa901ff-aa00-4f2c-8ac3-83e170be2670/image.png" alt=""></p>
<ul>
<li><p><strong>화면 처리 영역</strong>: 사용자가 브라우저에서 보는 화면과 서버 간의 요청과 응답을 담당한다.</p>
</li>
<li><p><strong>업무 처리 영역</strong>: 실제 비즈니스 로직이 실행되는 부분이다.</p>
</li>
<li><p><strong>데이터 처리 영역</strong>: 데이터베이스와 연결하고 데이터를 조회하거나 저장하는 역할을 한다.</p>
</li>
<li><p><strong>연계 및 통합 영역</strong>: 외부 시스템이나 다른 기관 시스템과 통신하는 기능을 담당한다.</p>
</li>
<li><p><strong>공통 기반 영역</strong>: 로그, 예외 처리, 메시지 처리 같은 공통 기능을 제공한다.</p>
</li>
<li><p><strong>배치 처리 영역</strong>: 대량의 데이터를 정해진 시간에 일괄 처리하는 기능을 담당한다.</p>
</li>
</ul>
<p>예를 들어 매일 밤 전 국민의 특정 데이터를 정산하거나, 대량의 통계 데이터를 생성하는 작업은 배치 처리 영역에서 담당할 수 있다.</p>
<p>&nbsp;</p>
<h2 id="🛠️-개발환경">🛠️ 개발환경</h2>
<p>개발환경은 개발자가 표준프레임워크 기반 프로젝트를 더 쉽게 만들고 개발할 수 있도록 돕는 도구 모음이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/5eb92b6e-8619-4143-afb7-770e9f85f430/image.png" alt=""></p>
<p>과거에는 전자정부 표준프레임워크 개발환경이 Eclipse 중심으로 제공되었다. 하지만 최근 개발 환경은 많이 바뀌었다. 많은 개발자가 VS Code, IntelliJ, 다양한 프론트엔드 도구를 함께 사용한다.</p>
<p>이 흐름에 맞춰 전자정부 표준프레임워크도 VS Code 기반 개발 도구를 제공하고 있다. <del>여기서 내가 맡은 <code>egovframe-vscode-initializr</code> 프로젝트가 등장한다.</del></p>
<p>이 도구는 VS Code 안에서 전자정부 표준프레임워크 기반 프로젝트를 쉽게 생성하고, 설정 파일을 만들고, CRUD 코드까지 자동 생성할 수 있도록 돕는 확장 프로그램이다. 즉, 개발자가 표준프레임워크 프로젝트를 시작할 때 겪는 복잡한 초기 설정 과정을 줄여주는 도구다.</p>
<p>&nbsp;</p>
<h2 id="👨🏻🔧-관리환경">👨🏻‍🔧 관리환경</h2>
<p>관리환경은 표준프레임워크 자체를 유지하고 개선하기 위한 체계다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d7d22b2e-4763-4b0f-9b36-25306381a3c3/image.png" alt=""></p>
<p>프레임워크도 한 번 만들고 끝나는 것이 아니다. 오픈소스 라이브러리 버전이 올라가고, 보안 취약점이 발견되고, 새로운 기술이 등장하면 계속 개선되어야 한다. 관리환경은 이런 변경 요청, 오류 수정, 기능 개선, 버전 관리, 표준 관리 등을 처리하는 역할을 한다. 쉽게 말하면 표준프레임워크가 계속 안정적으로 발전할 수 있도록 관리하는 내부 운영 체계다.</p>
<p>&nbsp;</p>
<h2 id="🏭-운영환경">🏭 운영환경</h2>
<p>운영환경은 실제 서비스가 운영되는 동안 시스템 상태를 확인하고 관리하기 위한 환경이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cfd2ee57-2ef3-44e1-8856-eaceb1ffd073/image.png" alt=""></p>
<p>서비스가 정상적으로 동작하는지, 서버 자원은 충분한지, 응답 시간이 너무 느리지는 않은지, 에러는 발생하지 않는지 등을 모니터링한다. 쉽게 말해 운영환경은 시스템의 관제실이다.</p>
<p>공공기관 서비스는 많은 사용자가 이용하고, 장애가 발생하면 사회적 영향이 클 수 있다. 따라서 시스템이 잘 동작하는지 지속적으로 확인하는 운영환경이 중요하다.</p>
<p>&nbsp;</p>
<h2 id="📦-공통-컴포넌트">📦 공통 컴포넌트</h2>
<p>전자정부 표준프레임워크에서 가장 실질적으로 개발 생산성을 높여주는 요소가 공통컴포넌트다. 공통 컴포넌트는 여러 정보시스템에서 반복적으로 사용되는 기능을 미리 만들어둔 소프트웨어 부품이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/755b5823-14eb-4c5f-87ca-3d3593efca12/image.png" alt=""></p>
<p>예를 들어 다음과 같은 기능들이 공통 컴포넌트로 제공된다고 한다면,</p>
<ul>
<li>로그인</li>
<li>사용자 관리</li>
<li>권한 관리</li>
<li>게시판</li>
<li>공지사항</li>
<li>주소록</li>
<li>일정 관리</li>
<li>파일 업로드</li>
<li>공통 코드 관리</li>
<li>메뉴 관리</li>
<li>로그 관리</li>
<li>통계 관리</li>
<li>암호화</li>
<li>유효성 검사</li>
</ul>
<p>개발자는 이런 기능을 처음부터 새로 만들 필요 없이, 필요한 공통컴포넌트를 가져와 프로젝트에 적용할 수 있다. 비유하자면 공통 컴포넌트는 레고 블록과 비슷하다. 필요한 블록을 선택해서 조립하면 원하는 구조물을 더 빠르게 만들 수 있다.</p>
<p>물론 공통 컴포넌트를 그대로 사용하는 것만이 정답은 아니다. 기관이나 프로젝트의 요구사항에 맞게 수정해서 사용할 수도 있다. 중요한 것은 완전히 빈 상태에서 시작하지 않아도 된다는 점이다.</p>
<p>&nbsp;</p>
<h2 id="👍-전자정부-표준프레임워크를-사용하면-좋은-점">👍 전자정부 표준프레임워크를 사용하면 좋은 점</h2>
<p>전자정부 표준프레임워크를 사용하면 여러 장점이 있다.</p>
<ol>
<li><p><strong>개발 생산성 향상</strong>: 반복적으로 필요한 기능을 매번 새로 만들 필요가 줄어든다. 개발자는 로그인, 권한, DB 연결, 로그 처리 같은 기반 기능보다 실제 업무 로직에 더 집중할 수 있다.</p>
</li>
<li><p><strong>품질 향상</strong>: 프로젝트마다 제각각 구현하던 공통 기능을 검증된 표준 방식으로 구현할 수 있다. 이는 코드 품질과 시스템 안정성을 높이는 데 도움이 된다.</p>
</li>
<li><p><strong>유지보수성 향상</strong>: 표준화된 구조를 사용하면 새로운 개발자가 프로젝트에 참여했을 때 구조를 이해하기 쉽다. 공공 시스템은 한 번 만들고 끝나는 것이 아니라 오랜 기간 운영되기 때문에 유지보수성이 매우 중요하다.</p>
</li>
<li><p><strong>특정 업체 종속성 완화</strong>: 표준프레임워크는 공개된 표준과 오픈소스 기반 기술을 활용한다. 따라서 특정 업체의 폐쇄적인 프레임워크에 의존하는 문제를 줄일 수 있다.</p>
</li>
<li><p><strong>중소기업 참여 기회 확대</strong>: 공통된 표준 기반이 있으면 특정 대기업만 시스템을 이해하고 유지보수할 수 있는 구조를 줄일 수 있다. 이는 더 많은 기업이 공정하게 경쟁할 수 있는 환경을 만드는 데 도움이 된다.</p>
</li>
</ol>
<p>&nbsp;</p>
<h2 id="🌊-전자정부-표준프레임워크의-최신-흐름">🌊 전자정부 표준프레임워크의 최신 흐름</h2>
<p>전자정부 표준프레임워크는 과거의 공공 SI 개발 도구에 머무르지 않고 계속 변화하고 있다.</p>
<p>초기 버전은 주로 공공 웹 시스템의 표준 개발 기반을 제공하는 데 초점이 있었다. 이후 모바일 환경 지원, 보안 강화, 오픈소스 라이브러리 업그레이드, Spring 버전 업그레이드 등이 꾸준히 이루어졌다. 최근에는 Spring Boot, MSA, 클라우드 네이티브, OpenSearch, AI 기반 검색, VS Code 개발환경 같은 현대적인 흐름도 반영되고 있다. </p>
<p>특히 Spring Boot 기반으로 전환되면서 과거보다 설정이 간소화되고, 독립 실행 가능한 애플리케이션 구성이 쉬워졌다. 기존에는 복잡한 XML 설정을 많이 작성해야 했지만, Spring Boot 기반에서는 자동 설정을 활용해 초기 설정 부담을 크게 줄일 수 있다.</p>
<p>또한 MSA 환경도 중요해지고 있다. 과거에는 하나의 큰 애플리케이션 안에 모든 기능이 들어 있는 모놀리식 구조가 일반적이었다. 하지만 시스템 규모가 커지고 트래픽이 늘어나면 하나의 거대한 애플리케이션을 관리하기 어려워진다. MSA는 사용자, 게시판, 예약, 인증 같은 기능을 작고 독립적인 서비스로 나누어 운영하는 방식이다. 특정 서비스에 트래픽이 몰리면 해당 서비스만 확장할 수 있고, 일부 서비스에 문제가 생겨도 전체 시스템 장애로 번지는 것을 줄일 수 있다. 전자정부 표준프레임워크도 이런 클라우드 네이티브 환경에 맞춰 계속 확장되고 있다.</p>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://www.egovframe.go.kr/home/main.do">표준프레임워크 포털 eGovFrame</a>
오픈소스 컨트리뷰션 아카데미 배포 자료</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[애그리거트(Aggregate)]]></title>
            <link>https://velog.io/@rocker_nun/%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B1%B0%ED%8A%B8Aggregate</link>
            <guid>https://velog.io/@rocker_nun/%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B1%B0%ED%8A%B8Aggregate</guid>
            <pubDate>Sun, 03 May 2026 14:43:16 GMT</pubDate>
            <description><![CDATA[<h1 id="🏰-애그리거트">🏰 애그리거트</h1>
<p>온라인 쇼핑몰 시스템을 상위 수준 개념을 이용해서 바라보면 아래와 같이 전체 모델들 간의 관계를 이해할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/07a7f2b9-3e3f-4d8a-a76d-589cd2aaa7f8/image.png" alt=""></p>
<p>위와 같은 상위 수준 모델 간의 관계를 제대로 이해하지 않고 개별 객체들 간의 관계만을 보고 전체 모델의 관계를 파악하기는 매우 어렵고, 코드를 변경하고 확장하는 것이 매우 힘들어진다. 이를 위해 상위 수준에서 모델을 관찰할 수 있게 해주는 <strong>애그리거트(Aggregate)</strong>가 등장한다. 도메인 규칙에 따라 함께 일관성을 유지해야 하는 객체들을 하나의 애그리거트 경계로 묶어서 상위 수준에서 도메인 모델 간의 관계를 파악하는 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7c08746d-bca9-495e-8ed3-44747c5d2e64/image.png" alt=""></p>
<p>위의 그림처럼 애그리거트는 전체 모델 간의 관계를 이해할 수 있도록 도와줄 뿐만 아니라, 일관성을 관리하는 기준도 될 수 있다. 그리고 관련된 모델을 하나로 모았기 때문에 한 애그리거트에 속하는 객체는 유사하거나 동일한 라이프 사이클을 갖는다. 그리고 그림을 보면 알 수 있듯이 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. 경계를 명확히 해서 복잡한 도메인을 단순한 구조로 만드는 것이 애그리거트의 임무이기 때문에 어찌 보면 당연하다. </p>
<p>이 경계를 어떻게 결정하는지에 대한 답은 도메인 규칙과 요구사항으로부터 찾아야 한다. 일단 도메인 규칙에 따라 함께 생성되고 변경되는 구성요소는 한 애그리거트에 속할 가능성이 높다. 그리고 주의할 점은 <em>“A가 B를 갖는다”</em> 와 같은 요구사항을 보고 A와 B는 무조건 하나의 애그리거트에 속할 것이라고 속단하는 것이다. 아래 상품과 리뷰 예시를 보자.</p>
<p>상품 상세 페이지에 들어가면 보통 리뷰가 달려 있으니 상품과 리뷰는 하나의 애그리거트에 포함시키는 것이 이상하다고 느껴지지 않는다. 하지만 분명 상품과 리뷰는 함께 생성되지도, 변경되지도 않는다. 게다가 변경 주체도 상품은 관리자가 변경하고, 리뷰는 고객이 변경 주체이기 때문에 하나의 애그리거트에 포함시키는 것은 무리가 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/78460e31-38da-4795-9ba6-a770c6445d0f/image.png" alt=""></p>
<p>&nbsp;</p>
<h1 id="👨🏻🔧-애그리거트-루트">👨🏻‍🔧 애그리거트 루트</h1>
<p>도메인 규칙을 지키기 위해서는 하나의 애그리거트에 속한 여러 객체들이 모두 정상 상태를 유지해야 한다. 따라서 애그리거트 전체를 컨트롤할 수 있는 관리자가 필요한데, 이 책임을 지는 것이 바로 애그리거트의 <strong>루트 엔티티(Root Entity)</strong>다. </p>
<p>루트 엔티티의 임무는 애그리거트의 일관성이 깨지지 않도록 하는 것이다. 이를 위해 루트 엔티티는 애그리거트가 제공해야 할 도메인 기능을 구현한다. 불필요한 중복을 피하고 루트 엔티티를 통해서만 도메인 로직을 구현하게 만들기 위해서는 <code>setter</code> 메서드를 외부에서 접근할 수 없도록 만들고, 밸류 타입은 불변으로 구현해야 한다. </p>
<p>그리고 루트 엔티티는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다. 아래 <code>Order</code> 루트 엔티티 코드와 <code>Member</code> 루트 엔티티 코드를 살펴보자.</p>
<pre><code class="language-java">public class Order {
    private Money totalAmount;
    private List&lt;OrderLine&gt; orderLines;

    private void calculateTotalAmount() {
        int sum = orderLines.stream()
                .mapToInt(ol -&gt; ol.getPrice() * ol.getQuantity())
                .sum();
        this.totalAmount = new Money(sum);
    }
}</code></pre>
<pre><code class="language-java">public class Member {
    private Password password;

    public void changePassword(Password currentPassword, Password newPassword) {
        if (!password.match(currentPassword)) {
            throw new PasswordNotMatchException();
        }
        this.password = new Password(newPassword);
    }
}</code></pre>
<p><code>Order</code>는 총 주문 금액을 구하기 위해 <code>OrderLine</code> 목록을 사용하고, <code>Member</code>는 비밀번호를 변경하기 위해 <code>Password</code> 밸류 타입에 비밀번호가 일치하는지 확인하고 있다. 추가로 루트 엔티티는 구성요소의 필드만 참조하는 것이 아니라 기능 실행을 위임하기도 한다.</p>
<p>&nbsp;</p>
<h2 id="🚧-트랜잭션-범위">🚧 트랜잭션 범위</h2>
<p>트랜잭션의 범위는 작을수록 좋다. 예를 들어 1개의 테이블을 수정하면 락의 대상이 그 테이블의 1개 행일 뿐이지만, 3개의 테이블을 수정한다면 락의 대상이 많아질 수밖에 없다. 이는 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 의미하고 전체적인 성능을 떨어뜨린다. </p>
<p>이와 마찬가지로 한 번에 수정하는 애그리거트 개수가 많으면 많아질수록 전체 처리량이 떨어지기 때문에 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. 다시 말하자면, 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 되도록 두지 말자는 것이다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class Order {
    private Orderer orderer;

    public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddressAsMemberAddress) {
        verifyNotYetShipped();
        setShippingInfo(newShippingInfo);  // 주문 애그리거트의 배송지 정보를 변경
        if (useNewShippingAddressAsMemberAddress) {
                // 회원의 주소를 변경된 배송지로 설정...
            orderer.getMember().changeAddress(newShippingInfo.getAddress());
        }
    }

    ...
}</code></pre>
<p>위의 코드는 주문 애그리거트에서 회원 애그리거트 내부 상태까지 변경하고 있는 상태다. <code>Order</code> 루트 엔티티나 <code>Member</code> 루트 엔티티는 각각 주문, 회원 애그리거트 내부의 일관성을 책임져야 하는데 <code>Order</code> 루트 엔티티가 회원 애그리거트 내부 일관성을 해치고 있는 것이다. 하지만 상황에 따라 한 트랜잭션으로 2개 이상의 애그리거트를 수정해야 한다면 위의 코드처럼 직접 수정하는 것보다 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 한다.</p>
<pre><code class="language-java">public class ChangeOrderService {
    private OrderRepository orderRepository;
    private MemberRepository memberRepository;

    @Transactional
    public void changeShippingInfo(
            OrderId orderId,
            ShippingInfo newShippingInfo,
            boolean useNewShippingAddressAsMemberAddress
    ) {
        Order order = orderRepository.findById(orderId);
        if (order == null) {
            throw new OrderNotFoundException();
        }
        order.shipTo(newShippingInfo);

        if (useNewShippingAddressAsMemberAddress) {
            Member member = findMember(order.getOrderer());
            member.changeAddress(newShippingInfo.getAddress());
        }
    }

    ...
}</code></pre>
<p>정리하면, 한 트랜잭션에서 한 개의 애그리거트를 변경하는 것이 가장 좋지만, 아래의 경우에는 한 트랜잭션에서 2개 이상의 애그리거트를 변경하는 것을 고려할 수도 있다.</p>
<ul>
<li><strong>팀 표준</strong>: 팀이나 조직의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우가 있다.</li>
<li><strong>기술 제약</strong>: 기술적으로 이벤트 방식을 도입할 수 없는 경우 한 트랜잭션에서 다수의 애그리거트를 수정해서 일관성을 처리해야 한다.</li>
<li><strong>UI 구현의 편리</strong>: 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶을 것이다. 이 경우 한 트랜잭션에서 여러 주문 애그리거트의 상태를 변경해야 한다.</li>
</ul>
<p>&nbsp;</p>
<h1 id="💾-리포지토리와-애그리거트">💾 리포지토리와 애그리거트</h1>
<p>객체의 영속성을 처리하는 리포지토리도 애그리거트 단위로 존재한다. 새로운 애그리거트를 만들면 저장소에 애그리거트를 영속화하고 애그리거트를 사용하려면 저장소에서 애그리거트를 읽어 와야 하기 때문에 리포지토리는 기본적으로 아래 2개의 메서드를 제공한다.</p>
<ul>
<li><code>save()</code>: 애그리거트를 저장</li>
<li><code>findById()</code>: ID로 애그리거트를 조회</li>
</ul>
<p>알다시피 리포지토리를 구현하는 기술들은 정말 다양하다. 어떤 기술을 채택하느냐에 따라 리포지토리 구현 방식도 달라진다. 그리고 리포지토리 구현체는 애그리거트 루트뿐만 아니라 애그리거트에 속한 구성요소까지 함께 저장하고 조회해야 한다. 만약 <code>Order</code> 애그리거트 루트와 그와 관련된 테이블이 여러 개가 있다면 루트 엔티티뿐만 아니라 나머지 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다. </p>
<pre><code class="language-java">// 애그리거트 전체를 영속화
orderRepository.save(order);

// 완전한 주문 애그리거트를 조회
Order order = orderRepository.findById(orderId);</code></pre>
<p>&nbsp;</p>
<h1 id="🆔-id를-이용한-애그리거트-참조">🆔 ID를 이용한 애그리거트 참조</h1>
<p>아까 봤다시피 하나의 애그리거트도 다른 애그리거트를 참조할 수 있다. 더 정확히 말하면, 다른 애그리거트의 루트 엔티티를 참조하는 것이다. 앞에서 본 예시 코드처럼 애그리거트 간의 참조는 필드를 통해 쉽게 구현할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9266b8b1-1081-4cb8-b6c0-98b3771d206d/image.png" alt=""></p>
<p>이처럼 애그리거트 간의 참조를 구현하는 것은 쉽지만 몇 가지 문제들이 발생할 수 있다는 것을 유념해야 한다.</p>
<ul>
<li>편한 탐색 오용</li>
<li>성능에 대한 고민</li>
<li>확장 어려움</li>
</ul>
<p>&nbsp;</p>
<p>일단 첫 번째로, 한 애그리거트가 관리하는 범위는 자기 자신으로 한정하는 것이 가장 좋다고 했다. 하지만 구현이 워낙 편리하기 때문에 다른 애그리거트를 수정하고자 하는 유혹에 빠지기 쉽다. 또한 애그리거트 간의 결합도가 높아져 애그리거트의 변경을 어렵게 만든다. </p>
<p>두 번째 문제는 성능이다. 애그리거트를 객체로 직접 참조하면 JPA 같은 ORM을 사용할 때 지연 로딩과 즉시 로딩 중 무엇을 선택할지 고민해야 한다. 잘못 선택하면 불필요한 쿼리가 많이 실행되거나, 반대로 필요하지 않은 객체까지 한 번에 조회하는 문제가 생길 수 있다.</p>
<p>마지막으로, 확장에 대한 문제다. 서비스가 성장하고 사용자 수가 늘면 자연스럽게 부하를 분산하기 위해 하위 도메인별로 시스템을 분리해야 할 것이다. 이 과정에서 하위 도메인마다 각기 다른 DBMS를 사용하거나 아예 다른 데이터 저장소를 사용할 수도 있다. 이렇게 되면 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술은 사용할 수 없게 된다.</p>
<p>&nbsp;</p>
<p>위와 같은 문제점들을 한 번에 해결할 수 있는 방법이 바로 ID를 이용해서 다른 애그리거트를 참조하는 방법이다. 아래 다이어그램을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7f7987b6-0296-4be0-96a3-164604474bed/image.png" alt=""></p>
<p>보다시피 ID 참조를 사용하면 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결되기 때문에 모델의 복잡도를 낮추고 각 애그리거트의 응집도도 높여줄 수 있다. 추가로 구현 복잡도도 낮아진다. 이제 직접적으로 참조하지 않기 때문에 JPA를 예로 들면 애그리거트 간의 참조를 지연 로딩으로 할지 즉시 로딩으로 할지 더 이상 고민하지 않아도 된다. 참조하는 애그리거트가 필요하다면 그냥 응용 서비스에서 ID로 로딩하면 된다.</p>
<pre><code class="language-java">public class ChangeOrderService {

    ...

    @Transactional
    public void changeShippingInfo(
            OrderId orderId,
            ShippingInfo newShippingInfo,
            boolean useNewShippingAddressAsMemberAddress
    ) {
        Order order = orderRepository.findById(orderId);
        if (order == null) {
            throw new OrderNotFoundException();
        }
        order.changeShippingInfo(newShippingInfo);

        if (useNewShippingAddressAsMemberAddress) {
            // ID를 이용해서 참조하는 애그리거트를 구한다.
            Member member = memberRepository.findById(
                    order.getOrderer().getMemberId()
            );
            member.changeAddress(newShippingInfo.getAddress());
        }
    }

    ...
}</code></pre>
<p>&nbsp;</p>
<h2 id="⚙️-조회-성능">⚙️ 조회 성능</h2>
<p>하지만 다른 애그리거트를 ID로 참조하게 되면 여러 애그리거트를 읽을 때 조회 속도가 저하될 수도 있다. 예를 들어 주문 목록을 보여주려면 상품 애그리거트와 회원 애그리거트를 같이 읽어 와야 할 것이다. 아래 코드를 보자.</p>
<pre><code class="language-java">...

Member member = memberRepository.findById(ordererId);

List&lt;Order&gt; orders = orderRepository.findByOrderer(ordererId);
List&lt;OrderView&gt; dtos = orders.stream()
        .map(order -&gt; {
            ProductId productId = order.getOrderLines().get(0).getProductId();

            // 각 주문마다 첫 번째 주문 상품 정보 로딩을 위한 쿼리 실행
            Product product = productRepository.findById(productId);

            return new OrderView(order, member, product);
        })
        .collect(Collectors.toList());

 ...</code></pre>
<p>만약 주문이 10개면 주문을 읽어오기 위한 1번의 쿼리와 주문별로 각 상품을 읽어오기 위한 10번의 쿼리를 실행해야 한다. 이게 그 유명한 <strong>N + 1 조회 문제</strong>다. 이는 더 많은 쿼리를 날리기 때문에 당연히 성능을 저하시킬 수밖에 없다. </p>
<p>이 문제를 해결하려면 조회 목적에 맞는 전용 쿼리를 사용하는 것이 좋다. 객체 참조와 즉시 로딩으로 해결할 수도 있지만, 이는 애그리거트 간 결합도를 높이고 필요하지 않은 데이터까지 함께 조회할 위험이 있다. </p>
<p>따라서 목록 화면처럼 여러 애그리거트의 데이터를 한 번에 보여줘야 하는 경우에는 조회 전용 DAO나 조회 전용 모델을 두고 조인 쿼리로 필요한 데이터만 읽어오는 방식이 더 적합하다. 예를 들어 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 1번의 쿼리로 필요한 데이터를 로딩하면 된다. 아래 특정 사용자의 주문 내역을 보여주는 코드를 보자.</p>
<pre><code class="language-java">@Repository
public class JpaOrderViewDao implements OrderViewDao {
    @PersistenceContext
    private EntityManager em;

    @Override
    public List&lt;OrderView&gt; selectByOrderer(String ordererId) {
        String selectQuery =
                &quot;select new com.myshop.order.application.dto.OrderView(o, m, p) &quot; + 
                        &quot;from Order o join o.orderLines ol, Member m, Product p &quot; +
                        &quot;where o.orderer.memberId.id = :ordererId &quot; +
                        &quot;and o.orderer.memberId = m.id &quot; +
                        &quot;and index(ol) = 0 &quot; +
                        &quot;and ol.productId = p.id &quot; +
                        &quot;order by o.number.number desc&quot;;
        TypedQuery&lt;OrderView&gt; query =
                em.createQuery(selectQuery, OrderView.class);
        query.setParameter(&quot;ordererId&quot;, ordererId);
        return query.getResultList();
    }
}</code></pre>
<p>이 JPQL은 <code>Order</code> 애그리거트와 <code>Member</code> 애그리거트, <code>Product</code> 애그리거트를 조인으로 조회해서 1번의 쿼리로 로딩한다. 따라서 즉시 로딩이든 지연 로딩이든 상관없이 조회 화면에서 필요한 애그리거트 데이터를 1번의 쿼리로 로딩할 수 있는 것이다. </p>
<p>&nbsp;</p>
<h1 id="⛓️-애그리거트-간-집합-연관">⛓️ 애그리거트 간 집합 연관</h1>
<p>이제 애그리거트 간의 일대다 관계, 다대다 관계에 대해 알아보자. 이 두 연관은 <strong>컬렉션(Collection)</strong>을 이용한 연관이다. 일단 애그리거트 간의 일대다 관계로, 하나의 카테고리와 그에 연관된 상품을 값으로 갖는 컬렉션을 필드로 아래와 같이 정의할 수 있다.</p>
<pre><code class="language-java">public class Category {

    private Set&lt;Product&gt; products;

    ...
}</code></pre>
<p>&nbsp;</p>
<p>근데 개념적으로 존재하는 애그리거트 간의 일대다 관계를 실제 구현에 반영하는 것이 요구사항을 충족하는 것과는 상관없을 때가 있다. 특정 카테고리에 속한 상품 목록을 보여주는 요구사항을 생각해보자. 보통 목록과 관련된 요구사항은 한 번에 모든 정보를 보여주기보다는 페이징 기법을 이용해 요소들을 나눠서 보여준다. 이 기능을 카테고리 입장에서 일대다 관계를 이용해서 구현하면 아래와 같이 코드를 작성할 수 있다. </p>
<pre><code class="language-java">public class Category {

    private Set&lt;Product&gt; products;

    public List&lt;Product&gt; getProducts(int page, int size) {
        List&lt;Product&gt; sortedProducts = sortById(products);
        return sortedProducts.subList((page - 1) * size, page * size);
    }

    ...
}</code></pre>
<p>하지만 이 코드를 실제 DB와 연동한다면 카테고리에 있는 모든 상품들이 다 딸려 나온다. 그러면 성능에 아주 심각한 문제가 생긴다. 따라서 개념적으로는 애그리거트 간에 일대다 연관이 있더라도 실제 구현에 반영하지는 않는다. 카테고리에 속한 상품 목록이 필요하다면, 카테고리 애그리거트가 상품 컬렉션을 직접 들고 있기보다 상품 조회용 리포지토리나 조회 전용 DAO를 통해 <code>categoryId</code> 조건으로 페이징 조회하는 편이 더 적절하다. </p>
<p>다대다 연관도 일대다 연관과 마찬가지로, 실제 요구사항을 고려해서 구현에 포함할지를 결정해야 한다. 카테고리와 상품을 예로 들면, 개념적으로는 상품과 카테고리 사이에 양방향 다대다 관계가 존재할 수 있다. 하지만 실제 구현에서는 요구사항에 필요한 방향만 반영하면 된다.</p>
<p>예를 들어 상품 상세 화면에서 상품이 속한 카테고리 정보만 필요하다면, 상품에서 카테고리 ID 목록을 참조하거나 별도 조회 쿼리로 상품과 카테고리 관계를 읽어오는 방식으로 충분할 수 있다. 반대로 특정 카테고리에 속한 상품 목록이 필요하다면, 카테고리가 상품 컬렉션을 직접 들고 있기보다 상품 조회용 DAO나 조회 전용 쿼리를 통해 페이징해서 조회하는 편이 더 적절하다.</p>
<p>&nbsp;</p>
<h1 id="🏭-애그리거트를-팩토리로-사용하기">🏭 애그리거트를 팩토리로 사용하기</h1>
<p>고객이 특정 상점을 신고해서 해당 상점이 더 이상 물건을 등록하지 못하는 상황을 생각해보자. 상품 등록 기능을 구현한 응용 서비스는 상점 계정이 차단 상태가 아닌 경우에만 상품을 등록할 수 있도록 로직을 구현해야 할 것이다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class RegisterProductService {

    ...

    public ProductId registerNewProduct(NewProductRequest request) {
        Store store = storeRepository.findById(request.getStoreId());
        checkNull(store);
        if (store.isBlocked()) {
            throw new StoreBlockedException();
        }
        ProductId productId = productRepository.nextId();
        Product product = new Product(productId, store.getId(), ...);
        productRepository.save(product);
        return productId;
    }

    ...
}</code></pre>
<p>겉으로 보기에는 문제가 없어 보이지만, <code>Store</code>가 상품을 생성할 수 있는지 여부를 검사하고 상품을 생성하는 로직은 분명 논리적으로 하나의 도메인 기능인데 현재 응용 서비스에서 구현하고 있는 것이다. 해당 도메인 기능을 별도의 도메인 서비스나 팩토리 클래스를 만들 수도 있지만 아래와 같이 <code>Store</code> 애그리거트에 구현하는 방법도 있다. </p>
<pre><code class="language-java">public class Store {
    ...

    public Product createProduct(ProductId newProductId, ...) {
        if (isBlocked()) {
            throw new StoreBlockedException();
        }
        return new Product(newProductId, getId(), ...);
    }
}</code></pre>
<p><code>Store</code> 애그리거트의 <code>createProduct()</code> 메서드는 <code>Product</code> 애그리거트를 생성하는 팩토리 역할을 하면서도 중요한 도메인 로직을 구현하고 있다. 이제 응용 서비스에서 해당 팩토리 기능을 사용하기만 하면 된다.</p>
<pre><code class="language-java">public class RegisterProductService {
    ...

    public ProductId registerNewProduct(NewProductRequest request) {
        Store store = storeRepository.findById(request.getStoreId());
        checkNull(store);
        ProductId productId = productRepository.nextId();
        Product product = store.createProduct(productId, ...);
        productRepository.save(product);
        return productId;
    }

    ...
}</code></pre>
<p>이제 상품을 생성할 수 있는지 여부를 검사하는 도메인 로직에 변경이 일어나더라도 응용 서비스는 전혀 영향을 받지 않기 때문에 도메인의 응집도가 높아진 것이다. 이게 바로 애그리거트를 팩토리로 사용할 때의 장점이다. 따라서 앞으로 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 이런 식으로 애그리거트에 팩토리 메서드를 구현하는 것을 고려하도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java Stream 이해하기]]></title>
            <link>https://velog.io/@rocker_nun/Java-Stream-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-vm4fqdrr</link>
            <guid>https://velog.io/@rocker_nun/Java-Stream-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-vm4fqdrr</guid>
            <pubDate>Fri, 01 May 2026 08:51:13 GMT</pubDate>
            <description><![CDATA[<h1 id="↩️-외부-반복자에서-내부-반복자로">↩️ 외부 반복자에서 내부 반복자로</h1>
<p>자바에서 컬렉션을 다룰 때 우리는 보통 <code>for</code>, <code>while</code>, <code>Iterator</code>를 사용해왔다. 예를 들어 숫자 리스트에서 짝수만 출력한다고 해보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

for (int number : numbers) {
    if (number % 2 == 0) {
        System.out.println(number);
    }
}</code></pre>
<p>이 코드는 굉장히 익숙하다. 리스트에서 값을 하나씩 꺼내고, 조건을 검사하고, 조건에 맞으면 출력한다.</p>
<pre><code class="language-text">1 꺼냄 → 짝수인지 검사
2 꺼냄 → 짝수인지 검사 → 출력
3 꺼냄 → 짝수인지 검사
4 꺼냄 → 짝수인지 검사 → 출력
5 꺼냄 → 짝수인지 검사</code></pre>
<p>이처럼 개발자가 컬렉션 바깥에서 직접 요소를 꺼내며 반복을 제어하는 방식을 <strong>외부 반복자</strong>라고 한다.</p>
<p>&nbsp;</p>
<p>반면 Java 8부터 자주 사용하게 된 <code>Stream</code>은 조금 다른 방식으로 컬렉션을 처리한다.</p>
<pre><code class="language-java">numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .forEach(number -&gt; System.out.println(number));</code></pre>
<p>이 코드에는 <code>for</code>, <code>while</code>, <code>Iterator</code>가 보이지 않는다. 그렇다고 반복이 사라진 것이 아니라 반복은 여전히 일어난다. 다만 개발자가 직접 반복을 제어하지 않을 뿐이다. 스트림 내부에서 반복이 일어나고, 개발자는 각 요소에 적용할 처리 규칙만 람다식으로 전달한다. 이것이 바로 <strong>내부 반복자</strong> 방식이다.</p>
<p>&nbsp;</p>
<h1 id="💉-스트림을-비유로-이해하기">💉 스트림을 비유로 이해하기</h1>
<p>스트림을 처음 접하면 이런 코드가 낯설게 느껴진다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();</code></pre>
<p><code>stream()</code>은 무엇이고, <code>filter()</code>는 무엇이며, <code>map()</code>은 무엇이고, 마지막에 <code>toList()</code>는 왜 필요한 걸까? 이걸 다음과 같이 비유해서 이해해보자.</p>
<pre><code class="language-text">내 몸 = 컬렉션
주사기 = 스트림
주사하는 것 = 람다식으로 전달하는 처리 규칙
중간 처리기 = filter, map, sorted
추출기 = forEach, sum, collect, toList</code></pre>
<p>여기서 중요한 점은 <strong>스트림이 원본 컬렉션을 직접 바꾸는 도구가 아니라는 것</strong>이다. 스트림은 컬렉션 안의 데이터를 하나씩 흘려보내면서, 중간중간 람다식으로 전달한 처리 규칙을 적용하고, 마지막에 원하는 결과를 꺼내 쓰는 방식이다. 즉, 스트림을 정리하면 다음과 같다.</p>
<blockquote>
<p><em><strong>&quot;스트림은 컬렉션의 원본 데이터를 직접 수정하지 않고, 요소들을 하나씩 흘려보내며 처리한 뒤, 최종 연산을 통해 원하는 결과를 꺼내 쓰는 데이터 처리 방식이다.&quot;</strong></em></p>
</blockquote>
<p>&nbsp;</p>
<h1 id="🎭-컬렉션은-원본-데이터다">🎭 컬렉션은 원본 데이터다</h1>
<p>먼저 컬렉션이 있다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);</code></pre>
<p>위 <code>numbers</code>는 원본 데이터다. 비유하자면 내 몸 안에 있는 데이터라고 볼 수 있다.</p>
<pre><code class="language-text">내 몸 = 컬렉션
몸 안의 요소들 = 컬렉션 안의 데이터</code></pre>
<p>&nbsp;</p>
<p><code>numbers</code> 안에는 1, 2, 3, 4, 5라는 값이 들어 있다. 이제 이 컬렉션을 스트림으로 바꿔보면...</p>
<pre><code class="language-java">Stream&lt;Integer&gt; stream = numbers.stream();</code></pre>
<p>위와 같이 <code>stream()</code>을 호출했다고 해서 원본 리스트가 바뀌는 것은 아니다. <code>numbers</code>가 사라지는 것도 아니고, 새로운 리스트가 바로 만들어지는 것도 아니다. 단지 컬렉션 안의 요소들을 하나씩 처리할 수 있는 <strong>흐름</strong>을 만든 것이다.</p>
<blockquote>
<p><em><strong>&quot;numbers.stream(): numbers 안의 요소들을 하나씩 흘려보낼 수 있는 통로를 만든다 == 빨대를 꽂는다(강사님 피셜)&quot;</strong></em></p>
</blockquote>
<p>&nbsp;</p>
<h1 id="🌊-스트림은-결과물이-아니라-흐름이다">🌊 스트림은 결과물이 아니라 흐름이다</h1>
<p>스트림을 처음 배울 때 가장 헷갈리는 부분이 있었는데, 바로 스트림 자체를 결과물처럼 생각하는 것이다. 예를 들어 다음 코드를 보자.</p>
<pre><code class="language-java">numbers.stream().filter(number -&gt; number % 2 == 0);</code></pre>
<p>이 코드는 짝수만 골라낸 것처럼 보인다. 하지만 이 시점에서 아직 <code>List&lt;Integer&gt;</code> 결과가 만들어진 것은 아니다. 찍어보면 위 코드의 타입은 여전히 <code>Stream&lt;Integer&gt;</code>인 것을 확인할 수 있다. 즉, 결과 리스트가 아니라 스트림이다.</p>
<p>주사기 비유로 마저 설명해보자면 대략 아래와 느낌이다.</p>
<ol>
<li>주사기로 데이터를 뽑았는데,</li>
<li>아직 결과물을 꺼낸 것이 아니라</li>
<li>처리 규칙이 연결된 주사기 상태로 들고 있는 것이다.</li>
</ol>
<p>따라서 <em>&quot;<code>filter()</code> 처리했으니까 필터링된 결과가 리스트로 담겼겠네?&quot;</em> 라는 이해는 완전히 잘못된 것이다. <code>filter</code>, <code>map</code>, <code>sorted</code> 같은 중간 연산은 결과물을 바로 꺼내주지 않는다. 대부분 다시 <code>Stream</code>을 반환한다.</p>
<pre><code class="language-java">numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10);</code></pre>
<p>위 코드도 아직 최종 결과가 아니라 <code>numbers</code>에서 요소를 흘려보내는데, 짝수만 통과시키고, 통과한 값을 10배한 값으로 바꾼 처리 규칙이 명시된 스트림일 뿐이다. 따라서 <strong>결과물을 실제로 꺼내려면 마지막에 최종 연산이 필요</strong>하다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();</code></pre>
<p>위와 같이 작성해야 결과가 리스트로 나온다. 정리하면 중간 연산만 연결했다면 여전히 <code>Stream</code> 타입이고 최종 연산을 해야 비로소 실제 결과물을 얻을 수 있다는 것이다.</p>
<p>&nbsp;</p>
<h1 id="📝-람다식은-스트림에-전달하는-처리-규칙이다">📝 람다식은 스트림에 전달하는 처리 규칙이다</h1>
<p>스트림에서는 람다식이 자주 등장한다. 예를 들어 다음 코드를 보자.</p>
<pre><code class="language-java">.filter(number -&gt; number % 2 == 0)</code></pre>
<p>위 코드에서 람다식은 <code>number -&gt; number % 2 == 0</code> 부분인데, 이 람다식은 <em>&quot;숫자 하나를 받아서 짝수인지 검사하라&quot;</em> 와 같은 의미다.</p>
<p>스트림은 컬렉션의 요소를 하나씩 흘려보내면서 이 람다식을 적용하는 것이다.</p>
<ul>
<li>1 들어옴 → 1 % 2 == 0 → false → 버림</li>
<li>2 들어옴 → 2 % 2 == 0 → true  → 통과</li>
<li>3 들어옴 → 3 % 2 == 0 → false → 버림</li>
<li>4 들어옴 → 4 % 2 == 0 → true  → 통과</li>
<li>5 들어옴 → 5 % 2 == 0 → false → 버림</li>
</ul>
<p>결국 2와 4만 통과하게 되는 것이다.</p>
<p>&nbsp;</p>
<p>이번에는 <code>map</code>을 보자.</p>
<pre><code class="language-java">.map(number -&gt; number * 10)</code></pre>
<p>여기서 람다식은 <code>number -&gt; number * 10</code>인데 숫자 하나를 받아서 10배로 바꾸라는 같은 의미를 갖는다. 따라서 앞에서 <code>filter</code>를 통과한 2, 4는 <code>map</code>을 지나면서 각각 20과 40으로 바뀌는 것이다. 즉, 람다식은 스트림에게 전달하는 처리 규칙이다.</p>
<p>&nbsp;</p>
<ul>
<li><code>filter()</code>에 전달하는 람다식: 어떤 요소를 통과시킬지 판단하는 규칙</li>
<li><code>map()</code>에 전달하는 람다식: 요소를 어떤 값으로 바꿀지 정하는 규칙</li>
<li><code>forEach()</code>에 전달하는 람다식: 요소를 어떻게 소비할지 정하는 규칙</li>
</ul>
<p>&nbsp;</p>
<h1 id="🧩-중간-연산-filter-map-sorted">🧩 중간 연산: filter, map, sorted</h1>
<p>스트림에는 크게 중간 연산, 최종 연산이 있다. 먼저 중간 연산부터 보자.</p>
<p>중간 연산은 스트림을 받아서 다시 스트림을 반환하는 연산이다. 대표적으로 다음과 같은 것들이 있다.</p>
<ul>
<li><code>filter()</code></li>
<li><code>map()</code></li>
<li><code>sorted()</code></li>
<li><code>distinct()</code></li>
<li><code>limit()</code></li>
<li><code>skip()</code></li>
</ul>
<p>&nbsp;</p>
<p>중간 연산의 특징은 다음과 같다.</p>
<ol>
<li>최종 결과를 바로 만들지 않는다.</li>
<li>대부분 <code>Stream</code>을 다시 반환한다.</li>
<li>여러 개를 연결해서 사용할 수 있다.</li>
<li>최종 연산이 호출되기 전까지 실제로 실행되지 않는다.</li>
</ol>
<p>&nbsp;</p>
<h2 id="📌-filter-조건에-맞는-요소만-통과시킨다">📌 filter(): 조건에 맞는 요소만 통과시킨다</h2>
<p><code>filter</code>는 조건에 맞는 요소만 통과시킨다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .toList();

System.out.println(result);

/**
 * [2, 4]
 */</code></pre>
<p><code>filter</code>에 전달된 람다식은 <code>number -&gt; number % 2 == 0</code>로 각 요소에 대해 <code>true</code> 또는 <code>false</code>를 반환하고 <code>true</code>는 통과, <code>false</code>는 제외시킨다. 따라서 <code>filter</code>는 조건문과 비슷한 역할을 한다.</p>
<p>외부 반복자로 작성하면 다음과 같다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = new ArrayList&lt;&gt;();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number);
    }
}</code></pre>
<p>스트림으로 작성하면 다음과 같다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .toList();</code></pre>
<p>&nbsp;</p>
<h2 id="⛏️-map-요소를-다른-값으로-변환한다">⛏️ map(): 요소를 다른 값으로 변환한다</h2>
<p><code>map</code>은 스트림을 지나가는 요소를 다른 값으로 변환한다.</p>
<pre><code class="language-java">List&lt;String&gt; names = List.of(&quot;kim&quot;, &quot;lee&quot;, &quot;park&quot;);

List&lt;String&gt; result = names.stream()
        .map(name -&gt; name.toUpperCase())
        .toList();

System.out.println(result);

/**
 * [KIM, LEE, PARK]
 */</code></pre>
<p>&nbsp;</p>
<p>원본 리스트는 바뀌지 않는다.</p>
<pre><code class="language-java">System.out.println(names);  // [kim, lee, park]</code></pre>
<p>즉, <code>map</code>은 원본 데이터를 직접 수정하는 것이 아니다. 스트림을 지나가는 값을 새로운 값으로 변환해서 다음 단계로 넘긴다.</p>
<pre><code class="language-text">&quot;kim&quot;  → &quot;KIM&quot;
&quot;lee&quot;  → &quot;LEE&quot;
&quot;park&quot; → &quot;PARK&quot;</code></pre>
<p>&nbsp;</p>
<p>객체 리스트에서도 자주 사용한다.</p>
<pre><code class="language-java">List&lt;Student&gt; students = List.of(
        new Student(&quot;Kim&quot;, 80),
        new Student(&quot;Lee&quot;, 90),
        new Student(&quot;Park&quot;, 70)
);

List&lt;String&gt; names = students.stream()
        .map(student -&gt; student.getName())
        .toList();

/**
 * Student(&quot;Kim&quot;, 80)  → &quot;Kim&quot;
 * Student(&quot;Lee&quot;, 90)  → &quot;Lee&quot;
 * Student(&quot;Park&quot;, 70) → &quot;Park&quot;
 */</code></pre>
<p>위와 같이 <code>Student</code> 객체에서 이름만 뽑아 새로운 리스트를 만든다.</p>
<p>&nbsp;</p>
<h2 id="⛓️-sorted-요소를-정렬한다">⛓️ sorted(): 요소를 정렬한다</h2>
<p><code>sorted</code>는 스트림의 요소를 정렬한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(5, 3, 1, 4, 2);

List&lt;Integer&gt; result = numbers.stream()
        .sorted()
        .toList();

System.out.println(numbers);
System.out.println(result);

/**
 * [5, 3, 1, 4, 2]
 * [1, 2, 3, 4, 5]
 */</code></pre>
<p>여기도 마찬가지로 원본 리스트가 바뀌지 않는다. <code>sorted()</code>는 원본 컬렉션을 직접 정렬하는 것이 아니라 스트림을 지나가는 요소들을 정렬된 흐름으로 만들어주고, 최종 연산을 통해 새로운 결과를 꺼내는 것이다.</p>
<p>객체를 정렬할 때는 <code>Comparator</code>를 함께 사용할 수 있다.</p>
<pre><code class="language-java">List&lt;Student&gt; students = List.of(
        new Student(&quot;Kim&quot;, 80),
        new Student(&quot;Lee&quot;, 90),
        new Student(&quot;Park&quot;, 70)
);

List&lt;Student&gt; result = students.stream()
        .sorted(Comparator.comparing(Student::getScore))
        .toList();</code></pre>
<p>이 코드는 학생들을 점수 기준으로 오름차순 정렬한 결과를 새 리스트로 만든다.</p>
<p>&nbsp;</p>
<h1 id="🎊-최종-연산-foreach-sum-collect-tolist">🎊 최종 연산: forEach, sum, collect, toList</h1>
<p>중간 연산만으로는 결과를 꺼낼 수 없다. 스트림에서 결과를 꺼내려면 반드시 최종 연산이 필요하다. 대표적인 최종 연산은 다음과 같다.</p>
<ul>
<li><code>forEach()</code></li>
<li><code>count()</code></li>
<li><code>sum()</code></li>
<li><code>average()</code></li>
<li><code>collect()</code></li>
<li><code>toList()</code></li>
<li><code>anyMatch()</code></li>
<li><code>allMatch()</code></li>
<li><code>findFirst()</code></li>
</ul>
<p>최종 연산의 특징은 다음과 같다.</p>
<ol>
<li>스트림을 실제로 실행시킨다.</li>
<li>결과를 반환하거나 소비한다.</li>
<li>최종 연산 이후 스트림은 다시 사용할 수 없다.</li>
</ol>
<p>&nbsp;</p>
<h2 id="🥪-foreach-요소를-하나씩-소비한다">🥪 forEach(): 요소를 하나씩 소비한다</h2>
<p><code>forEach</code>는 스트림의 요소를 하나씩 꺼내서 소비한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .forEach(number -&gt; System.out.println(number));

/**
 * 2
 * 4
 */</code></pre>
<p><code>forEach</code>는 최종 연산이다. 하지만 결과를 리스트로 모아서 반환하지는 않는다. 각 요소를 하나씩 소비하고 끝낸다. 그래서 <code>forEach</code>는 보통 출력, 로그 확인, 특정 동작 수행 등에 사용된다.</p>
<p>&nbsp;</p>
<h2 id="🗃️-tolist-결과를-리스트로-모은다">🗃️ toList(): 결과를 리스트로 모은다</h2>
<p><code>toList</code>는 스트림의 결과를 리스트로 모은다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();

System.out.println(result);

// [20, 40]</code></pre>
<p>&nbsp;</p>
<p>흐름을 풀어보면 다음과 같다.</p>
<pre><code class="language-text">원본 리스트: [1, 2, 3, 4, 5]

stream()
→ 요소를 하나씩 흘려보낼 준비

filter(number -&gt; number % 2 == 0)
→ 짝수만 통과
→ 2, 4

map(number -&gt; number * 10)
→ 통과한 값을 10배로 변환
→ 20, 40

toList()
→ 결과를 List로 추출
→ [20, 40]</code></pre>
<p>&nbsp;</p>
<h2 id="🔍-collect-원하는-형태로-결과를-수집한다">🔍 collect(): 원하는 형태로 결과를 수집한다</h2>
<p><code>collect</code>는 스트림의 결과를 원하는 자료구조나 형태로 모을 때 사용한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .collect(Collectors.toList());</code></pre>
<p>&nbsp;</p>
<p>Java 16 이후에는 단순히 리스트로 모을 때 <code>toList()</code>를 자주 사용할 수 있다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .toList();</code></pre>
<p>&nbsp; </p>
<p>하지만 <code>collect</code>는 더 다양한 수집 작업을 할 수 있다. 예를 들어 이름들을 하나의 문자열로 합칠 수 있다.</p>
<pre><code class="language-java">List&lt;String&gt; names = List.of(&quot;Kim&quot;, &quot;Lee&quot;, &quot;Park&quot;);

String result = names.stream()
        .collect(Collectors.joining(&quot;, &quot;));

System.out.println(result);  // Kim, Lee, Park</code></pre>
<p>또는 그룹화도 할 수 있다.</p>
<pre><code class="language-java">Map&lt;Integer, List&lt;Student&gt;&gt; result = students.stream()
        .collect(Collectors.groupingBy(Student::getGrade));</code></pre>
<p>즉, <code>collect</code>는 스트림의 결과를 원하는 방식으로 수집하는 강력한 최종 연산이다.</p>
<p>&nbsp;</p>
<h2 id="✚-sum-숫자-값을-합산한다">✚ sum(): 숫자 값을 합산한다</h2>
<p><code>sum</code>은 숫자 스트림에서 합계를 구할 때 사용한다. 일반 <code>Stream&lt;Integer&gt;</code>에서는 바로 <code>sum()</code>을 사용할 수 없다. 보통 <code>mapToInt</code>를 사용해서 <code>IntStream</code>으로 바꾼 뒤 사용한다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

int sum = numbers.stream()
        .mapToInt(number -&gt; number)
        .sum();

System.out.println(sum);  // 15</code></pre>
<p>&nbsp;</p>
<p>객체 리스트에서도 자주 사용한다.</p>
<pre><code class="language-java">List&lt;Student&gt; students = List.of(
        new Student(&quot;Kim&quot;, 80),
        new Student(&quot;Lee&quot;, 90),
        new Student(&quot;Park&quot;, 70)
);

int totalScore = students.stream()
        .mapToInt(student -&gt; student.getScore())
        .sum();

System.out.println(totalScore);</code></pre>
<p>흐름은 다음과 같다.</p>
<pre><code class="language-text">Student 객체들이 지나간다.
→ 각 Student에서 score만 뽑는다.
→ int 값들의 흐름이 된다.
→ sum()으로 합산한다.</code></pre>
<p>&nbsp;</p>
<h1 id="🚫-중간-연산은-최종-연산이-호출되기-전까지-실행되지-않는다">🚫 중간 연산은 최종 연산이 호출되기 전까지 실행되지 않는다</h1>
<p>스트림에서 중요한 특징 중 하나는 <strong>지연 실행(Lazy Evaluation)</strong>이다. 중간 연산은 최종 연산이 호출되기 전까지 실제로 실행되지 않는다. 다음 코드를 보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

Stream&lt;Integer&gt; stream = numbers.stream()
        .filter(number -&gt; {
            System.out.println(&quot;filter 실행: &quot; + number);
            return number % 2 == 0;
        });</code></pre>
<p>이 코드를 실행해도 아무것도 출력되지 않는다. 왜냐하면 아직 최종 연산이 없기 때문이다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">stream.toList();</code></pre>
<p>이렇게 최종 연산을 호출해야 그때 실제로 실행된다.</p>
<pre><code class="language-text">filter 실행: 1
filter 실행: 2
filter 실행: 3
filter 실행: 4
filter 실행: 5</code></pre>
<p>&nbsp;</p>
<p>즉, 스트림의 중간 연산은 바로 실행되는 것이 아니라, 최종 연산이 호출될 때 한 번에 동작한다. 비유하자면 다음과 같다.</p>
<pre><code class="language-text">filter, map, sorted를 연결하는 것은
주사기에 처리 장치를 연결해두는 것과 비슷하다.

하지만 아직 실제로 뽑아낸 것은 아니다.

toList, collect, forEach 같은 최종 연산을 해야
비로소 데이터가 흐르면서 처리되고 결과가 나온다.</code></pre>
<p>&nbsp;</p>
<h1 id="😢-스트림은-한-번-사용하면-다시-사용할-수-없다">😢 스트림은 한 번 사용하면 다시 사용할 수 없다</h1>
<p>스트림은 한 번 최종 연산을 수행하면 다시 사용할 수 없다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

Stream&lt;Integer&gt; stream = numbers.stream();

stream.forEach(System.out::println);

stream.forEach(System.out::println);  // 예외 발생</code></pre>
<p>두 번째 <code>forEach</code>에서는 예외가 발생한다. 스트림은 일회용이다. 한 번 최종 연산으로 소비되면 다시 사용할 수 없다. 다시 사용하고 싶다면 원본 컬렉션에서 스트림을 새로 만들어야 한다.</p>
<pre><code class="language-java">numbers.stream().forEach(System.out::println);
numbers.stream().forEach(System.out::println);</code></pre>
<p>&nbsp;</p>
<p>비유하면 다음과 같다.</p>
<pre><code class="language-text">한 번 사용한 주사기는 버리고 새로운 주사기를 사용해야 한다.
다시 처리하고 싶다면 원본 컬렉션에서 새로운 스트림을 만들어야 한다는 말이다.</code></pre>
<p>&nbsp;</p>
<h1 id="🤔-외부-반복자란-무엇인가">🤔 외부 반복자란 무엇인가?</h1>
<p>이제 스트림을 외부 반복자와 내부 반복자 관점에서 다시 살펴보자. 그동안 컬렉션을 다룰 때 <code>for</code>, <code>while</code>, <code>Iterator</code>를 사용해왔다. 이 방식은 외부 반복자 방식이다. 외부 반복자는 개발자가 직접 컬렉션의 요소를 하나씩 꺼내며 반복을 제어하는 방식이다.</p>
<p>예를 들어 다음 코드를 보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = new ArrayList&lt;&gt;();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number * 10);
    }
}

System.out.println(result);</code></pre>
<p>&nbsp;</p>
<p>위 코드에서 개발자는 다음 작업을 직접 수행한다.</p>
<ol>
<li>새로운 리스트를 만든다.</li>
<li>numbers에서 숫자를 하나씩 꺼낸다.</li>
<li>짝수인지 검사한다.</li>
<li>짝수라면 10을 곱한다.</li>
<li>결과 리스트에 추가한다.</li>
<li>반복이 끝나면 결과 리스트를 사용한다.</li>
</ol>
<p>즉, 반복의 흐름을 개발자가 직접 제어한다. <code>Iterator</code>를 사용해도 마찬가지다.</p>
<pre><code class="language-java">Iterator&lt;Integer&gt; iterator = numbers.iterator();

while (iterator.hasNext()) {
    Integer number = iterator.next();

    if (number % 2 == 0) {
        result.add(number * 10);
    }
}</code></pre>
<p>여기서도 개발자는 직접 다음 요소가 있는지 확인하고, 직접 요소를 꺼낸다.</p>
<pre><code class="language-java">iterator.hasNext()
iterator.next()</code></pre>
<p>이처럼 컬렉션 바깥에서 개발자가 반복을 직접 제어하는 방식이 외부 반복자다.</p>
<p>&nbsp;</p>
<h1 id="🤔-내부-반복자란-무엇인가">🤔 내부 반복자란 무엇인가?</h1>
<p>내부 반복자는 반복의 제어권을 개발자가 직접 가지지 않고, 스트림 내부에 맡기는 방식이다. 앞의 코드를 스트림으로 바꿔보자.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(1, 2, 3, 4, 5);

List&lt;Integer&gt; result = numbers.stream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();

System.out.println(result);</code></pre>
<p>&nbsp;</p>
<p>이 코드에서 개발자는 직접 요소를 꺼내지 않는다. 대신 스트림에게 다음과 같이 요청한다.</p>
<ol>
<li>numbers에서 스트림을 만들어줘.</li>
<li>짝수만 통과시켜줘.</li>
<li>통과한 값을 10배로 바꿔줘.</li>
<li>리스트로 모아줘.</li>
</ol>
<p>반복은 스트림 내부에서 일어난다. 개발자는 각 요소에 적용할 처리 규칙만 람다식으로 전달한다. 즉, 내부 반복자는 반복은 스트림 내부에 맡기고, 개발자는 처리 규칙만 전달하는 방식이라고 이해할 수 있다.</p>
<p>&nbsp;</p>
<p>외부 반복자와 내부 반복자의 차이점은 집중하는 관점이 다르다. </p>
<p><em><strong>&lt;외부 반복자&gt;</strong></em></p>
<ul>
<li>어떻게 반복할 것인가?</li>
<li>어떻게 요소를 꺼낼 것인가?</li>
<li>언제 결과 리스트에 추가할 것인가?</li>
</ul>
<p><em><strong>&lt;내부 반복자&gt;</strong></em></p>
<ul>
<li>무엇을 골라낼 것인가?</li>
<li>무엇으로 변환할 것인가?</li>
<li>어떤 결과로 모을 것인가?</li>
</ul>
<p>&nbsp;</p>
<p>따라서 스트림은 단순히 반복문을 짧게 쓰는 문법이 아니다. 스트림은 데이터 처리 과정을 더 선언적으로 표현하게 해주는 도구라고 할 수 있다.</p>
<p>&nbsp;</p>
<h1 id="👍-스트림의-장점">👍 스트림의 장점</h1>
<p>외부 반복자 방식은 반복 로직 때문에 핵심 의도가 묻힐 때가 있다.</p>
<pre><code class="language-java">List&lt;String&gt; result = new ArrayList&lt;&gt;();

for (Student student : students) {
    if (student.getScore() &gt;= 60) {
        result.add(student.getName());
    }
}</code></pre>
<p>이 코드는 다음 과정을 직접 따라가야 한다.</p>
<pre><code class="language-text">학생을 하나씩 꺼낸다.
점수가 60점 이상인지 검사한다.
조건에 맞으면 이름을 결과 리스트에 추가한다.</code></pre>
<p>&nbsp;</p>
<p>반면, 위와 코드를 똑같이 스트림으로 작성하면 의도가 더 직접적으로 드러난다.</p>
<pre><code class="language-java">List&lt;String&gt; result = students.stream()
        .filter(student -&gt; student.getScore() &gt;= 60)
        .map(student -&gt; student.getName())
        .toList();</code></pre>
<p>이 코드는 다음처럼 읽힌다.</p>
<pre><code class="language-text">60점 이상인 학생만 골라서
이름만 뽑고
리스트로 만든다.</code></pre>
<p>&nbsp;</p>
<p>두 번째 장점으로, 스트림은 여러 중간 연산을 연결해서 사용할 수 있다.</p>
<pre><code class="language-java">List&lt;String&gt; result = students.stream()
        .filter(student -&gt; student.getScore() &gt;= 60)
        .map(student -&gt; student.getName())
        .sorted()
        .toList();</code></pre>
<p>이 코드는 다음처럼 읽을 수 있다.</p>
<pre><code class="language-text">60점 이상인 학생만 고른다.
학생 객체에서 이름만 뽑는다.
이름을 정렬한다.
리스트로 만든다.</code></pre>
<p>각 단계가 파이프라인처럼 이어지기 때문에 데이터 처리 흐름을 파악하기 좋다.</p>
<p>&nbsp;</p>
<p>세 번째 장점으로는 스트림은 원본 데이터를 직접 변경하지 않는다는 것이다.</p>
<pre><code class="language-java">List&lt;Integer&gt; numbers = List.of(5, 3, 1, 4, 2);

List&lt;Integer&gt; result = numbers.stream()
        .sorted()
        .toList();

System.out.println(numbers);
System.out.println(result);

/*
 * [5, 3, 1, 4, 2]
 * [1, 2, 3, 4, 5]
 */</code></pre>
<p>원본 리스트는 그대로 유지되고, 정렬된 결과가 새롭게 만들어진다. 이 점은 데이터를 안전하게 다루는 데 도움이 된다.</p>
<p>&nbsp;</p>
<p>그리고 스트림은 내부 반복자 방식이라고 했다. 이 말은 반복의 제어권을 개발자가 직접 가지는 것이 아니라 스트림이 가진다는 말이고, 이 구조 덕분에 경우에 따라 병렬 처리를 적용하기 쉽다.</p>
<pre><code class="language-java">List&lt;Integer&gt; result = numbers.parallelStream()
        .filter(number -&gt; number % 2 == 0)
        .map(number -&gt; number * 10)
        .toList();</code></pre>
<p>물론 <code>parallelStream()</code>이 항상 좋은 것은 아니다. 데이터 양이 적거나, 처리 비용이 작거나, 순서가 중요하거나, 공유 자원을 변경하는 작업이 있다면 오히려 문제가 될 수 있다. 하지만 구조적으로 보면 내부 반복자는 반복 제어권을 라이브러리가 가지고 있기 때문에, 최적화나 병렬 처리에 유리한 여지가 생긴다.</p>
<p>&nbsp;</p>
<h1 id="🫣-스트림이-항상-정답은-아니다">🫣 스트림이 항상 정답은 아니다</h1>
<p>스트림이 편리하다고 해서 항상 스트림을 써야 하는 것은 아니다. 단순한 반복은 오히려 <code>for</code>문이 더 읽기 쉬울 수 있다.</p>
<pre><code class="language-java">for (int i = 0; i &lt; 5; i++) {
    System.out.println(i);
}</code></pre>
<p>이런 코드를 굳이 스트림으로 바꾸면 오히려 어색해질 수 있다.</p>
<p>또한 반복 중간에 복잡한 상태 변경이 필요하거나, <code>break</code>, <code>continue</code>처럼 흐름 제어가 중요한 경우에는 일반 반복문이 더 적합할 수 있다. 예를 들어 특정 학생을 찾으면 즉시 반복을 멈추고 싶다고 해보자.</p>
<pre><code class="language-java">for (Student student : students) {
    if (student.getName().equals(&quot;Kim&quot;)) {
        System.out.println(&quot;찾았다!&quot;);
        break;
    }
}</code></pre>
<p>스트림에도 <code>findFirst</code>, <code>anyMatch</code> 같은 최종 연산이 있지만, 복잡한 흐름 제어가 들어가면 오히려 코드가 읽기 어려워질 수 있다.</p>
<p>&nbsp;</p>
<p>따라서 기준은 다음과 같이 잡으면 좋다.</p>
<ul>
<li>단순 반복, 복잡한 흐름 제어가 필요하다 → for문이 나을 수 있다.</li>
<li>필터링, 변환, 정렬, 집계가 중심이다 → 스트림이 잘 어울린다.</li>
</ul>
<p>&nbsp;</p>
<h1 id="🖋️-정리">🖋️ 정리</h1>
<p>스트림을 처음 배울 때는 다음 세 가지를 반드시 기억해야 한다.</p>
<p>첫 번째, 스트림은 원본 컬렉션을 직접 변경하지 않는다.</p>
<pre><code class="language-text">원본 데이터는 그대로 두고,
처리 결과를 새롭게 만들어서 사용한다.</code></pre>
<p>&nbsp;</p>
<p>두 번째, 중간 연산만으로는 결과가 나오지 않는다.</p>
<pre><code class="language-text">filter, map, sorted는 중간 처리기다.
이 연산들은 대부분 다시 Stream을 반환한다.
따라서 최종 연산을 해야 결과를 꺼낼 수 있다.</code></pre>
<p>&nbsp;</p>
<p>세 번째, 스트림은 내부 반복자를 사용한다.</p>
<pre><code class="language-text">for, while, Iterator는 외부 반복자다.
개발자가 직접 요소를 꺼내며 반복을 제어한다.

Stream은 내부 반복자다.
반복은 스트림 내부에서 일어나고,
개발자는 람다식으로 처리 규칙만 전달한다.</code></pre>
<p>&nbsp;</p>
<p>결국 스트림은 다음과 같이 이해할 수 있다.</p>
<pre><code class="language-text">스트림은 컬렉션의 요소를 직접 꺼내던 외부 반복 방식에서 벗어나,
컬렉션 내부에서 요소를 흘려보내며
람다식으로 전달한 처리 규칙을 적용하고,
최종 연산으로 결과를 꺼내는 내부 반복 기반의 데이터 처리 방식이다.</code></pre>
<p>&nbsp;</p>
<p>처음 비유로 다시 돌아가면 다음과 같다.</p>
<pre><code class="language-text">내 몸 = 컬렉션
주사기 = 스트림
주사하는 것 = 람다식으로 전달하는 처리 규칙
중간 처리기 = filter, map, sorted
추출기 = forEach, sum, collect, toList</code></pre>
<p>&nbsp;</p>
<p>단, 스트림은 결과물이 담긴 주사기 자체가 아니다. 스트림은 데이터를 흘려보내며 처리하는 흐름이다. 따라서 중간 처리기만 연결해두면 아직 결과물이 아니라 <code>Stream</code> 타입일 뿐이다. 결과물을 사용하려면 반드시 <code>forEach</code>, <code>sum</code>, <code>collect</code>, <code>toList</code> 같은 최종 연산으로 꺼내야 한다. 이 관점을 잡으면 스트림 코드는 훨씬 자연스럽게 읽힌다.</p>
<p>스트림은 단순히 반복문을 짧게 쓰기 위한 문법이 아니다. 스트림은 컬렉션 데이터를 더 선언적으로, 더 조합하기 쉽게, 그리고 원본 데이터를 직접 변경하지 않는 방식으로 처리하기 위한 도구다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[아키텍처 개요]]></title>
            <link>https://velog.io/@rocker_nun/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%9A%94</link>
            <guid>https://velog.io/@rocker_nun/%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EA%B0%9C%EC%9A%94</guid>
            <pubDate>Fri, 01 May 2026 06:58:57 GMT</pubDate>
            <description><![CDATA[<h1 id="4️⃣-네-개의-영역">4️⃣ 네 개의 영역</h1>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/33c3f699-cac6-4e61-99be-65e76c1de4fe/image.png" alt=""></p>
<p>위 4개의 영역 중 <strong>표현(Presentation)</strong> 영역은 사용자의 요청을 받아 <strong>응용(Application)</strong> 영역에 전달하고 응용 영역의 결과를 다시 사용자에게 보여주는 역할을 한다. 표현 영역을 통해 사용자의 요청을 전달받는 응용 영역은 시스템이 사용자에게 제공해야 할 기능을 구현하는데 <em>“주문 등록”</em>, <em>“주문 취소”</em>, <em>“상품 상세 조회”</em> 와 같은 기능 구현을 예로 들 수 있다. 응용 영역 역시 기능을 구현하기 위해 도메인 영역의 도메인 모델을 사용한다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class CancelOrderService {

    @Transactional
    public void cancelOrder(String orderId) {
        Order order = findOrderById(orderId);
        if (order == null) {
            throw new OrderNotFoundException(orderId);          
        }
        order.cancel();
    }

    ...
}</code></pre>
<p>주문 취소 기능을 제공하는 응용 서비스 코드다. 이처럼 응용 서비스는 로직을 직접 수행하기보다는 <code>Order</code>라는 도메인 모델에 책임을 위임해서 <code>Order</code> 클래스가 <code>cancel()</code> 메서드가 실행하도록 처리했다. 이처럼 응용 영역은 도메인 모델을 이용해서 사용자에게 제공할 기능을 구현한다. 실제 도메인 로직 구현은 도메인 모델에 위임한다. </p>
<p><strong>인프라스트럭처(Infrastructure)</strong> 영역은 DB 연동, 메시징 큐에 메시지를 전송하거나 수신하는 기능을 구현하는 등 논리적인 개념을 표현하기보다는 실제 구현을 다루는 영역이다. 표현 영역은 HTTP, JSON, 웹 프레임워크 같은 기술을 사용해 외부 요청을 처리한다. 응용 영역도 트랜잭션 처리와 같은 프레임워크 기능을 일부 사용할 수 있다. 다만 핵심 도메인 규칙은 특정 구현 기술에 의존하지 않도록 도메인 영역에 위치시키는 것이 중요하다. 인프라스트럭처 영역은 DB, 메시징, 외부 API, 룰 엔진 같은 구체 기술을 담당한다.</p>
<p>&nbsp;</p>
<h1 id="↕️-계층-구조-아키텍처">↕️ 계층 구조 아키텍처</h1>
<p>위의 4개의 영역으로 나눠놓은 계층 구조에서는 상위 계층에서 하위 계층으로의 의존만 존재할 뿐, 그 반대 방향의 의존은 존재하지 않는다. 계층 구조를 엄격하게 지킨다고 한다면, 바로 아래의 하위 계층에만 의존해야 하지만, 상황에 따라 더 아래 계층에도 의존할 수도 있다.</p>
<img src="https://velog.velcdn.com/images/rocker_nun/post/114bd3dd-71a0-4bb9-9a51-c946db8589b6/image.png" width=500 />


<p>결국 응용 계층과 도메인 계층은 인프라스트럭처 계층의 기능을 사용하기 때문에 위와 같은 계층 구조가 직관적으로 이해하기 쉬울 것이다. 다만, 각 계층이 구체적인 구현 기술이 담겨 있는 인프라스트럭처 계층에 종속된다는 점은 명백한 사실이다. </p>
<p>&nbsp;</p>
<p>예시로 도메인의 가격 계산 규칙 코드를 보자.</p>
<pre><code class="language-java">public class DroolsRuleEngine {
    private KieContainer kContainer;

    public DroolsRuleEngine() {
        KieServices ks = KieServices.Factory.get();
        kContainer = ks.getKieClasspathContainer();
    }

    public void evaluate(String sessionName, List&lt;?&gt; facts) {
        KieSession kSession = kContainer.newKieSession(sessionName);
        try {
            facts.forEach(x -&gt; kSession.insert(x));
            kSession.fireAllRules();
        } finally {
            kSession.dispose();
        }
    }
}</code></pre>
<p>&nbsp;</p>
<p><code>evaluate()</code> 메서드에 파라미터를 넘기면 별도 파일로 작성한 규칙을 이용해서 연산을 수행하는 간단한 코드다. 이제 응용 영역은 가격 계산을 위해 인프라스트럭처 영역의 <code>DroolsRuleEngine</code>을 사용할 것이다. </p>
<pre><code class="language-java">// 응용 계층의 서비스 코드
public class CalculateDiscountService {
    private DroolsRuleEngine ruleEngine;

    public CalculateDiscountService() {
        ruleEngine = new DroolsRuleEngine();
    }

    public Money calculateDiscount(List&lt;OrderLine&gt; orderLines, String customerId) {
        Customer customer = findCustomer(customerId);

        MutableMoney money = new MutableMoney(0);

        List&lt;?&gt; facts = new ArrayList&lt;&gt;();
        facts.add(customer);
                facts.add(money);
        facts.addAll(orderLines);

        ruleEngine.evaluate(&quot;discountCalculation&quot;, facts);
        return money.toImmutableMoney();
    }

    ...
}</code></pre>
<p>별다른 문제가 없어 보이지만, 2가지 치명적인 문제가 있다. 먼저 <code>CalculateDiscountService</code> 클래스만 <strong>테스트하기 어렵다</strong>는 것이다. 왜냐하면 해당 클래스를 테스트하기 위해서는 그 전에 <code>ruleEngine</code>이 완벽하게 동작해야 한다. 두 번째 <strong>문제는 구현 방식을 변경하기 어렵다</strong>는 것이다. <code>evaluate()</code> 메서드로 연산을 수행하기 위해 <em>“discountCalculation”</em> 라는 이름의 세션을 넘겨야 하는데 이는 <code>Drools</code>의 세션 이름이다. 따라서 그 세션 이름이 바뀐다? <code>CalculateDiscountService</code> 코드까지 찾아와서 추가로 수정해줘야 한다. <code>Drools</code>가 아닌 다른 구현 기술로 변경하기로 결정했다면 문제는 더욱 커진다. </p>
<p>&nbsp;</p>
<h1 id="👍-dip로-문제-해결">👍 DIP로 문제 해결</h1>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/24f7f334-3781-4bf5-93f7-58543cd685ef/image.png" alt=""></p>
<p>가격 할인 계산 기능을 구현하는 <code>CalculateDiscountService</code> 고수준 모듈은 고객 정보도 구해야 하고, 룰을 실행하는 등 여러 하위 기능이 필요한 상황이다. 앞선 문제점들을 해결하기 위해서는 저수준 모듈이 고수준 모듈에 의존하도록 설계를 변경해야 할 필요가 있다. 바로 이때 <strong>인터페이스(Interface)</strong>를 사용하면 된다.</p>
<pre><code class="language-java">// 고객 정보와 룰을 적용해서 할인 금액을 구하는 역할을 하는 인터페이스
public interface RuleDiscounter {
    Money applyRules(Customer customer, List&lt;OrderLine&gt; orderLines);
}

// 인터페이스에 의존하도록 리팩토링 한 서비스 코드
public class CalculateDiscountService {
    private RuleDiscounter ruleDiscounter;

    public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
        this.ruleDiscounter = ruleDiscounter;
    }

    public Money calculateDiscount(List&lt;OrderLine&gt; orderLines, String customerId) {
        Customer customer = findCustomer(customerId);
        return ruleDiscounter.applyRules(customer, orderLines);
    }

    ...
}</code></pre>
<p>이제 더 이상 <code>Drools</code>에 의존하는 코드는 전혀 찾아볼 수 없다. 그냥 <code>RuleDiscounter</code>가 룰을 적용한다는 사실만 알 뿐이다. 이제 상황에 맞게 해당 인터페이스를 구현한 구현체가 생성자를 통해 필드에 꽂힐 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e1d60000-f1a5-445a-86b7-8095d9eabc70/image.png" alt=""></p>
<p>위 그림이 바로 최종 DIP를 적용한 다이어그램이다. 고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 <strong>DIP(Dependency Inversion Principle) 의존 역전 원칙</strong>이라고 한다.</p>
<p>&nbsp;</p>
<p>이제 테스트를 위해 고객을 찾는 기능도 고수준 인터페이스로 추가로 설계해서 서비스 코드를 리팩토링 해보자.</p>
<pre><code class="language-java">public class CalculateDiscountService {
    private CustomerRepository customerRepository;  // 고객 조회 고수준 인터페이스에 의존
    private RuleDiscounter ruleDiscounter;  // 규칙 인터페이스에 의존

    public CalculateDiscountService(CustomerRepository customerRepository, RuleDiscounter ruleDiscounter) {
        this.customerRepository = customerRepository;
        this.ruleDiscounter = ruleDiscounter;
    }

    public Money calculateDiscount(List&lt;OrderLine&gt; orderLines, String customerId) {
        Customer customer = findCustomer(customerId);
        return ruleDiscounter.applyRules(customer, orderLines);
    }

    private Customer findCustomer(String customerId) {
        Customer customer = customerRepository.findById(customerId);
        if (customer == null) {
            throw new NoCustomerException();
        }
        return customer;
    }

    ...
}</code></pre>
<p>이제 룰에 대한 로직과 고객을 조회하는 구체 클래스가 구현되어 있지 않더라도 대역 객체를 사용해서 테스트를 충분히 진행할 수 있게 됐다. </p>
<pre><code class="language-java">public class CalculateDiscountServiceTest {

    @Test
    public void noCustomer_thenExceptionShouldBeThrown() {
        // 대역 객체 생성
        CustomerRepository stubRepo = mock(CustomerRepository.class);
        when(stubRepo.findById(&quot;noCusId&quot;)).thenReturn(null);

        RuleDiscounter stubRule = (cust, lines) -&gt; null;

        // 대역 객체들을 주입 받아서 테스트 진행 가능
        CalculateDiscountService service = new CalculateDiscountService(stubRepo, stubRule);
        assertThrows(NoCustomerException.class, () -&gt; service.calculateDiscount(someLines, &quot;noCusId&quot;));
    }
}</code></pre>
<p>&nbsp;</p>
<h3 id="💥-dip-주의사항">💥 DIP 주의사항</h3>
<p>DIP는 단순히 인터페이스와 구체 클래스로 쪼개는 것이 아니라 고수준 모듈이 저수준 모듈에 의존하지 않도록 하는 것이 핵심이다. 따라서 아래와 같이 저수준 모듈에서 인터페이스를 추출하면 안 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b6658408-6867-4a24-834a-a772aef38b5d/image.png" alt=""></p>
<p>이건 그냥 도메인 영역인 <code>CalculateDiscountService</code>가 구현 기술을 다루는 인프라스트럭처 계층에 직접적으로 의존하고 있는 상황이다. 고수준 모듈이 아닌 그냥 <code>RuleEngine</code>이라는 저수준 모듈에서 인터페이스를 뽑아낸 것이다. DIP를 적용할 때는 아래와 같이 <strong>하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출해야 한다.</strong> </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4a256018-c293-4e0e-b559-909b304c8aed/image.png" alt=""></p>
<p>이처럼 인프라스트럭처 영역은 저수준, 응용 영역과 도메인 영역은 고수준 모듈이다. 근데 4개의 영역 구조를 보면 인프라스트럭처 영역이 가장 하단에 위치했는데 DIP를 적용하면 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존하는 구조가 된다. 하지만 고수준 모듈에서 정의한 인터페이스를 상속 받아서 구현하기 때문에 상위 계층에 영향을 주지 않는다. 아래 다이어그램을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a23ec057-2361-40ee-8818-a4d769f1ace2/image.png" alt=""></p>
<p>다이어그램을 보면 인프라스트럭처 영역의 <code>CompositeNotifier</code> 클래스는 응용 영역의 <code>Notifier</code> 인터페이스를 구현하고, <code>JpaRepository</code>는 도메인 영역의 <code>OrderRepository</code> 인터페이스, <code>DroolsRuleDiscounter</code> 는 <code>RuleDiscounter</code> 인터페이스를 구현하고 있기 때문에 향후 새로운 요구사항이 들어왔을 때도 응용 영역의 <code>OrderService</code> 코드 변경 없이 기능을 교체하거나 확장할 수 있다. </p>
<p>&nbsp;</p>
<h1 id="🦾-도메인-영역의-주요-구성요소">🦾 도메인 영역의 주요 구성요소</h1>
<p>도메인 영역의 모델은 도메인의 주요 개념을 표현하며 핵심 로직을 구현한다. 도메인 영역의 주요 구성요소는 엔티티와 밸류 타입이라고 했는데 사실 다른 요소들이 더 존재한다. 전체적으로 정리해보자.</p>
<ul>
<li><p><strong>엔티티(Entity)</strong>: 고유의 식별자를 갖는 객체로 자신의 생명주기를 갖는다. 주문, 회원, 상품과 같이 도메인의 고유한 개념을 표현한다. 도메인 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공한다.</p>
</li>
<li><p><strong>밸류(Value)</strong>: 고유의 식별자를 갖지 않는 객체로 주로 개념적으로 하나인 값을 표현할 때 사용된다. 배송지 주소를 표현하기 위한 주소나 구매 금액을 위한 금액과 같은 타입이 밸류 타입이다. 엔티티의 속성으로 사용할 뿐만 아니라 다른 밸류 타입의 속성으로도 사용할 수 있다.</p>
</li>
<li><p><strong>애그리거트(Aggregate)</strong>: 애그리거트는 연관된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다. 예를 들어, 주문과 관련된 <code>Order</code> 엔티티, <code>OrderLine</code> 밸류, <code>Orderer</code> 밸류 객체를 주문 애그리거트로 묶을 수 있다.</p>
</li>
<li><p><strong>리포지토리(Repository)</strong>: 도메인 모델의 영속성을 처리한다. 예를 들어, DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공한다.</p>
</li>
<li><p><strong>도메인 서비스(Domain Service)</strong>: 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. 할인 금액 계산은 상품, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다.</p>
</li>
</ul>
<p>&nbsp;</p>
<p>근데 엔티티를 볼 때마다 든 생각인데, <em>“DB 테이블을 설계할 때의 그 엔티티랑 같은 건가?”</em> 라는 의문이 들었다. 결론부터 말하면 도메인 모델의 엔티티와 DB 관계형 모델의 엔티티는 같은 것이 아니다. 차이점은 도메인 모델의 엔티티는 데이터와 함께 도메인 기능을 함께 제공한다는 점이다. 뭔 소리지? 아래 코드를 보자.</p>
<pre><code class="language-java">public class Order {
    private OrderNo number;
    private Orderer orderer;
    private ShippingInfo shippingInfo;

    ...

    // 도메인 모델 엔티티는 도메인 기능도 함께 제공
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        ...
    }
}</code></pre>
<p>이처럼 그냥 데이터만 담고 있는 객체가 아니라 구체적인 기능을 제공할 수 있다는 말이다. DB 테이블은 그냥 필드만 작성돼 있지, 메서드(기능)가 적혀 있지 않으니까… </p>
<p>그리고 또 다른 차이점은 도메인 모델의 엔티티는 2개 이상의 데이터가 개념적으로 하나인 경우에 밸류 타입으로 뽑아낼 수 있다는 점이다. 위의 <code>Orderer</code>만 보더라도 아래와 같이 주문자 이름과 이메일 데이터를 포함할 수 있다. </p>
<pre><code class="language-java">public class Orderer {
    private String name;
    private String email;

    ...
}</code></pre>
<p>DB 테이블에서 밸류 타입을 표현하기 위해서는 해당 테이블에 데이터를 넣거나 별도 테이블로 분리해야 한다. </p>
<p>&nbsp;</p>
<h2 id="📦-애그리거트aggregate">📦 애그리거트(Aggregate)</h2>
<p>도메인의 규모가 커질수록 도메인 모델의 구성요소 또한 많아지고 복잡해진다. 이때 도메인 모델 개별 객체뿐만 아니라 상위 수준에서 모델을 볼 수 있어야 전체 모델의 관계와 개별 모델을 이해할 수 있다. 도메인 모델에서 전체 구조를 이해하는 데 도움이 되는 것이 바로 <strong>애그리거트(Aggregate)</strong>다.</p>
<p>애그리거트는 단순히 관련 있는 객체를 모아둔 묶음이 아니라, 함께 변경되어야 하고 같은 규칙 안에서 일관성을 지켜야 하는 객체들의 경계다. 따라서 애그리거트를 나눌 때의 기준은 <strong><em>“관련이 있는가?”</em></strong> 보다 <strong><em>“같은 트랜잭션 안에서 반드시 일관성을 유지해야 하는가?”</em></strong> 에 가깝다. 예를 들어 주문도 애그리거트인데, 주문은 <em>“주문”, “배송지 정보”, “주문자”, “주문 목록”, “총 결제 금액”</em> 의 하위 모델로 이루어져 있다. 애그리거트를 사용하면 각각의 객체가 아닌 관련 객체를 묶어서 객체 군집 단위로 도메인을 바라볼 수 있다. 따라서 애그리거트 간의 관계로 확장해서 도메인 모델을 이해할 수 있다.</p>
<p>애그리거트는 군집에 속한 객체를 관리하는 <strong>루트 엔티티(Root Entity)</strong>를 갖는다. 이 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서 애그리거트가 구현해야 할 기능을 제공한다. 애그리거트를 사용하는 코드는 루트 엔티티가 제공하는 기능을 실행하고, 루트 엔티티를 통해 애그리거트 내의 다른 엔티티나 밸류 객체에 접근하여 내부 구현을 숨겨서 애그리거트 단위로 구현을 캡슐화할 수 있도록 돕는다. 이해를 위해 아래 다이어그램을 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c536d602-3d3c-4458-bcbd-f73ca27c2496/image.png" alt=""></p>
<p><code>Order</code> 애그리거트 루트는 주문 도메인 로직에 맞게 애그리거트의 상태를 관리한다. <code>Order</code>의 배송지 정보를 변경하기 위해서는 일단 배송지를 변경할 수 있는지 확인한 뒤에 배송지를 변경하는 것처럼 말이다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class Order {
    ...

    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        checkShippingInfoChangeable();  // 배송지 변경 가능 여부 확인
        this.shippingInfo = newShippingInfo;
    }

    private void checkShippingInfoChangeable() {
        // 배송지 변경이 가능한지 여부를 검사하는 도메인 규칙
    }
}</code></pre>
<p>쉽게 말해, 주문 애그리거트는 루트 엔티티인 <code>Order</code>를 통하지 않고서는 <code>ShippingInfo</code>를 변경할 수 있는 방법을 제공하지 않는다는 것이다. 애그리거트를 어떻게 구현해야 하는지는 이후에 살펴보도록 하자.</p>
<p>&nbsp;</p>
<h3 id="🤔-도메인-모델-vs-애그리거트">🤔 도메인 모델 vs 애그리거트</h3>
<p><strong>도메인 모델(Domain Model)</strong>은 특정 도메인을 이해하고 표현하기 위한 개념 모델이다. 예를 들어 주문 도메인을 모델링한다면 <code>Order</code>, <code>OrderLine</code>, <code>ShippingInfo</code>, <code>Orderer</code>, <code>Money</code>, <code>OrderState</code> 같은 개념들이 등장할 수 있다. 이처럼 도메인 모델은 해당 도메인을 설명하기 위해 필요한 개념, 규칙, 상태, 관계를 포함하는 넓은 범위의 모델이다. </p>
<p>반면, <strong>애그리거트(Aggregate)</strong>는 도메인 모델 안에서 관련 객체들을 하나의 일관성 단위로 묶은 군집이다. 단순히 객체들이 서로 관련 있다고 해서 모두 하나의 애그리거트가 되는 것은 아니다. 애그리거트는 함께 변경되어야 하고, 반드시 같은 규칙 안에서 일관성을 유지해야 하는 객체들을 하나의 경계로 묶은 것이다.</p>
<p>예를 들어 주문은 <code>Order</code>, <code>OrderLine</code>, <code>ShippingInfo</code>, <code>Orderer</code>, <code>Money</code> 같은 여러 객체로 구성될 수 있다. 이때 <code>OrderLine</code>은 주문 없이 독립적으로 존재하기 어렵고, 총 주문 금액은 주문 항목들의 금액 합과 일치해야 하며, 배송지 변경이나 주문 취소 같은 규칙도 주문 상태에 따라 통제되어야 한다. 따라서 이 객체들은 <code>Order</code>를 중심으로 하나의 애그리거트로 묶을 수 있다.</p>
<pre><code>Order 애그리거트
 ├── Order    // 애그리거트 루트
 ├── OrderLine
 ├── ShippingInfo
 ├── Orderer
 ├── Money
 └── OrderState</code></pre><p>여기서 <code>Order</code>는 애그리거트 루트다. 애그리거트 루트는 애그리거트 내부 객체에 접근하고 변경하는 진입점 역할을 한다. 외부에서는 <code>OrderLine</code>이나 <code>ShippingInfo</code>를 직접 수정하는 것이 아니라, <code>Order.changeShippingInfo()</code>, <code>Order.cancel()</code> 같은 메서드를 통해 변경해야 한다. 그래야 애그리거트 내부의 규칙과 일관성을 안전하게 지킬 수 있다.</p>
<p>따라서 애그리거트는 도메인 모델이라는 큰 범주 안에 포함된다. 모든 도메인 모델이 애그리거트인 것은 아니지만, 애그리거트는 도메인 모델을 구성하는 중요한 설계 단위다. 한 문장으로 정리하면, <strong>도메인 모델(Domain Model)은 도메인을 이해하기 위한 전체 지도이고, 애그리거트(Aggregate)는 그 지도 안에서 함께 일관성을 지켜야 하는 객체들의 경계</strong>라고 볼 수 있다.</p>
<p>&nbsp;</p>
<h2 id="💾-리포지토리repository">💾 리포지토리(Repository)</h2>
<p>도메인 객체를 지속적으로 사용하기 위해서는 DB에 안전하게 보관해야 한다. 이를 위해 사용하는 도메인 영역의 구성요소가 바로 <strong>리포지토리(Repository)</strong>다.</p>
<p>리포지토리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다. 예시로 주문 애그리거트를 위한 리포지토리는 아래와 같이 구현할 수 있다. </p>
<pre><code class="language-java">public interface OrderRepository {
    Order findByNumber(OrderNumber number);
    void save(Order order);
    void delete(Order order);
}</code></pre>
<p>보다시피 찾고 저장하는 대상이 <code>Order</code> 루트 애그리거트다. 도메인 모델을 사용해야 하는 코드는 일단 리포지토리를 통해 도메인 객체를 꺼내와서 기능을 수행하도록 해야 한다. </p>
<pre><code class="language-java">public class CancelOrderService {
    private OrderRepository orderRepository;

    public void cancel(OrderNumber number) {
        Order order = orderRepository.findByNumber(number);  // 리포지토리에서 도메인 객체를 꺼냄
        if (order == null) {
            throw new NoOrderException(number);
        }
        order.cancel();  // 그 다음 기능 수행
    }
}</code></pre>
<p>위 코드를 보면 알겠지만 리포지토리 도메인 모델은 도메인 객체를 영속화하는 데 필요한 기능들을 인터페이스로 추상화했기 때문에 고수준 모듈에 속한다. <code>OrderRepository</code>를 구현한 클래스들은 저수준 모듈로 인프라스트럭처 영역에 속하는 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9bb4b0c1-76b0-40ff-9cd9-50405689a96b/image.png" alt=""></p>
<p>위의 코드와 다이어그램을 보면 응용 서비스는 의존 관계 주입을 통해 실제 리포지토리 구현 객체(<code>JpaOrderRepository</code>)에 접근하게 되는 것이다. 이처럼 응용 서비스는 리포지토리를 통해 애그리거트를 조회하고, 도메인 기능을 실행한 뒤, 변경된 상태가 저장소에 일관되게 반영되도록 트랜잭션 경계를 관리한다.</p>
<p>&nbsp;</p>
<h1 id="🌊-요청-처리-흐름">🌊 요청 처리 흐름</h1>
<p>알다시피 사용자의 요청을 처음 받는 영역은 <strong>표현(Presentation)</strong> 영역이다. 표현 영역은 사용자가 전송한 데이터 형식이 올바른지 검사하고 문제가 없다면 데이터를 이용해서 응용 서비스에 기능 실행을 위임한다. 이때 표현 영역은 데이터를 응용 서비스가 요구하는 형식으로 변환해서 전달하게 된다. 아래는 대략적인 요청 처리 흐름이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/26f233e9-181b-4e4f-87e7-b195f2340a1e/image.png" alt=""></p>
<p>보다시피 컨트롤러(표현 영역)가 사용자의 요청을 받아서 응용 서비스가 요구하는 형식으로 데이터를 변환해서 응용 서비스에 전달한다. 응용 서비스는 도메인의 기능을 사용하기 위해 리포지토리로부터 도메인 객체를 꺼내서 실행하거나 신규 도메인 객체를 생성해서 리포지토리에 저장한다. 추가로 도메인의 상태가 변경되는 기능은 리포지토리에 일관되게 반영되도록 트랜잭션을 관리해야 한다. </p>
<pre><code class="language-java">public class CancelOrderService {
    private OrderRepository orderRepository;

    // 응용 서비스는 트랜잭션을 관리해야 한다.
    @Transactional
    public void cancel(OrderNumber number) {
        Order order = orderRepository.findByNumber(number);
        if (order == null) {
            throw new NoOrderException(number);
        }
        order.cancel();
    }
    ...
}</code></pre>
<p>&nbsp;</p>
<h1 id="🏗️-인프라스트럭처-개요">🏗️ 인프라스트럭처 개요</h1>
<p><strong>인프라스트럭처(Infrastructure)</strong>는 다른 영역에서 필요로 하는 프레임워크, 구현 기술, 보조 기능을 지원한다. 여기서 명심해야 할 점은 위에서도 말했지만 고수준 모듈에서 정의한 인터페이스를 저수준 모듈인 인프라스트럭처 영역에서 구현하는 것이 시스템을 더 유연하게 만들고 테스트하기 쉽게 만들어준다. 하지만 상황에 따라 인프라스트럭처에 대한 의존을 일부 도메인에 넣을 수도 있다.</p>
<p>&nbsp;</p>
<h1 id="🗂️-모듈-구성">🗂️ 모듈 구성</h1>
<p>위에서 봤던 표현, 응용, 도메인, 인프라스트럭처 계층이 있었듯이 각 영역별로 모듈이 위치할 패키지를 구성해주는 것이 기본이다. 여기서 도메인에 따라 알맞은 패키지로 대체할 수 있다. 도메인 하나가 너무 크다면 여러 개의 하위 도메인으로 나누고 그 도메인마다 패키지를 구성하면 된다. 여기서 도메인 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성한다.</p>
<pre><code>com.myshop.order
 ├── ui
 │   └── OrderController
 ├── application
 │   ├── CancelOrderService
 │   └── PlaceOrderService
 ├── domain
 │   ├── Order
 │   ├── OrderLine
 │   ├── OrderNo
 │   ├── OrderRepository
 │   └── ShippingInfo
 └── infrastructure
     └── JpaOrderRepository</code></pre><p>애그리거트, 모델, 리포지토리는 같은 패키지에 위치시킨다. 도메인이 너무 복잡하면 도메인 모델과 도메인 서비스를 별도 패키지에 위치시킬 수도 있다. 이처럼 모듈 구조를 어디까지 나눠야 하는지에 대한 정답은 없다. 하나의 패키지에 몇 개 정도의 타입을 구성해야 하는지에 대한 본인만의 기준을 세우는 것이 좋다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[도메인 모델 시작하기]]></title>
            <link>https://velog.io/@rocker_nun/%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/%EB%8F%84%EB%A9%94%EC%9D%B8-%EB%AA%A8%EB%8D%B8-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 30 Apr 2026 00:59:01 GMT</pubDate>
            <description><![CDATA[<h1 id="📝-도메인domain이란">📝 도메인(Domain)이란?</h1>
<p>온라인 서점을 한번 떠올려보면 장바구니 기능, 쿠폰 적용 기능, 결제 기능, 배송 추적 기능 등 상당히 많은 기능들을 가지고 있다. 이때 <strong>온라인 서점</strong>이 바로 소프트웨어로 해결하고자 하는 문제 영역, 즉 <strong>도메인(Domain)</strong>에 해당한다.</p>
<p>한 도메인은 다시 하위의 여러 도메인으로 나눌 수 있다. 위의 온라인 서점만 생각하더라도 카탈로그 도메인, 주문 도메인, 혜택 도메인, 배송 도메인 등 여러 도메인으로 쪼개질 수 있다. 이 하위 도메인들이 서로 연동하여 완전한 기능을 제공하는 것이다. 여기서 하나의 도메인을 맡았다고 그 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다. 예를 들어, 배송 시스템은 다른 업체의 제품을 채택하고 그 제품을 사용하기 위해 필요한 기능만 일부 연동하는 것이다. </p>
<p>알다시피 개발자는 고객의 요구사항을 분석하고 설계하여 코드를 작성하며 테스트하고 배포한다. 따라서 요구사항을 명확하게 이해하는 것이 가장 중요한데, 이를 위해 그 해당 도메인의 전문가와 직접 대화해서 그 전문가만큼은 아니어도 해당 도메인 지식을 갖춰야 할 필요가 있다. 도메인 주도 설계는 단순히 클래스를 잘 나누는 설계 기법이 아니다. 복잡한 비즈니스 문제를 이해하고, 도메인 전문가와 개발자가 같은 언어로 소통하며, 그 이해를 모델과 코드에 반영해 나가는 개발 방식이다.</p>
<p>&nbsp;</p>
<h1 id="🩻-도메인-모델domain-model">🩻 도메인 모델(Domain Model)</h1>
<p>도메인 모델은 특정 도메인을 개념적으로 표현한 것이다. 예시로 온라인 서점의 주문 모델을 객체 모델로 구성하면 아래 그림과 같이 만들 수 있다. </p>
<h3 id="🍔-클래스-다이어그램">🍔 클래스 다이어그램</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3fbe6033-94b3-452b-a54b-b80d8214f597/image.png" alt=""></p>
<p>위 도메인 모델은 <strong>객체를 이용한 도메인 모델</strong>이다. 도메인을 이해하기 위해서는 해당 <strong>도메인이 제공하는 기능</strong>과 <strong>필요한 주요 데이터</strong>를 알아야 하는데, 이런 면에서 객체 모델이 도메인을 모델링하기에 안성맞춤이다. 주문(<code>Order</code>)은 주문 번호(<code>orderNumber</code>), 총 주문 금액(<code>totalAmounts</code>)이 있고, 배송 정보(<code>ShippingInfo</code>)를 변경(<code>changeShipping</code>)할 수 있고, 주문을 취소(<code>cancel</code>)할 수 있다는 사실을 바로 알 수 있다. 이처럼 개발자가 아니더라도 해당 도메인에 대한 이해가 필요한 모든 사람들이 지식을 이해하고 공유하는데 많은 도움이 된다. </p>
<p>하지만 도메인을 모델링하기 위해 꼭 객체만 사용할 수 있는 것은 아니다. 아래와 같이 상태 다이어그램을 이용해서 주문의 상태 전이를 모델링할 수도 있다. </p>
<p>&nbsp;</p>
<h3 id="🔁-상태-다이어그램">🔁 상태 다이어그램</h3>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d453bf53-57a1-4b0f-a086-23f3a4aa22b9/image.png" alt=""></p>
<p>이외에도 다양한 방법으로 도메인을 모델링할 수 있다. 관계가 중요한 도메인이라면 그래프를 이용할 수 있고, 계산 규칙이 중요하다면 수학 공식을 활용해서 모델링 할 수도 있다. 하여간 도메인을 이해하는데 도움이 된다면 그 어떤 것이든 가능하다. </p>
<p>정리하자면, 도메인 모델은 도메인 자체를 이해하기 위한 개념 모델이다. 이 개념 모델만으로는 바로 기능을 구현할 수 있는 것은 아니기에 구현 기술에 맞는 별도의 구현 모델도 필요하다. <del>그나마 위 클래스 다이어그램은 객체지향 언어로 개념 모델에 가깝게 구현할 수는 있다…</del> 처음부터 완벽한 개념 모델을 만들겠다는 마음보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성해야 한다. 이후에 코드로 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다. </p>
<p>&nbsp;</p>
<h3 id="💥-주의-사항">💥 주의 사항</h3>
<p>따라서 여러 하위 도메인의 개념을 하나의 모델에 억지로 합치려고 하면 의미가 뒤섞일 수 있다. 같은 “상품”이라는 용어도 카탈로그 컨텍스트에서는 가격과 설명을 가진 판매 정보일 수 있고, 배송 컨텍스트에서는 실제 고객에게 전달되는 물리적인 물품을 의미할 수 있다.</p>
<p>그래서 도메인 모델은 특정 문맥 안에서 일관된 의미를 가져야 한다. DDD에서는 이런 모델의 의미가 유지되는 경계를 <strong>바운디드 컨텍스트(Bounded Context)</strong>라고 부른다. 하위 도메인은 비즈니스 문제 영역을 나눈 것이고, 바운디드 컨텍스트는 특정 모델이 일관된 의미를 갖는 경계다.</p>
<p>&nbsp;</p>
<h1 id="📦-도메인-모델-패턴">📦 도메인 모델 패턴</h1>
<p>일반적인 애플리케이션의 아키텍처는 아래와 같다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4587fae4-97a6-4b0d-945b-90847aa14edd/image.png" alt=""></p>
<ul>
<li><p><code>사용자 인터페이스(UI) or 표현(Presentation)</code>: 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람뿐만 아니라 외부 시스템일 수도 있다.</p>
</li>
<li><p><code>응용(Application)</code>: 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.</p>
</li>
<li><p><code>도메인(Domain)</code>: 시스템이 제공할 도메인 규칙을 구현한다.</p>
</li>
<li><p><code>인프라스트럭처(Infrastructure)</code>: 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.</p>
</li>
</ul>
<p>&nbsp;</p>
<p>위에서 살펴봤던 도메인 모델은 진짜 도메인에 대해 이해하기 위해 필요한 개념 모델을 의미했다면, 지금부터 살펴볼 도메인 모델은 일종의 패턴을 의미한다. 좀 더 자세히 말하자면, 아키텍처 상의 도메인 계층에서는 도메인의 핵심 규칙을 구현하는데, 이런 도메인의 규칙을 객체지향 기법으로 구현하는 패턴을 말한다. 직접 코드를 보면서 이해해보자.</p>
<pre><code class="language-java">// 주문 상태와 배송 정보를 담은 Order 클래스
public class Order {
    private OrderState state;
    private ShippingInfo shippingInfo;

    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!state.isShippingChangeable()) {
            throw new IllegalStateException(&quot;can&#39;t change shipping in &quot; + state);
        }
        this.shippingInfo = newShippingInfo;
    }

    ...
}

// 주문 상태에 대한 정보를 담은 OrderState 클래스
public enum OrderState {
    PAYMENT_WAITING {
        public boolean isShippingChangeable() {
            return true;
        }
    },
    PREPARING {
        public boolean isShippingChangeable() {
            return true;
        }
    },
    SHIPPED, DELIVERING, DELIVERY_COMPLETED;

    public boolean isShippingChangeable() {
        return false;
    }
}</code></pre>
<p>주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것이다. 주문 상태를 표현하는 <code>OrderState</code>는 현재 주문 상태에서 배송지 정보를 변경할 수 있는지 판단하는 <code>isShippingChangeable()</code> 메서드를 제공한다. <code>PAYMENT_WAITING</code> 또는 <code>PREPARING</code> 상태에서는 아직 출고 전이므로 배송지 변경이 가능하고, <code>SHIPPED</code>, <code>DELIVERING</code>, <code>DELIVERY_COMPLETED</code> 상태에서는 배송지 변경이 불가능하다는 도메인 규칙을 코드로 표현한 것이다.</p>
<p>근데 여기서 <code>OrderState</code>는 <code>Order</code>에 속한 데이터라고 판단할 수도 있지 않나? 주문 상태를 변경할 수 있는지에 대한 판단 정도는 그냥 <code>Order</code>에 넣어도 될거 같다. 아래 코드를 보자.</p>
<pre><code class="language-java">public class Order {
    private OrderState state;
    private ShippingInfo shippingInfo;

    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        if (!isShippingChangeable()) {
            throw new IllegalStateException(&quot;can&#39;t change shipping in &quot; + state);
        }
        this.shippingInfo = newShippingInfo;
    }

    // 주문 상태를 변경할 수 있는지 여부를 검사하는 메서드를 Order 클래스 내부로 이동
    private boolean isShippingChangeable() {
        return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
    }

    ...

}

public enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}</code></pre>
<p>큰 무리는 없어 보인다. 만약 해당 메서드가 주문 상태뿐만 아니라 다른 정보를 함께 사용하고 있다면 <code>OrderState</code>만으로는 배송지를 변경할 수 있는지 판단할 수 없으므로 <code>Order</code>에서 로직을 구현해야 할 것이다. 아무튼 여기서 중요한 점은 해당 메서드가 <code>Order</code>에 있든 <code>OrderState</code>에 있든 결국 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 <code>Order</code>나 <code>OrderState</code>에서 구현해야 한다는 것이다.</p>
<p>&nbsp;</p>
<h1 id="🔍-도메인-모델-도출">🔍 도메인 모델 도출</h1>
<blockquote>
<p><strong><em>“그래서 도메인 모델을 어떻게 뽑아내는데?”</em></strong></p>
</blockquote>
<p>개인적으로 그동안 가장 많이 고민하고 판단 기준이 모호하다고 느낀 부분이다. 위에서 언급되었듯이 답은 요구사항에 있다. 요구사항으로부터 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다. 주문 도메인에 아래와 같은 요구사항이 있다고 해보자.</p>
<ul>
<li>최소 한 종류 이상의 상품을 주문해야 한다.</li>
<li>한 상품을 1개 이상 주문할 수 있다.</li>
<li>총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.</li>
<li>각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.</li>
<li>주문할 때 배송지 정보를 반드시 지정해야 한다.</li>
<li>배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.</li>
<li>출고를 하면 배송지를 변경할 수 없다.</li>
<li>출고 전에 주문을 취소할 수 있다.</li>
<li>고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.</li>
</ul>
<p>&nbsp;</p>
<p>위 요구사항에서 뽑아낼 수 있는 것은 주문은 배송지를 변경하는 기능, 결제를 완료하는 기능, 출고 상태로 변경하는 기능, 주문을 취소하는 기능을 제공한다는 것이다. </p>
<pre><code class="language-java">public class Order {
    public void changeShippingInfo(ShippingInfo shippingInfo) {...}
    public void completePayment() {...}
    public void changeShipped() {...}
    public void cancel() {...}
}</code></pre>
<p>&nbsp;</p>
<p>그리고 <em>“한 상품을 1개 이상 주문할 수 있다”</em>, <em>“각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다”</em> 라는 요구사항에서는 주문 항목의 데이터를 뽑아낼 수 있다. 주문할 상품, 상품의 가격, 구매 수량, 각 구매 항목의 구매 가격을 포함해야 한다. 이를 바탕으로 <code>OrderLine</code>을 구현해보자.</p>
<pre><code class="language-java">public class OrderLine {
    private Product product;
    private int price;
    private int quantity;
    private int amount;

    public OrderLine(Product product, int price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amount = calculateAmount();
    }

    public int getAmount() {
        ...
    }

    private int calculateAmount() {
        return price * quantity;
    }
}</code></pre>
<p>보다시피 <code>OrderLine</code>은 한 상품을 얼마에, 몇 개 살지를 담고 있고 <code>calculateAmount()</code> 메서드로 구매 가격을 구하는 로직을 담고 있다. 그리고 나서 아래 요구사항으로 <code>Order</code>와의 관계를 알 수 있다.</p>
<ul>
<li>최소 한 종류 이상의 상품을 주문해야 한다.</li>
<li>총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.</li>
</ul>
<p>&nbsp;</p>
<p><code>Order</code> 클래스를 리팩토링 해보자.</p>
<pre><code class="language-java">public class Order {
    private List&lt;OrderLine&gt; orderLines;
    private Money totalAmount;

    public Order(List&lt;OrderLine&gt; orderLines) {
        setOrderLines(orderLines);
    }

    private void setOrderLines(List&lt;OrderLine&gt; orderLines) {
        verifyAtLeastOneOrMoreOrderLines(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmount();
    }

    private void verifyAtLeastOneOrMoreOrderLines(List&lt;OrderLine&gt; orderLines) {
        if (orderLines == null || orderLines.isEmpty()) {
            throw new IllegalArgumentException(&quot;No OrderLines...&quot;);
        }
    }

    private void calculateTotalAmount() {
        int sum = orderLines.stream()
                .mapToInt(x -&gt; x.getAmount())
                .sum();
        this.totalAmount = new Money(sum);
    }

    ...  // 다른 메서드
}</code></pre>
<p>최소 한 종류 이상의 상품을 주문해야 한다는 규칙은 실제 <code>Order</code>를 생성할 때 <code>setOrderLines()</code> 메서드에 <code>OrderLine</code> 리스트를 전달해서 <code>verifyAtLeastOneOrMoreOrderLines()</code> 메서드로 검사한다. 검사를 통과하면 <code>calculateTotalAmount()</code> 메서드로 총 주문 금액을 계산하도록 처리했다. </p>
<p>&nbsp;</p>
<p>이제 받는 사람 이름, 전화번호, 주소 데이터를 가지고 있는 배송지 정보 클래스를 아래와 같이 정의해보자.</p>
<pre><code class="language-java">public class ShippingInfo {
    private String receiverName;
    private String receiverPhoneNumber;
    private String shippingAddress1;
    private String shippingAddress2;
    private String shippingZipcode;

    ... 생성자, getter
}</code></pre>
<p>&nbsp;</p>
<p>위의 요구사항 중 <em>“주문할 때 배송지 정보를 반드시 지정해야 한다”</em> 라는 규칙이 있었다. 이 규칙으로 <code>Order</code>를 새로 생성할 때 <code>OrderLine</code> 뿐만 아니라 <code>ShippingInfo</code>도 전달해야 한다는 것을 알 수 있다. 다시 <code>Order</code> 클래스를 리팩토링 해보자.</p>
<pre><code class="language-java">public class Order {
    private List&lt;OrderLine&gt; orderLines;
    private ShippingInfo shippingInfo;  // 배송 정보 필드 추가

    ...

    public Order(List&lt;OrderLine&gt; orderLines, ShippingInfo shippingInfo) {
        setOrderLines(orderLines);
        setShippingInfo(shippingInfo);
    }

    private void setOrderLines(List&lt;OrderLine&gt; orderLines) {
        verifyAtLeastOneOrMoreOrderLines(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmount();
    }

    // 생성 시 배송 정보를 설정하는 메서드
    private void setShippingInfo(ShippingInfo shippingInfo) {
        if (shippingInfo == null) {
            throw new IllegalArgumentException(&quot;No ShippingInfo...&quot;);
        }
        this.shippingInfo = shippingInfo;
    }

    ... 
}</code></pre>
<p>&nbsp;</p>
<p>이외에도 여러가지 제약과 규칙이 존재한다. <em>“출고를 하면 배송지 정보를 변경할 수 없다”</em> 와 <em>“출고 전에 주문을 취소할 수 있다”</em> 와 같은 요구사항은 출고 상태가 되기 전과 후의 제약사항이다. 그럼 <code>Order</code>는 최소한 출고 상태에 대해서는 알고 있어야 한다. 이제 기존의 <code>changeShippingInfo()</code> 메서드와 <code>cancel()</code>은 출고 전에 실행되도록 리팩토링하자.</p>
<pre><code class="language-java">public class Order {
    private OrderState state;

    ...

    // 출고 전일 때만 배송지를 변경할 수 있는 제약 추가
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
    }

    // 출고 전일 때만 주문을 취소할 수 있는 제약 추가
    public void cancel() {
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
    }

    // 이미 출고 되었는지 검증하는 메서드
    private void verifyNotYetShipped() {
        if (state != OrderState.PAYMENT_WAITING &amp;&amp; state != OrderState.PREPARING) {
            throw new IllegalArgumentException(&quot;Already shipped...&quot;);
        }
    }
}</code></pre>
<p>이런 식으로 도메인 모델을 점진적으로 만들어나가면 된다.</p>
<p>&nbsp;</p>
<h1 id="🎏-엔티티entity와-밸류value">🎏 엔티티(Entity)와 밸류(Value)</h1>
<p>위에서 도출한 모델은 크게 <strong>엔티티(Entity)</strong>와 <strong>밸류(Value)</strong>로 구분할 수 있다. 아래 클래스 다이어그램을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/f4de6c4e-4b52-4cf8-958c-c317a0469ab5/image.png" alt=""></p>
<p>엔티티는 <strong>식별자(Identifier)</strong>를 가진다. 각 엔티티는 구분할 수 있다는 말이다. 위의 <code>Order</code> 클래스가 바로 엔티티이며, <em>“주문번호”</em> 라는 식별자를 속성으로 가져야 한다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7bea5f12-f26a-40e0-8935-d5d28d76ab6b/image.png" alt=""></p>
<p>엔티티의 식별자는 바뀌지 않고 고유하기 때문에 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 말할 수 있다. 엔티티를 구현한 클래스는 식별자를 이용해서 <code>equals()</code> 메서드와 <code>hashCode()</code> 메서드를 구현해야 한다. 아래는 리팩토링한 <code>Order</code> 클래스다.</p>
<pre><code class="language-java">public class Order {
    private String orderNumber;

    @Override
    public boolean equals(Object object) {
        if (this == object) {
            return true;
        }
        if (object == null || object.getClass() != Order.class) {
            return false;
        }
        Order other = (Order) object;
        if (this.orderNumber == null) {
            return false;
        }
        return this.orderNumber.equals(other.orderNumber);
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((this.orderNumber == null) ? 0 : this.orderNumber.hashCode());
        return result;
    }
}</code></pre>
<p>&nbsp;</p>
<h3 id="🌱-식별자-생성">🌱 식별자 생성</h3>
<p>근데 식별자를 저렇게 항상 필드에 박아 넣어야 할까? 엔티티의 식별자를 생성하는 시점은 도메인의 특징과 사용 기술에 따라 달라지게 된다. 보통 아래 방법 중 하나를 채택하게 된다.</p>
<ul>
<li>특정 규칙에 따라 생성한다.</li>
<li>UUID나 Nano ID와 같은 고유 식별자 생성기를 사용한다.</li>
<li>값을 직접 입력한다.</li>
<li>시퀀스나 DB의 자동 증가 컬럼과 같은 일련번호를 사용한다.</li>
</ul>
<p>주문번호처럼 사용자가 직접 확인하는 식별자는 현재 시각, 일련번호, 난수 등을 조합해서 의미 있는 형식으로 만들기도 한다. 반면 내부 기술 식별자는 UUID, Nano ID, DB 시퀀스, 자동 증가 컬럼 등을 사용할 수 있다. 어떤 방식을 선택할지는 도메인의 요구사항과 사용하는 기술에 따라 달라진다. </p>
<p>추가로, 식별자를 DB가 생성하는 경우에는 객체 생성 시점에는 식별자가 없을 수 있다. 이 상태에서 <code>equals()</code>와 <code>hashCode()</code>를 식별자만으로 구현하면 주의가 필요하다. 그래서 도메인에서 중요한 식별자는 가능하면 객체 생성 시점에 함께 생성하거나, 별도의 식별자 타입으로 다루는 방법도 고려할 수 있다.</p>
<p>&nbsp;</p>
<p>이제 <code>ShippingInfo</code> 클래스를 살펴보자.</p>
<pre><code class="language-java">public class ShippingInfo {
    // 받는 사람
    private String receiverName;
    private String receiverPhoneNumber;

    // 주소
    private String shippingAddress1;
    private String shippingAddress2;
    private String shippingZipcode;

    ... 
}</code></pre>
<p>보다시피 받는 사람과 주소에 대한 데이터가 존재한다. 주석처럼 나누어 놓은 이유는 <code>receiverName</code>와 <code>receiverPhoneNumber</code>는 분명 다른 데이터지만 개념적으로는 <em>“받는 사람”</em> 을 의미하고, 밑에 <code>shippingAddress</code>와 <code>shippingZipcode</code> 필드도 개념적으로는 <em>“주소”</em> 를 의미하기 때문이다. </p>
<p>&nbsp;</p>
<p>이처럼 밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다. 따라서 <em>“받는 사람”</em> 과 <em>“주소”</em> 를 각각 도메인 개념으로 표현하기 위해 <code>Receiver</code> 클래스와 <code>Address</code> 클래스로 나눠서 설계해줄 필요가 있다. 아래 코드를 보자.</p>
<pre><code class="language-java">// 받는 사람
public class Receiver {
    private String name;
    private String phoneNumber;

    public Receiver(String name, String phoneNumber) {
        this.name = name;
        this.phoneNumber = phoneNumber;
    }

    public String getName() {
        return name;
    }

    public String getPhoneNumber() {
        return phoneNumber;
    }
}

// 주소
public class Address {
    private String address1;
    private String address2;
    private String zipcode;

    public Address(String address1, String address2, String zipcode) {
        this.address1 = address1;
        this.address2 = address2;
        this.zipcode = zipcode;
    }

    public String getAddress1() {
        return address1;
    }

    public String getAddress2() {
        return address2;
    }

    public String getZipcode() {
        return zipcode;
    }
}</code></pre>
<p>&nbsp;</p>
<p>이제 기존의 <code>ShippingInfo</code> 클래스에서의 받는 사람에 관한 데이터를 <code>Receiver</code>, 주소에 관한 데이터는 <code>Address</code> 밸류 타입을 사용해서 보다 명확하게 표현할 수 있게 된 것이다. 밸류 타입을 도입함으로써  <code>ShippingInfo</code> 클래스는 아래와 같이 리팩토링 된다.</p>
<pre><code class="language-java">public class ShippingInfo {
    private Receiver receiver;
    private Address address;

    ... 
}</code></pre>
<p>&nbsp;</p>
<p>이처럼 밸류 타입은 의미를 명확하게 표현하기 위해 사용한다. 다음으로 <code>OrderLine</code>을 살펴보도록 하자. <code>price</code>와 <code>amount</code>는 개념적으로는 금액을 의미하기 때문에 별도로 <code>Money</code> 라는 밸류 타입을 설계해서 추가했다.</p>
<pre><code class="language-java">public class OrderLine {
    private Product product;
    private Money price;
    private int quantity;
    private Money amount;

        ... 
}</code></pre>
<p>&nbsp;</p>
<p>밸류 타입의 또 다른 장점은 해당 밸류 타입만의 기능을 추가할 수 있다는 점이다. <code>Money</code> 타입에서 금액 계산 같은 로직을 추가할 수 있다.</p>
<pre><code class="language-java">public class Money {
    private final int value;

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

    ... 

    // 금액 추가 기능
    public Money add(Money money) {
        return new Money(this.value + money.value);
    }

    // 금액 곱셈 기능
    public Money multiply(int multiplier) {
        return new Money(this.value * multiplier);
    }
}</code></pre>
<p>&nbsp;</p>
<p>이제 아래의 리팩토링된 <code>OrderLine</code> 클래스를 보면 금액 계산이라는 의미가 첨가되어 코드 가독성이 향상된 것을 볼 수 있다.</p>
<pre><code class="language-java">public class OrderLine {
    private final Product product;
    private final Money price;
    private final int quantity;
    private final Money amount;

    public OrderLine(Product product, Money price, int quantity) {
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.amount = calculateAmount();
    }

    private Money calculateAmount() {
        return price.multiply(quantity);
    }


    public Money getAmount() {
        ...
    }

    ...
}</code></pre>
<p>&nbsp;</p>
<h3 id="🤔-왜-객체를-새로-생성하지">🤔 왜 객체를 새로 생성하지?</h3>
<p>근데 <code>Money</code> 밸류 타입에서 연산을 수행한 후에 그 연산 결과를 바탕으로 새로운 인스턴스를 생성하고 있다. 금액이라는 개념은 바뀔 수 없기 때문에 그런가? 생각해보면 내가 들고 있는 만원이 값을 다르게 세팅한다고 5만원이 되지는 않는다. 아무튼 <code>Money</code> 처럼 데이터 변경 기능을 제공하지 않는 타입을 <strong>불변(Immutable)</strong>하다고 표현한다.</p>
<p>이렇게 불변으로 처리하는 이유 중 가장 중요한 것은 바로 안전한 코드를 작성하기 위함이다. 막말로 <code>setter</code>를 도입해서 기존 객체의 값을 변경했다고 치자. 이미 해당 금액이 반영된 <code>OrderLine</code>이 생성됐는데 이후에 값이 변경되면 기존 <code>OrderLine</code>에 있는 금액도 변경되는 참사가 발생할 것이다. </p>
<p>따라서 밸류 타입에는 <code>setter</code>를 두지 않는 것이 좋다. 값이 바뀌어야 한다면 기존 객체를 수정하는 대신 새로운 밸류 객체를 생성하는 방식이 안전하다. 엔티티 역시 무분별한 <code>setter</code>보다는 <code>cancel()</code>, <code>changeShippingInfo()</code>처럼 의미 있는 도메인 메서드를 통해 상태를 변경하는 것이 좋다.</p>
<p>&nbsp;</p>
<h1 id="🤪-도메인-용어와-유비쿼터스-언어">🤪 도메인 용어와 유비쿼터스 언어</h1>
<p>코드를 작성할 때 도메인에서 사용하는 용어는 아주 중요하다. 아래 주문 상태 코드를 보자.</p>
<pre><code class="language-java">public enum OrderState {
    STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}</code></pre>
<p>아니, STEP이 뭔데? 몇 단계가 어떤 상태라는거지? 상태에 대해 명확하게 알지 못 하니까 아래와 같은 의도를 알기 어려운 코드를 작성할 가능성이 높다.</p>
<pre><code class="language-java">public class Order {
    public void changeShippingInfo(ShippingInfo newShippingInfo) {
        verifyStep1OrStep2();
        setShippingInfo(newShippingInfo);
    }

    private void verifyStep1OrStep2() {
        if (state != OrderState.STEP1 &amp;&amp; state != OrderState.STEP2) {
            throw new IllegalArgumentException(&quot;Already shipped...&quot;);
        }
    }
}</code></pre>
<p>&nbsp;</p>
<p>일단 그냥 봐도 <code>verifyStep1OrStep2()</code> 메서드가 무슨 검증을 하는지 감이 안 온다. <code>STEP1</code>과 <code>STEP2</code>가 각각 <em>“결제 대기 중”</em>, <em>“상품 준비 중”</em> 상태를 의미하는 것을 알아야 한다. 따라서 아래 코드처럼 도메인 용어를 사용해서 주문 상태를 구현하면 위와 같은 불필요한 변환 과정이 필요없다. </p>
<pre><code class="language-java">public enum OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}</code></pre>
<p>&nbsp;</p>
<p>보다시피 각 상태가 어떤 의미인지 직관적으로 파악이 가능해서 코드를 분석하고 이해하는 시간을 대폭 줄여준다. 도메인 주도 설계에서 언어의 중요성은 몇번을 강조해도 모자르다. 해당 도메인에 관련된 모든 이해관계자들은 공통의 언어를 만들고 이를 대화, 문서, 도메인 모델, 코드, 테스트 등 모든 곳에서 그 언어를 사용한다. 시간이 지나면서 도메인에 대한 이해가 깊어지면 해당 내용을 더 잘 표현할 수 있는 용어를 찾아내서 다시 공통의 언어로 만들어 다 같이 사용하기도 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[리눅스 기본 명령어와 개념 정리]]></title>
            <link>https://velog.io/@rocker_nun/%EB%A6%AC%EB%88%85%EC%8A%A4-%EA%B8%B0%EB%B3%B8-%EB%AA%85%EB%A0%B9%EC%96%B4%EC%99%80-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@rocker_nun/%EB%A6%AC%EB%88%85%EC%8A%A4-%EA%B8%B0%EB%B3%B8-%EB%AA%85%EB%A0%B9%EC%96%B4%EC%99%80-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 16 Apr 2026 02:38:14 GMT</pubDate>
            <description><![CDATA[<h1 id="🗂️-파일과-디렉터리-구조">🗂️ 파일과 디렉터리 구조</h1>
<p>리눅스를 처음 배우기 시작하면 낯선 명령어가 한꺼번에 쏟아진다. <code>ls</code>, <code>cd</code>, <code>rm</code>, <code>chmod</code>, <code>ps</code>, <code>kill</code> 같은 명령어를 보다 보면, 뭔가 외워야 할 것이 너무 많아 보여서 막막해지기도 한다. 그런데 막상 리눅스를 조금 사용하다보니, 중요한 건 명령어를 많이 아는 것보다 <strong>리눅스가 파일, 사용자, 권한, 프로세스, 네트워크를 어떤 방식으로 다루는지 흐름을 이해하는 것</strong>이라는 생각이 들었다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/023594eb-8229-4e51-b7f2-f035ed095a54/image.png" alt=""></p>
<p>리눅스를 이해할 때 가장 먼저 익혀야 하는 것은 <strong>파일과 디렉터리 구조</strong>다. 리눅스는 모든 것을 파일처럼 다루는 운영체제라고 할 만큼 파일 중심적인 성격이 강하다. 사용자는 보통 <code>/home/{사용자명}</code> 아래에 자신만의 작업 공간을 가지는데, 이 경로는 편의상 <code>~</code> 기호로 표시된다. 예를 들어 <code>/home/ubuntu</code>는 <code>~</code>로 줄여 쓸 수 있다. 그래서 터미널을 사용할 때는 먼저 <strong>내가 지금 어디에 있는지</strong>를 아는 것이 중요하다. <code>pwd</code>는 현재 위치를 확인하는 명령어이고, <code>cd</code>는 디렉터리를 이동하는 명령어다. 여기에 <code>cd ..</code>는 상위 디렉터리로, <code>cd -</code>는 이전 디렉터리로 이동하는 명령어라는 점까지 익혀두면 기본적인 이동은 거의 해결된다. 경로를 볼 때 중요한 부분은 절대 경로와 상대 경로도 함께 이해해야 한다는 것이다. 절대 경로는 <code>/home/ubuntu/app</code>처럼 루트(<code>/</code>)부터 시작하는 전체 경로이고, 상대 경로는 현재 위치를 기준으로 <code>./app</code>, <code>../app</code>처럼 표현하는 방식이다. </p>
<p>기본적인 파일 조작 명령어도 리눅스 사용의 시작점이다. 파일은 <code>touch 파일명</code>으로 만들고, 디렉터리는 <code>mkdir 디렉터리명</code>으로 생성할 수 있다. 파일 삭제는 <code>rm 파일명</code>, 비어 있는 디렉터리 삭제는 <code>rmdir 디렉터리명</code>, 내부 내용까지 함께 삭제하려면 <code>rm -r 디렉터리명</code>을 사용한다. 파일을 복사할 때는 <code>cp</code>, 이동하거나 이름을 바꿀 때는 <code>mv</code>를 사용한다. 여기서 특히 조심해야 하는 명령어가 <code>rm -rf</code>다. 처음에는 이 명령어 하나로 파일과 디렉터리를 한꺼번에 지울 수 있어서 편해 보이지만, 실제로는 매우 위험하다. 경로를 잘못 입력하면 정말 지우면 안 되는 파일까지 한 번에 삭제할 수 있기 때문이다. 그래서 <code>rm -rf</code>는 편하니까 습관처럼 쓰는 명령어가 아니라, <strong>지금 내가 무엇을 지우는지 충분히 확인한 뒤 정말 필요할 때만 쓰는 명령어</strong>라고 생각하도록 하자.</p>
<p>&nbsp;</p>
<ul>
<li>파일 생성: <code>touch [파일명]</code></li>
<li>디렉터리 생성: <code>mkdir [디렉터리명]</code></li>
<li>파일 제거: <code>rm [파일명]</code></li>
<li>디렉터리 제거: <code>rm -r [디렉터리명]</code>(디렉터리 내부 파일까지 싸그리 삭제), <code>rmdir [디렉터리명]</code>(얘는 내부에 뭔가 있으면 삭제 못함)</li>
<li>파일 복사: <code>cp [복사할 파일][복사할 위치/파일명을 뭐라고 하고 복사할건지(선택)]</code></li>
<li>디렉터리 복사: <code>cp -r [복사할디렉터리][디렉터리명을 뭐라고 하고 복사할건지]</code></li>
<li>파일/디렉터리 이동: <code>mv [이동시킬 파일/이동시킬 디렉터리][이동시킬 디렉터리]</code></li>
<li>파일명 변경: 그냥 그 파일명이 있는 경로에서 <code>mv [이름을 바꿀 파일명][뭐라고 바꿀건지]</code></li>
</ul>
<p>&nbsp;</p>
<h1 id="🖋️-vim-편집기">🖋️ Vim 편집기</h1>
<p>리눅스를 배우다 보면 자연스럽게 터미널 기반 편집기인 <code>vim</code>도 만나게 된다. 리눅스에서 IntelliJ나 VSCode 같은 GUI 편집기를 전혀 사용할 수 없는 것은 아니지만, 서버에 SSH로 접속해서 작업하는 환경에서는 보통 <code>vim</code> 같은 편집기를 많이 사용한다. <code>vi 파일명</code>으로 파일을 열 수 있고, vim은 기본적으로 명령 모드에서 시작한다. 여기서 <code>i</code>를 누르면 입력 모드로 전환되어 내용을 작성할 수 있고, <code>Esc</code>를 누르면 다시 명령 모드로 돌아온다. 저장하고 종료하려면 <code>:wq</code>, 저장하지 않고 나가려면 <code>:q!</code>를 입력하면 된다. 또 <code>gg</code>는 파일 맨 앞으로, <code>G</code>는 파일 맨 뒤로 이동하는 명령이다. 처음에는 이런 조작 방식이 낯설었지만, 막상 몇 번 써보니 생각보다 금방 익숙해졌다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8d65f82f-830a-4a2f-bcec-6cf9f56df148/image.png" alt=""></p>
<p>편집 중에는 <code>.swp</code> 파일이 생성될 수 있는데, 이는 비정상 종료 상황에서 복구를 돕기 위한 임시 파일이다. 따라서 swap 관련 메시지가 보이더라도 에러가 났다기보다는 복구할 파일이 남아 있구나 정도로 이해하면 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a6a6408f-aa43-4b33-8d41-4dc846437576/image.png" alt=""></p>
<p>실제 저 <code>app.txt</code>를 열게 되면 아래와 같이 편집 중이던 작업을 어떻게 할건지 초록색 텍스트로 물어본다. 몇 가지만 살펴보면 아래와 같다.</p>
<ul>
<li>R: 편집 중이던 내용을 복구하고 계속 작성</li>
<li>D: 편집 중이던 내용 무시하고 계속 작성</li>
<li>Q: 아무 작업도 안 하고 그냥 vim 나가기</li>
</ul>
<p>&nbsp;</p>
<h1 id="👤-권한permission">👤 권한(Permission)</h1>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/aa4333fb-d9eb-4c1e-b592-f7cb058f0488/image.png" alt=""></p>
<p>리눅스를 사용하다 보면 자주 마주치는 문구 중 하나가 <code>Permission denied</code>다. 처음에는 이 메시지만 보면 습관적으로 <code>sudo</code>부터 붙이고 싶어진다. 하지만 오히려 이 시점이 리눅스의 사용자와 권한 구조를 이해할 좋은 기회다. 리눅스에는 <strong>일반 사용자</strong>와 <strong>슈퍼 사용자(root)</strong>, 그리고 <strong>그룹</strong>이라는 개념이 존재한다. </p>
<p>일반 사용자는 허용된 범위 안에서만 파일을 다루고 명령을 실행할 수 있지만, root 사용자는 시스템 전체에 강력한 권한을 가진다. 그룹은 여러 사용자에게 공통 권한을 부여하기 위한 단위다. 현재 내가 어떤 사용자로 로그인했는지는 <code>whoami</code>로 확인할 수 있고, <code>groups 사용자명</code>이나 <code>id 사용자명</code>을 사용하면 그룹 정보까지 함께 볼 수 있다. 또한 프롬프트에서 <code>$</code>가 보이면 <em>일반 사용자</em>, <code>#</code>가 보이면 <em>root 사용자</em> 라고 이해하면 된다. 중요한 점은, 평소에는 일반 사용자로 작업하다가 정말 필요한 순간에만 <code>sudo</code>를 사용하는 습관이 훨씬 안전하다는 것이다. 무조건 <code>sudo</code>를 붙이는 방식은 당장은 편할 수 있어도, 나중에는 권한 문제의 원인을 이해하지 못한 채 넘어가게 될 수도 있다.</p>
<p>&nbsp;</p>
<p>다시 한번 정리하자.</p>
<ul>
<li><code>사용자(User)</code>: 컴퓨터에 접근하는 계정이다. 권한이 허용된 명령어만 실행시킬 수 있고, 권한이 허용된 파일만 조작할 수 있다.</li>
<li><code>슈퍼 사용자(Super user)</code>: 시스템의 모든 권한을 가진 계정을 말한다. 모든 명령어를 실행할 수 있고, 모든 파일을 조작할 수 있다. 일반적으로 root 계정이 슈퍼 사용자로 설정되어 있다.</li>
<li><code>그룹(Group)</code>: 사용자 계정을 묶어서 관리하기 위한 단위이다. 여러 사용자에게 공통된 권한을 한 번에 부여하고 관리할 때 유용하게 사용된다. 한 사용자는 무조건 하나의 그룹에 속해야 하고, 한 사용자는 여러 그룹에 속할 수 있다.</li>
</ul>
<p>&nbsp;</p>
<p>권한 구조를 조금 더 자세히 보려면 <code>ls -l</code> 명령어도 알아야 한다. 이 명령어를 실행하면 위의 이미지와 같이 좌측에 <code>-rw-r--r--</code> 같은 문자열이 보이는데…</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/10c51d42-661b-4299-bccf-4c194423c04c/image.png" alt=""></p>
<p>맨 앞의 <code>-</code>는 일반 파일이라는 뜻이고, <code>d</code>가 나오면 디렉터리를 의미한다. 그 뒤의 아홉 글자는 소유자, 그룹, 기타 사용자 권한을 세 글자씩 나눠서 보여준다. 여기서 <code>r</code>은 읽기, <code>w</code>는 쓰기, <code>x</code>는 실행 권한이다. 권한은 숫자로도 표현할 수 있는데, <code>r=4</code>, <code>w=2</code>, <code>x=1</code>로 계산해서 <code>chmod 755 파일명</code>처럼 설정할 수 있다. 예를 들어 <code>755</code>는 소유자에게는 읽기/쓰기/실행을 모두 허용하고, 그룹과 기타 사용자에게는 읽기와 실행만 허용한다는 뜻이다. </p>
<p>권한뿐 아니라 소유자도 중요하다. 파일 소유자를 변경할 때는 <code>sudo chown 사용자명:그룹명 파일명</code>을 사용한다. 결국 권한 문제는 단순히 실행 가능 유무의 문제가 아니라, <strong>누가 그 파일의 주인이고, 누가 어디까지 접근할 수 있느냐</strong>의 문제라고 보는 것이 더 정확하다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9c14b87c-5bb8-4236-95f4-cb8b92429caf/image.png" alt=""></p>
<p>참고로 <code>/etc/passwd</code> 파일을 까보면 기본적으로 생성되어 있는 사용자들의 목록(root, daemon, bin, sys 등등)을 확인할 수 있다. 일단 지금은 <strong>root(슈퍼 사용자)</strong>와 <strong>ubuntu(일반 사용자)</strong>만 기억해두도록 하자.</p>
<p>&nbsp;</p>
<h1 id="🤖-패키지-매니저package-manager">🤖 패키지 매니저(Package Manager)</h1>
<p>Ubuntu 환경에서 프로그램을 설치하거나 삭제할 때는 보통 <code>apt</code>를 사용한다. 자바 진영에서 Maven이나 Gradle을 쓰고, Node.js에서 npm을 쓰듯이, 리눅스에는 패키지 매니저가 있는 셈이다. </p>
<p><code>sudo apt update</code>는 설치 가능한 패키지 목록 정보를 최신화하는 명령어이고, <code>sudo apt upgrade</code>는 현재 설치된 패키지들을 실제로 업그레이드한다. 새로운 프로그램을 설치할 때는 <code>sudo apt install 패키지명</code>, 설치된 패키지 목록을 보고 싶을 때는 <code>sudo apt list --installed</code>, 특정 패키지가 설치되어 있는지 찾고 싶을 때는 <code>sudo apt list --installed | grep 패키지명</code>을 사용할 수 있다. 삭제할 때는 <code>sudo apt purge --auto-remove 패키지명</code>처럼 관련 설정까지 함께 정리하는 방식도 자주 쓴다. </p>
<p>여기서 왜 <code>sudo</code>가 필요한지를 생각해보면, 설치와 삭제 과정이 결국 시스템 디렉터리와 root 소유 파일을 건드리는 작업이기 때문이다. 예를 들어, 그냥 <code>apt install nginx</code>로 실행해보면 <code>/var/lib/dpkg/lock-frontend</code> 폴더에 접근 권한이 없다는 이유로 설치가 안 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4e3b666a-72c6-4e4a-b680-ff2288ee50a1/image.png" alt=""></p>
<p>위와 같이 실제 들어가보면 <code>lock-frontend</code> 파일의 소유자가 root, 소유 그룹도 root다. root이외의 사용자한테는 아무 권한도 없는 것을 확인할 수 있다. 그래서 <code>sudo</code>를 붙이지 않으면 에러가 발생한 것이다. 이런 맥락까지 함께 이해하고 나면, <code>apt</code> 명령어가 단순한 설치 도구가 아니라 시스템 수준의 변경을 수행하는 관리자 도구라는 것을 알 수 있다.</p>
<p>&nbsp;</p>
<ul>
<li>패키지 설치 명령어: <code>sudo apt install [패키지명]</code></li>
<li>패키지 목록 최신화: <code>sudo apt update</code></li>
<li>현재 컴퓨터에 설치된 모든 패키지 목록 출력: <code>sudo apt list —installed</code></li>
<li>현재 컴퓨터에 설치된 특정 패키지 확인: <code>sudo apt list —installed | grep [패키지명]</code></li>
<li>설치된 패키지에 관련된 모든 파일을 삭제: <code>sudo apt purge —auto-remove [패키지명]</code></li>
</ul>
<p>&nbsp;</p>
<h1 id="🖨️-표준-출력stdout--표준-에러stderr">🖨️ 표준 출력(stdout) &amp; 표준 에러(stderr)</h1>
<p>터미널을 조금 더 잘 이해하려면 <strong>표준 출력</strong>과 <strong>표준 에러</strong> 개념도 꼭 알아야 한다. 명령어를 실행했을 때 결과가 그냥 화면에 찍히는 것처럼 느껴지지만, 실제로는 결과가 먼저 <strong>표준 출력(stdout)</strong> 이라는 통로로 전달되고, 그 통로의 기본 목적지가 터미널 화면으로 연결되어 있는 것이다. 반면 에러 메시지는 <strong>표준 에러(stderr)</strong> 라는 별도 통로를 사용한다. 이 개념을 이해하면 <code>&gt;</code>, <code>&gt;&gt;</code>, <code>2&gt;</code>, <code>2&gt;&gt;</code> 같은 기호들이 훨씬 자연스럽게 받아들여진다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/79f34b7d-40d6-4c76-9ae0-59cafa58ca53/image.png" alt=""></p>
<p>예를 들어 <code>명령어 &gt; 파일</code>은 표준 출력을 파일에 덮어쓰는 것이고, <code>명령어 2&gt; 파일</code>은 에러 메시지를 파일에 저장하는 것이다. 즉, 리눅스에서는 출력 결과와 에러 메시지를 서로 다른 곳으로 보낼 수 있다. 처음에는 단순한 기호처럼 보이지만, 실제로 로그를 남기고 에러를 분리해서 확인할 때 이 기능은 생각보다 강력하다.</p>
<p>&nbsp;</p>
<ul>
<li>기존 파일에 출력 결과 덮어쓰기: <code>명령어 &gt; [그 결괏값을 저장할 파일]</code></li>
<li>기존 파일에 출력 결과 이어서 쓰기: <code>명령어 &gt;&gt; [그 결괏값을 저장할 파일]</code></li>
<li>기존 파일에 에러 출력 덮어쓰기: <code>명령어 2&gt; [그 결괏값을 저장할 파일]</code></li>
<li>기존 파일에 에러 출력 이어서 쓰기: <code>명령어 2&gt;&gt; [그 결괏값을 저장할 파일]</code></li>
</ul>
<p>&nbsp;</p>
<h1 id="🔍-특정-키워드가-들어간-문장-찾기grep">🔍 특정 키워드가 들어간 문장 찾기(grep)</h1>
<p>특정 키워드가 포함된 줄만 찾고 싶을 때는 <code>grep</code>이 아주 유용하다. 예를 들어 <code>ls -al | grep media</code>는 <code>ls -al</code>의 결과 중에서 <code>media</code>가 포함된 줄만 보여준다. <code>cat README | grep you</code>처럼 다른 명령어의 출력 결과와 함께 사용할 수도 있고, <code>grep you README</code>처럼 파일을 직접 검색할 수도 있다. </p>
<p><code>grep -n</code>은 줄 번호까지 같이 보여주고, <code>grep -i</code>는 대소문자를 구분하지 않고 검색한다. grep은 단독으로도 자주 쓰이지만, 파이프(<code>|</code>)와 함께 사용할 때 진짜 빛을 발한다. 예를 들어 <code>ps aux | grep java</code>처럼 실행 중인 프로세스 목록에서 특정 프로그램만 추려볼 수 있기 때문이다. 리눅스에서 결과가 너무 많아서 원하는 정보만 보고 싶은 상황이 오면, <code>grep</code>은 거의 항상 떠올려야 하는 도구다.</p>
<p>&nbsp;</p>
<h1 id="❤️🔥-실행-중인-프로세스-조회--종료">❤️‍🔥 실행 중인 프로세스 조회 &amp; 종료</h1>
<p>프로세스는 현재 실행 중인 프로그램을 의미한다. 윈도우의 작업 관리자처럼 생각하면 이해하기 쉽다. <code>ps aux</code>를 실행하면 현재 돌아가고 있는 프로세스 목록을 볼 수 있는데…</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e58f5377-cba2-4768-bef7-cee23bfc2d67/image.png" alt=""></p>
<p>여기서 <code>USER</code>는 누가 실행했는지, <code>PID</code>는 프로세스 ID, <code>%CPU</code>와 <code>%MEM</code>은 자원 사용량, <code>COMMAND</code>는 어떤 명령으로 실행되었는지를 보여준다. 프로세스를 종료하려면 <code>kill [PID]</code>를 사용하고, 정상 종료가 되지 않을 때는 <code>kill -9 [PID]</code>로 강제 종료할 수 있다. 다만 <code>kill -9</code>는 프로그램이 스스로 정리할 기회도 주지 않고 바로 종료시키는 방식이므로, 가능하면 일반 <code>kill</code>을 먼저 시도하고 정말 필요할 때만 사용해야 한다. 리눅스에서는 단순히 프로그램을 실행하는 것만큼이나, <strong>지금 무엇이 돌아가고 있는지 확인하고 필요한 것을 종료하는 능력</strong>도 중요하다.</p>
<p>실행 중인 프로그램을 다룰 때는 <strong>포그라운드(Foreground)</strong>와 <strong>백그라운드(Background)</strong>의 차이도 꼭 이해해야 한다. 포그라운드는 프로그램이 현재 터미널을 점유한 상태이고, 백그라운드는 화면을 점유하지 않고 뒤에서 계속 실행되는 상태다. 예를 들어 스프링 부트 서버를 포그라운드로 실행하면 그 터미널은 사실상 서버 로그를 보는 창이 되어버려서 다른 명령을 입력하기 어렵다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/89cf6b8f-052b-4d66-9a40-be6800bd3309/image.png" alt=""></p>
<p><del>명령어를 입력해도 반응이 없다…</del></p>
<p>이럴 때 <code>nohup 명령어 &amp;</code> 형태로 실행하면 프로그램을 백그라운드로 돌릴 수 있다. <code>nohup</code>은 터미널이 닫혀도 프로그램이 계속 실행되게 해주고, 기본적으로 출력은 <code>nohup.out</code> 파일에 저장된다. 조금 더 실무적으로는 <code>nohup java -jar app.jar &gt; app.log 2&gt;&amp;1 &amp;</code>처럼 로그 파일을 직접 지정해서 표준 출력과 에러를 함께 저장하는 방식이 더 자주 쓰인다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c4b64370-2153-49fe-b7ce-7642afe2d989/image.png" alt=""></p>
<p>이후 <code>tail -f app.log</code>로 로그를 실시간으로 확인하면 서버 상태를 훨씬 편하게 추적할 수 있다. 서버를 띄워놓고 다른 작업을 해야 하는 상황에서는 이 흐름을 기억하도록 하자.</p>
<p>&nbsp;</p>
<ul>
<li>실행 중인 모든 프로세스 조회: <code>ps aux</code></li>
<li>실행 중인 프로세스 종료: <code>kill [PID 값]</code></li>
<li>실행 중인 프로세스 강제 종료: <code>kill -9 [PID 값]</code></li>
<li>백그라운드에서 실행: <code>nohup + 명령어 + &amp;</code></li>
</ul>
<p>&nbsp;</p>
<h1 id="📲-특정-포트에서-실행되고-있는-프로세스-조회">📲 특정 포트에서 실행되고 있는 프로세스 조회</h1>
<p>네트워크와 관련해서는 포트와 IP 주소도 기본 개념으로 꼭 정리해둘 필요가 있다. 포트는 한 컴퓨터 안에서 특정 프로그램이 네트워크 통신을 위해 사용하는 번호라고 생각하면 된다. 그래서 같은 서버에서 같은 포트를 두 프로그램이 동시에 사용하려 하면 충돌이 난다. </p>
<p>예를 들어 이미 8080번 포트에서 스프링 부트가 실행 중인데, 또 다른 프로그램이 8080번 포트를 쓰려고 하면 에러가 발생한다. 이럴 때는 <code>sudo lsof -i:8080</code> 또는 <code>sudo ss -ltnp | grep 8080</code>을 사용해서 어떤 프로세스가 그 포트를 점유하고 있는지 확인할 수 있다. 보통은 결과에서 <code>COMMAND</code>와 <code>PID</code>를 중심으로 보면 된다. IP 주소는 네트워크에서 특정 컴퓨터를 식별하기 위한 주소인데, <strong>외부 인터넷에서 보이는 주소는 Public IP, 내부 네트워크 안에서만 쓰이는 주소는 Private IP</strong>라고 한다. Public IP는 <code>curl ifconfig.me</code>, Private IP는 <code>ip a</code> 또는 <code>hostname -I</code>로 확인할 수 있다. </p>
<p>&nbsp;</p>
<ul>
<li>특정 포트 번호에 실행되고 있는 프로세스 조회: <code>sudo lsof -i:[포트번호]</code></li>
<li>Public IP 주소 확인 명령어: <code>curl [ifconfig.me](http://ifconfig.me/)</code></li>
<li>Private IP 주소 확인 명령어: <code>ip a</code></li>
</ul>
<p>&nbsp;</p>
<h1 id="📝-쉘-스크립트-파일-작성--실행">📝 쉘 스크립트 파일 작성 &amp; 실행</h1>
<p>마지막으로 쉘 스크립트는 반복 작업을 자동화할 수 있게 해주는 아주 강력한 도구다. 여러 리눅스 명령어를 순서대로 실행해야 할 때, 매번 직접 입력하는 대신 <code>.sh</code> 파일에 적어두고 한 번에 실행할 수 있다. </p>
<p>보통 파일 맨 위에는 <code>#!/bin/bash</code>를 적어서 이 스크립트를 어떤 쉘로 실행할지 명시한다. 그 아래에는 <code>echo</code>, <code>pwd</code>, <code>ls -al</code> 같은 작업을 자동적으로 수행할 명령어를 순서대로 작성하면 된다. 실행할 때는 <code>chmod +x script.sh</code>로 실행 권한을 부여한 뒤 <code>./script.sh</code>로 실행할 수 있고, 또는 <code>bash script.sh</code>처럼 직접 bash로 실행할 수도 있다. 처음에는 단순히 명령어를 모아둔 파일처럼 보이지만, 실제로는 반복 작업, 배포, 로그 정리, 서버 실행 같은 복잡한 작업을 자동화하는 출발점이 된다.</p>
<p>아래는 스프링 부트 서버 실행과 종료, 서버 재시작을 자동화 처리하는 예시 쉘 스크립트 파일이다.</p>
<pre><code class="language-bash">#!/bin/bash

APP_NAME=&quot;my-spring-app&quot;
APP_HOME=&quot;/home/ubuntu/app&quot;
JAR_FILE=&quot;$APP_HOME/app.jar&quot;
LOG_DIR=&quot;$APP_HOME/logs&quot;
LOG_FILE=&quot;$LOG_DIR/app.log&quot;
PID_FILE=&quot;$APP_HOME/app.pid&quot;

echo &quot;[$APP_NAME] 시작합니다.&quot;

# JAR 파일 존재 확인
if [ ! -f &quot;$JAR_FILE&quot; ]; then
  echo &quot;[$APP_NAME] JAR 파일이 없습니다: $JAR_FILE&quot;
  exit 1
fi

# 로그 디렉터리 생성
mkdir -p &quot;$LOG_DIR&quot;

# 이미 실행 중인지 확인
if [ -f &quot;$PID_FILE&quot; ]; then
  PID=$(cat &quot;$PID_FILE&quot;)
  if ps -p &quot;$PID&quot; &gt; /dev/null 2&gt;&amp;1; then
    echo &quot;[$APP_NAME] 이미 실행 중입니다. PID=$PID&quot;
    exit 1
  else
    echo &quot;[$APP_NAME] 오래된 PID 파일을 삭제합니다.&quot;
    rm -f &quot;$PID_FILE&quot;
  fi
fi

# 애플리케이션 실행
nohup java -jar &quot;$JAR_FILE&quot; &gt; &quot;$LOG_FILE&quot; 2&gt;&amp;1 &amp;

PID=$!
echo &quot;$PID&quot; &gt; &quot;$PID_FILE&quot;

sleep 2

if ps -p &quot;$PID&quot; &gt; /dev/null 2&gt;&amp;1; then
  echo &quot;[$APP_NAME] 실행 성공&quot;
  echo &quot;[$APP_NAME] PID=$PID&quot;
  echo &quot;[$APP_NAME] LOG=$LOG_FILE&quot;
else
  echo &quot;[$APP_NAME] 실행 실패&quot;
  rm -f &quot;$PID_FILE&quot;
  exit 1
fi</code></pre>
<pre><code class="language-bash">#!/bin/bash

APP_NAME=&quot;my-spring-app&quot;
APP_HOME=&quot;/home/ubuntu/app&quot;
PID_FILE=&quot;$APP_HOME/app.pid&quot;

echo &quot;[$APP_NAME] 종료합니다.&quot;

if [ ! -f &quot;$PID_FILE&quot; ]; then
  echo &quot;[$APP_NAME] PID 파일이 없습니다. 이미 종료되었을 수 있습니다.&quot;
  exit 1
fi

PID=$(cat &quot;$PID_FILE&quot;)

if ps -p &quot;$PID&quot; &gt; /dev/null 2&gt;&amp;1; then
  kill &quot;$PID&quot;
  echo &quot;[$APP_NAME] 종료 요청을 보냈습니다. PID=$PID&quot;

  # 최대 10초 대기
  for i in {1..10}
  do
    if ps -p &quot;$PID&quot; &gt; /dev/null 2&gt;&amp;1; then
      sleep 1
    else
      break
    fi
  done

  if ps -p &quot;$PID&quot; &gt; /dev/null 2&gt;&amp;1; then
    echo &quot;[$APP_NAME] 정상 종료되지 않아 강제 종료합니다. PID=$PID&quot;
    kill -9 &quot;$PID&quot;
  fi

  rm -f &quot;$PID_FILE&quot;
  echo &quot;[$APP_NAME] 종료 완료&quot;
else
  echo &quot;[$APP_NAME] 해당 PID의 프로세스가 없습니다. PID 파일만 삭제합니다.&quot;
  rm -f &quot;$PID_FILE&quot;
fi</code></pre>
<pre><code class="language-bash">#!/bin/bash

APP_NAME=&quot;my-spring-app&quot;
APP_HOME=&quot;/home/ubuntu/app&quot;

echo &quot;[$APP_NAME] 재시작합니다.&quot;

&quot;$APP_HOME/stop.sh&quot;
sleep 2
&quot;$APP_HOME/start.sh&quot;</code></pre>
<hr>
<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://www.inflearn.com/course/%EB%B9%84%EC%A0%84%EA%B3%B5%EC%9E%90%EB%8F%84-%EC%9D%B4%ED%95%B4%ED%95%A0-%EC%88%98-%EC%9E%88%EB%8A%94-%EB%A6%AC%EB%88%85%EC%8A%A4-%EC%9E%85/dashboard?cid=337817">비전공자도 이해할 수 있는 리눅스 입문/실전</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue3 템플릿]]></title>
            <link>https://velog.io/@rocker_nun/Vue3-%ED%85%9C%ED%94%8C%EB%A6%BF</link>
            <guid>https://velog.io/@rocker_nun/Vue3-%ED%85%9C%ED%94%8C%EB%A6%BF</guid>
            <pubDate>Wed, 25 Mar 2026 02:44:04 GMT</pubDate>
            <description><![CDATA[<p>Vue에서의 템플릿은 Vue로 화면을 조작하는 방법을 말한다. <strong>템플릿 문법은 크게 데이터 바인딩과 디렉티브로 나뉜다.</strong> 하나씩 알아보도록 하자.</p>
<h1 id="🔗-data-binding">🔗 Data Binding</h1>
<p><strong>데이터 바인딩(Data Binding)은 Vue 인스턴스에서 정의한 속성들을 화면에 표시하는 방법</strong>이다. 보통 머시태시(<code>{{}}</code>) 문법을 사용한다.</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;app&quot;&gt;{{ message }}&lt;/div&gt;
    &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
    &lt;script&gt;
      let vm = Vue.createApp({
        name: &quot;App&quot;,
        data() {
          return {
            message: &quot;Hello Vue3!&quot;,
          };
        },
      }).mount(&quot;#app&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>예제 코드를 보면 <code>&lt;div&gt;</code> 안에 머스태시 괄호를 이용해서 Vue 인스턴스의 <code>message</code> 속성을 연결한 것을 볼 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ceb27948-435d-4c01-a6ab-4cc1346cc906/image.png" alt=""></p>
<p>&nbsp;</p>
<h2 id="🛎️-vue-인스턴스">🛎️ Vue 인스턴스?</h2>
<p><strong>인스턴스는 Vue로 개발할 때 필수로 생성해야 하는 코드</strong>를 말한다. 위의 예제 코드에서 <code>Vue.createApp()</code>을 이용해서 인스턴스를 생성할 수 있다. 인스턴스 안에는 미리 정의되어 있는 속성과 메서드(API)들이 있기 때문에 해당 기능들을 이용해서 빠르게 화면을 개발할 수 있다. 인스턴스에서 사용할 수 있는 주요 속성들에 대해 간단히 말하자면…</p>
<pre><code class="language-jsx">Vue.createApp({
    template: ,  // 화면에 렌더링되는 녀석들
    data: ,  // 반응형 데이터 속성
    methods: ,  // 이벤트 로직을 제어하는 메서드
    created: ,  // 뷰의 라이프사이클에서의 부가 작업
    watch: ,  // data에서 정의한 속성에 변화가 일어났을 때 추가 동작을 수행할 수 있게 정의하는 속성
});</code></pre>
<p>참고로 Vue2에서는 <code>el</code>이라는 속성이 있었지만, Vue3로 넘어오면서 해당 속성을 사용하지 않고 <code>mount()</code> API만 사용한다.</p>
<p>&nbsp;</p>
<h1 id="🪝-directives">🪝 Directives</h1>
<p><strong>디렉티브(Directives)는 Vue로 화면의 요소를 더 쉽게 조작할 수 있게 하는 문법</strong>이다. <code>v-</code> 접두사가 붙은 특수 속성으로 직역하면 말 그대로 <em>“지시”</em> 를 뜻한다. 쉽게 말해, 디렉티브는 컴포넌트나 DOM 요소에게 <em>“~ 하게 작동할 것”</em> 을 지시해주는 지시문인 셈이다. 디렉티브는 아래와 같이 구성되어 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7dc345c8-ed49-4d5c-9407-586570906b09/image.png" alt=""></p>
<p>어떤 디렉티브들이 있는지 직접 보면서 알아보도록 하자.</p>
<p>&nbsp;</p>
<h2 id="💬-v-text--v-html-디렉티브">💬 v-text &amp; v-html 디렉티브</h2>
<p>둘 다 텍스트를 출력하는 디렉티브지만, 둘의 차이는 <strong>문자열을</strong> <em>“그대로 텍스트로 넣느냐”, HTML로 해석해서 넣느냐”</em> 다. </p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;app&quot;&gt;
      &lt;span v-text=&quot;message&quot;&gt;&lt;/span&gt;
      &lt;span v-html=&quot;message&quot;&gt;&lt;/span&gt;
      &lt;span&gt;{{ message }}&lt;/span&gt;
    &lt;/div&gt;
    &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
    &lt;script&gt;
      let vm = Vue.createApp({
        name: &quot;App&quot;,
        data() {
          return {
            message: &quot;&lt;b&gt;Hello Vue3!&lt;/b&gt;&quot;,
          };
        },
      }).mount(&quot;#app&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/3d8a4cff-06ac-4aa1-bb48-2a1551c04fd9/image.png" alt=""></p>
<p>보다시피 <code>v-text</code>는 텍스트를 그대로 뱉어내고, <code>v-html</code>은 HTML 요소는 HTML로 해석해서 뱉어낸다. 보통 <code>v-text</code> 디렉티브를 사용할 것을 권장한다. 왜냐하면 사용자가 입력한 값을 그대로 <code>v-html</code>에 넣으면 악성 스크립트가 섞일 수도 있기 때문이다. 이걸 보통 XSS(Cross-Site Scripting) 문제라고도 한다.</p>
<p>&nbsp;</p>
<h2 id="⛓️-v-bind">⛓️ v-bind</h2>
<p>이 디렉티브는 Vue 데이터와 HTML 속성을 연결해주는 디렉티브다. 바인딩은 말 그대로 <em>“연결한다”</em> 는 의미인데, Vue에서의 바인딩은 보통 <strong>data에 있는 값</strong>과 <strong>화면(HTML 요소)</strong>을 연결하는 것을 말한다.</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
    &lt;head&gt;
        &lt;meta charset=&quot;UTF-8&quot; /&gt;
        &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
        &lt;title&gt;Document&lt;/title&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;div id=&quot;app&quot;&gt;
            &lt;input type=&quot;text&quot; v-bind:value=&quot;message&quot; /&gt;
            &lt;br /&gt;
            &lt;img v-bind:src=&quot;imagePath&quot; /&gt;
        &lt;/div&gt;
        &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
        &lt;script&gt;
            let viewModel = Vue.createApp({
                name: &#39;App&#39;,
                data() {
                    return {
                        message: &#39;v-bind 디렉티브&#39;,
                        imagePath:
                            &#39;https://img.freepik.com/free-photo/shot-tiger-laying-ground-while-watching-his-territory_181624-44049.jpg&#39;,
                    };
                },
            }).mount(&#39;#app&#39;);
        &lt;/script&gt;
    &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>일반적으로 HTML은 정적인 문서지만, 동적인 UI를 만들고 싶어서 Vue를 사용하는 것이다. 그래서 속성값도 고정된 값이 아니라 데이터의 값에 따라 달라지길 원하기 때문에 <code>v-bind</code>가 필요한 것이다. 여기서 짚고 넘어가야 할 점은 <code>v-bind</code> 디렉티브는 <strong>단방향으로만 데이터를 바인딩</strong>한다는 점이다. 즉, Vue 인스턴스의 데이터나 속성이 바뀌면 UI를 갱신한다는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/9e28ea1c-592c-4159-abcc-f0de635fce01/image.png" alt=""></p>
<p>렌더링된 화면을 보면 알 수 있듯이 화면에 바인딩된 요소에서 값을 변경하더라도 데이터가 바뀌지 않는 것을 볼 수 있다. 콘솔에서 <code>viewModel.message=&quot;단방향임...&quot;</code> 으로 속성에 직접 값을 할당해서 데이터를 변경해야 UI도 변경된다.</p>
<p>&nbsp;</p>
<h2 id="↔️-v-model">↔️ v-model</h2>
<p>앞서 살펴봤던 디렉티브들과는 다르게 <code>v-model</code>은 폼 입력 요소의 값과 Vue 데이터를 양방향으로 연결하는 디렉티브다. 쉽게 말해 데이터가 바뀌면 입력창 화면도 바뀌고, 사용자가 입력창 값을 바꾸면 데이터도 같이 바뀐다는 것이다.</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
    &lt;head&gt;
        &lt;meta charset=&quot;UTF-8&quot; /&gt;
        &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
        &lt;title&gt;Document&lt;/title&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;div id=&quot;app&quot;&gt;
            &lt;input type=&quot;text&quot; v-model=&quot;name&quot; /&gt;
            &lt;br /&gt;
            입력한 값: &lt;span&gt;{{name}}&lt;/span&gt;
        &lt;/div&gt;
        &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
        &lt;script&gt;
            let viewModel = Vue.createApp({
                name: &#39;App&#39;,
                data() {
                    return {
                        name: &#39;&#39;,
                    };
                },
            }).mount(&#39;#app&#39;);
        &lt;/script&gt;
    &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/81936a57-3bc8-4489-8eb6-3248bc54e7d8/image.gif" alt=""></p>
<p>그리고 <code>checkbox</code>와 <code>select</code> 요소에서의 <code>v-model</code>은 다중 선택인지 단일 선택을 하는지에 따라 양방향 데이터 바인딩으로 값을 받아내기 위한 데이터의 형식이 달라진다. 다중 선택은 배열을 사용해야 하고, 단일 선택은 문자열로 값을 받아내야 한다. 그리고 <code>checkbox</code>, <code>radio</code>와 같이 <code>input</code> 요소를 사용하는 경우는 각각의 <code>input</code> 요소마다 <code>v-model</code> 디렉티브를 적용해야 하지만, <code>select</code>와 같이 값을 선택하는 요소를 감싸는 부모 요소가 있다면 부모 요소인 <code>select</code>에 <code>v-model</code> 디렉티브를 한 번만 적용해야 한다는 점도 알아두도록 하자.</p>
<p>&nbsp;</p>
<h2 id="↪️-v-if--v-for">↪️ v-if &amp; v-for</h2>
<p><code>v-if</code> 디렉티브는 느낌 그대로 화면 렌더링에서의 <code>if문</code>이라고 생각하면 된다. 조건이 참이면 화면에 렌더링되고, 거짓이면 아예 렌더링되지 않는다. 그냥 조건이 거짓이면 DOM에 아예 만들지 않는다는 점만 기억해두도록 하자.</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;app&quot;&gt;
      잔고: &lt;input type=&quot;text&quot; v-model=&quot;deposit&quot; /&gt;
      &lt;br /&gt;
      회원 등급:
      &lt;span v-if=&quot;deposit &gt; 1000000&quot;&gt;Gold&lt;/span&gt;
      &lt;span v-else-if=&quot;deposit &gt;= 500000&quot;&gt;Silver&lt;/span&gt;
      &lt;span v-else-if=&quot;deposit &gt;= 200000&quot;&gt;Bronze&lt;/span&gt;
      &lt;span v-else=&quot;deposit&quot;&gt;Basic&lt;/span&gt;
    &lt;/div&gt;
    &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
    &lt;script&gt;
      let viewModel = Vue.createApp({
        name: &quot;App&quot;,
        data() {
          return {
            deposit: 0,
          };
        },
      }).mount(&quot;#app&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>&nbsp;</p>
<p><code>v-for</code> 디렉티브도 느낌 그대로다. 배열이나 객체 데이터를 반복해서 화면에 출력하는 디렉티브다.</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;app&quot;&gt;
      &lt;ul&gt;
        &lt;li v-for=&quot;item in items&quot;&gt;{{item}}&lt;/li&gt;
      &lt;/ul&gt;
    &lt;/div&gt;
    &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
    &lt;script&gt;
      let vm = Vue.createApp({
        name: &quot;App&quot;,
        data() {
          return {
            items: [&quot;맥북 프로&quot;, &quot;아이패드 프로&quot;, &quot;에어팟 프로&quot;],
          };
        },
      }).mount(&quot;#app&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p><code>v-for=&quot;(item, index) in items&quot;</code>처럼 인덱스 정보도 추가로 뿌려줄 수 있고 객체도 각 속성들을 뽑아서 뿌려줄 수도 있다. 그리고 나중에 요소가 많아지는 상황에서 반복 렌더링할 때는 보통 <code>:key</code>를 같이 사용한다. 아래 예제 코드를 살펴보고 넘어가도록 하자.</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id=&quot;app&quot;&gt;
      &lt;ul&gt;
        &lt;li v-for=&quot;item in items&quot; :key=&quot;item.id&quot;&gt;{{ item.text }}&lt;/li&gt;
      &lt;/ul&gt;
    &lt;/div&gt;
    &lt;script src=&quot;https://unpkg.com/vue&quot;&gt;&lt;/script&gt;
    &lt;script&gt;
      let vm = Vue.createApp({
        name: &quot;App&quot;,
        data() {
          return {
            items: [
              {
                id: 1,
                text: &quot;맥북 프로&quot;,
              },
              {
                id: 2,
                text: &quot;아이패드 프로&quot;,
              },
              {
                id: 3,
                text: &quot;에어팟 프로&quot;,
              },
            ],
          };
        },
      }).mount(&quot;#app&quot;);
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Node에서의 비동기 처리 방식]]></title>
            <link>https://velog.io/@rocker_nun/Node%EC%97%90%EC%84%9C%EC%9D%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D</link>
            <guid>https://velog.io/@rocker_nun/Node%EC%97%90%EC%84%9C%EC%9D%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EC%8B%9D</guid>
            <pubDate>Fri, 13 Mar 2026 23:54:24 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-자바스크립트는-싱글-스레드인데-어떻게-비동기-처리를">🤔 자바스크립트는 싱글 스레드인데 어떻게 비동기 처리를?</h1>
<p>자바스크립트는 싱글 스레드 기반으로, 한번에 하나의 명령만 수행 가능하지만 여러 라이브러리를 통해 비동기 방식으로 여러 가지 일을 처리할 수 있다. 자바스크립트를 비동기 방식으로 처리하는 방법은 크게 3가지가 있다.</p>
<ul>
<li><code>콜백(Callback)</code>: 요청이 끝난 후 실행할 함수를 매개변수로 추가하는 방식</li>
<li><code>프로미스(Promise)</code>: Promise 객체를 반환하는 방식</li>
<li><code>Async/Await</code>: Promise를 간단하게 사용하도록 변경한 방식</li>
</ul>
<p>&nbsp;</p>
<h2 id="↪️-callback-함수-사용">↪️ Callback 함수 사용</h2>
<p><strong>콜백 함수는 내가 원하는 순서로 비동기 코드를 실행할 수 있게 하는 함수</strong>를 말한다. 함수를 인자로 넘기고 작업 완료 시 실행한다(함수의 인자로 함수가 넘어감). 커피숍에서 점원에게 주문 후에 완료되면 점원이 손님을 호출하는 상황을 떠올려보면 이해가 편하다.</p>
<p>아래 회원 가입을 하는 과정을 예로 들어보자. 먼저 회원 가입 API가 호출되면 DB에 사용자 정보를 저장하고, 회원 가입 성공 시 사용자에게 이메일 전송 후, 가입 성공 메시지를 출력해주는 것이다. </p>
<pre><code class="language-jsx">const database = []; // 회원 DB

// 가입 요청한 회원의 정보를 저장하는 API 작성
function saveDb(user, callback) {
  database.push(user);
  console.log(`Save ${user.name} in Database...`);
  return callback(user);
}

// 회원에게 이메일 전송하는 API 작성
function sendEmail(user, callback) {
  console.log(`Send Email to ${user.email}`);
  return callback(user);
}

// 회원에게 가입 성공 메시지를 출력하는 API 작성
function getResult(user) {
  return `${user.name} is successfully registered...`;
}

// 콜백 함수로 API 작성
function register(user) {
  return saveDb(user, function (user) {
    return sendEmail(user, function (user) {
      return getResult(user);
    });
  });
}

const result = register({
  email: &#39;byeonguk@gmail.com&#39;,
  password: &#39;1234&#39;,
  name: &#39;ByeongUk&#39;,
});

console.log(result);
// Save ByeongUk in Database...
// Send Email to byeonguk@gmail.com
// ByeongUk is successfully registered...

// register() 함수는 순서대로 아래 절차대로 실행된다.
// 1. saveDb()
// 2. sendEmail()
// 3. getResult()
</code></pre>
<p>보다시피 콜백은 가장 기본적인 비동기 처리 방식이지만, 작업이 많아질수록 함수 안에 함수가 계속 중첩되어 코드가 복잡해진다. 이런 형태를 흔히 <strong>콜백 지옥(Callback Hell)</strong>이라고 부른다. 그래서 가독성과 에러 처리를 개선하기 위해 Promise가 등장했다.</p>
<p>&nbsp;</p>
<h2 id="👌-promise-객체-사용">👌 Promise 객체 사용</h2>
<p><strong>프로미스(Promise)</strong>는 비동기 작업의 최종 결과를 담는 객체를 말한다. 작업이 아직 끝나지 않았을 수도 있고, 성공했을 수도 있고, 실패했을 수도 있다. Promise는 처음 생성 시 대기 상태(PENDING)였다가 이행이 되면 <code>resolve()</code> 함수를 호출, 거절되면 <code>reject()</code> 함수가 호출된다.</p>
<p>&nbsp;</p>
<p>쉽게 비유를 하면, 현실에서 약속이란 의미는 미래의 어떤 것을 할 거라고 정하는 약속은 이행, 거절, 대기 3가지의 상태를 갖는다는 컨셉인 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/c3c52870-c7ee-4daa-b346-48d67d290e44/image.png" alt=""></p>
<p><em><strong>&lt;<code>then()</code> 메서드 체이닝 사용&gt;</strong></em></p>
<pre><code class="language-jsx">// 회원 DB
const database = [];

// 회원 정보
const user = {
  email: &#39;byeonguk@gmail.com&#39;,
  name: &#39;ByeongUk&#39;,
  password: &#39;1234&#39;,
};

function saveDb(user) {
  const oldDbSize = database.length;
  database.push(user);
  console.log(`Save ${user.name} in Database...`);

  return new Promise(
    // Promise는 처음 대기 상태 정보를 가짐
    // resolve, reject 함수를 가지고 있음
    (resolve, reject) =&gt; {
      // 콜백 대신 Promise 객체 반환
      if (database.length &gt; oldDbSize) {
        resolve(user); // 성공 시 유저 정보 반환
      } else {
        reject(new Error(&#39;Save Failed...&#39;));
      }
    },
  );
}

function sendEmail(user) {
  console.log(`Send Email to ${user.email}`);
  return new Promise((resolve) =&gt; resolve(user));
}

function getResult(user) {
  return new Promise((resolve, reject) =&gt; {
    resolve(`${user.name} is successfully registered...`);
  });
}

function registerByPromise(user) {
  // 비동기 호출이지만, 순서를 지켜서 실행
  const result = saveDb(user).then(sendEmail).then(getResult);

  // 아직 완료되지 않았으므로 지연(PENDING) 상태
  // 실행이 완료되지 않았는데 result를 출력해버림
  console.log(result);
  return result;
}

const result = registerByPromise(user);

// 결과가 Promise 객체이므로 then() 메서드에 함수를 넣어서 결과를 출력
result.then(console.log);

// Save ByeongUk in Database...
// Promise { &lt;pending&gt; } -&gt; 이 메시지 출력으로 Promise가 아직 실행 중임을 알 수 있음
// Send Email to byeonguk@gmail.com
// ByeongUk is successfully registered...
</code></pre>
<p>&nbsp;</p>
<p><em><strong>&lt;<code>Promise.all()</code> 사용&gt;</strong></em></p>
<pre><code class="language-jsx">const database = [];

const user = {
  email: &#39;byeonguk@gmail.com&#39;,
  name: &#39;ByeongUk&#39;,
  password: &#39;1234&#39;,
};

function saveDb(user) {
  const oldDbSize = database.length;
  database.push(user);
  console.log(`Save ${user.name} in Database...`);

  return new Promise(
    (resolve, reject) =&gt; {
      if (database.length &gt; oldDbSize) {
        resolve(user); 
      } else {
        reject(new Error(&#39;Save Failed...&#39;));
      }
    },
  );
}

function sendEmail(user) {
  console.log(`Send Email to ${user.email}`);
  return new Promise((resolve) =&gt; resolve(user));
}

function getResult(user) {
  return new Promise((resolve, reject) =&gt; {
    resolve(`${user.name} is successfully registered...`);
  });
}

function registerByPromise(user) {
  const result = saveDb(user).then(sendEmail).then(getResult);
  console.log(result);
  return result;
}

allResult = Promise.all([saveDb(user), sendEmail(user), getResult(user)]);
allResult.then(console.log);

// Save ByeongUk in Database...
// Send Email to byeonguk@gmail.com
// [
//   { email: &#39;byeonguk@gmail.com&#39;, name: &#39;ByeongUk&#39;, password: &#39;1234&#39; },
//   { email: &#39;byeonguk@gmail.com&#39;, name: &#39;ByeongUk&#39;, password: &#39;1234&#39; },
//   &#39;ByeongUk is successfully registered...&#39;
// ]

// 출력된 배열이 getResult()임
</code></pre>
<p>&nbsp;</p>
<p><em><strong>&lt;예외 처리 로직까지 추가한 코드&gt;</strong></em></p>
<pre><code class="language-jsx">const database = [];

const user = {
  email: &#39;byeonguk@gmail.com&#39;,
  name: &#39;ByeongUk&#39;,
  password: &#39;1234&#39;,
};

function saveDb(user) {
  const oldDbSize = database.length + 1;
  database.push(user);
  console.log(`Save ${user.name} in Database...`);

  return new Promise((resolve, reject) =&gt; {
    if (database.length &gt; oldDbSize) {
      resolve(user);
    } else {
      reject(new Error(&#39;Save Failed...&#39;));
    }
  });
}

function sendEmail(user) {
  console.log(`Send Email to ${user.email}`);
  return new Promise((resolve) =&gt; resolve(user));
}

function getResult(user) {
  return new Promise((resolve, reject) =&gt; {
    resolve(`${user.name} is successfully registered...`);
  });
}

function registerByPromise(user) {
  const result = saveDb(user)
    .then(sendEmail)
    .then(getResult)
    .catch((error) =&gt; new Error(error))
    .finally(() =&gt; console.log(&#39;완료&#39;));

  console.log(result);
  return result;
}

const result = registerByPromise(user);
result.then(console.log);

// Save ByeongUk in Database...
// Promise { &lt;pending&gt; }
// 완료
// Error: Error: Save Failed...
//     at C:\Users\student\Desktop\node\chapter04\promiseCatch_test.js:40:23
//     at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
</code></pre>
<p>&nbsp;</p>
<p><em><strong>&lt;Promise 객체로 영화 Top 20 불러오기&gt;</strong></em></p>
<pre><code class="language-jsx">const axios = require(&#39;axios&#39;);
const url =
  &#39;https://raw.githubusercontent.com/wapj/musthavenodejs/main/movieinfo.json&#39;;

axios
  .get(url)
  .then((result) =&gt; {
    if (result.status != 200) {
      throw new Error(&#39;요청 정보를 가져오는데 실패했습니다.&#39;);
    }

    if (result.data) {
      return result.data;
    }

    throw new Error(&#39;전송 받은 데이터가 없습니다...&#39;);
  })
  .then((data) =&gt; {
    if (!data.articleList &amp;&amp; data.articleList.size == 0) {
      throw new Error(&#39;데이터가 없습니다.&#39;);
    }
    return data.articleList;
  })
  .then((articles) =&gt; {
    return articles.map((article, idx) =&gt; {
      return { title: article.title, rank: idx + 1 };
    });
  })
  .then((results) =&gt; {
    for (let movieInfo of results) {
      console.log(`${movieInfo.rank}위 - ${movieInfo.title}`);
    }
  })
  .catch((err) =&gt; {
    console.log(&#39;에러 발생!&#39;);
    console.error(err);
  });

// 1위 - 처음부터 잘했으면 얼마나 좋니
// 2위 - &lt;본즈 앤 올&gt; 궁지로 내몰린 10대를 보는 시선
// 3위 - 경이로운 생生의 의지로 창조해낸 ‘페르시아어’
// 4위 - 뻔하지 않은 사랑 영화
// 5위 - 우린 아무것도 모른다, 틀렸다는것만 증명할 뿐…에올
// 6위 - [영화리뷰] &lt;더 메뉴&gt;를 보고
// 7위 - 진실한 삶의 태도를 제시하다
// 8위 - 중요한 건 꺾이지 않는 마음
// 9위 - 치즈버거 세트의 행복
// 10위 - 즐기거나 놀리거나,
// 11위 - [영화감상]오늘 밤, 세계에서 이 사랑이 사라진다 해도
// 12위 - 아들을 구하고 싶다면 달려라
// 13위 - 인생은 아름다워(2022)
// 14위 - 영화 &lt;사도&gt;, 죽음의 문턱에 와서야 닿는 마음에 대해
// 15위 - 스타워즈: 안도르
// 16위 - 죽음의 문턱에서 거짓말로 살아남은 자의 고백
// 17위 - 닫힌 마음 - 영화 &#39;체리향기&#39;
// 18위 - &lt;오늘 밤, 세계에서 이 사랑이 사라진다 해도&gt; 리뷰
// 19위 - &lt;더 메뉴&gt; 180만 원짜리 먹으러 와서 사레 걸린기분
// 20위 - 닭장을 나온 백호
</code></pre>
<p>&nbsp;</p>
<h2 id="🦾-asyncawait-사용">🦾 Async/Await 사용</h2>
<p>Async/Await는 Promise를 더 쉽게 읽고 쓸 수 있도록 만든 문법이다. 겉보기에는 동기 코드처럼 보이지만, 실제로는 Promise 기반으로 동작한다.</p>
<pre><code class="language-jsx">async function myName() {
  // async는 Promise 객체를 반환
  return &#39;ByeongUk&#39;;
}

console.log(myName());
// Promise { &#39;ByeongUk&#39; }</code></pre>
<p>&nbsp;</p>
<p>함수 앞에 <code>async</code>를 붙이면 그 함수는 항상 반환값을 <code>Promise</code>로 감싸서 반환한다. <code>await</code>는 Promise 객체의 실행이 성공 또는 실패로 완료되기를 기다린다. 성공하면 그 결과값을 꺼내온다.</p>
<pre><code class="language-jsx">async function myName() {
  // async는 Promise 객체를 반환
  return &#39;ByeongUk&#39;;
}

// 이름을 출력하는 함수
async function showName() {
  const name = await myName();
  console.log(name);
}

console.log(showName());

// Promise { &lt;pending&gt; } &lt;- console.log(showName())의 결괏값임
// ByeongUk</code></pre>
<p>&nbsp;</p>
<p>여기서 <code>await myName()</code>은 <code>myName()</code>이 반환한 Promise가 끝날 때까지 기다린 뒤(<code>Promise { &lt;pending&gt; }</code>), 실제 결과인 <em>&#39;ByeongUk&#39;</em> 을 <code>name</code>에 넣어주는 것이다. 그리고 <code>showName()</code> 함수가 종료된다.</p>
<pre><code class="language-jsx">// 1초 대기하고 전달 받은 메시지 출력
function waitOneSecond(message) {
  return new Promise((resolve, _) =&gt; {
    setTimeout(() =&gt; {
      resolve(`전달 받은 메시지: ${message}`);
    }, 1000);
  });
}

// 10초 동안 1초마다 숫자 메시지를 출력
// Promise 객체를 처리해야 하기 때문에 async를 붙임
async function countOneToTen() {
  for (let number of [...Array(10).keys()]) {
    let result = await waitOneSecond(`${number + 1}초 대기 중...`);
    console.log(result);
  }

  console.log(&#39;실행 완료!&#39;);
}

countOneToTen();

// 전달 받은 메시지: 1초 대기 중...
// 전달 받은 메시지: 2초 대기 중...
// 전달 받은 메시지: 3초 대기 중...
// 전달 받은 메시지: 4초 대기 중...
// 전달 받은 메시지: 5초 대기 중...
// 전달 받은 메시지: 6초 대기 중...
// 전달 받은 메시지: 7초 대기 중...
// 전달 받은 메시지: 8초 대기 중...
// 전달 받은 메시지: 9초 대기 중...
// 전달 받은 메시지: 10초 대기 중...
// 실행 완료!</code></pre>
<p>위 코드는 반복문 안에서 <code>await</code>를 사용하고 있기 때문에 각 작업이 순차적으로 1초씩 기다린 뒤 실행되는 것을 볼 수 있다.</p>
<p>&nbsp;</p>
<p><em><strong>&lt;영화 Top 20 불러오는 코드 Async/Await를 사용해서 다시 작성&gt;</strong></em></p>
<pre><code class="language-jsx">const axios = require(&#39;axios&#39;);

async function getTop20Movies() {
  // await를 사용하여 동기화 시키기 위해 async 붙임
  const url =
    &#39;https://raw.githubusercontent.com/wapj/musthavenodejs/main/movieinfo.json&#39;;

  // http 네트워크에서 데이터를 받아 오기 때문에 await로 기다려야 함
  try {
    const result = await axios.get(url);
    const { data } = result;

    if (!data.articleList || data.articleList.size == 0) {
      throw new Error(&#39;데이터가 없습니다.&#39;);
    }

    const movieInfos = data.articleList.map((article, index) =&gt; {
      return { title: article.title, rank: index + 1 };
    });

    // 데이터 영화 정보 출력
    for (let movieInfo of movieInfos) {
      console.log(`[${movieInfo.rank}위] - ${movieInfo.title}`);
    }
  } catch (error) {
    throw new Error(error);
  }
}

getTop20Movies();
</code></pre>
<p>&nbsp;</p>
<h1 id="💥-node에서-꼭-알아야-하는-비동기-실행-구조">💥 Node에서 꼭 알아야 하는 비동기 실행 구조</h1>
<p>Node는 서버 환경에서의 자바스크립트 런타임이고, 자바스크립트는 기본적으로 싱글 스레드 방식으로 동작한다. 근데 다양한 작업이나 오래 걸리는 작업은 어떻게 처리하는지가 의문이었는데, 그 답은 바로 <strong>이벤트 루프 기반 실행 구조</strong>에 있었다. </p>
<p>&nbsp;</p>
<p>일단 간단하게 Node의 비동기 실행 구조를 이해하자면…</p>
<ol>
<li><p>자바스크립트 코드가 콜 스택(Call Stack)에서 실행된다.</p>
</li>
<li><p>오래 걸리는 작업은 Node 런타임(libuv, Node API) 쪽에 맡긴다.</p>
</li>
<li><p>그 작업이 끝나면, 실행해야 할 콜백 함수가 콜백 큐에 들어간다.</p>
</li>
<li><p>이벤트 루프가 콜 스택이 비었는지 확인한다.</p>
</li>
<li><p>비어 있으면 큐에서 콜백을 꺼내 다시 콜 스택에 넣어 실행한다.</p>
</li>
</ol>
<p>&nbsp;</p>
<p>그럼 먼저 콜 스택(Call Stack)에 대해 알아보자. 콜 스택은 동기 함수 호출 정보가 쌓이는 구조다. 다시 말해, 지금 실행 중인 함수들이 차곡차곡 쌓이는 공간이라는 소리다. 예를 들어, 아래와 같은 간단한 코드가 있으면…</p>
<pre><code class="language-jsx">console.log(&#39;첫 번째 작업&#39;);
console.log(&#39;두 번째 작업&#39;);
console.log(&#39;세 번째 작업&#39;);</code></pre>
<p>전부 동기 코드라서 한 줄이 끝나야 다음 줄로 간다. 따라서 실행 순서는 무조건 위에서 아래로 간다. </p>
<p>&nbsp;</p>
<p>자바스크립트 엔진은 코드 실행 자체를 담당하기는 하지만, 오래 걸리는 작업을 직접 다 처리하지는 않고 <strong>Node API</strong>와 <strong>libuv</strong> 쪽으로 넘긴다. 이 녀석들이 비동기 함수를 관리한다. 이해를 위해 동기 함수와 비동기 함수가 섞여 있는 아래 코드를 살펴보자.</p>
<pre><code class="language-jsx">console.log(&#39;첫 번째 작업&#39;);
setTimeout(() =&gt; {
    console.log(&#39;두 번째 작업&#39;);
}, 3000);
console.log(&#39;세 번째 작업&#39;);</code></pre>
<p>&nbsp;</p>
<p>먼저 <em>‘첫 번째 작업’</em> 이 콜 스택에 들어가고 출력될 것이다. 그 다음 <code>setTimeout()</code> 함수도 호출 자체는 콜 스택에서 들어가는데, 내부 콜백 함수는 Node API, libuv에 등록되는 것이다. 그래서 3초가 지나면 그 콜백이 바로 실행되는 것이 아니라 콜백 큐로 이동한다. 이후에 이벤트 루프가 <em>‘세 번째 작업’</em> 까지 출력이 끝나고 콜 스택이 비었을 때 콜백 큐에 있는 콜백 함수를 실행하는 것이다. 아래 그림을 보면 이해가 빠를 것이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/cac50771-5f8a-4e2e-a478-6fefafe6aa5a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fcade0bd-df4c-4914-9b7f-6ed77668cbcd/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8a7eefa6-2cae-4f88-b975-a04526415014/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/453e2e0b-3452-403c-a253-11d6698d1b2c/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/1c771322-5101-44c6-aa76-0c89fd6e02ac/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/68ce2db3-18a3-49e3-bcb0-4b2abd0deec2/image.png" alt=""></p>
<p>이 모든 흐름을 이어주는 녀석이 바로 이벤트 루프인 것이다. 이벤트 루프가 하는 가장 중요한 일은 딱 하나라고 생각하면 된다. </p>
<blockquote>
<p><strong><em>“콜 스택이 비었는지 계속 확인하고, 비어 있으면 콜백 큐에서 콜백 함수를 꺼내서 콜 스택에 넣는다.”</em></strong></p>
</blockquote>
<p>즉, 이벤트 루프가 직접 함수를 실행한다기보다 실행 순서를 조율하는 관리자에 가깝다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DOM이란?]]></title>
            <link>https://velog.io/@rocker_nun/DOM%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@rocker_nun/DOM%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Thu, 12 Mar 2026 02:19:33 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-dom의-정의와-필요성">🤔 DOM의 정의와 필요성</h1>
<p>HTML의 목적은 오직 정보의 구조를 명시적으로 표현하는 것 뿐이다. 따라서 HTML만으로는 사용자와 상호작용하는 것은 불가능하다. 그래서 생겨난 것이 자바스크립트다. </p>
<p>하지만 가장 중요한 점을 짚고 넘어갈 필요가 있다. 바로 <strong><em>“자바스크립트는 HTML을 직접 수정할 수 있는가?”</em></strong> 이다. 결론부터 말하면 직접 수정할 수는 없고, 브라우저가 HTML을 해석해서 메모리에 올려 놓은 객체 구조를 조작하는 것이다. 이 객체 구조를 바로 <strong>DOM(Document Object Model)</strong>이라고 하는 것이다.</p>
<p>DOM은 브라우저가 HTML을 파싱해서 구성한 트리 형태의 객체 집합이다. 말로는 이해가 힘드니 예시를 들어보자.</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;title&gt;Document&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Hello World&lt;/h1&gt;
    &lt;ul&gt;
      &lt;li&gt;HTML&lt;/li&gt;
      &lt;li&gt;JavaScript&lt;/li&gt;
      &lt;li&gt;DOM&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p>위와 같은 HTML 파일이 있다고 했을 때, 브라우저는 아래와 같이 DOM 트리를 생성한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/fbcd5de6-dfea-43ee-96ed-9ba7e4d0d5b9/image.jpg" alt=""></p>
<p>위와 같이 구조화하는 과정을 자세히 뜯어보면,</p>
<ol>
<li><p>HTML 문자열을 토큰으로 분할한다. (ex. <code>&lt;h1&gt;</code>, <code>&lt;/h1&gt;</code>, <code>&lt;body&gt;</code>와 같은 단위로 자름)</p>
</li>
<li><p>토큰을 기반으로 노드라는 객체를 생성한다.</p>
</li>
<li><p>각 노드를 계층 구조의 트리로 연결한다.</p>
</li>
</ol>
<p>이렇게 생성된 DOM 트리를 통해 자바스크립트는 원하는 DOM 요소를 가져와서 이벤트 리스너를 등록하고 내용을 조작할 수 있는 것이다. 명심하자. 이 모든 과정은 HTML 파일을 수정하는 게 아니라, 브라우저 메모리 상의 DOM 객체를 실시간으로 조작하는 것이다.</p>
<p>&nbsp;</p>
<h1 id="🛠️-dom-구조와-조작-도구">🛠️ DOM 구조와 조작 도구</h1>
<p>이제 더 나아가보자. <em>“DOM이란 무엇이냐”</em> 라는 질문에 제대로 대답하기 위해서는 구조와 API로 나눠서 생각하는 과정이 필요하다. 이를 건물과 공구로 생각해보자.</p>
<ul>
<li><p>건물: HTML을 해석해서 만들어진 DOM 구조</p>
</li>
<li><p>공구: DOM을 조작하기 위한 API 함수들</p>
</li>
</ul>
<p>건물은 설계도에 따라 지어진 구조물이고, 공구는 이 건물을 유지보수, 리모델링하기 위한 도구라고 하자. 그렇다면 브라우저가 생성해낸 DOM 트리가 건물, 자바스크립트로 그 DOM을 다룰 때 사용하는 다양한 메서드들이 공구라고 생각하면 된다. 이 메서드(API)가 바로 브라우저에서 자바스크립트에게 제공하는 <strong>표준화된 조작 도구 모음집</strong>인 것이다.</p>
<p>&nbsp;</p>
<p>아래 예시를 보자.</p>
<pre><code class="language-html">&lt;body&gt;
    &lt;h1 id=&quot;title&quot;&gt;제목&lt;/h1&gt;
    &lt;button&gt;제목 바꾸기 버튼&lt;/button&gt;
&lt;/body&gt;</code></pre>
<p>일단 브라우저가 위 HTML 파일을 파싱해서 DOM 트리로 변환할 것이다. 여기까지가 구조인 것이다. 이 시점에 이 구조만 가지고는 아무 것도 할 수 없다. 당연하다. 아무 것도 시키지 않았으니까…</p>
<p>&nbsp;</p>
<p>이제 버튼을 누르면 제목을 바꾸기 위해 DOM 구조에 접근해서 조작해야 한다. 이때 사용하는 것이 바로 공구, DOM API라는 것이다. 아래 자바스크립트 코드를 보자.</p>
<pre><code class="language-jsx">const h1 = document.getElementById(&#39;title&#39;);
const button = document.querySelector(&#39;button&#39;);

button.addEventListener(&quot;click&quot;, () =&gt; {
    h1.textContent = &#39;바뀐 제목입니다...&#39;;
});</code></pre>
<p><code>getElementById(&#39;title&#39;),</code> <code>querySelector(&#39;button&#39;)</code>로 아이디가 title인 요소와 button 태그인 요소를 DOM 트리에서 가져온다. 그리고 이벤트 리스너를 등록하고 <code>h1.textContent = ...</code>를 통해 노드의 텍스트 콘텐츠를 변경하는 것이다. 이와 같이 DOM 요소를 가져오고 조작하는 방법이 바로 <strong>DOM API</strong>라는 것이다.</p>
<p>이렇게 구조와 API로 분리해서 이해하는 것이 중요한 이유는 모든 DOM과 관련된 기능들이 모두 DOM 구조 위에서 DOM API를 사용해서 일어나는 일이기 때문이다. 구조를 모르면 조작 대상이 없고, API를 모르면 조작 대상을 조작할 수 없는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CSS]]></title>
            <link>https://velog.io/@rocker_nun/CSS</link>
            <guid>https://velog.io/@rocker_nun/CSS</guid>
            <pubDate>Mon, 09 Mar 2026 14:05:23 GMT</pubDate>
            <description><![CDATA[<h1 id="🎨-stylesheet">🎨 Stylesheet</h1>
<p>HTML에 스타일을 입히고 싶으면 CSS로 스타일시트를 작성해서 해당 HTML에 적용하면 된다. 스타일시트 구성은 아래와 같다.</p>
<pre><code class="language-css">h1(선택자) { color(스타일 속성) : red(스타일 값); }</code></pre>
<p>제일 먼저 스타일을 적용할 대상을 설정해야 하는데, 이때 <strong>선택자(Selector)</strong>를 이용하면 된다. </p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;ko&quot;&gt;
  &lt;head&gt;
    &lt;title&gt;TITLE&lt;/title&gt;
  &lt;/head&gt;
  &lt;style&gt;
    h1 {
      color: white;
      background: black;
    }
  &lt;/style&gt;
  &lt;body&gt;
    &lt;h1&gt;Hello World..!&lt;/h1&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>
<p><code>&lt;style&gt;</code> 태그 안에 스타일시트를 작성해서 HTML에 배치하면 되는데 어디에 배치하든 상관없지만, 보통 <code>&lt;head&gt;</code> 태그 안에 두는 것이 관례이다. 그리고 원활한 작업을 위해 스타일시트를 변경할 때마다 실제 적용된 스타일을 보는 습관을 가지도록 하자.</p>
<p>&nbsp;</p>
<h1 id="🔍-css3-선택자-종류">🔍 CSS3 선택자 종류</h1>
<ul>
<li><p>전체 선택자(<code>*</code>): HTML 페이지 내부 태그 모두를 선택한다. 보통 모든 요소에 공통으로 입히고 싶은 초기 스타일이 있을 경우 사용하면 좋을 듯하다.</p>
</li>
<li><p>태그 선택자(<code>태그</code>): 특정 태그들을 모두 선택한다.</p>
</li>
<li><p>아이디 선택자(<code>#아이디</code>): 특정 아이디를 가진 태그를 선택한다. 아이디는 웹 페이지 내부에서 중복되면 안 되기 때문에 특정 태그 하나에 스타일을 입히고 싶을 때 사용하자.</p>
</li>
<li><p>클래스 선택자(<code>.클래스</code>): 가장 유용하고, 가장 많이 사용되는 선택자다. 특정 클래스가 붙어 있는 모든 태그들에 적용된다.</p>
</li>
<li><p>속성 선택자(<code>선택자[속성]</code>, <code>선택자[속성=값]</code>): 특정한 속성을 가진 태그나 그 속성이 특정 값을 가지고 있는 태그들을 선택할 수 있다.</p>
</li>
<li><p>자손 선택자(<code>선택자A &gt; 선택자B</code>): 지정한 태그를 기준으로 바로 아래 단계에 위치한 태그들에 적용된다. 여기서 기준이 중요하다. 기준이 누구냐에 따라 해석이 달라질 수 있다.</p>
</li>
<li><p>후손 선택자(<code>선택자A 선택자B</code>): 지정한 태그 아래에 있는 모든 태그들에 적용된다.</p>
</li>
<li><p>반응 선택자: 사용자의 실제 반응으로 생성되는 특정한 상태를 선택한다. 마우스로 클릭한다든지(<code>active</code>), 마우스를 올려 놓는다든지(<code>hover</code>), 여러가지가 있을 수 있다.</p>
</li>
<li><p>상태 선택자: 입력 양식의 상태를 선택할 수도 있다.</p>
</li>
</ul>
<p>&nbsp;</p>
<h1 id="🧩-css-배치-속성">🧩 CSS 배치 속성</h1>
<p>CSS 배치 속성은 HTML 요소를 화면에 어떻게 보여주고, 어떤 방식으로 배치할지를 결정하는 속성이다. 대표적으로 알아야 할 개념이 <code>display</code> 속성인데, 이 속성은 요소를 Block으로 배치할지, Inline으로 배치할지 결정한다.</p>
<ul>
<li><strong>Block 요소</strong> : 한 줄을 통째로 독차지하는 형태로 배치된다. 즉, 요소 하나가 배치되면 자동으로 줄바꿈이 일어나고, 다음 요소는 그 아래 줄에 위치하게 된다. 대표적으로 <code>div</code>, <code>p</code>, <code>h1</code> 같은 태그가 이에 해당된다.</li>
<li><strong>Inline 요소</strong>: 줄바꿈 없이 한 줄 안에서 물 흐르듯이 배치된다. 마치 글자처럼 옆으로 이어지기 때문에, 여러 요소가 같은 줄에 나란히 놓일 수 있다. 대표적으로 <code>span</code>, <code>a</code> 태그가 있다.</li>
</ul>
<p>블록과 인라인 개념이 왜 필요한가? 웹 페이지에서 요소를 원하는 위치에 배치하려면, 각 태그가 어떤 방식으로 공간을 차지하는지 알아야 한다. 즉, block과 inline의 차이를 이해해야 화면을 더 보기 좋고 의도한 형태로 구성할 수 있다.</p>
<p>CSS에서 모든 HTML 요소는 기본적으로 하나의 박스(Box)로 생각할 수 있다. 이를 박스 모델이라고 하는데, 아래와 같은 구조로 이루어진다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2cb5b304-b628-4d9f-a041-386a4cad4ad3/image.webp" alt=""></p>
<ul>
<li><strong>content(요소 내용)</strong> : 실제 텍스트나 이미지가 들어가는 영역</li>
<li><strong>padding</strong> : 내용과 테두리 사이의 여백</li>
<li><strong>border(테두리)</strong> : 요소의 경계선</li>
<li><strong>margin</strong> : 요소 바깥쪽의 여백</li>
</ul>
<p>이처럼 HTML 요소를 배치할 때는 단순히 내용만 보는 것이 아니라 내용 테두리 기준, 안쪽 영역, 바깥쪽 영역까지 함께 고려해야 한다.</p>
<p>&nbsp;</p>
<h1 id="🔧-css-레이아웃">🔧 CSS 레이아웃</h1>
<p>레이아웃이란 이미지, 텍스트 같은 요소들을 화면에서 원하는 위치에 배치하는 것을 의미한다. 웹 페이지를 만들 때는 요소를 단순히 나열하는 것이 아니라, 보기 좋고 사용하기 편하도록 정렬하는 과정이 필요하다. 이때 많이 사용하는 개념이 <strong>Flexbox</strong>와 <strong>position 속성</strong>이다.</p>
<h2 id="🎏-flexbox란">🎏 Flexbox란?</h2>
<p>요소들을 한 줄 또는 한 칸에 딱딱하게 배치하는 것이 아니라, 공간에 맞게 유연하게 정렬할 수 있도록 도와준다. Flexbox를 사용할 때 주의해야 할 점은 <strong>부모 요소에 적용해야 한다는 것</strong>이다. 자식 요소를 정렬하고 싶다면 먼저 부모와 자식의 관계를 먼저 파악한 뒤에 부모에게 <code>display: flex</code>를 적용해야 자식 요소들이 그 영향을 받아서 정렬된다.</p>
<p><code>display: flex</code>를 적용하면 자식 요소들을 원하는 방향으로 유연하게 배치할 수 있다. 이때 자주 사용하는 속성은 아래와 같다.</p>
<ul>
<li><strong>justify-content</strong> : 주축(가로) 방향으로 정렬</li>
<li><strong>align-items</strong> : 교차축(세로) 방향으로 정렬</li>
<li><strong>flex-direction</strong> : 축의 방향 변경</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/83ad3c56-f5d3-48e2-a534-1f1bd582be44/image.png" alt=""></p>
<p>플렉스박스는 <strong>주축(main-axis)</strong>과 <strong>교차축(cross axis)</strong>을 기준으로 요소를 움직인다. 기본적으로 주축은 가로 방향이고, 교차축은 세로 방향이다. 원한다면 <code>flex-direction</code> 속성을 사용해서 요소들이 정렬되는 방향을 바꿀 수도 있다. 축의 기본값은 <code>row</code>이며 세로 방향으로 축을 바꾸고 싶다면 <code>column</code>으로 설정하면 된다. <code>row-reverse</code> 등으로 역순 배치 또한 가능하다. </p>
<p>&nbsp;</p>
<p>주로 사용하는 속성은 <code>justify-content</code>와 <code>align-items</code>가 있다. 하나씩 살펴보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/eadb20dc-fc2c-42c8-982c-f3a7665e0c36/image.png" alt=""></p>
<ul>
<li><code>justify-content</code>: flex item들의 주축 상 위치/여백을 결정한다.<ul>
<li><code>flex-start</code>: 주축의 시작 지점부터 flex item이 시작된다.</li>
<li><code>center</code>: flex item이 주축 중앙으로 정렬된다.</li>
<li><code>flex-end</code>: flex item이 주축 끝 지점부터 정렬된다.</li>
<li><code>space-around</code>: flex item이 동일한 여백으로 정렬된다. 이때 flex container의 시작과 끝 지점에는 flex item 사이 여백의 1/2에 해당하는 여백이 들어간다.</li>
<li><code>space-between</code>: flex item이 동일한 여백으로 정렬된다. 하지만 <code>space-around</code>와는 다르게 flex container의 시작과 끝에는 여백이 존재하지 않는다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/7dcfb4f0-99b8-4c0e-8424-f362758f5e49/image.png" alt=""></p>
<ul>
<li><code>align-items</code>: flex item들의 교차 축 상 위치/여백을 결정한다. 설정값은 바라보는 축만 다를 뿐, <code>justify-content</code>와 동일하다. <code>stretch</code>만 flex item의 높이를 교차 축의 길이와 동일하게 세팅한다는 것만 알아두자.</li>
</ul>
<hr />

<p><em><strong>&lt;참고 자료&gt;</strong></em>
<a href="https://developer.mozilla.org/ko/docs/Learn_web_development/Core/CSS_layout/Flexbox">Flexbox - Web 개발 학습하기 | MDN</a>
<a href="https://armadillo-dev.github.io/css/what-is-flexbox/">[CSS] Flexbox 이해하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[스프링 JdbcTemplate]]></title>
            <link>https://velog.io/@rocker_nun/%EC%8A%A4%ED%94%84%EB%A7%81-JdbcTemplate</link>
            <guid>https://velog.io/@rocker_nun/%EC%8A%A4%ED%94%84%EB%A7%81-JdbcTemplate</guid>
            <pubDate>Sat, 21 Feb 2026 13:13:04 GMT</pubDate>
            <description><![CDATA[<h1 id="🆚-sql-mapper-vs-orm">🆚 SQL Mapper vs ORM</h1>
<p>데이터 접근 기술에는 아주 다양한 것들이 존재한다. 이를 크게 나누면 <code>SQL Mapper</code>, <code>ORM</code>으로 나뉘는데, <code>SQL Mapper</code>는 <strong>개발자가 SQL만 작성하면 해당 SQL의 결과를 객체로 편리하게 매핑해주는 기술</strong>로, JDBC를 직접 사용할 때 발생하는 여러가지 중복을 제거하고 기타 편리한 기능을 제공해준다. <strong>반면, ORM은 SQL을 대신 작성하고 처리해준다.</strong> JPA로 예를 들면, 개발자는 저장하고 싶은 객체를 마치 자바 컬렉션에 저장하고 조회하듯이 사용하면 ORM이 알아서 DB에 해당 객체를 저장하고 조회해준다. </p>
<p>이 중에서 <code>SQL Mapper</code>와 비슷한 <code>JdbcTemplate</code>에 대해 먼저 알아보자. <code>JdbcTemplate</code>은 템플릿 콜백 패턴을 사용해서 JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 대신 처리해준다. 개발자는 그저 SQL을 작성하고, 전달할 파라미터를 정의하고, 응답값을 매핑해주기만 하면 된다. 이제 실제 <code>JdbcTemplate</code>를 적용한 코드를 살펴보자.</p>
<p>&nbsp;</p>
<h1 id="🛠-jdbctemplate-적용">🛠 JdbcTemplate 적용</h1>
<pre><code class="language-java">package hello.itemservice.repository.jdbctemplate;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.util.StringUtils;

@Slf4j
public class JdbcTemplateItemRepositoryV1 implements ItemRepository {

    private final JdbcTemplate jdbcTemplate;

    public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Item save(Item item) {
        String sql = &quot;INSERT INTO item (item_name, price, quantity) VALUES (?, ?, ?)&quot;;
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -&gt; {
            // 자동 증가 키
            PreparedStatement ps = connection.prepareStatement(sql, new String[]{&quot;id&quot;});
            ps.setString(1, item.getItemName());
            ps.setInt(2, item.getPrice());
            ps.setInt(3, item.getQuantity());
            return ps;
        }, keyHolder);

        long key = keyHolder.getKey().longValue();
        item.setId(key);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        String sql = &quot;UDPATE item SET item_name = ?, price = ?, quantity = ? WHERE id = ?&quot;;
        jdbcTemplate.update(
                sql,
                updateParam.getItemName(),
                updateParam.getPrice(),
                updateParam.getQuantity(),
                itemId
        );
    }

    @Override
    public Optional&lt;Item&gt; findById(Long itemId) {
        String sql = &quot;SELECT * FROM item WHERE id = ?&quot;;
        try {
            Item item = jdbcTemplate.queryForObject(sql, itemRowMapper(), itemId);
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    @Override
    public List&lt;Item&gt; findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        String sql = &quot;SELECT * FROM item&quot;;
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += &quot; WHERE&quot;;
        }

        boolean andFlag = false;
        List&lt;Object&gt; param = new ArrayList&lt;&gt;();
        if (StringUtils.hasText(itemName)) {
            sql += &quot; item_name LIKE CONCAT(&#39;%&#39;, ?, &#39;%&#39;)&quot;;
            param.add(itemName);
            andFlag = true;
        }

        if (maxPrice != null) {
            if (andFlag) {
                sql += &quot; AND&quot;;
            }
            sql += &quot; price &lt;= ?&quot;;
            param.add(maxPrice);
        }

        log.info(&quot;sql={}&quot;, sql);
        return jdbcTemplate.query(sql, itemRowMapper(), param.toArray());
    }

    private RowMapper&lt;Item&gt; itemRowMapper() {
        return (rs, rowNum) -&gt; {
            Item item = new Item();
            item.setId(rs.getLong(&quot;id&quot;));
            item.setItemName(rs.getString(&quot;item_name&quot;));
            item.setPrice(rs.getInt(&quot;price&quot;));
            item.setQuantity(rs.getInt(&quot;quantity&quot;));
            return item;
        };
    }
}
</code></pre>
<p><code>JdbcTemplate</code>은 데이터소스가 필요하기 때문에 데이터소스를 주입 받고 생성자 내부에서 <code>JdbcTemplate</code>을 생성한다. 스프링에서 관례상 이 방법을 많이 사용하고는 한다. 메서드를 하나씩 살펴보자.</p>
<p>&nbsp;</p>
<ul>
<li><p><code>save()</code>: 데이터 저장</p>
<ul>
<li><code>jdbcTemplate.update()</code>: 데이터를 변경할 때는 <code>update()</code> 메서드를 사용하면 된다. 이 메서드의 반환값은 영향 받은 ROW 수를 정수형으로 반환한다.</li>
<li>데이터를 저장할 때는 PK 생성에 <code>AUTO INCREMENT</code> 방식을 사용하기 때문에, 개발자가 직접 PK를 지정하지 않고 비워두고 저장하면 된다. DB가 PK인 ID를 대신 생성해줄 것이다.</li>
<li>DB에 INSERT가 완료된 데이터의 ID 값을 <code>KeyHolder</code>와 <code>connection.prepareStatement(sql, new String[]{&quot;id&quot;})</code>를 사용해서 <code>id</code>를 지정해줌으로써 가져올 수 있다.</li>
</ul>
</li>
<li><p><code>update()</code>: 데이터를 업데이트</p>
<ul>
<li><code>jdbcTemplate.update()</code>: 데이터를 변경할 때도 <code>update()</code> 메서드를 사용하면 된다. 그냥 <code>?</code>에 바인딩할 파라미터를 순서대로 전달하면 된다. 이 녀석의 반환값 역시 해당 쿼리로 영향을 받은 ROW이지만, 여기서는 <code>WHERE id = ?</code>이라고 지정해줬기 때문에 하나의 ROW만 반환할 것이다.</li>
</ul>
</li>
<li><p><code>findById()</code>: 데이터를 하나 조회</p>
<ul>
<li><code>jdbcTemplate.queryForObject()</code>: 결과 ROW가 하나일 경우에만 사용한다. 여기서 <code>RowMapper</code>가 반환 결과인 <code>ResultSet</code>을 객체로 변환하고, 결과가 없으면 <code>EmptyResultDataAccessException</code> 예외를, 결과가 둘 이상이면 <code>IncorrectResultSizeDataAccessException</code> 예외를 터뜨린다.</li>
<li>따라서 <code>ItemRepository.findById()</code> 에서 결과가 없을 때 <code>Optional</code>을 반환하도록 해서 결과가 없으면 예외를 잡아 <code>Optional.empty()</code>를 대신 반환하도록 한 것이다.</li>
</ul>
</li>
</ul>
<pre><code>    ```java
    // queryForObject() 인터페이스 정의
    @Nullable
    public &lt;T&gt; T queryForObject(String sql, RowMapper&lt;T&gt; rowMapper, @Nullable Object... args) throws DataAccessException {
        List&lt;T&gt; results = (List)this.query((String)sql, (Object[])args, (ResultSetExtractor)(new RowMapperResultSetExtractor(rowMapper, 1)));
        return DataAccessUtils.nullableSingleResult(results);
    }
    ```</code></pre><ul>
<li><p><code>findAll()</code>: 데이터를 리스트로 조회</p>
<ul>
<li><p><code>jdbcTemplate.query()</code>: 결과가 하나 이상일 때 사용한다. 마찬가지로 <code>RowMapper</code>는 반환 결과인 <code>ResultSet</code>을 객체로 변환한다. 결과가 없으면 빈 컬렉션을 반환한다.</p>
<pre><code class="language-java">public &lt;T&gt; List&lt;T&gt; query(String sql, RowMapper&lt;T&gt; rowMapper, @Nullable Object... args) throws DataAccessException {
  return (List)result(this.query((String)sql, (Object[])args, (ResultSetExtractor)(new RowMapperResultSetExtractor(rowMapper))));
}</code></pre>
</li>
</ul>
</li>
</ul>
<ul>
<li><code>itemRowMapper()</code>: DB의 조회 결과를 객체로 변환</li>
</ul>
<p>하지만 위 메서드들 중 <code>findAll()</code>에서 난감한 부분이 있다. 바로 사용자가 검색하는 값에 따라서 실행하는 SQL이 동적으로 달려져야 한다는 것이다. 검색 조건이 없는 경우, 상품명으로 검색하는 경우, 최대 가격으로 검색하는 경우 등등 검색 조건은 굉장히 다양하기 때문에 깊은 고민이 필요하다.</p>
<p>&nbsp;</p>
<h1 id="📝namedparameterjdbctemplate">📝NamedParameterJdbcTemplate</h1>
<p><code>JdbcTemplate</code>을 기본으로 사용하면 아래와 같이 파라미터를 순서대로 바인딩 해줘야 한다. </p>
<pre><code class="language-java">@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    String sql = &quot;UDPATE item SET item_name = ?, price = ?, quantity = ?&quot;;
    jdbcTemplate.update(
            sql,
            updateParam.getItemName(),
            updateParam.getPrice(),
            updateParam.getQuantity(),
            itemId
    );
}</code></pre>
<p>하지만 사람은 실수를 하기 마련… <strong>실수로 파라미터를 순서에 어긋나게 전달했을 때 문제가 발생한다.</strong> 현업에서는 파라미터가 수십개가 될 수도 있기 때문에 수정하면서 이러한 문제가 충분히 발생할 수 있다. 버그 중에서 가장 고치기 힘든 버그는 DB에 데이터가 잘못 들어가는 버그다. 개발할 때는 코드를 몇줄 줄이는 편리함도 물론 중요하지만, 모호함을 제거해서 코드를 명확하게 만드는 것이 유지보수 관점에서 매우 중요하다. </p>
<p>따라서 이런 문제를 보완하기 위해 <code>NamedParameterJdbcTemplate</code>이라는 파라미터 순서가 아닌 파라미터 이름 자체로 바인딩하는 기능을 제공한다. 아래 코드를 보자.</p>
<pre><code class="language-java">@Slf4j
public class JdbcTemplateItemRepositoryV2 implements ItemRepository {

    private final NamedParameterJdbcTemplate jdbcTemplate;

    public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
        this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
    }

    @Override
    public Item save(Item item) {
        String sql = &quot;INSERT INTO item (item_name, price, quantity) VALUES (:itemName, :price, :quantity)&quot;;

        SqlParameterSource params = new BeanPropertySqlParameterSource(item);
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(sql, params, keyHolder);

        long key = keyHolder.getKey().longValue();
        item.setId(key);
        return item;

    }

    @Override
    public void update(Long id, ItemUpdateDto updateParam) {
        String sql = &quot;UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id&quot;;

        SqlParameterSource params = new MapSqlParameterSource()
                .addValue(&quot;itemName&quot;, updateParam.getItemName())
                .addValue(&quot;price&quot;, updateParam.getPrice())
                .addValue(&quot;quantity&quot;, updateParam.getQuantity())
                .addValue(&quot;id&quot;, id);

        jdbcTemplate.update(sql, params);
    }

    @Override
    public Optional&lt;Item&gt; findById(Long id) {
        String sql = &quot;SELECT * FROM item WHERE id = :id&quot;;
        try {
            Map&lt;String, Object&gt; params = Map.of(&quot;id&quot;, id);
            Item item = jdbcTemplate.queryForObject(sql, params, itemRowMapper());
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    @Override
    public List&lt;Item&gt; findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        SqlParameterSource param = new BeanPropertySqlParameterSource(cond);

        String sql = &quot;SELECT * FROM item&quot;;
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += &quot; WHERE&quot;;
        }

        boolean andFlag = false;
        if (StringUtils.hasText(itemName)) {
            sql += &quot; item_name LIKE CONCAT(&#39;%&#39;, :itemName, &#39;%&#39;)&quot;;
            andFlag = true;
        }

        if (maxPrice != null) {
            if (andFlag) {
                sql += &quot; AND&quot;;
            }
            sql += &quot; price &lt;= :maxPrice&quot;;
        }

        log.info(&quot;sql={}&quot;, sql);
        return jdbcTemplate.query(sql, param, itemRowMapper());
    }

    private RowMapper&lt;Item&gt; itemRowMapper() {
        return BeanPropertyRowMapper.newInstance(Item.class);
    }
}</code></pre>
<p>기존에 <code>?</code>로 순서대로 바인딩된 파라미터를 넘겨줬던 부분들이 <code>:파라미터이름</code>으로 정확하게 파라미터 이름을 받아 바인딩하고 있는 것을 확인할 수 있다. </p>
<p>&nbsp;</p>
<pre><code class="language-java">@Override
public void update(Long id, ItemUpdateDto updateParam) {
    String sql = &quot;UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id&quot;;

    SqlParameterSource params = new MapSqlParameterSource()
            .addValue(&quot;itemName&quot;, updateParam.getItemName())
            .addValue(&quot;price&quot;, updateParam.getPrice())
            .addValue(&quot;quantity&quot;, updateParam.getQuantity())
            .addValue(&quot;id&quot;, id);

    jdbcTemplate.update(sql, params);
}</code></pre>
<p>파라미터를 전달하기 위해서는 <code>Map</code>처럼 Key로 <code>:파라미터이름</code>으로 지정한 파라미터 이름을, Value에 해당 파라미터 값을 가진 데이터 구조를 만들어서 전달해야 한다. 이렇게 만든 파라미터(<code>params</code>)를 전달하는 것을 볼 수 있다. 물론 <code>update()</code> 메서드처럼 <code>MapSqlParameterSource</code>라는 구현체를 사용해도 된다. 얘는 <code>Map</code>과 유사한데, SQL 타입을 지정할 수 있는 등 SQL에 좀 더 특화된 기능을 제공한다. </p>
<p>&nbsp;</p>
<pre><code class="language-java">@Override
public Item save(Item item) {
    String sql = &quot;INSERT INTO item (item_name, price, quantity) VALUES (:itemName, :price, :quantity)&quot;;

    SqlParameterSource params = new BeanPropertySqlParameterSource(item);
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcTemplate.update(sql, params, keyHolder);

    long key = keyHolder.getKey().longValue();
    item.setId(key);
    return item;
}</code></pre>
<p>또한 위의 <code>save()</code> 메서드처럼 <code>BeanPropertySqlParameterSource</code> 구현체를 사용해도 된다. 얘는 자바빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다. 예를 들어, <code>getItemName()</code>, <code>getPrice()</code>가 있다고 하면 각각 <code>key=itemName, value=상품명 값</code>, <code>key=price, value=가격 값</code>과 같이 데이터를 자동으로 만들어낸다. 하지만 아쉽게도 <code>BeanPropertySqlParameterSource</code>는 항상 사용할 수는 없다.</p>
<pre><code class="language-java">package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {
    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
</code></pre>
<p> <code>update()</code> 메서드만 보더라도 SQL에 <code>:id</code>를 바인딩해야 하는데, <code>ItemUpdateDto</code>를 보면 <code>itemId</code>가 없다. 따라서 <code>BeanPropertySqlParameterSource</code> 대신에 <code>MapSqlParameterSource</code>를 사용해야 한다.</p>
<p> &nbsp;</p>
<p>그리고 <code>JdbcTemplateItemRepositoryV1</code>에서 <code>JdbcTemplateItemRepositoryV2</code>로 코드를 수정할 때 변화된 또 다른 부분이 있다. 바로 <code>BeanPropertyRowMapper</code>를 사용한 것이다.</p>
<pre><code class="language-java">// JdbcTemplateItemRepositoryV1
private RowMapper&lt;Item&gt; itemRowMapper() {
    return (rs, rowNum) -&gt; {
        Item item = new Item();
        item.setId(rs.getLong(&quot;id&quot;));
        item.setItemName(rs.getString(&quot;item_name&quot;));
        item.setPrice(rs.getInt(&quot;price&quot;));
        item.setQuantity(rs.getInt(&quot;quantity&quot;));
        return item;
    };
}

// JdbcTemplateItemRepositoryV2
private RowMapper&lt;Item&gt; itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class);
}</code></pre>
<p><code>BeanPropertyRowMapper</code>는 <code>ResultSet</code>의 결과를 받아서 자바빈 규약에 맞춰 데이터를 변환한다. 예를 들어, DB에서 조회한 결과가 <code>SELECT id, price</code>라고 한다면 아래와 같은 코드를 작성해준다.</p>
<pre><code class="language-java">Item item = new Item();
item.setId(rs.getLong(&quot;id&quot;)); 
item.setPrice(rs.getInt(&quot;price&quot;));</code></pre>
<p>DB에서 조회한 결과 이름을 바탕으로 <code>setId()</code>, <code>setPrice()</code>처럼 자바빈 프로퍼티 규약에 맞춘 메서드를 호출하는 것이다.</p>
<p>&nbsp;</p>
<h1 id="💉-simplejdbcinsert">💉 SimpleJdbcInsert</h1>
<p><code>JdbcTemplate</code>에서는 INSERT SQL 쿼리를 직접 작성하지 않도록 <code>SimpleJdbcInsert</code>라는 편리한 기능도 제공한다. 바로 아래 코드를 보자.</p>
<pre><code class="language-java">@Slf4j
public class JdbcTemplateItemRepositoryV3 implements ItemRepository {

    private final NamedParameterJdbcTemplate jdbcTemplate;
    private final SimpleJdbcInsert simpleJdbcInsert;

    public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
        this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
        this.simpleJdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName(&quot;item&quot;)
                .usingGeneratedKeyColumns(&quot;id&quot;);
    }

    @Override
    public Item save(Item item) {
        SqlParameterSource params = new BeanPropertySqlParameterSource(item);
        Number key = simpleJdbcInsert.executeAndReturnKey(params);

        item.setId(key.longValue());
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        String sql = &quot;UDPATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE item_id = :id&quot;;

        SqlParameterSource params = new MapSqlParameterSource()
                .addValue(&quot;itemName&quot;, updateParam.getItemName())
                .addValue(&quot;price&quot;, updateParam.getPrice())
                .addValue(&quot;quantity&quot;, updateParam.getQuantity())
                .addValue(&quot;itemId&quot;, itemId);

        jdbcTemplate.update(sql, params);
    }

    @Override
    public Optional&lt;Item&gt; findById(Long itemId) {
        String sql = &quot;SELECT * FROM item WHERE id = :id&quot;;
        try {
            Map&lt;String, Object&gt; params = Map.of(&quot;id&quot;, itemId);
            Item item = jdbcTemplate.queryForObject(sql, params, itemRowMapper());
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    @Override
    public List&lt;Item&gt; findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        SqlParameterSource param = new BeanPropertySqlParameterSource(cond);

        String sql = &quot;SELECT * FROM item&quot;;
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += &quot; WHERE&quot;;
        }

        boolean andFlag = false;
        if (StringUtils.hasText(itemName)) {
            sql += &quot; item_name LIKE CONCAT(&#39;%&#39;, :itemName, &#39;%&#39;)&quot;;
            andFlag = true;
        }

        if (maxPrice != null) {
            if (andFlag) {
                sql += &quot; AND&quot;;
            }
            sql += &quot; price &lt;= :maxPrice&quot;;
        }

        log.info(&quot;sql={}&quot;, sql);
        return jdbcTemplate.query(sql, param, itemRowMapper());
    }

    private RowMapper&lt;Item&gt; itemRowMapper() {
        return BeanPropertyRowMapper.newInstance(Item.class);
    }
}</code></pre>
<p>생성자를 보면 의존관계 주입은 <code>dataSource</code>를 받고 내부에서 <code>SimpleJdbcInsert</code>를 생성해서 가지고 있는 것을 볼 수 있다. <code>withTableName()</code>을 통해 데이터를 저장할 테이블명을 지정하고, <code>usingGeneratedKeyColumns()</code>를 통해 Key를 생성하는 PK 컬럼명을 지정한다. 경우에 따라 <code>usingColumns()</code>로 INSERT SQL 쿼리에 사용할 컬럼을 지정할 수도 있다. 보통 특정 값만 저장하고 싶을 때 사용한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트랜잭션이란?]]></title>
            <link>https://velog.io/@rocker_nun/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%B4%EB%9E%80</link>
            <guid>https://velog.io/@rocker_nun/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%B4%EB%9E%80</guid>
            <pubDate>Thu, 19 Feb 2026 02:35:35 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-트랜잭션이-뭐고-왜-필요함">🤔 트랜잭션이 뭐고, 왜 필요함?</h1>
<p>트랜잭션(Transaction)을 직역하면 <strong><em>“거래”</em></strong> 라는 뜻이다. DB에서의 트랜잭션은 <strong><em>“하나의 거래를 안전하게 처리하도록 보장해주는 것”</em></strong> 을 말한다.</p>
<p>트랜잭션을 설명할 때 등장하는 흔한 예시인 계좌 이체를 떠올리면 트랜잭션이 왜 필요한지 바로 체감된다. 계좌 이체라는 일종의 거래는 송금한 사람의 잔고가 줄어든 만큼 입금된 사람의 잔고가 늘어나야 한다. 이 2가지의 작업 중 하나라도 문제가 생긴다면 대참사가 일어날 것이다.</p>
<p>이때 데이터베이스가 제공하는 트랜잭션의 기능을 사용하면 2가지 작업이 모두 성공해야 저장(<code>Commit</code>)하고, 하나라도 문제가 생기면 거래 이전의 상태로 돌아가게(<code>Rollback</code>) 할 수 있다.</p>
<p>&nbsp;</p>
<h2 id="📝-트랜잭션-acid">📝 트랜잭션 ACID</h2>
<p>트랜잭션은 아래 4가지를 보장해야 한다.</p>
<ol>
<li><p><code>원자성(Atomicity)</code>: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 한다.</p>
</li>
<li><p><code>일관성(Consistency)</code>: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.</p>
</li>
<li><p><code>격리성(Isolation)</code>: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야 한다. </p>
</li>
<li><p><code>지속성(Durability)</code>: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록돼야 한다. </p>
</li>
</ol>
<p>&nbsp;</p>
<h1 id="🥓-데이터베이스-연결-구조와-db-세션">🥓 데이터베이스 연결 구조와 DB 세션</h1>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/d7ae636f-b2cf-4811-a759-5fb04419df5b/image.png" alt=""></p>
<p>클라이언트는 DB 서버에 연결을 요청하고 커넥션을 맺게 되면, DB 서버는 내부에 세션이라는 것을 만든다. 만약 커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 만들어진다. 하여간 이 DB 세션이라는 친구가 트랜잭션을 생성하는 것부터 시작해서 SQL 쿼리를 실행하고 커밋을 하든 롤백을 하든 하는 거다. 사용자가 커넥션을 닫아버리거나 세션을 강제로 종료하면 세션은 종료된다. </p>
<p>&nbsp;</p>
<h1 id="🏒-db로-트랜잭션-동작-확인해보기">🏒 DB로 트랜잭션 동작 확인해보기</h1>
<p>트랜잭션 사용법은 간단하다. 데이터 조작 쿼리문을 실행하고 그 결과를 최종 반영하고 싶으면 <code>커밋(Commit)</code>, 결과를 반영하고 싶지 않으면 <code>롤백(Rollback)</code>을 시전하면 된다. 커밋이나 롤백을 하지 않으면 임시로 데이터가 저장된다. 따라서 해당 트랜잭션을 시작한 세션에게만 변경 데이터가 보이고 다른 세션에게는 변경 데이터가 보이지 않는다. 만약 보인다면 데이터 정합성에 아주 큰 문제가 발생할 것이다. 아래 그림에서 커밋이나 롤백을 해야 다른 세션도 비로소 최종 처리된 데이터를 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/e9793384-4cc6-4079-b05e-336244a9772b/image.png" alt=""></p>
<p><strong><em>고민 사항)</em></strong> 보통의 데이터베이스들은 오토 커밋 모드로 설정되어 있는데, 만약 중간에 문제가 생기면 어떻게 될까? </p>
<p>&nbsp;</p>
<h2 id="🔒-db-락">🔒 DB 락</h2>
<p>DB에서는 원자성이 상당히 중요하다. 이 원자성을 충족하기 위해서는 <strong>하나의 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 다른 세션이 해당 데이터를 건들 수 없게 해야 한다.</strong> 이때 바로 <code>락(Lock)</code>이라는 개념이 제공된다.</p>
<p>메커니즘은 간단하다. <strong>해당 데이터를 수정하고 싶으면 그 데이터의 락을 누구보다 빠르게 획득</strong>하면 된다. 그렇지 않으면 해당 데이터의 락을 선점한 사람이 커밋 혹은 롤백할 때까지 기다려야 한다. 이때 무한정 기다리는 것은 아니고, 락 대기 시간(설정 가능)을 넘어가면 락 타임아웃 오류가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/163b7c28-5be7-44c9-8515-fa0a437b7565/image.png" alt=""></p>
<p>&nbsp;</p>
<h1 id="🧩-트랜잭션-적용">🧩 트랜잭션 적용</h1>
<p>그래서 트랜잭션을 어디에 걸어야 할까?</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/db428009-404c-4a9c-9224-5f156d8aba83/image.png" alt=""></p>
<p><strong>트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다.</strong> 왜냐하면 해당 비즈니스 로직이 잘못되어 결과가 이상하게 반영된다면 해당 부분을 함께 롤백해야 하기 때문이다. 이때 트랜잭션을 사용하려면 결국 커넥션도 서비스 계층에서 만들어주고 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 하지만 애플리케이션에서 DB 트랜잭션을 적용하려면 서비스 계층이 엄청 지저분해지고, 코드가 매우 복잡해진다. 스프링에게 도움을 요청할 순간이 온 것이다. </p>
<p>&nbsp;</p>
<h1 id="🧱-애플리케이션-구조">🧱 애플리케이션 구조</h1>
<p>애플리케이션 구조는 아래 그림이 국룰이다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b984f44f-da1a-4eed-bf77-399b47bbdf59/image.png" alt=""></p>
<p>여기서 <strong>가장 중요한 부분은 당연히 서비스 계층</strong>이다. <strong>비즈니스 로직은 최대한 고결해야 한다.</strong> 위와 같이 계층을 나눈 이유도 다 서비스 계층을 보호하기 위함이다. 프레젠테이션 계층이 클라이언트가 접근하는 UI와 관련된 부분을 담당해주고, 데이터 접근 계층이 데이터를 저장하고 관리하는 기술을 담당해줌으로써 서비스 계층을 이중으로 보호한다. </p>
<p>&nbsp;</p>
<pre><code class="language-java">package springdb.jdbc.service;

import java.sql.SQLException;
import lombok.RequiredArgsConstructor;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV1;

@RequiredArgsConstructor
public class MemberServiceV1 {

    private final MemberRepositoryV1 repository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        repository.update(toId, toMember.getMoney() + money);
    }
}</code></pre>
<p>기존 <code>MemberServiceV1</code> 코드를 보면 JDBC에 의존하고 있기는 하지만 나름 순수한 비즈니스 로직만 들어 있다. 향후 비즈니스 로직의 변경이 필요하면 이 부분만 변경하면 되는 것이다. 여기서 트랜잭션을 걸면 아래 <code>MemberServiceV2</code>로 수정할 수 있을 것 같다.</p>
<pre><code class="language-java">package springdb.jdbc.service;

import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV2;

@Slf4j
@RequiredArgsConstructor
public class MemberServiceV2 {

    private final DataSource dataSource;
    private final MemberRepositoryV2 repository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false);  // 트랜잭션 시작
            bizLogic(con, fromId, toId, money);
            con.commit();  // 성공 시 커밋
        } catch (Exception e) {
            con.rollback();  // 실패 시 롤백
            throw new IllegalStateException(e);
        } finally {
            releaseConnection(con);
        }
    }

    private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(con, fromId);
        Member toMember = repository.findById(con, toId);

        repository.update(con, fromId, fromMember.getMoney() - money);
        repository.update(con, toId, toMember.getMoney() + money);
    }

    private static void releaseConnection(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true);  // 커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info(&quot;error&quot;, e);
            }
        }
    }
}</code></pre>
<p>근데 여전히 JDBC에 의존적으로 코드를 작성해야 한다. 트랜잭션을 시작하고, 커밋과 롤백, 자원을 반납하는 로직들… 비즈니스 로직과 관련 없는 코드들에 벌써부터 짜증이 몰려온다. 이거 만약 나중에 JPA를 도입한다고 하면 위 서비스 코드를 상당 부분 갈아 엎어야 한다. </p>
<p>&nbsp;</p>
<p>현재 문제점들을 다시 나열해보자면, </p>
<ul>
<li><p><strong>JDBC 구현 기술이 서비스 계층에 누수되고 있음</strong></p>
</li>
<li><p><strong>트랜잭션 동기화 문제</strong></p>
<ul>
<li>같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘기고 있다.</li>
</ul>
</li>
<li><p><strong>트랜잭션 시작, 커밋/롤백, 자원 해제와 같은 코드가 반복되고 있다.</strong></p>
</li>
</ul>
<p>&nbsp;</p>
<h1 id="👥-트랜잭션-추상화">👥 트랜잭션 추상화</h1>
<p>일단 간단한 해결책은 트랜잭션 기능 자체를 아래처럼 인터페이스로 빼버리면 되지 않을까? </p>
<pre><code class="language-java">public interface TxManager {
    begin();
    commit();
    rollback();
}</code></pre>
<p>이 인터페이스를 구현해서 구현체로 끼워 넣으면 된다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/4c6c8066-5b75-47d3-a406-e487ab6c2595/image.png" alt=""></p>
<p>위 그림처럼 JDBC 트랜잭션 기능을 사용하려면 <code>JdbcTxManager</code>를 서비스에 주입하고, JPA 트랜잭션 기능을 사용하려면 <code>JpaTxManager</code>를 서비스에 주입해서 사용하면 된다. 다행스럽게도 스프링 형님은 트랜잭션 추상화 기술을 아래와 같이 마련하셨다.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/2e667ccb-ea14-4188-9c90-9bc185f10e96/image.png" alt=""></p>
<p>트랜잭션 추상화의 핵심인 <code>PlatformTransactionManager</code>의 코드는 아래와 같다.</p>
<pre><code class="language-java">public interface PlatformTransactionManager extends TransactionManager {
    TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
    void commit(TransactionStatus status) throws TransactionException;
    void rollback(TransactionStatus status) throws TransactionException;
}</code></pre>
<p><code>getTransaction()</code>을 통해 트랜잭션을 시작할 수도 있고, 진행 중인 트랜잭션에 참여할 수도 있다. <code>commit()</code>과 <code>rollback()</code>은 뭐 당연하지.</p>
<p>&nbsp;</p>
<h2 id="⌚-트랜잭션-동기화">⌚ 트랜잭션 동기화</h2>
<p>스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.</p>
<ul>
<li><strong>트랜잭션 추상화</strong></li>
<li><strong>리소스 동기화</strong></li>
</ul>
<p>아까도 말했다시피 트랜잭션을 유지하기 위해서는 같은 데이터베이스 커넥션을 유지해야 한다. 이때 커넥션을 파라미터로 전달하게 되면 코드가 굉장히 지저분해졌다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/bef29e5e-43d6-4fee-9e50-cd880545ae75/image.png" alt=""></p>
<p>그림을 보다시피 <strong>스프링은 트랜잭션 동기화 매니저를 제공</strong>하는데, 이 친구는 <code>ThreadLocal</code>을 사용해서 커넥션을 동기화해준다. 이제부터 파라미터로 커넥션을 건네주지 않고 그냥 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다. </p>
<ol>
<li><p>트랜잭션 매니저는 데이터소스를 통해 트랜잭션을 시작한다.</p>
</li>
<li><p>트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.</p>
</li>
<li><p>리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. </p>
</li>
<li><p>트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고, 커넥션도 닫는다.</p>
</li>
</ol>
<p>&nbsp;</p>
<p>아래는 실제 애플리케이션 코드에 트랜잭션 매니저를 적용한 코드다.</p>
<pre><code class="language-java">package springdb.jdbc.repository;

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import springdb.jdbc.domain.Member;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * 트랜잭션 매니저 도입
 * DataSourceUtils.getConnection()
 * DataSourceUtils.releaseConnection()
 */
@Slf4j
public class MemberRepositoryV3 {

    private final DataSource dataSource;

    public MemberRepositoryV3(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Member save(Member member) throws SQLException {
        String sql = &quot;INSERT INTO member (member_id, money) VALUES (?, ?)&quot;;

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error(&quot;DB 에러: &quot;, e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public Member findById(String memberId) throws SQLException {
        String sql = &quot;SELECT * FROM member WHERE member_id = ?&quot;;

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            rs = pstmt.executeQuery();

            if (rs.next()) {
                Member member = new Member();
                member.setMemberId(rs.getString(&quot;member_id&quot;));
                member.setMoney(rs.getInt(&quot;money&quot;));
                return member;
            } else {
                throw new NoSuchElementException(&quot;Member not found memberId = &quot; + memberId);
            }
        } catch (SQLException e) {
            log.error(&quot;DB ERROR&quot;, e);
            throw e;
        } finally {
            close(con, pstmt, rs);
        }
    }

    public void update(String memberId, int money) throws SQLException {
        String sql = &quot;UPDATE member SET money = ? WHERE member_id = ?&quot;;
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error(&quot;DB ERROR&quot;, e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = &quot;DELETE FROM member WHERE member_id = ?&quot;;

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error(&quot;DB ERROR&quot;, e);
            throw e;
        } finally {
            close(con, pstmt, null);
        }
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);

        // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    private Connection getConnection() throws SQLException {
        // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        Connection connection = DataSourceUtils.getConnection(dataSource);
        log.info(&quot;get connection={}, class={}&quot;, connection, connection.getClass());
        return connection;
    }
}</code></pre>
<pre><code class="language-java">package springdb.jdbc.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV2;
import springdb.jdbc.repository.MemberRepositoryV3;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 트랜잭션 매니저
 */
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_1 {

    private final PlatformTransactionManager transactionManager;
    private final MemberRepositoryV3 repository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        // 트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            bizLogic(fromId, toId, money);
            transactionManager.commit(status);  // 성공 시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status);  // 실패 시 롤백
            throw new IllegalStateException(e);
        }
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        repository.update(toId, toMember.getMoney() + money);
    }

    private static void releaseConnection(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true);  // 커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info(&quot;error&quot;, e);
            }
        }
    }
}</code></pre>
<p>&nbsp;</p>
<h2 id="📋-트랜잭션-템플릿">📋 트랜잭션 템플릿</h2>
<p>근데 트랜잭션이 적용된 로직을 보면 다 비슷하게 생겼다.</p>
<pre><code class="language-java">// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

try {
    bizLogic(fromId, toId, money);
    transactionManager.commit(status);  // 성공 시 커밋
} catch (Exception e) {
    transactionManager.rollback(status);  // 실패 시 롤백
    throw new IllegalStateException(e);
}</code></pre>
<p>애플리케이션에서 서비스 로직이 1개만 있는 것도 아니고, 수많은 서비스 로직에서 이런 반복되는 코드가 작성되어 있는 것이다. <strong>템플릿 콜백 패턴</strong>을 활용하여 이런 반복 문제를 깔끔하게 해결해보자.</p>
<p>&nbsp;</p>
<pre><code class="language-java">public class TransactionTemplate extends DefaultTransactionDefinition
        implements TransactionOperations, InitializingBean {

    protected final Log logger = LogFactory.getLog(getClass());

    @Nullable
    private PlatformTransactionManager transactionManager;

    public TransactionTemplate() {
    }

    public TransactionTemplate(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    public TransactionTemplate(PlatformTransactionManager transactionManager, TransactionDefinition transactionDefinition) {
        super(transactionDefinition);
        this.transactionManager = transactionManager;
    }

    public void setTransactionManager(@Nullable PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Nullable
    public PlatformTransactionManager getTransactionManager() {
        return this.transactionManager;
    }

    @Override
    public void afterPropertiesSet() {
        if (this.transactionManager == null) {
            throw new IllegalArgumentException(&quot;Property &#39;transactionManager&#39; is required&quot;);
        }
    }

    @Override
    @Nullable
    public &lt;T&gt; T execute(TransactionCallback&lt;T&gt; action) throws TransactionException {
        Assert.state(this.transactionManager != null, &quot;No PlatformTransactionManager set&quot;);

        if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager cpptm) {
            return cpptm.execute(this, action);
        }
        else {
            TransactionStatus status = this.transactionManager.getTransaction(this);
            T result;
            try {
                result = action.doInTransaction(status);
            }
            catch (RuntimeException | Error ex) {
                // Transactional code threw application exception -&gt; rollback
                rollbackOnException(status, ex);
                throw ex;
            }
            catch (Throwable ex) {
                // Transactional code threw unexpected exception -&gt; rollback
                rollbackOnException(status, ex);
                throw new UndeclaredThrowableException(ex, &quot;TransactionCallback threw undeclared checked exception&quot;);
            }
            this.transactionManager.commit(status);
            return result;
        }
    }

    private void rollbackOnException(TransactionStatus status, Throwable ex) throws TransactionException {
        Assert.state(this.transactionManager != null, &quot;No PlatformTransactionManager set&quot;);

        logger.debug(&quot;Initiating transaction rollback on application exception&quot;, ex);
        try {
            this.transactionManager.rollback(status);
        }
        catch (TransactionSystemException ex2) {
            logger.error(&quot;Application exception overridden by rollback exception&quot;, ex);
            ex2.initApplicationException(ex);
            throw ex2;
        }
        catch (RuntimeException | Error ex2) {
            logger.error(&quot;Application exception overridden by rollback exception&quot;, ex);
            throw ex2;
        }
    }

    @Override
    public boolean equals(@Nullable Object other) {
        return (this == other || (super.equals(other) &amp;&amp; (!(other instanceof TransactionTemplate template) ||
                getTransactionManager() == template.getTransactionManager())));
    }

}</code></pre>
<p>위와 같이 <strong>스프링은 <code>TransactionTemplate</code>이라는 템플릿 클래스를 제공하고 있다.</strong> 위의 템플릿을 사용하여 반복되는 부분을 제거하면 아래 코드와 같아진다. </p>
<pre><code class="language-java">package springdb.jdbc.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV3;

import java.sql.Connection;
import java.sql.SQLException;

/**
 * 트랜잭션 - 트랜잭션 템플릿
 */
@Slf4j
public class MemberServiceV3_2 {

    private final TransactionTemplate transactionTemplate;
    private final MemberRepositoryV3 repository;

    public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 repository) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.repository = repository;
    }

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        // 트랜잭션 관련 코드가 엄청 깔끔해졌다...
        transactionTemplate.executeWithoutResult((status) -&gt; {
            try {
                bizLogic(fromId, toId, money);
            } catch (SQLException e) {
                throw new IllegalStateException(e);
            }
        });
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        validateTransfer(toMember);
        repository.update(toId, toMember.getMoney() + money);
    }

    private static void validateTransfer(Member toMember) {
        if (toMember.getMemberId().equals(&quot;ex&quot;)) {
            throw new IllegalStateException(&quot;이체 중 오류 발생!&quot;);
        }
    }
}</code></pre>
<p><code>TransactionTemplate</code> 코드를 보면 알 수 있듯이 <code>TransactionTemplate</code>을 사용하려면 <code>transactionManager</code>를 주입 받아야 한다. 보다시피 트랜잭션을 시작하고 커밋/롤백하는 로직이 모두 제거된 것을 확인할 수 있다. </p>
<p>하지만 위 코드도 완전하지 않다. 분명 서비스 로직인데 트랜잭션 코드가 껴있기 때문이다. 서비스 로직 입장에서는 트랜잭션 관련 코드는 그저 들러리일 뿐이다. 이제 이 문제를 해결해보자.</p>
<p>&nbsp;</p>
<h2 id="🎭-트랜잭션-aop">🎭 트랜잭션 AOP</h2>
<p>트랜잭션을 편리하게 처리하기 위해 트랜잭션 추상화를 도입하고, 반복적인 트랜잭션 로직을 상당 부분 생략하기 위해 트랜잭션 템플릿까지 도입했다. 남은 건 하나. 서비스 로직을 최대한 고결한 상태로 둬야 한다. <strong>스프링 AOP를 통해 프록시를 도입하면 이 문제를 깔끔하게 해결할 수 있다.</strong></p>
<p>여기서 <strong>프록시(Proxy)란 간단히 말하자면 진짜 객체(타깃 객체) 앞에 세워두는 대리 객체</strong>를 뜻한다. 클라이언트(호출하는 쪽)는 진짜 객체를 직접 호출하는 것처럼 보이지만, 실제로는 프록시를 먼저 호출하고, 프록시가 중간에서 추가 작업을 끼워 넣은 뒤 진짜 객체를 호출하는 메커니즘이다. </p>
<p>&nbsp;</p>
<p>프록시를 도입하기 전과 후의 상황을 관찰하면서 프록시의 역할을 음미해보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/611c956f-32a6-477d-9129-d99ee3da4109/image.png" alt=""></p>
<p>프록시를 도입하기 전에는 클라이언트가 그냥 서비스 로직에 바로 트랜잭션 관련 로직을 때려박았다. 그래서 트랜잭션 관련 코드와 비즈니스 로직이 뒤섞여 유지보수에 불편함이 있었다는 것을 기억해야 한다. 이제 프록시를 도입한 그림을 보자.</p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/b586d95b-9868-4390-b073-a08dee0061cf/image.png" alt=""></p>
<p>서비스 계층 앞쪽에 뭔가가 들어섰다. 흐름을 간단히 살펴보면 <strong>클라이언트가 프록시의 트랜잭션을 시작하는 메서드를 호출하면 프록시는 해당 작업을 수행하다가 순수 비즈니스 로직만 들어있는 서비스 객체를 호출</strong>하는 것이다. 그 비즈니스 로직이 끝나면 다시 프록시가 커밋이나 롤백과 같은 부가 기능을 수행하고 마무리하는 흐름이다. <strong>즉 프록시가 가로채서 뭔가를 더 하고 넘기는 구조로 생각하면 편하다.</strong></p>
<p>&nbsp;</p>
<p>그래서 스프링이 제공하는 AOP 기능을 어떻게 사용하냐? 여러 방법이 있지만 그냥 트랜잭션 처리가 필요한 곳에 <code>@Transactional</code> 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다. 그럼 한번 <code>@Transactional</code> 애노테이션을 적용한 코드를 만들어보자.</p>
<pre><code class="language-java">package springdb.jdbc.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import springdb.jdbc.domain.Member;
import springdb.jdbc.repository.MemberRepositoryV3;

import java.sql.SQLException;

/**
 * 트랜잭션 - @Transactional AOP
 */
@Slf4j
public class MemberServiceV3_3 {

    private final MemberRepositoryV3 repository;

    public MemberServiceV3_3(MemberRepositoryV3 repository) {
        this.repository = repository;
    }

    @Transactional
    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        bizLogic(fromId, toId, money);
    }

    private void bizLogic(String fromId, String toId, int money) throws SQLException {
        Member fromMember = repository.findById(fromId);
        Member toMember = repository.findById(toId);

        repository.update(fromId, fromMember.getMoney() - money);
        validateTransfer(toMember);
        repository.update(toId, toMember.getMoney() + money);
    }

    private static void validateTransfer(Member toMember) {
        if (toMember.getMemberId().equals(&quot;ex&quot;)) {
            throw new IllegalStateException(&quot;이체 중 오류 발생!&quot;);
        }
    }
}</code></pre>
<p>완전 웅장하다… 서비스 클래스 그 어디에도 트랜잭션과 관련된 코드는 찾아볼 수가 없다. 위와 같이 <code>@Transactional</code> 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 <code>public</code> 메서드가 AOP 적용 대상이 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[KB국민은행] IT’s Your Life 7기(전공자) 최종 합격 후기]]></title>
            <link>https://velog.io/@rocker_nun/KB-%EA%B5%AD%EB%AF%BC%EC%9D%80%ED%96%89-ITs-Your-Life-7%EA%B8%B0%EC%A0%84%EA%B3%B5%EC%9E%90-%EC%B5%9C%EC%A2%85-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@rocker_nun/KB-%EA%B5%AD%EB%AF%BC%EC%9D%80%ED%96%89-ITs-Your-Life-7%EA%B8%B0%EC%A0%84%EA%B3%B5%EC%9E%90-%EC%B5%9C%EC%A2%85-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Mon, 02 Feb 2026 14:43:28 GMT</pubDate>
            <description><![CDATA[<h1 id="🙏🏻-지원-동기">🙏🏻 지원 동기</h1>
<p>우테코 불합격 이후에 원하는 기업의 직무에 맞는 프로젝트를 개인적으로 진행하면서 코딩 테스트 대비도 열심히 하던 중 KB 국민은행과 멀티캠퍼스에서 주관하는 <strong>IT’s Your Life 7기</strong> 모집 공고를 보게 되었다. 원래는 우테코나 소프트웨어 마에스트로, 싸피 이외의 부트캠프에 지원할 생각은 없었는데 선발 프로세스도 타이트하고 커리큘럼도 상당히 괜찮아 보였다. 무엇보다 어디 가서 쉽게 경험할 수 없는 금융 도메인 관련 프로젝트를 실제 KB 국민은행 실무자에게 멘토링을 받으면서 만들어 나가는 경험이 큰 매력으로 다가왔다. 대기업답게 KB 측에서 지원금을 더 주는 점도 굉장히 메리트 있었고, 코딩 테스트 강의도 있는데 개발 유튜버 개발남노씨가 직접 수업하는 것 같았다. 그래서 망설임없이 전공자 전형으로 지원했다. </p>
<p>&nbsp;</p>
<h1 id="🔍-선발-프로세스">🔍 선발 프로세스</h1>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/091d82ac-a9d6-459e-9f03-eeae5926731b/image.png" alt=""></p>
<p>서류 접수, SW 적성진단, 대면 면접으로 총 3단계로 이루어져 있다. 많은 사람들이 지원했는지 예정된 일정보다 빠르게 선발 절차가 진행됐다. <del>비대면이라고 되어 있는데, 실제로는 대면 면접이었다.</del> </p>
<p>&nbsp;</p>
<h2 id="📋-서류-접수">📋 서류 접수</h2>
<p>먼저 서류 전형에는 자기소개서와 더불어 아래와 같은 항목을 작성 및 첨부해야 했다.</p>
<ul>
<li>기본 학력 및 경력 사항</li>
<li>취득한 자격증</li>
<li>수상 경력 및 학내외 활동 사항</li>
<li>어학 점수 등 기타 사항</li>
</ul>
<p>&nbsp;</p>
<ol>
<li><p>IT&#39;s Your life에 지원하신 동기와 과정 수료 후, 이루고 싶은 취업계획을 작성해 주십시오.</p>
</li>
<li><p>SW 관련 경험 중 어려웠던 과제와 해결 방안에 대해 작성하고, IT&#39;s Your Life 과정을 통해 향후 어떤 개발자로 성장하고 싶은지 작성해 주십시오. (※ SW 관련 경험 : SW 개발, SW 프로젝트 및 경진대회 경험(참여, 수상 등), IT 관련 자격증 취득 등)</p>
</li>
</ol>
<p>&nbsp;</p>
<p>자기소개서는 <strong>총 2개의 질문</strong>으로, <strong>각각 500자 이내</strong>로 작성해야 한다. 첫 번째 질문에 <strong>내가 이 과정이 꼭 필요한 이유</strong>에 대해 설득하는 것이 중요한 것 같다. 그래서 협업이나 완성도 있는 프로젝트에 대한 경험이 부족했던 부분을 어필했고, 수료 후에는 배운 내용을 금융권 기업에서 어떤 식으로 활용하고 적용할지에 대해 이야기했다. </p>
<p>2번 내용은 과거 팀 스터디에서 했던 내용을 작성하고 IT’s Your Life가 풀스택 과정인만큼, IT 전반에 대한 역량을 갖추고, 더 나아가 협업이나 소통에 대한 소프트 스킬에 관한 내용도 <strong>1번 항목에 이어지도록 작성</strong>했다. </p>
<img src="https://velog.velcdn.com/images/rocker_nun/post/de953e34-cc78-433f-b2bc-8017ff66f27a/image.png" width=500 />

<p>&nbsp;</p>
<h2 id="💻-sw-적성진단">💻 SW 적성진단</h2>
<p>SW 적성진단은 <strong>JOBDA(잡다)의 개발자 역량 검사</strong>를 활용하여 진행되었다. 그냥 코딩 테스트다. 처음 접하는 플랫폼이어서 환경 테스트(?) 같은 걸 미리 해보면서 적응했다. 시간은 <strong>총 2시간이고 문항은 2문항</strong>, 내가 생각하기에 프로그래머스 기준 <strong>어려운 레벨1 ~ 레벨2</strong> 정도의 난이도인 것 같았다. 약간 독특했던 점은 각 문항이 한번 풀고 끝나는게 아닌, <strong>각 5단계로 이루어져 있다는 것이었다.</strong> 단계가 올라갈수록 요구 사항이 추가되는 컨셉이었다.</p>
<ul>
<li><p><strong>1번 문항</strong>: 구현 느낌의 문제, 요구 사항이 계속 붙기 때문에 다음 단계에서 어떤 요구 사항이 있을지 생각하면서 초반 코드 작성을 잘 하면 수월할 것 같다. 특별한 알고리즘은 필요하지 않았다.</p>
</li>
<li><p><strong>2번 문항</strong>: 자료구조를 써야 할 것 같은 냄새가 물씬 풍겼다. 그렇다고 어려운 자료구조는 아니고, 스택과 큐 정도만 알아도 괜찮아 보였다. <del>난 원형큐를 이용해서 문제를 풀었다.</del></p>
</li>
</ul>
<p>&nbsp;</p>
<p>1번은 5단계, 2번은 4단계를 풀다가 시험이 종료되었다. </p>
<img src="https://velog.velcdn.com/images/rocker_nun/post/beb0d949-4d4a-474b-8021-c7ccbc33e853/image.png" width=500 />

<p>&nbsp;</p>
<h2 id="🗣️-대면-면접">🗣️ 대면 면접</h2>
<p>복장이 비즈니스 캐주얼이길래 부리나케 지난 후기들을 살펴봤다. 다들 정장 입고 가는게 마음 편하다고 해서 부랴부랴 인생 첫 정장을 구입했다. 정장에 구두, 넥타이, 셔츠, 코트까지 하니까 돈이 와장창 깨졌다… 각 지역마다 정장을 대여해주는 서비스 같은 게 있던데 기간만 겹친다면 서비스를 이용하는 걸 적극 추천한다.</p>
<img src="https://velog.velcdn.com/images/rocker_nun/post/9e353653-32c3-448b-8c63-72379b359c28/image.jpeg" width=450/>


<p>갈 때 신분증은 필수, 내일배움카드는 발급 받았으면 들고 가면 좋다. 들어갔더니 정장 입은 지원자들이 엄청나게 많아서 깜짝 놀랐다. 오후까지 면접이 있던데 이 많은 지원자들 사이에서 어떻게 살아남아야 할지 살짝 막막해졌다. 일단 명찰이랑 쿠키 상자 받고 9시 57분 정도까지 정해진 대기실에서 멘트를 중얼중얼대고 있었다.</p>
<img src="https://velog.velcdn.com/images/rocker_nun/post/d319a0a3-60f7-4ae3-bc50-177cdc5bab4f/image.jpeg" width=450/>

<img src="https://velog.velcdn.com/images/rocker_nun/post/b24fedcb-7ee8-4414-938b-73da1ca3d78f/image.jpeg" width=450/>


<p>위층 면접실 복도에 단체로 주르륵 줄 서 있다가 정각에 노크하고 들어갔다. 면접은 <strong>면접관 2명, 지원자 6명</strong>으로 진행된다. 일단 <strong>1분 자기소개</strong>를 시작으로 <strong>서류에 작성했던 내용</strong>을 물어보신다. 다른 기본적인 질문과 함께 <strong>가장 자신있는 프로그래밍 언어는 무엇이고, 그 이유에 대한 질문</strong> 정도만 나오고 이외에는 완전 기술적인 질문은 없었다. 그냥 바른 자세로 솔직하게 나의 생각을 자신있게 대답하고, 이 과정이 나에게 왜 필요한지 논리적으로 설득할 수 있다면 좋은 결과가 있을거 같다.</p>
<p>&nbsp;</p>
<h1 id="🎊-최종-합격">🎊 최종 합격</h1>
<p>최종 합격 발표 이후 26일 오후 2시부터 여의도의 KB국민은행 신관 건물에서 발대식이 진행되었다. </p>
<img src="https://velog.velcdn.com/images/rocker_nun/post/389816aa-1298-4b6d-a34c-a2e42cfa1b2a/image.jpg" width=800 />

<img src="https://velog.velcdn.com/images/rocker_nun/post/6bbdc680-5e73-4317-a66e-7ab35003f7e4/image.jpg" width=500 />

<p><img src="https://velog.velcdn.com/images/rocker_nun/post/a36d6a81-51f4-4116-b955-0d1712481b1f/image.jpg" alt=""></p>
<p>발대식 이후, 이번 년도 목표에 우수 수료자가 되자는 목표를 하나 더 추가했다. 면접장에서도 느꼈지만 다들 열정이 넘치고 개발 역량도 상당해 보였다. 이런 동료들과 함께 협업하고 소통할 생각을 하니 든든하면서도 굉장히 기대가 많이 됐다. 다 같이 열심히 공부해서 많은 성장을 할 수 있는 6개월이 됐으면 좋겠다. 6개월만 고생하자. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Outbox 폴링 성능 개선 실험]]></title>
            <link>https://velog.io/@rocker_nun/Outbox-%ED%8F%B4%EB%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EC%8B%A4%ED%97%98</link>
            <guid>https://velog.io/@rocker_nun/Outbox-%ED%8F%B4%EB%A7%81-%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EC%8B%A4%ED%97%98</guid>
            <pubDate>Fri, 30 Jan 2026 15:30:12 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-개발-배경">🤔 개발 배경</h1>
<p>지금 일련의 서비스 흐름을 정리하자면 아래와 같다.</p>
<ol>
<li>주문 생성 트랜잭션에서 <code>orders</code> 테이블에 주문 정보를 저장한다.</li>
<li>같은 트랜잭션 내에서 <code>outbox_events</code> 테이블에 해당 주문 정보에 대한 이벤트도 저장한다.</li>
<li><code>OutboxDispatcher</code>가 <code>outbox_events</code> 테이블을 폴링해서 외부 시스템을 전송한다.</li>
</ol>
<p>&nbsp;</p>
<p>이렇게 할 수밖에 없던 이유는 <strong><em>“주문 정보를 저장하는 것”</em></strong> 과, <strong><em>“이벤트를 외부 시스템에 전송하는 것”</em></strong> 을 한 번에 묶어서 원자적으로 처리할 수 없었기 때문이었다. 근데 여기서 만약 <code>outbox_events</code> 테이블이 무진장 커질 수도 있다는 우려도 있었다. 그럼 <code>OutboxDispatcher</code>의 폴링 성능에도 문제가 생기지 않을까? </p>
<p>&nbsp;</p>
<p>아래는 <code>OutboxRepository</code>의 <code>findAndLockPending()</code> 메서드의 일부다.</p>
<pre><code class="language-java">public List&lt;OutboxEvent&gt; findAndLockPending(int limit, String workerId) {
    String sql = &quot;&quot;&quot;
    SELECT id, event_type, aggregate_type, aggregate_id, payload, status,
           retry_count, next_run_at, last_error, created_at
    FROM outbox_events
    WHERE status = &#39;PENDING&#39;
      AND (next_run_at IS NULL OR next_run_at &lt;= CURRENT_TIMESTAMP)
    ORDER BY id
    LIMIT ?
    FOR UPDATE SKIP LOCKED
    &quot;&quot;&quot;;

    ...</code></pre>
<p>이 쿼리가 바로 <code>OutboxDispatcher</code> 폴링의 핵심이다. 요약하자면, PENDING 상태인 이벤트 중에서 실행 예정 시각이 임박했거나 혹은 즉시 실행해야 하는 것들 중에서 현재는 <code>BATCH_SIZE</code>를 50으로 설정했기 때문에 50개만 가져온다는 내용이다. </p>
<p>&nbsp;</p>
<h1 id="😵💫-만약-외부-시스템로의-전송이-밀린다면">😵‍💫 만약 외부 시스템로의 전송이 밀린다면?</h1>
<p>생각해보니 외부 결제 파트너사의 서버에 장애가 나서 우리 쪽에서 이벤트 전송이 계속 밀리거나, 혹은 이후에 복구되면서 <code>next_run_at</code>이 미래로 날아가버린 이벤트들이 무지막지하게 쌓일 수 있었다. 그리고 다시 정상적으로 복구된다면 그 시점부터는 폴링해야 하는 이벤트들이 쌓이기 시작하겠지?</p>
<p>바로 이런 상황에 대해 성능 개선의 여지가 있어 보였다. 왜냐하면 그 정상적으로 복구된 시점으로부터 생성되기 시작한 이벤트들은 <code>id</code>를 기준으로 <code>outbox_events</code>의 끝자락에 몰려 있을 것이 뻔했기 때문이다.</p>
<p>성능 향상을 한눈에 보기 위해 극단적으로 생각해봤다. 100만 건의 이벤트를 폴링하는데 계속 장애가 지속돼서 이벤트 전송이 다 밀리고, 마지막 50개만 처리해야 하는 상황을 가정했다. 폴링 조건을 다시 생각해보면, <code>status</code>로 PENDING만 솎아 내고, <code>next_run_at</code>로 범위를 좁히고, 그 중에서 <code>id</code>를 기준으로 50개만 가져온다. 따라서 1차적으로는 인덱스를 추가하기 전에 성능을 측정하고, 이후에 아래 복합 인덱스를 추가한 다음에 성능을 측정했다.</p>
<pre><code class="language-sql">CREATE INDEX idx_outbox_pending_pick
ON outbox_events (status, next_run_at, id);</code></pre>
<p>&nbsp;</p>
<h2 id="⏪️-인덱스-추가-전-성능-측정">⏪️ 인덱스 추가 전 성능 측정</h2>
<p>100만 건을 생성하고, 마지막 50개만 즉시 폴링해야 하는 환경을 세팅하고, 아래 쿼리를 통해 성능을 측정했다.</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT id
FROM outbox_events
WHERE status=&#39;PENDING&#39;
  AND (next_run_at IS NULL OR next_run_at &lt;= CURRENT_TIMESTAMP)
ORDER BY id
LIMIT 50;

/*
&#39;-&gt; Limit: 50 row(s)  
(cost=4.58 rows=2) (actual time=321..321 rows=50 loops=1)\n    
-&gt; Filter: ((outbox_events.`status` = \&#39;PENDING\&#39;) 
and ((outbox_events.next_run_at is null) 
or (outbox_events.next_run_at &lt;= &lt;cache&gt;(now()))))  
(cost=4.58 rows=2) (actual time=321..321 rows=50 loops=1)\n        
-&gt; Index scan on outbox_events using PRIMARY  
(cost=4.58 rows=50) 
(actual time=0.0596..135 rows=1e+6 loops=1)\n&#39;
*/

/*
&#39;-&gt; Limit: 50 row(s)  
(cost=4.58 rows=2) (actual time=359..359 rows=50 loops=1)\n    
-&gt; Filter: ((outbox_events.`status` = \&#39;PENDING\&#39;) 
and ((outbox_events.next_run_at is null) 
or (outbox_events.next_run_at &lt;= &lt;cache&gt;(now()))))  
(cost=4.58 rows=2) (actual time=359..359 rows=50 loops=1)\n
-&gt; Index scan on outbox_events using PRIMARY 
(cost=4.58 rows=50) 
(actual time=0.493..176 rows=1e+6 loops=1)\n&#39;
*/</code></pre>
<p>보다시피 실제 실행 시간은 약 321ms, 359ms가 나왔고, 스캔한 row 수는 1e + 6으로 그냥 풀 테이블 스캔했다. 평균 시간은 대략 340ms 정도 걸렸다고 하자.</p>
<p>&nbsp;</p>
<h2 id="⏩️-인덱스-추가-후-성능-측정">⏩️ 인덱스 추가 후 성능 측정</h2>
<p>인덱스 추가 후 결과는 아래와 같았다.</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT id
FROM outbox_events
WHERE status=&#39;PENDING&#39;
  AND (next_run_at IS NULL OR next_run_at &lt;= CURRENT_TIMESTAMP)
ORDER BY id
LIMIT 50;

/*
&#39;-&gt; Limit: 50 row(s)  
(cost=11.6 rows=50) 
(actual time=0.0684..0.0729 rows=50 loops=1)\n    
-&gt; Sort: outbox_events.id, limit input to 50 row(s) per chunk  
(cost=11.6 rows=50) (actual time=0.0677..0.0698 rows=50 loops=1)\n        
-&gt; Filter: ((outbox_events.`status` = \&#39;PENDING\&#39;) 
and ((outbox_events.next_run_at is null) 
or (outbox_events.next_run_at &lt;= &lt;cache&gt;(now()))))  
(cost=11.6 rows=50) (actual time=0.0232..0.0508 rows=50 loops=1)\n            
-&gt; Index range scan on outbox_events using idx_outbox_pending_pick over 
(status = \&#39;PENDING\&#39; AND NULL &lt;= next_run_at &lt;= \&#39;2026-01-30 23:20:34\&#39;)  
(cost=11.6 rows=50) 
(actual time=0.0209..0.0401 rows=50 loops=1)\n&#39;
*/

/*
&#39;-&gt; Limit: 50 row(s)  
(cost=11.6 rows=50) 
(actual time=0.104..0.108 rows=50 loops=1)\n    
-&gt; Sort: outbox_events.id, limit input to 50 row(s) per chunk  
(cost=11.6 rows=50) 
(actual time=0.102..0.104 rows=50 loops=1)\n        
-&gt; Filter: ((outbox_events.`status` = \&#39;PENDING\&#39;) 
and ((outbox_events.next_run_at is null) 
or (outbox_events.next_run_at &lt;= &lt;cache&gt;(now()))))  
(cost=11.6 rows=50) 
(actual time=0.0451..0.0793 rows=50 loops=1)\n            
-&gt; Index range scan on outbox_events using idx_outbox_pending_pick over 
(status = \&#39;PENDING\&#39; AND NULL &lt;= next_run_at &lt;= \&#39;2026-01-30 23:20:45\&#39;)  
(cost=11.6 rows=50) 
(actual time=0.0417..0.0678 rows=50 loops=1)\n&#39;
*/</code></pre>
<p>실제 실행 시간은 약 0.0729ms, 0.108ms가 나왔다. 조건에 맞는 인덱스 구간만 빠르게 읽어 왔으니까 어찌 보면 당연하지만 그래도 충격적이다… 평균적으로 걸린 시간은 약 0.09ms다.</p>
<p>&nbsp;</p>
<h1 id="🔍-얻은-결론">🔍 얻은 결론</h1>
<p>인덱스 하나 추가했다고 340ms에서 0.09ms? 거의 3700배??? 성능이 개선되었다. 분명 <code>outbox_events</code> 테이블이 작을 때는 아무 차이를 느끼지 못 했겠지만, Outbox는 구조상 이렇게 충분히 데이터가 쌓이기 쉽기 때문에 규모가 커지만 폴링 성능이 무조건 문제가 되는 것 같다. 특히 이런 극단적인 상황에서는 인덱스 없이 <code>LIMIT</code>를 사용하면 쿼리가 매우 비싸지는 것을 체감했다. 따라서 Outbox 폴링 쿼리는 서비스 초반부터 인덱스 설계를 해두는 것이 바람직하다고 판단했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[주문 연동 플랫폼과 Outbox 패턴]]></title>
            <link>https://velog.io/@rocker_nun/%EC%A3%BC%EB%AC%B8-%EC%97%B0%EB%8F%99-%ED%94%8C%EB%9E%AB%ED%8F%BC%EA%B3%BC-Outbox-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@rocker_nun/%EC%A3%BC%EB%AC%B8-%EC%97%B0%EB%8F%99-%ED%94%8C%EB%9E%AB%ED%8F%BC%EA%B3%BC-Outbox-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Wed, 28 Jan 2026 06:33:36 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-개발-배경">🤔 개발 배경</h1>
<p>고객으로부터 주문 요청이 발생하면 본 서비스의 DB에 주문 정보를 INSERT 할 것이다. 그리고 그 주문 정보에 해당하는 외부 파트너 API를 호출해야 하는데, 이 과정은 둘 다 성공하면 <code>COMMIT</code>, 둘 중 하나라도 실패하면 둘 다 <code>ROLLBACK</code>해야 원자성을 지킬 수 있다. </p>
<p>하지만… 외부 파트너 호출은 프로젝트 DB 트랜잭션의 일부가 될 수 없다는 점에서 문제가 발생했다. 즉, 서로 다른 시스템 간에 진짜 원자성은 불가능하다고 생각했다. 이게 진짜 심각한 문제인게, 대충만 생각해봐도 말도 안 되는 상황이 벌어질 수도 있었다. 몇 가지만 생각해본다면,</p>
<h3 id="💥-db는-커밋됐는데-파트너-전송이-실패한-경우">💥 DB는 커밋됐는데, 파트너 전송이 실패한 경우</h3>
<ol>
<li>주문 정보 INSERT 성공</li>
<li>파트너 API 호출</li>
<li>파트너 서버 응답을 못 받아 오거나 네트워크가 끊김(문제 발생!!!)</li>
</ol>
<p>파트너 호출 실패 했다고 롤백을 하자니 주문 정보 자체가 없어지고, 커밋을 하자니 고객은 본인이 결제했다고 굳게 믿고 있는데, 파트너는 주문이 있는지도 모를 것이다. </p>
<p>&nbsp;</p>
<h3 id="💥-파트너-전송은-성공했는데-db-커밋이-실패">💥 파트너 전송은 성공했는데, DB 커밋이 실패</h3>
<ol>
<li>주문 INSERT 시도</li>
<li>파트너 API 호출 및 파트너 응답 성공</li>
<li>DB 커밋 단계에서 커넥션이 끊긴다든지, 서버가 다운(문제 발생!!!)</li>
</ol>
<p>이 상황도 진짜 최악이다. 주문 정보가 없는데, 파트너는 이미 상품 준비 중이다. </p>
<p>어떻게 하면 이 과정을 일관성 있게 유지할 수 있을까? 지금 최종 목표는 아래와 같다.</p>
<ul>
<li>주문 정보는 반드시 저장되어야 한다.</li>
<li>파트너 전송도 언젠가는 반드시 성공해야 한다.</li>
<li>전송 실패 했을 경우에도 데이터 유실 없이 재처리 가능해야 한다.</li>
<li>중복 전송이 되더라도 멱등하게 처리 가능해야 한다.</li>
</ul>
<p>따라서 설계를 내부 DB 트랜잭션으로 확실하게 주문 정보를 저장하고, 나중에 안전하게 주문 정보를 전송할 수 있어야 한다.</p>
<p>&nbsp;</p>
<h1 id="🗃️-outbox-패턴">🗃️ Outbox 패턴</h1>
<p>주문 정보를 무조건 커밋한 이후에 파트너로 전송해야 떠돌아 다니는 유령 주문이 없을 것이다. 하지만 커밋 후에 전송할 때에도 서버가 죽거나 장애가 발생하면 전송이 유실될 수도 있어서 <strong><em>“Outbox 패턴”</em></strong> 을 도입하기로 결정했다. </p>
<p>개념은 간단하다. 주문 정보를 DB에 INSERT 할 때 커밋된 이후에 파트너에 전송할 이벤트도 같이 저장하면 된다. 즉, 주문 정보를 INSERT 하는 작업과 나중에 전송할 이벤트를 DB에 저장하는 과정이 하나의 트랜잭션 내부에서 일어나게 하면 된다는 것이다. </p>
<p>프로젝트에서는 같은 트랜잭션 내부에 <code>orders</code> 테이블에 주문 정보를 삽입하는 작업과 <code>outbox_events</code> 테이블에 그 주문 정보에 해당하는 이벤트를 삽입하는 작업을 넣어두었다. 이렇게 하면 주문은 저장됐는데 이벤트가 없는 상태가 있을 수 없기 때문에 데이터 유실을 막을 수 있다. </p>
<p>대략 구현 방법은 아래와 같다.</p>
<ul>
<li>고객이 주문을 요청한다.</li>
<li><code>OrderService</code>가 트랜잭션을 시작한다.</li>
<li><code>orders</code> 테이블에 해당 주문 정보를 저장하거나 멱등키로 기존 주문을 반환한다.</li>
<li>신규 주문인 경우에만 <code>outbox_events</code>에 방금 주문 정보에 대한 이벤트를 저장한다.</li>
<li>별도의 스케줄러가 주기적으로 <code>outbox_events</code>에서 이벤트들을 처리하도록 한다.</li>
<li>성공 혹은 실패에 따라 해당 이벤트의 상태를 바꾼다.</li>
</ul>
<p>&nbsp;</p>
<p>일단 <code>outbox_events</code>에 삽입될 데이터의 형태를 아래와 같이 정의해줬다.</p>
<pre><code class="language-java">package order_system.pickup.outbox.dto;

import java.time.Instant;

public record OutboxEvent(
        Long id,
        String eventType,
        String aggregateType,
        Long aggregateId,
        String payload,
        String status,
        int retryCount,
        Instant nextRunAt,
        String lastError,
        Instant createdAt
) {}
</code></pre>
<p>&nbsp;</p>
<p>그리고 <code>OutboxRepository</code>를 생성해서 그 안에 신규 생성된 주문 정보에 대한 이벤트를 <code>outbox_events</code> 테이블에 삽입하는 로직을 작성해두었다.</p>
<pre><code class="language-java">...

@Repository
public class OutboxRepository {

    private final JdbcTemplate jdbcTemplate;

    public OutboxRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    ...

    public void saveOrderCreated(OrderCreatedEventPayload payload) {
        String sql = &quot;INSERT INTO outbox_events(event_type, aggregate_type, aggregate_id, payload) VALUES (?, ?, ?, CAST(? AS JSON))&quot;;

        jdbcTemplate.update(
                sql,
                OutboxEventType.ORDER_CREATED,
                AggregateType.ORDER,
                payload.orderId(),
                toJson(payload)
        );
    }

    ...</code></pre>
<p>다만, 지금 이벤트마다 <code>payload</code> 구조는 다르기 때문에 다른 타입이 필요했다. 현재 <code>payload</code>는 JSON String이라서 타입 안전성이 없다. 그리고 현재는 이벤트 타입이  <code>ORDER_CREATED</code>인 것들만 있어서 망정이지, 향후에는 이벤트 타입이 결제 승인인  <code>payload</code>, 주문 취소인 <code>payload</code> 등 각 이벤트에 맞는 DTO를 쓰는 것이 자연스럽다고 생각했다. </p>
<pre><code class="language-java">package order_system.pickup.outbox.dto;

import java.time.Instant;

public record OrderCreatedEventPayload(
        String eventId,
        int schemaVersion,
        Instant occurredAt,
        Long orderId,
        Long storeId,
        Integer totalPrice,
        String idempotencyKey
) {
    public static final int SCHEMA_VERSION = 1;

    public static OrderCreatedEventPayload of(Long orderId,
                                              Long storeId,
                                              Integer totalPrice,
                                              String idempotencyKey,
                                              String eventId,
                                              Instant occurredAt) {

        return new OrderCreatedEventPayload(
                eventId,
                SCHEMA_VERSION,
                occurredAt,
                orderId,
                storeId,
                totalPrice,
                idempotencyKey
        );
    }
}
</code></pre>
<p>이렇게 처리를 하고, <code>OrderService</code>에서 주문 정보를 <code>orders</code> 테이블에 저장하는 작업과 해당 주문 정보에 대한 이벤트를 <code>outbox_events</code> 테이블에 삽입하는 과정을 하나의 트랜잭션으로 묶어 주었다.</p>
<pre><code class="language-java">@Transactional
public OrderResponse createOrder(OrderCreateRequest req, String idempotencyKey) {
    validateIdempotencyKey(idempotencyKey);
    var existOrder = orderRepository.findByIdempotencyKey(idempotencyKey);

    if (existOrder.isPresent()) {
        return existOrder.get();
    }

    boolean existStore = storeRepository.findById(req.storeId()).isPresent();

    if (!existStore) {
        throw new StoreNotFoundException(req.storeId());
    }

    Long orderId;
    try {
        // 신규 주문의 경우에 orders 테이블에 주문 정보를 저장
        orderId = orderRepository.save(req, idempotencyKey);
    } catch (DuplicateKeyException e) {
        return orderRepository.findByIdempotencyKey(idempotencyKey)
                .orElseThrow(() -&gt; new IdempotencyKeyInconsistentStateException(idempotencyKey, e));
    }

    var payload = OrderCreatedEventPayload.of(
            orderId,
            req.storeId(),
            req.totalPrice(),
            idempotencyKey,
            java.util.UUID.randomUUID().toString(),
            java.time.Instant.now()
    );

    // ORDER_CREATED 이벤트를 outbox_events 테이블에 저장
    outboxRepository.saveOrderCreated(payload);

    return orderRepository.findById(orderId)
            .orElseThrow(() -&gt; new OrderNotFoundException(orderId));
}</code></pre>
<p>이로써 커밋이 성공했다면 주문 정보와 그 주문 정보에 해당하는 이벤트도 반드시 존재하고, 커밋에 실패하면 둘 다 없어지는 것을 보장할 수 있게 되었다. </p>
<p>&nbsp;</p>
<h1 id="🪝-dispatcher-polling">🪝 Dispatcher Polling</h1>
<p>지금까지의 내용을 다시 복기해보자. 주문 생성 API에서 해야 할 일은 <strong><em>“주문 정보를 DB에 저장하는 것”</em></strong>, <strong><em>“파트너로 주문을 전송하는 것”</em></strong> 이다. 문제는 파트너로 주문을 전송하는 작업은 외부 네트워크에 의존한다는 점이다. </p>
<p>둘 다 그냥 한 번에 묶어서 처리하면 되지 않나? 여기서 둘은 같은 트랜잭션 내부에 둘 수 없기 때문에 둘 중 무언가에 문제가 생기면 중복 전송이나 데이터 유실, 상태가 불일치하는 등의 불상사가 생길 수 있었다. 그래서 파트너로 이벤트를 전송하는 작업은 별도의 Dispatcher가 처리하는 것이 더 효율적이라는 생각을 했다. 나름대로 Dispatcher가 해야 하는 일을 정리해보니 아래와 같았다. </p>
<ul>
<li><code>outbox_events</code>에서 처리해야 할 이벤트를 조회한다.</li>
<li>이벤트를 선점해서 처리하도록 한다.</li>
<li>성공하면 PROCESSED 처리한다.</li>
<li>실패하면 재시도 횟수를 증가시키고, 일정 시간이 지난 이후에 재시도하도록 설정한다.</li>
</ul>
<p>&nbsp;</p>
<p>이때 이벤트를 선점한다는 것은 여러 워커가 떠도 같은 이벤트를 동시에 처리하지 않도록 하기 위함이다. 따라서 아래와 같이 <code>OutboxRepository</code>에 이벤트 <code>status</code>가 PENDING인 이벤트를 찾아서 락을 해주도록 <code>findAndLockPending()</code> 메서드를 추가하고, 선점에 성공하면 <code>status</code>를 PROCESSING으로 바꿔줄 <code>markProcessed()</code> 메서드도 추가해주었다.</p>
<pre><code class="language-java">public List&lt;OutboxEvent&gt; findAndLockPending(int limit, String workerId) {
    String sql = &quot;&quot;&quot;
    SELECT id, event_type, aggregate_type, aggregate_id, payload, status,
           retry_count, next_run_at, last_error, created_at
    FROM outbox_events
    WHERE status = &#39;PENDING&#39;
      AND (next_run_at IS NULL OR next_run_at &lt;= CURRENT_TIMESTAMP)
    ORDER BY id
    LIMIT ?
    FOR UPDATE SKIP LOCKED
    &quot;&quot;&quot;;

    List&lt;OutboxEvent&gt; events = jdbcTemplate.query(sql, OUTBOX_ROW_MAPPER, limit);

    if (events.isEmpty()) {
        return events;
    }

    String lockSql = &quot;&quot;&quot;
        UPDATE outbox_events 
        SET status = &#39;PROCESSING&#39;, 
            locked_by = ?, 
            locked_at = CURRENT_TIMESTAMP 
        WHERE id = ?
        &quot;&quot;&quot;;

    jdbcTemplate.batchUpdate(
            lockSql,
            events,
            events.size(),
            (ps, event) -&gt; {
                ps.setString(1, workerId);
                ps.setLong(2, event.id());
            }
    );

    return events;
}

...

public int markProcessed(Long id, String workerId) {
    String sql = &quot;&quot;&quot;
        UPDATE outbox_events
        SET status = &#39;PROCESSED&#39;, 
            processed_at = CURRENT_TIMESTAMP,
            next_run_at = NULL,
            last_error = NULL,
            locked_by = NULL,
            locked_at = NULL
        WHERE id = ? 
            AND status = &#39;PROCESSING&#39;
            AND locked_by = ?
        &quot;&quot;&quot;;

    return jdbcTemplate.update(sql, id, workerId);
}

...</code></pre>
<p>&nbsp;</p>
<p>그리고 실제 <code>outbox_events</code>에서 이벤트들을 꺼내 파트너에게 전송할 <code>OutboxDispatcher</code>를 구현했다. 아래는 코드 중 일부를 발췌한 부분이다. </p>
<pre><code class="language-java">@Scheduled(fixedDelay = 1000)
public void dispatch() {
    List&lt;OutboxEvent&gt; events = outboxRepository.findAndLockPending(BATCH_SIZE, workerId);

    if (events.isEmpty()) {
        return;
    }

    log.info(&quot;Outbox 디스패처 실행: size={}, workerId={}&quot;, events.size(), workerId);

    for (OutboxEvent event : events) {
        try {
            handle(event);

            int updated = outboxRepository.markProcessed(event.id(), workerId);
            if (updated == 0) {
                log.warn(&quot;markProcessed 실패: id={}, workerId={}&quot;, event.id(), workerId);
            }
        } catch (Exception e) {
            onFailure(event, e);
        }
    }
}

private void handle(OutboxEvent event) {
    String eventType = event.eventType();

    if (&quot;ORDER_CREATED&quot;.equals(eventType)) {
        // TODO: 향후에 실제 파트너 어댑터 혹은 클라이언트 호출
        log.debug(&quot;Handle ORDER_CREATED: id={}, aggregateId={}&quot;, event.id(), event.aggregateId());
        return;
    }

    throw new IllegalArgumentException(&quot;알 수 없는 이벤트 타입입니다. &quot; + eventType);
}

private void onFailure(OutboxEvent event, Exception e) {
    int nextRetry = event.retryCount() + 1;

    boolean toFailed = nextRetry &gt; MAX_RETRY_ATTEMPTS;
    String reason = e.getClass().getSimpleName() + &quot;: &quot; + e.getMessage();

    if (toFailed) {
        int updated = outboxRepository.markFailed(event.id(), workerId, reason);
        log.warn(&quot;outbox 실패 → FAILED(DLQ): id={}, retry={}, updated={}, reason={}&quot;,
                event.id(), nextRetry, updated, reason, e);
        return;
    }

    Instant nextRunAt = Instant.now().plusSeconds(backoffTime(nextRetry));
    int updated = outboxRepository.markRetry(event.id(), workerId, nextRetry, nextRunAt, reason);

    log.warn(&quot;outbox 실패 → 재시도 예약: id={}, retry={}, nextRunAt={}, updated={}, reason={}&quot;,
            event.id(), nextRetry, nextRunAt, updated, reason, e);
}</code></pre>
<p>&nbsp;</p>
<h2 id="🗳️-polling이란">🗳️ Polling이란?</h2>
<p>폴링(Polling)은 DB를 일정 주기로 확인해서 해야 할 일을 가져오는 방식을 말한다. 지금 위의 <code>dispatch()</code> 메서드를 보면 <code>@Scheduled</code> 애노테이션을 통해 1초마다 PENDING 이벤트를 조회하도록 했다. </p>
<p>일단 현재 <strong>Kafka</strong>나 <strong>RabbitMQ</strong>와 같은 메시지 브로커에 대한 지식이 전혀 없기도 하고, 이 정도까지는 너무 과한 것 같아 폴링 방식을 채택했다. 하지만 폴링 방식은 트래픽이 없는데도 계속 쿼리를 날리기 때문에 DB에 부하가 발생할 수 있고, 긴 간격으로 처리하면 지연이 커질 수도 있다. 따라서 향후 기능을 확장해나가면서 메시지 브로커를 도입해볼 예정이다. </p>
<p>지금은 폴링 방식으로 여러 워커들이 돌아도 같은 이벤트를 동시에 처리하지 않도록 락 처리를 해주었고, 만약 파트너에게 이벤트 전송을 실패한다면 재시도하도록 처리했다. 장애가 계속 지속되어 이벤트가 계속 쌓이고, 워커는 계속 같은 이벤트만 재시도하느라 리소스를 소모하게 된다. 따라서 일정 재시도 횟수를 초과할 경우에는 <code>status</code>를 FAILED로 전환하고, 자동 처리 대상에서 제외하고 <strong><em>“DLQ(Dead Letter Queue)”</em></strong> 에 따로 보관하도록 했다. </p>
<p>이로써 <code>OutboxDispatcher</code>를 통해 API를 안정화시키고, 이벤트 유실을 방지할 수 있게 되었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[주문 연동 플랫폼과 멱등성]]></title>
            <link>https://velog.io/@rocker_nun/%EC%A3%BC%EB%AC%B8-%EC%97%B0%EB%8F%99-%ED%94%8C%EB%9E%AB%ED%8F%BC%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%B1%EB%93%B1%EC%84%B1</link>
            <guid>https://velog.io/@rocker_nun/%EC%A3%BC%EB%AC%B8-%EC%97%B0%EB%8F%99-%ED%94%8C%EB%9E%AB%ED%8F%BC%EC%97%90%EC%84%9C%EC%9D%98-%EB%A9%B1%EB%93%B1%EC%84%B1</guid>
            <pubDate>Wed, 28 Jan 2026 04:31:43 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-개발-배경">🤔 개발 배경</h1>
<p>생각해보면 현실에서 주문 연동을 할 때, 아래와 같은 문제가 자주 생길 수도 있다고 생각했다. </p>
<ol>
<li>고객이 결제 버튼을 눌렀는데 네트워크가 끊김</li>
<li>주문을 요청 받은 그 파트너 서버가 느리거나 불안정함</li>
<li>지금 내가 만들 서버에 주문은 저장됐는데 파트너로 주문 정보 전송을 실패함</li>
</ol>
<p>실제 이런 주문을 연동하는 시스템을 가지고 있는 기업들에서는 눈에 진물이 나도록 고민하고 지금도 끊임없는 개선과 유지보수를 이어 나가고 있을 것이다. 나는 이런 주문 시스템이 현대 사회를 움직이는 가장 기본적이면서도 중요한 요소라고 생각한다. 그래서 단순 API만 붙어 있는 주문 앱 말고, 외부 파트너와 안정적으로 주문을 연동할 수 있는 플랫폼에 대해 고민하게 됐다. 이 프로젝트는 중복 주문을 막고, 이벤트가 유실되지 않고 결국 처리되게 만드는 것이 목표다. 추후 계속 생각나는 부분들이 있다면 적극적으로 고민하고 실험해볼 생각이다. </p>
<p><a href="https://github.com/Rockernun/Order-Integration-Platform">Github Link: 외부 파트너와 안정적으로 주문을 연동하기 위한 주문 연동 플랫폼</a></p>
<p>&nbsp;</p>
<h1 id="🎭-중복-주문은-어떤-상황에-왜-생길까">🎭 중복 주문은 어떤 상황에, 왜 생길까?</h1>
<p>배달앱에서 결제 버튼을 눌렀는데, 갑자기 화면이 멈춘 상황을 상상해보자. 사용자는 이런 상황에서 뒤로 갔다가 다시 결제를 시도하거나 분노에 쌓인 채 폭풍 새로고침을 할 수도 있고, 결제 버튼을 계속 연타할 수도 있다. </p>
<p>이런 상황을 서버 입장에서 생각해본다면, 동일 주문 요청이 여러 번 들어오고 있는 상황인 것이다. 만약 서버가 이를 곧이 곧대로 받아들인다면 같은 주문이 무지막지하게 생성되고, 사용자의 잔고에서는 계속해서 돈이 빠져나갈 것이다. </p>
<p>이때 필요한 것이 바로 <strong><em>“멱등성(Idempotency)”</em></strong> 이라고 생각했다. 멱등성에 대한 자세한 내용은 아래 링크를 참고하도록 하자.</p>
<p><a href="https://docs.tosspayments.com/blog/what-is-idempotency">멱등성이 뭔가요? | 토스페이먼츠 개발자센터</a>
<a href="https://velog.io/@mw310/%EC%8B%9D%EA%B5%AC%ED%95%98%EC%9E%90MSA-%EB%A9%B1%EB%93%B1%ED%82%A4%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A4%91%EB%B3%B5-%EC%9A%94%EC%B2%AD-%EB%B0%A9%EC%A7%80-Feat-%EB%94%B0%EB%8B%A5-%EC%9D%B4%EC%8A%88">[식구하자_MSA] 멱등키를 활용한 사용자 중복 요청 방지 (Feat: 따닥 이슈)</a></p>
<p>&nbsp;</p>
<h2 id="🚫-idempotency-key로-중복-요청-막기">🚫 Idempotency-Key로 중복 요청 막기</h2>
<p>본 프로젝트에서는 클라이언트가 주문 요청 헤더에 <code>Idempotency-Key</code>를 넣고, 같은 주문 재시도라면 같은 키를 재사용하는 것으로 멱등성을 실현했다. 예를 들어, <code>A 주문</code>을 내가 생성했고, 뭔가 문제가 생겨서 다시 주문 버튼을 눌러도 새 주문을 생성하지 않고, 기존 <code>A 주문</code>을 반환하도록 한 것이다. </p>
<p>이 과정을 어떻게 하면 효율적으로 구현할 수 있을지 생각을 하다 2가지의 선택의 기로에 놓이게 됐다. DB 기반으로 하느냐, Redis 기반으로 하느냐…</p>
<h3 id="선택1-db-기반">선택1: DB 기반</h3>
<p>순수하게 DB를 사용한다고 한다면, 그냥 <code>orders</code> 테이블에 <code>idempotency_key</code> 컬럼을 <code>UNIQUE</code>로 박아두는 것이다. 만약 같은 주문을 재시도하면, <code>orders</code> 테이블에 중복 삽입이 되므로 예외를 터뜨리고, 기존 주문에서 해당 <code>idempotency_key</code>를 가진 주문을 반환하는 방법이다. </p>
<pre><code class="language-java">public OrderResponse createOrder(OrderCreateRequest req, String idempotencyKey) {
    validateIdempotencyKey(idempotencyKey);
    var existOrder = orderRepository.findByIdempotencyKey(idempotencyKey);

    if (existOrder.isPresent()) {
        return existOrder.get();
    }

    boolean existStore = storeRepository.findById(req.storeId()).isPresent();

    if (!existStore) {
        throw new StoreNotFoundException(req.storeId());
    }

    Long orderId;
    try {
        orderId = orderRepository.save(req, idempotencyKey);
    } catch (DuplicateKeyException e) {
        return orderRepository.findByIdempotencyKey(idempotencyKey)
                .orElseThrow(() -&gt; new IdempotencyKeyInconsistentStateException(idempotencyKey, e));
    }</code></pre>
<p>이 방법은 딱히 무슨 기술이 필요한 것이 아니라 코드 작성만 하면 되고, DB가 알아서 잘 보장해주기 때문에 강한 정합성을 기대할 수 있었다. 하지만, 만약 중복 요청이 많아지면 DB에 충돌 및 조회에 대한 부담이 커질 수도 있을 것 같다는 우려가 있었다.</p>
<p>&nbsp;</p>
<h3 id="선택2--redis-기반">선택2 : Redis 기반</h3>
<p>이 프로젝트를 진행할 당시에 Redis가 뭔지만 알고, 사용해본 경험이나 사용하는 방법에 대해서도 잘 몰랐다. 그래서 여러 자료나 AI를 활용해서 프로젝트에 적용하려 노력했다. 결론만 말하면 Redis는 메모리 기반이라 중복 요청이 발생했다면 DB까지 가지 않고, 빠르게 주문 정보를 가지고 올 수 있다는 점에서 충분히 고민해볼 가치가 있었다.</p>
<p>일단 멱등키를 요청마다 헤더로 받고, Redis를 상태 저장소로 이용해보기로 했다. 상태는 <em>“지금 누군가가 처리 중(IN_PROGRESS)”</em> 와 <em>“이미 처리 완료하고 결과를 캐시(DONE)”</em> 총 2가지 정도를 생각했다. </p>
<pre><code class="language-java">@Service
public class IdempotencyRedisService {

    private static final String PREFIX = &quot;idempotency:&quot;;
    private static final String IN_PROGRESS = &quot;IN_PROGRESS&quot;;
    private static final String DONE = &quot;DONE&quot;;

    private final StringRedisTemplate redisTemplate;

    public IdempotencyRedisService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 멱등키를 선점
     * 해당 멱등키를 가지고 최초 요청한 경우 True 주문 생성
     * 중복된 요청이면 False 반환
     */
    public boolean tryAcquire(String idempotencyKey, Duration ttl) {
        String redisKey = toRedisKey(idempotencyKey);
        String value = IN_PROGRESS + &quot;:&quot; + UUID.randomUUID();

        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, value, ttl);

        return Boolean.TRUE.equals(success);
    }

    /**
     * 정상적으로 처리된 주문이 있는지 확인
     * DONE:&lt;OrderId&gt; 형태면 orderId를 반환
     */
    public Optional&lt;String&gt; findDoneResponseJson(String idempotencyKey) {
        String value = getValue(toRedisKey(idempotencyKey));
        if (value == null) {
            return Optional.empty();
        }

        if (value.startsWith(DONE + &quot;:&quot;)) {
            return Optional.of(value.substring((DONE + &quot;:&quot;).length()));
        }
        return Optional.empty();
    }

    /**
     * 주문을 정상적으로 생성하고 나서 결과를 저장
     */
    public void markDoneResponse(String idempotencyKey, String responseJson, Duration ttl) {
        String redisKey = toRedisKey(idempotencyKey);
        String value = DONE + &quot;:&quot; + responseJson;

        redisTemplate.opsForValue().set(redisKey, value, ttl);
    }

    /**
     * 실패 시 락 해제
     * IN_PROGRESS 상태에서만 삭제 가능
     */
    public void releaseIfInProgress(String idempotencyKey) {
        String redisKey = toRedisKey(idempotencyKey);
        String value = getValue(redisKey);

        if (value != null &amp;&amp; value.startsWith(IN_PROGRESS + &quot;:&quot;)) {
            redisTemplate.delete(redisKey);
        }
    }

    private String getValue(String redisKey) {
        return redisTemplate.opsForValue().get(redisKey);
    }

    private String toRedisKey(String idempotencyKey) {
        return PREFIX + idempotencyKey;
    }
}</code></pre>
<p>이렇게 하면 만약 동일한 멱등키로 요청이 100개가 들어 온다고 해도, 첫 번째 요청만 받아 들인다. 즉, 해당 멱등키에 대한 처리 권한을 선점한 요청은 처음에 온 요청 딱 하나인 것이다. </p>
<p>&nbsp;</p>
<p>아래는 <code>OrderService</code>의 일부분이다.</p>
<pre><code class="language-java">@Transactional
public OrderResponse createOrder(OrderCreateRequest req, String idempotencyKey) {
    validateIdempotencyKey(idempotencyKey);

    // 해당 멱등키로 들어온 요청이 DONE이면 바로 반환
    Optional&lt;OrderResponse&gt; cached = findCachedDoneResponse(idempotencyKey);
    if (cached.isPresent()) {
        return cached.get();
    }

    // 그렇지 않다면 Redis 락 선점
    boolean acquired = idempotencyRedisService.tryAcquire(idempotencyKey, IN_PROGRESS_TTL);

    if (!acquired) {  // 락 선점 실패 시
        Optional&lt;OrderResponse&gt; cachedAgain = findCachedDoneResponse(idempotencyKey);
        if (cachedAgain.isPresent()) {
            return cachedAgain.get();
        }

        // 바로 409 에러를 반환
        throw new IdempotencyInProgressException(idempotencyKey);
    }

    try {
        boolean existStore = storeRepository.findById(req.storeId()).isPresent();
        if (!existStore) {
            throw new StoreNotFoundException(req.storeId());
        }

        Long orderId;
        try {
            orderId = orderRepository.save(req, idempotencyKey);
        } catch (DuplicateKeyException e) {
            OrderResponse existing = orderRepository.findByIdempotencyKey(idempotencyKey)
                    .orElseThrow(() -&gt; new IllegalStateException(
                            &quot;멱등키 중복이 감지되었지만 기존 주문 조회에 실패했습니다: &quot; + idempotencyKey, e
                    ));

            cacheDoneResponse(idempotencyKey, existing);
            return existing;
        }

        OrderResponse created = orderRepository.findById(orderId)
                .orElseThrow(() -&gt; new OrderNotFoundException(orderId));
        cacheDoneResponse(idempotencyKey, created);

        return created;
    } catch (RuntimeException e) {
        // 처리 중 실패 시 IN_PROGRESS 상태의 주문의 락을 해제
        idempotencyRedisService.releaseIfInProgress(idempotencyKey);
        throw e;
    }
}

...</code></pre>
<p>최종적으로 DB를 기반으로 했을 때와 Redis를 도입한 상황을 각각 k6 라이브러리로 테스트를 수행해봤다. </p>
<h2 id="성능-측정-결과">&lt;성능 측정 결과&gt;</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>DB 기반</th>
<th>Redis 기반</th>
<th>성능 향상(Redis-DB)</th>
</tr>
</thead>
<tbody><tr>
<td>처리량(RPS)</td>
<td>6023.80/s</td>
<td>5451.02/s</td>
<td>-9.5%</td>
</tr>
<tr>
<td>평균 지연(avg)</td>
<td>16.54ms</td>
<td>18.29ms</td>
<td>+10.6%</td>
</tr>
<tr>
<td>p95</td>
<td>30.31ms</td>
<td>33.52ms</td>
<td>+10.6%</td>
</tr>
<tr>
<td>p90</td>
<td>22.34ms</td>
<td>19.9ms</td>
<td>-11%</td>
</tr>
<tr>
<td>최대(max)</td>
<td>181.95ms</td>
<td>174.40ms</td>
<td>-4.15%</td>
</tr>
</tbody></table>
<p>&nbsp;</p>
<p>내가 생각했던 결과보다 그렇게(?) Redis에서의 눈에 띄는 성능 향상은 볼 수 없었다. 생각해보니, Redis에서 락을 잡는다 하더라도 결국 DB를 조회해서 반환하는 부분이 있기 때문에 엄청난 성능 향상은 기대하기 어려웠던 것 같다. 그래서 <strong>본 프로젝트에서는 DB 멱등을 기반으로 하고, 향후 Redis는 옵션으로 붙이는 것이 더 낫다고 판단</strong>했다. </p>
<p>하지만 이후에 바로 더 큰 문제에 봉착했다. 지금 프로젝트 내부에서 DB에 접근하는 과정은 쉽게 트랜잭션을 적용할 수 있다. 근데 지금 프로젝트는 내부의 DB 트랜잭션과는 다른 별개의 시스템 호출에 대해서도 일관성을 유지해야 한다. 만약 내가 프로젝트 내부에서 어떤 결과를 롤백한다 하더라도 외부 시스템은 그 사실을 알 수 있는 방법이 없다는 것이다. 이 부분에 대해서는 다음 글에서 설명하도록 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[DFS와 BFS]]></title>
            <link>https://velog.io/@rocker_nun/DFS%EC%99%80-BFS</link>
            <guid>https://velog.io/@rocker_nun/DFS%EC%99%80-BFS</guid>
            <pubDate>Mon, 26 Jan 2026 16:53:08 GMT</pubDate>
            <description><![CDATA[<h1 id="🆚-dfs깊이-우선-탐색와-bfs너비-우선-탐색의-비교">🆚 DFS(깊이 우선 탐색)와 BFS(너비 우선 탐색)의 비교</h1>
<p><strong><code>DFS(Depth First Search)</code></strong>는 특정 노드에서 시작해, <strong>인접한 노드 중 방문하지 않은 노드를 따라 가능한 한 깊게 탐색</strong>하는 알고리즘이다. 더 이상 진행할 수 없는 지점에 도달하면 이전 지점으로 되돌아가며 다른 경로를 탐색한다. <code>DFS</code>는 <strong>방문한 노드를 다시 방문하지 않기 위해</strong> <code>visited 배열/집합</code>을 사용하며, 구현은 보통 <code>재귀 호출 또는 스택(Stack)</code>을 이용한다.</p>
<p><strong><code>BFS(Breadth First Search)</code></strong>는 특정 노드에서 시작해, <strong>현재 깊이(거리)의 모든 노드를 먼저 탐색한 뒤</strong> 다음 깊이로 이동하는 알고리즘이다. <code>BFS</code>는 <code>큐(Queue)</code>를 사용하며, 방문한 노드를 다시 방문하지 않기 위해 <code>visited</code>를 사용한다. 특히 <strong>간선 가중치가 모두 동일한 그래프에서는 시작점으로부터의 최단 거리(최소 이동 횟수)를 보장</strong>하기 때문에 최단 경로 문제에서 자주 활용된다.</p>
<p>정리하면 <code>DFS</code>는 <em>“한 경로를 끝까지 파고든 뒤 되돌아오며 탐색하는 방식”,</em>  <code>BFS</code>는 <em>“가까운 노드부터 차례대로 넓게 퍼져나가며 탐색하는 방식”</em> 이라고 생각하면 편하다.</p>
<p>&nbsp;</p>
<h2 id="🤔-왜-dfs-bfs-같은-탐색-알고리즘을-배워야-할까">🤔 왜 DFS, BFS 같은 탐색 알고리즘을 배워야 할까?</h2>
<p><code>DFS</code>와 <code>BFS</code>는 그래프에서 연결된 모든 경우의 수를 탐색하는 가장 원초적인 방법이다. 현실의 많은 문제는 결국 노드와 간선으로 표현할 수 있기 때문에, 탐색 알고리즘을 알면 복잡해 보이는 문제도 그래프로 모델링하고 탐색해서 해결할 수 있다. </p>
<p>특히 <code>DFS</code>와 <code>BFS</code>는 다른 심화된 알고리즘의 기반이 된다. 예를 들어, 최단 경로(다익스트라), 위상 정렬, 사이클 탐지, 연결 요소 개수 세기, 트리 순회, 미로 탐색 같은 문제들이 모두 <code>DFS</code>나 <code>BFS</code>와 연결되어 있다. 즉 <code>DFS</code>와 <code>BFS</code>를 잘 이해하고 있으면 단순히 탐색만 하는 것이 아닌, 문제를 구조화하고 해결하는 사고력 자체가 크게 향상될 것이다. </p>
<p>&nbsp;</p>
<p>간단하게 각 알고리즘이 쓰이는 상황을 나열하자면, </p>
<ul>
<li><code>DFS가 많이 쓰이는 상황</code><ol>
<li>모든 경로 탐색(경로가 존재하는지, 가능한 경우의 수)</li>
<li>백트래킹이나 완전 탐색(순열, 조합, N-Queen, 스도쿠 등)</li>
<li>트리나 그래프 순회</li>
<li>사이클 판별, 연결 요소 탐색</li>
</ol>
</li>
</ul>
<ul>
<li><code>BFS가 많이 쓰이는 상황</code><ol>
<li>최단 거리나 최소 이동 횟수를 구할 때(간선 가중치가 동일해야 함)</li>
<li>레벨 단위 탐색(거리가 1, 2, 3 …)</li>
<li>미로 최단 거리 탈출, 최소 버튼 누르기, 최소 시간 등</li>
</ol>
</li>
</ul>
<p>&nbsp;</p>
<p>가능한 경우를 깊게 들어가서 확인해야 한다면 <code>DFS</code>, 가장 빠르게 혹은 최소 횟수라는 키워드가 있다면 <code>BFS</code>가 옳은 선택인 경우가 많다.</p>
<hr>
<h1 id="🛠-dfsdepth-first-search-분석">🛠 DFS(Depth First Search) 분석</h1>
<p>1번 노드에서 시작한다고 했을 때, 1번 노드가 갈 수 있는 선택지는 왼쪽, 가운데, 오른쪽이 있다. 여기서 왼쪽에 있는 노드에서 갈 수 있는 끝까지를 찍고 나서 가운데, 오른쪽으로 차례대로 간다는 것이다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/8eb1275a-0884-4a60-bff7-1b356ad93e5a/image.webp" alt=""></p>
<p>구체적으로 실행 과정을 요약해보자면, 노드를 방문하고, 깊이 우선으로 인접한 노드를 방문하고, 또 그 노드를 방문해서 깊이 우선으로 인접한 노드를 방문한다. 만약 끝까지 갔다면 리턴한다는 정도로 말할 수 있다. </p>
<p><code>DFS</code>의 반복 방식은 아래와 같이 방문하지 않은 원소를 계속해서 찾아가면 된다. 방문하지 않았다는 조건은 <code>visited</code>와 같은 배열에 방문한 노드를 일일이 기록해주면 된다.</p>
<blockquote>
<p><strong>DFS(노드 A) = 노드 A + DFS(노드 A와 인접하지만 방문하지 않은 다른 노드)</strong></p>
</blockquote>
<p>&nbsp;</p>
<h2 id="🍕-dfs-재귀-구현">🍕 DFS 재귀 구현</h2>
<p>이제 DFS를 구현해보도록 하자.</p>
<pre><code class="language-java">package org.dfs;

import java.util.ArrayList;
import java.util.List;

public class DfsRecursive {

    static List&lt;Integer&gt;[] adjList;
    static boolean[] visited;

    static void dfs(int node) {
        visited[node] = true;
        System.out.println(&quot;Visited node: &quot; + node);

        for (int nextNode : adjList[node]) {
            if (!visited[nextNode]) {
                dfs(nextNode);
            }
        }
    }

    public static void main(String[] args) {
        int n = 10;
        int[][] edgeInfo = {
                {1, 2}, {1, 5}, {1, 9}, {2, 3}, {3, 4},
                {5, 6}, {6, 7}, {5, 8}, {9, 10}
        };

        adjList = new ArrayList[n + 1];
        visited = new boolean[n + 1];

        for (int i = 1; i &lt;= n; i++) {
            adjList[i] = new ArrayList&lt;&gt;();
        }

        for (int[] info : edgeInfo) {
            adjList[info[0]].add(info[1]);
            adjList[info[1]].add(info[0]);
        }

        dfs(1);
    }
}

/**
 * Visited node: 1
 * Visited node: 2
 * Visited node: 3
 * Visited node: 4
 * Visited node: 5
 * Visited node: 6
 * Visited node: 7
 * Visited node: 8
 * Visited node: 9
 * Visited node: 10
 */</code></pre>
<p>&nbsp;</p>
<ol>
<li><p>루트 노드부터 시작한다.</p>
</li>
<li><p>현재 방문한 노드를 <code>visited</code>에 추가한다.</p>
</li>
<li><p>현재 방문한 노드와 인접한 노드 중 방문하지 않은 노드를 방문한다.</p>
</li>
<li><p>2번 과정부터 반복한다.</p>
</li>
</ol>
<p>&nbsp;</p>
<h2 id="🥞-dfs-스택-구현">🥞 DFS 스택 구현</h2>
<p>하지만, 재귀를 통해서는 무한정 깊어지는 노드가 있는 경우 에러가 발생할 수 있다. 이 문제를 피하기 위해 다른 방법을 강구해야 한다. <code>DFS</code>는 탐색하는 원소를 최대한 깊게 따라가야 하는데, 이걸 다시 말하면 인접한 노드 중 방문하지 않은 모든 노드들을 저장해두고, 가장 마지막에 넣은 노드들만 꺼내서 탐색하면 된다는 것이다. 이때 스택을 사용하는 것이다. </p>
<ul>
<li><code>스택에 있는 노드</code>: 아직 방문하지 않았지만 방문할 예정인 노드</li>
<li><code>스택이 비었음</code>: 방문할 수 있는 노드를 싹 다 방문한 상태</li>
<li><code>pop한 노드</code>: 최근에 스택에 push한 노드</li>
</ul>
<p>&nbsp;</p>
<p>구현 순서는 아래와 같다.</p>
<ol>
<li><p>시작 노드를 정하고 시작 노드를 스택에 넣는다. </p>
</li>
<li><p>스택이 비었는지 확인하고, 비었으면 탐색을 종료한다.</p>
</li>
<li><p>스택에서 노드를 pop하고, 아직 방문하지 않은 노드면 방문 처리한다.</p>
</li>
<li><p>방금 방문 처리한 노드의 인접 노드가 있는지 확인하고, 아직 방문하지 않은 노드를 스택에 push한다. </p>
</li>
</ol>
<p>&nbsp;</p>
<p>여기서 반드시 고려해야 할 사항은 <strong><em>&quot;탐색할 노드가 없을 때까지 타고 내려가야 하고&quot;</em></strong>, <strong><em>&quot;가장 최근에 방문한 노드를 알아야 하며&quot;</em></strong>, <strong><em>&quot;이미 방문한 노드인지 알 수 있어야&quot;</em></strong> 한다는 것이다. 그리고 탐색하고 있는 방향의 반대 방향으로 되돌아가는 동작을 <code>백트래킹(Back Tracking)</code>이라고 한다. 재귀에서와 다르게 스택에서는 pop 연산 자체가 백트래킹 동작인 것이다.</p>
<p>&nbsp;</p>
<pre><code class="language-java">import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

class Solution {

    static List&lt;Integer&gt;[] adjList;  // 인접 노드 정보 리스트를 담을 배열
    static boolean[] visited;  // 각 노드들의 방문 여부를 저장할 배열

    static void dfs(int start) {  // 시작 노드를 알아야 한다.
        Deque&lt;Integer&gt; stack = new ArrayDeque&lt;&gt;();
        stack.push(start);  // 1. 시작 노드를 스택에 push

        while (!stack.isEmpty()) {  // 2. 스택이 빌 때까지 탐색
            int current = stack.pop();  // 3. 스택에서 노드를 pop하고...

            if (!visited[current]) {
                // 3. 아직 방문하지 않은 노드라면 방문 처리
                visited[current] = true;
                System.out.println(&quot;Visited node: &quot; + current);
            }

            // 4. 방금 방문한 노드의 인접 노드들 중에서...
            for (int nextNode : adjList[current]) {
                // 4. 아직 방문하지 않은 노드가 있다면 스택에 push
                if (!visited[nextNode]) {
                    stack.push(nextNode);
                }
            }
        }
    }

    public static void main(String[] args) {
        int n = 10;
        int[][] edgeInfo = {
                {1, 2}, {1, 5}, {1, 9}, {2, 3}, {3, 4},
                {5, 6}, {6, 7}, {5, 8}, {9, 10}
        };

        adjList = new ArrayList[n + 1];
        visited = new boolean[n + 1];

        for (int i = 1; i &lt;= n; i++) {
            adjList[i] = new ArrayList&lt;&gt;();
        }

        for (int[] info : edgeInfo) {
            adjList[info[0]].add(info[1]);
            adjList[info[1]].add(info[0]);
        }

        dfs(1);
    }
}

/**
 * Visited node: 1
 * Visited node: 9
 * Visited node: 10
 * Visited node: 5
 * Visited node: 8
 * Visited node: 6
 * Visited node: 7
 * Visited node: 2
 * Visited node: 3
 * Visited node: 4
 */
</code></pre>
<hr>
<h1 id="⚒-bfsbreadth-first-search-분석">⚒ BFS(Breadth First Search) 분석</h1>
<p>최대한 깊게 따라가야 하는 <code>DFS</code>와 다르게, <code>BFS</code>는 현재 인접한 노드(들)를 먼저 방문해야 한다. 이걸 다시 말하면 인접한 노드 중 방문하지 않은 모든 노드들을 저장해두고, 가장 처음에 넣은 노드를 꺼내서 탐색하면 된다. </p>
<p><img src="https://velog.velcdn.com/images/rocker_nun/post/ffc37104-7454-45d5-bad2-91da7e7dfbc0/image.webp" alt=""></p>
<p>구현 순서는 아래와 같다. </p>
<ol>
<li>루트 노드를 큐에 넣는다.</li>
<li>현재 큐의 노드를 빼서 <code>visited</code>에 추가한다.</li>
<li>현재 방문한 노드와 인접한 노드 중 방문하지 않은 노드를 큐에 추가한다.</li>
<li>2번 과정부터 반복한다.</li>
<li>큐가 비면 탐색을 종료한다.</li>
</ol>
<p>&nbsp;</p>
<h2 id="🍡-bfs-큐-구현">🍡 BFS 큐 구현</h2>
<pre><code class="language-java">package org.bfs;

import java.util.*;

public class Bfs {

    static List&lt;Integer&gt;[] adjList;
    static boolean[] visited;

    static void bfs(int start) {
        Deque&lt;Integer&gt; queue = new ArrayDeque&lt;&gt;();
        queue.offer(start);
        visited[start] = true;

        while (!queue.isEmpty()) {
            int node = queue.poll();
            System.out.println(&quot;Visited node: &quot; + node);

            for (int next : adjList[node]) {
                if (!visited[next]) {
                    visited[next] = true;
                    queue.offer(next);
                }
            }
        }
    }

    public static void main(String[] args) {
        int n = 10;
        int[][] edgeInfo = {
                {1, 2}, {1, 3}, {1, 4}, {2, 5}, {3, 6},
                {3, 7}, {4, 8}, {5, 9}, {6, 10}
        };

        adjList = new ArrayList[n + 1];
        visited = new boolean[n + 1];

        for (int i = 1; i &lt;= n; i++) {
            adjList[i] = new ArrayList&lt;&gt;();
        }

        for (int[] info : edgeInfo) {
            adjList[info[0]].add(info[1]);
            adjList[info[1]].add(info[0];
        }

        bfs(1);
    }
}

/**
 * Visited node: 1
 * Visited node: 2
 * Visited node: 3
 * Visited node: 4
 * Visited node: 5
 * Visited node: 6
 * Visited node: 7
 * Visited node: 8
 * Visited node: 9
 * Visited node: 10
 */</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[다익스트라 알고리즘]]></title>
            <link>https://velog.io/@rocker_nun/%EB%8B%A4%EC%9D%B5%EC%8A%A4%ED%8A%B8%EB%9D%BC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</link>
            <guid>https://velog.io/@rocker_nun/%EB%8B%A4%EC%9D%B5%EC%8A%A4%ED%8A%B8%EB%9D%BC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</guid>
            <pubDate>Mon, 26 Jan 2026 08:47:41 GMT</pubDate>
            <description><![CDATA[<h1 id="🤔-다익스트라-알고리즘dikstra이란">🤔 다익스트라 알고리즘(<strong>Dikstra</strong>)이란?</h1>
<p>다익스트라 알고리즘은 방향성을 가지는 그래프에서 최단 거리를 구할 때 사용된다. 가중치가 있는 그래프의 최단 경로를 구하는 문제들은 대부분 다익스트라 알고리즘을 사용한다고 보면 된다. 다익스트라 알고리즘은 <strong><code>너비 우선 탐색(BFS)</code></strong>과 유사한 형태를 가진 알고리즘으로, 시작점에서 가까운 순서대로 노드를 방문해간다. 차이점이 있다면 가중치가 있기 때문에 <strong><code>우선 순위 큐(PriorityQueue)</code></strong>를 사용하여 이를 해결하면 된다는 것이다.</p>
<p>&nbsp;</p>
<p>다익스트라의 핵심은, 현재까지 알려진 최단 거리 중 가장 짧은 노드부터 확정해가면서, 그 노드로부터 뻗어 나가는 간선을 갱신하면 된다는 것이다. 동작 흐름은 대략 아래와 같다.</p>
<ol>
<li><p>일단 각 노드까지의 최단 거리를 담을 배열 <code>dist[]</code>에 시작 노드에 대한 최단 거리만 0으로 초기화하고, 나머지는 <code>INF</code>로 초기화한다.</p>
</li>
<li><p>우선 순위 큐에 시작 노드를 삽입한다.</p>
</li>
<li><p>현재 <code>dist</code>가 가장 작은 노드를 꺼낸다.</p>
</li>
<li><p>그 노드로부터 뻗어나가는 간선을 전부 검사한다.</p>
</li>
<li><p>더 짧아지면 갱신하고 우선 순위 큐에 삽입한다.</p>
</li>
<li><p>모든 노드가 확정되면 종료한다.</p>
</li>
</ol>
<p>&nbsp;</p>
<pre><code class="language-java">import java.util.*;

class Solution {
    static class Node {
        int destination;
        int cost;

        public Node(int destination, int cost) {
            this.destination = destination;
            this.cost = cost;
        }
    }

    public int[] dijkstra(int N, int[][] edges, int start) {
           // 1) 그래프 구성 (인접 리스트)
          List&lt;Node&gt;[] graph = new ArrayList[N + 1];  // 노드 번호가 양수임을 가정
           for (int i = 1; i &lt;= N; i++) {
            graph[i] = new ArrayList&lt;&gt;();
        }

        // edges: [시작 노드, 목적지 노드, 가중치]
        for (int[] e : edges) {
            int from = e[0];
            int to = e[1];
            int w = e[2];
            graph[from].add(new Node(to, w));
            graph[to].add(new Node(from, w)); // 무방향이면 추가
        }

        // 2) dist 초기화
        int INF = Integer.MAX_VALUE;
        int[] dist = new int[N + 1];
        Arrays.fill(dist, INF);
        dist[start] = 0;

        // 3) cost가 작은 Node부터 먼저 꺼내는 큐
        PriorityQueue&lt;Node&gt; pq = new PriorityQueue&lt;&gt;(Comparator.comparingInt(o -&gt; o.cost));
        pq.offer(new Node(start, 0));  // 시작점에서 시작

        // 4) 다익스트라 알고리즘
        while (!pq.isEmpty()) {
            Node cur = pq.poll();  // cost가 가장 작은 노드 뽑기
            int now = cur.destination;  // 지금 확정하려는 노드
            int nowCost = cur.cost;  // cur와 now까지의 가중치

            if (dist[now] &lt; nowCost) {  // 만약 더 적은 가중치를 알고 있다면, 지금 꺼낸건 버려라
                continue;
            }

            for (Node next : graph[now]) {  // now에서 갈 수 있는 모든 이웃을 확인
                int nextNode = next.destination;  // now에서 갈 수 있는 다음 노드
                int edgeCost = next.cost;  // now에서 다음 노드로 가는 가중치

                int newCost = nowCost + edgeCost; // &quot;start -&gt; now 비용&quot; + &quot;now -&gt; nextNode 비용&quot;
                if (dist[nextNode] &gt; newCost) {  // 원래 알고 있던 최단 거리보다 방금 계산한 newCost가 더 작다면...
                    dist[nextNode] = newCost;  // 최단 거리 갱신
                    pq.offer(new Node(nextNode, newCost));  // 앞으로의 처리 대상 후보로 등록
                }
            }
        }

        // 시작 노드로부터 각 노드까지 가는 최소 비용이 담긴 배열 반환
        return dist;  
    }
}</code></pre>
]]></description>
        </item>
    </channel>
</rss>