<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Falcon</title>
    <link>https://m-falcon.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 5 Jul 2026 05:59:58 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>M_Falcon</managingEditor>
    <image>
      <title>Falcon</title>
      <url>https://tistory1.daumcdn.net/tistory/3133125/attach/b54570f37ca84821aa52f2faad465544</url>
      <link>https://m-falcon.tistory.com</link>
    </image>
    <item>
      <title>File 을 읽고 쓸 때 `.tmp` 파일은 왜 필요할까</title>
      <link>https://m-falcon.tistory.com/815</link>
      <description>&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;즉, &quot;쓰는 중&quot; 과 같은 상태는 허용하지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 방법&lt;/h2&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;/data/
 |--- data.snapshot
 |--- data.snapshot.tmp&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일에 데이터를 쓴다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일 쓰기를 완료한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일을 &lt;code&gt;data.snapshot&lt;/code&gt; 파일로 이름을 변경한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data.snapshot&lt;/code&gt; 파일이 있는 경우, 덮어쓰기한다.&lt;/li&gt;
&lt;/ol&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;.tmp&lt;/code&gt; 는 왜 필요할까?&lt;br /&gt;&lt;code&gt;.tmp&lt;/code&gt; 파일 이름은 temporary 임시파일로부터 왔다.&lt;br /&gt;그럼 '임시' 파일이란게 왜 필요한지를 생각해보자.&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;p data-ke-size=&quot;size16&quot;&gt;문자열 &quot;a b c d e&quot; 를 3차례 걸쳐서 써보자&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;a b&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;a b c&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;a b c d e&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문자열 &quot;a b c d e&quot; 를 모두 쓰고 나서는 반드시 파일을 저장해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장된 파일을 열면 다음과 같이 써있을 것이다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;a b c d e&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데, &lt;code&gt;a b c&lt;/code&gt; 까지만 쓴 상황에서 누군가 파일을 열었다고 가정해보자&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;a b c&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이런 상태를 허용해선 안된다.&lt;/b&gt;&lt;br /&gt;요구사항에서 파일은 완결된 상태를 가져야 한다고 했다.&lt;br /&gt;여기서 완결이란, &quot;모든 데이터가 파일에 쓰고 저장한 상태&quot; 를 의미한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;code&gt;.tmp&lt;/code&gt; 파일이 필요한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일을 읽는 프로세스는 파일명 &lt;code&gt;data.snapshot&lt;/code&gt; 를 읽는다.&lt;br /&gt;파일을 읽는 프로세스에서는 이 파일이 쓰다만 파일인지, 완결된 파일인지 모른다. 그저 읽을 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기 프로세스는 &lt;code&gt;.tmp&lt;/code&gt; 파일에 대한 visibility (가시성) 이 없는 상태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;data.snapshot&lt;/code&gt; -&amp;gt; 완결된 상태만 허용&lt;br /&gt;&lt;code&gt;data.snapshot.tmp&lt;/code&gt; -&amp;gt; 쓰는 '중' 인 상태 허용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기 프로세스는 &lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일에 접근하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 쓰기 프로세스가 &lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일에 다음과 같이 쓸 수 있다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;a b&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;a b c&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;a b c d e&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일에 &lt;code&gt;a b c d e&lt;/code&gt; 를 모두 입력 완료하여 파일을 저장했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 간단하다.&lt;br /&gt;&lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일을 &lt;code&gt;data.snapshot&lt;/code&gt; 파일로 덮어쓰면 끝이다.&lt;br /&gt;기존 &lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일은 제거하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로, 일부만 써진 상태 (Write In Process) 를 없애 완결된 파일만 쓰고, 읽을 수 있도록 제한할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXWGhJ/dJMcahyeqLx/W2R0lrDRrKg2jjxfjKJRIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXWGhJ/dJMcahyeqLx/W2R0lrDRrKg2jjxfjKJRIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXWGhJ/dJMcahyeqLx/W2R0lrDRrKg2jjxfjKJRIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXWGhJ%2FdJMcahyeqLx%2FW2R0lrDRrKg2jjxfjKJRIK%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;2720&quot; height=&quot;1720&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsu1mp/dJMcadij6oo/IkQdqtwRq1KJn7QuH8CtT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsu1mp/dJMcadij6oo/IkQdqtwRq1KJn7QuH8CtT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsu1mp/dJMcadij6oo/IkQdqtwRq1KJn7QuH8CtT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbsu1mp%2FdJMcadij6oo%2FIkQdqtwRq1KJn7QuH8CtT0%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;2720&quot; height=&quot;1640&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1640&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;위에서 설명한 방식을 Kotlin 코드로 작성해보자.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption

class FileManager {
  fun saveSnapshot() {
    Files.createDirectories(baseDir)

    val tmp = baseDir.resolve(SNAPSHOT_TMP)
    val target = baseDir.resolve(SNAPSHOT)

    DataOutputStream(BufferedOutputStream(
      Files.newOutputStream(tmp),
      BUFFER_SIZE)
    ).use { out -&amp;gt;
      out.write() // ..
      }
    }

    // Atomic rename within the same dir: a reader sees either the old snapshot or the complete new one.
    // No fsync &amp;mdash; the snapshot is a startup optimization, rebuildable from the Kafka dedup-store topic.
    Files.move(
      tmp,
      target,
      StandardCopyOption.ATOMIC_MOVE,
      StandardCopyOption.REPLACE_EXISTING
    )
  }

  companion object {
    const val SNAPSHOT = &quot;data.snapshot&quot;
    const val SNAPSHOT_TMP = &quot;data.snapshot.tmp&quot;
  }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 함수는 &lt;code&gt;data.snapshot.tmp&lt;/code&gt; 파일에서 &lt;code&gt;data.snapshot&lt;/code&gt; 파일로 bytes 를 복사하지 않는다.&lt;br /&gt;파일명 변경 명령어 (&lt;code&gt;rename&lt;/code&gt;)는 메타데이터 명령어로, 기존에 &lt;code&gt;data.snapshot&lt;/code&gt; 파일의 inode/data 를 원자적으로 새로운 &lt;code&gt;data.snapshot&lt;/code&gt; 파일을 가리키도록 변경한다.&lt;br /&gt;Windows, Linux 등의 운영체제는 &lt;code&gt;rename&lt;/code&gt; 명령어를 Atomic 하게 설계해놓았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게하면 아래 2가지 상황만 가능하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;덮어쓰기 전에 파일 읽기 -&amp;gt; 이전 버전의 &lt;code&gt;data.snapshot&lt;/code&gt; 파일 읽기&lt;/li&gt;
&lt;li&gt;덮어쓰기 후 파일 읽기 -&amp;gt; 새로운 버전의 &lt;code&gt;data.snapshot&lt;/code&gt; 파일 읽기&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느쪽이든 완결된 파일을 읽는다.&lt;br /&gt;Atomic 한 연산으로 써지기 때문에 &quot;일부만 써진&quot; 파일이 존재할 수 있는 시간이 아예 없다.&lt;/p&gt;</description>
      <category>LINUX/Linux</category>
      <category>Atomic</category>
      <category>file</category>
      <category>Kotlin</category>
      <category>linux</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/815</guid>
      <comments>https://m-falcon.tistory.com/815#entry815comment</comments>
      <pubDate>Wed, 1 Jul 2026 23:23:30 +0900</pubDate>
    </item>
    <item>
      <title>[Docker] multiplatfom 이미지 빌드하고 푸시하기</title>
      <link>https://m-falcon.tistory.com/814</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;OS&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Chip: Apple M4&lt;/li&gt;
&lt;li&gt;archituecture: arm64&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;private repository 에 architecture amd64 / arm64 모든 이미지를 넣고싶다.&lt;br /&gt;즉, DockerHub 에서 흔히 볼 수 있는 multiplatform image 를 빌드하고 관리하고 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;900&quot; data-origin-height=&quot;313&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OD0sA/dJMcaii2gAc/tOhPeYhDHSgK0nokq3X4V0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OD0sA/dJMcaii2gAc/tOhPeYhDHSgK0nokq3X4V0/img.png&quot; data-alt=&quot;DockerHub adobe/s3mock MultiPlatform&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OD0sA/dJMcaii2gAc/tOhPeYhDHSgK0nokq3X4V0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOD0sA%2FdJMcaii2gAc%2FtOhPeYhDHSgK0nokq3X4V0%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;900&quot; height=&quot;313&quot; data-origin-width=&quot;900&quot; data-origin-height=&quot;313&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DockerHub adobe/s3mock MultiPlatform&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MultiPlatform image 란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 OS / architecture 를 지원하는 이미지라는 뜻이다.&lt;br /&gt;쉽게 생각하면, 내 운영체제가 linux / amd64 라면 linux / amd64 빌드 이미지를 사용하게 되고&lt;br /&gt;linux / arm64 라면 linux / arm64 이미지를 자동으로 받는다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ podman pull adobe/s3mock:5.0&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;# image 
$ podman inspcect adobe/s3mock:5.0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 CPU Architecture 는 M4 로 arm64 므로 linux/arm64 이미지를 받았음을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;560&quot; data-origin-height=&quot;301&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQq1ca/dJMcaiDkYar/WAn0MiI4dY7zIngk22BqDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQq1ca/dJMcaiDkYar/WAn0MiI4dY7zIngk22BqDk/img.png&quot; data-alt=&quot;linux/arm64&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQq1ca/dJMcaiDkYar/WAn0MiI4dY7zIngk22BqDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQq1ca%2FdJMcaiDkYar%2FWAn0MiI4dY7zIngk22BqDk%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;560&quot; height=&quot;301&quot; data-origin-width=&quot;560&quot; data-origin-height=&quot;301&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;linux/arm64&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apple M4 칩이 아닌 Intel 칩을 사용했다면 linux/amd64 를 다운받았을 것이다.&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;로컬 머신의 OS, Architecture가 pull 할 이미지를 결정한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;arm64 뿐만 아니라 amd64에서도 사용하고 싶다면 &lt;b&gt;multiplatform 이미지를 빌드해야한다.&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;우선, multi platform 이미지를 말려면 &lt;code&gt;manifest&lt;/code&gt; 를 생성해야한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) manifest 생성&lt;/h3&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;$ podman manifest create s3mock-multiarch&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) manifest 에 multiplatform 이미지 추가&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ podman manifest add --all s3mock-multiarch \
adobe/s3mock:5.0.0&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(3) manifest 확인&lt;/h3&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;$ podman manifest inspect s3mock-multiarch&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1470&quot; data-origin-height=&quot;1238&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8YIFN/dJMcaa6phpz/ZCPWQjS3OzjO1ao34O6sUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8YIFN/dJMcaa6phpz/ZCPWQjS3OzjO1ao34O6sUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8YIFN/dJMcaa6phpz/ZCPWQjS3OzjO1ao34O6sUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8YIFN%2FdJMcaa6phpz%2FZCPWQjS3OzjO1ao34O6sUK%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;1470&quot; height=&quot;1238&quot; data-origin-width=&quot;1470&quot; data-origin-height=&quot;1238&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;os/architecture 가 unknown 2개가 생성되는데 무시해도된다.&lt;br /&gt;unknown 의 정체는..&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker BuildKit 으로 빌드된 이미지는 자동으로 Provenance attestation - &quot;Github Action 이면 어떤 runner 에서, 어떤 commit 으로 어떤 빌드 인자로 만들어졌는가&quot; 와 같은 메타데이터를 함께 publish한다. 이 과정에서 multi-arch 빌드면 각 플랫폼마다 하나씩 생성되므로 2개 플랫폼 (arm64 + amd64) -&amp;gt; 2개 attestation 이 자동생성된다.&lt;br /&gt;아무튼 몰라도 된다. 별로 안중요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(4) manifest push&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS ECR 에 manifest 로 생성한 멀티플랫폼 이미지를 push 한다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;$ podman manifest push --all s3mock-multiarch \
    docker://{AWS_ACCOUNT_ID}.dkr.ecr.{AWS_REGION}.amazonaws.com/{ECR_REPOSITORY}/s3mock:5.0.0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹시나 이 단계에서 에러가 난다면 다음과 같은 방법으로 unknown 의 os architecture digest 를 제거하고 다시 시도해보라.&lt;br /&gt;Registry 에서 os/architecture 가 unknown 인 이미지를 거절하는 경우도 있다.&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;$ podman manifest remove &amp;lt;unknown-digest&amp;gt;&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;✅ 멀티 플랫폼 이미지를 빌드할 수 있다.&lt;br /&gt;✅ 멀티 플랫폼 이미지를 pull 하면 자동으로 해당 머신의 os/architecture 에 따라 이미지를 선택하여 다운받는다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;manifest 가 뭐고 왜쓰는거지?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 의문일거다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;manifest 등장 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기의 Docker 는 이미지 1개당 1개의 아키텍처였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;nginx:latest # amd64 전용&lt;/li&gt;
&lt;li&gt;arm64v8/nginx:latest # arm64 전용&lt;/li&gt;
&lt;li&gt;amd64v7/nginx:latest # armv7 전용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면, 지원하고자 하는 아키텍처 개수만큼 image 를 따로 빌드해야한다.&lt;br /&gt;Dockerfile 의 내용은 똑같은데 이미지 개수만큼 빌드를 진행해야한다.&lt;br /&gt;사용자도 매번 아키텍처별로 이미지를 구분해서 사용해야했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;manifest 아이디어는 다음과 같다.&lt;br /&gt;&quot;하나의 태그 (&lt;code&gt;nginx:latest&lt;/code&gt;) 로 여러 플랫폼별 이미지를 가리키는 인덱스를 가리키게 할 수 있게하자.&lt;br /&gt;사용자가 pull 하면 클라이언트 플랫폼에 맞는 이미지를 알아서 골라갈 수 있도록 지원하자.&quot;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;manifest 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;adobe/s3mock:5.0.0 manifest 전체 구조를 살펴보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;6717&quot; data-origin-height=&quot;5185&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SS0g1/dJMcagetcck/8ZlntoqKTemje1BmsHxGT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SS0g1/dJMcagetcck/8ZlntoqKTemje1BmsHxGT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SS0g1/dJMcagetcck/8ZlntoqKTemje1BmsHxGT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSS0g1%2FdJMcagetcck%2F8ZlntoqKTemje1BmsHxGT1%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;6717&quot; height=&quot;5185&quot; data-origin-width=&quot;6717&quot; data-origin-height=&quot;5185&quot;/&gt;&lt;/span&gt;&lt;/figure&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;code&gt;adobe/s3mock:5.0.0&lt;/code&gt; 을 pull 한다.&lt;/li&gt;
&lt;li&gt;Manifest List 가 반환된다.&lt;/li&gt;
&lt;li&gt;클라이언트가 자기 플랫폼 (ex. arm64) 를 보고 arm64 Image Manifest digest 를 선택한다.&lt;/li&gt;
&lt;li&gt;선택한 image 를 pull 받는다.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>기타/Docker</category>
      <category>docker</category>
      <category>manifest</category>
      <category>podman</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/814</guid>
      <comments>https://m-falcon.tistory.com/814#entry814comment</comments>
      <pubDate>Fri, 15 May 2026 18:21:46 +0900</pubDate>
    </item>
    <item>
      <title>Docker 대신 Podman 으로 이미지 업로드 하기</title>
      <link>https://m-falcon.tistory.com/812</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- OS: MacOS M4 CPU (arm64)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Docker version: v29.1.3&lt;/p&gt;
&lt;pre id=&quot;code_1768972744642&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# arm 대신 amd 아키텍처 이미지 다운로드
$ docker pull --platform linux/amd64 eclipse-temurin:25-jdk

# 다운로드 결과 확인시 `Architecture` 가 빈 값으로 출력됨
$ docker inspect eclipse-temurin:25-jdk | grep Architecture
&quot;Architecture&quot;: &quot;&quot;,&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;로컬 PC 는 Mac arm 아키텍처고 서버 PC 는 amd64 아키텍처를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 사용할 이미지 아키텍처를 linux/amd64 로 지정했으나 정상적으로 pull 되지 않는 현상이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Podman 설치 절차&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(1) Podman Desktop installer 다운로드&lt;/p&gt;
&lt;figure id=&quot;og_1768972280764&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Podman Desktop&quot; data-og-description=&quot;Downloads&quot; data-og-host=&quot;podman-desktop.io&quot; data-og-source-url=&quot;https://podman-desktop.io/downloads&quot; data-og-url=&quot;https://podman-desktop.io/downloads&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cpXtxX/dJMb9kl6P00/eQ66ytQNJlUnK5IXGKl3j0/img.png?width=959&amp;amp;height=479&amp;amp;face=0_0_959_479,https://scrap.kakaocdn.net/dn/OQw1T/dJMb9eTJCv3/Ik13Nan0C7YSYxFKVrqkKK/img.png?width=959&amp;amp;height=479&amp;amp;face=0_0_959_479&quot;&gt;&lt;a href=&quot;https://podman-desktop.io/downloads&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://podman-desktop.io/downloads&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cpXtxX/dJMb9kl6P00/eQ66ytQNJlUnK5IXGKl3j0/img.png?width=959&amp;amp;height=479&amp;amp;face=0_0_959_479,https://scrap.kakaocdn.net/dn/OQw1T/dJMb9eTJCv3/Ik13Nan0C7YSYxFKVrqkKK/img.png?width=959&amp;amp;height=479&amp;amp;face=0_0_959_479');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Podman Desktop&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Downloads&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;podman-desktop.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(2) PodMan Desktop 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1015&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IXAOn/dJMcafL9EP3/b7Py1I2REkFoXpHSVKKnek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IXAOn/dJMcafL9EP3/b7Py1I2REkFoXpHSVKKnek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IXAOn/dJMcafL9EP3/b7Py1I2REkFoXpHSVKKnek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIXAOn%2FdJMcafL9EP3%2Fb7Py1I2REkFoXpHSVKKnek%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;1015&quot; height=&quot;634&quot; data-origin-width=&quot;1015&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(3) 설치 여부 확인&lt;/p&gt;
&lt;pre id=&quot;code_1768972454211&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$ podman --version
podman version 5.7.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;(4) Podman machine 시작&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BwESw/dJMcaiWmV4C/oRi3T9PAjWCkw93d4QS7KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BwESw/dJMcaiWmV4C/oRi3T9PAjWCkw93d4QS7KK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BwESw/dJMcaiWmV4C/oRi3T9PAjWCkw93d4QS7KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBwESw%2FdJMcaiWmV4C%2FoRi3T9PAjWCkw93d4QS7KK%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;1024&quot; height=&quot;768&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;768&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Podman 사용 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실상 docker cli 와 똑같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768973331667&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# podman image pull --platform &amp;lt;platform&amp;gt; &amp;lt;registry&amp;gt;/&amp;lt;image-name&amp;gt;:&amp;lt;tag&amp;gt;
$ podman image pull --platform linux/amd64 eclipse-temurin:25-jdk&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;정상적으로 linux/amd64 아키텍처 이미지를 받아왔다.&lt;/p&gt;
&lt;pre id=&quot;code_1768973594277&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$ podman inspect eclipse-temurin:25-jdk | grep Architecture
          &quot;Architecture&quot;: &quot;amd64&quot;,&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>기타/Docker</category>
      <category>Container</category>
      <category>docker</category>
      <category>Pod</category>
      <category>podman</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/812</guid>
      <comments>https://m-falcon.tistory.com/812#entry812comment</comments>
      <pubDate>Wed, 21 Jan 2026 15:44:40 +0900</pubDate>
    </item>
    <item>
      <title>2025년 개발 회고</title>
      <link>https://m-falcon.tistory.com/811</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ClusterMode.png&quot; data-origin-width=&quot;2140&quot; data-origin-height=&quot;2884&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9qXOu/dJMcafFjzuc/TJKGUnUJOkcbyGp4jl8hb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9qXOu/dJMcafFjzuc/TJKGUnUJOkcbyGp4jl8hb0/img.png&quot; data-alt=&quot;2025 주요 학습 내용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9qXOu/dJMcafFjzuc/TJKGUnUJOkcbyGp4jl8hb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9qXOu%2FdJMcafFjzuc%2FTJKGUnUJOkcbyGp4jl8hb0%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;739&quot; height=&quot;996&quot; data-filename=&quot;ClusterMode.png&quot; data-origin-width=&quot;2140&quot; data-origin-height=&quot;2884&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;2025 주요 학습 내용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;카프카 내부 소스를 뜯어보다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2025년 1월 1일 Kafka Topic Replication , Compact 원리를 파악하기 위해 소스를 뜯어봤다.&lt;br /&gt;머릿속에서 많이 휘발되긴 했지만&lt;br /&gt;이 경험이 카프카 기반 서비스를 운영하다가 발생한 이슈를 해결하는데 큰 도움이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JVM 멀티스레드 학습&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미뤄왔던 JVM 진영에서 비동기 프로그래밍에 사용되는 기술&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(Completable)Future API&lt;/li&gt;
&lt;li&gt;JVM GC, Heap Memory Layout&lt;/li&gt;
&lt;li&gt;Multi Thread / ThreadPool&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;부끄럽지만 JVM 기반지식은 텅 비어있었는데 동료들과의 간극을 조금이나마 채울 수 있었다.&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;br /&gt;회사에서 자면서까지 서비스 개발을 했지만 그 서비스를 아무도 사용하지 않는 일이 몇 차례 있었다.&lt;br /&gt;문득 그런 생각이 들었다.&lt;br /&gt;내가 세상에 존재하긴 하는건가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적으로 어쩌구 저쩌구 고민하고 머리 싸메도 아무도 내 서비스를 써주지 않으면 의미가 없다.&lt;br /&gt;세상에 존재함은, 누군가가 사용했을 때다.&lt;br /&gt;음식은 누군가 먹었을 때&lt;br /&gt;음악은 누군가 들었을 때&lt;br /&gt;실존한다고 말할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 맥락에서, 누군가가 나를 찾는 다는건 실존한다는 느낌을 준다.&lt;br /&gt;입사 초기에 아무도 나를 찾지 않고 오로지 내가 남을 찾았을 때가 있었다.&lt;br /&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;만드는데 참여한 서비스 성능 개선 작업 등.&lt;/li&gt;
&lt;/ul&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;br /&gt;그러다보니 사람들이 나를 더 찾았다.&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;p data-ke-size=&quot;size16&quot;&gt;나는 싱글스레드인데 자꾸 인터럽트가 발생하여 문맥 전환이 일어났다.&lt;br /&gt;나라는 자원을 별로 중요하지 않은 일에 너무 많이 소비해버렸다.&lt;/p&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;&quot;아 이제 나를 그만 좀 불렀으면 -- &quot; 싶었다.&lt;br /&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;10월부터는 퇴근하고 사이드 프로젝트 개발을 하기 시작했다.&lt;br /&gt;회사에서는 도저히 뭔가를 개발할 수가 없었다.&lt;br /&gt;프로그래밍은 상당한 집중력을 요한다.&lt;br /&gt;인터럽트는 방해가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 퇴근하고 온전히 나만 있는 시간이 필요했다.&lt;br /&gt;집에가면 누워서 늘어지고 싶어졌다.&lt;br /&gt;집으로 퇴근하는 대신 PC방으로 출근하기 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행복해졌다.&lt;br /&gt;몸은 진짜 피곤한데, 내가 살아있다는 느낌이 들기 시작했다.&lt;br /&gt;내가 왜 프로그래밍을 시작했나.&lt;br /&gt;나는 뭔가를 만들면서 문제를 해결하는데 짜릿함을 느꼈다.&lt;br /&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;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;/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;2025년 12월 30일 기능 개발을 마쳤고&lt;br /&gt;2026년 1월 1주차에 설계 문서 초안을 작성하여 2026-01-08 사내에 설계 내용을 공유했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Github 오픈소스로 만들어뒀기 때문에&lt;br /&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;h2 data-ke-size=&quot;size26&quot;&gt;요약 정리&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2025 가장 잘한 일&lt;/h4&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;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2025 가장 아쉬운 일&lt;/h4&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;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026년은 반복업무 자동화로 코어 개발 시간을 확보해야한다.&lt;br /&gt;외부 요청중 중요도가 낮은 일은 후순위로 미뤄야한다.&lt;br /&gt;즉시 응답하지 않는다고해서 그게 잘못된 것은 아니다.&lt;/p&gt;</description>
      <category>기타/잡동사니</category>
      <category>2025</category>
      <category>개발자</category>
      <category>사이드프로젝트</category>
      <category>회고</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/811</guid>
      <comments>https://m-falcon.tistory.com/811#entry811comment</comments>
      <pubDate>Sat, 10 Jan 2026 20:56:18 +0900</pubDate>
    </item>
    <item>
      <title>[Java] Decorator Pattern</title>
      <link>https://m-falcon.tistory.com/810</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;요구사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FileRepository 인터페이스는 파일 스토리지로부터 파일키를 입력받아 파일 InputStream을 반환한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;/**
 * Get the file contents from a file storage (e.g. LocalFile System, S3, GCS, Azure Blob Storage, etc.)
 */
public interface FileRepository {
  InputStream getFile(FileKey fileKey) throws IOException;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;LocalFile 은 LocalFileRepository,&lt;/li&gt;
&lt;li&gt;AWS S3 File 은 S3FileRepository 클래스로 파일을 가져와 InputStream 을 반환한다.&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;&quot;.zip, .gz, .zst 같은 압축 파일은 압축 해제를 지원해야한다&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 구현할 것인가?&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;LocalFileRepository&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;public class LocalFileRepository implements FileRepository {
  @Override
  public InputStream getFile(FileKey fileKey) throws IOException {
    Path path = Paths.get(URI.create(fileKey.get()));
    return Files.newInputStream(path, READ);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;S3FileRepository&lt;/h4&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class S3FileRepository implements FileRepository {
  private final S3Client s3Client;

  @Override
  public InputStream getFile(FileKey fileKey) {
    S3Location s3Location = S3Location.from(fileKey);
    GetObjectRequest request = GetObjectRequest.builder()
      .bucket(s3Location.bucket())
      .key(s3Location.key())
      .build();

    return s3Client.getObject(request);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Solution 1 각 클래스에 압축해제 메소드 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;FileRepository&lt;/code&gt; 에 &lt;b&gt;압축해제&lt;/b&gt; 라는 책임을 추가한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface FileRepository {
  InputStream getFile(FileKey fileKey) throws IOException;
  InpusStream decompress(FileKey key, InputStream in) throws IOException;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벌써 구린 냄새가난다.&lt;br /&gt;FileRepository 는 원래 '파일을 가져오기'만 했는데 책임이 커지고있다.&lt;br /&gt;압축해제 뿐만 아니라 Encoding / Decoding 도 추가된다면? 인터페이스가 점점 비대해질 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 중복도 심해진다 예를들면&lt;br /&gt;decompress 메소드를 LocalFileRepository, S3FileRepository 양쪽 모두 중복 구현해야한다.&lt;br /&gt;일단 가져온 파일에 대한 파일 압축 해제 로직은 같기 때문에, DRY(Don't Repeat Yourself) 원칙을 어기게된다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;  @Override
  public InputStream decompress(FileKey key, InputStream in) throws IOException {
    String extension = FilenameUtils.getExtension(key.get())
    switch (extension) {
      case &quot;zip&quot;:
        ZipInputStream zipInputStream = new ZipInputStream(in);
        ZipEntry zipEntry = zipInputStream.getNextEntry();
        if (zipEntry == null) {
          throw new IOException(&quot;Empty zip file&quot;);
        }
        return zipInputStream;
    }
    case &quot;zst&quot;:
      return new ZstdInputStream(in);
    case &quot;gz&quot;:
      return new GZIPInputStream(in);
    default:
      return in;
  }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 코드를 그대로 두고, 행위만 추가한다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞선 코드의 문제는 ISP, DRY 원칙을 어긴 것이었다.&lt;br /&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;Decorator 사전 정의를 보면&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;a person whose job is to paint the inside or outside of buildings and to do other related work&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;디자인 패턴에서의 데코레이터는 &quot;어떤 추가적인 행위를 더해주는 역할&quot; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과같이 구현할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) Solution II. Decroator Pattern&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DecompressingFileRepository&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(1) 원본 파일을 가져온다. (기존 동작)&lt;br /&gt;(2) 압축 해제 객체에 의해 압축을 해제하여 반환한다 (추가된 동작)&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class DecompressingFileRepository implements FileRepository {
  private final FileRepository delegate;

  @Override
  public InputStream getFile(FileKey fileKey) throws IOException {
    InputStream inputStream = delegate.getFile(fileKey); // (1) 원본 파일을 기존 행위 그대로 가져온다.
    Decompressor decompressor = DecompressorSelector.select(fileKey);
    return decompressor.decompress(inputStream); // (2) 압축을 해제하여 반환한다.
  }
}


@RequiredArgsConstructor
public enum DecompressorSelector {
  GZIP(new GzipDecompressor()),
  ZIP(new ZipDecompressor()),
  ZSTD(new ZstdDecompressor()),
  NONE(new NoneDecompressor());

  private final Decompressor decompressor;

  public static Decompressor select(FileKey fileKey) {
    for (DecompressorSelector selector : values()) {
      if (selector.decompressor.supports(fileKey)) {
        return selector.decompressor;
      }
    }
    throw new IllegalArgumentException(&quot;No supported decompressor found for file: &quot; + fileKey.get());
  }

}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Class Diagram&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;247&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xaq9t/dJMcacBKjlJ/GfZCVQc5KYRavjUNKZEZT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xaq9t/dJMcacBKjlJ/GfZCVQc5KYRavjUNKZEZT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xaq9t/dJMcacBKjlJ/GfZCVQc5KYRavjUNKZEZT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fxaq9t%2FdJMcacBKjlJ%2FGfZCVQc5KYRavjUNKZEZT1%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;768&quot; height=&quot;247&quot; data-origin-width=&quot;768&quot; data-origin-height=&quot;247&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>JVM/Java</category>
      <category>Decorator Pattern</category>
      <category>Design Pattern</category>
      <category>java</category>
      <category>OOP</category>
      <category>객체지향</category>
      <category>디자인패턴</category>
      <category>자바</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/810</guid>
      <comments>https://m-falcon.tistory.com/810#entry810comment</comments>
      <pubDate>Mon, 29 Dec 2025 14:37:24 +0900</pubDate>
    </item>
    <item>
      <title>[Java] JUnit Inner class 를 정의하는 팁</title>
      <link>https://m-falcon.tistory.com/807</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Purpose&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Inner class 로 &lt;code&gt;static class&lt;/code&gt; 로 선언하는 방식과 &lt;code&gt;class&lt;/code&gt; 로 선언하는 방식의 차이를 안다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;static inner class&lt;/h2&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;class Car {
  public static final Duration fixDuration;

  private final String name;
  static class Painting {
    private final String color;
    public void paint(){
    };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Pros&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outter 인스턴스 생성없이 바로 인스턴스 생성이 가능하다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Painting painting = new Painting(&quot;RED&quot;);
painting.paint();&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Cons&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outter 인스턴스의 내부 변수에 접근 불가능하다.&lt;br /&gt;오로지 static 변수에만 접근 가능하다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;class Car {
  public static final Duration fixDuration;

  private final String name;
  static class Painting {
    private final String color;
    public void paint(){
    // Can not resolved `name` 
      log.info(&quot;paint to the car: {}&quot; + name);
    };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;non-static&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class Car {
  public static final Duration fixDuration;

  private final String name;
  class Painting {
    private final String color;
    public void paint(){
      System.out.println(&quot;Print to the Car: &quot; + name);
    };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Pros&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 Outter class 변수에 접근 가능하다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class Car {
 private final String name;
 . class Painting{
    public void paint(){
      System.out.println(&quot;Print to the Car: &quot; + name);
    };
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Cons&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성시 Outter 인스턴스를 먼저 생성해야한다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;Car car = new Car(&quot;Ferarri&quot;);
Car.Painting painting = car.new Painting()l
painting.paint();&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JUnit Test - &lt;code&gt;@Nested&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JUnit 6 테스트 코드 작성시 한 클래스 내에서 Inner Class 로 테스트 코드를 그룹핑하고 싶을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;JUnit 에서 Inner class 는 non-static 으로 선언하라!&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;Outter class 에 선언된 모든 인스턴스 변수를 Inner class 에서 사용가능하도록 열기 위함이다.&lt;/li&gt;
&lt;li&gt;Static 없이 &lt;code&gt;@Before&lt;/code&gt; , &lt;code&gt;@After&lt;/code&gt; 와 같은 어노테이션 처리도 공유 가능하도록 하기 위함이다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Non Static class 는 Outter class 가 인스턴스화 되지 않으면 생성 불가하다.&lt;br /&gt;즉, 생성 순서가 Outter -&amp;gt; Inner class 로 강제된다.&lt;br /&gt;이 기본 매커니즘에 근거하여&lt;br /&gt;Outter class 에는 공통의 공유 리소스를 초기화해두고, Inner class 는 특정 시나리오만 작성하면된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outter -&amp;gt; Inner class 생성 순서가 강제되어 있지 않다면&lt;br /&gt;Inner class 가 먼저 실행될 때 Outter class 의 공유 리소스는 초기화 전이라 사용 불가능했을 것이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;예제&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class SharedFileTest {
    // 1. THE SHARED RESOURCE: A temporary file managed by the outer class.
    @TempDir
    Path tempDir;

    private Path sharedFile;

    // 2. THE SHARED SETUP FIXTURE (@BeforeEach)
    @BeforeEach
    void setupFile() throws IOException {
        // Create the file path.
        sharedFile = tempDir.resolve(&quot;my-test-file.txt&quot;);
        // Write a default message to it.
        Files.writeString(sharedFile, &quot;Hello from the outer class!&quot;);
    }


    // 3. THE NON-STATIC NESTED CLASS
    @Nested
    class WhenFileIsReady {

        @Test
        @DisplayName(&quot;it should exist&quot;)
        void fileShouldExist() {
            // This test uses the 'sharedFile' that was created in the outer setupFile() method.
            assertThat(Files.exists(sharedFile)).isTrue;
        }

        @Test
        @DisplayName(&quot;it should contain the default content&quot;)
        void fileShouldContainDefaultContent() throws IOException {
            // This test also uses the 'sharedFile' from the outer class instance.
            String content = Files.readString(sharedFile);
            assertThat(content).isEqualTo(&quot;Hello from the outer class!&quot;);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Summary&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Characteristic&lt;/th&gt;
&lt;th&gt;Static Inner class&lt;/th&gt;
&lt;th&gt;Non-Static Inner class&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instantiation&lt;/td&gt;
&lt;td&gt;Outter 인스턴스 필요&lt;/td&gt;
&lt;td&gt;Outter 인스턴스 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access&lt;/td&gt;
&lt;td&gt;Outter 인스턴스 모든 변수 접근 가능&lt;/td&gt;
&lt;td&gt;Outter &lt;code&gt;static&lt;/code&gt; 변수만 접근 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JUnit usage&lt;/td&gt;
&lt;td&gt;인스턴스 레벨의 공유자원 사용, 일반적인 케이스&lt;/td&gt;
&lt;td&gt;공유자원, 상태가 없는 케이스에서만 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description>
      <category>JVM/Java</category>
      <category>java</category>
      <category>JUnit</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/807</guid>
      <comments>https://m-falcon.tistory.com/807#entry807comment</comments>
      <pubDate>Mon, 22 Dec 2025 11:14:58 +0900</pubDate>
    </item>
    <item>
      <title>[Error] TestContainers: Could not find a valid Docker environment. Please see logs and check configuration</title>
      <link>https://m-falcon.tistory.com/805</link>
      <description>&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;OS: MacOS Tahoe 26.1&lt;/li&gt;
&lt;li&gt;Java Version: eclipse-temurin:25&lt;/li&gt;
&lt;li&gt;Docker desktop: v4.52.0&lt;/li&gt;
&lt;li&gt;Testcontainers: &lt;b&gt;v1.21.3&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Docker CLI: v29.0.1&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;build.gradle&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;dependencies{
  testImplementation('org.junit.jupiter:junit-jupiter')
  testImplementation(&quot;org.testcontainers:testcontainers-localstack:2.0.2&quot;)
  testImplementation(&quot;org.testcontainers:testcontainers-junit-jupiter:2.0.2&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LocalStack Container 를 초기화하는 과정에 에러가 발생했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Testcontainers
abstract class S3TestSupport {

  /** static fields will be shared between test methods.
   * Started only once before any test methods are executed and stopped after the last test method has executed.
    */
  @Container
  protected static final LocalStackContainer localStackContainer = new LocalStackContainer(
    DockerImageName.parse(&quot;localstack/localstack:s3-latest&quot;)
    // Define which AWS Service is enabled.
  ).withServices(&quot;s3&quot;);
  // 이하 생략
}&lt;/code&gt;&lt;/pre&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;Could not find a valid Docker environment. Please see logs and check configuration&quot;&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
    at org.testcontainers.dockerclient.DockerClientProviderStrategy.lambda$getFirstValidStrategy$7(DockerClientProviderStrategy.java:274)
    at java.base/java.util.Optional.orElseThrow(Optional.java:403)
    at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:265)
    at org.testcontainers.DockerClientFactory.getOrInitializeStrategy(DockerClientFactory.java:154)
    at org.testcontainers.DockerClientFactory.getRemoteDockerUnixSocketPath(DockerClientFactory.java:165)
    at org.testcontainers.localstack.LocalStackContainer.&amp;lt;init&amp;gt;(LocalStackContainer.java:61)
    at sourceconnector.repository.file.S3TestSupport.&amp;lt;clinit&amp;gt;(S3TestSupport.java:33)
    ... 7 more&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker 29.x 버전과 호환문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/testcontainers/testcontainers-java/issues/11212&quot;&gt;https://github.com/testcontainers/testcontainers-java/issues/11212&lt;/a&gt;&lt;/p&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;Docker CLI 버전을 28.x 이하로 사용한다.&lt;/li&gt;
&lt;li&gt;testcontainers 버전을 &lt;a href=&quot;https://github.com/testcontainers/testcontainers-java/releases/tag/2.0.2&quot;&gt;v2.0.2&lt;/a&gt; 이상으로 업그레이드한다. (추천)&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;dependencies {
  testImplementation(&quot;org.testcontainers:testcontainers:2.0.2&quot;)
  testImplementation(&quot;org.testcontainers:testcontainers-localstack:2.0.2&quot;)
  testImplementation(&quot;org.testcontainers:testcontainers-junit-jupiter:2.0.2&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker CLI v28.x 이하 사용자는 아래 케이스에 해당한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 2&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Testcontainers, docker-cli, 많은 도구들은 기본적으로 &lt;code&gt;/var/run/docker.sock&lt;/code&gt; 파일을 읽어 Docker 데몬과 통신하려고 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ ls -l /var/run/docker.sock
lrwxr-xr-x@ 1 root  daemon  37 Nov 22 11:24 /var/run/docker.sock -&amp;gt; /Users/Falcon/.docker/run/docker.sock&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 실제 Mac 에서 사용하는 파일은 &lt;code&gt;/var/run/docker.sock&lt;/code&gt; 이 아니라 &lt;code&gt;$HOME/.docker/run.docker.sock&lt;/code&gt; 파일이다.&lt;br /&gt;Testcontainers 가 &lt;code&gt;/var/run.docker.sock&lt;/code&gt; 에 접근했으나 실제 소켓은 없어 실패하여 이런 메시지가 발생하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&quot;Could not find a valid Docker environment&quot;&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책 2&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;~/.zshrc&lt;/code&gt; 에 다음 구문을 추가한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Testcontainers 문서에 따르면 Testcontainers 는 다음 순서로 Docker 환경을 찾는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Environment variables:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DOCKER_HOST&lt;/li&gt;
&lt;li&gt;DOCKER_TLS_VERIFY&lt;/li&gt;
&lt;li&gt;DOCKER_CERT_PATH&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이중 &lt;code&gt;DOCKER_HOST&lt;/code&gt; 변수를 실제 Mac 에서 사용하는 값으로 매핑해주는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 포인트는 &lt;code&gt;unix://&lt;/code&gt; 프로토콜 스키마를 반드시 넣어줘야 한다는 것이다.&lt;/p&gt;</description>
      <category>JVM/Error</category>
      <category>docker</category>
      <category>localstack</category>
      <category>testconatiner</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/805</guid>
      <comments>https://m-falcon.tistory.com/805#entry805comment</comments>
      <pubDate>Mon, 24 Nov 2025 00:25:25 +0900</pubDate>
    </item>
    <item>
      <title>[Java] Primitive 타입보다 VO 를 의존하라</title>
      <link>https://m-falcon.tistory.com/804</link>
      <description>&lt;p&gt;Kafka Record 에 들어갈 Record Key, Record Value 를 &lt;code&gt;OffsetRecord&lt;/code&gt; 인터페이스로 추상화했다.&lt;br&gt;&lt;code&gt;OffsetRecord&lt;/code&gt; 는 오로지 오프셋을 저장하는 토픽에 쓰인다.&lt;/p&gt;
&lt;p&gt;예를 들면 이런식이다.&lt;/p&gt;
&lt;h4&gt;Record Key&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;C:\\Users\M_Faclon\path\a.txt&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Record value&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;30&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;/**
 * Domain interface used by OffsetManager and SourceConnector Producer. &amp;lt;br&amp;gt;
 * Consists of object unique identifier and offset. &amp;lt;br&amp;gt;
 * Stored in the Offset topic partition.
 */
public interface OffsetRecord {
  /**
   * The unique key representing the source object &amp;lt;br&amp;gt;
   * e.g., S3 bucket and object key, local file path
   */
  String key();

  /**
   * The offset value &amp;lt;br&amp;gt;
   * Refer to the {@link OffsetStatus} defines special offset
   */
  long offset();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;도메인 값 객체 &lt;code&gt;FileKey&lt;/code&gt; 등장&lt;/h2&gt;
&lt;p&gt;프로젝트 후반에 Record Key 로 사용되는 도메인 값 객체 &lt;code&gt;FileKey&lt;/code&gt; 를 정의했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface FileKey  {
  String get();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;&amp;quot;&lt;code&gt;LocalFileKey&lt;/code&gt;, &lt;code&gt;S3FileKey&lt;/code&gt;, &lt;code&gt;GCSFileKey&lt;/code&gt; 등 다양한 파일 스토리지의 식별자를 인터페이스로 제공한다.&amp;quot;&lt;/em&gt; 는 규칙이 생겼다.&lt;/p&gt;
&lt;h3&gt;값 객체(VO) 사용 이유&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;도메인 객체의 행위 사용 가능&lt;/li&gt;
&lt;li&gt;항상 유효한 도메인 객체가 생성된다는 보장  &lt;br&gt;&lt;code&gt;String&lt;/code&gt; 은 어떤 값이든 입력될 수 있으므로 유효성을 보장하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;quot;유효한 객체만 사용한다는 보장&amp;quot;이 OOP에서 굉장히 중요한 개념이다.&lt;br&gt;예를 들어보자&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class LocalFileKey implements FileKey {
  private final URI fileUri;

  public static LocalFileKey from(Path path) {
    return new LocalFileKey(path.toUri());
  }

  @Override
  public String get() {
    return this.fileUri.toString();
  }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; LocalFileKey 는 항상 유효한 &lt;code&gt;Path&lt;/code&gt; 로부터 생성됨을 보장한다.&lt;br&gt; 즉, String 과 달리 &lt;strong&gt;항상 유효한 값을 갖는다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;더 이상 String 은 유효하지 않다.&lt;/h2&gt;
&lt;p&gt;LocalFileKey 은 다음과 같은 URI 포맷을 강제한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;file:///C:/Users/M_Faclon/path/a.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최초에 정의했던 다음 String key 값은 이제 유효하지 않다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;C:\\Users\M_Faclon\path\a.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 &lt;code&gt;String&lt;/code&gt; 을 사용했던 다음 인터페이스도 모두 고쳐야한다.&lt;br&gt;String 을 사용하면 어떤 값이든 입력될 수 있고 이는 곧 에러 발생으로 이어진다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface OffsetManager {
  Optional&amp;lt;OffsetRecord&amp;gt; findLatestOffsetRecord(String key);
  List&amp;lt;OffsetRecord&amp;gt; findLatestOffsetRecords(List&amp;lt;String&amp;gt; keys);
  void upsert(String key, OffsetRecord offsetRecord);
  void removeKey(String key);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;도메인과 연관된 Public API 는 도메인 객체를 사용해야한다.&lt;/strong&gt;&lt;br&gt;OffsetManager 를 비롯한 API 사용자에게 도메인 객체 사용을 강제하므로써 다음 이점을 얻는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;타입 안전성&lt;/li&gt;
&lt;li&gt;에러 없는 비교 &amp;amp; 정렬&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;개선된 API&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface OffsetRecord {
  FileKey key();
  long offset();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface OffsetManager {
  Optional&amp;lt;OffsetRecord&amp;gt; findLatestOffsetRecord(FileKey key);
  List&amp;lt;OffsetRecord&amp;gt; findLatestOffsetRecords(List&amp;lt;FileKey&amp;gt; keys);
  void upsert(FIleKey key, OffsetRecord offsetRecord);
  void removeKey(FileKey key);
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>JVM/Java</category>
      <category>Domain</category>
      <category>java</category>
      <category>OOP</category>
      <category>vo</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/804</guid>
      <comments>https://m-falcon.tistory.com/804#entry804comment</comments>
      <pubDate>Thu, 20 Nov 2025 11:05:08 +0900</pubDate>
    </item>
    <item>
      <title>[Java] Clean Code - index, array 대신 Iterator</title>
      <link>https://m-falcon.tistory.com/803</link>
      <description>&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class Args {
  private String[] args;
  int currentIndex;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;args 를 하나씩 파싱하는 메소드가 필요하다고 해보자,&lt;br /&gt;인자를 넘길때마다 이런 형식이 된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Object parseArgument(String[] args, int index);&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;인자 수는 적으면 적을수록 좋다. 인자가 많을수록 복잡하다.&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;- 클린코드&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Args&lt;/code&gt; 클래스를 Iterator 로 리팩터링해보자.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;class Args {
  List&amp;lt;String&amp;gt; args;
  Iterator&amp;lt;String&amp;gt; currentElement;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 args 속 원소를 하나씩 넘길 수 있다.&lt;br /&gt;그저 currentElement 만 넘겨주면 된다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;Object parseArgument(Iterator&amp;lt;String&amp;gt; element);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>JVM/Java</category>
      <category>CleanCode</category>
      <category>java</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/803</guid>
      <comments>https://m-falcon.tistory.com/803#entry803comment</comments>
      <pubDate>Wed, 19 Nov 2025 09:05:10 +0900</pubDate>
    </item>
    <item>
      <title>Clean Code - 테스트는 어디서든 반복 실행 가능해야한다</title>
      <link>https://m-falcon.tistory.com/802</link>
      <description>&lt;p&gt;클린코드 9장에 보면 깨끗한 테스트 코드 원칙으로 FIRST 원칙을 소개한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;F: Fast&lt;/li&gt;
&lt;li&gt;I: Independent&lt;/li&gt;
&lt;li&gt;R: Repeatable&lt;/li&gt;
&lt;li&gt;S: Self-Validated&lt;/li&gt;
&lt;li&gt;T: Timely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 중 Repeatable 원칙을 지키지 못하고 있는 실제 사례를 소개하고 어떻게 Reatable 원칙을 지키는 코드로 변모하는지 과정을 공유해보겠다.&lt;/p&gt;
&lt;h2&gt;Repeatble 원칙&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;테스트는 어떤 환경에서도 반복 가능해야한다.&lt;/strong&gt;&lt;br&gt;그렇지 않으면 테스트를 실행시키지 않을 변명이 생긴다. 게다가 환경이 지원되지 않기에 실제 테스트를 수행하지 못하는 상황에 직면한다.&lt;/p&gt;
&lt;h2&gt;환경에 의존적인 테스트 코드&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;class S3FileRepositoryTest {

  @DisplayName(&amp;quot;Should return when S3 file object path is given&amp;quot;)
  @Test
  void getFile() throws IOException {
    // given
    FileRepository s3FileRepository = new S3FileRepository(Region.AP_NORTHEAST_2, &amp;quot;test-bucket&amp;quot;);
    // when
    InputStream inputStream = s3FileRepository.getFile(&amp;quot;path/to/file.txt&amp;quot;);
    // then
    assertThat(inputStream).isNotNull();
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 테스트 코드는 AWS S3 버킷 (&amp;quot;my-bucket&amp;quot;), 리전, 그리고 파일 (&amp;quot;path/to/file.txt&amp;quot;) 에 의존적이다.&lt;/p&gt;
&lt;p&gt;다음과 같은 제반 조건이 필요하다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;s3://my-bucket/path/to/file.txt&lt;/code&gt; 경로에 실제 파일이 업로드 되어있어야한다.&lt;/li&gt;
&lt;li&gt;테스트 실행 환경에서 AWS S3 접근 권한이 있어야한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이런 조건을 우회하여 &lt;strong&gt;어디서든 반복 실행 가능한 테스트를 만들 수 없을까?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Testcontainers: 외부 저장소를 컨테이너로!&lt;/h2&gt;
&lt;p&gt;복잡한 외부 인프라 설정 없이 코드로 테스트에 필요한 의존성을 정의한다.&lt;br&gt;코드로 정의한 의존성에 따라 컨테이너가 자동 생성/제거되며 테스트 환경을 경량화해준다.&lt;br&gt;사용자는 오로지 Docker 만 있으면된다.&lt;/p&gt;
&lt;p&gt;특징1) 테스트 시작시 Container 가 생성되고, 종료시 Container 또한 종료된다.&lt;br&gt;-&amp;gt; 삽입한 데이터 삭제 등의 Cleans 코드가 필요하지 않다. 테스트마다 새 Container 에서 테스트하므로 환경이 격리된다.&lt;/p&gt;
&lt;p&gt;특징2) 외부 인프라를 테스트 코드 내에서 정의한다.&lt;br&gt;-&amp;gt; Docker Compose, 개발환경 DB 연결 설정 파일 등이 필요없다.&lt;/p&gt;
&lt;p&gt;특징3) 테스트하고자 하는 외부 인프라와 99% 같은 동작을 한다.&lt;br&gt;-&amp;gt; Mocking 의 약점인 실제 API 와의 괴리를 줄여준다.&lt;br&gt;로컬 환경에서 외부 의존성을 갖는 API 를 호출하여 동작을 테스트할 수 있다.&lt;/p&gt;
&lt;h2&gt;LocalStack: 클라우드 서비스를 컨테이너로!&lt;/h2&gt;
&lt;p&gt;LocalStack 은 외부 저장소 중 AWS Cloud 에서 제공하는 서비스 (S3, SQS)등의 환경을 제공한다.&lt;/p&gt;
&lt;p&gt;Testcontainers + LocalStack 을 함께 사용하면&lt;br&gt;코드만으로 로컬 환경에서 외부 저장소 의존성 없는 테스트를 할 수 있다.&lt;/p&gt;
&lt;h2&gt;설정 방법&lt;/h2&gt;
&lt;h3&gt;(1) 의존성 설정&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;build.gradle&lt;/code&gt; 에 다음 의존성을 추가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;dependencies {
  testImplementation(&amp;quot;org.testcontainers:testcontainers:2.0.2&amp;quot;) // testcontainer core 버전
  testImplementation(&amp;quot;org.testcontainers:testcontainers-localstack:2.0.2&amp;quot;) 
  testImplementation(&amp;quot;org.testcontainers:testcontainers-junit-jupiter:2.0.2&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;(2) Helper 테스트 클래스 작성&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;s3Client&lt;/code&gt; 변수 하나를 하위 모든 클래스에서 돌려쓴다.&lt;br&gt;실제 AWS S3 버킷에 접근하지 않고, LocalStack 에 구성된 S3을 사용하게된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Testcontainers
abstract class S3TestSupport {
  protected final String BUCKET_NAME = &amp;quot;test-bucket&amp;quot;;
  protected final Region REGION = Region.AP_NORTHEAST_2;
  protected S3Client s3Client;

  /** static fields will be shared between test methods.
   * Started only once before any test methods are executed and stopped after the last test method has executed.
    */
  @Container
  protected static final LocalStackContainer localStackContainer = new LocalStackContainer(
    DockerImageName.parse(&amp;quot;localstack/localstack:s3-latest&amp;quot;)
    // Define which AWS Service is enabled.
  ).withServices(&amp;quot;s3&amp;quot;);

  @BeforeAll
  protected void initS3Client() {
    URI s3Endpoint = localStackContainer.getEndpoint();

    s3Client = S3Client.builder()
      // Redirect endpoint to that localstack container provides
      .endpointOverride(s3Endpoint)
      .credentialsProvider(StaticCredentialsProvider.create(
        AwsBasicCredentials.create(&amp;quot;test-access-key&amp;quot;, &amp;quot;test-secret-key&amp;quot;)
      ))
      .region(REGION)
      .build();

    // TODO: why not throw BucketAlreadyExistsException?
    s3Client.createBucket(CreateBucketRequest.builder()
      .bucket(BUCKET_NAME)
      .build());
  }

  @AfterAll
  protected void cleanS3Client() {
    if (s3Client != null) {
      s3Client.close();
    }
  }

  public void upload(String key, String content) {
    s3Client.putObject(
      builder -&amp;gt; builder.bucket(BUCKET_NAME).key(key).build(),
      RequestBody.fromString(content)
    );
  }

  public void upload(S3Location s3Location, Path path) {
    PutObjectResponse response = s3Client.putObject(
      builder -&amp;gt; builder
        .bucket(s3Location.bucket())
        .key(s3Location.key())
        .build(),
      RequestBody.fromFile(path)
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;변경된 테스트 코드&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class S3FileRepositoryTest extends S3TestSupport {

  @DisplayName(&amp;quot;Should get InputStream when the file exists&amp;quot;)
  @Test
  void getFileTest() {
    // given
    Path localFilePath = Path.of(&amp;quot;src/test/resources/sample-data/empty.ndjson&amp;quot;);
    S3Location s3Location = new S3Location(BUCKET_NAME, &amp;quot;sample-data/empty.ndjson&amp;quot;);
    this.upload(s3Location, localFilePath);  // localStack S3 에 파일 업로드

    S3FileRepository fileRepository = new S3FileRepository(s3Client);

    // when
    InputStream inputStream = fileRepository.getFile(s3Location.toFileKey());

    // then
    assertThat(inputStream).isNotNull();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;S3TestSupport&lt;/code&gt; 클래스에서 초기화한 LocalStack S3 에 연결하는 클라이언트로 초기화했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;    S3FileRepository fileRepository = new S3FileRepository(s3Client);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;S3TestSupport 클래스가 추가된 것 외에 테스트 코드 변경은 거의 없다.&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;Testcontainers + localstack 사용으로 다음과 같은 이점을 얻었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;외부 의존성을 코드로 정의한다.&lt;/li&gt;
&lt;li&gt;실제 외부 서비스 접근 없이 로컬 환경에서 테스트를 실행할 수 있다.&lt;/li&gt;
&lt;li&gt;외부 의존성에 대한 Configuration, 권한 부여 등의 과정을 생략했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://java.testcontainers.org/test_framework_integration/junit_5/&quot;&gt;https://java.testcontainers.org/test_framework_integration/junit_5/&lt;/a&gt;&lt;br&gt;&lt;a href=&quot;https://testcontainers.com/guides/testing-aws-service-integrations-using-localstack/&quot;&gt;https://testcontainers.com/guides/testing-aws-service-integrations-using-localstack/&lt;/a&gt;&lt;/p&gt;</description>
      <category>기타/잡동사니</category>
      <category>TDD</category>
      <category>TEST</category>
      <category>클린코드</category>
      <category>테스트</category>
      <author>M_Falcon</author>
      <guid isPermaLink="true">https://m-falcon.tistory.com/802</guid>
      <comments>https://m-falcon.tistory.com/802#entry802comment</comments>
      <pubDate>Fri, 7 Nov 2025 12:53:22 +0900</pubDate>
    </item>
  </channel>
</rss>