Tech Blog

직접 구현하고 검토를 거친 기술적 선택과 설계를 정리해 남깁니다

프로그래밍 언어별 메커니즘 탐구 (2)

  1. 1. static 키워드의 메커니즘 해부 - Java / Kotlin 편

    static 키워드의 메커니즘 해부 - Java / Kotlin 편

    시리즈 집필의 계기 AI를 활용한 코딩이 일상화되면서, 더 이상 특정 언어의 API나 문법에 대한 ‘암기’가 개발자의 핵심 역량이 되진 않는 시대가 되었습니다. 이제는 “어떤 자료구조를 쓰겠다”, “이런 방식으로 구성하겠다”는 정도의 아이디어만 정리하면, AI가 문법과 구현은 대부분 완성해줍니다. 더불어, 언어들 자체도 점차 닮아가고 있습니다. 각 언어가 가진 고유한 문법 차이보다는, 객체지향 프로그래밍(OOP)이라는 공통 패러다임 아래 구조적 유사성이 강해졌습니다. 특히 실무에서 주력으로 사용되는 TypeScript, Java, Kotlin, Python 네 언어는 모던한 문법과 도구 지원을 바탕으로, 빠르게 수렴 중입니다. 이런 맥락에서 이제 중요한 것은 개별 언어의 경험적 숙련이 아니라, 언어를 관통하는 핵심 메커니즘에 대한 깊은 이해입니다. 이 시리즈는 각 언어에서 동일한 개념이 어떻게 구현되고, 어떤 철학을 기반으로 설계되었는지를 분석해보려 합니다. static이란? static은 객체지향 언어를 배울 때 가장 먼저 마주치는 키워드 중 하나입니다. 클래스 수준에서 정의되지만, 클래스의 인스턴스와는 별개로 동작하며, 상태를 공유하거나 유틸리티 메서드를 구현할 때 자주 사용됩니다. 하지만 이 단순해 보이는 키워드는 언어마다 해석이 다르고, 내부적으로 작동하는 방식도 제각각입니다. 어떤 언어는 이를 명시적 키워드로 선언하고, 어떤 언어는 싱글톤 객체나 데코레이터로 대체합니다. 또 어떤 언어는 정적 바인딩을 통해 컴파일 타임에 결정되며, 어떤 언어는 런타임 싱글톤 객체로 처리합니다. 이 글에서는 Java, Kotlin, Python, TypeScript 네 언어를 기준으로 static 키워드가 어떤 방식으로 표현되고 구현되는지, 그리고 그 배경에 깔린 언어 설계 철학은 무엇인지를 깊이 있게 분석해보겠습니다. Java의 static 키워드 Java에서 static 키워드는 클래스 로딩 시점에 JVM의 메서드 영역(Method Area)에 정적으로 할당되는 클래스 수준 멤버를 선언할 때 사용됩니다. 하지만 표면적인 “클래스 멤버”라는 정의만으로 static의 실제 동작을 설명하긴 부족합니다. 실제로는 상속, shadowing(은닉), 정적 초기화, 동적 로딩, 접근 제한 등 다양한 메커니즘이 얽혀 있습니다. 메모리 구조와 static static으로 선언된 필드는 JVM의 Runtime Data Area 중 Method Area에 저장됩니다. 이는 모든 인스턴스가 동일한 메모리 공간을 공유함을 의미합니다. 클래스가 로딩될 때 단 한 번 할당됨 인스턴스가 아닌 클래스 단위로 존재함 공용 상태를 가지므로 멀티스레드 환경에서 동기화 주의 필요 static 초기화 블록과 순서 static 필드 및 static 블록은 클래스 로딩 시점에, 선언 순서대로 실행됩니다. 만약 static 블록 내에서 예외가 발생하면, 해당 클래스는 로딩 자체가 실패하여 NoClassDefFoundError가 발생할 수 있습니다. 관련해서 여러 예시를 알아보겠습니다. x가 초기화되기 위해 printX() 호출 이 시점에 y는 아직 선언되지 않았으므로 0 (원시타입 기본값) JVM의 static 초기화는 선언 순서대로 진행되며, 다른 static 멤버를 참조해도 그 시점에 초기화되지 않았을 수 있음 클래스 로딩시점에, 선언순서대로 실행된다는 말의 의미를 보여주는 예시코드 입니다. System.out.println("Inner static block"); 이 초기화 코드 블럭에 있는 코드가 Inner.class라인에선 실행되지 않다가 정적필드에 접근했을 때 비로소 실행되는 모습을 볼 수 있습니다. static 필드가 첫 접근을 받을 때 (단, final이 아닌 경우) static 메서드 호출 시 Class.forName("...") 사용 시 new 연산자로 인스턴스를 생성할 때 단순히 Inner.class로 로딩하는 것은 초기화 요건에 해당되지 않으니 주의가 필요합니다. static의 상속과 shadowing(은닉) Java에서 static 멤버는 인스턴스가 아닌 클래스에 종속됩니다. 하지만 상속 관계에서 static 필드/메서드가 어떻게 동작하는지는 오해의 소지가 많습니다. static 필드/메서드는 ‘참조 변수의 타입’에 따라 바인딩됩니다. 실제 인스턴스 타입이 아닌 정적 타입(컴파일 타임에 결정) 기준입니다. 이를 static shadowing이라고 부릅니다. 오버라이딩(override)이 아니라, 은닉(hide)일 뿐이며, 인스턴스 메서드와 다르게 동적 바인딩이 전혀 일어나지 않습니다. 정적 import와 네임스페이스 오염 Java 5부터는 import static 구문으로 static 멤버를 직접 import할 수 있습니다. 하지만 static import를 남용하면 이름 충돌(Name Collision)의 위험이 커집니다. 가급적 사용하지 않는걸 권장 드립니다. static 내부 클래스(정적 중첩 클래스)와 외부 참조 Java에서 클래스 안에 선언된 내부 클래스(nested class) 중 static 키워드가 붙은 클래스는 정적 중첩 클래스라고 부릅니다. 일반 내부 클래스는 암묵적으로 외부 클래스의 인스턴스를 참조하고 있습니다. 컴파일 시 외부 클래스의 인스턴스 참조(outerRef)를 자동으로 생성자에 포함시킵니다. 이 경우 GC가 외부 객체를 수거하지 못하는 메모리 누수 위험이 생길 수 있습니다.  반면 static 내부 클래스는 외부 클래스와 완전히 분리된 독립 클래스이므로, 외부 클래스의 인스턴스를 암묵적으로 참조하지 않습니다. 즉, GC의 수거 대상에서 자유롭고, 메모리 해제가 명확하게 관리됩니다. 주로 유틸리티성 도우미 클래스가 외부 클래스의 상태와 무관한 경우  ResponseDto.Success response = ResponseUtil.success(data) Enum, Builder 패턴, DSL 구조 등에서 캡슐화된 논리적 구성 단위로 활용 User user = new User.Builder().name("Astor").build(); VO, DTO 등을 하나의 클래스로 응집하고 싶을 때 AuthInfo.Simple initialize(AuthCommand.Initialize request) 같은 케이스에서 활용할 수 있습니다. Kotlin의 static: Companion Object와 객체지향적 대체 Kotlin은 static 키워드를 제거한 대표적인 JVM 언어입니다. 그렇다면 Kotlin에서 ‘클래스 단위의 멤버’, 즉 Java의 static은 어떻게 구현될까요? Kotlin이 택한 방식은 단순한 문법적 대체가 아니라, JVM 메커니즘 위에 객체지향 설계 철학을 얹은 형태입니다. Companion Object의 메커니즘 Kotlin에서 companion object는 클래스 당 하나만 존재하는 싱글톤 객체입니다. Java의 static처럼 클래스명으로 접근할 수 있지만, 실제로는 클래스 로딩 시 생성된 진짜 객체입니다. Kotlin 컴파일러는 클래스명.필드 또는 클래스명.메서드 형식의 호출을 내부적으로 클래스명.Companion.필드 형태로 변환합니다. 예를 들어 다음 Kotlin 코드를 보겠습니다: Kotlin에서는 Counter.count와 Counter.increment()로 접근하지만, 이 코드는 컴파일 후 JVM에서는 다음과 같이 변환됩니다: 즉 Counter.count는 내부적으로 Counter.Companion.getCount() 호출로 바뀌며, 이는 클래스에 소속된 정적 필드가 아니라 Companion 객체의 멤버에 접근하는 것입니다. 코틀린 파일을 아래처럼 자바에서 직접 호출해보시면 쉽게 이해할 수 있습니다. Java Interop, @JvmStatic, 그리고 진짜 static Kotlin은 static 키워드를 제거한 대신, companion object라는 객체 기반 구조를 통해 Java의 정적 멤버 역할을 대체합니다. 이 방식은 Kotlin 내부에선 매우 일관된 객체지향 구조를 유지해주지만, Java와의 상호 운용성에서는 다소 불편함을 유발할 수 있습니다. 이를 보완하기 위해 Kotlin은 @JvmStatic 어노테이션을 제공합니다. 이 어노테이션은 해당 메서드 또는 프로퍼티를 진짜 static으로 만들어줍니다. 다음과 같이 선언하면: Java에서는 이렇게 바로 사용할 수 있게 됩니다: Companion 역시 여전히 사용가능한걸 볼 수 있는데, @JvmStatic을 붙이면 Counter.Companion.reset()과 Counter.reset() 둘 다 호출 가능해지며, Java 코드 입장에서는 훨씬 깔끔한 API가 됩니다. 상속: 객체이므로 상속과 구현 가능 Kotlin의 companion object는 실제 객체이기 때문에, 클래스처럼 상속하거나 인터페이스를 구현할 수 있습니다. 이는 Java의 static 멤버와는 근본적으로 다른 특징입니다. 여기서 companion object는 Factory 인터페이스를 구현하며, 외부에서 다음과 같이 사용할 수 있습니다: 이처럼 Product 클래스 자체가 Factory로 사용될 수 있는 이유는 companion object가 인터페이스를 구현한 싱글톤 객체이기 때문입니다. 이 구조는 팩토리, 전략, 서비스 로케이터 등 다양한 객체지향 패턴에 응용될 수 있습니다. 다형성: 정적 멤버처럼 보이지만 동적 디스패치 불가 companion object는 객체이긴 하지만, 클래스 간 상속 관계 내에서 다형성(polymorphism)은 지원하지 않습니다. Kotlin은 companion object를 자동으로 상속하지 않기 때문입니다. 이 구조에서 각각 hello를 호출해보면 위와 같은 결과를 알 수 있습니다. 서로 상속 관계지만, 클래스의 companion object는 완전히 독립된 객체입니다. 따라서 Derived의 companion은 Base의 companion을 상속하거나 오버라이딩하지 않으며, 다형적 호출도 불가능합니다. Shadowing: 컴파일 타임 기준의 정적 참조 Kotlin의 companion object 내부 멤버는 정적 바인딩(static binding) 됩니다. 즉, 어떤 클래스명을 통해 호출하느냐에 따라 어떤 메서드가 호출될지가 컴파일 타임에 결정됩니다. 이 때문에 Shadowing(은닉)이 발생합니다. 위의 예시를 통해 코틀린에서 생길 수 있는 companion object의 은닉 케이스들을 모두 확인하실 수 있습니다. object 키워드 Kotlin은 static 키워드를 제거하면서도, 정적 멤버 또는 전역 객체처럼 사용 가능한 구조를 제공하기 위해 object 키워드를 도입했습니다. object는 "단 하나만 존재하는 객체(Singleton)"를 선언하는 구문입니다. 클래스의 인스턴스를 생성하지 않고도, 상태와 동작을 담은 객체를 바로 정의하고 사용할 수 있습니다. 이 객체는 다음과 같이 바로 사용할 수 있습니다: Java에서 static 메서드로 구성되던 유틸리티 클래스를 Kotlin에서는 object로 선언 등 활용할 수 있습니다.  Top-level 함수의 정적화 코틀린은 파일 수준(최상위)의 함수 및 변수를 통해 정적 멤버처럼 사용할 수 있는 또 다른 구조를 제공합니다. 이것이 바로 Top-level 함수와 프로퍼티입니다. 클래스나 객체 내부가 아닌, 파일 자체의 루트 레벨에 선언된 함수를 Kotlin에서는 Top-level 함수라고 부릅니다. 이 함수는 Kotlin에서는 별다른 클래스나 객체 없이 이렇게 호출됩니다: Kotlin 내부에서는 매우 자연스럽고 간결한 표현 방식입니다. 하지만 이 함수는 실제로 JVM에선 어떻게 표현될까요? JVM에서의 정적 함수 변환 방식 Top-level 함수는 Kotlin 컴파일러에 의해 JVM의 정적(static) 메서드로 변환됩니다. 위의 MathUtils.kt 파일을 Java로 디컴파일하면 다음과 같은 클래스가 자동 생성됩니다: 즉, Kotlin의 Top-level 함수는 실제로는 정적 메서드이며, 클래스명은 \[파일명 + Kt] 형식으로 자동 생성됩니다. Top-level 함수는 Kotlin의 파일 지향 문법 구조 덕분에 가능한 방식입니다. Kotlin은 파일 단위로 컴파일 단위를 만들고, 파일 내 정의된 함수들을 자동으로 정적 메서드로 컴파일합니다.

    2025년 07월 13일
  2. 2. static 키워드의 메커니즘 해부 - TypeScript / Python 편

    static 키워드의 메커니즘 해부 - TypeScript / Python 편

    시작하며 지난 글에서는 Java와 Kotlin에서의 static 키워드를 깊이 있게 분석해봤습니다. JVM 기반 언어들이 공유하는 메모리 구조와 클래스 로딩이라는 메커니즘 위에서, 각 언어의 설계 철학이 어떻게 다르게 표현되는지 확인할 수 있었습니다. 이번 글에서는 그 관점을 넓혀, TypeScript와 Python이라는 두 언어에서의 정적 멤버 처리 방식을 살펴보겠습니다. TypeScript와 Python은 JVM과 완전히 다른 런타임 환경과 언어 설계를 갖추고 있습니다. JavaScript를 기반으로 하는 TypeScript는 클래스라는 개념을 비교적 최근에야 명확히 도입했고, 여전히 객체지향과 함수형, 그리고 프로토타입 기반의 유연한 구조가 공존합니다. Python 역시 클래스가 '실행 가능한 객체'라는 점에서 JVM 언어들과는 근본적으로 다른 접근을 취합니다. 이로 인해 두 언어 모두 Java나 Kotlin과 같은 정적인 클래스 구조를 그대로 적용하지 않고, 나름의 철학을 담아 독특한 방식으로 정적 멤버를 처리합니다. 이번 글에서도 역시 단순히 문법을 나열하는 것에서 그치지 않고, TypeScript와 Python의 내부 메커니즘을 들여다보며 각 언어가 가진 설계 철학을 깊이 있게 분석하고자 합니다. 이 과정을 통해 독자 여러분이 서로 다른 언어 간의 유사성과 차이점을 명확히 이해하고, 실제 프로젝트에서 보다 명확한 기준을 가지고 설계할 수 있게 되기를 바랍니다. TypeScript의 static 키워드와 메커니즘 TypeScript는 JavaScript에 정적 타입과 클래스를 도입한 언어입니다. ES6(ECMAScript 2015)부터 클래스 문법을 공식적으로 지원하게 되면서, JavaScript와 그 슈퍼셋인 TypeScript에도 비로소 클래스 기반의 객체지향 프로그래밍이 일반화되었습니다. 하지만 TypeScript가 제공하는 클래스는 Java나 Kotlin의 클래스와는 상당히 다릅니다. JVM의 클래스 로딩이나 메모리 구조와 같은 개념이 전혀 없기 때문이죠. 그렇다면, TypeScript의 static 키워드는 어떻게 동작할까요? 먼저, 아주 기본적인 예제를 통해 TypeScript에서의 static 키워드를 이해해 보겠습니다. 기본 문법과 메커니즘 다음과 같은 TypeScript 클래스가 있다고 가정해 봅시다. 여기서 사용된 static 멤버는 클래스의 인스턴스가 아니라, 클래스 자체에 소속됩니다. 인스턴스 생성 없이 MathUtils.PI와 같은 형식으로 접근이 가능하죠. 그런데 중요한 점은 이 코드가 실제로 어떻게 동작하느냐입니다. TypeScript는 컴파일 시 JavaScript 코드로 변환됩니다. 위의 TypeScript 코드는 컴파일 후 다음과 같은 JavaScript 코드로 변환됩니다. TypeScript는 클래스 내부에 선언된 static 필드와 메서드를 클래스의 프로토타입이 아닌 클래스 생성자 함수 자체의 프로퍼티로 추가합니다. 즉, JavaScript에서는 클래스도 결국 함수 객체이기 때문에, 클래스 자체가 하나의 객체이며 그 객체의 프로퍼티로서 static 멤버가 관리되는 것입니다. JavaScript와 프로토타입 체인의 한계 Java나 Kotlin에서는 클래스의 메서드나 필드가 클래스 로딩 단계에서 정적인 메모리 영역에 할당됩니다. 하지만 JavaScript(그리고 TypeScript)는 그런 고정된 메모리 영역 개념이 없습니다. 대신 프로토타입 체인(prototype chain)과 함수 객체를 통해 메서드와 속성 접근이 이루어집니니다. TypeScript에서 클래스의 인스턴스 메서드는 프로토타입 객체(Class.prototype)에 추가되어 인스턴스 간에 공유됩니다. 반면 static 멤버는 클래스 함수 자체에 직접 추가됩니다. 프로토타입 체인에서 상속되는 게 아니라 클래스 함수 객체의 프로퍼티로 존재하므로, JavaScript와 TypeScript에서 static 멤버는 상속을 지원하지 않고, 정확히는 부모 클래스의 정적 멤버를 자식 클래스에서 '복사'하거나 직접 다시 정의해줘야 합니다. 다음 예시를 보겠습니다: 이 코드가 작동하는 이유는 JavaScript 클래스 문법이 내부적으로 정적 멤버를 프로토타입이 아닌 클래스 객체 자체에 할당하기 때문입니다. 즉, 자식 클래스의 생성자 함수가 부모 클래스의 생성자 함수를 상속하는 형태이기 때문에, 자식 클래스는 정적 멤버를 부모로부터 복사하지 않고 상위 클래스의 메서드를 그대로 참조합니다. 하지만 만약 자식 클래스에서 동일한 이름의 정적 메서드를 재정의하면 어떻게 될까요? 이때 Child는 Parent의 메서드를 덮어쓰는 형태(shadowing)로 자신의 메서드를 정의합니다. Java에서 봤던 static의 shadowing 개념과 매우 유사하죠. 하지만 JVM처럼 정적 바인딩이 명시적으로 일어나기보다는, 프로토타입 기반의 JavaScript 언어 특성 때문에 발생하는 현상입니다. Generic과 Static의 복잡한 관계 마지막으로, TypeScript에서 제네릭 클래스와 static 멤버의 관계를 보겠습니다. 여기서 흥미로운 점은 제네릭 타입 파라미터 \<T>가 있더라도, static 멤버는 모든 제네릭 인스턴스가 공유하는 단 하나의 값을 가진다는 점입니다. Java나 Kotlin의 제네릭과 달리, JavaScript 런타임에서는 제네릭 타입 파라미터가 지워지므로 static 멤버 역시 제네릭 인자와 전혀 상관없이 동작합니다. 이는 언어의 런타임 설계가 JVM과 완전히 다른 점을 명확히 보여줍니다. Python의 static: 런타임 객체로서의 클래스 지금까지 Java와 Kotlin에서 JVM의 메모리 구조와 클래스 로딩을 중심으로 한 static의 메커니즘, 그리고 TypeScript가 JavaScript의 함수 객체와 프로토타입 기반 구조 위에서 어떻게 정적 멤버를 다루는지에 대해 깊게 알아봤습니다. 이제 Python이라는 완전히 다른 언어 환경 속에서, 클래스와 정적 멤버가 어떤 방식으로 작동하는지 면밀히 살펴볼 차례입니다. Python은 기본적으로 객체지향 언어입니다. 하지만 Python의 클래스는 Java나 Kotlin의 그것과는 근본적으로 다릅니다. 클래스 자체가 하나의 런타임 객체로서 존재하며, 클래스의 정의가 코드 실행 시점에 동적으로 처리됩니다. 그렇다면, Python에서 정적인 멤버는 어떻게 표현되고, 내부적으로는 어떤 메커니즘이 사용될까요? 하나씩 살펴봅시다. 클래스 변수와 객체의 경계 Python에서는 클래스 수준에서 직접 변수를 선언할 수 있습니다. 이를 클래스 변수(Class variable) 라고 부르며, Java나 TypeScript의 static 필드와 개념적으로 매우 유사합니다. 아래의 예시를 통해 클래스 변수의 기본 동작을 확인해 보겠습니다. 이 코드에서 클래스 변수인 PI는 인스턴스 생성과 관계없이 MathUtils.PI 형태로 접근할 수 있습니다. 하지만 Python에서 클래스 변수는 어떻게 관리될까요? Java나 Kotlin처럼 클래스 로딩 시점에 정적 메모리 영역에 할당되는 개념이 존재하지 않기 때문에, Python 클래스 변수는 실제로 클래스라는 객체의 속성(attribute)으로 관리됩니다. Python에서는 클래스 자체가 런타임 객체입니다. 아래 코드를 보면 명확히 이해할 수 있습니다. 위의 코드 실행 결과는 다음과 같습니다: 여기서 볼 수 있듯이, PI는 클래스 객체의 \_\_dict\_\_ 속성에 저장된 하나의 키-값 쌍(key-value pair)입니다. 클래스 변수는 런타임에 이 딕셔너리 형태로 저장되기 때문에, 클래스 정의 후에도 동적으로 추가하거나 변경할 수 있습니다. @staticmethod의 내부 메커니즘 Python에서 클래스 내에서 인스턴스 상태에 접근할 필요가 없는 메서드를 만들 때, @staticmethod 데코레이터를 사용합니다. Java나 TypeScript의 static 메서드와 유사한 기능입니다. 하지만 Python의 @staticmethod는 단순한 문법적 장식이 아닙니다. 내부적으로 데코레이터라는 독특한 메커니즘을 사용하여 메서드를 클래스의 속성으로 등록합니다. 다음과 같은 코드가 있다고 해봅시다. 여기서 @staticmethod는 메서드를 클래스의 일반 함수 형태로 등록하고, 호출 시 자동으로 클래스나 인스턴스 참조(self, cls)를 넘겨주지 않습니다. 이것이 인스턴스나 클래스의 상태와 전혀 무관한 메서드를 명확히 표현하는 Python 특유의 방식입니다. 내부적으로 @staticmethod 데코레이터는 다음과 같은 작업을 수행합니다: 즉, 클래스 정의 시점에 staticmethod()라는 내장 함수가 메서드를 감싸서, 그 반환값을 클래스 객체의 속성으로 등록하는 방식으로 동작합니다. @classmethod와 cls의 정체 Python에는 @staticmethod 외에도 유사한 또 다른 데코레이터가 존재합니다. 바로 @classmethod입니다. @classmethod는 메서드의 첫 번째 인수로 자동으로 클래스 자신(cls)을 넘겨줍니다. 일반적으로 Factory 메서드 패턴이나 클래스 자체의 상태를 변경하는 용도로 쓰입니다. 다음 예시를 보겠습니다: 위 코드에서 @classmethod가 하는 일은 다음과 같습니다: 즉, @classmethod 역시 내부적으로 메서드를 감싸 클래스의 속성으로 추가하지만, 호출 시 클래스 참조를 자동으로 넘겨주는 점이 @staticmethod와 다릅니다. 인스턴스 변수와 클래스 변수의 충돌 Python에서 클래스 변수는 때로 인스턴스 변수와 혼동될 수 있습니다. 다음 예시를 보겠습니다: 이 코드에서, config1.DEBUG에 값을 할당하면 인스턴스 변수로 별도의 값이 생성됩니다. 클래스 변수는 그대로 유지되며, 다른 인스턴스(config2)는 여전히 클래스 변수를 참조합니다. 인스턴스가 클래스 변수의 값을 변경하지 않고, 새로운 인스턴스 변수를 만들어 클래스 변수를 가려버리는(shadowing) 형태가 되는 것입니다. 상속과 다형성의 한계 Python의 클래스 변수와 정적 메서드는 상속됩니다. 그러나 오버라이딩(overriding)의 동작은 조금 다릅니다. Python은 동적 타입 언어이며 클래스가 런타임 객체이기 때문에 Java나 Kotlin과 같은 정적 타입 언어의 정적 바인딩 개념은 적용되지 않습니다. Python은 모든 것이 동적으로 바인딩되기 때문에, 정적 멤버와 메서드도 Java와 달리 런타임에 다형성을 지원합니다. 이는 Python의 런타임 객체로서의 클래스 특성을 명확히 보여주는 사례입니다.

    2025년 07월 21일