<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Frank's IT Blog</title>
    <link>https://blog.advenoh.pe.kr/en</link>
    <description>개발자를 위한 기술 블로그</description>
    <language>en</language>
    <lastBuildDate>Wed, 03 Jun 2026 15:12:44 GMT</lastBuildDate>
    <atom:link href="https://blog.advenoh.pe.kr/en/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Getting Started with React Native Using Expo: Building a Todo App</title>
      <link>https://blog.advenoh.pe.kr/en/expo로-시작하는-react-native-앱-개발</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/expo로-시작하는-react-native-앱-개발</guid>
      <pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate>
      <description>Set up a React Native development environment with Expo and build a simple Todo app from scratch.</description>
      <content:encoded><![CDATA[<h1>1. Introduction</h1>
<p>Have you ever built a web app with React but felt lost about where to even start with a mobile app? With React Native, you can build iOS/Android apps using the same React syntax you already know. But when you actually try to get started, you run into barriers like Xcode, Android Studio, and native build configuration.</p>
<p>The thing that almost completely removes these barriers is <strong>Expo</strong>. In this article, we'll set up a development environment with Expo and build a simple <strong>Todo app</strong> from start to finish. The features we'll build are:</p>
<ul>
<li>Add a task / toggle completion / delete</li>
<li>Persist data so the list survives even after closing and reopening the app</li>
</ul>
<p>This article targets readers familiar with React (components, JSX, <code>useState</code>), and focuses on <strong>"how to develop with Expo"</strong> rather than React syntax itself. You can find the complete code in the <a href="https://github.com/kenshin579/tutorials-go/tree/master/web/expo-todo-app">GitHub repository</a>.</p>
<blockquote>
<p>The environment is based on macOS, and assumes the <a href="https://nodejs.org/">Node.js</a> LTS version is installed.</p>
</blockquote>
<h1>2. What Is Expo? Why Expo?</h1>
<p><strong>React Native</strong> is a framework that renders code written in JavaScript/TypeScript as actual native UI. In other words, it produces a real native app, not an app faked with a webview.</p>
<p>The problem is that to start with pure React Native, you have to install Xcode and Android Studio and configure the native build environment yourself. For beginners, this initial setup alone is the biggest wall.</p>
<p><strong>Expo</strong> is a collection of tools and services layered on top of React Native that dramatically lowers this wall.</p>
<ul>
<li><strong>Start without a native build environment</strong>: you can create a project and run it immediately with a single command</li>
<li><strong>Expo Go</strong>: if you install the Expo Go app from the App Store/Play Store, you can scan a QR code and instantly launch your app on your phone. No separate build needed</li>
<li><strong>Rich SDK</strong>: you can use native features like camera, location, notifications, and local storage just by installing a package</li>
<li><strong>EAS Build</strong>: later, when you want to build a real app and publish it to the stores, you can use the cloud build service</li>
</ul>
<p>The relationship between your code and the runtime environment looks like this in a diagram.</p>
<pre><code class="language-mermaid">flowchart TD
    A["My code (TypeScript / JSX)"] --> B["Expo (CLI · SDK · Expo Go)"]
    B --> C["React Native"]
    C --> D["iOS native"]
    C --> E["Android native"]
</code></pre>
<p>We write code in <code>App.tsx</code>, and Expo connects it to React Native and renders it as each platform's native screen.</p>
<h1>3. Getting Started: From Project Creation to Running</h1>
<p>First, check whether Node is installed.</p>
<pre><code class="language-bash">node -v
</code></pre>
<p>Now create a project with <code>create-expo-app</code>. Here we use the <strong>blank TypeScript template</strong>.</p>
<pre><code class="language-bash">npx create-expo-app@latest my-todo-app --template blank-typescript
</code></pre>
<blockquote>
<p>Expo's default template includes file-based routing (Expo Router) and tab navigation, which is a bit heavy for beginners. So we chose the <code>blank-typescript</code> template that starts with a single empty screen. We'll only touch on Expo Router as a keyword in Section 7.</p>
</blockquote>
<p>Once creation is done, move into the project folder and start the development server.</p>
<pre><code class="language-bash">cd my-todo-app
npx expo start
</code></pre>
<p>A QR code and a run-options menu appear in the terminal. Pick the one that fits your situation.</p>
<ul>
<li><strong>Expo Go (easiest)</strong>: install the <a href="https://expo.dev/go">Expo Go</a> app on your phone and scan the QR code in the terminal</li>
<li><code>i</code>: run on the iOS simulator (requires macOS + Xcode)</li>
<li><code>a</code>: run on the Android emulator (requires Android Studio)</li>
<li><code>w</code>: run in a web browser</li>
</ul>
<p>When it runs, you'll see the default screen that says "Open up App.tsx to start working on your app!". If you've made it this far, your development environment is ready.</p>
<p>The terminal displays a QR code and a key-shortcut menu similar to the following. Seeing this screen means the development server has started successfully.</p>
<p><img src="https://blog.advenoh.pe.kr/images/web/expo로-시작하는-react-native-앱-개발/expo-start-qr.png" alt="QR code shown in the terminal when running npx expo start (example image — in reality your own LAN address is encoded)"></p>
<p>Below the QR code, the following guidance and shortcut menu are also shown.</p>
<pre><code class="language-text">› Metro waiting on exp://192.168.0.10:8081
› Scan the QR code above with Expo Go (Android) or the Camera app (iOS)

› Press s │ switch to development build
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web

› Press r │ reload app
› Press m │ toggle menu
› Press ? │ show all commands
</code></pre>
<p>The <code>exp://...</code> address is the location where the Metro bundler is running, and your phone's <a href="https://expo.dev/go">Expo Go</a> app connects to this address to download and run the app bundle. On iOS, scan the QR above with the default Camera app; on Android, scan it with the scanner inside Expo Go, and the app launches on your phone right away. For the simulator/emulator/web, press the <code>i</code>/<code>a</code>/<code>w</code> keys respectively.</p>
<h2>What If You Don't Have an iOS Simulator?</h2>
<p>The iOS simulator only works with <strong>macOS + Xcode</strong>. If you don't have Xcode, use Windows/Linux, or find the installation burdensome, you can run the app perfectly fine without a simulator.</p>
<ul>
<li><strong>Real device + Expo Go (most recommended)</strong>: install the Expo Go app on your phone and scan the QR code from <code>npx expo start</code>. On iOS, scan with the default Camera app; on Android, scan with the scanner inside Expo Go. Your <strong>PC and phone must be on the same Wi-Fi</strong>, and if a corporate network or similar prevents the connection, you can work around it with <code>npx expo start --tunnel</code></li>
<li><strong>Web browser</strong>: with <code>npx expo start --web</code> (or the <code>w</code> key), you can check it immediately without any installation. The Todo app we'll build also works on the web, where AsyncStorage is handled as the browser's <code>localStorage</code>, so the list persists even after a refresh. There may, however, be subtle differences from a real mobile device</li>
<li><strong>Android emulator</strong>: if you have Android Studio, you can create an AVD and run it with the <code>a</code> key</li>
</ul>
<p>If you really want to use the iOS simulator, install Xcode from the Mac App Store, then download the iOS simulator runtime from <strong>Xcode → Settings → Components (or Platforms)</strong> and press the <code>i</code> key in <code>npx expo start</code>.</p>
<blockquote>
<p>If you just want to quickly check the screen, use <strong>web</strong>; if you need a real mobile feel, <strong>a real device + Expo Go</strong> is recommended. Installing the iOS simulator is optional.</p>
</blockquote>
<h1>4. Looking at the Project Structure</h1>
<p>Let's look at only the core files of the generated project.</p>
<pre><code>my-todo-app/
├── App.tsx          # The app's entry point. Where the screen is drawn
├── app.json         # Expo settings such as app name, icon, splash
├── package.json     # List of dependencies
├── tsconfig.json    # TypeScript settings (extends expo/tsconfig.base)
└── assets/          # Resources such as icons and splash images
</code></pre>
<p>The one place where we'll spend almost all our time is the single file <strong><code>App.tsx</code></strong>. Like the web's <code>index.html</code> or a root component, this component is the first thing drawn on the screen when you open the app.</p>
<p>One more thing: Expo supports <strong>Fast Refresh</strong>. When you edit and save <code>App.tsx</code>, the changes are immediately reflected on the screen without needing to restart the app. Think of it as the same experience as HMR (Hot Module Replacement) in web development.</p>
<p>Now let's build the Todo app in earnest.</p>
<h1>5. Building the Todo App</h1>
<p>Now we implement the Todo app in <code>App.tsx</code>. The screen composition and state management are almost the same as the React we know, so here we'll point out only <strong>the parts that differ from the web and the parts that are Expo-specific</strong>. You can find the full code on <a href="https://github.com/kenshin579/tutorials-go/tree/master/web/expo-todo-app">GitHub</a>.</p>
<p>In React Native, you use dedicated components instead of web tags.</p>
<ul>
<li><code>View</code> / <code>Text</code>: corresponds to <code>&#x3C;div></code> / <code>&#x3C;p></code>. All text must go inside a <code>Text</code></li>
<li><code>TextInput</code>: an input field. It receives values via <code>onChangeText</code>, not <code>onChange</code></li>
<li><code>FlatList</code>: list rendering. A virtualized list that only draws the items visible on screen</li>
<li><code>TouchableOpacity</code>: a touch area that acts as a button</li>
<li>Styles are written not in a CSS file but as objects created with <code>StyleSheet.create</code> (properties are camelCase, and Flexbox is the default for layout)</li>
</ul>
<p>The state and core logic (add/complete/delete) are ordinary React code.</p>
<pre><code class="language-tsx">type Todo = { id: string; text: string; done: boolean };

const [todos, setTodos] = useState&#x3C;Todo[]>([]);
const [text, setText] = useState('');

const addTodo = () => {
  const trimmed = text.trim();
  if (!trimmed) return;
  setTodos((prev) => [
    { id: Date.now().toString(), text: trimmed, done: false },
    ...prev,
  ]);
  setText('');
};

const toggleTodo = (id: string) => {
  setTodos((prev) =>
    prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
  );
};

const deleteTodo = (id: string) => {
  setTodos((prev) => prev.filter((t) => t.id !== id));
};
</code></pre>
<p>The screen consists of an input field (<code>TextInput</code> + an add button) and a list (<code>FlatList</code>); tapping an item toggles completion, and pressing "Delete" removes it. Since the full JSX and styles (<code>StyleSheet</code>) are lengthy, refer to the <a href="https://github.com/kenshin579/tutorials-go/tree/master/web/expo-todo-app">GitHub source</a>. At this point, you have a Todo app that works in memory.</p>
<table>
<thead>
<tr>
<th>Web</th>
<th>App (Expo Go)</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<p>The result after adding 4 todos and marking 2 of them complete (✅, strikethrough) — it works identically in the Web and the Expo Go app.</p>
<h2>Persisting Data (AsyncStorage)</h2>
<p>Right now, the app loses all its tasks when you close and reopen it. That's because all state lives only in memory. To save data on the device, we use <strong>AsyncStorage</strong> (an asynchronous key-value local storage).</p>
<p>Here is where Expo's real strength shows. When adding a native module, you use <strong><code>npx expo install</code></strong> instead of <code>npm install</code>.</p>
<pre><code class="language-bash">npx expo install @react-native-async-storage/async-storage
</code></pre>
<p><code>expo install</code> automatically picks and installs a version <strong>compatible</strong> with the current project's Expo SDK version. Native modules are sensitive to compatibility depending on the SDK version, and Expo takes care of matching this for you. On top of that, you don't need to touch native code directly or eject the project.</p>
<p>Now, with <code>useEffect</code>, we (1) load the saved list when the app starts and (2) save it whenever the list changes.</p>
<pre><code class="language-tsx">const STORAGE_KEY = '@expo_todo_app/todos';

const [loaded, setLoaded] = useState(false);

// Load saved todos when the app starts
useEffect(() => {
  (async () => {
    const raw = await AsyncStorage.getItem(STORAGE_KEY);
    if (raw) setTodos(JSON.parse(raw));
    setLoaded(true);
  })();
}, []);

// Save whenever todos change (after the initial load completes)
useEffect(() => {
  if (!loaded) return;
  AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos, loaded]);
</code></pre>
<p>The reason for the <code>loaded</code> flag is to prevent the save effect from running before the initial load finishes and overwriting the existing data with an empty array. Now, even after adding items, fully quitting the app, and reopening it, the list is preserved as is.</p>
<h1>6. Expo's Limitations and Considerations</h1>
<p>Expo makes getting started easy, but it isn't a silver bullet. There are some limitations worth knowing before applying it in real-world projects.</p>
<ul>
<li><strong>In Expo Go, you can only use the native modules included in the SDK.</strong> Arbitrary third-party native libraries (e.g., a specific payment SDK or a Bluetooth library) won't work directly in the Expo Go app. In that case, you need to create a <strong>development build</strong> (a development app you build yourself). In other words, the convenience of "scan a QR with Expo Go and run instantly" only holds within the scope of pure JS and built-in modules</li>
<li><strong>Reflecting the latest native features can be slow.</strong> A new OS feature or a particular native SDK may take time to be included in the Expo SDK, or you may have to write a <strong>config plugin</strong> yourself</li>
<li><strong>Versions are tied to the Expo SDK.</strong> It's hard to freely bump the versions of React Native or some libraries, and you have to follow the Expo SDK upgrade cycle. The fact that <code>expo install</code> enforces compatible versions is a convenience and a constraint at the same time</li>
<li><strong>Build and deployment are ultimately the native domain.</strong> To publish to the stores, you need EAS Build (cloud build, with a free quota) or a local native build. Development may be easy, but at the deployment stage you face the reality of native builds</li>
<li><strong>App size and low-level control.</strong> Because the Expo runtime is included, the binary size can be somewhat larger, and there can be constraints for apps that require fine-grained native handling, such as high-performance graphics or special hardware control</li>
</ul>
<blockquote>
<p>For reference, the old concept of "eject" (detaching Expo to switch to a pure native project) has now been replaced by <strong>prebuild</strong> and development builds. It's now common to add the native code you need while keeping Expo, so the old worry that "if you start with Expo, you'll get stuck later" has largely been resolved.</p>
</blockquote>
<p>In summary, <strong>Expo is enough for most typical apps</strong>, and the limitations above mainly apply when you need deep native customization.</p>
<h1>7. Conclusion</h1>
<p>To summarize the flow of getting started with a React Native app using Expo:</p>
<ol>
<li>Create a project with <code>create-expo-app</code></li>
<li>Run instantly on Expo Go or the simulator with <code>expo start</code></li>
<li>Write the screen and logic in <code>App.tsx</code> (check immediately with Fast Refresh)</li>
<li>Compose the UI with RN components like <code>View</code>/<code>Text</code>/<code>TextInput</code>/<code>FlatList</code></li>
<li>Easily add native modules (AsyncStorage) with <code>expo install</code></li>
</ol>
<p>The core appeal of Expo is being able to build a working app with React knowledge alone, without complex native configuration.</p>
<p>Good topics to explore as next steps are:</p>
<ul>
<li><strong>Expo Router</strong>: compose multiple screens and tab navigation with file-based routing</li>
<li><strong>EAS Build</strong>: build the app in the cloud and publish it to the App Store/Play Store</li>
<li>Make use of various <strong>Expo SDKs</strong> (camera, location, notifications, etc.)</li>
</ul>
<h1>8. References</h1>
<ul>
<li><a href="https://docs.expo.dev/">Expo Official Docs</a></li>
<li><a href="https://docs.expo.dev/more/create-expo/">create-expo-app Docs</a></li>
<li><a href="https://react-native-async-storage.github.io/async-storage/">AsyncStorage Docs</a></li>
<li><a href="https://github.com/kenshin579/tutorials-go/tree/master/web/expo-todo-app">Full Source Code (GitHub)</a></li>
</ul>]]></content:encoded>
      <category>web</category>
      <category>expo</category>
      <category>react-native</category>
      <category>react</category>
      <category>mobile</category>
      <category>typescript</category>
      <category>todo</category>
      <category>모바일</category>
      <category>앱개발</category>
      <category>프론트엔드</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Getting Started with tmux: From Terminal Session Management to Using It with Claude Code</title>
      <link>https://blog.advenoh.pe.kr/en/tmux-입문-터미널-세션-관리부터-claude-code-활용까지</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/tmux-입문-터미널-세션-관리부터-claude-code-활용까지</guid>
      <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
      <description>A beginner-friendly guide to the terminal multiplexer tmux, covering its core concepts, how to use Sessions, Windows, and Panes, a minimal config, and how to use it alongside Claude Code sessions.</description>
      <content:encoded><![CDATA[<h1>1. Introduction</h1>
<p>Back when I worked with Linux servers a lot, I couldn't imagine getting things done without <code>tmux</code>. Then I moved to mostly local development and forgot about it for a while, but lately, using terminal-based AI coding tools like Claude Code, I've started reaching for it again. So I figured I'd take this chance to lay out tmux from scratch, from a beginner's perspective.</p>
<p>If any of these sound familiar, tmux might be the answer.</p>
<ul>
<li>You have 10 terminal tabs open and have no idea what's where</li>
<li>Your SSH connection dropped while working on a remote server, and the build or script that was running got wiped out entirely</li>
<li>You want to see code editing and logs on one screen, but you keep switching back and forth between windows</li>
</ul>
<p>This guide covers installation on macOS, walks through the concepts, basic usage, and a minimal config in order, and finishes by introducing how to use it alongside a Claude Code Session. You can just follow along from top to bottom.</p>
<h1>2. What is tmux?</h1>
<p>tmux is short for <strong>t</strong>erminal <strong>mux</strong>(multiplexer), i.e. "terminal multiplexer." It's a tool that lets you handle multiple workspaces within a single terminal window and keep those workspaces running in the background.</p>
<p>There are two core values a beginner should remember.</p>
<p><strong>① Session persistence</strong> — A tmux Session stays alive in the background even if you close the terminal window or your SSH connection drops. Later, all you have to do is attach again, and your work picks up as if it was never interrupted. The "SSH dropped and my build got wiped out" problem I mentioned earlier disappears right here.</p>
<p><strong>② Screen splitting</strong> — You can divide a single screen into multiple Windows and Panes to view several tasks at once. You can keep an editor, logs, and a dev server all in view while you work.</p>
<p>To sum it up in one line, tmux is <strong>a workspace that doesn't disappear when you close it and that you can freely divide with partitions</strong>.</p>
<h1>3. Installation (macOS)</h1>
<p>On macOS, it's a one-liner with Homebrew.</p>
<pre><code class="language-bash">brew install tmux
</code></pre>
<p>Once installation finishes, check the version.</p>
<pre><code class="language-bash">tmux -V
# tmux 3.5a  (example)
</code></pre>
<p>If a version string is printed, you're ready to go. Now, type <code>tmux</code> in your terminal and your first Session begins.</p>
<h1>4. Core Concepts: Session, Window, Pane</h1>
<p>To use tmux, you need to know three units. They form a containment hierarchy in the order <strong>Session > Window > Pane</strong>.</p>
<ul>
<li><strong>Session</strong>: The top-level workspace unit in tmux. Typically you use one Session per project or task. The detach/attach mentioned earlier targets exactly this Session</li>
<li><strong>Window</strong>: Think of it as a "tab" inside a Session. It takes up the whole screen, and you switch between multiple Windows</li>
<li><strong>Pane</strong>: A split screen within a Window. Multiple Panes are visible at once inside a single Window</li>
</ul>
<p>To put it together, a single Session contains several Windows, and a single Window contains several Panes. By analogy with a browser, a Window is like a "tab," and a Pane is like a "split screen" that divides one tab left/right or top/bottom.</p>
<p>In practice, if you split one Window into three Panes, it looks like the image below. One Pane on top and two Panes on the bottom are all visible on one screen at once, and the bottom line is the status bar that shows the Window list and status.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/tmux-입문-터미널-세션-관리부터-claude-code-활용까지/tmux-panes.png" alt="A screen with one tmux Window split into three Panes"></p>
<h1>5. Basic Usage</h1>
<p>Almost every tmux shortcut works by <strong>pressing <code>Ctrl+b</code> first</strong> and then pressing the next key. This <code>Ctrl+b</code> is called the <strong>prefix key</strong>. For example, <code>Ctrl+b c</code> means "press <code>Ctrl+b</code>, release it, then press <code>c</code>." Just remember that you press them in sequence, not at the same time. (This prefix key can be changed to another key, which is covered in Section 6.)</p>
<h2>5.1 Session</h2>
<p>A Session is the unit that holds the entire workspace. Typically you create one Session per project, detach when you're done to step out, and attach again later to continue. The three you'll use most often are these.</p>
<ul>
<li>Start a new Session (with a name): <code>tmux new -s my-project</code></li>
<li>Step out of a Session (detach): <code>Ctrl+b d</code> (the Session stays alive)</li>
<li>Attach to a Session again: <code>tmux attach -t my-project</code></li>
</ul>
<p>Even after you detach, the work inside the Session keeps running in the background. This detach/attach is the heart of tmux. The rest of the Session-related commands are as follows.</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Command / Shortcut</th>
</tr>
</thead>
<tbody>
<tr>
<td>Start a new Session</td>
<td><code>tmux new -s name</code></td>
</tr>
<tr>
<td>detach (step out)</td>
<td><code>Ctrl+b d</code></td>
</tr>
<tr>
<td>attach (re-attach)</td>
<td><code>tmux attach -t name</code></td>
</tr>
<tr>
<td>Attach to the most recent Session</td>
<td><code>tmux a</code></td>
</tr>
<tr>
<td>View the Session list</td>
<td><code>tmux ls</code></td>
</tr>
<tr>
<td>Pick and switch from the Session list</td>
<td><code>Ctrl+b s</code></td>
</tr>
<tr>
<td>Rename a Session</td>
<td><code>Ctrl+b $</code></td>
</tr>
<tr>
<td>Kill a Session</td>
<td><code>tmux kill-session -t name</code></td>
</tr>
</tbody>
</table>
<h2>5.2 Window</h2>
<p>A Window is a "tab" inside a Session. If you split Windows by the nature of the task (e.g. one for editing, one for the server), it's convenient to switch between them. The ones you'll use most are creating a new Window and moving between them.</p>
<ul>
<li>New Window: <code>Ctrl+b c</code></li>
<li>Next / previous Window: <code>Ctrl+b n</code> / <code>Ctrl+b p</code></li>
<li>Jump by number: <code>Ctrl+b 0</code> ~ <code>Ctrl+b 9</code></li>
</ul>
<p>The rest of the Window-related shortcuts are as follows.</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Shortcut</th>
</tr>
</thead>
<tbody>
<tr>
<td>New Window</td>
<td><code>Ctrl+b c</code></td>
</tr>
<tr>
<td>Next / previous Window</td>
<td><code>Ctrl+b n</code> / <code>Ctrl+b p</code></td>
</tr>
<tr>
<td>Jump by number</td>
<td><code>Ctrl+b 0</code> ~ <code>Ctrl+b 9</code></td>
</tr>
<tr>
<td>Toggle to the last viewed Window</td>
<td><code>Ctrl+b l</code></td>
</tr>
<tr>
<td>Pick and move from the Window list</td>
<td><code>Ctrl+b w</code></td>
</tr>
<tr>
<td>Rename a Window</td>
<td><code>Ctrl+b ,</code></td>
</tr>
<tr>
<td>Close the current Window</td>
<td><code>Ctrl+b &#x26;</code> (confirm with y)</td>
</tr>
</tbody>
</table>
<h2>5.3 Pane</h2>
<p>A Pane is a split screen that divides a Window left/right or top/bottom. You use it when you want to see code, logs, and server output at the same time on one screen. The ones you'll use most are splitting and moving.</p>
<ul>
<li>Split left/right: <code>Ctrl+b %</code></li>
<li>Split top/bottom: <code>Ctrl+b "</code></li>
<li>Move between Panes: <code>Ctrl+b arrow key</code></li>
</ul>
<p>The rest of the Pane-related shortcuts are as follows.</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Shortcut</th>
</tr>
</thead>
<tbody>
<tr>
<td>Split left/right</td>
<td><code>Ctrl+b %</code></td>
</tr>
<tr>
<td>Split top/bottom</td>
<td><code>Ctrl+b "</code></td>
</tr>
<tr>
<td>Move between Panes</td>
<td><code>Ctrl+b arrow key</code></td>
</tr>
<tr>
<td>Cycle to the next Pane</td>
<td><code>Ctrl+b o</code></td>
</tr>
<tr>
<td>Resize a Pane</td>
<td><code>Ctrl+b Ctrl+arrow key</code></td>
</tr>
<tr>
<td>Toggle fullscreen for the current Pane (zoom)</td>
<td><code>Ctrl+b z</code></td>
</tr>
<tr>
<td>Show Pane numbers, then select</td>
<td><code>Ctrl+b q</code></td>
</tr>
<tr>
<td>Break the current Pane into a new Window</td>
<td><code>Ctrl+b !</code></td>
</tr>
<tr>
<td>Close the current Pane</td>
<td><code>Ctrl+b x</code> (confirm with y)</td>
</tr>
</tbody>
</table>
<h1>6. Minimal .tmux.conf Config</h1>
<p>There's no need to deck it out lavishly from the start. Let's put just four things that make a big difference for beginners into <code>~/.tmux.conf</code>.</p>
<pre><code class="language-bash"># 1) Change prefix to Ctrl+a (if Ctrl+b doesn't feel right)
unbind C-b
set -g prefix C-a
bind C-a send-prefix

# 2) Enable mouse for Pane selection/resizing/scrolling
set -g mouse on

# 3) Make split shortcuts intuitive ( | left/right, - top/bottom )
bind | split-window -h
bind - split-window -v

# 4) Config reload shortcut (prefix r)
bind r source-file ~/.tmux.conf \; display "Reloaded!"
</code></pre>
<p>After saving, to apply it, press <code>Ctrl+b r</code> inside tmux. There's also the option of restarting with <code>tmux kill-server</code>, but be careful—this <strong>terminates every running Session</strong>. Usually the <code>Ctrl+b r</code> reload is enough.</p>
<p>For reference, changing the prefix to <code>Ctrl+a</code> in setting #1 is purely a matter of taste, an <strong>optional</strong> choice. Changing it makes it differ from the default prefix, which can be confusing. Just remember that all the shortcuts used in the rest of this article are based on the <strong>default prefix <code>Ctrl+b</code></strong>.</p>
<h1>7. Using It with Claude Code</h1>
<p>That covers the tmux fundamentals. Now let's come back to what I mentioned at the start—the reason I started using tmux often again. Claude Code and tmux pair quite well. There are two reasons.</p>
<ul>
<li><strong>Long autonomous work survives via detach.</strong> If you hand Claude Code a long task and step out with <code>Ctrl+b d</code>, the work keeps running even after you close the terminal</li>
<li><strong>You can work in parallel on one screen.</strong> Splitting Claude Code, the dev server, and logs into Panes and viewing them at once keeps your workflow from breaking</li>
</ul>
<h2>Creating and Naming a Session per Folder</h2>
<p>When you move between several projects using Claude Code, it's convenient to keep one tmux Session running per project (folder). On top of that, if you use multiple machines (laptop, desktop, mini PC, etc.), Session names can collide, so naming them with the <strong><code>&#x3C;machine>-&#x3C;folder></code> rule</strong> keeps things from getting confusing no matter where you attach. For example, if you open the <code>blog</code> folder on your desktop (<code>m4</code>), the Session name becomes <code>m4-blog</code>.</p>
<p>Going one step further, it's nice to pass the same name along when you launch Claude Code in each Session. <code>-n &#x3C;name></code> gives the Claude session a name and shows it in the prompt, the resume picker, and the terminal title, while <code>--remote-control &#x3C;name></code> turns on remote control under that name. Even if you attach from your phone or another PC, "which task this is right now" is clear from the name alone.</p>
<p>Below is a simplified version of the script I use. You just need to change <code>WORKSPACES</code> (the folder list) and <code>VALID_MACHINES</code> (machine names) to match your own environment.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

# ===== Config (edit to match your own environment) =====
WORKSPACES=(blog inspireme markora)   # Folder names under ~/src
VALID_MACHINES=(m1 m4 mini)           # Machine names (laptop/desktop, etc.)
SRC_DIR="${SRC_DIR:-$HOME/src}"
CLAUDE_CMD="${CLAUDE_CMD:-claude}"

# Session name rule: &#x3C;machine>-&#x3C;folder>  (replace . : in the folder with -)
session_name() { echo "$1-${2//[.:]/-}"; }

# Command to run in the Session: give Claude a name (-n) and a remote-control name (--remote-control)
launch_cmd() { echo "$CLAUDE_CMD -n $1 --remote-control $1"; }

create_one() { # &#x3C;machine> &#x3C;directory> &#x3C;folder>
  local session; session="$(session_name "$1" "$3")"
  if tmux has-session -t "$session" 2>/dev/null; then
    echo "Already exists, skipping -> $session"
    return 0
  fi
  tmux new-session -d -s "$session" -c "$2" "$(launch_cmd "$session")"
  echo "Created -> $session ($2)"
}

# Usage: claude_tmux_sessions.sh &#x3C;machine> [&#x3C;folder>]
#   If the folder is omitted, all of WORKSPACES; if specified, just that one folder
machine="${1:?A machine name is required (e.g. m4)}"
folder="${2:-}"

if [[ -n "$folder" ]]; then
  create_one "$machine" "$SRC_DIR/$folder" "$folder"
else
  for f in "${WORKSPACES[@]}"; do
    [[ -d "$SRC_DIR/$f" ]] &#x26;&#x26; create_one "$machine" "$SRC_DIR/$f" "$f"
  done
fi

echo "----- Current tmux sessions -----"
tmux ls
echo "Connect: tmux attach -t &#x3C;session-name>"
</code></pre>
<p>Using it is simple.</p>
<ul>
<li>Launch all defined folders at once: <code>claude_tmux_sessions.sh m4</code></li>
<li>Launch just one specific folder: <code>claude_tmux_sessions.sh m4 blog</code></li>
</ul>
<p>Then Sessions like <code>m4-blog</code> and <code>m4-inspireme</code> are created per folder, and Claude Code is running under the same name inside each Session. From anywhere, you can attach with <code>tmux attach -t m4-blog</code> and continue working. If you leave it detached, the work keeps running even if you close your laptop or your SSH drops, so you can leave it running on a remote server and continue the same task while moving between home, the office, and your phone. (The actual script also includes a feature to clean up the Sessions you created all at once with <code>--kill</code>.)</p>
<blockquote>
<p>Note: Appending <code>--dangerously-skip-permissions</code> to <code>CLAUDE_CMD</code> lets you skip the permission prompt every time, but since the tool then runs commands without confirmation, use it only in a trusted environment. Also, options like <code>-n</code> and <code>--remote-control</code> may differ depending on your Claude Code version, so check with <code>claude --help</code>.</p>
</blockquote>
<blockquote>
<p>Caution: If you open the same repo in multiple Sessions at once and Claude edits the same file simultaneously, conflicts can occur. For parallel work, it's safer to split by folder or separate them with <code>git worktree</code>.</p>
</blockquote>
<h1>8. Wrapping Up</h1>
<p>The real value of tmux can be summed up in one line: <strong>a workspace that doesn't disappear when you close it.</strong> Screen splitting is handy too, but in the end the biggest change is escaping the state of "not being able to close the terminal for fear of losing my work."</p>
<p>You don't need to memorize every shortcut from the start. You can start by getting these five into your hands.</p>
<ul>
<li>New Session: <code>tmux new -s name</code></li>
<li>Step out: <code>Ctrl+b d</code></li>
<li>Re-attach: <code>tmux attach -t name</code></li>
<li>New Window: <code>Ctrl+b c</code></li>
<li>Split left/right: <code>Ctrl+b %</code></li>
</ul>
<p>In particular, I recommend getting comfortable with <code>Ctrl+b d</code> (detach) and <code>tmux attach</code> (re-attach) first. Once just these two are in your hands, the way you approach the terminal changes. After that, layer it on top of long-running work like Claude Code, and you'll quickly feel why you find yourself reaching for tmux again.</p>
<h1>9. References</h1>
<ul>
<li><a href="https://willness.dev/blog/tmux-claude-code-workflow">tmux + Claude Code: The Perfect Terminal Workflow</a></li>
<li><a href="https://hboon.com/using-tmux-with-claude-code/">Using tmux with Claude Code</a></li>
<li><a href="https://codeongrass.com/blog/how-to-run-claude-code-with-tmux/">How to Run Claude Code with tmux on a VPS</a></li>
<li><a href="https://elliotbonneville.com/phone-to-mac-persistent-terminal/">Seamless Claude Code Handoff: SSH From Your Phone With tmux</a></li>
<li><a href="https://www.dariuszparys.com/claude-code-multi-agent-tmux-setup/">Claude Code Multi-Agent tmux Setup</a></li>
</ul>]]></content:encoded>
      <category>ai</category>
      <category>tmux</category>
      <category>terminal</category>
      <category>터미널</category>
      <category>세션</category>
      <category>claude-code</category>
      <category>생산성</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Getting Started with Go Dependency Injection using uber/fx</title>
      <link>https://blog.advenoh.pe.kr/en/go-fx-의존성-주입</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/go-fx-의존성-주입</guid>
      <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
      <description>How to use uber/fx to automatically wire dependencies in a Go application and manage their lifecycle. Covers advanced patterns like fx.Module, fx.Decorate, and fx.Annotate, along with testing strategies, all through hands-on examples.</description>
      <content:encoded><![CDATA[<h1>1. Introduction</h1>
<p>As a Go application grows, assembling dependencies becomes complex. You have to call constructors one by one in <code>main()</code>, line up parameter orders, and manage the lifecycle yourself. uber/fx is a DI (Dependency Injection) framework for Go that solves this problem.</p>
<p>The scope of this article is as follows.</p>
<ul>
<li>Basic API: <code>fx.Provide</code>, <code>fx.Invoke</code>, <code>fx.Supply</code>, <code>fx.New</code></li>
<li>Lifecycle management: <code>fx.Lifecycle</code> (OnStart/OnStop)</li>
<li>Grouping and extension patterns: <code>fx.Module</code>, <code>fx.Decorate</code></li>
<li>Multiple instances of the same type: <code>fx.Annotate</code> + <code>name:</code> / <code>group:</code> tags (<code>fx.Group</code>)</li>
<li>Module encapsulation: <code>fx.Private</code></li>
<li>Testing strategies: <code>fxtest.New</code>, <code>fx.Replace</code>, <code>fx.Populate</code></li>
</ul>
<h1>2. fx Basics</h1>
<h2>2.1 Why DI Is Needed in Go</h2>
<p>A real-world project with separated layers has many dependencies. For example, to construct an Article API, you must assemble the following dependencies in order.</p>
<pre><code class="language-go">// Manual DI: assembled directly in main()
cfg, _ := config.New()
db, _ := database.New(cfg)

authorRepo := author.NewMysqlAuthorRepository(db)
articleRepo := article.NewMysqlArticleRepository(db)

timeout := time.Duration(cfg.Context.Timeout) * time.Second
articleUsecase := article.NewArticleUsecase(articleRepo, authorRepo, timeout)

e := NewEcho()
article.NewArticleHandler(e, articleUsecase)

e.Start(cfg.Server.Address)
</code></pre>
<p>As the number of dependencies grows, this code becomes dramatically more complex. Get the order wrong and you get a compile error, and every time you add a new service you have to modify main().</p>
<p>uber/fx solves this problem. It analyzes the <strong>parameters and return types</strong> of constructor functions to build the dependency graph automatically, and creates everything in the correct order.</p>
<pre><code class="language-bash">go get go.uber.org/fx
</code></pre>
<h2>2.2 fx Basic Concepts</h2>
<p>Before diving in, here is an at-a-glance summary of the fx methods covered in this article. fx has many methods that are easy to confuse, so use the table below as a map while reading. The order of categories matches the article's flow (basics → extension → testing).</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Category</th>
<th>Role</th>
<th>Introduced in</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fx.New</code></td>
<td>Basic</td>
<td>Create the app container</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Provide</code></td>
<td>Basic</td>
<td>lazy registration (register in the graph by return type)</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Invoke</code></td>
<td>Basic</td>
<td>eager execution (side effects like starting the server, registering routes)</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Supply</code></td>
<td>Basic</td>
<td>register a value directly without a constructor</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Lifecycle</code></td>
<td>Lifecycle</td>
<td>manage startup/shutdown via OnStart/OnStop hooks</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Module</code></td>
<td>Extension</td>
<td>group dependencies by domain</td>
<td>v1.17+</td>
</tr>
<tr>
<td><code>fx.Decorate</code></td>
<td>Extension</td>
<td>wrap an existing dependency (logging/caching/metrics)</td>
<td>v1.18+</td>
</tr>
<tr>
<td><code>fx.Annotate</code></td>
<td>Extension</td>
<td>attach metadata to a constructor (name/group/As)</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.ResultTags</code> + <code>name:</code></td>
<td>Extension</td>
<td>identify the same type individually</td>
<td>—</td>
</tr>
<tr>
<td><code>group:</code> tag</td>
<td>Extension</td>
<td>collect implementations of the same interface into a slice</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Private</code></td>
<td>Extension</td>
<td>encapsulate a dependency within a Module</td>
<td>v1.20+</td>
</tr>
<tr>
<td><code>fxtest.New</code></td>
<td>Testing</td>
<td>create a test-only app</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Replace</code></td>
<td>Testing</td>
<td>replace an existing Provide with a Mock</td>
<td>—</td>
</tr>
<tr>
<td><code>fx.Populate</code></td>
<td>Testing</td>
<td>extract an internal container instance into an external variable</td>
<td>—</td>
</tr>
</tbody>
</table>
<p>The following sections cover each method one by one with hands-on examples. First, let's look at the most basic building blocks: <code>fx.Provide</code>, <code>fx.Invoke</code>, <code>fx.Supply</code>, and <code>fx.New</code>.</p>
<p>A constructor registered with <code>fx.Provide()</code> is not executed immediately. It is created <strong>lazily</strong> when the corresponding type is needed elsewhere.</p>
<pre><code class="language-go">// fx_test.go
func TestFx_Provide_Invoke(t *testing.T) {
    var svc *UserService

    app := fxtest.New(t,
        // fx.Provide: only registers the constructor. It is not run immediately; it is called lazily when needed as a dependency.
        fx.Provide(
            NewLogger,       // returns the Logger interface
            NewMysqlUserRepo, // returns the UserRepository interface (needs Logger)
            NewUserService,   // returns *UserService (needs UserRepository)
        ),
        // fx.Invoke: a side effect run immediately at app startup. Here it pulls the assembled UserService into an external variable.
        fx.Invoke(func(s *UserService) {
            svc = s
        }),
    )
    defer app.RequireStop()
    app.RequireStart()

    assert.Equal(t, "user-1", svc.repo.FindByID(1))
}
</code></pre>
<p><code>fx.Supply()</code> provides an already-built value directly, without a constructor.</p>
<pre><code class="language-go">// fx_test.go
type Config struct {
    DBHost string
    DBPort int
}

cfg := &#x26;Config{DBHost: "localhost", DBPort: 3306}

app := fxtest.New(t,
    fx.Supply(cfg), // register the value directly without a constructor
    fx.Invoke(func(c *Config) {
        // c.DBHost == "localhost"
    }),
)
</code></pre>
<h2>2.3 Applying fx to a Real Project</h2>
<p>Converting the manual DI code to fx looks like this.</p>
<pre><code class="language-go">// cmd/main.go
app := fx.New(
    fx.Provide(
        config.New,              // *config.Config
        database.New,            // *sql.DB (needs *config.Config)
        NewEcho,                 // *echo.Echo
        ProvideBasicConfig,      // time.Duration

        article.NewArticleHandler,         // Handler (needs Echo, UseCase)
        article.NewArticleUsecase,         // UseCase (needs Repo, AuthorRepo, Duration)
        article.NewMysqlArticleRepository, // Repository (needs DB)

        author.NewMysqlAuthorRepository,   // AuthorRepo (needs DB)
    ),
    fx.Invoke(registerHooks),  // start the server
)
</code></pre>
<p>fx looks at each constructor's parameter types to determine the dependency order automatically. For example, <code>database.New(cfg *config.Config)</code> needs a <code>*config.Config</code>, so <code>config.New()</code> is called first.</p>
<p>Looking at each constructor's signature, the dependency relationships are clear.</p>
<pre><code class="language-go">// pkg/config/config.go
func New() (*config.Config, error) { ... }

// pkg/database/db.go
func New(cfg *config.Config) (*sql.DB, error) { ... }

// article/usecase.go
func NewArticleUsecase(
    a domain.ArticleRepository,
    ar domain.AuthorRepository,
    timeout time.Duration,
) domain.ArticleUsecase { ... }

// article/handler.go
func NewArticleHandler(e *echo.Echo, us domain.ArticleUsecase) *ArticleHandler { ... }
</code></pre>
<p><code>fx.Provide()</code> accepts not only predefined constructors like the ones above but also <strong>anonymous functions</strong> directly. For simple conversion logic, it's convenient to handle it inline rather than creating a separate constructor file. Expanding the <code>ProvideBasicConfig</code> above into an anonymous function looks like this.</p>
<pre><code class="language-go">// cmd/main.go
app := fx.New(
    fx.Provide(
        config.New,
        database.New,
        NewEcho,

        // an anonymous function can be registered as a constructor too — only the parameters and return type need to match
        func(cfg *config.Config) time.Duration {
            return time.Duration(cfg.Context.Timeout) * time.Second
        },

        article.NewArticleHandler,
        article.NewArticleUsecase,
        article.NewMysqlArticleRepository,
        author.NewMysqlAuthorRepository,
    ),
    fx.Invoke(registerHooks),
)
</code></pre>
<p>Just like a named constructor, fx analyzes the anonymous function's parameters (<code>*config.Config</code>) and return type (<code>time.Duration</code>) and wires it into the dependency graph. Only the form of the function differs; from fx's perspective it's the same constructor.</p>
<p>Visualizing the dependency graph that fx builds from the constructors registered this way looks like this.</p>
<pre><code class="language-mermaid">graph TD
    Config["config.New()"] --> Database["database.New()"]
    Database --> ArticleRepo["NewMysqlArticleRepository()"]
    Database --> AuthorRepo["NewMysqlAuthorRepository()"]
    ArticleRepo --> ArticleUsecase["NewArticleUsecase()"]
    AuthorRepo --> ArticleUsecase
    ArticleUsecase --> ArticleHandler["NewArticleHandler()"]
    Config --> Echo["NewEcho()"]
    Config --> RegisterHooks["registerHooks()"]
    Echo --> RegisterHooks
    ArticleHandler --> RegisterHooks
</code></pre>
<p>fx builds this graph automatically using only the constructors' parameters and return types. If there is a circular dependency, it prints a clear error message at app startup.</p>
<h2>2.4 Lifecycle Management</h2>
<p><code>fx.Lifecycle</code> manages app startup and shutdown. You start the server in <code>OnStart</code> and handle graceful shutdown in <code>OnStop</code>.</p>
<pre><code class="language-go">// cmd/main.go
func registerHooks(lifecycle fx.Lifecycle, e *echo.Echo, cfg *config.Config) {
    lifecycle.Append(
        fx.Hook{
            OnStart: func(context.Context) error {
                fmt.Println("Starting server")
                go e.Start(cfg.Server.Address)
                return nil
            },
            OnStop: func(context.Context) error {
                fmt.Println("Stopping server")
                return nil
            },
        },
    )
}
</code></pre>
<p><code>registerHooks</code> is registered with <code>fx.Invoke()</code>. One thing to watch out for is the <strong>timing of execution</strong>. <code>fx.Invoke</code> (and <code>fx.Populate</code>, which uses it internally) runs immediately at the <strong>time <code>fx.New()</code> is called</strong>, not at <code>app.Start()</code>. So the <code>registerHooks</code> above is called during the <code>fx.New()</code> step and only <em>registers</em> the OnStart/OnStop hooks on the <code>Lifecycle</code>; the actual server boot (the body of <code>OnStart</code>) is triggered later at <code>app.Start(ctx)</code>. This is why <code>fx.Lifecycle</code> has this two-phase structure — register the hooks early via Invoke, and defer running the hook bodies until <code>Start</code>.</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Timing</th>
<th>What runs</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>fx.New()</code> / <code>fxtest.New()</code></td>
<td>the body of the <code>fx.Invoke</code> function (= <code>fx.Populate</code> is also filled here), <em>registration</em> of Lifecycle hooks</td>
</tr>
<tr>
<td>2</td>
<td><code>app.Start(ctx)</code></td>
<td>the registered <code>OnStart</code> hooks run (server boot, etc.)</td>
</tr>
<tr>
<td>3</td>
<td><code>app.Stop(ctx)</code></td>
<td>the registered <code>OnStop</code> hooks run (graceful shutdown)</td>
</tr>
</tbody>
</table>
<pre><code class="language-go">// fx_test.go
func TestFx_Lifecycle(t *testing.T) {
    var startCalled, stopCalled bool

    app := fxtest.New(t,
        fx.Invoke(func(lc fx.Lifecycle) {
            lc.Append(fx.Hook{
                OnStart: func(context.Context) error {
                    startCalled = true
                    return nil
                },
                OnStop: func(context.Context) error {
                    stopCalled = true
                    return nil
                },
            })
        }),
    )

    app.RequireStart()
    assert.True(t, startCalled)

    app.RequireStop()
    assert.True(t, stopCalled)
}
</code></pre>
<h1>3. Extension Patterns</h1>
<p>From fx.Module to fx.Private, let's look at the extension tools that build on top of the fx basics.</p>
<h2>3.1 The fx.Module Pattern</h2>
<p><code>fx.Module()</code> groups related dependencies by domain. As the app grows, listing all constructors together in <code>fx.Provide()</code> hurts readability. Splitting them into Modules makes concerns clearer.</p>
<pre><code class="language-go">// fx_test.go
var UserModule = fx.Module("user",
    fx.Provide(
        NewMysqlUserRepo,
        NewUserService,
    ),
)

var OrderModule = fx.Module("order",
    fx.Provide(
        NewMysqlOrderRepo,
        NewOrderService,
    ),
)

app := fxtest.New(t,
    fx.Provide(NewLogger), // shared dependency
    UserModule,
    OrderModule,
    fx.Invoke(func(u *UserService, o *OrderService) {
        // all dependencies are wired automatically
    }),
)
</code></pre>
<p>In real projects, a common pattern is to define a Module variable in each domain package and compose them in main().</p>
<pre><code class="language-go">// real-world application example
app := fx.New(
    fx.Provide(config.New, database.New, NewEcho),
    article.Module,   // article domain module
    author.Module,    // author domain module
    payment.Module,   // payment domain module
    fx.Invoke(registerHooks),
)
</code></pre>
<blockquote>
<p><strong>fx.Module is available from v1.17.0+</strong>. In earlier versions, you can do similar grouping with <code>fx.Options()</code>, but it does not support module names or scope isolation.</p>
</blockquote>
<h2>3.2 The fx.Decorate Pattern</h2>
<p><code>fx.Decorate()</code> wraps an existing dependency to add behavior. It's the same concept as the decorator pattern, used for logging, caching, metrics collection, and so on.</p>
<pre><code class="language-go">// fx_test.go
// logging decorator: wraps the existing UserRepository
type loggingUserRepo struct {
    inner  UserRepository
    logger Logger
    calls  []string
}

func (r *loggingUserRepo) FindByID(id int) string {
    r.calls = append(r.calls, fmt.Sprintf("FindByID(%d)", id))
    return r.inner.FindByID(id) // call the original
}

app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    // replace the existing UserRepository with a logging wrapper
    fx.Decorate(func(repo UserRepository, logger Logger) UserRepository {
        return &#x26;loggingUserRepo{inner: repo, logger: logger}
    }),
    fx.Invoke(func(svc *UserService) {
        svc.repo.FindByID(1) // called through the logging wrapper
    }),
)
</code></pre>
<p><code>fx.Decorate()</code> takes the original dependency as a parameter and returns a new wrapped instance. UserService receives the wrapped Repository automatically, with no changes.</p>
<blockquote>
<p><strong>fx.Decorate is available from v1.18.0+</strong>.</p>
</blockquote>
<h2>3.3 fx.Annotate + Named Dependencies</h2>
<p>When you need to distinguish multiple instances of the same type, use <code>fx.Annotate()</code> and the <code>name</code> tag. A typical case is separating Read/Write DBs.</p>
<pre><code class="language-go">// fx_test.go
type DBConnection struct {
    Name string
    DSN  string
}

func NewReadDB() *DBConnection {
    return &#x26;DBConnection{Name: "read", DSN: "read-replica:3306"}
}

func NewWriteDB() *DBConnection {
    return &#x26;DBConnection{Name: "write", DSN: "primary:3306"}
}
</code></pre>
<p>Use <code>fx.Annotate()</code> to give each constructor a name.</p>
<pre><code class="language-go">// fx_test.go
fx.Provide(
    fx.Annotate(NewReadDB, fx.ResultTags(`name:"readDB"`)),
    fx.Annotate(NewWriteDB, fx.ResultTags(`name:"writeDB"`)),
    NewDBService,
)
</code></pre>
<p>On the receiving side, match by <code>name</code> tag in an <code>fx.In</code> struct.</p>
<pre><code class="language-go">// fx_test.go
type DBParams struct {
    fx.In
    ReadDB  *DBConnection `name:"readDB"`
    WriteDB *DBConnection `name:"writeDB"`
}

func NewDBService(params DBParams) *DBService {
    return &#x26;DBService{
        readDB:  params.ReadDB,  // read-replica:3306
        writeDB: params.WriteDB, // primary:3306
    }
}
</code></pre>
<h2>3.4 Collecting Multiple Implementations of the Same Interface with fx.Group</h2>
<p>The <code>name:</code> tag is for identifying the same type <strong>individually</strong>. But if you want to inject multiple implementations of the same interface all at once — for example, sending a notification to every Notifier — <code>name:</code> is not enough, because you'd have to give each implementation a different name and receive them one by one on the receiving side.</p>
<p>The <code>group:</code> tag solves this problem. Implementations registered in the same group are injected together as a slice.</p>
<pre><code class="language-go">// fx_test.go
type Notifier interface {
    Send(msg string) string
}

type EmailNotifier struct{}
func (e *EmailNotifier) Send(msg string) string { return "email:" + msg }

type SlackNotifier struct{}
func (s *SlackNotifier) Send(msg string) string { return "slack:" + msg }

type SMSNotifier struct{}
func (s *SMSNotifier) Send(msg string) string { return "sms:" + msg }
</code></pre>
<p>Use <code>fx.Annotate()</code> and <code>fx.ResultTags()</code> to register each constructor in the same group.</p>
<pre><code class="language-go">// fx_test.go
fx.Provide(
    fx.Annotate(func() Notifier { return &#x26;EmailNotifier{} },
        fx.ResultTags(`group:"notifiers"`)),
    fx.Annotate(func() Notifier { return &#x26;SlackNotifier{} },
        fx.ResultTags(`group:"notifiers"`)),
    fx.Annotate(func() Notifier { return &#x26;SMSNotifier{} },
        fx.ResultTags(`group:"notifiers"`)),
    NewNotifierService,
)
</code></pre>
<p>The receiving side takes it as a slice field with a <code>group:</code> tag in an <code>fx.In</code> struct.</p>
<pre><code class="language-go">// fx_test.go
type NotifierParams struct {
    fx.In
    Notifiers []Notifier `group:"notifiers"`
}

type NotifierService struct {
    notifiers []Notifier
}

func NewNotifierService(p NotifierParams) *NotifierService {
    return &#x26;NotifierService{notifiers: p.Notifiers}
}
</code></pre>
<p>A representative real-world use is collecting multiple external service clients into a single interface slice. Even when a new implementation is added, the receiving-side code does not change.</p>
<p>To summarize the difference between <code>name:</code> and <code>group:</code>:</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Use</th>
<th>Receiving side</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name:"X"</code></td>
<td>identify the same type <strong>individually</strong></td>
<td>single field</td>
</tr>
<tr>
<td><code>group:"Y"</code></td>
<td><strong>collect</strong> the same type (or interface)</td>
<td>slice field</td>
</tr>
</tbody>
</table>
<h2>3.5 Encapsulating a Module with fx.Private</h2>
<p>Even if you split domains with <code>fx.Module()</code>, every <code>fx.Provide()</code> is exposed globally by default. For a dependency you want to use only inside a Module, you can block exposure with <code>fx.Private</code>. It's useful for preventing another Module from accidentally sharing the same instance of an infrastructure dependency such as a database handle or external API client.</p>
<p>When you place <code>fx.Private</code> inside the same <code>fx.Provide()</code> call alongside other constructors, it makes that entire group Module-private.</p>
<pre><code class="language-go">// fx_test.go
type internalDB struct {
    name string
}

func newInternalDB() *internalDB {
    return &#x26;internalDB{name: "private-db"}
}

type ModuleService struct {
    db *internalDB
}

func newModuleService(db *internalDB) *ModuleService {
    return &#x26;ModuleService{db: db}
}

PrivateModule := fx.Module("private",
    fx.Provide(
        newInternalDB,
        fx.Private,        // makes the entire fx.Provide() group Module-internal
    ),
    fx.Provide(newModuleService), // ModuleService is exposed externally
)
</code></pre>
<p><code>*internalDB</code> can only be injected into <code>newModuleService</code> inside <code>PrivateModule</code>. If you request <code>*internalDB</code> directly from outside the Module, fx returns an error when constructing the dependency graph (<code>fx.Populate</code> is covered in §2.8.3).</p>
<pre><code class="language-go">// fx_test.go
// attempting to extract *internalDB directly from outside → fx.New returns an error
var leaked *internalDB
leakApp := fx.New(
    PrivateModule,
    fx.Populate(&#x26;leaked),
    fx.NopLogger,
)
// leakApp.Err() != nil
</code></pre>
<blockquote>
<p><strong>fx.Private is available from v1.20.0+</strong>. In earlier versions, even with <code>fx.Module</code> isolation, all Provides are registered in the global graph.</p>
</blockquote>
<h1>4. Testing Strategies</h1>
<p>An app built with fx is tested with the <code>fxtest</code> package. We'll also look at mock injection and instance extraction.</p>
<h2>4.1 fxtest.New</h2>
<p><code>fxtest.New()</code> creates a test-only app. It cleans up automatically on test failure, and fx logs are included in the test output.</p>
<pre><code class="language-go">// fx_test.go
app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Invoke(func(svc *UserService) { ... }),
)
defer app.RequireStop()
app.RequireStart()
</code></pre>
<h2>4.2 Injecting a Mock with fx.Replace</h2>
<p><code>fx.Replace()</code> completely replaces an existing Provide. It's useful for injecting a Mock instead of the real implementation in a test.</p>
<pre><code class="language-go">// fx_test.go
type mockUserRepo struct{}

func (r *mockUserRepo) FindByID(id int) string {
    return fmt.Sprintf("mock-user-%d", id) // Mock response
}

app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    // replace the real UserRepository with a Mock
    fx.Replace(fx.Annotate(&#x26;mockUserRepo{}, fx.As(new(UserRepository)))),
    fx.Invoke(func(svc *UserService) {
        result := svc.repo.FindByID(1)
        // result == "mock-user-1"
    }),
)
</code></pre>
<p><code>fx.As(new(UserRepository))</code> registers <code>*mockUserRepo</code> after type-converting it to the <code>UserRepository</code> interface.</p>
<h2>4.3 Extracting an Instance with fx.Populate</h2>
<p>So far we've captured instances into external variables in the <code>fx.Invoke(func(s *Svc) { svc = s })</code> form. <code>fx.Populate</code> does the same thing more concisely.</p>
<p>In fact, <code>fx.Populate</code> is a convenience function implemented internally with <code>fx.Invoke</code>. <code>fx.Populate(&#x26;svc)</code> is equivalent to automatically generating "an <code>fx.Invoke</code> that assigns the injected value to <code>svc</code>." In other words, the two are essentially the same. The difference is in <strong>purpose</strong>. <code>fx.Invoke</code> exists to <em>do something</em> with the pulled-out dependency, so you can call, verify, or do anything in the closure body (extraction is just one of those things); <code>fx.Populate</code> exists for <em>extraction itself</em>, so it omits the closure when it would just be boilerplate.</p>
<p>Comparing the two methods:</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th><code>fx.Invoke</code></th>
<th><code>fx.Populate</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Essence</td>
<td><strong>runs</strong> a function</td>
<td><strong>fills</strong> a variable</td>
</tr>
<tr>
<td>What you pass</td>
<td>a function (closure)</td>
<td>a pointer</td>
</tr>
<tr>
<td>Body</td>
<td>yes — assign, call, verify, anything</td>
<td>none — extraction only</td>
</tr>
<tr>
<td>Extraction</td>
<td>possible as a side effect via <code>svc = s</code> in the closure</td>
<td>that is its sole purpose</td>
</tr>
</tbody>
</table>
<pre><code class="language-go">// fx_test.go
// Approach 1: capture via an fx.Invoke closure (the approach used earlier)
var svc1 *UserService
app1 := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Invoke(func(s *UserService) {
        svc1 = s
    }),
)

// Approach 2: extract directly with fx.Populate
var svc2 *UserService
app2 := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Populate(&#x26;svc2),
)
</code></pre>
<p>The difference is even more pronounced when extracting multiple instances at once.</p>
<pre><code class="language-go">// fx_test.go
var (
    svc    *UserService
    logger Logger
)
app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Populate(&#x26;svc, &#x26;logger),
)
</code></pre>
<p>The selection guide is simple.</p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Recommended</th>
</tr>
</thead>
<tbody>
<tr>
<td>The goal is to pull an instance into an external variable</td>
<td><code>fx.Populate</code></td>
</tr>
<tr>
<td>Perform a function call or additional verification at the same point after extraction</td>
<td><code>fx.Invoke</code></td>
</tr>
</tbody>
</table>
<h1>5. Wrapping Up</h1>
<p>fx has many methods and is easy to confuse. Here's a summary of what to choose in each situation.</p>
<table>
<thead>
<tr>
<th>What you want to do</th>
<th>Method</th>
<th>Tip for choosing</th>
</tr>
</thead>
<tbody>
<tr>
<td>Register a dependency in the graph (created lazily later)</td>
<td><code>fx.Provide</code></td>
<td>not immediate — called when needed</td>
</tr>
<tr>
<td>Run immediately at app startup (server boot, route registration)</td>
<td><code>fx.Invoke</code></td>
<td>if confused with <code>Provide</code>, "if it's a side effect, use Invoke"</td>
</tr>
<tr>
<td>Inject an already-built value/config without a constructor</td>
<td><code>fx.Supply</code></td>
<td>suitable for constants and config values</td>
</tr>
<tr>
<td>Manage startup/shutdown hooks (graceful shutdown)</td>
<td><code>fx.Lifecycle</code></td>
<td>register with <code>Append</code> inside <code>Invoke</code></td>
</tr>
<tr>
<td>Group dependencies by domain</td>
<td><code>fx.Module</code></td>
<td>split a grown <code>Provide</code> list</td>
</tr>
<tr>
<td>Add logging/caching to an existing dependency</td>
<td><code>fx.Decorate</code></td>
<td>wrap without modifying the original code</td>
</tr>
<tr>
<td>Identify the same type <strong>individually</strong> (read/write DB)</td>
<td><code>fx.Annotate</code> + <code>name:</code></td>
<td>receiving side is a single field</td>
</tr>
<tr>
<td>Inject implementations of the same interface <strong>collected together</strong></td>
<td><code>group:</code> tag</td>
<td>receiving side is a slice field</td>
</tr>
<tr>
<td>Hide a Module's internal dependency from the outside</td>
<td><code>fx.Private</code></td>
<td>isolate infrastructure handles</td>
</tr>
<tr>
<td>Use a Mock instead of the real implementation in tests</td>
<td><code>fx.Replace</code></td>
<td>match the interface with <code>fx.As</code></td>
</tr>
<tr>
<td>Pull a container's internal instance out in tests</td>
<td><code>fx.Populate</code></td>
<td>if verifying at the same time, use <code>fx.Invoke</code></td>
</tr>
</tbody>
</table>
<p>Remember just one thing. You don't need to manage every dependency with fx. Simple value objects and utilities are clearer to create directly, and fx shines most when you focus it on <strong>components that need lifecycle management</strong> (DB connections, HTTP servers, external clients). Because it's reflection-based, you give up some compile-time type safety, but its detailed runtime error messages and real-world productivity more than make up for it.</p>
<p>The full source is available in the following two places.</p>
<ul>
<li>Per-method learning examples (the <code>// fx_test.go</code> code in this article): <a href="https://github.com/kenshin579/tutorials-go/tree/master/golang/third-party/fx">golang/third-party/fx</a></li>
<li>Real-world application example (Clean Architecture + fx, the <code>// cmd/main.go</code> code): <a href="https://github.com/kenshin579/tutorials-go/tree/master/project-layout/go-clean-arch-v2">project-layout/go-clean-arch-v2</a></li>
</ul>
<h1>6. References</h1>
<ul>
<li><a href="https://uber-go.github.io/fx/">uber/fx Official Docs</a></li>
<li><a href="https://github.com/uber-go/fx">uber/fx GitHub</a></li>
<li><a href="https://github.com/uber-go/dig">uber/dig GitHub</a></li>
<li><a href="https://github.com/uber-go/fx/releases/tag/v1.17.0">fx.Module introduced (v1.17)</a></li>
<li><a href="https://github.com/uber-go/fx/releases/tag/v1.18.0">fx.Decorate introduced (v1.18)</a></li>
<li><a href="https://pkg.go.dev/go.uber.org/fx">Go Dependency Injection - uber/fx</a></li>
<li><a href="https://uber-go.github.io/fx/value-groups/">Value Groups (fx Docs)</a></li>
<li><a href="https://github.com/uber-go/fx/releases/tag/v1.20.0">fx.Private introduced (v1.20)</a></li>
<li><a href="https://pkg.go.dev/go.uber.org/fx#Populate">fx.Populate API</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>Golang</category>
      <category>uber/fx</category>
      <category>Dependency Injection</category>
      <category>DI</category>
      <category>fx.Module</category>
      <category>fx.Decorate</category>
      <category>fxtest</category>
      <category>fx.Group</category>
      <category>fx.Private</category>
      <category>fx.Populate</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Complete Guide to Claude Code Superpowers: From Brainstorm to PR</title>
      <link>https://blog.advenoh.pe.kr/en/claude-code-superpowers-완벽-가이드</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/claude-code-superpowers-완벽-가이드</guid>
      <pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate>
      <description>A comprehensive guide to building a Todo web app through the full cycle—from brainstorm to PR—using the Claude Code superpowers plugin.</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<p>When you write code with Claude Code, there's one recurring frustration. You start out clean, but at some point the context becomes scattered, you lose track of how far you've gotten, and it becomes ambiguous where the tests should stop and restart. Give the AI autonomy and it gets improvisational; try to control it and a human has to step in at every turn.</p>
<p><strong>Superpowers is a plugin that structures this flow.</strong> It organizes ideas into a spec with brainstorm, decomposes them into work-unit plans with writing-plans, goes through per-phase implementation + automatic review with subagent-driven-development, runs a final check with requesting-code-review, and handles PR/cleanup with finishing-a-development-branch—these five stages are bundled into one complete workflow.</p>
<p>In this article, I'll lay out the core superpowers skills all at once, then walk step by step through an actual case where I built an Echo + React Todo web app from scratch all the way to PR/merge. At the end, I've added a separate honest-review section based on actually using it, so colleagues considering adoption can weigh cost vs. value.</p>
<blockquote>
<p>This article assumes a reader who is reasonably familiar with Claude Code itself and the Skill/Plugin concepts. If any concept is new to you, it helps to first read <a href="/articles/claude-code-%ED%99%95%EC%9E%A5-%EA%B8%B0%EB%8A%A5-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-command-skill-subagent">Complete Guide to Claude Code Extensions: Command, Skill, Subagent</a> and <a href="/articles/claude-code-plugin-hooks-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C">Complete Guide to Claude Code Plugin Hooks</a>.</p>
</blockquote>
<h1>2. What Is Superpowers</h1>
<p>Superpowers is a plugin officially registered in the <a href="https://www.anthropic.com/engineering/claude-code-plugins">Claude Code plugin marketplace run by Anthropic</a>. Unlike an ordinary plugin that provides a single function, its distinguishing feature is that it provides <strong>a bundle of multiple skills + a workflow in which the skills are invoked in a defined order</strong>.</p>
<p>Where the existing <a href="/articles/claude-code-%ED%99%95%EC%9E%A5-%EA%B8%B0%EB%8A%A5-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-command-skill-subagent">Skill guide</a> explains "what each skill is and when it's invoked," this article steps up one level to cover <strong>in what order multiple skills are woven together to form a single cycle</strong>.</p>
<h1>3. Core Skill Catalog</h1>
<p>There are many skills inside Superpowers, but the core ones that form the full cycle are eight. Let's first skim them all at once, then look at each skill one paragraph at a time.</p>
<table>
<thead>
<tr>
<th>Skill</th>
<th>Role</th>
<th>Input → Output</th>
<th>Coverage Depth</th>
</tr>
</thead>
<tbody>
<tr>
<td>brainstorming</td>
<td>idea → spec</td>
<td>natural language → spec.md</td>
<td>deep (section 5)</td>
</tr>
<tr>
<td>writing-plans</td>
<td>spec → plan</td>
<td>spec.md → plan.md</td>
<td>deep</td>
</tr>
<tr>
<td>subagent-driven-development</td>
<td>plan → code (subagent)</td>
<td>plan.md → commit</td>
<td>deep</td>
</tr>
<tr>
<td>executing-plans</td>
<td>plan → code (inline)</td>
<td>plan.md → commit</td>
<td>brief (alternative)</td>
</tr>
<tr>
<td>requesting-code-review</td>
<td>code → review</td>
<td>branch/commit → review comments</td>
<td>medium</td>
</tr>
<tr>
<td>test-driven-development</td>
<td>enforces TDD every phase</td>
<td>explicit Red→Green</td>
<td>brief</td>
</tr>
<tr>
<td>using-git-worktrees</td>
<td>isolated worktree</td>
<td>feature-work isolation</td>
<td>brief</td>
</tr>
<tr>
<td>finishing-a-development-branch</td>
<td>wrap-up</td>
<td>work complete → PR/cleanup</td>
<td>medium</td>
</tr>
</tbody>
</table>
<h2>3.1 brainstorming</h2>
<p>A skill invoked with <code>/superpowers:brainstorming</code>. It takes an idea expressed in natural language and asks the user one multiple-choice question at a time, organizing it into spec.md. It saves the decisions to <code>docs/superpowers/specs/YYYY-MM-DD-&#x3C;topic>-design.md</code> and commits them.</p>
<p>The key is "one question at a time." Rather than dumping the stack, feature scope, libraries, and tone all at once, it proceeds like a decision tree. For visual decisions like UI design, it can also bring up a visual companion (browser mockup comparison).</p>
<h2>3.2 writing-plans</h2>
<p>Once the spec is complete, <code>superpowers:writing-plans</code> is invoked automatically. It decomposes the spec into per-phase tasks and explicitly writes the following five steps for each task.</p>
<ol>
<li>Write a failing test</li>
<li>Confirm the test fails (Red)</li>
<li>Minimal implementation</li>
<li>Confirm the test passes (Green)</li>
<li>Commit</li>
</ol>
<p>Each task is one commit unit, usually 2-5 minutes of work. The plan is saved to <code>docs/superpowers/plans/YYYY-MM-DD-&#x3C;topic>-plan.md</code>.</p>
<h2>3.3 subagent-driven-development</h2>
<p>Once the plan is ready, two execution options are presented. <strong>subagent-driven</strong> dispatches a new subagent for each task to isolate context. A single task goes through the following flow.</p>
<ol>
<li><strong>Implementer subagent</strong> — receives the task instructions, writes code, tests, self-reviews, and commits</li>
<li><strong>Spec compliance reviewer subagent</strong> — verifies the implementation matches the spec exactly</li>
<li><strong>Code quality reviewer subagent</strong> — checks code quality, test gaps, and pitfalls</li>
</ol>
<p>All three stages must pass before moving to the next task. If a review finds an issue, the implementer is called again to fix → re-review. With no context contamination, one subagent is responsible for one task.</p>
<h2>3.4 executing-plans (alternative)</h2>
<p>An alternative that runs the same plan inline within the same session. At each step it directly updates the checkbox in the plan file to <code>- [x]</code> as it proceeds. There's no subagent dispatch cost, but also no context isolation, so on large work context contamination can accumulate.</p>
<p>Selection criteria:</p>
<ul>
<li>subagent-driven: large multi-stage work, many tasks, where the value of the review cycle is high</li>
<li>executing-plans: short and simple work, where the plan file itself serves as progress tracking</li>
</ul>
<h2>3.5 requesting-code-review</h2>
<p>Before the entire cycle ends, you get one more comprehensive review. It bundles all commits in the branch and checks spec compliance, regressions, pitfalls, and items needing additional tests. Comments classified as critical/important/minor come back.</p>
<h2>3.6 test-driven-development</h2>
<p>TDD is already built into the task skeleton of writing-plans, so a separate invocation is rare. But it guides the implementer subagent to consciously follow the TDD cycle. The key is confirming, in the Red stage, that the test really fails.</p>
<h2>3.7 using-git-worktrees</h2>
<p>Before starting large work, it creates an isolated worktree so the current working directory isn't affected. It's often omitted for a single learning cycle, but it's recommended in real-world environments where you work on multiple features simultaneously.</p>
<h2>3.8 finishing-a-development-branch</h2>
<p>Invoked at the very end after all tasks are done. It presents the following as structured options.</p>
<ul>
<li>choose among merge / PR / keep a separate branch</li>
<li>automatic push policy</li>
<li>branch cleanup</li>
</ul>
<p>In this article's case, I didn't invoke this skill explicitly; following the user's global policy (no arbitrary push) I ran <code>gh pr create</code> + <code>gh pr merge</code> directly—I'll revisit this in the review section.</p>
<h1>4. The Full Cycle Flow</h1>
<p>It becomes clear when you see, in a diagram, the order in which the core skills are woven together.</p>
<pre><code class="language-mermaid">flowchart LR
    A[brainstorming] --> B[writing-plans]
    B --> C[subagent-driven-development]
    C --> D[requesting-code-review]
    D --> E[finishing-a-development-branch]
</code></pre>
<p>Each arrow means an automatic invocation. When brainstorming finishes writing the spec, after user approval it automatically calls writing-plans, and once the plan is written the user chooses the execution mode (subagent-driven or executing-plans). At the end, finishing-a-development-branch presents the PR/merge options.</p>
<h2>4.1 Points of User Intervention</h2>
<p>Across the entire flow, the points where the user must decide are as follows.</p>
<table>
<thead>
<tr>
<th>Point</th>
<th>What</th>
<th>Common Question Received</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. During brainstorm</td>
<td>stack/feature scope/libraries</td>
<td>multiple choice like "which of A/B/C?"</td>
</tr>
<tr>
<td>2. Spec review</td>
<td>whether to approve the spec document</td>
<td>"anything to change?"</td>
</tr>
<tr>
<td>3. Plan review (optional)</td>
<td>approve the plan document</td>
<td>"shall we run it right away?"</td>
</tr>
<tr>
<td>4. Execution mode</td>
<td>subagent-driven vs executing-plans</td>
<td>"which way?"</td>
</tr>
<tr>
<td>5. Applying review comments</td>
<td>handling spec/quality review issues</td>
<td>"OK to proceed with the fixup?"</td>
</tr>
<tr>
<td>6. Wrap-up</td>
<td>PR / push / merge approval</td>
<td>"shall I push now?"</td>
</tr>
</tbody>
</table>
<p>Outside these six points, it's almost all automatic. Because the points requiring human time are clear, the workflow's predictability is high.</p>
<h2>4.2 Tracking Method (subagent-driven vs executing-plans)</h2>
<p>Progress tracking happens in one of two places.</p>
<ul>
<li><strong>subagent-driven</strong>: an in-memory task list via TaskCreate/TaskUpdate. The <code>- [ ]</code> checkboxes in the plan file are not updated.</li>
<li><strong>executing-plans</strong>: marking directly by editing the plan file to <code>- [x]</code>. The plan file itself is the progress tracker.</li>
</ul>
<p>The result is the same, but the artifacts differ. Because this article's case used subagent-driven, the plan file remained <code>- [ ]</code> to the end—I'll revisit this in the review section.</p>
<h2>4.3 Directory Structure</h2>
<p>The work output is organized into the following form.</p>
<pre><code>project/
├── docs/superpowers/
│   ├── specs/YYYY-MM-DD-&#x3C;topic>-design.md   # brainstorming output
│   └── plans/YYYY-MM-DD-&#x3C;topic>-plan.md     # writing-plans output
├── (actual code)
└── (tests)
</code></pre>
<p>The specs/plans are included together in the PR and become a permanent reference. Since the intent and steps of the work are recorded next to the code, even when you look back six months later it's easy to trace "why did I do it this way."</p>
<h1>5. Case Study: A Todo Web App from Scratch to PR (Echo + React)</h1>
<p>This section is the core of this guide. It walks step by step through the actual flow of building a learning Todo web app from brainstorm to PR/merge under <code>ai/superpowers/todo/</code> in the <code>tutorials-go</code> repository. The resulting PR is <a href="https://github.com/kenshin579/tutorials-go/pull/701">#701</a>.</p>
<p>Laying out the whole flow in advance:</p>
<pre><code class="language-mermaid">flowchart LR
    S0["brainstorm start"] --> S1["write spec"]
    S1 --> S2["decompose plan into 11 phases"]
    S2 --> S3["implement Phase 0-10"]
    S3 --> S4["MCP playwright e2e"]
    S4 --> S5["PR / merge"]
</code></pre>
<p>Let's look at what command started each stage and what artifacts it left behind.</p>
<h2>5.1 Brainstorming — Up to Writing the Spec</h2>
<p>It starts with a single line, <code>/superpowers:brainstorming</code>. You pass a natural-language idea as the argument.</p>
<pre><code>/superpowers:brainstorming I want to write sample code for a todo web application.
- stack: react, golang 1.25, inmemory
- I want to write the sample code here: /Users/user/src/workspace_blog3/tutorials-go/ai/superpowers
Note: the main purpose is to learn how to use superpowers
</code></pre>
<p>The skill immediately checks the context (go.mod, sibling directories, existing conventions), then presents multiple choices one question at a time. An excerpt of the actual questions received:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Question</th>
<th>Options</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Feature scope</td>
<td>A minimal / B basic / C extended</td>
</tr>
<tr>
<td>2</td>
<td>Backend framework</td>
<td>A net/http / B chi / C gin or echo</td>
</tr>
<tr>
<td>3</td>
<td>Go module structure</td>
<td>A root module / B sub-module / C downgrade</td>
</tr>
<tr>
<td>4</td>
<td>Frontend stack</td>
<td>Vite + React + TS or JS</td>
</tr>
<tr>
<td>5</td>
<td>BE/FE integration</td>
<td>A separate + Vite proxy / B CORS / C static serving</td>
</tr>
<tr>
<td>6</td>
<td>Test coverage</td>
<td>A BE full + FE smoke / B both full / C BE only</td>
</tr>
<tr>
<td>7</td>
<td>Filter/sort location</td>
<td>A server-side / B client-side</td>
</tr>
</tbody>
</table>
<p>Each question shows a recommendation (one of A/B/C) along with an explanation of the trade-offs. After passing the 7 questions, a 9-section spec.md (<code>2026-04-30-todo-app-design.md</code>, 600+ lines) is generated automatically, ambiguities are reinforced inline via self-review, and then a user review gate opens.</p>
<p>Once approved, the next skill is invoked automatically.</p>
<h2>5.2 Writing-plans — Decomposing the Plan into 11 Phases</h2>
<p><code>superpowers:writing-plans</code> reads the spec and creates a task-unit plan. In the Todo case, it was decomposed into the following 11 stages.</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Content</th>
<th>TDD Applied</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pre-flight</td>
<td>feature branch + spec/plan commit</td>
<td>—</td>
</tr>
<tr>
<td>0</td>
<td>directory/Makefile/.gitignore + backend main.go stub + Vite setup</td>
<td>—</td>
</tr>
<tr>
<td>1</td>
<td>domain model (Priority/Todo/Validate)</td>
<td>✅</td>
</tr>
<tr>
<td>2.1</td>
<td>Store CRUD + concurrency verification</td>
<td>✅</td>
</tr>
<tr>
<td>2.2</td>
<td>Store List filter/sort</td>
<td>✅</td>
</tr>
<tr>
<td>3.1~3.3</td>
<td>Handler Create/Delete/List/Update + writeError</td>
<td>✅</td>
</tr>
<tr>
<td>4</td>
<td>Echo server integration + httptest</td>
<td>✅</td>
</tr>
<tr>
<td>5</td>
<td>FE infrastructure (types/api/MSW)</td>
<td>—</td>
</tr>
<tr>
<td>6</td>
<td>useTodos hook</td>
<td>✅</td>
</tr>
<tr>
<td>7</td>
<td>TodoForm/FilterBar/TodoItem/TodoList</td>
<td>smoke</td>
</tr>
<tr>
<td>8</td>
<td>App integration + e2e</td>
<td>✅</td>
</tr>
<tr>
<td>9</td>
<td>README + manual verification</td>
<td>—</td>
</tr>
<tr>
<td>10</td>
<td>invoke code-review skill + create PR</td>
<td>—</td>
</tr>
</tbody>
</table>
<p>Each phase is one commit unit, averaging 5-10 minutes of work. The plan ran about 3500 lines, specifying the code/commands/expected output of every step.</p>
<h2>5.3 Subagent-driven-development — 25 commits, two-stage review</h2>
<p>This is the heart of the cycle. The following flow repeats for every phase.</p>
<pre><code class="language-mermaid">flowchart LR
    P["task start"] --> I["implementer subagent"]
    I --> S["spec compliance reviewer"]
    S --> Q["code quality reviewer"]
    Q --> N["next task"]
</code></pre>
<p>All three subagents operate on the same task in independent contexts. In the actual case, there were two instances where this structure caught a critical issue.</p>
<p><strong>Case ① — Phase 1 Korean length validation (caught by the TDD reviewer)</strong></p>
<p>In the domain model's <code>NewTodo.Validate()</code>, the title length was being checked with <code>len()</code>, and the code quality reviewer pointed out the following.</p>
<blockquote>
<p><code>todo.go:60</code> — Title length uses <code>len()</code> (bytes), not runes. A 100-character Korean title is 300 bytes, so it gets rejected. In this project where Korean content is a first-class concern, this is a bug.</p>
</blockquote>
<p>I called the implementer again to add <code>utf8.RuneCountInString</code> + Korean boundary tests (200 chars valid / 201 chars invalid).</p>
<p><strong>Case ② — Phase 10 DueDate pointer aliasing (caught by the final reviewer)</strong></p>
<p>In the final review of the entire branch, the following was found.</p>
<blockquote>
<p><code>store.go:39, 82</code> — <code>Add</code>/<code>Update</code> store the caller's <code>*time.Time</code> pointer as-is. The package GoDoc promises "callers cannot mutate stored state through returned references," but this promise is broken.</p>
</blockquote>
<p>I added a defensive copy (a <code>copyTimePointer</code> helper) and included regression tests (<code>TestStore_Add_DueDateNotAliased</code>, <code>TestStore_Update_DueDateNotAliased</code>) along with it.</p>
<p>An excerpt of the representative commit flow:</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Commit</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pre-flight</td>
<td><code>[feat/todo-app] docs: add spec and implementation plan</code></td>
<td>feat/todo-app branch start</td>
</tr>
<tr>
<td>0.1</td>
<td><code>chore: project skeleton (README, Makefile)</code></td>
<td>—</td>
</tr>
<tr>
<td>1</td>
<td><code>feat: domain model (Priority, Todo, NewTodo, Patch, Validate)</code></td>
<td>TDD Red→Green</td>
</tr>
<tr>
<td>1 fixup</td>
<td><code>fix: count title length by rune, not byte (Korean support)</code></td>
<td>applying reviewer comment</td>
</tr>
<tr>
<td>2.1</td>
<td><code>feat: in-memory Store CRUD (Add/Get/Update/Delete)</code></td>
<td>sync.RWMutex</td>
</tr>
<tr>
<td>4</td>
<td><code>feat: Echo server integration (routing/middleware/CORS) + httptest integration tests</code></td>
<td>backend complete</td>
</tr>
<tr>
<td>6</td>
<td><code>feat: useTodos hook (useReducer + create/update/remove + auto refetch)</code></td>
<td>FE core</td>
</tr>
<tr>
<td>8</td>
<td><code>feat: App assembly + integration scenario tests (MSW full round-trip)</code></td>
<td>FE integration</td>
</tr>
<tr>
<td>10 fixup</td>
<td><code>fix: Store defensively copies DueDate pointer to block external mutation</code></td>
<td>final reviewer comment</td>
</tr>
</tbody>
</table>
<p>A total of 25 commits piled up on the feat/todo-app branch.</p>
<h2>5.4 Creating and Merging the PR</h2>
<p>Branch push + PR creation + merge are each one line of command.</p>
<pre><code class="language-bash"># push
git push -u origin feat/todo-app

# create PR (HEREDOC per CLAUDE.md policy)
gh pr create --title "feat: superpowers todo app (learning sample)" \
  --body "$(cat &#x3C;&#x3C;'EOF'
## Summary
- Echo + React + in-memory Todo web application (for learning)
- fully experienced the superpowers plugin skill cycle
... (omitted)
EOF
)" --assignee kenshin579

# merge
gh pr merge 701 --merge --delete-branch
</code></pre>
<p>Result: <a href="https://github.com/kenshin579/tutorials-go/pull/701">PR #701</a> merged, integrated into master. From the start of work to merge, it finished within a single session.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/claude-code-superpowers-완벽-가이드/todo-app-final.png" alt="Todo app final screen — header count, segmented filter, priority color badge, line-through on completed items, due date subtext"></p>
<h1>6. Getting Started</h1>
<p>The procedure needed to adopt superpowers for the first time is short.</p>
<h2>6.1 Installing the Plugin</h2>
<p>Install it via the marketplace in Claude Code.</p>
<pre><code class="language-bash">/plugin install superpowers@anthropic
</code></pre>
<p>(Check the exact command on the <a href="https://www.anthropic.com/engineering/claude-code-plugins">Anthropic Plugin Marketplace page</a>. The channel/name may differ depending on the point in time.)</p>
<p>After installation, you're good if you see slash commands starting with <code>/superpowers:</code> in a new session.</p>
<h2>6.2 Starting the First Cycle</h2>
<pre><code class="language-bash">/superpowers:brainstorming &#x3C;a line or two of the idea in natural language>
</code></pre>
<p>After that, just follow this flow as-is.</p>
<ol>
<li>Answer 7-10 multiple-choice questions</li>
<li>Review the auto-generated spec.md (you can request 1-2 rounds of changes)</li>
<li>Run the automatically invoked writing-plans</li>
<li>Review the generated plan.md</li>
<li>Choose the execution mode (subagent-driven recommended)</li>
<li>Implementation proceeds automatically (occasionally enter additional context when it gets stuck)</li>
<li>Apply the final review comments</li>
<li>Create the PR (after user approval)</li>
</ol>
<h2>6.3 Recommended Preparation</h2>
<ul>
<li><strong>Spell out the CLAUDE.md policy</strong>: if you write your push/PR automation policy in CLAUDE.md, the skill respects it (e.g., "DO NOT push without explicit consent").</li>
<li><strong>Code convention rules</strong>: if you write code-style/test conventions in <code>.claude/rules/*.md</code>, the reviewer subagent checks against them (e.g., <code>gofmt</code>, <code>testify/assert</code>, <code>GoDoc</code>, etc.).</li>
</ul>
<h2>6.4 Directory Structure (recap)</h2>
<pre><code>project/
├── docs/superpowers/
│   ├── specs/   # brainstorming output
│   └── plans/   # writing-plans output
├── code/
└── tests/
</code></pre>
<p>Commit the specs/plans together with the PR to leave them as a permanent reference. When you look back six months later, the answer to "why did I structure it this way" is right there.</p>
<h1>7. What I Felt After Actually Trying It (honest review)</h1>
<p>That's it for the guide-style information. This section is "observations from someone who actually ran one cycle." Please bear with a slightly informal tone.</p>
<h2>What I Liked</h2>
<ul>
<li><strong>The entire development flow connects naturally</strong>: previously, I had to manually write a PRD → implementation plan → todo.md separately and review markdown at each stage, which took quite a bit of time. Superpowers ties brainstorm → spec → plan → implementation → review into one flow, so each stage's output naturally becomes the input to the next, and the documentation and review cost the worker spends drops significantly</li>
<li><strong>Almost zero context contamination</strong>: when a task ends, the context is cleaned up along with the implementer subagent → the reason I could finish a large PR (25 commits) in one session</li>
<li><strong>The two-stage spec/quality review pulls PR review cost inside the cycle</strong>: critical issues that pass compilation/tests (Korean rune count, DueDate aliasing) and a regression gap (a Vitest e2e config bug) were all discovered and fixed mid-cycle</li>
</ul>
<h2>Costs and Trade-offs</h2>
<ul>
<li><strong>Token usage ramps up fast</strong>: each task accumulates 3 dispatches—implementer + spec/quality reviewer—so consumption is heavy. Even on the Claude Max 100x plan, I experienced hitting a rate limit mid-cycle</li>
<li><strong>Plan file checkboxes are not auto-updated</strong>: subagent-driven tracks via an in-memory task list, and plan.md stays <code>- [ ]</code> to the end. If you want to use the plan as a permanent tracker, executing-plans is a better fit</li>
</ul>
<h2>Recommended Application Scenarios</h2>
<p>In short: <strong>most powerful for multi-stage + multi-area work</strong>; for a single-file edit, a regular slash command is faster.</p>
<table>
<thead>
<tr>
<th>Fit</th>
<th>Work Type</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr>
<td>✅ Very good</td>
<td>multi-stage feature implementation, domain + API + UI all at once</td>
<td>phase separation keeps context clean, TDD applies naturally</td>
</tr>
<tr>
<td>✅ Good</td>
<td>redesign/refactoring</td>
<td>the two-stage review quickly catches regression risk</td>
</tr>
<tr>
<td>✅ Good</td>
<td>learning/onboarding material</td>
<td>the cycle itself becomes reproducible learning material</td>
</tr>
<tr>
<td>🟡 Average</td>
<td>small changes to one or two files</td>
<td>dispatch cost is large relative to value—controller-level handling is enough</td>
</tr>
<tr>
<td>❌ Unsuitable</td>
<td>one-line fix, simple typo</td>
<td>only formalism cost arises</td>
</tr>
<tr>
<td>❌ Unsuitable</td>
<td>deep debugging (debugger step-through)</td>
<td>superpowers does combine with debugging skills, but the cycle skeleton itself doesn't suit debugging</td>
</tr>
</tbody>
</table>
<h1>8. Conclusion</h1>
<p>Run one cycle of Superpowers and one thing becomes clear. <strong>The efficiency of AI coding is influenced more by workflow structure than by the quality of individual prompts.</strong> With the same model and the same code-writing ability, forcing the flow of brainstorm → plan → impl → review → finish substantially raises the consistency and regression stability of the output.</p>
<p>Three lines of key takeaways:</p>
<ol>
<li><strong>It's powerful for multi-stage work.</strong> The scenario of building Domain + API + UI all at once is the sweet spot. For a one-line fix, it's overkill.</li>
<li><strong>The two-stage review catches issues that were invisible.</strong> Even when compilation/tests pass, the promise between spec and code can be broken, and the reviewer verifies that.</li>
<li><strong>It's very good as learning/onboarding material.</strong> The cycle itself remains in spec/plan in a reproducible form.</li>
</ol>
<p>Things worth trying next:</p>
<ul>
<li>isolate multiple work with <code>using-git-worktrees</code></li>
<li>inline execution with <code>executing-plans</code> (auto-updating plan checkboxes)</li>
<li>formally invoke the <code>finishing-a-development-branch</code> skill to be presented with PR/cleanup options</li>
</ul>
<p>These three are the parts missing from this case, so I'll cover them separately in a follow-up article, or I'd love to hear the experiences of those who have tried them directly.</p>
<h1>9. References</h1>
<ul>
<li><a href="https://docs.claude.com/en/docs/claude-code">Anthropic Claude Code official docs</a></li>
<li><a href="https://www.anthropic.com/engineering/claude-code-plugins">Claude Code Plugin Marketplace guide</a></li>
<li>This case's PRs: <a href="https://github.com/kenshin579/tutorials-go/pull/701">tutorials-go #701 (full Todo implementation)</a>, <a href="https://github.com/kenshin579/tutorials-go/pull/702">#702 (FE theme redesign)</a></li>
<li><a href="/articles/claude-code-%ED%99%95%EC%9E%A5-%EA%B8%B0%EB%8A%A5-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-command-skill-subagent">Complete Guide to Claude Code Extensions: Command, Skill, Subagent</a></li>
<li><a href="/articles/claude-code-plugin-hooks-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C">Complete Guide to Claude Code Plugin Hooks</a></li>
<li><a href="/articles/claude-code-mcp-%EC%B6%94%EC%B2%9C-%EA%B0%80%EC%9D%B4%EB%93%9C">Claude Code MCP Recommendation Guide</a></li>
</ul>]]></content:encoded>
      <category>ai</category>
      <category>Claude Code</category>
      <category>Superpowers</category>
      <category>AI</category>
      <category>Skill</category>
      <category>Plugin</category>
      <category>Subagent</category>
      <category>MCP</category>
      <category>Playwright</category>
      <category>AI코딩도구</category>
      <category>워크플로우자동화</category>
      <category>TDD</category>
      <category>코드리뷰</category>
      <category>Anthropic</category>
      <category>Series: Claude Code Complete Guide</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Golang Concurrency Part 5 - The Complete Context Guide</title>
      <link>https://blog.advenoh.pe.kr/en/golang-concurrency-5-context-완벽-가이드</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/golang-concurrency-5-context-완벽-가이드</guid>
      <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
      <description>Covers how to use the Go context package's WithCancel, WithTimeout, WithDeadline, and WithValue, and context propagation patterns</description>
      <content:encoded><![CDATA[<p>When working with multiple goroutines in Go, there are questions you naturally run into. "When should I stop this goroutine?", "What if the request doesn't finish within 3 seconds?", "How do I pass the request ID down to lower-level functions?" The answer to all these questions is the <code>context</code> package.</p>
<h1>1. Introduction - Why the context Package Is Needed</h1>
<p>When a server handles an HTTP request, it's common for a single request to be distributed across multiple goroutines. Three core requirements arise here.</p>
<ul>
<li><strong>goroutine cancellation</strong>: when the client disconnects, all related goroutines must be cleaned up</li>
<li><strong>timeout management</strong>: you need to put a time limit on external API calls or DB queries</li>
<li><strong>passing request-scoped values</strong>: request ID, authentication info, etc. need to be passed along the function chain</li>
</ul>
<p>The <code>context</code> package unifies these three into a single interface.</p>
<pre><code class="language-go">type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() &#x3C;-chan struct{}
    Err() error
    Value(key any) any
}
</code></pre>
<ul>
<li><code>Done()</code>: returns a channel that closes when the context is canceled or expires</li>
<li><code>Err()</code>: returns the reason the Done channel closed (<code>Canceled</code> or <code>DeadlineExceeded</code>)</li>
<li><code>Deadline()</code>: returns the expiration time</li>
<li><code>Value()</code>: looks up a value stored in the context</li>
</ul>
<h1>2. context.Background() and context.TODO()</h1>
<p>There are two empty contexts that serve as the <strong>root</strong> of every context tree.</p>
<pre><code class="language-go">ctx := context.Background() // the general root context
ctx := context.TODO()       // when you don't yet know which context to use
</code></pre>
<ul>
<li><code>context.Background()</code>: used as the top-level context in the main function, initialization code, and tests</li>
<li><code>context.TODO()</code>: used temporarily when you haven't yet decided which context to pass. When you see a TODO in code review, it's a signal that "the context design here needs to be checked"</li>
</ul>
<p>In practice, you mostly use <code>context.Background()</code> as the root and wrap it with <code>WithCancel</code>, <code>WithTimeout</code>, <code>WithValue</code>, etc. to create derived contexts.</p>
<h1>3. WithCancel - Cancellation Propagation</h1>
<h2>3.1 Basic Usage</h2>
<p><code>context.WithCancel</code> returns a new context and a <code>cancel</code> function from a parent context. Calling <code>cancel()</code> closes the <code>Done()</code> channel and terminates the goroutine watching it.</p>
<pre><code class="language-go">func TestWithCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())

    var stopped atomic.Bool

    go func() {
        &#x3C;-ctx.Done() // wait until cancel() is called
        stopped.Store(true)
    }()

    cancel() // cancel explicitly
    time.Sleep(50 * time.Millisecond)

    assert.True(t, stopped.Load())
    assert.ErrorIs(t, ctx.Err(), context.Canceled)
}
</code></pre>
<p>When <code>cancel()</code> is called, <code>ctx.Done()</code> closes and the goroutine wakes up. <code>ctx.Err()</code> returns <code>context.Canceled</code>.</p>
<blockquote>
<p>If you're curious about the internal implementation of the <code>cancel()</code> function, see <a href="#9-faq">FAQ - cancel() internal implementation</a>.</p>
</blockquote>
<h2>3.2 Cancellation Chain (parent -> child)</h2>
<p>A context forms a tree structure. <strong>When a parent is canceled, all children are automatically canceled.</strong> Conversely, canceling a child does not affect the parent.</p>
<pre><code class="language-go">func TestCancelChain(t *testing.T) {
    parent, parentCancel := context.WithCancel(context.Background())
    child, childCancel := context.WithCancel(parent)
    defer childCancel()

    parentCancel() // cancel parent → child is canceled too
    time.Sleep(50 * time.Millisecond)

    assert.ErrorIs(t, parent.Err(), context.Canceled)
    assert.ErrorIs(t, child.Err(), context.Canceled)
}
</code></pre>
<p>Even though only <code>parentCancel()</code> was called, <code>child.Err()</code> also becomes <code>context.Canceled</code>. This is the essence of context propagation.</p>
<h2>3.3 Use in the worker Pattern</h2>
<p>This is the most commonly used pattern in practice. A worker goroutine watches <code>ctx.Done()</code> with <code>select</code> and terminates cleanly when a cancellation signal arrives.</p>
<pre><code class="language-go">func TestCancelWorker(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    results := make(chan int, 10)

    // worker: works until canceled
    go func() {
        defer close(results)
        for i := 0; ; i++ {
            select {
            case &#x3C;-ctx.Done():
                return
            case results &#x3C;- i:
                time.Sleep(10 * time.Millisecond)
            }
        }
    }()

    // cancel after 50ms
    time.Sleep(50 * time.Millisecond)
    cancel()

    var collected []int
    for v := range results {
        collected = append(collected, v)
    }

    t.Logf("collected values: %v", collected)
    assert.Greater(t, len(collected), 0)
}
</code></pre>
<p>The worker loops infinitely sending results to a channel, and when <code>ctx.Done()</code> closes, it exits via <code>return</code>. Since <code>defer close(results)</code> cleans up the channel too, <code>range results</code> terminates naturally.</p>
<h1>4. WithTimeout and WithDeadline</h1>
<h2>4.1 The Difference Between timeout and deadline</h2>
<p>Both cancel the context based on time, but the way they specify the time differs.</p>
<ul>
<li><code>WithTimeout(parent, duration)</code>: cancel after duration <strong>from now</strong></li>
<li><code>WithDeadline(parent, time)</code>: cancel at a <strong>specific time</strong></li>
</ul>
<p>Internally, <code>WithTimeout</code> calls <code>WithDeadline(parent, time.Now().Add(timeout))</code>. Ultimately it's the same mechanism.</p>
<pre><code class="language-go">func TestWithTimeout(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    select {
    case &#x3C;-time.After(200 * time.Millisecond):
        t.Fatal("should have timed out")
    case &#x3C;-ctx.Done():
        assert.ErrorIs(t, ctx.Err(), context.DeadlineExceeded)
    }
}
</code></pre>
<p>Since a 50ms timeout was set, <code>ctx.Done()</code> closes before the 200ms wait completes. When canceled by timeout, <code>ctx.Err()</code> returns <code>context.DeadlineExceeded</code>.</p>
<pre><code class="language-go">func TestWithDeadline(t *testing.T) {
    deadline := time.Now().Add(50 * time.Millisecond)
    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

    &#x3C;-ctx.Done()
    assert.ErrorIs(t, ctx.Err(), context.DeadlineExceeded)

    // check the expiration time with the Deadline() method
    dl, ok := ctx.Deadline()
    assert.True(t, ok)
    assert.Equal(t, deadline, dl)
}
</code></pre>
<p><code>WithDeadline</code> uses an absolute time. You can look up the set expiration time with the <code>Deadline()</code> method.</p>
<h2>4.2 Nested Timeout (when the inner one is shorter)</h2>
<p>When nesting timeouts, <strong>the shorter timeout always takes precedence</strong>. Even if the inner context expires first, the outer context is unaffected.</p>
<pre><code class="language-go">func TestNestedTimeout(t *testing.T) {
    outer, outerCancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
    defer outerCancel()

    inner, innerCancel := context.WithTimeout(outer, 50*time.Millisecond)
    defer innerCancel()

    &#x3C;-inner.Done()

    // inner times out first
    assert.ErrorIs(t, inner.Err(), context.DeadlineExceeded)
    // outer is still alive
    assert.NoError(t, outer.Err())
}
</code></pre>
<p>outer is 200ms, inner is 50ms. inner expires first, but outer is still valid. This pattern is like putting a shorter timeout on a DB query (inner) inside an HTTP handler (outer).</p>
<p>What if the inner's timeout is <strong>longer</strong> than the outer's? In that case too, if outer is canceled first, inner is canceled along with it. A parent's cancellation always propagates to its children.</p>
<h1>5. WithValue - Passing Request-Scoped Values</h1>
<h2>5.1 Basic Usage</h2>
<p><code>context.WithValue</code> stores a key-value pair in the context. It's mainly used to pass <strong>request-scoped</strong> data such as request IDs, authentication tokens, and tracing information.</p>
<pre><code class="language-go">func TestWithValue(t *testing.T) {
    ctx := context.Background()
    ctx = context.WithValue(ctx, userIDKey, "user-123")
    ctx = context.WithValue(ctx, requestIDKey, "req-456")

    // look up values
    userID := ctx.Value(userIDKey).(string)
    requestID := ctx.Value(requestIDKey).(string)

    assert.Equal(t, "user-123", userID)
    assert.Equal(t, "req-456", requestID)
}
</code></pre>
<p>Looking up a non-existent key returns <code>nil</code>. So it's safe to do a nil check before a type assertion.</p>
<h2>5.2 Preventing Collisions with a typed key</h2>
<p>If you use <code>string</code> directly as a context key, different packages might use the same string as a key, causing a collision. <strong>Defining a private type as the key</strong> prevents this problem.</p>
<pre><code class="language-go">// define type-safe context keys
type contextKey string

const (
    userIDKey    contextKey = "userID"
    requestIDKey contextKey = "requestID"
)
</code></pre>
<p><code>contextKey</code> is based on <code>string</code> but is a distinct type, so even if another package accesses it as <code>string("userID")</code>, the types differ and won't match. This is the standard pattern for preventing context key collisions in Go.</p>
<h2>5.3 value chain (looking up parent -> child values)</h2>
<p>A context value is <strong>searched toward the parent</strong> along the tree. When you look up a value in a child context, if it's not on the child, it checks the parent, and if not on the parent, its parent, in order.</p>
<pre><code class="language-go">func TestWithValueChain(t *testing.T) {
    parent := context.WithValue(context.Background(), userIDKey, "parent-user")
    child := context.WithValue(parent, requestIDKey, "child-req")

    // can access the parent's value from the child
    assert.Equal(t, "parent-user", child.Value(userIDKey))
    assert.Equal(t, "child-req", child.Value(requestIDKey))

    // cannot access the child's value from the parent
    assert.Nil(t, parent.Value(requestIDKey))
}
</code></pre>
<p>The child can access the parent's <code>userIDKey</code> value, but the parent cannot access the child's <code>requestIDKey</code> value. Value lookup always proceeds <strong>from bottom to top</strong> (from child toward parent).</p>
<h1>6. Context Propagation Patterns</h1>
<h2>6.1 parent cancellation -> all children canceled</h2>
<p>If you create multiple child goroutines from a single root context, you can clean up all goroutines at once just by canceling the root.</p>
<pre><code class="language-go">func TestPropagation(t *testing.T) {
    root, rootCancel := context.WithCancel(context.Background())

    var stopped atomic.Int64

    // create 3 child goroutines
    for i := range 3 {
        child, childCancel := context.WithCancel(root)
        defer childCancel()

        go func() {
            &#x3C;-child.Done()
            stopped.Add(1)
            t.Logf("child %d stopped", i)
        }()
    }

    rootCancel() // cancel root → all children canceled
    time.Sleep(50 * time.Millisecond)

    assert.Equal(t, int64(3), stopped.Load())
}
</code></pre>
<p>A single <code>rootCancel()</code> cleans up all 3 child goroutines. This is the power of context propagation. In an HTTP server, when a client disconnects, you can clean up all goroutines derived from that request with this pattern.</p>
<h2>6.2 Pass context as the function's first parameter (Go convention)</h2>
<p>There's a convention established in the Go community. <strong>Pass context as the function's first parameter, and use <code>ctx</code> as the variable name.</strong></p>
<pre><code class="language-go">// worker - a function that takes context as the first parameter (Go convention)
func worker(ctx context.Context, id int, results chan&#x3C;- string) {
    for {
        select {
        case &#x3C;-ctx.Done():
            results &#x3C;- fmt.Sprintf("worker %d: stopped", id)
            return
        case &#x3C;-time.After(10 * time.Millisecond):
            results &#x3C;- fmt.Sprintf("worker %d: working", id)
        }
    }
}
</code></pre>
<p>Using this worker function with a WaitGroup enables clean goroutine management.</p>
<pre><code class="language-go">func TestContextAsFirstParam(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    results := make(chan string, 100)

    var wg sync.WaitGroup
    wg.Add(2)
    go func() { defer wg.Done(); worker(ctx, 1, results) }()
    go func() { defer wg.Done(); worker(ctx, 2, results) }()

    // close the channel after all workers terminate
    go func() {
        wg.Wait()
        close(results)
    }()

    var messages []string
    for msg := range results {
        messages = append(messages, msg)
    }

    t.Logf("messages: %v", messages)
    assert.Greater(t, len(messages), 0)
}
</code></pre>
<p>It sets a 50ms limit with <code>WithTimeout</code>, waits for all workers to terminate with a WaitGroup, and then closes the channel. Combining context and WaitGroup lets you safely manage "parallel work with a time limit."</p>
<h1>7. Best Practices</h1>
<h2>7.1 Don't Abuse context.Value</h2>
<p><code>context.Value</code> is convenient, but it should not be used to replace function signatures.</p>
<pre><code class="language-go">// bad example: putting function parameters into context
ctx = context.WithValue(ctx, "dbConn", db)
ctx = context.WithValue(ctx, "logger", log)

// good example: use explicit parameters
func HandleRequest(ctx context.Context, db *sql.DB, log *slog.Logger) {
    // ...
}
</code></pre>
<p>Data suitable for context.Value is <strong>request-scoped</strong> and <strong>hard to put in a function signature</strong>. Typical examples are request IDs, tracing spans, and authentication tokens.</p>
<h2>7.2 Don't Store context in a struct</h2>
<pre><code class="language-go">// bad example
type Server struct {
    ctx context.Context // don't store in a struct
}

// good example: pass as a method parameter
func (s *Server) HandleRequest(ctx context.Context) {
    // ...
}
</code></pre>
<p>A context should exist together with the request's lifecycle. Storing it in a struct makes it ambiguous which request's context it is, and cancellation propagation may not work properly.</p>
<h2>7.3 Always Call cancel() with defer</h2>
<p>The cancel function returned by <code>WithCancel</code>, <code>WithTimeout</code>, and <code>WithDeadline</code> <strong>must be called</strong>. If you don't call it, context-related resources won't be released, causing a memory leak.</p>
<pre><code class="language-go">ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // always call with defer

// do work...
</code></pre>
<p>Even when the timeout expires and it's automatically canceled, it's safe to call <code>cancel()</code>. Calling <code>cancel()</code> on an already-canceled context does nothing (idempotent).</p>
<h1>8. Wrapping Up</h1>
<table>
<thead>
<tr>
<th>Function</th>
<th>Use</th>
<th>When Done closes</th>
<th>Err return value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>WithCancel</code></td>
<td>explicit cancellation</td>
<td>when <code>cancel()</code> is called</td>
<td><code>context.Canceled</code></td>
</tr>
<tr>
<td><code>WithTimeout</code></td>
<td>time limit (relative)</td>
<td>after duration elapses</td>
<td><code>context.DeadlineExceeded</code></td>
</tr>
<tr>
<td><code>WithDeadline</code></td>
<td>time limit (absolute)</td>
<td>when the deadline is reached</td>
<td><code>context.DeadlineExceeded</code></td>
</tr>
<tr>
<td><code>WithValue</code></td>
<td>passing values</td>
<td>(no cancellation feature)</td>
<td>-</td>
</tr>
</tbody>
</table>
<p>The core principles of the context package can be summarized as follows.</p>
<ul>
<li>pass <strong>context as the function's first parameter</strong></li>
<li><strong>call cancel() immediately with defer</strong></li>
<li><strong>a parent's cancellation propagates to all children</strong></li>
<li><strong>context.Value stores only request-scoped data</strong></li>
<li><strong>pass context as a parameter, not in a struct</strong></li>
</ul>
<p>context is the core tool for managing the goroutine lifecycle in Go. It's especially essential in server programming when implementing request handling, timeout management, and graceful shutdown.</p>
<h1>9. FAQ</h1>
<h3>Q. How is the cancel() function implemented internally?</h3>
<p>The <code>cancel()</code> function returned by <code>context.WithCancel</code> is defined on the <code>cancelCtx</code> struct in the Go standard library (<code>go/src/context/context.go</code>).</p>
<pre><code class="language-go">// cancelCtx - the internal struct of a cancelable context
type cancelCtx struct {
    Context                        // embeds the parent context
    mu       sync.Mutex            // mutex to protect concurrent access
    done     atomic.Value          // stores chan struct{}, lazily created on first call
    children map[canceler]struct{} // list of child contexts (for cancellation propagation)
    err      error                 // cancellation reason (Canceled or DeadlineExceeded)
    cause    error                 // Go 1.20+ cause chain
}

// cancel - the actual cancellation logic
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
    c.mu.Lock()

    if c.err != nil {
        c.mu.Unlock()
        return // ignore if already canceled (guarantees idempotency)
    }

    c.err = err     // set context.Canceled
    c.cause = cause

    // close the done channel to wake goroutines waiting on &#x3C;-ctx.Done()
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan) // if not yet created, store a pre-closed channel
    } else {
        close(d) // close the existing channel → all receivers wake up immediately
    }

    // recursively cancel all child contexts too
    for child := range c.children {
        child.cancel(false, err, cause)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c) // remove itself from the parent's children map
    }
}
</code></pre>
<p>The core behavior can be summarized as follows.</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Behavior</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>idempotency check</td>
<td>safe to call <code>cancel()</code> multiple times (only the first runs)</td>
</tr>
<tr>
<td>2</td>
<td>set <code>err</code></td>
<td>set it so <code>ctx.Err()</code> returns <code>context.Canceled</code></td>
</tr>
<tr>
<td>3</td>
<td>close the <code>done</code> channel</td>
<td><code>close(d)</code> wakes all goroutines waiting on <code>&#x3C;-ctx.Done()</code></td>
</tr>
<tr>
<td>4</td>
<td>propagate cancellation to children</td>
<td>iterate the <code>children</code> map and recursively cancel all children</td>
</tr>
<tr>
<td>5</td>
<td>remove from parent</td>
<td>remove itself from the parent's <code>children</code> map to free memory</td>
</tr>
</tbody>
</table>
<p><code>close(d)</code> is the key — in Go, a <strong>closed channel immediately returns the zero value</strong>, so all goroutines waiting on <code>&#x3C;-ctx.Done()</code> wake up simultaneously. This is why context's cancellation propagation is efficient.</p>
<h3>Q. How should I set the context timeout when calling a DB / external API from an API handler?</h3>
<p>When an API handler calls a DB query or an external API, if a problem occurs at any layer, the entire request can stall. To prevent this, you need to set context timeouts appropriately.</p>
<p><strong>Method 1: a single timeout for the entire request</strong></p>
<pre><code class="language-go">// resty client (reused at the package level)
var client = resty.New()

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) // 5-second limit for the whole thing
    defer cancel()

    // DB query — interrupted when ctx is canceled
    user, err := db.GetUser(ctx, userID)
    if err != nil { return }

    // external API call — pass the same ctx to resty
    var profile Profile
    _, err = client.R().
        SetContext(ctx).           // propagate the context
        SetResult(&#x26;profile).
        Get(fmt.Sprintf("https://api.example.com/profile/%s", user.Email))
    if err != nil { return }
}
</code></pre>
<p>It's simple, but it has the problem that if the DB takes 4.5 seconds, the external API has only 0.5 seconds.</p>
<p><strong>Method 2: per-layer individual timeouts (recommended)</strong></p>
<pre><code class="language-go">func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // the parent context

    // 2-second limit for the DB
    dbCtx, dbCancel := context.WithTimeout(ctx, 2*time.Second)
    defer dbCancel()
    user, err := db.GetUser(dbCtx, userID)
    if err != nil { return }

    // 3-second limit for the external API — pass an individual context to resty
    apiCtx, apiCancel := context.WithTimeout(ctx, 3*time.Second)
    defer apiCancel()

    var profile Profile
    _, err = client.R().
        SetContext(apiCtx).        // per-layer timeout context
        SetResult(&#x26;profile).
        Get(fmt.Sprintf("https://api.example.com/profile/%s", user.Email))
    if err != nil { return }
}
</code></pre>
<p>By giving each layer an appropriate timeout, a slowdown in one doesn't affect the other layers. resty's <code>SetContext()</code> internally calls <code>http.Request.WithContext()</code>, so when a timeout or cancel occurs, <strong>even the TCP connection is interrupted immediately</strong>.</p>
<p><strong>Method 3: parallel calls + errgroup (when the calls are independent)</strong></p>
<p>If the DB and external API are independent of each other, you can call them in parallel and cancel the rest if any one fails.</p>
<pre><code class="language-go">func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    g, gCtx := errgroup.WithContext(ctx) // any error → the rest are automatically canceled

    var user *User
    var profile Profile

    g.Go(func() error {
        var err error
        user, err = db.GetUser(gCtx, userID) // use gCtx
        return err
    })

    g.Go(func() error {
        _, err := client.R().
            SetContext(gCtx).      // errgroup's context → auto-canceled when another goroutine fails
            SetResult(&#x26;profile).
            Get(fmt.Sprintf("https://api.example.com/profile/%s", email))
        return err
    })

    if err := g.Wait(); err != nil { return }
}
</code></pre>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>When to use</th>
</tr>
</thead>
<tbody>
<tr>
<td>whole-request timeout</td>
<td>simple APIs, when there are 1-2 layers</td>
</tr>
<tr>
<td>per-layer timeout</td>
<td>when the DB/external API have different allowed times (most recommended)</td>
</tr>
<tr>
<td>errgroup + context</td>
<td>parallel independent calls, cancel the rest if one fails</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>How resty's <code>SetContext()</code> works internally</strong>: when you call <code>SetContext(ctx)</code>, resty internally uses the Go standard library's <code>http.Request.WithContext(ctx)</code>. This context propagates down to the <code>roundTrip()</code> step of <code>net/http.Transport</code>, so when the context is canceled, it's immediately interrupted at every step of the HTTP request — <strong>DNS lookup, TCP connection, TLS handshake, response reading</strong>. Therefore, as long as you pass <code>SetContext(ctx)</code> correctly, unnecessary network resources are immediately released on timeout or cancel.</p>
<pre><code class="language-go">// inside resty (simplified)
func (r *Request) Execute(method, url string) (*Response, error) {
    // 1. create an http.Request
    req, _ := http.NewRequestWithContext(r.ctx, method, url, body)

    // 2. call http.Client.Do() — immediately interrupted when the context is canceled
    resp, err := r.client.httpClient.Do(req)
    // returns a context.DeadlineExceeded or context.Canceled error
}
</code></pre>
</blockquote>
<h3>Q. How do I manage timeouts in the Echo framework?</h3>
<p>Echo has a built-in <code>TimeoutMiddleware</code>, so you can automatically apply a timeout to all handlers.</p>
<pre><code class="language-go">import "github.com/labstack/echo/v4/middleware"

e := echo.New()

// apply a 5-second timeout to all handlers
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
    Timeout: 5 * time.Second,
}))
</code></pre>
<p>In a handler, use the timeout context set by the middleware via <code>c.Request().Context()</code>.</p>
<pre><code class="language-go">func getUser(c echo.Context) error {
    ctx := c.Request().Context() // the timeout context set by the middleware

    user, err := db.GetUser(ctx, id)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return echo.NewHTTPError(http.StatusGatewayTimeout, "request timed out")
        }
        return err
    }
    return c.JSON(http.StatusOK, user)
}
</code></pre>
<p>You can also set a different timeout per route.</p>
<pre><code class="language-go">// default 5 seconds
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
    Timeout: 5 * time.Second,
}))

// 30 seconds only for the file upload route
uploadGroup := e.Group("/upload")
uploadGroup.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
    Timeout: 30 * time.Second,
}))
</code></pre>
<p>The middleware is the <strong>upper limit for the entire request</strong>, while per-layer granularity (DB 2 seconds, external API 3 seconds) is, as a practical pattern, set directly inside the handler with <code>context.WithTimeout</code>.</p>
<h1>10. References</h1>
<ul>
<li>Example code: <a href="https://github.com/kenshin579/tutorials-go/tree/master/golang/concurrency/context">tutorials-go/golang/concurrency/context</a></li>
<li><a href="https://pkg.go.dev/context">Go official docs - context package</a></li>
<li><a href="https://go.dev/blog/context-and-structs">Go Blog - Contexts and structs</a></li>
<li><a href="https://go.dev/blog/context">Go Concurrency Patterns: Context</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>concurrency</category>
      <category>context</category>
      <category>timeout</category>
      <category>cancel</category>
      <category>Series: Golang Concurrency</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Golang Concurrency Part 4 - The Complete sync Package Guide</title>
      <link>https://blog.advenoh.pe.kr/en/golang-concurrency-4-sync-패키지</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/golang-concurrency-4-sync-패키지</guid>
      <pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate>
      <description>Covers how to use WaitGroup, Mutex, RWMutex, Once, and sync.Map from Go's sync package, and how to resolve race conditions</description>
      <content:encoded><![CDATA[<p>Go recommends communication through channels, but channels aren't optimal in every situation. For simple protection of shared memory, the synchronization primitives in the <code>sync</code> package are more intuitive and efficient.</p>
<p>In this part, we cover how to use the core tools of the <code>sync</code> package — WaitGroup, Mutex, RWMutex, Once, and sync.Map — and when to apply each.</p>
<h1>1. Why Do We Need Synchronization?</h1>
<p>When multiple goroutines <strong>access the same variable simultaneously</strong>, a <strong>race condition</strong> occurs. A race condition is the phenomenon where, when two or more goroutines access a shared resource at the same time, the result varies depending on the execution order. It creates non-deterministic bugs where the program sometimes works correctly and sometimes produces wrong results.</p>
<pre><code class="language-go">// dangerous code: accessing a shared variable without protection
counter := 0
for range 1000 {
    go func() {
        counter++ // Data Race! values are lost when multiple goroutines read and write simultaneously
    }()
}
// expected: 1000, actual: different every time (e.g. 937, 982, 1000...)
</code></pre>
<p><code>counter++</code> is actually a 3-step operation: "read → increment → write." When multiple goroutines run it simultaneously, values are lost. For example, if two goroutines read the counter value 5 at the same time and each write 6, it incremented twice but the result is 6.</p>
<blockquote>
<p>Running with <code>go test -race</code> or <code>go run -race</code> lets Go's Race Detector detect data races. Always perform race checks on production code.</p>
</blockquote>
<h1>2. sync.WaitGroup</h1>
<p>The most basic tool for <strong>waiting on the completion</strong> of multiple goroutines. It maintains a counter internally, incrementing it with <code>Add()</code> and decrementing it with <code>Done()</code>. <code>Wait()</code> blocks until the counter reaches 0.</p>
<p>You can also wait for goroutine completion with a channel, but for simply "waiting until all N goroutines finish," WaitGroup is far more concise.</p>
<pre><code class="language-go">var wg sync.WaitGroup

for i := range 5 {
    wg.Add(1)        // counter +1: must be called before starting the goroutine
    go func() {
        defer wg.Done() // counter -1: called when the goroutine completes (defer makes it panic-safe)
        // do work
    }()
}

wg.Wait() // block until the counter reaches 0 (wait for all goroutines to complete)
</code></pre>
<p><strong>Key rule</strong>: <code>wg.Add()</code> must be called <strong>before</strong> the <code>go</code> statement. If you Add inside the goroutine, <code>Wait()</code> may run first.</p>
<blockquote>
<p>WaitGroup is reusable. However, you must call <code>Add()</code> again after <code>Wait()</code> returns. Calling <code>Add()</code> while <code>Wait()</code> is running causes a panic.</p>
</blockquote>
<h1>3. sync.Mutex</h1>
<p>Guarantees <strong>mutual exclusion</strong>. Only one goroutine can access the critical section at a time. Only the goroutine that called <code>Lock()</code> monopolizes that section until it calls <code>Unlock()</code>. Other goroutines, when they call <code>Lock()</code>, wait until the existing goroutine calls <code>Unlock()</code>.</p>
<p>Bundling a Mutex into a struct together with the data it protects is Go's idiomatic pattern. This makes it clear in the code "which lock protects which data."</p>
<pre><code class="language-go">func TestMutexCriticalSection(t *testing.T) {
    // bundling the Mutex and the data it protects into a single struct is Go's idiomatic pattern
    type SafeCounter struct {
        mu sync.Mutex
        v  map[string]int
    }

    c := SafeCounter{v: make(map[string]int)}
    var wg sync.WaitGroup

    for range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.mu.Lock()   // enter the critical section: other goroutines wait here
            c.v["key"]++  // protected section: only one goroutine runs at a time
            c.mu.Unlock() // release the critical section: a waiting goroutine can enter
        }()
    }

    wg.Wait()
    assert.Equal(t, 100, c.v["key"]) // exactly 100 thanks to Mutex protection
}
</code></pre>
<p><strong>Best Practice</strong>: using <code>defer mu.Unlock()</code> releases the lock even if a panic occurs. It's especially useful when the critical section is long or there are multiple return points.</p>
<blockquote>
<p>A Mutex must not be copied. When passing it to a function, always pass a <strong>pointer</strong>. <code>go vet</code> detects Mutex copies.</p>
</blockquote>
<h1>4. sync.RWMutex</h1>
<p>A Mutex forces all access — read or write — to run one at a time without distinction. But read operations don't change data, so multiple goroutines can safely read simultaneously. <strong>RWMutex</strong> leverages this to greatly improve read performance.</p>
<p>A <strong>read lock (RLock)</strong> can be <strong>acquired simultaneously</strong> by multiple goroutines. The write lock (Lock) is exclusive. While a write is in progress, all reads/writes wait; while a read is in progress, only writes wait.</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Another RLock</th>
<th>Another Lock</th>
</tr>
</thead>
<tbody>
<tr>
<td>RLock (read)</td>
<td>allowed</td>
<td>wait</td>
</tr>
<tr>
<td>Lock (write)</td>
<td>wait</td>
<td>wait</td>
</tr>
</tbody>
</table>
<pre><code class="language-go">var rwmu sync.RWMutex

// multiple readers can run simultaneously — improves read performance
rwmu.RLock()        // acquire read lock: other readers can also acquire simultaneously
_ = data["key"]     // read data (no modification)
rwmu.RUnlock()      // release read lock

// the writer is exclusive — both reads and writes block
rwmu.Lock()             // acquire write lock: all readers/writers wait
data["key"] = "updated" // modify data
rwmu.Unlock()           // release write lock
</code></pre>
<p><strong>When reads are frequent and writes are rare</strong>, RWMutex performs better than Mutex. For example, it's a good fit for scenarios like caches, config lookups, and read APIs where reads are 90%+ and writes are 10% or less.</p>
<blockquote>
<p>If writes are frequent, RWMutex may actually be slower than a plain Mutex due to its internal overhead. Choose after verifying with a benchmark.</p>
</blockquote>
<h1>5. sync.Once</h1>
<p>Guarantees that a function runs <strong>exactly once</strong>. Even if multiple goroutines call <code>once.Do()</code> simultaneously, the passed function runs <strong>exactly once</strong>, and the remaining goroutines wait until execution completes.</p>
<p>It's most useful when you want to <strong>lazily</strong> perform an <strong>expensive initialization</strong> (DB connection pool initialization, config file loading, etc.) while making it goroutine-safe. Unlike an <code>init()</code> function, the initialization runs at the point it's actually needed.</p>
<pre><code class="language-go">func TestOnceSingleton(t *testing.T) {
    type Config struct {
        DBHost string
        DBPort int
    }

    var (
        instance *Config   // the singleton instance to be initialized only once
        once     sync.Once // guarantees the initialization function runs exactly once
    )

    getConfig := func() *Config {
        once.Do(func() { // no matter how many times this is called, it runs exactly once
            instance = &#x26;Config{
                DBHost: "localhost",
                DBPort: 5432,
            }
        })
        return instance // after initialization, immediately return the cached instance
    }

    // returns the same instance even when called simultaneously from multiple goroutines
    var wg sync.WaitGroup
    results := make([]*Config, 10)

    for i := range 10 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            results[i] = getConfig() // 10 goroutines call simultaneously
        }()
    }

    wg.Wait()

    for i := 1; i &#x3C; len(results); i++ {
        assert.Same(t, results[0], results[i]) // all the same pointer — initialization ran only once
    }
}
</code></pre>
<blockquote>
<p>Even if a panic occurs inside <code>once.Do()</code>, it is considered "executed once." The function won't run again on subsequent calls, so be careful with error handling inside the initialization function.</p>
</blockquote>
<h1>6. sync.Map</h1>
<p>Go's regular <code>map</code> is not concurrent-safe. If multiple goroutines read and write a map simultaneously, a <code>fatal error: concurrent map writes</code> runtime panic occurs. <code>sync.Map</code> uses a lock-free algorithm and a read-only cache internally, enabling concurrent access without a separate lock.</p>
<p><code>sync.Map</code> does not support generics, so it operates with the <code>any</code> type. A downside is that you need a type assertion when retrieving values.</p>
<pre><code class="language-go">var m sync.Map

m.Store("key", "value")                   // store: add a key-value pair
val, ok := m.Load("key")                  // load: returns the value and whether it exists
m.Delete("key")                            // delete: remove a key
actual, loaded := m.LoadOrStore("k", "v")  // store if absent, return the existing value if present

// iterate: run a function for every key-value
m.Range(func(key, value any) bool {
    fmt.Println(key, value)
    return true // true: continue to the next item, false: stop iteration
})
</code></pre>
<p>So which should you choose between a regular <code>map + Mutex</code> and <code>sync.Map</code>?</p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Recommended</th>
</tr>
</thead>
<tbody>
<tr>
<td>keys are stable and reads dominate</td>
<td><code>sync.Map</code> (faster)</td>
</tr>
<tr>
<td>writes are frequent or keys keep changing</td>
<td><code>map + RWMutex</code> (more efficient)</td>
</tr>
<tr>
<td>key type is fixed and type safety is needed</td>
<td><code>map + RWMutex</code> (use generics)</td>
</tr>
</tbody>
</table>
<blockquote>
<p>In most cases, the <code>map + RWMutex</code> combination is more general-purpose. <code>sync.Map</code> shines in cache scenarios where keys are rarely changed once set.</p>
</blockquote>
<h1>7. Wrapping Up</h1>
<p>In this part, we looked at the core synchronization primitives provided by Go's <code>sync</code> package. Each tool has its own purpose, and it's important to choose the right one for the situation.</p>
<table>
<thead>
<tr>
<th>Primitive</th>
<th>Use</th>
<th>Core</th>
</tr>
</thead>
<tbody>
<tr>
<td>WaitGroup</td>
<td>wait for goroutine completion</td>
<td>Add → go → Done → Wait</td>
</tr>
<tr>
<td>Mutex</td>
<td>mutual exclusion</td>
<td>Lock/Unlock, use of defer recommended</td>
</tr>
<tr>
<td>RWMutex</td>
<td>read concurrency</td>
<td>better than Mutex when reads are frequent</td>
</tr>
<tr>
<td>Once</td>
<td>run only once</td>
<td>Singleton, suitable for initialization</td>
</tr>
<tr>
<td>sync.Map</td>
<td>concurrent-safe map</td>
<td>efficient for read-heavy workloads</td>
</tr>
</tbody>
</table>
<p>So which should you choose between Channel and Mutex? The official Go Wiki recommends "Channel for transferring ownership of data, Mutex for protecting the internal state of a struct." Refer to the table below to choose the right tool for the situation.</p>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Mutex</th>
</tr>
</thead>
<tbody>
<tr>
<td>transferring ownership of data</td>
<td>protecting simple shared state</td>
</tr>
<tr>
<td>communication between goroutines</td>
<td>caches, counters</td>
</tr>
<tr>
<td>pipelines, fan-in/out</td>
<td>protecting a struct's fields</td>
</tr>
<tr>
<td>complex synchronization patterns</td>
<td>simple critical sections</td>
</tr>
</tbody>
</table>
<p>In practice, rather than sticking to a single tool, it's better to combine them to fit the situation. For example, the pattern of waiting for goroutine completion with WaitGroup while protecting shared data with Mutex is very common. The important thing is to make a habit of always verifying data races with <code>go test -race</code>.</p>
<p>In the next part, we'll cover the <strong>Context package</strong>, the core tool for managing the goroutine lifecycle.</p>
<h1>8. References</h1>
<ul>
<li><a href="https://go.dev/blog/codelab-share">Go Blog - Share Memory By Communicating</a></li>
<li><a href="https://pkg.go.dev/sync">sync package docs</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>concurrency</category>
      <category>sync</category>
      <category>mutex</category>
      <category>rwmutex</category>
      <category>waitgroup</category>
      <category>once</category>
      <category>race-condition</category>
      <category>고랭</category>
      <category>동시성</category>
      <category>동기화</category>
      <category>Series: Golang Concurrency</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>The Complete Terraform Guide: From Basic Concepts to GitOps in Practice</title>
      <link>https://blog.advenoh.pe.kr/en/terraform-완벽-가이드-기본-개념부터-gitops-실전까지</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/terraform-완벽-가이드-기본-개념부터-gitops-실전까지</guid>
      <pubDate>Tue, 14 Apr 2026 00:00:00 GMT</pubDate>
      <description>Learn Terraform's basic concepts, architecture, and usage, then walk through a hands-on exercise deploying a Kind cluster and ArgoCD. Also introduces how to manage infrastructure efficiently with Terraform + ArgoCD + Helm.</description>
      <content:encoded><![CDATA[<h1>1. Introduction</h1>
<p><img src="https://blog.advenoh.pe.kr/images/cloud/terraform-완벽-가이드-기본-개념부터-gitops-실전까지/cover.png" alt="Terraform 완벽 가이드"></p>
<p>When managing infrastructure, you often run into the question: "Who changed this server setting, when, and why?" You click around in the console manually, type commands into the CLI, and try to document the process, but eventually a gap appears between the actual state and the documentation.</p>
<p><strong>IaC (Infrastructure as Code)</strong> solves this problem. When you define infrastructure as code:</p>
<ul>
<li><strong>Version control</strong>: You can track change history with Git</li>
<li><strong>Reproducibility</strong>: Running the same code produces the same environment</li>
<li><strong>Code review</strong>: Infrastructure changes can also be reviewed via PRs</li>
<li><strong>Automation</strong>: It can be integrated into CI/CD pipelines</li>
</ul>
<h2>1.1 Why Terraform</h2>
<p>There are several IaC tools available.</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Characteristics</th>
<th>Approach</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Terraform</strong></td>
<td>Multi-cloud, declarative, large ecosystem</td>
<td>Declarative</td>
</tr>
<tr>
<td>Pulumi</td>
<td>Uses general-purpose programming languages (Go, Python, etc.)</td>
<td>Imperative</td>
</tr>
<tr>
<td>CloudFormation</td>
<td>AWS-only, deep AWS integration</td>
<td>Declarative</td>
</tr>
<tr>
<td>Ansible</td>
<td>Configuration-management focused, agentless</td>
<td>Imperative/Declarative</td>
</tr>
</tbody>
</table>
<p>The reasons for choosing Terraform are as follows:</p>
<ul>
<li><strong>Declarative approach</strong>: You define "what" you want rather than "how." Terraform calculates the difference between the current state and the desired state and applies it</li>
<li><strong>Provider ecosystem</strong>: There are thousands of Providers, including AWS, GCP, and Azure, as well as Kubernetes, Helm, Docker, GitHub, and more</li>
<li><strong>Plan feature</strong>: You can preview what changes will occur before actually applying them</li>
<li><strong>Community</strong>: As the most widely used IaC tool, it has abundant resources and modules</li>
</ul>
<blockquote>
<p>In this article, after understanding Terraform's basic concepts, we will walk through a hands-on exercise of deploying ArgoCD to a Kind cluster. Finally, I introduce the Terraform + ArgoCD + Helm combination that I actually use.</p>
<p>The full source code is available on <a href="https://github.com/kenshin579/tutorials-go/tree/main/cloud/terraform">GitHub</a>.</p>
</blockquote>
<h1>2. Terraform Basic Concepts</h1>
<h2>2.1 HCL (HashiCorp Configuration Language)</h2>
<p>Terraform uses its own configuration language called <strong>HCL</strong>. It is easier to read than JSON and has clearer typing than YAML.</p>
<pre><code class="language-hcl"># Basic syntax structure
resource "resource_type" "name" {
  attribute1 = "value"
  attribute2 = 123

  nested_block {
    attribute3 = true
  }
}
</code></pre>
<p>Key characteristics of HCL:</p>
<ul>
<li><strong>Block-based</strong>: Organized into block units wrapped in <code>{}</code></li>
<li><strong>Attribute assignment</strong>: Values are assigned with <code>=</code></li>
<li><strong>Comments</strong>: Use <code>#</code> or <code>//</code></li>
<li><strong>String interpolation</strong>: You can reference variables like <code>"${var.name}"</code> (for simple references, <code>var.name</code> is sufficient)</li>
</ul>
<h2>2.2 Core Components</h2>
<p>The core components of Terraform can be summarized as follows. The order below is arranged based on dependencies and learning flow. You need a Provider to create Resources, you configure Resources flexibly with Variables/Outputs, you query existing resources with Data Sources, and you bundle all of this into reusable units with Modules.</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph "Terraform Components"
        P[Provider] --> R[Resource]
        P --> D[Data Source]
        V[Variable] --> R
        R --> O[Output]
        R --> M[Module]
    end
</code></pre>
<h3>2.2.1 Provider</h3>
<p>A <strong>Provider</strong> is a plugin that allows Terraform to communicate with external services. There is a Provider for each service, such as AWS, Kubernetes, and Helm.
A Provider is the starting point for everything—without a Provider, you cannot create Resources. When you run <code>terraform init</code>, the declared Providers are downloaded automatically, and by pinning versions, the entire team can work in an identical environment.</p>
<pre><code class="language-hcl"># Provider declaration
terraform {
  required_providers {
    kind = {
      source  = "tehcyx/kind"    # Provider source
      version = "0.8"            # Version constraint
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "2.36"
    }
  }
}

# Provider configuration
provider "kubernetes" {
  config_path = kind_cluster.local_cluster.kubeconfig_path
}
</code></pre>
<h3>2.2.2 Resource</h3>
<p>A <strong>Resource</strong> is an actual infrastructure object that Terraform creates and manages. It is declared in the form <code>resource "type" "name"</code>.
Any infrastructure supported by a Provider—servers, networks, databases, and so on—can be defined as a Resource, and Terraform manages the entire lifecycle from creation to modification to deletion.</p>
<pre><code class="language-hcl"># Kind cluster resource declaration
resource "kind_cluster" "local_cluster" {
  name           = var.kind_cluster_name
  wait_for_ready = true
  node_image     = "kindest/node:v1.28.15"
}

# Kubernetes Namespace resource declaration
resource "kubernetes_namespace" "study" {
  depends_on = [kind_cluster.local_cluster]
  metadata {
    name = var.study_namespace
  }
}
</code></pre>
<ul>
<li><code>kind_cluster.local_cluster</code>: A unique identifier combining the resource type and name</li>
<li><code>depends_on</code>: An explicit dependency declaration between resources (in this case, the cluster must be created first)</li>
</ul>
<h3>2.2.3 Variable and Output</h3>
<p><strong>Variable</strong> allows configuration values to be injected from the outside, and <strong>Output</strong> outputs the generated results.
Using Variables, you can apply different values per environment (dev/staging/prod), and Output is useful when other modules or scripts need to reference information about created resources.</p>
<pre><code class="language-hcl"># Variable: input value definition
variable "kind_cluster_name" {
  description = "Kind cluster name"
  type        = string
  default     = "terraform-study-cluster"
}

# Output: result value output
output "kubeconfig_path" {
  description = "Path to the Kind cluster's kubeconfig file"
  value       = kind_cluster.local_cluster.kubeconfig_path
}
</code></pre>
<p>How to override a Variable value:</p>
<pre><code class="language-bash"># CLI argument
terraform apply -var="kind_cluster_name=my-cluster"

# Environment variable
export TF_VAR_kind_cluster_name="my-cluster"

# terraform.tfvars file
kind_cluster_name = "my-cluster"
</code></pre>
<h3>2.2.4 Data Source</h3>
<p>A <strong>Data Source</strong> reads information about resources that already exist outside of Terraform. If a Resource is about "creating," a Data Source is about "querying."
For example, you can fetch information about a VPC or Namespace that was already created manually and reference it from other resources, naturally connecting existing infrastructure with new resources.</p>
<pre><code class="language-hcl"># Query information about an existing Namespace
data "kubernetes_namespace" "default" {
  metadata {
    name = "default"
  }
}

# Use the queried information
resource "kubernetes_config_map" "example" {
  metadata {
    namespace = data.kubernetes_namespace.default.metadata[0].name
  }
}
</code></pre>
<h3>2.2.5 Module</h3>
<p>A <strong>Module</strong> bundles related resources into a single reusable package. By separating them into directory units, it improves code organization and reusability.
You can use community modules published on the Terraform Registry, or you can turn common in-house infrastructure patterns into modules and use them consistently across multiple projects.</p>
<pre><code class="language-hcl"># Module call (main.tf)
module "infra" {
  source          = "./modules/infra"            # Module path
  study_namespace = kubernetes_namespace.study.metadata[0].name  # Pass variable

  depends_on = [kubernetes_namespace.study]
}
</code></pre>
<pre><code class="language-hcl"># Inside the module (modules/infra/infra.tf)
# - Create resources using the passed-in variables
resource "helm_release" "argocd" {
  name       = "argocd"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  version    = "7.8.28"
  namespace  = kubernetes_namespace.argocd.metadata[0].name
  # ...
}
</code></pre>
<h2>2.3 State (State Management)</h2>
<p>Terraform records the current state of infrastructure in a <strong>State file</strong> (<code>terraform.tfstate</code>). This file is the heart of Terraform.</p>
<pre><code class="language-mermaid">flowchart LR
    A[".tf files\n(desired state)"] --> C{Terraform}
    B["terraform.tfstate\n(current state)"] --> C
    C --> D["Plan\n(change plan)"]
    D --> E["Apply\n(actual changes)"]
    E --> B
</code></pre>
<ul>
<li><strong>During Plan</strong>: Compares the <code>.tf</code> files (desired state) and the State file (current state) to calculate the differences</li>
<li><strong>During Apply</strong>: Applies the calculated changes to the actual infrastructure and updates the State file</li>
</ul>
<p>State management methods:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Description</th>
<th>Suitable Environment</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Local</strong></td>
<td>Stored as a local file (default)</td>
<td>Personal projects, learning</td>
</tr>
<tr>
<td><strong>Remote</strong></td>
<td>Remote storage such as S3, GCS, etc.</td>
<td>Team collaboration, production</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>Note</strong>: The State file may contain sensitive information such as passwords and API keys. Be sure to add it to <code>.gitignore</code>, and use a Remote Backend in team environments.</p>
</blockquote>
<h1>3. Architecture</h1>
<h2>3.1 How Terraform Works</h2>
<p>The Terraform workflow consists of four stages.</p>
<pre><code class="language-mermaid">flowchart LR
    A["**Init**\nDownload Providers\nCreate .terraform/"] --> B["**Plan**\nPreview changes\n+ create, ~ modify, - delete"]
    B --> C["**Apply**\nApply to real infrastructure\nUpdate State"]
    C --> D["**Destroy**\nDelete all resources\nReset State"]

    style A fill:#e1f5fe
    style B fill:#fff3e0
    style C fill:#e8f5e9
    style D fill:#ffebee
</code></pre>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Command</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Init</strong></td>
<td><code>terraform init</code></td>
<td>Download Provider plugins, create the <code>.terraform/</code> directory</td>
</tr>
<tr>
<td><strong>Plan</strong></td>
<td><code>terraform plan</code></td>
<td>Compare the current state with the code and output a change plan</td>
</tr>
<tr>
<td><strong>Apply</strong></td>
<td><code>terraform apply</code></td>
<td>Apply the changes from the Plan to the actual infrastructure</td>
</tr>
<tr>
<td><strong>Destroy</strong></td>
<td><code>terraform destroy</code></td>
<td>Delete all resources created with Terraform</td>
</tr>
</tbody>
</table>
<h2>3.2 Provider Plugin Architecture</h2>
<p>Terraform Core is responsible for HCL parsing and state management, while the actual infrastructure manipulation is performed by Provider Plugins.</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph "Terraform Core"
        A[HCL Parser] --> B[Dependency Graph]
        B --> C[State Management]
    end

    subgraph "Provider Plugins (gRPC)"
        D[Kind Provider]
        E[Kubernetes Provider]
        F[Helm Provider]
    end

    C --> D
    C --> E
    C --> F

    D --> G[Docker - Kind cluster]
    E --> H[K8s API Server]
    F --> I[Helm chart deployment]
</code></pre>
<ul>
<li>Providers are separate binaries that communicate with Terraform Core via gRPC</li>
<li>When you run <code>terraform init</code>, the required Providers are downloaded automatically</li>
<li>By pinning Provider versions, reproducible builds are guaranteed</li>
</ul>
<h2>3.3 Dependency Graph</h2>
<p>Terraform manages dependency relationships between resources as a <strong>DAG (Directed Acyclic Graph)</strong>. Resources without dependencies are created in parallel, which speeds things up.</p>
<pre><code class="language-mermaid">flowchart TD
    A[kind_cluster.local_cluster] --> B[kubernetes_namespace.study]
    A --> C[Provider initialization\nkubernetes, helm]
    B --> D[null_resource.set_default_namespace]
    B --> E[module.infra]
    E --> F[helm_release.ingress-nginx]
    E --> G[helm_release.argocd]

    style A fill:#e1f5fe
    style E fill:#fff3e0
</code></pre>
<p>In the graph above, the Kind cluster is created first, and then the Namespace and Provider initialization proceed in parallel. After that, Ingress-NGINX and ArgoCD inside the infra module are also installed in parallel.</p>
<p>You can view the dependency graph with the following command:</p>
<pre><code class="language-bash">terraform graph | dot -Tpng > graph.png
</code></pre>
<h1>4. Installation and Basic Usage</h1>
<h2>4.1 Installation</h2>
<p>On macOS, installing via Homebrew is the simplest.</p>
<pre><code class="language-bash"># Install via Homebrew
brew install terraform

# Check version
terraform version
</code></pre>
<p>If you need to use different Terraform versions across multiple projects, <strong>tfenv</strong> (a version manager) is recommended.</p>
<pre><code class="language-bash"># Install tfenv
brew install tfenv

# Install and use a specific version
tfenv install 1.10.0
tfenv use 1.10.0

# Check the currently used version
tfenv list
</code></pre>
<h2>4.2 Key CLI Commands</h2>
<p>The commonly used commands can be summarized as follows.</p>
<pre><code class="language-bash"># Initialize: download Providers and install modules
terraform init
terraform init -upgrade    # Upgrade Providers to the latest allowed version

# Code validation and formatting
terraform validate         # Validate configuration file syntax
terraform fmt              # Automatically format code (to the standard style)
terraform fmt -check       # Only check whether formatting changes are needed

# Review the change plan
terraform plan             # Preview which resources will be created/modified/deleted

# Apply
terraform apply            # Apply changes (shows a confirmation prompt)
terraform apply -auto-approve  # Apply immediately without confirmation

# Check state
terraform show             # Detailed information about resources in the current State
terraform output           # Output the Output values
terraform state list       # List of managed resources

# Delete
terraform destroy          # Delete all resources
terraform destroy -target=kind_cluster.local_cluster  # Delete only a specific resource
</code></pre>
<h2>4.3 File Structure Convention</h2>
<p>A Terraform project usually separates files as follows.</p>
<pre><code>project/
├── main.tf          # Provider configuration, module calls
├── variables.tf     # Variable definitions
├── outputs.tf       # Output definitions
├── terraform.tfvars # Variable values (optional, target for .gitignore)
├── modules/         # Reusable modules
│   └── infra/
│       ├── infra.tf
│       └── variables.tf
└── .gitignore       # Exclude State, Provider cache
</code></pre>
<p>Items that should be included in <code>.gitignore</code>:</p>
<pre><code>.terraform/          # Provider binary cache
*.tfstate            # State files
*.tfstate.backup     # State backups
*.tfvars             # Sensitive variable values
</code></pre>
<h1>5. Hands-on: Deploying a Kind Cluster + ArgoCD</h1>
<p>The hands-on code is available at <a href="https://github.com/kenshin579/tutorials-go/tree/main/cloud/terraform">tutorials-go/cloud/terraform</a>. In this exercise, we will go through the process of creating a local Kubernetes cluster with Terraform and installing ArgoCD.</p>
<h2>5.1 Hands-on Goals and Architecture</h2>
<pre><code class="language-mermaid">flowchart TB
    subgraph "Resources created by Terraform"
        A[Kind cluster\n1 Control Plane + 2 Workers]
        B[study Namespace]
        C[Ingress-NGINX\nHelm deployment]
        D[ArgoCD\nHelm deployment]
    end

    A --> B
    A --> C
    A --> D
</code></pre>
<h2>5.2 Step 1: Provider Configuration (main.tf)</h2>
<p>First, declare the Providers you will use.</p>
<pre><code class="language-hcl">terraform {
  required_providers {
    kind = {
      source  = "tehcyx/kind"
      version = "0.8"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "2.36"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "3.0.0-pre2"
    }
    null = {
      source = "hashicorp/null"
    }
  }
}
</code></pre>
<p>The Kubernetes and Helm Providers connect by referencing the Kind cluster's kubeconfig.</p>
<pre><code class="language-hcl">provider "kubernetes" {
  config_path = kind_cluster.local_cluster.kubeconfig_path
}

provider "helm" {
  kubernetes = {
    config_path = kind_cluster.local_cluster.kubeconfig_path
  }
}
</code></pre>
<h2>5.3 Step 2: Create the Kind Cluster (kind.tf)</h2>
<p>Define the Kind cluster. It consists of 1 Control Plane and 2 Worker nodes.</p>
<pre><code class="language-hcl">resource "kind_cluster" "local_cluster" {
  name           = var.kind_cluster_name
  wait_for_ready = true
  node_image     = "kindest/node:v1.28.15"

  kind_config {
    kind        = "Cluster"
    api_version = "kind.x-k8s.io/v1alpha4"

    node {
      role = "control-plane"
      extra_port_mappings {
        container_port = 30080
        host_port      = 30080
        listen_address = "127.0.0.1"
      }
      extra_mounts {
        host_path      = "/tmp/kind-storage"
        container_path = "/opt/local-path-provisioner"
      }
    }

    node { role = "worker" }
    node { role = "worker" }
  }
}
</code></pre>
<ul>
<li><code>extra_port_mappings</code>: Maps ports so that NodePort services can be accessed from the host</li>
<li><code>extra_mounts</code>: Stores Persistent Volume data on the host disk</li>
</ul>
<h2>5.4 Step 3: Deploy ArgoCD with a Module (modules/infra/)</h2>
<p>The infrastructure installation is separated into a module for management. The module is called from <code>main.tf</code>.</p>
<pre><code class="language-hcl">module "infra" {
  source          = "./modules/infra"
  study_namespace = kubernetes_namespace.study.metadata[0].name

  depends_on = [kubernetes_namespace.study]
}
</code></pre>
<p>Inside the module, ArgoCD is installed using the Helm Provider.</p>
<pre><code class="language-hcl"># modules/infra/infra.tf
resource "helm_release" "argocd" {
  name       = "argocd"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  version    = "7.8.28"
  namespace  = kubernetes_namespace.argocd.metadata[0].name

  values = [
    &#x3C;&#x3C;-EOT
    configs:
      secret:
        argocdServerAdminPassword: ${var.argocd_password}
    server:
      service:
        type: "ClusterIP"
    EOT
  ]
}
</code></pre>
<p><code>helm_release</code> performs the same operation as <code>helm install</code>. In the <code>values</code> block, you can define Helm values inline.</p>
<h2>5.5 Step 4: Run and Verify</h2>
<pre><code class="language-bash"># 1. Initialize
make tf-init

# 2. Preview changes with Plan
terraform plan

# 3. Apply
make tf-install

# 4. Check the created resources
kubectl get nodes
kubectl get pods -n argocd

# 5. Access the ArgoCD UI (port forwarding)
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Access https://localhost:8080 in your browser
# ID: admin / PW: password
</code></pre>
<p>When you run <code>terraform plan</code>, the resources to be created are displayed as follows:</p>
<pre><code>Plan: 7 to add, 0 to change, 0 to destroy.
</code></pre>
<h2>5.6 Step 5: Deploy an Application with ArgoCD</h2>
<p>Now that the cluster and ArgoCD are ready with Terraform, let's register and deploy an application in ArgoCD. We will use <code>bootstrap/sample-apps.yaml</code> and <code>charts/sample-nginx/</code> included in the sample code.</p>
<h3>Helm Chart Structure</h3>
<p>First, let's look at the Helm chart structure of the sample NGINX app to be deployed.</p>
<pre><code>charts/sample-nginx/
├── Chart.yaml           # Chart metadata
├── values.yaml          # Definition of settings like image, replica count, resources
└── templates/
    ├── deployment.yaml  # K8s Deployment template that references settings via {{ .Values.xxx }}
    └── service.yaml     # K8s Service template
</code></pre>
<h3>Registering Apps in ArgoCD with an ApplicationSet</h3>
<p>When registering apps in ArgoCD, use an <strong>ApplicationSet</strong>. An ApplicationSet lets you define multiple Applications at once based on a template.</p>
<pre><code class="language-yaml"># bootstrap/sample-apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: sample-apps
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - appName: sample-nginx      # List of apps to deploy
  template:
    metadata:
      name: "{{appName}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/kenshin579/tutorials-go.git
        targetRevision: main
        path: "cloud/terraform/charts/{{appName}}"   # Helm chart path
      destination:
        server: https://kubernetes.default.svc
        namespace: study
      syncPolicy:
        automated:
          prune: true       # Resources deleted from Git are also deleted in K8s
          selfHeal: true    # Automatically restores to the Git state on manual changes
        syncOptions:
          - CreateNamespace=true
</code></pre>
<ul>
<li><code>generators.list.elements</code>: The list of apps to deploy. To add a new app, just add an entry here</li>
<li><code>source.path</code>: The Helm chart path within the Git repository. <code>{{appName}}</code> is substituted with the actual app name</li>
<li><code>syncPolicy.automated</code>: When a change is detected in Git, it is automatically applied to K8s</li>
</ul>
<h3>Running and Verifying the Deployment</h3>
<pre><code class="language-bash"># 1. Apply the ApplicationSet
kubectl apply -f bootstrap/sample-apps.yaml

# 2. Check the ArgoCD Application status
kubectl get applications -n argocd
# NAME           SYNC STATUS   HEALTH STATUS
# sample-nginx   Synced        Healthy

# 3. Check the deployed Pods
kubectl get pods -n study
# NAME                            READY   STATUS    RESTARTS   AGE
# sample-nginx-xxxxx-xxxxx        1/1     Running   0          30s

# 4. Check the Service
kubectl get svc -n study
# NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
# sample-nginx   ClusterIP   10.96.x.x      &#x3C;none>        80/TCP    30s

# 5. NGINX access test
kubectl port-forward svc/sample-nginx -n study 8081:80
# Access http://localhost:8081 in your browser
</code></pre>
<p>In the ArgoCD UI (<code>https://localhost:8080</code>), you can also confirm that the <code>sample-nginx</code> app is registered in the Synced/Healthy state.</p>
<h3>Testing a Configuration Change</h3>
<p>Let's verify ArgoCD's automatic synchronization. If you change the replica count in <code>values.yaml</code> and Git push, ArgoCD applies it automatically.</p>
<pre><code class="language-yaml"># Modify charts/sample-nginx/values.yaml
replicaCount: 2   # Change from 1 to 2
</code></pre>
<pre><code class="language-bash"># After Git push, wait a moment and ArgoCD detects it automatically
kubectl get pods -n study
# NAME                            READY   STATUS    RESTARTS   AGE
# sample-nginx-xxxxx-aaaaa        1/1     Running   0          5m
# sample-nginx-xxxxx-bbbbb        1/1     Running   0          10s   ← newly created
</code></pre>
<p>Without touching the Terraform code at all, the deployment was completed by modifying only the Helm chart's <code>values.yaml</code>. This is the key advantage of the Terraform + ArgoCD + Helm combination.</p>
<h2>5.7 Step 6: Cleanup</h2>
<pre><code class="language-bash"># Delete the ArgoCD Application
kubectl delete -f bootstrap/sample-apps.yaml

# Delete all Terraform resources (Kind cluster + ArgoCD)
make tf-destroy

# Clean up even the Terraform cache
make tf-clean
</code></pre>
<h1>6. The Way I Use It: Terraform + ArgoCD + Helm</h1>
<h2>6.1 Why Minimize Terraform's Scope</h2>
<p>You could manage all Kubernetes resources with Terraform. But in practice, the following problems arise:</p>
<ul>
<li><strong>Increased State management complexity</strong>: As the number of apps grows, the State file becomes bloated and <code>terraform plan</code> slows down</li>
<li><strong>Deployment speed</strong>: Even changing the configuration of a single app requires checking the entire State</li>
<li><strong>Difficult role separation</strong>: Infrastructure engineers and app developers have to manage the same Terraform code</li>
</ul>
<p>That's why I <strong>limit Terraform's role to the minimum</strong>.</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Tool</th>
<th>What It Manages</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Cluster provisioning</strong></td>
<td>Terraform</td>
<td>Kind cluster, ArgoCD installation</td>
</tr>
<tr>
<td><strong>App deployment automation</strong></td>
<td>ArgoCD</td>
<td>App registration/synchronization via ApplicationSet</td>
</tr>
<tr>
<td><strong>App configuration management</strong></td>
<td>Helm Charts</td>
<td>Deployment, Service, ConfigMap, etc.</td>
</tr>
</tbody>
</table>
<h2>6.2 Overall Structure</h2>
<pre><code class="language-mermaid">flowchart TB
    subgraph "Layer 1: Terraform"
        T1[Create Kind cluster]
        T2[Install ArgoCD via Helm]
        T1 --> T2
    end

    subgraph "Layer 2: ArgoCD"
        A1[ApplicationSet]
        A2[Watch Git repository]
        A1 --> A2
    end

    subgraph "Layer 3: Helm Charts"
        H1["sample-nginx"]
        H2["my-app-be"]
        H3["my-app-fe"]
    end

    T2 --> A1
    A2 --> H1
    A2 --> H2
    A2 --> H3

    H1 --> K1[K8s Deployment + Service]
    H2 --> K2[K8s Deployment + Service]
    H3 --> K3[K8s Deployment + Service]
</code></pre>
<p>The directory structure of the project I actually use is as follows.</p>
<pre><code>charts/
├── main.tf                          # Provider configuration
├── k8s.tf                           # Kind cluster definition (1 CP + 3 Workers)
├── variables.tf
├── outputs.tf
├── modules/
│   └── infra/
│       └── infra.tf                 # Managed by Terraform: installs only ArgoCD
├── bootstrap/                       # ArgoCD ApplicationSet definitions
│   ├── macmini-infra.yaml           #   → DB (MySQL, Redis)
│   ├── macmini-app.yaml             #   → Apps (10+)
│   └── macmini-gateway.yaml         #   → Gateway (NGINX, cert-manager)
├── charts/                          # Collection of Helm charts (20+)
│   ├── mysql/                       #   infrastructure
│   ├── redis/
│   ├── nginx-gateway/
│   ├── cert-manager/
│   ├── inspireme-be/                #   applications
│   ├── inspireme-fe/
│   ├── moneyflow-be/
│   ├── moneyflow-fe/
│   ├── ai-chatbot-be/
│   └── ...
└── Makefile
</code></pre>
<p>The key point is that <code>modules/infra/infra.tf</code> installs <strong>only ArgoCD</strong>. Infrastructure DBs like MySQL and Redis and all applications are registered in ArgoCD via the ApplicationSet in <code>bootstrap/</code>, and the actual K8s resource definitions are managed in each Helm chart under <code>charts/</code>.</p>
<p>The core of this structure is the <strong>separation of concerns</strong>:</p>
<ul>
<li><strong>Terraform</strong>: Only responsible for "the cluster exists and ArgoCD is installed"</li>
<li><strong>ArgoCD</strong>: Manages "which apps should be deployed"</li>
<li><strong>Helm Charts</strong>: Define "how each app is configured"</li>
</ul>
<h2>6.3 Real-World Workflow</h2>
<p>The flow in actual operations is as follows.</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Dev as Developer
    participant Git as Git Repository
    participant Argo as ArgoCD
    participant K8s as Kubernetes

    Dev->>Git: 1. Modify Helm values.yaml (image version, etc.)
    Dev->>Git: 2. Create PR → review → merge
    Git->>Argo: 3. ArgoCD detects the change (automatic)
    Argo->>K8s: 4. Render Helm chart → apply K8s resources
    K8s-->>Argo: 5. Synchronization complete (Healthy/Synced)
</code></pre>
<p>To change an app's configuration, you only need to <strong>modify the Helm chart's <code>values.yaml</code></strong>. There is no need to touch the Terraform code.</p>
<h3>ApplicationSet Example</h3>
<p>When registering apps in ArgoCD, use an ApplicationSet. The following is an example included in the sample code.</p>
<pre><code class="language-yaml">apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: sample-apps
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - appName: sample-nginx
  template:
    metadata:
      name: "{{appName}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/kenshin579/tutorials-go.git
        targetRevision: main
        path: "cloud/terraform/charts/{{appName}}"
      destination:
        server: https://kubernetes.default.svc
        namespace: study
      syncPolicy:
        automated:
          prune: true       # Resources deleted from Git are also deleted in K8s
          selfHeal: true    # Automatically restores to the Git state on manual changes
</code></pre>
<ul>
<li><code>generators.list</code>: The list of apps to deploy. To add a new app, just add an entry here</li>
<li><code>syncPolicy.automated</code>: When ArgoCD detects a Git change, it is automatically applied to K8s</li>
</ul>
<h3>Helm Chart Example</h3>
<p>Each app's Kubernetes resources are defined with a Helm chart.</p>
<pre><code class="language-yaml"># charts/sample-nginx/values.yaml
replicaCount: 1

image:
  repository: nginx
  tag: "1.27.0"

service:
  type: ClusterIP
  port: 80

resources:
  requests:
    cpu: 50m
    memory: 64Mi
  limits:
    cpu: 100m
    memory: 128Mi
</code></pre>
<p>If you want to change the replica count, just modify <code>replicaCount</code> in <code>values.yaml</code> and Git push, and ArgoCD applies it automatically.</p>
<h2>6.4 Advantages of This Structure</h2>
<ol>
<li><strong>The scope of changes is clear</strong>: App configuration is changed only in Helm Charts, and the cluster only in Terraform</li>
<li><strong>The Terraform State is concise</strong>: Since it only manages the cluster and ArgoCD, the State is small and fast</li>
<li><strong>ArgoCD detects drift</strong>: Even if someone manually changes a K8s resource, ArgoCD restores it to the original state</li>
<li><strong>Adding an app is simple</strong>: Just create a Helm chart and add its name to the ApplicationSet, and you're done</li>
</ol>
<h1>7. Conclusion</h1>
<p>In this article, we looked at Terraform's basic concepts (Provider, Resource, Variable, Module, State) and walked through a hands-on exercise of deploying ArgoCD to a Kind cluster.</p>
<p>Key takeaways:</p>
<ul>
<li><strong>Terraform</strong>: A declarative IaC tool. Define infrastructure with <code>.tf</code> files and apply it via <code>plan → apply</code></li>
<li><strong>State</strong>: The core mechanism by which Terraform tracks the current infrastructure state</li>
<li><strong>Module</strong>: Separates resources into reusable units to structure the code</li>
<li><strong>Real-world structure</strong>: Separating roles with the combination of Terraform (cluster) + ArgoCD (deployment automation) + Helm (app configuration) makes management much easier</li>
</ul>
<h2>References</h2>
<ul>
<li><a href="https://developer.hashicorp.com/terraform/docs">Terraform Official Documentation</a></li>
<li><a href="https://registry.terraform.io/browse/providers">Terraform Registry - Provider List</a></li>
<li><a href="https://registry.terraform.io/providers/tehcyx/kind/latest/docs">Kind Provider Documentation</a></li>
<li><a href="https://argo-cd.readthedocs.io/">ArgoCD Official Documentation</a></li>
<li><a href="https://github.com/kenshin579/tutorials-go/tree/main/cloud/terraform">Full Source Code - GitHub</a></li>
</ul>]]></content:encoded>
      <category>cloud</category>
      <category>Terraform</category>
      <category>IaC</category>
      <category>Infrastructure as Code</category>
      <category>HCL</category>
      <category>HashiCorp</category>
      <category>Kind</category>
      <category>Kubernetes</category>
      <category>ArgoCD</category>
      <category>GitOps</category>
      <category>Helm</category>
      <category>DevOps</category>
      <category>Provider</category>
      <category>Module</category>
      <category>테라폼</category>
      <category>인프라</category>
      <category>쿠버네티스</category>
      <category>인프라스트럭처 애즈 코드</category>
      <category>Series: Terraform</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Profiling Go Programs with pprof</title>
      <link>https://blog.advenoh.pe.kr/en/go-pprof-프로파일링으로-성능-문제-진단하기</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/go-pprof-프로파일링으로-성능-문제-진단하기</guid>
      <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
      <description>How to use Go's built-in profiling tool pprof to diagnose and optimize performance bottlenecks in CPU, memory, goroutines, and more</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<h2>1.1 What Is Profiling?</h2>
<p>Profiling is a technique for measuring and analyzing resource usage patterns — CPU, memory, I/O, and so on — while a program runs. Through profiling, you can accurately identify performance bottlenecks and find the code regions that need optimization.</p>
<p>Without profiling, if you rely on a gut feeling that "it seems slow," you'll waste time optimizing code unrelated to the actual bottleneck. <strong>"Don't optimize without measuring"</strong> is a fundamental principle of software performance analysis.</p>
<h2>1.2 Why Profiling Matters in Go</h2>
<p>Go has runtime-specific concurrency mechanisms such as goroutines, the garbage collector (GC), and channels. These characteristics are powerful, but they can also make it hard to pinpoint the cause of performance problems.</p>
<ul>
<li><strong>Goroutine leaks</strong>: goroutines that never terminate keep piling up and consume memory</li>
<li><strong>GC overhead</strong>: GC load caused by excessive heap allocation</li>
<li><strong>Mutex contention</strong>: multiple goroutines competing over the same lock, degrading performance</li>
<li><strong>Channel blocking</strong>: goroutine stalls caused by waiting on channels</li>
</ul>
<p>Go has profiling tools to diagnose these problems <strong>built into the standard library</strong>, so you can use them right away without any extra installation.</p>
<h2>1.3 Introducing the pprof Tools</h2>
<p>In Go, profiling is provided mainly through two packages.</p>
<table>
<thead>
<tr>
<th>Package</th>
<th>Description</th>
<th>Use Scenario</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>runtime/pprof</code></td>
<td>Save profile data to a file</td>
<td>CLI programs, batch jobs</td>
</tr>
<tr>
<td><code>net/http/pprof</code></td>
<td>Expose profiling via HTTP endpoints</td>
<td>web servers, long-running processes</td>
</tr>
</tbody>
</table>
<p><code>net/http/pprof</code> uses <code>runtime/pprof</code> internally, and by registering HTTP handlers it lets you connect remotely to a running program to collect profile data. Its overhead is low enough to use safely even in production environments.</p>
<h1>2. pprof Basic Setup</h1>
<h2>2.1 net/http/pprof - HTTP Endpoint Approach</h2>
<p>The simplest method is to import the <code>net/http/pprof</code> package. A single blank import (<code>_</code>) line automatically registers the profiling HTTP endpoints.</p>
<pre><code class="language-go">package main

import (
	"fmt"
	"log"
	"net/http"
	"sync"
	"time"

	_ "net/http/pprof" // automatically register pprof endpoints
)

func main() {
	// start the HTTP server for pprof
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	fmt.Println("hello world")
	var wg sync.WaitGroup
	wg.Add(1)
	go leakyFunction(wg)
	wg.Wait()
}

// leakyFunction keeps appending strings to a slice, causing a memory leak.
// As append() repeats, the slice's internal array is reallocated again and again,
// and the previous arrays become GC candidates, but new allocations grow faster, so memory usage keeps increasing.
func leakyFunction(wg sync.WaitGroup) {
	defer wg.Done()
	s := make([]string, 3)
	for i := 0; i &#x3C; 10000000; i++ {
		s = append(s, "magical pandas") // the slice grows without bound, causing a memory leak
		if (i % 100000) == 0 {
			time.Sleep(500 * time.Millisecond)
		}
	}
}
</code></pre>
<p>After running the program, if you open <code>http://localhost:6060/debug/pprof/</code> in a browser, you can see a profile list like the one below.</p>
<table>
<thead>
<tr>
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/debug/pprof/</code></td>
<td>profile index page</td>
</tr>
<tr>
<td><code>/debug/pprof/profile</code></td>
<td>CPU profile (default 30 seconds)</td>
</tr>
<tr>
<td><code>/debug/pprof/heap</code></td>
<td>heap memory profile</td>
</tr>
<tr>
<td><code>/debug/pprof/goroutine</code></td>
<td>goroutine stack traces</td>
</tr>
<tr>
<td><code>/debug/pprof/allocs</code></td>
<td>memory allocation profile</td>
</tr>
<tr>
<td><code>/debug/pprof/block</code></td>
<td>blocking profile</td>
</tr>
<tr>
<td><code>/debug/pprof/mutex</code></td>
<td>mutex contention profile</td>
</tr>
<tr>
<td><code>/debug/pprof/threadcreate</code></td>
<td>thread creation profile</td>
</tr>
<tr>
<td><code>/debug/pprof/trace</code></td>
<td>execution trace</td>
</tr>
</tbody>
</table>
<h2>2.2 runtime/pprof - File Output Approach</h2>
<p>In a CLI program or batch job that has no HTTP server, you can use the <code>runtime/pprof</code> package to save profile data directly to a file.</p>
<h3>2.2.1 Saving a CPU Profile File</h3>
<pre><code class="language-go">package main

import (
	"log"
	"os"
	"runtime/pprof"
)

func main() {
	// create the CPU profile file
	f, err := os.Create("cpu.prof")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	// start CPU profiling
	if err := pprof.StartCPUProfile(f); err != nil {
		log.Fatal(err)
	}
	defer pprof.StopCPUProfile()

	// run the code to be profiled
	heavyComputation()
}

func heavyComputation() {
	result := 0
	for i := 0; i &#x3C; 100000000; i++ {
		result += i * i
	}
}
</code></pre>
<h3>2.2.2 Saving a Heap Memory Profile File</h3>
<pre><code class="language-go">func writeHeapProfile() {
	f, err := os.Create("mem.prof")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	// save the heap profile
	if err := pprof.WriteHeapProfile(f); err != nil {
		log.Fatal(err)
	}
}
</code></pre>
<p>The saved profile files are analyzed with the <code>go tool pprof</code> command.</p>
<pre><code class="language-bash"># analyze the CPU profile
go tool pprof cpu.prof

# analyze the memory profile
go tool pprof mem.prof
</code></pre>
<h2>2.3 Using with go test -bench</h2>
<p>You can collect profile data while running benchmark tests at the same time. This is useful when analyzing the performance of a specific function.</p>
<pre><code class="language-bash"># collect a CPU profile
go test -bench=. -cpuprofile=cpu.prof

# collect a memory profile
go test -bench=. -memprofile=mem.prof

# collect a blocking profile
go test -bench=. -blockprofile=block.prof

# collect a mutex profile
go test -bench=. -mutexprofile=mutex.prof
</code></pre>
<p>The way to analyze the collected profile files is the same.</p>
<pre><code class="language-bash"># analyze the benchmark CPU profile
go tool pprof cpu.prof

# open in the web UI
go tool pprof -http=:8080 cpu.prof
</code></pre>
<h1>3. Analysis by Profile Type</h1>
<p>Go pprof provides various types of profiles. This chapter looks at the characteristics of each profile type, how to collect it, and concrete examples.</p>
<p>The comprehensive example program below is structured so that all types of profiles can be collected simultaneously.</p>
<pre><code class="language-go">package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
	"os"
	"os/signal"
	"runtime"
	"syscall"

	"example.com/profiling/pkg/block"
	"example.com/profiling/pkg/cpu"
	"example.com/profiling/pkg/memory"
	"example.com/profiling/pkg/mutex"
	"example.com/profiling/pkg/threadcreate"
)

func main() {
	// start the pprof HTTP server
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	// blocking/mutex profiles are disabled by default, so they must be explicitly enabled
	runtime.SetBlockProfileRate(1)     // record all blocking events (1 = nanosecond threshold)
	runtime.SetMutexProfileFraction(1) // record all mutex contention (1 = sample with probability 1/1)

	// start goroutines that generate load for each type
	go cpu.IncreaseInt()                  // CPU load (infinite loop computation)
	go cpu.IncreaseIntGoroutine()         // CPU load (nested goroutine)
	go memory.AllocMemory()               // heap memory allocation
	go block.PrintHello()                 // stdout blocking (I/O lock contention)
	go block.PrintWorld()                 // stdout blocking (I/O lock contention)
	go threadcreate.CreateGoroutine1000() // mass goroutine creation → triggers OS thread creation
	go mutex.Mutex01()                    // mutex contention
	go mutex.Mutex02()                    // mutex contention
	go mutex.Mutex03()                    // mutex contention

	// wait for a termination signal
	log.Println("profiling server started: http://localhost:6060/debug/pprof/")
	termSignal := make(chan os.Signal, 1)
	signal.Notify(termSignal, syscall.SIGTERM, syscall.SIGINT)
	&#x3C;-termSignal
}
</code></pre>
<h2>3.1 CPU Profile</h2>
<p>A CPU profile identifies the functions that consume the most CPU time in a program. By default it samples 100 times per second, recording the stack trace of the function running at that moment.</p>
<h3>How to Collect</h3>
<pre><code class="language-bash"># collect a CPU profile for 30 seconds
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# collect for 10 seconds
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10
</code></pre>
<h3>CPU Load Example Code</h3>
<pre><code class="language-go">package cpu

func IncreaseInt() {
	i := 0
	for {
		i = increase1000(i)
		i = increase2000(i)
	}
}

func IncreaseIntGoroutine() {
	go func() {
		i := 0
		for {
			i = increase1000(i)
			i = increase2000(i)
		}
	}()
}

func increase1000(n int) int {
	for n := 0; n &#x3C; 1000; n++ {
		n = n + 1
	}
	return n
}

func increase2000(n int) int {
	for n := 0; n &#x3C; 2000; n++ {
		n = n + 1
	}
	return n
}
</code></pre>
<h3>Example Analysis Result</h3>
<pre><code class="language-bash">(pprof) top10
Showing nodes accounting for 5.20s, 98.11% of 5.30s total
Showing top 10 nodes out of 23
      flat  flat%   sum%        cum   cum%
     2.08s 39.25% 39.25%      2.08s 39.25%  main.increase2000
     1.52s 28.68% 67.92%      1.52s 28.68%  main.increase1000
     0.80s 15.09% 83.02%      3.60s 67.92%  main.IncreaseInt
     0.60s 11.32% 94.34%      2.12s 40.00%  main.IncreaseIntGoroutine
     ...
</code></pre>
<p>You can see that the <code>increase2000</code> function accounts for about 39% of CPU time, and <code>increase1000</code> accounts for about 29%. The difference in loop iteration count (1000 vs 2000) is directly reflected in the CPU time.</p>
<h2>3.2 Heap Memory Profile (heap)</h2>
<p>A heap profile shows the current memory allocation state. It is used to find memory leaks or to identify functions that use a lot of memory.</p>
<h3>How to Collect</h3>
<pre><code class="language-bash"># collect a heap profile
go tool pprof http://localhost:6060/debug/pprof/heap
</code></pre>
<h3>Memory Allocation Example Code</h3>
<pre><code class="language-go">package memory

import "time"

func AllocMemory() {
	bytes1000 := alloc1000()
	bytes1000[0] = '0'

	for {
		time.Sleep(1 * time.Second)
	}
}

func alloc1000() []byte {
	return make([]byte, 1000)
}
</code></pre>
<h3>inuse_space vs alloc_space</h3>
<p>A heap profile can be analyzed from two perspectives.</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
<th>Use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>inuse_space</code></td>
<td>memory currently in use</td>
<td>detecting memory leaks</td>
</tr>
<tr>
<td><code>inuse_objects</code></td>
<td>number of objects currently in use</td>
<td>object-count-based analysis</td>
</tr>
<tr>
<td><code>alloc_space</code></td>
<td>total memory allocated since program start</td>
<td>allocation frequency analysis</td>
</tr>
<tr>
<td><code>alloc_objects</code></td>
<td>total number of objects allocated since program start</td>
<td>allocation count analysis</td>
</tr>
</tbody>
</table>
<pre><code class="language-bash"># based on memory currently in use (default)
go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap

# based on total allocated memory
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
</code></pre>
<p><code>inuse_space</code> shows memory that has not been freed by GC and remains in use, so it is mainly used to <strong>detect memory leaks</strong>. <code>alloc_space</code> includes already-freed memory as well, so it is useful for finding <strong>code that allocates frequently</strong>.</p>
<h3>Comparing Heap Profiles (diff)</h3>
<p>Comparing heap profiles from two points in time makes a memory leak even clearer.</p>
<pre><code class="language-bash"># collect the base profile
curl -o base.prof http://localhost:6060/debug/pprof/heap

# collect a second profile a little later
curl -o current.prof http://localhost:6060/debug/pprof/heap

# compare the two profiles
go tool pprof -base=base.prof current.prof
</code></pre>
<h2>3.3 Goroutine Profile (goroutine)</h2>
<p>A goroutine profile shows the stack traces of all currently running goroutines. It is used to detect goroutine leaks or to check which goroutine is blocked where.</p>
<h3>How to Collect</h3>
<pre><code class="language-bash"># collect a goroutine profile
go tool pprof http://localhost:6060/debug/pprof/goroutine

# full stack dump (view in browser)
curl http://localhost:6060/debug/pprof/goroutine?debug=2
</code></pre>
<p>Using the <code>debug=2</code> parameter, you can view the full stack traces of all goroutines in text form, making it easy to see at a glance where each goroutine is waiting.</p>
<h3>Goroutine Leak Example Code</h3>
<p>A goroutine leak is the phenomenon where created goroutines never terminate and keep piling up.</p>
<pre><code class="language-go">package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	_ "net/http/pprof"
)

func main() {
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	// goroutine leak: waiting on a channel that is never closed
	for i := 0; i &#x3C; 100; i++ {
		go leakyGoroutine(i)
	}

	// the main goroutine keeps running
	select {}
}

func leakyGoroutine(id int) {
	ch := make(chan struct{}) // a channel nobody closes
	&#x3C;-ch                     // waits forever -> goroutine leak!
	fmt.Println("never reached", id)
}
</code></pre>
<p>In the code above, <code>leakyGoroutine</code> waits on a channel that nobody closes, so 100 goroutines never terminate and keep occupying memory.</p>
<h3>Goroutine Leak Prevention Pattern</h3>
<pre><code class="language-go">func safeGoroutine(ctx context.Context, id int) {
	ch := make(chan struct{})
	select {
	case &#x3C;-ch:
		fmt.Println("received", id)
	case &#x3C;-ctx.Done():
		fmt.Println("cancelled", id)
		return // terminate normally when context is canceled
	}
}
</code></pre>
<p>Using <code>context.Context</code>, you can cancel a goroutine from the outside, which prevents leaks.</p>
<h2>3.4 Blocking Profile (block)</h2>
<p>A blocking profile analyzes the time goroutines spend in a blocking state. It includes channel receive waits, mutex lock waits, I/O waits, and so on.</p>
<h3>How to Enable and Collect</h3>
<p>The blocking profile is disabled by default, so it must be explicitly enabled.</p>
<pre><code class="language-go">// enable the blocking profile (at program startup)
runtime.SetBlockProfileRate(1) // 1 = record all blocking events
</code></pre>
<p>The argument to <code>SetBlockProfileRate</code> is a threshold in nanoseconds. Setting it to <code>1</code> records all blocking events; larger values ignore short blocking. In production, set an appropriate value to reduce overhead.</p>
<pre><code class="language-bash"># collect the blocking profile
go tool pprof http://localhost:6060/debug/pprof/block
</code></pre>
<h3>Blocking Example Code</h3>
<pre><code class="language-go">package block

import "fmt"

func PrintHello() {
	for {
		fmt.Printf("Hello\n")
	}
}

func PrintWorld() {
	for {
		fmt.Printf("World\n")
	}
}
</code></pre>
<p><code>fmt.Printf</code> internally acquires a lock on stdout, so when <code>PrintHello</code> and <code>PrintWorld</code> run simultaneously, blocking occurs over the stdout lock.</p>
<h2>3.5 Mutex Profile (mutex)</h2>
<p>A mutex profile analyzes mutex contention. When multiple goroutines compete over the same mutex, it measures the time each goroutine waited to acquire the lock.</p>
<h3>How to Enable and Collect</h3>
<pre><code class="language-go">// enable the mutex profile
runtime.SetMutexProfileFraction(1) // 1 = record all mutex contention
</code></pre>
<p>The argument to <code>SetMutexProfileFraction</code> is the sampling rate. <code>1</code> records all contention events; <code>N</code> records with probability 1/N.</p>
<pre><code class="language-bash"># collect the mutex profile
go tool pprof http://localhost:6060/debug/pprof/mutex
</code></pre>
<h3>Mutex Contention Example Code</h3>
<pre><code class="language-go">package mutex

import (
	"fmt"
	"sync"
)

var mu = sync.Mutex{}

func Mutex01() {
	for {
		mu.Lock()
		fmt.Printf("Mutex01\n")
		mu.Unlock()
	}
}

func Mutex02() {
	for {
		mu.Lock()
		fmt.Printf("Mutex02\n")
		mu.Unlock()
	}
}

func Mutex03() {
	for {
		mu.Lock()
		fmt.Printf("Mutex03\n")
		mu.Unlock()
	}
}
</code></pre>
<p>Three goroutines compete over the same <code>mu</code> mutex, so the mutex profile records the wait time of each function.</p>
<h2>3.6 Thread Creation Profile (threadcreate)</h2>
<p>A thread creation profile shows the pattern of OS threads the program created. Excessive thread creation wastes system resources, so this is used to monitor it.</p>
<h3>Mass Goroutine Creation Example Code</h3>
<p>The Go runtime multiplexes goroutines on top of OS threads to run them. When a goroutine blocks on a system call and the like, the runtime creates a new OS thread so that other goroutines can keep running. Running a large number of goroutines simultaneously lets you observe this thread creation pattern in the profile.</p>
<pre><code class="language-go">package threadcreate

// CreateGoroutine1000 creates 100,000 goroutines to simulate massive concurrent execution.
// Since the number of goroutines is far greater than GOMAXPROCS, scheduling overhead occurs.
func CreateGoroutine1000() {
	for i := 0; i &#x3C; 100000; i++ {
		go innerFunc()
	}
}

func innerFunc() {
	n := 0
	for i := 0; i &#x3C; 1000000; i++ {
		n++
	}
}
</code></pre>
<pre><code class="language-bash"># collect the thread creation profile
go tool pprof http://localhost:6060/debug/pprof/threadcreate
</code></pre>
<h1>4. Using the pprof Analysis Tools</h1>
<p>Once you've collected profile data, you now need to use the analysis tools to find the cause of the performance problem. Go provides a powerful CLI tool and web-based visualization tools.</p>
<h2>4.1 go tool pprof CLI Interactive Mode</h2>
<p>Running <code>go tool pprof</code> enters an interactive shell.</p>
<pre><code class="language-bash">go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10
</code></pre>
<p>When collection finishes, a <code>(pprof)</code> prompt appears, and you can analyze the profile data with various commands.</p>
<h3>Key Commands</h3>
<table>
<thead>
<tr>
<th>Command</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>top [N]</code></td>
<td>top N resource-consuming functions</td>
<td><code>top10</code></td>
</tr>
<tr>
<td><code>list &#x3C;func></code></td>
<td>per-source-line profile info</td>
<td><code>list IncreaseInt</code></td>
</tr>
<tr>
<td><code>tree</code></td>
<td>display as a call tree</td>
<td><code>tree</code></td>
</tr>
<tr>
<td><code>web</code></td>
<td>visualize the call graph in a browser</td>
<td><code>web</code></td>
</tr>
<tr>
<td><code>peek &#x3C;func></code></td>
<td>check callers/callees</td>
<td><code>peek increase1000</code></td>
</tr>
<tr>
<td><code>disasm &#x3C;func></code></td>
<td>assembly-level profile</td>
<td><code>disasm increase2000</code></td>
</tr>
<tr>
<td><code>svg</code></td>
<td>save the call graph as an SVG file</td>
<td><code>svg</code></td>
</tr>
<tr>
<td><code>png</code></td>
<td>save the call graph as a PNG image</td>
<td><code>png</code></td>
</tr>
</tbody>
</table>
<h3>The top Command</h3>
<pre><code class="language-bash">(pprof) top10
Showing nodes accounting for 5.20s, 98.11% of 5.30s total
      flat  flat%   sum%        cum   cum%
     2.08s 39.25% 39.25%      2.08s 39.25%  main.increase2000
     1.52s 28.68% 67.92%      1.52s 28.68%  main.increase1000
     0.80s 15.09% 83.02%      3.60s 67.92%  main.IncreaseInt
</code></pre>
<h3>The Difference Between flat and cum</h3>
<p>These are the two most important metrics in profile analysis.</p>
<ul>
<li><strong>flat</strong>: the time the function spent <strong>directly itself</strong> (excluding calls to lower-level functions)</li>
<li><strong>cum</strong> (cumulative): the time including the function + <strong>all the lower-level functions it called</strong></li>
</ul>
<pre><code>Example:
func A() {        // flat=1s, cum=3s
    doWork(1s)    // 1 second spent in A itself
    B()           // 2 seconds spent calling B
}

func B() {        // flat=2s, cum=2s
    doWork(2s)    // 2 seconds spent in B itself
}
</code></pre>
<ul>
<li>Function A: <code>flat=1s</code> (its own work), <code>cum=3s</code> (own 1s + B call 2s)</li>
<li>Function B: <code>flat=2s</code> (its own work), <code>cum=2s</code> (no lower-level calls)</li>
</ul>
<p>A <strong>function with high <code>flat</code></strong> is a direct optimization target, while a <strong>function with high <code>cum</code></strong> requires examining the entire call chain.</p>
<h3>The list Command</h3>
<p>You can view the source code of a specific function line by line, along with profile information.</p>
<pre><code class="language-bash">(pprof) list increase2000
Total: 5.30s
ROUTINE ======================== main.increase2000
     2.08s      2.08s (flat, cum) 39.25% of Total
         .          .     27: func increase2000(n int) int {
     2.08s      2.08s     28:     for n := 0; n &#x3C; 2000; n++ {
         .          .     29:         n = n + 1
         .          .     30:     }
         .          .     31:     return n
         .          .     32: }
</code></pre>
<p>You can pinpoint that most of the CPU time is spent in the for loop on line 28.</p>
<h2>4.2 Web UI Visualization</h2>
<p>Using the <code>-http</code> flag with <code>go tool pprof</code>, you can open a browser-based interactive analysis tool.</p>
<pre><code class="language-bash"># open a profile file in the web UI
go tool pprof -http=:8080 cpu.prof

# open the web UI directly from an HTTP endpoint
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=10
</code></pre>
<p>The web UI provides the following views.</p>
<h3>Graph View</h3>
<p>Visualizes the call graph. Nodes (rectangles) represent functions, and the size and color of a node are proportional to its resource consumption. Arrows represent call relationships, and the thickness of an arrow is proportional to call frequency.</p>
<ul>
<li><strong>Large node</strong> → a function that consumes a lot of resources</li>
<li><strong>Thick arrow</strong> → a frequent call path</li>
<li><strong>Red</strong> → high resource consumption</li>
</ul>
<h3>Flame Graph</h3>
<p>You can view the flame graph in the Flame Graph view. A flame graph visually represents the call stack, letting you grasp performance bottlenecks intuitively.</p>
<h3>Top View</h3>
<p>Shows the same information as the CLI <code>top</code> command, in table form. You can change the sort criterion or filter.</p>
<h3>Source View</h3>
<p>Shows profiling results per source line. It's similar to the CLI <code>list</code> command, but you can navigate the entire source file.</p>
<h2>4.3 How to Read a Flame Graph</h2>
<p>A flame graph is the most intuitive visualization tool in performance analysis.</p>
<pre><code>┌──────────────────────────────────────────────────────┐
│                     main.main                        │ ← root (program entry point)
├────────────────────────┬─────────────────────────────┤
│    main.IncreaseInt    │  main.IncreaseIntGoroutine  │ ← lower-level functions
├───────────┬────────────┼──────────┬──────────────────┤
│increase1000│increase2000│increase1000│  increase2000  │ ← leaf functions
└───────────┴────────────┴──────────┴──────────────────┘
</code></pre>
<ul>
<li><strong>X-axis</strong>: proportion of samples (the wider, the more time spent in that function)</li>
<li><strong>Y-axis</strong>: call stack depth (root at the bottom, leaf at the top)</li>
<li><strong>Wide block</strong>: a lot of time spent in that function (and its lower-level functions)</li>
<li><strong>Color</strong>: usually random and just for distinction (red does not mean a problem)</li>
</ul>
<p><strong>Analysis point</strong>: in a flame graph, find the widest "plateau." A function with a wide plateau is a candidate for a performance bottleneck.</p>
<h1>5. Hands-On Example: A Performance Problem Diagnosis Workflow</h1>
<p>Let's look step by step at the process of diagnosing a real performance problem.</p>
<h2>5.1 Scenario: Diagnosing a CPU Bottleneck</h2>
<h3>The Problem</h3>
<p>A particular API response of a web server is slow. We need to find the cause.</p>
<h3>Diagnosis Steps</h3>
<p><strong>Step 1: Collect a CPU profile</strong></p>
<pre><code class="language-bash"># collect a CPU profile for 30 seconds
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
</code></pre>
<p><strong>Step 2: Check hot spots with top</strong></p>
<pre><code class="language-bash">(pprof) top10
Showing nodes accounting for 5.20s, 98.11% of 5.30s total
      flat  flat%   sum%        cum   cum%
     2.08s 39.25% 39.25%      2.08s 39.25%  main.increase2000
     1.52s 28.68% 67.92%      1.52s 28.68%  main.increase1000
</code></pre>
<p>→ the <code>increase2000</code> function accounts for 39% of CPU time</p>
<p><strong>Step 3: Per-line analysis with list</strong></p>
<pre><code class="language-bash">(pprof) list increase2000
</code></pre>
<p>→ confirm that the for loop is the bottleneck</p>
<p><strong>Step 4: Check the call graph with web</strong></p>
<pre><code class="language-bash">(pprof) web
</code></pre>
<p>→ visually check the call chain to figure out which path calls the function</p>
<h3>Verification After Optimization</h3>
<p>After optimization, run the same profiling to measure the improvement.</p>
<pre><code class="language-bash"># compare the profiles before and after optimization
go tool pprof -base=before.prof after.prof
</code></pre>
<h2>5.2 Scenario: Diagnosing a Memory Leak</h2>
<h3>The Problem</h3>
<p>While the service runs in production, memory usage keeps increasing over time.</p>
<h3>Diagnosis Steps</h3>
<p><strong>Step 1: Collect heap profiles from two points in time</strong></p>
<pre><code class="language-bash"># point 1: right after service start
curl -o heap_t1.prof http://localhost:6060/debug/pprof/heap

# point 2: after some time has passed
curl -o heap_t2.prof http://localhost:6060/debug/pprof/heap
</code></pre>
<p><strong>Step 2: Compare the two profiles</strong></p>
<pre><code class="language-bash"># check the memory that increased in t2 relative to t1
go tool pprof -base=heap_t1.prof heap_t2.prof
</code></pre>
<p><strong>Step 3: Identify the leak point with inuse_space</strong></p>
<pre><code class="language-bash">(pprof) top10 -inuse_space
</code></pre>
<p>→ find the function that allocates memory that is never freed over time</p>
<p><strong>Step 4: Check and fix the source code</strong></p>
<pre><code class="language-bash">(pprof) list leakyFunction
</code></pre>
<p>→ identify and fix patterns such as a slice being appended without bound</p>
<h2>5.3 Scenario: Diagnosing a Goroutine Leak</h2>
<h3>The Problem</h3>
<p>The number of goroutines keeps increasing over time.</p>
<h3>Diagnosis Steps</h3>
<p><strong>Step 1: Check the current goroutine count</strong></p>
<pre><code class="language-bash"># check the goroutine count
curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -1
</code></pre>
<p><strong>Step 2: Check goroutine stack traces</strong></p>
<pre><code class="language-bash"># full goroutine stack dump
curl http://localhost:6060/debug/pprof/goroutine?debug=2
</code></pre>
<p><strong>Step 3: Identify goroutines waiting at the same location</strong></p>
<pre><code>goroutine 18 [chan receive]:
main.leakyGoroutine(0x0)
    /app/main.go:25 +0x34
...

goroutine 19 [chan receive]:
main.leakyGoroutine(0x1)
    /app/main.go:25 +0x34
</code></pre>
<p>→ if many goroutines are found waiting on a channel receive at the same location (<code>main.go:25</code>), suspect a leak</p>
<p><strong>Step 4: Manage the goroutine lifecycle with context.Context</strong></p>
<pre><code class="language-go">// before: goroutine leak
go func() {
    &#x3C;-ch // waits forever
}()

// after: cancelable with context
go func(ctx context.Context) {
    select {
    case &#x3C;-ch:
        // normal processing
    case &#x3C;-ctx.Done():
        return // normal termination
    }
}(ctx)
</code></pre>
<h1>6. Integrating pprof with the Echo Framework</h1>
<p>To use pprof in a production web server, you need to know how to integrate it with your framework. When using the Echo framework, you can use the <code>echo-pprof</code> library.</p>
<pre><code class="language-go">package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	echopprof "github.com/sevenNt/echo-pprof"
)

func main() {
	e := echo.New()
	echopprof.Wrap(e) // register pprof endpoints

	e.GET("/hello", helloHandler)
	e.POST("/stress/cpu", cpuHandler)
	e.POST("/stress/memory", memoryHandler)

	e.Logger.Fatal(e.Start(":8080"))
}

func helloHandler(ctx echo.Context) error {
	return ctx.JSON(http.StatusOK, map[string]string{
		"message": "Hello World",
	})
}
</code></pre>
<p>A single <code>echopprof.Wrap(e)</code> line registers pprof endpoints on the Echo server, accessible at <code>http://localhost:8080/debug/pprof/</code>.</p>
<h3>Security Considerations in Production</h3>
<p>The pprof endpoints expose the internal state of the program, so in production environments you should separate them onto a dedicated port and block external access.</p>
<pre><code class="language-go">func main() {
	// main server (publicly exposed)
	e := echo.New()
	e.GET("/api/hello", helloHandler)
	go e.Start(":8080")

	// pprof server (internal only, separate port)
	pprofMux := http.NewServeMux()
	pprofMux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP)
	go http.ListenAndServe("localhost:6060", nil) // bind to localhost only
}
</code></pre>
<h1>7. Useful Auxiliary Tools</h1>
<h2>7.1 gops</h2>
<p><a href="https://github.com/google/gops">gops</a> is a tool for monitoring running Go processes.</p>
<pre><code class="language-bash"># install gops
go install github.com/google/gops@latest
</code></pre>
<p>Add the gops agent to your program.</p>
<pre><code class="language-go">import "github.com/google/gops/agent"

func main() {
	if err := agent.Listen(agent.Options{}); err != nil {
		log.Fatal(err)
	}
	// ...
}
</code></pre>
<p>You can query process information with gops.</p>
<pre><code class="language-bash"># list running Go processes
gops

# query information about a specific process
gops &#x3C;pid>

# check GC stats
gops gc &#x3C;pid>

# memory stats
gops memstats &#x3C;pid>

# current stack trace
gops stack &#x3C;pid>

# collect a pprof CPU profile
gops pprof-cpu &#x3C;pid>

# collect a pprof heap profile
gops pprof-heap &#x3C;pid>
</code></pre>
<h2>7.2 go tool trace</h2>
<p><code>go tool trace</code> is a tool that traces a program's execution flow along a time axis. If pprof focuses on "where time was spent," trace focuses on "what happened in chronological order."</p>
<h3>Collecting Trace Data</h3>
<pre><code class="language-bash"># collect a trace for 5 seconds from an HTTP endpoint
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5

# open the trace viewer
go tool trace trace.out
</code></pre>
<h3>Collecting a Trace in Code</h3>
<p>Basic trace collection can be implemented simply with <code>trace.Start</code> and <code>trace.Stop</code>.</p>
<pre><code class="language-go">package main

import (
	"os"
	"runtime/trace"
)

func main() {
	f, _ := os.Create("trace.out")
	defer f.Close()

	trace.Start(f)
	defer trace.Stop()

	// program code...
}
</code></pre>
<h3>Per-Section Tracing with Task and Region</h3>
<p>Using <code>trace.NewTask</code> and <code>trace.WithRegion</code>, you can logically separate specific work sections in the trace viewer. This is useful in complex programs for figuring out which work spends time in which section.</p>
<pre><code class="language-go">func worker(ctx context.Context, id int) {
	// Task: defines a logical unit of work (grouped and displayed in the trace viewer)
	ctx, task := trace.NewTask(ctx, fmt.Sprintf("worker-%d", id))
	defer task.End()

	// Region: defines a detailed section within a Task
	trace.WithRegion(ctx, "compute", func() {
		// CPU computation work...
	})

	trace.WithRegion(ctx, "channel-work", func() {
		// channel communication work...
	})

	// Log: records a user-defined log into the trace
	trace.Log(ctx, "status", fmt.Sprintf("worker-%d completed", id))
}
</code></pre>
<h3>Information You Can See in the Trace Viewer</h3>
<ul>
<li><strong>Goroutine analysis</strong>: distribution of execution/wait time per goroutine</li>
<li><strong>Network/Sync blocking</strong>: network and synchronization blocking events</li>
<li><strong>Syscall blocking</strong>: system call blocking</li>
<li><strong>Scheduler latency</strong>: scheduler delay time</li>
<li><strong>GC events</strong>: garbage collection event timeline</li>
</ul>
<h2>7.3 benchstat</h2>
<p><a href="https://pkg.go.dev/golang.org/x/perf/cmd/benchstat">benchstat</a> is a tool that statistically compares Go benchmark results.</p>
<pre><code class="language-bash"># install benchstat
go install golang.org/x/perf/cmd/benchstat@latest

# run benchmarks before optimization (10 repetitions)
go test -bench=. -count=10 > old.txt

# perform code optimization...

# run benchmarks after optimization (10 repetitions)
go test -bench=. -count=10 > new.txt

# compare the results
benchstat old.txt new.txt
</code></pre>
<p>Example output:</p>
<pre><code>name          old time/op  new time/op  delta
Increase-8    1.23µs ± 2%  0.45µs ± 1%  -63.41% (p=0.000 n=10+10)
</code></pre>
<p>You can check the performance improvement ratio in the <code>delta</code> column. If the <code>p</code> value is below 0.05, the difference is statistically significant.</p>
<h1>8. Tips for Using pprof in Production</h1>
<h2>8.1 Overhead</h2>
<table>
<thead>
<tr>
<th>Profile Type</th>
<th>Overhead</th>
<th>Can Be Always-On?</th>
</tr>
</thead>
<tbody>
<tr>
<td>CPU</td>
<td>about 5% performance impact (only during collection)</td>
<td>collect only when needed</td>
</tr>
<tr>
<td>Heap</td>
<td>very low</td>
<td>can be always-on</td>
</tr>
<tr>
<td>Goroutine</td>
<td>very low</td>
<td>can be always-on</td>
</tr>
<tr>
<td>Block</td>
<td>depends on settings</td>
<td>sampling rate adjustment needed</td>
</tr>
<tr>
<td>Mutex</td>
<td>depends on settings</td>
<td>sampling rate adjustment needed</td>
</tr>
</tbody>
</table>
<p>Importing <code>net/http/pprof</code> itself has no performance impact. Overhead occurs only when you actually collect profile data.</p>
<h2>8.2 Security</h2>
<ul>
<li>Separate the pprof endpoints onto a <strong>dedicated port</strong> and block external access</li>
<li>Bind only to <code>localhost</code> to allow local access only</li>
<li>Add authentication middleware if needed</li>
<li>In Kubernetes environments, access via <code>port-forward</code></li>
</ul>
<pre><code class="language-bash"># access pprof in Kubernetes
kubectl port-forward pod/my-app-xxx 6060:6060

# profile locally
go tool pprof http://localhost:6060/debug/pprof/heap
</code></pre>
<h2>8.3 Continuous Profiling</h2>
<p>To catch intermittent performance problems in production, you need a tool that continuously collects profiles.</p>
<ul>
<li><strong><a href="https://pyroscope.io/">Pyroscope</a></strong>: an open-source continuous profiling platform</li>
<li><strong><a href="https://cloud.google.com/profiler">Google Cloud Profiler</a></strong>: a GCP-based profiling service</li>
<li><strong><a href="https://www.datadoghq.com/product/code-profiling/">Datadog Continuous Profiler</a></strong>: integrated with monitoring tools</li>
</ul>
<p>These tools periodically collect profiles in the background and store them as time-series data, letting you compare and analyze the performance state at past points in time.</p>
<h1>9. Summary</h1>
<h2>Use Scenarios by Profile Type</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Suspected Cause</th>
<th>Profile to Use</th>
<th>Analysis Point</th>
</tr>
</thead>
<tbody>
<tr>
<td>Slow API response</td>
<td>CPU bottleneck</td>
<td>CPU profile</td>
<td>check hot spots with <code>top</code>, <code>list</code></td>
</tr>
<tr>
<td>Increasing memory usage</td>
<td>memory leak</td>
<td>Heap profile</td>
<td><code>inuse_space</code> + diff comparison</td>
</tr>
<tr>
<td>Increasing goroutine count</td>
<td>goroutine leak</td>
<td>Goroutine profile</td>
<td>check stack dump with <code>debug=2</code></td>
</tr>
<tr>
<td>Throughput degradation</td>
<td>lock contention</td>
<td>Mutex profile</td>
<td>check contention points</td>
</tr>
<tr>
<td>Intermittent latency</td>
<td>blocking</td>
<td>Block profile</td>
<td>analyze wait times</td>
</tr>
<tr>
<td>Too many threads</td>
<td>excessive thread creation</td>
<td>Threadcreate profile</td>
<td>check creation pattern</td>
</tr>
<tr>
<td>Understand the overall flow</td>
<td>scheduling/GC issues</td>
<td>Trace</td>
<td>timeline analysis</td>
</tr>
</tbody>
</table>
<h2>Diagnosis Workflow Summary</h2>
<pre><code class="language-mermaid">flowchart TD
    A["Performance problem detected"] --> B{"Classify symptom"}
    B -->|"High CPU usage"| C["Collect CPU Profile"]
    B -->|"Memory increase"| D["Collect Heap Profile"]
    B -->|"Goroutine increase"| E["Collect Goroutine Profile"]
    B -->|"Throughput degradation"| F["Collect Mutex/Block Profile"]
    C --> G["Analyze with top, list, web"]
    D --> H["inuse_space + diff comparison"]
    E --> I["Check stack dump with debug=2"]
    F --> J["Check contention/wait points"]
    G --> K["Optimize the bottleneck function"]
    H --> K
    I --> K
    J --> K
    K --> L["Verify by re-collecting the profile"]
</code></pre>
<p>The code written in this post is available on <a href="https://github.com/kenshin579/tutorials-go/tree/master/golang/profiling">github</a>.</p>
<h1>10. References</h1>
<ul>
<li><a href="https://pkg.go.dev/net/http/pprof">https://pkg.go.dev/net/http/pprof</a></li>
<li><a href="https://pkg.go.dev/runtime/pprof">https://pkg.go.dev/runtime/pprof</a></li>
<li><a href="https://go.dev/blog/pprof">https://go.dev/blog/pprof</a></li>
<li><a href="https://go.dev/doc/diagnostics">https://go.dev/doc/diagnostics</a></li>
<li><a href="https://github.com/google/pprof">https://github.com/google/pprof</a></li>
<li><a href="https://github.com/google/gops">https://github.com/google/gops</a></li>
<li><a href="https://jvns.ca/blog/2017/09/24/profiling-go-with-pprof/">https://jvns.ca/blog/2017/09/24/profiling-go-with-pprof/</a></li>
<li><a href="https://www.practical-go-lessons.com/chap-36-program-profiling">https://www.practical-go-lessons.com/chap-36-program-profiling</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>pprof</category>
      <category>profiling</category>
      <category>performance</category>
      <category>cpu</category>
      <category>memory</category>
      <category>heap</category>
      <category>goroutine</category>
      <category>flame graph</category>
      <category>프로파일링</category>
      <category>성능분석</category>
      <category>고랭</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Golang Concurrency Part 3 - Advanced select and Channel Patterns</title>
      <link>https://blog.advenoh.pe.kr/en/golang-concurrency-3-select와-channel-심화</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/golang-concurrency-3-select와-channel-심화</guid>
      <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
      <description>Covers advanced channel patterns using Go's select statement — timeout, fan-in/fan-out, and dynamic deactivation with nil channels</description>
      <content:encoded><![CDATA[<p>The <code>select</code> statement is a powerful control structure unique to Go that <strong>waits on multiple channels at once</strong>. Using it, you can implement various concurrency patterns such as timeout, fan-in/fan-out, and dynamic channel management.</p>
<h1>1. select Statement Basics</h1>
<p><code>select</code> is similar to switch but specialized for <strong>channel operations</strong>. It runs <strong>one of the cases that is ready</strong>.</p>
<pre><code class="language-go">select {
case msg := &#x3C;-ch1: // runs if data arrives from ch1 first
    fmt.Println("ch1:", msg)
case msg := &#x3C;-ch2: // runs if data arrives from ch2 first
    fmt.Println("ch2:", msg)
}
</code></pre>
<h2>1.1 Random Selection Characteristic</h2>
<p>When multiple cases are ready at the same time, the Go runtime picks one <strong>at random</strong>. This prevents the <strong>starvation</strong> problem where a particular channel is always prioritized.</p>
<pre><code class="language-go">func TestSelectMultipleReady(t *testing.T) {
    ch1 := make(chan int, 1) // channel with buffer 1
    ch2 := make(chan int, 1)

    ch1Count, ch2Count := 0, 0
    for range 1000 { // repeat 1000 times to check the selection ratio
        ch1 &#x3C;- 1 // put values into both channels at once so both are ready
        ch2 &#x3C;- 2

        select {
        case &#x3C;-ch1: // both cases are ready, so the runtime picks at random
            ch1Count++
        case &#x3C;-ch2:
            ch2Count++
        }
        // drain the remaining value from the channel that wasn't selected
        select {
        case &#x3C;-ch1:
        case &#x3C;-ch2:
        default:
        }
    }

    t.Logf("ch1: %d, ch2: %d", ch1Count, ch2Count)
    // example output: ch1: 516, ch2: 484 (roughly 50:50)
}
</code></pre>
<h1>2. Using the default Case</h1>
<p>Adding a <code>default</code> case makes it <strong>non-blocking</strong>. When no channel is ready, default runs immediately.</p>
<h2>2.1 Non-blocking Receive</h2>
<pre><code class="language-go">select {
case val := &#x3C;-ch:
    fmt.Println("received:", val)
default:
    fmt.Println("no data available") // runs immediately if the channel is empty
}
</code></pre>
<h2>2.2 Non-blocking Send</h2>
<pre><code class="language-go">ch := make(chan int, 1)
ch &#x3C;- 1 // buffer full

select {
case ch &#x3C;- 2:
    fmt.Println("sent")
default:
    fmt.Println("buffer full") // runs immediately if the buffer is full
}
</code></pre>
<blockquote>
<p>default is useful for polling or busy-wait, but overusing it in a loop can use excessive CPU.</p>
</blockquote>
<h1>3. Handling Timeouts</h1>
<h2>3.1 time.After</h2>
<p><code>time.After</code> returns a channel that sends a value after the specified duration. Combined with select, you can implement a timeout simply.</p>
<pre><code class="language-go">func TestTimeoutWithTimeAfter(t *testing.T) {
    ch := make(chan string)

    go func() {
        time.Sleep(200 * time.Millisecond) // simulate a slow task taking 200ms
        ch &#x3C;- "result"
    }()

    select {
    case msg := &#x3C;-ch: // handle normally if the task result arrives first
        t.Log("received:", msg)
    case &#x3C;-time.After(50 * time.Millisecond): // receive from the timeout channel if 50ms is exceeded
        t.Log("timeout!")
    }
}
</code></pre>
<h2>3.2 context.WithTimeout</h2>
<p>In practice, <code>context.WithTimeout</code> is used more often. context allows cancellation propagation and lets you manage a timeout across multiple goroutines.</p>
<pre><code class="language-go">// simulateAPICall - simulates an API call with a context-based timeout
func simulateAPICall(ctx context.Context, delay time.Duration) (string, error) {
    ch := make(chan string, 1) // buffer 1: the goroutine can send the result and terminate right away

    go func() {
        time.Sleep(delay) // simulate API call latency
        ch &#x3C;- "api response"
    }()

    select {
    case result := &#x3C;-ch: // return normally if the API response arrives first
        return result, nil
    case &#x3C;-ctx.Done(): // return an error if the context timeout is exceeded
        return "", ctx.Err() // context.DeadlineExceeded
    }
}

func TestSimulateAPICallTimeout(t *testing.T) {
    // set a 50ms timeout — the API takes 200ms, so a timeout occurs
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel() // always call cancel to release resources

    result, err := simulateAPICall(ctx, 200*time.Millisecond)
    assert.ErrorIs(t, err, context.DeadlineExceeded)
    assert.Empty(t, result)
}
</code></pre>
<h1>4. Fan-in / Fan-out Patterns</h1>
<h2>4.1 Fan-out</h2>
<p>A pattern that <strong>distributes a single input to multiple workers</strong>. Multiple goroutines take work from the same channel.</p>
<pre><code class="language-mermaid">graph LR
    J[jobs] --> W1[Worker 1] --> R1[result]
    J --> W2[Worker 2] --> R2[result]
    J --> W3[Worker 3] --> R3[result]
</code></pre>
<pre><code class="language-go">func TestFanOut(t *testing.T) {
    jobs := make(chan int, 10)  // shared channel for distributing work
    numWorkers := 3

    workerResults := make([]chan int, numWorkers) // result channel per worker
    for i := range numWorkers {
        workerResults[i] = make(chan int, 10)
    }

    var wg sync.WaitGroup
    for i := range numWorkers {
        wg.Add(1)
        go func() { // each worker takes work from the same jobs channel
            defer wg.Done()
            for job := range jobs { // the loop ends when jobs is closed
                workerResults[i] &#x3C;- job * job // square and send the result
            }
            close(workerResults[i])
        }()
    }

    for i := 1; i &#x3C;= 9; i++ { // send 9 jobs to the channel
        jobs &#x3C;- i
    }
    close(jobs) // all jobs sent → workers end their loop
    wg.Wait()
}
</code></pre>
<h2>4.2 Fan-in</h2>
<p>A pattern that <strong>merges the results of multiple channels into a single channel</strong>.</p>
<pre><code class="language-mermaid">graph LR
    S1[source1] --> M[merged channel]
    S2[source2] --> M
    S3[source3] --> M
</code></pre>
<pre><code class="language-go">// fanIn - merges values from multiple channels into a single channel
func fanIn(channels ...&#x3C;-chan string) &#x3C;-chan string {
    var wg sync.WaitGroup
    merged := make(chan string) // the single channel where all results collect

    for _, ch := range channels {
        wg.Add(1)
        go func() { // a goroutine per source channel forwards values to merged
            defer wg.Done()
            for v := range ch { // the loop ends when the source channel is closed
                merged &#x3C;- v
            }
        }()
    }

    go func() {
        wg.Wait()   // wait until all sources complete
        close(merged) // close the merged channel after all sources complete
    }()

    return merged
}
</code></pre>
<p><strong>Example of calling fanIn:</strong></p>
<pre><code class="language-go">func TestFanIn(t *testing.T) {
    // 3 independent data sources
    source1 := make(chan string, 3)
    source2 := make(chan string, 3)

    go func() {
        for _, s := range []string{"a1", "a2", "a3"} {
            source1 &#x3C;- s
        }
        close(source1) // always close after sending data
    }()

    go func() {
        for _, s := range []string{"b1", "b2"} {
            source2 &#x3C;- s
        }
        close(source2)
    }()

    // Fan-in: merge 2 channels into one
    merged := fanIn(source1, source2)

    for v := range merged { // the loop ends when the merged channel is closed
        fmt.Println(v)
    }
    // output (order is non-deterministic): a1, b1, a2, b2, a3
}
</code></pre>
<h2>4.3 Combining Fan-out + Fan-in</h2>
<p>In practice, you combine the two patterns to build a <strong>parallel processing pipeline</strong>.</p>
<pre><code class="language-mermaid">graph LR
    Input --> Fan-out
    Fan-out --> Worker1
    Fan-out --> Worker2
    Fan-out --> Worker3
    Worker1 --> Fan-in
    Worker2 --> Fan-in
    Worker3 --> Fan-in
    Fan-in --> Result
</code></pre>
<h1>5. The Nil Channel Trick</h1>
<p>Characteristics of a nil channel:</p>
<ul>
<li><strong>sending</strong> to a nil channel <strong>blocks forever</strong></li>
<li><strong>receiving</strong> from a nil channel <strong>blocks forever</strong></li>
<li>a nil channel case in a select is <strong>ignored</strong></li>
</ul>
<p>Using this, you can <strong>dynamically enable/disable</strong> cases in a select.</p>
<pre><code class="language-go">func TestNilChannelDisable(t *testing.T) {
    ch1 := make(chan int, 3)
    ch2 := make(chan int, 3)

    ch1 &#x3C;- 1; ch1 &#x3C;- 2; ch1 &#x3C;- 3; close(ch1) // send 3 values to ch1 then close
    ch2 &#x3C;- 10; ch2 &#x3C;- 20; close(ch2)           // send 2 values to ch2 then close

    var results []int
    // declare as receive-only channel variables — can be disabled by assigning nil
    var active1, active2 = (&#x3C;-chan int)(ch1), (&#x3C;-chan int)(ch2)

    for active1 != nil || active2 != nil { // when both become nil, all data is consumed
        select {
        case v, ok := &#x3C;-active1:
            if !ok {
                active1 = nil // closed channel → setting to nil makes select ignore it
                continue
            }
            results = append(results, v)
        case v, ok := &#x3C;-active2:
            if !ok {
                active2 = nil // disable ch2 the same way
                continue
            }
            results = append(results, v)
        }
    }

    assert.Len(t, results, 5) // ch1: 3 + ch2: 2 = 5 total
}
</code></pre>
<p><strong>Use cases</strong>:</p>
<ul>
<li>when merging multiple data sources, disable each source as it completes</li>
<li>turn processing of a particular channel on/off depending on a condition</li>
</ul>
<h1>6. Wrapping Up</h1>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Core</th>
</tr>
</thead>
<tbody>
<tr>
<td>select</td>
<td>wait on multiple channels at once, run one ready case</td>
</tr>
<tr>
<td>random selection</td>
<td>random pick when multiple cases are ready (prevents starvation)</td>
</tr>
<tr>
<td>default</td>
<td>non-blocking behavior, runs immediately when no channel is ready</td>
</tr>
<tr>
<td>time.After</td>
<td>simple timeout handling</td>
</tr>
<tr>
<td>context.WithTimeout</td>
<td>production timeout (supports cancellation propagation)</td>
</tr>
<tr>
<td>Fan-out</td>
<td>distribute one input to multiple workers</td>
</tr>
<tr>
<td>Fan-in</td>
<td>merge the results of multiple channels into one</td>
</tr>
<tr>
<td>Nil channel</td>
<td>dynamically disable a select case</td>
</tr>
</tbody>
</table>
<p>In the next part, we'll cover the <code>sync</code> package, which <strong>safely manages shared resources</strong> between goroutines.</p>
<h1>7. References</h1>
<ul>
<li><a href="https://go.dev/tour/concurrency/5">Go Tour - Select</a></li>
<li><a href="https://go.dev/blog/pipelines">Go Blog - Pipelines and cancellation</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>concurrency</category>
      <category>select</category>
      <category>timeout</category>
      <category>fan-in</category>
      <category>fan-out</category>
      <category>nil-channel</category>
      <category>고랭</category>
      <category>동시성</category>
      <category>Series: Golang Concurrency</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>The Complete Guide to Claude Code Extensions: Command, Skill, Subagent</title>
      <link>https://blog.advenoh.pe.kr/en/claude-code-확장-기능-완벽-가이드-command-skill-subagent</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/claude-code-확장-기능-완벽-가이드-command-skill-subagent</guid>
      <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
      <description>Organizes the concepts and differences between Claude Code's core extension mechanisms—Command, Skill, and Subagent—and explores how to use them in practice.</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<p><img src="https://blog.advenoh.pe.kr/images/ai/claude-code-확장-기능-완벽-가이드-command-skill-subagent/image-20260206193705314.png" alt="Claude Code"></p>
<p>Claude Code is an AI-powered CLI tool built by Anthropic that reads and modifies code directly in the terminal and performs a wide range of development tasks. It goes beyond simple question-and-answer interactions to autonomously handle file exploration, code modification, Git operations, and test execution—an agentic coding tool.</p>
<p>Using Claude Code with its default configuration is already productive enough, but when repetitive work patterns emerge or you want to apply project-specific rules, you need <strong>extensions</strong>. Claude Code provides three core mechanisms for this.</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>Custom Commands</th>
<th>Skills</th>
<th>Subagents</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Definition</strong></td>
<td>Reusable prompt shortcuts</td>
<td>Extension system that injects knowledge/guidelines</td>
<td>Specialized agents that work in an isolated context</td>
</tr>
<tr>
<td><strong>Location</strong></td>
<td><code>.claude/commands/</code></td>
<td><code>.claude/skills/&#x3C;name>/SKILL.md</code></td>
<td><code>.claude/agents/&#x3C;name>.md</code></td>
</tr>
<tr>
<td><strong>Invocation</strong></td>
<td><code>/name</code> (user manual)</td>
<td><code>/name</code> (manual) + automatic invocation possible</td>
<td>Claude delegates automatically or on user request</td>
</tr>
<tr>
<td><strong>Execution context</strong></td>
<td>Main conversation (inline)</td>
<td>Main conversation (inline), isolated with <code>context: fork</code></td>
<td>Separate context window (isolated)</td>
</tr>
<tr>
<td><strong>Tool restriction</strong></td>
<td>None</td>
<td>Restricted via <code>allowed-tools</code></td>
<td>Fine-grained control via <code>tools</code>/<code>disallowedTools</code></td>
</tr>
<tr>
<td><strong>Parallel execution</strong></td>
<td>Not possible</td>
<td>Not possible</td>
<td>Possible (multiple subagents run concurrently)</td>
</tr>
</tbody>
</table>
<p>These three look similar, but <strong>they break problems down in fundamentally different ways</strong>. This article organizes the concept, configuration, and practical examples of each, and discusses when to choose which.</p>
<blockquote>
<p>The example code used in this article is available in the <a href="https://github.com/kenshin579/tutorials-go/tree/master/.claude">tutorials-go/.claude</a> repository.</p>
</blockquote>
<h1>2. Command (Slash Command)</h1>
<h2>2.1 Concept</h2>
<p>A Command is a prompt template that the user runs directly by typing <code>/</code>. It acts as a <strong>shortcut</strong> that stores a frequently repeated task as a single command and invokes it when needed.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Invoked directly by the user</strong> (<code>/commit</code>, <code>/plan-task</code>, etc.)</li>
<li><strong>One-time execution</strong>: the prompt is injected at invocation time and Claude executes it immediately</li>
<li><strong>Argument passing supported</strong>: parameters passed in the form <code>/command arg1 arg2</code></li>
<li><strong>Autocomplete support</strong>: typing <code>/</code> displays the list of available commands</li>
<li><strong>Inline execution in the main conversation context</strong> (no separate isolation)</li>
</ul>
<h3>2.1.1 Storage Location</h3>
<pre><code>~/.claude/commands/        # Global (used across all projects)
.claude/commands/          # Project-specific (used only in that project)
</code></pre>
<p>Global commands are used commonly across all projects, while project commands can be stored in the <code>.claude/commands/</code> directory and shared with teammates via Git.</p>
<p>Using subdirectories creates namespace separation.</p>
<pre><code>.claude/commands/
├── commit.md            # /commit
├── plan-task.md         # /plan-task
├── start-task.md        # /start-task
└── work/                # Namespace (group commands via subdirectory)
    ├── commit-pr.md     # /work:commit-pr
    └── start-task.md    # /work:start-task
</code></pre>
<h2>2.2 Practical Example: Repetitive Work in a Single Slash</h2>
<h3>2.2.1 Git Commit Automation (<code>/commit</code>)</h3>
<p>This is the most basic form of a Command—a simple prompt template.
Instead of repeating <code>git add</code>, <code>git commit</code>, and <code>git push</code> every time you modify code, a single <code>/commit</code> automates everything from analyzing changes to generating the commit message to pushing.</p>
<p><code>.claude/commands/commit.md</code>:</p>
<pre><code class="language-markdown">## Purpose
Commit modified files and push to the remote repository

## Execution Steps
1. Check changes with `git status`
2. If the current branch is main/master:
   - Create a new branch in the format `feature/{summary-of-work}`
3. Stage changed files (`git add`)
4. Write the commit message (conventional commits format)
5. Push to the remote repository

## Rules
- No direct commits to the main/master branch
- Commit message format: `type: brief description`
  - type: feat, fix, docs, refactor, test, chore, etc.
</code></pre>
<p><strong>Usage</strong>: After modifying code, type <code>/commit</code> and Claude analyzes the changes, automatically generates a conventional commit message, and commits.</p>
<h3>2.2.2 Generating Implementation Docs from a PRD (<code>/plan-task</code>)</h3>
<p>This is an example of a Command that takes arguments using <code>$ARGUMENTS</code>.
Instead of manually creating an implementation document and todo checklist every time after writing a PRD (requirements document), you just pass the PRD path and Claude analyzes it and generates them automatically.</p>
<p><code>.claude/commands/plan-task.md</code>:</p>
<pre><code class="language-markdown">## Purpose
Generate an implementation document and todo checklist based on $ARGUMENTS (requirements file)

## Arguments
- $ARGUMENTS: requirements file path (e.g., docs/start/1_feature_prd.md)

## Execution Steps
1. Read and analyze the requirements file ($ARGUMENTS)
2. Generate the implementation document ({order}_{feature-name}_implementation.md)
   - Include only the core implementation details
   - Exclude unnecessary content such as future plans and extensibility
3. Generate the todo checklist ({order}_{feature-name}_todo.md)
   - Break it into stages
   - Each item in checkbox format (- [ ])

## File Generation Rules
- Create in the same directory as the requirements file
- Filename pattern: `{order}_{feature-name}_{document-type}.md`

## Example
Input: `/plan-task docs/start/1_github_action_prd.md`

Output:
- `docs/start/1_github_action_implementation.md`
- `docs/start/1_github_action_todo.md`
</code></pre>
<p><strong>Usage</strong>: Typing <code>/plan-task docs/start/3_claude_prd.md</code> analyzes the PRD and automatically generates the implementation document and todo checklist.</p>
<p><strong>Key point</strong>: It takes user arguments via <code>$ARGUMENTS</code> and operates dynamically. Although it is a multi-stage workflow of read file -> analyze -> generate file, it still runs inline in the main conversation context.</p>
<h3>2.2.3 Starting a Task (<code>/start-task</code>)</h3>
<p>This is an example of a composite-workflow Command that combines multiple tools (GitHub MCP, Git).
When starting development of a new feature, it handles GitHub Issue creation, feature branch creation, and work environment setup with a single command. It demonstrates how Commands can extend beyond simple prompts to integrate external tools.</p>
<p><code>.claude/commands/start-task.md</code>:</p>
<pre><code class="language-markdown">## Purpose
Start work based on $ARGUMENTS (PRD document)

## Arguments
- $ARGUMENTS: PRD document path (e.g., docs/start/3_claude_prd.md)

## Execution Steps
1. Create a GitHub Issue (using GitHub MCP)
   - title: PRD title
   - body: summary of PRD content
   - assignee: kenshin579
2. Pull the latest code from the master branch
3. Create a new feature branch: `feature/{issue-number}-{feature-name}`
4. If a todo file exists, proceed with the work in order
5. On completion of each step:
   - Commit the changes
   - Mark the item as done in the todo file (- [x])

## Rules
- No direct commits to the master branch
- Follow the step order in the todo file
- Commit messages in conventional commits format

## Example
Input: `/start-task docs/start/3_claude_prd.md`

Behavior:
1. Create GitHub Issue #607: "Claude Code Skills vs Commands vs Subagents blog examples"
2. Create branch `feature/607-claude-blog-examples`
3. Start work in the order of the todo file
</code></pre>
<p><strong>Key point</strong>: After generating the implementation document and todo with <code>/plan-task</code>, you can kick off the actual work with <code>/start-task</code>—<strong>workflow chaining between Commands</strong> is possible. This is a composite Command that combines GitHub MCP + Git commands.</p>
<h1>3. Skill</h1>
<h2>3.1 Concept</h2>
<p>A Skill is a <strong>knowledge package that Claude automatically detects and loads</strong>. While the user can invoke it directly, the core feature is that Claude analyzes the current task context and automatically activates the relevant Skill.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Automatically invoked by the model</strong>: Claude judges relevance and loads it without the user explicitly running it</li>
<li><strong>Manual invocation also possible</strong>: with <code>user-invocable: true</code>, you can invoke it directly via <code>/skill-name</code></li>
<li><strong>Progressive Disclosure</strong>: it first scans the metadata (about 100 tokens) and loads the full content (under 5,000 tokens) only when relevant</li>
<li><strong>Folder-based structure</strong>: auxiliary files (templates, references, scripts) can be packaged together</li>
<li><strong>Tool restriction possible</strong>: <code>allowed-tools</code> restricts the tools the Skill can use</li>
<li><strong>Isolated execution possible</strong>: with <code>context: fork</code>, it runs in a separate context (the boundary with Subagents)</li>
</ul>
<h3>3.1.1 Storage Location</h3>
<pre><code>~/.claude/skills/          # Global (personal)
.claude/skills/            # Project-specific
</code></pre>
<p>Each Skill is organized as an independent folder.</p>
<pre><code>.claude/skills/
├── go-convention/
│   └── SKILL.md
├── go-project-layout/
│   └── SKILL.md
├── api-convention/
│   └── SKILL.md
└── analyze-codebase/
    └── SKILL.md
</code></pre>
<h3>3.1.2 SKILL.md Frontmatter Fields</h3>
<table>
<thead>
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td>O</td>
<td>Unique name of the Skill</td>
</tr>
<tr>
<td><code>description</code></td>
<td>O</td>
<td>Purpose description (<strong>the key to automatic activation</strong>)</td>
</tr>
<tr>
<td><code>user-invocable</code></td>
<td>X</td>
<td><code>true</code> allows manual invocation via <code>/name</code>; <code>false</code> makes it Subagent-only</td>
</tr>
<tr>
<td><code>allowed-tools</code></td>
<td>X</td>
<td>Restricts usable tools (uses all when omitted)</td>
</tr>
<tr>
<td><code>context</code></td>
<td>X</td>
<td>Setting <code>fork</code> runs it isolated in a separate context</td>
</tr>
<tr>
<td><code>agent</code></td>
<td>X</td>
<td>The agent type to use with <code>context: fork</code></td>
</tr>
<tr>
<td><code>model</code></td>
<td>X</td>
<td>The model to use (<code>sonnet</code>, <code>opus</code>, <code>haiku</code>)</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>Note on the <code>agent</code> field</strong>: Built-in types include <code>Explore</code> (code exploration, read-only), <code>Plan</code> (implementation-plan research), and <code>general-purpose</code> (general, default). You can also specify the name of a custom agent defined in <code>.claude/agents/</code>.</p>
</blockquote>
<h2>3.2 Practical Example: Project Knowledge Injected Automatically</h2>
<h3>3.2.1 Go Coding Convention (<code>go-convention</code>)</h3>
<p>This example demonstrates a Skill's core features: "automatic invocation" and "knowledge injection."
When you request "write some Go code," Claude looks at the <code>description</code> and automatically activates the Skill. At that point, the coding rules written in the SKILL.md body are loaded into the main conversation's context as if they were a system prompt—this is "knowledge injection." The injected knowledge persists until the conversation ends, so subsequent code generation or reviews automatically follow the conventions without separate instructions.</p>
<p><code>.claude/skills/go-convention/SKILL.md</code>:</p>
<pre><code class="language-markdown">---
name: go-convention
description: Apply the project's coding conventions when writing or reviewing Go code.
  Used automatically when creating, modifying, or reviewing Go files.
model: haiku
user-invocable: true
allowed-tools: Read, Grep, Glob
---

## This Project's Go Coding Conventions

### Package Structure
- Test files are placed in the same directory as the source file: `*_test.go`
- Mock files are created in the `mocks/` subdirectory
- Each directory is organized as an independent example (may have its own `go.mod`)

### Test Writing Rules
- Use testify: `github.com/stretchr/testify/assert`
- Apply the table-driven test pattern
- Test function name: `Test{FunctionName}_{Scenario}` format

### Error Handling
- For custom error types, use `errors.New()` or `fmt.Errorf("context: %w", err)`
- Define sentinel errors as package-level variables: `var ErrNotFound = errors.New("not found")`

### Import Ordering
- Order: standard library → external packages → internal packages
- Internal package alias: use an underscore prefix (`_articleHttp`)
</code></pre>
<p><strong>Behavior scenario</strong>:</p>
<ol>
<li><strong>Automatic invocation</strong>: When the user requests "create a new Go file," Claude recognizes the "creating, modifying Go files" wording in the <code>description</code>, automatically loads this Skill, and applies the conventions</li>
<li><strong>Manual invocation</strong>: Invoke <code>/go-convention</code> directly to check whether existing code follows the conventions</li>
</ol>
<p><strong>Key points</strong>:</p>
<ul>
<li>The trigger condition in the <code>description</code> is clear, so Claude <strong>automatically invokes</strong> it as appropriate (the key difference from a Command)</li>
<li><code>user-invocable: true</code> also enables manual invocation</li>
<li>The knowledge is injected into the main conversation and <strong>continues to influence</strong> later work</li>
</ul>
<h3>3.2.2 Go Project Layout (<code>go-project-layout</code>)</h3>
<p>When you use the <code>!`shell command`</code> syntax in the SKILL.md body, the shell command runs at the moment the Skill is activated and its result is inserted into the body. This is called "dynamic context."
For example, if you write <code>!`tree project-layout/ -L 3`</code>, the <code>tree</code> command runs each time the Skill loads and injects the actual directory structure into the context. Unlike writing the structure directly as static text, this always reflects the latest state even when the project structure changes.</p>
<p>Any command runnable in a shell can be used, so Python scripts or pipe combinations are also possible.</p>
<pre><code class="language-bash"># Run a Python script
!`python3 scripts/generate_context.py`

# Pipe combination
!`cat config.json | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin), indent=2))"`
</code></pre>
<blockquote>
<p>However, since it runs every time the Skill is activated, a long-running script can delay Skill loading.</p>
</blockquote>
<p><code>.claude/skills/go-project-layout/SKILL.md</code>:</p>
<pre><code class="language-markdown">---
name: go-project-layout
description: Apply a clean architecture folder structure when creating a new Go project
  or changing its structure.
model: haiku
user-invocable: true
allowed-tools: Read, Grep, Glob
---

## Reference Project Structure

Below is the clean architecture layout used in this project.
Follow this structure when creating a new Go project.

!`tree project-layout/go-clean-arch-v2 -I vendor -L 3`

## Roles by Layer

### `cmd/`
- Application entry point (`main.go`)
- DI container setup, server startup

### `domain/`
- Core business entities (struct)
- Repository/use case interface definitions
- Error type definitions (`errors.go`)
- Mock files located in `domain/mocks/`

### `{domain-name}/` (e.g., `article/`, `author/`)
- `handler.go`: HTTP handlers (Echo routing)
- `usecase.go`: business logic implementation
- `repository.go`: data access implementation
- `*_test.go`: tests for each file

### `pkg/`
- `config/`: Viper-based configuration management
- `database/`: DB connection setup
- `middleware/`: common middleware such as CORS and authentication

## Dependency Direction
\```
cmd/ → {domain}/ → domain/
         ↓
        pkg/
\```
- The domain package has no external dependencies (pure Go)
- Domain-specific packages implement the domain interfaces
- cmd/ is responsible for assembling all packages
</code></pre>
<p><strong>Key points</strong>:</p>
<ul>
<li>With <code>!</code> (a dynamic-context command), the <strong>actual project structure is read via tree</strong> at execution time—referencing runtime data rather than static text</li>
<li>It can be separated from <code>go-convention</code> (coding style) for <strong>separation of concerns</strong></li>
<li>Later combined and used in the <code>api-developer</code> Subagent via <code>skills: [go-project-layout]</code></li>
</ul>
<h3>3.2.3 Codebase Analysis with <code>context: fork</code> (<code>analyze-codebase</code>)</h3>
<p>This example shows that a Skill can run in an isolated context like a Subagent.
Because it runs in a separate context via <code>context: fork</code>, it can analyze a large number of files without consuming the main conversation's context window and returns only the result.</p>
<p><code>.claude/skills/analyze-codebase/SKILL.md</code>:</p>
<pre><code class="language-markdown">---
name: analyze-codebase
description: Analyzes and summarizes the structure of a codebase
model: sonnet
user-invocable: true
context: fork
agent: Explore
---

Analyze the codebase at the $ARGUMENTS path.

## Analysis Items
1. **Directory structure**: file and package organization
2. **Core types/interfaces**: list of key structs and interfaces
3. **Dependencies**: dependency relationships between external libraries and internal packages
4. **Test status**: presence of test files, test patterns (testify, testcontainers, etc.)
5. **Entry point**: main.go or the main executable file

## Output Format
Summarize in markdown, referencing each file in `filename:line-number` format
</code></pre>
<p><strong>Usage</strong>: Running <code>/analyze-codebase golang/concurrency</code> has the Explore agent analyze that directory in a separate context and return only the result to the main conversation.</p>
<p><strong>Key points</strong>:</p>
<ul>
<li>Using <code>context: fork</code> makes the Skill <strong>run isolated in a separate context</strong></li>
<li><code>agent: Explore</code> specifies the agent type to use (optimized for fast exploration)</li>
<li>Reading a large number of files does not pollute the main context window</li>
<li>The point where the boundaries of Skills and Subagents meet: <strong>Skill format + Subagent execution model</strong></li>
</ul>
<h2>3.3 Skill vs CLAUDE.md</h2>
<p>Both provide knowledge to Claude, but there is a fundamental difference.</p>
<table>
<thead>
<tr>
<th>Comparison</th>
<th>CLAUDE.md</th>
<th>Skill</th>
</tr>
</thead>
<tbody>
<tr>
<td>Loading timing</td>
<td>Always loaded at conversation start</td>
<td>Loaded only when relevant work is detected</td>
</tr>
<tr>
<td>Token consumption</td>
<td>Always occupies context</td>
<td>Consumed only when needed (progressive loading)</td>
</tr>
<tr>
<td>Structure</td>
<td>Single file</td>
<td>Folder (can include auxiliary files)</td>
</tr>
<tr>
<td>Purpose</td>
<td>Project-wide rules and conventions</td>
<td>Specialized knowledge for specific tasks</td>
</tr>
</tbody>
</table>
<p><strong>Decision criterion</strong>: Putting rules that are always needed in every conversation in <code>CLAUDE.md</code>, and separating specialized knowledge needed only in specific situations into <code>Skill</code>, is how to use context efficiently.</p>
<h2>3.4 Improving Automatic Activation</h2>
<p>If a Skill does not activate automatically as intended, you can use a Hook to explicitly trigger activation. A Hook registers a regex matcher on the <code>UserPromptSubmit</code> event in <code>.claude/settings.json</code>, so that when the user input matches the pattern, it instructs Claude to use a specific Skill.</p>
<p><strong>Example 1</strong>: Force-activate the <code>subagent-creator</code> Skill on requests like "create a subagent"</p>
<pre><code class="language-json">// .claude/settings.json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "sub-?agent|custom agent|에이전트 만들",
        "command": "echo 'Use Skill(subagent-creator)'"
      }
    ]
  }
}
</code></pre>
<p>When the user types "create a subagent" or "create a custom agent," Claude automatically loads the <code>subagent-creator</code> Skill and guides the agent-creation process.</p>
<p><strong>Example 2</strong>: Force-activate the <code>go-convention</code> Skill on Go test-related requests</p>
<pre><code class="language-json">{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "테스트 작성|test 추가|_test\\.go",
        "command": "echo 'Use Skill(go-convention)'"
      }
    ]
  }
}
</code></pre>
<p>When you request "write a test for this function," the <code>go-convention</code> Skill is force-loaded and conventions such as using testify and the table-driven test pattern are applied. This is useful when automatic activation via the <code>description</code> alone does not work well.</p>
<h1>4. Subagent</h1>
<h2>4.1 Concept</h2>
<p>A Subagent is a <strong>specialized agent with its own independent context window</strong>. When the main Claude encounters a complex task, it delegates to an appropriate Subagent, which performs the work in its own context and then returns a summary of the result.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Independent context</strong>: does not pollute the main conversation's context</li>
<li><strong>Role separation</strong>: separated by specialized domains such as code review, debugging, and test execution</li>
<li><strong>Tool restriction</strong>: <code>tools</code>/<code>disallowedTools</code> allow only the tools appropriate for the role</li>
<li><strong>Model selection</strong>: a different model can be used depending on task complexity (cost savings)</li>
<li><strong>Skill combination</strong>: domain knowledge is injected via the <code>skills</code> field to strengthen expertise</li>
<li><strong>Automatic delegation</strong>: based on the <code>description</code>, Claude automatically delegates work at the appropriate time</li>
</ul>
<h3>4.1.1 Storage Location</h3>
<pre><code>~/.claude/agents/          # Global (personal)
.claude/agents/            # Project-specific
</code></pre>
<p>Each Subagent is defined as a single <code>.md</code> file.</p>
<pre><code>.claude/agents/
├── code-reviewer.md       # Code review specialist
├── debugger.md            # Debugging specialist
├── test-runner.md         # Test execution specialist
└── api-developer.md       # API development specialist (Skill integration)
</code></pre>
<h3>4.1.2 Frontmatter Fields</h3>
<table>
<thead>
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td>O</td>
<td>Lowercase, hyphen-separated</td>
</tr>
<tr>
<td><code>description</code></td>
<td>O</td>
<td>Purpose and invocation timing (the key to automatic delegation)</td>
</tr>
<tr>
<td><code>tools</code></td>
<td>X</td>
<td>Comma-separated tool list (inherits all when omitted)</td>
</tr>
<tr>
<td><code>disallowedTools</code></td>
<td>X</td>
<td>List of tools to forbid (control in the opposite direction of <code>tools</code>)</td>
</tr>
<tr>
<td><code>model</code></td>
<td>X</td>
<td><code>sonnet</code>, <code>opus</code>, <code>haiku</code>, <code>inherit</code></td>
</tr>
<tr>
<td><code>permissionMode</code></td>
<td>X</td>
<td><code>default</code>, <code>acceptEdits</code>, <code>bypassPermissions</code>, <code>plan</code></td>
</tr>
<tr>
<td><code>skills</code></td>
<td>X</td>
<td>Skills to auto-load</td>
</tr>
</tbody>
</table>
<h2>4.2 Practical Example: Building Role-Specific Specialist Agents</h2>
<h3>4.2.1 Code Review Specialist (<code>code-reviewer</code>)</h3>
<p>This example demonstrates independent execution and <strong>read-only</strong> tool restriction.
It is automatically delegated to right after code is modified to perform a review, but since it has no Write/Edit tools, it cannot change the code directly and returns only feedback.</p>
<p><code>.claude/agents/code-reviewer.md</code>:</p>
<pre><code class="language-markdown">---
name: code-reviewer
description: A professional code review specialist. Proactively reviews code quality,
  security, and maintainability. Use right after writing or modifying code.
tools: Read, Grep, Glob, Bash
model: inherit
---

You are a senior code reviewer who ensures high code quality and security.

When invoked:
1. Run git diff to check recent changes
2. Focus on the modified files
3. Begin the review immediately

Review checklist:
- Is the code simple and readable
- Are functions and variables well named
- Is there duplicated code
- Is there proper error handling
- Are there any exposed secrets or API keys
- Is input validation implemented
- Is there good test coverage
- Are performance considerations addressed

Provide feedback organized by priority:
- Critical issues (must fix)
- Warnings (should fix)
- Suggestions (consider improving)

Include concrete code examples of how to resolve issues.
</code></pre>
<p><strong>Behavior scenario</strong>:</p>
<ol>
<li>The user writes/modifies code</li>
<li>Claude recognizes the "Use right after writing or modifying code" wording in the description</li>
<li>Automatically delegates the work to the code-reviewer subagent</li>
<li>The Subagent analyzes <code>git diff</code> in a separate context and returns the review result</li>
</ol>
<p><strong>Key point</strong>: <code>tools: Read, Grep, Glob, Bash</code> has <strong>no Write/Edit</strong>. Being read-only, there is no risk of accidentally modifying code during analysis.</p>
<h3>4.2.2 Debugging Specialist (<code>debugger</code>)</h3>
<p>Unlike code-reviewer, this is a Subagent example that <strong>includes Edit</strong> to perform fixes as well.
When a test failure or error occurs, it is automatically delegated to, analyzes the cause, and directly modifies the code to resolve the problem.</p>
<p><code>.claude/agents/debugger.md</code>:</p>
<pre><code class="language-markdown">---
name: debugger
description: A debugging specialist for errors, test failures, and unexpected behavior.
  Use proactively when a problem occurs.
tools: Read, Edit, Bash, Grep, Glob
---

You are an expert debugger specializing in root cause analysis.

When invoked:
1. Capture the error message and stack trace
2. Identify reproduction steps
3. Isolate the failure location
4. Implement a minimal fix
5. Verify the fix with `go test`

Debugging process:
- Analyze error messages and logs
- Check recent code changes (git diff, git log)
- Form hypotheses and test them
- Check related test files (*_test.go)

For each issue, provide:
- An explanation of the root cause
- Evidence supporting the diagnosis (filename:line-number)
- A concrete code fix
- Test execution results after the fix

Focus on solving the fundamental problem, not the symptoms.
</code></pre>
<p><strong>Key point</strong>: With <code>tools: Read, Edit, Bash, Grep, Glob</code>, <strong>Edit is added</strong> compared to code-reviewer, allowing it to modify code directly. It autonomously performs the entire flow of error analysis -> cause identification -> code modification -> go test verification in a separate context.</p>
<h3>4.2.3 Test Execution Specialist (<code>test-runner</code>)</h3>
<p>This example demonstrates fine-grained control with <code>disallowedTools</code> and <code>model: haiku</code>.
It is responsible only for test execution and result reporting, and is restricted from modifying code. Since it is a lightweight task, the <code>haiku</code> model is specified to optimize cost and speed.</p>
<p><code>.claude/agents/test-runner.md</code>:</p>
<pre><code class="language-markdown">---
name: test-runner
description: A specialist that runs Go tests and analyzes the results. Use for test
  execution requests or when test verification is needed after code changes.
tools: Read, Bash, Grep, Glob
disallowedTools: Write, Edit
model: haiku
---

You are a specialist in running tests and analyzing results for Go projects.

When invoked:
1. Check the test files of the target package (*_test.go)
2. Run `go test` (verbose + coverage)
3. Analyze the results and generate a report

Execution commands:
- Single package: `go test -v -cover ./path/to/package/...`
- Specific test: `go test -v -run TestFunctionName ./path/to/package`
- All: `go test -v -cover ./...`

Report format:
- Total tests / passed / failed / skipped
- Per failed test: test name, error message, failure location (filename:line-number)
- Coverage: coverage % per package
- Execution time

Failure analysis:
- Compare expected vs actual values of failed tests
- Reference related source code (filename:line-number)
- Estimate possible causes (do not modify code)

Do not modify code directly. Provide only the analysis results.
</code></pre>
<p><strong>Key points</strong>:</p>
<ul>
<li><code>disallowedTools: Write, Edit</code> explicitly forbids code modification (analysis only)</li>
<li><code>model: haiku</code> uses a <strong>low-cost model</strong> for the simple run+analyze task to save costs</li>
<li>The system prompt also states "Do not modify code directly" as a double safeguard</li>
</ul>
<h2>4.3 Comparing the Tool Combinations of the 3 Subagents</h2>
<table>
<thead>
<tr>
<th>Subagent</th>
<th>tools</th>
<th>disallowedTools</th>
<th>model</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td>code-reviewer</td>
<td>Read, Grep, Glob, Bash</td>
<td>-</td>
<td>inherit</td>
<td>Read and analyze</td>
</tr>
<tr>
<td>debugger</td>
<td>Read, Edit, Bash, Grep, Glob</td>
<td>-</td>
<td>(default)</td>
<td>Read, analyze, and fix</td>
</tr>
<tr>
<td>test-runner</td>
<td>Read, Bash, Grep, Glob</td>
<td>Write, Edit</td>
<td>haiku</td>
<td>Run and analyze (no fixes)</td>
</tr>
</tbody>
</table>
<p>They are the same kind of Subagent, but <strong>the scope of what they can do differs completely</strong> depending on the tool combination.</p>
<h2>4.4 Design Principle: "Subagent Analyzes, Main Claude Executes"</h2>
<p>The effective Subagent design pattern is the <strong>separation of analysis and execution</strong>.</p>
<pre><code class="language-mermaid">flowchart LR
    A[User request] --> B[Main Claude]
    B -- Delegate --> C(Subagent: analyze)
    C -- Return result --> D(Main Claude: execute)

    style C fill:#e8f4fd,stroke:#1a73e8
    style D fill:#fce8e6,stroke:#d93025
</code></pre>
<ul>
<li><strong>Subagent (analyze)</strong>: uses only Read, Grep, Glob → code exploration, problem analysis, planning</li>
<li><strong>Main Claude (execute)</strong>: uses Write, Edit, Bash → modifies based on the Subagent's analysis results</li>
</ul>
<p>Granting the Subagent only read-only tools provides:</p>
<ul>
<li><strong>Safety</strong>: no accidental file modification during the analysis stage</li>
<li><strong>Context preservation</strong>: large-scale file exploration does not pollute the main conversation</li>
<li><strong>Result summarization</strong>: the Subagent's work result comes back summarized, so only the essentials are conveyed</li>
</ul>
<blockquote>
<p><strong>The importance of conciseness</strong>: A Subagent's system prompt <strong>performs better the more concise it is</strong>. In one experiment, reducing an 803-line agent prompt to 281 lines (a 65% reduction) raised the evaluation score from 62/100 to 82–85/100 with no loss of functionality.</p>
</blockquote>
<h1>5. Subagent + Skill Integration</h1>
<h2>5.1 API Development Specialist (<code>api-developer</code> + <code>api-convention</code> + <code>go-project-layout</code>)</h2>
<p>This example shows a Subagent preloading Skills to operate as a <strong>specialist equipped with domain knowledge</strong>.</p>
<p>First is the Skill that serves as the knowledge. With <code>user-invocable: false</code>, direct invocation is blocked, but it can be used from a Subagent.</p>
<p><code>.claude/skills/api-convention/SKILL.md</code>:</p>
<pre><code class="language-markdown">---
name: api-convention
description: RESTful API design conventions
user-invocable: false
---

## API Design Rules

### URL Naming
- Use plural nouns: `/users`, `/articles`
- Hierarchical relationships: `/users/{id}/articles`
- No verbs: `/getUser` → `/users/{id}`

### Response Format
- Success: `{ "data": ..., "meta": { "page": 1, "total": 100 } }`
- Error: `{ "error": { "code": "NOT_FOUND", "message": "..." } }`

### HTTP Status Codes
- 200: success, 201: created, 204: delete success
- 400: bad request, 401: unauthenticated, 403: forbidden, 404: not found
- 500: server error

### Echo Framework Patterns
- Handlers located in the `http/` directory
- Route groups: `e.Group("/api/v1")`
- Middleware: CORS and JWT verification applied at the group level
</code></pre>
<p>And here is the executor Subagent that leverages this knowledge.</p>
<p><code>.claude/agents/api-developer.md</code>:</p>
<pre><code class="language-markdown">---
name: api-developer
description: A professional developer who implements API endpoints. Designs and implements
  RESTful APIs based on the Echo framework.
tools: Read, Write, Edit, Bash, Grep, Glob
model: sonnet
skills:
  - api-convention
  - go-project-layout
---

You are an API development specialist based on the Go Echo framework.

When invoked:
1. Define the domain model (struct) of the requested API
2. Design the repository interface
3. Implement the use case (business logic)
4. Write the HTTP handler
5. Register the router
6. Write tests

Project structure (clean architecture):
- `domain/`: entities and interfaces
- `repository/`: data access implementation
- `usecase/`: business logic
- `http/`: handlers and routers

Be sure to follow the rules of the preloaded api-convention and go-project-layout skills.
</code></pre>
<p><strong>Behavior scenario</strong>:</p>
<ol>
<li>The user requests "add a user CRUD API"</li>
<li>Claude delegates to the api-developer subagent</li>
<li>When the Subagent starts, it automatically loads the contents of the <code>api-convention</code> and <code>go-project-layout</code> Skills</li>
<li>Implements the endpoints while adhering to the API conventions and project structure</li>
</ol>
<p><strong>Key points</strong>:</p>
<ul>
<li><code>skills: [api-convention, go-project-layout]</code> injects domain knowledge into the Subagent</li>
<li>The Skill's <code>user-invocable: false</code> blocks direct invocation but allows use from a Subagent</li>
<li>The role split of <strong>Skills = knowledge, Subagents = executors</strong> is clear</li>
</ul>
<h1>6. When Should You Use Which?</h1>
<h2>6.1 Decision Tree for Choosing</h2>
<p>When you are unsure which of the three extensions to use, following the decision tree below will help you find the right choice.</p>
<pre><code class="language-mermaid">flowchart TD
    A["Do you repeat this task frequently?"] -- No --> B["Just ask Claude directly"]
    A -- Yes --> C["Is it the same procedure every time?"]
    C -- Yes --> D["Command"]
    D -.- D1["/commit, /start-task, etc."]
    C -- No --> E["Do you want Claude to apply it on its own?"]
    E -- Yes --> F["Skill"]
    F -.- F1["go-convention, go-project-layout, etc."]
    E -- No --> G["Do you need independent analysis/processing?"]
    G -- Yes --> H["Subagent"]
    H -.- H1["code-reviewer, debugger, etc."]
    G -- No --> B

    style D fill:#d4edda,stroke:#28a745
    style D1 fill:#d4edda,stroke:#28a745,stroke-dasharray: 5 5
    style F fill:#e8f4fd,stroke:#1a73e8
    style F1 fill:#e8f4fd,stroke:#1a73e8,stroke-dasharray: 5 5
    style H fill:#fce8e6,stroke:#d93025
    style H1 fill:#fce8e6,stroke:#d93025,stroke-dasharray: 5 5
</code></pre>
<h2>6.2 Recommendations by Situation</h2>
<p>Once the decision tree has given you a rough direction, you can find a recommendation for your specific situation in the table below.</p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Recommendation</th>
</tr>
</thead>
<tbody>
<tr>
<td>When you need a simple shortcut command</td>
<td>Command (<code>/commit</code>)</td>
</tr>
<tr>
<td>When you take arguments and run a fixed procedure</td>
<td>Command (<code>/plan-task docs/...</code>)</td>
</tr>
<tr>
<td>When you automatically apply coding conventions/guidelines</td>
<td>Skill (<code>go-convention</code>)</td>
</tr>
<tr>
<td>When you need knowledge that references runtime data</td>
<td>Skill with <code>!</code> (<code>go-project-layout</code>)</td>
</tr>
<tr>
<td>When you run independent work in isolation</td>
<td>Subagent (<code>code-reviewer</code>)</td>
</tr>
<tr>
<td>When you restrict tools to read-only</td>
<td>Subagent (<code>tools</code> field)</td>
</tr>
<tr>
<td>When you handle simple work with a low-cost model</td>
<td>Subagent (<code>model: haiku</code>)</td>
</tr>
<tr>
<td>When you run multiple tasks in parallel</td>
<td>Subagent (multiple running concurrently)</td>
</tr>
<tr>
<td>When you combine knowledge + execution</td>
<td>Subagent + Skill (<code>skills</code> field)</td>
</tr>
<tr>
<td>When you need a Skill but with isolated execution</td>
<td>Skill (<code>context: fork</code>)</td>
</tr>
</tbody>
</table>
<h2>6.3 Comparison Table</h2>
<p>This table lets you compare the core differences of the three features at a glance. In particular, advanced features such as tool restriction, model selection, and parallel execution are supported only by Skills and Subagents.</p>
<table>
<thead>
<tr>
<th>Criterion</th>
<th>Command</th>
<th>Skill</th>
<th>Subagent</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Invocation method</strong></td>
<td>User direct (<code>/command</code>)</td>
<td>Automatic + manual</td>
<td>Claude automatic delegation</td>
</tr>
<tr>
<td><strong>Context</strong></td>
<td>Injected into main conversation</td>
<td>Loaded into main conversation (<code>fork</code> possible)</td>
<td>Separate context</td>
</tr>
<tr>
<td><strong>State persistence</strong></td>
<td>None (one-time)</td>
<td>Persists within the conversation</td>
<td>Persists within the task</td>
</tr>
<tr>
<td><strong>File structure</strong></td>
<td>Single <code>.md</code> file</td>
<td>Folder (SKILL.md + auxiliary files)</td>
<td>Single <code>.md</code> file</td>
</tr>
<tr>
<td><strong>Tool restriction</strong></td>
<td>Not possible</td>
<td>Possible (<code>allowed-tools</code>)</td>
<td>Possible (<code>tools</code>/<code>disallowedTools</code>)</td>
</tr>
<tr>
<td><strong>Model selection</strong></td>
<td>Not possible</td>
<td>Possible</td>
<td>Possible</td>
</tr>
<tr>
<td><strong>Argument passing</strong></td>
<td>Possible (<code>$ARGUMENTS</code>)</td>
<td>Possible (<code>$ARGUMENTS</code>)</td>
<td>Passed via task description</td>
</tr>
<tr>
<td><strong>Skill combination</strong></td>
<td>Not possible</td>
<td>Not possible</td>
<td>Possible (<code>skills</code> field)</td>
</tr>
<tr>
<td><strong>Parallel execution</strong></td>
<td>Not possible</td>
<td>Not possible</td>
<td>Possible</td>
</tr>
</tbody>
</table>
<h2>6.4 Practical Tips</h2>
<p><strong>Understanding by analogy</strong>:</p>
<ul>
<li><strong>Commands</strong> = macros (run repetitive work as a shortcut)</li>
<li><strong>Skills</strong> = reference documents (spread out on your desk and referenced while working)</li>
<li><strong>Subagents</strong> = teammates (work independently at a separate desk and hand back only the result)</li>
</ul>
<p><strong>Practical growth path</strong>: You do not need to use all three features from the start. Introducing them step by step naturally broadens your range of use.</p>
<ol>
<li><strong>Start with Commands</strong>: turn daily repetitive tasks (commits, deployments, PR creation) into slash commands</li>
<li><strong>When patterns emerge, move to Skills</strong>: package knowledge such as per-project coding rules and review checklists as Skills</li>
<li><strong>As things get complex, move to Subagents</strong>: separate work requiring independent analysis—like code review, debugging, and document exploration—into Subagents</li>
<li><strong>Combine</strong>: inject Skills into a Subagent to build a specialist agent equipped with domain knowledge</li>
</ol>
<p><strong>Watch out for anti-patterns</strong>:</p>
<ul>
<li><strong>Overusing Subagents</strong>: separating even simple tasks into Subagents only increases overhead. If you do not need an independent context, a Command is enough.</li>
<li><strong>Using a Skill like a macro</strong>: a Skill provides "knowledge," not the execution of a "procedure." If sequential execution is the goal, a Command is more appropriate.</li>
</ul>
<h1>7. Summary of Constraints</h1>
<table>
<thead>
<tr>
<th>Constraint</th>
<th>Commands</th>
<th>Skills</th>
<th>Subagents</th>
</tr>
</thead>
<tbody>
<tr>
<td>Automatic invocation</td>
<td>Not possible</td>
<td>Possible (requires <code>description</code>)</td>
<td>Possible (requires <code>description</code>)</td>
</tr>
<tr>
<td>Context isolation</td>
<td>Not possible</td>
<td>Possible via <code>context: fork</code></td>
<td>Default behavior</td>
</tr>
<tr>
<td>Creating another agent</td>
<td>Not possible</td>
<td>Not possible</td>
<td>Not possible (no nesting)</td>
</tr>
<tr>
<td>Using MCP tools</td>
<td>Possible</td>
<td>Possible</td>
<td>Not possible during background execution</td>
</tr>
<tr>
<td>Parallel execution</td>
<td>Not possible</td>
<td>Not possible</td>
<td>Possible</td>
</tr>
<tr>
<td>Auxiliary file support</td>
<td>Not possible (single file)</td>
<td>Possible (directory structure)</td>
<td>Not possible (single file)</td>
</tr>
</tbody>
</table>
<h1>8. FAQ</h1>
<p><strong>Q. Can you also specify <code>model</code> in a Command?</strong></p>
<p>A. No. A Command is a pure markdown prompt template without frontmatter, so it does not support metadata settings such as <code>model</code>. When a Command runs, the model used in the current conversation is applied as is. To specify a model, you must use a Skill (<code>model</code> field) or a Subagent (<code>model</code> field).</p>
<p><strong>Q. Can you call another Subagent from within a Subagent?</strong></p>
<p>A. No. Subagent nesting is not supported. If you need a complex pipeline, you must design it so that the main Claude calls multiple Subagents sequentially or in parallel.</p>
<p><strong>Q. Can you use a Skill and a Command at the same time?</strong></p>
<p>A. Yes. For example, while running the <code>/commit</code> Command, the <code>go-convention</code> Skill can automatically activate. Since the Skill's knowledge is injected together into the main conversation context where the Command runs, it combines so that the commit message is written by referencing the coding conventions during the commit.</p>
<p><strong>Q. What is the difference between <code>context: fork</code> and a Subagent?</strong></p>
<p>A. Both run in a separate context, but <code>context: fork</code> is an option of a Skill, while a Subagent is an independent agent. A Skill + <code>context: fork</code> is suitable for running a single task in isolation, whereas a Subagent is suitable when you need fine-grained control such as tool restriction, model selection, and Skill preloading.</p>
<h1>9. Conclusion</h1>
<p>The reason Command, Skill, and Subagent are confusing is that all three share the commonality of "customizing Claude's behavior." Summarizing the differences in one line each:</p>
<ul>
<li><strong>Command</strong>: "When I tell you to, run this procedure" (e.g., <code>/commit</code>, <code>/plan-task</code>)</li>
<li><strong>Skill</strong>: "Refer to this field's specialized knowledge when needed" (e.g., <code>go-convention</code>, <code>go-project-layout</code>)</li>
<li><strong>Subagent</strong>: "Hand this type of work to a separate specialist" (e.g., <code>code-reviewer</code>, <code>debugger</code>, <code>test-runner</code>)</li>
<li><strong>Subagent + Skill</strong>: "Hand it to a specialist equipped with specialized knowledge" (e.g., <code>api-developer</code> + <code>api-convention</code>)</li>
</ul>
<p>The full directory structure of this article is as follows.</p>
<pre><code>.claude/
├── commands/
│   ├── commit.md              # Example 1: Git Commit automation
│   ├── plan-task.md           # Example 2: PRD → implementation doc generation
│   └── start-task.md          # Example 3: Starting a task
├── skills/
│   ├── go-convention/
│   │   └── SKILL.md           # Example 4: Go coding convention (auto-invocation)
│   ├── go-project-layout/
│   │   └── SKILL.md           # Example 5: Go project layout (dynamic context)
│   ├── analyze-codebase/
│   │   └── SKILL.md           # Example 6: Codebase analysis (context: fork)
│   └── api-convention/
│       └── SKILL.md           # Example 7: API convention (Subagent-only)
└── agents/
    ├── code-reviewer.md       # Example 8: Code review (read-only)
    ├── debugger.md            # Example 9: Debugging (can modify)
    ├── test-runner.md         # Example 10: Test execution (haiku, no modification)
    └── api-developer.md       # Example 11: API development (Skill combination)
</code></pre>
<h1>10. References</h1>
<ul>
<li><a href="https://www.youngleaders.tech/p/claude-skills-commands-subagents-plugins">Claude Code: Skills, Commands, Subagents, and Plugins</a></li>
<li><a href="https://alexop.dev/posts/claude-code-customization-guide-claudemd-skills-subagents/">Claude Code Customization Guide</a></li>
<li><a href="https://tilnote.io/pages/695a5e15d63c6ab14589c082">Claude Code Skills, Commands, Subagents 정리</a></li>
<li><a href="https://rosettalens.com/s/ko/skills-explained">Skills explained: How Skills compares to prompts, Projects, MCP, and subagents</a></li>
<li><a href="https://rudaks.tistory.com/entry/Claude-%EB%B2%88%EC%97%AD-Skills-explained-How-Skills-compares-to-prompts-Projects-MCP-and-subagents">Skills explained 번역</a></li>
<li><a href="https://goddaehee.tistory.com/440">Claude Code Skills vs Plugins</a></li>
<li><a href="https://github.com/kenshin579/tutorials-go/tree/master/.claude">tutorials-go/.claude 예제 코드</a></li>
</ul>]]></content:encoded>
      <category>ai</category>
      <category>Claude Code</category>
      <category>AI</category>
      <category>Skill</category>
      <category>Subagent</category>
      <category>Command</category>
      <category>서브에이전트</category>
      <category>슬래시커맨드</category>
      <category>AI코딩도구</category>
      <category>워크플로우자동화</category>
      <category>Series: Claude Code Complete Guide</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Complete Guide to Claude Code Plugins &amp; Hooks: From Packaging Extensions to Event Automation</title>
      <link>https://blog.advenoh.pe.kr/en/claude-code-plugin-hooks-완벽-가이드</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/claude-code-plugin-hooks-완벽-가이드</guid>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <description>An in-depth look at Claude Code's Plugin system and Hooks, covering Plugin structure, Marketplace, event-driven automation, and practical examples.</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<p>In the previous article, <a href="/article/claude-code-%ED%99%95%EC%9E%A5-%EA%B8%B0%EB%8A%A5-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-command-skill-subagent">Claude Code 확장 기능 완벽 가이드: Command, Skill, Subagent</a>, we covered the concepts and practical usage of Claude Code's three core extension features: Command, Skill, and Subagent. In this article, <strong>without overlap</strong>, we organize new content centered on the next steps: <strong>Hooks</strong> (event-driven automation) and the <strong>Plugin system</strong> (packaging and distributing extensions).</p>
<h2>1.1 Standalone Configuration vs Plugin</h2>
<p>Individual extensions can be configured directly in the <code>.claude/</code> directory and used immediately. However, if you want to share them with your team or distribute them to the community, packaging them as a <strong>Plugin</strong> is more efficient.</p>
<table>
<thead>
<tr>
<th>Criteria</th>
<th>Standalone Configuration</th>
<th>Plugin</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Suitable for</strong></td>
<td>Personal workflows, project-specific</td>
<td>Team sharing, community distribution</td>
</tr>
<tr>
<td><strong>Version control</strong></td>
<td>Managed directly with Git</td>
<td><code>version</code> field in <code>plugin.json</code></td>
</tr>
<tr>
<td><strong>Namespace</strong></td>
<td>None (name collisions possible)</td>
<td>Separated with <code>/plugin-name:skill-name</code> format</td>
</tr>
<tr>
<td><strong>Install/update</strong></td>
<td>Manual copy</td>
<td>One-click via <code>/plugins</code> command</td>
</tr>
<tr>
<td><strong>MCP/LSP integration</strong></td>
<td>Requires separate setup</td>
<td>Bundled in Plugin and distributed at once</td>
</tr>
</tbody>
</table>
<blockquote>
<p>In this article, we first cover the concept and practical usage of Hooks, then explain how to package all extensions into one with the Plugin system.</p>
</blockquote>
<h1>2. Hooks (Event-Driven Automation)</h1>
<h2>2.1 What Are Hooks</h2>
<p>Hooks are an automation mechanism that executes <strong>deterministically</strong> in response to specific Claude Code events. The key point is that, rather than relying on the LLM's judgment, they <strong>always behave identically</strong> according to predefined rules.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Deterministic execution</strong>: The LLM does not decide "whether or not" to run. If conditions match, it always executes</li>
<li><strong>Event-driven</strong>: Can intervene at 14 points such as before/after tool calls, session start/end, etc.</li>
<li><strong>Blocking capability</strong>: Certain Hooks can block dangerous operations before they execute</li>
<li><strong>Three handler types</strong>: shell command (<code>command</code>), LLM single turn (<code>prompt</code>), subagent (<code>agent</code>)</li>
</ul>
<p>For example, if you want to "prevent accidentally running <code>rm -rf /</code>," you could write "do not use rm -rf" in CLAUDE.md, but the LLM may ignore it. With Hooks, you can inspect the command before the Bash tool call and block it with <strong>100% certainty</strong>.</p>
<pre><code class="language-mermaid">flowchart LR
    A["Event occurs\n(e.g. Bash call)"] --> B{"matcher pattern\nmatches?"}
    B -- No --> C["Skip Hook"]
    B -- Yes --> D["Run Hook\n(command/prompt/agent)"]
    D --> E{"Check exit code"}
    E -- "0 (success)" --> F["Continue event"]
    E -- "2 (block)" --> G["Block event\n+ stderr feedback"]

    style G fill:#fce8e6,stroke:#d93025
    style F fill:#d4edda,stroke:#28a745
</code></pre>
<h2>2.2 The 14 Hook Events</h2>
<p>Claude Code provides 14 events across the full session lifecycle. Each event falls into one of two categories depending on <strong>whether it can block</strong>.</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>When it fires</th>
<th>Blockable</th>
<th>Main use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>SessionStart</code></td>
<td>On session start/resume</td>
<td>No</td>
<td>Environment initialization, logging</td>
</tr>
<tr>
<td><code>UserPromptSubmit</code></td>
<td>On user prompt submission</td>
<td>Yes</td>
<td>Input validation, prompt transformation</td>
</tr>
<tr>
<td><code>PreToolUse</code></td>
<td>Before a tool call</td>
<td>Yes</td>
<td>Block dangerous commands, validate input</td>
</tr>
<tr>
<td><code>PermissionRequest</code></td>
<td>When permission dialog is shown</td>
<td>Yes</td>
<td>Auto-approve/deny</td>
</tr>
<tr>
<td><code>PostToolUse</code></td>
<td>After a tool call succeeds</td>
<td>No</td>
<td>Linting, formatting, logging</td>
</tr>
<tr>
<td><code>PostToolUseFailure</code></td>
<td>After a tool call fails</td>
<td>No</td>
<td>Error logging, recovery attempts</td>
</tr>
<tr>
<td><code>Notification</code></td>
<td>When a notification is sent</td>
<td>No</td>
<td>Notification customization</td>
</tr>
<tr>
<td><code>SubagentStart</code></td>
<td>When a subagent is created</td>
<td>No</td>
<td>Monitoring, logging</td>
</tr>
<tr>
<td><code>SubagentStop</code></td>
<td>When a subagent ends</td>
<td>Yes</td>
<td>Result validation</td>
</tr>
<tr>
<td><code>Stop</code></td>
<td>When Claude finishes a response</td>
<td>Yes</td>
<td>Task completion validation, post-processing</td>
</tr>
<tr>
<td><code>TeammateIdle</code></td>
<td>When a teammate agent is idle</td>
<td>Yes</td>
<td>Assign additional work</td>
</tr>
<tr>
<td><code>TaskCompleted</code></td>
<td>When a task is marked complete</td>
<td>Yes</td>
<td>Validate completion criteria</td>
</tr>
<tr>
<td><code>PreCompact</code></td>
<td>Before context compaction</td>
<td>No</td>
<td>Save information before compaction</td>
</tr>
<tr>
<td><code>SessionEnd</code></td>
<td>When a session ends</td>
<td>No</td>
<td>Cleanup tasks, final logging</td>
</tr>
</tbody>
</table>
<p>When a Hook returns exit code <code>2</code> on a <strong>blocking</strong> event, that operation is aborted and the contents of stderr are passed to Claude as feedback.</p>
<p>Common fields in the stdin JSON passed to a Hook:</p>
<pre><code class="language-json">{
  "session_id": "abc123",
  "cwd": "/home/user/my-project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  }
}
</code></pre>
<h2>2.3 How to Configure Hooks</h2>
<h3>2.3.1 Configuration Format</h3>
<p>Hooks are configured in JSON format. The structure is <code>event → matcher → hooks array</code>.</p>
<pre><code class="language-json">{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/block-dangerous-commands.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/auto-lint.sh"
          }
        ]
      }
    ]
  }
}
</code></pre>
<h3>2.3.2 matcher Patterns</h3>
<p><code>matcher</code> is a regular expression pattern that filters which tool names the Hook reacts to.</p>
<table>
<thead>
<tr>
<th>matcher pattern</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>"Bash"</code></td>
<td>Reacts only to the Bash tool</td>
</tr>
<tr>
<td><code>"Write|Edit"</code></td>
<td>Reacts to the Write or Edit tool</td>
</tr>
<tr>
<td><code>"mcp__.*"</code></td>
<td>Reacts to all MCP tools</td>
</tr>
<tr>
<td><code>"mcp__memory__.*"</code></td>
<td>Reacts to all tools of the memory MCP server</td>
</tr>
<tr>
<td><code>null</code> (omitted)</td>
<td>Reacts to all tools of that event</td>
</tr>
</tbody>
</table>
<h3>2.3.3 Configuration Locations &#x26; Scope</h3>
<p>Hooks can be configured in 4 locations, each with a different scope of application.</p>
<table>
<thead>
<tr>
<th>Location</th>
<th>File path</th>
<th>Scope</th>
<th>Team sharing</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>User global</strong></td>
<td><code>~/.claude/settings.json</code></td>
<td>All projects</td>
<td>No</td>
</tr>
<tr>
<td><strong>Project</strong></td>
<td><code>.claude/settings.json</code></td>
<td>Current project</td>
<td>Yes (Git commit)</td>
</tr>
<tr>
<td><strong>Project local</strong></td>
<td><code>.claude/settings.local.json</code></td>
<td>Current project</td>
<td>No (gitignore)</td>
</tr>
<tr>
<td><strong>Plugin</strong></td>
<td><code>&#x3C;plugin>/hooks/hooks.json</code></td>
<td>When Plugin is enabled</td>
<td>Yes</td>
</tr>
</tbody>
</table>
<p>Hooks from all scopes are merged and executed. If multiple Hooks are configured for the same event, they all run in order.</p>
<h2>2.4 The Three Hook Handler Types</h2>
<p>When a Hook is triggered, you can choose among 3 handlers depending on <strong>what to execute</strong>.</p>
<h3>2.4.1 command (Shell Command)</h3>
<p>The most basic handler, which directly runs a shell command. Event data is passed <strong>as JSON via stdin</strong>.</p>
<pre><code class="language-json">{
  "type": "command",
  "command": "bash .claude/hooks/block-rm.sh"
}
</code></pre>
<ul>
<li><strong>exit code 0</strong>: Success (event continues)</li>
<li><strong>exit code 2</strong>: Block (blocking events only). stderr content is passed to Claude as feedback</li>
<li><strong>stdout output</strong>: Passed to Claude as additional context (useful in non-blocking Hooks)</li>
<li><strong>Timeout</strong>: Default 600 seconds, adjustable via the <code>timeout</code> field (prompt is 30 seconds, agent is 60 seconds)</li>
<li><strong>Async execution</strong>: Setting <code>"async": true</code> proceeds without waiting for the Hook to complete</li>
<li><strong>Environment variables</strong>: <code>$CLAUDE_PROJECT_DIR</code> (project root) and <code>${CLAUDE_PLUGIN_ROOT}</code> (Plugin root) are available</li>
</ul>
<h3>2.4.2 prompt (LLM Single Turn)</h3>
<p>A handler that requests a single-turn evaluation from the LLM. It analyzes and judges the event data without using tools.</p>
<pre><code class="language-json">{
  "type": "prompt",
  "prompt": "Review the result of the following task and judge whether the goal was achieved. If not achieved, tell me what is missing."
}
</code></pre>
<ul>
<li>The LLM receives the event context and generates a single response</li>
<li>Since it cannot use tools, it is suitable for <strong>reading/analysis</strong> purposes</li>
<li>Useful when combined with the <code>Stop</code> event to judge whether a task is complete</li>
</ul>
<h3>2.4.3 agent (Subagent Multi-Turn)</h3>
<p>A handler that creates a subagent to perform multi-turn validation tasks.</p>
<pre><code class="language-json">{
  "type": "agent",
  "prompt": "Read the file that was just modified, check the code style guide, and report any violations."
}
</code></pre>
<ul>
<li>The subagent can use tools to read files, search, etc.</li>
<li>More costly than <code>prompt</code>, but suitable for complex validation</li>
<li>Useful when you need to read and analyze multiple files</li>
</ul>
<table>
<thead>
<tr>
<th>Handler</th>
<th>Execution method</th>
<th>Tool use</th>
<th>Cost</th>
<th>Suitable for</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>command</code></td>
<td>Directly runs a shell command</td>
<td>None</td>
<td>Minimal</td>
<td>Linting, formatting, simple validation</td>
</tr>
<tr>
<td><code>prompt</code></td>
<td>LLM single-turn evaluation</td>
<td>None</td>
<td>Medium</td>
<td>Judging task completion, evaluating results</td>
</tr>
<tr>
<td><code>agent</code></td>
<td>Subagent multi-turn</td>
<td>Yes</td>
<td>High</td>
<td>File inspection, complex validation</td>
</tr>
</tbody>
</table>
<h2>2.5 Practical Examples</h2>
<h3>2.5.1 Blocking Dangerous Commands (PreToolUse + Bash)</h3>
<p>Block dangerous commands like <code>rm -rf /</code> and <code>git push --force</code> before the Bash tool call.</p>
<p><code>settings.json</code>:</p>
<pre><code class="language-json">{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/block-dangerous-commands.sh"
          }
        ]
      }
    ]
  }
}
</code></pre>
<p><code>.claude/hooks/block-dangerous-commands.sh</code>:</p>
<pre><code class="language-bash">#!/bin/bash
# Event data is passed as JSON via stdin
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')

# Check for dangerous command patterns
dangerous_patterns=(
  "rm -rf /"
  "rm -rf ~"
  "git push.*--force.*main"
  "git push.*--force.*master"
  "DROP TABLE"
  "DROP DATABASE"
)

for pattern in "${dangerous_patterns[@]}"; do
  if echo "$command" | grep -qE "$pattern"; then
    echo "Dangerous command blocked: $command" >&#x26;2
    echo "This command was blocked by security policy." >&#x26;2
    exit 2  # exit code 2 = block
  fi
done

exit 0  # exit code 0 = allow
</code></pre>
<p><strong>Behavior scenario</strong>: When Claude tries to run <code>rm -rf /tmp/build</code>, the Hook inspects the command and allows it since it does not match a dangerous pattern. On the other hand, if it tries <code>git push --force origin main</code>, it is blocked with exit code 2, and the feedback "This command was blocked by security policy" is passed to Claude.</p>
<h3>2.5.2 Auto-Linting After File Save (PostToolUse + Write|Edit)</h3>
<p>Automatically run a linter after creating or modifying a file.</p>
<p><code>settings.json</code>:</p>
<pre><code class="language-json">{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/auto-lint.sh"
          }
        ]
      }
    ]
  }
}
</code></pre>
<p><code>.claude/hooks/auto-lint.sh</code>:</p>
<pre><code class="language-bash">#!/bin/bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

if [ -z "$file_path" ]; then
  exit 0
fi

# Run the appropriate linter based on file extension
case "$file_path" in
  *.ts|*.tsx)
    npx eslint --fix "$file_path" 2>/dev/null
    ;;
  *.py)
    ruff check --fix "$file_path" 2>/dev/null
    ;;
  *.go)
    gofmt -w "$file_path" 2>/dev/null
    ;;
esac

exit 0
</code></pre>
<p><strong>Point</strong>: Since <code>PostToolUse</code> is a non-blocking event, returning exit code 2 does not abort the operation. The linter's results are passed to Claude via stdout.</p>
<h3>2.5.3 Task Completion Validation (Stop + prompt handler)</h3>
<p>When Claude is about to finish a response, the <code>prompt</code> handler validates whether the task is complete.</p>
<pre><code class="language-json">{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Review the task that was just completed. Comparing it to the user's original request: 1) Were all requirements met? 2) Was this a change requiring tests, but tests were not written? 3) Is anything missing? If anything is lacking, tell me specifically."
          }
        ]
      }
    ]
  }
}
</code></pre>
<p><strong>Behavior scenario</strong>: When Claude processes a request like "add a function" and is about to finish its response, the <code>Stop</code> Hook asks the LLM to review. If the LLM judges that "tests are missing," that feedback is passed to Claude, which then continues with additional work.</p>
<h3>2.5.4 MCP Tool Monitoring (mcp__*__ matcher)</h3>
<p>Monitor and log tool calls of an MCP server.</p>
<pre><code class="language-json">{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__memory__.*",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'echo \"[$(date)] MCP memory tool call: $(cat | jq -r .tool_name)\" >> /tmp/mcp-audit.log'"
          }
        ]
      }
    ]
  }
}
</code></pre>
<h3>2.5.5 Asynchronous Test Execution (async: true)</h3>
<p>After a file change, run tests asynchronously so they do not block Claude's next task.</p>
<pre><code class="language-json">{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'npm test -- --related $(cat | jq -r .tool_input.file_path) &#x26;>/tmp/test-results.log'",
            "async": true,
            "timeout": 120000
          }
        ]
      }
    ]
  }
}
</code></pre>
<p><strong>Point</strong>: Setting <code>async: true</code> means it does not wait until the Hook finishes executing. This is suitable for time-consuming tasks like tests.</p>
<h2>2.6 Hooks Best Practices</h2>
<h3>2.6.1 Recommended Use Case Collection</h3>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Event</th>
<th>Handler</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Block dangerous commands</td>
<td><code>PreToolUse</code> (Bash)</td>
<td><code>command</code></td>
<td>Block <code>rm -rf /</code>, <code>git push --force main</code>, etc.</td>
</tr>
<tr>
<td>Auto lint/format</td>
<td><code>PostToolUse</code> (Write|Edit)</td>
<td><code>command</code></td>
<td>Auto-run ESLint, gofmt, ruff on file save</td>
</tr>
<tr>
<td>Enforce commit message rules</td>
<td><code>PreToolUse</code> (Bash)</td>
<td><code>command</code></td>
<td>Block if not in Conventional Commits format</td>
</tr>
<tr>
<td>Protect sensitive files</td>
<td><code>PreToolUse</code> (Write|Edit)</td>
<td><code>command</code></td>
<td>Block edits to <code>.env</code>, <code>secrets.yaml</code>, etc.</td>
</tr>
<tr>
<td>Auto-run tests</td>
<td><code>PostToolUse</code> (Write|Edit)</td>
<td><code>command</code> (async)</td>
<td>Run related tests asynchronously without blocking the workflow</td>
</tr>
<tr>
<td>Task completion validation</td>
<td><code>Stop</code></td>
<td><code>prompt</code></td>
<td>Review requirement fulfillment and missing tests</td>
</tr>
<tr>
<td>Code review automation</td>
<td><code>Stop</code></td>
<td><code>agent</code></td>
<td>Read modified files and check for style guide violations</td>
</tr>
<tr>
<td>API call audit log</td>
<td><code>PreToolUse</code> (mcp__*)</td>
<td><code>command</code></td>
<td>Log MCP tool call records to a file</td>
</tr>
<tr>
<td>Branch protection</td>
<td><code>PreToolUse</code> (Bash)</td>
<td><code>command</code></td>
<td>Prevent direct commits to main/master branch</td>
</tr>
<tr>
<td>Environment initialization</td>
<td><code>SessionStart</code></td>
<td><code>command</code></td>
<td>Load <code>.env</code> and verify required tools exist at session start</td>
</tr>
</tbody>
</table>
<h3>2.6.2 Design Principles</h3>
<p><strong>1. Use the command handler by default</strong></p>
<p>For tasks with clear rules, such as linting, formatting, and pattern checking, <code>command</code> is the most efficient. Use <code>prompt</code> or <code>agent</code> only when judgment is required, since they incur LLM cost.</p>
<p><strong>2. Block minimally, give clear feedback</strong></p>
<p>When blocking with exit code 2, you should specifically explain <strong>why it was blocked</strong> in stderr so that Claude can find an alternative.</p>
<pre><code class="language-bash"># ❌ Bad example: blocking without a reason
echo "Blocked" >&#x26;2 &#x26;&#x26; exit 2

# ✅ Good example: provide a specific reason and alternative
echo "You cannot push directly to the main branch. Create a PR from a feature branch." >&#x26;2 &#x26;&#x26; exit 2
</code></pre>
<p><strong>3. Run heavy tasks with async</strong></p>
<p>For time-consuming tasks like running tests or validating builds, set <code>"async": true</code> so they do not block Claude's workflow.</p>
<p><strong>4. Make Hook scripts exit quickly</strong></p>
<p>Synchronous Hooks block Claude's response. Do not rely on the default timeout (command: 600 seconds); write the script itself to finish as quickly as possible.</p>
<p><strong>5. Narrow the event + matcher combination precisely</strong></p>
<p>If you omit the matcher, the Hook runs for <strong>all</strong> tools of that event. To reduce unnecessary execution, specify the matcher as specifically as possible.</p>
<pre><code class="language-json">// ❌ Run lint on all tools (unnecessary)
{ "matcher": null }

// ✅ Run lint only on file-modifying tools
{ "matcher": "Write|Edit" }
</code></pre>
<h1>3. Plugin System</h1>
<h2>3.1 What Is a Plugin</h2>
<p>A Plugin is a packaging system that bundles Skills, Hooks, Agents, MCP servers, and LSP servers into <strong>a single distribution unit</strong>. Instead of configuring individual extensions directly, packaging them as a Plugin makes installation, updates, and sharing easy.</p>
<p>Key characteristics:</p>
<ul>
<li><strong>Unified packaging</strong>: Bundles Command, Skill, Agent, Hook, MCP, and LSP into one</li>
<li><strong>Namespacing</strong>: Prevents name collisions with the <code>/plugin-name:skill-name</code> format</li>
<li><strong>One-click install</strong>: Install via the <code>/plugins</code> command or Marketplace</li>
<li><strong>Auto-update</strong>: Supports version management and auto-update when registered on a Marketplace</li>
<li><strong>Scope selection</strong>: Choose among user global, project shared, or project local</li>
</ul>
<h2>3.2 Plugin Structure</h2>
<h3>3.2.1 Directory Layout</h3>
<pre><code>my-plugin/
├── .claude-plugin/
│   └── plugin.json         # Manifest (required)
├── commands/               # Slash commands (markdown files)
│   └── deploy.md
├── skills/                 # Agent Skills
│   └── code-review/
│       └── SKILL.md
├── agents/                 # Custom subagents
│   └── reviewer.md
├── hooks/
│   └── hooks.json          # Event handlers
├── .mcp.json               # MCP server config
└── .lsp.json               # LSP server config
</code></pre>
<p>All directories are optional. You only need to include the extensions you need. The only required file is <code>.claude-plugin/plugin.json</code>.</p>
<h3>3.2.2 plugin.json Manifest Schema</h3>
<pre><code class="language-json">{
  "name": "my-awesome-plugin",
  "description": "A Plugin that automates the project development workflow",
  "version": "1.0.0",
  "author": {
    "name": "Developer Name",
    "url": "https://github.com/username"
  },
  "homepage": "https://github.com/username/my-awesome-plugin",
  "repository": "https://github.com/username/my-awesome-plugin",
  "license": "MIT"
}
</code></pre>
<table>
<thead>
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td>Yes</td>
<td>Plugin identifier (lowercase, numbers, hyphens)</td>
</tr>
<tr>
<td><code>description</code></td>
<td>Yes</td>
<td>Plugin description</td>
</tr>
<tr>
<td><code>version</code></td>
<td>Yes</td>
<td>Semantic Versioning (e.g. <code>1.0.0</code>)</td>
</tr>
<tr>
<td><code>author</code></td>
<td>No</td>
<td>Author info (<code>name</code>, <code>url</code>)</td>
</tr>
<tr>
<td><code>homepage</code></td>
<td>No</td>
<td>Plugin homepage URL</td>
</tr>
<tr>
<td><code>repository</code></td>
<td>No</td>
<td>Source code repository URL</td>
</tr>
<tr>
<td><code>license</code></td>
<td>No</td>
<td>License</td>
</tr>
</tbody>
</table>
<h2>3.3 Plugin Development Workflow</h2>
<h3>3.3.1 Local Testing (--plugin-dir)</h3>
<p>When developing a Plugin, you can test it locally right away.</p>
<pre><code class="language-bash"># Load a single Plugin
claude --plugin-dir ./my-plugin

# Load multiple Plugins at once
claude --plugin-dir ./plugin-one --plugin-dir ./plugin-two
</code></pre>
<p>A Plugin loaded with <code>--plugin-dir</code> behaves the same as a regular installation. Skills can be invoked in the <code>/plugin-name:skill-name</code> format, and Hooks also work as configured.</p>
<h3>3.3.2 Migrating Existing .claude/ Configuration to a Plugin</h3>
<p>If you already have extensions configured in the <code>.claude/</code> directory, you can convert them to a Plugin with a simple structural transformation.</p>
<table>
<thead>
<tr>
<th>Existing location</th>
<th>Plugin location</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>.claude/commands/*.md</code></td>
<td><code>commands/*.md</code></td>
</tr>
<tr>
<td><code>.claude/skills/&#x3C;name>/SKILL.md</code></td>
<td><code>skills/&#x3C;name>/SKILL.md</code></td>
</tr>
<tr>
<td><code>.claude/agents/*.md</code></td>
<td><code>agents/*.md</code></td>
</tr>
<tr>
<td>hooks section in <code>settings.json</code></td>
<td><code>hooks/hooks.json</code></td>
</tr>
<tr>
<td><code>.mcp.json</code></td>
<td><code>.mcp.json</code> (as is)</td>
</tr>
</tbody>
</table>
<p>Migration steps:</p>
<ol>
<li>Create the Plugin directory and write <code>plugin.json</code></li>
<li>Copy existing files into the new structure</li>
<li>Separate the hooks from <code>settings.json</code> into <code>hooks/hooks.json</code></li>
<li>Test with <code>--plugin-dir</code></li>
<li>Remove the existing <code>.claude/</code> configuration (optional)</li>
</ol>
<h2>3.4 LSP Server (Code Intelligence)</h2>
<p>A Plugin can include an LSP (Language Server Protocol) server to provide <strong>real-time code intelligence</strong>. The LSP server provides Claude with features such as code diagnostics (errors, warnings), autocompletion, and go-to-definition.</p>
<h3>3.4.1 .lsp.json Configuration</h3>
<pre><code class="language-json">{
  "mcpServers": {
    "typescript-lsp": {
      "command": "npx",
      "args": ["typescript-language-server", "--stdio"],
      "languages": ["typescript", "typescriptreact"]
    },
    "python-lsp": {
      "command": "pylsp",
      "args": [],
      "languages": ["python"]
    }
  }
}
</code></pre>
<p>When the LSP server is enabled, Claude can reference real-time diagnostic information when modifying code. It detects compile errors or type errors and recognizes problems before making fixes.</p>
<h3>3.4.2 Official LSP Plugins</h3>
<p>The official Anthropic Marketplace provides LSP Plugins for 11 languages.</p>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Language</th>
<th>Required binary</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>typescript-lsp</code></td>
<td>TypeScript/JavaScript</td>
<td><code>typescript-language-server</code></td>
</tr>
<tr>
<td><code>pyright-lsp</code></td>
<td>Python</td>
<td><code>pyright-langserver</code></td>
</tr>
<tr>
<td><code>gopls-lsp</code></td>
<td>Go</td>
<td><code>gopls</code></td>
</tr>
<tr>
<td><code>rust-analyzer-lsp</code></td>
<td>Rust</td>
<td><code>rust-analyzer</code></td>
</tr>
<tr>
<td><code>clangd-lsp</code></td>
<td>C/C++</td>
<td><code>clangd</code></td>
</tr>
<tr>
<td><code>jdtls-lsp</code></td>
<td>Java</td>
<td><code>jdtls</code></td>
</tr>
<tr>
<td><code>kotlin-lsp</code></td>
<td>Kotlin</td>
<td><code>kotlin-language-server</code></td>
</tr>
<tr>
<td><code>swift-lsp</code></td>
<td>Swift</td>
<td><code>sourcekit-lsp</code></td>
</tr>
<tr>
<td><code>csharp-lsp</code></td>
<td>C#</td>
<td><code>csharp-ls</code></td>
</tr>
<tr>
<td><code>php-lsp</code></td>
<td>PHP</td>
<td><code>intelephense</code></td>
</tr>
<tr>
<td><code>lua-lsp</code></td>
<td>Lua</td>
<td><code>lua-language-server</code></td>
</tr>
</tbody>
</table>
<p>When an LSP Plugin is enabled, Claude automatically receives diagnostic information (errors, warnings) after modifying a file, allowing it to recognize problems without running the compiler directly.</p>
<h1>4. Plugin Marketplace</h1>
<h2>4.1 Official Marketplace</h2>
<p>Anthropic operates an official Plugin Marketplace (<code>anthropics/claude-plugins-official</code>), which provides curated Plugins. It is available automatically when Claude Code starts.</p>
<p>Characteristics of the official Marketplace:</p>
<ul>
<li>Includes only Plugins verified by Anthropic</li>
<li>Connected by default when Claude Code is installed</li>
<li>Browse and install directly via the <code>/plugins</code> command</li>
</ul>
<h2>4.2 Installing Plugins</h2>
<h3>4.2.1 /plugins Command</h3>
<p>Running the <code>/plugins</code> command in Claude Code opens the Plugin management interface. You can search for and install Plugins from the Marketplace.</p>
<h3>4.2.2 Installation Scope</h3>
<p>When installing a Plugin, you choose one of 3 scopes.</p>
<table>
<thead>
<tr>
<th>Scope</th>
<th>Description</th>
<th>Storage location</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Install for you</strong></td>
<td>Use across all projects (user global)</td>
<td><code>~/.claude/plugins/</code></td>
</tr>
<tr>
<td><strong>Install for this project</strong></td>
<td>Share with project teammates</td>
<td><code>.claude/plugins/</code> (Git-committable)</td>
</tr>
<tr>
<td><strong>Install locally</strong></td>
<td>Current repo, current user only</td>
<td><code>.claude/plugins.local/</code></td>
</tr>
</tbody>
</table>
<h3>4.2.3 Installing via CLI</h3>
<pre><code class="language-bash"># Install from the Marketplace
claude plugin install &#x3C;plugin-name>

# Install directly from a GitHub repo
claude plugin install github:username/repo-name

# Install from a local path
claude plugin install /path/to/local/plugin
</code></pre>
<h3>4.2.4 Plugin Management CLI Commands</h3>
<table>
<thead>
<tr>
<th>Command</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>claude plugin install &#x3C;plugin> [-s scope]</code></td>
<td>Install a Plugin</td>
</tr>
<tr>
<td><code>claude plugin uninstall &#x3C;plugin> [-s scope]</code></td>
<td>Remove a Plugin</td>
</tr>
<tr>
<td><code>claude plugin enable &#x3C;plugin> [-s scope]</code></td>
<td>Enable a disabled Plugin</td>
</tr>
<tr>
<td><code>claude plugin disable &#x3C;plugin> [-s scope]</code></td>
<td>Disable without removing</td>
</tr>
<tr>
<td><code>claude plugin update &#x3C;plugin> [-s scope]</code></td>
<td>Update to the latest version</td>
</tr>
<tr>
<td><code>claude plugin validate .</code></td>
<td>Validate Plugin structure</td>
</tr>
</tbody>
</table>
<h2>4.3 Creating &#x26; Distributing a Marketplace</h2>
<h3>4.3.1 Using a GitHub Repo as a Marketplace</h3>
<p>A community Marketplace can be run on top of a GitHub repo. Just create a <code>marketplace.json</code> file at the repo root.</p>
<pre><code class="language-json">{
  "name": "my-marketplace",
  "owner": {
    "name": "DevTools Team",
    "email": "devtools@example.com"
  },
  "metadata": {
    "description": "Team-only Plugin Marketplace",
    "version": "1.0.0"
  },
  "plugins": [
    {
      "name": "my-plugin",
      "description": "Plugin description",
      "source": {
        "source": "github",
        "repo": "username/my-plugin"
      },
      "tags": ["workflow", "automation"]
    },
    {
      "name": "local-plugin",
      "description": "Local path Plugin",
      "source": "./plugins/local-plugin"
    }
  ]
}
</code></pre>
<h3>4.3.2 Registering a Marketplace</h3>
<p>How users add a community Marketplace:</p>
<p>You can add it from the Marketplace tab via <code>/plugins</code> within Claude Code, or add it via the CLI.</p>
<pre><code class="language-bash"># Add by GitHub repo
/plugin marketplace add username/marketplace-repo

# Add by Git URL (GitLab, Bitbucket, etc.)
/plugin marketplace add https://gitlab.com/org/marketplace-repo.git

# Pin to a specific branch/tag
/plugin marketplace add https://github.com/org/plugins.git#v1.0.0
</code></pre>
<p>In team projects, you can configure the Marketplace in <code>.claude/settings.json</code> so that teammates use it automatically.</p>
<pre><code class="language-json">{
  "extraKnownMarketplaces": {
    "company-tools": {
      "source": {
        "source": "github",
        "repo": "your-org/claude-plugins"
      }
    }
  },
  "enabledPlugins": {
    "code-formatter@company-tools": true,
    "deployment-tools@company-tools": true
  }
}
</code></pre>
<h3>4.3.3 Distribution Checklist</h3>
<p>Things to check when distributing a Plugin:</p>
<ol>
<li>Complete the <code>.claude-plugin/plugin.json</code> manifest</li>
<li>Write a <code>README.md</code> (installation, usage, configuration options)</li>
<li>Apply Semantic Versioning (match the Git tag with the <code>version</code> field)</li>
<li>Check whether sensitive information (API keys, personal paths) is included</li>
<li>Complete local testing with <code>--plugin-dir</code></li>
<li>Push to a public GitHub repo</li>
</ol>
<h1>5. Agent Teams (Experimental Feature)</h1>
<h2>5.1 Overview</h2>
<p>Agent Teams is an experimental feature that coordinates multiple Claude Code instances as a <strong>team</strong>. One session takes the role of Team Lead, distributing work, and multiple Teammates work independently before sharing their results.</p>
<p>The key difference from Subagent is that <strong>direct communication between Teammates</strong> is possible.</p>
<table>
<thead>
<tr>
<th>Criteria</th>
<th>Subagent</th>
<th>Agent Team</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Context</strong></td>
<td>Own context, returns only the result</td>
<td>Fully independent instance</td>
</tr>
<tr>
<td><strong>Communication</strong></td>
<td>Reports only to the main agent</td>
<td>Teammates communicate directly with each other</td>
</tr>
<tr>
<td><strong>Coordination</strong></td>
<td>Managed by the main agent</td>
<td>Self-coordinated via a shared task list</td>
</tr>
<tr>
<td><strong>Suitable for</strong></td>
<td>Focused tasks where only the result matters</td>
<td>Complex tasks requiring discussion and collaboration</td>
</tr>
<tr>
<td><strong>Token cost</strong></td>
<td>Low (result summary)</td>
<td>High (context per instance)</td>
</tr>
</tbody>
</table>
<pre><code class="language-mermaid">flowchart TD
    Lead["Team Lead\n(coordination only)"]
    T1["Teammate 1\nSecurity review"]
    T2["Teammate 2\nPerformance analysis"]
    T3["Teammate 3\nTest validation"]
    TL["Shared task list"]

    Lead --> T1
    Lead --> T2
    Lead --> T3
    T1 &#x3C;--> T2
    T2 &#x3C;--> T3
    T1 &#x3C;--> T3
    T1 --- TL
    T2 --- TL
    T3 --- TL

    style Lead fill:#e8f4fd,stroke:#1a73e8
    style TL fill:#fff3cd,stroke:#ffc107
</code></pre>
<h2>5.2 Enabling &#x26; Usage</h2>
<h3>5.2.1 Enabling</h3>
<p>Agent Teams is disabled by default. Enable it via an environment variable or settings.json.</p>
<pre><code class="language-json">{
  "env": {
    "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
  }
}
</code></pre>
<h3>5.2.2 Creating a Team</h3>
<p>Request the team composition from Claude in natural language.</p>
<pre><code>Create an agent team to review PR #142.
- 1 person responsible for reviewing security vulnerabilities
- 1 person responsible for analyzing performance impact
- 1 person responsible for verifying test coverage
Each reviews and shares the results.
</code></pre>
<h3>5.2.3 Delegate Mode</h3>
<p>Pressing Shift+Tab switches to Delegate mode. In this mode, the Team Lead does not modify code directly and performs <strong>only coordination work</strong>.</p>
<ul>
<li>Creating and managing Teammates</li>
<li>Assigning tasks and checking status</li>
<li>Relaying messages and synthesizing results</li>
<li>Delegating code modification, file creation, etc. to Teammates</li>
</ul>
<h3>5.2.4 Display Modes</h3>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Description</th>
<th>Requirements</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>in-process</code></td>
<td>Run all Teammates in the main terminal</td>
<td>None (default)</td>
</tr>
<tr>
<td><code>tmux</code></td>
<td>Each Teammate runs in a separate pane</td>
<td>tmux or iTerm2</td>
</tr>
<tr>
<td><code>auto</code></td>
<td>Split if in a tmux session, otherwise in-process</td>
<td>Auto-detect</td>
</tr>
</tbody>
</table>
<pre><code class="language-json">{
  "teammateMode": "in-process"
}
</code></pre>
<h2>5.3 Suitable Use Cases &#x26; Limitations</h2>
<h3>5.3.1 Suitable Cases</h3>
<ul>
<li><strong>Research &#x26; review</strong>: Investigate from multiple perspectives simultaneously (security, performance, UX)</li>
<li><strong>Cross-layer work</strong>: Assign frontend, backend, and tests to different Teammates</li>
<li><strong>Competing-hypothesis debugging</strong>: Verify multiple hypotheses simultaneously and have them refute each other</li>
<li><strong>New module/feature development</strong>: Develop independent modules in parallel</li>
</ul>
<h3>5.3.2 Limitations</h3>
<ul>
<li><strong>No session resume</strong>: in-process Teammates cannot be restored with <code>/resume</code> or <code>/rewind</code></li>
<li><strong>One team per session</strong>: Only one team can run at a time</li>
<li><strong>No nesting</strong>: Teammates cannot create their own teams</li>
<li><strong>Fixed Lead</strong>: The session that created the team is permanently the Lead role</li>
<li><strong>Watch for file conflicts</strong>: If multiple Teammates modify the same file, overwrites occur</li>
<li><strong>Token cost</strong>: Since each Teammate needs a separate context, the cost increases significantly</li>
</ul>
<h1>6. Security &#x26; Enterprise Management</h1>
<h2>6.1 Hook Security Considerations</h2>
<p>Hooks run with <strong>the user's system privileges</strong>. Therefore, you must be especially careful about security.</p>
<ul>
<li><strong>Input validation</strong>: Always validate the JSON data passed via stdin when parsing it</li>
<li><strong>Path traversal defense</strong>: When handling file paths in a Hook script, prevent path traversal attacks such as <code>..</code></li>
<li><strong>Sensitive file protection</strong>: Block commands that access <code>.env</code>, <code>.git/</code>, API key files, etc.</li>
<li><strong>Timeout setting</strong>: Set the <code>timeout</code> field to prevent infinite loops</li>
</ul>
<h2>6.2 Enterprise Management Settings</h2>
<p>In an enterprise environment, IT administrators can control extensions centrally.</p>
<h3>6.2.1 managed-mcp.json</h3>
<p>IT administrators centrally deploy MCP servers across the entire organization. The organization's standard tools are provided automatically, without users having to configure them individually.</p>
<h3>6.2.2 MCP Server Allow/Deny Lists</h3>
<pre><code class="language-json">{
  "allowedMcpServers": ["github", "jira", "slack"],
  "deniedMcpServers": ["*"]
}
</code></pre>
<ul>
<li><code>allowedMcpServers</code>: List of allowed MCP servers</li>
<li><code>deniedMcpServers</code>: List of blocked MCP servers (block all with <code>*</code>, then use only the allow list)</li>
</ul>
<h3>6.2.3 allowManagedHooksOnly</h3>
<pre><code class="language-json">{
  "allowManagedHooksOnly": true
}
</code></pre>
<p>When this setting is enabled, all user- or project-level Hooks are ignored, and <strong>only Hooks set by the administrator</strong> are run. It is useful in security-critical enterprise environments.</p>
<h3>6.2.4 strictKnownMarketplaces</h3>
<pre><code class="language-json">{
  "strictKnownMarketplaces": true
}
</code></pre>
<p>When this setting is enabled, Plugins can be installed only from Marketplaces approved by the administrator. It prevents installing Plugins from unknown sources.</p>
<h1>7. Conclusion</h1>
<p>Including the Command, Skill, and Subagent covered in the previous article, when we put together Claude Code's 5 extension mechanisms, you can choose among them depending on the situation as follows.</p>
<pre><code class="language-mermaid">flowchart TD
    A["Do you need an extension?"] -- Yes --> B["Which type?"]
    A -- No --> Z["Use plain Claude Code"]

    B --> C["Automate repetitive tasks"]
    B --> D["Inject knowledge/rules"]
    B --> E["Delegate independent work"]
    B --> F["Event automation"]
    B --> G["Team sharing/distribution"]

    C --> C1["Command\n(/commit, /deploy)"]
    D --> D1["Skill\n(coding conventions, project rules)"]
    E --> E1["Subagent\n(code review, debugging)"]
    F --> F1["Hooks\n(linting, command blocking)"]
    G --> G1["Plugin\n(package everything)"]

    style C1 fill:#d4edda,stroke:#28a745
    style D1 fill:#e8f4fd,stroke:#1a73e8
    style E1 fill:#fce8e6,stroke:#d93025
    style F1 fill:#fff3cd,stroke:#ffc107
    style G1 fill:#e8daef,stroke:#8e44ad
</code></pre>
<table>
<thead>
<tr>
<th>Extension mechanism</th>
<th>One-line summary</th>
<th>Suitable for</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Command</strong></td>
<td>"Run this procedure"</td>
<td>Standardized tasks you repeat daily</td>
</tr>
<tr>
<td><strong>Skill</strong></td>
<td>"Refer to this knowledge"</td>
<td>Coding rules, project guidelines</td>
</tr>
<tr>
<td><strong>Subagent</strong></td>
<td>"Leave it to this expert"</td>
<td>Independent analysis, need for isolated execution</td>
</tr>
<tr>
<td><strong>Hooks</strong></td>
<td>"Automatically react to this event"</td>
<td>Linting, security checks, deterministic validation</td>
</tr>
<tr>
<td><strong>Plugin</strong></td>
<td>"Bundle everything and distribute it"</td>
<td>Team sharing, community distribution, Marketplace</td>
</tr>
</tbody>
</table>
<p>Summarizing the growth path in practice:</p>
<ol>
<li><strong>Start with Command</strong>: Automate repetitive tasks as slash commands</li>
<li><strong>Inject knowledge with Skill</strong>: Teach Claude your project rules</li>
<li><strong>Delegate with Subagent</strong>: Separate independent work to specialized agents</li>
<li><strong>Automate with Hooks</strong>: Add event-driven deterministic validation</li>
<li><strong>Package with Plugin</strong>: Bundle all extensions into one and share with your team</li>
</ol>
<h1>8. References</h1>
<ul>
<li><a href="https://code.claude.com/docs/en/hooks">Claude Code Hooks Reference</a></li>
<li><a href="https://code.claude.com/docs/en/hooks-guide">Claude Code Hooks Guide</a></li>
<li><a href="https://code.claude.com/docs/en/plugins">Claude Code Plugins</a></li>
<li><a href="https://code.claude.com/docs/en/plugins-reference">Claude Code Plugins Reference</a></li>
<li><a href="https://code.claude.com/docs/en/discover-plugins">Discover and Install Plugins</a></li>
<li><a href="https://code.claude.com/docs/en/plugin-marketplaces">Plugin Marketplaces</a></li>
<li><a href="https://code.claude.com/docs/en/agent-teams">Agent Teams</a></li>
<li><a href="https://www.petegypps.uk/blog/claude-code-official-plugin-marketplace-complete-guide-36-plugins-december-2025">Claude Code Official Plugin Marketplace Guide</a></li>
<li><a href="https://composio.dev/blog/claude-code-plugin">Improving your coding workflow with Claude Code Plugins</a></li>
<li><a href="https://addyosmani.com/blog/claude-code-agent-teams/">Claude Code Agent Teams - Addy Osmani</a></li>
<li><a href="https://alexop.dev/posts/from-tasks-to-swarms-agent-teams-in-claude-code/">From Tasks to Swarms: Agent Teams</a></li>
<li><a href="/article/claude-code-%ED%99%95%EC%9E%A5-%EA%B8%B0%EB%8A%A5-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C-command-skill-subagent">Claude Code 확장 기능 완벽 가이드: Command, Skill, Subagent</a></li>
</ul>]]></content:encoded>
      <category>ai</category>
      <category>Claude Code</category>
      <category>Plugin</category>
      <category>Hooks</category>
      <category>Marketplace</category>
      <category>AI</category>
      <category>AI코딩도구</category>
      <category>워크플로우자동화</category>
      <category>Anthropic</category>
      <category>Series: Claude Code Complete Guide</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Golang Concurrency Part 2 - Mastering Channels</title>
      <link>https://blog.advenoh.pe.kr/en/golang-concurrency-2-channel-완전-정복</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/golang-concurrency-2-channel-완전-정복</guid>
      <pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate>
      <description>Covers Go channels from basic behavior to the buffered/unbuffered difference, direction restrictions, close rules, and the producer-consumer pattern</description>
      <content:encoded><![CDATA[<p>A channel is the <strong>means of communication for exchanging data</strong> between goroutines. It is the core mechanism that realizes Go's concurrency philosophy: "do not communicate by sharing memory; instead, share memory by communicating."</p>
<p>In this part, we fully cover channels — from basic behavior to the buffered/unbuffered difference, direction restrictions, and close rules.</p>
<h2>1. Channel Concept and Creation</h2>
<p>A channel is created with the <code>make</code> function. Think of it as a typed pipe.</p>
<pre><code class="language-go">ch := make(chan int)       // unbuffered channel (int type)
ch := make(chan string, 5) // buffered channel (string type, buffer size 5)
</code></pre>
<h2>2. Send / Receive Behavior</h2>
<p>To send a value to a channel, use the <code>&#x3C;-</code> operator.</p>
<pre><code class="language-go">ch &#x3C;- 42    // send: send 42 to the channel
val := &#x3C;-ch // receive: receive a value from the channel
</code></pre>
<pre><code class="language-go">func TestChannelSendReceive(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch &#x3C;- 42 // send
    }()

    value := &#x3C;-ch // receive (blocks until sent)
    assert.Equal(t, 42, value)
}
</code></pre>
<p>Channels support various types. Passing a struct lets you send a result and an error together.</p>
<pre><code class="language-go">func TestChannelStructType(t *testing.T) {
    type Result struct {
        Value int
        Err   error
    }

    ch := make(chan Result)

    go func() {
        ch &#x3C;- Result{Value: 100, Err: nil}
    }()

    result := &#x3C;-ch
    assert.Equal(t, 100, result.Value)
    assert.NoError(t, result.Err)
}
</code></pre>
<h2>3. Understanding Blocking Behavior</h2>
<p>The most important characteristic of a channel is <strong>blocking</strong>.</p>
<ul>
<li><strong>Unbuffered channel</strong>: send and receive must be <strong>ready at the same time</strong> to proceed</li>
<li><strong>Buffered channel</strong>: send blocks when the buffer is full; receive blocks when the buffer is empty</li>
</ul>
<pre><code class="language-mermaid">graph LR
    subgraph "Unbuffered Channel"
        GA[Goroutine A&#x3C;br/>send - blocking] -- "handshake&#x3C;br/>(proceed together)" --> GB[Goroutine B&#x3C;br/>receive - blocking]
    end
</code></pre>
<pre><code class="language-mermaid">graph LR
    subgraph "Buffered Channel (size 3)"
        GA2[Goroutine A&#x3C;br/>3 sends OK&#x3C;br/>4th send BLOCK!] --> BUF[Buffer&#x3C;br/>size: 3] --> GB2[Goroutine B&#x3C;br/>receive]
    end
</code></pre>
<h2>4. Unbuffered vs Buffered Channel</h2>
<h3>Unbuffered Channel</h3>
<pre><code class="language-go">ch := make(chan int) // buffer size 0
</code></pre>
<ul>
<li>when you send, it blocks <strong>until the receiver receives</strong></li>
<li>synchronous communication: both sides must be ready to proceed</li>
</ul>
<pre><code class="language-go">func TestUnbufferedChannel(t *testing.T) {
    ch := make(chan int)

    go func() {
        ch &#x3C;- 42 // blocks here until the receiver is ready
    }()

    time.Sleep(100 * time.Millisecond) // the sender is already blocking
    value := &#x3C;-ch                       // the moment we receive, the sender proceeds too
    assert.Equal(t, 42, value)
}
</code></pre>
<h3>Buffered Channel</h3>
<pre><code class="language-go">ch := make(chan int, 3) // buffer size 3
</code></pre>
<ul>
<li>if there's free space in the buffer, send completes <strong>immediately</strong></li>
<li>send blocks when the buffer is full</li>
<li>asynchronous communication</li>
</ul>
<pre><code class="language-go">func TestBufferedChannel(t *testing.T) {
    ch := make(chan int, 3)

    // can send up to 3 even without a receiver
    ch &#x3C;- 1
    ch &#x3C;- 2
    ch &#x3C;- 3
    // ch &#x3C;- 4 → blocking (buffer full)

    assert.Equal(t, 1, &#x3C;-ch)
    assert.Equal(t, 2, &#x3C;-ch)
    assert.Equal(t, 3, &#x3C;-ch)
}
</code></pre>
<h3>cap and len</h3>
<pre><code class="language-go">func TestBufferedChannelCapLen(t *testing.T) {
    ch := make(chan string, 5)

    assert.Equal(t, 5, cap(ch)) // buffer capacity
    assert.Equal(t, 0, len(ch)) // number of values currently waiting

    ch &#x3C;- "a"
    ch &#x3C;- "b"

    assert.Equal(t, 5, cap(ch))
    assert.Equal(t, 2, len(ch))
}
</code></pre>
<h3>When to Use Which?</h3>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Choice</th>
</tr>
</thead>
<tbody>
<tr>
<td>synchronization (handshake) between goroutines needed</td>
<td>Unbuffered</td>
</tr>
<tr>
<td>signaling (done, quit)</td>
<td>Unbuffered</td>
</tr>
<tr>
<td>buffering the difference between production and consumption speeds</td>
<td>Buffered</td>
</tr>
<tr>
<td>Producer/Consumer pattern</td>
<td>Buffered</td>
</tr>
<tr>
<td>high-throughput data transfer where performance matters</td>
<td>Buffered</td>
</tr>
</tbody>
</table>
<h2>5. Channel Direction Restrictions</h2>
<p>You can restrict a channel's direction in a function parameter.</p>
<pre><code class="language-go">chan&#x3C;- int  // send-only (can only send)
&#x3C;-chan int  // receive-only (can only receive)
</code></pre>
<p>This lets you prevent incorrect use of a channel <strong>at compile time</strong>.</p>
<pre><code class="language-go">// producer: send-only channel
func produce(ch chan&#x3C;- int, values []int) {
    for _, v := range values {
        ch &#x3C;- v
    }
    close(ch)
}

// consumer: receive-only channel
func consume(ch &#x3C;-chan int) []int {
    var results []int
    for v := range ch {
        results = append(results, v)
    }
    return results
}

func TestChannelDirection(t *testing.T) {
    ch := make(chan int, 5)
    go produce(ch, []int{1, 2, 3, 4, 5})
    results := consume(ch)
    assert.Equal(t, []int{1, 2, 3, 4, 5}, results)
}
</code></pre>
<p>A bidirectional channel is <strong>implicitly converted</strong> to send-only or receive-only. Conversion in the opposite direction causes a compile error.</p>
<pre><code class="language-go">var sendOnly chan&#x3C;- int = ch  // OK: bidirectional → send-only
var recvOnly &#x3C;-chan int = ch  // OK: bidirectional → receive-only
</code></pre>
<h2>6. Channel Close</h2>
<h3>The Meaning of close</h3>
<p><code>close(ch)</code> is a declaration that "<strong>no more values will be sent</strong> to this channel."</p>
<h3>Receiving from a Closed Channel</h3>
<pre><code class="language-go">func TestReceiveFromClosedChannel(t *testing.T) {
    ch := make(chan int, 1)
    ch &#x3C;- 42
    close(ch)

    // if there's a value in the buffer, it returns normally
    val, ok := &#x3C;-ch
    assert.Equal(t, 42, val)
    assert.True(t, ok)       // a valid value

    // empty buffer and closed channel → zero value + false
    val, ok = &#x3C;-ch
    assert.Equal(t, 0, val)  // int's zero value
    assert.False(t, ok)      // closed indicator
}
</code></pre>
<h3>Close Rules</h3>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Sender closes</strong></td>
<td>the <strong>sender</strong>, not the receiver, is responsible for closing</td>
</tr>
<tr>
<td><strong>No double close</strong></td>
<td>closing an already-closed channel causes a <strong>panic</strong></td>
</tr>
<tr>
<td><strong>No send on a closed channel</strong></td>
<td>sending a value to a closed channel causes a <strong>panic</strong></td>
</tr>
<tr>
<td><strong>Receive from a closed channel is OK</strong></td>
<td>returns zero value + false</td>
</tr>
</tbody>
</table>
<h3>Close Responsibility Pattern</h3>
<pre><code class="language-go">// pattern: the sender creates the channel, sends, and closes
func generator() &#x3C;-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)   // the sender is responsible for closing
        for i := range 5 {
            ch &#x3C;- i
        }
    }()
    return ch // return as receive-only
}
</code></pre>
<h2>7. Range over Channel</h2>
<p>Using <code>range</code>, values are automatically received <strong>until the channel is closed</strong>.</p>
<pre><code class="language-go">func TestRangeOverChannel(t *testing.T) {
    ch := make(chan int, 5)

    go func() {
        for i := 1; i &#x3C;= 5; i++ {
            ch &#x3C;- i
        }
        close(ch) // close is required for range to terminate!
    }()

    var results []int
    for v := range ch {
        results = append(results, v)
    }

    assert.Equal(t, []int{1, 2, 3, 4, 5}, results)
}
</code></pre>
<blockquote>
<p>For <code>range ch</code> to terminate, <code>close(ch)</code> must be called. Without close, range blocks forever.</p>
</blockquote>
<h3>Signaling Channel</h3>
<p>To send only a <strong>completion signal</strong> rather than data, use <code>chan struct{}</code>. struct{} takes up no memory.</p>
<pre><code class="language-go">func TestChannelSignaling(t *testing.T) {
    done := make(chan struct{})

    go func() {
        // do work...
        close(done) // completion signal (delivered to all receivers)
    }()

    &#x3C;-done // wait for completion
}
</code></pre>
<p><code>close(done)</code> sends a signal to <strong>all</strong> receivers simultaneously. This is the difference from simply doing <code>done &#x3C;- struct{}{}</code>.</p>
<blockquote>
<p>If you're confused about the difference between <code>struct{}</code> and <code>struct{}{}</code>, see the <a href="#q-whats-the-difference-between-struct-and-struct">FAQ</a>.</p>
</blockquote>
<h2>8. Practice: Producer / Consumer Pattern</h2>
<pre><code class="language-go">func TestProducerConsumer(t *testing.T) {
    ch := make(chan int, 10)
    var results []int

    // Producer: generate squares
    go func() {
        for i := 1; i &#x3C;= 10; i++ {
            ch &#x3C;- i * i
        }
        close(ch)
    }()

    // Consumer: collect results
    for val := range ch {
        results = append(results, val)
    }

    expected := []int{1, 4, 9, 16, 25, 36, 49, 64, 81, 100}
    assert.Equal(t, expected, results)
}
</code></pre>
<h3>Multiple Producers</h3>
<pre><code class="language-go">func TestMultipleProducers(t *testing.T) {
    ch := make(chan int, 20)
    var wg sync.WaitGroup

    // 3 producers
    for p := range 3 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for i := range 5 {
                ch &#x3C;- p*100 + i
            }
        }()
    }

    // close the channel once all producers finish
    go func() {
        wg.Wait()
        close(ch)
    }()

    var results []int
    for val := range ch {
        results = append(results, val)
    }

    assert.Len(t, results, 15) // 3 x 5 = 15 items
}
</code></pre>
<h2>9. Summary</h2>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Core</th>
</tr>
</thead>
<tbody>
<tr>
<td>Channel</td>
<td>a type-safe means of communication between goroutines</td>
</tr>
<tr>
<td>Unbuffered</td>
<td>synchronous handshake, requires simultaneous send/receive readiness</td>
</tr>
<tr>
<td>Buffered</td>
<td>asynchronous queue, can send up to the buffer size</td>
</tr>
<tr>
<td>Direction restriction</td>
<td><code>chan&#x3C;-</code> (send-only), <code>&#x3C;-chan</code> (receive-only)</td>
</tr>
<tr>
<td>Close</td>
<td>the sender is responsible; a closed channel returns the zero value</td>
</tr>
<tr>
<td>Range</td>
<td>automatically receives until the channel is closed</td>
</tr>
<tr>
<td><code>chan struct{}</code></td>
<td>for signaling (0 memory)</td>
</tr>
</tbody>
</table>
<p>In the next part, we'll cover advanced channel patterns such as the <strong>select</strong> statement, which handles multiple channels simultaneously, and <strong>fan-in/fan-out</strong>.</p>
<h2>FAQ</h2>
<h3>Q. What's the difference between <code>struct{}</code> and <code>struct{}{}</code>?</h3>
<p><code>struct{}</code> is a <strong>type</strong>, and <code>struct{}{}</code> is a <strong>value (instance)</strong>.</p>
<p>It's easier to understand by analogy with a regular struct:</p>
<pre><code class="language-go">type Person struct {
    Name string
    Age  int
}

p := Person{Name: "Frank", Age: 30}
//   ^^^^^^ type
//         ^^^^^^^^^^^^^^^^^^^^^^^^ value
</code></pre>
<p>Likewise, an empty struct has the same structure:</p>
<pre><code class="language-go">v := struct{}{}
//   ^^^^^^^^ type: struct{} (a struct with no fields)
//           ^^ value: {} (empty literal)
</code></pre>
<table>
<thead>
<tr>
<th>Expression</th>
<th>Meaning</th>
<th>Analogy</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>int</code></td>
<td>int type</td>
<td>blueprint</td>
</tr>
<tr>
<td><code>42</code></td>
<td>int value</td>
<td>physical object</td>
</tr>
<tr>
<td><code>struct{}</code></td>
<td>empty struct type</td>
<td>blueprint (no fields)</td>
</tr>
<tr>
<td><code>struct{}{}</code></td>
<td>empty struct value</td>
<td>physical object (no content)</td>
</tr>
</tbody>
</table>
<p>When used with a channel:</p>
<pre><code class="language-go">// create a channel — specify the type
done := make(chan struct{})  // a channel of type struct{}

// send a value — send a struct{}{} instance
done &#x3C;- struct{}{}

// close — deliver a "closed" signal to all receivers instead of a value
close(done)
</code></pre>
<p>Since <code>struct{}</code> takes up 0 bytes of memory, it's the most efficient choice when you want to <strong>only deliver a signal</strong> without data.</p>
<h2>References</h2>
<ul>
<li><a href="https://go.dev/tour/concurrency/2">Go Tour - Channels</a></li>
<li><a href="https://go.dev/doc/effective_go#channels">Effective Go - Channels</a></li>
<li><a href="https://go.dev/blog/pipelines">Go Blog - Pipelines and cancellation</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>concurrency</category>
      <category>channel</category>
      <category>buffered</category>
      <category>unbuffered</category>
      <category>producer-consumer</category>
      <category>고랭</category>
      <category>동시성</category>
      <category>채널</category>
      <category>Series: Golang Concurrency</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Getting Started with Continuous Profiling for Go Applications Using Grafana Pyroscope</title>
      <link>https://blog.advenoh.pe.kr/en/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기</guid>
      <pubDate>Mon, 16 Mar 2026 00:00:00 GMT</pubDate>
      <description>A hands-on guide to integrating Grafana Pyroscope with a Go application, covering Push mode (SDK) and Pull mode (Alloy), and analyzing bottlenecks with flame graphs.</description>
      <content:encoded><![CDATA[<h1>1. Introduction</h1>
<p>When doing performance analysis in Go, we mainly use <code>net/http/pprof</code> or <code>runtime/pprof</code>. These are sufficient for checking the CPU usage or memory allocation at a specific point in time as a snapshot in a development environment, but in a production environment they have a few limitations.</p>
<ul>
<li>You have to collect profiles <strong>manually at the moment</strong> a problem occurs</li>
<li>The collected profiles remain only as <strong>local files</strong>, making comparison over time difficult</li>
<li>You cannot <strong>centrally manage</strong> profile data distributed across multiple instances</li>
</ul>
<p><strong>Continuous Profiling</strong> solves these limitations. It collects profile data with consistently low overhead in production, stores it in a central repository, and lets you query historical data anytime.</p>
<p>In this post, we'll get hands-on with how to integrate <strong>Grafana Pyroscope</strong>, a Continuous Profiling platform, into a Go application. We'll cover both collection methods — <strong>Push mode (SDK)</strong> and <strong>Pull mode (Alloy)</strong> — and walk through analyzing performance bottlenecks with flame graphs.</p>
<blockquote>
<p>The full code used in this post can be found on <a href="https://github.com/kenshin579/tutorials-go/tree/master/golang/profiling/pyroscope">GitHub</a>.</p>
</blockquote>
<h1>2. Continuous Profiling Overview</h1>
<h2>2.1 Traditional Profiling vs Continuous Profiling</h2>
<table>
<thead>
<tr>
<th>Category</th>
<th>Traditional Profiling</th>
<th>Continuous Profiling</th>
</tr>
</thead>
<tbody>
<tr>
<td>Collection time</td>
<td>manual execution during development/debugging</td>
<td>always-on automatic collection in production</td>
</tr>
<tr>
<td>Overhead</td>
<td>high (used only in development environments)</td>
<td>low (~2-5% CPU)</td>
</tr>
<tr>
<td>Data range</td>
<td>snapshot at a specific point in time</td>
<td>continuous data over time</td>
</tr>
<tr>
<td>Analysis approach</td>
<td>post-hoc analysis (reactive)</td>
<td>proactive analysis</td>
</tr>
<tr>
<td>Storage</td>
<td>local files</td>
<td>centralized DB (long-term retention)</td>
</tr>
</tbody>
</table>
<p>Traditional profiling collects data manually after a problem occurs, whereas Continuous Profiling always collects data, so you can immediately check the profile at the moment a problem occurs.</p>
<h2>2.2 Profile Types (Go)</h2>
<p>The main profile types you can collect in Go are as follows.</p>
<table>
<thead>
<tr>
<th>Profile Type</th>
<th>Description</th>
<th>How to Enable</th>
</tr>
</thead>
<tbody>
<tr>
<td>CPU</td>
<td>CPU time used per function</td>
<td>enabled by default</td>
</tr>
<tr>
<td>Alloc (Objects/Space)</td>
<td>number/size of memory allocations</td>
<td>enabled by default</td>
</tr>
<tr>
<td>Inuse (Objects/Space)</td>
<td>currently used memory</td>
<td>enabled by default</td>
</tr>
<tr>
<td>Goroutine</td>
<td>number of active goroutines and stacks</td>
<td>optionally enabled</td>
</tr>
<tr>
<td>Mutex (Count/Duration)</td>
<td>mutex contention count/time</td>
<td><code>runtime.SetMutexProfileFraction()</code></td>
</tr>
<tr>
<td>Block (Count/Duration)</td>
<td>blocking wait count/time</td>
<td><code>runtime.SetBlockProfileRate()</code></td>
</tr>
</tbody>
</table>
<blockquote>
<p>The Mutex and Block profiles are disabled by default, so you have to enable them explicitly. In Push mode, set them before SDK initialization; in Pull mode, set them at application startup.</p>
</blockquote>
<h1>3. Grafana Pyroscope Architecture</h1>
<h2>3.1 Core Components</h2>
<p>Pyroscope consists of the following microservice components, and runs as a single process in Monolithic mode.</p>
<pre><code class="language-mermaid">flowchart LR
    Client["Client\n(SDK / Alloy)"]
    Dist["Distributor"]
    Ing["Ingester"]
    Store["Object Storage"]
    QF["Query Frontend"]
    Q["Querier"]
    SG["Store Gateway"]
    UI["Grafana UI"]

    Client --> Dist --> Ing --> Store
    UI --> QF --> Q --> Ing
    Q --> SG --> Store
</code></pre>
<table>
<thead>
<tr>
<th>Component</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Distributor</strong></td>
<td>receives and routes profile data from clients</td>
</tr>
<tr>
<td><strong>Ingester</strong></td>
<td>temporarily stores in memory, then writes to Object Storage</td>
</tr>
<tr>
<td><strong>Querier</strong></td>
<td>queries and merges profile data</td>
</tr>
<tr>
<td><strong>Query Frontend</strong></td>
<td>query caching and optimization</td>
</tr>
<tr>
<td><strong>Store Gateway</strong></td>
<td>accesses long-term storage (Object Storage)</td>
</tr>
</tbody>
</table>
<h2>3.2 Data Collection Methods: Push vs Pull</h2>
<p>Pyroscope can collect profile data in two ways. <strong>Once the data reaches the Pyroscope server, the storage, querying, and flame graph analysis are completely identical regardless of which collection method you use.</strong> The only difference is the collection path.</p>
<pre><code class="language-mermaid">flowchart TD
    subgraph push["Push Mode (SDK)"]
        App1["Go App\n+ pyroscope-go SDK"] -->|"direct send"| PS1["Pyroscope Server"]
    end

    subgraph pull["Pull Mode (Alloy)"]
        App2["Go App\n+ pprof endpoint"] &#x3C;-->|"periodic scraping"| Alloy["Grafana Alloy"]
        Alloy -->|"send"| PS2["Pyroscope Server"]
    end
</code></pre>
<table>
<thead>
<tr>
<th>Criteria</th>
<th>Push (SDK)</th>
<th>Pull (Alloy)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Code change</strong></td>
<td>requires adding the SDK</td>
<td>none (just expose <code>pprof</code>)</td>
</tr>
<tr>
<td><strong>Infrastructure</strong></td>
<td>none added</td>
<td>requires installing Alloy</td>
</tr>
<tr>
<td><strong>Profiling Labels</strong></td>
<td>fine-grained label tagging possible with <code>TagWrapper</code></td>
<td>only the default pprof labels</td>
</tr>
<tr>
<td><strong>Leveraging existing pprof</strong></td>
<td>requires separate coexistence setup</td>
<td>used as-is</td>
</tr>
<tr>
<td><strong>K8s environment</strong></td>
<td>SDK setup per Pod</td>
<td>bulk collection with an Alloy DaemonSet</td>
</tr>
<tr>
<td><strong>Recommended for</strong></td>
<td>new projects, when fine-grained analysis is needed</td>
<td>existing services, when code changes are difficult</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>Practical tip</strong>: In a Kubernetes environment, if you already have many services that expose pprof, Pull mode is efficient. On the other hand, if you need fine-grained analysis like per-endpoint profiling, Push mode's <code>TagWrapper</code> is advantageous.</p>
</blockquote>
<h1>4. Setting Up a Local Environment</h1>
<p>With Docker Compose, you can run the Pyroscope server, Grafana, and the Push/Pull mode sample applications all at once.</p>
<h2>4.1 Docker Compose Configuration</h2>
<pre><code class="language-yaml">services:
  # --- common infrastructure ---
  pyroscope:
    image: grafana/pyroscope:latest
    ports:
      - "4040:4040"
    networks:
      - pyroscope-net

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
    depends_on:
      - pyroscope
    networks:
      - pyroscope-net

  # --- Push mode ---
  app-http:
    build:
      context: .
      dockerfile: http-server/Dockerfile
    ports:
      - "8080:8080"
    depends_on:
      - pyroscope
    environment:
      - PYROSCOPE_SERVER=http://pyroscope:4040
      - PORT=8080
    networks:
      - pyroscope-net

  # --- Pull mode ---
  app-pull:
    build:
      context: .
      dockerfile: pull-server/Dockerfile
    ports:
      - "6060:6060"
    environment:
      - PORT=6060
    networks:
      - pyroscope-net

  alloy:
    image: grafana/alloy:latest
    volumes:
      - ./alloy/config.alloy:/etc/alloy/config.alloy
    command: ["run", "/etc/alloy/config.alloy"]
    depends_on:
      - pyroscope
      - app-pull
    networks:
      - pyroscope-net

networks:
  pyroscope-net:
    driver: bridge
</code></pre>
<pre><code class="language-bash">> docker compose up -d
</code></pre>
<h2>4.2 Connecting the Grafana Data Source</h2>
<p>You can automatically register the Pyroscope data source with Grafana provisioning settings.</p>
<pre><code class="language-yaml"># grafana/provisioning/datasources/pyroscope.yml
apiVersion: 1

datasources:
  - name: Pyroscope
    type: grafana-pyroscope-datasource
    url: http://pyroscope:4040
    isDefault: true
    editable: true
</code></pre>
<h2>4.3 Access URLs</h2>
<table>
<thead>
<tr>
<th>Service</th>
<th>URL</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pyroscope</td>
<td><a href="http://localhost:4040">http://localhost:4040</a></td>
<td>Pyroscope UI</td>
</tr>
<tr>
<td>Grafana</td>
<td><a href="http://localhost:3000">http://localhost:3000</a></td>
<td>Grafana dashboard (anonymous access)</td>
</tr>
<tr>
<td>App (Push)</td>
<td><a href="http://localhost:8080">http://localhost:8080</a></td>
<td>Echo HTTP server (Push mode)</td>
</tr>
<tr>
<td>App (Pull)</td>
<td><a href="http://localhost:6060">http://localhost:6060</a></td>
<td>pprof server (Pull mode)</td>
</tr>
</tbody>
</table>
<p>In Grafana, select the <strong>Explore</strong> menu → Pyroscope data source, and you can check the collected profile data as a flame graph. The Push mode app is shown as <code>echo.server</code>, and the Pull mode app as <code>pull.golang.app</code>.</p>
<h1>5. Data Collection</h1>
<h2>5.1 Push Mode: SDK Integration</h2>
<p>Push mode is a method where you add the <strong>Pyroscope Go SDK</strong> to the application and send profile data directly to the Pyroscope server.</p>
<h3>5.1.1 Installing and Basic Setup of the SDK</h3>
<pre><code class="language-bash">> go get github.com/grafana/pyroscope-go
</code></pre>
<p>When you initialize the profiler with <code>pyroscope.Start()</code>, it continuously sends data of the configured profile types to the Pyroscope server while the application is running.</p>
<pre><code class="language-go">package main

import (
	"log"
	"os"
	"runtime"

	"github.com/grafana/pyroscope-go"
)

func main() {
	// mutex/blocking profiles are disabled by default, so enable them explicitly
	runtime.SetMutexProfileFraction(5)
	runtime.SetBlockProfileRate(5)

	profiler, err := pyroscope.Start(pyroscope.Config{
		ApplicationName: "simple.golang.app",       // the name shown in the Pyroscope UI
		ServerAddress:   "http://localhost:4040",    // the Pyroscope server address
		Logger:          pyroscope.StandardLogger,
		Tags:            map[string]string{"hostname": os.Getenv("HOSTNAME")},
		ProfileTypes: []pyroscope.ProfileType{
			pyroscope.ProfileCPU,           // CPU profile
			pyroscope.ProfileAllocObjects,  // memory allocation count
			pyroscope.ProfileAllocSpace,    // memory allocation size
			pyroscope.ProfileInuseObjects,  // number of objects currently in use
			pyroscope.ProfileInuseSpace,    // size of memory currently in use
			pyroscope.ProfileGoroutines,    // goroutines
			pyroscope.ProfileMutexCount,    // mutex contention count
			pyroscope.ProfileMutexDuration, // mutex contention time
			pyroscope.ProfileBlockCount,    // blocking count
			pyroscope.ProfileBlockDuration, // blocking time
		},
	})
	if err != nil {
		log.Fatalf("failed to start pyroscope: %v", err)
	}
	defer profiler.Stop() // send the last profile data on shutdown
}
</code></pre>
<h3>5.1.2 Key Configuration Items</h3>
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Default</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ApplicationName</code></td>
<td>the application name shown in the Pyroscope UI</td>
<td>(required)</td>
</tr>
<tr>
<td><code>ServerAddress</code></td>
<td>the Pyroscope server URL</td>
<td>(required)</td>
</tr>
<tr>
<td><code>Tags</code></td>
<td>metadata tags to add to the profile data</td>
<td><code>nil</code></td>
</tr>
<tr>
<td><code>ProfileTypes</code></td>
<td>the list of profile types to collect</td>
<td>CPU + Alloc + Inuse</td>
</tr>
<tr>
<td><code>Logger</code></td>
<td>the logging interface</td>
<td><code>nil</code></td>
</tr>
<tr>
<td><code>DisableGCRuns</code></td>
<td>disable GC runs (reduces CPU overhead)</td>
<td><code>false</code></td>
</tr>
</tbody>
</table>
<h3>5.1.3 Profiling Labels (TagWrapper)</h3>
<blockquote>
<p><strong>Note</strong>: Profiling Labels can be used <strong>only in Push mode</strong>. In Pull mode, only the default stack traces provided by pprof are collected, so custom label tagging is impossible. This is the biggest functional difference between Push/Pull mode.</p>
</blockquote>
<p>Using Pyroscope's <code>TagWrapper</code>, you can tag a specific code path with a label. Tagged profile data can be filtered by label in the flame graph, so you can answer questions like "which endpoint uses a lot of CPU?"</p>
<pre><code class="language-go">pyroscope.TagWrapper(ctx,
	pyroscope.Labels("workload", "cpu"),
	func(c context.Context) {
		cpuWork() // tag the profile data of this block with the workload=cpu label
	})
</code></pre>
<h3>5.1.4 Per-Endpoint Profiling</h3>
<p>In an Echo HTTP server, if you wrap each handler with <code>TagWrapper</code>, you can analyze the performance of each endpoint individually.</p>
<pre><code class="language-go">func handleSlow(c echo.Context) error {
	start := time.Now()

	pyroscope.TagWrapper(c.Request().Context(),
		pyroscope.Labels("endpoint", "/slow"),
		func(ctx context.Context) {
			fibonacci(38) // CPU-intensive computation
		})

	return c.JSON(http.StatusOK, response{
		Message: "slow response (CPU intensive)",
		Elapsed: time.Since(start).String(),
	})
}

func handleMemory(c echo.Context) error {
	start := time.Now()

	pyroscope.TagWrapper(c.Request().Context(),
		pyroscope.Labels("endpoint", "/memory"),
		func(ctx context.Context) {
			allocateMemory() // large memory allocation
		})

	return c.JSON(http.StatusOK, response{
		Message: "memory response (heap allocation)",
		Elapsed: time.Since(start).String(),
	})
}
</code></pre>
<p>When you query the Pyroscope data source in Grafana, you can filter the profiles of the <code>/slow</code> and <code>/memory</code> requests respectively by the <code>endpoint</code> label.</p>
<p>Below is the CPU profile flame graph of Push mode (echo.server). You can see at a glance that <code>main.fibonacci</code> takes up most of the CPU time.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-cpu-flamegraph.png" alt="Push mode CPU flame graph"></p>
<p>In the memory profile, you can check the memory allocation pattern of <code>main.allocateMemory</code>.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-memory-flamegraph.png" alt="Push mode memory flame graph"></p>
<h2>5.2 Pull Mode: Alloy Integration</h2>
<p>Pull mode is a method where, without changing the application code, <strong>Grafana Alloy</strong> periodically scrapes the existing <code>net/http/pprof</code> endpoint. It's the same concept as Prometheus's Pull method.</p>
<h3>5.2.1 Application-Side Setup</h3>
<p>In Pull mode, the application only needs to expose the pprof endpoint. There's no need to add the Pyroscope SDK.</p>
<pre><code class="language-go">import (
	"net/http"
	_ "net/http/pprof" // automatically registers the /debug/pprof/* endpoints
)

func main() {
	http.ListenAndServe(":6060", nil)
}
</code></pre>
<h3>5.2.2 Grafana Alloy Configuration</h3>
<p>Alloy is a telemetry collector made by Grafana, and it handles Pyroscope's Pull mode collection. Define the scrape targets in the <code>config.alloy</code> file.</p>
<pre><code class="language-hcl">// config.alloy
pyroscope.scrape "default" {
  targets = [
    {"__address__" = "app-pull:6060", "service_name" = "pull.golang.app"},
  ]

  scrape_interval = "15s"  // scrape every 15 seconds

  profiling_config {
    profile.process_cpu { enabled = true }           // CPU profile
    profile.memory {                                  // memory profile
      enabled = true
      path    = "/debug/pprof/allocs"
    }
    profile.goroutine { enabled = true }              // goroutine profile
    profile.mutex { enabled = true }                  // mutex profile
    profile.block { enabled = true }                  // blocking profile
  }

  forward_to = [pyroscope.write.endpoint.receiver]    // target to send the collected data
}

pyroscope.write "endpoint" {
  endpoint {
    url = "http://pyroscope:4040"                     // the Pyroscope server address
  }
}
</code></pre>
<p>Since Alloy scrapes the pprof endpoint every 15 seconds, if you generate load and wait a moment, you can query the profile data as the <code>pull.golang.app</code> application in Grafana.</p>
<p>Below is the CPU profile of Pull mode (pull.golang.app). Just like Push mode, <code>main.fibonacci</code> is shown as the CPU bottleneck, but <code>TagWrapper</code>-based label filtering cannot be used.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-pull-flamegraph.png" alt="Pull mode CPU flame graph"></p>
<h2>5.3 Load Testing</h2>
<p>Both Push/Pull modes can generate load with the same endpoints.</p>
<pre><code class="language-bash"># --- Push mode (http://localhost:8080) ---
> curl http://localhost:8080/fast       # fast response (baseline)
> curl http://localhost:8080/slow       # CPU load
> curl http://localhost:8080/memory     # memory load

# --- Pull mode (http://localhost:6060) ---
> curl http://localhost:6060/fast       # fast response
> curl http://localhost:6060/slow       # CPU load
> curl http://localhost:6060/memory     # memory load

# Directly check the Pull mode pprof endpoint
> curl http://localhost:6060/debug/pprof/
</code></pre>
<h1>6. Grafana Profiles Drilldown</h1>
<p><strong>Regardless of which collection method you use</strong>, the profile data stored on the Pyroscope server can be analyzed the same way. After generating load, you can check the collected profile data in Grafana's <strong>Drilldown > Profiles</strong> menu. Profiles Drilldown lets you progressively narrow the analysis scope in the order of service list → profile type → flame graph → labels.</p>
<h2>6.1 All Services (Service List)</h2>
<p>The first screen shows the profile data of all services registered in Pyroscope in a grid view.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-profiles-drilldown.png" alt="Grafana Profiles Drilldown - All Services"></p>
<table>
<thead>
<tr>
<th>Service Name</th>
<th>Description</th>
<th>Collection Method</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>echo.server</strong></td>
<td>Echo HTTP server (per-endpoint profiling)</td>
<td>Push (SDK)</td>
</tr>
<tr>
<td><strong>pull.golang.app</strong></td>
<td>a server exposing pprof endpoints</td>
<td>Pull (Alloy)</td>
</tr>
<tr>
<td><strong>pyroscope</strong></td>
<td>the profile of the Pyroscope server itself</td>
<td>Push (self-collection)</td>
</tr>
<tr>
<td><strong>simple.golang.app</strong></td>
<td>basic SDK integration example</td>
<td>Push (SDK)</td>
</tr>
</tbody>
</table>
<p>In the <strong>Profile type</strong> dropdown at the top, you can switch profile types such as <code>process_cpu/cpu</code> and <code>memory</code>, and you can also filter by searching for a service name.</p>
<h2>6.2 Profile Types (Status by Profile Type)</h2>
<p>When you select a service, you can see at a glance all the profile types being collected from that service. Below is the Profile Types screen of <code>echo.server</code>.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-drilldown-profile-types.png" alt="Profile Types - echo.server"></p>
<p>Time-series graphs of each profile type — CPU, memory, goroutine, mutex, block, etc. — are displayed, so you can quickly grasp which resource has an anomaly. Clicking the <strong>Flame graph</strong> link on each card takes you to the detailed flame graph of that profile type.</p>
<h2>6.3 Flame Graph (Detailed Analysis)</h2>
<p>When you select a specific profile type, a flame graph is displayed along with a symbol table. In the symbol table, you can sort the Self time and Total time of each function to quickly identify the performance bottleneck function.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-drilldown-flamegraph.png" alt="Flame Graph - echo.server CPU"></p>
<p>A flame graph is a graph that visualizes profiling data based on stack traces.</p>
<ul>
<li><strong>Horizontal axis</strong>: the proportion of total time taken by that function (the wider, the more resources used)</li>
<li><strong>Vertical axis</strong>: the function call hierarchy (calls get deeper from top to bottom)</li>
<li><strong>Root node</strong>: 100% of the total application time</li>
</ul>
<pre><code>[              root (100%)                ]
[     funcA (60%)      ][   funcB (40%)   ]
[  funcC (30%) ][ funcD (30%) ]
</code></pre>
<p>The points to note when analyzing a flame graph are as follows.</p>
<ul>
<li><strong>Wide block</strong> = a performance bottleneck candidate (much time is spent in that function)</li>
<li><strong>Deep stack</strong> = the call chain is deep (it doesn't necessarily mean a problem)</li>
<li><strong>Self time vs Total time</strong>: its own execution time vs the total time including subordinate functions</li>
</ul>
<p>The main analysis features are as follows.</p>
<ul>
<li><strong>Time range selection</strong>: analyze only the profile of a specific time interval</li>
<li><strong>Function click</strong>: filter centered on that function for detailed inspection</li>
<li><strong>Labels filtering</strong>: analyze only a specific code path with <code>endpoint=/slow</code>, etc. (when label tagging was done in Push mode)</li>
</ul>
<h2>6.4 Labels (Classification by Label)</h2>
<p>In the <strong>Labels</strong> tab, you can view profile data grouped by label. You can separate and compare time series by labels tagged with <code>TagWrapper</code> in Push mode (e.g. <code>hostname</code>, <code>pyroscope_spy</code>).</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-drilldown-labels.png" alt="Labels - echo.server"></p>
<h2>6.5 Diff Flame Graph (Comparative Analysis)</h2>
<p>In the <strong>Diff flame graph</strong> tab, you can compare the profiles of two time intervals side by side. When you select the Baseline and Comparison intervals respectively, it visualizes the performance difference before and after the change with colors (red=increase, green=decrease).</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/grafana-pyroscope로-go-애플리케이션-continuous-profiling-시작하기/pyroscope-drilldown-diff-flamegraph.png" alt="Diff Flame Graph"></p>
<h1>7. Practical Tips</h1>
<h2>7.1 Precautions When Applying in Production</h2>
<ul>
<li><strong>Overhead management</strong>: The CPU overhead of the Pyroscope SDK is about 2-5%. You can reduce GC-related overhead with the <code>DisableGCRuns: true</code> option</li>
<li><strong>Choosing profile types</strong>: Enabling all profiles increases overhead, so it's recommended to enable only CPU and memory profiles by default and add Mutex/Block when needed</li>
<li><strong><code>SetMutexProfileFraction</code> and <code>SetBlockProfileRate</code> values</strong>: The smaller the value, the more events are recorded. In production, control overhead with a value of <code>5</code> or higher</li>
</ul>
<h2>7.2 Coexistence with Existing pprof Code</h2>
<p>The Pyroscope Go SDK internally uses <code>runtime/pprof</code>. If you're already using <code>net/http/pprof</code>, you can use it together with the Pyroscope SDK.</p>
<pre><code class="language-go">import _ "net/http/pprof" // keep the existing pprof HTTP endpoints

// Add the Pyroscope SDK - also send the same profile data to the Pyroscope server
profiler, _ := pyroscope.Start(pyroscope.Config{...})
defer profiler.Stop()
</code></pre>
<p>A hybrid configuration is possible where you keep the existing pprof endpoints for ad-hoc debugging while collecting always-on profiling data with Pyroscope.</p>
<h2>7.3 Push/Pull Mode Migration</h2>
<p>You can add Push mode to a service already operating in Pull mode, or vice versa.</p>
<ul>
<li><strong>Pull → Push transition</strong>: Add the SDK and remove that target from the Alloy configuration. Transition when you need fine-grained label tagging with <code>TagWrapper</code>.</li>
<li><strong>Push + Pull coexistence</strong>: You can expose pprof endpoints while pushing with the SDK. However, if Alloy scrapes the same service, the <strong>data will be duplicated</strong>, so it's recommended to enable only one collection method.</li>
</ul>
<h1>8. Conclusion</h1>
<p>In this post, we covered Continuous Profiling for Go applications using Grafana Pyroscope.</p>
<ul>
<li><strong>Continuous Profiling</strong> collects profiles continuously in production, solving the "manual collection after a problem occurs" limitation of traditional pprof</li>
<li><strong>Push mode (SDK)</strong> can be integrated with a single line, <code>pyroscope.Start()</code>, and enables fine-grained per-endpoint analysis with <code>TagWrapper</code></li>
<li><strong>Pull mode (Alloy)</strong> leverages existing pprof endpoints without code changes, which is especially advantageous for bulk-collecting multiple services as a DaemonSet in a K8s environment</li>
<li>Through <strong>flame graphs</strong>, you can quickly grasp performance bottlenecks visually, and check the performance difference before and after a change with the comparison/Diff view</li>
</ul>
<p>The full code can be found on <a href="https://github.com/kenshin579/tutorials-go/tree/master/golang/profiling/pyroscope">GitHub</a>.</p>
<h1>9. References</h1>
<ul>
<li><a href="https://grafana.com/docs/pyroscope/latest/">Grafana Pyroscope official documentation</a></li>
<li><a href="https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/go_push/">Pyroscope Go SDK (Push mode)</a></li>
<li><a href="https://grafana.com/docs/pyroscope/latest/configure-client/grafana-alloy/">Grafana Alloy (Pull mode)</a></li>
<li><a href="https://github.com/grafana/pyroscope">Pyroscope GitHub repository</a></li>
<li><a href="https://github.com/grafana/pyroscope-go">pyroscope-go Go client</a></li>
<li><a href="https://grafana.com/docs/pyroscope/latest/introduction/continuous-profiling/">What is Continuous Profiling?</a></li>
<li><a href="https://grafana.com/docs/pyroscope/latest/introduction/flamegraphs/">Flame Graph guide</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>profiling</category>
      <category>pyroscope</category>
      <category>grafana</category>
      <category>continuous-profiling</category>
      <category>flame-graph</category>
      <category>observability</category>
      <category>performance</category>
      <category>pprof</category>
      <category>고랭</category>
      <category>프로파일링</category>
      <category>Series: Grafana Complete Guide</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>What's New in Go 1.26</title>
      <link>https://blog.advenoh.pe.kr/en/go-1-26-변경사항-whats-new-in-go-1-26</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/go-1-26-변경사항-whats-new-in-go-1-26</guid>
      <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
      <description>A roundup of the major changes in Go 1.26. We walk through language, performance, and security improvements — new(expr) initialization, recursive generic constraints, errors.AsType, Green Tea GC enabled by default, 30% faster cgo, reflect iterators, crypto/hpke, SIMD, goroutine leak profile — with sample code.</description>
      <content:encoded><![CDATA[<p>Go 1.26 was released in February 2026. The core themes of this version are <strong>improved language expressiveness</strong>, <strong>major performance gains</strong>, and <strong>stronger security</strong>. There are many changes you can apply to real-world work right away, such as <code>new(expr)</code> initialization, recursive generic constraints, Green Tea GC enabled by default, and a 30% cgo performance improvement.</p>
<h1>1.Language Changes</h1>
<h2>1.1 new() Function Extension - Specifying an Initial Value</h2>
<p>This is the most noticeable language change in Go 1.26. The previous <code>new(T)</code> could only create a pointer to a zero value, but now the <code>new(expr)</code> form lets you <strong>create a pointer with an initial value in a single line</strong>.</p>
<pre><code class="language-go">func TestNewWithExpression(t *testing.T) {
	// Old way: declare a variable first, then take its address
	x := 42
	pOld := &#x26;x
	fmt.Println("old way:", *pOld) // 42

	// New way: create a pointer directly with new(expr)
	pNew := new(42)
	fmt.Println("new way:", *pNew) // 42
}
</code></pre>
<p>You can also create pointers to slices and maps directly.</p>
<pre><code class="language-go">func TestNewWithSliceExpression(t *testing.T) {
	// Create a slice pointer
	ps := new([]int{11, 12, 13})
	fmt.Println(*ps) // [11 12 13]
}

func TestNewWithMapExpression(t *testing.T) {
	// Create a map pointer
	pm := new(map[string]int{"a": 1, "b": 2})
	fmt.Println(*pm) // map[a:1 b:2]
}
</code></pre>
<p>It is especially useful when assigning a pointer to a struct field. Previously you had to declare a separate variable, but now you can handle it in one line.</p>
<pre><code class="language-go">type Person struct {
	Name string
	Age  *int
}

func yearsSince(t time.Time) int {
	return int(time.Since(t).Hours() / 8766)
}

func TestNewInStructField(t *testing.T) {
	born := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)

	// Old way: a separate variable is needed
	age := yearsSince(born)
	p1 := Person{Name: "Alice", Age: &#x26;age}

	// New way: assign directly with new(expr)
	p2 := Person{Name: "Alice", Age: new(yearsSince(born))}

	fmt.Printf("old: %s, age %d\n", p1.Name, *p1.Age)
	fmt.Printf("new: %s, age %d\n", p2.Name, *p2.Age)
}
</code></pre>
<p>It can also be used cleanly in JSON marshaling.</p>
<pre><code class="language-go">func TestNewWithJSON(t *testing.T) {
	born := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)

	data, _ := json.Marshal(Person{
		Name: "Bob",
		Age:  new(yearsSince(born)),
	})
	fmt.Println(string(data)) // {"Name":"Bob","Age":36}
}
</code></pre>
<h2>1.2 Recursive Type Constraints</h2>
<p>A generic type can now reference itself as a type parameter. This makes it possible to implement the <strong>F-bounded polymorphism</strong> pattern.</p>
<h3>1.2.1 What is F-bounded polymorphism?</h3>
<p>F-bounded polymorphism is a <strong>pattern where an interface (type constraint) takes, as a parameter, a type that implements the interface itself</strong>. A normal generic constraint declares "T must have these methods," but an F-bounded constraint declares <strong>"T must have a method that takes its own type as an argument."</strong></p>
<pre><code>Normal constraint:     Comparable       → Equal(other any) bool
F-bounded constraint: Comparable[T]   → Equal(other T) bool   ← T refers to itself
</code></pre>
<p>The key advantage of this pattern is <strong>type safety</strong>. With a normal interface, <code>Equal(other any)</code> has to accept any type, which requires a type assertion at runtime. But with the F-bounded pattern, <code>Equal(other T)</code> <strong>guarantees at compile time that only values of the same type are compared</strong>.</p>
<p>It is a pattern widely used in other languages as well — Java's <code>Comparable&#x3C;T></code>, Rust's <code>trait Add&#x3C;Rhs = Self></code> — and Go supports it starting from Go 1.26.</p>
<pre><code class="language-go">// Ordered - a generic type referencing itself as a type parameter
type Ordered[T Ordered[T]] interface {
	Less(T) bool
}

type MyInt int

func (a MyInt) Less(b MyInt) bool {
	return a &#x3C; b
}

// Min - a minimum function using recursive generics
func Min[T Ordered[T]](a, b T) T {
	if a.Less(b) {
		return a
	}
	return b
}

func TestRecursiveGenericMin(t *testing.T) {
	result := Min(MyInt(3), MyInt(7))
	fmt.Printf("Min(3, 7) = %d\n", result) // 3
}
</code></pre>
<p>It can also be used in practical patterns such as vector arithmetic.</p>
<pre><code class="language-go">// Adder - the F-bounded polymorphism pattern
type Adder[A Adder[A]] interface {
	Add(A) A
}

type Vector2D struct {
	X, Y float64
}

func (v Vector2D) Add(other Vector2D) Vector2D {
	return Vector2D{X: v.X + other.X, Y: v.Y + other.Y}
}

func Sum[A Adder[A]](values []A) A {
	var zero A
	result := zero
	for _, v := range values {
		result = result.Add(v)
	}
	return result
}

func TestAdderInterface(t *testing.T) {
	vectors := []Vector2D{{1, 2}, {3, 4}, {5, 6}}
	result := Sum(vectors)
	fmt.Printf("Sum = %+v\n", result) // {X:9 Y:12}
}
</code></pre>
<h1>2.Performance Improvements</h1>
<h2>2.1 Green Tea Garbage Collector (Enabled by Default)</h2>
<p>The Green Tea GC, introduced experimentally in Go 1.25, is <strong>enabled by default</strong> in Go 1.26. It improves the marking/scanning performance of small objects, <strong>reducing GC overhead by 10–40%</strong>.</p>
<p><strong>Key characteristics:</strong></p>
<ul>
<li>Significantly improved memory access efficiency</li>
<li>An additional 10% improvement on recent CPUs (Intel Ice Lake, AMD Zen 4 and later)</li>
<li>The disable option is scheduled to be removed in Go 1.27</li>
</ul>
<pre><code class="language-bash"># Disable (scheduled for removal in Go 1.27)
GOEXPERIMENT=nogreenteagc go build
</code></pre>
<h2>2.2 30% Faster cgo Calls</h2>
<p>By simplifying processor state management, the baseline runtime overhead of cgo calls has been reduced by about <strong>30%</strong>.</p>
<pre><code># Benchmark comparison
Go 1.25: 28.55ns/op
Go 1.26: 19.02ns/op  (-33%)
</code></pre>
<h2>2.3 io.ReadAll Is 2x Faster</h2>
<p>The performance of <code>io.ReadAll</code> has improved roughly 2x and <strong>memory usage has dropped by 50%</strong>.</p>
<pre><code class="language-go">func BenchmarkReadAll_1MB(b *testing.B) {
	data := bytes.Repeat([]byte("x"), 1024*1024)
	for b.Loop() {
		io.ReadAll(bytes.NewReader(data))
	}
}
</code></pre>
<h2>2.4 fmt.Errorf Is 92% Faster</h2>
<p>A <code>fmt.Errorf</code> call with no format arguments has been optimized to <strong>0 allocations</strong>, making it 92% faster than before.</p>
<pre><code class="language-go">func BenchmarkErrorf_NoFormat(b *testing.B) {
	// Optimized to 0 allocations in Go 1.26 (previously 2 allocations)
	for b.Loop() {
		_ = fmt.Errorf("simple error message")
	}
}
</code></pre>
<h2>2.5 Optimized Memory Allocation for Small Objects</h2>
<p>Allocation speed for small objects of 1–512 bytes has improved by up to <strong>30%</strong>. Stack-based slice backing-store allocation has also been extended to more situations.</p>
<h1>3.Major Standard Library Changes</h1>
<h2>3.1 errors.AsType - Type-Safe Error Inspection</h2>
<p>The existing <code>errors.As</code> required you to declare the target variable in advance, but the new <code>errors.AsType[T]</code> provides <strong>generics-based, type-safe error inspection</strong>.</p>
<pre><code class="language-go">type AppError struct {
	Code    int
	Message string
}

func (e *AppError) Error() string {
	return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

func TestErrorsAs_OldWay(t *testing.T) {
	err := fmt.Errorf("wrapped: %w", &#x26;AppError{Code: 404, Message: "not found"})

	// Old way: the target variable must be declared in advance
	var target *AppError
	if errors.As(err, &#x26;target) {
		fmt.Printf("Code: %d, Message: %s\n", target.Code, target.Message)
	}
}

func TestErrorsAsType_NewWay(t *testing.T) {
	err := fmt.Errorf("wrapped: %w", &#x26;AppError{Code: 404, Message: "not found"})

	// New way: errors.AsType[T]() - type-safe, no variable declaration needed
	if target, ok := errors.AsType[*AppError](err); ok {
		fmt.Printf("Code: %d, Message: %s\n", target.Code, target.Message)
	}
}
</code></pre>
<p>It also works on nested error chains.</p>
<pre><code class="language-go">func TestErrorsAsType_ChainedErrors(t *testing.T) {
	innerErr := &#x26;ValidationError{Field: "email", Message: "invalid format"}
	wrappedErr := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", innerErr))

	// Type lookup even through a nested error chain
	if target, ok := errors.AsType[*ValidationError](wrappedErr); ok {
		fmt.Printf("Field: %s, Message: %s\n", target.Field, target.Message)
	}
}
</code></pre>
<h2>3.2 reflect Iterators Added</h2>
<p><code>Fields()</code> and <code>Methods()</code> iterators have been added to <code>reflect.Type</code>, so you can iterate over fields and methods with a range loop.</p>
<pre><code class="language-go">func TestReflectFields(t *testing.T) {
	type User struct {
		Name  string
		Email string
		Age   int
	}

	typ := reflect.TypeFor[User]()

	// Old way: iterate by index
	for i := 0; i &#x3C; typ.NumField(); i++ {
		f := typ.Field(i)
		fmt.Printf("%s: %s\n", f.Name, f.Type)
	}

	// New way: range iterator
	for f := range typ.Fields() {
		fmt.Printf("%s: %s\n", f.Name, f.Type)
	}
}

func TestReflectMethods(t *testing.T) {
	// Look up methods on a pointer type
	typ := reflect.TypeFor[*http.Client]()

	for m := range typ.Methods() {
		fmt.Printf("%s\n", m.Name)
	}
	// CloseIdleConnections, Do, Get, Head, Post, PostForm
}
</code></pre>
<h2>3.3 bytes.Buffer.Peek() Method</h2>
<p>A <code>Peek</code> method has been added that lets you inspect the next bytes without advancing the buffer. Unlike <code>Read</code>, it does not change the buffer position.</p>
<pre><code class="language-go">func TestBufferPeek(t *testing.T) {
	buf := bytes.NewBufferString("hello world")

	// Peek: inspect the next n bytes without advancing the buffer
	peeked, _ := buf.Peek(5)
	fmt.Printf("Peek(5): %q\n", peeked)   // "hello"
	fmt.Printf("Len: %d\n", buf.Len())     // 11 (unchanged)

	// Read: advances the buffer
	readBuf := make([]byte, 5)
	buf.Read(readBuf)
	fmt.Printf("Len after Read: %d\n", buf.Len()) // 6
}
</code></pre>
<h2>3.4 slog.NewMultiHandler()</h2>
<p><code>NewMultiHandler</code>, which can write to multiple log handlers at once, has been added. It is useful when you want to use console and file, or text and JSON formats, simultaneously.</p>
<pre><code class="language-go">func TestSlogNewMultiHandler(t *testing.T) {
	var textBuf, jsonBuf bytes.Buffer

	textHandler := slog.NewTextHandler(&#x26;textBuf, nil)
	jsonHandler := slog.NewJSONHandler(&#x26;jsonBuf, nil)

	// NewMultiHandler: write to multiple handlers at once
	multi := slog.NewMultiHandler(textHandler, jsonHandler)
	logger := slog.New(multi)

	logger.Info("user login", "user", "alice", "ip", "192.168.1.1")

	fmt.Println("Text:", textBuf.String())
	// time=... level=INFO msg="user login" user=alice ip=192.168.1.1
	fmt.Println("JSON:", jsonBuf.String())
	// {"time":"...","level":"INFO","msg":"user login","user":"alice","ip":"192.168.1.1"}
}
</code></pre>
<p>You can also set a different level for each handler.</p>
<pre><code class="language-go">func TestSlogMultiHandlerWithLevels(t *testing.T) {
	var infoBuf, errorBuf bytes.Buffer

	infoHandler := slog.NewTextHandler(&#x26;infoBuf, &#x26;slog.HandlerOptions{Level: slog.LevelInfo})
	errorHandler := slog.NewTextHandler(&#x26;errorBuf, &#x26;slog.HandlerOptions{Level: slog.LevelError})

	multi := slog.NewMultiHandler(infoHandler, errorHandler)
	logger := slog.New(multi)

	logger.Info("info message")   // written only to infoBuf
	logger.Error("error message") // written to both handlers
}
</code></pre>
<h2>3.5 testing.ArtifactDir() - Storing Test Artifacts</h2>
<p>A <code>t.ArtifactDir()</code> method has been added that returns a directory for storing outputs generated during a test run (logs, screenshots, dumps, etc.).</p>
<pre><code class="language-go">func TestArtifactDir(t *testing.T) {
	dir := t.ArtifactDir()
	fmt.Printf("Artifact directory: %s\n", dir)

	// Save a test result file
	content := []byte("test output data")
	os.WriteFile(filepath.Join(dir, "output.txt"), content, 0644)
}
</code></pre>
<p>In CI environments, you can preserve artifacts on test failure and use them for debugging.</p>
<h2>3.6 signal.NotifyContext and Context Cause</h2>
<p>When a context created with <code>signal.NotifyContext</code> is canceled, you can check which signal was received via <code>context.Cause</code>.</p>
<pre><code class="language-go">func TestSignalNotifyContext(t *testing.T) {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	stop() // simulate cancellation

	select {
	case &#x3C;-ctx.Done():
		cause := context.Cause(ctx)
		fmt.Printf("Cause: %v\n", cause) // context canceled
	}
}

func TestContextCauseBasic(t *testing.T) {
	ctx, cancel := context.WithCancelCause(context.Background())

	// Cancel with a specific cause
	cancel(fmt.Errorf("user requested cancellation"))

	cause := context.Cause(ctx)
	fmt.Printf("Context cause: %v\n", cause)
	// user requested cancellation
}
</code></pre>
<h2>3.7 netip.Prefix.Compare - Sorting Subnets</h2>
<p>A <code>Compare</code> method has been added to <code>netip.Prefix</code>, so you can sort CIDR-notation subnets with <code>slices.SortFunc</code>.</p>
<pre><code class="language-go">func TestNetipPrefixCompare(t *testing.T) {
	prefixes := []netip.Prefix{
		netip.MustParsePrefix("192.168.1.0/24"),
		netip.MustParsePrefix("10.0.0.0/8"),
		netip.MustParsePrefix("172.16.0.0/12"),
		netip.MustParsePrefix("10.0.1.0/24"),
		netip.MustParsePrefix("192.168.0.0/16"),
	}

	// Go 1.26: sort with netip.Prefix.Compare
	slices.SortFunc(prefixes, netip.Prefix.Compare)

	for _, p := range prefixes {
		fmt.Println(p)
	}
	// 10.0.0.0/8
	// 10.0.1.0/24
	// 172.16.0.0/12
	// 192.168.0.0/16
	// 192.168.1.0/24
}
</code></pre>
<p>You can also sort a mix of IPv4 and IPv6, with IPv4 sorted before IPv6.</p>
<h1>4.Security and Cryptography</h1>
<h2>4.1 crypto/hpke Package (New)</h2>
<p>An implementation of the RFC 9180 Hybrid Public Key Encryption (HPKE) standard has been added as a new package. It also supports post-quantum hybrid KEM.</p>
<h2>4.2 Cryptographic API Improvements - io.Reader Removed</h2>
<p>In functions of <code>crypto/dsa</code>, <code>crypto/ecdh</code>, <code>crypto/ecdsa</code>, <code>crypto/rsa</code>, <code>crypto/rand</code>, and others, the <code>random</code> parameter is now ignored and a secure random source is automatically used internally.</p>
<pre><code class="language-go">// Old way
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

// New way: you can pass nil (a secure source is used automatically internally)
key, _ := ecdsa.GenerateKey(elliptic.P256(), nil)
</code></pre>
<h2>4.3 Heap Base Address Randomization</h2>
<p>On 64-bit platforms, the heap base address is now randomized, making memory address prediction harder. This increases defense against attacks when using cgo.</p>
<pre><code class="language-bash"># Disable
GOEXPERIMENT=norandomizedheapbase64
</code></pre>
<h2>4.4 Stronger TLS Security</h2>
<ul>
<li><code>SecP256r1MLKEM768</code> and <code>SecP384r1MLKEM1024</code> key exchanges are enabled by default</li>
<li>Post-quantum hybrid TLS support</li>
</ul>
<h1>5.Tooling Improvements</h1>
<h2>5.1 go fix Rewritten</h2>
<p>The <code>go fix</code> command has been completely rewritten on top of the Go analysis framework. It includes dozens of <strong>modernizers</strong> that automatically update code to take advantage of the latest language features.</p>
<pre><code class="language-bash"># Update current code to the latest patterns
go fix ./...
</code></pre>
<p><strong><code>//go:fix inline</code> directive:</strong>
A function call to a function annotated with the <code>//go:fix inline</code> directive is automatically inlined. This is useful when library authors want to automate API migrations.</p>
<pre><code class="language-go">// Add an inline directive to a deprecated function
//go:fix inline
func OldFunc() string {
	return NewFunc()
}

// When go fix runs, calls to OldFunc() are automatically converted to NewFunc()
</code></pre>
<h2>5.2 go mod init Default Version Changed</h2>
<p>When you run <code>go mod init</code>, the Go version is now set with compatibility against currently supported versions in mind. When run with Go 1.26, it is set to <code>go 1.24.0</code>.</p>
<h1>6.Experimental Features</h1>
<h2>6.1 SIMD Operations (simd/archsimd)</h2>
<p>A <code>simd/archsimd</code> package that supports architecture-specific vectorized operations has been added experimentally. On amd64, you can use 128/256/512-bit vector operations.</p>
<pre><code class="language-bash"># Enable
GOEXPERIMENT=simd go build
</code></pre>
<pre><code class="language-go">// SIMD vector operation example (requires GOEXPERIMENT=simd)
va := archsimd.LoadFloat32x16Slice(a[i : i+16])
vb := archsimd.LoadFloat32x16Slice(b[i : i+16])
vSum := va.Add(vb)
vSum.StoreSlice(res[i : i+16])
</code></pre>
<h2>6.2 Goroutine Leak Profile (goroutineleak)</h2>
<p>A new profile that detects leaked goroutines has been added experimentally. It is scheduled to be enabled by default in Go 1.27.</p>
<pre><code class="language-bash"># Enable
GOEXPERIMENT=goroutineleakprofile go build

# Access the endpoint
curl http://localhost:6060/debug/pprof/goroutineleak
</code></pre>
<p>Leak example: if an error occurs on an unbuffered channel and the function returns early, goroutines block forever.</p>
<pre><code class="language-go">func processWorkItems(ws []workItem) ([]workResult, error) {
	ch := make(chan result) // unbuffered channel
	for _, w := range ws {
		go func() {
			res, err := processWorkItem(w)
			ch &#x3C;- result{res, err} // blocks → leak occurs
		}()
	}

	for range len(ws) {
		r := &#x3C;-ch
		if r.err != nil {
			return nil, r.err // early return → remaining goroutines leak
		}
	}
	return results, nil
}
</code></pre>
<h2>6.3 runtime/secret - Safely Erasing Sensitive Data</h2>
<p>For the forward secrecy of cryptographic protocols, a <code>runtime/secret</code> package that safely erases sensitive data from memory has been added experimentally.</p>
<pre><code class="language-bash"># Enable
GOEXPERIMENT=runtimesecret go build
</code></pre>
<pre><code class="language-go">// Example of automatically erasing sensitive data
secret.Do(func() {
	privKey, _ := ecdh.P256().GenerateKey(rand.Reader)
	sharedSecret, _ := privKey.ECDH(peerPublicKey)
	// ... session key negotiation
})
// The key is automatically deleted from memory when the function returns
</code></pre>
<h1>7.Other Changes</h1>
<h2>7.1 Platform Changes</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>macOS</td>
<td>Go 1.26 is the last version to support Monterey (Go 1.27 will require Ventura or later)</td>
</tr>
<tr>
<td>Windows</td>
<td><code>windows/arm</code> (32-bit) removed</td>
</tr>
<tr>
<td>RISC-V</td>
<td><code>linux/riscv64</code> race detector support</td>
</tr>
<tr>
<td>S390X</td>
<td>Register-based passing of function arguments/return values</td>
</tr>
<tr>
<td>WebAssembly</td>
<td>Sign extension and floating-point conversion standardized</td>
</tr>
</tbody>
</table>
<h2>7.2 Other Standard Library Changes</h2>
<table>
<thead>
<tr>
<th>Package</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>net</code></td>
<td><code>DialTCP</code>, <code>DialUDP</code>, <code>DialIP</code>, <code>DialUnix</code> methods added to <code>Dialer</code></td>
</tr>
<tr>
<td><code>net/http</code></td>
<td>Finer-grained HTTP/2 configuration, <code>NewClientConn()</code> added</td>
</tr>
<tr>
<td><code>crypto/x509</code></td>
<td><code>String()</code>, <code>OID()</code> methods on <code>ExtKeyUsage</code>, <code>KeyUsage</code></td>
</tr>
<tr>
<td><code>runtime/metrics</code></td>
<td>Per-goroutine-state metrics added (<code>/sched/goroutines/running</code>, etc.)</td>
</tr>
<tr>
<td><code>os</code></td>
<td><code>Process.WithHandle()</code> - access to a process handle (Linux 5.4+, Windows)</td>
</tr>
</tbody>
</table>
<h1>8.Summary</h1>
<p>Go 1.26 is a release with meaningful improvements across language expressiveness, performance, and security.</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>Change</th>
<th>Impact</th>
</tr>
</thead>
<tbody>
<tr>
<td>Language</td>
<td><code>new(expr)</code> initialization</td>
<td>★★★</td>
</tr>
<tr>
<td>Language</td>
<td>Recursive generics</td>
<td>★★☆</td>
</tr>
<tr>
<td>Performance</td>
<td>Green Tea GC enabled by default</td>
<td>★★★</td>
</tr>
<tr>
<td>Performance</td>
<td>30% faster cgo</td>
<td>★★★</td>
</tr>
<tr>
<td>Performance</td>
<td><code>io.ReadAll</code> 2x faster</td>
<td>★★☆</td>
</tr>
<tr>
<td>Performance</td>
<td><code>fmt.Errorf</code> 92% faster</td>
<td>★★☆</td>
</tr>
<tr>
<td>Standard library</td>
<td><code>errors.AsType[T]()</code></td>
<td>★★★</td>
</tr>
<tr>
<td>Standard library</td>
<td>reflect iterators</td>
<td>★★☆</td>
</tr>
<tr>
<td>Standard library</td>
<td><code>bytes.Buffer.Peek()</code></td>
<td>★★☆</td>
</tr>
<tr>
<td>Standard library</td>
<td><code>slog.NewMultiHandler()</code></td>
<td>★★☆</td>
</tr>
<tr>
<td>Security</td>
<td><code>crypto/hpke</code> new package</td>
<td>★★☆</td>
</tr>
<tr>
<td>Security</td>
<td>Heap address randomization</td>
<td>★★☆</td>
</tr>
<tr>
<td>Tooling</td>
<td><code>go fix</code> rewrite + modernizers</td>
<td>★★★</td>
</tr>
<tr>
<td>Experimental</td>
<td>SIMD operations</td>
<td>★★☆</td>
</tr>
<tr>
<td>Experimental</td>
<td>Goroutine leak profile</td>
<td>★★★</td>
</tr>
</tbody>
</table>
<p>In particular, <code>new(expr)</code> and <code>errors.AsType</code> are features you can use immediately in everyday Go development, so adopt them actively. With Green Tea GC becoming the default, GC performance improves significantly without any extra configuration. The goroutine leak profile is experimental, but it is extremely useful for production debugging, so it is recommended to validate it in advance in a test environment.</p>
<h2>Go 1.27 Preview</h2>
<p>GODEBUG options scheduled for removal in Go 1.27:</p>
<ul>
<li><code>tlsunsafeekm</code>, <code>tlsrsakex</code>, <code>tls10server</code>, <code>tls3des</code></li>
<li><code>x509keypairleaf</code>, <code>gotypesalias</code>, <code>asynctimerchan</code></li>
<li><code>GOEXPERIMENT=nogreenteagc</code> (disable option removed)</li>
</ul>
<h1>9.References</h1>
<ul>
<li><a href="https://go.dev/doc/go1.26">Go 1.26 Release Notes</a></li>
<li><a href="https://go.dev/blog/go1.26">Go 1.26 is released</a></li>
<li><a href="https://antonz.org/go-1-26/">Go 1.26 변경사항 상세</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>go1.26</category>
      <category>new</category>
      <category>green-tea-gc</category>
      <category>cgo</category>
      <category>simd</category>
      <category>hpke</category>
      <category>generics</category>
      <category>errors</category>
      <category>reflect</category>
      <category>고랭</category>
      <category>새기능</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Managing Multiple Applications with GitOps in ArgoCD: App of Apps vs ApplicationSet</title>
      <link>https://blog.advenoh.pe.kr/en/argocd-여러-application-gitops-관리</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/argocd-여러-application-gitops-관리</guid>
      <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
      <description>Compare ArgoCD's App of Apps and ApplicationSet patterns and learn how to deploy multiple applications efficiently the GitOps way</description>
      <content:encoded><![CDATA[<h2>1. Overview</h2>
<p><img src="https://blog.advenoh.pe.kr/images/cloud/argocd-여러-application-gitops-관리/cover.png" alt="cover"></p>
<p>When managing multiple applications with <code>Helm Chart</code> in a <code>GitOps</code> environment, manually creating an <code>ArgoCD</code> Application for each application is tedious and error-prone.</p>
<p>For example, suppose you have the following <code>Helm Chart</code> structure.</p>
<pre><code>chart/
├── echo-server/
├── hello-world-server/
└── hello-world-server-hook/
</code></pre>
<p>How should you deploy these three applications with <code>ArgoCD</code>?</p>
<h3>Deployment Patterns Provided by ArgoCD</h3>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Description</th>
<th>Automation Level</th>
<th>Flexibility</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Single Application</strong></td>
<td>Manually create an Application for each app</td>
<td>Low</td>
<td>High</td>
</tr>
<tr>
<td><strong>App of Apps</strong></td>
<td>A parent Application manages child Applications</td>
<td>Medium</td>
<td>Medium</td>
</tr>
<tr>
<td><strong>ApplicationSet</strong></td>
<td>Dynamically generate Applications via a <code>Generator</code></td>
<td>High</td>
<td>Very High</td>
</tr>
</tbody>
</table>
<p>In this article, we compare the <strong>App of Apps</strong> pattern and the <strong>ApplicationSet</strong> pattern with real examples.</p>
<blockquote>
<p>Full sample code: <a href="https://github.com/kenshin579/argocd-charts-sample">kenshin579/argocd-charts-sample</a></p>
</blockquote>
<hr>
<h2>2. Single Application</h2>
<p>This is the most basic approach: you write a single Application <code>YAML</code> directly and deploy it.</p>
<pre><code class="language-yaml"># bootstrap/single-app/single.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: hello-world-server
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/kenshin579/argocd-charts-sample
    path: chart/hello-world-server
    targetRevision: HEAD
    helm:
      valueFiles:
        - values.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd-test
</code></pre>
<pre><code class="language-bash">kubectl apply -f bootstrap/single-app/single.yaml -n argocd
</code></pre>
<p>This works fine when you only have a few applications, but as the number of managed targets grows, you have to write <code>YAML</code> files repeatedly, which makes maintenance harder.</p>
<hr>
<h2>3. App of Apps Pattern</h2>
<h3>3.1 Concept</h3>
<p>This is a structure where a parent Application references a directory containing the child Application manifests. It can be implemented using only <code>ArgoCD</code>'s built-in features.</p>
<pre><code>bootstrap/app-of-apps/
├── apps.yaml                          # Parent Application
└── applications/
    ├── echo-server.yaml               # Child Application 1
    ├── hello-world-server.yaml        # Child Application 2
    └── hello-world-server-hook.yaml   # Child Application 3
</code></pre>
<h3>3.2 Parent Application</h3>
<p>The parent Application specifies the <code>applications/</code> directory as <code>source.path</code>. <code>ArgoCD</code> automatically detects and creates all Application manifests inside this directory.</p>
<pre><code class="language-yaml"># bootstrap/app-of-apps/apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: app-of-apps
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/kenshin579/argocd-charts-sample
    path: bootstrap/app-of-apps/applications
    targetRevision: HEAD
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
</code></pre>
<h3>3.3 Child Applications</h3>
<p>Each child Application is defined as a separate <code>YAML</code> file that references an individual <code>Helm Chart</code>.</p>
<pre><code class="language-yaml"># bootstrap/app-of-apps/applications/echo-server.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: echo-server
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/kenshin579/argocd-charts-sample
    path: chart/echo-server
    targetRevision: HEAD
    helm:
      valueFiles:
        - values.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd-test
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
</code></pre>
<pre><code class="language-yaml"># bootstrap/app-of-apps/applications/hello-world-server.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: hello-world-server
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/kenshin579/argocd-charts-sample
    path: chart/hello-world-server
    targetRevision: HEAD
    helm:
      valueFiles:
        - values.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd-test
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
</code></pre>
<h3>3.4 Deployment</h3>
<pre><code class="language-bash">kubectl apply -f bootstrap/app-of-apps/apps.yaml -n argocd
</code></pre>
<p>When you deploy the parent Application, <code>ArgoCD</code> reads the <code>YAML</code> files in the <code>applications/</code> directory and automatically creates the child Applications.</p>
<h3>3.5 Pros and Cons</h3>
<p><strong>Pros</strong></p>
<ul>
<li>Simple concept and easy to understand</li>
<li>Each Application can be controlled independently and in fine detail</li>
<li>Easy to debug (you can immediately identify which Application has an issue)</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>Requires writing a <code>YAML</code> file every time you add a new application</li>
<li>The number of files to manage grows as the number of applications increases</li>
<li>Duplicate code is easy to occur</li>
</ul>
<hr>
<h2>4. ApplicationSet Pattern</h2>
<h3>4.1 Concept</h3>
<p>ApplicationSet is an <code>ArgoCD</code> extension feature that dynamically generates Applications using a <code>Generator</code> (ArgoCD 2.0+). Being template-based, it lets you define multiple Applications at once and keep the <code>DRY</code> principle.</p>
<p>The main <code>Generator</code> types are as follows.</p>
<table>
<thead>
<tr>
<th><code>Generator</code></th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>List</code></td>
<td>Explicitly defines a list of applications</td>
</tr>
<tr>
<td><code>Git</code></td>
<td>Automatically detects the directory structure of a <code>Git</code> repository</td>
</tr>
<tr>
<td><code>Matrix</code></td>
<td>Combines multiple <code>Generator</code>s to produce a cartesian product</td>
</tr>
<tr>
<td><code>Cluster</code></td>
<td>Generates based on the list of registered clusters</td>
</tr>
</tbody>
</table>
<h3>4.2 List Generator</h3>
<p>Explicitly enumerate the list of applications to deploy. You can specify a name, path, namespace, and so on for each entry.</p>
<pre><code class="language-yaml"># bootstrap/application-set/appset-list.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: chart-applications-list
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - name: echo-server
            path: chart/echo-server
            namespace: argocd-test
          - name: hello-world-server
            path: chart/hello-world-server
            namespace: argocd-test
          - name: hello-world-server-hook
            path: chart/hello-world-server-hook
            namespace: argocd-test
  template:
    metadata:
      name: '{{name}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/kenshin579/argocd-charts-sample
        targetRevision: HEAD
        path: '{{path}}'
        helm:
          valueFiles:
            - values.yaml
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{namespace}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
</code></pre>
<pre><code class="language-bash">kubectl apply -f bootstrap/application-set/appset-list.yaml -n argocd
</code></pre>
<p>In the <code>template</code> block, the <code>{{name}}</code>, <code>{{path}}</code>, and <code>{{namespace}}</code> variables are substituted with each entry in <code>generators.list.elements</code>, creating the Applications.</p>
<h3>4.3 Git Generator</h3>
<p>This automatically detects the directory structure of a <code>Git</code> repository and creates Applications. When you add a new <code>Chart</code> directory, an Application is automatically created without any separate configuration change.</p>
<pre><code class="language-yaml"># bootstrap/application-set/appset.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: chart-applications
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://github.com/kenshin579/argocd-charts-sample
        revision: HEAD
        directories:
          - path: chart/*
  template:
    metadata:
      name: '{{path.basename}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/kenshin579/argocd-charts-sample
        targetRevision: HEAD
        path: '{{path}}'
        helm:
          valueFiles:
            - values.yaml
      destination:
        server: https://kubernetes.default.svc
        namespace: argocd-test
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
</code></pre>
<pre><code class="language-bash">kubectl apply -f bootstrap/application-set/appset.yaml -n argocd
</code></pre>
<p>It detects all subdirectories under the <code>chart/*</code> path and uses <code>{{path.basename}}</code> (the directory name) as the Application name. Simply adding a <code>chart/new-app/</code> directory automatically creates a <code>new-app</code> Application.</p>
<h3>4.4 Matrix Generator</h3>
<p>This combines multiple <code>Generator</code>s to produce a cartesian product. It is useful for multi-environment (<code>dev</code>/<code>staging</code>/<code>prod</code>) deployments.</p>
<pre><code class="language-yaml"># bootstrap/application-set/appset-matrix.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: chart-applications-matrix
  namespace: argocd
spec:
  generators:
    - matrix:
        generators:
          - git:
              repoURL: https://github.com/kenshin579/argocd-charts-sample
              revision: HEAD
              directories:
                - path: chart/*
          - list:
              elements:
                - env: dev
                  namespace: argocd-dev
                  replicas: "1"
                - env: staging
                  namespace: argocd-staging
                  replicas: "2"
                - env: prod
                  namespace: argocd-prod
                  replicas: "3"
  template:
    metadata:
      name: '{{path.basename}}-{{env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/kenshin579/argocd-charts-sample
        targetRevision: HEAD
        path: '{{path}}'
        helm:
          valueFiles:
            - values.yaml
          parameters:
            - name: replicaCount
              value: '{{replicas}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{namespace}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
</code></pre>
<pre><code class="language-bash">kubectl apply -f bootstrap/application-set/appset-matrix.yaml -n argocd
</code></pre>
<p>3 Charts detected by the <code>Git Generator</code> × 3 environments from the <code>List Generator</code> = a total of 9 Applications are created.</p>
<ul>
<li><code>echo-server-dev</code>, <code>echo-server-staging</code>, <code>echo-server-prod</code></li>
<li><code>hello-world-server-dev</code>, <code>hello-world-server-staging</code>, <code>hello-world-server-prod</code></li>
<li><code>hello-world-server-hook-dev</code>, <code>hello-world-server-hook-staging</code>, <code>hello-world-server-hook-prod</code></li>
</ul>
<p>Since you can set <code>replicaCount</code> differently per environment, you can deploy the same <code>Chart</code> tailored to each environment.</p>
<h3>4.5 Pros and Cons</h3>
<p><strong>Pros</strong></p>
<ul>
<li>Adheres to the <code>DRY</code> principle (minimizes duplicate code)</li>
<li>Easy to add new applications (auto-detection when using <strong>Git Generator</strong>)</li>
<li>Easy multi-environment deployment (<strong>Matrix Generator</strong>)</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>Requires learning <code>Generator</code> and template syntax</li>
<li>Debugging is relatively harder for complex requirements</li>
<li>Because the same configuration applies to all Applications, it is unsuitable when each app needs completely different settings</li>
</ul>
<hr>
<h2>5. App of Apps vs ApplicationSet Comparison</h2>
<table>
<thead>
<tr>
<th>Category</th>
<th>App of Apps</th>
<th>ApplicationSet</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Application Definition</strong></td>
<td>A separate <code>YAML</code> file for each</td>
<td>Template-based dynamic generation</td>
</tr>
<tr>
<td><strong>Adding a New App</strong></td>
<td>Requires writing a new <code>YAML</code> file</td>
<td>Automatic, or added to a list, depending on the <code>Generator</code></td>
</tr>
<tr>
<td><strong>Code Duplication</strong></td>
<td>Easy to occur</td>
<td>Minimized</td>
</tr>
<tr>
<td><strong>Multi-Environment Support</strong></td>
<td>Requires file duplication</td>
<td>Easily implemented with <code>Matrix Generator</code></td>
</tr>
<tr>
<td><strong>Fine-Grained Control</strong></td>
<td>Complete control per Application</td>
<td>Controlled via variables in the template</td>
</tr>
<tr>
<td><strong>Debugging</strong></td>
<td>Easy</td>
<td>Relatively hard</td>
</tr>
<tr>
<td><strong><code>ArgoCD</code> Version</strong></td>
<td>Built-in feature</td>
<td><code>ArgoCD</code> 2.0+</td>
</tr>
</tbody>
</table>
<h3>When to Use Which Pattern</h3>
<p><strong>When App of Apps Is a Good Fit</strong></p>
<ul>
<li>Teams adopting <code>ArgoCD</code> for the first time</li>
<li>When each Application needs completely different settings</li>
<li>When there are few Applications and changes are infrequent</li>
</ul>
<p><strong>When ApplicationSet Is a Good Fit</strong></p>
<ul>
<li>When you need to manage a large number of Applications</li>
<li>When deploying the same app across multiple environments (<code>dev</code>/<code>staging</code>/<code>prod</code>)</li>
<li>When new applications are added frequently</li>
<li>When managing many services in a microservices architecture</li>
</ul>
<hr>
<h2>6. FAQ</h2>
<h3>6.1 How do I delete a specific Application in an ApplicationSet?</h3>
<p>If you directly delete an Application created by an ApplicationSet using the <code>ArgoCD</code> UI or <code>kubectl delete</code>, the <code>ApplicationSet Controller</code> detects this and <strong>recreates it</strong>.</p>
<p>The correct way to delete it is to remove the corresponding entry from the <code>Generator</code>.</p>
<p><strong>Removing from a <code>List Generator</code>:</strong></p>
<pre><code class="language-yaml"># Before
generators:
  - list:
      elements:
        - name: echo-server
          path: chart/echo-server
          namespace: argocd-test
        - name: hello-world-server
          path: chart/hello-world-server
          namespace: argocd-test

# After (echo-server removed)
generators:
  - list:
      elements:
        - name: hello-world-server
          path: chart/hello-world-server
          namespace: argocd-test
</code></pre>
<p><strong>Excluding from a <code>Git Generator</code>:</strong></p>
<pre><code class="language-yaml">generators:
  - git:
      repoURL: https://github.com/kenshin579/argocd-charts-sample
      revision: HEAD
      directories:
        - path: chart/*
        - path: chart/echo-server
          exclude: true  # exclude echo-server
</code></pre>
<h3>6.2 How do I create a Secret needed by a PreSync Job first?</h3>
<p>You can control the resource creation order using <code>ArgoCD</code>'s <strong><code>Sync Wave</code></strong>. Resources with a lower <code>Sync Wave</code> value are created first.</p>
<p><strong>Secret definition (sync-wave: "-1")</strong></p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: hello-world-server-hook-secret
  namespace: argocd-test
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/sync-wave: "-1"  # runs first
type: Opaque
data:
  common_secret.yaml: "dGVzdDoKICBkYXRhOgogICAgYXV0aF9rZXk6IC..."
</code></pre>
<p><strong>PreSync Job definition (sync-wave: "1")</strong></p>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata:
  generateName: presync-job1
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
    argocd.argoproj.io/sync-wave: "1"  # runs after the Secret
spec:
  backoffLimit: 2
  template:
    spec:
      volumes:
        - name: secret-volume
          secret:
            secretName: hello-world-server-hook-secret
      containers:
        - name: presync-job1
          image: busybox:1.36
          volumeMounts:
            - name: secret-volume
              mountPath: /opt/secret
          command:
            - /bin/sh
            - -c
            - |
              echo "Pre Sync Job - Secret available"
              cat /opt/secret/common_secret.yaml
      restartPolicy: Never
</code></pre>
<p><strong><code>Sync Wave</code> execution order:</strong></p>
<pre><code>sync-wave: "-1"  →  create Secret
sync-wave: "1"   →  run PreSync Job
(default: "0")   →  deploy normal resources (Deployment, Service, etc.)
PostSync         →  run PostSync Hook
</code></pre>
<p>Summary of key annotations:</p>
<table>
<thead>
<tr>
<th>Annotation</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>argocd.argoproj.io/hook</code></td>
<td><code>Hook</code> type (<code>PreSync</code>, <code>Sync</code>, <code>PostSync</code>, <code>SyncFail</code>)</td>
</tr>
<tr>
<td><code>argocd.argoproj.io/sync-wave</code></td>
<td>Execution order (lower value runs first, default: "0")</td>
</tr>
<tr>
<td><code>argocd.argoproj.io/hook-delete-policy</code></td>
<td><code>Hook</code> resource deletion policy (<code>BeforeHookCreation</code>, <code>HookSucceeded</code>, <code>HookFailed</code>)</td>
</tr>
</tbody>
</table>
<hr>
<h2>7. Conclusion</h2>
<p>We have looked at the two main patterns for managing multiple applications in <code>ArgoCD</code>.</p>
<ul>
<li><strong>App of Apps</strong>: Intuitive and able to control each Application individually, making it suitable for small projects or the early stages of adopting <code>ArgoCD</code>.</li>
<li><strong>ApplicationSet</strong>: Template-based, reducing duplication with a high level of automation, making it suitable for large-scale microservices or multi-environment deployments.</li>
</ul>
<p>The two patterns are not mutually exclusive, so you can choose the appropriate one or mix them based on your project's scale and requirements.</p>
<hr>
<h2>References</h2>
<ul>
<li><a href="https://argo-cd.readthedocs.io/en/stable/user-guide/application-set/">ArgoCD ApplicationSet Documentation</a></li>
<li><a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/cluster-bootstrapping/">ArgoCD App of Apps Pattern</a></li>
<li><a href="https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/">ArgoCD Sync Waves and Hooks</a></li>
<li><a href="https://github.com/kenshin579/argocd-charts-sample">Example Repository - kenshin579/argocd-charts-sample</a></li>
</ul>]]></content:encoded>
      <category>cloud</category>
      <category>argocd</category>
      <category>argo</category>
      <category>kubernetes</category>
      <category>k8s</category>
      <category>gitops</category>
      <category>applicationset</category>
      <category>app-of-apps</category>
      <category>helm</category>
      <category>generator</category>
      <category>matrix</category>
      <category>배포</category>
      <category>쿠버네티스</category>
      <category>멀티앱관리</category>
      <category>헬름차트</category>
      <category>Series: ArgoCD</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Migration Strategy to Gateway API After Ingress NGINX End of Support</title>
      <link>https://blog.advenoh.pe.kr/en/ingress-nginx-지원-종료-이후-gateway-api로의-마이그레이션-전략</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/ingress-nginx-지원-종료-이후-gateway-api로의-마이그레이션-전략</guid>
      <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
      <description>Covers the Gateway API concepts driven by the Ingress NGINX end of support and how to migrate using NGINX Gateway Fabric</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<p><img src="https://blog.advenoh.pe.kr/images/cloud/ingress-nginx-지원-종료-이후-gateway-api로의-마이그레이션-전략/cover.png" alt="cover"></p>
<p><code>Ingress NGINX</code>, one of the most widely used <code>Ingress Controllers</code> in Kubernetes environments, has announced its <strong>official end of life (EOL) as of March 2026</strong>.</p>
<p>End of support does not simply mean "no more updates."</p>
<ul>
<li>No more new feature development</li>
<li>Possible discontinuation of security patches</li>
<li>A gradual <code>Deprecated</code> position within the Kubernetes ecosystem</li>
</ul>
<p>In other words, a structure that depends on <code>Ingress NGINX</code> over the long term becomes technical debt. As a result, transitioning to <code>Gateway API</code>—the next-generation standard proposed by the official Kubernetes <code>SIG-Network</code>—is becoming a necessity rather than a choice.</p>
<blockquote>
<p><strong>Note</strong>: What is being retired is the <code>Ingress NGINX Controller</code> (the implementation), while the <code>Ingress</code> API itself has been in GA status since Kubernetes 1.19 and has no <code>Deprecated</code> plan. That said, <code>Gateway API</code> is establishing itself as the next-generation standard.</p>
</blockquote>
<p>This article covers the following topics.</p>
<ul>
<li>Limitations of the existing <code>Ingress</code> structure</li>
<li>Concepts and core resources of <code>Gateway API</code></li>
<li>Migration strategy from <code>Ingress</code> to <code>Gateway</code></li>
<li>How to actually configure it using <code>NGINX Gateway Fabric</code></li>
</ul>
<blockquote>
<p>The example code used in this article can be found in the <a href="https://github.com/kenshin579/tutorials-go/tree/master/cloud/ingress-gateway">GitHub repository</a>.</p>
</blockquote>
<h1>2. Existing Ingress Structure and Its Limitations</h1>
<p><code>Ingress</code> is a resource for routing external traffic of a Kubernetes cluster to internal <code>Services</code>. It has long been used as a de facto standard thanks to the advantage of being able to define HTTP/HTTPS-based routing with simple YAML.</p>
<p>A basic <code>Ingress</code> resource is defined as follows. This configuration routes the example project's <code>echo-server</code> via <code>Ingress</code>.</p>
<pre><code class="language-yaml"># charts/ingress/ingress-routes/templates/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echo-server-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
    - host: echo.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: echo-server
                port:
                  number: 80
</code></pre>
<p>However, <code>Ingress</code> has structural limitations.</p>
<h2>2.1 Separation of Roles Is Difficult</h2>
<p>A single <code>Ingress</code> resource mixes all of the following responsibilities.</p>
<ul>
<li><strong>Infrastructure-level settings</strong>: <code>LoadBalancer</code>, TLS, IP policies</li>
<li><strong>Application-level routing</strong>: <code>path</code> and <code>host</code> based routing</li>
</ul>
<p>This makes it difficult to separate the roles of the infrastructure team and the application team. Since a single resource must be modified by both sides, conflicts and confusion are likely to occur.</p>
<h2>2.2 Limitations in Extensibility and Expressiveness</h2>
<ul>
<li>TCP / UDP support is limited</li>
<li>Advanced routing (header-based, weight-based, etc.) relies on <code>Annotations</code></li>
<li><code>Annotations</code> differ across <code>Controller</code> implementations</li>
</ul>
<p>As a result, the structure becomes one where the standard is loose while implementation dependency grows stronger. For example, an <code>Annotation</code> used in NGINX must be written differently in <code>Traefik</code>.</p>
<h1>3. Ingress vs Gateway API Comparison</h1>
<table>
<thead>
<tr>
<th>Category</th>
<th><code>Ingress</code></th>
<th><code>Gateway API</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Standardization level</td>
<td>Low (<code>Annotation</code> dependent)</td>
<td>High (<code>SIG-Network</code> led)</td>
</tr>
<tr>
<td>Role separation</td>
<td>None (single resource)</td>
<td>Clear (<code>GatewayClass</code>/<code>Gateway</code>/<code>Route</code>)</td>
</tr>
<tr>
<td>Extensibility</td>
<td>Limited (HTTP/HTTPS centered)</td>
<td>L4–L7 multi-protocol support</td>
</tr>
<tr>
<td>Implementation dependency</td>
<td>Strong</td>
<td>Relatively low</td>
</tr>
<tr>
<td>Advanced routing</td>
<td><code>Annotation</code> required</td>
<td>Native support (headers, weights, etc.)</td>
</tr>
<tr>
<td>Multi-tenancy</td>
<td>Not supported</td>
<td>Cross-namespace control via <code>ReferenceGrant</code></td>
</tr>
</tbody>
</table>
<p><code>Gateway API</code> emerged to solve these limitations of <code>Ingress</code>.</p>
<h1>4. What Is Gateway API?</h1>
<p><code>Gateway API</code> is the next-generation API for standardizing the traffic ingress layer in Kubernetes. It is designed to replace <code>Ingress</code> and assumes <strong>extensibility and role separation</strong> from the ground up.</p>
<h2>4.1 Core Resources</h2>
<p><code>Gateway API</code> separates and manages resources by role. This structure is similar to the <code>StorageClass</code>/<code>PersistentVolume</code> pattern.</p>
<h3>GatewayClass</h3>
<p>Defines which implementation to use. This is a resource managed by the infrastructure provider.</p>
<pre><code class="language-yaml">apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: nginx
spec:
  controllerName: gateway.nginx.org/nginx-gateway-controller
</code></pre>
<ul>
<li>e.g., NGINX, <code>Istio</code>, <code>Kong</code>, <code>Traefik</code>, etc.</li>
</ul>
<h3>Gateway</h3>
<p>The actual traffic ingress point. It handles <code>LoadBalancer</code> and <code>Listener</code> (TLS/HTTP) settings and is an <strong>infrastructure-perspective resource</strong>.</p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/templates/gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: echo-gateway
spec:
  gatewayClassName: nginx
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        namespaces:
          from: All
</code></pre>
<p>If TLS is used, an HTTPS listener can be added.</p>
<pre><code class="language-yaml">    - name: https
      port: 443
      protocol: HTTPS
      allowedRoutes:
        namespaces:
          from: All
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: echo-tls
</code></pre>
<h3>HTTPRoute</h3>
<p>Defines which <code>Service</code> a request should be sent to. This is an <strong>application-perspective resource</strong>.</p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/templates/httproutes.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: echo-server-route
spec:
  parentRefs:
    - name: echo-gateway
  hostnames:
    - echo.local
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: echo-server
          namespace: app
          port: 80
</code></pre>
<p>Through this structure, the infrastructure settings (<code>Gateway</code>) and application routing (<code>HTTPRoute</code>) can be clearly separated.</p>
<h2>4.2 Role-Based Design</h2>
<p><code>Gateway API</code> makes the separation of responsibilities between teams explicit.</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Responsible Resource</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Infrastructure provider</td>
<td><code>GatewayClass</code></td>
<td>Defines the implementation</td>
</tr>
<tr>
<td>Cluster operator</td>
<td><code>Gateway</code></td>
<td>Traffic ingress point, TLS settings</td>
</tr>
<tr>
<td>Application developer</td>
<td><code>HTTPRoute</code></td>
<td>Service routing rules</td>
</tr>
</tbody>
</table>
<h2>4.3 Supported Route Types</h2>
<p><code>Gateway API</code> supports not only HTTP but also various protocols.</p>
<ul>
<li><strong><code>HTTPRoute</code></strong>: HTTP/HTTPS traffic routing</li>
<li><strong><code>GRPCRoute</code></strong>: gRPC traffic routing</li>
<li><strong><code>TLSRoute</code></strong>: TLS passthrough routing</li>
<li><strong><code>TCPRoute</code></strong>: TCP traffic routing</li>
<li><strong><code>UDPRoute</code></strong>: UDP traffic routing</li>
</ul>
<h1>5. Types of Gateway API Implementations</h1>
<p><code>Gateway API</code> is only an interface; the actual behavior is handled by the implementation. Representative implementations are as follows.</p>
<table>
<thead>
<tr>
<th>Implementation</th>
<th>Characteristics</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>NGINX Gateway Fabric</strong></td>
<td>NGINX-based; the most natural transition for <code>Ingress NGINX</code> users</td>
</tr>
<tr>
<td><strong>Istio</strong></td>
<td>Integrated with the service mesh, advanced traffic management</td>
</tr>
<tr>
<td><strong>Kong Gateway</strong></td>
<td>Rich API Gateway features, plugin ecosystem</td>
</tr>
<tr>
<td><strong>Traefik</strong></td>
<td>Automatic configuration, <code>Let's Encrypt</code> integration</td>
</tr>
<tr>
<td><strong>Envoy Gateway</strong></td>
<td>Based on the <code>Envoy</code> proxy, a CNCF project</td>
</tr>
</tbody>
</table>
<p>This article explains things based on <strong>NGINX Gateway Fabric</strong>, the most natural choice for <code>Ingress NGINX</code> users.</p>
<h1>6. Ingress → Gateway Migration Strategy</h1>
<p>The transition from <code>Ingress</code> to <code>Gateway</code> is not a rip-and-replace approach all at once. A phased approach is important.</p>
<h2>6.1 Analyzing the Existing Ingress Configuration</h2>
<p>First, organize the <code>Ingress</code> currently in use.</p>
<ul>
<li><code>host</code> / <code>path</code> rules</li>
<li>TLS settings</li>
<li>Whether <code>Annotations</code> are used</li>
<li>Whether <code>Controller</code>-dependent features are used</li>
</ul>
<p>In particular, <code>Annotation</code>-based features must be expressed differently in <code>Gateway API</code>.</p>
<h2>6.2 Ingress ↔ HTTPRoute Mapping</h2>
<p>The main elements of <code>Ingress</code> map to <code>HTTPRoute</code> as follows.</p>
<table>
<thead>
<tr>
<th><code>Ingress</code></th>
<th><code>Gateway API</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>host</code></td>
<td><code>HTTPRoute.hostnames</code></td>
</tr>
<tr>
<td><code>path</code></td>
<td><code>HTTPRoute.rules.matches</code></td>
</tr>
<tr>
<td><code>backend service</code></td>
<td><code>HTTPRoute.rules.backendRefs</code></td>
</tr>
<tr>
<td><code>TLS</code></td>
<td><code>Gateway.listener</code></td>
</tr>
<tr>
<td><code>annotations</code></td>
<td>Native fields or Policy resources</td>
</tr>
</tbody>
</table>
<p>Comparing the example project's <code>Ingress</code> and <code>HTTPRoute</code> one-to-one looks like this.</p>
<p><strong><code>Ingress</code> approach:</strong></p>
<pre><code class="language-yaml"># charts/ingress/ingress-routes/values.yaml
ingress:
  name: echo-server-ingress
  className: nginx
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
  hosts:
    - host: echo.local
      paths:
        - path: /
          pathType: Prefix
          serviceName: echo-server
          servicePort: 80
</code></pre>
<p><strong><code>Gateway API</code> approach:</strong></p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/values.yaml
gateway:
  name: echo-gateway
  className: nginx
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        from: All

httpRoutes:
  - name: echo-server-route
    hostnames:
      - echo.local
    rules:
      - matches:
          - path:
              type: PathPrefix
              value: /
        backendRefs:
          - name: echo-server
            namespace: app
            port: 80
</code></pre>
<p>Structurally, the <code>Ingress</code> → <code>HTTPRoute</code> conversion is relatively intuitive. The key difference is that in <code>Gateway API</code>, the infrastructure settings (<code>Gateway</code>) and routing rules (<code>HTTPRoute</code>) are separated.</p>
<blockquote>
<p><strong>Tip</strong>: Using AI coding tools such as Cursor or Claude Code, you can quickly convert existing <code>Ingress</code> YAML into the <code>Gateway API</code> format. Since the mapping rules are clear, the conversion accuracy is high, and batch conversion is possible even when there are many resources.</p>
</blockquote>
<h1>7. Configuring Gateway with NGINX Gateway Fabric</h1>
<p>This section explains how to actually configure a <code>Gateway</code> based on the <a href="https://github.com/kenshin579/tutorials-go/tree/master/cloud/ingress-gateway">example project</a>.</p>
<h2>7.1 Architecture Overview</h2>
<p>The example project has a structure that deploys <code>Ingress</code> and <code>Gateway</code> separately via <code>ArgoCD</code> on top of a <code>Kind</code> cluster. It provisions the <code>Kind</code> cluster and <code>ArgoCD</code> with <code>Terraform</code>, and <code>ArgoCD</code> deploys the <code>Ingress</code> or <code>Gateway API</code> resources and the <code>echo-server</code> application based on <code>Helm</code> charts. You can compare the differences by cross-deploying the <code>Ingress</code> approach and the <code>Gateway</code> approach against the same <code>echo-server</code>.</p>
<pre><code class="language-mermaid">flowchart TD
    Terraform["Terraform"]
    Client["Client"]

    Terraform -->|Provisioning| Infra

    subgraph Kind_Cluster["Kind Cluster"]
        subgraph Infra["Infra (terraform/)"]
            KindCluster["Create Kind Cluster"]
            ArgoCD["Install ArgoCD"]
            KindCluster --> ArgoCD
        end

        subgraph Apps["Apps (charts/)"]
            Gateway["Ingress or Gateway API"]
            Echo["echo-server"]
        end

        ArgoCD -->|Deployment management| Gateway
        ArgoCD -->|Deployment management| Echo
        Gateway --> Echo
    end

    Client -->|HTTP request| Gateway
</code></pre>
<p>The project directory structure is as follows.</p>
<pre><code>.
├── Makefile                    # Automation commands
├── terraform/                  # Kind cluster + ArgoCD installation
├── bootstrap/                  # ArgoCD Applications
│   ├── apps.yaml               # echo-server deployment
│   ├── infra-ingress.yaml      # Ingress infrastructure
│   └── infra-gateway.yaml      # Gateway infrastructure
└── charts/                     # Helm Charts
    ├── echo-server/            # Sample application
    ├── ingress/                # Ingress-related charts
    │   ├── nginx-ingress/      # NGINX Ingress Controller
    │   └── ingress-routes/     # Ingress resources
    └── gateway/                # Gateway API-related charts
        ├── gateway-api-crds/   # Gateway API CRDs
        ├── cert-manager/       # cert-manager (TLS certificate management)
        ├── nginx-gateway/      # NGINX Gateway Fabric
        └── gateway-routes/     # Gateway, HTTPRoute, TLS resources
</code></pre>
<p>To run the example, you need <a href="https://www.terraform.io/downloads">Terraform</a> >= 1.0, <a href="https://kubernetes.io/docs/tasks/tools/">kubectl</a>, <a href="https://www.docker.com/get-started">Docker</a>, and <a href="https://kind.sigs.k8s.io/docs/user/quick-start/#installation">Kind</a>.</p>
<h2>7.2 Creating the Cluster and Deploying Gateway</h2>
<p>First, create a <code>Kind</code> cluster with <code>Terraform</code> and install <code>ArgoCD</code>.</p>
<pre><code class="language-bash">make tf-init
make tf-apply
</code></pre>
<p>To deploy based on <code>Gateway API</code>, run the following command.</p>
<pre><code class="language-bash">make deploy-gateway
</code></pre>
<p>This command applies the <code>bootstrap/infra-gateway.yaml</code> <code>ArgoCD</code> <code>ApplicationSet</code> to deploy the following components sequentially.</p>
<pre><code class="language-yaml"># bootstrap/infra-gateway.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: gateway-infra
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - name: gateway-api-crds
            namespace: gateway
            path: cloud/ingress-gateway/charts/gateway/gateway-api-crds
          - name: nginx-gateway
            namespace: gateway
            path: cloud/ingress-gateway/charts/gateway/nginx-gateway
          - name: gateway-routes
            namespace: gateway
            path: cloud/ingress-gateway/charts/gateway/gateway-routes
  template:
    metadata:
      name: "{{name}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/kenshin579/tutorials-go
        targetRevision: HEAD
        path: "{{path}}"
      destination:
        server: https://kubernetes.default.svc
        namespace: "{{namespace}}"
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
</code></pre>
<p>The deployment flow is as follows.</p>
<ol>
<li><strong>Install Gateway API CRDs</strong> - Custom resource definitions such as <code>Gateway</code>, <code>HTTPRoute</code>, etc.</li>
<li><strong>Install <code>NGINX Gateway Fabric</code></strong> - The <code>Gateway API</code> implementation (<code>Helm</code> chart, version 2.2.1)</li>
<li><strong>Deploy Gateway Routes</strong> - Create <code>Gateway</code> and <code>HTTPRoute</code> resources</li>
</ol>
<h2>7.3 Configuring Gateway Resources</h2>
<p>To use <code>Gateway API</code>, you need to configure the implementation settings, define the actual routing resources, and, if necessary, set up TLS certificates.
This section walks through, in order, from <code>NGINX Gateway Fabric</code> configuration to <code>Gateway</code>/<code>HTTPRoute</code> definitions and TLS settings.</p>
<h3>7.3.1 NGINX Gateway Fabric Configuration</h3>
<p><code>NGINX Gateway Fabric</code> is installed via a <code>Helm</code> chart and is configured as follows to suit the <code>Kind</code> cluster environment.</p>
<pre><code class="language-yaml"># charts/gateway/nginx-gateway/values.yaml
nginx-gateway-fabric:
  service:
    type: NodePort
  nginxGateway:
    gwAPIExperimentalFeatures:
      enable: true
  nodeSelector:
    ingress-ready: "true"
  tolerations:
    - key: "node-role.kubernetes.io/control-plane"
      operator: "Equal"
      effect: "NoSchedule"
    - key: "node-role.kubernetes.io/master"
      operator: "Equal"
      effect: "NoSchedule"
</code></pre>
<ul>
<li>Expose the service with the <code>NodePort</code> type (<code>Kind</code> environment)</li>
<li>Enabling <code>gwAPIExperimentalFeatures</code> allows the use of <code>Gateway API</code> experimental channel resources (<code>TLSRoute</code>, <code>TCPRoute</code>, <code>UDPRoute</code>, etc.)</li>
<li>Configure <code>tolerations</code> so it can be scheduled on Control Plane nodes</li>
</ul>
<h3>7.3.2 Gateway and HTTPRoute</h3>
<p><code>Gateway</code> defines the traffic ingress point, and <code>HTTPRoute</code> defines the service routing rules. The example project manages them as <code>Helm</code> templates, so you can deploy them to suit various environments by modifying only the <code>values</code> file.</p>
<h4>Gateway Resource</h4>
<p><code>Gateway</code> defines the ingress point where external traffic enters the cluster. It configures which <code>GatewayClass</code> to use, which port and protocol to listen on, and which namespaces' <code>Routes</code> to allow.</p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/templates/gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: {{ .Values.gateway.name }}
  namespace: {{ .Release.Namespace }}
spec:
  gatewayClassName: {{ .Values.gateway.className }}
  listeners:
    {{- range .Values.gateway.listeners }}
    - name: {{ .name }}
      port: {{ .port }}
      protocol: {{ .protocol }}
      {{- if .tls }}
      tls:
        mode: {{ .tls.mode }}
        certificateRefs:
          {{- range .tls.certificateRefs }}
          - kind: {{ .kind }}
            name: {{ .name }}
          {{- end }}
      {{- end }}
      allowedRoutes:
        namespaces:
          from: {{ .allowedRoutes.from }}
    {{- end }}
</code></pre>
<h4>HTTPRoute Resource</h4>
<p><code>HTTPRoute</code> defines, based on hostname and path conditions, which backend <code>Service</code> to forward requests that come into the <code>Gateway</code>. Multiple <code>HTTPRoutes</code> can be connected to a single <code>Gateway</code>, enabling independent routing management per service.</p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/templates/httproutes.yaml
{{- range .Values.httpRoutes }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .name }}
  namespace: {{ $.Release.Namespace }}
spec:
  parentRefs:
    - name: {{ $.Values.gateway.name }}
      namespace: {{ $.Release.Namespace }}
  hostnames:
    {{- range .hostnames }}
    - {{ . }}
    {{- end }}
  rules:
    {{- range .rules }}
    - matches:
        {{- range .matches }}
        - path:
            type: {{ .path.type }}
            value: {{ .path.value }}
        {{- end }}
      backendRefs:
        {{- range .backendRefs }}
        - name: {{ .name }}
          namespace: {{ .namespace }}
          port: {{ .port }}
        {{- end }}
    {{- end }}
{{- end }}
</code></pre>
<p>When rendered with the actual <code>values</code> file, the following resources are created.</p>
<pre><code class="language-yaml"># Gateway: echo-gateway (port 80, HTTP)
# HTTPRoute: echo-server-route
#   - host: echo.local
#   - path: / (PathPrefix)
#   - backend: echo-server (namespace: app, port: 80)
</code></pre>
<h3>7.3.3 TLS/HTTPS Configuration (Optional)</h3>
<p>To use HTTPS, enable <code>cert-manager</code> and add the related settings.</p>
<h4>Step 1: Enable cert-manager</h4>
<p>Uncomment the <code>cert-manager</code> entry in <code>bootstrap/infra-gateway.yaml</code>.</p>
<pre><code class="language-yaml">elements:
  - name: gateway-api-crds
    namespace: gateway
    path: cloud/ingress-gateway/charts/gateway/gateway-api-crds
  - name: cert-manager      # Uncomment
    namespace: gateway
    path: cloud/ingress-gateway/charts/gateway/cert-manager
  - name: nginx-gateway
    namespace: gateway
    path: cloud/ingress-gateway/charts/gateway/nginx-gateway
  - name: gateway-routes
    namespace: gateway
    path: cloud/ingress-gateway/charts/gateway/gateway-routes
</code></pre>
<h4>Step 2: Configure TLS-related values</h4>
<p>Enable TLS in <code>charts/gateway/gateway-routes/values.yaml</code>.</p>
<pre><code class="language-yaml">tls:
  enabled: true  # change from false -> true

gateway:
  listeners:
    - name: http
      port: 80
      protocol: HTTP
      allowedRoutes:
        from: All
    - name: https        # Uncomment
      port: 443
      protocol: HTTPS
      allowedRoutes:
        from: All
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: echo-tls

letsencrypt:
  email: your-email@example.com
  environment: staging  # staging or prod

certificate:
  name: echo-tls
  dnsNames:
    - echo.local
</code></pre>
<p>When TLS is enabled, the following resources are additionally created.</p>
<p><code>ClusterIssuer</code> is a resource that defines the CA (Certificate Authority) from which <code>cert-manager</code> issues certificates. It is shared across the entire cluster, and the example below uses the <code>Let's Encrypt</code> ACME server.</p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/templates/clusterissuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    email: your-email@example.com
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-staging-key
    solvers:
      - http01:
          gatewayHTTPRoute:
            parentRefs:
              - name: echo-gateway
                kind: Gateway
</code></pre>
<p><code>Certificate</code> is a resource that requests the actual issuance of a TLS certificate. The issued certificate is stored in the specified <code>Secret</code> and is referenced by the <code>Gateway</code>'s TLS listener.</p>
<pre><code class="language-yaml"># charts/gateway/gateway-routes/templates/certificate.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: echo-tls
spec:
  secretName: echo-tls
  dnsNames:
    - echo.local
  issuerRef:
    name: letsencrypt-staging
    kind: ClusterIssuer
</code></pre>
<h2>7.4 Testing</h2>
<p>Once the deployment is complete, verify the behavior with the following commands.</p>
<pre><code class="language-bash"># Gateway test
make test-gateway

# Direct curl call
curl -H "Host: echo.local" http://localhost/ping
</code></pre>
<h2>7.5 Comparative Deployment with the Ingress Approach</h2>
<p>To deploy the same <code>echo-server</code> using the <code>Ingress</code> approach, use the following command.</p>
<pre><code class="language-bash">make deploy-ingress
</code></pre>
<p>In this case, <code>bootstrap/infra-ingress.yaml</code> is applied, deploying the <code>NGINX Ingress Controller</code> and the <code>Ingress</code> resource. You can directly compare the differences by cross-deploying the two approaches.</p>
<h1>8. Conclusion</h1>
<p>The end of support for <code>Ingress NGINX</code> may look like an issue that still has time left. However, an infrastructure transition is the kind of task that requires the most time for preparation.</p>
<ul>
<li>For a simple service, there is no need to remove <code>Ingress</code> right away</li>
<li>However, for a new service or a long-term operational service, it is reasonable to consider transitioning to <code>Gateway API</code></li>
<li>In particular, for <code>Ingress NGINX</code> users, <code>NGINX Gateway Fabric</code> is the most natural choice</li>
<li>Since <code>Ingress</code> and <code>Gateway</code> can coexist, a phased transition is possible</li>
</ul>
<p><code>Gateway API</code> is not merely a replacement for <code>Ingress</code>, but a standard that redefines the structure of the Kubernetes network layer.</p>
<h1>9. References</h1>
<ul>
<li><a href="https://nginxstore.com/blog/kubernetes/ingress-nginx-%EC%A7%80%EC%9B%90-%EC%A2%85%EB%A3%8C-%EC%95%88%EB%82%B4-kubernetes-ingress-controller/">Ingress NGINX End of Support Notice</a></li>
<li><a href="https://yozm.wishket.com/magazine/detail/3559/">Gateway API Overview - 요즘IT</a></li>
<li><a href="https://somaz.tistory.com/403">Gateway API Hands-on Notes - somaz</a></li>
<li><a href="https://chabin37.tistory.com/133">Gateway API Hands-on Notes - chabin</a></li>
<li><a href="https://gateway-api.sigs.k8s.io/">Gateway API Official Docs</a></li>
<li><a href="https://github.com/nginxinc/nginx-gateway-fabric">NGINX Gateway Fabric GitHub</a></li>
<li><a href="https://github.com/kenshin579/tutorials-go/tree/master/cloud/ingress-gateway">Example Code Repository</a></li>
</ul>]]></content:encoded>
      <category>cloud</category>
      <category>kubernetes</category>
      <category>gateway-api</category>
      <category>ingress</category>
      <category>nginx</category>
      <category>nginx-gateway-fabric</category>
      <category>k8s</category>
      <category>httproute</category>
      <category>migration</category>
      <category>게이트웨이</category>
      <category>인그레스</category>
      <category>마이그레이션</category>
      <category>쿠버네티스</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Running Multiple AI Bots from One Gateway with OpenClaw Telegram Multi-Agent</title>
      <link>https://blog.advenoh.pe.kr/en/openclaw-telegram-멀티-에이전트-구성하기</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/openclaw-telegram-멀티-에이전트-구성하기</guid>
      <pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate>
      <description>A step-by-step guide to running several Telegram bots independently using OpenClaw's multi-agent architecture.</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<p>OpenClaw is a <strong>self-hosted gateway</strong> that connects messaging apps such as WhatsApp, Telegram, and Discord to AI agents. By default a single agent handles all messages, but in real-world operations you often want to separate bots by purpose.</p>
<p>For example:</p>
<ul>
<li><strong>Alert bot</strong>: delivers only server failure or deployment notifications</li>
<li><strong>Monitoring bot</strong>: analyzes system metrics and produces reports</li>
<li><strong>Assistant bot</strong>: handles everyday questions and tasks</li>
</ul>
<p>OpenClaw's <strong>multi-agent</strong> feature makes this kind of setup possible. Each agent runs as a completely isolated, independent "brain" and can be managed from a single Gateway.</p>
<p>This article walks through how to configure multiple Telegram bots as OpenClaw multi-agents, step by step. The whole process proceeds in the following four steps.</p>
<ol>
<li><strong>Create Telegram bots</strong> - Create as many bots as agents in BotFather</li>
<li><strong>Add agents</strong> - Register agents with the OpenClaw CLI</li>
<li><strong>Pass bot tokens</strong> - Hand the tokens to OpenClaw to auto-configure the bindings</li>
<li><strong>Verify and run</strong> - Check the agent list and channel status</li>
</ol>
<blockquote>
<p>For OpenClaw's basic concepts and installation, refer to the <a href="/article/openclaw-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C">Complete OpenClaw Guide</a> article.</p>
</blockquote>
<h1>2. Multi-Agent Architecture</h1>
<h2>2.1 Overall Structure</h2>
<p>The core of a multi-agent system is that <strong>a single Gateway manages multiple agents and routes messages according to binding rules</strong>.</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph Telegram
        B1["🤖 alerts_bot"]
        B2["🤖 monitoring_bot"]
        B3["🤖 assistant_bot"]
    end

    subgraph Gateway
        R["Routing Engine\n(Bindings)"]
    end

    subgraph Agents
        A1["Agent: alerts\n(Claude Opus)"]
        A2["Agent: monitoring\n(Claude Sonnet)"]
        A3["Agent: assistant\n(Claude Haiku)"]
    end

    B1 --> R
    B2 --> R
    B3 --> R
    R --> A1
    R --> A2
    R --> A3
</code></pre>
<p>Each Telegram bot has an independent account (accountId) and is connected to a specific agent through binding rules.</p>
<h2>2.2 Components of an Agent</h2>
<p>Each agent has completely isolated components.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Path</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Workspace</strong></td>
<td><code>~/.openclaw/workspace-&#x3C;agentId>/</code></td>
<td>AGENTS.md, SOUL.md, USER.md, local files</td>
</tr>
<tr>
<td><strong>State directory (agentDir)</strong></td>
<td><code>~/.openclaw/agents/&#x3C;agentId>/agent/</code></td>
<td>Auth profiles, model registry, agent settings</td>
</tr>
<tr>
<td><strong>Session store</strong></td>
<td><code>~/.openclaw/agents/&#x3C;agentId>/sessions/</code></td>
<td>Conversation history</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>Caution</strong>: never share the <code>agentDir</code> between agents. Sharing it causes authentication conflicts.</p>
</blockquote>
<h2>2.3 Directory Structure</h2>
<p>The actual multi-agent structure on the filesystem looks like this.</p>
<pre><code class="language-mermaid">graph TD
    ROOT["~/.openclaw/"] --> CONFIG["openclaw.json"]
    ROOT --> WS1["workspace-alerts/"]
    ROOT --> WS2["workspace-monitoring/"]
    ROOT --> AGENTS["agents/"]

    WS1 --> WS1_A["AGENTS.md"]
    WS1 --> WS1_S["SOUL.md"]
    WS1 --> WS1_U["USER.md"]

    WS2 --> WS2_A["AGENTS.md"]
    WS2 --> WS2_S["SOUL.md"]
    WS2 --> WS2_U["USER.md"]

    AGENTS --> AG1["alerts/"]
    AGENTS --> AG2["monitoring/"]

    AG1 --> AG1_D["agent/"]
    AG1 --> AG1_S["sessions/"]
    AG1_D --> AG1_AUTH["auth-profiles.json"]

    AG2 --> AG2_D["agent/"]
    AG2 --> AG2_S["sessions/"]
    AG2_D --> AG2_AUTH["auth-profiles.json"]
</code></pre>
<p>Each agent's Workspace contains files that define the agent's character and role.</p>
<ul>
<li><strong>AGENTS.md</strong>: the agent's instructions and tool settings</li>
<li><strong>SOUL.md</strong>: the agent's persona and personality</li>
<li><strong>USER.md</strong>: user information and preferences</li>
</ul>
<h1>3. Multi-Agent Configuration</h1>
<h2>3.1 Step 1 - Create Telegram Bots (BotFather)</h2>
<p>In Telegram, chat with <a href="https://t.me/BotFather">@BotFather</a> and create as many bots as you have agents.</p>
<ol>
<li>Send the <code>/newbot</code> command to BotFather</li>
<li>Enter the bot name (e.g., <code>My Alert Bot</code>)</li>
<li>Enter the bot username (e.g., <code>my_alert_bot</code>)</li>
<li>Store the issued <strong>bot token</strong> securely</li>
</ol>
<p>Repeat this process for the number of bots you need. For example, if you plan to run three agents, create three bots.</p>
<pre><code># Example tokens received from BotFather
alerts_bot:     123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
monitoring_bot: 987654:XYZ-UVW9876tgHjk-abc34V3w9z456ty22
assistant_bot:  111222:GHI-JKL5678mnOpq-rst12X4y6z789ab33
</code></pre>
<h2>3.2 Step 2 - Add Agents</h2>
<p>Add each agent with the OpenClaw CLI.</p>
<pre><code class="language-bash">openclaw agents add alerts
openclaw agents add monitoring
openclaw agents add assistant
</code></pre>
<p>Each command automatically creates the following.</p>
<ul>
<li>Workspace directory (<code>~/.openclaw/workspace-&#x3C;agentId>/</code>)</li>
<li>State directory (<code>~/.openclaw/agents/&#x3C;agentId>/agent/</code>)</li>
<li>Session store (<code>~/.openclaw/agents/&#x3C;agentId>/sessions/</code>)</li>
</ul>
<p>When you actually run <code>openclaw agents add</code>, an interactive wizard runs.</p>
<pre><code>$ openclaw agents add proj-photo

🦞 OpenClaw 2026.2.17 (4134875) — The UNIX philosophy meets your DMs.

┌  Add OpenClaw agent
│
◇  Workspace directory
│  /Users/user/.openclaw/workspace-proj-photo
│
◇  Copy auth profiles from "main"?
│  Yes
│
◇  Auth profiles ─────────────────────╮
│                                     │
│  Copied auth profiles from "main".  │
│                                     │
├─────────────────────────────────────╯
│
◇  Configure model/auth for this agent now?
│  Yes
│
◇  Model/auth provider
│  Anthropic
│
◇  Anthropic auth method
│  Anthropic token (paste setup-token)
│
◇  Paste Anthropic setup-token
│  sk-ant-oat01-****
│
◇  Token name (blank = default)
│  proj-photo
│
◇  Configure chat channels now?
│  Yes
│
◇  Select a channel
│  Finished
│
└  Agent "proj-photo" ready.
</code></pre>
<p>Workspace, auth profiles, and model settings are all completed in one go.</p>
<h2>3.3 Step 3 - Pass the Bot Tokens to OpenClaw</h2>
<p>You don't need to edit <code>openclaw.json</code> directly. In the OpenClaw chat window (Dashboard or Telegram), tell it that you added a new bot and hand over the token; OpenClaw will add the configuration on its own and restart the Gateway.</p>
<pre><code>User: "I added a new Telegram bot. Its name is alerts_bot and the token is 123456:ABC-DEF1234..."
OpenClaw: Configuration added → Gateway automatically restarted
</code></pre>
<p><strong>Actual workflow:</strong></p>
<ol>
<li>Create a bot in BotFather → copy the token</li>
<li>Pass the token in the OpenClaw chat window</li>
<li>OpenClaw handles it automatically:
<ul>
<li>Adds the agent and Telegram account to <code>openclaw.json</code></li>
<li>Creates binding rules</li>
<li>Restarts the Gateway</li>
</ul>
</li>
</ol>
<p>Below is what passing a bot token actually looks like in the OpenClaw Telegram chat window. Once you hand over the token, OpenClaw automatically adds the configuration and shows the bot status.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw-telegram-멀티-에이전트-구성하기/openclaw-token-transfer.jpg" alt="OpenClaw에게 Telegram 봇 토큰을 전달하는 화면"></p>
<h2>3.4 Understanding the openclaw.json Structure</h2>
<p>OpenClaw generates the configuration automatically, but understanding the internal structure helps with customization and troubleshooting. <code>openclaw.json</code> consists of three main sections.</p>
<h3>agents Section</h3>
<p>Defines each agent (AI brain). You can assign an independent workspace and model to each agent.</p>
<pre><code class="language-json5">{
  agents: {
    list: [
      {
        id: "alerts",
        name: "Alert Agent",
        workspace: "~/.openclaw/workspace-alerts",
        agentDir: "~/.openclaw/agents/alerts/agent",
        model: "anthropic/claude-opus-4-6",
      },
      {
        id: "monitoring",
        name: "Monitoring Agent",
        workspace: "~/.openclaw/workspace-monitoring",
        agentDir: "~/.openclaw/agents/monitoring/agent",
        model: "anthropic/claude-sonnet-4-5",
      },
      {
        id: "assistant",
        name: "Assistant Agent",
        workspace: "~/.openclaw/workspace-assistant",
        agentDir: "~/.openclaw/agents/assistant/agent",
        model: "anthropic/claude-haiku-4-5",
      },
    ],
  },
}
</code></pre>
<p>Since each agent can use a different model, you can tune performance and cost to fit each purpose.</p>
<h3>channels Section</h3>
<p>Configures Telegram bot tokens and access policies.</p>
<pre><code class="language-json5">{
  channels: {
    telegram: {
      accounts: {
        alerts_bot: {
          botToken: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
          dmPolicy: "pairing",
        },
        monitoring_bot: {
          botToken: "987654:XYZ-UVW9876tgHjk-abc34V3w9z456ty22",
          dmPolicy: "allowlist",
          allowFrom: ["tg:123456789"],
        },
        assistant_bot: {
          botToken: "111222:GHI-JKL5678mnOpq-rst12X4y6z789ab33",
          dmPolicy: "open",
          allowFrom: ["*"],
        },
      },
    },
  },
}
</code></pre>
<p><code>dmPolicy</code> controls who can send DMs to a bot.</p>
<table>
<thead>
<tr>
<th>dmPolicy</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>pairing</code></td>
<td>Only users who enter the pairing code can chat (default)</td>
</tr>
<tr>
<td><code>allowlist</code></td>
<td>Only users registered in <code>allowFrom</code> can chat</td>
</tr>
<tr>
<td><code>open</code></td>
<td>Anyone can chat (<code>allowFrom: ["*"]</code> required)</td>
</tr>
<tr>
<td><code>disabled</code></td>
<td>DM disabled</td>
</tr>
</tbody>
</table>
<h3>bindings Section</h3>
<p>Defines the routing rules for which bot's messages go to which agent.</p>
<pre><code class="language-json5">{
  bindings: [
    {
      agentId: "alerts",
      match: { channel: "telegram", accountId: "alerts_bot" },
    },
    {
      agentId: "monitoring",
      match: { channel: "telegram", accountId: "monitoring_bot" },
    },
    {
      agentId: "assistant",
      match: { channel: "telegram", accountId: "assistant_bot" },
    },
  ],
}
</code></pre>
<blockquote>
<p>When you need advanced settings such as changing dmPolicy, swapping models, or refining bindings, you can edit this file directly.</p>
</blockquote>
<h2>3.5 Step 4 - Verify and Run</h2>
<p>Once the configuration is complete, verify it with the following commands.</p>
<pre><code class="language-bash"># Check the agent list and bindings
openclaw agents list --bindings

# Check channel status (verify bot token validity)
openclaw channels status --probe

# Restart the Gateway (when changing settings manually)
openclaw gateway restart
</code></pre>
<p>If everything is set up correctly, sending a message to each Telegram bot will get a response from the corresponding agent. Below is what an actual conversation with a newly added bot looks like.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw-telegram-멀티-에이전트-구성하기/openclaw-bot-conversation.png" alt="새로 추가한 Telegram 봇과의 대화 화면"></p>
<h1>4. Conclusion</h1>
<p>With OpenClaw's multi-agent feature, you can run multiple Telegram bots independently from a single Gateway. To summarize the key points:</p>
<ul>
<li>Each agent is an independent AI brain with completely isolated <strong>Workspace, agentDir, and sessions</strong></li>
<li>Create bots in BotFather and <strong>just hand the tokens to OpenClaw</strong> for automatic configuration</li>
<li><strong>Binding rules</strong> route messages to agents, allowing branching per bot, per user, or per group</li>
<li>Use a <strong>different AI model</strong> per agent to optimize performance and cost</li>
</ul>
<p>Multi-agent isn't just about creating several bots; it's about giving each bot a <strong>specialized role and context</strong>. A well-designed agent separation raises both response quality and operational efficiency at the same time.</p>
<h1>5. References</h1>
<ul>
<li><a href="https://docs.openclaw.ai">OpenClaw Official Documentation</a></li>
<li><a href="https://docs.openclaw.ai/concepts/multi-agent">Multi-Agent Concept</a></li>
<li><a href="https://docs.openclaw.ai/channels/telegram">Telegram Channel Setup</a></li>
<li><a href="https://docs.openclaw.ai/start/getting-started">Getting Started Guide</a></li>
</ul>]]></content:encoded>
      <category>ai</category>
      <category>OpenClaw</category>
      <category>Telegram</category>
      <category>AI에이전트</category>
      <category>멀티에이전트</category>
      <category>셀프호스팅</category>
      <category>챗봇</category>
      <category>Series: OpenClaw Guide</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Connecting Google Workspace to OpenClaw with gog</title>
      <link>https://blog.advenoh.pe.kr/en/openclaw에서-gog로-google-workspace-연동하기</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/openclaw에서-gog로-google-workspace-연동하기</guid>
      <pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate>
      <description>How to set up gog (Google Workspace CLI) in OpenClaw to control Gmail, Calendar, and Drive from Telegram</description>
      <content:encoded><![CDATA[<h1>1. Overview</h1>
<p>Connecting Google Workspace to OpenClaw lets you control Gmail, Calendar, and Drive entirely through Telegram chat. This article walks through setting up <a href="https://clawhub.ai/steipete/gog">gog (Google Workspace CLI)</a> in OpenClaw.</p>
<blockquote>
<p><strong>After downloading the OAuth JSON file from the Google Cloud Console, every step was carried out solely through Telegram chat.</strong> I never typed a command directly in a terminal.</p>
</blockquote>
<ul>
<li>OAuth setup in the Google Cloud Console (web browser)</li>
<li>After sending the JSON credential file to Telegram, everything was done through Telegram, without typing any CLI command directly</li>
</ul>
<blockquote>
<p>For the basic OpenClaw installation and Telegram bot integration, see the <a href="/article/openclaw-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C">Complete OpenClaw Guide</a>.</p>
</blockquote>
<h1>2. What is gog?</h1>
<p>gog is a CLI tool for controlling Google Workspace from the terminal.</p>
<p>Supported services:</p>
<ul>
<li><strong>Gmail</strong> - search, read, send mail, and manage labels</li>
<li><strong>Calendar</strong> - view, create, and edit events</li>
<li><strong>Drive</strong> - search, upload, and download files</li>
<li><strong>Contacts</strong> - manage contacts</li>
<li><strong>Docs / Sheets / Slides</strong> - manage documents</li>
<li><strong>Tasks</strong> - manage to-dos</li>
<li><strong>Chat</strong> - Google Chat messages</li>
<li><strong>Forms</strong> - query survey data</li>
<li><strong>Apps Script</strong> - manage scripts</li>
</ul>
<p>By integrating with OpenClaw, you can handle all of this through natural-language conversation.</p>
<pre><code class="language-mermaid">flowchart LR
    A[User] -->|Telegram message| B[OpenClaw Bot]
    B -->|Run gog CLI| C[gog]
    C -->|OAuth 2.0| D[Google Workspace API]
    D --> E[Gmail]
    D --> F[Calendar]
    D --> G[Drive]
    D --> H[Docs / Sheets / ...]
</code></pre>
<h1>3. Prerequisites</h1>
<ul>
<li>OpenClaw installed and Telegram bot integration complete</li>
<li>macOS environment (brew available)</li>
<li>A Google account</li>
</ul>
<h1>4. Full Setup Flow</h1>
<p>The overall flow can be summarized as follows.</p>
<ol>
<li><strong>Google Cloud Console</strong> - create an OAuth project &#x26; a Client ID → download JSON</li>
<li><strong>Telegram</strong> - send the JSON file to the OpenClaw bot</li>
<li><strong>Telegram</strong> - provide your Gmail address → OpenClaw generates an authentication URL</li>
<li><strong>Browser</strong> - sign in to Google &#x26; approve permissions → copy the callback URL</li>
<li><strong>Telegram</strong> - paste the callback URL → OpenClaw exchanges the token &#x26; finishes the setup</li>
<li><strong>Google Cloud Console</strong> - enable the Gmail, Calendar, and Drive APIs</li>
<li><strong>Telegram</strong> - "Tell me tomorrow's schedule" → start using Google Workspace</li>
</ol>
<h1>5. Installing gog</h1>
<p>I asked the OpenClaw bot in Telegram how to set up gog.</p>
<blockquote>
<p><strong>Me</strong>: I want to set up gog google workspace. <a href="https://clawhub.ai/steipete/gog">https://clawhub.ai/steipete/gog</a></p>
<p><strong>OpenClaw</strong>: I already installed <code>gogcli</code> earlier. Now we need to <strong>set up Google authentication</strong>. Let's go step by step:</p>
<p>Step 1: Create a Google Cloud OAuth client</p>
<ol>
<li>Open the <strong>Google Cloud Console</strong> — <a href="https://console.cloud.google.com/">https://console.cloud.google.com/</a></li>
<li>Create a <strong>new project</strong> (name: <code>gog-cli</code>)</li>
<li>Configure <strong>APIs &#x26; Services → OAuth consent screen</strong></li>
<li><strong>APIs &#x26; Services → Credentials → Create Credentials → OAuth client ID</strong>
<ul>
<li>Application type: <strong>Desktop app</strong></li>
<li>Download <code>client_secret_xxxx.json</code></li>
</ul>
</li>
</ol>
</blockquote>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/telegram-gog-setup.png" alt="Requesting gog setup from OpenClaw in Telegram"></p>
<p>OpenClaw immediately guides you through the entire setup. If gog is not yet installed, just say "install gog" and OpenClaw runs <code>brew install gogcli</code> in the background.</p>
<h1>6. Google Cloud Console OAuth Setup</h1>
<p>This is the only stage that <strong>the user must perform directly in a web browser</strong>.</p>
<h2>6.1 Create a Project</h2>
<p>Go to the <a href="https://console.cloud.google.com/">Google Cloud Console</a> and create a new project.</p>
<ul>
<li>Project name: <code>gog-cli</code> (any name you like)</li>
</ul>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/gcloud-create-project.png" alt="Create a new project"></p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/gcloud-console-main.png" alt="Google Cloud Console main screen"></p>
<h2>6.2 Configure the OAuth Consent Screen</h2>
<p>Click APIs &#x26; Services → OAuth consent screen to go to the OAuth overview page, then click the Get Started button.</p>
<p>Enter the <strong>app information</strong>:</p>
<ul>
<li>App name: <code>gog-cli</code></li>
<li>User support email: your own email</li>
</ul>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-consent-app-info.png" alt="App information input form"></p>
<p>Select the <strong>audience</strong>:</p>
<ul>
<li>Select <strong>External</strong></li>
</ul>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-consent-external.png" alt="Audience selection - External"></p>
<p>Enter the contact information and finish.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-consent-confirm.png" alt="Final confirmation">## 6.3 Create an OAuth Client ID</p>
<p>Go to APIs &#x26; Services → Credentials → <strong>Create Credentials</strong> → <strong>OAuth client ID</strong>.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/credentials-menu.png" alt="Creating an OAuth client ID from the Credentials menu"></p>
<ul>
<li>Application type: <strong>Desktop app</strong></li>
<li>Name: <code>gog-cli</code></li>
<li>Click <strong>Create</strong></li>
</ul>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-client-create.png" alt="Create OAuth client ID - select Desktop app"></p>
<p>Once it is created, click the <strong>Download JSON</strong> button to download the <code>client_secret_xxxx...xxxx.json</code> file.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-client-complete.jpg" alt="OAuth client creation complete - download JSON"></p>
<blockquote>
<p><strong>This is the last step you do in a web browser.</strong> From here on, you return to Telegram chat.</p>
</blockquote>
<h1>7. gog Authentication Setup (done via Telegram chat)</h1>
<blockquote>
<p>From this section on, <strong>you do not operate the terminal directly.</strong> OpenClaw runs every CLI command on your behalf.</p>
</blockquote>
<h2>7.1 Send the JSON Credential File</h2>
<p>Send the downloaded JSON file to the Telegram chat as a file attachment.</p>
<blockquote>
<p><strong>Me</strong>: <em>(attaching the client_secret_xxxx...xxxx.json file)</em></p>
<p><strong>OpenClaw</strong>: Got the file! I'll proceed with the setup.</p>
<p><strong>OpenClaw</strong>: Credential registration complete! Now we need to authenticate the Google account.
Tell me your Gmail address — I'll run it in <code>--manual</code> mode and send you the authentication URL!</p>
</blockquote>
<p><strong>OpenClaw background tasks:</strong></p>
<pre><code class="language-bash"># 1. Save the JSON file received from Telegram to the Mac Mini
mv /tmp/telegram_download/client_secret_xxxx.json ~/Downloads/

# 2. Register the OAuth credentials with gog
gog auth credentials ~/Downloads/client_secret_xxxx...xxxx.json
</code></pre>
<h2>7.2 Provide the Gmail Address → Receive the Authentication URL</h2>
<p>Type your Gmail address into the chat, and OpenClaw sends you the authentication URL.</p>
<blockquote>
<p><strong>Me</strong>: <a href="mailto:your-email...@gmail.com">your-email...@gmail.com</a></p>
<p><strong>OpenClaw</strong>: Standing by! Now follow the steps below:</p>
<p><strong>1.</strong> Open this link in your browser:
<code>https://accounts.google.com/o/oauth2/auth?access_type=offline&#x26;client_id=xxxx...xxxx&#x26;...</code></p>
<p><strong>2.</strong> Sign in to Google and approve the permissions</p>
<p><strong>3.</strong> It's normal for the page not to open! Copy the <strong>full URL from the browser's address bar</strong> and send it to me
It's a URL that looks like <code>http://127.0.0.1:.../oauth2/callback?state=...&#x26;code=...</code>!</p>
</blockquote>
<p><strong>OpenClaw background tasks:</strong></p>
<pre><code class="language-bash"># Start authentication in --manual mode (print the URL directly instead of opening a browser)
gog auth add your-email...@gmail.com --manual

# Send the printed OAuth authentication URL as a Telegram message
# Wait for the local callback server (127.0.0.1:50554) in the background
</code></pre>
<h3>7.3 Troubleshooting: 403 access_denied</h3>
<p>When you first open the authentication URL, you may get a 403 error.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/403-access-denied.png" alt="403 access_denied error screen"></p>
<blockquote>
<p><strong>Me</strong>: <em>(sending the 403 access_denied screenshot)</em></p>
<p><strong>OpenClaw</strong>: <code>403 access_denied</code> — it's because the OAuth app is still in <strong>test mode</strong>. You need to add a test user.</p>
<p>In the Google Cloud Console:</p>
<ol>
<li>Go to <strong>APIs &#x26; Services → OAuth consent screen</strong></li>
<li>In the <strong>Test users</strong> section, click <strong>Add users</strong></li>
<li>Add your own email</li>
</ol>
<p><strong>Me</strong>: I don't want to add a test user; I just want it to actually work. Can I just click Publish app?</p>
<p><strong>OpenClaw</strong>: Yes, you can <strong>Publish</strong> the app! If only you use it, there's no problem.</p>
<p><strong>Me</strong>: Done publishing.</p>
</blockquote>
<p>The cause is that OAuth apps are created in <strong>test mode</strong> by default. There are two ways to fix it:</p>
<ol>
<li><strong>Publish the app</strong>: OAuth consent screen → Audience → click <strong>Publish app</strong> (recommended if only you use it)</li>
<li><strong>Add a test user</strong>: Audience → add your own email to the test users</li>
</ol>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-publish-page.png" alt="App publishing page"></p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/oauth-publish-confirm.png" alt="Publish confirmation dialog"></p>
<h3>7.4 Sign in to Google → Provide the callback URL</h3>
<p>After publishing the app, open the authentication URL again and the Google sign-in proceeds normally.</p>
<p><strong>1. Select an account</strong></p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/google-account-select.png" alt="Google account selection"></p>
<p><strong>2. "Google hasn't verified this app" warning</strong></p>
<p>A warning appears because the app hasn't gone through Google's official verification. Since you created the app yourself, click <strong>Advanced → Continue</strong>.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/google-unverified-app.png" alt="Google hasn&#x27;t verified this app warning"></p>
<p><strong>3. Approve the permission request</strong></p>
<p>The list of Google services that gog-cli will access is displayed. Click <strong>Continue</strong> to approve all permissions.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/google-permission-1.png" alt="Permission request screen 1"></p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/google-permission-2.png" alt="Permission request screen 2"></p>
<p><strong>4. Copy the callback URL</strong></p>
<p>After approval, you are redirected to a <code>http://127.0.0.1:50554/oauth2/callback?...</code> address, and it is <strong>normal</strong> for the page not to open. <strong>Copy the full URL</strong> from the browser's address bar and paste it into Telegram.</p>
<blockquote>
<p><strong>Me</strong>: <code>http://127.0.0.1:50554/oauth2/callback?state=xxxx...xxxx&#x26;code=4/0Axxxx...xxxx&#x26;scope=email%20profile%20...</code></p>
<p><strong>OpenClaw</strong>: Authentication complete!</p>
<pre><code>email     your-email...@gmail.com
services  appscript, calendar, chat, classroom, contacts,
          docs, drive, forms, gmail, people, sheets, slides, tasks
</code></pre>
</blockquote>
<p><strong>OpenClaw background tasks:</strong></p>
<pre><code class="language-bash"># 1. Extract the authorization code from the callback URL the user sent
#    http://127.0.0.1:50554/oauth2/callback?code=4/0Axxxx...xxxx&#x26;...
#    → code = "4/0Axxxx...xxxx"

# 2. Exchange the authorization code for Google OAuth tokens
#    (internally gog exchanges code → access_token + refresh_token)

# 3. Store the tokens securely in the macOS Keychain

# 4. Verify the authentication result
gog auth list
# → email: your-email...@gmail.com, confirm 13 services connected
</code></pre>
<h3>7.5 Troubleshooting: macOS Keychain Access</h3>
<p>gog stores authentication tokens in the macOS Keychain. On the first run, a Keychain access prompt may appear.</p>
<blockquote>
<p><strong>OpenClaw</strong>: <code>gog auth list</code> keeps hanging — it might be a macOS Keychain access issue. Test it directly in the terminal on the Mac Mini — a Keychain access prompt may appear.</p>
<p><strong>Me</strong>: A lot of Keychain access prompt windows popped up; I entered my login password and finished.</p>
<p><strong>OpenClaw</strong>: Great! Now that you've allowed Keychain access, it should work. Let me test again!</p>
</blockquote>
<p>The Keychain prompt must be handled directly on the Mac Mini screen. Once allowed, it is accessed automatically thereafter.</p>
<h2>7.6 Set the Environment Variable</h2>
<p>You need to set the <code>GOG_ACCOUNT</code> environment variable so you don't have to add the <code>--account</code> option every time.</p>
<blockquote>
<p><strong>Me</strong>: Is the GOG_ACCOUNT environment variable set as well?</p>
<p><strong>OpenClaw</strong>: It's not! I'll set it up.</p>
<p><strong>OpenClaw</strong>: Done adding it!</p>
<ul>
<li>Added <code>export GOG_ACCOUNT=your-email...@gmail.com</code> to <code>~/.zshrc</code></li>
<li>Applied automatically from new terminal sessions</li>
</ul>
<p><strong>Me</strong>: Run source ~/.zshrc.</p>
<p><strong>OpenClaw</strong>: Done! <code>GOG_ACCOUNT=your-email...@gmail.com</code> is now applied.</p>
</blockquote>
<p><strong>OpenClaw background tasks:</strong></p>
<pre><code class="language-bash"># 1. Add the environment variable to ~/.zshrc
echo 'export GOG_ACCOUNT=your-email...@gmail.com' >> ~/.zshrc

# 2. Apply it to the current session
source ~/.zshrc
</code></pre>
<h1>8. Enabling Google APIs</h1>
<p>Authentication is complete, but you must <strong>enable</strong> the APIs you actually use in the Google Cloud project for them to work. As OpenClaw tries testing, it tells you the list of required APIs and their links.</p>
<blockquote>
<p><strong>OpenClaw</strong>: We're making progress! Keychain is resolved, and now we need to <strong>enable the APIs in Google Cloud</strong>.</p>
<ol>
<li><strong>Gmail API</strong>: <code>https://console.developers.google.com/apis/api/gmail.googleapis.com/...</code></li>
<li><strong>Calendar API</strong>: <code>https://console.developers.google.com/apis/api/calendar-json.googleapis.com/...</code></li>
<li><strong>Drive API</strong>: <code>https://console.developers.google.com/apis/api/drive.googleapis.com/...</code></li>
</ol>
</blockquote>
<p>In the Google Cloud Console, search for each API and click the <strong>Enable</strong> button.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/gmail-api-enable.png" alt="Gmail API enable example"></p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/gmail-api-complete.png" alt="Gmail API enable complete"></p>
<p>Once you let OpenClaw know in Telegram that it's enabled, OpenClaw tests it right away.</p>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/telegram-api-test-result.png" alt="API test result - all working"></p>
<p><strong>OpenClaw background tasks:</strong></p>
<pre><code class="language-bash"># Run the test as soon as the user says "I enabled it"
gog gmail search 'newer_than:1d'     # → confirm Gmail API works
gog calendar calendars                # → confirm Calendar API works
gog drive files list                  # → confirm Drive API works

# Summarize the results and respond via Telegram
</code></pre>
<blockquote>
<p>You can enable additional APIs such as Contacts, Tasks, and Sheets as needed.</p>
</blockquote>
<h1>9. Real-World Usage</h1>
<p>Once the setup is complete, you can use Google Workspace right away with natural language.</p>
<h2>9.1 Querying Calendar Events</h2>
<p><img src="https://blog.advenoh.pe.kr/images/ai/openclaw에서-gog로-google-workspace-연동하기/telegram-calendar-query.jpg" alt="Querying calendar events in Telegram"></p>
<h2>9.2 Natural Language → gog Command Conversion</h2>
<p>When the user makes a request in natural language, OpenClaw converts it into the appropriate gog command and runs it. The user doesn't need to know the gog command syntax at all.</p>
<table>
<thead>
<tr>
<th>User (natural language)</th>
<th>Command OpenClaw runs</th>
</tr>
</thead>
<tbody>
<tr>
<td>"Tell me tomorrow's schedule"</td>
<td><code>gog calendar events --from 2026-02-20 --to 2026-02-21</code></td>
</tr>
<tr>
<td>"Also tell me my March weekend schedule"</td>
<td><code>gog calendar events --from 2026-03-01 --to 2026-03-31</code> + weekend filtering</td>
</tr>
<tr>
<td>"Check the mail that came in today"</td>
<td><code>gog gmail search 'newer_than:1d'</code></td>
</tr>
<tr>
<td>"Find the XX file in Drive"</td>
<td><code>gog drive files list --query 'name contains "XX"'</code></td>
</tr>
</tbody>
</table>
<h1>10. Wrapping Up</h1>
<p>The entire setup takes about 10 minutes, and the only parts the user does directly are the Google Cloud Console and the browser authentication.</p>
<p>After setup, you just make requests in natural language from Telegram. Say things like "Check my mail today" or "Tell me this week's schedule," and OpenClaw picks the right gog command on its own and answers with the results organized in a human-readable form.</p>
<h1>11. References</h1>
<ul>
<li><a href="https://clawhub.ai/steipete/gog">gog official docs (ClaWHub)</a></li>
<li><a href="https://console.cloud.google.com/">Google Cloud Console</a></li>
<li><a href="/article/openclaw-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C">Complete OpenClaw Guide</a></li>
<li><a href="/article/openclaw-telegram-%EB%A9%80%ED%8B%B0-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0">Setting Up OpenClaw Telegram Multi-Agent</a></li>
</ul>]]></content:encoded>
      <category>ai</category>
      <category>openclaw</category>
      <category>gog</category>
      <category>google-workspace</category>
      <category>gmail</category>
      <category>google-calendar</category>
      <category>google-drive</category>
      <category>telegram</category>
      <category>oauth</category>
      <category>ai-agent</category>
      <category>오픈클로</category>
      <category>구글워크스페이스</category>
      <category>텔레그램</category>
      <category>AI에이전트</category>
      <category>자동화</category>
      <category>Series: OpenClaw Guide</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Building a Desktop App with Go Wails</title>
      <link>https://blog.advenoh.pe.kr/en/go-wails-desktop-app</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/go-wails-desktop-app</guid>
      <pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate>
      <description>How to build a native desktop app with a Go backend + React frontend using the Wails v2 framework, walked through with a Todo app example</description>
      <content:encoded><![CDATA[<blockquote>
<p>One of our teammates built an internal debugging tool with Wails. It turned out so clean, and it was built in Golang — which we're already comfortable with — that I thought this would be a good opportunity to study Wails and write it up on the blog.</p>
</blockquote>
<p>Building web servers or CLI tools in Go is familiar, but what about <strong>desktop apps</strong>? <a href="https://wails.io/">Wails</a> is a framework that lets you build cross-platform desktop applications by combining a Go backend with a web frontend (React, Vue, Svelte, etc.).</p>
<p>Unlike Electron, which bundles Chromium, it uses the <strong>OS's native WebView</strong>, so the binary size is small and memory usage is low. What's appealing is that you can build native-level desktop apps while leveraging the web technologies Go developers already know.</p>
<p>In this article, we'll build a <strong>Todo app</strong> with Wails v2, covering the whole process — from project creation to Go-JavaScript binding, using native features, and building.</p>
<blockquote>
<p>The full example code is available on <a href="https://github.com/kenshin579/tutorials-go/tree/master/desktop/wails-todo">GitHub</a>.</p>
</blockquote>
<h1>1. Introduction</h1>
<h2>1.1 What Is Wails?</h2>
<p>Wails is a framework that lets you build desktop apps in Go. The core idea is simple:</p>
<ul>
<li><strong>Backend</strong>: write business logic in Go</li>
<li><strong>Frontend</strong>: write the UI with web technologies such as React, Vue, Svelte</li>
<li><strong>Binding</strong>: call Go methods directly from JavaScript</li>
</ul>
<p>The Go code and the web frontend are compiled into a single <strong>single binary</strong>, and the frontend assets are embedded into the binary with <code>go:embed</code>.</p>
<h2>1.2 Comparison with Electron and Tauri</h2>
<p>When choosing a desktop app framework, you often end up comparing Wails, Electron, and Tauri.</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Wails</th>
<th>Electron</th>
<th>Tauri</th>
</tr>
</thead>
<tbody>
<tr>
<td>Backend language</td>
<td>Go</td>
<td>Node.js</td>
<td>Rust</td>
</tr>
<tr>
<td>Rendering</td>
<td>OS WebView</td>
<td>bundled Chromium</td>
<td>OS WebView</td>
</tr>
<tr>
<td>Binary size</td>
<td>~10MB</td>
<td>~150MB+</td>
<td>~5MB</td>
</tr>
<tr>
<td>Memory usage</td>
<td>low</td>
<td>high (100-200MB)</td>
<td>low</td>
</tr>
<tr>
<td>Learning curve</td>
<td>low (Go + Web)</td>
<td>low (JS)</td>
<td>high (Rust)</td>
</tr>
<tr>
<td>Ecosystem</td>
<td>small</td>
<td>very large</td>
<td>moderate</td>
</tr>
</tbody>
</table>
<blockquote>
<p>An <strong>OS WebView</strong> is the web browser component the operating system provides by default. macOS uses WKWebView (Safari engine), Windows uses WebView2 (Edge engine), and Linux uses WebKitGTK. Electron bundles all of Chromium into the app, but Wails and Tauri reuse the WebView already installed on the OS, making them much lighter.</p>
</blockquote>
<p>Tauri is Rust-based and has the smallest binary, but Rust has a steep learning curve. If you're already using Go, Wails is the option with the lowest barrier to entry.</p>
<h2>1.3 Wails v2 vs v3</h2>
<p>Currently Wails exists in two versions: <strong>v2 (stable)</strong> and <strong>v3 (Alpha)</strong>.</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>v2 (stable)</th>
<th>v3 (Alpha)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Status</td>
<td>production-ready</td>
<td>under development</td>
</tr>
<tr>
<td>Multi-window</td>
<td>not supported</td>
<td>supported</td>
</tr>
<tr>
<td>System tray</td>
<td>not supported</td>
<td>supported</td>
</tr>
<tr>
<td>Frontend</td>
<td>React, Vue, Svelte, etc.</td>
<td>same + improvements</td>
</tr>
</tbody>
</table>
<p>This article uses <strong>v2</strong>. v3's API may still change, so if you need stability, v2 is recommended.</p>
<h1>2. Environment Setup</h1>
<h2>2.1 Prerequisites</h2>
<p>To use Wails, you need the following:</p>
<ul>
<li><strong>Go</strong> 1.20 or higher</li>
<li><strong>Node.js</strong> 15 or higher + npm</li>
<li><strong>Platform-specific dependencies</strong>:
<ul>
<li>macOS: Xcode Command Line Tools</li>
<li>Linux: <code>gtk3</code>, <code>webkit2gtk</code> packages</li>
<li>Windows: WebView2 Runtime (included by default on Windows 11)</li>
</ul>
</li>
</ul>
<h2>2.2 Installing the Wails CLI</h2>
<pre><code class="language-bash">go install github.com/wailsapp/wails/v2/cmd/wails@latest
</code></pre>
<p>After installation, check your environment with <code>wails doctor</code>:</p>
<pre><code class="language-bash">$ wails doctor

# System
┌──────────────────────────────────────────────────────────────────┐
| OS           | Darwin                                           |
| Version      | 15.5.0                                           |
| ID           | macOS Sequoia                                    |
| Go Version   | go1.24.0                                         |
| Platform     | darwin                                           |
| Architecture | arm64                                            |
├──────────────────────────────────────────────────────────────────┤
| Wails CLI    | v2.11.0                                          |
└──────────────────────────────────────────────────────────────────┘
# Dependencies
┌──────────────────────────────────────────────────────────────────┐
| Dependency | Package Name | Status    | Version                 |
| Xcode CLI  | N/A          | Installed | 2508                    |
| npm        | N/A          | Installed | 11.4.1                  |
| node       | N/A          | Installed | 24.1.0                  |
└──────────────────────────────────────────────────────────────────┘
</code></pre>
<h2>2.3 Creating a Project</h2>
<p>Create a new project with the <code>wails init</code> command. You can specify the frontend template with the <code>-t</code> option.</p>
<pre><code class="language-bash">$ wails init -n wails-todo -t react-ts

Wails CLI v2.11.0

# Initialising Project 'wails-todo'
Project Name      | wails-todo
Project Directory | /path/to/wails-todo
Template          | React + Vite (Typescript)
Template Source   | https://wails.io

Initialised project 'wails-todo' in 264ms.
</code></pre>
<p>Available templates: <code>react</code>, <code>react-ts</code>, <code>vue</code>, <code>vue-ts</code>, <code>svelte</code>, <code>svelte-ts</code>, <code>vanilla</code>, etc.</p>
<p>You can run the generated project right away:</p>
<pre><code class="language-bash">$ cd wails-todo
$ wails dev

Wails CLI v2.11.0

Executing: go mod tidy
  • Generating bindings: Done.
  • Installing frontend dependencies: Done.
  • Compiling frontend: Done.

> frontend@0.0.0 dev
> vite

  VITE v3.2.11  ready in 144 ms

Vite Server URL: http://localhost:5173/
  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
Running frontend DevWatcher command: 'npm run dev'
Building application for development...
  • Generating bindings: Done.
  • Compiling application: Done.
  • Packaging application: Done.

Using DevServer URL: http://localhost:34115
Using Frontend DevServer URL: http://localhost:5173/
Watching (sub)/directory: /path/to/wails-todo

To develop in the browser and call your bound Go methods from Javascript, navigate to: http://localhost:34115
</code></pre>
<p><code>wails dev</code> is a development mode that supports frontend HMR (Hot Module Replacement) and automatic Go rebuilds at the same time.</p>
<h1>3. Project Structure</h1>
<h2>3.1 Directory Layout</h2>
<p>The Todo app in this article has the following structure:</p>
<pre><code>wails-todo/
├── build/                  # build settings (icon, app info)
│   ├── appicon.png
│   └── darwin/             # macOS-specific settings
├── backend/                # Go backend logic
│   ├── app.go              # App struct, Wails binding methods
│   ├── todo.go             # Todo struct, JSON file storage
│   └── menu.go             # system menu settings
├── frontend/               # web frontend (React + TypeScript)
│   ├── src/
│   │   ├── App.tsx         # main component
│   │   ├── components/     # UI components
│   │   └── App.css         # styles
│   ├── index.html
│   ├── package.json
│   └── wailsjs/            # auto-generated Go bindings
│       ├── go/backend/     # JS/TS for calling Go methods
│       └── runtime/        # Wails runtime API
├── main.go                 # app entry point
├── wails.json              # Wails project settings
└── go.mod
</code></pre>
<h2>3.2 Embedding the Frontend with go:embed</h2>
<p>One of Wails's core mechanisms is embedding frontend assets using <code>go:embed</code>. You declare it in <code>main.go</code> like this:</p>
<pre><code class="language-go">//go:embed all:frontend/dist
var assets embed.FS
</code></pre>
<p>At build time, the frontend build output generated in <code>frontend/dist/</code> is included in the Go binary. So when deploying, you can ship a <strong>single executable</strong> without needing separate static files.</p>
<h2>3.3 wails.json Settings</h2>
<pre><code class="language-json">{
  "$schema": "https://wails.io/schemas/config.v2.json",
  "name": "wails-todo",
  "outputfilename": "wails-todo",
  "frontend:install": "npm install",
  "frontend:build": "npm run build",
  "frontend:dev:watcher": "npm run dev",
  "frontend:dev:serverUrl": "auto",
  "author": {
    "name": "Frank Oh",
    "email": "kenshin579@hotmail.com"
  }
}
</code></pre>
<p>Key settings:</p>
<ul>
<li><code>frontend:install</code>: command to install frontend dependencies</li>
<li><code>frontend:build</code>: command to build the frontend</li>
<li><code>frontend:dev:watcher</code>: command to run the frontend dev server in development mode</li>
<li><code>frontend:dev:serverUrl</code>: when set to <code>auto</code>, Wails auto-detects the dev server URL</li>
</ul>
<h1>4. Go-JavaScript Binding</h1>
<p>The core feature of Wails is that you can <strong>call Go methods directly from JavaScript</strong>.</p>
<h2>4.1 How It Works</h2>
<pre><code class="language-mermaid">sequenceDiagram
    participant JS as Frontend (React)
    participant W as Wails Runtime
    participant Go as Backend (Go)

    JS->>W: AddTodo("a task")
    W->>Go: App.AddTodo("a task")
    Go-->>W: Todo{id, title, done}
    W-->>JS: Promise&#x3C;Todo>
</code></pre>
<p>The binding works as follows:</p>
<p><strong>Step 1: Register the Go struct with <code>Bind</code></strong></p>
<p>In <code>main.go</code>, register the Go struct you want to expose to the frontend.</p>
<pre><code class="language-go">// main.go
err := wails.Run(&#x26;options.App{
    Bind: []interface{}{
        app,  // register the backend.App struct
    },
})
</code></pre>
<p><strong>Step 2: Wails auto-generates the JS/TS binding code</strong></p>
<p>When you run <code>wails dev</code> or <code>wails build</code>, Wails analyzes the public methods of the registered struct and generates binding code in the <code>frontend/wailsjs/go/</code> directory. For example, for the <code>App.AddTodo()</code> method, the following JS code is auto-generated:</p>
<pre><code class="language-js">// frontend/wailsjs/go/backend/App.js (auto-generated)
export function AddTodo(arg1) {
  return window['go']['backend']['App']['AddTodo'](arg1);
}

export function GetTodos() {
  return window['go']['backend']['App']['GetTodos']();
}
</code></pre>
<p>The key is the <code>window['go']</code> object. The Wails runtime injects this global object into the WebView at app startup, and when you call a Go method from JS through this object, it is passed to the Go backend internally via IPC (inter-process communication). TypeScript type definitions are also generated, so you can call them in a type-safe manner:</p>
<pre><code class="language-ts">// frontend/wailsjs/go/backend/App.d.ts (auto-generated)
export function AddTodo(arg1:string):Promise&#x3C;backend.Todo>;
export function GetTodos():Promise&#x3C;Array&#x3C;backend.Todo>>;
export function DeleteTodo(arg1:string):Promise&#x3C;Array&#x3C;backend.Todo>>;
</code></pre>
<p><strong>Step 3: Import and call from the frontend</strong></p>
<p>Once you import the auto-generated bindings, you can call Go methods just like regular async functions.</p>
<pre><code class="language-tsx">import { GetTodos, AddTodo } from "../wailsjs/go/backend/App";

const handleAdd = async (title: string) => {
    await AddTodo(title);  // call Go's App.AddTodo()
    loadTodos();
};
</code></pre>
<blockquote>
<p>Without having to implement an HTTP API or WebSocket yourself, Wails analyzes the Go method signatures and handles everything automatically — generating the binding code → injecting the <code>window['go']</code> object → the IPC communication.</p>
</blockquote>
<h2>4.2 Event System</h2>
<p>You can send events from Go to the frontend, or receive events on the frontend.</p>
<p><strong>Go → JavaScript event emit:</strong></p>
<pre><code class="language-go">// emit an event from Go
runtime.EventsEmit(a.ctx, "todos:reload")
</code></pre>
<p><strong>Receiving an event in JavaScript:</strong></p>
<pre><code class="language-tsx">import { EventsOn } from "../wailsjs/runtime/runtime";

useEffect(() => {
    // receive the event sent from Go
    EventsOn("todos:reload", loadTodos);
}, []);
</code></pre>
<p>The event system is useful for <strong>one-way notifications</strong> that are hard to handle with binding calls (e.g. refreshing the UI after loading a file from the menu).</p>
<h1>5. Hands-On Example: Todo App</h1>
<p>This is the running screen of the finished Todo app. You can add Todo items, mark them complete with a checkbox, and delete them.</p>
<p><img src="https://blog.advenoh.pe.kr/images/go/go-wails-desktop-app/wails-todo-with-items.png" alt="Wails Todo app running screen"></p>
<h2>5.1 Backend Implementation (Go)</h2>
<h3>5.1.1 Todo Struct and Store</h3>
<p>Define a simple struct that saves and loads Todo data as a JSON file.</p>
<pre><code class="language-go">// backend/todo.go
type Todo struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"createdAt"`
}

type TodoStore struct {
    filePath string
    Todos    []Todo `json:"todos"`
}
</code></pre>
<p><code>TodoStore</code> is a simple store that reads and writes a JSON file with <code>os.ReadFile</code>/<code>os.WriteFile</code>. The full code is available on <a href="https://github.com/kenshin579/tutorials-go/blob/master/desktop/wails-todo/backend/todo.go">GitHub</a>.</p>
<h3>5.1.2 CRUD Methods</h3>
<p>The public methods of the <code>App</code> struct are exposed to the frontend through Wails binding. Taking <code>AddTodo</code> as an example, you write it just like a regular Go method.</p>
<pre><code class="language-go">// backend/app.go
func (a *App) AddTodo(title string) Todo {
    todo := Todo{
        ID:        fmt.Sprintf("%d", time.Now().UnixNano()),
        Title:     title,
        Done:      false,
        CreatedAt: time.Now(),
    }
    a.store.Todos = append(a.store.Todos, todo)
    a.store.Save()
    return todo
}
</code></pre>
<p>Once you write this method, you can call it directly from the frontend as <code>AddTodo("a task")</code>. For the parameter (<code>string</code>) and return value (<code>Todo</code>), Wails handles JSON serialization/deserialization automatically. The remaining <code>GetTodos</code>, <code>ToggleTodo</code>, and <code>DeleteTodo</code> methods work the same way. The full code is available on <a href="https://github.com/kenshin579/tutorials-go/blob/master/desktop/wails-todo/backend/app.go">GitHub</a>.</p>
<h2>5.2 Frontend Implementation (React + TypeScript)</h2>
<p>On the frontend, you import the auto-generated bindings and call Go methods. The difference from a typical React app is that instead of API calls, you <strong>call the binding functions directly</strong>.</p>
<pre><code class="language-tsx">// frontend/src/App.tsx
import { GetTodos, AddTodo, ToggleTodo, DeleteTodo } from "../wailsjs/go/backend/App";
import { EventsOn } from "../wailsjs/runtime/runtime";

function App() {
    const [todos, setTodos] = useState&#x3C;Todo[]>([]);

    useEffect(() => {
        loadTodos();
        EventsOn("todos:reload", loadTodos);  // receive Go events
    }, []);

    const handleAdd = async (title: string) => {
        await AddTodo(title);  // call the Go method
        loadTodos();
    };
    // ...
}
</code></pre>
<p>Without an HTTP call like <code>fetch('/api/todos')</code>, calling a function directly like <code>AddTodo(title)</code> lets the Wails runtime communicate with the Go backend on its own. The UI components (<code>TodoInput</code>, <code>TodoItem</code>, etc.) are the same as regular React components. The full frontend code is available on <a href="https://github.com/kenshin579/tutorials-go/tree/master/desktop/wails-todo/frontend/src">GitHub</a>.</p>
<h2>5.3 App Entry Point</h2>
<p>In <code>main.go</code>, configure and run the Wails app.</p>
<pre><code class="language-go">// main.go
package main

import (
    "embed"

    "wails-todo/backend"

    "github.com/wailsapp/wails/v2"
    "github.com/wailsapp/wails/v2/pkg/options"
    "github.com/wailsapp/wails/v2/pkg/options/assetserver"
)

//go:embed all:frontend/dist
var assets embed.FS

func main() {
    app := backend.NewApp()

    err := wails.Run(&#x26;options.App{
        Title:  "Wails Todo",
        Width:  800,
        Height: 600,
        AssetServer: &#x26;assetserver.Options{
            Assets: assets,
        },
        BackgroundColour: &#x26;options.RGBA{R: 27, G: 38, B: 54, A: 1},
        OnStartup:        app.Startup,
        Menu:              app.CreateMenu(),
        Bind: []interface{}{
            app,
        },
    })
    if err != nil {
        println("Error:", err.Error())
    }
}
</code></pre>
<p>Key options:</p>
<ul>
<li><code>Title</code>, <code>Width</code>, <code>Height</code>: window title and size</li>
<li><code>AssetServer</code>: frontend assets (go:embed-ed files)</li>
<li><code>OnStartup</code>: callback invoked at app startup (passes context)</li>
<li><code>Menu</code>: system menu settings</li>
<li><code>Bind</code>: list of Go structs to expose to the frontend</li>
</ul>
<h2>5.4 Development Mode and Hot Reload</h2>
<pre><code class="language-bash">wails dev
</code></pre>
<p><code>wails dev</code> runs the following two things simultaneously:</p>
<ul>
<li><strong>Frontend</strong>: Vite dev server (with HMR support)</li>
<li><strong>Backend</strong>: automatic rebuild when Go source changes are detected</li>
</ul>
<p>When you modify frontend code, it is reflected immediately without a browser refresh, and when you modify Go code, the app restarts automatically.</p>
<h1>6. Using Native Features</h1>
<p>Wails provides various native features through the <code>runtime</code> package.</p>
<h2>6.1 File Dialog</h2>
<p>You can use the OS's native file dialog to open or save files.</p>
<pre><code class="language-go">// backend/app.go

// Export: save dialog
func (a *App) ExportTodos() error {
    path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
        Title:           "Export Todo list",
        DefaultFilename: "todos.json",
        Filters: []runtime.FileFilter{
            {DisplayName: "JSON Files", Pattern: "*.json"},
        },
    })
    if err != nil || path == "" {
        return err
    }
    return a.store.ExportToFile(path)
}

// Import: open dialog
func (a *App) ImportTodos() ([]Todo, error) {
    path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
        Title: "Import Todo list",
        Filters: []runtime.FileFilter{
            {DisplayName: "JSON Files", Pattern: "*.json"},
        },
    })
    if err != nil || path == "" {
        return a.store.Todos, err
    }
    if err := a.store.LoadFromFile(path); err != nil {
        return a.store.Todos, err
    }
    return a.store.Todos, nil
}
</code></pre>
<p>With <code>SaveDialogOptions</code> and <code>OpenDialogOptions</code>, you can set the title, default filename, file filters, and more.</p>
<h2>6.2 System Menu</h2>
<p>Define the system menu displayed at the top of the app in Go code.</p>
<pre><code class="language-go">// backend/menu.go
func (a *App) CreateMenu() *menu.Menu {
    appMenu := menu.NewMenu()

    fileMenu := appMenu.AddSubmenu("File")
    fileMenu.AddText("Import...", keys.CmdOrCtrl("o"), func(_ *menu.CallbackData) {
        a.ImportTodos()
        runtime.EventsEmit(a.ctx, "todos:reload")
    })
    fileMenu.AddText("Export...", keys.CmdOrCtrl("s"), func(_ *menu.CallbackData) {
        a.ExportTodos()
    })
    fileMenu.AddSeparator()
    fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
        runtime.Quit(a.ctx)
    })

    return appMenu
}
</code></pre>
<ul>
<li><code>keys.CmdOrCtrl("o")</code>: Cmd+O on macOS, Ctrl+O on Windows/Linux</li>
<li><code>AddSeparator()</code>: a divider between menu items</li>
<li>after importing, emit a reload event to the frontend with <code>EventsEmit</code></li>
</ul>
<h2>6.3 Dialogs</h2>
<p>You can display a native dialog with <code>runtime.MessageDialog</code>.</p>
<pre><code class="language-go">result, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
    Type:          runtime.QuestionDialog,
    Title:         "Confirm deletion",
    Message:       fmt.Sprintf("Do you want to delete '%s'?", t.Title),
    DefaultButton: "No",
})
if err == nil &#x26;&#x26; result == "Yes" {
    // handle deletion
}
</code></pre>
<p>Dialog types:</p>
<ul>
<li><code>runtime.InfoDialog</code>: information notice</li>
<li><code>runtime.WarningDialog</code>: warning</li>
<li><code>runtime.ErrorDialog</code>: error</li>
<li><code>runtime.QuestionDialog</code>: yes/no confirmation</li>
</ul>
<h2>6.4 Window Control</h2>
<p>You can dynamically control window properties with the <code>runtime</code> package.</p>
<pre><code class="language-go">// change the window title
runtime.WindowSetTitle(a.ctx, "New title")

// change the window size
runtime.WindowSetSize(a.ctx, 1024, 768)

// minimize/maximize the window
runtime.WindowMinimise(a.ctx)
runtime.WindowMaximise(a.ctx)

// fullscreen
runtime.WindowFullscreen(a.ctx)
</code></pre>
<h1>7. Build and Distribution</h1>
<h2>7.1 Production Build</h2>
<pre><code class="language-bash">$ wails build

Wails CLI v2.11.0

# Build Options
Platform(s)        | darwin/arm64
Compiler           | /usr/local/go/bin/go
Build Mode         | production
Frontend Directory | /path/to/wails-todo/frontend
Package            | true

# Building target: darwin/arm64
  • Generating bindings: Done.
  • Installing frontend dependencies: Done.
  • Compiling frontend: Done.
  • Compiling application: Done.
  • Packaging application: Done.
Built 'build/bin/wails-todo.app/Contents/MacOS/wails-todo' in 3.506s.
</code></pre>
<p>When the build finishes, an executable is created in the <code>build/bin/</code> directory:</p>
<ul>
<li><strong>macOS</strong>: <code>wails-todo.app</code> (app bundle)</li>
<li><strong>Windows</strong>: <code>wails-todo.exe</code></li>
<li><strong>Linux</strong>: <code>wails-todo</code> (binary)</li>
</ul>
<pre><code class="language-bash">$ ls -lh build/bin/wails-todo.app/Contents/MacOS/wails-todo
-rwxr-xr-x  1 user  staff   7.5M  Mar  7 13:30 wails-todo

$ du -sh build/bin/wails-todo.app
7.7M	build/bin/wails-todo.app
</code></pre>
<p>The actual binary size is about <strong>7.5MB</strong>, which is very small compared to an Electron app (~150MB+).</p>
<h2>7.2 Per-Platform Builds</h2>
<p>Wails builds for the current OS by default. When you need cross-compilation:</p>
<pre><code class="language-bash"># build for Windows on macOS
GOOS=windows wails build

# debug build (developer tools enabled)
wails build -debug
</code></pre>
<p>Note that cross-compilation may require the target platform's C compiler. Building for Windows on macOS requires <code>mingw-w64</code>.</p>
<h2>7.3 Optimizing Binary Size</h2>
<pre><code class="language-bash"># UPX compression (optional)
wails build -upx

# remove debug info with build flags
wails build -ldflags "-s -w"
</code></pre>
<h1>8. FAQ</h1>
<h2>8.1 What's the Scope of What You Can Build with Wails?</h2>
<p>Wails is suitable for building <strong>single-window desktop applications</strong>. Specifically:</p>
<ul>
<li><strong>Suitable cases</strong>: window-based desktop apps such as internal tools, data viewers, settings management apps, file-processing utilities, dashboards</li>
<li><strong>Possible but limited cases</strong>: v2 does not support multi-window or system tray (planned for v3)</li>
<li><strong>Unsuitable cases</strong>: games, high-performance graphics apps, system-level utilities (cases that need direct access to OS APIs)</li>
</ul>
<p>Since the frontend is WebView-based, most things you can do on the web are possible. On top of that, you can add native features through the Go backend, such as file system access, network communication, and database connections.</p>
<h2>8.2 Can I Also Build macOS Desktop Widgets?</h2>
<p><strong>No.</strong> macOS widgets (Notification Center, desktop widgets) can only be built with Apple's <strong>WidgetKit + SwiftUI</strong>, and they use a separate architecture called App Extension. Wails is a WebView-based standalone window app, so it does not support widget development.</p>
<p>If you need a feature that stays resident on the desktop, you can use the <strong>system tray</strong> (menu bar icon) planned for v3.</p>
<h1>9. Wrapping Up</h1>
<p>In this article, we looked at the process of building a Go + React based Todo desktop app using Wails v2. To summarize:</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Content</th>
</tr>
</thead>
<tbody>
<tr>
<td>Go-JS binding</td>
<td>Go public methods → automatically converted to JS Promise functions</td>
</tr>
<tr>
<td>Event system</td>
<td>two-way communication with <code>EventsEmit</code> / <code>EventsOn</code></td>
</tr>
<tr>
<td>Native features</td>
<td>file dialog, system menu, confirmation dialog</td>
</tr>
<tr>
<td>Asset embedding</td>
<td>include the frontend in a single binary with <code>go:embed</code></td>
</tr>
<tr>
<td>Binary size</td>
<td>~7.5MB (1/20 of Electron)</td>
</tr>
<tr>
<td>Developer experience</td>
<td>HMR + automatic Go rebuild with <code>wails dev</code></td>
</tr>
</tbody>
</table>
<p>Wails is a great option for Go developers to build lightweight, fast desktop apps using web technologies. If Electron's heavy binary is a burden and learning Rust from scratch is daunting, Wails is worth considering.</p>
<h1>10. References</h1>
<ul>
<li><a href="https://wails.io/ko/docs/introduction">Wails Official Docs</a></li>
<li><a href="https://wails.io/ko/docs/reference/runtime/intro">Wails v2 API Reference</a></li>
<li><a href="https://github.com/wailsapp/wails">Wails GitHub</a></li>
<li><a href="https://pkg.go.dev/embed">Go embed package</a></li>
<li><a href="https://github.com/kenshin579/tutorials-go/tree/master/desktop/wails-todo">Full example code - GitHub</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>wails</category>
      <category>desktop</category>
      <category>react</category>
      <category>typescript</category>
      <category>cross-platform</category>
      <category>webview</category>
      <category>고랭</category>
      <category>데스크톱</category>
      <category>크로스플랫폼</category>
      <category>웹뷰</category>
      <category>바인딩</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
    <item>
      <title>Golang Concurrency Part 1 - Overview and Goroutine Basics</title>
      <link>https://blog.advenoh.pe.kr/en/golang-concurrency-1-goroutine-기초</link>
      <guid isPermaLink="true">https://blog.advenoh.pe.kr/en/golang-concurrency-1-goroutine-기초</guid>
      <pubDate>Sat, 07 Mar 2026 00:00:00 GMT</pubDate>
      <description>Covers the fundamental concepts of Go concurrency — Concurrency vs Parallelism, the CSP model, the GMP scheduler, and goroutine basics</description>
      <content:encoded><![CDATA[<p>One of the biggest features that sets Go apart from other languages is its support for <strong>concurrency</strong>. Go provides goroutines and channels at the language level, designed so that you can write complex concurrent programming concisely and safely.</p>
<p>In this series, we cover Go concurrency step by step, from the basics to real-world use. In this first part, we look at the basic concepts of concurrency and Go's core unit of execution, the <strong>goroutine</strong>.</p>
<h2>1. Concurrency vs Parallelism</h2>
<p>Concurrency and parallelism are often confused, but they are different concepts.</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Concurrency</th>
<th>Parallelism</th>
</tr>
</thead>
<tbody>
<tr>
<td>Definition</td>
<td>a structure for <strong>dealing with</strong> multiple tasks at once</td>
<td><strong>executing</strong> multiple tasks at the same time</td>
</tr>
<tr>
<td>Core</td>
<td>task <strong>composition</strong></td>
<td>task <strong>execution</strong></td>
</tr>
<tr>
<td>CPU</td>
<td>possible even on a single CPU</td>
<td>requires multiple CPUs</td>
</tr>
<tr>
<td>Analogy</td>
<td>one person alternating between several tasks</td>
<td>several people each doing their task simultaneously</td>
</tr>
</tbody>
</table>
<pre><code class="language-mermaid">graph LR
    subgraph "Concurrency (one CPU)"
        A1[Task A] --> B1[Task B] --> A2[Task A] --> B2[Task B]
    end
</code></pre>
<pre><code class="language-mermaid">graph LR
    subgraph "Parallelism (multiple CPUs)"
        C1[CPU 1: Task A] --> C1E[ ]
        C2[CPU 2: Task B] --> C2E[ ]
    end
    style C1E fill:none,stroke:none
    style C2E fill:none,stroke:none
</code></pre>
<p>Go's creator Rob Pike explains it this way:</p>
<blockquote>
<p><strong>"Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once."</strong>
— Rob Pike</p>
</blockquote>
<p>In Go, concurrency is about the <strong>structure</strong> of a program. Separating code into independently executable units is concurrency, and actually running them simultaneously on multiple CPUs is parallelism. If you design a Go program to be concurrent, the runtime takes care of leveraging parallelism.</p>
<h2>2. Why Is Go Strong at Concurrency?</h2>
<h3>The CSP Model</h3>
<p>Go's concurrency model is based on <strong>CSP (Communicating Sequential Processes)</strong>. The core of this model, proposed by Tony Hoare in 1978, is that independent processes communicate through <strong>message passing</strong>.</p>
<p>In Go, this is implemented with <strong>goroutines</strong> (independent units of execution) and <strong>channels</strong> (the means of message passing).</p>
<h3>Go's Concurrency Philosophy</h3>
<blockquote>
<p><strong>"Do not communicate by sharing memory; instead, share memory by communicating."</strong>
— Go Proverb</p>
</blockquote>
<p>In traditional multithreaded programming, access to shared memory is protected with locks (mutexes). This approach is prone to problems such as deadlocks and race conditions.</p>
<p>Go recommends <strong>passing data through channels</strong>. Because ownership of the data is transferred through the channel, only one goroutine accesses the data at a given moment.</p>
<pre><code class="language-mermaid">graph LR
    subgraph "Traditional approach (Shared Memory + Lock)"
        TA[Thread A] --> SD[Shared Data&#x3C;br/>+ Lock]
        TB[Thread B] --> SD
    end
</code></pre>
<pre><code class="language-mermaid">graph LR
    subgraph "Go approach (Message Passing)"
        GA[Goroutine A&#x3C;br/>owns data] -- "channel (passing)" --> GB[Goroutine B&#x3C;br/>receives data]
    end
</code></pre>
<h2>3. When Should You Use Concurrency?</h2>
<h3>Cases Where It's a Good Fit</h3>
<ul>
<li><strong>I/O-heavy work</strong>: HTTP requests, DB queries, file read/write</li>
<li><strong>Parallel processing of independent tasks</strong>: calling multiple APIs simultaneously</li>
<li><strong>Event-based processing</strong>: handling requests in a web server</li>
<li><strong>Pipeline processing</strong>: chaining data transformation stages</li>
</ul>
<h3>Cases Where You Shouldn't Use It (Over-Engineering)</h3>
<ul>
<li><strong>When simple sequential processing is enough</strong>: simple data transformations</li>
<li><strong>When you create excessive goroutines for CPU-bound work</strong></li>
<li><strong>When there's so much shared state that locks become complex</strong>: in this case, reconsider the design</li>
<li><strong>When it becomes so complex that debugging is hard</strong>: concurrency adds complexity</li>
</ul>
<h2>4. Goroutine Basics</h2>
<h3>What Is a Goroutine?</h3>
<p>A goroutine is a <strong>lightweight unit of execution</strong> managed by the Go runtime. Putting the <code>go</code> keyword in front of a function call creates a new goroutine.</p>
<pre><code class="language-go">// create a goroutine - using the go keyword
go func() {
    fmt.Println("goroutine ran")
}()

// named functions work too
go sayHello("World")
</code></pre>
<h3>Goroutine vs OS Thread</h3>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Goroutine</th>
<th>OS Thread</th>
</tr>
</thead>
<tbody>
<tr>
<td>Initial stack size</td>
<td>~2KB (grows dynamically)</td>
<td>~1MB (fixed)</td>
</tr>
<tr>
<td>Creation cost</td>
<td>very cheap</td>
<td>relatively expensive</td>
</tr>
<tr>
<td>Scheduling</td>
<td>Go runtime (user space)</td>
<td>OS kernel</td>
</tr>
<tr>
<td>Concurrent count</td>
<td>hundreds of thousands possible</td>
<td>thousands range</td>
</tr>
<tr>
<td>Context switching</td>
<td>fast (3 registers)</td>
<td>slow (all registers)</td>
</tr>
</tbody>
</table>
<p>Goroutines are <strong>multiplexed</strong> on top of OS threads. Thousands to tens of thousands of goroutines run efficiently on a small number of OS threads.</p>
<h3>Execution Order Is Non-Deterministic</h3>
<p>The execution order of goroutines is <strong>not guaranteed</strong>. In the code below, you should not expect the numbers to be printed in order.</p>
<pre><code class="language-go">func TestGoroutineNonDeterministicOrder(t *testing.T) {
    var mu sync.Mutex
    var order []int
    var wg sync.WaitGroup

    const numGoroutines = 10
    wg.Add(numGoroutines)

    for i := range numGoroutines {
        go func() {
            defer wg.Done()
            mu.Lock()
            order = append(order, i)
            mu.Unlock()
        }()
    }

    wg.Wait()
    t.Logf("execution order: %v", order)
    // example output: execution order: [1 4 2 3 5 9 8 0 6 7]
}
</code></pre>
<h3>main goroutine and lifecycle</h3>
<p>In a Go program, the <code>main()</code> function runs in the <strong>main goroutine</strong>. When the main goroutine terminates, <strong>the entire program terminates</strong> regardless of whether other goroutines have finished.</p>
<pre><code class="language-go">func TestMainExitKillsGoroutines(t *testing.T) {
    var completed atomic.Bool

    go func() {
        time.Sleep(100 * time.Millisecond) // time-consuming work
        completed.Store(true)
    }()

    // if you don't wait, the goroutine won't complete
    assert.False(t, completed.Load())
}
</code></pre>
<p>To wait until a goroutine completes, you need to use <code>sync.WaitGroup</code> or a <code>channel</code>.</p>
<pre><code class="language-go">func TestWaitGroupSolution(t *testing.T) {
    var completed atomic.Bool
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(50 * time.Millisecond)
        completed.Store(true)
    }()

    wg.Wait() // wait until the goroutine completes
    assert.True(t, completed.Load())
}
</code></pre>
<h3>Creating Tens of Thousands of Goroutines</h3>
<p>Goroutines are so lightweight that creating tens of thousands of them is no problem.</p>
<pre><code class="language-go">func TestGoroutineLightweight(t *testing.T) {
    const numGoroutines = 10000
    var counter atomic.Int64
    var wg sync.WaitGroup
    wg.Add(numGoroutines)

    for range numGoroutines {
        go func() {
            defer wg.Done()
            counter.Add(1)
        }()
    }

    wg.Wait()
    assert.Equal(t, int64(numGoroutines), counter.Load())
    // all 10000 goroutines completed
}
</code></pre>
<h2>5. Comparison with Other Languages</h2>
<p>To better understand the characteristics of goroutines, let's compare them with Kotlin Coroutines and Java Threads.</p>
<h3>Overall Comparison</h3>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Go Goroutine</th>
<th>Kotlin Coroutine</th>
<th>Java Platform Thread</th>
<th>Java Virtual Thread (21+)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Stack size</td>
<td>~2KB (grows dynamically)</td>
<td>stackless (heap object)</td>
<td>~1MB (fixed)</td>
<td>~a few KB (dynamic)</td>
</tr>
<tr>
<td>Scheduling</td>
<td>Go runtime (preemptive)</td>
<td>cooperative (suspend/resume)</td>
<td>OS kernel</td>
<td>JVM (cooperative)</td>
</tr>
<tr>
<td>Creation cost</td>
<td>very cheap</td>
<td>very cheap</td>
<td>expensive</td>
<td>cheap</td>
</tr>
<tr>
<td>Concurrent count</td>
<td>hundreds of thousands</td>
<td>hundreds of thousands</td>
<td>thousands</td>
<td>millions</td>
</tr>
<tr>
<td>Communication</td>
<td>Channel (CSP)</td>
<td>Flow, Channel</td>
<td>synchronized, Lock</td>
<td>synchronized, Lock</td>
</tr>
</tbody>
</table>
<h3>Goroutine vs Kotlin Coroutine</h3>
<p><strong>The biggest difference is the scheduling method.</strong></p>
<p>Go's goroutines use <strong>preemptive</strong> scheduling. If a goroutine holds the CPU for a long time, the Go runtime forcibly switches it (Go 1.14+). In contrast, Kotlin coroutines use <strong>cooperative</strong> scheduling, where switching happens only at <code>suspend</code> points.</p>
<pre><code class="language-kotlin">// Kotlin - suspension points occur only in suspend functions
suspend fun fetchData() {
    delay(1000)  // yields here
    // CPU work without suspend does not yield
}
</code></pre>
<pre><code class="language-go">// Go - the runtime switches automatically with no special keyword
func fetchData() {
    time.Sleep(time.Second)
    // the runtime forcibly switches even CPU-bound work
}
</code></pre>
<p>The <strong>function coloring problem</strong> is another important difference.</p>
<ul>
<li><strong>Kotlin</strong>: a <code>suspend</code> function can only be called within a <code>suspend</code> function or a coroutine. To convert existing synchronous code to asynchronous, you may have to propagate <code>suspend</code> throughout the entire call chain</li>
<li><strong>Go</strong>: all functions are the same. You can call any function from a goroutine, and there's no <code>async/suspend</code> distinction</li>
</ul>
<p>On the other hand, Kotlin has some advantages too:</p>
<ul>
<li><strong>Structured Concurrency</strong> built in — when a parent coroutine is canceled, its children are automatically canceled too</li>
<li><strong>Error propagation</strong> is systematic — consistent handling is possible with <code>CoroutineExceptionHandler</code></li>
</ul>
<h3>Goroutine vs Java Thread</h3>
<p>A traditional Java Platform Thread maps 1:1 to an OS thread and takes up a ~1MB stack. Creating thousands or more causes memory and context-switching costs to spike.</p>
<pre><code class="language-java">// Java Platform Thread - 1:1 mapping to an OS thread
new Thread(() -> doWork()).start(); // allocates a ~1MB stack
</code></pre>
<p>The <strong>Virtual Thread</strong> introduced in Java 21 is a lightweight thread conceptually similar to a goroutine.</p>
<pre><code class="language-java">// Java Virtual Thread - similar to a goroutine
Thread.startVirtualThread(() -> doWork());
</code></pre>
<p>However, Java does not have a communication mechanism like Channel built into the language, so you have to use separate tools such as <code>BlockingQueue</code> or <code>CompletableFuture</code>.</p>
<h3>Summary of Goroutine's Core Strengths</h3>
<ol>
<li><strong>Language built-in</strong>: <code>go</code> + <code>chan</code> are provided as keywords, so no separate library is needed</li>
<li><strong>No function coloring problem</strong>: there's no <code>async/await/suspend</code> distinction — all functions are the same</li>
<li><strong>Preemptive scheduling</strong>: the runtime automatically switches even CPU-bound goroutines</li>
<li><strong>Consistent ecosystem</strong>: the entire standard library is designed around goroutines</li>
</ol>
<p>As <strong>weaknesses</strong>, there's the lack of structured concurrency (you need to manually manage <code>Context</code>/<code>WaitGroup</code>) and the fact that a <code>panic</code> in a goroutine can terminate the entire program.</p>
<h2>6. Goroutine Scheduling Concepts</h2>
<h3>The GMP Model</h3>
<p>The Go runtime schedules goroutines with the <strong>GMP model</strong>. Rather than the OS directly managing goroutines, the Go runtime performs scheduling itself in user space. Thanks to this, context switching is possible at a much lower cost than OS threads.</p>
<pre><code class="language-mermaid">graph TD
    subgraph "Go Scheduler (GMP model)"
        subgraph "G (Goroutine)"
            G1[G1]
            G2[G2]
            G3[G3]
            G4[G4]
            G5[G5]
            G6[G6]
        end
        subgraph "M (OS Thread)"
            M1[M1]
            M2[M2]
        end
        subgraph "P (logical processor)"
            P1[P1]
            P2[P2]
        end
    end

    P1 --> M1
    G1 -- "running" --> P1
    G2 -- "waiting (run queue)" --> P1
    G3 -- "waiting (run queue)" --> P1
</code></pre>
<p>In the diagram above, P1 is bound to M1 (an OS thread) and is running G1, while G2 and G3 are waiting in P1's local run queue. When G1 blocks on I/O waiting and the like, P1 immediately pulls G2 from the run queue and runs it.</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>G (Goroutine)</strong></td>
<td>a lightweight unit of execution holding the function to run and stack info</td>
</tr>
<tr>
<td><strong>M (Machine)</strong></td>
<td>OS thread. actually runs code on the CPU</td>
</tr>
<tr>
<td><strong>P (Processor)</strong></td>
<td>logical processor. manages the goroutine run queue</td>
</tr>
</tbody>
</table>
<p>The scheduling flow can be summarized as follows:</p>
<ol>
<li>When a new goroutine (<code>G</code>) is created, it is added to the current <code>P</code>'s <strong>local run queue</strong></li>
<li><code>P</code> pulls goroutines from the run queue one by one and runs them on the bound <code>M</code> (OS thread)</li>
<li>When a running goroutine blocks on I/O waiting, channel waiting, <code>time.Sleep</code>, etc., <code>P</code> switches to the next goroutine</li>
<li>When the local run queue is empty, it fetches a goroutine from another <code>P</code>'s queue via <strong>work stealing</strong></li>
</ol>
<h3>runtime.GOMAXPROCS</h3>
<p><code>runtime.GOMAXPROCS(n)</code> sets the <strong>maximum number of Ps (Processors)</strong> that can run goroutines simultaneously. The default is the number of CPU cores. That is, on a 4-core machine, up to 4 goroutines can run physically at the same time.</p>
<pre><code class="language-go">func TestGOMAXPROCS(t *testing.T) {
    currentProcs := runtime.GOMAXPROCS(0) // passing 0 returns the current value without changing it
    numCPU := runtime.NumCPU()

    t.Logf("number of CPUs: %d", numCPU)           // e.g. number of CPUs: 12
    t.Logf("current GOMAXPROCS: %d", currentProcs) // e.g. current GOMAXPROCS: 12

    // setting GOMAXPROCS to 1 creates only one P
    // → all goroutines run alternately on a single OS thread (no true parallelism)
    runtime.GOMAXPROCS(1)
}
</code></pre>
<ul>
<li><code>GOMAXPROCS=1</code>: since there is one P, goroutines are configured concurrently but only one runs at a time. Useful for debugging or reproducing race conditions</li>
<li><code>GOMAXPROCS=N</code>: up to N goroutines can run simultaneously. Generally it's recommended to keep the default (number of CPU cores)</li>
</ul>
<h2>7. Goroutine Leak</h2>
<h3>What Is a Goroutine Leak?</h3>
<p>A state where a goroutine is no longer needed but <strong>stays alive without terminating</strong> is called a goroutine leak. It occupies memory and is not subject to GC, so memory usage keeps increasing over time.</p>
<h3>Common Causes</h3>
<ol>
<li><strong>Channel waiting</strong>: blocking forever on a channel that nobody receives from / sends to</li>
<li><strong>Infinite loop</strong>: a goroutine with no termination condition</li>
<li><strong>Not using context</strong>: a goroutine that runs without a cancellation signal</li>
</ol>
<h3>Leak Example</h3>
<p>In the code below, <code>leakyFunc</code> creates an unbuffered channel and sends a value from a goroutine. But if the caller does not receive from the channel, the goroutine <strong>blocks forever</strong> at <code>ch &#x3C;- 42</code>. This goroutine is not reclaimed even by GC.</p>
<pre><code class="language-go">func TestGoroutineLeak(t *testing.T) {
    initialCount := runtime.NumGoroutine()

    leakyFunc := func() &#x3C;-chan int {
        ch := make(chan int)
        go func() {
            ch &#x3C;- 42 // blocks forever because nobody receives
        }()
        return ch
    }

    _ = leakyFunc() // receives the channel but doesn't use it → leak!

    time.Sleep(50 * time.Millisecond)
    leakedCount := runtime.NumGoroutine()
    // initial: 2, after leak: 3 → the goroutine count increased
}
</code></pre>
<p>If this pattern is called repeatedly, goroutines keep piling up and memory usage increases without bound.</p>
<h3>Preventing Leaks with Context</h3>
<p>To solve the problem above, a goroutine must be able to <strong>terminate itself upon receiving an external signal</strong>. Using <code>context.Context</code>'s cancellation mechanism, you can send a termination signal to the goroutine.</p>
<pre><code class="language-go">func TestGoroutineLeakPrevention_WithContext(t *testing.T) {
    safeFunc := func(ctx context.Context) &#x3C;-chan int {
        ch := make(chan int, 1) // changed to a buffered channel → send does not block
        go func() {
            defer close(ch)
            select {
            case ch &#x3C;- 42:       // deliver the value normally
            case &#x3C;-ctx.Done():   // terminate the goroutine when context is canceled
                return
            }
        }()
        return ch
    }

    ctx, cancel := context.WithCancel(context.Background())
    ch := safeFunc(ctx)
    _ = ch

    cancel() // canceling the context cleans up the goroutine
}
</code></pre>
<p>The improvements can be summarized as follows:</p>
<ul>
<li><strong>buffered channel</strong> (<code>make(chan int, 1)</code>): send doesn't block even without a receiver, so the goroutine doesn't get stuck</li>
<li><strong><code>select</code> + <code>ctx.Done()</code></strong>: when the context is canceled, the <code>ctx.Done()</code> channel closes and the goroutine terminates via <code>return</code></li>
<li><strong><code>defer close(ch)</code></strong>: the channel is cleaned up together when the goroutine terminates</li>
</ul>
<p><strong>Key principle</strong>: when creating a goroutine, always secure a <strong>termination path</strong>. Use <code>context</code>, a <code>done channel</code>, <code>close</code>, and the like.</p>
<h2>8. Summary</h2>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Core</th>
</tr>
</thead>
<tbody>
<tr>
<td>Concurrency vs Parallelism</td>
<td>Concurrency is structure, Parallelism is execution</td>
</tr>
<tr>
<td>CSP model</td>
<td>independent processes + message passing</td>
</tr>
<tr>
<td>Goroutine</td>
<td>created with the <code>go</code> keyword, ~2KB stack, tens of thousands possible</td>
</tr>
<tr>
<td>GMP model</td>
<td>G(goroutine) + M(OS thread) + P(processor)</td>
</tr>
<tr>
<td>GOMAXPROCS</td>
<td>the number of Ps that can run simultaneously (default = number of CPU cores)</td>
</tr>
<tr>
<td>Comparison with other languages</td>
<td>language built-in, no function coloring problem, preemptive scheduling</td>
</tr>
<tr>
<td>Goroutine Leak</td>
<td>a goroutine that doesn't terminate → prevent with context/done channel</td>
</tr>
</tbody>
</table>
<p>In the next part, we'll look at the <strong>core mechanism for exchanging data</strong> between goroutines: the Channel.</p>
<h2>References</h2>
<ul>
<li><a href="https://go.dev/doc/effective_go#concurrency">Effective Go - Concurrency</a></li>
<li><a href="https://go.dev/blog/waza-talk">Go Blog - Concurrency is not parallelism</a></li>
<li><a href="https://go.dev/blog/codelab-share">Go Blog - Share Memory By Communicating</a></li>
</ul>]]></content:encoded>
      <category>go</category>
      <category>golang</category>
      <category>concurrency</category>
      <category>goroutine</category>
      <category>parallelism</category>
      <category>scheduler</category>
      <category>lightweight-thread</category>
      <category>고랭</category>
      <category>동시성</category>
      <category>고루틴</category>
      <category>Series: Golang Concurrency</category>
      <author>advenoh@gmail.com (advenoh)</author>
    </item>
  </channel>
</rss>