<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Devon's Tech Blog</title>
    <link>https://imgdevel.tistory.com/</link>
    <description>Devon(ImGdevel) 님의 기술 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 27 Jun 2026 11:08:54 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Devon</managingEditor>
    <image>
      <title>Devon's Tech Blog</title>
      <url>https://tistory1.daumcdn.net/tistory/8116892/attach/ff5c8992ab724aebb4df17fb5f388326</url>
      <link>https://imgdevel.tistory.com</link>
    </image>
    <item>
      <title>어느 순간 테스트가 5분 &amp;mdash; @MockBean이 컨텍스트 캐시를 깨고 있었다</title>
      <link>https://imgdevel.tistory.com/107</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;726&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crUOCr/dJMcahRJCOV/xxwFCNVO0bGlUTKFF8mN2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crUOCr/dJMcahRJCOV/xxwFCNVO0bGlUTKFF8mN2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crUOCr/dJMcahRJCOV/xxwFCNVO0bGlUTKFF8mN2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcrUOCr%2FdJMcahRJCOV%2FxxwFCNVO0bGlUTKFF8mN2k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;889&quot; height=&quot;496&quot; data-origin-width=&quot;726&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평소 1분 30초쯤 걸리던 테스트 빌드가 있었다. 슬라이스 테스트가 300개를 넘어가던 어느 시점부터 5분이 됐다. 정확히 언제부터 그랬는지는 기억이 안 나는데, PR 올리고 화장실 다녀와서 커피 받아도 CI가 안 끝나 있는 게 어느 순간 일상이 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 그냥 &quot;테스트가 많아졌으니까 그렇겠지&quot; 하고 넘겼다. 근데 단순 산수가 안 맞았다. 그 사이 새로 추가된 슬라이스 테스트는 10개 남짓이었는데, 빌드 시간은 5배 가까이 늘어 있었다. 한 테스트당 1초씩 늘었다 해도 10초인데, 6분이 추가된 셈이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 들여다봤다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 테스트 컨텍스트 캐시는 ApplicationContext를 한 번 만들어두고 다음 테스트가 같은 설정을 요구하면 재사용한다. 이게 빠른 테스트의 핵심이다. 보통은 @WebMvcTest, @DataJpaTest 같은 슬라이스 어노테이션 단위로 캐시 키가 결정된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 @MockBean을 쓰면, 그 클래스의 컨텍스트는 다른 어떤 컨텍스트와도 공유 안 되는 &lt;b&gt;새 캐시 키&lt;/b&gt;가 된다. @MockBean(UserService::class)가 들어간 슬라이스 테스트와 안 들어간 슬라이스 테스트는 Spring 입장에서 다른 컨텍스트로 취급된다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@WebMvcTest(UserController::class)
class UserControllerTest {
    @MockBean private lateinit var userService: UserService  // 새 캐시 키
}

@WebMvcTest(PostController::class)
class PostControllerTest {
    @MockBean private lateinit var postService: PostService  // 또 새 캐시 키
}

@WebMvcTest(CommentController::class)
class CommentControllerTest {
    @MockBean private lateinit var commentService: CommentService
    @MockBean private lateinit var userService: UserService    // 위 UserControllerTest와도 안 공유됨
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@MockBean이 들어간 빈 조합이 다르면 Spring은 그것들을 &lt;b&gt;독립적인 ApplicationContext&lt;/b&gt;로 본다. 캐시 미스 &amp;rarr; 컨텍스트 새로 부팅 &amp;rarr; 빌드 시간 누적.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략 그림으로 보면 이런 분기가 일어난다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;707&quot; data-origin-height=&quot;371&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXaXb4/dJMcabKOhpR/NTko6jKrkrILHOrfCc5ibK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXaXb4/dJMcabKOhpR/NTko6jKrkrILHOrfCc5ibK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXaXb4/dJMcabKOhpR/NTko6jKrkrILHOrfCc5ibK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXaXb4%2FdJMcabKOhpR%2FNTko6jKrkrILHOrfCc5ibK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;707&quot; height=&quot;371&quot; data-origin-width=&quot;707&quot; data-origin-height=&quot;371&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApplicationContext 하나 만드는 데 우리 프로젝트에서는 7~8초 정도 걸렸다 (시간 측정 안 해본 사람이 많을 텐데, 한 번 켜보면 놀란다). 슬라이스 클래스 30개에서 각자 다른 @MockBean 조합을 쓰면, 캐시 미스가 30번 가까이 나서 30 &amp;times; 7초 = 약 3분 30초가 그냥 컨텍스트 부팅에만 쓰인다. 6분 늘어난 시간 중 절반쯤이 여기서 나오고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 확인한 방법은 단순했다. Spring Test의 org.springframework.test.context.cache 로거를 DEBUG로 켜면 cacheHits / cacheMisses 카운트가 찍힌다. 도입 전엔 misses가 hits의 두 배가 넘었다. 정상이라면 hits가 압도적으로 많아야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;가설들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인이 컨텍스트 캐시인 건 알았는데, 어떻게 풀어야 할지는 또 다른 문제였다. 후보가 몇 개 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;후보 1. 슬라이스 테스트를 줄이고 다 통합 테스트로.&lt;/b&gt; 컨텍스트가 1개로 줄어드니까 캐시 미스는 사라진다. 근데 통합 테스트 하나가 5초 걸리고 슬라이스 테스트는 200ms 걸리니까, 결국 다른 쪽에서 시간이 폭발한다. 그리고 슬라이스가 주는 &quot;이 레이어만 검증한다&quot;는 명확함도 잃는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;후보 2. Standalone MockMvc로 전환.&lt;/b&gt; Spring 컨텍스트를 안 띄우고 MockMvcBuilders.standaloneSetup(controller)로 직접 만드는 방식이다. 빠른데, Validation&amp;middot;ExceptionHandler&amp;middot;ArgumentResolver 같은 게 자동으로 안 묶인다. 그걸 다 수동으로 등록하기 시작하면 테스트 코드가 부풀어 오른다. 일부 케이스에만 쓸 만했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;후보 3. @TestConfiguration + @Primary로 Mock 빈을 명시 등록.&lt;/b&gt; Mock 빈 설정을 별도 @TestConfiguration 클래스로 빼고, 같은 설정을 쓰는 테스트들끼리 컨텍스트를 공유하게 한다. 카카오페이 기술 블로그에서 본 패턴이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;후보 1은 트레이드오프가 안 맞았고, 후보 2는 일부 케이스에만 쓸 수 있었다. 후보 3을 메인으로 가기로 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;@TestConfiguration + @Primary 적용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 컨트롤러 그룹의 테스트들이 자주 mocking하는 빈들을 한 곳에 모은다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@TestConfiguration
class WebMvcMockConfig {

    @Bean
    @Primary
    fun userService(): UserService = mockk()

    @Bean
    @Primary
    fun postService(): PostService = mockk()

    @Bean
    @Primary
    fun authService(): AuthService = mockk()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트에서는 @Import로 가져다 쓴다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@WebMvcTest(UserController::class)
@Import(WebMvcMockConfig::class)
class UserControllerTest(
    @Autowired private val mockMvc: MockMvc,
    @Autowired private val userService: UserService,  // mockk 인스턴스
) {
    @Test
    fun `사용자 조회`() {
        every { userService.find(1L) } returns User(...)
        // ...
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 @MockBean이 사라졌다는 것이다. Spring 입장에서는 이게 그냥 일반 @Bean이라서 컨텍스트 캐시 키에 영향을 주지 않는다. WebMvcMockConfig를 @Import하는 모든 테스트는 같은 컨텍스트를 공유한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 단계 더 가면, 메타 어노테이션과 묶을 수 있다. 메타 어노테이션 컨벤션은 &lt;a href=&quot;https://claude.ai/local_sessions/local_c528bbb9-9ace-47c3-aa8e-1a9b3e921f6d#&quot;&gt;별도 글&lt;/a&gt;에서 정리했으니, 이 글에서는 결과만.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;controller-webmvc&quot;)
@WebMvcTest
@Import(WebMvcMockConfig::class)
annotation class ControllerWebMvcTest
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 테스트 클래스에서는 @ControllerWebMvcTest만 쓰면 된다. @Import도, @MockBean도 안 쓴다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Before / After&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 시간 측정값은 정확히 기억이 안 난다. 측정 그래프를 보관 안 해뒀다. 대략 이런 순서였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도입 전: 8분&lt;/li&gt;
&lt;li&gt;@TestConfiguration + @Primary 전환: 4분 후반대&lt;/li&gt;
&lt;li&gt;메타 어노테이션과 묶고 캐시 일관성 확보: 3분대&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR CI가 4~5분 빨라졌다. 절반 가까이 줄어든 셈이라, 머지 빈도가 자연스럽게 늘었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApplicationContext 캐시 hit ratio도 같이 봤는데 이쪽이 더 극적이었다. 도입 전엔 misses가 hits의 두 배가 넘었던 게, 도입 후엔 misses가 한 자릿수로 떨어졌다. 컨텍스트 부팅이 거의 시작 한 번으로 끝나는 흐름으로 바뀐 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴이 모든 경우에 좋다는 건 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 자주 부딪힌 건, 새 빈을 추가할 때마다 WebMvcMockConfig도 손봐야 한다는 점이다. 이게 깜빡할 때가 많다. 한 번은 새로 만든 컨트롤러가 의존하는 서비스를 WebMvcMockConfig에 안 넣어서, 30분 정도 &quot;왜 NPE가 나지&quot; 하고 헤맸다. @MockBean은 클래스 안에서 자동으로 처리되는 편의가 있는데, @TestConfiguration으로 빼면 그 편의가 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 한두 테스트만 다른 mocking이 필요할 때, @TestConfiguration을 두 개로 쪼개거나 그 케이스만 별도 @MockBean을 쓰게 된다. 그러면 또 컨텍스트가 새로 생긴다. 이런 케이스를 의식해서 격리해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, Spring Boot 3.4부터 @MockBean이 deprecated됐다. 권고는 @MockitoBean으로 옮기라는 건데, 이 어노테이션도 동작이 비슷해서 같은 문제(컨텍스트 캐시 키 분리)를 가진다. 결국 우리는 @MockitoBean도 같은 이유로 피하고 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마지막으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 빌드가 느려졌을 때, 직관적으로는 &quot;테스트가 너무 많아서 그런 거 아닐까&quot; 싶었다. 근데 측정해 보니 테스트 개수보다 &lt;b&gt;컨텍스트 개수&lt;/b&gt;가 진짜 변수였다. Spring 테스트 빌드에서는 &quot;테스트 N개&quot;보다 &quot;컨텍스트 K개 &amp;times; 부팅 시간 T초&quot;가 훨씬 큰 영향을 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@MockBean 자체가 나쁜 도구라기보다는, 그게 컨텍스트 캐시에 미치는 영향을 의식하지 않으면 비용이 누적된다는 게 결론이었다. 한 번 한 번의 @MockBean은 0.1초밖에 안 보일 수 있는데, 30개 모이면 3분이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남은 과제는 &quot;@TestConfiguration을 어디까지 분리할 것인가&quot;다. 지금은 webmvc / repository / service-integration 단위로 하나씩 두고 있는데, 도메인이 더 커지면 이 설정 클래스 자체가 비대해진다. 그건 다음에 정리해볼 생각이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토스 &amp;mdash; &lt;a href=&quot;https://toss.tech/article/test-strategy-server&quot;&gt;가치있는 테스트를 위한 전략과 구현&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;카카오페이 &amp;mdash; Mock 테스트 코드 시리즈 (&lt;a href=&quot;https://tech.kakaopay.com/&quot;&gt;tech.kakaopay.com&lt;/a&gt; 검색)&lt;/li&gt;
&lt;li&gt;Spring Framework &amp;mdash; &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/caching.html&quot;&gt;Context Caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Spring Boot 3.4 &amp;mdash; @MockitoBean 도입 / @MockBean deprecation 안내&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/107</guid>
      <comments>https://imgdevel.tistory.com/107#entry107comment</comments>
      <pubDate>Fri, 13 Feb 2026 20:08:34 +0900</pubDate>
    </item>
    <item>
      <title>픽스처가 두 번째 도메인이 되지 않으려면</title>
      <link>https://imgdevel.tistory.com/94</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;header.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bscJhj/dJMcafsQgEJ/xZRFX0qrdU4sPm3bKqqGV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bscJhj/dJMcafsQgEJ/xZRFX0qrdU4sPm3bKqqGV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bscJhj/dJMcafsQgEJ/xZRFX0qrdU4sPm3bKqqGV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbscJhj%2FdJMcafsQgEJ%2FxZRFX0qrdU4sPm3bKqqGV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1020&quot; height=&quot;574&quot; data-filename=&quot;header.png&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 지옥이 되는 첫 순간은, 의외로 실행이 느려질 때가 아니다. &lt;b&gt;Given을 다섯 번째 쓸 때&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 댓글 테스트 하나를 추가하는데, 댓글을 쓰려면 게시글이 있어야 하고, 게시글이 있으려면 작성자가 있어야 하고, 작성자는 OAuth provider가 있어야 하고&amp;hellip; 이 체인이 테스트 본문을 50줄 잠식한다. 그리고 다음 테스트도, 그 다음 테스트도 똑같다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;@Test
fun `댓글이 정상 추가된다`() {
    val author = User(
        email = &quot;test@example.com&quot;,
        nickname = &quot;tester&quot;,
        password = &quot;encoded&quot;,
        role = Role.USER,
        provider = AuthProvider.LOCAL,
        createdAt = LocalDateTime.now(),
        updatedAt = LocalDateTime.now(),
    )
    val post = Post(
        author = author,
        title = &quot;제목&quot;,
        content = &quot;내용&quot;,
        category = Category.TECH,
        viewCount = 0,
        likeCount = 0,
        createdAt = LocalDateTime.now(),
    )
    // 진짜 검증하려는 건 여기 아래에 있는데, 여기까지 도달하기 전에 지친다.
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오페이 블로그에서 본 &quot;Given 지옥&quot;이라는 표현이 정확하다(아래 참고에 링크).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흔한 첫 대응은 헬퍼 함수다. createTestUser(), createPost() 같은 걸 모듈 안에 만든다. 며칠은 살 만해진다. 근데 두 가지가 곧 부딪힌다. 우선 멀티 모듈 환경에서는 app-api와 app-batch가 같은 도메인을 만지는데도 헬퍼는 따로 만든다. 다음으로 헬퍼가 점점 도메인을 흉내내기 시작한다. &quot;관리자면 Notice 카테고리, 아니면 General&quot; 같은 분기가 슬며시 헬퍼 안으로 들어온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 다음 단계, 헬퍼를 졸업하고 픽스처를 구조로 잡을 때 우리 팀이 정한 규칙을 정리한 글이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;java-test-fixtures의 위치&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;705&quot; data-origin-height=&quot;387&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qKkFM/dJMcabRvSue/HLorDG9DK3QCij7Vf0OQk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qKkFM/dJMcabRvSue/HLorDG9DK3QCij7Vf0OQk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qKkFM/dJMcabRvSue/HLorDG9DK3QCij7Vf0OQk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqKkFM%2FdJMcabRvSue%2FHLorDG9DK3QCij7Vf0OQk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;532&quot; height=&quot;292&quot; data-origin-width=&quot;705&quot; data-origin-height=&quot;387&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 정해야 할 건 위치다. 헬퍼처럼 메인 소스 옆에 떠다니지 않고, 별도 소스셋에 둔다. Gradle이 제공하는 java-test-fixtures 플러그인을 쓰면 자동으로 src/testFixtures/kotlin 디렉토리가 생긴다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;// app-api/build.gradle.kts
plugins {
    id(&quot;java-test-fixtures&quot;)
}

dependencies {
    testFixturesImplementation(&quot;org.springframework.boot:spring-boot-starter&quot;)
    testFixturesImplementation(project(&quot;:domain&quot;))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 모듈에서는 이렇게 끌어다 쓴다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;// app-batch/build.gradle.kts
testImplementation(testFixtures(project(&quot;:app-api&quot;)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;H2처럼 테스트 시점에만 필요한 런타임 의존성도 비슷한 방식으로 전파할 수 있다 (testFixturesRuntimeOnly + testRuntimeOnly(testFixtures(...))).&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;flowchart LR
    subgraph api[&quot;:app-api&quot;]
        api_main[&quot;src/main&quot;]
        api_fix[&quot;src/testFixtures&amp;lt;br/&amp;gt;PostFixture / UserFixture&quot;]
        api_test[&quot;src/test&quot;]
        api_test -.uses.-&amp;gt; api_fix
    end
    subgraph batch[&quot;:app-batch&quot;]
        batch_test[&quot;src/test&quot;]
    end
    domain[&quot;:domain&quot;]
    api_main --&amp;gt; domain
    api_fix -.depends on.-&amp;gt; domain
    batch_test -.testFixtures(:app-api).-&amp;gt; api_fix
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 단계가 왜 중요하냐면, 헬퍼는 메인 코드에서 실수로 import해도 빌드가 깨지지 않는다. 픽스처는 별도 소스셋이라 메인이 부르려고 하면 컴파일 에러가 난다. 즉 &quot;이건 테스트 전용이다&quot;가 빌드 시스템 차원에서 강제된다. 작은 차이 같지만, 시간이 지나서 픽스처가 100개를 넘기면 이 강제가 실수를 줄여준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Fixture의 네이밍&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규칙은 단순한데, 안 지키기도 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티 픽스처는 PostFixture처럼 클래스명에 Fixture 접미사를 붙이고, 안에는 create()와 (필요할 때만) createWithId()를 둔다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;object PostFixture {
    const val DEFAULT_TITLE = &quot;기본 제목&quot;

    fun create(
        author: User = UserFixture.create(),
        title: String = DEFAULT_TITLE,
        content: String = &quot;본문&quot;,
        category: Category = Category.TECH,
    ): Post = Post(author, title, content, category)

    fun createWithId(id: Long, title: String = DEFAULT_TITLE): Post =
        create(title = title).also { ReflectionTestUtils.setField(it, &quot;id&quot;, id) }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;createWithId는 일부러 분리한다. ID는 일반 생성 시점에 노출하지 않는 게 좋고, ID가 필요한 테스트(거의 Repository 테스트뿐이다)만 명시적으로 호출하게 만든다. 이렇게 하면 &quot;왜 이 테스트가 ID에 의존하지?&quot;가 코드 읽는 사람에게 바로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 DTO 픽스처는 메서드명이 의도를 말해주게 한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;object PostRequestFixture {
    fun createRequest(title: String = &quot;새 글&quot;, content: String = &quot;본문&quot;) =
        PostCreateRequest(title, content)

    fun updateRequest(title: String = &quot;수정&quot;) = PostUpdateRequest(title)

    // Validation 검증 시 자주 부른다. 이름 자체가 &quot;왜 invalid한지&quot;를 말해야 한다.
    fun invalidRequest_titleEmpty() =
        PostCreateRequest(title = &quot;&quot;, content = &quot;본문&quot;)

    fun invalidRequest_titleTooLong() =
        PostCreateRequest(title = &quot;x&quot;.repeat(201), content = &quot;본문&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;invalidRequest_titleEmpty()라는 이름 하나로, 컨트롤러 테스트의 given이 한 줄로 끝난다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Test
fun `제목이 비어있으면 400`() {
    val request = PostRequestFixture.invalidRequest_titleEmpty()
    mockMvc.post(&quot;/posts&quot;) { jsonContent(request) }
        .andExpect { status { isBadRequest() } }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 DTO에는 픽스처를 잘 안 만든다. 만들기 시작하면 테스트가 응답 구조 변경에 둔감해지는 일이 종종 있어서, 우리는 &quot;같은 구조의 응답 검증이 4번 이상 반복될 때&quot;를 임계로 잡았다. 그 전까진 그냥 assertThat(response.title).isEqualTo(...)로 직접 푼다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;도메인 테스트에서는 픽스처를 쓰지 않는다&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;707&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kTLUv/dJMcabxcwMj/pvneK6irVWPSmbMAhxETJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kTLUv/dJMcabxcwMj/pvneK6irVWPSmbMAhxETJK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kTLUv/dJMcabxcwMj/pvneK6irVWPSmbMAhxETJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkTLUv%2FdJMcabxcwMj%2FpvneK6irVWPSmbMAhxETJK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;527&quot; height=&quot;338&quot; data-origin-width=&quot;707&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙이 처음엔 좀 어색했다. 픽스처를 만들어두고 안 쓰는 게 낭비처럼 느껴졌으니까. 그런데 한 번 사고가 난 적이 있어서 규칙으로 못 박았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Post 엔티티의 좋아요 카운트 증가 메서드에 사실은 버그가 있었는데, PostFixture.create()도 같은 생성자 호출을 쓰고 있어서 픽스처가 만든 Post 객체가 그 버그 상태를 그대로 갖고 있었다. 테스트는 픽스처가 만든 객체를 받아 메서드를 호출하고 결과를 확인하니까, 두 군데가 같이 망가진 채로 그린이 떴다. 정확히 며칠을 잃었는지는 기억이 안 난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이렇게 정했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
fun `좋아요 시 likeCount가 1 증가한다`() {
    val author = UserFixture.create()  // 협력자는 픽스처 OK
    val post = Post(author, &quot;t&quot;, &quot;c&quot;, Category.TECH)  // SUT는 직접

    post.like()

    assertThat(post.likeCount).isEqualTo(1)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &quot;SUT는 직접, 협력자는 픽스처&quot;. Post의 생성자가 변경되면 이 테스트는 깨진다. 그게 좋은 신호다. 픽스처 뒤에 숨어 있으면 그 신호를 놓친다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 레이어는 어떻게 쓰는지를 표로 정리하면 대충 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어 픽스처 사용도&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;도메인 단위 (SUT)&lt;/td&gt;
&lt;td&gt;안 씀&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도메인 협력 객체&lt;/td&gt;
&lt;td&gt;씀&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository&lt;/td&gt;
&lt;td&gt;잘 씀 (createWithId 활용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service 단위&lt;/td&gt;
&lt;td&gt;적극 씀&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller WebMvc&lt;/td&gt;
&lt;td&gt;Request DTO 픽스처 위주&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;통합 테스트&lt;/td&gt;
&lt;td&gt;도메인 픽스처 + 실제 Repository&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 표는 가이드일 뿐이고, 사실 팀마다 사정이 다를 거다. 우리도 통합 테스트에서 어디까지 픽스처를 쓸지는 의견이 갈리는 영역이 아직 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;픽스처가 두 번째 도메인이 되는 신호들&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;639&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEx48Z/dJMcafNbVp1/5qcFWd06VjTmK1yXKmUB1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEx48Z/dJMcafNbVp1/5qcFWd06VjTmK1yXKmUB1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEx48Z/dJMcafNbVp1/5qcFWd06VjTmK1yXKmUB1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEx48Z%2FdJMcafNbVp1%2F5qcFWd06VjTmK1yXKmUB1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;566&quot; height=&quot;328&quot; data-origin-width=&quot;639&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 경계해야 할 패턴 몇 가지를 정리해두면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 규칙이 픽스처 안으로 기어들어오는 경우.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;object PostFixture {
    fun create(author: User = UserFixture.create()): Post {
        // &quot;관리자면 Notice&quot;라는 정책이 픽스처 안에 들어옴
        val category = if (author.role == Role.ADMIN) Category.NOTICE else Category.GENERAL
        return Post(author, &quot;...&quot;, &quot;...&quot;, category)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점부터 픽스처 변경은 도메인 정책 변경과 같은 무게가 된다. 픽스처는 &quot;유효한 기본값&quot;까지만 책임진다고 못 박지 않으면, 1년 뒤에 픽스처 안에 if 문이 5개 들어 있는 자기가 짠 코드를 보게 된다(경험담이다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오를 픽스처에 박는 메가 픽스처도 같은 종류의 함정이다. &quot;댓글 5개에 좋아요 10개 달린 게시글&quot;을 한 메서드로 만들어두면 처음엔 편한데, 어느 테스트가 정확히 무엇을 검증하는지 메서드명만 보고 알 수 없게 된다. 시나리오 조립은 테스트 본문이 직접 해야 한다. 그래야 그 테스트만의 setup이 코드에 그대로 드러난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 픽스처를 너무 일찍 만드는 것도 위험하다. 위에서 잠깐 말했지만, 응답 구조가 바뀌어도 테스트가 멀쩡히 통과하는 회귀가 생긴다. 응답 픽스처를 만든다면 그건 사실상 &quot;이 API의 스펙 문서&quot;가 되는 셈이고, PR 리뷰에서 함께 변경 대상으로 올려야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컨벤션이 다 좋은 건 아니다. java-test-fixtures 소스셋이 추가되면 처음 보는 사람은 디렉토리 구조에 한 번 당황한다. 헬퍼 메서드 하나 만드는 것보다 픽스처 클래스를 처음 만드는 비용도 크다. 작은 단일 모듈 프로젝트라면 굳이 이 단계로 갈 필요가 없을 수도 있다. 우리는 모듈이 두 개 이상에서 같은 도메인을 만지기 시작했을 때를 임계점으로 잡았는데, 이 기준도 절대적인 건 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어별 사용 규칙은 결국 PR 리뷰에서 자주 짚어지는 항목이 된다. 자동화하기 어렵고, 한동안은 사람이 의식해서 지켜야 한다. 솔직히 우리도 아직 잘 안 지켜지는 케이스가 종종 나온다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마지막으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;픽스처를 도입하면 Given이 한 줄로 줄어들고, 테스트 본문이 의도만 말하게 된다. 거기까지는 명확한 이득이다. 그 다음부터는 픽스처가 시간이 지나며 부패하지 않게 관리하는 게 진짜 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 모델이 바뀌었는데 픽스처 기본값은 그대로인지, &quot;유효한 기본값&quot; 안에 어느새 분기가 슬며시 들어와 있지 않은지, 응답 픽스처가 박제되어 있는지. 한 번 만들고 끝나는 게 아니라, 분기마다 한 번씩 봐줘야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 정리하면서도 사실 응답 DTO 픽스처는 우리 팀에서 아직 100% 합의가 안 된 영역이다. 이 부분은 다음 글에서 다시 정리해볼 생각이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토스 &amp;mdash; &lt;a href=&quot;https://toss.tech/article/test-strategy-server&quot;&gt;가치있는 테스트를 위한 전략과 구현&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;카카오페이 &amp;mdash; Given 지옥 / 테스트 코드 시리즈 (&lt;a href=&quot;https://tech.kakaopay.com/&quot;&gt;tech.kakaopay.com&lt;/a&gt; 검색)&lt;/li&gt;
&lt;li&gt;Gradle &amp;mdash; &lt;a href=&quot;https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures&quot;&gt;Java Test Fixtures Plugin&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/94</guid>
      <comments>https://imgdevel.tistory.com/94#entry94comment</comments>
      <pubDate>Sat, 24 Jan 2026 20:58:29 +0900</pubDate>
    </item>
    <item>
      <title>반복되는 테스트 환경에 메타 어노테이션을 만들기</title>
      <link>https://imgdevel.tistory.com/89</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;398&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r5QRh/dJMcagkZB41/Kai8cllJCadAz4ITlx2Ve1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r5QRh/dJMcagkZB41/Kai8cllJCadAz4ITlx2Ve1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r5QRh/dJMcagkZB41/Kai8cllJCadAz4ITlx2Ve1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr5QRh%2FdJMcagkZB41%2FKai8cllJCadAz4ITlx2Ve1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;760&quot; height=&quot;422&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;398&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 30개를 넘어가면, PR 리뷰에서 같은 질문이 계속 반복된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이거 @SpringBootTest 맞아요? @WebMvcTest 아닌가?&quot; &quot;여기엔 @DataJpaTest가 빠진 것 같은데...&quot; &quot;이 테스트는 unit이에요 integration이에요?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답이 매번 다르고, 같은 사람이 한 달 전과 다르게 답한다. 한 분기쯤 지나면 어노테이션 조합이 팀원 수만큼 늘어나 있다. 새로 합류한 사람의 첫 PR에서는 다섯 가지 다른 어노테이션이 등장하기도 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 단순한 스타일 문제가 아닌 이유는, &quot;이 테스트가 어떤 종류인가&quot;가 사람마다 다르게 해석되면 &lt;b&gt;빠르게 돌릴 테스트와 느리게 돌릴 테스트의 경계가 무너진다&lt;/b&gt;는 데 있다. 그러면 결국 다 같이 느린 모드로 돌리게 되고, 시간이 더 지나면 테스트 자체를 덜 돌리게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 우리 팀이 테스트 어노테이션을 컨벤션으로 박기로 한 이유, 그리고 메타 어노테이션 6개로 정리한 과정을 적은 글이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정한 한 줄&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 정한 규칙은 이거 하나다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 레이어는 이 메타 어노테이션 하나만 쓴다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;너무 단순해서 부끄러울 정도인데, 의외로 잘 작동한다. 사람이 기억해야 하는 게 &quot;어떤 어노테이션 + 어떤 import + 어떤 Tag&quot;가 아니라 &quot;이 테스트가 어느 레이어인가&quot; 하나로 줄어든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;메타 어노테이션 6종&lt;/h2&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;unit&quot;)
@ExtendWith(MockitoExtension::class)
annotation class UnitTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;repository&quot;)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
annotation class RepositoryJpaTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;controller-webmvc&quot;)
@WebMvcTest
@Import(WebMvcTestConfig::class)
annotation class ControllerWebMvcTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;integration&quot;)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(&quot;test&quot;)
annotation class IntegrationTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;integration-security&quot;)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(&quot;test&quot;, &quot;security&quot;)
annotation class IntegrationSecurityTest

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Tag(&quot;service-integration&quot;)
@SpringBootTest
@ActiveProfiles(&quot;test&quot;)
annotation class ServiceIntegrationTest
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(@JobIntegrationTest도 있는데 위 패턴과 거의 똑같아서 생략한다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각의 의도는 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어노테이션 검증 레이어 빠른가&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;@UnitTest&lt;/td&gt;
&lt;td&gt;도메인 / 순수 로직&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@RepositoryJpaTest&lt;/td&gt;
&lt;td&gt;JPA 매핑 / 쿼리&lt;/td&gt;
&lt;td&gt;보통&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@ControllerWebMvcTest&lt;/td&gt;
&lt;td&gt;HTTP 계약&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@ServiceIntegrationTest&lt;/td&gt;
&lt;td&gt;서비스 + Repository 협력&lt;/td&gt;
&lt;td&gt;느림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@IntegrationTest&lt;/td&gt;
&lt;td&gt;전체 흐름&lt;/td&gt;
&lt;td&gt;느림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@IntegrationSecurityTest&lt;/td&gt;
&lt;td&gt;전체 흐름 + 보안&lt;/td&gt;
&lt;td&gt;느림&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 클래스에는 한 줄로 끝난다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@RepositoryJpaTest
class PostRepositoryTest(
    @Autowired private val postRepository: PostRepository,
) {
    @Test
    fun `id로 조회하면 게시글이 반환된다`() { ... }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@DataJpaTest도, @AutoConfigureTestDatabase도, @Tag(&quot;repository&quot;)도 따로 안 쓴다. 메타 어노테이션 안에 다 들어 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JUnit Tag를 메타 안에 두는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메타 어노테이션 안에 @Tag(&quot;unit&quot;)을 넣은 이유는 테스트를 종류별로 골라서 실행하기 위해서다. JUnit 5는 @Tag로 마킹된 테스트만 골라 돌릴 수 있는데, 메타 어노테이션이 Tag를 자동으로 붙이니까 사용자는 클래스에 @UnitTest만 써도 그 클래스가 자동으로 &quot;unit&quot; 태그를 갖게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 왜 중요했냐면, 한 번은 도메인 로직 한 줄을 고치고 PR을 올렸는데 CI가 12분이 걸렸다. 평소 같으면 1~2분이면 끝날 변경이었다. 추적해 보니 CI가 모든 통합 테스트까지 포함해서 돌고 있었다. 그 시점에는 unit과 integration을 구분할 방법이 없어서, 한 번 돌리면 다 도는 구조였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 사건이 메타 어노테이션 안에 Tag를 묶기로 한 결정적 계기였다. 사람한테 &quot;이 테스트에 @Tag(&quot;unit&quot;) 붙여주세요&quot; 하고 부탁하면, 절반 정도는 잊는다. 메타 어노테이션 안에 같이 묶여 있으면 잊을 수가 없다. @UnitTest를 붙이는 순간 Tag도 따라온다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Gradle Task로 한 단계 더&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tag만으로는 부족했다. 개발자마다 IntelliJ Run Configuration을 다르게 만들어 두고 돌렸기 때문에, &quot;지금 빠른 테스트만 돌렸어요&quot;라는 말이 사람마다 다른 걸 의미했다. 그래서 Gradle Task에 명시적으로 박았다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;// build.gradle.kts
tasks.register&amp;lt;Test&amp;gt;(&quot;unitTest&quot;) {
    useJUnitPlatform {
        includeTags(&quot;unit&quot;)
    }
}

tasks.register&amp;lt;Test&amp;gt;(&quot;repositoryTest&quot;) {
    useJUnitPlatform {
        includeTags(&quot;repository&quot;)
    }
}

tasks.register&amp;lt;Test&amp;gt;(&quot;controllerTest&quot;) {
    useJUnitPlatform {
        includeTags(&quot;controller-webmvc&quot;)
    }
}

tasks.register&amp;lt;Test&amp;gt;(&quot;integrationTest&quot;) {
    useJUnitPlatform {
        includeTags(&quot;integration&quot;, &quot;integration-security&quot;, &quot;service-integration&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 루프는 이렇게 돈다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;./gradlew :app-api:unitTest         # 1초대
./gradlew :app-api:repositoryTest   # 10초대
./gradlew :app-api:integrationTest  # 분 단위
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CI도 같은 분리를 따라간다. PR에는 unitTest + repositoryTest + controllerTest만 돌리고, main 머지에는 integration까지 돌린다. 12분 걸리던 CI가 PR 단계에서는 2~3분으로 떨어졌다 (정확히 몇 분 줄었는지는 기록 안 해뒀다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 체인을 그림으로 보면 이렇다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart LR
    A[&quot;테스트 클래스&amp;lt;br/&amp;gt;@UnitTest&quot;] --&amp;gt; B[&quot;메타 어노테이션 내부&amp;lt;br/&amp;gt;@Tag('unit')&quot;]
    B --&amp;gt; C[&quot;JUnit Platform&amp;lt;br/&amp;gt;includeTags('unit')&quot;]
    C --&amp;gt; D[&quot;Gradle Task&amp;lt;br/&amp;gt;:unitTest&quot;]
    D --&amp;gt; E[&quot;CI / 로컬&amp;lt;br/&amp;gt;./gradlew :unitTest&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 이 체인이 &lt;b&gt;한 곳에서 시작해서 한 곳으로 흐른다&lt;/b&gt;는 것이다. 어느 한 단계에서 사람이 의식해서 맞춰야 하는 부분이 있으면, 결국 어긋난다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;도입 후 변화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도입한 지 얼마나 됐는지는 정확히 기억이 안 난다. 한 분기 정도였던 것 같은데, 두 가지가 분명히 바뀌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &quot;이거 @WebMvcTest 맞아요?&quot; 같은 PR 코멘트가 사라졌다. 어노테이션이 한 가지로 줄어들면 고를 게 없어지니까, 자연스럽게 메뉴가 단순해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로, PR 단계에서 unit + repository + controller만 돌리는 게 자연스러워졌다. 더 자주 PR을 올리고, 더 자주 머지하게 됐다. 이건 측정 가능한 결과가 아니라 분위기 변화에 가까운데, 분위기 변화 쪽이 사실 더 큰 효과였다고 생각한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컨벤션을 다 좋다고는 못 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 메타 어노테이션은 추상화라서, 처음 보는 사람은 &quot;이 어노테이션이 진짜로 뭘 하는지&quot; 한 번 까봐야 한다. IDE에서 @RepositoryJpaTest를 Cmd+클릭해서 정의를 보지 않으면 무엇이 들어 있는지 모른다. 도구가 자기를 가린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 어떤 테스트에는 메타 어노테이션을 안 만드는 게 낫다. 예를 들어 @JsonTest는 한 모듈에서 두세 번만 쓰는데, 이걸 @JsonSliceTest 같은 메타로 또 만들면 메뉴만 늘어난다. 우리는 메타 어노테이션을 7개, 8개로 늘리지 않으려고 의식한다. 일회성에 가까운 슬라이스는 그냥 직접 붙인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 함정은 메타 어노테이션 안의 import 구성이다. 한 번은 @Import(SecurityTestConfig::class)를 @ControllerWebMvcTest 안에 넣었다가, 그게 필요 없는 컨트롤러 테스트도 SecurityTestConfig를 다 로드해서 30초쯤 빌드 시간이 늘어난 적이 있다. 메타 어노테이션은 영향 범위가 넓어서, 변경할 때 PR 리뷰에서 평소보다 한 번 더 본다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마지막으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 컨벤션의 핵심은 &quot;어노테이션을 줄인 것&quot;보다 **&quot;어노테이션 분류 자체를 한 곳에서 결정한 것&quot;**에 가깝다. 사람마다 다르게 답하던 질문이 코드베이스에 명시적으로 박혀 있게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 처음에는 이 정도까지 컨벤션으로 박을 필요가 있을까 의심했다. 가이드 문서로 두면 될 거라고 생각했는데, 가이드 문서는 본 사람만 따른다. 메타 어노테이션은 안 따르면 컴파일이 안 되거나 Tag가 빠진다. 그 차이가 생각보다 컸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남은 과제는 &quot;메타 어노테이션을 몇 개까지 늘릴 것인가&quot;다. 지금은 6개인데, 새로운 슬라이스를 검증하고 싶을 때마다 메타로 만들어야 할지 그냥 직접 붙일지 기준이 아직 모호하다. 그건 다음에 정리해볼 생각이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토스 &amp;mdash; &lt;a href=&quot;https://toss.tech/article/test-strategy-server&quot;&gt;가치있는 테스트를 위한 전략과 구현&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;JUnit 5 User Guide &amp;mdash; &lt;a href=&quot;https://docs.junit.org/current/user-guide/#writing-tests-tags-and-filters&quot;&gt;Tags and Filters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Spring Boot &amp;mdash; &lt;a href=&quot;https://docs.spring.io/spring-boot/appendix/test-auto-configuration/slices.html&quot;&gt;Test Slice Annotations&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/89</guid>
      <comments>https://imgdevel.tistory.com/89#entry89comment</comments>
      <pubDate>Fri, 23 Jan 2026 23:31:31 +0900</pubDate>
    </item>
    <item>
      <title>레이어별 테스트 전략은 어떻게 나누어야 하는가?</title>
      <link>https://imgdevel.tistory.com/87</link>
      <description>&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 테스트가 지금 뭘 검증하는 거였지?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 글을 쓰게 된 계기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 작성한 테스트 파일들을 돌아보다 이상한 장면을 발견했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 테스트인데 &lt;code&gt;@DataJpaTest&lt;/code&gt;가 붙어 있고, 실제 DB에 insert가 들어가고 있었습니다.&lt;br /&gt;컨트롤러 테스트에는 비즈니스 분기 조건이 잔뜩 들어가 있었습니다.&lt;br /&gt;통합 테스트를 열어봤더니 &lt;code&gt;verify(repository).save(any())&lt;/code&gt; 같은 Mock 검증이 중간에 섞여 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 테스트가 깨졌을 때였습니다.&lt;br /&gt;서비스 테스트가 깨졌는데 원인이 JPA 매핑 문제였습니다.&lt;br /&gt;컨트롤러 테스트가 깨졌는데 원인이 서비스의 분기 로직이었습니다.&lt;br /&gt;어느 레이어의 문제인지 판정이 안 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 깨달았습니다. 테스트가 뭘 검증하는지 모르면, 실패해도 원인을 찾을 수 없었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음에는 다 섞여 있었습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에 제가 쓴 테스트들은 대체로 이런 모습이었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스 테스트가 JPA dirty checking까지 검증합니다.&lt;/li&gt;
&lt;li&gt;컨트롤러 테스트가 할인 계산 분기까지 검증합니다.&lt;/li&gt;
&lt;li&gt;통합 테스트가 JSON 응답 필드 하나하나까지 확인합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각은 &quot;더 많이 검증한다&quot;는 이유로 괜찮아 보였습니다.&lt;br /&gt;그런데 테스트가 쌓이니까 이상한 일이 벌어졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티 필드 하나를 바꿨는데 컨트롤러 테스트 10개가 깨졌습니다.&lt;br /&gt;서비스의 분기 하나를 수정했는데 통합 테스트 5개가 같이 깨졌습니다.&lt;br /&gt;&quot;이게 진짜 문제인가?&quot;를 판정하는 데 시간이 더 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 테스트가 모든 걸 검증하려 하면, 그 테스트는 아무것도 제대로 검증하지 못합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 피라미드, 체감으로 다시 읽기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;교과서는 보통 단위 &amp;rarr; 통합 &amp;rarr; E2E 순서로 설명합니다.&lt;br /&gt;근데 저는 그 순서로 피라미드를 이해하지 못했습니다.&lt;br /&gt;제가 체감한 순서는 달랐습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;단위 테스트가 많아야 변경이 빨라집니다.&lt;/b&gt; 분기 하나 바꿨을 때 초 단위로 피드백이 옵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;통합 테스트가 핵심만 있어야 CI가 견딥니다.&lt;/b&gt; 모든 플로우를 통합으로 돌리면 빌드가 10분을 넘깁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인수 테스트는 진짜 엔드투엔드만.&lt;/b&gt; 유저 시나리오 그대로. 나머지는 위에서 이미 다 걸러졌어야 합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피라미드는 개수 분포가 아닙니다. 책임 분포입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;레이어별로 정리한 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어마다 &quot;답해야 할 질문&quot;이 다르다는 걸 받아들이고 나서, 테스트도 레이어별로 정리됐습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도메인은 가장 먼저 정리되는 레이어입니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Entity와 Policy는 DB도 Spring도 필요 없습니다.&lt;br /&gt;생성자나 팩토리 메서드로 시작 상태를 만들고, 메서드를 호출하고, 상태를 확인합니다. 그게 전부입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
void 주문_금액이_10만원_이상이면_VIP_등급으로_변경된다() {
    Member member = Member.create(&quot;user@test.com&quot;, &quot;tester&quot;);
    Order order = Order.of(member, 150_000);

    member.applyRankPolicy(order);

    assertThat(member.getRank()).isEqualTo(Rank.VIP);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 조심할 게 있습니다.&lt;br /&gt;테스트용 객체를 만드는 코드가 20줄이 넘어간다면, 도메인 설계가 의심스럽다는 신호입니다.&lt;br /&gt;Fixture를 복잡하게 만드는 방향이 아니라, 생성 자체가 쉬워지도록 도메인을 다듬어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Policy는 (조건 &amp;rarr; 결과) 매트릭스가 그대로 테스트가 됩니다.&lt;br /&gt;조건별로 한 케이스씩, 결과를 단문으로 검증합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 테스트가 가벼워야, 그 위에 쌓을 테스트가 가벼워집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;리포지토리는 JPA가 의도대로 돌아가는지만 봅니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 사용하는 메타 어노테이션은 이렇게 구성되어 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@DataJpaTest
@ActiveProfiles(&quot;test&quot;)
@Import({QueryDslConfig.class, JpaAuditingTestConfig.class})
public @interface RepositoryJpaTest { }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 테스트는 이렇게 생겼습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@RepositoryJpaTest
class PostRepositoryTest {

    @Autowired
    private PostRepository postRepository;

    @Autowired
    private TestEntityManager em;

    @Test
    void 특정_작성자의_게시글을_최신순으로_조회한다() {
        Member author = em.persist(Member.create(&quot;user@test.com&quot;, &quot;tester&quot;));
        em.persist(Post.of(author, &quot;old title&quot;, &quot;body&quot;));
        em.persist(Post.of(author, &quot;new title&quot;, &quot;body&quot;));
        em.flush();
        em.clear();

        List&amp;lt;Post&amp;gt; result = postRepository.findByAuthorOrderByCreatedAtDesc(author);

        assertThat(result).extracting(Post::getTitle)
                .containsExactly(&quot;new title&quot;, &quot;old title&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 검증하는 건 다음 네 가지뿐입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;엔티티 매핑이 의도대로 붙는가 (컬럼 타입, nullable, 연관관계)&lt;/li&gt;
&lt;li&gt;커스텀 쿼리가 예상한 결과를 돌려주는가 (QueryDSL, @Query)&lt;/li&gt;
&lt;li&gt;카운트/집계 쿼리의 결과가 맞는가&lt;/li&gt;
&lt;li&gt;cascade / orphanRemoval이 기대한 대로 동작하는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 여기서 &lt;b&gt;검증하지 않는 것들&lt;/b&gt;이 중요합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스의 if/else 분기는 검증하지 않습니다.&lt;/li&gt;
&lt;li&gt;컨트롤러의 응답 포맷은 검증하지 않습니다.&lt;/li&gt;
&lt;li&gt;트랜잭션 전파 동작은 여기서 보지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리포지토리 테스트는 JPA가 한 일만 검증합니다. 서비스 분기를 집어넣는 순간 책임이 섞입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서비스는 분기와 플로우만 봅니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 단위 테스트는 가장 빠르고 가장 많아야 하는 영역입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@ExtendWith(MockitoExtension.class)
@Tag(&quot;unit&quot;)
public @interface UnitTest { }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB도, HTTP도, Security도 없습니다.&lt;br /&gt;협력자는 Mock 또는 Stub으로 대체합니다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@UnitTest
class SignupServiceTest {

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private SignupService signupService;

    @Test
    void 이미_가입된_이메일이면_예외를_던진다() {
        given(memberRepository.existsByEmail(&quot;user@test.com&quot;)).willReturn(true);

        assertThatThrownBy(() -&amp;gt; signupService.signup(
                new SignupRequest(&quot;user@test.com&quot;, &quot;password&quot;, &quot;tester&quot;)))
                .isInstanceOf(DuplicatedEmailException.class);

        verify(memberRepository, never()).save(any());
    }

    @Test
    void 신규_이메일이면_비밀번호를_인코딩해_저장한다() {
        given(memberRepository.existsByEmail(&quot;user@test.com&quot;)).willReturn(false);
        given(passwordEncoder.encode(&quot;password&quot;)).willReturn(&quot;encoded&quot;);
        given(memberRepository.save(any(Member.class)))
                .willAnswer(inv -&amp;gt; inv.getArgument(0));

        signupService.signup(new SignupRequest(&quot;user@test.com&quot;, &quot;password&quot;, &quot;tester&quot;));

        ArgumentCaptor&amp;lt;Member&amp;gt; captor = ArgumentCaptor.forClass(Member.class);
        verify(memberRepository).save(captor.capture());
        assertThat(captor.getValue().getPassword()).isEqualTo(&quot;encoded&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 검증하는 건 세 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;if/else 분기가 맞게 갈라지는가&lt;/li&gt;
&lt;li&gt;예외가 의도한 조건에서 의도한 타입으로 던져지는가&lt;/li&gt;
&lt;li&gt;협력 순서가 비즈니스 요구사항과 맞는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 검증하지 &lt;b&gt;않는 것&lt;/b&gt;은 더 중요합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPA 영속성 (그건 리포지토리 테스트에서 확인합니다)&lt;/li&gt;
&lt;li&gt;HTTP 응답 형식 (그건 컨트롤러 테스트에서 확인합니다)&lt;/li&gt;
&lt;li&gt;Spring Security 필터 체인 (그건 통합 테스트에서 확인합니다)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 테스트에 DB가 들어오는 순간, 그건 통합 테스트입니다. 분리해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨트롤러는 HTTP 계약만 봅니다&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@WebMvcTest
@AutoConfigureMockMvc(addFilters = false)
@Import(TestSecurityConfig.class)
public @interface ControllerWebMvcTest { }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스는 &lt;code&gt;@MockitoBean&lt;/code&gt;으로 고정하고, 응답 스텁만 제공합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;@ControllerWebMvcTest(SignupController.class)
class SignupControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private SignupService signupService;

    @Test
    void 정상_요청이면_201과_생성된_유저ID를_돌려준다() throws Exception {
        given(signupService.signup(any(SignupRequest.class)))
                .willReturn(new SignupResponse(1L));

        mockMvc.perform(post(&quot;/auth/signup&quot;)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(
                                new SignupRequest(&quot;user@test.com&quot;, &quot;password&quot;, &quot;tester&quot;))))
                .andExpect(status().isCreated())
                .andExpect(jsonPath(&quot;$.data.userId&quot;).value(1L));
    }

    @Test
    void 이메일_형식이_아니면_400을_돌려준다() throws Exception {
        mockMvc.perform(post(&quot;/auth/signup&quot;)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(
                                new SignupRequest(&quot;not-an-email&quot;, &quot;password&quot;, &quot;tester&quot;))))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath(&quot;$.errors[0].field&quot;).value(&quot;email&quot;));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 검증하는 건 이런 것들입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;URL 매핑이 맞게 걸려 있는가&lt;/li&gt;
&lt;li&gt;요청 스키마가 맞게 deserialize 되는가&lt;/li&gt;
&lt;li&gt;응답 스키마가 맞게 serialize 되는가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Valid&lt;/code&gt;가 제대로 걸리는가&lt;/li&gt;
&lt;li&gt;에러 응답 포맷이 규약에 맞는가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 비즈니스 규칙은 검증하지 않습니다.&lt;br /&gt;&quot;할인율 계산이 맞는가&quot;는 컨트롤러 테스트의 관심사가 아닙니다.&lt;br /&gt;서비스가 돌려준 값을 HTTP 응답으로 잘 내보내는지만 봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러 테스트는 &quot;HTTP 껍데기가 잘 붙어있는지&quot;만 확인합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;통합 테스트는 처음부터 끝까지 붙어 있는지만 확인합니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합 테스트는 세 갈래로 나눴습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@IntegrationTest&lt;/code&gt; &amp;mdash; HTTP 포함 전 구간 플로우&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@ServiceIntegrationTest&lt;/code&gt; &amp;mdash; HTTP 없이 서비스 + DB 연동&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@JobIntegrationTest&lt;/code&gt; &amp;mdash; 배치/스케줄러 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통점은 &quot;진짜로 붙어 있는지&quot;를 보는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@IntegrationTest
class SignupIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private ObjectMapper objectMapper;

    @TestConfiguration
    static class StubbedExternalConfig {
        @Bean
        @Primary
        MailClient mailClient() {
            return new NoOpMailClient();
        }
    }

    @Test
    void 회원가입_요청이_DB와_메일_전송까지_이어진다() throws Exception {
        mockMvc.perform(post(&quot;/auth/signup&quot;)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(
                                new SignupRequest(&quot;user@test.com&quot;, &quot;password&quot;, &quot;tester&quot;))))
                .andExpect(status().isCreated());

        assertThat(memberRepository.findByEmail(&quot;user@test.com&quot;)).isPresent();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 메일 시스템은 &lt;code&gt;@TestConfiguration&lt;/code&gt; + &lt;code&gt;@Primary&lt;/code&gt;로 Fake로 대체합니다.&lt;br /&gt;DB는 실제로 붙여서, 회원가입 플로우가 끝까지 이어지는지만 확인합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컴포넌트가 실제로 조립되어 동작하는가&lt;/li&gt;
&lt;li&gt;트랜잭션 경계가 의도대로 잡히는가&lt;/li&gt;
&lt;li&gt;외부 시스템과의 연동이 끊기지 않았는가 (외부는 Fake로 대체)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;검증하지 말아야 할 것&lt;/b&gt;이 핵심입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단위 테스트에서 이미 커버된 분기를 반복 검증하지 않습니다.&lt;/li&gt;
&lt;li&gt;JSON 필드 하나하나를 여기서 검증하지 않습니다 (그건 컨트롤러 테스트 몫).&lt;/li&gt;
&lt;li&gt;리포지토리 쿼리의 세부 결과를 여기서 다시 검증하지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합 테스트는 &quot;다 붙어서 돌아가는가&quot;만 봅니다. 분기 검증을 여기서 반복하면 CI가 느려집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;레이어 경계를 지킨다는 것은&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어 경계는 책임 경계입니다.&lt;br /&gt;그리고 책임 경계는 곧 테스트 범위의 경계입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 경계를 넘어가면 벌어지는 일은 매번 비슷했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실패했을 때 원인 레이어가 불명확해집니다.&lt;/li&gt;
&lt;li&gt;한 번의 변경에 여러 레이어 테스트가 같이 깨집니다.&lt;/li&gt;
&lt;li&gt;CI가 느려지고, 느려지면 안 돌리게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전편에서 &quot;Mock이 힘들면 설계를 의심해야 합니다&quot;라고 했습니다.&lt;br /&gt;이번 글의 주장은 그 연장선입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테스트 범위가 자꾸 넓어진다면, 레이어 분리를 의심해야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트의 경계를 지키면, 설계의 경계도 같이 지켜집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;저도 한때 이렇게 생각했습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;&lt;code&gt;@SpringBootTest&lt;/code&gt; 하나면 다 되는 거 아닌가?&quot;&lt;/b&gt; &amp;mdash; 아닙니다.&lt;br /&gt;모든 걸 로딩하면 모든 걸 놓칩니다. 실패 원인이 어디인지 판정이 안 되고, 테스트 한 개가 2초씩 걸립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;레이어별로 나누면 테스트가 더 많아지는 거 아닌가?&quot;&lt;/b&gt; &amp;mdash; 더 많아집니다.&lt;br /&gt;근데 각각이 더 빠르고 더 명확합니다. 100개의 단위 테스트가 10개의 통합 테스트보다 빠르게 돌아갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;통합 테스트만 있으면 단위 테스트는 중복 아닌가?&quot;&lt;/b&gt; &amp;mdash; 아닙니다.&lt;br /&gt;통합 테스트로 분기를 전부 덮으려 하면 테스트가 폭발합니다. 분기는 단위에서 덮고, 통합은 &quot;붙어 있는가&quot;만 보는 게 맞습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리하면서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전전편에서 좋은 테스트는 빠르고 독립적이고 반복 가능해야 한다고 썼습니다.&lt;br /&gt;전편에서는 Mock을 더 잘 쓰는 방법이 아니라 Mock이 왜 필요한지를 물어야 한다고 썼습니다.&lt;br /&gt;이번 글에서 제가 정리한 건, 레이어마다 테스트가 답해야 할 질문이 다르다는 것이었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도메인: &quot;이 규칙이 맞나?&quot;&lt;/li&gt;
&lt;li&gt;리포지토리: &quot;JPA가 이렇게 돌아가나?&quot;&lt;/li&gt;
&lt;li&gt;서비스: &quot;분기가 맞나?&quot;&lt;/li&gt;
&lt;li&gt;컨트롤러: &quot;HTTP 계약이 맞나?&quot;&lt;/li&gt;
&lt;li&gt;통합: &quot;다 붙어서 돌아가나?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 레이어마다 다른 질문에 답해야 합니다. 같은 질문을 반복하는 순간, 테스트는 중복이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이어서 이런 주제를 다뤄볼 예정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Fixture와 실행 전략은 왜 유지보수성에 직접 영향을 주는가&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/614&quot;&gt;테스트 코드에서 내부 구현 검증 피하기 &amp;mdash; 조졸두 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/637&quot;&gt;Stub을 이용한 Service 계층 단위 테스트 하기 &amp;mdash; 조졸두 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/mock-test-code/&quot;&gt;실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 1: Mock &amp;mdash; 카카오페이 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/mock-test-code-part-2/&quot;&gt;실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 2: 테스트 코드로부터 피드백 받기 &amp;mdash; 카카오페이 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.inflab.com/20230404-test-code/&quot;&gt;테스트 코드를 왜 그리고 어떻게 작성해야 할까? &amp;mdash; 인프랩 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>테스트 코드</category>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/87</guid>
      <comments>https://imgdevel.tistory.com/87#entry87comment</comments>
      <pubDate>Sat, 17 Jan 2026 15:44:57 +0900</pubDate>
    </item>
    <item>
      <title>테스트 더블은 언제 사용하고 언제 남용이 되는가</title>
      <link>https://imgdevel.tistory.com/85</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이거 Mock으로 감싸면 되지 않나요?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;테스트 카드의 대조적 변화.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPS0Iz/dJMcabKCIuR/HNKo5K6NKLU0zkU4gHh1c0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPS0Iz/dJMcabKCIuR/HNKo5K6NKLU0zkU4gHh1c0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPS0Iz/dJMcabKCIuR/HNKo5K6NKLU0zkU4gHh1c0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPS0Iz%2FdJMcabKCIuR%2FHNKo5K6NKLU0zkU4gHh1c0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;732&quot; height=&quot;488&quot; data-filename=&quot;테스트 카드의 대조적 변화.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 글을 쓰게 된 계기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 테스트 코드의 필요성과 좋은 테스트의 기준에 대해 정리했습니다. 그 이후로 실제로 테스트를 붙이기 시작했는데, 새로운 종류의 고민이 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 테스트를 하나 작성하려고 클래스를 열었습니다. &lt;code&gt;@Mock&lt;/code&gt;을 붙여야 할 필드가 네 개. &lt;code&gt;PostRepository&lt;/code&gt;, &lt;code&gt;MemberRepository&lt;/code&gt;, &lt;code&gt;PostLikeRepository&lt;/code&gt;, 거기에 외부 알림용 클라이언트까지. &lt;code&gt;given().willReturn()&lt;/code&gt; 체인을 하나씩 설정하다 보니, 테스트 코드가 프로덕션 코드보다 길어졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 테스트가 통과하니까 괜찮다고 생각했습니다. 문제는 그 다음이었습니다. 서비스 로직을 리팩터링했는데, 기능은 정상 동작하는데 테스트가 깨졌습니다. 원인을 보니 &lt;code&gt;verify(repository).save(any())&lt;/code&gt;가 실패한 것이었습니다. 저장 로직의 호출 방식이 바뀌었을 뿐, 결과는 똑같았는데 테스트가 그걸 허용하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'테스트가 리팩터링을 막고 있다.' 이전 글에서 테스트는 변경을 두려워하지 않게 만들어주는 장치라고 썼는데, 정반대의 일이 벌어지고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 더블을 쓸 줄 아는 것과, 잘 쓸 줄 아는 것은 다른 문제였습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 더블, 간단히 정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 Mockito를 배웠을 때는 모든 의존성에 &lt;code&gt;@Mock&lt;/code&gt;을 붙이는 게 당연하다고 생각했습니다. 근데 테스트 더블에는 Mock 말고도 여러 종류가 있고, 각각 목적이 다릅니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;종류&lt;/th&gt;
&lt;th&gt;한 줄 설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Dummy&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;쓰이지 않는 파라미터를 채우기 위한 자리 채우기 객체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Stub&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;미리 정해둔 값을 반환하는 &quot;대답 전용&quot; 객체. 호출 횟수&amp;middot;순서에 관심 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Fake&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;실제 동작을 흉내 내는 간이 구현. 예: &lt;code&gt;InMemoryRepository&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Mock&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;어떻게 호출되었는지(횟수, 인자, 순서)를 검증하는 객체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Spy&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;실제 객체를 감싸서 호출을 기록. 일부만 스텁 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;// Stub: 항상 20%를 반환하는 할인율 클라이언트
RateClient stubClient = () -&amp;gt; 20;

// Fake: HashMap 기반 인메모리 저장소
class InMemoryMemberRepository implements MemberRepository {
    private final Map&amp;lt;Long, Member&amp;gt; store = new HashMap&amp;lt;&amp;gt;();
    // save(), findById() 등을 메모리에서 처리
}

// Mock: Mockito로 행위 검증
verify(authValidator).validateSignup(request);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 개발자가 Mock부터 배웁니다. Mockito가 워낙 편하니까 &lt;code&gt;@Mock&lt;/code&gt;과 &lt;code&gt;given().willReturn()&lt;/code&gt;만으로 모든 걸 해결하려 합니다. 그게 문제의 시작이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock은 테스트 더블의 한 종류일 뿐입니다. 그런데 대부분 Mock부터 배웁니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 써야 하는가 &amp;mdash; 체감 순서대로&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 객체로 충분한 곳에 Mock을 쓰고 있었습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 엔티티나 단순한 정책 객체를 테스트할 때도 &lt;code&gt;@Mock&lt;/code&gt;을 붙이고 있었습니다. &lt;code&gt;OwnershipPolicy&lt;/code&gt;를 Mock으로 감싸고, &lt;code&gt;given(policy.check(...)).willReturn(true)&lt;/code&gt;를 설정한 뒤, 서비스 로직을 테스트했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 이 테스트가 실제로 검증하는 게 뭔지 생각해보면, Policy의 로직이 아니라 &quot;제가 짠 Mock 설정&quot;이 맞는지를 확인하는 것이었습니다. Policy에 버그가 있어도 이 테스트는 통과합니다. Mock이 진짜 로직을 대신하고 있으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 알게 된 우선순위가 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 객체 &amp;rarr; Fake/Stub &amp;rarr; Mock&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 엔티티, 값 객체, 단순 Policy는 그냥 &lt;code&gt;new&lt;/code&gt;로 만들면 됩니다. DB나 네트워크가 필요 없는 객체를 굳이 Mock으로 감쌀 이유가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 객체로 검증할 수 있는데 Mock을 쓰면, 테스트가 검증하는 건 Mock 설정뿐입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;verify()는 비즈니스 규칙일 때만 씁니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock의 핵심 기능은 &lt;code&gt;verify()&lt;/code&gt;입니다. &quot;이 메서드가 호출되었는가&quot;를 검증합니다. 근데 모든 호출을 verify할 필요는 없습니다. 기준은 하나입니다. &lt;b&gt;그 호출이 비즈니스 규칙인가?&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;// 좋은 verify: &quot;댓글이 생성되면 게시글의 댓글 카운트가 증가해야 한다&quot;는 비즈니스 규칙
verify(post).incrementCommentCount();

// 나쁜 verify: repository.save()가 호출되었는지는 구현 세부사항
verify(repository).save(any());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 &quot;댓글 생성 시 카운트가 올라간다&quot;는 비즈니스 요구사항을 표현합니다. 이건 내부 구현이 바뀌더라도 유지되어야 할 규칙입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 그냥 구현을 따라 적은 것입니다. 저장 방식이 &lt;code&gt;save()&lt;/code&gt; 한 번에서 &lt;code&gt;saveAll()&lt;/code&gt;로 바뀌면 테스트가 깨집니다. 기능은 동일한데. 이런 테스트가 리팩터링을 방해합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;verify가 비즈니스 요구사항을 말하고 있는지, 구현을 따라 적은 건지 구분해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Stub과 Fake는 과소평가되어 있습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock만 알면 모든 것을 Mock으로 해결하려 합니다. 근데 많은 경우 Stub이나 Fake가 더 나은 선택입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// Stub: 인터페이스 하나만 구현하면 끝
RateClient stubClient = () -&amp;gt; 20;
DiscountService service = new DiscountService(stubClient);

// Fake: InMemoryRepository로 DB 없이 서비스 로직 전체를 검증
MemberRepository fakeRepo = new InMemoryMemberRepository();
SignupService service = new SignupService(fakeRepo);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stub은 외부 API 응답을 고정할 때 유용합니다. 인터페이스를 람다 한 줄로 구현하면 됩니다. &lt;code&gt;given().willReturn()&lt;/code&gt;을 쓸 필요도 없고, &lt;code&gt;verify()&lt;/code&gt;로 뭘 검증할 일도 없습니다. 입력에 대한 출력만 정해주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fake는 한 단계 더 갑니다. &lt;code&gt;InMemoryMemberRepository&lt;/code&gt;처럼 HashMap으로 구현하면, 실제 DB 없이도 저장/조회 흐름을 검증할 수 있습니다. 통합 테스트까지 가지 않아도 서비스 로직의 흐름을 빠르게 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock 없이도 테스트할 수 있는 방법은 생각보다 많습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mock이 힘들면 설계를 의심해야 합니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 가장 하고 싶은 이야기입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 테스트를 작성하는데 Given 절이 30줄을 넘어갔습니다. Elasticsearch에서 상품 정보를 가져오고, Redis에서 환율을 조회하고, MySQL에서 쿠폰과 가맹점 정보를 읽어오는 서비스였습니다. 테스트 하나를 쓰려면 네 개의 Mock에 대해 각각 &lt;code&gt;given().willReturn()&lt;/code&gt;을 설정해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때 든 생각은 'Mock을 더 잘 써야 하나?'였습니다. 근데 정답은 그게 아니었습니다. &lt;b&gt;이 클래스가 너무 많은 일을 하고 있었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오페이 기술 블로그에서 본 비유가 인상적이었습니다. 컨베이어 벨트의 작업자가 전부 열심히 일하고 있는데도 라인이 멈추면, 문제는 작업자의 능력이 아니라 한 사람에게 너무 많은 공정이 몰려 있는 것입니다. 새로운 작업자를 추가해서 일을 나눠야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에서도 마찬가지입니다. &lt;code&gt;OrderService&lt;/code&gt; 하나가 상품 검증, 환율 계산, 쿠폰 정책, 가맹점 조회를 전부 담당하고 있으면, 테스트가 복잡해지는 건 당연합니다. &lt;code&gt;ProductPolicy&lt;/code&gt;, &lt;code&gt;CouponPolicy&lt;/code&gt;, &lt;code&gt;PricingService&lt;/code&gt; 같은 협력 객체로 책임을 나누면, 각 테스트는 자기 범위 안에서만 집중할 수 있습니다. Given 절도 짧아집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 &quot;테스트를 붙이기 어려운 코드는 대개 설계도 좋지 않다&quot;고 썼습니다. 테스트 더블 관점에서 같은 이야기를 다시 하면 이렇게 됩니다. Mock이 너무 많이 필요하다는 건, 그 클래스의 의존성이 너무 많다는 뜻이고, 의존성이 많다는 건 책임이 과하다는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock을 더 잘 쓰는 방법을 찾지 말고, Mock이 왜 이렇게 많이 필요한지를 물어야 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;@MockBean은 편리하지만 비용이 있습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러 테스트에서 &lt;code&gt;@MockBean&lt;/code&gt;은 거의 필수적으로 사용됩니다. &lt;code&gt;@WebMvcTest&lt;/code&gt;에서 Service를 대체하려면 &lt;code&gt;@MockBean&lt;/code&gt;을 쓰는 게 자연스럽습니다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;@WebMvcTest(SignupController.class)
class SignupControllerTest {

    @MockBean
    SignupService signupService;

    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 이게 통합 테스트까지 퍼지면 문제가 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 30개쯤 돌릴 때까지는 몰랐습니다. 60개가 넘어가면서 빌드 시간이 눈에 띄게 느려졌습니다. 원인을 추적해보니 Spring ApplicationContext가 반복적으로 재초기화되고 있었습니다. &lt;code&gt;@MockBean&lt;/code&gt; 조합이 테스트 클래스마다 다르면, Spring은 기존 컨텍스트를 재사용하지 못하고 새로 띄웁니다. 테스트 클래스가 늘어날수록 컨텍스트 초기화 횟수도 비례해서 늘어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대안은 &lt;code&gt;@TestConfiguration&lt;/code&gt;과 &lt;code&gt;@Primary&lt;/code&gt;를 사용하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@TestConfiguration
class ClientTestConfiguration {

    @Bean
    @Primary
    PartnerClient mockPartnerClient() {
        return mock(PartnerClient.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 Mock Bean이 테스트 전용 설정으로 한 번만 등록되고, 모든 테스트 클래스가 같은 컨텍스트를 재사용합니다. &lt;code&gt;@MockBean&lt;/code&gt;처럼 각 테스트마다 컨텍스트를 새로 만들 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@MockBean은 편리하지만, 통합 테스트에서 남발하면 테스트 전체를 느리게 만듭니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 저는 이렇게 정리했습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지의 경험을 레이어별로 정리하면 이렇게 됩니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;레이어&lt;/th&gt;
&lt;th&gt;전략&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Domain (Entity/Policy)&lt;/td&gt;
&lt;td&gt;실제 객체만. 테스트 더블 금지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository&lt;/td&gt;
&lt;td&gt;실제 JPA + 테스트 DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service 단위 테스트&lt;/td&gt;
&lt;td&gt;@Mock (Repository/외부 클라이언트)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller&lt;/td&gt;
&lt;td&gt;@MockitoBean (Service)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;통합 테스트&lt;/td&gt;
&lt;td&gt;실제 빈 유지, 외부 시스템만 Fake/Stub (@TestConfiguration)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 표는 처음부터 있었던 게 아닙니다. 도메인에 Mock을 쓰고, 통합 테스트에서 &lt;code&gt;@MockBean&lt;/code&gt;을 남발하고, 하나씩 고쳐가면서 만들어진 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 레이어마다 적합한 테스트 더블이 다르다는 것입니다. 도메인은 실제 객체가 맞고, 서비스 단위 테스트에서는 Mock이 합리적이고, 통합 테스트에서는 Fake가 나은 경우가 많습니다. 한 가지 방식으로 모든 곳을 커버하려는 게 남용의 시작입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리하면서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 &quot;테스트는 변경을 두려워하지 않게 만들어주는 장치입니다&quot;라고 썼습니다. 여전히 맞는 말이라고 생각합니다. 근데 테스트 자체가 구현에 강하게 결합되어 있으면, 오히려 테스트가 변경을 두렵게 만드는 장치가 됩니다. Mock을 남용한 테스트가 정확히 그 상태입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 더블은 실제 객체를 쓸 수 없는 곳에서 테스트를 가능하게 만드는 도구입니다. 그 이상도 이하도 아닙니다. 실제 객체로 검증할 수 있는 곳에서는 실제 객체를 쓰고, 외부 의존성을 격리해야 할 때만 목적에 맞는 테스트 더블을 선택하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 더블은 테스트를 가능하게 만드는 도구이지, 테스트를 대신해주는 도구가 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이어서 이런 주제를 다뤄볼 예정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;레이어별 테스트 전략은 어떻게 나누어야 하는가&lt;/li&gt;
&lt;li&gt;Fixture와 실행 전략은 왜 유지보수성에 직접 영향을 주는가&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/614&quot;&gt;테스트 코드에서 내부 구현 검증 피하기 &amp;mdash; 조졸두 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/637&quot;&gt;Stub을 이용한 Service 계층 단위 테스트 하기 &amp;mdash; 조졸두 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/mock-test-code/&quot;&gt;실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 1: Mock &amp;mdash; 카카오페이 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/mock-test-code-part-2/&quot;&gt;실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 2: 테스트 코드로부터 피드백 받기 &amp;mdash; 카카오페이 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.inflab.com/20230404-test-code/&quot;&gt;테스트 코드를 왜 그리고 어떻게 작성해야 할까? &amp;mdash; 인프랩 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>[ 인사이트 ]</category>
      <category>Test</category>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/85</guid>
      <comments>https://imgdevel.tistory.com/85#entry85comment</comments>
      <pubDate>Fri, 16 Jan 2026 09:13:45 +0900</pubDate>
    </item>
    <item>
      <title>테스트 코드란 왜 필요한가? 좋은 테스트 코드란 무엇인가?</title>
      <link>https://imgdevel.tistory.com/83</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;테스트요? 네, 나중에 붙이려고요.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;코드 변경 보호를 위한 자동화 시스템.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dRzQBW/dJMcahKOPFm/N2ikbkfhe1oIrrwWzPfnnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dRzQBW/dJMcahKOPFm/N2ikbkfhe1oIrrwWzPfnnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dRzQBW/dJMcahKOPFm/N2ikbkfhe1oIrrwWzPfnnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdRzQBW%2FdJMcahKOPFm%2FN2ikbkfhe1oIrrwWzPfnnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;751&quot; height=&quot;501&quot; data-filename=&quot;코드 변경 보호를 위한 자동화 시스템.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 글을 쓰게 된 계기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 한동안 테스트 코드를 '있으면 좋은 것' 정도로만 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 수정 API 하나를 확인한다고 가정해 보겠습니다. 서버를 띄우고, 로그인해서 토큰을 받고, Postman으로 요청을 보내고, 응답을 확인하고, 혹시 몰라서 DB도 한 번 열어봅니다. 한 번 하는 건 괜찮습니다. 그런데 이걸 예외 처리를 추가할 때도, 리팩터링할 때도, 버그를 고칠 때도 반복하고 있으면 슬슬 회의감이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'이거 매번 내가 손으로 해야 하나?'&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정적이었던 건, 한 번은 서비스 로직을 좀 정리했는데 기존에 잘 되던 기능이 깨져 있었던 적이 있었습니다. 제가 수정한 부분은 멀쩡했는데, 그 로직을 쓰고 있던 다른 쪽에서 터진 것이었습니다. 수동으로 확인할 때는 제가 고친 곳만 보게 되니까, 이런 건 놓칠 수밖에 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때부터 테스트 코드를 진지하게 생각하기 시작했습니다. 이 글은 그 과정에서 정리한 것들을 모아본 시도입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style7&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 코드를 내가 이해한 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드가 뭐냐고 물으면 보통 &quot;내 코드가 의도대로 동작하는지 확인하는 코드&quot;라고 답합니다. 틀린 말은 아닌데, 실무에서 체감한 정의는 좀 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 느낀 테스트 코드의 핵심은 두 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 검증을 제가 아니라 코드가 합니다. 제가 눈으로 응답 보고 &quot;이거 맞네&quot; 하는 게 아니라, &lt;code&gt;assert&lt;/code&gt;가 대신 판단해줍니다. 둘째, '이 기능은 이렇게 동작해야 한다'는 기대가 실행 가능한 형태로 남습니다. 노션 문서에 &quot;로그인 실패 시 401을 반환한다&quot;고 적어놔봤자 코드가 바뀌면 그 문서는 아무도 고치지 않습니다. 그런데 테스트 코드는 코드가 바뀌면 같이 깨집니다. 그래서 테스트가 통과하고 있다는 건, 적어도 그 시점에 그 동작이 보장되고 있다는 뜻이기도 합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복 가능한 검증 절차이면서, &lt;br /&gt;동시에 현재 시스템이 보장해야 하는 동작을 표현하는 실행 가능한 문서.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이 정도가 테스트 코드에 대한 정의라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 코드가 필요한 이유 &amp;mdash; 체감이 컸던 것부터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트의 이점을 나열하자면 한도 끝도 없는데, 실제로 겪어보면서 '이건 진짜 크다'고 느꼈던 것 위주로 정리해 보겠습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 리팩터링이 가능해집니다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 제일 컸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트가 없으면 구조를 바꾸는 게 두렵습니다. 오래된 코드, 중첩 조건이 세네 겹 들어간 메서드, 의도를 알 수 없는 변수명들. 건드리고 싶은데 건드리면 어디서 터질지 모릅니다. 그래서 결국 안 건드리게 됩니다. 코드는 점점 나빠지고, 나중에는 더 건드릴 수 없게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 테스트가 있으면 얘기가 다릅니다. 구조를 바꾸고 테스트를 돌려봅니다. 통과하면 '적어도 기존 동작은 유지되고 있다'는 근거가 생깁니다. 이 안전망이 있느냐 없느냐가, 코드를 개선할 수 있느냐 없느냐를 사실상 결정합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 수정하지 않은 곳이 깨졌는지 알 수 있습니다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서도 말씀드렸지만, 수동 테스트의 가장 큰 함정은 제가 고친 곳만 확인하게 된다는 점입니다. 회원가입 쪽 로직을 살짝 고쳤는데, 그게 주문 쪽에 영향을 줄 수도 있습니다. 주문 쪽은 확인하지 않습니다. 테스트가 있었으면 잡혔을 문제를, 운영에 올라간 뒤에야 발견하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 전체로 돌리면 이런 사각지대가 줄어듭니다. 완전히 없앨 수는 없지만, 적어도 커버된 영역에서의 사이드 이펙트는 빠르게 잡힙니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 설계가 나빠지고 있다는 신호를 줍니다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 좀 나중에 깨달은 건데, 테스트를 쓰려고 하면 자연스럽게 코드 구조에 대한 질문이 생깁니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 메서드 안에서 현재 시간도 읽고, 외부 API도 호출하고, DB도 건드리는데 이걸 어떻게 테스트하지?&lt;/li&gt;
&lt;li&gt;하나의 클래스가 너무 많은 일을 하고 있는 건 아닌가?&lt;/li&gt;
&lt;li&gt;이 의존성을 주입받게 바꾸면 테스트가 쉬워질 텐데?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 붙이기 어려운 코드는 대개 설계도 좋지 않습니다. 그래서 테스트를 작성하려는 시도 자체가 설계 피드백이 됩니다. 이건 직접 겪어봐야 체감이 옵니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;그 외에도...&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;살아있는 문서 역할&lt;/b&gt;: 주석이나 위키는 코드가 바뀌어도 그대로인 경우가 많습니다. 테스트는 코드가 바뀌면 같이 깨지니까, 최신 동작을 반영하는 문서에 가장 가깝습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;버그 재발 방지&lt;/b&gt;: 운영에서 터진 버그를 고치고 그 케이스를 테스트로 남겨두면, 같은 문제가 다시 생겼을 때 자동으로 걸립니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비용&lt;/b&gt;: 버그는 빨리 발견할수록 저렴합니다. 개발 중에 잡으면 코드 수정이면 끝이지만, 운영에서 터지면 핫픽스에 장애 대응에 커뮤니케이션 비용까지 붙습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;존재하는 것과 좋은 것은 다릅니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 좀 붙여보고 나면 다음 질문이 생깁니다. '이 테스트가 진짜 도움이 되고 있는 건가?'&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 시간이 너무 느려서 아무도 안 돌리는 테스트. 로컬에서는 되는데 CI에서는 실패하는 테스트. 실패했는데 뭐가 잘못된 건지 알 수 없는 테스트. 구현을 살짝만 바꿔도 와르르 깨지는 테스트. 이런 것들이 쌓이면 팀은 테스트 결과를 신뢰하지 않게 됩니다. &quot;아 그거 원래 깨져 있어요&quot;라는 말이 나오기 시작하면 테스트는 품질 장치가 아니라 관리 부채가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 좋은 테스트는 '존재하는 테스트'가 아니라 &lt;b&gt;실제로 믿고 돌릴 수 있는 테스트&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;FIRST 원칙&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 테스트의 기준으로 FIRST 원칙이라는 게 있습니다. Fast, Independent, Repeatable, Self-validating, Timely. 용어 자체는 교과서적인데, 각각이 왜 필요한지를 체감한 순서대로 정리해 보겠습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Repeatable (가장 첫번째로 마주했던 문제)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서 테스트를 돌렸을 때는 통과했는데, CI에서 돌리면 실패합니다. 같은 코드인데 왜?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 대부분 환경 의존이었습니다. &lt;code&gt;LocalDateTime.now()&lt;/code&gt;를 직접 호출해서 테스트 결과가 시간대에 따라 달라진다든가, 외부 API 응답이 그날그날 다르다든가, 랜덤 값에 기대고 있다든가.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 테스트는 실패했을 때 &quot;코드가 잘못된 건지, 환경이 달라서 그런 건지&quot; 구분이 안 됩니다. 신뢰할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 테스트는 같은 코드를 두고 결과가 바뀌지 않아야 합니다. 시간은 &lt;code&gt;Clock&lt;/code&gt;으로 주입하고, 랜덤은 고정하고, 외부 응답은 테스트 더블로 제어합니다. 실패의 원인은 환경이 아니라 코드 변경이어야 합니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Independent&amp;nbsp; (순서가 꼬이면 지옥이다)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A 테스트가 만든 데이터를 B 테스트가 씁니다. A를 먼저 돌려야 B가 통과합니다. 이런 구조가 되면, 한 테스트만 골라서 돌릴 수도 없고, 병렬 실행도 안 되고, 실패 원인 파악도 어려워집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 테스트는 자기가 필요한 데이터를 자기가 만들고, 자기가 검증하고, 자기가 정리해야 합니다. Given-When-Then 구조를 유지하면 자연스럽게 이렇게 됩니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Fast&amp;nbsp; (느리면 안 돌린다)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 도메인 규칙 하나 검증하는데 &lt;code&gt;@SpringBootTest&lt;/code&gt;로 전체 컨텍스트를 띄웁니다. 외부 API를 직접 호출합니다. 이러면 테스트 하나에 몇 초씩 걸리고, 전체를 돌리면 몇 분이 날아갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;느린 테스트는 안 돌리게 됩니다. 안 돌리는 테스트는 없는 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 모든 테스트를 가볍게 만들라는 게 아닙니다. 검증 범위에 비해 과도하게 무겁지 않게 만들라는 것입니다. 순수한 도메인 로직은 유닛 테스트로, 외부 의존성은 Fake나 Stub으로, 통합 테스트는 정말 필요한 경계에서만.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. Self-validating (assert가 없으면 테스트가 아닙니다)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;System.out.println&lt;/code&gt;으로 결과를 출력해놓고 눈으로 확인하는 코드를 본 적이 있습니다. 그건 테스트가 아니라 그냥 실행 스크립트입니다. CI는 출력을 읽고 의미를 해석하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;assertThat&lt;/code&gt;, &lt;code&gt;assertEquals&lt;/code&gt;, &lt;code&gt;assertThrows&lt;/code&gt;로 기대 결과를 명시해야 합니다. 성공과 실패가 코드 안에서 결정되어야 자동화된 검증이라고 부를 수 있습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. Timely (나중에 쓰겠다는 건 작성하지 않겠다는 것)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 짧게 말씀드릴 수 있습니다. 기능 개발 끝나고 몰아서 테스트를 쓰겠다는 생각은, 경험상 거의 실행되지 않습니다. 구현이 굳어진 뒤에는 테스트하기 어려운 구조를 그대로 따라가게 되고, 설계 피드백도 이미 늦습니다. TDD를 엄격하게 따르지 않더라도, 최소한 기능 구현과 테스트를 같은 시점에 고려하는 습관은 필요하다고 느꼈습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;한때 잘못 생각했던 테스트에 대한 오해&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;커버리지 높으면 된 거 아닌가?&quot;&lt;/b&gt; &amp;mdash; 아닙니다. 커버리지는 코드가 실행되었는지를 보여주는 지표일 뿐입니다. assert 없이 그냥 메서드를 호출만 해도 커버리지는 올라갑니다. 중요한 건 숫자가 아니라 핵심 정책과 예외를 실제로 검증하고 있느냐입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;통합 테스트로 다 묶으면 더 안전하지 않나?&quot;&lt;/b&gt; &amp;mdash; 겉보기에는 그렇습니다. 그런데 하나의 통합 테스트에 분기가 너무 많이 들어가면 실행도 느리고, 실패 원인 분리도 어렵고, 다른 레이어에서 이미 검증한 걸 또 검증하게 됩니다. 좋은 전략은 모든 걸 한 종류에 몰아넣는 게 아니라, 무엇을 어디에서 검증할지 역할을 나누는 것에 가깝습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style7&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마무리하면서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 쓰면서 느낀 건, 테스트에 대한 이해는 결국 직접 겪어봐야 온다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글로 읽을 때는 '당연한 소리 아닌가?' 싶은 것들이, 실제로 수동 테스트를 반복하다 지쳐보고, 리팩터링하다 기존 기능이 깨져보고, CI에서 원인 모를 실패를 디버깅해보고 나서야 진짜로 와닿습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'테스트는 비용이 아니라 투자'라는 말이 있는데, 저는 거기에 한 가지를 더 붙이고 싶습니다. 테스트는 변경을 두려워하지 않게 만들어주는 장치입니다. 코드를 고치는 게 두렵지 않아야 코드가 좋아질 수 있습니다. 그 용기를 주는 게 테스트라는 생각을 남겨두고 싶습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이어서 이런 주제를 다뤄볼 예정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트 더블은 언제 사용하고 언제 남용이 되는가&lt;/li&gt;
&lt;li&gt;레이어별 테스트 전략은 어떻게 나누어야 하는가&lt;/li&gt;
&lt;li&gt;Fixture와 실행 전략은 왜 유지보수성에 직접 영향을 주는가&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.inflab.com/20230404-test-code/&quot;&gt;테스트 코드를 왜 그리고 어떻게 작성해야 할까? &amp;mdash; 인프랩 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/614&quot;&gt;테스트 코드에서 내부 구현 검증 피하기 &amp;mdash; 조졸두 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jojoldu.tistory.com/637&quot;&gt;Stub을 이용한 Service 계층 단위 테스트 하기 &amp;mdash; 조졸두 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/mock-test-code/&quot;&gt;실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 1: Mock &amp;mdash; 카카오페이 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/given-test-code-2/&quot;&gt;실무에서 적용하는 테스트 코드 작성 방법과 노하우 Part 2: Given &amp;mdash; 카카오페이 기술 블로그&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>[ 인사이트 ]</category>
      <category>Test</category>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/83</guid>
      <comments>https://imgdevel.tistory.com/83#entry83comment</comments>
      <pubDate>Thu, 15 Jan 2026 21:53:33 +0900</pubDate>
    </item>
    <item>
      <title>Local Cache로 Caffeine 쓰면서 배운 것들</title>
      <link>https://imgdevel.tistory.com/52</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;Redis 붙이기 전에, 애플리케이션 안에서 할 수 있는 캐싱부터 제대로 써보자.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ChatGPT Image 2026년 2월 21일 오후 02_41_01.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCVssy/dJMcabpHBR6/VA8Go2KutBkadmTUhK1Ank/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCVssy/dJMcabpHBR6/VA8Go2KutBkadmTUhK1Ank/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCVssy/dJMcabpHBR6/VA8Go2KutBkadmTUhK1Ank/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCVssy%2FdJMcabpHBR6%2FVA8Go2KutBkadmTUhK1Ank%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;797&quot; height=&quot;531&quot; data-filename=&quot;ChatGPT Image 2026년 2월 21일 오후 02_41_01.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 글을 쓰게 된 계기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시라고 하면 자연스럽게 Redis부터 떠올렸다.&lt;br /&gt;나도 처음에는 &amp;ldquo;캐시 = Redis, 네트워크 뒤에 있는 뭔가 빠른 저장소&amp;rdquo; 정도로만 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 실제로 API를 최적화해 보니,&lt;br /&gt;모든 캐시를 굳이 네트워크 너머에 둘 필요는 없다는 걸 느꼈다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;JVM 안에서만 써도 되는 값&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;인스턴스별로 달라도 괜찮은 값&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;ldquo;조금 부정확해도 괜찮은, 읽기 위주의 값&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 것들은 오히려 &lt;b&gt;로컬 캐시(Local Cache)&lt;/b&gt;가 더 잘 맞는 경우가 많았다.&lt;br /&gt;그 와중에 Spring에서 가장 손쉽게 쓸 수 있는 라이브러리 중 하나가 바로 &lt;b&gt;Caffeine&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Caffeine이 어떤 특징을 가진 로컬 캐시인지&lt;/li&gt;
&lt;li&gt;Spring Cache와 &lt;code&gt;@Cacheable&lt;/code&gt;을 통해 어떻게 사용하는지&lt;/li&gt;
&lt;li&gt;AOP 기반 캐싱에서 자주 만나는 함정(특히 self-invocation, 프록시)들을 어떻게 봐야 하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 한 번에 정리해 보려는 시도다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 왜 굳이 Local Cache(Caffeine)를 써야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 같은 분산 캐시가 있는데, 굳이 애플리케이션 내부 로컬 캐시를 쓸 이유가 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 느낀 이유는 대략 이렇다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;네트워크 비용 없이, 그냥 메모리에서 바로 읽고 싶다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis는 빠르지만, 그래도 네트워크 hop은 있다.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;진짜 자주 쓰고, 인스턴스별로 분리되어도 되는 데이터&amp;rdquo;라면 로컬 캐시가 더 가볍다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인스턴스마다 달라도 괜찮은 데이터가 있다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예: 특정 설정값, 트래픽에 따라 변하는 통계, HealthCheck용 값 등&lt;/li&gt;
&lt;li&gt;굳이 모든 인스턴스에서 동일할 필요 없으면, 공유 캐시보다는 로컬 캐시가 낫다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 캐시 인프라 없이도 손쉽게 캐시를 붙이고 싶을 때&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PoC 단계나 내부 도구처럼, Redis를 따로 깔기 부담스러운 상황&lt;/li&gt;
&lt;li&gt;이럴 때 Caffeine + Spring Cache 조합은 &amp;ldquo;붙였다 뗐다&amp;rdquo; 하기 쉬운 옵션이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 Local Cache는,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인스턴스마다 캐시 내용이 다를 수 있고&lt;/li&gt;
&lt;li&gt;인스턴스 재시작 시 캐시가 싹 날아간다는 한계가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;데이터 특성에 따라 Local Cache / Redis / DB를 적절히 섞어 쓰는 설계&lt;/b&gt;가 필요하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Caffeine 한 줄 소개 &amp;ndash; Guava Cache 후계자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 Caffeine을 한 줄로 요약한다면 이렇게 말할 것 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;JVM 안에서 쓰는 고성능 캐시 라이브러리 (Guava Cache의 사실상 후속)&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 풀면 이런 특징이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;동시성에 최적화된 설계&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;멀티 스레드 환경에서 경쟁을 줄이기 위해 내부적으로 많은 튜닝이 들어가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유연한 만료/용량 정책&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;maximumSize&lt;/code&gt;, &lt;code&gt;expireAfterWrite&lt;/code&gt;, &lt;code&gt;expireAfterAccess&lt;/code&gt;, &lt;code&gt;refreshAfterWrite&lt;/code&gt; 등&lt;/li&gt;
&lt;li&gt;꽤 다양한 정책을 조합해 사용할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;통계/모니터링 지원&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;recordStats()&lt;/code&gt;를 켜면 hit/miss 같은 통계를 수집할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Spring Cache 연동이 잘 되어 있다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별도의 복잡한 코드 없이 &lt;code&gt;@EnableCaching&lt;/code&gt; + &lt;code&gt;CaffeineCacheManager&lt;/code&gt; 설정 정도로 바로 얹을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로는 &lt;b&gt;Caffeine 자체 API를 직접 써도 되지만&lt;/b&gt;,&lt;br /&gt;Spring을 쓰고 있다면 대부분 &lt;b&gt;Spring Cache 추상화를 통해 &lt;code&gt;@Cacheable&lt;/code&gt;로 감싸서&lt;/b&gt; 사용하는 패턴이 흔하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Spring에서 Caffeine Local Cache 사용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기본적인 구성은 다음 세 단계였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;의존성 추가&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CacheManager&lt;/code&gt; 설정 (Caffeine 연동)&lt;/li&gt;
&lt;li&gt;서비스 메서드에 &lt;code&gt;@Cacheable&lt;/code&gt;/&lt;code&gt;@CacheEvict&lt;/code&gt; 붙이기&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1. 의존성 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gradle 기준으로는 보통 이렇게 추가한다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;implementation &quot;org.springframework.boot:spring-boot-starter-cache&quot;
implementation &quot;com.github.ben-manes.caffeine:caffeine&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;@EnableCaching&lt;/code&gt;을 설정 클래스에 붙여서 Spring Cache를 활성화한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. Caffeine 기반 CacheManager 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 이렇게 &lt;code&gt;CaffeineCacheManager&lt;/code&gt;를 Bean으로 등록할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager(&quot;userCache&quot;, &quot;configCache&quot;);
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .recordStats());
        return cacheManager;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 포인트는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 이름(&lt;code&gt;&quot;userCache&quot;&lt;/code&gt;, &lt;code&gt;&quot;configCache&quot;&lt;/code&gt;)을 미리 선언해 두고&lt;/li&gt;
&lt;li&gt;각각에 대해 &lt;b&gt;같은 정책(max size, TTL 등)&lt;/b&gt;이 적용된다는 점이다.&lt;br /&gt;(더 세밀한 정책이 필요하면 &lt;code&gt;CacheManager&lt;/code&gt; 구현을 커스터마이징하거나 캐시별로 나눌 수 있다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-3. 서비스 메서드에 @Cacheable/@CacheEvict 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제 서비스 코드에서 이렇게 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Service
public class UserService {

    @Cacheable(cacheNames = &quot;userCache&quot;, key = &quot;#userId&quot;)
    public UserDto getUser(Long userId) {
        // 여기서부터는 DB 조회나 외부 API 호출 등
        return loadUserFromDatabase(userId);
    }

    @CacheEvict(cacheNames = &quot;userCache&quot;, key = &quot;#userId&quot;)
    public void updateUser(Long userId, UpdateUserRequest request) {
        updateUserInDatabase(userId, request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 정리해 보면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;getUser(1L)&lt;/code&gt; 첫 호출 &amp;rarr; 캐시 miss &amp;rarr; 실제 로직 실행 &amp;rarr; 결과를 &lt;code&gt;userCache&lt;/code&gt;에 저장&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getUser(1L)&lt;/code&gt; 두 번째 호출 &amp;rarr; 캐시 hit &amp;rarr; Caffeine에서 바로 반환 (메서드 본문 실행 안 됨)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;updateUser(1L, ...)&lt;/code&gt; 호출 &amp;rarr; DB 업데이트 + 해당 키 캐시 제거&lt;/li&gt;
&lt;li&gt;이후 &lt;code&gt;getUser(1L)&lt;/code&gt; 호출 &amp;rarr; 다시 캐시 miss &amp;rarr; 최신 값으로 재채움&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 Spring Cache 공식 문서 수준의 내용이다.&lt;br /&gt;하지만 실무에서 막상 써보면, &lt;b&gt;AOP/프록시 구조 때문에 생각보다 이상한(?) 곳에서 캐시가 안 먹는 상황&lt;/b&gt;을 종종 만나게 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. @Cacheable과 AOP &amp;ndash; &amp;ldquo;프록시를 안 거치면 캐시도 안 걸린다&amp;rdquo;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Cache는 내부적으로 &lt;b&gt;AOP 기반&lt;/b&gt;으로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 거칠게 표현하면,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;&lt;code&gt;@Cacheable&lt;/code&gt;이 붙은 메서드를 직접 호출하는 게 아니라,&lt;br /&gt;프록시 객체를 통해 호출될 때만 캐시 로직이 개입한다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 때문에 몇 가지 함정이 생긴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 같은 클래스 내부에서 자기 메서드를 호출할 때 (self-invocation)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 흔한 케이스가 이거였다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
public class UserService {

    @Cacheable(cacheNames = &quot;userCache&quot;, key = &quot;#userId&quot;)
    public UserDto getUser(Long userId) { ... }

    public UserDto getUserForAdmin(Long userId) {
        // 여기서 캐시가 적용되길 기대했지만...
        return getUser(userId);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;겉으로 보기에는,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;getUserForAdmin()&lt;/code&gt; &amp;rarr; &lt;code&gt;getUser()&lt;/code&gt;를 호출하니까&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getUser()&lt;/code&gt;에 걸려 있는 &lt;code&gt;@Cacheable&lt;/code&gt;이 동작할 것처럼 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로는 &lt;b&gt;캐시가 적용되지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 단순하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;UserService&lt;/code&gt; 내부에서 &lt;code&gt;this.getUser(userId)&lt;/code&gt;를 호출하면&lt;/li&gt;
&lt;li&gt;프록시를 거치는 게 아니라 &lt;b&gt;자기 자신의 실제 인스턴스 메서드를 바로 호출&lt;/b&gt;한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Cache AOP는 &lt;b&gt;프록시에서 메서드를 가로채면서&lt;/b&gt; 캐시 로직을 넣는데,&lt;br /&gt;이미 실제 인스턴스 안에 들어와 있는 상황에서는 그걸 가로챌 수가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 여러 가지가 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;캐시 메서드를 다른 빈/서비스로 분리한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ApplicationContext&lt;/code&gt;에서 자기 자신을 프록시 타입으로 다시 주입받아 호출한다. (개인적으로는 선호하지 않음)&lt;/li&gt;
&lt;li&gt;아예 &lt;code&gt;getUserForAdmin()&lt;/code&gt;에도 &lt;code&gt;@Cacheable&lt;/code&gt;을 붙여 별도 캐시 전략을 가져간다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로는 &lt;b&gt;&amp;ldquo;캐시할 메서드는 별도의 서비스 계층으로 잘라내기&amp;rdquo;&lt;/b&gt;가 구조상 더 깔끔했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. public 메서드만 캐시가 적용된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring의 기본 프록시 기반 AOP는 &lt;b&gt;public 메서드&lt;/b&gt;에만 적용되는 게 일반적이다.&lt;br /&gt;(프록시 방식, 설정에 따라 다를 수 있지만 기본은 그렇다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 아래 코드는 의도와 다르게 동작할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
public class UserService {

    @Cacheable(cacheNames = &quot;userCache&quot;, key = &quot;#userId&quot;)
    private UserDto getUserInternal(Long userId) { ... }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;어차피 같은 클래스 안에서만 쓰니까 private로 숨겨야지&amp;rdquo;라고 생각했는데,&lt;br /&gt;이 경우에는 &lt;b&gt;캐시가 전혀 적용되지 않을 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 캐싱을 걸고 싶은 메서드는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가급적 &lt;b&gt;public&lt;/b&gt;으로 두고&lt;/li&gt;
&lt;li&gt;필요한 경우 별도의 &amp;ldquo;내부 헬퍼 메서드&amp;rdquo;로 분리하는 식으로 가져가는 편이 안전했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. @Transactional과 @Cacheable 순서/조합&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 자주 헷갈린 부분은 &lt;code&gt;@Transactional&lt;/code&gt;과 &lt;code&gt;@Cacheable&lt;/code&gt;의 조합이었다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Service
public class UserService {

    @Transactional
    @Cacheable(cacheNames = &quot;userCache&quot;, key = &quot;#userId&quot;)
    public UserDto getUser(Long userId) { ... }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring AOP가 어떻게 프록시 체인을 구성하는지&lt;/li&gt;
&lt;li&gt;트랜잭션 시작/종료와 캐시 조회/저장 시점이 어떤 순서로 일어나는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 체감한 포인트는 이 정도였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;읽기 전용 조회 메서드에 &lt;code&gt;@Transactional(readOnly = true)&lt;/code&gt; + &lt;code&gt;@Cacheable&lt;/code&gt;을 같이 쓰는 건 보통 무난했다.&lt;/li&gt;
&lt;li&gt;쓰기/갱신 로직에서는 &lt;b&gt;&lt;code&gt;@CacheEvict&lt;/code&gt;를 어디에 붙일지&lt;/b&gt;를 더 신경 써야 했다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션이 롤백되면 캐시도 롤백되어야 하는지?&lt;/li&gt;
&lt;li&gt;아니면 &amp;ldquo;실패했는데 캐시만 먼저 지워진 상태&amp;rdquo;가 되어도 괜찮은지?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 답은 &amp;ldquo;비즈니스 요구사항에 따라&amp;rdquo; 달라지긴 하지만,&lt;br /&gt;&lt;b&gt;트랜잭션 경계와 캐시 갱신/무효화 사이의 순서를 의식적으로 설계&lt;/b&gt;해야 한다는 점은 분명했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Caffeine과 Redis를 함께 쓸 때의 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Local Cache(Caffeine)와 분산 캐시(Redis)를 함께 쓰는 패턴도 제법 많다.&lt;br /&gt;이때는 보통 다음과 같은 구조를 생각하게 된다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;1st level &amp;ndash; Caffeine (JVM 로컬)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 인스턴스에서 반복되는 호출을 막는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2nd level &amp;ndash; Redis (분산 캐시)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 인스턴스 간에 값을 공유한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;3rd level &amp;ndash; DB/원천 저장소&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최종 진실의 원천&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Spring에서는,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Caffeine을 기본 &lt;code&gt;CacheManager&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;Redis를 별도 캐시 영역/네임스페이스로 두고&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이 키는 로컬, 저 키는 Redis&amp;rdquo; 식으로 나눠 쓰는 방식도 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 기억해 두고 싶은 포인트는 하나였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;Local Cache는 언제나 &amp;lsquo;조금 더 빠른 최적화 레이어&amp;rsquo;일 뿐,&lt;br /&gt;시스템의 정합성은 여전히 Redis/DB 쪽에서 보장해야 한다.&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Caffeine 캐시는 공격적으로 비워도 되는 쪽에 두는 게 마음이 편했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 마무리 &amp;ndash; 캐시는 &amp;ldquo;어디에, 어떤 범위로&amp;rdquo; 두느냐의 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Caffeine을 포함한 Local Cache는,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;어떻게 쓰면 되지?&amp;rdquo; 보다는&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&amp;ldquo;어디까지를 이 캐시에 맡길 것인가?&amp;rdquo;&lt;/b&gt;를 먼저 정의해야 편했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 정리한 내용을 기준으로,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우리 서비스에서 &lt;b&gt;JVM 내부에서만 캐시해도 괜찮은 데이터&lt;/b&gt;는 무엇인지&lt;/li&gt;
&lt;li&gt;그 데이터를 &lt;code&gt;@Cacheable&lt;/code&gt; + Caffeine으로 감싸는 게 적절한지&lt;/li&gt;
&lt;li&gt;AOP/프록시 구조 때문에 캐시가 먹지 않는 구간은 없는지 (특히 self-invocation)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;을 한 번 점검해 보면 좋을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로, 이미 Redis 캐시를 쓰고 있다면,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가장 호출 빈도가 높은 조회 API 하나를 고르고&lt;/li&gt;
&lt;li&gt;그 중 &amp;ldquo;인스턴스별로 달라도 괜찮은 부분&amp;rdquo;만 골라 Caffeine으로 한 겹 더 둘러본 다음&lt;/li&gt;
&lt;li&gt;모니터링을 통해 hit/miss와 레이턴시 개선 폭을 확인해 보는 것&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정도가 Local Cache를 도입해 보는 좋은 출발점이라고 느꼈다.&lt;/p&gt;</description>
      <category>[ 기술 스택 ]/Cache</category>
      <category>Cache</category>
      <category>Spring</category>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/52</guid>
      <comments>https://imgdevel.tistory.com/52#entry52comment</comments>
      <pubDate>Tue, 23 Dec 2025 00:50:28 +0900</pubDate>
    </item>
    <item>
      <title>Redis Pub/Sub로 서비스 간 이벤트 다루기</title>
      <link>https://imgdevel.tistory.com/48</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;h4&gt;“Kafka를 쓰기엔 무겁고, 그냥 동기 호출만 하기엔 아쉬울 때.”&lt;/h4&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ChatGPT Image 2026년 2월 21일 오후 11_01_07.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BobNr/dJMcah4vrCT/CKNTqrU5EpS8IaSKaePbd1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BobNr/dJMcah4vrCT/CKNTqrU5EpS8IaSKaePbd1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BobNr/dJMcah4vrCT/CKNTqrU5EpS8IaSKaePbd1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBobNr%2FdJMcah4vrCT%2FCKNTqrU5EpS8IaSKaePbd1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;764&quot; height=&quot;509&quot; data-filename=&quot;ChatGPT Image 2026년 2월 21일 오후 11_01_07.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2&gt;이 글을 쓰게 된 계기&lt;/h2&gt;
&lt;p&gt;마이크로서비스까지는 아니더라도,&lt;br&gt;서비스 레이어가 조금씩 나뉘기 시작하면 자연스럽게 “이벤트” 이야기가 나온다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;주문이 생성되면 알림 서비스를 호출해야 하고&lt;/li&gt;
&lt;li&gt;유저가 가입하면 추천/포인트/메일링 시스템이 반응해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;처음에는 대부분 &lt;strong&gt;동기 HTTP 호출&lt;/strong&gt;로 시작한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;“주문 생성 API 안에서 알림/포인트/메일을 순서대로 호출하면 되지.”&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;근데 서비스가 커질수록,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;한 서비스의 장애가 다른 서비스까지 전파되고&lt;/li&gt;
&lt;li&gt;요청 응답 시간이 일관되지 않고&lt;/li&gt;
&lt;li&gt;“이건 사실 비동기로 처리해도 되는 일인데…”라는 생각이 쌓인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그 지점에서 가볍게 고려해볼 수 있는 도구 중 하나가 &lt;strong&gt;Redis Pub/Sub&lt;/strong&gt;였다.&lt;/p&gt;
&lt;p&gt;이 글은,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Redis Pub/Sub이 어떤 구조로 동작하는지&lt;/li&gt;
&lt;li&gt;실제 프로젝트에서 어떻게 써봤는지&lt;/li&gt;
&lt;li&gt;Kafka 같은 “무거운 메시지 브로커”와 비교했을 때 어디까지를 맡길 수 있는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;를 정리해 보려는 시도다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 왜 Redis Pub/Sub을 알아둘 필요가 있을까?&lt;/h2&gt;
&lt;p&gt;솔직하게 말하면, Redis Pub/Sub은 &lt;strong&gt;만능 메시지 브로커가 아니다.&lt;/strong&gt;&lt;br&gt;그럼에도 불구하고 알아둘 가치가 있다고 느낀 이유는 세 가지 정도였다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;“동기 호출 vs 제대로 된 메시지 브로커” 사이의 간극을 메워준다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Kafka, RabbitMQ, NATS 같은 브로커는 강력하지만, 초기 도입/운영 비용이 있다.&lt;/li&gt;
&lt;li&gt;Pub/Sub은 이미 쓰고 있는 Redis 위에서 “가볍게 비동기 이벤트”를 붙이기 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;서비스 간 결합도를 한 단계만이라도 낮출 수 있다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;주문 서비스가 “알림/포인트/메일” 서비스의 존재를 직접 알지 않고,&lt;/li&gt;
&lt;li&gt;단순히 “order.created” 이벤트를 발행하는 식으로 바꿀 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis를 이미 쓰고 있다면, 추가 인프라 없이 바로 실험해 볼 수 있다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;별도의 클러스터나 운영 도구 없이도,&lt;/li&gt;
&lt;li&gt;“이 이벤트는 Pub/Sub으로 한 번 빼보자”는 실험을 빠르게 해볼 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;물론 그 대가로,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메시지 내구성&lt;/li&gt;
&lt;li&gt;재처리/재시도&lt;/li&gt;
&lt;li&gt;소비자 그룹 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;같은 부분은 어느 정도 포기해야 한다.&lt;br&gt;그래서 어디까지를 Redis Pub/Sub에게 맡길지, 어떤 곳부터는 Kafka 같은 도구로 넘길지 기준을 갖고 있는 게 중요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. Redis Pub/Sub 구조 한 번에 훑어보기&lt;/h2&gt;
&lt;p&gt;먼저 개념부터 정리해 보자.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Publisher&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;특정 채널(channel)로 메시지를 발행(PUBLISH)하는 쪽&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subscriber&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;하나 이상의 채널에 구독(SUBSCRIBE)하고, 메시지를 받아 처리하는 쪽&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Channel&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;문자열로 된 이름. 예: &lt;code&gt;&amp;quot;order.created&amp;quot;&lt;/code&gt;, &lt;code&gt;&amp;quot;user.signup&amp;quot;&lt;/code&gt;, &lt;code&gt;&amp;quot;notice.*&amp;quot;&lt;/code&gt; 등&lt;/li&gt;
&lt;li&gt;Pub/Sub에서는 채널별로 메시지가 브로드캐스트된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ChatGPT Image 2026년 2월 21일 오후 10_52_08.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJjDo7/dJMcaibhSSr/AbTHTtCGrXydE3sJuDCCX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJjDo7/dJMcaibhSSr/AbTHTtCGrXydE3sJuDCCX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJjDo7/dJMcaibhSSr/AbTHTtCGrXydE3sJuDCCX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJjDo7%2FdJMcaibhSSr%2FAbTHTtCGrXydE3sJuDCCX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;483&quot; data-filename=&quot;ChatGPT Image 2026년 2월 21일 오후 10_52_08.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;흐름을 텍스트로 그려보면 대략 이렇다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Subscriber가 &lt;code&gt;SUBSCRIBE order.created&lt;/code&gt;로 Redis에 구독을 건다.&lt;/li&gt;
&lt;li&gt;Publisher가 &lt;code&gt;PUBLISH order.created &amp;quot;{...payload...}&amp;quot;&lt;/code&gt;를 보낸다.&lt;/li&gt;
&lt;li&gt;Redis는 해당 채널을 구독 중인 모든 Subscriber에게 메시지를 푸시한다.&lt;/li&gt;
&lt;li&gt;메시지는 &lt;strong&gt;즉시 전달되고, 그 뒤에는 저장되지 않는다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;한 줄로 줄이면,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;“Redis Pub/Sub은 ‘현재 연결 중인 구독자들’에게만 메시지를 뿌리는 브로드캐스트 채널”&lt;/strong&gt;이라고 이해하는 게 제일 편했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;여기서 중요한 제약이 하나 나온다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;구독자가 잠시 끊겨 있던 동안 발행된 메시지는, 나중에 다시 연결해도 받을 수 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이 제약 때문에, Redis Pub/Sub은&lt;br&gt;“로그성 이벤트를 안전하게 쌓고 나중에 재처리해야 하는” 시나리오보다는,&lt;br&gt;“실시간 알림/신호”에 더 가깝게 쓰는 게 맞다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. Pub/Sub 명령어 감각 잡기&lt;/h2&gt;
&lt;p&gt;실제 Redis CLI 기준으로 보면 명령어는 단순하다.&lt;/p&gt;
&lt;h3&gt;3-1. 발행 – PUBLISH&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;PUBLISH order.created &amp;quot;{ \&amp;quot;orderId\&amp;quot;: 123, \&amp;quot;userId\&amp;quot;: 1 }&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;반환값은 이 메시지를 받은 Subscriber의 수다. (예: &lt;code&gt;integer 2&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3-2. 구독 – SUBSCRIBE / PSUBSCRIBE&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;SUBSCRIBE order.created&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;패턴으로 여러 채널을 한 번에 구독할 수도 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PSUBSCRIBE order.*&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;일단 &lt;code&gt;SUBSCRIBE&lt;/code&gt;/&lt;code&gt;PSUBSCRIBE&lt;/code&gt; 모드에 들어가면,&lt;br&gt;해당 Redis 연결(connection)은 &lt;strong&gt;블로킹된 상태로 계속 메시지를 기다리게 된다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;그래서 애플리케이션 코드에서는 보통:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;별도의 스레드/비동기 작업에서 구독 연결을 유지하고&lt;/li&gt;
&lt;li&gt;메시지가 올 때마다 핸들러를 호출하는 구조를 많이 쓴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;4. Spring에서 Redis Pub/Sub 사용하기&lt;/h2&gt;
&lt;p&gt;Spring Data Redis를 쓰면 Pub/Sub도 비교적 손쉽게 연동할 수 있다.&lt;/p&gt;
&lt;h3&gt;4-1. 의존성과 기본 설정&lt;/h3&gt;
&lt;p&gt;Gradle:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;implementation &amp;quot;org.springframework.boot:spring-boot-starter-data-redis&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;application.yml&lt;/code&gt;에서 Redis 접속 정보를 설정해 두고,&lt;br&gt;보통은 &lt;code&gt;LettuceConnectionFactory&lt;/code&gt; 기반의 &lt;code&gt;RedisConnectionFactory&lt;/code&gt;가 자동 구성된다.&lt;/p&gt;
&lt;h3&gt;4-2. 메시지 발행 – RedisTemplate / StringRedisTemplate&lt;/h3&gt;
&lt;p&gt;발행 쪽은 단순하다. 예를 들어:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Service
public class OrderEventPublisher {

    private final StringRedisTemplate redisTemplate;

    public void publishOrderCreated(OrderCreatedEvent event) {
        String channel = &amp;quot;order.created&amp;quot;;
        String payload = objectMapper.writeValueAsString(event);
        redisTemplate.convertAndSend(channel, payload);
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;내부적으로는 &lt;code&gt;PUBLISH&lt;/code&gt; 명령을 사용한다.&lt;/p&gt;
&lt;h3&gt;4-3. 구독 – MessageListener와 Container&lt;/h3&gt;
&lt;p&gt;구독 쪽은 &lt;code&gt;MessageListener&lt;/code&gt;와 &lt;code&gt;RedisMessageListenerContainer&lt;/code&gt;를 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Configuration
public class RedisPubSubConfig {

    @Bean
    public RedisMessageListenerContainer redisContainer(
        RedisConnectionFactory connectionFactory,
        MessageListenerAdapter orderCreatedListener
    ) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(orderCreatedListener, new ChannelTopic(&amp;quot;order.created&amp;quot;));
        return container;
    }

    @Bean
    public MessageListenerAdapter orderCreatedListener(OrderCreatedSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, &amp;quot;onMessage&amp;quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;핸들러는 이런 식으로 작성할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Component
public class OrderCreatedSubscriber {

    public void onMessage(String message, String channel) {
        // message: payload, channel: &amp;quot;order.created&amp;quot;
        // 여기서 JSON 파싱 후 실제 비즈니스 로직 처리
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;중요한 포인트는:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;RedisMessageListenerContainer&lt;/code&gt;가 내부적으로 &lt;strong&gt;구독용 연결을 유지&lt;/strong&gt;하면서&lt;/li&gt;
&lt;li&gt;메시지가 도착할 때마다 &lt;code&gt;MessageListener&lt;/code&gt;를 콜백으로 호출해 준다는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;5. Redis Pub/Sub의 장점과 한계&lt;/h2&gt;
&lt;p&gt;실제로 써보면서 느낀 장점과 한계를 정리해 보면 이렇다.&lt;/p&gt;
&lt;h3&gt;5-1. 장점&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;도입이 매우 쉽다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Redis를 이미 쓰고 있다면 추가 인프라 없이 바로 시작할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;지연(latency)이 낮다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Pub/Sub 자체는 메모리 기반이고, 브로커 로직도 단순하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;구현이 가볍다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Publisher/Subscriber 코드가 비교적 단순하고, 개념이 직관적이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;서비스 간 결합도를 줄이는 첫 단계로 좋다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;기존 동기 호출에서 “이벤트 발행”으로만 바꿔도, 의존 관계가 한 단계 느슨해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5-2. 한계와 주의사항&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;메시지가 저장되지 않는다 (내구성 없음)&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Subscriber가 잠시 죽어 있던 동안 발행된 메시지는 사라진다.&lt;/li&gt;
&lt;li&gt;나중에 다시 켜져도 “놓친 메시지”를 알 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;재처리/리플레이가 어렵다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Kafka처럼 “특정 시점 이후 메시지를 다시 읽기” 같은 패턴이 없다.&lt;/li&gt;
&lt;li&gt;이런 요구가 생기는 순간, Pub/Sub만으로는 충분하지 않다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;소비자 그룹, 오프셋 개념이 없다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Pub/Sub은 브로드캐스트에 가깝다.&lt;/li&gt;
&lt;li&gt;특정 그룹 단위로 메시지를 분배하는 구조가 아니라,&lt;br&gt;채널에 붙어 있는 모든 Subscriber가 다 받는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;백프레셔/버퍼링 전략이 거의 없다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Subscriber가 느리게 처리하면, 그 연결에서 메시지가 쌓이거나,&lt;br&gt;결국 처리 지연/타임아웃으로 이어질 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;보안/격리 수준이 브로커 전문 솔루션보다 약하다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Redis 전체 인스턴스에 대한 접근 제어에 많이 의존한다.&lt;/li&gt;
&lt;li&gt;멀티 테넌트/복잡한 권한 모델이 필요한 환경에서는 부족할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;결론적으로, Redis Pub/Sub은&lt;br&gt;&lt;strong&gt;“메시지는 잃어도 괜찮지만, 실시간 반응이 중요한 이벤트”&lt;/strong&gt;에 어울리는 도구라는 느낌이 강했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. Redis Pub/Sub 사용할 때 실무 주의점&lt;/h2&gt;
&lt;p&gt;장단점을 알고 나서 실제로 붙여보면, 특히 아래 포인트들을 신경 쓰게 되었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;절대 유실되면 안 되는 이벤트에는 쓰지 않는다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;주문/결제/정산/재고 등은 Pub/Sub 대신 Kafka·큐·DB 트랜잭션 로그 등 내구성 있는 경로를 먼저 고려한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subscriber 수명/상태를 모니터링한다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;프로세스가 죽어 있던 동안의 메시지는 놓치기 때문에,&lt;br&gt;“구독자가 살아 있는지”를 별도의 HealthCheck/알람으로 감시하는 게 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;처리 실패/재시도 전략을 애플리케이션에서 직접 설계한다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Pub/Sub 자체는 재시도 큐가 없다.&lt;/li&gt;
&lt;li&gt;실패 시 별도 DLQ(예: Redis List, DB 테이블)에 적재하거나, 재시도 로직을 애플리케이션 레벨에서 구현해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;채널 설계를 너무 거칠게 가져가지 않는다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*&lt;/code&gt; 패턴으로 너무 많은 이벤트를 한 채널에 섞어 버리면,&lt;br&gt;구독자 코드에서 분기/파싱이 복잡해지고 디버깅이 어려워진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;한 구독자에 너무 많은 일을 몰아 넣지 않는다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;하나의 Subscriber가 여러 종류의 일을 모두 처리하면,&lt;br&gt;특정 이벤트 처리 지연이 다른 이벤트의 지연으로 그대로 전파된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pub/Sub을 “로그 저장소”처럼 쓰지 않는다&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;나중에 다시 보고 싶은 이벤트라면, Pub/Sub과는 별도로&lt;br&gt;DB·Kafka·파일 등 영속적인 저장소로도 함께 흘려 보내는 설계가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;7. 언제 Redis Pub/Sub을 쓰고, 언제 Kafka 같은 걸 써야 할까?&lt;/h2&gt;
&lt;p&gt;내가 기준으로 삼고 싶은 몇 가지 질문은 이렇다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;메시지를 잃어버려도 되는가?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;예: 실시간 알림 배너, 온라인 사용자 수, 메트릭/모니터링 신호 등&lt;br&gt;→ 한두 개 놓쳐도 큰 문제가 없다면 Pub/Sub도 충분하다.&lt;/li&gt;
&lt;li&gt;반대로 “모든 주문 이벤트를 반드시 기록하고, 나중에 재처리해야 한다”&lt;br&gt;→ Kafka 같은 내구성 있는 로그/브로커가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;여러 소비자 그룹이 서로 다른 속도로 처리해도 괜찮은가?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Kafka는 consumer group별로 오프셋을 관리해 각자 속도로 읽을 수 있다.&lt;/li&gt;
&lt;li&gt;Pub/Sub은 그런 개념이 없다.&lt;/li&gt;
&lt;li&gt;소비자 속도가 많이 달라지고, 그룹이 여러 개라면 Redis Pub/Sub만으로는 부족하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;이벤트 스트림을 “데이터 파이프라인”으로도 쓰고 싶은가?&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;예: 주문 이벤트를 기반으로 실시간 대시보드, 배치 통계, ML 피처 파이프라인까지&lt;/li&gt;
&lt;li&gt;이런 “장기 보관 + 다양한 소비자” 시나리오에는 Kafka가 훨씬 잘 맞는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;실무에서는 보통 이렇게 나눌 수 있다고 느꼈다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Redis Pub/Sub&lt;ul&gt;
&lt;li&gt;실시간 알림, 간단한 서비스 간 신호, UI 업데이트 트리거 등&lt;/li&gt;
&lt;li&gt;유실을 어느 정도 허용할 수 있는 “신호/notification” 계열&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Kafka / RabbitMQ 등&lt;ul&gt;
&lt;li&gt;주문/결제/재고/로그처럼 &lt;strong&gt;유실이 치명적인 도메인 이벤트&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;데이터 파이프라인/분석/재처리가 중요한 영역&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;8. 마무리 – Pub/Sub은 메시징의 “입문용 도구”로 보기&lt;/h2&gt;
&lt;p&gt;Redis Pub/Sub을 쓰면서 느낀 건,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;“메시지 브로커의 모든 기능을 기대하기보다는,&lt;br&gt;동기 호출에서 한 단계만 비동기로 나아가기 위한 입문용 도구로 보는 게 편하다.”&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;라는 점이었다.&lt;/p&gt;
&lt;p&gt;이 글에서 정리한 내용을 기준으로,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;지금 서비스에서 “동기 호출로 얽혀 있는 부분” 중&lt;ul&gt;
&lt;li&gt;유실을 어느 정도 허용할 수 있고&lt;/li&gt;
&lt;li&gt;실시간성이 중요하며&lt;/li&gt;
&lt;li&gt;이벤트 방식으로 바꾸면 구조가 훨씬 단순해질 영역이 있는지&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;을 한 번 찾아보면 좋을 것 같다.&lt;/p&gt;
&lt;p&gt;그 영역에 한정해서 Redis Pub/Sub을 도입해 보고,&lt;br&gt;그 한계를 실제로 부딪혀 보는 것 자체가 좋은 학습 경험이 된다.&lt;/p&gt;
&lt;p&gt;그 이후에 Kafka나 다른 브로커를 도입하게 되더라도,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“어떤 기능이 있었으면 좋겠는지”&lt;/li&gt;
&lt;li&gt;“어떤 한계를 해결하고 싶은지”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;가 더 분명해져서, 도구 선택이 훨씬 수월해진다는 점을 마지막으로 남겨두고 싶다.&lt;/p&gt;</description>
      <category>[ 기술 스택 ]/Async &amp;amp; MQ</category>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/48</guid>
      <comments>https://imgdevel.tistory.com/48#entry48comment</comments>
      <pubDate>Sun, 14 Dec 2025 12:56:06 +0900</pubDate>
    </item>
    <item>
      <title>동기랑 블로킹은 같은 말 아닌가요?</title>
      <link>https://imgdevel.tistory.com/45</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;719&quot; data-origin-height=&quot;403&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/das1Ko/dJMcaaE7Pia/Zx6VH9SXPxDoMQPd2CBJD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/das1Ko/dJMcaaE7Pia/Zx6VH9SXPxDoMQPd2CBJD0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/das1Ko/dJMcaaE7Pia/Zx6VH9SXPxDoMQPd2CBJD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdas1Ko%2FdJMcaaE7Pia%2FZx6VH9SXPxDoMQPd2CBJD0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;925&quot; height=&quot;518&quot; data-origin-width=&quot;719&quot; data-origin-height=&quot;403&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거 면접에서 동기와 비동기, 블로킹과 논블로킹의 차이를 물어봤다. 정확히 어떤 맥락에서 나왔던 질문인지는 기억이 안 나는데, 솔직히 답을 잘 못 했다. 머릿속에서는 &quot;동기는 블로킹, 비동기는 논블로킹&quot; 같은 식으로 굳어 있어서 두 단어를 거의 같은 말처럼 썼고, 면접관이 &quot;둘이 다른 개념입니다&quot; 하고 슬쩍 정정해줬을 때 머쓱했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접 끝나고 집에 와서 다시 정리해본 내용을 적는다. 이미 잘 아시는 분들에게는 새로울 게 없을 수 있는데, 저처럼 처음에는 같은 거라고 생각했던 분이 있다면 도움이 될지도 모르겠다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 개념은 서로 다른 축을 보고 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 정리하면서 가장 결정적이었던 건 이 한 줄이었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로킹/논블로킹은 &quot;지금 기다릴까?&quot; 동기/비동기는 &quot;결과는 언제 받을까?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 질문은 같은 동작을 다른 각도에서 보는 거다. 그래서 두 축이 독립적이고, 결국 4가지 조합이 가능하다는 결론으로 이어진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;블로킹과 논블로킹&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 A가 함수 B를 호출했다고 치자. B가 끝날 때까지 A가 다음 줄로 못 넘어간다면, 그건 블로킹이다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 블로킹
const data = fs.readFileSync('./file.txt');
console.log('이 줄은 파일을 다 읽어야 실행됨');
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 B가 &quot;응, 요청 받았어&quot; 하고 즉시 제어권을 돌려주면 A는 다음 줄로 넘어간다. 이건 논블로킹.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 논블로킹
fs.readFile('./file.txt', (err, data) =&amp;gt; { /* ... */ });
console.log('이 줄은 파일 읽기 시작 직후 바로 실행됨');
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 헷갈리지 말아야 할 건, 이 관점은 &lt;b&gt;제어권을 누가 잡고 있느냐&lt;/b&gt;만 본다는 것이다. 결과를 어떻게 받는지는 다른 이야기다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동기와 비동기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A가 B에게 일을 시켰을 때, 그 일이 끝나는 순서와 A의 다음 코드 실행 순서가 일치하면 동기다. 일치하지 않으면 비동기.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 동기 - 순서대로 끝남
const a = computeA();   // 끝나야
const b = computeB();   // 시작
console.log(a, b);

// 비동기 - 끝나는 순서가 호출 순서와 다를 수 있음
fetchA().then(a =&amp;gt; console.log(a));
fetchB().then(b =&amp;gt; console.log(b));
console.log('이게 먼저 실행될 수 있음');
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 헷갈렸던 부분은 &quot;비동기는 순서가 안 맞다&quot;가 아니라 &lt;b&gt;호출자가 결과를 그 자리에서 기다리지 않는다&lt;/b&gt;라는 쪽이었다. 결과는 콜백, Promise, async/await 같은 메커니즘으로 나중에 받는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 4가지 조합이 나온다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 축이 독립적이라는 건 실제로 4가지 패턴이 가능하다는 뜻이다. 처음에는 &quot;동기/블로킹&quot;과 &quot;비동기/논블로킹&quot; 둘만 있다고 생각했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로킹 논블로킹&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동기&lt;/td&gt;
&lt;td&gt;가장 흔한 패턴. 그냥 함수 호출&lt;/td&gt;
&lt;td&gt;폴링(polling). 즉시 반환받지만 결과는 즉시 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비동기&lt;/td&gt;
&lt;td&gt;의미가 흐려지는 조합. 비동기로 시작했는데 결과를 기다림&lt;/td&gt;
&lt;td&gt;JavaScript에서 가장 많이 쓰는 패턴&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비동기/블로킹 조합이 신기했다. &quot;비동기로 호출했지만 결과는 기다리는&quot; 좀 이상한 패턴인데, 사실상 비동기의 의미를 무력화하는 거라 실무에서는 잘 안 쓴다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기/논블로킹은 &quot;즉시 반환은 받지만 결과를 즉시 사용&quot;이라 폴링 같은 데 쓰인다고 한다. 본격적으로 만나본 적은 아직 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JavaScript에서 자주 보는 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JavaScript는 싱글스레드라서 블로킹이 일어나면 화면 자체가 멈춘다. 그래서 거의 모든 I/O가 비동기/논블로킹으로 설계돼 있다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;console.log('1');

setTimeout(() =&amp;gt; {
    console.log('2');
}, 0);

console.log('3');

// 출력 순서: 1, 3, 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setTimeout이 0ms여도 결과는 마지막에 찍힌다. 동기/블로킹이라면 1, 2, 3 순으로 나와야 하는데, 비동기/논블로킹이라서 setTimeout은 일단 Web API에 넘겨두고 console.log('3')이 먼저 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 처음 본 사람한테는 좀 마법 같은데, 이벤트 루프와 콜백 큐가 어떻게 돌아가는지 이해하면 자연스럽다고 한다. 그건 다음에 따로 정리해보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 면접에서 다시 답한다면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 같은 질문을 받으면 이렇게 답할 것 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블로킹/논블로킹은 함수 호출이 즉시 제어권을 돌려주느냐를 봅니다. 동기/비동기는 호출 결과를 그 자리에서 기다려서 받느냐를 봅니다. 두 축이 독립적이라 4가지 조합이 다 가능한데, 가장 자주 쓰이는 건 동기/블로킹과 비동기/논블로킹 두 개입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 아직도 머릿속에서 깨끗이 정리되지 않은 부분이 있다. 비동기/블로킹 조합이 정말 항상 무의미한 건지, 아니면 어떤 특수 케이스에서 쓰이는지 잘 모르겠다 (찾아봤는데 명쾌한 답을 못 찾았다). 일단은 &quot;실무에서는 거의 안 쓴다&quot; 정도로 이해하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접에서 처음 듣고 헷갈렸던 게 결국 좋은 학습 계기가 됐다. 다음에 같은 질문을 받으면 이번에는 좀 더 자신 있게 답할 수 있을 것 같다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MDN &amp;mdash; &lt;a href=&quot;https://developer.mozilla.org/ko/docs/Learn/JavaScript/Asynchronous&quot;&gt;Asynchronous JavaScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Node.js &amp;mdash; &lt;a href=&quot;https://nodejs.org/en/docs/guides/blocking-vs-non-blocking/&quot;&gt;Blocking vs Non-Blocking&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/45</guid>
      <comments>https://imgdevel.tistory.com/45#entry45comment</comments>
      <pubDate>Tue, 9 Dec 2025 08:41:51 +0900</pubDate>
    </item>
    <item>
      <title>Redis 캐싱 전략, 그냥 쓰지 말고 설계해서 쓰자</title>
      <link>https://imgdevel.tistory.com/43</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;캐시는 어렵지 않은데, &lt;b&gt;잘못 붙이면 더 큰 장애 포인트가 된다&lt;/b&gt;.&amp;rdquo;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ChatGPT Image 2026년 2월 20일 오후 10_01_26.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dQ9B26/dJMcagEw1mY/oRZvoU4hevr4eYmVuzlHs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dQ9B26/dJMcagEw1mY/oRZvoU4hevr4eYmVuzlHs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dQ9B26/dJMcagEw1mY/oRZvoU4hevr4eYmVuzlHs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdQ9B26%2FdJMcagEw1mY%2FoRZvoU4hevr4eYmVuzlHs0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;763&quot; height=&quot;509&quot; data-filename=&quot;ChatGPT Image 2026년 2월 20일 오후 10_01_26.png&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 글을 쓰게 된 계기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 기반 백엔드에서 Redis를 캐시 용도로 붙이기 시작했을 때, 솔직히 이렇게 생각했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;DB 쿼리 전에 Redis 한 번 보고, 없으면 DB 조회해서 넣으면 끝 아닌가?&amp;rdquo;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 진짜로 그 정도만 해도 성능이 꽤나 좋아졌다.&lt;br /&gt;조회 수가 많은 API에 캐시를 한 번 둘러줬더니, DB 커넥션 수가 눈에 띄게 줄고 응답 시간도 안정적으로 떨어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 그다음이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 갱신 타이밍이 꼬이면서 &lt;b&gt;유저에게는 이미 삭제된 데이터가 계속 보이고&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;특정 키의 TTL이 한꺼번에 만료되면서 &lt;b&gt;캐시 스탬피드가 터지고&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;여러 서비스에서 같은 Redis를 쓰다 보니 &lt;b&gt;키 충돌과 메모리 압박&lt;/b&gt;이 슬슬 보이기 시작했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때부터 &amp;ldquo;그냥 Redis 붙였다&amp;rdquo;와 &amp;ldquo;캐싱 전략을 설계했다&amp;rdquo; 사이에 꽤 큰 차이가 있다는 걸 체감했다.&lt;br /&gt;이 글은 그 과정을 정리하면서,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis로 캐시를 쓸 때 어떤 전략들이 있는지&lt;/li&gt;
&lt;li&gt;각 전략은 어떤 장단점과 위험을 가지고 있는지&lt;/li&gt;
&lt;li&gt;실제 프로젝트에서는 어떤 선택을 했는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 한 번에 정리해보려는 시도다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1.&amp;nbsp; 왜 캐싱 전략까지 고민해야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 Redis를 처음 붙였을 때는 &amp;ldquo;성능&amp;rdquo; 말고는 크게 생각하지 않았다.&lt;br /&gt;근데 실무에서 겪은 문제를 정리해보면, 캐시는 단순한 성능 도구가 아니라 &lt;b&gt;설계 대상&lt;/b&gt;이라는 걸 알게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 특히 크게 느꼈던 지점은 세 가지였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;데이터 정합성 문제&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB에서는 이미 삭제/수정된 데이터가 Redis에 계속 남아 있어&lt;br /&gt;&amp;ldquo;클라이언트에서 삭제했다고 하는데 왜 다시 살아나요?&amp;rdquo; 같은 이슈가 터진다.&lt;/li&gt;
&lt;li&gt;특히 관리자 콘솔과 사용자 화면이 같은 데이터를 다른 뷰로 보여줄 때,&lt;br /&gt;한쪽만 최신 상태로 보이는 일이 생각보다 자주 발생했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 스탬피드(Cache Stampede)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인기 있는 키의 TTL이 한 지점에 몰려 있으면, 만료 순간에 &lt;b&gt;동시에 수백/수천 요청이 DB로 튀어 들어간다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;모니터링을 켜 놓고 보면 딱 TTL 만료 타이밍에 DB QPS가 스파이크 치는 걸 눈으로 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리소스/비용 이슈&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시라고 해서 공짜가 아니다. Redis도 메모리 기반이라 &lt;b&gt;용량과 비용&lt;/b&gt;을 신경 써야 한다.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;일단 다 캐싱하자&amp;rdquo;는 전략은 거의 항상 나중에 &lt;b&gt;Eviction 정책과 메모리 압박&lt;/b&gt;으로 부메랑처럼 돌아왔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &amp;ldquo;Redis를 쓴다&amp;rdquo;는 말은 곧,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;우리는 어떤 데이터에 대해, 어떤 일관성을 희생하면서, 어떤 성능을 얻을 것인가&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 선택하는 일과 거의 같다.&lt;br /&gt;그 선택을 구체적인 전략으로 내려보는 게 이 글의 핵심이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 그림 한 장으로 보는 캐싱 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, Redis 캐싱 전략을 아주 거칠게 &amp;ldquo;읽기/쓰기/갱신 흐름&amp;rdquo; 관점에서 나눠보면 이렇게 정리할 수 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Cache-Aside (Lazy Loading)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;애플리케이션이 먼저 캐시를 조회하고, 없으면 DB를 조회한 뒤 캐시에 채워 넣는 패턴&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Read-Through / Write-Through&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시가 저장소 앞에 프록시처럼 서 있어서, 읽기/쓰기를 항상 캐시를 통해 수행하는 패턴&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Write-Back (Write-Behind)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쓰기는 캐시에만 반영하고, 나중에 비동기 배치로 실제 저장소에 밀어 넣는 패턴&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Refresh-Ahead&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TTL이 끝나기 전에 미리 캐시를 갱신해 두는 패턴 (배치/백그라운드 작업)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 더해서 실무에서는 다음 요소들을 필수로 같이 고민하게 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;키 설계 전략&lt;/b&gt;: 네임스페이스, 버전, 샤딩 키 등&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TTL 전략&lt;/b&gt;: 데이터 성격에 따른 만료 시간, Jitter(랜덤 값) 추가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일관성 전략&lt;/b&gt;: 쓰기 시 무효화 vs 갱신, 강한/약한 일관성 선택&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스탬피드/핫키 대응&lt;/b&gt;: 요청 한 번만 DB로 보내고 나머지는 대기시키는 방법, 락 활용 등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄로 줄이면 이렇게 정리할 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;무엇을 언제 캐시에 올리고, 언제까지 살려둘 것인지, 그리고 쌓인 캐시를 어떻게 정리할 것인지&amp;rdquo;&lt;/b&gt;를 미리 정해두는 게 캐싱 전략이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 각 전략을 조금 더 사람처럼(?) 소개해 보자.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 주요 캐싱 전략들을 사람처럼 소개해 보기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-1. Cache-Aside &amp;ndash; 필요할 때만 창고에서 꺼내는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 가장 먼저, 그리고 가장 많이 쓰는 전략이 &lt;b&gt;Cache-Aside&lt;/b&gt;다.&lt;br /&gt;이 패턴은 말 그대로 &amp;ldquo;캐시는 옆에 두고, 필요할 때만 슬쩍 사용하는&amp;rdquo; 느낌이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 단순하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;요청이 들어오면 캐시(Redis)에서 데이터를 먼저 찾는다.&lt;/li&gt;
&lt;li&gt;있으면 바로 반환한다. (cache hit)&lt;/li&gt;
&lt;li&gt;없으면 DB를 조회해서
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 결과를 캐시에 넣고 (set + TTL)&lt;/li&gt;
&lt;li&gt;그 데이터를 반환한다. (cache miss)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드로 적으면 대략 이런 느낌이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;String key = &quot;user:&quot; + userId;
String cached = redis.get(key);

if (cached != null) {
    return deserialize(cached);
}

User user = userRepository.findById(userId);
redis.setex(key, TTL_SECONDS, serialize(user));
return user;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴의 좋은 점은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;애플리케이션 입장에서 &lt;b&gt;DB가 진실의 원천(Source of Truth)&lt;/b&gt;으로 남아 있고&lt;/li&gt;
&lt;li&gt;Redis는 단순히 &amp;ldquo;조회 결과의 복제본&amp;rdquo;이기 때문에,&lt;br /&gt;캐시가 날아가도 시스템이 동작을 멈추지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 단점/주의점은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쓰기/수정 때 캐시를 어떻게 갱신할지(무효화 vs 업데이트)를 &lt;b&gt;코드 레벨에서 신경 써야 한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;여러 서비스에서 같은 데이터를 캐시하고 있다면, 캐시 일관성 문제가 금방 드러난다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 나는 &amp;ldquo;대부분의 읽기 캐시는 Cache-Aside를 기본값으로 두자&amp;rdquo;는 식으로 접근했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-2. Write-Through &amp;ndash; 창고 관리자에게 항상 맡겨 두는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Write-Through는 &amp;ldquo;쓰기까지 캐시가 책임지는&amp;rdquo; 전략이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;애플리케이션이 데이터 쓰기를 요청할 때&lt;/li&gt;
&lt;li&gt;캐시 계층이 먼저 DB에 쓰고&lt;/li&gt;
&lt;li&gt;그 결과를 캐시에 갱신한 뒤&lt;/li&gt;
&lt;li&gt;애플리케이션에 결과를 돌려준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cache-Aside와 비교하면 차이는 정확히 여기 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Cache-Aside: 애플리케이션이 DB와 캐시를 모두 신경 쓴다.&lt;/li&gt;
&lt;li&gt;Write-Through: 캐시 계층이 DB 쓰기와 캐시 갱신을 함께 책임진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시와 DB의 일관성이 상대적으로 좋다.&lt;/li&gt;
&lt;li&gt;애플리케이션 코드에서는 &amp;ldquo;항상 캐시만 보고/쓰면 된다&amp;rdquo;는 단순한 모델이 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 계층이 DB 쓰기까지 책임지기 때문에 구현 복잡도가 올라간다.&lt;/li&gt;
&lt;li&gt;대부분의 애플리케이션에서는 &lt;b&gt;캐시 앞에 별도의 스토리지 계층을 두는 구조&lt;/b&gt;를 따로 설계해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말하면, 일반적인 Spring 애플리케이션에서 순수 Write-Through를 &amp;ldquo;예쁘게&amp;rdquo; 구현하는 경우는 많이 보지는 못했다.&lt;br /&gt;대부분은 프레임워크나 라이브러리(예: 특정 ORM 2nd level cache)가 이 패턴을 내부에서 쓰고 있는 형태였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-3. Write-Back (Write-Behind) &amp;ndash; 먼저 메모에 적어두고 나중에 정산하는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Write-Back은 &amp;ldquo;쓰기 성능&amp;rdquo;에 엄청 민감할 때 선택하게 되는 전략이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 이렇다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;쓰기 요청이 오면 DB가 아니라 &lt;b&gt;캐시에만 반영&lt;/b&gt;하고&lt;/li&gt;
&lt;li&gt;일정 시간/조건이 되면 &lt;b&gt;비동기로 캐시 &amp;rarr; DB 동기화&lt;/b&gt;를 수행한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마치, 편의점 알바가 판매할 때마다 창고 재고를 즉시 업데이트하는 대신,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;판매 내역만 노트에 적어두고&lt;/li&gt;
&lt;li&gt;새벽에 몰아서 재고를 정산하는 느낌이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쓰기 요청 처리 속도가 매우 빠르다.&lt;/li&gt;
&lt;li&gt;여러 번의 쓰기를 하나의 배치로 묶어서 DB I/O를 줄일 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점(그리고 이게 꽤 크다):&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis(캐시)가 장애 나면, 아직 DB에 반영되지 않은 데이터가 날아갈 수 있다.&lt;/li&gt;
&lt;li&gt;DB와 캐시 간 정합성을 맞추는 로직(배치, 리플레이 등)을 별도로 설계해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로는, &lt;b&gt;업데이트가 잦고, 약간의 데이터 유실/지연을 감수할 수 있는 로그성 데이터&lt;/b&gt;에만 아주 제한적으로 사용할 수 있겠다는 생각이 들었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-4. Refresh-Ahead &amp;ndash; 만료되기 전에 미리 채워두는 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh-Ahead는 스탬피드를 방지하기 위한 전략 중 하나다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이디어는 단순하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TTL이 1시간인 캐시가 있다고 할 때&lt;/li&gt;
&lt;li&gt;만료 직전에 백그라운드 작업이 미리 DB를 조회해서 캐시를 갱신해 둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 항상 &amp;ldquo;따뜻한(warm) 캐시&amp;rdquo;만 보게 되고,&lt;br /&gt;TTL 만료 타이밍에 몰리는 스파이크를 어느 정도 완화할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 이 전략을 쓰려면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 키를 대상으로&lt;/li&gt;
&lt;li&gt;어떤 주기로&lt;/li&gt;
&lt;li&gt;어떤 방식으로 선제적 갱신을 할 것인지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 꽤 구체적으로 결정해야 한다.&lt;br /&gt;실제로는 &lt;b&gt;정말 중요한 소수의 핫 키에만&lt;/b&gt; 이 패턴을 적용하는 게 현실적이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 읽기/쓰기 흐름 따라가 보기 &amp;ndash; Cache-Aside 기준으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 패턴 중에서도, 실무에서 제일 많이 쓰는 건 결국 Cache-Aside였다.&lt;br /&gt;그래서 이 패턴을 기준으로 &amp;ldquo;요청 하나가 어떻게 흐르는지&amp;rdquo;를 정리해보면, 다른 전략을 이해할 때도 기준이 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 읽기 요청 하나를 끝까지 따라가기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오: &lt;code&gt;GET /users/{id}&lt;/code&gt; 요청이 들어왔을 때, 사용자 정보를 캐싱하고 싶다고 해보자.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;컨트롤러 &amp;rarr; 서비스에서 유저 조회 메서드를 호출한다.&lt;/li&gt;
&lt;li&gt;서비스는 우선 Redis에 &lt;code&gt;user:{id}&lt;/code&gt; 키가 있는지 조회한다.&lt;/li&gt;
&lt;li&gt;있으면 그 값을 역직렬화해서 반환한다. (cache hit)&lt;/li&gt;
&lt;li&gt;없으면 DB에서 &lt;code&gt;userRepository.findById(id)&lt;/code&gt;로 조회한다.&lt;/li&gt;
&lt;li&gt;조회 결과가 있으면 Redis에 &lt;code&gt;setex(&quot;user:{id}&quot;, TTL, value)&lt;/code&gt;로 저장한다.&lt;/li&gt;
&lt;li&gt;최종적으로 조회 결과를 클라이언트에게 반환한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 모두가 알고 있는 흐름인데, 실무에서는 다음 지점을 하나씩 더 고민하게 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TTL을 얼마로 둘 것인가? (5분 vs 1시간 vs 24시간)&lt;/li&gt;
&lt;li&gt;유저가 탈퇴/정지/닉네임 변경 등 상태가 바뀌었을 때, &lt;b&gt;언제 캐시를 갱신할 것인가?&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;없는 유저(404)를 얼마나 오래 캐시할 것인가? (Negative Cache)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 쓰기 요청과 캐시 무효화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 사용자에 대해 &lt;code&gt;PUT /users/{id}&lt;/code&gt;로 프로필을 수정하는 경우를 생각해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션은 크게 두 가지가 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;쓰기 후 캐시 삭제(Invalidate)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 업데이트가 성공하면 &lt;code&gt;DEL user:{id}&lt;/code&gt;를 호출한다.&lt;/li&gt;
&lt;li&gt;다음 읽기 요청에서 다시 DB를 보고 캐시를 채운다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;쓰기 후 캐시 갱신(Update)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 업데이트가 성공하면 새로운 값으로 &lt;code&gt;SETEX user:{id}&lt;/code&gt;를 호출한다.&lt;/li&gt;
&lt;li&gt;다음 요청부터는 바로 최신 캐시를 보게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 써본 결과,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도메인 로직이 단순하고 변경이 잦지 않은 데이터는 &lt;b&gt;&amp;ldquo;삭제 후 Lazy Loading&amp;rdquo;&lt;/b&gt;이 훨씬 단순했다.&lt;/li&gt;
&lt;li&gt;반대로 실시간성이 중요한 데이터(예: 프로필 닉네임, 노출 순서 등)는 &lt;b&gt;&amp;ldquo;즉시 캐시 갱신&amp;rdquo;&lt;/b&gt;이 필요했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국, 캐시 무효화 전략도 &lt;b&gt;데이터 중요도/변경 패턴에 따라 케이스 바이 케이스&lt;/b&gt;로 가져가는 게 현실적이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. 캐시 스탬피드와 핫키 대응&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번은 특정 리소스가 &amp;ldquo;이벤트 페이지&amp;rdquo;로 쓰이면서 갑자기 트래픽이 쏠리는 일이 있었다.&lt;br /&gt;캐시 TTL이 5분이었는데, 딱 TTL 만료 타이밍마다 DB QPS가 두 배로 튀는 스파이크가 찍혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상황을 이해해 보면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;TTL 만료 직후, 처음 들어온 여러 요청이 동시에 캐시에서 MISS를 맞고&lt;/li&gt;
&lt;li&gt;각각이 DB로 동일한 쿼리를 날리면서&lt;/li&gt;
&lt;li&gt;캐시가 따뜻해지기 전까지 짧은 시간 동안 DB가 괴로워진다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 줄이기 위해 사용했던 방법은 크게 두 가지였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;랜덤 TTL(Jitter)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본 TTL에 랜덤 값을 섞어서, 만료 타이밍을 분산시킨다.&lt;/li&gt;
&lt;li&gt;예: &lt;code&gt;TTL = 300초 + rand(0~60초)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 락 기반의 Single Flight&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 번째 요청만 DB로 가도록 락을 걸고&lt;/li&gt;
&lt;li&gt;나머지 요청은 락 해제까지 기다렸다가 캐시에서 읽도록 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 방식은 구현이 조금 귀찮지만,&lt;br /&gt;&amp;ldquo;핫키 + 짧은 TTL&amp;rdquo; 조합에서는 꽤 효과가 좋았다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 실제 프로젝트에서 어떻게 가져갔는지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 서비스 코드에서는 다음과 같은 순서로 캐시를 도입하는 게 현실적이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. 먼저 &amp;ldquo;어떤 데이터에 캐시를 쓸지&amp;rdquo;부터 고르기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &amp;ldquo;조회가 많은 모든 API에 캐시를 붙이자&amp;rdquo;는 생각이었다.&lt;br /&gt;근데 곧 깨달았다. 그렇게 하면 &lt;b&gt;캐시 관리 비용이 너무 커진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 기준을 이렇게 바꿨다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;읽기:쓰기 비율이 압도적으로 높은 데이터&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;DB 쿼리가 상대적으로 무겁거나,&lt;br /&gt;조인/집계가 많이 들어가는 조회&lt;/li&gt;
&lt;li&gt;약간의 지연된 일관성을 허용해도 되는 데이터&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인기 게시글 목록&lt;/li&gt;
&lt;li&gt;정적에 가까운 설정값/메타데이터&lt;/li&gt;
&lt;li&gt;홈 화면에 반복적으로 노출되는 리소스들&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. 키 설계와 네임스페이스 정리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 서비스가 같은 Redis 클러스터를 공유하면서, 키 설계를 대충 하면 금방 지옥이 열린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 키는 항상 아래 패턴을 강제했다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;{서비스명}:{도메인}:{버전}:{식별자}
예) feed:user-timeline:v1:123
예) user:profile:v2:42&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스별 키를 쉽게 구분할 수 있고&lt;/li&gt;
&lt;li&gt;버전 업(스키마 변경)이 필요할 때 &lt;b&gt;&lt;code&gt;v1&lt;/code&gt; &amp;rarr; &lt;code&gt;v2&lt;/code&gt;로 키를 분리&lt;/b&gt;해서 점진 전환이 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. Spring Cache vs 직접 Redis 접근&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서는 &lt;code&gt;@Cacheable&lt;/code&gt;/&lt;code&gt;@CacheEvict&lt;/code&gt; 같은 애너테이션 기반 캐시도 지원한다.&lt;br /&gt;처음에는 이게 너무 편해서 거의 모든 캐시를 여기부터 시작했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Cacheable(cacheNames = &quot;userProfile&quot;, key = &quot;#userId&quot;)
public UserProfile getUserProfile(Long userId) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 쓰다 보면 다음 같은 고민이 생겼다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시 키가 복잡해질수록, 애너테이션만으로는 &lt;b&gt;키 규칙을 일관되게 관리하기 어렵다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;캐시 스탬피드 방지, 분산 락, 캐시 프리로드 등은 애너테이션 모델에서 표현하기 애매하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 결론은 대략 이렇게 정리됐다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단순한 읽기 캐시&lt;/b&gt;: &lt;code&gt;@Cacheable&lt;/code&gt;로 최대한 빠르게 도입&lt;/li&gt;
&lt;li&gt;&lt;b&gt;핵심 도메인/핫키/고급 전략이 필요한 곳&lt;/b&gt;: 직접 Redis 템플릿/클라이언트로 제어&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-4. 모니터링과 인덱스/메모리 튜닝&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시를 붙인 이후에 제일 도움됐던 건, Redis 모니터링을 제대로 보는 습관이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;키 개수/메모리 사용량&lt;/li&gt;
&lt;li&gt;히트율(hit/miss)&lt;/li&gt;
&lt;li&gt;가장 많이 사용되는 키 패턴&lt;/li&gt;
&lt;li&gt;Eviction이 발생하는지 여부&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 보고 나서야,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정말 효과 있는 캐시와&lt;/li&gt;
&lt;li&gt;그냥 &amp;ldquo;심리적 안심용&amp;rdquo; 캐시를 구분할 수 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Redis 캐시를 직접 만지면서 배운 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하다 보니, 결국 캐시에서 가장 중요한 건 &amp;ldquo;기술 이름&amp;rdquo;보다도 &lt;b&gt;어떤 위험을 감수하겠다고 합의했는지&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 기억해 두고 싶은 포인트는 대략 이렇다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;캐시는 결국 복제본이다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;진실의 원천이 무엇인지(DB? 외부 API?)를 항상 명확히 해두자.&lt;/li&gt;
&lt;li&gt;장애/재시작 시 캐시를 날려도, 시스템이 복구 가능한 구조인지 먼저 확인해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시는 일관성 대신 성능을 산다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;최신 데이터가 아니어도 되는 구간&amp;rdquo;을 먼저 찾는 게 중요하다.&lt;/li&gt;
&lt;li&gt;그 구간을 좁게 잡을수록, 캐시는 관리가 어렵지 않다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TTL과 무효화가 반은 먹고 들어간다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TTL을 충분히 짧게 두면 정합성 문제는 줄지만, 스탬피드와 DB 부하가 올라간다.&lt;/li&gt;
&lt;li&gt;TTL을 길게 두면 성능은 좋은데, 잘못된 데이터가 더 오래 노출될 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;핫키/스탬피드는 언젠가 꼭 만난다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기에 Jitter(랜덤 TTL)와 Single Flight/락 등에 대한 대비를 해두면,&lt;/li&gt;
&lt;li&gt;나중에 장애 대응 속도가 훨씬 빨라진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 마무리 &amp;ndash; 캐시는 &amp;ldquo;조금 느린 정확함&amp;rdquo;과 &amp;ldquo;조금 빠른 부정확함&amp;rdquo; 사이의 선택이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 Redis 캐시를 붙였을 때는,&lt;br /&gt;&amp;ldquo;성능 개선을 위한 보너스&amp;rdquo; 정도로만 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여러 장애와 버그를 겪고 나니,&lt;br /&gt;캐시는 단순한 성능 최적화가 아니라 &lt;b&gt;도메인 설계의 일부&lt;/b&gt;라는 생각이 든다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;캐시는 &amp;ldquo;조금 느린 정확함&amp;rdquo;과 &amp;ldquo;조금 빠른 부정확함&amp;rdquo; 사이에서,&lt;br /&gt;어디까지 타협할 것인지에 대한 설계 선택이다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 정리한 것들을 기준으로,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우리 서비스에서 &amp;ldquo;캐시로 감싸고 싶은 데이터&amp;rdquo;는 무엇인지&lt;/li&gt;
&lt;li&gt;그 데이터에 대해 허용할 수 있는 &lt;b&gt;일관성/지연/유실의 범위&lt;/b&gt;는 어디까지인지&lt;/li&gt;
&lt;li&gt;그에 맞는 캐싱 전략(Cache-Aside, Write-Through, Write-Back, Refresh-Ahead 등)을 어떻게 조합할 것인지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 한 번씩 점검해 보면 좋을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 실제 프로젝트에서 캐시를 도입할 때는,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가장 트래픽이 높은 조회 API 하나를 고르고&lt;/li&gt;
&lt;li&gt;Cache-Aside 패턴으로 작은 TTL과 함께 시작해 보고&lt;/li&gt;
&lt;li&gt;모니터링을 통해 효과와 부작용(스탬피드, 정합성 문제 등)을 관찰한 뒤&lt;/li&gt;
&lt;li&gt;필요할 때만 전략을 확장(프리로드, 락, 키 버전 등)하는 방식으로&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;작게 시작해서 점진적으로 정교하게 만들어가는 캐시&amp;rdquo;&lt;/b&gt;를 지향하는 게 현실적이라는 생각을 남겨두고 싶다.&lt;/p&gt;</description>
      <category>[ 기술 스택 ]/Cache</category>
      <author>Devon</author>
      <guid isPermaLink="true">https://imgdevel.tistory.com/43</guid>
      <comments>https://imgdevel.tistory.com/43#entry43comment</comments>
      <pubDate>Mon, 8 Dec 2025 13:16:24 +0900</pubDate>
    </item>
  </channel>
</rss>