<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Seolhxx' Log</title>
    <link>https://seolhxx.tistory.com/</link>
    <description>코딩 공부</description>
    <language>ko</language>
    <pubDate>Wed, 3 Jun 2026 18:50:02 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor> 설희hxx</managingEditor>
    <image>
      <title>Seolhxx' Log</title>
      <url>https://tistory1.daumcdn.net/tistory/7179666/attach/74f5ccd8f0ed48c88951965bc26958a2</url>
      <link>https://seolhxx.tistory.com</link>
    </image>
    <item>
      <title>[FastAPI] 분석 엔진에 '눈', EasyOCR 연동 및 트러블슈팅 (Saga Step 2)</title>
      <link>https://seolhxx.tistory.com/35</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 오늘은 우리 분석 요원(FastAPI)에게 이미지 속 글자를 읽을 수 있는 능력을 부여했습니다.   파이썬의 강력한 라이브러리인 &lt;b data-index-in-node=&quot;80&quot; data-path-to-node=&quot;4&quot;&gt;EasyOCR&lt;/b&gt;을 활용해 실제 메뉴판을 텍스트로 변환하는 과정을 공유할게요!&lt;/p&gt;
&lt;hr data-path-to-node=&quot;5&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ️ 1. 왜 EasyOCR인가요?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;메뉴판은 한글과 영어가 섞여 있는 경우가 많아요.  &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,0&quot;&gt;다국어 지원&lt;/b&gt;: 80개 이상의 언어를 지원하며, 특히 한글 인식률이 매우 뛰어납니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,0&quot;&gt;가벼움과 고성능&lt;/b&gt;: 별도의 클라우드 유료 API 없이도 로컬에서 딥러닝 기반의 정확한 분석이 가능해요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;9&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ️ 2. 오늘 만난 에러 에러들 (Troubleshooting)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size23&quot;&gt;❌ Issue 1: &quot;이미지가 어디 있는지 모르겠어요!&quot; (경로 문제)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;메시지는 잘 수신했는데, 자꾸 파일을 못 찾는다는 에러가 떴습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,0,0&quot;&gt;원인&lt;/b&gt;: 모노레포 구조에서 FastAPI가 NestJS의 uploads 폴더를 찾아가야 하는데, 상대 경로(../) 설정이 꼬여있었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,1,0&quot;&gt;해결&lt;/b&gt;: os.path.abspath와 os.path.join을 활용해 절대 경로를 로그로 찍어가며 위치를 정확히 잡아주었습니다!  &lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;❌ Issue 2: &quot;분석 결과가 텅 비어 있어요&quot; (인식 실패)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;파일은 찾았는데 결과가 빈 리스트([])로 나오는 현상이 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;16&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16,0,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;16,1,0&quot;&gt;해결&lt;/b&gt;: 글자가 선명한 메뉴판 이미지로 테스트하고, OCR 엔진 내부의 try-except 문을 보강해 원본 결과를 상세히 디버깅하도록 수정했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;17&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  3. 핵심 코드: OCR 서비스 쪼개기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;유지보수를 위해 main.py에 다 넣지 않고, 서비스를 별도로 분리했습니다.&lt;/p&gt;
&lt;div data-hveid=&quot;5&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# app/services/ocr_service.py
class OCRService:
    def __init__(self):
        # 한글, 영어 모델 로드 (서버 시작 시 한 번만!)
        self.reader = easyocr.Reader(['ko', 'en'])

    def extract_text(self, image_path: str):
        # 이미지에서 텍스트만 리스트로 쏙!
        return self.reader.readtext(image_path, detail=0)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;21&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  4. 분석 결과 (Log)&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-10 오전 10.16.59.png&quot; data-origin-width=&quot;1592&quot; data-origin-height=&quot;1648&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckObZU/dJMcabpSB9G/M6q5awcOczbUKvM9g02mP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckObZU/dJMcabpSB9G/M6q5awcOczbUKvM9g02mP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckObZU/dJMcabpSB9G/M6q5awcOczbUKvM9g02mP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckObZU%2FdJMcabpSB9G%2FM6q5awcOczbUKvM9g02mP0%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;1592&quot; height=&quot;1648&quot; data-filename=&quot;스크린샷 2026-03-10 오전 10.16.59.png&quot; data-origin-width=&quot;1592&quot; data-origin-height=&quot;1648&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;드디어 터미널에 찍힌 한글 메뉴 이름들! 비빔밥, 떡볶이, 심지어 원산지 정보까지 완벽하게 읽어냈습니다.  &lt;/p&gt;
&lt;hr data-path-to-node=&quot;24&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;이제 분석 엔진은 글자를 읽을 줄 알게 되었습니다!  ️ 다음 포스팅에서는 이 텍스트들을 다시 NestJS로 보내서 DB의 상태를 '분석 완료'로 바꾸는, &lt;b data-index-in-node=&quot;88&quot; data-path-to-node=&quot;26&quot;&gt;비동기 Saga 패턴을 완성해보겠습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;26&quot; data-ke-style=&quot;style2&quot;&gt;추후 AI를 학습시켜서 이렇게 분석한 메뉴판마다의 음식들 별로 칼로리도 계산할 수 있도록 만들어 보고 싶습니다.&amp;nbsp;&lt;br /&gt;또한 현재는 글이나 단어를 읽고 분석 결과를 나타내는데 사진만 보고도 어떤 음식인지 알 수 있도록 구현해보고 싶습니다.&lt;/blockquote&gt;
&lt;hr data-path-to-node=&quot;27&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>FastAPI #EasyOCR #Python #MSA #SagaPattern #BackEnd #트러블슈팅</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/35</guid>
      <comments>https://seolhxx.tistory.com/35#entry35comment</comments>
      <pubDate>Tue, 10 Mar 2026 10:29:45 +0900</pubDate>
    </item>
    <item>
      <title>[FastAPI] 파이썬 분석 엔진 기지 건설! RabbitMQ 메시지 수신 성공기 (feat. zsh 트러블슈팅)</title>
      <link>https://seolhxx.tistory.com/34</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 오늘은 스마트 식단 관리 에이전트 프로젝트의 두 번째 주인공, &lt;b&gt;FastAPI 분석 엔진(Analysis Engine)&lt;/b&gt;의 기초를 다져보았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;NestJS가 보낸 분석 요청 메시지를 RabbitMQ라는 기차역에서 낚아채는 &lt;b&gt;비동기 컨슈머(Consumer)*&lt;/b&gt;를 구현하며 겪은 에러 해결입니다.&lt;/p&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;b&gt; ️ 1. 왜 FastAPI를 선택했나요?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;우리 프로젝트의 핵심은 &lt;b&gt;메뉴판 이미지 분석(OCR &amp;amp; AI)&lt;/b&gt;입니다.  &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;Python 생태계&lt;/b&gt;: AI 및 이미지 처리 라이브러리가 가장 풍부한 언어를 선택했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;성능&lt;/b&gt;: 비동기(Async) 처리에 최적화된 FastAPI를 통해 여러 분석 요청을 효율적으로 처리하기 위함입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ️ 2. 오늘의 에러 (Troubleshooting)&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;❌ Issue 1: &quot;python 명령어를 찾을 수 없대요!&quot;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;가상환경을 만들려는데 zsh: command not found: python 에러가 발생했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,0&quot;&gt;원인&lt;/b&gt;: 최신 Mac/Linux 환경에서는 보안과 버전 관리를 위해 python 대신 python3를 명시적으로 사용해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,1,0&quot;&gt;해결&lt;/b&gt;: python3 -m venv venv 명령어로 가상환경을 성공적으로 생성했습니다!  &lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;❌ Issue 2: zsh의 깜찍한 방해, no matches found&lt;/h3&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;pip install fastapi[all]을 입력했더니 설치가 되지 않고 에러가 떴습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,0,0&quot;&gt;원인&lt;/b&gt;: 터미널 쉘인 zsh가 대괄호([])를 파일을 찾는 특수 문자로 오해해서 발생한 문제였습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,1,0&quot;&gt;해결&lt;/b&gt;: pip install &quot;fastapi[all]&quot; 처럼 &lt;b data-index-in-node=&quot;34&quot; data-path-to-node=&quot;17,1,0&quot;&gt;따옴표&lt;/b&gt;로 감싸주어 해결 완료!&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  3. 핵심 코드: RabbitMQ 리스너 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;aio-pika 라이브러리를 사용해 NestJS가 보낸 메시지를 실시간으로 기다리는 코드를 작성했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQijE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;async def process_message(message: aio_pika.IncomingMessage):
    async with message.process():
        # 메시지 수신 및 데이터 파싱
        body = json.loads(message.body.decode())
        print(f&quot;  [분석 시작] Task ID: {body['taskId']}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;  4. 테스트 결과&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size23&quot;&gt;① 드디어 만난 교신 성공 로그!&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-09 오후 5.59.06.png&quot; data-origin-width=&quot;1428&quot; data-origin-height=&quot;374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNSv8E/dJMcadA758n/869SVrdJQsm8CutvQIBtF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNSv8E/dJMcadA758n/869SVrdJQsm8CutvQIBtF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNSv8E/dJMcadA758n/869SVrdJQsm8CutvQIBtF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNSv8E%2FdJMcadA758n%2F869SVrdJQsm8CutvQIBtF1%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;1428&quot; height=&quot;374&quot; data-filename=&quot;스크린샷 2026-03-09 오후 5.59.06.png&quot; data-origin-width=&quot;1428&quot; data-origin-height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,0,0&quot;&gt;설명&lt;/b&gt;: NestJS에서 쏜 Task ID가 파이썬 터미널에 예쁘게 찍혔습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,1,0&quot;&gt;포인트&lt;/b&gt;: 서로 다른 언어로 만들어진 두 서버가 RabbitMQ라는 기차역에서 완벽하게 대화하는 모습입니다!&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;② RabbitMQ 관리자 전광판 확인&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-09 오후 6.07.13.png&quot; data-origin-width=&quot;2232&quot; data-origin-height=&quot;1344&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qtMGD/dJMcab4rUJl/JFlYL6YiG0nzgsKJZl6Gp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qtMGD/dJMcab4rUJl/JFlYL6YiG0nzgsKJZl6Gp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qtMGD/dJMcab4rUJl/JFlYL6YiG0nzgsKJZl6Gp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqtMGD%2FdJMcab4rUJl%2FJFlYL6YiG0nzgsKJZl6Gp0%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;2232&quot; height=&quot;1344&quot; data-filename=&quot;스크린샷 2026-03-09 오후 6.07.13.png&quot; data-origin-width=&quot;2232&quot; data-origin-height=&quot;1344&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;NextJS에서 보낸 데이터를 가지고 있는 모습입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-10 오전 9.41.59.png&quot; data-origin-width=&quot;2132&quot; data-origin-height=&quot;1138&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cz59GU/dJMcacoLc5g/Lga9fOWCckE03D1ivBAjM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cz59GU/dJMcacoLc5g/Lga9fOWCckE03D1ivBAjM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cz59GU/dJMcacoLc5g/Lga9fOWCckE03D1ivBAjM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcz59GU%2FdJMcacoLc5g%2FLga9fOWCckE03D1ivBAjM0%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;2132&quot; height=&quot;1138&quot; data-filename=&quot;스크린샷 2026-03-10 오전 9.41.59.png&quot; data-origin-width=&quot;2132&quot; data-origin-height=&quot;1138&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,0,0&quot;&gt;설명&lt;/b&gt;: &lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;27,0,0&quot;&gt;Consumers: 1&lt;/b&gt; 이라는 숫자가 보이시나요? 우리 FastAPI 분석 엔진이 &quot;나 여기 준비됐어!&quot;라고 손을 들고 있는 증거입니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,1,0&quot;&gt;설명&lt;/b&gt;: &lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;27,1,0&quot;&gt;menu_analysis_queue&lt;/b&gt; 바구니가 예쁘게 생성되어 메시지를 담을 준비를 마쳤습니다.&lt;/li&gt;
&lt;/ul&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;  마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 MSA 구조의 핵심인 &lt;b data-index-in-node=&quot;16&quot; data-path-to-node=&quot;30&quot;&gt;비동기 이벤트 기반 통신&lt;/b&gt;을 직접 구현해 보았습니다. 환경 세팅부터 통신 성공까지, 에러를 해결하는 과정과 함께 이러한 결과를 얻으니까 즐거운 개발이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;이전에 MSA 프로젝트에서 kafka를 사용할 때에는 연결이 끊기는 오류가 자주나고 설정을 조금만 변경해도 연결이 끊겨서 힘들었는데&amp;nbsp;&lt;br /&gt;RabbitMQ는 그런게 없어서 다행히 금방 끝난 것 같습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;추후에 이렇게 비동기 통신한 것을 프론트까지 구현하게 되어서 통신이 잘 되는 걸 보면 뿌듯할 것 같습니다.&lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>CONSUMER</category>
      <category>fastapi</category>
      <category>MSA</category>
      <category>nestjs</category>
      <category>python3</category>
      <category>rabbitmq</category>
      <category>venv</category>
      <category>백엔드</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/34</guid>
      <comments>https://seolhxx.tistory.com/34#entry34comment</comments>
      <pubDate>Tue, 10 Mar 2026 09:46:17 +0900</pubDate>
    </item>
    <item>
      <title>  [NestJS] Saga 패턴의 시작: RabbitMQ와 함께하는 비동기 메뉴 분석 API 구현기</title>
      <link>https://seolhxx.tistory.com/33</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 오늘은 스마트 식단 관리 에이전트 프로젝트의 핵심, &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;4&quot;&gt;메뉴판 분석 요청 API&lt;/b&gt;를 개발하며 겪은 우여곡절과 기술적 고민들을 정리해 보려고 합니다.  ✨&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;이번 작업의 핵심은 &lt;b&gt;&quot;무거운 AI 분석 작업을 어떻게 사용자 대기 시간 없이 처리할 것인가?&quot;&lt;/b&gt;였습니다.&lt;/p&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;b&gt; ️ 1. 아키텍처 설계: 왜 Saga 패턴인가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 메뉴판 이미지를 업로드하면 OCR과 AI 분석이 돌아가야 하는데, 이 작업은 수 초 이상 걸릴 수 있습니다. 이를 동기(Sync) 방식으로 처리하면 유저는 화면이 멈춘 듯한 경험을 하게 되죠.&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;그래서 저는 &lt;b data-index-in-node=&quot;7&quot; data-path-to-node=&quot;9&quot;&gt;Saga 패턴&lt;/b&gt;의 첫 단계를 도입했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;10&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;10,0,0&quot;&gt;NestJS&lt;/b&gt;: 이미지 업로드 즉시 DB에 태스크를 저장하고 taskId를 반환 (비동기 처리 시작).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;RabbitMQ&lt;/b&gt;: 분석에 필요한 정보를 메시지 큐에 담아 &lt;b data-index-in-node=&quot;32&quot; data-path-to-node=&quot;10,1,0&quot;&gt;FastAPI&lt;/b&gt; 분석 엔진으로 전달.&lt;/li&gt;
&lt;/ol&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;&lt;b&gt; ️ 2. 트러블슈팅: 내가 만난 에러와 해결책&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;❌ Issue 1: &quot;나 분명히 만들었는데?&quot; (404 Not Found)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;새로운 모듈을 생성하고 포스트맨으로 쐈는데 404 Not Found가 떴습니다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;15,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,0&quot;&gt;원인&lt;/b&gt;: NestJS의 중앙 관리실인 AppModule에 신규 모듈인 AnalysisModule을 등록하지 않아 서버가 해당 경로를 인식하지 못했습니다. &lt;br /&gt;&lt;b data-index-in-node=&quot;85&quot; data-path-to-node=&quot;15,0&quot;&gt;해결&lt;/b&gt;: app.module.ts의 imports 배열에 모듈을 추가하여 해결!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;❌ Issue 2: FileTypeValidator의 배신 (400 Bad Request)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;이미지 파일만 받으려고 FileTypeValidator를 썼는데, 계속해서 검증 실패 에러가 났습니다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;18,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0&quot;&gt;원인&lt;/b&gt;: 저희 서버는 메모리 효율을 위해 파일을 디스크에 바로 저장하는 diskStorage를 사용합니다. 하지만 NestJS의 기본 Validator는 메모리에 있는 file.buffer를 필요로 하기 때문에 디스크 저장 시에는 버퍼가 비어있어 검증에 실패한 것이죠. &lt;br /&gt;&lt;b data-index-in-node=&quot;150&quot; data-path-to-node=&quot;18,0&quot;&gt;해결&lt;/b&gt;: 컨트롤러에서 file.mimetype을 직접 대조하는 수동 검증 로직으로 교체하여 해결했습니다.  &lt;/p&gt;
&lt;pre id=&quot;code_1772942069716&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// mimetype 직접 검증
    const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
    if (!allowedMimeTypes.includes(file.mimetype)) {
      throw new BadRequestException('이미지 파일만 업로드 가능합니다. (jpeg, jpg, png, gif)');
    }&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;hr data-path-to-node=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;  3. 핵심 코드 구현&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;  분석 요청 컨트롤러 (Swagger 적용)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;파일 업로드 형식을 명시하여 스웨거 문서에서도 편리하게 테스트할 수 있도록 구성했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQiyk&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Post('analyze')
@UseInterceptors(FileInterceptor('image'))
@ApiConsumes('multipart/form-data')
async analyzeMenu(@UploadedFile() file: Express.Multer.File) {
    // 1. mimetype 직접 검증
    const allowedMimeTypes = ['image/jpeg', 'image/png'];
    if (!allowedMimeTypes.includes(file.mimetype)) throw new BadRequestException('이미지만 가능!');

    // 2. 서비스 호출 (DB 저장 및 RabbitMQ 발행)
    return this.analysisService.createAnalysisTask(file.path);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;24&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;  4. 테스트 결과 및 사진 설명&lt;/h2&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27&quot;&gt;① 포스트맨 404 에러 상황&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;설명&lt;/b&gt;: 모듈 등록 전, 서버가 경로를 찾지 못해 404 응답을 내뱉는 당황스러운 순간입니다. 이 과정을 통해 NestJS 모듈 시스템의 중요성을 다시 한번 깨달았죠.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29&quot;&gt;② 분석 요청 성공 (201 Created)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-08 오전 11.59.21.png&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;852&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbrwxe/dJMcagEHFJ4/S6TErNjkZ405RlHJPXkXZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbrwxe/dJMcagEHFJ4/S6TErNjkZ405RlHJPXkXZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbrwxe/dJMcagEHFJ4/S6TErNjkZ405RlHJPXkXZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcbrwxe%2FdJMcagEHFJ4%2FS6TErNjkZ405RlHJPXkXZ1%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;1512&quot; height=&quot;852&quot; data-filename=&quot;스크린샷 2026-03-08 오전 11.59.21.png&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;852&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;30&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;30,0,0&quot;&gt;설명&lt;/b&gt;: 트러블슈팅 후, 드디어 이미지가 성공적으로 업로드된 모습입니다! 응답으로 전달된 taskId와 status: PENDING은 비동기 작업이 정상적으로 시작되었음을 알려줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DTO 추가 후 응답 body&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.32.11.png&quot; data-origin-width=&quot;1582&quot; data-origin-height=&quot;956&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dnaQlb/dJMcafsd3pP/DHL83qsl9aedNkBQcvkr3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dnaQlb/dJMcafsd3pP/DHL83qsl9aedNkBQcvkr3k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dnaQlb/dJMcafsd3pP/DHL83qsl9aedNkBQcvkr3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdnaQlb%2FdJMcafsd3pP%2FDHL83qsl9aedNkBQcvkr3k%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;1582&quot; height=&quot;956&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.32.11.png&quot; data-origin-width=&quot;1582&quot; data-origin-height=&quot;956&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31&quot;&gt;③ 분석 태스크 상태 조회 (200 OK)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.01.59.png&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;936&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9mMnt/dJMcacoJ91V/y0yyXU0NYInfO5eMfUPxu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9mMnt/dJMcacoJ91V/y0yyXU0NYInfO5eMfUPxu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9mMnt/dJMcacoJ91V/y0yyXU0NYInfO5eMfUPxu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9mMnt%2FdJMcacoJ91V%2Fy0yyXU0NYInfO5eMfUPxu1%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;1586&quot; height=&quot;936&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.01.59.png&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;936&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;32&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;32,0,0&quot;&gt;설명&lt;/b&gt;: 발급받은 taskId로 상태를 조회한 결과입니다. DB에 저장된 유저의 targetCalories 정보가 분석 태스크와 결합되어 안전하게 관리되고 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;33&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;33&quot;&gt;④ RabbitMQ 관리자 대시보드 (Overview)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.35.21.png&quot; data-origin-width=&quot;1056&quot; data-origin-height=&quot;672&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMUO3v/dJMcajah5rj/KeuBVMP2srV3pWb87NeP2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMUO3v/dJMcajah5rj/KeuBVMP2srV3pWb87NeP2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMUO3v/dJMcajah5rj/KeuBVMP2srV3pWb87NeP2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMUO3v%2FdJMcajah5rj%2FKeuBVMP2srV3pWb87NeP2K%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;1056&quot; height=&quot;672&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.35.21.png&quot; data-origin-width=&quot;1056&quot; data-origin-height=&quot;672&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;34&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;34,0,0&quot;&gt;설명&lt;/b&gt;: 메시지 큐 전광판에 찍힌 신호입니다! NestJS 서버(Connections: 1)가 기차역에 접속해 메시지를 성공적으로 발행(Publish)한 흔적을 그래프로 확인할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;⑤ 메시지 통로(Exchange) 설정 완료&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.35.12.png&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;804&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3I990/dJMcajah5rm/amofOKrnhbheXNuZIyIKVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3I990/dJMcajah5rm/amofOKrnhbheXNuZIyIKVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3I990/dJMcajah5rm/amofOKrnhbheXNuZIyIKVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3I990%2FdJMcajah5rm%2FamofOKrnhbheXNuZIyIKVK%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;1134&quot; height=&quot;804&quot; data-filename=&quot;스크린샷 2026-03-08 오후 12.35.12.png&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;804&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;36&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36,0,0&quot;&gt;설명&lt;/b&gt;: 우리가 코드로 정의한 menu_analysis_exchange가 리스트에 예쁘게 등록되었습니다. 이제 이곳을 통해 분석 데이터들이 FastAPI로 슝슝 날아갈 예정이에요!  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;37&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;38&quot; data-ke-size=&quot;size26&quot;&gt;  마치며&lt;/h2&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 NestJS에서 이미지 업로드를 처리하고 RabbitMQ를 통해 다른 서비스와 통신하는 첫 단추를 끼워보았습니다. 인프라 설정부터 타입 에러까지 쉽지 않았지만, 한 단계씩 뚫어내며 시스템이 견고해지는 것을 느끼니 정말 뿌듯하네요!&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;다음 포스팅에서는 이 메시지를 받아 실제 분석을 수행할 &lt;b data-index-in-node=&quot;31&quot; data-path-to-node=&quot;40&quot;&gt;FastAPI 분석 엔진&lt;/b&gt; 구현기로 돌아오겠습니다!  &lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>NestJS #TypeScript #RabbitMQ #Prisma #PostgreSQL</category>
      <category>SagaPattern #Saga패턴 #MSA #마이크로서비스</category>
      <category>Swagger #API문서화 #TroubleShooting #트러블슈팅</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/33</guid>
      <comments>https://seolhxx.tistory.com/33#entry33comment</comments>
      <pubDate>Sun, 8 Mar 2026 12:59:30 +0900</pubDate>
    </item>
    <item>
      <title>[NestJS] 회원가입부터 사용자 취향 설정까지: 인증 및 유저 모듈 통합기 (F-03, F-04)</title>
      <link>https://seolhxx.tistory.com/32</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! &lt;b data-index-in-node=&quot;7&quot; data-path-to-node=&quot;3&quot;&gt;Smart Diet Agent&lt;/b&gt; 프로젝트를 개발하고 있는 백엔드 엔지니어 로즈입니다. 오늘은 프로젝트의 초기 설계를 리팩토링하고, 사용자의 개인화된 목표를 관리하기 위한 API를 구축한 과정을 공유하려 합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;4&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ️ 1. 설계의 변화: 왜 모듈을 통합했는가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;초기 구조에서는 UsersController가 회원가입을, AuthController가 로그인을 담당하고 있었습니다. 하지만 &lt;b data-index-in-node=&quot;70&quot; data-path-to-node=&quot;6&quot;&gt;F-04(프로필 및 목표 설정)&lt;/b&gt; 기능을 추가하며 다음과 같은 고민이 생겼습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;7&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;7,0,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;7,1,0&quot;&gt;경로의 일관성&lt;/b&gt;: /auth/signup, /auth/login, /auth/preferences처럼 공통 경로를 사용해 클라이언트의 가독성을 높이고 싶었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;따라서 과감하게 users 폴더를 정리하고 모든 계정 관련 로직을 &lt;b data-index-in-node=&quot;37&quot; data-path-to-node=&quot;8&quot;&gt;Auth 모듈&lt;/b&gt;로 통합했습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;9&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ️ 2. 기술적 도전: PATCH 메서드와 Partial Update&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 닉네임만 바꾸고 싶을 때, 목표 칼로리 데이터가 유실되면 안 됩니다. 이를 위해 전체 교체인 PUT 대신 **PATCH**를 선택했습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;핵심 로직 (Spread Operator 활용)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;Prisma를 사용할 때 전개 연산자를 활용하면 DTO에 담긴 데이터만 동적으로 업데이트할 수 있습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQox8&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;TypeScript&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;async updateUserPreferences(userId: string, dto: UpdateUserPreferencesDto) {
  return this.prisma.user.update({
    where: { id: userId },
    data: {
      ...(dto.nickname &amp;amp;&amp;amp; { nickname: dto.nickname }),
      ...(dto.goalCalories !== undefined &amp;amp;&amp;amp; { goalCalories: dto.goalCalories }),
    },
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;15&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;⚠️ 3. 트러블슈팅: 사라진 목표 칼로리를 찾아서&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size23&quot;&gt;Issue 1: 응답 객체에서 특정 필드가 누락되는 현상&lt;/h3&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;수정 API 개발 후 조회를 해보니 goalCalories 필드가 보이지 않았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;원인&lt;/b&gt;: 보안을 위해 도입한 UserResponseDto에서 해당 필드를 정의하지 않아 데이터가 정제(Sanitization) 과정에서 걸러졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;해결&lt;/b&gt;: DTO에 goalCalories 필드를 추가하고 매핑 로직을 보완하여 해결했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;Issue 2: NotFoundException 임포트 에러&lt;/h3&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;예외 처리를 추가하는 과정에서 TS2304: Cannot find name 'NotFoundException' 에러가 발생했습니다. 상단의 @nestjs/common 임포트 리스트에 해당 클래스를 추가하여 즉시 해결했습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;22&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  4. Postman 테스트 결과&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;통합된 모듈이 의도대로 작동하는지 단계별로 검증했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-06 오후 6.52.10.png&quot; data-origin-width=&quot;1122&quot; data-origin-height=&quot;966&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ITJwD/dJMcabwAcDR/tzHzpfeew6kpCKHxS66kb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ITJwD/dJMcabwAcDR/tzHzpfeew6kpCKHxS66kb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ITJwD/dJMcabwAcDR/tzHzpfeew6kpCKHxS66kb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FITJwD%2FdJMcabwAcDR%2FtzHzpfeew6kpCKHxS66kb1%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;1122&quot; height=&quot;966&quot; data-filename=&quot;스크린샷 2026-03-06 오후 6.52.10.png&quot; data-origin-width=&quot;1122&quot; data-origin-height=&quot;966&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-06 오후 7.08.56.png&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;846&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTFmjl/dJMcad2ccBP/0z2cxTcDCDj6FFaCSsnGw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTFmjl/dJMcad2ccBP/0z2cxTcDCDj6FFaCSsnGw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTFmjl/dJMcad2ccBP/0z2cxTcDCDj6FFaCSsnGw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTFmjl%2FdJMcad2ccBP%2F0z2cxTcDCDj6FFaCSsnGw0%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;1120&quot; height=&quot;846&quot; data-filename=&quot;스크린샷 2026-03-06 오후 7.08.56.png&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;846&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-06 오후 6.52.38.png&quot; data-origin-width=&quot;1118&quot; data-origin-height=&quot;770&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VqOkn/dJMcagEHhh7/qw1qPk4TjpbarppK7v5JhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VqOkn/dJMcagEHhh7/qw1qPk4TjpbarppK7v5JhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VqOkn/dJMcagEHhh7/qw1qPk4TjpbarppK7v5JhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVqOkn%2FdJMcagEHhh7%2Fqw1qPk4TjpbarppK7v5JhK%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;1118&quot; height=&quot;770&quot; data-filename=&quot;스크린샷 2026-03-06 오후 6.52.38.png&quot; data-origin-width=&quot;1118&quot; data-origin-height=&quot;770&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-06 오후 6.57.16.png&quot; data-origin-width=&quot;1128&quot; data-origin-height=&quot;874&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dClyeU/dJMcabXFq7z/E0V9YuaQUSEh3DLBLzavK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dClyeU/dJMcabXFq7z/E0V9YuaQUSEh3DLBLzavK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dClyeU/dJMcabXFq7z/E0V9YuaQUSEh3DLBLzavK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdClyeU%2FdJMcabXFq7z%2FE0V9YuaQUSEh3DLBLzavK1%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;1128&quot; height=&quot;874&quot; data-filename=&quot;스크린샷 2026-03-06 오후 6.57.16.png&quot; data-origin-width=&quot;1128&quot; data-origin-height=&quot;874&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-06 오후 7.08.39.png&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;956&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GJFg7/dJMcab4qr9G/DKXke4qFyeOwQAAjvKddz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GJFg7/dJMcab4qr9G/DKXke4qFyeOwQAAjvKddz0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GJFg7/dJMcab4qr9G/DKXke4qFyeOwQAAjvKddz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGJFg7%2FdJMcab4qr9G%2FDKXke4qFyeOwQAAjvKddz0%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;1120&quot; height=&quot;956&quot; data-filename=&quot;스크린샷 2026-03-06 오후 7.08.39.png&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;956&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25,2,0&quot;&gt;프로필 수정&lt;/b&gt;: PATCH 요청을 통해 닉네임과 목표 칼로리가 정상적으로 업데이트됨을 확인&lt;/p&gt;
&lt;hr data-path-to-node=&quot;26&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;  마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;단순히 기능을 만드는 것보다 &lt;b&gt;&quot;왜 이 구조가 더 나은가?&quot;&lt;/b&gt;를 고민하며 리팩토링하는 과정에서 많은 것을 배웠습니다. 특히 DTO를 통한 데이터 정제가 보안에는 강력하지만, 필드 추가 시 꼼꼼한 관리가 필요하다는 점을 다시 한번 깨달았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;다음 단계로는 오늘 설정한 목표 데이터를 바탕으로 &lt;b data-index-in-node=&quot;28&quot; data-path-to-node=&quot;29&quot;&gt;식단 추천 알고리즘&lt;/b&gt;을 고도화해 볼 예정입니다!  &lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>Backend #API디자인 #RESTAPI #Refactoring</category>
      <category>NestJS #TypeScript #NodeJS #Prisma #Postman</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/32</guid>
      <comments>https://seolhxx.tistory.com/32#entry32comment</comments>
      <pubDate>Sat, 7 Mar 2026 13:43:11 +0900</pubDate>
    </item>
    <item>
      <title>  [NestJS/Prisma] 식단 관리 서비스의 꽃, 주간 리포트 API 구현 및 트러블슈팅</title>
      <link>https://seolhxx.tistory.com/31</link>
      <description>&lt;h2 data-path-to-node=&quot;3&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 개요: 왜 주간 리포트인가?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 매일 식단을 기록하는 이유는 결국 &lt;b&gt;'내가 잘하고 있는가?'&lt;/b&gt;를 확인하기 위함입니다. 단순히 리스트를 보여주는 CRUD를 넘어, 지난 7일간의 데이터를 집계하여 성취도를 시각화해 주는 리포트 API를 구현했습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;5&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 주요 구현 포인트: &quot;데이터가 없는 날은 어떻게 하죠?&quot;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size23&quot;&gt;✅ 시계열 데이터의 완전성 보장 (Zero-filling)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;가장 큰 고민은 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;8&quot;&gt;식단을 기록하지 않은 날&lt;/b&gt;이었습니다. DB 쿼리 결과만 그대로 반환하면 차트에서 해당 날짜가 통째로 빠져버려 흐름이 끊기게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;해결&lt;/b&gt;: 서버에서 최근 7일치 빈 객체를 포함한 Map을 미리 생성(dailyMap)하여 0으로 채워두고, DB에서 가져온 데이터를 덮어쓰는 방식을 채택했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;결과&lt;/b&gt;: 프론트엔드 차트 구현이 훨씬 단순해지고, 사용자에게도 '기록 누락'을 명확히 인지시킬 수 있게 되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;✅ 비즈니스 로직: &quot;성공&quot;의 정의&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;단순히 목표 칼로리를 안 넘으면 성공일까요?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;결정&lt;/b&gt;: 건강한 식습관을 위해 &lt;b data-index-in-node=&quot;16&quot; data-path-to-node=&quot;12,0,0&quot;&gt;목표치의 90% ~ 110% 사이&lt;/b&gt;를 섭취했을 때만 isGoalAchieved: true를 반환하도록 설계했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;이유&lt;/b&gt;: 다이어트 식단 관리는 과식뿐만 아니라 극단적인 절식도 지양해야 하기 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;13&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3.   트러블슈팅: 개발은 역시 에러와의 싸움&lt;/b&gt;&lt;/h2&gt;
&lt;p data-path-to-node=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;오늘 개발 중 가장 진땀 뺐던 두 가지 에러와 해결 과정입니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size23&quot;&gt;❌ 이슈 1: DTO 클래스 중복 및 필드 누락 (TS2300, TS2339)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,0,0&quot;&gt;현상&lt;/b&gt;: DailyNutritionDto 클래스가 중복 정의되었다는 에러와 함께, 서비스 로직에서 추가한 achievementRate 필드를 인식하지 못하는 문제가 발생했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,1,0&quot;&gt;원인&lt;/b&gt;: 코드 복사 과정에서 클래스가 두 번 선언되었고, DTO 정의에 새로운 분석용 필드들이 누락되어 TypeScript가 타입을 엄격하게 체크했기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,2,0&quot;&gt;해결&lt;/b&gt;: 중복 클래스를 제거하고, 응답 객체에 필요한 모든 통계 필드를 @ApiProperty와 함께 명시적으로 추가하여 해결했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size23&quot;&gt;❌ 이슈 2: Prisma 스키마와 서비스 로직의 불일치 (TS2353)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;현상&lt;/b&gt;: targetCalories 필드를 찾을 수 없다는 에러가 발생했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;원인&lt;/b&gt;: 서비스 로직에서는 targetCalories라는 변수명을 썼지만, 실제 Prisma 스키마(DB 필드명)는 **goalCalories**로 명명되어 있었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;해결&lt;/b&gt;: select 절에서 실제 필드명인 goalCalories를 가져온 뒤, 코드 내에서 사용할 변수에 매핑하여 데이터 정합성을 맞췄습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;2&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;3&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  [추가 섹션] API 기능 검증 및 Postman 테스트 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-05 오전 11.36.53.png&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;1396&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bb9Aky/dJMcaa5vd1g/KGhH0BVXfJkhm7KRqJSmy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bb9Aky/dJMcaa5vd1g/KGhH0BVXfJkhm7KRqJSmy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bb9Aky/dJMcaa5vd1g/KGhH0BVXfJkhm7KRqJSmy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbb9Aky%2FdJMcaa5vd1g%2FKGhH0BVXfJkhm7KRqJSmy1%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;1666&quot; height=&quot;1396&quot; data-filename=&quot;스크린샷 2026-03-05 오전 11.36.53.png&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;1396&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;설계한 주간 리포트 로직이 의도대로 동작하는지 확인하기 위해 &lt;b data-index-in-node=&quot;34&quot; data-path-to-node=&quot;4&quot;&gt;Postman&lt;/b&gt;을 사용한 통합 테스트를 진행했습니다. 주요 검증 포인트는 다음과 같습니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5&quot;&gt;1. 데이터 집계 및 기간 필터링 검증&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,0,0&quot;&gt;테스트 케이스&lt;/b&gt;: 최근 7일 이내의 식단 데이터가 있는 날과 없는 날이 섞여 있는 경우.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,0&quot;&gt;결과&lt;/b&gt;: 3월 4일에 기록한 데이터가 weeklyTotals에 정확히 합산되었으며, 기록이 없는 다른 날짜들은 0으로 채워져(Zero-filling) 총 7개의 객체가 반환됨을 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7&quot;&gt;2. 목표 달성 로직 및 수치 정확도 확인&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,0&quot;&gt;테스트 케이스&lt;/b&gt;: 섭취 칼로리가 목표 칼로리(2000kcal)의 25%인 상황.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,0&quot;&gt;결과&lt;/b&gt;: achievementRate가 25%로 정확히 산출되었고, 목표 범위(90~110%)를 벗어났으므로 isGoalAchieved가 false로 올바르게 표시되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9&quot;&gt;3. 보안 및 인증 연동&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;테스트 케이스&lt;/b&gt;: JWT 토큰을 Header에 포함하여 요청.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;결과&lt;/b&gt;: 200 OK 응답과 함께 현재 로그인한 유저의 데이터만 정확히 집계되어 반환되었습니다.&lt;/li&gt;
&lt;/ul&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;4. 마치며: ADR의 중요성&lt;/h2&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;이번 개발 과정을 통해 &lt;b data-index-in-node=&quot;13&quot; data-path-to-node=&quot;22&quot;&gt;ADR(Architecture Decision Record)&lt;/b&gt; 작성의 소중함을 다시 느꼈습니다. 왜 90~110%를 기준으로 잡았는지, 왜 실시간 집계를 선택했는지를 기록해두니 협업이나 나중에 코드를 다시 볼 때 큰 확신을 가질 수 있었습니다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;23&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;23,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,0&quot;&gt;&quot;코드는 사라질 수 있어도, 왜 그렇게 짰는지에 대한 고민의 흔적은 기술 블로그와 ADR에 남는다.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>NestJS #Prisma #TypeScript #PostgreSQL #NodeJS #Backend</category>
      <category>주간리포트 #데이터통계 #데이터집계 #Aggregation #WeeklyReport #비즈니스로직 #식단관리앱</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/31</guid>
      <comments>https://seolhxx.tistory.com/31#entry31comment</comments>
      <pubDate>Thu, 5 Mar 2026 11:52:32 +0900</pubDate>
    </item>
    <item>
      <title>Meal API Troubleshooting(API 보안 강화)</title>
      <link>https://seolhxx.tistory.com/30</link>
      <description>&lt;h2 data-path-to-node=&quot;3&quot; data-ke-size=&quot;size26&quot;&gt;  Meal API 구현 중 발생한 설계 이슈 및 해결 (Troubleshooting)&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;1. IDOR(Insecure Direct Object Reference) 취약점 방지&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;5&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,0,0&quot;&gt;문제 상황&lt;/b&gt;: 특정 식단 조회(GET /meals/:id) 시, 식단의 id값(UUID)만으로 조회할 경우 다른 사용자의 UUID를 추측하거나 입수하여 타인의 식단 기록을 열람할 위험이 있었습니다.&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;: 서비스 계층에서 조회 쿼리 작성 시, id와 함께 JWT에서 추출한 userId를 복합 조건으로 사용하도록 수정했습니다.&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;:
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 변경 전: 단순 ID 조회
const meal = await this.prisma.meal.findUnique({ where: { id } });

// 변경 후: 소유권 검증 포함
const meal = await this.prisma.meal.findFirst({
  where: { id, userId },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,3,0&quot;&gt;결과&lt;/b&gt;: 데이터 소유권이 명확히 격리되어 보안성이 향상되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;2. 403 Forbidden vs 404 Not Found: 보안적 선택&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;7&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,0,0&quot;&gt;문제 상황&lt;/b&gt;: 타인의 식단에 접근하려 할 때 403(권한 없음)을 반환하면, 공격자에게 &quot;이 ID의 데이터가 실제로 존재한다&quot;는 정보를 노출하게 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,1,0&quot;&gt;해결 방법&lt;/b&gt;: 보안성 강화를 위해 데이터가 존재하더라도 본인의 것이 아니면 일관되게 **404 Not Found**를 반환하도록 설계했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,2,0&quot;&gt;교훈&lt;/b&gt;: 'Security through Obscurity(은닉을 통한 보안)'를 통해 리소스 존재 여부 자체를 숨기는 것이 더 안전하다는 것을 배웠습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size23&quot;&gt;3. 유연한 수정을 위한 PATCH 메서드와 DTO 설계&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;문제 상황&lt;/b&gt;: 식단 수정 기능 구현 시, 모든 필드를 보내야 하는 PUT 방식은 클라이언트 측에 불필요한 데이터 전송 부하를 줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;해결 방법&lt;/b&gt;: PartialType을 활용한 UpdateMealDto를 생성하여, 클라이언트가 수정을 원하는 필드만 선택적으로 보낼 수 있도록 PATCH 메서드를 적용했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,2,0&quot;&gt;기술 포인트&lt;/b&gt;: class-validator의 @IsOptional() 데코레이터를 사용하여 타입 안정성을 유지하면서도 유연한 데이터 수신을 가능케 했습니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>api보안</category>
      <category>Authorization</category>
      <category>IDOR</category>
      <category>NestJS보안</category>
      <category>PartialType</category>
      <category>patch</category>
      <category>restapi설계</category>
      <category>권한검증</category>
      <category>데이터격리</category>
      <category>보안전략</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/30</guid>
      <comments>https://seolhxx.tistory.com/30#entry30comment</comments>
      <pubDate>Wed, 4 Mar 2026 14:05:51 +0900</pubDate>
    </item>
    <item>
      <title>[NestJS/Prisma] 식단 관리 서비스 백엔드 고도화 및 트러블슈팅 기록</title>
      <link>https://seolhxx.tistory.com/29</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;본 포스팅에서는 NestJS 환경에서 &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;3&quot;&gt;Prisma 7&lt;/b&gt;을 도입하며 겪은 설정 이슈와 인프라 최적화, 그리고 API 보안 강화를 위한 설계 결정 사항을 공유합니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;4&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. Prisma 7 환경 설정 및 스키마 구조 최적화&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6&quot;&gt;문제 상황&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 규모가 커짐에 따라 단일 schema.prisma 파일 관리의 한계가 예상되었습니다. 또한, Prisma 7에서 새롭게 도입된 설정 방식을 프로젝트에 적용해야 했습니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8&quot;&gt;해결 방법&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;Prisma Config 도입&lt;/b&gt;: prisma.config.ts를 생성하고 defineConfig를 사용하여 데이터베이스 경로 및 마이그레이션 설정을 중앙 집중화했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;스키마 폴더 분할&lt;/b&gt;: prismaSchemaFolder 프리뷰 기능을 활성화하여 도메인별(User, Meal 등)로 스키마 파일을 분리, 유지보수성을 확보했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQ0wg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// prisma.config.ts
import { defineConfig } from 'prisma/config';

export default defineConfig({
  schema: './prisma/schema',
  migrations: { path: './prisma/migrations' },
});
&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;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. InvalidClassModuleException: 모듈과 서비스의 구분&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13&quot;&gt;문제 상황&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;MealModule을 작성하는 과정에서 InvalidClassModuleException 에러가 발생하며 서버 실행이 중단되었습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQ1Ag&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;ERROR [ExceptionHandler] Classes annotated with @Injectable() must not appear in the &quot;imports&quot; array of a module.
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16&quot;&gt; 원인 분석&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;NestJS의 imports 배열에는 @Module() 데코레이터가 붙은 &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;17&quot;&gt;모듈&lt;/b&gt;만 올 수 있습니다. 하지만 @Injectable()인 PrismaService를 imports에 직접 넣으면서 발생한 설정 오류였습니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18&quot;&gt;해결 방법&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;PrismaService를 개별 모듈의 providers에 직접 넣는 대신, &lt;b data-index-in-node=&quot;43&quot; data-path-to-node=&quot;19&quot;&gt;전역 모듈(Global Module)&lt;/b&gt; 시스템을 도입하여 해결했습니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 글로벌 싱글톤 패턴을 통한 DB 커넥션 최적화&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22&quot;&gt;문제 상황&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;각 기능 모듈(Auth, Meal)에서 PrismaService를 개별적으로 주입받을 경우, 모듈별로 별도의 인스턴스가 생성되어 DB 커넥션 풀(Connection Pool)에 과부하를 줄 위험이 있었습니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;24&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24&quot;&gt;해결 방법&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;PrismaModule을 생성하고 @Global() 데코레이터를 적용하여 애플리케이션 전체에서 단 하나의 PrismaService 인스턴스만 공유하도록 설계했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQ1Qg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// src/prisma/prisma.module.ts
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;27&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. API 보안 강화: Response DTO와 소유권 검증&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29&quot;&gt;보안 이슈 1: 민감 정보 노출&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;사용자 프로필 조회 시 DB 모델을 그대로 반환할 경우, 비밀번호 해시값 등 민감한 정보가 JSON 응답에 포함되는 보안 결함이 발견되었습니다.&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;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;해결&lt;/b&gt;: UserResponseDto를 도입하여 응답 데이터의 필드를 엄격히 제한했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQ1gg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;export class UserResponseDto {
  id: string;
  email: string;
  nickname: string;

  constructor(user: any) {
    this.id = user.id;
    this.email = user.email;
    this.nickname = user.nickname;
    // password 제외
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;33&quot;&gt;보안 이슈 2: 데이터 격리(Data Isolation)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;식단 기록 조회 및 수정 시 ID 값만 검증할 경우, 다른 사용자의 UUID를 추측하여 타인의 기록에 접근할 수 있는 위험이 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;35&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;35,0,0&quot;&gt;해결&lt;/b&gt;: 모든 쿼리에 userId 조건을 필수적으로 포함하여 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;35,0,0&quot;&gt;데이터 소유권&lt;/b&gt;을 검증하도록 로직을 강화했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwii2bXPp4WTAxUAAAAAHQAAAAAQ1wg&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;async findOne(id: string, userId: string) {
  return this.prisma.meal.findFirst({
    where: { id, userId }, // 소유자 검증 로직 포함
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;37&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;38&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5. Lesson Learned&lt;/b&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;39&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;39,0,0&quot;&gt;NestJS 아키텍처 이해&lt;/b&gt;: 모듈, 컨트롤러, 서비스의 역할 분담을 명확히 하고 의존성 주입(DI) 원리를 정확히 파악하는 것이 중요함을 재확인했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;39,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;39,2,0&quot;&gt;최신 기술 스택 추적&lt;/b&gt;: Prisma 7과 같은 최신 라이브러리의 변경 사항을 신속하게 파악하고 프로젝트에 적용하는 과정에서 인프라 효율성을 높일 수 있었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-path-to-node=&quot;3&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6. API 기능 검증 및 테스트 결과 (Postman)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;설계한 비즈니스 로직이 의도대로 동작하는지 확인하기 위해 &lt;b data-index-in-node=&quot;32&quot; data-path-to-node=&quot;4&quot;&gt;Postman&lt;/b&gt;을 사용하여 각 엔드포인트에 대한 통합 테스트를 진행했습니다. 주요 검증 포인트는 &lt;b data-index-in-node=&quot;85&quot; data-path-to-node=&quot;4&quot;&gt;HTTP 상태 코드의 정확성&lt;/b&gt;, &lt;b data-index-in-node=&quot;102&quot; data-path-to-node=&quot;4&quot;&gt;데이터 무결성&lt;/b&gt;, 그리고 &lt;b data-index-in-node=&quot;115&quot; data-path-to-node=&quot;4&quot;&gt;보안 필터링&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5&quot;&gt;① 식단 기록 생성 (POST /meals)&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,0,0&quot;&gt;검증 내용&lt;/b&gt;: 클라이언트로부터 전달받은 식단 정보(제목, 종류, 칼로리 등)가 DB에 정상적으로 적재되는지 확인.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,0&quot;&gt;결과&lt;/b&gt;: 201 Created 응답과 함께 서버에서 생성된 UUID 기반의 id와 서버 시간인 mealDate가 정상 반환됨을 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.54.24.png&quot; data-origin-width=&quot;1660&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/K54rc/dJMcafTgNEi/TkdzR5OQlwdpyksxfxLbb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/K54rc/dJMcafTgNEi/TkdzR5OQlwdpyksxfxLbb0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/K54rc/dJMcafTgNEi/TkdzR5OQlwdpyksxfxLbb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FK54rc%2FdJMcafTgNEi%2FTkdzR5OQlwdpyksxfxLbb0%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;1660&quot; height=&quot;1024&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.54.24.png&quot; data-origin-width=&quot;1660&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7&quot;&gt;② 내 식단 목록 및 상세 조회 (GET /meals)&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,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;8,1,0&quot;&gt;결과&lt;/b&gt;: 목록 조회 시 배열(Array) 형태로 최신 데이터가 반환되며, 상세 조회 시 해당 기록의 소유자(userId) 정보가 일치함을 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.55.29.png&quot; data-origin-width=&quot;1662&quot; data-origin-height=&quot;840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6r2iq/dJMcafTgNEO/5roCyycaRXwIGOiHz9UISk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6r2iq/dJMcafTgNEO/5roCyycaRXwIGOiHz9UISk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6r2iq/dJMcafTgNEO/5roCyycaRXwIGOiHz9UISk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6r2iq%2FdJMcafTgNEO%2F5roCyycaRXwIGOiHz9UISk%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;1662&quot; height=&quot;840&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.55.29.png&quot; data-origin-width=&quot;1662&quot; data-origin-height=&quot;840&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-04 오후 1.07.45.png&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;980&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HwLza/dJMcaaj7PgQ/VvRWTwpL7wYRdtZobr3fH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HwLza/dJMcaaj7PgQ/VvRWTwpL7wYRdtZobr3fH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HwLza/dJMcaaj7PgQ/VvRWTwpL7wYRdtZobr3fH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHwLza%2FdJMcaaj7PgQ%2FVvRWTwpL7wYRdtZobr3fH1%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;1666&quot; height=&quot;980&quot; data-filename=&quot;스크린샷 2026-03-04 오후 1.07.45.png&quot; data-origin-width=&quot;1666&quot; data-origin-height=&quot;980&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9&quot;&gt;③ 식단 정보 수정 (PATCH /meals/:id)&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,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;10,1,0&quot;&gt;결과&lt;/b&gt;: PATCH 메서드를 통해 요청한 필드만 변경되었으며, updatedAt 시간이 수정 시점에 맞춰 갱신됨을 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.57.10.png&quot; data-origin-width=&quot;1670&quot; data-origin-height=&quot;1132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GhWpg/dJMcachWxxH/f1sppY0s1QuaLuhqP1Ph0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GhWpg/dJMcachWxxH/f1sppY0s1QuaLuhqP1Ph0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GhWpg/dJMcachWxxH/f1sppY0s1QuaLuhqP1Ph0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGhWpg%2FdJMcachWxxH%2Ff1sppY0s1QuaLuhqP1Ph0k%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;1670&quot; height=&quot;1132&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.57.10.png&quot; data-origin-width=&quot;1670&quot; data-origin-height=&quot;1132&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11&quot;&gt;④ 사용자 프로필 조회 및 보안 필터링 (GET /auth/profile)&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;검증 내용&lt;/b&gt;: UserResponseDto를 통한 민감 정보 필터링이 정상 동작하는지 확인.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;결과&lt;/b&gt;: 응답 바디에서 password 필드가 완전히 제외된 채 이메일, 닉네임 등 공개 가능한 정보만 반환되는 것을 최종 검증했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.46.14.png&quot; data-origin-width=&quot;1662&quot; data-origin-height=&quot;866&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/djpZmR/dJMcaaRYTZr/Ebe1Y6ZY0ahFglvDZ5HwBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/djpZmR/dJMcaaRYTZr/Ebe1Y6ZY0ahFglvDZ5HwBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/djpZmR/dJMcaaRYTZr/Ebe1Y6ZY0ahFglvDZ5HwBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdjpZmR%2FdJMcaaRYTZr%2FEbe1Y6ZY0ahFglvDZ5HwBk%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;1662&quot; height=&quot;866&quot; data-filename=&quot;스크린샷 2026-03-04 오후 12.46.14.png&quot; data-origin-width=&quot;1662&quot; data-origin-height=&quot;866&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-path-to-node=&quot;13&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;7. 테스트 요약 및 가용성 확인&lt;/h3&gt;
&lt;table style=&quot;background-color: #000000; color: #1f1f1f; border-collapse: collapse; width: 100%; height: 114px;&quot; border=&quot;1&quot; data-path-to-node=&quot;15&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;
&lt;td style=&quot;background-color: #efefef; color: #1f1f1f; height: 19px;&quot;&gt;&lt;b&gt;API 구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #efefef; color: #1f1f1f; height: 19px;&quot;&gt;&lt;b&gt;Method&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #efefef; color: #1f1f1f; height: 19px;&quot;&gt;&lt;b&gt;Endpoint&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #efefef; color: #1f1f1f; height: 19px;&quot;&gt;&lt;b&gt;기대 결과&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #efefef; color: #1f1f1f; height: 19px;&quot;&gt;&lt;b&gt;테스트 상태&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody style=&quot;background-color: #000000; color: #1f1f1f;&quot;&gt;
&lt;tr style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,1,0,0&quot;&gt;식단 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,1,1,0&quot;&gt;POST&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,1,2,0&quot;&gt;/meals&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,1,3,0&quot;&gt;201 Created / 데이터 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,1,4,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,1,4,0&quot;&gt;Pass&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,2,0,0&quot;&gt;목록 조회&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,2,1,0&quot;&gt;GET&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,2,2,0&quot;&gt;/meals&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,2,3,0&quot;&gt;200 OK / 본인 데이터 리스트&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,2,4,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,2,4,0&quot;&gt;Pass&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,3,0,0&quot;&gt;상세 조회&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,3,1,0&quot;&gt;GET&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,3,2,0&quot;&gt;/meals/:id&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,3,3,0&quot;&gt;200 OK / 소유권 확인&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,3,4,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,3,4,0&quot;&gt;Pass&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,4,0,0&quot;&gt;식단 수정&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,4,1,0&quot;&gt;PATCH&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,4,2,0&quot;&gt;/meals/:id&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,4,3,0&quot;&gt;200 OK / 부분 업데이트&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,4,4,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,4,4,0&quot;&gt;Pass&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,5,0,0&quot;&gt;프로필 조회&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,5,1,0&quot;&gt;GET&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,5,2,0&quot;&gt;/auth/profile&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,5,3,0&quot;&gt;200 OK / 비밀번호 필드 제외&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;background-color: #000000; color: #1f1f1f; height: 19px;&quot;&gt;&lt;span style=&quot;color: #ffffff;&quot; data-path-to-node=&quot;15,5,4,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,5,4,0&quot;&gt;Pass&lt;/b&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;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>DI</category>
      <category>DTO</category>
      <category>nestjs</category>
      <category>Prisma</category>
      <category>Prisma7</category>
      <category>기술블로그</category>
      <category>백엔드 최적화</category>
      <category>보안 가이드</category>
      <category>싱글톤패턴</category>
      <category>트러블슈팅</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/29</guid>
      <comments>https://seolhxx.tistory.com/29#entry29comment</comments>
      <pubDate>Wed, 4 Mar 2026 13:50:19 +0900</pubDate>
    </item>
    <item>
      <title>[NestJS] Prisma 7과 JWT로 철벽 보안 로그인 구현하기 (feat. 드라이버 어댑터의 늪)</title>
      <link>https://seolhxx.tistory.com/28</link>
      <description>&lt;h1&gt;[NestJS] Prisma 7 Driver Adapter 도입 및 JWT 기반 인증 시스템 구축 (F-03)&lt;/h1&gt;
&lt;p&gt;본 포스팅은 &amp;#39;Smart Diet Agent&amp;#39; 프로젝트의 인가(Authorization) 시스템 구축 과정에서 발생한 기술적 이슈와 의사결정 과정을 정리한 기록입니다.&lt;/p&gt;
&lt;h2&gt;1. 개요&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;작업 내용&lt;/strong&gt;: JWT를 활용한 인증/인가 시스템 구축 및 Prisma 7 마이그레이션&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;요구사항&lt;/strong&gt;: F-03 (사용자 인가 관리)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;핵심 스택&lt;/strong&gt;: NestJS, Prisma 7, Passport.js, PostgreSQL&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;2. Architecture Decision Records (ADR)&lt;/h2&gt;
&lt;p&gt;기능 구현에 앞서 기술적 부채를 최소화하기 위해 다음과 같은 설계 결정을 내렸습니다.&lt;/p&gt;
&lt;h3&gt;ADR-001: Prisma 7 드라이버 어댑터 패턴 채택&lt;/h3&gt;
&lt;p&gt;Prisma 7.4.1 버전의 Breaking Changes에 대응하기 위해 기존의 &lt;code&gt;datasource&lt;/code&gt; 설정 방식을 폐기하고 드라이버 어댑터 패턴을 도입했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;결정&lt;/strong&gt;: &lt;code&gt;@prisma/adapter-pg&lt;/code&gt; 채택 및 &lt;code&gt;prisma.config.ts&lt;/code&gt;를 통한 설정 일원화.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;이유&lt;/strong&gt;: 런타임 DB 연결 계층의 유연성 확보 및 최신 보안 패치 적용.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;ADR-002: 인증 전략 수립&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Password Hashing&lt;/strong&gt;: &lt;code&gt;bcrypt&lt;/code&gt;를 통한 키 스트레칭 및 솔팅 적용.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Token Strategy&lt;/strong&gt;: Stateless 환경 구현을 위한 JWT(JSON Web Token) 채택.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;3. 본격적인 JWT 인증 시스템 구축&lt;/h2&gt;
&lt;h3&gt;3.1 필수 패키지 설치&lt;/h3&gt;
&lt;p&gt;NestJS 환경에서 Passport 기반의 JWT 인증 체계를 구축하기 위해 필요한 핵심 라이브러리들을 설치했습니다. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@nestjs/jwt&lt;/code&gt;: JWT 생성 및 검증을 위한 NestJS 래퍼 모듈&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@nestjs/passport&lt;/code&gt;, &lt;code&gt;passport-jwt&lt;/code&gt;: 인증 미들웨어인 Passport 연동 및 JWT 전략 라이브러리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@nestjs/config&lt;/code&gt;: 환경 변수(&lt;code&gt;.env&lt;/code&gt;) 관리를 위한 모듈&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 관련 패키지 설치
npm install @nestjs/jwt @nestjs/passport passport passport-jwt @nestjs/config
npm install -D @types/passport-jwt&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 JwtModule 비동기 설정&lt;/h3&gt;
&lt;p&gt;보안을 위해 JWT_SECRET과 EXPIRES_IN 등의 설정값은 소스 코드에 하드코딩하지 않고 환경 변수(.env)에서 관리합니다. 이를 위해 ConfigService가 완전히 로드된 후 모듈을 초기화하도록 registerAsync 방식을 채택했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-TypeScript&quot;&gt;// src/auth/auth.module.ts

JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: async (configService: ConfigService) =&amp;gt; ({
    // getOrThrow를 사용하여 런타임 시 필수 설정값 누락 방지
    secret: configService.getOrThrow&amp;lt;string&amp;gt;(&amp;#39;JWT_SECRET&amp;#39;),
    signOptions: { 
      // ms 라이브러리 규격에 따른 만료 시간 설정 (ex: &amp;#39;3600s&amp;#39;)
      expiresIn: configService.get&amp;lt;string&amp;gt;(&amp;#39;JWT_EXPIRES_IN&amp;#39;) as any 
    },
  }),
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의존성 주입(DI)을 통해 ConfigService를 활용함으로써, 인증 모듈이 환경 설정 모듈에 유연하게 대응할 수 있도록 구조화했습니다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 주요 구현 상세&lt;/h2&gt;
&lt;h3&gt;4.1 JwtModule 비동기 설정&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ConfigService&lt;/code&gt;를 주입받아 환경 변수에서 시크릿 키를 로드하는 비동기 등록 방식을 사용했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: async (configService: ConfigService) =&amp;gt; ({
    secret: configService.getOrThrow&amp;lt;string&amp;gt;(&amp;#39;JWT_SECRET&amp;#39;),
    signOptions: { 
      expiresIn: configService.get&amp;lt;string&amp;gt;(&amp;#39;JWT_EXPIRES_IN&amp;#39;) as any 
    },
  }),
})&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2 JwtStrategy 및 Guard 구현&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;PassportStrategy&lt;/code&gt;를 상속받아 HTTP 요청 헤더의 &lt;strong&gt;Bearer 토큰&lt;/strong&gt;을 추출 및 검증하는 로직을 구축했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;추출 및 검증&lt;/strong&gt;: &lt;code&gt;ExtractJwt.fromAuthHeaderAsBearerToken()&lt;/code&gt;을 통해 토큰을 확보하고, 설정된 &lt;code&gt;secretOrKey&lt;/code&gt;로 서명의 유효성을 판단합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;사용자 주입&lt;/strong&gt;: &lt;code&gt;validate&lt;/code&gt; 메서드 내에서 페이로드의 식별자(&lt;code&gt;sub&lt;/code&gt;)를 이용해 DB 내 실존 사용자인지 검증한 후, 해당 유저 객체를 &lt;code&gt;Request&lt;/code&gt; 객체에 자동으로 주입하여 후속 로직에서 활용할 수 있게 설계했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;5. Troubleshooting: TypeScript 엄격한 타입 체크 대응&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;문제 상황&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ConfigService.get()&lt;/code&gt; 메서드를 사용하여 환경 변수를 로드할 때, 반환 타입이 &lt;code&gt;string | undefined&lt;/code&gt;로 추론되면서 &lt;code&gt;JwtModule&lt;/code&gt; 및 &lt;code&gt;Passport&lt;/code&gt; 옵션의 기대 타입(&lt;code&gt;string&lt;/code&gt;)과 충돌하는 정적 컴파일 에러가 발생했습니다.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;원인 분석&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;TypeScript의 &lt;code&gt;strictNullChecks&lt;/code&gt; 옵션이 활성화된 환경에서, 런타임 시 환경 변수(&lt;code&gt;.env&lt;/code&gt;) 누락으로 인한 &lt;code&gt;undefined&lt;/code&gt; 참조 가능성을 컴파일 단계에서 차단하기 위한 제약 사항임을 확인했습니다.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;해결 방안&lt;/strong&gt;&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;getOrThrow&amp;lt;T&amp;gt;()&lt;/code&gt; 적용&lt;/strong&gt;: &lt;code&gt;get()&lt;/code&gt; 대신 &lt;code&gt;getOrThrow()&lt;/code&gt;를 사용하여 환경 변수 미존재 시 런타임에서 즉시 예외를 발생시킴으로써, 반환 타입에서 &lt;code&gt;undefined&lt;/code&gt;를 제거하고 타입 안정성을 확보했습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicit Type Casting&lt;/strong&gt;: &lt;code&gt;signOptions&lt;/code&gt;의 &lt;code&gt;expiresIn&lt;/code&gt; 필드와 같이 라이브러리 간 인터페이스 타입이 상이한 경우, &lt;code&gt;as any&lt;/code&gt; 또는 명시적 타입 캐스팅을 통해 타입 불일치 문제를 우회하여 해결했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;6. 결과 및 검증&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;Swagger UI 연동&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@ApiBearerAuth()&lt;/code&gt; 데코레이터를 적용하여 Swagger 명세서 상에서 JWT 인증 테스트를 진행할 수 있는 환경을 구축했습니다. 전역 &lt;code&gt;addBearerAuth()&lt;/code&gt; 설정을 통해 모든 보호된 엔드포인트에 대한 인증 인터페이스를 통일했습니다.&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;인가(Authorization) 테스트&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;JwtAuthGuard&lt;/code&gt;가 적용된 &lt;code&gt;/auth/profile&lt;/code&gt; 엔드포인트를 대상으로 다음과 같이 테스트를 수행했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Case 1&lt;/strong&gt;: 유효한 토큰 포함 시 &lt;code&gt;200 OK&lt;/code&gt; 및 &lt;code&gt;req.user&lt;/code&gt; 데이터 반환 확인.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdPDeT/dJMcacWvfJ9/FYwykAiiaQaMbyBjJpL54K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdPDeT/dJMcacWvfJ9/FYwykAiiaQaMbyBjJpL54K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdPDeT/dJMcacWvfJ9/FYwykAiiaQaMbyBjJpL54K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdPDeT%2FdJMcacWvfJ9%2FFYwykAiiaQaMbyBjJpL54K%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOGolk/dJMcajuxWAQ/myFKKxMppokxoB4DdZfMh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOGolk/dJMcajuxWAQ/myFKKxMppokxoB4DdZfMh0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOGolk/dJMcajuxWAQ/myFKKxMppokxoB4DdZfMh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOGolk%2FdJMcajuxWAQ%2FmyFKKxMppokxoB4DdZfMh0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Case 2&lt;/strong&gt;: 토큰 누락 또는 유효하지 않은 토큰 전송 시 &lt;code&gt;401 Unauthorized&lt;/code&gt; 정상 차단 확인.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHiePX/dJMcafMvlFf/33vCZjhqaL8R6ThZbMt4eK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHiePX/dJMcafMvlFf/33vCZjhqaL8R6ThZbMt4eK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHiePX/dJMcafMvlFf/33vCZjhqaL8R6ThZbMt4eK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHiePX%2FdJMcafMvlFf%2F33vCZjhqaL8R6ThZbMt4eK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;7. 결론 및 향후 계획&lt;/h2&gt;
&lt;p&gt;인증 시스템의 기초 설계와 인가 가드(Guard) 구현을 완료함으로써 보안 계층의 안정성을 확보했습니다. 이를 기반으로 다음 단계인 &lt;strong&gt;F-04: 식단 기록(Meal CRUD) API&lt;/strong&gt; 개발로 전환할 예정입니다. &lt;/p&gt;
&lt;p&gt;향후 식단 데이터의 &lt;strong&gt;소유권 검증(Ownership Validation)&lt;/strong&gt; 로직을 Guard 계층에서 범용적으로 처리할지, 혹은 Service 계층에서 비즈니스 로직으로 포함할지에 대한 아키텍처 설계를 구체화할 계획입니다.&lt;/p&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>NestJS #Prisma #Prisma7 #JWT #Backend #백엔드 #Nodejs #TypeScript #타입스크립트</category>
      <category>Passportjs #인증 #Authorization #보안 #Bcrypt #BearerToken</category>
      <category>SmartDietAgent #개발일지 #포트폴리오</category>
      <category>트러블슈팅 #Troubleshooting #ADR #환경변수 #ConfigService #DriverAdapter</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/28</guid>
      <comments>https://seolhxx.tistory.com/28#entry28comment</comments>
      <pubDate>Mon, 2 Mar 2026 16:27:23 +0900</pubDate>
    </item>
    <item>
      <title>[Deep Dive] Spring WebFlux와 Optimistic UI: 기술적 한계를 넘어선 성능 최적화</title>
      <link>https://seolhxx.tistory.com/27</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트&amp;nbsp; 개발하며 단순히 기능을 구현하는 것에 그치지 않고, 선택한 기술이 가진 &lt;b&gt;트레이드 오프(Trade-off)&lt;/b&gt;를 고민하고 문제를 해결했던 과정을 기록합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6&quot;&gt;1. Spring WebFlux: 고성능을 위한 선택과 그 이면&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;주식 데이터와 AI 응답을 처리하기 위해 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;7&quot;&gt;Spring WebFlux&lt;/b&gt;를 도입했습니다. 적은 스레드로 대량의 요청을 처리할 수 있다는 장점이 있었지만, 실제 적용 과정에서 마주한 단점과 극복 방안을 공유합니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8&quot;&gt;⚠️ 마주한 한계점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;9&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,0,0&quot;&gt;디버깅의 복잡성:&lt;/b&gt; 비동기 논블로킹 방식으로 동작하다 보니, 에러 발생 시 스택 트레이스(Stack Trace)가 여러 스레드에 걸쳐 있어 원인을 한눈에 파악하기 어려웠습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9,1,0&quot;&gt;학습 곡선:&lt;/b&gt; 명령형(Imperative) 방식에 익숙했던 팀원들에게 Mono와 Flux 연산자는 코드 가독성을 떨어뜨리는 요인이 되기도 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10&quot;&gt;  해결 전략&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;log() 연산자와 checkpoint()를 활용하여 데이터 흐름의 단계를 기록함으로써 디버깅 효율을 높였습니다.&lt;/li&gt;
&lt;li&gt;모든 로직을 WebFlux로 짜기보다, CPU 연산이 집중되는 부분과 I/O가 집중되는 부분을 분리하여 자원을 효율적으로 배분했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;12&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13&quot;&gt;2. Race Condition 제어: DB 제약 조건을 활용한 정합성 확보&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;사용자가 관심 종목 버튼을 빠르게 여러 번 클릭할 경우, 동일한 데이터가 중복으로 저장되는 &lt;b data-index-in-node=&quot;51&quot; data-path-to-node=&quot;14&quot;&gt;경쟁 상태(Race Condition)&lt;/b&gt; 문제가 발생할 수 있었습니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15&quot;&gt; ️ 구현 코드: 예외 핸들링을 통한 제어&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;단순히 서버 코드에서 체크하는 방식은 동시 요청 시 뚫릴 위험이 있어, &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;16&quot;&gt;DB 유니크 제약 조건&lt;/b&gt;을 최종 수비수로 활용했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwitme7U4eeSAxUAAAAAHQAAAAAQzQE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Transactional
public Mono&amp;lt;Void&amp;gt; addWatchlist(WatchlistRequest request) {
    return watchlistRepository.save(new Watchlist(request.getUserId(), request.getStockCode()))
            .onErrorResume(DataIntegrityViolationException.class, e -&amp;gt; {
                // 중복 데이터 인입 시 사용자에게 친절한 에러 메시지 반환
                return Mono.error(new AlreadyExistsException(&quot;이미 등록된 종목입니다.&quot;));
            })
            .then();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;blockquote data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;18,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0&quot;&gt;  Tech Dictionary: 경쟁 상태(Race Condition)&lt;/b&gt; 두 개 이상의 프로세스가 공통 자원에 동시에 접근하여 결과값이 달라지는 현상입니다. 저는 이를 방지하기 위해 DB 레벨의 Unique Index를 설정하고 예외를 캐치하는 방식을 택했습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-path-to-node=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;20&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;20&quot;&gt;3. 데이터 정합성 유지: 로컬과 서버의 싱크(Sync) 맞추기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;낙관적 UI(Optimistic UI)를 구현하면서, 로컬 스토리지에 먼저 데이터를 쓰고 서버 호출에 실패했을 때의 &lt;b data-index-in-node=&quot;65&quot; data-path-to-node=&quot;21&quot;&gt;데이터 불일치&lt;/b&gt; 문제를 해결해야 했습니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22&quot;&gt;  Dual-Write와 보상 트랜잭션&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;서버 API 호출이 실패했을 때, 사용자가 보는 화면(로컬 데이터)을 이전 상태로 되돌리는 &lt;b data-index-in-node=&quot;51&quot; data-path-to-node=&quot;23&quot;&gt;'롤백(Rollback)'&lt;/b&gt; 로직을 프론트엔드에 구현했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;24&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;24,0,0&quot;&gt;UI 업데이트:&lt;/b&gt; 버튼 클릭 즉시 로컬 스토리지 업데이트 및 화면 반영.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,1,0&quot;&gt;API 호출:&lt;/b&gt; 백엔드 서버로 데이터 전송.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;24,2,0&quot;&gt;예외 처리:&lt;/b&gt; API 실패 시 catch 블록에서 로컬 스토리지를 이전 상태로 복구(Compensation)하고 에러 메시지 노출.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-path-to-node=&quot;25&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;26&quot;&gt;4. 마치며: '왜?'라는 질문에 답하는 개발&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;이번 프로젝트를 통해 기술은 단순히 유행을 따르는 것이 아니라, &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;27&quot;&gt;서비스의 특성과 제약 사항에 맞춰 선택해야 함&lt;/b&gt;을 깨달았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;28&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,0,0&quot;&gt;WebFlux&lt;/b&gt;는 응답성을 높여주었지만 디버깅 비용을 요구했고,&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;28,1,0&quot;&gt;Optimistic UI&lt;/b&gt;는 체감 속도를 높여주었지만 정합성 관리라는 숙제를 던져주었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;이러한 고민의 과정들이 모여 더 견고한 서비스를 만드는 밑거름이 되었다고 생각합니다. 앞으로도 기술의 명암을 동시에 살피는 개발자로 성장하겠습니다.&lt;/p&gt;</description>
      <category>면접 준비/포트폴리오</category>
      <category>면접준비 #백엔드질문 #WebFlux단점 #데이터정합성 #RaceCondition #개발자면접 #CS지식 #트러블슈팅 #OptimisticUI #정합성유지</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/27</guid>
      <comments>https://seolhxx.tistory.com/27#entry27comment</comments>
      <pubDate>Thu, 26 Feb 2026 14:29:16 +0900</pubDate>
    </item>
    <item>
      <title>03. Prisma 7 + NestJS 셋업 트러블 슈팅 &amp;mdash; `datasources`는 이제 없다</title>
      <link>https://seolhxx.tistory.com/26</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;환경:&lt;/strong&gt; NestJS + Prisma 7.4.1 + PostgreSQL / macOS&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;배경&lt;/h2&gt;
&lt;p&gt;NestJS 백엔드 프로젝트를 새로 세팅하면서 Prisma를 ORM으로 선택했다. 예전에 쓰던 코드를 그대로 가져왔는데, 서버를 켜는 순간 오류 폭탄을 맞았다. Prisma 7은 이전 버전과 설정 방식이 &lt;strong&gt;완전히 바뀌어 있었다&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1단계 오류 — &lt;code&gt;datasources&lt;/code&gt; is Unknown&lt;/h2&gt;
&lt;p&gt;서버 실행 시 처음 만난 오류:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PrismaClientConstructorValidationError: Unknown property datasources provided to PrismaClient constructor.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;기존 &lt;code&gt;PrismaService&lt;/code&gt;는 이렇게 생겼었다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;super({
  datasources: {
    db: { url: process.env.DATABASE_URL },
  },
} as any);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prisma 6까지는 잘 동작하던 코드다. 그런데 Prisma 7에서는 &lt;strong&gt;&lt;code&gt;datasources&lt;/code&gt; 옵션이 생성자에서 완전히 제거됐다.&lt;/strong&gt; 에러 메시지를 보고 공식 문서 링크(&lt;code&gt;https://pris.ly/d/client-constructor&lt;/code&gt;)를 따라가 봤다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2단계 오류 — &lt;code&gt;PrismaClientInitializationError&lt;/code&gt;: non-empty options required&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;datasources&lt;/code&gt;를 제거하고 &lt;code&gt;super()&lt;/code&gt;만 쓰니 이번엔 이런 오류가 났다:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PrismaClientInitializationError: `PrismaClient` needs to be constructed with a non-empty, valid `PrismaClientOptions`&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Prisma 7에서는 &lt;code&gt;prisma.config.ts&lt;/code&gt;가 별도로 존재하면 클라이언트 생성자에 &lt;strong&gt;반드시 옵션을 넘겨야 하는데, 동시에 &lt;code&gt;datasources&lt;/code&gt;는 쓸 수 없는&lt;/strong&gt; 상황이 생긴다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3단계 오류 — &lt;code&gt;datasourceUrl&lt;/code&gt; does not exist in type&lt;/h2&gt;
&lt;p&gt;그래서 Prisma 7 문서에서 찾은 &lt;code&gt;datasourceUrl&lt;/code&gt; 옵션을 써봤다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;super({
  datasourceUrl: process.env.DATABASE_URL,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TypeScript 컴파일 오류:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error TS2353: Object literal may only specify known properties, and &amp;#39;datasourceUrl&amp;#39; does not exist in type &amp;#39;Subset&amp;lt;PrismaClientOptions, PrismaClientOptions&amp;gt;&amp;#39;.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;as any&lt;/code&gt;로 우회했더니, 이번엔 런타임에서:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PrismaClientConstructorValidationError: Unknown property datasourceUrl provided to PrismaClient constructor.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;즉, &lt;code&gt;prisma.config.ts&lt;/code&gt;가 존재하면 생성자에서 어떤 datasource 관련 옵션도 받지 않는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4단계 오류 — schema.prisma에서 &lt;code&gt;url&lt;/code&gt; 사용 불가&lt;/h2&gt;
&lt;p&gt;전통적인 방식으로 돌아가려고 &lt;code&gt;schema.prisma&lt;/code&gt;에 url을 추가했다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;datasource db {
  provider = &amp;quot;postgresql&amp;quot;
  url      = env(&amp;quot;DATABASE_URL&amp;quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;npx prisma generate&lt;/code&gt;를 실행하니:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: P1012
The datasource property `url` is no longer supported in schema files.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Prisma 7은 &lt;strong&gt;스키마 파일에서 url 설정을 완전히 금지&lt;/strong&gt;했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5단계 오류 — &lt;code&gt;@prisma/client&lt;/code&gt;를 찾을 수 없음&lt;/h2&gt;
&lt;p&gt;이제 Prisma 7의 새로운 방식인 &lt;strong&gt;드라이버 어댑터&lt;/strong&gt;를 써야 한다는 걸 알았다. &lt;code&gt;previewFeatures = [&amp;quot;driverAdapters&amp;quot;]&lt;/code&gt;를 추가하고 &lt;code&gt;npx prisma generate&lt;/code&gt;를 실행했더니:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Error: Could not resolve @prisma/client.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;프로젝트 구조가 모노레포처럼 되어 있어서 &lt;strong&gt;루트의 &lt;code&gt;node_modules&lt;/code&gt;에 설치된 &lt;code&gt;prisma&lt;/code&gt; CLI&lt;/strong&gt;가 실행되고 있었고, 정작 &lt;strong&gt;&lt;code&gt;smart-diet-backend&lt;/code&gt; 안에는 &lt;code&gt;prisma&lt;/code&gt; CLI 자체가 없었다.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# smart-diet-backend에 prisma CLI가 없음을 확인
ls ./node_modules/.bin/prisma  # → No such file or directory&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;최종 해결 방법&lt;/h2&gt;
&lt;h3&gt;1. &lt;code&gt;smart-diet-backend&lt;/code&gt;에 패키지 설치&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd smart-diet-backend
npm install prisma --save-dev
npm install @prisma/adapter-pg pg
npm install -D @types/pg&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;포인트:&lt;/strong&gt; 루트의 &lt;code&gt;npx prisma generate&lt;/code&gt;가 계속 실패했던 이유가 바로 여기 있었다. &lt;code&gt;smart-diet-backend&lt;/code&gt; 안에 &lt;code&gt;prisma&lt;/code&gt; CLI 자체가 없었기 때문에 &lt;code&gt;./node_modules/.bin/prisma&lt;/code&gt; 경로 자체가 존재하지 않았다. &lt;code&gt;npm install prisma --save-dev&lt;/code&gt;로 로컬에 설치한 뒤, 아래처럼 직접 경로를 지정해서 실행하니 처음으로 성공했다:&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./node_modules/.bin/prisma generate&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이후에는 &lt;code&gt;npx&lt;/code&gt;가 로컬 &lt;code&gt;node_modules&lt;/code&gt;를 우선 탐색하므로 &lt;code&gt;npx prisma generate&lt;/code&gt;로도 정상 동작한다.&lt;/p&gt;
&lt;h3&gt;2. &lt;code&gt;schema.prisma&lt;/code&gt; — url 없이, driverAdapters 선언&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;generator client {
  provider        = &amp;quot;prisma-client-js&amp;quot;
  previewFeatures = [&amp;quot;driverAdapters&amp;quot;]
}

datasource db {
  provider = &amp;quot;postgresql&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. &lt;code&gt;prisma.config.ts&lt;/code&gt; — CLI(migrate, generate)용 URL 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { defineConfig } from &amp;#39;@prisma/config&amp;#39;;
import &amp;#39;dotenv/config&amp;#39;;

export default defineConfig({
  datasource: {
    url: process.env.DATABASE_URL,
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. &lt;code&gt;prisma.service.ts&lt;/code&gt; — 드라이버 어댑터로 연결&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { Injectable, OnModuleInit } from &amp;#39;@nestjs/common&amp;#39;;
import { PrismaClient } from &amp;#39;@prisma/client&amp;#39;;
import { PrismaPg } from &amp;#39;@prisma/adapter-pg&amp;#39;;
import &amp;#39;dotenv/config&amp;#39;;

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  constructor() {
    const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
    super({ adapter });
  }

  async onModuleInit() {
    await this.$connect();
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. 클라이언트 재생성 및 서버 실행&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma generate
npm run start:dev&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;핵심 정리 — Prisma 7의 변경점&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Prisma 6 이하&lt;/th&gt;
&lt;th&gt;Prisma 7&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;생성자 URL 설정&lt;/td&gt;
&lt;td&gt;&lt;code&gt;datasources: { db: { url } }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;생성자에서 불가, 어댑터 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스키마 URL 설정&lt;/td&gt;
&lt;td&gt;&lt;code&gt;url = env(&amp;quot;DATABASE_URL&amp;quot;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;지원 안 함&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI URL 설정&lt;/td&gt;
&lt;td&gt;스키마 파일&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma.config.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB 연결 방식&lt;/td&gt;
&lt;td&gt;내장 엔진&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;드라이버 어댑터 필수&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;교훈&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Prisma 7은 메이저 버전답게 breaking change가 크다.&lt;/strong&gt; 마이그레이션 가이드를 먼저 읽자.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;모노레포 구조에서는 패키지 위치에 주의.&lt;/strong&gt; CLI가 어느 &lt;code&gt;node_modules&lt;/code&gt;를 참조하는지 꼭 확인해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prisma.config.ts&lt;/code&gt;와 &lt;code&gt;schema.prisma&lt;/code&gt;의 역할이 분리됐다. 스키마는 모델 정의만, URL 설정은 &lt;code&gt;prisma.config.ts&lt;/code&gt;가 담당한다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프로젝트/개인프로젝트</category>
      <category>Prisma #Prisma7 #NestJS #TypeScript #PostgreSQL</category>
      <category>백엔드 #Backend #NodeJS #개발블로그 #개발자</category>
      <category>트러블슈팅 #Troubleshooting #드라이버어댑터 #DriverAdapter #ORM</category>
      <author> 설희hxx</author>
      <guid isPermaLink="true">https://seolhxx.tistory.com/26</guid>
      <comments>https://seolhxx.tistory.com/26#entry26comment</comments>
      <pubDate>Thu, 26 Feb 2026 13:49:23 +0900</pubDate>
    </item>
  </channel>
</rss>