<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>DOTELOPER</title>
    <link>https://doteloper.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 13 Apr 2026 06:58:38 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>점이</managingEditor>
    <image>
      <title>DOTELOPER</title>
      <url>https://tistory1.daumcdn.net/tistory/4831714/attach/7535bfefcead40668a1da742e9136dc6</url>
      <link>https://doteloper.tistory.com</link>
    </image>
    <item>
      <title>실무에서의 Claude Code 활용법: 개발 플로우 자동화 (confluence, jira, github)</title>
      <link>https://doteloper.tistory.com/153</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 개발자 대부분이 그렇듯, 나도 Claude Code 같은 에이전트형 도구를 일상적으로 쓰고 있다. 그런데&amp;nbsp;쓸수록 한 가지 질문이 자꾸 떠올랐다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 개발 업무에서 가장 시간을 잡아먹는 건 '코드를 치는 순간'이 아니잖아?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기획서를 읽고, 위키에 개발 문서를 정리하고, 티켓을 쪼개고, 구현하고, PR을 올리고, 리뷰 받고, 다시 문서를 맞추는&lt;/b&gt; 이 전체 흐름 안에서 정작 '순수하게 코드를 치는 시간'은 작은 조각일 뿐이다. AI가 코드만 잘 써줘도 개발자는 여전히 앞뒤 맥락을 이어 붙이느라 바쁘다.&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;그래서 이번 글에서는 실제 실무에서 업무를 진행하며 Claude Code의 &lt;b&gt;MCP(Model Context Protocol)&lt;/b&gt; 와 &lt;b&gt;Skill&lt;/b&gt; 두 가지를 활용해, 기획서에서 시작해 PR 이후 재리뷰까지 이어지는 개발 플로우를 하나의 흐름으로 꿰어본 경험을 공유해 보려고 한다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;우리가 그리려던 개발 플로우&lt;/h2&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;기획서(PRD)
   &amp;darr;
개발 문서 (Wiki)
   &amp;darr;
Jira 티켓
   &amp;darr;
구현 &amp;amp; 커밋
   &amp;darr;
PR 생성
   &amp;darr;
기획서&amp;middot;개발 문서 재리뷰&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 단계는 이미 팀에서 다 하고 있는 일들이다. 다만 단계 사이에 생기는 '&lt;b&gt;번역 비용&lt;/b&gt;'이 생각보다 크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획서를 읽고 개발 관점으로 재해석하는 시간, 개발 문서에 정리한 항목을 다시 티켓으로 쪼개는 시간, 구현이 끝난 뒤에 원래 문서와 달라진 부분을 손보는 시간 같은 것들이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 '번역 비용'을 줄일 수 있다면, 플로우 전체가 훨씬 가벼워질 거라고 생각했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 가지 축: MCP와 Skill&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 플로우를 자동화하기 위한 도구로 우리 팀은 Claude Code의 두 가지 기능을 중심으로 사용했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MCP&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MCP는 쉽게 말해 &lt;b&gt;Claude가 외부 도구를 직접 읽고 쓸 수 있게 해주는 표준 인터페이스&lt;/b&gt;다. 실제로 붙인 건 두가지다.&lt;/p&gt;
&lt;table style=&quot;height: 77px; width: 669px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style3&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center; width: 137px;&quot;&gt;&lt;b&gt;MCP&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 291px;&quot;&gt;하는 일&lt;/td&gt;
&lt;td style=&quot;text-align: center; width: 241px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 137px;&quot;&gt;&lt;b&gt;Atlassian MCP&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 291px;&quot;&gt;Confluence Wiki, Jira&lt;/td&gt;
&lt;td style=&quot;width: 241px;&quot;&gt;&lt;a href=&quot;https://github.com/atlassian/atlassian-mcp-server&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/atlassian/atlassian-mcp-server&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 137px;&quot;&gt;&lt;b&gt;GitHub MCP&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 291px;&quot;&gt;리포지토리 탐색, PR 생성, 리뷰 코멘트&lt;/td&gt;
&lt;td style=&quot;width: 241px;&quot;&gt;&lt;a href=&quot;https://github.com/github/github-mcp-server&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/github/github-mcp-server&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 개만 있어도 &quot;문서 &amp;rarr; 티켓 &amp;rarr; 코드 &amp;rarr; PR&quot;이라는 종축이 완성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Claude가 직접 위키에서 기획서를 읽고, Jira에 티켓을 만들고, GitHub에 PR을 올리는 것까지 한 세션 안에서 가능해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(요즘은 MCP의 느린 속도로 CLI로 많이 갈아타고 있는 추세라고 하던데, 조만간 바꾸어 볼 계획이다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Skill&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MCP가 '도구'라면 Skill은 &lt;b&gt;'그 도구를 어떻게 쓸지에 대한 합의'&lt;/b&gt; 다. 예를 들어 &quot;개발 문서를 작성한다&quot;는 일은 사람마다 스타일이 다를 수밖에 없는데, 팀에서 합의된 템플릿과 원칙을 Skill로 적어두면 Claude가 그 규칙을 따라 초안을 만들어 준다.&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;한 사람이 고민해서 다듬은 Skill이 팀 전체의 자산으로 쌓이는 구조라는 점도 중요한데, 이 부분은 바로 뒤에서 '플러그인'과 함께 다시 얘기하려고 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;플러그인 &amp;mdash; 팀이 같은 기준선에서 Claude Code를 쓰게 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 여기까지 얘기한 MCP와 Skill은 전부 &quot;혼자서도 쓸 수 있는&quot; 도구들이다. 문제는 팀이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각자 알아서 MCP를 붙이고, 각자 알아서 프롬프트를 다듬고, 각자 알아서 Skill을 만들면, 같은 회사에서 같은 코드를 만지는데 Claude Code가 뱉어내는 결과가 사람마다 조금씩 다르게 생기기 시작한다. 개인 생산성은 올라가도, 팀 단위의 일관성은 오히려 흔들릴 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 팀 단위로 쓰기 시작하면서 가장 먼저 한 일은, &lt;b&gt;Claude Code를 쓰는 방식 자체를 팀 단위로 통일&lt;/b&gt;하는 것이었다. 구체적으로는 이런 것들이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 MCP 서버를 붙일지, 어떤 설정 값을 쓸지&lt;/li&gt;
&lt;li&gt;어떤 Skill과 커맨드를 제공하여 워크플로우를 맞출지&lt;/li&gt;
&lt;li&gt;팀의 코딩 스타일&amp;middot;아키텍처 원칙&amp;middot;PR 템플릿을 Claude가 참조할 수 있는 &lt;b&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/b&gt; 와 &lt;b&gt;&lt;code&gt;.claude/rules/*&lt;/code&gt;&lt;/b&gt; 형태로 어떻게 떨굴지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 각자 로컬에 복붙해 두는 대신, Claude Code의 &lt;b&gt;플러그인&lt;/b&gt;이라는 포장 단위로 묶어서 팀에 배포했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 규칙이 바뀌면 리포지토리에 푸시하는 것만으로 팀 전체에 반영된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전이 하나로 맞춰지니, 같은 질문을 같은 커맨드로 던지면 누가 실행하든 거의 같은 품질의 결과가 나온다.&lt;/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;code&gt;/setup&lt;/code&gt; 같은 초기화 커맨드도 넣어두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 프로젝트에서 이 커맨드를 한 번 실행하면, 언어&amp;middot;프레임워크를 자동으로 감지해서 해당 스택에 맞는&lt;b&gt; Claude Code 컨벤션 파일&lt;/b&gt;을 &lt;code&gt;CLAUDE.md&lt;/code&gt;와 &lt;code&gt;.claude/rules/&lt;/code&gt; 밑에 떨군다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Kotlin/Spring 프로젝트면 Kotlin 코드 스타일, Spring 구조, API 컨벤션, 테스트 컨벤션 같은 문서가 각각 떨어지고, 각 문서에는 &lt;code&gt;paths&lt;/code&gt;로 &quot;어떤 파일을 편집할 때 이 문서를 같이 로드할지&quot;가 선언되어 있다. 덕분에 컨트롤러를 수정할 때는 API 컨벤션이, 테스트 파일을 수정할 때는 테스트 컨벤션이 자동으로 Claude의 컨텍스트에 들어온다. 사람은 &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;여기서 한 가지 짚고 가자면, 이걸 &quot;팀의 컨벤션을 정의한다&quot;라고 부르는 건 엄밀히 말해 조금 어색하다. &lt;b&gt;코딩 컨벤션 자체는 이미 팀에 존재하던 것&lt;/b&gt;이고, 플러그인이 한 일은 그 컨벤션을 &lt;b&gt;Claude가 읽을 수 있는 형태로 박제&lt;/b&gt;하고, 동시에 &lt;b&gt;Claude Code를 쓰는 방식 자체를 팀 단위로 묶어내는&lt;/b&gt; 것에 더 가깝다. 말하자면 &lt;b&gt;플러그인은 &quot;우리 팀의 Claude Code 사용 설명서&quot;를 코드로 옮겨 둔 결과물인 셈&lt;/b&gt;이다. 새로 합류한 팀원이 따로 튜토리얼 없이도 첫날부터 팀의 기준선 위에서 Claude Code를 쓸 수 있다는 것, 이 점이 생각보다 큰 효과였다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단계별로 살펴보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 앞에서 그린 플로우를 단계별로 따라가 보자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계. 기획서 &amp;rarr; 개발 문서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 맞닥뜨리는 단계이자, 사실 가장 '개발자의 뇌'가 많이 갈려 나가는 단계다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획서는 기획자의 언어로 쓰여 있고, 개발자는 그걸 읽으면서 머릿속으로 API, 도메인, 예외 케이스, 배치 여부 같은 걸 재구성해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계를 Skill로 표준화했다. 대략 이런 식으로 돌아간다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기획서의 Confluence 링크를 전달한다.&lt;/li&gt;
&lt;li&gt;Claude가 &lt;b&gt;Confluence MCP&lt;/b&gt;로 해당 페이지를 읽는다.&lt;/li&gt;
&lt;li&gt;필요한 경우 관련된 기존 문서&amp;middot;코드&amp;middot;API 스펙도 같이 읽는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개발 문서 작성 스킬&lt;/b&gt;의 템플릿에 따라 구현 항목 단위로 정리한다.&lt;/li&gt;
&lt;li&gt;결과를 다시 Confluence의 지정된 공간에 문서로 생성한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 규칙이 하나 있는데, &lt;b&gt;기획서에 없는 내용은 Claude가 임의로 채우지 않도록 강제&lt;/b&gt;했다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단이 필요한 부분은 &lt;code&gt;[확인 필요]&lt;/code&gt; 같은 태그로 표시해 '검토 필요 항목' 섹션에 모아둔다. 개발자는 이 섹션만 한 번 훑어보면, 기획자에게 물어볼 것들을 한 번에 정리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경험적으로, 이 단계에서 가장 크게 얻은 건 &lt;b&gt;생각의 누락을 줄여준다&lt;/b&gt;는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람은 익숙한 패턴을 무의식적으로 당연하게 여기기 쉬운데, 템플릿 기반의 Skill은 &quot;이 항목의 예외 케이스는?&quot; &quot;트랜잭션 경계는?&quot; 같은 질문을 빠뜨리지 않고 던져 준다.&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;2단계. 개발 문서 &amp;rarr; Jira 티켓&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 문서의 구조를 &quot;구현 항목 하나 = 티켓 하나&quot;가 되도록 설계해 둔 게 여기서 빛을 발한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 문서의 &lt;code&gt;####&lt;/code&gt; 섹션 하나가 곧 Jira 티켓 하나에 대응하기 때문에, 각 항목을 읽고 &lt;b&gt;Jira MCP&lt;/b&gt;로 티켓을 만드는 일이 매우 단순해진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제목: 구현 항목의 제목을 그대로&lt;/li&gt;
&lt;li&gt;본문: 해당 섹션의 스펙&amp;middot;로직&amp;middot;예외 처리&lt;/li&gt;
&lt;li&gt;에픽/상위 이슈: 개발 문서 전체를 대표하는 상위 이슈에 연결&lt;/li&gt;
&lt;li&gt;라벨/컴포넌트: 개발 문서 메타에서 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 포인트는 &lt;b&gt;티켓과 문서의 추적성&lt;/b&gt;이다. 티켓 본문에는 개발 문서 링크가, 개발 문서에는 티켓 번호가 각각 박혀 있기 때문에, 어느 쪽에서 들어가도 반대쪽을 찾아갈 수 있다. 나중에 구현이 끝난 뒤 재리뷰를 할 때 이 링크가 결정적인 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계. 티켓 &amp;rarr; 구현 &amp;rarr; PR&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 단계는에서는 Jira 티켓 번호 하나를 던지면 대략 이런 순서로 흘러간다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;티켓 읽기&lt;/b&gt; &amp;mdash; Jira MCP로 이슈 타입과 상세 내용 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브랜치 생성&lt;/b&gt; &amp;mdash; 이슈 타입에 맞는 prefix로 브랜치 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구현 계획 수립&lt;/b&gt; &amp;mdash; 영향 범위, 도메인 설계, API 설계, 테스트 계획을 먼저 제시하고 사용자 승인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구현&lt;/b&gt; &amp;mdash; 팀의 아키텍처 원칙에 맞게 레이어 단위로 Bottom-Up 구현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 &amp;amp; 빌드 검증&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자체 코드 리뷰&lt;/b&gt; &amp;mdash; 코드 리뷰 Skill의 체크리스트를 스스로 돌려보기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;커밋 &amp;amp; PR 생성&lt;/b&gt; &amp;mdash; GitHub MCP로 PR 본문 템플릿에 맞게 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주의 깊게 설계한 부분이 몇 가지 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;① 계획을 먼저 보여주고 멈추기.&lt;/b&gt; 바로 코드로 뛰어들게 두면 기대와 다른 방향으로 가기 쉽다. 그래서 3단계에서 반드시 사람의 승인을 받고 넘어가게 했다. 이 한 단계만 넣어도 &quot;어... 그거 말고요...&quot; 하면서 뒤집는 일이 거의 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;②&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;애매하면 물어보기.&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;어떠한 구현에 있어서 조금이라도 애매한 부분은 무조건 개발자의 확인을 받도록 지시했다. 그렇지 않고 그대로 진행하게 둔다면, 또 예상치 못한 방향으로 개발이 된다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;③&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;Skill이 컨벤션을 자동 주입하도록 하기.&lt;/b&gt; 파일을 편집할 때 어떤 컨벤션 문서가 로드될지를 파일 경로 기준으로 미리 지정해 두었다.(.claude/rules) 예를 들어 컨트롤러를 수정할 때는 API 컨벤션 문서가, 테스트 파일을 수정할 때는 테스트 컨벤션이 자동으로 Claude의 컨텍스트에 들어간다. 덕분에 &quot;우리팀 컨벤션에 맞춰줘&quot;라고 매번 얘기할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;④&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;실패하면 즉시 멈추기.&lt;/b&gt; 테스트가 깨지거나 빌드가 실패하면 다음 단계로 절대 넘어가지 않도록 했다. 자동화에서 가장 위험한 건 '반쯤 된 결과를 끝까지 밀고 나가는' 것이라, &quot;멈추고 보고하는&quot; 지점을 명시적으로 박아두는 게 생각보다 중요하다.&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;4단계. PR 이후, 기획서&amp;middot;개발 문서와 재리뷰&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계가 사실 이 플로우의 핵심이자 가장 소홀해지기 쉬운 부분이다. 구현이 끝나면 많은 팀이 PR 리뷰는 꼼꼼히 해도, &lt;b&gt;&quot;원래 기획과 실제 구현이 정말 같은 방향을 보고 있는지&quot;&lt;/b&gt; 는 별로 확인하지 않는다. 시간이 없기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서도 Skill이 한 몫을 한다. &lt;b&gt;개발 문서 동기화 스킬&lt;/b&gt;은 대략 이런 일을 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;원본 개발 문서(Confluence)를 읽는다.&lt;/li&gt;
&lt;li&gt;현재 브랜치의 &lt;code&gt;git diff&lt;/code&gt;를 읽는다.&lt;/li&gt;
&lt;li&gt;두 가지를 항목 단위(&lt;code&gt;####&lt;/code&gt; 섹션)로 매칭한다.&lt;/li&gt;
&lt;li&gt;각 항목에 대해 다음 중 하나로 리포트한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;일치&lt;/b&gt; &amp;mdash; 개발 문서와 구현이 동일&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구현만 존재&lt;/b&gt; &amp;mdash; 개발 문서엔 없는데 코드에 있음 &amp;rarr; 개발 문서 보강이 필요한가?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개발 문서만 존재&lt;/b&gt; &amp;mdash; 개발 문서엔 있는데 구현이 없음 &amp;rarr; 빠뜨린 건가, 의도적 제외인가?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;차이 있음&lt;/b&gt; &amp;mdash; 시그니처&amp;middot;로직&amp;middot;예외 처리 중 어디가 어떻게 다른지&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;사용자 판단에 따라 개발 문서를 업데이트한다 (코드는 건드리지 않는다).&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트는 이 Skill이 &lt;b&gt;코드를 직접 수정하지 않는다&lt;/b&gt;는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기화의 목적은 &quot;문서가 거짓말을 하지 않게 만드는 것&quot;이지, 구현을 뒤집는 게 아니기 때문이다. 구현을 바꿀지 말지는 사람이 결정할 영역으로 남겨둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계를 넣고 나서 가장 큰 변화는, &lt;b&gt;개발 문서가 오래되지 않는다&lt;/b&gt; 는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 개발이 끝나는 순간 개발 문서가 과거형 문서가 되어버렸는데, 이제는 구현과 맞춰진 최신 상태로 남게 된다. 이후에 합류하는 팀원이나, 같은 도메인을 다시 건드릴 때의 비용이 확연히 줄어들었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 플로우를 굴려보고 느낀 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두세 달 굴려 보면서 가장 크게 체감한 것은 세 가지다.&lt;/p&gt;
&lt;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;① '반복'과 '판단'을 분리하게 된다.&lt;/b&gt;&lt;br /&gt;이전에는 '문서 정리', '티켓 쪼개기', 'PR 본문 쓰기' 같은 반복 작업이 판단 작업과 뒤엉켜 있었다. Skill로 반복을 묶어내고 나니, 사람은 &quot;이걸 할지 말지&quot;, &quot;이 설계가 맞는지&quot;라는 &lt;b&gt;판단에만 집중&lt;/b&gt;할 수 있게 됐다. 같은 하루를 써도 뇌가 훨씬 덜 피곤하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② 자동화의 진입점이 분산되면 안 된다.&lt;/b&gt;&lt;br /&gt;처음에는 기능별로 이것저것 커맨드를 만들었는데, 쓰다 보니 &quot;어떤 커맨드를 언제 써야 하지?&quot;가 오히려 인지 부하가 됐다. 그래서 가능한 한 &lt;b&gt;한 커맨드로 전체 플로우를 꿰고&lt;/b&gt;, 그 안에서 Skill이 각자의 역할을 하도록 정리했다. 사용자의 머릿속에 있어야 할 건 &quot;이 작업의 진입점은 어디였지?&quot; 하나 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;③ 자동화는 '멈추는 지점'을 잘 넣는 것이 반이다.&lt;/b&gt;&lt;br /&gt;계획 승인, 테스트 실패 시 중단, 개발 문서 갱신 전 확인 &amp;mdash; 이런 &lt;b&gt;사람 개입 지점&lt;/b&gt;을 빠뜨리지 않는 게, 역설적으로 자동화를 신뢰할 수 있게 만드는 핵심이었다. &quot;알아서 다 해주는 자동화&quot;가 아니라, &quot;믿고 맡길 수 있는 지점에서만 알아서 해주는 자동화&quot;를 만드는 게 목표가 되어야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;한계와 앞으로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 아직 매끄럽지 않은 부분도 많다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 히스토리를 읽는 영역에서는 부족하다. 클로드가 '코드'만 보고 기존 비즈니스 로직에 대한 히스토리를 파악하기란 쉽지 않다. 따라서, (기획서 driven이 아닌) &lt;b&gt;기존 동작하는 기능을 수정할 때에는 비즈니스 로직을 해치지는 않는지&lt;/b&gt; 꼭 사람이 확인하는 과정을 거쳐야하더라.&lt;/li&gt;
&lt;li&gt;기획서의 구조가 팀마다 달라서, 개발 문서 Skill이 모든 PRD에 똑같이 잘 먹히지는 않는다. PRD 포맷 자체를 조금씩 정돈해 나가야 한다.&lt;/li&gt;
&lt;li&gt;재리뷰 단계에서 Claude가 '의도적 제외'와 '누락'을 완벽히 구분하진 못한다. 결국 판단은 사람이 해야 하고, Skill은 그 판단을 &lt;b&gt;놓치지 않게 떠올려 주는 역할&lt;/b&gt;에 머무르는 게 적절한 것 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로는 이 플로우를 더 다양한 포지션(프론트엔드, 데이터, QA)에도 맞춰 보고 싶다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code 같은 에이전트 도구의 힘은 결국 &lt;b&gt;'혼자 잘하는 것'이 아니라 '플로우 안에 녹아드는 것'&lt;/b&gt; 에 있다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획서 한 장에서 출발해 PR이 머지되는 순간, 그리고 그 이후 문서가 다시 최신 상태로 돌아오는 순간까지 &amp;mdash; 그 긴 호흡을 사람 혼자 놓치지 않고 끌고 가기란 쉽지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 틈을 MCP가 연결하고, Skill이 반복을 다듬어 주면, 개발자는 정말로 재밌는 '판단'과 '설계'에 시간을 쓸 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 고민을 하고 계신 분들께 이 글이 작은 참고라도 되었으면 좋겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각자 팀의 문화와 툴 스택은 다르겠지만, &lt;b&gt;&quot;플로우 전체를 한 줄로 꿰어 본다&quot;&lt;/b&gt; 는 관점 자체는 어디에서든 해볼 만한 시도라고 생각한다.&lt;/p&gt;</description>
      <category>  개발 일지</category>
      <category>Claude Code Plugin</category>
      <category>Claude Code Skill</category>
      <category>claudecode</category>
      <category>codex</category>
      <category>MCP</category>
      <category>바이브코딩</category>
      <category>실무</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/153</guid>
      <comments>https://doteloper.tistory.com/153#entry153comment</comments>
      <pubDate>Fri, 10 Apr 2026 16:45:45 +0900</pubDate>
    </item>
    <item>
      <title>백엔드 개발자의 End-to-End 개발기 with. Claude Code</title>
      <link>https://doteloper.tistory.com/152</link>
      <description>&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;대 클로드 코드 시대를 맞아, 관심있던 주제의 사이드 프로젝트를 혼자 개발해보기로 했다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;기획, 디자인, FE, BE, 인프라.. 리얼 End-to-End로!&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;클코와 함께 한 달 정도 작업했다. 결론부터 말하면 &amp;mdash;&amp;nbsp;&lt;b&gt;어렵지 않았다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;잘 아는 쪽에서는 AI가 손발이 됐고, 모르는 쪽에서는 AI가 가이드가 됐다. 두 경우 모두 내가 한 건 &lt;b&gt;디렉팅&lt;/b&gt;이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;바이브 코딩이라고들 하지만, 실제로 해보니 &quot;바이브&quot;으로만 되는 건 아니었다. 체계가 있어야 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;한달동안 클로드와 지지고 볶으며 작업했던 방식 및 꾸르팁들을 소개하려 한다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 id=&quot;1-%ED%99%94%EB%A9%B4-%EB%A8%BC%EC%A0%80-%EC%84%9C%EB%B2%84%EB%8A%94-%EB%82%98%EC%A4%91%EC%97%90&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 화면 먼저, 서버는 나중에&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;나는 보통 개발할 때에 API를 만들고, 클라이언트를 붙인다. 백엔드 개발자로서 어쩌면 당연한지도 모르겠다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만 클로드 코드와 일하면서는&amp;nbsp;&lt;b&gt;화면부터 만들었다.&lt;/b&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Flutter로 홈 화면, 상세 화면, 검색 화면을 먼저 그렸다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;화면을 만들다 보면 &quot;여기서 카드 목록이 필요하네&quot;, &quot;여기서 카테고리별 통계가 필요하네&quot; &amp;mdash; 필요한 API가 자연스럽게 드러난다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;처음에는 매번 &quot;이 화면에 필요한 API를&amp;nbsp;API_SPECIFICATION.md에 정리해줘&quot;라고 말했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 그런데 반복되길래 아예 CLAUDE.md에 룰로 넣었다:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;markdown&quot; style=&quot;background-color: #000000; color: #cccccc; text-align: start;&quot;&gt;&lt;code&gt;## 클라이언트에서 API가 필요할 때
- 서버 API가 필요한 기능을 구현할 때:
  1. 필요한 엔드포인트를 API_SPECIFICATION.md에 먼저 추가
  2. 클라이언트에서는 mock 데이터로 대체하여 화면이 동작하게 구현
  3. 서버 구현은 별도로 진행
- 존재하지 않는 API를 직접 호출하는 코드를 작성하지 말 것
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 룰 하나로, Claude가 화면을 구현하다가 API가 필요하면 자동으로 명세에 추가하고 mock으로 대체한다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 매번 말할 필요가 없다.&amp;nbsp;&lt;b&gt;반복되는 지시는 룰로 만들어라&lt;/b&gt;&amp;nbsp;&amp;mdash; 이게 CLAUDE.md의 핵심 활용법이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;mock으로 화면을 돌리다 보면 &quot;이 데이터 구조가 화면에 맞지 않네&quot;라는 걸 발견한다. 서버가 아직 없으니 명세만 고치면 된다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 id=&quot;%EB%AA%85%EC%84%B8%EA%B0%80-%EA%B5%B3%EC%9C%BC%EB%A9%B4-%EC%84%9C%EB%B2%84%EB%A5%BC-%EC%A7%A0%EB%8B%A4&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;37&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;명세가 굳으면, 서버를 짠다&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;화면이 완성되고 mock으로 충분히 검증되면, 그때 서버를 짠다.&amp;nbsp;API_SPECIFICATION.md는 이미 완성되어 있다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Claude에게 &quot;이 스펙대로 구현해&quot;라고 하면 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;서버 코드는 내 영역이니까 결과물을 보고 바로 판단할 수 있다. &quot;이 sealed class 구조가 맞는지&quot;, &quot;예외 처리가 빠졌는지&quot;, &quot;트랜잭션 범위가 적절한지&quot;. AI가 짠 코드를 리뷰할 수 있으니까 안심하고 맡긴다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;&quot; style=&quot;background-color: #000000; color: #cccccc; text-align: start;&quot;&gt;&lt;code&gt;화면 구현 &amp;rarr; API가 드러남 &amp;rarr; Claude가 API 명세 작성
&amp;rarr; mock으로 화면 검증 &amp;rarr; 명세대로 서버 구현
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이렇게 하면 &quot;쓸모없는 API&quot;를 만들 일이 없다. 화면이 필요로 하는 것만 만든다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-line=&quot;50&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;2-ai%EC%97%90%EA%B2%8C-%EB%8A%90%EB%82%8C%EC%9D%84-%EC%A0%84%EB%8B%AC%ED%95%98%EB%8A%94-%EB%B2%95&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;52&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. AI에게 &quot;느낌&quot;을 전달하는 법&lt;/span&gt;&lt;/h2&gt;
&lt;h3 id=&quot;%EB%AC%B8%EC%A0%9C-%EA%B8%B0%EB%8A%A5%EC%9D%80-%EB%A7%9E%EB%8A%94%EB%8D%B0-%EB%8A%90%EB%82%8C%EC%9D%B4-%EC%95%84%EB%8B%88%EB%8B%A4&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;54&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;문제: 기능은 맞는데, 느낌이 아니다&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;56&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Flutter를 활용하여 개발하는 것과, UI를 &quot;잘&quot; 만드는 건 다른 얘기다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;56&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;처음 Claude에게 &quot;홈 화면 만들어줘&quot;라고 했을 때 기능적으로는 맞았지만 게시판 같았다. 텍스트 나열, flat한 배경, 시각적 계층 없음.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;AI에게 &quot;어떻게 만들어&quot;가 아니라 &lt;b&gt;&quot;어떤 느낌이어야 해&quot;&lt;/b&gt;를 전달해야 했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 id=&quot;%ED%95%B4%EA%B2%B0-design-systemmd&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;60&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;해결: design-system.md&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;프로젝트 안에&amp;nbsp;docs/design-system.md&amp;nbsp;파일을 만들었다. 여기에 넣은 건 코드가 아니라&amp;nbsp;&lt;b&gt;취향&lt;/b&gt;이다:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;markdown&quot; style=&quot;background-color: #000000; color: #cccccc; text-align: start;&quot;&gt;&lt;code&gt;## 디자인 철학

### 레퍼런스
삼성카드 monimo, 스픽(Speak)

### 원칙
1. 미니멀하고 깔끔하게 &amp;mdash; 요소가 적을수록 좋다.
2. 단, depth는 유지 &amp;mdash; 미니멀 &amp;ne; flat. 그림자, 레이어, 시각적 계층.
3. Vivid Spectrum 그라데이션 &amp;mdash; 블루 &amp;rarr; 퍼플 &amp;rarr; 핑크.
4. 텍스트 중심 &amp;mdash; 정보 전달은 타이포그래피로.

### 하지 말 것
- 텍스트 나열식 리스트 (게시판처럼 보임)
- 시각 요소를 전부 제거한 flat 디자인
- 일정 중심으로 치우친 홈 화면
- 카드 내부에 아이콘 넣기

### 판단 기준
1. monimo/스픽 앱에 이 화면을 넣어도 어색하지 않은가?
2. depth가 느껴지는가?
3. &quot;사진 찍기&quot; 행동이 눈에 띄는가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;핵심은&amp;nbsp;&lt;b&gt;&quot;하지 말 것&quot;과 &quot;판단 기준&quot;&lt;/b&gt;&amp;nbsp;섹션이다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;AI는 &quot;이렇게 해&quot;보다 &quot;이건 하지 마&quot;를 더 정확하게 따른다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그리고 &quot;monimo/스픽에 넣어도 어색하지 않은가?&quot;라는 질문은 Claude가 레퍼런스 앱과 비교하면서 스스로 검증하게 만든다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 id=&quot;%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%80-%EC%A7%84%ED%99%94%ED%95%9C%EB%8B%A4&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;90&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;디자인 시스템은 진화한다&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;92&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 문서는 한 번 쓰고 끝이 아니다. 처음에는 인디고 블루 단색이었는데, 실제 앱을 쓰다 보니 밋밋했다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;92&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그래서 Vivid Spectrum 그라데이션(블루&amp;rarr;퍼플&amp;rarr;핑크)으로 바꾸고, 글래스모피즘 카드를 도입했다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;92&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;중요한 건&amp;nbsp;&lt;b&gt;코드를 먼저 바꾸지 않았다&lt;/b&gt;는 것. &lt;b&gt;design-system.md의 원칙을 먼저 업데이트하고, 그 원칙에 맞춰 구현했다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;design-system.md는 &quot;느낌&quot;을 전달하는 문서였다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그런데 프로젝트에는 느낌 말고도 AI에게 전달해야 할 것들이 있다 &amp;mdash; 작업 순서, 프로세스, 하지 말아야 할 실수. 이걸 담는 곳이 CLAUDE.md다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 id=&quot;3-claudemd--ai%EC%97%90%EA%B2%8C-%EB%A7%A5%EB%9D%BD%EC%9D%84-%EC%A3%BC%EB%8A%94-%EB%B2%95&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;98&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. CLAUDE.md &amp;mdash; AI에게 맥락을 주는 법&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Claude Code에는&amp;nbsp;&lt;b&gt;CLAUDE.md&lt;/b&gt;라는 파일이 있다. &lt;b&gt;프로젝트 루트에 넣으면 매 세션마다 자동으로 읽힌다&lt;/b&gt;. 프로젝트의 규칙서 같은 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 id=&quot;%EC%B2%98%EC%9D%8C-%EB%AA%A8%EB%93%A0-%EA%B1%B8-%EB%8B%A4-%EB%84%A3%EC%97%88%EB%8B%A4&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;102&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;처음: 모든 걸 다 넣었다&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;104&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;처음에는 기술 스택, 아키텍처 패턴, 파일 구조를 상세하게 넣었다:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #000000; color: #cccccc; text-align: start;&quot;&gt;&lt;code&gt;## 클라이언트 (Flutter)
- 상태관리: StatefulWidget + setState
- 아키텍처: Screen &amp;rarr; Service(singleton) &amp;rarr; BaseApiClient
- 주요 파일: home_screen.dart, card_service.dart, ...

## 서버 (Spring Boot + Kotlin)
- 4계층: ui &amp;rarr; application &amp;rarr; domain &amp;rarr; infrastructure
- Card 도메인: sealed interface (GeneralCard, EventCard)
- JWT 인증, FCM 푸시, 토큰 블랙리스트, 레이트 리미팅

## 인프라
- Docker + Railway 배포
- PaddleOCR + Flask OCR 서버
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;122&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그런데 돌아보니, 이건 Claude가&amp;nbsp;pubspec.yaml이나&amp;nbsp;build.gradle.kts를 읽으면 바로 아는 정보다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;122&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;폴더 구조를 탐색하면 아키텍처도 파악한다.&amp;nbsp;&lt;b&gt;넣어봐야 컨텍스트만 낭비한다.&lt;/b&gt;&amp;nbsp;전부 뺐다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 id=&quot;%EC%B5%9C%EC%A2%85%EB%B3%B8&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;124&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;최종본&lt;/span&gt;&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #000000; color: #cccccc; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# Glip

사진을 찍으면 AI가 정보를 분석&amp;middot;분류&amp;middot;저장하고,
필요할 때 검색하는 앱. 

## UI 작업 시
- 반드시 docs/design-system.md를 먼저 읽고 따를 것
- 레퍼런스: monimo, 토스
- 미니멀하되 flat 금지 &amp;mdash; depth(그림자, 레이어링) 유지
- 텍스트 나열식 리스트 금지 (게시판처럼 보임)

## 클라이언트에서 API가 필요할 때
- 서버 API가 필요한 기능을 구현할 때:
  1. 필요한 엔드포인트를 API_SPECIFICATION.md에 먼저 추가
  2. 클라이언트에서는 mock 데이터로 대체하여 화면이 동작하게 구현
  3. 서버 구현은 별도로 진행
- 존재하지 않는 API를 직접 호출하는 코드를 작성하지 말 것

## API 작업 시
- API 스펙 문서: API_SPECIFICATION.md (프로젝트 루트)
- API 추가/변경 시 이 문서를 먼저 업데이트하고 구현할 것&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;%EC%99%9C-%EC%9D%B4%EA%B2%8C-%ED%9A%A8%EA%B3%BC%EC%A0%81%EC%9D%B8%EA%B0%80&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;152&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;왜 이게 효과적인가&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;154&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;여기 있는 건 전부&amp;nbsp;&lt;b&gt;코드를 읽어도 알 수 없는 것들&lt;/b&gt;이다:&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;항목왜 필요한가&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;color: #cccccc; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;156&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr data-line=&quot;160&quot;&gt;
&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&quot;게시판처럼 보임&quot;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;사람의 감각적 판단. 코드로 표현 불가&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;161&quot;&gt;
&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;mock-first 프로세스&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&quot;API가 필요하면 명세 추가 &amp;rarr; mock 대체&quot;라는 작업 흐름&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;158&quot;&gt;
&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&quot;일정 관리 앱이 아님&quot;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #000000;&quot;&gt;코드에 일정 기능이 있어서 AI가 일정 앱으로 오해할 수 있다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;163&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;CLAUDE.md가 길면 두 가지 문제가 생긴다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;163&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;매 세션마다 컨텍스트를 낭비하고, 코드와 문서가 어긋나면 AI가 혼란스러워한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;163&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;코드에서 읽어낼 수 있으면 넣지 않는다&lt;/b&gt;&amp;nbsp;&amp;mdash; 이 원칙 하나면 충분하다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 id=&quot;4-superpowers--%EB%8B%A4%EC%8B%9C-%ED%95%B4%EC%A4%98-%EB%A3%A8%ED%94%84-%ED%83%88%EC%B6%9C&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;167&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;4. Superpowers &amp;mdash; &quot;다시 해줘&quot; 루프 탈출&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;169&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Claude Code에는&amp;nbsp;&lt;a href=&quot;https://github.com/obra/superpowers&quot;&gt;Superpowers&lt;/a&gt;라는 플러그인이 있다. 스킬이라 불리는 작업 프로세스를 설치해두면, 자연어로 요청했을 때 Claude가 맥락에 맞는 스킬을 알아서 발동시킨다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;171&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&quot;홈 화면을 개편하고 싶다&quot;고 말하면, Claude가 brainstorming 스킬을 발동시켜 먼저 대화를 시작한다. 뭘 바꿀지, 제약이 뭔지 물어보고, 내가 고른 방향으로 스펙 문서를 만든다. 스펙이 나오면 writing-plans 스킬이 이어져 실행 계획을 체크리스트로 쪼개고, executing-plans가 한 스텝씩 처리하면서 매번 확인을 받는다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;background-color: #000000; color: #cccccc; text-align: start;&quot;&gt;&lt;code&gt;brainstorming &amp;rarr; 방향 고정 (스펙 문서)
    &amp;darr;
writing-plans &amp;rarr; 순서 결정 (실행 계획)
    &amp;darr;
executing-plans &amp;rarr; 단계별 구현 + 검수
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;181&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이게 좋았던 건,&amp;nbsp;&lt;b&gt;&quot;홈 화면 리디자인해줘&quot; 한 번에 시키고 마음에 안 들면 &quot;다시 해줘&quot;를 반복하는 루프에 안 빠진다&lt;/b&gt;는 것이다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;181&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;매 단계마다 내가 개입하니까 방향이 어긋날 틈이 없다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;UI 변경 때 특히 빛났다. &quot;온보딩 가이드를 넣고 싶다&quot;고 말하자 brainstorming이 발동됐는데, 이 때에는&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;실제 HTML 목업을 만들어서 로컬 서버를 띄워주었다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;브라우저에서 각 안을 직접 눈으로 보고 고를 수 있었다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&quot;설명 들어보고 상상해봐&quot;가 아니라 &lt;b&gt;&quot;직접 보고 골라&quot;&lt;/b&gt;인 셈이다. 텍스트로 &quot;A안은 이렇고 B안은 이렇습니다&quot;를 읽는 것과, 실제 화면을 보면서 고르는 건 판단의 정확도가 다르다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3514&quot; data-origin-height=&quot;1842&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/epP4gD/dJMcad2xJFF/MdjTRGKySQ8kV6RJFWUPa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/epP4gD/dJMcad2xJFF/MdjTRGKySQ8kV6RJFWUPa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/epP4gD/dJMcad2xJFF/MdjTRGKySQ8kV6RJFWUPa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FepP4gD%2FdJMcad2xJFF%2FMdjTRGKySQ8kV6RJFWUPa0%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;3514&quot; height=&quot;1842&quot; data-origin-width=&quot;3514&quot; data-origin-height=&quot;1842&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 id=&quot;%EB%A7%8C%EB%93%A0-%EC%95%B1-shotz&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;189&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;만든 앱: Glip&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이렇게 만든 앱이 Glip다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;휴대폰 용량이 가득 차 사진을 지우다보니, 정보성 사진들이 굉장히 많았다. (나중에 갈 맛집, 화장품 정보 등 일단 찍어놓고 보는 것들..)&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이런 사진들 용량만 차지하고 나중에 다시 찾기도 쉽지 않은데, 꼭 사진으로 찍어놔야하나라는 문제의식에서 출발했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;사진을 찍으면 AI가 알아서 분석하고, 카테고리별로 정리하고, 나중에 검색할 수 있게 저장해준다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;영수증, 일정, 연락처, 메모 &amp;mdash; 뭐든 찍으면 된다.&amp;nbsp;&lt;b&gt;찍고 잊어도 된다.&lt;/b&gt; 나중에 &quot;지난달 영수증 어디 있지?&quot; 하면 검색하면 된다.&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfr73F/dJMcaiv2ljv/GMuyvuNiZMSsXwQa4CL3F1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfr73F/dJMcaiv2ljv/GMuyvuNiZMSsXwQa4CL3F1/img.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2640&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot; id=&quot;kEditorPhotosEditingImage-1&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfr73F/dJMcaiv2ljv/GMuyvuNiZMSsXwQa4CL3F1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbfr73F%2FdJMcaiv2ljv%2FGMuyvuNiZMSsXwQa4CL3F1%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;1080&quot; height=&quot;2640&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bL529F/dJMcadO0dyC/0LdlKKoaFr5XV5ITXrrgVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bL529F/dJMcadO0dyC/0LdlKKoaFr5XV5ITXrrgVK/img.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2640&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot; id=&quot;kEditorPhotosEditingImage-2&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bL529F/dJMcadO0dyC/0LdlKKoaFr5XV5ITXrrgVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbL529F%2FdJMcadO0dyC%2F0LdlKKoaFr5XV5ITXrrgVK%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;1080&quot; height=&quot;2640&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;figure id=&quot;og_1775191425540&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;글립 - AI로 사진 속 정보만 쏙 - Google Play 앱&quot; data-og-description=&quot;찍기만 하세요, 정리는 글립이 할게요.&quot; data-og-host=&quot;play.google.com&quot; data-og-source-url=&quot;https://play.google.com/store/apps/details?id=com.doteloper.shotz&quot; data-og-url=&quot;https://play.google.com/store/apps/details?id=com.doteloper.shotz&amp;amp;hl=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cUaGDd/dJMb9jgxFxL/2VCSSKvTvZutVVdKYlVtY0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/drejAH/dJMb9c9yhEx/CD5LgRzN9w12sVgahUcOL0/img.png?width=600&amp;amp;height=300&amp;amp;face=0_0_600_300&quot;&gt;&lt;a href=&quot;https://play.google.com/store/apps/details?id=com.doteloper.shotz&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://play.google.com/store/apps/details?id=com.doteloper.shotz&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cUaGDd/dJMb9jgxFxL/2VCSSKvTvZutVVdKYlVtY0/img.png?width=512&amp;amp;height=512&amp;amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/drejAH/dJMb9c9yhEx/CD5LgRzN9w12sVgahUcOL0/img.png?width=600&amp;amp;height=300&amp;amp;face=0_0_600_300');&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;글립 - AI로 사진 속 정보만 쏙 - Google Play 앱&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;play.google.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(앱스토어: TO BE)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 id=&quot;%EC%A0%95%EB%A6%AC&quot; style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;207&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;정리&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #cccccc; text-align: start;&quot; data-line=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;백엔드 개발자가 엔드투엔드로 앱을 만들면서 느낀 것:&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #cccccc; text-align: start;&quot; data-line=&quot;211&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;211&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;본인 영역의 전문성이 다른 영역에서도 작동한다.&lt;/b&gt;&amp;nbsp;서버 개발하면서 쌓은 &quot;좋은 코드를 판단하는 눈&quot;은 Flutter에서도 통한다. 구현은 AI가 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-line=&quot;212&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;CLAUDE.md는 짧게.&lt;/b&gt; 코드에서 읽어낼 수 있으면 넣지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-line=&quot;213&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;문서가 코드에 선행한다.&lt;/b&gt;&amp;nbsp;API 명세든, 디자인 가이드든, 먼저 문서로 정의하고 구현한다. AI가 임의로 결정하게 두면 내 의도와 어긋난다.&lt;/span&gt;&lt;/li&gt;
&lt;li data-line=&quot;214&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;바이브코딩도 체계가 있다.&lt;/b&gt;&amp;nbsp;방향 &amp;rarr; 계획 &amp;rarr; 실행. 한 번에 시키면 &quot;다시 해줘&quot;의 무한 루프에 빠진다. 매 단계에서 디렉팅할 수 있어야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>  개발 일지</category>
      <category>Claude</category>
      <category>claude code</category>
      <category>codex</category>
      <category>glip</category>
      <category>openai</category>
      <category>Superpowers</category>
      <category>글립</category>
      <category>바이브코딩</category>
      <category>클로드</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/152</guid>
      <comments>https://doteloper.tistory.com/152#entry152comment</comments>
      <pubDate>Fri, 3 Apr 2026 13:50:01 +0900</pubDate>
    </item>
    <item>
      <title>2년 묵힌 프로젝트, 바이브 코딩으로 이틀만에 끝내기 - 웨잇 개발 회고</title>
      <link>https://doteloper.tistory.com/151</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;완벽한 앱을 만들려다 2년을 질질 끈 프로젝트, 최소 기능의 웹으로 이틀만에 런칭하다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;position: absolute;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  2년간의 삽질, 그리고 테스트앱까지 올렸지만...&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2년 전, AI 기반 스미싱 탐지 서비스 &lt;b&gt;웨잇&lt;/b&gt;을 기획했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI Embedding API를 활용해 문자 메시지를 벡터화하고, Elasticsearch로 기존 스미싱 패턴과의 유사도를 측정하는 서비스였다. 아이디어는 좋았다. 문제는 실행이었다.&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;사용자가 의심스러운 문자를 받으면 앱에서 바로 분석할 수 있도록 하는 게 목표였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드는 Kotlin + Spring Boot로 탄탄하게 구축했고, AI 분석 서버도 Python Flask로 완성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심지어 Google Play 스토어에 테스트 앱까지 올렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 앱을 담당하던 친구와 나 모두 너무 바빴고, 커뮤니케이션 비용이 점점 커졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 기간이 길어질수록 점점 루즈해졌다. 계속 보완해야 할 점이 생겨났지만, 진척은 더뎠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 2년이 지났지만 정식 런칭은 하지 못했다.&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 data-ke-size=&quot;size16&quot;&gt;이대로 끝낼 순 없었다. 완벽한 앱을 만들겠다는 욕심을 내려놓고, 핵심 기능만 담은 웹사이트라도 먼저 배포하기로 결심했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최소 기능 정의:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문자 내용 입력&lt;/li&gt;
&lt;li&gt;AI 기반 스미싱 여부 판단&lt;/li&gt;
&lt;li&gt;관련 뉴스 제공&lt;/li&gt;
&lt;li&gt;월간 사용 제한 (OpenAI API 비용 관리)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱에서 구상했던 &lt;b&gt;가족 연동이나 실시간 알림 같은 리치한 기능&lt;/b&gt;들은 과감히 포기했다. 아쉽지만, 일단 세상에 내놓는 게 우선이었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;바이브 코딩의 마법 - 이틀만에 완성한 웹사이트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드는 바이브 코딩만한 게 없다. React에 익숙하지 않아도, AI와 함께라면 빠르게 결과물을 만들 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프로젝트 세팅&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Vite&lt;/b&gt;: 빠른 개발 환경 구축&lt;/li&gt;
&lt;li&gt;&lt;b&gt;React 19&lt;/b&gt;: 최신 버전으로 시작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TailwindCSS 4&lt;/b&gt;: 스타일링은 유틸리티 클래스로 간편하게&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;바이브 코딩 3단계 전략&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1단계: 깔끔한 UI 프로토타입 생성 (하드코딩 OK)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;스미싱 탐지 서비스 메인 페이지 만들어줘. 
문자 입력창, 분석 버튼, 결과 표시 영역이 필요해. 
서버 연동은 나중에 할 거니까 일단 하드코딩된 샘플 데이터로.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude가 만들어준 첫 프로토타입은 놀라웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깔끔한 레이아웃에 직관적인 UI, 그리고 TailwindCSS로 스타일링까지 완벽했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단계에서는 기능 구현보다 &lt;b&gt;사용자 경험&lt;/b&gt;에 집중했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2단계: 백엔드 연동&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;&quot;현재 UI에 우리 백엔드 API 연결해줘.
엔드포인트: POST /api/analyze
요청: { message: string }
응답: { isSafe: boolean, similarity: number, cases: [...] }&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드는 이미 2년간 다듬어져 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin Spring Boot 서버가 Redis 캐싱, JPA 기반 데이터 관리를 담당하고, Python Flask 서버가 OpenAI Embedding과 Elasticsearch 벡터 검색을 처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 스펙만 넘겨주니 Claude가 즉시 연동 코드를 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3단계: UI 디테일 개선&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;분석 중일 때 로딩 애니메이션 추가하고,
결과가 위험할 경우 경고 색상으로 강조해줘.
그리고 유사 케이스 카드에 호버 효과도 넣어줘.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능은 완성됐지만 폴리싱이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로딩 스피너, 색상 코딩, 애니메이션 등 사용자 경험을 향상시키는 디테일에 집중했다.&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;앱에서 웹으로 전환하면서 몇 가지 중요한 결정을 내려야 했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;비로그인 vs 로그인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 접근성을 위해 비로그인 서비스로 만들까 했다. 누구나 URL만 입력하면 바로 사용할 수 있게. 하지만 현실은 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제: OpenAI API 토큰 비용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매 분석마다 OpenAI Embedding API를 호출하는데, 이건 돈이 드는 작업이다. 무제한 무료로 열어두면 어뷰징 우려도 있고, 비용 폭탄을 맞을 수도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론: 로그인 + 월간 사용 제한&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Google OAuth 로그인 구현&lt;/li&gt;
&lt;li&gt;월 10회 무료 분석 제공&lt;/li&gt;
&lt;li&gt;사용량 제어를 통한 비용 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿠키 인증 추가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 환경에서는 JWT 토큰 기반 인증만 있었다. 하지만 웹 브라우저 환경에서는 쿠키 기반 세션 인증이 더 자연스럽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드를 수정해서 JWT와 쿠키 인증을 모두 지원하도록 했다. 다행히 모바일 앱을 염두에 두고 설계했던 인증 구조가 유연해서, 쿠키 인증 추가가 어렵지 않았다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과: waiit.me 런칭&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이틀간의 바이브 코딩 끝에 &lt;a href=&quot;https://waiit.me/&quot;&gt;waiit.me&lt;/a&gt;가 세상에 나왔다.&lt;/p&gt;
&lt;figure id=&quot;og_1768642802369&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;WAIIT - 스미싱 탐지 | 문자사기 검사 | 보이스피싱 예방 서비스&quot; data-og-description=&quot;AI 기반 스미싱 탐지 서비스. 문자 사기, 피싱 문자, 보이스피싱을 실시간으로 분석하고 예방하세요. 무료로 의심 문자를 검사하세요.&quot; data-og-host=&quot;waiit.me&quot; data-og-source-url=&quot;https://waiit.me/&quot; data-og-url=&quot;https://waiit.me&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://waiit.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://waiit.me/&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;WAIIT - 스미싱 탐지 | 문자사기 검사 | 보이스피싱 예방 서비스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;AI 기반 스미싱 탐지 서비스. 문자 사기, 피싱 문자, 보이스피싱을 실시간으로 분석하고 예방하세요. 무료로 의심 문자를 검사하세요.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;waiit.me&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UVODE/dJMcafrOGOt/NO1Bwo2OkzirMAdDDRJ5w1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UVODE/dJMcafrOGOt/NO1Bwo2OkzirMAdDDRJ5w1/img.png&quot; width=&quot;501&quot; height=&quot;371&quot; data-origin-width=&quot;2142&quot; data-origin-height=&quot;1586&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.2688%; margin-right: 10px;&quot; data-widthpercent=&quot;49.85&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UVODE/dJMcafrOGOt/NO1Bwo2OkzirMAdDDRJ5w1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUVODE%2FdJMcafrOGOt%2FNO1Bwo2OkzirMAdDDRJ5w1%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;2142&quot; height=&quot;1586&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SASRy/dJMcahJWTjq/oA5K7qayPKmHfl6ADM0m9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SASRy/dJMcahJWTjq/oA5K7qayPKmHfl6ADM0m9k/img.png&quot; data-origin-width=&quot;2136&quot; data-origin-height=&quot;1572&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.5684%;&quot; data-widthpercent=&quot;50.15&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SASRy/dJMcahJWTjq/oA5K7qayPKmHfl6ADM0m9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSASRy%2FdJMcahJWTjq%2FoA5K7qayPKmHfl6ADM0m9k%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;2136&quot; height=&quot;1572&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로젝트 구조:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;waiit/
├── fe/          # React + Vite + TailwindCSS
├── noun/        # Kotlin Spring Boot API 서버
├── determiner/  # Python Flask AI 분석 서버
└── docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 기능:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;문자 메시지 입력 시 벡터 유사도 기반 스미싱 탐지&lt;/li&gt;
&lt;li&gt;Elasticsearch 벡터 DB로 기존 스미싱 패턴과 비교&lt;/li&gt;
&lt;li&gt;유사도 점수 및 유사 케이스 제공&lt;/li&gt;
&lt;li&gt;Google 로그인 + 월간 사용 제한&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub: &lt;a href=&quot;https://github.com/jeongum/waiit&quot;&gt;github.com/jeongum/waiit&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1768642805850&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 - jeongum/waiit: AI 기반 스미싱 탐지 서비스&quot; data-og-description=&quot;AI 기반 스미싱 탐지 서비스. Contribute to jeongum/waiit development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/jeongum/waiit&quot; data-og-url=&quot;https://github.com/jeongum/waiit&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/kFiLn/dJMb8862BDF/oKk5M9nGnDLzNrFo7Z7OK1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ObxQ2/dJMb8862BDG/9wryk4CV8znt0KKQ2j6UQ0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/waiit&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/jeongum/waiit&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/kFiLn/dJMb8862BDF/oKk5M9nGnDLzNrFo7Z7OK1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ObxQ2/dJMb8862BDG/9wryk4CV8znt0KKQ2j6UQ0/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 - jeongum/waiit: AI 기반 스미싱 탐지 서비스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;AI 기반 스미싱 탐지 서비스. Contribute to jeongum/waiit 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 contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회고: 완벽함보다 완성이 중요하다&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;아쉬운 점&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱에서 구현하려던 가족 연동 기능을 못 넣은 것&lt;/li&gt;
&lt;li&gt;실시간 문자 감지 알림 같은 리치한 기능들&lt;/li&gt;
&lt;li&gt;2년이란 시간을 허비한 것&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배운 점&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;완벽주의는 독이다&lt;/b&gt;: 완벽한 걸 만들려다 영원히 출시 못 합니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;바이브 코딩의 힘&lt;/b&gt;: 익숙하지 않은 분야도 AI와 함께라면 빠르게 프로토타이핑 가능&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최소 기능(MVP)의 중요성&lt;/b&gt;: 핵심만 담아 빠르게 검증하는 게 답&lt;/li&gt;
&lt;li&gt;&lt;b&gt;완성이 완벽을 이긴다&lt;/b&gt;: 세상에 나온 70%짜리가 서랍 속 100%짜리보다 낫다&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;앞으로의 계획&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 피드백 수집&lt;/li&gt;
&lt;li&gt;필요하다면 앱 개발 재개 (하지만 이번엔 더 빠르게)&lt;/li&gt;
&lt;li&gt;스미싱 패턴 DB 지속 업데이트&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2년 동안 완벽한 앱을 만들겠다고 질질 끌었지만, 최소 기능의 웹사이트는 이틀만에 완성했다. &lt;b&gt;바이브 코딩&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;회사에서도 AI를 활용해서 많은 프로젝트를 진행했었다. 특히 POC하는데에는 최적이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나같은 백엔드 개발자 입장에서 프론트 리소스를 기다리지 않고도, 해당 기능이 동작하는 최소한의 프로토타입을 굉장히 빠른 시간내에 만들어 낼 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이브 코딩, AI가 도입되면서 프로젝트의 이터레이션이 굉장히 빨라짐을 느낀다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2년 묵힌 프로젝트, 이틀만에 런칭. 가능하다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;웨잇 서비스&lt;/b&gt;: &lt;a href=&quot;https://waiit.me/&quot;&gt;waiit.me&lt;/a&gt;&lt;br /&gt;  &lt;b&gt;GitHub&lt;/b&gt;: &lt;a href=&quot;https://github.com/jeongum/waiit&quot;&gt;github.com/jeongum/waiit&lt;/a&gt;&lt;/p&gt;</description>
      <category>  개발 일지</category>
      <category>AI</category>
      <category>Claude</category>
      <category>GPT</category>
      <category>react 바이브코딩</category>
      <category>ViTE</category>
      <category>바이브코딩</category>
      <category>스미싱 탐지</category>
      <category>스미싱탐지 서비스</category>
      <category>웨잇</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/151</guid>
      <comments>https://doteloper.tistory.com/151#entry151comment</comments>
      <pubDate>Sat, 17 Jan 2026 18:41:22 +0900</pubDate>
    </item>
    <item>
      <title>스프링 IoC와 DI 파헤치기</title>
      <link>https://doteloper.tistory.com/150</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;스프링 IoC와 DI&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링에서 &amp;lsquo;&lt;b&gt;bean&lt;/b&gt;&amp;rsquo;은 단순한 객체가 아닌, &lt;b&gt;스프링이 직접 생성하고, 의존관계를 설정하며, 필요한 시점에 주입까지 담당하는 오브젝트.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션을 구성하는 최소 단위이며, 스프링이 제어권을 갖고 관리한다는 점에서 일반 객체와 구분됨.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC(Inversion of Control)는 이런 제어권을 &lt;b&gt;개발자가 아닌 프레임워크가 갖는 구조.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오브젝트 간의 관계를 설정하고 주입하는 책임을 외부 컨테이너에 넘김으로써, 애플리케이션의 유연성과 테스트 용이성이 크게 향상됨.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;빈 팩토리: IoC의 핵심 기능을 담당하는 컨테이너&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링이 제공하는 가장 기본적인 IoC 컨테이너.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1866&quot; data-origin-height=&quot;1710&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y1tQ1/btsOnZZBomN/L56wBXb3eKeTnGFTSGpgCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y1tQ1/btsOnZZBomN/L56wBXb3eKeTnGFTSGpgCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y1tQ1/btsOnZZBomN/L56wBXb3eKeTnGFTSGpgCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy1tQ1%2FbtsOnZZBomN%2FL56wBXb3eKeTnGFTSGpgCK%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;1866&quot; height=&quot;1710&quot; data-origin-width=&quot;1866&quot; data-origin-height=&quot;1710&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;오브젝트의 생성과 의존관계 설정을 담당하며, `getBean()` 메서드를 통해 등록된 빈을 꺼내 쓸 수 있도록 함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부적으로는 빈의 정의와 인스턴스를 중앙 레지스트리에 보관하고 관리.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빈 팩토리는 다음과 같은 역할을 수행하는 IoC 오브젝트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빈의 정의(BeanDefinition) 정보를 읽고 등록&lt;/li&gt;
&lt;li&gt;빈 인스턴스 생성 및 의존관계 주입&lt;/li&gt;
&lt;li&gt;필요 시 빈을 제공 (lazy initialization도 지원)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 2.0 이후부터는 다양한 스코프(scope)를 지원하며, 기본 싱글턴뿐 아니라 request, session 등의 스코프 빈도 관리 가능.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;애플리케이션 컨텍스트: 빈 팩토리를 확장한 IoC 컨테이너&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;BeanFactory를 확장한 컨테이너로, 스프링이 제공하는 종합적인 애플리케이션 지원 기능을 포함&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1874&quot; data-origin-height=&quot;1282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1xeEl/btsOolBkzsC/kBNsWs8Lwi5CZXDjk0yaoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1xeEl/btsOolBkzsC/kBNsWs8Lwi5CZXDjk0yaoK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1xeEl/btsOolBkzsC/kBNsWs8Lwi5CZXDjk0yaoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1xeEl%2FbtsOolBkzsC%2FkBNsWs8Lwi5CZXDjk0yaoK%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;1874&quot; height=&quot;1282&quot; data-origin-width=&quot;1874&quot; data-origin-height=&quot;1282&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 오브젝트 생성뿐 아니라, 리소스 로딩, 국제화 메시지 처리, 이벤트 발행 등 다양한 부가기능을 제공하는 오브젝트.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApplicationContext가 제공하는 기능은 다음과 같음:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ListableBeanFactory: 빈 탐색 및 조회 기능&lt;/li&gt;
&lt;li&gt;ResourceLoader: 범용적인 리소스 접근 기능&lt;/li&gt;
&lt;li&gt;ApplicationEventPublisher: 이벤트 발행 기능&lt;/li&gt;
&lt;li&gt;MessageSource: 국제화 메시지 처리 기능&lt;/li&gt;
&lt;li&gt;HierarchicalBeanFactory: 부모 컨텍스트 상속 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 부모 컨텍스트를 여러 서블릿이 공유하고, 각 서블릿이 독립적인 자식 컨텍스트를 가짐으로써, 계층적이고 재사용 가능한 설정 구성이 가능함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ApplicationContext 장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트는 구체적인 팩토리 클래스를 알 필요가 없음: 일관된 방식으로 원하는 오브젝트를 가져올 수 있음&lt;/li&gt;
&lt;li&gt;종합 IoC 서비스 제공: 오브젝트를 효과적으로 활용할 수 있는 다양한 기능 제공&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;싱글톤 레지스트리로서의 ApplicationContext&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ApplicationContext는 기본적으로 빈을 싱글톤으로 관리.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동일한 빈 요청이 반복되더라도 항상 같은 인스턴스를 반환함으로써 메모리 효율성과 일관성을 보장&lt;/b&gt;.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;내부적으로 생성하는 대부분의 빈은 싱글톤으로 관리되며, 별도로 명시하지 않는 한 기본 스코프는 singleton.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;단순한 POJO 클래스라도 스프링 컨테이너가 생성과 관계설정을 담당하면 자연스럽게 싱글톤 빈으로 전환됨.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;어노테이션 기반 설정: @Configuration과 @Bean&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`@Configuration`: 해당 클래스가 설정 정보를 담고 있다는 선언.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApplicationContext나 BeanFactory는 이 어노테이션이 붙은 클래스를 스캔하여 설정 클래스로 인식.&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;`@Bean`: 메서드가 IoC 컨테이너가 관리할 빈을 반환한다는 의미&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드 내부에서 오브젝트를 생성하고 의존관계를 주입한 후 반환하면, 컨테이너가 이를 빈으로 등록함.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
class AppConfig {
    @Bean
    fun userDao(): UserDao {
        val userDao = UserDao()
        userDao.connectionMaker = connectionMaker()
        return userDao
    }

    @Bean
    fun connectionMaker(): ConnectionMaker {
        return DConnectionMaker()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 설정 방식은&lt;b&gt; XML에 비해 직관적이고 타입 안정성이 높아, 최근에는 거의 표준처럼 사용되는 방식.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;IoC와 DI: 오브젝트를 제어하는 주체를 전환하는 개념&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC의 본질은 오브젝트의 제어권을 개발자가 아닌 프레임워크에게 넘기는 구조.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자신이 사용할 오브젝트를 직접 생성하거나 찾지 않고, 외부에서 주입받아 사용하는 방식이 DI(Dependency Injection).&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;DI는 생성자, 수정자(setter), 일반 메서드 등 다양한 방식으로 의존 오브젝트를 주입하며, 이 과정에서 스프링의 컨테이너가 모든 제어를 담당. 개발자는 단순히 &amp;ldquo;이 오브젝트가 필요하다&amp;rdquo;는 명세만 제공하면 됨.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DI는 다음과 같은 방법으로 수행됨:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;수정자 메소드 주입&lt;/b&gt; &amp;mdash; 가장 많이 쓰이는 방식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일반 메소드 주입&lt;/b&gt; &amp;mdash; 여러 의존성을 묶어서 처리할 때 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;생성자 주입&lt;/b&gt; &amp;mdash; final로 선언된 필드를 주입할 때 유용&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DI는 자신이 사용할 오브젝트에 대한 생성과 선택 책임을 외부로 넘기고, 주입받아 사용하는 점에서 IoC의 전형적인 구현 방식&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;IoC와 DI의 의의&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;IoC는 오브젝트 생성과 관계설정에 대한 제어권을 프레임워크에게 위임하는 개념&lt;/li&gt;
&lt;li&gt;DI는 이 제어권 위임을 실현하는 구체적인 방식&lt;/li&gt;
&lt;li&gt;스프링은 BeanFactory와 ApplicationContext를 통해 IoC/DI를 실현&lt;/li&gt;
&lt;li&gt;@Configuration과 @Bean을 활용하면 타입 안전성과 가독성이 뛰어난 설정 구성이 가능&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>  Study/CS</category>
      <category>DI</category>
      <category>IOC</category>
      <category>Spring</category>
      <category>의존관계 주입</category>
      <category>제어의역전</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/150</guid>
      <comments>https://doteloper.tistory.com/150#entry150comment</comments>
      <pubDate>Tue, 3 Jun 2025 17:38:01 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot/Kotlin] 레디스 명령어들을 Atomic하게 실행하기 - Lua Script</title>
      <link>https://doteloper.tistory.com/149</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 하다 보면 여러 Redis 명령어를 하나의 흐름으로 묶어 실행해야 하는 경우가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 경우, &lt;b&gt;Rate Limit&lt;/b&gt; 기능을 Redis로 구현하면서 이런 상황을 마주했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;1. 현재 window 만큼 sorted set 자르기
2. sorted set의 크기 가져오기
3. 해당 크기가 limit보다 작다면, 현재의 timestamp를 sorted set에 추가하기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 과정은 반드시 &lt;b&gt;원자적(atomic)&lt;/b&gt; 으로 보장돼야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 2번 &amp;rarr; 3번 실행 사이에 다른 요청이 들어오면, 잘못된 limit 체크 결과가 나올 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해, Redis에서는 &lt;b&gt;여러 명령어를 하나의 단위로 실행&lt;/b&gt;할 수 있는 방법을 제공한다. 그중 하나가 &lt;b&gt;Lua Script&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Lua Script란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis 공식 문서에 따르면, Lua Script는 다음과 같은 특징을 가진다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;데이터가 존재하는 서버&lt;/b&gt;에서 로직을 실행하여, 네트워크 지연을 줄이고 리소스를 절약할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스크립트 전체가 블로킹 방식&lt;/b&gt;으로 실행되어, 중간 개입 없이 &lt;b&gt;완전한 원자성&lt;/b&gt;을 보장한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://redis.io/docs/latest/develop/interact/programmability/eval-intro/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://redis.io/docs/latest/develop/interact/programmability/eval-intro/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1745846274920&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;Scripting with Lua&quot; data-og-description=&quot;Executing Lua in Redis&quot; data-og-host=&quot;redis.io&quot; data-og-source-url=&quot;https://redis.io/docs/latest/develop/interact/programmability/eval-intro/&quot; data-og-url=&quot;https://redis.io/docs/latest/develop/interact/programmability/eval-intro/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://redis.io/docs/latest/develop/interact/programmability/eval-intro/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://redis.io/docs/latest/develop/interact/programmability/eval-intro/&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;Scripting with Lua&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Executing Lua in Redis&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;redis.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로그래밍 레벨 Lock vs Lua Script&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, &lt;b&gt;프로그래밍 레벨에서 Lock을 잡는 것과 Lua Script를 사용하는 것&lt;/b&gt;은 어떤 차이가 있을까?&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프로그래밍 레벨에서의 Lock&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트가 먼저 SET resource_key unique_id NX PX timeout 으로 락을 획득&lt;/li&gt;
&lt;li&gt;락이 잡히면 일련의 Redis 명령을 순차적으로 실행&lt;/li&gt;
&lt;li&gt;끝나면 if redis.call(&quot;GET&quot;, key) == unique_id then DEL key end 형태로 해제&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 27px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span&gt;장점&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span&gt;단점&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 27px;&quot; rowspan=&quot;2&quot;&gt;&lt;span&gt;- 분산 락을 적용하여 여러 Redis 인스턴스 간에도 락 조율 가능&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;br /&gt;- 복잡한 비즈니스 로직 흐름 제어가 자유로움:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;즉, Redis 명령어 뿐 아니라 다른 비즈니스 로직이 포함되어도 OK&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 10px;&quot; rowspan=&quot;2&quot;&gt;&lt;span&gt;- 코드 구현이 복잡합: 락 획득/해제 실패 대비, 타임아웃 관리, 재시도 등&lt;br /&gt;&lt;/span&gt;&lt;span&gt;- 락을 잡은 동안에도 각 명령어는 개별 네트워크 왕복&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Lua Script&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 명령을 하나의 Lua 스크립트로 묶어 EVAL 호출&lt;/li&gt;
&lt;li&gt;Redis 서버가 스크립트를 한 덩어리로 &lt;b&gt;단일 스레드&lt;/b&gt;에서 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style4&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span&gt;장점&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span&gt;단점&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 27px;&quot; rowspan=&quot;2&quot;&gt;&lt;span&gt;&lt;span&gt;- &lt;/span&gt;&lt;/span&gt;&lt;span&gt;완전한 원자성 보장: 중간에 다른 클라이언트 명령이 개입 불가&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;br /&gt;- &lt;/span&gt;&lt;/span&gt;&lt;span&gt;네트워크 왕복 제거: 스크립트 내부 명령은 하나의 네트워크 Call로 처리 가능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 10px;&quot; rowspan=&quot;2&quot;&gt;&lt;span&gt;&lt;span&gt;- 단일 인스턴스 내에서만 원자성 보장: &lt;/span&gt;&lt;/span&gt;&lt;span&gt;클러스터 모드라면 키가 같은 슬롯에 있어야 함&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순히 Redis 명령어 간의 &lt;b&gt;원자성&lt;/b&gt;만 필요하다면 &amp;rarr; &lt;b&gt;Lua Script&lt;/b&gt; 추천&lt;/li&gt;
&lt;li&gt;복잡한 비즈니스 로직(외부 API 호출, 다양한 데이터 연산 등)이 포함된다면 &amp;rarr; &lt;b&gt;Lock&lt;/b&gt; 기반 처리 추천&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Boot에서 Lua Script 사용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 위 시나리오를 &lt;b&gt;Spring Boot&lt;/b&gt; 환경에서 어떻게 구현하는지 살펴보자. (전체 코드는 아래에서!)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Lua Script 작성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;resource 디렉토리에 필요한 레디스 명령들을 작성한 .lua 파일을 생성&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- rate_limit_script.lua
-- KEYS[1]: rate limit key
-- ARGV[1]: 현재 타임스탬프 (밀리초)
-- ARGV[2]: 윈도우 크기 (밀리초)
-- ARGV[3]: 허용 횟수(limit)
local key       = KEYS[1]
local now       = tonumber(ARGV[1])
local window    = tonumber(ARGV[2])
local limit     = tonumber(ARGV[3])
local window_start = now - window

-- 1) 윈도우 밖 오래된 기록 삭제
redis.call(&quot;ZREMRANGEBYSCORE&quot;, key, 0, window_start)

-- 2) 현재 윈도우 내 요청 수 확인
local cnt = redis.call(&quot;ZCARD&quot;, key)
if cnt &amp;gt;= limit then
  return 0
end

-- 3) 새 요청 기록
redis.call(&quot;ZADD&quot;, key, now, now)

return 1
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;KEYS와 ARGV를 통해 외부에서 값을 입력받는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RedisScriptConfig&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에 작성한 lua 스크립트를 가져와 RedisScript Bean 등록&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Configuration
class RedisScriptConfig {
    @Bean
    fun rateLimiterScript(): RedisScript&amp;lt;Long&amp;gt; = DefaultRedisScript(
			  // 1) Script의 ClassPath를 지정하여 가져온 후 등록
        ResourceScriptSource(ClassPathResource(&quot;rate_limit_script.lua&quot;)).scriptAsString,
				// 2) Return Type
        Long::class.java
    )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;LuaScript에서 리턴하는 타입에 맞게, 타입을 명시한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bean으로 등록한 스크립트를 사용&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Component
class RedisRateLimiter(
    private val redisTemplate: StringRedisTemplate,
    private val rateLimiterScript: RedisScript&amp;lt;Long&amp;gt;
) {
    companion object {
        private const val ALLOW = 1L
    }

    fun isAllowed(key: String, timestamp: Long, windowMs: Long, limit: Int): Boolean {
        // 1) Script 실행
        val result = redisTemplate.execute(
            rateLimiterScript,
            listOf(key),
            timestamp.toString(),
            windowMs.toString(),
            limit.toString()
        )
        return result == ALLOW
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`redisTemplate.execute` 명령어를 사용하여 script 실행한다.&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;1184&quot; data-origin-height=&quot;252&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k8mLV/btsNCos4pfp/RoRyqrLF4f2uSDMT28YYw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k8mLV/btsNCos4pfp/RoRyqrLF4f2uSDMT28YYw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k8mLV/btsNCos4pfp/RoRyqrLF4f2uSDMT28YYw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk8mLV%2FbtsNCos4pfp%2FRoRyqrLF4f2uSDMT28YYw0%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;500&quot; height=&quot;106&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;252&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두번째 파라미터 `keys` 에는 스크립트에서 사용될 key 들을 넣는다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용: `local key = KEYS[1]`&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이후 파라미터는 스크립트에서 사용될 argument들을 나열한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용: `local limit = tonumber(ARGV[3])`&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면, &lt;b&gt;Spring Boot&lt;/b&gt; 프로젝트에서도 &lt;b&gt;Lua Script를 통한 Redis의 원자적 작업 처리&lt;/b&gt;를 쉽게 구현할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단일 명령이 아닌 &lt;b&gt;복합 작업이 필요한 경우&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;레이스 컨디션이나 데이터 꼬임 없이 &lt;b&gt;안정적으로 처리하고 싶을 때&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua Script는 Redis에서 매우 강력한 무기가 될 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 전체 코드는 아래에서 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/ratelimit/tree/luascript&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/jeongum/ratelimit/tree/luascript&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1745845828577&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 - jeongum/ratelimit&quot; data-og-description=&quot;Contribute to jeongum/ratelimit development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/jeongum/ratelimit/tree/luascript&quot; data-og-url=&quot;https://github.com/jeongum/ratelimit&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bVO4es/hyYIfEttOG/Mnfs8lYe2ScA8zWKgLO0q0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/ratelimit/tree/luascript&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/jeongum/ratelimit/tree/luascript&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bVO4es/hyYIfEttOG/Mnfs8lYe2ScA8zWKgLO0q0/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 - jeongum/ratelimit&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to jeongum/ratelimit 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>  개발 일지/SpringBoot</category>
      <category>Kotlin</category>
      <category>Lock</category>
      <category>luascript</category>
      <category>Redis</category>
      <category>spring boot</category>
      <category>분산락</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/149</guid>
      <comments>https://doteloper.tistory.com/149#entry149comment</comments>
      <pubDate>Mon, 28 Apr 2025 22:10:51 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot/Kotlin] E2EE: 간단한 메신저 서버 구현하기</title>
      <link>https://doteloper.tistory.com/148</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;암호화의 두 축: 대칭키 vs 비대칭키&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호화 시스템은 크게 두 가지 방식으로 나뉜다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;대칭키 암호화&lt;/b&gt;: 하나의 비밀키로 암호화와 복호화를 모두 수행. 대표적으로 AES. 빠르고 계산량이 적어 대용량 데이터 암호화에 적합.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비대칭키 암호화&lt;/b&gt;: 공개키와 비공개키라는 두 개의 키를 사용. 대표적으로 RSA가 있으며, 한 키로 암호화한 데이터는 반드시 다른 키로만 복호화 가능. 키 교환, 인증, 서명 등에 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;⚡ 성능 측면에서의 차이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 대칭키 (AES) 비대칭키 (RSA)&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;속도&lt;/td&gt;
&lt;td&gt;매우 빠름&lt;/td&gt;
&lt;td&gt;상대적으로 느림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;연산량&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;많음 (큰 수 연산)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자원 사용&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용 용도&lt;/td&gt;
&lt;td&gt;메시지/파일 암호화&lt;/td&gt;
&lt;td&gt;키 전달/서명/인증&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 성능 차이로 인해 실제 보안 시스템에서는 &amp;ldquo;하이브리드 암호화&amp;rdquo;라는 방식이 등장&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&amp;nbsp;하이브리드 암호화란?&lt;/b&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠르고 효율적인 AES로 메시지를 암호화하고, 이 AES 키를 수신자의 공개키(RSA)로 암호화하는 방식&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;전송 흐름:&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트는 랜덤 AES 키를 생성&lt;/li&gt;
&lt;li&gt;메시지를 AES 키로 암호화 (IV 포함)&lt;/li&gt;
&lt;li&gt;AES 키를 상대방의 공개키로 RSA 암호화&lt;/li&gt;
&lt;li&gt;암호문 + 암호화된 AES 키를 서버에 저장 또는 전송&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수신자는:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;자신의 비공개키로 AES 키를 복호화&lt;/li&gt;
&lt;li&gt;해당 AES 키로 메시지를 복호화&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 통해 속도와 보안을 동시에 확보&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;&amp;nbsp;IV란?&lt;/b&gt; (Initialization Vector): AES 블록 암호화에서 첫 블록을 랜덤화하기 위해 사용되는 16바이트 값. 암호문에 포함되며 공개되어도 보안에 문제가 없음.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;nbsp;실제 구현 시 암호문 구조&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;encryptedMessage&quot;: &quot;&amp;lt;IV + AES 암호문&amp;gt;&quot;,
  &quot;encryptedAesKey&quot;: &quot;&amp;lt;RSA로 암호화된 AES 키&amp;gt;&quot;
}
&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;&lt;b&gt; 왜 클라이언트에서 암복호화를 할까?&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버에서 암복호화를 하면 서버가 평문을 보게 되므로 진정한 보안이 어려움&lt;/li&gt;
&lt;li&gt;클라이언트에서 키를 생성하고, 암호화를 수행하며 서버는 단순한 전달자 역할만 수행하면 진정한 &lt;b&gt;종단간 암호화 (E2EE)&lt;/b&gt; 구조가 완성.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트 중심 E2EE 흐름 정리&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트가 공개키를 서버에 등록&lt;/li&gt;
&lt;li&gt;메시지를 보내기 전 상대방의 공개키를 서버에서 조회&lt;/li&gt;
&lt;li&gt;AES 키 생성 &amp;rarr; 메시지를 암호화 &amp;rarr; AES 키는 상대의 공개키로 암호화&lt;/li&gt;
&lt;li&gt;수신자는 자신의 비공개키로 AES 키 복호화 &amp;rarr; 메시지 복호화&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✅ 결론&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대칭키는 빠르지만 키 전달이 문제&lt;/li&gt;
&lt;li&gt;비대칭키는 느리지만 키 전달에 강력함&lt;/li&gt;
&lt;li&gt;그래서 하이브리드 암호화를 통해 둘의 장점을 조합&lt;/li&gt;
&lt;li&gt;클라이언트가 암복호화를 주도하면 진정한 종단간 보안(E2EE)이 가능함&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드로 이해하기&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 클라이언트의 역할이 너무나도 명확한 주제라 클라이언트를 함께 구현하고 싶었지만, 빠른 이해와 학습을 위해 SpringBoot로 E2EE 모든 Flow를 구현하였다.&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;코드를 구현하면서 알게된 새로운 사실이 있다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;일반 클라-서버 관계라면 서버는 단순히 클라에서 전송한 publicKey만 들고 있으며, keyPair생성, 메세지 암/복호화의 롤은 모두 클라가 갖는다. 또한, 비대칭키 암/복호화는 많은 computing 자원이 필요하기 때문에, 비대칭키로 암호화된 대칭키를 매번 변경하지 않는다.&lt;br /&gt;즉, 클라에서 비대칭키로 복호화한 메세징 대칭키를 Caching 해놓고, 빠르게 메세지를 복호화 할 수 있도록 한다.&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service&lt;/h3&gt;
&lt;pre id=&quot;code_1744439723913&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
class EncryptionService {
    /**
     *  AES: 대칭키 블록 암호화 알고리즘
     *  iv: 동일한 암호화 결과가 나오지 않도록 붙여주는 랜덤 값 (키랑 무관)
     */

    //  AES 대칭키 생성: 256 비트 길이 (메세지 본문 암호화 하는데 사용)
    fun generateAESKey(): SecretKey {
        val keyGen = KeyGenerator.getInstance(&quot;AES&quot;)
        keyGen.init(256)
        return keyGen.generateKey()
    }

    // 평문 메세지 암호화: 대칭키(secretKey) 사용
    fun encryptAESWithIVPrefixed(plainText: String, secretKey: SecretKey): String {
        val cipher = Cipher.getInstance(&quot;AES/CBC/PKCS5Padding&quot;)     // 암호 처리기 생성
        val ivBytes = ByteArray(16)     // 16 바이트 짜리 배열 생성
        SecureRandom().nextBytes(ivBytes)   // ivBytes 배열에 무작위 값 채움
        val ivSpec = IvParameterSpec(ivBytes)

        cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec)     // 암호 처리기 초기화
        val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))

        val combined = ivBytes + encryptedBytes     // 메세지 앞에 16 바이트를 붙여서, 메세지 생성
        return Base64.getEncoder().encodeToString(combined)
    }

    // 메제지 복호화: 대칭키(SecretKey) 사용
    fun decryptAESWithIVPrefixed(encryptedCombined: String, secretKey: SecretKey): String {
        val combinedBytes = Base64.getDecoder().decode(encryptedCombined)
        val iv = combinedBytes.copyOfRange(0, 16)   // 암호화에 사용한 iv 추출
        val encrypted = combinedBytes.copyOfRange(16, combinedBytes.size)   // 암호화된 실제 메세지 추출

        val cipher = Cipher.getInstance(&quot;AES/CBC/PKCS5Padding&quot;)     // 암호 처리기 생성
        val ivSpec = IvParameterSpec(iv)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) // 암호 처리기 초기화 - Decrypt 모드
        val decryptedBytes = cipher.doFinal(encrypted)

        return String(decryptedBytes, Charsets.UTF_8)
    }

    // 대칭키 암호화: 수신자의 공개키로 암호화 하여 전달할 수 있도록 함
    fun encryptAESKeyWithRSA(secretKey: SecretKey, publicKey: PublicKey): String {
        val cipher = Cipher.getInstance(&quot;RSA&quot;)
        cipher.init(Cipher.ENCRYPT_MODE, publicKey)
        val encryptedKey = cipher.doFinal(secretKey.encoded)
        return Base64.getEncoder().encodeToString(encryptedKey)
    }

    // 대칭키 복호화: 비공개키로 복호화
    fun decryptAESKeyWithRSA(encryptedAesKey: String, privateKey: PrivateKey): SecretKey {
        val cipher = Cipher.getInstance(&quot;RSA&quot;)
        cipher.init(Cipher.DECRYPT_MODE, privateKey)
        val decodedKey = cipher.doFinal(Base64.getDecoder().decode(encryptedAesKey))
        return SecretKeySpec(decodedKey, &quot;AES&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`generateAESKey`: 메세지를 암호화하기 위한 랜덤 키를 생성&lt;/li&gt;
&lt;li&gt;`encryptAESKeyWithRSA` / `decryptAESKeyWithRSA`: 대칭키 암/복호화 코드
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;암호화: 수신자의 공개키로 대칭키를 암호화한다.&lt;/li&gt;
&lt;li&gt;복호화: 본인의 비공개키로 대칭키를 복호화한다.&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;`encryptAESWithIVPrefixed` / `decryptAESWithIVPrefixed`: 메세지 암/복호화 코드
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;암호화: 대칭키를 사용하여 메세지를 암호화한다. 이때 메세지 앞에 iv를 붙여서, 같은 내용에 대해 같은 결과가 나오지 않도록 한다.&lt;/li&gt;
&lt;li&gt;복호화: 대칭키를 사용하여 메세지를 복호화한다. 마찬가지로 iv bytes를 제거하고, 메세지를 복호화 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Controller&lt;/h3&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@RequestMapping(&quot;/api&quot;)
class MessageController(
    private val encryptionService: EncryptionService,
) {
    private val publicKeyMap: MutableMap&amp;lt;String, PublicKey&amp;gt; = mutableMapOf()
    private val privateKeyMap: MutableMap&amp;lt;String, PrivateKey&amp;gt; = mutableMapOf()
    private val messageBox: MutableMap&amp;lt;String, MutableList&amp;lt;EncryptedPayload&amp;gt;&amp;gt; = mutableMapOf()

    @PostMapping(&quot;/register&quot;)
    fun registerUser(@RequestBody request: PublicKeyRegisterRequest): String {
        // userId에 대한 KeyPair 생성
        val keyPairGen = KeyPairGenerator.getInstance(&quot;RSA&quot;)
        keyPairGen.initialize(2048)
        val keyPair = keyPairGen.generateKeyPair()

        publicKeyMap[request.userId] = keyPair.public
        privateKeyMap[request.userId] = keyPair.private

        return &quot;User ${request.userId} registered with key pair&quot;
    }

    @PostMapping(&quot;/send&quot;)
    fun sendMessage(@RequestBody request: MessageRequest): String {
        val receiverPublicKey = publicKeyMap[request.receiverId] ?: error(&quot;Receiver not found&quot;)
        val aesKey = encryptionService.generateAESKey()

        // 메세지 암호화(대칭키)
        val encryptedMessage = encryptionService.encryptAESWithIVPrefixed(request.message, aesKey)

        // 대칭키 암호화(비대칭키)
        val encryptedAesKey = encryptionService.encryptAESKeyWithRSA(aesKey, receiverPublicKey)

        val payload = EncryptedPayload(request.senderId, encryptedMessage, encryptedAesKey)
        messageBox.computeIfAbsent(request.receiverId) { mutableListOf() }.add(payload)

        return &quot;Message sent securely to ${request.receiverId}&quot;
    }

    @GetMapping(&quot;/messages/{userId}&quot;)
    fun receiveMessages(@PathVariable userId: String): List&amp;lt;MessageResponse&amp;gt; {
        val privateKey = privateKeyMap[userId] ?: error(&quot;Private key not found for $userId&quot;)
        val messages = messageBox[userId] ?: return emptyList()

        return messages.map { payload -&amp;gt;
            // 대칭키 복호화(비대칭키)
            val aesKey = encryptionService.decryptAESKeyWithRSA(payload.encryptedAesKey, privateKey)

            // 메세지 복호화(대칭키)
            val plainText = encryptionService.decryptAESWithIVPrefixed(payload.encryptedMessage, aesKey)
            MessageResponse(&quot;${payload.senderId}: $plainText&quot;)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;sendMessage`: 메세지를 보내기 위한 API&lt;br /&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;대칭키를 사용해서 메세지를 암호화한다.&lt;/li&gt;
&lt;li&gt;사용된 대칭키를 같이 보내야하기 때문에 이 대칭키를 수신자의 공개키로 암호화 한다.&lt;/li&gt;
&lt;li&gt;해당 메세지를 메모리에 임시 저장해둔다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;`receiveMessages`: 수신된 메세지 확인을 위한 API
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;나의 비공개키를 사용하여 대칭키를 우선 복호화 한다.&lt;/li&gt;
&lt;li&gt;복호화된 키로 메세지를 복호화한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  전체 코드는 아래에서!  &lt;/p&gt;
&lt;figure id=&quot;og_1744440879084&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 - jeongum/e2ee&quot; data-og-description=&quot;Contribute to jeongum/e2ee development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/jeongum/e2ee&quot; data-og-url=&quot;https://github.com/jeongum/e2ee&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dRkvdC/hyYEw6C8Ot/6VD3Mww3y2RxLIPxTOkQ5k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/G4v6e/hyYFBTI9of/kzjiDBNARTHAJBs9OSidk1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/e2ee&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/jeongum/e2ee&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dRkvdC/hyYEw6C8Ot/6VD3Mww3y2RxLIPxTOkQ5k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/G4v6e/hyYFBTI9of/kzjiDBNARTHAJBs9OSidk1/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 - jeongum/e2ee&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to jeongum/e2ee 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>  개발 일지/SpringBoot</category>
      <category>e2ee</category>
      <category>Kotlin</category>
      <category>SpringBoot</category>
      <category>메신저</category>
      <category>암호화</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/148</guid>
      <comments>https://doteloper.tistory.com/148#entry148comment</comments>
      <pubDate>Sat, 12 Apr 2025 15:56:08 +0900</pubDate>
    </item>
    <item>
      <title>Railway로 SpringBoot + MySQL + Redis 배포하기 (+Dockerfile)</title>
      <link>https://doteloper.tistory.com/147</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Railway란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 AWS의 EC2 프리티어를 사용하였는데, 프리티어 기간에 매번 의존해야하고, 인프라도 모두 스스로 구축해야하는 부분이 굉장히 번거롭게 느껴졌다.&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;그래서 저렴하게 호스팅할 수 있는 서비스를 찾다가 Railway를 발견하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Railway는&lt;b&gt; 개발자가 손쉽게 인프라를 구축하고 애플리케이션을 배포할 수 있도록 돕는 PaaS(Platform as a Service) 서비스&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://railway.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://railway.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1740919733353&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;Railway&quot; data-og-description=&quot;Railway is an infrastructure platform where you can provision infrastructure, develop with that infrastructure locally, and then deploy to the cloud.&quot; data-og-host=&quot;railway.com&quot; data-og-source-url=&quot;https://railway.com/&quot; data-og-url=&quot;https://railway.app&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/hg2vx/hyYmMVO85H/RLDx81gVmeXKH99yfkVHZ1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://railway.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://railway.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/hg2vx/hyYmMVO85H/RLDx81gVmeXKH99yfkVHZ1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&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;Railway&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Railway is an infrastructure platform where you can provision infrastructure, develop with that infrastructure locally, and then deploy to the cloud.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;railway.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;주요 특징으로는 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;간편한 배포: GitHub와 연동하여 코드 변경 사항을 자동으로 배포할 수 있다. Docker를 직접 설정할 필요 없이 git push만 하면 새로운 버전이 배포된다.&lt;/li&gt;
&lt;li&gt;자동화된 인프라: 기본적으로 &lt;b&gt;MySQL, PostgreSQL, Redis, MongoDB 같은 데이터베이스를 제공&lt;/b&gt;하여 쉽게 추가할 수 있다. 즉, 몇 번의 클릭만으로 애플리케이션과 데이터베이스를 한 번에 배포할 수 있다.&lt;/li&gt;
&lt;li&gt;실시간 로그 및 모니터링: 애플리케이션의 성능을 실시간으로 모니터링하고, 로그를 확인할 수 있다.&lt;/li&gt;
&lt;li&gt;⭐️ 무료 크레딧 제공: 무료 크레딧 $5를 제공하여, 토이 프로젝트에 적합하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;결론부터 말하자면, 토이 프로젝트 배포에 Railway 사용은 정말 강추한다. &lt;/span&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_Screenshot 2025-03-02 at 9.09.12 PM.png&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;1560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bh0yh1/btsMyWc6ltP/FuvhcpUETr7dqIF7KqzFAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bh0yh1/btsMyWc6ltP/FuvhcpUETr7dqIF7KqzFAK/img.png&quot; data-alt=&quot;나의 프로젝트 예시이다. 보다시피 UI가 굉장히 직관적이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bh0yh1/btsMyWc6ltP/FuvhcpUETr7dqIF7KqzFAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbh0yh1%2FbtsMyWc6ltP%2FFuvhcpUETr7dqIF7KqzFAK%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;600&quot; height=&quot;531&quot; data-filename=&quot;edited_Screenshot 2025-03-02 at 9.09.12 PM.png&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;1560&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;나의 프로젝트 예시이다. 보다시피 UI가 굉장히 직관적이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis / MySQL Deployment&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 두가지 Database는 설명을 하는 것이 부끄러울 정도로, 정말 클릭 몇번으로 바로 생성된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;772&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3shgh/btsMzekijh5/wJZYSHc1QCkmwBWdiebGh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3shgh/btsMzekijh5/wJZYSHc1QCkmwBWdiebGh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3shgh/btsMzekijh5/wJZYSHc1QCkmwBWdiebGh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3shgh%2FbtsMzekijh5%2FwJZYSHc1QCkmwBWdiebGh1%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;600&quot; height=&quot;400&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;772&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 화면에서 필요한 Database들을 클릭해주면 바로 생성이 완료된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2025-03-02 at 9.16.01 PM.png&quot; data-origin-width=&quot;2512&quot; data-origin-height=&quot;1742&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8gdHb/btsMyVrIJpf/TkXirOFXlDtv3Kn7nCwtB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8gdHb/btsMyVrIJpf/TkXirOFXlDtv3Kn7nCwtB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8gdHb/btsMyVrIJpf/TkXirOFXlDtv3Kn7nCwtB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8gdHb%2FbtsMyVrIJpf%2FTkXirOFXlDtv3Kn7nCwtB1%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;600&quot; height=&quot;416&quot; data-filename=&quot;Screenshot 2025-03-02 at 9.16.01 PM.png&quot; data-origin-width=&quot;2512&quot; data-origin-height=&quot;1742&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;생성이 완료된 Database에 대해 Variables 탭으로 들어가면, Spring 설정에 필요한 변수들을 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Screenshot 2025-03-02 at 9.17.10 PM.png&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;78&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bneKzH/btsMBjYKvL1/KliAmPfwVpGyqLNmJIri6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bneKzH/btsMBjYKvL1/KliAmPfwVpGyqLNmJIri6k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bneKzH/btsMBjYKvL1/KliAmPfwVpGyqLNmJIri6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbneKzH%2FbtsMBjYKvL1%2FKliAmPfwVpGyqLNmJIri6k%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;600&quot; height=&quot;44&quot; data-filename=&quot;Screenshot 2025-03-02 at 9.17.10 PM.png&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;78&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예를들어 위와 같이 설정되어 있을 경우, 스프링 환경변수에도 동일하게 설정하면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# application.yml
spring: 
  data:
    redis:
      host: redis.railway.internal&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Boot Deployment&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 Database를 먼저 설정하고, Spring Boot의 환경 변수 설정까지 마치면,&lt;/p&gt;
&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;+ Create 버튼을 눌러 GitHub Repo를 클릭한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_Screenshot 2025-03-02 at 9.12.08 PM.png&quot; data-origin-width=&quot;1122&quot; data-origin-height=&quot;546&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8pkcj/btsMALIcrQ9/RFYkJkAr3Qf7LH5CkAuty1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8pkcj/btsMALIcrQ9/RFYkJkAr3Qf7LH5CkAuty1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8pkcj/btsMALIcrQ9/RFYkJkAr3Qf7LH5CkAuty1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8pkcj%2FbtsMALIcrQ9%2FRFYkJkAr3Qf7LH5CkAuty1%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;600&quot; height=&quot;292&quot; data-filename=&quot;edited_Screenshot 2025-03-02 at 9.12.08 PM.png&quot; data-origin-width=&quot;1122&quot; data-origin-height=&quot;546&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Configure GitHub App 을 통해 접근 설정을 마치면, 사진과 같이 설정한 레포가 보인다.&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;1678&quot; data-origin-height=&quot;2112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EsEl1/btsMyFoWiS2/JSijm1iqNTSALfJmNKLEyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EsEl1/btsMyFoWiS2/JSijm1iqNTSALfJmNKLEyK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EsEl1/btsMyFoWiS2/JSijm1iqNTSALfJmNKLEyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEsEl1%2FbtsMyFoWiS2%2FJSijm1iqNTSALfJmNKLEyK%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;600&quot; height=&quot;755&quot; data-origin-width=&quot;1678&quot; data-origin-height=&quot;2112&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어플리케이션이 생성되고, Deployments 탭으로 들어가면, 위와 같이 빌드되고 있는 현황을 볼 수 있다.&lt;/li&gt;
&lt;li&gt;Dockerfile로 빌드를 진행하며, 배포까지 완료한다.&lt;/li&gt;
&lt;li&gt;탭에서 보이듯, 빌드 로그와 배포 후 애플리케이션 로그, Http 요청 로그까지 한번에 확인이 가능하다. (터미널로 로그 보던 시절 잘가라&amp;hellip; )&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;1708&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKDyCG/btsMzYab06r/rGR0dQe5dcJXqtlxKExoJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKDyCG/btsMzYab06r/rGR0dQe5dcJXqtlxKExoJK/img.png&quot; data-alt=&quot;Build Logs&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKDyCG/btsMzYab06r/rGR0dQe5dcJXqtlxKExoJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKDyCG%2FbtsMzYab06r%2FrGR0dQe5dcJXqtlxKExoJK%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;600&quot; height=&quot;146&quot; data-origin-width=&quot;1708&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Build Logs&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1728&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1ETEA/btsMy3wjZvS/ojDyWx4zmDJ3sAobk9C9V1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1ETEA/btsMy3wjZvS/ojDyWx4zmDJ3sAobk9C9V1/img.png&quot; data-alt=&quot;Deploy Logs&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1ETEA/btsMy3wjZvS/ojDyWx4zmDJ3sAobk9C9V1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1ETEA%2FbtsMy3wjZvS%2FojDyWx4zmDJ3sAobk9C9V1%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;600&quot; height=&quot;131&quot; data-origin-width=&quot;1728&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Deploy Logs&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1746&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGvUMi/btsMAKbpxKm/7jHkZlMIFtm2eK5NsleaQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGvUMi/btsMAKbpxKm/7jHkZlMIFtm2eK5NsleaQK/img.png&quot; data-alt=&quot;HTTP Logs&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGvUMi/btsMAKbpxKm/7jHkZlMIFtm2eK5NsleaQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGvUMi%2FbtsMAKbpxKm%2F7jHkZlMIFtm2eK5NsleaQK%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;600&quot; height=&quot;127&quot; data-origin-width=&quot;1746&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;HTTP Logs&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;hellip;&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;Railway에서 제공하는 무료 크레딧 $5가 얼마나 버틸 수 있는지 아직까지 가늠되지 않지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;월 구독을 하고서라도 계속해서 사용하고 싶을 정도로 UI 및 모든 세팅이 간편하고 명료&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;➕ 나는 구매한 도메인을 연결하였는데, 놀랍게도 HTTP Secure 설정까지 자동으로 되는 것으로 보인다. 더 말하지 않아도 될 것 같지만, 커스텀 도메인 연결까지도 클릭 몇번이면 가능하다!!!!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커 빌드에도 애를 좀 먹어서, Dockerfile 코드 공유로 Railway 사용기를 마치겠다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;# 1단계: Gradle을 사용해 빌드
FROM openjdk:17-alpine AS builder
WORKDIR /app
COPY . .
RUN apk add --no-cache bash
RUN chmod +x ./gradlew
RUN ./gradlew clean build --no-daemon

# 2단계: 빌드된 JAR 파일만 실행 컨테이너로 복사
FROM openjdk:17-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8081
ENTRYPOINT [&quot;java&quot;, &quot;-Dspring.profiles.active=prod&quot;, &quot;-Duser.timezone=Asia/Seoul&quot;, &quot;-jar&quot;, &quot;/app/app.jar&quot;]
&lt;/code&gt;&lt;/pre&gt;</description>
      <category>  개발 일지/SpringBoot</category>
      <category>deployment</category>
      <category>docker</category>
      <category>dockerfile</category>
      <category>mysql 배포</category>
      <category>Railway</category>
      <category>redis 배포</category>
      <category>SpringBoot</category>
      <category>배포</category>
      <category>토이프로젝트</category>
      <category>호스팅</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/147</guid>
      <comments>https://doteloper.tistory.com/147#entry147comment</comments>
      <pubDate>Sun, 2 Mar 2025 21:46:38 +0900</pubDate>
    </item>
    <item>
      <title>[WebDev] Patterns for building realtime features</title>
      <link>https://doteloper.tistory.com/146</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://zknill.io/posts/patterns-for-building-realtime/?utm_source=tldrwebdev&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://zknill.io/posts/patterns-for-building-realtime/?utm_source=tldrwebdev&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1739364475754&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;Patterns for building realtime features&quot; data-og-description=&quot;Realtime features make apps feel modern, collaborative, and up-to-date. The features predominantly require sharing changes triggered by one user to other users, as the changes are happening. This typically means your server needs to send data to some set o&quot; data-og-host=&quot;zknill.io&quot; data-og-source-url=&quot;https://zknill.io/posts/patterns-for-building-realtime/?utm_source=tldrwebdev&quot; data-og-url=&quot;https://zknill.io/posts/patterns-for-building-realtime/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://zknill.io/posts/patterns-for-building-realtime/?utm_source=tldrwebdev&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://zknill.io/posts/patterns-for-building-realtime/?utm_source=tldrwebdev&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;Patterns for building realtime features&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Realtime features make apps feel modern, collaborative, and up-to-date. The features predominantly require sharing changes triggered by one user to other users, as the changes are happening. This typically means your server needs to send data to some set o&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;zknill.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 기능은 앱을 더 현대적이고, 협업적이며, 최신 상태로 유지되도록 해준다. 이 기능은 주로 한 사용자가 발생시킨 변경을 다른 사용자들에게 실시간으로 공유하는 것을 필요로한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 일반적으로 서버가 특정 클라이언트들에게 데이터를 보내야 하는데, 그 클라이언트들은 자신들이 데이터를 놓치고 있다는 사실을 알지 못하는 상황을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴들은 서버가 클라이언트에게 데이터를 알릴 수 있는 connection에 의존한다. 이 connection은 웹소켓, sse, 이벤트 스트림 또는 polling일 수 있다. 이 connection은 단순히 클라이언트가 새로운 데이터가 있다는 것을 알지 못하는 상황에서 서버가 클라이언트에게 데이터를 보낼 수 있는 있도록 하면 된다.&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;Patterns&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Poke / Pull&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`poke/pull` 은 기존 앱과 호환될 수 있는 가장 쉬운 방법이다. 서버에서 update가 일어났을 때, 이를 subscribe하고 있는 모든 클라이언트에게 새로운 변경을 반영하라고 알리는 `poke`가 전송된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 이 알림을 받으면, 새로운 상태를 `pull`하기 위한 요청을 서버에 보낸다. poke/pull 은 새로운 상태를 가져가기 위한 엔드 포인트를 기존에 있는 것으로 활용할 수 있기 때문에 기존 앱과의 호환에 유리하다. 이미 존재하는 `pull API`를 사용하면 되기 때문에 `poke`만 구현하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴의 문제점은 fan-out이다. 서버에 하나의 변경ㅇ만 일어나도, 다수의 클라이언트에 대해 fan-out이 일어난다. 이는 다수의 클라이언트들이 &lt;b&gt;동시에&lt;/b&gt; 서버에 pull 요청을 보낸다는 것을 의미한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2020&quot; data-origin-height=&quot;752&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m0O97/btsMfXQBEkD/de7ZXPIKGwGqlCyuhOoHgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m0O97/btsMfXQBEkD/de7ZXPIKGwGqlCyuhOoHgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m0O97/btsMfXQBEkD/de7ZXPIKGwGqlCyuhOoHgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm0O97%2FbtsMfXQBEkD%2Fde7ZXPIKGwGqlCyuhOoHgk%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;2020&quot; height=&quot;752&quot; data-origin-width=&quot;2020&quot; data-origin-height=&quot;752&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 클라이언트들이 동시에 요청하기 때문에, 캐싱은 매우 효과적인 방법이다. 따라서, 응답을 캐싱하고 모든 클라이언트에게 동일한 응답을 주는 것은 비교적 쉽다. 클라이언트들은 거의 동시에 pulll 요청을 보내기 때문에, 캐싱된 응답은 충분히 최신 상태를 유지할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 또한 이러한 `poke`를 더 긴 시간을 두고 분산시킬 수 있는데, 이렇게 하면 동시에 `pull` 요청을 보내는 클라이언트 수를 줄일 수 있다.&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;Push state&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째 패턴은 `push state`이다. 이 패턴에서 서버는 이미 클라이언트가 받아야할 최신 상태를 알고 있기 때문에, 그 상태를 클라이언트에 푸시한다. `poke`를 보내는 대신, 새로운 상태를 즉시 서버에서 보내는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2016&quot; data-origin-height=&quot;850&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ol39u/btsMfV6g144/bQqmPWK4jyEzhfk7bJwYsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ol39u/btsMfV6g144/bQqmPWK4jyEzhfk7bJwYsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ol39u/btsMfV6g144/bQqmPWK4jyEzhfk7bJwYsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fol39u%2FbtsMfV6g144%2FbQqmPWK4jyEzhfk7bJwYsK%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;2016&quot; height=&quot;850&quot; data-origin-width=&quot;2016&quot; data-origin-height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 모든 클라이언트가 `pull`을 할 필요 없이 즉시 업데이트된 상태를 받기 때문에 fan-out 문제를 완화시킨다. 상태를 푸싱하는 것은 클라이언트가 상태와 동기화되지 않을 가능성이 적기 때문에 다루기가 쉽다. 매번 업데이트될 때마다 클라이언트는 사용해야 할 전체 상태의 사본을 받게 되므로, 클라이언트에서 발생한 버그로 인해 상태가 엇갈리는 문제가 빠르게 해결될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 전체 상태를 푸싱하는 것은 클라이언트가 변경된 부분을 파악하기 어려울 수 있다. 또한 작은 변경이 일어나도 전체 상태를 재 전송해야하기 때문에, 대규모 상태를 처리 하는데에는 적합하지 않다. 예를 들어, &amp;ldquo;walk the dog&amp;rdquo;가 완료되어서 true로 변경되는 경우에도, 모든 &amp;ldquo;todos&amp;rdquo;를 다시 전송해야 한다.&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;Push ops&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;push ops는 클라이언트가 어느 부분이 변경되었는지 쉽게 파악할 수 있도록 도와주며, 전송되는 데이터의 사이즈도 줄여준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2012&quot; data-origin-height=&quot;630&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rjCFH/btsMfGVQxgV/X5uZI6k7PhRIrfPZOYZML1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rjCFH/btsMfGVQxgV/X5uZI6k7PhRIrfPZOYZML1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rjCFH/btsMfGVQxgV/X5uZI6k7PhRIrfPZOYZML1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrjCFH%2FbtsMfGVQxgV%2FX5uZI6k7PhRIrfPZOYZML1%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;2012&quot; height=&quot;630&quot; data-origin-width=&quot;2012&quot; data-origin-height=&quot;630&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적응로 서버는 상태 변경을 발생시키는 작업에 대해 이해하고 있으며, 이를 연결된 클라이언트들에게 전송할 수 있다. 즉, 서버는 전체 상태를 보내는 대신 클라이언트의 상태를 서버의 상태와 일치하도록 업데이트할 수 있는 작업(operation)을 전송한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제에서와 같이, 이 작업(operation)은 id가 2인 todo 항목의 상태가 `completed: true` 로 변경되었음을 나타낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 클라이언트는 이 작업을 기존 상태에 병합한다. `push state` 에서 언급한 것 처럼, 이것은 서버가 클라이언트의 상태를 정확히 이해하고 있다는 것에 의존한다. 그렇지 않으면 서버가 생성한 작업(operation)만으로는 클라이언트의 상태를 서버와 동기화 시킬 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 `push ops` 는 클라이언트가 작업을 적용할 수 있는 초기 상태를 얻는 매커니즘을 제공해야한다. 이것은 일반적으로 초기 상태를 설정하는 `initial` 작업을 먼저 제공하고, 이후의 작업이 그 위에 적용되는 식으로 모델링된다.&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;Event Sourcing&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`event sourcing` 에서는 변경을 나타내는 작업 혹은 업데이트된 상태를 전송하는 대신 변경을 유발한 &amp;lsquo;이벤트&amp;rsquo; 자체를 클라이언트에 전송한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-02-12 오후 9.32.24.png&quot; data-origin-width=&quot;2012&quot; data-origin-height=&quot;526&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AWUKq/btsMg4HWo2D/QRblY5RIz0Jrmtgr9HOeEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AWUKq/btsMg4HWo2D/QRblY5RIz0Jrmtgr9HOeEK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AWUKq/btsMg4HWo2D/QRblY5RIz0Jrmtgr9HOeEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAWUKq%2FbtsMg4HWo2D%2FQRblY5RIz0Jrmtgr9HOeEK%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;2012&quot; height=&quot;526&quot; data-filename=&quot;스크린샷 2025-02-12 오후 9.32.24.png&quot; data-origin-width=&quot;2012&quot; data-origin-height=&quot;526&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 예제에서 볼 수 있듯이, 이벤트는 업데이트 된 상태를 포함하는 대신 발생한 &amp;lsquo;이벤트&amp;rsquo;를 포함한다. 그렇기 때문에, 이 이벤트가 무엇인지 아는 것은 클라이언트의 책임이다. purists 들은 이것이 더 나은 데이터 표현 방식이라고 주장하지만, 실제로는 각 클라이언트들이 동일한 비즈니스 로직을 구현해야한다는 부담이 생긴다. 즉, 이 이벤트가 completed 필드를 true로 변경해야 한다는 의미임을 이해하는 로직을 각 클라이언트에서 구현해야 한다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마도 만약 클라이언트가 단순히 업데이트된 상태를 표시하는 것 이상의 복잡한 로직을 갖는 경우라면, 이벤트를 보내는 것이 유용할 수 있다. 이렇게 하면 클라이언트가 completed 필드가 true로 변경되었음을 역으로 해석할 필요 없이, 그저 &amp;ldquo;todo was completed&amp;rdquo;라는 이벤트를 직접 받아서 처리할 수 있기 때문이다.&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;Transports&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버와 클라이언트를 연결된 상태로 유지하기 위해 사용할 수 있는 웹소켓, sse, 이벤트 스트림, 코멧, 폴링 등의 다양한 전송방식이 있다. 각 방식에는 장단점이 있지만, 전반적으로 동일한 기능을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나열한 모든 전송 방식은 HTTP위에서 동작한다. 즉, 이들은 모두 서버와 클라이언트 간의 1:1 연결 방식이다. 즉, 하나의 클라이언트는 하나의 서버에 연결된다. 이제 서버가 여러개의 `replicas` 로 수평 확장된 시스템을 생각해보자. 이 경우, 상태 변경을 유발한 최초 요청을 처리한 서버가 모든 클라이언트 연결을 관리하는 서버와 다를 가능성이 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-02-12 오후 9.43.29.png&quot; data-origin-width=&quot;2024&quot; data-origin-height=&quot;1120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bniPxe/btsMfqZWss5/XkVxfK1Wus0V8KIB5DBhJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bniPxe/btsMfqZWss5/XkVxfK1Wus0V8KIB5DBhJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bniPxe/btsMfqZWss5/XkVxfK1Wus0V8KIB5DBhJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbniPxe%2FbtsMfqZWss5%2FXkVxfK1Wus0V8KIB5DBhJk%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;2024&quot; height=&quot;1120&quot; data-filename=&quot;스크린샷 2025-02-12 오후 9.43.29.png&quot; data-origin-width=&quot;2024&quot; data-origin-height=&quot;1120&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 이미지에는 두 대의 서버 `replica`가 있다. 녹색 클라이언트가 변경을 수행하고, 웹소켓 연결을 통해 해당 변경 사항을 server A와 공유하면, server A가 파란색 클라이언트와 이 업데이트를 공유하는 것은 비교적 쉽다. 하지만, server A가 주황색 클라이언트 두 명과 이 업데이트를 공유하는 것은 상당히 어렵다. 왜냐하면 이 클라이언트들은 server B에 연결되어 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 매우 번거로운 문제이다. 일반적으로 수평 확장된 서버들 간의 동기화는 데이터베이스를 통해 이루어진다. 하지만, 각 서버 `replica`가 변경 사항을 감지하기 위해 데이터베이스를 지속적으로 폴링하는 것은 비효율 적이다. `LISTEN/NOTIFY` 같은 데이터베이스 알림 방식을 사용하는 것도 이상적인 해결책이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 상황에서 Pub/Sub은 꽤 유용하다. Pub/Sub 시스템은 웹소켓 인프라와 팬아운을 대신 처리해주기 때문이다.&lt;/p&gt;</description>
      <category>  Study/TLDR</category>
      <category>event sourcing</category>
      <category>poke/pull</category>
      <category>push ops</category>
      <category>push state</category>
      <category>tldr</category>
      <category>trasnport</category>
      <category>web dev</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/146</guid>
      <comments>https://doteloper.tistory.com/146#entry146comment</comments>
      <pubDate>Wed, 12 Feb 2025 21:53:43 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot/Kotlin] SSE 활용한 AI Streaming Chat  구현 (w. React) - (2) FE</title>
      <link>https://doteloper.tistory.com/145</link>
      <description>&lt;h1&gt;&lt;b&gt;SSE 활용한 AI Streaming Chat 구현 (w. React) - 2탄: 웹 챗 구현&lt;/b&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;➡️ 1탄: 서버 구현&lt;/p&gt;
&lt;figure id=&quot;og_1739360511521&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;[SpringBoot/Kotlin] SSE 활용한 AI Streaming Chat 구현 (w. React)&quot; data-og-description=&quot;SSE 활용한 AI Streaming Chat 구현 (w. React) - 1탄: 서버 구현미리보기전체 코드는 아래에!Spring Boot와 SSE를 활용하여 AI Streaming Chat 서비스를 구현하였다.사실 채팅 스트리밍을 구현하기 위한 다양한 언&quot; data-og-host=&quot;doteloper.tistory.com&quot; data-og-source-url=&quot;https://doteloper.tistory.com/144#SSE%20%ED%99%9C%EC%9A%A9%ED%95%9C%20AI%20Streaming%20Chat%20%EA%B5%AC%ED%98%84%20(w.%20React)%20-%201%ED%83%84%3A%20%EC%84%9C%EB%B2%84%20%EA%B5%AC%ED%98%84-1&quot; data-og-url=&quot;https://doteloper.tistory.com/144&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/by6K5l/hyYcleuR67/nPa3YCzAjADLGfrIIRdOuK/img.gif?width=400&amp;amp;height=260&amp;amp;face=0_0_400_260,https://scrap.kakaocdn.net/dn/bazxcp/hyYahpHBRc/0R7rGxrn5tEpklvoKN2leK/img.gif?width=400&amp;amp;height=260&amp;amp;face=0_0_400_260,https://scrap.kakaocdn.net/dn/cWnE8A/hyYae7ADPK/fQDJztY07E634El6aAuFKK/img.png?width=2542&amp;amp;height=2430&amp;amp;face=0_0_2542_2430&quot;&gt;&lt;a href=&quot;https://doteloper.tistory.com/144#SSE%20%ED%99%9C%EC%9A%A9%ED%95%9C%20AI%20Streaming%20Chat%20%EA%B5%AC%ED%98%84%20(w.%20React)%20-%201%ED%83%84%3A%20%EC%84%9C%EB%B2%84%20%EA%B5%AC%ED%98%84-1&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://doteloper.tistory.com/144#SSE%20%ED%99%9C%EC%9A%A9%ED%95%9C%20AI%20Streaming%20Chat%20%EA%B5%AC%ED%98%84%20(w.%20React)%20-%201%ED%83%84%3A%20%EC%84%9C%EB%B2%84%20%EA%B5%AC%ED%98%84-1&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/by6K5l/hyYcleuR67/nPa3YCzAjADLGfrIIRdOuK/img.gif?width=400&amp;amp;height=260&amp;amp;face=0_0_400_260,https://scrap.kakaocdn.net/dn/bazxcp/hyYahpHBRc/0R7rGxrn5tEpklvoKN2leK/img.gif?width=400&amp;amp;height=260&amp;amp;face=0_0_400_260,https://scrap.kakaocdn.net/dn/cWnE8A/hyYae7ADPK/fQDJztY07E634El6aAuFKK/img.png?width=2542&amp;amp;height=2430&amp;amp;face=0_0_2542_2430');&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;[SpringBoot/Kotlin] SSE 활용한 AI Streaming Chat 구현 (w. React)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SSE 활용한 AI Streaming Chat 구현 (w. React) - 1탄: 서버 구현미리보기전체 코드는 아래에!Spring Boot와 SSE를 활용하여 AI Streaming Chat 서비스를 구현하였다.사실 채팅 스트리밍을 구현하기 위한 다양한 언&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;doteloper.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 기록 2025-02-11 오후 9.52.51.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;260&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blresq/btsMhfWVtg4/XwVTk3LWkTL7PnlmDLeYXk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blresq/btsMhfWVtg4/XwVTk3LWkTL7PnlmDLeYXk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blresq/btsMhfWVtg4/XwVTk3LWkTL7PnlmDLeYXk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/blresq/btsMhfWVtg4/XwVTk3LWkTL7PnlmDLeYXk/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;400&quot; height=&quot;260&quot; data-filename=&quot;화면 기록 2025-02-11 오후 9.52.51.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;260&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;미리보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅은 저번 서버 구현을 받는 웹 채팅에 관련된 코드이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 &lt;b&gt;이번 프로젝트의 프론트 담당은 &amp;ldquo;GPT&amp;rdquo;&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 설명의 한계가 있으므로, 중요한 실시간 응답 처리만 간단히 설명하고 마치도록 하겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(‼️‼️‼️‼️‼️&amp;nbsp;아래 모든 프론트 코드는 chat gpt로만 만든 코드로 실제 FE 기술과 멀리 떨어져있을 수 있습니다 ‼️‼️‼️‼️‼️)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 스택&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;React (Vite)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;tailwindcss&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SSE를 이용한 실시간 응답 처리&lt;/h3&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;const sendMessage = () =&amp;gt; {
  if (!input.trim() || isComposing) return;

  setMessages((prev) =&amp;gt; [...prev, { role: &quot;user&quot;, text: input }]);
  setInput(&quot;&quot;);
  setLoading(true);

  const eventSource = new EventSource(`${API_URL}?query=${encodeURIComponent(input)}`);
  let assistantText = &quot;&quot;;

  eventSource.onmessage = (event) =&amp;gt; {
    assistantText += event.data.replace(/^&quot;|&quot;$/g, &quot;&quot;);
    setMessages((prev) =&amp;gt; {
      if (prev.length === 0 || prev[prev.length - 1].role !== &quot;assistant&quot;) {
        return [...prev, { role: &quot;assistant&quot;, text: assistantText }];
      } else {
        return prev.map((msg, index) =&amp;gt; index === prev.length - 1 ? { ...msg, text: assistantText } : msg);
      }
    });
  };

  eventSource.onerror = () =&amp;gt; {
    eventSource.close();
    setLoading(false);
  };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`EventSource`를 사용하여 백엔드에서 오는 스트리밍 데이터를 수신&lt;/li&gt;
&lt;li&gt;`onmessage` 이벤트에서 수신한 데이터를 기존 텍스트에 추가하면서, UI를 실시간으로 업데이트&lt;/li&gt;
&lt;li&gt;한 글자씩 추가되므로 자연스러운 스트리밍 효과가 구현&lt;/li&gt;
&lt;li&gt;`onerror` 이벤트에서 스트림을 종료하고 로딩 상태를 해제&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/openai-chat/tree/master/my-sse-chat&quot;&gt;https://github.com/jeongum/openai-chat/tree/master/my-sse-chat&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1739360590985&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;openai-chat/my-sse-chat at master &amp;middot; jeongum/openai-chat&quot; data-og-description=&quot;Contribute to jeongum/openai-chat development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/jeongum/openai-chat/tree/master/my-sse-chat&quot; data-og-url=&quot;https://github.com/jeongum/openai-chat/tree/master/my-sse-chat&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/br6Po4/hyYcefojyZ/SPF58cZ5qxiI3myFoWdNvK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/wXCNa/hyYcke82hY/ovUAaCprRZ1jUjUPx7UFdK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/openai-chat/tree/master/my-sse-chat&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/jeongum/openai-chat/tree/master/my-sse-chat&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/br6Po4/hyYcefojyZ/SPF58cZ5qxiI3myFoWdNvK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/wXCNa/hyYcke82hY/ovUAaCprRZ1jUjUPx7UFdK/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;openai-chat/my-sse-chat at master &amp;middot; jeongum/openai-chat&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to jeongum/openai-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;</description>
      <category>  개발 일지/SpringBoot</category>
      <category>chat</category>
      <category>eventsource</category>
      <category>Kotlin</category>
      <category>open ai api</category>
      <category>react</category>
      <category>spring boot</category>
      <category>SSE</category>
      <category>Stream</category>
      <category>스트리밍</category>
      <category>채팅앱</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/145</guid>
      <comments>https://doteloper.tistory.com/145#entry145comment</comments>
      <pubDate>Wed, 12 Feb 2025 20:43:59 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot/Kotlin] SSE 활용한 AI Streaming Chat 구현 (w. React) - (1) 서버</title>
      <link>https://doteloper.tistory.com/144</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;SSE 활용한 AI Streaming Chat 구현 (w. React) - 1탄: 서버 구현&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;미리보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드는 아래에!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;260&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cP91mn/btsMdMbbgSX/1vJlA3SJ554r1q2uwrYgqK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cP91mn/btsMdMbbgSX/1vJlA3SJ554r1q2uwrYgqK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cP91mn/btsMdMbbgSX/1vJlA3SJ554r1q2uwrYgqK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cP91mn/btsMdMbbgSX/1vJlA3SJ554r1q2uwrYgqK/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;400&quot; height=&quot;260&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;260&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot와 SSE를 활용하여 AI Streaming Chat 서비스를 구현하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 채팅 스트리밍을 구현하기 위한 다양한 언어, 기술이 많지만 현업에서 사용하는 언어와 SSE를 경험하고 싶어 해당 기술스택을 활용하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(즉, 스트리밍 채팅을 구현하기 위한 기술 스택은 다양하므로 상황과 조건에 맞게 사용하길 바란다.)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SSE(Server-Sent-Event)란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 서버로부터 &lt;b&gt;단방향 스트리밍&lt;/b&gt; 데이터를 받을 수 있도록 지원하는 기술이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;단방향 통신&lt;/b&gt;: 서버에서 클라이언트로만 데이터를 전송할 수 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HTTP 기반&lt;/b&gt;: 기존 HTTP 프로토콜을 사용하기 때문에 방화벽 및 프록시 환경에서 사용하기 용이함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동 재연결&lt;/b&gt;: 클라이언트에서 SSE 연결이 끊어졌을 경우 자동으로 재연결하는 기능 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;텍스트 기반 전송&lt;/b&gt;: JSON, XML 등의 데이터를 텍스트로 전송 가능&lt;/li&gt;
&lt;li&gt;손쉬운 구현: 다양한 API로 제공되어 간단하게 사용할 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Boot SseEmitter&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot에서는 SseEmitter 클래스를 제공하여 SSE를 쉽게 구현할 수 있다. (&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.html&quot;&gt;docs&lt;/a&gt;)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 메소드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드 설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;send(Object data)&lt;/td&gt;
&lt;td&gt;클라이언트에게 데이터를 전송&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;complete()&lt;/td&gt;
&lt;td&gt;SSE 스트림을 정상적으로 종료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completeWithError(Throwable t)&lt;/td&gt;
&lt;td&gt;오류 발생 시 스트림 종료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;onCompletion(Runnable callback)&lt;/td&gt;
&lt;td&gt;스트림 완료 시 실행할 콜백 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;onTimeout(Runnable callback)&lt;/td&gt;
&lt;td&gt;스트림이 타임아웃될 경우 실행할 콜백 지정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AI Streaming Chat with SseEmitter&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OpenAiClient.kt&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;OpenAI API Request&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI 의 ChatCompletion API 를 위한 Request를 생성한다. (&lt;a href=&quot;https://platform.openai.com/docs/api-reference/chat&quot;&gt;openai docs&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로젝트는 정교한 설정이 필요하지 않아, 필수 값만 채워넣을 수 있는 DTO로 구현하였다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;val request = ChatCompletionRequest.build(message)

// Request DTO
data class ChatCompletionRequest(
    val model: String = &quot;gpt-3.5-turbo&quot;,
    val messages: List&amp;lt;ChatCompletionMessageRequest&amp;gt;,
    val stream: Boolean = true, // --- (1)
) {
    data class ChatCompletionMessageRequest(
        val role: String = &quot;user&quot;,
        val content: String,
    )

    companion object {
        fun build(message: String): ChatCompletionRequest = ChatCompletionRequest(
            messages = listOf(
                ChatCompletionMessageRequest(
                    content = message
                )
            )
        )
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;응답을 Streaming으로 받아야하기 때문에, 해당 값은 true로 설정하자. 만약, 한번에 응답을 받고 싶다면 필드 값을 설정하지 않거나 false로 설정하면된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;WebClient&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선, OpenAI에서 ChatCompletion API에서 어떻게 응답이 오는지 확인해보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2542&quot; data-origin-height=&quot;2040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlMHb6/btsMeMOSZCU/4lKXCFkRPYToBb9Emj3yhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlMHb6/btsMeMOSZCU/4lKXCFkRPYToBb9Emj3yhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlMHb6/btsMeMOSZCU/4lKXCFkRPYToBb9Emj3yhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdlMHb6%2FbtsMeMOSZCU%2F4lKXCFkRPYToBb9Emj3yhK%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;600&quot; height=&quot;482&quot; data-origin-width=&quot;2542&quot; data-origin-height=&quot;2040&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해, &lt;b&gt;응답 파싱에 주의를 기울여야 한다는 것&lt;/b&gt;을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 아래 WebClient 코드를 보자.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;fun chatCompletion(message: String): SseEmitter {
    val request = ChatCompletionRequest.build(message)

    return SseEmitter().also { emitter -&amp;gt;
        WebClient.create()
            .post()
            .uri(&quot;&amp;lt;https://api.openai.com/v1/chat/completions&amp;gt;&quot;)
            .contentType(MediaType.APPLICATION_JSON)
            .header(&quot;Authorization&quot;, &quot;Bearer $token&quot;)
            .body(BodyInserters.fromValue(request))
            .exchangeToFlux { response -&amp;gt; response.bodyToFlux() } // --- (1)
            .doOnNext { data -&amp;gt; // --- (2)
                if (data.equals(&quot;[DONE]&quot;)) {
                    emitter.complete()
                } else {
                    objectMapper.readValue(data, ChatCompletionResponse::class.java).getContent()
                        ?.let { emitter.send(&quot;\\&quot;$it\\&quot;&quot;) }
                }
            }
            .doOnComplete(emitter::complete)
            .doOnError(emitter::completeWithError)
            .subscribe()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`exchangeToFlux`: 서버로부터 받은 응답을 Flux&amp;lt;String&amp;gt;으로 변환
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`Flux`: 0개 이상의 데이터를 비동기적으로 스트리밍할 수 있는 Reactor의 라이브러리&lt;/li&gt;
&lt;li&gt;`bodyToFlux&amp;lt;String&amp;gt;`() : 연속된 응답 Chunk를 한 줄씩 `Flux`로 변환하여 비동기적으로 데이터를 스트리밍 할 수 있도록 함&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;`doOnNext`: String으로 선변환 된 데이터로 알맞은 처리를 진행
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`&quot;[DONE]&quot;` : 위 실제 open ai 응답에서 보았듯이 위 데이터가 반환된다면 스트리밍 종료를 의미함
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`emitter.complete()`: SSE 스트림을 정상적으로 종료시킴&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;그 외: json 형식을 ChatCompletionResponse로 파싱함
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;`emitter.send(it)` : 변환된 Response 내 content를 가져와 SSE 스트림으로 전송&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ChatController.kt&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트로 SseEmitter로 전송하는 코드를 작성하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chat 응답은 일정한 Json 포맷으로 내려오지만, 응답이 종료되었을 경우 &amp;ldquo;[DONE]&amp;rdquo; 텍스트로 내려오는 것을 볼 수 있다. (docs에도 명시되어있음)&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun chat(@RequestParam(&quot;query&quot;) message: String): SseEmitter {
    return openAiClient.chatCompletion(message)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`MediaType.TEXT_EVENT_STREAM_VALUE`
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해당 MediaType을 사용하면, HTTP응답의 Content-Type이 text/event-stream으로 설정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이는 SSE 방식으로 데이터를 스트리밍할 것을 나타냄&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;`SseEmitter` : 서버에서 이를 반환하면, 클라이언트에서는 `EventSource API`를 사용하여 응답 수신&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 API로 요청을 보내면, SSE 응답으로 OpenAI로 받은 응답 한글자씩을 Client로 내려주는 것을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2542&quot; data-origin-height=&quot;2430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZZwOk/btsMeb9ybwg/IkGrUmiSNBugg9OrvlDEJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZZwOk/btsMeb9ybwg/IkGrUmiSNBugg9OrvlDEJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZZwOk/btsMeb9ybwg/IkGrUmiSNBugg9OrvlDEJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZZwOk%2FbtsMeb9ybwg%2FIkGrUmiSNBugg9OrvlDEJ1%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;600&quot; height=&quot;574&quot; data-origin-width=&quot;2542&quot; data-origin-height=&quot;2430&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이 EventSource를 받아 Chat 화면을 그리는 React 프로젝트에 대해 작성하겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(사실 프론트 코드는 다 GPT가 작성해줬다  )&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전체 코드&lt;/h3&gt;
&lt;figure id=&quot;og_1739279301240&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;openai-chat/openai at master &amp;middot; jeongum/openai-chat&quot; data-og-description=&quot;Contribute to jeongum/openai-chat development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/jeongum/openai-chat/tree/master/openai&quot; data-og-url=&quot;https://github.com/jeongum/openai-chat/tree/master/openai&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eBSVZa/hyYch33V2b/tq21suhZHKIwL8K3js3aq0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/dPW5Vc/hyYfXW9u5y/tKWGd6xsdzEkYcWWmkHBl0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/jeongum/openai-chat/tree/master/openai&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/jeongum/openai-chat/tree/master/openai&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eBSVZa/hyYch33V2b/tq21suhZHKIwL8K3js3aq0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/dPW5Vc/hyYfXW9u5y/tKWGd6xsdzEkYcWWmkHBl0/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;openai-chat/openai at master &amp;middot; jeongum/openai-chat&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to jeongum/openai-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;</description>
      <category>  개발 일지/SpringBoot</category>
      <category>chat</category>
      <category>Kotlin</category>
      <category>openai</category>
      <category>spring boot</category>
      <category>SSE</category>
      <category>SSEemitter</category>
      <category>streaming</category>
      <author>점이</author>
      <guid isPermaLink="true">https://doteloper.tistory.com/144</guid>
      <comments>https://doteloper.tistory.com/144#entry144comment</comments>
      <pubDate>Tue, 11 Feb 2025 22:11:40 +0900</pubDate>
    </item>
  </channel>
</rss>