Chapter 04 트리 트리는 데이터를 부모와 자식의 관계로 연결하는 계층적 자료구조이다. 아래 내용은 제공된 목차에 맞춘 일반적인 자료구조 정리이며, 코드는 Python으로 작성했다. 높이와 레벨은 교재마다 기준이 다르다. 이 정리에서는 루트의 레벨을 1, 트리의 높이를 전체 레벨 수로 정한다. 04-1 트리란? 트리(Tree) : 노드들을 계층적으로 연결한 비선형 자료구조이다. 리스트와 스택처럼 데이터를 한 줄로 나열하는 구조와 달리, 하나의 노드에서 여러 갈래로 뻗어나갈 수 있다. 대표적인 예: 컴퓨터의 폴더 구조, 회사의 조직도, 가계도. 루트가 있는 트리는 하나의 루트와 그 아래에 연결된 여러 서브트리로 구성된다. 비어 있지 않은 트리에서 루트를 제외한 각 노드는 부모를 정확히 하나 가진다. 트리는 연결되어 있고 사이클이 없으며, 두 노드 사이의 단순 경로는 유일하다. 트리 관련 용어 다음 관계를 예로 사용한다. A의 자식: B, C B의 자식: D, E C의 자식: F D, E, F는 자식이 없다. 용어 의미 예시 노드(Node) 데이터를 저장하는 기본 단위 A, B, C, D, E, F 간선(Edge) 두 노드를 연결하는 선 A와 B의 연결 루트(Root) 트리의 가장 위에 있는 노드 A 부모(Parent) 바로 위에 연결된 노드 D의 부모는 B 자식(Child) 바로 아래에 연결된 노드 B의 자식은 D, E 형제(Sibling) 부모가 같은 노드 D와 E 조상(Ancestor) 루트에서 해당 노드까지의 경로에 있는 상위 노드 D의 조상은 A, B 자손(Descendant) 해당 노드 아래의 모든 노드 B의 자손은 D, E 단말 노드(Leaf) 자식이 없는 노드 D, E, F 내부 노드(Internal node) 자식이 하나 이상 있는 노드 A, B, C 노드의 차수(Degree) 해당 노드의 자식 수 B의 차수는 2 트리의 차수 모든 노드의 차수 중 최댓값 2 깊이(Depth) 루트에서 해당 노드까지의 간선 수 A는 0, D는 2 레벨(Level) 노드가 속한 층 A는 1, D는 3 높이(Height) 이 정리에서는 트리의 전체 레벨 수 3 경로(Path) 한 노드에서 다른 노드로 이동할 때 거치는 노드들의 순서 A → B → D 경로 길이 경로에 포함된 간선 수 A → B → D의 길이는 2 서브트리(Subtree) 한 노드와 그 노드의 모든 자손으로 이루어진 트리 B, D, E로 이루어진 트리 포리스트(Forest) 서로 분리된 트리들의 모임 루트 A를 제거한 뒤 남은 두 트리 꼭 기억할 성질 노드가 n개인 비어 있지 않은 트리의 간선 수는 n - 1개 이다. 루트에는 부모가 없고, 나머지 노드에는 부모가 하나씩 있다. 단말 노드의 차수는 0 이다. 빈 트리의 높이는 0, 루트 하나만 있는 트리의 높이는 1로 계산한다. 높이를 간선 수로 정의하는 교재에서는 루트 하나의 높이가 0이다. 문제를 풀 때 기준을 먼저 확인한다. 트리의 표현 방법 1. 부모-자식 관계를 그림으로 표현 루트를 위에 놓고, 자식 노드를 아래에 배치한다. 부모와 자식을 선으로 연결하면 계층 구조를 쉽게 파악할 수 있다. 2. 중첩된 괄호로 표현 부모 뒤의 괄호 안에 자식들을 나열한다. 위 예시: A(B(D, E), C(F)) A의 자식은 B와 C이며, B의 자식은 D와 E라는 뜻이다. 3. 들여쓰기로 표현 깊이가 깊어질수록 들여쓰기를 늘린다. 폴더 목록이나 목차에서 자주 사용하는 방식이다. A B D E C F 4. 프로그램 내부에서 표현 부모 배열 : 각 노드의 부모를 저장한다. 부모를 찾기 쉽다. 자식 리스트 : 각 노드에 자식 노드들의 목록을 저장한다. 자식 수가 서로 달라도 표현하기 쉽다. 첫째 자식-다음 형제 표현 : 첫 번째 자식과 바로 다음 형제를 가리키는 두 링크를 사용한다. 일반 트리는 자식 수에 제한이 없지만, 이진 트리는 왼쪽과 오른쪽 두 자식 위치를 사용한다. 04-2 이진트리 이진 트리(Binary tree) : 각 노드가 최대 두 개의 자식을 가지는 트리이다. 두 자식은 각각 왼쪽 자식 과 오른쪽 자식 으로 구분한다. 자식이 하나뿐이어도 왼쪽 자식인지 오른쪽 자식인지 구분해야 한다. 왼쪽 서브트리와 오른쪽 서브트리도 각각 이진 트리이다. 빈 트리도 이진 트리로 취급한다. 이진 트리의 기본 성질 높이 h와 레벨의 기준은 루트가 1이다. 레벨 i의 최대 노드 수: 2^(i - 1) 높이 h인 이진 트리의 최대 노드 수: 2^h - 1 높이 h인 이진 트리의 최소 노드 수: h 노드가 n개인 비어 있지 않은 이진 트리의 최소 높이: ceil(log2(n + 1)) 노드가 n개인 이진 트리의 최대 높이: n ceil 은 소수점 아래를 올림한다는 뜻이다. 예: 높이가 3이면 노드 수는 최소 3개, 최대 7개이다. 이진 트리의 종류 종류 특징 기억할 점 포화 이진 트리(Perfect binary tree) 모든 레벨이 노드로 가득 차 있다 높이 h일 때 노드 수는 2^h - 1 완전 이진 트리(Complete binary tree) 마지막 레벨을 제외한 모든 레벨이 가득 차고, 마지막 레벨은 왼쪽부터 빈틈없이 채워진다 배열로 표현하기 좋다 정 이진 트리(Full/Proper binary tree) 모든 노드의 자식 수가 0개 또는 2개이다 자식이 1개인 노드가 없다 편향 이진 트리(Skewed binary tree) 노드들이 왼쪽 또는 오른쪽 한 방향으로만 이어진다 연결 리스트처럼 길어진다 높이 균형 이진 트리 모든 노드에서 두 서브트리의 높이 차이를 제한한다 대표적으로 AVL 트리는 차이가 1 이하이다 포화·완전·정 이진 트리 구분 포화 : 모든 층이 꽉 찼는가? 완전 : 위층이 꽉 차 있고, 마지막 층도 왼쪽부터 채웠는가? 정 : 각 노드의 자식 수가 0개 또는 2개인가? 포화 이진 트리는 완전 이진 트리이면서 정 이진 트리이다. 완전 이진 트리라고 해서 반드시 포화 이진 트리이거나 정 이진 트리인 것은 아니다. 영문 용어와 한글 번역이 교재마다 다를 수 있으므로 이름보다 정의를 기준으로 구분한다. 주의: 이진 트리와 이진 탐색 트리는 다르다. 일반 이진 트리에는 값의 대소 관계에 따른 배치 규칙이 없다. 이진 트리의 표현 방법 1. 배열을 이용한 표현 루트를 인덱스 1에 저장하고, 노드의 위치에 따라 배열 인덱스를 정한다. 대상 현재 노드의 인덱스가 i일 때 부모 i // 2, 단 루트 제외 왼쪽 자식 2 * i 오른쪽 자식 2 * i + 1 예: 1번 노드의 왼쪽 자식은 2번, 오른쪽 자식은 3번에 저장한다. 존재하지 않는 위치는 None 등으로 표시한다. 장점: 부모와 자식의 위치를 계산으로 바로 구할 수 있다. 단점: 편향 트리처럼 비어 있는 자리가 많으면 배열 공간을 낭비한다. 완전 이진 트리에서는 중간에 빈자리가 없어 효율적이다. 루트를 인덱스 0에 저장할 때는 공식이 달라진다. 부모: (i - 1) // 2 , 단 루트 제외 왼쪽 자식: 2 * i + 1 오른쪽 자식: 2 * i + 2 2. 링크를 이용한 표현 각 노드에 데이터, 왼쪽 자식 참조, 오른쪽 자식 참조를 저장한다. class Node: def __init__(self, data, left=None, right=None): self.data = data self.left = left self.right = right data : 노드에 저장할 값 left : 왼쪽 자식 노드. 없으면 None right : 오른쪽 자식 노드. 없으면 None 장점: 실제로 존재하는 노드만 만들 수 있고, 연결 구조를 바꾸기 쉽다. 단점: 자식을 가리키는 참조를 저장할 공간이 추가로 필요하다. 04-3 이진 트리의 연산 이진 트리의 표준순회 순회(Traversal) 란 트리의 모든 노드를 정해진 순서에 따라 한 번씩 방문하는 것이다. 방문은 해당 노드의 데이터를 출력하거나 계산에 사용하는 등의 처리를 뜻한다. 표준 순회 세 가지는 모두 깊이 우선 탐색(DFS)에 해당한다. 순회 방식 방문 순서 루트 처리 시점 대표 활용 전위 순회(Preorder) 루트 → 왼쪽 → 오른쪽 가장 먼저 계층 구조 출력, 전위 수식 중위 순회(Inorder) 왼쪽 → 루트 → 오른쪽 가운데 이진 탐색 트리의 정렬 출력, 중위 수식 후위 순회(Postorder) 왼쪽 → 오른쪽 → 루트 가장 나중 수식 계산, 자식부터 처리하는 연산 순회 예시 다음과 같은 이진 트리를 가정한다. A: 왼쪽 B, 오른쪽 C B: 왼쪽 D, 오른쪽 E C: 왼쪽 F, 오른쪽 G D, E, F, G: 단말 노드 순회 결과 전위 A B D E C F G 중위 D B E A F C G 후위 D E B F G C A 재귀로 구현하기 def preorder(node): if node is not None: print(node.data, end=" ") preorder(node.left) preorder(node.right) def inorder(node): if node is not None: inorder(node.left) print(node.data, end=" ") inorder(node.right) def postorder(node): if node is not None: postorder(node.left) postorder(node.right) print(node.data, end=" ") node is None 이면 더 내려갈 노드가 없으므로 호출을 끝낸다. 세 함수는 현재 노드를 처리하는 print의 위치 만 다르다. 왼쪽과 오른쪽 서브트리에서도 같은 규칙을 반복한다. n개 노드를 모두 방문하므로 시간 복잡도는 O(n) 이다. 재귀 호출 스택의 추가 공간은 높이 h에 대해 O(h) 이다. 암기: 전위는 루트를 앞에, 중위는 가운데에, 후위는 뒤에 처리한다. 주의: 중위 순회의 결과가 정렬되는 것은 이진 탐색 트리 일 때이다. 일반 이진 트리에서는 정렬이 보장되지 않는다. 레벨 순회 레벨 순회(Level-order traversal) : 루트부터 시작하여 같은 레벨의 노드를 왼쪽에서 오른쪽으로 방문한다. 위에서 아래로 한 층씩 방문하는 너비 우선 탐색(BFS)이다. 먼저 발견한 노드를 먼저 처리하기 위해 큐(Queue) 를 사용한다. 동작 과정 루트를 큐에 넣는다. 큐의 맨 앞에서 노드를 꺼내 방문한다. 그 노드의 왼쪽 자식, 오른쪽 자식을 순서대로 큐에 넣는다. 큐가 빌 때까지 반복한다. 위 예시의 레벨 순회 결과: A B C D E F G from collections import deque def levelorder(root): if root is None: return queue = deque([root]) while queue: node = queue.popleft() print(node.data, end=" ") if node.left is not None: queue.append(node.left) if node.right is not None: queue.append(node.right) append() : 큐의 뒤에 넣는다. popleft() : 큐의 앞에서 꺼낸다. 시간 복잡도: O(n) 최대 레벨 너비를 w라고 할 때 큐의 추가 공간: O(w) , 최악의 경우 O(n) Python 리스트의 pop(0) 은 원소 이동이 필요하므로, 큐에는 deque 를 사용한다. 이진 트리의 연산들 1. 전체 노드 수 구하기 전체 노드 수 = 왼쪽 서브트리 노드 수 + 오른쪽 서브트리 노드 수 + 1 def count_nodes(node): if node is None: return 0 return count_nodes(node.left) + count_nodes(node.right) + 1 빈 트리에는 노드가 없으므로 0을 반환한다. 마지막의 + 1 은 현재 노드 자신을 센 것이다. 2. 단말 노드 수 구하기 def count_leaves(node): if node is None: return 0 if node.left is None and node.right is None: return 1 return count_leaves(node.left) + count_leaves(node.right) 왼쪽과 오른쪽 자식이 둘 다 없어야 단말 노드이다. 단말 노드를 만나면 1을 반환하고, 나머지는 양쪽 결과를 더한다. 3. 트리의 높이 구하기 높이 = max(왼쪽 높이, 오른쪽 높이) + 1 def height(node): if node is None: return 0 return max(height(node.left), height(node.right)) + 1 두 서브트리 중 더 깊은 쪽을 기준으로 한다. 현재 노드가 차지하는 한 층을 더한다. 예: 왼쪽 높이 2, 오른쪽 높이 4이면 현재 트리 높이는 5이다. 4. 특정 값 탐색하기 def find(node, target): if node is None: return None if node.data == target: return node result = find(node.left, target) if result is not None: return result return find(node.right, target) 현재 노드 → 왼쪽 → 오른쪽 순서로 찾는다. 찾으면 해당 노드, 찾지 못하면 None 을 반환한다. 일반 이진 트리에는 값의 배치 규칙이 없으므로 최악의 경우 모든 노드를 확인한다. 연산 시간 복잡도 재귀 추가 공간 전체 노드 수 O(n) O(h) 단말 노드 수 O(n) O(h) 높이 O(n) O(h) 값 탐색 최악 O(n) O(h) 삽입과 삭제는 트리의 목적에 따라 규칙이 다르다. 일반 이진 트리, 이진 탐색 트리, 힙에 동일한 삽입·삭제 규칙을 적용하지 않는다. 테스트 프로그램 앞에서 정의한 Node 클래스와 순회·연산 함수들 아래에 다음 코드를 붙여 실행한다. root = Node( "A", Node("B", Node("D"), Node("E")), Node("C", Node("F"), Node("G")) ) print("전위:", end=" ") preorder(root) print() print("중위:", end=" ") inorder(root) print() print("후위:", end=" ") postorder(root) print() print("레벨:", end=" ") levelorder(root) print() print("전체 노드 수:", count_nodes(root)) print("단말 노드 수:", count_leaves(root)) print("높이:", height(root)) result = find(root, "E") print("탐색 결과:", result.data if result is not None else "없음") 예상 출력: 전위: A B D E C F G 중위: D B E A F C G 후위: D E B F G C A 레벨: A B C D E F G 전체 노드 수: 7 단말 노드 수: 4 높이: 3 탐색 결과: E 추가 확인 기준: 빈 트리: 전체 노드 수 0, 단말 노드 수 0, 높이 0 루트만 있는 트리: 전체 노드 수 1, 단말 노드 수 1, 높이 1 노드 3개가 한 방향으로 이어진 트리: 전체 노드 수 3, 단말 노드 수 1, 높이 3 04-4 모스코드결정트리 결정 트리(Decision tree) : 조건이나 입력에 따라 가지를 선택하며 결과를 찾는 트리이다. 모스 코드는 점 . 과 선 - 의 조합으로 문자를 표현한다. 점과 선이라는 두 가지 입력을 이진 트리의 두 방향에 대응시킬 수 있다. 여기서는 점은 왼쪽, 선은 오른쪽 으로 이동하도록 정한다. 결정 트리를 이용한 모스 코드의 디코딩 모스 코드 트리의 구조 루트는 문자를 나타내지 않는 시작 지점이다. 루트에서 왼쪽으로 한 번 이동하면 E( . ), 오른쪽으로 한 번 이동하면 T( - )이다. 루트에서 특정 노드까지 이동한 경로가 그 노드의 모스 코드이다. 문자 모스 코드 루트에서의 이동 E . 왼쪽 T - 오른쪽 I .. 왼쪽 → 왼쪽 A .- 왼쪽 → 오른쪽 N -. 오른쪽 → 왼쪽 M -- 오른쪽 → 오른쪽 S ... 왼쪽 → 왼쪽 → 왼쪽 O --- 오른쪽 → 오른쪽 → 오른쪽 디코딩 과정 현재 위치를 루트로 설정한다. 입력 기호가 . 이면 왼쪽 자식으로 이동한다. 입력 기호가 - 이면 오른쪽 자식으로 이동한다. 한 문자의 코드가 끝나면 현재 노드에 저장된 문자를 읽는다. 다음 문자를 해독할 때는 다시 루트로 돌아간다. 예: ... --- ... ... → 왼쪽 세 번 → S --- → 오른쪽 세 번 → O ... → 왼쪽 세 번 → S 최종 결과: SOS 문자 경계가 중요한 이유 E의 코드 . 은 A의 코드 .- 의 앞부분이기도 하다. 따라서 문자가 저장된 노드에 도착했다고 즉시 한 글자를 확정하면 안 된다. 모스 코드 트리에서는 내부 노드에도 문자가 저장될 수 있다. 실제 신호에서는 시간 간격으로, 텍스트 예시에서는 공백으로 글자를 구분한다. 텍스트에서 단어 사이를 / 로 표시하기도 하지만, 이는 구분을 위한 표기 관례이다. def decode_letter(root, code): if not code: raise ValueError("빈 코드는 해독할 수 없습니다.") node = root for symbol in code: if node is None: raise ValueError("트리에 없는 코드입니다.") if symbol == ".": node = node.left elif symbol == "-": node = node.right else: raise ValueError("점과 선만 입력할 수 있습니다.") if node is None or node.data is None: raise ValueError("문자가 배정되지 않은 코드입니다.") return node.data 위 함수는 모스 코드 규칙에 맞는 트리가 이미 만들어져 있다고 가정한다. 코드 길이가 k이면 이동 횟수도 k이므로 시간 복잡도는 O(k) 이다. 트리 구축 비용을 제외하고, 총 m개 기호를 해독하는 시간은 O(m) 이다. 04-5 수식트리 수식 트리(Expression tree) : 연산자와 피연산자의 관계를 트리로 표현한 것이다. 여기서는 + , - , * , / 처럼 피연산자가 두 개인 이항 연산자를 사용한다. 단말 노드 에는 숫자나 변수 같은 피연산자를 저장한다. 내부 노드 에는 연산자를 저장한다. 왼쪽 서브트리는 왼쪽 피연산자, 오른쪽 서브트리는 오른쪽 피연산자를 나타낸다. 각 연산자 노드에 자식이 둘씩 있으므로, 이 조건의 수식 트리는 정 이진 트리이다. 예: (3 + 2) * (4 - 1) 루트: * 루트의 왼쪽 자식: + , 오른쪽 자식: - + 의 왼쪽·오른쪽 자식: 각각 3, 2 - 의 왼쪽·오른쪽 자식: 각각 4, 1 수식 트리의 계산 계산에는 후위 순회를 사용한다 연산자를 계산하려면 양쪽 피연산자의 값을 먼저 알아야 한다. 따라서 왼쪽 계산 → 오른쪽 계산 → 현재 연산자 적용 순서로 처리한다. 예: (3 + 2) * (4 - 1) 왼쪽 서브트리에서 3 + 2 = 5 를 계산한다. 오른쪽 서브트리에서 4 - 1 = 3 을 계산한다. 루트에서 5 * 3 = 15 를 계산한다. def evaluate(node): if node is None: raise ValueError("빈 수식입니다.") if node.left is None and node.right is None: return float(node.data) if node.left is None or node.right is None: raise ValueError("연산자의 피연산자가 부족합니다.") left_value = evaluate(node.left) right_value = evaluate(node.right) if node.data == "+": return left_value + right_value if node.data == "-": return left_value - right_value if node.data == "*": return left_value * right_value if node.data == "/": return left_value / right_value raise ValueError("지원하지 않는 연산자입니다.") 이 코드는 피연산자가 숫자인 수식을 계산한다. 변수까지 계산하려면 변수 이름에 대응하는 값을 별도로 제공해야 한다. 뺄셈과 나눗셈에
Meta Description AI가 개발 생산성을 60% 높여준다는 소문, 과연 사실일까요? 12만 명의 개발자를 분석한 스탠퍼드 연구의 충격적인 결과와 2026년 가트너가 주목한 'AI 네이티브 로우코드(Low-Code)' 플랫폼이 기업의 IT 백로그를 어떻게 해결하는지 시니어 전략가의 시선으로 파헤쳐 봅니다. 안녕하세요. 다년간 엔터프라이즈 환경에서 IT 전략과 디지털 트랜스포메이션을 이끌어온 시니어 콘텐츠 전략가입니다. 단도직입적으로 묻겠습니다. 최근 사내 개발팀에 AI 코딩 어시스턴트(GitHub Copilot, Cursor 등)를 도입하셨나요? 아마 "AI가 개발 생산성을 60% 이상 끌어올릴 것"이라는 장밋빛 전망을 기대하셨을 겁니다. 쏟아지는 업무 백로그를 AI가 마법처럼 해결해 줄 것이라 믿으셨겠죠. 하지만 현업 리더들의 진짜 속마음은 다릅니다. "생각보다 산출물이 늘지 않는데?", "버그 수정하느라 시간이 더 걸리는 것 같아." 여러분의 직감이 맞습니다. 오늘은 12만 명의 개발자를 추적한 스탠퍼드 대학의 충격적인 연구 결과를 바탕으로, AI 시대의 진짜 개발 생산성은 어디서 오는지, 그리고 가트너(Gartner)가 왜 '로우코드(Low-Code)' 를 2026년 기업 생존의 핵심 키워드로 지목했는지 깊이 있게 파헤쳐 보겠습니다. 1. 환상과 현실의 괴리: 스탠퍼드 연구가 밝힌 차가운 진실 업계에 떠도는 '생산성 60% 향상'이라는 수치는 대부분 통제된 환경의 소규모 '토이 프로젝트'에서 나온 결과입니다. 스탠퍼드 소프트웨어 엔지니어링 생산성 연구 그룹(SWEPR)이 600개 이상의 조직, 12만 명의 엔지니어를 대상으로 분석한 현실은 훨씬 겸허합니다. 진짜 생산성 향상은 10% 15% 수준: 대규모 운영 환경에서 AI 도구를 통한 생산성 향상의 중앙값은 10 15%에 불과했습니다. 심지어 도입 초기(30~100시간)에는 새로운 AI 도구의 특성을 학습하느라 오히려 업무 속도가 떨어지는 현상도 발견되었습니다. '고스트 엔지니어(Ghost Engineers)'의 등장: 놀랍게도 전체 엔지니어의 약 9%는 기대 산출물의 20% 이하만을 내는 고스트 엔지니어로 분류되었습니다. 이는 그들이 게을러서가 아닙니다. AI 도구가 도입되어도, 불투명한 원격 근무 환경과 피드백 부재가 겹치면 개발자는 동기를 잃고 표류하게 됩니다. 결국, 단순히 코드 자동 완성 도구를 쥐여준다고 해서 근본적인 IT 병목(Bottleneck) 현상이 해결되는 것은 아닙니다. '코드를 더 빨리 작성하는 것' 이 아니라, '작성해야 할 코드 자체를 줄이는 것' 이 진짜 생산성의 핵심입니다. 2. 가트너의 2026년 예언: 판을 뒤집을 '로우코드(Low-Code)' 이러한 한계를 극복하기 위해 글로벌 선도 기업들은 코딩의 추상화 단계, 즉 로우코드 플랫폼으로 눈을 돌리고 있습니다. 가트너(Gartner)의 전망에 따르면, 2026년까지 신규 엔터프라이즈 애플리케이션의 75%가 로우코드 기술로 구축될 것이라고 합니다. 2020년 25% 미만이었던 것을 생각하면 폭발적인 성장입니다. 시장 규모 역시 445억 달러(약 60조 원)에 달할 것으로 예상됩니다. 왜 이런 급격한 변화가 일어날까요? 극심한 개발자 부족: 미국에서만 120만 명의 개발자가 부족한 상황입니다. 섀도우 IT의 양성화: 현업 비즈니스 부서의 요구사항을 IT 부서가 모두 감당할 수 없습니다. 로우코드는 비개발자(시티즌 디벨로퍼)가 안전한 거버넌스 하에 직접 앱을 만들 수 있게 해줍니다. 철의 삼각형(Iron Triangle) 파괴: 전통적으로 소프트웨어 개발은 '속도, 비용, 품질' 중 하나를 포기해야 했습니다. 로우코드는 이 제약을 깨고 세 가지를 동시에 충족시킵니다. 3. 전통적 코딩 vs 로우코드: 압도적인 38배의 속도 차이 "로우코드는 장난감 수준의 간단한 앱만 만들 수 있는 것 아닐까?"라고 의심하실 수 있습니다. 하지만 Pique Solutions의 최근 Time and Motion 연구 데이터는 이러한 편견을 완벽히 부수어 버립니다. 데이터 중심의 인터랙티브 그리드(Interactive Grid)와 패싯 검색 모듈을 개발할 때, 전통적인 프레임워크인 ReactJS와 로우코드 플랫폼인 Oracle APEX를 비교한 결과는 가히 충격적입니다. 개발 속도: ReactJS로 3.5일(약 28.2시간)이 걸렸던 작업이 로우코드(Oracle APEX)로는 단 44분 만에 끝났습니다. 무려 38배 이상 빠른 속도입니다. 코드 양: ReactJS에서 평균 1,850줄 이상의 코드를 손으로 짜야 했다면, 로우코드는 단 93줄의 추가 코드만으로 완벽하게 동일한(오히려 보안이 더 튼튼한) 기능을 구현했습니다. 코드를 20배 적게 사용한 것입니다. 보안 패치, 데이터베이스 동시성 관리, 대규모 트래픽 대응 등 개발자들을 괴롭히던 아키텍처적 난제들을 플랫폼이 알아서(Out-of-the-box) 해결해 주니, 개발자는 '비즈니스 로직'과 '고객 경험'이라는 진짜 가치에만 집중할 수 있게 됩니다. 4. Agentic AI와 로우 코드의 결합: 다음 세대의 패러다임 여기서 끝이 아닙니다. 최근 로우코드 시장의 가장 뜨거운 화두는 바로 'AI 네이티브(AI-Native)' 와 '에이전틱 AI(Agentic AI)' 의 결합입니다. (OutSystems, Mendix, ToolJet 등 선도 기업들이 모두 이 방향으로 질주하고 있습니다.) 과거의 AI가 단순히 챗봇이나 코드를 추천해 주는 수준이었다면, 에이전틱 AI는 "결재 프로세스를 자동화하는 대시보드를 만들어줘" 라는 자연어 명령(Prompt) 하나만으로, 데이터베이스 모델링부터 UI 구성, 보안 권한 설정까지 스스로 판단하고 '실행'합니다. 이것은 혁명입니다. 인간과 AI가 협업하여 복잡한 레거시(Legacy) 시스템을 걷어내고, 아이디어를 단 며칠 만에 엔터프라이즈급 소프트웨어로 탄생시키는 마법 같은 시대가 열린 것입니다. 🚀 결론: 귀사의 다음 단계는 무엇입니까? AI 도구 하나 도입했다고 해서 안심할 때가 아닙니다. 진정한 '개발 생산성 60% 향상'의 마법은 개발자 개인의 손끝이 아니라, 조직의 개발 환경을 'AI 네이티브 로우코드' 중심으로 재편할 때 비로소 실현됩니다. 지금 우리 회사의 IT 백로그를 살펴보십시오. 수개월째 먼지만 쌓여가는 내부 운영 툴, 부서 간 데이터 연동 문제, 구형 레거시 시스템들이 보이지 않으신가요? 이제 결정하셔야 합니다. 계속해서 비싼 인건비를 들여 무거운 코드를 수작업으로 쌓아 올리며 기술 부채를 늘려갈 것인지, 아니면 로우코드 플랫폼을 통해 38배 빠른 혁신의 고속도로에 탑승할 것인지 말입니다.
Python_Day.5 클래스 상속과 super() , 오버라이딩, 자주 쓰는 내장 함수, 표준 모듈( time , random , pickle ), 모듈 직접 만들기. Day.4에서 클래스의 기본 틀( __init__ , self , 인스턴스 메소드)을 배웠다. 오늘은 그 위에 상속 을 얹고, 파이썬이 기본으로 제공하는 내장 함수와 모듈 , 그리고 내가 만든 파일을 모듈로 가져다 쓰는 방법 까지 정리했다. 1. 클래스 복습 class Car: def __init__(self, make, model, year): # 객체를 만들 때 자동으로 호출되는 생성자 self.make = make self.model = model self.year = year self.speed = 0 # 속성을 선언하면서 초기화 # 인스턴스 메소드는 첫 번째 매개변수가 항상 self def name(self): return str(self.year) + " " + self.make + " " + self.model def speed1(self): print(str(self.speed) + '이다') c1 = Car('테슬라', 'models', 2018) # 객체명 = 클래스명(__init__의 매개변수) print(c1.name()) # 2018 테슬라 models c1.speed = 15 c1.speed1() # 15이다 c1.name() 처럼 인스턴스를 통해 호출하면 파이썬이 c1 을 자동으로 self 에 넣어 준다. 그래서 호출할 때는 인자를 안 넘기는데 정의에는 self 가 있는 것이다. self 는 이름일 뿐이다 class Fruit: price = 20000 # 클래스 변수 def __init__(a, title, color): # self 대신 a를 써도 동작한다 a.title = title a.color = color def info(a): return "{} 과일은 {}색".format(a.title, a.color) def buy1(a, buy1): # buy1은 매개변수로 바로 쓰는 값이라 a.buy1처럼 속성에 붙일 필요가 없다 return '{} 과일 {}에서 사야지~'.format(a.title, buy1) a = Fruit('사과', '빨간') b = Fruit('포도', '검은') print(a.info()) # 사과 과일은 빨간색 print(b.info()) # 포도 과일은 검은색 print(a.buy1('코스트코')) # 사과 과일 코스트코에서 사야지~ print(b.buy1('이마트')) # 포도 과일 이마트에서 사야지~ 첫 번째 매개변수 이름이 꼭 self 일 필요는 없다. 위치 가 중요하고, 관례상 self 를 쓴다. 메소드 안에서만 쓰고 끝나는 값은 self. 를 붙여 속성으로 만들 필요 없이 매개변수 그대로 쓰면 된다. 파이썬의 모든 클래스는 최상위 클래스인 object 를 상속받는다. self가 없는 메소드 class A: def add(a, b): print(a + b) A.add(3, 4) # 7 (클래스 이름으로 호출) A().add(3, 4) # TypeError: A.add() takes 2 positional arguments but 3 were given A.add(3, 4) 는 일반 함수처럼 동작해서 7이 나온다. 반면 인스턴스로 호출하면 A() 가 첫 번째 인자( a )로 자동 전달되어, 인자가 3개가 되어 버리기 때문에 에러가 난다. 필기에는 이 형태를 "클래스 메소드(정적 메소드)"로 적어 두었는데, 정확하게는 아니다. 정적 메소드는 @staticmethod 를 붙여서 만드는 것 이고, 그러면 인스턴스로 호출해도 에러가 나지 않는다. 위처럼 데코레이터 없이 self 만 뺀 메소드는 "클래스 이름으로 부를 때만 우연히 동작하는" 형태다. class B: @staticmethod def add(a, b): print(a + b) B.add(3, 4) # 7 B().add(3, 4) # 7 (인스턴스로 호출해도 된다) 2. 클래스 안에 클래스 (객체를 속성으로 쓰기) 학생 객체를 만들고, 계산기 클래스가 학생들을 리스트로 갖고 있다가 평균을 구하는 예제다. class Student: def __init__(self, name, id, score=0): # 매개변수의 초기값(기본값) 설정 self.name = name self.id = id self.score = score def getId(self): return self.id def getName(self): return self.name def setScore(self, score): self.score = score def getScore(self): return self.score class Cal: def __init__(self): self.stu = [] # 학생 객체를 담을 리스트 def add(self, student): self.stu.append(student) def avg(self): total = 0 for i in self.stu: total += i.score # i.getScore()로 써도 같다 average = total / len(self.stu) return average, len(self.stu) # 값 두 개를 튜플로 반환 a = Student('Tom', 'qwe12') # score는 기본값 0 a.setScore(70) a2 = Student('Amy', 'NTE', 60) a3 = Student('Bob', 'B01', 90) a4 = Student('Cat', 'C01', 10) c = Cal() c.add(a) c.add(a2) c.add(a3) c.add(a4) p, s = c.avg() # 반환된 두 값을 각각 p, s에 받는다 print('{1}명의 평균은 {0}'.format(p, s)) # 4명의 평균은 57.5 score=0 처럼 기본값이 있는 매개변수 는 생략할 수 있다. a 는 점수를 안 넘겨서 0으로 만들어졌고, 나중에 setScore(70) 으로 바꿨다. return average, len(self.stu) 는 값을 두 개 돌려주는 것처럼 보이지만 사실 튜플 하나 를 반환한다. 받는 쪽에서 p, s = ... 로 언패킹했다. (Day.2의 패킹/언패킹) Cal 은 내부에 Student 객체들을 갖고 있다. 이렇게 객체를 다른 객체의 속성으로 담는 구조 는 이후에도 자주 나온다. 함수 안에서 sum 을 변수 이름으로 쓰면 내장 함수 sum() 을 가려 버린다. 위 코드에서는 total 로 바꿨다. 3. 상속 상속 은 부모 클래스의 속성과 메소드를 자식 클래스가 그대로 물려받는 것이다. class 자식클래스(부모클래스): ... 3-1. super() 로 부모의 __init__ 호출하기 class Person: def __init__(self): print('안녕') class Student(Person): def __init__(self): print('학생은 공부를 해야지') super().__init__() # 부모(Person)의 __init__을 호출 a = Student() # 학생은 공부를 해야지 # 안녕 자식 클래스에서 __init__ 을 다시 정의하면, 부모의 __init__ 은 자동으로 호출되지 않는다. 부모의 초기화가 필요하면 super().__init__() 으로 직접 불러야 한다. 필기에는 "덮어쓰기 되는 식"이라고 적어 뒀는데, 이게 맞는 이해다. 호출하지 않으면 어떻게 되는지 확인해 보자. class P2: def __init__(self): self.x = 1 class S2(P2): def __init__(self): # super().__init__()을 빼먹음 self.y = 2 s = S2() print(s.y) # 2 print(s.x) # AttributeError: 'S2' object has no attribute 'x' 부모의 __init__ 이 실행되지 않았으니 x 라는 속성이 만들어지지 않았다. 3-2. 부모의 __init__ 에 값 넘기기 class Camping(Car): # 위에서 만든 Car 상속 def __init__(self, make, model, year, bed): super().__init__(make, model, year) # 공통 속성은 부모에게 맡김 self.bed = bed # 자식만의 속성 추가 camp1 = Camping('벤츠', '스프린터', 2024, 2) print(camp1.name()) # 2024 벤츠 스프린터 (부모의 메소드를 그대로 사용) camp1.speed1() # 0이다 (speed도 부모가 초기화해 줬다) print(camp1.bed) # 2 c1 = Car('현대', '아반떼', 1998) print(c1.name()) # 1998 현대 아반떼 c1.speed = 60 c1.speed1() # 60이다 Camping 은 name() 과 speed1() 을 새로 만들지 않았는데도 쓸 수 있다. 부모에게 있는 것은 물려받고, 자식에만 필요한 bed 만 추가 한 것이다. 코드 중복이 줄어드는 게 상속의 장점이다. 3-3. 오버라이딩 (메소드 재정의) 부모에 있는 메소드를 자식이 같은 이름으로 다시 정의 하는 것을 오버라이딩이라고 한다. class Super: def a(self): print('super') class Sub(Super): def a(self): # 오버라이딩: 부모의 a()를 재정의 print('자식 클래스') def b(self): # 자식에만 있는 메소드 print('sub') a = Super() b = Sub() a.a() # super b.a() # 자식 클래스 (자식 버전이 우선) b.b() # sub a.b() # AttributeError: 'Super' object has no attribute 'b' 같은 이름의 메소드가 있으면 자식 쪽이 우선 한다. 상속은 한 방향이다. 자식은 부모의 것을 쓸 수 있지만, 부모는 자식에만 있는 메소드를 쓸 수 없다. 3-4. 오버라이딩 + super() 로 부모 기능 재활용 class Cal: def __init__(self, num1, num2): self.num1 = num1 self.num2 = num2 def div(self): return self.num1 / self.num2 class NewCal(Cal): def div(self): if self.num2 == 0: return 0 # 0으로 나누는 경우만 새로 처리 else: return super().div() # 나머지는 부모의 div()를 그대로 사용 a = NewCal(6, 0) b = NewCal(6, 3) print(a.div(), b.div()) # 0 2.0 else 에서 부모의 계산식을 다시 쓰지 않고 super().div() 로 가져왔다. 부모 기능은 그대로 쓰고, 예외 상황만 자식에서 덧붙이는 방식이다. 필기 코드의 부모 div() 는 num1 + num2 를 반환하고 있었다. 이름이 div 니까 나누기( / )로 고쳤다. 나누기로 고쳐야 num2 == 0 을 따로 막는 이유( ZeroDivisionError )도 설명이 된다. 4. 클래스 문제 풀이 Q1. 클래스를 상속받고 메소드 추가하기 Calculator 를 상속하는 UpgradeCal 을 만들고, 값을 빼는 minus 메소드를 추가한다. class Calculator: def __init__(self): self.value = 0 def add(self, val): self.value += val class UpgradeCal(Calculator): def minus(self, val): self.value -= val cal = UpgradeCal() cal.add(10) cal.minus(7) print(cal.value) # 3 Q2. 값이 100을 넘지 않는 계산기 Calculator 를 상속해서, add 로 값을 더할 때 100이 넘으면 100으로 고정 하는 MaxLimitCal 을 만든다. 필기에서는 이 문제를 풀다가 add 의 첫 매개변수를 self 가 아닌 n 이나 val 로 쓰고, 메소드 안에서 value = 0 처럼 지역 변수를 만들어 버려서 결과가 이상했다. 포인트는 두 가지다. 첫 매개변수가 꼭 self 여야 하는 것은 아니지만, 인스턴스가 들어오는 자리 이므로 인스턴스 속성에 접근하려면 그 이름으로 n.value 처럼 써야 한다. 매개변수 없이 def add(val): 만 쓰면 인스턴스가 들어올 자리가 없다. 이 상태에서 value = 0 을 쓰면 인스턴스 속성이 아니라 함수 안에서만 쓰이는 지역 변수 가 된다. 그래서 self.value 를 쓰는 형태로 고치면 이렇게 된다. class MaxLimitCal(Calculator): def add(self, val): super().add(val) # 부모의 add로 먼저 더하고 if self.value >= 100: # 100 이상이면 self.value = 100 # 100으로 고정 m = MaxLimitCal() m.add(50) m.add(70) print(m.value) # 100 Q6. 리스트 항목마다 3을 곱해서 반환하기 a = [1, 2, 3, 4] print(list(map(lambda x: x * 3, a))) # [3, 6, 9, 12] 5. 내장 함수 Day.4에 이어 자주 쓰는 내장 함수를 정리했다. print(abs(-3)) # 3 (절댓값) all , any iterable의 요소를 검사해서 참/거짓을 돌려준다. print(all([1, 2, 3])) # True 모두 참 print(all([1, 2, 0])) # False 0이 하나 있으니까 print(any([1, 2, 0])) # True 하나라도 참이면 print(any([0, 0])) # False 전부 거짓일 때만 함수 True가 되는 조건 all 모든 요소가 참 any 하나라도 참 조건식과 조합하면 더 유용하다. a = [1, 60, 4, 10, 50] print(all(i < 20 for i in a)) # False (60, 50이 20 이상) print(any(i > 30 for i in a)) # True (60, 50이 30 초과) 필기에는 any([1,2,0]) 이 True인 이유를 "하나라도 False(1,2)이기 때문"이라고 적어 뒀는데, 정확히는 하나라도 참(1, 2)이 있기 때문 이다. 참고로 빈 리스트는 all([]) 이 True , any([]) 가 False 다. chr , ord print(chr(65)) # A (아스키 코드 → 문자) print(ord('A')) # 65 (문자 → 아스키 코드) enumerate 인덱스와 요소를 함께 돌려준다. for i, name in enumerate(['body', 'foo', 'bar']): print(i, name) # 0 body # 1 foo # 2 bar 필기에서는 {'body','foo','bar'} 라는 set 에 enumerate 를 썼는데, set은 순서가 없어서 매번 출력 순서가 달라질 수 있다. 인덱스가 의미가 있으려면 리스트처럼 순서가 있는 자료형에 쓰는 게 좋다. filter , map a = [1, 60, 4, 10, 50] def num(x): return abs(x) > 3 print(list(filter(num, a))) # [60, 4, 10, 50] print(list(filter(lambda x: x > 3, a))) # [60, 4, 10, 50] print(list(map(lambda x: x + 2, range(1, 7)))) # [3, 4, 5, 6, 7, 8] filter(함수, iterable) : 함수가 참 을 돌려주는 요소만 추출한다. map(함수, iterable) : 모든 요소 에 함수를 적용한 결과를 돌려준다. 반복 가능한 객체는 리스트, 튜플, 문자열, 딕셔너리, range , 파일 객체 등이다. 결과를 보려면 list() 로 감싸야 한다. (Day.4에서 정리한 대로 map / filter 는 지연 평가라서 그대로 출력하면 객체 주소만 나온다.) id , len , type print(id(10)) # 객체의 주소값 (실행 환경마다 다름) print(len('파이썬')) # 3 print(len([1, 2, 3, 4, 5])) # 5 print(type(3)) # <class 'int'> print(type({})) # <class 'dict'> print(type(())) # <class 'tuple'> print(type([])) # <class 'list'> range print(range(5)) # range(0, 5) (리스트가 아니라 range 객체) print(range(1, 10, 2)) # range(1, 10, 2) print(list(range(1, 10, 2))) # [1, 3, 5, 7, 9] (시작, 끝(미포함), 증감) # 9부터 0까지 1씩 감소 print(list(range(9, -1, -1))) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] max , min print(max([1, 2, 3])) # 3 print(max('ABCD')) # D (사전순으로 가장 뒤) print(min('ABCD')) # A (사전순으로 가장 앞) print(max(1, 2, 3)) # 3 (리스트 없이 값을 바로 나열해도 된다) round print(round(4.6)) # 5 print(round(3.6432, 2)) # 3.64 (소수점 둘째 자리까지) 주의할 점이 있다. 파이썬의 round 는 우리가 학교에서 배운 사사오입(반올림)이 아니라 가장 가까운 짝수로 보내는 방식 이다. print(round(2.5)) # 2 print(round(3.5)) # 4 print(round(0.5)) # 0 딱 .5인 경우에는 결과가 짝수 쪽으로 가기 때문에, 평균 점수처럼 정확한 반올림이 필요한 곳에서는 헷갈릴 수 있다. sorted , sum , zip print(sorted([5, 4, 3, 2, 1, 6])) # [1, 2, 3, 4, 5, 6] print(sorted([5, 4, 3, 2, 1, 6], reverse=True)) # [6, 5, 4, 3, 2, 1] print(sorted(['p', 'y', 't', 'h', 'o', 'n'])) # ['h', 'n', 'o', 'p', 't', 'y'] print(sum([1, 2, 3, 4, 5])) # 15 print(sum(range(1, 11))) # 55 print(list(zip([10, 20, 30], [40, 50, 60]))) # [(10, 40), (20, 50), (30, 60)] (같은 인덱스끼리 튜플로 묶는다) sorted 는 새 리스트를 반환 하고 원본은 바뀌지 않는다. 필기에서는 list(reversed(sorted(...))) 로 내림차순을 만들었는데, reverse=True 옵션을 쓰면 더 간단하다. zip 은 여러 iterable의 같은 위치 요소를 묶어 주는 함수다. 6. 표준 모듈: time , random , pickle 내장 함수와 달리 import 로 불러와서 쓰는 모듈들이다. time import time print(time.time()) # 1970-01-01부터 지금까지의 초 (실수) print(time.localtime(time.time())) # 연/월/일/시/분/초 등을 담은 구조체 print(time.ctime()) # 사람이 읽는 형태의 현재 시간 문자열 # 가장 많이 쓰는 형식 print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))) # 예) 2026-09-11 17:30:05 코드 의미 %Y 연도(4자리) %m 월 %d 일 %H 시(24시간) %M 분 %S 초 random import random print(random.random()) # 0.0 이상 1.0 미만의 실수 print(random.randint(1, 45)) # 1 이상 45 이하의 정수 (둘 다 포함) 필기에는 random.random() 이 "1.0까지"라고 되어 있는데, 1.0은 포함하지 않는다. randint(1, 45) 는 두 번째 인자도 포함 이다. 필기에 "미만이 아니라 포함임!"이라고 적어 둔 부분이 이 점이다. d =
Python_Day.6 패키지와 import, 예외 처리( try / except / else / finally , raise , 사용자 정의 예외), 클로저, 데코레이터(함수형/클래스형). Day.5에서 파일 하나를 모듈로 만들어 import 하는 것까지 했다. 오늘은 그걸 폴더 단위(패키지) 로 확장하는 것에서 시작해서, 프로그램이 죽지 않게 하는 예외 처리 , 그리고 함수를 값처럼 다루는 클로저와 데코레이터 까지 정리했다. 1. 패키지 파이썬 프로그램은 패키지로 나뉜 개별 모듈 들로 구성된다. 패키지는 모듈( .py )을 담은 폴더 이고, 폴더 안에 폴더(하위 패키지)를 둘 수 있다. 실습에서는 이런 구조를 만들었다. (폴더 구조 스크린샷은 Notion에서 읽을 수 없어서, 코드에 맞춰 구조를 다시 적었다.) project/ ├── main.py └── pro_test/ # 패키지 ├── __init__.py ├── test2/ # 하위 패키지 │ ├── __init__.py │ └── module1.py # mod1_test(), mod1_test2() └── test3/ # 하위 패키지 ├── __init__.py └── module2.py # mod2_test(), mod2_test2() 각 폴더의 __init__.py 는 "이 폴더는 패키지다"라는 표시다. (파이썬 3.3부터는 없어도 동작하지만, 패키지임을 분명히 하고 초기화 코드를 넣을 수 있어서 두는 편이 일반적이다.) import 하는 세 가지 방법 1) import 패키지.하위패키지.모듈 : 호출할 때도 전체 경로를 다 써야 한다. import pro_test.test2.module1 import pro_test.test3.module2 pro_test.test2.module1.mod1_test() pro_test.test3.module2.mod2_test() 2) from 패키지.하위패키지 import 모듈 : 모듈 이름만 쓰면 된다. as 로 별명도 붙일 수 있다. from pro_test.test2 import module1 module1.mod1_test() from pro_test.test3 import module2 as m2 m2.mod2_test() 3) from 패키지.하위패키지 import * : 패키지 안의 것을 한꺼번에 가져온다. from pro_test.test2 import * from pro_test.test3 import * module1.mod1_test() module2.mod2_test() 3번은 주의가 필요하다. 필기 코드에서는 위에서 이미 module1 을 import 해 둔 상태라 동작했다. import * 만 단독으로 쓰면 NameError: name 'module1' is not defined 가 난다. 패키지에서 * 로 가져올 모듈은 __init__.py 에 __all__ = ['module1'] 처럼 목록을 적어 줘야 한다. 이름 충돌 위험도 있어서 1번이나 2번을 권장한다. 상대 경로 import 필기 맨 위에 .. (부모 디렉토리), . (현재 디렉토리)라고 적어 둔 것이 상대 경로 import 표기다. 패키지 안의 모듈끼리 서로 가져올 때 쓴다. # pro_test/test3/module2.py from ..test2 import module1 # .. : 부모(pro_test) 아래의 test2 from . import helper # . : 현재 패키지(test3) def mod2_test2(): module1.mod1_test() helper.hello() . 은 현재 패키지, .. 은 한 단계 위 패키지를 뜻한다. 상대 경로 import는 패키지의 일부로 import 될 때만 동작한다. 그 파일을 python module2.py 처럼 직접 실행하면 에러가 난다. 직접 실행하는 파일은 앞 장에서 본 __name__ == "__main__" 이 되고, 패키지 소속이 아니기 때문이다. 2. 예외 처리 2-1. 자주 만나는 예외 에러가 나면 파이썬은 종류에 맞는 예외(Exception) 를 던진다. 각각 어떤 코드에서 나는지 실제로 확인해 보았다. 예외 코드 메시지 SyntaxError print('hi')) unmatched ')' NameError print(z) (정의 안 한 변수) name 'z' is not defined IndexError a=[1,2,3]; a[5] list index out of range ZeroDivisionError 4/0 division by zero KeyError {'a':1}['c'] 'c' ValueError [1,2,3].remove(5) , int('abc') list.remove(x): x not in list FileNotFoundError open('b.txt','r') No such file or directory: 'b.txt' TypeError [1,2] + 'python' can only concatenate list (not "str") to list TypeError 는 x + list(y) 처럼 자료형을 맞춰 주면 해결된다. SyntaxError 는 실행하기도 전에 코드를 읽는 단계에서 나기 때문에, 이 오류가 있는 줄 위쪽의 print 도 실행되지 않는다. 2-2. try / except / else / finally 예외가 나더라도 프로그램이 멈추지 않게 처리하는 문법이다. li = ['db', 'python', 'react'] try: # 예외가 날 수도 있는 코드 x = 'java' y = li.index(x) # 예외가 나면 바로 except로 넘어간다 print('try 블럭 수행') except Exception as e: # 예외가 났을 때 실행 print(e) # 'java' is not in list else: # 예외가 없을 때만 실행 print('else') finally: # 예외 여부와 상관없이 항상 실행 print('finally') 실행 결과는 이렇다. 'java' is not in list finally x 를 'db' 로 바꾸면 이렇게 된다. try 블럭 수행 else finally 블록 실행 시점 try 예외가 날 수 있는 코드. 예외가 나는 순간 나머지는 건너뛴다 except 예외가 발생했을 때 else 예외가 발생하지 않았을 때 finally 무조건 (파일 닫기 같은 마무리 작업에 쓴다) except ValueError: 처럼 특정 예외만 잡을 수도 있고, except Exception as e: 로 대부분의 예외 를 잡을 수도 있다. Exception 은 거의 모든 예외의 부모 클래스다. e 에 예외 내용이 들어 있어서 print(e) 로 메시지를 볼 수 있다. 필기의 앞부분 코드는 except ValueError: 로 잡고 있었다. list.index() 로 없는 값을 찾으면 ValueError 가 나기 때문이다. 예외끼리도 상속 관계가 있다. KeyError 와 IndexError 는 LookupError 의 자식이고, FileNotFoundError 는 OSError 의 자식이며, 전부 Exception 의 자손이다. 2-3. raise : 일부러 예외 발생시키기 def calc(msg, *args): result = 0 try: if msg == 'add': for i in args: result += i print(result) elif msg == 'mul': result = 1 for i in args: result *= i print(result) elif msg == 'avg': for i in args: result += i print(result / len(args)) else: raise Exception('잘못된 연산이다') # 일부러 예외 발생 except Exception as e: print(e) calc("add", 1, 2, 3) # 6 calc("mul", 1, 2, 3, 4) # 24 calc("avg", 10, 20, 30) # 20.0 calc("al", 10, 20, 30) # 잘못된 연산이다 calc("avg") # division by zero (인자가 없으면 len(args)가 0) raise 는 조건에 맞지 않을 때 직접 예외를 던지는 명령어다. 던져진 예외는 바깥의 except 가 받는다. *args 는 인자를 튜플로 받는다. (Day.3) 필기 코드에는 except 안에 return None 이 있었는데, 파이썬 함수는 return 이 없으면 어차피 None 을 반환하므로 빼도 같다. 마지막 줄처럼 인자 없이 avg 를 호출하면 ZeroDivisionError 가 나는데, except Exception 이 이것도 같이 잡아 준다. 2-4. 사용자 정의 예외 Exception 을 상속해서 내 예외 클래스 를 만들 수 있다. class MyError(Exception): def __str__(self): # 예외를 출력할 때의 문자열을 정한다 return '허용되지 않는 별명입니다.' def say_nick(nick): if nick == '바보': raise MyError() # 금지어면 예외 발생 print(nick) try: say_nick('천사') # 천사 say_nick('바보') # 여기서 예외 발생 except MyError as e: print(e) # 허용되지 않는 별명입니다. __str__ 은 print(객체) 를 했을 때 보일 문자열을 정하는 특수 메소드 다. 예외 객체 e 를 print(e) 하면 이 문자열이 나온다. 2-5. 사용자 정의 예외에서 헷갈린 부분 나이를 입력받아 0 이하면 예외를 내는 코드를 두 가지로 만들어 봤다. 첫 번째는 이렇다. class MyError(Exception): def __str__(self): return '숫자를 입력해주세요' try: age = int(input('나이를 입력해주세요')) if age <= 0: raise MyError('나이는 0 이상이어야 해') except Exception: raise MyError() else: print('입력완료') 이 코드는 -5 를 입력하든 abc 를 입력하든 둘 다 같은 숫자를 입력해주세요 에러 로 끝난다. 이유는 세 가지다. raise MyError('나이는 0 이상이어야 해') 로 넘긴 메시지는 쓰이지 않는다. __str__ 을 오버라이딩해서 항상 같은 문자열을 반환하기 때문이다. (메시지는 e.args 에는 들어 있지만 출력되지 않는다.) MyError 도 Exception 의 자식이라서, try 안에서 던진 MyError 를 바로 아래 except Exception 이 다시 잡고 , raise MyError() 로 새로 던진다. 결국 -5 를 입력해도 "숫자를 입력해주세요"가 나온다. except 안에서 다시 raise 했으니 처리되지 않은 예외가 되어 프로그램이 트레이스백과 함께 종료 되고, else 도 실행되지 않는다. 두 번째 방식은 메시지를 __init__ 에서 부모에게 넘긴다. class MyError(Exception): def __init__(self): super().__init__('0보다 커야해') # 부모(Exception)에 메시지 전달 try: age = int(input('나이를 입력해주세요')) if age <= 0: raise MyError() except ValueError as e: print('숫자가 아니야') else: print('입력완료') 이쪽은 abc 를 입력하면 숫자가 아니야 가 잘 나온다. 그런데 -5 를 입력하면 except ValueError 는 MyError 를 잡지 못하므로 MyError: 0보다 커야해 와 함께 프로그램이 종료된다. MyError 를 처리하는 except 가 없기 때문이다. 두 가지를 모두 처리하도록 정리하면 이렇게 된다. class AgeError(Exception): def __init__(self): super().__init__('나이는 0보다 커야 한다') try: age = int(input('나이를 입력해주세요')) if age <= 0: raise AgeError() except ValueError: print('숫자가 아니다') # abc 입력 except AgeError as e: print(e) # -5 입력 → 나이는 0보다 커야 한다 else: print('입력완료') # 5 입력 입력 출력 abc 숫자가 아니다 -5 나이는 0보다 커야 한다 5 입력완료 예외 종류마다 except 를 나눠 쓰면 상황별로 다른 메시지 를 줄 수 있다. 또 except Exception 을 쓸 때는 그 안에서 내가 던진 예외까지 같이 잡히는지 확인해야 한다. 사용자 정의 예외에서 메시지를 정하는 방법은 두 가지다. 방법 코드 __str__ 오버라이딩 출력 문자열을 고정한다. 던질 때 넘긴 메시지는 무시된다 __init__ 에서 super().__init__(메시지) 부모에게 메시지를 넘긴다 3. 클로저 클로저 는 함수 안에서 함수를 정의하고, 그 안쪽 함수를 결과로 반환 하는 구조다. 콜백 함수나 데코레이터에 쓰인다. def add(n1): def wrapper(n): # 내부 함수 (inner function) return n1 + n # 바깥 함수의 n1을 사용 return wrapper # 내부 함수를 반환 a1 = add(10) # n1=10을 저장한 채로 wrapper가 a1에 들어감 print(a1(10)) # 20 (wrapper(10) → 기억해 둔 10 + 10) a2 = add(20) # n1=20을 기억하는 또 다른 wrapper print(a2(10)) # 30 흐름을 풀어 보면 이렇다. add(10) 이 실행되면 n1 = 10 이 만들어진다. 안쪽 wrapper 함수가 만들어져서 반환 되고, a1 이 그 함수를 가리킨다. add 는 이미 끝났지만, wrapper 는 n1 = 10 을 기억하고 있다. 이것이 클로저의 특징이다. a1(10) 은 wrapper(10) 이므로 기억하던 10과 더해 20이 된다. add(10) 과 add(20) 이 서로 다른 n1 을 각자 기억하는 함수를 만든다는 점이 핵심이다. 기억해 둔 값은 a1.__closure__[0].cell_contents 로 확인할 수 있고, 값은 10 이다. 기억한 값을 바꾸고 싶을 때: nonlocal 내부 함수에서 바깥 함수의 변수를 읽는 것은 되지만, 값을 바꾸려면 nonlocal 을 선언 해야 한다. (Day.4의 global 과 같은 역할이고 범위가 바깥 함수다.) def counter(): c = 0 def inc(): nonlocal c # 바깥 함수의 c를 쓰겠다 c += 1 return c return inc f = counter() print(f(), f(), f()) # 1 2 3 호출할 때마다 c 가 유지되며 늘어난다. 전역 변수를 쓰지 않고도 상태를 함수에 숨겨 두는 방법이다. 4. 데코레이터 데코레이터 는 "장식하는 도구"로, 기존 함수의 코드를 건드리지 않고 기능을 확장 할 때 쓴다. 함수 위에 @이름 을 붙이는 문법이고, 안에서 클로저를 사용한다. 4-1. 클로저로 직접 만들어 보기 def trace(func): def wrapper(): print('시작') func() # func는 함수 자체를 받았으므로 뒤에 ()를 붙여 호출 print('끝') return wrapper # 함수를 반환 def hi(): print('hi') def hello(): print('hello') t1 = trace(hi) t1() # 시작 # hi # 끝 t2 = trace(hello) t2() # 시작 # hello # 끝 trace 는 함수를 인자로 받고, 함수를 반환 한다. 반환된 wrapper 가 원래 함수 앞뒤에 시작 / 끝 출력을 덧붙인다. 4-2. @ 문법 위의 t1 = trace(hi) 과정을 @ 로 줄일 수 있다. @trace # hi = trace(hi) 와 같다 def hi(): print('hi') @trace # hello = trace(hello) 와 같다 def hello(): print('hello') hi() # 시작 # hi # 끝 hello() # 시작 # hello # 끝 필기에는 " hi() 를 호출하면 hi = trace(hi) 를 자동으로 실행해준다"고 적어 뒀는데, 정확히는 함수를 정의하는 시점 에 hi = trace(hi) 가 한 번 실행된다. 그 뒤로 hi 라는 이름은 원래 함수가 아니라 wrapper 를 가리킨다. 그래서 hi() 를 호출하면 wrapper() 가 실행되는 것이다. 4-3. 인자와 반환값이 있는 함수에 쓰기 인자의 개수나 형태가 제각각인 함수에도 쓰려면 *args , **kwargs 로 받는다. def trace(func): def wrapper(*args, **kwargs): # *args : 위치 인자를 튜플로 받는다 # **kwargs: 키=값 인자를 딕셔너리로 받는다 result = func(*args, **kwargs) print(args, kwargs, result) return result # 원래 함수의 반환값을 그대로 돌려준다 return wrapper @trace def big(*args): return max(args) @trace def mina(**kwargs): return min(kwargs.values()) print(big(10, 20)) # (10, 20) {} 20 # 20 print(mina(x=20, y=30, z=40)) # () {'x': 20, 'y': 30, 'z': 40} 20 # 20 흐름은 이렇다. @trace 가 big = trace(big) 를 실행해서 big 이 wrapper 가 된다. big(10, 20) 은 wrapper(10, 20) 이 되어 args = (10, 20) 로 들어온다. func(*args, **kwargs) 로 원래 big 을 호출해 max(10, 20) 인 20을 얻는다. 인자와 결과를 출력하고, result 를 반환해서 호출한 곳에서도 20을 받는다. wrapper 에서 return result 를 빼면 원래 함수의 반환값이 사라져서 print(big(10, 20)) 이 None 이 된다. 4-4. 데코레이터의 부작용과 functools.wraps 데코레이터를 붙인 함수는 이름이 바뀐다. @trace def hi(): print('hi') print(hi.__name__) # wrapper (hi가 아니다) hi 가 실제로는 wrapper 이기 때문이다. 디버깅하거나 함수 이름을 쓸 때 헷갈릴 수 있어서, 실무에서는 functools.wraps 를 붙여 원래 함수의 이름과 설명을 유지한다. import functools def trace(func): @functools.wraps(func) # func의 이름 등 정보를 wrapper에 복사 def wrapper(*args, **kwargs): result = func(*args, **kwargs) print(args, kwargs, result) return result return wrapper @trace def big(*args): return max(args) print(big.__name__) # big 4-5. 클래스형 데코레이터와 __call__ 데코레이터는 함수만이 아니라 클래스 로도 만들 수 있다. class Tr: def __init__(self, func): self.func = func def __call__(self): print(self.func.__name__, '시작') self.func() print(self.func.__name__, '끝') @Tr # hi = Tr(hi) def hi(): print('hi') hi() # hi 시작 # hi # hi 끝 동작 순서를 따라가 보면 이렇다. @Tr 이 함수를 정의하는 시점에 hi = Tr(hi) 를 실행한다. Tr.__init__(self, func) 가 호출되고 원래 hi 함수가 self.func 에 저장된다. 이제 hi 라는 이름은 함수가 아니라 Tr 의 인스턴스 다. type(hi) 를 찍으면 <class '__main__.Tr'> 가 나온다. hi() 처럼 인스턴스에 괄호를 붙여 호출하면, 파이썬이 __call__ 을 자동으로 호출 한다. __call__ 안에서 self.func.__name__ 으로 이름( hi