0930 운영 자동화/AI 워크플로우 심화 (24/N): Workflow Orchestration, Saga, Compensation과 부분 실패 복구 ✅ 1. 자동화가 길어질수록 ‘한 함수’로 처리하면 문제가 생긴다 처음에는 자동화가 단순하다. 주문 상태 변경 → 알림톡 발송 하지만 기능이 늘어나면: 주문 상태 변경 ↓ Audit Log ↓ NotificationJob 생성 ↓ 알림톡 발송 ↓ 외부 Provider 응답 ↓ Webhook 수신 ↓ 고객 상태 업데이트 ↓ 관리자 알림 ↓ 통계 반영 처럼 여러 단계가 연결된다. AI 자동화도 비슷하다. Git 변경 수집 ↓ LLM 분석 ↓ Markdown 생성 ↓ 파일 저장 ↓ Notion 업로드 ↓ Git Commit ↓ Push 이걸 하나의 함수에서 끝까지 처리하면 운영이 어려워진다. ✅ 2. 긴 작업에서는 ‘전체 성공 / 전체 실패’만으로 부족하다 예를 들어: Step 1 Git 수집 SUCCESS Step 2 LLM Report 생성 SUCCESS Step 3 Markdown 저장 SUCCESS Step 4 Notion Upload FAILED 라고 하자. 이때 전체 Workflow를: FAILED 라고만 기록하면 정보가 부족하다. 이미 앞의 세 단계는 정상 완료됐다. 따라서 중요한 것은: 어디까지 성공했는가? 어디서 실패했는가? 다시 시작할 때 어디부터 시작해야 하는가? 다. ✅ 3. Workflow란? Workflow는 여러 작업을 하나의 업무 흐름으로 묶은 것이다. 예: DailyReportWorkflow 안에: COLLECT_GIT GENERATE_REPORT SAVE_MARKDOWN UPLOAD_NOTION 이 존재한다. 각 Step은 독립적인 상태를 가진다. ✅ 4. Workflow와 Job의 차이 Job은 보통: 하나의 실행 단위 다. 예: Notion에 페이지 업로드 Workflow는: 여러 Job/Step의 실행 순서와 상태를 관리 한다. 예: Report 생성 → 파일 저장 → Notion 업로드 이다. ✅ 5. Orchestration이란? Workflow의 다음 Step을 중앙에서 결정하는 방식이다. 예: Workflow Orchestrator ↓ Step 1 실행 ↓ 성공 확인 ↓ Step 2 실행 ↓ 성공 확인 ↓ Step 3 실행 즉 Orchestrator가: 지금 어느 Step인가? 다음에는 무엇을 해야 하는가? 실패하면 어떻게 해야 하는가? 를 알고 있다. ✅ 6. Orchestration과 Choreography 분산 시스템에서는 흔히 두 방식을 구분한다. Orchestration 중앙 Workflow가 순서를 관리한다. OrderWorkflow ↓ Payment ↓ Notification ↓ Complete Choreography 각 서비스가 Event를 보고 다음 Event를 만든다. ORDER_CREATED ↓ Payment Service PAYMENT_COMPLETED ↓ Notification Service NOTIFICATION_SENT 중앙 지휘자가 없다. ✅ 7. 현재 프로젝트에는 Orchestration이 더 이해하기 쉽다 현재는 1인 개발 환경이고 서비스 규모도 상대적으로 단순하다. Choreography를 과도하게 사용하면: 어떤 Event가 어떤 Event를 발생시키는지 전체 흐름을 어디서 봐야 하는지 실패 시 어느 서비스가 복구해야 하는지 가 복잡해질 수 있다. 따라서 중요한 업무 흐름은: 명시적인 Workflow Orchestrator 를 두는 것이 관리하기 쉽다. ✅ 8. 모든 Event를 없애라는 뜻은 아니다 예: Order Status Changed 같은 Domain Event는 여전히 유용하다. 다만 중요한 장시간 업무를: Event A → Event B → Event C → Event D 에만 맡기지 말고 Workflow의 현재 상태를 별도로 기록하는 것이다. ✅ 9. Workflow Instance Workflow 정의와 실제 실행을 구분한다. 예: Workflow Definition DailyReportWorkflow 실제 실행: Workflow Run 2026-09-30 platform 이다. 0929의 Schedule / ScheduleRun과 같은 사고방식이다. ✅ 10. Workflow Definition과 WorkflowRun Workflow Definition = 실행 절차 WorkflowRun = 실제 한 번의 업무 실행 예: DailyReportWorkflow ├─ 09/28 Run ├─ 09/29 Run └─ 09/30 Run ✅ 11. WorkflowRun 상태 예: PENDING RUNNING WAITING SUCCESS FAILED COMPENSATING COMPENSATED MANUAL_REQUIRED CANCELLED 정도로 둘 수 있다. ✅ 12. WAITING 상태가 중요하다 Workflow는 항상 CPU를 사용하며 실행되는 것이 아니다. 예: 알림톡 발송 ↓ 외부 Webhook 대기 처럼 외부 Event를 기다릴 수도 있다. 이때: RUNNING 으로 계속 Worker를 점유할 필요가 없다. WAITING 상태로 저장해두고 Webhook이 오면 다시 Resume한다. ✅ 13. 긴 Workflow가 Worker를 계속 잡고 있으면 안 된다 예: 상품 신청 ↓ 외부 본인인증 대기 ↓ 최대 30분 이런 Workflow에서 Worker 하나가 30분 동안 기다리는 것은 비효율적이다. 대신: Workflow 상태 저장 ↓ WAITING_EXTERNAL_EVENT ↓ Worker 종료 후 Event가 들어오면 재개한다. ✅ 14. Workflow Step 예: interface WorkflowStep { id: string; workflowRunId: string; name: string; status: | 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'WAITING' | 'SKIPPED'; attemptCount: number; } ✅ 15. Step은 가능한 한 작고 의미 있게 나눈다 너무 크게: PROCESS_ORDER 하나로 만들면 실패 위치를 알기 어렵다. 반대로: READ_FIELD_1 READ_FIELD_2 CHECK_VALUE 처럼 너무 작게 나누면 오케스트레이션이 복잡해진다. 업무 의미 단위로 나누는 것이 좋다. ✅ 16. 좋은 Step 예시 VALIDATE_ORDER UPDATE_ORDER_STATUS CREATE_NOTIFICATION_JOB WAIT_NOTIFICATION_RESULT FINALIZE_ORDER 처럼 실제 업무 단계가 드러나야 한다. ✅ 17. Step은 상태를 바꾸는 경계가 된다 각 Step이 완료될 때: Checkpoint 를 남긴다. 예: Step 1 SUCCESS ↓ DB 저장 ↓ Step 2 실행 Worker가 Step 2 중간에 죽어도 Step 1은 다시 실행하지 않아도 된다. ✅ 18. Checkpoint의 핵심 Checkpoint는: 이 시점까지는 완료됐다는 확정된 사실 이다. AI Workflow에서 매우 중요하다. 예: CODE_MODIFIED SUCCESS TEST SUCCESS REPORT PENDING 이면 Report부터 재개할 수 있다. ✅ 19. Resume는 Workflow의 핵심 기능이다 긴 작업을 안정적으로 운영하려면: 처음부터 Retry 보다: 마지막 성공 Step 이후부터 Resume 가 훨씬 낫다. ✅ 20. 단 Resume 전에 상태를 재검증한다 예: Step 2에서 파일 생성 완료 라고 DB에 기록되어 있어도 실제 파일이 삭제됐을 수 있다. 그래서 Resume 전: Artifact 존재 여부 Version External State Commit SHA 등을 확인한다. ✅ 21. Workflow는 사실 하나의 State Machine이다 예: PENDING ↓ VALIDATING ↓ PROCESSING ↓ WAITING_EXTERNAL ↓ FINALIZING ↓ SUCCESS 잘못된 상태 전이는 차단한다. ✅ 22. State Machine을 명시하는 이유 예: FAILED → SUCCESS 를 아무 조건 없이 허용하면 문제가 생긴다. 정상 흐름은: FAILED → RETRY_PENDING → RUNNING → SUCCESS 처럼 명확해야 한다. ✅ 23. Workflow에서 가장 까다로운 것은 ‘부분 성공’이다 예: 1. 주문 상태 변경 SUCCESS 2. 알림톡 발송 SUCCESS 3. 관리자 통계 업데이트 FAILED 이때: 전체를 Rollback 할 수 있을까? 알림톡은 이미 고객에게 발송됐다. 되돌릴 수 없다. ✅ 24. DB Transaction으로 해결할 수 없는 영역 DB 내부에서는: BEGIN Order Update Audit Log Outbox Insert COMMIT 처럼 Transaction을 사용할 수 있다. 하지만 외부 API까지 포함하면: DB Commit ↓ 외부 알림톡 전송 을 하나의 DB Transaction으로 묶을 수 없다. ✅ 25. Distributed Transaction 문제 예: 우리 DB 외부 Kakao API Notion S3 GitHub 는 서로 다른 시스템이다. 이 모든 시스템에: BEGIN TRANSACTION 을 걸 수 없다. 그래서 Saga 같은 사고방식이 필요하다. ✅ 26. Saga란? 긴 비즈니스 Transaction을 여러 Local Transaction으로 나누고, 중간에 실패하면 이미 완료된 작업을: Compensation 으로 되돌리거나 보정하는 패턴이다. ✅ 27. 일반 Transaction과 Saga 일반 Transaction: A B C 하나 실패 ↓ 전체 Rollback Saga: A SUCCESS B SUCCESS C FAILED ↓ Compensate B ↓ Compensate A 이다. ✅ 28. Compensation이란? 완벽한 Rollback이 아니라: 이미 일어난 업무 효과를 비즈니스적으로 반대되는 작업으로 보정 하는 것이다. ✅ 29. 예: 예약 시스템 좌석 예약 ↓ 결제 ↓ 티켓 발급 티켓 발급이 실패했다면: 결제 취소 좌석 예약 해제 가 Compensation이 될 수 있다. ✅ 30. 모든 Action에 Compensation이 가능한 것은 아니다 예: 고객에게 카카오톡 메시지 발송 은 이미 전송됐다. 이를: 발송 취소 할 수 없다. 이런 Action은 Irreversible Side Effect 다. ✅ 31. Irreversible Action은 뒤쪽에 배치하는 것이 좋다 가능하다면 Workflow를: 검증 ↓ DB 상태 변경 ↓ 필수 데이터 저장 ↓ 외부 메시지 발송 순으로 둔다. 실패 가능성이 높은 검증을 먼저 한다. 되돌릴 수 없는 Action은 가능한 한 뒤쪽에 둔다. ✅ 32. 하지만 무조건 뒤로 보낼 수 있는 것은 아니다 업무상 메시지 발송이 먼저 필요한 경우도 있다. 이때는 Rollback보다: Forward Recovery 를 고려해야 한다. ✅ 33. Rollback과 Compensation은 다르다 Rollback: 실행 전 상태로 완전히 되돌림 Compensation: 업무적으로 최대한 원상태에 가깝게 보정 이다. 예: 고객에게 잘못된 안내 발송 ↓ 메시지 삭제 불가능 ↓ 정정 메시지 발송 은 Compensation이다. ✅ 34. Forward Recovery 이미 일어난 Side Effect를 되돌리지 않고: 앞으로 진행하면서 정상 상태를 만든다. 예: 주문 완료 알림톡 성공 통계 반영 실패 ↓ 통계 반영 Retry 이다. 이 상황에서는 주문과 알림톡을 되돌릴 필요가 없다. ✅ 35. 실제 운영에서는 Forward Recovery가 더 자연스러운 경우가 많다 특히: 메시지 발송 파일 업로드 외부 API Webhook AI 보고서 같은 작업은 완전 Rollback보다: 실패한 단계만 복구 하는 것이 낫다. ✅ 36. Compensation을 무조건 만들 필요는 없다 각 Step마다 먼저 판단한다. Retry 가능한가? Compensation 가능한가? Forward Recovery가 더 나은가? 사람 판단이 필요한가? ✅ 37. Step Policy 예: interface StepPolicy { retryable: boolean; compensatable: boolean; irreversible: boolean; requiresApproval?: boolean; } 같은 Metadata를 둘 수 있다. ✅ 38. Step 예시 Step Retry Compensation Irreversible DB 상태 변경 가능 가능 X S3 업로드 가능 파일 삭제 가능 X Notion 페이지 생성 가능 삭제/Archive 가능 X 알림톡 발송 제한적 사실상 어려움 O Git Commit 가능 Revert 가능 부분적 Production 배포 가능 Rollback 가능 조건부 ✅ 39. Compensation도 실패할 수 있다 예: Step B 실패 ↓ Compensation A 실행 ↓ Compensation A도 실패 할 수 있다. 그래서 Compensation 자체도 하나의 Job으로 관리해야 한다. ✅ 40. Compensation 상태 예: COMPENSATION_PENDING COMPENSATING COMPENSATED COMPENSATION_FAILED MANUAL_REQUIRED 를 둘 수 있다. ✅ 41. Compensation도 Idempotent해야 한다 예: S3 파일 삭제 가 두 번 실행돼도 최종 상태가 같아야 한다. 이미 파일 없음 → 성공 취급 처럼 처리할 수 있다. ✅ 42. Compensation 순서는 반대 방향 Workflow: A → B → C 가 실행됐고 C에서 실패했다면 일반적으로: Compensate B ↓ Compensate A 순서로 간다. ✅ 43. 예: 파일 기반 Workflow 1. Report 생성 2. S3 업로드 3. DB Record 생성 4. 외부 공유 링크 발송 3번 실패했다고 가정한다. 2번 S3 파일을 삭제할 수 있다면 Compensation을 수행할 수 있다. ✅ 44. 하지만 외부 공유 링크까지 이미 발송됐다면 다르다 발송한 메시지를 되돌릴 수 없다. 따라서: 보정 안내 새 링크 발송 관리자 개입 등이 필요하다. 이게 비즈니스 Compensation이다. ✅ 45. 현재 투게더몰에서 Saga가 필요한 영역 모든 기능에 Saga가 필요한 것은 아니다. 특히 다음처럼 여러 시스템이 연결되는 경우 가치가 있다. 주문 상태 변경 + 알림톡 + 외부 API 대량 Export + S3 AI Report + 파일 + Notion + Git 배포 + Health + Rollback ✅ 46. 단순 CRUD에는 Saga를 쓰지 않는다 예: 관리자 메모 수정 같은 단일 DB 작업은 기존 Transaction이면 충분하다. Saga를 붙이면 오히려 복잡해진다. ✅ 47. Saga를 도입할 기준 대략 다음 조건을 볼 수 있다. 여러 시스템에 Side Effect 발생 Workflow가 오래 실행됨 부분 실패 가능성 높음 Rollback이 단순 DB Transaction으로 불가능 실패 후 복구 과정이 중요 ✅ 48. Order Workflow 예시 예: OrderStatusChangeWorkflow Step 1 Validate Transition Step 2 Update Order Step 3 Create Audit Step 4 Create NotificationJob Step 5 Wait Notification Result Step 6 Complete ✅ 49. 여기서 Step 1~3은 DB Transaction으로 묶을 수 있다 굳이 각각 Workflow Step으로 쪼갤 필요는 없다. 예: DB Transaction Step - Order Update - Audit - Outbox 를 하나의 Local Transaction으로 처리한다. ✅ 50. Workflow 안에서도 Local Transaction을 사용한다 중요한 점이다. Saga가 Transaction을 대체하는 것이 아니다. 구조는: Workflow ↓ Local Transaction A ↓ External Side Effect ↓ Local Transaction B 처럼 된다. ✅ 51. Outbox와 Workflow를 같이 사용 예: Order Transaction - 상태 변경 - Audit - Outbox Event COMMIT 후: Outbox Worker ↓ Workflow 다음 Step 으로 진행할 수 있다. ✅ 52. Workflow Orchestrator가 직접 모든 일을 할 필요는 없다 Orchestrator는: 다음 Step 결정 상태 기록 실행 요청 정도를 담당한다. 실제 작업은 기존 Service/Worker가 한다. ✅ 53. Orchestrator가 Business Logic까지 가지면 비대해진다 좋지 않은 구조: WorkflowService 5000줄 주문 로직 알림톡 로직 DB 로직 Notion 로직 Git 로직 좋은 구조: Workflow Orchestrator ↓ Use Case / Worker 호출 이다. ✅ 54. Workflow Step Executor 예: interface WorkflowStepExecutor { execute(context: WorkflowContext): Promise<StepResult>; } 각 Step을 독립 Executor로 구현할 수 있다. ✅ 55. Step Result 예: type StepResult = | { type: 'SUCCESS'; output?: unknown; } | { type: 'WAIT'; waitFor: string; } | { type: 'RETRY'; errorCode: string; } | { type: 'FAIL'; errorCode: string; }; ✅ 56. WAIT가 매우 중요하다 예: 외부 Webhook 기다리기 Human Approval 기다리기 다른 Job 완료 기다리기 같은 장시간 흐름에서 필요하다. ✅ 57. Human Approval도 Workflow Step이다 예: AI 코드 수정 ↓ Test ↓ Release Risk HIGH ↓ WAITING_APPROVAL ↓ 승인 ↓ Deploy 처럼 모델링할 수 있다. ✅ 58. Approval Timeout 영원히 기다리지 않도록: expiresAt 를 둘 수 있다. 예: 24시간 내 승인 없음 → CANCELLED 또는 MANUAL_REQUIRED로 보낸다. ✅ 59. 외부 Event를 기다리는 Step 예: Notification Provider 발송 ↓ WAITING_PROVIDER_RESULT Webhook 수신 시: workflowRunId 또는 Provider Message ID로 Workflow를 찾아 Resume한다. ✅ 60. Correlation ID가 여기서 중요해진다 Workflow 전체가 하나의: correlationId 를 공유한다. 각 Step에는: workflowRunId stepRunId jobId correlationId 가 연결된다. ✅ 61. Workflow Timeline 운영 화면에서: 09:00 Workflow 시작 09:00 VALIDATE SUCCESS 09:01 UPDATE_ORDER SUCCESS 09:01 NOTIFICATION QUEUED 09:02 WAITING_PROVIDER 09:04 Webhook 수신 09:04 FINALIZE SUCCESS 09:04 Workflow SUCCESS 처럼 볼 수 있다. ✅ 62. Workflow 상태를 로그만으로 복원하지 않는다 중요한 상태는 DB에 저장한다. 로그는 분석용이고, Workflow DB 상태는: 현재 어디까지 진행됐는가? 를 결정하는 Source of Truth다. ✅ 63. WorkflowRun 모델 예시 model WorkflowRun { id String @id @default(cuid()) type String status String correlationId String idempotencyKey String @unique currentStep String? input Json? output Json? startedAt DateTime? completedAt DateTime? createdA
DB 조회 4번을 2번으로 줄였다. 당연히 빨라졌을 줄 알았다. 실제로 재 보니 작은 현장은 빨라졌는데 큰 현장은 그대로였다. 범인은 조회 횟수가 아니라 인덱스였다. 어떤 서버냐면 건설 현장 출입구에 얼굴 인식 단말기가 있다. 근로자가 얼굴을 인식하면 단말기가 "누가, 언제 인증했는지"를 서버로 보내고, 서버는 그걸 DB에 저장한다. 하루에 4만 건 넘게 들어온다. 그런데 단말기는 근로자를 PIN 번호로만 안다. 그래서 서버는 기록이 올 때마다 "이 현장의 이 PIN이 누구지?"를 DB에서 찾아야 한다. 이번 이야기는 이 찾는 부분이다. 조회가 최대 4번씩 나가고 있었다 이 서버를 넘겨받고 코드를 열어 보니, 근로자 한 명을 찾는 데 조회가 여러 번 나가고 있었다. for (AttendanceInput in : records) { int registered = mapper.countWorkersByPin(in); // 1 이 현장에 등록된 PIN인가 if (registered == 0) { hold(in); continue; } WorkerMatch existing = mapper.findExistingLog(in); // 2 같은 기록이 이미 있나 WorkerMatch active = mapper.findActiveWorker(in); // 3 근무 중인 근로자가 있나 if (existing != null) save(existing); else if (active == null) save(mapper.findLatestWorker(in)); // 4 else save(mapper.findActiveWorkerDetail(in)); // 4 } 거슬리는 게 세 가지 있었다. 2에서 기록을 찾으면 3의 결과는 안 쓰는데, 3은 항상 실행된다. 비슷한 코드가 여기저기 복사돼 있어서, 이 조회를 부르는 곳이 12군데였다. 사진이나 일 집계가 이미 저장됐는지는 확인하지 않아서, 단말기가 같은 기록을 다시 보내면(재전송) 저장 단계까지 그대로 내려갔다. 그래서 하나로 합쳤다 2~4를 조회 하나로 합치고, 이미 저장된 게 뭔지도 같이 가져오게 했다. for (AttendanceInput in : records) { int registered = mapper.countWorkersByPin(in); // 1 그대로 if (registered == 0) { hold(in); continue; } WorkerMatch m = mapper.findWorkerWithStatus(in); // 통합 조회 (근로자 + 이미 저장된 것들) if (m.alreadyProcessed()) continue; // 이미 처리한 기록은 건너뜀 save(m); } 조회는 최대 4번에서 2번으로, 호출하는 곳은 12군데에서 1군데로 줄었다. 조회가 반으로 줄었으니 당연히 빨라졌겠지..... 진짜 빨라졌는지 재 봤다 이 작업을 정리하다가 문득 걸렸다. 조회 횟수를 센 적은 있어도 속도를 잰 적은 없었다. 그래서 옛 조회들과 통합 조회를 같은 입력으로 EXPLAIN (ANALYZE, BUFFERS) 에 넣어 돌려 봤다. 운영 DB라서 읽기 전용 트랜잭션 안에서만 실행했다. 실행 계획에서 본 건 두 가지다. Buffers : DB가 읽은 페이지 수. 많이 읽을수록 느리다. Filter : 인덱스로 바로 찾지 못하고, 일단 읽은 다음 걸러 낸 조건. 여기 있으면 쓸데없는 행까지 읽는다. 현장마다 근로자 수가 크게 달라서, 작은 현장(400명), 중간 현장(850명), 큰 현장(2,610명)을 하나씩 골라 비교했다. 결과: 작은 현장만 빨라졌다 작은 현장은 읽는 양이 4분의 1로 줄었다(새 기록 기준). 중간 현장도 줄었다. 그런데 큰 현장은 비슷하거나, 재전송 상황에서는 오히려 더 많이 읽었다. 조회를 합쳤는데 왜 큰 현장만 이럴까? 범인은 인덱스였다 옛 조회의 실행 계획을 열어 보니 이런 게 있었다. Bitmap Heap Scan on worker Recheck Cond: (site_id = '<현장>') ← 현장까지는 인덱스로 찾고 Filter: (pin = '<PIN>') ← PIN은 다 읽은 다음 걸러 냄 Rows Removed by Filter: 849 ← 850명 읽어서 849명 버림 근로자 한 명 찾으려고 현장 근로자 전원을 읽고 있었다. 팀 테이블은 더 심해서, 팀 26개를 찾으려고 6만 행을 통째로 읽었다. 이유는 단순했다. pin 이 들어간 인덱스가 어느 테이블에도 없었다. 통합 조회는 팀 테이블을 안 거쳐서 그 낭비는 사라졌다. 작은 현장이 빨라진 이유다. 대신 근태 로그를 뒤지는 부분이 새로 생겼는데, 여기서도 pin 은 Filter였다. 근태 로그는 현장이 클수록 많으니, 큰 현장에서는 새로 생긴 비용이 사라진 비용보다 커져 버렸다. 그래서 인덱스를 제안했다 원인이 pin 인덱스라는 건 분명했다. 근로자와 근태 로그 테이블에 이런 인덱스가 있으면 pin 이 Filter가 아니라 Index Cond로 처리될 것이다. CREATE INDEX ix_worker_site_pin ON worker (site_id, pin); CREATE INDEX ix_attendance_log_site_pin ON attendance_log (site_id, pin); 평소 인덱스는 직접 추가하는 편인데, 이번에는 그러지 않았다. 근태 로그는 1억 건이 넘고 단말기 기록이 하루 종일 쓰이는 테이블이다. 인덱스를 만드는 동안 쓰기가 막히거나 이후 저장이 느려지면 근태 수신 전체가 영향을 받는다. 그래서 혼자 넣지 않고 DB를 관리하는 CTO님께 말씀드렸다. 통합 조회는 그대로 두었다. 이미 처리한 기록을 걸러 내는 확인이 이 조회에 들어 있고, 12군데 흩어져 있던 코드를 다시 흩어 놓을 이유는 없었다. 정리 조회를 합친 건 성능보다 코드 정리에 더 의미가 있었다. 12군데 흩어진 호출을 한 곳으로 모았고, 이미 처리한 기록을 한 번에 걸러 낼 수 있게 됐다. 성능의 열쇠는 인덱스였다. 실행 계획은 "인덱스를 쓰나"보다 "몇 행 읽고 몇 행 버리나"를 봐야 한다. 인덱스를 쓰고 있었는데도 850행을 읽고 849행을 버리고 있었다. 한 곳만 쟀으면 결론이 틀렸을 거다. 작은 현장만 쟀으면 "4분의 1로 줄었다", 큰 현장만 쟀으면 "오히려 느려졌다"고 썼을 거다.
프로그래밍을 처음 배울 때 데이터베이스 트랜잭션은 보통 여러 쿼리를 하나로 묶어 모두 성공시키거나 모두 취소하는 기능이라고 배운다. BEGIN; UPDATE wallets SET balance_cents = balance_cents - 1000 WHERE id = 'sender-wallet'; UPDATE wallets SET balance_cents = balance_cents + 1000 WHERE id = 'receiver-wallet'; COMMIT; 두 UPDATE 가 모두 성공하면 변경사항을 확정하고, 중간에 문제가 발생하면 ROLLBACK 으로 되돌린다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 쿼리 개수보다 더 많은 판단이 필요하다. 어느 작업까지 같은 트랜잭션에 포함해야 하는가? 잔액 차감은 성공했는데 입금 기록 저장이 실패하면 어떻게 되는가? 트랜잭션 안에서 외부 결제 API나 이메일을 호출해도 되는가? 이미 전송된 알림도 롤백할 수 있는가? 같은 송금 요청이 다시 들어오면 중복 처리되지 않는가? 트랜잭션이 오래 유지되면 다른 요청에는 어떤 영향을 주는가? 데이터베이스가 여러 개라면 하나의 트랜잭션으로 묶을 수 있는가? 트랜잭션은 단순히 여러 쿼리를 묶는 기능이 아니다. 트랜잭션은 서비스가 하나의 비즈니스 작업으로 약속한 변경들이 함께 성공하거나 함께 실패하도록 일관성의 경계를 정의하는 방법이다. 먼저 쿼리가 아니라 하나의 업무가 어디까지인지 정해야 한다 크리스가 사용자끼리 잔액을 전송할 수 있는 전자지갑 서비스를 만든다고 생각해 보자. 사용자가 10달러를 송금하면 서비스는 최소한 다음 작업을 수행해야 한다. 보내는 지갑의 잔액 확인 ↓ 보내는 지갑에서 10달러 차감 ↓ 받는 지갑에 10달러 추가 ↓ 송금 기록 저장 ↓ 원장 기록 저장 각 작업은 별도의 쿼리로 구현될 수 있지만 사용자에게는 하나의 송금이다. 보내는 지갑의 잔액만 차감되고 받는 지갑에는 금액이 추가되지 않았다면 서비스는 송금을 일부만 처리한 것이다. 쿼리 몇 개가 성공했다는 사실보다 비즈니스 작업 전체가 유효한 상태로 끝났는지가 중요하다. 트랜잭션 없이 순서대로 처리하면 다음과 같은 코드가 만들어질 수 있다. await walletRepository.decreaseBalance({ walletId: senderWalletId, amountCents, }); await walletRepository.increaseBalance({ walletId: receiverWalletId, amountCents, }); await transferRepository.create({ senderWalletId, receiverWalletId, amountCents, status: "completed", }); 첫 번째 쿼리가 성공한 뒤 두 번째 또는 세 번째 쿼리가 실패하면 보내는 사용자의 돈만 줄어든 상태가 남을 수 있다. 개선된 코드는 송금의 핵심 변경을 같은 트랜잭션 안에서 처리한다. await database.transaction(async (tx) => { await tx.wallets.decreaseBalance({ walletId: senderWalletId, amountCents, }); await tx.wallets.increaseBalance({ walletId: receiverWalletId, amountCents, }); await tx.transfers.create({ senderWalletId, receiverWalletId, amountCents, status: "completed", }); }); 중간 작업이 실패하면 트랜잭션 안에서 수행된 변경을 확정하지 않는다. 중요한 것은 세 쿼리를 함께 실행했다는 사실이 아니다. 세 변경이 하나의 송금이라는 비즈니스 의미를 공유한다는 사실이다. 함께 성공해야 하는 데이터만 같은 경계에 들어가야 한다 송금이 완료되면 알림도 보내야 한다고 생각해 보자. await database.transaction(async (tx) => { await tx.wallets.decreaseBalance({ walletId: senderWalletId, amountCents, }); await tx.wallets.increaseBalance({ walletId: receiverWalletId, amountCents, }); await notificationService.sendTransferCompleted({ receiverUserId, amountCents, }); }); 이 코드는 데이터베이스 트랜잭션 안에서 외부 알림 서비스를 호출한다. 알림 API가 느리게 응답하면 데이터베이스 트랜잭션도 오래 열린 상태로 남는다. 알림은 전송되었지만 이후 데이터베이스 커밋이 실패할 수도 있다. 이 경우 사용자는 완료 알림을 받았지만 실제 송금은 저장되지 않는다. 데이터베이스의 ROLLBACK 은 이미 외부 서비스가 전송한 푸시 알림을 취소하지 못한다. 따라서 작업의 성격을 구분해야 한다. 작업 실패 시 송금을 취소해야 하는가? 일반적인 처리 위치 보내는 지갑 잔액 차감 예 데이터베이스 트랜잭션 받는 지갑 잔액 증가 예 데이터베이스 트랜잭션 송금 기록 생성 예 데이터베이스 트랜잭션 원장 기록 생성 예 데이터베이스 트랜잭션 알림 발송 아니오, 재시도 가능 커밋 이후 또는 비동기 작업 분석 이벤트 전송 아니오, 재시도 가능 커밋 이후 또는 비동기 작업 알림이 실패했다고 이미 완료된 송금을 되돌리는 것은 적절하지 않을 수 있다. 대신 송금은 확정하고 알림을 다시 시도할 수 있어야 한다. 트랜잭션 경계는 “한 함수에서 실행되는 모든 코드”가 아니다. 비즈니스 결과가 유효하려면 반드시 함께 확정되어야 하는 데이터 변경의 범위다. 검증은 트랜잭션 밖에서 끝나는 것이 아니라 경계 안에서도 다시 보장되어야 한다 송금 요청은 외부에서 들어오는 입력이다. 외부 입력은 검증 전까지 신뢰할 수 없다. import { z } from "zod"; const TransferInputSchema = z.object({ receiverWalletId: z.string().uuid(), amountCents: z.number().int().positive().max(1_000_000), idempotencyKey: z.string().uuid(), }); const input = TransferInputSchema.parse(request.body); 이 검증은 금액이 양수인지, 허용된 범위인지, ID 형식이 올바른지를 확인한다. 하지만 입력 형식이 올바르다고 송금이 가능한 것은 아니다. 보내는 지갑이 존재하는가? 받는 지갑이 존재하는가? 같은 지갑으로 송금하려는 것은 아닌가? 보내는 지갑이 정지 상태가 아닌가? 잔액이 충분한가? 현재 사용자가 보내는 지갑을 사용할 권한이 있는가? 일부 검사는 트랜잭션 전에 수행할 수 있다. if (senderWalletId === input.receiverWalletId) { throw new InvalidTransferError( "The sender and receiver must be different." ); } 그러나 잔액처럼 처리 중에 바뀔 수 있는 값은 실제 변경과 같은 트랜잭션 안에서 확인해야 한다. await database.transaction(async (tx) => { const senderWallet = await tx.wallets.findForUpdate(senderWalletId); if (!senderWallet) { throw new WalletNotFoundError(); } if (senderWallet.balanceCents < input.amountCents) { throw new InsufficientBalanceError(); } await tx.wallets.decreaseBalance({ walletId: senderWalletId, amountCents: input.amountCents, }); await tx.wallets.increaseBalance({ walletId: input.receiverWalletId, amountCents: input.amountCents, }); }); findForUpdate 는 개념적으로 송금이 완료될 때까지 해당 잔액을 변경 대상으로 읽는다는 의미다. 실제 구현은 사용하는 데이터베이스와 ORM에 따라 달라진다. 트랜잭션 밖에서 잔액을 확인한 뒤 나중에 차감하면 그 사이에 다른 요청이 같은 돈을 사용할 수 있다. 검증과 변경 사이에 데이터가 달라질 수 있기 때문이다. 검증이 필요한 위치는 규칙의 성격에 따라 달라진다. 문자열 형식과 금액 범위는 요청 경계에서 확인한다. 현재 사용자 권한은 도메인 작업을 시작하기 전에 확인한다. 변할 수 있는 잔액과 상태는 실제 변경과 같은 트랜잭션에서 확인한다. 음수 잔액 금지 같은 마지막 규칙은 데이터베이스 제약으로도 보호한다. 트랜잭션은 검증을 대신하지 않는다. 검증한 상태와 실제 변경 사이에 다른 작업이 끼어들어 서비스 규칙을 깨뜨리지 않도록 경계를 제공한다. 데이터베이스 제약 조건은 트랜잭션의 마지막 안전망이 된다 애플리케이션 코드에서 잔액을 확인하더라도 데이터베이스에 잘못된 값이 저장되지 않도록 제약 조건을 추가할 수 있다. CREATE TABLE wallets ( id UUID PRIMARY KEY, owner_id UUID NOT NULL, balance_cents BIGINT NOT NULL, status TEXT NOT NULL, CONSTRAINT wallets_non_negative_balance CHECK (balance_cents >= 0) ); 이 제약 조건은 어떤 코드 경로에서 지갑을 수정하더라도 음수 잔액이 커밋되지 않게 한다. 잔액 차감도 읽기와 쓰기를 분리하지 않고 조건부 변경으로 표현할 수 있다. UPDATE wallets SET balance_cents = balance_cents - $1 WHERE id = $2 AND status = 'active' AND balance_cents >= $1; 수정된 행이 없다면 다음 중 하나일 수 있다. 지갑이 존재하지 않는다. 지갑이 활성 상태가 아니다. 잔액이 부족하다. 애플리케이션은 결과를 확인하고 송금을 중단할 수 있다. const updated = await tx.wallets.decreaseBalanceIfAvailable({ walletId: senderWalletId, amountCents, }); if (!updated) { throw new TransferRejectedError(); } 예외가 발생하면 트랜잭션 전체가 롤백된다. 애플리케이션 검증은 사용자에게 구체적인 오류를 설명하고, 데이터베이스 제약 조건은 어떤 실행 경로에서도 깨져서는 안 되는 규칙을 보호한다. 둘은 서로 대체하는 것이 아니라 다른 위치에서 같은 서비스 약속을 지킨다. 원장과 현재 잔액의 책임을 분명히 해야 한다 전자지갑 서비스에는 현재 잔액과 금액 이동 기록이 함께 필요하다. CREATE TABLE wallet_ledger_entries ( id UUID PRIMARY KEY, transfer_id UUID NOT NULL, wallet_id UUID NOT NULL, entry_type TEXT NOT NULL, amount_cents BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL ); 하나의 송금에는 두 개의 원장 항목이 만들어질 수 있다. await tx.ledgerEntries.createMany([ { transferId, walletId: senderWalletId, entryType: "debit", amountCents, }, { transferId, walletId: receiverWalletId, entryType: "credit", amountCents, }, ]); 보내는 지갑에는 출금 기록이, 받는 지갑에는 입금 기록이 남는다. 문제는 wallets.balance_cents 와 원장 항목을 서로 다른 작업으로 저장할 때 발생한다. 잔액만 바뀌고 원장이 남지 않거나, 원장은 생겼지만 잔액이 바뀌지 않을 수 있다. await database.transaction(async (tx) => { await tx.wallets.decreaseBalance({ walletId: senderWalletId, amountCents, }); await tx.wallets.increaseBalance({ walletId: receiverWalletId, amountCents, }); await tx.transfers.create({ id: transferId, senderWalletId, receiverWalletId, amountCents, status: "completed", }); await tx.ledgerEntries.createMany([ { transferId, walletId: senderWalletId, entryType: "debit", amountCents, }, { transferId, walletId: receiverWalletId, entryType: "credit", amountCents, }, ]); }); 잔액, 송금 기록, 원장 기록을 하나의 경계에서 확정하면 서로 다른 데이터가 같은 비즈니스 사실을 표현하게 된다. 이때 Source of Truth도 명확히 정해야 한다. 예를 들어 원장 항목을 금액 이동의 Source of Truth로 정하고 wallets.balance_cents 를 빠른 현재 잔액 조회를 위한 값으로 사용할 수 있다. 이 경우 잔액은 원장 합계와 일치해야 하며, 정기적인 검증이나 복구 방법이 필요하다. SELECT wallet_id, SUM( CASE WHEN entry_type = 'credit' THEN amount_cents WHEN entry_type = 'debit' THEN -amount_cents END ) AS calculated_balance_cents FROM wallet_ledger_entries WHERE wallet_id = $1 GROUP BY wallet_id; 이 조회는 원장 기록으로 계산한 잔액을 보여준다. 트랜잭션은 두 저장 위치를 함께 변경할 수 있지만 어느 데이터가 원본인지 결정해 주지는 않는다. Source of Truth와 파생 값의 책임은 서비스가 별도로 정의해야 한다. 커밋은 데이터베이스 변경이 외부에 보이는 시점을 결정한다 트랜잭션 안에서 수행된 변경은 COMMIT 이 완료되기 전까지 최종 결과가 아니다. await database.transaction(async (tx) => { await tx.transfers.create({ id: transferId, status: "processing", }); await tx.wallets.decreaseBalance({ walletId: senderWalletId, amountCents, }); await tx.wallets.increaseBalance({ walletId: receiverWalletId, amountCents, }); await tx.transfers.update(transferId, { status: "completed", }); }); 함수 안에서는 여러 중간 상태가 만들어지지만 외부 요청은 일반적으로 커밋된 결과를 기준으로 데이터를 읽어야 한다. 트랜잭션 안에서 오류가 발생하면 다음 변경은 모두 확정되지 않는다. 송금 상태 processing 저장 보내는 잔액 차감 받는 잔액 증가 송금 상태 completed 변경 이 덕분에 다른 요청이 영구적으로 남은 불완전한 상태를 보는 일을 줄일 수 있다. 그러나 트랜잭션 안에서 어떤 중간 상태든 자유롭게 만들어도 된다는 뜻은 아니다. 트랜잭션이 너무 오래 유지되거나 많은 데이터를 변경하면 잠금과 리소스를 오래 점유할 수 있다. 다음 코드는 트랜잭션 안에서 불필요하게 긴 작업을 수행한다. await database.transaction(async (tx) => { await tx.wallets.decreaseBalance({ walletId: senderWalletId, amountCents, }); const riskResult = await externalRiskService.analyseTransfer({ senderWalletId, receiverWalletId, amountCents, }); if (!riskResult.approved) { throw new TransferRejectedError(); } await tx.wallets.increaseBalance({ walletId: receiverWalletId, amountCents, }); }); 외부 위험 분석 API가 10초 동안 응답하지 않으면 데이터베이스 자원도 그동안 유지될 수 있다. 가능하다면 외부 검사는 트랜잭션 전에 수행하고, 트랜잭션 안에서는 데이터베이스에서 반드시 함께 확정해야 하는 짧은 작업만 처리하는 편이 낫다. 단, 외부 검사 이후 내부 상태가 바뀔 수 있다면 트랜잭션 안에서 필요한 조건을 다시 확인해야 한다. 트랜잭션은 가능한 한 작게 유지하되 비즈니스 일관성을 보호하는 데 필요한 변경은 빠뜨리지 않아야 한다. 외부 시스템까지 데이터베이스 롤백으로 되돌릴 수는 없다 송금 이후 영수증 이메일을 보내는 코드를 생각해 보자. await database.transaction(async (tx) => { await completeTransfer(tx, transferInput); await emailService.sendTransferReceipt({ userId: senderUserId, transferId, }); }); 이메일이 성공한 뒤 데이터베이스 커밋이 실패하면 실제 송금 기록이 없는데 영수증이 전송될 수 있다. 반대로 먼저 커밋하고 이후 이메일을 보내면 애플리케이션이 중간에 종료되어 이메일 발송이 누락될 수 있다. await database.transaction(async (tx) => { await completeTransfer(tx, transferInput); }); await emailService.sendTransferReceipt({ userId: senderUserId, transferId, }); 두 작업은 서로 다른 시스템에 있으므로 하나의 일반적인 데이터베이스 트랜잭션으로 완전히 묶이지 않는다. 이 문제를 줄이기 위해 송금 데이터와 “이메일을 보내야 한다”는 이벤트를 같은 트랜잭션에 저장할 수 있다. CREATE TABLE outbox_events ( id UUID PRIMARY KEY, event_type TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL ); 송금과 이벤트를 함께 생성한다. await database.transaction(async (tx) => { await completeTransfer(tx, transferInput); await tx.outboxEvents.create({ id: crypto.randomUUID(), eventType: "transfer.completed", payload: { transferId, senderUserId, receiverUserId, amountCents, }, }); }); 트랜잭션이 커밋되면 송금과 이벤트가 함께 존재한다. 트랜잭션이 롤백되면 둘 다 존재하지 않는다. 별도의 작업자가 아직 처리되지 않은 이벤트를 읽어 알림을 전송한다. const events = await outboxRepository.findPending({ limit: 100 }); for (const event of events) { await notificationService.handle(event); await outboxRepository.markAsProcessed(event.id); } 작업자가 중간에 실패하면 이벤트를 다시 처리할 수 있다. 이때 알림 처리도 같은 이벤트 ID를 기준으로 중복을 견딜 수 있어야 한다. 아웃박스는 외부 작업을 데이터베이스 트랜잭션 안으로 강제로 넣는 방법이 아니다. 외부 작업이 필요하다는 사실을 트랜잭션 안에 남기고 실제 실행은 별도의 복구 가능
지금까지 배운 내용은 대략 다음과 같다. Day 1 CPU / ALU / Register / Fetch-Decode-Execute ↓ Day 2 Cache / Locality ↓ Day 3 RAM / Memory Address ↓ Day 4 Address / Data / Control Bus ↓ Day 5 I/O / Controller / Polling ↓ Day 6 Interrupt / Context 보존 내가 인터넷 창도열고, 동영상도 키고, 노래도 듣고, 카톡도하고 ... 여러가지가 동시에 실행되는것처럼 보이는데 CPU 는 정말 이것들을 전부 동시에 실행하는걸까? 우선, 내가 여러가지 프로그램들을 실행중이라고 하자. 크롬 동영상 노래 카톡 개발 ... DAY 1 에서 배운 CPU 의 기본 실행 방식은 다음과 같았다. PC ↓ Fetch ↓ Decode ↓ Execute ↓ 다음 명령어 어떤 순간에는, 특정 프로그램을 실행하고 있는것이다. 그런데, 어떻게 여러개의 프로그램이 동시에 움직이는것처럼 보이는걸까? 동영상을 보면서, 노래도 듣고, 동시에 카톡도하는것처럼 ... 여기서, Core / OS Scheduler / Context Switching 에 대한것들이 필요하다. CPU / CPU Core CPU ┌───────────────────────────┐ │ │ │ Core 1 Core 2 │ │ │ │ Core 3 Core 4 │ │ │ └───────────────────────────┘ CPU 안에 Core 는 여러개가 들어간다. 각 Core 는 명령어를 실행할수있다. CPU 안에 Core 가 4개가 있다고하면 ... Core 1 → Program A Core 2 → Program B Core 3 → Program C Core 4 → Program D 처럼 여러 실행프로그램들을 병렬로 실행할 수 있다. 우선, CPU Core 가 1개뿐이라고 가정하자. 실행할 프로그램이 3개가 있다고 하자. Program A Program B Program C Core 는 현재 1개이다. CPU Core ? Program A ────────┐ Program B ────────┼──→ CPU Program C ────────┘ CPU Core 가 1개이기때문에 프로그램 A / B / C 를 동시에 실행할 수 없다. 가장 단순하게는 아래와 같이 실행할 수 있을것이다. A 실행 ↓ B 실행 ↓ C 실행 ↓ A 실행 ↓ B 실행 ↓ ... 처럼 아주 빠르게 번갈아가면서 실행하는것이다. 그런데, A 실행하고 B 실행하고 하는것처럼, 어떻게 A -> B 로 바꾸는걸까? Program A 가 실행중이라고 해보자. CPU Core PC = 120 R1 = 10 R2 = 20 ... 다음과 같은 상태를 가질것이다. ( Context ) 이 상태에서 갑자기 B 를 실행하면 문제가 생긴다. Program B R1 = 500 R2 = 700 ... 그냥 B 를 실행한다면, A 의 상태가 사라지게될것이다. 따라서 먼저, B 를 실행하기이전에 A 의 상태를 저장해두어야한다. Program A PC = 120 R1 = 10 R2 = 20 ↓ 저장 그리고, Program B 에 대한 이전상태가 있었다면 CPU 에 복원해놓는다. Program B PC = 300 R1 = 500 R2 = 700 ↓ CPU에 복원 이후, Prgoram B 를 실행한다. 즉, 다음과 같은 실행과정을 거친다. CPU Program A 실행 ↓ A 상태 저장 ↓ B 상태 복원 ↓ Program B 실행 이게, Context Switching 의 핵심 아이디어이다. DAY 6 에서 배웠던 Interrupt 를 다시한번 생각해보자. 어떤 이벤트가 발생했을때, CPU 에게 알려주는 역할이었다. 다시 돌아와서, Program A 를 실행하고 있는데 언제 A 를 멈추고 B 를 실행해야 하는걸까? A 가 위를 판단한다고 하자. OS: "적당히 쓰고 CPU 돌려줘." Program A: "ᄋᄏ." while (true) { } A 가 계속해서 CPU 를 사용할것이다. 이때 Timer Interrupt 가 등장한다. Program A 실행 AAAAAAAAAAAAAAAA ↓ Timer Interrupt! ↓ Operating System OS 가 CPU 제어권을 다시 얻을 기회를 얻는다. 그리고나서, OS 가 A 를 계속해서 실행할지 / B 를 실행할지를 판단한다. B 를 실행하기로 판단했다면, A Context 저장 ↓ B Context 복원 ↓ B 실행 전체 흐름은 다음과 같다. Single Core 라고하자. 이후, Program A / Program B 가 존재한다. CPU Core Program A Program A 를 실행한다. Fetch ↓ Decode ↓ Execute Timer Interrupt OS 가 실행이 된다. Program A ↓ Interrupt ↓ OS A 를 계속해서 실행할까 ? / B 를 실행해야하나? 를 OS 가 판단한다. ( 이런 판단들을 담당하는게 Scheduler 이다. ) A 상태(Context) 보관 B 를 실행하기로 맘먹었고, B 를 실제로 실행하기 이전에 실행중이던 Program A 에 대한 상태 ( Context ) 를 보관해놔야한다. A Context PC Register 기타 필요한 실행 상태 B의 Context 복원 이전에 B가 실행이 된적이 있다면, B 가 어디까지 실행했는지에 대한 상태 ( Context ) 가 있을것이다. B Context ↓ CPU에 복원 B를 실행 이제, CPU Core가 B 를 실행한다. CPU Core Program B Fetch ↓ Decode ↓ Execute 이후, 어느정도 시간이 지난뒤에 다시 Timer Interrupt 가 발생한다. B ↓ Interrupt ↓ OS ↓ 다음 실행 대상 결정 이후, 위와같은 과정들을 반복한다. 즉, 실제로는 여러개의 프로그램들이 동시에 돌아가는것이 아니라 사실은 엄청빠르게 프로그램들이 번갈아가면서 실행되고 있었기때문에 사람이 느끼기에는 동시에 실행되는것처럼 느낀것이다. 즉, Single Core 에서는 병렬로 실행되는것이아닌 번갈아가면서 실행됐던것이다. Single Core -> 동시성 (Concurrency) Multie Core -> 병렬성 (Parallelism) Context Switching 은 공짜가 아니다. A 에서 B 로 바꾸려면, A 실행 ↓ A 상태 저장 ↓ B 상태 복원 ↓ B 실행 를 해야한다. 저장하고 복원하고 다시 실행하는데에까지는 걸리는 시간이 있을것이다. ( 매우작지만 .. ) 우리가 매우 작다고 느끼는시간도 CPU 입장에서는 굉장히 큰 시간으로 느껴질수 있다. ( 연산속도가 압도적으로 빠르기때문에 ) 즉, Context Switching 에는 비용이 들어간다. CPU Core 수는 정해져있으므로, OS 는 여러 실행 작업에 CPU 작업시간을 나누어준다. 이후, 실행대상을 변경할때 기존 Context 를 저장하 새로 실행할 프로그램의 Context 복원하고 실행하게된다. 여러 Core 가 있다면 병렬적으로 처리하겠지만 싱글 Core 라면 Context Switching 에 대한 비용이 들어간다. Single Core 동시성(Concurrency) CPU Core Program A ───────┐ Program B ───────┤ Program C ───────┘ ↑ Scheduler 시간 → A A A | B B | C C | A A | B B ↑ Context Switching Multie Core 병렬성(Parallelism) Core 가 여러개이므로 ... Core 1 → AAAAAAAAAAAAA Core 2 → BBBBBBBBBBBBB Core 3 → CCCCCCCCCCCCC Core 4 → DDDDDDDDDDDDD 병렬로 실행된다. 동시성은, 여러 작업이 일정 시간 범위내에서 함께 진행되도록 실행하는것이다. Single Core 에서도 Context Switching 과 같은 흐름으로 구현할수있다. 병렬성은 여러 Core 들이 여러작업이 실제 같은 시점에 실행되는것이다.
정찬용의 《영어공부 절대로 하지 마라》에서 내가 가져오고 싶은 핵심 언어를 문법책으로 먼저 배우지 말고, 소리와 의미를 먼저 연결한다. 이를 일본어에 적용하면 어떻게 될까? 일본어는 한국인에게 상당히 특이한 외국어다. 영어나 러시아어보다 문장 구조가 한국어와 훨씬 비슷하기 때문이다. 1. 일본어는 한국인이 시작하기 쉬운 언어다 한국어: 나는 오늘 친구와 밥을 먹는다. 일본어: 私は今日友達とご飯を食べる。 대략적인 구조가 상당히 비슷하다. 나 + 는 私 + は 밥 + 을 ご飯 + を 먹는다 食べる 그래서 영어처럼 문장을 완전히 다른 순서로 재조립할 필요가 상대적으로 적다. 초반 일본어 공부에서는 복잡한 문법 설명보다 소리 기본 표현 어휘 문자 를 빠르게 연결하는 것이 중요하다. 2. 처음에는 일본어 소리를 많이 듣는다 예를 들어 다음 문장을 처음 접했다고 하자. これは何ですか? 처음부터 これ = 이것 は = 조사 何 = 무엇 です = 입니다 か = 의문 라고 분석하지 않는다. 먼저 코레와 난데스카? 라는 하나의 소리 패턴으로 듣는다. 그리고 실제 상황과 연결한다. 누군가 물건을 가리키면서 これは何ですか? 라고 한다면 '저 사람이 저게 뭔지 물어보고 있구나.' 라는 의미를 바로 연결한다. 목표는 일본어 → 한국어 번역 → 의미 가 아니라 일본어 → 의미 가 되는 것이다. 3. 짧은 음성을 반복한다 처음부터 애니메이션 한 편이나 드라마 한 편을 통째로 공부할 필요는 없다. 30초~2분 정도의 짧은 자료 하나면 충분하다. 순서는 다음처럼 한다. 자막 없이 듣는다. 아는 표현을 찾아본다. 상황을 보고 의미를 추측한다. 일본어 자막을 확인한다. 다시 듣는다. 따라 말한다. 중요한 것은 반복 횟수 자체가 아니다. 처음에는 코레와난데스카 처럼 하나의 소리 덩어리였던 것이 これは / 何 / ですか 처럼 분리되기 시작하는 것이 중요하다. 4. 히라가나는 빨리 배우되, 문자 공부에 갇히지 않는다 일본어를 배우려면 히라가나와 가타카나는 결국 알아야 한다. 하지만 히라가나 100% 암기 완료 → 가타카나 100% 암기 → 그 다음 회화 처럼 진행할 필요는 없다. 예를 들어 ありがとう 를 보면서 ありがとう 아리가토 고마워 를 동시에 연결한다. 문자를 독립적으로 암기하기보다 실제 표현과 같이 익힌다. 히라가나는 초기에 빠르게 읽을 수 있는 정도까지 만들고 바로 듣기와 병행한다. 5. 일본어는 '단어'보다 '표현 덩어리'를 배운다 예를 들어 食べる = 먹다 하나만 외우는 것보다 ご飯を食べる 밥을 먹다 何食べる? 뭐 먹을래? 一緒に食べる? 같이 먹을래? 처럼 배우는 것이 좋다. 실제 대화에서는 단어 하나씩 조립해서 문장을 만드는 경우보다 이미 익숙한 표현 덩어리를 가져오는 경우가 많기 때문이다. 6. 문법은 패턴을 발견한 뒤 정리한다 예를 들어 계속 일본어를 듣다 보면 行きます 食べます 見ます 帰ります 처럼 ~ます 가 반복해서 등장한다. 그때 문법책을 보면 아, ~ます가 정중한 표현이구나. 라고 이해한다. 즉 문법 → 예문 보다 예문 → 반복 → 패턴 발견 → 문법으로 정리 순서를 사용한다. 문법은 언어의 출발점이라기보다 이미 경험한 언어를 정리하는 설명서에 가깝게 사용한다. 7. 일본어에서 진짜 장기전은 한자다 일본어는 초반 문법보다 한자가 더 큰 장벽이 될 가능성이 높다. 하지만 한자 역시 글자 하나씩 외우는 것보다 단어 속에서 학습한다. 예를 들어 食 = 먹을 식 이라고만 외우지 않는다. 食べる 食事 食品 처럼 실제 단어를 통해 익힌다. 한자의 의미와 소리가 실제 표현 속에서 반복되면서 자연스럽게 연결되도록 만든다. 8. 하루 30분 공부한다면 10분 30초~2분 일본어 음성을 반복해서 듣는다. 5분 일본어 자막이나 스크립트를 확인한다. 5분 중요한 표현 3~5개를 정리한다. 5분 음성을 따라 말한다. 5분 자료를 보지 않고 오늘 배운 표현을 말해본다. 9. 일본어 공부의 핵심 일본어는 한국어와 어순이 비슷하기 때문에 초반부터 문법을 깊게 파기보다 입력량을 빠르게 늘리는 전략이 잘 맞는다. 내가 일본어를 처음 시작한다면 우선순위를 이렇게 둘 것 같다. 듣기 → 기본 표현 → 문자 → 어휘 → 한자 → 문법 정리 그리고 가장 중요한 목표는 이것이다. 일본어를 들었을 때 한국어로 번역하지 않고 상황과 의미가 바로 떠오르게 만드는 것. 정찬용식 접근을 일본어에 적용한다면 결국 많이 듣고 → 확인하고 → 따라 말하고 → 반복해서 다시 만나는 것 이 핵심이라고 생각한다. ```
개요 실제 수정에 들어가기 전 prod, dev 를 어떻게 분리해서 개발하는지 대략적으로 정리해놓겠습니다 !! 백엔드 구성 프론트는 Vercel에 올렸고 백엔드는 EC2와 RDS로 따로 운영했습니다. 공모전 프로젝트라 비용은 줄이도록 노력하면서 운영 서버가 개발 작업의 영향을 받지 않도록 서버 단위로 분리하는 것을 목표로 잡았습니다. prod와 dev 서버를 EC2로 분리 처음에는 비용을 아끼려고 EC2 한 대에 prod와 dev 컨테이너를 함께 띄우는 방식을 고려했습니다. 하지만 컨테이너는 프로세스만 격리할 뿐 메모리, 디스크, CPU는 호스트를 공유합니다. dev에서 메모리를 과하게 쓰거나 로그로 디스크가 차면 prod까지 영향을 받고 서버 자체가 장애를 겪으면 둘 다 내려가기 때문에 서버를 분리했습니다. prod : ALB 뒤에 Auto Scaling이 적용된 EC2를 구성하고 각 인스턴스에서는 prod 컨테이너만 실행합니다. ALB에서 HTTPS 인증서와 도메인 연결을 처리하며 트래픽 증가에 따라 prod EC2가 자동으로 확장되도록 구성했습니다. dev : 트래픽이 거의 없으니 작은 인스턴스 한 대로 충분합니다. dev 서버는 외부에 공개할 필요가 없어서 보안 그룹에서 팀원 IP만 허용했습니다. Vercel Preview 프론트가 dev API를 호출할 때 요청은 Vercel 서버가 아니라 그 페이지를 열어 둔 사람의 브라우저에서 나갑니다. 그래서 팀원이 자기 노트북으로 Preview 페이지를 열면 그 노트북의 공인 IP가 서버에 도착하고, 등록된 IP면 통과합니다. Vercel 쪽 IP를 따로 허용할 필요는 없습니다. 서버가 나뉘었기 때문에 컨테이너마다 포트를 다르게 잡을 필요가 없고 이미지는 하나로 유지하되 서버마다 env만 다르게 주입합니다. 컨테이너에는 로그 크기 제한( max-size , max-file )을 걸어 디스크가 차는 일을 막았습니다. DB는 RDS 1개, 데이터베이스 2개 서버는 분리했지만 DB는 비용을 고려해 RDS 인스턴스 1개 안에 padong_prod 와 padong_dev 두 개의 데이터베이스 를 만드는 방식으로 갔습니다. 또한 DB 이름뿐 아니라 계정도 분리했습니다. prod_user 는 prod DB에만 dev_user 는 dev DB에만 권한을 줬기 때문에 dev 설정을 잘못 넣어도 운영 데이터를 건드릴 수 없습니다. 인스턴스를 공유하는 만큼 dev에서 무거운 쿼리를 돌리면 prod DB도 느려질 수 있다는 단점이 있습니다. 공모전 규모에서는 느려지지는 않을 거라고 생각했고 자동 백업을 켜고 RDS 접근은 EC2 보안 그룹에서 오는 요청만 허용했습니다. GitHub Secrets + Environments로 시크릿 관리 DB 주소와 계정, 비밀번호는 저장소에 두지 않고 GitHub Secrets로 관리했습니다. Settings의 Environments에 dev 와 prod 를 만들고 같은 이름의 시크릿에 환경별로 다른 값 을 넣었습니다. GitHub Actions가 실행되는 환경에 따라 같은 secrets.DB_URL 이 다른 값으로 풀리기 때문에, 워크플로 하나로 두 환경을 모두 배포할 수 있습니다. prod 환경에는 배포 승인자(Required reviewers)를 걸어 실수로 운영에 배포되는 것을 막았습니다. dev 브랜치 push → Actions(environment: dev) → dev EC2 → padong_dev main 브랜치 push → Actions(environment: prod) → prod EC2 → padong_prod
오늘의 인프런 강의 어제 Body Mesh의 손 부분 수정과 특정 애니메이션의 팔꿈치 틀어짐 현상 해결에 생각보다 많은 시간이 소요되어 프로젝트 개발 일정이 지연되었다. 이에 오늘은 강의 수강을 생략하고 프로젝트 개발에 집중하였다. 오늘의 프로젝트 개발 -> 링크 방어구 시스템 구현 방패 시스템 구현
일본어와 러시아어에 이어 스페인어를 생각해보자. 세 언어 중 정찬용식 듣기 → 의미 → 표현 → 문법 이라는 방식이 가장 자연스럽게 적용될 수 있는 언어 중 하나가 스페인어라고 생각한다. 스페인어는 철자와 발음의 대응이 비교적 규칙적이고 실제로 소리 내어 읽기 시작하기도 어렵지 않다. 하지만 대신 다른 문제가 있다. 바로 동사 변화다. 1. 스페인어는 처음부터 소리를 적극적으로 사용한다 예를 들어 ¿Cómo estás? 라는 표현을 처음 배운다고 하자. 처음부터 cómo = 어떻게 estar = ~한 상태이다 estás = estar의 2인칭 단수형 이라고 분석하기보다 ¿Cómo estás? 라는 표현 자체를 상대방의 상태를 물어보는 표현 으로 먼저 익힌다. 실제 상황에서 여러 번 듣다 보면 ¿Cómo estás? 라는 소리를 듣는 순간 '잘 지내냐고 묻는구나.' 라는 의미가 바로 떠오르게 된다. 2. 스페인어는 읽기와 듣기를 빠르게 연결할 수 있다 스페인어의 장점은 철자와 발음의 관계가 영어보다 규칙적이라는 점이다. 그래서 기본적인 발음 규칙을 익히고 나면 처음 보는 단어도 어느 정도 읽을 수 있다. 예를 들어 Gracias Amigo Casa Comida 같은 단어들을 음성과 같이 접한다. 스페인어에서는 문자 공부를 끝낸 다음 듣기 가 아니라 글자를 보면서 소리를 계속 확인하기 가 효율적이다. 3. 발음은 초반에 확실하게 잡는다 스페인어는 글자를 읽기는 비교적 쉽지만 몇몇 소리는 한국어와 다르다. 대표적으로 r rr j ll ñ 같은 발음을 접하게 된다. 그리고 스페인, 멕시코, 아르헨티나 등 지역에 따라 발음 차이도 존재한다. 초보 단계에서는 모든 지역 발음을 다 배울 필요는 없다. 한 지역의 음성을 기준으로 삼아 듣고 따라 하는 것이 좋다. 처음에는 소리를 정확하게 구별하고 흉내내는 능력 을 만드는 데 집중한다. 4. 단어보다 문장을 배운다 예를 들어 querer = 원하다 하나만 암기한다고 하자. 실제 대화를 하려면 또 문장을 만들어야 한다. 그래서 처음부터 Quiero café. 커피를 원한다. Quiero comer. 먹고 싶다. Quiero ir. 가고 싶다. 처럼 배운다. 그러면 Quiero ___. 라는 하나의 패턴을 얻는다. 새로운 단어를 배우면 그 자리에 넣으면 된다. 5. 스페인어에서 가장 큰 문제는 동사 변화다 스페인어를 배우기 시작하면 곧 이런 형태를 만나게 된다. hablo hablas habla hablamos hablan 모두 hablar에서 나온 형태다. 처음부터 이 변화표를 전부 외울 수도 있다. 하지만 정찬용식으로 접근한다면 먼저 실제 표현을 접한다. Hablo español. ¿Hablas inglés? Ella habla español. 이 문장을 반복해서 듣는다. 그리고 왜 hablar가 계속 달라지지? 라는 질문이 생겼을 때 동사 활용을 공부한다. 6. 문법은 패턴을 압축해주는 도구다 문법을 공부하는 이유를 다르게 생각할 수 있다. 문법은 외워야 하는 규칙 목록이 아니라 수십 개의 문장에서 발견한 공통점을 하나의 규칙으로 압축하는 도구 다. 예를 들어 hablo como vivo 라는 표현을 계속 듣다가 1인칭 단수에서는 이런 형태가 나타난다는 것을 발견한다. 그때 활용 규칙을 보면 실제 경험했던 표현들이 하나의 구조로 정리된다. 7. 스페인어는 처음부터 말하기를 많이 해도 좋다 스페인어는 초반부터 기본적인 문장 패턴을 이용해서 상당히 많은 말을 만들 수 있다. 예를 들어 Quiero ___. Me gusta ___. Tengo ___. Voy a ___. No sé ___. 같은 표현 몇 개만 확보해도 수많은 문장을 만들 수 있다. 따라서 입력만 하는 것이 아니라 배운 패턴으로 즉시 문장을 만들어보는 것이 좋다. 예를 들어 Quiero comer. 를 배웠다면 Quiero dormir. Quiero estudiar. Quiero viajar. 처럼 바꿔본다. 8. 쉐도잉과 직접 말하기를 함께 한다 스페인어는 리듬이 중요한 언어다. 단어 하나씩 또박또박 읽는 것과 실제 스페인어 발화는 상당히 다르게 들릴 수 있다. 그래서 듣기 → 따라 말하기 → 음성과 동시에 말하기 순서로 연습한다. 그리고 마지막에는 음성을 끈다. 배운 내용을 보지 않고 직접 말해본다. 9. 스페인어 하루 30분 루틴 10분 짧은 스페인어 콘텐츠 반복 듣기 5분 스크립트 확인 5분 핵심 표현 3~5개 학습 5분 쉐도잉 5분 배운 문장 패턴을 이용해 새로운 문장 만들기 10. 스페인어에서 특히 중요한 것 스페인어에서는 문법책 한 권을 처음부터 끝까지 외우는 것보다 자주 사용하는 동사와 문장 패턴을 먼저 확보하는 것이 좋다. 예를 들어 초반에는 ser estar tener ir hacer querer poder gustar 같이 자주 등장하는 동사를 실제 문장에서 반복해서 접한다. 그리고 활용 형태를 조금씩 정리한다. 11. 콘텐츠를 많이 접하기 좋은 것도 장점이다 스페인어는 여러 국가에서 사용되기 때문에 사용할 수 있는 콘텐츠가 굉장히 많다. 유튜브 드라마 영화 음악 팟캐스트 뉴스 인터뷰 브이로그 등을 이용할 수 있다. 처음부터 어려운 콘텐츠를 선택할 필요는 없다. 핵심은 내가 내용을 어느 정도 추측할 수 있는 쉬운 자료 를 많이 반복해서 접하는 것이다. 12. 스페인어 공부의 핵심 내가 스페인어를 처음 시작한다면 다음 순서를 사용할 것 같다. 발음 → 기본 표현 → 반복 듣기 → 문장 패턴 → 동사 변화 → 실제 콘텐츠 스페인어 역시 문법은 중요하다. 하지만 문법을 먼저 외운 뒤 말을 만드는 것 보다 실제 표현을 먼저 사용하고 문법으로 구조를 정리하는 것 이 더 자연스럽다. 결국 목표는 동일하다. ¿Cómo estás? 라는 말을 들었을 때 cómo는 무엇이고 estar의 활용이 무엇인지 분석하는 것이 아니라 상대가 내 상태를 묻고 있다는 의미가 바로 떠오르는 것. 그 상태가 외국어를 실제 언어로 받아들이기 시작하는 지점이라고 생각한다. ```
(출처: arXiv:2609.35069, Figure 1) RenderRank: Learning to Rerank Text with Compressed Visual Tokens Seongtae Hong, Youngjoon Jang, Jungseob Lee, Hyeonseok Moon, Heuiseok Lim 고려대학교 NLP & AI Lab / 숙명여자대학교 (교신저자: Heuiseok Lim) 공개일: 2026년 9월 28일 · arXiv 분류: cs.IR arXiv: https://arxiv.org/abs/2609.35069 코드/모델: 논문 본문에 공개 링크 없음 (2026-09-30 기준) 태그: Reranking Vision-Language Model Visual Text Compression RAG Information Retrieval 🔖 TL;DR (한눈에) 문서를 텍스트 토큰이 아니라 "이미지로 렌더링한 뒤 시각 토큰"으로 넣어 리랭킹하는 리랭커를 제안한다. 리랭킹은 질의 1건당 후보 문서 수십 개를 각각 채점하므로, 문서당 토큰 절감이 후보 수만큼 곱해져 효율 이득이 크다는 점을 공략한다. 학습은 2단계다. 1 텍스트 teacher의 관련도 점수를 시각 입력으로 옮기는 CMRD , 2 같은 질의 안에서 정답/오답 문서의 상대 순서를 다듬는 QLRD . BEIR 11개 데이터셋에서 평균 NDCG@10 55.96 , 입력 토큰은 텍스트 기반 베이스라인보다 16.5~35.5% 적다 . 4B 미만 베이스라인은 전부 앞섰다. 롱 도큐먼트 4종에서는 평균 NDCG@10 88.27 (표 내 최고)에 토큰은 약 절반(4,637.7 vs 10,060.9), 처리량은 최고 베이스라인의 1.70배 . 한 줄 요약 : 문서를 읽히기 좋은 텍스트가 아니라 "보기 좋은 이미지"로 바꿔 넣으면, 2.1B 모델로도 4B급 리랭커에 근접한 정확도를 절반 수준의 토큰으로 낼 수 있다. 📄 초록(Abstract) 완역 문서 텍스트를 이미지로 렌더링하면 비전-언어 모델이 문서를 시각 토큰으로 인코딩할 수 있게 되고, 이는 텍스트 입력에 비해 입력 시퀀스 길이를 줄일 수 있다. 이러한 입력 길이 감소는 리랭킹에서 특히 유용한데, 리랭킹에서는 하나의 질의마다 여러 후보 문서를 채점해야 하므로 토큰 절감이 후보 문서 평가마다 적용되기 때문이다. 우리는 RenderRank를 제안한다. 이는 기존 텍스트 기반 리랭커가 사용하는 텍스트 토큰 시퀀스 대신, 압축된 시각적 문서 표현으로부터 질의 의존적 관련도 점수를 학습하는 리랭커다. 학습은 먼저 시각 입력에서 얻은 관련도 점수를 텍스트 기반 teacher의 점수에 정렬시키고, 이후 동일한 질의에 대한 정답 문서와 오답 문서의 상대적 점수를 정교화한다. BEIR의 11개 데이터셋에 걸쳐 RenderRank는 입력 토큰을 16.5~35.5% 더 적게 사용하면서 평균 NDCG@10 55.96을 달성했으며, 이는 평가한 4B 파라미터 미만의 모든 텍스트 기반 베이스라인과 일부 더 큰 모델을 능가한다. 4개의 롱 도큐먼트 데이터셋에서는 평가한 텍스트 기반 리랭커들의 평균 입력 토큰 수의 약 절반으로 평균 NDCG@10 88.27을 달성했다. 이 설정에서 RenderRank는 평가한 베이스라인들의 최고 평균 처리량 대비 1.70배의 처리량을 제공한다. 이러한 결과는 압축된 시각적 표현이 정확한 문서 관련도 채점을 뒷받침할 수 있으며, 리랭킹에서 텍스트 토큰 표현을 대체할 수 있는 대안이 됨을 보여준다. 초록을 짧게 정리하면 이렇다. 리랭킹의 비용 구조는 "질의 1개 × 후보 문서 N개"라서, 문서 하나를 줄이는 효과가 N배로 증폭된다. 저자들은 문서를 이미지로 렌더링해 비전 인코더의 패치 병합으로 압축하면 같은 내용을 더 적은 토큰에 담을 수 있다고 보고, 이를 리랭커 학습으로 직접 연결했다. 2단계 학습으로 절대적 점수 보정과 상대적 순서 판별을 나눠 학습하며, 그 결과 2.1B 크기로 BEIR 평균 55.96, 롱 도큐먼트 평균 88.27을 기록했다. 🧩 왜 이 문제가 중요한가 (배경) RAG 파이프라인에서 1차 검색기(BM25, 임베딩 검색 등)는 재현율을 위해 후보를 넉넉히 뽑는다. 그 후보들의 실제 관련도는 편차가 크기 때문에 리랭커가 순서를 다시 매긴다. 문제는 여기서 발생하는 비용이다. 리랭커는 (질의, 문서) 쌍을 하나씩 모델에 통과시키므로, 후보가 50개면 forward pass도 50번이다. 문서 하나가 길면 그 길이가 50번 반복해서 청구된다. 현장에서는 보통 두 가지로 타협한다. 문서를 앞부분만 잘라 넣거나(그러면 관련도 판단에 필요한 근거가 잘려 나갈 수 있다), 전체를 넣되 비용을 감당하거나(후보 수와 문서 길이에 비례해 비용이 누적된다). 논문의 표현을 빌리면 "이 비용들은 검색된 후보 전체에 걸쳐 누적된다". RenderRank가 노리는 지점은 이 트레이드오프 자체를 우회하는 것이다. 문서를 텍스트 토큰으로 넣지 않고 이미지로 렌더링해 시각 토큰으로 넣으면, 비전 인코더의 패치화와 공간 병합 덕분에 같은 글자 수가 더 적은 토큰으로 압축된다. 텍스트를 이미지로 렌더링해도 내용이 보존된다는 선행 연구(Rust et al. 2022; Lyu et al. 2025), 폰트 크기·행간·레이아웃이 정보 보존에 영향을 준다는 분석(Tang et al. 2026), 생성 태스크에서의 토큰 절감 결과(Li et al. 2025c; Cheng et al. 2026)가 이미 있었지만, 리랭킹에 체계적으로 적용한 것은 이 논문이 처음이라고 저자들은 주장한다. 기존 멀티모달 리랭킹 연구와의 차이도 분명히 한다. 이미지-텍스트 리랭킹(Taraday et al. 2026)이나 문서 페이지 이미지 리랭킹(Sun et al. 2026)은 애초에 이미지 형태로 주어진 입력을 다룬다. 반면 RenderRank는 원래 텍스트로 주어진 문서를 일부러 렌더링해서 입력 길이를 줄이는 쪽에 초점을 둔다. 🔬 방법론 렌더링: 문서를 어떻게 그리는가 렌더링 설정은 비전 인코더의 패치 구조에 맞춰 정해졌다. 폰트: Roboto Regular 12pt, 행간 1.0 해상도: 96 DPI, 너비 896px 고정, 높이 최대 896px(32px 단위로 조정) 흰 배경에 검은 글자, 여백 없음, 이미지 너비에서 자동 줄바꿈 비전 인코더의 16×16 패치 + 2×2 공간 병합에 맞춘 크기 긴 문서는 여러 장의 이미지로 렌더링하되 문서 순서를 유지하고 겹침 없이 이어 붙여 하나의 후보 문서로 처리 한다. 이 설정에서 BEIR 기준 (질의, 문서) 쌍당 평균 입력 토큰은 290.07개 다. 백본과 학습 가능한 부분 백본은 Qwen3-VL-Reranker-2B에서 초기화한 2.1B 모델이다. 비전 인코더와 시각 특징 merger는 동결(frozen) 하고, 텍스트 디코더에만 LoRA 를 붙여 학습한다. 시각 표현 추출기는 그대로 두고 "그 표현을 관련도 점수로 바꾸는 부분"만 학습한다는 설계다. 1단계 — CMRD (Cross-Modal Relevance Distillation) 시각 입력으로 매긴 점수를 텍스트 teacher의 점수에 맞추는 회귀 학습이다. $$\mathcal{L} {CMRD} = \mathbb{E} {(q,d)\sim D}\left[\left(s_\theta(q, R(d)) - t(q,d)\right)^2\right]$$ 여기서 $s_\theta(q, R(d))$는 질의 $q$와 렌더링된 문서 이미지 $R(d)$에 대한 student 점수, $t(q,d)$는 텍스트 쌍에 대한 teacher 점수다. Teacher는 Qwen3-Reranker-4B . 이 손실의 핵심 직관은 점수 수준(score-level) 증류 라는 점이다. 텍스트 토큰과 시각 토큰은 개수도 정렬도 다르므로 토큰 단위 대응을 잡을 수 없다. 그런데 "이 질의에 이 문서가 얼마나 관련 있나"라는 스칼라 값은 모달리티와 무관하게 비교 가능하다. 그래서 토큰 정렬 없이도 크로스모달 전이가 성립한다. 학습 데이터는 영어 파인튜닝 코퍼스 157만 건이며, 질의마다 정답 1개와 오답 3개를 골라 628만 개의 (질의, 문서) 쌍 을 만들었다. 2단계 — QLRD (Query-Local Relevance Discrimination) 1단계가 점수의 절대적 눈금을 맞추는 단계라면, 2단계는 같은 질의 안에서의 상대적 순서 를 다듬는다. 질의별 후보 집합으로 범위를 제한한 InfoNCE다. $$\mathcal{L} {QLRD} = -\frac{1}{N}\sum {i} \log \frac{\exp\left(s_\theta(q_i, R(d_i^+))\right)}{\exp\left(s_\theta(q_i, R(d_i^+))\right) + \sum_{j=1}^{K}\exp\left(s_\theta(q_i, R(d_{i,j}^-))\right)}$$ 오답 수는 $K=3$, 데이터는 RLHN-100K를 쓴다. 리랭킹 성능 지표(NDCG@10)는 결국 순위 지표이므로, 절대 점수가 teacher와 잘 맞아도 같은 질의 내 순서가 흔들리면 손해다. 2단계가 그 부분을 직접 겨냥한다. 단계 전환에는 ReLoRA의 merge-and-reinitialize 방식을 쓴다. 1단계 LoRA 업데이트를 백본에 병합한 뒤 새 어댑터를 초기화해 2단계를 시작한다. 두 단계 모두 비전 인코더와 merger는 계속 동결 상태다. 추론 시 전제 처리량 측정은 NVIDIA A6000 48GB에서, 배치 크기를 8부터 OOM 직전까지 두 배씩 올려 최고 처리량 지점을 보고한다. 중요한 전제가 하나 있다. 선행 연구의 관례에 따라 문서 이미지의 시각 임베딩이 미리 계산되어 있다고 가정 한다. 코퍼스가 고정된 검색 시스템에서는 합리적인 가정이지만, 캐시가 없으면 처리량이 절반으로 떨어진다(뒤의 ablation 참조). 📊 실험 결과 BEIR 11개 데이터셋 (NDCG@10) 모델 파라미터 평균 NDCG@10 평균 입력 토큰 BM25 – 41.71 – gte-reranker-modernbert-base 150M 53.20 359.25 mxbai-rerank-large-v1 435M 48.73 347.45 bge-reranker-large 560M 49.87 409.32 LAMAR-600m 560M 55.19 409.32 Qwen3-Reranker-0.6B 600M 54.56 438.70 llama-nemotron-rerank-1b-v2 1.2B 54.27 363.99 mxbai-rerank-large-v2 1.5B 47.15 449.76 LightOn-rerank-PW-2B 2.2B 50.42 416.22 bge-reranker-v2-gemma 2.5B 55.82 399.11 Qwen3-Reranker-4B (teacher) 4B 57.99 438.70 zerank-2-reranker 4B 51.98 380.69 LightOn-rerank-PW-4B 4.5B 52.17 416.22 RenderRank (제안) 2.1B 55.96 290.07 읽는 포인트는 세 가지다. 첫째, 토큰 절감폭이 초록의 수치와 정확히 맞는다. 290.07을 베이스라인 중 가장 적은 347.45와 비교하면 16.5% 절감, 가장 많은 449.76과 비교하면 35.5% 절감이다. 둘째, 4B 미만 구간에서는 RenderRank(55.96)가 최고다. 같은 구간 2위인 bge-reranker-v2-gemma(2.5B, 55.82)를 0.14점 앞선다. 더 큰 모델 중에서도 zerank-2-reranker(4B, 51.98)와 LightOn-rerank-PW-4B(4.5B, 52.17)를 앞선다. 셋째, teacher인 Qwen3-Reranker-4B(57.99)에는 2.03점 뒤진다. 시각 압축이 공짜가 아니라는 뜻이며, 논문도 이를 teacher 초과가 아니라 "절반 크기·2/3 토큰으로 근접"이라는 프레임으로 제시한다. 데이터셋별로 보면 강점과 약점이 갈린다. FEVER 88.92, TREC-COVID 84.82, HotpotQA 78.60처럼 teacher와 거의 붙는 항목이 있는 반면, ArguAna는 69.11로 teacher 75.40 대비 6.29점, Touché-2020은 34.27로 teacher 38.77 대비 4.50점 뒤진다. 논증 검색처럼 미세한 어휘 단서가 중요한 태스크에서 손실이 더 크게 나타난다. 연산 효율 (11개 BEIR 평균) 모델 파라미터 평균 토큰 TFLOPs ↓ QPP ↑ gte-reranker-modernbert-base 150M 359.25 0.07 209.5 Qwen3-Reranker-0.6B 600M 438.70 0.25 53.2 llama-nemotron-rerank-1b-v2 1.2B 363.99 0.52 28.2 LightOn-rerank-PW-2B 2.2B 416.22 0.73 18.3 bge-reranker-v2-gemma 2.5B 399.11 1.10 12.3 Qwen3-Reranker-4B 4B 438.70 2.12 6.1 LightOn-rerank-PW-4B 4.5B 416.22 1.73 7.7 RenderRank 2.1B 290.07 0.63 20.1 QPP는 고정 연산 예산(PetaFLOP) 안에서 후보 전체를 리랭킹할 수 있는 질의 수다. RenderRank는 0.63 TFLOPs / QPP 20.1로, teacher(2.12 TFLOPs / QPP 6.1) 대비 연산량은 약 3분의 1, 처리 가능 질의 수는 약 3.3배다. 정확도 차이 2.03점을 이 비용 차이와 견주는 것이 이 논문의 핵심 거래다. 롱 도큐먼트 4종 (16K 이상 지원 모델만 비교) 모델 평균 NDCG@10 평균 토큰 평균 PPS Qwen3-Reranker-0.6B 87.21 10,060.9 2.66 LightOn-rerank-PW-2B 79.38 10,240.5 0.94 Qwen3-Reranker-4B 88.15 10,060.9 0.80 zerank-2-reranker 88.26 10,003.3 0.74 LightOn-rerank-PW-4B 87.82 10,240.5 0.44 RenderRank 88.27 4,637.7 4.51 여기서는 결과가 훨씬 선명하다. RenderRank가 정확도 1위(88.27)이면서 토큰은 베이스라인의 46.1%(4,637.7 / 10,060.9), 처리량은 최고 베이스라인 2.66 PPS의 1.70배 인 4.51 PPS다. 데이터셋별로는 MLDR 99.74, 2WikiMQA 94.30, QMSum 59.94, SummScreenFD 99.10을 기록했다. 문서가 길어질수록 시각 압축의 이득이 커지는 것은 자연스럽다. 짧은 문서는 렌더링 이미지에 여백이 남아 압축률이 떨어지지만, 긴 문서는 이미지를 빽빽하게 채우기 때문이다. 이 논문의 진짜 활용처는 롱 도큐먼트 리랭킹이라고 읽는 편이 타당하다. 시퀀스 길이 예산을 고정했을 때 (MLDR) 모델 2K 4K 8K gte-reranker-modernbert 93.2 95.8 98.5 llama-nemotron-rerank-1b-v2 97.4 98.5 99.4 Qwen3-Reranker-4B 96.9 98.0 99.6 LightOn-rerank-PW-4B 95.1 97.3 99.5 RenderRank 97.9 99.5 99.7 같은 시퀀스 길이 한도라도 시각 토큰은 더 많은 문서 내용을 담을 수 있다는 것을 보여주는 표다. RenderRank의 2K 성능(97.9)이 경쟁 모델들의 4K 성능을 넘어서고, 4K 성능(99.5)은 텍스트 기반 리랭커의 8K 성능에 준한다. 메모리나 컨텍스트 한도가 먼저 걸리는 환경에서 특히 의미가 있다. Ablation 구성 평균 NDCG@10 RenderRank (전체) 55.96 − 2단계(QLRD) 제외 54.86 − 1·2단계 모두 제외 50.20 구성 PPS ↑ RenderRank 76.83 시각 임베딩 캐싱 없음 37.31 학습을 전혀 하지 않은 VLM 리랭커는 50.20에 머문다. CMRD를 더하면 54.86으로 +4.66점 , QLRD까지 더하면 55.96으로 +1.10점 이 추가된다. 성능의 대부분은 텍스트 teacher로부터의 점수 증류에서 오고, 질의 내 상대 순서 학습이 마무리를 담당하는 구조다. 캐싱 항목도 중요하다. 76.83 → 37.31 PPS로, 캐시가 없으면 처리량이 약 2.06배 나빠진다. 앞서 본 처리량 수치는 임베딩 사전 계산을 전제로 한 값이라는 점을 기억할 필요가 있다. 렌더링 밀도의 트레이드오프 Figure 3은 폰트 크기에 따른 성능·토큰·처리량 변화를 보여준다. 기본값 12pt는 290.07 토큰 / 76.83 PPS / NDCG@10 55.96이다. 폰트를 줄이면 토큰과 처리량이 좋아지고 성능이 내려가며, 키우면 반대로 움직인다. 렌더링 밀도가 정확도-효율의 조절 노브 역할을 한다는 것이 이 분석의 요지다(그래프에서 읽은 10pt·14pt 지점의 정확한 수치는 본문 표로 제시되지 않아 여기서는 옮기지 않는다). 🚧 한계 논문에 별도의 Limitations 절은 없다. 본문에서 확인되는 제약과 리뷰 관점의 한계를 정리하면 다음과 같다. Teacher를 넘지 못한다. BEIR 평균 55.96 vs teacher 57.99로 2.03점 차이가 남는다. 시각 압축에는 대가가 있고, 정확도가 최우선인 상황에서는 여전히 텍스트 기반 대형 리랭커가 유리하다. 처리량 수치는 캐싱 전제다. 시각 임베딩 사전 계산을 가정하며, 캐시 없이는 76.83 → 37.31 PPS로 떨어진다. 문서가 자주 바뀌는 코퍼스에서는 이득이 크게 줄어든다. 렌더링 설정에 민감하다. 폰트 크기와 행간에 따라 결과가 흔들린다(Table 1에서 QASPER F1이 설정별로 41.32~48.72 범위). 비전 인코더의 패치 구조에 맞춘 세팅 튜닝이 필요하다. 영어 중심 실험이다. 학습 코퍼스가 영어이고 BEIR도 영어 벤치마크다. 한국어·중국어·일본어처럼 글자 밀도와 자형이 다른 언어에서 같은 압축률과 정확도가 나올지는 검증되지 않았다. 태스크별 편차가 있다. ArguAna(−6.29점), Touché-2020(−4.50점)처럼 논증 검색 계열에서 teacher 대비 손실이 크다. 재현 정보가 제한적이다. 학습 하이퍼파라미터는 부록에 있고, 본문 기준으로 공개 코드·모델 링크가 확인되지 않는다. 🎯 마무리 이 논문의 기여는 "텍스트를 이미지로 넣으면 토큰이 줄어든다"는 관찰 자체보다, 그 관찰이 리랭킹이라는 태스크에서 유독 비용 효율이 좋다 는 점을 짚고 학습 레시피까지 붙여 검증한 데 있다. 리랭킹은 질의 1건에 후보 N개를 곱하는 구조라서 문서당 절감이 N배로 증폭된다. 이 곱셈 구조를 정확히 겨냥한 것이 설계의 출발점이다. 학습 방법에서 눈여겨볼 점은 점수 수준 증류다. 텍스트와 이미지는 토큰 개수도 정렬도 다르지만, "관련도 점수"라는 스칼라는 모달리티를 건너 비교할 수 있다. 덕분에 잘 학습된 텍스트 리랭커를 시각 입력 리랭커의 teacher로 그대로 쓸 수 있다. 이 트릭은 리랭킹에만 국한되지 않고, 다른 스코어링 태스크의 크로스모달 전이에도 적용 가능한 아이디어로 보인다. 실무 관점의 결론은 조건부다. 짧은 패시지 위주라면 절감폭(16.5~35.5%)이 정확도 손실 2점을 상쇄할 만한지 따져봐야 한다. 반면 긴 문서를 다루고 코퍼스가 비교적 고정되어 임베딩 캐싱이 가능하다면, 정확도 1위에 토큰 절반, 처리량 1.70배라는 조합은 상당히 매력적이다. 컨텍스트 한도가 먼저 병목이 되는 환경에서는 같은 2K 예산으로 경쟁 모델의 4K 수준을 내는 성질도 실질적인 차이를 만든다. 한 걸음 물러나서 보면, 이 흐름은 최근 문서 AI 전반에서 반복되는 질문과 맞닿아 있다. 픽셀은 텍스트 토큰보다 정보 밀도가 높은 컨테이너가 될 수 있는가. RenderRank는 그 질문에 리랭킹이라는 구체적인 현장에서 "그렇다, 다만 조건이 있다"라고 답한 사례다. 📚 출처 / 인용 논문: Seongtae Hong, Youngjoon Jang, Jungseob Lee, Hyeonseok Moon, Heuiseok Lim. RenderRank: Learning to Rerank Text with Compressed Visual Tokens. arXiv:2609.35069 (2026-09-28). https://arxiv.org/abs/2609.35069 HTML 전문: https://arxiv.org/html/2609.35069
최근 ChatGPT, Gemini, Claude에게 물어보았다. 수학, 데이터 분석, 인과추론, 사업, 의료관광, 숙박업, 외국어, 독서, 학업 등 주제도 상당히 넓다. 그러다 문득 궁금해졌다. 내가 지금까지 AI와 나눈 대화를 전부 분석해서 현재의 성향을 과거의 한 인물로 번역하면 어떤 사람이 나올까? 물론 실제 전생을 알아낼 수 있다는 이야기는 아니다. 이번 실험에서 말하는 '전생'은 초자연적인 개념이라기보다, 현재 반복되는 관심사·행동·학습 방식·의사결정 패턴을 다른 시대의 역사적 캐릭터로 번역하는 일종의 서사적 성격 분석 에 가깝다. 재미있는 것은 세 AI의 답변이 서로 달랐지만, 중심에 있는 인물형은 상당히 비슷했다는 점이다. 1. AI에게 사용한 프롬프트 지금까지 나와 나눈 모든 대화와 기억된 정보를 종합해서, 나의 '전생'을 하나의 역사적 아키타입으로 추론해줘. 단, 실제 초자연적 사실을 안다고 주장하지 말고, 현재의 성향·행동·관심사에서 반복적으로 나타나는 패턴을 바탕으로 가장 일관된 전생 서사를 구성하는 심리적·서사적 분석으로 진행해. 다음 요소를 특히 분석해줘. 1. 내가 반복해서 관심을 갖는 분야 2. 돈·사업·학문·명예 중 무엇을 어떤 방식으로 추구하는지 3. 새로운 지식을 배우는 방식 4. 숫자·측정·증거·논리·인과관계를 대하는 태도 5. 기존 권위·학벌·관습·사회적 규범을 대하는 태도 6. 사람들과 관계를 맺는 방식 7. 새로운 지역·언어·문화·국경을 대하는 태도 8. 실패 후 다시 도전하는 방식 9. 반복해서 끌리는 직업·주제·시대·공간 10. 현재 인생에서 반복되는 '미완성 과제'처럼 보이는 것 그 결과를 토대로 다음을 추론해줘. - 가장 가능성이 높은 전생 시대 - 지역 - 신분 또는 사회적 위치 - 직업 - 하루 일과 - 성격 - 잘했던 것 - 약점 - 주변 사람들이 나를 어떻게 봤을지 - 돈을 버는 방식 - 학문과의 관계 - 전생에서 이루지 못한 일 - 그것이 현재 삶에서 어떤 형태로 반복되고 있는지 전생 후보를 아무렇게나 여러 개 던지지 말고, 1위: 가장 일관성이 높은 전생 2위: 다른 가능성 3위: 의외지만 설명력이 있는 가능성 세 가지 정도만 제시해. 각 후보마다 반드시 현재 대화에서 발견한 행동 패턴을 근거로 연결해. 마지막에는 "당신의 전생을 한 문장으로 표현하면" 이라는 문장으로 전생 캐릭터를 압축해줘. 분석은 재미있게 하되 지나친 미신적 표현보다는 역사소설 속 인물을 복원하듯 구체적으로 작성해줘. 2. Gemini의 답변 Gemini가 뽑은 1위는 상당히 강렬했다. 1위 — 19세기 조선·청 국경의 실무형 역관 겸 거상 핵심 설정은 이렇다. 어릴 때는 목적도 모른 채 배우는 공부를 별로 좋아하지 않았다. 산학이나 고전을 배우면서도 "그래서 이걸 어디에 쓰는데?" 라는 의문을 품는다. 그런데 청년이 되어 실제 무역과 국제 거래를 경험하면서 생각이 바뀐다. 환율, 물동량, 가격 차이, 거래 조건을 이해하려면 결국 자신이 대충 넘겼던 수학과 계산이 필요하다는 것을 깨닫는다. 그 순간부터 공부의 의미가 완전히 달라진다. 공부를 위해 공부하는 것이 아니라, 문제를 해결하기 위해 공부하기 시작한다. 목적이 생긴 뒤 다시 시작한 산학 Gemini가 특히 강조한 부분은 이 지점이었다. 처음부터 수학을 좋아했던 사람이 아니다. 오히려 필요성을 느끼지 못했기 때문에 기초를 제대로 다지지 않았다. 하지만 현실에서 한계를 경험하면서, "내가 원하는 걸 하려면 결국 이걸 넘어야 한다." 라는 결론에 도달한다. 그리고 그 뒤부터는 오히려 자신이 약했던 기초부터 다시 파고든다. 현대로 번역하면, 수학 통계 파이썬 데이터 분석 인과추론 AI 같은 도구를 목적에 맞게 다시 배우는 모습이다. 성격 Gemini는 이 인물을 상당한 실용주의자로 묘사했다. 특징 목적을 설명하지 못하는 관습을 싫어한다. 권위만으로 어떤 주장을 받아들이지 않는다. 필요성을 납득하면 엄청난 집중력을 발휘한다. 이론 그 자체보다는 현실 문제를 해결하는 데 관심이 많다. 자신의 약점을 발견하면 오히려 그 부분을 집중적으로 파고든다. 결국 핵심은 이것이다. 목적이 없는 공부에는 동기가 약하지만, 목적이 생기면 기초부터 다시 올라가는 사람. 돈과 학문의 관계 이 캐릭터에게 학문은 현실과 분리되어 있지 않다. 경제와 무역을 이해하기 위해 수학을 배우고, 가격과 흐름을 예측하기 위해 데이터를 분석하며, 제도를 이해하기 위해 정책을 공부한다. 즉, 학문 → 현실 이 아니라 현실의 문제 → 필요한 학문을 찾아감 의 방향이다. 전생에서 이루지 못한 일 Gemini가 설정한 미완성 과제는 완전한 국제 무역 시스템을 만드는 것 이었다. 시대적인 기술 한계와 정보 부족 때문에 자신이 머릿속으로 그린 시스템을 끝까지 구현하지 못했다. 그래서 현생에서는 데이터 수학 프로그래밍 인과추론 국제 비즈니스 등을 이용해 다시 비슷한 문제에 접근하는 것으로 해석했다. 3. Gemini의 다른 후보들 2위 — 17세기 네덜란드의 항해사 겸 정밀기기 제작자 두 번째 후보는 대항해시대의 항해사였다. 이 캐릭터 역시 처음에는 복잡한 수학을 귀찮아한다. 하지만 실제 바다에 나가보니 위치 계산 하나가 틀리면 배가 엉뚱한 곳으로 간다. 그래서 결국 구면기하 천문학 측량 항해 계산 을 다시 배우기 시작한다. 중요한 것은 수학을 사랑해서 배우는 것이 아니다. 정확성을 통제하기 위해 수학을 배운다. 3위 — 고대 로마의 전투 공병 겸 구조 설계자 세 번째는 공병이었다. 처음에는 추상적인 기하학보다 현장에서 직접 만드는 것을 선호한다. 그러나 도로 수로 성벽 구조물 같은 대규모 시스템을 만들다 보면 결국 수학과 구조 원리를 피할 수 없다. 그래서 다시 이론으로 돌아간다. 이 후보 역시 동일한 패턴을 가진다. 현실 문제 → 한계 경험 → 이론의 필요성 발견 → 다시 공부 4. Gemini가 본 핵심 Gemini의 답변을 한 문장으로 정리하면 이렇다. 목적 없는 앎을 한때 외면했지만, 세상을 움직이는 구조를 이해하기 위해 자신의 약점이던 수학의 밑바닥부터 다시 올라가기 시작한 실용주의 무역상. 5. Claude의 답변 Claude의 답변은 훨씬 구체적인 장소를 골랐다. 1위 — 18세기 초 부산 초량왜관의 왜학 역관 Claude는 내가 부산, 외국인, 국제교류, 서비스, 데이터, 제도 변화 등에 관심을 가진다는 점을 바탕으로 18세기 부산 초량왜관에서 근무하던 역관 을 골랐다. 이 설정이 재미있는 이유는 당시 부산이 실제로 조선 일본 중국 사이의 물자와 정보가 교차하던 장소였기 때문이다. 6. 역관이라는 직업 역관은 단순한 통역사가 아니다. 외교와 무역의 최전선에서 언어 문서 협상 시장 상황 가격 제도 를 동시에 다루는 전문직이었다. Claude가 상상한 인물은 사역원에서 왜학을 공부한 뒤 부산으로 파견된 역관이다. 주요 업무는 대략 이런 모습이다. 외국 상인과 관리 사이 통역 외교문서 검토 무역 규정 해석 시장 거래 입회 품목별 시세 기록 거래 조건 확인 결국 이 사람 역시 언어만 잘하는 사람이 아니라 정보를 구조화하는 사람 이다. 7. Claude가 상상한 하루 새벽 전날 거래 기록을 펼친다. 품목, 수량, 가격, 날짜, 출처 등을 장부에 정리한다. 단순히 기록만 하는 것이 아니다. 날짜별 변화를 비교한다. "지난달과 무엇이 달라졌는가?" 를 보는 것이다. 오전 외국 상인과 협상 테이블에 앉는다. 통역만 하는 것이 아니라 상대의 요구 협상의 조건 규정의 범위 거래 가능성 을 동시에 계산한다. 한낮 상인들의 거래를 조율한다. 누가 무엇을 원하는지 파악하고, 필요한 사람들을 연결한다. 오후 외교문서나 거래 규칙을 읽는다. 문장 하나가 애매하면 그냥 넘어가지 않는다. 조건 하나가 달라지면 전체 의미가 바뀔 수 있기 때문이다. 저녁 낮에 사용한 언어나 문서를 다시 검토한다. 틀린 표현이나 불명확한 부분을 수정해서 다음에는 더 쉽게 사용할 수 있도록 정리한다. 결국 하루 종일 관찰 → 기록 → 비교 → 수정 을 반복하는 사람이다. 8. Claude가 본 성격 Claude는 이 사람을 겉은 조용하지만 내부에서는 계속 구조를 만들고 있는 사람 으로 묘사했다. 특징은 다음과 같다. 모호한 상태를 오래 견디지 못한다. 정보를 분류하고 이름 붙이고 싶어 한다. 규칙을 무작정 싫어하지는 않는다. 대신 규칙이 왜 만들어졌는지 알고 싶어 한다. 제도가 바뀌었을 때 실제 행동이 어떻게 달라지는지를 궁금해한다. 하나를 연구하다 보면 범위가 계속 넓어진다. 마지막 특징은 특히 익숙하다. 처음에는 작은 질문 하나로 시작했는데, 조사하다 보면 "그러면 이것도 알아야 하는데?" 가 계속 생긴다. 결국 연구 범위가 계속 커진다. 9. Claude가 본 가장 큰 능력 가장 큰 능력은 정책과 시장 사이의 연결을 읽는 것 이다. 예를 들어 제도가 하나 바뀌었을 때 단순히 법 조문만 보는 것이 아니라, "그러면 실제 사람들의 행동은 어떻게 바뀔까?" 를 생각한다. 현대적인 표현을 쓰면 상당히 인과추론적인 사고방식 에 가깝다. 정책 변화 이전과 이후를 보고, 변화가 실제 시장에 어떤 영향을 주었는지 보려는 것이다. 10. 돈을 버는 방식 Claude의 역관 역시 정보에서 돈을 번다. 가장 중요한 자산은 남보다 조금 먼저 아는 것 이다. 어느 물건의 수요가 늘었는가 어느 지역에서 가격이 변했는가 어떤 규정이 새로 생겼는가 어느 거래가 가능해졌는가 이 정보를 빠르게 알면 거래 기회를 먼저 잡을 수 있다. 그래서 이 사람의 자본은 돈 이전에 정보 + 네트워크 + 기록 이다. 11. 학문과의 관계 당시 역관이나 산학자들의 지식은 정통 학문에 비해 낮게 평가되기도 했다. 하지만 실제 현장에서는 언어 계산 천문 지리 외교 무역 같은 실용적인 지식이 반드시 필요했다. 그래서 Claude가 만든 인물은 정통 엘리트의 길보다는 현장에서 필요한 지식을 계속 축적하는 전문직 지식인 에 가깝다. 12. Claude가 설정한 미완성 과제 Claude는 이 사람이 이루지 못한 것을 몇 가지로 압축했다. 첫 번째 자기 이름으로 남는 연구나 책 평생 남의 말을 통역하고 기록했지만, 정작 자신이 발견한 것들을 하나의 체계로 완성하지 못했다. 두 번째 실용적 지식이 정식 학문으로 인정받는 것 현장에서는 누구보다 많은 것을 알았지만, 그 지식이 정통 학문의 중심에는 들어가지 못했다. 세 번째 정보와 사람을 연결하는 자기 사업 관료나 조직의 일부로 일하는 것을 넘어 자신만의 시스템을 만들고 싶었지만 끝내 완성하지 못했다. 13. ChatGPT가 뽑았던 결과 ChatGPT에게 같은 질문을 했을 때도 방향은 비슷했다. 개항기 항구도시의 역관 겸 상단 실무자·산술가 핵심은 국경 + 사업 + 숫자 + 지식 이었다. 외국인을 상대하면서, 시장 가격을 보고, 장부를 관리하고, 새로운 지식을 배우며, 그것을 다시 의사결정에 사용하는 사람이다. 14. 세 AI의 결과를 비교해보면 AI 시대 직업 핵심 능력 Gemini 19세기 조선·청 국경 역관 겸 거상 실무, 무역, 수학 Claude 18세기 부산 초량왜관 왜학 역관 언어, 제도, 정보 분석 ChatGPT 조선 말기 개항기 항구 역관·상단 실무자·산술가 정보 연결, 계산, 의사결정 시대와 세부 설정은 조금씩 다르다. 그런데 세 모델 모두 이상할 정도로 비슷한 캐릭터를 만들었다. 15. 세 AI가 공통적으로 발견한 것 1. 학자보다는 실무형 지식인 순수하게 지식을 축적하는 사람보다 필요한 문제를 해결하기 위해 공부하는 사람 에 가깝다고 봤다. 2. 상인과 학자의 중간 돈만 보는 상인도 아니고, 현실에서 떨어져 있는 학자도 아니다. 사업을 하다가 연구를 하고, 연구를 하다가 다시 사업 아이디어를 찾는 사람 이다. 3. 국경과 항구 세 모델 모두 이상하게 외국 항구 무역 통역 이동 이라는 요소를 넣었다. 현대적으로 바꾸면 국제 서비스나 관광, 무역, 외국인 대상 사업과 비슷한 영역이다. 4. 정보가 가장 중요한 자산 세 모델의 캐릭터 모두 힘의 원천은 물리적인 재산보다 정보 우위 였다. 남보다 먼저 알고, 정리하고, 연결하고, 의사결정에 사용하는 것이다. 5. 수학을 늦게 다시 배우는 사람 이 부분도 거의 공통이다. 처음부터 수학 자체를 좋아했던 천재가 아니다. 현실에서 "이걸 모르면 내가 원하는 수준까지 갈 수 없다." 라는 벽을 경험한 뒤 다시 기초부터 공부한다. 16. 세 모델을 합치면 어떤 인물이 나오는가 세 답변을 하나로 합쳐보면 이런 사람이 된다. 18~19세기 동아시아 항구의 역관·무역상·산술가 부산 같은 국제 항구에서 일한다. 외국 상인의 말을 통역하고, 가격과 환율을 기록하며, 정책이 바뀌면 시장이 어떻게 반응하는지 관찰한다. 처음에는 계산과 이론을 귀찮아했지만, 거대한 거래를 이해하려면 결국 수학이 필요하다는 것을 깨닫는다. 그래서 밤마다 다시 산학을 공부한다. 목표는 단순히 부자가 되는 것이 아니다. 정보와 숫자를 이용해 세상이 움직이는 구조를 이해하는 것 이다. 17. 이 사람의 가장 큰 장점 서로 다른 영역을 연결한다. 언어와 무역을 연결하고, 정책과 시장을 연결하고, 수학과 현실을 연결하고, 정보와 사람을 연결한다. 현대적인 표현으로 바꾸면 Connector 혹은 Bridge 에 가까운 역할이다. 18. 가장 큰 약점 장점과 약점은 사실 같은 곳에서 나온다. 하나를 보면 관련된 다른 것들이 계속 보인다. 그래서 프로젝트 하나가 연구 주제 다섯 개로 늘어난다. 문제를 깊이 이해하는 데는 좋지만, 완성 속도는 느려질 수 있다. 결국 중요한 것은 어디까지 알아야 실제 행동을 시작할 수 있는가 를 정하는 것이다. 19. 전생에서 가장 아쉬웠던 것 세 AI의 답변을 합치면 결국 하나로 수렴한다. 끝까지 완성하지 못한 자기 시스템 이다. 남의 거래를 돕고, 남의 말을 통역하고, 남의 장부를 관리하면서 수많은 정보를 모았다. 하지만 자신이 발견한 것들을 하나의 체계로 만들어 세상에 남기지는 못했다. 그래서 이번 생에서는 계속 분석하고 기록하고 공부하고 사업 아이디어를 만들고 새로운 프로젝트를 시작하는 것 일지도 모른다. 20. 세 AI를 합친 최종 전생 18~19세기 부산과 동아시아 국제무역권을 오가며, 외국어와 장부를 무기로 거래를 중개하고 제도 변화와 시장의 인과관계를 분석하던 역관 출신 무역상. 처음에는 목적 없는 공부를 싫어했지만, 더 큰 판을 이해하려면 결국 수학이 필요하다는 것을 깨닫고 늦게나마 산학을 다시 파기 시작한 실용주의 지식인. 21. 한 문장으로 표현하면 "장부와 외국 서적을 끼고 항구를 돌아다니며, 돈을 벌기 위해 숫자를 배웠다가 결국 세상이 움직이는 원리를 알고 싶어 수학을 다시 시작한 역관." 22. 소설 제목을 붙인다면 《장부를 덮고 수학책을 펼친 역관》 조금 더 웹소설스럽게 바꾸면, 《역관인데 수학을 너무 늦게 배웠다》 혹은 《조선의 데이터 분석가》 정도가 될 것 같다. 23. 결론 물론 실제 전생이 이런 사람이었는지는 알 수 없다. 오히려 이 실험에서 흥미로웠던 점은 세 AI가 서로 다른 방식으로 분석했는데도 비슷한 인간형을 만들어냈다는 것 이다. 역관. 무역상. 산술가. 항구. 외국어. 데이터. 그리고 늦게 다시 시작한 공부. 결국 AI들이 보고 있었던 것은 전생이라기보다, 지금까지 내가 반복해서 보여준 하나의 패턴인지도 모른다. 나는 지식을 모으기 위해 현실을 보는 사람이 아니라, 현실을 이해하기 위해 지식을 모으는 사람이다. 그리고 시대가 달라졌더라도 아마 똑같은 질문을 했을 것 같다. "그래서 이걸 실제로 어디에 써먹을 수 있는데?" ``` 이 버전은 제미나이 → 클로드 → ChatGPT → 세 모델 종합 구조라서 벨로그 글로 읽기 훨씬 자연스럽고, 사적인 연애·호감 관련 내용은 전부 뺐어.
문제 해설 피보나치는 정말 많이 풀기도 하고 많은 강의에서 예시로 드는 대표적인 항목이라, 바로 풀었다. 가장 빠른 DP로 해결 코드 #include <string> #include <vector> using namespace std; int d[1'000'001]; int solution(int n) { const int div = 1234567; d[0] = 0; d[1] = 1; for(int i=2;i<=n;++i) { d[i] = (d[i-1] + d[i-2])%div; } return d[n] % div; }