<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>리눅스·서버 학습 - TerminalNexus</title>
    <link>https://tnhub.kr/learn</link>
    <description>서브넷, RAID, cron, 권한, nginx, 디스크 장애, systemd, SSH 보안, 로그 분석을 예제로 정리한 글입니다.</description>
    <language>ko</language>
    <item>
      <title>서브넷 마스크와 CIDR, 예제로 이해하기</title>
      <link>https://tnhub.kr/learn/subnet-mask-basics</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/subnet-mask-basics</guid>
      <description>/24가 왜 254개인지, 서브넷을 어떻게 나누는지 숫자 계산 예제로 정리했습니다.</description>
      <content:encoded><![CDATA[<p>IPv4 주소는 32비트 숫자입니다. 서브넷 마스크는 이 32비트 중 앞쪽 몇 비트가 네트워크를 나타내고 나머지가 호스트를 나타내는지 정합니다. 192.168.0.10/24에서 /24는 앞 24비트가 네트워크라는 뜻이고, 마스크로 쓰면 255.255.255.0입니다.</p><h2>왜 /24에서는 254개만 쓸 수 있을까</h2><p>/24는 호스트 비트가 8개이므로 주소가 256개입니다. 맨 앞 192.168.0.0은 네트워크 주소, 맨 뒤 192.168.0.255는 브로드캐스트 주소라서 장비에 줄 수 없습니다. 그래서 사용 가능한 호스트는 254개입니다. 일반식은 2의 (32 - 프리픽스)제곱에서 2를 뺀 값입니다.</p><h2>서브넷 나누기 예제</h2><p>192.168.0.0/24를 /26으로 나누면 프리픽스가 2비트 늘어나므로 4개 서브넷이 생기고, 각 서브넷은 주소 64개, 호스트 62개를 가집니다.</p><ul><li>192.168.0.0/26: 호스트 .1 ~ .62, 브로드캐스트 .63</li><li>192.168.0.64/26: 호스트 .65 ~ .126, 브로드캐스트 .127</li><li>192.168.0.128/26: 호스트 .129 ~ .190, 브로드캐스트 .191</li><li>192.168.0.192/26: 호스트 .193 ~ .254, 브로드캐스트 .255</li></ul><h2>서버에서 내 네트워크 확인하기</h2><pre><code>ip -4 addr show
ip route</code></pre><p>ip addr 결과의 inet 192.168.0.10/24처럼 프리픽스까지 표시됩니다. 같은 서브넷의 장비끼리는 라우터 없이 통신하고, 다른 서브넷은 기본 게이트웨이를 거칩니다.</p><h2>자주 하는 실수</h2><ul><li>서버 두 대의 마스크를 다르게 설정해 한쪽에서만 통신이 되는 경우</li><li>게이트웨이가 다른 서브넷에 있어 외부 통신이 안 되는 경우</li><li>사설 대역(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) 범위를 잘못 기억해 172.32.x.x를 사설로 착각하는 경우</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>RAID 0, 1, 5, 6, 10 차이와 선택 기준</title>
      <link>https://tnhub.kr/learn/raid-levels-compared</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/raid-levels-compared</guid>
      <description>RAID 레벨별 용량, 장애 허용, 성능을 비교하고 용도별 선택 기준을 정리했습니다.</description>
      <content:encoded><![CDATA[<p>RAID는 여러 디스크를 묶어 성능이나 가용성을 높이는 방식입니다. 가장 중요한 점은 RAID가 백업이 아니라는 것입니다. 실수로 지운 파일이나 랜섬웨어로 암호화된 파일은 모든 디스크에 똑같이 반영됩니다.</p><h2>레벨별 특징</h2><ul><li>RAID 0: 스트라이핑. 용량은 전체 합계이고 빠르지만 디스크 하나만 고장 나도 전체 데이터를 잃습니다.</li><li>RAID 1: 미러링. 용량은 디스크 1개분이고 같은 내용을 복제합니다.</li><li>RAID 5: 패리티 1개. 용량은 (N-1)개분이고 디스크 1개 고장까지 버팁니다. 최소 3개가 필요합니다.</li><li>RAID 6: 패리티 2개. 용량은 (N-2)개분이고 디스크 2개 고장까지 버팁니다. 최소 4개가 필요합니다.</li><li>RAID 10: 미러를 스트라이핑합니다. 용량은 절반이고 쓰기 성능이 좋으며, 각 미러에서 1개씩은 고장 나도 됩니다.</li></ul><h2>4TB 디스크 6개로 비교</h2><ul><li>RAID 0: 24TB</li><li>RAID 5: 20TB</li><li>RAID 6: 16TB</li><li>RAID 10: 12TB</li></ul><h2>용도별 선택 기준</h2><p>데이터베이스처럼 작은 쓰기가 많으면 RAID 10이 유리합니다. RAID 5와 6은 패리티 계산 때문에 작은 쓰기가 느립니다. 영상이나 백업처럼 큰 파일을 주로 읽는 저장소는 RAID 6이 용량과 안전의 균형이 좋습니다. 임시 작업 공간은 RAID 0도 쓸 수 있습니다.</p><h2>대용량 디스크에서 RAID 5가 위험한 이유</h2><p>디스크 하나가 고장 나면 나머지를 전부 읽어 복구합니다. 디스크가 클수록 복구 시간이 길어지고, 그동안 다른 디스크까지 문제가 생기면 데이터를 잃습니다. 같은 시기에 산 디스크는 비슷한 시기에 고장 나기도 합니다. 그래서 큰 용량에는 RAID 6이나 RAID 10을 많이 권합니다.</p><h2>리눅스 소프트웨어 RAID 상태 확인</h2><pre><code>cat /proc/mdstat
sudo mdadm --detail /dev/md0</code></pre><p>상태가 degraded로 표시되면 이미 디스크 하나를 잃고 보호 없이 운영 중이라는 뜻입니다. 알림을 설정해 바로 알 수 있게 해두세요.</p>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>crontab 사용법과 실행이 안 될 때 확인할 것</title>
      <link>https://tnhub.kr/learn/crontab-guide</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/crontab-guide</guid>
      <description>cron 표현식 읽는 법과 &quot;터미널에서는 되는데 cron에서는 안 되는&quot; 문제의 원인을 정리했습니다.</description>
      <content:encoded><![CDATA[<p>cron은 정해진 시간에 명령을 실행하는 리눅스 스케줄러입니다. crontab -e로 편집하고 crontab -l로 확인합니다.</p><h2>표현식 읽는 법</h2><p>다섯 칸은 순서대로 분, 시, 일, 월, 요일입니다.</p><pre><code># 매일 새벽 3시 30분
30 3 * * * /usr/local/bin/backup.sh

# 평일 오전 9시
0 9 * * 1-5 /usr/local/bin/report.sh

# 10분마다
*/10 * * * * /usr/local/bin/check.sh</code></pre><h2>터미널에서는 되는데 cron에서는 안 될 때</h2><p>가장 흔한 원인은 환경 차이입니다. cron은 로그인 셸의 환경을 읽지 않아 PATH가 매우 짧고, 작업 폴더도 다릅니다.</p><ul><li>명령과 파일은 절대 경로로 씁니다.</li><li>스크립트 맨 위에 #!/bin/bash를 넣고 실행 권한을 줍니다.</li><li>필요한 환경 변수는 crontab이나 스크립트 안에 직접 적습니다.</li><li>% 문자는 cron에서 줄바꿈으로 처리되므로 \%로 씁니다.</li></ul><h2>출력을 로그로 남기기</h2><p>기본 상태에서는 오류가 어디에도 보이지 않는 경우가 많습니다. 표준 출력과 오류를 파일로 보내면 원인을 바로 찾을 수 있습니다.</p><pre><code>30 3 * * * /usr/local/bin/backup.sh &gt;&gt; /var/log/backup.log 2&gt;&amp;1</code></pre><h2>실행 기록 확인</h2><pre><code># Debian, Ubuntu
grep CRON /var/log/syslog | tail

# RHEL, Rocky
sudo tail /var/log/cron</code></pre><h2>일과 요일을 같이 쓸 때 주의</h2><p>일과 요일을 모두 지정하면 둘 중 하나만 맞아도 실행됩니다. 0 0 13 * 5는 13일의 금요일이 아니라 매월 13일과 매주 금요일에 실행됩니다.</p><h2>겹쳐서 실행되는 것 막기</h2><p>작업이 다음 실행 시각까지 끝나지 않으면 같은 작업이 겹칩니다. flock을 쓰면 이전 실행이 끝나지 않았을 때 건너뜁니다.</p><pre><code>*/5 * * * * flock -n /tmp/job.lock /usr/local/bin/job.sh</code></pre>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>chmod 755, 644 의미와 안전한 권한 설정</title>
      <link>https://tnhub.kr/learn/chmod-permissions</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/chmod-permissions</guid>
      <description>리눅스 권한 숫자를 읽는 법, 디렉터리 권한의 의미, 777을 쓰면 안 되는 이유를 정리했습니다.</description>
      <content:encoded><![CDATA[<p>ls -l의 -rwxr-xr-x는 소유자, 그룹, 기타 사용자의 권한을 순서대로 보여줍니다. 숫자로는 읽기 4, 쓰기 2, 실행 1을 더합니다. rwx는 7, r-x는 5이므로 -rwxr-xr-x는 755입니다.</p><h2>자주 쓰는 권한</h2><ul><li>755: 실행 파일과 디렉터리</li><li>644: 일반 파일</li><li>600: SSH 개인키, 비밀 설정 파일</li><li>700: 소유자만 들어갈 디렉터리 (~/.ssh 등)</li></ul><h2>디렉터리에서 r, w, x의 의미</h2><p>디렉터리의 r은 목록 보기, w는 안에서 파일 만들기와 삭제, x는 들어가기와 안의 파일 접근입니다. 그래서 파일 권한이 644여도 상위 디렉터리에 x가 없으면 읽을 수 없습니다. 파일을 지울 수 있는지는 파일 권한이 아니라 디렉터리의 w로 결정됩니다.</p><h2>Permission denied가 날 때</h2><p>경로의 모든 단계를 확인해야 합니다. namei는 경로별 권한을 한 번에 보여줍니다.</p><pre><code>namei -l /var/www/html/index.html</code></pre><h2>777을 쓰면 안 되는 이유</h2><p>777은 서버의 모든 계정이 파일을 수정할 수 있다는 뜻입니다. 웹 애플리케이션이 뚫렸을 때 공격자가 코드를 바꾸는 길을 열어줍니다. 웹 서버가 쓰기 어려운 문제는 대부분 소유자나 그룹을 맞춰 해결합니다.</p><pre><code>sudo chown -R www-data:www-data /var/www/uploads
sudo find /var/www/uploads -type d -exec chmod 750 {} +
sudo find /var/www/uploads -type f -exec chmod 640 {} +</code></pre><h2>특수 권한</h2><ul><li>setuid(4): 파일 소유자 권한으로 실행. passwd 명령이 대표적입니다.</li><li>setgid(2): 디렉터리에 걸면 새 파일이 디렉터리의 그룹을 물려받습니다.</li><li>sticky(1): 자기 파일만 지울 수 있습니다. /tmp의 1777이 대표적입니다.</li></ul><p>정기적으로 setuid 파일 목록을 확인하면 이상 징후를 찾는 데 도움이 됩니다.</p><pre><code>sudo find / -xdev -perm -4000 -type f</code></pre>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>nginx 리버스 프록시와 HTTPS 설정 따라하기</title>
      <link>https://tnhub.kr/learn/nginx-reverse-proxy-https</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/nginx-reverse-proxy-https</guid>
      <description>백엔드 앱 앞에 nginx를 두고 Let&apos;s Encrypt 인증서를 적용하는 과정을 정리했습니다.</description>
      <content:encoded><![CDATA[<p>리버스 프록시는 외부 요청을 받아 내부 애플리케이션에 전달합니다. 앱을 8080 같은 내부 포트에만 열어두고 nginx가 TLS, 압축, 정적 파일, 요청 제한을 맡습니다.</p><h2>기본 설정</h2><pre><code>server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}</code></pre><p>이 헤더를 빠뜨리면 앱이 실제 사용자 IP나 HTTPS 여부를 알 수 없어 로그가 틀리거나 리다이렉트가 잘못됩니다.</p><h2>적용 전에 항상 문법 검사</h2><pre><code>sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre><p>restart 대신 reload를 쓰면 처리 중인 연결을 끊지 않습니다.</p><h2>HTTPS 인증서 적용</h2><pre><code>sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
sudo certbot renew --dry-run</code></pre><p>도메인이 서버를 가리키고 80번 포트가 열려 있어야 발급됩니다. 갱신이 자동으로 되는지 마지막 명령으로 확인하세요.</p><h2>자주 만나는 오류</h2><ul><li>502 Bad Gateway: 앱이 꺼져 있거나 포트가 다릅니다.</li><li>504 Gateway Timeout: 앱 응답이 proxy_read_timeout보다 오래 걸립니다.</li><li>413 Request Entity Too Large: client_max_body_size를 늘려야 합니다.</li><li>WebSocket 끊김: Upgrade, Connection 헤더 전달이 필요합니다.</li></ul><h2>앱 포트를 외부에 노출하지 않기</h2><p>앱이 0.0.0.0:8080으로 열려 있으면 nginx의 보안 설정을 우회해 직접 접속할 수 있습니다. 127.0.0.1에 바인딩하거나 방화벽으로 막으세요. Docker는 -p 127.0.0.1:8080:8080처럼 지정합니다.</p><h2>로그인 무차별 대입 줄이기</h2><pre><code>limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;

location = /login {
    limit_req zone=login burst=10 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:8080;
}</code></pre>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>리눅스 디스크가 꽉 찼을 때 원인 찾고 해결하기</title>
      <link>https://tnhub.kr/learn/disk-full-troubleshooting</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/disk-full-troubleshooting</guid>
      <description>df, du로 범인을 찾는 순서와 파일을 지워도 용량이 안 줄어드는 경우를 정리했습니다.</description>
      <content:encoded><![CDATA[<p>No space left on device가 나오면 서비스가 로그를 못 쓰고 DB가 멈출 수 있습니다. 먼저 무엇이 찼는지 확인하고, 지우기 전에 어떤 파일인지 확인합니다.</p><h2>1단계: 어느 파일시스템이 찼는지 확인</h2><pre><code>df -h
df -i</code></pre><p>df -h는 용량, df -i는 아이노드 사용량입니다. 용량이 남았는데도 쓸 수 없다면 작은 파일이 너무 많아 아이노드가 소진된 경우입니다.</p><h2>2단계: 큰 디렉터리 찾기</h2><pre><code>sudo du -xh --max-depth=1 / 2&gt;/dev/null | sort -h | tail -15</code></pre><p>-x는 다른 파일시스템으로 넘어가지 않게 합니다. 큰 곳으로 내려가며 반복합니다. 대표적인 범인은 /var/log, Docker 데이터, 패키지 캐시, 빌드 산출물입니다.</p><h2>3단계: 흔한 원인별 정리</h2><pre><code># systemd 저널을 500MB로 제한
sudo journalctl --vacuum-size=500M

# 패키지 캐시 (Debian, Ubuntu)
sudo apt clean

# Docker 사용량 확인 후 미사용 이미지 정리
docker system df
docker image prune</code></pre><p>docker system prune -a --volumes는 데이터 볼륨까지 지울 수 있으므로 내용을 확인하지 않고 쓰면 안 됩니다.</p><h2>파일을 지웠는데 용량이 그대로일 때</h2><p>프로세스가 파일을 열고 있으면 삭제해도 공간이 반환되지 않습니다. 아래 명령으로 찾은 뒤 해당 서비스를 재시작하거나 로그를 다시 열게 합니다.</p><pre><code>sudo lsof +L1</code></pre><p>실행 중인 서비스의 로그는 rm보다 비우는 쪽이 안전합니다.</p><pre><code>sudo truncate -s 0 /var/log/app.log</code></pre><h2>재발 막기</h2><ul><li>logrotate로 로그를 회전합니다.</li><li>Docker 로그 크기를 max-size로 제한합니다.</li><li>사용률 80%에서 알림을 받습니다.</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>systemd로 내 프로그램을 서비스로 등록하기</title>
      <link>https://tnhub.kr/learn/systemd-service</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/systemd-service</guid>
      <description>서비스 파일 작성, 자동 시작, 재시작 정책, 로그 확인까지 한 번에 정리했습니다.</description>
      <content:encoded><![CDATA[<p>터미널에서 띄운 프로그램은 접속이 끊기면 함께 멈추기 쉽습니다. systemd 서비스로 등록하면 부팅 시 자동 시작되고, 죽으면 다시 살리고, 로그도 한곳에 모입니다.</p><h2>서비스 파일 만들기</h2><p>/etc/systemd/system/myapp.service 파일을 작성합니다.</p><pre><code>[Unit]
Description=My App
After=network-online.target
Wants=network-online.target

[Service]
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
Restart=on-failure
RestartSec=5
EnvironmentFile=/etc/myapp/env

[Install]
WantedBy=multi-user.target</code></pre><h2>등록과 시작</h2><pre><code>sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp</code></pre><p>서비스 파일을 고칠 때마다 daemon-reload가 필요합니다. enable은 부팅 시 자동 시작, --now는 지금 바로 시작까지 함께 합니다.</p><h2>로그 보기</h2><pre><code>journalctl -u myapp -f
journalctl -u myapp --since &quot;1 hour ago&quot;</code></pre><h2>안전하게 실행하기</h2><ul><li>root가 아닌 전용 계정으로 실행합니다.</li><li>비밀값은 서비스 파일이 아니라 권한 600의 EnvironmentFile에 둡니다.</li><li>NoNewPrivileges=true, ProtectSystem=strict 같은 격리 옵션을 검토합니다.</li></ul><h2>시작이 안 될 때</h2><ul><li>status=203/EXEC: ExecStart의 경로나 실행 권한이 틀렸습니다.</li><li>status=217/USER: User에 적은 계정이 없습니다.</li><li>start-limit-hit: 짧은 시간에 너무 많이 재시작했습니다. 원인을 고친 뒤 systemctl reset-failed를 실행합니다.</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>SSH 키 로그인 설정과 서버 기본 보안</title>
      <link>https://tnhub.kr/learn/ssh-hardening</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/ssh-hardening</guid>
      <description>비밀번호 로그인을 끄고 키 인증으로 바꾸는 순서와 잠기지 않기 위한 주의사항을 정리했습니다.</description>
      <content:encoded><![CDATA[<p>인터넷에 열린 서버의 22번 포트에는 자동화된 로그인 시도가 끊임없이 들어옵니다. 가장 효과가 큰 조치는 비밀번호 로그인을 끄고 키 인증만 허용하는 것입니다.</p><h2>키 만들고 서버에 등록</h2><pre><code>ssh-keygen -t ed25519 -C &quot;laptop&quot;
ssh-copy-id user@server</code></pre><p>개인키는 내 컴퓨터를 떠나면 안 되고, 서버에는 공개키만 등록됩니다. 서버 쪽 ~/.ssh는 700, authorized_keys는 600이어야 합니다.</p><h2>비밀번호 로그인 끄기</h2><pre><code># /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes</code></pre><pre><code>sudo sshd -t &amp;&amp; sudo systemctl reload ssh</code></pre><p>기존 접속을 끊지 말고 새 터미널에서 키 로그인이 되는지 확인한 뒤에 닫으세요. 잘못 설정하면 서버에 들어갈 수 없게 됩니다. RHEL 계열의 서비스 이름은 sshd입니다.</p><h2>무차별 대입 차단</h2><pre><code>sudo apt install fail2ban
sudo fail2ban-client status sshd</code></pre><h2>방화벽과 포트 점검</h2><p>필요한 포트만 열어둡니다. 어떤 프로그램이 어떤 포트를 듣고 있는지부터 확인하세요. DB나 관리 도구가 0.0.0.0으로 열려 있으면 외부에서 바로 공격받을 수 있습니다.</p><pre><code>sudo ss -tulpn</code></pre><h2>자주 놓치는 것</h2><ul><li>쓰지 않는 계정과 오래된 authorized_keys 항목을 지웁니다.</li><li>보안 업데이트를 자동으로 적용합니다 (unattended-upgrades).</li><li>공유기 포트 포워딩에 예전 규칙이 남아 있는지 확인합니다.</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>grep, awk로 서버 로그 빠르게 분석하기</title>
      <link>https://tnhub.kr/learn/log-analysis-grep-awk</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/log-analysis-grep-awk</guid>
      <description>nginx 접속 로그에서 많이 호출한 IP, 오류 비율, 느린 요청을 찾는 명령을 모았습니다.</description>
      <content:encoded><![CDATA[<p>장애 때는 무거운 도구를 켜기 전에 서버에서 바로 쓸 수 있는 명령이 빠릅니다. 아래는 nginx 기본 combined 로그 형식 기준입니다.</p><h2>많이 요청한 IP 상위 10개</h2><pre><code>awk '{print $1}' access.log | sort | uniq -c | sort -rn | head</code></pre><h2>상태 코드별 개수</h2><pre><code>awk '{print $9}' access.log | sort | uniq -c | sort -rn</code></pre><h2>5xx 오류가 난 경로</h2><pre><code>awk '$9 ~ /^5/ {print $7}' access.log | sort | uniq -c | sort -rn | head</code></pre><h2>시간대별 요청 수</h2><pre><code>awk '{print substr($4, 2, 14)}' access.log | sort | uniq -c</code></pre><p>결과가 시와 분까지 묶여 나오므로 언제 트래픽이 튀었는지 바로 보입니다.</p><h2>grep 활용</h2><pre><code># 대소문자 무시, 앞뒤 3줄 함께
grep -i -C3 &quot;timeout&quot; app.log

# 압축된 로그까지 검색
zgrep &quot;error&quot; /var/log/nginx/access.log.*.gz

# 실시간으로 오류만 보기
tail -f app.log | grep --line-buffered ERROR</code></pre><h2>정규식이 맞는지 먼저 확인</h2><p>복잡한 패턴은 실제 로그 몇 줄로 먼저 시험하면 시간을 아낍니다. 다만 grep은 기본 정규식을 쓰므로 +, ?, |, 괄호를 쓰려면 grep -E가 필요합니다.</p><h2>systemd 서비스 로그</h2><pre><code>journalctl -u nginx -p err --since today</code></pre>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>JWT 구조와 안전하게 쓰는 방법</title>
      <link>https://tnhub.kr/learn/jwt-explained</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/jwt-explained</guid>
      <description>JWT가 어떻게 생겼는지, 어디에 저장하고 무엇을 넣으면 안 되는지 정리했습니다.</description>
      <content:encoded><![CDATA[<p>JWT는 점으로 구분된 세 부분입니다. 헤더는 알고리즘, 페이로드는 사용자 정보와 만료 시각, 서명은 위조 방지를 담습니다. 앞의 두 부분은 Base64URL 인코딩일 뿐이라 누구나 읽을 수 있습니다.</p><h2>서명이 하는 일</h2><p>서버는 비밀키로 서명을 만들고, 요청이 올 때 다시 계산해 비교합니다. 내용을 바꾸면 서명이 맞지 않아 거부됩니다. 서명은 내용을 숨기지 않습니다. 비밀번호나 주민번호 같은 값을 페이로드에 넣으면 안 됩니다.</p><h2>꼭 검증할 항목</h2><ul><li>알고리즘을 서버에서 고정합니다. 토큰 헤더의 alg를 그대로 믿으면 none 공격에 노출될 수 있습니다.</li><li>exp를 확인하고 액세스 토큰은 짧게 유지합니다.</li><li>iss와 aud를 확인해 다른 서비스용 토큰을 거부합니다.</li><li>서명 키는 충분히 길고 무작위여야 합니다.</li></ul><h2>어디에 저장할까</h2><p>localStorage는 쓰기 쉽지만 XSS가 생기면 스크립트가 토큰을 읽을 수 있습니다. HttpOnly, Secure, SameSite 쿠키는 스크립트가 읽을 수 없는 대신 CSRF를 고려해야 합니다. 어느 쪽이든 XSS를 막는 것이 우선이고, 짧은 액세스 토큰과 교체되는 리프레시 토큰을 함께 쓰면 피해를 줄일 수 있습니다.</p><h2>로그아웃이 어려운 이유</h2><p>JWT는 서버가 상태를 저장하지 않으므로 발급된 토큰을 바로 무효로 만들기 어렵습니다. 그래서 만료를 짧게 두고, 리프레시 토큰은 서버에 저장해 폐기할 수 있게 하는 구성을 많이 씁니다.</p>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>React 사이트가 검색에 안 나올 때: 빈 HTML 문제 해결기</title>
      <link>https://tnhub.kr/learn/spa-seo-prerender</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/spa-seo-prerender</guid>
      <description>모든 페이지가 같은 제목의 빈 화면으로 수집되던 문제를 찾고 빌드 단계에서 해결한 기록입니다.</description>
      <content:encoded><![CDATA[<p>이 사이트는 React 단일 페이지 앱입니다. 화면에서는 페이지마다 제목과 내용이 잘 보였지만, 구글 애드센스는 &quot;게시자 콘텐츠가 없는 화면&quot;이라고 판정했고 검색에서 발견된 페이지도 5개뿐이었습니다. 브라우저로만 확인해서는 원인이 보이지 않았습니다.</p><h2>크롤러가 받는 화면 확인하기</h2><p>브라우저는 자바스크립트를 실행한 뒤의 화면을 보여줍니다. 원본 HTML을 보려면 curl로 직접 받아야 합니다.</p><pre><code>curl -s https://example.com/tools/subnet | grep -o &quot;&lt;title&gt;[^&lt;]*&quot;
curl -s https://example.com/tools/subnet | wc -c</code></pre><p>결과는 충격적이었습니다. 어떤 주소를 요청해도 제목이 같았고, 본문이 들어가는 root 요소는 비어 있었습니다. 구글은 자바스크립트를 실행해 읽기도 하지만 시간이 더 걸리고, 다른 크롤러가 어떻게 처리하는지는 보장되지 않습니다.</p><h2>선택지 비교</h2><ul><li>SSR(서버 렌더링): 가장 정확하지만 앱 구조를 크게 바꿔야 하고 서버 부담이 늘어납니다.</li><li>동적 렌더링: 크롤러에게만 다른 화면을 주는 방식이라 관리가 번거롭습니다.</li><li>빌드 시 사전 렌더링: 내용이 자주 바뀌지 않는 공개 페이지에 적합하고 서버 비용이 들지 않습니다.</li></ul><p>공개 페이지 내용이 대부분 고정되어 있어서 세 번째를 골랐습니다. 빌드가 끝난 뒤 스크립트가 페이지마다 제목, 설명, canonical, 본문을 채운 HTML 파일을 만듭니다.</p><h2>시험에서 잡은 실수</h2><p>처음에는 tools/json/index.html처럼 폴더로 만들었는데, nginx가 주소 끝에 슬래시를 붙이며 전 페이지를 301로 보냈습니다. canonical 주소와 어긋나는 문제라서 tools/json.html 파일로 바꾸고 try_files에 $uri.html을 추가했습니다.</p><pre><code>location / {
    try_files $uri $uri.html $uri/ /index.html;
}</code></pre><p>또 하나는 HTML 캐시입니다. 정적 파일로 나간 HTML이 브라우저에 오래 남으면 이미 사라진 자산 파일을 찾을 수 있어서, 응답 형식이 HTML일 때만 no-cache를 붙였습니다.</p><pre><code>map $sent_http_content_type $html_cache_control {
    ~^text/html &quot;no-cache&quot;;
    default     &quot;&quot;;
}</code></pre><h2>라이브러리 호환성도 확인하기</h2><p>react-helmet-async 2.0.5는 React 19를 공식 지원하지 않아서 화면을 옮겨도 제목이 바뀌지 않았습니다. 3.0으로 올리자 이번에는 정적 태그와 새 태그가 함께 남아 canonical이 두 개가 됐습니다. 화면을 그린 뒤 정적 태그를 지워 해결했습니다.</p><h2>배운 점</h2><ul><li>SEO 점검은 브라우저가 아니라 curl로 시작합니다.</li><li>설정은 운영에 올리기 전에 격리된 임시 서버로 시험합니다.</li><li>제목이 바뀌는지는 화면 이동까지 포함해 확인합니다.</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>내 게시판에서 찾은 저장형 XSS와 수정 과정</title>
      <link>https://tnhub.kr/learn/stored-xss-found</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/stored-xss-found</guid>
      <description>에디터 HTML 정화가 꺼져 있던 문제를 직접 공격 문자열로 확인하고 고친 기록입니다.</description>
      <content:encoded><![CDATA[<p>게시판 에디터 라이브러리를 업데이트한 뒤 정상 동작을 확인하려고 일부러 위험한 내용을 글로 써 봤습니다. 업데이트 검증이 목적이었는데, 뜻밖에 원래부터 있던 취약점을 찾았습니다.</p><h2>시험 방법</h2><p>본문에 이벤트 속성이 있는 이미지, script 태그, javascript 주소 링크를 넣고 글을 저장한 뒤 읽기 화면의 DOM을 확인했습니다.</p><pre><code>&lt;img src=x onerror=&quot;alert(1)&quot;&gt;
&lt;script&gt;alert(2)&lt;/script&gt;
[링크](javascript:alert(3))</code></pre><p>세 가지 모두 지워지지 않고 그대로 페이지에 들어갔습니다.</p><h2>왜 위험한가</h2><p>저장형 XSS는 글을 읽는 모든 사람의 브라우저에서 공격 코드가 실행됩니다. 로그인 토큰을 localStorage에 두는 구조라면 스크립트가 토큰을 읽어 외부로 보낼 수 있고, 관리자가 글을 열면 관리자 권한까지 넘어갈 수 있습니다.</p><h2>원인</h2><p>코드를 찾아보니 뷰어에 정화 기능을 사실상 끄는 옵션이 있었습니다. 유튜브 임베드가 지워지는 문제를 해결하려던 흔적으로 보입니다.</p><pre><code>customHTMLSanitizer={(html) =&gt; html}</code></pre><h2>수정</h2><p>정화를 끄는 대신 필요한 것만 허용했습니다. DOMPurify로 스크립트와 이벤트 속성을 지우고, iframe은 유튜브 임베드 주소일 때만 남깁니다.</p><pre><code>const YOUTUBE = /^https:\/\/www\.youtube(-nocookie)?\.com\/embed\/[\w-]+/;

purify.addHook('uponSanitizeElement', (node, data) =&gt; {
  if (data.tagName === 'iframe' &amp;&amp; !YOUTUBE.test(node.getAttribute('src') || '')) {
    node.remove();
  }
});</code></pre><p>정규식 끝을 열어두면 주소 뒤에 다른 값이 붙을 수 있지만, 도메인과 경로 앞부분을 고정했기 때문에 다른 사이트의 삽입은 막힙니다.</p><h2>수정 후 확인</h2><ul><li>같은 공격 문자열이 모두 사라지는지 DOM으로 확인합니다.</li><li>유튜브 임베드, 표, 코드 블록이 그대로 나오는지 확인합니다.</li><li>이미 저장된 글에도 악성 코드가 없는지 검사합니다. 읽을 때 정화하므로 예전 글도 보호됩니다.</li></ul><h2>배운 점</h2><p>기능이 안 될 때 안전장치를 끄는 해결은 나중에 가장 비싸게 돌아옵니다. 허용 목록을 좁게 만드는 쪽이 조금 번거로워도 맞습니다. 그리고 보안 기능은 켜져 있다고 믿지 말고 실제 공격 문자열로 시험해야 합니다.</p>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>nginx로 로그인 무차별 대입 줄이기: limit_req 시험 기록</title>
      <link>https://tnhub.kr/learn/nginx-login-rate-limit</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/nginx-login-rate-limit</guid>
      <description>로그인과 가입 요청만 제한하는 설정과 실제로 429가 나오는지 시험한 결과를 정리했습니다.</description>
      <content:encoded><![CDATA[<p>점검 중에 로그인 시도 횟수 제한이 어디에도 없다는 것을 확인했습니다. 애플리케이션을 고치기 전에 nginx에서 먼저 막으면 앱까지 요청이 가지 않아 작은 서버에 특히 유리합니다.</p><h2>GET은 두고 POST만 제한하기</h2><p>가입 주소는 GET과 POST를 함께 씁니다. limit_req의 키가 빈 값이면 제한하지 않는 점을 이용하면 POST만 고를 수 있습니다.</p><pre><code>map $request_method $signup_key {
    POST    $binary_remote_addr;
    default &quot;&quot;;
}

limit_req_zone $binary_remote_addr zone=login:10m rate=20r/m;
limit_req_zone $signup_key zone=signup:10m rate=5r/m;
limit_req_status 429;</code></pre><p>기본 상태 코드는 503인데, 서버 장애와 구분되도록 429로 바꿨습니다.</p><h2>burst와 nodelay</h2><pre><code>location = /api/user {
    limit_req zone=signup burst=5 nodelay;
    proxy_pass http://127.0.0.1:8080;
}</code></pre><p>burst는 잠깐 몰리는 요청을 허용하는 여유분이고, nodelay는 그 요청을 대기시키지 않고 바로 처리합니다. 없으면 사용자가 느린 사이트로 느낍니다.</p><h2>값을 어떻게 정했나</h2><p>같은 공유기나 통신사 뒤의 사용자는 IP를 나눠 쓸 수 있습니다. 너무 낮추면 정상 사용자가 막히므로 로그인은 분당 20회에 여유 20회로 시작했습니다. 공격을 완전히 막는 값은 아니고 속도를 크게 늦추는 값입니다.</p><h2>실제로 시험하기</h2><p>설정 파일이 맞는지만 보면 부족합니다. 격리된 임시 nginx를 다른 포트로 띄우고 요청을 연속으로 보내 상태 코드를 셌습니다.</p><pre><code>for i in $(seq 1 30); do
  curl -s -o /dev/null -w &quot;%{http_code}\n&quot; -X POST http://127.0.0.1:18999/login
done | sort | uniq -c</code></pre><p>로그인 30회 중 21회가 백엔드로 전달되고 9회가 429였습니다. 가입은 12회 중 6회가 429였고, 같은 주소의 GET은 12회 모두 제한되지 않아 의도대로 동작했습니다.</p><h2>한계</h2><ul><li>여러 IP로 나눠 들어오는 공격에는 약합니다.</li><li>프록시나 CDN 뒤에 있으면 실제 사용자 IP를 복원해야 합니다.</li><li>계정별 실패 횟수 제한과 함께 쓰면 더 좋습니다.</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Jenkins로 nginx 설정까지 안전하게 자동 배포하기</title>
      <link>https://tnhub.kr/learn/jenkins-nginx-safe-deploy</link>
      <guid isPermaLink="true">https://tnhub.kr/learn/jenkins-nginx-safe-deploy</guid>
      <description>설정 오류가 나면 이전 상태로 자동 복구하는 배포 단계를 구성한 방법입니다.</description>
      <content:encoded><![CDATA[<p>애플리케이션 배포는 자동으로 해두고 nginx 설정만 서버에서 손으로 고치면, 저장소와 서버의 내용이 어긋납니다. 어느 쪽이 진짜인지 모르게 되고 재구축할 때 설정을 잃기 쉽습니다. 설정 파일도 저장소에서 관리하고 배포 때 함께 반영하도록 바꿨습니다.</p><h2>위험한 점</h2><p>nginx 설정에 오타가 있는 채로 재시작하면 사이트 전체가 내려갑니다. 그래서 복사, 검사, 적용, 복구 순서를 지켜야 합니다.</p><h2>배포 단계</h2><pre><code>SITE=/etc/nginx/sites-available/default
BACKUP=$SITE.bak-jenkins

if sudo cmp -s nginx/site.conf &quot;$SITE&quot;; then
  echo &quot;변경 없음&quot;
else
  sudo cp &quot;$SITE&quot; &quot;$BACKUP&quot;
  sudo cp nginx/site.conf &quot;$SITE&quot;
  if sudo nginx -t; then
    sudo systemctl reload nginx
  else
    sudo cp &quot;$BACKUP&quot; &quot;$SITE&quot;
    sudo nginx -t &amp;&amp; sudo systemctl reload nginx
    exit 1
  fi
fi</code></pre><p>cmp로 변경이 없으면 건너뛰고, nginx -t가 실패하면 백업을 되돌린 뒤 빌드를 실패 처리합니다. 사이트는 이전 설정으로 계속 동작합니다.</p><h2>reload를 쓰는 이유</h2><p>reload는 새 워커를 띄우고 기존 연결은 끝날 때까지 유지합니다. 문법이 틀리면 기존 프로세스가 그대로 남기 때문에 재시작보다 안전합니다.</p><h2>문법 검사로는 못 잡는 것</h2><p>nginx -t는 문법만 봅니다. 리다이렉트나 캐시 헤더가 의도대로인지는 알려주지 않습니다. 중요한 변경은 다른 포트에 임시 인스턴스를 띄워 실제 요청으로 시험합니다.</p><pre><code>nginx -t -c /tmp/test/nginx.conf -p /tmp/test
nginx -c /tmp/test/nginx.conf -p /tmp/test
curl -sI http://127.0.0.1:18999/tools/json</code></pre><h2>sudo 권한은 좁게</h2><p>Jenkins 계정에 모든 sudo를 주면 빌드 스크립트가 서버 전체를 다룰 수 있습니다. 가능하면 필요한 명령만 sudoers에 지정합니다.</p><h2>비밀값 다루기</h2><ul><li>비밀번호와 키는 저장소가 아니라 Jenkins Credentials에 둡니다.</li><li>셸 trace가 켜져 있으면 값이 로그에 찍힐 수 있으니 해당 구간에서 끕니다.</li><li>docker run에는 변수 이름만 넘겨 프로세스 목록에 값이 드러나지 않게 합니다.</li></ul>]]></content:encoded>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>
