<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>파이썬 보관 - 하우인포-IT·테크</title>
	<atom:link href="https://howinfo.kr/tag/%ed%8c%8c%ec%9d%b4%ec%8d%ac/feed/" rel="self" type="application/rss+xml" />
	<link>https://howinfo.kr/tag/파이썬/</link>
	<description>IT·AI 자동화 &#38; 인프라 전문 블로그 (하우인포)</description>
	<lastBuildDate>Sat, 28 Feb 2026 00:28:24 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>

<image>
	<url>https://howinfo.kr/wp-content/uploads/2026/02/cropped-ChatGPT-Image-2026년-2월-12일-오후-05_39_40-32x32.png</url>
	<title>파이썬 보관 - 하우인포-IT·테크</title>
	<link>https://howinfo.kr/tag/파이썬/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>파이썬 프로그램을 윈도우 EXE로 만드는 방법 (PyInstaller 실전 가이드)</title>
		<link>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%a8%ec%9d%84-%ec%9c%88%eb%8f%84%ec%9a%b0-exe%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%eb%b0%a9%eb%b2%95-pyinstaller-%ec%8b%a4%ec%a0%84/</link>
					<comments>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%a8%ec%9d%84-%ec%9c%88%eb%8f%84%ec%9a%b0-exe%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%eb%b0%a9%eb%b2%95-pyinstaller-%ec%8b%a4%ec%a0%84/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Sat, 28 Feb 2026 00:28:23 +0000</pubDate>
				<category><![CDATA[IT기초]]></category>
		<category><![CDATA[exe배포]]></category>
		<category><![CDATA[Pyinstaller]]></category>
		<category><![CDATA[tkinter]]></category>
		<category><![CDATA[개발팁]]></category>
		<category><![CDATA[배포자동화]]></category>
		<category><![CDATA[빌드]]></category>
		<category><![CDATA[윈도우exe]]></category>
		<category><![CDATA[파이썬]]></category>
		<category><![CDATA[파이썬패키징]]></category>
		<category><![CDATA[프로그램 배포]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=2108</guid>

					<description><![CDATA[<p>파이썬으로 만든 스크립트가 잘 돌아가도, 주변 사람에게 공유하려면 항상 이런 문제가 생깁니다. 그래서 결국 많이 선택하는 방법이 EXE로 패키징하는 겁니다.이번...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%a8%ec%9d%84-%ec%9c%88%eb%8f%84%ec%9a%b0-exe%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%eb%b0%a9%eb%b2%95-pyinstaller-%ec%8b%a4%ec%a0%84/">파이썬 프로그램을 윈도우 EXE로 만드는 방법 (PyInstaller 실전 가이드)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>파이썬으로 만든 스크립트가 잘 돌아가도, 주변 사람에게 공유하려면 항상 이런 문제가 생깁니다.</p>



<ul class="wp-block-list">
<li>“파이썬 설치해야 해?”</li>



<li>“pip로 뭐 설치해야 해?”</li>



<li>“실행했더니 검은 창만 뜨고 꺼지는데?”</li>
</ul>



<p>그래서 결국 많이 선택하는 방법이 <strong>EXE로 패키징</strong>하는 겁니다.<br>이번 글은 제가 실제로 가장 많이 쓰는 도구인 <strong>PyInstaller</strong> 기준으로, “초보도 따라 할 수 있게” 정리했습니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">1) EXE 만들기 전에 꼭 알아야 할 것</h2>



<h3 class="wp-block-heading">✅ EXE로 만들면 좋은 점</h3>



<ul class="wp-block-list">
<li>파이썬이 설치되지 않은 PC에서도 실행 가능(대부분의 경우)</li>



<li>배포/공유가 편해짐</li>



<li>파일 1개(또는 폴더 1개)로 정리 가능</li>
</ul>



<h3 class="wp-block-heading">⚠️ 현실적으로 생기는 문제</h3>



<ul class="wp-block-list">
<li>용량이 커짐(수십~수백 MB도 흔함)</li>



<li>백신 오탐(특히 새로 만든 exe)</li>



<li>경로 문제(파일 저장/설정 파일 위치)</li>



<li>GUI(Tkinter 등)에서는 괜찮은데, 콘솔 출력은 안 보이기도 함</li>
</ul>



<p>이걸 미리 알고 가면 삽질이 크게 줄어요.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">2) 준비물: 파이썬/폴더 구조 추천</h2>



<h3 class="wp-block-heading">권장 폴더 구조 예시</h3>



<pre class="wp-block-preformatted">my_app/<br>  main.py<br>  core/<br>  assets/<br>  data/<br>  requirements.txt</pre>



<ul class="wp-block-list">
<li><strong>assets/</strong> : 이미지, 아이콘 등 리소스</li>



<li><strong>data/</strong> : 설정 파일, 저장 파일(앱 실행 중 생성되는 데이터)</li>



<li><strong>requirements.txt</strong> : 의존성 목록</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3) PyInstaller 설치</h2>



<p>가장 먼저 <strong>가상환경</strong>을 추천합니다. (빌드가 깔끔해져요)</p>



<h3 class="wp-block-heading">(선택) 가상환경 만들기</h3>



<pre class="wp-block-preformatted">cd my_app<br>python -m venv .venv<br>.venv\Scripts\activate</pre>



<h3 class="wp-block-heading">PyInstaller 설치</h3>



<pre class="wp-block-preformatted">python -m pip install --upgrade pip<br>pip install pyinstaller</pre>



<p>설치 확인:</p>



<pre class="wp-block-preformatted">pyinstaller --version</pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">4) 가장 기본 빌드: onefile로 EXE 만들기</h2>



<h3 class="wp-block-heading">콘솔 프로그램(터미널 창 필요)</h3>



<pre class="wp-block-preformatted">pyinstaller --onefile main.py</pre>



<h3 class="wp-block-heading">GUI 프로그램(창만 띄우고 콘솔 숨김)</h3>



<pre class="wp-block-preformatted">pyinstaller --onefile --noconsole main.py</pre>



<p>빌드가 끝나면 보통 아래 폴더가 생깁니다.</p>



<ul class="wp-block-list">
<li><code>dist/</code> : 실제 배포 파일(exe)</li>



<li><code>build/</code> : 빌드 캐시</li>



<li><code>main.spec</code> : 빌드 설정 파일</li>
</ul>



<p>결과 EXE는 <code>dist/main.exe</code> 에서 확인합니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">5) 아이콘 넣기(윈도우 exe 느낌 살리기)</h2>



<p>아이콘은 <code>.ico</code> 파일이 필요합니다. (png 그대로는 안됨)</p>



<pre class="wp-block-preformatted">pyinstaller --onefile --noconsole --icon=assets/app.ico main.py</pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">6) 이미지/설정파일 등 “외부 파일” 포함하기</h2>



<p>여기서 많이 막힙니다.<br>파이썬에서는 상대경로로 잘 읽히는데, exe로 묶이면 경로가 달라져요.</p>



<h3 class="wp-block-heading">6-1) 빌드 옵션으로 파일 포함</h3>



<p>예: <code>assets/</code> 폴더를 함께 포함</p>



<pre class="wp-block-preformatted">pyinstaller --onefile --noconsole ^<br>  --add-data "assets;assets" ^<br>  main.py</pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>윈도우에서 <code>--add-data "원본;대상"</code> 형태로 세미콜론(<code>;</code>)을 사용합니다.</p>
</blockquote>



<h3 class="wp-block-heading">6-2) exe 환경에서 경로 처리(핵심)</h3>



<p>파이썬 코드에서 “실행 위치”를 잡는 함수를 넣어두면 안정적입니다.</p>



<pre class="wp-block-preformatted">import os, sysdef resource_path(relative_path: str) -&gt; str:<br>    # PyInstaller로 묶이면 _MEIPASS 경로에 리소스가 풀림<br>    base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))<br>    return os.path.join(base_path, relative_path)</pre>



<p>사용 예:</p>



<pre class="wp-block-preformatted">icon_path = resource_path("assets/app.ico")</pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">7) “exe 실행하면 저장이 안돼요/초기화돼요” 문제 해결</h2>



<p>이 현상은 거의 대부분 <strong>저장 위치가 잘못 잡혀서</strong> 생깁니다.</p>



<ul class="wp-block-list">
<li>개발 중: 프로젝트 폴더에 저장</li>



<li>exe 실행: 사용자 PC의 다른 위치에서 실행 → 상대경로가 달라짐</li>



<li>또는 Program Files 아래라면 쓰기 권한 문제로 저장 실패</li>
</ul>



<h3 class="wp-block-heading">추천: 사용자 폴더(AppData) 아래에 저장 폴더 만들기</h3>



<pre class="wp-block-preformatted">import osdef get_app_data_dir(app_name="MyApp"):<br>    base = os.getenv("APPDATA")  # C:\Users\...\AppData\Roaming<br>    path = os.path.join(base, app_name)<br>    os.makedirs(path, exist_ok=True)<br>    return path</pre>



<p>저장 예:</p>



<pre class="wp-block-preformatted">cfg_path = os.path.join(get_app_data_dir("HStockGuard"), "config.json")</pre>



<p>이렇게 하면 exe로 배포해도 <strong>설정/저장 데이터가 유지</strong>됩니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">8) 자주 겪는 에러/해결 체크리스트</h2>



<h3 class="wp-block-heading">✅ 실행하자마자 창이 꺼짐</h3>



<ul class="wp-block-list">
<li>콘솔 모드로 빌드해서 에러 확인<br><code>--noconsole</code> 빼고 실행해보기</li>
</ul>



<h3 class="wp-block-heading">✅ 모듈 못 찾는다고 나옴</h3>



<ul class="wp-block-list">
<li>누락된 모듈을 hidden-import로 추가</li>
</ul>



<pre class="wp-block-preformatted">pyinstaller --onefile --hidden-import=패키지명 main.py</pre>



<h3 class="wp-block-heading">✅ 백신이 위험하다고 막음</h3>



<ul class="wp-block-list">
<li>새 exe는 오탐이 흔함</li>



<li>코드서명까지는 현실적으로 어렵고, 최소한 배포 파일을 자주 바꾸지 않는 게 도움</li>



<li>가능하면 <code>onedir</code> 배포도 고려(오탐이 덜 나는 경우가 있음)</li>
</ul>



<h3 class="wp-block-heading">✅ 용량이 너무 큼</h3>



<ul class="wp-block-list">
<li>가상환경을 깨끗하게 유지</li>



<li>불필요한 패키지 제거</li>



<li><code>--exclude-module</code> 사용 고려(상황에 따라)</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">9) 배포 방식 추천: onefile vs onedir</h2>



<h3 class="wp-block-heading">onefile</h3>



<ul class="wp-block-list">
<li>장점: 파일 1개로 배포 끝</li>



<li>단점: 실행 시 임시폴더에 풀리는 과정 때문에 첫 실행이 느릴 수 있음</li>
</ul>



<h3 class="wp-block-heading">onedir</h3>



<pre class="wp-block-preformatted">pyinstaller --noconsole main.py</pre>



<ul class="wp-block-list">
<li>장점: 실행 빠르고 문제 추적이 쉬움</li>



<li>단점: 폴더째로 배포해야 함</li>
</ul>



<p>개인적으로는 <strong>처음엔 onedir로 안정화 → 마지막에 onefile</strong>로 마무리하는 경우가 많았습니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<p><strong>Q1. PyInstaller 말고 다른 방법도 있나요?</strong><br>A. 네. <code>cx_Freeze</code>, <code>Nuitka</code> 같은 대안도 있습니다. 다만 가장 대중적이고 자료가 많은 건 PyInstaller입니다.</p>



<p><strong>Q2. 32비트/64비트는 어떻게 맞추나요?</strong><br>A. 빌드에 사용하는 파이썬이 64비트면 보통 64비트 exe가 됩니다. 배포 대상 PC 환경과 맞추세요.</p>



<p><strong>Q3. exe로 만들면 소스가 완전히 숨겨지나요?</strong><br>A. 완전한 보안은 아닙니다. “배포 편의” 목적에 가깝다고 보는 게 현실적입니다.</p>



<h1 class="wp-block-heading">부록) 그대로 복사해서 쓰는 샘플 코드(저장 유지 + 리소스 경로)</h1>



<h2 class="wp-block-heading">core/paths.py</h2>



<pre class="wp-block-preformatted">import os<br>import sysdef resource_path(relative_path: str) -&gt; str:<br>    """<br>    PyInstaller(onefile)에서 리소스 경로를 안전하게 가져온다.<br>    """<br>    base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))<br>    return os.path.join(base_path, relative_path)def appdata_dir(app_name: str = "MyApp") -&gt; str:<br>    """<br>    AppData\Roaming 아래에 앱 폴더를 생성해 저장 경로로 사용한다.<br>    """<br>    base = os.getenv("APPDATA") or os.path.expanduser("~")<br>    path = os.path.join(base, app_name)<br>    os.makedirs(path, exist_ok=True)<br>    return path</pre>



<h2 class="wp-block-heading">core/config.py</h2>



<pre class="wp-block-preformatted">import json<br>import os<br>from .paths import appdata_dirdef load_config(app_name: str = "MyApp", filename: str = "config.json") -&gt; dict:<br>    path = os.path.join(appdata_dir(app_name), filename)<br>    if not os.path.exists(path):<br>        return {}<br>    with open(path, "r", encoding="utf-8") as f:<br>        return json.load(f)def save_config(cfg: dict, app_name: str = "MyApp", filename: str = "config.json") -&gt; str:<br>    path = os.path.join(appdata_dir(app_name), filename)<br>    with open(path, "w", encoding="utf-8") as f:<br>        json.dump(cfg, f, ensure_ascii=False, indent=2)<br>    return path</pre>



<h2 class="wp-block-heading">main.py (테스트용)</h2>



<pre class="wp-block-preformatted">from core.config import load_config, save_configAPP_NAME = "HowinfoPyExeDemo"def main():<br>    cfg = load_config(APP_NAME)<br>    run_count = int(cfg.get("run_count", 0)) + 1<br>    cfg["run_count"] = run_count    saved_path = save_config(cfg, APP_NAME)<br>    print(f"[OK] 실행 횟수: {run_count}")<br>    print(f"[OK] 설정 저장 위치: {saved_path}")if __name__ == "__main__":<br>    main()</pre>



<h2 class="wp-block-heading">빌드 명령어(윈도우)</h2>



<pre class="wp-block-preformatted">pyinstaller --onefile main.py</pre>



<p>GUI + 아이콘 + assets 포함 예시:</p>



<pre class="wp-block-preformatted">pyinstaller --onefile --noconsole ^<br>  --icon=assets/app.ico ^<br>  --add-data "assets;assets" ^<br>  main.py<br></pre>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%a8%ec%9d%84-%ec%9c%88%eb%8f%84%ec%9a%b0-exe%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%eb%b0%a9%eb%b2%95-pyinstaller-%ec%8b%a4%ec%a0%84/">파이썬 프로그램을 윈도우 EXE로 만드는 방법 (PyInstaller 실전 가이드)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%a8%ec%9d%84-%ec%9c%88%eb%8f%84%ec%9a%b0-exe%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%eb%b0%a9%eb%b2%95-pyinstaller-%ec%8b%a4%ec%a0%84/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</title>
		<link>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/</link>
					<comments>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Thu, 12 Feb 2026 02:25:07 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[sysemd]]></category>
		<category><![CDATA[tts]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[알람시계]]></category>
		<category><![CDATA[오렌지파이5]]></category>
		<category><![CDATA[자동화]]></category>
		<category><![CDATA[파이썬]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1594</guid>

					<description><![CDATA[<p>아침에 알람이 울리긴 하는데…“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠. 이번 글에서는 오렌지파이5 + Ubuntu 환경에서, 파이썬으로...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/">파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h1 class="wp-block-heading"></h1>



<p>아침에 알람이 울리긴 하는데…<br>“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠.</p>



<p>이번 글에서는 <strong>오렌지파이5 + Ubuntu</strong> 환경에서, 파이썬으로 <strong>말하는 음성 알람</strong>을 만드는 방법을 정리했습니다.</p>



<ul class="wp-block-list">
<li>06:00부터 10분 단위로 06:30까지</li>



<li>“주인님 일어나세요. 현재 시간 06시 10분입니다.” 같은 문장을 <strong>TTS로 말해주고</strong></li>



<li>설정파일 1개로 <strong>매일/평일/1회 + 공휴일 제외</strong>까지 제어하고</li>



<li><strong>EdgeTTS 캐시(재생 빠름)</strong> + <strong>중복 재생 방지(안전)</strong> + **systemd 자동 실행(운영 편함)**까지 묶었습니다.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">목표 동작 요약</h2>



<ul class="wp-block-list">
<li>알람 시간: <code>06:00</code>, <code>06:10</code>, <code>06:20</code>, <code>06:30</code></li>



<li>출력: 스피커로 음성 재생(mp3)</li>



<li>스케줄 방식: 설정파일(JSON) 기반</li>



<li>운영 안정성:
<ul class="wp-block-list">
<li>같은 분에 두 번 울리는 것 방지(상태파일 기록)</li>



<li>TTS는 캐시(mp3 재사용)로 속도/안정성 개선</li>



<li>systemd로 부팅 후 자동 실행</li>
</ul>
</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">준비물</h2>



<ul class="wp-block-list">
<li>Orange Pi 5 (또는 Ubuntu 머신)</li>



<li>Ubuntu 22.04/24.04 계열</li>



<li>스피커(3.5mm/USB/블루투스 등)</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">설치(필수 패키지)</h2>



<pre class="wp-block-code"><code>sudo apt update
sudo apt install -y python3-pip mpg123
pip3 install edge-tts holidays
</code></pre>



<ul class="wp-block-list">
<li><code>edge-tts</code> : 텍스트 → 음성(mp3) 생성</li>



<li><code>mpg123</code> : mp3를 바로 재생(가볍고 안정적)</li>



<li><code>holidays</code> : 한국 공휴일 제외(선택처럼 보이지만 “휴일 제외”를 쓰려면 필요)</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">1) 설정파일 1개로 알람 규칙 관리하기</h2>



<p>프로젝트 폴더를 만들고, 설정파일을 준비합니다.</p>



<pre class="wp-block-code"><code>mkdir -p ~/edge_alarm
cd ~/edge_alarm
nano alarm_config.json
</code></pre>



<h3 class="wp-block-heading"><code>alarm_config.json</code></h3>



<pre class="wp-block-code"><code>{
  "mode": "weekdays",
  "times": &#91;"06:00", "06:10", "06:20", "06:30"],
  "message_template": "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.",
  "voice": "ko-KR-SunHiNeural",
  "rate": "+0%",
  "volume": 100,
  "exclude_public_holidays": true,
  "country_holidays": "KR",
  "once_date": "2026-02-12"
}
</code></pre>



<h3 class="wp-block-heading">핵심 옵션 설명</h3>



<ul class="wp-block-list">
<li><code>mode</code>
<ul class="wp-block-list">
<li><code>daily</code> : 매일</li>



<li><code>weekdays</code> : 평일만(토/일 제외)</li>



<li><code>once</code> : 특정 날짜 <code>once_date</code>에만 1회 실행</li>
</ul>
</li>



<li><code>times</code> : 울릴 시간을 배열로 관리</li>



<li><code>message_template</code> : <code>{hh}</code>, <code>{mm}</code>가 현재 시각으로 자동 치환</li>



<li><code>exclude_public_holidays</code> : 공휴일 제외 여부</li>



<li><code>country_holidays</code> : 한국은 <code>"KR"</code></li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">2) 파이썬 실행 코드(alarm_tts.py)</h2>



<p>이 코드는 아래 3가지를 “운영 가능한 수준”으로 묶는 게 포인트입니다.</p>



<ol class="wp-block-list">
<li><strong>EdgeTTS 캐시</strong>: 같은 문장은 mp3를 저장해 재사용</li>



<li><strong>중복방지</strong>: <code>YYYY-MM-DD_HH:MM</code> 키로 “이미 울림” 기록</li>



<li><strong>자동실행</strong>: systemd로 부팅 시 자동 기동</li>
</ol>



<p>아래 파일을 저장하세요.</p>



<pre class="wp-block-code"><code>nano alarm_tts.py
chmod +x alarm_tts.py
</code></pre>



<h3 class="wp-block-heading"><code>alarm_tts.py</code> (한글 상세 주석 포함)</h3>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
&#91;EdgeTTS 음성 알람 스크립트]
- 설정 파일(alarm_config.json) 하나만 수정해서 운영 가능
- EdgeTTS로 MP3 생성 후 스피커로 재생(mpg123 사용)
- MP3 캐시 저장(같은 문장 재사용) -&gt; 빠르고 안정적
- 상태 파일 기록(같은 분 중복 재생 방지)
- systemd 서비스로 등록하면 부팅 후 자동 실행 가능
"""

import asyncio
import json
import os
import sys
import time
import hashlib
import subprocess
from datetime import datetime, date, timedelta

# EdgeTTS 라이브러리 import
try:
    import edge_tts
except ImportError:
    print("edge-tts가 설치되어 있지 않습니다. `pip3 install edge-tts`를 실행하세요.")
    sys.exit(1)

# 공휴일 제외 기능을 위한 라이브러리(없으면 공휴일 판단 기능이 비활성)
try:
    import holidays as holidays_lib
except ImportError:
    holidays_lib = None

# -----------------------------
# 파일 경로(환경변수로 오버라이드 가능)
# -----------------------------
CONFIG_PATH = os.environ.get("ALARM_CONFIG", "./alarm_config.json")  # 설정 파일
STATE_PATH  = os.environ.get("ALARM_STATE", "./alarm_state.json")    # 중복방지 상태 파일
CACHE_DIR   = os.environ.get("ALARM_CACHE", "./tts_cache")           # TTS mp3 캐시 폴더


def load_json(path: str, default):
    """JSON 파일 로딩. 파일이 없으면 default 반환"""
    if not os.path.exists(path):
        return default
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json(path: str, obj):
    """
    JSON 저장을 안전하게 하기 위한 방식
    - 임시 파일(.tmp)에 먼저 저장한 뒤 os.replace로 교체
    - 저장 중 전원 문제 등으로 파일이 깨질 위험을 줄임
    """
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(obj, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def ensure_dir(p: str):
    """폴더가 없으면 생성"""
    os.makedirs(p, exist_ok=True)


def is_public_holiday(d: date, country_code: str) -&gt; bool:
    """
    특정 국가 공휴일 여부 판단
    - holidays 라이브러리가 없으면 False 처리(공휴일 제외 비활성)
    """
    if holidays_lib is None:
        return False

    try:
        h = holidays_lib.country_holidays(country_code)
        return d in h
    except Exception:
        return False


def should_run_today(cfg: dict, today: date) -&gt; bool:
    """
    오늘 알람을 동작시킬지 판단
    - mode(daily/weekdays/once)
    - 공휴일 제외 옵션
    """
    mode = cfg.get("mode", "daily").lower()

    # once 모드: 특정 날짜에만 동작
    if mode == "once":
        once_date = cfg.get("once_date")
        if not once_date:
            return False
        try:
            od = datetime.strptime(once_date, "%Y-%m-%d").date()
            return today == od
        except ValueError:
            return False

    # weekdays 모드: 토/일이면 동작 안 함
    if mode == "weekdays":
        if today.weekday() &gt;= 5:
            return False

    # 공휴일 제외 옵션
    if cfg.get("exclude_public_holidays", False):
        cc = cfg.get("country_holidays", "KR")
        if is_public_holiday(today, cc):
            return False

    return True


def parse_times(cfg: dict):
    """
    설정 times(&#91;"06:00","06:10"...])를 (hh,mm) 튜플 리스트로 변환
    - 잘못된 값은 무시
    - 중복 제거 + 정렬
    """
    times = cfg.get("times", &#91;])
    parsed = &#91;]
    for t in times:
        try:
            hh, mm = t.split(":")
            parsed.append((int(hh), int(mm)))
        except Exception:
            pass
    return sorted(set(parsed))


def make_message(cfg: dict, now: datetime) -&gt; str:
    """설정 템플릿에서 멘트 생성({hh},{mm} 치환)"""
    tpl = cfg.get("message_template", "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.")
    return tpl.format(hh=now.strftime("%H"), mm=now.strftime("%M"))


def tts_cache_path(text: str, voice: str, rate: str) -&gt; str:
    """
    같은 텍스트/목소리/속도 조합은 mp3를 재사용하기 위해 해시 파일명으로 캐시 저장
    """
    key = f"{voice}|{rate}|{text}".encode("utf-8")
    h = hashlib.sha256(key).hexdigest()&#91;:24]
    return os.path.join(CACHE_DIR, f"{h}.mp3")


async def synthesize_mp3(text: str, voice: str, rate: str, out_path: str):
    """EdgeTTS로 mp3 생성(비동기)"""
    communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate)
    await communicate.save(out_path)


def play_mp3(path: str, volume: int = 100):
    """
    mpg123로 mp3 재생
    - volume(0~100)을 gain으로 완만하게 반영
    """
    gain = max(0, min(32768, int(volume) * 80))
    subprocess.run(&#91;"mpg123", "-q", "-f", str(gain), path], check=False)


def minute_key(d: date, hh: int, mm: int) -&gt; str:
    """중복방지 키: YYYY-MM-DD_HH:MM"""
    return f"{d.isoformat()}_{hh:02d}:{mm:02d}"


def next_trigger_datetime(now: datetime, times):
    """
    현재 시각 기준으로 다음 알람 시각 찾기
    - 오늘~모레까지 탐색(안전장치)
    """
    for day_offset in range(0, 3):
        base = now.date() + timedelta(days=day_offset)
        for hh, mm in times:
            dt = datetime.combine(base, datetime.min.time()).replace(hour=hh, minute=mm)
            if dt &gt; now:
                return dt
    return None


async def main():
    """메인 루프"""
    ensure_dir(CACHE_DIR)

    cfg = load_json(CONFIG_PATH, default={})
    state = load_json(STATE_PATH, default={"fired": {}})

    times = parse_times(cfg)
    if not times:
        print("times 설정이 비어 있거나 형식이 잘못되었습니다.")
        return

    voice = cfg.get("voice", "ko-KR-SunHiNeural")
    rate = cfg.get("rate", "+0%")
    volume = int(cfg.get("volume", 100))

    print(f"&#91;alarm] 시작 mode={cfg.get('mode')} times={cfg.get('times')} voice={voice}")

    while True:
        now = datetime.now()
        today = now.date()

        # 오늘 동작 조건이 아니면 내일 새벽까지 대기
        if not should_run_today(cfg, today):
            tomorrow = datetime.combine(today + timedelta(days=1), datetime.min.time()).replace(minute=1)
            sleep_sec = max(5, int((tomorrow - now).total_seconds()))
            print(f"&#91;alarm] 오늘({today}) 스킵. {sleep_sec}초 후 재확인")
            time.sleep(sleep_sec)
            continue

        # 다음 알람 시각 계산
        nxt = next_trigger_datetime(now, times)
        if not nxt:
            time.sleep(10)
            continue

        # 다음 알람까지 대기(너무 길게 한번에 sleep하지 않도록 최대 60초 단위로 쪼갬)
        sleep_sec = (nxt - now).total_seconds()
        if sleep_sec &gt; 1:
            time.sleep(min(60, sleep_sec))
            continue

        # 중복방지: 같은 분에 이미 울렸으면 스킵
        k = minute_key(nxt.date(), nxt.hour, nxt.minute)
        if state.get("fired", {}).get(k):
            time.sleep(1)
            continue

        # 현재 시간 안내가 정확하도록 "울리는 순간"의 시간을 기준으로 멘트 생성
        speak_time = datetime.now()
        text = make_message(cfg, speak_time)

        # 캐시 mp3가 있으면 재사용, 없으면 새로 생성
        cache_path = tts_cache_path(text, voice, rate)
        if not os.path.exists(cache_path):
            try:
                await synthesize_mp3(text, voice, rate, cache_path)
            except Exception as e:
                print("&#91;alarm] TTS 생성 실패:", e)
                time.sleep(2)
                continue

        print(f"&#91;alarm] 울림 {k} =&gt; {text}")
        play_mp3(cache_path, volume=volume)

        # 상태 기록(이 분에는 이미 울렸음)
        state.setdefault("fired", {})&#91;k] = True
        save_json(STATE_PATH, state)

        # once 모드면 오늘 남은 알람이 없을 때 종료
        if cfg.get("mode", "").lower() == "once":
            remaining = &#91;]
            for hh, mm in times:
                dt = datetime.combine(today, datetime.min.time()).replace(hour=hh, minute=mm)
                if dt &gt; speak_time:
                    remaining.append(dt)
            if not remaining:
                print("&#91;alarm] once 모드 완료. 종료")
                return

        time.sleep(1)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n&#91;alarm] 사용자에 의해 종료됨")
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3) 실행 방법(수동 테스트)</h2>



<pre class="wp-block-code"><code>cd ~/edge_alarm
python3 alarm_tts.py
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>당장 테스트하고 싶으면 <code>times</code>를 현재 시간 기준으로 1~2분 뒤로 잠깐 바꿔보면 바로 확인됩니다.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">4) systemd 자동 실행(부팅 시 자동 시작)</h2>



<h3 class="wp-block-heading">1) 서비스 파일 생성</h3>



<pre class="wp-block-code"><code>sudo nano /etc/systemd/system/edge-alarm.service
</code></pre>



<h3 class="wp-block-heading">2) 아래 내용 입력(경로는 본인 계정에 맞게 수정)</h3>



<pre class="wp-block-code"><code>&#91;Unit]
Description=Edge TTS Alarm (EdgeTTS cache + dedup + systemd)
After=network.target sound.target

&#91;Service]
Type=simple
WorkingDirectory=/home/orangepi/edge_alarm
ExecStart=/usr/bin/python3 /home/orangepi/edge_alarm/alarm_tts.py
Restart=always
RestartSec=3

&#91;Install]
WantedBy=multi-user.target
</code></pre>



<h3 class="wp-block-heading">3) 적용 및 실행</h3>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now edge-alarm.service
sudo systemctl status edge-alarm.service
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">운영 팁(실제로 써보면 도움이 되는 부분)</h2>



<ul class="wp-block-list">
<li><strong>네트워크가 잠깐 끊겨도</strong> 이미 만들어둔 mp3 캐시가 있으면 재생은 계속 됩니다.</li>



<li>“같은 시간에 두 번 울림”이 싫다면 <strong>상태파일(alarm_state.json)</strong> 방식이 꽤 든든합니다.</li>



<li>멘트/시간/평일여부는 코드가 아니라 <strong>설정파일 하나로</strong> 운영하면 나중에 유지보수가 편해요.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<p><strong>Q. 공휴일 제외는 어떻게 동작해요?</strong><br>A. <code>holidays</code> 라이브러리에서 KR 공휴일을 체크해서 해당 날짜면 스킵합니다.</p>



<p><strong>Q. 스피커가 USB/블루투스면 안 나올 때가 있어요.</strong><br>A. 대부분 “기본 출력 장치”가 다르게 잡혀서 생깁니다. 먼저 Ubuntu 사운드 출력 장치를 확인해 주세요.</p>



<p><strong>Q. 멘트를 바꾸려면 코드를 수정해야 하나요?</strong><br>A. 아니요. <code>message_template</code>만 바꾸면 됩니다.</p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/">파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</title>
		<link>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/</link>
					<comments>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Mon, 09 Feb 2026 06:45:24 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[AI음성]]></category>
		<category><![CDATA[DIY프로젝트]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[OpenCV]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[RaspberryPi]]></category>
		<category><![CDATA[라즈베리파이]]></category>
		<category><![CDATA[모션감지]]></category>
		<category><![CDATA[보안카메라]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[알림시스템]]></category>
		<category><![CDATA[임베디드]]></category>
		<category><![CDATA[파이썬]]></category>
		<category><![CDATA[홈네트워크]]></category>
		<category><![CDATA[홈캠만들기]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1462</guid>

					<description><![CDATA[<p>집을 비울 때 누군가 들어오는지 궁금하신가요? 시중의 비싼 홈캠 대신, 라즈베리파이와 파이썬을 활용해 움직임을 감지하고 목소리로 경고를 날리는 스마트 감시...</p>
<p>게시물 <a href="https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/">[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>집을 비울 때 누군가 들어오는지 궁금하신가요? 시중의 비싼 홈캠 대신, 라즈베리파이와 파이썬을 활용해 <strong>움직임을 감지하고 목소리로 경고를 날리는 스마트 감시 시스템</strong>을 직접 만들어보았습니다. AI를 활용한 고품질 TTS 기능까지 더해 더욱 강력해진 &#8216;모션 가드&#8217; 제작기를 공유합니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">1. 주요 기능 및 특징: 이 프로젝트가 특별한 이유</h3>



<p>단순히 녹화만 하는 카메라가 아닙니다. 상황에 맞춰 즉각 대응하는 지능형 시스템입니다.</p>



<ul class="wp-block-list">
<li><strong>실시간 모션 감지:</strong> OpenCV를 활용해 지정된 ROI(관심 영역) 내의 움직임을 픽셀 단위로 분석하여 작은 변화도 놓치지 않습니다.</li>



<li><strong>고품질 AI 음성 안내:</strong> <code>edge-tts</code>를 연동하여 기계음이 아닌 자연스러운 한국어 목소리로 침입 경고 멘트를 송출합니다.</li>



<li><strong>오탐 방지 알고리즘:</strong> 연속 프레임 감지(Confirm Frames)와 쿨다운 타임을 적용해 조명 변화나 미세한 노이즈로 인한 오작동을 최소화했습니다.</li>



<li><strong>강력한 비프음 발생:</strong> 경고 멘트 후 강렬한 &#8216;삐삐삐&#8217; 패턴의 비프음을 재생해 청각적인 보안 효과를 극대화합니다.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">2. 준비물 및 환경 설정: 시작하기 전에</h3>



<p>이 프로젝트를 실행하기 위해 라즈베리파이에 몇 가지 하드웨어와 라이브러리 설치가 필요합니다.</p>



<ul class="wp-block-list">
<li><strong>하드웨어:</strong>
<ul class="wp-block-list">
<li>Raspberry Pi (Zero W, 3, 4 등 모든 모델 가능)</li>



<li>USB 웹캠 또는 라즈베리파이 카메라 모듈</li>



<li>스피커 (3.5mm 오디오 잭 또는 USB 스피커)</li>
</ul>
</li>



<li><strong>소프트웨어 설치:</strong>Bash<code># 1. 시스템 의존성 설치 (음성 재생을 위한 mpg123, alsa-utils) sudo apt-get update &amp;&amp; sudo apt-get install -y mpg123 alsa-utils # 2. 파이썬 라이브러리 설치 (OpenCV, NumPy, Edge-TTS, Asyncio) pip install opencv-python numpy edge-tts asyncio </code><strong>💡 Tip:</strong> <code>pip</code> 명령어가 오류난다면 <code>pip3 install ...</code>을 시도해 보세요.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">3. 핵심 코드 분석: 어떻게 움직임을 감지할까?</h3>



<p>코드의 핵심은 <strong>이전 프레임과 현재 프레임 간의 픽셀 차이를 계산하여 움직임을 수치화</strong>하는 것입니다.</p>



<h4 class="wp-block-heading">🔍 모션 감지 알고리즘 (<code>motion_ratio</code> 함수)</h4>



<p>Python</p>



<pre class="wp-block-code"><code>import cv2
import numpy as np
import os
import asyncio
import edge_tts
import time

# --- 환경 변수 설정 (값을 변경하여 감지 민감도를 조절할 수 있습니다) ---
MOTION_RATIO_THRESH = float(os.environ.get("MOTION_RATIO_THRESH", "0.03")) # 움직임 감지 임계값 (0.01~0.1 사이 권장)
CONFIRM_FRAMES = int(os.environ.get("CONFIRM_FRAMES", "5"))                 # 연속 감지 확인 프레임 수
ALERT_COOLDOWN_SEC = int(os.environ.get("ALERT_COOLDOWN_SEC", "30"))        # 경고 후 쿨다운 시간(초)
TTS_MP3_PATH = os.environ.get("TTS_MP3_PATH", "/tmp/alert.mp3")            # TTS 음성 파일 저장 경로
ALERT_MESSAGE = os.environ.get("ALERT_MESSAGE", "경고! 움직임이 감지되었습니다.") # 경고 음성 메시지
ROI_X, ROI_Y, ROI_W, ROI_H = map(int, os.environ.get("ROI", "0,0,0,0").split(',')) # 관심 영역 (x,y,width,height)

async def tts_save_mp3(text, mp3_path, voice="ko-KR-SunHiNeural"):
    """
    Edge-TTS를 사용하여 텍스트를 mp3 파일로 변환하여 저장합니다.
    """
    try:
        communicate = edge_tts.Communicate(text=text, voice=voice)
        await communicate.save(mp3_path)
    except Exception as e:
        print(f"TTS 생성 중 오류 발생: {e}")

def motion_ratio(prev_gray, gray):
    """
    두 회색조 이미지 간의 움직임 비율을 계산합니다.
    """
    diff = cv2.absdiff(prev_gray, gray) # 이전 프레임과 현재 프레임의 픽셀 차이 계산
    _, th = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY) # 임계값 처리 (차이가 25 이상인 픽셀만 흰색으로)
    th = cv2.medianBlur(th, 5) # 노이즈 제거를 위한 미디언 블러 적용
    changed_pixels = np.count_nonzero(th) # 변경된 픽셀 수 계산
    return changed_pixels / th.size # 전체 픽셀 대비 변경된 픽셀 비율 반환

def play_alert_sound(tts_path, beep_count=3, beep_duration=0.2):
    """
    경고 음성 메시지와 비프음을 재생합니다.
    """
    print("경고음 재생...")
    if os.path.exists(tts_path):
        os.system(f"mpg123 {tts_path}") # TTS 음성 재생
    
    # 비프음 재생
    for _ in range(beep_count):
        os.system(f"aplay -q -c 1 -t raw -f S16_LE -r 44100 /dev/zero") # 기본 비프음 (라즈비안에서 작동 확인)
        time.sleep(beep_duration)
        os.system(f"aplay -q -c 1 -t raw -f S16_LE -r 44100 /dev/zero") # 종료 비프음
        time.sleep(beep_duration)
    print("경고음 재생 완료.")

async def main():
    cap = cv2.VideoCapture(0) # 웹캠 (0번 장치) 초기화
    if not cap.isOpened():
        print("카메라를 열 수 없습니다.")
        return

    ret, frame = cap.read()
    if not ret:
        print("첫 프레임을 읽을 수 없습니다.")
        cap.release()
        return

    # ROI 설정이 유효하면 해당 영역으로 프레임을 자름
    if ROI_W &gt; 0 and ROI_H &gt; 0:
        frame = frame&#91;ROI_Y:ROI_Y+ROI_H, ROI_X:ROI_X+ROI_W]
        
    prev_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    
    motion_detected_count = 0
    last_alert_time = 0

    # TTS 파일 미리 생성
    await tts_save_mp3(ALERT_MESSAGE, TTS_MP3_PATH)

    print(f"모션 감지 시작. 임계값: {MOTION_RATIO_THRESH}, 확인 프레임: {CONFIRM_FRAMES}, 쿨다운: {ALERT_COOLDOWN_SEC}초")
    print(f"관심 영역(ROI): X={ROI_X}, Y={ROI_Y}, W={ROI_W}, H={ROI_H}")

    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            display_frame = frame.copy() # 화면 표시용 원본 프레임 복사

            # ROI 설정이 유효하면 해당 영역으로 프레임을 자르고 ROI 표시
            if ROI_W &gt; 0 and ROI_H &gt; 0:
                frame_for_detection = frame&#91;ROI_Y:ROI_Y+ROI_H, ROI_X:ROI_X+ROI_W]
                cv2.rectangle(display_frame, (ROI_X, ROI_Y), (ROI_X+ROI_W, ROI_Y+ROI_H), (0, 255, 0), 2) # ROI 박스 그리기
            else:
                frame_for_detection = frame

            gray = cv2.cvtColor(frame_for_detection, cv2.COLOR_BGR2GRAY)
            
            ratio = motion_ratio(prev_gray, gray)
            
            current_time = time.time()

            if ratio &gt; MOTION_RATIO_THRESH:
                motion_detected_count += 1
                if motion_detected_count &gt;= CONFIRM_FRAMES and (current_time - last_alert_time) &gt; ALERT_COOLDOWN_SEC:
                    print(f"!!! 움직임 감지됨 (비율: {ratio:.4f}) !!!")
                    play_alert_sound(TTS_MP3_PATH)
                    last_alert_time = current_time
                    motion_detected_count = 0 # 알림 후 카운트 초기화
            else:
                motion_detected_count = 0 # 움직임이 없으면 카운트 초기화

            # 프레임에 감지 정보 표시 (선택 사항, 라즈베리파이 성능 고려하여 주석 처리 가능)
            # cv2.putText(display_frame, f"Motion Ratio: {ratio:.4f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
            # cv2.putText(display_frame, f"Alerts: {current_time - last_alert_time:.0f}s cooldown", (10, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

            # cv2.imshow('Motion Guard Cam', display_frame) # 화면에 표시 (GUI 환경에서만 작동)
            
            prev_gray = gray # 현재 프레임을 다음 반복의 이전 프레임으로 저장

            if cv2.waitKey(1) &amp; 0xFF == ord('q'):
                break

    finally:
        cap.release()
        cv2.destroyAllWindows()
        print("프로그램 종료.")

if __name__ == '__main__':
    asyncio.run(main())

</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>코드 설명:</strong> 단순 픽셀 차이 외에도 <code>cv2.medianBlur</code>를 적용해 미세한 노이즈를 제거하여 오작동을 줄였습니다. 또한 <code>CONFIRM_FRAMES</code>로 여러 프레임에 걸쳐 움직임이 지속될 때만 감지하도록 설정하여 신뢰도를 높였습니다.</p>
</blockquote>



<h4 class="wp-block-heading">🗣 AI 음성 경고 (<code>edge_tts</code> 활용)</h4>



<p>구글 TTS(gTTS)보다 훨씬 자연스러운 Microsoft Edge의 TTS 엔진을 활용하여 고품질의 한국어 음성 경고를 구현했습니다. <code>asyncio</code>를 통해 비동기적으로 음성을 생성합니다.</p>



<p>Python</p>



<pre class="wp-block-code"><code>async def tts_save_mp3(text, mp3_path, voice="ko-KR-SunHiNeural"):
    """
    Edge-TTS를 사용하여 텍스트를 mp3 파일로 변환하여 저장합니다.
    """
    try:
        communicate = edge_tts.Communicate(text=text, voice=voice)
        await communicate.save(mp3_path)
    except Exception as e:
        print(f"TTS 생성 중 오류 발생: {e}")
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>코드 설명:</strong> <code>ko-KR-SunHiNeural</code>은 한국어 여성 목소리입니다. 이 외에도 다양한 목소리가 있으니 <code>edge-tts --list-voices</code> 명령어로 확인 후 변경해 볼 수 있습니다.</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">4. 실제 구동 팁: 나만의 환경에 최적화하기</h3>



<p>환경에 따라 설정을 미세하게 조정하면 훨씬 똑똑해집니다. 코드 상단의 <strong>환경 변수</strong>를 통해 조절할 수 있습니다.</p>



<ul class="wp-block-list">
<li><strong>민감도 조절 (<code>MOTION_RATIO_THRESH</code>):</strong> 기본값 <code>0.03</code>은 실내에서 적합합니다. 바람에 흔들리는 나뭇잎이 보이거나 외부 환경이라면 <code>0.05</code> ~ <code>0.1</code> 정도로 높여 오탐을 줄일 수 있습니다.</li>



<li><strong>관심 영역 지정 (<code>ROI_X, ROI_Y, ROI_W, ROI_H</code>):</strong>
<ul class="wp-block-list">
<li><code>ROI="0,0,0,0"</code> (기본값) : 전체 화면을 감지합니다.</li>



<li><code>ROI="100,50,400,300"</code> : X좌표 100, Y좌표 50에서 시작하여 가로 400, 세로 300 픽셀 영역만 감지합니다. 이 기능은 문이나 창문 쪽만 집중적으로 감시하게 설정할 수 있어 효율적입니다.</li>
</ul>
</li>



<li><strong>쿨다운 시간 (<code>ALERT_COOLDOWN_SEC</code>):</strong> 한 번 알림이 울린 후 지정된 시간 동안은 재알림을 하지 않습니다. 반복적인 알림으로 인한 소음 공해를 방지해 줍니다.</li>



<li><strong>연속 감지 프레임 (<code>CONFIRM_FRAMES</code>):</strong> 짧은 순간의 노이즈로 인한 오탐을 줄이기 위해, 움직임이 N 프레임 이상 연속될 때만 실제 움직임으로 간주합니다.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">5. 마치며: 나만의 스마트 홈 시큐리티</h3>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>💡 직접 구현해보니:</strong> 처음에는 <code>cv2.medianBlur</code>나 <code>CONFIRM_FRAMES</code>를 적용하지 않아 바람에 흔들리는 커튼, 혹은 갑작스러운 조명 변화 때문에 알람이 계속 울려 고생했습니다. 하지만 이러한 &#8216;오탐 방지&#8217; 로직을 추가하니 시스템의 신뢰도가 비약적으로 향상되었습니다. 여러분도 환경에 맞춰 임계값이나 ROI를 조금씩 바꿔보며 최적의 보안 환경을 구축해 보세요!</p>
</blockquote>



<p>이 프로젝트는 간단하지만 매우 실용적인 라즈베리파이 활용 예시입니다. 여기에서 더 나아가 감지된 영상을 텔레그램으로 전송하거나, 특정 시간대에만 작동하도록 스케줄링하는 등 다양한 기능으로 확장할 수 있습니다.</p>



<p></p>



<p>전체소스 참고</p>



<p><strong>&#8220;이 코드는 별도의 유료 API 키 없이도 작동하며, 환경 변수만으로 간편하게 설정할 수 있도록 설계했습니다.&#8221;</strong></p>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import time
import asyncio
import tempfile
import subprocess

import cv2
import numpy as np
import edge_tts

# =========================
# 설정(튜닝 포인트)
# =========================
CAM_INDEX = int(os.environ.get("CAM_INDEX", "0"))     # /dev/video0
FRAME_W   = int(os.environ.get("FRAME_W", "640"))
FRAME_H   = int(os.environ.get("FRAME_H", "480"))

# 모션 감지 ROI (전체 화면이면 기본값 그대로 두세요)
# x,y,w,h
ROI = (
    int(os.environ.get("ROI_X", "0")),
    int(os.environ.get("ROI_Y", "0")),
    int(os.environ.get("ROI_W", str(FRAME_W))),
    int(os.environ.get("ROI_H", str(FRAME_H))),
)

# 모션 민감도: "변화한 픽셀 비율"
# - 너무 잘 울리면 ↑ (0.08~0.20)
# - 잘 안 울리면 ↓ (0.01~0.06)
MOTION_RATIO_THRESH = float(os.environ.get("MOTION_RATIO_THRESH", "0.03"))

# 픽셀 차이 임계치(조명 변화에 민감하면 ↑)
DIFF_THRESH = int(os.environ.get("DIFF_THRESH", "25"))

# 모션을 몇 프레임 연속 감지해야 트리거할지(오탐 줄임)
MOTION_CONFIRM_FRAMES = int(os.environ.get("MOTION_CONFIRM_FRAMES", "3"))

# 경고 후 쿨다운(연속 재생 방지)
ALERT_COOLDOWN_SEC = float(os.environ.get("ALERT_COOLDOWN_SEC", "15.0"))

# 프레임 처리 간격(부하 조절)
SLEEP_SEC = float(os.environ.get("SLEEP_SEC", "0.01"))

# TTS
TTS_VOICE  = os.environ.get("TTS_VOICE", "ko-KR-SunHiNeural")
TTS_RATE   = os.environ.get("TTS_RATE", "+0%")
TTS_VOLUME = os.environ.get("TTS_VOLUME", "+0%")

ALERT_TEXT = "여기 들어오시면 안됩니다. 허가된 주인님만 입장 가능합니다."

# 비프 패턴: "3초짜리 삐삐삐"를 3번 반복
BEEP_FREQ = int(os.environ.get("BEEP_FREQ", "1100"))     # Hz
BEEP_MS   = int(os.environ.get("BEEP_MS", "180"))        # beep 1회 길이
BEEP_GAP_MS = int(os.environ.get("BEEP_GAP_MS", "120"))  # beep 사이 간격
BEEP_CYCLE_SEC = float(os.environ.get("BEEP_CYCLE_SEC", "3.0"))  # 3초
BEEP_REPEAT = int(os.environ.get("BEEP_REPEAT", "3"))    # 3회 반복

# =========================
# 오디오 유틸
# =========================
def require_bins():
    for binname, pkg in &#91;("mpg123", "mpg123"), ("aplay", "alsa-utils")]:
        try:
            subprocess.run(&#91;binname, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
        except FileNotFoundError:
            raise RuntimeError(f"{binname}가 없습니다. 설치: sudo apt-get install -y {pkg}")

def play_mp3(path: str):
    subprocess.run(&#91;"mpg123", "-q", path], check=False)

async def tts_save_mp3(text: str, mp3_path: str):
    communicate = edge_tts.Communicate(text=text, voice=TTS_VOICE, rate=TTS_RATE, volume=TTS_VOLUME)
    await communicate.save(mp3_path)

def speak(text: str):
    text = " ".join(text.split()).strip()
    if not text:
        return
    with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as tf:
        asyncio.run(tts_save_mp3(text, tf.name))
        play_mp3(tf.name)

def gen_beep_wav(path: str, freq_hz: int, ms: int, volume: float = 0.4, sr: int = 16000):
    t = np.linspace(0, ms/1000.0, int(sr*ms/1000.0), endpoint=False)
    wave = (np.sin(2*np.pi*freq_hz*t) * volume).astype(np.float32)
    pcm_i16 = (np.clip(wave, -1.0, 1.0) * 32767).astype(np.int16)

    import wave as _wave
    with _wave.open(path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sr)
        wf.writeframes(pcm_i16.tobytes())

def play_wav(path: str):
    subprocess.run(&#91;"aplay", "-q", path], check=False)

def beep_3sec_triple_repeat3():
    """
    3초 사이클 안에 '삐삐삐'(3회) 후 남는 시간 쉬기.
    그 3초 사이클을 3번 반복.
    """
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tf:
        gen_beep_wav(tf.name, BEEP_FREQ, BEEP_MS)

        for _ in range(BEEP_REPEAT):
            start = time.time()

            # 삐삐삐
            for i in range(3):
                play_wav(tf.name)
                if i &lt; 2:
                    time.sleep(BEEP_GAP_MS / 1000.0)

            # 3초 맞추기
            elapsed = time.time() - start
            remain = max(0.0, BEEP_CYCLE_SEC - elapsed)
            time.sleep(remain)

# =========================
# 모션 감지 유틸
# =========================
def motion_ratio(prev_gray: np.ndarray, gray: np.ndarray) -&gt; float:
    diff = cv2.absdiff(prev_gray, gray)
    _, th = cv2.threshold(diff, DIFF_THRESH, 255, cv2.THRESH_BINARY)

    # 작은 노이즈 제거(조금만)
    th = cv2.medianBlur(th, 5)

    changed = np.count_nonzero(th)
    total = th.size
    return changed / max(1, total)

# =========================
# main
# =========================
def main():
    require_bins()

    cap = cv2.VideoCapture(CAM_INDEX)
    if not cap.isOpened():
        raise RuntimeError(f"카메라 열기 실패: CAM_INDEX={CAM_INDEX} (/dev/video{CAM_INDEX})")

    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_W)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_H)

    x, y, w, h = ROI

    print("=== Motion Guard (Camera) ===")
    print("CAM_INDEX:", CAM_INDEX)
    print("FRAME:", FRAME_W, "x", FRAME_H)
    print("ROI:", ROI)
    print("MOTION_RATIO_THRESH:", MOTION_RATIO_THRESH, "DIFF_THRESH:", DIFF_THRESH)
    print("CONFIRM_FRAMES:", MOTION_CONFIRM_FRAMES, "COOLDOWN:", ALERT_COOLDOWN_SEC)
    print("종료: Ctrl+C\n")

    prev_gray = None
    motion_hits = 0
    last_alert = 0.0

    try:
        while True:
            ok, frame = cap.read()
            if not ok or frame is None:
                time.sleep(0.05)
                continue

            roi = frame&#91;y:y+h, x:x+w]
            gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

            # 조명변화 완화(살짝 블러)
            gray = cv2.GaussianBlur(gray, (5, 5), 0)

            if prev_gray is None:
                prev_gray = gray
                time.sleep(SLEEP_SEC)
                continue

            ratio = motion_ratio(prev_gray, gray)
            prev_gray = gray

            if ratio &gt;= MOTION_RATIO_THRESH:
                motion_hits += 1
            else:
                motion_hits = max(0, motion_hits - 1)

            now = time.time()

            # 트리거 조건: 모션 연속 감지 + 쿨다운 지난 후
            if motion_hits &gt;= MOTION_CONFIRM_FRAMES and (now - last_alert) &gt; ALERT_COOLDOWN_SEC:
                last_alert = now
                motion_hits = 0

                print(f"&#91;ALERT] motion detected! ratio={ratio:.4f}")

                # 1) 경고 멘트
                speak(ALERT_TEXT)

                # 2) 삐삐삐(3초) x 3회
                beep_3sec_triple_repeat3()

            time.sleep(SLEEP_SEC)

    except KeyboardInterrupt:
        print("\n종료합니다.")
    finally:
        cap.release()

if __name__ == "__main__":
    main()

</code></pre>
<p>게시물 <a href="https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/">[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>파이썬으로 네트워크 ping, portscan 웹 화면에서 체크 프로그램</title>
		<link>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-ping-portscan-%ec%9b%b9-%ed%99%94%eb%a9%b4%ec%97%90%ec%84%9c-%ec%b2%b4%ed%81%ac-%ed%94%84%eb%a1%9c%ea%b7%b8/</link>
					<comments>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-ping-portscan-%ec%9b%b9-%ed%99%94%eb%a9%b4%ec%97%90%ec%84%9c-%ec%b2%b4%ed%81%ac-%ed%94%84%eb%a1%9c%ea%b7%b8/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Tue, 03 Feb 2026 09:56:06 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[네트워크]]></category>
		<category><![CDATA[파이썬]]></category>
		<category><![CDATA[포트스캔]]></category>
		<category><![CDATA[프로그램]]></category>
		<category><![CDATA[핑]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1223</guid>

					<description><![CDATA[<p>파이썬으로 PING , PORTSCAN 웹 화면에서 테스트 하기 ​ 포트는 9999 FLASK를 설치 PIP INSTALL FLASK ​ 아래 파일. CSV,...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-ping-portscan-%ec%9b%b9-%ed%99%94%eb%a9%b4%ec%97%90%ec%84%9c-%ec%b2%b4%ed%81%ac-%ed%94%84%eb%a1%9c%ea%b7%b8/">파이썬으로 네트워크 ping, portscan 웹 화면에서 체크 프로그램</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p id="SE-6dce18c2-278e-47ab-939c-6c0720b30031">파이썬으로 PING , PORTSCAN 웹 화면에서 테스트 하기</p>



<p id="SE-8a0b1a42-16a6-4704-9426-3cc10c3814a3">​</p>



<p id="SE-aa2efb20-0f62-4d06-be6c-3f6d4e0b73f8">포트는 9999</p>



<p id="SE-0be077fa-e882-48c8-b86b-8541bf7433af">FLASK를 설치 PIP INSTALL FLASK</p>



<p id="SE-e758dacf-de70-4ede-89e9-a34dcfc2d17f">​</p>



<p id="SE-9a14dc4c-a7de-4153-a8e8-0aad41c6f0e1">아래 파일.</p>



<p id="SE-383ede7b-1dbb-40c5-a47b-617a6e0615a0">CSV, JSON 파일 다운로드 받기 .</p>



<p id="SE-1411fe46-919a-421d-8b46-22459c9a4673">1-포트 설정하여 스캔</p>



<p id="SE-2db68a39-9880-4059-9569-4eb424bc1c48">스캔도 멀티테스킹 처리 .</p>



<p></p>



<p>============ 소스 ===================</p>



<p id="SE-a52f7786-43ba-4ae3-8abb-af611b33ddf2">import subprocess</p>



<p id="SE-11e820d4-b6cb-4713-a5f3-7f820bb8843c">import platform</p>



<p id="SE-6183ba10-96fc-4560-b2c6-4053d277cb61">import re</p>



<p id="SE-d799e250-23ed-4d0f-9e31-c8b608912741">import time</p>



<p id="SE-a76baae9-4e71-4939-885e-5669b3df1abe">import socket</p>



<p id="SE-69875a97-4b82-4953-865c-f26d8443863c">from datetime import datetime</p>



<p id="SE-920646c9-7316-4e4e-873d-2338fe6df435">​</p>



<p id="SE-8ec8de03-0fda-4956-886b-61358148b369">from flask import Flask, request, render_template_string, jsonify</p>



<p id="SE-df3270e5-7d47-4771-ba6e-6c1c6eb28294">​</p>



<p id="SE-551aed4a-77ca-46d7-8f52-5ea399cbd95e">app = Flask(__name__)</p>



<p id="SE-1cee548b-6eda-4d5b-8a39-8d16557c0009">​</p>



<p id="SE-5b2f5b8f-18dc-4b81-9073-5ff2994416bd"># &#8212;&#8212;&#8212;&#8212;&#8212;- HTML 템플릿 &#8212;&#8212;&#8212;&#8212;&#8212;-</p>



<p id="SE-c5f49dfb-d8f4-46f8-95cc-b6a55b26d95d">PAGE_TEMPLATE = &#8220;&#8221;&#8221;</p>



<p id="SE-5b3cfa20-5950-4e55-8cdb-ad06ba98f6c5">&lt;!doctype html&gt;</p>



<p id="SE-77973741-61ff-4544-95f8-e053ebb14735">&lt;html lang=&#8221;ko&#8221;&gt;</p>



<p id="SE-8e873eda-277e-4c04-99a4-8b909d5e7cc8">&lt;head&gt;</p>



<p id="SE-2a39e35e-5568-4e7b-a305-4195575b6d6a">&lt;meta charset=&#8221;utf-8&#8243;&gt;</p>



<p id="SE-69f34426-6d93-4535-9415-f3792bdd43be">&lt;title&gt;Ping + Port Scan 테스트&lt;/title&gt;</p>



<p id="SE-902091d9-4f38-4209-9c9c-ad41e4a5ef29">&lt;style&gt;</p>



<p id="SE-a7ff6f3d-f1d8-4e35-b954-7f3b2ab6426d">body {</p>



<p id="SE-8667e2a6-02df-4c75-a1ee-b95cf4c07b27">font-family: Arial, sans-serif;</p>



<p id="SE-fd72f41f-2c42-46e3-a2d2-fd1e0d05cd62">max-width: 900px;</p>



<p id="SE-05f7017d-d6d8-4943-a851-49c43c3726d0">margin: 40px auto;</p>



<p id="SE-2e1664ab-cfbc-4b30-9c59-9eddde3cd8fa">}</p>



<p id="SE-35fd4063-0a94-4359-9ff3-bf52cc680320">h1 {</p>



<p id="SE-12adba14-f2d8-4e3c-bf0a-cfe7bc087e81">text-align: center;</p>



<p id="SE-914fc5f0-911d-448b-aeeb-c1ccb6974923">}</p>



<p id="SE-9a5bf6a9-136b-43b4-8f18-67ad14fc2626">form {</p>



<p id="SE-fb8a1042-23d7-4f5a-ae22-ec1f0de76999">margin-bottom: 20px;</p>



<p id="SE-55ad20e9-3fc3-41ed-bf79-eb0f1d3289a3">text-align: center;</p>



<p id="SE-03b521c8-60aa-4251-8102-fcfff912d6b0">}</p>



<p id="SE-5e3f48f1-2651-4350-a27e-8eb14d618d16">input[type=&#8221;text&#8221;] {</p>



<p id="SE-2a38d11d-762b-45dd-a6b1-8db409f0e082">width: 280px;</p>



<p id="SE-4ab53379-95b1-4e16-83cc-356d732bc81b">padding: 8px;</p>



<p id="SE-46bcb4d5-b624-4fe1-8398-95f555b90601">font-size: 14px;</p>



<p id="SE-b305015b-1ddc-4c61-8ac0-b75dbe5f0c93">}</p>



<p id="SE-febb3ea6-759d-41e6-8cd5-192354a99a4d">input[type=&#8221;number&#8221;] {</p>



<p id="SE-ca6ce5e8-4b32-46fe-a5b6-71e913802088">width: 80px;</p>



<p id="SE-7c11bfdf-b39c-4430-be7b-50878d904f95">padding: 6px;</p>



<p id="SE-dad76c20-8d92-4553-aca3-b27b10333c78">font-size: 14px;</p>



<p id="SE-d6573cd4-1b46-4f5e-b2d1-64fb5d356a86">}</p>



<p id="SE-a2070929-74d3-4c1b-afb9-8fbd2d67afde">input[type=&#8221;submit&#8221;], button {</p>



<p id="SE-dbb2add4-68b4-4cf4-9604-41c6de643cbc">padding: 8px 16px;</p>



<p id="SE-8b7413dc-dd29-4e80-9213-9f6253dea76a">font-size: 14px;</p>



<p id="SE-618a6bbc-3e89-4737-ae43-e3f2f4a6ae55">cursor: pointer;</p>



<p id="SE-7618669d-92f8-4e1e-aa41-0c23e082faf5">margin-left: 6px;</p>



<p id="SE-8941254b-1ad0-4bec-8f1a-a1a46bc35397">}</p>



<p id="SE-e9b7f56c-3398-49c0-bdac-6d3f0eafc0c9">.result-box {</p>



<p id="SE-44403d56-1d4c-4349-9492-23ef81b90a83">border: 1px solid #ddd;</p>



<p id="SE-125da1e8-8532-4e4d-b4f6-649d9413fc02">padding: 15px;</p>



<p id="SE-2da4677c-bf4a-4c49-b2ea-208f29ec6a90">border-radius: 8px;</p>



<p id="SE-fc1f2ee7-e26e-42f6-b6a7-e32f3cb9bb47">background: #f9f9f9;</p>



<p id="SE-dfcedd67-6b0a-4b54-abe2-c8383a53ffb8">margin-top: 20px;</p>



<p id="SE-533da813-9d96-44af-8f49-9ce4fa7e7664">}</p>



<p id="SE-87fff300-9986-4f19-94f0-29ceb9247017">.status-ok {</p>



<p id="SE-132e6b9f-9f96-4878-ab4f-f56682416fcc">color: green;</p>



<p id="SE-51cf2153-15e5-43d1-a162-bedcae874bcf">font-weight: bold;</p>



<p id="SE-6f3d8adc-1dea-4f06-ae43-4f3b7435b31d">}</p>



<p id="SE-52f89ca8-26cb-4915-a5a7-ef6252dec462">.status-fail {</p>



<p id="SE-d781c3e9-9be4-425a-b6ef-d5e858bdf718">color: red;</p>



<p id="SE-4c7c47f3-4fe4-4b67-a029-b625e00f9224">font-weight: bold;</p>



<p id="SE-6c9fdbd8-ab5d-4724-848c-f3b72043e970">}</p>



<p id="SE-4e08bd82-409b-41b2-a19f-e8c9f6e1e72b">pre {</p>



<p id="SE-be74e649-fdeb-44ba-bcd8-b2a3b905f727">background: #222;</p>



<p id="SE-e1a078db-a08f-4c90-91e5-c43264bf0e56">color: #eee;</p>



<p id="SE-edee00da-2b7d-4e77-ba33-a9b232a67d32">padding: 10px;</p>



<p id="SE-966fb216-f5b4-486f-b9a9-72c620f90c4a">overflow-x: auto;</p>



<p id="SE-57a3ff67-c09f-4e2f-81ca-9c6128d047ed">border-radius: 6px;</p>



<p id="SE-4637a0cb-7276-4003-b64a-d01c9fd4deaf">font-size: 12px;</p>



<p id="SE-9d483904-0dce-4ddd-a1b5-dfd586d34557">}</p>



<p id="SE-f6093cd0-67bb-445b-95bf-afec76de685e">table {</p>



<p id="SE-41f10c92-3715-49a5-a26f-5ca447229b5e">border-collapse: collapse;</p>



<p id="SE-043cae21-fdce-45a6-bc4a-2431f6d102f9">width: 100%;</p>



<p id="SE-d372efce-8811-456f-baa0-d5343cfc2998">margin-top: 10px;</p>



<p id="SE-9f05928a-a859-47b2-a748-cbdc86228045">}</p>



<p id="SE-4c716b83-ce87-4b59-86a2-c54630f0685a">table th, table td {</p>



<p id="SE-6c686e96-8138-45fc-99bf-9a8dc6b81f5b">border: 1px solid #ccc;</p>



<p id="SE-2c305b85-3e94-4da0-bd46-5e27a4df0a91">padding: 6px;</p>



<p id="SE-bd486711-d2a8-47a1-8add-2eab65f69381">text-align: center;</p>



<p id="SE-bc5f0998-732e-4c7a-bc30-ff8f18400e8b">font-size: 13px;</p>



<p id="SE-ff17bda9-314b-456b-a2e7-840dc153a418">}</p>



<p id="SE-7e8eb31d-5a66-472f-8059-71ee9b1e74eb">table th {</p>



<p id="SE-33e71083-ee26-41d3-8118-656025c73a94">background: #eee;</p>



<p id="SE-541a6fc7-bd95-4dac-934f-8a9796710206">}</p>



<p id="SE-6ff8a907-ecfb-4483-a533-ad6269c2833c">.small-text {</p>



<p id="SE-7f9db6a3-66ca-4a99-ab68-eda9ada1a18e">font-size: 12px;</p>



<p id="SE-079e5e7d-3d0a-49e0-a3c2-16b3080f2429">color: #666;</p>



<p id="SE-5f730e12-3850-457e-9c5c-9d04eac70899">}</p>



<p id="SE-212b737a-fdb9-4c91-b87e-a3c2f3ea7338">#continuous-area {</p>



<p id="SE-efa1204a-e569-4e60-aecf-70a5334bd99b">margin-top: 20px;</p>



<p id="SE-594194fb-5635-4108-b012-b549a9625919">}</p>



<p id="SE-24b4f26f-19ba-4916-bf8c-2c9717bb591c">#continuous-log {</p>



<p id="SE-a99b3ac7-496d-4936-8ade-2c81f139d751">max-height: 260px;</p>



<p id="SE-e80666ec-2ecb-415c-babb-68f95da06c38">overflow-y: auto;</p>



<p id="SE-a65a5f84-6acc-4833-9e1b-5bfe659d2429">border: 1px solid #ddd;</p>



<p id="SE-749f06b0-cff5-401c-9372-a055f4c38106">border-radius: 6px;</p>



<p id="SE-d463aa7e-906a-41a3-9607-29bb0dddc5e6">padding: 6px;</p>



<p id="SE-590a092d-03af-4d2e-bd52-023b4b50903d">background: #111;</p>



<p id="SE-9e603e08-053b-4a47-94c2-97319befc3f2">color: #eee;</p>



<p id="SE-e4514a1c-3493-4a1d-8d18-43a5d3a109ec">font-size: 12px;</p>



<p id="SE-743a51ab-e427-421b-afd1-439a3d7d5372">}</p>



<p id="SE-d5f4bbd7-51c5-41cc-b0ec-02b4ffd323ce">#continuous-log div {</p>



<p id="SE-738809f7-b7d3-48c9-880e-5c1dabda6850">margin-bottom: 2px;</p>



<p id="SE-4270def2-33c5-45f0-be16-8018df236c4d">}</p>



<p id="SE-53a5d5c1-6e4b-49c8-bfb6-d839ca3c2505">#port-scan-area {</p>



<p id="SE-53b197fe-bbb9-4945-9e62-8cb71aae2485">margin-top: 20px;</p>



<p id="SE-3e59f1e8-8732-485f-9279-65869cfcc6ee">}</p>



<p id="SE-7780f5cb-ffee-4e78-8557-32b1b08e2741">#port-scan-log {</p>



<p id="SE-11288146-7596-4d08-b03f-018a8ac7d8c4">font-size: 12px;</p>



<p id="SE-7dccbf37-c3d5-4c28-9768-3c9453083237">margin-top: 4px;</p>



<p id="SE-3f060763-e337-46a8-990a-05be339ff3fa">white-space: pre-wrap;</p>



<p id="SE-a377eed3-1cef-4445-8903-5338ecae51cc">}</p>



<p id="SE-6d4e8ef6-63b2-4429-b198-8445f6ea5e23">#port-progress {</p>



<p id="SE-0bdcf172-d464-4d13-9523-4b6e6afe6b33">font-size: 12px;</p>



<p id="SE-d30e5d5f-24c5-422b-86cf-eeab2d1813df">margin-top: 4px;</p>



<p id="SE-664b1d36-4d35-46b4-a4b4-76658e36d33b">}</p>



<p id="SE-6f042574-7698-4a45-b1d6-c73952515885">#open-port-summary {</p>



<p id="SE-90ab4282-26ef-4390-afab-0ac50e7c1822">font-size: 13px;</p>



<p id="SE-197b30d8-cc09-4bbe-8ae5-553393fc50b1">margin-top: 8px;</p>



<p id="SE-c288f9a0-816a-4021-b405-2bef854ccbdf">}</p>



<p id="SE-edca4d8e-027c-414c-98cd-40d968121abd">&lt;/style&gt;</p>



<p id="SE-05e6f5c0-d6f0-45bf-96c0-582e1bdb91dc">&lt;/head&gt;</p>



<p id="SE-0b94e45b-1f3a-45ae-960b-835d960765a1">&lt;body&gt;</p>



<p id="SE-a34490d9-16f3-4b96-a5a3-cde9680bcc4e">&lt;h1&gt;Ping + Port Scan 테스트 (포트 9999)&lt;/h1&gt;</p>



<p id="SE-c7ec814e-4944-47c5-b853-f65dbae4207a">​</p>



<p id="SE-93f86af4-c8ed-4364-b61c-ca1591065f8e">&lt;!&#8211; 폼: 타깃 + 실행횟수 + 계속모드 + 포트 문자열 &#8211;&gt;</p>



<p id="SE-90efd0c5-a2de-483b-b636-7e663270358c">&lt;form id=&#8221;ping-form&#8221; method=&#8221;POST&#8221; action=&#8221;/run_test&#8221;&gt;</p>



<p id="SE-643e973a-ae39-49b3-b0ca-174aa46bb249">&lt;div style=&#8221;margin-bottom: 8px;&#8221;&gt;</p>



<p id="SE-abafd461-3b60-4781-9279-7e4054a8186e">&lt;input type=&#8221;text&#8221; name=&#8221;target&#8221; id=&#8221;target&#8221;</p>



<p id="SE-4e2e8225-f68f-49c8-899a-d53cfb4d5e1a">placeholder=&#8221;도메인 또는 IP 입력 (예: 8.8.8.8)&#8221;</p>



<p id="SE-d0606ef1-b5f4-496a-bb00-69c4a9de1672">value=&#8221;{{ target or &#8221; }}&#8221;&gt;</p>



<p id="SE-9fe7bfdf-655f-448e-af63-f0f42c0e66b6">&lt;/div&gt;</p>



<p id="SE-b3a5ea2f-2344-4fb0-8ac4-6ce89f8bea9d">&lt;div style=&#8221;margin-bottom: 8px;&#8221;&gt;</p>



<p id="SE-107f3f6c-4926-4284-b867-f3af259b0eee">&lt;label&gt;</p>



<p id="SE-d04083a8-bf5b-4874-adba-cdf6fded27ac">실행 횟수 (Ping):</p>



<p id="SE-75605669-3f51-42b7-84dd-fbfe4ba239a6">&lt;input type=&#8221;number&#8221; name=&#8221;count&#8221; id=&#8221;count&#8221; min=&#8221;1&#8243; value=&#8221;{{ count or 4 }}&#8221;&gt;</p>



<p id="SE-86831644-b4fd-4f92-9e44-2d860dd22e7a">&lt;/label&gt;</p>



<p id="SE-53a7760c-6d94-4471-8653-7d863caaad6c">&lt;label style=&#8221;margin-left: 10px;&#8221;&gt;</p>



<p id="SE-09907890-4e17-4659-bb11-571353514dd1">&lt;input type=&#8221;checkbox&#8221; name=&#8221;continuous&#8221; id=&#8221;continuous&#8221;</p>



<p id="SE-06323be9-5a49-4ff7-88f6-04215afa9223">{% if continuous %}checked{% endif %}&gt;</p>



<p id="SE-8f5e20e4-8fb9-4e93-b7cb-5763b2c985f4">계속 (1초마다 Ping)</p>



<p id="SE-d4c59062-720b-421e-aa06-e05a5d47b284">&lt;/label&gt;</p>



<p id="SE-3cb6fb44-03bb-49e1-9953-8ed84a6f6dfe">&lt;/div&gt;</p>



<p id="SE-eca7f07e-2dc9-4f4e-b785-daec78a76122">&lt;div style=&#8221;margin-bottom: 10px;&#8221;&gt;</p>



<p id="SE-8fa36e6e-a212-4c27-88f8-f92a881c22e9">&lt;label&gt;</p>



<p id="SE-bf4a468a-ce28-4ddc-8bbc-22b905e5da84">포트 스캔 (예: 80,443 또는 1-100):</p>



<p id="SE-42352619-a977-41fa-9021-0b191145d5ea">&lt;input type=&#8221;text&#8221; name=&#8221;ports&#8221; id=&#8221;ports&#8221; value=&#8221;{{ ports or &#8221; }}&#8221;&gt;</p>



<p id="SE-081f86ba-4e1c-41cf-ab54-7fa281e2fda2">&lt;/label&gt;</p>



<p id="SE-f6d2424a-e456-48e6-b974-7f6dcea21d08">&lt;/div&gt;</p>



<p id="SE-7f625bd7-7967-4919-92d5-4f33e2bf92dd">&lt;input type=&#8221;submit&#8221; value=&#8221;Ping 실행&#8221;&gt;</p>



<p id="SE-019102c7-6a2f-45a0-81d0-3eb0d38543b0">&lt;button type=&#8221;button&#8221; id=&#8221;stop-btn&#8221; disabled&gt;계속 모드 정지&lt;/button&gt;</p>



<p id="SE-3c8580f6-7db0-4ae6-86a7-827ff0a2f2ea">&lt;/form&gt;</p>



<p id="SE-76a5d8c3-6a3c-4348-af5e-14e2d1b3f11a">​</p>



<p id="SE-0329adcc-1791-4be4-99c7-2a250ad54726">&lt;div class=&#8221;small-text&#8221;&gt;</p>



<p id="SE-0403c511-26df-4cd9-aae9-61ffa7ad4423">• Ping 실행: 지정한 횟수만큼 서버에서 ping 실행 후 결과 요약&lt;br&gt;</p>



<p id="SE-f9a2b384-6d46-41eb-a46d-a36ad5fbad8a">• 계속 모드: 브라우저에서 1초마다 /api/ping_once 호출하여 실시간 Ping 로그&lt;br&gt;</p>



<p id="SE-0096a036-f9b5-49da-b111-79f9a03c46b9">• 포트 스캔: 아래 별도 버튼으로 진행 (멀티스레드처럼 여러 포트를 동시에 스캔, 진행률/요약/다운로드 제공)</p>



<p id="SE-a050ea3b-53cc-4bc6-abd9-7f1464beda3f">&lt;/div&gt;</p>



<p id="SE-4e6d9c53-0ccd-4da7-9aaa-c73855cf0ff4">​</p>



<p id="SE-6bd96dc7-fb46-44d3-ae27-5b38d598a06c">{% if summary %}</p>



<p id="SE-445196e2-4033-4a3d-875b-73bb36cd65fe">&lt;!&#8211; Ping 실행 결과 &#8211;&gt;</p>



<p id="SE-8c120f8d-ca4d-41a5-bc1c-9ecd68c3db8a">&lt;div class=&#8221;result-box&#8221;&gt;</p>



<p id="SE-cbb8c65b-ae99-4963-a5bf-f81a9f4a0da0">&lt;h3&gt;Ping 실행 결과 요약 ({{ summary.total }}회)&lt;/h3&gt;</p>



<p id="SE-c50988eb-f435-415f-80ba-04de9beb3a55">&lt;p&gt;대상: &lt;b&gt;{{ target }}&lt;/b&gt;&lt;/p&gt;</p>



<p id="SE-94187760-f26f-4783-9a69-8aac4325cc8b">&lt;p&gt;</p>



<p id="SE-f8a97d15-5c85-4f9e-b005-24136290fef6">성공: &lt;b&gt;{{ summary.success }}&lt;/b&gt; 회,</p>



<p id="SE-1775297b-bbda-43a9-b289-e0c33cede98b">실패: &lt;b&gt;{{ summary.fail }}&lt;/b&gt; 회,</p>



<p id="SE-b7d3590b-3c1f-465c-a897-934137572fb1">패킷 손실: &lt;b&gt;{{ summary.loss_rate }}%&lt;/b&gt;</p>



<p id="SE-9e60d9f1-10aa-4eb9-ae03-bc6d1bb00df3">&lt;/p&gt;</p>



<p id="SE-8c7fa34b-32aa-4d87-8683-9e0978edcfac">&lt;p&gt;</p>



<p id="SE-80b08a40-b365-4bd8-a81c-c594d5566de1">핑 시간(ms) (성공 패킷 기준):&lt;br&gt;</p>



<p id="SE-48ae9f39-58c9-4d08-a421-c0f30226a90a">최소: &lt;b&gt;{{ summary.min_ms }}&lt;/b&gt;,</p>



<p id="SE-48c1f59a-a9fb-4e78-9cf8-76dd8b419877">최대: &lt;b&gt;{{ summary.max_ms }}&lt;/b&gt;,</p>



<p id="SE-072e767d-8488-4652-b065-6ea7b041ee7d">평균: &lt;b&gt;{{ summary.avg_ms }}&lt;/b&gt;</p>



<p id="SE-7971626a-e32e-4655-83eb-5db01d4618f1">&lt;/p&gt;</p>



<p id="SE-5e595626-ae8b-4a93-a247-bd6aed31c772">&lt;table&gt;</p>



<p id="SE-5f51bbac-b6dd-4503-92d3-f59ea1d6a7d1">&lt;thead&gt;</p>



<p id="SE-8e38f575-3954-4069-ad8e-130399d94543">&lt;tr&gt;</p>



<p id="SE-4febeb6a-eeb3-46d7-830e-83c8bd3e3fb9">&lt;th&gt;순번&lt;/th&gt;</p>



<p id="SE-cc979fb7-7487-46e9-a862-156f831c64bd">&lt;th&gt;시간&lt;/th&gt;</p>



<p id="SE-6c3c07c9-7ca7-41a5-8474-353a25e2b375">&lt;th&gt;상태&lt;/th&gt;</p>



<p id="SE-4782f2ec-e333-409a-8dbb-82f9fee554e1">&lt;th&gt;핑 시간(ms)&lt;/th&gt;</p>



<p id="SE-612f1626-77ef-4c6e-a5fc-90f1eb80743c">&lt;/tr&gt;</p>



<p id="SE-7ba8fb06-4e72-49f6-a4a7-b3ef80b75c19">&lt;/thead&gt;</p>



<p id="SE-53a670d2-3fca-4be4-aad8-522053983c80">&lt;tbody&gt;</p>



<p id="SE-1a1edb9d-526e-43fe-9143-826559c9d5f1">{% for row in detail %}</p>



<p id="SE-40928d93-8d36-4c44-976d-d55338e3e801">&lt;tr&gt;</p>



<p id="SE-a158de20-b98d-40ec-b286-5108bbacf954">&lt;td&gt;{{ loop.index }}&lt;/td&gt;</p>



<p id="SE-9677d53a-f301-4140-bd0e-ed6d10a15ba4">&lt;td&gt;{{ row.timestamp }}&lt;/td&gt;</p>



<p id="SE-b8927158-1b87-4e2e-b328-eba72fba5ddc">&lt;td&gt;</p>



<p id="SE-f8e306e9-cfe8-40ae-8613-9b74b58eafe6">{% if row.success %}</p>



<p id="SE-76795938-67af-41a3-b84d-4c38993aa4ca">&lt;span class=&#8221;status-ok&#8221;&gt;성공&lt;/span&gt;</p>



<p id="SE-d28ab588-eb3c-4225-90bb-c9fe653b7ef8">{% else %}</p>



<p id="SE-d7ea5be6-58b1-4c73-a7a7-d375ddd37efd">&lt;span class=&#8221;status-fail&#8221;&gt;실패&lt;/span&gt;</p>



<p id="SE-d8496b50-f56d-48a1-852a-b55cf487cd0d">{% endif %}</p>



<p id="SE-b1d9c611-0845-4d38-814c-00c2285ac4f6">&lt;/td&gt;</p>



<p id="SE-3c05d66d-7e76-4a60-a87e-bf3aef807ff4">&lt;td&gt;</p>



<p id="SE-4de889d3-f6ff-4c3a-ae30-da0c48eb27e0">{% if row.latency_ms is none %}</p>



<p id="SE-fb4863e9-041c-4f08-b046-29884ddf2198">PING 실패</p>



<p id="SE-2ca8ade8-4ec1-438f-aa8a-9a4d2355d4db">{% else %}</p>



<p id="SE-f7def097-f70c-4949-999a-da50bcac9cbd">{{ &#8220;%.1f&#8221;|format(row.latency_ms) }}</p>



<p id="SE-60d2351a-232f-4dc4-87e6-fb2399f4df09">{% endif %}</p>



<p id="SE-fa2a0b00-cce0-4ef9-b037-a08f2c819bb0">&lt;/td&gt;</p>



<p id="SE-6f22f03b-069a-4fb8-8682-438dea537636">&lt;/tr&gt;</p>



<p id="SE-28bcacbd-6911-48df-8fb4-41ee5d1934df">{% endfor %}</p>



<p id="SE-73196062-07ad-4ee1-b02b-145baacae9a2">&lt;/tbody&gt;</p>



<p id="SE-62e99194-ad91-4184-9377-f42159e7b1e6">&lt;/table&gt;</p>



<p id="SE-5e6ecd7a-aa5e-48db-ae33-659447a45c70">​</p>



<p id="SE-375d8168-60cc-4331-8767-680c4848ea51">{% if raw_output %}</p>



<p id="SE-54fcbf64-8c73-4fa2-aba8-82ce90ee5525">&lt;details&gt;</p>



<p id="SE-a257a28a-e0af-44d4-88f3-85a1d30218d9">&lt;summary&gt;마지막 ping 원본 출력 보기&lt;/summary&gt;</p>



<p id="SE-58cbe632-6c4e-45e5-94b9-eb15257214ed">&lt;pre&gt;{{ raw_output }}&lt;/pre&gt;</p>



<p id="SE-a1311cb0-70ac-4d1a-ae56-19e108ebc964">&lt;/details&gt;</p>



<p id="SE-caead6db-42e5-483b-bff2-b06c66f2bd88">{% endif %}</p>



<p id="SE-65d0822b-3c55-46bf-88ed-aff3a320632e">&lt;/div&gt;</p>



<p id="SE-353602e0-2abe-4d84-a75f-d7170ba932da">{% endif %}</p>



<p id="SE-ee6e473f-5162-4b93-8253-ea210f9ee7b2">​</p>



<p id="SE-a0464e21-23c7-4e33-b18f-1764ce5c422e">&lt;!&#8211; 포트 스캔 영역 &#8211;&gt;</p>



<p id="SE-50ab3969-88a9-4050-baba-7848ce73da2f">&lt;div id=&#8221;port-scan-area&#8221; class=&#8221;result-box&#8221;&gt;</p>



<p id="SE-fd708808-2743-40aa-a5e7-54ec2d142ef5">&lt;h3&gt;포트 스캔 (멀티스레드 진행상황 + 요약 + 다운로드)&lt;/h3&gt;</p>



<p id="SE-4d5c590b-be35-49e6-9a31-1c00c1257497">&lt;p class=&#8221;small-text&#8221;&gt;</p>



<p id="SE-3d49debf-e8eb-4e31-a347-793b4e14b135">• 타깃 / 포트 목록 입력 후 [포트 스캔 시작] 클릭&lt;br&gt;</p>



<p id="SE-e25e691a-176c-4203-b830-2243e4e9eaf5">• 여러 포트를 동시에 스캔하면서 진행률과 결과 테이블을 실시간 갱신합니다.&lt;br&gt;</p>



<p id="SE-2941f819-9a0a-4de8-9fe8-5928f975bd2b">• 스캔 완료 후 열린 포트 요약 + CSV / JSON 다운로드 가능</p>



<p id="SE-0fd42d1b-acbc-47d1-8d1d-955208a45056">&lt;/p&gt;</p>



<p id="SE-9929dc73-272c-4065-8dc9-41fee63f94bc">&lt;div style=&#8221;margin-bottom: 8px;&#8221;&gt;</p>



<p id="SE-c1520890-c24b-44aa-ba61-672151e50803">&lt;label&gt;</p>



<p id="SE-ccaba22b-8818-4b23-a3ea-7444fb58dfd5">동시 스캔 수:</p>



<p id="SE-ca9bd20a-33ab-47f2-89cb-5ecbab2fd226">&lt;input type=&#8221;number&#8221; id=&#8221;concurrency&#8221; min=&#8221;1&#8243; max=&#8221;200&#8243; value=&#8221;{{ concurrency or 20 }}&#8221;&gt;</p>



<p id="SE-577bc6c3-5796-44e4-b9fa-5ea2cd7f6b36">&lt;/label&gt;</p>



<p id="SE-8ace717a-c123-458e-830f-fab4bbb5491c">&lt;span class=&#8221;small-text&#8221;&gt; (한 번에 동시에 처리할 최대 포트 수, 기본 20 / 최대 200)&lt;/span&gt;</p>



<p id="SE-07fe2e85-638c-4824-a2e5-af680feab236">&lt;/div&gt;</p>



<p id="SE-465cbef6-3040-4f35-b3cf-efb8a7bb167f">&lt;button type=&#8221;button&#8221; id=&#8221;port-scan-start-btn&#8221;&gt;포트 스캔 시작&lt;/button&gt;</p>



<p id="SE-00c902a8-aadf-4e8a-8f85-bf7b4cc3d3b5">&lt;button type=&#8221;button&#8221; id=&#8221;port-scan-stop-btn&#8221; disabled&gt;포트 스캔 정지&lt;/button&gt;</p>



<p id="SE-32b47a7d-2613-4ec0-b775-23b98da68688">&lt;button type=&#8221;button&#8221; id=&#8221;download-csv-btn&#8221; disabled&gt;CSV 다운로드&lt;/button&gt;</p>



<p id="SE-606d3471-c2c9-4fed-9ffe-c9bb040adf3f">&lt;button type=&#8221;button&#8221; id=&#8221;download-json-btn&#8221; disabled&gt;JSON 다운로드&lt;/button&gt;</p>



<p id="SE-7da73aaf-257f-48aa-ad1b-b8d6e72975d1">&lt;div id=&#8221;port-progress&#8221;&gt;&lt;/div&gt;</p>



<p id="SE-e67acb56-872b-49dc-993e-ed535e49b931">&lt;div id=&#8221;port-scan-log&#8221;&gt;&lt;/div&gt;</p>



<p id="SE-f44b9cd1-73fa-4b11-bf0c-fab04bf60629">&lt;div id=&#8221;open-port-summary&#8221;&gt;&lt;/div&gt;</p>



<p id="SE-ce5664d0-2260-4550-8315-10d176cd563d">&lt;table&gt;</p>



<p id="SE-ffd88ab2-2210-4c2c-92f7-06376233ffd7">&lt;thead&gt;</p>



<p id="SE-d862ec8d-fb8c-4721-835f-e8a961630e9c">&lt;tr&gt;</p>



<p id="SE-6692fd15-b59a-4089-985f-23ead0482fa7">&lt;th&gt;포트&lt;/th&gt;</p>



<p id="SE-9fc0e847-c58a-43e7-9d29-3809c2ae7ec5">&lt;th&gt;상태&lt;/th&gt;</p>



<p id="SE-5f902014-0789-4f2e-9aaa-7ba77802902f">&lt;th&gt;비고&lt;/th&gt;</p>



<p id="SE-0b6c9c50-6460-49c4-9da4-ac51d41351cc">&lt;/tr&gt;</p>



<p id="SE-1cac92ba-e2e0-4110-95ab-16da1be5317c">&lt;/thead&gt;</p>



<p id="SE-65e1d3e9-9d42-4ae9-a607-74dcf36422cf">&lt;tbody id=&#8221;port-table-body&#8221;&gt;</p>



<p id="SE-a0f40491-0332-4333-9641-3d24d016ae3d">&lt;/tbody&gt;</p>



<p id="SE-738546be-554d-4596-85b1-2f414937e712">&lt;/table&gt;</p>



<p id="SE-731e4808-c81a-443a-b8ad-0d6e7b8201ed">&lt;/div&gt;</p>



<p id="SE-4bf24d01-3ee1-4683-b5fb-a74493af998a">​</p>



<p id="SE-122831eb-0cd9-4ab5-87b8-a81a0ddf0c06">&lt;!&#8211; 계속 모드용 영역 &#8211;&gt;</p>



<p id="SE-4f9bba71-04b3-4cea-9a12-56b0bec86e2d">&lt;div id=&#8221;continuous-area&#8221;&gt;</p>



<p id="SE-2eca8fa3-c8c7-4140-8323-387391c12e0d">&lt;h3&gt;계속 모드 실시간 Ping 로그&lt;/h3&gt;</p>



<p id="SE-416aaa03-c550-4ba4-9b9f-6927b0731fcb">&lt;div class=&#8221;small-text&#8221;&gt;</p>



<p id="SE-d07ac941-f057-44ea-91f6-5ae8ffc14db1">• 위에서 &#8220;계속&#8221; 체크 후 Ping 실행 버튼을 누르면 1초마다 자동으로 ping 결과가 아래에 추가됩니다.&lt;br&gt;</p>



<p id="SE-3cab55e6-92e1-4086-95c3-65d92f5e5f19">• &#8220;계속 모드 정지&#8221; 버튼을 누르면 중단됩니다.</p>



<p id="SE-5ce9b1f3-b883-4167-bfd4-7f6907745ece">&lt;/div&gt;</p>



<p id="SE-31df9d3f-ef64-4e6e-bc92-058a07d2d639">&lt;div id=&#8221;continuous-log&#8221;&gt;&lt;/div&gt;</p>



<p id="SE-9989cc4b-6ee5-43f4-919c-bc9a63fc1b38">&lt;/div&gt;</p>



<p id="SE-caa7a44f-9659-41ed-97de-a0279ae451b4">​</p>



<p id="SE-f7d3a642-2257-4e6b-978d-a617cb0a8eb6">&lt;script&gt;</p>



<p id="SE-a416147a-8f83-45be-a30b-7b0456fdd178">let pingIntervalId = null;</p>



<p id="SE-7e31edb4-8bde-4a6c-979a-a8af29939cb5">​</p>



<p id="SE-890188de-386f-4268-8063-b131aac6a05f">const form = document.getElementById(&#8216;ping-form&#8217;);</p>



<p id="SE-ed6ea4e9-1d0e-442f-9eee-1223c1ec4133">const continuousCheckbox = document.getElementById(&#8216;continuous&#8217;);</p>



<p id="SE-83b01ad4-e3fa-4163-9e13-8a9b814b152c">const stopBtn = document.getElementById(&#8216;stop-btn&#8217;);</p>



<p id="SE-c29cd080-36b2-49f3-aac0-889e2cd462fd">const targetInput = document.getElementById(&#8216;target&#8217;);</p>



<p id="SE-25391262-ec5e-4923-bc4e-5708cc56834e">const countInput = document.getElementById(&#8216;count&#8217;);</p>



<p id="SE-7ff4530c-43f4-4d5f-a7e7-c8da543bc5b1">const logDiv = document.getElementById(&#8216;continuous-log&#8217;);</p>



<p id="SE-6cfbb706-5ad4-4bba-b602-b04a4432048d">​</p>



<p id="SE-a4ceadd0-65dd-4dc6-a37d-14594fe15ce7">const portScanStartBtn = document.getElementById(&#8216;port-scan-start-btn&#8217;);</p>



<p id="SE-e5653f1a-f9b3-4dfe-bbe6-4c9ba839f438">const portScanStopBtn = document.getElementById(&#8216;port-scan-stop-btn&#8217;);</p>



<p id="SE-13438056-e891-4b9d-bee0-c034d098f8b1">const portsInput = document.getElementById(&#8216;ports&#8217;);</p>



<p id="SE-fa4f1b42-d29e-492d-b195-53b1f9507140">const portProgressDiv = document.getElementById(&#8216;port-progress&#8217;);</p>



<p id="SE-87299f04-2277-4416-9195-a15db7cb2acd">const portScanLogDiv = document.getElementById(&#8216;port-scan-log&#8217;);</p>



<p id="SE-a21d9f45-f978-4f76-a60a-e99ddbdc746d">const portTableBody = document.getElementById(&#8216;port-table-body&#8217;);</p>



<p id="SE-476509f8-aa5a-4fec-b94a-b1cf814edb74">const concurrencyInput = document.getElementById(&#8216;concurrency&#8217;);</p>



<p id="SE-cf4ffb35-3b57-459c-a499-b416b6556aac">const openPortSummaryDiv = document.getElementById(&#8216;open-port-summary&#8217;);</p>



<p id="SE-dfb50651-caf9-41b8-9b89-89efe08a0648">const downloadCsvBtn = document.getElementById(&#8216;download-csv-btn&#8217;);</p>



<p id="SE-686ab0c1-2346-4cc0-9213-1cb6b5c41a86">const downloadJsonBtn = document.getElementById(&#8216;download-json-btn&#8217;);</p>



<p id="SE-de351d7a-0dd8-4bfb-bba9-03651efff3ca">​</p>



<p id="SE-bed0c7ee-55ff-4787-9fb5-e1d83740d967">let portScanAbort = false;</p>



<p id="SE-94fd1a52-4d01-496e-b49d-05c26581d2c9">let portResults = []; // {port, open, note}</p>



<p id="SE-2d1496fd-0751-416b-9967-4c97099ff0bb">​</p>



<p id="SE-c0a4420d-153d-4aa6-b741-c150cddd8116">function addLogLine(text, isOk) {</p>



<p id="SE-e8f109d2-5c6b-4988-b0ef-7cd9aa4133a2">const line = document.createElement(&#8216;div&#8217;);</p>



<p id="SE-ddd938ca-519f-401c-94e9-1dda3df7ff32">if (isOk === true) {</p>



<p id="SE-34046e2e-86a0-40b5-8a25-a544075f01ec">line.style.color = &#8216;#7CFC00&#8217;;</p>



<p id="SE-c1e5d204-b18b-43d7-9cb4-727bd668d9c6">} else if (isOk === false) {</p>



<p id="SE-d34d58f5-856c-45fa-b256-f79117feabce">line.style.color = &#8216;#FF6347&#8217;;</p>



<p id="SE-a7196890-4054-4caa-bfae-63404d5b3ff4">} else {</p>



<p id="SE-a98b1e95-2a8d-40b6-bc64-3faa0ee7dc8c">line.style.color = &#8216;#FFFFFF&#8217;;</p>



<p id="SE-cfa6c9b8-fd5a-4222-a158-5e6a347d5d97">}</p>



<p id="SE-cf049fa5-8f12-49bc-b56c-034d1637aedf">line.textContent = text;</p>



<p id="SE-945b76e5-29c4-4cb4-ade2-bfbfe54ff230">logDiv.appendChild(line);</p>



<p id="SE-2225f87b-a068-49ec-9f51-79d11ef91761">logDiv.scrollTop = logDiv.scrollHeight;</p>



<p id="SE-9be8bbec-b052-4c5e-9d9c-d336a1cb824e">}</p>



<p id="SE-f6bd8de3-12be-4eb8-a344-7535f8169f3e">​</p>



<p id="SE-b9a8a8b0-8b27-469d-9fed-5e9c4a3b0297">function startContinuousPing(target) {</p>



<p id="SE-9eb15e14-7ce5-4fb7-bf45-fbedaeeb9f51">if (!target) {</p>



<p id="SE-6980d527-31da-433f-b38b-a193b872ee1d">alert(&#8216;타겟을 입력해주세요.&#8217;);</p>



<p id="SE-4bb9220f-81bb-4f2a-84a1-bd23286b41c8">continuousCheckbox.checked = false;</p>



<p id="SE-10ccbee3-caf5-478d-b5a1-8a996d401386">return;</p>



<p id="SE-f7160c6f-3b46-4080-ac30-9db34cc23f09">}</p>



<p id="SE-9b69fa32-0f04-4680-879e-d40ef4ae543d">​</p>



<p id="SE-5aed8ff9-8e6f-4e02-9611-1c47d2e78518">if (pingIntervalId !== null) {</p>



<p id="SE-2fcac262-33e0-4309-9984-6bb99415821b">clearInterval(pingIntervalId);</p>



<p id="SE-5a108fea-b4a2-4ff7-9435-6b8ae6ecd8c4">}</p>



<p id="SE-f554b98a-4fa8-4e0a-96cf-b399206fbb10">​</p>



<p id="SE-244bd90c-05e6-428c-b571-39f8234b2100">addLogLine(&#8216;&#8212; 계속 모드 시작: &#8216; + target + &#8216; &#8212;&#8216;, null);</p>



<p id="SE-1f1ca813-f31d-4edd-ab2a-69a858f9cb85">​</p>



<p id="SE-891ea92e-8ad1-4aa8-acc5-743be7b5ada3">pingIntervalId = setInterval(function() {</p>



<p id="SE-d5a9882d-926c-4065-85d8-d2d8b4e082be">fetch(&#8216;/api/ping_once?target=&#8217; + encodeURIComponent(target))</p>



<p id="SE-e8df6678-6602-48f7-9f37-f0468ad6a811">.then(function(response) { return response.json(); })</p>



<p id="SE-17653a95-8add-4e42-b62f-9a7f1c28cca9">.then(function(data) {</p>



<p id="SE-5491304b-fdb9-4a1a-89a6-f69cc0e01e40">const ts = data.timestamp || &#8221;;</p>



<p id="SE-8f664a87-ed28-4120-98c2-742d54b3c8fc">const ok = data.success;</p>



<p id="SE-4a164e34-1d65-4133-80e5-6fee6e913065">const latency = data.latency_ms;</p>



<p id="SE-9da14515-6adf-4ebb-b73b-44d5963dda98">let msg = &#8216;[&#8216; + ts + &#8216;] &#8216; + data.target + &#8216; -&gt; &#8216;;</p>



<p id="SE-4b838699-431d-4db0-be1a-c82b3fb76743">​</p>



<p id="SE-5d05d855-4f3b-418a-9920-e264bbddadfb">if (ok) {</p>



<p id="SE-2ef6dc54-583e-4d66-92af-7feec09ec183">if (latency === null) {</p>



<p id="SE-e77f2127-e68c-4ebe-83e0-e6d776ed0b4e">msg += &#8216;✅ 성공 | PING 시간: -&#8216;;</p>



<p id="SE-9cb2f5c6-8733-4de6-bc7b-90c4de5cbfd9">} else {</p>



<p id="SE-95fb8de2-16a9-4ba3-9a27-52cd82d3a700">msg += &#8216;✅ 성공 | PING 시간: &#8216; + latency.toFixed(1) + &#8216; ms&#8217;;</p>



<p id="SE-245c9fa9-545e-4430-8178-c2cd3295abc8">}</p>



<p id="SE-5dd0fdf1-99da-407e-a21d-229ca143829d">} else {</p>



<p id="SE-5fee3a87-fb83-41ba-b8f7-33a086511c1f">msg += &#8216;❌ 실패 | PING 시간: 알 수 없음&#8217;;</p>



<p id="SE-7c7a8173-ecf7-46a1-b052-8e1b61ca0a77">}</p>



<p id="SE-fa085b10-7e93-4231-ad62-ec13bb049c8c">addLogLine(msg, ok);</p>



<p id="SE-d938574f-6d32-47b7-ba15-c29974d72e7b">})</p>



<p id="SE-2e9d5bb4-fdf1-4f43-bc70-f130c761fd90">.catch(function(err) {</p>



<p id="SE-4d2bb5e5-0ee3-40b8-845d-d7d1c6c2699b">addLogLine(&#8216;에러: &#8216; + err, false);</p>



<p id="SE-f40c437f-8cce-49c9-9d85-bf191c36c047">});</p>



<p id="SE-5cd794cf-2f25-49de-b1be-d9bb5fb9a062">}, 1000);</p>



<p id="SE-afee2512-f882-4477-a301-b3588e830c40">​</p>



<p id="SE-6534b713-bc71-4456-9880-b120202cae56">stopBtn.disabled = false;</p>



<p id="SE-eacb0e1e-cd2a-4e47-bd74-dc351c2ec1d1">}</p>



<p id="SE-6357d0bd-b747-4f7f-90a6-391b81a7a206">​</p>



<p id="SE-b963f4fa-867c-4df5-852a-2ab02f0f7e12">function stopContinuousPing() {</p>



<p id="SE-df4a0ebd-c8b9-43b8-b6b5-44094e9b6e88">if (pingIntervalId !== null) {</p>



<p id="SE-917362fd-6c76-4f28-9daa-3cff1aae9c9f">clearInterval(pingIntervalId);</p>



<p id="SE-1bf9b4d7-af05-4763-9e72-5868c8eb9280">pingIntervalId = null;</p>



<p id="SE-7ac2dc49-62b3-45f8-8766-9e1e4cd2cdaa">addLogLine(&#8216;&#8212; 계속 모드 정지 &#8212;&#8216;, null);</p>



<p id="SE-b7649dbe-9989-424d-9484-1ad045ee0738">}</p>



<p id="SE-a0687009-9081-476a-a751-413b45c4ee1a">stopBtn.disabled = true;</p>



<p id="SE-f312f06b-cb94-4b7c-b02d-05fc1424ba26">}</p>



<p id="SE-cfe13b4d-f26b-4898-8bc2-97e258fc1322">​</p>



<p id="SE-52ac3012-83fe-451b-8f8f-8d804188a0f4">// Ping 폼 제출 시 동작</p>



<p id="SE-74ef2fa5-b9b1-491b-ad36-dde1292b9b90">form.addEventListener(&#8216;submit&#8217;, function(ev) {</p>



<p id="SE-604e33fd-bb0a-4d31-ad19-cc7f1a251a56">const isContinuous = continuousCheckbox.checked;</p>



<p id="SE-23f7af71-6111-4b09-bbb6-c4d79b053bf5">const target = targetInput.value.trim();</p>



<p id="SE-b25ef23b-53bc-404a-9652-28c5c6726485">​</p>



<p id="SE-67fb5942-c7ec-4992-9eb0-e877dab34ad2">if (isContinuous) {</p>



<p id="SE-5e210560-69c4-4f9d-8019-4cab64baadad">ev.preventDefault();</p>



<p id="SE-b8bef7d1-34b7-4c09-ad83-b05fafd18b8b">startContinuousPing(target);</p>



<p id="SE-6d15d1af-6d08-46f6-b418-38b78684cb13">} else {</p>



<p id="SE-88c216cd-70c1-48ed-802c-c22bf83caacf">stopContinuousPing();</p>



<p id="SE-714d15bc-6f5b-4679-bfe1-3b445433d9f1">if (!countInput.value) {</p>



<p id="SE-e0d653b9-9c11-4073-b5a1-acd6e69767b0">countInput.value = 4;</p>



<p id="SE-80346428-7da0-442b-9a0d-7d2567e8d11c">}</p>



<p id="SE-c918efd7-a61e-4320-a7e7-3541f01ee66e">}</p>



<p id="SE-f9c805e3-a0d3-40ed-98eb-48a3dad3c083">});</p>



<p id="SE-5bff3ab9-0d9a-4f0b-9007-a4357473e617">​</p>



<p id="SE-a7e4ccc6-b7ca-4040-941e-05790548e841">stopBtn.addEventListener(&#8216;click&#8217;, function() {</p>



<p id="SE-4c896451-ca71-4909-b8ef-dbc015a1b12c">stopContinuousPing();</p>



<p id="SE-c1c58d1f-d74f-4fa0-a4ec-2065efb25c33">});</p>



<p id="SE-f1aae170-90ff-4b30-abbf-9ce71285670f">​</p>



<p id="SE-78d7c17d-0e4d-4193-924c-c61ec8277201">// &#8212;- 포트 스캔 관련 JS &#8212;-</p>



<p id="SE-6c413c2a-53d7-4fa6-9813-11c6f9c650b5">function parsePortsLocal(portsStr) {</p>



<p id="SE-a2585748-2910-4968-a6f1-2e1cd6398bf9">portsStr = portsStr.trim();</p>



<p id="SE-7c800694-630c-4bf1-975b-7adcd85d3f9e">if (!portsStr) return [];</p>



<p id="SE-dfd9d347-f826-413c-9a65-4a19a62788e0">​</p>



<p id="SE-ce1f724b-944d-459b-9bce-4d93e9523833">const result = new Set();</p>



<p id="SE-4c169b9e-1aa1-43b3-be97-64fe2e88ef7c">const parts = portsStr.split(&#8216;,&#8217;);</p>



<p id="SE-be728fdb-bd2d-4d3e-a3d5-0b23206de151">for (let part of parts) {</p>



<p id="SE-abdb4a3e-524b-4724-91cf-b0f2d8c84218">part = part.trim();</p>



<p id="SE-64bf7ae3-719f-45ec-a23c-af907bded8eb">if (!part) continue;</p>



<p id="SE-8ba6fc68-aa08-459b-8e74-d9c2b0803075">​</p>



<p id="SE-f26d9c91-7620-40fa-9214-5de194168220">// 혹시 전각 대시(– — −) 들어올 경우 ASCII &#8216;-&#8216; 로 치환</p>



<p id="SE-13f5c1ac-c7e1-4d17-9202-ef3ae554271f">part = part.replace(/[–—−]/g, &#8216;-&#8216;);</p>



<p id="SE-32ea9193-ff14-45f0-a2c0-0641aafa2f01">​</p>



<p id="SE-9886fd7f-c67a-4dee-99d6-a64d25e01723">if (part.includes(&#8216;-&#8216;)) {</p>



<p id="SE-cf713900-4e88-4928-9e02-4a52ba58e1ec">const rangeParts = part.split(&#8216;-&#8216;);</p>



<p id="SE-1ab2d4a8-b6a9-4419-ae19-2bb50631b5e6">if (rangeParts.length !== 2) continue;</p>



<p id="SE-8267098a-2682-4759-b596-a8234201dd64">const startStr = rangeParts[0].trim();</p>



<p id="SE-d25b5128-63ad-4365-8b4d-26944674ba17">const endStr = rangeParts[1].trim();</p>



<p id="SE-fac1d2eb-2fe4-4262-8498-702009082261">const s = parseInt(startStr, 10);</p>



<p id="SE-fe6db5f2-9965-4fae-919d-acba68d49309">const e = parseInt(endStr, 10);</p>



<p id="SE-b8dcfd80-2c93-42d9-bf88-dde633f2b8b2">if (isNaN(s) || isNaN(e)) continue;</p>



<p id="SE-6ad799fb-ada4-4708-923b-2748257ce9ac">const start = Math.min(s, e);</p>



<p id="SE-d65d8d03-ceed-4efa-aa22-cebe06bb7e1a">const end = Math.max(s, e);</p>



<p id="SE-f5498071-bf56-43ba-9cde-a252a186543b">for (let p = start; p &lt;= end; p++) {</p>



<p id="SE-df6c242c-04b1-43e6-a3dc-379fa846148f">if (p &gt;= 1 &amp;&amp; p &lt;= 65535) result.add(p);</p>



<p id="SE-b662591c-c501-46a1-9896-c520f8c6a51e">}</p>



<p id="SE-d35d7ff6-d6e0-4bc0-b011-b063e5af459a">} else {</p>



<p id="SE-ba59939f-d035-4fa0-b085-f79e663fd81d">const p = parseInt(part, 10);</p>



<p id="SE-86364e2e-b623-41ea-89ca-cf92b18019e4">if (!isNaN(p) &amp;&amp; p &gt;= 1 &amp;&amp; p &lt;= 65535) result.add(p);</p>



<p id="SE-fe3fbae4-05c0-4b5e-86b3-be916b1d85a1">}</p>



<p id="SE-e1e6db27-84aa-4942-88bb-8bae931039d0">}</p>



<p id="SE-019a6150-04e3-487a-9b41-0d49dffad087">return Array.from(result).sort((a, b) =&gt; a &#8211; b);</p>



<p id="SE-15457b10-a725-465b-ae99-0166e1768fd5">}</p>



<p id="SE-8e2cfb8f-20c9-4e53-87a1-ef05b3634b13">​</p>



<p id="SE-c10352ef-eae3-483b-b50a-b25ec4f0fe63">async function scanPortOnce(target, port) {</p>



<p id="SE-a429400f-6791-4af5-9709-e4b31d588a97">const res = await fetch(</p>



<p id="SE-d072b001-44d7-4f57-86bd-77529d2cd677">&#8216;/api/scan_port?target=&#8217;</p>



<p id="SE-11f6e32d-aa5b-4b9e-9548-bd64113d4b36">+ encodeURIComponent(target)</p>



<p id="SE-029537ed-4ed8-4cc9-8b62-d163239951e7">+ &#8216;&amp;port=&#8217; + encodeURIComponent(port)</p>



<p id="SE-5d2f4030-29e6-4869-8ae7-df79951b1c7b">);</p>



<p id="SE-a13203d5-887e-4c44-ab83-d3b88bb3a842">return res.json();</p>



<p id="SE-14bad5e5-38ad-4fc4-9cbb-3d38ad64bc7e">}</p>



<p id="SE-96c625b4-2521-4edd-ab27-21f57dec51d4">​</p>



<p id="SE-fa8335e5-d228-4f8a-b60c-88db5d9a8eb0">function updateOpenPortSummary() {</p>



<p id="SE-f36be8a7-8be4-44e7-9693-80816fecaa2b">if (portResults.length === 0) {</p>



<p id="SE-f13e6f38-8208-4f46-af50-6ac38f90d381">openPortSummaryDiv.textContent = &#8220;&#8221;;</p>



<p id="SE-3ef8fb4c-36ce-4694-8101-7b4d421875d2">return;</p>



<p id="SE-4f52a592-3fac-44bf-91a4-19057e767216">}</p>



<p id="SE-60d071fd-3b4a-4945-a9fb-b073089ef192">const openPorts = portResults</p>



<p id="SE-ac4bd320-38da-4312-9a09-0691bb771014">.filter(r =&gt; r.open)</p>



<p id="SE-2fc5d24e-4905-4c3d-8d34-317ad8df545c">.map(r =&gt; r.port)</p>



<p id="SE-f5a6f80d-bee4-4edb-b7f6-eb3e0a3e872f">.sort((a, b) =&gt; a &#8211; b);</p>



<p id="SE-3e33f064-7520-4e99-9945-0853c7aa8f40">​</p>



<p id="SE-1871d3e3-e201-430b-914d-45eb5401d650">if (openPorts.length === 0) {</p>



<p id="SE-c71de5da-e1cb-48bc-9d62-e14b1fe74bea">openPortSummaryDiv.textContent = &#8220;열린 포트 없음.&#8221;;</p>



<p id="SE-eaab455a-8acd-4079-b5e8-37ae3bb02237">} else {</p>



<p id="SE-63781d2e-4b57-4a0b-b5d5-8ed51348a9af">openPortSummaryDiv.textContent =</p>



<p id="SE-5412028d-96ad-40b4-b52f-3fd52a96eb17">&#8220;열린 포트 (&#8221; + openPorts.length + &#8220;개): &#8221; + openPorts.join(&#8220;, &#8220;);</p>



<p id="SE-fc15bfdc-9d2d-4348-b6c8-4b271268a0d0">}</p>



<p id="SE-7372dade-7323-4277-998d-59f5c9e11c70">}</p>



<p id="SE-dfb8b845-8c93-41bc-834f-3eb9399f454a">​</p>



<p id="SE-033d7b3a-9130-44cc-ae01-e463ac60e34a">function downloadJSON() {</p>



<p id="SE-793bc98a-6f5a-448c-b4a5-e3de03c52312">if (portResults.length === 0) {</p>



<p id="SE-50112704-0248-46b2-8104-9cbfdd58cf93">alert(&#8220;다운로드할 스캔 결과가 없습니다.&#8221;);</p>



<p id="SE-90bb5a84-25ea-48ea-bfd9-a9839556a737">return;</p>



<p id="SE-832384ed-1e9e-4e9e-b9af-5933d04bf8d4">}</p>



<p id="SE-5c79dc24-9ae3-4941-9a27-e9487cb28e4b">const target = targetInput.value.trim() || &#8220;target&#8221;;</p>



<p id="SE-fd842b3d-ddd6-401d-a51f-31fd384caa19">const dataStr = JSON.stringify(portResults, null, 2);</p>



<p id="SE-dc95bdd9-7b59-4e8b-ae3d-c260607512be">const blob = new Blob([dataStr], { type: &#8220;application/json&#8221; });</p>



<p id="SE-a8ca936e-69b2-43b9-964b-6f158341f403">const url = URL.createObjectURL(blob);</p>



<p id="SE-4228b239-3e64-45d2-866b-e509b8716c4d">const a = document.createElement(&#8220;a&#8221;);</p>



<p id="SE-a4f826be-8569-41a8-a871-f3dfecf1f0fc">const ts = new Date().toISOString().replace(/[:.]/g, &#8220;-&#8220;);</p>



<p id="SE-2ae44b35-9fa1-4926-a77d-881fddd721e7">a.href = url;</p>



<p id="SE-d7c45456-c144-4c48-979a-0150a02c0e21">a.download = &#8220;port_scan_&#8221; + target + &#8220;_&#8221; + ts + &#8220;.json&#8221;;</p>



<p id="SE-b15d2246-d28d-47aa-8eae-c3da8e540d15">document.body.appendChild(a);</p>



<p id="SE-e38a0936-7194-4a90-9830-ca5f8634977b">a.click();</p>



<p id="SE-107273c0-cbbe-4b13-914d-a324bd5b3fe8">document.body.removeChild(a);</p>



<p id="SE-e7a7e3d8-a35f-4727-aae4-fcff26f48065">URL.revokeObjectURL(url);</p>



<p id="SE-66012696-bee1-459f-940a-f4b34fcebe79">}</p>



<p id="SE-25e893ac-0ad9-4773-8a49-bba9f8e5dbba">​</p>



<p id="SE-2aa6687e-ea9a-4877-987b-de1699c456d0">function downloadCSV() {</p>



<p id="SE-df86b0b6-5b77-4961-83c8-94ff973099be">if (portResults.length === 0) {</p>



<p id="SE-fbd80fbc-82c0-49f3-8745-eb478b35829d">alert(&#8220;다운로드할 스캔 결과가 없습니다.&#8221;);</p>



<p id="SE-8c257293-1d14-41bf-be9d-77aaceeb998f">return;</p>



<p id="SE-c4658066-806c-49b8-97a2-20d33c71e4c0">}</p>



<p id="SE-1b24e648-2b0e-43fb-99c5-85deb21a7810">const target = targetInput.value.trim() || &#8220;target&#8221;;</p>



<p id="SE-5ad3ef16-ff2e-4649-a9ce-06c68c0bda32">​</p>



<p id="SE-8ea5536a-baf3-404f-8049-c31374b1a584">function escapeCsv(value) {</p>



<p id="SE-58a0c788-5e47-494f-81c0-bbd7e1c08b02">if (value == null) return &#8220;&#8221;;</p>



<p id="SE-5b8d548b-f316-4f6c-a64c-dbb0c25ecf3d">const s = String(value);</p>



<p id="SE-33959af6-a670-41b7-a775-1707a5accb10">if (s.includes(&#8216;&#8221;&#8216;) || s.includes(&#8216;,&#8217;) || s.includes(&#8216;\\n&#8217;)) {</p>



<p id="SE-04f4ec13-4a28-4a05-a986-98d5baa60b9a">return &#8216;&#8221;&#8216; + s.replace(/&#8221;/g, &#8216;&#8221;&#8221;&#8216;) + &#8216;&#8221;&#8216;;</p>



<p id="SE-341e2010-7ca0-4f81-baa8-29a8e591b068">}</p>



<p id="SE-bff20802-42a9-4ab9-ad28-ff95eac1cd22">return s;</p>



<p id="SE-a346d77e-bac9-4c65-90b9-5e4ca1b83134">}</p>



<p id="SE-b8fc9e94-0715-46e5-abfb-1638b10e178d">​</p>



<p id="SE-c27eaff0-1ce5-4307-8696-3eadd29652bc">let csv = &#8220;port,status,note\\n&#8221;;</p>



<p id="SE-6232423e-5749-458c-8517-96cf92a42065">for (const r of portResults) {</p>



<p id="SE-040d75d4-7405-42dd-8984-062ab28d93b9">const status = r.open ? &#8220;OPEN&#8221; : &#8220;CLOSED&#8221;;</p>



<p id="SE-9347d305-109c-4876-8651-2899d6ddf1de">csv += [</p>



<p id="SE-78ff4779-52e5-49e6-b124-c53b46bf677f">escapeCsv(r.port),</p>



<p id="SE-4c24083c-48f1-4b5a-a8cb-2aa83bc9d88c">escapeCsv(status),</p>



<p id="SE-340cac94-877f-481f-8bee-3cdbcc2f913c">escapeCsv(r.note)</p>



<p id="SE-af1c551b-911e-4303-8396-d68ce731d393">].join(&#8220;,&#8221;) + &#8220;\\n&#8221;;</p>



<p id="SE-17d454a2-6786-4fcf-97bd-e72c9a59d6d6">}</p>



<p id="SE-a0a857c8-2350-4e73-98ce-9af5aedce9b9">​</p>



<p id="SE-ea96ca49-84aa-4467-a6f9-8648663dff66">// 👉 한글이 엑셀에서 안 깨지도록 UTF-8 BOM 추가</p>



<p id="SE-dd14a1ad-146b-46b7-9196-16ff340cdc83">const bom = &#8220;\\uFEFF&#8221;; // JS에서 문자열 리터럴로 해석되면서 실제 BOM 문자로 변환</p>



<p id="SE-da6516e6-cd50-46ef-94e7-219ba4142c04">const blob = new Blob([bom + csv], { type: &#8220;text/csv;charset=utf-8;&#8221; });</p>



<p id="SE-4e8a88ff-6557-4afc-9f99-471f6085ad1b">​</p>



<p id="SE-8c98dbdd-4664-40aa-af6a-ad7ac162562c">const url = URL.createObjectURL(blob);</p>



<p id="SE-43806f73-8072-4e0b-b66e-7e61417aa555">const a = document.createElement(&#8220;a&#8221;);</p>



<p id="SE-e5500455-2c29-4072-a710-e4d9797b3131">const ts = new Date().toISOString().replace(/[:.]/g, &#8220;-&#8220;);</p>



<p id="SE-190fbf22-20cc-44c5-b93a-617139ac1900">a.href = url;</p>



<p id="SE-23d715ce-ec87-45a8-b207-2a1d6d4fc621">a.download = &#8220;port_scan_&#8221; + target + &#8220;_&#8221; + ts + &#8220;.csv&#8221;;</p>



<p id="SE-37d0190f-eae1-4395-bd1d-cf5a46c87437">document.body.appendChild(a);</p>



<p id="SE-5918cb38-d639-4d24-93d9-32e0119022ac">a.click();</p>



<p id="SE-c2169fee-e013-4a4e-8a3b-2c56c0e3aa94">document.body.removeChild(a);</p>



<p id="SE-e9250656-9f97-4bd6-9a27-85605f91a2c9">URL.revokeObjectURL(url);</p>



<p id="SE-89368a7d-75a4-4ddc-a8ce-024233ba9359">}</p>



<p id="SE-0135af9c-a764-44b3-abcc-cd10a50d2a71">​</p>



<p id="SE-7a9ea432-c48c-496d-afd5-22fffc636c9d">async function startPortScan() {</p>



<p id="SE-3e9998c1-d980-4956-9721-21042d0cc421">const target = targetInput.value.trim();</p>



<p id="SE-c0df711d-ed67-4641-ba58-959b8bd324ae">const portsStr = portsInput.value.trim();</p>



<p id="SE-47f4fed8-b69f-4173-8e39-8a7594de61e5">​</p>



<p id="SE-ad24fe1e-d7a4-474a-9578-02332a68bddb">if (!target) {</p>



<p id="SE-3ecb83eb-6c44-4d88-a607-e4f36ce25f4a">alert(&#8220;타깃(도메인 또는 IP)을 입력하세요.&#8221;);</p>



<p id="SE-e7846cf8-0ef4-4a13-b4b8-57cba963da14">return;</p>



<p id="SE-76e17c67-e0a5-42a8-81d2-ccf7c4c4224f">}</p>



<p id="SE-58fe7a59-cbf9-4f2a-9eff-259fffa117ef">if (!portsStr) {</p>



<p id="SE-2e39b6ed-dcca-4db9-b0ef-f31495f94e5f">alert(&#8220;포트 목록을 입력하세요.&#8221;);</p>



<p id="SE-2d226abe-8722-4c76-ad97-00bbca1fef53">return;</p>



<p id="SE-f67b7fb9-0217-4f59-aa85-d3fe4b736f15">}</p>



<p id="SE-f7cda149-1c8f-460b-831c-caf310772476">​</p>



<p id="SE-75b0a059-e526-4e11-aa9e-58f3eb1af6fa">const ports = parsePortsLocal(portsStr);</p>



<p id="SE-44288f63-0952-4a17-a654-b7e128f96b60">if (ports.length === 0) {</p>



<p id="SE-95b0a759-e946-4f59-b2c0-3647ff3edab0">alert(&#8220;유효한 포트가 없습니다. (예: 80,443 또는 1-100)&#8221;);</p>



<p id="SE-a6c610a1-c2e2-4b1c-8c2e-93a51ab1d523">return;</p>



<p id="SE-6d7641d0-2218-47d7-a757-676124e9388b">}</p>



<p id="SE-714083da-1080-4bcb-b8ab-46fde1f2c1ab">​</p>



<p id="SE-3a4a7f4a-f190-4227-84a1-0e3673b0a06e">let concurrency = parseInt(concurrencyInput.value, 10);</p>



<p id="SE-e737b4d5-0d16-4f95-a810-5438712eb21a">if (isNaN(concurrency) || concurrency &lt;= 0) {</p>



<p id="SE-65fbd1fe-b28f-40e9-b863-18c28d094a9b">concurrency = 10;</p>



<p id="SE-9093e792-2e63-4084-b66a-21e538572a66">}</p>



<p id="SE-1e9ba9d7-87d1-4f14-b592-fc5639956f0c">if (concurrency &gt; 200) {</p>



<p id="SE-839f7d7f-7742-4b53-a74a-28c89c7a4be7">concurrency = 200;</p>



<p id="SE-6fe03b4a-1f6b-485b-8c18-c038d107c2bf">}</p>



<p id="SE-eb7fb56a-f0d0-4658-951d-865b6e0df257">concurrencyInput.value = concurrency;</p>



<p id="SE-d39a3f1e-83af-4fde-88f2-8347df889178">​</p>



<p id="SE-e494b86a-4ed9-424e-ae69-a4f5356c449d">// 초기화</p>



<p id="SE-3debee0b-3380-4c94-bfb1-e0bca90999c4">portScanAbort = false;</p>



<p id="SE-ff684e5d-17a2-44f8-886d-189dcf6a9717">portResults = [];</p>



<p id="SE-2ef78276-14d5-4c5f-b203-28897567da06">portTableBody.innerHTML = &#8220;&#8221;;</p>



<p id="SE-0f09af8a-5975-4359-9162-a7780beb4899">portProgressDiv.textContent = &#8220;&#8221;;</p>



<p id="SE-c842f6bd-8f3b-49f5-a09e-c4c375c6d9c5">portScanLogDiv.textContent = &#8220;&#8221;;</p>



<p id="SE-e3ff74f4-dbfe-4062-af51-4bc1ff3a7756">openPortSummaryDiv.textContent = &#8220;&#8221;;</p>



<p id="SE-04f02d72-5f1e-4ed5-8f46-bc37908db8c2">portScanStartBtn.disabled = true;</p>



<p id="SE-f3d3017f-8c43-4d7c-b7bd-ca74b37983c3">portScanStopBtn.disabled = false;</p>



<p id="SE-07d3975e-14e5-41d9-8c68-f30d2e892af6">downloadCsvBtn.disabled = true;</p>



<p id="SE-dbaf95ea-361b-4625-8ce3-6d02f855d0b6">downloadJsonBtn.disabled = true;</p>



<p id="SE-c9623637-a055-4d40-9216-05a839f43186">​</p>



<p id="SE-301086ad-a502-4cb5-9480-197b3b9d2ba4">const total = ports.length;</p>



<p id="SE-e65a81b4-47b9-4ce9-aa11-b49d532cee00">let done = 0;</p>



<p id="SE-ae6a4aa4-8c45-4638-ab7a-e631c2846286">let index = 0;</p>



<p id="SE-8e8d152b-7b84-4f98-a0eb-72eea4dedddf">​</p>



<p id="SE-2b6ebf6b-8064-4b93-8f00-9f5cc4b4b67e">portScanLogDiv.textContent =</p>



<p id="SE-6d6ec4c5-8e9c-4b7a-8ba0-aa045f9fb3e4">`스캔 시작: ${target}, 포트 ${total}개 (동시 스캔 수: ${concurrency})`;</p>



<p id="SE-aad73b58-3bbd-430e-a75a-b4a18e1e00f7">portScanLogDiv.textContent += &#8220;\\n포트 목록: &#8221; + ports.join(&#8220;, &#8220;);</p>



<p id="SE-a58a021f-31ba-4497-a2d5-78e856e8d4c6">​</p>



<p id="SE-c80884fd-817d-49b7-b109-d8898aa6b955">async function worker() {</p>



<p id="SE-300031be-1834-4724-80bf-aa8eb53d4d8c">while (!portScanAbort) {</p>



<p id="SE-1eb826b7-79e8-4443-aab0-02485516d479">let currentIndex;</p>



<p id="SE-eb7469e5-3d35-458e-8f51-e8d7586d8b10">if (index &gt;= total) break;</p>



<p id="SE-a7204e93-49d4-450f-8849-ccb1c6c72e6e">currentIndex = index++;</p>



<p id="SE-b82e621c-a780-4ab1-b881-15698dca39f2">const port = ports[currentIndex];</p>



<p id="SE-891a9e5d-b177-4519-bb42-9a41a2c42962">​</p>



<p id="SE-cb30df01-c329-4c2d-84ee-0cbeb29d2b87">portProgressDiv.textContent =</p>



<p id="SE-922b56ca-028c-4757-b7fe-1d10f453860b">`진행: ${done} / ${total} (현재 포트: ${port})`;</p>



<p id="SE-6b4eea01-f868-4299-be9a-865511a1c6f0">​</p>



<p id="SE-25024a7b-0248-4538-89b0-0852eae2e386">try {</p>



<p id="SE-67031a33-1dae-486a-9500-f84fe26aa26b">const data = await scanPortOnce(target, port);</p>



<p id="SE-5cda0556-5865-44ae-8aa8-123dae9a5ba0">​</p>



<p id="SE-05f81fb1-e138-4e6a-9ec7-cedbdb011220">const tr = document.createElement(&#8220;tr&#8221;);</p>



<p id="SE-f5afb984-66c8-4308-be81-1f204d8af233">const tdPort = document.createElement(&#8220;td&#8221;);</p>



<p id="SE-3bda6df2-8270-4a01-8703-945663da4b08">const tdStatus = document.createElement(&#8220;td&#8221;);</p>



<p id="SE-3faaeaf7-b06d-4546-9942-28e8cdc74cec">const tdNote = document.createElement(&#8220;td&#8221;);</p>



<p id="SE-5d449363-ab92-4465-bb14-59035f5996bd">​</p>



<p id="SE-4f52370c-db12-44f7-a539-6b22cd1299bf">tdPort.textContent = port;</p>



<p id="SE-27bb795d-2cd8-4f5f-91b1-5fd609c02b3c">if (data.open) {</p>



<p id="SE-b995f59c-4701-4ecf-9e70-2c7024a268ff">tdStatus.innerHTML = &#8216;&lt;span class=&#8221;status-ok&#8221;&gt;OPEN&lt;/span&gt;&#8217;;</p>



<p id="SE-4313363e-2e54-452c-a690-17febfc1cff4">} else {</p>



<p id="SE-d1b7ff55-8424-4cb2-8fe9-0425dfbe7d1a">tdStatus.innerHTML = &#8216;&lt;span class=&#8221;status-fail&#8221;&gt;CLOSED&lt;/span&gt;&#8217;;</p>



<p id="SE-871323a9-05ac-4e9b-98e6-b998e8253471">}</p>



<p id="SE-a0cdabb1-e090-4393-96f8-fdfa66a8d214">tdNote.textContent = data.note || &#8220;&#8221;;</p>



<p id="SE-dcbdb3e6-134f-4a22-b519-4b334babaf0b">​</p>



<p id="SE-f7d8b3ea-726b-4022-8e74-b4f668a53260">tr.appendChild(tdPort);</p>



<p id="SE-6eff6eef-1fba-4f6d-9b37-301a29b09b56">tr.appendChild(tdStatus);</p>



<p id="SE-c176d652-d123-4f4c-bc20-deca2a999501">tr.appendChild(tdNote);</p>



<p id="SE-bd56f892-2cba-48f2-87c3-4502c6d9d1c4">portTableBody.appendChild(tr);</p>



<p id="SE-7aa2eafb-68b6-4932-9480-2ffc33def4dd">​</p>



<p id="SE-2cb157a0-2655-4004-b7d1-c8cea445302c">portResults.push({</p>



<p id="SE-33a424f5-a15d-4d53-9733-d3ee88a14db9">port: port,</p>



<p id="SE-29f288b5-3b57-48f2-b42d-6ff4a28bde9b">open: !!data.open,</p>



<p id="SE-c07f2890-3a3e-41c1-acac-7ac6e110d45e">note: data.note || &#8220;&#8221;</p>



<p id="SE-e277865a-f2a3-40e8-9ab5-c551f7ca0f16">});</p>



<p id="SE-5a7f30ac-dec3-41d2-88d6-422859c5f8eb">​</p>



<p id="SE-af966d35-4521-4e2b-bcb2-026a1c4edf6d">} catch (e) {</p>



<p id="SE-12012075-91ae-49be-80bf-311d77254cc7">const tr = document.createElement(&#8220;tr&#8221;);</p>



<p id="SE-0972b68f-00ef-4817-ba88-4025f86d0e59">tr.innerHTML = &#8216;&lt;td&gt;&#8217; + port + &#8216;&lt;/td&gt;&#8217; +</p>



<p id="SE-7e02065c-ad55-4969-872d-c8900180f424">&#8216;&lt;td&gt;&lt;span class=&#8221;status-fail&#8221;&gt;ERROR&lt;/span&gt;&lt;/td&gt;&#8217; +</p>



<p id="SE-9b308082-ed6e-47bb-abd5-9851dfb15dbe">&#8216;&lt;td&gt;&#8217; + e + &#8216;&lt;/td&gt;&#8217;;</p>



<p id="SE-297f5dc4-8e1b-4076-9d27-970b83074659">portTableBody.appendChild(tr);</p>



<p id="SE-35aaea8b-ffe6-4b8e-ac19-c4e344b3d90b">​</p>



<p id="SE-462127be-a1a1-483c-a5ec-aeb36fddd234">portResults.push({</p>



<p id="SE-85ff8cf2-ae54-470c-bfc5-bb6a1aaef50f">port: port,</p>



<p id="SE-dc82b968-835a-4b2a-8815-4d51e6351711">open: false,</p>



<p id="SE-bc2aa2f3-481e-454b-8845-da3fe2f1d5ec">note: &#8220;에러: &#8221; + e</p>



<p id="SE-d0011099-9cde-4a43-b55f-867221db7eac">});</p>



<p id="SE-3b13024b-8866-4536-8e92-58a7ce1b49b4">}</p>



<p id="SE-cb38abec-0ece-4e0e-aa4b-c3484a459984">​</p>



<p id="SE-86cfdf89-fdf8-406a-bbfb-0ebbe76648cc">done += 1;</p>



<p id="SE-02f73428-7b2a-4038-a025-c02a508098d5">portProgressDiv.textContent = `진행: ${done} / ${total}`;</p>



<p id="SE-77d1bbc0-9080-4110-ab8f-a409c01e55d7">}</p>



<p id="SE-39d030b7-fcf6-4840-b62b-04d90b3c1e99">}</p>



<p id="SE-803fbec6-23c3-4455-9bd8-ebbc75880e3a">​</p>



<p id="SE-fcf07cca-d16c-4090-ac12-ab3bdb1cc124">const workers = [];</p>



<p id="SE-fd325045-3631-4d6b-97e2-a5644e4ad075">const numWorkers = Math.min(concurrency, total);</p>



<p id="SE-7de62744-9dc1-4d82-9497-2af9659c2390">for (let i = 0; i &lt; numWorkers; i++) {</p>



<p id="SE-2b256aa9-b7ac-4e03-be46-3a6b16f6ae33">workers.push(worker());</p>



<p id="SE-117941cc-c3ed-4e26-835d-135551c5ad88">}</p>



<p id="SE-f2edc15f-10d4-4142-96df-fbbf9ab81bbd">​</p>



<p id="SE-b1ded541-33d2-450e-a561-28e8d181f2d0">await Promise.all(workers);</p>



<p id="SE-7f070788-e701-4b33-a2f4-4f57fc3e00c6">​</p>



<p id="SE-78fd58b7-53b9-4bc7-a71b-42aa7dc96b58">updateOpenPortSummary();</p>



<p id="SE-cdbae70b-e774-4963-b210-f14063c0603e">​</p>



<p id="SE-6231e316-11c2-4cf9-bbb3-201f00d628fa">if (!portScanAbort) {</p>



<p id="SE-20592d3f-216a-490a-81a5-b9fb84431f34">portScanLogDiv.textContent += &#8220;\\n스캔 완료.&#8221;;</p>



<p id="SE-05d7d605-ba5b-47b1-b39c-c1a5bee36a64">} else {</p>



<p id="SE-e0ca1af9-7e1a-4a4e-be35-68174b540114">portScanLogDiv.textContent += &#8220;\\n사용자에 의해 스캔 중단됨.&#8221;;</p>



<p id="SE-b95e5e9b-2b89-4f77-a340-931c9e02024b">}</p>



<p id="SE-2a6de277-1df8-4e8a-ad35-08f308e37b25">​</p>



<p id="SE-854f6817-1607-4859-aef6-a5ae971ace04">portScanStartBtn.disabled = false;</p>



<p id="SE-2eeda5ca-0f37-491a-b5d2-1cd09e0b576f">portScanStopBtn.disabled = true;</p>



<p id="SE-8c2b7477-8a35-43ec-bfff-cfc2f1e255f5">if (portResults.length &gt; 0) {</p>



<p id="SE-9cdcea0d-74ec-4b8d-958c-9b20ae2f4ccd">downloadCsvBtn.disabled = false;</p>



<p id="SE-7f31ee8f-69c4-425b-b2fe-3ea24d8faddf">downloadJsonBtn.disabled = false;</p>



<p id="SE-2f00d6e4-557f-4819-baa3-afb8dd66e19c">}</p>



<p id="SE-65814443-cee6-46bf-b83b-75103e920db5">}</p>



<p id="SE-dad133ff-6a3d-43c0-abf7-6baa6883c32a">​</p>



<p id="SE-9ea129b7-24b6-4893-886d-69e895f4f782">function stopPortScan() {</p>



<p id="SE-9e7fe9e6-dac7-4095-805b-830d319f98e9">portScanAbort = true;</p>



<p id="SE-355e836d-d5a3-412f-9a0d-d20ecadc5b8f">portScanStopBtn.disabled = true;</p>



<p id="SE-d4caf592-5025-43ba-9ef6-fb3fd5037f0e">portScanLogDiv.textContent += &#8220;\\n정지 요청됨&#8230;&#8221;;</p>



<p id="SE-e00505bd-fe82-40b7-acda-91c8b4c8af82">}</p>



<p id="SE-40b520de-a761-4287-a870-140d7683b2fc">​</p>



<p id="SE-6ac9bb25-92a3-4edb-9a28-8e8326aeea83">portScanStartBtn.addEventListener(&#8220;click&#8221;, startPortScan);</p>



<p id="SE-eeebbdc4-567b-4e37-83ea-65ad551cda74">portScanStopBtn.addEventListener(&#8220;click&#8221;, stopPortScan);</p>



<p id="SE-e72b0097-99b6-49e0-bb00-fd7a048cd9d3">downloadCsvBtn.addEventListener(&#8220;click&#8221;, downloadCSV);</p>



<p id="SE-1b2abac2-8894-4cee-b5d5-8b62b71baeb2">downloadJsonBtn.addEventListener(&#8220;click&#8221;, downloadJSON);</p>



<p id="SE-594f91dc-c93f-4b91-b7e2-9a66378912d4">&lt;/script&gt;</p>



<p id="SE-5dbbb3c8-714b-4615-a723-f68f0ff40bb1">&lt;/body&gt;</p>



<p id="SE-fd0a1fec-cf52-4094-8069-59d5422162f4">&lt;/html&gt;</p>



<p id="SE-27618033-3491-4594-ab17-6bf0cc46ab29">&#8220;&#8221;&#8221;</p>



<p id="SE-0ffd78a1-8518-4786-9429-7a58f7287b7b">​</p>



<p id="SE-801b2a6a-58d2-4812-80a4-33b6bc01da53"># &#8212;&#8212;&#8212;&#8212;&#8212;- Ping 관련 함수 &#8212;&#8212;&#8212;&#8212;&#8212;-</p>



<p id="SE-c167bcc5-b0ea-4f04-8f85-fb94c28d0266">​</p>



<p id="SE-46f4ab94-967c-4411-b885-7f362398ca31">def parse_latency(output: str):</p>



<p id="SE-aa7e4c4b-35fc-43b0-9ddb-80d1ac7708bc">&#8220;&#8221;&#8221;</p>



<p id="SE-3e9f77c7-6d2b-425f-94eb-290e33f70291">ping 출력에서 time=XXms / time=XX ms 패턴 찾아서 ms 값 추출</p>



<p id="SE-436e59b9-88a8-42e1-a767-997a19fe4764">&#8220;&#8221;&#8221;</p>



<p id="SE-114cab54-3591-4237-aed3-6bc1487b46e4">pattern = r&#8221;time[=&lt;]\s*([0-9]+(?:\.[0-9]+)?)\s*ms&#8221;</p>



<p id="SE-602b6e35-daa8-4af6-8cea-878853a09427">match = re.search(pattern, output, re.IGNORECASE)</p>



<p id="SE-7a5ed771-2628-4015-9d1e-2ab3115846b8">if match:</p>



<p id="SE-cbbb2487-0b12-465b-ac77-757d7da398b3">try:</p>



<p id="SE-8a612baf-d3a3-4d10-816c-62ee1e89b24d">return float(match.group(1))</p>



<p id="SE-b73a92d0-191a-419c-970d-11337d7703fd">except ValueError:</p>



<p id="SE-2eaf27d7-84e9-49c4-b9a6-eeb30ba0b9bc">return None</p>



<p id="SE-0ce3cb37-ff6b-4948-ba5d-95d04ed9426d">return None</p>



<p id="SE-6f529187-dc39-4d3d-beaa-846fb5ea9c17">​</p>



<p id="SE-de3bbf3c-98de-4535-afde-07e4f00b97b1">​</p>



<p id="SE-3465f8d7-cbc8-4731-9075-106bfdcaa1d6">def ping_once(target: str, timeout_ms: int = 1000):</p>



<p id="SE-5bd13f0a-9d85-49f5-add3-28228329dd92">&#8220;&#8221;&#8221;</p>



<p id="SE-37b7ab3d-24ae-4d43-955b-fcd667a110b5">ping 1회 실행</p>



<p id="SE-2ecb191b-3067-46d0-beeb-c00c765622a2">&#8220;&#8221;&#8221;</p>



<p id="SE-79446186-a679-4b5e-a8cd-d8eb6cf4566b">system = platform.system().lower()</p>



<p id="SE-500f6aab-9547-46b6-9c48-2d0fdd1cacce">​</p>



<p id="SE-161e37a0-3e97-4602-9c34-2f3c497f884b">if system.startswith(&#8220;win&#8221;):</p>



<p id="SE-ca30656a-1523-401e-9203-20e263be39a4">cmd = [&#8220;ping&#8221;, &#8220;-n&#8221;, &#8220;1&#8221;, &#8220;-w&#8221;, str(timeout_ms), target]</p>



<p id="SE-887d44bc-08ba-4bd9-acc2-b9a76f89f98e">else:</p>



<p id="SE-1410a13c-34c8-4b5a-9175-553839608a6e">timeout_s = max(int(timeout_ms / 1000), 1)</p>



<p id="SE-083ffa5a-b050-4e5f-aa29-3b55ca73da77">cmd = [&#8220;ping&#8221;, &#8220;-c&#8221;, &#8220;1&#8221;, &#8220;-W&#8221;, str(timeout_s), target]</p>



<p id="SE-1ac12298-8dc0-43f6-872a-5a19a698d72a">​</p>



<p id="SE-36e5b062-4031-4ad6-9b0c-1985669dae6b">try:</p>



<p id="SE-ad0f5fd1-cc03-4377-9e3f-e5110dbff1ce">result = subprocess.run(</p>



<p id="SE-01d7f97a-52bf-422a-bfe7-464a10f4fada">cmd,</p>



<p id="SE-fc3480c3-341e-4d45-a24f-d1aaa429f462">stdout=subprocess.PIPE,</p>



<p id="SE-7b0b5ac1-d060-4f3f-a8be-8c82ef429771">stderr=subprocess.PIPE,</p>



<p id="SE-03daec3d-6473-464f-a0f2-af4d78dff87c">text=True</p>



<p id="SE-364a79fa-b8c7-4ac1-b4f2-3f7260dd111a">)</p>



<p id="SE-097e6442-b2a3-4768-8cd5-8ae57652ebbb">except Exception as e:</p>



<p id="SE-22670ac2-18c7-4f93-ac78-908594363336">return False, None, f&#8221;ping 실행 에러: {e}&#8221;</p>



<p id="SE-201cb634-1fbb-4097-bde3-0060f2468ba7">​</p>



<p id="SE-f1919f4d-176b-4eab-bc6a-e45f91bc239f">success = (result.returncode == 0)</p>



<p id="SE-0b4d69f9-4d27-4059-ae8c-43e19a3de440">latency = parse_latency(result.stdout)</p>



<p id="SE-d19c666b-03be-4e5b-96ea-ea79c558027b">raw_output = result.stdout if result.stdout.strip() else result.stderr</p>



<p id="SE-c9c03fff-8bb1-4203-b72d-43b67b3caa35">​</p>



<p id="SE-3e01c936-4aa4-48c1-b041-b2f1cb5b25ca">return success, latency, raw_output</p>



<p id="SE-d4b4f5fa-d32e-4904-ad7b-9d6fe66ae657">​</p>



<p id="SE-127738e2-beea-4479-8a29-e48fb4a79794">​</p>



<p id="SE-c8c87432-d366-49dd-9f58-d04cb373786b">def ping_multi(target: str, count: int, timeout_ms: int = 1000, interval_sec: float = 0.2):</p>



<p id="SE-90c01f5f-f4b5-4efc-9d2e-6f50ad948fbe">&#8220;&#8221;&#8221;</p>



<p id="SE-9bd50605-9874-40e7-a78d-7460b8d4bebc">같은 대상에 대해 여러 번 ping 실행</p>



<p id="SE-51647577-8d10-45c3-968a-4ba56234c937">&#8220;&#8221;&#8221;</p>



<p id="SE-52561ede-d6bd-4374-ac1d-b7b49397c22c">detail_rows = []</p>



<p id="SE-1a1014ff-a72c-481c-8d88-0d1eafb74398">last_output = &#8220;&#8221;</p>



<p id="SE-af939c6a-1c14-4670-808e-924d605d6ad3">for _ in range(count):</p>



<p id="SE-a034bbec-14c8-4382-b46f-dd53ea921bb7">success, latency_ms, raw_output = ping_once(target, timeout_ms=timeout_ms)</p>



<p id="SE-7f9f423b-d9b3-4f34-943c-5bd997b0efe0">last_output = raw_output</p>



<p id="SE-09cfe7f0-21fb-4fbb-ae70-370037691c58">detail_rows.append({</p>



<p id="SE-781ab639-150f-4e26-9d14-5b28f5292a48">&#8220;success&#8221;: success,</p>



<p id="SE-6c8de2e4-c74b-4cec-8d2b-95af1889fd57">&#8220;latency_ms&#8221;: latency_ms,</p>



<p id="SE-11f21ea0-33c4-44f9-ae22-c0bd3fa49402">&#8220;timestamp&#8221;: datetime.now().strftime(&#8220;%H:%M:%S&#8221;),</p>



<p id="SE-0ff60aef-ea47-43d3-9d9e-01b65ee5e540">})</p>



<p id="SE-07bae62b-ec0f-4d96-9783-b222c126fb71">if _ &lt; count &#8211; 1:</p>



<p id="SE-33e0653a-57d6-43c7-8d20-f26919b5039d">time.sleep(interval_sec)</p>



<p id="SE-8793ffb0-a975-40c3-aa5a-a82cb4a6c4f9">​</p>



<p id="SE-e2334cf9-4794-4104-b99b-f664d0609976">total = len(detail_rows)</p>



<p id="SE-f6574ff4-5b2f-416f-9e05-78e14f1839aa">success_cnt = sum(1 for r in detail_rows if r[&#8220;success&#8221;])</p>



<p id="SE-5bb55f33-d365-40a5-852a-460ecd858b25">fail_cnt = total &#8211; success_cnt</p>



<p id="SE-182f9657-e475-4ca8-97f4-c5b39a90331d">loss_rate = round(fail_cnt / total * 100, 1) if total &gt; 0 else 0.0</p>



<p id="SE-36ab1f9c-349c-4a28-a5fc-922dbda8fc81">​</p>



<p id="SE-6dc33cfe-da00-42d4-ae14-11b80cb663dd">latencies = [r[&#8220;latency_ms&#8221;] for r in detail_rows if r[&#8220;latency_ms&#8221;] is not None]</p>



<p id="SE-7a002cda-c1ba-4841-88ca-cf36200636b0">if latencies:</p>



<p id="SE-a35afced-0895-4111-9577-d8afa3207723">min_ms = round(min(latencies), 1)</p>



<p id="SE-86144303-6a5c-4365-8818-2afb55674069">max_ms = round(max(latencies), 1)</p>



<p id="SE-1f777c31-edd3-47d2-9377-7f8e8be85188">avg_ms = round(sum(latencies) / len(latencies), 1)</p>



<p id="SE-b3b07309-2407-49d2-ad44-aca402e7a9c0">else:</p>



<p id="SE-ca2dbc69-5b67-433c-b517-4a9e87e91ef1">min_ms = max_ms = avg_ms = &#8220;-&#8220;</p>



<p id="SE-95afc686-cfdc-4cf2-9529-2933688211b9">​</p>



<p id="SE-d811463f-c417-4039-9e75-9a2aeefc3796">summary = {</p>



<p id="SE-35ef8ca8-5f8d-4e7d-a5ba-a88b9d0f7571">&#8220;total&#8221;: total,</p>



<p id="SE-622bebf7-3928-4da1-8b7f-653ba070fa87">&#8220;success&#8221;: success_cnt,</p>



<p id="SE-93dccdf8-9715-49b6-a689-3f76e7f4e423">&#8220;fail&#8221;: fail_cnt,</p>



<p id="SE-05dd2d12-521d-42c2-8ce9-5ee21a1bb72f">&#8220;loss_rate&#8221;: loss_rate,</p>



<p id="SE-e16229b5-bd43-4490-ba19-c23703a0e99e">&#8220;min_ms&#8221;: min_ms,</p>



<p id="SE-4f20f249-a861-4a83-834c-e911ae974454">&#8220;max_ms&#8221;: max_ms,</p>



<p id="SE-dc505b02-5d3c-47d6-9cbc-22c369dedfb4">&#8220;avg_ms&#8221;: avg_ms,</p>



<p id="SE-1fe3d44c-2cc6-481a-9b14-ecad5a38c749">}</p>



<p id="SE-2d787dfd-290d-49e4-89f6-0ba8932396cd">​</p>



<p id="SE-0ad98fb0-1997-4bf2-adb0-f52741b38a38">return summary, detail_rows, last_output</p>



<p id="SE-ed32c3b8-4dd5-4b24-be82-df77c0c4be22">​</p>



<p id="SE-3afadcc7-1e54-43b1-8caa-ed4f575ed38e">​</p>



<p id="SE-202e6572-9a1e-461c-8d6b-db678218bd87"># &#8212;&#8212;&#8212;&#8212;&#8212;- 포트 스캔 관련 함수 &#8212;&#8212;&#8212;&#8212;&#8212;-</p>



<p id="SE-3d7c1558-9499-4d4e-80e8-3c7bd5f9611e">​</p>



<p id="SE-26af34e0-f5c1-47cd-b60e-17efcf5aad04">def scan_port(target: str, port: int, timeout: float = 1.0):</p>



<p id="SE-77d1fb0f-61c0-4c7c-8804-4287e6038c73">&#8220;&#8221;&#8221;</p>



<p id="SE-6a7bc5d8-34e0-4063-9cc9-97837778355b">TCP 포트 1개 스캔 (열림/닫힘)</p>



<p id="SE-1e12825e-6f0c-4854-867c-cef42c4dfab3">&#8220;&#8221;&#8221;</p>



<p id="SE-5eca775b-b2e0-4dcf-8934-fcea6b3f0769">sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)</p>



<p id="SE-a208890e-ac4f-48cc-96ba-87a685b367a4">sock.settimeout(timeout)</p>



<p id="SE-c08943d7-d295-49d4-b3b4-62bf6a9c0367">try:</p>



<p id="SE-220845e6-1ad0-442b-a1dd-53f3f65db399">result = sock.connect_ex((target, port))</p>



<p id="SE-52e7c97e-3863-4ec6-8a5e-77bfc011ee60">if result == 0:</p>



<p id="SE-094aafeb-2077-43bc-be56-77c4706186a7">return True, &#8220;연결 성공&#8221;</p>



<p id="SE-d5acb821-b50b-414b-9690-f3bac5f6c163">else:</p>



<p id="SE-d2a80913-7284-41ab-83e5-f0b05d065395">return False, f&#8221;연결 실패 (코드: {result})&#8221;</p>



<p id="SE-3d0e0e31-0e58-46e4-b6e9-b6178fd73c0f">except Exception as e:</p>



<p id="SE-254e41db-4114-4ac3-be62-783543d4eacd">return False, f&#8221;예외 발생: {e}&#8221;</p>



<p id="SE-ea17cb2b-089a-4e4f-8245-eeba497aaba6">finally:</p>



<p id="SE-b2bdd5f7-313a-4f91-8c8d-14d33753b4cb">sock.close()</p>



<p id="SE-b6645d30-c9e5-4448-9612-973f2888235e">​</p>



<p id="SE-c7c159b5-7339-4cad-b693-8587a27b92ff">​</p>



<p id="SE-8f4ca71f-cf00-4f8a-90cc-f2fb52dd994d"># &#8212;&#8212;&#8212;&#8212;&#8212;- Flask 라우팅 &#8212;&#8212;&#8212;&#8212;&#8212;-</p>



<p id="SE-0539e5d2-2c7f-4864-8bce-642079ede83d">​</p>



<p id="SE-8bd9d394-ae68-496b-b0ce-71c83b5779e0">@app.route(&#8220;/&#8221;, methods=[&#8220;GET&#8221;])</p>



<p id="SE-ce4acac2-7e96-4fe4-9b6a-8dd3c8e7de59">def index():</p>



<p id="SE-481a55e3-bad3-4e94-9fff-86e8953d818d">return render_template_string(</p>



<p id="SE-30483b4e-a189-4417-bd7f-c1ad319524ae">PAGE_TEMPLATE,</p>



<p id="SE-9f9f9c32-576f-4fdc-bf6f-e7dfaeda9e17">target=&#8221;&#8221;,</p>



<p id="SE-bcab2cac-a490-4b10-b04b-c3f8bd1bb4a5">count=4,</p>



<p id="SE-95c85d9e-6b37-44dc-bbae-654bc2d5b0a8">continuous=False,</p>



<p id="SE-86f62eeb-ee4e-441a-8ea9-a1ee9ecbcb6d">ports=&#8221;&#8221;,</p>



<p id="SE-f6696879-715d-434b-b205-427885237c30">concurrency=20,</p>



<p id="SE-717edeb3-5fba-4168-978e-b521a9120f7f">summary=None,</p>



<p id="SE-34e9ac01-4820-48f4-befd-91d3e6559e60">detail=None,</p>



<p id="SE-25a05b19-8472-4474-9188-6bfc4b6bf906">raw_output=None,</p>



<p id="SE-98dae548-9ea2-4d49-a343-75a961863cde">)</p>



<p id="SE-44dc5ca7-707d-45ba-b9bf-cd91769a7f66">​</p>



<p id="SE-e608e72b-4d29-477f-ad48-b9b0d350a117">​</p>



<p id="SE-859975e7-d481-420c-860d-960f75b75426">@app.route(&#8220;/run_test&#8221;, methods=[&#8220;POST&#8221;])</p>



<p id="SE-8474d683-382e-4718-9ce6-10f1af064630">def run_test():</p>



<p id="SE-b0bae9f3-dc5f-43f6-9faf-41c936ef5fca">target = request.form.get(&#8220;target&#8221;, &#8220;&#8221;).strip()</p>



<p id="SE-c894b737-9ae6-4e40-8829-4fd4def8cf90">count_str = request.form.get(&#8220;count&#8221;, &#8220;4&#8221;).strip()</p>



<p id="SE-cd368ace-7967-40d8-bf5f-f9903f9983ab">continuous = bool(request.form.get(&#8220;continuous&#8221;))</p>



<p id="SE-e19ad8d4-42a0-44fe-91e6-011dda95e4fd">ports_input = request.form.get(&#8220;ports&#8221;, &#8220;&#8221;).strip()</p>



<p id="SE-6f7b1975-775c-4dd3-acf9-7a4935e5153c">​</p>



<p id="SE-1fe61d6b-657f-4e55-8918-3c738c21169b">summary = None</p>



<p id="SE-2adbd9c1-ca21-4a2f-b6d2-67cb097d02b4">detail = None</p>



<p id="SE-ce10e609-5ab2-4c7d-8c7b-0038c54f8a00">raw_output = None</p>



<p id="SE-c5413bf6-191e-4890-84db-0f5d9e856934">​</p>



<p id="SE-a51ca242-a4d8-4e6d-867c-f161bda12ecd">if not target:</p>



<p id="SE-b47e8803-7eb2-405b-9781-f8eaa2694575">return render_template_string(</p>



<p id="SE-d2f42c89-2c6e-4278-b270-33a5c667f0b8">PAGE_TEMPLATE,</p>



<p id="SE-433f6990-b262-4165-8594-8b61ee1ecdeb">target=target,</p>



<p id="SE-43084af5-6f2a-4717-9151-94fd0646e2de">count=count_str,</p>



<p id="SE-7a567e17-d28a-4f23-9228-2ea1aca0e13e">continuous=continuous,</p>



<p id="SE-cc355c7f-fa75-44d3-920e-8cf7acc33f8b">ports=ports_input,</p>



<p id="SE-1023926b-3ef5-47dd-ba95-4fdd6f6059d8">concurrency=20,</p>



<p id="SE-37c3e7a8-d81f-43f5-9ca3-0aeff9b60b3e">summary=None,</p>



<p id="SE-459530a7-a24c-4298-a52c-6331a9a8cb89">detail=None,</p>



<p id="SE-00a940cf-e35e-47eb-9411-4fa2109d3889">raw_output=None,</p>



<p id="SE-3cac88fe-1b50-40f7-b7c7-5669dd503183">)</p>



<p id="SE-c7d0cb62-b46e-48c4-8aef-ca3c6f0e7bc7">​</p>



<p id="SE-bc6a8b49-ceff-428a-b065-6d0f1c18f88f">try:</p>



<p id="SE-a15002c7-693a-4bd4-b65f-d6b6d8961c1d">count = int(count_str)</p>



<p id="SE-fa89d925-6174-4c5d-8e71-1f1105f16a8d">if count &lt;= 0:</p>



<p id="SE-e11854a1-54ed-44e2-a2f9-c0fb16f4d1a8">count = 1</p>



<p id="SE-49602e74-9ac0-4e28-9c0f-da4e805aa21a">except ValueError:</p>



<p id="SE-e21678e3-609a-4b9b-a759-7456f2b16651">count = 4</p>



<p id="SE-f2992d2c-f67b-44d2-967c-009c93ec3450">​</p>



<p id="SE-4c6e4606-51ea-433c-ba3d-1cd827c881e7">summary, detail, raw_output = ping_multi(target, count=count, timeout_ms=1000)</p>



<p id="SE-1db365b4-1ac8-4780-8785-ade3bbed9700">​</p>



<p id="SE-463fc1e7-e5ce-4cdb-b458-511cdd135d73">return render_template_string(</p>



<p id="SE-d9ceb947-e2e0-4a70-ae2f-8d8293ed158a">PAGE_TEMPLATE,</p>



<p id="SE-221d955d-3eec-46c4-b97c-bc60d864642c">target=target,</p>



<p id="SE-1c888fd6-39ea-4980-aef8-5848c8a5d1d6">count=count,</p>



<p id="SE-a12ce0c0-616b-4386-9a26-de9a85aa5548">continuous=False,</p>



<p id="SE-c6af5b4d-158b-4c55-815a-a306d52fa5d6">ports=ports_input,</p>



<p id="SE-d290af3e-bb23-4213-bf43-7d9490961a18">concurrency=20,</p>



<p id="SE-d4ad8655-fc91-4158-a732-6de9c1fffaed">summary=summary,</p>



<p id="SE-2bbe6942-7d7e-43c3-8607-fbec27b19cbc">detail=detail,</p>



<p id="SE-62337ee0-96e5-4922-a3c2-b71c49cf79cc">raw_output=raw_output,</p>



<p id="SE-987aaf34-3666-48e7-b4de-b189d199344e">)</p>



<p id="SE-c1c4de05-84dc-45a0-817f-61a4eef192b0">​</p>



<p id="SE-f21c220e-b06e-44b4-bc70-c19caa984866">​</p>



<p id="SE-1a61ad19-c6b2-4212-84bd-27a6bf24bdbc">@app.route(&#8220;/api/ping_once&#8221;, methods=[&#8220;GET&#8221;])</p>



<p id="SE-cbf5d576-a2ba-4652-9aef-6dbfb2a35403">def api_ping_once():</p>



<p id="SE-806ae674-819a-464e-a52b-b4255cc8e511">target = request.args.get(&#8220;target&#8221;, &#8220;&#8221;).strip()</p>



<p id="SE-30276c71-9a7d-4ede-afa2-0e5113ca390a">if not target:</p>



<p id="SE-b0ffc277-d783-4ce1-b623-cf9435b897e5">return jsonify({</p>



<p id="SE-b1d1fb7c-d382-4d88-8579-8bb8891e7525">&#8220;success&#8221;: False,</p>



<p id="SE-d460e3f6-26c7-4a14-9ada-a4790eb9d48b">&#8220;latency_ms&#8221;: None,</p>



<p id="SE-ae18021f-5869-4900-9b55-e8865ed060fb">&#8220;target&#8221;: &#8220;&#8221;,</p>



<p id="SE-91b5843d-8cde-4e09-bd42-c9401592934f">&#8220;timestamp&#8221;: datetime.now().strftime(&#8220;%H:%M:%S&#8221;),</p>



<p id="SE-5c123d31-2fcf-4bab-94f6-5e8b553688a3">&#8220;error&#8221;: &#8220;target parameter is required&#8221;,</p>



<p id="SE-85f1db72-3fac-4109-a7f7-c5a450ca00e3">})</p>



<p id="SE-58bccd60-23a7-4ae4-8a4a-0417484b79fe">​</p>



<p id="SE-80143e19-af64-470b-96ab-0d4dcdc6aa79">success, latency_ms, raw_output = ping_once(target, timeout_ms=1000)</p>



<p id="SE-ca62ead7-a49d-4d5a-bec0-58f424bd7c48">return jsonify({</p>



<p id="SE-8aa943c2-7d16-4171-9fe4-b2252ba53bee">&#8220;success&#8221;: success,</p>



<p id="SE-1889ff5e-23ce-4a5a-9bea-f0d77de102ea">&#8220;latency_ms&#8221;: latency_ms,</p>



<p id="SE-a9eb4cd8-d7ee-4ed0-9ca8-fd153a00fd89">&#8220;target&#8221;: target,</p>



<p id="SE-8a65c5fc-2c90-427a-87ca-c209ad5b6294">&#8220;timestamp&#8221;: datetime.now().strftime(&#8220;%H:%M:%S&#8221;),</p>



<p id="SE-1451b6ec-aba1-490d-8d9f-f10ab9815ab5">&#8220;raw_output&#8221;: raw_output,</p>



<p id="SE-a3b2d472-554a-4a43-b0ba-9532d1f36323">})</p>



<p id="SE-bf3f6682-392a-4698-adbc-0137457ae8e8">​</p>



<p id="SE-8a1a9e63-699b-4eb2-8f78-98942921cf9d">​</p>



<p id="SE-d5227bbb-8a73-4863-9a12-43dd5479e085">@app.route(&#8220;/api/scan_port&#8221;, methods=[&#8220;GET&#8221;])</p>



<p id="SE-e5b8b59a-62cf-47ea-ace8-5a4441b268f3">def api_scan_port():</p>



<p id="SE-e96ddd6a-b2bb-4365-9187-acd983cfc62b">target = request.args.get(&#8220;target&#8221;, &#8220;&#8221;).strip()</p>



<p id="SE-135b2828-d1a5-4dfb-9f36-b45b39e9263d">port_str = request.args.get(&#8220;port&#8221;, &#8220;&#8221;).strip()</p>



<p id="SE-f96c0c32-4868-4cdd-bfb5-45cf3a01fc20">​</p>



<p id="SE-b66649a8-36fe-497a-a910-6fde4a33c8b2">if not target or not port_str:</p>



<p id="SE-1c4914ab-9ac7-4dc0-aa8d-7aa879eaca06">return jsonify({</p>



<p id="SE-e9b67b76-aa46-44b4-91f2-a3d9c581470f">&#8220;open&#8221;: False,</p>



<p id="SE-e0917b76-a977-4571-a9ba-d11add9d7785">&#8220;note&#8221;: &#8220;target 또는 port 파라미터가 없습니다.&#8221;,</p>



<p id="SE-d25e4764-af95-4c41-b236-0386fa7b1a41">})</p>



<p id="SE-acaa5219-e54e-432d-a71d-4be3131aa62d">​</p>



<p id="SE-308ae8d2-995d-49de-abc9-4cee4d709bae">try:</p>



<p id="SE-220e8fb9-0c14-4415-bc4b-5b30eeac9ef5">port = int(port_str)</p>



<p id="SE-98c2196e-2875-44e3-99cd-5bd7c89ae29b">except ValueError:</p>



<p id="SE-42e4f772-07e6-41cd-ac48-0c8557486eea">return jsonify({</p>



<p id="SE-3f99d16d-2cec-444f-ba46-cdb3541da2a3">&#8220;open&#8221;: False,</p>



<p id="SE-daa42912-eab4-490d-b7db-04905e384d21">&#8220;note&#8221;: &#8220;포트 번호가 잘못되었습니다.&#8221;,</p>



<p id="SE-a909f95d-1144-48e3-8718-4287978dd543">})</p>



<p id="SE-df5c2b99-fbb3-4837-b259-066cf3fbc689">​</p>



<p id="SE-624a9d8f-56ad-4b78-a045-b6755cf9f4d3">is_open, note = scan_port(target, port, timeout=1.0)</p>



<p id="SE-346c08fe-0098-4bf9-8091-7ea4a9cbe68a">return jsonify({</p>



<p id="SE-d826bb42-d477-4962-b61c-5b20df2ecacc">&#8220;open&#8221;: is_open,</p>



<p id="SE-25d9f803-dea9-46d8-9ed7-defebed346b1">&#8220;note&#8221;: note,</p>



<p id="SE-4293e1ca-8a85-4cb6-9f0b-05cd19b42f1d">})</p>



<p id="SE-99191ca2-6ed8-4e00-baa5-a3b705f8af76">​</p>



<p id="SE-e18ecaa1-96c2-4bcc-8cd2-af3cb45c3972">​</p>



<p id="SE-dbafe63b-4adf-4e01-a252-1b67788c9543">if __name__ == &#8220;__main__&#8221;:</p>



<p id="SE-2296f39e-2bb9-4507-afb9-528550ea0406"># threaded=True: 동시에 여러 /api/scan_port 요청 처리</p>



<p id="SE-b15f8126-8448-4dfc-b81d-6205827e28f4">app.run(host=&#8221;0.0.0.0&#8243;, port=9999, debug=True, threaded=True)</p>



<p>========== end =====================</p>


<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-ping-portscan-%ec%9b%b9-%ed%99%94%eb%a9%b4%ec%97%90%ec%84%9c-%ec%b2%b4%ed%81%ac-%ed%94%84%eb%a1%9c%ea%b7%b8/">파이썬으로 네트워크 ping, portscan 웹 화면에서 체크 프로그램</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac-ping-portscan-%ec%9b%b9-%ed%99%94%eb%a9%b4%ec%97%90%ec%84%9c-%ec%b2%b4%ed%81%ac-%ed%94%84%eb%a1%9c%ea%b7%b8/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
