맥북 서버 탈출기

[맥북 서버 탈출기 6편] 운영 중인 앱, 3분 만에 서버 옮기기: DB 덤프부터 행 개수 대조까지

TerryDev 2026. 10. 15. 20:04

이 글 요약

  • 앱을 멈추는 시간은 PowerUp 약 4분, LoveToday 약 3분. 할 수 있는 준비는 전부 멈추기 전에 끝냈다
  • 순서: 서버 준비와 리허설 → 맥 앱 중지 → DB 덤프·복원 → 테이블별 행 개수 대조 → 서버 앱 시작 → DNS 전환
  • 검증 스크립트가 "0개 테이블 일치"를 성공이라고 출력한 사고, DB 시간대, 사진 403까지 정리

5편에서 터널을 서버에 연결했다. 길은 깔렸다. 이제 짐을 옮길 차례다.
옮길 짐은 두 앱의 데이터다. 운동 기록, 회원 정보, 교환일기, 사진. 이 데이터는 지금도 사용자가 쓰고 있다. 옮기는 동안 앱을 잠깐 멈춰야 하는데, 그 시간을 몇 분으로 줄이는 게 이번 편의 목표다.


원칙: 멈추기 전에 할 수 있는 건 전부 미리 한다

이사로 치면, 짐을 다 싸 두고 새 집 청소와 가구 배치까지 끝낸 뒤에 마지막으로 냉장고만 옮기는 방식이다. 냉장고를 옮기는 동안만 불편하다.

준비서버에 Java, MySQL 설치없음
준비앱 파일, 설정, 서비스 등록 후 빈 DB로 리허설없음
준비터널 연결, 경로 추가 (5편)없음
준비사진 폴더 1차 복사없음
전환맥 앱 중지 → DB 덤프 → 서버에 복원 → 행 개수 대조 → 서버 앱 시작여기부터
전환DNS를 새 터널로 변경여기서 복구
정리며칠 지켜본 뒤 맥 쪽 정리없음

중단 구간에 들어가는 작업은 "데이터 옮기기"와 "주소 바꾸기" 둘뿐이다. 나머지는 미리 해 두고, 미리 시험해 둔다.


준비 1. 서버에 Java와 MySQL 설치

Java

두 앱 모두 Spring Boot(Java 21)다. 서버에는 실행만 하면 되니 JRE(실행 환경)만 설치한다.

sudo apt-get update
sudo apt-get install -y openjdk-21-jre-headless
sudo timedatectl set-timezone Asia/Seoul   # 서버 시간을 한국 시간으로
  • headless: 화면(GUI) 관련 부품이 빠진 서버용 버전이다. 용량이 작다.
  • 시간대를 맞춰 두면 로그 시간을 읽을 때 9시간씩 빼고 더할 필요가 없다.

처음 만난 함정: 새 서버에서 바로 apt-get install을 하면 Could not get lock /var/lib/dpkg/lock-frontend 오류가 날 수 있다. 서버가 켜지자마자 자동 보안 업데이트가 돌면서 설치 도구를 잡고 있어서다. 고장이 아니다. 몇 분 기다리면 풀린다. 옵션으로 기다리게 할 수도 있다.

sudo apt-get install -y -o DPkg::Lock::Timeout=300 docker.io

MySQL은 Docker로

맥에서 쓰던 MySQL은 8.4였다. 서버도 같은 8.4로 맞추고 싶었다. 버전이 다르면 덤프를 복원할 때 문법이나 기본 설정 차이로 문제가 생길 수 있다. Ubuntu 기본 저장소의 MySQL 버전은 내가 고를 수 없어서 Docker를 썼다. Docker는 프로그램을 버전째 포장된 상자(컨테이너)로 받아서 실행하는 도구다.

sudo docker run -d --name powerup-mysql --restart unless-stopped \
  --env-file /etc/powerup/mysql.env \
  -p 127.0.0.1:3306:3306 \
  -v powerup-mysql:/var/lib/mysql \
  mysql:8.4 --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci

한 줄씩 보면 이렇다.

옵션 뜻 이유
--restart unless-stopped 서버가 재부팅되거나 MySQL이 죽으면 자동으로 다시 켠다 맥북 시절의 "재부팅하면 끝"을 반복하지 않으려고
--env-file root 비밀번호 등을 파일에서 읽는다 비밀번호를 명령어에 직접 쓰면 기록(history)에 남는다
-p 127.0.0.1:3306:3306 서버 안에서만 접속 가능 127.0.0.1을 빼면 모든 주소에서 열린다. DB는 밖에 열 이유가 없다
-v powerup-mysql:/var/lib/mysql 데이터를 컨테이너 밖 볼륨에 저장 컨테이너를 지우고 다시 만들어도 데이터는 남는다
utf8mb4, utf8mb4_0900_ai_ci 문자 인코딩과 정렬 규칙 맥 MySQL과 똑같이 맞췄다. 한글과 이모지가 깨지지 않는다

DB 비밀번호는 맥에서 쓰던 걸 가져오지 않고 서버에서 새로 만들었다(openssl rand -hex 24). 맥 MySQL은 root 비밀번호가 비어 있었다. 집 안에서만 쓰던 습관을 서버로 들고 갈 필요는 없다.


준비 2. 앱 올리고 빈 DB로 리허설

운영 중인 jar 파일을 그대로 옮긴다

맥에서 실제로 돌고 있던 jar 파일(앱을 한 파일로 묶은 것)을 그대로 서버에 올렸다. 새로 빌드하지 않았다.
이유가 있다. 확인해 보니 LoveToday 저장소에는 9월에 커밋만 하고 배포하지 않은 변경이 하나 있었다. DB에 컬럼을 추가하는 변경이었다. 새로 빌드해서 올리면 이사와 기능 배포가 한꺼번에 일어난다. 문제가 생기면 이사 때문인지 새 기능 때문인지 구분할 수 없다.
이사할 때는 짐만 옮긴다. 리모델링은 이사가 끝나고 한다.

비밀값은 환경 파일로

맥에서는 비밀값(JWT 키, 카카오 키, DB 비밀번호)이 launchd 설정 파일 안에 들어 있었다. 서버에서는 앱마다 환경 파일 하나로 모았다.

/etc/powerup/powerup.env      # 권한 600: root만 읽을 수 있다
  • 맥 설정에서 값을 읽어 SSH로 바로 서버 파일에 썼다. 화면이나 로그에 값이 찍히지 않게 했다.
  • 쓰지 않는 AI 서비스 키 두 개는 옮기지 않았다. 안 쓰는 비밀은 갖고 있을수록 위험만 늘어난다.

systemd 서비스로 등록

맥의 launchd 역할을 리눅스에서는 systemd가 한다.

# /etc/systemd/system/powerup.service
[Unit]
Description=PowerUp backend (Spring Boot)
After=network-online.target docker.service
Requires=docker.service

[Service]
User=powerup
WorkingDirectory=/opt/powerup
EnvironmentFile=/etc/powerup/powerup.env
ExecStart=/usr/bin/java -Xms256m -Xmx2g -jar /opt/powerup/app.jar
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
항목 뜻
After=... docker.service, Requires=docker.service MySQL(Docker)이 먼저 뜬 다음에 앱을 켠다
User=powerup 앱 전용 계정으로 실행한다. root로 돌리면 앱이 뚫렸을 때 서버 전체가 뚫린다
EnvironmentFile 위의 환경 파일을 읽는다
-Xmx2g 앱이 쓸 최대 메모리. 12GB 서버에 앱 두 개와 MySQL이 같이 있으니 넉넉하되 끝없이 쓰지는 않게
Restart=always 앱이 죽으면 10초 뒤 다시 켠다
sudo systemctl daemon-reload
sudo systemctl enable powerup   # 부팅할 때 자동 시작
sudo systemctl start powerup

리허설: 빈 DB로 한 번 띄워 본다

이 상태로 앱을 켜면 빈 DB에 테이블이 만들어지고 앱이 뜬다. 헬스체크가 200이면 설정은 맞다는 뜻이다.

curl http://127.0.0.1:8080/api/v1/health   # 200

환경 변수 이름 오타, DB 접속 정보 오류, 권한 문제는 전부 이 단계에서 드러난다. 실제 데이터를 옮기기 전에 잡아야 한다. 전환 중에 설정 오류를 찾으면 그 시간이 전부 앱 중단 시간이 된다.
리허설이 끝나면 앱을 다시 끈다. 빈 DB는 전환 때 지우고 진짜 데이터로 채운다.


준비 3. 사진 폴더 미리 복사 (LoveToday)

LoveToday에는 DB 말고 사진 폴더도 있었다. 360개, 60MB. 이걸 전환 중에 처음부터 복사하면 그만큼 중단이 길어진다.

rsync로 미리 한 번 복사해 두고, 전환 때는 바뀐 파일만 다시 보냈다.

rsync -a --delete -e "ssh -i ~/.ssh/oracle.key" --rsync-path="sudo rsync" \
  ~/couple-diary-uploads/  ubuntu@<서버>:/var/lib/lovetoday/uploads/
  • rsync: 양쪽을 비교해서 다른 부분만 보내는 복사 도구다. 두 번째부터는 빠르다.
  • -a: 권한, 시간 같은 파일 정보를 그대로 유지한다.
  • --delete: 원본에서 지운 파일은 서버에서도 지운다. 정확히 같은 상태로 맞춘다.
  • 경로 끝의 /: "폴더 안의 내용물"을 보낸다는 뜻이다. 빼면 폴더째 한 단계 안에 들어간다.

전환 때 다시 돌린 rsync는 6초 만에 끝났다.


전환: 실제로 앱을 멈추는 3~4분

여기서부터 앱이 멈춘다. 순서가 중요하다.

1) 맥 앱부터 멈춘다

launchctl unload ~/Library/LaunchAgents/com.powerup.backend.plist

DB를 덤프하기 전에 앱부터 멈춰야 한다. 앱이 켜져 있으면 덤프한 뒤에도 사용자가 새 기록을 남긴다. 그 기록은 서버로 옮겨지지 않고 맥에만 남았다가 사라진다. 쓰기를 멈추는 게 데이터 유실을 막는 유일한 방법이다.

2) 덤프

mysqldump -h127.0.0.1 -P3307 -uroot \
  --single-transaction --routines --triggers \
  --set-gtid-purged=OFF --no-tablespaces \
  powerup > ~/backups/powerup-20261015-005936.sql
옵션 뜻
--single-transaction 덤프하는 동안의 데이터를 한 시점으로 고정해서 읽는다. 테이블마다 시점이 달라지지 않는다
--routines --triggers 저장 프로시저와 트리거도 같이 담는다
--set-gtid-purged=OFF 복제(replication) 관련 정보를 빼서 다른 서버에 그대로 복원되게 한다
--no-tablespaces 특별한 권한이 필요한 정보를 빼서 권한 오류를 피한다

덤프 파일은 맥의 ~/backups/에 남겨 뒀다. 문제가 생겼을 때 돌아갈 원본이다.

3) 서버에 복원

gzip -c powerup.sql | ssh ubuntu@<서버> \
  'gunzip -c | sudo docker exec -i powerup-mysql mysql -uroot -p... powerup'

덤프를 압축해서 SSH로 흘려보내고, 서버에서 풀면서 바로 MySQL에 넣는다. 서버에 파일을 따로 저장하지 않으니 비밀 데이터가 디스크에 남지 않는다. 11MB짜리 덤프라 몇 초면 끝났다.

4) 테이블별 행 개수 대조

복원했다고 끝이 아니다. 맥과 서버의 모든 테이블에서 COUNT(*)를 세서 비교했다.

diff mac_counts srv_counts && echo "ROW COUNTS MATCH"
ROW COUNTS MATCH (19 tables, 11331 rows)

여기서 사고가 하나 있었다. 아래에 따로 적는다.

5) 서버 앱 시작, 헬스체크

sudo systemctl start powerup
curl http://127.0.0.1:8080/api/v1/health   # 200

6) DNS 전환

5편의 마지막 단계다. CNAME 대상을 새 터널 ID로 바꾸는 순간 사용자는 새 서버로 온다.

curl https://powerup.example.com/api/v1/health   # 첫 요청부터 200

맥 앱을 멈춘 시각부터 외부 헬스체크가 200을 받은 시각까지 약 4분이었다. LoveToday는 같은 순서로 약 3분 걸렸다.


사고: "0개 테이블 일치"

4단계의 행 개수 대조 스크립트는 처음에 이렇게 출력했다.

ROW COUNTS MATCH (0 tables, rows)

일치라고 나왔다. 테이블 0개끼리 비교했으니 당연히 일치한다. 맥 쪽에서 개수를 세는 명령이 셸 문법 문제로 실행조차 안 됐고, 빈 결과끼리 diff를 하니 차이가 없었던 것이다.

두 번째 시도도 SQL 문법 오류로 같은 결과가 나왔다. 세 번째에 테이블 목록을 먼저 뽑고 쿼리를 파일로 만들어 실행하는 방식으로 바꿨고, 그제야 19개 테이블과 11,331행이 나왔다.
"MATCH"라는 단어만 봤다면 검증했다고 착각하고 넘어갔을 것이다. 검증 결과를 볼 때는 무엇을 몇 개 비교했는지를 같이 봐야 한다. 0개를 비교한 검증은 검증이 아니다.


LoveToday에서 추가로 만난 것들

DB 시간대

LoveToday DB에는 날짜·시간 컬럼(DATETIME)이 18개 있었다. DATETIME은 시간대 정보 없이 "2026-10-15 08:28" 같은 숫자만 저장한다. 맥 MySQL은 한국 시간으로 돌고 있었고, 서버의 MySQL 컨테이너는 UTC(국제 표준시)다.

덤프와 복원은 숫자를 그대로 옮기니 기존 데이터는 그대로다. 문제는 앞으로 저장될 데이터다. DB 쪽 기본 시간대가 달라지면 새로 저장되는 시간이 9시간씩 어긋날 수 있다. 같은 테이블에 한국 시간과 UTC가 섞이면 나중에 바로잡기 어렵다.
그래서 앱이 DB에 연결할 때 한국 시간을 쓰도록 접속 주소에 옵션을 넣었다.

jdbc:mysql://127.0.0.1:3306/today?serverTimezone=Asia/Seoul&forceConnectionTimeZoneToSession=true
  • serverTimezone=Asia/Seoul: 앱이 DB의 시간을 한국 시간으로 해석한다.
  • forceConnectionTimeZoneToSession=true: 연결할 때마다 DB 세션의 시간대도 한국 시간으로 맞춘다. DB의 NOW() 같은 함수도 한국 시간을 쓰게 된다.

맥에서 쓰던 기준(한국 시간)을 서버에서도 그대로 유지한 것이다.

사진이 403

전환 직후 사진 하나를 주소로 열어 보니 403 Forbidden이 나왔다. 사진이 안 옮겨진 줄 알았다.

코드를 보니 원래 그렇게 만든 기능이었다. LoveToday의 사진은 서명된 링크로만 열린다. 앱이 사진 주소에 만료 시간과 서명값을 붙여서 내려주고, 서버는 서명이 맞을 때만 사진을 준다. 커플의 사진이 주소만으로 열리면 안 되니까. 서명 없이 열면 403이 맞다.
서명을 붙여 다시 요청하니 200이 나왔고, 내려온 파일의 크기도 맥의 원본과 같았다. 403은 고장이 아니라 보안이 잘 동작한다는 증거였다.

접속 로그 경로

맥에서는 앱이 접속 로그를 홈 폴더 아래에 쓰고 있었다. 서버에는 그 경로가 없다. 환경 변수로 로그 위치를 서버 경로(/var/log/lovetoday)로 바꿔 줬다. 맥 전용 경로가 설정에 남아 있지 않은지 옮기기 전에 확인해 두자.


되돌리기 계획

전환을 시작하기 전에 되돌리는 방법부터 정했다.

  1. DNS의 CNAME을 예전 맥 터널로 되돌린다.
  2. 맥에서 launchctl load로 앱을 다시 켠다.

맥의 DB는 덤프할 때 읽기만 했으니 그대로 남아 있다. 이 두 단계면 1분 안에 원래대로 돌아간다. 실제로 쓸 일은 없었다. 그래도 맥 쪽 앱과 터널은 며칠 동안 지우지 않고 남겨 뒀다.


에이전트와 한 부분

전환 작업은 Claude Code가 맥 터미널, 서버 SSH, Cloudflare 대시보드를 오가며 했다.

  • 멈추기 전에 순서를 표로 보여 주고 확인을 받았다. 어느 단계부터 앱이 멈추는지, 예상 시간은 얼마인지, 되돌리는 방법은 무엇인지를 먼저 설명했다. 나는 "진행해줘"라고 했다.
  • "0개 일치"를 성공으로 넘기지 않았다. 결과의 테이블 수가 0인 걸 보고 스크립트를 두 번 고쳐 다시 돌렸다. 19개 테이블이 나오고 나서야 다음 단계로 갔다.
  • 배포 안 된 커밋을 먼저 짚었다. 저장소의 최신 코드와 실제로 돌고 있는 파일이 다르다는 걸 확인하고, 이사에는 돌고 있던 파일을 쓰자고 했다. 그 커밋을 배포할지는 나한테 물었다. 나는 아직 답하지 않았다.
  • 403을 고장으로 단정하지 않았다. 코드를 읽어 서명 링크 기능을 찾고, 서명을 붙여 직접 요청해서 원본과 같은 파일이 오는 걸 확인했다.

비밀값을 옮길 때도 화면에 값을 찍지 않고, 키 이름과 길이만 출력해서 빠진 게 없는지 확인했다. 비밀번호가 비어 있던 맥 MySQL의 root 계정은 에이전트가 봐도 할 말이 없었을 것이다.


오늘의 교훈 검증 스크립트도 검증해야 한다. 0개끼리는 언제나 일치한다.

다음 편에서는 이사한 데이터를 지키는 방법을 다룬다. 서버가 통째로 사라져도 되살릴 수 있게, 매일 새벽 4시에 암호화된 백업을 오라클 밖의 Cloudflare R2로 보낸다. 백업은 복원해 보기 전까지는 백업이 아니라는 것도 그때 확인했다.