<?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>Fri, 17 Jul 2026 23:52:29 +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>[사이드 프로젝트 회고 #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>
      <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>
      <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>
      <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>
      <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>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>
    <item>
      <title>[javascript/jquery]&amp;nbsp;자바스크립트&amp;nbsp;url&amp;nbsp;파라미터&amp;nbsp;숨기기 (history.replaceState)</title>
      <link>https://mmee2.tistory.com/135</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
  &lt;meta charset=&quot;UTF-8&quot;&gt;
  &lt;title&gt;history.replaceState로 URL 파라미터 숨기기&lt;/title&gt;
  &lt;style&gt;
    /* 티스토리 붙여넣기용 스타일 — &lt;style&gt; 태그째로 HTML 편집기에 붙여넣으세요 */
    .post-wrap {
      font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
      font-size: 15px;
      line-height: 1.85;
      color: #1e1e1e;
      max-width: 760px;
      margin: 0 auto;
      padding: 0 16px;
    }
    .post-wrap h2 {
      font-size: 22px;
      font-weight: 700;
      margin: 48px 0 16px;
      padding-bottom: 10px;
      border-bottom: 2px solid #4f46e5;
      color: #1a1a2e;
    }
    .post-wrap h3 {
      font-size: 17px;
      font-weight: 700;
      margin: 32px 0 12px;
      color: #2d2d2d;
    }
    .post-wrap p {
      margin: 12px 0;
    }
    .post-wrap pre {
      background: #1e1e2e;
      color: #cdd6f4;
      border-radius: 10px;
      padding: 20px 22px;
      overflow-x: auto;
      font-family: 'D2Coding', 'Fira Code', 'Courier New', monospace;
      font-size: 13.5px;
      line-height: 1.7;
      margin: 16px 0;
    }
    .post-wrap code.inline {
      background: #ede9fe;
      color: #4f46e5;
      padding: 2px 7px;
      border-radius: 4px;
      font-family: 'D2Coding', monospace;
      font-size: 13.5px;
    }
    .post-wrap .highlight-box {
      background: #f5f3ff;
      border-left: 4px solid #4f46e5;
      border-radius: 0 8px 8px 0;
      padding: 14px 18px;
      margin: 20px 0;
      font-size: 14.5px;
    }
    .post-wrap .tip-box {
      background: #fff7ed;
      border-left: 4px solid #f97316;
      border-radius: 0 8px 8px 0;
      padding: 14px 18px;
      margin: 20px 0;
      font-size: 14.5px;
    }
    .post-wrap table {
      width: 100%;
      border-collapse: collapse;
      margin: 16px 0;
      font-size: 14px;
    }
    .post-wrap table th {
      background: #4f46e5;
      color: #fff;
      padding: 10px 14px;
      text-align: left;
    }
    .post-wrap table td {
      padding: 9px 14px;
      border-bottom: 1px solid #e5e7eb;
    }
    .post-wrap table tr:nth-child(even) td {
      background: #f9fafb;
    }
    .post-wrap .flow-box {
      background: #f8fafc;
      border: 1px solid #e2e8f0;
      border-radius: 10px;
      padding: 18px 22px;
      margin: 16px 0;
      font-family: 'D2Coding', monospace;
      font-size: 13.5px;
      line-height: 2;
      color: #334155;
    }
    .post-wrap .flow-box .arrow {
      color: #4f46e5;
      font-size: 18px;
    }
    .post-wrap .tag-list {
      margin-top: 48px;
      font-size: 13px;
      color: #6b7280;
    }
    .post-wrap .tag-list span {
      display: inline-block;
      background: #f3f4f6;
      border-radius: 99px;
      padding: 3px 10px;
      margin: 3px 2px;
    }

    /* 코드 컬러 */
    .kw  { color: #cba6f7; }  /* 키워드: function, const 등 */
    .fn  { color: #89dceb; }  /* 함수명 */
    .str { color: #a6e3a1; }  /* 문자열 */
    .cm  { color: #6c7086; font-style: italic; }  /* 주석 */
    .prop{ color: #fab387; }  /* 프로퍼티 */
    .num { color: #f38ba8; }  /* 숫자 */
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;post-wrap&quot;&gt;

  &lt;p&gt;웹 개발 중 파라미터가 노출된 URL을 지워야하는 상황&lt;/p&gt;
  &lt;pre&gt;https://example.com/search?keyword=자바스크립트&amp;amp;userId=1234&amp;amp;token=abcxyz&lt;/pre&gt;
  &lt;p&gt;보안이 중요한 데이터가 주소창에 그대로 보이거나, URL을 깔끔하게 유지하고 싶은 상황에서 유용하게 쓸 수 있는 방법을 정리했다.&lt;/p&gt;

  &lt;div class=&quot;highlight-box&quot;&gt;
      &lt;strong&gt;history.replaceState({}, null, location.pathname)&lt;/strong&gt; 한 줄로 URL 파라미터를 숨기는 방법
  &lt;/div&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;✅ 핵심 코드 한 줄&lt;/h2&gt;

  &lt;pre&gt;&lt;span class=&quot;prop&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;({}, &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;, location.pathname);&lt;/pre&gt;

  &lt;p&gt;이 한 줄이 하는 일은 &lt;strong&gt;현재 URL에서 쿼리스트링(&lt;code class=&quot;inline&quot;&gt;?&lt;/code&gt; 이후)을 전부 제거하고, 경로(path)만 남긴다.&lt;/strong&gt; 페이지는 새로고침되지 않는다.&lt;/p&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;  인자 하나씩 뜯어보기&lt;/h2&gt;

  &lt;pre&gt;&lt;span class=&quot;fn&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;( state,  title,  url )
                      ↓       ↓       ↓
&lt;span class=&quot;fn&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;( {},     &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;,   location.pathname )&lt;/pre&gt;

  &lt;h3&gt;① {} — state 객체&lt;/h3&gt;
  &lt;p&gt;히스토리에 함께 저장할 데이터다. 빈 객체 &lt;code class=&quot;inline&quot;&gt;{}&lt;/code&gt;를 넘겼다는 건, 따로 저장할 데이터 없이 URL만 바꾸겠다는 의미다.&lt;/p&gt;
  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;// 데이터를 함께 저장하고 싶다면 이렇게&lt;/span&gt;
&lt;span class=&quot;prop&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;({ &lt;span class=&quot;prop&quot;&gt;keyword&lt;/span&gt;: &lt;span class=&quot;str&quot;&gt;'검색어'&lt;/span&gt; }, &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;, location.pathname);&lt;/pre&gt;

  &lt;h3&gt;② null — title&lt;/h3&gt;
  &lt;p&gt;원래 페이지 타이틀을 지정하는 인자인데, &lt;strong&gt;현재 모든 브라우저가 이 값을 무시한다.&lt;/strong&gt; 관례적으로 &lt;code class=&quot;inline&quot;&gt;null&lt;/code&gt; 또는 &lt;code class=&quot;inline&quot;&gt;&quot;&quot;&lt;/code&gt;을 넣는다.&lt;/p&gt;

  &lt;h3&gt;③ location.pathname — 변경할 URL&lt;/h3&gt;
  &lt;p&gt;여기가 핵심이다. &lt;code class=&quot;inline&quot;&gt;location&lt;/code&gt; 객체의 속성들을 비교해보면 이해가 쉽다.&lt;/p&gt;

  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;// 현재 URL: https://example.com/search?keyword=테스트&amp;amp;page=1&lt;/span&gt;

location.pathname  &lt;span class=&quot;cm&quot;&gt;→  &quot;/search&quot;                           (? 이전 경로만)&lt;/span&gt;
location.search    &lt;span class=&quot;cm&quot;&gt;→  &quot;?keyword=테스트&amp;amp;page=1&quot;   (쿼리스트링)&lt;/span&gt;
location.href      &lt;span class=&quot;cm&quot;&gt;→  &quot;https://example.com/search?keyword=테스트&amp;amp;page=1&quot;&lt;/span&gt;&lt;/pre&gt;

  &lt;p&gt;&lt;code class=&quot;inline&quot;&gt;location.pathname&lt;/code&gt;만 넘기면 쿼리스트링이 포함되지 않으므로, URL에서 파라미터가 사라진다.&lt;/p&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;⚙️ 동작 흐름&lt;/h2&gt;

  &lt;div class=&quot;flow-box&quot;&gt;
    페이지 진입&lt;br&gt;
    &lt;span class=&quot;arrow&quot;&gt;↓&lt;/span&gt; https://example.com/search?keyword=테스트&amp;amp;page=1&lt;br&gt;&lt;br&gt;
    파라미터 읽어서 처리 (검색 실행 등)&lt;br&gt;
    &lt;span class=&quot;arrow&quot;&gt;↓&lt;/span&gt;&lt;br&gt;&lt;br&gt;
    history.replaceState({}, null, location.pathname);&lt;br&gt;
    &lt;span class=&quot;arrow&quot;&gt;↓&lt;/span&gt;&lt;br&gt;&lt;br&gt;
    주소창 URL 변경&lt;br&gt;
    &lt;span class=&quot;arrow&quot;&gt;→&lt;/span&gt; https://example.com/search &amp;nbsp;&amp;nbsp; ✅ 파라미터 사라짐&lt;br&gt;
    &lt;span style=&quot;color:#94a3b8; font-size:12px;&quot;&gt;(페이지 새로고침 없음, 히스토리 스택 교체)&lt;/span&gt;
  &lt;/div&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;  pushState vs replaceState&lt;/h2&gt;

  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;// replaceState: 현재 히스토리를 덮어씀&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;// → 뒤로가기 해도 파라미터 있던 URL로 못 돌아감&lt;/span&gt;
&lt;span class=&quot;prop&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;({}, &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;, location.pathname);

&lt;span class=&quot;cm&quot;&gt;// pushState: 새 히스토리를 쌓음&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;// → 뒤로가기 하면 파라미터 있던 URL로 돌아감&lt;/span&gt;
&lt;span class=&quot;prop&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;pushState&lt;/span&gt;({}, &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;, location.pathname);&lt;/pre&gt;

  &lt;table&gt;
    &lt;tr&gt;
      &lt;th&gt;메서드&lt;/th&gt;
      &lt;th&gt;히스토리 처리&lt;/th&gt;
      &lt;th&gt;뒤로가기 시&lt;/th&gt;
      &lt;th&gt;적합한 상황&lt;/th&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;inline&quot;&gt;replaceState&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;현재 항목 교체&lt;/td&gt;
      &lt;td&gt;파라미터 URL 차단 ✅&lt;/td&gt;
      &lt;td&gt;파라미터 완전히 제거&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;inline&quot;&gt;pushState&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;새 항목 추가&lt;/td&gt;
      &lt;td&gt;파라미터 URL 복원&lt;/td&gt;
      &lt;td&gt;히스토리 유지 필요 시&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/table&gt;

  &lt;p&gt;파라미터를 &lt;strong&gt;완전히 지워버리고 싶을 때&lt;/strong&gt;는 &lt;code class=&quot;inline&quot;&gt;replaceState&lt;/code&gt;가 맞다. 뒤로가기로 파라미터가 다시 노출되는 상황을 막을 수 있기 때문이다.&lt;/p&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;  실제 활용 패턴&lt;/h2&gt;

  &lt;h3&gt;기본 패턴 — 파라미터 읽고 → 즉시 제거&lt;/h3&gt;

  &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;const&lt;/span&gt; params = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;fn&quot;&gt;URLSearchParams&lt;/span&gt;(location.search);
&lt;span class=&quot;kw&quot;&gt;const&lt;/span&gt; keyword = params.&lt;span class=&quot;fn&quot;&gt;get&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;'keyword'&lt;/span&gt;);

&lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (keyword) {
  &lt;span class=&quot;cm&quot;&gt;// ① 파라미터 값으로 필요한 처리 먼저&lt;/span&gt;
  &lt;span class=&quot;fn&quot;&gt;doSearch&lt;/span&gt;(keyword);

  &lt;span class=&quot;cm&quot;&gt;// ② 처리 후 URL에서 파라미터 제거&lt;/span&gt;
  &lt;span class=&quot;prop&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;({}, &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;, location.pathname);
}&lt;/pre&gt;

  &lt;div class=&quot;tip-box&quot;&gt;
    ⚠️ &lt;strong&gt;순서가 중요하다.&lt;/strong&gt; 반드시 파라미터를 읽고 처리한 후에 제거해야 한다. 제거 후 읽으면 이미 파라미터가 없으므로 &lt;code class=&quot;inline&quot;&gt;null&lt;/code&gt;이 반환된다.
  &lt;/div&gt;

  &lt;h3&gt;jQuery AJAX 조합 패턴&lt;/h3&gt;

  &lt;pre&gt;$(&lt;span class=&quot;str&quot;&gt;'#searchForm'&lt;/span&gt;).&lt;span class=&quot;fn&quot;&gt;on&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;'submit'&lt;/span&gt;, &lt;span class=&quot;kw&quot;&gt;function&lt;/span&gt;(e) {
  e.&lt;span class=&quot;fn&quot;&gt;preventDefault&lt;/span&gt;();

  &lt;span class=&quot;kw&quot;&gt;const&lt;/span&gt; keyword = $(&lt;span class=&quot;str&quot;&gt;'#keyword'&lt;/span&gt;).&lt;span class=&quot;fn&quot;&gt;val&lt;/span&gt;();

  $.&lt;span class=&quot;fn&quot;&gt;ajax&lt;/span&gt;({
    url: &lt;span class=&quot;str&quot;&gt;'/api/search'&lt;/span&gt;,
    method: &lt;span class=&quot;str&quot;&gt;'GET'&lt;/span&gt;,
    data: { keyword: keyword },
    success: &lt;span class=&quot;kw&quot;&gt;function&lt;/span&gt;(response) {
      &lt;span class=&quot;cm&quot;&gt;// AJAX로 데이터를 가져오되, URL에는 파라미터 노출하지 않음&lt;/span&gt;
      &lt;span class=&quot;prop&quot;&gt;history&lt;/span&gt;.&lt;span class=&quot;fn&quot;&gt;replaceState&lt;/span&gt;({}, &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;, location.pathname);
      &lt;span class=&quot;fn&quot;&gt;renderResult&lt;/span&gt;(response);
    }
  });
});&lt;/pre&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;⚠️ 주의사항&lt;/h2&gt;

  &lt;table&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;내용&lt;/th&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;새로고침 시 state 소멸&lt;/td&gt;
      &lt;td&gt;replaceState에 저장한 state는 새로고침하면 사라진다. 중요한 데이터는 &lt;code class=&quot;inline&quot;&gt;sessionStorage&lt;/code&gt;와 병행 사용 권장&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;서버 라우팅 주의&lt;/td&gt;
      &lt;td&gt;존재하지 않는 URL로 변경 후 새로고침하면 404 발생 가능. 서버에서 해당 경로를 처리할 수 있어야 함&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;완전한 보안 X&lt;/td&gt;
      &lt;td&gt;URL에서 숨길 뿐, 클라이언트 단의 조작이다. 민감한 데이터는 POST + HTTPS와 함께 서버 인증을 병행해야 함&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/table&gt;

  &lt;!-- ======================== --&gt;
  &lt;h2&gt;  한 줄 요약&lt;/h2&gt;

  &lt;div class=&quot;highlight-box&quot;&gt;
    &lt;code class=&quot;inline&quot;&gt;history.replaceState({}, null, location.pathname)&lt;/code&gt; 는 &lt;strong&gt;&quot;URL은 깔끔하게 바꾸되, 페이지는 그대로 유지&quot;&lt;/strong&gt; 하는 가장 심플한 파라미터 은닉 방법이다.
  &lt;/div&gt;


&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>javascript</category>
      <category>jQuery</category>
      <category>url파라미터</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/135</guid>
      <comments>https://mmee2.tistory.com/135#entry135comment</comments>
      <pubDate>Sun, 15 Mar 2026 21:39:58 +0900</pubDate>
    </item>
    <item>
      <title>[error] 이클립스 안 켜짐 해결방법 (로딩화면에서 응답없음)</title>
      <link>https://mmee2.tistory.com/134</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;439&quot; data-origin-height=&quot;291&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cuJSLn/dJMcajnyigN/DIuCNLDCj0zz0amZ7dY36k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuJSLn/dJMcajnyigN/DIuCNLDCj0zz0amZ7dY36k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuJSLn/dJMcajnyigN/DIuCNLDCj0zz0amZ7dY36k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuJSLn%2FdJMcajnyigN%2FDIuCNLDCj0zz0amZ7dY36k%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;439&quot; height=&quot;291&quot; data-origin-width=&quot;439&quot; data-origin-height=&quot;291&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;blockquote data-ke-style=&quot;style2&quot;&gt;나의 상황&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이클립스를 열어야되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로딩되다가 중간에 자꾸만 멈춰서 응답없음 상태가 되는 상황&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;원인&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;용량 정리한다고 바탕화면과 폴더 정리를 했는데 뭔가 그때 이클립스 폴더도 왔다갔다 해서 그런지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이클립스 상태 정보 파일이 꼬인 것 같았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이클립스가 켜지면서 그 파일이 깨져 있으니 무한 루프에 빠져버린 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;해결방법&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cmd를 열어서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이클립스 설치 경로로 이동하여 아래 명령어를 입력해 실행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1770640960622&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;eclipse.exe -clearPersistedState&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;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;/p&gt;</description>
      <category>오류해결</category>
      <category>이클립스</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/134</guid>
      <comments>https://mmee2.tistory.com/134#entry134comment</comments>
      <pubDate>Mon, 9 Feb 2026 21:46:25 +0900</pubDate>
    </item>
    <item>
      <title>[LINUX] 서버 용량(디스크) 체크하는 방법 (du/df)</title>
      <link>https://mmee2.tistory.com/133</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;/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;/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. 서버 전체 디스크 용량 확인&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769522771619&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;df -h&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_1769522822868&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        50G   46G  2.0G  96% /
tmpfs           1.9G     0  1.9G   0% /dev/shm
/dev/sdb1       100G   20G   75G  22% /home&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;size : 전체용량&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;used : 사용중인 용량&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;avail : 남은 용량&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;use % : 사용률&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mounted on : 실제 경로&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. 디렉토리 별로 뭐가 제일 많이 차지하고 있는지 확인&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769522948777&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;du -h | sort -rh | head -n 10&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_1769523023326&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;12G     ./var
9.5G    ./var/log
4.8G    ./var/log/syslog
3.9G    ./var/log/nginx/access.log
1.2G    ./var/lib/docker&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;
&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;
&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;</description>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/133</guid>
      <comments>https://mmee2.tistory.com/133#entry133comment</comments>
      <pubDate>Tue, 27 Jan 2026 23:16:40 +0900</pubDate>
    </item>
    <item>
      <title>[HTML/CSS] 홈페이지에 rss로 최신 뉴스기사 불러오기 (닷홈빌더 활용)</title>
      <link>https://mmee2.tistory.com/132</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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;닷홈빌더로 만든 홈페이지에 rss로 최신 뉴스기사 불러오기&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;1901&quot; data-origin-height=&quot;931&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZvLFD/dJMcajnszgl/ECekc5dec29KQrgADHkOQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZvLFD/dJMcajnszgl/ECekc5dec29KQrgADHkOQ1/img.png&quot; data-alt=&quot;사이트 이름은 가렸음&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZvLFD/dJMcajnszgl/ECekc5dec29KQrgADHkOQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZvLFD%2FdJMcajnszgl%2FECekc5dec29KQrgADHkOQ1%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;1901&quot; height=&quot;931&quot; data-origin-width=&quot;1901&quot; data-origin-height=&quot;931&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;사이트 이름은 가렸음&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;
&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1889&quot; data-origin-height=&quot;926&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MkuiI/dJMcadALPH2/OhX9ceKmCPaVYlpVteCUqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MkuiI/dJMcadALPH2/OhX9ceKmCPaVYlpVteCUqk/img.png&quot; data-alt=&quot;완성 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MkuiI/dJMcadALPH2/OhX9ceKmCPaVYlpVteCUqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMkuiI%2FdJMcadALPH2%2FOhX9ceKmCPaVYlpVteCUqk%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;1889&quot; height=&quot;926&quot; data-origin-width=&quot;1889&quot; data-origin-height=&quot;926&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;완성 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;1. 프레임을 나누고 요소 코드를 추가해준다.&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/kXboj/dJMcajud10r/ty7nMeenCt1i5M2naSoG5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kXboj/dJMcajud10r/ty7nMeenCt1i5M2naSoG5k/img.png&quot; data-origin-width=&quot;447&quot; data-origin-height=&quot;885&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;58.61&quot; style=&quot;width: 57.9309%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kXboj/dJMcajud10r/ty7nMeenCt1i5M2naSoG5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkXboj%2FdJMcajud10r%2Fty7nMeenCt1i5M2naSoG5k%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;447&quot; height=&quot;885&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEP7qE/dJMcaajNAPS/GLaid15LBsGjnO5EEiSaH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEP7qE/dJMcaajNAPS/GLaid15LBsGjnO5EEiSaH0/img.png&quot; data-origin-width=&quot;311&quot; data-origin-height=&quot;872&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;41.39&quot; style=&quot;width: 40.9063%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEP7qE/dJMcaajNAPS/GLaid15LBsGjnO5EEiSaH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEP7qE%2FdJMcaajNAPS%2FGLaid15LBsGjnO5EEiSaH0%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;311&quot; height=&quot;872&quot;/&gt;&lt;/span&gt;&lt;/div&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1895&quot; data-origin-height=&quot;669&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qg2Ip/dJMcai29tyV/4uldwhalKZU1f3CtwyX9QK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qg2Ip/dJMcai29tyV/4uldwhalKZU1f3CtwyX9QK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qg2Ip/dJMcai29tyV/4uldwhalKZU1f3CtwyX9QK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqg2Ip%2FdJMcai29tyV%2F4uldwhalKZU1f3CtwyX9QK%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;669&quot; data-origin-width=&quot;1895&quot; data-origin-height=&quot;669&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 코드를 입력하고 적용하기&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cC3i2b/dJMcaia4qQs/CP15OkCZFYhcTh2Kk44gY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cC3i2b/dJMcaia4qQs/CP15OkCZFYhcTh2Kk44gY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cC3i2b/dJMcaia4qQs/CP15OkCZFYhcTh2Kk44gY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcC3i2b%2FdJMcaia4qQs%2FCP15OkCZFYhcTh2Kk44gY1%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;792&quot; height=&quot;432&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1769440401289&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;style&amp;gt;
    .news-widget-box {
        font-family: 'Malgun Gothic', sans-serif;
        border: 1px solid #ddd;
        border-radius: 10px;
        background: #fff;
        overflow: hidden;
        max-width: 100%;
        margin-bottom: 20px; /* 박스 간 간격 */
        box-shadow: 0 2px 5px rgba(0,0,0,0.05);
    }
    .news-widget-header {
        background: #0b0e23; /* 배경색 */
        color: #fff;
        padding: 15px;
        font-weight: bold;
        font-size: 16px;
        display: flex;
        justify-content: space-between;
        align-items: center;
    }
    .news-widget-list { padding: 0; margin: 0; list-style: none; }
    .news-widget-item { padding: 15px; border-bottom: 1px solid #eee; transition: background 0.2s; }
    .news-widget-item:hover { background-color: #f9fbfc; }
    .news-widget-item a { text-decoration: none; color: #333; font-size: 15px; font-weight: 500; display: block; margin-bottom: 5px; }
    .news-widget-item a:hover { color: #1a73e8; text-decoration: underline; }
    .news-widget-meta { font-size: 12px; color: #888; }
    .news-loading { padding: 20px; text-align: center; color: #666; }
&amp;lt;/style&amp;gt;

&amp;lt;script&amp;gt;
    // 이 함수가 뉴스를 가져와서 박스에 넣어줍니다.
    function loadNewsWidget(boxId, searchKeyword, titleText) {
        // 1. 박스 찾기
        const box = document.getElementById(boxId);
        if (!box) return;

        // 2. 제목 설정
        const titleArea = box.querySelector('.widget-title-text');
        if (titleArea) titleArea.innerText = titleText || searchKeyword + &quot; 뉴스&quot;;

        // 3. RSS 주소 생성
        const baseUrl = &quot;https://news.google.com/rss/search&quot;;
        const params = `?q=${encodeURIComponent(searchKeyword)}&amp;amp;hl=ko&amp;amp;gl=KR&amp;amp;ceid=KR:ko`;
        const apiUrl = `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(baseUrl + params)}`;

        // 4. 데이터 가져오기
        fetch(apiUrl)
        .then(res =&amp;gt; res.json())
        .then(data =&amp;gt; {
            const listArea = box.querySelector('.news-widget-list');
            
            if (data.status === 'ok' &amp;amp;&amp;amp; data.items.length &amp;gt; 0) {
                let html = '';
                data.items.slice(0, 5).forEach(item =&amp;gt; {
                    // 날짜 포맷
                    let dateStr = item.pubDate;
                    try {
                        const d = new Date(item.pubDate);
                        dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
                    } catch(e) {}

                    html += `
                        &amp;lt;div class=&quot;news-widget-item&quot;&amp;gt;
                            &amp;lt;a href=&quot;${item.link}&quot; target=&quot;_blank&quot;&amp;gt;${item.title}&amp;lt;/a&amp;gt;
                            &amp;lt;div class=&quot;news-widget-meta&quot;&amp;gt;${dateStr} | ${item.author || '뉴스'}&amp;lt;/div&amp;gt;
                        &amp;lt;/div&amp;gt;`;
                });
                listArea.innerHTML = html;
            } else {
                listArea.innerHTML = '&amp;lt;div class=&quot;news-loading&quot;&amp;gt;검색 결과가 없습니다.&amp;lt;/div&amp;gt;';
            }
        })
        .catch(err =&amp;gt; {
            const listArea = box.querySelector('.news-widget-list');
            if(listArea) listArea.innerHTML = '&amp;lt;div class=&quot;news-loading&quot;&amp;gt;불러오기 실패&amp;lt;/div&amp;gt;';
        });
    }
&amp;lt;/script&amp;gt;

&amp;lt;div id=&quot;news-1&quot; class=&quot;news-widget-box&quot;&amp;gt;
    &amp;lt;div class=&quot;news-widget-header&quot;&amp;gt;
        &amp;lt;span class=&quot;widget-title-text&quot;&amp;gt;뉴스 로딩 중...&amp;lt;/span&amp;gt;
        &amp;lt;span style=&quot;font-size:11px; opacity:0.8;&quot;&amp;gt;Live&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&quot;news-widget-list&quot;&amp;gt;
        &amp;lt;div class=&quot;news-loading&quot;&amp;gt;뉴스를 검색하고 있습니다...&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
    // loadNewsWidget('아이디', '검색어', '박스제목');
    loadNewsWidget('news-1', '핸드볼', '핸드볼 최신 뉴스');
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째 code/ 박스&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769440447848&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div id=&quot;news-2&quot; class=&quot;news-widget-box&quot;&amp;gt;
    &amp;lt;div class=&quot;news-widget-header&quot;&amp;gt;
        &amp;lt;span class=&quot;widget-title-text&quot;&amp;gt;뉴스 로딩 중...&amp;lt;/span&amp;gt;
        &amp;lt;span style=&quot;font-size:11px; opacity:0.8;&quot;&amp;gt;Live&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class=&quot;news-widget-list&quot;&amp;gt;
        &amp;lt;div class=&quot;news-loading&quot;&amp;gt;뉴스를 검색하고 있습니다...&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
    // 아이디를 news-2 로 맞춰줍니다.
    loadNewsWidget('news-2', '여자축구', '여자축구 최신 뉴스');
&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째 code/ 박스&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;style 과 script 는 이미 첫번째 박스에서 써서 훨씬 간단하게 작성할 수 있다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;loadNewsWidget('아이디', '검색어', '박스제목');&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이디만 각 박스 별 div id와 맞추고,&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록이 잘 되길...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끝&lt;/p&gt;</description>
      <category>HTML, CSS</category>
      <author>개발자 leelee</author>
      <guid isPermaLink="true">https://mmee2.tistory.com/132</guid>
      <comments>https://mmee2.tistory.com/132#entry132comment</comments>
      <pubDate>Tue, 27 Jan 2026 00:29:19 +0900</pubDate>
    </item>
  </channel>
</rss>