부분집합 · 조합 · Greedy 이번에는 완전탐색에서 자주 사용하는 부분집합, 조합 과 현재 상황에서 가장 좋은 선택을 하는 Greedy 알고리즘 을 정리한다. 1. 부분집합 주어진 집합에서 일부 원소를 선택하여 만든 집합이다. 아무것도 선택하지 않은 공집합 도 포함한다. 원소가 N 개라면 부분집합의 개수는 2^N 개이다. 예를 들어 {A, B, C} 의 부분집합은 다음과 같다. {} {A} {B} {C} {A, B} {A, C} {B, C} {A, B, C} 재귀를 이용한 부분집합 각 원소마다 선택하거나 선택하지 않는 2가지 경우 를 만든다. Branch = 2 Level = 원소의 개수 arr = ['O', 'X'] path = [] def run(level): if level == 3: print(path) return for i in range(2): path.append(arr[i]) run(level + 1) path.pop() run(0) O 는 선택, X 는 선택하지 않는 경우이다. Binary Counting 부분집합은 이진수를 이용해서도 표현할 수 있다. 000 001 010 011 100 101 110 111 0 : 선택하지 않음 1 : 선택 예를 들어 101 이라면 {A, C} 가 선택된 것이다. arr = ['A', 'B', 'C'] n = len(arr) def get_sub(target): for i in range(n): if target & 0x1: print(arr[i], end=' ') target >>= 1 target & 0x1 로 마지막 비트가 1 인지 확인하고, target >>= 1 로 다음 비트를 확인한다. 2. 조합 서로 다른 N 개의 원소 중 R 개를 순서 없이 선택한다. 순열과 다르게 선택한 순서는 고려하지 않는다. 예를 들어 A, B, C 중 2개를 선택하면 AB AC BC 가 된다. AB 와 BA 는 같은 경우이다. 순열 : 순서가 중요하다. 조합 : 순서가 중요하지 않다. 조합 구현 조합에서는 이미 확인한 원소를 다시 확인하지 않기 위해 start 를 사용한다. arr = ['A', 'B', 'C', 'D', 'E'] path = [] def recur(cnt, start): if cnt == 3: print(*path) return for i in range(start, len(arr)): path.append(arr[i]) recur(cnt + 1, i + 1) path.pop() recur(0, 0) 핵심은 다음 재귀에서 i + 1 부터 확인하는 것이다. A 선택 → B, C, D, E 확인 B 선택 → C, D, E 확인 C 선택 → D, E 확인 이렇게 하면 AB 를 선택한 뒤 BA 를 다시 만드는 중복을 막을 수 있다. 3. 탐욕 알고리즘 Greedy 현재 상황에서 가장 좋아 보이는 선택 을 반복하는 알고리즘이다. 모든 경우를 확인하는 완전탐색과 달리 하나의 기준을 정해서 선택한다. 현재의 최선이 전체의 최선이 되는 문제에서 사용할 수 있다. 동전 문제 동전이 다음과 같이 있다고 하자. 500원 100원 50원 10원 1730원 을 최소 개수의 동전으로 거슬러 준다면 큰 동전부터 사용한다. 500원 × 3 = 1500원 100원 × 2 = 200원 10원 × 3 = 30원 총 8개 coin_list = [500, 100, 50, 10] target = 1730 cnt = 0 for coin in coin_list: possible_cnt = target // coin cnt += possible_cnt target -= coin * possible_cnt print(cnt) target // coin 으로 현재 동전을 최대 몇 개 사용할 수 있는지 구한다. Greedy가 항상 정답은 아니다 동전이 다음과 같이 있다고 하자. 70원 50원 10원 100원 을 만들어야 할 때 큰 동전부터 선택하면 70 + 10 + 10 + 10 = 4개 가 필요하다. 하지만 실제 최소 동전은 50 + 50 = 2개 이다. 따라서 Greedy는 단순히 가장 큰 값이나 가장 작은 값을 선택하는 것이 아니라, 문제에 맞는 선택 기준이 성립하는지 확인하는 것이 중요하다. 4. Greedy 대표 문제 Knapsack 정해진 무게 안에서 최대 가치를 만드는 문제이다. 예를 들어 최대 30kg 까지 담을 수 있고 다음 물건들이 있다고 하자. 물건 무게 가치 물건1 5kg 50만원 물건2 10kg 60만원 물건3 20kg 140만원 일반적인 0-1 Knapsack 에서는 물건을 통째로 넣거나 넣지 않아야 한다. 따라서 단순히 가치가 높은 물건이나 kg 당 가치가 높은 물건부터 선택한다고 해서 항상 정답이 되는 것은 아니다. Fractional Knapsack Fractional Knapsack은 물건을 필요한 만큼 잘라서 넣을 수 있는 문제 이다. 이 경우에는 가치 / 무게 즉, 단위 무게당 가치가 높은 물건부터 선택하는 Greedy 방식 을 사용할 수 있다. 0-1 Knapsack → 물건을 나눌 수 없음 Fractional Knapsack → 물건을 나눌 수 있음 활동 선택 문제 여러 개의 회의가 있을 때 서로 겹치지 않으면서 최대한 많은 회의를 선택하는 문제이다. 이 문제에서는 종료 시간이 가장 빠른 회의부터 선택한다. 진행 과정은 다음과 같다. 1. 종료 시간이 빠른 순서로 정렬한다. 2. 가장 빨리 끝나는 회의를 선택한다. 3. 선택한 회의가 끝난 뒤 시작할 수 있는 회의를 찾는다. 4. 그중 다시 가장 빨리 끝나는 회의를 선택한다. 5. 반복한다. 정리 부분집합 → 각 원소를 선택 / 선택하지 않음 → 경우의 수는 2^N Binary Counting → 0과 1로 부분집합 표현 조합 → 순서 없이 선택 → start를 이용해서 중복 방지 Greedy → 현재 가장 좋아 보이는 선택을 반복 → 선택 기준이 전체 최적해로 이어지는지 확인 0-1 Knapsack → 물건을 나눌 수 없음 Fractional Knapsack → 물건을 나눌 수 있음 → 단위 무게당 가치가 높은 순서로 선택 활동 선택 문제 → 종료 시간이 가장 빠른 활동부터 선택
스택을 처음 배우면 나중에 넣은 데이터를 먼저 꺼내는 자료구조라고 설명한다. const stack: string[] = []; stack.push("A"); stack.push("B"); const value = stack.pop(); // "B" push() 로 데이터를 쌓고 pop() 으로 가장 최근 데이터를 꺼낸다. 접시를 위로 쌓았다가 위에서부터 꺼내는 모습으로 이해하면 LIFO(Last In, First Out)의 동작을 쉽게 기억할 수 있다. 입문 단계에서는 충분한 설명이지만, 이 정의만으로는 서비스에서 언제 스택을 선택해야 하는지 판단하기 어렵다. 배열의 마지막 요소를 꺼낼 수 있다고 해서 모든 데이터가 스택이 되는 것은 아니기 때문이다. 문서 편집기의 실행 취소 기능을 생각해보자. 크리스가 제목을 수정하고, 문단을 추가한 뒤, 이미지 하나를 삭제했다. 실행 취소 버튼을 누르면 어떤 작업부터 되돌려야 할까? 작업 이름만 저장하면 원래 상태를 복구할 수 있을까? 여러 번 되돌린 뒤 새로운 내용을 입력하면 기존의 다시 실행 기록은 어떻게 처리해야 할까? 실제 서비스에서 스택은 데이터를 거꾸로 꺼내기 위한 구조가 아니라, 가장 최근의 상태 변화가 먼저 해결되어야 하는 의존 관계를 표현하는 구조다. 왜 가장 최근 작업부터 되돌려야 할까? 크리스가 메모 앱에서 다음 순서로 문서를 편집했다고 해보자. 1. 제목을 "Meeting"에서 "Project Meeting"으로 변경 2. 첫 번째 문단 추가 3. 이미지 삭제 현재 문서 상태는 세 작업이 순서대로 반영된 결과다. 여기서 첫 번째 작업인 제목 변경만 먼저 취소하면, 그 이후 작업들이 어떤 상태를 기준으로 실행되었는지 설명하기 어려워진다. 반면 마지막에 적용한 이미지 삭제부터 취소하면 그 직전 상태로 자연스럽게 돌아갈 수 있다. type EditAction = | { type: "change-title"; previousTitle: string; nextTitle: string; } | { type: "insert-paragraph"; paragraphId: string; position: number; content: string; } | { type: "delete-image"; imageId: string; position: number; source: string; }; const undoStack: EditAction[] = []; 스택에는 화면에 표시할 문서 내용 자체보다 문서를 어떻게 변경했는지를 기록한다. 이미지 삭제를 되돌리려면 이미지 ID뿐 아니라 원래 위치와 이미지 주소도 필요하다. delete-image 라는 작업 이름만 남기면 무엇을 어디에 복구해야 하는지 알 수 없다. 스택이 해결하는 핵심은 최근 항목 조회가 아니다. 여러 변경이 앞선 결과 위에 차례로 적용되었을 때, 그 의존 관계를 역순으로 해제하는 것이다. 작업 이름만 저장해서는 상태를 복구할 수 없다 다음 구현은 편집 작업의 종류만 스택에 넣는다. // Bad: 되돌리는 데 필요한 이전 상태가 없다. undoStack.push({ type: "change-title" }); 이 기록을 꺼내도 이전 제목이 무엇이었는지 알 수 없다. 서버에서 변경 이력을 다시 조회하거나 현재 문서를 추측해서 복구해야 한다. 실행 취소 기록에는 작업을 식별하는 정보뿐 아니라 반대 작업을 수행하는 데 필요한 데이터가 포함되어야 한다. // Better: 변경 전후의 값을 함께 기록한다. undoStack.push({ type: "change-title", previousTitle: document.title, nextTitle: newTitle }); document.title = newTitle; 실행 취소할 때는 가장 최근 작업을 꺼내 변경 전 값을 복원한다. function undo(document: DocumentState) { const action = undoStack.pop(); if (!action) { return document; } if (action.type === "change-title") { document.title = action.previousTitle; } return document; } 여기서 previousTitle 은 사용자의 입력이 아니라 변경 직전에 애플리케이션이 확인한 상태여야 한다. 클라이언트가 이전 제목을 함께 보내더라도 그대로 믿으면 다른 문서의 값이나 오래된 값을 복원할 수 있다. 현실의 편집 작업은 다음과 같이 코드의 흐름으로 바뀐다. 입력 사용자가 요청한 새 제목과 문서 ID 상태 검증된 현재 제목과 실행 취소 작업 스택 출력 변경된 문서와 실행 취소 가능 여부 서버나 편집기는 문서 접근 권한과 현재 상태를 먼저 확인한다. 그다음 변경 직전의 값을 스택에 기록하고 새 값을 반영한다. 입력 검증, 상태 변경, 복구 정보 저장이 하나의 작업으로 이어져야 실행 취소 결과를 신뢰할 수 있다. 실행 취소와 다시 실행에는 두 개의 스택이 필요하다 사용자는 실행 취소만 누르지 않는다. 되돌린 작업을 다시 적용하기도 한다. 이를 위해 보통 실행 취소 스택과 다시 실행 스택을 따로 관리한다. const undoStack: EditAction[] = []; const redoStack: EditAction[] = []; function undo(document: DocumentState) { const action = undoStack.pop(); if (!action) { return document; } applyReverse(document, action); redoStack.push(action); return document; } function redo(document: DocumentState) { const action = redoStack.pop(); if (!action) { return document; } apply(document, action); undoStack.push(action); return document; } 크리스가 세 번 편집한 뒤 두 번 실행 취소하면, 취소된 두 작업은 다시 실행 스택에 쌓인다. 이때 크리스가 새로운 문단을 입력하면 어떻게 해야 할까? function recordNewAction(action: EditAction) { undoStack.push(action); redoStack.length = 0; } 새로운 작업은 기존 편집 흐름에서 다른 분기를 만든다. 이전에 취소했던 작업을 그대로 다시 실행하면 크리스가 방금 만든 상태와 충돌할 수 있으므로 다시 실행 스택을 비운다. 이 규칙은 push() 와 pop() 의 사용법만으로 나오지 않는다. 사용자가 기대하는 편집 흐름을 먼저 정의한 뒤, 그 흐름과 스택의 성질을 연결한 결과다. 모든 변경 이력을 스택으로 관리할 필요는 없다 실행 취소 기록과 감사 로그는 비슷해 보이지만 목적이 다르다. 실행 취소는 현재 상태에서 가장 최근 작업을 되돌리는 기능이다. 감사 로그는 누가 언제 무엇을 변경했는지 장기간 조회하기 위한 기록이다. type AuditLog = { id: string; documentId: string; userId: string; action: string; createdAt: Date; }; 감사 로그에서는 특정 날짜의 기록을 검색하거나 여러 사용자의 작업을 시간순으로 조회해야 한다. 중간 기록을 직접 찾아야 하므로 마지막 항목만 다루는 스택으로는 충분하지 않다. 데이터베이스에 저장된 변경 이벤트나 별도의 이력 테이블이 더 잘 맞는다. 실행 취소 스택의 생명주기도 결정해야 한다. 브라우저 탭이 닫히면 사라져도 되는 로컬 편집 기록이라면 메모리에 둘 수 있다. 로그인한 사용자가 다른 기기에서도 편집을 이어가야 한다면 서버에 저장하거나 문서 버전으로 관리해야 한다. 스택을 어디에 저장할지는 자료구조보다 제품 요구사항에서 결정된다. 공동 편집에서는 범위가 더 중요하다. 모든 사용자의 작업을 하나의 전역 스택에 쌓으면 크리스가 실행 취소를 눌렀을 때 다른 사용자의 최근 작업이 사라질 수 있다. 일반적인 공동 편집기라면 사용자별 작업 범위를 구분하고, 다른 변경과 충돌하지 않는 방식으로 되돌려야 한다. 이 단계에서는 단순한 스택만으로 충분하지 않을 수 있다. 스택을 선택하기 전에 확인할 질문 가장 최근에 생성된 상태부터 처리해야 하는 이유가 분명한가? 각 항목에는 작업을 되돌리는 데 필요한 이전 상태가 포함되어 있는가? 실행 취소 후 새로운 작업이 들어오면 다시 실행 기록을 어떻게 처리할 것인가? 스택이 비어 있을 때의 동작을 정의했는가? 기록은 브라우저 세션까지만 유지하면 되는가, 서버에 저장해야 하는가? 사용자별 실행 취소와 전체 변경 이력을 구분했는가? 기록이 계속 쌓이지 않도록 최대 개수나 보관 기간을 정했는가? 최근 상태부터 풀어야 하는 흐름에 스택이 맞는다 문서 편집기의 실행 취소가 스택과 잘 맞는 이유는 마지막 작업이 가장 최근 상태를 만들었고, 그 상태부터 역순으로 해제해야 이전 상태를 안전하게 복원할 수 있기 때문이다. 이때 스택 항목에는 작업 이름뿐 아니라 복구에 필요한 데이터가 있어야 하며, 다시 실행과 새 작업의 관계도 함께 정의해야 한다. 반대로 특정 시점의 기록을 검색하거나 모든 변경을 장기간 보관하려는 목적이라면 감사 로그나 버전 이력이 더 적합하다. 스택을 선택하는 기준은 데이터를 넣고 빼는 모양이 아니라, 최근 상태를 먼저 처리해야 하는 서비스 규칙이 존재하는지다. 다음 글에서는 큐를 통해 요청이 들어온 순서와 실제 처리 순서를 어떻게 통제하는지 살펴본다.
조합적 문제 부분집합 어떤 집합의 공집합과 자기 자신을 포함한 모든 부분 구하고자 하는 어떤 집합의 원소 개수가 n일 경우 부분집합의 수 = 2^n개 집합에서 부분집합을 찾아내는 구현 방법 1. 완전 탐색 재귀호출을 이용한 완전탐색으로 부분집합을 구할 수 있음 실전 보다는 안전 탐색 학습용으로 추천하는 방법 2. Binary Counting 2진수와 비트연산을 이용해 부분집합을 구할 수 있음 모든 부분집합이 필요할 때 사용 하는 추천 방법 완전 탐색으로 부분집합 구하기 # 민철이에게는 세명의 친구가 있습니다. {MIN, CO, TIM} # 함께 영화관에 갈 수 있는 멤버를 구성하고자 합니다. # 모든 경우의 수를 출력해봅시다. 완전 탐색을 이용해 구현 O, X로 집합에 포함시킬지 말지 결정 코드 구현 Branch : 2개 Level : 3개 arr = ['O', 'X'] path = [] name = ['MIN', 'CO', 'TIM'] def run(lev): if lev == 3: print(path) return for i in range(2): path.append(arr[i]) run(lev + 1) path.pop() run(0) 실행 결과 ['O', 'O', 'O'] ['O', 'O', 'X'] ['O', 'X', 'O'] ['O', 'X', 'X'] ['X', 'O', 'O'] ['X', 'O', 'X'] ['X', 'X', 'O'] ['X', 'X', 'X'] #### 완성된 소스 코드 * 이름 출력 코드 추가 ```python arr = ['O', 'X'] path = [] name = ['MIN', 'CO', 'TIM'] def run(lev): if lev == 3: print_name() return for i in range(2): path.append(arr[i]) run(lev + 1) path.pop() run(0) # 이름 출력 함수 def print_name(): print('{', end=' ') for i in range(3): if path[i] == 'O': print(name[i], end=' ') print('}') # 출력 결과 { MIN CO TIM } { MIN CO } { MIN TIM } { MIN } { CO TIM } { CO } { TIM } { } 바이너리 카운팅 (Binary Counting) 원소 수에 해당하는 N개의 비트열을 이용해 부분집합을 표시 001 이면 부분집합 {A}를 나타냄 0번 비트가 1이므로 첫 원소인 A만 포함된 부분집합을 나타냄 110이면 부분집합 {B, C}를 나타냄 1번, 2번 비트가 1이므로, 두번째와 세 번째 원소인 B, C 가 포함된 부분집합을 나타냄 10진수 이진수 {A, B. C} 0 000 {} 1 001 {A} 2 010 {B} 3 011 {A, B} 4 100 {C} 5 101 {A, C} 6 110 {B, C} 7 111 {A, B, C} 부분닙합의 총 개수 만들 수 있는 집합의 총 개수는 2^n 이며, n=3 이기기에 총 8개의 부분집합 존재 2^n은 1<<n 공식을 이용해 빠르게 구할 수 있음 print(pow(2, 3)) print(1 << 3) # 출력 결과 8 8 부분집합 {B, C} 만드는 rhkwjd 6(0b110)에서 비트 연산을 이용해 마지막 한 자리가 1인지 0인지 검사 arr = ['A', 'B', 'C'] n = len(arr) def get_sub(tar): for i in range(n): if tar & 0x1: print(arr[i], end= '') tar >>= 1 # 검사한 한 자리를 제거 get_sub(6) #### 완성된 부분집합 코드 * get_sub(0) ~ get_sub(7) 까지 호출하여 모든 부분집합 출력 ```python arr = ['A', 'B', 'C'] n = len(arr) def get_sub(tar): for i in range(n): if tar & 0x1: print(arr[i], end=' ') tar >>= 1 # 검사한 한 자리를 제거 for tar in range(1 << n): # range(0, 8) print('{', end= ' ') get_sub(tar) print('}') # 출력 결과 {} { A } { B } { A B } { C } { A C } { B C } { A B C } 조합 (Combination) 서로 다른 n개의 원소 중 r 개를 순서 없이 골라낸 것 순열과 조합 차이 탐욕 알고리즘 Greedy (탐욕 알고리즘) 결정이 필요할 때, 현재 기준으로 가장 좋아 보이는 선택지로 결정해 답을 도출하는 알고리즘 대표적인 문제해결 기법 완전 탐색(Brute-Force) 답이 될 수 있는 모든 경우를 시도해보는 알고리즘 Greedy 결정이 필요할 때 가장 좋아보이는 선택지로 결정하는 알고리즘 DP 현재에서 가장 좋아보이는 것을 선택하는 것이 아닌, 과거의 데이터를 이용해 현재의 데이터를 만들어내는 문제해결 기법 분할 정복 큰 문제를 작은 문제로 나누어 해결하는 문제해결 기법 Knapsack 문제 활동 선택 문제
전설의 탈북인의 본명은 이사진입니다. 160cm의 키를 가지고 있고 팔을 돌리며 뛰는 특이한 달리기로 100미터를 10.5초만에 달립니다. 그리고 굉장한 근육을 가지고 있습니다. 전설의 탈북인인 이유는 막화장실 미확인 구역의 완전 북쪽 끝에서부터 확인 구역의 2번 라인 위험구역까지 온전히 걸어서 왔기 때문입니다. 이는 약 우주 407.8억개 길이로 추정되고, 이 거리를 걸으려면 810,000,000,000,000,000,000,000,000,000년 정도가 걸릴 걸로 예상됩니다. 그래도 이는 막주영보다는 적은 나이이기 때문에 막화장실 나이 2-3위 정도로 추정됩니다.
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 들이 여러작업이 실제 같은 시점에 실행되는것이다.