새 시스템으로 흘리는 비율
새 시스템 오류율
어긋난 데이터
어제 대조 기준
되돌리는 데 걸리는 시간
지금 어디까지 넘어갔는지, 넘겨도 되는지
구 시스템새 시스템
두 시스템이 같은 데이터를 보고 있어서 되돌리기는 라우팅만 바꾸면 끝납니다. 그래서 입니다.
다만 마감 배치가 새 시스템에서 돌고 있으면, 배치가 끝날 때까지 기다려야 합니다.
최근 기록
화면을 묶어 네 번에 나눠 넘깁니다
응답 시간 (주문 목록 기준)
옮기는 이유의 절반은 이것입니다. 나머지 절반은 고칠 수 있게 되는 것입니다.
오늘 처리한 주문
합쳐서 하루 1,200건. 한 건도 흘리면 안 되는 숫자입니다.
옮기기 전에 무엇이 있는지부터 셌습니다 · 2026-05 조사
안에서 도는 것보다 밖으로 물린 것이 늘 더 어렵습니다
| 연결 | 어디에 | 방식 | 난이도 | 어떻게 옮기나 |
|---|---|---|---|---|
| 택배사 3곳 | 송장 발급 · 배송 조회 | SOAP | 보통 | 규격이 REST 로 바뀌어 새로 붙입니다 |
| 전자세금계산서 | 발행 · 국세청 전송 | 외부 SaaS | 낮음 | 같은 업체를 그대로 씁니다 |
| 라벨 프린터 | 출고 라벨 · 4대 | ActiveX | 높음 | 웹 인쇄로 다시 만들고 현장에서 시험합니다 |
| 엑셀 업로드 | 대량 주문 등록 | .xls 매크로 | 보통 | 양식을 고정하고 검사 결과를 돌려줍니다 |
| 바코드 스캐너 | 입출고 · 9대 | 키보드 입력 | 낮음 | 그대로 됩니다. 초점만 맞춰 두면 됩니다 |
테스트가 0개인 코드는 옮길 수가 없습니다. 그래서 옮기기 전에 이것부터 만들었습니다
3,548개다 옮기는 게 아닙니다. 자산마다 따로 정합니다
| 자산 | 규모 | 전략 | 왜 이렇게 정했나 | 언제 |
|---|---|---|---|---|
같은 주문 등록을 화면 · 코드 · 데이터 세 층에서 나란히 놓았습니다
2011 2026| * 거래처코드 | A0142 대성전기(주) [F2] | * 주문일자 | 2026-09-01 |
| 담당자 | 1042 김현우 | 납기일자 | 2026-09-05 |
| 비고 | |||
| No | 품목코드 | 품목명 | 규격 | 수량 | 단가 | 금액 | 비고 |
|---|---|---|---|---|---|---|---|
| 1 | PM-1140 | 배선용 차단기 | 30A 2P | 40 | 18,400 | 736,000 | |
| 2 | CT-0820 | 계기용 변류기 | 200/5A | 12 | 57,000 | 684,000 | |
| 3 | |||||||
| 합계금액 | 1,420,000 | ||||||
품목코드를 외워서 칩니다. [F2] 를 눌러야 찾기 창이 뜨는데, 그걸 아는 사람은 오래 다닌 사람뿐입니다. 1024×768 고정이라 창고 태블릿에서는 옆으로 밀어야 합니다.
| 품목 | 규격 | 수량 | 단가 | 금액 |
|---|---|---|---|---|
| 배선용 차단기 PM-1140 |
30A 2P | 40 | 18,400 | 736,000 |
| 계기용 변류기 CT-0820 |
200/5A | 12 | 57,000 | 684,000 |
| 품목명이나 코드를 입력하세요 | — | |||
| 합계 | 1,420,000 | |||
계약 단가를 자동으로 넣었습니다. 고치면 사유가 함께 기록됩니다 — 지난달 단가 실수 6건이 여기서 걸립니다.
이름으로 찾고, 계약 단가가 먼저 들어옵니다. 20줄을 넣다가 자리를 비워도 임시 저장이 남습니다. 단축키 F9 저장은 15년 쓰던 그대로 뒀습니다.
같은 주문 한 건을 넣는 데 드는 것
| 항목 | 구 시스템 · 2011 | 새 시스템 · 2026 | 왜 바꿨나 |
|---|---|---|---|
<% ' ord_reg_save.asp — 주문 저장 (2011-03, 마지막 수정 2019-11) Dim cn, rs, strSQL, ordNo, i Set cn = Server.CreateObject("ADODB.Connection") cn.Open Application("GSORD_DSN") custCd = Request.Form("cust_cd") ordDt = Request.Form("ord_dt") ' 주문번호 채번 : 오늘 날짜 + 순번 4자리 strSQL = "SELECT NVL(MAX(TO_NUMBER(SUBSTR(ORD_NO,9))),0)+1 SEQ FROM TB_ORDER " & _ " WHERE SUBSTR(ORD_NO,1,8) = '" & Replace(ordDt,"-","") & "'" Set rs = cn.Execute(strSQL) ordNo = Replace(ordDt,"-","") & Right("000" & rs("SEQ"), 4) For i = 1 To CInt(Request.Form("row_cnt")) qty = Request.Form("qty_" & i) prc = Request.Form("prc_" & i) If qty <> "" Then strSQL = "INSERT INTO TB_ORDER_ITEM(ORD_NO,SEQ,ITEM_CD,QTY,PRC,AMT) VALUES(" & _ "'" & ordNo & "'," & i & ",'" & Request.Form("item_" & i) & "'," & _ qty & "," & prc & "," & (CDbl(qty) * CDbl(prc)) & ")" cn.Execute(strSQL) End If Next cn.Execute "BEGIN SP_ORD_CLOSE('" & ordNo & "'); END;" Response.Redirect "ord_list.asp?ord_no=" & ordNo %>
' OR 1=1 -- 를 넣으면 그대로 실행됩니다. 15년 동안 아무도 넣어 보지 않아서 아직 안 터졌을 뿐입니다.1,904건 있었습니다.MAX + 1 로 합니다두 사람이 같은 초에 저장하면 같은 주문번호를 받습니다. 월말에 한 번씩 나던 사고의 원인입니다.ord_reg, ord_edit, ord_copy 세 군데에 따로 있고 반올림이 다릅니다.// orders/create-order.ts const Body = z.object({ customerCode: z.string().regex(/^[A-Z]\d{4}$/), orderedOn: z.coerce.date(), dueOn: z.coerce.date(), lines: z.array(z.object({ itemCode: z.string(), qty: z.number().int().positive(), priceOverride: z.number().int().nonnegative().optional(), priceOverrideReason: z.string().min(2).optional(), })).min(1), }); export async function createOrder(raw: unknown, actor: Actor) { const body = Body.parse(raw); // 틀린 입력은 DB 근처에도 못 온다 return db.$transaction(async (tx) => { // 한 줄이라도 실패하면 전부 없던 일 const orderNo = await legacySeq.next(tx); // 번호는 구 시스템 채번기 한 곳에서만 const lines = await priceLines(tx, body); // 계약 단가는 서버가 정한다 const amount = sumAmount(lines); // 반올림 규칙은 vat.ts 한 곳에만 있다 const order = await tx.order.create({ data: { orderNo, ...totalsOf(amount), lines: { create: lines } }, }); await reserveStock(tx, lines); // 재고가 모자라면 여기서 예외 await outbox.add(tx, "order.created", order); // 구 시스템에 보낼 것도 같은 트랜잭션 await audit.add(tx, actor, "order.create", order.orderNo, body); return order; }); }
outbox 행이 주문과 함께 저장되므로, 주문만 저장되고 전달이 빠지는 경우가 없습니다.프로시저 391개 중 하나. 여기에만 있는 규칙이 세 개 들어 있습니다
CREATE OR REPLACE PROCEDURE SP_ORD_CLOSE(P_ORD_NO IN VARCHAR2) IS V_AMT NUMBER; BEGIN SELECT SUM(AMT) INTO V_AMT FROM TB_ORDER_ITEM WHERE ORD_NO = P_ORD_NO; UPDATE TB_ORDER SET TOT_AMT = V_AMT, VAT = ROUND(V_AMT * 0.1), STS = '10' WHERE ORD_NO = P_ORD_NO; FOR C IN (SELECT ITEM_CD, QTY FROM TB_ORDER_ITEM WHERE ORD_NO = P_ORD_NO) LOOP UPDATE TB_STOCK SET QTY = QTY - C.QTY WHERE ITEM_CD = C.ITEM_CD; END LOOP; COMMIT; EXCEPTION WHEN OTHERS THEN NULL; END;
ROUND(V_AMT * 0.1). 화면 어디에도, 문서 어디에도 안 적혀 있습니다. 이걸 찾는 데 이틀 걸렸습니다.TB_STOCK 에 음수인 품목이 23개 있습니다.WHEN OTHERS THEN NULL무슨 일이 나도 조용히 넘어갑니다. 화면에는 저장됐다고 나옵니다. 이 한 줄이 이 시스템에서 가장 위험한 코드입니다.// orders/close-order.ts + money/vat.ts export function totalsOf(goods: number) { return { goodsAmount: goods, vatAmount: vatOf(goods) }; } // 구 시스템과 같은 값이 나와야 한다. 이상해 보여도 이게 이 회사의 규칙이다. // 행마다 반올림하지 않고 주문 단위로 한 번 반올림한다 → 대조 차이 2건은 알고 둔다. export const vatOf = (goods: number) => Math.round(goods * 0.1); export async function reserveStock(tx: Tx, lines: Line[]) { for (const l of lines) { const { count } = await tx.stock.updateMany({ where: { itemCode: l.itemCode, onHand: { gte: l.qty } }, // 모자라면 0건 data: { onHand: { decrement: l.qty } }, }); if (count === 0) throw new OutOfStock(l.itemCode, l.qty); // 트랜잭션 전체가 되돌아간다 } }
vatOf() 하나만 고치면 전부 바뀝니다. 주석에 왜 이렇게 두는지를 같이 적어 둡니다.onHand >= qty 를 조건에 넣어 0건이면 예외를 던집니다. 재고가 음수가 될 수 없습니다.옮기기 전에 이것부터 만들었습니다. 6주 걸렸고, 그만한 값을 했습니다
// test/golden/orders.spec.ts // 정답은 구 시스템이다. 버그까지 포함해서. // 실제 주문 3,200건을 그대로 넣고 나온 값을 한 글자씩 맞춘다. describe("주문 저장 — 구 시스템과 같은 결과", () => { const cases = loadGolden("orders-2025Q4.jsonl"); // 구 시스템에서 뽑은 입력 + 결과 test.each(cases)("$legacy.ORD_NO $legacy.CUST_CD", async (g) => { const got = await createOrder(g.input, SYSTEM); expect(got.goodsAmount).toBe(g.legacy.TOT_AMT); expect(got.orderNo).toMatch(/^\d{12}$/); expect(got.lines.map(l => l.amount)).toEqual(g.legacy.ITEMS.map(i => i.AMT)); // 알고 두는 차이 : 부가세 반올림 위치. 목록에 없는 건이면 실패시킨다. if (KNOWN_VAT_DIFF.has(g.legacy.ORD_NO)) { expect(Math.abs(got.vatAmount - g.legacy.VAT)).toBeLessThanOrEqual(1); } else { expect(got.vatAmount).toBe(g.legacy.VAT); } }); }); // $ pnpm test:golden // 3,548 passed · 6 known-diff · 0 failed (2분 41초)
-- TB_ORDER (2011-03 생성, 컬럼 4개 추가됨) CREATE TABLE TB_ORDER ( ORD_NO VARCHAR2(12) NOT NULL, -- 날짜8 + 순번4. 하루 9999건을 넘으면 터진다 CUST_CD VARCHAR2(10), ORD_DT VARCHAR2(8), -- 날짜를 문자열로. 정렬은 되지만 계산은 못 한다 STS CHAR(2), -- '10' '20' '30' '99' — 어디에도 안 적혀 있다 TOT_AMT NUMBER, -- 소수점이 들어간 행이 37건 있다 VAT NUMBER, REMARK1 VARCHAR2(200), -- 쓰다 만 칸. 전부 NULL REMARK2 VARCHAR2(200), -- 실제로는 배송 메모가 들어 있다 USE_YN CHAR(1) DEFAULT 'Y', -- 지운 것을 'N' 으로. 진짜로 지운 적은 없다 INS_ID VARCHAR2(20), INS_DT DATE, CONSTRAINT PK_TB_ORDER PRIMARY KEY (ORD_NO) ); -- 인덱스 : PK 하나. 거래처로 조회하면 412만 행을 전부 읽는다
// prisma/schema.prisma model Order { id String @id @default(uuid(7)) orderNo String @unique // 구 시스템 번호를 그대로 들고 온다 customerId String orderedOn DateTime @db.Date // 날짜는 날짜로 dueOn DateTime @db.Date status OrderStatus // 값의 뜻이 코드에 적혀 있다 goodsAmount Int // 원 단위 정수 — 소수점 사고를 없앤다 vatAmount Int shippingMemo String? // REMARK2 가 실제로 담고 있던 것 note String? deletedAt DateTime? // USE_YN='N' 12,408건이 여기로 lines OrderLine[] @@index([customerId, orderedOn]) @@index([status, orderedOn]) } enum OrderStatus { RECEIVED SHIPPED CLOSED CANCELED }
412만 행을 옮기는 일의 대부분은 이 표를 만드는 일이었습니다
보정 12,659행| 구 컬럼 | 새 컬럼 | 걸린 행 | 어떻게 했나 |
|---|---|---|---|
| ORD_DT VARCHAR2(8) | orderedOn DATE | 214 | 빈 값 214행. 등록일(INS_DT)로 채우고 migrated_from 에 표시를 남겼습니다. 지어내지 않고 남겨 둡니다. |
| STS CHAR(2) | status enum | 31 | 규격에 없는 값 '40' 이 31행. 2016년에 잠깐 쓰다 만 "보류" 였습니다. 만든 사람을 찾아 물어봤습니다. |
| TOT_AMT NUMBER | goodsAmount Int | 37 | 소수점이 있는 행 37개. 반올림하고 원본 값을 따로 보관했습니다. 회계팀 확인을 받았습니다. |
| REMARK2 VARCHAR2(200) | shippingMemo · note | 8,940 | 85%가 배송 메모. "부재시 경비실", "오전배송" 같은 것들. 나머지는 note 로 보냈습니다. |
| USE_YN CHAR(1) | deletedAt | 12,408 | 지운 시각을 아무도 안 적었습니다. 이관 시각으로 넣고, 진짜 삭제 시각은 모른다고 문서에 적었습니다. |
| CUST_CD VARCHAR2(10) | customerId | 29 | 거래처 표에 없는 코드 29개. 폐업한 곳입니다. 거래처를 먼저 만들고 이어 붙였습니다. |
옮긴 뒤에도 두 시스템이 같은 값을 갖고 있는지 매일 확인합니다
-- 매일 03:00 · 두 시스템을 통째로 맞춰 본다 WITH legacy AS ( SELECT ORD_NO, TOT_AMT, VAT, STS FROM gsord11.TB_ORDER WHERE ORD_DT = TO_CHAR(SYSDATE - 1, 'YYYYMMDD') ), fresh AS ( SELECT order_no, goods_amount, vat_amount, status FROM orders WHERE ordered_on = CURRENT_DATE - 1 ) SELECT COALESCE(l.ORD_NO, f.order_no) AS ord_no, l.TOT_AMT AS legacy_amt, f.goods_amount AS new_amt, CASE WHEN l.ORD_NO IS NULL THEN '새 시스템에만 있음' WHEN f.order_no IS NULL THEN '구 시스템에만 있음' WHEN l.TOT_AMT <> f.goods_amount THEN '금액 다름' WHEN l.VAT <> f.vat_amount THEN '부가세 다름' ELSE '상태 다름' END AS reason FROM legacy l FULL OUTER JOIN fresh f ON l.ORD_NO = f.order_no WHERE l.ORD_NO IS NULL OR f.order_no IS NULL OR l.TOT_AMT <> f.goods_amount OR l.VAT <> f.vat_amount OR l.STS <> map_status(f.status); -- 결과가 0건이어도 좋아하지 않는다. 쿼리가 안 돌았을 때도 0건이다.
무엇을 먼저 넘길지가 이 일의 대부분입니다
| 파 | 묶음 | 화면 | 비중 | 위험도 | 시기 | 지금 | 되돌리면 |
|---|---|---|---|---|---|---|---|
옮기는 동안 두 시스템이 같은 데이터를 봅니다
되돌리기가 빠른 이유는 되돌릴 것이 설정 파일 한 장이기 때문입니다
# gateway/routes.yaml — 이 파일 한 장이 되돌리기의 전부다 waves: - id: 1 name: 조회 화면 traffic: 100 routes: [GET /orders, GET /orders/{id}, GET /stock] - id: 2 name: 주문 등록·수정 traffic: 60 # ← 이 숫자만 0 으로 바꾸면 구 시스템으로 돌아간다 routes: [POST /orders, PUT /orders/{id}] sticky: customer_id # 같은 거래처는 늘 같은 쪽으로. 새로고침마다 바뀌면 못 쓴다 - id: 3 name: 출고·송장 traffic: 0 routes: [POST /shipments, POST /invoices] guards: # 넘으면 자동으로 traffic 을 0 으로 내린다 error_rate: { limit: 0.5, window: 5m } diff_count: { limit: 20, window: 24h } p95_latency: { limit: 800ms, window: 5m }
// sync/outbox-relay.ts — 새 시스템에 쓴 것을 구 시스템에도 쓴다 // 주문 저장과 같은 트랜잭션에서 outbox 행이 생기므로, 둘이 어긋날 수 없다. export async function relay() { const batch = await db.outbox.findMany({ where: { sentAt: null }, orderBy: { id: "asc" }, take: 100, }); for (const ev of batch) { try { await legacy.upsertOrder(toLegacyRow(ev.payload)); // TB_ORDER 에 그대로 await db.outbox.update({ where: { id: ev.id }, data: { sentAt: new Date() } }); } catch (e) { await db.outbox.update({ // 실패는 버리지 않고 남긴다 where: { id: ev.id }, data: { tries: { increment: 1 }, lastError: String(e) }, }); if (ev.tries >= 5) await alert.page("outbox 5회 실패", ev); // 사람을 부른다 } } } // 평균 지연 240ms · 어제 재시도 3건 · 미전송 0건
매일 새벽 3시에 두 시스템을 통째로 맞춰 봅니다
| 표 | 대조한 행 | 차이 | 원인과 조치 |
|---|---|---|---|
구 시스템을 끄는 날과, 끄지 못하고 돌아오는 경우
새벽 세 시에 판단하지 않으려고 미리 적었습니다
실제로 해 보지 않은 되돌리기는 되돌리기가 아닙니다
| 날짜 | 무엇을 | 걸린 시간 |
|---|---|---|
| 2026-06-21 | 1파 전체를 구 시스템으로 | 18초 |
| 2026-07-19 | 2파 60% → 0% | 22초 |
| 2026-08-16 | 2파 전체 + 이중 쓰기 정지 | 41초 |
한 달에 한 번, 영업이 끝난 뒤에 진짜로 되돌려 봅니다. 위에서 되돌리기 를 누르면 이 표에 기록이 남습니다.
$ bridge rollback --wave=2 --to=0 --reason="정합성 차이 24건 (기준 20건)" wave 2 주문 등록·수정 60% → 0% ├ gateway/routes.yaml 갱신 0.4s ├ 게이트웨이 12대에 반영 6.1s ├ 진행 중이던 요청 대기 (max 30s) 8.7s ├ outbox 잔여 4건 전송 완료 2.2s └ 이중 쓰기 정지 (구 → 신 CDC 는 계속) 0.3s 완료 · 17.7초 · 유실 0건 구 시스템이 100% 받고 있습니다. 원인은 지금부터 찾으세요.
화면보다 먼저 정한 것들
주말에 다 옮기고 월요일에 여는 방식은, 잘못됐을 때 되돌릴 시간이 없습니다.
화면을 묶어 네 번에 나누고, 각 묶음의 트래픽을 0 → 10 → 50 → 100% 로 올립니다.
한 번에 하나만 바꿔야 무엇 때문에 나빠졌는지 알 수 있습니다.
"이관" 을 한 덩어리로 부르면 견적도 한 덩어리가 됩니다. 자산을 하나씩 세어 놓고
Rehost · Replatform · Refactor · Rebuild · Replace · Retain · Retire 중에서 각각 고릅니다.
15개 중 4개는 Retire 로 정했습니다. 만드는 쪽이 제일 재미있다고 전부 Rebuild 로 잡으면,
만들지 않아도 될 것까지 만들게 됩니다.
사용량이 많은 것도, 만들기 쉬운 것도 아닙니다. 잘못됐을 때 티가 빨리 나고 되돌리기 쉬운 것부터입니다. 조회 화면은 틀려도 새로고침으로 끝나지만, 마감 배치는 숫자가 이미 밖으로 나간 뒤에 압니다.
새 시스템에 쓴 것을 구 시스템에도 같이 씁니다. 그래서 되돌리기가 라우팅 한 줄로 끝납니다. 데이터를 한쪽에만 쌓아 두면, 되돌리는 순간 그동안의 주문이 사라집니다.
새벽 세 시에 "이 정도면 괜찮은가" 를 판단하게 두지 않습니다. 오류율·데이터 차이·응답 시간 셋 중 하나라도 넘으면 상의하지 않고 되돌립니다. 원인은 구 시스템이 전부 받고 있는 동안 찾습니다.
그리고 한 달에 한 번 진짜로 되돌려 봅니다. 해 보지 않은 되돌리기는 되돌리기가 아닙니다.
화면 87개 중 35개는 6개월간 아무도 열지 않았습니다. 옮기면 만들고, 시험하고, 앞으로 계속 고쳐야 합니다. 지우는 것도 이관 계획의 일부입니다. 다만 데이터는 지우지 않고 읽기 전용으로 남깁니다 — 화면과 데이터는 다른 문제입니다.
테스트가 하나도 없는 34만 줄입니다. 옮긴 코드가 맞는지 확인할 방법이 없습니다. 그래서 화면이 무엇을 받아 무엇을 내놓는지를 기준으로 다시 만들고, 같은 입력을 두 시스템에 동시에 넣어 결과를 대조합니다.
화면을 만들기 전에 회귀 테스트 3,548개부터 만들었습니다. 6주 걸렸고, 그만한 값을 했습니다.
테스트가 하나도 없는 34만 줄을 옮기는 유일한 방법은,
옮긴 것이 예전과 같은 값을 내는지 기계가 매번 확인하게 만드는 것입니다.
구 시스템의 이상해 보이는 계산도 틀린 게 아니라 이 회사의 규칙입니다. 부가세를 행마다 반올림하든 주문 단위로 하든, 지금 장부에 찍혀 있는 숫자가 기준입니다. 먼저 그대로 옮기고, 고치는 건 이관이 끝난 뒤에 따로 이야기합니다.
이관과 개선을 같이 하면, 달라진 것이 이관 때문인지 새 기능 때문인지 알 수 없습니다. 요구가 들어오면 목록에 적어 두고 컷오버 뒤에 합니다. 대신 새 화면이 있는 곳은 구 시스템을 고치지 않습니다.
컷오버 다음 날 끄지 않습니다. 읽기 전용으로 두 달, 그다음 데이터만 5년 보관합니다. "예전 화면에서 보던 그 숫자" 를 찾는 사람이 반드시 나오고, 그때 없으면 신뢰를 잃습니다.
서버도 두 시스템도 없습니다. 지표는 손잡이 위치로 계산한 값이고, 새로고침하면 처음으로 돌아갑니다. 실제 프로젝트에서는 이 자리에 라우팅 게이트웨이, CDC 파이프라인, 대조 배치, 그리고 새벽에 이 화면을 보고 있는 사람이 있습니다.