타입 시스템과 런타임은 별개 주제처럼 보이지만 한 결정이 다른 결정을 강제한다. 정적 타입(Static Typing)은 검증을 컴파일 타임으로 옮겨 런타임을 가볍게 하고, 동적 타입(Dynamic Typing)은 런타임에 타입을 들고 다니며 유연성을 산다. 인터페이스를 명시적으로 선언할지 구조로 추론할지도 같은 결정 위에서 갈린다. Java, Python, Go 는 이 결정을 다르게 내리고, 그 차이가 컴파일러 / JIT / 인터프리터 라는 런타임 매커니즘의 차이로 이어진다.

Java JIT

Java 는 정적 강타입이다. 변수와 메서드 시그니처에 타입이 모두 적혀 있고, 컴파일러가 호출 가능 여부를 컴파일 타임에 검증한다. 소스는 javac.class 바이트코드로 컴파일하고, JVM 이 그 바이트코드를 적재해서 실행한다.

flowchart LR
    src[".java
(source)"] --> bc[".class
(bytecode)"] bc --> int["JVM Interpreter"] int --> hot{"hot method?"} hot -->|no| int hot -->|yes| c1["C1 Compiler
(빠른 컴파일)"] c1 --> c2["C2 Compiler
(공격적 최적화)"] c1 --> native["Native code"] c2 --> native

JVM 의 핵심 매커니즘은 JIT(Just-In-Time) 컴파일이다. HotSpot JVM 은 시작 시 바이트코드를 인터프리터로 실행하다가, 자주 호출되는 메서드(hot method) 가 보이면 C1 컴파일러로 빠르게 native code 로 변환한다. 더 hot 해지면 C2 컴파일러가 공격적인 최적화 (inlining, escape analysis, vectorization) 를 적용해 다시 컴파일한다. Java 8 부터 두 단계가 결합된 계층형 컴파일(Tiered Compilation)이 기본이다.

JIT 의 이점은 런타임 프로파일링 이다. 어떤 분기가 자주 도는지, 어떤 타입이 실제로 흘러오는지 런타임에 관찰해서 그 정보 위에서 최적화한다. 정적 컴파일러가 못 보는 패턴까지 잡는다. 단점은 워밍업 시간과 메모리 비용.

타입 시스템에 한 가지 함정이 있다. 제네릭의 타입 소거(Type Erasure)다.

List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();

// 컴파일 타임에는 다른 타입
// 런타임에는 둘 다 List 로만 보임
System.out.println(strings.getClass() == integers.getClass()); // true

List<String><String> 은 컴파일 타임에만 존재하고 바이트코드에서는 사라진다. JVM 은 raw List 만 본다. 컴파일 시점 안전을 제공하되 런타임 reflection 으로는 타입 인자를 알아낼 수 없는 한계가 따라온다.

Python Type Hint

Python 은 동적 타입이다. 변수에 타입이 없고 객체에만 타입이 있다. 함수 호출은 매개변수 객체가 호출하려는 메서드를 가지고 있느냐로 판단한다 — 덕 타이핑(Duck Typing).

def quack(thing):
    return thing.quack()   # 어떤 객체든 .quack() 만 있으면 동작

class Duck:
    def quack(self): return "오리"

class Robot:
    def quack(self): return "기계오리"

quack(Duck())     # 오리
quack(Robot())    # 기계오리

CPython 은 바이트코드 인터프리터다. 소스를 .pyc 바이트코드로 컴파일하고 인터프리터가 한 명령씩 실행한다. JIT 가 없는 대신 Python 3.11 부터 특화 적응형 인터프리터(Specializing Adaptive Interpreter, PEP 659)가 점진 도입돼 자주 도는 명령을 타입 특화 버전으로 자체 교체한다.

동적 타입의 대가는 대규모 코드베이스에서의 안정성 이다. 호출 시점에 오류가 드러나니 정적 분석으로는 잡히지 않는 버그가 많다. 이 빈틈을 메우려고 도입된 게 타입 힌트(Type Hint)다.

def add(a: int, b: int) -> int:
    return a + b

a: int 같은 타입 힌트는 선언적 메타데이터 다. 런타임에 강제되지 않는다. add("hello", "world") 라고 부르면 인터프리터는 그냥 문자열 concat 을 실행한다. 타입 힌트의 검증은 mypy / pyright 같은 외부 정적 타입 체커가 담당한다. PEP 484 (3.5+) 로 시작해서 PEP 526 (변수 annotation), PEP 695 (3.12 의 새 type alias 문법) 로 표현력이 꾸준히 늘어왔다.

결과적으로 Python 의 타입 시스템은 점진적 타이핑(Gradual Typing)이다. 타입 힌트가 있는 부분은 정적으로 검증되고, 없는 부분은 동적으로 둔다. 안정성과 유연성의 비율을 코드 단위로 조정한다.

Go Structural Typing

Go 는 정적 강타입이지만 인터페이스가 구조적(Structural)이다. 어떤 타입이 어떤 인터페이스를 구현한다고 선언할 필요가 없다. 메서드 시그니처가 인터페이스에 들어 있는 메서드들과 일치하면 자동으로 그 인터페이스를 만족한다.

type Quacker interface {
    Quack() string
}

type Duck struct{}
func (Duck) Quack() string { return "오리" }

type Robot struct{}
func (Robot) Quack() string { return "기계오리" }

// implements 키워드 없음. Duck 도 Robot 도 자동으로 Quacker.
var q Quacker = Duck{}
q = Robot{}

명시적 선언이 없으니 라이브러리 사이의 결합이 약해진다. 인터페이스를 정의하는 쪽이 그 인터페이스를 구현할 타입을 알 필요가 없고, 구현하는 쪽도 어떤 인터페이스를 만족하게 될지 신경 쓸 필요가 없다. Go 의 “인터페이스는 소비자가 정의한다” 관례가 여기서 나온다.

상속 대신 구성(Composition, embedding)이 객체 재사용의 기본이다.

type Logger struct{}
func (Logger) Log(msg string) { /* ... */ }

type Service struct {
    Logger   // embedding
}

s := Service{}
s.Log("hello")   // Logger 의 메서드가 Service 의 메서드처럼 호출됨

Go 컴파일러는 단일 패스에 가깝다. 소스를 직접 native code 로 컴파일해 단일 정적 바이너리를 만든다. JVM 같은 런타임 가상 머신이 없고, JIT 도 없다. 시작이 빠르고 배포가 단순한 대신 런타임 적응형 최적화는 포기했다. Generics 는 Go 1.18 (2022.3) 에서 추가됐고, 타입 인자를 컴파일 타임에 처리한다 (GC shape stenciling).

세 모델 비교

타입 시스템 결정이 런타임 매커니즘으로 어떻게 이어지는지 정리하면 다음과 같다.

항목JavaPythonGo
타입 검증 시점컴파일 타임런타임 (+ 외부 정적 체커)컴파일 타임
인터페이스 모델명시적 (implements)duck typing (런타임)structural (암시적)
컴파일 결과bytecode → JVMbytecode → CPython 인터프리터native binary
런타임 최적화HotSpot JIT (C1/C2 tiered)Specializing interpreter (3.11+)컴파일 타임에 완료
시작 시간워밍업 필요빠름 (인터프리터)빠름 (정적 바이너리)
코드 재사용상속 + 인터페이스duck typing + 다중상속composition (embedding) + interface
제네릭Type Erasureruntime 제네릭 (모든 타입이 객체)컴파일 타임 (1.18+)

Java 의 모델이 적합한 경우:

  • 장수 서비스에서 JIT 워밍업이 비용 대비 이득
  • 명시적 인터페이스 계약이 도메인에 잘 맞음
  • IDE 의 강력한 리팩토링 지원이 필요한 대규모 코드베이스

Python 의 모델이 적합한 경우:

  • 빠른 반복과 실험성 코드
  • 덕 타이핑의 유연성이 도메인 모델링에 잘 맞음
  • 타입 힌트 + mypy/pyright 로 점진적 타이핑을 도입할 수 있는 환경

Go 의 모델이 적합한 경우:

  • 빠른 시작과 단순한 배포 (CLI, 서버 부팅이 빠른 워크로드)
  • 라이브러리 사이 결합을 느슨하게 유지해야 하는 환경
  • 컴파일 타임 안전이 필요하지만 의식할 인터페이스 boilerplate 는 최소화하고 싶을 때

세 언어가 같은 작업을 다른 매커니즘으로 풀어낸다. Java 는 강타입 안전을 정적 검증으로, 성능을 런타임 적응형 JIT 로 분리해 양쪽을 다 잡는다. Python 은 동적 유연성을 기본으로 두고 타입 힌트와 외부 체커로 안정성을 점진 도입한다. Go 는 정적 검증과 단일 컴파일러로 단순함을 잡고, 구조적 타이핑으로 라이브러리 결합을 느슨하게 유지한다. 어느 한쪽이 절대적으로 낫다기보다, 시스템이 무엇을 우선해야 하는가 — 시작 시간, 적응형 성능, 유연성, 결합도 — 가 선택을 결정한다.

같은 thesis 가 시리즈 전체를 통과한다. 함수 인자 전달, 가비지 컬렉터, 동시성 모델, 타입 시스템 — 네 결정 모두 각 언어가 무엇을 우선했는지를 드러낸다. 한 언어에 익숙해진 뒤 다른 언어를 만나면 그 언어가 같은 문제를 다른 우선순위 위에서 풀고 있다는 점을 보면 된다. 그 우선순위의 지도가 시리즈가 그리려 한 그림이다.

참고