Devin.KR

CS · 기본

비전공자를 위한 컴퓨터공학 지도 - 전공 4년을 개발자 눈으로 따라잡기

프로그래밍 언어와 컴파일러 - 코드가 실행되기까지

컴파일과 인터프리트, 타입 시스템, 스코프와 메모리 관리(GC), 완성 코드는 사칙연산 식을 토큰화·파싱·계산하는 작은 인터프리터

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 데이터베이스에 요청을 전달하고 결과를 받는 방법을 살펴보았다. 이번에는 요청을 만드는 프로그램 자체를 들여다본다. 파일에 적힌 Python 코드는 어떤 단계를 거쳐 계산이 되는가. 숫자와 문자열을 섞었을 때 왜 오류가 나는가. 함수 안에서 만든 이름은 어디까지 유효하며, 쓰지 않는 객체는 누가 정리하는가. 전공 수업에서는 이런 질문을 프로그래밍 언어와 컴파일러 과목에서 다룬다.

이 과목은 새 문법을 많이 외우는 과목이 아니다. 코드에 부여한 의미와 실행 과정 사이의 약속을 이해하는 과목이다. 약속을 알면 오류 메시지를 읽을 때 원인을 좁힐 수 있고, 설정 파일이나 계산식처럼 작은 언어가 필요한 상황에서 구현 범위를 정할 수 있다. 이 장에서는 핵심을 네 가지로 묶는다. 실행 단계, 타입, 스코프, 메모리 관리다.

  • 컴파일과 인터프리트가 서로 다른 작업이며 한 실행 과정에 함께 나타날 수 있음을 설명한다.
  • 값의 종류를 구분하는 타입 시스템과 오류가 드러나는 시점을 연결한다.
  • 이름을 찾는 범위인 스코프와 객체가 살아 있는 시간을 구분한다.
  • 사칙연산 식을 토큰화하고 구조를 만든 다음 계산하는 작은 인터프리터를 완성한다.

문제 상황

다른 분야에서 개발로 옮겨 온 사람이 학습 기록 도구를 만들고 있다고 하자. 도구에는 과목별 예상 학습 시간을 계산하는 기능이 있다. 사용자는 운영체제 학습 시간을 나타내는 숫자들을 넣어 “6 + 2 * 3” 같은 식을 작성한다. 처음에는 입력을 공백으로 나누고 왼쪽부터 계산하려 했다. 그런데 그 방식으로는 곱셈을 먼저 처리하지 못한다. 괄호를 허용하면 문제가 더 커진다.

문자열을 Python 코드로 실행하는 함수에 그대로 넘기는 방법도 떠오른다. 그러나 필요한 기능은 숫자와 사칙연산뿐이다. 함수 호출이나 파일 접근까지 허용할 이유는 없다. 입력에서 허용할 문자를 정하고, 식의 구조를 확인하고, 그 구조에 맞게 계산하는 편이 요구사항에 맞는다. 여기에서 전공 과목의 지식이 구체적인 설계 기준이 된다.

이 과목을 먼저 보충할 신호는 명확하다. 문법 오류와 실행 중 오류를 구분하기 어렵거나, 같은 이름의 변수가 함수 안팎에서 다르게 보이는 이유가 궁금하거나, 참조를 지웠는데 객체가 남는 현상을 설명하기 어렵다면 이 장의 실험이 도움이 된다. 컴파일러 전체를 만들기 전에, 자신이 쓰는 언어의 실행 규칙부터 확인하는 순서가 적절하다.

코드는 단계를 거쳐 실행된다

번역하는 일과 실행하는 일

한 표현을 다른 표현으로 옮기는 작업을 컴파일(compilation)이라고 한다. 입력은 사람이 쓴 소스 코드일 수 있고, 출력은 기계어 또는 실행기가 처리할 중간 표현일 수 있다. 컴파일 결과가 반드시 독립 실행 파일이어야 하는 것은 아니다. 명령을 읽고 정해진 의미에 따라 실행하는 작업은 인터프리트(interpretation)라고 한다. 그 작업을 수행하는 프로그램이 인터프리터다.

두 작업은 언어를 둘로 나누는 고정된 꼬리표가 아니다. 같은 언어도 구현에 따라 실행 경로가 다를 수 있다. 널리 쓰이는 Python 구현인 CPython은 소스를 바이트코드(bytecode)라는 명령 표현으로 컴파일하고, 이를 실행기로 처리한다. 실행 도중 자주 쓰는 부분을 추가로 번역하는 구현도 있다. 따라서 면접에서 “Python은 컴파일하지 않는가”라는 질문을 받으면, 어떤 구현과 어떤 단계를 말하는지부터 나누어 설명해야 한다.

완성 코드에서는 내장 함수 compile()로 직접 작성한 식을 코드 객체로 바꾼다. 코드 객체는 실행에 필요한 정보를 담은 객체다. 이어서 eval()로 그 객체를 실행한다. 두 줄을 분리하면 번역 결과를 만드는 일과 결과를 실행하는 일이 별개임을 확인할 수 있다. 여기서 실행하는 문자열은 프로그램 안에 고정되어 있다. 사용자 입력을 이 경로로 보내지는 않는다.

문자에서 토큰으로, 토큰에서 구조로

문자열을 의미 있는 조각으로 나누는 작업을 어휘 분석(lexical analysis)이라고 한다. 그 조각 하나를 토큰(token)이라고 부른다. “12 + 3”에서는 숫자 12, 덧셈 기호, 숫자 3이 각각 토큰이다. “12”를 두 글자로 읽더라도 하나의 숫자 토큰으로 묶어야 한다. 공백은 이 언어에서 계산 의미가 없으므로 버린다.

토큰의 배열이 언어의 규칙에 맞는지 확인하고 구조를 만드는 작업은 구문 분석(parsing)이다. 이때 사용하는 규칙의 묶음을 문법(grammar)이라고 한다. 완성 코드의 문법은 정수, 괄호, 네 가지 이항 연산만 허용한다. 이항 연산이란 왼쪽과 오른쪽에 피연산자가 하나씩 필요한 연산이다. 음수를 적는 앞쪽의 빼기 기호는 지원하지 않는다. 음수가 필요하면 “0 - 3”처럼 쓴다.

작은 계산 언어의 문법과 담당 함수
구성허용하는 형태담당 함수
식항 뒤에 + 항 또는 - 항이 반복됨expression()
항기본식 뒤에 * 기본식 또는 / 기본식이 반복됨term()
기본식정수 또는 괄호로 감싼 식primary()
전체 입력식 하나 뒤에 입력 끝이 옴parse()

함수들이 문법 구조를 따라 서로 호출하도록 만드는 방식을 재귀 하강 파싱(recursive descent parsing)이라고 한다. 재귀는 함수 실행 중 같은 함수가 다시 호출되는 구조다. 괄호 안에서 expression()을 다시 호출하면 안쪽에도 바깥과 같은 문법을 적용할 수 있다. 곱셈을 처리하는 term()이 덧셈을 처리하는 함수보다 안쪽에 있으므로 곱셈이 먼저 묶인다.

이렇게 만든 연산 구조를 추상 구문 트리(abstract syntax tree)라고 한다. 트리는 부모와 자식 관계로 이어지는 구조다. 여기서는 숫자를 ("number", 3), 덧셈을 ("+", 왼쪽, 오른쪽) 같은 튜플로 표현한다. 괄호는 구조를 정하는 데 쓰고 결과에는 남기지 않는다. 계산에 필요한 관계를 보존하고 표면적인 기호 일부를 생략한다는 뜻에서 추상이라는 말을 쓴다.

문자열은 토큰과 연산 구조를 거친 뒤 계산 결과가 된다

구조를 만든 뒤 각 노드의 의미를 실행하는 과정은 평가(evaluation)다. 덧셈 노드라면 양쪽 자식을 평가한 뒤 더한다. 이번 구현은 트리를 직접 따라가며 평가한다. 기계어를 만들거나 실행 속도를 높이는 최적화까지 구현하지는 않는다. 그래도 입력의 모양을 검사하는 단계와 실제 계산을 하는 단계가 구분된다는 핵심은 확인할 수 있다.

타입은 허용할 연산의 약속이다

값의 종류와 그 값에 허용되는 연산을 다루는 규칙을 타입 시스템(type system)이라고 한다. Python에서 정수 3과 문자열 “3”은 표시가 비슷해도 서로 다른 값이다. 정수 둘의 덧셈은 수치 계산이고, 문자열 둘의 덧셈은 이어 붙이기다. 정수와 문자열을 더하면 어느 의미를 적용해야 할지 Python의 규칙이 허용하지 않으므로 TypeError가 발생한다.

타입과 관련된 제약을 실행 전에 검사하는 방식을 정적 타입 검사라고 하고, 실행 중 값의 타입을 확인하는 방식을 동적 타입 검사라고 한다. Python에서는 같은 이름에 먼저 정수를 연결하고 나중에 문자열을 연결할 수 있다. 이름에 고정된 타입이 없다고 해서 값에 타입이 없는 것은 아니다. 완성 코드의 type_demo()는 같은 이름에 연결된 값의 타입이 바뀌는 모습을 출력한다.

함수의 value: int 같은 표기는 타입 힌트라고 한다. 별도의 검사 도구가 코드를 분석할 때 활용할 수 있지만, Python 실행기가 그 표기만 보고 인자를 정수로 변환하거나 다른 타입의 전달을 막지는 않는다. 완성 코드에서는 정수로 표시한 함수에 문자열을 전달하고 실제 타입을 출력한다. 실무에서는 입력 검증, 정적 검사, 실행 중 오류 처리가 각각 무엇을 책임지는지 구분해야 한다.

자동 변환을 많이 허용하는지와 실행 전에 검사하는지는 별개의 질문이다. 정적·동적이라는 분류를 “엄격하다·느슨하다”와 같은 한 줄의 척도로 외우면 설명이 흐려진다. 면접에서 타입에 관해 물으면 “언제 검사하는가”, “어떤 변환을 허용하는가”, “실패하면 어떻게 알리는가”를 구체적인 연산으로 설명하는 편이 낫다.

이번 계산 언어에는 문자열과 이름이 없다. 숫자 토큰은 정수로 바뀌며, 나눗셈은 Python의 /를 사용하므로 실수 결과를 낼 수 있다. 그래서 “8 / 2”의 결과는 4.0이다. 별도의 정적 타입 검사 단계는 만들지 않는다. 지원하는 값의 종류가 좁다는 설계 선택으로 타입 규칙을 단순하게 유지한다.

이름의 범위와 객체의 수명은 다르다

스코프는 이름을 찾는 규칙이다

코드의 어느 영역에서 이름을 사용할 수 있는지를 정하는 범위를 스코프(scope)라고 한다. Python 함수 안에서 대입한 이름은 별도의 선언이 없다면 그 함수의 지역 이름이다. 함수 밖에 같은 이름이 있어도 서로 다른 연결이다. 완성 코드에서는 바깥의 topic을 “언어”로 두고, 함수 안에서 같은 이름에 “파싱”을 연결한다. 함수 호출이 끝나도 바깥의 값은 바뀌지 않는다.

Python 함수의 이름 탐색은 기본적으로 현재 함수, 감싸는 함수, 모듈, 내장 이름의 순서를 따른다. 모듈은 여기서는 main.py 파일이 만드는 이름 공간이라고 이해하면 된다. 이름 공간은 이름과 객체의 연결을 보관하는 영역이다. 코드를 어디에 정의했는지에 따라 이름을 찾는 이런 규칙을 렉시컬 스코프(lexical scope)라고 한다.

함수 실행이 끝난 뒤에도 내부 함수가 바깥 함수의 이름을 사용할 수 있다. 완성 코드의 make_label()은 내부 함수를 반환한다. 반환된 함수는 바깥 함수의 prefix를 계속 사용한다. 필요한 바깥 환경을 함께 유지하는 함수를 클로저(closure)라고 한다. “지역 변수는 함수가 끝나면 모두 사라진다”는 설명만으로는 이 결과를 설명하지 못한다.

메모리 회수는 도달 가능성을 살핀다

객체가 필요 없는지 판단할 때 중요한 기준은 프로그램이 유지하는 참조를 따라 그 객체에 도달할 수 있는가다. 참조는 다른 객체를 가리키는 연결이다. 쓰지 않는 객체의 메모리를 자동으로 회수하는 기능을 가비지 컬렉션(garbage collection), 줄여서 GC라고 한다. 모든 언어 구현이 같은 방식이나 같은 시점에 회수하는 것은 아니다.

CPython은 객체를 가리키는 참조 수를 세는 방식과 순환 참조를 찾는 수거 기능을 함께 사용한다. 순환 참조란 객체들이 서로를 가리켜 고리를 만드는 상태다. 바깥 연결이 사라져도 고리 안의 참조 수는 남을 수 있다. 완성 코드에서는 객체가 자기 자신을 가리키게 만든 뒤 바깥 이름을 지우고 gc.collect()를 호출한다.

관찰에는 약한 참조(weak reference)를 사용한다. 약한 참조는 객체의 생존 여부를 확인할 수 있지만 그 참조 자체가 객체를 계속 살려 두지는 않는다. 출력은 객체가 회수되었는지만 확인한다. 회수한 객체 수는 실행 환경의 다른 객체에도 영향을 받으므로 출력하지 않는다. 자동 회수가 호출 전에 일어났더라도 최종 관찰 결과는 같다.

바깥 이름을 지워도 자기 참조 고리는 남을 수 있으며 순환 참조 수거가 이를 정리한다

메모리 회수를 기다리는 것과 외부 자원을 제때 닫는 것은 다른 문제다. 파일 같은 자원은 사용 범위가 끝날 때 닫히도록 with 문으로 관리한다. 객체 회수 시점에 기대면 자원을 얼마나 오래 붙잡을지 코드만 보고 판단하기 어렵다. 이번 프로그램은 파일을 열지 않지만, 이름의 범위와 자원의 사용 기간을 같게 생각하지 않는 습관은 그대로 적용된다.

완성 코드

다음 내용을 main.py로 저장한다. 외부 입력을 기다리지 않으며 표준 라이브러리만 사용한다. 정수는 0부터 9까지의 문자로 쓰고, 공백은 허용한다. 토큰에는 원문에서의 위치를 함께 보관한다. 위치는 0부터 세므로 오류 메시지의 “위치 3”은 네 번째 문자를 뜻한다.

import gc
import weakref


def tokenize(source):
    tokens = []
    index = 0
    while index < len(source):
        char = source[index]
        if char.isspace():
            index += 1
        elif "0" <= char <= "9":
            start = index
            while index < len(source) and "0" <= source[index] <= "9":
                index += 1
            tokens.append(("NUMBER", int(source[start:index]), start))
        elif char in "+-*/()":
            tokens.append((char, char, index))
            index += 1
        else:
            raise ValueError(f"위치 {index}: 허용하지 않는 문자 {char!r}")
    tokens.append(("EOF", None, len(source)))
    return tokens


class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.index = 0

    def current(self):
        return self.tokens[self.index]

    def take(self, kind):
        token = self.current()
        if token[0] != kind:
            raise ValueError(f"위치 {token[2]}: {kind} 필요")
        self.index += 1
        return token

    def parse(self):
        node = self.expression()
        self.take("EOF")
        return node

    def expression(self):
        node = self.term()
        while self.current()[0] in ("+", "-"):
            operator = self.current()[0]
            self.take(operator)
            node = (operator, node, self.term())
        return node

    def term(self):
        node = self.primary()
        while self.current()[0] in ("*", "/"):
            operator = self.current()[0]
            self.take(operator)
            node = (operator, node, self.primary())
        return node

    def primary(self):
        kind, value, position = self.current()
        if kind == "NUMBER":
            self.take("NUMBER")
            return ("number", value)
        if kind == "(":
            self.take("(")
            node = self.expression()
            self.take(")")
            return node
        raise ValueError(f"위치 {position}: 숫자 또는 여는 괄호 필요")


def evaluate(node):
    kind = node[0]
    if kind == "number":
        return node[1]
    left = evaluate(node[1])
    right = evaluate(node[2])
    if kind == "+":
        return left + right
    if kind == "-":
        return left - right
    if kind == "*":
        return left * right
    if kind == "/":
        if right == 0:
            raise ValueError("0으로 나눌 수 없음")
        return left / right
    raise ValueError("알 수 없는 연산")


def interpret(source):
    tokens = tokenize(source)
    tree = Parser(tokens).parse()
    return evaluate(tree)


def type_demo():
    value = 3
    print("첫 타입:", type(value).__name__)
    value = "3"
    print("다음 타입:", type(value).__name__)
    try:
        3 + value
    except TypeError:
        print("혼합 덧셈: TypeError")

    def annotated(item: int):
        return type(item).__name__

    print("힌트만 있는 함수:", annotated("3"))


def make_label(prefix):
    def label(topic):
        return prefix + topic
    return label


class Box:
    pass


def memory_demo():
    box = Box()
    box.link = box
    observer = weakref.ref(box)
    del box
    gc.collect()
    print("순환 객체 회수:", observer() is None)


def main():
    compiled = compile("2 + 3 * 4", "<demo>", "eval")
    print("컴파일 후 실행:", eval(compiled, {"__builtins__": {}}))
    type_demo()

    topic = "언어"

    def local_topic():
        topic = "파싱"
        return topic

    print("함수 안과 밖:", local_topic(), "/", topic)
    label = make_label("보충: ")
    print("클로저:", label("스코프"))
    memory_demo()

    source = "2 + 3 * 4"
    tokens = tokenize(source)
    tree = Parser(tokens).parse()
    print("토큰:", " ".join(str(token[1]) for token in tokens[:-1]))
    print("구조:", tree)

    examples = (
        source,
        "(2 + 3) * 4",
        "10 - 3 - 2",
        "8 / 2",
        "1 +",
        "2 3",
        "2 & 3",
        "4 / (2 - 2)",
    )
    for expression in examples:
        try:
            print(f"{expression} => {interpret(expression)}")
        except ValueError as error:
            print(f"{expression} => 오류: {error}")


if __name__ == "__main__":
    main()

줄별 해설

토큰을 만드는 부분

import gc와 import weakref는 메모리 실험에 사용할 표준 라이브러리를 불러온다. tokenize()의 index는 다음에 읽을 문자 위치다. 공백이면 위치만 이동하고, 숫자면 연속된 숫자의 끝까지 이동한다. 따라서 “123”은 숫자 토큰 하나가 된다. int(source[start:index])가 글자 묶음을 정수 값으로 바꾼다.

각 토큰은 종류, 값, 위치를 담은 튜플이다. 기호는 종류와 값이 같지만 숫자는 종류가 NUMBER이고 값은 정수다. 마지막의 EOF는 입력이 끝났다는 표시다. 실제 원문에 있는 문자가 아니라 분석을 단순하게 만드는 표식이다. 알 수 없는 문자는 건너뛰지 않고 즉시 오류로 알린다. 입력의 일부를 조용히 버리면 사용자가 쓴 식과 실행한 식이 달라질 수 있다.

구조를 만드는 부분

Parser 객체는 토큰 목록과 현재 위치를 보관한다. current()는 현재 토큰을 보기만 하고, take()는 예상한 종류가 맞을 때 위치를 한 칸 옮긴다. 예상과 다르면 토큰의 위치를 담아 오류를 발생시킨다. 숫자 토큰의 값과 위치를 구조에 모두 복사하지는 않는다. 이번 계산에는 숫자 값만 있으면 된다.

parse()가 식을 읽은 뒤 EOF까지 요구하는 줄은 짧지만 중요하다. 이 줄이 없으면 “2 3”에서 첫 번째 숫자만 읽고 성공했다고 보고할 수 있다. 파서는 시작 부분이 식처럼 보이는지만 확인해서는 안 된다. 허용한 문법이 입력 전체를 설명하는지 검사해야 한다.

expression()의 반복문은 기존 node를 새 연산의 왼쪽 자식으로 넣는다. 따라서 “10 - 3 - 2”는 “(10 - 3) - 2”로 묶인다. 같은 우선순위 연산을 어느 방향부터 묶는지 정하는 규칙을 결합성(associativity)이라고 한다. 이번 네 연산은 모두 왼쪽부터 묶는다. 우선순위와 결합성은 다른 규칙이므로 둘 다 확인해야 한다.

term()은 같은 방식으로 곱셈과 나눗셈을 묶는다. primary()는 숫자나 괄호를 처리한다. 여는 괄호를 읽으면 내부 식을 분석하고 닫는 괄호까지 요구한다. 빈 괄호나 닫히지 않은 괄호는 이 과정에서 거절된다. 괄호를 단순히 지우는 방식과 달리, 괄호가 지시한 묶음이 구조에 남는다.

구조를 실행하고 실험하는 부분

evaluate()는 숫자면 값을 반환하고, 연산이면 양쪽 자식을 먼저 평가한다. 나눗셈의 오른쪽 값이 0인지 확인하는 시점에는 이미 구문 분석이 끝났다. “4 / (2 - 2)”는 문법에는 맞지만 실행할 수 없다. 이 차이가 구문 규칙과 실행 의미를 구분해야 하는 이유다.

interpret()의 세 줄은 토큰화, 파싱, 평가를 순서대로 연결한다. type_demo()는 타입 변경과 타입 오류, 타입 힌트의 실행 시 효과를 각각 출력한다. 오류 메시지 전문 대신 오류 종류를 고정된 문자열로 출력하여 환경에 따른 표현 차이를 피한다. make_label()은 바깥 함수 호출이 끝나도 필요한 연결이 유지되는 사례다.

memory_demo()의 del box는 이름의 연결을 지운다. 객체를 즉시 파괴하라는 명령은 아니다. main()은 실험을 정해진 순서로 실행하고, 마지막 반복문은 각 식의 결과 또는 예상한 오류를 출력한다. 마지막 조건문은 이 파일을 직접 실행했을 때만 main()을 호출하는 Python의 실행 관례다.

실행 결과

저장한 파일이 있는 디렉터리에서 다음 명령을 실행한다. 첫 명령은 경고를 오류로 취급하여 Python 문법을 컴파일해 확인한다. 정상이라면 출력 없이 끝난다. 두 번째 명령이 프로그램을 실행한다.

python3 -W error -m py_compile main.py
python3 main.py
컴파일 후 실행: 14
첫 타입: int
다음 타입: str
혼합 덧셈: TypeError
힌트만 있는 함수: str
함수 안과 밖: 파싱 / 언어
클로저: 보충: 스코프
순환 객체 회수: True
토큰: 2 + 3 * 4
구조: ('+', ('number', 2), ('*', ('number', 3), ('number', 4)))
2 + 3 * 4 => 14
(2 + 3) * 4 => 20
10 - 3 - 2 => 5
8 / 2 => 4.0
1 + => 오류: 위치 3: 숫자 또는 여는 괄호 필요
2 3 => 오류: 위치 2: EOF 필요
2 & 3 => 오류: 위치 2: 허용하지 않는 문자 '&'
4 / (2 - 2) => 오류: 0으로 나눌 수 없음

오류 세 종류가 서로 다른 단계에서 나온다. “2 & 3”은 허용하지 않은 문자 때문에 토큰화에 실패한다. “1 +”와 “2 3”은 토큰은 만들 수 있지만 문법에 맞지 않는다. 0으로 나누는 식은 구조까지 만들 수 있으나 평가 중 실패한다. 오류가 발생한 단계를 알면 입력 규칙을 고칠지, 문법을 고칠지, 실행 조건을 고칠지 판단할 수 있다.

실무에서 자주 틀리는 것

사용자 식을 실행 함수에 바로 넘긴다

다음 코드는 고정된 예시에서는 14를 출력하지만, 입력을 바꾸면 Python이 허용하는 다른 표현도 실행한다. 사칙연산만 허용한다는 제품의 규칙이 코드에 드러나지 않는다. 내장 이름을 제한하는 설정 하나만으로 임의 코드 실행을 안전하게 격리했다고 판단해서도 안 된다.

# 잘못된 방식
user_expression = "2 + 3 * 4"
print(eval(user_expression))

완성 코드와 같은 파일에서 다음처럼 호출하면 허용한 숫자와 연산만 처리한다. 어휘 분석과 구문 분석은 기능을 구현하는 동시에 입력의 허용 범위를 명확히 한다.

# 고친 방식
user_expression = "2 + 3 * 4"
print(interpret(user_expression))

다만 이번 구현은 학습용이다. 외부 사용자가 보내는 입력을 처리한다면 길이, 괄호 깊이, 정수 크기, 계산 시간의 한도도 필요하다. 문자 종류를 제한했다는 사실만으로 자원 사용량까지 제한되지는 않는다.

타입 힌트가 값을 변환한다고 생각한다

다음 함수에 문자열을 전달하면 덧셈에서 오류가 난다. 매개변수 옆의 int는 변환 명령이 아니다.

# 잘못된 기대
def add_one(value: int):
    return value + 1

try:
    print(add_one("3"))
except TypeError:
    print("변환되지 않음")

입력이 숫자 문자열이라는 계약이라면 입력 경계에서 명시적으로 변환한다. 아래 코드는 4를 출력한다. 숫자로 바꿀 수 없는 문자열에 대한 처리는 호출하는 쪽의 요구사항에 맞춰 추가한다.

# 고친 방식
def add_one(value: int):
    return value + 1

raw_value = "3"
print(add_one(int(raw_value)))

함수 안의 대입이 바깥 이름을 바꾼다고 생각한다

다음 함수는 바깥의 hours를 읽어서 늘리는 것처럼 보인다. 하지만 함수 안에 대입이 있으므로 이 이름은 지역 이름으로 취급된다. 아직 지역 값이 연결되지 않은 상태에서 읽어 오류가 난다.

# 잘못된 방식
hours = 2

def add_hour():
    hours = hours + 1
    return hours

try:
    add_hour()
except UnboundLocalError:
    print("지역 이름에 값이 없음")

값을 인자로 받고 결과를 반환하면 변경 경로가 명확해진다. 아래 코드는 3을 출력한다. 바깥 이름을 직접 변경하는 문법도 있지만, 이 상황에서는 함수의 입력과 출력을 드러내는 것으로 충분하다.

# 고친 방식
hours = 2

def add_hour(current):
    return current + 1

hours = add_hour(hours)
print(hours)

이름 하나를 지우면 객체도 없어졌다고 생각한다

다음 코드는 0을 출력한다. first와 second는 같은 목록을 가리킨다. 이름 하나를 지워도 다른 참조가 남아 있으며, 그 참조로 변경한 결과도 남는다.

# 잘못된 기대
first = [1, 2]
second = first
del first
second.clear()
print(len(second))

서로 독립된 목록이 필요했다면 복사해야 한다. 아래 코드는 2를 출력한다. 이 예시는 정수만 담으므로 바깥 목록을 복사하는 것으로 충분하다. 안쪽에 다른 목록이 있다면 내부 객체를 공유하는지도 추가로 살펴야 한다.

# 고친 방식
first = [1, 2]
second = first.copy()
first.clear()
print(len(second))

한눈에 보기

네 가지 핵심을 실무와 면접 질문에 연결하기
핵심실무에서 쓰는 곳면접에서 묻는 방식실험 근거
컴파일과 실행 단계설정 언어, 입력 오류 진단컴파일과 인터프리트는 함께 가능한가compile() 후 eval(), 세 단계의 interpret()
타입 시스템입력 변환, 함수 계약동적 타입과 타입 힌트는 어떤 관계인가type_demo()의 세 가지 관찰
스코프함수 분리, 상태 보관함수가 끝나도 클로저가 값을 쓰는 이유는 무엇인가local_topic()과 make_label()
메모리 관리참조 누적 조사, 객체 수명 이해del과 객체 회수는 같은가자기 참조 객체와 약한 참조

면접 답변은 용어를 나열하는 대신 관찰에서 시작한다. “곱셈이 먼저 묶인 트리를 만들었다”, “타입 힌트가 있어도 문자열이 들어왔다”, “바깥 함수가 끝나도 반환된 함수가 접두어를 사용했다”처럼 실행 결과를 말한 다음 원리를 붙이면 된다. 어떤 구현에 관한 설명인지도 덧붙인다. CPython의 참조 수 관리 방식을 모든 언어의 공통 규칙으로 확대하지 않는다.

더 깊게 볼 서재와 읽을 때 가져갈 질문
책이어지는 주제읽을 때의 질문
Crafting Interpreters작은 언어의 구현이름과 함수를 추가하면 실행 환경은 어떻게 달라지는가
Types and Programming Languages타입과 실행 규칙허용하는 프로그램과 계산의 의미를 어떻게 명시하는가
Engineering a Compiler컴파일러의 구성과 변환같은 의미를 유지하면서 표현을 어떻게 바꾸는가

서재의 책은 지금 모두 끝내야 할 과제가 아니다. 입력 언어를 만들 일이 생기면 구현을, 타입 검사의 근거가 궁금하면 타입 규칙을, 성능을 위한 코드 변환이 궁금하면 컴파일러 구성을 찾아본다. 사실 확인이 필요할 때는 Python의 compile 설명, 이름 탐색과 실행 모델, 순환 참조 수거 기능 설명을 참고할 수 있다.

다음 장에서는 동작하는 프로그램을 요구사항, 설계, 테스트, 협업의 관점에서 살핀다. 이번 코드에서도 “무엇을 입력으로 허용하는가”와 “어떤 실패를 사용자에게 알리는가”라는 선택이 이미 등장했다. 언어의 실행 규칙을 이해한 뒤에는 그 선택을 팀이 확인할 수 있는 약속으로 만드는 일이 이어진다.

연습 문제

  1. 완성 코드에서 interpret("18 / 3 / 2")와 interpret("18 / (3 / 2)")의 결과를 예상하라. 우선순위와 결합성 중 무엇이 차이를 만드는지 설명하라.
  2. "(1 + 2", "1 @ 2", "7 / 0"이 각각 어느 단계에서 실패하는지 분류하고, 코드를 실행해 오류 메시지를 확인하라.
  3. label = make_label("복습: ") 뒤에 print(label("타입"))을 실행하면 무엇이 출력되는가. 바깥 함수 실행이 끝났는데도 가능한 이유를 설명하라.
  4. GC 실험에서 observer = weakref.ref(box) 바로 앞에 keeper = box를 추가하라. gc.collect() 직후 객체가 남는지 확인하고, keeper까지 지운 뒤 다시 확인하는 코드를 작성하라.

정답과 해설

  1. 결과는 각각 3.0과 12.0이다. 첫 식은 같은 우선순위의 나눗셈을 왼쪽부터 묶어 “(18 / 3) / 2”로 계산한다. 두 번째 식은 괄호가 오른쪽 계산을 먼저 묶도록 지정한다. 곱셈과 덧셈 사이의 우선순위를 바꾼 사례가 아니라, 같은 연산의 묶음을 바꾼 사례다.

    print(interpret("18 / 3 / 2"))
    print(interpret("18 / (3 / 2)"))
    
  2. 첫 식은 닫는 괄호가 없어 파싱에 실패한다. 두 번째 식은 허용하지 않은 문자가 있어 토큰화에 실패한다. 세 번째 식은 평가 중 0으로 나누려 하므로 실패한다. 다음 코드를 완성 코드와 같은 파일에서 실행하면 확인할 수 있다.

    for source in ("(1 + 2", "1 @ 2", "7 / 0"):
        try:
            print(interpret(source))
        except ValueError as error:
            print(error)
    

    출력은 순서대로 위치 6: ) 필요, 위치 2: 허용하지 않는 문자 '@', 0으로 나눌 수 없음이다. 오류 종류만 보면 모두 ValueError지만, 발생한 함수와 메시지는 서로 다르다.

  3. 복습: 타입이 출력된다. 반환된 내부 함수가 바깥의 prefix 연결을 유지하기 때문이다. 스코프는 이름을 어디에서 찾는지를 설명하고, 필요한 객체가 언제까지 유지되는지는 남아 있는 참조와 함께 설명해야 한다.

    label = make_label("복습: ")
    print(label("타입"))
    
  4. 첫 확인에서는 True, 두 번째 확인에서도 True가 출력된다. 첫 줄은 “아직 존재하는가”를, 둘째 줄은 “회수되었는가”를 묻는 서로 다른 조건이다. keeper가 있는 동안에는 도달 가능한 객체이므로 수거 대상이 아니다.

    box = Box()
    box.link = box
    keeper = box
    observer = weakref.ref(box)
    del box
    gc.collect()
    print(observer() is not None)
    del keeper
    gc.collect()
    print(observer() is None)
    

    이 결과는 GC를 호출하면 모든 객체를 지운다는 뜻이 아니다. 프로그램이 계속 사용하는 객체를 보존하면서 더 이상 도달할 수 없는 객체를 정리한다는 뜻이다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.