Bluehost Plans – WordPress에서 VPS로 갈아타기

기존에 WordPress Choice Plus Hosting사용하시던 분들 중에 서버작업이 자유롭게 안되서 답답하다고 느끼신 분들에게 VPS플랜을 소개해 드리고자 합니다. VPS는 직접 서버를 관리하고, root권한까지 가지게 되어 모든 작업이 가능하게 되지만, 리눅스서버에 익숙하지 않은 사용자들은 다소 어려움이 있을수 있으니, Self-managed VPS보다는 managed VPS를 추천드립니다. 하지만 이 강의에서는 가장 저렴한 NVMe 2를 기준으로 설명을 드리고 있으니 참고 바랍니다.

Bluehost의 Plan

아래의 화면에서 보시듯이 Self-Managed VPS플랜은 관리해주는 툴을 사용할때보다 매우 저렴합니다.

하지만 리눅스서버에 자신이 없으신 분들은 cPanel등이 제공되는 Managed VPS플랜을 선택하세요.

Standard VPS NVMe 2

본 강의에서는 가장 저렴한 Standard VPS NVMe 2를 2년동안 사용하는 걸로 구매를 해보도록하겠습니다. 블루호스트의 메뉴에서 For Developers > Self-Managed VPS Hosting을 선택해주세요.

그러면 위에서 보여드린 플랜선택화면이 나오고 여기에서 가장 저렴한 NVMe 2를 선택합니다. Choose Plan버튼 클릭.

그러면 아래와 같이 결제하는 화면이 뜹니다.

지역은 버지니아 주에 있는 데이터센터가 기본적으로 선택이 되어 있는데 그대로 둡니다.

하드웨어는 선택한 플랜에 따라 정해지는데 가장 저렴한 버젼은 1 vCPU, RAM은 2 GB DDR5, 그리고 Storage는 50GB입니다.

Self-Managed VPS를 구매하면 하나의 앱을 무료로 제공받는데 기본값으로는 우분투OS에 Claude Code가 제공되지만 저는 Docker컨테이너를 사용할 것이라서 Coolify를 선택했습니다.

만약 우분투가 아닌 다른 OS를 설치하고 싶다면 Plain OS탭을 선택한 뒤 설치하고 싶은 OS를 선택하시면 됩니다.

cPanel을 설치해서 편리하게 이용할 수도 있지만, 한달에 $15씩 더 내야하므로 이건 pass합니다.

기타 Support & Security나, Advanced options는 한달에 $150씩 내고 기술지원서비스를 받는다거나 IP를 추가해야할 필요가 있으때 선택하는 항목인데 둘다 유료이므로 일단 패스하도록 하겠습니다.

선택이 끝났다면 우측하단의 Continue to checkout버튼을 눌러서 결제를 마무리합니다.

이제 Bluehost의 Hosting서비스를 열어보시면 기존에 WordPress Choice Plus Hosting과 Standard VPS – NVMe2가 나란히 있는것을 확인할 수 있습니다. 가장 우선적으로 Reset Password버튼을 눌러서 Root Password를 변경해 주세요.

그리고 Hostname이 설정이 안되어 있다면, Run Server Setup을 눌러서 메인 도메인으로 사용할 도메인도 설정해주세요

그 다음 Launch Console버튼을 누르시면 서버에 터미널로 접속이 되는데요. Login:에는 root를 넣으시고, Password:에는 방금전에 root password로 설정한 비번을 넣으시면 서버에 접속이 됩니다.

사용자 추가

앞으로는 root로 SSH에 접속하지 마시고, 생성하신 사용자로 접속하시고 sudo를 통해 필요할 때만 root권한을 행사하시기를 추천드립니다. 그것이 리눅스 서버 운영의 가장 기본이자 핵심인 보안 원칙(Principle of Least Privilege)입니다.

-- 사용자 생성
# sudo adduser sol1000
info: Adding user `sol1000' ...
info: Selecting UID/GID from range 1000 to 59999 ...
info: Adding new group `sol1000' (1001) ...
info: Adding new user `sol1000' (1001) with group `sol1000 (1001)' ...
info: Creating home directory `/home/sol1000' ...
info: Copying files from `/etc/skel' ...
New password:
Retype new password:
passwd: password updated successfully
Changing the user information for sol1000
Enter the new value, or press ENTER for the default
	Full Name []: Firstname Lastname
	Room Number []:
	Work Phone []:
	Home Phone []:
	Other []:
Is the information correct? [Y/n] y
info: Adding new user `sol1000' to supplemental / extra groups `users' ...
info: Adding user `sol1000' to group `users' ...

-- sudo 권한 부여
# usermod -aG sudo sol1000

생성한 사용자로 SSH접속

아래 명령어를 입력한 뒤 위에서 설정한 비번을 입력해 SSH로 서버에 접속합니다.

ssh sol1000@내서버주소

Git Server 설정

Git Folder 생성

-- gitusers그룹 생성하여 방금 생성한 사용자를 등록한다
sudo groupadd gitusers
sudo usermod -aG gitusers sol1000
-- git폴더의 소유를 그룹으로 변경한다
sudo chgrp -R gitusers /srv/git
-- 그룹에게 7권한을 외부인에게는 쓰기 권한은 허용하지 않는다
sudo chmod -R 775 /srv/git
-- SGID 비트 설정 (git의 하위폴더도 부모폴더의 권한을 상속받아야 추후 오류가 없다)
sudo chmod g+s /srv/git

Git 저장소 생성

만약 생성하려는 프로젝트가 기존에 Git서버가 없다면 아래 명령어를 통해 새롭게 생성해주세요.

-- 프로젝트 폴더 생성
sudo mkdir -p /srv/git/blockpangpang.git
-- Bare 저장소로 초기화
sudo git init --bare /srv/git/blockpangpang.git

Git 저장소 복사

만약 생성하려는 프로젝트의 Git서버가 다른 서버에 이미 존재할 경우에는 폴더를 새로 생성하지 말고 기존 폴더를 복사해오세요.

sudo rsync -avz --progress 이전사용자@이전서버:/home3/이전사용자/git/blockpangpang.git /srv/git/

Git 폴더권한 변경

-- 생성한 폴더가 gitusers그룹의 소유인지 확인하고 아니라면 그룹소유권 변경
sudo chown -R root:gitusers /srv/git/blockpangpang.git
-- 폴더 설정도 775인지 확인하고 아니라면 폴더권한 변경
sudo chmod -R 775 /srv/git/blockpangpang.git

Git은 2022년경 보안 업데이트를 통해 “Git 저장소의 소유자가 현재 명령을 실행하는 사용자와 다르면, 설령 권한이 있더라도 실행하지 않겠다”는 아주 강력한 보안 정책(safe.directory)을 도입했습니다. 그룹 권한으로 푸는 방식이 아니라, Git에게 “내 그룹 권한을 믿고 작업을 허용하라”고 명시적으로 예외 처리를 해주어야 합니다. 현재 sol1000 계정으로 서버에 접속해서 다음 명령어를 입력하세요:

-- 이 저장소는 신뢰할 수 있는 소유자가 관리하고 있으니 예외로 둠
git config --global --add safe.directory /srv/git/blockpangpang.git

로컬 환경에서 원격 저장소 경로 수정

-- 마스터브랜치로 이동
git checkout main

-- 기존 서버 주소 삭제
git remote remove origin

-- 지금 서버 주소로 재등록
git remote add origin sol1000@현재서버:/srv/git/blockpangpang.git

Git서버 이전이 완료되었습니다. 몇가지 코드를 변경해서 서버로 정상적으로 push가 되는지 확인해봅니다. 자동인증설정

git push --set-upstream origin main

Coolify 설정

위의 콘솔화면에서 Management UI: 에 뜨는 주소를 복사에서 브라우저에 붙여넣기 하세요. 그러면 아래와 같이 Root User를 설정하는 화면이 뜨는데 여기서 사용자 이름과, 이메일, 그리고 비번을 넣고 사용자를 생성해주세요. 앞으로 Coolify에 접속할때 해당 이메일과 비번으로 로그인해서 Docker container들을 관리하게 되실겁니다.

계정을 생성하고 나면, 배포할 앱과 데이타베이스들을 어디에 저장할지 물어보는 화면이 나오는데요. 보통은 클라우드나 다른 서버에 별도로 관리하지만 우리는 서버가 이거 하나니까 여기에서 앱이랑 DB도 죄다 관리하도록 “This Machine”을 선택합니다.

다음 단계는 첫번째 프로젝트를 생성하는 일입니다. 여기서 프로젝트란 말그대로 어떤 특정 서비스의 그룹을 말합니다. 이 프로젝트는 앱이나 웹, 또는 데이타베이스등 여러개의 컨테이너를 가질수 있습니다. Create “My First Project”를 눌러서 프로젝트를 생성합니다. 프로젝트명은 나중에 변경할 수 있습니다.

이제 배포를 해보도록 하겠습니다. Deploy Your First Resource를 클릭해주세요.

그러면 상단에 New Resource라는 타이틀이 뜨고, 아래에 각종 애플리케이션들이 나옵니다.

프로젝트에 Resource 추가하기

Git 저장소 추가하기

위의 Resource중에 “Private Repository (with Deploy Key)”를 선택합니다.
아래와 같은 화면이 뜨면 “Create a new private key”버튼을 클릭합니다.

그러면 Private Key목록을 보여줍니다. 아래 localhost’s key라는게 있지만 우리는 따로 만들도록 합니다.
옆에 +Add버튼을 클릭하세요.

Generate new ED25519 SSH Key버튼을 클릭하여 Private Key를 생성해줍니다.
이 방식이 RSA방식보다 훨씬 최신 기술이며, 암호화 수준도 훨씬 강력합니다.

continue버튼을 클릭하면 아래와 같이 키가 생성되고 여기에서 이름과 설명을 수정할 수도 있습니다.

서버에는 Public Key (잠금장치)

localhost’s key에서 일단 Public key를 가져다가 서버의 .ssh폴더의 authorized_keys에 복사해줍니다. 주의! 키를 입력한뒤 엔터를 쳐서 줄바꿈을 해줘야합니다.

vi ~/.ssh/authorized_keys

로컬에는 Private Key (열쇠)

그리고 Private key를 가져다가 랩탑의 .ssh폴더에 vps_key파일에 복사해줍니다.

vi ~/.ssh/bluehost/vps_key

다시 돌아오면 키를 추가하라는 버튼 대신 방금 생성한 키가 보일거에요. 그걸 선택해주세요.

그러면 우리가 이전한 새로운 Git저장소의 정보를 넣는 부분이 나옵니다. Repository URL에 “사용자@서버호스트:Git폴더경로”를 입력하고, Branch는 main으로 그리고 Build Pack는 Static으로 선택한 뒤 Continue버튼을 클릭합니다. 필요에 따라 Monorepo를 지향하는 경우 해당 컨테이너에 영향을 주는 폴더만 Base Directory에 넣을 수 있습니다.

컨테이너의 이름을 알맞게 변경해주고 Domain에 실제 서비스할 도메인을 추가한 뒤 Save버튼을 누릅니다. (도메인을 이전하는 부분은 추후에 따로 다루도록 하겠습니다.)

그리고 우측 상단의 Deploy버튼을 클릭합니다.

이제 설정된 임시도메인으로 브라우저를 띄우면 http://llkz3vhg9uffd6keuu4ahd4e.50.6.6.213.sslip.io 아래와 같이 웹사이트가 뜹니다.

도메인 이전

이제 기존 WordPress Plan에 묶여 있는 도메인만 가져오면 되는데요. 가장 우선적으로 현재 Coolify가 돌아가고 있는 VPS의 Public IP를 정확히 알아야합니다.

# curl ifconfig.me
50.6.6.***

이제 도메인의 A레코드를 수정해야하는데요. 일단 Bluehost에 로그인하셔서 좌측메뉴에 Domains를 클릭하면 소유한 도메인목록이 뜹니다. 그중에 수정하고자 하는 도메인을 클릭하고 들어가면 다음과 같은 화면이 나옵니다. 아랫쪽에 보명 현재 WordPress서비스에 연결된 것이 확인됩니다.

밑으로 스크롤 내려보시면 DNS Record들이 보이실거에요. 그중에 Type이 A인 것들이 Point To가 50.87.169.177로 설정되어 있는게 보이시죠? 그 항목들을 전부 위에서 받아온 IP로 변경해주세요.

TTL이 4시간으로 되어 있으므로, 변경 사항이 완전히 반영되기까지 최대 몇 시간이 걸릴 수 있습니다.

Cloudflare Email Routing

기존에는 cPanel에서 이메일까지 다 관리를 해주었지만 도메인이 이전이 되는 순간 cPanel로 관리되던 모든 기능들은 더이상 사용할 수 없게됩니다. 따라서 기존에 해당 도메인으로 사용하던 이메일이 있다면 새로운 메일서버를 설치해서 관리해야 하지만 메일서버를 관리하는 일은 쉽지 않습니다. 그래서 Cloudflare를 이용해서 이메일을 내 개인메일로 포워딩하도록 설정하겠습니다.

우선 Cloudflare.com에 들어가셔서 회원가입을 해주세요. 이메일과 비번만 입력하면 별도의 확인절차 없이 바로 회원가입이 됩니다. 회원가입을 하면 이런 저런 질문들을 하는 화면이 나오는데 우측하단의 Skip버튼을 누르면 바로 대시보드로 이동합니다. 좌측메뉴에서 Domains를 클릭한뒤 Add domain을 눌러서 도메인을 추가해줍니다.

그러면 도메인을 연결만 할지, 이전할지, 새로 구매를 할지를 물어봅니다. 여기서 Connect a domain을 클릭합니다.

연결할 도메인을 입력해주세요.

Continue버튼을 클릭하면 유료버젼을 쓸지 물어보는데 Free버젼을 선택합니다.

그러면 아래와 같이 Record들을 보여주는데 이메일 포워딩을 하려면 기존의 MX 레코드와 이메일 관련 CNAME 레코드들을 모두 지워야 합니다. (기존 호스팅 업체의 메일 서버와 충돌이 발생하기 때문입니다.) MX와 TXT타입의 왼쪽 선택박스에 체크를 하면 상단에 Delete 5 Records버튼이 생깁니다. 클릭해서 삭제해주세요. 그리고 CNAME에서 imap, pop, smtp, webmail 전부 삭제하고, cpanel도 VPS에서는 더이상 사용하지 않으니까 cpanel도 삭제합니다. 그리고 A타입에 mail이 있는데 우리는 이메일을 포워딩할거기 때문에 mail.blockpangpang.com은 더이상 사용하지 않을것 이므로 그것도 삭제를 해주세요. 그 밖에 다른 A타입 레코드들은 그대로 두시면 웹사이트 연결에 문제없습니다. Continue to activation버튼을 눌러서 활성화 시켜주세요.

IP주소에 ***은 사실 숫자인데 보안상 가린거에요. 서버 IP입력하시면 됩니다.

전부 삭제하고나면 다음과 같이 심플한 DNS레코드만 남습니다.

Continue to activation버튼을 누르면 도메인 관리회사에 들어가서 네임서버를 Cloudflare로 바꾸라고 합니다. 저는 도메인은 Whois.com에서 관리하는데 로그인해서 blockpangpang.com의 네임서버를 아래와 같이 바꿔줍니다.

현재 네임서버: ns1.bluehost.com, ns2.bluehost.com
Cloudflare: oaklyn.ns.cloudflare.com, peter.ns.cloudflare.com

Email Routing

이제 이메일 포워딩을 설정하겠습니다.

Cloudflare의 대시보드 좌측메뉴에서 Email > Email Routing을 클릭한뒤, 우측 상단에 Destination Addresses버튼을 클립합니다.

그러면 포워딩할 이메일들의 목록이 뜨는데 현재는 아무것도 없고, 하단 텍스트상자에 포워딩 받을 이메일을 추가합니다.

이메일이 등록되면 화면에 들어갑니다.

다시 Email Routing화면으로 돌아와서 우측 상단에 + Onboard Domain버튼을 눌러서 포워딩 신청을 합니다. Zone에서 이전하려는 도메인을 선택하면 추가할 DNS 레코드를 보여줍니다. Done버튼을 클릭합니다.

이메일 포워딩 신청이 완료되었습니다.

위의 목록에서 해당 도메인을 클릭하고 들어가면 다음과 같이 상세페이지가 뜹니다.

이제 여기에서 info@blockpangpang.com을 등록할거에요. 상단에 Routing rules라는 탭을 누르면 아래와 같이 각종 규칙들을 만들수가 있는데 여기에서 + Create routing rule버튼을 클릭합니다.

그리고 규칙을 만듭니다. info@blockpangpang.com으로 이메일이 오면 아래 지정한 이메일로 메일을 보내라고 설정한 뒤 Save합니다.

이제 모든 설정이 마무리 되었습니다. 도메인이 Cloudflare로 이전되는 시간이 이틀정도 걸리니 이틀뒤에 다시 확인해서 업데이트 하도록 하겠습니다. 수고하셨습니다.

Git Push할때 자동 인증을 위한 Public/Private key설정

git push할때마다 서버에 접근하는데요. 그때마다 서버는 인증을 요구합니다. 하지만 그때마다 비번을 입력하려면 곤혹스럽겠죠.

# git push
사용자@서버호스트's password:
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 12 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (8/8), 1.21 MiB | 68.73 MiB/s, done.
Total 8 (delta 0), reused 4 (delta 0), pack-reused 0 (from 0)
To 서버명:/srv/git/blockpangpang.git
   8bb10b53..daf3d064  main -> main

그래서 서버에서 Public/Private key를 생성하고 Public key는 서버에, 그리고 Private key는 로컬에 저장하여. 마치 자물쇠를 열쇠로 열듯이 자동으로 인증을 해주는 방법이 있습니다.

우선 서버에 접근에 이용할 사용자계정으로 SSH로 접속하여 ~/.ssh/authorized_keys에 Public key를 저장합니다. 주의 하실점은 키를 저장한 뒤에 엔터를 쳐서 줄바꿈을 반드시 해주셔야합니다.

그리고 로컬에서는 Private key를 ~/.ssh폴더에 저장합니다. 저는 다른 서버도 많아서 ~/.ssh/bluehost/vps_key라고 저장했습니다. 그리고 ~/.ssh/config에 서버 호스트와 사용자명, 그리고 Private key의 위치를 지정합니다.

Host my-vps
  HostName 서버호스트
  User 사용자
  IdentityFile ~/.ssh/bluehost/vps_key
  IdentitiesOnly yes

이제 Git에게 원격 저장소를 알려주어야합니다. 일단 현재 원격 저장소의 주소를 확인하는 명령어는 아래와 같습니다.

git remote -v

이제 아래 명령으로 주소를 변경합니다.

git remote set-url origin 사용자@my-vps:/srv/git/blockpangpang.git

그러면 앞으로 git push를 할때마다 비번을 입력하지 않으셔도 됩니다.

# git push
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 12 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 342 bytes | 342.00 KiB/s, done.
Total 4 (delta 3), reused 0 (delta 0), pack-reused 0 (from 0)
To my-vps:/srv/git/blockpangpang.git
   daf3d064..130d23b7  main -> main
Archived: Git

Git Server Setting

일단 Git서버를 세팅하려면 SSH로 접속할수 있는 서버가 필요합니다.

ssh 아이디@도메인.com -p 2222 -i /Users/사용자/.ssh/bluehost/id_rsa

서버에 접속한 뒤, Git서버로 사용할 폴더에 초기화를 해주세요. 이게 Git 서버가 됩니다.

cd /home3/아이디/git
git init --bare project.git

이제 로컬에서는 Clone해서 작업을 하도록합니다.

git clone ssh://아이디@도메인.com:2222/home3/아이디/git/project.git
cd project

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!라는 게시물은 워드프레스를 설치하면 기본적으로 들어가 있는 게시글입니다.

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