1. Trino 조회 스크립트 마무리
Trino 접속 + 조회 + refang + 해시를 한 파일로 통합하면서 정리한 내용
1.1 verify=False 유지 이유
- 원인 — Trino 서버가 자체서명 인증서를 사용하므로 공인 CA 체인 검증을 통과할 수 없음
- verify=False — 검증 자체를 건너뛰는 옵션. 동작은 하지만 중간자 공격에 무방비
- 더 안전한 대안 — 서버 인증서 파일을 받아 경로를 직접 지정하면 검증을 유지한 채 접속 가능
conn = trino.dbapi.connect(
host="localhost",
port=8443,
user="user",
http_scheme="https",
verify="/path/to/trino-server.crt",
)
1.2 refang 정규식 해설
re.sub(r'[\[\]]', '', url)
| 구성요소 |
의미 |
| 바깥 [ ... ] |
문자 클래스. 안에 든 문자 중 하나와 매칭 |
| \[, \] |
대괄호 자체를 리터럴로 매칭하기 위한 이스케이프 |
| '' |
매칭된 문자를 빈 문자열로 치환, 즉 제거 |
1.3 HMAC-SHA256 해시
- 구현 — 비밀키 기반 해시로 URL을 가명화함
import hmac, hashlib
def hash_url(url, key):
return hmac.new(key.encode(), url.encode(), hashlib.sha256).hexdigest()
- 비밀키 관리 — 혼자 쓰는 스크립트라면 하드코딩도 동작상 문제는 없음. 다만 코드를 공유하거나 저장소에 올릴 가능성이 있으면 환경변수로 분리하는 편이 안전함
이 해시 방식은 이후 "원본을 다시 봐야 한다"는 요구가 나오면서 AES 암호화로 교체됨 (5장)
2. 증분 실행 스크립트 설계
일회성 조회가 아니라 주기적으로 실행하면서 새로 들어온 데이터만 처리하는 구조를 설계함
2.1 워터마크 방식
- 저장 — 마지막으로 처리한 기준값을 로컬 JSON 파일(last_watermark.json)에 기록
- 조회 — 다음 실행 시 WHERE 기준컬럼 > 워터마크 조건으로 이후 데이터만 가져옴
- 갱신 — 처리가 끝나면 이번 배치의 최댓값으로 워터마크를 덮어씀
wm = json.load(open("last_watermark.json"))["last"]
cur.execute(f"SELECT * FROM target_table WHERE updated_at > TIMESTAMP '{wm}' ORDER BY updated_at")
rows = cur.fetchall()
if rows:
json.dump({"last": str(rows[-1][ts_idx])}, open("last_watermark.json", "w"))
2.2 실행 방식 비교
| 방식 |
구조 |
특징 |
| 상주 프로세스 |
while True + time.sleep(60) |
구현 단순, 프로세스가 죽으면 수동 재기동 필요 |
| cron 주기 실행 |
1회 실행 스크립트를 주기 등록 |
장애에 강함, 실행 간 상태는 워터마크 파일로만 유지 |
- 미정 사항 — 증분 기준으로 쓸 컬럼(타임스탬프인지 증가 ID인지)이 아직 결정되지 않음
3. CUBRID 디팽 SQL
DB에 저장된 정상 URL을 디팽 형태로 변환하는 UPDATE 쿼리를 작성함
3.1 변환 규칙
| 원본 |
변환 |
| http |
hxxp |
| :// |
[://] |
| 모든 . |
[.] |
| 모든 / |
[/] |
https://www.example.go.kr/report/reportInfo.do
→ hxxps[://]www[.]example[.]go[.]kr[/]report[/]reportInfo[.]do
3.2 플레이스홀더로 :// 보호
- 문제 — /를 먼저 [/]로 감싸면 ://의 슬래시까지 감싸져 :[/][/]가 됨. 순서를 바꿔도 이중 감싸기가 발생함
- 해결 — ://를 임시 문자열(@@SCHEME@@)로 치환해 두고, /와 . 처리가 끝난 뒤 [://]로 복원함
- 서브쿼리 여부 — 중첩 REPLACE는 SELECT가 없으므로 서브쿼리가 아니라 단순 함수 중첩임
UPDATE target_table
SET url_col =
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(url_col, '://', '@@SCHEME@@'),
'/', '[/]'),
'.', '[.]'),
'@@SCHEME@@', '[://]'),
'http', 'hxxp')
WHERE url_col LIKE 'http%';
3.3 재실행 안전성
- 버그 — 이미 디팽된 행에 쿼리를 다시 돌리면 [.]가 [[.]]처럼 이중 중첩됨
- 가드 — WHERE url_col LIKE 'http%' 조건 추가. 디팽된 행은 hxxp로 시작하므로 대상에서 빠짐
- 효과 — 몇 번을 실행해도 결과가 같은 멱등(idempotent) 쿼리가 됨
3.4 오염된 테스트 데이터 정리 — 투패스 전략
- 1패스 정규화 — 대괄호를 전부 제거하고 hxxp를 http로 되돌려 깨끗한 원본 상태로 만듦
- 2패스 재적용 — 정규화된 상태에서 3.2의 디팽 쿼리를 일괄 적용함
UPDATE target_table
SET url_col = REPLACE(REPLACE(REPLACE(url_col, '[', ''), ']', ''), 'hxxp', 'http');
섞여 있는 데이터를 조건 분기로 하나씩 고치려 하면 경우의 수가 폭발한다. 먼저 한 가지 상태로 정규화한 뒤 변환을 다시 거는 게 가장 단순함
3.5 파일 확장자 별도 처리
- 요구사항 — URL 끝의 확장자는 [.]do가 아니라 [.do]처럼 통째로 감쌈
- 방법 — SUBSTRING_INDEX(url, '.', -1)로 마지막 점 이후를 확장자로 분리하고, 나머지에 일반 디팽 로직을 적용한 뒤 ||로 재조합함
디팽(앞부분) || '[.' || SUBSTRING_INDEX(url_col, '.', -1) || ']'
- 엣지케이스 — 확장자가 없는 URL(.../report/MD12)에서는 마지막 점이 도메인의 점으로 잡혀 kr/report/MD12 전체가 확장자로 오인됨. 문제가 생기면 1패스 정규화로 되돌리는 것으로 감수하기로 함
3.6 DBeaver 포맷터 한계
- 증상 — Ctrl+Shift+F 포맷팅이 중첩 함수 호출은 줄바꿈해 주지 않음
- 대응 — 중첩 REPLACE는 수동 들여쓰기로 유지함
4. NiFi CUBRID → CSV 파이프라인
전체 컬럼을 읽어 URL 컬럼만 가공한 뒤 CSV로 만들어 전송 직전 큐에 대기시키는 흐름을 구성함
4.1 전체 구조
ExecuteSQLRecord (SELECT *)
→ ScriptedTransformRecord (Groovy, URL 필드만 가공, CSV 출력)
→ UpdateAttribute (정지 상태 더미, 큐 대기용)
- 더미 프로세서 — 설정 없는 UpdateAttribute는 순수 pass-through라 큐를 붙잡아 두는 용도로 적합함. LogAttribute도 대안
- failure 연결 — 테스트 중에는 failure relationship도 더미에 임시 연결해 두면 실패 FlowFile을 직접 열어볼 수 있음
4.2 Controller Service 활성화
- 증상 — Record Writer ... disabled 에러로 프로세서가 시작되지 않음
- 원인 — Controller Service는 생성만으로는 동작하지 않음
- 해결 — Controller Services 탭에서 번개 아이콘으로 Enable 해야 함
- AvroRecordSetWriter — 내장 타입이지만 자동으로 인스턴스가 생기지는 않음. 직접 추가해야 함
4.3 스크립트 언어 선택
- Groovy — JVM 스크립팅 언어. NiFi에 기본 번들되어 있어 별도 설치 불필요
- Python — ScriptingComponentHelper.java 확인 결과, 설치된 JSR-223 엔진만 드롭다운에 표시됨
- Jython — 추가 설치하면 선택 가능하지만 Python 2 문법 기반이라 비추천
- NiFi 2.x 네이티브 Python 프로세서 — ScriptedTransformRecord와는 별개의 프레임워크
4.4 테스트 방법
- Run Once — 프로세서별로 1회만 실행
- List Queue → View — 다음 커넥션의 큐에서 FlowFile을 열어 CSV 내용 확인
- 소량 테스트 — SQL에 LIMIT을 붙여 부담을 줄임. "최근 5건"처럼 특정 순서가 필요하면 ORDER BY 컬럼 DESC LIMIT 5로 순서를 명시해야 함
5. 해시에서 AES-GCM 암호화로 전환
"나중에 원본 URL을 다시 봐야 한다"는 요구로 단방향 해시를 양방향 암호화로 교체함
5.1 해시와 암호화의 차이
| 구분 |
해시 (HMAC 포함) |
암호화 (AES) |
| 방향 |
단방향 |
양방향 |
| 원본 복원 |
원천적으로 불가 |
키가 있으면 가능 |
| 용도 |
무결성 검증, 가명화 |
기밀성 보장 후 재열람 |
- 용어 — "양방향 해시"는 암호학적으로 모순된 표현. 정확한 용어는 "암호화"
5.2 AES-256-GCM 구성
- 키 생성 — 비밀번호 문자열을 SHA-256에 통과시켜 32바이트 AES 키로 만듦. URL을 해시하는 게 아니라 키 길이를 맞추기 위한 용도
- IV — 레코드마다 12바이트를 랜덤 생성. 같은 URL이라도 암호문이 매번 달라짐
- 저장 형식 — IV + 암호문(+인증태그)를 이어 붙여 Base64로 저장. 복호화할 때 IV가 필요하기 때문
- 비밀번호 관리 — 스크립트 본문에 두지 않고 Parameter Context의 Sensitive 파라미터 등으로 주입하는 편이 안전함
단순 SHA-256으로 비밀번호를 키로 늘리는 방식은 동작은 하지만, 비밀번호가 약하면 무차별 대입에 취약하다. 운영 환경이라면 PBKDF2처럼 반복 연산과 salt를 쓰는 키 유도 함수가 더 적합함
5.3 ENCRYPT 스크립트 (현재 흐름용)
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.crypto.spec.GCMParameterSpec
import java.security.MessageDigest
import java.security.SecureRandom
def secret = '<비밀번호>'
def key = MessageDigest.getInstance('SHA-256').digest(secret.getBytes('UTF-8'))
def url = record.getValue('url_col')
if (url != null) {
def plain = url.toString().replaceAll(/[\[\]]/, '').replace('hxxp', 'http')
def iv = new byte[12]
new SecureRandom().nextBytes(iv)
def cipher = Cipher.getInstance('AES/GCM/NoPadding')
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, 'AES'), new GCMParameterSpec(128, iv))
def ct = cipher.doFinal(plain.getBytes('UTF-8'))
def out = new ByteArrayOutputStream()
out.write(iv)
out.write(ct)
record.setValue('url_col', Base64.encoder.encodeToString(out.toByteArray()))
}
record
5.4 Groovy 바이트 배열 결합 에러
- 증상 — iv + ct 작성 시 MissingMethodException: No signature of method: [B.plus()
- 원인 — Groovy는 List에는 +를 지원하지만 원시 byte[]에는 plus() 메서드가 없음
- 해결 — ByteArrayOutputStream에 두 배열을 순서대로 write 해서 결합함 (5.3 코드 반영)
5.5 DECRYPT 스크립트 (나중에 별도 프로세서용)
- 구분 — 현재 흐름에는 ENCRYPT만 들어감. DECRYPT는 원본 확인이 필요할 때 따로 붙이는 프로세서용
- 타임스탬프 변환 — 복호화 시 epoch 밀리초로 들어오는 일시 컬럼을 사람이 읽는 형식으로 함께 변환함 (6.3)
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.crypto.spec.GCMParameterSpec
import java.security.MessageDigest
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
def secret = '<비밀번호>'
def key = MessageDigest.getInstance('SHA-256').digest(secret.getBytes('UTF-8'))
def enc = record.getValue('url_col')
if (enc != null) {
def raw = Base64.decoder.decode(enc.toString())
def iv = Arrays.copyOfRange(raw, 0, 12)
def ct = Arrays.copyOfRange(raw, 12, raw.length)
def cipher = Cipher.getInstance('AES/GCM/NoPadding')
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, 'AES'), new GCMParameterSpec(128, iv))
record.setValue('url_col', new String(cipher.doFinal(ct), 'UTF-8'))
}
def ms = record.getValue('event_dt')
if (ms != null) {
def fmt = DateTimeFormatter.ofPattern('yyyy-MM-dd HH:mm:ss.SSS').withZone(ZoneId.of('Asia/Seoul'))
record.setValue('event_dt', fmt.format(Instant.ofEpochMilli(ms as long)))
}
record
6. 라이브러리 없는 환경에서 복호화 검증
암호화 결과가 실제로 복원되는지, Python·Java 등 암호 라이브러리를 쓸 수 없는 환경에서 검증함
6.1 환경 제약
- 확인 결과 — Python, Java, PowerShell 7, dotnet CLI 모두 사용 불가
- 남은 수단 — Windows PowerShell의 .NET Framework AesManaged (GCM 모드 미지원)
6.2 AES-ECB로 CTR 모드 직접 구현
- 원리 — GCM의 암호화 부분은 CTR 모드임. CTR은 "카운터 블록을 AES로 암호화한 값"을 암호문과 XOR 하는 구조라, 블록 하나를 암호화하는 ECB만 있으면 직접 만들 수 있음
- 카운터 시작값 — 12바이트 IV 뒤에 32비트 카운터를 붙이며, GCM은 1을 인증태그용으로 쓰고 데이터 암호화는 2부터 시작함
- 인증태그 — 검증은 생략하고 평문 복원만 목적으로 함. 마지막 16바이트(태그)는 잘라내고 처리
counter = IV || 00 00 00 02
for each 16-byte block C_i in ciphertext (태그 제외):
keystream = AES_ECB_Encrypt(key, counter)
P_i = C_i XOR keystream
counter 마지막 4바이트 += 1
모드는 결국 블록 암호를 어떻게 조합하느냐의 문제다. ECB 프리미티브 하나만 있어도 CTR, 나아가 GCM의 암호화 부분까지 재구성할 수 있음
6.3 타임스탬프 불일치의 정체
- 증상 — 일시 컬럼 값 1778783696159가 예상 DATETIME 2026-05-15 03:34:56.159와 달라 보임
- 확인 — DateTimeOffset.FromUnixTimeMilliseconds로 변환해 보니 UTC 기준 2026-05-14 18:34:56.159
- 결론 — UTC+9를 적용하면 정확히 일치함. 데이터 오류가 아니라 epoch 밀리초 표현 방식의 차이였음
| 표현 |
값 |
| epoch ms |
1778783696159 |
| UTC |
2026-05-14 18:34:56.159 |
| KST (UTC+9) |
2026-05-15 03:34:56.159 |
- 후속 조치 — DECRYPT 스크립트에 epoch ms → yyyy-MM-dd HH:mm:ss.SSS (Asia/Seoul) 변환 로직 추가
7. 미해결 항목
결론이 나지 않았거나 확인이 남은 항목을 정리함
- 증분 기준 컬럼 — 타임스탬프와 증가 ID 중 무엇을 쓸지 미정
- 확장자 없는 URL — 마지막 점 분리 로직이 오작동할 수 있음. 현재는 문제 발생 시 되돌리는 것으로 감수
- "최근 5건" 테스트 — 정렬 기준 컬럼 확정 및 결과 확인 필요
- DBeaver 포맷팅 — 중첩 함수 자동 정렬 방법 미해결
요약
| 주제 |
핵심 내용 |
| Trino 스크립트 |
자체서명 인증서라 verify=False, 인증서 경로 지정이 더 안전 |
| 증분 실행 |
워터마크 JSON 파일 + 상주 프로세스 또는 cron |
| 디팽 SQL |
:// 플레이스홀더 보호, LIKE 'http%' 가드로 멱등성 확보 |
| 오염 데이터 |
정규화 후 재적용하는 투패스 전략 |
| NiFi 파이프라인 |
ExecuteSQLRecord → ScriptedTransformRecord → 더미 큐, Controller Service Enable 필수 |
| 해시 → 암호화 |
재열람 요구로 AES-256-GCM 전환, 레코드별 랜덤 IV를 암호문 앞에 저장 |
| 복호화 검증 |
AES-ECB로 CTR 직접 구현해 평문 복원, 타임스탬프는 epoch ms의 UTC/KST 차이 |
요구사항 한 줄이 설계를 통째로 바꿨다. "나중에 다시 봐야 한다"는 순간 해시는 선택지에서 빠지고, 키 관리와 IV 저장까지 책임져야 하는 암호화 구조로 넘어가게 됨