<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dev_1000e</title>
        <link>https://velog.io/</link>
        <description>Java/Spring BackEnd</description>
        <lastBuildDate>Fri, 20 Mar 2026 12:36:16 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dev_1000e</title>
            <url>https://velog.velcdn.com/images/dev_1000e/profile/81182387-5183-4252-9cfa-128b32bcbaba/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dev_1000e. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dev_1000e" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[(JPA) 1차 캐시, Dirty Checking, Flush 타이밍]]></title>
            <link>https://velog.io/@dev_1000e/JPA-1%EC%B0%A8-%EC%BA%90%EC%8B%9C-Dirty-Checking-Flush-%ED%83%80%EC%9D%B4%EB%B0%8D</link>
            <guid>https://velog.io/@dev_1000e/JPA-1%EC%B0%A8-%EC%BA%90%EC%8B%9C-Dirty-Checking-Flush-%ED%83%80%EC%9D%B4%EB%B0%8D</guid>
            <pubDate>Fri, 20 Mar 2026 12:36:16 GMT</pubDate>
            <description><![CDATA[<ol>
<li><p><strong>영속성 컨텍스트(Persistence Context) = ‘엔티티를 관리하는 전체 작업 공간</strong></p>
<p> 이것은 어떤 특정한 메모리 자료구조 하나만을 지칭하는 것이 아니라, JPA가 엔티티 객체들을 관리하기 위해 조성해 놓은 논리적인 환경이자 시스템 전체를 의미한다. (실제 코드 상으로는 보통 하나의 <code>EntityManager</code> 당 하나의 영속성 컨텍스트가 생성된다.</p>
</li>
<li><p><strong>영속성 컨텍스트 ‘내부’의 핵심 구성 요서 3가지</strong></p>
<ul>
<li><strong>1차 캐시 (1st Level Cache):</strong> 방금 DB에서 조회했거나 새로 생성한 엔티티 객체들의 <strong>현재 상태</strong>를 저장해두는 실제 메모리 공간(<code>Map&lt;EntityUID, Entity&gt;</code> 형태)입니다. 캐시 히트를 통해 DB 접근을 줄여준다.</li>
<li><strong>스냅샷 (Snapshot):</strong> 엔티티가 1차 캐시에 처음 들어오는 순간의 <strong>최초 상태</strong>를 복사해서 따로 보관해 두는 영역이다. 트랜잭션이 끝날 때, JPA는 &#39;1차 캐시의 현재 상태&#39;와 &#39;스냅샷의 최초 상태&#39;를 비교하여 변경 사항을 감지(Dirty Checking)한다.</li>
<li><strong>쓰기 지연 SQL 저장소 (Write-behind SQL Storage):</strong> Dirty Checking을 통해 변경이 감지되었거나 새로 저장해야 할 객체가 있을 때, 당장 DB에 쿼리를 날리지 않고 SQL문들을  모아두는 큐(Queue)이다. 나중에 Flush가 발생할 때 한꺼번에 DB로 쏟아낸다.</li>
</ul>
</li>
</ol>
<aside>

<h3 id="📌1차-캐시">📌1차 캐시</h3>
<p>JPA에서 트랜잭션이 시작되면 내부에 ‘영속성 컨텍스트’라는 가상의 공간이 생긴다.
이 안에는 1차 캐시라는 Map 형태의 저장소(Key: DB PK, Value: Entity 객체)가 존재한다.</p>
<pre><code class="language-java">@Transactional
public void viewPost() {
        // 1. 처음 조회: 1차 캐시에 없으므로 DB로 SELECT 쿼리를 날린다. 
        //             후에 1차 캐시에 저장 하고 반환
        Post post1 = postRepository.findById(1L);

        // 2. 두 번째 조회 : 이미 1차 캐시에 있으므로 DB를 가지 않고 캐시에서 바로 가져온다!
        Post post2 = postRepository.findById(1L);
}</code></pre>
<ul>
<li>핵심 원리: 위 코드에서 <code>post1</code> 과 <code>post2</code>를 <code>==</code> 비교하면 true가 나온다. JPA는 같은 트랜잭션 안에서 동일한 식별자(PK)를 가진 엔티티에 대해 <strong>객체의 동일성을 보장</strong>해 주기 때문이다.</li>
<li>오해하기 쉬운 점 : 1차 캐시는 애플리케이션 전체가 공유하는 글로벌 캐시(Redis 같은 2차 캐시)가 아니다. <strong>딱 하나의 트랜잭션(하나의 사용자 요청) 
안에서만 잠깐 살아있다가 사라지는 찰나의 공간</strong>이다. 따라서 극적인 성능 향상보다는 ‘객체 지향적인 매커니즘 보장’에 더 큰 의의가 있다.</aside>

</li>
</ul>
<aside>

<h3 id="📌2-변경-감지dirty-checking-왜-update-메서드가-없을까">📌2. 변경 감지(Dirty Checking): 왜 <code>update()</code> 메서드가 없을까?</h3>
<p>게시글의 제목이나 내용을 수정할 때, JPA 코드를 보면 <code>save()</code>나 <code>update()</code> 를 명시적으로 호출하지 않는 경우가 많다. 그냥 객체의 값만 바꿨는데 알아서 UPDATE 쿼리가 날아간다.</p>
<pre><code class="language-java">@Transactional
public void updatePostTitle(Long postId, String newTitle) {
        Post post = postRepository.findById(postId); // 1차 캐시에 저장 + &#39;스냅샷&#39; 생성

        post.setTitle(newTitle); // 객체의 값만 변경

        // postRepository.save(post); &lt;- 이런 코드가 필요 없다.
} // 트랜잭션 종료 시점</code></pre>
<ul>
<li><strong>스냅샷(snapshot)</strong>: 엔티티가 1차 캐시에 처음 들어올 때, JPA는 그 최초 상태를 복사해서 ‘스냅샷’으로 보관한다.</li>
<li><strong>비교와 쿼리 생성</strong> : 트랜잭션이 끝나는 시점(Commit)에 JPA는 1차 캐시에 있는 현재 엔티티의 상태와 보관해둔 스냅샷을 싹 다 비교한다. 만약 제목이 변경 되었다면(상태가 다르면)? 그때 알아서 UPDATE SQL을 생성해 DB에 날린다.
이것이 Dirty(변경됨)를 Checking(감지)하는 원리이다.</aside>

</li>
</ul>
<aside>
📌

<p>Q : 프로젝트에는 save()가 있는데 이건 뭐지?</p>
<p>A : 결론 → updatePost는 DirtyChecking이 맞고, createPost의 save()는 반드시 필요하다.</p>
<hr>
<ul>
<li><code>save()</code> = 새로 만든 객체를 JPA에 등록할 때, 새로 생성된 것은 비영속이기 때문에 JPA가 모른다. 그래서 JPA가 관리하기 위해 save()가 필요한 것이다.</li>
<li>Dirty Checking은 이미 JPA가 관리하는 엔티티(영속 상태)에만 동작한다.
<code>new</code> 로 만든 객체는 JPA가 모르는 비영속 상태라서 save()로 등록해야 한다.</li>
<li><code>findById()</code> 같은 것들은 이미 JPA가 관리를 하기 때문에 save()가 필요하지 않다.</aside>

</li>
</ul>
<aside>

<h3 id="📌3-flush-타이밍--모아둔-쿼리를-db로-쏘는-순간">📌3. Flush 타이밍 : 모아둔 쿼리를 DB로 쏘는 순간</h3>
<p>우리는 객체를 수정하거나 생성했다고 해서 그 즉시 DB에 쿼리가 날아가는 것은 아니다. JPA는 쿼리를 ‘쓰기 지연 SQL 저장소’라는 곳에 차곡차곡 모아둔다. 이 모아둔 쿼리들을 실제 DB에 동기화하는 작업이 <strong><code>Flush(플러시)</code></strong> 이다. </p>
<p>Flush가 발생하는 타이밍은 보통 다음 세 가지이다.</p>
<ol>
<li><strong>트랜잭션 커밋(Commit) 시</strong>: (가장 일반적) 코드가 정상적으로 다 돌고 트랜잭션이 끝날 때 자동으로 발생한다.</li>
<li><strong>JPQL 쿼리 실행 시 :</strong> 만약 게시글 5개를 새로 생성(1차 캐시에만 있음)해 둔 상태에서, 갑자기 <code>SELECT * FROM post</code> 같은 JPQL을 날리면 어떻게 될까?<ul>
<li>DB에는 아직 새로 만든 5개의 데이터가 없기 때문에 조회가 안 될 것이다.</li>
<li>이런 데이터 불일치를 막기 위해, JPA는 JPQL을 실행하기 직전에 무조건 자동으로 Flush를 호출하여 DB와 상태를 동기화한다.</li>
</ul>
</li>
<li><strong>수동 호출 :</strong> <code>em.flush()</code> 를 직접 호출할 때. (테스트 코드 작성할 때 외에는 실무에서 직접 쓸 일은 거의 없다)</li>
</ol>
<ul>
<li>주의할 점 : Flush는 1차 캐시를 비우는 것이 아니다! 단순히 변경 내용(생성/수정/삭제)을 DB에 반영(SQL 전송)할 뿐, 1차 캐시는 트랜잭션이 끝날 때까지 그대로 유지된다.</aside>

</li>
</ul>
<aside>

<p>Q : 만약 게시글의 제목을 수정했는데(<code>post.setTitle()</code>), 메서드가 끝나기 전에 예상치 못한 에러가 발생해서 트랜잭션이 롤백(Rollback)된다면, DB의 데이터와 1차 캐시의 상태는 각각 어떻게 될까?</p>
<p>A : 트랜잭션이 커밋되기 전에 롤백되었으므로 DB에는 아무런 변화가 없다.
하지만 1차 캐시에 대해서는 초기 상태로 되돌아가는 것이 아니라, 그냥 파괴(clear)되어 버린다.</p>
<ul>
<li><strong>영속성 컨텍스트 초기화 :</strong> 대신 JPA 데이터 정합성이 깨졌다고 판단하고, 트랜잭션을 롤백함과 동시에 영속성 컨텍스트 자체를 싹 비워버리거나 종료해버린다.</li>
<li><strong>준영속 상태(Detached) :</strong> 그 결과, 아까 수정했던 <code>post</code> 객체는 더 이상 JPA의 관리를 받지 못하는 준영속 상태가 된다.</li>
</ul>
<hr>
</aside>

<aside>

<p>Q : 1차 캐시에 대해서 설명할 때, <code>post1</code> 과 <code>post2</code>는 서로 다른 인스턴스 같은데 <code>==</code> 비교하면 true가 나온다고 했는데 Java에서는 서로 다른 인스턴스는 메모리 주소 할당이 서로 다르기 때믄에 == 비교하면 false가 나와서 equals()를 쓰는 걸로 알고있는데 이건 왜 이런거지?</p>
<p>A : 말한대로 자바에서 <code>new</code> 키워드로 각각 생성한 서로 다른 인스턴스는 메모리 주소가 다르기 때문에 <code>==</code> 로 비교하면 <code>false</code>가 나오는 것이 맞다.
그런데 JPA의 1차 캐시에서 post1 == post2가 <code>true</code> 가 나오는 이유는, post1과 post2 가 서로 다른 인스턴스가 아니기 때문이다. 완벽히 동일한 하나의 메모리 주소를 가리키고 있다.</p>
<p>작동 원리를 순서대로 보면 이렇다.</p>
<ol>
<li><strong>첫 번째 조회 (<code>post1</code>):</strong> <code>findById(1L)</code>을 호출하면 JPA는 1차 캐시(내부적으로 <code>Map&lt;Object, Object&gt;</code> 형태)를 뒤져본다. 비어있으니 DB에 <code>SELECT</code> 쿼리를 날려 데이터를 가져온다.</li>
<li><strong>인스턴스 생성 및 캐시 저장:</strong> 가져온 데이터로 <code>new Post()</code>를 해서 자바 객체를 하나 만든 다음, 1차 캐시에 <code>Key: 1L, Value: 방금 만든 Post 인스턴스의 메모리 주소</code> 형태로 저장한다. 그리고 <code>post1</code> 변수에 그 주소를 준다.</li>
<li><strong>두 번째 조회 (<code>post2</code>):</strong> 다시 <code>findById(1L)</code>을 호출한다. JPA가 1차 캐시를 확인해 보니 <code>1L</code>이라는 Key가 이미 있다.</li>
<li><strong>캐시 히트(Cache Hit):</strong> DB에 가지 않고, <strong>1차 캐시에 저장되어 있던 아까 그 <code>Post</code> 인스턴스의 메모리 주소를 그대로 <code>post2</code>에게 던져줍니다.</strong></li>
</ol>
<p>결과적으로 <code>post1</code>과 <code>post2</code>는 힙(Heap) 메모리에 떠 있는 <strong>단 하나의 동일한 객체</strong>를 쳐다보고 있는 쌍둥이 참조 변수일 뿐이다. 그래서 <code>==</code> 비교 시 메모리 주소가 같으므로 <code>true</code>가 반환됩니다. JPA는 이를 통해 애플리케이션 레벨에서 &#39;동일성(Identity) 보장&#39;이라는 엄청난 장점을 제공한다.</p>
</aside>

<hr>
<h3 id="정리">정리</h3>
<p>영속성 컨텍스트 안에는 1차 캐시와 스냅샷이 존재.</p>
<ol>
<li>비영속(New) <ul>
<li>상태 : 방금 new 키워드로 생성된 순수한 자바 객체</li>
<li>위치 : 영속성 컨텍스트 바깥에 위치. 당연히 1차 캐시에도 없음</li>
<li>JPA 추적 : X (JPA는 이 객체의 존재 자체를 모름. 값을 아무리 바꿔도 DB에 영향X)</li>
<li>코드 : <code>Post pst = new Post(”새 글”);</code></li>
</ul>
</li>
<li>영속(Managed)<ul>
<li>상태: 객체가 영속성 컨텍스트 안으로 들어와 정식 관리를 받는 상태</li>
<li>위치 : 영속성 컨텍스트 내부의 1차 캐시에 이름(PK)과 현재 상태가 등록되고, 동시에 스냅샷에 들어올 때의 최초 모습이 복사되어 안전하게 보관</li>
<li>JPA 추적 : O (트랜잭션이 끝날 떄, JPA가 1차 캐시와 스냅샷을 비교해서 다르면 UPDATE 쿼리를 날린다. 즉, 변경 감지가 동작하는 유일한 상태)</li>
<li>코드 : <code>em.persist(post);</code> (저장) 또는 <code>postRepository.findById(1L);</code> (조회해서 영속성 컨텍스트로 끌고 옴)</li>
</ul>
</li>
<li>준영속(Detached)<ul>
<li>상태 : 한때는 영속 상태였지만, 지금은 관리가 끊긴 상태</li>
<li>위치 : 객체 자체는 자바 메모리에 살아있지만, 1차 캐시와 스냅샷에서는 기록이 완전히 삭제 되었다.(DB에 데이터가 남아있든 말든 상관없다)</li>
<li>JPA 추적 : X (영속성 컨텍스트에서 지워졌으므로, 이제 객체의 값을 바꿔도 JPA 관리를 받지 않기 때문에 변경 감지가 작동되지 않는다)</li>
<li>발생 시점 : 트랜잭션이 끝나서 영속성 컨텍스트 자체가 파괴될 때</li>
</ul>
</li>
<li>삭제(Removed)<ul>
<li><strong>상태:</strong> 관리소 안에는 있지만, &#39;삭제 예정&#39; 딱지가 붙은 상태입니다.</li>
<li><strong>위치:</strong> 여전히 영속성 컨텍스트 안의 1차 캐시에 있지만, 트랜잭션이 끝날 때 DB에 <code>DELETE</code> 쿼리를 날리기로 예약되어 있습니다.</li>
<li><strong>코드:</strong> <code>em.remove(post);</code> 또는 <code>postRepository.delete(post);</code></li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Service 계층 단위 테스트 작성 가이드]]></title>
            <link>https://velog.io/@dev_1000e/Service-%EA%B3%84%EC%B8%B5-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%91%EC%84%B1-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@dev_1000e/Service-%EA%B3%84%EC%B8%B5-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%9E%91%EC%84%B1-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Fri, 20 Mar 2026 12:30:47 GMT</pubDate>
            <description><![CDATA[<p>서비스 계층의 단위 테스트는 <strong>“이 메서드가 주어진 상황에서 의도한 대로 동작하는가?”</strong>를 검증하는 과정이다. DB나 외부 API에 의존하지 않고, Mock(가짜 객체)을 활용하여 비즈니스 로직 자체만 집중해서 테스트하는 것이 핵심이다.</p>
<aside>


<h3 id="📌-1-테스트할-타겟-메서드-target-method">📌 1. 테스트할 타겟 메서드 (Target Method)</h3>
<p>먼저 우리가 테스트할 <code>createPost()</code> (게시글 작성) 메서드를 살펴보자.</p>
<pre><code class="language-java">public PostResponse createPost(CustomUserDetails userDetails, PostCreateRequest request) {
    // 1. 회원 조회 (없으면 예외 발생)
    Member member = memberRepository.findById(userDetails.getId())
            .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));

    // 2. 게시글 엔티티 생성
    Post post = Post.builder()
            .member(member)
            .title(request.getTitle())
            .content(request.getContent())
            .build();

    // 3. DB 저장 및 응답 DTO 반환
    return PostResponse.from(postRepository.save(post));
}</code></pre>
<p>이 메서드를 테스트하려면 크게 두 가지 상황(Case)을 고려해야 한다.</p>
<ol>
<li><strong>성공 Case :</strong> 회원이 정상적으로 존재하고, 게시글이 성공적으로 저장되는 경우</li>
<li><strong>실패 Case :</strong> 요청한 회원의 ID가 DB에 존재하지 않아 예외가 발생하는 경우</aside>

</li>
</ol>
<hr>
<aside>

<h3 id="📌-2-성공-case--게시글-정상-생성">📌 2. 성공 Case : 게시글 정상 생성</h3>
<p><code>Given</code> 에서는 <code>createPost()</code> 메서드 실행에 필요한 재료들을 먼저 준비해 둔다! 필요한 인자(<code>CustomUserDetails</code>, <code>PostCreateRequest</code>)를 만들고, Mock 객체들이 어떻게 행동할지(<code>given()</code>) 미리 정의해 준다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;게시글 생성 성공&quot;)
void createPost_성공() {
    // given
    PostCreateRequest request = new PostCreateRequest(&quot;새 제목&quot;, &quot;새 내용&quot;);

    // memberRepository.findById()가 호출되면 가짜 member 객체를 반환하도록 설정
    given(memberRepository.findById(1L)).willReturn(Optional.of(member));
    // postRepository.save()가 호출되면 가짜 post 객체를 반환하도록 설정
    given(postRepository.save(any(Post.class))).willReturn(post);

    // when (실제 테스트할 메서드 실행)
    PostResponse result = postService.createPost(userDetails, request);

    // then (결과 검증)
    assertThat(result.getTitle()).isEqualTo(&quot;새 제목&quot;);
    assertThat(result.getContent()).isEqualTo(&quot;새 내용&quot;);

    // save() 메서드가 실제로 1번 호출되었는지 검증 (중요!)
    verify(postRepository, times(1)).save(any(Post.class));
}</code></pre>
<p><strong>해설</strong></p>
<ul>
<li><strong>준비 (Given)</strong> : 서비스 로직 안에서 <code>findById</code>와 <code>save</code> 가 호출될 때 DB까지 가지 않도록, “위 메서드(<code>findById()</code>, <code>save()</code>)가 호출되면 이거 반환해!”라고 Mock 객체에 지시를 내린다.</li>
<li><strong>실행 (When) :</strong> 실제 서비스 메서드를 실행한다.</li>
<li><strong>검증 (Then) :</strong> 반환된 <code>PostResponse</code> 의 데이터가 내가 요청한 데이터와 일치하는지(<code>assertThat</code>) 확인하고, 저장 로직인 <code>save()</code> 가 빼먹지 않고 잘 호출되었는지(<code>verify</code>) 확인한다.</aside>

</li>
</ul>
<hr>
<aside>

<h3 id="📌-3-실패-case--회원이-존재하지-않을-때">📌 3. 실패 Case : 회원이 존재하지 않을 때</h3>
<p>실패 Case에서는 비정상적인 상황을 가정한다. 즉, DB에서 회원을 찾았는데 결과가 없는 상황(<code>Optional.empty()</code>)을 강제로 만들어주고, 우리가 기대한 예외(<code>CustomException</code>)가 제대로 터지는지 확인한다.</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;게시글 생성 시 없는 회원이면 예외 발생&quot;)
void createPost_없는회원_예외() {
    // given
    PostCreateRequest request = new PostCreateRequest(&quot;새 제목&quot;, &quot;새 내용&quot;);

    given(memberRepository.findById(1L)).willReturn(Optional.empty());

    // when &amp; then
    // 없는 회원으로 createPost를 호출하면 CustomException이 발생해야 한다!
    assertThatThrownBy(() -&gt; postService.createPost(userDetails, request))
            .isInstanceOf(CustomException.class);

    verify(postRepository, never()).save(any(Post.class));
}</code></pre>
<p><strong>해설</strong> </p>
<ul>
<li><strong>준비 (Given):</strong> <code>findById</code>가 <code>Optional.empty()</code>를 반환하도록 세팅하여 예외 상황의 조건을 만든다.</li>
<li><strong>실행 및 검증 (When &amp; Then):</strong> 예외 테스트는 <code>assertThatThrownBy</code>를 사용하면 깔끔하다. 실행 시 우리가 지정한 <code>CustomException</code>이 발생하는지 검증하다.</li>
<li><strong>추가 검증:</strong> 앞에서 예외가 발생했기 때문에, 그 아래에 있는 게시글 저장 로직(<code>save</code>)은 <strong>절대 실행되면 안 된다.</strong> <code>never()</code>를 사용해 이를 명확히 보장해 준다.</aside></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[F-Lab 멘토링, 자바 백엔드 코스를 통해 성장한 나의 이야기]]></title>
            <link>https://velog.io/@dev_1000e/F-Lab-%EB%A9%98%ED%86%A0%EB%A7%81-%EC%9E%90%EB%B0%94-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%BD%94%EC%8A%A4%EB%A5%BC-%ED%86%B5%ED%95%B4-%EC%84%B1%EC%9E%A5%ED%95%9C-%EB%82%98%EC%9D%98-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@dev_1000e/F-Lab-%EB%A9%98%ED%86%A0%EB%A7%81-%EC%9E%90%EB%B0%94-%EB%B0%B1%EC%97%94%EB%93%9C-%EC%BD%94%EC%8A%A4%EB%A5%BC-%ED%86%B5%ED%95%B4-%EC%84%B1%EC%9E%A5%ED%95%9C-%EB%82%98%EC%9D%98-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Fri, 26 Sep 2025 13:28:18 GMT</pubDate>
            <description><![CDATA[<p>취업 준비를 막 시작했을 때 저는 방향을 전혀 잡지 못한 상태였습니다. 무엇을 공부해야 할지, 어떻게 준비해야 할지 감이 잡히지 않았고, 그냥 기술을 배우고 프로젝트를 따라 만드는 게 전부라고 생각했습니다. 그러다 F-Lab 멘토링을 알게 되었고, 검증된 멘토님과 함께라면 시행착오를 줄이고 확실한 길을 잡을 수 있겠다는 생각으로 멘토링을 시작하게 되었습니다.
멘토링 전과 후를 비교하면 제 생각의 깊이가 완전히 달라졌습니다. 예전에는 단순히 새로운 기술을 배우고 적용하는 데에만 집중했지만, 지금은 ‘왜 이 기술을 선택해야 하는지’, ‘이 코드를 작성해야 하는 이유가 무엇인지’ 스스로 질문하고 답을 찾을 수 있게 되었습니다. 기술 습득에서 멈추지 않고, 그 기술이 실제 비즈니스나 서비스 상황에서 어떤 의미를 가지는지 고민하는 힘을 얻게 된 것이 가장 큰 변화였습니다.
면접 준비 과정에서도 멘토님의 조언은 큰 도움이 되었습니다. “제가 면접관이라 생각하고, 지금 질문에 대한 답을 직접 고민해보세요.”라는 멘토님의 말씀이 특히 기억에 남습니다. 단순히 지식을 외우는 것이 아니라, 실전처럼 사고하고 답변하는 훈련을 반복하면서 점점 자신감이 붙었습니다. 그 결과, 현재 취업난에도 불구하고 핀테크 회사와 몇몇 SI 업체로부터 면접 제의를 받을 수 있었고, 실제로 금융 관련 프로젝트 경험이 큰 강점으로 작용했습니다. 금융 도메인에 대한 이해도를 멘토링 과정에서 많이 쌓을 수 있었기에 가능했던 결과라고 생각합니다.
최종적으로 저는 정부와 대기업의 프로그램을 개발하는 회사에 입사하게 되었습니다. 아직 신입 인턴 단계이지만, 다양한 프로젝트와 업무를 경험하며 시야를 넓힐 수 있다는 점에서 만족도가 매우 높습니다. 무엇보다 멘토링 과정에서 쌓은 학습 습관과 문제 해결 방식이 실제 업무에 그대로 이어져, 빠르게 적응할 수 있었습니다.
프로젝트 퀄리티 역시 만족스러웠습니다. 제 꿈은 금융 서비스 회사에 입사하는 것인데, 멘토님 또한 금융 관련 업무 경험이 풍부하셨기 때문에 현실적인 조언을 많이 들을 수 있었습니다. 단순히 “이 기능을 구현하면 끝”이 아니라, “만약 금융 서비스가 갑자기 멈춘다면 어떻게 보상할 것인가?” 같은 실제 서비스 관점에서 생각을 확장할 수 있었습니다. 이런 훈련 덕분에 프로젝트의 깊이가 더해졌고, 결과적으로 제 포트폴리오에 자신감을 가질 수 있었습니다.
멘토링의 가장 큰 장점은 방향을 잃고 헤매지 않도록 실시간으로 피드백을 받을 수 있다는 점입니다. 필요할 때는 DM으로도 바로 조언을 받을 수 있었고, 무엇보다 멘토님들이 이미 실무에서 검증된 분들이라 신뢰할 수 있었습니다. 또, 스타일이 자신과 맞지 않다면 F-Lab 측에서 멘토님도 변경해주십니다! 다만 아쉬운 점을 꼽자면, 취준생이나 이제 막 주니어 단계에 들어선 분들에게는 가격대가 다소 부담될 수 있다는 점입니다. 하지만 제가 얻은 성장과 결과를 생각하면 충분히 가치 있는 투자였다고 확신합니다.
F-Lab 멘토링은 저에게 단순히 기술을 배우는 자리가 아니라, 개발자로서 사고하는 힘을 길러준 과정이었습니다. 지금 이 글을 읽는 누군가가 방향을 잡지 못해 고민하고 있다면, 저처럼 F-Lab 멘토링을 통해 길을 찾고 더 넓은 세상으로 나아갈 수 있기를 바랍니다.
<img src="https://velog.velcdn.com/images/dev_1000e/post/81be9eab-6ba6-427b-add8-3ea9cfbb25e3/image.png" alt=""> (멘토링 중 Gradle Multi-Module 코드 리뷰를 받는..)</p>
<p>제가 들었던 코스 링크입니다. <a href="https://f-lab.kr/mentoring-courses/java-backend">https://f-lab.kr/mentoring-courses/java-backend</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring에서 서버 예외를 프론트로 일관된 포맷으로 보내기 - 전역 예외 처리]]></title>
            <link>https://velog.io/@dev_1000e/%EB%B0%B1%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EB%A1%9C-%EA%B3%B5%ED%86%B5-%EC%97%90%EB%9F%AC-%ED%8F%AC%EB%A7%B7-%EB%82%B4%EB%A0%A4%EC%A3%BC%EA%B8%B0</link>
            <guid>https://velog.io/@dev_1000e/%EB%B0%B1%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EB%A1%9C-%EA%B3%B5%ED%86%B5-%EC%97%90%EB%9F%AC-%ED%8F%AC%EB%A7%B7-%EB%82%B4%EB%A0%A4%EC%A3%BC%EA%B8%B0</guid>
            <pubDate>Sat, 13 Sep 2025 06:21:35 GMT</pubDate>
            <description><![CDATA[<h1 id="문제-상황">문제 상황</h1>
<p>전부터 계속 백엔드 서버만 개발을 해오다 보니, 프론트엔드와 협업 또는 독자적인 프론트 공부를 한 적이 없었습니다. 하지만 이번 인턴 생활을 하던 도중 과제에 프론트엔드도 구현하라는 요구사항이 있었습니다.
프론트 쪽은 일단 AI 에이전트 도움을 받아가면서 구현 및 공부를 했는데, 프론트와 백엔드를 연동하면서 서버에서 발생한 문제를 <code>Http Status</code>와 <code>Custom Error Message</code>를 사용자 화면에 보여주게 하고 싶었는데, 백엔드 서버만 만들 때 처럼 Service 계층에서 특정 로직이 발생했을 때 </p>
<pre><code class="language-java">throw new IllgerArgumentException(&quot;중복된 이메일입니다&quot;);</code></pre>
<p>위 코드처럼 오류를 처리해왔었습니다. 하지만 프론트와 연동을 시작하니 문제가 생겼습니다. 저는 &quot;중복된 이메일입니다&quot;라는 메시지를 프론트 alert에 보여주고 싶었지만 실제 응답은 이랬습니다.</p>
<pre><code class="language-json">{
  &quot;timestamp&quot;: &quot;...&quot;,
  &quot;status&quot;: 500,
  &quot;error&quot;: &quot;Internal Server Error&quot;,
  &quot;path&quot;: &quot;/api/users&quot;
}</code></pre>
<p>그래서 이번 글은 왜 우리가 서버에서 메시지를 담아서 오류가 나도 프론트에서는 그 메시지가 보이지 않았을까? 이걸 해결하려면 어떻게 해야할까?에 대하여 글을 작성하겠습니다.</p>
<hr>
<h1 id="문제-해결-과정">문제 해결 과정</h1>
<p>위에 말로만 설명해서 감이 잘 안 오셨을 수도 있을텐데 일단 이해를 돕기위한 간단한 코드 예제를 보면서 진행해보겠습니다.</p>
<pre><code class="language-java">// Controller
@RestController
@RequestMapping(&quot;/api/users&quot;)
@RequiredArgsConstructor
public class Controller {

    private final UserService service;

    @PostMapping
    public ResponseEntity&lt;?&gt; register(@RequestBody RequestDto request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.register(request));
    }
}

// Service 
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    @Transactional
    public ResponseDto register(RequestDto request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new IllegalArgumentException(&quot;중복된 이메일 입니다.&quot;);
        }

        User user = User.create(request);
        userRepository.save(user);

        return ResponseDto.of(user.getEmail(), user.getName());
    }
}</code></pre>
<p>일단 코드를 보시면 바로 유저의 가입 API라는 것이 보이실텐데 이 코드를 실행해서 Postman으로 통해 최초 가입하고 이후 똑같은 이메일로 가입을 다시 해보겠습니다. 
<img src="https://velog.velcdn.com/images/dev_1000e/post/4ecc20f1-1d64-4d2d-9171-e6d82b040bdc/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/dev_1000e/post/11c5bc43-2909-436c-86dd-56b7c50b9434/image.png" alt=""></p>
<p>혹시 차이가 보이실까요? 저희는 프론트엔드와 작업을 하기 위해서는 JSON을 통해 프론트에서 알기 쉽게 데이터를 가공하여 보여줘야 합니다. 물론 오류도 마찬가지입니다. 
하지만 위에를 보면 저희 서버 콘솔에만 Service에서 작성한 오류 메세지가 보이고 Postman으로 API 요청을 통해 받은 응답에는 JSON이긴 하지만 저희가 작성한 오류 메시지가 보이지 않고, 그냥 Spring에서 기본적으로 발생시키는 오류를 JSON 형태로 올려보내서 500 Internal Server Error가 나타나는 것입니다.</p>
<h2 id="왜-이런-일이-발생했을까">왜 이런 일이 발생했을까?</h2>
<p>현재 코드로 <code>중복 이메일 가입 시도</code> 흐름을 보겠습니다.</p>
<h3 id="1-service에서-예외-발생-→-컨트롤러-응답까지-가지-못함">1. Service에서 예외 발생 → 컨트롤러 응답까지 가지 못함</h3>
<p>(1) Tomcat이 HTTP 요청 수신 → <code>DispatcherServlet의 doDispatch()</code>로 진입 합니다.
<strong>(2) 핸들러 탐색</strong>
HandlerMapping이 /api/users POST와 매칭되는 컨트롤러 메서드(Controller#register)를 찾습니다.
<strong>(3) 컨트롤러 실행</strong>
<code>HandlerAdapter</code>가 컨트롤러 메서드 호출.
@RequestBody로 JSON → RequestDto 바인딩 완료
<strong>(4) 서비스 호출</strong>
service.register(request) 진입 → existsByEmail(email) 호출 → true 반환(이미 등록됨)
<strong>(5) 예외 발생</strong></p>
<pre><code class="language-java">@PostMapping
    public ResponseEntity&lt;?&gt; register(@RequestBody RequestDto request) {
        ResponseDto response = service.register(request); → 예외 발생!
        return ResponseEntity.status(HttpStatus.CREATED).body(response); → 코드 실행 X
    }</code></pre>
<p><code>throw new IllegalArgumentException(&quot;중복된 이메일 입니다.&quot;);</code>
여기서 컨트롤러 메서드의 <code>return ResponseEntity.status(CREATED)...</code> 라인은 실행되지 못하고 중단됩니다.(스택이 예외로 튀어나감)</p>
<hr>
<h3 id="2--dispatcherservlet의-예외-처리-흐름">2.  DispatcherServlet의 예외 처리 흐름</h3>
<p><strong>(1) 예외 전파 → DispatcherServlet 예외 처리 단계</strong>
doDispatch()가 예외를 잡아 processHandlerException()으로 넘깁니다. HandlerExceptionResolver 리스트를 순회합니다.
    - <code>ExceptionHandlerExceptionResolver</code> → @ExceptionHandler 있는지 확인
    - <code>ResponseStatusExceptionResolver</code> → @ResponseStatus 붙어있는지 확인
    - <code>DefaultHandlerExceptionResolver</code> → MVC 표준 예외 처리
하지만 IllegalArgumentException은 어느 곳에서도 처리되지 않습니다 → 결국 Null 반환</p>
<hr>
<h3 id="3-컨테이너의-에러-디스패치">3. 컨테이너의 에러 디스패치</h3>
<p><strong>(1)컨테이너 에러 디스패치</strong>
모든 Resolver가 패스하면 예외는 ServletContainer로 전파됩니다.→ 이를 서블릿 컨테이너가 ERROR 디스패치를 수행, /error로 forward 합니다.
<strong>(2) Spring Boot의 BasicErrorController 동작</strong>
컨테이너가 실어 준 javax.servlet.error.* 속성을 ErrorAttributes로 읽어 기본 에러 맵(JSON/HTML) 생성합니다.
Content Negotiation 결과 JSON 선호면 기본 JSON 에러 바디(timestamp/status/error/message/path 등)로 응답합니다.</p>
<pre><code class="language-JSON">{
  &quot;timestamp&quot;: &quot;2025-09-13T00:30:12.345+09:00&quot;,
  &quot;status&quot;: 500,
  &quot;error&quot;: &quot;Internal Server Error&quot;,
  &quot;path&quot;: &quot;/api/users&quot;
}</code></pre>
<p>위 코드가 500 기본 JSON 에러 포맷입니다.</p>
<hr>
<h2 id="그럼-어떻게-이-방법을-해결할까--전역-예외-처리기-도입">그럼 어떻게 이 방법을 해결할까? : 전역 예외 처리기 도입</h2>
<p>Spring에서 <code>@RestControllerAdvice</code>와 <code>@ExceptionHandler</code>를 통해 발생한 예외를 한 곳에서 모아 처리할 수 있습니다.
이를 기반으로 <code>CustomException</code>과 <code>ErrorCode</code>를 정의하고, <code>ErrorResponse</code>로 변환해 내려주는 구조를 만듭니다.</p>
<h3 id="코드-구성과-동작-원리">코드 구성과 동작 원리</h3>
<ol>
<li><p>ErrorCode - 에러 사전 정의</p>
<pre><code class="language-java">@AllArgsConstructor
@Getter
public enum ErrorCode {
 NOT_FOUND_EMAIL(HttpStatus.NOT_FOUND, &quot;U001&quot;,&quot;없는 이메일 입니다.&quot;);

 private final HttpStatus status;
 private final String code;
 private final String message;
}</code></pre>
</li>
</ol>
<ul>
<li>HttpStatus : Http 상태코드 (404, 400, 409 등)</li>
<li>code : 서비스 내부에서 정의한 식별 코드(U001, A001 등)</li>
<li>message : 사용자에게 보여줄 메시지</li>
</ul>
<hr>
<ol start="2">
<li><p>CustomException - 예외 객체</p>
<pre><code class="language-java">@Getter
public class CustomException extends RuntimeException{
 private final ErrorCode errorCode;

 public CustomException(ErrorCode errorCode) {
     super(errorCode.getMessage()); // RuntimeException.message에 저장
     this.errorCode = errorCode;
 }
}</code></pre>
</li>
</ol>
<ul>
<li>개발자가 예외를 던질 때는 throw new CustomException(ErrorCode.NOT_FOUND_EMAIL)처럼 사용 가능합니다.</li>
<li>내부적으로 ErrorCode를 품고 있어 예외가 발생했을 때 상태/코드/메시지를 함께 전달 가능 합니다.</li>
</ul>
<hr>
<ol start="3">
<li><p>ErrorResponse - 응답 DTO</p>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public class ErrorResponse {
 private final int status;
 private final String code;
 private final String message;

 public static ErrorResponse of(ErrorCode errorCode) {
     return new ErrorResponse(
         errorCode.getStatus().value(),
         errorCode.getCode(),
         errorCode.getMessage()
     );
 }
}</code></pre>
</li>
</ol>
<ul>
<li>클라이언트로 내려가는 JSON 구조를 명확히 고정합니다.</li>
<li>ErrorCode를 받아 바로 변환 가능합니다.</li>
</ul>
<hr>
<ol start="4">
<li><p>GlobalExceptionHandler - 전역 예외 처리기</p>
<pre><code class="language-java">@RestControllerAdvice
public class GlobalExceptionHandler {

 @ExceptionHandler(CustomException.class)
 public ResponseEntity&lt;ErrorResponse&gt; handleCustomException(CustomException e, HttpServletRequest request) {
     ErrorCode errorCode = e.getErrorCode();
     ErrorResponse errorResponse = ErrorResponse.of(errorCode);

     return ResponseEntity.status(errorCode.getStatus()).body(errorResponse);
 }

 @ExceptionHandler(Exception.class)
 public ResponseEntity&lt;ErrorResponse&gt; handleEtc(Exception e) {
     e.printStackTrace();
     return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
             .body(new ErrorResponse(500, &quot;S001&quot;, &quot;서버 오류가 발생했습니다.&quot;));
 }
}</code></pre>
</li>
</ol>
<ul>
<li>CustomException 처리<ul>
<li>@ExceptionHandler(CustomException.class)가 잡는다</li>
<li>ErrorCode를 꺼내 ErrorResponse로 변환</li>
<li>ResponseEntity로 상태코드와 함께 반환</li>
</ul>
</li>
<li>그 외 모든 예외 처리<ul>
<li>Exception 핸들러에서 500 상태로 통일</li>
</ul>
</li>
</ul>
<hr>
<ol start="5">
<li>그럼 위 코드를 조합한 후 어떻게 동작하는지?
위에서 설명한 <code>processHandlerException()</code>에서 <code>예외 resolver 체인 순회</code>를 한다는 것을 기억할 것입니다.
스프링 컨테이너에 등록된 <code>HandlerExceptionResolver 리스트</code>를 순서대로 호출합니다.
그럼 첫 번째로 <code>ExceptionHandlerExcetpionResolver</code>를 먼저 호출하는데 <code>@ExceptionHandler 메서드</code>를 갖는 컨트롤러/@ControllerAdvice(= 우리가 만든 @RestControllerAdvice)를 스캔해, 현재 예외와 매칭되는 메서드를 찾습니다. 일단 저희의 오류는 바로 매칭이 됩니다.
이후 메서드 호출을 준비 → 핸들러 메서드 실행(<code>ErrorResponse</code>를 만들고 <code>ResponseEntity</code>로 감싸서 반환) → 리턴 값 처리(<code>ErrorResponse</code>를 <code>HttpMessageConverter</code>에게 전달되어 JSON 직렬화) 하여 프론트에게 JSON 형태로 에러 포맷을 보여줍니다.</li>
</ol>
<hr>
<ol start="6">
<li>적용 후 확인<pre><code class="language-java">UserService.register()
...
if (userRepository.existsByEmail(request.getEmail())) {
         throw new CustomException(ErrorCode.EMAIL_ALREADY_IN_USE); → 변경
     }
...</code></pre>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dev_1000e/post/c460bc30-2183-4146-b7bf-04edeab76bd9/image.png" alt=""></p>
<p>이로써 프론트는 언제나 response.data.message로 사용자 메시지를 안전하게 꺼낼 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mockito로  Service UnitTest Code 작성하기]]></title>
            <link>https://velog.io/@dev_1000e/Mockito%EB%A1%9C-Service-UnitTest-Code-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dev_1000e/Mockito%EB%A1%9C-Service-UnitTest-Code-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 30 Jul 2025 08:39:25 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번 글에서는 실제 프로젝트 서비스 중 <code>Create()</code> 로직을 <strong>Mockito</strong>를 활용하여 <strong>Unit Test</strong>를 진행하는 과정을 보여드리겠습니다. Mockito를 사용한 유닛 테스트는 저에게도 낯설었기 때문에 직접 코드를 작성하면서 어려웠던 부분과 헷갈렸던 부분을 공유하며 자세히 설명하고자 합니다.
<strong>참고)</strong> 이 글은 테스트 코드 작성 경험이 한 번이라도 있으신 분들을 위한 내용입니다.</p>
</blockquote>
<hr>
<h3 id="들어가기-전">들어가기 전</h3>
<p>일단 테스트 코드를 작성하기 전에 왜 <strong>Mockito</strong>를 활용하여 <strong>UnitTest</strong>를 진행하는지에 대한 필요성을 알고 들어갑시다.</p>
<p>먼저 유닛 테스트는 소프트웨어의 가장 작은 단위, <code>즉 하나의 메서드나 클래스가 예상대로 동작하는지 검증하는 과정</code>입니다. 핵심은 테스트 대상이 외부 환경이나 다른 코드의 영향을 받지 않고 <strong>완전히 독립적인 상태</strong>에서 테스트 되어야 합니다.</p>
<blockquote>
<p><strong>Isolated</strong> : Unit tests are standalone, can run in isolation, and have no dependencies on any outside factors, such as a file system or database.
<a href="https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices#:~:text=Isolated%3A%20Unit%20tests%20are%20standalone%2C%20can%20run%20in%20isolation%2C%20and%20have%20no%20dependencies%20on%20any%20outside%20factors%2C%20such%20as%20a%20file%20system%20or%20database.">MS Test 공식 문서</a></p>
</blockquote>
<p>하지만 <code>Service</code> 계층의 메서드는 보통 <code>Repository</code>나 다른 <code>Service</code> 등 외부 의존성을 가집니다. 예를 들어 저의 프로젝트 <code>Service 계층</code>에서, <code>ContractService</code>의 <code>createContract()</code> 메서드는 <code>ContractRepository</code>를 사용하여 데이터를 조회하거나 저장합니다.</p>
<pre><code class="language-java">ContractService.java

private final ContractRepository contractRepository; // 외부 의존성

 @Transactional
 public Contract createContract(ContractCreateRequest request) {
        Optional&lt;Contract&gt; checkDuplicateContract = contractRepository.findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
                request.getBorrowerPhoneNumber(),
                request.getPrincipal(),
                request.getRepaymentDate(),
                ContractStatus.getActiveStatuses()
        );

        if (checkDuplicateContract.isPresent()) {
            throw new IllegalArgumentException(&quot;중복된 계약이 존재합니다.&quot;);
        }

        Contract contract = Contract.create(request);

        // 추후 DTO 변환해서 반환, 현재까지는 엔티티 반환 유지
        return contractRepository.save(contract);
    }
</code></pre>
<p>만약 <code>contractRepository</code>가 실제 DB에 연결되어 있다면, 테스트는 느려질 수 밖에 없을 것입니다.
그래서 <strong>Mockito</strong>가 등장하는데 <strong>Mockito</strong>는 이러한 외부 의존성 객체들을(여기서는 <code>contractRepository</code>) <strong>Mock Object</strong>로 만들어 줍니다. 이 <strong>Mock Object</strong>들에게 어떠한 메소드가 호출되면 이런 값을 반환해달라고 설정할 수 있고, 예상대로 호출되었는지 확인도 할 수 있습니다.</p>
<hr>
<h3 id="createcontract-성공-케이스-테스트-코드">createContract() 성공 케이스 테스트 코드</h3>
<p>이제 <code>ContractService</code>의 <code>createContract()</code> 메서드가 정상적으로 동작할 때를 가정한 성공 케이스 테스트 코드를 살펴보겠습니다,</p>
<pre><code class="language-java">Service CreateContract().test

@ExtendWith(MockitoExtension.class)
public class ContractServiceTest {

    @Mock 
    private ContractRepository contractRepository;

    @InjectMocks
    private ContractService contractService; 

    @Test
    @DisplayName(&quot;계약 생성 성공 - 유효한 요청&quot;)
    void createContract_success() {
        //Given
        ContractCreateRequest request = ContractCreateRequest.builder()
                .borrowerPhoneNumber(&quot;010-9665-1195&quot;)
                .principal(BigDecimal.valueOf(100000))
                .repaymentDate(LocalDate.of(2025, 8, 30))
                .build();

        List&lt;ContractStatus&gt; activeStatuses = ContractStatus.getActiveStatuses();

        when(contractRepository.findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
                anyString(),
                any(BigDecimal.class),
                any(LocalDate.class),
                eq(activeStatuses)
        )).thenReturn(Optional.empty());
        //쉽게 이해하자면 createContract()가 내부에서 사용하는 DB 접근 로직(findBy.., save)은 실제로 동작하지 않기 때문에 그 동작을 이렇게 반응해라 고 미리 설정해줘야 정상 흐름으로 테스트가 진행된다.

        when(contractRepository.save(any(Contract.class)))
                .thenAnswer(invocation -&gt; {
                    Contract contract = invocation.getArgument(0);
                    return Contract.builder()
                            .id(&quot;test-contract-id-&quot; + contract.getBorrowerPhoneNumber())
                            .borrowerPhoneNumber(contract.getBorrowerPhoneNumber())
                            .principal(contract.getPrincipal())
                            .repaymentDate(contract.getRepaymentDate())
                            .status(ContractStatus.PENDING_AGREEMENT)
                            .createdAt(LocalDateTime.now())
                            .updatedAt(LocalDateTime.now())
                            .build();
                });

        // When
        Contract createContract = contractService.createContract(request);

        // then
        assertNotNull(createContract, &quot;생성된 계약은 null이 아니어야 합니다.&quot;);
        assertNotNull(createContract.getId(), &quot;생성된 계약의 ID는 null이 아니어야 합니다.&quot;);
        assertEquals(request.getBorrowerPhoneNumber(), createContract.getBorrowerPhoneNumber(), &quot;휴대폰 번호가 일치해야 합니다.&quot;);
        assertEquals(request.getPrincipal(), createContract.getPrincipal(), &quot;원금이 일치해야 합니다.&quot;);
        assertEquals(request.getRepaymentDate(), createContract.getRepaymentDate(), &quot;상환일이 일치해야 합니다.&quot;);
        assertEquals(ContractStatus.PENDING_AGREEMENT, createContract.getStatus(), &quot;초기 상태는 PENDING_AGREEMENT여야 합니다.&quot;);

        verify(contractRepository, times(1)).findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
                eq(request.getBorrowerPhoneNumber()),
                eq(request.getPrincipal()),
                eq(request.getRepaymentDate()),
                eq(activeStatuses)
        );

        verify(contractRepository, times(1)).save(any(Contract.class));
    }
}
</code></pre>
<hr>
<ul>
<li><code>@ExtendWith(MockitoExtension.class)</code> : Junit5에게 이 테스트 클래스에서 Mockito 어노테이션을 사용하겠다고 선언을 합니다.</li>
<li><code>@Mock</code> : 이 어노테이션이 붙은 필드에 Mockito가 해당 타입의 <strong>가짜 객체</strong>를 만들어줍니다. 이 가짜 객체는 <strong>contractRepository</strong>는 실제 로직을 수행하지 않고, 저희가 설정한 대로만 응답합니다.</li>
<li><code>@InjectMocks</code> : 이 어노테이션이 붙은 필드에 Mockito가 인스턴스를 생성하고, <code>@Mock</code>으로 생성된 모든 Mock 객체들을 자동으로 주입해줍니다. 쉽게 말해서 위 실제 service 계층에서 <strong>실제contractRepository</strong>를 주입 받고 있는데 테스트 과정에서는 우리가 만든 <strong>가짜 contractRepository</strong>를 사용하게 됩니다.</li>
</ul>
<hr>
<h3 id="given">Given</h3>
<pre><code class="language-java">ContractCreateRequest request = ContractCreateRequest.builder()
                .borrowerPhoneNumber(&quot;010-9665-1195&quot;)
                .principal(BigDecimal.valueOf(100000))
                .repaymentDate(LocalDate.of(2025, 8, 30))
                .build();

        List&lt;ContractStatus&gt; activeStatuses = ContractStatus.getActiveStatuses();</code></pre>
<ol>
<li>일단 저희 Service 코드에서도 Controller에서 request를 인자로 받았으니 똑같이 테스트 코드에서도 <code>createContract()에 전달할 request DTO를 생성</code>해줍니다.</li>
</ol>
<pre><code class="language-java">when(contractRepository.findByBorrowerPhoneNumberAndPrincipalAndRepaymentDateAndStatusIn(
                anyString(),
                any(BigDecimal.class),
                any(LocalDate.class),
                eq(activeStatuses)
        )).thenReturn(Optional.empty());</code></pre>
<ol start="2">
<li>이제부터가 중요한데, Mock Repository의 동작을 <strong>Stubbing</strong> 하는 과정입니다.
저희가 Mockito에게 <strong>(When)</strong><code>&quot;만약 contractRepository의 findBy... 메서드가 어떤 문자열, 어떤 BigDecimal, 어떤 LocalDate, activeStatuse 리스트를 인자로 받아 호출되면</code>, <strong>(Then)</strong><code>그때는 &#39;Optional.empty()를 반환해라.</code> 라는 의미입니다. 
왜냐하면 저희는 일단 성공 케이스를 테스트 작성하는 과정이니 중복 계약이 있어서는 안되겠죠? 그래서 Optional.empty()를 반환 받아 <code>중복 계약이 없음을 의미</code>하는 뜻으로 작성했습니다.</li>
</ol>
<pre><code class="language-java">when(contractRepository.save(any(Contract.class)))
            .thenAnswer(invocation -&gt; {
                Contract argContract = invocation.getArgument(0); 
                return Contract.builder()
                        .id(&quot;test-contract-id-&quot; + argContract.getBorrowerPhoneNumber()) 
                        .borrowerPhoneNumber(argContract.getBorrowerPhoneNumber())
                        .principal(argContract.getPrincipal())
                        .repaymentDate(argContract.getRepaymentDate())
                        .status(ContractStatus.PENDING_AGREEMENT)
                        .createdAt(LocalDateTime.now())
                        .updatedAt(LocalDateTime.now())
                        .build();
            });</code></pre>
<ol start="3">
<li>이 부분도 보면 실제 Service 코드를 보면 save() 메서드를 호출하고 있는데, <strong>(When)</strong><code>만약 ContractRepository의 save 메서드가 어떤 Contract 객체를 인자로 받아 호출되면,</code><strong>(Then)</strong><code>해당 Contract 객체에 가상의 Id와 시간 정보를 추가한 Contract 객체를 만들어서 반환해라</code> 라는 의미입니다. 이 부분도 어쨋든 성공 케이스를 작성하는 것이기 때문에 저장이 성공하여 Id가 부여된 것처럼 시뮬레이션 한 것입니다.</li>
</ol>
<p>이렇게 하면 <strong>Given</strong> 부분이 완료되었습니다. </p>
<hr>
<h3 id="when">When</h3>
<ol>
<li>이제 테스트 대상인 contractService.createContract()를 실제로 호출합니다. 이 시점에서는 Service 내부의 로직이 실행되며, 주입된 <code>Mock contractRepository</code>의 메소드 들이 호출됩니다.
Mockito는 이 호출들을 가로채서 저희가 위에서 작성한 <strong>Given</strong> 단계에서 설정한 대로 응답합니다.<pre><code class="language-java">Contract createdContract = contractService.createContract(request);;</code></pre>
</li>
</ol>
<h4 id="하면서-궁금했던-점">하면서 궁금했던 점</h4>
<blockquote>
<p><code>공식문서, 블로그에 작성된 Test 코드, Gemini 등 활용</code>하면서 공부하고 TestCode를 작성했는데 쓰고나서도 제가 왜 쓴지 몰랐습니다...<code>&#39;그래서 when.then..()은 왜 쓰는건데?&#39;</code>라고 계속 생각했습니다. 혹시 저와 같은 상황에 있으신 분들을 위해 일단 제가 이해한대로 아래 그림과 같이 설명드리겠습니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dev_1000e/post/9c76bbd4-db1d-442f-b75d-d59c9555ff0f/image.png" alt=""></p>
<ol>
<li>실제 <code>createContract()</code> 메서드 로직을 봅시다. <code>contractRepository</code>를 활용해 처음부터 중복 계약이 있는지 확인 하는 로직이 있습니다. 또한 마지막 return 반환 부분에서 <code>contractRepository</code>를 이용해서 <code>save</code> 메서드를 호출하고 있습니다. 이 두 부분은 모두 외부 의존성 즉, 실제 DB와 연동에 해당합니다.</li>
<li><code>Unit Test의 목적은 createContract() 메서드 자체의 로직 검증입니다.</code> <code>createContract()</code>가 중복이 없으면 저장하고 성공적으로 반환한다는 비지니스 로직을 제대로 수행하는지 보고싶지, <code>Repository</code>가 실제 DB에 데이터를 어떻게 저장하는지는 이 테스트에서 관심사가 아니라는겁니다.<blockquote>
<p><strong>&quot;그래, 그건 알겠는데 그래서 when.then..()을 왜 쓰냐고?&quot;</strong></p>
</blockquote>
</li>
<li>만든 <code>ContractRepository</code>는 <code>Mock 객체</code>입ㅂ니다. 이 Mock객체는 스스로 아무것도 할 수 없는 <code>바보</code>입니다. 그래서 우리는 <code>when().then..()</code> 구문을 통해<code>Mockito</code>에게 <code>Mock Repository</code>의 특정 메서드가 호출되면 이런 값을 반환해 줘와 같이 미리 시나리오를 정해주는 것입니다.
1) <strong>파란색 화살표 1번</strong>
성공 테스트 목적을 달성하려면 createContract() 메서드 안의 중복 확인 로직이 <code>중복이 없다</code>고 판단해야 합니다. 그래서 <code>when(contractRepository.findBy...).thenReturn(Optional.empty());</code> 라고 설정하여, 실제 findBy... 메서드가 호출될 때 <code>Optional.empty()</code>를 즉시 반환하게끔 강제하는 것입니다. 이렇게 되면 <strong>service는 중복이 없다고 판단</strong>하고 다음 로직으로 넘어갈 수 있게 됩니다.
만약 이 부분을 중복 계약이 있다고 가정으로 설정하면 <code>createContract()</code>는 바로 <strong>IllegalArgumentException</strong>을 던지며 오류가 발생했을 것입니다.&lt;- 이 부분은 실패케이스에서 활용
2) <strong>파란색 화살표 2번</strong>
마찬가지로 <code>createContract()</code>가 <code>return</code> 까지 성공적으로 도달하려면 save 메서드 호출도 성공해야 합니다. <code>when(contractRepository.save(...)).thenAnswer(...)</code> 라고 설정하여 save 메서드가 호출될 때 마치 DB에 정상적으로 저장되고 ID가 부여된 Contract 객체가 반환된 것처럼 가상의 객체를 만들어 돌려주도록 강제한 것입니다. </li>
</ol>
<p><strong>(질문)</strong> 왜 가상의 객체를 만들어 돌려주도록 했을까요?
-&gt; <code>createContract()</code> 메서드의 <strong>반환타입은 Contract</strong>이니깐 당연히 Contract 객체를 반환해줘야죠.</p>
<p><strong>이제는 느낌이 와야합니다..</strong></p>
<hr>
<h3 id="then">Then</h3>
<p>이제 마지막 부분은 검증을 하는 구간입니다.
<code>when().then..()</code>이 Mock 객체가 무엇을 반환할지를 설정하는 것이라면, <code>verify()</code>는 service 로직이 Mock 객체(외부 의존성)를 예상한 대로 호출했는지를 확인하는 역할입니다.
테스트 대상 메서드가 실행되는 동안 외부 의존성(Mock객체(여기서는 repository))과 어떻게 상호작용했는지를 검증합니다. 예를 들어 <code>findBy...</code> 메서드가 정확히 1번 호출되었는지, <code>save</code> 메서드도 정확히 1번 호출되어서 어떤 Contract 객체를 반환했는지 등을 확인하여 Service의 비지니스 로직이 의존성을 올바르게 사용했는지 검증합니다.</p>
<h3 id="마무리">마무리</h3>
<p><img src="https://velog.velcdn.com/images/dev_1000e/post/aac56b11-e09b-4794-af24-3c290520df00/image.png" alt="">
이제 코드를 실행한 결과 성공적으로 테스트를 통과한 것을 볼 수가 있습니다.
실패 케이스도 이와 비슷하게 작성하시면 될 것 같습니다. 
이 글이 <strong>Mockito</strong>를 활용하여 <strong>UnitTest</strong>를 작성하시는 분들에게 도움이 되기를 바랍니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(Java/Spring) Gradle Multi-Module]]></title>
            <link>https://velog.io/@dev_1000e/JavaSpring-Gradle-Multi-Module</link>
            <guid>https://velog.io/@dev_1000e/JavaSpring-Gradle-Multi-Module</guid>
            <pubDate>Sun, 27 Jul 2025 12:58:03 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>프로젝트를 진행하면서 단일 프로젝트 구조에 익숙했던 나에게 처음으로 도입한 <strong>Gradle Multi-Module</strong> 구조는 많은 시행착오를 안겨줬다. 공식 문서나 기업들의 Multi-Module 도입 영상도 찾아보면서 구조를 분리해봤지만 설정을 추가할 때마다 문제가 생겨서 구조에 문제가 있는 것을 깨닫고 수차례 재정비해야 했다. 
이 글은 그 과정을 되짚으며, <strong>내가 겪은 문제와 그 해결 과정, 특히 Gradle이 권장하는 구조로의 전환을 중심으로 정리</strong>한 것이다.</p>
</blockquote>
<hr>
<h2 id="1-초기에-도입했던-gradle-구조와-설정">1. 초기에 도입했던 Gradle 구조와 설정</h2>
<h3 id="1-초기-디렉토리-구조">1) 초기 디렉토리 구조</h3>
<pre><code>credit(root)
├── settings.gradle -&gt; root.projectName과 하위 모듈을 정의하는 include 존재   
├── build.gradle        
├── credit-api/
│   └── build.gradle     
│   └── settings.gradle -&gt; root.projectName만 존재
├── credit-common/
│   └── build.gradle     
│   └── settings.gradle -&gt; root.projectName만 존재
├── credit-core/
│   └── build.gradle     
│   └── settings.gradle -&gt; root.projectName만 존재
└── credit-external-api/
    └── build.gradle     
    └── settings.gradle -&gt; root.projectName만 존재</code></pre><p>모듈을 나눈 건 좋아 보였지만 실제로 프로젝트를 운영하면서 몇 가지 구조적 한계에 부딪혔다.</p>
<hr>
<h3 id="2-root-buildgradle-내용">2) root build.gradle 내용</h3>
<pre><code class="language-bash">plugins {
    id &#39;java&#39;
    id &#39;org.springframework.boot&#39; version &#39;3.5.3&#39;
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.7&#39;
}
allprojects {
    group = &#39;com&#39;
    version = &#39;0.0.1-SNAPSHOT&#39;
}
subprojects {
    apply plugin: &#39;java&#39;
    apply plugin: &#39;org.springframework.boot&#39;
    apply plugin: &#39;io.spring.dependency-management&#39;
    repositories {
        mavenCentral()
    }
    dependencies {
        testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
        testRuntimeOnly &#39;org.junit.platform:junit-platform-launcher&#39;
        compileOnly &#39;org.projectlombok:lombok&#39;
        annotationProcessor &#39;org.projectlombok:lombok&#39;
    }
    tasks.named(&#39;test&#39;) {
        useJUnitPlatform()
    }
}
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}</code></pre>
<hr>
<h3 id="3-주요-문제점">3. 주요 문제점</h3>
<h4 id="문제점-1--allprojects--subprojects-사용">문제점 1 : allProjects{} / subProjects{} 사용</h4>
<p>Gradle 공식 문서에서도 언급되듯 <code>cross-project configuration 방식</code>은 <code>configuration-on-demand</code> 기능을 방해하고, 각 모듈의 설정을 외부에서 암묵적으로 주입하는 방식이라 유지보수에 불리하다.</p>
<blockquote>
<p><strong>왜 문제가 될까?</strong></p>
</blockquote>
<ul>
<li><strong>Configuration-Time coupling 유발</strong> : 프로젝트 간에 구성 시간 결합을 유발하여 <strong>configuration-on-demand</strong>와 같은 최적화 기능이 제대로 작동하지 못하게 할 수 있다.</li>
<li><em>configuration-on-demand*</em>는 빌드 시간을 줄이기 위해 요청된 Task에 관련된 프로젝트만 구성하려고 시도하는데 <code>allProjects{}나 subProjects{}</code>와 같은 구성을 통한 주입은 프로젝트 간의 결합을 만들어서 최적화를 방해할 수 있다.</li>
<li><strong>숨겨진 빌드 로직</strong> : 빌드 로직이 하위 모듈 프로젝트의 빌드 스크립트에서 명확하게 드러나지 않고 상위 수준에서 주입되기 때문에, 해당 프로젝트의 빌드 스크립트만으로는 어떤 로직이 적용되는지 파악하기 어려울 수 있다.</li>
</ul>
<blockquote>
<p><strong>해결책</strong></p>
</blockquote>
<ul>
<li><strong>Convention Plugins 사용</strong> : Convention Plugins는 프로젝트 루트에 있는 특별한 <code>buildSrc 디렉토리</code> 내에 위치하며, 공통 빌드 로직(Plugin 적용, 공통 의존성, Task 설정 등)을 중앙 집중화하고 재사용성을 높일 수 있다.
예를 들어, 특정 유형의 하위 프로젝트에 플러그인이나 기타 구성을 적용하는 로직은 해당 유형의 하위 프로젝트에 직접 Convention Plugin을 적용하는 것으로 대체할 수 있다.</li>
</ul>
<hr>
<h4 id="문제점-2--plugin-버전-하드코딩">문제점 2 : plugin 버전 하드코딩</h4>
<p>루트 디렉토리의 <code>build.gradle</code>에 plugin의 의존성 버전이 직접 하드코딩 될 경우, 여러 하위 프로젝트에서 동일한 의존성을 사용할 때 버전이 중복 선언될 수 있다.</p>
<blockquote>
<p><strong>왜 문제가 될까?</strong>
버전이 중복이 되면 나중에 버전을 업그레이드하거나 변경할 때 모든 파일을 일일이 수정해야 해서 번거롭고 실수를 유발할 수 있습니다. 또 프로젝트 간의 의존성 충돌 문제가 발생할 수 도 있다.</p>
</blockquote>
<blockquote>
<p><strong>해결책</strong>
의존성 버전 관리의 비효율성 문제를 해결하기 위해 <code>settings.gradle 파일</code>에서 <code>pluginManagement{} 블록</code>을 이용하여 Gradle 빌드에서 사용될 플러그인들의 버전 관리를 한 곳에서 할 수 있다.</p>
</blockquote>
<hr>
<p><strong>초기 Sub-Module</strong> 
<strong>credit-api/build.gradle</strong>
(다른 하위 모듈들 아래 모듈 gradle 설정이 비슷하기에 1개만 대표로 올립니다.)</p>
<pre><code class="language-bash">plugins {
    id &#39;java&#39;
    id &#39;org.springframework.boot&#39; version &#39;3.5.3&#39;
    id &#39;io.spring.dependency-management&#39; version &#39;1.1.7&#39;
}
dependencies {
    implementation project(&#39;:credit-core&#39;)
    implementation project(&#39;:credit-external-api&#39;)
    implementation project(&#39;:credit-common&#39;)
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    testImplementation &#39;com.h2database:h2&#39;
}
tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}</code></pre>
<hr>
<h2 id="2-개선-gradle이-권장하는-구조로-전환">2. 개선: Gradle이 권장하는 구조로 전환</h2>
<p>위에서 언급된 문제점들을 해결하고, Gradle이 권장하는 <strong>중앙 집중화</strong>와 <strong>Convention Plugins</strong> 방식을 도입하여 빌드 구조를 개선했습니다.</p>
<h3 id="1-변경된-디렉토리-구조">1) 변경된 디렉토리 구조</h3>
<pre><code>credit(root)
├── settings.gradle      
├── buildSrc
│   └── src
│       └── main
│           └── groovy
│               └── java-common-convention.gradle
│               └── spring-boot-convention.gradle
│   └── build.gradle
├── credit-api/
│   └── build.gradle     
├── credit-common/
│   └── build.gradle     
├── credit-core/
│   └── build.gradle     
└── credit-external-api/
    └── build.gradle     </code></pre><blockquote>
<ul>
<li><code>모든 하위 모듈에서 settings.gradle 제거</code></li>
</ul>
</blockquote>
<ul>
<li><code>공통 로직은 buildSrc로 convention plugin으로 재구성</code></li>
<li><code>모듈별로 필요한 설정만 명시적으로 선언하도록 변경</code></li>
</ul>
<hr>
<h3 id="2-settingsgradle-개선">2) settings.gradle 개선</h3>
<p><strong>credit/settings.gradle</strong></p>
<pre><code class="language-bash">pluginManagement { 
    repositories {
        gradlePluginPortal()
        mavenCentral()
    }
    plugins { 
        id &#39;org.springframework.boot&#39; version &#39;3.5.3&#39; 
        id &#39;io.spring.dependency-management&#39; version &#39;1.1.7&#39;
    }
}
rootProject.name = &#39;credit&#39;

include &#39;credit-api&#39;
include &#39;credit-core&#39;
include &#39;credit-external-api&#39;
include &#39;credit-common&#39;
</code></pre>
<p>이제 모듈을 추가하거나 제거할 때 이 root settings.gradle 파일만 수정하면 되니깐 유지 보수성 측면에서 많이 편해졌다.</p>
<hr>
<h3 id="3-buildsrc--java-공통-convention-plugin">3) buildSrc + Java 공통 Convention plugin</h3>
<p>초기 구조에서는 모든 공통 설정을 루트 build.gradle에서 처리하다 보니 모듈별로 불필요한 설정까지 강제로 적용되고 있었다. 이를 개선하기 위해 Gradle에서 권장하는 <strong>buildSrc 디렉토리</strong>를 만들어 <strong>Custom Gradle Plugin</strong>을 정의했다.</p>
<blockquote>
<p><em><strong>buildSrc란?</strong></em>
Gradle에서 buildSrc는 자동으로 빌드에 포함되는 특별한 디렉토리 입니다.
여기에 정의한 플로그인은 프로젝트 내 어떤 모듈에서도 바로 사용할 수 있습니다.</p>
</blockquote>
<p><strong>buildSrc/build.gradle</strong></p>
<pre><code class="language-bash">plugins {
    id &#39;groovy-gradle-plugin&#39; 
}
repositories {
    gradlePluginPortal()
    mavenCentral()
}</code></pre>
<p><strong>buildSrc/.../java-common-coventions.gradle (New)</strong></p>
<pre><code class="language-bash">plugins {
    id &#39;java&#39; 
}
group = &#39;com&#39; 
version = &#39;0.0.1-SNAPSHOT&#39;

repositories {
    mavenCentral()
}
dependencies { 
    testRuntimeOnly &#39;org.junit.platform:junit-platform-launcher:1.10.2&#39;
    compileOnly &#39;org.projectlombok:lombok:1.18.30&#39;
    annotationProcessor &#39;org.projectlombok:lombok:1.18.30&#39;
}
tasks.named(&#39;test&#39;) {
    useJUnitPlatform()
}
java { 
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}</code></pre>
<p>이제 각 모듈에서는 다음과 같이 <strong>id &#39;java-common-coventions&#39;</strong> 한 줄만 선언하면 java 공통 설정을 바로 사용할 수 있습니다.</p>
<hr>
<p><strong>credit-api/build.gradle</strong></p>
<pre><code class="language-bash">plugins {
    id &#39;java-common-conventions&#39; 
    id &#39;org.springframework.boot&#39; 
    id &#39;io.spring.dependency-management&#39;
}
dependencies { 
    implementation project(&#39;:credit-core&#39;)
    implementation project(&#39;:credit-external-api&#39;)
    implementation project(&#39;:credit-common&#39;)
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    testImplementation &#39;com.h2database:h2&#39;
}
(credit-core/build.gradle 및 credit-external-api/build.gradle, credit-common/build.gradle도 위와 유사하게 개선)</code></pre>
<hr>
<h3 id="4-spring-boot-covnention-plugin-도입">4) Spring Boot Covnention Plugin 도입</h3>
<p>이전 개선된 구조에서 <code>java-common-conventions.gradle</code>을 통해 java 관련 공통 설정을 중앙에서 관리했다. 하지만 여전히 <code>org.springframework.boot</code> 및 <code>io.spring.dependency-management</code>플러그인 적용과 특정 Spring Boot starter 의존성(spring-boot-starter-web, spring-boot-starter-validation, test 등)은 각 모듈의 build.gradle 파일에 <strong>직접 명시</strong>되어 있었다.</p>
<p>나는 이게 마음에 들지 않아서, <strong>모든 Spring Boot 관련 공통 관심사도 Java 처럼 별도의 Convention Plugin으로 통합함</strong>으로써 각 모듈의 build.gradle 파일을 간결하게 만들고싶었다. </p>
<p><strong>buildSrc/.../spring-boot-coventions.gradle (New)</strong></p>
<pre><code class="language-bash">plugins {
    id &#39;java-common-conventions&#39;
    id &#39;org.springframework.boot&#39;
    id &#39;io.spring.dependency-management&#39;
}

dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    testImplementation &#39;org.springframework.boot:spring-boot-starter-test&#39;
}</code></pre>
<p><strong>buildSrc/build.gradle</strong></p>
<pre><code class="language-bash">...

dependencies { -&gt; 새롭게 추가된 부분
    implementation &#39;org.springframework.boot:spring-boot-gradle-plugin:3.2.5&#39;
    implementation &#39;io.spring.dependency-management:io.spring.dependency-management.gradle.plugin:1.1.4&#39;
}</code></pre>
<p><strong>credit-api/build.gradle</strong></p>
<pre><code class="language-bash">plugins {
    id &#39;spring-boot-conventions&#39;
}

dependencies { 
    implementation project(&#39;:credit-core&#39;)
    implementation project(&#39;:credit-external-api&#39;)
    implementation project(&#39;:credit-common&#39;)

    testImplementation &#39;com.h2database:h2&#39;
}
(credit-core/build.gradle 및 credit-external-api/build.gradle, credit-common/build.gradle도 위와 유사하게 개선)</code></pre>
<hr>
<h2 id="3-트러블-슈팅--unknownpluginexception">3. 트러블 슈팅 : UnknownPluginException</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>spring boot 관련 Convention Plugin을 만들고 적용하려 했을 때 아래 오류가 발생했다.</p>
<p><strong>buildSrc/build.gradle (오류 발생 당시)</strong></p>
<pre><code class="language-bash">plugins {
    id &#39;groovy-gradle-plugin&#39;
}

repositories { 
    gradlePluginPortal() 
    mavenCentral()
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_1000e/post/d72deb7a-d1de-49dd-9c31-513b084e3602/image.png" alt="실패한 빌드"></p>
<h3 id="원인-분석">원인 분석</h3>
<blockquote>
<p><strong>왜 UnknownPluginException이 발생했을까?</strong></p>
</blockquote>
<p>이 오류의 핵심은 <strong>&quot;Plugin with id &#39;org.springframework.boot&#39; not found&quot;</strong> 라는 메시지에 있었다. 이는 buildSrc가 <code>spring-boot-conventions.gradle</code>을 Convention Plugin으로 컴파일하여 만들려고 할 때 발생한 문제였다.</p>
<p>공식문서를 찾아보며 더 깊이 파고들면 그 이유를 찾을 수 있었다.</p>
<ol>
<li><p>Gradle은 buildSrc 디렉토리를 발견하면, 이를 독립적인 Gradle 서브 프로젝트처럼 취급하고 메인 빌드 보다 먼저 빌드 한다.</p>
</li>
<li><p><code>spring-boot-conventions.gradle</code> 파일은 그 자체로 Groovy 코드 스크립트이기도 하면서 buildSrc가 컴파일하여 생성할 커스텀 플러그인의 정의이다. 이 스크립트 내부에 <code>plugins {id &#39;org.springframework.boot`}</code>와 같이 다른 플러그인을 참조하는 구문이 있다.</p>
</li>
<li><p>문제는 buildSrc가 이 <code>spring-boot-conventions.gradle</code>을 컴파일할 때 발생했다. <code>spring-boot-conventions.gradle</code> 내부에서 참조하는 <code>org.springframework.boot 플러그인 (org.springframework.boot:spring-boot-gradle-plugin JAR 파일 내부의 클래스 및 메타데이터)</code>이 buildSrc 프로젝트의 컴파일 타임 클래스패스에 존재하지 않았던 것이다.</p>
</li>
<li><p>오류 발생 당시의 <code>buildSrc/build.gradle</code> 파일에는 <code>groovy-gradle-plugin</code>만 적용되어 있었고, <code>org.springframework.boot</code> 플러그인 자체에 대한 의존성 선언이 누락되어 있었다. 즉, buildSrc는 자신이 빌드해야 할 <code>spring-boot-conventions</code>가 참조하는 <code>org.springframework.boot</code> 플러그인이 무엇인지, 그리고 어디에서 그 정의를 찾을 수 있는지 전혀 알 방법이 없었던 것이다. 이로 인해 <strong>UnknownPluginException</strong>이 발생했던 것입니다.</p>
</li>
</ol>
<p>결론적으로, <code>buildSrc/build.gradle</code>에 <code>org.springframework.boot:spring-boot-gradle-plugin</code>과 <code>io.spring.dependency-management:io.spring.dependency-management.gradle.plugin</code> JAR 파일을 implementation 의존성으로 추가함으로써 문제가 해결되었다.</p>
<p><strong>buildSrc/build.gradle</strong></p>
<pre><code class="language-bash">...
dependencies {
    implementation &#39;org.springframework.boot:spring-boot-gradle-plugin:3.2.5&#39;
    implementation &#39;io.spring.dependency-management:io.spring.dependency-management.gradle.plugin:1.1.4&#39;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dev_1000e/post/ce248a0f-540f-4776-95e7-0f8ad4cf10a7/image.png" alt="성공한 빌드">
이로써,성공적으로 빌드가 완료되었다.</p>
<h4 id="결과적으로-문제가-해결된-이유">결과적으로 문제가 해결된 이유</h4>
<ol>
<li><p>buildSrc의 <code>build.gradle</code> 파일에 <code>Spring Boot Gradle Plugin</code>과 <code>Spring Dependency Management Gradle Plugin</code>을 implementation 의존성으로 추가했다.</p>
</li>
<li><p>Gradle은 메인 프로젝트 빌드를 시작하기 전에 buildSrc 디렉토리를 독립적인 프로젝트로 먼저 빌드한다. 이 과정에서 implementation으로 선언된 두 플러그인의 JAR 파일들이 buildSrc 프로젝트의 컴파일 타임 및 런타임 클래스패스에 포함된다.</p>
</li>
<li><p>이렇게 빌드된 buildSrc의 결과물(커스텀 컨벤션 플러그인들)은 메인 빌드의 클래스패스에 자동으로 추가된다.</p>
</li>
<li><p>결과적으로, 메인 프로젝트의 하위 모듈(credit-api 등)에서 plugins { id &#39;spring-boot-conventions&#39; }와 같이 <code>spring-boot-conventions</code> 플러그인을 적용할 때, 이 플러그인 내에서 참조하는 <code>org.springframework.boot</code> 플러그인에 대한 정의가 이미 buildSrc를 통해 메인 빌드의 클래스패스에 로드되어 있으므로 Gradle은 해당 플러그인을 성공적으로 찾아 적용할 수 있게 된 것이다.</p>
</li>
</ol>
<hr>
<h4 id="출처">출처</h4>
<p><a href="https://docs.gradle.org/current/userguide/multi_project_builds.html">Gradle 8.14.3 공식문서</a></p>
<hr>
<p>많은 도움이 되셨으면 좋겠습니다!</p>
]]></description>
        </item>
    </channel>
</rss>