<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>rose.log</title>
        <link>https://velog.io/</link>
        <description>개발자를 꿈꾸며, 하루하루 쌓아가는 로제의 지식 아카이브입니다.</description>
        <lastBuildDate>Sat, 20 Sep 2025 09:09:01 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>rose.log</title>
            <url>https://velog.velcdn.com/images/dob-by/profile/4e8b5e8d-b9eb-423d-990a-2e05d8291703/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. rose.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/dob-by" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[LCEL로 RAG 파이프라인 구축하기]]></title>
            <link>https://velog.io/@dob-by/LCEL%EB%A1%9C-RAG-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@dob-by/LCEL%EB%A1%9C-RAG-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 20 Sep 2025 09:09:01 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@dob-by/LCEL">지난 글</a>에서 LCEL이 뭔지, 그리고 왜 중요한지 개념적으로 정리해보았습니다. 간단히 복습하면 LCEL은 위 그림처럼 Runnable 단위를 쌓아올려서 최종적으로 체인이나 파이프라인을 만드는 구조입니다.</p>
<p>이번 포스팅에서는 이 구조를 실제 코드로 구현해보며 RAG 파이프라인 안에서 검색, 채팅, 메모리, 비동기 실행까지 직접 확인해보겠습니다.</p>
<h1 id="실습-목표">실습 목표</h1>
<p>이번 실습의 목표는 LCEL의 다양한 실행 방식과 블록들을 직접 체험하는 것입니다. </p>
<p>구체적으로는 다음을 확인해볼 것입니다.</p>
<blockquote>
<p><strong>1. 검색 모드</strong>: 벡터DB에서 관련 문서 찾기
<strong>2. 채팅 모드(Streaming)</strong>: 답변을 토큰 단위로 스트리밍 출력
<strong>3. 메모리 적용</strong>: RunnableWithMessageHistory를 활용하여 대화 맥락 유지
<strong>4. 배치 처리</strong>: 여러 질문을 한 번에 처리하는 batch/abatch 실행
<strong>5. 비동기 실행</strong>: ainvoke/astream으로 동시에 요청 처리</p>
</blockquote>
<p>정리하면 <span style="background-color:#FFE6E6"><strong>&quot;검색 → 답변 → 맥락 유지 → 병렬/비동기 처리라는 흐름을 하나의 파이프라인에서 확인하는 것&quot;</strong>이 핵심 목표</span>입니다.</p>
<h1 id="코드-구조-요약">코드 구조 요약</h1>
<p>이번 실습 코드는 기능별로 모듈을 나누어 구성했습니다.</p>
<blockquote>
<p>lcel_pipeline/
├── config.py                <span style="color:green"><small># 설정 및 상수</span></small>
├── models.py                <span style="color:green"><small># 데이터 모델(KeywordSection 등)</small></span>
├── document_processor.py    <span style="color:green"><small># 문서 로드 &amp; 섹션/청크 분리</small></span>
├── vector_store_manager.py  <span style="color:green"><small># 벡터스토어 구축 및 로드</small></span>
├── rag_chain_builder.py     <span style="color:green"><small># LCEL 기반 RAG 체인</small></span>
├── chat_history_manager.py  <span style="color:green"><small># 대화 히스토리 관리 (RunnableWithMessageHistory)</span></small>
├── main.py                  <span style="color:green"><small># RAGApplication (검색/채팅/비동기/배치 실행)</span></small>
│
├── run.py                   <span style="color:green"><small># 기본 실행 스크립트</small></span>
├── interactive_chat.py      <span style="color:green"><small># CLI 대화형 실행</small></span>
│
├── finance-keywords.txt     <span style="color:green"><small># 금융 키워드 데이터</small></span>
├── nlp-keywords.txt         <span style="color:green"><small># NLP 키워드 데이터</small></span>
└── faiss_keywords/          <span style="color:green"><small># 실행 시 생성되는 벡터스토어 디렉토리</small></span></p>
</blockquote>
<p><strong>1. 핵심 모듈</strong></p>
<ul>
<li>config.py: 모델명, 경로, 검색 파라미터 등 공통 설정</li>
<li>models.py: 데이터 모델 정의 (KeywordSection 등)</li>
<li>document_processor.py: 문서 로드 및 섹션/청크 분리</li>
<li>vector_store_manager.py: 임베딩 생성 &amp; 벡터스토어(FAISS) 관리</li>
<li>rag_chain_builder.py: LCEL 기반 RAG 체인 (Branch, Memory, Stream 포함)</li>
<li>chat_history_manager.py: 대화 히스토리 관리 (RunnableWithMessageHistory)</li>
<li>main.py: RAGApplication 클래스 → 검색, 채팅, 비동기, 배치 실행 총괄</li>
</ul>
<p><strong>2. 실행 스크립트</strong></p>
<ul>
<li>run.py: 기본 실행 스크립트</li>
<li>interactive_chat.py: CLI 기반 대화형 실행 (검색/채팅 모드 선택)</li>
</ul>
<p><strong>3. 데이터 파일</strong></p>
<ul>
<li>finance-keywords.txt: 금융 키워드 데이터</li>
<li>nlp-keywords.txt: NLP 키워드 데이터</li>
<li>faiss_keywords/: 실행 시 자동 생성되는 벡터스토어 디렉토리</li>
<li>index.faiss / index.pkl</li>
</ul>
<h1 id="데이터-플로우">데이터 플로우</h1>
<p><img src="https://velog.velcdn.com/images/dob-by/post/10f0dfc5-9f0f-4fc6-8252-ddb26e0ca257/image.png" alt=""></p>
<h1 id="파이프라인-구축">파이프라인 구축</h1>
<h2 id="앱-초기화">앱 초기화</h2>
<p>아래 함수 하나로 <strong><span style="background-color:#FFE6E6">문서 로드 → 청크 분할 → 벡터스토어 구축(FAISS) → retriever 생성 → RAG체인 연결 → 메모리</span>까지 한 번에 준비</strong>됩니다.</p>
<p>즉, <strong>질문을 던지면 바로 검색과 답변이 가능하도록 RAG 파이프라인 전체를 세팅하는 단계</strong>입니다.</p>
<pre><code class="language-python">def initialize(self) -&gt; None:
    print(&quot;문서를 로드하는 중...&quot;)
    docs = DocumentProcessor.load_documents(FILES)
    if not docs:
        raise ValueError(&quot;문서를 찾지 못했거나 섹션이 비어 있습니다.&quot;)

    print(&quot;문서를 청크로 분할하는 중...&quot;)
    split_docs = DocumentProcessor.split_long_docs(docs)

    print(&quot;벡터 스토어를 구축하는 중...&quot;)
    vs = VectorStoreManager.build_and_save_vectorstore(split_docs)
    self.retriever = vs.as_retriever(search_kwargs={&quot;k&quot;: DEFAULT_RETRIEVER_K})

    print(&quot;RAG 체인을 구축하는 중...&quot;)
    rag_chain = RAGChainBuilder.make_rag_chain(self.retriever)

    self.chain_with_history = RunnableWithMessageHistory(
        rag_chain,
        self.history_manager.get_session_history,
        input_messages_key=&quot;query&quot;,
        history_messages_key=&quot;history&quot;,
    )
    print(&quot;초기화 완료!&quot;)</code></pre>
<h3 id="코드-상세-설명">코드 상세 설명</h3>
<p><strong>1. 문서 로드</strong></p>
<pre><code class="language-python">def initialize(self) -&gt; None:
    print(&quot;문서를 로드하는 중...&quot;)
    docs = DocumentProcessor.load_documents(FILES)
    if not docs:
        raise ValueError(&quot;문서를 찾지 못했거나 섹션이 비어 있습니다.&quot;)</code></pre>
<p>텍스트 파일(<code>finance-keywords.txt</code>, <code>nlp-keywords.txt</code>)을 읽어 Document객체로 변환합니다. 만약 파일이 비어있다면 에러를 발생시켜 초기화가 멈추게합니다.</p>
<p><strong>2. 청크 분할</strong></p>
<pre><code class="language-python">    print(&quot;문서를 청크로 분할하는 중...&quot;)
    split_docs = DocumentProcessor.split_long_docs(docs)</code></pre>
<p>이 단계에서는 문서를 그대로 쓰지 않고 길이가 너무 긴 섹션은 자동으로 잘라냅니다.
실제로는 <code>DocumentProcessor.split_long_docs()</code> 안에서 <code>RecursiveCharacterTextSplitter</code>라는 유틸을 써서, 일정한 크기(chunk_size=800) 단위로 청크를 나누고, 겹치는 부분(chunk_overlap=120)도 남겨둡니다. 이렇게 해야 LLM이 문맥을 놓치지 않고 검색 정확도가 올라갑니다. </p>
<p>아래는 <code>split_long_docs</code> 함수를 정의한 부분입니다.</p>
<pre><code class="language-python"># document_processor.py
    @staticmethod
    def split_long_docs(docs: List[Document], chunk_size: int = DEFAULT_CHUNK_SIZE, 
                       chunk_overlap: int = DEFAULT_CHUNK_OVERLAP) -&gt; List[Document]:
        &quot;&quot;&quot;긴 문서들을 청크로 분할합니다.

        Args:
            docs: 분할할 Document 객체들의 리스트
            chunk_size: 청크 크기
            chunk_overlap: 청크 간 겹치는 부분의 크기

        Returns:
            분할된 Document 객체들의 리스트
        &quot;&quot;&quot;
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=TEXT_SPLITTER_SEPARATORS,
        )
        return splitter.split_documents(docs)</code></pre>
<p><strong>3. 벡터스토어 구축</strong></p>
<pre><code class="language-python">    print(&quot;벡터 스토어를 구축하는 중...&quot;)
    vs = VectorStoreManager.build_and_save_vectorstore(split_docs)
    self.retriever = vs.as_retriever(search_kwargs={&quot;k&quot;: DEFAULT_RETRIEVER_K})</code></pre>
<p>이 단계에서는 <strong>잘라낸 문서 청크들을 벡터(숫자 표현)로 변환</strong>한 후, 이를 <strong>FAISS라는 벡터 데이터베이스에 저장</strong>합니다. <span style="color:red">이 과정을 거쳐야 나중에 질문이 들어왔을 때, 관련성이 높은 문서들을 빠르게 찾아올 수 있습니다.</span></p>
<p>실제로는 <code>VectorStoreManager.build_and_save_vectorstore()</code>함수 안에서 다음 과정을 수행합니다.</p>
<pre><code class="language-python"># vector_store_manager.py
@staticmethod
def build_and_save_vectorstore(docs: List[Document], save_dir: Path = Path(&quot;./faiss_keywords&quot;)) -&gt; FAISS:
    embeddings = OpenAIEmbeddings(model=&quot;text-embedding-3-small&quot;)
    vectorstore = FAISS.from_documents(documents=docs, embedding=embeddings)
    save_dir.mkdir(parents=True, exist_ok=True)
    vectorstore.save_local(str(save_dir))
    return vectorstore</code></pre>
<ul>
<li><strong>임베딩</strong>: OpenAI의 &quot;text-embedding-3-small&quot;모델을 사용해 각 문서를 벡터로 변환합니다.</li>
<li><strong>FAISS 저장</strong>: 변환된 벡터들을 FAISS에 저장하고, 로컬 디렉토리(faiss_keywords/)에도 파일(index.faiss, indel.pkl)로 남깁니다.</li>
<li><strong>retriever 변환</strong>: 마지막으로 <code>vs.as_retriever(k=DEFAULT_RETRIEVER_K)</code>를 통해 검색 인터페이스로 바꿉니다. 이제 사용자가 질문을 던지면, retriever가 FAISS에서 관련 문서 k개를 뽑아 RAG 체인에 넘깁니다.</li>
</ul>
<p><strong>4. RAG 체인 구축</strong></p>
<pre><code class="language-python">    print(&quot;RAG 체인을 구축하는 중...&quot;)
    rag_chain = RAGChainBuilder.make_rag_chain(self.retriever)</code></pre>
<p>이 단계에서는 검색기(retriever)를 받아서 RAG 파이프라인을 구성합니다. <code>RAGChainBuilder.make_rag_chain()</code>내부에서는 <span style="background-color:#FFE6E6">프롬프트 템플릿을 정의하고, LLM(ChatOpenAI)을 연결한 뒤, 검색 결과(retriever)에서 가져온 문서를 프롬프트에 포함시킵니다.</span> 이렇게하면 <strong>&quot;질문 → 관련 문서 검색 → 문맥을 붙여서 답변 생성&quot;</strong>의 RAG 흐름이 완성됩니다.</p>
<p><strong>5. 메모리 적용</strong></p>
<pre><code class="language-python">    self.chain_with_history = RunnableWithMessageHistory(
        rag_chain,
        self.history_manager.get_session_history,
        input_messages_key=&quot;query&quot;,
        history_messages_key=&quot;history&quot;,
    )</code></pre>
<p>마지막 단계에서는 체인에 대화 히스토리(메모리)를 붙입니다.</p>
<ul>
<li><code>RunnableWithMessageHistory</code>로 감싸면, 동일한 세션(session_id) 안에서는 이전 대화 내용이 자동으로 프롬프트에 포함됩니다.</li>
<li><code>input_messages_key="query"</code> : 사용자가 입력한 질문</li>
<li><code>history_messages_key="history"</code> : 저장된 대화 히스토리</li>
</ul>
<h2 id="검색-모드">검색 모드</h2>
<p>LLM을 붙이기 전에, 검색이 제대로 되는지 먼저 확인하는 모드입니다. 이 단계에서는 키워드를 입력하면, 관련 문서 섹션의 출처(source)와 제목(section_title)만 출력됩니다.</p>
<p>RAG를 튜닝할 때 첫번째 체크포인트입니다. 검색이 잘 안될 경우 답변 품질이 떨어지기 때문에 반드시 확인해야합니다.</p>
<pre><code class="language-python">def run_search(self, query: str) -&gt; None:
    items = self.retriever.invoke(query)
    print(&quot;\n[Search Results]&quot;)
    for d in items:
        print(&quot;-&quot;, d.metadata.get(&quot;source&quot;), d.metadata.get(&quot;section_title&quot;))</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/07d901ac-de60-4224-9268-0b4a3661ec00/image.png" alt=""></p>
<h2 id="채팅-모드동기-스트리밍">채팅 모드(동기 스트리밍)</h2>
<p>채팅모드에서는 <code>stream()</code>을 사용해 답변이 토큰 단위로 출력됩니다. 기다리지 않고 첫 토큰부터 바로 보여주기때문에 실시간 채팅 환경을 제공합니다. </p>
<pre><code class="language-python">def run_chat(self, query: str, session_id: str = DEFAULT_SESSION_ID) -&gt; None:
    print(&quot;\n[Answer Streaming]&quot;)
    print(&quot;=&quot; * 50)
    response_chunks = []
    for chunk in self.chain_with_history.stream(
        {&quot;query&quot;: query},
        config={&quot;configurable&quot;: {&quot;session_id&quot;: session_id}}
    ):
        print(chunk, end=&quot;&quot;, flush=True)
        response_chunks.append(chunk)
    print(&quot;\n&quot; + &quot;=&quot; * 50)
    if not response_chunks:
        print(&quot;❌ 응답을 생성할 수 없습니다.&quot;)
    else:
        print(f&quot;✅ 응답 완료! (총 {len(&#39;&#39;.join(response_chunks))} 문자)&quot;)</code></pre>
<p>예를 들어, &quot;딥러닝이 뭐야?&quot;라고 물으면 한 글자씩 쌓이면서 답변이 완성됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/cbe0fae3-e5b2-4040-9069-4d340ac23d83/image.png" alt=""></p>
<h3 id="코드-추가-설명">코드 추가 설명</h3>
<p><strong>1. 함수 진입</strong></p>
<pre><code class="language-python">def run_chat(self, query: str, session_id: str = DEFAULT_SESSION_ID) -&gt; None:</code></pre>
<p>인자로 query(사용자 질문)와 session_id(대화 세션 ID)를 받습니다. 세션 ID는 <code>RunnableWithMessageHistory</code>와 연결되어 있어서, 같은 세션 안에서는 대화 히스토리가 이어집니다. (여기서 히스토리 관리는 chat_history_manager.py의 <code>get_session_history()</code>함수가 담당합니다.)</p>
<p><strong>2. 스트리밍 출력 준비</strong></p>
<pre><code class="language-python">response_chunks = []
for chunk in self.chain_with_history.stream(
    {&quot;query&quot;: query},
    config={&quot;configurable&quot;: {&quot;session_id&quot;: session_id}}
):</code></pre>
<p>핵심은 <code>self.chain_with_history.stream(...)</code>입니다. <code>self.chain_with_history</code>는 <code>initialize()</code> 단계에서 만들어지는데, 사실은 이렇게 구성돼 있어요.</p>
<pre><code class="language-python">self.chain_with_history = RunnableWithMessageHistory(
    rag_chain,  # ← rag_chain은 rag_chain_builder.py에서 생성
    self.history_manager.get_session_history,
    input_messages_key=&quot;query&quot;,
    history_messages_key=&quot;history&quot;,
)</code></pre>
<p>즉 <strong>self.chain_with_history = RAG 체인 + 메모리</strong>입니다.</p>
<ul>
<li>RAG 체인은 rag_chain_builder.py의 <code>make_rag_chain()</code>에서 생성</li>
<li>메모리는 chat_history_manager.py의 <code>get_session_history()</code>로 관리</li>
<li><code>stream()</code> 메서드는 답변을 한 번에 기다리지 않고, 토큰 단위로 잘라서 실시간으로 전달해줍니다.</li>
</ul>
<p><strong>3. 출력 처리</strong></p>
<pre><code class="language-python">print(chunk, end=&quot;&quot;, flush=True)
response_chunks.append(chunk)</code></pre>
<p>각 토큰(chunk)을 바로 출력(print)하면서, 동시에 리스트에 저장합니다. <code>flush=True</code>옵션 덕분에 터미널에 바로 찍히고, 버퍼에 쌓이지 않습니다. 이렇게 모아둔 <code>response_chunks</code>는 마지막에 답변 전체를 합칠 때 사용됩니다.</p>
<p><strong>4. 최종 결과 출력</strong></p>
<pre><code class="language-python">if not response_chunks:
    print(&quot;❌ 응답을 생성할 수 없습니다.&quot;)
else:
    print(f&quot;✅ 응답 완료! (총 {len(&#39;&#39;.join(response_chunks))} 문자)&quot;)</code></pre>
<p>만약 스트리밍 도중 아무 토큰도 못 받으면 에러 메시지를 출력합니다. 정상적으로 답변이 생성되면 토큰을 전부 합쳐서 길이(문자 수)를 계산해 보여줍니다.</p>
<h2 id="메모리-적용">메모리 적용</h2>
<p>(별도 함수는 없고, 위 run_chat/run_chatasync에서 <code>session_id</code>로 동작합니다.)</p>
<pre><code class="language-python">for chunk in self.chain_with_history.stream(
    {&quot;query&quot;: query},
    config={&quot;configurable&quot;: {&quot;session_id&quot;: session_id}}
):
    ...</code></pre>
<p>같은 세션(<code>session_id</code>)에서는 이전 대화를 기억합니다. 예를 들어, 먼저 &quot;딥러닝이 뭐야?&quot;라고 물은 뒤에 &quot;예시를 들어줘&quot;라고 하면 모델이 앞 대화의 맥락을 기억하고 이어서 대답합니다. 이것은 <code>RunnableWithMessageHistory</code>덕분에 가능한 기능입니다.</p>
<p><code>session_id</code>는 사실상 “방 번호” 같은 역할을 합니다. 같은 방에서는 이전 대화가 그대로 이어지고, 새로운 <code>session_id</code>로 실행하면 완전히 새로운 대화 세션이 시작됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/34986b9a-7f3b-4d3f-865d-98bd2bf5a132/image.png" alt=""></p>
<h2 id="배치-처리">배치 처리</h2>
<pre><code class="language-python">def run_batch(self, queries, session_id: str = DEFAULT_SESSION_ID):
    print(&quot;\n[Batch Processing]&quot;)
    results = self.chain_with_history.batch(
        [{&quot;query&quot;: q} for q in queries],
        config={&quot;configurable&quot;: {&quot;session_id&quot;: session_id}}
    )
    for idx, (q, res) in enumerate(zip(queries, results)):
        print(f&quot;\n[Query {idx+1}] {q}\n[Answer]&quot;)
        print(res)</code></pre>
<p><code>batch()</code>를 사용하면 여러 질문을 한 번에 처리할 수 있습니다. 예를 들어, [&quot;시장 변동성 정의&quot;, &quot;NLP 토큰화&quot;, &quot;딥러닝이 뭐야?&quot;]를 입력하면 각각의 답변이 순서대로 출력됩니다. 그리고 <span style="color:red">내부적으로 LCEL은 병렬 최적화를 지원</span>하기 때문에, 순차 실행보다 빠르게 결과를 얻을 수 있습니다. 출력도 [Query 1], [Query 2]처럼 깔끔하게 정리돼 나오기 때문에 <strong>부하 테스트나 대량 요청 시 매우 유용</strong>합니다.</p>
<p><strong>실행 방법</strong></p>
<pre><code class="language-bash"># ====== 1. 프로젝트 디렉토리 진입 ======
cd lcel_pipeline
python

# ====== 2. 파이썬 REPL에서 실행 ======
import asyncio
from main import RAGApplication

app = RAGApplication()
app.initialize()

# 비동기 실행
asyncio.run(app.run_chat_async(&quot;딥러닝이 뭐야?&quot;))</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/efddc3ab-d505-4c24-95e7-c145f196dd86/image.png" alt=""></p>
<h2 id="비동기-스트리밍">비동기 스트리밍</h2>
<pre><code class="language-python">async def run_chat_async(self, query: str, session_id: str = DEFAULT_SESSION_ID) -&gt; str:
    print(f&quot;\n[Async Answer Streaming] 질문: {query}&quot;)
    print(&quot;=&quot; * 50)
    response = &quot;&quot;
    async for chunk in self.chain_with_history.astream(
        {&quot;query&quot;: query},
        config={&quot;configurable&quot;: {&quot;session_id&quot;: session_id}}
    ):
        print(chunk, end=&quot;&quot;, flush=True)
        response += chunk
    print(&quot;\n&quot; + &quot;=&quot; * 50)
    return response</code></pre>
<p><code>astream()</code>은 <code>stream()</code>의 비동기 버전입니다. 차이점은 <strong>“여러 요청을 동시에 처리할 수 있느냐”</strong>에 있습니다.</p>
<p>예를 들어, 동기 <code>stream()</code>은 한 사용자의 요청을 처리하는 동안 다른 요청은 대기해야 하지만, <code>astream()</code>은 동시에 여러 사용자의 질문을 받아 각각 독립적으로 스트리밍 답변을 보낼 수 있습니다.</p>
<p>서버 환경에서 동시 접속자가 많은 경우, 이 비동기 방식이 필수적입니다.</p>
<p><strong>실행 방법</strong></p>
<pre><code class="language-bash"># ====== 1. 프로젝트 디렉토리 진입 ======
cd lcel_pipeline
python

# ====== 2. 파이썬 REPL에서 실행 ======
from main import RAGApplication

app = RAGApplication()
app.initialize()

queries = [&quot;시장 변동성 정의&quot;, &quot;NLP 토큰화&quot;, &quot;딥러닝이 뭐야?&quot;]
app.run_batch(queries)</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/5c3e4dbf-1989-48e8-8b90-d197a5bf6d96/image.png" alt=""></p>
<h1 id="마무리">마무리</h1>
<p>이번 실습에서는 <strong>LCEL의 다양한 실행 방식(stream, astream, batch, branch, memory)</strong>을 실제 코드에 적용해보면서 RAG 파이프라인을 확장해보았습니다.</p>
<p>검색만 검증할 수 있는 검증 모드, 토큰 단위로 답변을 보여주는 스트리밍, 맥락을 이어가는 메모리, 여러 질문을 동시에 처리하는 배치, 서버 환경에서 유용한 비동기 처리까지 동시에 확인할 수 있었습니다.</p>
<p>처음에는 복잡해보였지만, 결국 <strong>LCEL은 각 단계를 Runnable블록으로 나누고 파이프(|)로 연결한다라는 단순한 원리에 기반</strong>하는 것을 깨달았습니다.</p>
<h1 id="참고자료">참고자료</h1>
<p><a href="https://teddylee777.github.io/langchain/langchain-lcel/#google_vignette">LangChain Expression Language(LCEL) 원리 이해와 파이프라인 구축 가이드</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[LCEL 쉽게 이해하기 (feat. 한강 라면)]]></title>
            <link>https://velog.io/@dob-by/LCEL</link>
            <guid>https://velog.io/@dob-by/LCEL</guid>
            <pubDate>Thu, 18 Sep 2025 01:58:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dob-by/post/fa8807cc-3ec6-45dc-80a3-6028ee0bcbc8/image.png" alt=""></p>
<p>여러분 혹시 한강라면 좋아하시나요? 저는 며칠 전에 한강에 갔다가 라면을 먹었는데, 대기 줄이 정말 길더라구요.</p>
<p>LCEL에 대해 공부하다가 이게 한강라면기계와 같은 것이 아닌가하는 생각이 들었습니다.(저도 왜 이런 생각을 했는지 모르겠네요😂 아마 배고파서 그런것 같아요.) 집에서 라면을 끓일 땐 물 계량, 불 세기, 면 넣는 타이밍까지 전부 직접 챙겨야 합니다. 그런데 한강 라면 기계는 그 과정을 다 자동화해주잖아요?</p>
<p>LCEL도 마치 그 한강 라면 기계와 비슷합니다.</p>
<p>우리가 <strong>&quot;무엇을 할지(What)&quot;만 선언하면 &quot;어떻게 할지(How)&quot;는 LangChain이 알아서 처리</strong>해줍니다. 덕분에 우리는 <strong>복잡한 세부 구현에 매달리지 않고, 큰 흐름만 설계</strong>하면 되는거죠.</p>
<p>이번 글에서는 위 특징을 가능하게해주는 핵심개념인 LCEL에 대해 정리해보겠습니다.</p>
<h1 id="lcellangchain-expression-language이란">LCEL(LangChain Expression Language)이란?</h1>
<p><a href="https://python.langchain.com/docs/concepts/lcel/?utm_source=chatgpt.com#benefits-of-lcel">공식문서</a>에는 LCEL에 대해 다음과같이 소개하고 있습니다.</p>
<blockquote>
<p><em>The LangChain Expression Language (LCEL) takes a declarative approach to building new Runnables from existing Runnables.
This means that you describe what should happen, rather than how it should happen, allowing LangChain to optimize the run-time execution of the chains.</em></p>
</blockquote>
<p>즉, LCEL은 <strong>기존 Runnable들을 조합해 새로운 체인을 선언적으로 표현하는 언어</strong>입니다.
중요한 점은 <span style="background-color:#FFE6E6">“무엇을 할지”를 기술하는 데 집중할 수 있고, 실행 과정의 최적화는 <strong><span style="color:red">LangChain이 자동으로 처리</strong></span>한다는 겁니다.</p>
<h1 id="lcel의-주요-특징">LCEL의 주요 특징</h1>
<ul>
<li><strong>모듈화(Modularity)</strong>: 모든 구성 요소가 Runnable 단위로 통일되어 있어 조합이 쉽습니다.</li>
<li><strong>일관된 실행 모델</strong>: 동기/비동기, 단일 입력/배치 입력, 스트리밍까지 모두 동일한 인터페이스로 실행 가능합니다.</li>
<li><strong>확장성(Extensibility)</strong>: 조건 분기, 사용자 정의 함수, 메모리 등 다양한 기능을 유연하게 추가할 수 있습니다.</li>
</ul>
<p>이 중에서 가장 핵심이 되는 키워드가 바로 <strong>*&quot;Runnable&quot;*</strong>입니다. <strong><span style="background-color:#FFE6E6">LCEL은 모든 요소를 Runnable단위로 <span style="color:red">추상화</span>하기 때문에, Runnable을 이해하는 것이 곧 LCEL을 이해하는 출발점</strong></span>이라고 할 수 있습니다.</p>
<h1 id="runnable">Runnable</h1>
<h2 id="개념">개념</h2>
<p>Runnable은 한마디로 <strong>입력을 받아 → 어떤 작업을 하고 → 출력을 내는 실행 단위</strong>입니다.
LCEL에서는 모든 구성 요소(프롬프트, LLM, 검색기, 변환기 등)가 Runnable로 표현됩니다.</p>
<h2 id="특징">특징</h2>
<p><strong>1. 입력 → 출력 구조</strong></p>
<blockquote>
<ul>
<li>&quot;무언가를 넣으면 결과가 나온다&quot;라는 점에서 함수와 유사합니다.</li>
</ul>
</blockquote>
<ul>
<li>ex) &quot;runnable&quot;를 넣으면 &quot;RUNNABLE&quot; 출력</li>
</ul>
<p><strong>2. 공통 실행 방식 제공</strong>
모든 Runnable은 동일한 메서드를 가집니다.</p>
<blockquote>
<ul>
<li>.invoke(input): 단일 입력 처리</li>
</ul>
</blockquote>
<ul>
<li>.batch([inputs]): 여러 입력 처리<ul>
<li>.stream(input): 토큰 단위 스트리밍</li>
<li>.ainvoke, .abatch, .astream: 비동기 버전</li>
</ul>
</li>
</ul>
<p><strong>3. 조합 가능</strong></p>
<blockquote>
<ul>
<li>Runnable들을 |(파이프)로 연결해서 체인(Chain)을 만듭니다.</li>
</ul>
</blockquote>
<ul>
<li>ex) 검색기 | 프롬프트 | LLM → RAG 체인</li>
</ul>
<p>LCEL의 특징 중 하나인 조합 기능을 보면 이런 의문이 들 수 있습니다.</p>
<p>*&quot;LCEL로 조립했을 때 뭐가 좋은거지?&quot;* 🤔 </p>
<p><span style="background-color:#FFE6E6">LCEL을 사용했을 때의 가장 큰 장점은 <strong>&quot;복잡한 기능을 단순하고 일관된 방법으로 제공한다&quot;</strong>라는 점입니다.</span> 병렬처리, 비동기, 스트리밍, 디버깅, 배포까지 모두 LCEL의 공통 인터페이스 안에서 쉽게 다룰 수 있다는 것이 장점입니다.</p>
<h1 id="주요-runnable-정리">주요 Runnable 정리</h1>
<p>이번에는 LCEL에서 자주 쓰이는 Runnable들을 정리해보겠습니다. 처음에는 &quot;이게 뭐야?&quot;싶었는데, 알고보면 되게 단순한 블록들입니다.</p>
<h3 id="runnablepassthrough">RunnablePassthrough</h3>
<p>가장 간단한 Runnable입니다. 입력을 그대로 통과시켜줍니다. 아무 일도 안 하지만, 디버깅이나 중간 데이터를 확인할 때 은근 유용합니다.</p>
<pre><code class="language-python">from langchain_core.runnables import RunnablePassthrough

passthrough = RunnablePassthrough()
print(passthrough.invoke(&quot;Hello&quot;))  
# 출력: &quot;Hello&quot;</code></pre>
<h3 id="runnablelambda">RunnableLambda</h3>
<p>python함수를 그대로 <code>Runnable</code>로 감싸는 기능입니다. 이를 통해 개발자는 자신만의 함수를 정의하고, 해당 함수를 <code>RunnableLambda</code>를 사용하여 실행할 수 있습니다.</p>
<pre><code class="language-python">from langchain_core.runnables import RunnableLambda

to_upper = RunnableLambda(lambda x: x.upper())
print(to_upper.invoke(&quot;hello&quot;))  
# 출력: &quot;HELLO&quot;</code></pre>
<p>예를 들어, 데이터 전처리, 계산, 또는 외부 API와의 상호작용과 같은 작업을 수행하는 함수를 정의하고 실행할 수 있습니다.</p>
<h3 id="runnablebranch">RunnableBranch</h3>
<p>조건에 따라 실행 경로를 분기하는 Runnable입니다. 예를 들어, 질문에 <code>"finance"</code>라는 단어가 있으면 금융 체인으로, &quot;nlp&quot;가 들어가 있으면 NLP 체인으로 보내는 식입니다.</p>
<pre><code class="language-python">from langchain_core.runnables import RunnableBranch, RunnableLambda

branch = RunnableBranch(
    (lambda x: &quot;finance&quot; in x, RunnableLambda(lambda x: &quot;금융 체인 실행&quot;)),
    (lambda x: &quot;nlp&quot; in x, RunnableLambda(lambda x: &quot;NLP 체인 실행&quot;)),
    RunnableLambda(lambda x: &quot;기본 체인 실행&quot;)  # fallback
)

print(branch.invoke(&quot;finance keyword&quot;))  </code></pre>
<h3 id="runnablewithmessagehistory">RunnableWithMessageHistory</h3>
<p>마지막은 대화형 시스템에서 정말 중요한 블록입니다. 이 <code>Runnable</code>을 사용하면 이전 대화 내용을 <strong>히스토리(history)</strong>로 저장해두고, 그 맥락을 이어받아 다음 대화를 할 수 있습니다.</p>
<p>예를 들어,</p>
<blockquote>
<p><em>사용자: &quot;노트북 추천 해줘.&quot;
모델: &quot;게임용이 필요한가요, 아니면 코딩을 하기 위해 필요한가요?&quot;
사용자: &quot;코딩용&quot;
모델: &quot;그럼 가볍고 키보드 타건감이 좋은 모델을 추천해드릴게요&quot;</em></p>
</blockquote>
<p>이렇게 앞에서 했던 대화를 기억하고 이어서 답변할 수 있는 것이 바로 <em>RunnableWithMessageHistory</em>덕분입니다.</p>
<h1 id="실행-방식">실행 방식</h1>
<p>Runnable은 기본적으로 <strong>입력 → 처리 → 출력 구조</strong>인데, 실행하는 방법이 다양합니다. 상황에 따라 적절히 골라 쓰면 됩니다.</p>
<p><strong>1. invoke</strong>
가장 기본적인 실행 방식입니다. 입력 하나를 동기적으로 처리합니다.</p>
<pre><code class="language-python">result = chain.invoke(&quot;딥러닝이 뭐야?&quot;)
print(result)</code></pre>
<p><strong>2. ainvoke</strong>
invoke의 비동기 버전입니다. asyncio같은 환경에서 동시에 여러 요청을 처리할 때 유용합니다.</p>
<pre><code class="language-python">import asyncio

result = await chain.ainvoke(&quot;딥러닝이 뭐야?&quot;)
print(result)</code></pre>
<p><strong>3. batch</strong>
여러 입력을 한꺼번에 넣고 병렬 처리합니다. 리스트를 넣으면 리스트 결과가 나옵니다.</p>
<pre><code class="language-python">queries = [&quot;딥러닝이 뭐야?&quot;, &quot;NLP가 뭐야?&quot;, &quot;금융 리스크란?&quot;]
results = chain.batch(queries)
print(results)</code></pre>
<p><strong>4. abatch</strong>
batch의 비동기 버전입니다. 비동기 서버 환경에서 동시에 수십 개 요청을 처리할 때 사용합니다.</p>
<pre><code class="language-python">queries = [&quot;딥러닝이 뭐야?&quot;, &quot;NLP가 뭐야?&quot;, &quot;금융 리스크란?&quot;]
results = await chain.abatch(queries)
print(results)</code></pre>
<p><strong>5. stream</strong>
체인 실행 결과를 토큰 단위로 스트리밍 출력합니다. 즉, 답변이 다 생성될 때까지 기다리지 않고, 하나씩 실시간으로 볼 수 있어요.</p>
<pre><code class="language-python">for chunk in chain.stream(&quot;딥러닝이 뭐야?&quot;):
    print(chunk, end=&quot;&quot;)</code></pre>
<p><strong>6. astream</strong>
stream의 비동기 버전입니다. 채팅 애플리케이션에서 토큰 단위로 실시간 출력할 때 자주 씁니다.</p>
<pre><code class="language-python">async for chunk in chain.astream(&quot;딥러닝이 뭐야?&quot;):
    print(chunk, end=&quot;&quot;)</code></pre>
<blockquote>
<p>•    invoke: 단일 입력 동기 실행
    •    ainvoke: 단일 입력 비동기 실행
    •    batch: 여러 입력 동기 실행
    •    abatch: 여러 입력 비동기 실행
    •    stream: 스트리밍 동기 실행
    •    astream: 스트리밍 비동기 실행
    👉 결국 상황에 따라 &quot;단일/여러 개&quot; + &quot;동기/비동기/스트리밍&quot; 조합으로 고르면 됩니다.</p>
</blockquote>
<h1 id="마무리">마무리</h1>
<p>오늘은 LCEL을 한강 라면 기계 비유로 풀어보고, Runnable의 개념부터 실행 방식, 그리고 주요 블록들까지 정리해봤습니다.</p>
<p>요약하자면..</p>
<ul>
<li>Runnable은 LCEL의 가장 작은 실행 단위(입력-&gt;처리-&gt;출력)</li>
<li>LCEL은 이 Runnable들을 레고 블록처럼 조합해서 체인을 만드는 언어</li>
<li>병렬 처리, 비동기, 스트리밍, 메모리 같은 기능을 쉽게 적용할 수 있다는 점이 강점</li>
</ul>
<p>👉 결국 LCEL을 쓰면 복잡한 구현을 단순한 흐름 설계로 바꿀 수 있다는 것이 핵심입니다.</p>
<p>다음 글에서는 실제로 LCEL을 활용해서 RAG 파이프라인을 직접 구축하는 실습을 다뤄볼 예정입니다. 실제 코드와 실행 결과를 보면서, 오늘 배운 내용이 어떻게 쓰이는지 확인해보는 시간을 가져봅시다.</p>
<h1 id="참고자료">참고자료</h1>
<ul>
<li><a href="https://python.langchain.com/docs/concepts/lcel/?utm_source=chatgpt.com#benefits-of-lcel">LangChain 공식문서</a></li>
<li><a href="https://teddylee777.github.io/langchain/langchain-lcel/#google_vignette">LangChain Expression Language(LCEL) 원리 이해와 파이프라인 구축 가이드</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] 즉시로딩과 지연로딩 (with N+1문제)]]></title>
            <link>https://velog.io/@dob-by/JPA-%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9%EA%B3%BC-%EC%A6%89%EC%8B%9C%EB%A1%9C%EB%94%A9-N1%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@dob-by/JPA-%EC%A7%80%EC%97%B0%EB%A1%9C%EB%94%A9%EA%B3%BC-%EC%A6%89%EC%8B%9C%EB%A1%9C%EB%94%A9-N1%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Wed, 27 Aug 2025 04:11:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dob-by/post/8b23898d-3707-4049-a463-eb6872da4396/image.png" alt=""></p>
<p>JPA에서 엔티티를 조회할 때 연관된 엔티티를 언제 불러올지는 중요한 주제입니다. 이를 결정하는 방식이 바로 <strong>지연 로딩</strong>과 <strong>즉시 로딩</strong>입니다. 이번 포스팅에서는 두 로딩 방식의 동작 차이를 코드 예제와 함께 살펴보고, 여기서 자주 발생하는 N+1문제까지 연결해서 정리해보겠습니다.</p>
<h1 id="1-지연-로딩-lazy를-사용하여-프록시로-조회">1. 지연 로딩 LAZY를 사용하여 프록시로 조회</h1>
<p>먼저 이번 예제에서 사용할 연관관계 구조를 그림으로 정리하면 다음과 같습니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/39142e45-f60a-4f10-8348-38c7487837e9/image.png" alt=""></p>
<p>이 구조를 코드로 매핑한 Member 엔티티는 다음과 같습니다.</p>
<h4 id="member엔티티-코드">Member엔티티 코드</h4>
<pre><code class="language-java">import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Member extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;MEMBER_ID&quot;)
    private Long id;

    @Column(name = &quot;USERNAME&quot;)
    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Team team;
}</code></pre>
<p>위 코드는 <code>fetch = FetchType.Lazy</code>로 설정한 경우입니다.</p>
<h4 id="실행-코드main">실행 코드(Main)</h4>
<pre><code class="language-java">package hellojpa;

import jakarta.persistence.*;
import org.hibernate.Hibernate;

import java.util.List;

public class JpaMain {

    public static void main(String[] args) {

        System.out.println(&quot;MARKER &gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&gt;&quot;);

        EntityManagerFactory emf = Persistence.createEntityManagerFactory(&quot;hello&quot;);
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin(); //트랜잭션 시작

        try {
            Team team = new Team();
            team.setName(&quot;teamA&quot;);
            em.persist(team);

            Member member1 = new Member();
            member1.setUsername(&quot;member1&quot;);
            member1.setTeam(team);

            em.persist(member1);

            em.flush();
            em.clear();

            Member m = em.find(Member.class, member1.getId());

            System.out.println(&quot;m = &quot; + m.getTeam().getClass());

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
            e.printStackTrace();
        } finally {
            em.close();
        }
        emf.close();
    }

}
</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/e6921617-2b64-4d2c-89d6-514e6df663a3/image.png" alt=""></p>
<p>Member만 먼저 조회되고(<code>select * from MEMBER …</code>), Team은 실제 속성 접근 시 쿼리가 나가며 초기화됩니다.
즉, “Member 조회 시 Member만 조회 / Team은 프록시로 보관 → 사용하는 순간 DB 조회”가 지연 로딩의 핵심 동작입니다.</p>
<p>그럼 Team은 언제 실제로 불러올까요? 🤔</p>
<p>아래처럼 실행 코드를 조금 바꿔서 getName()을 호출해보면, 이 시점에 프록시가 초기화되며 쿼리가 실행되는 것을 확인할 수 있습니다.</p>
<pre><code class="language-java">try {
            ...

            System.out.println(&quot;m = &quot; + m.getTeam().getClass());

            System.out.println(&quot;====================&quot;);
            m.getTeam().getName(); //초기화 시점
            System.out.println(&quot;====================&quot;);

            tx.commit();
        }</code></pre>
<p>team의 어떤 속성을 사용하는(<strong>실제 team을 사용하는 시점</strong>. team을 가져오는 시점이 아닙니다.) 시점에 프록시 객체가 초기화되면서 db에서 Team을 가져옵니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/90281cec-d6a7-412d-934d-f22f10e01087/image.png" alt=""></p>
<h1 id="2-즉시-로딩eager을-사용해서-함께-조회">2. 즉시 로딩(EAGER)을 사용해서 함께 조회</h1>
<p>Member와 Team을 자주 함께 사용하는 경우에는 즉시로딩(EAGER)를 사용해서 함께 조회할 수 있습니다.</p>
<pre><code class="language-java">    @ManyToOne(fetch = FetchType.EAGER) //지연로딩(LAZY: team을 proxy로 조회)
    @JoinColumn
    private Team team; //연관관계 주인</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/d9a8b59d-df51-4b15-8dbb-8db989c291ca/image.png" alt=""></p>
<p>즉시 로딩을 사용하는 경우, <code>em.find()</code>를 호출하면 Member와 Team을 한 번에 직접 조회합니다.</p>
<p>따라서 연관 엔티티가 프록시가 아닌 실제 엔티티로 로딩됩니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/a4a09a9b-2252-4655-87d7-fa7348d66e24/image.png" alt=""></p>
<p><code>m.getTeam().getClass()</code>를 확인해보면 class hellojpa.Team이 출력되며, <code>m.getTeam().getName()</code>을 호출하면 지연 없이 바로 teamA가 출력됩니다.</p>
<p>즉, member1을 조회하면 곧바로 team1 엔티티까지 함께 가져옵니다.</p>
<p>즉시 로딩을 사용하는 경우 JPA 구현체는 다음과 같이 동작할 수 있습니다.</p>
<ul>
<li>조인을 사용하여 SQL 한 번에 함께 조회합니다.</li>
<li>또는 두 테이블을 각각 조회하여 데이터를 채울 수 있습니다.</li>
</ul>
<p>대부분의 경우 JPA 구현체는 가능하다면 조인을 사용해서 SQL 한 번에 조회하려고 합니다.</p>
<h1 id="3-프록시와-즉시로딩-주의">3. 프록시와 즉시로딩 주의</h1>
<h3 id="1-실무에서는-가급적-지연-로딩만-사용해야-합니다">1) 실무에서는 가급적 지연 로딩만 사용해야 합니다.</h3>
<p>즉시 로딩을 적용하면 예상하지 못한 SQL이 발생할 수 있습니다.</p>
<p>예를 들어, 연관된 테이블이 10개라면, 단순히 <code>find()</code> 한 번으로도 10개의 테이블이 전부 조인되어 성능이 급격히 떨어질 수 있습니다.</p>
<h3 id="2-즉시-로딩은-jpql에서-n1-문제를-일으킵니다">2) 즉시 로딩은 JPQL에서 N+1 문제를 일으킵니다.</h3>
<p>즉,</p>
<ul>
<li>1번: select * from Member</li>
<li>N번: 각 Member의 Team을 가져오기 위한 select * from Team where TEAM_ID = ?</li>
</ul>
<p>결과적으로 총 1 + N번의 쿼리가 발생하는 것이 바로 N+1 문제입니다.</p>
<h4 id="예제-코드-member-1명-team-1개--eager-가정">예제 코드 (Member 1명, Team 1개 / EAGER 가정)</h4>
<pre><code class="language-java">public class JpaMain {

        ...
        try {
            Team team = new Team();
            team.setName(&quot;teamA&quot;);
            em.persist(team);

            Member member1 = new Member();
            member1.setUsername(&quot;member1&quot;);
            member1.setTeam(team);

            em.persist(member1);

            em.flush();
            em.clear();

            // JPQL: 루트 엔티티(Member)목록 조회
            List&lt;Member&gt; members = em.createQuery(&quot;select m from Member m&quot;, Member.class).getResultList();

            tx.commit();
        } 
        ...
    }

}
</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/c88bf1dc-4c2a-4fd0-acc5-e3f848da8b18/image.png" alt=""></p>
<h4 id="로그-해석">로그 해석</h4>
<ol>
<li>쿼리 1실행 (Member 조회)<pre><code class="language-sql">select * from Member;</code></pre>
</li>
</ol>
<blockquote>
<p>JPQL select m from Member m 가 그대로 Member 테이블 조회로 변환됩니다.</p>
</blockquote>
<ol start="2">
<li>쿼리 2실행 (Team 조회)<pre><code class="language-sql">select * from Team where TEAM_ID = xxx; //xxx에는 각 Member가 가진 TEAM_ID값이 바인딩</code></pre>
<blockquote>
<p>Member 엔티티의 team 필드가 EAGER로 지정되어 있기 때문에, 조회된 Member의 연관 Team을 즉시 초기화하기 위해 추가 SQL이 실행됩니다.</p>
</blockquote>
</li>
</ol>
<p>즉, Member 1번 + Team 1번 = 총 2번의 쿼리가 실행됩니다.
(여기서 Team이 프록시가 아닌 실제 엔티티로 채워집니다.)</p>
<h4 id="요약">요약</h4>
<blockquote>
<ul>
<li>Member 엔티티 목록을 조회하는 JPQL 한 줄이 실제로는 Member 쿼리 1번 + Team 쿼리 N번을 발생시킵니다.</li>
</ul>
</blockquote>
<ul>
<li>이 때문에 <strong>총 1 + N번의 쿼리(N+1 문제)</strong>가 실행됩니다.</li>
<li>즉, em.createQuery 결과 리스트를 만들 때 이미 Member와 Team이 모두 채워져 있어야 하므로 JPA가 자동으로 Team까지 가져오는 쿼리를 날리는 것입니다.</li>
</ul>
<h4 id="lazy로-지정했을-경우">LAZY로 지정했을 경우</h4>
<p>같은 JPQL을 실행하더라도 연관관계가 LAZY이면 처음에는 <code>select * from Member</code> 한 번만 실행됩니다.
Team은 실제로 <code>getTeam().getName()</code> 같이 Team의 속성을 사용하는 순간에 별도 쿼리가 나가며 초기화됩니다.</p>
<p>즉, 실제로 접근하기 전까지는 쿼리가 나가지 않는다는 점이 핵심입니다.  </p>
<h3 id="3-n1문제-해결-방법">3) N+1문제 해결 방법</h3>
<blockquote>
</blockquote>
<ul>
<li><strong>모든 연관관계 기본은 지연 로딩(LAZY)로 설정</strong>
(<code>@ManyToOne</code>, <code>@OneToOne</code>은 기본이 EAGER → 꼭 <code>fetch = FetchType.LAZY</code>로 변경)</li>
<li><strong>필요할 때만 fetch join으로 가져오기</strong>
: Member는 LAZY지만, JPQL에서 join fetch를 사용했으므로 Member와 Team을 한 번에 조인해서 가져옵니다.<pre><code class="language-java"># JpaMain.java
List&lt;Member&gt; members = em.createQuery(
  &quot;select m from Member m join fetch m.team&quot;, Member.class)
  .getResultList();</code></pre>
<ul>
<li><strong>엔티티 그래프(EntityGraph)</strong>
: 애노테이션이나 동적 API를 통해 특정 시점에 연관 엔티티까지 함께 조회하도록 설정 가능</li>
<li><strong>배치사이즈 방법</strong>
: 여러 개의 연관 엔티티를 한 번의 IN 쿼리로 묶어서 가져오도록 최적화</li>
</ul>
</li>
</ul>
<h1 id="실무-권장-사항">실무 권장 사항</h1>
<ul>
<li>모든 연관관계는 기본적으로 LAZY로 설정하는 것을 추천합니다.</li>
<li>실제로 함께 조회가 꼭 필요한 상황에서는 fetch join 또는 엔티티 그래프를 활용하는 것이 좋습니다.</li>
<li>즉시 로딩(EAGER)은 사용하지 말 것 → 의도하지 않은 조인이나 N+1 문제가 발생할 수 있습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring Boot] 왜 굳이 DTO를 써야 할까? 유효성 검증까지 한번에 이해하기 (feat. 회원가입)]]></title>
            <link>https://velog.io/@dob-by/Spring-Boot-%EC%99%9C-%EA%B5%B3%EC%9D%B4-DTO%EB%A5%BC-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%A6%9D%EA%B9%8C%EC%A7%80-%ED%95%9C%EB%B2%88%EC%97%90-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-feat.-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85</link>
            <guid>https://velog.io/@dob-by/Spring-Boot-%EC%99%9C-%EA%B5%B3%EC%9D%B4-DTO%EB%A5%BC-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%A6%9D%EA%B9%8C%EC%A7%80-%ED%95%9C%EB%B2%88%EC%97%90-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-feat.-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85</guid>
            <pubDate>Wed, 18 Jun 2025 10:58:49 GMT</pubDate>
            <description><![CDATA[<p>Spring을 공부하다 보면 한 번쯤 듣게 되는 개념, <strong>DTO (Data Transfer Object)</strong>.</p>
<p>처음에는 &quot;뭔가 계층 간 데이터를 전달하는 용도인가보다~&quot; 하고 지나쳤지만, 실제로 회원가입 기능을 만들면서 <strong>DTO가 왜 필요한지, 어떻게 써야 하는지</strong> 체감하게 되었습니다.</p>
<p>이 글에서는 직접 회원가입 기능을 구현하면서 </p>
<blockquote>
<ul>
<li>DTO를 왜 따로 만드는것이 좋은지 </li>
</ul>
</blockquote>
<ul>
<li>유효성 검증 과정에서 어떤 문제가 발생했는지</li>
<li>그리고 어떻게 해결했는지  </li>
</ul>
<p>제가 겪은 과정을 기반으로 정리해보려고 합니다.</p>
<hr>
<h1 id="dto란">DTO란?</h1>
<p><strong>DTO (Data Transfer Object)</strong>는 계층 간 데이터 전달을 위해 사용하는 <strong>순수 데이터 전송 객체</strong>입니다. 웹 애플리케이션에서는 <strong>사용자의 입력값을 안전하게 받기 위한 전용 폼 객체</strong>로 자주 사용됩니다.</p>
<h2 id="🤔-왜-굳이-dto를-써야-할까">🤔 왜 굳이 DTO를 써야 할까?</h2>
<p>처음엔 이렇게 생각했습니다.</p>
<blockquote>
<p>&quot;User 엔티티를 그냥 써도 되지 않나? 굳이 SignupFormDto 같은 걸 왜 만들어야 하지?&quot;</p>
</blockquote>
<hr>
<h1 id="회원가입-기능을-만들며-겪은-실제-흐름">회원가입 기능을 만들며 겪은 실제 흐름</h1>
<p>처음엔 이렇게 <code>User</code> 엔티티를 그냥 컨트롤러에 받아서 처리했습니다.</p>
<pre><code class="language-java">@PostMapping(&quot;/signup&quot;)
public String signup(User user) {
    userRepository.save(user);
    return &quot;redirect:/login&quot;;
}</code></pre>
<p>간단해 보이지만 몇 가지 문제가 있었습니다.</p>
<p>❌ <strong>User 엔티티를 바로 받으면 생기는 문제</strong></p>
<table>
<thead>
<tr>
<th>문제</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>비즈니스 목적과 불일치</td>
<td>회원가입 시 필요한 필드만 받으면 되는데, 불필요한 필드까지 열려있음</td>
</tr>
<tr>
<td>보안상 위험</td>
<td>User 내부에 있는 민감한 필드까지 클라이언트에서 보낼 수 있음</td>
</tr>
<tr>
<td>유지보수 어려움</td>
<td>나중에 User 엔티티가 바뀌면 폼 로직이 다 깨짐</td>
</tr>
</tbody></table>
<br>

<h2 id="그래서-등장한-dto">그래서 등장한 DTO</h2>
<p>회원가입에 필요한 필드만 딱 담은 전용 객체 SignupFormDto를 만들었습니다.</p>
<pre><code class="language-java">@Data
public class SignupFormDto {

    @NotBlank(message = &quot;아이디는 필수입니다.&quot;)
    private String userId;

    @NotBlank(message = &quot;비밀번호는 필수입니다.&quot;)
    private String password;

    @NotBlank(message = &quot;이름은 필수입니다.&quot;)
    private String username;

    @Email(message = &quot;이메일 형식이 올바르지 않습니다.&quot;)
    @NotBlank(message = &quot;이메일 입력은 필수입니다.&quot;)
    private String email;

    @NotBlank(message = &quot;소속 선택은 필수입니다.&quot;)
    private String affiliation; //학생대, 교육대

    @NotBlank(message = &quot;중대 선택은 필수입니다.&quot;)
    private String unit; //1~3중대
}</code></pre>
<p>오직 회원가입 시 사용자가 입력한 정보만 받도록 설계했습니다.</p>
<h2 id="그런데-검증이-안된다">그런데 검증이 안된다?</h2>
<p><code>@NotBlank</code>, <code>@Email</code>을 다 붙였는데 값 안넣고 제출해도 그대로 통과되는 문제가 발생했습니다.</p>
<p>그 이유는 바로 <strong>@Valid와 BindingResult의 위치와 사용 여부</strong> 때문이었습니다.</p>
<h2 id="유효성-검증이-동작하려면">유효성 검증이 동작하려면</h2>
<p>컨트롤러는 다음처럼 구성되어야 합니다.</p>
<pre><code class="language-java">@PostMapping(&quot;/signup&quot;)
    public String processSignup(@Valid @ModelAttribute(&quot;signupForm&quot;) SignupFormDto form, BindingResult bindingResult, Model model) {

        if (bindingResult.hasErrors()) {
            return &quot;signup&quot;; // 유효성 에러 처리
        }

        try {
            signupService.registerUser(form);
            return &quot;redirect:/login?signupSuccess&quot;; //회원가입 성공 시 로그인 페이지로
        } catch (IllegalArgumentException e) {
            model.addAttribute(&quot;error&quot;, e.getMessage());
            return &quot;signup&quot;;
        }
    }</code></pre>
<table>
<thead>
<tr>
<th>요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>@Valid</td>
<td>DTO에 붙은 @NotBlank 등의 어노테이션을 <strong>실제로 작동시킴</strong></td>
</tr>
<tr>
<td>BindingResult</td>
<td><strong>유효성 검사 결과를 잡아주는</strong> 바구니 역할</td>
</tr>
</tbody></table>
<p>❗️ 순서 주의: <strong>반드시 @Valid → BindingResult 순서로 선언</strong></p>
<blockquote>
<p>처음엔 try-catch문이 있는데 왜 유효성 에러처리가 안되지? 라고 생각했는데 <strong>@Valid 유효성 검사는 컨트롤러 메서드 호출 전에 실행</strong>되기 때문이었습니다.</p>
</blockquote>
<blockquote>
<p><strong>BindingResult가 없으면?</strong>
: 검증 실패 시 Spring이 MethodArgumentNotValidException을 던지고 예외로 끝냅니다 → try-catch 안으로 못 들어옵니다.</p>
</blockquote>
<h2 id="폼-페이지-thymeleaf">폼 페이지 (Thymeleaf)</h2>
<pre><code class="language-html">&lt;form th:action=&quot;@{/signup}&quot; th:object=&quot;${signupForm}&quot; method=&quot;post&quot;&gt;
  &lt;input type=&quot;text&quot; th:field=&quot;*{userId}&quot; /&gt;
  &lt;small th:if=&quot;${#fields.hasErrors(&#39;userId&#39;)}&quot; th:errors=&quot;*{userId}&quot;&gt;&lt;/small&gt;

  &lt;input type=&quot;text&quot; th:field=&quot;*{email}&quot; /&gt;
  &lt;small th:if=&quot;${#fields.hasErrors(&#39;email&#39;)}&quot; th:errors=&quot;*{email}&quot;&gt;&lt;/small&gt;

  &lt;select th:field=&quot;*{affiliation}&quot;&gt;
    &lt;option value=&quot;&quot;&gt;📌 선택하세요&lt;/option&gt;
    &lt;option value=&quot;학생대&quot;&gt;학생대&lt;/option&gt;
    &lt;option value=&quot;교육대&quot;&gt;교육대&lt;/option&gt;
  &lt;/select&gt;
  &lt;small th:if=&quot;${#fields.hasErrors(&#39;affiliation&#39;)}&quot; th:errors=&quot;*{affiliation}&quot;&gt;&lt;/small&gt;
&lt;/form&gt;
</code></pre>
<hr>
<p>자 그럼 이렇게 DTO를 쓸 경우 어떤 점이 좋아질까요?</p>
<h2 id="dto-사용-시-장점">DTO 사용 시 장점</h2>
<table>
<thead>
<tr>
<th>장점</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>유효성 검증 최적화</td>
<td><code>@NotBlank</code>, <code>@Email</code>, <code>@Size</code> 등을 자유롭게 붙여 검증 가능</td>
</tr>
<tr>
<td>보안성 강화</td>
<td>민감한 필드를 사용자에게 노출하지 않음</td>
</tr>
<tr>
<td>필요한 필드만 사용</td>
<td>회원가입, 로그인, 비밀번호 변경 등 용도에 맞는 DTO 설계 가능</td>
</tr>
<tr>
<td>구조적 유연성</td>
<td>DTO는 컨트롤러~뷰 전용 객체라, Entity가 바뀌어도 독립적으로 유지 가능</td>
</tr>
</tbody></table>
<hr>
<h1 id="정리">정리</h1>
<ul>
<li>회원가입 같은 폼에서는 User 엔티티를 직접 사용하지 않는 것이 좋습니다.</li>
<li>User엔티티는 DB 저장용 객체이고, 사용자의 입력값은 DTO로 분리하는 것이 좋습니다.</li>
<li>유효성 검증은 DTO에 어노테이션으로 붙이고 컨트롤러에서는 @Valid, BindingResult로 처리해야 합니다.</li>
<li>Thymeleaf에서는 th:field, th:errors를 제대로 써야 오류 메시지 표시가 가능합니다.</li>
</ul>
<hr>
<h1 id="결론">결론</h1>
<p>DTO는 단순한 데이터 전달용 객체가 아닙니다. <strong>사용자 입력을 안전하게 받고, 검증하고, 분리해서 처리</strong>하기 위한 <strong>아키텍처적 보호막</strong>이자, <strong>유지보수성과 보안성을 높이는 필수 도구</strong>입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[기술 노트 / WingITs] Spring Boot MVC 아키텍처와 백엔드 구조 설계]]></title>
            <link>https://velog.io/@dob-by/%EA%B8%B0%EC%88%A0-%EB%85%B8%ED%8A%B8-WingITs-Spring-Boot-MVC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@dob-by/%EA%B8%B0%EC%88%A0-%EB%85%B8%ED%8A%B8-WingITs-Spring-Boot-MVC-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EC%99%80-%EB%B0%B1%EC%97%94%EB%93%9C-%EA%B5%AC%EC%A1%B0-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Tue, 17 Jun 2025 08:15:26 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dob-by/post/4c4e4cbc-65b4-4dfc-8fcc-d8e994bc7a34/image.png" alt=""></p>
<blockquote>
<p>📌 이 글은 WingITs의 기술 구조 정리입니다. 이 프로젝트가 어떻게 시작되었는지 궁금하시다면
👉 <a href="https://velog.io/@dob-by/series/WingITs">개발일지 WingITs #1~#6</a></p>
</blockquote>
<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p>제가 개발한 노트북 관리 시스템 WingITs는 <strong>Spring Boot 기반의 MVC 아키텍처</strong>로 설계되었습니다.</p>
<ul>
<li><strong>Controller</strong>가 사용자 요청을 받고,  </li>
<li><strong>Service</strong>에서 비즈니스 로직을 처리하며,  </li>
<li><strong>Repository (JPA 기반)</strong>를 통해 MySQL과 연동됩니다.</li>
</ul>
<p>프론트엔드는 Thymeleaf를 사용하여 렌더링되며, 사용자 역할에 따라 <strong>학생</strong>과 <strong>관리자</strong> 기능을 명확하게 분리하였습니다.</p>
<p>인증과 보안은 <strong>Spring Security</strong>를 기반으로 구현했고, <strong>Google/Kakao OAuth2 로그인도 지원</strong>합니다. 특히 관리자 계정은 <strong>승인 절차를 거쳐야 로그인</strong>할 수 있도록 설정하여 보안을 강화했습니다.</p>
<p>운영 환경은 <strong>Docker</strong> 기반으로 구성되어 있으며, 개발 초기에는 H2 DB를 사용하다가 배포 단계에서 MySQL로 전환하였습니다. 또한 초기 데이터 세팅을 위해 Excel 업로드 기능도 구현했습니다 (<strong>Apache POI</strong>).</p>
<hr>
<h2 id="프레임워크-및-라이브러리별-역할-정리">프레임워크 및 라이브러리별 역할 정리</h2>
<table>
<thead>
<tr>
<th>분류</th>
<th>사용 기술</th>
<th>목적 / 기능</th>
</tr>
</thead>
<tbody><tr>
<td>백엔드 프레임워크</td>
<td>Spring Boot</td>
<td>전체 애플리케이션 구성 (MVC 구조 포함)</td>
</tr>
<tr>
<td>템플릿 엔진</td>
<td>Thymeleaf</td>
<td>서버사이드 렌더링 기반 HTML 화면 구성</td>
</tr>
<tr>
<td>인증 / 보안</td>
<td>Spring Security</td>
<td>로그인, 로그아웃, 권한 제어 (학생 / 관리자)</td>
</tr>
<tr>
<td>소셜 로그인</td>
<td>Spring Security OAuth2</td>
<td>Google/Kakao 계정 연동</td>
</tr>
<tr>
<td>비밀번호 암호화</td>
<td>BCrypt</td>
<td>비밀번호 해시 처리</td>
</tr>
<tr>
<td>DB 연동</td>
<td>Spring Data JPA</td>
<td>Entity 기반 CRUD 처리</td>
</tr>
<tr>
<td>ORM 구현체</td>
<td>Hibernate</td>
<td>SQL 없이 객체 지향적으로 DB 처리</td>
</tr>
<tr>
<td>운영 DB</td>
<td>MySQL</td>
<td>실 서비스 운영용 RDBMS</td>
</tr>
<tr>
<td>개발용 DB</td>
<td>H2</td>
<td>개발/테스트용 가벼운 DB</td>
</tr>
<tr>
<td>Excel 업로드</td>
<td>Apache POI</td>
<td>초기 데이터 입력용 <code>.xlsx</code> 처리</td>
</tr>
<tr>
<td>배포 환경</td>
<td>Docker</td>
<td>MySQL 컨테이너 실행, 환경 일관성 확보</td>
</tr>
<tr>
<td>글로벌 데이터 처리</td>
<td>@ControllerAdvice</td>
<td>로그인 사용자 정보 전역 전달 (Thymeleaf)</td>
</tr>
</tbody></table>
<hr>
<h2 id="각-기술을-선택한-이유">각 기술을 선택한 이유</h2>
<h3 id="spring-boot"><strong>Spring Boot</strong></h3>
<ul>
<li><code>spring-boot-starter-web</code> 하나만 추가해도 필요한 웹 기능이 자동 설정됨  </li>
<li>복잡한 설정 없이 빠르게 개발 시작 가능  </li>
<li>내장 톰캣 서버 덕분에 별도 WAS 설정 없이 실행 가능 → 개발 생산성 ↑</li>
</ul>
<hr>
<h3 id="thymeleaf"><strong>Thymeleaf</strong></h3>
<ul>
<li>Spring MVC와의 자연스러운 통합 덕분에 별도 설정 없이 사용 가능  </li>
<li>컨트롤러에서 전달한 데이터를 템플릿에서 직관적으로 활용 가능  </li>
<li>서버사이드 렌더링에 적합한 직관적 문법</li>
</ul>
<hr>
<h3 id="spring-security"><strong>Spring Security</strong></h3>
<ul>
<li>Spring 생태계에 최적화된 보안 프레임워크  </li>
<li>인증/인가 설정 및 커스터마이징이 유연함  </li>
<li>별도 연동 없이도 강력한 보안을 쉽게 구현 가능</li>
</ul>
<hr>
<h3 id="oauth2"><strong>OAuth2</strong></h3>
<ul>
<li>Google, Kakao 등 외부 서비스와 손쉽게 연동 가능  </li>
<li>사용자 비밀번호를 직접 다루지 않아 보안상 유리  </li>
<li>정립된 인증 플로우 덕분에 구현이 간편하고 유지보수도 쉬움</li>
</ul>
<hr>
<h3 id="bcrypt"><strong>BCrypt</strong></h3>
<ul>
<li>솔트 자동 추가, 키 스트레칭 등 보안성이 뛰어난 해시 알고리즘  </li>
<li>Spring Security와의 연동이 자연스러워 쉽게 적용 가능  </li>
<li>널리 사용되는 방식으로 신뢰성과 적용 편의성이 높음</li>
</ul>
<hr>
<h3 id="spring-data-jpa--hibernate"><strong>Spring Data JPA + Hibernate</strong></h3>
<ul>
<li>객체지향적으로 DB를 다룰 수 있어 코드가 직관적이고 재사용성 높음  </li>
<li>SQL을 직접 작성하지 않아도 되므로 반복적인 CRUD 로직이 줄어듦  </li>
<li>Repository 인터페이스만으로 기본 CRUD 자동 처리 가능</li>
</ul>
<blockquote>
<p><strong>객체지향적으로 DB를 다룰 수 있다?</strong>
: 데이터베이스의 테이블을 자바 객체로 매핑해서, 마치 자바 객체를 다루듯 데이터를 조작할 수 있다는 의미. 즉, 개발자는 SQL쿼리를 직접 작성하는 수고를 덜고, 자바의 클래스를 통해 데이터베이스 레코드를 다룰 수 있음. 
👉 코드가 직관적, 재사용성 증가</p>
</blockquote>
<blockquote>
<p><strong>반복적인 CRUD 로직을 줄일 수 있다?</strong>
: 기본적인 데이터 조작 작업들(ex. 저장, 조회, 수정, 삭제 등)이 메서드 호출만으로 가능해지기 때문에 반복적인 코드를 줄이고 생산성을 높일 수 있음.</p>
</blockquote>
<hr>
<h3 id="mysql--h2"><strong>MySQL &amp; H2</strong></h3>
<ul>
<li>개발 초기에는 H2로 빠르게 작업  </li>
<li>운영 환경에서는 안정적인 MySQL을 사용해 데이터 영속성과 확장성을 확보</li>
</ul>
<hr>
<h3 id="apache-poi"><strong>Apache POI</strong></h3>
<ul>
<li><code>.xlsx</code> 엑셀 데이터를 Java에서 쉽게 처리 가능  </li>
<li>초기 사용자/노트북 등록 등 관리자 기능 구현에 활용</li>
</ul>
<hr>
<h3 id="docker"><strong>Docker</strong></h3>
<ul>
<li>MySQL을 Docker로 컨테이너화해 개발/운영 환경을 통일  </li>
<li>환경 차이로 인한 문제 최소화  </li>
<li>배포 자동화 및 협업에도 유리</li>
</ul>
<br>

<h2 id="마무리하며">마무리하며</h2>
<p>이처럼 WingITs 프로젝트는 Spring Boot를 중심으로 보안, 인증, 데이터 연동, 배포 등 <strong>실제 서비스를 운영하기 위한 필수 요소들을 직접 설계하고 구현한 경험이 집약된 시스템</strong>입니다.</p>
<p>이후 포스트에서는 아래 주제들을 다룰 예정입니다:</p>
<ul>
<li>Spring Security + OAuth2 로그인 처리 흐름</li>
<li>ControllerAdvice를 활용한 사용자 정보 처리</li>
<li>JPA 연관관계 매핑 설계 및 이슈</li>
<li>Docker-Compose로 구성한 로컬 개발 환경</li>
</ul>
<hr>
<p>아키텍처를 설계할 때 이 글이 도움이 되었으면 좋겠습니다. 🙌</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발일지] WingITs #6 - 마무리와 회고, 그리고 다음을 위하여]]></title>
            <link>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-6-%EB%A7%88%EB%AC%B4%EB%A6%AC%EC%99%80-%ED%9A%8C%EA%B3%A0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8B%A4%EC%9D%8C%EC%9D%84-%EC%9C%84%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-6-%EB%A7%88%EB%AC%B4%EB%A6%AC%EC%99%80-%ED%9A%8C%EA%B3%A0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%8B%A4%EC%9D%8C%EC%9D%84-%EC%9C%84%ED%95%98%EC%97%AC</guid>
            <pubDate>Sun, 15 Jun 2025 02:38:25 GMT</pubDate>
            <description><![CDATA[<h2 id="🎯-프로젝트를-마무리하며">🎯 프로젝트를 마무리하며</h2>
<p>2025년 3월 말, 군복무 중 직접 겪은 불편함을 해결하고자 시작한 <strong>노트북 수리 요청 시스템 WingITs</strong>는 기획부터 구현, 디자인, 배포 직전 단계까지 약 3개월 간의 개발 여정을 거쳐 완성되었습니다. 사실 전역 준비와 이사 준비로 예상했던 것보다 더 오래 걸렸던 것 같네요.</p>
<hr>
<h2 id="🛠-아직-배포하지-않은-이유">🛠 아직 배포하지 않은 이유</h2>
<p>현재까지의 개발 결과물은 완성도 측면에서 <strong>핵심 기능이 대부분 구현되어 실사용이 가능한 수준</strong>이지만, 아직 <strong>외부 배포는 진행하지 않았습니다.</strong> </p>
<p>그 이유는...</p>
<ul>
<li><strong>군부대 특성상 실제 학생/교직원 정보를 외부 서버에 올릴 수 없음</strong></li>
<li>이 시스템은 <strong>실제 부대 내에서 사용될 목적으로 직접 개발한 것</strong>이기 때문에,<br>→ <strong>정보보호 및 보안에 대한 내부 검토가 반드시 필요</strong></li>
<li>아직 <strong>보안 기능이 충분히 안전한지 검토가 완료되지 않음</strong></li>
<li>현재는 <strong>임의의 테스트 데이터</strong>를 활용해 기능을 검증하고 있으며, <strong>실제 학생 및 교직원 정보를 포함한 운영 환경 적용은</strong> <strong>군 부대의 보안 검토 절차를 모두 마친 후</strong>, <strong>공식적인 허가가 떨어지는 경우에 한해 외부 배포를 진행할 예정</strong></li>
</ul>
<hr>
<h2 id="☁️-배포-방식에-대한-고민">☁️ 배포 방식에 대한 고민</h2>
<p>원래는 <strong>Render를 통해 간편하게 배포</strong>하고, 테스트 사용자 피드백을 빠르게 받을 계획이었습니다.</p>
<p>하지만 이후 고려한 몇 가지 요소로 인해 <strong>EC2 + Docker 기반 클라우드 배포 방식</strong>으로 방향을 바꾸게 되었습니다:</p>
<ul>
<li><strong>보안 설정 및 서버 제어권 확보가 필요</strong></li>
<li>단순 호스팅보다 <strong>직접 인프라 구성 경험을 쌓고 싶었음</strong></li>
<li><strong>Docker, EC2, Nginx, HTTPS 인증서 등 기술적 역량 향상 목적</strong></li>
</ul>
<p>따라서 현재는 배포를 보류하고 있지만, <strong>보안 검토 및 허가 절차가 완료되면 EC2 기반으로 정식 배포를 진행할 계획</strong>입니다.</p>
<hr>
<h2 id="🔭-향후-추가-예정-기능-및-개선-사항">🔭 향후 추가 예정 기능 및 개선 사항</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>✅ 관리자별 통계 분리</td>
<td>현재는 전체 관리자 통계만 제공 → <strong>로그인한 관리자 기준으로 분리 예정</strong></td>
</tr>
<tr>
<td>🔐 비밀번호 변경 이력 기록</td>
<td>보안 강화를 위해 <strong>비밀번호 변경 시점 및 이력 추적</strong> 기능 추가</td>
</tr>
<tr>
<td>📊 노트북 상태 통계</td>
<td>전체 노트북의 상태(보유/수리중/반납 등)를 <strong>시각적으로 확인</strong></td>
</tr>
<tr>
<td>⛓️ 소유권 이력 자동화</td>
<td>수리 상태 변경 시 자동으로 <strong>소유권 이전 기록 반영</strong></td>
</tr>
<tr>
<td>🧪 통합 테스트 코드 작성</td>
<td><strong>단위/통합 테스트 작성</strong>을 통해 코드 신뢰성 향상 예정</td>
</tr>
<tr>
<td>🏁 AWS EC2 배포</td>
<td>Docker + Nginx + MySQL 구성으로 <strong>클라우드 배포 예정</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="💭-이-프로젝트가-나에게-남긴-것">💭 이 프로젝트가 나에게 남긴 것</h2>
<ul>
<li><strong>실제 문제 해결의 전 과정을 경험</strong>하며 개발자로서의 자신감 얻음  </li>
<li>Spring Security, OAuth2, Docker 등 <strong>다양한 기술 스택을 실전에 적용</strong>  </li>
<li><strong>기획 → 개발 → 테스트까지의 경험</strong></li>
</ul>
<hr>
<h2 id="📌-다음-글부터는">📌 다음 글부터는?</h2>
<p>이번 시리즈에서는 마무리 단계 이후, <strong>보안 검토를 통과하거나 실제 운영에 필요한 기능이 추가되는 경우</strong>를 중심으로 <strong>기능 개선 및 후속 개발기</strong>를 다룰 예정입니다.</p>
<blockquote>
<ul>
<li>관리자 전용 통계 기능 고도화  </li>
<li>수리 요청 소유권 변경 로직 완성  </li>
<li>실사용자 기반 피드백 반영 등</li>
</ul>
</blockquote>
<hr>
<p>※ 개발 중 겪었던 다양한 시행착오와 기술적 문제들은  별도의 시리즈, <strong>&quot;개발 이슈 로그&quot;</strong>에서 보다 상세히 다루고 있습니다. 🔧</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발일지] WingITs #5 - 소통의 장, 공지사항 · 게시판 · FAQ]]></title>
            <link>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-5-%EC%86%8C%ED%86%B5%EC%9D%98-%EC%9E%A5-%EA%B3%B5%EC%A7%80%EC%82%AC%ED%95%AD-%EA%B2%8C%EC%8B%9C%ED%8C%90-FAQ</link>
            <guid>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-5-%EC%86%8C%ED%86%B5%EC%9D%98-%EC%9E%A5-%EA%B3%B5%EC%A7%80%EC%82%AC%ED%95%AD-%EA%B2%8C%EC%8B%9C%ED%8C%90-FAQ</guid>
            <pubDate>Sun, 15 Jun 2025 02:24:11 GMT</pubDate>
            <description><![CDATA[<p>노트북 수리 요청 시스템을 개발하면서 느낀 또 하나의 필요성은, <strong>학생과 관리자가 서로 정보를 주고받을 수 있는 &#39;소통 창구&#39;</strong>였습니다.</p>
<p>기존에는 학생 대상 공지를 대부분 <strong>카카오톡 단체방</strong>을 통해 전달했지만..</p>
<ul>
<li>모든 선생님이 공지를 올리다 보니 <strong>중요한 정보가 금방 묻혀버리고</strong></li>
<li>나중에 다시 확인하려 해도 <strong>검색이 어려운 문제</strong>가 자주 발생했습니다.</li>
</ul>
<p>이를 해결하기 위해, <strong>공지사항 · 게시판 · FAQ를 웹 시스템 내에 별도로 구현</strong>하고 역할별 접근 권한, 검색 기능, 댓글 기능 등을 통해 <strong>효율적인 커뮤니케이션 구조</strong>를 만들고자 했습니다.</p>
<hr>
<h2 id="📢-공지사항-기능">📢 공지사항 기능</h2>
<p>공지사항은 관리자만 등록할 수 있으며, <strong>페이징 / 검색 / 파일 첨부</strong> 기능을 지원합니다.</p>
<ul>
<li>목록에서 제목 클릭 시 상세 보기</li>
<li>첨부 파일이 있는 경우 다운로드 가능</li>
<li><strong>최근 공지사항 5개는 메인 페이지에 미리보기로 표시됨</strong></li>
<li>학생은 열람만 가능, 관리자는 작성 가능</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dob-by/post/70435f2a-716f-415f-b94a-111bff7d8f2e/image.gif" alt=""></p>
<hr>
<h2 id="💬-게시판--댓글-기능">💬 게시판 &amp; 댓글 기능</h2>
<p>게시판은 <strong>학생들이 자유롭게 글을 작성하고 소통할 수 있는 공간</strong>입니다.<br>각 게시글에는 댓글 기능이 포함되어 있어 간단한 피드백이나 공감 표현이 가능합니다.</p>
<ul>
<li><strong>학생</strong>: 게시글 작성, 수정, 삭제 가능</li>
<li><strong>관리자</strong>: 게시글 작성은 불가, 학생 글 삭제 가능</li>
<li>댓글은 역할 상관없이 누구나 작성 가능</li>
<li>본인 댓글은 본인만 수정/삭제 가능, 관리자는 모든 댓글 삭제 가능</li>
<li>조회수 증가 기능</li>
<li><strong>최근 게시글 5개는 메인 화면에 미리보기로 표시됨</strong>
<img src="https://velog.velcdn.com/images/dob-by/post/587f634a-dfba-4f8e-92d1-f65a3e20cd0b/image.gif" alt=""></li>
</ul>
<hr>
<h2 id="❓-자주-묻는-질문-faq">❓ 자주 묻는 질문 (FAQ)</h2>
<p>자주 묻는 질문은 <strong>Bootstrap 기반 아코디언 UI</strong>로 구현해 가독성을 높였습니다.</p>
<ul>
<li>관리자가 등록</li>
<li>질문 클릭 시 답변이 펼쳐지는 구조</li>
<li>나중에 카테고리 필터 기능 추가 예정</li>
</ul>
<p><img src="https://velog.velcdn.com/images/dob-by/post/be85c95a-62ef-499e-9faf-4eae9a148b8f/image.gif" alt=""></p>
<hr>
<h2 id="🔐-역할-기반-접근-제한">🔐 역할 기반 접근 제한</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>학생 (<code>ROLE_STUDENT</code>)</th>
<th>관리자 (<code>ROLE_MID_ADMIN</code> 이상)</th>
</tr>
</thead>
<tbody><tr>
<td>공지사항 보기</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>공지사항 작성</td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td>게시판 글 작성</td>
<td>O</td>
<td>X (학생 글 삭제만 가능)</td>
</tr>
<tr>
<td>게시판 댓글 작성</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>게시판 댓글 삭제</td>
<td>본인만 가능</td>
<td>전체 삭제 가능</td>
</tr>
<tr>
<td>FAQ 열람</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>FAQ 등록</td>
<td>X</td>
<td><code>ROLE_TOP_ADMIN</code>만 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="⏭-다음-글에서는">⏭ 다음 글에서는...</h2>
<blockquote>
<p><strong>개발 마무리와 회고</strong>, 그리고 앞으로 추가할 기능들에 대한 계획을 공유하며 이 프로젝트의 여정을 정리할 예정입니다. 특히 <strong>배포를 아직 하지 않은 이유</strong>, 그리고 향후 도입 예정인 <strong>보안 기능</strong>과 <strong>개선 계획</strong>에 대해서도 함께 다룰 예정입니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발일지] WingITs #4 - 수리 요청 시스템, 학생과 관리자의 연결 고리]]></title>
            <link>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-4-%EC%88%98%EB%A6%AC-%EC%9A%94%EC%B2%AD-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%95%99%EC%83%9D%EA%B3%BC-%EA%B4%80%EB%A6%AC%EC%9E%90%EC%9D%98-%EC%97%B0%EA%B2%B0-%EA%B3%A0%EB%A6%AC</link>
            <guid>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-4-%EC%88%98%EB%A6%AC-%EC%9A%94%EC%B2%AD-%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%95%99%EC%83%9D%EA%B3%BC-%EA%B4%80%EB%A6%AC%EC%9E%90%EC%9D%98-%EC%97%B0%EA%B2%B0-%EA%B3%A0%EB%A6%AC</guid>
            <pubDate>Sun, 15 Jun 2025 02:05:54 GMT</pubDate>
            <description><![CDATA[<p>수리 요청은 이 시스템의 핵심 기능입니다.</p>
<p>학생이 노트북 수리 요청을 보내고, 관리자가 그 요청을 검토하여 상태를 업데이트하는 과정은 곧 이 시스템이 해결하고자 했던 가장 중요한 ‘불편함’이었습니다.</p>
<hr>
<h2 id="🧑🎓-학생-→-수리-요청">🧑‍🎓 학생 → 수리 요청</h2>
<p>학생은 로그인 후 <strong>마이페이지에서 본인의 노트북 정보</strong>를 확인할 수 있습니다. 
이 페이지에서 직접 수리 요청을 작성할 수 있도록 구성했습니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/3a1ea411-9f30-48a3-95a9-72507ba93b9a/image.gif" alt=""></p>
<hr>
<h2 id="📋-요청-내역-확인">📋 요청 내역 확인</h2>
<p>요청한 내역은 <strong>요청 일자, 상태, 문제 요약</strong>으로 리스트업되며, 학생은 이를 통해 현재 수리 진행 상황을 실시간으로 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/dob-by/post/612ef464-225e-442e-a966-88b4bce51b75/image.png" alt=""></p>
<hr>
<h2 id="🛠-관리자-→-수리-요청-관리">🛠 관리자 → 수리 요청 관리</h2>
<p>관리자는 모든 학생의 요청을 <strong>목록 형태로 한눈에 볼 수 있으며</strong>, 검색, 필터링, 정렬이 가능합니다.</p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/a10402b5-6f5e-4e8d-86c3-edd337bf89c8/image.gif" alt=""></p>
<p>각 요청을 클릭하면 상세 정보가 나오고, 여기서 관리자는 수리 상태를 다음중 하나로 변경할 수 있습니다.</p>
<blockquote>
<ul>
<li>수리 요청됨, 수리 중, 수리 완료, 반려</li>
</ul>
</blockquote>
<p>반려의 경우 관리자가 사유를 입력하여 학생이 반려 사유를 인지할 수 있도록합니다.</p>
<p>(중간 관리자의 경우 총괄 관리자와 다르게 승인과 반려 둘 중에 하나만 선택할 수 있습니다.)</p>
<hr>
<h2 id="🧭-상태-변경-로직-예정">🧭 상태 변경 로직 (예정)</h2>
<p>향후에는 수리 요청 상태에 따라 <strong>노트북 소유자가 자동으로 전환</strong>되는 로직을 도입할 예정입니다.</p>
<p>예를 들어,  </p>
<ul>
<li><strong>수리 요청됨 / 수리 중</strong> 상태일 때 → 소유자를 <strong>관리자</strong>로  </li>
<li><strong>수리 완료</strong> 시점 → 다시 <strong>학생에게 소유권 복구</strong></li>
</ul>
<p>이러한 흐름을 통해 실제 현장에서 자주 들었던  </p>
<blockquote>
<p>“누가 노트북을 가지고 있는지 모르겠어요…”  </p>
</blockquote>
<p>라는 문제를 <strong>기술적으로 해결</strong>하는 것을 목표로 하고 있습니다.</p>
<blockquote>
<p>⚙️ 현재는 해당 로직을 구현하기 위한 엔티티/서비스 구조를 고민 중이며, 추후 기능 안정화 이후 단계적으로 도입할 계획입니다.</p>
</blockquote>
<hr>
<h2 id="✅-관리자-마이페이지에서-확인-가능">✅ 관리자 마이페이지에서 확인 가능</h2>
<p>추가적으로, 관리자는 마이페이지에서 <strong>수리 요청 현황</strong>을 한눈에 파악할 수 있도록 구성된 통계 카드들을 통해 확인할 수 있습니다.  </p>
<p>요청 수, 처리 상태, 이번 주 접수 건수 등을 <strong>시각적으로 보여주어 업무 효율을 높였습니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/dob-by/post/4048124a-d319-4e95-b248-9358fbe5fbed/image.png" alt=""></p>
<blockquote>
<p>⚙️ 현재는 전체 수리 요청 기준의 통계만 제공되며, 추후에는 로그인한 관리자의 개인 처리 현황까지 구분하여 보여주는 기능도 추가할 예정입니다.</p>
</blockquote>
<hr>
<h2 id="📌-요약">📌 요약</h2>
<table>
<thead>
<tr>
<th>역할</th>
<th>가능 작업</th>
<th>권한 조건</th>
</tr>
</thead>
<tbody><tr>
<td>학생</td>
<td>수리 요청 작성 / 내역 확인</td>
<td><code>ROLE_STUDENT</code></td>
</tr>
<tr>
<td>관리자</td>
<td>모든 요청 조회 및 상태 변경</td>
<td><code>MID_ADMIN</code> 이상</td>
</tr>
</tbody></table>
<hr>
<h2 id="⏭-다음-글에서는">⏭ 다음 글에서는...</h2>
<blockquote>
<p>공지사항, 게시판, 그리고 FAQ까지 <strong>학생과 관리자 모두가 정보를 쉽게 주고받을 수 있도록 만든 커뮤니케이션 기능들</strong>에 대해 소개합니다. 특히 댓글 기능, 검색/페이징 처리, 역할 기반 접근제한 등의 구현 과정을 다룰 예정입니다!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발일지] WingITs #3 - 하나의 로그인, 여러 개의 권한]]></title>
            <link>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-3-%ED%95%98%EB%82%98%EC%9D%98-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%91%90-%EA%B0%9C%EC%9D%98-%EA%B6%8C%ED%95%9C</link>
            <guid>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-3-%ED%95%98%EB%82%98%EC%9D%98-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%91%90-%EA%B0%9C%EC%9D%98-%EA%B6%8C%ED%95%9C</guid>
            <pubDate>Sat, 14 Jun 2025 15:28:59 GMT</pubDate>
            <description><![CDATA[<h2 id="🔐-로그인은-하나-권한은-둘">🔐 로그인은 하나, 권한은 둘</h2>
<p>이 프로젝트를 설계하면서 가장 고민이 많았던 부분 중 하나는 바로<br><strong>“학생과 관리자가 동일한 로그인 페이지를 통해 접근하되, 서로 다른 권한을 갖게 하자”</strong>는 요구사항이었습니다.</p>
<p>처음에는 간단하게 생각했습니다.<br>“그냥 Role만 구분하면 되겠지?”</p>
<p>하지만 구현 단계에서는 여러 갈래의 고민이 이어졌습니다.</p>
<ul>
<li>학생은 OAuth2만 허용하고, 관리자는 로컬 로그인만?</li>
<li>관리자도 OAuth2를 쓸 수 있게 하려면 <strong>추가 정보 입력</strong>을 어떻게 처리하지?</li>
<li>로그인 후 역할에 따라 라우팅을 나눌까, 통합할까?</li>
<li>로그인 폼을 아예 나눌까, 하나로 통합할까?</li>
</ul>
<p>결국 제가 내린 결론은 이랬습니다:</p>
<hr>
<h3 id="👩🎓-학생">👩‍🎓 학생</h3>
<ul>
<li><strong>로그인 방식</strong>: 군번(ID) + <code>군번+생년월일 6자리</code> (초기 비밀번호)</li>
<li><strong>사전 등록된 DB 정보</strong> 기반으로만 로그인 가능</li>
<li>최초 로그인 시 비밀번호 <strong>즉시 변경 필수</strong></li>
<li>자동으로 <code>ROLE_STUDENT</code> 부여</li>
<li><strong>학생 전용 기능만 접근 가능</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/dob-by/post/174e530f-87b2-4d9a-9576-b1c886352553/image.gif" alt=""></p>
<hr>
<h3 id="👩💼-관리자">👩‍💼 관리자</h3>
<h4 id="✅-로컬-로그인">✅ 로컬 로그인</h4>
<ol>
<li>회원가입 시 이름, 군번, 이메일 등 기본 정보 입력</li>
<li><code>ROLE_PENDING_ADMIN</code> 상태로 DB 저장 (로그인 불가)</li>
<li>총괄 관리자(<code>ROLE_TOP_ADMIN</code>)의 <strong>승인 후</strong></li>
<li><code>ROLE_MID_ADMIN</code> 권한 부여 → 로그인 가능</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dob-by/post/d659ee05-ec64-4f77-b138-67766fd9c895/image.gif" alt=""></p>
<h4 id="✅-oauth2-로그인-google--kakao">✅ OAuth2 로그인 (Google / Kakao)</h4>
<ol>
<li>OAuth2 로그인 성공 시 <code>ROLE_TEMP</code>으로 임시 등록</li>
<li>즉시 <strong>추가 정보 입력 페이지로 리디렉션</strong></li>
<li>입력 완료 시 <code>ROLE_PENDING_ADMIN</code>으로 상태 전환</li>
<li>총괄 관리자 승인 후 <code>ROLE_MID_ADMIN</code> 으로 변경 → 로그인 가능</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dob-by/post/db11ccb8-4635-44da-8a19-86900c73e4b7/image.gif" alt=""></p>
<hr>
<h2 id="🗝️-관리자-권한-접근-정책">🗝️ 관리자 권한 접근 정책</h2>
<h3 id="✅-role_temp-role_pending_admin">✅ <code>ROLE_TEMP</code>, <code>ROLE_PENDING_ADMIN</code></h3>
<ul>
<li><strong>로그인 가능</strong></li>
<li>하지만 <code>/admin/**</code> 경로는 <strong>모두 접근 차단</strong></li>
<li>단, 예외적으로 아래 경로는 접근 허용:<ul>
<li><code>/admin/profile</code> (추가 정보 입력용 페이지)</li>
</ul>
</li>
</ul>
<blockquote>
<p>🔒 관리 기능은 <strong>사용 불가</strong></p>
</blockquote>
<hr>
<h3 id="✅-role_mid_admin-role_top_admin">✅ <code>ROLE_MID_ADMIN</code>, <code>ROLE_TOP_ADMIN</code></h3>
<ul>
<li><code>/admin/**</code> 경로 포함한 <strong>모든 관리자 기능 접근 가능</strong></li>
<li>권한 설명:<ul>
<li><code>ROLE_MID_ADMIN</code>: 일반 관리자</li>
<li><code>ROLE_TOP_ADMIN</code>: 총괄 관리자 (다른 관리자 승인 가능)</li>
</ul>
</li>
</ul>
<hr>
<h2 id="🧑✈️-총괄-관리자-role_top_admin">🧑‍✈️ 총괄 관리자 (<code>ROLE_TOP_ADMIN</code>)</h2>
<ul>
<li><code>ROLE_TOP_ADMIN</code>은 <strong>시스템 초기 등록 시 1명만 존재</strong></li>
<li>승인 대기 관리자 조회/승인 기능 (<code>/admin/pending-admins</code>)은 <strong>TOP_ADMIN만 접근 가능</strong></li>
<li>추후 <strong>인수인계를 위한 <code>TOP_ADMIN</code> 권한 이관 기능</strong> 별도 개발 예정</li>
</ul>
<hr>
<h2 id="⚙️-설계-흐름">⚙️ 설계 흐름</h2>
<pre><code>로그인 요청
├── 학생 로그인 (로컬)
│   └── DB에 저장된 사전 등록 정보 확인 → ROLE_STUDENT 부여
│
└── 관리자 로그인
    ├── 로컬 회원가입 → ROLE_TEMP
    │   └── ROLE_TOP_ADMIN 승인 → ROLE_MID_ADMIN
    │
    └── OAuth2 로그인 → ROLE_TEMP
        └── 추가 정보 입력 → ROLE_PENDING_ADMIN
            └── ROLE_TOP_ADMIN 승인 → ROLE_MID_ADMIN</code></pre><hr>
<h2 id="🔁-로그인-폼-하나로-통합할-것인가">🔁 로그인 폼, 하나로 통합할 것인가?</h2>
<p>이 프로젝트에서 고민했던 포인트 중 하나는<br><strong>&quot;로그인 폼을 기술적으로 통합하면서도, 사용자 경험을 구분할 수 있을까?&quot;</strong>였습니다.</p>
<p>처음에는 <code>/login</code> 하나로 통합된 폼만 제공했지만, 학생과 교직원이 혼동하지 않도록 <strong>UI 상에서만 탭으로 구분</strong>하는 방식으로 개선했습니다.</p>
<ul>
<li><strong>기술적으로는 여전히 하나의 엔드포인트(<code>/login</code>)</strong></li>
<li>입력받은 <code>userId(군번)</code>을 기반으로 이메일을 조회</li>
<li>Spring Security 내부에서 <strong>이메일 기반 인증</strong> 수행</li>
</ul>
<p>덕분에 로그인 플로우는 단순하게 유지하면서도, <strong>학생 / 관리자의 구분은 <code>Role</code> 기반으로 유연하게 처리</strong>할 수 있었습니다.</p>
<hr>
<h2 id="✅-다음-글에서는">✅ 다음 글에서는...</h2>
<blockquote>
<p>다음 글에서는 가장 핵심 기능인 <strong>학생과 관리자가 주고받는 수리 요청 프로세스</strong>를 어떻게 시스템으로 구현했는지 소개할 예정입니다.<br>특히 수리 이력 관리, 상태 변경 흐름, 권한에 따른 요청 처리 방식까지  <strong>실제 현장 문제를 어떻게 해결했는지</strong> 상세히 풀어보려고합니다!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발일지] WingITs #2 - 기능은 어떻게 정했을까?]]></title>
            <link>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-2-%EA%B8%B0%EB%8A%A5%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A0%95%ED%96%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-2-%EA%B8%B0%EB%8A%A5%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A0%95%ED%96%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Sat, 14 Jun 2025 13:53:37 GMT</pubDate>
            <description><![CDATA[<p>개발을 결심한 건 좋았지만, 막상 만들려고 보니 머릿속이 하얘졌습니다.</p>
<blockquote>
<p>“그래서… 이걸 뭐부터 만들어야 하지?”</p>
</blockquote>
<h2 id="📌-기능-목록은-어디서-나왔을까">📌 기능 목록은 어디서 나왔을까?</h2>
<p>시작은 아주 단순했습니다.<br>실제 업무 중 겪은 <strong>수리 요청 절차의 불편함</strong>을 ‘어떻게 하면 시스템화할 수 있을까’에서 출발했기 때문이죠.</p>
<p>그래서 처음엔 그냥 <strong>내가 자주 들었던 말들</strong>을 적어봤습니다.</p>
<ul>
<li>&quot;선생님, 제 노트북 수리 요청하고 싶은데요.&quot;</li>
<li>&quot;담당자분 누구셨어요?&quot;</li>
<li>&quot;수리 맡긴지 오래됐는데 지금 어디 있는지 모르겠어요.&quot;</li>
<li>&quot;어떤 학생 노트북이 고장나서 다른 노트북으로 교환했는데 지금 누가 사용하고있는지 모르겠네요.&quot;</li>
</ul>
<p>이런 대화 속에서 필요한 기능들이 자연스럽게 떠올랐습니다.</p>
<h3 id="🎯-최소-기능-목록">🎯 최소 기능 목록</h3>
<ul>
<li><strong>학생</strong><ul>
<li>수리 요청 등록</li>
<li>내가 맡긴 수리 내역 확인</li>
</ul>
</li>
<li><strong>관리자</strong><ul>
<li>수리 요청 목록 보기</li>
<li>요청 상태 변경 (접수됨 / 수리 중 / 완료 등)</li>
<li>수리 이력 관리</li>
</ul>
</li>
</ul>
<p>우선은 이 정도만 있어도 “입력 → 처리 → 이력 확인”의 흐름이 가능하다고 생각했습니다.</p>
<hr>
<h2 id="🔁-기능을-정하면서-고민했던-점들">🔁 기능을 정하면서 고민했던 점들</h2>
<p>처음엔 기능이 아주 단순할 거라고 생각했지만, 실제로 만들다 보니 하나하나에서 고민할 게 정말 많았습니다.</p>
<h3 id="1-수리-요청은-누구나-할-수-있을까">1. 수리 요청은 누구나 할 수 있을까?</h3>
<p>→ 아니다. <strong>로그인한 학생만</strong> 가능해야 했고, <strong>중복 요청</strong>은 방지해야 했습니다.<br>→ 그래서 상태가 &#39;처리 중&#39;인 요청이 있을 땐, 새 요청을 막는 로직을 넣었습니다.</p>
<h3 id="2-관리자-권한은-어떻게-나눌까">2. 관리자 권한은 어떻게 나눌까?</h3>
<p>→ 그냥 다 admin이면 될 줄 알았는데, 관리자도 <strong>중간 관리자 / 최고 관리자</strong>로 나눠야 했습니다. 
(관리자 인원이 증가할수록 수동으로 사용자를 변경하는 기능을 모든 관리자가 할 수 있다면 잘못 설정하는 일이 발생할 수 있다고 생각이 들었습니다.)</p>
<h3 id="3-수리-요청-상태는-어떻게-설계할까">3. 수리 요청 상태는 어떻게 설계할까?</h3>
<p>→ &#39;요청됨 → 수리 중 → 수리 완료&#39;처럼 간단해보이지만, 학생들이 요청서를 잘못 적는 경우도 있기 때문에 실제로는 &#39;반려됨&#39;같은 예외 상태도 필요했습니다.</p>
<p>→ 그래서 <code>enum</code>으로 <code>RepairStatus</code>를 설계하고, 뷰에서도 한눈에 상태별 UI를 바꿔줄 수 있도록 처리했습니다.</p>
<hr>
<h2 id="🧠-흐름도와-erd의-시작">🧠 흐름도와 ERD의 시작</h2>
<p>처음에는 아주 단순한 흐름만 그렸습니다.</p>
<ul>
<li>수리 요청 흐름: 학생 → 요청 등록 → 관리자 확인 → 처리</li>
<li>화면 구성: 로그인 → 메인 → 내역 확인 (학생) / 전체 요청 관리 (관리자)</li>
</ul>
<p>ERD도 초안은 금방 그렸지만, 개발을 진행하면서 기능이 늘어나고 흐름이 바뀌며 구조도 계속 수정됐습니다.</p>
<p>그래서 이 글에서는 구체적인 ERD보다는, 어떤 기능을 어떤 흐름으로 구현했는지에 초점을 맞춰 정리해보려 합니다.</p>
<hr>
<h2 id="🤔-정리하며">🤔 정리하며</h2>
<p>‘기획’이라고 해서 거창한 게 필요한 건 아니었습니다. 오히려 <strong>내가 겪은 불편함을 적어보는 것</strong>부터 시작하니 그 안에서 자연스럽게 기능들이 나왔습니다.</p>
<p>무조건 많이 만들기보다는, <strong>실제로 필요한 흐름을 작동시킬 수 있는 최소한의 기능</strong>에서 부터 출발하는 게 정말 중요하다고 느꼈습니다.</p>
<hr>
<h2 id="다음-글에서는">다음 글에서는...</h2>
<blockquote>
<p>✅ 개발 중 가장 복잡했던 <strong>&quot;학생과 관리자, 로그인은 같지만 권한은 다르게&quot;</strong><br>인증 방식과 권한 분리, 그리고 로그인 흐름 설계에 대해 자세히 풀어보려 합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[개발일지] WingITs #1 - 불편함에서 시작된 개발, 그리고 나만의 첫 서비스]]></title>
            <link>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-%EB%B6%88%ED%8E%B8%ED%95%A8%EC%97%90%EC%84%9C-%EC%8B%9C%EC%9E%91%EB%90%9C-%EA%B0%9C%EB%B0%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%B2%AB-%EC%99%84%EC%84%B1%ED%98%95-%EC%84%9C%EB%B9%84%EC%8A%A4</link>
            <guid>https://velog.io/@dob-by/%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80-WingITs-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-%EB%B6%88%ED%8E%B8%ED%95%A8%EC%97%90%EC%84%9C-%EC%8B%9C%EC%9E%91%EB%90%9C-%EA%B0%9C%EB%B0%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%B2%AB-%EC%99%84%EC%84%B1%ED%98%95-%EC%84%9C%EB%B9%84%EC%8A%A4</guid>
            <pubDate>Sat, 14 Jun 2025 13:31:14 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/dob-by/post/d201a382-121d-4bb2-baf1-fa59165a7002/image.png" alt="시스템 도입 전, 구두 안내 장면"><br><sub>시스템 도입 전, 400여 명의 학생에게 수리 절차를 구두로 설명하던 당시 상황.<br>비효율적인 수기 방식의 문제를 절실히 느끼게 된 계기였습니다.</sub></p>
<h2 id="🙋♀️-왜-시작했을까">🙋‍♀️ 왜 시작했을까?</h2>
<p>장교로 군 복무 중, 항공과학고에서 전산 교관으로 근무하면서  저는 학교 내 노트북의 <strong>소프트웨어 관리를 담당</strong>하고 있었습니다.</p>
<p>이 시기, 학생들의 수리 요청은 대부분  고장 난 노트북을 <strong>직접 들고 와서 구두로 설명</strong>하거나, <strong>종이에 간단히 적어</strong> 전달하는 방식이었습니다.</p>
<p>요청이 많아질수록 내용을 체계적으로 기록하거나 수리 이력을 관리하기엔 너무나 비효율적인 구조였습니다.<br>(심지어 하드웨어 담당자와의 공간적 분리로 인해  <strong>수리 중 노트북이 분실되는</strong> 일도 있었습니다.)</p>
<blockquote>
<p>“선생님, 제 노트북이 갑자기 꺼져요. 그래서 수리를 맡겼는데 노트북을 받지 못했어요.”<br>“그때 담당자는 누구셨죠?”<br>“음… 잘 모르겠는데요…?”</p>
</blockquote>
<p>문제가 생겨도 이력을 추적할 수 없었고, 수리 상태를 공유하거나 누가 어떤 노트북을 갖고 있는지도 알기 어려웠습니다. 모든 것이 너무 <strong>아날로그적</strong>이었습니다.</p>
<p>그래서 생각했습니다.  </p>
<p><strong>“이걸 시스템으로 만들 수 있지 않을까?”</strong></p>
<hr>
<h2 id="💡-처음엔-아주-단순했다">💡 처음엔 아주 단순했다</h2>
<p>처음엔 그저 ‘수리 요청서 작성 기능’ 하나만 만들 생각이었습니다. 그런데 막상 시작하고 보니 생각보다 고려할 게 많았습니다.</p>
<ul>
<li>누가 노트북을 보유 중인지 어떻게 판단할까?  </li>
<li>수리 중일 땐 소유권이 누구에게 있을까?  </li>
<li>관리자와 학생의 권한을 어떻게 분리할까?  </li>
<li>OAuth2 로그인도 붙일 수 있을까?  </li>
<li>어차피 하는 김에 공지사항, 댓글, 마이페이지도?</li>
</ul>
<p>하나씩 기능이 붙어가며, 단순한 요청서 시스템에서 <strong>자산 및 수리 이력 관리 시스템</strong>으로 확장됐습니다. 기획부터 개발까지 모든 걸 직접 하면서  <strong>첫 웹 서비스</strong>를 완성했습니다.</p>
<hr>
<h2 id="🧱-기술보다-사용자-경험">🧱 기술보다 사용자 경험</h2>
<p>이번 프로젝트에선 멋진 기술보다  <strong>“실제로 쓰기 편한가?”</strong>에 더 집중했습니다.</p>
<ul>
<li>학생은 간단히 수리 요청을 등록할 수 있어야 했고  </li>
<li>관리자는 전체 요청 내역과 현재 수리 상태를 한눈에 볼 수 있어야 했다  </li>
<li>관리자 승인 흐름, 역할 구분, 로그인 방식까지 고려해야 했다  </li>
<li>군 환경에 맞춘 OAuth2 로그인과 관리자 승인 로직도 직접 설계했다</li>
</ul>
<p>그 과정에서 Spring Security, Docker, MySQL, Excel 데이터 마이그레이션까지<br>정말 다 해봤고… 그만큼 삽질도 많이 했습니다 😇</p>
<hr>
<h2 id="🏁-문제를-발견하고-직접-만든-첫-개발-프로젝트">🏁 문제를 발견하고, 직접 만든 첫 개발 프로젝트</h2>
<p>아직도 고칠 점은 많습니다. 하지만 이 프로젝트는 문제를 스스로 발견하고 → 기획하고 → 설계하고 → 개발한  <strong>나만의 첫 번째 서비스</strong>입니다.</p>
<p>그리고 무엇보다, 진짜 재밌었습니다.</p>
<hr>
<h2 id="💬-마무리">💬 마무리</h2>
<p>제가 만들고 싶은 건 “대단한 기술”이 아니라 <strong>“실용적이고 사용자에게 필요한 서비스”</strong>입니다.</p>
<p>WingITs는 그 첫걸음이었고, 앞으로 더 나은 개발자가 되기 위한 여정은 계속될 것입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[WingITs / Spring Boot] H2 → MySQL 전환 시 서버 재시작마다 DB 날아간다면?]]></title>
            <link>https://velog.io/@dob-by/Spring-Boot-H2-MySQL-%EC%A0%84%ED%99%98-%EC%8B%9C-%EC%84%9C%EB%B2%84-%EC%9E%AC%EC%8B%9C%EC%9E%91%EB%A7%88%EB%8B%A4-DB-%EB%82%A0%EC%95%84%EA%B0%84%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@dob-by/Spring-Boot-H2-MySQL-%EC%A0%84%ED%99%98-%EC%8B%9C-%EC%84%9C%EB%B2%84-%EC%9E%AC%EC%8B%9C%EC%9E%91%EB%A7%88%EB%8B%A4-DB-%EB%82%A0%EC%95%84%EA%B0%84%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Mon, 09 Jun 2025 01:19:53 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>로컬 개발 환경에서 H2 인메모리 DB를 사용하다가 MySQL로 전환하면서 발생했던 문제들과 그 해결 과정을 정리합니다. Docker와 TablePlus를 활용했으며, <code>data.sql</code> 자동 실행 및 <code>application-test.yml</code> 오작동 등 다양한 이슈들을 직접 해결했습니다.</p>
</blockquote>
<h2 id="❗️문제-정의">❗️문제 정의</h2>
<p>H2에서 MySQL로 데이터베이스를 전환한 후 <strong>회원가입을 하게 되면 정상적으로 데이터가 저장되지만</strong>, 서버를 재시작하면 저장된 데이터가 사라지는 문제가 발생했습니다. </p>
<h3 id="문제-재현-절차">문제 재현 절차</h3>
<ol>
<li><p>IntelliJ에서 서버 실행</p>
</li>
<li><p>브라우저에서 회원가입 진행</p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dob-by/post/9086c148-b866-4e38-9a3f-9d7a65e33526/image.png" alt=""></p>
<ol start="3">
<li><p>TablePlus에서 <code>users</code> 테이블 확인<br><img src="https://velog.velcdn.com/images/dob-by/post/a20c7858-a8cc-4176-9cd9-8812d066ac6c/image.png" alt=""><em>▲ 회원가입 직후 TablePlus에 저장된 사용자 정보 확인 화면</em></p>
</li>
<li><p>IntelliJ에서 서버 종료 (DemoApplication 수동 종료)</p>
</li>
<li><p>Docker 컨테이너 종료  </p>
<pre><code class="language-bash">docker stop wingits-mysql</code></pre>
</li>
<li><p>Docker 컨테이너 다시 시작  </p>
<pre><code class="language-bash">docker start wingits-mysql</code></pre>
</li>
<li><p>IntelliJ에서 서버 재실행</p>
</li>
<li><p>다시 TablePlus에서 <code>users</code> 테이블 확인<br><img src="https://velog.velcdn.com/images/dob-by/post/3c870656-d7af-465d-8b3b-b91cfaf83e85/image.png" alt=""><em>▲ 서버 재실행 후 회원 정보가 사라진 상태</em></p>
</li>
</ol>
<hr>
<h2 id="🔍-사실-수집">🔍 사실 수집</h2>
<ul>
<li><code>spring.jpa.hibernate.ddl-auto: update</code>로 설정되어 있음</li>
<li>회원가입은 성공했고, TablePlus에서도 데이터 확인됨</li>
<li><code>docker volume</code>도 정상적으로 연결되어 있었음</li>
<li><code>CommandLineRunner</code> 안에서 <code>userRepository.deleteAll()</code> 사용</li>
<li><code>data.sql</code> 파일이 존재함</li>
<li><code>application-test.yml</code>에서 mysql을 사용하도록 되어 있었다는 사실을 발견함</li>
</ul>
<hr>
<h2 id="💡-원인-추론">💡 원인 추론</h2>
<ol>
<li><code>data.sql</code> 자동 실행
→ Spring Boot는 서버 시작 시 data.sql이 존재하면 이를 실행함. 이로 인해 MySQL DB의 기존 데이터가 매번 초기화됨.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/dob-by/post/4b6ad389-ac08-48f9-af79-9c5dfbb008a9/image.png" alt=""><em>▲ MySQL로 전환한 뒤에도 <code>data.sql</code> 파일이 남아 있는 상태</em></p>
<ol start="2">
<li><p>CommandLineRunner에서 <code>deleteAll()</code> 호출
→ 개발 중 테스트용 코드가 실서버에서도 동작하며 회원 데이터를 모두 삭제해 문제를 악화시킴.</p>
</li>
<li><p><code>application-test.yml</code> 설정이 제대로 분리되지 않음
→ 테스트 환경에서도 실제 MySQL을 참조하게 되어 의도치 않게 실DB에 영향을 줄 수 있는 구조였음.</p>
</li>
</ol>
<hr>
<h2 id="해결-방법-결정">해결 방법 결정</h2>
<ol>
<li><p>운영 환경에서 유저 데이터가 삭제되지 않도록
•    CommandLineRunner 내 userRepository.deleteAll() 코드 제거</p>
</li>
<li><p>Spring Boot의 자동 SQL 실행 방지
•    data.sql, schema.sql 파일 완전 삭제</p>
</li>
<li><p>테스트 환경과 운영 환경의 설정 명확히 분리
•    운영은 MySQL, 테스트는 H2를 사용
•    application.yml과 application-test.yml을 분리하고, 테스트에서는 아래와 같은 H2 설정만 유지</p>
</li>
</ol>
<pre><code class="language-yaml">spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
  h2:
    console:
      enabled: true</code></pre>
<ol start="4">
<li>도커 볼륨 확인 및 MySQL 영속성 확보
•    Docker Volume이 /var/lib/mysql에 제대로 마운트되어 있는지 확인 (docker inspect 명령 사용)
•    컨테이너 재시작 시 데이터가 유지되는지 테스트 완료</li>
</ol>
<hr>
<h2 id="결론-요약">결론 요약</h2>
<table>
<thead>
<tr>
<th>원인</th>
<th>설명</th>
<th>조치</th>
</tr>
</thead>
<tbody><tr>
<td><code>data.sql</code> 자동 실행</td>
<td>서버 시작 시 실행되어 DB 초기화</td>
<td><code>data.sql</code> 삭제</td>
</tr>
<tr>
<td><code>CommandLineRunner.deleteAll()</code></td>
<td>운영 환경에서도 모든 유저 삭제 실행됨</td>
<td>해당 코드 제거 또는 조건 분기</td>
</tr>
<tr>
<td><code>application-test.yml</code> 설정 오류</td>
<td>테스트 DB가 MySQL로 설정됨</td>
<td>H2로 수정하여 테스트 분리</td>
</tr>
<tr>
<td>Docker 볼륨 마운트 불확실</td>
<td>데이터 유지가 안 되는 의심</td>
<td><code>docker inspect</code>로 확인, 문제 없음</td>
</tr>
</tbody></table>
<hr>
<h2 id="실수를-줄이기-위한-팁">실수를 줄이기 위한 팁</h2>
<ul>
<li><code>data.sql</code>파일은 <strong>운영 환경에서 사용하지 않으면 삭제</strong>해두는 것이 좋습니다. (실수로 DB가 초기화될 수 있습니다.)</li>
<li><code>CommandLineRunner</code>에서 초기화 코드(<code>deleteAll()</code> 등)는 <strong>테스트 전용 조건 분기</strong> 또는 <strong>@Profile(&quot;test&quot;)</strong> 설정으로 운영 환경과 분리하는 것이 좋습니다.</li>
<li><code>application.yml</code>, <code>application-test.yml</code>의 설정을 명확히 구분하고, 테스트 환경에서는 반드시 <strong>H2 같은 인메모리 DB</strong>를 사용합시다.</li>
<li><code>docker inspect</code> 명령어로 <strong>볼륨 마운트 상태를 꼭 확인</strong>해두면, 데이터 손실 여부를 빠르게 파악할 수 있습니다.</li>
<li>중요한 설정(<code>ddl-auto</code>, datasource url 등)은 <strong>코드 리뷰 전 꼭 double-check</strong>!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[ [WingITs / Spring Security] 인증은 됐는데 왜 역할은 ANONYMOUS?]]></title>
            <link>https://velog.io/@dob-by/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%97%AD%ED%95%A0%EC%9D%B4-%EC%86%90%EB%8B%98ANONYMOUS%EC%9C%BC%EB%A1%9C-%EB%9C%A8%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@dob-by/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%97%AD%ED%95%A0%EC%9D%B4-%EC%86%90%EB%8B%98ANONYMOUS%EC%9C%BC%EB%A1%9C-%EB%9C%A8%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Sat, 17 May 2025 15:43:55 GMT</pubDate>
            <description><![CDATA[<h1 id="프로젝트-소개">프로젝트 소개</h1>
<p>현재 학교 내 노트북 수리 요청 및 사용자 관리 시스템을 개발 중입니다.
Spring Boot와 Spring Security를 이용해 <strong>로컬 로그인과 카카오/구글 OAuth 로그인을 모두 지원</strong>합니다.</p>
<p>사용자 역할(Role)에 따라</p>
<ul>
<li><strong>일반 학생(STUDENT)</strong>은 게시글 작성 및 수리 요청 가능</li>
<li><strong>관리자(MID_ADMIN, TOP_ADMIN)</strong>는 사용자/노트북 관리 권한을 갖습니다.</li>
</ul>
<h1 id="문제-상황">문제 상황</h1>
<p>상단 메뉴바에 현재 로그인 중인 사용자 정보를 표시합니다. 이때 관리자 계정으로 로그인이 되어있기 때문에 TOP_ADMIN으로 표시되어야하나, 역할이 ANONYMOUS(손님)으로 잘못 표시되는 문제가 발생했습니다.</p>
<blockquote>
<p>👉 문제는 <strong>게시글 상세 페이지(<code>/posts/{id}</code>)에서만 발생</strong>했어요.
다른 페이지에서는 정상적으로 <strong>TOP_ADMIN | 관리자</strong>로 표시되었죠.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/dob-by/post/906b8ec5-f60c-4a26-b1c0-df558c225e23/image.png" alt=""></p>
<h1 id="🔍-원인-분석">🔍 원인 분석</h1>
<p>원인을 파악하기 위해 게시글 상세 페이지(/posts/{postId})와 관련된 컨트롤러에서 <code>viewPost()</code>메서드를 분석했습니다.</p>
<pre><code class="language-java">// 게시글 상세
    @GetMapping(&quot;/{postId}&quot;)
    public String viewPost(@PathVariable String postId,
                           Model model,
                           HttpServletRequest request) {

        postService.incrementViewCount(postId);
        Post post = postService.getPost(postId);
        model.addAttribute(&quot;post&quot;, post);
        model.addAttribute(&quot;comments&quot;, commentService.getCommentsByPostId(postId));

        // 현재 로그인한 사용자 정보를 가져오기 위한 인증 객체 획득
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        // 인증된 사용자의 경우
        if (authentication != null &amp;&amp; authentication.isAuthenticated()) {
            Object principal = authentication.getPrincipal();

            if (principal instanceof CustomOAuth2User user) {
                model.addAttribute(&quot;currentUserEmail&quot;, user.getEmail());
                model.addAttribute(&quot;userRole&quot;, user.getRole().name());
            } else if (principal instanceof org.springframework.security.core.userdetails.User user) {
                // 로컬 유저의 경우 DB에서 이메일/역할 가져오기 (‼️문제 발생 의심 지점‼️)
                System.out.println(&quot;✅ 로컬사용자&quot;); //출력 ❌
                String email = user.getUsername(); // email이 username에 담김
                String role = postService.findRoleByEmail(email); // PostService에서 사용자 role 조회하는 메서드 만들기

                model.addAttribute(&quot;currentUserEmail&quot;, email);
                model.addAttribute(&quot;userRole&quot;, role);
            } else if (principal instanceof org.springframework.security.core.userdetails.User user) {
                String email = user.getUsername();
                String role = postService.findRoleByEmail(email);
                model.addAttribute(&quot;currentUserEmail&quot;, email);
                model.addAttribute(&quot;userRole&quot;, role);

            } else if (principal instanceof String email) {
                model.addAttribute(&quot;currentUserEmail&quot;, email);
                model.addAttribute(&quot;userRole&quot;, &quot;USER&quot;);

            } else {
                model.addAttribute(&quot;currentUserEmail&quot;, null);
                model.addAttribute(&quot;userRole&quot;, &quot;ANONYMOUS&quot;);
            }
        } else {
            model.addAttribute(&quot;currentUserEmail&quot;, null);
            model.addAttribute(&quot;userRole&quot;, &quot;ANONYMOUS&quot;);
        }

        // CSRF
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(&quot;_csrf&quot;);
        model.addAttribute(&quot;_csrf&quot;, csrfToken);

        return &quot;post/view&quot;;
    }</code></pre>
<p>콘솔 출력 결과 <code>authentication != null && authentication.isAuthenticated()</code>조건은 통과하지만 <strong>✅ 로컬 사용자</strong>가 출력되지 않았습니다. <code>principal instanceof org.springframework.security.core.userdetails.User user</code>조건이 false가 되는 것이 원인이었죠.</p>
<p><code>SecurityContextHolder.getContext().getAuthentication().getPrincipal()</code>을 통해 로그인 정보를 가져오는데, principal 객체 타입이 잘못되어 발생한 문제였습니다.</p>
<table>
<thead>
<tr>
<th>로그인 방식</th>
<th>principal 타입</th>
</tr>
</thead>
<tbody><tr>
<td>OAuth 로그인(구글, 카카오)</td>
<td>CustomOAuth2User</td>
</tr>
<tr>
<td>로컬 로그인</td>
<td>CustomUserDetails</td>
</tr>
</tbody></table>
<p>👉 그런데 저는 컨트롤러에서 <span style="background-color:#FFE6E6">CustomOAuth2User만 체크하고, CustomUserDetails는 체크하지 않아
userRole = ANONYMOUS로 떨어졌던 겁니다.</span></p>
<h1 id="✅-해결-방법">✅ 해결 방법</h1>
<p>viewPost() 메서드에서 principal의 타입을 명확히 분기했습니다.</p>
<pre><code class="language-java">if (principal instanceof CustomOAuth2User user) {
    // OAuth 사용자 처리
    ...
} else if (principal instanceof CustomUserDetails userDetails) {
    // 로컬 로그인 사용자 처리
    ...
}</code></pre>
<p><img src="https://velog.velcdn.com/images/dob-by/post/768c7309-a4bc-45cb-8bfb-34a9a477e6d1/image.png" alt=""></p>
<p>이로써 로컬 로그인 사용자도 userRole, username이 정확하게 나왔답니다!!👏👏👏</p>
<h1 id="배운-점">배운 점</h1>
<ul>
<li>Spring Security의 Authentication.getPrincipal()은 로그인 방식에 따라 타입이 다르다.</li>
<li>로그인 방식을 혼합해서 사용할 땐, @ControllerAdvice나 개별 컨트롤러에서 instanceof 분기 처리를 꼭 하자!</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[SQL] WHERE과 HAVING의 차이 비교]]></title>
            <link>https://velog.io/@dob-by/SQL-WHERE%EA%B3%BC-HAVING</link>
            <guid>https://velog.io/@dob-by/SQL-WHERE%EA%B3%BC-HAVING</guid>
            <pubDate>Tue, 28 Jan 2025 05:49:53 GMT</pubDate>
            <description><![CDATA[<h1 id="select문의-실행-순서">SELECT문의 실행 순서</h1>
<p>WHERE과 HAVING의 차이를 비교하기 전에 우선 SQL쿼리의 실행 순서를 알아야합니다.</p>
<pre><code class="language-sql">SELECT ename
FROM emp
WHERE empno=10
GROUP BY ename
HAVING count(*)&gt;=1
ORDER BY ename;</code></pre>
<p>위 쿼리에서 SELECT문의 실행 순서는 <code>FROM > <span style="color:red">WHERE > <strong>GROUP BY</strong> > HAVING</span> > SELECT > ORDER BY</code>입니다.</p>
<h1 id="where과-having">WHERE과 HAVING</h1>
<h2 id="where">WHERE</h2>
<pre><code class="language-sql">SELECT * FROM 테이블 WHERE 조건절;</code></pre>
<pre><code>•    역할: 데이터를 그룹화(GROUP BY) 이전에, 개별 행(row) 수준에서 조건을 필터링.
•    적용 대상: 테이블의 개별 행.
•    사용 위치: FROM 절 바로 다음에 실행.
•    집계 함수(예: SUM, AVG)는 사용할 수 없음.</code></pre><p>WHERE은 기본적인 조건절로 항상 FROM 뒤에 위치하며 다양한 비교 연산자로 구체적인 조건을 줄 수 있습니다.</p>
<h2 id="having">HAVING</h2>
<pre><code class="language-sql">SELECT * FROM 테이블 GROUP BY 필드 HAVING 조건절;</code></pre>
<pre><code>•    역할: 데이터를 그룹화(GROUP BY) 이후에, 그룹 전체를 대상으로 조건을 필터링.
•    적용 대상: 그룹화된 데이터(aggregate data).
•    사용 위치: GROUP BY 절 뒤에 실행.
•    집계 함수(예: SUM, AVG)를 사용할 수 있음.</code></pre><p>항상 GROUP BY 뒤에 위치하며 WHERE과 마찬가지로 다양한 비교 연산자로 조건을 줄 수 있습니다.</p>
<h3 id="공통점과-차이점">공통점과 차이점</h3>
<p>둘 다 필드에 조건을 줄 수 있다는 것은 동일하지만, <span style="background-color:#FFE6E6">WHERE은 기본적으로 모든 필드에 조건을 줄 수 있지만 HAVING은 <strong>GROUP BY로 그룹화 된 필드에 조건을 줄 수 있습니다</strong>.</span> 또한, <strong>HAVING에서 조건을 줄 필드는 <span style="color:red">SELECT에 반드시 명시</strong></span>되어 있어야 합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th align="left">WHERE</th>
<th align="left">HAVING</th>
</tr>
</thead>
<tbody><tr>
<td>적용 시점</td>
<td align="left"><strong>그룹화(GROUP BY) 이전</strong></td>
<td align="left"><strong>그룹화(GROUP BY) 이후</strong></td>
</tr>
<tr>
<td>적용 대상</td>
<td align="left">테이블의 <strong>개별 행(row)</strong></td>
<td align="left">그룹화된 <strong>집계 데이터</strong></td>
</tr>
<tr>
<td>집계 함수 사용 가능 여부</td>
<td align="left"><strong>불가능</strong></td>
<td align="left"><strong>가능</strong></td>
</tr>
<tr>
<td>사용 위치</td>
<td align="left">GROUP BY 이전</td>
<td align="left">GROUP BY 이후</td>
</tr>
<tr>
<td>필터링 기준</td>
<td align="left">개별 행을 기준으로 조건 검사</td>
<td align="left">그룹화된 데이터를 기준으로 조건 검사</td>
</tr>
</tbody></table>
<h1 id="적용-예제">적용 예제</h1>
<p>우선 직업별 급여 합계 중에 급여 합계가 1000이상인 직업을 조회하는 상황을 가정해봅시다.</p>
<h2 id="1-where-사용-span-stylecolorred오류-발생span">1. WHERE 사용( <span style="color:red">오류 발생</span>)</h2>
<pre><code class="language-sql">SELECT job, SUM(salary) AS total_salary
FROM employees
WHERE SUM(salary) &gt;= 1000 -- 오류 발생
GROUP BY job;</code></pre>
<p><strong>SUM(salary)는 그룹화 이후에 계산</strong>되므로, WHERE(그룹화 이전에 적용됨)에서 사용할 수 없습니다.</p>
<h2 id="2-having-사용올바른-사용">2. HAVING 사용(올바른 사용)</h2>
<pre><code class="language-sql">SELECT job, SUM(salary) AS total_salary
FROM employees
GROUP BY job
HAVING SUM(salary) &gt;= 1000;</code></pre>
<blockquote>
</blockquote>
<p><STRONG>쿼리 실행 흐름: </STRONG>
•    1단계: <code>FROM employees</code> → 직원 테이블 로드.
•    2단계: <code>GROUP BY job</code> → 직업별로 데이터를 그룹화.
•    3단계: <code>SUM(salary)</code> → 각 그룹의 급여 합계 계산.
•    4단계: <code>HAVING SUM(salary) >= 1000</code> → 급여 합계가 1000 이상인 그룹만 필터링.</p>
<p>GROUP BY는 데이터를 그룹 단위로 묶는 작업을 합니다. <span style="background-color:#FFE6E6">각 그룹에 대해 집계함수(SUM, AVG, COUNT, MAX, MIN 등)가 적용되며, 이 작업은 <strong>그룹화 후에 수행</strong>됩니다.</span></p>
<p>HAVING절은 GROUP BY 이후에 실행됩니다. 따라서, 이미 그룹화가 끝난 상태에서 SUM(salary) 값을 조건으로 사용할 수 있습니다.</p>
<p>HAVING은 그룹화된 결과에서 집계 결과를 필터링하므로 올바르게 작동합니다.</p>
<h1 id="요약">요약</h1>
<ul>
<li>SUM(salary)는 그룹화된 결과를 기반으로 계산되기 때문에, 그룹화 이전의 WHERE절에서는 사용할 수 없습니다.</li>
<li><span style="background-color:#FFE6E6">그룹화가 끝난 후 조건을 적용하려면, <span style="color:red">반드시 HAVING절을 사용</span>해야 합니다.</span></li>
<li>WHERE는 개별 행을 대상으로 작동하지만, HAVING은 그룹 전체를 대상으로 작동합니다.    <ul>
<li>WHERE: 개별 행 수준의 필터링. <strong>집계 함수 사용 불가.</strong></li>
<li>HAVING: 그룹화된 데이터의 필터링. <strong>집계 함수 사용 가능.</strong></li>
</ul>
</li>
</ul>
<blockquote>
</blockquote>
<p>참고자료</p>
<ul>
<li><a href="https://amaranth1ne.tistory.com/52">https://amaranth1ne.tistory.com/52</a></li>
<li><a href="https://velog.io/@rocknzero/having-where%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%A1%B0%EA%B1%B4%EC%A0%88-%EB%B9%84%EA%B5%90">https://velog.io/@rocknzero/having-where%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%A1%B0%EA%B1%B4%EC%A0%88-%EB%B9%84%EA%B5%90</a> </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[python/백준] 2644. 촌수 계산(S2)]]></title>
            <link>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-2644.-%EC%B4%8C%EC%88%98-%EA%B3%84%EC%82%B0S2</link>
            <guid>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-2644.-%EC%B4%8C%EC%88%98-%EA%B3%84%EC%82%B0S2</guid>
            <pubDate>Fri, 30 Aug 2024 16:09:42 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제-탐색하기">📌 문제 탐색하기</h2>
<p>👉 <a href="https://www.acmicpc.net/problem/2644">문제바로가기</a></p>
<blockquote>
<p>n: 전체 사람의 수(1 ≤ n ≤ 100)
a, b: 촌수를 계산해야하는 서로 다른 두 사람의 번호
m: 부모 자식들간의 관계의 개수 m
x, y: 부모 자식간의 관계를 나타내는 두 번호(x는 y의 부모)</p>
</blockquote>
<p>부모 자식들 간의 관계가 주어졌을 때, 이를 그래프로 구축하고 두 사람의 촌수(간선의 개수)를 구하는 문제입니다. 두 사람의 친척 관계가 전혀 없어 촌수를 계산할 수 없는 경우 -1을 출력해야합니다.</p>
<p>주어진 예제를 예시로 들어보겠습니다.
<img src="https://velog.velcdn.com/images/dob-by/post/2326bb8b-8f44-4383-a607-f28ec0709316/image.png" alt=""></p>
<blockquote>
<ul>
<li>총 9명이 있습니다.</li>
</ul>
</blockquote>
<ul>
<li>우리가 구해야하는 것은 7번과 3번의 촌수입니다.</li>
<li>부모 자식들 간의 관계의 개수는 7개입니다.</li>
<li>1번은 2번의 부모입니다.</li>
<li>1번은 3번의 부모입니다.</li>
<li>2번은 7번의 부모입니다.</li>
<li>2번은 8번의 부모입니다.</li>
<li>2번은 9번의 부모입니다.</li>
<li>4번은 5번의 부모입니다.</li>
<li>4번은 6번의 부모입니다.</li>
</ul>
<p>위 정보를 그림으로 표현하면 아래와 같습니다.</p>
<img src="https://velog.velcdn.com/images/dob-by/post/a463b7dc-6150-4bab-9b2d-7fd5f5b86371/image.png" width="550" height="50"/>

<p><span style="background-color:#FFE6E6">주어진 부모 자식 관계를 통해 그래프를 구축</span>하고, 3에서 시작하여 7까지 가는 <span style="color:red">간선의 수</span>를 구하면 되는 문제입니다!</p>
<h3 id="1️⃣-입력-및-변수-정의">1️⃣ 입력 및 변수 정의</h3>
<pre><code class="language-python">n = int(sys.stdin.readline())
a, b = map(int, sys.stdin.readline().split())
m = int(sys.stdin.readline())</code></pre>
<h3 id="2️⃣-노드정보-저장-및-그래프-기록">2️⃣ 노드정보 저장 및 그래프 기록</h3>
<pre><code class="language-python">graph = [[] for _ in range(n+1)]  #그래프 리스트 초기화
visited = [False] * (n+1)    #방문 여부

for _ in range(m):
    x, y = map(int, sys.stdin.readline().split())   #부모 자식 관계정보
    graph[x].append(y)
    graph[y].append(x)</code></pre>
<h3 id="3️⃣-dfs함수-정의">3️⃣ DFS함수 정의</h3>
<p>우리가 구해야하는 것은 한 정점에서 다른 노드까지의 간선 개수입니다. 따라서 dfs의 매개변수인 v에는 인자를 a로하여 호출하면 됩니다. </p>
<pre><code class="language-python">def dfs(v):
    global count
    visited[v] = True
    if v == b:
        print(count)
        exit()
    for i in graph[v]:
        if not visited[i]:
            count += 1 
            dfs(i)
            count -= 1 #재귀 호출이 끝나면 깊이 감소

dfs(s)
print(-1)    </code></pre>
<p><code>dfs()</code>에서 <code>v == b</code>인 경우 <code>exit()</code>를 만나 코드가 종료된다는 것은 우리가 찾고자하는 b를 만나면 자동으로 종료된다는 의미입니다. <code>dfs()</code>에서 코드가 종료되지 못하면.. 즉, <span style="background-color:#FFE6E6">for문을 모두 반복해도 원하는 값을 만나지 못하면 <code>print(-1)</code>이 실행됩니다.</span></p>
<h2 id="📌-알고리즘-선택">📌 알고리즘 선택</h2>
<p><span style="background-color:#FFE6E6">한 점을 정점으로 선택하고 해당 점과 연결된 노드들을 모두 탐색</span>하면서 원하는 값을 찾아야하기 때문에 <span style="background-color:#FFE6E6">DFS를 활용</span>할 수 있겠네요.</p>
<h3 id="시간복잡도">시간복잡도</h3>
<p>dfs함수가 한 번 호출되면, 현재 노드에서 가능한 모든 인접한 노드를 재귀적으로 탐색합니다. 모든 노드와 간선을 한 번씩 방문하므로 시간복잡도는 <code>O(n+m)</code>입니다. n의 최대값은 1000이고, <span style="background-color:#FFE6E6">노드가 n개인 경우 부모-자식의 관계(간선의 수)는 n-1</span>이므로 최대 999개가 됩니다. 따라서 최악의 경우 연산은 약 1999회가 수행되므로 이는 수행 시간 내에 충분히 연산이 가능합니다.</p>
<hr>
<h2 id="📌-코드-설계하기">📌 코드 설계하기</h2>
<ol>
<li>전체 사람의 수(n), 촌수를 계산해야하는 서로 다른 두 사람의 번호, 부모 자식들간의 관계의 개수(m)을 Input받습니다.</li>
<li>부모 자식간의 관계를 나타내는 두 번호(x, y)를 입력받으면서 그래프를 구축합니다.</li>
<li>DFS를 수행하면서 간선의 개수를 카운트합니다.</li>
<li>원하는 값을 가진 노드를 찾으면 <code>count</code>를 출력하고, DFS를 모두 수행했음에도 원하는 값을 찾지 못하면 -1을 출력합니다.</li>
</ol>
<hr>
<h2 id="📌-정답-코드">📌 정답 코드</h2>
<pre><code class="language-python">import sys

count = 0

def dfs(v):
    global count
    visited[v] = True
    if v == b:
        print(count)
        exit()
    for i in graph[v]:
        if not visited[i]:
            count += 1 
            dfs(i)
            count -= 1 #재귀 호출이 끝나면 깊이 감소

n = int(sys.stdin.readline())
a, b = map(int, sys.stdin.readline().split())
m = int(sys.stdin.readline())

graph = [[] for _ in range(n+1)]  #그래프 리스트 초기화
visited = [False] * (n+1)

for _ in range(m):
    x, y = map(int, sys.stdin.readline().split())
    graph[x].append(y)
    graph[y].append(x)

dfs(a)
print(-1)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[python/백준] 11742. 연결 요소의 개수(S2)]]></title>
            <link>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-11742.-%EC%97%B0%EA%B2%B0-%EC%9A%94%EC%86%8C%EC%9D%98-%EA%B0%9C%EC%88%98S2</link>
            <guid>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-11742.-%EC%97%B0%EA%B2%B0-%EC%9A%94%EC%86%8C%EC%9D%98-%EA%B0%9C%EC%88%98S2</guid>
            <pubDate>Fri, 30 Aug 2024 14:56:51 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제-탐색하기">📌 문제 탐색하기</h2>
<p>👉 <a href="https://www.acmicpc.net/problem/11724">문제바로가기</a></p>
<blockquote>
<ul>
<li>N: 정점의 개수(1 ≤ N ≤ 1,000)</li>
</ul>
</blockquote>
<ul>
<li>M: 간선의 개수(0 ≤ M ≤ N×(N-1)/2)</li>
<li>u, v: 간선의 양 끝점(1 ≤ u, v ≤ N, u ≠ v) </li>
</ul>
<p>노드와 노드의 쌍이 주어진 경우 연결 요소의 개수를 구하는 문제입니다. </p>
<p>주어진 예제를 예시로 들어보겠습니다.
<img src="https://velog.velcdn.com/images/dob-by/post/5909ab79-80e0-4745-8be7-4434487bc862/image.png" alt=""></p>
<blockquote>
<ul>
<li>노드는 6개이며, 간선은 5개입니다.</li>
</ul>
</blockquote>
<ul>
<li>1과 2가 연결되어 있습니다.</li>
<li>2와 5가 연결되어 있습니다.</li>
<li>5와 1이 연결되어 있습니다.</li>
<li>3과 4가 연결되어 있습니다.</li>
<li>4와 5가 연결되어 있습니다.</li>
</ul>
<p>위 정보를 그림으로 그려보면 아래와 같습니다.
<img src="https://velog.velcdn.com/images/dob-by/post/3776f6c9-414f-4f4d-bc27-2d10ccab9004/image.png" width="550" height="50"/></p>
<p>그림에서 볼 수 있듯이 1-2-5가 연결되어 하나의 요소를 이루고, 6-4-3이 연결되어 하나의 요소를 이룹니다. </p>
<p>이 문제를 보자마자 몇일 전 풀어보았던 <a href="https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-10451.-%EC%88%9C%EC%97%B4-%EC%82%AC%EC%9D%B4%ED%81%B4S3">순열 사이클</a> 문제가 생각났습니다. (상당히 유사한 방법으로 풀 수 있겠네요ㅎㅎ)</p>
<p>요소의 개수를 구한다는 것은 <span style="background-color:#FFE6E6">한 점을 정점으로 선택하고 해당 점과 연결된 모든 노드들을 탐색해나가는 과정에서, <strong>선택된 정점의 개수를 count한다</strong>는 의미로 해석</span>하면 되겠네요.</p>
<p>결국 우리가 구해야 할 것은 <span style="color:red">정점의 개수</span>입니다!!</p>
<h3 id="1️⃣-입력-및-변수-정의">1️⃣ 입력 및 변수 정의</h3>
<p>먼저 정점의 개수(N)과 간선의 개수(M)를 공백을 사이에 두고 입력받습니다. 그래프 구축 전 그래프 리스트를 초기화하고 방문 여부를 담을 리스트도 정의합니다. </p>
<pre><code class="language-python">N, M = map(int, sys.stdin.readline().split())
graph = [[] for _ in range(N+1)]  #그래프 리스트 초기화
visited = [False] * (N+1)
count = 0</code></pre>
<h3 id="2️⃣-노드정보-저장-및-그래프-기록">2️⃣ 노드정보 저장 및 그래프 기록</h3>
<p>노드-노드 쌍을 공백을 사이에 두고 입력받은 후 인접리스트 방법으로 그래프에 저장합니다.</p>
<pre><code class="language-python">for _ in range(M):
    a, b = map(int, sys.stdin.readline().split())
    graph[a].append(b)
    graph[b].append(a)
</code></pre>
<h3 id="3️⃣-dfs함수-정의">3️⃣ DFS함수 정의</h3>
<pre><code class="language-python">def dfs(v):
    visited[v] = True
    for i in graph[v]:
        if not visited[i]:
            dfs(i)</code></pre>
<h3 id="4️⃣-연결-요소의-개수-구하기">4️⃣ 연결 요소의 개수 구하기</h3>
<p>1부터 시작해서 정점으로 잡고 연결된 모든 노드를 방문하며 <code>dfs()</code>를 수행합니다. 연결된 노드가 더이상 없을 경우 다른 정점을 찾아 <code>dfs()</code>를 다시 수행합니다. 정점을 하나 찾을 때 마다 count를 1씩 증가시킵니다.</p>
<pre><code class="language-python">for i in range(1, N+1):
    if not visited[i]:
        dfs(i)
        count += 1</code></pre>
<h2 id="📌-알고리즘-선택">📌 알고리즘 선택</h2>
<p><span style="background-color:#FFE6E6">한 점을 정점으로 선택하고 해당 점과 연결된 모든 노드들을 모두 탐색</span>하면 되기 때문에 <span style="background-color:#FFE6E6">DFS를 활용</span>할 수 있겠네요.</p>
<h3 id="시간복잡도">시간복잡도</h3>
<blockquote>
<ul>
<li>그래프 구축: <code>O(M)</code></li>
</ul>
</blockquote>
<ul>
<li>dfs탐색: <code>O(N+M)</code></li>
</ul>
<p>최악의 경우 DFS는 모든 노드와 간선을 정확히 한 번 방문합니다. 따라서 전체 시간복잡도는 <code><strong>O(N+M)</strong></code>입니다. N의 최댓값은 1000이고, M의 최댓값은 (1,000*999)/2이므로 약 500,000입니다. 따라서 연산은 최대 약 501,000번 수행되므로 주어진 시간 내에 충분히 가능한 연산입니다.</p>
<hr>
<h2 id="📌-코드-설계하기">📌 코드 설계하기</h2>
<ol>
<li>N, M값과 노드-노드 쌍을 입력받습니다.</li>
<li>노드 연결정보를 통해 그래프 리스트를 구축합니다.</li>
<li>DFS를 수행합니다.</li>
</ol>
<hr>
<h2 id="📌-정답-코드">📌 정답 코드</h2>
<pre><code class="language-python">import sys

sys.setrecursionlimit(10000)

def dfs(v):
    visited[v] = True
    for i in graph[v]:
        if not visited[i]:
            dfs(i)

N, M = map(int, sys.stdin.readline().split())
graph = [[] for _ in range(N+1)]  #그래프 리스트 초기화
visited = [False] * (N+1)
count = 0

for _ in range(M):
    a, b = map(int, sys.stdin.readline().split())
    graph[a].append(b)
    graph[b].append(a)

for i in range(1, N+1):
    if not visited[i]:
        dfs(i)
        count += 1

print(count)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[python/백준] 17204. 죽음의 게임(S3)]]></title>
            <link>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-17204.-%EC%A3%BD%EC%9D%8C%EC%9D%98-%EA%B2%8C%EC%9E%84S3</link>
            <guid>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-17204.-%EC%A3%BD%EC%9D%8C%EC%9D%98-%EA%B2%8C%EC%9E%84S3</guid>
            <pubDate>Fri, 30 Aug 2024 11:42:27 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제-탐색하기">📌 문제 탐색하기</h2>
<p>👉 <a href="https://www.acmicpc.net/problem/17204">문제바로가기</a></p>
<blockquote>
</blockquote>
<p>N: 게임에 참여하는 사람의 수(3 ≤ N ≤ 150)
K: 보성이의 번호(1 ≤ K ≤ N - 1)
a<small>i</small>: i번 사람이 지목하는 사람의 번호(0 ≤ i ≤ N - 1, 0 ≤ a<small>i</small> ≤ N - 1)</p>
<p>지목하는 사람의 번호를 입력받아 차례대로 다음 노드로 이동하여 보성이의 번호(K)를 찾는 문제입니다. 어떤 방법으로도 K를 찾지 못한다면 -1을 출력하면됩니다.</p>
<h2 id="📌-알고리즘-선택">📌 알고리즘 선택</h2>
<p><code>방법1</code>: 단순히 지목하는 번호를 리스트에 담고, 그 번호를 가진 사람이 지목하는 번호를 뽑아 K와 일치하는지 확인하면서 몇 번 불렀는지 카운트하면 됩니다.</p>
<p><code>방법2</code>: M번만에 보성이에게 도달하는 최단 경로를 찾는 문제이므로 bfs를 활용할 수도 있겠네요.</p>
<h3 id="시간복잡도">시간복잡도</h3>
<p>1번 방법에서 최악의 경우 시간복잡도는 <code><strong>O(N)</strong></code>입니다. N의 최댓값은 150이므로 <code>O(N)</code>의 시간복잡도를 가진 알고리즘으로도 충분히 시간 내에 연산이 가능합니다.</p>
<hr>
<h2 id="📌-코드-설계하기">📌 코드 설계하기</h2>
<ol>
<li>N, K를 공백을 두고 Input받습니다.</li>
<li>N줄에 걸쳐 각각 사람이 지목하는 사람의 번호를 Input받습니다.</li>
<li>0부터 시작해서 해당 번호의 사람이 지목한 번호의 인덱스로 가서 다음 사람의 번호를 구합니다. 지목할때마다 count를 1씩 증가시키면서 K를 찾으면 count를 출력 후 반복문을 종료시키고, N번만큼 반복하여도 K를 찾지 못하면 종료하고 -1을 출력합니다.</li>
</ol>
<hr>
<h2 id="📌-정답-코드">📌 정답 코드</h2>
<pre><code class="language-python">import sys

N, K = map(int, sys.stdin.readline().split())
numbers = [int(sys.stdin.readline()) for _ in range(N)]
count = 0
number = 0

for _ in range(N):
    if number != K:
        number = numbers[number]
        count += 1
    else:
        print(count)
        break
else:
    print(-1)            </code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[python/백준] 10451. 순열 사이클(S3)]]></title>
            <link>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-10451.-%EC%88%9C%EC%97%B4-%EC%82%AC%EC%9D%B4%ED%81%B4S3</link>
            <guid>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-10451.-%EC%88%9C%EC%97%B4-%EC%82%AC%EC%9D%B4%ED%81%B4S3</guid>
            <pubDate>Thu, 29 Aug 2024 03:27:05 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제-탐색하기">📌 문제 탐색하기</h2>
<p>👉 <a href="https://www.acmicpc.net/problem/10451">문제바로가기</a></p>
<blockquote>
<h4 id="입력">입력</h4>
<p>T: 테스트 케이스의 개수
N: 순열의 크기(2 ≤ N ≤ 1,000)
순열: 각 정수는 공백으로 구분</p>
</blockquote>
<p>순열의 크기를 지정하고 순열이 주어진 경우 순열 사이클의 개수를 구하는 문제입니다. <strong>한 숫자를 지정했을 경우 해당 숫자와 연결된 모든 노드를 탐색해야 합니다. </strong></p>
<p>우리가 구해야 하는 것은 사이클의 수이며 사이클마다 정점은 하나씩 존재합니다. <span style="background-color:#FFE6E6">결론적으로 우리는 <span style="color:red">정점의 갯수</span>를 구해주면 됩니다!!</span></p>
<p>문제에서 주어진 순열을 예시로 들어보면, 8개의 순열 (3, 2, 7, 8, 1, 4, 5, 6)이 있을 경우 배열을 이용해 다음과 같이 표현할 수 있습니다.</p>
<p align="center"><img src="https://velog.velcdn.com/images/dob-by/post/abdb23a1-fbfa-4c6e-878b-1e5698d05730/image.png" width="500" height="50"/><p/>

<blockquote>
<p>1) 1 &rarr; 3, 3 &rarr; 7, 7 &rarr; 5, 5 &rarr; 1
2) 2 &rarr; 2
3) 4 &rarr; 8, 8 &rarr; 6, 6 &rarr; 4</p>
</blockquote>
<p>위 순서를 참고하여 그림으로 나타내면 다음과 같습니다.</p>
<p align="center"><img src="https://velog.velcdn.com/images/dob-by/post/e7fbebf4-0c7c-4c08-9c25-5ac325261f99/image.png" width="300" height="50"/><p/>

<p><span style="background-color:#FFE6E6">결국 1~8까지의 배열과 순열 (3, 2, 7, 8, 1, 4, 5, 6)를 하나씩 매치한 화살표가 <strong>노드와 노드의 연결을 의미</strong>합니다. </span></p>
<p>노드와 노드의 연결쌍을 알았으면 이전에 풀었던 문제들처럼 그래프를 기록할 수 있습니다.</p>
<h3 id="1️⃣-입력-및-변수-정의">1️⃣ 입력 및 변수 정의</h3>
<p>먼저 테스트 케이스의 개수(T)와 순열의 크기(N)를 입력받습니다.</p>
<pre><code class="language-python">T = int(sys.stdin.readline())   #테스트 케이스 수
N = int(sys.stdin.readline())   #순열의 크기</code></pre>
<p>이후 순열의 크기만큼 순열을 입력받고 방문 횟수여부를 담은 리스트<code>visited</code>를 정의합니다. </p>
<pre><code class="language-python">    arr2 = list(map(int, sys.stdin.readline().split()))   #순열
    visited = [False] * (N+1)
    count = 0   #정점의 수</code></pre>
<p><code>arr1</code>은 순열과 매치하기 위한 1~8까지의 숫자 리스트이기 때문에 반복문을 이용하여 1부터 순열의 크기만큼 숫자를 append해줍니다.</p>
<pre><code class="language-python">arr1 = []

for i in range(1, N+1):
    arr1.append(i)</code></pre>
<h3 id="2️⃣-노드정보-저장-및-그래프-기록">2️⃣ 노드정보 저장 및 그래프 기록</h3>
<p>1~8까지를 저장한 리스트 <code>arr1</code>과 8개의 수로 이루어진 순열을 저장한 리스트 <code>arr2</code>를 활용하여 노드와 노드의 연결 정보를 리스트<code>newArray</code>에 저장합니다.</p>
<pre><code class="language-python">newArray = [[] for _ in range(N)]

for i in range(N):
    newArray[i].append(arr1[i])
    newArray[i].append(arr2[i])</code></pre>
<p>저장된 노드 정보를 활용하여 인접리스트를 활용해 리스트<code>graph</code>에 기록합니다. 이때 해당 리스트의 중복을 제거하고, 작은 수부터 시작하도록 정렬하기 위해 <code>sort()</code>를 사용합니다.</p>
<pre><code class="language-python">graph = [[] for _ in range(N+1)]

for i in range(N):
    graph[arr1[i]].append(arr2[i])
    graph[arr2[i]].append(arr1[i])

#graph의 각 요소들을 정렬
for i in range(N+1):
    graph[i] = list(set(graph[i]))   #중복제거
    graph[i].sort()    </code></pre>
<h3 id="3️⃣-dfs함수-정의">3️⃣ DFS함수 정의</h3>
<pre><code class="language-python">def dfs(start):
    visited[start] = True
    #현재 노드와 연결된 다른 노드를 재귀적으로 방문
    for i in graph[start]:
        if not visited[i]:
            dfs(i)</code></pre>
<h3 id="4️⃣-순열-사이클-개수-구하기">4️⃣ 순열 사이클 개수 구하기</h3>
<p>1부터 시작해서 아직 방문하지 않은 연결된 노드들을 방문합니다. 모두 탐색했으면 방문하지 않은 다른 숫자를 정점으로해서 반복합니다. <span style="background-color:#FFE6E6">정점이 하나 정해질때마다 count하여 정점의 개수를 구하고 출력합니다.</span></p>
<pre><code class="language-python">for i in range(1, N+1):
    if not visited[i]:
        dfs(i)
        count += 1

print(count)        </code></pre>
<h2 id="📌-알고리즘-선택">📌 알고리즘 선택</h2>
<p>한 시작점이 정해지면 <span style="background-color:#FFE6E6">해당 노드와 연결된 노드를 끝까지 다 탐색</span>해야 하므로 DFS를 활용하여 풀 수 있습니다.</p>
<h3 id="시간복잡도">시간복잡도</h3>
<blockquote>
<ul>
<li>그래프 구축: <code>O(N)</code></li>
</ul>
</blockquote>
<ul>
<li>중복 제거 및 <code>sort()</code>를 사용하여 정렬: <code>O(NlogN)</code></li>
<li>dfs탐색: <code>O(N+E)</code></li>
</ul>
<p>최악의 경우 시간복잡도를 구하기 위해 N의 최대값이 1000인 경우, 모든 노드가 다른 모든 노드와 연결된 경우를 고려합니다. 이경우 E는 최대 N이며 결론적으로 <code>E=N=1000</code>입니다. 따라서 전체 시간복잡도는 <code>O(N)+O(NlogN)+O(N)=O(NlogN)=O(1000*log1000)</code>입니다. </p>
<hr>
<h2 id="📌-코드-설계하기">📌 코드 설계하기</h2>
<ol>
<li>테스트 케이스의 개수(T)와 순열의 크기(N)를 Input받습니다.</li>
<li>노드의 정보를 저장하고 해당 정보를 활용해 그래프를 기록합니다.</li>
<li>DFS를 수행하면서 정점의 개수를 구하고 출력합니다. </li>
</ol>
<hr>
<h2 id="📌-정답-코드">📌 정답 코드</h2>
<pre><code class="language-python">import sys

def dfs(start):
    visited[start] = True
    #현재 노드와 연결된 다른 노드를 재귀적으로 방문
    for i in graph[start]:
        if not visited[i]:
            dfs(i)

T = int(sys.stdin.readline())   #테스트 케이스 수

for _ in range(T):
    N = int(sys.stdin.readline())   #순열의 크기

    arr1 = []   
    arr2 = list(map(int, sys.stdin.readline().split()))   #순열
    visited = [False] * (N+1)
    count = 0   #정점의 수

    for i in range(1, N+1):
        arr1.append(i)

    newArray = [[] for _ in range(N)]

    for i in range(N):
        newArray[i].append(arr1[i])
        newArray[i].append(arr2[i])

    graph = [[] for _ in range(N+1)]

    for i in range(N):
        graph[arr1[i]].append(arr2[i])
        graph[arr2[i]].append(arr1[i])

    #graph의 각 요소들을 정렬
    for i in range(N+1):
        graph[i] = list(set(graph[i]))   #중복제거
        graph[i].sort()    

    for i in range(1, N+1):
        if not visited[i]:
            dfs(i)
            count += 1

    print(count)            </code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[python/백준] 1012. 유기농 배추(S2)]]></title>
            <link>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-1012.-%EC%9C%A0%EA%B8%B0%EB%86%8D-%EB%B0%B0%EC%B6%94-S2</link>
            <guid>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-1012.-%EC%9C%A0%EA%B8%B0%EB%86%8D-%EB%B0%B0%EC%B6%94-S2</guid>
            <pubDate>Tue, 27 Aug 2024 14:51:06 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제-탐색하기">📌 문제 탐색하기</h2>
<p>👉 <a href="https://www.acmicpc.net/problem/1012">문제바로가기</a></p>
<blockquote>
<p>T: 테스트 케이스 개수
M: 배추밭의 가로 길이(1 ≤ M ≤ 50)
N: 배추밭의 세로 길이(1 ≤ N ≤ 50)
K: 배추가 심어져 있는 위치의 개수(1 ≤ K ≤ 2500)
K줄에 배추의 위치X(0 ≤ X ≤ M-1), Y(0 ≤ Y ≤ N-1)가 주어진다. </p>
</blockquote>
<p>배추가 심어져 있는 위치를 입력받고, 땅의 처음부터 끝까지 탐색하면서 정점의 개수를 찾는 문제입니다. 한 정점에 대해 탐색을 수행하면서 값이 1인 곳(배추가 심어져 있는 곳)의 방문 여부를 True로 변경시켜주면 됩니다.</p>
<h3 id="1️⃣-입력-및-변수-정의">1️⃣ 입력 및 변수 정의</h3>
<pre><code class="language-python">M, N, K = map(int, sys.stdin.readline().split())
    arr = [[0] * M for _ in range(N)]  # 배추의 위치
    visited = [[False] * M for _ in range(N)]
    count = 0  # 정점의 갯수</code></pre>
<h3 id="2️⃣-배추의-위치-설정">2️⃣ 배추의 위치 설정</h3>
<pre><code class="language-python">for _ in range(K):
    X, Y = map(int, sys.stdin.readline().split())
    arr[Y][X] = 1</code></pre>
<h3 id="3️⃣-위치-탐색">3️⃣ 위치 탐색</h3>
<pre><code class="language-python">for i in range(N):
        for j in range(M):
            if not visited[i][j] and arr[i][j] == 1:
                count += 1
                dfs(i, j) </code></pre>
<p>땅의 처음부터 끝까지 모두 탐색하면서 dfs()를 호출합니다. 한 번의 정점을 기준으로 탐색하고, 해당 탐색이 끝나면 다음 칸을 정점으로 잡고 다시 탐색합니다. 하나의 정점에 대해 탐색이 끝난 경우 배추흰지렁이는 더이상 이동할 수 있는 칸이 없으므로 1마리를 추가합니다. <span style="color:red">정점의 갯수가 우리가 구하고자하는 배추흰지렁이의 수가 되는 것입니다.</span></p>
<h3 id="4️⃣-dfs">4️⃣ DFS</h3>
<pre><code class="language-python">def dfs(i, j): 
    visited[i][j] = True
    # 아래쪽 탐색
    if i + 1 &lt; N and not visited[i + 1][j] and arr[i + 1][j] == 1:
        dfs(i + 1, j)
    # 위쪽 탐색
    if i - 1 &gt;= 0 and not visited[i - 1][j] and arr[i - 1][j] == 1:
        dfs(i - 1, j)
    # 오른쪽 탐색
    if j + 1 &lt; M and not visited[i][j + 1] and arr[i][j + 1] == 1:
        dfs(i, j + 1)
    # 왼쪽 탐색
    if j - 1 &gt;= 0 and not visited[i][j - 1] and arr[i][j - 1] == 1:
        dfs(i, j - 1)
    else:
        return    </code></pre>
<p>한 정점을 기준으로 사방(오른쪽, 왼쪽, 위쪽, 아래쪽)에 배추가 더 있는지 모두 탐색해야합니다. 배추가 더 있는 경우 탐색을 이어서 더 진행해야하기 때문에 재귀함수를 사용합니다.</p>
<hr>
<h2 id="📌-알고리즘-선택">📌 알고리즘 선택</h2>
<p>한 정점과 인접한 가능한 모든 노드를 탐색해야 하므로 DFS를 사용하였습니다.</p>
<h3 id="시간복잡도">시간복잡도</h3>
<p>for루프가 중첩되어있는 부분에서 전체 탐색에 대한 시간복잡도는 <code>O(N*M)</code>이며, 각 위치에 대한 DFS의 시간복잡도는 <code>O(N*M)</code>입니다. 따라서 전체 시간복잡도는 <code><strong>O(N*M)</strong></code>입니다. N, M의 최댓값은 50이므로 약 2500회의 연산이 수행됩니다. 이는 주어진 제한시간 1초 안에 충분히 가능한 연산입니다.</p>
<hr>
<h2 id="📌-코드-설계하기">📌 코드 설계하기</h2>
<ol>
<li>T, M, N, K를 Input받고 배추가 심어져있는 위치는 K줄에 Input받습니다.</li>
<li>입력받은 배추의 위치 좌표에 1을 대입하여 리스트값을 변경합니다.</li>
<li>땅 위치의 처음부터 끝까지 탐색하며 정점의 갯수를 구합니다.</li>
</ol>
<hr>
<h2 id="📌-시도회차-수정사항">📌 시도회차 수정사항</h2>
<h3 id="1회차">1회차</h3>
<p>테스트 케이스 개수를 고려하지 않았습니다.</p>
<h3 id="2회차-런타임-에러">2회차: 런타임 에러</h3>
<h4 id="런타임-에러란">런타임 에러란?</h4>
<ul>
<li>프로그램 실행 도중 비정상적으로 종료되는 것<h4 id="런타임-에러의-이유">런타임 에러의 이유</h4>
런타임 에러의 이유는 다양하지만.. 제가 작성한 코드에서의 런타임 에러 이유는 다음과 같습니다.<blockquote>
<ul>
<li>백준 채점 시스템에서 최대 재귀 깊이를 디폴트 값으로 1000으로 정해놓았습니다.</li>
</ul>
</blockquote>
</li>
<li><span style="color:red">런타임 에러는 그 최대 깊이를 초과하여 재귀 호출을 하기 때문에 발생</span>하는 것입니다.</li>
</ul>
<h4 id="해결방법">해결방법</h4>
<pre><code class="language-python">import sys
sys.setrecursionlimit(10000)</code></pre>
<p>위 코드를 사용하여 최대 재귀 깊이를 늘려주었습니다.</p>
<hr>
<h2 id="📌-정답-코드">📌 정답 코드</h2>
<pre><code class="language-python">import sys

sys.setrecursionlimit(10000)

def dfs(i, j): 
    visited[i][j] = True
    # 아래쪽 탐색
    if i + 1 &lt; N and not visited[i + 1][j] and arr[i + 1][j] == 1:
        dfs(i + 1, j)
    # 위쪽 탐색
    if i - 1 &gt;= 0 and not visited[i - 1][j] and arr[i - 1][j] == 1:
        dfs(i - 1, j)
    # 오른쪽 탐색
    if j + 1 &lt; M and not visited[i][j + 1] and arr[i][j + 1] == 1:
        dfs(i, j + 1)
    # 왼쪽 탐색
    if j - 1 &gt;= 0 and not visited[i][j - 1] and arr[i][j - 1] == 1:
        dfs(i, j - 1)
    else:
        return    

T = int(sys.stdin.readline())

for _ in range(T):
    M, N, K = map(int, sys.stdin.readline().split())
    arr = [[0] * M for _ in range(N)]  # 배추의 위치
    visited = [[False] * M for _ in range(N)]
    count = 0  # 정점의 갯수

    # 배추의 위치 입력
    for _ in range(K):
        X, Y = map(int, sys.stdin.readline().split())
        arr[Y][X] = 1

    # 모든 위치를 탐색하며 DFS 호출
    for i in range(N):
        for j in range(M):
            if not visited[i][j] and arr[i][j] == 1:
                count += 1
                dfs(i, j)     
    print(count)</code></pre>
<hr>
<h2 id="📌-다른-풀이">📌 다른 풀이</h2>
<p>아래 코드는 BFS를 활용한 코드입니다. (출처: <code>@why_dev_says_no</code>)</p>
<pre><code class="language-python">import sys

T = int(sys.stdin.readline())
dx = [-1, 1, 0, 0]
dy = [0, 0, -1, 1]


# 2. 한 노드에서 BFS를 탐색하는 과정을 구현합니다.
def bfs(x, y):
    queue = [(x, y)]
    global board
    board[x][y] = 0

    while queue:
        x, y = queue.pop()
        for i in range(4):
            nx = x + dx[i]
            ny = y + dy[i]

            if nx &lt; 0 or nx &gt;= M or ny &lt; 0 or ny &gt;= N:
                continue
            if board[nx][ny] == 1:
                board[nx][ny] = 0
                queue.append((nx, ny))


# 1. 문제의 input을 받습니다.
for _ in range(T):
    M, N, K = map(int, sys.stdin.readline().split())
    board = [[0 for _ in range(N)] for _ in range(M)]
    for _ in range(K):
        x, y = map(int, sys.stdin.readline().split())
        board[x][y] = 1

    ans = 0
    # 3. 문제의 배열에 대해 BFS 탐색을 진행하며 지렁이의 개수를 구합니다.
    for x in range(M):
        for y in range(N):
            if board[x][y] == 1:
                bfs(x, y)
                ans += 1
    print(ans)
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[python/백준] 1260. DFS와 BFS(S2)]]></title>
            <link>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-1260.-DFS%EC%99%80-BFS</link>
            <guid>https://velog.io/@dob-by/python%EB%B0%B1%EC%A4%80-1260.-DFS%EC%99%80-BFS</guid>
            <pubDate>Tue, 27 Aug 2024 10:23:26 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-문제-탐색하기">📌 문제 탐색하기</h2>
<p>👉 <a href="https://www.acmicpc.net/problem/1260">문제바로가기</a>
N: 정점 개수(1&lt;=N&lt;=1,000)
M: 간선 개수(1&lt;=M&lt;=10,000)
V: 탐색을 시작할 정점의 번호</p>
<p>간선이 연결하는 두 정점의 번호가 주어지면 그래프를 정의하고, V를 정점으로하여 DFS와 BFS를 수행한 결과를 출력하는 문제입니다.</p>
<p>아래는 문제에서 주어진 예제 입력과 출력을 예시로 들어 설명한 것입니다.</p>
<h3 id="1️⃣-입력-받기">1️⃣ 입력 받기</h3>
<pre><code class="language-python">N, M, V = map(int, sys.stdin.readline().split())</code></pre>
<h3 id="2️⃣-그래프-리스트-정의하기">2️⃣ 그래프 리스트 정의하기</h3>
<p>그래프에 원소를 넣기 전에 그래프의 크기를 지정합니다. 그래프의 각 요소가 해당 원소의 인덱스번호 숫자와 연결된 숫자들을 담도록 하기 위한 것이므로, 그래프는 N+1개의 원소를 가집니다. 여기서 N개가 아닌 이유는 0자체가 없을뿐만아니라 0과 연결된 숫자도 없기 때문에 이 숫자를 제외하고 총 N개의 원소가 되기 위해 크기를 N+1로 지정하는 것입니다.</p>
<p>그리고 방문 여부는 초기에 모두 Flase인 상태로 지정합니다. 추후 방문할 때마다 True로 값을 변경하게됩니다.</p>
<pre><code class="language-python">graph = [[] for _ in range(N+1)]
visited = [False] * (N+1)</code></pre>
<p>Input받은 정보는 간선이 연결하는 두 정점의 정보입니다. 해당 정보를 통해 우리는 그래프를 정의할 수 있습니다. 그래프는 인접리스트 형태로 구성하였습니다.
<img src="https://velog.velcdn.com/images/dob-by/post/bc39c295-8471-445a-956b-50051770ce66/image.png" alt=""></p>
<blockquote>
<ul>
<li>1은 2와 연결되어 있습니다.</li>
</ul>
</blockquote>
<ul>
<li>1은 3과 연결되어 있습니다.</li>
<li>1은 4와 연결되어 있습니다.</li>
<li>2는 4와 연결되어 있습니다.</li>
<li>3은 4와 연결되어 있습니다.</li>
</ul>
<p>아래는 인접한 숫자가 무엇인지 한 눈에 볼 수 있는 리스트로 정의한 것이라고 보면 됩니다.</p>
<pre><code class="language-python">#각 정점들별로 가지고 있는 간선 정보 정렬
graph = [[], [2, 3, 4], [1, 4], [1, 4], [1, 2, 3]]</code></pre>
<p>그래프를 기록할 때 인접 행렬 / 인접 리스트 두 가지 방법이 있습니다. 모두 가능하지만 <span style="background-color:#FFE6E6">입력 편의성과 탐색 속도를 줄이기위해</span> 인접리스트를 사용했습니다.</p>
<blockquote>
</blockquote>
<p>🍯 TIP ! 
DFS / BFS 모두 </p>
<ul>
<li>인접 리스트로 탐색하면 O(V+E)</li>
<li>인접 행렬로 탐색하면 O(V^2)
이 문제에서 V(정점)은 1,000개, E(간선)은 10,000개로 인접리스트가 유리합니다.
<span style="background-color:#FFE6E6">간선의 개수가 많으면 인접행렬이 유리하고, 간선의 개수가 적으면 인접 리스트가 유리합니다.</span> </li>
</ul>
<h3 id="3️⃣-dfs--bfs-함수-구현">3️⃣ DFS / BFS 함수 구현</h3>
<p>👉 DFS와 BFS함수 구현과 관련된 자세한 내용 <a href="https://velog.io/@dob-by/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-DFS%EC%99%80-BFS%EC%9D%98-%EC%9D%B4%ED%95%B4">참고</a></p>
<h4 id="dfs">DFS</h4>
<pre><code class="language-python">def dfs(start):
    visited[start] = True
    print(start, end=&quot; &quot;)
    #현재 노드와 연결된 다른 노드를 재귀적으로 방문
    for i in graph[start]:
        if not visited[i]:
            dfs(i)</code></pre>
<h4 id="bfs">BFS</h4>
<p>한 노드에 대해서 <span style="background-color:#FFE6E6">연결된 “같은 깊이의” 노드들을 먼저 탐색</span>합니다. 탐색 중인 노드와 <span style="background-color:#FFE6E6">연결된 노드들은 visited=True로 체크</span>하고, <span style="background-color:#FFE6E6">다음 깊이 탐색을 위해 queue에 넣어둡니다.</span></p>
<p>(연결된 노드와 연결된 다음 노드를 바로 탐색하는 dfs와 달리, bfs는 현재 노드와 연결된 노드들을 먼저 모두 탐색합니다)</p>
<p>이 과정을 queue가 empty가 될 때 까지 반복합니다.</p>
<pre><code class="language-python">def bfs(start):
    queue = deque([start])
    visited[start] = True
    #큐가 빌 때까지 반복
    while queue:
        x = queue.popleft()
        print(x, end=&#39; &#39;)
        #해당 원소와 연결된, 아직 방문하지 않은 원소들을 큐에 삽입
        for i in graph[x]:
            if not visited[i]:
                queue.append(i)
                visited[i] = True</code></pre>
<h3 id="4️⃣-dfs---bfs-결과-출력">4️⃣ DFS -&gt; BFS 결과 출력</h3>
<p><code>dfs()</code>를 완료하면 <code>visited</code>리스트의 모든 원소 값이 True가 됩니다. 따라서 <code>dfs()</code>를 실행하기 전에 <code>visited</code>값을 한번 더 초기화시켜줍니다.</p>
<hr>
<h2 id="📌-알고리즘-선택">📌 알고리즘 선택</h2>
<p>문제에서 직접적으로 DFS와 BFS를 각각 활용하라고 주어졌으므로 해당 알고리즘을 사용하면 됩니다. </p>
<h3 id="시간복잡도">시간복잡도</h3>
<p>정렬 부분에서 <code>sort()</code>를 사용하여 정렬하므로 시간복잡도는 <code>O(MlongM)</code>입니다. DFS와 BFS알고리즘 각각은 각 노드를 최대한 한 번씩 모두 방문하고, 각 간선을 최대 한 번씩 탐색하므로 시간복잡도는 <code>O(N+M)</code>입니다. 전체 시간복잡도에 가장 영향을 많이 주는 것은 <code>sort()</code>이고, 전체 시간 복잡도는 <code>O(MlogM+N+M)</code>입니다. </p>
<p>최악의 경우 모든 정점 N개에 대해 <strong><code>O(MlogM)</code></strong>만큼의 시간복잡도가 걸리므로, 최종 시간복잡도는 O(NMlogM)입니다. 총 연산은 약 40,000,000번 필요하고 이는 시간안에 가능한 연산 개수입니다.</p>
<hr>
<h2 id="📌-코드-설계하기">📌 코드 설계하기</h2>
<ol>
<li>N(정점 개수), M(간선 개수), V(탐색을 시작할 정점의 번호)를 Input받습니다.</li>
<li>그래프 리스트와 방문 여부를 초기화합니다.</li>
<li>간선이 연결하는 두 정점의 정보들을 입력받은 후 그래프 리스트에 값을 정의합니다.</li>
<li><code>dsf()</code>와 <code>bfs()</code>를 구현한 후 해당 알고리즘을 실행한 결과를 출력합니다.</li>
</ol>
<hr>
<h2 id="📌-정답-코드">📌 정답 코드</h2>
<pre><code class="language-python">import sys
from collections import deque

def dfs(start):
    visited[start] = True
    print(start, end=&quot; &quot;)
    #현재 노드와 연결된 다른 노드를 재귀적으로 방문
    for i in graph[start]:
        if not visited[i]:
            dfs(i)

def bfs(start):
    queue = deque([start])
    visited[start] = True
    #큐가 빌 때까지 반복
    while queue:
        x = queue.popleft()
        print(x, end=&#39; &#39;)
        #해당 원소와 연결된, 아직 방문하지 않은 원소들을 큐에 삽입
        for i in graph[x]:
            if not visited[i]:
                queue.append(i)
                visited[i] = True

N, M, V = map(int, sys.stdin.readline().split())
graph = [[] for _ in range(N+1)]
visited = [False] * (N+1)

for _ in range(M):
    a, b = map(int, sys.stdin.readline().split())
    graph[a].append(b)
    graph[b].append(a)

#graph의 각 요소들을 정렬
for i in range(N+1):
    graph[i].sort()  

dfs(V)
print()
#방문여부 초기화
visited = [False] * (N+1)
bfs(V)</code></pre>
]]></description>
        </item>
    </channel>
</rss>