<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>코드로 말해요</title>
    <link>https://saysimple.tistory.com/</link>
    <description>코드만 봐도 다 알 수 있다.</description>
    <language>ko</language>
    <pubDate>Sat, 8 Aug 2026 12:01:14 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Say simple</managingEditor>
    <image>
      <title>코드로 말해요</title>
      <url>https://tistory1.daumcdn.net/tistory/3874021/attach/13f9b19694f04f0ba8f72beb8977ab76</url>
      <link>https://saysimple.tistory.com</link>
    </image>
    <item>
      <title>Git 단축어로 사용하기</title>
      <link>https://saysimple.tistory.com/207</link>
      <description>&lt;h1 data-path-to-node=&quot;2&quot;&gt;Git Shortcut 설정 문서&lt;/h1&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;Git을 더 빠르고 효율적으로 사용하기 위한 Alias(단축키) 설정 가이드입니다.&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size26&quot;&gt;설정 원칙&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;5&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,0,0&quot;&gt;안전한 명령어만 alias&lt;/b&gt;: 위험한 명령어(reset --hard, rm, clean 등)는 실수 방지를 위해 풀네임 사용&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,1,0&quot;&gt;자주 쓰는 것만&lt;/b&gt;: 하루에 여러 번 사용하는 명령어에 집중&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,2,0&quot;&gt;짧고 직관적&lt;/b&gt;: 1-2글자로 기억하기 쉽게&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size26&quot;&gt;설치 명령어&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;터미널에 아래 내용을 복사해서 붙여넣으면 한 번에 설정됩니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;한 번에 설치하기&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 읽기 전용 (100% 안전)
git config --global alias.s status
git config --global alias.l log
git config --global alias.d diff
git config --global alias.sh show
git config --global alias.b branch
git config --global alias.f fetch

# 안전한 쓰기 명령어
git config --global alias.c checkout
git config --global alias.sw switch
git config --global alias.a add
git config --global alias.cm commit
git config --global alias.p push
git config --global alias.pl pull
git config --global alias.t tag
git config --global alias.st stash

# 유용한 복합 alias
git config --global alias.lg &quot;log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)&amp;lt;%an&amp;gt;%Creset' --abbrev-commit&quot;
git config --global alias.unstage 'reset HEAD --'
git config --global alias.last 'log -1 HEAD'
git config --global alias.amend 'commit --amend'
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;11&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size26&quot;&gt;Alias 목록&lt;/h2&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;필요한 것만 개별적으로 설정하고 싶다면 아래 목록을 참고하세요.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;1. 읽기 전용 명령어 (안전)&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;15&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Alias&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;원본 명령어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,1,0,0&quot;&gt;g s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,1,1,0&quot;&gt;git status&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,1,2,0&quot;&gt;작업 상태 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,2,0,0&quot;&gt;g l&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,2,1,0&quot;&gt;git log&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,2,2,0&quot;&gt;커밋 히스토리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,3,0,0&quot;&gt;g d&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,3,1,0&quot;&gt;git diff&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,3,2,0&quot;&gt;변경사항 비교&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,4,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,4,0,0&quot;&gt;g sh&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,4,1,0&quot;&gt;git show&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,4,2,0&quot;&gt;커밋 상세보기&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,5,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,5,0,0&quot;&gt;g b&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,5,1,0&quot;&gt;git branch&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,5,2,0&quot;&gt;브랜치 목록&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,6,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,6,0,0&quot;&gt;g f&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,6,1,0&quot;&gt;git fetch&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;15,6,2,0&quot;&gt;원격 변경사항 가져오기&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;2. 안전한 쓰기 명령어&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;17&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Alias&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;원본 명령어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,1,0,0&quot;&gt;g c&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,1,1,0&quot;&gt;git checkout&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,1,2,0&quot;&gt;브랜치 전환&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,2,0,0&quot;&gt;g sw&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,2,1,0&quot;&gt;git switch&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,2,2,0&quot;&gt;브랜치 전환 (최신)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,3,0,0&quot;&gt;g a&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,3,1,0&quot;&gt;git add&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,3,2,0&quot;&gt;파일 스테이징&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,4,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,4,0,0&quot;&gt;g cm&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,4,1,0&quot;&gt;git commit&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,4,2,0&quot;&gt;커밋 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,5,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,5,0,0&quot;&gt;g p&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,5,1,0&quot;&gt;git push&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,5,2,0&quot;&gt;원격에 푸시&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,6,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,6,0,0&quot;&gt;g pl&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,6,1,0&quot;&gt;git pull&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,6,2,0&quot;&gt;원격에서 풀&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,7,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,7,0,0&quot;&gt;g t&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,7,1,0&quot;&gt;git tag&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,7,2,0&quot;&gt;태그 관리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,8,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,8,0,0&quot;&gt;g st&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,8,1,0&quot;&gt;git stash&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;17,8,2,0&quot;&gt;임시 저장&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;3. 복합 명령어 (꿀팁)&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;19&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Alias&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;원본 명령어&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0,0&quot;&gt;g lg&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,1,1,0&quot;&gt;git log --graph...&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,1,2,0&quot;&gt;그래프가 포함된 예쁜 로그&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0,0&quot;&gt;g unstage&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,2,1,0&quot;&gt;git reset HEAD --&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,2,2,0&quot;&gt;add 취소 (스테이징 내리기)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,3,0,0&quot;&gt;g last&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,3,1,0&quot;&gt;git log -1 HEAD&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,3,2,0&quot;&gt;바로 직전 커밋 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,4,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,4,0,0&quot;&gt;g amend&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,4,1,0&quot;&gt;git commit --amend&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;19,4,2,0&quot;&gt;마지막 커밋 수정&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;사용 예시&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;일상적인 워크플로우&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;g s                           # 상태 확인
g d                           # 변경사항 확인
g a src/                      # 파일 추가
g cm -m &quot;feat: add feature&quot;   # 커밋
g p origin feature-branch     # 푸시
g l                           # 로그 확인
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;브랜치 작업&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;g b                           # 브랜치 목록
g c main                      # main으로 전환
g c -b feature/new-feature    # 새 브랜치 생성 및 전환
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;원격 동기화&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;g f origin                    # fetch
g pl origin main              # pull
g p origin feature-branch     # push
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;28&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;❌ Alias 하지 않는 위험 명령어&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;다음 명령어들은 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;30&quot;&gt;절대 alias하지 않습니다.&lt;/b&gt; 항상 풀네임을 타이핑하며 한 번 더 생각하고 사용해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;git reset --hard : 작업 내용 영구 삭제&lt;/li&gt;
&lt;li&gt;git clean -fd : 추적되지 않은 파일 삭제&lt;/li&gt;
&lt;li&gt;git rm : 파일 삭제&lt;/li&gt;
&lt;li&gt;git branch -D : 브랜치 강제 삭제&lt;/li&gt;
&lt;li&gt;git push --force : 원격 히스토리 덮어쓰기&lt;/li&gt;
&lt;li&gt;git push --force-with-lease : 안전한 강제 푸시&lt;/li&gt;
&lt;li&gt;git rebase : 히스토리 재작성&lt;/li&gt;
&lt;li&gt;git cherry-pick : 커밋 선택 적용&lt;/li&gt;
&lt;li&gt;git revert : 커밋 되돌리기&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-path-to-node=&quot;32&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;32,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0&quot;&gt;이유:&lt;/b&gt; 긴 타이핑 시간 = 생각할 시간 (실수 방지 및 명령어의 의미 명확 인지)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-path-to-node=&quot;33&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;설정 확인 및 관리&lt;/h2&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;35&quot;&gt;현재 설정된 alias 보기&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;git config --global --get-regexp alias
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;37&quot;&gt;특정 alias 삭제&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;git config --global --unset alias.s
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;39&quot;&gt;모든 alias 삭제&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;git config --global --remove-section alias
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;41&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;42&quot; data-ke-size=&quot;size26&quot;&gt;전제 조건&lt;/h2&gt;
&lt;p data-path-to-node=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;이 문서는 git 명령어를 g로 alias한 환경을 가정합니다. 쉘 설정 파일(~/.bashrc 또는 ~/.zshrc)에 아래 내용을 추가해주세요.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;alias g='git'
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;45&quot; data-ke-size=&quot;size16&quot;&gt;설정 없이 사용하려면 g 대신 git으로 사용하면 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;46&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;git s &amp;rarr; git status&lt;/li&gt;
&lt;li&gt;git cm -m &quot;message&quot; &amp;rarr; git commit -m &quot;message&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;47&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;48&quot; data-ke-size=&quot;size26&quot;&gt;팀 협업 가이드&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;49&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;49,0,0&quot;&gt;개인 환경 설정&lt;/b&gt;: 이 alias는 개인 생산성 도구입니다. 프로젝트 설정(gitignore 등)에 포함하지 마세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;49,1,0&quot;&gt;문서/코드리뷰&lt;/b&gt;: 팀원과 소통할 때는 표준 git 명령어(git status, git commit 등)를 사용하세요.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;49,2,0&quot;&gt;위험한 명령어&lt;/b&gt;: 위험한 작업(reset --hard 등)을 가이드할 때는 항상 풀네임으로 알려주세요.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-path-to-node=&quot;50&quot; data-ke-size=&quot;size26&quot;&gt;유지보수&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;51&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정기적으로 사용 빈도 검토&lt;/li&gt;
&lt;li&gt;잘 안 쓰는 alias는 과감히 제거&lt;/li&gt;
&lt;li&gt;새로운 반복 패턴 발견 시 추가&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>GIT/GIT 심화</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/207</guid>
      <comments>https://saysimple.tistory.com/207#entry207comment</comments>
      <pubDate>Mon, 2 Feb 2026 17:09:43 +0900</pubDate>
    </item>
    <item>
      <title>[VibeCoding] 바이브 코딩으로 LLM 챗봇 만들기 -1-</title>
      <link>https://saysimple.tistory.com/206</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;VibeCoding&quot;&lt;/h2&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;a href=&quot;https://en.wikipedia.org/wiki/Vibe_coding&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://en.wikipedia.org/wiki/Vibe_coding&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1744465361764&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;Vibe coding - Wikipedia&quot; data-og-description=&quot;From Wikipedia, the free encyclopedia Computer programming technique ChatGPT being asked to &amp;quot;Write a Javascript function to shuffle a deck of cards.&amp;quot; Vibe coding (also vibecoding) is an AI-dependent programming technique where a person describes a problem &quot; data-og-host=&quot;en.wikipedia.org&quot; data-og-source-url=&quot;https://en.wikipedia.org/wiki/Vibe_coding&quot; data-og-url=&quot;https://en.wikipedia.org/wiki/Vibe_coding&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/mVqR2/hyYCi2AsIY/ZF9j2IZVw2Pkg9xHE6Cx01/img.png?width=923&amp;amp;height=1870&amp;amp;face=0_0_923_1870,https://scrap.kakaocdn.net/dn/cxaoTA/hyYCdNJTpS/Y4LpC4rUsRNa9Eqt9yS34K/img.png?width=800&amp;amp;height=1621&amp;amp;face=0_0_800_1621,https://scrap.kakaocdn.net/dn/AnOYc/hyYCbPWldI/vWCXWyod3HRC63VoIDRla0/img.png?width=640&amp;amp;height=1297&amp;amp;face=0_0_640_1297&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Vibe_coding&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://en.wikipedia.org/wiki/Vibe_coding&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/mVqR2/hyYCi2AsIY/ZF9j2IZVw2Pkg9xHE6Cx01/img.png?width=923&amp;amp;height=1870&amp;amp;face=0_0_923_1870,https://scrap.kakaocdn.net/dn/cxaoTA/hyYCdNJTpS/Y4LpC4rUsRNa9Eqt9yS34K/img.png?width=800&amp;amp;height=1621&amp;amp;face=0_0_800_1621,https://scrap.kakaocdn.net/dn/AnOYc/hyYCbPWldI/vWCXWyod3HRC63VoIDRla0/img.png?width=640&amp;amp;height=1297&amp;amp;face=0_0_640_1297');&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;Vibe coding - Wikipedia&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;From Wikipedia, the free encyclopedia Computer programming technique ChatGPT being asked to &quot;Write a Javascript function to shuffle a deck of cards.&quot; Vibe coding (also vibecoding) is an AI-dependent programming technique where a person describes a problem&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;en.wikipedia.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;br /&gt;위키피디아 정의에 따르면영어: Vibe 코딩 (또는 vibecoding)은 AI 에 의존하는 프로그래밍 기술로, 사람이 코딩에 맞춰 조정된 대규모 언어 모델(LLM) 에 대한&amp;nbsp;프롬프트&amp;nbsp;로 몇 문장으로 문제를 설명합니다 . LLM은&amp;nbsp;소프트웨어를&amp;nbsp;생성하여 프로그래머의 역할을 수동 코딩에서 AI가 생성한&amp;nbsp;소스 코드를&amp;nbsp;안내, 테스트 및 개선하는 것으로 전환합니다 .&amp;nbsp;[&amp;nbsp;1&amp;nbsp;]&amp;nbsp;[&amp;nbsp;2&amp;nbsp;]&amp;nbsp;[&amp;nbsp;3&amp;nbsp;]&amp;nbsp;Vibe 코딩의 옹호자들은&amp;nbsp;아마추어 프로그래머 도&amp;nbsp;소프트웨어 엔지니어링&amp;nbsp;에 필요한 광범위한 교육과 기술 없이 소프트웨어를 제작할 수 있도록 한다고 주장합니다&amp;nbsp;.&amp;nbsp;[&amp;nbsp;4&amp;nbsp;]&amp;nbsp;이 용어는 2025년 2월&amp;nbsp;Andrej Karpathy&amp;nbsp;가 도입했으며&amp;nbsp;[&amp;nbsp;5&amp;nbsp;]&amp;nbsp;[&amp;nbsp;2&amp;nbsp;]&amp;nbsp;[&amp;nbsp;4&amp;nbsp;]&amp;nbsp;[&amp;nbsp;1&amp;nbsp;]&amp;nbsp;다음 달&amp;nbsp;Merriam-Webster 사전&amp;nbsp;에 &quot;속어 및 트렌드&quot; 명사로 등재되었습니다 .&amp;nbsp;[&amp;nbsp;6&amp;nbsp;]&lt;br /&gt;&lt;br /&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에 의존해서 자연어 기반으로 프로젝트를 진행하는 코딩 기법을 의미합니다. 저도 궁금해서 아래 위키를 보면서 공부해봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://wikidocs.net/book/17562&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wikidocs.net/book/17562&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1744465505679&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;바이브 코딩(Vibe Coding): FPS 게임 제작기&quot; data-og-description=&quot;### AI와 함께하는 게임 개발 여정 인공지능을 활용해 혼자서 게임을 만들 수 있을까? 이 책은 LLM 챗봇의 코드 생성 능력을 활용해 웹 기반 3D FPS 게임을 개발하&amp;hellip;&quot; data-og-host=&quot;wikidocs.net&quot; data-og-source-url=&quot;https://wikidocs.net/book/17562&quot; data-og-url=&quot;https://wikidocs.net/book/17562&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/chIOWy/hyYEHUOyKQ/9oNCCsfGz3eaC2KMl6mlQ1/img.png?width=307&amp;amp;height=400&amp;amp;face=0_0_307_400,https://scrap.kakaocdn.net/dn/beGTst/hyYClkIJkL/TqrTKjofHYar29l26kXojk/img.png?width=307&amp;amp;height=400&amp;amp;face=0_0_307_400,https://scrap.kakaocdn.net/dn/bvGBcq/hyYEzWI8UG/snPBzs3B1fqjHSfiXdaF5k/img.png?width=307&amp;amp;height=400&amp;amp;face=0_0_307_400&quot;&gt;&lt;a href=&quot;https://wikidocs.net/book/17562&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wikidocs.net/book/17562&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/chIOWy/hyYEHUOyKQ/9oNCCsfGz3eaC2KMl6mlQ1/img.png?width=307&amp;amp;height=400&amp;amp;face=0_0_307_400,https://scrap.kakaocdn.net/dn/beGTst/hyYClkIJkL/TqrTKjofHYar29l26kXojk/img.png?width=307&amp;amp;height=400&amp;amp;face=0_0_307_400,https://scrap.kakaocdn.net/dn/bvGBcq/hyYEzWI8UG/snPBzs3B1fqjHSfiXdaF5k/img.png?width=307&amp;amp;height=400&amp;amp;face=0_0_307_400');&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;바이브 코딩(Vibe Coding): FPS 게임 제작기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;### AI와 함께하는 게임 개발 여정 인공지능을 활용해 혼자서 게임을 만들 수 있을까? 이 책은 LLM 챗봇의 코드 생성 능력을 활용해 웹 기반 3D FPS 게임을 개발하&amp;hellip;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wikidocs.net&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;약간의 프롬프트만으로 게임의 골자가 잡히며 부족한 기능 혹은 버그는 AI에게 요청해 지속적으로 고쳐나갑니다. GPT3가 나왔던 시절만 해도 상상도 할 수 없었던 코딩 방식이었고 그저 AI에게 도움을 받는 정도였는데, 이젠 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;그렇다면 Vibe Coding은 어떻게 하는걸까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 Claude Sonnet 3.7 모델과 GitHub 연동 기능을 활용해 Vibe Coding을 해봤습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 만들기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Pro 요금제를 이미 사용하고 있다는 가정 하에 진행합니다. 먼저 프로젝트를 생성합니다. 저는 아래와 같이 바이브 챗봇 프로젝트를 생성했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3420&quot; data-origin-height=&quot;1954&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvWUvE/btsNjHZfai9/UHeXKSzHRW8WUWn2qOhcn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvWUvE/btsNjHZfai9/UHeXKSzHRW8WUWn2qOhcn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvWUvE/btsNjHZfai9/UHeXKSzHRW8WUWn2qOhcn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvWUvE%2FbtsNjHZfai9%2FUHeXKSzHRW8WUWn2qOhcn1%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;729&quot; height=&quot;417&quot; data-origin-width=&quot;3420&quot; data-origin-height=&quot;1954&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GitHub 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우측 위의 + 버튼을 누르고 GitHub 버튼을 누릅니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2818&quot; data-origin-height=&quot;1912&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4ANT6/btsNgAAxXV8/0BN8KJItAc3ybFCEm2fCi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4ANT6/btsNgAAxXV8/0BN8KJItAc3ybFCEm2fCi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4ANT6/btsNgAAxXV8/0BN8KJItAc3ybFCEm2fCi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4ANT6%2FbtsNgAAxXV8%2F0BN8KJItAc3ybFCEm2fCi1%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;722&quot; height=&quot;490&quot; data-origin-width=&quot;2818&quot; data-origin-height=&quot;1912&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2380&quot; data-origin-height=&quot;2068&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddhEVD/btsNiH7AUc1/65QLOuT8ulvSnRaLOVhKs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddhEVD/btsNiH7AUc1/65QLOuT8ulvSnRaLOVhKs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddhEVD/btsNiH7AUc1/65QLOuT8ulvSnRaLOVhKs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FddhEVD%2FbtsNiH7AUc1%2F65QLOuT8ulvSnRaLOVhKs1%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;730&quot; height=&quot;634&quot; data-origin-width=&quot;2380&quot; data-origin-height=&quot;2068&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1408&quot; data-origin-height=&quot;1176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmCChi/btsNiPRQlsG/PXBoMgYlMFtaCuYsKaqES1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmCChi/btsNiPRQlsG/PXBoMgYlMFtaCuYsKaqES1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmCChi/btsNiPRQlsG/PXBoMgYlMFtaCuYsKaqES1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmCChi%2FbtsNiPRQlsG%2FPXBoMgYlMFtaCuYsKaqES1%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;716&quot; height=&quot;598&quot; data-origin-width=&quot;1408&quot; data-origin-height=&quot;1176&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;바이브 코딩을 해보자&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;바이브 코딩의 서술&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이브 코딩은 주로 question(prompt)와 answer(LLM)로 진행됩니다. 예를 들면 아래와 같습니다. 저는 question과 answer 부분은 생략하겠습니다. AI의 답변을 모두 자세하게 들여볼 필요는 없습니다. 버그 수정도, 디버그도 모두 AI가 할거니까요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;question&lt;br /&gt;나는 토이프로젝트를 만들고 있어 간단하게 기획해줘&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;answer&lt;br /&gt;토이프로젝트 골자는 아래와 같습니다.&lt;br /&gt;|-main.py&lt;br /&gt;|--...&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;바이브 코딩 시작하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 프로젝트 골자를 잡습니다. 제가 사용할 라이브러리와 챗봇 플랫폼을 적어서 어떤 프로젝트를 진행할건지 알려줍니다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;나는 랭체인과 랭서브를 활용해서 챗봇을 만들고 싶어 챗봇은 카카오톡 챗봇과 연동할거야&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;안녕하세요! 랭체인(LangChain)과 랭서브(LangServe)를 활용하여 카카오톡과 연동되는 챗봇을 만드는 프로젝트를 도와드리겠습니다. 기본적인 구현 방법과 필요한 단계들을 안내해 드리겠습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;카카오톡 챗봇 + LangChain + LangServe 구현 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;먼저 필요한 기술 스택을 살펴보겠습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;LangChain: 대형 언어 모델(LLM)을 애플리케이션에 통합하기 위한 프레임워크 LangServe: LangChain 기반 애플리케이션을 API로 배포하기 위한 도구 카카오톡 챗봇 API: 카카오톡 플랫폼과 연동하기 위한 인터페이스 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1. 기본 환경 설정&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;Python 환경을 설정하고 필요한 패키지를 설치합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;pythonpip install langchain langserve fastapi uvicorn requests&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. LangChain으로 챗봇 로직 구현&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;pythonfrom langchain.chains import ConversationChain from langchain.memory import ConversationBufferMemory from langchain_openai import ChatOpenAI # LLM 설정 (OpenAI 예시) llm = ChatOpenAI( model_name=&quot;gpt-3.5-turbo&quot;, temperature=0.7, openai_api_key=&quot;your-api-key&quot; ) # 대화 메모리 설정 memory = ConversationBufferMemory(return_messages=True) # 대화 체인 생성 conversation = ConversationChain( llm=llm, memory=memory, verbose=True )&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3. LangServe로 API 배포&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;pythonfrom fastapi import FastAPI, Request from langserve import add_routes import uvicorn app = FastAPI(title=&quot;카카오톡 챗봇 API&quot;) # LangChain 체인을 API로 노출 add_routes( app, conversation, path=&quot;/chat&quot; ) # 카카오톡 스킬 엔드포인트 @app.post(&quot;/kakao/skill&quot;) async def kakao_skill(request: Request): data = await request.json() user_utterance = data[&quot;userRequest&quot;][&quot;utterance&quot;] # LangChain 체인으로 응답 생성 response = conversation.invoke({&quot;input&quot;: user_utterance}) # 카카오톡 응답 형식으로 변환 kakao_response = { &quot;version&quot;: &quot;2.0&quot;, &quot;template&quot;: { &quot;outputs&quot;: [ { &quot;simpleText&quot;: { &quot;text&quot;: response[&quot;response&quot;] } } ] } } return kakao_response if __name__ == &quot;__main__&quot;: uvicorn.run(app, host=&quot;0.0.0.0&quot;, port=8000)&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4. 카카오톡 챗봇 설정&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;카카오 i 오픈빌더에서 계정 생성 및 로그인 새 봇 생성 및 기본 설정 스킬 설정에서 웹훅 URL 연결 (예: https://your-server.com/kakao/skill) 배포 및 테스트 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;고려사항 및 발전 방향&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;서버 배포: AWS, GCP, Heroku 등을 통해 API 서버 배포 보안: HTTPS 프로토콜 적용, API 키 관리, 환경변수 사용 메모리 관리: 대화 이력 저장을 위한 데이터베이스 연동 (예: MongoDB, Redis) 다양한 응답 타입: 카카오톡의 다양한 응답 템플릿 활용 (이미지, 카드, 리스트 등) &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;더 세부적인 구현이나 특정 기능에 대해 알고 싶으시다면 말씀해 주세요!&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체적인 기술스택 개요를 먼저 알려줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸로만 코딩할 순 없으니 프로젝트를 기획해달라고 합니다. 저는 고객센터 상담 챗봇 설계 기획을 시켰습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;PO와 PM역할을 해야 돼 나는 고객센터 상담 챗봇을 만들거야 각 종 시나리오를 생각해서 챗봇 API를 설계해줘&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;고객센터 상담 챗봇 API 설계 및 시나리오&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;고객센터 상담 챗봇을 위한 API를 설계해 드리겠습니다. PO와 PM 관점에서 필요한 시나리오와 기능을 포함했습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1. 핵심 시나리오&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1.1 기본 상담 시나리오&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;초기 인사: 사용자 접속 시 첫 인사와 도울 수 있는 카테고리 안내 FAQ 응답: 자주 묻는 질문에 대한 즉각적인 응답 상담사 연결: 복잡한 문의 시 인간 상담사로 전환 문의 접수: 추가 조사가 필요한 문의 티켓 생성 상담 종료: 문제 해결 확인 및 추가 문의 여부 확인 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1.2 제품/서비스별 시나리오&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;주문/배송 관련: 주문 상태 확인, 배송 추적, 반품/교환 절차 결제 관련: 결제 내역 확인, 환불 처리, 결제 방법 변경 계정 관리: 비밀번호 변경, 개인정보 수정, 회원탈퇴 기술 지원: 제품 사용법, 오류 해결, 기능 안내 불만 처리: 불만 접수, 피드백 처리 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1.3 특수 시나리오&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;긴급 상황 대응: 보안 침해, 서비스 장애 등 긴급 상황 처리 VIP 고객 대응: 우선 처리 및 특별 서비스 제공 다국어 지원: 주요 언어별 대응 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. API 설계&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2.1 기본 엔드포인트&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;POST /api/v1/chat&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;요청 본문 (Request Body):&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;json{ &quot;session_id&quot;: &quot;unique-session-id&quot;, &quot;user_id&quot;: &quot;user123&quot;, &quot;message&quot;: &quot;배송이 언제 도착하나요?&quot;, &quot;context&quot;: { &quot;platform&quot;: &quot;kakao&quot;, &quot;previous_intents&quot;: [&quot;greeting&quot;, &quot;order_inquiry&quot;], &quot;user_info&quot;: { &quot;name&quot;: &quot;홍길동&quot;, &quot;membership_level&quot;: &quot;standard&quot;, &quot;language&quot;: &quot;ko&quot; } } }&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;응답 (Response):&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;json{ &quot;message_id&quot;: &quot;msg123456&quot;, &quot;response_type&quot;: &quot;text&quot;, &quot;content&quot;: &quot;안녕하세요 홍길동님, 주문하신 상품의 배송 상태를 확인해드리겠습니다. 주문번호를 알려주시겠어요?&quot;, &quot;intents&quot;: [&quot;order_tracking&quot;], &quot;confidence&quot;: 0.92, &quot;suggested_replies&quot;: [ &quot;최근 주문 조회하기&quot;, &quot;다른 문의하기&quot;, &quot;상담사 연결&quot; ], &quot;actions&quot;: [ { &quot;type&quot;: &quot;button&quot;, &quot;label&quot;: &quot;주문내역 확인&quot;, &quot;value&quot;: &quot;check_recent_orders&quot; } ], &quot;session_data&quot;: { &quot;conversation_state&quot;: &quot;awaiting_order_number&quot; } }&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2.2 추가 엔드포인트&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;대화 세션 관리&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;POST /api/v1/sessions/start GET /api/v1/sessions/{session_id} PUT /api/v1/sessions/{session_id}/end&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;상담사 연결&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;POST /api/v1/sessions/{session_id}/transfer&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;피드백 수집&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;POST /api/v1/sessions/{session_id}/feedback&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;사용자 인증&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;POST /api/v1/auth/verify&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;주문/제품 정보 조회&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;GET /api/v1/orders/{order_id} GET /api/v1/products/{product_id}/faq&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3. 주요 인텐트(Intent) 및 엔티티(Entity)&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3.1 상위 인텐트&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;greeting: 인사 order_inquiry: 주문 관련 문의 shipping_inquiry: 배송 관련 문의 payment_inquiry: 결제 관련 문의 product_inquiry: 제품 관련 문의 account_management: 계정 관련 문의 complaint: 불만 접수 technical_support: 기술 지원 human_agent: 상담사 연결 요청 farewell: 대화 종료 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3.2 주요 엔티티&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;order_number: 주문번호 product_name: 제품명 tracking_number: 운송장번호 date: 날짜 price: 가격 payment_method: 결제수단 issue_type: 문제 유형 user_emotion: 감정 상태 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4. 통합 시나리오 예시&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4.1 주문 조회 및 배송 문의&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;사용자: 제가 주문한 물건이 언제 도착하나요?&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;시스템:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;order_inquiry 및 shipping_inquiry 인텐트 식별 사용자 주문 내역 확인 필요 판단 주문번호 요청 또는 최근 주문 조회 버튼 제공 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;챗봇: &quot;최근 주문하신 내역을 확인해 드릴게요. 주문번호를 알고 계신가요? 아니면 최근 주문을 조회해 드릴까요?&quot;&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;사용자: 최근 주문 조회해주세요&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;시스템:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;사용자 API를 통해 최근 주문 조회 배송 추적 API 연동하여 배송 상태 확인 정보 포맷팅 및 응답 생성 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;챗봇: &quot;홍길동님의 최근 주문은 ABC123 상품으로, 현재 '배송중' 상태입니다. 예상 도착일은 4월 15일입니다. 배송 현황을 실시간으로 확인하시겠어요?&quot;&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4.2 결제 오류 및 환불 처리&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;사용자: 결제했는데 오류가 발생했어요. 카드는 결제됐는데 주문 내역이 없어요.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;시스템:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;payment_inquiry 인텐트와 issue_type: payment_error 엔티티 식별 문제 심각도 평가 결제 검증 프로세스 시작 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;챗봇: &quot;불편을 드려 죄송합니다. 정확한 확인을 위해 결제하신 카드 정보 마지막 4자리와 결제 일시를 알려주시겠어요?&quot;&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;사용자: 1234, 오늘 오후 2시요&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;시스템:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;결제 시스템 API 호출하여 거래 내역 확인 오류 유형 분류 및 해결책 제시 필요시 환불 프로세스 안내 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;챗봇: &quot;확인해보니 결제는 정상적으로 이루어졌으나 주문 등록 과정에서 오류가 발생했네요. 바로 환불 처리를 진행해드릴까요, 아니면 주문을 다시 등록해드릴까요?&quot;&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;5. 성능 지표(KPI) 모니터링 API&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;GET /api/v1/analytics/performance&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;모니터링할 주요 지표:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;문제 해결률 (Resolution Rate) 첫 응답 해결률 (First Contact Resolution) 상담사 전환율 (Human Handoff Rate) 평균 대화 시간 (Average Conversation Duration) 사용자 만족도 (CSAT) 인텐트 인식 정확도 (Intent Recognition Accuracy) &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;6. 확장 가능성&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;맞춤형 추천 엔진: 고객 문의 이력 기반 제품/서비스 추천 감정 분석: 고객 감정 상태에 따른 대응 조정 음성 인터페이스: 음성 인식 및 합성 통합 옴니채널 지원: 카카오톡 외 다양한 채널 통합 (웹, 모바일 앱, 전화 등) 실시간 대시보드: 관리자용 실시간 모니터링 도구 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 설계를 기반으로 개발팀과 협업하여 구체적인 구현 방향을 정하고, 사용자 피드백을 통해 지속적으로 개선해 나가는 것이 좋겠습니다. 추가적인 시나리오나 특정 영역에 대한 상세 설계가 필요하시면 말씀해 주세요.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 시나리오와 API 설계, 인텐트 또한 알려주는걸 볼 수 있습니다. 시나리오에 대한 커스터마이징은 나중에 필요하면 하도록 합시다.&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;conda에서 진행하기 위해 환경을 만들어달라 했습니다. 하지만 라이브러리를 보니 venv를 쓰면 충분해서 저는 venv를 사용했습니다. 아래엔 기록만 남깁니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;위 main.py를 실행하기 위한 conda 환경도 만들어줘&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;랭체인과 랭서브를 활용한 고객센터 상담 챗봇을 위한 Conda 환경을 만드는 방법을 알려드리겠습니다. 다음은 필요한 환경 설정 과정입니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;Conda 환경 설정 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1. Conda 환경 생성&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# Conda 환경 생성 (Python 3.9 기반) conda create -n kakao_chatbot_env python=3.9&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. 환경 활성화&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 환경 활성화 conda activate kakao_chatbot_env&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3. 필요한 패키지 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 기본 패키지 설치 conda install -c conda-forge pip pip install langchain langchain-openai langserve fastapi uvicorn pydantic requests python-dotenv # 추가 유용한 패키지 pip install pytest pytest-asyncio pip install pymongo redis # 필요시 데이터베이스 연결용&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4. 환경 설정 파일 생성 (environment.yml)&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;환경 설정을 공유하기 위한 YAML 파일을 생성할 수 있습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 현재 환경 설정 저장 conda env export &amp;gt; environment.yml&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;5. .env 파일 생성&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;API 키 등의 환경 변수를 관리하기 위한 .env 파일을 생성합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;# .env 파일 내용 OPENAI_API_KEY=your-api-key-here KAKAO_API_KEY=your-kakao-api-key&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;6. 프로젝트 구조 설정&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;프로젝트를 더 체계적으로 구성하고 싶다면 다음과 같은 구조를 추천합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;kakao_chatbot/ ├── .env # 환경 변수 ├── environment.yml # Conda 환경 설정 ├── main.py # 메인 애플리케이션 ├── config.py # 설정 파일 ├── requirements.txt # pip 의존성 목록 ├── README.md # 프로젝트 설명 ├── tests/ # 테스트 코드 │ ├── __init__.py │ └── test_api.py └── src/ # 소스 코드 ├── __init__.py ├── chains/ # LangChain 관련 코드 │ ├── __init__.py │ └── conversation.py ├── api/ # API 관련 코드 │ ├── __init__.py │ └── routes.py └── utils/ # 유틸리티 함수 ├── __init__.py └── helpers.py&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;7. 실행 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;환경 설정 후 다음과 같이 애플리케이션을 실행할 수 있습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 환경 활성화 conda activate kakao_chatbot_env # 애플리케이션 실행 python main.py&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;또는 uvicorn을 직접 사용:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashuvicorn main:app --reload --host 0.0.0.0 --port 8000&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;8. 배포 시 고려사항&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;프로덕션 배포: Gunicorn 등의 WSGI 서버와 함께 사용 bashpip install gunicorn gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app 컨테이너화: Docker를 사용하여 배포할 경우 bash# Dockerfile 생성 pip install docker-compose &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 설정으로 LangChain과 LangServe를 활용한 고객센터 상담 챗봇을 개발하기 위한 기본 환경이 준비됩니다. 필요에 따라 추가 패키지를 설치하거나 환경을 조정할 수 있습니다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;conda로 진행해도 문제는 없지만 인터프리터 설정이나 디펜던시 설치가 번거로운 경우가 있으므로 venv로 넘어갔습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;venv로 해도 되지? 파이썬 3.9.7로 만들어줘&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;네, 물론 venv로도 만들 수 있습니다! Python 3.9.7을 사용하는 venv 환경을 설정하는 방법을 알려드리겠습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;Python 3.9.7 venv 환경 설정 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1. Python 3.9.7 설치 확인&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;먼저 Python 3.9.7이 설치되어 있는지 확인합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpython --version # 또는 python3 --version&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;설치되어 있지 않다면 Python 공식 사이트나 pyenv 등을 통해 설치하세요.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. 가상환경 생성&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 프로젝트 디렉토리로 이동 mkdir kakao_chatbot cd kakao_chatbot # venv 환경 생성 python3.9 -m venv venv&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3. 가상환경 활성화&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;Windows: &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashvenv\Scripts\activate&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;macOS/Linux: &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashsource venv/bin/activate&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4. 필요한 패키지 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# pip 업그레이드 pip install --upgrade pip # 필요한 패키지 설치 pip install langchain langchain-openai langserve fastapi uvicorn pydantic requests python-dotenv&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;5. requirements.txt 생성&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip freeze &amp;gt; requirements.txt&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;6. .env 파일 생성&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# .env 파일 생성 touch .env # .env 파일에 필요한 환경 변수 추가 echo &quot;OPENAI_API_KEY=your-api-key-here&quot; &amp;gt;&amp;gt; .env echo &quot;KAKAO_API_KEY=your-kakao-api-key&quot; &amp;gt;&amp;gt; .env&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;7. 프로젝트 파일 구조 설정&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;위에서 제안한 것과 동일한 파일 구조를 생성합니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;8. 실행 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 가상환경 활성화 상태에서 python main.py # 또는 uvicorn으로 직접 실행 uvicorn main:app --reload --host 0.0.0.0 --port 8000&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;9. 가상환경 종료&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;작업이 끝난 후 가상환경을 종료하려면:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashdeactivate&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;venv는 Conda와 달리 Python 패키지만 관리하지만, 가볍고 Python 표준 라이브러리에 포함되어 있어 추가 설치 없이 사용할 수 있다는 장점이 있습니다. 개발 환경이나 프로젝트 요구사항에 따라 venv와 Conda 중 적합한 것을 선택하시면 됩니다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.gitignore도 알차게 만들어줍니다. 그리고 사전에 생성한 기획을 바탕으로 프로젝트 초안을 만듭니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;git에 환경변수가 올라가면 안되니까 .gitignore도 만들어줘 그리고 위 기획을 참고해서 프로젝트를 적절하게 만들어줘&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;고객센터 상담 챗봇 프로젝트를 위한 적절한 구조와 gitignore 파일을 만들어 드리겠습니다. .gitignore 파일 # Python __pycache__/ *.py[cod] *$py.class *.so .Python venv/ ENV/ env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # 환경 변수 파일 .env .env.* # IDE 설정 .idea/ .vscode/ *.swp *.swo .DS_Store # 로그 파일 *.log logs/ # 캐시 파일 .cache/ .pytest_cache/ # 데이터베이스 *.db *.sqlite3 # 기타 .coverage htmlcov/ 이제 전체 프로젝트 구조를 만들어 보겠습니다.고객센터 상담 챗봇 프로젝트 구조코드&amp;nbsp;# 고객센터 상담 챗봇 프로젝트 구조 ``` kakao_chatbot/ ├── .env # 환경 변수 파일 (gitignore에 포함됨) ├── .gitignore # 위에서 생성한 .gitignore 파일 ├── README.md # 프로젝트 설명 ├── requirements.txt # 의존성 목록 ├── main.py 이제 위 구조에 맞게 주요 파일들의 기본 코드를 작성해 드리겠습니다.main.py코드&amp;nbsp;&quot;&quot;&quot; 고객센터 상담 챗봇 애플리케이션의 메인 진입점 &quot;&quot;&quot; import os import logging from fastapi import FastAPI from dotenv import load_dotenv from app.api.router import router as api_router from app.utils.logger import setup_logger # 환경 변수 로드 load_dotenv() # 로거 설정 loggerconfig.py코드&amp;nbsp;&quot;&quot;&quot; 애플리케이션 설정 파일 &quot;&quot;&quot; import os from pydantic import BaseSettings from dotenv import load_dotenv # 환경 변수 로드 load_dotenv() class Settings(BaseSettings): &quot;&quot;&quot;애플리케이션 설정&quot;&quot;&quot; # 기본 설정 APP_NAME: str = &quot;고객센터 상담 챗봇&quot; DEBUG: bool = os.getenv(&quot;DEBapp/api/router.py코드&amp;nbsp;&quot;&quot;&quot; API 라우터 설정 &quot;&quot;&quot; from fastapi import APIRouter, Depends, HTTPException, status, Request import logging from langserve import add_routes from pydantic import BaseModel from typing import Dict, List, Optional, Any from app.chains.conversation importapp/api/models.py코드&amp;nbsp;&quot;&quot;&quot; API 요청 및 응답 모델 정의 &quot;&quot;&quot; from pydantic import BaseModel, Field from typing import Dict, List, Optional, Any from datetime import datetime class Action(BaseModel): &quot;&quot;&quot;UI 작업 액션&quot;&quot;&quot; type: str label: str value: Any class ChatRequest(Bapp/chains/conversation.py코드&amp;nbsp;&quot;&quot;&quot; 대화 처리를 위한 LangChain 체인 구현 &quot;&quot;&quot; import os from typing import Dict, Any, List from langchain.chains import ConversationChain, LLMChain from langchain.memory import ConversationBufferMemory from langchain_openai import ChatOpenAI from langchain.prompapp/intents/classifier.py코드&amp;nbsp;&quot;&quot;&quot; 사용자 의도(intent) 분류 모듈 &quot;&quot;&quot; import re from typing import Dict, Any, List, Tuple from langchain_openai import ChatOpenAI from langchain.chains import LLMChain from langchain.prompts import PromptTemplate from app.config import settings from app.util재시도Claude가 메시지 길이 제한에 도달하여 응답을 일시 중지했습니다. '계속하기'를 입력하시면 대화를 이어갈 수 있습니다.&lt;/b&gt;&lt;br /&gt;&lt;b&gt;S계속편집&lt;/b&gt;&lt;br /&gt;&lt;b&gt;app/intents/classifier.py코드&amp;ensp;∙&amp;ensp;버전 2&amp;nbsp;&quot;&quot;&quot; 사용자 의도(intent) 분류 모듈 &quot;&quot;&quot; import re from typing import Dict, Any, List, Tuple from langchain_openai import ChatOpenAI from langchain.chains import LLMChain from langchain.prompts import PromptTemplate from app.config import settings from app.utilapp/intents/entities.py코드&amp;nbsp;&quot;&quot;&quot; 엔티티(entity) 추출 모듈 &quot;&quot;&quot; import re from typing import Dict, Any, List, Optional from datetime import datetime, timedelta import json from app.utils.logger import get_logger from langchain_openai import ChatOpenAI from langchain.chains import LLMChaapp/services/user.py코드&amp;nbsp;&quot;&quot;&quot; 사용자 정보 관련 서비스 &quot;&quot;&quot; from typing import Dict, Any, Optional import json import os from datetime import datetime from app.utils.logger import get_logger logger = get_logger(__name__) # 임시 사용자 데이터 (실제로는 데이터베이스 사용) TEMP_USER_DATA = { &quot;user123&quot;: 재시도Claude가 메시지 길이 제한에 도달하여 응답을 일시 중지했습니다. '계속하기'를 입력하시면 대화를 이어갈 수 있습니다.&lt;/b&gt;&lt;br /&gt;&lt;b&gt;S계속편집&lt;/b&gt;&lt;br /&gt;&lt;b&gt;app/services/user.py코드&amp;ensp;∙&amp;ensp;버전 2 &quot;&quot;&quot; 사용자 정보 관련 서비스 &quot;&quot;&quot; from typing import Dict, Any, Optional, List import json import os from datetime import datetime from app.utils.logger import get_logger logger = get_logger(__name__) # 임시 사용자 데이터 (실제로는 데이터베이스 사용) TEMP_USER_DATA = { &quot;userapp/utils/logger.py코드 &quot;&quot;&quot; 로깅 유틸리티 모듈 &quot;&quot;&quot; import logging import os import sys from typing import Optional from datetime import datetime # 로그 레벨 설정 LOG_LEVEL = os.getenv(&quot;LOG_LEVEL&quot;, &quot;INFO&quot;).upper() LOG_FORMAT = &quot;%(asctime)s - %(name)s - %(levelname)s - %(message)s&quot; LOG_DIapp/services/analytics.py코드 &quot;&quot;&quot; 대화 분석 및 성능 지표 서비스 &quot;&quot;&quot; from typing import Dict, Any, List, Optional from datetime import datetime, timedelta import json import os from app.utils.logger import get_logger from app.api.models import ChatRequest, ChatResponse logger = get_logger(_run.py코드 &quot;&quot;&quot; 애플리케이션 실행 스크립트 &quot;&quot;&quot; import uvicorn import os from dotenv import load_dotenv # 환경 변수 로드 load_dotenv() if __name__ == &quot;__main__&quot;: # 환경 변수에서 포트 가져오기 (기본값: 8000) port = int(os.getenv(&quot;PORT&quot;, &quot;8000&quot;)) # 개발 환경 여부 debug = os.getenvREADME.md코드&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘텍스트가 부족해 응답이 중지되면 계속을 입력합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;계속&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;README.md코드&amp;ensp;∙&amp;ensp;버전 2&amp;nbsp;# 고객센터 상담 챗봇 LangChain과 LangServe를 활용한 카카오톡 고객센터 상담 챗봇 프로젝트입니다. ## 프로젝트 개요 이 프로젝트는 대형 언어 모델(LLM)을 활용하여 고객센터 문의에 자동으로 응답하는 챗봇 API를 구현합니다. 카카오톡 챗봇 플랫폼과 연동하여 사용자들에게 쉽게 접근 가능한 고객 지원 서비스를 제공합니다. ### 주요 기능 - 사용자 메시지 인텐트 분류 및 엔티티 추출 - 주문/배송/결제 관련 문의 처리&lt;/b&gt;&lt;br /&gt;&lt;b&gt;requirements.txt코드&amp;nbsp;fastapi==0.110.0 uvicorn==0.28.0 langchain==0.1.9 langchain-openai==0.0.7 langserve==0.0.42 pydantic==2.6.3 python-dotenv==1.0.1 requests==2.31.0 pytest==7.4.3 pytest-asyncio==0.21.1 pymongo==4.6.1 redis==5.0.1 &lt;/b&gt;&lt;br /&gt;&lt;b&gt;.env.example코드&amp;nbsp;# API 키 OPENAI_API_KEY=your-openai-api-key KAKAO_API_KEY=your-kakao-api-key # 애플리케이션 설정 DEBUG=False PORT=8000 HOST=0.0.0.0 LOG_LEVEL=INFO # LLM 설정 LLM_MODEL=gpt-3.5-turbo LLM_TEMPERATURE=0.7 # 데이터베이스 설정 DATABASE_URL=sqlite:///./chatbot.db # 보안 설정&lt;/b&gt;&lt;br /&gt;&lt;b&gt;docs/setup.md코드&amp;nbsp;# 고객센터 상담 챗봇 개발 가이드 이 문서는 고객센터 상담 챗봇 프로젝트의 설정 및 개발 가이드를 제공합니다. ## 1. 개발 환경 구성 ### Python 및 가상환경 설정 Python 3.9.7을 사용하여 개발합니다. 가상환경을 사용하여 의존성을 관리합니다. ```bash # Python 버전 확인 python --version # 3.9.7 # 가상환경 생성 python -m venv venv # 가상환경 활성화 # Win&lt;/b&gt;&lt;br /&gt;&lt;b&gt;개발에 필요한 기본 파일들을 모두 생성했습니다. 이제 고객센터 상담 챗봇 프로젝트의 핵심 구조와 파일이 준비되었습니다. 프로젝트의 주요 내용을 요약해 드리겠습니다:프로젝트 구성 요약기본 구조랭체인(LangChain)과 랭서브(LangServe)를 활용한 Python 3.9.7 기반 프로젝트venv 가상환경으로 의존성 관리FastAPI 기반 웹 서버 구현핵심 기능인텐트 분류: 규칙 기반 및 LLM 기반 분류기엔티티 추출: 정규식 및 LLM 기반 엔티티 인식대화 관리: LangChain의 대화 체인 활용카카오톡 연동: 카카오톡 스킬 서버 API 구현주요 시나리오주문/배송 조회결제 문의 처리계정 관리상담사 연결불만 처리성능 지표문제 해결률첫 응답 해결률상담사 전환율평균 대화 시간사용자 만족도실행 방법가상환경 설정 및 활성화bashpython3.9 -m venv venv source venv/bin/activate # Linux/macOS # 또는 venv\Scripts\activate # Windows의존성 설치bashpip install -r requirements.txt환경 변수 설정bashcp .env.example .env # .env 파일 편집하여 API 키 등 설정서버 실행bashpython run.py확장 가능성데이터베이스 연동: 현재는 메모리 내 임시 데이터 사용, 실제 DB로 전환 가능추가 인텐트: 비즈니스 요구사항에 맞게 인텐트 및 처리 로직 확장분석 대시보드: 수집된 성능 지표 데이터로 대시보드 구현 가능다국어 지원: 추가 언어 모델 통합 가능이 프로젝트는 고객센터 상담 챗봇의 기본 구조를 제공하며, 실제 비즈니스 요구사항에 맞게 확장 가능합니다. 추가적인 기능이나 변경사항이 필요하시면 말씀해 주세요.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 프로젝트 초안이 만들어졌습니다. 만들어진 각 파일은 프로젝트 구조에 맞게 레포에 파일을 생성하고 붙여넣기합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디펜던시를 설치하던 중 오류가 발생했습니다. 아래에선 오류가 발생했다고 말해주고 있지만 요즘엔 그냥 오류만 복붙해도 답이 잘나옵니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;requirements 설치하는 도중 아래와 같은 오류가 발생했어 ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts. langchain-text-splitters 0.3.8 requires langchain-core&amp;lt;1.0.0,&amp;gt;=0.3.51, but you have langchain-core 0.1.53 which is incompatible.&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;이 오류는 패키지 의존성 충돌로 인해 발생한 것입니다. langchain-text-splitters 패키지가 langchain-core 0.3.51 이상 버전을 필요로 하는데, 현재 설치된 langchain-core가 0.1.53 버전이라 호환되지 않는 상황입니다. 이 문제를 해결하기 위한 방법은 다음과 같습니다: 해결 방법 1: 의존성 버전 업데이트 requirements.txt 파일을 수정하여 호환되는 버전을 명시합니다: fastapi==0.110.0 uvicorn==0.28.0 langchain==0.1.9 langchain-openai==0.0.7 langchain-core&amp;gt;=0.3.51 langserve==0.0.42 pydantic==2.6.3 python-dotenv==1.0.1 requests==2.31.0 pytest==7.4.3 pytest-asyncio==0.21.1 pymongo==4.6.1 redis==5.0.1 langchain-core의 버전을 0.3.51 이상으로 명시했습니다. 해결 방법 2: 의존성 충돌 무시 및 별도 설치 먼저 기존 패키지를 설치합니다: bashpip install -r requirements.txt --ignore-installed 그 다음 langchain-core를 업데이트합니다: bashpip install --upgrade langchain-core&amp;gt;=0.3.51 해결 방법 3: 가상환경 재설정 의존성 충돌이 복잡한 경우, 가상환경을 새로 만드는 것이 깨끗한 해결책이 될 수 있습니다: bash# 기존 가상환경 비활성화 deactivate # 기존 가상환경 삭제 rm -rf venv # Linux/macOS # 또는 rmdir /s /q venv # Windows # 새 가상환경 생성 python3.9 -m venv venv # 가상환경 활성화 source venv/bin/activate # Linux/macOS # 또는 venv\Scripts\activate # Windows # 의존성 설치 (수정된 requirements.txt 사용) pip install -r requirements.txt 추가 고려사항 LangChain 생태계는 빠르게 발전하고 있어 패키지 간 의존성 관리가 중요합니다. 프로젝트 진행 시: 패키지 버전을 명시적으로 고정하는 것이 좋습니다. 호환성 문제가 발생할 경우, 공식 문서나 GitHub 이슈를 확인하는 것이 도움이 됩니다. 의존성 관리 도구인 pip-tools나 Poetry 사용을 고려해 볼 수 있습니다. 이 중에서 해결 방법 1(requirements.txt 수정)이 가장 안정적인 해결책입니다. 호환되는 버전을 명시적으로 지정하여 동일한 의존성 환경을 보장할 수 있습니다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 오류가 발생합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;아래와 같은 오류 발생 ERROR: Cannot install langchain-core&amp;gt;=0.3.51 and langchain==0.1.9 because these package versions have conflicting dependencies. The conflict is caused by: The user requested langchain-core&amp;gt;=0.3.51 langchain 0.1.9 depends on langchain-core&amp;lt;0.2 and &amp;gt;=0.1.26 To fix this you could try to: 1. loosen the range of package versions you've specified 2. remove package versions to allow pip to attempt to solve the dependency conflict ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;좀 더 복잡한 의존성 충돌이 발생했네요. langchain 패키지는 langchain-core&amp;lt;0.2 and &amp;gt;=0.1.26 버전을 필요로 하는데, langchain-text-splitters는 langchain-core&amp;gt;=0.3.51을 필요로 합니다. 이 두 요구사항이 충돌하고 있습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이런 경우 해결 방법은 다음과 같습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 1: LangChain 버전 업그레이드&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;LangChain 버전을 업그레이드하여 최신 langchain-core와 호환되게 만듭니다. 다음과 같이 requirements.txt를 수정해보세요:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;fastapi==0.110.0 uvicorn==0.28.0 langchain&amp;gt;=0.2.0 # 버전 업그레이드 langchain-openai&amp;gt;=0.1.0 # 호환 버전 업그레이드 langserve==0.0.42 pydantic==2.6.3 python-dotenv==1.0.1 requests==2.31.0 pytest==7.4.3 pytest-asyncio==0.21.1 pymongo==4.6.1 redis==5.0.1&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 2: 직접 패키지 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;requirements.txt에 의존성 버전을 명시하지 않고 직접 설치하는 방식입니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 기본 패키지 설치 pip install fastapi==0.110.0 uvicorn==0.28.0 pydantic==2.6.3 python-dotenv==1.0.1 requests==2.31.0 # LangChain 관련 패키지 설치 (최신 버전) pip install langchain langchain-openai langserve # 테스트 및 데이터베이스 패키지 설치 pip install pytest pytest-asyncio pymongo redis&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 3: 격리된 환경에서 필요한 패키지만 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;프로젝트에 실제 필요한 기능들만 골라서 설치합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;가상환경 초기화: &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashdeactivate rm -rf venv # Linux/macOS 또는 rmdir /s /q venv (Windows) python3.9 -m venv venv source venv/bin/activate # Linux/macOS 또는 venv\Scripts\activate (Windows)&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;필수 패키지 설치: &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install fastapi uvicorn python-dotenv requests pip install openai langchain-openai langserve&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;기능 구현 시 필요한 패키지를 그때그때 추가 설치: &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install pymongo # 필요한 경우에만&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;추천하는 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이런 상황에서는 해결 방법 3을 추천합니다. 필요한 패키지만 설치하고, 프로젝트를 진행하면서 추가 패키지가 필요할 때 설치하는 방식입니다. 이렇게 하면 불필요한 의존성 충돌을 피할 수 있습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;의존성 관리 개선 제안&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;Poetry 사용 고려: Python 패키지 의존성 관리에 Poetry를 사용하면 의존성 충돌을 더 효과적으로 관리할 수 있습니다. 명시적 버전 지정: 모든 패키지의 버전을 명시적으로 지정하면 일관된 환경을 유지하는 데 도움이 됩니다. 동결된 의존성 파일 사용: pip freeze &amp;gt; requirements-lock.txt로 정확한 의존성 목록을 생성하고 이를 배포에 사용할 수 있습니다. &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;LangChain 생태계가 빠르게 발전하고 있어 이런 의존성 문제가 자주 발생할 수 있습니다. 간소화된 환경으로 시작하여 필요에 따라 확장하는 것이 더 안정적인 접근법입니다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성이 해결되어 python run.py를 실행했더니 오류가 발생했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;python run.py 에러 ImportError: cannot import name 'VerifyTypes' from 'httpx._types' (/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/httpx/_types.py)&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;이 오류는 httpx 라이브러리와 관련된 의존성 문제입니다. VerifyTypes를 httpx 라이브러리에서 가져오려고 하는데 현재 설치된 httpx 버전에는 해당 타입이 없는 것으로 보입니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 문제를 해결하기 위한 방법은 다음과 같습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 1: httpx 업그레이드&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;httpx 패키지를 최신 버전으로 업그레이드하세요:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install --upgrade httpx&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 2: 특정 버전의 httpx 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;특정 버전의 httpx를 설치하는 것도 도움이 될 수 있습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install httpx==0.24.1&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 3: 의존성 문제가 있는 패키지 재설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;가끔은 fastapi나 langserve와 같은 의존성 패키지를 재설치하는 것이 도움이 됩니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip uninstall -y fastapi langserve httpx pip install fastapi langserve httpx&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 4: Python 가상환경 초기화&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;의존성 문제가 복잡할 경우, 가상환경을 초기화하고 다시 설정하는 것이 가장 확실한 방법입니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bash# 가상환경 비활성화 deactivate # 가상환경 재생성 rm -rf .venv # macOS/Linux # 또는 # rmdir /s /q .venv # Windows # 새 가상환경 생성 python3.9 -m venv .venv # 가상환경 활성화 source .venv/bin/activate # macOS/Linux # 또는 # .venv\Scripts\activate # Windows # 핵심 패키지 설치 pip install fastapi uvicorn langchain langchain-openai langserve httpx==0.24.1 python-dotenv&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 문제의 원인&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 문제는 일반적으로 다음과 같은 상황에서 발생합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;다양한 패키지가 httpx의 서로 다른 버전에 의존하는 경우 httpx API가 변경되면서 일부 기능이 다른 위치로 이동하거나 이름이 변경된 경우 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;특히 FastAPI, LangServe, LangChain 같은 패키지들은 모두 httpx에 의존하는데, 서로 호환되지 않는 버전을 요구할 수 있습니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;가장 간단한 해결책은 httpx를 최신 버전으로 업그레이드하는 것입니다. 이 방법이 효과가 없으면 가상환경을 초기화하고 다시 시작하는 것이 좋습니다.&lt;/b&gt;&lt;/blockquote&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;Pydantic에러가 발생했습니다. 버전 문제인 것 같습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;raise PydanticImportError( pydantic.errors.PydanticImportError: &lt;/b&gt;&lt;br /&gt;&lt;b&gt;BaseSettings&lt;/b&gt;&lt;br /&gt;&lt;b&gt;has been moved to the &lt;/b&gt;&lt;br /&gt;&lt;b&gt;pydantic-settings&lt;/b&gt;&lt;br /&gt;&lt;b&gt;package. See &lt;/b&gt;&lt;br /&gt;&lt;b&gt;https://docs.pydantic.dev/2.6/migration/#basesettings-has-moved-to-pydantic-settings&lt;/b&gt;&lt;br /&gt;&lt;b&gt;for more details. For further information visit &lt;/b&gt;&lt;br /&gt;&lt;b&gt;https://errors.pydantic.dev/2.6/u/import-error&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;이 오류는 Pydantic v2에서 발생하는 문제입니다. Pydantic 버전 2에서 BaseSettings 클래스가 메인 패키지에서 별도의 pydantic-settings 패키지로 이동했기 때문에 발생합니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 문제를 해결하는 방법은 두 가지입니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 1: pydantic-settings 패키지 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;Pydantic 2.x를 계속 사용하면서 settings 모듈을 추가로 설치합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install pydantic-settings&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;그런 다음 config.py 파일에서 import 문을 수정해야 합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;python# 변경 전: from pydantic import BaseSettings # 변경 후: from pydantic_settings import BaseSettings&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;해결 방법 2: Pydantic 1.x 버전 사용&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;프로젝트를 Pydantic 1.x 버전으로 다운그레이드 하는 방법입니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip uninstall -y pydantic pip install pydantic==1.10.8&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;추천 방법&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;새 프로젝트라면 해결 방법 1을 추천합니다. 최신 버전의 Pydantic을 사용하면서 pydantic-settings 패키지를 추가로 설치하는 것이 장기적으로 더 나은 접근 방식입니다. 코드 변경도 간단하게 import 경로만 수정하면 됩니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;config.py 파일의 코드를 다음과 같이 수정하세요:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;python&quot;&quot;&quot; 애플리케이션 설정 파일 &quot;&quot;&quot; import os from pydantic_settings import BaseSettings # 여기를 수정 from dotenv import load_dotenv # 환경 변수 로드 load_dotenv() class Settings(BaseSettings): &quot;&quot;&quot;애플리케이션 설정&quot;&quot;&quot; # 기본 설정 APP_NAME: str = &quot;고객센터 상담 챗봇&quot; DEBUG: bool = os.getenv(&quot;DEBUG&quot;, &quot;False&quot;).lower() == &quot;true&quot; # API 키 OPENAI_API_KEY: str = os.getenv(&quot;OPENAI_API_KEY&quot;, &quot;&quot;) KAKAO_API_KEY: str = os.getenv(&quot;KAKAO_API_KEY&quot;, &quot;&quot;) # LLM 설정 LLM_MODEL: str = os.getenv(&quot;LLM_MODEL&quot;, &quot;gpt-3.5-turbo&quot;) LLM_TEMPERATURE: float = float(os.getenv(&quot;LLM_TEMPERATURE&quot;, &quot;0.7&quot;)) # 데이터베이스 설정 DATABASE_URL: str = os.getenv(&quot;DATABASE_URL&quot;, &quot;sqlite:///./chatbot.db&quot;) # 보안 설정 API_KEY_HEADER: str = os.getenv(&quot;API_KEY_HEADER&quot;, &quot;X-API-Key&quot;) API_KEY: str = os.getenv(&quot;API_KEY&quot;, &quot;&quot;) # 서비스 설정 MAX_SESSION_IDLE_TIME: int = int(os.getenv(&quot;MAX_SESSION_IDLE_TIME&quot;, &quot;1800&quot;)) # 초 단위(30분) MAX_CONVERSATION_HISTORY: int = int(os.getenv(&quot;MAX_CONVERSATION_HISTORY&quot;, &quot;10&quot;)) # 대화 기록 최대 보관 수 class Config: env_file = &quot;.env&quot; env_file_encoding = &quot;utf-8&quot; # 설정 인스턴스 생성 settings = Settings()&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이렇게 수정한 후 pydantic-settings 패키지를 설치하면 문제가 해결될 것입니다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pydantic v1과 v2의 차이점에서 생긴 문제입니다. 복붙해주니 바로 해결됩니다.&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;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/app/chains/conversation.py:94: LangChainDeprecationWarning: The class &lt;/b&gt;&lt;br /&gt;&lt;b&gt;LLMChain&lt;/b&gt;&lt;br /&gt;&lt;b&gt;was deprecated in LangChain 0.1.17 and will be removed in 1.0. Use RunnableSequence, e.g., &lt;/b&gt;&lt;br /&gt;&lt;b&gt;prompt | llm&lt;/b&gt;&lt;br /&gt;&lt;b&gt;instead. conversation = LLMChain( Traceback (most recent call last): File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/langserve/server.py&quot;, line 349, in add_routes from sse_starlette import EventSourceResponse ModuleNotFoundError: No module named 'sse_starlette' During handling of the above exception, another exception occurred: Traceback (most recent call last): File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/run.py&quot;, line 26, in &amp;lt;module&amp;gt; uvicorn.run(&quot;main:app&quot;, host=host, port=port, reload=debug) File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/uvicorn/main.py&quot;, line 575, in run server.run() File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/uvicorn/server.py&quot;, line 62, in run return asyncio.run(self.serve(sockets=sockets)) File &quot;/opt/homebrew/Cellar/python@3.9/3.9.7_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/runners.py&quot;, line 44, in run return loop.run_until_complete(main) File &quot;/opt/homebrew/Cellar/python@3.9/3.9.7_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/base_events.py&quot;, line 642, in run_until_complete return future.result() File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/uvicorn/server.py&quot;, line 69, in serve config.load() File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/uvicorn/config.py&quot;, line 433, in load self.loaded_app = import_from_string(self.app) File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/uvicorn/importer.py&quot;, line 19, in import_from_string module = importlib.import_module(module_str) File &quot;/opt/homebrew/Cellar/python@3.9/3.9.7_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/importlib/&lt;/b&gt;&lt;br /&gt;&lt;b&gt;init&lt;/b&gt;&lt;br /&gt;&lt;b&gt;.py&quot;, line 127, in import_module return &lt;/b&gt;&lt;br /&gt;&lt;b&gt;bootstrap.&lt;/b&gt;&lt;br /&gt;&lt;b&gt;gcd_import(name[level:], package, level) File &quot;&amp;lt;frozen importlib._bootstrap&amp;gt;&quot;, line 1030, in &lt;/b&gt;&lt;br /&gt;&lt;b&gt;gcd&lt;/b&gt;&lt;br /&gt;&lt;b&gt;import File &quot;&amp;lt;frozen importlib._bootstrap&amp;gt;&quot;, line 1007, in &lt;/b&gt;&lt;br /&gt;&lt;b&gt;find&lt;/b&gt;&lt;br /&gt;&lt;b&gt;and_load File &quot;&amp;lt;frozen importlib._bootstrap&amp;gt;&quot;, line 986, in &lt;/b&gt;&lt;br /&gt;&lt;b&gt;find&lt;/b&gt;&lt;br /&gt;&lt;b&gt;and_load_unlocked File &quot;&amp;lt;frozen importlib._bootstrap&amp;gt;&quot;, line 680, in &lt;/b&gt;&lt;br /&gt;&lt;b&gt;load&lt;/b&gt;&lt;br /&gt;&lt;b&gt;unlocked File &quot;&amp;lt;frozen importlib._bootstrap_external&amp;gt;&quot;, line 850, in exec_module File &quot;&amp;lt;frozen importlib._bootstrap&amp;gt;&quot;, line 228, in &lt;/b&gt;&lt;br /&gt;&lt;b&gt;call&lt;/b&gt;&lt;br /&gt;&lt;b&gt;with_frames_removed File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/main.py&quot;, line 10, in &amp;lt;module&amp;gt; from app.api.router import router as api_router File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/app/api/router.py&quot;, line 26, in &amp;lt;module&amp;gt; add_routes(router, conversation_chain, path=&quot;/chat/chain&quot;, input_type=Dict[str, Any]) File &quot;/Users/gusdn0828/Documents/GitHub/vibe-chatbot/.venv/lib/python3.9/site-packages/langserve/server.py&quot;, line 351, in add_routes raise ImportError( ImportError: sse_starlette must be installed to implement the stream and stream_log endpoints. Use &lt;/b&gt;&lt;br /&gt;&lt;b&gt;pip install sse_starlette&lt;/b&gt;&lt;br /&gt;&lt;b&gt;to install.&lt;/b&gt;&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;두 가지 문제가 발생했습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;LangChain 버전 관련 경고 메시지: LLMChain 클래스가 곧 사라질 예정입니다. sse_starlette 모듈이 설치되어 있지 않습니다. &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 두 문제를 순서대로 해결해 보겠습니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;1. sse_starlette 설치&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;먼저 즉시 오류를 해결하기 위해 필요한 패키지를 설치합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install sse-starlette&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2. LLMChain 대신 최신 구문 사용&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;conversation.py 파일의 코드를 최신 LangChain API에 맞게 업데이트하는 것이 좋습니다. 다음과 같이 수정하세요:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;pythondef get_conversation_chain(): &quot;&quot;&quot;대화 처리 체인 반환&quot;&quot;&quot; try: # LLM 모델 인스턴스 가져오기 llm = get_llm() # 메모리 설정 memory = ConversationBufferMemory( return_messages=True, memory_key=&quot;chat_history&quot;, input_key=&quot;input&quot; ) # 프롬프트 템플릿 설정 system_prompt_template = SystemMessagePromptTemplate.from_template(get_system_prompt()) human_prompt_template = HumanMessagePromptTemplate.from_template(get_human_prompt()) chat_prompt = ChatPromptTemplate.from_messages([ system_prompt_template, human_prompt_template ]) # 새로운 방식으로 체인 생성 (RunnableSequence 사용) conversation = chat_prompt | llm # 메모리 포함 체인 생성 chain_with_memory = RunnableWithMessageHistory( conversation, lambda session_id: memory, input_messages_key=&quot;input&quot;, history_messages_key=&quot;chat_history&quot; ) return chain_with_memory except Exception as e: logger.error(f&quot;대화 체인 초기화 오류: {str(e)}&quot;) raise&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이를 위해서는 필요한 import 구문도 추가해야 합니다:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;pythonfrom langchain.memory import ConversationBufferMemory from langchain_openai import ChatOpenAI from langchain.prompts import PromptTemplate, ChatPromptTemplate, HumanMessagePromptTemplate, SystemMessagePromptTemplate from langchain.chains.history_aware_retriever import RunnableWithMessageHistory&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;하지만 이렇게 수정하려면 LangChain의 더 최신 버전이 필요합니다. 가장 간단한 방법은 일단 sse-starlette를 설치하여 현재 코드가 실행되도록 한 다음, 점진적으로 코드를 업데이트하는 것입니다.&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;현재 가장 빠른 해결 방법:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;bashpip install sse-starlette&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;이 패키지를 설치한 후 애플리케이션이 실행될 것입니다. 경고는 잠시 무시해도 괜찮습니다(코드는 여전히 작동함).&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;장기적인 해결 방법:&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;LangChain 코드를 최신 API에 맞게 업데이트 필요한 경우 LangChain 버전 업그레이드 코드베이스를 점진적으로 최신화 &lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;새로운 LangChain 0.2+ 버전에서는 선언적 체인 구성 방식을 사용하므로, 이에 맞게 코드를 업데이트하는 것이 좋습니다. 하지만 당장은 필수는 아니며, 현재 코드도 경고가 있더라도 작동할 것입니다.&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디펜던시 문제가 해결되고 서버가 실행됐습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1046&quot; data-origin-height=&quot;466&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WsBP1/btsNkurSLy3/IlrphD89U26hr5wWsrriiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WsBP1/btsNkurSLy3/IlrphD89U26hr5wWsrriiK/img.png&quot; data-alt=&quot;이게 된다고?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WsBP1/btsNkurSLy3/IlrphD89U26hr5wWsrriiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWsBP1%2FbtsNkurSLy3%2FIlrphD89U26hr5wWsrriiK%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;635&quot; height=&quot;283&quot; data-origin-width=&quot;1046&quot; data-origin-height=&quot;466&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이게 된다고?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LangServe가 FastAPI 기반인 덕분에 OpenAPI Docs도 잘 나오는 모습을 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1880&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYdojf/btsNjOxADVL/YybSNoUdRdGkmmSHKhSnek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYdojf/btsNjOxADVL/YybSNoUdRdGkmmSHKhSnek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYdojf/btsNjOxADVL/YybSNoUdRdGkmmSHKhSnek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYdojf%2FbtsNjOxADVL%2FYybSNoUdRdGkmmSHKhSnek%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;778&quot; height=&quot;869&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1880&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;이렇게 프로젝트 초안까지 완성했습니다. 아직 각 기능이 작동하는지는 아직 알 수 없습니다. 하지만 과거 프로젝트를 구성하기 위해 기획, 설계, 구현에 들어갔던 공수를 생각하면 정말 획기적이네요. 토이프로젝트를 만드는 정도는 거의 다 가능할 것 같습니다. (&lt;s&gt;만들다 만 토이프로젝트들 다 있어보이게 만들기 매우 용이해 보이는&lt;/s&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/saysimple0828/vibe-chatbot&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/saysimple0828/vibe-chatbot&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1744471867995&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 - saysimple0828/vibe-chatbot&quot; data-og-description=&quot;Contribute to saysimple0828/vibe-chatbot development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/saysimple0828/vibe-chatbot&quot; data-og-url=&quot;https://github.com/saysimple0828/vibe-chatbot&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/yqz5z/hyYEA9eITG/akkfK5iY547Zrs3CckmDz1/img.png?width=1200&amp;amp;height=600&amp;amp;face=965_170_1021_231,https://scrap.kakaocdn.net/dn/biezNX/hyYCmqn9WL/H99UldPbF5325mL6xWy7p1/img.png?width=1200&amp;amp;height=600&amp;amp;face=965_170_1021_231&quot;&gt;&lt;a href=&quot;https://github.com/saysimple0828/vibe-chatbot&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/saysimple0828/vibe-chatbot&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/yqz5z/hyYEA9eITG/akkfK5iY547Zrs3CckmDz1/img.png?width=1200&amp;amp;height=600&amp;amp;face=965_170_1021_231,https://scrap.kakaocdn.net/dn/biezNX/hyYCmqn9WL/H99UldPbF5325mL6xWy7p1/img.png?width=1200&amp;amp;height=600&amp;amp;face=965_170_1021_231');&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 - saysimple0828/vibe-chatbot&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to saysimple0828/vibe-chatbot 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>LLM/VibeCoding</category>
      <category>AI</category>
      <category>ChatGPT</category>
      <category>Claude</category>
      <category>coding</category>
      <category>GPT</category>
      <category>LLM</category>
      <category>vibe</category>
      <category>vibecoding</category>
      <category>바이브코딩</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/206</guid>
      <comments>https://saysimple.tistory.com/206#entry206comment</comments>
      <pubDate>Sun, 13 Apr 2025 00:40:46 +0900</pubDate>
    </item>
    <item>
      <title>[테크포임팩트] 누구나데이터 활동일지 - 02 (feat. kakao cloud)</title>
      <link>https://saysimple.tistory.com/205</link>
      <description>&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://saysimple.tistory.com/204&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://saysimple.tistory.com/204&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1733138322448&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;[테크포임팩트] 누구나데이터 활동일지 - 01 (feat. langchain v0.3)&quot; data-og-description=&quot;안녕하세요! 세이 심플입니다. 카카오임팩트와 모두의연구소가 함께 '돕는 기술'로 사회문제를 해결하는 테크포임팩트에 대해서 아시나요? https://techforimpact.io/&amp;nbsp;테크포임팩트돕는 기술과 돕는 &quot; data-og-host=&quot;saysimple.tistory.com&quot; data-og-source-url=&quot;https://saysimple.tistory.com/204&quot; data-og-url=&quot;https://saysimple.tistory.com/204&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/pXRaE/hyXGzwtIKK/s81iVLN5vRTEeMnckIK1Lk/img.png?width=800&amp;amp;height=308&amp;amp;face=0_0_800_308,https://scrap.kakaocdn.net/dn/blt6o2/hyXGNIfN2a/IOKbaIrhXvR75UeuVqlFk0/img.png?width=800&amp;amp;height=308&amp;amp;face=0_0_800_308,https://scrap.kakaocdn.net/dn/b4GO8z/hyXGELg8yQ/X41aNvQA1bGBfLR14g9YtK/img.png?width=1702&amp;amp;height=656&amp;amp;face=0_0_1702_656&quot;&gt;&lt;a href=&quot;https://saysimple.tistory.com/204&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://saysimple.tistory.com/204&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/pXRaE/hyXGzwtIKK/s81iVLN5vRTEeMnckIK1Lk/img.png?width=800&amp;amp;height=308&amp;amp;face=0_0_800_308,https://scrap.kakaocdn.net/dn/blt6o2/hyXGNIfN2a/IOKbaIrhXvR75UeuVqlFk0/img.png?width=800&amp;amp;height=308&amp;amp;face=0_0_800_308,https://scrap.kakaocdn.net/dn/b4GO8z/hyXGELg8yQ/X41aNvQA1bGBfLR14g9YtK/img.png?width=1702&amp;amp;height=656&amp;amp;face=0_0_1702_656');&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;[테크포임팩트] 누구나데이터 활동일지 - 01 (feat. langchain v0.3)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;안녕하세요! 세이 심플입니다. 카카오임팩트와 모두의연구소가 함께 '돕는 기술'로 사회문제를 해결하는 테크포임팩트에 대해서 아시나요? https://techforimpact.io/&amp;nbsp;테크포임팩트돕는 기술과 돕는&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;saysimple.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테크포임팩트와 누구나리포터 LAB에 대해서 궁금하신 글은 이전 포스트를 참고해주시기 바랍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;11월에는 주로 카카오 클라우드에 인스턴스를 올리고 카카오톡과 연동하는 작업을 진행했습니다. 카카오 클라우드는 회사에서 인프라를 작업할 때 종종 사용했기에 다행히 사용법이 익숙해 어렵지 않게 올릴 수 있었습니다. 이 과정을 공유하고자 합니다.&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;1. VPC 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 인스턴스의 네트워크 대역을 설정하기 위해 VPC를 생성해야합니다. AWS와 같은 경우엔 default vpc가 있어 일반적으로 처음에 default를 사용하는데 카카오 클라우드의 경우엔 처음에 VPC를 직접 생성해야합니다.&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;검색에서 VPC를 검색하면 들어가실 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;988&quot; data-origin-height=&quot;888&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cs7aG0/btsK5qSSP8i/aEpu9WOZkqD82t1Af1o0O1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cs7aG0/btsK5qSSP8i/aEpu9WOZkqD82t1Af1o0O1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cs7aG0/btsK5qSSP8i/aEpu9WOZkqD82t1Af1o0O1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcs7aG0%2FbtsK5qSSP8i%2FaEpu9WOZkqD82t1Af1o0O1%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;988&quot; height=&quot;888&quot; data-origin-width=&quot;988&quot; data-origin-height=&quot;888&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPC는 제가 권한이 없어 캡처를 하진 못했습니다. 하지만 VPC에서 만드는 버튼을 클릭한 후 아무것도 수정할 것 없이 그대로 생성하면 됩니다. 생성을 한 후 Pending 상태로 5분 정도 기다리면 생성이 완료됩니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 보안그룹 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안그룹은 검색에 보안을 입력하시면 이동하실 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZteoN/btsK4bJkMZO/ccDRSnDYr5pEerlN4DnxK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZteoN/btsK4bJkMZO/ccDRSnDYr5pEerlN4DnxK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZteoN/btsK4bJkMZO/ccDRSnDYr5pEerlN4DnxK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZteoN%2FbtsK4bJkMZO%2FccDRSnDYr5pEerlN4DnxK1%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;898&quot; height=&quot;430&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;430&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안 그룹에선 default와 생성하신 그룹을 볼 수 있습니다. 보안 그룹 생성을 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2870&quot; data-origin-height=&quot;1228&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnE5UI/btsK3081E5o/HjlJXOPqhDUweHm0YeOXU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnE5UI/btsK3081E5o/HjlJXOPqhDUweHm0YeOXU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnE5UI/btsK3081E5o/HjlJXOPqhDUweHm0YeOXU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnE5UI%2FbtsK3081E5o%2FHjlJXOPqhDUweHm0YeOXU0%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;2870&quot; height=&quot;1228&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2870&quot; data-origin-height=&quot;1228&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 입력하여 보안그룹을 생성해주세요. 보안그룹에선 인바운드, 아웃바운드를 설정하여 서버에서 외부와 연결할 포트를 설정할 수 있습니다. 저는 임의로 기본 http, https와 api 포트인 8080, ssh 접속을 위한 22번 포트를 설정하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1758&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VooLp/btsK5AukTuk/pKmrgQpKFBU8tFNk22GFV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VooLp/btsK5AukTuk/pKmrgQpKFBU8tFNk22GFV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VooLp/btsK5AukTuk/pKmrgQpKFBU8tFNk22GFV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVooLp%2FbtsK5AukTuk%2FpKmrgQpKFBU8tFNk22GFV0%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;1854&quot; height=&quot;1758&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1758&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 인스턴스 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 클라우드 콘솔의 서비스 목록에서 Virtual Machine을 클릭하면 인스턴스를 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1614&quot; data-origin-height=&quot;1434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rfQzU/btsK3uoUWEw/3mRlY9KGFnzg5WSfHWdjn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rfQzU/btsK3uoUWEw/3mRlY9KGFnzg5WSfHWdjn0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rfQzU/btsK3uoUWEw/3mRlY9KGFnzg5WSfHWdjn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrfQzU%2FbtsK3uoUWEw%2F3mRlY9KGFnzg5WSfHWdjn0%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;1614&quot; height=&quot;1434&quot; data-origin-width=&quot;1614&quot; data-origin-height=&quot;1434&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2852&quot; data-origin-height=&quot;972&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/duyUwT/btsK4rylJGj/P6Ik9s8AKsHsb8EMgHrcI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/duyUwT/btsK4rylJGj/P6Ik9s8AKsHsb8EMgHrcI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/duyUwT/btsK4rylJGj/P6Ik9s8AKsHsb8EMgHrcI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FduyUwT%2FbtsK4rylJGj%2FP6Ik9s8AKsHsb8EMgHrcI0%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;2852&quot; height=&quot;972&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2852&quot; data-origin-height=&quot;972&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 이미지, 인스턴스 유형, 볼륨, 키 페어, 아까 생성한 VPC와 보안 그룹을 설정해주세요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1990&quot; data-origin-height=&quot;1890&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byYWKE/btsK3t4LxQZ/8N5KPts8I1cerFsk3QYXpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byYWKE/btsK3t4LxQZ/8N5KPts8I1cerFsk3QYXpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byYWKE/btsK3t4LxQZ/8N5KPts8I1cerFsk3QYXpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyYWKE%2FbtsK3t4LxQZ%2F8N5KPts8I1cerFsk3QYXpk%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;1990&quot; height=&quot;1890&quot; data-origin-width=&quot;1990&quot; data-origin-height=&quot;1890&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1978&quot; data-origin-height=&quot;1778&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yR3TK/btsK4MWHM8E/yzlVgmaZnFsEEky6VTzPVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yR3TK/btsK4MWHM8E/yzlVgmaZnFsEEky6VTzPVk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yR3TK/btsK4MWHM8E/yzlVgmaZnFsEEky6VTzPVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyR3TK%2FbtsK4MWHM8E%2FyzlVgmaZnFsEEky6VTzPVk%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;1978&quot; height=&quot;1778&quot; data-origin-width=&quot;1978&quot; data-origin-height=&quot;1778&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2004&quot; data-origin-height=&quot;1900&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFqUYO/btsK5noxKlI/xiA4OHvMotS8sFwnhAVCB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFqUYO/btsK5noxKlI/xiA4OHvMotS8sFwnhAVCB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFqUYO/btsK5noxKlI/xiA4OHvMotS8sFwnhAVCB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFqUYO%2FbtsK5noxKlI%2FxiA4OHvMotS8sFwnhAVCB1%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;2004&quot; height=&quot;1900&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2004&quot; data-origin-height=&quot;1900&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 퍼블릭 IP 할당&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ip를 검색해 퍼블릭 IP에 들어가면 확인할 수 있습니다. 퍼블릭 IP 또한 권한이 없어서 이미 생성된 상태로 확인하였기 때문에 생성하는 과정은 없지만 필요한 요소입니다. 인스턴스에 할당하면 해당 ip로 인스턴스에 접근할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqzvrw/btsK31zy2F1/XrLXCfSmEDu1EFnDSg8mq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqzvrw/btsK31zy2F1/XrLXCfSmEDu1EFnDSg8mq1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqzvrw/btsK31zy2F1/XrLXCfSmEDu1EFnDSg8mq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdqzvrw%2FbtsK31zy2F1%2FXrLXCfSmEDu1EFnDSg8mq1%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;860&quot; height=&quot;458&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;458&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스 우측의 점점점 버튼을 클릭하면 퍼블릭 IP를 선택할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;1424&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blJM7m/btsK46m9Z78/VXfKFXnkcgckY5f6TXrS2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blJM7m/btsK46m9Z78/VXfKFXnkcgckY5f6TXrS2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blJM7m/btsK46m9Z78/VXfKFXnkcgckY5f6TXrS2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblJM7m%2FbtsK46m9Z78%2FVXfKFXnkcgckY5f6TXrS2K%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;762&quot; height=&quot;1424&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;1424&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시큐리티 그룹에 22번 포트도 추가하였다면 ssh로 접근할 수 있으며 서버에서 도커를 설치하는 등의 액션을 할 수 있습니다. 키페어를 생성할 때 받았던 pem키로 접속할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_blob&quot; data-origin-width=&quot;664&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dKJsBE/btsK4fSMsMs/o9xq93zffEYcSW2QkWpMN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dKJsBE/btsK4fSMsMs/o9xq93zffEYcSW2QkWpMN0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dKJsBE/btsK4fSMsMs/o9xq93zffEYcSW2QkWpMN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdKJsBE%2FbtsK4fSMsMs%2Fo9xq93zffEYcSW2QkWpMN0%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;664&quot; height=&quot;506&quot; data-filename=&quot;edited_blob&quot; data-origin-width=&quot;664&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>활동</category>
      <category>누구나데이터</category>
      <category>누구나리포터lab</category>
      <category>모두의연구소</category>
      <category>브라이언임팩트</category>
      <category>카카오</category>
      <category>카카오임팩트</category>
      <category>테크포임팩트</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/205</guid>
      <comments>https://saysimple.tistory.com/205#entry205comment</comments>
      <pubDate>Tue, 3 Dec 2024 13:48:28 +0900</pubDate>
    </item>
    <item>
      <title>[테크포임팩트] 누구나데이터 활동일지 - 01 (feat. langchain v0.3)</title>
      <link>https://saysimple.tistory.com/204</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 세이 심플입니다. 카카오임팩트와 모두의연구소가 함께 '돕는 기술'로 사회문제를 해결하는 테크포임팩트에 대해서 아시나요? &lt;a href=&quot;https://techforimpact.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://techforimpact.io/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1730112248513&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;테크포임팩트&quot; data-og-description=&quot;돕는 기술과 돕는 사람이 만나 세상을 변화시키는 곳&quot; data-og-host=&quot;techforimpact.io&quot; data-og-source-url=&quot;https://techforimpact.io/&quot; data-og-url=&quot;https://techforimpact.io&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/wQTRj/hyXpuPBeNR/XxMycQpEugZXsl9zglKrK1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/bX7mT3/hyXpCmxosH/K35UbwmpEeV8BkSvI5WuKK/img.png?width=2868&amp;amp;height=1912&amp;amp;face=195_228_863_310&quot;&gt;&lt;a href=&quot;https://techforimpact.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://techforimpact.io/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/wQTRj/hyXpuPBeNR/XxMycQpEugZXsl9zglKrK1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/bX7mT3/hyXpCmxosH/K35UbwmpEeV8BkSvI5WuKK/img.png?width=2868&amp;amp;height=1912&amp;amp;face=195_228_863_310');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;테크포임팩트&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;돕는 기술과 돕는 사람이 만나 세상을 변화시키는 곳&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;techforimpact.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 테크포임팩트의 누구나리포터 LAB에 프롬프트 엔지니어로 새로 참가하게 되었습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누구나리포터 LAB은 AI 챗봇으로 데이터 기반 의사결정돕기 프로젝트를 진행할 예정입니다. 위 프로젝트는 소셜벤처 '(주)누구나데이터'와 함께, 기부자 데이터 분석 솔루션 '오늘의리포트'를 활용한 LLM 기반 챗봇 리포트 서비스를 개발하는 프로젝트입니다. 이를 통해 데이터 분석 인력이 없는 비영리단체들에게 마치 주니어 분석가를 채용한 것과 같은 수준의 모금 전략 인사이트를 제공하는 것을 목표로 합니다.&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;a href=&quot;https://modulabs.co.kr/product/lab-17894-2024-09-05-150155/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://modulabs.co.kr/product/lab-17894-2024-09-05-150155/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1730112426167&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;[테크포임팩트] 누구나리포터 LAB&quot; data-og-description=&quot;누구나리포터 LAB은 디지털 전환에 어려움을 겪는 국내 500개 이상의 비영리단체를 위해 데이터 기반의 디지털 모금 솔루션을 제공하는 소셜벤처 &amp;lsquo;(주)누구나데이터&amp;rsquo;와 함께, 기부자 데이터 분&quot; data-og-host=&quot;modulabs.co.kr&quot; data-og-source-url=&quot;https://modulabs.co.kr/product/lab-17894-2024-09-05-150155/&quot; data-og-url=&quot;https://modulabs.co.kr/product/lab-17894-2024-09-05-150155/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bEo77X/hyXpz4q55K/0mNblvNWrTMUbTZB12rRbK/img.png?width=1000&amp;amp;height=800&amp;amp;face=0_0_1000_800,https://scrap.kakaocdn.net/dn/UwFHb/hyXpxety81/jNY4Jc4aU9n3YREYOmQ6F1/img.png?width=1000&amp;amp;height=800&amp;amp;face=0_0_1000_800,https://scrap.kakaocdn.net/dn/bdd9Ll/hyXpx6CyVb/GveUFrmH6SyL09xfXzQivK/img.png?width=1000&amp;amp;height=800&amp;amp;face=0_0_1000_800&quot;&gt;&lt;a href=&quot;https://modulabs.co.kr/product/lab-17894-2024-09-05-150155/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://modulabs.co.kr/product/lab-17894-2024-09-05-150155/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bEo77X/hyXpz4q55K/0mNblvNWrTMUbTZB12rRbK/img.png?width=1000&amp;amp;height=800&amp;amp;face=0_0_1000_800,https://scrap.kakaocdn.net/dn/UwFHb/hyXpxety81/jNY4Jc4aU9n3YREYOmQ6F1/img.png?width=1000&amp;amp;height=800&amp;amp;face=0_0_1000_800,https://scrap.kakaocdn.net/dn/bdd9Ll/hyXpx6CyVb/GveUFrmH6SyL09xfXzQivK/img.png?width=1000&amp;amp;height=800&amp;amp;face=0_0_1000_800');&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;[테크포임팩트] 누구나리포터 LAB&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;누구나리포터 LAB은 디지털 전환에 어려움을 겪는 국내 500개 이상의 비영리단체를 위해 데이터 기반의 디지털 모금 솔루션을 제공하는 소셜벤처 &amp;lsquo;(주)누구나데이터&amp;rsquo;와 함께, 기부자 데이터 분&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;modulabs.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;10월에는 초기 준비 단계를 거쳐 프로젝트 설계 및 개발 단계를 위한 준비를 하고 있습니다. AI 파트에선 빅쿼리 스터디, Text to sql SOTA 모델 리서치, 오픈소스 모델별 비용 및 속도 리서치, 랭체인 리서치 초기셋팅 진행 등의 업무를 진행하고 있는데요, 저는 요약 리포트 생성 프롬프트 작성을 진행하였습니다.&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;랩 내에서 랭체인 v0.3을 사용하기로 하여서 프롬프트를 작성하기 전에 랭체인 v0.3에 대한 조사를 진행하였습니다.&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;일단 ChatGPT에게 두 버전의 차이점에 대해서 물어보았습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;LangChain v0.2 vs v0.3 주요 차이점 요약&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 주요 기능 추가&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;&lt;b&gt;에이전트 시스템 개선:&lt;/b&gt; v0.3에서는 에이전트의 유연성과 확장성이 향상되었으며, 새로운 에이전트 타입과 사용자 정의 에이전트 생성을 위한 API가 추가되었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;멀티모달 지원:&lt;/b&gt; 텍스트 외에도 이미지, 오디오 등 다양한 데이터 유형을 처리할 수 있어, 보다 풍부한 애플리케이션 개발이 가능해졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;통합 데이터베이스 지원 강화:&lt;/b&gt; PostgreSQL, MongoDB 등 다양한 데이터베이스와의 통합이 개선되어 데이터 관리가 용이해졌습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 성능 및 최적화&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;&lt;b&gt;속도 향상:&lt;/b&gt; 체인 실행 속도가 전반적으로 개선되어 대규모 데이터 처리 시 효율성이 높아졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메모리 사용 최적화:&lt;/b&gt; 메모리 사용량이 감소하여 자원 소모를 줄이면서도 높은 성능을 유지할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 처리 강화:&lt;/b&gt; 비동기 작업 처리 능력이 향상되어 여러 작업을 동시에 효율적으로 처리할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. API 변경 및 개선&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;&lt;b&gt;함수 및 클래스 리팩토링:&lt;/b&gt; 일부 함수와 클래스가 구조적으로 변경되어 사용법이 달라졌으며, Chain 클래스가 모듈화되어 특정 기능을 독립적으로 사용할 수 있게 되었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새로운 설정 옵션 추가:&lt;/b&gt; 사용자 설정을 세밀하게 조정할 수 있는 옵션들이 추가되어, 개발자의 유연한 설정이 가능해졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러 핸들링 개선:&lt;/b&gt; 에러 메시지가 더 구체적이고 이해하기 쉽게 개선되어 디버깅이 용이해졌습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. 문서화 및 예제 강화&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;&lt;b&gt;자세한 튜토리얼 및 코드 예제 추가:&lt;/b&gt; 새로운 기능과 사용법을 다룬 튜토리얼과 다양한 사용 사례의 코드 예제가 추가되어 개발자가 실제 애플리케이션에 적용하기 쉬워졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;API 문서 개선:&lt;/b&gt; API 문서가 체계적으로 정리되어 필요한 정보를 빠르게 찾을 수 있게 되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 호환성 및 마이그레이션&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;&lt;b&gt;백워드 호환성 이슈:&lt;/b&gt; 일부 API 변경으로 인해 기존 v0.2 코드는 v0.3에서 호환되지 않을 수 있어, 마이그레이션 시 코드 수정을 필요로 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Deprecation 알림:&lt;/b&gt; v0.2에서 사용되던 몇몇 기능이 v0.3에서 deprecated 되었으므로, 대체 기능으로 전환이 필요합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;버전 관리:&lt;/b&gt; 업그레이드 시 종속성을 신중히 관리하고, 주요 변경 사항을 반영한 테스트를 충분히 수행해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;6. 커뮤니티 및 지원 강화&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;&lt;b&gt;포럼 및 실시간 채팅 지원 확대:&lt;/b&gt; 사용자들이 질문하고 답변을 주고받을 수 있는 커뮤니티 지원이 강화되었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;플러그인 및 확장 기능 공식 지원:&lt;/b&gt; 커뮤니티에서 개발한 다양한 플러그인과 확장 기능이 공식적으로 지원되어 LangChain의 기능을 더욱 확장할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 기능은 에이전트 시스템 개선, 멀티모달 지원, 통합 데이터베이스 지원 강화입니다. 세가지 내용을 봤을 때 v0.3에선 부가 기능과 사용성을 위주로 많은 개선이 이뤄진 것으로 보입니다. GPT는 거짓말을 할 수 있으므로 실제 랭체인의 문서를 살펴보겠습니다.&lt;br /&gt;&lt;br /&gt;&lt;a href=&quot;https://python.langchain.com/docs/versions/v0_3/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://python.langchain.com/docs/versions/v0_3/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1730114012191&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;LangChain v0.3 |  ️  LangChain&quot; data-og-description=&quot;Last updated: 09.16.24&quot; data-og-host=&quot;python.langchain.com&quot; data-og-source-url=&quot;https://python.langchain.com/docs/versions/v0_3/&quot; data-og-url=&quot;https://python.langchain.com/docs/versions/v0_3/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dRsAXl/hyXpo9FDwB/kY8xmIDesYTUCZxCfNJ1pk/img.png?width=800&amp;amp;height=418&amp;amp;face=0_0_800_418,https://scrap.kakaocdn.net/dn/JYnPz/hyXpCz56Wp/jFPWTRb3woASuROkRpTJyk/img.png?width=800&amp;amp;height=418&amp;amp;face=0_0_800_418,https://scrap.kakaocdn.net/dn/NzUsZ/hyXpq0JbZ8/zi66YyTKeXiNOQnjUqJyKk/img.png?width=1586&amp;amp;height=251&amp;amp;face=0_0_1586_251&quot;&gt;&lt;a href=&quot;https://python.langchain.com/docs/versions/v0_3/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://python.langchain.com/docs/versions/v0_3/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dRsAXl/hyXpo9FDwB/kY8xmIDesYTUCZxCfNJ1pk/img.png?width=800&amp;amp;height=418&amp;amp;face=0_0_800_418,https://scrap.kakaocdn.net/dn/JYnPz/hyXpCz56Wp/jFPWTRb3woASuROkRpTJyk/img.png?width=800&amp;amp;height=418&amp;amp;face=0_0_800_418,https://scrap.kakaocdn.net/dn/NzUsZ/hyXpq0JbZ8/zi66YyTKeXiNOQnjUqJyKk/img.png?width=1586&amp;amp;height=251&amp;amp;face=0_0_1586_251');&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;LangChain v0.3 |  ️  LangChain&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Last updated: 09.16.24&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;python.langchain.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;공식문서의 변경사항&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 글은 랭체인 공식문서의 변경사항입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1702&quot; data-origin-height=&quot;656&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CcqRr/btsKn4WVnVt/oJCjvog1nu8otGxHK8IBUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CcqRr/btsKn4WVnVt/oJCjvog1nu8otGxHK8IBUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CcqRr/btsKn4WVnVt/oJCjvog1nu8otGxHK8IBUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCcqRr%2FbtsKn4WVnVt%2FoJCjvog1nu8otGxHK8IBUK%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;1702&quot; height=&quot;656&quot; data-origin-width=&quot;1702&quot; data-origin-height=&quot;656&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 패키지에서 Pydantic을 v1 -&amp;gt; v2로 업그레이드 했다고 합니다. 기존에 pydantic_v1를 브릿지로 활용해 환경변수를 사용했던 부분들이 이젠 필요없다고 합니다.&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;그래서 Pydantic 1이 곧 지원되지 않을 예정이니 참고하면 좋을 것 같습니다. 또한 파이썬 3.8 버전이 2024년 10월 이후로 지원되지 않을 예정이라 하니 레거시 버전의 파이썬을 사용하고 계신 분들은 버전 업그레이드를 고려하셔야 할 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1188&quot; data-origin-height=&quot;858&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bo5VyQ/btsKvpuSjjw/jD7K3qBOvyieyYx8HOKTPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bo5VyQ/btsKvpuSjjw/jD7K3qBOvyieyYx8HOKTPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bo5VyQ/btsKvpuSjjw/jD7K3qBOvyieyYx8HOKTPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbo5VyQ%2FbtsKvpuSjjw%2FjD7K3qBOvyieyYx8HOKTPk%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;1188&quot; height=&quot;858&quot; data-origin-width=&quot;1188&quot; data-origin-height=&quot;858&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;langchain-community는 랭체인을 사용하면서 많이 봤던 랭체인의 모듈입니다. 이 모듈에서 각 특징에 맞는 langchain-x 패키지로 옮겼다고 합니다. 모듈화 및 리팩토링을 진행했다고 볼 수 있겠네요. 기존 api는 deprecated 되어 사용할 순 있지만 모든 라이브러리가 그렇듯 언젠간 사용이 불가능해질 수 있으니 코드 업데이트도 고려해봐야 될 것 같습니다.&lt;/li&gt;
&lt;li&gt;간단한 툴 바인딩 함수가 생겼습니다. 모델을 선언할 때 .bind_tools 메소드를 통해 기존의 툴을 통합할 수 있습니다.&lt;/li&gt;
&lt;li&gt;파운데이션 모델 생성자, 속도 제한기, 메세지 유틸리티가 추가되었습니다.&lt;/li&gt;
&lt;li&gt;사용자 정의 이벤트를 전송하는 기능이 추가되었습니다.&lt;/li&gt;
&lt;li&gt;레거시 체인을 더 이상 사용하지 않으며 사용되지 않는 체인은 표시됩니다. 이와 함께 마이그레이션 가이드를 제공합니다. langchian v1.0.0에서는 레거시 체인이 사라질 예정이니 코드 업데이트를 고려해봐야 될 것 같습니다.&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;&lt;b&gt;결론&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v0.2.0에서 v0.3.0으로의 변화는 확실히 새로운 기능의 추가 보다는 전체적인 코드 리팩토링 및 편의성을 다듬은 것 같습니다. 또한 LLM이 얼추 자리를 잡으면서 패키지의 전체적인 모습을 함께 잡아가는 느낌입니다.&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;LLM이 이미 시장에서 모습을 드러내고 있으며 기능적으로도 많은 부분이 정의되고 있다는 사실이라 생각합니다.&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;저는 개인적으로 앞으로 LLM에서 발전해나갈 방향은 ACTION 부분이라고 생각하는 만큼 다음 버전에서는 어떠한 상호작용에 대한 기능의 발전이 있지 않을까 조심스래 추측(기대?) 해봅니다.&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;&amp;nbsp;&lt;/p&gt;</description>
      <category>활동</category>
      <category>ChatGPT</category>
      <category>Claude</category>
      <category>GPT</category>
      <category>LangChain</category>
      <category>langchain v0.2</category>
      <category>langchain v0.3</category>
      <category>LLM</category>
      <category>OpenAI</category>
      <category>Sonnet</category>
      <category>랭체인</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/204</guid>
      <comments>https://saysimple.tistory.com/204#entry204comment</comments>
      <pubDate>Mon, 4 Nov 2024 20:59:52 +0900</pubDate>
    </item>
    <item>
      <title>[리뷰] 개발자를 위한 m3 맥북프로 vs 맥북에어 내돈내산 리뷰</title>
      <link>https://saysimple.tistory.com/203</link>
      <description>&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;m3 칩셋을 장착한 맥북에어가 24년 4월 15일 한국에 출시되었습니다! 오늘은 출시된지 이미 한 달 가량이 지난 시점인데요. 인터넷의 리뷰글과 유튜브를 보니 대부분 영상편집을 위한 비교글들은 많은데 개발자를 위한 글은 많이 안보이더라구요. 그래서! 개발자를 위한 비교글을 작성해 보았습니다.&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;두 기기는 원활한 개발을 진행하기 위한 최소 사양 16GB, 512GB의 스펙을 기준으로 비교합니다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스펙 비교&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 256px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;항목/모델 별 스펙&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;14인치 맥북프로&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;15인치 맥북에어&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;CPU&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;8코어 CPU(성능 코어 4개, 효율 코어 4개), 16코어 뉴럴 엔진&amp;nbsp; 탑재&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;8코어 CPU(성능 코어 4개, 효율 코어 4개), 16코어 뉴럴 엔진&amp;nbsp; 탑재&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;GPU&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;10코어 GPU&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;10코어 GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;디스플레이&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;14.2인치 리퀴드 레티나 &lt;b&gt;XDR&lt;/b&gt; 디스플레이, &lt;b&gt;3024 x 1964&lt;/b&gt; 기본 해상도(254 ppi), &lt;b&gt;1000 니트(전체 화면 최대 밝기)~1600니트&lt;/b&gt;&lt;span&gt;&lt;b&gt; (피크 밝기(HDR)&lt;/b&gt;, P3 컬러, 트루 톤, &lt;b&gt;ProMotion 최대 120Hz 지원&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;15.3인치 리퀴드 레티나 LED 백라이트 디스플레이(IPS), 2880&amp;times;1864 기본 해상도(224ppi), 500니트 밝기, P3 컬러, 트루 톤&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 57px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 57px;&quot;&gt;오디오&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 57px;&quot;&gt;포스 캔슬링 우퍼를 탑재한 6 스피커 사운드 시스템, 와이드 스테레오 사운드, Dolby Atmos, &lt;b&gt;스튜디오급&lt;/b&gt; 3 마이크 어레이&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 57px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;포스 캔슬링 우퍼를 탑재한 6 스피커 사운드 시스템, 와이드 스테레오 사운드, Dolby Atmos, 3 마이크 어레이&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;카메라&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;1080p FaceTime HD 카메라&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;1080p FaceTime HD 카메라&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;포트&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;썬더볼트 4/USB-C 2개, 맥세이프 3, &lt;b&gt;HDMI 1개, SDXC 카드 슬롯 1개&lt;/b&gt;, 3.5mm 오디오 잭&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;썬더볼트 4/USB-C 2개, 맥세이프 3, 3.5mm 오디오 잭&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;네트워크&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;와이파이 6E, 블루투스 5.3&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;text-align: justify;&quot;&gt;와이파이 6E(802.11ax), 블루투스 5.3&lt;/span&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;규격&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;312.6 x 221.2 x 15.5 mm, 1.55kg&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: justify;&quot;&gt;340.4 x 237.6 x 11.5 mm, 1.51kg&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;색상&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;실버, 스페이스 그레이&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: justify;&quot;&gt;실버, 스타라이트, 스페이스 그레이, 미드나이트&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;배터리 용량&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;69.6Wh&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: justify;&quot;&gt;66.5Wh&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;가격&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;1,799달러(약 266만 원)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;&lt;span style=&quot;text-align: justify;&quot;&gt;1,699달러(약 243만&lt;/span&gt;&lt;span style=&quot;text-align: justify;&quot;&gt; 원)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 기기의 가격 차이는 100달러 (약 13만원) 정도 차이가 나는데요. 에어와 프로의 차이가 이 정도 가격이면 프로사는게 낫지 않나? 생각하실 수 있습니다. 동영상 편집과 같이 지속적으로 높은 성능을 제공해야 하는 경우엔 무조건 프로를 가야 한다고 생각합니다. 하지만 개발자인 제 개인적 관점으로는 에어가 사용하기 더 좋았습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14.2인치 vs 15.3인치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 13인치 맥북은 정말 여기저기 돌아다니면서 쓰시거나 크림쉘 모드를 사용하시는게 아니라면 정말 비추합니다. 저에게 가장 중요한 포인트는 양쪽 화면에 창을 배치 했을 때 두 창을 적절하게 다 볼 수 있는가인데 13인치 맥북은 불가능한 수준이었습니다. 창의 배율을 낮추면 글자가 너무 작아져 보기 어려운 수준이 됩니다.&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;14인치 맥북부터는 그럭저럭 쓸만한 단계에 옵니다. 가끔 창을 양쪽으로 분할하면 홈페이지가 반응형으로 반쪽 크기가 될 때 화면이 잘리긴 하지만 디스플레이 해상도가 높아 화면이 볼만한 단계에 옵니다. 하지만 15인치 노트북을 사용해 본 입장에선 1.1인치의 아쉬움이 계속 따라옵니다.&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;15.3인치에 와서는 기존의 15.6인치 화면들에 최적화된 홈페이지들의 사양 덕분에 드디어 양 쪽으로 창을 나눠도 잘리지 않은 페이지를 볼 수 있습니다. 개발자에게 이것은 창을 좌우로 스크롤하지 않아도 되게 만들어 개발 스트레스, 화면 가독성을 증가시켜줍니다. 실제 나눈 화면은 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3420&quot; data-origin-height=&quot;2128&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/42op0/btsHu8g7QiE/es0RF6ZR8nhgiveoejRAm1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/42op0/btsHu8g7QiE/es0RF6ZR8nhgiveoejRAm1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/42op0/btsHu8g7QiE/es0RF6ZR8nhgiveoejRAm1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F42op0%2FbtsHu8g7QiE%2Fes0RF6ZR8nhgiveoejRAm1%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;722&quot; height=&quot;449&quot; data-origin-width=&quot;3420&quot; data-origin-height=&quot;2128&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;16인치 화면은 정말 화면이 안잘리고 쾌적한 화면을 볼 수 있습니다. 하지만 2.1kg의 무게를 들고 다니기엔 제 허리에 무리가 있었습니다. 게다가 아이패드까지 들고 다니니 한 쪽으로 무게가 몰리면 점점 감당하기 힘들어지더라구요.. ㅠ 그래서 결국 보내줬습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;냉각 팬의 유무&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pro 이상의 칩셋을 선택하지 않는 이상 에어와 프로의 단순 칩셋 성능 차이는 제로입니다. 그렇기 때문에 성능 차이는 오직 냉각 성능에서만 나타납니다. 그렇다면 이 팬의 차이가 개발자의 개발 경험에 영향을 미칠 수 있을까요?&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;저는 Kubernetes에 React, Spring Boot, MongoDB, PostgreSQL, Kafka 3-클러스터, Redis를 모두 올려서 사용합니다. Kubernetes 환경은 사내 개발 서버에 있는 덕분에 로컬에서 Kubernetes까지 올리진 않고(혹은 미니쿠버나 Kind를 사용합니다.) docker-compose와 같은 오케스트레이션으로 개발을 진행합니다. 그러므로 docker-compose에 위의 서비스를 모두 올린 환경에서 원활히 개발할 수 있을지 테스트 하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1508&quot; data-origin-height=&quot;536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/emZL2I/btsHux3s2bm/fi9FBWsKwe6LcTYUofMXRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/emZL2I/btsHux3s2bm/fi9FBWsKwe6LcTYUofMXRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/emZL2I/btsHux3s2bm/fi9FBWsKwe6LcTYUofMXRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FemZL2I%2FbtsHux3s2bm%2Ffi9FBWsKwe6LcTYUofMXRK%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;728&quot; height=&quot;259&quot; data-origin-width=&quot;1508&quot; data-origin-height=&quot;536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1562&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dr5u59/btsHuddhQZH/laFpSefBdDTDdzdghtOnlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dr5u59/btsHuddhQZH/laFpSefBdDTDdzdghtOnlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dr5u59/btsHuddhQZH/laFpSefBdDTDdzdghtOnlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdr5u59%2FbtsHuddhQZH%2FlaFpSefBdDTDdzdghtOnlK%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;716&quot; height=&quot;238&quot; data-origin-width=&quot;1562&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 많은 서비스가 올라가 있을 때 CPU 사용량은 아래와 같습니다. 실제 업무를 하고 있는 상황을 재현하기 위해 카카오톡, 구글크롬을 켜둔 상태로 올렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot와 Cloud에 필요한 Config, DIscovery, Gateway 서비스와 Orders 서비스를 실행시킨 모습은 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;984&quot; data-origin-height=&quot;592&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccZ6g8/btsHvV3p0AA/AVLq9SPiojOpnFU0WAOXl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccZ6g8/btsHvV3p0AA/AVLq9SPiojOpnFU0WAOXl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccZ6g8/btsHvV3p0AA/AVLq9SPiojOpnFU0WAOXl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccZ6g8%2FbtsHvV3p0AA%2FAVLq9SPiojOpnFU0WAOXl1%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;602&quot; height=&quot;362&quot; data-origin-width=&quot;984&quot; data-origin-height=&quot;592&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU 사용량은 꽤 여유있는 모습을 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;210&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1atJr/btsHwrU3aj4/xWuPYsAk6W4OIKK7UPT3NK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1atJr/btsHwrU3aj4/xWuPYsAk6W4OIKK7UPT3NK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1atJr/btsHwrU3aj4/xWuPYsAk6W4OIKK7UPT3NK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1atJr%2FbtsHwrU3aj4%2FxWuPYsAk6W4OIKK7UPT3NK%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;585&quot; height=&quot;98&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;210&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 온도는 평균적으로 51도를 유지하고 있는 것으로 보입니다. 온도의 측정은 Hot앱을 사용했습니다. 해당 앱을 처음 사용해봐서 신뢰도는 정확하지 않지만 작업시 51도 정도의 온도라면 적당한 온도로 보입니다. 실제로 느끼기엔 키보드 위쪽으로 열감은 느껴지지 않았으며 밑판을 만져보면 애플 로고보다 약간 위쪽에 위치한 부분이 살짝 따뜻했습니다. 아마 CPU의 위치로 예상됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;554&quot; data-origin-height=&quot;772&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cR29Lp/btsHuJXceKh/aKasJzpKFPsDhzMx73Y2FK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cR29Lp/btsHuJXceKh/aKasJzpKFPsDhzMx73Y2FK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cR29Lp/btsHuJXceKh/aKasJzpKFPsDhzMx73Y2FK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcR29Lp%2FbtsHuJXceKh%2FaKasJzpKFPsDhzMx73Y2FK%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;409&quot; height=&quot;570&quot; data-origin-width=&quot;554&quot; data-origin-height=&quot;772&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 영상은 위의 환경에서 실제로 에디터에서 버그를 수정하는 모습입니다. Spring Boot의 서비스 빌드, 실행 및 테스트까지 문제없이 실행되는 모습을 볼 수 있습니다.&lt;/p&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;디스플레이&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Promotion&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;120hz와 60hz는 매우 다른 사용자 경험을 선사합니다. 아이폰 15 프로를 사용하고 있는 저도 핸드폰을 보다가 아이패드를 만지면 역체감을 매우 크게 느낍니다. 하지만 그 외에는 사실 개발자 입장에선 크게 쓸모있는 기능은 아닙니다. 게임 개발자 혹은 앱개발자가 고주사율의 화면을 확인할 때 사용하곤 하지만 두 가지 영역을 모두 경험해본 입장으로썬 고주사율을 개발시 사용할 일은 거의 없었던 것 같습니다.&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;s&gt;근데 에어 정도의 가격이면 솔직히 넣어주는게 맞지 않나..&lt;/s&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;해상도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 프로의 완승입니다. 3k급 고해상도에 XDR을 지원하는 프로는 디스플레이 품질의 차이가 육안으로 확인할 수 있을 정도이며 영상편집을 하시는 분들꼐는 더더욱 그 차이가 두드러집니다. 리퀴드 레티나 디스플레이는 정확하게 검은색을 표현할 수 있지만 백라이트 led인 맥북 에어의 경우엔 검은색을 완벽히 표현하지 못해 고퀄리티의 디자인 및 영상 편집하시는 분들일수록 체감이 더 심하실 것으로 생각됩니다.&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;위의 경우에 해당되지 않으신다면 큰 차이를 느끼지 못하시는 분들도 두루 계시지만 확실한 차이가 있는건 맞습니다. 저는 16인치를 쓰다가 바꾼 입장에서 ppi의 차이로 텍스트 뭉개짐 같은게 신경쓰이면 어떻게 하지 생각했었는데, ppi 차이가 크진 않아서 텍스트 표현에서의 차이는 느끼지 못했습니다. XDR및 1000니트 이상의 밝기, 고해상도의 화면이 필요하신게 아니라면 에어도 무난한 선택지가 될 수 있다 생각합니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;본체 높이&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bA2TPY/btsHupSm7zI/vSMU5RxN8X3kl8qzpylUj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bA2TPY/btsHupSm7zI/vSMU5RxN8X3kl8qzpylUj0/img.png&quot; data-alt=&quot;이 말도 안되는 본체 두께가 보이시나요?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bA2TPY/btsHupSm7zI/vSMU5RxN8X3kl8qzpylUj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbA2TPY%2FbtsHupSm7zI%2FvSMU5RxN8X3kl8qzpylUj0%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;589&quot; height=&quot;589&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1440&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이 말도 안되는 본체 두께가 보이시나요?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로는 15.5mm, 에어는 11.5mm의 두께를 가지고 있습니다. 저는 16인치 맥북을 사용할 땐 본체의 높이 때문에 손목에 큰 무리가 갔습니다. 본체 위치가 높은 만큼 불편한 손목 위치를 유지해야 하며, 본체 밑부분에 계속 살이 긁히더라구요. 16인치의 두께는 1.68cm, 14인치의 두께는 1.55cm로, 체감상 크게 다르지 않았습니다.&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;하지만 에어는 11.5mm로 매우 얇은 편에 속하는 두께를 가지고 있어 손목에 무리가 가지 않아 매우 편안한 타이핑 경험을 할 수 있었습니다. 솔직히 노트북 두께 만으로 이렇게 차이가 날 줄 몰랐어서 다음 노트북을 구매하더라도 무조건 두께가 얇은 노트북을 고르게 될 것 같습니다.&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;가볍고 얇은 노트북이라 하면 당연 그램이 떠오르는데요. 스펙이 가장 유사한 24년 출시 LG Gram Pro 16을 기준으로 비교하면 12.4mm의 두께를 가지고 있어 이보다 더 얇은 두께를 가진 맥북 에어가 얼마나 얇은지 알 수 있습니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;포트의 갯수&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;에어는 HDMI, SD카드 슬롯이 없습니다. 이 부분에서 동영상 편집을 하시는 분들께선 촬영하시면서 이동하시고, SD카드 데이터를 바로 백업하는 등의 작업을 진행하실 때 편의성이 많이 떨어지는 부분이라 생각됩니다. HDMI의 유무도 회의 등을 할 때 화면에 바로 연결해서 보는 경우가 많은 사무실에선 차이가 크게 갈릴 것이라 생각됩니다. 15인치 라인의 보급형 노트북들도 가지고 있는 HDMI 포트의 차이가 나는 것은 사실 많이 아쉬운 부분입니다.&lt;/span&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;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;개발자인 제 입장에선 SD카드의 유무는 크게 상관없지만(가지고 있는 SD카드도 없습니다.) HDMI 슬롯의 차이는 외장모니터를 연결해야 될 때 마다 외장 포트를 찾아야 한다는 점에서 큰 아쉬움을 느낍니다. 요즘 사무실에서 사용하는 티비들은 대부분 미러링을 제공하기 때문에 극복 가능한 부분이긴 하지만 가격대를 생각했을 때는 충분히 넣어줄 수 있지 않았나 생각합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;색상&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;233&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FjRHP/btsHwP2GsS7/4WVjioehfatLULKu8A40g0/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FjRHP/btsHwP2GsS7/4WVjioehfatLULKu8A40g0/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FjRHP/btsHwP2GsS7/4WVjioehfatLULKu8A40g0/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFjRHP%2FbtsHwP2GsS7%2F4WVjioehfatLULKu8A40g0%2Fimg.webp&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;762&quot; height=&quot;233&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;233&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로는 실버, 스페이스 그레이, 스페이스 블랙의 색상을 선택할 수 있으며 에어는 실버, 스타라이트, 스페이스 그레이 미드나이트 색상을 선택할 수 있습니다. 프로가 아닌 에어에서 더 많은 색상을 지원한다는 것이 솔직히 의아하긴 합니다. 더 높은 라인업에서 더 많은 색상을 제공하는게 맞지 않나 생각하지만 아이폰 5c와 미니, 15를 생각해보면 애플은 항상 저가 라인업에서 여러 색상을 제공했어서 애플만의 전략이 아닌가 싶습니다.&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;개인적으로 이번 미드나이트 색상이 정말 예쁘게 잘 나왔다고 생각합니다. 개인적으로 ASUS의 젠북 색상과 매우 비슷하다고 생각하는데, 저는 젠북도 매우 예쁘다고 생각해서 더 예쁘게 보이는 것 같습니다. 프로에서 지원해주지 않는 것은 아쉽지만 대신 스페이스 블랙 색상을 포함하고 있는데요. 스페이스 블랙 또한 중후한 검은색 색상에 맥북 고유의 스페이스 그레이 느낌을 잘 살렸다는 평가가 많아서 꼭 사용해보고 싶은 색상입니다.&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;&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;지극히 주관적으로 개발자 입장에서 맥북 프로와 에어를 리뷰해 보았습니다. 저는 결국 m3 맥북 에어를 선택한 입장으로써 현재 정말 만족하면서 사용하고 있습니다. 시원한 화면과 쾌적한 개발 환경, 긴 배터리 타임은 저에게 m1 맥북 프로를 처음 구매 했을 때와 같은 감동을 선사 해주었습니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이상 Saysimple 이었습니다. 긴 글 읽어주셔서 감사합니다!&lt;/p&gt;</description>
      <category>일상/언박싱</category>
      <category>AIR</category>
      <category>m3</category>
      <category>m3 air</category>
      <category>M3 Pro</category>
      <category>Macbook Air</category>
      <category>macbook pro</category>
      <category>pro</category>
      <category>맥북</category>
      <category>맥북에어 m3</category>
      <category>맥북프로 M3</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/203</guid>
      <comments>https://saysimple.tistory.com/203#entry203comment</comments>
      <pubDate>Mon, 20 May 2024 11:29:48 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 4</title>
      <link>https://saysimple.tistory.com/202</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;3편:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a href=&quot;https://saysimple.tistory.com/200&quot;&gt;https://saysimple.tistory.com/201&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;본론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번 글에선 Aggregate와 Handler를 마저 리뷰했습니다. 이번 시간에는 제 입맛에 맞게 디렉토리 구조를 리팩토링하고 유저 서비스를 Aggregate에 추가해 보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폴더 구조는 저번 구조에서 아래와 같이 변경하였습니다. 설정 파일, 컨트롤러, service, dto 등을 기존 스프링부트 파일 구조와 같이 가져갔으며 command, query, event를 모델로 분리 하였습니다. 또한 메모리에서 변경이 일어나는 부분을 Aggregate, 실제 저장소에서 읽기, 변경이 일어나는 부분을 Handler로 분리하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1714483839473&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;└─src
    ├─main
    │  ├─java
    │  │  └─com
    │  │      └─saysimple
    │  │          └─axon
    │  │              │  OrderApplication.java
    │  │              │  OrderApplicationConfiguration.java
    │  │              │
    │  │              ├─config
    │  │              │      MongoConfiguration.java
    │  │              │
    │  │              ├─controller
    │  │              │      OrdersController.java
    │  │              │
    │  │              ├─dto
    │  │              │      Order.java
    │  │              │      OrderStatus.java
    │  │              │
    │  │              ├─entity
    │  │              │      OrderEntity.java
    │  │              │
    │  │              ├─exceptions
    │  │              │      DuplicateOrderLineException.java
    │  │              │      OrderAlreadyConfirmedException.java
    │  │              │      UnconfirmedOrderException.java
    │  │              │
    │  │              ├─handler
    │  │              │      InMemoryOrdersEventHandler.java
    │  │              │      MongoOrdersEventHandler.java
    │  │              │      OrdersEventHandler.java
    │  │              │
    │  │              ├─model
    │  │              │  ├─command
    │  │              │  │      AddProductCommand.java
    │  │              │  │      ConfirmOrderCommand.java
    │  │              │  │      CreateOrderCommand.java
    │  │              │  │      DecrementProductCountCommand.java
    │  │              │  │      IncrementProductCountCommand.java
    │  │              │  │      ShipOrderCommand.java
    │  │              │  │
    │  │              │  ├─event
    │  │              │  │      OrderConfirmedEvent.java
    │  │              │  │      OrderCreatedEvent.java
    │  │              │  │      OrderShippedEvent.java
    │  │              │  │      ProductAddedEvent.java
    │  │              │  │      ProductCountDecrementedEvent.java
    │  │              │  │      ProductCountIncrementedEvent.java
    │  │              │  │      ProductRemovedEvent.java
    │  │              │  │
    │  │              │  └─query
    │  │              │          FindAllOrderedProductsQuery.java
    │  │              │          OrderUpdatesQuery.java
    │  │              │          TotalProductsShippedQuery.java
    │  │              │
    │  │              ├─service
    │  │              │      OrderQueryService.java
    │  │              │
    │  │              ├─uow
    │  │              │      OrderAggregate.java
    │  │              │      OrderLine.java
    │  │              │
    │  │              └─vo
    │  │                      OrderResponse.java
    │  │                      OrderStatusResponse.java&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전 구조를 조금 더 스프링부트 스럽게 변경 하였습니다. 폴더 구조는 저의 입맛대로 변경한 것이고, 구조에 정답은 없으므로 참고만 하시길 바랍니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Order 서비스 비즈니스 로직 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 원하는 Order 서비스 흐름은 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;606&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cb8WBM/btsG3yBZxuk/UBeoYLMCEd6qVjdmkBsqwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cb8WBM/btsG3yBZxuk/UBeoYLMCEd6qVjdmkBsqwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cb8WBM/btsG3yBZxuk/UBeoYLMCEd6qVjdmkBsqwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcb8WBM%2FbtsG3yBZxuk%2FUBeoYLMCEd6qVjdmkBsqwk%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;833&quot; height=&quot;606&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;606&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;Command
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문서비스의 Command에서 주문 관련 이벤트를 발행합니다.&lt;/li&gt;
&lt;li&gt;Axon의 이벤트 스토어에 Command와 이벤트가 기록됩니다.&lt;/li&gt;
&lt;li&gt;이벤트에서 Kafka로 메세지를 발행합니다.&lt;/li&gt;
&lt;li&gt;Kafka의 Source Connector가 Write DB(Command)에 CUD를 합니다.&lt;/li&gt;
&lt;li&gt;Kafka의 Sink Connector가 Read DB(Query)에 CUD를 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Query
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문서비스의 EventHandler의 Query에서 Read DB(Query)의 데이터를 읽어옵니다.&lt;/li&gt;
&lt;li&gt;Axon의 이벤트 스토어에 Query와 이벤트가 기록됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;유저 서비스 추가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유저 서비스를 추가하기 전에 먼저 Order 서비스와 User 서비스의 Entity를 살펴 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserEntity.java&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/202</guid>
      <comments>https://saysimple.tistory.com/202#entry202comment</comments>
      <pubDate>Wed, 24 Apr 2024 13:00:24 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 3</title>
      <link>https://saysimple.tistory.com/201</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;2편: &lt;a href=&quot;https://saysimple.tistory.com/199&quot;&gt;https://saysimple.tistory.com/199&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1713863606178&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] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 2&quot; data-og-description=&quot;1편: https://saysimple.tistory.com/197 [SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 1 CQRS에 대한 정보는 아래 글을 참고해주세요. https://saysimple.tistory.com/196 [CQRS] CQRS란? CQRS CQR&quot; data-og-host=&quot;saysimple.tistory.com&quot; data-og-source-url=&quot;https://saysimple.tistory.com/199&quot; data-og-url=&quot;https://saysimple.tistory.com/199&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bmSNGB/hyVSW15sSj/i6zHATJ4eX4kfKK3LtJWc0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cXCBwl/hyVS1vzcfV/n1KyaNKeABbyNw1HUGhAWk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://saysimple.tistory.com/199&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://saysimple.tistory.com/199&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bmSNGB/hyVSW15sSj/i6zHATJ4eX4kfKK3LtJWc0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cXCBwl/hyVS1vzcfV/n1KyaNKeABbyNw1HUGhAWk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 2&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;1편: https://saysimple.tistory.com/197 [SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 1 CQRS에 대한 정보는 아래 글을 참고해주세요. https://saysimple.tistory.com/196 [CQRS] CQRS란? CQRS CQR&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;saysimple.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번 시간에는 테스트 코드를 리뷰했습니다. 이번 시간에는 Handler와 Aggregate를 리뷰해 어떻게 Command, Query를 실행하고 Event를 발행하는지 알아보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 구조&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;commandmodel&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OrderAggregate&lt;/li&gt;
&lt;li&gt;OrderLine&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;querymodel
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;InMemoryOrdersEventHandler&lt;/li&gt;
&lt;li&gt;MongoConfiguration&lt;/li&gt;
&lt;li&gt;OrderQueryService&lt;/li&gt;
&lt;li&gt;OrderResponse&lt;/li&gt;
&lt;li&gt;OrdersEventhandler&lt;/li&gt;
&lt;li&gt;OrderStatusResponse&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Aggregate&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Aggregate란 집합하다, 모이다란 뜻으로 Command 수행을 위해 CRUD해야 하는 도메인의 집합입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderAggregate&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 Aggregate는 주문, 상품의 도메인을 관리합니다. 주문을 생성, 수정, 삭제하며 주문에 상품을 추가 및 삭제, 상품의 갯수를 증감 시킵니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713864869099&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Aggregate(snapshotTriggerDefinition = &quot;orderAggregateSnapshotTriggerDefinition&quot;)
public class OrderAggregate {

    @AggregateIdentifier
    private String orderId;
    private boolean orderConfirmed;

    @AggregateMember
    private Map&amp;lt;String, OrderLine&amp;gt; orderLines;

    @CommandHandler
    public OrderAggregate(CreateOrderCommand command) {
        apply(new OrderCreatedEvent(command.getOrderId()));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 아이디, 주문 확인 유무, 주문 라인과 생성자로 이루어져 있습니다. snapshotTriggerDefinition은 Aggregate 스냅샷에 대한 설정 값을 등록합니다. 자세한 내용은 아래 링크를 참고해주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/axon-snapshotting-aggregates&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/axon-snapshotting-aggregates&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OrderApplicationConfiguration&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 서비스의 설정을 불러옵니다. 위 코드에선 스냅샷을 트리거하는 최대 threshold를 지정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713865777182&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class OrderApplicationConfiguration {

    @Bean
    public SnapshotTriggerDefinition orderAggregateSnapshotTriggerDefinition(Snapshotter snapshotter, @Value(&quot;${axon.aggregate.order.snapshot-threshold:250}&quot;) int threshold) {
        return new EventCountSnapshotTriggerDefinition(snapshotter, threshold);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;EventCountSnapshotTriggerDefinition&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트의 스냅샷 트리거 규칙을 정의하는 클래스입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713866112541&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private static class EventCountSnapshotTrigger extends AbstractSnapshotTrigger {
        private final int threshold;
        private int counter = 0;

        public EventCountSnapshotTrigger(Snapshotter snapshotter, Class&amp;lt;?&amp;gt; aggregateType, int threshold) {
            super(snapshotter, aggregateType);
            this.threshold = threshold;
        }

        public boolean exceedsThreshold() {
            return ++this.counter &amp;gt;= this.threshold;
        }

        public void reset() {
            this.counter = 0;
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 EventCountSnapshoutTrigger 클래스를 보면 this.counter가 this.threshold보다 커졌을 때 threshold를 exceeds 하는 것을 볼 수 있습니다. 이어서 주문 Aggregate의 CommandHandler 메소드입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713879687626&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; @CommandHandler
    public void handle(AddProductCommand command) {
        if (orderConfirmed) {
            throw new OrderAlreadyConfirmedException(orderId);
        }

        String productId = command.getProductId();
        if (orderLines.containsKey(productId)) {
            throw new DuplicateOrderLineException(productId);
        }
        apply(new ProductAddedEvent(orderId, productId));
    }

    @CommandHandler
    public void handle(ConfirmOrderCommand command) {
        if (orderConfirmed) {
            return;
        }

        apply(new OrderConfirmedEvent(orderId));
    }

    @CommandHandler
    public void handle(ShipOrderCommand command) {
        if (!orderConfirmed) {
            throw new UnconfirmedOrderException();
        }

        apply(new OrderShippedEvent(orderId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AddProductCommand, ConfirmOrderCommand, ShipOrderCommand 커맨드에 대한 핸들러 메소드 입니다. 각 메소드에서 커맨드를 받고, 이벤트를 apply 하는 모습을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 EventSourcingHandler 메소드입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713880021053&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @EventSourcingHandler
    public void on(OrderCreatedEvent event) {
        this.orderId = event.getOrderId();
        this.orderConfirmed = false;
        this.orderLines = new HashMap&amp;lt;&amp;gt;();
    }

    @EventSourcingHandler
    public void on(OrderConfirmedEvent event) {
        this.orderConfirmed = true;
    }

    @EventSourcingHandler
    public void on(ProductAddedEvent event) {
        String productId = event.getProductId();
        this.orderLines.put(productId, new OrderLine(productId));
    }

    @EventSourcingHandler
    public void on(ProductRemovedEvent event) {
        this.orderLines.remove(event.getProductId());
    }

    protected OrderAggregate() {
        // Required by Axon to build a default Aggregate prior to Event Sourcing
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OrderCreatedEvent, OrderConfirmedEvent, ProductAddedEvent가 트리거 될 때 메모리에 주문 추가, 주문 상태 변경, 주문 라인에서 주문 삭제를 실행하는 것을 볼 수 있습니다. 이와 같은 패턴을 UOW(Unit Of Work) 패턴이라 합니다. 메모리에서 Aggregate 안의 데이터 변경 사항을 저장 및 관리해 데이터의 일관성을 보장하는 패턴입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderLine&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문의 상품 데이터를 나타냅니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713880615638&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class OrderLine {

    @EntityId
    private final String productId;
    private Integer count;
    private boolean orderConfirmed;

    public OrderLine(String productId) {
        this.productId = productId;
        this.count = 1;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 아이디, 상품 갯수, 주문 확인 유무와 생성자로 이루어져 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713880906478&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD})
public @interface EntityId {
    String routingKey() default &quot;&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EntityId는 Aggregate 안의 멤버를 routing할 키를 설정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 CommandHandler입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713881270794&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@CommandHandler
    public void handle(IncrementProductCountCommand command) {
        if (orderConfirmed) {
            throw new OrderAlreadyConfirmedException(command.getOrderId());
        }

        apply(new ProductCountIncrementedEvent(command.getOrderId(), productId));
    }

    @CommandHandler
    public void handle(DecrementProductCountCommand command) {
        if (orderConfirmed) {
            throw new OrderAlreadyConfirmedException(command.getOrderId());
        }

        if (count &amp;lt;= 1) {
            apply(new ProductRemovedEvent(command.getOrderId(), productId));
        } else {
            apply(new ProductCountDecrementedEvent(command.getOrderId(), productId));
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IncrementProductCountCommand, DecrementProductCountCommand를 통해 상품 갯수를 증감하고 갯수가 1개 이하일 때 요청을 받으면 상품을 제거합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EventSourcingHandler입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713881746622&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @EventSourcingHandler
    public void on(ProductCountIncrementedEvent event) {
        this.count++;
    }

    @EventSourcingHandler
    public void on(ProductCountDecrementedEvent event) {
        this.count--;
    }

    @EventSourcingHandler
    public void on(OrderConfirmedEvent event) {
        this.orderConfirmed = true;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ProductCountIncrementedEvent, ProductCountDecrementedEvent를 통해 상품 갯수를 증감시키며 상품 확인 상태를 변경합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713881818381&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        OrderLine orderLine = (OrderLine) o;
        return Objects.equals(productId, orderLine.productId) &amp;amp;&amp;amp; Objects.equals(count, orderLine.count);
    }

    @Override
    public int hashCode() {
        return Objects.hash(productId, count);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;equals, hashCode로 각 객체를 식별합니다. productId, orderLine.count를 이용합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;EventHandler&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 핸들러는 Query를 수행하고 관련된 Event를 발행합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ImMemoryOrdersEventHandler&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리에서 주문 쿼리를 수행하고 관련 이벤트를 발행합니다. @ProcessingGroup은 이벤트 핸들러 그룹의 이름을 지정합니다. 지정하지 않으면 기본값으로 패키지의 풀네임이 들어갑니다. @Profile은 해당 프로필에서만 Bean으로 등록하며 !, &amp;amp;, | 와 같은 연산자를 사용할 수 있습니다. 위에선 프로필이 mongo가 아닐 때만 등록되도록 명시되어 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713883003352&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@ProcessingGroup(&quot;orders&quot;)
@Profile(&quot;!mongo&quot;)
public class InMemoryOrdersEventHandler implements OrdersEventHandler {

    private final Map&amp;lt;String, Order&amp;gt; orders = new HashMap&amp;lt;&amp;gt;();
    private final QueryUpdateEmitter emitter;

    public InMemoryOrdersEventHandler(QueryUpdateEmitter emitter) {
        this.emitter = emitter;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EventHandler입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713884234736&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @EventHandler
    public void on(OrderCreatedEvent event) {
        String orderId = event.getOrderId();
        orders.put(orderId, new Order(orderId));
    }

    @EventHandler
    public void on(ProductAddedEvent event) {
        orders.computeIfPresent(event.getOrderId(), (orderId, order) -&amp;gt; {
            order.addProduct(event.getProductId());
            emitUpdate(order);
            return order;
        });
    }

    @EventHandler
    public void on(ProductCountIncrementedEvent event) {
        orders.computeIfPresent(event.getOrderId(), (orderId, order) -&amp;gt; {
            order.incrementProductInstance(event.getProductId());
            emitUpdate(order);
            return order;
        });
    }

    @EventHandler
    public void on(ProductCountDecrementedEvent event) {
        orders.computeIfPresent(event.getOrderId(), (orderId, order) -&amp;gt; {
            order.decrementProductInstance(event.getProductId());
            emitUpdate(order);
            return order;
        });
    }

    @EventHandler
    public void on(ProductRemovedEvent event) {
        orders.computeIfPresent(event.getOrderId(), (orderId, order) -&amp;gt; {
            order.removeProduct(event.getProductId());
            emitUpdate(order);
            return order;
        });
    }

    @EventHandler
    public void on(OrderConfirmedEvent event) {
        orders.computeIfPresent(event.getOrderId(), (orderId, order) -&amp;gt; {
            order.setOrderConfirmed();
            emitUpdate(order);
            return order;
        });
    }

    @EventHandler
    public void on(OrderShippedEvent event) {
        orders.computeIfPresent(event.getOrderId(), (orderId, order) -&amp;gt; {
            order.setOrderShipped();
            emitUpdate(order);
            return order;
        });
    }&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;computeIfPresent는 주어진 값이 주어진 람다 함수를 실행합니다. 각 이벤트에 따라서 주문 생성, 상품 증감, 상품 제거, 주문 상태 변경을 하는 모습을 볼 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713884668797&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private void emitUpdate(Order order) {
        emitter.emit(OrderUpdatesQuery.class, q -&amp;gt; order.getOrderId()
                .equals(q.getOrderId()), order);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;emitUpdate는 emitter에 Query의 업데이트를 전달하는 역할을 합니다. 2번 게시글의 AbstractOrdersEventHandler에서 QueryUpdateEmitter를 모킹해 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QueryHandler입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713885067350&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @QueryHandler
    public List&amp;lt;Order&amp;gt; handle(FindAllOrderedProductsQuery query) {
        return new ArrayList&amp;lt;&amp;gt;(orders.values());
    }

    @QueryHandler
    public Publisher&amp;lt;Order&amp;gt; handleStreaming(FindAllOrderedProductsQuery query) {
        return Mono.fromCallable(orders::values)
                .flatMapMany(Flux::fromIterable);
    }

    @QueryHandler
    public Integer handle(TotalProductsShippedQuery query) {
        return orders.values()
                .stream()
                .filter(o -&amp;gt; o.getOrderStatus() == OrderStatus.SHIPPED)
                .map(o -&amp;gt; Optional.ofNullable(o.getProducts()
                                .get(query.getProductId()))
                        .orElse(0))
                .reduce(0, Integer::sum);
    }

    @QueryHandler
    public Order handle(OrderUpdatesQuery query) {
        return orders.get(query.getOrderId());
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 주문을 동기적으로 혹은 Streaming으로 가져오는 모습을 볼 수 있습니다&lt;/p&gt;
&lt;pre id=&quot;code_1713885431439&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Override
    public void reset(List&amp;lt;Order&amp;gt; orderList) {
        orders.clear();
        orderList.forEach(o -&amp;gt; orders.put(o.getOrderId(), o));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;reset메소드는 테스트 코드에서 주문 데이터를 초기화하는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리 안에서 주문에 대한 Query를 받고 적절한 값을 리턴하는 모습을 볼 수 있습니다. 메모리 안에서만 작동하기 때문에 테스트 코드를 위한 코드로 보입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MongoConfiguration&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB의 설정 값을 지정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713886099069&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@Profile(&quot;mongo&quot;)
public class MongoConfiguration {

    @Bean
    public TokenStore getTokenStore(MongoClient client, Serializer serializer) {
        return MongoTokenStore.builder()
          .mongoTemplate(DefaultMongoTemplate.builder()
            .mongoDatabase(client)
            .build())
          .serializer(serializer)
          .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoTokenStore는 Axon에서 제공하는 mongo extension입니다. Axon docs에 따르면 이벤트 프로세서에서 어떤 이벤트가 처리 되었는지, 어떤 세그먼트가 요청 되었는지 추적하는데 사용된다 합니다. 자세한 내용은 아래 docs를 참고해주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.axoniq.io/reference-guide/extensions/mongo&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.axoniq.io/reference-guide/extensions/mongo&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1713887299379&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;Mongo | 4.9 | Axon Reference Guide&quot; data-og-description=&quot;In pre Axon Framework 3 release we found MongoDb to be a very good fit as an Event Store. However, with the introduction of Tracking Event Processors and how they track their events, we have encountered some inefficiencies regarding the Mongo Event Store i&quot; data-og-host=&quot;docs.axoniq.io&quot; data-og-source-url=&quot;https://docs.axoniq.io/reference-guide/extensions/mongo&quot; data-og-url=&quot;https://docs.axoniq.io/reference-guide/extensions/mongo&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cZ5ljv/hyVVJtklvv/PrU1MNHNpl5R7UU9Pdkd60/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/GZoHS/hyVVDGE4qE/Zneqx3IguBHdXizIX4Cw2K/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://docs.axoniq.io/reference-guide/extensions/mongo&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.axoniq.io/reference-guide/extensions/mongo&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cZ5ljv/hyVVJtklvv/PrU1MNHNpl5R7UU9Pdkd60/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/GZoHS/hyVVDGE4qE/Zneqx3IguBHdXizIX4Cw2K/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;Mongo | 4.9 | Axon Reference Guide&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;In pre Axon Framework 3 release we found MongoDb to be a very good fit as an Event Store. However, with the introduction of Tracking Event Processors and how they track their events, we have encountered some inefficiencies regarding the Mongo Event Store i&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.axoniq.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MongoOrdersEventHandler&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몽고 DB의 이벤트 핸들러입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713887751892&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@ProcessingGroup(&quot;orders&quot;)
@Profile(&quot;mongo&quot;)
public class MongoOrdersEventHandler implements OrdersEventHandler {

    static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup()
            .lookupClass());
    private static final String ORDER_COLLECTION_NAME = &quot;orders&quot;;
    private static final String AXON_FRAMEWORK_DATABASE_NAME = &quot;axonframework&quot;;
    private static final String ORDER_ID_PROPERTY_NAME = &quot;orderId&quot;;
    private static final String PRODUCTS_PROPERTY_NAME = &quot;products&quot;;
    private static final String ORDER_STATUS_PROPERTY_NAME = &quot;orderStatus&quot;;
    private final MongoCollection&amp;lt;Document&amp;gt; orders;
    private final QueryUpdateEmitter emitter;

    public MongoOrdersEventHandler(MongoClient client, QueryUpdateEmitter emitter) {
        orders = client.getDatabase(AXON_FRAMEWORK_DATABASE_NAME)
                .getCollection(ORDER_COLLECTION_NAME);
        orders.createIndex(Indexes.ascending(ORDER_ID_PROPERTY_NAME), new IndexOptions().unique(true));
        this.emitter = emitter;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로거를 생성하고 각 프로퍼티와 데이터베이스 이름을 정의합니다. 그리고 MongoDB에 삽입할 주문 목록과 마찬가지로 emitter를 선언합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자에선 MongoClient를 통해서 주문 목록을 가져오고 오름차순으로 인덱스를 생성해줍니다. 그리고 emiiter를 정의합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EventHandler입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713888410697&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @EventHandler
    public void on(OrderCreatedEvent event) {
        orders.insertOne(orderToDocument(new Order(event.getOrderId())));
    }

    @EventHandler
    public void on(ProductAddedEvent event) {
        update(event.getOrderId(), o -&amp;gt; o.addProduct(event.getProductId()));
    }

    @EventHandler
    public void on(ProductCountIncrementedEvent event) {
        update(event.getOrderId(), o -&amp;gt; o.incrementProductInstance(event.getProductId()));
    }

    @EventHandler
    public void on(ProductCountDecrementedEvent event) {
        update(event.getOrderId(), o -&amp;gt; o.decrementProductInstance(event.getProductId()));
    }

    @EventHandler
    public void on(ProductRemovedEvent event) {
        update(event.getOrderId(), o -&amp;gt; o.removeProduct(event.getProductId()));
    }

    @EventHandler
    public void on(OrderConfirmedEvent event) {
        update(event.getOrderId(), Order::setOrderConfirmed);
    }

    @EventHandler
    public void on(OrderShippedEvent event) {
        update(event.getOrderId(), Order::setOrderShipped);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 이벤트 핸들러에서 MongoDB에 주문 인덱스에 Document 삽입, 주문에 상품 추가, 주문 상태 변경 등의 수행하고 MongoDB에 반영합니다. 또한 emitter로 주문의 변경을 알립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 로직입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713891834597&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private Optional&amp;lt;Order&amp;gt; getOrder(String orderId) {
        return Optional.ofNullable(orders.find(eq(ORDER_ID_PROPERTY_NAME, orderId))
                        .first())
                .map(this::documentToOrder);
    }

    private Order emitUpdate(Order order) {
        emitter.emit(OrderUpdatesQuery.class, q -&amp;gt; order.getOrderId()
                .equals(q.getOrderId()), order);
        return order;
    }

    private Order updateOrder(Order order, Consumer&amp;lt;Order&amp;gt; updateFunction) {
        updateFunction.accept(order);
        return order;
    }

    private UpdateResult persistUpdate(Order order) {
        return orders.replaceOne(eq(ORDER_ID_PROPERTY_NAME, order.getOrderId()), orderToDocument(order));
    }

    private void update(String orderId, Consumer&amp;lt;Order&amp;gt; updateFunction) {
        UpdateResult result = getOrder(orderId).map(o -&amp;gt; updateOrder(o, updateFunction))
                .map(this::emitUpdate)
                .map(this::persistUpdate)
                .orElse(null);
        logger.info(&quot;Result of updating order with orderId '{}': {}&quot;, orderId, result);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Document의 Collections에서 주문을 가져와 주문 객체로 변환하는 것과 emitter, 주문, 실제 저장 등의 업데이트 로직을 볼 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713891936889&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private Document orderToDocument(Order order) {
        return new Document(ORDER_ID_PROPERTY_NAME, order.getOrderId()).append(PRODUCTS_PROPERTY_NAME, order.getProducts())
                .append(ORDER_STATUS_PROPERTY_NAME, order.getOrderStatus()
                        .toString());
    }

    private Order documentToOrder(@NonNull Document document) {
        Order order = new Order(document.getString(ORDER_ID_PROPERTY_NAME));
        Document products = document.get(PRODUCTS_PROPERTY_NAME, Document.class);
        products.forEach((k, v) -&amp;gt; order.getProducts()
                .put(k, (Integer) v));
        String status = document.getString(ORDER_STATUS_PROPERTY_NAME);
        if (OrderStatus.CONFIRMED.toString()
                .equals(status)) {
            order.setOrderConfirmed();
        } else if (OrderStatus.SHIPPED.toString()
                .equals(status)) {
            order.setOrderShipped();
        }
        return order;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문과 Document를 각각 변환하는 함수입니다. 복잡해 보이지만 단순히 각 데이터 타입에 맞게 변환해주는 로직입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713892144122&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private Bson shippedProductFilter(String productId) {
        return and(eq(ORDER_STATUS_PROPERTY_NAME, OrderStatus.SHIPPED.toString()), exists(String.format(PRODUCTS_PROPERTY_NAME + &quot;.%s&quot;, productId)));
    }&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;OrderEventHandler입니다. 주문 이벤트를 관리하는 인터페이스가 선언되어 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713895886414&quot; class=&quot;cs&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface OrdersEventHandler {

    void on(OrderCreatedEvent event);

    void on(ProductAddedEvent event);

    void on(ProductCountIncrementedEvent event);

    void on(ProductCountDecrementedEvent event);

    void on(ProductRemovedEvent event);

    void on(OrderConfirmedEvent event);

    void on(OrderShippedEvent event);

    List&amp;lt;Order&amp;gt; handle(FindAllOrderedProductsQuery query);

    Publisher&amp;lt;Order&amp;gt; handleStreaming(FindAllOrderedProductsQuery query);

    Integer handle(TotalProductsShippedQuery query);

    Order handle(OrderUpdatesQuery query);

    void reset(List&amp;lt;Order&amp;gt; orderList);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;QueryService&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderQueryService&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 쿼리 서비스입니다. 주문에 대한 쿼리를 수행해 주문 DTO를 반환하고 주문 DTO를 OrderResponse vo로 변환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713895042206&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class OrderQueryService {

    private final QueryGateway queryGateway;

    public OrderQueryService(QueryGateway queryGateway) {
        this.queryGateway = queryGateway;
    }&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;Query 비즈니스 로직입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713895138759&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public CompletableFuture&amp;lt;List&amp;lt;OrderResponse&amp;gt;&amp;gt; findAllOrders() {
        return queryGateway.query(new FindAllOrderedProductsQuery(), ResponseTypes.multipleInstancesOf(Order.class))
                .thenApply(r -&amp;gt; r.stream()
                        .map(OrderResponse::new)
                        .collect(Collectors.toList()));
    }

    public Flux&amp;lt;OrderResponse&amp;gt; allOrdersStreaming() {
        Publisher&amp;lt;Order&amp;gt; publisher = queryGateway.streamingQuery(new FindAllOrderedProductsQuery(), Order.class);
        return Flux.from(publisher)
                .map(OrderResponse::new);
    }

    public Integer totalShipped(String productId) {
        return queryGateway.scatterGather(new TotalProductsShippedQuery(productId), ResponseTypes.instanceOf(Integer.class), 10L, TimeUnit.SECONDS)
                .reduce(0, Integer::sum);
    }

    public Flux&amp;lt;OrderResponse&amp;gt; orderUpdates(String orderId) {
        return subscriptionQuery(new OrderUpdatesQuery(orderId), ResponseTypes.instanceOf(Order.class)).map(OrderResponse::new);
    }

    private &amp;lt;Q, R&amp;gt; Flux&amp;lt;R&amp;gt; subscriptionQuery(Q query, ResponseType&amp;lt;R&amp;gt; resultType) {
        return Flux.using(
                () -&amp;gt; queryGateway.subscriptionQuery(query, resultType, resultType),
                ret -&amp;gt; Flux.from(ret.initialResult()),
                SubscriptionQueryResult::close
        );
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 주문을 비동기 혹은 Streaming으로 반환하거나 모든 배송 상태 상품의 갯수, 주문 쿼리 구독 함수를 볼 수 있습니다. 주문 쿼리 구독 함수에선 Flux를 반환하여 Server Send Event 형태로 통신합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderResponse&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문에 대한 반환을 표현하는 vo입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713895845636&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class OrderResponse {

    private final String orderId;
    private final Map&amp;lt;String, Integer&amp;gt; products;
    private final OrderStatusResponse orderStatus;

    OrderResponse(Order order) {
        this.orderId = order.getOrderId();
        this.products = order.getProducts();
        this.orderStatus = toResponse(order.getOrderStatus());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자에게 반환할 멤버와 생성자로 이루어져 있습니다. 마찬가지로 vo에는 사용자에게 보여줘도 되는 멤버만 포함되어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 상태 enum입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713895953765&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public enum OrderStatusResponse {
    CREATED, CONFIRMED, SHIPPED, UNKNOWN;

    static OrderStatusResponse toResponse(OrderStatus status) {
        for (OrderStatusResponse response : values()) {
            if (response.toString()
              .equals(status.toString())) {
                return response;
            }
        }
        return UNKNOWN;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 OrderResponse의 orderStatus 멤버의 타입으로 사용됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로써 해당 예제를 모두 둘러보았습니다. 이번 리뷰를 통해서 Command는 Aggregate에서 CommandGateway를 통해 Command를 수행하고 관련 Event를 발행, Query는 QueryService에서 QueryGateway를 통해 Query를 수행하고 EventHandler에서 적절한 vo로 변환해 반환하며 QueryService에서 엔드포인트(API)로 데이터를 반환한다는 것을 알았습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;여기서 주의해야 할 점은 OrderAggregate에서 CommandHandler를 정의하지만 실제 Event에 의한 데이터 CRUD는 EventHandler에서 일어난다는 점입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에선 제가 좋아하는 방식으로 프로젝트를 리팩토링하고, 유저 도메인을 추가 해보겠습니다.&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <category>AXON</category>
      <category>Command</category>
      <category>Event</category>
      <category>EventSourcing</category>
      <category>Order</category>
      <category>query</category>
      <category>SpirngBoot</category>
      <category>Spring</category>
      <category>주문</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/201</guid>
      <comments>https://saysimple.tistory.com/201#entry201comment</comments>
      <pubDate>Tue, 23 Apr 2024 23:14:50 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 2</title>
      <link>https://saysimple.tistory.com/199</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;1편: &lt;a href=&quot;https://saysimple.tistory.com/197&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://saysimple.tistory.com/197&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1713685132137&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] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 1&quot; data-og-description=&quot;CQRS에 대한 정보는 아래 글을 참고해주세요. https://saysimple.tistory.com/196 [CQRS] CQRS란? CQRS CQRS란 Command and Query Responsibility Segregation 의 약자입니다. 저장소로부터 질의/명령을 이용하여 읽기/ 쓰기 작&quot; data-og-host=&quot;saysimple.tistory.com&quot; data-og-source-url=&quot;https://saysimple.tistory.com/197&quot; data-og-url=&quot;https://saysimple.tistory.com/197&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/B9WYm/hyVSYrqoII/ISKrwJdstbf0LT9fHL8ht1/img.png?width=529&amp;amp;height=619&amp;amp;face=0_0_529_619,https://scrap.kakaocdn.net/dn/bDb0ps/hyVSWf3EBX/1MxZZM9KPfIVNsjYNkzpUK/img.png?width=529&amp;amp;height=619&amp;amp;face=0_0_529_619,https://scrap.kakaocdn.net/dn/bdTPyw/hyVS5D3ypz/Va2PUBlOZlJhLe76FpF2eK/img.png?width=904&amp;amp;height=840&amp;amp;face=0_0_904_840&quot;&gt;&lt;a href=&quot;https://saysimple.tistory.com/197&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://saysimple.tistory.com/197&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/B9WYm/hyVSYrqoII/ISKrwJdstbf0LT9fHL8ht1/img.png?width=529&amp;amp;height=619&amp;amp;face=0_0_529_619,https://scrap.kakaocdn.net/dn/bDb0ps/hyVSWf3EBX/1MxZZM9KPfIVNsjYNkzpUK/img.png?width=529&amp;amp;height=619&amp;amp;face=0_0_529_619,https://scrap.kakaocdn.net/dn/bdTPyw/hyVS5D3ypz/Va2PUBlOZlJhLe76FpF2eK/img.png?width=904&amp;amp;height=840&amp;amp;face=0_0_904_840');&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] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 1&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;CQRS에 대한 정보는 아래 글을 참고해주세요. https://saysimple.tistory.com/196 [CQRS] CQRS란? CQRS CQRS란 Command and Query Responsibility Segregation 의 약자입니다. 저장소로부터 질의/명령을 이용하여 읽기/ 쓰기 작&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;saysimple.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번 글에선 Axon을 이용한 CQRS구조를 Postman을 이용해 테스트 해보았습니다. 이번 글에선 유닛 테스트, 통합 테스트를 통해 테스트의 시나리오를 구성하는 방법과 테스트 코드를 리뷰해 보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;폴더 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 파일의 구조는 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;commandmodel
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OrderAggregateUnitTest.java&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;gui
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OrderRestEndpointManualTest.java&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;querymodel
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AbstractOrdersEventHandlerUnitTest.java&lt;/li&gt;
&lt;li&gt;InMemoryOrdersEventHandlerUnitTest.java&lt;/li&gt;
&lt;li&gt;MongoOrdersEventHandlerLiveTest.java&lt;/li&gt;
&lt;li&gt;OrderQueryServiceIntergrationTest.java&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderAggregateUnitTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커맨드 모델인 주문 Aggregate안의 유닛에서 발생할 수 있는 시나리오를 테스트합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713685822820&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class OrderAggregateUnitTest {

    private static final String ORDER_ID = UUID.randomUUID()
      .toString();
    private static final String PRODUCT_ID = UUID.randomUUID()
      .toString();

    private FixtureConfiguration&amp;lt;OrderAggregate&amp;gt; fixture;

    @BeforeEach
    void setUp() {
        fixture = new AggregateTestFixture&amp;lt;&amp;gt;(OrderAggregate.class);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문, 상품 아이디와 fixture를 정의합니다. fixture는 테스트 코드에서 미리 정의하고 재사용하는 변수, 객체 등을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드의 이름은 전반적으로 given, when, then으로 이뤄집니다. Axon에서 제공하는 테스트 fixture에서 이벤트 기반으로 정의한 given, when, expect 함수를 따라가는 모습입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;given: ~한 상황일 때&lt;/li&gt;
&lt;li&gt;when: ~가 발생하면&lt;/li&gt;
&lt;li&gt;then: ~가 기대된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정리할 수 있어 보입니다. 유닛 테스트는 코드만 빠르게 검토하고 넘어가겠습니다. 설명은 @DisplayName을 참고해주세요. 제가 따로 작성한 것이어서 틀린 내용이 있을 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713685892234&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 커맨드를 받으면 주문 생성 이벤트를 발행한다.&quot;)
    void giveNoPriorActivity_whenCreateOrderCommand_thenShouldPublishOrderCreatedEvent() {
        fixture.givenNoPriorActivity()
          .when(new CreateOrderCommand(ORDER_ID))
          .expectEvents(new OrderCreatedEvent(ORDER_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 고의로 틀리게 바꿔서 작성해 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713703684682&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 커맨드를 받으면 주문 생성 이벤트를 발행한다.&quot;)
    void giveNoPriorActivity_whenCreateOrderCommand_thenShouldPublishOrderCreatedEvent() {
        fixture.givenNoPriorActivity()
          .when(new CreateOrderCommand(ORDER_ID))
          .expectEvents(new ProductAddedEvent(ORDER_ID, PRODUCT_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기대되는 이벤트를 ProductAddedEvent로 변경했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713704176667&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.axonframework.test.AxonAssertionError: The published events do not match the expected events

Expected                                             |  Actual
-----------------------------------------------------|-----------------------------------------------------
com.saysimple.axon.coreapi.events.ProductAddedEvent &amp;lt;|&amp;gt; com.saysimple.axon.coreapi.events.OrderCreatedEvent&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 기대되는 이벤트는 OrderCreatedEvent인데 ProductAddedEvent가 발생해 테스트 통과에 실패한 것을 확인할 수 있습니다. 아래부터는 나머지 테스트의 코드를 둘러보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713686429111&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트를 받고 상품 추가 커맨드를 받으면 상품 추가 이벤트를 발행한다.&quot;)
    void givenOrderCreatedEvent_whenAddProductCommand_thenShouldPublishProductAddedEvent() {
        fixture.given(new OrderCreatedEvent(ORDER_ID))
          .when(new AddProductCommand(ORDER_ID, PRODUCT_ID))
          .expectEvents(new ProductAddedEvent(ORDER_ID, PRODUCT_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686511646&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 상품 추가 이벤트를 받고 동일한 상품 추가 커맨드를 받으면 중복 주문 라인 예외를 발생시킨다.&quot;)
    void givenOrderCreatedEventAndProductAddedEvent_whenAddProductCommandForSameProductId_thenShouldThrowDuplicateOrderLineException() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID))
          .when(new AddProductCommand(ORDER_ID, PRODUCT_ID))
          .expectException(DuplicateOrderLineException.class)
          .expectExceptionMessage(Matchers.predicate(message -&amp;gt; ((String) message).contains(PRODUCT_ID)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686520328&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 상품 추가 이벤트를 받고 상품 추가 커맨드를 받으면 상품 추가 이벤트를 발행한다.&quot;)
    void givenOrderCreatedEventAndProductAddedEvent_whenIncrementProductCountCommand_thenShouldPublishProductCountIncrementedEvent() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID))
          .when(new IncrementProductCountCommand(ORDER_ID, PRODUCT_ID))
          .expectEvents(new ProductCountIncrementedEvent(ORDER_ID, PRODUCT_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686529559&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 상품 추가 이벤트와 상품 수량 증가 이벤트를 받고 상품 수량 감소 커맨드를 받으면 상품 수량 감소 이벤트를 발행한다.&quot;)
    void givenOrderCreatedEventProductAddedEventAndProductCountIncrementedEvent_whenDecrementProductCountCommand_thenShouldPublishProductCountDecrementedEvent() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID), new ProductCountIncrementedEvent(ORDER_ID, PRODUCT_ID))
          .when(new DecrementProductCountCommand(ORDER_ID, PRODUCT_ID))
          .expectEvents(new ProductCountDecrementedEvent(ORDER_ID, PRODUCT_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686540861&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 상품 추가 이벤트를 받고 상품 수량 감소 커맨드를 받으면 상품 제거 이벤트를 발행한다.&quot;)
    void givenOrderCreatedEventAndProductAddedEvent_whenDecrementProductCountCommand_thenShouldPublishProductRemovedEvent() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID))
          .when(new DecrementProductCountCommand(ORDER_ID, PRODUCT_ID))
          .expectEvents(new ProductRemovedEvent(ORDER_ID, PRODUCT_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686548429&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트를 받고 주문 확인 커맨드를 받으면 주문 확인 이벤트를 발행한다.&quot;)
    void givenOrderCreatedEvent_whenConfirmOrderCommand_thenShouldPublishOrderConfirmedEvent() {
        fixture.given(new OrderCreatedEvent(ORDER_ID))
          .when(new ConfirmOrderCommand(ORDER_ID))
          .expectEvents(new OrderConfirmedEvent(ORDER_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686557579&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 주문 확인 이벤트를 받고 주문 확인 커맨드를 받으면 이벤트를 발행하지 않는다.&quot;)
    void givenOrderCreatedEventAndOrderConfirmedEvent_whenConfirmOrderCommand_thenExpectNoEvents() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new OrderConfirmedEvent(ORDER_ID))
          .when(new ConfirmOrderCommand(ORDER_ID))
          .expectNoEvents();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686566729&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트를 받고 주문 배송 커맨드를 받으면 미확인 주문 예외를 발생시킨다.&quot;)
    void givenOrderCreatedEvent_whenShipOrderCommand_thenShouldThrowUnconfirmedOrderException() {
        fixture.given(new OrderCreatedEvent(ORDER_ID))
          .when(new ShipOrderCommand(ORDER_ID))
          .expectException(UnconfirmedOrderException.class);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686574676&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 주문 확인 이벤트를 받고 주문 배송 커맨드를 받으면 주문 배송 이벤트를 발행한다.&quot;)
    void givenOrderCreatedEventAndOrderConfirmedEvent_whenShipOrderCommand_thenShouldPublishOrderShippedEvent() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new OrderConfirmedEvent(ORDER_ID))
          .when(new ShipOrderCommand(ORDER_ID))
          .expectEvents(new OrderShippedEvent(ORDER_ID));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686583063&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 주문 확인 이벤트를 받고 상품 추가 커맨드를 받으면 주문 확인 예외를 발생시키고 주문 확인 예외 메시지에 주문 아이디를 포함한다.&quot;)
    void givenOrderCreatedEventProductAndOrderConfirmedEvent_whenAddProductCommand_thenShouldThrowOrderAlreadyConfirmedException() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new OrderConfirmedEvent(ORDER_ID))
          .when(new AddProductCommand(ORDER_ID, PRODUCT_ID))
          .expectException(OrderAlreadyConfirmedException.class)
          .expectExceptionMessage(Matchers.predicate(message -&amp;gt; ((String) message).contains(ORDER_ID)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686593782&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 상품 추가 이벤트와 주문 확인 이벤트를 받고 상품 수량 증가 커맨드를 받으면 주문 확인 예외를 발생시키고 주문 확인 예외 메시지에 주문 아이디를 포함한다.&quot;)
    void givenOrderCreatedEventProductAddedEventAndOrderConfirmedEvent_whenIncrementProductCountCommand_thenShouldThrowOrderAlreadyConfirmedException() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID), new OrderConfirmedEvent(ORDER_ID))
          .when(new IncrementProductCountCommand(ORDER_ID, PRODUCT_ID))
          .expectException(OrderAlreadyConfirmedException.class)
          .expectExceptionMessage(Matchers.predicate(message -&amp;gt; ((String) message).contains(ORDER_ID)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713686602677&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트와 상품 추가 이벤트와 주문 확인 이벤트를 받고 상품 수량 감소 커맨드를 받으면 이미 확인된 주문 예외를 발생시키고 주문 확인 예외 메시지에 주문 아이디를 포함한다.&quot;)
    void givenOrderCreatedEventProductAddedEventAndOrderConfirmedEvent_whenDecrementProductCountCommand_thenShouldThrowOrderAlreadyConfirmedException() {
        fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID), new OrderConfirmedEvent(ORDER_ID))
          .when(new DecrementProductCountCommand(ORDER_ID, PRODUCT_ID))
          .expectException(OrderAlreadyConfirmedException.class)
          .expectExceptionMessage(Matchers.predicate(message -&amp;gt; ((String) message).contains(ORDER_ID)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분 주문 Aggregate에서 발생할 수 있는 시나리오와, 기대되는 상황이 올바르게 발생 하였는가 테스트 하는 내용을 볼 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;InMemoryOrdersEventHandlerUnitTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리 객체에서 이벤트를 테스트하는 유닛 테스트입니다. 추상 주문 이벤트 핸들러 유닛 테스트를 상속 받아 테스트를 실행합니다. handler에 대한 리뷰는 다음 글에서 진행할 예정입니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public class InMemoryOrdersEventHandlerUnitTest extends AbstractOrdersEventHandlerUnitTest {

    @Override
    protected OrdersEventHandler getHandler() {
        return new InMemoryOrdersEventHandler(emitter);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MongoOrdersEventHandlerLiveTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몽고 디비에서 이벤트를 테스트하는 라이브 테스트입니다. 실제 가상 컨테이너를 띄워 마찬가지로 추상 주문 이벤트 핸들러 유닛 테스트를 상속 받아 테스트를 실행합니다. @DynamicPropertySource는 동적으로 스프링 부트의 환경 변수를 초기화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몽고 디비 컨테이너를 생성하고 uri를 가져와 초기화 한 뒤 핸들러를 초기화 하는 함수를 오버라이드 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713697055819&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@DataMongoTest
public class MongoOrdersEventHandlerLiveTest extends AbstractOrdersEventHandlerUnitTest {

    @Autowired
    MongoClient mongoClient;

    @Container
    static MongoDBContainer mongoDBContainer = new MongoDBContainer(&quot;mongo:6.0&quot;).withExposedPorts(27017);

    @DynamicPropertySource
    static void mongoDbProperties(DynamicPropertyRegistry registry) {

        mongoDBContainer.start();
        registry.add(&quot;spring.data.mongodb.uri&quot;, mongoDBContainer::getReplicaSetUrl);
    }



    @Override
    protected OrdersEventHandler getHandler() {
        mongoClient.getDatabase(&quot;axonframework&quot;)
          .drop();
        return new MongoOrdersEventHandler(mongoClient, emitter);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AbstractOrdersEventhandlerUnitTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추상 주문 이벤트 핸들러의 유닛 테스트입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713686821782&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public abstract class AbstractOrdersEventHandlerUnitTest {

    private static final String ORDER_ID_1 = UUID.randomUUID()
      .toString();
    private static final String ORDER_ID_2 = UUID.randomUUID()
      .toString();
    private static final String PRODUCT_ID_1 = UUID.randomUUID()
      .toString();
    private static final String PRODUCT_ID_2 = UUID.randomUUID()
      .toString();
    private OrdersEventHandler handler;
    private static Order orderOne;
    private static Order orderTwo;
    QueryUpdateEmitter emitter = mock(QueryUpdateEmitter.class);

    @BeforeAll
    static void createOrders() {
        orderOne = new Order(ORDER_ID_1);
        orderOne.getProducts()
          .put(PRODUCT_ID_1, 3);
        orderOne.setOrderShipped();

        orderTwo = new Order(ORDER_ID_2);
        orderTwo.getProducts()
          .put(PRODUCT_ID_1, 1);
        orderTwo.getProducts()
          .put(PRODUCT_ID_2, 1);
        orderTwo.setOrderConfirmed();
    }
    
    @BeforeEach
    void setUp() {
        handler = getHandler();
    }

    protected abstract OrdersEventHandler getHandler();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 2개의 주문과 상품 아이디를 선언하고, 주문 이벤트 핸들러, 에미터를 선언합니다. 에미터는 QueryUpdateEmitter를 모킹하며 QueryUpdateEmitter는 Axon에서 제공합니다. @BeforeAll은 모든 테스트가 실행되기 전에 한 번만 실행됩니다. @BeforeEach는 각 테스트가 실행되기 전에 실행됩니다. createOrders는 주문 생성, 상품 추가를 한 후, 각각 배송 상태, 확인 상태로 변경합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713687460391&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 수행하면 두 개의 주문이 반환된다.&quot;)
    void givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQuery_thenCorrectOrdersAreReturned() {
        resetWithTwoOrders();
        List&amp;lt;Order&amp;gt; result = handler.handle(new FindAllOrderedProductsQuery());

        assertNotNull(result);
        assertEquals(2, result.size());

        Order order_1 = result.stream()
          .filter(o -&amp;gt; o.getOrderId()
            .equals(ORDER_ID_1))
          .findFirst()
          .orElse(null);
        assertEquals(orderOne, order_1);

        Order order_2 = result.stream()
          .filter(o -&amp;gt; o.getOrderId()
            .equals(ORDER_ID_2))
          .findFirst()
          .orElse(null);
        assertEquals(orderTwo, order_2);
    }&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;/p&gt;
&lt;pre id=&quot;code_1713691878814&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BUILD SUCCESSFUL in 1s
7 actionable tasks: 2 executed, 5 up-to-date
6:30:55 PM: Execution finished ':domains:cqrs:test --tests &quot;com.saysimple.axon.querymodel.InMemoryOrdersEventHandlerUnitTest.givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQuery_thenCorrectOrdersAreReturned&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;해당 테스트의 Order order_1 = result.stream() 의 하위에서 오더 아이디를 2로 바꿔서 테스트를 진행해 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713691768194&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Expected :Order{orderId='2c0f5a7f-f0e9-4159-a35c-9b3862cdc1c5', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=3}, orderStatus=SHIPPED}
Actual   :Order{orderId='3666b6aa-6585-4775-b551-33a0616bb895', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=1, 8e874d45-025c-4dd6-a794-c83c7ca27680=1}, orderStatus=CONFIRMED}
&amp;lt;Click to see difference&amp;gt;

org.opentest4j.AssertionFailedError: expected: &amp;lt;Order{orderId='2c0f5a7f-f0e9-4159-a35c-9b3862cdc1c5', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=3}, orderStatus=SHIPPED}&amp;gt; but was: &amp;lt;Order{orderId='3666b6aa-6585-4775-b551-33a0616bb895', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=1, 8e874d45-025c-4dd6-a794-c83c7ca27680=1}, orderStatus=CONFIRMED}&amp;gt;
...

Failed to map supported failure 'org.opentest4j.AssertionFailedError: expected: &amp;lt;Order{orderId='2c0f5a7f-f0e9-4159-a35c-9b3862cdc1c5', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=3}, orderStatus=SHIPPED}&amp;gt; but was: &amp;lt;Order{orderId='3666b6aa-6585-4775-b551-33a0616bb895', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=1, 8e874d45-025c-4dd6-a794-c83c7ca27680=1}, orderStatus=CONFIRMED}&amp;gt;' with mapper 'org.gradle.api.internal.tasks.testing.failure.mappers.OpenTestAssertionFailedMapper@630d1b2f': Cannot invoke &quot;Object.getClass()&quot; because &quot;expectedValue&quot; is null
&amp;gt; Task :domains:cqrs:test FAILED
InMemoryOrdersEventHandlerUnitTest &amp;gt; 두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 수행하면 두 개의 주문이 반환된다. FAILED
    org.opentest4j.AssertionFailedError at AssertionFailureBuilder.java:151
1 test completed, 1 failed&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trace 부분은 ...으로 생략 하였습니다. 보시는 것과 같이 오더 아이디가 달라 테스트가 실패하는 것을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StepVerifier는 reactor-test에서 제공하는 Streaming 용 테스트 라이브러리입니다. create에서 Flux로 부터 Publisher 등을 받으면 assertNext에 Consumer를 파라미터로 제공해 다음에 발행되는 데이터를 검증할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713691911558&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  @Test
    @DisplayName(&quot;두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 스트리밍으로 수행하면 두 개의 주문이 반환된다.&quot;)
    void givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQueryStreaming_thenCorrectOrdersAreReturned() {
        resetWithTwoOrders();
        final Consumer&amp;lt;Order&amp;gt; orderVerifier = order -&amp;gt; {
            if (order.getOrderId()
              .equals(orderOne.getOrderId())) {
                assertEquals(orderOne, order);
            } else if (order.getOrderId()
              .equals(orderTwo.getOrderId())) {
                assertEquals(orderTwo, order);
            } else {
                throw new RuntimeException(&quot;Would expect either order one or order two&quot;);
            }
        };

        StepVerifier.create(Flux.from(handler.handleStreaming(new FindAllOrderedProductsQuery())))
          .assertNext(orderVerifier)
          .assertNext(orderVerifier)
          .expectComplete()
          .verify();
    }&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;테스트에 대한 설명은 DisplayName을 참고 해주시기 바랍니다. 위에서 테스트를 성공 및 실패 시켜 보았으니 이제부턴 실제 테스트는 생략하겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713692843952&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Test
    @DisplayName(&quot;두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 스트리밍으로 수행하면 두 개의 주문이 반환된다.&quot;)
    void givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQueryStreaming_thenCorrectOrdersAreReturned() {
        resetWithTwoOrders();
        final Consumer&amp;lt;Order&amp;gt; orderVerifier = order -&amp;gt; {
            if (order.getOrderId()
              .equals(orderOne.getOrderId())) {
                assertEquals(orderOne, order);
            } else if (order.getOrderId()
              .equals(orderTwo.getOrderId())) {
                assertEquals(orderTwo, order);
            } else {
                throw new RuntimeException(&quot;Would expect either order one or order two&quot;);
            }
        };

        StepVerifier.create(Flux.from(handler.handleStreaming(new FindAllOrderedProductsQuery())))
          .assertNext(orderVerifier)
          .assertNext(orderVerifier)
          .expectComplete()
          .verify();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692854883&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문이 없는 경우 총 배송된 제품 쿼리를 수행하면 0이 반환된다.&quot;)
    void givenNoOrdersPlaced_whenTotalProductsShippedQuery_thenZeroReturned() {
        assertEquals(0, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_1)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692866384&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;두 개의 주문을 초기화 한 후 각 주문의 총 배송된 제품 쿼리를 수행하면 각 주문의 제품 수가 반환된다.&quot;)
    void givenTwoOrdersPlacedOfWhichOneNotShipped_whenTotalProductsShippedQuery_thenOnlyCountProductsFirstOrder() {
        resetWithTwoOrders();

        assertEquals(3, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_1)));
        assertEquals(0, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_2)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692875715&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;두 개의 주문을 초기화 한 후 두 번째 주문에 대한 주문 배송 이벤트를 수행하면 두 번째 주문의 제품 수가 결과에 반영되어야 한다.&quot;)
    void givenTwoOrdersPlacedAndShipped_whenTotalProductsShippedQuery_thenCountBothOrders() {
        resetWithTwoOrders();
        handler.on(new OrderShippedEvent(ORDER_ID_2));

        assertEquals(4, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_1)));
        assertEquals(1, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_2)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692884864&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;1번 주문에 대한 주문 갱신 쿼리를 수행하면 1번 주문이 반환되고 3개의 제품이 포함되어야 한다.&quot;)
    void givenOrderExist_whenOrderUpdatesQuery_thenOrderReturned() {
        resetWithTwoOrders();

        Order result = handler.handle(new OrderUpdatesQuery(ORDER_ID_1));
        assertNotNull(result);
        assertEquals(ORDER_ID_1, result.getOrderId());
        assertEquals(3, result.getProducts()
          .get(PRODUCT_ID_1));
        assertEquals(OrderStatus.SHIPPED, result.getOrderStatus());
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692893467&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.&quot;)
    void givenOrderExist_whenProductAddedEvent_thenUpdateEmittedOnce() {
        handler.on(new OrderCreatedEvent(ORDER_ID_1));

        handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));

        verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692902631&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 상품 감소 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.&quot;)
    void givenOrderWithProductExist_whenProductCountDecrementedEvent_thenUpdateEmittedOnce() {
        handler.on(new OrderCreatedEvent(ORDER_ID_1));
        handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
        reset(emitter);

        handler.on(new ProductCountDecrementedEvent(ORDER_ID_1, PRODUCT_ID_1));

        verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692910534&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 상품 제거 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.&quot;)
    void givenOrderWithProductExist_whenProductRemovedEvent_thenUpdateEmittedOnce() {
        handler.on(new OrderCreatedEvent(ORDER_ID_1));
        handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
        reset(emitter);

        handler.on(new ProductRemovedEvent(ORDER_ID_1, PRODUCT_ID_1));

        verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692954116&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 상품 증가 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.&quot;)
    void givenOrderWithProductExist_whenProductCountIncrementedEvent_thenUpdateEmittedOnce() {
        handler.on(new OrderCreatedEvent(ORDER_ID_1));
        handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
        reset(emitter);

        handler.on(new ProductCountIncrementedEvent(ORDER_ID_1, PRODUCT_ID_1));

        verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692980050&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 주문 확인 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.&quot;)
    void givenOrderWithProductExist_whenOrderConfirmedEvent_thenUpdateEmittedOnce() {
        handler.on(new OrderCreatedEvent(ORDER_ID_1));
        handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
        reset(emitter);

        handler.on(new OrderConfirmedEvent(ORDER_ID_1));

        verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713692989184&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 주문 배송 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.&quot;)
    void givenOrderWithProductAndConfirmationExist_whenOrderShippedEvent_thenUpdateEmittedOnce() {
        handler.on(new OrderCreatedEvent(ORDER_ID_1));
        handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
        reset(emitter);

        handler.on(new OrderShippedEvent(ORDER_ID_1));

        verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 위해서 두 개의 주문을 초기화합니다. reset은 handler에서 확인할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713693040502&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private void resetWithTwoOrders() {
        handler.reset(Arrays.asList(orderOne, orderTwo));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderQueryServiceIntergrationTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 쿼리 서비스 통합 테스트입니다. 주문 비즈니스 로직에 대한 시나리오들을 테스트 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713700990235&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootTest(classes = OrderApplication.class)
class OrderQueryServiceIntegrationTest {

    @Autowired
    OrderQueryService queryService;

    @Autowired
    EventGateway eventGateway;

    @Autowired
    OrdersEventHandler handler;

    private String orderId;
    private final String productId = &quot;Deluxe Chair&quot;;

    @BeforeEach
    void setUp() {
        orderId = UUID.randomUUID()
          .toString();
        Order order = new Order(orderId);
        handler.reset(Collections.singletonList(order));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 서비스, 이벤트 게이트웨이, 이벤트 핸들러를 선언하고 setUp 메소드에서 주문을 정의한 후 핸들러의 reset 메소드로 주문을 초기화 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713706645433&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트를 보내고 모든 주문을 조회하면 주문이 하나 반환된다.&quot;)
    void givenOrderCreatedEventSend_whenCallingAllOrders_thenOneCreatedOrderIsReturned() throws ExecutionException, InterruptedException {
        List&amp;lt;OrderResponse&amp;gt; result = queryService.findAllOrders().get();
        assertEquals(1, result.size());

        OrderResponse response = result.get(0);
        assertEquals(orderId, response.getOrderId());
        assertEquals(OrderStatusResponse.CREATED, response.getOrderStatus());
        assertTrue(response.getProducts().isEmpty());
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 주문을 가져오면 이전에 생성된 주문 1개와 해당 주문이 이전에 생성된 주문 아이디, 생성 상태를 갖고, 상품 목록은 비어있어야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713707067178&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BUILD SUCCESSFUL in 8s
7 actionable tasks: 2 executed, 5 up-to-date
오후 10:44:04: Execution finished ':domains:cqrs:test --tests &quot;com.saysimple.axon.querymodel.OrderQueryServiceIntegrationTest.givenOrderCreatedEventSend_whenCallingAllOrders_thenOneCreatedOrderIsReturned&quot;'.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드가 성공되어 테스트가 잘 진행된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713706679741&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트를 보내고 모든 주문을 조회하면 주문이 하나 반환된다.&quot;)
    void givenOrderCreatedEventSend_whenCallingAllOrders_thenOneCreatedOrderIsReturned() throws ExecutionException, InterruptedException {
        List&amp;lt;OrderResponse&amp;gt; result = queryService.findAllOrders().get();
        assertEquals(1, result.size());

        OrderResponse response = result.get(0);
        assertEquals(orderId, response.getOrderId());
        assertEquals(OrderStatusResponse.SHIPPED, response.getOrderStatus());
        assertTrue(response.getProducts().isEmpty());
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기대되는 주문 상태를 CREATED에서 SHIPPED로 변경해 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713706932807&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;22:41:35.322 [Test worker] INFO  org.mongodb.driver.client - MongoClient with metadata {&quot;driver&quot;: {&quot;name&quot;: &quot;mongo-java-driver|sync|spring-boot&quot;, &quot;version&quot;: &quot;4.11.1&quot;}, &quot;os&quot;: {&quot;type&quot;: &quot;Windows&quot;, &quot;name&quot;: &quot;Windows 11&quot;, &quot;architecture&quot;: &quot;amd64&quot;, &quot;version&quot;: &quot;10.0&quot;}, &quot;platform&quot;: &quot;Java/Oracle Corporation/19.0.2+7-44&quot;} created with settings MongoClientSettings{readPreference=primary, writeConcern=WriteConcern{w=null, wTimeout=null ms, journal=null}, retryWrites=true, retryReads=true, readConcern=ReadConcern{level=null}, credential=null, transportSettings=null, streamFactoryFactory=null, commandListeners=[], codecRegistry=ProvidersCodecRegistry{codecProviders=[ValueCodecProvider{}, BsonValueCodecProvider{}, DBRefCodecProvider{}, DBObjectCodecProvider{}, DocumentCodecProvider{}, CollectionCodecProvider{}, IterableCodecProvider{}, MapCodecProvider{}, GeoJsonCodecProvider{}, GridFSFileCodecProvider{}, Jsr310CodecProvider{}, JsonObjectCodecProvider{}, BsonCodecProvider{}, EnumCodecProvider{}, com.mongodb.client.model.mql.ExpressionCodecProvider@7d2ccd7f, com.mongodb.Jep395RecordCodecProvider@7ae49d52, com.mongodb.KotlinCodecProvider@40ef1198]}, loggerSettings=LoggerSettings{maxDocumentLength=1000}, clusterSettings={hosts=[localhost:27017], srvServiceName=mongodb, mode=SINGLE, requiredClusterType=UNKNOWN, requiredReplicaSetName='null', serverSelector='null', clusterListeners='[]', serverSelectionTimeout='30000 ms', localThreshold='15 ms'}, socketSettings=SocketSettings{connectTimeoutMS=10000, readTimeoutMS=0, receiveBufferSize=0, proxySettings=ProxySettings{host=null, port=null, username=null, password=null}}, heartbeatSocketSettings=SocketSettings{connectTimeoutMS=10000, readTimeoutMS=10000, receiveBufferSize=0, proxySettings=ProxySettings{host=null, port=null, username=null, password=null}}, connectionPoolSettings=ConnectionPoolSettings{maxSize=100, minSize=0, maxWaitTimeMS=120000, maxConnectionLifeTimeMS=0, maxConnectionIdleTimeMS=0, maintenanceInitialDelayMS=0, maintenanceFrequencyMS=60000, connectionPoolListeners=[], maxConnecting=2}, serverSettings=ServerSettings{heartbeatFrequencyMS=10000, minHeartbeatFrequencyMS=500, serverListeners='[]', serverMonitorListeners='[]'}, sslSettings=SslSettings{enabled=false, invalidHostNameAllowed=false, context=null}, applicationName='null', compressorList=[], uuidRepresentation=JAVA_LEGACY, serverApi=null, autoEncryptionSettings=null, dnsClient=null, inetAddressResolver=null, contextProvider=null}
22:41:35.483 [Test worker] INFO  c.s.a.q.OrderQueryServiceIntegrationTest - Started OrderQueryServiceIntegrationTest in 4.556 seconds (process running for 5.697)
22:41:36.034 [EventProcessor[orders]-0] INFO  o.a.e.TrackingEventProcessor - Worker assigned to segment Segment[0/0] for processing
22:41:36.052 [EventProcessor[orders]-0] INFO  o.a.e.TrackingEventProcessor - Using current Thread for last segment worker: TrackingSegmentWorker{processor=orders, segment=Segment[0/0]}
22:41:36.057 [EventProcessor[orders]-0] INFO  o.a.e.TrackingEventProcessor - Fetched token: null for segment: Segment[0/0]

Expected :SHIPPED
Actual   :CREATED&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기대되는 주문은 CREATED인데 SHIPPED가 반환되어 테스트가 실패하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나머지 테스트입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713707162698&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 생성 이벤트를 보내고 모든 주문을 스트리밍하면 주문이 하나 반환되고 스트리밍이 완료된다.&quot;)
    void givenOrderCreatedEventSend_whenCallingAllOrdersStreaming_thenOneOrderIsReturned() {
        Flux&amp;lt;OrderResponse&amp;gt; result = queryService.allOrdersStreaming();
        StepVerifier.create(result)
                .assertNext(order -&amp;gt; assertEquals(orderId, order.getOrderId()))
                .expectComplete()
                .verify();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713707169202&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;3개의 Deluxe Chair가 발송된 주문을 보내고 해당 상품의 총 발송량을 조회하면 3이 반환된다.&quot;)
    void givenThreeDeluxeChairsShipped_whenCallingAllShippedChairs_then234PlusTreeIsReturned() {
        Order order = new Order(orderId);
        order.getProducts()
                .put(productId, 3);
        order.setOrderShipped();
        handler.reset(Collections.singletonList(order));

        assertEquals(3, queryService.totalShipped(productId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713707175927&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;주문 갱신 쿼리를 구독하고 상품 추가, 상품 수량 증가, 상품 수량 감소, 주문 확인, 주문 발송 이벤트를 보내면 갱신된 주문을 반환한다.&quot;)
    void givenOrdersAreUpdated_whenCallingOrderUpdates_thenUpdatesReturned() {
        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        executor.schedule(this::addIncrementDecrementConfirmAndShip, 100L, TimeUnit.MILLISECONDS);
        try {
            StepVerifier.create(queryService.orderUpdates(orderId))
                    .assertNext(order -&amp;gt; assertTrue(order.getProducts()
                            .isEmpty()))
                    .assertNext(order -&amp;gt; assertEquals(1, order.getProducts()
                            .get(productId)))
                    .assertNext(order -&amp;gt; assertEquals(2, order.getProducts()
                            .get(productId)))
                    .assertNext(order -&amp;gt; assertEquals(1, order.getProducts()
                            .get(productId)))
                    .assertNext(order -&amp;gt; assertEquals(OrderStatusResponse.CONFIRMED, order.getOrderStatus()))
                    .assertNext(order -&amp;gt; assertEquals(OrderStatusResponse.SHIPPED, order.getOrderStatus()))
                    .thenCancel()
                    .verify();
        } finally {
            executor.shutdown();
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꼭 각 테스트에서 값을 바꿔서 테스트를 실패하는 상태를 만들어 보시길 권장드립니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713707213365&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private void addIncrementDecrementConfirmAndShip() {
        eventGateway.publish(new ProductAddedEvent(orderId, productId));
        eventGateway.publish(new ProductCountIncrementedEvent(orderId, productId));
        eventGateway.publish(new ProductCountDecrementedEvent(orderId, productId));
        eventGateway.publish(new OrderConfirmedEvent(orderId));
        eventGateway.publish(new OrderShippedEvent(orderId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트에 사용되는 상품 추가, 상품 수량 증가, 수량 감소, 주문 확인, 주문 배송 이벤트 발행 메소드입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고) 메소드가 재활용되지 않아 제가 하나의 메소드로 합쳤습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;OrderRestEndpointManualTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 메뉴얼 테스트입니다. User Acceptance Test로 봐도 될 것 같습니다. WebClient를 이용해 실제로 API에 요청을 보내 각 시나리오를 테스트합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713707532419&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootTest(classes = OrderApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//marked as manual as the test is unstable on Jenkins due to low resources
class OrderRestEndpointManualTest {

    @LocalServerPort
    private int port;&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;@DirtiesContext는 테스트를 실행할 때 Context를 재생성해 각 테스트 간의 Context 공유로 발생하는 문제를 방지합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713708528794&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DirtiesContext
    @DisplayName(&quot;주문 생성 API를 호출하고 모든 주문을 조회하면 주문이 하나 반환된다.&quot;)
    void givenCreateOrderCalled_whenCallingAllOrders_thenOneCreatedOrderIsReturned() {
        WebClient client = WebClient.builder()
                .clientConnector(httpConnector())
                .build();
        createRandomNewOrder(client);
        StepVerifier.create(retrieveListResponse(client.get()
                        .uri(&quot;http://localhost:&quot; + port + &quot;/all-orders&quot;)))
                .expectNextMatches(list -&amp;gt; 1 == list.size() &amp;amp;&amp;amp; list.get(0)
                        .getOrderStatus() == OrderStatusResponse.CREATED)
                .verifyComplete();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 테스트가 정상적으로 실행되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713709776816&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;BUILD SUCCESSFUL in 12s
7 actionable tasks: 2 executed, 5 up-to-date
오후 11:28:44: Execution finished ':domains:cqrs:test --tests &quot;com.saysimple.axon.gui.OrderRestEndpointManualTest.givenCreateOrderCalled_whenCallingAllOrders_thenOneCreatedOrderIsReturned&quot;'.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 기대되는 주문 목록 갯수를 2로 변경해서 테스트 해보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713709893829&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;java.lang.AssertionError: expectation &quot;expectNextMatches&quot; failed (predicate failed on value: [com.saysimple.axon.querymodel.OrderResponse@7486f41e])
...


OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended

&amp;gt; Task :domains:cqrs:test
OrderRestEndpointManualTest &amp;gt; �ֹ� ���� API�� ȣ���ϰ� ��� �ֹ��� ��ȸ�ϸ� �ֹ��� �ϳ� ��ȯ�ȴ�. FAILED
    java.lang.AssertionError at MessageFormatter.java:115
1 test completed, 1 failed
&amp;gt; Task :domains:cqrs:test FAILED
FAILURE: Build failed with an exception.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trace는 ...으로 생략하였습니다. 다음과 같이 expectNextMatches가 실패했으며 OrderResponse에서 실패를 발견했다는 로그를 확인할 수 있습니다. 위의 깨진 문자는 DisplayName이 한글이라 깨진 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나머지 테스트를 둘러보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713710182592&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DirtiesContext
    @DisplayName(&quot;주문 생성 API를 세 번 호출하고 모든 주문을 스트리밍하면 세 개의 주문이 반환된다.&quot;)
    void givenCreateOrderCalledThreeTimesAnd_whenCallingAllOrdersStreaming_thenTwoCreatedOrdersAreReturned() {
        WebClient client = WebClient.builder()
                .clientConnector(httpConnector())
                .build();
        for (int i = 0; i &amp;lt; 3; i++) {
            createRandomNewOrder(client);
        }
        StepVerifier.create(retrieveStreamingResponse(client.get()
                        .uri(&quot;http://localhost:&quot; + port + &quot;/all-orders-streaming&quot;)))
                .expectNextMatches(o -&amp;gt; o.getOrderStatus() == OrderStatusResponse.CREATED)
                .expectNextMatches(o -&amp;gt; o.getOrderStatus() == OrderStatusResponse.CREATED)
                .expectNextMatches(o -&amp;gt; o.getOrderStatus() == OrderStatusResponse.CREATED)
                .verifyComplete();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713710056705&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DirtiesContext
    @DisplayName(&quot;배송 전 확인이 필요한 규칙이 있는 경우 미확인 상태 주문에 주문 배송을 요청하면 오류가 발생한다.&quot;)
    void givenRuleExistThatNeedConfirmationBeforeShipping_whenCallingShipUnconfirmed_thenErrorReturned() {
        WebClient client = WebClient.builder()
                .clientConnector(httpConnector())
                .build();
        StepVerifier.create(retrieveResponse(client.post()
                        .uri(&quot;http://localhost:&quot; + port + &quot;/ship-unconfirmed-order&quot;)))
                .verifyError(WebClientResponseException.class);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713710275216&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DirtiesContext
    @DisplayName(&quot;주문상태를 배송으로 갱신하면 총 배송된 제품 수량이 1 증가한다.&quot;)
    void givenShipOrderCalled_whenCallingAllShippedChairs_thenPlusOneIsReturned() {
        WebClient client = WebClient.builder()
                .clientConnector(httpConnector())
                .build();
        verifyVoidPost(client, &quot;http://localhost:&quot; + port + &quot;/ship-order&quot;);
        StepVerifier.create(retrieveIntegerResponse(client.get()
                        .uri(&quot;http://localhost:&quot; + port + &quot;/total-shipped/Deluxe Chair&quot;)))
                .assertNext(r -&amp;gt; assertEquals(1, r))
                .verifyComplete();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1713710522525&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Test
    @DirtiesContext
    void givenOrdersAreUpdated_whenCallingOrderUpdates_thenUpdatesReturned() {
        WebClient updaterClient = WebClient.builder()
                .clientConnector(httpConnector())
                .build();
        WebClient receiverClient = WebClient.builder()
                .clientConnector(httpConnector())
                .build();

        String orderId = UUID.randomUUID().toString();
        String productId = UUID.randomUUID().toString();

        StepVerifier.create(retrieveResponse(updaterClient.post()
                        .uri(&quot;http://localhost:&quot; + port + &quot;/order/&quot; + orderId)))
                .assertNext(Assertions::assertNotNull)
                .verifyComplete();
        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        executor.schedule(() -&amp;gt; addIncrementDecrementConfirmAndShipProduct(orderId, productId), 1L, TimeUnit.SECONDS);

        try {
            StepVerifier.create(retrieveStreamingResponse(receiverClient.get()
                            .uri(&quot;http://localhost:&quot; + port + &quot;/order-updates/&quot; + orderId)))
                    .assertNext(p -&amp;gt; assertTrue(p.getProducts().isEmpty()))
                    .assertNext(p -&amp;gt; assertEquals(1, p.getProducts().get(productId)))
                    .assertNext(p -&amp;gt; assertEquals(2, p.getProducts().get(productId)))
                    .assertNext(p -&amp;gt; assertEquals(1, p.getProducts().get(productId)))
                    .assertNext(p -&amp;gt; assertEquals(OrderStatusResponse.CONFIRMED, p.getOrderStatus()))
                    .assertNext(p -&amp;gt; assertEquals(OrderStatusResponse.SHIPPED, p.getOrderStatus()))
                    .thenCancel()
                    .verify();
        } finally {
            executor.shutdown();
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 메소드와 클래스입니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1713710559012&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private void addIncrementDecrementConfirmAndShipProduct(String orderId, String productId) {
        WebClient client = WebClient.builder()
                .clientConnector(httpConnector())
                .build();
        String base = &quot;http://localhost:&quot; + port + &quot;/order/&quot; + orderId;
        verifyVoidPost(client, base + &quot;/product/&quot; + productId);
        verifyVoidPost(client, base + &quot;/product/&quot; + productId + &quot;/increment&quot;);
        verifyVoidPost(client, base + &quot;/product/&quot; + productId + &quot;/decrement&quot;);
        verifyVoidPost(client, base + &quot;/confirm&quot;);
        verifyVoidPost(client, base + &quot;/ship&quot;);
    }

    private void createRandomNewOrder(WebClient client) {
        StepVerifier.create(retrieveResponse(client.post()
                        .uri(&quot;http://localhost:&quot; + port + &quot;/order&quot;)))
                .assertNext(Assertions::assertNotNull)
                .verifyComplete();
    }

    private void verifyVoidPost(WebClient client, String uri) {
        StepVerifier.create(retrieveResponse(client.post().uri(uri)))
                .verifyComplete();
    }

    private Mono&amp;lt;String&amp;gt; retrieveResponse(WebClient.RequestBodySpec spec) {
        return spec.retrieve().bodyToMono(String.class);
    }

    private Mono&amp;lt;ResponseList&amp;gt; retrieveListResponse(WebClient.RequestHeadersSpec&amp;lt;?&amp;gt; spec) {
        return spec.accept(MediaType.APPLICATION_JSON)
                .retrieve()
                .bodyToMono(ResponseList.class);
    }

    private Mono&amp;lt;Integer&amp;gt; retrieveIntegerResponse(WebClient.RequestHeadersSpec&amp;lt;?&amp;gt; spec) {
        return spec.retrieve()
                .bodyToMono(Integer.class);
    }

    private Flux&amp;lt;OrderResponse&amp;gt; retrieveStreamingResponse(WebClient.RequestHeadersSpec&amp;lt;?&amp;gt; spec) {
        return spec.retrieve()
                .bodyToFlux(OrderResponse.class);
    }

    private static class ResponseList extends ArrayList&amp;lt;OrderResponse&amp;gt; {

        private ResponseList() {
            super();
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 생성, 상품 수량 증가, 감소, 주문 확인, 배송 시나리오 로직과 WebClient를 적절한 Response로 변환해주는 메소드입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Command, Query, API에 대한 모든 테스트를 둘러봤습니다. 각 테스트 코드는 EventHandler, QueryService, Aggregate에서 발생할 수 있는 시나리오를 중심으로 유닛, 라이브, 통합 테스트로 구성됩니다. 각 테스트 코드는 꼭 실행 시켜봐야 하며 실패할 수 있는 케이스도 실행해 보는 것이 좋습니다. 다음 글에선 EventHandler와 Aggregate의 코드를 리뷰해 보겠습니다.&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <category>AGGREGATE</category>
      <category>AXON</category>
      <category>Command</category>
      <category>Event</category>
      <category>EventHandler</category>
      <category>handler</category>
      <category>junit</category>
      <category>query</category>
      <category>spring boot</category>
      <category>Test</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/199</guid>
      <comments>https://saysimple.tistory.com/199#entry199comment</comments>
      <pubDate>Sun, 21 Apr 2024 18:48:48 +0900</pubDate>
    </item>
    <item>
      <title>[Java] CompletableFuture에 대한 이해 및 사용법</title>
      <link>https://saysimple.tistory.com/198</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 자바8에 추가된 CompletableFuture에 대해 알아보도록 하겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #f15f5f;&quot;&gt;1.&lt;span&gt;&lt;span&gt; CompletableFuture에 대한 이해&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;hr data-ke-style=&quot;style5&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;b&gt;[&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt; Future&lt;/span&gt;&lt;span&gt;&lt;b&gt;&lt;span&gt;&lt;span&gt;의 단점 및 한계&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&amp;nbsp;]&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java5에 Future가 추가되면서 비동기 작업에 대한 결과값을 반환 받을 수 있게 되었다. 하지만 Future는 다음과 같은 한계점이 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부에서 완료시킬 수 없고, get의 타임아웃 설정으로만 완료 가능&lt;/li&gt;
&lt;li&gt;블로킹 코드(get)를 통해서만 이후의 결과를 처리할 수 있음&lt;/li&gt;
&lt;li&gt;여러 Future를 조합할 수 없음 ex) 회원 정보를 가져오고, 알림을 발송하는 등&lt;/li&gt;
&lt;li&gt;여러 작업을 조합하거나 예외 처리할 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Future는 외부에서 작업을 완료시킬 수 없고, 작업 완료는 오직 get 호출 시에 타임아웃으로만 가능하다. 또한 비동기 작업의 응답에 추가 작업을 하려면 get을 호출해야 하는데, get은 블로킹 호출이므로 좋지 않다. 또한 여러 Future들을 조합할 수도 없으며, 예외가 발생한 경우에 이를 위한 예외처리도 불가능하다. 그래서 Java8에서는 이러한 문제를 모두 해결한 CompletableFuture가 등장하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;b&gt;[&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt; Completable&lt;/span&gt;Future 클래스&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&amp;nbsp;]&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html&quot;&gt;CompletableFuture&lt;/a&gt;는 기존의 Future를 기반으로 외부에서 완료시킬 수 있어서 CompletableFuture라는 이름을 갖게 되었다. Future 외에도 &lt;a href=&quot;https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletionStage.html&quot;&gt;CompletionStage&lt;/a&gt; 인터페이스도 구현하고 있는데, CompletionStage는 작업들을 중첩시키거나 완료 후 콜백을 위해 추가되었다.&amp;nbsp;예를 들어 Future에서는 불가능했던 &quot;몇 초 이내에 응답이 안 오면 기본값을 반환한다.&quot; 와 같은 작업이 가능해진 것이다. 즉, Future의 진화된 형태로써 &lt;span style=&quot;color: #ee2323;&quot;&gt;외부에서 작업을 완료시킬 수 있을 뿐만 아니라 콜백 등록 및 Future 조합 등이 가능&lt;/span&gt;하다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #f15f5f;&quot;&gt;2.&lt;span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;CompletableFuture의 기능들 및 예시코드&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;hr data-ke-style=&quot;style5&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;&lt;b&gt;[&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Future&lt;/span&gt;&lt;span&gt;&lt;b&gt;&lt;span&gt;&lt;span&gt;의 단점 및 한계&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&amp;nbsp;]&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CompletableFuture가 갖는 작업의 종류는 크게 다음과 같이 구분할 수 있는데, 이에 대해서는 자세히 코드로 살펴보도록 하자.&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;예외 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;runAsync
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반환값이 없는 경우&lt;/li&gt;
&lt;li&gt;비동기로 작업 실행 콜&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;supplyAsync
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반환값이 있는 경우&lt;/li&gt;
&lt;li&gt;비동기로 작업 실행 콜&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;runAsync는 반환 값이 없으므로 Void 타입이며, 아래의 코드를 실행해보면 future가 별도의 쓰레드에서 실행됨을 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1657113452287&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void runAsync() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;Void&amp;gt; future = CompletableFuture.runAsync(() -&amp;gt; {
        System.out.println(&quot;Thread: &quot; + Thread.currentThread().getName());
    });

    future.get();
    System.out.println(&quot;Thread: &quot; + Thread.currentThread().getName());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;supplyAsync는 runAsync와 달리 반환값이 존재한다. 그래서 비동기 작업의 결과를 받아올 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1657113678792&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void supplyAsync() throws ExecutionException, InterruptedException {

    CompletableFuture&amp;lt;String&amp;gt; future = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Thread: &quot; + Thread.currentThread().getName();
    });

    System.out.println(future.get());
    System.out.println(&quot;Thread: &quot; + Thread.currentThread().getName());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;runAsync와 supplyAsync는 기본적으로 자바7에 추가된 ForkJoinPool의 commonPool()을 사용해 작업을 실행할 쓰레드를 쓰레드 풀로부터 얻어 실행시킨다. 만약 원하는 쓰레드 풀을 사용하려면, ExecutorService를 파라미터로 넘겨주면 된다. 직접 비동기 작업에 대한 코드를 실행해보려면 &lt;a href=&quot;https://github.com/MangKyu/java-concurrency/blob/master/src/test/java/com/mangkyu/concurrency/java8/CompletableFutureRunTest.java&quot;&gt;깃허브&lt;/a&gt;를 참고하도록 하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;thenApply
&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;함수형 인터페이스 Function을 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;thenAccpet
&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;함수형 인터페이스 Consumer를 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;thenRun
&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;함수형 인터페이스 Runnable을 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java8에는 다양한 함수형 인터페이스들이 추가되었는데, CompletableFuture 역시 이들을 콜백으로 등록할 수 있게 한다. 그래서 비동기 실행이 끝난 후에 전달 받은 작업 콜백을 실행시켜주는데,&amp;nbsp;thenApply는 값을 받아서 다른 값을 반환시켜주는 콜백이다.&lt;/p&gt;
&lt;pre id=&quot;code_1657114606761&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void thenApply() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; future = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Thread: &quot; + Thread.currentThread().getName();
    }).thenApply(s -&amp;gt; {
        return s.toUpperCase();
    });

    System.out.println(future.get());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;thenAccept는 반환 값을 받아서 사용하고, 값을 반환하지는 않는 콜백이다.&lt;/p&gt;
&lt;pre id=&quot;code_1657114677102&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void thenAccept() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;Void&amp;gt; future = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Thread: &quot; + Thread.currentThread().getName();
    }).thenAccept(s -&amp;gt; {
        System.out.println(s.toUpperCase());
    });

    future.get();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;thenRun은 반환 값을 받지 않고, 그냥 다른 작업을 실행하는 콜백이다. 작업 콜백 관련 추가 예시 코드들은 &lt;a href=&quot;https://github.com/MangKyu/java-concurrency/blob/master/src/test/java/com/mangkyu/concurrency/java8/CompletableFutureCallbackTest.java&quot;&gt;깃허브&lt;/a&gt;에서 참고하도록 하자.&lt;/p&gt;
&lt;pre id=&quot;code_1657114724406&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void thenRun() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;Void&amp;gt; future = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Thread: &quot; + Thread.currentThread().getName();
    }).thenRun(() -&amp;gt; {
        System.out.println(&quot;Thread: &quot; + Thread.currentThread().getName());
    });

    future.get();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;thenCompose
&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;함수형 인터페이스 Function을 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;thenCombine
&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;함수형 인터페이스 Function을 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;allOf
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 작업들을 동시에 실행하고, 모든 작업 결과에 콜백을 실행함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;anyOf
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 작업들 중에서 가장 빨리 끝난 하나의 결과에 콜백을 실행함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래에서 살펴볼 thenCompose와 thenCombine 예제의 실행 결과는 같지만 동작 과정은 다르다. 먼저 thenCompose를 살펴보면 hello Future가 먼저 실행된 후에 반환된 값을 매개변수로 다음 Future를 실행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1657114956154&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void thenCompose() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; hello = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Hello&quot;;
    });

    // Future 간에 연관 관계가 있는 경우
    CompletableFuture&amp;lt;String&amp;gt; future = hello.thenCompose(this::mangKyu);
    System.out.println(future.get());
}

private CompletableFuture&amp;lt;String&amp;gt; mangKyu(String message) {
    return CompletableFuture.supplyAsync(() -&amp;gt; {
        return message + &quot; &quot; + &quot;MangKyu&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 thenCombine은 각각의 작업들이 독립적으로 실행되고, 얻어진 두 결과를 조합해서 작업을 처리한다.&lt;/p&gt;
&lt;pre id=&quot;code_1657114974805&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void thenCombine() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; hello = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Hello&quot;;
    });

    CompletableFuture&amp;lt;String&amp;gt; mangKyu = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;MangKyu&quot;;
    });

    CompletableFuture&amp;lt;String&amp;gt; future = hello.thenCombine(mangKyu, (h, w) -&amp;gt; h + &quot; &quot; + w);
    System.out.println(future.get());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음은 allOf와 anyOf를 살펴볼 차례이다. 아래의 코드를 실행해보면 모든 결과에 콜백이 적용됨을 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1657115037882&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void allOf() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; hello = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;Hello&quot;;
    });

    CompletableFuture&amp;lt;String&amp;gt; mangKyu = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;MangKyu&quot;;
    });

    List&amp;lt;CompletableFuture&amp;lt;String&amp;gt;&amp;gt; futures = List.of(hello, mangKyu);

    CompletableFuture&amp;lt;List&amp;lt;String&amp;gt;&amp;gt; result = CompletableFuture.allOf(futures.toArray(new CompletableFuture[futures.size()]))
            .thenApply(v -&amp;gt; futures.stream().
                    map(CompletableFuture::join).
                    collect(Collectors.toList()));

    result.get().forEach(System.out::println);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면에 anyOf의 경우에는 가장 빨리 끝난 1개의 작업에 대해서만 콜백이 실행됨을 확인할 수 있다. 관련 코드는 &lt;a href=&quot;https://github.com/MangKyu/java-concurrency/blob/master/src/test/java/com/mangkyu/concurrency/java8/CompletableFutureComposeTest.java&quot;&gt;깃허브&lt;/a&gt;를 참고하도록 하자.&lt;/p&gt;
&lt;pre id=&quot;code_1657115084650&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
void anyOf() throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; hello = CompletableFuture.supplyAsync(() -&amp;gt; {
        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        return &quot;Hello&quot;;
    });

    CompletableFuture&amp;lt;String&amp;gt; mangKyu = CompletableFuture.supplyAsync(() -&amp;gt; {
        return &quot;MangKyu&quot;;
    });

    CompletableFuture&amp;lt;Void&amp;gt; future = CompletableFuture.anyOf(hello, mangKyu).thenAccept(System.out::println);
    future.get();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;예외 처리&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;exeptionally
&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;함수형 인터페이스 Function을 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;handle, handleAsync
&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;함수형 인터페이스 BiFunction을 파라미터로 받음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각에 대해 throw하는 경우와 throw하지 않는 경우를 모두 실행시켜보도록 하자. 아래의 @ParameterizedTest는 동일한 테스트를 다른 파라미터로 여러 번 실행할 수 있도록 도와주는데, 실행해보면 throw 여부에 따라 실행 결과가 달라짐을 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1657115325503&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@ParameterizedTest
@ValueSource(booleans =  {true, false})
void exceptionally(boolean doThrow) throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; future = CompletableFuture.supplyAsync(() -&amp;gt; {
        if (doThrow) {
            throw new IllegalArgumentException(&quot;Invalid Argument&quot;);
        }

        return &quot;Thread: &quot; + Thread.currentThread().getName();
    }).exceptionally(e -&amp;gt; {
        return e.getMessage();
    });

    System.out.println(future.get());
}

java.lang.IllegalArgumentException: Invalid Argument
// Thread: ForkJoinPool.commonPool-worker-19&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마찬가지로 handle을 실행해보면 &lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;throw 여부에 따라 실행 결과가 달라짐을 확인할 수 있다. 예외 처리 관련 예제 코드는 &lt;a href=&quot;https://github.com/MangKyu/java-concurrency/blob/master/src/test/java/com/mangkyu/concurrency/java8/CompletableFutureExceptionTest.java&quot;&gt;깃허브&lt;/a&gt;에 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1657115442870&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@ParameterizedTest
@ValueSource(booleans =  {true, false})
void handle(boolean doThrow) throws ExecutionException, InterruptedException {
    CompletableFuture&amp;lt;String&amp;gt; future = CompletableFuture.supplyAsync(() -&amp;gt; {
        if (doThrow) {
            throw new IllegalArgumentException(&quot;Invalid Argument&quot;);
        }

        return &quot;Thread: &quot; + Thread.currentThread().getName();
    }).handle((result, e) -&amp;gt; {
        return e == null
                ? result
                : e.getMessage();
    });

    System.out.println(future.get());
}&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;그 외에도 아직 완료되지 않았으면 get을 바로 호출하고, 실패 시에 주어진 exception을 던지게 하는 completeExceptionally와 강제로 예외를 발생시키는 obtrudeException과 예외적으로 완료되었는지를 반환하는 isCompletedExceptionally 등과 같은 기능들도 있으니, 관련해서는 추가적으로 살펴보도록 하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련 포스팅&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;i&gt;&lt;b&gt;&lt;a href=&quot;https://mangkyu.tistory.com/258&quot;&gt;Thread와&amp;nbsp;Runnable에&amp;nbsp;대한&amp;nbsp;이해&amp;nbsp;및&amp;nbsp;사용법&lt;/a&gt;&lt;/b&gt;&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;&lt;i&gt;&lt;b&gt;&lt;a href=&quot;https://mangkyu.tistory.com/259&quot;&gt;Callable,&amp;nbsp;Future&amp;nbsp;및&amp;nbsp;Executors,&amp;nbsp;Executor,&amp;nbsp;ExecutorService,&amp;nbsp;ScheduledExecutorService에&amp;nbsp;대한&amp;nbsp;이해&amp;nbsp;및&amp;nbsp;사용법&lt;/a&gt;&lt;/b&gt;&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;&lt;i&gt;&lt;b&gt;CompletableFuture에&amp;nbsp;대한&amp;nbsp;이해&amp;nbsp;및&amp;nbsp;사용법&lt;/b&gt;&lt;/i&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처: &lt;a href=&quot;https://mangkyu.tistory.com/263&quot;&gt;https://mangkyu.tistory.com/263&lt;/a&gt; [MangKyu's Diary:티스토리] &lt;/p&gt;</description>
      <category>Language/Java</category>
      <category>async</category>
      <category>completablefuture</category>
      <category>future</category>
      <category>handle</category>
      <category>java</category>
      <category>runAsync</category>
      <category>Runnable</category>
      <category>supplyAsync</category>
      <category>thenApply</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/198</guid>
      <comments>https://saysimple.tistory.com/198#entry198comment</comments>
      <pubDate>Sun, 21 Apr 2024 01:50:09 +0900</pubDate>
    </item>
    <item>
      <title>[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 1</title>
      <link>https://saysimple.tistory.com/197</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;CQRS에 대한 정보는 아래 글을 참고해주세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://saysimple.tistory.com/196&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://saysimple.tistory.com/196&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1713620765390&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;[CQRS] CQRS란?&quot; data-og-description=&quot;CQRS CQRS란 Command and Query Responsibility Segregation 의 약자입니다. 저장소로부터 질의/명령을 이용하여 읽기/ 쓰기 작업을 분리하여 애플리케이션의 성능, 확장성, 보안성 등을 증가시키는 방법입니다.&quot; data-og-host=&quot;saysimple.tistory.com&quot; data-og-source-url=&quot;https://saysimple.tistory.com/196&quot; data-og-url=&quot;https://saysimple.tistory.com/196&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/by65gU/hyVSYLtPi4/uiyY6XgLWb03fI8dUY5xo0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bUAWwW/hyVS21qAzy/wN2bMZwNy8jpsaMik3Jgo0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://saysimple.tistory.com/196&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://saysimple.tistory.com/196&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/by65gU/hyVSYLtPi4/uiyY6XgLWb03fI8dUY5xo0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bUAWwW/hyVS21qAzy/wN2bMZwNy8jpsaMik3Jgo0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[CQRS] CQRS란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;CQRS CQRS란 Command and Query Responsibility Segregation 의 약자입니다. 저장소로부터 질의/명령을 이용하여 읽기/ 쓰기 작업을 분리하여 애플리케이션의 성능, 확장성, 보안성 등을 증가시키는 방법입니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;saysimple.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 부트에서는 CQRS를 구현하기 위해 Axon 라이브러리를 사용합니다. baeldung의 글을 참고해 스프링 부트에선 어떻게 CQRS를 구현하는지 알아보겠습니다. 해당 예제에선 Axon Server를 이벤트 스토어로 사용하며 Postman을 이용해 테스트합니다. 이번 글에선 API와 비즈니스 로직, 이벤트 소싱에 대해서 알아보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디펜던시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;build.gradle.kts 파일입니다. 본 예제에선 코틀린 그래들을 사용하며 스프링 부트 3.2.4, 자바 17을 사용합니다. 원래 예제에선 pom.xml을 사용하지만 트랜드에 맞게 수정하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713620875120&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;plugins {
    java
    id(&quot;org.springframework.boot&quot;) version &quot;3.2.4&quot;
    id(&quot;io.spring.dependency-management&quot;) version &quot;1.1.4&quot;
}

group = &quot;com.saysimple&quot;
version = &quot;0.0.1-SNAPSHOT&quot;

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

configurations {
    compileOnly {
        extendsFrom(configurations.annotationProcessor.get())
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(&quot;org.axonframework:axon-spring-boot-starter&quot;)
    implementation(&quot;org.springframework.boot:spring-boot-autoconfigure&quot;)
    implementation(&quot;org.springframework.boot:spring-boot-starter-web&quot;)
    implementation(&quot;org.springframework.boot:spring-boot-starter-data-jpa&quot;)
    implementation(&quot;org.axonframework.extensions.mongo:axon-mongo&quot;)
    implementation(&quot;io.projectreactor:reactor-core:3.6.0&quot;)
    implementation(&quot;io.projectreactor.addons:reactor-extra&quot;)
    compileOnly(&quot;org.projectlombok:lombok&quot;)
    annotationProcessor(&quot;org.projectlombok:lombok&quot;)
    runtimeOnly(&quot;com.h2database:h2&quot;)
    testImplementation(&quot;org.springframework.boot:spring-boot-starter-test&quot;)
    testImplementation(&quot;org.axonframework:axon-test&quot;)
    testImplementation(&quot;org.springframework:spring-test&quot;)
    testImplementation(&quot;io.projectreactor:reactor-test:3.6.0&quot;)
    testImplementation(&quot;org.awaitility:awaitility&quot;)
    testImplementation(&quot;org.springframework:spring-webflux&quot;)
    testImplementation(&quot;io.projectreactor.netty:reactor-netty-http&quot;)
    testImplementation(&quot;org.testcontainers:junit-jupiter&quot;)
    testImplementation(&quot;org.testcontainers:mongodb:1.17.6&quot;)
    testImplementation(&quot;org.junit.jupiter:junit-jupiter:5.8.2&quot;)
}

dependencyManagement {
    imports {
        mavenBom(&quot;org.axonframework:axon-bom:4.9.3&quot;)
    }
}

tasks.withType&amp;lt;Test&amp;gt; {
    useJUnitPlatform()
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Controller&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OrderRestEndpoint.java에 있는 API 스펙입니다. 저는 주문을 생성하는 api를 약간 수정하였습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 272px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;이름&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;URL&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;ship-order&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot;&gt;fdfdsafdsa&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;랜덤 주문을 생성하고 상품 추가, 주문 확인, 주문 배송 커맨드를 생성합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;ship-unconfirmed-order&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/ship-unconfirmed-order &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;랜덤 주문을 생성하고 한 후 주문 배송 커맨드를 생성합니다. 주문 확인을 하지 않았기 때문에 익셉션이 발생합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;create&amp;nbsp;order&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/order &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;주문을 생성합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;create&amp;nbsp;product&amp;nbsp;in&amp;nbsp;order&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/order/{orderId}/product/{ProductId}&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;주문에 상품을 추가합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: start;&quot;&gt;product increment in order&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/order/{orderId}/product/productId}/increment &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;주문 상품 갯수를 1 더합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 34px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 34px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: start;&quot;&gt;product decrement in order&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 34px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt;http://localhost:8080/order/{orderId}/product/productId}/decrement&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 34px;&quot;&gt;주문 상품 갯수를 1 뺍니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;confirm&amp;nbsp;order&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/&lt;span style=&quot;text-align: left;&quot;&gt;order/{orderId}&lt;/span&gt;/confirm &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;주문 상태를 확인으로 갱신합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;ship&amp;nbsp;order&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/&lt;span style=&quot;text-align: left;&quot;&gt;order/{orderId}&lt;/span&gt;/ship &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;주문 상태를 배송으로 갱신합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;get&amp;nbsp;orders&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/all-orders &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;모든 주문을 반환합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;get&amp;nbsp;orders&amp;nbsp;streaming&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/all-orders-streaming &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;모든 주문을 streaming으로 반환합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 17px;&quot;&gt;get&amp;nbsp;total&amp;nbsp;shipped&amp;nbsp;product&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #ffffff; text-align: left;&quot;&gt; http://localhost:8080/total-shipped/{productId}&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 17px;&quot;&gt;배송 상태인 모든 상품의 갯수를 반환합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 51px;&quot;&gt;
&lt;td style=&quot;width: 21.3565%; height: 51px;&quot;&gt;get&amp;nbsp;update&amp;nbsp;orders&amp;nbsp;streaming&lt;/td&gt;
&lt;td style=&quot;width: 29.031%; height: 51px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;http://localhost:808&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;0/order-updates/{orderId}&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.5039%; height: 51px;&quot;&gt;주문의 갱신을 streaming으로 반환합니다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ship order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 UUID를 생성하고 commandGateway에 주문 생성 커맨드, 상품 추가 커맨드, 주문 확인 커맨드, 주문 배송 커맨드를 보냅니다. 커맨드 생성 테스트용 API 입니다. commandGateway는 Axon에서 제공하는 게이트웨이입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713622250045&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/ship-order&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; shipOrder() {
        String orderId = UUID.randomUUID()
                .toString();
        return commandGateway.send(new CreateOrderCommand(orderId))
                .thenCompose(result -&amp;gt; commandGateway.send(new AddProductCommand(orderId, &quot;Deluxe Chair&quot;)))
                .thenCompose(result -&amp;gt; commandGateway.send(new ConfirmOrderCommand(orderId)))
                .thenCompose(result -&amp;gt; commandGateway.send(new ShipOrderCommand(orderId)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CreateOrderCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713622615793&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class CreateOrderCommand {
    @TargetAggregateIdentifier
    private final String orderId;

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        CreateOrderCommand that = (CreateOrderCommand) o;
        return Objects.equals(orderId, that.orderId);
    }

    @Override
    public String toString() {
        return &quot;CreateOrderCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AddProductCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713622649640&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class AddProductCommand {
    @TargetAggregateIdentifier
    private final String orderId;
    private final String productId;

    public String getOrderId() {
        return orderId;
    }

    public String getProductId() {
        return productId;
    }

    @Override
    public String toString() {
        return &quot;AddProductCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + &quot;, productId='&quot; + productId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfirmOrderCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713622673057&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class ConfirmOrderCommand {
    private @TargetAggregateIdentifier String orderId;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }
        final ConfirmOrderCommand other = (ConfirmOrderCommand) obj;
        return Objects.equals(this.orderId, other.orderId);
    }

    @Override
    public String toString() {
        return &quot;ConfirmOrderCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ShipOrderCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713622689315&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class ShipOrderCommand {
    @TargetAggregateIdentifier String orderId;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }
        final ShipOrderCommand other = (ShipOrderCommand) obj;
        return Objects.equals(this.orderId, other.orderId);
    }

    @Override
    public String toString() {
        return &quot;ShipOrderCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 커맨드는 간단하게 멤버, equals, toString으로 구성됩니다. Postman으로 해당 API를 테스트 해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;529&quot; data-origin-height=&quot;619&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wQ56T/btsGMgBUu35/9GFXHKTeZEpMXAHzw9IMDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wQ56T/btsGMgBUu35/9GFXHKTeZEpMXAHzw9IMDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wQ56T/btsGMgBUu35/9GFXHKTeZEpMXAHzw9IMDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwQ56T%2FbtsGMgBUu35%2F9GFXHKTeZEpMXAHzw9IMDK%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;529&quot; height=&quot;619&quot; data-origin-width=&quot;529&quot; data-origin-height=&quot;619&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;200코드가 정상적으로 나온 것을 확인할 수 있습니다. Axon 대시보드를 확인 해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1781&quot; data-origin-height=&quot;724&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9XrB0/btsGMR2DUtO/PU9LCbuOPsKPl9ZqkibKX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9XrB0/btsGMR2DUtO/PU9LCbuOPsKPl9ZqkibKX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9XrB0/btsGMR2DUtO/PU9LCbuOPsKPl9ZqkibKX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9XrB0%2FbtsGMR2DUtO%2FPU9LCbuOPsKPl9ZqkibKX1%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;1781&quot; height=&quot;724&quot; data-origin-width=&quot;1781&quot; data-origin-height=&quot;724&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 ShipOrderCommand, CreateOrderCommand, ConfirmOrderCommand, AddProductCommand가 정상적으로 생성된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ship unconfirmed order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문을 생성하고 주문 배송 커맨드를 생성합니다. 주문 확인 커맨드가 생성되지 않았기 때문에 UnConfirmedOrderException이 발생합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713628384713&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/ship-unconfirmed-order&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; shipUnconfirmedOrder() {
        String orderId = UUID.randomUUID()
                .toString();
        return commandGateway.send(new CreateOrderCommand(orderId))
                .thenCompose(result -&amp;gt; commandGateway.send(new AddProductCommand(orderId, &quot;Deluxe Chair&quot;)))
                // This throws an exception, as an Order cannot be shipped if it has not been confirmed yet.
                .thenCompose(result -&amp;gt; commandGateway.send(new ShipOrderCommand(orderId)));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UnConfirmedOrderException&lt;/p&gt;
&lt;pre id=&quot;code_1713628402208&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class UnconfirmedOrderException extends IllegalStateException {

    public UnconfirmedOrderException() {
        super(&quot;Cannot ship an order which has not been confirmed yet.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;577&quot; data-origin-height=&quot;718&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsngvN/btsGMMmTkkb/exYmZJxRWDfDO4HdcbB6gK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsngvN/btsGMMmTkkb/exYmZJxRWDfDO4HdcbB6gK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsngvN/btsGMMmTkkb/exYmZJxRWDfDO4HdcbB6gK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsngvN%2FbtsGMMmTkkb%2FexYmZJxRWDfDO4HdcbB6gK%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;577&quot; height=&quot;718&quot; data-origin-width=&quot;577&quot; data-origin-height=&quot;718&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;500 에러가 발생한 것을 확인할 수 있습니다. 에러메세지입니다. Trace 메세지는 ... 부분으로 생략하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1713628482839&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;00:54:32.673 [CommandProcessor-9] WARN  o.a.a.c.command.CommandSerializer - Serializing exception [class com.saysimple.axon.coreapi.exceptions.UnconfirmedOrderException] without details.
com.saysimple.axon.coreapi.exceptions.UnconfirmedOrderException: Cannot ship an order which has not been confirmed yet.

...

00:54:32.674 [CommandProcessor-9] INFO  o.a.a.c.command.CommandSerializer - To share exceptional information with the recipient it is recommended to wrap the exception in a CommandExecutionException with provided details.
00:54:32.677 [grpc-default-executor-3] WARN  o.a.c.gateway.DefaultCommandGateway - Command 'com.saysimple.axon.coreapi.commands.ShipOrderCommand' resulted in org.axonframework.commandhandling.CommandExecutionException(Cannot ship an order which has not been confirmed yet.)
00:54:32.678 [http-nio-8080-exec-5] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] threw exception
org.axonframework.axonserver.connector.command.AxonServerRemoteCommandHandlingException: An exception was thrown by the remote message handling component: Cannot ship an order which has not been confirmed yet.
00:54:32.679 [http-nio-8080-exec-5] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.axonframework.commandhandling.CommandExecutionException: Cannot ship an order which has not been confirmed yet.] with root cause
org.axonframework.axonserver.connector.command.AxonServerRemoteCommandHandlingException: An exception was thrown by the remote message handling component: Cannot ship an order which has not been confirmed yet.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 아랫 줄을 보면 Cannot ship an order which has not been confirmed yet. 메세지를 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;create&amp;nbsp;order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문을 생성합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713628666619&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/order&quot;)
    public CompletableFuture&amp;lt;String&amp;gt; createOrder() {
        return commandGateway.send(new CreateOrderCommand(UUID.randomUUID().toString()));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;622&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BDDCJ/btsGNNd1TPD/SkaaF9kHfRuei9ZWJGg4R1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BDDCJ/btsGNNd1TPD/SkaaF9kHfRuei9ZWJGg4R1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BDDCJ/btsGNNd1TPD/SkaaF9kHfRuei9ZWJGg4R1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBDDCJ%2FbtsGNNd1TPD%2FSkaaF9kHfRuei9ZWJGg4R1%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;561&quot; height=&quot;622&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;622&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 아이디가 생성된 것을 확인할 수 있습니다. Axon 대시보드를 확인 해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;921&quot; data-origin-height=&quot;706&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vjP5i/btsGMJ4Km08/Gmek2phtNKgmrdygikzXM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vjP5i/btsGMJ4Km08/Gmek2phtNKgmrdygikzXM1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vjP5i/btsGMJ4Km08/Gmek2phtNKgmrdygikzXM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvjP5i%2FbtsGMJ4Km08%2FGmek2phtNKgmrdygikzXM1%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;921&quot; height=&quot;706&quot; data-origin-width=&quot;921&quot; data-origin-height=&quot;706&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 생성 커맨드만 1 더 많은 것을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고) 원래 2, 3, 1, 2가 되어야 하는데 제가 실수로 주문 생성을 두 번 누른 것 같습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;create product in order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문에 상품을 추가합니다. 상품 추가 커맨드가 발생합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713629919723&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/order/{order-id}/product/{product-id}&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; addProduct(@PathVariable(&quot;order-id&quot;) String orderId, @PathVariable(&quot;product-id&quot;) String productId) {
        return commandGateway.send(new AddProductCommand(orderId, productId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;575&quot; data-origin-height=&quot;633&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CfYzv/btsGL061CDt/Jn8d0qKd60zMc7pLYvT8h1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CfYzv/btsGL061CDt/Jn8d0qKd60zMc7pLYvT8h1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CfYzv/btsGL061CDt/Jn8d0qKd60zMc7pLYvT8h1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCfYzv%2FbtsGL061CDt%2FJn8d0qKd60zMc7pLYvT8h1%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;575&quot; height=&quot;633&quot; data-origin-width=&quot;575&quot; data-origin-height=&quot;633&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상품 아이디는 오더 아이디에서 앞자리만 바꿔서 a로 변경했습니다. 지금은 존재하지 않는 상품이어도 추가가 되지만 실제 서비스를 개발할 땐 같은 Aggregate 안에 Product를 추가해서 isExists 체크를 하는 것이 좋을 것으로 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Axon 대시보드입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;919&quot; data-origin-height=&quot;733&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OGZvq/btsGMDqbFfX/W0xwMLRDOZMugwx6jBbDG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OGZvq/btsGMDqbFfX/W0xwMLRDOZMugwx6jBbDG1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OGZvq/btsGMDqbFfX/W0xwMLRDOZMugwx6jBbDG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOGZvq%2FbtsGMDqbFfX%2FW0xwMLRDOZMugwx6jBbDG1%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;919&quot; height=&quot;733&quot; data-origin-width=&quot;919&quot; data-origin-height=&quot;733&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AddProductCommand가 1 증가한 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;product&amp;nbsp;increment&amp;nbsp;in&amp;nbsp;order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문에 상품 갯수를 1개 증가시킵니다. 상품 갯수 증가 커맨드가 발생합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713630225448&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/order/{order-id}/product/{product-id}/increment&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; incrementProduct(@PathVariable(&quot;order-id&quot;) String orderId, @PathVariable(&quot;product-id&quot;) String productId) {
        return commandGateway.send(new IncrementProductCountCommand(orderId, productId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IncrementProductCountCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713630387376&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class IncrementProductCountCommand {
    @TargetAggregateIdentifier
    private String orderId;
    private String productId;

    @Override
    public String toString() {
        return &quot;IncrementProductCountCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + &quot;, productId='&quot; + productId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;540&quot; data-origin-height=&quot;613&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ruEmc/btsGPafnSk6/fvP5ltfnl3JqmITd9DCiak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ruEmc/btsGPafnSk6/fvP5ltfnl3JqmITd9DCiak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ruEmc/btsGPafnSk6/fvP5ltfnl3JqmITd9DCiak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FruEmc%2FbtsGPafnSk6%2FfvP5ltfnl3JqmITd9DCiak%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;540&quot; height=&quot;613&quot; data-origin-width=&quot;540&quot; data-origin-height=&quot;613&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다. Decrement API도 테스트 하기 위해 두 번 실행 시켰습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vKIwD/btsGO8oldKA/crrs0WfzaotP2EUrK1jzlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vKIwD/btsGO8oldKA/crrs0WfzaotP2EUrK1jzlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vKIwD/btsGO8oldKA/crrs0WfzaotP2EUrK1jzlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvKIwD%2FbtsGO8oldKA%2Fcrrs0WfzaotP2EUrK1jzlK%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;904&quot; height=&quot;840&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;840&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IncrementProductCountCommand가 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;product&amp;nbsp;decrement&amp;nbsp;in&amp;nbsp;order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문의 상품 갯수를 1개 뺍니다. 상품 갯수 빼기 커맨드가 발생합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713630537793&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/order/{order-id}/product/{product-id}/decrement&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; decrementProduct(@PathVariable(&quot;order-id&quot;) String orderId, @PathVariable(&quot;product-id&quot;) String productId) {
        return commandGateway.send(new DecrementProductCountCommand(orderId, productId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DecrementProductCountCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713630557656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class DecrementProductCountCommand {
    private @TargetAggregateIdentifier String orderId;
    private String productId;

    @Override
    public String toString() {
        return &quot;DecrementProductCountCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + &quot;, productId='&quot; + productId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;609&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lWJI3/btsGMISk6DX/RMVioDJ2V1IwPGwJukKiN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lWJI3/btsGMISk6DX/RMVioDJ2V1IwPGwJukKiN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lWJI3/btsGMISk6DX/RMVioDJ2V1IwPGwJukKiN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlWJI3%2FbtsGMISk6DX%2FRMVioDJ2V1IwPGwJukKiN1%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;583&quot; height=&quot;609&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;609&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;877&quot; data-origin-height=&quot;727&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mXEXa/btsGOxaO3Ck/TLU50lM2xB6vTolujpPh1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mXEXa/btsGOxaO3Ck/TLU50lM2xB6vTolujpPh1k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mXEXa/btsGOxaO3Ck/TLU50lM2xB6vTolujpPh1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmXEXa%2FbtsGOxaO3Ck%2FTLU50lM2xB6vTolujpPh1k%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;877&quot; height=&quot;727&quot; data-origin-width=&quot;877&quot; data-origin-height=&quot;727&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DecrementProductCountCommand가 1개 생성된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;confirm&amp;nbsp;order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 상태를 확인으로 갱신합니다. 주문 확인 커맨드가 발생됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713630776067&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/order/{order-id}/confirm&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; confirmOrder(@PathVariable(&quot;order-id&quot;) String orderId) {
        return commandGateway.send(new ConfirmOrderCommand(orderId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;529&quot; data-origin-height=&quot;553&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F3PeT/btsGNEhciSH/KURqH4jJRkySQJy68y7ifk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F3PeT/btsGNEhciSH/KURqH4jJRkySQJy68y7ifk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F3PeT/btsGNEhciSH/KURqH4jJRkySQJy68y7ifk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF3PeT%2FbtsGNEhciSH%2FKURqH4jJRkySQJy68y7ifk%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;529&quot; height=&quot;553&quot; data-origin-width=&quot;529&quot; data-origin-height=&quot;553&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;761&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zU5DU/btsGOeCq0ok/zCyPCug49szHv8ozmIJjKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zU5DU/btsGOeCq0ok/zCyPCug49szHv8ozmIJjKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zU5DU/btsGOeCq0ok/zCyPCug49szHv8ozmIJjKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzU5DU%2FbtsGOeCq0ok%2FzCyPCug49szHv8ozmIJjKk%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;898&quot; height=&quot;761&quot; data-origin-width=&quot;898&quot; data-origin-height=&quot;761&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfirmOrderCommand가 2개로 증가한 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ship&amp;nbsp;order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 상태를 배송으로 갱신합니다. 주문 배송 커맨드가 발생됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713630963644&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @PostMapping(&quot;/order/{order-id}/ship&quot;)
    public CompletableFuture&amp;lt;Void&amp;gt; shipOrder(@PathVariable(&quot;order-id&quot;) String orderId) {
        return commandGateway.send(new ShipOrderCommand(orderId));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ShipOrderCommand&lt;/p&gt;
&lt;pre id=&quot;code_1713630982690&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class ShipOrderCommand {
    @TargetAggregateIdentifier String orderId;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }
        final ShipOrderCommand other = (ShipOrderCommand) obj;
        return Objects.equals(this.orderId, other.orderId);
    }

    @Override
    public String toString() {
        return &quot;ShipOrderCommand{&quot; + &quot;orderId='&quot; + orderId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;529&quot; data-origin-height=&quot;639&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IN3HH/btsGMTTHtrY/TxKU1nKZYWAtzcv0bjo8V1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IN3HH/btsGMTTHtrY/TxKU1nKZYWAtzcv0bjo8V1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IN3HH/btsGMTTHtrY/TxKU1nKZYWAtzcv0bjo8V1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIN3HH%2FbtsGMTTHtrY%2FTxKU1nKZYWAtzcv0bjo8V1%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;529&quot; height=&quot;639&quot; data-origin-width=&quot;529&quot; data-origin-height=&quot;639&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;890&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bo6cYu/btsGM1xkzeC/K1kEKUBXp1tTfvjQXHwFI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bo6cYu/btsGM1xkzeC/K1kEKUBXp1tTfvjQXHwFI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bo6cYu/btsGM1xkzeC/K1kEKUBXp1tTfvjQXHwFI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbo6cYu%2FbtsGM1xkzeC%2FK1kEKUBXp1tTfvjQXHwFI0%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;890&quot; height=&quot;823&quot; data-origin-width=&quot;890&quot; data-origin-height=&quot;823&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ShipOrderCommand가 1개 증가한 것을 볼 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;get&amp;nbsp;orders&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오더 목록을 불러옵니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1713631223956&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @GetMapping(&quot;/all-orders&quot;)
    public CompletableFuture&amp;lt;List&amp;lt;OrderResponse&amp;gt;&amp;gt; findAllOrders() {
        return orderQueryService.findAllOrders();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오더 서비스의 모든 오더를 불러오는 비즈니스 로직입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713632170258&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public CompletableFuture&amp;lt;List&amp;lt;OrderResponse&amp;gt;&amp;gt; findAllOrders() {
        return queryGateway.query(new FindAllOrderedProductsQuery(), ResponseTypes.multipleInstancesOf(Order.class))
          .thenApply(r -&amp;gt; r.stream()
            .map(OrderResponse::new)
            .collect(Collectors.toList()));
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;thenApply와 반환 타입이 CompletableFuture인 것을 보니 비동기 콜을 사용하는 것으로 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Axon queryGateway의 query 함수입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713632277336&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    default &amp;lt;R, Q&amp;gt; CompletableFuture&amp;lt;R&amp;gt; query(@Nonnull Q query, @Nonnull Class&amp;lt;R&amp;gt; responseType) {
        return this.query(QueryMessage.queryName(query), query, responseType);
    }

    default &amp;lt;R, Q&amp;gt; CompletableFuture&amp;lt;R&amp;gt; query(@Nonnull String queryName, @Nonnull Q query, @Nonnull Class&amp;lt;R&amp;gt; responseType) {
        return this.query(queryName, query, ResponseTypes.instanceOf(responseType));
    }

    default &amp;lt;R, Q&amp;gt; CompletableFuture&amp;lt;R&amp;gt; query(@Nonnull Q query, @Nonnull ResponseType&amp;lt;R&amp;gt; responseType) {
        return this.query(QueryMessage.queryName(query), query, responseType);
    }

    &amp;lt;R, Q&amp;gt; CompletableFuture&amp;lt;R&amp;gt; query(@Nonnull String queryName, @Nonnull Q query, @Nonnull ResponseType&amp;lt;R&amp;gt; responseType);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 이름과 Class도 파라미터로 줄 수 있어 원하는 형태로 값을 받을 수 있어 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Order class 입니다. 멤버와 상품 증감, 상태 변경 Setter와 equals, hashCode, toString을 갖고 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713635004087&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
public class Order {

    private final String orderId;
    private final Map&amp;lt;String, Integer&amp;gt; products;
    private OrderStatus orderStatus;

    public Order(String orderId) {
        this.orderId = orderId;
        this.products = new HashMap&amp;lt;&amp;gt;();
        orderStatus = OrderStatus.CREATED;
    }

    public void addProduct(String productId) {
        products.putIfAbsent(productId, 1);
    }

    public void incrementProductInstance(String productId) {
        products.computeIfPresent(productId, (id, count) -&amp;gt; ++count);
    }

    public void decrementProductInstance(String productId) {
        products.computeIfPresent(productId, (id, count) -&amp;gt; --count);
    }

    public void removeProduct(String productId) {
        products.remove(productId);
    }

    public void setOrderConfirmed() {
        this.orderStatus = OrderStatus.CONFIRMED;
    }

    public void setOrderShipped() {
        this.orderStatus = OrderStatus.SHIPPED;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Order that = (Order) o;
        return Objects.equals(orderId, that.orderId) &amp;amp;&amp;amp; Objects.equals(products, that.products) &amp;amp;&amp;amp; orderStatus == that.orderStatus;
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderId, products, orderStatus);
    }

    @Override
    public String toString() {
        return &quot;Order{&quot; + &quot;orderId='&quot; + orderId + '\'' + &quot;, products=&quot; + products + &quot;, orderStatus=&quot; + orderStatus + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OrderStatus&lt;/p&gt;
&lt;pre id=&quot;code_1713635104382&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public enum OrderStatus {

    CREATED, CONFIRMED, SHIPPED
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FindAllOrderedProductsQuery&lt;/p&gt;
&lt;pre id=&quot;code_1713632873297&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class FindAllOrderedProductsQuery {

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;579&quot; data-origin-height=&quot;796&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UdXxZ/btsGOIi0zI8/nLhLDe1qPxiA3l0kD5GCX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UdXxZ/btsGOIi0zI8/nLhLDe1qPxiA3l0kD5GCX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UdXxZ/btsGOIi0zI8/nLhLDe1qPxiA3l0kD5GCX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUdXxZ%2FbtsGOIi0zI8%2FnLhLDe1qPxiA3l0kD5GCX1%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;579&quot; height=&quot;796&quot; data-origin-width=&quot;579&quot; data-origin-height=&quot;796&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성한 주문과 상품 아이디를 확인할 수 있습니다. Axon 대시보드입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwbGql/btsGOIXBG3v/eo3UiTjKWlg2KXFlKz4WCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwbGql/btsGOIXBG3v/eo3UiTjKWlg2KXFlKz4WCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwbGql/btsGOIXBG3v/eo3UiTjKWlg2KXFlKz4WCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbwbGql%2FbtsGOIXBG3v%2Feo3UiTjKWlg2KXFlKz4WCK%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;904&quot; height=&quot;776&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;776&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번엔 이벤트가 발행되지 않았으므로 변화는 없습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;get&amp;nbsp;orders&amp;nbsp;streaming&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문을 streaming으로 반환합니다. Flux가 반환값이 되며 SSE(Server Sent Event) 타입이 사용됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713632584159&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @GetMapping(path = &quot;/all-orders-streaming&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux&amp;lt;OrderResponse&amp;gt; allOrdersStreaming() {
        return orderQueryService.allOrdersStreaming();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 주문을 streaming으로 가져오는 비즈니스 로직입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713632638495&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public Flux&amp;lt;OrderResponse&amp;gt; allOrdersStreaming() {
        Publisher&amp;lt;Order&amp;gt; publisher = queryGateway.streamingQuery(new FindAllOrderedProductsQuery(), Order.class);
        return Flux.from(publisher)
          .map(OrderResponse::new);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;517&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beR86p/btsGMB6YFXX/kqF5EWf67c4wdK9NjLoyqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beR86p/btsGMB6YFXX/kqF5EWf67c4wdK9NjLoyqk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beR86p/btsGMB6YFXX/kqF5EWf67c4wdK9NjLoyqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeR86p%2FbtsGMB6YFXX%2FkqF5EWf67c4wdK9NjLoyqk%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;517&quot; height=&quot;416&quot; data-origin-width=&quot;517&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 모든 주문이 순서대로 반환되고 커넥션이 종료되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;get&amp;nbsp;total&amp;nbsp;shipped&amp;nbsp;product&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당하는 상품 아이디를 가진, 주문 배송 상태인 주문에 생성된, 모든 상품의 갯수를 반환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713633004284&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @GetMapping(&quot;/total-shipped/{product-id}&quot;)
    public Integer totalShipped(@PathVariable(&quot;product-id&quot;) String productId) {
        return orderQueryService.totalShipped(productId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 상품의 갯수를 계산하는 비즈니스 로직입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713633021158&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public Integer totalShipped(String productId) {
        return queryGateway.scatterGather(new TotalProductsShippedQuery(productId), ResponseTypes.instanceOf(Integer.class), 10L, TimeUnit.SECONDS)
          .reduce(0, Integer::sum);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scatterGather는 Stream을 반환합니다. Stream의 reduce 함수를 이용해 합계를 계산합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713633107507&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    default &amp;lt;R, Q&amp;gt; Stream&amp;lt;R&amp;gt; scatterGather(@Nonnull Q query, @Nonnull ResponseType&amp;lt;R&amp;gt; responseType, long timeout, @Nonnull TimeUnit timeUnit) {
        return this.scatterGather(QueryMessage.queryName(query), query, responseType, timeout, timeUnit);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TotalProductsShippedQuery&lt;/p&gt;
&lt;pre id=&quot;code_1713633130912&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@AllArgsConstructor
public class TotalProductsShippedQuery {
    private final String productId;


    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        TotalProductsShippedQuery that = (TotalProductsShippedQuery) o;
        return Objects.equals(productId, that.productId);
    }

    @Override
    public String toString() {
        return &quot;TotalProductsShippedQuery{&quot; + &quot;productId='&quot; + productId + '\'' + '}';
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GbwbV/btsGM3hEbwE/TNsUy7lah3JQOBkxtyngU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GbwbV/btsGM3hEbwE/TNsUy7lah3JQOBkxtyngU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GbwbV/btsGM3hEbwE/TNsUy7lah3JQOBkxtyngU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGbwbV%2FbtsGM3hEbwE%2FTNsUy7lah3JQOBkxtyngU1%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;588&quot; height=&quot;620&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 성공했고 2가 반환된 것을 확인할 수 있습니다. Axon 대시보드입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고) Decrement를 실행했는데 2개인 것을 보아 제가 Increment를 두 번 누른 것 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;872&quot; data-origin-height=&quot;683&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QOEDz/btsGNORy40X/Rl8RkPjWJI7wIH8aJpdiMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QOEDz/btsGNORy40X/Rl8RkPjWJI7wIH8aJpdiMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QOEDz/btsGNORy40X/Rl8RkPjWJI7wIH8aJpdiMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQOEDz%2FbtsGNORy40X%2FRl8RkPjWJI7wIH8aJpdiMk%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;872&quot; height=&quot;683&quot; data-origin-width=&quot;872&quot; data-origin-height=&quot;683&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TotalProductsShippedQuery가 생성된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고) 제가 Command에서 TotalProductsShippedQuery를 찾다가 여러번 눌러 4개가 생성되고 나서 Command에서 찾고 있었음을 깨달았습니다...&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;get&amp;nbsp;update&amp;nbsp;orders&amp;nbsp;streaming&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리를 구독해 주문 갱신이 발생할 때 주문 반환 객체를 반환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713633829563&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @GetMapping(path = &quot;/order-updates/{order-id}&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux&amp;lt;OrderResponse&amp;gt; orderUpdates(@PathVariable(&quot;order-id&quot;) String orderId) {
        return orderQueryService.orderUpdates(orderId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 갱신 비즈니스 로직입니다. 주문 반환 객체를 반환 값의 타입으로 지정하여 Flux를 사용해 쿼리를 구독합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713633886256&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public Flux&amp;lt;OrderResponse&amp;gt; orderUpdates(String orderId) {
        return subscriptionQuery(new OrderUpdatesQuery(orderId), ResponseTypes.instanceOf(Order.class)).map(OrderResponse::new);
    }

    private &amp;lt;Q, R&amp;gt; Flux&amp;lt;R&amp;gt; subscriptionQuery(Q query, ResponseType&amp;lt;R&amp;gt; resultType) {
        return Flux.using(
                () -&amp;gt; queryGateway.subscriptionQuery(query, resultType, resultType),
                ret -&amp;gt; Flux.from(ret.initialResult()),
                SubscriptionQueryResult::close
        );
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;subscriptionQuery는 쿼리, 초기 ResponseType, 갱신 ResponseType을 받아 쿼리를 구독합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1713633947474&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    default &amp;lt;Q, I, U&amp;gt; SubscriptionQueryResult&amp;lt;I, U&amp;gt; subscriptionQuery(@Nonnull Q query, @Nonnull Class&amp;lt;I&amp;gt; initialResponseType, @Nonnull Class&amp;lt;U&amp;gt; updateResponseType) {
        return this.subscriptionQuery(QueryMessage.queryName(query), query, initialResponseType, updateResponseType);
    }

    default &amp;lt;Q, I, U&amp;gt; SubscriptionQueryResult&amp;lt;I, U&amp;gt; subscriptionQuery(@Nonnull String queryName, @Nonnull Q query, @Nonnull Class&amp;lt;I&amp;gt; initialResponseType, @Nonnull Class&amp;lt;U&amp;gt; updateResponseType) {
        return this.subscriptionQuery(queryName, query, ResponseTypes.instanceOf(initialResponseType), ResponseTypes.instanceOf(updateResponseType));
    }

    default &amp;lt;Q, I, U&amp;gt; SubscriptionQueryResult&amp;lt;I, U&amp;gt; subscriptionQuery(@Nonnull Q query, @Nonnull ResponseType&amp;lt;I&amp;gt; initialResponseType, @Nonnull ResponseType&amp;lt;U&amp;gt; updateResponseType) {
        return this.subscriptionQuery(QueryMessage.queryName(query), query, initialResponseType, updateResponseType);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postman 테스트입니다. 구독된 것을 확인하기 위해 주문 생성부터 위의 과정을 반복 해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;518&quot; data-origin-height=&quot;630&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgFwoK/btsGMHeT6ce/QQx0i3rIOiRcaYlyjUYfEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgFwoK/btsGMHeT6ce/QQx0i3rIOiRcaYlyjUYfEK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgFwoK/btsGMHeT6ce/QQx0i3rIOiRcaYlyjUYfEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgFwoK%2FbtsGMHeT6ce%2FQQx0i3rIOiRcaYlyjUYfEK%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;518&quot; height=&quot;630&quot; data-origin-width=&quot;518&quot; data-origin-height=&quot;630&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;497&quot; data-origin-height=&quot;578&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/voQGr/btsGPaGtljN/M11JtCIokzLFbBHWstY2VK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/voQGr/btsGPaGtljN/M11JtCIokzLFbBHWstY2VK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/voQGr/btsGPaGtljN/M11JtCIokzLFbBHWstY2VK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvoQGr%2FbtsGPaGtljN%2FM11JtCIokzLFbBHWstY2VK%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;497&quot; height=&quot;578&quot; data-origin-width=&quot;497&quot; data-origin-height=&quot;578&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;488&quot; data-origin-height=&quot;674&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJTBZi/btsGM15bZY2/EH5NRQXUgWJWVWIhzVwbe1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJTBZi/btsGM15bZY2/EH5NRQXUgWJWVWIhzVwbe1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJTBZi/btsGM15bZY2/EH5NRQXUgWJWVWIhzVwbe1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJTBZi%2FbtsGM15bZY2%2FEH5NRQXUgWJWVWIhzVwbe1%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;488&quot; height=&quot;674&quot; data-origin-width=&quot;488&quot; data-origin-height=&quot;674&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래에서 위 순으로 주문이 갱신될 때 마다 주문 정보를 반환하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Axon을 사용하면 Aggregate와 Query, Command 구현의 상당 부분을 어노테이션을 통해 쉽게 할 수 있으며 Axon Server의 메시징, 이벤트 저장 및 배포, Query 모델의 자동 업데이트가 적절히 진행되는 것을 확인하였으며 대시보드를 통해 생성된 메세지들을 확인해 이벤트 소싱이 적절히 이뤄짐을 확인했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에선 테스트 코드 부분을 리뷰하면서 InMemoryOrdersEventHandler, MongoOrdersEventHandler를 통해 메모리 테스트와 몽고 디비에 실제로 저장하는 테스트 예제를 리뷰해보도록 하겠습니다.&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;Reference&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a href=&quot;https://www.baeldung.com/axon-cqrs-event-sourcing&quot;&gt;https://www.baeldung.com/axon-cqrs-event-sourcing&lt;/a&gt;&lt;/p&gt;</description>
      <category>Backend/SpringBoot</category>
      <category>AXON</category>
      <category>Baeldung</category>
      <category>Command</category>
      <category>Event</category>
      <category>Flux</category>
      <category>Mono</category>
      <category>query</category>
      <category>Spring</category>
      <category>Streaming</category>
      <category>subscribe</category>
      <author>Say simple</author>
      <guid isPermaLink="true">https://saysimple.tistory.com/197</guid>
      <comments>https://saysimple.tistory.com/197#entry197comment</comments>
      <pubDate>Sat, 20 Apr 2024 23:16:38 +0900</pubDate>
    </item>
  </channel>
</rss>