Loading the catalog…
Loading the catalog…
들어가며: 교수님의 한마디에서 시작된 질문 자료구조 수업에서 교수님이 이런 말씀을 하셨다. "C언어는 배열의 의미를 제대로 보여주지만, 파이썬 같은 언어에서는 그 의미가 퇴색됐다." 처음엔 무슨 뜻인지 와닿지 않았다. 파이썬 리스트도 인덱스로 접근하고, 순서도 있고, 배열이라고 부르지 않나? 이 질문을 파고들다 보니 배열 하나에서 시작해 타입이 메모리에 어떻게 놓이는지, 메모리는 누가 언제 해제하는지, 컴파일러는 무엇을 하는지, 그리고 C·파이썬·Rust가 각각 어떤 선택을 했는지까지 전부 연결되어 있다는 걸 알게 됐다. 이 글은 그 흐름을 처음부터 끝까지 정리한 기록이다. 배열의 진짜 의미: 연속 메모리와 주소 산술 자료구조에서 배열(array)은 "여러 값을 순서대로 담는 통"이 아니라, 아주 구체적인 메모리 구조를 가리키는 말이다. 세 가지 조건으로 정의된다. 같은 타입 의 원소들이 메모리에 연속적으로(contiguous) 붙어서 놓이고 그래서 i번째 원소의 주소를 계산으로 바로 구할 수 있다 $$ \text{주소}(i) = \text{시작주소} + i \times \text{원소크기} $$ 3번이 배열의 존재 이유다. 1000만 개짜리 배열에서 7,342,118번째 원소를 꺼낼 때, 앞에서부터 세지 않고 곱셈 한 번 덧셈 한 번으로 그 자리로 점프한다. 이것이 "인덱스 접근이 O(1)"이라는 말의 실제 내용이다. 여기서 중요한 건 공식이 성립하려면 원소 하나의 크기가 고정 되어 있어야 한다는 점이다. 값은 바뀌어도 되지만, 각 칸이 차지하는 바이트 수는 같아야 한다. 크기가 제각각이면 곱셈으로 위치를 구할 수 없다. C 배열: 주소 산술 그 자체 C 배열은 위 정의를 숨기지 않고 그대로 드러낸다. int a[5] = {10, 20, 30, 40, 50}; 메모리에 int 크기(4바이트) × 5 = 20바이트가 정확히 연달아 잡힌다. 그리고 C에서 a[3] 은 문법적으로 *(a + 3) 과 완전히 동일하다. "시작주소에서 3칸 뒤를 읽어라"는 뜻이다. 덧셈은 교환법칙이 성립하므로 3[a] 라고 써도 컴파일된다. C의 배열은 주소 산술 그 자체다. 그래서 C 배열은 다음 특징을 갖는다. 크기가 고정된다. 뒤에 다른 데이터가 붙어 있으니 중간에 늘릴 수 없다. 모든 원소가 같은 타입이어야 한다. 크기가 달라지면 곱셈 공식이 깨진다. 범위를 넘어 읽으면 그냥 옆 메모리를 읽어버린다. 검사 없이 계산만 하기 때문이다. 이 불편함들은 전부 "배열이 무엇인가"를 그대로 드러내는 흔적이다. 참고로 배열이 스택에 있느냐 힙에 있느냐는 중요하지 않다. malloc(5 * sizeof(int)) 로 힙에 잡아도 완전한 배열이다. 핵심은 연속된 공간에 값 자체 가 들어 있다는 것이다. 파이썬 리스트: 값의 배열이 아니라 주소의 배열 a = [10, "hello", 3.14, [1, 2]] a.append(99) 타입이 섞이고 크기도 자유롭게 늘어난다. 자료구조 정의로 보면 이건 배열이 아니다. 그러면 내부는 어떻게 생겼을까? CPython의 리스트는 포인터들의 배열 이다. 메모리에 연속으로 놓인 건 값이 아니라 "값이 있는 곳의 주소"들이고, 실제 10, "hello", 3.14는 각각 힙 어딘가에 따로 떨어진 객체로 존재한다. C 배열: [10][20][30] ← 연속 공간에 값 자체 파이썬 리스트: [주소][주소][주소] ← 연속 공간에 주소 │ │ │ ▼ ▼ ▼ (10) ("hi") (3.14) ← 각자 흩어진 객체 포인터는 전부 8바이트로 크기가 같으니 주소 산술이 가능하고, 그래서 a[i] 는 여전히 O(1)이다. 다만 C는 계산한 칸에 도착하면 끝이고, 파이썬은 도착한 칸에서 주소를 꺼내 한 번 더 따라가야 값이 나온다. 이 한 겹의 차이가 교수님이 "퇴색됐다"고 표현한 지점이다. 파이썬은 배열의 편리한 결과(인덱스 접근)만 남기고, 배열을 배열이게 하는 구조적 제약(연속성, 동일 타입, 고정 크기)은 추상화 뒤로 치워버렸다. 사용자 입장에서는: 원소가 메모리에 붙어 있는지 알 수도 없고 신경 쓸 필요도 없다 타입 제약이 없다 크기 제약이 없다 (내부적으로 꽉 차면 더 큰 배열을 새로 잡고 복사하는 동적 배열 방식) 범위 밖 접근은 에러로 막아준다 흔히 하는 오해 하나를 짚고 가자. "파이썬 리스트도 값들이 연속되어 있는 건 맞다"는 말은 틀렸다. 연속인 건 포인터이고, 값은 흩어져 있다. 이 차이는 성능에서 눈에 띄게 갈린다. C 배열을 순회하면 CPU 캐시가 다음 값들을 미리 끌어오지만, 파이썬 리스트는 포인터를 읽고 그 주소로 점프하는 간접 참조가 매번 붙는다. NumPy가 빠른 이유는 이 간접 참조를 없애고 값 자체를 연속으로 깔기 때문이다. 왜 파이썬은 이맇게 했나: 모든 것이 객체 단편화 때문은 아니다. 오히려 포인터 방식이 단편화를 더 만든다. 진짜 이유는 파이썬 언어의 설계 자체에서 나온다. 파이썬에는 "그냥 값"이 없다. C에서 10 은 4바이트 그 자체지만, 파이썬에서 10 은 객체다. [참조 카운트][타입 정보 포인터][실제 값 10] ← 약 28바이트 문자열은 길이, 해시값, 인코딩 정보까지 붙어서 더 크고, 리스트는 또 다르다. 즉 파이썬의 모든 값은 크기가 제각각인 객체 다. 크기가 제각각인 걸 연속으로 깔면 주소 산술 공식이 깨진다. 그래서 크기가 똑같은 포인터만 깔고 객체는 밖에 둔다. 파이썬 변수는 전부 참조다. x = 6 은 "상자 x에 6을 넣는다"가 아니라 두 단계다. 6 이라는 int 객체가 메모리에 존재하게 됨 x 라는 이름이 그 객체의 주소를 가리키게 됨 x = [1, 2, 3] y = x y.append(4) print(x) # [1, 2, 3, 4] y = x 는 복사가 아니라 같은 객체를 둘이 가리키는 것이다. 이게 가능하려면 객체가 한 곳에 고정되어 있어야 하고, 변수든 리스트 칸이든 전부 주소만 들고 있어야 한다. id(x) 로 확인하면 x 와 y 의 주소가 같다. 안쪽 값이 크기를 바꿀 수 있다. a[0] = "hello world, this is long" 처럼 원소를 더 큰 것으로 바꿀 때, C 배열이었다면 옆 칸을 덮어버린다. 포인터 방식이면 새 객체를 만들고 주소만 갈아끼우면 끝이다. 연속 값 배열이 편한 건 C처럼 타입이 정해져 있고, 값이 고정 크기이고, 변수가 값을 직접 담는 언어에서의 이야기다. 파이썬은 그 세 전제를 전부 버린 언어라서 포인터 배열이 유일하게 말이 되는 선택이다. 속도를 내준 대가로 유연함을 산 것이다. C와 파이썬의 타입: 컴파일 때 사라지는가, 실행 내내 남는가 C: 메모리에는 값만 있고 타입은 없다. int a = 10; 에서 메모리에 들어가는 건 10을 나타내는 4바이트뿐이다. "이게 int다"라는 정보는 어디에도 저장되지 않는다. 타입은 컴파일러가 코드를 읽을 때만 알고 있다가, "이 4바이트는 정수로 읽어라"는 명령어를 생성하는 데 쓰고 버린다. 실행 중에는 타입이라는 개념 자체가 없다. float f = 3.14; int *p = (int*)&f; printf("%d", *p); // 같은 4바이트를 정수로 우겨서 읽음 → 이상한 숫자 메모리는 그냥 바이트 덩어리고, 어떻게 해석할지는 전적으로 코드가 정한다. 파이썬: 값이 자기 타입을 들고 다닌다. a = 10 에서 메모리에 들어가는 건 [참조카운트][타입: int][값 10] 묶음이다. "내가 int다"라는 정보가 값 옆에 같이 저장되어 있다. 그래서 실행 중에 type(a) 를 물어볼 수 있고, a + "x" 를 하면 런타임에 타입을 확인해서 에러를 낼 수 있다. C 파이썬 메모리에 저장되는 것 값(바이트)만 값 + 타입 + 참조카운트 등 메타데이터 타입은 어디 있나 컴파일러 머릿속에만, 실행 시 소멸 객체 안에, 실행 내내 유지 변수가 담는 것 값 자체 객체의 주소 "변수가 담는 것" 행이 배열 차이의 원인이다. C 변수는 값을 직접 담으니 배열도 값을 직접 깔고, 파이썬 변수는 주소를 담으니 배열도 주소를 깐다. 그리고 "타입이 객체 안에 있다"는 게 메타데이터 때문에 객체 크기가 제각각이 되는 이유다. 파이썬의 메모리 해제: 참조카운트와 순환참조 GC 객체마다 붙어 있는 참조카운트는 그 객체를 가리키는 것의 개수다. 변수뿐 아니라 리스트 칸, 딕셔너리 값, 함수 인자 등 모든 참조 를 센다. a = [1, 2, 3] # 리스트 객체 생성, 참조카운트 1 b = a # 2 c = [a] # 3 (c의 0번 칸이 가리킴) del b # 2 a = None # 1 (a가 다른 걸 가리키게 됨) c.clear() # 0 → 이 순간 즉시 해제 C의 free 와 다른 점은 0이 되는 즉시 해제된다는 것이다. 따로 "지금 치워"라고 할 필요도 없고, 언제 치워질지 모르는 것도 아니다. sys.getrefcount(x) 로 직접 확인할 수 있는데, 호출 시 인자로 넘기면서 +1이 되므로 실제보다 1 크게 나온다. 한 가지 구멍이 있다. 서로를 가리키는 순환 참조 다. a = [] b = [] a.append(b) b.append(a) del a, b 변수는 다 사라졌는데 두 리스트가 서로를 가리키고 있어서 참조카운트가 각각 1에서 안 내려간다. 접근할 방법은 없는데 메모리는 안 풀린다. 그래서 파이썬은 참조카운트 외에 순환 참조만 잡아내는 가비지 컬렉터 를 보조로 돌린다. 기본은 참조카운트로 즉시 처리하고, 가끔 GC가 돌면서 고립된 덩어리를 찾아 치우는 2중 구조다. 이 참조카운트가 객체마다 붙어 있고 모든 대입마다 증감 연산이 일어난다는 것 자체가 파이썬이 지불하는 런타임 비용의 일부다. 파이썬이 얻은 것: 런타임 타입 정보가 열어주는 기능들 메타데이터 비용은 분명히 비효율이다. 그 대가로 얻는 것은 "타입을 안 써도 된다" 하나가 아니라, "모든 것이 객체"라는 단 하나의 규칙으로 언어 전체가 통일된다 는 것이다. 메모리 관리를 안 해도 된다. 참조카운트 덕분에 아무도 안 가리키는 순간 자동 해제된다. malloc / free 짝 맞추기, 해제 후 접근, 메모리 누수와 싸우는 일이 통째로 사라진다. 범위 밖 접근이 터지지 않는다. 객체가 자기 길이와 타입을 들고 있으니 a[100] 에서 에러를 낼 수 있다. C에서는 옆 메모리를 읽어버리고, 이것이 보안 취약점의 역사 그 자체다. 실행 중에 타입을 물어볼 수 있다. 이게 핵심이다. 아래에서 자세히 본다. 숫자, 문자열, 함수, 클래스, 모듈이 전부 같은 방식으로 다뤄진다. 함수를 리스트에 넣고, 클래스를 변수에 담아 넘기는 게 전부 "객체 주소 하나 복사"라서 특별한 문법이 필요 없다. 타입을 안 써도 된다. 4번의 당연한 결과다. 실행 중에 타입을 물어본다는 것 C에서는 프로그램이 실행되는 동안 "이 값이 무슨 타입이지?"라고 물어볼 방법이 없다. 타입은 컴파일 때 사라졌으니까. void print_it(void *p) { // p가 가리키는 게 int인지 float인지 알 방법이 전혀 없음 } 파이썬은 객체가 타입을 몸에 붙이고 다니니 물어볼 수 있다. def print_it(x): print(type(x)) # 실행 중에 알아냄 if isinstance(x, str): # "문자열이냐?" 물어보기 print("문자열이네") class Dog: def speak(self): return "멍" d = Dog() hasattr(d, "speak") # True — speak이 있냐? getattr(d, "speak")() # 이름을 문자열로 주고 꺼내서 호출 이 "물어볼 수 있음" 하나에서 파이썬 특유의 기능들이 따라 나온다. 덕 타이핑 : "타입이 뭐냐"가 아니라 "할 수 있냐"로 판단. animal.speak() 는 Dog든 Cat이든 speak만 있으면 된다. 데코레이터 : 함수도 객체니까 함수를 받아 다른 함수로 바꿔치기할 수 있다. f.__name__ 이 되는 것도 함수 객체가 이름을 메타데이터로 들고 있어서다. 동적 import : 모듈도 객체라 이름을 문자열로 받아 실행 중에 불러올 수 있다. 메타클래스 : 클래스조차 객체라서 실행 중에 만들거나 고칠 수 있다. 설계 철학: 사람 시간 > 컴퓨터 시간 파이썬이 나온 1991년에도 C보다 수십 배 느린 건 알았다. 그런데 대부분의 프로그램은 CPU가 병목이 아니다. 사람이 코드를 쓰고 읽고 고치는 시간이 병목이다. 파이썬은 "컴퓨터가 10배 더 일하게 하고 사람은 3배 덜 일하게 하자"에 베팅한 언어다. 그리고 정말 속도가 필요한 부분만 빠져나갈 구멍을 열어뒀다. NumPy, pandas, PyTorch는 핵심 연산을 C로 짜고 파이썬은 조립만 한다. "파이썬은 느린데 왜 AI는 다 파이썬이냐"의 답이 이것이다. 최근 언어들의 방향: 편의는 가져오고 비용은 안 내기 "그러면 최근 언어들은 대부분 파이썬처럼 객체 형태를 띠겠네?"라는 추측은 반대다. 파이썬(1991), 루비(1995), 자바스크립트(1995)가 "전부 객체 + 동적 타입" 세대이고, 전부 30년 된 언어다. 이 세대의 발견은 "타입 안 쓰고 메모리 안 만지면 개발이 훨씬 빠르다"였고, 그 대가로 속도를 냈다. 그 이후 세대는 파이썬의 편의성은 갖되 그 비용은 안 내는 방법 을 찾는 쪽으로 움직였다. 언어 접근 Go (2009) 정적 타입이지만 타입 추론으로 거의 안 씀. GC는 있음. 값은 C처럼 직접 저장. Rust (2010) 정적 타입 + 타입 추론. GC도 참조카운트도 없이 컴파일러가 소유권을 추적해 메모리를 자동 해제. 실행 속도는 C와 동급. Kotlin (2011) 정적 타입 + 추론. JVM 위에서 자바보다 간결하게. TypeScript (2012) 자바스크립트에 타입을 다시 붙인 언어. Swift (2014) 정적 타입. 참조카운트는 쓰지만 int 같은 기본값은 C처럼 직접 저장. 공통점은 하나다. 타입은 다시 가져오되 사람이 일일이 쓰진 않게(타입 추론), 메모리 관리는 자동으로 하되 런타임 비용은 줄이게. 파이썬이 런타임에 돈을 내고 샀던 안전성과 편의를 컴파일 타임에 사려는 시도다. TypeScript가 특히 상징적이다. 동적 타입 언어의 끝판왕인 자바스크립트를 쓰던 사람들이 "타입 없이 큰 프로그램 짜는 건 힘들다"며 타입을 되돌린 것이다. 파이썬 자체도 3.5부터 타입 힌트( def f(x: int) -> str )를 넣었다. 실행엔 영향 없지만 도구가 검사해준다. Rust: 타입 추론, 컴파일러가 타입을 기억하고 버리는 과정, 기계어로의 변환 타입 추론: 타입은 있지만 쓰지는 않는다 같은 코드를 세 언어로 놓고 보면 바로 보인다. int x = 6; // C: 타입을 사람이 씀 x = "hello"; // 컴파일 에러 x = 6 # 파이썬: 타입 없음 x = "hello" # 그냥 됨 — x는 이름표라 아무거나 가리킴 let x = 6; // Rust: 타입을 안 썼는데... x = "hello"; // 컴파일 에러 — "x는 i32인데 왜 문자열?" Rust 컴파일러는 let x = 6; 을 읽고 "오른쪽에 6이 있네, 정수니까 x는 i32로 확정"이라고 추론한다. 마치 사람이 let x: i32 = 6; 이라고 쓴 것처럼 취급한다. 타입은 분명히 있는데, 그걸 적는 일을 사람 대신 컴파일러가 한 것뿐이다. ( i32 는 32비트 정수, C의 int 에 해당한다. Rust는 i8 , i64 , u32 , f64 처럼 타입 이름에 크기를 그대로 적는다.) 타입이 정해지는 시점 누가 정하나 나중에 바꿀 수 있나 C 컴파일 때 사람이 적음 불가 Rust 컴파일 때 컴파일러가 추론 불가 파이썬 정해지지 않음 (객체가 각자 들고 있음) — 가능 (이름표만 옮기면 됨) 파이썬의 x 는 6 객체에 붙은 이름표라 "hello" 객체로 옮기는 게 자유다. Rust의 x 는 i32로 못 박힌 4바이트 상자다. C와 같은 상자인데, 라벨을 컴파일러가 알아서 써준 것이다. 컴파일러는 어디에 타입을 기억하는가 "기억한다"는 건 어딘가에 저장한다는 뜻인데, 그 데이터는 어디 있을까? 답은 컴파일러가 돌아가는 동안에만 컴파일러의 메모리에 있다가, 컴파일이 끝나면 버려진다 이다. 런타임이 아니라 컴파일러가 기억한다. 컴파일 중 : 컴파일러는 소스코드를 읽는 하나의 프로그램이다. 코드를 읽으면서 자기 메모리에 심볼 테이블이라는 표를 만든다. x | i32 | 4바이트 | 스택 오프셋 8 같은 줄이 추가된다. x = "hello" 가 오면 이 표를 보고 에러를 낸다. 기계어 생성 : 표를 참고해 "오프셋 8번 자리에 4바이트짜리 정수 6을 써라" 같은 명령어를 뽑는다. 타입 정보가 명령어 선택에 녹아 들어간다. 컴파일 종료 : 표는 버려진다. 남는 건 기계어 파일뿐이다. 실행 : 메모리에는 6을 나타내는 4바이트만 있다. "이게 i32다"라는 정보는 어디에도 없다. 기계어에는 변수도 타입도 없다 let x = 6; let y = x + 1; 이것이 컴파일되면 대략 이런 어셈블리가 된다. mov DWORD [rbp-4], 6 ; rbp-4 번지에 4바이트(DWORD)로 6을 써라 mov eax, DWORD [rbp-4] ; rbp-4 번지에서 4바이트 읽어서 eax에 담아라 add eax, 1 ; eax에 정수 1을 더해라 mov DWORD [rbp-8], eax ; 결과를 rbp-8 번지에 4바이트로 써라 x 는 없다. rbp-4 라는 주소 가 됐다. i32 도 없다. 대신 DWORD (4바이트)라는 크기 지정 과 add (정수 덧셈)라는 명령어 종류 에 녹아 있다. CPU에는 같은 덧셈이어도 add (정수), addss (32비트 실수), addsd (64비트 실수)처럼 명령어가 여러 개 있다. 컴파일러가 "x는 i32"를 보고 add 를 골랐다. "정수로 읽어라"는 정보가 메모리에 적힌 게 아니라, 컴파일러가 정수용 명령어를 골라 박아놓은 것 자체가 그 정보 다. CPU는 rbp-4 에 든 게 x인지 i32인지 전혀 모르고, 받은 명령어를 그대로 실행할 뿐이다. 파이썬은 이 선택을 미리 못 한다. x + 1 에서 x가 정수일지 문자열일지 실행해봐야 알기 때문이다. 그래서 실행 중에 객체의 타입 칸을 읽고 "정수네, 정수 덧셈 하자"를 매번 결정한다. C와 Rust는 그 결정을 컴파일 때 한 번 하고 끝낸다. 이것이 속도 차이의 가장 직접적인 원인이다. Rust와 C의 차이: 소유권, 범위 기반 해제, 빌림 메모리에 값이 놓이는 방식과 타입이 컴파일 때 사라지는 것, 이 둘은 Rust와 C가 정말로 같다. 그래서 Rust 실행 속도가 C와 거의 같다. 차이는 컴파일러가 코드를 얼마나 깐깐하게 검사하느냐 에서 갈린다. 메모리 해제: C는 사람이, Rust는 컴파일러가 int *p = malloc(100); free(p); *p = 5; // 해제한 메모리에 씀 — 컴파일러는 통과시킴, 실행하면 뭔 일이 날지 모름 let v = vec![1, 2, 3]; drop(v); v[0]; // 컴파일 에러: "v는 이미 사라졌는데?" "프로그램은 동작하다가 값이 필요 없어지기도 하고 계속 필요하기도 한데, 컴파일러가 어떻게 그걸 미리 알 수 있나?"라는 의문이 자연스럽다. 답은 실행을 예측하는 게 아니라 코드의 구조(중괄호)만 보고 정한다 는 것이다. 소유권(ownership) 규칙은 세 가지뿐이다. 모든 값에는 주인(owner)이 딱 하나 주인은 넘길 수 있다 (반환, 인자 전달, 다른 변수에 대입) 주인이 중괄호를 나가는 순간 해제 fn main() { let a = vec![1]; { let b = vec![2]; } // ← 여기서 b 해제 println!("{:?}", a); } // ← 여기서 a 해제 컴파일러는 "이 벡터가 언제까지 필요할까"를 고민하지 않는다. 주인 변수가 선언된 블록이 어디서 닫히는지만 본다. 그건 코드를 읽기만 하면 100% 확정된다. 그 자리에 free 에 해당하는 명령어를 자동으로 끼워 넣는다. 블록이 끝나도 값이 더 필요하면 주인을 넘긴다(move). fn make() -> Vec<i32> { let v = vec![1, 2, 3]; v // 반환 = 주인 자격을 호출한 쪽으로 넘김 } // v는 주인이 아니게 됐으므로 해제 안 함 fn eat(v: Vec<i32>) { } // 인자로 받음 = 주인이 됨, 함수 끝에서 해제 fn main() { let w = make(); // 이제 w가 주인 eat(w); // 주인 넘김 println!("{:?}", w); // 컴파일 에러: "w는 이미 넘겨줬잖아
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
C, Python, Rust까지: 타입과 메모리의 이야기. 들어가며: 교수님의 한마디에서 시작된 질문 자료구조 수업에서 교수님이 이런 말씀을 하셨다. "C언어는 배열의 의미를 제대로 보여주지만, 파이썬 같은 언어에서는 그 의미가 퇴색됐다." 처음엔 무슨 뜻인지 와닿지 않았다. 파이썬 리스트도 인덱스로 접근하고, 순서도 있고, 배열이라고 부르지 않나? 이 질문을 파고들다 보니 배열 하나에서 시작해 타입이 메모리에 어떻게 놓이는지, 메모리는 누가 언제 해제하는지, 컴파일러는 무엇을 하는지, 그리고 C·파이썬·Rust가 각각 어떤 선택을 했는지까지 전부 연결되어 있다는 걸 알게 됐다. 이 글은 그 흐름을 처음부터 끝까지 정리한 기록이다. 배열의 진짜 의미: 연속 메모리와 주소 산술 자료구조에서…
Open source