최근 회사에서 진행한 프로젝트에서 이기종 DB 간 데이터 이관 배치 프로그램을 개발했다. MariaDB에서 PostgreSQL로 매일 데이터를 옮기고, API를 호출하고, 통계 테이블에 집계하는 시스템이다.
단순해 보이지만, 실제로 구현하면서 수많은 문제들을 마주했다. 이 글은 그 삽질의 기록이다. (이 글에서 나타나는 모든 코드, 네이밍 등은 사내 보안상 핵심 개념을 바탕으로 임의로 재작성한 것이다.)
1. 프로젝트 개요
1.1 해결해야 할 문제
- 매일 데이터를 원본 DB(MariaDB)에서 분석용 DB(PostgreSQL)로 이관
- 외부 API 호출 및 완료 대기
- 집계 데이터 생성
1.2 기술 스택
| 구분 | 기술 |
| 컨테이너 | Docker, Docker Compose |
| 스크립트 | Bash Shell, Python 3.11 |
| 데이터베이스 | MariaDB → PostgreSQL |
| 스케줄링 | Cron (컨테이너 내장) |
| 보안 | RSA-2048 OAEP 암호화 |
1.3 전체 배치 흐름
[매일 새벽] STEP0: 원본 DB 배치 완료 검증
- 선행 작업 완료 여부 확인
- 일정 시간까지 재시도
↓ 성공 시
[즉시 실행] STEP1: 원본 DB → 분석용 DB 데이터 이관
- 전일자 데이터 약 3만 건 적재
↓ 플래그 파일 생성
[아침 시간] STEP2: 외부 API 호출
- 일정 시간까지 재시도
↓ 성공 시
[즉시 실행] STEP3: 통계 테이블 집계
2. 설계 결정: 컨테이너를 어떻게 나눌까?
2.1 초기 설계: 3개 컨테이너 분리
처음에는 역할별로 컨테이너를 나누는 게 맞겠다고 생각했다. 마이크로서비스처럼.
batch-db-transfer # 데이터 이관 담당
batch-aggregator # 통계 집계 담당
batch-scheduler # 스케줄링만 담당
2.2 현실의 벽
구현을 시작하니 문제가 보였다.
- 컨테이너 간 의존성 관리가 복잡해짐 (STEP1이 끝나야 STEP2 실행)
- 단순한 순차 실행에 과도한 구조
- 각 컨테이너마다 같은 의존성 설치로 리소스 낭비
- 폐쇄망에서 컨테이너 간 통신 설정이 까다로움
2.3 최종 결정: 단일 컨테이너 + Cron
결국 모든 기능을 단일 컨테이너에 통합하고, Cron으로 스케줄링하는 방식을 선택했다. 오버엔지니어링의 유혹을 뿌리치고 단순한 구조를 선택한 것이 결과적으로 옳은 결정이었다.
마이크로서비스가 답이 아닌 경우도 많다. 문제의 복잡도에 맞는 솔루션을 선택하는 게 중요하다.
3. 삽질 기록 #1: Cron이 환경변수를 못 읽는다
문제 발생
배치 스크립트를 완성하고 테스트했다. 수동으로 실행하면 잘 된다. 그런데 Cron으로 실행하면 이런 에러가 났다.
Error: DB_HOST is not set
분명히 환경변수 파일을 source 했는데 이상했다.
1차 시도: source 추가 (실패)
Cron은 환경변수를 상속받지 않으니까 source를 명시적으로 해야겠다고 생각했다. Cron 명령어에 source를 추가했다.
* * * * * root bash -c 'source /app/env/config.env && /app/scripts/step0.sh'
여전히 안 됐다. 같은 에러.
2차 시도: set -a 패턴 (실패)
source만으로는 export가 안 되나 싶어서 set -a 패턴을 적용해 봤다.
set -a; source /app/env/config.env; set +a
여전히 안 됐다. 도대체 왜?
원인 분석: 진짜 문제는 다른 곳에 있었다
한참을 삽질하다가 깨달았다. 문제는 source가 아니라, source하는 파일의 내용에 있었다.
config.env 파일을 열어보니 암호화된 값이 그대로 저장되어 있었다.
# /app/env/config.env
DB_USER=edxGLis5vz4FvL2QFk1YuQE+v2pQcp6der/9es+2/vq...
DB_PASSWORD=kHcQ6MeJSXKqMFNWZXWcO2unmZFYw+Bu7FPUxR...
실행 흐름을 다시 생각해 봤다.
[타임라인 1: 컨테이너 시작 시]
start_cron.sh 실행 (PID 1)
↓
source config.env
↓
RSA 복호화 실행 → $DB_USER = "actual_user" (성공)
↓
exec cron -f (PID 1이 cron으로 교체됨)
[타임라인 2: 1분 후 Cron이 스크립트 실행]
cron 데몬이 새 프로세스 생성 (PID 123)
↓
source config.env
↓
파일 내용 그대로 읽음 (복호화 안 됨!)
↓
$DB_USER = "edxGLis5vz4FvL2Q..." (실패)
핵심을 깨달았다.
- start_cron.sh가 실행될 때는 RSA 복호화가 일어나서 실제 값이 환경변수에 들어간다
- 하지만 Cron이 나중에 실행하는 건 완전히 별개의 프로세스다
- 그 프로세스에서 env 파일을 다시 source하면 암호화된 원본값만 읽힌다
해결: 복호화된 값을 파일로 저장
해결책은 간단했다. start_cron.sh에서 복호화가 완료된 후, 그 값을 별도 파일에 저장해 두는 것이다.
# start_cron.sh에서 RSA 복호화 완료 후
# 복호화된 값을 새 파일에 저장 (변수 치환이 일어남!)
cat > /tmp/cron_env.sh << EOF
export DB_HOST="${DB_HOST}"
export DB_USER="${DB_USER}"
export DB_PASSWORD="${DB_PASSWORD}"
EOF
chmod 600 /tmp/cron_env.sh # 보안을 위해 권한 제한
생성된 /tmp/cron_env.sh 파일에는 이미 복호화된 실제 값이 들어간다.
export DB_HOST="xxx.xxx.xxx.xxx"
export DB_USER="actual_username"
export DB_PASSWORD="actual_password"
이제 Cron에서는 이 파일을 source한다.
* * * * * root /bin/bash -c 'source /tmp/cron_env.sh && /app/scripts/step0.sh'
Cron이 실행될 때 이미 복호화된 값만 있는 파일을 읽으므로, 추가 복호화 없이 바로 사용 가능했다.
문제 원인 비교
| 구분 | 기존 방식 | 수정된 방식 |
| source 대상 | config.env (암호화된 값) | /tmp/cron_env.sh (복호화된 값) |
| Cron 실행 결과 | DB 접속 실패 | 정상 동작 |
Cron은 완전히 격리된 프로세스에서 실행된다. 단순히 source한다고 해결되지 않는다. 실행 환경의 차이를 이해해야 한다.
4. 삽질 기록 #2: 3만 건 데이터가 COPY에서 깨진다
문제 발생
PostgreSQL COPY 명령 실행 시 "컬럼 수 불일치" 에러가 발생했다.
ERROR: missing data for column "title"
이상한 건, 어떤 날은 되고 어떤 날은 안 된다는 점이었다. 랜덤하게 실패하는 배치라니.
원인 추적: 데이터 속 특수문자
실패한 날의 데이터를 하나씩 확인해 봤다. 텍스트 필드에 이런 내용이 있었다.
[첫 번째 줄]: 내용
[두 번째 줄]: 내용
[세 번째 줄]: 내용
데이터 안에 줄바꿈(\n)이 있었다. TSV 파일에서 줄바꿈은 레코드 구분자다. 데이터의 줄바꿈이 레코드 구분자로 인식되면서 데이터가 깨진 것이다.
탭(\t) 문자도 마찬가지였다. 어떤 데이터에는 탭 문자가 들어있었고, 이게 TSV 컬럼 구분자와 충돌했다.
1차 시도: sed로 치환 (실패)
처음에는 단순하게 생각했다. sed로 줄바꿈을 치환하면 되지 않을까?
sed 's/\n/\\n/g' data.tsv
문제는, TSV 파일에서 "데이터 안의 줄바꿈"과 "레코드 구분자 줄바꿈"을 구별할 수 없다는 것이다. sed로는 이 둘을 구분할 방법이 없었다.
2차 시도: CSV 방식 (한계)
MariaDB의 --batch 옵션으로 추출하면 TSV 형태로 나온다. CSV 형태로 바꿔볼까 했지만, 큰따옴표로 감싸는 방식도 데이터 안에 큰따옴표가 있으면 문제가 생긴다. 근본적인 해결이 아니었다.
최종 해결: XML 파이프라인
MariaDB의 --xml 옵션을 발견했다. XML은 데이터를 태그로 감싸기 때문에, 데이터 내용에 어떤 특수문자가 있든 구조가 깨지지 않는다.
최종 데이터 파이프라인은 다음과 같다.
원본 DB
↓ --xml 옵션으로 SQL 쿼리 실행
XML 데이터
↓ Python xml.etree.ElementTree로 파싱
파싱된 데이터
↓ PostgreSQL COPY TEXT 형식에 맞게 이스케이프
이스케이프된 데이터
↓ TSV 파일로 저장
TSV_FILE (임시)
↓ PostgreSQL COPY 명령 실행
분석용 DB (최종)
핵심 코드: 이스케이프 처리
def escape_for_pg_copy(value):
"""PostgreSQL COPY TEXT 형식에 맞게 이스케이프"""
if value is None:
return ''
# PostgreSQL COPY TEXT 형식 이스케이프 규칙
# 주의: 백슬래시를 먼저 처리해야 함!
value = value.replace('\\', '\\\\') # \ → \\
value = value.replace('\t', '\\t') # Tab → \t
value = value.replace('\n', '\\n') # LF → \n
value = value.replace('\r', '\\r') # CR → \r
return value
이 함수가 핵심이다. 백슬래시를 먼저 이스케이프하고, 그 다음 탭/줄바꿈/캐리지리턴을 처리한다. 순서가 중요하다. 백슬래시를 나중에 처리하면 이미 이스케이프 된 \t가 \\t가 되어버린다.
메모리 고려
하루에 적재되는 데이터가 약 3만 건이다. XML 전체를 메모리에 올리고 파싱해도 될까 고민했다. 테스트해 보니 3만 건 정도는 문제없이 처리됐다. 만약 데이터가 더 커진다면 xml.etree.ElementTree.iterparse()를 사용해서 스트리밍 방식으로 처리하는 것도 방법이다. 하지만 현재 규모에서는 오버엔지니어링이라 판단하고 단순한 방식을 유지했다.
데이터 파이프라인에서 특수문자는 항상 문제가 된다. 원본 데이터의 구조를 보존할 수 있는 중간 형식(XML, JSON)을 사용하고, 최종 단계에서 이스케이프 처리하는 게 안전하다.
5. 삽질 기록 #3: RSA 복호화를 Bash에서 어떻게 하지?
보안 요구사항
보안 요구사항이 있었다. RSA-2048 OAEP 암호화를 적용해야 했다.
선택지 고민
두 가지 선택지가 있었다.
- 전체를 Python으로 작성: RSA 복호화는 쉬워지지만, Cron 스케줄링과 쉘 파이프라인 연동이 번거로워진다
- 전체를 Bash로 작성: openssl로 RSA 복호화가 가능하지만, OAEP 패딩 처리가 복잡하다
결국 하이브리드 방식을 선택했다. 각 언어의 장점만 취하는 것이다.
해결: Bash heredoc + Python
핵심 아이디어는 이렇다.
- Bash 스크립트 안에 Python 코드를 heredoc으로 삽입
- Python이 RSA 복호화를 수행
- Python이 export VAR='value' 형태의 문자열을 출력
- Bash에서 eval로 그 문자열을 실행해서 환경변수 설정
코드 메커니즘은 다음과 같다.
DECRYPT_OUTPUT=$(DB_USER="$DB_USER" DB_PASSWORD="$DB_PASSWORD" \
python3 - "$PRIVATE_KEY_PATH" 2>&1 <<'PY'
import os, sys, base64
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
key_path = sys.argv[1]
# 개인키 로드
with open(key_path, "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
def try_decrypt(val):
if not val: return None
b = base64.b64decode(val)
return private_key.decrypt(
b,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
).decode("utf-8")
db_user = try_decrypt(os.getenv("DB_USER"))
db_pwd = try_decrypt(os.getenv("DB_PASSWORD"))
# 작은따옴표 이스케이프 (비밀번호에 ' 가 있을 경우 대비)
def safe(v): return v.replace("'", "'\"'\"'")
print(f"export DB_USER='{safe(db_user)}'")
print(f"export DB_PASSWORD='{safe(db_pwd)}'")
PY
)
# Python 출력을 Bash에서 실행 → 환경변수 설정
eval "$DECRYPT_OUTPUT"
왜 이 방식이 좋은가?
| 작업 | Bash | Python |
| Cron 스케줄링 | 자연스러움 | subprocess 필요 |
| DB CLI 호출 | 파이프라인 편리 | subprocess 필요 |
| RSA 복호화 | openssl 복잡함 | cryptography 라이브러리로 간단 |
| 환경변수 설정 | export/eval로 간단 | os.environ (현재 프로세스만) |
하나의 언어로 모든 걸 해결하려 하지 말자. 각 언어의 장점을 조합하면 더 깔끔한 코드가 나온다.
6. 배운 점
기술적 성장
- Cron의 프로세스 격리: Cron은 완전히 독립된 프로세스에서 실행된다. 부모 프로세스의 환경변수, 복호화 로직 등을 상속받지 않는다
- 데이터 파이프라인 설계: 특수문자가 있는 데이터를 다룰 때는 중간 형식(XML)을 활용하고, 최종 단계에서 이스케이프하는 것이 안전하다
- 언어 간 협업: Bash의 환경변수/파이프라인 강점과 Python의 라이브러리 생태계를 조합하면 각각 단독으로 하는 것보다 깔끔하다
설계 철학
- KISS 원칙: 3개 컨테이너에서 1개 컨테이너로 줄였다. 복잡하다고 좋은 게 아니다
- 실용주의: 폐쇄망 환경에서는 이론적으로 좋은 것보다 실제로 돌아가는 것이 중요하다
- 문제의 본질 파악: "source가 안 된다"가 아니라 "무엇을 source하느냐"가 문제였다. 증상이 아닌 원인을 찾아야 한다
마치며
이번 프로젝트에서 가장 크게 느낀 점은 삽질도 결국 자산이 된다는 것이다. 매번 막힐 때마다 답답했지만, 그 과정에서 Cron, 쉘 스크립트, 데이터 파이프라인에 대한 이해가 깊어졌다.
특히 폐쇄망이라는 제약 조건이 오히려 기본기를 다지는 계기가 되었다. 인터넷 검색에 의존하기보다 공식 문서를 꼼꼼히 읽고, 직접 실험하며 원리를 이해하려고 노력했다.
비슷한 배치 시스템을 구축하는 분들께 이 글이 조금이나마 도움이 되기를 바란다.
부록: 트러블슈팅 체크리스트
Docker + Cron 배치 시스템에서 문제가 발생했을 때 확인할 사항들이다.
| 증상 | 확인 사항 |
| Cron에서 환경변수 없음 | source 대상 파일의 내용 확인. 암호화된 값이면 복호화된 값을 별도 파일로 저장 |
| COPY 컬럼 불일치 | 데이터 내 줄바꿈/탭 문자 확인. XML 파이프라인 + 이스케이프 처리 |
| RSA 복호화 실패 | 환경변수 전달 방식 확인. heredoc 내 Python에서 os.getenv()로 접근 |
| 수동 실행은 되고 Cron은 안 됨 | 실행 환경 차이 확인. PATH, 환경변수, 작업 디렉토리가 다를 수 있음 |
'트러블 슈팅' 카테고리의 다른 글
| 사내 AI 도구 배포 시스템 구축기 — 브라우저 캐시와 행망 보안이 설치를 망쳤다 (0) | 2026.04.09 |
|---|---|
| OpenCode 포크했더니 @opencode-ai/plugin이 안 된다고? 나도 그랬다 (0) | 2026.03.17 |
| Python + Inno Setup으로 사내 AI 도구 배포 자동화하기 (0) | 2026.03.11 |
| RTR을 활용한 토큰 탈취 안정성에 대한 나의 생각 (2) | 2024.12.18 |
| Spring Batch ItemReader 성능개선기 (2) | 2024.12.16 |