<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발 공부중</title>
    <link>https://mmee2.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Wed, 12 Aug 2026 09:12:31 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>개발자 leelee</managingEditor>
    <image>
      <title>개발 공부중</title>
      <url>https://tistory1.daumcdn.net/tistory/5513337/attach/736e058d54eb4caea79c7dce76217611</url>
      <link>https://mmee2.tistory.com</link>
    </image>
    <item>
      <title>VS Code에서 Codex를 쓰다가 보관한 채팅 다시 복원하는 방법</title>
      <link>https://mmee2.tistory.com/145</link>
      <description>&lt;p data-end=&quot;153&quot; data-start=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20260810_224134105.jpg&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;473&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTLQR9/dJMcagfrwB7/DVhpKuTrJkPWcGIUw7teM1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTLQR9/dJMcagfrwB7/DVhpKuTrJkPWcGIUw7teM1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTLQR9/dJMcagfrwB7/DVhpKuTrJkPWcGIUw7teM1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTLQR9%2FdJMcagfrwB7%2FDVhpKuTrJkPWcGIUw7teM1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1206&quot; height=&quot;473&quot; data-filename=&quot;KakaoTalk_20260810_224134105.jpg&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;473&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;153&quot; data-start=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;VS Code에서 Codex를 사용하다 보면 채팅 목록에서 &lt;b&gt;보관(Archive)&lt;/b&gt; 버튼을 누를 때가 있다.&lt;/p&gt;
&lt;p data-end=&quot;273&quot; data-start=&quot;155&quot; data-ke-size=&quot;size16&quot;&gt;문제는 보관 버튼을 누르는 순간 해당 대화가 일반 채팅 목록에서 사라지는데,&lt;/p&gt;
&lt;p data-end=&quot;273&quot; data-start=&quot;155&quot; data-ke-size=&quot;size16&quot;&gt;VS Code Codex에서는 &lt;b&gt;보관된 채팅 목록을 화면에서 바로 확인하거나 복원하는 메뉴를 찾기 어려웠다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;273&quot; data-start=&quot;155&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;323&quot; data-start=&quot;275&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 대화가 삭제된 줄 알았지만 확인해보니 실제 채팅 데이터는 그대로 남아 있었다.&lt;/p&gt;
&lt;p data-end=&quot;375&quot; data-start=&quot;325&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서는 직접 해본 &lt;b&gt;Codex 보관 채팅 복원 방법&lt;/b&gt;을 정리했다.&lt;/p&gt;
&lt;hr data-end=&quot;380&quot; data-start=&quot;377&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;407&quot; data-start=&quot;382&quot; data-section-id=&quot;147ezm0&quot; data-ke-size=&quot;size26&quot;&gt;1. Codex 채팅은 어디에 저장될까?&lt;/h2&gt;
&lt;p data-end=&quot;465&quot; data-start=&quot;409&quot; data-ke-size=&quot;size16&quot;&gt;Windows 환경에서 Codex 관련 데이터는 사용자 폴더 아래의 .codex 폴더에 저장된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;taggerscript&quot;&gt;&lt;code&gt;C:\Users\사용자명\.codex&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;521&quot; data-start=&quot;501&quot; data-ke-size=&quot;size16&quot;&gt;내 환경에서는 다음과 같은 구조였다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;C:\Users\user\.codex
├─ sessions
├─ archived_sessions
├─ state_5.sqlite
└─ ...&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;629&quot; data-start=&quot;612&quot; data-ke-size=&quot;size16&quot;&gt;여기서 중요한 폴더는 두 개다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;sessions&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;681&quot; data-start=&quot;653&quot; data-ke-size=&quot;size16&quot;&gt;현재 사용 중인 일반 채팅 세션이 저장되는 곳이다.&lt;/p&gt;
&lt;p data-end=&quot;686&quot; data-start=&quot;683&quot; data-ke-size=&quot;size16&quot;&gt;반면:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;archived_sessions&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;758&quot; data-start=&quot;719&quot; data-ke-size=&quot;size16&quot;&gt;는 &lt;b&gt;보관 처리한 채팅의 .jsonl 파일이 이동하는 곳&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr data-end=&quot;763&quot; data-start=&quot;760&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;789&quot; data-start=&quot;765&quot; data-section-id=&quot;ywptmt&quot; data-ke-size=&quot;size26&quot;&gt;2. 보관 버튼을 누르면 어떻게 될까?&lt;/h2&gt;
&lt;p data-end=&quot;838&quot; data-start=&quot;791&quot; data-ke-size=&quot;size16&quot;&gt;Codex 채팅 우측의 보관 버튼을 누르면 해당 채팅은 일반 채팅 목록에서 사라진다.&lt;/p&gt;
&lt;p data-end=&quot;858&quot; data-start=&quot;840&quot; data-ke-size=&quot;size16&quot;&gt;실제 채팅 파일은 삭제되지 않고:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;C:\Users\user\.codex\archived_sessions&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;916&quot; data-start=&quot;909&quot; data-ke-size=&quot;size16&quot;&gt;로 이동했다.&lt;/p&gt;
&lt;p data-end=&quot;920&quot; data-start=&quot;918&quot; data-ke-size=&quot;size16&quot;&gt;즉:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;일반 채팅
.codex\sessions
        &amp;darr;
     보관
        &amp;darr;
.codex\archived_sessions&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1014&quot; data-start=&quot;1010&quot; data-ke-size=&quot;size16&quot;&gt;형태다.&lt;/p&gt;
&lt;p data-end=&quot;1040&quot; data-start=&quot;1016&quot; data-ke-size=&quot;size16&quot;&gt;하지만 여기서 한 가지 중요한 점이 있었다.&lt;/p&gt;
&lt;p data-end=&quot;1096&quot; data-start=&quot;1042&quot; data-ke-size=&quot;size16&quot;&gt;Codex는 단순히 .jsonl 파일 위치만 보고 채팅의 보관 여부를 판단하는 것이 아니었다.&lt;/p&gt;
&lt;hr data-end=&quot;1101&quot; data-start=&quot;1098&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1140&quot; data-start=&quot;1103&quot; data-section-id=&quot;bcf62h&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3. .jsonl 파일만 sessions로 옮기면 안됨&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;1169&quot; data-start=&quot;1142&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 단순하게 보관된 .jsonl 파일을:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;archived_sessions&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1205&quot; data-start=&quot;1202&quot; data-ke-size=&quot;size16&quot;&gt;에서:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;sessions\2026\08\07&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1250&quot; data-start=&quot;1240&quot; data-ke-size=&quot;size16&quot;&gt;로 직접 옮기면 나올 거라고 생각했다.&lt;/p&gt;
&lt;p data-end=&quot;1250&quot; data-start=&quot;1240&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1287&quot; data-start=&quot;1252&quot; data-ke-size=&quot;size16&quot;&gt;그런데 VS Code를 재실행해도 채팅 목록에 나타나지 않았다.&lt;/p&gt;
&lt;p data-end=&quot;1335&quot; data-start=&quot;1289&quot; data-ke-size=&quot;size16&quot;&gt;원인을 확인해보니 Codex에서는 대략 다음 두 가지 정보가 같이 관리되고 있었다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 실제 대화 내용
   &amp;rarr; .jsonl 파일

2. 채팅의 상태 정보
   &amp;rarr; state_5.sqlite 등의 내부 상태 데이터&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1459&quot; data-start=&quot;1424&quot; data-ke-size=&quot;size16&quot;&gt;즉 실제 파일은 sessions에 있어도 내부적으로 여전히:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;archived = 1&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1534&quot; data-start=&quot;1487&quot; data-ke-size=&quot;size16&quot;&gt;상태라면 Codex는 해당 대화를 &lt;b&gt;보관된 채팅이라고 판단해서 목록에서 숨긴다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1564&quot; data-start=&quot;1536&quot; data-ke-size=&quot;size16&quot;&gt;따라서 파일을 직접 옮기는 방식은 추천하지 않는다.&lt;/p&gt;
&lt;hr data-end=&quot;1569&quot; data-start=&quot;1566&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1585&quot; data-start=&quot;1571&quot; data-section-id=&quot;12c195x&quot;&gt;4. 올바른 복원 방법&lt;/h1&gt;
&lt;p data-end=&quot;1607&quot; data-start=&quot;1587&quot; data-ke-size=&quot;size16&quot;&gt;가장 좋은 방법은 Codex 자체의:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;unarchive&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1645&quot; data-start=&quot;1632&quot; data-ke-size=&quot;size16&quot;&gt;명령을 사용하는 것이다.&lt;/p&gt;
&lt;p data-end=&quot;1700&quot; data-start=&quot;1647&quot; data-ke-size=&quot;size16&quot;&gt;이 명령을 사용하면 &lt;b&gt;채팅 파일 이동과 내부 보관 상태 변경을 Codex가 함께 처리&lt;/b&gt;한다.&lt;/p&gt;
&lt;hr data-end=&quot;1705&quot; data-start=&quot;1702&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1729&quot; data-start=&quot;1707&quot; data-section-id=&quot;xieu4f&quot; data-ke-size=&quot;size26&quot;&gt;5. VS Code 완전히 종료하기&lt;/h2&gt;
&lt;p data-end=&quot;1759&quot; data-start=&quot;1731&quot; data-ke-size=&quot;size16&quot;&gt;먼저 열려 있는 VS Code 창을 전부 종료한다.&lt;/p&gt;
&lt;p data-end=&quot;1830&quot; data-start=&quot;1761&quot; data-ke-size=&quot;size16&quot;&gt;VS Code 내부 터미널이 아니라 Windows 시작 메뉴에서 &lt;b&gt;PowerShell을 별도로 실행&lt;/b&gt;하는 것을 추천한다.&lt;/p&gt;
&lt;p data-end=&quot;1884&quot; data-start=&quot;1832&quot; data-ke-size=&quot;size16&quot;&gt;Codex 프로세스가 실행 중인 상태에서 변경하면 세션 상태가 충돌할 가능성이 있기 때문이다.&lt;/p&gt;
&lt;hr data-end=&quot;1889&quot; data-start=&quot;1886&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1911&quot; data-start=&quot;1891&quot; data-section-id=&quot;dnho5t&quot; data-ke-size=&quot;size26&quot;&gt;6. 최근 보관된 채팅 확인하기&lt;/h2&gt;
&lt;p data-end=&quot;1938&quot; data-start=&quot;1913&quot; data-ke-size=&quot;size16&quot;&gt;PowerShell에서 다음 명령을 실행한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Get-ChildItem &quot;$env:USERPROFILE\.codex\archived_sessions&quot; |
    Sort-Object LastWriteTime -Descending |
    Select-Object Name, LastWriteTime&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2135&quot; data-start=&quot;2101&quot; data-ke-size=&quot;size16&quot;&gt;그러면 최근 보관한 .jsonl 파일부터 확인할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2143&quot; data-start=&quot;2137&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;rollout-2026-08-07T15-19-59-019fdae0-e322-7a33-bbae-5ea4ec9aefb8.jsonl&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2241&quot; data-start=&quot;2229&quot; data-ke-size=&quot;size16&quot;&gt;같은 파일이 나타난다.&lt;/p&gt;
&lt;p data-end=&quot;2251&quot; data-start=&quot;2243&quot; data-ke-size=&quot;size16&quot;&gt;파일명의 뒤쪽:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;019fdae0-e322-7a33-bbae-5ea4ec9aefb8&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2325&quot; data-start=&quot;2303&quot; data-ke-size=&quot;size16&quot;&gt;이 값이 &lt;b&gt;Codex 세션 ID&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-end=&quot;2358&quot; data-start=&quot;2327&quot; data-ke-size=&quot;size16&quot;&gt;나중에 unarchive 명령에서 이 값을 사용한다.&lt;/p&gt;
&lt;hr data-end=&quot;2363&quot; data-start=&quot;2360&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2384&quot; data-start=&quot;2365&quot; data-section-id=&quot;1ow2g0p&quot;&gt;7. Codex 실행 파일 찾기&lt;/h1&gt;
&lt;p data-end=&quot;2398&quot; data-start=&quot;2386&quot; data-ke-size=&quot;size16&quot;&gt;내 환경에서는 단순히:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;codex&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2446&quot; data-start=&quot;2425&quot; data-ke-size=&quot;size16&quot;&gt;라고 실행했더니 다음 오류가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;CommandNotFoundException&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2559&quot; data-start=&quot;2486&quot; data-ke-size=&quot;size16&quot;&gt;VS Code Codex 확장은 설치되어 있었지만 codex.exe가 Windows PATH에 등록되어 있지는 않았기 때문이다.&lt;/p&gt;
&lt;p data-end=&quot;2595&quot; data-start=&quot;2561&quot; data-ke-size=&quot;size16&quot;&gt;Codex 실행 파일은 VS Code 확장 폴더 안에 있었다.&lt;/p&gt;
&lt;p data-end=&quot;2708&quot; data-start=&quot;2597&quot; data-ke-size=&quot;size16&quot;&gt;다만 확장 프로그램이 업데이트될 때마다 폴더 버전이 달라질 수 있으므로 경로를 직접 고정해서 사용하는 것보다 &lt;b&gt;PowerShell로 가장 최근 설치된 Codex 실행 파일을 찾는 방법&lt;/b&gt;이 편하다.&lt;/p&gt;
&lt;p data-end=&quot;2722&quot; data-start=&quot;2710&quot; data-ke-size=&quot;size16&quot;&gt;다음 명령을 실행한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;powershell&quot;&gt;&lt;code&gt;$codexExe = Get-ChildItem &quot;$env:USERPROFILE\.vscode\extensions\openai.chatgpt-*\bin\windows-x86_64\codex.exe&quot; |
    Sort-Object LastWriteTime -Descending |
    Select-Object -First 1 -ExpandProperty FullName&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2969&quot; data-start=&quot;2951&quot; data-ke-size=&quot;size16&quot;&gt;그리고 제대로 찾았는지 확인한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;$codexExe&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3023&quot; data-start=&quot;3000&quot; data-ke-size=&quot;size16&quot;&gt;정상이라면 다음과 비슷한 경로가 출력된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;C:\Users\user\.vscode\extensions\openai.chatgpt-26.803.41515-win32-x64\bin\windows-x86_64\codex.exe&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3158&quot; data-start=&quot;3135&quot; data-ke-size=&quot;size16&quot;&gt;확장 버전에 따라 숫자는 달라질 수 있다.&lt;/p&gt;
&lt;hr data-end=&quot;3163&quot; data-start=&quot;3160&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;3181&quot; data-start=&quot;3165&quot; data-section-id=&quot;1v2zak4&quot;&gt;8. 보관된 채팅 복원하기&lt;/h1&gt;
&lt;p data-end=&quot;3206&quot; data-start=&quot;3183&quot; data-ke-size=&quot;size16&quot;&gt;이제 앞에서 확인한 세션 ID를 사용한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;&amp;amp; $codexExe unarchive &amp;lt;세션ID&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3281&quot; data-start=&quot;3256&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 내가 테스트한 채팅의 세션 ID는:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;019fdae0-e322-7a33-bbae-5ea4ec9aefb8&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3353&quot; data-start=&quot;3333&quot; data-ke-size=&quot;size16&quot;&gt;이었기 때문에 다음과 같이 실행했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;&amp;amp; $codexExe unarchive 019fdae0-e322-7a33-bbae-5ea4ec9aefb8&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3457&quot; data-start=&quot;3433&quot; data-ke-size=&quot;size16&quot;&gt;정상적으로 처리되자 다음 메시지가 출력됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Unarchived session 테스트 채팅 확인 (019fdae0-e322-7a33-bbae-5ea4ec9aefb8).&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3567&quot; data-start=&quot;3541&quot; data-ke-size=&quot;size16&quot;&gt;이 메시지가 나오면 복원 작업이 완료된 것이다.&lt;/p&gt;
&lt;hr data-end=&quot;3572&quot; data-start=&quot;3569&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;3594&quot; data-start=&quot;3574&quot; data-section-id=&quot;jdz7jt&quot;&gt;9. 실제 파일도 이동됐는지 확인&lt;/h1&gt;
&lt;p data-end=&quot;3623&quot; data-start=&quot;3596&quot; data-ke-size=&quot;size16&quot;&gt;복원 후 실제 .jsonl 파일도 확인해봤다.&lt;/p&gt;
&lt;p data-end=&quot;3634&quot; data-start=&quot;3625&quot; data-ke-size=&quot;size16&quot;&gt;일반 세션 폴더:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;Test-Path -LiteralPath &quot;C:\Users\user\.codex\sessions\2026\08\07\rollout-2026-08-07T15-19-59-019fdae0-e322-7a33-bbae-5ea4ec9aefb8.jsonl&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3792&quot; data-start=&quot;3789&quot; data-ke-size=&quot;size16&quot;&gt;결과:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;True&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3822&quot; data-start=&quot;3812&quot; data-ke-size=&quot;size16&quot;&gt;반대로 보관 폴더:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;Test-Path -LiteralPath &quot;C:\Users\user\.codex\archived_sessions\rollout-2026-08-07T15-19-59-019fdae0-e322-7a33-bbae-5ea4ec9aefb8.jsonl&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;3978&quot; data-start=&quot;3975&quot; data-ke-size=&quot;size16&quot;&gt;결과:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;False&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4029&quot; data-start=&quot;3999&quot; data-ke-size=&quot;size16&quot;&gt;즉 Codex의 unarchive 명령이 자동으로:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;archived_sessions
        &amp;darr;
sessions\2026\08\07&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4111&quot; data-start=&quot;4092&quot; data-ke-size=&quot;size16&quot;&gt;로 파일까지 옮겨준 것을 확인했다.&lt;/p&gt;
&lt;hr data-end=&quot;4116&quot; data-start=&quot;4113&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;4139&quot; data-start=&quot;4118&quot; data-section-id=&quot;r9bqrl&quot;&gt;10. VS Code에서 채팅 확인&lt;/h1&gt;
&lt;p data-end=&quot;4164&quot; data-start=&quot;4141&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 VS Code를 다시 실행한다.&lt;/p&gt;
&lt;p data-end=&quot;4222&quot; data-start=&quot;4166&quot; data-ke-size=&quot;size16&quot;&gt;Codex 패널에서 &lt;b&gt;시계 모양의 이전 대화 버튼&lt;/b&gt;을 누르면 복원한 채팅이 다시 목록에 표시된다.&lt;/p&gt;
&lt;p data-end=&quot;4229&quot; data-start=&quot;4224&quot; data-ke-size=&quot;size16&quot;&gt;내 경우:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;테스트 채팅 확인&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4276&quot; data-start=&quot;4254&quot; data-ke-size=&quot;size16&quot;&gt;이라는 채팅이 정상적으로 다시 나타났다.&lt;/p&gt;
&lt;hr data-end=&quot;4281&quot; data-start=&quot;4278&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;4287&quot; data-start=&quot;4283&quot; data-section-id=&quot;yim43j&quot;&gt;정리&lt;/h1&gt;
&lt;p data-end=&quot;4328&quot; data-start=&quot;4289&quot; data-ke-size=&quot;size16&quot;&gt;다음에 Codex 채팅을 실수로 보관했다면 아래 순서만 기억하면 된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. VS Code 완전히 종료

2. archived_sessions에서 보관된 jsonl 확인

3. 파일명에서 세션 ID 확인

4. 현재 설치된 codex.exe 찾기

5. codex.exe unarchive &amp;lt;세션ID&amp;gt; 실행

6. VS Code 재실행

7. 이전 대화 목록에서 확인&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;4531&quot; data-start=&quot;4509&quot; data-ke-size=&quot;size16&quot;&gt;핵심 명령만 다시 정리하면 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# 최근 보관 채팅 확인
Get-ChildItem &quot;$env:USERPROFILE\.codex\archived_sessions&quot; |
    Sort-Object LastWriteTime -Descending |
    Select-Object Name, LastWriteTime&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;powershell&quot;&gt;&lt;code&gt;# Codex 실행 파일 찾기
$codexExe = Get-ChildItem &quot;$env:USERPROFILE\.vscode\extensions\openai.chatgpt-*\bin\windows-x86_64\codex.exe&quot; |
    Sort-Object LastWriteTime -Descending |
    Select-Object -First 1 -ExpandProperty FullName&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;# 보관 해제
&amp;amp; $codexExe unarchive &amp;lt;세션ID&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;5011&quot; data-start=&quot;5008&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;5020&quot; data-start=&quot;5013&quot; data-section-id=&quot;1pl1e0w&quot; data-ke-size=&quot;size26&quot;&gt;주의사항&lt;/h2&gt;
&lt;p data-end=&quot;5070&quot; data-start=&quot;5022&quot; data-ke-size=&quot;size16&quot;&gt;.jsonl 파일을 임의로 sessions 폴더로 옮기는 것은 추천하지 않는다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;파일 위치
+
Codex 내부 archived 상태&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;5180&quot; data-start=&quot;5114&quot; data-ke-size=&quot;size16&quot;&gt;두 정보가 서로 달라지면 실제 파일은 존재하는데도 VS Code Codex 채팅 목록에서는 계속 보이지 않을 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;5185&quot; data-start=&quot;5182&quot; data-ke-size=&quot;size16&quot;&gt;또한:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;state_5.sqlite&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;5240&quot; data-start=&quot;5215&quot; data-ke-size=&quot;size16&quot;&gt;파일을 직접 수정하는 것도 피하는 것이 좋다.&lt;/p&gt;
&lt;p data-end=&quot;5260&quot; data-start=&quot;5242&quot; data-ke-size=&quot;size16&quot;&gt;가능하면 Codex에서 제공하는:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;unarchive&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;5309&quot; data-start=&quot;5285&quot; data-ke-size=&quot;size16&quot;&gt;명령을 이용해 복원하는 것이 가장 안전하다.&lt;/p&gt;
&lt;p data-end=&quot;5465&quot; data-start=&quot;5311&quot; data-ke-size=&quot;size16&quot;&gt;이번 문제를 해결하면서 알게 된 점은 &lt;b&gt;프로그램의 데이터 파일이 존재한다고 해서 프로그램이 그 데이터를 활성 상태로 인식하는 것은 아니라는 것&lt;/b&gt;이다. Codex처럼 실제 파일과 별도의 상태 정보를 함께 관리하는 프로그램에서는 두 상태가 일치해야 정상적으로 목록에 표시된다.&lt;/p&gt;
&lt;blockquote data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;5591&quot; data-start=&quot;5467&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-is-last-node=&quot;&quot; data-end=&quot;5591&quot; data-start=&quot;5469&quot; data-ke-size=&quot;size16&quot;&gt;참고: 위 방법은 내가 사용 중인 Windows + VS Code Codex 환경에서 직접 보관/복원 테스트를 진행해 정상 동작을 확인한 방법이다. Codex 확장 버전에 따라 저장 구조나 경로는 이후 변경될 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>비주얼코드</category>
      <category>오류해결</category>
      <category>코덱스</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/145</guid>
      <comments>https://mmee2.tistory.com/145#entry145comment</comments>
      <pubDate>Mon, 10 Aug 2026 22:32:33 +0900</pubDate>
    </item>
    <item>
      <title>[LINUX] 크론탭이 갑자기 실행되지 않았을 경우, 오류 로그 확인과 해결방법</title>
      <link>https://mmee2.tistory.com/144</link>
      <description>&lt;h2 style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;증상&lt;/h2&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;매시간 정상적으로 돌던 크론탭이 어떤 시점을 이후로 실행되지 않았던 걸 발견했다.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스크립트는 수정한 적이 없고 크론탭으로 설정해둔 로그에는 오류도 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;로그 확인&lt;/h2&gt;
&lt;pre id=&quot;code_1784735387672&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/usr/sbin/crond -n&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;로그를 확인해보니 다음과 같은 메시지가 나왔다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #1e1e1e; color: #e6e6e6; text-align: start;&quot;&gt;&lt;code&gt;pam_unix(crond:account): expired password for user tomcat (password aged)
(tomcat) PAM ERROR (Authentication token is no longer valid; new one required)
(tomcat) FAILED to authorize user with PAM (Authentication token is no longer valid; new one required)&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;첫 줄에 원인이 써져있었다.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;계정의 비밀번호가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;aging(사용 기간 만료)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;정책에 의해 만료되었다는 뜻.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제로 내가 그 전에 계정 패스워드 정책을 변경했었다.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;리눅스 계정의 비밀번호 최대 사용 기간(예:&lt;span&gt;&amp;nbsp;&lt;/span&gt;chage로 설정된&lt;span&gt;&amp;nbsp;&lt;/span&gt;PASS_MAX_DAYS) 정책에 걸려 자동으로 만료 처리된 것이었다.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그 아래 두 줄은 이 만료 상태가 실제 인증 단계에서 어떻게 반영되는지를 보여줬다. PAM 모듈이 계정 상태를 확인(account 단계)하는 과정에서 비밀번호 만료를 감지하고&lt;span&gt;&amp;nbsp;&lt;/span&gt;PAM ERROR를 반환했고, 결국 crond는 해당 계정으로 인증을 완료하지 못해&lt;span&gt;&amp;nbsp;&lt;/span&gt;FAILED to authorize user with PAM으로 작업 실행 자체를 포기했다. crond는 이 스크립트를 애초에 tomcat 계정 권한으로 실행하도록 설정되어 있었기 때문에, 정책 변경의 영향을 그대로 받은 것이었다.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;원인&lt;/h2&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 흐름은 이랬다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #2b2b2b; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;사용자 정책 변경으로 비밀번호 재설정이 필요한 상태가 됨&lt;/li&gt;
&lt;li&gt;기존 로그인 세션은 유지되고 있어 사용자 입장에서는 변화를 인지하지 못함&lt;/li&gt;
&lt;li&gt;crond는 매 실행마다 PAM을 통해 별도로 인증을 시도함&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #2b2b2b; text-align: start;&quot;&gt;비밀번호 만료가 감지되어 PAM 인증이 거부됨&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;크론 작업이 조용히 실행되지 않음&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;비밀번호를 재설정한 이후로는 크론탭이 다시 정상적으로 도는 것을 확인했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;크론탭은 실제 인증 토큰 상태를 그대로 반영한다는 점을 알게 됐다.&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;비밀번호 정책 변경이나 만료 주기가 있는 환경에서는&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;크론탭으로 돌아가는 배치 작업들이 이런 식으로 조용히 멈출 수 있다는 걸 알 수 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #2b2b2b; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;정책 변경 할 때는 조심해야겠다.&amp;nbsp;&lt;/p&gt;</description>
      <category>SERVER</category>
      <category>리눅스</category>
      <category>서버</category>
      <category>트러블슈팅</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/144</guid>
      <comments>https://mmee2.tistory.com/144#entry144comment</comments>
      <pubDate>Thu, 23 Jul 2026 01:00:15 +0900</pubDate>
    </item>
    <item>
      <title>[사이드 프로젝트 회고 #5] 서비스 운영하면서 알게 된 것들</title>
      <link>https://mmee2.tistory.com/143</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4편까지 닷홈빌더에서 PHP로 넘어온 이유부터, FCM 웹 푸시를 붙이고 버그를 잡고 구독 기능의 DB 구조를 설계한 과정까지 정리했다. 이번 글은 이 시리즈를 마무리하는 글로, 만들고 나서 든 생각들을 정리했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관리자 페이지 제작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌더로 사이트를 운영할 때 관리자 페이지가 있었다. 근데 기능이 제한적이라 회원이 몇 명인지, 어떤 글이 올라오는지, 문의가 들어왔는지 같은 것들을 빌더가 제공하는 화면 안에서만 제한적으로 볼 수 있었다. 그래서 내가 원하는 방식으로 데이터를 들여다보거나 조작할 방법이 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PHP로 새로 만들면서는 이 부분을 직접 설계할 수 있었다. 그래서 운영에 필요한 관리자 페이지를 따로 만들었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;924&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOY2bb/dJMcaiD7cr4/lD2MUQC98HA13DPlkvVFlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOY2bb/dJMcaiD7cr4/lD2MUQC98HA13DPlkvVFlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOY2bb/dJMcaiD7cr4/lD2MUQC98HA13DPlkvVFlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOY2bb%2FdJMcaiD7cr4%2FlD2MUQC98HA13DPlkvVFlK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1898&quot; height=&quot;924&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;924&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;대시보드&lt;/b&gt; &amp;mdash; 사이트 전반의 현황을 한눈에 보는 첫 화면&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1891&quot; data-origin-height=&quot;924&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHKhB1/dJMcahZr7xD/vxtkl91aVsaGr39Pcki9K0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHKhB1/dJMcahZr7xD/vxtkl91aVsaGr39Pcki9K0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHKhB1/dJMcahZr7xD/vxtkl91aVsaGr39Pcki9K0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHKhB1%2FdJMcahZr7xD%2Fvxtkl91aVsaGr39Pcki9K0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1891&quot; height=&quot;924&quot; data-origin-width=&quot;1891&quot; data-origin-height=&quot;924&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;회원관리&lt;/b&gt; &amp;mdash; 가입한 회원 목록과 상태를 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;925&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJx3LU/dJMcadCEIVq/i8U7tmKmZZBNHtbIv88ETK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJx3LU/dJMcadCEIVq/i8U7tmKmZZBNHtbIv88ETK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJx3LU/dJMcadCEIVq/i8U7tmKmZZBNHtbIv88ETK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJx3LU%2FdJMcadCEIVq%2Fi8U7tmKmZZBNHtbIv88ETK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1904&quot; height=&quot;925&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;925&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;작가신청&lt;/b&gt; &amp;mdash; 글쓴이로 활동하고 싶은 사용자의 신청을 받고 승인/거절 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1912&quot; data-origin-height=&quot;923&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcKHw3/dJMcaiD7csl/zuGqdN3RoKIyu6bUhZZ840/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcKHw3/dJMcaiD7csl/zuGqdN3RoKIyu6bUhZZ840/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcKHw3/dJMcaiD7csl/zuGqdN3RoKIyu6bUhZZ840/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcKHw3%2FdJMcaiD7csl%2FzuGqdN3RoKIyu6bUhZZ840%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1912&quot; height=&quot;923&quot; data-origin-width=&quot;1912&quot; data-origin-height=&quot;923&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;문의관리&lt;/b&gt; &amp;mdash; 사용자들이 남긴 문의(QnA)를 확인하고 답변&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;933&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJR2Aj/dJMcahLWkRP/4jFJv2eXoUdq8peI88tM4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJR2Aj/dJMcahLWkRP/4jFJv2eXoUdq8peI88tM4K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJR2Aj/dJMcahLWkRP/4jFJv2eXoUdq8peI88tM4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJR2Aj%2FdJMcahLWkRP%2F4jFJv2eXoUdq8peI88tM4K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1898&quot; height=&quot;933&quot; data-origin-width=&quot;1898&quot; data-origin-height=&quot;933&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;게시글관리&lt;/b&gt; &amp;mdash; 올라온 글을 관리(숨김, 삭제 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1910&quot; data-origin-height=&quot;928&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bt6cGY/dJMcaixmslc/InHcAS2mASVLFHtDlkraY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bt6cGY/dJMcaixmslc/InHcAS2mASVLFHtDlkraY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bt6cGY/dJMcaixmslc/InHcAS2mASVLFHtDlkraY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbt6cGY%2FdJMcaixmslc%2FInHcAS2mASVLFHtDlkraY1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1910&quot; height=&quot;928&quot; data-origin-width=&quot;1910&quot; data-origin-height=&quot;928&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;월별주제&lt;/b&gt; &amp;mdash; 매달 바뀌는 글쓰기 주제를 등록&amp;middot;관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1895&quot; data-origin-height=&quot;927&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcWiBf/dJMcadW5u0B/E7dtlxD3Q0tEPn864ek6k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcWiBf/dJMcadW5u0B/E7dtlxD3Q0tEPn864ek6k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcWiBf/dJMcadW5u0B/E7dtlxD3Q0tEPn864ek6k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcWiBf%2FdJMcadW5u0B%2FE7dtlxD3Q0tEPn864ek6k0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1895&quot; height=&quot;927&quot; data-origin-width=&quot;1895&quot; data-origin-height=&quot;927&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;통계&lt;/b&gt; &amp;mdash; 사이트 이용 현황을 수치로 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서비스를 만든다는 건 운영 도구를 만드는 일이기도 했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드 프로젝트를 시작할 때는 사용자 화면만 잘 만들면 된다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 운영을 시작하고 나니, 정작 가장 많이 사용하는 화면은 오히려 관리자 페이지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원을 관리하고, 문의를 확인하고, 게시글을 관리하고, 사이트 현황을 확인하는 일이 반복되면서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 페이지는 단순한 부가 기능이 아니라 &lt;b&gt;서비스를 운영하기 위해 필수적&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트를 통해 가장 크게 느낀 점은 하나다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 사용하는 기능만큼, 운영자가 사용하는 기능도 중요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 서비스는 사용자 경험만 좋은 것이 아니라, 운영하는 사람도 편하게 관리할 수 있어야 오래 유지될 수 있다는 것을 이번 사이드 프로젝트를 통해 배웠다. 빌더로 운영할 때는 이 부분이 아예 없었으니 몰랐던 감각인데, 직접 만들고 나서야 &quot;사용자 화면과 운영자 화면은 같이 성장해야 하는 관계&quot;라는 걸 알게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그 외에 든 생각들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 외에도 짧게 남겨두고 싶은 생각들이 있다. 만드는 것과 쓰이는 것은 다른 문제라는 것, 진짜 사용자가 있어야 개선점도 보인다는 것, 그리고 만들었다고 해서 저절로 쓰이지는 않는다는 것. 기능을 다 완성했다고 끝난 게 아니라, 그 다음부터가 진짜 시작이라는 걸 이번에 알게됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돌아보며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;닷홈빌더에서 PHP로 갈아타면서 시작한 바이브코딩이었는데, 직접해보니 어떻게 하면 사용하기 더 편할지, 운영은 어떻게 해야할지, 또 보안적인 부분도 생각하게 되었고, 사용자들을 어떻게 활성화시킬지 고민도 해보고, 등등 많이 배우게 된 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음에 뭔가를 다시 만든다면, 처음부터 조금 더 구체적으로 고민해서 설계해야겠다는 생각이 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>REVIEW</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/143</guid>
      <comments>https://mmee2.tistory.com/143#entry143comment</comments>
      <pubDate>Mon, 20 Jul 2026 20:54:01 +0900</pubDate>
    </item>
    <item>
      <title>[사이드 프로젝트 회고 #4] 구독 기능 설계하면서 고민했던 DB 구조</title>
      <link>https://mmee2.tistory.com/142</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편에서 알림이 두 번 오던 문제의 원인이 정리되지 않은 fcm_tokens였다는 걸 찾아냈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 그 과정에서 다시 들여다보게 된 구독 관련 DB 구조 이야기다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1118&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1GhJ2/dJMcabZkRZM/doysbkLNkukWUZDkSZhjlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1GhJ2/dJMcabZkRZM/doysbkLNkukWUZDkSZhjlk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1GhJ2/dJMcabZkRZM/doysbkLNkukWUZDkSZhjlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1GhJ2%2FdJMcabZkRZM%2FdoysbkLNkukWUZDkSZhjlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1118&quot; height=&quot;506&quot; data-origin-width=&quot;1118&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&quot;구독&quot;이 하나가 아니었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;구독&quot;이라는 개념을 단순하게 생각했다. 게시판 하나를 구독하면, 그 게시판에 올라오는 모든 글에 대한 알림을 받는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 막상 알림 종류를 하나씩 나열해보니, 실제로 필요한 구독은 두 가지 결이 달랐다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;게시판 구독&lt;/b&gt; &amp;mdash; &quot;이 게시판에 새 글이 올라오면 알려줘&quot; (게시판 단위)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;글 구독&lt;/b&gt; &amp;mdash; &quot;내가 댓글 단 이 글에 다른 댓글이 더 달리면 알려줘&quot; (개별 게시글 단위)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이 둘을 하나의 테이블로 운영할까 고민했다. 하나의 테이블에서 target_type('board' 또는 'post') 컬럼으로 분류하여 모든 구독 데이터를 관리하면 테이블 개수를 줄여 관리를 단순화할 수 있다고 생각했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;하지만 고민해보니 테이블을 분리하는 게 나았다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;첫번째, 추후에 게시판이 늘어날 것을 생각하여 board_type과 board_id 컬럼을 모두 생성하고 상황에 따라 하나를 비워두는(NULL) 방식을 했으나 너무 비효율적이라고 판단했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;두번째, 데이터 조회 및 알림 발송 로직이 복잡해진다. 통합 테이블을 사용할 경우 쿼리마다 WHERE target_type = '?' 조건을 필수적으로 써야했고, 백엔드(PHP) 코드에서도 알림을 처리할 때마다 if나 switch 문을 통해 대상을 분류하는 분기 처리를 해야한다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;그래서 처음부터 테이블을 분리했다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 게시판 단위 구독 (새 글 알림용)
CREATE TABLE board_subscriptions (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  board_type VARCHAR(50) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_board_sub (user_id, board_type)
);

-- 게시글 단위 구독 (댓글 알림용)
CREATE TABLE post_subscriptions (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  board_id INT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_post_sub (user_id, board_id)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UNIQUE KEY를 (user_id, board_type) / (user_id, board_id) 조합으로 걸어둬서 같은 사용자가 같은 게시판이나 같은 글을 실수로 여러 번 구독 신청해도, 중복 행이 쌓이지 않게 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 없으면 &quot;구독 취소를 눌렀는데도 여전히 알림이 온다&quot;거나 &quot;알림이 구독 횟수만큼 여러 번 온다&quot; 같은 문제가 생길 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;584&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bY0iLW/dJMcafglMRC/v8KRrKVv7h1hqk26IlKkV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bY0iLW/dJMcafglMRC/v8KRrKVv7h1hqk26IlKkV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bY0iLW/dJMcafglMRC/v8KRrKVv7h1hqk26IlKkV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbY0iLW%2FdJMcafglMRC%2Fv8KRrKVv7h1hqk26IlKkV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;489&quot; height=&quot;239&quot; data-origin-width=&quot;584&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;487&quot; data-origin-height=&quot;160&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3XQFW/dJMcaazkWP2/cmAVtlBrrcwNq4wc3xdDcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3XQFW/dJMcaazkWP2/cmAVtlBrrcwNq4wc3xdDcK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3XQFW/dJMcaazkWP2/cmAVtlBrrcwNq4wc3xdDcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3XQFW%2FdJMcaazkWP2%2FcmAVtlBrrcwNq4wc3xdDcK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;487&quot; height=&quot;160&quot; data-origin-width=&quot;487&quot; data-origin-height=&quot;160&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새 글 알림에서는 게시판 구독자만 조회한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 글이 등록됐을 때는 그 게시판을 구독한 사람만 걸러서 알림을 보낸다. 근데 작성자 본인은 제외 시켜야된다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;$stmt = $pdo-&amp;gt;prepare(&quot;
    SELECT user_id
    FROM board_subscriptions
    WHERE board_type = :type
      AND user_id != :writer_id
&quot;);
$stmt-&amp;gt;execute([':type' =&amp;gt; $board_type, ':writer_id' =&amp;gt; $writer_id]);
$subscribers = $stmt-&amp;gt;fetchAll(PDO::FETCH_COLUMN);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;댓글 알림은 두 갈래로 나뉜다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;댓글 알림은 새 글 알림보다 조금 더 복잡했다. 대상이 두 그룹이기 때문이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;글쓴이&lt;/b&gt; &amp;mdash; 자기 글에 댓글이 달렸을 때 (작성자와 댓글 작성자가 다를 때만)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그 글을 구독 중인 다른 사람들&lt;/b&gt; &amp;mdash; 글쓴이가 아니어도, 이전에 댓글을 달았거나 구독을 걸어둔 사람들&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 중에서 글쓴이도 아니고, 이번에 댓글을 단 사람도 아닌 나머지 구독자만 추려야 했다. 그래서 제외 대상을 배열로 만들고, NOT IN 조건으로 걸러내는 방식을 썼다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;$exclude = array_filter([
    (int)($board['user_id'] ?? 0), // 글쓴이
    $commenter_id,                 // 이번 댓글 작성자
]);

$placeholders = implode(',', array_fill(0, count($exclude), '?'));

$stmt = $pdo-&amp;gt;prepare(&quot;
    SELECT user_id FROM post_subscriptions
    WHERE board_id = ?
      AND user_id NOT IN ({$placeholders})
&quot;);
$stmt-&amp;gt;execute(array_merge([$board_id], $exclude));
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;fcm_tokens는 조금 다르게 다시 설계했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3편에서 겪었던 문제 &amp;mdash; 옛날 토큰이 정리되지 않고 계속 쌓이는 것 &amp;mdash; 를 겪고 나서, fcm_tokens 테이블도 그대로 둘 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래는 그냥 INSERT만 하던 구조였는데, 이걸 &quot;같은 사용자의 같은 토큰이 다시 들어오면 갱신만 하고, 새로 쌓이지는 않게&quot; 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE fcm_tokens (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  token VARCHAR(255) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_token (token)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;token 컬럼 자체에 유니크 제약을 걸어두고, 저장할 때는 INSERT ... ON DUPLICATE KEY UPDATE로 처리해서 같은 토큰이 다시 들어와도 새 행이 생기지 않고 created_at만 갱신되도록 했다. 구독 테이블에 걸어둔 유니크 제약과 결이 같은 방식이다. 되돌아보면 &quot;구독이든 토큰이든, 사용자가 같은 걸 중복으로 등록할 수 있는 지점에는 전부 유니크 제약을 걸어야 한다&quot;는 게 이번 프로젝트에서 깨달은 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돌아보며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구독 기능을 설계하면서 배운 건, 처음부터 완벽한 스키마를 그리려고 하기보다는 &lt;b&gt;실제로 발생하는 이벤트 종류를 먼저 나열하고, 그 이벤트마다 &quot;누구에게 보내야 하는가&quot;를 구체적으로 따져본 다음에 테이블을 쪼개는 게 훨씬 수월하다는 것&lt;/b&gt;이었다. 게시판 구독과 글 구독을 처음부터 하나의 테이블로 억지로 합쳤다면, 지금보다 훨씬 복잡한 조건문을 매번 짜야 했을 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>REVIEW</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/142</guid>
      <comments>https://mmee2.tistory.com/142#entry142comment</comments>
      <pubDate>Sun, 19 Jul 2026 14:40:18 +0900</pubDate>
    </item>
    <item>
      <title>[사이드 프로젝트 회고 #3] 새 글 알림이 두 번 와요</title>
      <link>https://mmee2.tistory.com/141</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2편에서 FCM 웹 푸시를 어떻게 구성했는지 정리했다. 그런데 그 글 마지막에 살짝 흘렸던 문제가 하나 있었다 &amp;mdash; 개발 중에 PC 토큰이 정리되지 않은 채 fcm_tokens 테이블에 그대로 남아 있었던 것. 이번 글은 그 잔여 토큰이 실제로 어떤 문제를 일으켰고, 어떻게 찾아서 고쳤는지에 대한 이야기다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1669&quot; data-origin-height=&quot;691&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rESKH/dJMcaf1vGlV/KqQIDk5PS9VeCEYWLQ1XKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rESKH/dJMcaf1vGlV/KqQIDk5PS9VeCEYWLQ1XKK/img.png&quot; data-alt=&quot;https://console.firebase.google.com/&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rESKH/dJMcaf1vGlV/KqQIDk5PS9VeCEYWLQ1XKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrESKH%2FdJMcaf1vGlV%2FKqQIDk5PS9VeCEYWLQ1XKK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1669&quot; height=&quot;691&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1669&quot; data-origin-height=&quot;691&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://console.firebase.google.com/&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;증상 &amp;mdash; 글 하나 등록했는데 알림이 두 번&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 글 알림 기능을 테스트하던 중이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글을 하나 올렸는데, 폰에 알림이 두 개씩 뜨는 거다. 당연히 코드에 버그가 있다고 생각했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20260708_215151819.jpg&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhTV3B/dJMcabdHSEP/vdwQkVhftKBgkyx08fvw9k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhTV3B/dJMcabdHSEP/vdwQkVhftKBgkyx08fvw9k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhTV3B/dJMcabdHSEP/vdwQkVhftKBgkyx08fvw9k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdhTV3B%2FdJMcabdHSEP%2FvdwQkVhftKBgkyx08fvw9k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;350&quot; height=&quot;147&quot; data-filename=&quot;KakaoTalk_20260708_215151819.jpg&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 번째 의심 &amp;mdash; 서비스워커&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 의심한 건 서비스워커였다. 포그라운드/백그라운드에서 알림을 각각 처리하는 로직이 겹쳐서 중복으로 뜨는 게 아닐까 싶어서, 이것저것 손을 댔다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;showNotification 호출 제거&lt;/li&gt;
&lt;li&gt;onBackgroundMessage 핸들러 수정&lt;/li&gt;
&lt;li&gt;포그라운드에서 띄우는 Notification 코드 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 뭘 바꿔도 증상이 똑같았다. 하나를 지우면 알림이 아예 안 뜨거나, 그대로 두 번 뜨거나 둘 중 하나였다. 서비스워커 쪽 로직을 아무리 뜯어고쳐도 원인에 접근하지 못하고 있다는 느낌이 들었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결정적인 힌트 &amp;mdash; PC를 끄면 알림이 하나로 줄어든다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계속 테스트하다가 패턴이 하나 보였다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PC를 켜두면 알림이 2번 온다. PC를 끄면 알림이 1번만 온다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 힌트를 보고서야 방향을 잘못 잡고 있었다는 걸 깨달았다. 문제는 서비스워커 코드가 아니라, 애초에 내가 &quot;한 사람&quot;에게 알림을 보낸다고 생각한 것 자체였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FCM은 사용자에게 보내는 게 아니라, &lt;b&gt;토큰(Token)에 보내는 시스템&lt;/b&gt;이다. PC Chrome과 iPhone PWA는 같은 계정으로 로그인해 있어도 서로 다른 토큰을 가진다. 그러니 새 글이 등록되면 서버는 이 계정에 연결된 토큰 전부에 푸시를 보내고, PC를 켜두면 PC 토큰으로 간 알림이 PC에서, 폰 토큰으로 간 알림이 폰에서 각각 뜬다. 내 입장에서는 &quot;두 번 왔다&quot;고 느껴지지만, 사실은 각각 다른 기기에 정확히 한 번씩 간 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 이해하고 나니 &quot;PC를 켜두면 2번, 끄면 1번&quot;이라는 현상 자체는 설명이 됐다. &lt;b&gt;그런데 이상한 점이 하나 남아 있었다.&lt;/b&gt; 폰 하나만 켜둔 상태로 테스트할 때도, 가끔 알림이 두 번 오는 경우가 있었던 거다. 기기는 분명 하나인데 왜 두 번이 오는 걸까.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;진짜 원인 &amp;mdash; 정리되지 않은 옛날 토큰&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fcm_tokens 테이블을 직접 열어서 확인해봤다. 그리고 원인을 찾았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 초기에 테스트하면서, PC에서 '새 글 알림 받기' 버튼을 눌러 토큰을 저장했다. 이후 같은 계정으로 모바일에서도 알림을 허용했는데, 이 과정에서 &lt;b&gt;기존 PC 토큰이 정리되지 않은 채 테이블에 그대로 남아 있었다.&lt;/b&gt; 여기에 더해서, 브라우저를 껐다 켜거나 알림 권한을 다시 요청하는 과정에서 토큰이 재발급되는 경우도 있었는데, 이때도 옛날 토큰을 지우지 않고 새 토큰을 그냥 추가로 저장하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 테이블 안에는 &quot;이미 유효하지 않거나 중복된 옛날 토큰&quot;들이 계속 쌓이고 있었던 거다. 겉으로는 기기 하나만 쓰는 것처럼 보여도, 실제로는 그 사용자에게 연결된 토큰이 여러 개 저장돼 있었고, send_notification()은 저장된 토큰 전부에 성실하게 알림을 보내고 있었을 뿐이었다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;$stmt = $pdo-&amp;gt;prepare(&quot;SELECT token FROM fcm_tokens WHERE user_id = :uid&quot;);
$stmt-&amp;gt;execute([':uid' =&amp;gt; $to_user_id]);
$tokens = $stmt-&amp;gt;fetchAll(PDO::FETCH_COLUMN);

foreach ($tokens as $token) {
    fcm_send_to_token($token, $title, $message, $url);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드 자체는 잘못된 게 없었다. 문제는 fcm_tokens 테이블에 &lt;b&gt;지워졌어야 할 옛날 토큰이 계속 살아있었다는 것&lt;/b&gt;이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 테이블을 정리하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당장 눈앞의 증상을 없애기 위해 fcm_tokens 테이블을 한 번 초기화했다. 정리하고 나니 중복 알림 증상이 말끔히 사라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이건 임시방편에 가까웠다. 근본적으로는 토큰을 저장하는 시점에 &lt;b&gt;같은 기기(또는 같은 사용자)의 옛날 토큰을 정리하거나 갱신하는 로직&lt;/b&gt;이 필요하다는 걸 이 과정에서 깨달았다. 지금처럼 매번 새로 INSERT만 하는 구조로는, 토큰이 재발급될 때마다 테이블에 쓰레기 데이터가 계속 쌓일 수밖에 없는 구조였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;460&quot; data-origin-height=&quot;294&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Hx9GE/dJMcafm0lYy/Y6lNrElsDcvdGSpjn2cxtK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Hx9GE/dJMcafm0lYy/Y6lNrElsDcvdGSpjn2cxtK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Hx9GE/dJMcafm0lYy/Y6lNrElsDcvdGSpjn2cxtK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHx9GE%2FdJMcafm0lYy%2FY6lNrElsDcvdGSpjn2cxtK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;460&quot; height=&quot;294&quot; data-origin-width=&quot;460&quot; data-origin-height=&quot;294&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돌아보며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;알림이 두 번 온다&quot;는 증상 하나를 두고 서비스워커 코드를 확인했다. 그런데 진짜 원인은 코드 로직이 아니라 &lt;b&gt;데이터 관리&lt;/b&gt; 쪽에 있었다. FCM이 &quot;토큰 단위로 발송하는 시스템&quot;이라는 걸 이해하는 것과, 그 토큰을 DB에서 어떻게 깨끗하게 관리할지는 완전히 다른 문제였다는 걸 이번에 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>REVIEW</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/141</guid>
      <comments>https://mmee2.tistory.com/141#entry141comment</comments>
      <pubDate>Wed, 8 Jul 2026 21:53:08 +0900</pubDate>
    </item>
    <item>
      <title>[사이드 프로젝트 회고 #2] 푸시 기능을 앱 대신 FCM(Firebase Cloud Messaging)으로 해결하기</title>
      <link>https://mmee2.tistory.com/140</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서 닷홈빌더의 한계 때문에 PHP로 새로 사이트를 만들기로 했다. 사이트를 다 완성하고 알림 기능을 추가하기 전에 그다음 고민으로 &quot;이걸 어떤 방식으로 구현할까&quot; 생각해봤다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;알림이 필요하면, 앱을 만들어야 하는 거 아닌가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 글 알림, 댓글 알림 기능이 필요하다는 결론을 내리고 나서, 가장 먼저 떠오른 방법은 사실 &quot;앱으로 만들기&quot;였다. 푸시 알림이라는 게 원래 앱에서 흔히 쓰는 기능이라, &quot;그냥 앱 하나 만들면 되지 않을까&quot;라는 생각이 자연스럽게 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 생각해볼수록 앱으로 가는 건 이 프로젝트의 규모와 전혀 맞지 않았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;안드로이드/iOS 각각 개발해야 하거나, 최소한 크로스플랫폼 프레임워크를 새로 익혀야 한다&lt;/li&gt;
&lt;li&gt;앱스토어/플레이스토어 등록 절차와 심사를 거쳐야 한다&lt;/li&gt;
&lt;li&gt;이미 PHP로 만든 웹사이트가 있는데, 그걸 앱으로 다시 만드는 건 중복 작업이다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이용자가 적은 익명 글쓰기 사이트 하나에 알림 기능을 붙이려고 앱까지 새로 만드는 건, 배보다 배꼽이 큰 선택이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차라리 네이버 카페 기능을 쓰는게 나을 정도...&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아이폰의 &quot;홈 화면에 추가&quot; 기능이 힌트가 됐다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아보니 아이폰의 &lt;b&gt;홈 화면에 추가&lt;/b&gt; 기능으로 웹사이트를 어플처럼 이용할 수 있었다. 사파리에서 웹사이트를 열고 &quot;홈 화면에 추가&quot;를 누르면, 그 사이트가 마치 하나의 앱처럼 아이콘으로 홈 화면에 생긴다. 이 상태로 열면 브라우저 주소창도 사라지고, 진짜 앱을 켠 것 같은 느낌을 준다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이렇게 홈 화면에 추가한 상태에서도, 푸시 알림을 받을 수 있을까?&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FCM (&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Firebase Cloud Messaging)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 질문에 대한 답을 찾다가 Firebase Cloud Messaging(FCM)을 알게 됐다. FCM은 원래 안드로이드/iOS 앱을 위한 푸시 서비스로 많이 알려져 있지만, &lt;b&gt;웹 푸시(Web Push)&lt;/b&gt; 도 지원한다. 그리고 확인해보니 이 웹 푸시가 홈 화면에 추가한 PWA(Progressive Web App) 상태에서도 정상적으로 동작한다는 걸 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇게 방향이 잡혔다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱을 새로 만드는 대신, 기존 PHP 웹사이트를 그대로 유지한다&lt;/li&gt;
&lt;li&gt;아이폰/안드로이드 사용자는 &quot;홈 화면에 추가&quot;로 앱처럼 쓰게 한다&lt;/li&gt;
&lt;li&gt;알림은 FCM 웹 푸시로 처리해서, PC 브라우저와 홈 화면에 추가된 모바일 상태 모두에서 받을 수 있게 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방향이면 앱스토어 심사도, 별도 앱 개발 비용도 없이 &quot;새 글이 올라오면 알려주는 앱 같은 사이트&quot;를 만들 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WZnbk/dJMcaa61BQQ/3bgzMvFEKjL3Axu3X8RImk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WZnbk/dJMcaa61BQQ/3bgzMvFEKjL3Axu3X8RImk/img.png&quot; data-origin-width=&quot;1203&quot; data-origin-height=&quot;409&quot; data-is-animation=&quot;false&quot; style=&quot;width: 76.4972%; margin-right: 10px;&quot; data-widthpercent=&quot;77.4&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WZnbk/dJMcaa61BQQ/3bgzMvFEKjL3Axu3X8RImk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWZnbk%2FdJMcaa61BQQ%2F3bgzMvFEKjL3Axu3X8RImk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1203&quot; height=&quot;409&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8O5Uc/dJMcaaFXXCn/fwK0JX8596TCGx7C8oPD0K/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8O5Uc/dJMcaaFXXCn/fwK0JX8596TCGx7C8oPD0K/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;1404&quot; data-filename=&quot;KakaoTalk_20260705_161254152.jpg&quot; style=&quot;width: 22.34%;&quot; data-widthpercent=&quot;22.6&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8O5Uc/dJMcaaFXXCn/fwK0JX8596TCGx7C8oPD0K/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8O5Uc%2FdJMcaaFXXCn%2FfwK0JX8596TCGx7C8oPD0K%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1206&quot; height=&quot;1404&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;좌 온라인 / 우 모바일&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;푸시 알림 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방향을 정하고 나서 실제로 붙인 흐름을 정리하면 이렇다.&lt;/p&gt;
&lt;pre id=&quot;code_1783234497997&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;사용자
   │
   ▼
'새 글 알림 받기' 버튼 클릭
   │
   ▼
Notification API 권한 요청
   │
   ▼
FCM getToken()
   │
   ▼
fcm_tokens 테이블 저장
   │
   ▼
게시글 작성
   │
   ▼
send_new_post_notification()
   │
   ▼
send_notification()
   │
   ├── notifications 테이블 저장
   │
   └── fcm_tokens 조회
            │
            ▼
     사용자의 모든 토큰 조회
            │
            ▼
     fcm_send_to_token()
            │
            ▼
 Firebase HTTP v1 API
            │
            ▼
      PC / 모바일 푸시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 크게 세 단계로 나눠보면 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 프론트에서 알림 권한을 요청하고 토큰을 발급받는다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();

  if (permission === &quot;granted&quot;) {
    const token = await messaging.getToken({
      vapidKey: &quot;발급받은_VAPID_KEY&quot;
    });

    // 발급받은 토큰을 서버로 전송해서 저장
    fetch(&quot;save_token.php&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ token })
    });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 게시글이 등록되면 구독자를 먼저 조회한다.&lt;/b&gt; 게시글이 등록됐다고 모든 사용자에게 알림을 보내는 건 아니었다. 게시판을 구독한 사용자만 조회하도록 구현했다. 실제로는 send_new_post_notification()에서 먼저 구독자를 가져온다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;$stmt = $pdo-&amp;gt;prepare(&quot;
    SELECT user_id
    FROM board_subscriptions
    WHERE board_type = :type
      AND user_id != :writer_id
&quot;);

$stmt-&amp;gt;execute([
    ':type' =&amp;gt; $board_type,
    ':writer_id' =&amp;gt; $writer_id
]);

$subscribers = $stmt-&amp;gt;fetchAll(PDO::FETCH_COLUMN);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작성자는 제외하고, 구독한 회원에게만 알림을 보내도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. send_notification() 하나에서 모든 알림을 처리하도록 만들었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 게시글 알림, 댓글 알림, 답글 알림마다 각각 FCM을 호출하려고 했다. 그런데 같은 코드가 계속 반복됐다. 그래서 알림 저장 + 푸시 발송을 모두 처리하는 send_notification() 함수를 하나 만들었다. 이 함수는 다음 세 가지를 모두 담당한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;notifications 테이블 저장&lt;/li&gt;
&lt;li&gt;사용자의 FCM 토큰 조회&lt;/li&gt;
&lt;li&gt;모든 토큰으로 푸시 발송&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 먼저 사이트 알림을 저장한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;푸시만 보내면 사용자가 알림을 놓쳤을 때 확인할 방법이 없다. 그래서 가장 먼저 notifications 테이블에 저장하도록 만들었다.&lt;/p&gt;
&lt;pre class=&quot;autohotkey&quot;&gt;&lt;code&gt;INSERT INTO notifications
(
    user_id,
    type,
    target_id,
    message,
    url
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 저장해두면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사이트 안에서도 알림 목록을 볼 수 있고&lt;/li&gt;
&lt;li&gt;읽음 처리도 가능하며&lt;/li&gt;
&lt;li&gt;삭제도 할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 저장된 모든 토큰으로 푸시를 보낸다.&lt;/b&gt; 그다음이 FCM이다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;$stmt = $pdo-&amp;gt;prepare(&quot;
    SELECT token
    FROM fcm_tokens
    WHERE user_id = :uid
&quot;);

$stmt-&amp;gt;execute([
    ':uid' =&amp;gt; $to_user_id
]);

$tokens = $stmt-&amp;gt;fetchAll(PDO::FETCH_COLUMN);

foreach ($tokens as $token) {
    fcm_send_to_token(
        $token,
        $title,
        $message,
        $url
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 fetchAll()을 쓴 이유는, 한 사용자가 PC와 모바일 두 곳 모두 로그인한 경우 두 기기로 모두 알림을 보내기 위해서였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6. 실제 유저가 푸시 알람을 설정하는 방법&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;상단   클릭 후 푸시 켜기 버튼을 누르면&lt;br /&gt;댓글, 답글, 새 글 알림(+개별페이지에서 클릭 필수)을 받을 수 있습니다.&lt;br /&gt;&lt;br /&gt;아이폰은 Safari에서 사이트 접속 후&lt;br /&gt;공유 버튼 &amp;rarr; 홈 화면에 추가 &amp;rarr; 홈 아이콘으로 실행해야 푸시 알림이 작동합니다.&lt;br /&gt;&lt;br /&gt;갤럭시는 Chrome 브라우저에서&lt;br /&gt;푸시 켜기 버튼을 누르면 바로 사용할 수 있습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;7. 그런데 이 구조 때문에 버그도 발생했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 여러 기기를 쓰는 사용자에게는 편리했지만, 개발하면서 테스트를 반복하다 보니 문제가 생겼다. 처음엔 같은 계정으로 PC에서 '새 글 알림 받기' 버튼을 눌러 토큰을 저장했다. 이후 모바일에서도 같은 계정으로 알림을 허용했다. 그런데 개발 과정에서 기존 PC 토큰이 정리되지 않은 채 fcm_tokens 테이블에 그대로 남아 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당장은 두 기기 모두에 알림이 잘 가는 것처럼 보였지만, 초기화가 제대로 안 된 토큰들이 결국 &quot;알림이 중복으로 오는 버그&quot;로 나타났다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 선택이 이후 구현에 준 영향&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 결정은 이후 알림 기능을 구현하는 방식 전체에 영향을 줬다. 앱 하나만 있었다면 &quot;이 사용자에게 보낸다&quot;는 개념으로 끝났을 텐데, &quot;웹사이트 + PWA + FCM&quot; 조합에서는 한 사용자가 여러 토큰(기기)을 가질 수 있다는 걸 구조적으로 감당해야 했다. 바로 위에서 언급한 PC 토큰이 정리되지 않고 남아있던 문제도 결국 이 구조 때문에 생긴 일이었다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돌아보며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱을 만들지 않기로 한 선택으로 배포&amp;middot;심사&amp;middot;업데이트 같은 앱 특유의 운영 부담 없이 빠르게 기능을 붙이고 고칠 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인이 혼자 운영하는 사이드 프로젝트에는 이 쪽이 훨씬 현실적인 선택이었다고 생각한다.&lt;/p&gt;</description>
      <category>REVIEW</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/140</guid>
      <comments>https://mmee2.tistory.com/140#entry140comment</comments>
      <pubDate>Sun, 5 Jul 2026 16:15:37 +0900</pubDate>
    </item>
    <item>
      <title>[사이드 프로젝트 회고 #1]  닷홈빌더를 쓰다가 PHP로 다시 제작한 이유</title>
      <link>https://mmee2.tistory.com/139</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 익명 글쓰기 사이트를 차음에는 닷홈빌더로 제작했다가 PHP로 새로 만들면서 겪은 과정을 정리한 글이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원래는 빌더로 만든 사이트가 있었다&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;최근 친구들과 사용하려고 만든 사이트가 있었다. 익명으로 매달 주제에 맞춰서 글을 올리는 사이트였다. 우리끼리만 편하게 쓸 수 있는 공간을 만들고 싶어서, 간편하고 빠르게 사이트를 띄울 수 있는 닷홈빌더로 먼저 시작했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;워드프레스를 사용해본 나에게는 닷홈빌더가 익숙했다. 디자인 템플릿 고르고, 어느정도 사이트 구색을 맞춰서 친구들에게 공개하기까지 하루 밖에 안 걸렸다. &quot;일단 사람들이 쓸 수 있는 형태로 빨리 내놓자&quot;는 목표에는 정확히 맞는 선택이었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HlEJO/dJMcaa0btuo/FWnXVkCv80YE4ekdh7Zzkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HlEJO/dJMcaa0btuo/FWnXVkCv80YE4ekdh7Zzkk/img.png&quot; data-origin-width=&quot;1461&quot; data-origin-height=&quot;938&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.9992%; margin-right: 10px;&quot; data-widthpercent=&quot;49.58&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HlEJO/dJMcaa0btuo/FWnXVkCv80YE4ekdh7Zzkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHlEJO%2FdJMcaa0btuo%2FFWnXVkCv80YE4ekdh7Zzkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1461&quot; height=&quot;938&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qFjVk/dJMcadCuVMC/5t5U1spYtXuG56IDV1krb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qFjVk/dJMcadCuVMC/5t5U1spYtXuG56IDV1krb0/img.png&quot; data-origin-width=&quot;1467&quot; data-origin-height=&quot;926&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.838%;&quot; data-widthpercent=&quot;50.42&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qFjVk/dJMcadCuVMC/5t5U1spYtXuG56IDV1krb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqFjVk%2FdJMcadCuVMC%2F5t5U1spYtXuG56IDV1krb0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1467&quot; height=&quot;926&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNm9R8/dJMcaf8do90/bxLXCJBbCERn3U5ov6hPS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNm9R8/dJMcaf8do90/bxLXCJBbCERn3U5ov6hPS0/img.png&quot; data-origin-width=&quot;1479&quot; data-origin-height=&quot;925&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.472%; margin-right: 10px;&quot; data-widthpercent=&quot;50.05&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNm9R8/dJMcaf8do90/bxLXCJBbCERn3U5ov6hPS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNm9R8%2FdJMcaf8do90%2FbxLXCJBbCERn3U5ov6hPS0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1479&quot; height=&quot;925&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EcTdy/dJMcaaMFA9a/UCS744EfFoCIHqDj9Cmv6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EcTdy/dJMcaaMFA9a/UCS744EfFoCIHqDj9Cmv6K/img.png&quot; data-origin-width=&quot;1479&quot; data-origin-height=&quot;927&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.3652%;&quot; data-widthpercent=&quot;49.95&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EcTdy/dJMcaaMFA9a/UCS744EfFoCIHqDj9Cmv6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEcTdy%2FdJMcaaMFA9a%2FUCS744EfFoCIHqDj9Cmv6K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1479&quot; height=&quot;927&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그런데 운영을 해보니 보이는 게 있었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 사이트를 열어두고 나서부터였다. 가끔 올라오는 글들을 바로 못보고 놓칠 때가 많았다. 나는 scripttable로 최신글을 가져오는 RSS 위젯을 만들어서 확인했다. 근데 친구들에게 하나하나 방법을 설명하고 해주려고 해도 알겠다고 하면서도 귀찮아서 안하니... 처음에는 수동으로 글이 올라오면 단톡방에 알려줬었는데 점점 피곤했다. 특히 이런 익명 게시판형 서비스는 &quot;글이 올라온 걸 바로바로 아는 맛&quot;이 재미의 절반인데 시간이 지나니 글 조회수도 많이 떨어졌다. 결국 이런 사이트는 &quot;알림이 없으면 재방문이 줄어드는&quot; 구조라는 걸 운영하면서 체감했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;닷홈빌더 안에서 해결할 수 있을까 고민했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 새로 만들자고 결심한 건 아니었다. 먼저 지금 쓰는 빌더 안에서 해결할 방법이 있는지부터 열심히 찾아봤다. 그러다 발견한 게 &lt;a href=&quot;https://dashboard.onesignal.com/apps&quot;&gt;OneSignal&lt;/a&gt;이었다. 웹 푸시를 붙일 수 있게 해주는 서비스다. 무료고.. 워드프레스에서도 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;161&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdIOMU/dJMcabSiBOb/VKwXMxmf20rz0GxQMLBDY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdIOMU/dJMcabSiBOb/VKwXMxmf20rz0GxQMLBDY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdIOMU/dJMcabSiBOb/VKwXMxmf20rz0GxQMLBDY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdIOMU%2FdJMcabSiBOb%2FVKwXMxmf20rz0GxQMLBDY0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;498&quot; height=&quot;161&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;161&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정을 마치니 구독 알림 팝업까지는 정상적으로 떴다. 여기까지는 순조로웠다. 그런데 정작 구독을 눌러도 푸시가 오지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 찾아보니, OneSignal이 웹 푸시를 보내려면 &lt;b&gt;서비스워커 파일을 사이트 루트 폴더에 직접 올려둬야 하는 구조&lt;/b&gt;였다. 브라우저가 이 파일을 통해서 백그라운드에서 푸시 이벤트를 받는 방식이라, 이 파일이 없으면 권한 팝업만 뜨고 실제 푸시는 절대 오지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 진짜 문제는, &lt;b&gt;닷홈빌더로 만든 사이트는 파일질라(FTP)로 폴더에 직접 접속할 수가 없다.&lt;/b&gt; 빌더가 사이트 파일 구조 자체를 감싸서 관리하는 방식이다 보니, 내가 임의로 파일을 하나 추가해 넣을 방법이 없었던 거다. 설정도 다 끝냈고 팝업까지 띄웠는데, 마지막 파일 하나를 못 넣어서 완성이 안 되는 상황이 답답했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;빌더 자체에서 제공하는 알림/푸시 관련 기능이 있는지 확인 &amp;rarr; 없었다&lt;/li&gt;
&lt;li&gt;OneSignal 같은 외부 웹 푸시 서비스로 우회할 수 있는지 확인 &amp;rarr; 설정은 가능했지만, 필수 파일을 서버에 올릴 방법이 없어 결국 막혔다&lt;/li&gt;
&lt;li&gt;이메일 알림 같은 대안이라도 있는지 확인 &amp;rarr; 있어도 사용자가 이메일을 잘 확인하지 않는다는 점에서 실효성이 떨어진다고 판단했다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &quot;&lt;b&gt;파일 하나를 내 마음대로 올릴 수 있는 서버 환경&lt;/b&gt;&quot;이 없으면 웹 푸시 자체가 불가능하다는 걸 이때 알게 됐다. 빌더는 &quot;빠르게 만들 수 있다&quot;는 장점과 정확히 맞바꾼 만큼, 서버 파일 구조를 직접 건드리는 영역에서는 한계가 뚜렷했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 PHP로 새로 만들기로 했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 결론은 하나였다. &lt;b&gt;알림 기능을 제대로 넣으려면 서버 로직을 내가 직접 짤 수 있어야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이왕 다시 만드는 김에 목표를 조금 더 구체적으로 잡았다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;게시글/댓글이 등록되는 시점을 서버 코드로 직접 감지할 수 있을 것&lt;/li&gt;
&lt;li&gt;그 시점에 원하는 대상에게 알림을 보낼 수 있을 것 (누구에게, 어떤 내용으로 보낼지 세밀하게 제어)&lt;/li&gt;
&lt;li&gt;앱을 새로 만들지 않고도, 웹사이트 상태에서 모바일 알림까지 받을 수 있을 것&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3번 조건이 사실 가장 까다로운 부분이었다. &quot;앱스토어에 올리지 않고, 웹사이트만으로 폰에 알림을 보낼 방법이 있을까?&quot;라는 질문에서 이후 FCM(Firebase Cloud Messaging) 웹 푸시를 알아보게 됐는데, 이 부분은 다음 글에서 자세히 풀어보려 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;돌아보며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌더로 사이트를 운영해본 경험이 없었다면 아마 &quot;그냥 처음부터 코드로 만들 걸&quot;이라고 후회했을 수도 있다. 그런데 반대로, 빌더로 먼저 운영해보면서 &lt;b&gt;어떤 기능이 실제로 필요한지를 사용자 반응으로 먼저 검증&lt;/b&gt;할 수 있었던 건 나쁘지 않은 순서였다고 생각한다. 처음부터 완벽하게 설계하려 했다면 오히려 우선순위를 놓쳤을 수도 있었을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>REVIEW</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/139</guid>
      <comments>https://mmee2.tistory.com/139#entry139comment</comments>
      <pubDate>Sun, 5 Jul 2026 00:04:42 +0900</pubDate>
    </item>
    <item>
      <title>[error] Could not initialize class sun.awt.X11GraphicsEnvironment 오류 해결</title>
      <link>https://mmee2.tistory.com/138</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 문제상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring 을 사용하는 화면들에서만 엑셀 다운로드시 아무 반응이 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련 코드 반영한 건 한참 전...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영만 안되고 개발은 또 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 오류확인&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 콘솔 : 500 Internal Server Error&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tomcat 로그 :&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779453509399&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Handler dispatch failed; nested exception is
java.lang.NoClassDefFoundError: Could not initialize class sun.awt.X11GraphicsEnvironment&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 에러 흐름 확인&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779453564726&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;엑셀 다운로드 클릭
&amp;darr;
SXSSFWorkbook.createSheet()
&amp;darr;
Apache POI가 엑셀 시트 생성하면서 기본 글자 너비 계산 시도
&amp;darr;
서버의 폰트/X11 그래픽 환경 초기화 실패
&amp;darr;
500 에러&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 원인 파악&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apache POI + SXSSFWorkbook + 폰트 계산 때문에 터진 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아보니 apache POI가 엑셀 컬럼 너비를 계산하다가 기본 글자 폭을 계산하는 과정에서 서버의 폰트 정보를 읽으려고 했고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 Java의 AWT 그래픽 환경이 초기화 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 운영 서버는 일반적으로 GUI 환경이 없는 리눅스 서버이기 때문에 X11GraphicsEnvironment 초기화에 실패했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring&amp;nbsp;Boot&amp;nbsp;애플리케이션은&amp;nbsp;실행&amp;nbsp;시&amp;nbsp;내부적으로&amp;nbsp;java.awt.headless=true&amp;nbsp;속성을&amp;nbsp;기본값으로&amp;nbsp;자동&amp;nbsp;설정하는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경설정이 뭔가 초기화 된 거 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. 해결방법&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣을 사용하고 있다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tomcat/bin/catalina.sh 파일 제일 위에 아래 코드를 넣어주면 된다. 그리고 톰캣 재기동&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779455107830&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash    #맨 윗줄

export CATALINA_OPTS=&quot;$CATALINA_OPTS -Djava.awt.headless=true&quot;    #입력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;JVM에게 &quot;이 서버는 모니터 화면이 없는(Headless) 환경이니, 화면 출력 시스템(X11)을 찾지 말고 메모리 상에서 자체적으로 폰트 크기만 계산해라&quot;라고 지시하여 에러를 우회한 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;(참고)&lt;/p&gt;
&lt;pre id=&quot;code_1779455338989&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;vi catalina.sh 		#vi 편집기로 셸 파일 열기
파일 수정 후-&amp;gt; esc -&amp;gt; :wq! -&amp;gt; enter    #vi 편집기 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 해결됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끝&lt;/p&gt;</description>
      <category>SPRING BOOT</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/138</guid>
      <comments>https://mmee2.tistory.com/138#entry138comment</comments>
      <pubDate>Fri, 22 May 2026 22:10:31 +0900</pubDate>
    </item>
    <item>
      <title>[LINUX] 셸 스크립트를 자동으로 실행시키는 크론탭(crontab) 사용하기</title>
      <link>https://mmee2.tistory.com/137</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;특정 폴더에 있는 파일들을 매월 1일에 자동으로 옮기고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 셸 스크립트를 매월 1일에 자동으로 실행하는 크론탭을 사용하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;진행 흐름정리&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉘스크립트&amp;nbsp;파일을&amp;nbsp;먼저&amp;nbsp;만들고&amp;nbsp;&amp;rarr;&amp;nbsp;수동&amp;nbsp;테스트하고&amp;nbsp;&amp;rarr;&amp;nbsp;크론탭에&amp;nbsp;등록&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전체 순서&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.&amp;nbsp;쉘스크립트&amp;nbsp;파일&amp;nbsp;생성 &lt;br /&gt;2.&amp;nbsp;스크립트&amp;nbsp;내용&amp;nbsp;작성 &lt;br /&gt;3.&amp;nbsp;실행&amp;nbsp;권한&amp;nbsp;부여 &lt;br /&gt;4.&amp;nbsp;수동&amp;nbsp;실행&amp;nbsp;테스트 &lt;br /&gt;5.&amp;nbsp;로그&amp;nbsp;확인 &lt;br /&gt;6. 크론탭 테스트 등록&lt;br /&gt;7. 실제 매월 1일 스케줄로 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계.&amp;nbsp;쉘스크립트&amp;nbsp;파일&amp;nbsp;만들기&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779288081755&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;vi /log.sh #파일 생성/수정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계. 스크립트 내용 작성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력 모드 : i&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 방법 : ESC =&amp;gt; :wq =&amp;gt; Enter&lt;/p&gt;
&lt;pre id=&quot;code_1779288436413&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash

# 1. 경로 및 기준일 설정
SRC_DIR=&quot;/var/log/myapp&quot;
DEST_DIR=&quot;/backup/log/myapp&quot;
TODAY=$(date +%Y%m%d)

echo &quot;[$TODAY] 로그 이동 작업을 시작합니다.&quot;
mkdir -p &quot;$DEST_DIR&quot;

# 2. 로그 파일 확인 및 이동 (for문 사용)
for file in &quot;$SRC_DIR&quot;/log.log.*; do
    
    # 파일이 실제로 존재하는지 확인
    [ -f &quot;$file&quot; ] || continue
    
    # 파일명 끝의 날짜(YYYYMMDD)만 추출
    file_date=&quot;${file##*.}&quot;

    # 3. 추출한 날짜가 오늘보다 과거일 경우(-lt)에만 이동
    # 2&amp;gt;/dev/null 은 날짜가 아닌 문자가 섞였을 때 나는 에러를 조용히 무시합니다.
    if [ &quot;$file_date&quot; -lt &quot;$TODAY&quot; ] 2&amp;gt;/dev/null; then
        mv &quot;$file&quot; &quot;$DEST_DIR/&quot;
        echo &quot; -&amp;gt; 이동 완료: $(basename &quot;$file&quot;)&quot;
    fi

done

echo &quot;로그 이동 작업이 종료되었습니다.&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크립트&amp;nbsp;파일&amp;nbsp;확인&lt;/p&gt;
&lt;pre id=&quot;code_1779289176532&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;nl -ba /log.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계. 실행 권한 부여&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779289210583&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;chmod 755 /log.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한부여 확인&lt;/p&gt;
&lt;pre id=&quot;code_1779289287424&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ls -al /log.sh
#실행결과 -rwxr-xr-x  x가 있는걸 확인&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4단계. 수동 실행 테스트 + 로그 남기기 / tail 로 log 결과 확인&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779289380264&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/log.sh &amp;gt;&amp;gt; /log.log 2&amp;gt;&amp;amp;1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5단계. 크론탭 백업&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779289468666&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;crontab -l &amp;gt; ~/crontab_backup_$(date +%Y%m%d).txt&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백업 확인&lt;/p&gt;
&lt;pre id=&quot;code_1779289485632&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ls -al ~/crontab_backup_*.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;6단계. 크론탭 테스트 등록 ( 매분 실행 테스트 )&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779289567523&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;crontab -e
* * * * * /log.sh &amp;gt;&amp;gt; log.log 2&amp;gt;&amp;amp;1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 후 확인&lt;/p&gt;
&lt;pre id=&quot;code_1779289603219&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;crontab -l
tail -f log.log&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;7단계.실제 매월 1일 스케줄 등록 &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매월 1일 새벽 1시 0분에 log.sh 실행하고 결과는 log.log에 저장&lt;/p&gt;
&lt;pre id=&quot;code_1779289672589&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;crontab -e
0 1 1 * * /log.sh &amp;gt;&amp;gt; /log.log 2&amp;gt;&amp;amp;1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(로그에 대한 고민&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;로그를 연속으로 쌓을건지, 날짜 별로 쌓을건지&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;로그는 쉘스크립트 내부에서 로그 저장할지, 크론탭 자체적으로 로그를 만들어 저장할지&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;정리는 다음 글에서 정리)&lt;/p&gt;</description>
      <category>SERVER</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/137</guid>
      <comments>https://mmee2.tistory.com/137#entry137comment</comments>
      <pubDate>Thu, 21 May 2026 00:14:27 +0900</pubDate>
    </item>
    <item>
      <title>[TIL] PHP로 DB 다루기: MySQLi vs PDO, 무엇을 선택할까?</title>
      <link>https://mmee2.tistory.com/136</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;엄청 오랜만에 PHP... 기억이 하나도 안난다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;개인적으로 새로운 PHP 프로젝트를 시작하려는데,&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;데이터베이스를 연결 하는 단계에서 쓸 수 있는게&amp;nbsp;&amp;nbsp;&lt;b data-path-to-node=&quot;4&quot; data-index-in-node=&quot;7&quot;&gt;MySQLi&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;와&amp;nbsp;&lt;/span&gt;&lt;b data-path-to-node=&quot;4&quot; data-index-in-node=&quot;15&quot;&gt;PDO &lt;/b&gt;가 있었다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;데이터베이스(DB)를 어떤 방식으로 연결하고 관리할 것인가? 에 대한 고민&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 써보는&amp;nbsp;&lt;b data-index-in-node=&quot;7&quot; data-path-to-node=&quot;4&quot;&gt;MySQLi&lt;/b&gt;와 &lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;4&quot;&gt;PDO 정리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6&quot;&gt;1. &lt;b data-path-to-node=&quot;7,0,0&quot; data-index-in-node=&quot;0&quot;&gt;MySQLi 와 &lt;b data-path-to-node=&quot;7,1,0&quot; data-index-in-node=&quot;0&quot;&gt;PDO 란?&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;7&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,0,0&quot;&gt;MySQLi (MySQL Improved):&lt;/b&gt; 딱 이름부터 &quot;나 MySQL 전용이야&quot;라고 말하고 있음. MySQL/MariaDB에 최적화된 드라이버.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,1,0&quot;&gt;PDO (PHP Data Objects):&lt;/b&gt; 일종의 '통번역기' . MySQL뿐만 아니라 SQLite, PostgreSQL 등 다양한 DB를 같은 방식으로 다룰 수 있게 해주는 추상화 계층.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;2. &lt;/b&gt;&lt;b data-path-to-node=&quot;6&quot; data-index-in-node=&quot;0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,0,0&quot;&gt;MySQLi&amp;nbsp;와&amp;nbsp;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,1,0&quot;&gt;PDO &lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;b&gt;비교&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;MySQLi&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;PDO&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;지원 DB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;오직 MySQL/MariaDB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;12종 이상의 다양한 DB&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;코딩 스타일&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;절차지향 + 객체지향&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,2,2,0&quot;&gt;객체지향 (Modern)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Named Parameter&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;지원 안 함 (? 사용)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,3,2,0&quot;&gt;지원 (:name 사용)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;에러 처리&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;조금 번거로움&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Exception(예외) 처리 우수&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;성능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;미세하게 빠름&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;아주 미세하게 느림&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;3.&amp;nbsp;사용법&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt; [MySQLi 방식] :위치 기반&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777959719825&quot; class=&quot;php&quot; data-ke-language=&quot;php&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$stmt = $mysqli-&amp;gt;prepare(&quot;INSERT INTO users (name, email, age) VALUES (?, ?, ?)&quot;);
// ?가 많아지면 나중에 힘듦
$stmt-&amp;gt;bind_param(&quot;ssi&quot;, $name, $email, $age);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;[PDO 방식] : 이름 기반&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777959741651&quot; class=&quot;php&quot; data-ke-language=&quot;php&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$stmt = $pdo-&amp;gt;prepare(&quot;INSERT INTO users (name, email, age) VALUES (:name, :email, :age)&quot;);
// 순서 상관없이 이름으로 매칭
$stmt-&amp;gt;execute(['name' =&amp;gt; $name, 'email' =&amp;gt; $email, 'age' =&amp;gt; $age]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;27&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27&quot;&gt;4. 추천 방식?&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;29&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,0,0&quot;&gt;MySQLi를 추천하는 경우:&lt;/b&gt;&lt;/span&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;29,0,1&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;오직 MySQL만 사용함이 확실하고&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;극한의 성능 최적화가 필요한 특수한 상황일 때&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;29,1,0&quot;&gt;PDO를 추천하는 경우:&lt;/b&gt;&lt;/span&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;29,1,1&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;현대적인 PHP 개발 표준을 따르고 싶을 때&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;가독성 좋고 유지보수가 쉬운 코드를 원할 때&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333;&quot;&gt;미래에 DB가 바뀔 가능성이 단 1%라도 있을 때&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot; data-index-in-node=&quot;7&quot; data-path-to-node=&quot;4&quot;&gt;나의 경우, &lt;/span&gt;&lt;b data-index-in-node=&quot;15&quot; data-path-to-node=&quot;4&quot;&gt;PDO&amp;nbsp;&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;방식을 선택했다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;MySQLi는 MySQL 계열의 DB에서만 작동하지만, PDO는 다른 디비도 지원가능하다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그리고 이름을 직접 지정할 수 있어서 코드가 훨씬 직관적임&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px; color: #333333;&quot;&gt;끝.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>REVIEW</category>
      <category>MariaDB</category>
      <category>mysqli</category>
      <category>PDO</category>
      <category>php</category>
      <category>TIL</category>
      <category>백엔드</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/136</guid>
      <comments>https://mmee2.tistory.com/136#entry136comment</comments>
      <pubDate>Tue, 5 May 2026 14:51:06 +0900</pubDate>
    </item>
  </channel>
</rss>