Mac에서 Cron 대신 LaunchD 사용

어느날 부턴가 MacBook의 Cron이 안돌기 시작합니다. 얼마전 MacOS업데이트 이후로 그런거 같습니다.

Crontab에 단순명령을 입력해보아도 돌지 않아요. 아래는 1분마다 date를 실행하는 job입니다.

$ crontab -l
* * * * * /bin/date >> /tmp/cron_test.txt 2>&1

크론탭 데몬이 살아있는지 확인해보면 살아있는데

$ ps aux | grep cron
slim              1155   0.0  0.0 34129352    724 s000  S+    9:09AM   0:00.00 grep cron
root              1051   0.0  0.0 33753552   2548   ??  Ss    8:58AM   0:00.02 /usr/sbin/cron

Cron 로그를 확인해도 아무것도 안뜹니다.

$ log show --predicate 'process == "cron"' --last 10m
Filtering the log data using "process == "cron""
Skipping info and debug messages, pass --info and/or --debug to include.
Timestamp                       Thread     Type        Activity             PID    TTL
--------------------------------------------------------------------------------------------------------------------
Log      - Default:          0, Info:                0, Debug:             0, Error:          0, Fault:          0
Activity - Create:           0, Transition:          0, Actions:           0

Launchd에서 cron상태를 확인했을때 정상적으로 com.vix.cron이 떠있습니다.

09:10 ~ $ sudo launchctl list | grep cron
Password:
1051	-15	com.vix.cron

Cron을 다시 로드해보지만 실패합니다.

$ sudo launchctl unload /System/Library/LaunchDaemons/com.vix.cron.plist
Unload failed: 5: Input/output error
Try running `launchctl bootout` as root for richer errors.
$ sudo launchctl load /System/Library/LaunchDaemons/com.vix.cron.plist
Load failed: 5: Input/output error
Try running `launchctl bootstrap` as root for richer errors.

시간이 지나도 Cron은 여전히 돌지 않습니다.

$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

아예 실행조차 되지 않는것 같았습니다.

$ log show --predicate 'process == "cron"' --last 15m 
Filtering the log data using "process == "cron""
Skipping info and debug messages, pass --info and/or --debug to include.
Timestamp                       Thread     Type        Activity             PID    TTL
--------------------------------------------------------------------------------------------------------------------
Log      - Default:          0, Info:                0, Debug:             0, Error:          0, Fault:          0
Activity - Create:           0, Transition:          0, Actions:           0

권한 문제가 있는것 같았습니다.

$ stat -f "%Sm %N" /usr/lib/cron/tabs /usr/lib/cron/tabs/slim
Mar 15 08:25:50 2026 /usr/lib/cron/tabs stat: /usr/lib/cron/tabs/slim: stat: Permission denied

권한 상태를 살펴보았습니다.

$ sudo ls -ld /usr/lib/cron/tabs
Password:
drwxr-xr-x@ 3 root  wheel  96 Mar 15 09:17 /usr/lib/cron/tabs
$ sudo ls -l /usr/lib/cron/tabs
total 8
-rw-------@ 1 root  wheel  226 Mar 15 09:17 slim

권한을 맞춰주었습니다.

$ sudo chmod 755 /usr/lib/cron/tabs
$ sudo chown root:wheel /usr/lib/cron/tabs
$ sudo chmod 600 /usr/lib/cron/tabs/slim
$ sudo chown root:wheel /usr/lib/cron/tabs/slim
$ sudo ls -ld /usr/lib/cron/tabs
drwxr-xr-x@ 3 root  wheel  96 Mar 15 09:17 /usr/lib/cron/tabs

Cron 데몬을 Restart시켜보려는데 에러가 납니다.

$ sudo launchctl kickstart -k system/com.vix.cron
Could not kickstart service "com.vix.cron": 150: Operation not permitted while System Integrity Protection is engaged

크론파일을 삭제하고 다시 넣어봅니다. 여전히 안돌아갑니다.

$ sudo rm /usr/lib/cron/tabs/slim
$ crontab -e
* * * * * /bin/date >> /tmp/cron_test.txt 2>&1
$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

뒤에 붙은 @ 표시가 중요합니다. 이건 extended attributes (xattr) 가 붙어 있다는 뜻입니다. macOS에서 /usr/lib/cron/tabs/* 파일에 quarantine / provenance 같은 xattr이 붙으면 cron이 실행을 무시하는 경우가 있습니다. @을 제거해보도록 하겠습니다. 하지만 제거가 원활하게 되지 않습니다.

$ sudo ls -l /usr/lib/cron/tabs
total 8
-rw-------@ 1 root  wheel  226 Mar 15 09:16 slim
$ sudo xattr -l /usr/lib/cron/tabs/slim
com.apple.provenance:
$ sudo xattr -c /usr/lib/cron/tabs/slim
$ sudo ls -l /usr/lib/cron/tabs
total 8
-rw-------@ 1 root  wheel  226 Mar 15 09:17 slim

MacBook을 재부팅해봤습니다. 여전히 Cron이 안돌아 갑니다. 로그를 열어놓고 기다려봤지만 안돌아 갑니다.

$ sudo log stream --predicate 'process == "cron"' --info
Filtering the log data using "process == "cron""

cron 대신 macOS 기본 스케줄러(launchd) 로 같은 작업을 실행해 보면 cron 문제인지 바로 알 수 있습니다.

09:29 ~ $ launchctl submit -l testcron -- /bin/date

그리고 확인. 시스템은 정상이고 cron만 문제입니다.

09:33 ~ $ log show --last 1m | grep testcron
2026-03-15 09:33:53.612271-0400 0xb2f6     Default     0x0                  1      0    launchd: [testcron:] This service is defined to be constantly running and is inherently inefficient.
2026-03-15 09:33:53.612293-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] internal event: WILL_SPAWN, code = 0
2026-03-15 09:33:53.612309-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service state: spawn scheduled
2026-03-15 09:33:53.612311-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service state: spawning
2026-03-15 09:33:53.612338-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] launching: speculative
2026-03-15 09:33:53.612871-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] xpcproxy spawned with pid 1261
2026-03-15 09:33:53.612887-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: SPAWNED, code = 0
2026-03-15 09:33:53.612890-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: xpcproxy
2026-03-15 09:33:53.612974-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: SOURCE_ATTACH, code = 0
2026-03-15 09:33:53.620899-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: running
2026-03-15 09:33:53.620908-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: INIT, code = 0
2026-03-15 09:33:53.620916-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] Successfully spawned date[1261] because speculative
2026-03-15 09:33:53.624623-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] exited due to exit(0), ran for 12ms
2026-03-15 09:33:53.624631-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: exited
2026-03-15 09:33:53.624635-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: EXITED, code = 0
2026-03-15 09:33:53.624638-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501 [100002]:] service inactive: testcron
2026-03-15 09:33:53.624640-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: not running
2026-03-15 09:33:53.624643-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] Service only ran for 0 seconds. Pushing respawn out by 10 seconds.
2026-03-15 09:33:53.624677-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] internal event: WILL_SPAWN, code = 0
2026-03-15 09:33:53.624681-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service state: spawn scheduled
2026-03-15 09:33:53.624682-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service spawn deferred by 10 seconds due to throttle

크론을 죽였다 살려봅니다. 여전히 안됩니다.

$ sudo pkill cron
$ ps aux | grep cron
$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

그렇다면 내 계정의 cron만 무시하는건지 root로 실행해도 안되는건지 테스트해봅니다. root로 해도 안됩니다.

$ sudo nano /etc/crontab
* * * * * root /bin/date >> /tmp/cron_test.txt 2>&1
$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

cron daemon은 떠 있지만 cron 스케줄러가 아예 돌지 않습니다. 이 패턴은 macOS에서 가끔 생기는 cron service 자체가 launchd에서 제대로 작동하지 않는 상태입니다. 그래서 해결 방법은 cron 설정이 아니라 cron launch daemon을 다시 로드하는 것입니다. 이건 cron 서비스를 완전히 unload 한뒤 다시 load 하는 것입니다. 그런데 에러가납니다.

$ sudo launchctl bootout system /System/Library/LaunchDaemons/com.vix.cron.plist
Boot-out failed: 5: Input/output error
$ sudo launchctl bootstrap system /System/Library/LaunchDaemons/com.vix.cron.plist
Bootstrap failed: 5: Input/output error

정상적인 cron 디버깅은 거의 다 했습니다. 그래서 이제 결론은 하나입니다.

지금 상태는 cron이 launchd에서 깨진 상태입니다.

증거들을 정리하면:

  • cron daemon 실행됨
  • crontab 정상
  • /etc/crontab도 실행 안됨
  • cron 로그 0
  • kill / reboot / permission 수정 → 효과 없음
  • launchctl bootout/bootstrap → I/O error

이 패턴은 macOS에서 launch daemon registry가 꼬였을 때 나타납니다.

cron을 다시 살리는 것보다 macOS 기본 스케줄러 launchd로 바꾸는 것이 훨씬 안정적입니다. Apple도 그렇게 권장합니다.

그래서 같은 테스트를 launchd job으로 만들어 보겠습니다. 임시테스트 파일을 하나 만들겠습니다.

nano ~/Library/LaunchAgents/test.cron.plist

그안에 이렇게 저장합니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>

<key>Label</key>
<string>test.cron</string>

<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/date >> /tmp/cron_test.txt</string>
</array>

<key>StartInterval</key>
<integer>60</integer>

<key>RunAtLoad</key>
<true/>

</dict>
</plist>

해당 파일을 LaunchD에 등록합니다.

launchctl load ~/Library/LaunchAgents/test.cron.plist

1분뒤에 확인합니다. 잘되네요.

$ cat /tmp/cron_test.txt
Sun Mar 15 09:04:29 EDT 2026

임시로 올렸던 스케줄파일과 테스트파일들 삭제하세요.

launchctl unload ~/Library/LaunchAgents/test.cron.plist
rm ~/Library/LaunchAgents/test.cron.plist
rm /tmp/cron_test.txt

크론에서 만약에 아래와 같은 파일을 실행하고 있었다면

0 5 * * * /Users/slim/git/mijutoday.com/rundailynews.sh > /Users/slim/git/mijutoday.com/output/output_rundailynews.txt

아래 폴더에 해당 스케줄을 넣을 파일을 하나 생성하고 (현재 사용자 이름이 slim인경우입니다.)

$ nano ~/Library/LaunchAgents/com.slim.rundailynews.plist

아래와 같은 XML파일을 저장합니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.slim.rundailynews</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/zsh</string>
        <string>/Users/slim/git/mijutoday.com/rundailynews.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>5</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/slim/git/mijutoday.com/output/output_rundailynews.txt</string>

    <key>StandardErrorPath</key>
    <string>/Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt</string>

    <key>RunAtLoad</key>
    <false/>
</dict>
</plist>

문법이 맞는지 확인합니다.

plutil -lint ~/Library/LaunchAgents/com.slim.rundailynews.plist
/Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist: OK
$ plutil -p ~/Library/LaunchAgents/com.slim.rundailynews.plist
{
  "EnvironmentVariables" => {
    "PATH" => "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
    "PYTHONPATH" => "/Users/slim/git/mijutoday.com"
  }
  "Label" => "com.slim.rundailynews"
  "ProgramArguments" => [
    0 => "/bin/zsh"
    1 => "/Users/slim/git/mijutoday.com/rundailynews.sh"
  ]
  "RunAtLoad" => 0
  "StandardErrorPath" => "/Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt"
  "StandardOutPath" => "/Users/slim/git/mijutoday.com/output/output_rundailynews.txt"
  "StartCalendarInterval" => {
    "Hour" => 5
    "Minute" => 0
  }
  "WorkingDirectory" => "/Users/slim/git/mijutoday.com"
}

실행권한을 줍니다.

$ chmod 644 ~/Library/LaunchAgents/com.slim.rundailynews.plist
$ ls -l ~/Library/LaunchAgents/com.slim.rundailynews.plist
-rw-r--r--@ 1 slim  staff  1200 Mar 15 09:53 /Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist

그리고 설정한 파일을 스케줄러에 등록합니다.

$ launchctl load ~/Library/LaunchAgents/com.slim.rundailynews.plist

혹시 이미 등록이 되어 있어서 에러가 난다면 내리고 다시 올려야합니다. 아래와 같이등록여부를 확인합니다.

$ launchctl list | grep rundailynews
- 0 com.slim.rundailynews

이미 등록이 되어 있다면 어떻게 등록이 되어있는지 확인합니다.

$ launchctl print gui/$(id -u)/com.slim.rundailynews
gui/501/com.slim.rundailynews = {
	active count = 0
	path = /Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist
	type = LaunchAgent
	state = not running

	program = /bin/zsh
	arguments = {
		/bin/zsh
		/Users/slim/git/mijutoday.com/rundailynews.sh
	}

	working directory = /Users/slim/git/mijutoday.com

	stdout path = /Users/slim/git/mijutoday.com/output/output_rundailynews.txt
	stderr path = /Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt
	inherited environment = {
		SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.tmkWVan7Yd/Listeners
	}

	default environment = {
		PATH => /usr/bin:/bin:/usr/sbin:/sbin
	}

	environment = {
		PATH => /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
		PYTHONPATH => /Users/slim/git/mijutoday.com
		XPC_SERVICE_NAME => com.slim.rundailynews
	}

	domain = gui/501 [100002]
	asid = 100002
	minimum runtime = 10
	exit timeout = 5
	runs = 0
	last exit code = (never exited)

	event triggers = {
		com.slim.rundailynews.268435470 => {
			keepalive = 0
			service = com.slim.rundailynews
			stream = com.apple.launchd.calendarinterval
			monitor = com.apple.UserEventAgent-Aqua
			descriptor = {
				"Minute" => 17
				"Hour" => 4
			}
		}
	}

	event channels = {
		"com.apple.launchd.calendarinterval" = {
			port = 0x0
			active = 0
			managed = 1
			reset = 0
			hide = 0
			watching = 1
		}
	}

	spawn type = daemon (3)
	jetsam priority = 40
	jetsam memory limit (active) = (unlimited)
	jetsam memory limit (inactive) = (unlimited)
	jetsamproperties category = daemon
	jetsam thread limit = 32
	cpumon = default
	probabilistic guard malloc policy = {
		activation rate = 1/1000
		sample rate = 1/0
	}

	properties = inferred program | needs LWCR update | managed LWCR
}

만약 plist파일을 수정했고 다시 반영하고 싶다면 bootout/bootstrap으로 재런칭을 합니다.

$ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.slim.rundailynews.plist
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.slim.rundailynews.plist
새로 반영된 스케줄을 확인합니다.
$ launchctl print gui/$(id -u)/com.slim.rundailynews
gui/501/com.slim.rundailynews = {
	active count = 0
	path = /Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist
	type = LaunchAgent
	state = not running

	program = /bin/zsh
	arguments = {
		/bin/zsh
		/Users/slim/git/mijutoday.com/rundailynews.sh
	}

	working directory = /Users/slim/git/mijutoday.com

	stdout path = /Users/slim/git/mijutoday.com/output/output_rundailynews.txt
	stderr path = /Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt
	inherited environment = {
		SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.tmkWVan7Yd/Listeners
	}

	default environment = {
		PATH => /usr/bin:/bin:/usr/sbin:/sbin
	}

	environment = {
		PATH => /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
		PYTHONPATH => /Users/slim/git/mijutoday.com
		XPC_SERVICE_NAME => com.slim.rundailynews
	}

	domain = gui/501 [100002]
	asid = 100002
	minimum runtime = 10
	exit timeout = 5
	runs = 0
	last exit code = (never exited)

	event triggers = {
		com.slim.rundailynews.268435471 => {
			keepalive = 0
			service = com.slim.rundailynews
			stream = com.apple.launchd.calendarinterval
			monitor = com.apple.UserEventAgent-Aqua
			descriptor = {
				"Minute" => 0
				"Hour" => 5
			}
		}
	}

	event channels = {
		"com.apple.launchd.calendarinterval" = {
			port = 0x0
			active = 0
			managed = 1
			reset = 0
			hide = 0
			watching = 1
		}
	}

	spawn type = daemon (3)
	jetsam priority = 40
	jetsam memory limit (active) = (unlimited)
	jetsam memory limit (inactive) = (unlimited)
	jetsamproperties category = daemon
	jetsam thread limit = 32
	cpumon = default
	probabilistic guard malloc policy = {
		activation rate = 1/1000
		sample rate = 1/0
	}

	properties = inferred program
}

등록된 스케줄을 확인합니다.

이제 Cron은 전부 지우세요

$ crontab -l

Automate Uploading with Watchdog and FTP

블루호스팅을 사용하는 경우 로컬에서 원하는 편집기로 파일을 수정하고 파일이 변경됨과 동시에 해당파일을 자동으로 업로드해서 실행결과를 서버에서 볼수 있도록 자동으로 파일을 감시하고 업로드하는 파이썬 스크립트를 만들어 보겠습니다.

일단 필요한 패키지는 watchdog이라는 파이썬 패키지 인데요 아래와 같이 설치해줍니다.

pip install watchdog

코딩에 앞서 필요한 정보를 모아주세요. 일단 변경할 폴더위치와 변경시 업로드할 FTP서버정보를 알고 있어야겠죠?

# ===== FTP 설정 =====
FTP_HOST = "ftp.mydomain.com"
FTP_USER = "your_username"
FTP_PASS = "your_password"
FTP_UPLOAD_DIR = "/remote/path/"  # FTP 서버에서 업로드할 경로

# ===== 감시할 폴더 경로 =====
WATCH_FOLDER = "/path/to/watch"  # 감시할 로컬 디렉토리

혹시 Mac에서 ftp명령이 없다고 나오면 ftp를 설치해주세요.

brew install inetutils

그리고 해당 FTP정보가 올바른지 확인합니다.

gftp ftp.mydomain.com

이제 Watchdog을 이용해서 특정 폴더를 감시하는 프로그램을 만들어볼게요.

가장 먼저 스크립트를 만들 폴더에 .env파일을 생성하고 각 환경변수를 아래와 같이 저장하도록 합니다.

FTP_HOST=ftp.mydomain.com
FTP_USER=your_username
FTP_PASS=your_password
FTP_UPLOAD_DIR=/remote/path/
WATCH_FOLDER=/path/to/watch

그리고 스크립트는 watch_and_upload.py라는 이름으로 생성할게요. 그리고 dotenv패키지를 이용해서 환경변수를 읽어옵니다.

import os
from dotenv import load_dotenv

# .env 파일 로드
load_dotenv()

# 환경 변수 가져오기
FTP_HOST = os.getenv("FTP_HOST")
FTP_USER = os.getenv("FTP_USER")
FTP_PASS = os.getenv("FTP_PASS")
FTP_UPLOAD_DIR = os.getenv("FTP_UPLOAD_DIR")
WATCH_FOLDER = os.getenv("WATCH_FOLDER")

우선 특정파일을 FTP로 업로드하는 함수를 정의하도록 하겠습니다. ftplib패키지에서 FTP를 import하고, ensure_remote_path함수와 upload_to_ftp함수 두개를 선언합니다. ensure_remote_path함수는 FTP로 파일을 업로드 하려고 할때 매칭되는 파일의 경로가 존재하지 않을 경우 폴더를 생성해주는 역할을 합니다. 그리고 upload_to_ftp함수는 FTP로 서버에 접근하여 로컬의 경로와 서버의 경로를 매칭하여 올바른 폴더에 해당 파일을 업로드하는 기능을 합니다.

from ftplib import FTP

def ensure_remote_path(ftp, remote_dir):
    """FTP 서버에 디렉토리 경로가 없으면 생성한다."""
    parts = remote_dir.strip("/").split("/")
    current_path = ""
    for part in parts:
        current_path += "/" + part
        try:
            ftp.cwd(current_path)
        except:
            try:
                ftp.mkd(current_path)
                ftp.cwd(current_path)
            except Exception as e:
                print(f"❌ Failed to create {current_path}: {e}")
                return False
    return True

def upload_to_ftp(file_path, relative_path):
    try:
        with FTP(FTP_HOST) as ftp:
            ftp.login(FTP_USER, FTP_PASS)

            # 경로 구성
            remote_path = os.path.join(FTP_UPLOAD_DIR, relative_path).replace("\\", "/")
            remote_dir = os.path.dirname(remote_path)
            filename = os.path.basename(file_path)

            if ensure_remote_path(ftp, remote_dir):
                with open(file_path, 'rb') as f:
                    ftp.storbinary(f"STOR {filename}", f)
                print(f"✅ Uploaded: {remote_path}")
            else:
                print(f"❌ Failed to ensure remote path: {remote_dir}")
    except Exception as e:
        print(f"❌ FTP Upload Error: {e}")

이제 파일을 감시하는 코드를 넣을게요. 우선 watchdog 패키지에서 파일을 감시하는 이벤트핸들러, FileSystemEventHandler를 import합니다. 그리고 해당 이벤트 핸들러에 on_modified이벤트가 발생했을때 취해야할 액션을 아래와 같이 재정의 합니다. on_modified는 watchdog을 실행했을때 파일이나 폴더에 변경이 있을 경우 실행되는 함수입니다. 폴더는 스킵하고 파일이 변경된 경우에만 FTP업로드를 하는 것으로 할게요. 바로 여기에서 변경된 파일의 풀경로와 상대경로를 획득해서 FTP에 업로드하도록 upload_to_ftp함수를 호출합니다.

from watchdog.events import FileSystemEventHandler

class ChangeHandler(FileSystemEventHandler):
    def on_modified(self, event):
        if not event.is_directory:
            full_path = event.src_path
            relative_path = os.path.relpath(full_path, WATCH_FOLDER)
            upload_to_ftp(full_path, relative_path)

그럼 이제 스크립트가 실행되면 핸들러를 작동시키도록 코드를 추가하겠습니다. 감시에 필요한 클래스 Observer를 import합니다. 그리고 Observer를 observer로 객체화 합니다. 방금 위에서재정의한 ChangeHandler클래스도 마찬가지로 event_handler에 객체생성을 하고, 해당 이벤트 핸들러를 적용하여 observer의 schedule을 start()시킵니다. 그리고 매초마다 Ctrl+C가 입력이 되었는지 확인하여 Ctrl+C를 누른경우 observer를 종료하도록 합니다.

from watchdog.observers import Observer

if __name__ == "__main__":
    print(f"👀 Watching folder (and subfolders): {WATCH_FOLDER}")
    observer = Observer()
    event_handler = ChangeHandler()
    observer.schedule(event_handler, path=WATCH_FOLDER, recursive=True)
    observer.start()
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

전체 코드는 다음과 같습니다.

import os
from dotenv import load_dotenv
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from ftplib import FTP
import time

# .env 파일 로드
load_dotenv()

# 환경 변수 가져오기
FTP_HOST = os.getenv("FTP_HOST")
FTP_USER = os.getenv("FTP_USER")
FTP_PASS = os.getenv("FTP_PASS")
FTP_UPLOAD_DIR = os.getenv("FTP_UPLOAD_DIR")
WATCH_FOLDER = os.getenv("WATCH_FOLDER")

# ===== FTP 전송 함수 =====
def ensure_remote_path(ftp, remote_dir):
    """FTP 서버에 디렉토리 경로가 없으면 생성한다."""
    parts = remote_dir.strip("/").split("/")
    current_path = ""
    for part in parts:
        current_path += "/" + part
        try:
            ftp.cwd(current_path)
        except:
            try:
                ftp.mkd(current_path)
                ftp.cwd(current_path)
            except Exception as e:
                print(f"❌ Failed to create {current_path}: {e}")
                return False
    return True

def upload_to_ftp(file_path, relative_path):
    try:
        with FTP(FTP_HOST) as ftp:
            ftp.login(FTP_USER, FTP_PASS)

            # 경로 구성
            remote_path = os.path.join(FTP_UPLOAD_DIR, relative_path).replace("\\", "/")
            remote_dir = os.path.dirname(remote_path)
            filename = os.path.basename(file_path)

            if ensure_remote_path(ftp, remote_dir):
                with open(file_path, 'rb') as f:
                    ftp.storbinary(f"STOR {filename}", f)
                print(f"✅ Uploaded: {remote_path}")
            else:
                print(f"❌ Failed to ensure remote path: {remote_dir}")
    except Exception as e:
        print(f"❌ FTP Upload Error: {e}")


# ===== 변경 감지 핸들러 클래스 =====
class ChangeHandler(FileSystemEventHandler):
    def on_modified(self, event):
        if not event.is_directory:
            full_path = event.src_path
            relative_path = os.path.relpath(full_path, WATCH_FOLDER)
            print(f"📂 Modified: {relative_path}")
            upload_to_ftp(full_path, relative_path)

# ===== 감시 시작 =====
if __name__ == "__main__":
    print(f"👀 Watching folder (and subfolders): {WATCH_FOLDER}")
    event_handler = ChangeHandler()
    observer = Observer()
    observer.schedule(event_handler, path=WATCH_FOLDER, recursive=True)
    observer.start()
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

실행을 해보면 아래와 같이 필요한 경로에 잘 들어가네요.

시청해주셔서 감사합니다.

워드프레스는 카테고리를 어디에 저장할까?

Overview

안녕하세요. 이번 시간에는 워드프레스가 데이타를 저장하는 방법에 대해서 이야기 해보도록 하겠습니다. 현재 최신 버젼의 워드프레스는 WordPress Version: 6.7.2이고 이하 설명은 해당 버젼을 기준으로 진행됩니다.

게시물이나 페이지를 저장하는 테이블

게시물이나 페이지는 직관적입니다. wp_posts테이블에 저장하고요 테이블 상세는 아래와 같습니다.

mysql> desc wp_posts;
ERROR 2013 (HY000): Lost connection to MySQL server during query
No connection. Trying to reconnect...
Connection id:    30134210
Current database: ???

+-----------------------+---------------------+------+-----+---------------------+----------------+
| Field                 | Type                | Null | Key | Default             | Extra          |
+-----------------------+---------------------+------+-----+---------------------+----------------+
| ID                    | bigint(20) unsigned | NO   | PRI | NULL                | auto_increment |
| post_author           | bigint(20) unsigned | NO   | MUL | 0                   |                |
| post_date             | datetime            | NO   |     | 0000-00-00 00:00:00 |                |
| post_date_gmt         | datetime            | NO   |     | 0000-00-00 00:00:00 |                |
| post_content          | longtext            | NO   |     | NULL                |                |
| post_title            | text                | NO   |     | NULL                |                |
| post_excerpt          | text                | NO   |     | NULL                |                |
| post_status           | varchar(20)         | NO   |     | publish             |                |
| comment_status        | varchar(20)         | NO   |     | open                |                |
| ping_status           | varchar(20)         | NO   |     | open                |                |
| post_password         | varchar(255)        | NO   |     |                     |                |
| post_name             | varchar(200)        | NO   | MUL |                     |                |
| to_ping               | text                | NO   |     | NULL                |                |
| pinged                | text                | NO   |     | NULL                |                |
| post_modified         | datetime            | NO   |     | 0000-00-00 00:00:00 |                |
| post_modified_gmt     | datetime            | NO   |     | 0000-00-00 00:00:00 |                |
| post_content_filtered | longtext            | NO   |     | NULL                |                |
| post_parent           | bigint(20) unsigned | NO   | MUL | 0                   |                |
| guid                  | varchar(255)        | NO   |     |                     |                |
| menu_order            | int(11)             | NO   |     | 0                   |                |
| post_type             | varchar(20)         | NO   | MUL | post                |                |
| post_mime_type        | varchar(100)        | NO   |     |                     |                |
| comment_count         | bigint(20)          | NO   |     | 0                   |                |
+-----------------------+---------------------+------+-----+---------------------+----------------+
23 rows in set (1.94 sec)

주요 필드를 설명하자면 아래와 같습니다:

  • post_type:
    • post: 일반 블로그 글
    • page: 정적 페이지
    • attachment: 이미지, PDF 등 업로드 파일
    • custom_post_type: 커스텀 포스트 타입 (예: product, portfolio)
  • post_status: publish, draft, private, trash 등
  • post_author: 작성자 (user ID)
  • post_parent: 페이지 간 계층 구조에서 부모 ID 지정할 때 사용
  • post_date: 작성일
  • post_content: 본문
  • post_title: 제목

카테고리를 저장하는 방식

wp_terms

우선 게시물을 쓰기 전에 선택하고자 하는 카테고리를 먼저 등록하게 되어 있잖아요? 그 카테고리들을 저장하는 테이블은 바로 wp_terms입니다. 테이블 안에 카테고리 이름(name), 슬러그(slug), 고유 ID(term_id)를 저장하고, 테이블 구조는 아래와 같습니다. 여기서 term_group는 원래 다국어 관련 기능을 위해 설계되었지만 거의 사용되지 않습니다.

mysql> desc wp_terms;
+------------+---------------------+------+-----+---------+----------------+
| Field      | Type                | Null | Key | Default | Extra          |
+------------+---------------------+------+-----+---------+----------------+
| term_id    | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| name       | varchar(200)        | NO   | MUL |         |                |
| slug       | varchar(200)        | NO   | MUL |         |                |
| term_group | bigint(10)          | NO   |     | 0       |                |
+------------+---------------------+------+-----+---------+----------------+

wp_term_taxonomy

사실 위에서 설명한 wp_terms테이블은 카테고리만을 위해 존재하는 것은 아닙니다. 그 안에 태그도 함께 저장할 수 가 있어요. 그렇다면 해당 레코드가 카테고리인지 태그인지는 어떻게 아느냐? 바로 wp_term_taxonomy테이블에서 해당 term이 어떤 분류 유형(taxonomy)인지 정의 (category, post_tag 등)하고 있습니다. 테이블 상세를 보시면 다음과 같습니다.

mysql> desc wp_term_taxonomy;
+------------------+---------------------+------+-----+---------+----------------+
| Field            | Type                | Null | Key | Default | Extra          |
+------------------+---------------------+------+-----+---------+----------------+
| term_taxonomy_id | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| term_id          | bigint(20) unsigned | NO   | MUL | 0       |                |
| taxonomy         | varchar(32)         | NO   | MUL |         |                |
| description      | longtext            | NO   |     | NULL    |                |
| parent           | bigint(20) unsigned | NO   |     | 0       |                |
| count            | bigint(20)          | NO   |     | 0       |                |
+------------------+---------------------+------+-----+---------+----------------+

term_taxonomy_id에 고유ID를 저장하고, term_id에 wp_terms.term_id를 저장합니다. 그리고 taxonomy에 category인지 post_tag인지를 저장하고, description에 설명을 넣을 수 있어요. 그리고 카테고리인 경우 부모카테고리가 있으면 parent필드에 명시합니다. 그리고 마지막으로 count에는 해당 카테고리에 게시글이 몇개인지가 저장됩니다.

wp_term_relationships

이제 어떤 게시글이 어떤 카테고리에 연결이 되는지를 알아야겠죠? 바로 wp_term_relationships테이블에서 어떤 게시물(post) 이 어떤 카테고리(또는 태그 등)에 연결되어 있는지 저장합니다. 테이블의 내용은 엄청 단순합니다.

mysql> desc wp_term_relationships;
+------------------+---------------------+------+-----+---------+-------+
| Field            | Type                | Null | Key | Default | Extra |
+------------------+---------------------+------+-----+---------+-------+
| object_id        | bigint(20) unsigned | NO   | PRI | 0       |       |
| term_taxonomy_id | bigint(20) unsigned | NO   | PRI | 0       |       |
| term_order       | int(11)             | NO   |     | 0       |       |
+------------------+---------------------+------+-----+---------+-------+

Object_id가 바로 post_id가 되고, term_taxonomy_id가 wp_term_taxonomy테이블의 term_taxonomy_id가 됩니다. 여기서 term_order는 하나의 게시물에 여러개의 태그나 카테고리가 걸려있는 경우 보여주는 순서를 정할 수가 있는데 기본적으로는 사용하지 않습니다.

카테고리 + 태그 모두 포함한 SQL 쿼리

위의 관계들을 종합해보면 게시물목록을 불러올 때 사용되는 쿼리는 다음과 같습니다.

SELECT 
    p.ID AS post_id,
    p.post_title,
    tt.taxonomy,
    t.name AS term_name,
    t.slug AS term_slug
FROM wp_posts p
JOIN wp_term_relationships tr ON (p.ID = tr.object_id)
JOIN wp_term_taxonomy tt ON (tr.term_taxonomy_id = tt.term_taxonomy_id)
JOIN wp_terms t ON (tt.term_id = t.term_id)
WHERE p.post_status = 'publish'
  AND p.post_type = 'post'
  AND tt.taxonomy IN ('category', 'post_tag')
ORDER BY p.ID, tt.taxonomy;

그리고 쿼리결과는 아래와 같습니다.

+---------+---------------------+----------+-----------+------------+
| post_id | post_title          | taxonomy | term_name | term_slug  |
+---------+---------------------+----------+-----------+------------+
|       1 | Hello world!        | category | 테크       | tech       |
|       6 | 테스트 포스트           | category | 부동산     | realestate |
|       8 | 뉴스 테스트            | category | 주식       | stocks     |
+---------+---------------------+----------+-----------+------------+

워드프레스의 테이블 형태를 이해하는데 도움이 되셨기를 바랍니다. 그럼 다음시간에 뵐게요!

OpenAI API사용하기

Overview

안녕하세요. 이번 시간에는 OpenAI API를 이용해서 챗GPT앱을 사용하지 않고 파이썬 스크립트로 AI응답을 받아오는 방법에 대해서 공부해 보도록 하겠습니다.

사용 Limit확인

OpenAI의 API를 이용하기 위해서는 본인의 계정에 API를 사용할 수 있는 크레딧이 남아 있어야합니다. 현재 남아있는 크레딧을 확인하시거나 결제를 하시려면 https://platform.openai.com/settings/organization/billing/overview에 접속하시면 아래와 같이 현재 보유하고 있는 크레딧을 확인하 실 수가 있습니다.

결제정보를 한번도 입력하지 않은 경우라면, Add payment details을 눌러서 결제정보를 입력해주세요. 카드정보와 빌링주소등을 적고 Continue버튼을 누릅니다.

그러면 충전화면으로 바로 이동합니다. 만약 결제정보가 이미 등록된 경우라면 화면에 Add to credit balance라는 버튼이 있을거에요. 그거 눌러서 아래 화면을 띄웁니다.

최소 충전 금액은 $5이고 한번에 $95까지 충전이 가능합니다. 충전하시고자 하는 금액을 입력하신 뒤 Continue버튼을 눌러서 충전을 완료해주세요. 충전이 완료되면 화면이 닫히고, 빌링대시보드에 충전된 금액이 표시됩니다.

API Key

크레딧이 충전이 되었다면 이제 API Key를 우선적으로 받아와야하는데요. https://platform.openai.com/api-keys에 들어가시면 아래와 같이 API Key관리화면이 뜨고 중앙에 + Create new secret key라는 버튼이 보일거에요.

+ Create secret key버튼을 누르면 팝업이 뜨고 해당 키의 이름과 사용될 프로젝트, 그리고 사용권한을 입력하는 양식이 나오는데요. 테스트니까 그냥 기본값으로 선택하신뒤에 Create secret key버튼을 누르시면 됩니다.

그러면 바로 API Key를 생성해서 화면에 보여줍니다. 그 Key를 복사해주세요. 여기서 반드시 복사하셔야합니다. 팝업창을 한번 닫으면 API Key를 다시 보여주지 않아요.

파이썬에서 API호출하기

우선 접속에 필요한 패키지를 설치해주세요.

pip install openai
pip install python-dotenv

.env 파일 생성

프로젝트 루트 디렉토리에 .env 파일을 생성하고, 다음과 같이 API 키를 저장합니다:

OPENAI_API_KEY=your-api-key-here

파이썬 코드에서 환경 변수 로드

이제 파이썬 코드에서 dotenv를 사용하여 환경 변수를 로드하고, OpenAI 클라이언트를 생성할 수 있습니다:

import os
from openai import OpenAI
from dotenv import load_dotenv

# .env 파일 로드
load_dotenv()

# 환경 변수에서 API 키 가져오기
api_key = os.getenv("OPENAI_API_KEY")

# OpenAI 클라이언트 생성
client = OpenAI(api_key=api_key)

Chat Completions API 호출 예제

이제 client 객체를 사용하여 Chat Completions API를 호출하는 예제를 보여드리겠습니다:​

response = client.chat.completions.create(
    model="gpt-3.5-turbo",  # 또는 사용 가능한 다른 모델
    messages=[
        {"role": "system", "content": "당신은 유용한 어시스턴트입니다."},
        {"role": "user", "content": "파이썬에서 리스트를 정렬하는 방법을 알려줘."}
    ],
    temperature=0.7,
)

# 응답 출력
print(response.choices[0].message.content)

위 코드에서 temperature 파라미터는 생성되는 응답의 창의성을 조절합니다. 값이 낮을수록 더 보수적인 응답을, 높을수록 더 창의적인 응답을 생성합니다.

위의 파일을 실행해보면 다음과 같은 결과가 나타납니다.

파이썬에서 리스트를 정렬하는 방법은 `sort()` 메서드나 `sorted()` 함수를 사용하는 것입니다   \n\n1   `sort()` 메서드를 사용하는 방법:\n```python\nmy_list = [3, 1, 4, 1, 5, 9, 2, 6, 5]\nmy_list  sort()\nprint(my_list)  # [1, 1, 2, 3, 4, 5, 5, 6, 9]\n```\n\n2   `sorted()` 함수를 사용하는 방법:\n```python\nmy_list = [3, 1, 4, 1, 5, 9, 2, 6, 5]\nsorted_list = sorted(my_list)\nprint(sorted_list)  # [1, 1, 2, 3, 4, 5, 5, 6, 9]\n```\n\n`sort()` 메서드는 원본 리스트를 직접 변경하고, `sorted()` 함수는 정렬된 새로운 리스트를 반환합니다   필요에 따라 적절히 선택하여 사용하시면 됩니다

참고로 response를 출력해보면, print(response), 다음과 같은 결과를 보여줍니다.

ChatCompletion(
  "id=""chatcmpl-BK8PJOCXL1zAp3MXID8Mn3YTCnrp8",
  "choices="[
     "Choice(
         finish_reason=""stop",
      index=0,
      "logprobs=None",
      "message=ChatCompletionMessage(
        content=""파이썬에서 리스트를 정렬하는 방법은 `sort()` 메서드나 `sorted()` 함수를 사용하는 것입니다   \n\n1   `sort()` 메서드를 사용하는 방법:\n```python\nmy_list = [3, 1, 4, 1, 5, 9, 2, 6, 5]\nmy_list  sort()\nprint(my_list)  # [1, 1, 2, 3, 4, 5, 5, 6, 9]\n```\n\n2   `sorted()` 함수를 사용하는 방법:\n```python\nmy_list = [3, 1, 4, 1, 5, 9, 2, 6, 5]\nsorted_list = sorted(my_list)\nprint(sorted_list)  # [1, 1, 2, 3, 4, 5, 5, 6, 9]\n```\n\n`sort()` 메서드는 원본 리스트를 직접 변경하고, `sorted()` 함수는 정렬된 새로운 리스트를 반환합니다   필요에 따라 적절히 선택하여 사용하시면 됩니다  ",
        "refusal=None",
        "role=""assistant",
        "annotations="[],
        "audio=None",
        "function_call=None",
        "tool_calls=None
      )
    )"
  ],
  created=1744138577,
  "model=""gpt-3  5-turbo-0125",
  "object=""chat  completion",
  "service_tier=""default",
  "system_fingerprint=None",
  usage=CompletionUsage(
    completion_tokens=273,
    prompt_tokens=50,
    total_tokens=323,
    completion_tokens_details=CompletionTokensDetails(
      accepted_prediction_tokens=0,
      audio_tokens=0,
      reasoning_tokens=0,
      rejected_prediction_tokens=0
    ),
    prompt_tokens_details=PromptTokensDetails(
      audio_tokens=0,
      cached_tokens=0
    )
  )
)

주의사항

  • API 키 보안: API 키는 개인 정보이므로, 코드에 직접 포함시키기보다는 위에서 소개한 방법처럼 환경 변수를 통해 관리하는 것이 좋습니다.​
  • 모델 선택: 사용하려는 모델(gpt-4o, gpt-3.5-turbo 등)이 계정에서 접근 가능한지 확인하세요.​
  • 에러 핸들링: API 호출 시 발생할 수 있는 예외를 처리하여 프로그램이 안정적으로 동작하도록 구현하는 것이 좋습니다.

오늘 강좌는 여기까지 입니다. 최신 버전의 OpenAI Python 라이브러리를 활용하여 다양한 API를 호출할 수 있는데, 더 자세한 내용은 OpenAI 공식 문서를 참고하시기 바랍니다. 시청해 주셔서 감사합니다.

Google Cloud Translation API

Overview

이번시간에는 Google API를 이용하여 파이썬 스크립트에서 영>한 번역서비스를 이용해보도록 하겠습니다. ​구글 번역 API는 일정 부분 무료로 사용할 수 있지만, 사용량에 따라 요금이 부과됩니다. Google Cloud는 새로운 고객에게 90일 동안 $300의 무료 크레딧을 제공하며, 이를 통해 Cloud Translation API를 포함한 다양한 서비스를 체험할 수 있습니다. ​

또한, 모든 고객은 매월 500,000자까지의 텍스트 번역을 무료로 이용할 수 있습니다. 이 무료 사용량을 초과하면 추가 요금이 발생하며, 자세한 요금 정보는 Cloud Translation 가격 책정 페이지에서 확인할 수 있습니다.​

따라서, 소규모 프로젝트나 제한된 번역 작업의 경우 무료 사용 한도 내에서 비용 없이 구글 번역 API를 활용할 수 있습니다. 그러나 대규모 번역이 필요한 경우에는 추가 비용이 발생할 수 있으므로, 사용 계획에 따라 요금제를 검토하는 것이 좋습니다.

새로운 프로젝트 생성

가장 먼저 https://console.cloud.google.com에 가셔서 Project를 하나 만드세요. 새로운 프로젝트를 만드는 방법은 이전 강좌에서 소개했으므로 프로젝트가 생성되어 있다는 전제하에 강의를 진행하겠습니다.

Cloud Translation API 활성화

새로운 프로젝트를 생성하셨으면 화면 상단에서 생성한 프로젝트를 선택해주세요.

그리고 아래 Quick access에서 APIs & Services를 클릭한 뒤에 좌측메뉴에서 Library를 선택합니다. 그러면 아래와 같은 화면이 뜰거에요. 검색창에 Cloud Translation API를 검색해 주세요.

검색결과에서 Cloud Translation API를 선택해주세요.

Enable버튼을 눌러서 해당 앱을 활성화를 시켜줍니다.

그러면 결제정보를 요구할거에요. Enable billing버튼을 눌러서 결제정보입력을 진행해주세요.

만약 선택할 결제정보를 기존에 가지고 있지 않으면 새로운 결제정보를 입력하셔야합니다. Manage billing accounts를 클릭해주세요.

결제관리화면이 나오면 Create account를 클릭해서 결제계정을 생성해주세요.

결제계좌의 이름을 입력하고, 결제 국가 및 화폐를 선택합니다.

결제계좌에 연결할 카드정보를 선택한 뒤 Submit and enable billing을 클릭해서 결제계정 생성절차를 완료합니다.

그러면 아래와 같이 해당 결제계좌에 관한 정보를 한눈에 볼 수 있는 대시보드가 나타납니다.

다시 아까 Cloud Translation API로 가서 Enable버튼을 누르면 이번에는 어떤 결제계좌를 이용할지를 물어봅니다. 방금 만든 My Billing Account 1을 선택해주세요.

팝업을 닫고 Enable버튼을 다시 누르면 아래와 같이 활성화된 결과를 보여줍니다.

API Key생성

이제 API를 사용하기 위해 필요한 API Key를 생성할 차례입니다. 좌측메뉴에서 Credentials를 클릭하면 아래와 같이 API Key들을 관리하는 화면이 뜹니다.

상단에 + Create credentials링크를 누르면 새창이 뜨면서 API Key를 생성해서 보여줍니다.

주의사항을 보면 API를 도용당할 수 있기때문에 Edit API Key를 눌러서 제한설정을 하라네요. 해당링크를 누르면 다음과 같이 사용제한을 설정할 수 있게 해주는데요.

애플리케이션 제한은 웹서비스나 앱이 없으므로 그냥 넘어갈게요. 그리고 API 제한은 Cloud Translation API로 범위를 좁힐게요. Restrict key옵션을 선택하신뒤에 목록에서 Cloud Translation API를 선택해주세요.

Save버튼을 누르면 다시 API Key관리화면으로 보내줍니다.

Cloud Translation API사용하기

이제 만들어진 API키를 가지고 번역서비스를 이용해볼까요? Google번역 API의 v2를 이용해서 간단한 인사를 한글로 번역해 가지고 오도록 하겠습니다. API출력하는 JSON결과를 예쁘게 보여주기 위해서 json패키지를 사용할게요.

import json
import requests

API_KEY = "여기에 API Key를 넣으세요"
text = "Hello, how are you?"
target = "ko"  # Translate to Korean

url = f"https://translation.googleapis.com/language/translate/v2"

params = {
    'q': text,
    'target': target,
    'format': 'text',
    'key': API_KEY,
}

response = requests.post(url, data=params)
result = response.json()

print(json.dumps(result, indent=2, ensure_ascii=False))

이 파일을 실행하면 다음과 같이 번역결과가 JSON에 저장됩니다.

{
  "data": {
    "translations": [
      {
        "translatedText": "안녕하세요. 어떻게 지내세요?",
        "detectedSourceLanguage": "en"
      }
    ]
  }
}

테스트가 끝나고 해당 API를 더이상 사용할 일이 없으시다면 API Key는 삭제해주세요. API Key관리자화면에서 우측에 Actions 점세개 누르면 Delete API Key를 하실수 있습니다.

그리고 불필요하게 Enabled되어있던 다른 API들도 전부 비활성화 시켜줍니다.

오늘 강의는 여기까지입니다. 도움이 되셨길 바래요. 앱을 만들다 보면 돈을 내고라도 번역서비스를 써야할 때가 있으니까요. 참! 앱이나 웹서비스가 있다면 API Key로 하지 마시고, OAuth 2.0 Client ID를 생성하셔서 인증을 하시기를 추천드립니다. API Key는 유출의 위험이 있고, 유출이 되면 도용되기가 너무 쉽기때문에 특정 앱에서 API를 사용하는 경우에는 반드시 OAuth로 인증받으시기를 강력히 추천드립니다. 그럼 좋은 저녁되세요!

References

뉴스서비스 WordPress Theme만들기

안녕하세요. 오늘은 뉴스서비스를 제공하는 사이트의 WordPress Theme을 만들어 볼건데요. Theme을 만드는게 처음이시라면 초간단 WordPress Theme만들기 강좌를 둘러보신 후 학습하시면 더 쉽게 따라오실 수 있으실거에요.

우선 /wp-content/themes폴더 안에 news라는 폴더를 만들어 주세요. 그리고 그 안에 아래와 같이 파일을 만들거에요.

/news/
  ├── style.css
  ├── index.php
  ├── functions.php
  ├── header.php
  ├── footer.php
  ├── sidebar.php        ← 카테고리 메뉴
  ├── single.php         ← 뉴스 상세
  ├── archive.php        ← 카테고리별 뉴스
  └── screenshot.png     ← 테마 미리보기 이미지

style.css를 생성해서 그 안에 기본 스타일을 넣을게요

/*
Theme Name: NewsTheme
Theme URI: https://yourdomain.com
Author: You
Description: A simple WordPress theme for news/blog style websites.
Version: 1.0
*/
body {
  font-family: sans-serif;
  margin: 0;
  padding: 0;
  background: #f5f5f5;
}
.container {
  width: 90%;
  max-width: 1200px;
  margin: 0 auto;
}
.post {
  background: white;
  padding: 20px;
  margin: 20px 0;
}

그 다음 header.php를 생성해서 HTML을 시작하는 부분을 선언해주세요. 이 파일에는 모든 페이지에 공통으로 들어가는 시작부분을 정의합니다. head태그 안에는 wp_head()만 넣으면 워드프레스가 알아서 필요한 코드를 넣어줍니다. 그리고 페이지 최상단에 사이트이름에 홈링크를 걸어서 언제든지 홈으로 돌아올수 있게 할게요.

<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
  <?php wp_head(); ?>
</head>
<body>
  <div class="container">
    <header>
      <h1><a href="<?php echo home_url(); ?>"><?php bloginfo('name'); ?></a></h1>
    </header>

그 다음으로 모든 페이지의 마지막에 들어갈 footer.php도 정의해볼게요. 워드프레스가 생성된 년도와 사이트이름을 카피라이트로 넣어줍니다.

    <footer>
      <p>&copy; <?php echo date('Y'); ?> <?php bloginfo('name'); ?></p>
    </footer>
  </div>
  <?php wp_footer(); ?>
</body>
</html>

그리고 카테고리목록을 보여줄 sidebar.php도 생성합니다. 여기서는 wp_list_categories함수를 호출해서 카테고리를 나열하고 카테고리를 클릭하면 카테고리별 뉴스 페이지로 이동하도록 합니다.

<sidebar>
    <div>
    <?php
        wp_list_categories(array(
           'orderby' => 'name',
           'title_li' => ''
        ));
    ?>
    </div>
</sidebar>

그리고 테마의 기본이 되는 첫페이지, index.php에서 위에 정의한 헤더와 푸터, 그리고 사이드바를 불러오도록할게요.

<?php get_header(); ?>
<?php get_sidebar(); ?>
<main>
  <?php if (have_posts()) : while (have_posts()) : the_post(); ?>
    <div class="post">
      <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
      <p><?php the_excerpt(); ?></p>
    </div>
  <?php endwhile; else : ?>
    <p>뉴스가 없습니다.</p>
  <?php endif; ?>
</main>
<?php get_footer(); ?>

이제 functions.php를 정의합니다. 아래 코드에서 after_setup_theme라는 액션은 테마를 불러온 후에 실행할 함수명을 지정하는 건데요. add_action('after_setup_theme', 'newstheme_setup');라고 하면 테마를 불러온 후에 newstheme_setup함수를 실행해달라고 하는건데요. 바로 위에 함수 newstheme_setup를 보시면 add_theme_support('title-tag');가 있는데요. 이건 게시글의 제목을 HTML의 Title태그에 넣으라는 뜻입니다.

<?php
function newstheme_setup() {
  add_theme_support('title-tag');
}
add_action('after_setup_theme', 'newstheme_setup');

function newstheme_scripts() {
  wp_enqueue_style('style', get_stylesheet_uri());
}
add_action('wp_enqueue_scripts', 'newstheme_scripts');

single.php는 뉴스제목을 클릭하고 들어와서 상세뉴스를 보는 페이지입니다.

<?php get_header(); ?>
<article class="post">
  <h2><?php the_title(); ?></h2>
  <p><?php the_date(); ?> | <?php the_author(); ?></p>
  <?php the_content(); ?>
</article>
<?php get_footer(); ?>

archive.php는 카테고리별 뉴스목록을 보여주는 페이지인데요. 사이드바에서 카테고리를 클릭하면 이 페이지를 열게 됩니다.

<?php get_header(); ?>
<h2><?php single_cat_title(); ?></h2>
<?php if (have_posts()) : while (have_posts()) : the_post(); ?>
  <div class="post">
    <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
    <p><?php the_excerpt(); ?></p>
  </div>
<?php endwhile; endif; ?>
<?php get_footer(); ?>

저장이 다 되었으면 뉴스와 카테고리를 몇가지 넣어보도록 하겠습니다. 그리고나서 사이트를 확인해보면 아래와 같이 나오는데요. 최상단에 홈으로 갈수 있는 링크와 그 밑에 사이드바에는 카테고리별 뉴스를 볼수 있게 카테고리목록이 나오고 그 밑으로 뉴스가 하나씩나옵니다.

카테고리를 클릭하면 archive.php에 선언한 대로 카테고리별 뉴스목록을 보여줍니다.

참고로 홈페이지를 캡쳐해서 테마폴더에 screenshot.png로 저장하면 테마 목록에서 썸네일로 사용됩니다.

오늘의 강좌를 활용해서 더 복잡하고 유연한 워드프레스 테마를 만들어보는건 여러분의 몫인것 같습니다. 많은 테마 디자인 기대하겠습니다. 시청해주셔서 감사합니다.

초간단 WordPress Theme만들기

안녕하세요. 오늘은 초간단 WordPress theme을 만들어볼게요. 이 강좌를 따라오시려면 우선적으로 WordPress가 설치되어있는 사이트를 가지고 계셔야합니다.

WordPress를 설치하신 뒤에 File Manager에 들어가보시면, 루트폴더에 wp-admin, wp-content, wp-includes, 세개의 폴더가 생성되어 있을거에요.

그중에 wp-content에 들어가면 themes라는 폴더가 있을거에요.

themes에 들어가서 새로운 폴더를 simplest라는 이름으로 하나 만들어 주세요.

그 안에 아래와 같이 파일을 3개 만들어주세요. index.php는 기본 템플릿 파일이고, style.css는 테마정보와 스타일을 정의할거구요. functions.php는 사실 없어도 되는데 있는게 좋아요. 여기다가 각종 테마의 기능들을 정의할거에요.

우선 style.css를 열어서 해당 테마에 대한 정보를 적어줄거에요.

/*
Theme Name: My First Theme
Theme URI: https://example.com
Author: Your Name
Author URI: https://your-site.com
Description: A simple custom WordPress theme.
Version: 1.0
*/

그리고 index.php를 열어서 화면을 구성합니다. 가장 기본적인 HTML구조를 작성하도록 하겠습니다.

<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
  <meta charset="<?php bloginfo('charset'); ?>">
  <title><?php bloginfo('name'); ?></title>
  <link rel="stylesheet" href="<?php bloginfo('stylesheet_url'); ?>">
</head>
<body>
  <h1><?php bloginfo('name'); ?></h1>
  <p><?php bloginfo('description'); ?></p>

  <?php
    if (have_posts()) :
      while (have_posts()) : the_post();
        the_title('<h2>', '</h2>');
        the_content();
      endwhile;
    else :
      echo '<p>No content found</p>';
    endif;
  ?>
</body>
</html>

마지막으로 funxtions.php를 열어서 아래와 같이 스타일을 등록합니다.

<?php
function mytheme_enqueue_styles() {
  wp_enqueue_style('main-style', get_stylesheet_uri());
}
add_action('wp_enqueue_scripts', 'mytheme_enqueue_styles');
?>

이제 워드프레스 관리자페이지를 열어보면 아래와 같이 My First Theme이 테마로 선택할 수 있게 뜹니다. Activate버튼을 눌러서 활성화를 시켜주세요.

이제 상단에 Visit site링크를 눌러서 결과를 확인해주세요. index.php에 명시한 대로 최상단에 사이트이름, 내사이트를 출력하고, 그 아래 가장 최근에 올라온 게시물을 한개 보여줍니다. Hello World!라는 게시물은 워드프레스를 설치하면 기본적으로 들어가 있는 게시글입니다.

일단 테마를 만드는 기본 개념을 알려드리기 위해서 사이트가 가져야하는 정상적인 기능들은 모두 배제하고 가장 간단하게 설명해드렸는데 다음 시간에는 좀더 사이트 다운 테마를 만들어 보도록 하겠습니다. 이번시간에는 어디에 테마파일을 저장하고 기본적으로 필요한 파일이 뭔지에 대해서 알아보았습니다. 시청해주셔서 감사합니다.

Audio Silence Trimming

안녕하세요. 이번시간에는 오디오파일에서 아무말도 안하고 있는 부분을 자동으로 지워주는 파이썬 스크립트를 만들어 볼까 합니다.

우선 필요한 라이브러리를 설치해주세요. pydub와 ffmpeg를 설치해주셔야합니다.

pip install pydub
brew install ffmpeg

pydub이 기존에 파이썬 기본패키지로 들어가 있던 audioop라이브러리를 사용하는데요, 혹시 파이썬 3.13버젼을 사용하신다면 새로운 버젼에서는 더이상 audioop을 기본적으로 제공하지 않기때문에 별도로 설치를 해주셔야합니다. audioop아니고요 audioop-lts를 설치해주셔야합니다.

pip install audioop-lts

코드는 다음과 같습니다. 음성파일을 같은 폴더에 저장해주세요.

from pydub import AudioSegment
from pydub.silence import split_on_silence

# Load the audio file
audio = AudioSegment.from_file("input.m4a")

# Split the audio by silence
chunks = split_on_silence(audio, min_silence_len=1000, silence_thresh=-40)

# Combine the chunks back together
clean_audio = AudioSegment.empty()
for chunk in chunks:
    clean_audio += chunk

# Export the cleaned audio file
clean_audio.export("output.mp3", format="mp3")

위의 코드는 input.m4a를 받아서 output.mp3로 중간에 아무런 말도 들리지 않는 경우에 트림을 하는 코드입니다. min_silence_len는 현재 1초로 설정되어 있는데 더 짧은 구간도 트림하고 싶으시면 700이나 그 이하로도 필요에따라 줄이셔도 됩니다. 그리고 silence_thresh는 -40인 경우에 삭제를 하도록 했습니다. output도 m4a로 하고 싶었는데 라이브러리에서 해당 포멧을 지원하지 않네요.

한번 실행해볼게요. 오디오나 비디오는 처리시간이 꽤 소요됩니다.

python audio-silence-trimming.py

결과를 보시면 output.mp3가 같은 폴더에 생성되어있고, 열어서 확인해보시면 공백이 모두 사라져있습니다.

감사합니다.

PDF to Images in Python

Overview

안녕하세요. 오늘은 PDF파일의 페이지들을 이미지로 변환하는 파이썬 스크립트를 만들어 보도록하겠습니다.

Install Package

우선 필요한 파이썬 패키지를 설치하도록 하겠습니다. 여기서 필요한 패키지는 pdf2image라는 패키지에요.

pip install pdf2image

그런데 pdf2image패키지는 poppler라는 라이브러리를 이용해서 돌아가는 패키지거든요. 운영체제에 poppler를 설치하셔야 pdf2image를 사용하실 수 있습니다. 저는 맥을 쓰고 있어서 brew를 통해서 설치합니다. 설치시간이 많이 걸리니까 한참 기다리셔야해요.

brew install poppler

참고로, poppler는 gcc라이브러리를 참조하므로 혹시 맥에 gcc가 이미 설치되어 있지 않다면 gcc를 먼저 설치하신 후에 poppler를 설치해주시기 바랍니다. 맥북에 XCode가 없으면 gcc설치가 안되니까 XCode를 우선적으로 설치하셔야합니다. XCode는 LaunchPad의 Apps에서 설치하실 수 있습니다.

brew install gcc

Windows운영체제를 사용하시는 분들은 최신 Poppler 패키지를 다운로드하고 bin\ 디렉토리를 PATH 환경 변수에 추가합니다.

Linux운영체제를 사용하시는 분들은 아래와 같이 apt-get라이브러리 매니저를 사용하여 라이브러리를 설치합니다.

sudo apt-get update
sudo apt-get install -y poppler-utils

하지만 최신버젼의 MacOS를 설치한 분들은 이런 잡다한 문제가 일어나지는 않을거에요.

poppler가 성공적으로 설치가 되었으면 pdf_to_images.py라는 이름으로 파일을 하나 만들어 주세요.

from pdf2image import convert_from_path

pdf_path = 'input.pdf'
output_folder = 'output'
images = convert_from_path(pdf_path)

for i, image in enumerate(images):
    image.save(f'{output_folder}/page_{i+1}.png', 'PNG')

그리고 이미지를 추출할 PDF파일을 만들어 input.pdf로 저장합니다. 그리고 output이라는 폴더를 만들어주세요. 그 뒤에 아래와 같이 스크립트를 실행합니다.

python pdf_to_images.py

실행이 완료되면 PDF파일 안의 모든 페이지들이 이미지로 변환되어 output폴더에 저장이됩니다.

Initializing MacBook Pro

Install Basic Apps

가장 먼저 필수앱들을 설치해줍니다. iMovie는 유투브 편집용으로 쓰고 있기 때문에 저는 필요한데 필요없으신 분들은 설치하지 않으셔도 됩니다.

  • Google Chrome
  • Sublime Text
  • iTerm2
  • Visual Studio Code
  • iMovie

subl

홈에 bin폴더 만들어서 subl의 심볼릭링크를 저장해줍니다.

cd ~
mkdir bin
cd bin
ln -s /Applications/Sublime\ Text.app/Contents/SharedSupport/bin/subl

그리고 .bash_profile을 생성해서 해당 경로를 인식할 수 있게 해주세요

export PATH=$PATH:~/bin

그러면 앞으로 subl라는 명령어로 Sublime Text를 열수 있습니다.

iTerm2

Settings > Appearance > General에 들어가서 Theme을 Dark으로 변경합니다.

그리고 옆에 Profiles > Colors탭을 열어서 배경색, 글자색등을 바꿔줍니다.

그리고 .bash_profile에서 프롬프트도 예쁘게 바꿔줍니다.

DEFAULT='\033[0m'
BLACK='\033[0;30m'
DARKGRAY='\033[1;30m'
RED='\033[0;31m'
LIGHTRED='\033[1;31m'
GREEN='\033[0;32m'
LIGHTGREEN='\033[1;32m'
BROWNORANGE='\033[0;33m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
LIGHTBLUE='\033[1;34m'
PURPLE='\033[0;35m'
LIGHTPURPLE='\033[1;35m'
CYAN='\033[0;36m'
LIGHTCYAN='\033[1;36m'
LIGHTGRAY='\033[0;37m'
WHITE='\033[1;37m'

export PS1="${BLUE}\D{%H:%M} ${GREEN}\w ${DEFAULT}\$ "

Basic Alias

.bash_profile를 열어서 자주 쓰는 명령어를 alias해줍니다.

alias l="ls -al"
alias h="history"
alias vialias="subl ~/.bash_profile"
alias sourcealias="source ~/.bash_profile"

현재 사용하고 있는 MacOS가 15.3.1 (24D70)인데 원래대로라면 .zshrc을 사용하는 것으로 알고 있습니다. 하지만 제가 지금 세팅하고 있는 랩탑은 2019년 생산제품이고 버젼 10.14가 디폴트로 설치되는 Macbook입니다. 파티션의 내용을 지우고 기본으로 제공되는 10버젼을 새로 설치한뒤 파티션 데이타 포멧에 오류가 있어서 바로 15로 업그레이드를 하지 못하고 11로 업그레이드한 후에 15를 설치한 경우라서 아마도 bash을 이용하는 것 같습니다.

References