<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발자 김선우 블로그</title>
    <link>https://code-run.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 9 May 2026 10:27:21 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Devvy_</managingEditor>
    <image>
      <title>개발자 김선우 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/4411001/attach/458f16201a9e42acb6b2fd58453547e1</url>
      <link>https://code-run.tistory.com</link>
    </image>
    <item>
      <title>Vibe Code an Open Source</title>
      <link>https://code-run.tistory.com/106</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;제가 개발자로서 가지고 있는 목표 중 하나는, 많은 사용자가 활용할 수 있는 오픈소스를 직접 만들어 보는 것입니다. 이를 위해 여러 프로젝트를 진행해 왔지만, 실제로는 기여자를 확보하기 어렵고, 확보하더라도 방향성이 맞지 않거나 피드백이 지연되는 문제가 반복되었습니다. 혼자서는 프로젝트 규모를 확장하기 어렵다는 점도 한계였습니다. 최근에는 AI 기반 도구들이 등장하면서 이러한 제약이 크게 줄었습니다. 개인이 단독으로도 일정 규모 이상의 프로젝트를 유지하고 확장할 수 있는 환경이 마련된 것입니다. 이번 글에서는 &lt;a href=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;spring-boot-starter-actor&lt;/a&gt; 프로젝트를 예로 들어, 제가 AI를 어떤 방식으로 활용하며 개발을 진행하고 있는지 그 과정을 정리해보고자 합니다.&lt;/p&gt;
&lt;figure id=&quot;og_1763258331606&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - seonWKim/spring-boot-starter-actor: Actors kindly introduced to Spring&quot; data-og-description=&quot;Actors kindly introduced to Spring . Contribute to seonWKim/spring-boot-starter-actor development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; data-og-url=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bjtNFL/hyZNTzz6U5/ydwg3NCknejqrYO2bbfC71/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/c7Pabo/hyZNNe34Oo/YVaiTK18ukxfMkDeunyyDK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bjtNFL/hyZNTzz6U5/ydwg3NCknejqrYO2bbfC71/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/c7Pabo/hyZNNe34Oo/YVaiTK18ukxfMkDeunyyDK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - seonWKim/spring-boot-starter-actor: Actors kindly introduced to Spring&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Actors kindly introduced to Spring . Contribute to seonWKim/spring-boot-starter-actor development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;hr data-end=&quot;573&quot; data-start=&quot;570&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;612&quot; data-start=&quot;575&quot; data-ke-size=&quot;size26&quot;&gt;프로젝트 소개: spring-boot-starter-actor&lt;/h2&gt;
&lt;p data-end=&quot;711&quot; data-start=&quot;614&quot; data-ke-size=&quot;size16&quot;&gt;spring-boot-starter-actor는 Akka의 fork인 Pekko를 Spring Boot 환경에서 자연스럽게 사용할 수 있도록 만든 glue 프로젝트입니다. Pekko는 액터 모델과 클러스터링 기능을 제공하는 라이브러리로 기능적인 측면에서는 성숙해 있지만, Spring과는 분리된 생태계를 가지고 있다는 점이 제약으로 작용합니다. Java 생태계에서 Spring의 비중을 고려하면, Pekko 기능을 Spring 기반 프로젝트에서 바로 사용할 수 없다는 점이 불편 요소였습니다. 이러한 문제를 해결하기 위해 해당 프로젝트를 시작하게 되었습니다.&lt;/p&gt;
&lt;hr data-end=&quot;934&quot; data-start=&quot;931&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;952&quot; data-start=&quot;936&quot; data-ke-size=&quot;size26&quot;&gt;AI를 활용한 개발 흐름&lt;/h2&gt;
&lt;h3 data-end=&quot;966&quot; data-start=&quot;954&quot; data-ke-size=&quot;size23&quot;&gt;아이디어 정교화&lt;/h3&gt;
&lt;p data-end=&quot;1067&quot; data-start=&quot;968&quot; data-ke-size=&quot;size16&quot;&gt;초기 MVP는 분산 채팅 애플리케이션 예제를 만들 수 있는 수준으로 구성했습니다. 이 과정에서 Claude와 ChatGPT를 활용해 아이디어를 구조화하고 방향성을 잡았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1129&quot; data-start=&quot;1069&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1087&quot; data-start=&quot;1069&quot;&gt;특정 기능이 타당한지 질문&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;1107&quot; data-start=&quot;1088&quot;&gt;설계 방향에 대한 의견 요청&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;1129&quot; data-start=&quot;1108&quot;&gt;제안된 구조를 기반으로 MVP 생성&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;848&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bF8hej/dJMcah3Q9PN/0EDNWgdOpJyNrrysYRK5k0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bF8hej/dJMcah3Q9PN/0EDNWgdOpJyNrrysYRK5k0/img.gif&quot; data-alt=&quot;분산 채팅 애플리케이션&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bF8hej/dJMcah3Q9PN/0EDNWgdOpJyNrrysYRK5k0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bF8hej/dJMcah3Q9PN/0EDNWgdOpJyNrrysYRK5k0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1426&quot; height=&quot;848&quot; data-origin-width=&quot;1426&quot; data-origin-height=&quot;848&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;분산 채팅 애플리케이션&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-end=&quot;1181&quot; data-start=&quot;1178&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1196&quot; data-start=&quot;1183&quot; data-ke-size=&quot;size23&quot;&gt;문서 작성 자동화&lt;/h3&gt;
&lt;p data-end=&quot;1267&quot; data-start=&quot;1198&quot; data-ke-size=&quot;size16&quot;&gt;기능 정리가 어느 정도 완료된 시점부터는 &lt;a href=&quot;https://seonwkim.github.io/spring-boot-starter-actor/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;문서화 작업&lt;/a&gt;을 시작했습니다. 문서 작성 방식은 다음과 같은 구조로 진행했습니다.&lt;/p&gt;
&lt;figure id=&quot;og_1763258437088&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Introduction - spring-boot-starter-actor&quot; data-og-description=&quot;Introduction Spring Boot Starter Actor is a library that integrates Spring Boot with the actor model using Pekko (an open-source, community-driven fork of Akka). What is this project about? This project bridges the gap between Spring Boot and the actor mod&quot; data-og-host=&quot;seonwkim.github.io&quot; data-og-source-url=&quot;https://seonwkim.github.io/spring-boot-starter-actor/&quot; data-og-url=&quot;https://seonwkim.github.io/spring-boot-starter-actor/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://seonwkim.github.io/spring-boot-starter-actor/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://seonwkim.github.io/spring-boot-starter-actor/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Introduction - spring-boot-starter-actor&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Introduction Spring Boot Starter Actor is a library that integrates Spring Boot with the actor model using Pekko (an open-source, community-driven fork of Akka). What is this project about? This project bridges the gap between Spring Boot and the actor mod&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;seonwkim.github.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1372&quot; data-start=&quot;1269&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1303&quot; data-start=&quot;1269&quot;&gt;문서 작성 형식 또는 구조에 대한 간단한 가이드 제공&lt;/li&gt;
&lt;li data-end=&quot;1318&quot; data-start=&quot;1304&quot;&gt;AI가 초안 작성&lt;/li&gt;
&lt;li data-end=&quot;1351&quot; data-start=&quot;1319&quot;&gt;초안을 검토한 뒤 스타일&amp;middot;일관성 관련 가이드 제공&lt;/li&gt;
&lt;li data-end=&quot;1372&quot; data-start=&quot;1352&quot;&gt;AI가 다시 반영하여 문서 정돈&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1425&quot; data-start=&quot;1374&quot; data-ke-size=&quot;size16&quot;&gt;덕분에 문서화에 큰 시간을 들이지 않으면서도 최신화된 문서를 유지할 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;1430&quot; data-start=&quot;1427&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1449&quot; data-start=&quot;1432&quot; data-ke-size=&quot;size23&quot;&gt;TASK.md 기반 개발&lt;/h3&gt;
&lt;p data-end=&quot;1517&quot; data-start=&quot;1451&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트가 확장되기 시작하면서 필요한 작업들을 TASK.md에 기록하였고, 이 작업들은 대부분 AI가 처리했습니다. 예를 들어, 최근 추가한 Pekko pub/sub 기능은 다음 한 문장으로 시작했습니다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1702&quot; data-start=&quot;1570&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1702&quot; data-start=&quot;1572&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;What do you think of adding Pekko's distributed publish subscribe feature to our project? And if so, how would you implement it?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1752&quot; data-start=&quot;1704&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1752&quot; data-start=&quot;1704&quot; data-ke-size=&quot;size16&quot;&gt;이 문장을 기반으로 AI와 기능의 방향을 협의했고, 단계별로 MVP를 완성했습니다. 기능 구현 이후에는 glue 프로젝트 특성상 사용자 경험을 고려한 API 정리가 필요했으며, 이를 위해 *&amp;ldquo;새로운 기능을 좋은 DX를 가진 API 형태로 리팩터링해달라&amp;rdquo;*는 요청을 반복적으로 전달했습니다. 테스트 코드 또한 직접 작성하지 않고, 검증해야 하는 케이스를 제시하고 AI가 코드를 작성하도록 했습니다.&lt;/p&gt;
&lt;hr data-end=&quot;1938&quot; data-start=&quot;1935&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1966&quot; data-start=&quot;1940&quot; data-ke-size=&quot;size23&quot;&gt;PR 리뷰: AI가 AI를 리뷰하는 구조&lt;/h3&gt;
&lt;p data-end=&quot;2051&quot; data-start=&quot;1968&quot; data-ke-size=&quot;size16&quot;&gt;개발은 로컬 환경에서 Claude를 중심으로 진행했지만, GitHub에 PR을 생성한 이후에는 Copilot을 사용해 리뷰를 진행하도록 했습니다.&lt;/p&gt;
&lt;p data-end=&quot;2105&quot; data-start=&quot;2053&quot; data-ke-size=&quot;size16&quot;&gt;혼자 개발하고 있어 바로 머지할 수 있지만, 다음을 위해 PR 리뷰 과정을 유지하고 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2154&quot; data-start=&quot;2107&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2120&quot; data-start=&quot;2107&quot;&gt;코드 스타일 유지&lt;/li&gt;
&lt;li data-end=&quot;2134&quot; data-start=&quot;2121&quot;&gt;문서 최신화 확인&lt;/li&gt;
&lt;li data-end=&quot;2154&quot; data-start=&quot;2135&quot;&gt;기능 간 균형 및 구조 점검&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1966&quot; data-origin-height=&quot;1168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cs01gE/dJMcabWR9L4/ZUcKEHp2sdJ1SnhGAX0H60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cs01gE/dJMcabWR9L4/ZUcKEHp2sdJ1SnhGAX0H60/img.png&quot; data-alt=&quot;Code Review by copilot&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cs01gE/dJMcabWR9L4/ZUcKEHp2sdJ1SnhGAX0H60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcs01gE%2FdJMcabWR9L4%2FZUcKEHp2sdJ1SnhGAX0H60%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1966&quot; height=&quot;1168&quot; data-origin-width=&quot;1966&quot; data-origin-height=&quot;1168&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Code Review by copilot&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-end=&quot;2213&quot; data-start=&quot;2210&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2226&quot; data-start=&quot;2215&quot; data-ke-size=&quot;size23&quot;&gt;이동 중 개발&lt;/h3&gt;
&lt;p data-end=&quot;2376&quot; data-start=&quot;2228&quot; data-ke-size=&quot;size16&quot;&gt;AI 기반 도구를 사용하면서 개발 환경이 PC 앞에만 고정되지 않게 되었습니다. 이동 중에는 GitHub Copilot을 활용해 떠오른 아이디어를 구현해보도록 지시하고, 결과를 휴대폰으로 확인해 필요한 부분만 수정 지시하는 방식으로 작업을 이어가고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2842&quot; data-origin-height=&quot;1538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lDFoc/dJMcafycy4I/a1zohcoFsyxe4RyjVQKYM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lDFoc/dJMcafycy4I/a1zohcoFsyxe4RyjVQKYM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lDFoc/dJMcafycy4I/a1zohcoFsyxe4RyjVQKYM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlDFoc%2FdJMcafycy4I%2Fa1zohcoFsyxe4RyjVQKYM0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2842&quot; height=&quot;1538&quot; data-origin-width=&quot;2842&quot; data-origin-height=&quot;1538&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-end=&quot;2381&quot; data-start=&quot;2378&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2389&quot; data-start=&quot;2383&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-end=&quot;2542&quot; data-start=&quot;2391&quot; data-ke-size=&quot;size16&quot;&gt;AI 도구가 등장한 이후, 개발자가 혼자서 만들 수 있는 프로젝트의 범위와 규모가 눈에 띄게 넓어진 것 같습니다. spring-boot-starter-actor 역시 AI가 없었다면 아마 초기 MVP 단계에서 멈췄을 가능성이 큽니다. 하지만 AI 덕분에 필요한 기능들을 지속적으로 확장할 수 있었고, 프로젝트의 완성도와 성숙도 역시 꾸준히 높여갈 수 있었습니다.&lt;/p&gt;</description>
      <category>Ai</category>
      <category>spring-boot-starter-actor</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/106</guid>
      <comments>https://code-run.tistory.com/106#entry106comment</comments>
      <pubDate>Sun, 16 Nov 2025 11:12:37 +0900</pubDate>
    </item>
    <item>
      <title>오픈소스 기여의 매력에 대해 feat. turso</title>
      <link>https://code-run.tistory.com/105</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;작년 여름 스페인을 여행하던 중 시작한 오픈소스 Turso 기여와 관련해, 그동안의 경험과 배운 점을 정리하고자 오랜만에 블로그에 글을 남깁니다. 한창 활발히 기여하던 시기에는 일주일에 서너 개 이상의 PR이 머지되곤 했지만, 요즘은 잠시 쉬어가고 있습니다. 대신 예전부터 관심을 가졌던 액터 모델(Actor Model)을 스프링과 통합하는 &lt;a href=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;spring-boot-starter-actor&lt;/a&gt; 프로젝트에 집중하고 있습니다. 잡설은 여기까지 하고, 이번 글에서는 Turso에 기여하면서 느꼈던 오픈소스의 매력과 그 속에서 얻은 개발자로서의 성장 경험을 나눠보고자 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작년만 해도 약 500개 정도의 Star을 가지고 있던 Turso는 이제 15,000개에 육박하는 인기 프로젝트로 성장했습니다. 규모가 커지면서 전 세계의 다양한 배경을 가진 컨트리뷰터들이 모이기 시작했습니다. 초기 Maria DB 개발 멤버, 교도소에서 기여하는 친구, 소프트웨어 테스팅을 연구하는 대학원생 등, 정말 다양한 사람들이 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저 또한 다소 특이한 배경을 가지고 있어 관련 인터뷰를 진행하기도 했는데, 이를 통해 오픈소스가 얼마나 다양한 사람들을 연결하는지 몸소 느낄 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1762083685038&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;The Faces of Turso: Meet Seon Woo Kim&quot; data-og-description=&quot;Turso is built by a large community of contributors. Today we get to know Seon Woo Kim&quot; data-og-host=&quot;turso.tech&quot; data-og-source-url=&quot;https://turso.tech/blog/faces-of-turso-seon&quot; data-og-url=&quot;https://turso.tech/blog/faces-of-turso-seon&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/XTqXf/hyZM4NOe5O/C07elNZ1VwDqxfZeOJ7Hn1/img.png?width=2400&amp;amp;height=1350&amp;amp;face=1061_307_1436_715,https://scrap.kakaocdn.net/dn/uU4f4/hyZNbsCRwU/qWJ0acGlq9iTpimy71lOyk/img.png?width=2400&amp;amp;height=1350&amp;amp;face=1061_307_1436_715,https://scrap.kakaocdn.net/dn/cIbu45/hyZMGGJFh1/r2ipKUS56W2lb6QVQHT4L1/img.png?width=2400&amp;amp;height=1350&amp;amp;face=1053_288_1450_723&quot;&gt;&lt;a href=&quot;https://turso.tech/blog/faces-of-turso-seon&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://turso.tech/blog/faces-of-turso-seon&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/XTqXf/hyZM4NOe5O/C07elNZ1VwDqxfZeOJ7Hn1/img.png?width=2400&amp;amp;height=1350&amp;amp;face=1061_307_1436_715,https://scrap.kakaocdn.net/dn/uU4f4/hyZNbsCRwU/qWJ0acGlq9iTpimy71lOyk/img.png?width=2400&amp;amp;height=1350&amp;amp;face=1061_307_1436_715,https://scrap.kakaocdn.net/dn/cIbu45/hyZMGGJFh1/r2ipKUS56W2lb6QVQHT4L1/img.png?width=2400&amp;amp;height=1350&amp;amp;face=1053_288_1450_723');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;The Faces of Turso: Meet Seon Woo Kim&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Turso is built by a large community of contributors. Today we get to know Seon Woo Kim&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;turso.tech&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람이 많아지면 소프트웨어 품질이 떨어지지 않을까 걱정도 되었지만, Turso의 주요 메인테이너 분들은 꼼꼼하고 체계적인 리뷰 문화를 유지하며 프로젝트를 안정적으로 이끌었습니다. 덕분에 Turso는 베타 릴리즈 단계까지 빠르게 진행된 상태이며, 저는 그 과정을 지켜보며 복잡한 시스템이 어떻게 설계되고 다듬어지는지를 간접적으로 체험할 수 있었습니다. 그 자체로 매우 값진 시간이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스의 또 다른 매력은, 그 매력을 다른 사람들과 나눌 수 있다는 점입니다. 최근 제가 Turso를 소개했던 두 분의 PR은 이미 머지되었고, 지금도 활발히 이슈를 찾아 기여를 이어가고 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1762083881758&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;bindings/java: Implement setObject(int, Object) in JDBC4PreparedStatement by moonwhistle &amp;middot; Pull Request #3864 &amp;middot; tursodatabase/&quot; data-og-description=&quot;Purpose Implement setObject(int, Object) to support binding of common Java types to SQL parameters in JDBC4. This implementation currently covers only standard JDBC4 supported types. LOB and strea...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/tursodatabase/turso/pull/3864&quot; data-og-url=&quot;https://github.com/tursodatabase/turso/pull/3864&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eK2Eu/hyZNcZnqFC/ngZJCGxk1zukwgJlrWEUA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/jkcfH/hyZMCqO89W/TG3x3Po5xWaBpgWALSpkFk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/tursodatabase/turso/pull/3864&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/tursodatabase/turso/pull/3864&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eK2Eu/hyZNcZnqFC/ngZJCGxk1zukwgJlrWEUA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/jkcfH/hyZMCqO89W/TG3x3Po5xWaBpgWALSpkFk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;bindings/java: Implement setObject(int, Object) in JDBC4PreparedStatement by moonwhistle &amp;middot; Pull Request #3864 &amp;middot; tursodatabase/&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Purpose Implement setObject(int, Object) to support binding of common Java types to SQL parameters in JDBC4. This implementation currently covers only standard JDBC4 supported types. LOB and strea...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1762083884306&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;Implement wasNull tracking in ResultSet getter methods by mingseok &amp;middot; Pull Request #3838 &amp;middot; tursodatabase/turso&quot; data-og-description=&quot;Summary Implemented comprehensive wasNull tracking and refactored getter methods in JDBC4ResultSet to ensure JDBC specification compliance and improve code maintainability. Changes Added wasNull tr...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/tursodatabase/turso/pull/3838&quot; data-og-url=&quot;https://github.com/tursodatabase/turso/pull/3838&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/enCddg/hyZMMOJMCV/WQfkLlMwOuASSLAzMXNpWk/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_125_1036_173,https://scrap.kakaocdn.net/dn/kgmms/hyZMEPIq4c/LOd1uUxvtetpAA4acvHzz0/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_125_1036_173&quot;&gt;&lt;a href=&quot;https://github.com/tursodatabase/turso/pull/3838&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/tursodatabase/turso/pull/3838&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/enCddg/hyZMMOJMCV/WQfkLlMwOuASSLAzMXNpWk/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_125_1036_173,https://scrap.kakaocdn.net/dn/kgmms/hyZMEPIq4c/LOd1uUxvtetpAA4acvHzz0/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_125_1036_173');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Implement wasNull tracking in ResultSet getter methods by mingseok &amp;middot; Pull Request #3838 &amp;middot; tursodatabase/turso&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Summary Implemented comprehensive wasNull tracking and refactored getter methods in JDBC4ResultSet to ensure JDBC specification compliance and improve code maintainability. Changes Added wasNull tr...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 제가 개발했던 Turso JDBC 드라이버가 &lt;a href=&quot;https://central.sonatype.com/artifact/tech.turso/turso/versions&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Maven Central에 정식 배포&lt;/a&gt;되었을 때, 그 동안의 노력이 결실을 맺은 것 같아 무척 보람찼습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스를 시작하지 않았다면, 저는 아마 세계 각지의 뛰어난 개발자들을 만나지 못했을 것입니다. 오픈소스를 시작하지 않았다면, Turso처럼 빠르게 성장하는 프로젝트의 여정을 가까이에서 함께하지 못했을 것입니다. 그리고 오픈소스를 시작하지 않았다면, 이 소중한 경험을 다른 이들과 나눌 수도 없었을 것입니다. 개발자로서 오픈소스에 참여한다는 것은 단순한 코드 기여를 넘어, &lt;b&gt;세상과 연결되는 경험&lt;/b&gt;을 만드는 일이라고 생각합니다. 첫 기여는 누구에게나 막막하고 어렵습니다. 저 역시 그랬습니다. 하지만 그 단계를 넘어서면, 오픈소스는 단순한 취미를 넘어 진정한 즐거움과 배움의 장이 됩니다.&lt;/p&gt;</description>
      <category>Open Source</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/105</guid>
      <comments>https://code-run.tistory.com/105#entry105comment</comments>
      <pubDate>Sun, 2 Nov 2025 20:55:44 +0900</pubDate>
    </item>
    <item>
      <title>[Rorschach] 임상심리사들을 위한 사이드 프로젝트, 로샤</title>
      <link>https://code-run.tistory.com/104</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;5월부터 임상심리사들의 로샤 채점 효율을 높이기 위한 사이드 프로젝트 로샤를 시작했습니다. 이전에 진행했던 사이드 프로젝트들과 다른 점이 있다면, 로샤 프로젝트는 타깃 고객군이 뚜렷하다는 것입니다. 예컨대, 이전에는 온라인 메뉴판이나 커뮤니티 서비스 등 폭넓은 대상자를 염두에 둔 프로젝트가 많았지만, 이번엔 임상심리사라는 특정한 집단을 중심으로 한 프로젝트입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3446&quot; data-origin-height=&quot;1910&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9r569/btsPN9MEJPF/UaSVkDRLc3p0IGf0LCkASk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9r569/btsPN9MEJPF/UaSVkDRLc3p0IGf0LCkASk/img.png&quot; data-alt=&quot;로샤 프로젝트 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9r569/btsPN9MEJPF/UaSVkDRLc3p0IGf0LCkASk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9r569%2FbtsPN9MEJPF%2FUaSVkDRLc3p0IGf0LCkASk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3446&quot; height=&quot;1910&quot; data-origin-width=&quot;3446&quot; data-origin-height=&quot;1910&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;로샤 프로젝트 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;348&quot; data-start=&quot;331&quot; data-ke-size=&quot;size23&quot;&gt;온라인으로 로샤 채점하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 엑셀을 활용해 로샤 채점을 진행하는 방식이 일반적이었습니다. 하지만 엑셀로는 채점 과정이 번거롭고, 필요한 정보를 찾는 데 시간도 많이 소요됐습니다. 이를 해결하기 위해 로샤 프로젝트는 온라인에서 손쉽게 채점할 수 있도록 채점 화면을 구성했고, 관련 정보를 빠르게 검색할 수 있는 기능도 추가했습니다.&lt;/p&gt;
&lt;p data-end=&quot;606&quot; data-start=&quot;533&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;606&quot; data-start=&quot;533&quot; data-ke-size=&quot;size16&quot;&gt;온라인화되면서 채점표를 효율적으로 관리할 수 있게 되었고, 검증 로직을 도입하여 채점의 정확성도 높일 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3408&quot; data-origin-height=&quot;1430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dVDGbr/btsPOl7aMBW/5y33BjLD8MmbjHPK6qgspk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dVDGbr/btsPOl7aMBW/5y33BjLD8MmbjHPK6qgspk/img.png&quot; data-alt=&quot;온라인 채점&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dVDGbr/btsPOl7aMBW/5y33BjLD8MmbjHPK6qgspk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdVDGbr%2FbtsPOl7aMBW%2F5y33BjLD8MmbjHPK6qgspk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3408&quot; height=&quot;1430&quot; data-origin-width=&quot;3408&quot; data-origin-height=&quot;1430&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;온라인 채점&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2620&quot; data-origin-height=&quot;1730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sB1yq/btsPM8AHUjM/H8kig731FB0xoshlVn2JkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sB1yq/btsPM8AHUjM/H8kig731FB0xoshlVn2JkK/img.png&quot; data-alt=&quot;검색&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sB1yq/btsPM8AHUjM/H8kig731FB0xoshlVn2JkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsB1yq%2FbtsPM8AHUjM%2FH8kig731FB0xoshlVn2JkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2620&quot; height=&quot;1730&quot; data-origin-width=&quot;2620&quot; data-origin-height=&quot;1730&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;검색&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;627&quot; data-start=&quot;613&quot; data-ke-size=&quot;size23&quot;&gt;프로젝트 개발 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 스펙 파악부터 구현까지 하루 1 ~ 2 시간씩 투자하여 2 ~ 3주에 걸쳐 초기 버전을 완성했습니다. JetBrains의 AI 도구인 Junie를 적극 활용하여 프로젝트 셋업부터 주요 기능 구현까지 많은 도움을 받았고, IDE와 자연스러운 통합과 소스 코드를 참고할 수 있는 기능 덕분에 생산성도 높였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;832&quot; data-start=&quot;815&quot; data-ke-size=&quot;size23&quot;&gt;진짜 사용자를 위한 고민&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 큰 변화는 프로젝트를 진행하는 마인드셋이였습니다. 단순히 재미있는 기능을 만들어보는 데서 그치는 것이 아니라, 실제 사용자를 염두에 두고 장기적으로 운영 가능한 형태를 고민했습니다. 타깃 고객군이 명확하다 보니, 자연스럽게 홍보 채널도 구체화됐습니다. 예를 들어, &lt;i&gt;찌질한 임상심리사 모임&lt;/i&gt;이나 &lt;i&gt;CPK&lt;/i&gt;와 같은 커뮤니티에 프로젝트를 소개했고, 빠르게 유입된 사용자들의 구체적인 피드백을 기반으로 업데이트를 반복하여 피드백 루프(channel talk을 통해 피드백을 받고 있습니다)를 만들 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;1716&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/br1SFN/btsPLHxdzox/CweHfqZ5mP7wxd0vldK1E1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/br1SFN/btsPLHxdzox/CweHfqZ5mP7wxd0vldK1E1/img.png&quot; data-alt=&quot;찌질한 임상심리사 네이버 카페 인기글 등극!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/br1SFN/btsPLHxdzox/CweHfqZ5mP7wxd0vldK1E1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbr1SFN%2FbtsPLHxdzox%2FCweHfqZ5mP7wxd0vldK1E1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1876&quot; height=&quot;1716&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;1716&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;찌질한 임상심리사 네이버 카페 인기글 등극!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1654&quot; data-origin-height=&quot;1008&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ts3Sn/btsPNdV834b/FifIfuxD38hUpR5KAShr01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ts3Sn/btsPNdV834b/FifIfuxD38hUpR5KAShr01/img.png&quot; data-alt=&quot;약 한달간 프로젝트 사용자 현황&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ts3Sn/btsPNdV834b/FifIfuxD38hUpR5KAShr01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTs3Sn%2FbtsPNdV834b%2FFifIfuxD38hUpR5KAShr01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1654&quot; height=&quot;1008&quot; data-origin-width=&quot;1654&quot; data-origin-height=&quot;1008&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;약 한달간 프로젝트 사용자 현황&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1161&quot; data-start=&quot;1140&quot; data-ke-size=&quot;size23&quot;&gt;현실적인 기술 선택과 비용 절감&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 AWS EC2, ALB, Route53, RDS 등 다양한 클라우드 인프라를 직접 셋업 하여, 개발자의 자존심(?)을 걸고 백엔드와 프론트를 구성했습니다. Kotlin + Spring 백엔드에 React(or Vue) 프론트를 올려 완성도 있는 구조를 만들었지만, 이 방식은 월 10만 원 이상의 비용이 발생한다는 한계가 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 로샤 프로젝트는 장기 운영을 염두에 두고, Firebase를 기반으로 구현했습니다. 프론트와 백엔드 모두 Typescript 하나로 통합해 개발했고, 보안이 필요한 로직은 Firebase Rules나 Cloud Function으로 처리했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과, 8월 10일까지 발생한 비용은 단 38원!&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2040&quot; data-origin-height=&quot;1306&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMtFMV/btsPO96uqmd/K7XJKrq93aXUg5bBB9xUz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMtFMV/btsPO96uqmd/K7XJKrq93aXUg5bBB9xUz1/img.png&quot; data-alt=&quot;8월 firebase 비용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMtFMV/btsPO96uqmd/K7XJKrq93aXUg5bBB9xUz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMtFMV%2FbtsPO96uqmd%2FK7XJKrq93aXUg5bBB9xUz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2040&quot; height=&quot;1306&quot; data-origin-width=&quot;2040&quot; data-origin-height=&quot;1306&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;8월 firebase 비용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1645&quot; data-start=&quot;1622&quot; data-ke-size=&quot;size23&quot;&gt;사용자와의 연결, 그리고 다음 단계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타깃이 명확해지면서 다양한 임상심리사분들과 직접 소통할 수 있는 기회도 많아졌고, 덕분에 프로젝트 방향에 대한 깊이 있는 피드백도 받을 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로는 이 프로젝트가 실제 로샤 채점에 실질적인 가치를 제공하고 있는지 파악하고, 이를 기반으로 기능을 개선하거나 추가할 계획입니다. 기존에 로샤 채점을 개선하고자 했던 다른 시도들을&amp;nbsp;참고하고, 해당 프로젝트를 진행했던 분들께 조언을 구해 방향을 다듬어갈 예정입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1918&quot; data-start=&quot;1910&quot; data-ke-size=&quot;size23&quot;&gt;러닝&amp;nbsp;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2078&quot; data-start=&quot;1920&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1964&quot; data-start=&quot;1920&quot;&gt;타깃 고객군이 명확할수록 제품 방향성과 커뮤니케이션 루트가 뚜렷해짐&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;1964&quot; data-start=&quot;1920&quot;&gt;완벽함보다 지속 가능성이 중요할 수 있음&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;1964&quot; data-start=&quot;1920&quot;&gt;AI 도구를 잘 활용하는건 시대의 사명. 적은 시간 투자로 큰 결과를 낼 수 있음&amp;nbsp;&lt;/li&gt;
&lt;li data-end=&quot;1964&quot; data-start=&quot;1920&quot;&gt;Firebase는 생각보다 훌륭한 사이드 프로젝트 플랫폼&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 그 자체로도 의미 있었지만, 무엇보다 사용자 중심의 사고방식과 기술 선택의 유연성에 대해 다시 한 번 돌아보게 해주는 좋은 계기였습니다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Others</category>
      <category>로샤</category>
      <category>사이드 프로젝트</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/104</guid>
      <comments>https://code-run.tistory.com/104#entry104comment</comments>
      <pubDate>Sun, 10 Aug 2025 20:52:07 +0900</pubDate>
    </item>
    <item>
      <title>[spring-boot-starter-actor] Redis 없이 spring boot 채팅 애플리케이션 개발하기</title>
      <link>https://code-run.tistory.com/103</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/examples-seonwkim/spring-boot-chat&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;프로젝트에 사용된 코드&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1748525476920&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - examples-seonwkim/spring-boot-chat&quot; data-og-description=&quot;Contribute to examples-seonwkim/spring-boot-chat development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/examples-seonwkim/spring-boot-chat&quot; data-og-url=&quot;https://github.com/examples-seonwkim/spring-boot-chat&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bJ2GH9/hyY1dyu6P2/zuwgaLqTj1TszLy66MtiX1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cZTvvY/hyYYFcjUIX/8rIoHDbTxqxI3kYBwunIw1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/examples-seonwkim/spring-boot-chat&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/examples-seonwkim/spring-boot-chat&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bJ2GH9/hyY1dyu6P2/zuwgaLqTj1TszLy66MtiX1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cZTvvY/hyYYFcjUIX/8rIoHDbTxqxI3kYBwunIw1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - examples-seonwkim/spring-boot-chat&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to examples-seonwkim/spring-boot-chat development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산&amp;nbsp; 시스템에서 채팅 애플리케이션을 개발하기 위해서는 서로 다른 서버에 접속한 유저에게 메시지를 송신할 수 있도록 Redis 등의 메시지 전송을 위한 미들웨어가 필요합니다. 다른 방법으로는 서버를 클러스터로 묶고 서로 통신할 수 있도록 하는 방법이 있는데요, 요 포스팅에서는 액터 클러스터링 기능을 활용해서 후자의 방법으로 채팅 애플리케이션을 개발해 보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 진영의 대표적인 액터 라이브러리로는 &lt;a href=&quot;https://github.com/apache/pekko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Pekko&lt;/a&gt;가 있습니다. 다만 Spring Boot를 사용하는 경우 Pekko와의 통합이 복잡하기 때문에 이를 단순화시킨 &lt;a href=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;spring-boot-starter-actor 라이브러리&lt;/a&gt;를 활용하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748350507056&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
    kotlin(&quot;jvm&quot;) version &quot;1.9.25&quot;
    kotlin(&quot;plugin.spring&quot;) version &quot;1.9.25&quot;
    id(&quot;org.springframework.boot&quot;) version &quot;3.3.12&quot;
    id(&quot;io.spring.dependency-management&quot;) version &quot;1.1.7&quot;
}

group = &quot;io.github.seonwkim&quot;
version = &quot;0.0.1-SNAPSHOT&quot;

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}


dependencyManagement {
    imports {
        // pekko-serialization-jackson_3 require minimum 2.17.3 version of jackson
        mavenBom(&quot;com.fasterxml.jackson:jackson-bom:2.17.3&quot;)
    }
}

dependencies {
    implementation(&quot;io.github.seonwkim:spring-boot-starter-actor_3:0.0.25&quot;)

    implementation(&quot;org.springframework.boot:spring-boot-starter&quot;)
    implementation(&quot;org.springframework.boot:spring-boot-starter-webflux&quot;)
    implementation(&quot;org.springframework.boot:spring-boot-starter-websocket&quot;)

    implementation(&quot;org.springframework.boot:spring-boot-starter-thymeleaf&quot;)
    implementation(&quot;io.micrometer:micrometer-registry-prometheus&quot;)
    implementation(&quot;com.fasterxml.jackson.core:jackson-databind&quot;)

    testImplementation(&quot;org.springframework.boot:spring-boot-starter-test&quot;)
    testRuntimeOnly(&quot;org.junit.platform:junit-platform-launcher&quot;)

}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll(&quot;-Xjsr305=strict&quot;)
    }
}

tasks.withType&amp;lt;Test&amp;gt; {
    useJUnitPlatform()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는 spring boot 버전 3을 활용하겠습니다. 추가로 웹소켓을 위해 spring-boot-starter-websocket을, 비동기 서버를 기본으로 사용하기 위해 spring-boot-starter-webflux를 추가하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;웹 소켓 설정 추가&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프런트에서 웹 소켓 세션을 생성할 수 있는 endpoint를 설정하는 코드를 우선 작성하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748350862456&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry

@Configuration
@EnableWebSocket
class WebSocketConfig(
    private val chatWebSocketHandler: ChatWebSocketHandler
) : WebSocketConfigurer {

    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry
            .addHandler(chatWebSocketHandler, &quot;/ws/chat&quot;)
            .setAllowedOrigins(&quot;*&quot;) // For development; restrict in production
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;/ws/chat&quot; endpoint를 통해 웹 소켓 세션이 생성되고 해당 세션은 chatWebSocketHandler에 의해 처리됩니다. 다음으로는 웹 소켓과 관련된 로직을 처리하는 ChatWebSocketHandler을 추가하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatWebSocketHandler 구현에 앞서 채팅 시스템의 아키텍처를 우선 생각해 보겠습니다. 유저가 웹소켓 커넥션을 생성하면 해당 커넥션을 담당하는 액터를 생성할 건데 해당 액터를 UserActor라고 명명하겠습니다. 그리고 특정 채팅방에서 발생하는 모든 이벤트를 처리하는 액터를 ChatRoomActor라고 명명하면 전체적인 아키텍처는 아래와 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2044&quot; data-origin-height=&quot;1062&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1OE6s/btsOiHq8oqV/dhgtqNwsagrVeJQgqD1jrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1OE6s/btsOiHq8oqV/dhgtqNwsagrVeJQgqD1jrk/img.png&quot; data-alt=&quot;채팅 애플리케이션 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1OE6s/btsOiHq8oqV/dhgtqNwsagrVeJQgqD1jrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1OE6s%2FbtsOiHq8oqV%2FdhgtqNwsagrVeJQgqD1jrk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2044&quot; height=&quot;1062&quot; data-origin-width=&quot;2044&quot; data-origin-height=&quot;1062&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;채팅 애플리케이션 아키텍처&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UserActor&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 유저의 웹 소켓과 관련된 로직을 담당하는 UserActor을 구현하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748523471801&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import io.github.seonwkim.core.SpringActor
import io.github.seonwkim.core.SpringActorContext
import io.github.seonwkim.core.SpringActorSystem
import io.github.seonwkim.core.SpringShardedActorRef
import io.github.seonwkim.core.serialization.JsonSerializable
import org.apache.pekko.actor.typed.Behavior
import org.apache.pekko.actor.typed.javadsl.ActorContext
import org.apache.pekko.actor.typed.javadsl.Behaviors
import org.springframework.stereotype.Component
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import java.io.IOException

@Component
class UserActor : SpringActor {

    interface Command : JsonSerializable

    class Connect : Command

    class Stop : Command

    data class JoinRoom(val roomId: String) : Command

    class LeaveRoom : Command

    data class SendMessage(val message: String) : Command

    data class JoinRoomEvent(val userId: String) : Command

    data class LeaveRoomEvent(val userId: String) : Command

    data class SendMessageEvent(
        val userId: String,
        val message: String
    ) : Command

    override fun commandClass(): Class&amp;lt;*&amp;gt; = Command::class.java

    class UserActorContext(
        val actorSystem: SpringActorSystem,
        val objectMapper: ObjectMapper,
        val userId: String,
        val session: WebSocketSession
    ) : SpringActorContext {
        override fun actorId(): String = userId
    }

    override fun create(actorContext: SpringActorContext): Behavior&amp;lt;Command&amp;gt; {
        val userActorContext = actorContext as? UserActorContext
            ?: throw IllegalStateException(&quot;Must be UserActorContext&quot;)

        return Behaviors.setup { context -&amp;gt;
            UserActorBehavior(
                context,
                userActorContext.actorSystem,
                userActorContext.objectMapper,
                userActorContext.userId,
                userActorContext.session
            ).create()
        }
    }

    private class UserActorBehavior(
        private val context: ActorContext&amp;lt;Command&amp;gt;,
        private val actorSystem: SpringActorSystem,
        private val objectMapper: ObjectMapper,
        private val userId: String,
        private val session: WebSocketSession
    ) {
        private var currentRoomId: String? = null

        fun create(): Behavior&amp;lt;Command&amp;gt; = Behaviors.receive(Command::class.java)
            .onMessage(Connect::class.java, ::onConnect)
            .onMessage(Stop::class.java, ::onStop)
            .onMessage(JoinRoom::class.java, ::onJoinRoom)
            .onMessage(LeaveRoom::class.java, ::onLeaveRoom)
            .onMessage(SendMessage::class.java, ::onSendMessage)
            .onMessage(JoinRoomEvent::class.java, ::onJoinRoomEvent)
            .onMessage(LeaveRoomEvent::class.java, ::onLeaveRoomEvent)
            .onMessage(SendMessageEvent::class.java, ::onSendMessageEvent)
            .build()

        private fun onConnect(connect: Connect): Behavior&amp;lt;Command&amp;gt; {
            sendEvent(&quot;connected&quot;) {
                put(&quot;userId&quot;, userId)
            }
            return Behaviors.same()
        }

        private fun onStop(stop: Stop): Behavior&amp;lt;Command&amp;gt; {
            if (currentRoomId == null) {
                context.log.info(&quot;$userId user has not joined any room.&quot;)
                return Behaviors.same()
            }

            val roomActor = getRoomActor()
            roomActor.tell(ChatRoomActor.LeaveRoom(userId))

            return Behaviors.same();
        }

        private fun onJoinRoom(command: JoinRoom): Behavior&amp;lt;Command&amp;gt; {
            currentRoomId = command.roomId
            val roomActor = getRoomActor()
            sendEvent(&quot;joined&quot;) {
                put(&quot;roomId&quot;, currentRoomId)
            }

            roomActor.tell(ChatRoomActor.JoinRoom(userId, context.self))
            return Behaviors.same()
        }

        private fun onLeaveRoom(command: LeaveRoom): Behavior&amp;lt;Command&amp;gt; {
            if (currentRoomId == null) {
                context.log.info(&quot;$userId user has not joined any room.&quot;)
                return Behaviors.same()
            }

            sendEvent(&quot;left&quot;) {
                put(&quot;roomId&quot;, currentRoomId)
            }

            val roomActor = getRoomActor()
            roomActor.tell(ChatRoomActor.LeaveRoom(userId))

            return Behaviors.same()
        }

        private fun onSendMessage(command: SendMessage): Behavior&amp;lt;Command&amp;gt; {
            if (currentRoomId == null) {
                context.log.info(&quot;$userId user has not joined any room.&quot;)
                return Behaviors.same()
            }

            val roomActor = getRoomActor()
            roomActor.tell(ChatRoomActor.SendMessage(userId, command.message))

            return Behaviors.same()
        }

        private fun onJoinRoomEvent(event: JoinRoomEvent): Behavior&amp;lt;Command&amp;gt; {
            sendEvent(&quot;user_joined&quot;) {
                put(&quot;userId&quot;, event.userId)
                put(&quot;roomId&quot;, currentRoomId)
            }
            return Behaviors.same()
        }

        private fun onLeaveRoomEvent(event: LeaveRoomEvent): Behavior&amp;lt;Command&amp;gt; {
            sendEvent(&quot;user_left&quot;) {
                put(&quot;userId&quot;, event.userId)
                put(&quot;roomId&quot;, currentRoomId)
            }
            return Behaviors.same()
        }

        private fun onSendMessageEvent(event: SendMessageEvent): Behavior&amp;lt;Command&amp;gt; {
            sendEvent(&quot;message&quot;) {
                put(&quot;userId&quot;, event.userId)
                put(&quot;message&quot;, event.message)
                put(&quot;roomId&quot;, currentRoomId)
            }
            return Behaviors.same()
        }

        private fun getRoomActor(): SpringShardedActorRef&amp;lt;ChatRoomActor.Command&amp;gt; =
            actorSystem.entityRef(ChatRoomActor.Companion.TYPE_KEY, currentRoomId!!)

        private fun sendEvent(type: String, builder: ObjectNode.() -&amp;gt; Unit) {
            try {
                val eventNode = objectMapper.createObjectNode().apply {
                    put(&quot;type&quot;, type)
                    builder()
                }

                if (session.isOpen) {
                    session.sendMessage(TextMessage(objectMapper.writeValueAsString(eventNode)))
                }
            } catch (e: IOException) {
                context.log.error(&quot;Failed to send message to WebSocket&quot;, e)
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserActor가 처리하는 이벤트에 대한 설명은 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Connect: 처음 웹 소켓 커넥션이 생성됐을 때 발생&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Stop: 웹 소켓 커넥션이 끊긴 경우&amp;nbsp;&lt;/li&gt;
&lt;li&gt;JoinRoom: 유저가 특정 채팅방에 입장했을 때 발생&amp;nbsp;&lt;/li&gt;
&lt;li&gt;LeaveRoom: 유저가 현재 접속한 채팅방을 퇴장할 때 발생&amp;nbsp;&lt;/li&gt;
&lt;li&gt;SendMessage: 유저가 채팅 메시지를 전송한 경우 발생&amp;nbsp;&lt;/li&gt;
&lt;li&gt;JoinRoomEvent: 유저가 접속한 채팅방에 다른 유저가 접속한 경우.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;LeaveRoomEvent: 유저가 접속한 채팅방에 다른 유저가 퇴장하는 경우&amp;nbsp;&lt;/li&gt;
&lt;li&gt;SendMessagEvent: 유저가 접속한 채팅방에서 채팅 메시지가 추가된 경우&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 이벤트의 처리는 UserActorBehavior에 정의되어 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ChatRoomActor&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로는 채팅방과 관련된 로직을 담당하는 ChatRoomActor을 구현하겠습니다. ChatRoomActor의 특징은 클러스터 내에서 유일해야 한다는 점인데요, 예를 들어 roomId가 1인 채팅방을 나타내는 ChatRoomActor 액터는 전체 클러스터에서 유일한 존재여야 합니다. 이를 위해 ShardedActor을 사용하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748523812591&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package io.github.seonwkim.springbootchat

import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
import io.github.seonwkim.core.serialization.JsonSerializable
import io.github.seonwkim.core.shard.DefaultShardingMessageExtractor
import io.github.seonwkim.core.shard.ShardEnvelope
import io.github.seonwkim.core.shard.ShardedActor
import org.apache.pekko.actor.typed.ActorRef
import org.apache.pekko.actor.typed.Behavior
import org.apache.pekko.actor.typed.javadsl.Behaviors
import org.apache.pekko.cluster.sharding.typed.ShardingMessageExtractor
import org.apache.pekko.cluster.sharding.typed.javadsl.EntityContext
import org.apache.pekko.cluster.sharding.typed.javadsl.EntityTypeKey
import org.springframework.stereotype.Component

@Component
class ChatRoomActor : ShardedActor&amp;lt;ChatRoomActor.Command&amp;gt; {

    companion object {
        val TYPE_KEY: EntityTypeKey&amp;lt;Command&amp;gt; = EntityTypeKey.create(Command::class.java, &quot;ChatRoomActor&quot;)
    }

    interface Command : JsonSerializable

    data class JoinRoom @JsonCreator constructor(
        @JsonProperty(&quot;userId&quot;) val userId: String,
        @JsonProperty(&quot;userRef&quot;) val userRef: ActorRef&amp;lt;UserActor.Command&amp;gt;
    ) : Command

    data class LeaveRoom @JsonCreator constructor(
        @JsonProperty(&quot;userId&quot;) val userId: String
    ) : Command

    data class SendMessage @JsonCreator constructor(
        @JsonProperty(&quot;userId&quot;) val userId: String,
        @JsonProperty(&quot;message&quot;) val message: String
    ) : Command

    override fun typeKey(): EntityTypeKey&amp;lt;Command&amp;gt; = TYPE_KEY

    override fun create(ctx: EntityContext&amp;lt;Command&amp;gt;): Behavior&amp;lt;Command&amp;gt; = Behaviors.setup {
        val roomId = ctx.entityId
        chatRoom(roomId, HashMap())
    }

    private fun chatRoom(
        roomId: String,
        connectedUsers: MutableMap&amp;lt;String, ActorRef&amp;lt;UserActor.Command&amp;gt;&amp;gt;
    ): Behavior&amp;lt;Command&amp;gt; = Behaviors.receive(Command::class.java)
        .onMessage(JoinRoom::class.java) { msg -&amp;gt;
            // Add the user to the connected users
            connectedUsers[msg.userId] = msg.userRef

            // Notify all users that a new user has joined
            val joinRoomEvent = UserActor.JoinRoomEvent(msg.userId)
            broadcastCommand(connectedUsers, joinRoomEvent)

            chatRoom(roomId, connectedUsers)
        }
        .onMessage(LeaveRoom::class.java) { msg -&amp;gt;
            // Remove the user from connected users
            val userRef = connectedUsers.remove(msg.userId)

            userRef?.let {
                // Notify the user that they left the room
                val leaveRoomEvent = UserActor.LeaveRoomEvent(msg.userId)
                it.tell(leaveRoomEvent)

                // Notify all remaining users that a user has left
                broadcastCommand(connectedUsers, leaveRoomEvent)
            }

            chatRoom(roomId, connectedUsers)
        }
        .onMessage(SendMessage::class.java) { msg -&amp;gt;
            // Create a message received command
            val receiveMessageCmd = UserActor.SendMessageEvent(msg.userId, msg.message)

            // Broadcast the message to all connected users
            broadcastCommand(connectedUsers, receiveMessageCmd)

            Behaviors.same()
        }
        .build()

    private fun broadcastCommand(
        connectedUsers: Map&amp;lt;String, ActorRef&amp;lt;UserActor.Command&amp;gt;&amp;gt;,
        command: UserActor.Command
    ) {
        connectedUsers.values.forEach { it.tell(command) }
    }

    override fun extractor(): ShardingMessageExtractor&amp;lt;ShardEnvelope&amp;lt;Command&amp;gt;, Command&amp;gt; =
        DefaultShardingMessageExtractor(3)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatRoomActor에서 처리하는 이벤트는 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JoinRoom: 해당 채팅방에 특정 유저가 입장한 경우&amp;nbsp;&lt;/li&gt;
&lt;li&gt;LeaveRoom: 해당 채팅방에서 특정 유저가 퇴장한 경우&amp;nbsp;&lt;/li&gt;
&lt;li&gt;SendMessage: 해당 채팅방에서 메시지가 생성된 경우&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ChatRoomActor는 채팅방에 접속한 유저들에게 이벤트를 브로드캐스팅 방식을 통해 전달합니다(broadcastCommand 함수 참고).&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ChatWebSocketHandler&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자, 그럼 대망의 웹 소켓 핸들링 로직을 작성해 보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748524063538&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import io.github.seonwkim.core.SpringActorRef
import io.github.seonwkim.core.SpringActorSystem
import org.springframework.stereotype.Component
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import org.springframework.web.socket.handler.TextWebSocketHandler
import java.io.IOException
import java.util.*
import java.util.concurrent.ConcurrentHashMap

@Component
class ChatWebSocketHandler(
    private val objectMapper: ObjectMapper,
    private val actorSystem: SpringActorSystem
) : TextWebSocketHandler() {

    private val userActors = ConcurrentHashMap&amp;lt;String, SpringActorRef&amp;lt;UserActor.Command&amp;gt;&amp;gt;()

    override fun afterConnectionEstablished(session: WebSocketSession) {
        val userId = UUID.randomUUID().toString()
        session.attributes[&quot;userId&quot;] = userId

        val userActorContext = UserActor.UserActorContext(
            actorSystem = actorSystem,
            objectMapper = objectMapper,
            userId = userId,
            session = session
        )

        actorSystem.spawn(UserActor.Command::class.java, userActorContext)
            .thenAccept { userActor -&amp;gt;
                userActors[userId] = userActor
                userActor.tell(UserActor.Connect())
            }
    }


    override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
        val userId = session.attributes[&quot;userId&quot;] as String?
        val userActor = getUserActor(userId)

        if (userId != null &amp;amp;&amp;amp; userActor != null) {
            userActor.tell(UserActor.Stop())
            actorSystem.stop(UserActor.Command::class.java, userId)
            userActors.remove(userId)
        }
    }

    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        val userId = session.attributes[&quot;userId&quot;] as String
        val payload = objectMapper.readTree(message.payload)

        when (payload.get(&quot;type&quot;).asText()) {
            &quot;join&quot; -&amp;gt; handleJoinRoom(userId, payload)
            &quot;leave&quot; -&amp;gt; handleLeaveRoom(userId)
            &quot;message&quot; -&amp;gt; handleChatMessage(userId, payload)
            else -&amp;gt; sendErrorMessage(session, &quot;Unknown message type: ${payload.get(&quot;type&quot;).asText()}&quot;)
        }
    }

    private fun handleJoinRoom(userId: String, payload: JsonNode) {
        val roomId = payload.get(&quot;roomId&quot;).asText()
        val userActor = getUserActor(userId)

        if (roomId != null &amp;amp;&amp;amp; userActor != null) {
            userActor.tell(UserActor.JoinRoom(roomId))
        }
    }

    private fun handleLeaveRoom(userId: String) {
        getUserActor(userId)?.tell(UserActor.LeaveRoom())
    }

    private fun handleChatMessage(userId: String, payload: JsonNode) {
        val userActor = getUserActor(userId)
        val messageText = payload.get(&quot;message&quot;).asText()

        if (userActor != null &amp;amp;&amp;amp; messageText != null) {
            userActor.tell(UserActor.SendMessage(messageText))
        }
    }

    private fun getUserActor(userId: String?): SpringActorRef&amp;lt;UserActor.Command&amp;gt;? {
        return userId?.let { userActors[it] }
    }

    private fun sendErrorMessage(session: WebSocketSession, errorMessage: String) {
        try {
            if (session.isOpen) {
                val response = objectMapper.createObjectNode().apply {
                    put(&quot;type&quot;, &quot;error&quot;)
                    put(&quot;message&quot;, errorMessage)
                }
                session.sendMessage(TextMessage(objectMapper.writeValueAsString(response)))
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 함수에서 어떤 동작을 수행하는지는 아래와 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;afterConnectionEstablished: UserActor을 생성. UserActor는 userId와 해당 유저의 웹 소켓 세션을 들고있습니다.&amp;nbsp;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;afterConnectionClosed: UserActor을 시스템에서 종료합니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;handleTextMessage: 채팅방 접속, 퇴장, 메시지 생성과 관련된 로직을 처리합니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 로직을 완성을 했으니 클러스터 실행을 위한 스크립트를 작성하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748524706555&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash

set -e

BASE_PORT=$1
BASE_PEKKO_PORT=$2
INSTANCE_COUNT=$3

if [[ -z &quot;$BASE_PORT&quot; || -z &quot;$BASE_PEKKO_PORT&quot; || -z &quot;$INSTANCE_COUNT&quot; ]]; then
  echo &quot;Usage: $0 &amp;lt;basePort&amp;gt; &amp;lt;basePekkoPort&amp;gt; &amp;lt;instanceCount&amp;gt;&quot;
  exit 1
fi

run_application() {
  local instance=$1
  local port=$((BASE_PORT + instance))
  local pekko_port=$((BASE_PEKKO_PORT + instance))

  echo &quot;Starting instance $instance: port=${port}, pekko_port=${pekko_port}&quot;

  ./gradlew bootRun \
    --args=&quot;--server.port=${port} \
            --spring.actor.pekko.remote.artery.canonical.port=${pekko_port}&quot; \
    -PmainClass=${MAIN_CLASS} \
    &amp;gt; &quot;log_${port}.txt&quot; 2&amp;gt;&amp;amp;1 &amp;amp;
}

for ((i=0; i&amp;lt;INSTANCE_COUNT; i++)); do
  run_application $i
done&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 스크립트 파일명을 cluster-start.sh로 작성하면 &quot;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;sh cluster-start.sh 8080 2551 3&quot; 커맨드로 3개의 서버를 지닌 클러스터를 실행할 수 있습니다. 그럼 브라우저 3개를 띄우고 각각 localhost:8080, localhost:8081, localhost:8082를 접속하면 채팅 기능이 정상 동작하는 것을 확인할 수 있습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2024&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxXpqx/btsOiaN8Bh9/vIlgfFiy48IO58PfwTktA0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxXpqx/btsOiaN8Bh9/vIlgfFiy48IO58PfwTktA0/img.gif&quot; data-alt=&quot;결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxXpqx/btsOiaN8Bh9/vIlgfFiy48IO58PfwTktA0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bxXpqx/btsOiaN8Bh9/vIlgfFiy48IO58PfwTktA0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2024&quot; height=&quot;1020&quot; data-origin-width=&quot;2024&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring-boot-starter-actor을 사용하면 spring boot와 pekko의 기능을 모두 편리하게 사용할 수 있습니다. 또한 pekko의 클러스터링을 활용하면 미들웨어 없이도 stateful 기능을 손쉽게 구현할 수 있습니다. 개인적으로 액터 모델은 무궁무진한 가능성이 있는 모델이라고 생각하기에 위 라이브러리를 production ready 상태로 만드는 것이 v1.0.0의 목표입니다. 언제나 컨트리뷰션은 환영입니다(편하게 의견 주셔도 좋습니다)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1748525161112&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - seonWKim/spring-boot-starter-actor: Actors kindly introduced to Spring&quot; data-og-description=&quot;Actors kindly introduced to Spring . Contribute to seonWKim/spring-boot-starter-actor development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; data-og-url=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bIP4ob/hyYYAB70yq/OOrCmckK37Kj4J26V3plyK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/y8hOW/hyY0rxdBio/kMvcc8gOogpok1ehp2Kx0k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bIP4ob/hyYYAB70yq/OOrCmckK37Kj4J26V3plyK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/y8hOW/hyY0rxdBio/kMvcc8gOogpok1ehp2Kx0k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - seonWKim/spring-boot-starter-actor: Actors kindly introduced to Spring&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Actors kindly introduced to Spring . Contribute to seonWKim/spring-boot-starter-actor development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Java/Spring Boot</category>
      <category>actor</category>
      <category>pekko</category>
      <category>spring boot</category>
      <category>spring-boot-starter-actor</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/103</guid>
      <comments>https://code-run.tistory.com/103#entry103comment</comments>
      <pubDate>Thu, 29 May 2025 22:26:56 +0900</pubDate>
    </item>
    <item>
      <title>[spring-boot-starter-actor] Spring 생태계에 Actor 초대하기</title>
      <link>https://code-run.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 환경에서 상태를 공유해야 하는 기능이 필요할 때면 늘 고민이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;데이터베이스를 사용할까?&quot;, &quot;레디스를 붙여볼까?&quot;&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 인프라가 잘 구축되어 있거나 프로젝트 규모가 크다면, 이런 미들웨어를 사용하는 게 크게 부담은 되지 않습니다. 하지만 이제 막 시작한 프로젝트이거나 예산이 빠듯한 경우라면 얘기가 좀 달라집니다. 클라우드에 Redis 클러스터를 올리는 비용도 무시할 수 없고, 단순히 DB를 상태 저장용으로 사용했을 때는 성능 저하나 부하 분산 같은 문제도 신경 써야 하거든요. 고민이 꼬리에 꼬리를 물기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;ldquo;그럼 미들웨어 없이 상태를 공유할 수는 없을까?&amp;rdquo;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 생각 끝에 Actor Model이 이 문제를 꽤 효과적으로 풀 수 있지 않을까 싶어졌고, Java 진영의 대표적인 Actor 라이브러리인 &lt;b&gt;Akka&lt;/b&gt;와 &lt;b&gt;Pekko&lt;/b&gt;를 알아보기 시작했습니다. 공부를 하다 보니 자연스럽게 이런 생각이 들더라고요. &quot;왜 Java, 특히 Spring 진영에서는 Actor Model이 잘 안 쓰일까?&quot; 직접 써보면 이유를 알 수 있을 것 같아서, 작은 프로젝트에 하나씩 적용해보며 경험을 쌓아봤습니다. ㅋㅋㅋㅋ&amp;hellip; 그리고 곧 Spring 환경에 Actor를 녹여내려면 꽤나 많은 노력이 필요하단걸 알게되었습니다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어&amp;hellip;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Actor의 &lt;b&gt;생성/제거/관리&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Actor library의 &lt;b&gt;Configuration&lt;/b&gt; 관리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Rolling Update&lt;/b&gt; 방법&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Metrics 수집&lt;/b&gt; 및 노출 등등&amp;hellip;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;없으면 만들어보자!&quot;는 생각으로 spring-boot-starter-actor 프로젝트를 시작하게 되었습니다. 처음엔 가장 기본적인 기능부터 차근차근 구현하고 있고, 앞으로는 운영 환경에 필요한 기능들(예: rolling update 대응, metrics 연동, Kubernetes 환경 설정 등)도 하나씩 추가할 예정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;혹시 관심 있으시다면 자유롭게 써보시고, 피드백도 언제든지 환영입니다!  &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GitHub: &lt;a href=&quot;https://github.com/seonWKim/spring-boot-starter-actor&quot;&gt;https://github.com/seonWKim/spring-boot-starter-actor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;문서: &lt;a href=&quot;https://seonwkim.github.io/spring-boot-starter-actor/examples&quot;&gt;https://seonwkim.github.io/spring-boot-starter-actor/examples&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Open Source</category>
      <category>actor</category>
      <category>pekko</category>
      <category>spring boot</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/102</guid>
      <comments>https://code-run.tistory.com/102#entry102comment</comments>
      <pubDate>Fri, 16 May 2025 03:04:01 +0900</pubDate>
    </item>
    <item>
      <title>자고 일어나니 기여하던 오픈소스가 대박나있음</title>
      <link>https://code-run.tistory.com/101</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;올해 여름에 할 것도 없고 해서... 오픈소스 기여나 하자라는 생각으로 github을 둘러보고 있던 와중, Rust로 SQLite를 재작성하는 &lt;a href=&quot;https://github.com/tursodatabase/limbo&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;limbo&lt;/a&gt;라는 프로젝트를 알게 되었고 그 이후 틈틈이 기여를 했습니다. Limbo는 SQLite를 단순히 rust로 포팅하는게 아니라 기존 SQLite의 동기식 I/O를 비동기식 I/O로 전환하는 목표를 가지고 있었고, 그 목표에 어느 정도 동의하는 입장인지라 적극적으로 오픈소스에 기여했습니다. 다만 업무와 전혀 관련 없는 데이터베이스 도메인이었고 심지어 익숙하지 않은 rust를 사용하는 프로젝트여서 익숙해지는데 꽤 시간이 걸렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Limbo에 기여하면서 데이터베이스 도메인에 관심이 생겼고, 더 잘 이해해보고 싶은 마음에 여러 사이드 프로젝트를 진행했습니다. LSM, MVCC 구현하기 등 평소에 개념적으로만 친숙했던 기술을 직접 구현하면서 그 이해도를 높여 나갔습니다. 그 기간 동안은 limbo에 기여를 하지 않았기에 서서히 limbo는 제 기억 속에서 잊혀갔습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 어느 날 turso라는 회사의 기술 블로그에서 limbo를 소개하는 &lt;a href=&quot;https://turso.tech/blog/introducing-limbo-a-complete-rewrite-of-sqlite-in-rust&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;포스팅&lt;/a&gt;을 보게됐습니다. Turso 기술블로그에는 새롭고 다양한 기술을 재밌게 풀어내는 포스팅이 많아서 자주 읽어보곤&amp;nbsp;했는데 제가 기여했던 오픈소스가 소개된 게 신기했습니다. 알고 보니 turso 공동 창업자가 limbo 프로젝트 창시자 ㅋㅋ...... 해당 포스팅이 바이럴을 타면서 hacker news에 등재됐고 hacker news에 등재되면서 limbo 프로젝트도 수많은 개발자들에게 노출되기 시작했습니다. 그 결과... 무려 일주일 만에 github star가 4000개가 증가하는걸 제 눈으로 확인할 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1403&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W4EeP/btsLlZPkuUa/cSLKoTojWXotDlZx0mVbd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W4EeP/btsLlZPkuUa/cSLKoTojWXotDlZx0mVbd0/img.png&quot; data-alt=&quot;limbo top trending repository&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W4EeP/btsLlZPkuUa/cSLKoTojWXotDlZx0mVbd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW4EeP%2FbtsLlZPkuUa%2FcSLKoTojWXotDlZx0mVbd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1403&quot; height=&quot;860&quot; data-origin-width=&quot;1403&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;limbo top trending repository&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xGkqx/btsLnGOkcUc/oKKg4b9MKbBE7gBOFxSfCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xGkqx/btsLnGOkcUc/oKKg4b9MKbBE7gBOFxSfCk/img.png&quot; data-alt=&quot;limbo github star history&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xGkqx/btsLnGOkcUc/oKKg4b9MKbBE7gBOFxSfCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxGkqx%2FbtsLnGOkcUc%2FoKKg4b9MKbBE7gBOFxSfCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;756&quot; height=&quot;524&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;524&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;limbo github star history&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 오픈소스를 선정하는 기준 중 하나는 기여 과정을 통해 새로운 것을 배울 수 있느냐입니다. Limbo는 그 기준을 넘어서는 것은 물론이고 폭발적으로 성장하는 과정을 함께 지켜볼 수 있게 해 준 고마운 프로젝트가 됐습니다. 프로젝트에 더 열심히 기여하고 싶은 동기부여가 되는 건 덤입니다. 추가로 turso와 limbo와의 관계를 지켜보면서 기업에서 기술을 어떻게 채택하고 그리고 어떻게 수익화하는지도 preview를 본 것 같아 흥미로웠습니다. 개발자라면 다양한 경험의 발판이 되어줄 오픈소스를 시도해 보는 거 어떻게 생각하시나요? ㅎㅎ&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;139&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RSgaS/btsLliPq9h2/vIbIDhnZk2Kdk1F1VKqV20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RSgaS/btsLliPq9h2/vIbIDhnZk2Kdk1F1VKqV20/img.png&quot; data-alt=&quot;4th contributor of limbo&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RSgaS/btsLliPq9h2/vIbIDhnZk2Kdk1F1VKqV20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRSgaS%2FbtsLliPq9h2%2FvIbIDhnZk2Kdk1F1VKqV20%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;697&quot; height=&quot;139&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;139&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;4th contributor of limbo&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Open Source</category>
      <category>Limbo</category>
      <category>Open Source</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/101</guid>
      <comments>https://code-run.tistory.com/101#entry101comment</comments>
      <pubDate>Wed, 18 Dec 2024 10:21:24 +0900</pubDate>
    </item>
    <item>
      <title>분산 시스템 일관성 한판정리</title>
      <link>https://code-run.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;분산환경 시스템을 학습하다 보면&amp;nbsp; linearizability, causal consistency, total ordering, atomic broadcast 등 비슷하면서도 다른 개념들로 인해 분산 시스템에서의 일관성을 이해하는데 종종 어려움을 겪고는 했습니다. 이번 포스팅을 통하여 위 개념들이 무엇을 의미하고 그리고 어떻게 다른지 살펴보도록 하겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Linearizable(Strong Consistency, Atomic Consistency)&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Linearizable 한 시스템은 분산 환경에서 발생한 모든 이벤트를 발생한 &lt;b&gt;절대적인 시간&lt;/b&gt; 기준으로 순서(global order)를 매길 수 있습니다. Linearizable 한 시스템에서 발생한 모든 이벤트는 단일 프로세스에서 이벤트를 순차적으로 발생시킨 상황처럼&amp;nbsp;모든 이벤트 간의 순서를 알 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Total Ordering&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Total ordering를 보장하는 시스템은 분산 환경에서 발생한 모든 이벤트의 순서를 매길 수 있습니다. Linearizable과 다른 점은 이벤트가 발생한 절대적인 시간이 아닌, &lt;b&gt;논리적인 순서&lt;/b&gt;를 지정할 수 있음을 의미합니다. 예를 들어 total ordering을 보장하는 시스템에서 두 이벤트 A, B가 순차적으로 발생했다고 보더라도 linearizable 한 시스템에서 B가 A보다 절대적인 시간을 기준으로 더 빠르게 발생한 이벤트라면 B가 A보다 순서가 빠르다고 간주합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Lamport Clock(or Timestamp)&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lamport clock은 total ordering을 가능케 하는 대표적인 방식입니다. 동작 방식은 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;N개의 프로세스로 구성된 분산 시스템이라고 가정하면(p0, p1, ... pN-1)&amp;nbsp;&lt;/li&gt;
&lt;li&gt;각각의 프로세스는 스스로 생성하는 모든 이벤트에 (logical_clock, process_id)를 추가합니다. logical_clock는 timestamp가 될 수도 있고 또는 전체 프로세스에서 가장 큰 값이 될 수도 있습니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;두 이벤트의 logical_clock이 동일하면 process_id를 활용해서 이벤트 간 순서를 비교합니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lamport clock을 사용하는 시스템에서 만드는 모든 이벤트는 (logical_clock, process_id)가 유일함을 보장할 수 있기에 total ordering이 가능합니다. Total ordering은 가능하지만 linearizable은 왜 보장할 수 없을까요? Lamport clock에서 각 프로세스가 스스로의 시간을 사용하면 linearizable을 보장할 수 있는 게 아닐까요?&amp;nbsp;각 프로세스의 시간이 과연 동기화(clock synchronization)가 되어있을까요? 정확하게 동기화가 되지 않는 이상 각각의 프로세스에서 생성된 timestamp는 절대적인 기준의 시간이 될 수 없습니다. 따라서 linearizable 한 시스템이 보장해야 하는 이벤트 간 절대적인 시간 기준에 대한 조건이 충족될 수 없기 때문에 lamport clock이 각 프로세스의 timestamp를 사용하더라도 linearizable 하다고 볼 수 없습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Causal Consistency&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Causal consistency를 보장하는 시스템은 A 이벤트(원인)로 인해 B 이벤트(결과)가 발생하였을 때, B로 인한 효과를 관찰할 수 있으면 시스템의 어디에서든 A로 인한 효과도 관찰할 수 있도록 보장합니다. Total ordering, linearizable과 비교하자면 앞선 두 속성은 모든 이벤트 간의 순서(global order)에 관심을 두지만 causal consistency는 부분적인 이벤트들 간의 순서(partial order)에 관심을 둡니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Vector Clock&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vector clock은 causal consistency를 보장할 수 있는 대표적인 방식입니다. Vector clock의 특성은 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Vector clock은 일종의 배열(array)입니다. 시스템 내 전체 프로세스의 수를 N이라고 가정했을 때 vector clock의 길이는 N이 됩니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;각각의 프로세스는 모든 메시지에 자신이 알고 있는 vector clock을 포함시킵니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 방식은 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로세스가 실행되면 vector clock을 초기화합니다. 시스템에 5개의 프로세스가 있다고 가정하면 vector clock은 [0, 0, 0, 0, 0]으로 초기화됩니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;프로세스가 이벤트를 생성하는 경우 vector clock에서 해당하는 위치의 값을 1 증가시킵니다. [0, 0, 0, 0, 0] -&amp;gt; [0, 1, 0, 0, 0]&amp;nbsp;&lt;/li&gt;
&lt;li&gt;다른 프로세스로부터 메시지를 수신받으면 메시지에 포함된 vector clock과 자신이 소유한 vector clock을 합칩니다. Vector clock의 최댓값만을 선택하는 방법으로 합치게 됩니다. 예를 들어 [0, 1, 0, 0, 0]과 [1, 0, 0, 0, 0]을 합치면 [1, 1, 0, 0, 0]이 됩니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Vector clock에서 causal consistency가 보장되는 원리는 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;[0, 0, 0, 0, 0]는 [1, 1, 1, 1, 1]과 비교했을 때 확실하게 이전에 발생했음을 알 수 있습니다. 즉, 예시의 두 vector clock은 비교 가능합니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;[0, 1, 0, 0, 0]과 [1, 0, 0, 0, 0]에서 어떤 이벤트가 먼저 발생했는지 알 수 없습니다. 즉, 예시의 두 vector clock은 비교가 불가능합니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 서로 다른 이벤트가 인과관계가 있다면 vector clock 간에 비교가 가능합니다(이벤트를 발생시키는 주체들끼리 vector clock을 주고받으면서 순차적으로 값을 올리기 때문). 즉, vector clock을 활용하면 이벤트 간에 causal consistency를 보장할 수 있음을 의미합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Causal consistent 한 시스템에서 우리가 &quot;캐릭터명 생성&quot; 기능을 만든다고 가정해 보겠습니다. 캐릭터명은 전체 시스템에서 유일함을 보장해야 한다는 제약조건이 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 클라이언트가 동시에 A, B 요청을 보냈다고 가정하겠습니다. 두 요청은 모두 &quot;devvy&quot;라는 캐릭터명이 포함되었다면 causal consistent 한 시스템은 이를 어떻게 처리할 수 있을까요? 앞서 설명드린 vector clock을 사용해서 A 요청이 [1, 0]에 해당하고 B 요청이 [0, 1]에 해당하는 vector clock을 사용한다고 가정하겠습니다. 그럼 과연 어떤 요청을 수용하고 어떤 요청을 거절해야 할까요. Causal consistent 시스템에서는 인과 관계가 있는 이벤트의 순서는 보장해 주지만 그 외의 경우에는 순서를 보장하지 않습니다. 이러한 한계를 극복할 수 있는 방법으로 total order broadcast가 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Total Order Broadcast(Atomic Broadcast)&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Total order broadcast부터는 애플리케이션 개발자들이 자주 접하는 시스템들에 구현체가 존재합니다. 예를 들면 Zookeeper의 ZAB(Zookeeper Atomic Broadcast)라던지 etcd의 RAFT 등이 있습니다. Total order broadcast는 다음과 같은 속성을 지닙니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Reliable delivery: 메시지는 모든 노드로 전달됩니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Totally ordered delivery: 메시지는 모든 노드로 동일한 순서로 전달됩니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 두 특성을 보장하기 때문에 causal consistent 한 시스템에서 불가능했던 기능이 구현이 가능해집니다. Total order broadcast의 대표적인 구현체인 Raft에 대해 살펴보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Raft&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Raft에는 3가지 유형의 참여자가 존재합니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Leader: 클라이언트의 요청을 처리하며 follower에게 복제 데이터를 제공하는 주체입니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Follower: Leader로부터 복제 데이터를 받아 저장하지만 클라이언트의 요청을 처리하지는 않습니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Candidate: Leader로 선출될 가능성이 있는 프로세스를 의미합니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 방식은 크게 leader election과 log replication으로 나뉩니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Leader election
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Follower process는 leader와 heartbeat message를 주고받습니다. Heartbeat가 일정 시간 동안 오지 않으면 leader election를 수행합니다.&lt;/li&gt;
&lt;li&gt;Follower는 자기 자신이 새로운 리더가 되기 위해 자신의 상태를 candidate으로 변경하고 다른 프로세스들에게 자신을 leader로 뽑아달라고 요청(vote request)합니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;대다수(보통 정족수)의 프로세스에 동의를 받은 candidate가 leader가 됩니다. &quot;대다수&quot;라는 조건 덕분에 split brain 현상을 방지할 수 있습니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Log replication&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Leader가 클라이언트의 쓰기 요청을 수신하면 스스로의 log에 기록하고 follower의 log에도 요청을 기록하도록 합니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;대다수의 follower가 log 복제에 성공하면 클라이언트 요청을 처리했다고 간주하고 클라이언트에게 응답합니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각화를 통해 Raft를 더 자세히 이해하고 싶다면 아래 링크를 확인해주세요.&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1731034350224&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Raft&quot; data-og-description=&quot;&quot; data-og-host=&quot;thesecretlivesofdata.com&quot; data-og-source-url=&quot;https://thesecretlivesofdata.com/raft/&quot; data-og-url=&quot;https://thesecretlivesofdata.com/raft/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://thesecretlivesofdata.com/raft/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://thesecretlivesofdata.com/raft/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Raft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;thesecretlivesofdata.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Others</category>
      <category>causal consistent</category>
      <category>linearizable</category>
      <category>RAFT</category>
      <category>total order broadcast</category>
      <category>분산 시스템</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/100</guid>
      <comments>https://code-run.tistory.com/100#entry100comment</comments>
      <pubDate>Fri, 8 Nov 2024 11:56:51 +0900</pubDate>
    </item>
    <item>
      <title>성급하게 시스템을 최적화하는 당신을 위한 글</title>
      <link>https://code-run.tistory.com/99</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;제가 개발 중인 랭킹 시스템은 유저가 앱에 접속했을 때 처음 바라보는 화면이기 때문에 높은 TPS를 견딜 수 있어야 합니다. 또한 장 시간에 따라(증권사앱) TPS가 들쭉날쭉해서... 급격하게 증가하는 TPS도 견딜 수 있어야 해서 다양한 최적화 전략을 사용하고 있습니다. 하지만 기존에 사용하던 최적화 방법으로는 랭킹에 신규 기능을 추가하는 게 불가능했습니다(왜 그런지는 뒤에서 설명하겠습니다). 또한 기존의 최적화 방법이 전혀 도움 되지&amp;nbsp;않았을뿐더러, 불필요한 TPS를 생성하는, 즉 self DDOS를 유발하고 있었음을 알게 되었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 랭킹 시스템&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2046&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AoHdx/btsKb9xNABQ/Cwcg63eESlH1bg6mXgy5u0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AoHdx/btsKb9xNABQ/Cwcg63eESlH1bg6mXgy5u0/img.png&quot; data-alt=&quot;랭킹 시스템 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AoHdx/btsKb9xNABQ/Cwcg63eESlH1bg6mXgy5u0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAoHdx%2FbtsKb9xNABQ%2FCwcg63eESlH1bg6mXgy5u0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2046&quot; height=&quot;780&quot; data-origin-width=&quot;2046&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;랭킹 시스템 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 시스템은 아래와 같은 요구사항을 만족해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장 시간에 따라 다르지만 최대 4000 TPS&amp;nbsp;&lt;/li&gt;
&lt;li&gt;유저가 처음 보는 화면이기 때문에 짧은 latency&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Upstream 서버(ranking 서버)에 과도한 부하를 발생시키면 안 됨&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저의 요청이 순간적으로 몰리는 경우 발생하는 cache stampede 현상도 고려하여 시스템을 설계하였습니다. 그 결과 스케줄러로 랭킹 정보를 미리 메모리에 캐시하고&amp;nbsp;유저 유청은 캐시로부터 꺼내와서 응답하는 방식으로 랭킹 시스템을 구현했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 시스템의 스케줄러는 다음과 같이 동작했습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2초 주기로 upstream 서버(ranking 서버)의 랭킹 정보를 조회하여 메모리에 캐싱&amp;nbsp;&lt;/li&gt;
&lt;li&gt;유저의 요청은 메모리의 캐시를 조회하여 응답&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만 이 방식에는 아래와 같은 문제점이 존재합니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장 시간에 관계없이 항상 2초 주기로 upstream 서버의 랭킹 API를 호출. 호출이 적은 장 시간에도 불필요한 트래픽이 계속 발생&lt;/li&gt;
&lt;li&gt;조회해야 하는 랭킹의 수(또는 조합의 수)가 증가할수록 upstream 서버의 API 호출 횟수가 급격히 증가. Dashboard 서버의 수도 많았기 때문에 scheduler로 인해 발생하는 TPS가 높음&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 설계는 랭킹 시스템이 복잡해지기 전까지 정상적으로 동작했습니다. 하지만 랭킹 시스템을 고도화하고 새로운 기능(거래 위험 종목 포함 여부, 기간 설정 등)이 추가되면서 스케줄러로 캐시 해야 하는 랭킹 조합의 수가 기하급수적으로 증가했습니다. 단순 계산으로만 해도 기존 시스템 TPS의 20 ~ 30배가 발생했기에 구조 개선이 필요했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구조 개선 작업&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조 개선을 위한 다양한 아이디어를 주고받았습니다. 스케줄러와 redis pub/sub을 활용해 upstream 서버의 부하를 최소화하고 redis에서 대신 처리하자부터... 하지만 스케줄러가 존재하는 이상 TPS를 줄일 수 있는 방법은 없었습니다. 다양한 아이디어가 오가던 와중 문득 &quot;왜 스케줄러를 사용하고 있지?&quot;라는 생각이 들었습니다. 클라이언트가 호출했을 때 upstream 서버 API를 호출해서 in-memory에 캐싱하면 되는 거 아닌가라는 생각이 들었습니다. 다만 우리가 스케줄러를 도입한 이유는 cache stampede 현상 때문이었습니다. 하지만 cache stampede가 정말 발생하는지를 확인해 본 적은 없었습니다. Cache stampede가 발생하지 않는데 스케줄러를 도입해서 불필요한 최적화를 진행한 게 아닐까?라는 생각이 들었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cache stampede가 자주 발생하는 상황은 다음과 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저의 요청이 한 번에 몰릴 때, 즉 순간적인 TPS가 높을 때&amp;nbsp;&lt;/li&gt;
&lt;li&gt;요청을 처리하는데 오래 걸릴 때, 즉 여기서는 ranking 서버의 API가 요청을 처리하는 게 오래 걸리는 경우입니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dashboard 서버의 경우 최대 TPS가 5000이라 가정하겠습니다(보통 4000 TPS 정도 나옵니다). Ranking 서버의 API 평균 응답 속도를 보니 10 ~ 20ms였습니다. 운영 환경에서 실행 중인 Dashboard 서버의 수(pod 수)는 100개(100 ~ 120개) 정도 되니 서버당 50 TPS를 처리한다고 볼 수 있습니다. 단순 계산으로 20ms 당(1s를 50으로 나누면 20ms) 1 TPS가 발생합니다. 그런데 Ranking 서버의 응답 속도가 10 ~ 20ms이면.... 물론 단순 계산이기는 하지만 cache stampede가 발생할 확률이 낮은 걸 알 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cache stampede 현상이 발생할 확률이 낮았지만 운영환경에서는 예상하지 못한 온갖 현상이 발생합니다. 따라서 cache stampede를 어느 정도는 방지할 수 있으면서도 ranking 서버에 부하를 주지 않는 방법을 생각해야 했습니다. 결과적으로 probability early expiration를 적용하기로 했습니다(&lt;a href=&quot;https://behumblefool.medium.com/cache-stampede-solution-probabilistic-early-expiration-ccd777be171a&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Probability Early Expiration 상세 설명&lt;/a&gt;).&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1358&quot; data-origin-height=&quot;1672&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/v97gp/btsKck646yx/TQJiTnivjo4FipEIel0WYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/v97gp/btsKck646yx/TQJiTnivjo4FipEIel0WYK/img.png&quot; data-alt=&quot;Probability Early Expiration 원리&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/v97gp/btsKck646yx/TQJiTnivjo4FipEIel0WYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fv97gp%2FbtsKck646yx%2FTQJiTnivjo4FipEIel0WYK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1358&quot; height=&quot;1672&quot; data-origin-width=&quot;1358&quot; data-origin-height=&quot;1672&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Probability Early Expiration 원리&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 캐시는 만료 시간이 존재합니다. 만료 시간이 지나면 해당 캐시가 무효화되면서 삭제됩니다. Probability early expiration은 만료 시간에 도달하기 전에 캐시를 갱신합니다(만료시간이 얼마나 남았는지에 따라 갱신 확률이 달라집니다). 캐시 만료 이전에 갱신하기 때문에 cache stampede 현상을 방지할 수 있습니다(단점으로는 로직이 약간 복잡해지고 추가적인 연산이 발생한다는 점입니다).&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자, 이제 해결 방법에 대해 충분히 고민했으니 적용해보고 결과를 확인해서 우리의 가정이 맞는지 확인이 필요합니다. Probability early expiration을 적용 후 다음과 같은 결과를 얻을 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 스케줄링 방식에 비해 upstream 서버의 API 호출 수가 전반적으로 낮아짐&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Scheduling으로 인한 불필요한 API 호출 수가 줄어들어 장 시간이 아닌 경우에 낮은 TPS 유지됨&amp;nbsp;&lt;/li&gt;
&lt;li&gt;랭킹에 다양한 기능이 추가될 수 있는 구조가 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TPS 외에 별개로 cache 만료 비율 등 다양한 지표를 보고 있습니다만 cache stampede 현상으로 인해 upstream 서버에 과도한 부하가 발생하는 현상은 관찰할 수 없었습니다. 그래서 다음 스텝으로 probability early expiration 로직을 제거해서 테스트를 진행해 보고자 합니다. Probability early expiration도 결국 cache stampede 방지를 위해 기존 캐시 만료 시간보다 짧은 만료 시간을 가져가므로... probability early expiration을 걷어낼 수 있으면 걷어내는 게 upstream 서버 API 호출 횟수를 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Latency, upstream 서버 부하 낮추기, cache stampede 등의 다양한 위험 요소를&amp;nbsp; 제거하기 위해 설계 초기부터 최적화를 진행하였습니다. 하지만 그 최적화들이 오히려 더 큰 부하를 발생시켰고 불필요한 비용을 유발하고 있었습니다. 불필요한 최적화는 운영 비용뿐 아니라 신규 기능 추가가 어려워지거나 최적화를 걷어내는 작업이 필요하게 될 수 있습니다. 따라서 최적화를 시작하기 전에 측정을 통해 그 최적화가 정말 필요한지 고민이 꼭 필요합니다. 다양한 최적화를 해봤지만 최적화를 걷어내는 작업은 이번이 처음이라 제 나름 새롭고 즐거운 경험이었습니다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Others</category>
      <category>랭킹</category>
      <category>최적화</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/99</guid>
      <comments>https://code-run.tistory.com/99#entry99comment</comments>
      <pubDate>Fri, 18 Oct 2024 18:00:10 +0900</pubDate>
    </item>
    <item>
      <title>첫 테크 블로그 기고</title>
      <link>https://code-run.tistory.com/98</link>
      <description>&lt;figure id=&quot;og_1722338407551&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;유연하고 확장 가능한 배너 기능 구현하기&quot; data-og-description=&quot;어떠한 요구사항도 수용할 수 있는 기능은 어떻게 만들 수 있을까요? 토스증권에서 유저에게 노출하는 배너 기능을 어떻게 유연하고 확장 가능하게 구현하였는지 공유 드려요.&quot; data-og-host=&quot;toss.tech&quot; data-og-source-url=&quot;https://toss.tech/article/intelligence_banner&quot; data-og-url=&quot;https://toss.tech/article/intelligence_banner&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/exsp53/hyWGQsPmoP/Ly16kwTmjEPv6HoiqWn97k/img.png?width=3125&amp;amp;height=1598&amp;amp;face=0_0_3125_1598,https://scrap.kakaocdn.net/dn/UKXDw/hyWGNXaXVe/To70xV6LwDJSUrCjZ3Utmk/img.png?width=3125&amp;amp;height=1598&amp;amp;face=0_0_3125_1598,https://scrap.kakaocdn.net/dn/J2Eok/hyWGWGBBIy/bDD7QJg3Boku2VQ7eJuook/img.png?width=2000&amp;amp;height=1302&amp;amp;face=0_0_2000_1302&quot;&gt;&lt;a href=&quot;https://toss.tech/article/intelligence_banner&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://toss.tech/article/intelligence_banner&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/exsp53/hyWGQsPmoP/Ly16kwTmjEPv6HoiqWn97k/img.png?width=3125&amp;amp;height=1598&amp;amp;face=0_0_3125_1598,https://scrap.kakaocdn.net/dn/UKXDw/hyWGNXaXVe/To70xV6LwDJSUrCjZ3Utmk/img.png?width=3125&amp;amp;height=1598&amp;amp;face=0_0_3125_1598,https://scrap.kakaocdn.net/dn/J2Eok/hyWGWGBBIy/bDD7QJg3Boku2VQ7eJuook/img.png?width=2000&amp;amp;height=1302&amp;amp;face=0_0_2000_1302');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;유연하고 확장 가능한 배너 기능 구현하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;어떠한 요구사항도 수용할 수 있는 기능은 어떻게 만들 수 있을까요? 토스증권에서 유저에게 노출하는 배너 기능을 어떻게 유연하고 확장 가능하게 구현하였는지 공유 드려요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;toss.tech&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음으로 사내 테크 블로그에 프로젝트와 관련된 글을 기고하게 됐습니다. 테크 블로그를 작성하면서 들었던 생각은 앞으로도 저의 작업을 다른 사람들에게 공유할 수 있도록 잘 정리하는 습관의 중요성을 느끼게 됐습니다. 정리된 문서가 부족하다 보니 기억에 의존하거나 프로젝트를 전체적으로 다 살펴보고 나서야 글을 작성할 수 있는 준비가 됐었습니다. 하지만 또 그 준비를 하는 과정에서 프로젝트에 대한 이해도를 높일 수 있어서 나름 좋은 점도 분명히 있었습니다.&lt;br /&gt;&lt;br /&gt;저의 첫 테크블로그는 그 어떠한 요구사항이 들어와도 이를 수용할 수 있는 기능을 어떻게 구현했는지를 소개합니다. 알면 쉽지만 모르면 어떻게 구현해야 하는지 방향도 잡기 어려운 내용이라 생각했기 때문에 이를 블로그 글로 작성하면 비슷한 기능을 구현해야 하는 다른 서버 개발자 분들께 도움이 될까하여 글을 작성하게 됐습니다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Career/회고</category>
      <category>테크블로그</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/98</guid>
      <comments>https://code-run.tistory.com/98#entry98comment</comments>
      <pubDate>Tue, 30 Jul 2024 20:19:30 +0900</pubDate>
    </item>
    <item>
      <title>터미널 로딩 속도 개선하기</title>
      <link>https://code-run.tistory.com/97</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 생산성 향상에 도움 되는 툴을 이것저것 만지면서 익숙해지고 있는 와중에 사용 중인 zsh 터미널이 느려지는 게 체감되기 시작했습니다. 작업을 하는 도중 &quot;터미널을 켜야겠네&quot;라는 생각이 들면 무의식적으로 귀찮다는 생각이 들 정도였기에, 이는 해결해야 하는 문제가 됐음이 분명했습니다. 터미널 속도를 향상하는 방법과 관련하여 훌륭한 &lt;a href=&quot;https://blog.mattclemente.com/2020/06/26/oh-my-zsh-slow-to-load/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;포스팅&lt;/a&gt;을 찾았고, 해당 포스팅의 내용을 적용해서 터미널 로딩 속도를 개선했던 과정을 공유드리고자 포스팅을 작성합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;느린 지점 파악하기&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;zsh는 plugin을 설치함으로써 다양한 기능을 활용할 수 있습니다. 초기에 의심했던 부분은 설치된 plugin을 로드하는 부분에서 느리지 않을까 하는 생각이었습니다. 그래서 각각의 plugin을 로드하는 시간을 확인하기 위해 &quot;~/. oh-my-zsh/oh-my-zsh.sh&quot;에 아래와 같이 코드를 추가하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1190&quot; data-origin-height=&quot;656&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRc5xY/btsIqVONSJk/qM5Dxcwt28aGIUolA9MmM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRc5xY/btsIqVONSJk/qM5Dxcwt28aGIUolA9MmM1/img.png&quot; data-alt=&quot;플러그인 로딩 속도 측정을 위해 코드 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRc5xY/btsIqVONSJk/qM5Dxcwt28aGIUolA9MmM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRc5xY%2FbtsIqVONSJk%2FqM5Dxcwt28aGIUolA9MmM1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1190&quot; height=&quot;656&quot; data-origin-width=&quot;1190&quot; data-origin-height=&quot;656&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;플러그인 로딩 속도 측정을 위해 코드 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 코드를 추가하고 터미널을 실행하면 아래와 같이 각 플러그인을 로드하는 데 걸린 시간을 확인할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;874&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0nO3S/btsIrdVZ1sE/QEXsaQK3GjkrLXZx5oTDpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0nO3S/btsIrdVZ1sE/QEXsaQK3GjkrLXZx5oTDpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0nO3S/btsIrdVZ1sE/QEXsaQK3GjkrLXZx5oTDpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0nO3S%2FbtsIrdVZ1sE%2FQEXsaQK3GjkrLXZx5oTDpK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;874&quot; height=&quot;334&quot; data-origin-width=&quot;874&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과를&amp;nbsp;보시면&amp;nbsp;아시겠지만,&amp;nbsp;plugin을&amp;nbsp;로딩하는&amp;nbsp;데&amp;nbsp;소요되는&amp;nbsp;시간은&amp;nbsp;매우&amp;nbsp;짧기&amp;nbsp;때문에&amp;nbsp;plugin이&amp;nbsp;문제가&amp;nbsp;아닌&amp;nbsp;것&amp;nbsp;같았습니다.&amp;nbsp;다음&amp;nbsp;의심할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;부분은&amp;nbsp;무엇일지&amp;nbsp;고민해&amp;nbsp;보았습니다.&amp;nbsp;Zsh&amp;nbsp;로딩&amp;nbsp;성능을&amp;nbsp;개선하기&amp;nbsp;위해&amp;nbsp;nvm을&amp;nbsp;삭제하는&amp;nbsp;것이&amp;nbsp;도움이&amp;nbsp;된다는&amp;nbsp;의견을&amp;nbsp;많이&amp;nbsp;찾을&amp;nbsp;수&amp;nbsp;있었는데,&amp;nbsp;그&amp;nbsp;이유는&amp;nbsp;nvm을&amp;nbsp;사용하지&amp;nbsp;않는&amp;nbsp;경우에도&amp;nbsp;eager&amp;nbsp;load&amp;nbsp;방법을&amp;nbsp;활용하기&amp;nbsp;때문이라고&amp;nbsp;설명하고&amp;nbsp;있습니다.&amp;nbsp;이&amp;nbsp;문제를&amp;nbsp;해결하기&amp;nbsp;위해&amp;nbsp;저는&amp;nbsp;nvm을&amp;nbsp;삭제하고&amp;nbsp;nvm과&amp;nbsp;기능이&amp;nbsp;동일하지만&amp;nbsp;더&amp;nbsp;빠른&amp;nbsp;fnm을&amp;nbsp;설치했습니다.&lt;/p&gt;
&lt;figure id=&quot;og_1720249144781&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Schniz/fnm:   Fast and simple Node.js version manager, built in Rust&quot; data-og-description=&quot;  Fast and simple Node.js version manager, built in Rust - Schniz/fnm&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://disq.us/url?url=https%3A%2F%2Fgithub.com%2FSchniz%2Ffnm%3AA3C5QdJWopX9sveJ7twwuZg4Q9Y&amp;amp;cuid=4083538&quot; data-og-url=&quot;https://github.com/Schniz/fnm&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eM5So2/hyWvXMkW3F/6wgEGoO7Z8qvoAbZ1NEa5K/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640&quot;&gt;&lt;a href=&quot;https://disq.us/url?url=https%3A%2F%2Fgithub.com%2FSchniz%2Ffnm%3AA3C5QdJWopX9sveJ7twwuZg4Q9Y&amp;amp;cuid=4083538&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://disq.us/url?url=https%3A%2F%2Fgithub.com%2FSchniz%2Ffnm%3AA3C5QdJWopX9sveJ7twwuZg4Q9Y&amp;amp;cuid=4083538&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eM5So2/hyWvXMkW3F/6wgEGoO7Z8qvoAbZ1NEa5K/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Schniz/fnm:   Fast and simple Node.js version manager, built in Rust&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  Fast and simple Node.js version manager, built in Rust - Schniz/fnm&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nvm을 삭제하고 fnm을 설치한 결과, 터미널의 로딩 속도가 명확히 개선되었습니다. 로딩 시간이 2초에서 0.1초로 단축된 것뿐이지만, &quot;터미널을 열 때 느리고 귀찮다...&quot;는 생각을 없앨 수 있다는 점에서 이 작업은 꼭 필요한 조치였습니다. 생산성 향상은 이와 같은 작은 변경들이 모이면 일어나기 때문에 여러분의 터미널이 느려진다면 위와 같이 개선을 고려해 보시는 게 어떨까요?&lt;/p&gt;</description>
      <category>Others</category>
      <author>Devvy_</author>
      <guid isPermaLink="true">https://code-run.tistory.com/97</guid>
      <comments>https://code-run.tistory.com/97#entry97comment</comments>
      <pubDate>Sat, 6 Jul 2024 16:07:03 +0900</pubDate>
    </item>
  </channel>
</rss>