프로그래밍 언어
프로그래밍 언어(Programming Language)는 사람이 컴퓨터에게 수행할 작업을 기술하고, 이를 컴퓨터가 이해하고 실행할 수 있는 형태로 표현하기 위해 만들어진 형식 언어이다. 프로그래밍 언어는 알고리즘, 데이터 구조, 제어 흐름, 추상화, 병렬 처리 등 다양한 계산 모델을 표...
프로그래밍 언어(Programming Language)는 사람이 컴퓨터에게 수행할 작업을 기술하고, 이를 컴퓨터가 이해하고 실행할 수 있는 형태로 표현하기 위해 만들어진 형식 언어이다. 프로그래밍 언어는 알고리즘, 데이터 구조, 제어 흐름, 추상화, 병렬 처리 등 다양한 계산 모델을 표현할 수 있도록 설계되며, 소프트웨어 개발의 기반을 이루는 핵심 기술이다. 현대의 프로그래밍 언어는 시스템 소프트웨어, 웹 애플리케이션, 모바일 애플리케이션, 인공지능, 임베디드 시스템, 게임 개발, 과학 계산 등 거의 모든 컴퓨팅 분야에서 활용되고 있으며, 각 언어는 설계 목적과 철학에 따라 문법, 실행 방식, 메모리 관리, 타입 시스템, 성능, 생산성 등의 특성이 서로 다르다. 프로그래밍 언어는 일반적으로 컴파일러나 인터프리터를 통해 기계어 또는 중간 표현으로 변환되어 실행되며, 컴퓨터 과학에서는 언어 설계, 구문(Syntax), 의미론(Semantics), 타입 시스템(Type System), 컴파일러, 런타임(Runtime), 표준 라이브러리 등 다양한 관점에서 연구되고 발전해 왔다.
역사
프로그래밍 언어의 역사는 전자식 컴퓨터가 등장한 뒤에 갑자기 시작된 것은 아니다. 그 기원은 사람이 계산 절차를 일정한 기호와 규칙으로 표현하고, 그 절차를 기계가 자동으로 수행하게 하려는 시도에서 찾을 수 있다. 오늘날의 프로그래밍 언어는 기계에 명령을 전달하는 표기법이 되었으며 알고리즘, 데이터, 추상화, 상태 변화, 논리적 관계와 계산 모델을 표현하는 형식 체계로 발전하였다.
기계식 계산과 프로그램 개념의 형성
19세기 초 조제프 마리 자카르가 개발한 자카르 직기는 천공 카드에 뚫린 구멍의 배열로 직조 무늬를 제어하였다. 자카르 직기의 카드는 수치 계산을 위한 명령어를 담은 것은 아니었지만, 기계의 동작을 외부 매체에 기록된 패턴에 따라 변경할 수 있다는 점에서 이후 프로그램 제어 방식에 영향을 준 중요한 선례로 평가된다.
찰스 배비지는 1830년대부터 범용 기계식 계산 장치인 해석기관을 설계하였다. 해석기관은 연산을 수행하는 장치인 밀(Mill), 수치를 보관하는 저장소인 스토어(Store), 입력과 출력을 위한 장치, 천공 카드로 전달되는 연산 및 데이터 지시를 포함하도록 구상되었다. 실제 기계는 완성되지 않았지만, 연산 장치와 기억 장치를 구분하고 외부 명령에 따라 여러 계산을 수행한다는 점에서 현대 범용 컴퓨터와 유사한 구조를 가지고 있었다.
1843년 에이다 러브레이스는 루이지 메나브레아가 작성한 해석기관 설명문을 영어로 번역하면서 원문보다 긴 주석을 추가하였다. 그중 주석 G에는 해석기관으로 베르누이 수를 계산하기 위한 연산 순서와 변수의 이동, 중간 결과, 반복 과정을 표로 정리한 내용이 포함되었다. 이 표는 일반적으로 기계를 대상으로 출판된 최초의 상세한 프로그램으로 소개된다. 다만 배비지 역시 1837년경 해석기관을 위한 계산 절차를 작성한 기록이 있으므로, 러브레이스를 아무런 조건 없이 역사상 최초의 프로그래머라고 단정하기보다는 최초로 널리 출판된 기계용 알고리즘을 체계적으로 설명한 인물로 보는 것이 더 정확하다.
러브레이스는 해석기관이 수치 계산을 넘어 일정한 규칙으로 표현할 수 있는 대상을 처리할 수 있다고 보았다. 이는 프로그램을 특정 계산 결과가 아니라 기호를 조작하는 일반적인 절차로 이해한 초기 관점이었다. 그러나 해석기관 자체가 완성되지 않았기 때문에 당시에는 해당 프로그램을 실제 기계에서 실행하거나 반복적으로 사용하는 프로그래밍 체계를 형성할 수 없었다.
계산 가능성의 수학적 정립
1930년대에는 실제 컴퓨터가 보급되기 전에 계산과 알고리즘을 수학적으로 정의하려는 연구가 이루어졌다. 알론조 처치는 함수를 추상화하고 적용하는 계산 체계인 람다 대수를 발전시켰으며, 앨런 튜링은 1936년 논문에서 기호가 기록된 테이프와 유한한 상태 전이를 이용하는 튜링 기계를 제시하였다.
튜링은 하나의 특정 계산만 수행하는 기계뿐 아니라 다른 튜링 기계의 설명을 입력받아 그 동작을 모사할 수 있는 보편 튜링 기계를 정의하였다. 이는 명령과 데이터를 같은 기억 장치에 저장하고, 저장된 프로그램을 교체하여 다양한 작업을 수행하는 범용 컴퓨터의 이론적 토대가 되었다. 람다 대수와 튜링 기계는 표현 방식은 달랐지만 계산 가능한 함수의 범위에서 서로 동등한 것으로 밝혀졌다. 이러한 계산 모델은 이후 프로그래밍 언어의 함수 호출, 재귀, 평가 규칙, 타입 이론과 형식 의미론에 장기적인 영향을 주었다.
초기 전자식 컴퓨터와 기계어
1940년대의 초기 전자식 컴퓨터는 오늘날처럼 소스 코드를 작성하여 실행하는 형태와 거리가 멀었다. 일부 컴퓨터는 케이블 연결, 스위치와 배선의 변경으로 계산 절차를 구성하였으며, 프로그램을 바꾸려면 기계의 물리적 구성을 다시 설정해야 했다. 이후 명령을 수치 형태로 기억 장치나 천공 테이프에 기록하는 저장 프로그램 방식이 확산되면서 프로그램과 하드웨어가 점차 분리되었다.
초기의 프로그램은 중앙처리장치가 직접 해석하는 숫자 명령인 기계어로 작성되었다. 프로그래머는 연산 코드와 기억 장치 주소, 분기 위치를 수치로 직접 기록해야 했으며, 컴퓨터의 기종이 달라지면 명령 체계와 프로그램도 대부분 다시 작성해야 했다. 작은 주소 계산이나 명령 하나의 오기입도 프로그램 전체의 오작동으로 이어질 수 있었고, 프로그램의 구조를 읽고 유지하는 일도 매우 어려웠다.
이 문제를 줄이기 위해 기계 명령에 ADD, LOAD, JMP와 같은 기호 이름을 부여하는 어셈블리어가 등장하였다. 어셈블러는 이러한 기호 명령을 기계어로 변환하고 기억 장치 주소를 계산하였다. 어셈블리어는 숫자로 된 기계어보다 읽기 쉬웠지만 특정 프로세서의 명령어 집합에 강하게 종속되었으며, 프로그램의 논리를 여전히 기계의 레지스터와 기억 장치 구조에 맞추어 작성해야 했다.
최초의 프로그래밍 언어
최초의 프로그래밍 언어가 무엇인지는 프로그래밍 언어를 어떤 기준으로 정의하는지에 따라 달라진다.
- 특정 기계를 위해 공개된 최초의 상세한 알고리즘으로는 에이다 러브레이스의 해석기관용 계산 절차가 거론되며,
- 최초로 설계된 고급 프로그래밍 언어로는 Plankalkül이 거론된다.
- 실제 전자식 컴퓨터에서 사용된 초기 고급 표기 체계로는 Short Code가 거론되며,
- 초기 자동 프로그래밍 및 컴파일 체계로는 A-0 System이 거론된다.
- 최초로 널리 사용된 실용적인 고급 컴파일 언어로는 FORTRAN이 거론된다.
콘라트 추제는 1942년부터 1945년 사이에 Plankalkül을 설계하였다. Plankalkül은 수치뿐 아니라 배열, 논리값, 레코드와 유사한 복합 자료를 표현하고 조건문과 반복 구조를 기술할 수 있도록 설계되었다. 특정 기계 명령을 그대로 나열하는 대신 문제 해결 절차를 더 높은 수준에서 표현하려 했다는 점에서 최초의 고급 프로그래밍 언어 설계로 평가된다. 그러나 전쟁과 전후 환경으로 인해 당시 추제의 컴퓨터에 구현되지 않았으며, 관련 내용도 뒤늦게 알려졌다.
Short Code는 1949년경 제안되고 1950년대 초에 사용된 초기 고급 언어 가운데 하나였다. 기계 명령 대신 수학식을 기호 형태로 표현할 수 있었지만, 일반적인 의미의 컴파일러로 기계어 전체를 생성하기보다는 인터프리터가 명령을 해석하여 실행하였다. 작성은 기계어보다 간단했으나 실행 속도가 느렸고 표현 방식도 제한적이었다.
그레이스 호퍼가 1951년부터 1952년 사이에 개발한 A-0 System은 미리 작성된 서브루틴을 기호로 지정하면 필요한 기계어 루틴을 찾아 프로그램으로 결합하는 자동 프로그래밍 체계였다. 당시에는 이를 컴파일러라고 불렀으며 최초의 컴파일러로 널리 소개된다. 다만 현대적인 분류에서는 고급 언어 전체를 번역하는 컴파일러라기보다 서브루틴을 배치하고 연결하는 링커 또는 로더에 가까운 성격도 가지고 있었다. 그럼에도 프로그램 작성 과정의 일부를 기계가 자동화할 수 있음을 보였다는 점에서 이후 컴파일러 발전의 중요한 단계가 되었다.
1950년대: 고급 언어와 컴파일러의 실용화
1950년대에는 프로그래머가 기계의 명령 체계보다 문제의 수학적·업무적 구조에 가까운 표현으로 프로그램을 작성하고, 번역기가 이를 기계어로 변환하는 고급 프로그래밍 언어가 실용화되기 시작하였다. 이 시기에는 과학 계산, 기호 처리와 업무 데이터 처리라는 서로 다른 요구에 따라 주요 언어 계열이 형성되었다.
FORTRAN
1954년 존 배커스가 이끄는 IBM 연구팀은 IBM 704를 위한 수식 번역 시스템인 FORTRAN(Formula Translation)의 개발을 시작하였다. 당시 고급 언어가 널리 받아들여지려면 사람이 직접 작성한 기계어에 근접하는 실행 성능을 내야 했다. 이에 따라 FORTRAN 개발에서는 언어 문법만큼이나 최적화 컴파일러의 설계가 중요하게 다루어졌다.
1957년 상용 배포된 FORTRAN은 수학식과 변수, 반복문, 조건 분기를 사용하여 과학 및 공학 계산을 기술할 수 있게 하였다. 프로그래머가 레지스터 배치와 세부 기계 명령을 직접 관리하지 않아도 되었으며, 컴파일러는 작성된 수식을 분석하여 효율적인 기계어를 생성하였다. FORTRAN은 최초의 고급 언어 자체는 아니지만 실제 산업과 연구 현장에서 널리 채택된 최초의 고급 컴파일 언어로 평가된다. FORTRAN의 성공은 고급 언어와 컴파일러가 성능을 크게 희생하지 않고도 실용적일 수 있음을 입증하였다.
이후 언어의 공식 명칭은 Fortran으로 표기되기 시작하였다. Fortran은 배열 연산, 구조적 제어문, 모듈, 사용자 정의 자료형, 객체 지향 기능과 병렬 처리 기능을 받아들이며 계속 개정되었다. 초기 언어 가운데 하나임에도 현대 고성능 계산, 수치 해석, 기상 및 물리 시뮬레이션 분야에서 계속 사용되고 있다.
FLOW-MATIC과 COBOL
과학 계산과 달리 기업의 급여, 회계, 재고 및 행정 업무에서는 복잡한 수식보다 대량의 기록을 읽고 분류하며 보고서를 생성하는 기능이 중요했다. 그레이스 호퍼는 업무 담당자가 수학 기호 대신 영어 단어에 가까운 명령을 사용해야 한다고 주장하였다.
호퍼의 팀이 개발한 FLOW-MATIC은 INPUT, OUTPUT, COMPARE와 같은 영어식 단어로 데이터 처리 절차를 표현한 초기 언어였다. FLOW-MATIC은 수학 중심 언어와 구별되는 업무 데이터 처리 언어의 가능성을 보였으며, 서로 다른 제조사의 컴퓨터에서도 사용할 수 있는 공통 업무 언어를 만들려는 움직임에 영향을 주었다.
1959년 미국 정부와 여러 컴퓨터 제조사 및 사용자 단체는 CODASYL을 구성하여 COBOL을 개발하였다. 1960년 COBOL 60 사양이 발표되었으며, COBOL은 영어 문장과 유사한 긴 키워드와 명시적인 데이터 정의부, 레코드 중심의 파일 처리 구조를 채택하였다. 특정 제조사의 기계 명령에서 벗어난 업무용 언어를 지향했으며, 이후 금융, 보험, 정부와 대형 기업의 핵심 정보 시스템에서 광범위하게 사용되었다. COBOL은 프로그래밍 언어 사양과 컴파일러의 표준 적합성을 중요하게 다루는 계기가 되었다.
LISP
존 매카시는 1950년대 후반 인공지능과 기호 계산을 위한 언어인 LISP를 개발하였다. LISP는 수치 배열을 중심으로 하던 FORTRAN과 달리 기호와 리스트를 기본 자료로 다루었다. 함수 정의, 재귀, 조건식, 동적 자료 구조와 자동 메모리 회수 등의 개념을 중심에 두었으며, 프로그램 코드 자체를 리스트 형태의 데이터로 표현할 수 있었다.
1960년에 발표된 LISP 논문은 재귀 함수와 기호식을 컴퓨터에서 처리하는 방법을 체계적으로 설명하였다. LISP의 함수 중심 계산 방식은 람다 대수의 영향을 받았으며, 이후 함수형 프로그래밍 언어와 매크로 시스템, 자동 메모리 관리, 대화형 개발 환경 및 인공지능 연구에 장기적인 영향을 주었다.
ALGOL
FORTRAN이 IBM의 특정 환경에서 출발한 것과 달리, 유럽과 미국의 연구자들은 기계와 국가에 독립적인 알고리즘 표기 언어를 만들고자 하였다. 그 결과 1958년 ALGOL 58이 등장하고, 이를 개정한 ALGOL 60의 공식 보고서가 1960년에 발표되었다.
ALGOL 60은 begin과 end로 구분되는 블록 구조와 블록 안에서만 유효한 지역 변수, 재귀적 프로시저 및 체계적인 제어 구조를 제공하였다. 언어의 구문은 배커스-나우르 표기법 계열의 형식 문법으로 기술되었으며, 이는 이후 프로그래밍 언어 사양과 파서 설계의 표준적인 방식이 되었다. ALGOL 자체의 산업 사용은 FORTRAN이나 COBOL보다 제한적이었지만, Pascal, C (프로그래밍 언어), Simula를 비롯한 많은 언어의 문법과 구조에 영향을 주었다.
1960년대: 구조화, 교육과 객체 개념의 등장
1960년대에는 컴퓨터의 사용 범위가 연구 기관과 군사 시설을 넘어 기업과 대학으로 확대되었다. 프로그램의 규모가 커지면서 단순히 기계어를 대신하는 표기법을 만드는 것보다 프로그램을 이해하고 수정하며 여러 사람이 협력할 수 있도록 구조화하는 문제가 중요해졌다.
ALGOL 계열 언어가 도입한 블록 구조와 지역 변수는 프로그램의 일부를 독립적인 단위로 분리하는 기반이 되었다. 무분별한 분기 명령을 줄이고 순차 실행과 선택, 반복, 프로시저 호출을 중심으로 프로그램을 구성하려는 구조적 프로그래밍 운동도 언어 설계에 영향을 주었다. 이후 언어들은 문법적 표현뿐 아니라 프로그램의 구조를 제한하고 명확하게 만드는 기능을 중요하게 다루기 시작하였다.
BASIC과 대화형 프로그래밍
1963년 존 케메니와 토머스 커츠는 다트머스 대학교에서 전공자가 아닌 학생도 쉽게 배울 수 있는 언어와 시분할 컴퓨팅 환경을 개발하기 시작하였다. 그 결과 1964년 5월 1일 최초의 BASIC 프로그램이 다트머스 시분할 시스템에서 실행되었다.
BASIC은 간단한 영어식 명령과 행 번호, 대화형 실행 방식을 사용하였다. 사용자는 천공 카드를 제출하고 오랫동안 결과를 기다리는 대신 터미널에서 프로그램을 입력하고 즉시 결과를 확인할 수 있었다. 이후 1970년대와 1980년대의 개인용 컴퓨터에 여러 종류의 BASIC 인터프리터가 기본 제공되면서 BASIC은 대중이 처음 접하는 대표적인 프로그래밍 언어가 되었다.
Simula와 객체 지향 프로그래밍
노르웨이의 올레요한 달과 크리스텐 뉘고르는 복잡한 현실 시스템을 모의실험하기 위해 Simula를 개발하였다. 초기 Simula는 ALGOL을 기반으로 한 시뮬레이션 언어였으며, 이후 Simula 67에서 클래스와 객체, 상속, 가상 메서드 및 동적 디스패치에 해당하는 개념을 체계화하였다.
Simula의 객체는 데이터와 그 데이터를 처리하는 동작을 하나의 단위로 결합하였다. 객체는 현실의 개별 행위자나 시스템 구성 요소를 표현하고, 클래스는 공통 구조를 정의하는 데 사용되었다. 이러한 모델은 이후 Smalltalk, C++, Java, C#을 비롯한 객체 지향 언어의 핵심 기반이 되었다. Simula는 일반적으로 최초의 객체 지향 프로그래밍 언어로 평가된다.
1970년대: 주요 프로그래밍 패러다임의 확립
1970년대에는 오늘날까지 이어지는 여러 프로그래밍 패러다임이 독립적인 언어 형태로 확립되었다. 시스템 프로그래밍에서는 C가, 교육과 구조적 프로그래밍에서는 Pascal이, 객체 지향 환경에서는 Smalltalk가, 논리 프로그래밍에서는 Prolog가, 정적 함수형 프로그래밍에서는 ML이 등장하였다.
Pascal
니클라우스 비르트는 ALGOL 60의 구조를 바탕으로 1970년 Pascal을 발표하였다. Pascal은 구조적 프로그래밍의 원칙을 명확한 문법과 강한 자료형 검사로 표현하였으며, 배열과 레코드, 집합, 포인터 및 사용자 정의 자료형을 제공하였다.
Pascal은 프로그래밍 교육에 널리 사용되었지만 처음부터 교육 전용 언어로만 설계된 것은 아니었다. 비르트는 언어가 단순한 기본 원칙을 가지면서도 큰 프로그램을 효율적으로 작성할 수 있어야 한다고 보았다. Pascal은 이후 Modula-2, Oberon, Object Pascal과 여러 교육용·상용 개발 환경에 영향을 주었다.
C와 UNIX
데니스 리치는 벨 연구소에서 BCPL과 B (프로그래밍 언어)의 영향을 받아 1971년부터 1973년 사이에 C (프로그래밍 언어)를 개발하였다. C는 초기 UNIX 운영체제를 구현하기 위한 시스템 프로그래밍 언어로 설계되었다.
기존 운영체제는 주로 어셈블리어로 작성되었으나, UNIX의 상당 부분이 C로 다시 작성되면서 운영체제를 다른 컴퓨터 아키텍처로 이식하기 쉬워졌다. C는 포인터와 직접적인 메모리 접근, 비트 연산, 단순한 런타임 모델을 제공하여 하드웨어에 가까운 제어를 유지하면서도 함수와 자료형, 구조체를 이용한 고급 프로그래밍을 가능하게 하였다.
C는 언어 자체를 작게 유지하고 입출력과 문자열, 메모리 관리 등의 기능을 라이브러리로 분리하였다. 이러한 구조는 운영체제와 컴파일러, 데이터베이스, 임베디드 소프트웨어 및 여러 언어 런타임의 구현에 적합했다. 이후 C의 문법과 실행 모델은 C++, Objective-C, Java, C#, JavaScript, Go (프로그래밍 언어), Rust Wave (프로그래밍 언어) 등 여러 언어에 직접 또는 간접적으로 영향을 주었다.
Smalltalk
1972년 앨런 케이, 댄 잉걸스, 아델 골드버그 등이 참여한 Xerox PARC의 연구팀은 Smalltalk의 초기 버전을 개발하였다. Smalltalk는 언어 하나만을 제공한 것이 아니라 객체 지향 언어와 가상 머신, 소스 코드 브라우저, 디버거 및 그래픽 사용자 인터페이스가 결합된 통합 환경이었다.
Smalltalk에서는 숫자와 문자열, 클래스, 실행 환경을 포함한 거의 모든 요소를 객체로 다루며, 객체 간 메시지 전달로 계산을 표현하였다. 개발자는 실행 중인 시스템 안에서 객체와 코드를 수정하고 결과를 즉시 확인할 수 있었다. Smalltalk의 객체 모델과 가상 머신, 대화형 개발 방식, 그래픽 환경은 이후 객체 지향 언어뿐 아니라 현대적인 통합 개발 환경과 사용자 인터페이스에도 큰 영향을 주었다.
Prolog
1972년 프랑스 마르세유에서 알랭 콜메로에와 연구진은 Prolog의 첫 구현을 개발하였다. Prolog는 프로그램을 수행해야 할 명령의 순서로 작성하기보다 사실과 규칙, 해결할 질의의 형태로 표현하였다.
Prolog 실행기는 논리적 통일과 역추적을 사용하여 조건을 만족하는 해를 탐색한다. 이러한 방식은 논리 프로그래밍이라는 별도의 패러다임을 형성하였으며, 자연어 처리와 지식 표현, 전문가 시스템, 규칙 기반 시스템 및 제약 조건 해결 연구에 사용되었다.
ML과 정적 함수형 언어
로빈 밀너는 정리 증명 시스템 LCF에서 증명 절차를 작성하기 위한 메타언어로 ML을 설계하였다. ML은 함수를 값으로 다루는 고차 함수형 언어이면서, 프로그래머가 대부분의 자료형을 직접 적지 않아도 컴파일러가 추론하는 다형적 정적 타입 시스템을 제공하였다.
ML의 타입 추론과 매개변수 다형성은 강한 타입 안전성을 유지하면서도 코드의 장황함을 줄일 수 있음을 보였다. 패턴 일치와 대수적 자료형, 모듈 시스템은 이후 Haskell, OCaml, F#, Scala와 현대 정적 타입 언어에 영향을 주었다.
SQL과 선언형 언어
관계형 데이터베이스가 발전하면서 데이터를 처리하는 별도의 선언형 언어도 등장하였다. IBM의 도널드 체임벌린과 레이먼드 보이스가 1970년대에 개발한 SEQUEL은 이후 SQL로 이름이 변경되었다.
SQL 사용자는 데이터를 어떤 순서로 탐색하고 결합할지를 세부적으로 명령하기보다 원하는 데이터의 조건과 형태를 기술한다. 실제 접근 경로와 실행 계획은 데이터베이스 관리 시스템이 결정한다. SQL은 범용 프로그래밍 언어와는 목적이 다르지만, 처리 절차보다 결과를 선언하는 언어 모델을 실용적인 대규모 시스템에 정착시켰다.
1980년대: 개인용 컴퓨터, 모듈화와 객체 지향의 확산
1980년대에는 개인용 컴퓨터가 보급되면서 프로그래밍 언어의 사용자층이 크게 확대되었다. 초기 개인용 컴퓨터에서는 BASIC이 널리 사용되었으며, Pascal과 C 컴파일러도 교육과 상용 소프트웨어 개발에 보급되었다. 통합 편집기와 컴파일러, 디버거를 하나의 제품으로 제공하는 개발 환경이 등장하면서 프로그램 작성과 실행 과정도 빨라졌다.
프로그램 규모가 커지자 코드를 단순한 프로시저 모음으로 구성하는 것만으로는 부족해졌다. 모듈 간 공개 인터페이스를 명시하고 구현을 숨기며, 별도로 컴파일된 구성 요소를 안전하게 결합하는 기능이 중요해졌다. 비르트의 Modula-2와 Oberon, 여러 Pascal 계열 언어는 모듈화와 인터페이스 검사를 언어 차원에서 다루었다.
C++
비야네 스트롭스트룹은 1979년부터 벨 연구소에서 “C with Classes”를 개발하기 시작하였다. 이 언어는 Simula의 클래스와 객체 추상화를 C의 성능 및 시스템 제어 능력과 결합하려는 시도였다. 1983년경 이름이 C++로 변경되었으며, 1985년 첫 상용 구현과 언어 서적이 공개되었다.
C++는 클래스와 상속뿐 아니라 함수 및 연산자 오버로딩, 생성자와 소멸자, 템플릿, 예외 처리, 일반화 프로그래밍을 점차 추가하였다. C와의 호환성을 상당 부분 유지하면서도 여러 프로그래밍 방식을 조합할 수 있는 다중 패러다임 언어로 발전하였다. 이후 대형 응용 프로그램과 운영체제 구성 요소, 게임 엔진, 브라우저 및 고성능 소프트웨어에서 널리 사용되었다.
Perl과 스크립트 언어
1987년 래리 월은 UNIX 환경에서 보고서 생성과 텍스트 처리, 시스템 관리 작업을 자동화하기 위해 Perl을 공개하였다. Perl은 C와 셸, sed, awk 등 여러 도구와 언어의 특징을 결합하였다.
Perl은 정규 표현식과 문자열 처리 기능을 언어 안에 깊게 통합하였으며, 짧은 프로그램으로 파일과 텍스트를 가공할 수 있었다. 이후 CGI 기반 초기 웹 프로그래밍과 시스템 자동화에서 중요한 역할을 하였고, 대규모 확장 모듈 저장소를 중심으로 언어 생태계를 구축하는 모델에도 영향을 주었다.
Haskell
1980년대에는 여러 지연 평가 함수형 언어가 연구되었으나 언어마다 문법과 기능이 달라 연구 결과와 라이브러리를 공유하기 어려웠다. 이를 해결하기 위한 위원회 작업을 통해 1980년대 후반 Haskell이 설계되었다.
Haskell은 부작용을 타입과 구조를 통해 분리하는 순수 함수형 프로그래밍과 지연 평가, 대수적 자료형, 패턴 일치 및 타입 클래스를 주요 특징으로 삼았다. Haskell은 상업적 사용뿐 아니라 프로그래밍 언어 연구에서 새로운 타입 시스템과 추상화 기능을 실험하는 기반이 되었다.
1990년대: 인터넷과 가상 머신, 동적 언어의 성장
1990년대에는 개인용 컴퓨터의 성능 향상과 그래픽 운영체제, 인터넷의 확산으로 프로그래밍 언어가 다루어야 할 환경이 크게 변하였다. 네트워크를 통해 프로그램을 배포하고, 서로 다른 운영체제에서 같은 코드를 실행하며, 빠르게 변화하는 응용 프로그램을 짧은 시간 안에 개발하는 능력이 중요해졌다.
Python
귀도 반 로섬은 1980년대 후반 네덜란드 CWI에서 교육용 언어 ABC의 장점을 계승하면서도 UNIX 환경과 실제 시스템 프로그래밍에 쉽게 확장할 수 있는 언어를 구상하였다. 1989년 말 구현을 시작하여 1990년대 초 Python을 공개하였다.
Python은 들여쓰기로 블록 구조를 표현하고 동적 타입과 자동 메모리 관리, 예외 처리, 모듈 및 풍부한 기본 자료형을 제공하였다. C로 작성된 외부 모듈과 쉽게 연결할 수 있는 구조는 간단한 스크립트부터 과학 계산, 웹 서버, 자동화와 인공지능까지 사용 범위를 넓히는 데 기여하였다.
Java
제임스 고슬링과 Sun Microsystems 연구진은 1990년대 초 네트워크에 연결된 소비자 기기와 임베디드 시스템을 위한 소프트웨어 플랫폼을 연구하였다. 초기에는 C++를 사용했으나 이식성과 안정성, 메모리 관리 및 보안 문제를 해결하기 위해 새로운 언어와 실행 환경을 만들었다.
1995년 공개된 Java는 소스 코드를 특정 CPU의 기계어가 아닌 바이트코드로 컴파일하고, 이를 Java Virtual Machine에서 실행하는 구조를 채택하였다. 자동 메모리 관리와 런타임 검증, 표준 라이브러리를 함께 제공함으로써 하나의 프로그램을 여러 운영체제에서 실행하는 플랫폼 독립성을 강조하였다. Java는 웹 애플릿에서 출발해 서버와 기업용 소프트웨어, 모바일 및 대규모 분산 시스템으로 사용 범위를 확장하였다.
JavaScript
1995년 Netscape의 브렌던 아이크는 웹 문서 안에서 사용자 입력에 반응하고 화면 내용을 동적으로 변경하기 위한 언어를 설계하였다. 이 언어는 처음에 다른 이름으로 개발되었으나 최종적으로 JavaScript라는 이름으로 Netscape Navigator에 포함되었다.
JavaScript는 Java와 이름이 비슷하지만 별개의 언어이며, 동적 타입과 프로토타입 기반 객체, 일급 함수를 사용하였다. 초기에는 웹 페이지에 삽입되는 짧은 스크립트를 주로 처리했지만, 브라우저 엔진의 성능 향상과 ECMAScript 표준화, Node.js 및 모듈 생태계의 등장으로 프론트엔드와 서버를 모두 다루는 범용 언어로 성장하였다.
PHP와 Ruby
라스무스 레르도르프는 1994년 자신의 웹사이트 방문 기록을 관리하기 위한 CGI 도구를 만들었으며, 이 도구는 PHP/FI를 거쳐 PHP로 발전하였다. PHP는 HTML 문서 안에 서버 측 코드를 삽입하는 방식과 간단한 배포 구조를 통해 동적 웹사이트 개발에 빠르게 확산되었다.
마츠모토 유키히로는 Perl과 Smalltalk, LISP 등의 영향을 결합하여 개발자의 표현력과 작성 편의를 중시한 Ruby를 설계하였다. Ruby는 1993년부터 개발되어 1995년 공개되었으며, 모든 값을 객체로 다루는 일관된 객체 모델과 블록 및 메타프로그래밍 기능을 제공하였다. 이후 Ruby on Rails가 등장하면서 웹 애플리케이션 개발에서 널리 알려졌다.
2000년대: 관리형 플랫폼과 다중 패러다임 언어
2000년대에는 가상 머신과 관리형 런타임이 대형 소프트웨어 개발의 중심 기술로 자리 잡았다. 언어는 소스 코드 문법만이 아니라 가비지 컬렉터와 표준 라이브러리, 패키지 관리, 디버거, JIT 컴파일러 및 배포 플랫폼을 포함하는 전체 생태계로 인식되기 시작하였다.
C#과 .NET
Microsoft는 .NET 플랫폼을 위해 안데르스 하일스베르를 중심으로 C#을 개발하였다. 첫 번째 널리 배포된 구현은 2000년 .NET 계획과 함께 공개되었다.
C#은 C 계열 문법과 객체 지향 모델을 사용하면서 자동 메모리 관리와 런타임 타입 정보, 속성, 델리게이트 및 이벤트를 통합하였다. 이후 제네릭과 LINQ, 람다식, 비동기 함수, 패턴 일치, 레코드 등을 추가하며 객체 지향과 함수형·선언형 프로그래밍을 함께 지원하는 언어로 발전하였다.
Scala
마틴 오더스키는 2001년부터 EPFL에서 Scala를 개발하였으며, 2004년 JVM용 구현을 공개하였다. Scala는 Java Virtual Machine 및 Java 라이브러리와의 상호 운용성을 유지하면서 객체 지향 프로그래밍과 함수형 프로그래밍을 하나의 정적 타입 체계에서 결합하였다.
Scala는 고차 함수와 패턴 일치, 불변 자료 구조, 트레이트 및 강력한 타입 추론을 제공하였다. 기존 플랫폼의 생태계를 활용하면서 새로운 언어 기능을 도입하는 전략은 이후 JVM과 .NET을 대상으로 한 여러 현대 언어에서도 반복되었다.
2010년대: 동시성, 메모리 안전성과 대규모 개발
2000년대 후반부터 프로세서의 발전이 단일 코어의 속도 향상보다 다중 코어와 병렬 처리 중심으로 변화하였다. 동시에 인터넷 서비스의 규모가 커지고 클라우드 인프라가 확산되면서 동시성과 안정적인 배포, 빠른 빌드 및 대규모 코드베이스 관리가 언어 설계의 핵심 문제가 되었다.
Go
Google의 로버트 그리즈머, 롭 파이크, 켄 톰프슨은 2007년 대규모 소프트웨어 인프라 개발에서 발생하는 긴 컴파일 시간과 복잡한 의존 관계, 동시성 문제를 해결하기 위해 Go (프로그래밍 언어)를 설계하기 시작하였다. Go 프로젝트는 2009년 공개되었고 2012년 Go 1이 발표되었다.
Go는 비교적 작은 문법과 빠른 컴파일, 가비지 컬렉션, 패키지 시스템, 고루틴 및 채널을 제공하였다. 언어 기능의 수를 제한하는 대신 표준 도구와 포매터, 테스트, 모듈 및 빌드 절차를 통합하여 팀 단위의 서버와 네트워크 소프트웨어 개발을 단순화하는 방향을 택하였다.
Rust
그레이든 호어가 시작하고 이후 Mozilla와 공개 커뮤니티가 발전시킨 Rust는 가비지 컬렉터 없이 메모리 안전성과 동시성 안전성을 제공하는 시스템 프로그래밍 언어를 목표로 하였다. 2015년 Rust 1.0이 공개되면서 안정적인 언어와 표준 라이브러리, Cargo 패키지 및 빌드 도구가 함께 제공되었다.
Rust는 소유권과 대여, 수명 분석을 통해 컴파일 시점에 메모리 사용 규칙을 검사한다. 데이터 경쟁과 해제된 메모리 접근 등 여러 종류의 오류를 컴파일 단계에서 방지하면서도 C와 C++에 가까운 세부 제어와 성능을 제공하려 하였다. 또한 에디션 제도를 통해 기존 코드와의 호환성을 유지하면서 언어 문법을 단계적으로 발전시키는 방식을 채택하였다.
Kotlin
JetBrains는 2010년 Kotlin 프로젝트를 시작하고 2011년 이를 공개하였다. Kotlin은 기존 Java 코드 및 JVM 생태계와 호환되면서도 널 안전성과 타입 추론, 확장 함수, 데이터 클래스, 코루틴 같은 현대적인 기능을 제공하도록 설계되었다.
2016년 Kotlin 1.0이 발표되었으며, 이후 JVM뿐 아니라 JavaScript와 네이티브 코드, WebAssembly를 대상으로 하는 다중 플랫폼 언어로 확장되었다.
Swift
Apple은 기존 Objective-C 기반 개발 환경을 현대화하기 위해 Swift를 개발하고 2014년 공개하였다. Swift는 정적 타입과 타입 추론, 값 타입, 선택적 값, 제네릭 및 프로토콜 중심 추상화를 제공하였다.
초기에는 iOS와 macOS 애플리케이션 개발을 중심으로 사용되었으나, 이후 오픈소스화되고 Linux와 Windows를 포함한 여러 플랫폼과 서버·임베디드 영역으로 확장되었다.
TypeScript
웹 애플리케이션이 커지면서 동적 타입인 JavaScript 코드의 구조와 오류를 개발 단계에서 검사하려는 요구가 커졌다. Microsoft가 2012년 공개한 TypeScript는 JavaScript의 상위 집합으로 설계되어 기존 코드를 유지하면서 선택적인 정적 타입 표기를 추가할 수 있게 하였다.
TypeScript는 구조적 타입 시스템과 제어 흐름 분석을 사용하며, 컴파일 결과로 일반 JavaScript를 생성한다. 이는 기존의 거대한 실행 환경과 생태계를 교체하지 않고 그 위에 정적 분석 계층을 추가하는 점진적 타입 시스템의 대표적인 성공 사례가 되었다.
WebAssembly
2019년 월드 와이드 웹 컨소시엄은 WebAssembly 핵심 사양을 웹 표준으로 채택하였다. WebAssembly는 사람이 직접 작성하는 일반적인 고급 언어라기보다 C와 C++, Rust 등의 언어에서 생성되는 안전하고 이식 가능한 저수준 바이트코드 및 가상 머신 명령 형식이다.
이로써 웹 브라우저는 JavaScript뿐 아니라 여러 언어의 컴파일 결과를 효율적으로 실행할 수 있는 공통 대상을 가지게 되었다. WebAssembly는 이후 브라우저 밖의 서버와 플러그인, 샌드박스 및 휴대 가능한 실행 환경에서도 사용되면서 프로그래밍 언어와 실행 플랫폼의 관계를 다시 확장하였다.
2020년대 이후
2020년대의 프로그래밍 언어는 하나의 패러다임이나 실행 방식으로 분류하기 어려워지고 있다. 현대 언어는 절차형과 객체 지향, 함수형, 선언형 및 동시성 모델을 필요에 따라 함께 제공한다. 정적 타입 언어는 타입 추론과 대화형 도구를 강화하고 있으며, 동적 언어도 선택적 타입 검사와 정적 분석 기능을 도입하고 있다.
언어 설계에서 중요하게 다루어지는 방향은 다음과 같다.
- 메모리 안전성과 정의되지 않은 동작의 축소
- 비동기 실행과 구조화된 동시성
- 널 및 오류 처리의 명시화
- 타입 추론과 점진적 타입 시스템
- 불변 자료와 함수형 표현의 확대
- 패키지 관리자, 빌드 시스템과 포매터의 표준화
- 여러 운영체제와 CPU 아키텍처를 지원하는 다중 플랫폼 실행
- 기존 C ABI, JVM, .NET, JavaScript 및 WebAssembly 생태계와의 상호 운용
- CPU, GPU와 가속기를 함께 다루는 이기종 컴퓨팅
- 언어 사양과 구현의 공개 개발 및 커뮤니티 거버넌스
컴파일러 구조에서도 하나의 소스 언어를 하나의 기계어로 직접 번역하는 단순한 흐름보다 여러 단계의 중간 표현을 사용하는 방식이 확대되고 있다. LLVM과 MLIR 같은 컴파일러 기반 기술은 언어 프런트엔드와 최적화기, CPU 및 GPU 백엔드를 재사용할 수 있게 하며, 특정 하드웨어나 계산 영역을 위한 도메인 특화 언어의 개발 비용을 줄이는 것을 목표로 한다.
한편 새로운 언어가 등장한다고 해서 기존 언어가 즉시 사라지는 것은 아니다. Fortran과 COBOL은 수십 년 동안 축적된 과학 및 업무 시스템에서 계속 사용되고 있으며, C와 C++는 운영체제와 고성능 소프트웨어의 방대한 생태계를 유지하고 있다. Java와 C#은 관리형 플랫폼을 중심으로 계속 기능을 추가하고 있으며, Python과 JavaScript 역시 기존 코드 및 패키지 생태계를 유지하면서 언어와 실행기의 성능 및 타입 분석 기능을 발전시키고 있다.
따라서 프로그래밍 언어의 역사는 당시 컴퓨터의 성능과 구조, 사용자의 범위, 개발하려는 소프트웨어의 규모, 요구되는 안전성과 생산성에 대응하여 서로 다른 계산 모델과 추상화 방식을 제시하였다. 현대 프로그래밍 언어는 이러한 역사적 계보를 조합하며 발전하고 있으며, 기계 제어에 가까운 저수준 언어부터 인간의 문제 표현에 가까운 선언형·도메인 특화 언어까지 여러 수준의 언어가 상호 보완적으로 사용되고 있다.
분류와 구성 요소
프로그래밍 언어는 하나의 기준만으로 명확하게 구분되지 않는다. 하나의 언어가 고급 언어이면서 정적 타입 언어이고, 객체 지향·절차형·함수형 프로그래밍을 함께 지원하며, 네이티브 코드와 가상 머신을 모두 실행 대상으로 삼을 수도 있다. 따라서 프로그래밍 언어의 분류는 서로 배타적인 목록이라기보다 언어의 추상화 수준, 실행 방식, 타입 체계, 프로그래밍 패러다임, 사용 목적과 자원 관리 방식 등을 서로 다른 관점에서 설명하는 다차원적인 체계로 이해해야 한다. ACM의 컴퓨팅 분류 체계도 함수형, 객체 지향, 논리·제약, 데이터 흐름, 병렬·분산 및 다중 패러다임 언어를 별개의 분류 항목으로 두면서 하나의 연구 대상에 여러 분류를 적용할 수 있는 구조를 사용한다.
주요 분류 기준은 다음과 같이 요약할 수 있다.
| 기준 | 분류 예시 | 구분 대상 |
|---|---|---|
| 추상화 수준 | 기계어, 어셈블리어, 고급 언어 | 하드웨어 세부 구조를 얼마나 직접 표현하는가 |
| 실행 방식 | 사전 컴파일, 인터프리트, 바이트코드, JIT | 소스 코드가 실행 가능한 형태로 변환되는 방식 |
| 타입 체계 | 정적·동적 타입, 명시적·추론형 타입 | 값과 연산의 유효성을 언제, 어떻게 검사하는가 |
| 타입 관계 | 명목적 타입, 구조적 타입 | 서로 다른 타입의 호환성을 무엇으로 판단하는가 |
| 패러다임 | 명령형, 객체 지향, 함수형, 논리형, 데이터 흐름형 | 계산과 프로그램 구조를 어떤 방식으로 표현하는가 |
| 사용 목적 | 범용 언어, 도메인 특화 언어 | 언어가 다루도록 설계된 문제 영역의 범위 |
| 자원 관리 | 수동 관리, 가비지 컬렉션, 소유권 기반 관리 | 메모리와 외부 자원의 생명 주기를 관리하는 방식 |
| 동시성 모델 | 스레드, 공유 메모리, 메시지 전달, 채널, 코루틴 | 여러 작업의 실행과 동기화를 표현하는 방식 |
추상화 수준에 따른 분류
기계어는 프로세서가 직접 해석하는 명령의 이진 표현이다. 각 명령은 특정 명령어 집합 아키텍처의 연산 코드와 피연산자, 레지스터 또는 메모리 주소를 표현한다. 기계어는 하드웨어를 가장 직접적으로 제어할 수 있지만 사람이 읽고 작성하기 어렵고, 같은 프로그램이라도 대상 프로세서가 달라지면 명령 표현을 다시 구성해야 한다.
어셈블리어는 기계 명령을 숫자 대신 MOV, ADD, JMP와 같은 기호로 표현한다. 레이블과 상수, 매크로 등을 사용할 수 있어 기계어보다 읽고 관리하기 쉽지만, 레지스터와 호출 규약, 메모리 주소 지정 방식, 명령어 집합 등 대상 아키텍처의 구조를 직접 다룬다는 점에서 저급 언어로 분류된다.
고급 프로그래밍 언어는 하드웨어 명령보다 알고리즘과 자료 구조, 함수, 객체, 모듈 등 문제 해결에 가까운 개념을 중심으로 프로그램을 표현한다. C처럼 메모리 주소와 포인터를 직접 다룰 수 있는 언어부터 Python이나 Haskell처럼 실행 환경과 자료 표현의 상당 부분을 언어 구현에 맡기는 언어까지 추상화 정도는 서로 다르다. 따라서 저급 언어와 고급 언어의 구분은 절대적인 두 단계라기보다 하드웨어 의존성과 추상화 정도에 따른 연속적인 구분에 가깝다.
여기서 고급이라는 표현은 언어의 품질이나 난도를 뜻하지 않는다. 하드웨어의 구체적인 동작을 얼마나 추상화하는지를 나타내며, 고급 언어라고 해서 반드시 사용하기 쉽거나 실행 속도가 느린 것은 아니다. 컴파일러는 고급 언어의 구조를 중간 표현과 최적화 단계를 거쳐 효율적인 기계어로 변환할 수 있다. LLVM은 언어 프런트엔드가 생성한 중간 표현을 분석하고 최적화한 뒤 여러 대상 아키텍처의 기계어로 변환하는 구조를 제공한다.
실행 방식에 따른 분류
프로그래밍 언어는 흔히 컴파일 언어와 인터프리트 언어로 구분되지만, 이 구분은 언어 자체의 절대적인 속성이라기보다 특정 구현체가 프로그램을 실행하는 방식에 가깝다. 같은 언어도 구현체에 따라 네이티브 코드로 미리 컴파일하거나, 가상 머신의 바이트코드로 변환하거나, 실행 중 기계어로 컴파일하거나, 구문 구조를 직접 해석할 수 있다. Python만 해도 CPython, PyPy, Jython, IronPython 등 서로 다른 구현체가 존재하며, PyPy는 JIT 컴파일을 사용하고 Jython과 IronPython은 각각 JVM과 .NET 실행 환경을 이용한다.
사전 컴파일 또는 AOT 컴파일은 프로그램을 실행하기 전에 소스 코드를 대상 시스템의 기계어나 다른 실행 형식으로 변환하는 방식이다. C와 C++의 일반적인 구현에서는 전처리, 구문 및 의미 분석, 중간 표현 생성, 최적화, 대상별 코드 생성, 어셈블과 링크 과정을 거쳐 실행 파일이나 라이브러리를 만든다. 다만 실제 도구에서는 여러 단계가 하나의 명령으로 결합되거나 일부 중간 파일의 생성을 생략할 수 있다.
인터프리터는 프로그램의 표현을 실행 시점에 읽고 그 의미에 따라 동작을 수행한다. 인터프리터가 반드시 원본 소스 코드를 한 줄씩 직접 실행하는 것은 아니다. 소스 코드를 먼저 토큰과 구문 트리, 내부 명령 또는 바이트코드로 변환한 뒤 그 중간 형식을 해석할 수도 있다.
바이트코드와 가상 머신을 이용하는 방식에서는 소스 코드를 실제 CPU의 명령어가 아닌 가상 명령어 집합으로 변환한다. Java 소스 코드는 일반적으로 JVM 클래스 파일과 바이트코드로 컴파일되며, JVM은 스택과 힙, 메서드 영역, 상수 풀 등의 실행 구조와 명령어 집합을 정의한다. 이 방식은 언어와 물리적 하드웨어 사이에 공통 실행 계층을 두어 서로 다른 운영체제와 프로세서에서 동일한 프로그램 형식을 사용할 수 있게 한다.
JIT 컴파일은 프로그램 실행 중에 중간 표현이나 자주 실행되는 코드를 현재 시스템의 기계어로 변환한다. 실행 중 수집한 정보를 바탕으로 실제 호출 관계와 값의 형태, 반복 빈도에 맞춘 최적화를 수행할 수 있지만, 컴파일에 필요한 시간과 메모리가 실행 과정에 포함된다. 현대의 언어 구현은 인터프리터와 JIT 컴파일러, AOT 컴파일러를 함께 사용하기도 하므로 컴파일과 인터프리트는 서로 완전히 분리된 방식이라기보다 하나의 실행 체계 안에서 조합할 수 있는 구현 전략이다.
타입 체계에 따른 분류
타입 시스템은 프로그램에서 사용할 수 있는 값의 종류와 각 값에 허용되는 연산, 값 사이의 관계를 정의하는 규칙 체계이다. 예를 들어 정수와 문자열에 허용되는 연산이 다르고, 함수의 매개변수와 반환값에도 특정 타입 조건이 적용될 수 있다. Go 사양은 타입을 특정 값의 집합과 그 값에 적용되는 연산 및 메서드의 집합으로 설명하며, Haskell에서는 표현식이 값을 나타내는 동시에 정적인 타입을 가진다.
정적 타입 언어는 프로그램을 실행하기 전에 표현식과 변수, 함수 호출 등의 타입 관계를 검사한다. C, C++, Rust, Java, Haskell, Go 등은 정적 타입 검사를 중심으로 한다. 정적 타입 검사는 가능한 오류의 일부를 실행 전에 발견하고, 컴파일러가 값의 표현과 호출 구조에 관한 정보를 최적화에 사용할 수 있게 한다. 그러나 정적 타입 언어에서도 배열 범위와 외부 입력, 동적 로딩처럼 실행 중에만 결정되는 조건은 별도로 검사해야 할 수 있다.
동적 타입 언어에서는 값이 실행 중에 타입 정보를 가지며, 연산이 실제 값에 적용되는 시점에 유효성을 검사하는 경우가 많다. Python, Ruby, JavaScript 등이 대표적이다. 동적 타입은 하나의 이름에 서로 다른 종류의 값을 순차적으로 연결하거나 실행 중 생성되는 구조를 자연스럽게 다룰 수 있게 하지만, 특정 타입 오류가 해당 코드 경로를 실제로 실행할 때까지 드러나지 않을 수 있다. Python의 데이터 모델에서는 프로그램의 모든 데이터를 객체 또는 객체 사이의 관계로 표현하며, 각 객체는 정체성과 타입, 값을 가진다.
정적 타입과 동적 타입의 구분은 타입 표기를 직접 작성하는지와는 별개이다. 타입 추론을 지원하는 정적 타입 언어는 프로그래머가 타입을 모두 명시하지 않아도 컴파일러가 표현식과 사용 관계를 분석하여 타입을 결정할 수 있다. Haskell, ML, Rust, Kotlin, Swift 등이 타입 추론을 적극적으로 사용한다. 반대로 동적 언어에서도 TypeScript의 타입 표기나 Python의 타입 힌트처럼 실행 동작을 직접 바꾸지 않는 정적 분석 계층을 추가할 수 있다.
타입 호환성은 명목적 타입과 구조적 타입으로도 구분할 수 있다. 명목적 타입 체계는 타입 선언의 이름이나 명시적인 상속·구현 관계를 중심으로 호환성을 판단한다. 구조적 타입 체계는 타입 이름보다 실제로 가진 필드와 메서드 등의 구조를 비교한다. TypeScript는 객체의 구성원을 기준으로 타입 호환성을 판단하는 구조적 서브타이핑을 사용한다.
타입 시스템은 이 밖에도 제네릭과 매개변수 다형성, 서브타이핑, 합 타입과 곱 타입, 대수적 자료형, 의존 타입, 점진적 타입 등 여러 특성으로 구분된다. 흔히 사용하는 강한 타입과 약한 타입이라는 표현은 문헌과 사용자에 따라 암시적 변환, 메모리 안전성, 런타임 검사, 타입 우회 가능성 등 서로 다른 의미로 사용되므로 정확한 분류가 필요할 때는 정적·동적 검사, 명시적·암시적 변환, 메모리 안전성처럼 구체적인 특성을 직접 서술하는 편이 적절하다. 타입과 다형성은 단일한 강약 관계가 아니라 값의 분류와 추상화, 호환성을 정의하는 여러 독립적인 규칙으로 구성된다.
프로그래밍 패러다임에 따른 분류
프로그래밍 패러다임은 계산을 어떤 개념과 구조로 표현하는지를 나타낸다. 현대 언어의 대부분은 하나의 패러다임만을 엄격하게 따르지 않고 여러 방식을 함께 지원하는 다중 패러다임 언어이다. 예를 들어 C++는 절차형과 객체 지향, 일반화 프로그래밍, 함수형 표현을 함께 지원하며, Python과 JavaScript도 객체와 함수, 명령형 제어 구조를 함께 사용할 수 있다. ACM의 언어 분류 역시 함수형, 객체 지향, 논리·제약, 데이터 흐름, 동시·분산 및 다중 패러다임 언어를 별도로 다룬다.
명령형 프로그래밍은 프로그램의 상태를 변경하는 명령의 순서로 계산을 표현한다. 변수에 값을 대입하고 조건문과 반복문, 함수 호출을 통해 실행 흐름을 제어한다. 절차형 프로그래밍은 명령형 프로그램을 함수나 프로시저, 서브루틴과 같은 호출 가능한 단위로 나누어 구성하는 방식이다. C, Pascal, FORTRAN 등이 대표적인 절차형 언어로 분류된다.
객체 지향 프로그래밍은 데이터와 그 데이터를 처리하는 동작을 객체라는 단위로 구성한다. 클래스 기반 언어에서는 클래스가 객체의 구조와 동작을 정의하고, 캡슐화와 상속, 다형성, 동적 디스패치 등을 통해 객체 사이의 관계를 표현한다. Java, C#, C++, Smalltalk 등이 대표적이지만, 언어마다 객체와 클래스, 상속을 정의하는 방식은 다르다. JavaScript처럼 클래스 선언을 제공하면서 내부적으로는 프로토타입 기반 객체 모델을 사용하는 언어도 있다. ECMAScript 사양은 언어의 계산과 객체 조작을 정의하면서도 파일과 네트워크 같은 입출력 기능은 호스트 환경의 역할로 분리한다.
함수형 프로그래밍은 함수를 계산의 중심 단위이자 값으로 다루며, 함수의 조합과 재귀, 고차 함수, 불변 자료 구조를 통해 프로그램을 구성한다. 순수 함수형 언어는 같은 입력에 대해 같은 결과를 반환하는 함수와 부작용의 통제를 강조한다. Haskell은 표현식과 함수, 정적 타입을 중심으로 언어를 구성하며, LISP와 ML, F# 및 여러 현대 언어도 함수형 프로그래밍의 주요 개념을 지원한다. 함수형 프로그래밍은 명령과 저장소 상태를 중심으로 한 전통적인 von Neumann식 프로그래밍을 대체하거나 보완하는 계산 모델로 연구되어 왔다.
논리 프로그래밍은 사실과 규칙, 질의를 기술하고 실행기가 논리적 추론을 통해 조건을 만족하는 해를 탐색하는 방식이다. Prolog가 대표적이며, 통일과 역추적, 관계 중심의 기술을 사용한다. 제약 프로그래밍은 변수가 만족해야 하는 조건을 기술하고 제약 해결기가 가능한 값을 탐색한다. 두 방식 모두 구체적인 명령 순서를 직접 작성하기보다 성립해야 할 관계와 조건을 선언한다는 특징을 가진다.
선언형 프로그래밍은 계산 과정을 세부적인 명령으로 지정하기보다 원하는 결과나 관계를 기술하는 넓은 범주의 방식이다. 함수형과 논리형 언어를 선언형 범주에 포함하기도 하며, SQL처럼 필요한 데이터의 조건과 형태를 기술하고 실제 접근 순서와 실행 계획은 시스템에 맡기는 언어도 선언형 언어로 분류된다.
데이터 흐름 프로그래밍은 연산 사이의 데이터 의존성을 그래프 형태로 표현하고, 필요한 입력이 준비되면 해당 연산을 수행한다. 시각적 프로그래밍 환경과 신호 처리, 병렬 계산 및 머신러닝 그래프 시스템에서 자주 사용된다. 반응형 프로그래밍은 시간에 따라 변하는 값과 사건의 흐름, 값 사이의 전파 관계를 중심으로 프로그램을 구성한다.
동시성 프로그래밍은 여러 계산이 겹쳐 실행되는 구조와 그 사이의 통신 및 동기화를 다룬다. 언어에 따라 운영체제 스레드와 공유 메모리, 액터와 메시지 전달, 코루틴과 비동기 함수, 채널 기반 통신 등을 제공한다. Go는 고루틴 사이의 주요 동기화 방식으로 채널 통신을 정의하며, 채널 송신과 대응하는 수신 사이의 순서 관계를 메모리 모델에 명시한다.
사용 목적에 따른 분류
범용 프로그래밍 언어는 특정 문제 하나에 한정되지 않고 여러 종류의 소프트웨어를 작성할 수 있도록 설계된다. C, C++, Java, Python, Rust, Go 등이 이에 해당한다. 다만 범용 언어도 설계 목적과 생태계에 따라 시스템 소프트웨어, 기업용 응용 프로그램, 웹 서버, 자동화 등 특정 분야에서 더 널리 사용될 수 있다.
도메인 특화 언어는 특정 문제 영역을 간결하고 정확하게 표현하도록 설계된다. SQL은 관계형 데이터 질의와 조작을, 정규 표현식은 문자열 패턴을, 셰이더 언어는 GPU 그래픽 연산을, 하드웨어 기술 언어는 디지털 회로를 표현한다. 도메인 특화 언어는 독립된 프로그램으로 실행될 수도 있고, 다른 언어의 문자열과 라이브러리, 매크로 또는 내장 문법 안에 포함될 수도 있다.
사용 분야에 따라 시스템 프로그래밍 언어, 스크립트 언어, 셸 언어, 데이터 질의 언어, 과학 계산 언어, 교육용 언어, 하드웨어 기술 언어, 셰이더 언어, 동시·병렬 프로그래밍 언어 등으로도 구분한다. 이러한 명칭은 언어가 사용할 수 있는 기능 전체보다 주된 설계 목적과 사용 관행을 나타내므로, 하나의 언어가 여러 분야에 속할 수 있다.
언어를 이루는 핵심 구성 요소
프로그래밍 언어는 문법만으로 이루어지지 않는다. 언어가 완전한 계산 체계로 기능하려면 소스 코드를 구성하는 문자와 토큰, 문장의 구조, 각 문장이 의미하는 동작, 값과 타입, 이름의 범위, 제어 흐름, 추상화 수단, 메모리 및 오류 처리 방식 등이 함께 정의되어야 한다. ACM의 분류 체계는 언어의 주요 구성 요소로 자료형과 자료 구조, 제어 구조, 함수와 프로시저, 클래스와 객체, 모듈과 패키지, 다형성, 재귀, 동시성 구조 및 동적 저장소 관리를 제시한다.
어휘 구조와 토큰
어휘 구조는 소스 코드를 이루는 가장 작은 문법 단위를 정의한다. 식별자와 키워드, 리터럴, 연산자, 구분자, 주석, 공백과 줄바꿈의 처리 규칙이 여기에 포함된다. 어휘 분석기는 입력된 문자의 연속을 읽어 파서가 처리할 수 있는 토큰의 연속으로 변환한다.
언어에 따라 공백의 의미도 다르다. C 계열 언어에서는 대부분의 공백이 토큰을 구분하는 역할만 하지만, Python에서는 들여쓰기의 변화가 INDENT와 DEDENT 토큰으로 변환되어 블록 구조를 나타낸다. Python 참조 문서는 문자 입력을 처리하는 어휘 분석과 그 결과로 생성된 토큰을 처리하는 구문 분석을 구분한다.
구문
구문은 토큰을 어떤 순서와 구조로 결합해야 올바른 프로그램이 되는지를 정의한다. 변수 선언과 함수 정의, 연산식, 조건문, 반복문, 타입 선언, 모듈 선언 등의 형태가 구문 규칙에 포함된다. 언어 사양은 일반적으로 BNF, EBNF, PEG와 같은 문법 표기법이나 이에 준하는 형식으로 구문을 기술한다.
구문 규칙은 프로그램의 외형적 구조만 결정한다. 예를 들어 a + b가 문법적으로 올바른 표현식이더라도 a와 b에 덧셈을 적용할 수 있는지, 연산 결과가 무엇인지는 타입 규칙과 의미론에 의해 결정된다.
의미론
의미론은 문법적으로 올바른 프로그램이 무엇을 뜻하고 어떻게 동작하는지를 정의한다. 언어 표준은 보통 구문과 함께 의미론을 명시하며, COBOL 표준 역시 기계 독립적인 프로그램을 위해 언어의 구문과 의미론을 규정한다.
정적 의미론은 프로그램을 실행하기 전에 확인할 수 있는 규칙을 다룬다. 이름이 선언되어 있는지, 변수의 범위가 올바른지, 함수 호출의 인수와 반환 타입이 맞는지, 제어문이 허용된 위치에 있는지 등이 포함된다. 구문만으로 표현하기 어려운 타입 검사와 이름 해석 규칙이 정적 의미론에 속한다.
동적 의미론은 프로그램이 실행될 때 각 표현식과 명령이 어떤 상태 변화를 만들고 어떤 값을 계산하는지를 정의한다. 연산자의 평가 순서와 함수 호출, 예외 발생, 객체 생성, 메모리 접근, 동시 실행의 순서 등이 동적 의미론에 포함된다. 의미론은 자연어로 설명할 수도 있고, 조작적 의미론과 지시적 의미론, 공리적 의미론 같은 수학적 방법으로 정의할 수도 있다.
값과 데이터 모델
데이터 모델은 언어에서 값이 무엇이며, 어떤 종류의 값을 만들고 조합할 수 있는지를 정의한다. 정수와 실수, 논리값, 문자, 문자열 같은 기본 값뿐 아니라 배열과 튜플, 레코드, 구조체, 리스트, 집합, 맵, 함수, 클래스와 객체 등이 포함될 수 있다.
언어마다 값과 객체의 관계는 다르다. Python은 모든 데이터를 객체 또는 객체 사이의 관계로 모델링하며 객체마다 정체성과 타입, 값이 있다고 정의한다. Haskell은 표현식이 값을 나타내며 각 표현식에 정적 타입을 부여한다. C는 객체와 값, 저장 영역을 하드웨어에 가까운 메모리 모델로 정의한다. 이러한 데이터 모델의 차이는 대입과 복사, 동일성 비교, 변경 가능성 및 메모리 배치 방식에 영향을 준다.
이름, 바인딩과 범위
프로그램의 식별자는 변수와 함수, 타입, 모듈 등의 대상을 가리킨다. 식별자와 실제 대상 사이의 연결을 바인딩이라고 한다. 바인딩은 컴파일 시점이나 링크 시점, 프로그램 로드 시점 또는 실행 중에 형성될 수 있다.
범위는 특정 이름을 참조할 수 있는 소스 코드의 영역이며, 수명은 값이나 객체, 저장 공간이 실제로 존재하는 실행 시간의 범위이다. 두 개념은 서로 관련되어 있지만 동일하지 않다. 범위를 벗어난 객체가 다른 참조를 통해 계속 존재할 수 있고, 반대로 이름이 범위 안에 있더라도 그 이름이 가리키던 자원이 더 이상 유효하지 않은 경우가 발생할 수 있다.
언어는 전역 범위와 모듈 범위, 함수 범위, 블록 범위, 클래스 범위 등을 제공할 수 있다. 중첩 함수에서는 외부 함수의 지역 변수를 캡처하는 클로저가 만들어질 수 있으며, 이름을 찾는 순서와 캡처 방식은 언어의 실행 모델에 따라 달라진다.
표현식과 문장
표현식은 하나의 값을 계산하거나 나타내는 언어 요소이다. 리터럴과 변수 참조, 연산자 적용, 함수 호출, 조건식, 객체 생성 등이 표현식이 될 수 있다. 문장 또는 명령문은 대입과 반복, 분기, 반환, 예외 처리처럼 프로그램의 실행 흐름이나 상태에 영향을 주는 동작을 나타낸다.
일부 언어에서는 조건문과 블록까지 값을 생성하는 표현식으로 취급하며, 다른 언어에서는 값을 만드는 표현식과 실행만 수행하는 문장을 엄격히 구분한다. Python 언어 참조도 표현식의 구문과 의미를 별도로 정의하며, Haskell은 표현식을 언어의 중심 계산 단위로 다룬다.
제어 흐름
제어 흐름은 프로그램의 어느 부분을 어떤 순서로 실행할지를 결정한다. 순차 실행과 조건 분기, 반복, 함수 호출과 반환, 재귀, 예외 처리 등이 기본적인 제어 구조에 해당한다.
언어에 따라 패턴 일치와 코루틴, 생성기, 비동기 함수, 연속, 비결정적 선택 같은 고급 제어 구조를 제공하기도 한다. 이러한 구조는 단순히 문법적 편의 기능에 그치지 않고 프로그램 상태와 호출 스택, 값의 평가 순서 및 오류 전파 방식을 결정한다.
함수와 추상화
함수와 프로시저, 서브루틴은 반복되는 계산을 이름이 있는 단위로 분리한다. 매개변수와 반환값, 지역 변수, 호출 규약, 값 전달 방식이 함수 체계의 주요 요소이다. 함수가 다른 함수의 인수나 반환값이 될 수 있으면 일급 함수를 지원한다고 하며, 다른 함수를 인수로 받거나 반환하는 함수를 고차 함수라고 한다.
언어는 함수 외에도 클래스와 인터페이스, 트레이트, 프로토콜, 타입 클래스, 추상 자료형 등을 통해 데이터와 동작의 추상화를 제공한다. 다형성은 하나의 인터페이스나 코드가 여러 타입의 값에 적용될 수 있게 하며, 오버로딩과 서브타입 다형성, 매개변수 다형성 등으로 나뉜다.
모듈과 패키지
모듈은 관련된 값과 함수, 타입, 클래스 등의 선언을 하나의 이름 공간과 배포 단위로 묶는다. 모듈은 외부에 공개할 항목과 내부 구현을 구분하고, 다른 모듈의 기능을 가져오는 구조를 제공한다. Haskell의 모듈은 값과 자료형, 타입 동의어, 클래스 등을 정의하고 일부 항목을 외부로 내보내며, 가져오기 선언으로 다른 모듈의 항목을 범위에 포함한다.
패키지는 하나 이상의 모듈과 자원, 메타데이터를 묶는 더 큰 배포 단위로 사용되는 경우가 많다. 다만 모듈과 패키지의 정확한 의미와 관계는 언어 및 도구 체계마다 다르다.
메모리와 자원 관리
프로그램은 값과 객체를 저장하기 위해 메모리를 할당하고 더 이상 필요하지 않은 메모리를 회수해야 한다. C와 C++에서는 프로그래머가 직접 메모리를 할당하고 해제하거나 객체의 수명 규칙을 이용한다. Java와 C#, Go, Python 등은 도달할 수 없는 객체를 실행 환경이 찾아 회수하는 가비지 컬렉션을 주로 사용한다.
Rust는 컴파일러가 검사하는 소유권과 대여, 수명 규칙을 통해 가비지 컬렉터 없이 메모리 안전성을 제공하는 방식을 사용한다. 값의 소유자가 범위를 벗어나면 자원을 정리하며, 참조가 원본보다 오래 살아남거나 충돌하는 가변 참조가 생성되는 상황을 컴파일 시점에 제한한다.
메모리 외에도 파일과 네트워크 연결, 잠금, GPU 자원처럼 명시적인 해제가 필요한 자원이 있다. 언어는 소멸자와 정리 구문, 컨텍스트 관리자, 지연 실행, 자원 획득과 초기화를 결합하는 방식 등을 사용해 이러한 자원의 수명을 관리한다.
오류와 예외 처리
언어는 실패를 표현하고 전파하는 방법을 제공한다. 전통적인 반환 코드와 전역 오류 상태를 사용할 수도 있고, 예외를 발생시켜 현재의 호출 흐름을 중단하고 상위 처리기로 전달할 수도 있다. 일부 정적 타입 언어는 성공 값과 오류 값을 합 타입이나 결과 타입으로 표현하여 호출자가 오류 가능성을 타입을 통해 처리하게 한다.
오류 처리 방식은 함수의 반환 타입과 제어 흐름, 자원 정리 및 프로그램 안정성에 영향을 준다. 예외를 제공하는 언어에서도 복구 가능한 오류와 프로그래밍 결함, 시스템 중단 상황을 서로 다른 방식으로 다룰 수 있다.
동시성과 비동기 실행
동시성 모델은 여러 작업을 생성하고 작업 사이에서 데이터를 교환하며 실행 순서를 조정하는 방법을 정의한다. 스레드와 공유 메모리를 사용하는 언어에서는 잠금과 원자적 연산, 메모리 순서 규칙이 중요하다. 메시지 전달이나 채널 모델에서는 작업이 값을 주고받는 통신 과정이 동기화의 중심이 된다.
코루틴과 async·await는 하나의 작업이 입출력이나 다른 비동기 작업을 기다리는 동안 실행 권한을 반환하고, 이후 중단된 지점부터 다시 실행할 수 있게 한다. 이러한 기능은 실제 병렬 실행을 반드시 의미하지는 않으며, 하나의 스레드에서 여러 작업을 번갈아 실행하거나 여러 스레드와 결합할 수 있다.
표준 라이브러리와 실행 환경
표준 라이브러리는 문자열과 컬렉션, 파일, 수학, 시간, 네트워크, 동시성 등 자주 필요한 기능을 공통 인터페이스로 제공한다. 언어의 핵심 사양과 표준 라이브러리는 서로 밀접하지만 동일한 것은 아니다. Python 언어 참조도 언어 자체의 규칙과 내장·표준 모듈의 기능을 구분한다.
런타임은 프로그램 실행 중 언어 기능을 지원하는 소프트웨어 계층이다. 메모리 할당과 가비지 컬렉션, 동적 타입 검사, 예외 처리, 스레드 관리, 가상 메서드 호출, 리플렉션 등을 담당할 수 있다. JVM처럼 실행 메모리와 가상 명령어 집합을 명시하는 독립된 가상 머신이 존재할 수도 있고, C처럼 비교적 작은 컴파일러 런타임과 시스템 라이브러리를 사용하는 구조도 있다.
언어가 입출력과 운영체제 기능을 직접 정의하지 않고 실행 환경에 맡기기도 한다. ECMAScript 사양은 언어의 계산 및 객체 모델을 정의하지만 외부 데이터 입력과 결과 출력은 브라우저나 서버 런타임 같은 호스트 환경에서 제공한다. 따라서 실제 개발 환경은 언어 사양과 런타임, 표준 라이브러리, 호스트 API가 결합된 형태로 구성된다.
언어 처리계와 도구
언어 처리계에는 어휘 분석기와 파서, 의미 분석기, 컴파일러, 인터프리터, 최적화기, 코드 생성기, 어셈블러, 링커 및 로더가 포함될 수 있다. 실제 컴파일 도구 체인은 소스 코드를 분석하여 추상 구문 트리를 만들고, 이를 중간 표현으로 낮춘 뒤 최적화와 대상별 코드 생성을 수행하며, 생성된 오브젝트 파일과 라이브러리를 링크해 실행 가능한 프로그램을 만든다.
개발 환경에는 디버거와 정적 분석기, 포매터, 린터, 테스트 도구, 문서 생성기, 빌드 시스템 및 패키지 관리자가 포함된다. 이러한 도구는 언어의 문법적 일부는 아니지만 실제 사용성과 생태계의 안정성, 코드의 이식성과 재현성에 큰 영향을 준다.
언어, 사양과 구현체의 구분
프로그래밍 언어는 언어 사양과 구현체, 표준 라이브러리 및 개발 생태계로 나누어 이해할 필요가 있다. 언어 사양은 어떤 프로그램이 올바르며 각 프로그램이 어떤 의미를 가지는지 정의한다. 구현체는 해당 사양을 실제로 실행할 수 있도록 만든 컴파일러와 인터프리터, 가상 머신 등의 소프트웨어이다.
하나의 언어에 여러 구현체가 존재할 수 있으며, 각 구현체는 내부 구조와 성능, 실행 대상 및 부가 기능에서 차이를 보일 수 있다. Python은 CPython 외에도 JVM용 Jython, .NET용 IronPython, JIT 컴파일러를 사용하는 PyPy 등 여러 구현체가 있다. 반대로 하나의 실행 환경이 여러 언어를 지원할 수도 있다. JVM에서는 Java 외에 Kotlin과 Scala, Clojure 등을 실행할 수 있고, .NET에서는 C#과 F#, Visual Basic 등을 공통 중간 언어와 런타임 위에서 실행할 수 있다.
따라서 특정 프로그래밍 언어의 성격을 설명할 때는 단순히 “컴파일 언어”, “객체 지향 언어” 또는 “정적 타입 언어”라고 한 가지 이름만 붙이기보다, 언어가 제공하는 문법과 의미론, 타입 체계, 패러다임, 실행 모델, 메모리 관리, 동시성 구조 및 주요 구현체를 함께 살펴보아야 한다. 이러한 요소의 조합이 해당 언어로 표현할 수 있는 프로그램의 형태와 개발 방식, 안전성, 성능 및 사용 분야를 결정한다.
프로그래밍 패러다임
프로그래밍 패러다임은 프로그램과 계산을 바라보고 표현하는 기본적인 관점과 방법을 말한다. 어떤 패러다임은 프로그램을 메모리 상태를 변경하는 명령의 순서로 설명하고, 다른 패러다임은 함수의 평가, 객체 사이의 메시지 교환, 논리적 관계, 데이터 의존성 또는 사건의 흐름으로 설명한다. 패러다임은 프로그램을 어떤 단위로 나누고, 상태와 제어 흐름을 어디에 두며, 문제를 어떤 방식으로 추상화할지를 결정하는 설계 모델이다.
프로그래밍 언어와 프로그래밍 패러다임은 일대일로 대응하지 않는다. 하나의 언어가 여러 패러다임을 지원할 수 있고, 같은 패러다임도 언어마다 서로 다른 문법과 실행 모델로 구현될 수 있다. C는 주로 절차형·명령형 언어로 사용되지만 함수 포인터와 추상 자료형을 이용한 다른 구성 방식도 가능하다. C++는 절차형, 객체 지향, 일반화 프로그래밍과 함수형 표현을 함께 지원하며, Python과 JavaScript 역시 명령형 코드와 객체, 일급 함수, 고차 함수를 함께 사용할 수 있다. 따라서 특정 언어를 하나의 패러다임만으로 규정하기보다 어떤 패러다임을 중심으로 설계되었고 각 패러다임을 어느 수준까지 지원하는지를 살펴보는 편이 정확하다.
주요 프로그래밍 패러다임은 다음과 같이 정리할 수 있다.
| 패러다임 | 계산을 바라보는 중심 관점 | 대표적인 구성 요소 |
|---|---|---|
| 명령형 프로그래밍 | 상태를 변경하는 명령의 순서 | 변수, 대입, 조건문, 반복문 |
| 절차형 프로그래밍 | 명령을 호출 가능한 절차로 분해 | 함수, 프로시저, 서브루틴 |
| 객체 지향 프로그래밍 | 상태와 동작을 가진 객체의 협력 | 객체, 메시지, 클래스, 인터페이스 |
| 함수형 프로그래밍 | 함수의 평가와 조합 | 순수 함수, 고차 함수, 불변 값 |
| 논리 프로그래밍 | 사실과 규칙으로부터 결과를 추론 | 관계, 규칙, 질의, 통일 |
| 제약 프로그래밍 | 값이 만족해야 하는 조건을 기술 | 변수, 정의역, 제약식, 해 탐색 |
| 데이터 흐름 프로그래밍 | 데이터 의존성에 따라 연산 실행 | 노드, 연결, 토큰, 데이터 그래프 |
| 반응형 프로그래밍 | 시간에 따라 발생하는 변화의 전파 | 사건 스트림, 신호, 구독, 전파 |
| 동시성 프로그래밍 | 여러 계산의 진행과 상호 작용 | 스레드, 메시지, 채널, 액터 |
| 일반화 프로그래밍 | 타입과 구현에서 독립된 알고리즘 | 타입 매개변수, 제약 조건, 개념 |
| 관점 지향 프로그래밍 | 여러 모듈에 걸친 관심사의 분리 | 관점, 결합 지점, 조언, 위빙 |
이 분류들은 서로 배타적이지 않다. 함수형 프로그램이 동시에 선언형·동시성 프로그램일 수 있고, 객체 지향 언어 안에서 절차형 알고리즘과 일반화된 자료 구조를 사용할 수도 있다. 데이터 흐름, 반응형, 액터 모델과 같은 방식은 언어 전체의 패러다임으로 구현되기도 하지만 특정 라이브러리나 프레임워크가 제공하는 부분적인 프로그래밍 모델로 사용되기도 한다.
명령형 프로그래밍
명령형 프로그래밍은 프로그램을 컴퓨터의 상태를 변경하는 명령의 연속으로 표현한다. 프로그램은 현재 상태를 읽고, 계산 결과를 변수나 메모리에 기록하며, 조건에 따라 다음에 실행할 명령을 선택한다. 일반적인 명령형 프로그램은 변수, 대입문, 조건문, 반복문, 함수 호출과 입출력 명령으로 구성된다.
명령형 프로그래밍의 기본 모델은 메모리에서 값을 읽어 연산하고 다시 메모리에 기록하는 전통적인 저장 프로그램 컴퓨터의 구조와 밀접하게 연결되어 있다. 존 배커스는 1977년 튜링상 강연에서 기존의 명령형 언어가 저장소의 값을 한 번에 하나씩 변경하는 von Neumann식 계산 모델을 강하게 반영한다고 분석하고, 함수의 조합을 중심으로 하는 대안적인 프로그래밍 방식을 제안하였다. 이 논의는 명령형과 함수형 프로그래밍의 차이를 설명하는 대표적인 이론적 배경이 되었다.
다음 코드는 명령형 방식으로 합계를 계산하는 전형적인 구조를 보여준다.
int total = 0;
for (int i = 0; i < count; ++i) {
total += values[i];
}
이 코드에서 total과 i는 실행 과정에서 값이 계속 변경된다. 프로그램의 결과를 이해하려면 각 명령이 실행된 뒤 변수의 상태가 어떻게 달라지는지를 추적해야 한다.
명령형 프로그래밍은 하드웨어의 동작과 자연스럽게 대응하며 실행 순서, 메모리 사용과 부작용을 세밀하게 제어할 수 있다. 운영체제, 장치 드라이버, 게임 엔진, 임베디드 시스템과 고성능 소프트웨어처럼 실행 과정과 자원 관리가 중요한 분야에서 널리 사용된다. 반면 여러 위치에서 공유 상태를 변경하면 코드의 동작을 추론하기 어려워질 수 있으며, 동시 실행 환경에서는 변경 가능한 상태에 대한 경쟁 조건과 동기화 문제가 발생할 수 있다.
절차형 프로그래밍
절차형 프로그래밍은 명령형 프로그램을 함수, 프로시저 또는 서브루틴이라고 부르는 호출 가능한 단위로 나누는 방식이다. 각 절차는 특정 작업을 수행하며, 매개변수를 통해 값을 입력받고 결과를 반환하거나 프로그램 상태를 변경한다.
절차형 프로그래밍은 큰 문제를 여러 개의 작은 절차로 분해한다. 예를 들어 파일을 처리하는 프로그램은 파일 열기, 헤더 읽기, 데이터 해석, 결과 저장과 오류 처리 절차로 나눌 수 있다. 각 절차는 호출자에게 필요한 인터페이스만 제공하고 내부의 세부 구현을 숨길 수 있다.
C, Pascal, FORTRAN과 BASIC 등이 대표적인 절차형 언어로 분류된다. 현대의 객체 지향 언어와 다중 패러다임 언어에서도 메서드 또는 일반 함수 안의 알고리즘은 절차형 방식으로 작성되는 경우가 많다.
절차형 프로그래밍의 핵심은 단순히 함수를 사용하는 데 있지 않다. 프로그램의 실행 흐름을 명확한 절차의 호출 관계로 구성하고, 각 절차가 담당하는 작업과 입력·출력을 분리하는 데 있다. 적절한 절차 분해는 코드의 재사용성과 시험 가능성을 높이지만, 프로그램 전체가 공유하는 가변 상태에 지나치게 의존하면 절차 사이의 결합도가 높아질 수 있다.
구조적 프로그래밍
구조적 프로그래밍은 프로그램의 제어 흐름을 순차 실행, 선택과 반복처럼 명확한 구조로 구성하고, 임의의 위치로 이동하는 비구조적인 분기를 줄이려는 접근이다. 프로그램의 흐름을 중첩 가능한 블록과 절차로 표현함으로써 코드의 동작을 지역적으로 이해하고 검증하기 쉽게 만드는 것이 목적이다.
에츠허르 데이크스트라를 비롯한 연구자들은 프로그램 규모가 커질수록 실행 경로를 사람이 정확하게 추론할 수 있는 구조가 중요하다고 보았다. 데이크스트라의 《Notes on Structured Programming》은 프로그램을 단계적으로 분해하고 제한된 제어 구조로 구성하는 방법을 체계화한 주요 문헌이다.
구조적 프로그래밍은 흔히 goto 문의 사용을 피하는 방식으로만 설명되지만, 핵심은 특정 문법을 금지하는 데 있지 않다. 프로그램을 한 번에 이해할 수 있는 작은 단위로 나누고, 각 단위의 진입점과 종료점, 전제 조건과 결과를 명확하게 만드는 것이 본질이다. 현대 언어의 블록, 함수, 조건문, 반복문, 예외 처리 구조는 대부분 구조적 프로그래밍의 영향을 받았다.
객체 지향 프로그래밍
객체 지향 프로그래밍은 프로그램을 상태와 동작을 가진 객체들의 집합과 객체 사이의 상호 작용으로 구성하는 패러다임이다. 객체는 자신이 관리하는 데이터와 그 데이터에 적용되는 연산을 하나의 단위로 묶는다. 다른 객체는 공개된 인터페이스나 메시지를 통해 해당 객체와 상호 작용하며, 객체 내부의 구체적인 표현은 외부에서 직접 알 필요가 없도록 구성할 수 있다.
객체 지향 프로그래밍의 기원은 1960년대에 개발된 Simula에 있다. Simula는 이산 사건 시뮬레이션에서 현실의 행위자와 과정을 표현하기 위해 객체, 클래스와 상속에 해당하는 개념을 도입하였다. Simula 67은 클래스, 객체와 가상 프로시저 등 이후 객체 지향 언어의 중심이 된 개념들을 체계화하였다.
1970년대의 Smalltalk는 객체와 메시지 전달을 프로그램 전체의 중심 원리로 확장하였다. Smalltalk에서는 숫자, 컬렉션, 클래스와 개발 환경 자체를 포함한 거의 모든 요소를 객체로 다루며, 객체가 서로 메시지를 보내는 방식으로 계산을 표현한다. Smalltalk는 객체 지향 언어뿐 아니라 그래픽 사용자 인터페이스와 대화형 개발 환경의 발전에도 큰 영향을 미쳤다.
객체 지향 프로그래밍에서는 일반적으로 다음과 같은 개념이 중요하게 다루어진다.
- 객체는 프로그램 안에서 정체성과 상태, 동작을 가지는 단위이다.
- 캡슐화는 객체의 내부 상태와 구현을 외부 인터페이스로부터 분리한다.
- 메시지 전달 또는 메서드 호출은 객체가 다른 객체에 동작을 요청하는 방식이다.
- 다형성은 동일한 인터페이스를 통해 서로 다른 구현을 사용할 수 있게 한다.
- 상속은 기존 타입이나 클래스의 특성을 바탕으로 새로운 타입을 정의하는 방법이다.
- 합성은 여러 객체를 조합하여 더 큰 동작을 구성하는 방법이다.
모든 객체 지향 언어가 이 요소들을 같은 방식으로 제공하는 것은 아니다. Java와 C#은 명시적인 클래스와 인터페이스를 중심으로 하며, C++는 클래스와 값 타입, 템플릿을 함께 사용한다. JavaScript의 객체 모델은 전통적으로 프로토타입 연결을 기반으로 하며, 언어의 class 문법도 내부적으로 프로토타입 기반 동작 위에 구성된다. Go는 전통적인 클래스 상속을 제공하지 않지만 메서드와 인터페이스, 합성을 통해 객체 지향적인 설계를 지원한다.
객체 지향 설계에서 상속은 하나의 도구일 뿐 필수적인 중심 요소는 아니다. 객체의 책임을 명확히 나누고, 외부에 안정된 인터페이스를 제공하며, 객체 사이의 의존 관계를 관리하는 것이 더 근본적인 목적이다. 지나치게 깊은 상속 계층과 광범위한 가변 상태 공유는 프로그램의 결합도를 높일 수 있으므로, 현대 객체 지향 설계에서는 인터페이스와 합성, 불변 객체, 의존성 주입 등을 함께 사용하는 경우가 많다.
함수형 프로그래밍
함수형 프로그래밍은 계산을 함수의 평가와 조합으로 표현하는 패러다임이다. 함수는 단순히 명령을 묶은 절차가 아니라 다른 값과 마찬가지로 변수에 저장하고, 인수로 전달하며, 결과로 반환할 수 있는 일급 값으로 취급된다.
함수형 프로그래밍의 이론적 기반에는 알론조 처치가 정립한 람다 대수가 있다. 초기 LISP는 함수와 재귀, 리스트 처리를 중심으로 함수형 프로그래밍의 주요 개념을 실제 프로그래밍 언어에 도입하였다. 이후 ML 계열 언어는 정적 타입 추론과 대수적 자료형을 발전시켰고, Haskell은 순수 함수와 지연 평가, 타입 클래스를 중심으로 순수 함수형 언어의 대표적인 모델을 확립하였다. Haskell은 1980년대에 서로 다른 지연 평가 함수형 언어를 위한 공통 공개 언어가 필요하다는 논의에서 출발하였다.
함수형 프로그래밍에서 중요하게 다루는 개념은 다음과 같다.
- 일급 함수는 함수를 다른 값처럼 저장하고 전달할 수 있게 한다.
- 고차 함수는 함수를 인수로 받거나 함수 자체를 결과로 반환한다.
- 순수 함수는 외부 상태를 변경하지 않으며 같은 입력에 대해 같은 결과를 반환한다.
- 참조 투명성은 표현식을 그 결과값으로 바꾸어도 프로그램의 의미가 달라지지 않는 성질이다.
- 불변성은 이미 생성된 값을 변경하기보다 새로운 값을 생성하는 방식을 사용한다.
- 재귀는 반복적인 계산을 함수가 자신을 호출하는 구조로 표현한다.
- 함수 합성은 작은 함수를 결합하여 더 복잡한 계산을 만든다.
- 패턴 일치는 값의 구조에 따라 계산을 분기하고 내부 요소를 추출한다.
다음은 함수의 조합을 통해 컬렉션을 처리하는 전형적인 함수형 표현이다.
sumOfSquares =
sum
. map (\x -> x * x)
. filter even
이 표현은 컬렉션을 어떤 순서로 순회하고 임시 변수에 어떤 값을 대입할지를 직접 기술하지 않는다. 짝수를 선택하고, 각 값을 제곱한 뒤, 결과를 합산한다는 변환의 관계를 나타낸다.
순수 함수와 불변 값을 중심으로 프로그램을 구성하면 함수가 외부 상태에 미치는 영향을 줄일 수 있어, 코드의 결과를 지역적으로 추론하고 독립적으로 시험하기 쉬워진다. 공유되는 변경 가능 상태가 줄어들기 때문에 동시성 프로그램에서도 데이터 경쟁을 제한하는 데 유리하다.
다만 모든 함수형 언어가 순수 함수만을 허용하는 것은 아니다. LISP, OCaml, F#과 Scala는 함수형 기능과 가변 상태, 명령형 제어 구조를 함께 제공한다. Haskell도 입출력과 상태 변경을 표현할 수 있지만, 이러한 효과를 타입과 계산 구조를 통해 순수한 표현식과 구분한다. 함수형 프로그래밍은 부작용을 완전히 없애는 것보다 부작용이 발생하는 위치와 관계를 명확하게 통제하는 방향으로 이해할 수 있다.
함수형 언어는 평가 전략에 따라서도 차이가 있다. 엄격한 평가는 함수의 인수를 호출 전에 계산하며, 비엄격 또는 지연 평가는 실제 결과에 필요할 때까지 계산을 미룰 수 있다. 지연 평가는 무한한 자료 구조와 계산의 조합을 자연스럽게 표현할 수 있지만, 실제 평가 시점과 메모리 사용을 예측하기 어려운 경우도 있다.
선언형 프로그래밍
선언형 프로그래밍은 계산을 수행할 구체적인 명령 순서를 직접 나열하기보다 원하는 결과, 관계 또는 제약 조건을 기술하는 접근을 포괄한다. 명령형 프로그램이 “어떤 순서로 계산할 것인가”를 중심으로 한다면 선언형 프로그램은 “어떤 결과가 성립해야 하는가”를 중심으로 한다.
선언형 프로그래밍은 하나의 단일한 실행 모델을 가리키지 않는다. 함수형 프로그래밍, 논리 프로그래밍, 제약 프로그래밍, 규칙 기반 프로그래밍과 데이터 질의 언어를 넓은 의미의 선언형 패러다임에 포함할 수 있다. 다만 함수형 언어 안에서도 명령형 기능을 사용할 수 있고, 선언형 언어의 실행기는 내부적으로 복잡한 명령형 알고리즘을 사용하므로 선언형과 명령형의 경계가 항상 명확한 것은 아니다.
SQL 질의는 선언형 프로그래밍의 대표적인 사례이다.
SELECT name
FROM users
WHERE active = TRUE
ORDER BY name;
이 질의는 데이터 파일을 어느 순서로 읽고 어떤 인덱스를 사용하며, 정렬과 필터링을 어떤 알고리즘으로 수행할지를 지정하지 않는다. 사용자는 원하는 결과의 형태를 기술하고, 데이터베이스 관리 시스템의 질의 최적화기가 실제 실행 계획을 결정한다.
선언형 프로그램은 실행 방법의 세부 사항을 시스템에 맡김으로써 표현을 간결하게 만들고, 실행기가 상황에 따라 다른 최적화 전략을 선택할 수 있게 한다. 반면 실행 과정이 구현체에 의해 결정되므로 성능 문제를 분석할 때 내부 실행 계획과 최적화 규칙을 별도로 이해해야 할 수 있다.
논리 프로그래밍
논리 프로그래밍은 프로그램을 사실과 규칙, 질의의 집합으로 표현한다. 프로그래머는 어떤 관계가 참인지를 기술하고, 실행기는 논리적 추론을 통해 질의를 만족하는 해를 찾는다.
로버트 코왈스키는 알고리즘을 논리와 제어의 결합으로 설명하였다. 논리는 문제에서 성립해야 할 관계와 의미를 나타내고, 제어는 그 관계를 어떤 순서와 방법으로 탐색할지를 결정한다. 이 구분은 논리 프로그래밍에서 문제의 의미와 실행 전략을 분리하여 이해하는 데 중요한 기반이 되었다.
Prolog는 대표적인 논리 프로그래밍 언어이다. Prolog 프로그램은 사실과 규칙을 정의한 뒤 질의를 수행한다.
parent(alice, bob).
parent(bob, carol).
ancestor(X, Y) :-
parent(X, Y).
ancestor(X, Y) :-
parent(X, Z),
ancestor(Z, Y).
여기서 ancestor 규칙은 특정 계산 순서를 일반적인 명령문으로 작성하지 않고 부모와 조상 사이의 관계를 정의한다. 실행기는 통일과 역추적을 사용하여 조건을 만족하는 변수의 값을 탐색한다.
논리 프로그래밍은 자연어 처리와 지식 표현, 정리 증명, 전문가 시스템, 프로그램 분석과 규칙 기반 시스템에 사용되어 왔다. Datalog와 같은 제한된 논리 언어는 데이터베이스 질의와 정적 분석, 접근 제어 정책과 분산 시스템의 관계 표현에도 활용된다.
제약 프로그래밍
제약 프로그래밍은 변수의 값을 직접 계산하는 절차를 작성하기보다, 변수들이 만족해야 하는 조건을 기술하고 제약 해결기가 가능한 해를 탐색하도록 하는 패러다임이다.
예를 들어 일정 계획 문제에서는 각 작업의 시작 시각과 종료 시각을 변수로 두고 다음과 같은 제약을 정의할 수 있다.
- 작업 A는 작업 B보다 먼저 완료되어야 한다.
- 두 작업은 같은 자원을 동시에 사용할 수 없다.
- 모든 작업은 지정된 마감 시각 이전에 끝나야 한다.
- 특정 작업은 허용된 시간 구간 안에서만 실행할 수 있다.
제약 해결기는 각 변수의 가능한 값인 정의역을 줄이고, 제약을 전파하며, 필요할 경우 후보를 선택하고 되돌아가는 탐색을 수행한다. 제약 프로그래밍은 일정 계획과 자원 배치, 조합 최적화, 회로 설계, 구성 문제와 검증 등에 적합하다.
논리 프로그래밍과 제약 프로그래밍을 결합한 제약 논리 프로그래밍에서는 논리적 관계와 수치·집합·시간 제약을 하나의 프로그램 안에서 함께 표현할 수 있다.
규칙 기반 프로그래밍
규칙 기반 프로그래밍은 특정 조건이 성립할 때 수행할 동작을 규칙으로 표현한다. 일반적인 규칙은 조건부와 결론부로 구성된다.
조건:
사용자 계정이 잠겨 있고
마지막 인증 실패 후 30분이 지났다.
동작:
계정 잠금을 해제한다.
규칙 엔진은 현재 사실과 상태를 검사하여 적용 가능한 규칙을 찾고, 우선순위와 충돌 해결 정책에 따라 규칙을 실행한다. 규칙 기반 방식은 전문가 시스템과 업무 정책, 접근 제어, 이벤트 처리 및 게임의 행동 시스템 등에 사용된다.
규칙 기반 프로그래밍은 논리 프로그래밍과 유사하게 보이지만 반드시 형식 논리의 추론 모델을 사용하는 것은 아니다. 일부 규칙 시스템은 상태를 직접 변경하는 명령형 동작을 포함하며, 규칙의 실행 순서가 결과에 영향을 미칠 수도 있다.
일반화 프로그래밍
일반화 프로그래밍은 특정 자료형이나 구현에 종속되지 않는 알고리즘과 자료 구조를 작성하고, 해당 알고리즘이 요구하는 연산과 성질을 명시하는 패러다임이다.
예를 들어 정렬 알고리즘은 정수 배열만을 위한 코드가 아니라 비교와 교환이 가능한 여러 자료형에 적용될 수 있도록 작성할 수 있다. 알고리즘이 요구하는 것은 구체적인 타입 이름이 아니라 원소를 비교하고 이동할 수 있다는 조건이다.
일반화 프로그래밍은 1980년대 데이비드 무서와 알렉산더 스테파노프의 연구를 통해 체계화되었다. 이후 C++ Standard Template Library는 반복자와 컨테이너, 알고리즘을 분리하면서도 타입 매개변수와 컴파일 시 다형성을 통해 서로 조합할 수 있는 대표적인 일반화 프로그래밍 체계를 제공하였다. 일반화 프로그래밍에서는 추상화된 알고리즘이 구체 타입에 대해 불필요한 실행 비용 없이 특수화될 수 있는지도 중요하게 다룬다.
언어에 따라 일반화 프로그래밍을 표현하는 방법은 다르다.
- C++는 템플릿과 concepts를 사용한다.
- Java와 C#은 제네릭 타입과 제약 조건을 사용한다.
- Rust는 제네릭과 trait bound를 사용한다.
- Haskell은 매개변수 다형성과 타입 클래스를 사용한다.
- Go는 타입 매개변수와 인터페이스 제약을 사용한다.
- ML 계열 언어는 다형적 함수와 모듈 시스템을 사용한다.
일반화 프로그래밍은 단순히 코드에서 타입 이름을 매개변수로 치환하는 기법이 아니다. 여러 타입과 알고리즘 사이에서 공통으로 성립하는 개념을 발견하고, 그 개념을 최소한의 요구 조건으로 표현하는 설계 방식이다.
데이터 흐름 프로그래밍
데이터 흐름 프로그래밍은 프로그램을 연산 노드와 그 사이를 연결하는 데이터 의존성 그래프로 표현한다. 각 연산은 필요한 입력이 준비되면 실행되며, 계산 결과는 연결된 다음 연산으로 전달된다.
전통적인 명령형 프로그램은 프로그램 카운터가 가리키는 명령을 순서대로 실행하지만, 데이터 흐름 모델에서는 데이터의 준비 여부가 실행 가능성을 결정한다. 서로 의존하지 않는 연산은 동시에 실행될 수 있으므로 데이터 흐름 모델은 병렬 계산을 표현하는 자연스러운 기반이 된다. Jack Dennis의 데이터 흐름 아키텍처 연구는 명령 순서보다 데이터 의존성에 따라 연산을 활성화하는 계산 모델을 구체화하였다.
데이터 흐름 프로그램은 다음과 같은 형태로 볼 수 있다.
입력 A ─┐
├─ 곱셈 ─┐
입력 B ─┘ │
├─ 덧셈 ─ 결과
입력 C ──────────┘
곱셈은 A와 B가 모두 준비되면 실행되고, 덧셈은 곱셈 결과와 C가 준비되면 실행된다. 프로그램의 제어 흐름이 별도로 명시되지 않고 데이터 의존 관계에서 파생된다.
데이터 흐름 방식은 그래픽 프로그래밍 환경과 디지털 신호 처리, 스트리밍 시스템, 빌드 시스템, 스프레드시트, GPU 연산 그래프와 머신러닝 프레임워크에서 널리 사용된다. 다만 반복, 조건 분기와 변경 가능한 상태를 데이터 흐름 그래프 안에서 표현하려면 별도의 노드와 토큰, 제어 의존성 또는 상태 관리 구조가 필요하다.
사건 기반 프로그래밍
사건 기반 프로그래밍은 프로그램의 실행 흐름이 사용자 입력, 네트워크 메시지, 타이머, 파일 변경과 센서 신호 같은 사건에 의해 결정되는 방식이다. 프로그램은 특정 사건에 대응하는 이벤트 처리기 또는 콜백을 등록하고, 실행 환경은 사건이 발생했을 때 해당 처리기를 호출한다.
그래픽 사용자 인터페이스는 대표적인 사건 기반 시스템이다. 프로그램은 사용자가 언제 버튼을 누를지 직접 제어하지 않으며, 버튼 클릭 사건이 발생했을 때 실행할 동작을 정의한다.
button.addEventListener("click", () => {
saveDocument();
});
사건 기반 프로그램은 외부 입력에 반응하는 서버와 사용자 인터페이스, 게임, 임베디드 시스템 및 분산 서비스에 적합하다. 그러나 콜백이 여러 단계로 중첩되고 서로 공유 상태를 변경하면 실행 순서와 오류 전파를 이해하기 어려워질 수 있다. Promise, future, 코루틴과 async·await는 비동기 사건의 연결과 결과 전달을 보다 구조적으로 표현하기 위해 사용된다.
사건 기반 프로그래밍과 반응형 프로그래밍은 밀접하지만 같은 개념은 아니다. 사건 기반 프로그램은 일반적으로 사건이 발생하면 등록된 처리기를 명령형으로 실행한다. 반응형 프로그램은 시간에 따라 변하는 값과 사건 사이의 의존 관계를 선언하고, 변화가 발생했을 때 그 관계를 따라 값이 전파되도록 구성하는 데 더 큰 비중을 둔다.
반응형 프로그래밍
반응형 프로그래밍은 시간에 따라 발생하는 값과 사건의 흐름을 표현하고, 입력의 변화가 관련 계산에 자동으로 전파되도록 구성하는 패러다임이다. 값의 연속적인 변화나 비동기 사건을 스트림 또는 신호로 모델링하며, 필터링과 변환, 결합 등의 연산으로 새로운 흐름을 만든다.
스프레드시트는 반응형 계산의 단순한 예로 볼 수 있다. 한 셀의 값이 바뀌면 그 셀을 참조하는 수식의 결과가 자동으로 다시 계산된다. 프로그래머는 각 셀을 갱신할 명령 순서를 직접 작성하지 않고 값 사이의 의존 관계를 정의한다.
함수형 반응형 프로그래밍은 함수형 프로그래밍과 반응형 계산을 결합한다. 초기 FRP 연구에서는 시간에 따라 연속적으로 변하는 동작과 불연속적으로 발생하는 사건을 합성 가능한 추상화로 표현하였다. 이후 FRP는 사용자 인터페이스와 애니메이션, 로봇 제어 및 상호 작용 시스템을 선언적으로 구성하는 방법으로 발전하였다.
현대의 반응형 라이브러리는 일반적으로 다음과 같은 연산을 제공한다.
map은 각 사건이나 값을 다른 값으로 변환한다.filter는 조건을 만족하는 사건만 통과시킨다.merge는 여러 흐름을 하나로 결합한다.combine은 여러 최신 값을 바탕으로 새로운 값을 만든다.debounce는 짧은 시간 동안 반복되는 사건을 하나로 줄인다.buffer는 일정 기간이나 개수의 사건을 모아 처리한다.subscribe는 흐름의 결과를 받을 처리기를 연결한다.
반응형 프로그램에서는 생산자가 소비자보다 빠르게 데이터를 생성할 때 처리 속도를 조절하는 배압, 구독의 수명, 오류 전파와 실행 스케줄러를 함께 관리해야 한다.
동시성 프로그래밍
동시성 프로그래밍은 둘 이상의 계산이 같은 시간 구간 동안 독립적으로 진행되는 프로그램을 구성하는 패러다임이다. 여러 작업이 실제로 같은 순간에 실행될 필요는 없으며, 하나의 프로세서에서 번갈아 실행되더라도 서로 겹치는 생명 주기를 가지면 동시성이 존재한다.
병렬 프로그래밍은 둘 이상의 계산을 실제로 동시에 실행하여 처리량이나 계산 속도를 높이는 데 초점을 둔다. 분산 프로그래밍은 서로 독립된 메모리와 장애 영역을 가진 여러 컴퓨터가 네트워크를 통해 협력하는 시스템을 다룬다.
세 개념은 서로 관련되지만 동일하지 않다.
| 개념 | 중심 문제 |
|---|---|
| 동시성 | 여러 작업을 올바르게 구성하고 조정하는 방법 |
| 병렬성 | 여러 계산 자원을 이용해 동시에 실행하는 방법 |
| 분산성 | 네트워크와 부분 장애가 존재하는 여러 시스템을 조정하는 방법 |
동시성 모델은 상태를 공유하는지, 통신을 어떤 방식으로 수행하는지, 작업의 실행 순서를 어떻게 정의하는지에 따라 여러 종류로 나뉜다.
공유 메모리와 스레드
공유 메모리 모델에서는 여러 스레드가 같은 메모리의 값에 접근한다. 스레드 사이의 통신은 공유 변수와 자료 구조를 통해 이루어지며, 동시에 같은 상태를 수정할 때 발생하는 데이터 경쟁을 막기 위해 잠금과 세마포어, 조건 변수, 원자적 연산 등을 사용한다.
공유 메모리는 데이터를 직접 전달하지 않아도 되므로 효율적인 통신이 가능하지만, 프로그램의 결과가 미세한 실행 순서에 따라 달라질 수 있다. 교착 상태와 라이브락, 우선순위 역전, 데이터 경쟁 및 메모리 가시성 문제를 피하려면 언어와 실행 환경이 정의하는 메모리 모델을 이해해야 한다.
Communicating Sequential Processes
Communicating Sequential Processes는 독립적으로 실행되는 순차 프로세스들이 명시적인 통신을 통해 상호 작용하는 모델이다. 토니 호어는 1978년 논문에서 입출력과 프로세스 사이의 동기화된 통신을 병렬 프로그램의 기본 구성 요소로 제안하였다.
CSP에서는 각 프로세스가 자신의 지역 상태를 관리하고 채널을 통해 값을 주고받는다. 통신은 단순한 데이터 전달뿐 아니라 프로세스 사이의 동기화 역할도 수행한다.
Go의 고루틴과 채널은 CSP의 영향을 받은 대표적인 프로그래밍 모델이다. 다만 Go의 실행 모델과 채널은 원래의 형식 CSP를 그대로 구현한 것은 아니며, 실제 시스템 프로그래밍에 맞춘 언어 기능과 런타임 스케줄링을 제공한다.
액터 모델
액터 모델은 동시 시스템을 서로 독립적인 액터들의 집합으로 표현한다. 각 액터는 메시지를 받으면 다음과 같은 동작을 수행할 수 있다.
- 자신의 내부 상태를 변경한다.
- 다른 액터에 메시지를 보낸다.
- 새로운 액터를 생성한다.
- 다음 메시지를 처리할 동작을 결정한다.
액터는 다른 액터의 내부 상태에 직접 접근하지 않고 비동기 메시지를 통해 통신한다. 액터 모델은 칼 휴잇, 피터 비숍과 리처드 스타이거가 1973년에 제안한 동시 계산 모델에서 출발하였다.
Erlang과 Elixir의 프로세스 모델, Akka와 여러 분산 액터 프레임워크가 액터 개념의 영향을 받았다. 액터의 격리된 상태와 메시지 전달은 공유 메모리 경쟁을 줄이고 분산 시스템의 구성 요소를 표현하기에 적합하다.
그러나 메시지의 전달 지연과 순서, 중복, 손실 및 액터 장애를 처리해야 하며, 여러 액터에 걸친 일관된 상태 변경은 별도의 프로토콜을 필요로 한다. 액터를 사용한다고 해서 분산 시스템의 장애와 일관성 문제가 자동으로 해결되는 것은 아니다.
코루틴과 구조화된 동시성
코루틴은 실행을 일시 중단하고 이후 같은 위치에서 다시 시작할 수 있는 계산 단위이다. 생성기와 비동기 함수, 협력적 태스크를 구현하는 데 사용되며, 운영체제 스레드보다 가벼운 실행 단위로 제공되는 경우가 많다.
async와 await는 비동기 작업을 일반적인 순차 코드와 유사한 구조로 표현한다. 함수가 입출력 결과를 기다리는 동안 실행을 중단하고 실행기 또는 이벤트 루프에 제어를 반환할 수 있다.
구조화된 동시성은 생성된 동시 작업의 수명을 코드의 블록과 호출 구조에 연결하려는 접근이다. 부모 작업이 자식 작업의 완료와 실패, 취소를 관리하도록 하여 프로그램 밖에 남는 작업과 추적하기 어려운 비동기 실행을 줄인다.
코루틴은 그 자체로 병렬성을 보장하지 않는다. 하나의 스레드에서 여러 코루틴을 협력적으로 실행할 수도 있고, 여러 스레드나 작업 실행기 위에서 코루틴을 병렬로 실행할 수도 있다.
관점 지향 프로그래밍
관점 지향 프로그래밍은 여러 모듈과 클래스에 반복적으로 걸쳐 나타나는 횡단 관심사를 별도의 단위로 분리하려는 패러다임이다. 로깅과 권한 검사, 트랜잭션, 성능 측정, 오류 복구처럼 프로그램의 여러 부분에 공통으로 삽입되는 동작이 대표적인 횡단 관심사이다.
일반적인 객체 지향 또는 절차형 구조에서는 이러한 기능이 여러 함수와 클래스에 반복되어 핵심 업무 로직과 뒤섞일 수 있다. 관점 지향 프로그래밍은 횡단 관심사를 관점으로 정의하고, 프로그램의 특정 결합 지점에 해당 동작을 적용한다. Gregor Kiczales 등의 연구는 관점 지향 프로그래밍을 관심사 분리를 개선하기 위한 방법으로 체계화하였다.
주요 개념은 다음과 같다.
- 결합 지점은 함수 호출이나 객체 생성처럼 부가 동작을 적용할 수 있는 프로그램의 지점이다.
- 포인트컷은 어떤 결합 지점을 선택할지 지정한다.
- 조언은 선택된 지점의 이전, 이후 또는 주위에서 실행할 동작이다.
- 위빙은 관점의 동작을 대상 프로그램에 결합하는 과정이다.
관점은 핵심 로직과 반복되는 부가 기능을 분리할 수 있지만, 소스 코드에 직접 나타나지 않은 동작이 다른 위치에서 삽입되므로 실제 제어 흐름을 추적하기 어려워질 수 있다. 현대 언어와 프레임워크에서는 관점 지향 언어 전체를 사용하기보다 속성, 애너테이션, 데코레이터, 프록시, 미들웨어와 컴파일러 플러그인으로 유사한 기능을 제공하기도 한다.
메타프로그래밍
메타프로그래밍은 프로그램을 데이터처럼 분석하거나 생성하고 변환하는 프로그램을 작성하는 방식이다. 독립된 핵심 패러다임이라기보다 여러 패러다임에서 사용할 수 있는 프로그램 구성 기법으로 보는 경우가 많다.
메타프로그래밍은 다음과 같은 형태로 나타난다.
- 전처리 매크로가 소스 코드를 치환하거나 생성한다.
- 구문 매크로가 추상 구문 구조를 변환한다.
- 템플릿이 타입과 상수를 바탕으로 코드를 생성한다.
- 리플렉션이 실행 중 프로그램의 타입과 구조를 조사한다.
- 코드 생성기가 스키마나 인터페이스 정의에서 소스 코드를 만든다.
- 컴파일러 플러그인이 선언과 표현식을 분석하거나 수정한다.
- 부분 평가가 알려진 입력을 미리 계산하여 프로그램을 특수화한다.
LISP 계열 언어의 매크로는 프로그램의 구문 구조를 언어 자체의 자료 구조로 다룰 수 있게 한다. C++의 템플릿은 일반화 프로그래밍뿐 아니라 컴파일 시 계산과 코드 생성에도 사용된다. Rust의 매크로는 패턴 기반 선언 매크로와 구문 트리를 처리하는 절차적 매크로를 제공한다.
메타프로그래밍은 반복 코드를 줄이고 언어에 새로운 추상화를 추가할 수 있지만, 생성된 코드와 실제 실행 흐름이 원본 소스에서 명확히 드러나지 않을 수 있다. 따라서 오류 메시지와 디버깅, 도구 지원, 빌드 시간과 코드 가독성을 함께 고려해야 한다.
다중 패러다임 프로그래밍
현대의 범용 프로그래밍 언어는 대체로 여러 패러다임을 함께 지원한다. 하나의 응용 프로그램 안에서도 문제의 성격에 따라 서로 다른 패러다임을 결합할 수 있다.
예를 들어 게임 엔진은 다음과 같은 방식으로 구성할 수 있다.
- 렌더링 명령 생성과 장치 제어에는 명령형 프로그래밍을 사용한다.
- 게임 시스템과 도구의 구성 요소에는 객체 지향 또는 데이터 지향 설계를 사용한다.
- 컬렉션 변환과 에셋 처리에는 함수형 연산을 사용한다.
- 작업 스케줄러에는 데이터 흐름과 동시성 모델을 사용한다.
- 셰이더 그래프에는 데이터 흐름 프로그래밍을 사용한다.
- 편집기 인터페이스에는 사건 기반·반응형 프로그래밍을 사용한다.
- 직렬화와 코드 생성을 위해 메타프로그래밍을 사용한다.
다중 패러다임 프로그래밍의 목적은 가능한 모든 방식을 한 프로그램에 사용하는 데 있지 않다. 각 문제의 구조를 가장 분명하게 표현하는 모델을 선택하고, 서로 다른 모델 사이의 경계를 명확히 만드는 데 있다.
패러다임을 조합할 때는 상태와 데이터의 소유권, 오류 전파, 동시성 경계, 호출 방향과 추상화 수준을 일관되게 유지해야 한다. 객체 지향 계층 안에 함수형 변환을 사용할 수 있지만, 객체의 내부 상태를 예측할 수 없는 방식으로 변경하면 두 모델의 장점을 모두 잃을 수 있다. 반응형 스트림을 명령형 콜백과 결합할 때도 구독의 생성과 해제, 실행 순서 및 오류 처리 규칙을 명확하게 정해야 한다.
패러다임과 소프트웨어 설계
프로그래밍 패러다임은 특정한 문법을 선택하는 문제만이 아니다. 프로그램을 어떤 단위로 분해하고 각 단위 사이의 관계를 어떻게 정의할지에 영향을 준다.
명령형 패러다임에서는 상태 변화와 명령 순서가 중심이 된다. 객체 지향 패러다임에서는 객체의 책임과 협력이 중심이 된다. 함수형 패러다임에서는 값의 변환과 함수의 합성이 중심이 된다. 논리·제약 패러다임에서는 성립해야 할 관계와 조건이 중심이 된다. 데이터 흐름 패러다임에서는 연산 사이의 의존성이 중심이 되고, 동시성 패러다임에서는 독립적인 작업 사이의 통신과 동기화가 중심이 된다.
같은 문제도 패러다임에 따라 서로 다르게 표현할 수 있다. 컬렉션에서 조건을 만족하는 값을 변환하여 합산하는 작업은 다음과 같이 볼 수 있다.
명령형:
요소를 순회하면서 조건을 검사하고
변수를 갱신한다.
함수형:
filter, map, reduce를 합성한다.
객체 지향:
컬렉션 객체에 변환과 집계 동작을 요청한다.
논리형:
결과에 포함될 요소의 관계와 조건을 기술한다.
데이터 흐름형:
필터, 변환, 집계 노드를 연결한다.
병렬형:
입력을 분할하여 여러 작업에서 계산한 뒤 결과를 결합한다.
어느 표현이 항상 우월한 것은 아니다. 입력의 크기와 변경 빈도, 성능 요구, 오류 처리, 팀의 경험, 기존 코드와 실행 환경에 따라 적합한 방식이 달라진다.
프로그래밍 패러다임은 서로를 완전히 대체하는 단계적인 발전 순서도 아니다. 절차형 언어가 객체 지향 언어의 등장으로 사라진 것이 아니며, 함수형 프로그래밍이 명령형 프로그래밍을 전면적으로 대체한 것도 아니다. 새로운 패러다임은 기존 방식으로 표현하기 어려웠던 문제를 다른 관점에서 다룰 수 있게 하며, 기존 패러다임과 결합되어 현대 언어와 소프트웨어 설계에 흡수된다.
따라서 패러다임을 이해한다는 것은 언어별 문법을 암기하는 것보다 넓은 의미를 가진다. 같은 계산을 여러 방식으로 모델링하고, 각 방식에서 상태와 제어, 데이터, 추상화와 동시성이 어떻게 표현되는지 비교할 수 있어야 한다. 이러한 이해는 특정 언어에 종속되지 않고 문제에 적합한 프로그램 구조와 언어 기능을 선택하는 기반이 된다.
실행과 구현
프로그래밍 언어로 작성된 소스 코드는 그 자체로 중앙처리장치가 실행할 수 있는 명령이 아니다. 언어 구현체는 소스 코드를 읽고 문법과 의미를 분석한 뒤, 해당 프로그램을 기계어, 바이트코드, 중간 표현 또는 인터프리터가 처리할 수 있는 내부 구조로 변환한다. 변환된 프로그램은 운영체제의 로더나 가상 머신, 언어 런타임의 도움을 받아 실제로 실행된다.
이 과정은 언어와 구현체에 따라 크게 달라진다. C와 C++의 일반적인 구현은 소스 코드를 오브젝트 파일로 컴파일하고 이를 링크하여 네이티브 실행 파일을 만든다. Java와 Kotlin은 일반적으로 JVM 바이트코드를 생성하며, C#과 F#은 .NET의 공통 중간 언어를 사용한다. CPython은 Python 소스 코드를 내부 바이트코드가 담긴 코드 객체로 변환하여 가상 머신에서 실행한다. JavaScript 엔진은 소스 코드를 파싱한 뒤 인터프리터와 JIT 컴파일러를 함께 사용하는 경우가 많다. 따라서 소스 코드에서 실행까지의 과정은 하나의 고정된 절차가 아니라 여러 단계를 선택하고 조합할 수 있는 구현 체계이다.
전통적인 네이티브 컴파일 과정은 다음과 같이 요약할 수 있다.
소스 코드
↓
전처리
↓
어휘 분석
↓
토큰
↓
구문 분석
↓
추상 구문 트리
↓
의미 분석과 타입 검사
↓
중간 표현
↓
최적화
↓
대상 코드 생성
↓
어셈블리 또는 기계어
↓
오브젝트 파일
↓
링크
↓
실행 파일 또는 라이브러리
↓
운영체제 로더와 런타임
↓
실행
모든 구현체가 이 단계를 같은 순서로 명확히 분리하는 것은 아니다. 전처리기가 없는 언어도 있고, 추상 구문 트리에서 직접 바이트코드를 생성하는 구현체도 있으며, 소스 코드를 실행 중에 분석하거나 중간 표현을 여러 단계로 나누는 컴파일러도 있다. Clang의 드라이버 역시 전처리, 컴파일, 어셈블, 링크를 개념적으로 구분하지만 실제 실행에서는 일부 단계를 하나의 프로세스 안에서 통합하거나 파이프와 임시 파일로 연결할 수 있다.
소스 코드와 번역 단위
소스 코드는 프로그래머가 특정 프로그래밍 언어의 문법에 따라 작성한 프로그램의 원문이다. 일반적으로 하나 이상의 파일로 구성되며, 각 파일은 문자 인코딩과 줄바꿈, 파일 경로, 모듈 또는 패키지 구조와 함께 처리된다.
컴파일러는 전체 프로젝트를 반드시 한 번에 읽는 것은 아니다. C와 C++에서는 전처리가 끝난 하나의 소스 파일을 번역 단위로 처리하는 전통적인 방식이 널리 사용된다. 여러 번역 단위에서 생성된 오브젝트 파일은 이후 링커에 의해 하나의 실행 파일이나 라이브러리로 결합된다. Java에서는 하나의 class 파일이 일반적으로 하나의 클래스 또는 인터페이스의 이진 표현을 담으며, JVM은 필요한 클래스를 실행 중에 개별적으로 불러올 수 있다.
현대 언어의 모듈 시스템은 소스 파일과 논리적 프로그램 단위를 반드시 동일하게 취급하지 않는다. 하나의 모듈이 여러 파일로 구성될 수도 있고, 하나의 파일에 여러 타입과 선언이 들어갈 수도 있다. 컴파일러는 모듈 간 의존 관계를 분석하여 처리 순서를 결정하거나, 공개 인터페이스만 별도의 중간 파일로 저장하여 다른 모듈을 다시 분석하는 비용을 줄일 수 있다.
전처리
전처리는 본격적인 구문 분석 전에 소스 코드의 일부를 변환하는 단계이다. 모든 언어가 별도의 전처리기를 사용하는 것은 아니지만, C와 C++에서는 전처리 지시문과 매크로가 언어 처리 과정에서 중요한 위치를 차지한다.
C 계열 전처리기는 일반적으로 다음 작업을 수행한다.
#include지시문으로 지정된 파일의 내용을 포함한다.#define으로 정의된 매크로를 확장한다.- 조건부 컴파일 지시문에 따라 일부 코드를 포함하거나 제외한다.
- 구현체가 제공하는 미리 정의된 매크로를 치환한다.
- 논리적인 소스 위치와 진단 정보를 추적한다.
전처리 결과는 원래 여러 파일로 나뉘어 있던 소스와 헤더가 합쳐지고 매크로가 확장된 토큰의 연속으로 볼 수 있다. 이후 컴파일러의 파서와 의미 분석기는 이 결과를 대상으로 작업한다. 실제 Clang은 전처리와 파싱을 내부적으로 긴밀하게 결합할 수 있으므로, 반드시 완전한 전처리 결과 파일을 디스크에 만든 뒤 다음 단계를 실행하는 것은 아니다.
전처리 매크로는 언어의 일반적인 구문 구조를 이해하지 않고 토큰을 변환한다. 이 때문에 매크로에서 생성된 코드의 범위와 타입, 평가 순서가 예상과 다르게 나타날 수 있다. 현대 언어들은 전처리기 대신 모듈, 상수 표현식, 제네릭, 속성, 구문 매크로와 컴파일러 플러그인 등 언어 구조를 이해하는 방식으로 유사한 기능을 제공하기도 한다.
어휘 분석
어휘 분석은 소스 코드의 문자들을 언어가 의미를 부여할 수 있는 토큰의 연속으로 변환하는 과정이다. 어휘 분석을 담당하는 구성 요소는 렉서 또는 토크나이저라고 한다.
다음과 같은 소스 코드가 있다고 가정할 수 있다.
int result = value + 10;
렉서는 이를 대략 다음과 같은 토큰으로 구분한다.
키워드(int)
식별자(result)
연산자(=)
식별자(value)
연산자(+)
정수 리터럴(10)
구분자(;)
렉서는 식별자와 키워드, 숫자 및 문자열 리터럴, 연산자, 구분자와 주석을 구분한다. 또한 소스 코드의 문자 인코딩을 해석하고, 이스케이프 시퀀스와 줄 연결, 공백의 의미 등을 처리할 수 있다. Python에서는 들여쓰기의 증감이 INDENT와 DEDENT 토큰으로 변환되어 블록 구조를 나타내므로 공백이 문법적인 의미를 가진다. Python 공식 문서도 프로그램이 파서에 전달되기 전에 어휘 분석기가 입력을 토큰 스트림으로 변환한다고 설명한다.
토큰에는 종류와 원문뿐 아니라 파일명, 행과 열, 시작·종료 위치 등의 정보가 함께 저장될 수 있다. 이러한 위치 정보는 오류 메시지와 코드 탐색, 리팩터링, 디버깅 정보 생성에 사용된다.
구문 분석
구문 분석은 토큰의 배열이 언어의 문법에 맞는지 검사하고, 토큰 사이의 구조적 관계를 구성하는 과정이다. 이를 수행하는 구성 요소를 파서라고 한다.
예를 들어 다음 표현식은 연산자 우선순위에 따라 서로 다른 구조를 가진다.
a + b * c
파서는 이를 단순히 왼쪽부터 나열된 다섯 개의 토큰으로 보지 않고 다음과 같은 계층 구조로 해석한다.
덧셈
├── a
└── 곱셈
├── b
└── c
곱셈이 덧셈보다 먼저 수행된다는 규칙은 구문 구조에 반영된다. 괄호와 연산자 결합 방향, 함수 호출, 조건문, 반복문, 선언문과 타입 표현 역시 파서가 문법에 따라 구조화한다.
파서는 재귀 하강 파싱, LR, LALR, LL, PEG 등 여러 방식으로 구현할 수 있다. 일부 파서는 문법 정의에서 자동으로 생성되고, 다른 파서는 구현자가 직접 작성한다. Clang의 파서는 렉서에서 토큰을 받아 소스의 문법적 구조를 확인하고 의미 분석과 함께 AST를 생성한다.
소스가 문법에 맞지 않으면 파서는 구문 오류를 보고한다. 실용적인 파서는 첫 번째 오류에서 즉시 중단하기보다 오류가 발생한 위치를 추정하고 다음 문장이나 선언까지 복구하여, 한 번의 컴파일에서 여러 오류를 표시하려고 한다. 다만 잘못된 토큰 이후의 구조는 불완전하게 추정될 수 있으므로 첫 오류가 뒤따르는 여러 진단의 원인이 되기도 한다.
추상 구문 트리
추상 구문 트리는 프로그램의 문법적 구조를 트리 형태로 나타내는 내부 표현이다. 일반적으로 AST라고 줄여 부른다.
AST는 원본 소스의 모든 문자를 그대로 보존하는 구체 구문 트리와 다르다. 괄호나 세미콜론처럼 구조를 결정하지만 실행 의미에는 직접 필요하지 않은 일부 요소를 생략하고, 선언과 표현식, 타입, 문장 사이의 의미 있는 관계를 중심으로 표현한다.
다음 코드를 예로 들 수 있다.
int square(int value) {
return value * value;
}
AST는 대략 다음과 같은 구조를 가질 수 있다.
함수 선언: square
├── 반환 타입: int
├── 매개변수
│ └── value: int
└── 함수 본문
└── return
└── 곱셈
├── value 참조
└── value 참조
AST는 타입 검사와 이름 해석, 정적 분석, 코드 생성, 리팩터링과 코드 탐색의 기반으로 사용된다. 컴파일러는 AST 노드에 해석된 타입과 선언의 참조, 소스 위치, 상수 값, 접근 권한 등의 정보를 추가할 수 있다. Clang은 파싱과 의미 분석의 결과로 소스의 추상적인 표현인 AST를 생성하며, ASTContext를 통해 타입과 선언을 포함한 장기 생존 AST 노드를 관리한다.
일부 개발 도구는 원문의 주석과 공백, 정확한 토큰 위치를 유지해야 하므로 AST와 별도로 구체 구문 트리나 토큰 정보를 보존한다. 자동 포매터와 소스 코드 변환기는 실행 의미뿐 아니라 원래의 표기와 주석 위치까지 다루어야 하기 때문이다.
의미 분석
구문적으로 올바른 코드라고 해서 의미까지 올바른 것은 아니다. 의미 분석은 AST를 바탕으로 프로그램이 언어의 정적 의미 규칙을 만족하는지 검사하고, 이후 단계에 필요한 정보를 계산한다.
의미 분석에는 일반적으로 다음 작업이 포함된다.
- 이름과 선언을 연결하는 이름 해석
- 변수와 함수, 타입의 범위 검사
- 표현식과 선언의 타입 결정
- 암시적 타입 변환 검사
- 함수 오버로드 해석
- 제네릭 또는 템플릿의 제약 검사
- 접근 제어 검사
- 상속과 인터페이스 관계 검사
- 반환문과 제어 흐름의 유효성 검사
- 상수 표현식 평가
- 초기화 순서와 수명 규칙 검사
- 언어에서 금지된 구조에 대한 진단
예를 들어 다음 코드는 문법적으로는 정상적인 함수 호출 표현이지만, 함수의 매개변수가 정수만 허용한다면 의미 분석에서 오류가 발생할 수 있다.
calculate("text")
의미 분석기는 "text"의 타입과 calculate의 매개변수 타입을 비교하여 호출의 유효성을 판단한다. C++처럼 오버로딩을 지원하는 언어에서는 같은 이름을 가진 여러 함수 중 인수 타입과 변환 규칙에 가장 적합한 함수를 선택해야 한다.
Clang은 의미 분석을 담당하는 부분을 Sema라고 부르며, 소스가 의미적으로 올바른지 판단하고 대부분의 컴파일 진단을 생성한다. 파서와 Sema는 밀접하게 동작하므로 구문 구조가 만들어지는 과정에서 이름과 타입을 함께 해석할 수 있다.
의미 분석을 마친 AST는 단순한 문법 트리보다 많은 정보를 담는다. 식별자는 실제 선언과 연결되고, 표현식에는 타입이 부여되며, 암시적 변환과 호출 대상이 구체화된다. 이러한 정보를 바탕으로 컴파일러는 프로그램의 동작을 더 낮은 수준의 표현으로 변환할 수 있다.
정적 분석과 진단
컴파일러는 언어 규칙을 위반한 오류뿐 아니라 실행은 가능하지만 결함일 가능성이 높은 코드도 분석할 수 있다. 정적 분석은 프로그램을 직접 실행하지 않고 소스 코드나 중간 표현을 조사하여 가능한 문제를 찾는 방식이다.
정적 분석은 다음과 같은 문제를 탐지할 수 있다.
- 초기화하지 않은 값의 사용
- 도달할 수 없는 코드
- 항상 참이거나 항상 거짓인 조건
- 해제된 메모리 또는 null 참조의 사용 가능성
- 자원 누수
- 배열 범위를 벗어날 가능성
- 잘못된 잠금 순서
- 데이터 경쟁 가능성
- 반환값 무시
- 형식 문자열과 인수의 불일치
- 정의되지 않은 동작을 유발할 가능성이 있는 연산
정적 분석기는 제어 흐름 그래프와 데이터 흐름 분석, 기호 실행, 추상 해석 등의 방법을 사용할 수 있다. 모든 실제 입력과 실행 경로를 완전하게 예측하는 것은 일반적으로 불가능하므로, 분석 결과에는 실제 결함이 아닌 경고나 분석기가 증명하지 못한 경로가 포함될 수 있다.
컴파일러의 진단은 오류와 경고, 참고 정보로 나뉠 수 있다. 오류는 올바른 프로그램을 만들 수 없어 컴파일을 중단해야 하는 문제를 나타내고, 경고는 언어상 허용되더라도 의심스러운 구조를 알린다. 좋은 진단 시스템은 문제의 위치뿐 아니라 관련된 선언과 가능한 수정 방법을 함께 보여준다.
중간 표현
중간 표현은 소스 언어의 AST와 실제 대상 기계의 명령어 사이에서 프로그램을 나타내는 내부 형식이다. 일반적으로 IR이라고 줄여 부른다.
컴파일러가 중간 표현을 사용하는 이유는 소스 언어와 대상 아키텍처를 직접 연결할 때 발생하는 복잡성을 줄이기 위해서이다. 여러 언어의 프런트엔드가 공통 IR을 생성하고, 공통 최적화기가 이를 처리한 뒤, 여러 백엔드가 각각의 CPU나 가상 머신 코드를 생성할 수 있다.
C ─────────┐
C++ ───────┤
Rust ──────┼─ 공통 중간 표현 ─┬─ x86-64
Fortran ───┤ ├─ AArch64
Swift ─────┘ ├─ RISC-V
└─ WebAssembly
중간 표현은 하나만 존재할 필요가 없다. 현대 컴파일러는 추상화 수준이 서로 다른 여러 IR을 단계적으로 사용할 수 있다.
- 고수준 IR은 함수와 객체, 배열, 예외, 코루틴 등 소스 언어의 구조를 비교적 많이 유지한다.
- 중간 수준 IR은 제어 흐름과 자료 흐름, 메모리 접근을 분석하기 쉬운 형태로 표현한다.
- 저수준 IR은 레지스터와 기계 연산에 가까운 구조를 사용하지만 특정 CPU에는 아직 종속되지 않을 수 있다.
- 기계 IR은 실제 명령 선택과 레지스터 할당을 위한 대상별 정보를 담는다.
LLVM IR은 타입이 있는 저수준 중간 표현이며 정적 단일 대입 형식을 중심으로 설계되어 있다. LLVM의 컴파일 전략 전반에서 공통 코드 표현으로 사용되며, 프런트엔드와 최적화, 백엔드 사이의 경계 역할을 한다.
정적 단일 대입 형식
정적 단일 대입 형식은 하나의 변수 이름에 값을 한 번만 대입하는 중간 표현 방식이며 SSA라고 줄여 부른다.
다음과 같은 명령형 코드가 있다고 가정할 수 있다.
x = 10
x = x + 1
x = x * 2
SSA에서는 각 대입에 새로운 이름을 부여한다.
x1 = 10
x2 = x1 + 1
x3 = x2 * 2
이 구조에서는 각 값이 어디에서 정의되었고 어디에서 사용되는지 명확해진다. 컴파일러는 값의 흐름을 쉽게 추적하여 상수 전파와 사용되지 않는 코드 제거, 공통 부분식 제거 등의 최적화를 수행할 수 있다.
제어 흐름이 합쳐지는 지점에서는 여러 경로에서 온 값 중 하나를 선택하는 φ 함수에 해당하는 구조를 사용할 수 있다.
if condition:
x1 = 10
else:
x2 = 20
x3 = φ(x1, x2)
실제 LLVM IR의 형태와 변환 규칙은 더 엄격하게 정의되며, 컴파일러는 최적화 과정에서 SSA 구조를 유지하거나 대상별 기계 표현으로 낮춘다.
낮추기
낮추기는 고수준의 언어 구조를 더 단순하고 낮은 수준의 연산으로 변환하는 과정이다. 영어의 lowering에서 유래한 표현이다.
예를 들어 고수준 언어의 다음 기능은 대상 기계에 직접 대응하는 단일 명령이 없을 수 있다.
- 객체와 가상 메서드
- 클로저
- 패턴 일치
- 예외 처리
- 코루틴과
async·await - 문자열 보간
- 반복자와 범위 기반 반복문
- 가비지 컬렉션을 사용하는 객체 생성
- 제네릭 함수
- 동적 타입 연산
컴파일러는 이러한 구조를 함수 호출과 분기, 메모리 할당, 상태 기계, 함수 포인터, 점프 테이블과 런타임 검사 등 더 기본적인 연산으로 변환한다.
코루틴은 중단 지점과 지역 변수의 상태를 저장하는 상태 기계로 바뀔 수 있다. 클로저는 캡처된 변수를 담는 환경 객체와 호출 가능한 함수의 조합으로 변환될 수 있다. 가상 메서드 호출은 객체의 타입 정보에서 함수 주소를 찾아 간접 호출하는 구조로 낮아질 수 있다.
낮추기는 한 번에 이루어질 필요가 없다. 소스 언어에 가까운 IR에서 공통 고수준 IR로, 다시 저수준 IR과 대상별 기계 IR로 여러 차례 진행할 수 있다. 단계별 IR을 사용하면 각 최적화와 변환이 자신에게 적합한 수준의 정보를 이용할 수 있다.
최적화
컴파일러 최적화는 프로그램의 관찰 가능한 의미를 유지하면서 실행 시간과 코드 크기, 메모리 접근, 전력 소비 등의 특성을 개선하는 변환이다.
최적화기는 프로그램이 무엇을 계산하는지 분석하고, 같은 결과를 더 적은 연산이나 더 효율적인 명령으로 얻을 수 있는지 판단한다. 다만 언어가 허용하는 관찰 가능한 동작과 메모리 모델, 부동소수점 규칙, 예외 및 정의되지 않은 동작의 범위 안에서만 변환해야 한다.
대표적인 최적화는 다음과 같다.
- 상수 접기는 컴파일 시점에 알 수 있는 연산을 미리 계산한다.
- 상수 전파는 상수로 판명된 값을 이후 사용 위치에 전달한다.
- 사용되지 않는 코드 제거는 결과에 영향을 주지 않는 연산과 도달할 수 없는 코드를 제거한다.
- 공통 부분식 제거는 같은 계산이 반복될 때 이전 결과를 재사용한다.
- 함수 인라이닝은 함수 호출을 함수 본문의 코드로 대체한다.
- 루프 불변 코드 이동은 반복마다 같은 결과를 내는 연산을 반복문 밖으로 이동한다.
- 루프 펼치기는 반복문의 본문을 여러 번 복제하여 분기 횟수를 줄인다.
- 벡터화는 여러 원소에 대한 동일한 연산을 SIMD 명령으로 결합한다.
- 탈출 분석은 객체가 함수나 스레드 밖으로 전달되는지를 분석하여 할당 위치를 최적화한다.
- 스칼라 치환은 작은 객체나 집합 값을 개별 값으로 분해한다.
- 꼬리 호출 최적화는 특정 함수 호출에서 현재 호출 프레임을 재사용한다.
- 분기 단순화는 불필요한 조건과 점프를 제거하거나 결합한다.
최적화는 하나의 단계에서 끝나지 않는다. 한 변환이 다른 최적화의 기회를 만들 수 있으므로 여러 최적화 패스를 반복하거나 정해진 순서로 실행한다. 예를 들어 함수 인라이닝을 수행하면 호출 경계가 사라져 상수 전파와 사용되지 않는 코드 제거가 가능해질 수 있다.
별칭 분석
포인터나 참조를 사용하는 언어에서는 서로 다른 두 표현이 같은 메모리 위치를 가리키는지 판단하는 것이 중요하다. 이를 별칭 분석이라고 한다.
컴파일러가 두 포인터가 같은 값을 가리킬 가능성을 배제하지 못하면, 한 포인터를 통한 쓰기가 다른 포인터의 읽기 결과를 변경할 수 있다고 가정해야 한다. 이는 연산의 재배치와 벡터화, 값의 레지스터 보관을 제한한다.
언어의 타입 체계와 소유권 규칙, restrict와 같은 한정자, 불변 참조 등의 정보는 별칭 가능성을 줄이고 최적화에 더 많은 정보를 제공할 수 있다. 반대로 임의의 포인터 연산과 외부 함수 호출은 메모리 효과를 추론하기 어렵게 만들 수 있다.
프로파일 기반 최적화
프로파일 기반 최적화는 실제 프로그램 실행에서 수집한 정보를 컴파일에 사용하는 방법이며 PGO라고 줄여 부른다.
일반적인 과정은 다음과 같다.
계측된 프로그램 빌드
↓
대표적인 입력으로 실행
↓
호출 빈도와 분기 확률 수집
↓
프로파일을 이용해 다시 컴파일
컴파일러는 자주 실행되는 함수와 분기, 드물게 실행되는 오류 경로를 구분하여 인라이닝과 코드 배치, 분기 순서 및 레지스터 사용을 조정할 수 있다. 프로파일이 실제 사용 패턴을 잘 대표하지 못하면 기대한 효과가 줄거나 특정 작업에서 성능이 나빠질 수 있다.
링크 시간 최적화
링크 시간 최적화는 개별 번역 단위를 별도로 컴파일한 뒤 링크 단계에서 여러 모듈의 중간 표현을 함께 분석하는 방식이며 LTO라고 줄여 부른다.
일반적인 분리 컴파일에서는 한 번역 단위의 컴파일러가 다른 파일에 정의된 함수의 본문을 알 수 없다. LTO에서는 링커 또는 링크 플러그인이 여러 모듈의 IR을 결합하거나 요약 정보를 교환하여 모듈 사이의 인라이닝과 사용되지 않는 전역 코드 제거, 전체 프로그램 분석을 수행할 수 있다.
전체 IR을 한꺼번에 처리하는 방식은 많은 메모리와 시간을 사용할 수 있으므로, 대규모 프로그램에서는 모듈 요약과 병렬 백엔드를 사용하는 분산형 또는 얇은 LTO 구조가 사용되기도 한다.
대상 코드 생성
최적화를 마친 중간 표현은 대상 프로세서나 가상 머신의 명령으로 변환된다. 이를 코드 생성이라고 한다.
네이티브 코드 생성기는 다음과 같은 작업을 수행한다.
- 중간 표현의 연산을 대상 명령으로 선택한다.
- 대상 프로세서가 지원하는 주소 지정 방식을 결정한다.
- 가상 레지스터를 실제 물리 레지스터에 배치한다.
- 레지스터가 부족한 값을 스택이나 메모리로 내보낸다.
- 명령의 실행 순서를 대상 프로세서에 맞게 조정한다.
- 함수 호출 규약에 따라 인수와 반환값을 배치한다.
- 스택 프레임을 구성한다.
- 분기와 상수, 전역 데이터의 위치를 결정한다.
- 예외 처리와 디버깅에 필요한 부가 정보를 생성한다.
명령 선택
명령 선택은 IR의 연산을 대상 프로세서의 실제 명령어로 변환하는 과정이다.
같은 연산이라도 프로세서에 따라 사용할 수 있는 명령이 다르다. 어떤 프로세서는 하나의 명령으로 메모리에서 값을 읽고 연산할 수 있으며, 다른 프로세서는 값을 먼저 레지스터로 불러온 뒤 별도의 연산 명령을 사용해야 할 수 있다.
코드 생성기는 대상 명령의 비용과 사용 가능한 기능을 고려하여 적절한 명령 조합을 선택한다. CPU가 SIMD나 특수한 비트 연산, 암호화 명령을 지원하면 일반 연산의 조합 대신 이러한 명령을 사용할 수 있다.
레지스터 할당
중간 표현은 실제 프로세서보다 많은 가상 레지스터를 사용할 수 있다. 레지스터 할당은 가상 값을 제한된 수의 물리 레지스터에 배치하는 과정이다.
동시에 살아 있는 값의 수가 사용 가능한 레지스터보다 많으면 일부 값은 스택 메모리에 임시로 저장된다. 이를 레지스터 스필이라고 한다. 레지스터 접근은 일반적으로 메모리 접근보다 빠르므로, 자주 사용되는 값을 레지스터에 유지하고 불필요한 저장과 불러오기를 줄이는 것이 중요하다.
함수 호출 규약은 어떤 레지스터를 호출자가 보존하고 어떤 레지스터를 피호출자가 보존해야 하는지 정의한다. 코드 생성기는 이러한 규칙을 따르면서 레지스터를 배치한다.
명령 스케줄링
현대 프로세서는 여러 명령을 겹쳐 실행할 수 있지만, 명령 사이의 데이터 의존성과 실행 장치의 제한이 성능에 영향을 준다. 명령 스케줄링은 의미를 유지하면서 명령의 순서를 조정하여 파이프라인 정지와 자원 충돌을 줄이는 과정이다.
대상 CPU가 실행 중 명령 순서를 동적으로 조정하더라도 컴파일러가 적절한 순서로 코드를 배치하면 프로세서가 병렬로 처리할 수 있는 명령의 범위를 넓힐 수 있다.
어셈블과 오브젝트 파일
코드 생성기가 텍스트 형태의 어셈블리어를 출력하는 경우 어셈블러는 이를 기계어로 변환한다. 컴파일러가 내부 어셈블러를 사용하여 중간 어셈블리 파일 없이 바로 오브젝트 파일을 만들 수도 있다.
오브젝트 파일은 완성된 실행 파일이 아니라, 기계어와 데이터, 심벌, 재배치 정보 및 여러 메타데이터를 담은 중간 바이너리이다.
일반적인 오브젝트 파일은 다음과 같은 내용을 포함한다.
- 실행 가능한 기계어 코드
- 읽기 전용 상수
- 초기화된 전역 데이터
- 초기화되지 않은 전역 데이터의 크기 정보
- 외부에 공개하거나 참조하는 심벌
- 아직 실제 주소가 결정되지 않은 위치의 재배치 정보
- 예외 처리와 스택 되감기 정보
- 디버깅 정보
- 플랫폼과 아키텍처 정보
플랫폼에 따라 ELF, PE/COFF, Mach-O 등의 오브젝트 및 실행 파일 형식을 사용한다. 각 형식은 코드와 데이터를 섹션 또는 세그먼트로 구분하고 링커와 로더가 필요한 정보를 제공한다.
심벌과 재배치
심벌은 함수와 전역 변수, 정적 데이터 등 링크 단계에서 식별해야 하는 대상의 이름과 속성을 나타낸다. 오브젝트 파일은 자신이 정의한 심벌과 외부에서 가져와야 하는 심벌을 기록한다.
다음과 같이 한 파일에서 외부 함수를 호출할 수 있다.
extern int calculate(int value);
int run(void) {
return calculate(10);
}
이 파일을 개별적으로 컴파일하는 시점에는 calculate의 최종 메모리 주소를 알지 못할 수 있다. 컴파일러와 어셈블러는 호출 위치에 임시 값과 재배치 항목을 남긴다. 링커는 calculate를 정의한 다른 오브젝트 파일이나 라이브러리를 찾아 실제 주소 또는 주소를 계산하는 방법을 반영한다.
재배치는 코드와 데이터 안의 주소 의존적인 위치를 최종 배치에 맞게 수정하는 과정이다. 절대 주소뿐 아니라 현재 명령 위치를 기준으로 한 상대 주소, 전역 오프셋 테이블과 프로시저 연결 테이블 등 다양한 재배치 방식이 존재한다.
링커
링커는 여러 오브젝트 파일과 라이브러리를 결합하여 실행 파일이나 공유 라이브러리를 생성한다.
링커는 일반적으로 다음 작업을 수행한다.
- 입력 오브젝트 파일의 섹션을 수집하고 배치한다.
- 같은 이름의 심벌 정의와 참조를 연결한다.
- 정의되지 않은 심벌을 라이브러리에서 찾는다.
- 중복되거나 충돌하는 정의를 진단한다.
- 각 코드와 데이터의 최종 주소를 결정한다.
- 재배치를 적용한다.
- 사용되지 않는 섹션을 제거할 수 있다.
- 초기화 및 종료 함수의 순서를 구성한다.
- 동적 링커가 사용할 정보를 생성한다.
- 실행 파일의 진입점과 프로그램 헤더를 기록한다.
정적 링크
정적 링크는 프로그램에 필요한 라이브러리 코드를 실행 파일에 포함하는 방식이다. 실행 시 해당 라이브러리 파일을 별도로 불러오지 않아도 되므로 배포가 단순해질 수 있지만, 실행 파일의 크기가 커지고 여러 프로그램이 같은 라이브러리 코드를 각각 포함할 수 있다.
정적 라이브러리는 일반적으로 여러 오브젝트 파일을 하나의 보관 파일로 묶은 형태이다. 링커는 프로그램에서 실제로 참조하는 심벌을 제공하는 오브젝트를 선택하여 결과물에 포함한다.
동적 링크
동적 링크는 공유 라이브러리의 코드와 데이터를 실행 파일에 모두 복사하지 않고, 프로그램을 불러오거나 심벌을 처음 사용할 때 외부 라이브러리와 연결하는 방식이다.
ELF 기반 시스템에서 동적 링크에 참여하는 실행 파일은 프로그램 인터프리터 정보를 통해 동적 링커를 지정할 수 있다. 운영체제는 실행 파일과 함께 동적 링커를 프로세스 주소 공간에 불러오며, 동적 링커는 필요한 공유 객체를 찾고 심벌을 해석하며 재배치를 처리한다.
동적 링크를 사용하면 여러 프로세스가 읽기 전용 라이브러리 코드를 공유할 수 있고, 실행 파일을 다시 만들지 않고 호환되는 라이브러리를 교체할 수 있다. 반면 실행 환경에 올바른 버전의 라이브러리가 존재해야 하며, 심벌 버전과 검색 경로, ABI 호환성을 관리해야 한다.
ABI와 호출 규약
응용 프로그램 이진 인터페이스는 별도로 컴파일된 코드가 바이너리 수준에서 상호 작용하기 위한 규칙이며 ABI라고 줄여 부른다.
ABI에는 일반적으로 다음과 같은 내용이 포함된다.
- 함수의 인수를 레지스터와 스택에 배치하는 방법
- 반환값을 전달하는 방법
- 호출 전후에 보존해야 하는 레지스터
- 스택 정렬과 스택 프레임의 구조
- 기본 타입의 크기와 정렬
- 구조체와 클래스의 메모리 배치
- 심벌 이름을 바이너리 이름으로 변환하는 방식
- 예외 처리와 스택 되감기 규칙
- 가상 함수 테이블의 구조
- 오브젝트 파일과 실행 파일 형식
- 시스템 호출과 운영체제 인터페이스
소스 코드의 함수 선언이 같더라도 서로 다른 ABI를 사용하면 인수와 반환값을 서로 다르게 해석할 수 있어 정상적으로 호출할 수 없다.
C++는 함수 오버로딩과 클래스, 네임스페이스, 템플릿 등의 정보를 심벌에 표현하기 위해 이름 장식을 사용한다. Itanium C++ ABI는 함수 호출 인터페이스와 예외 처리, 전역 이름, 객체 코드 규약 등을 정의하여 서로 독립적으로 컴파일된 C++ 코드가 함께 동작할 수 있게 한다.
ABI는 언어 사양과 동일하지 않다. 언어 사양이 프로그램의 의미를 정의한다면 ABI는 특정 플랫폼과 구현체에서 그 의미를 어떤 바이너리 구조로 표현할지를 정의한다. 같은 언어라도 운영체제와 CPU 아키텍처, 컴파일러 계열에 따라 ABI가 달라질 수 있다.
실행 파일과 로딩
로더는 실행 파일을 읽어 새로운 프로세스의 주소 공간을 구성하고 실행을 시작하는 운영체제 구성 요소이다.
일반적인 로딩 과정은 다음과 같다.
- 실행 파일의 헤더를 검사한다.
- 대상 아키텍처와 실행 형식을 확인한다.
- 코드와 읽기 전용 데이터, 쓰기 가능한 데이터를 메모리에 매핑한다.
- 초기화되지 않은 데이터 영역을 준비한다.
- 스택과 초기 프로세스 상태를 만든다.
- 동적 링크가 필요하면 동적 링커를 불러온다.
- 공유 라이브러리를 매핑하고 재배치를 적용한다.
- 언어 런타임과 전역 객체를 초기화한다.
- 프로그램의 진입점으로 제어를 전달한다.
운영체제가 처음 호출하는 진입점이 반드시 사용자가 작성한 main 함수인 것은 아니다. C와 C++ 프로그램에서는 시작 코드가 운영체제로부터 인수와 환경 정보를 전달받고 런타임을 초기화한 뒤 main을 호출한다. 프로그램이 종료되면 반환값을 운영체제에 전달하고 등록된 종료 처리와 정적 객체의 정리 작업을 수행할 수 있다.
Java Virtual Machine은 클래스의 이진 표현을 찾는 로딩, 해당 클래스를 실행 상태에 결합하는 링크, 정적 상태를 준비하는 초기화 단계를 구분한다. JVM에서는 실행 중에도 사용자 정의 클래스 로더를 통해 새로운 클래스를 불러올 수 있다.
런타임 시스템
런타임 시스템은 컴파일된 프로그램이 실행되는 동안 프로그래밍 언어의 기능을 지원하는 소프트웨어 계층이다. 런타임의 규모와 역할은 언어마다 크게 다르다.
C의 일반적인 실행 환경은 시작 코드와 표준 라이브러리, 스택 되감기 및 산술 보조 함수처럼 비교적 제한된 런타임을 사용할 수 있다. Java와 .NET은 가상 머신과 JIT 컴파일러, 가비지 컬렉터, 타입 시스템, 클래스 로더, 예외 처리와 스레드 관리 등을 포함하는 큰 관리형 런타임을 사용한다.
런타임은 다음 기능을 제공할 수 있다.
- 프로그램과 스레드의 초기화
- 함수 호출 스택과 실행 프레임 관리
- 힙 메모리 할당
- 가비지 컬렉션
- 객체와 타입 메타데이터 관리
- 동적 메서드 디스패치
- 예외 발생과 스택 되감기
- 리플렉션
- 동적 모듈 및 클래스 로딩
- 스레드와 작업 스케줄링
- 코루틴과 비동기 입출력
- 문자열과 배열의 범위 검사
- 동적 타입 연산
- 네이티브 코드와의 상호 운용
- 프로파일링과 JIT 최적화 정보 수집
.NET의 관리형 실행 환경은 JIT 컴파일과 가비지 컬렉션, 비관리 코드와의 상호 운용, 교차 언어 디버깅, 배포와 버전 관리 등의 서비스를 제공한다.
실행 스택과 호출 프레임
함수가 호출되면 일반적으로 해당 호출에 필요한 상태를 저장하는 호출 프레임이 만들어진다. 호출 프레임은 스택 프레임이라고도 한다.
호출 프레임에는 다음 정보가 포함될 수 있다.
- 반환 주소
- 함수의 인수
- 지역 변수
- 임시 계산 결과
- 이전 프레임을 가리키는 정보
- 보존해야 하는 레지스터 값
- 예외 처리와 스택 되감기 정보
함수가 다른 함수를 호출하면 새로운 프레임이 추가되고, 함수가 반환되면 해당 프레임이 제거된다. 재귀 호출은 같은 함수에 대한 서로 다른 프레임을 연속적으로 생성한다.
JVM은 각 스레드에 전용 JVM 스택을 두고, 메서드가 호출될 때 지역 변수 배열과 피연산자 스택 등을 포함하는 프레임을 생성한다. JVM의 힙은 모든 JVM 스레드가 공유하며 클래스 인스턴스와 배열이 할당된다.
모든 값이 반드시 물리적인 스택 메모리에 저장되는 것은 아니다. 최적화된 네이티브 코드에서는 지역 변수가 레지스터에만 존재하거나 완전히 제거될 수 있다. 가상 머신 구현에서도 호출 프레임이 연속된 네이티브 스택이나 힙, 별도의 내부 구조에 저장될 수 있다.
힙과 메모리 할당
힙은 실행 중 크기와 수명이 동적으로 결정되는 객체와 데이터를 저장하는 메모리 영역이다. 스택의 호출 구조와 독립적으로 살아남아야 하는 객체는 일반적으로 힙에 할당된다.
힙 할당기는 요청된 크기의 메모리 블록을 찾아 반환하고, 해제된 영역을 이후 요청에 재사용한다. 효율적인 할당기는 크기별 자유 목록과 영역 기반 할당, 스레드별 캐시, 페이지 단위 관리 등을 사용할 수 있다.
힙 사용에는 다음 문제가 발생할 수 있다.
- 할당된 메모리를 해제하지 않아 발생하는 누수
- 이미 해제된 메모리에 접근하는 오류
- 같은 메모리를 두 번 해제하는 오류
- 사용할 수 있는 연속 영역이 줄어드는 단편화
- 여러 스레드가 할당기에 접근하며 발생하는 동기화 비용
- 객체가 서로 멀리 배치되어 캐시 효율이 낮아지는 문제
언어와 런타임은 수동 해제, 참조 계수, 추적식 가비지 컬렉션, 영역 기반 관리, 소유권 분석 등 여러 방식으로 힙 객체의 수명을 관리한다.
가비지 컬렉션
가비지 컬렉션은 프로그램에서 더 이상 접근할 수 없는 객체의 메모리를 런타임이 자동으로 회수하는 방식이다.
추적식 가비지 컬렉터는 일반적으로 전역 변수와 스레드 스택, CPU 레지스터 등에서 시작하는 루트 집합을 찾고, 루트에서 참조를 따라 도달 가능한 객체를 표시한다. 도달할 수 없는 객체는 프로그램에서 다시 사용할 수 없으므로 회수 대상이 된다. .NET의 가비지 컬렉터 역시 JIT 컴파일러와 런타임이 관리하는 루트 정보를 바탕으로 도달 가능한 객체 그래프를 조사한다.
가비지 컬렉션에는 여러 구현 방식이 있다.
- 표시 후 쓸기 방식은 도달 가능한 객체를 표시한 뒤 표시되지 않은 객체를 회수한다.
- 복사 방식은 살아 있는 객체를 다른 영역으로 이동하고 기존 영역을 한꺼번에 비운다.
- 세대별 방식은 새로 생성된 객체와 오래 살아남은 객체를 서로 다른 영역에서 관리한다.
- 증분 방식은 수집 작업을 여러 작은 단계로 나눈다.
- 동시 방식은 응용 프로그램 스레드와 수집 작업의 일부를 동시에 실행한다.
- 압축 방식은 살아 있는 객체를 모아 단편화를 줄인다.
가비지 컬렉션은 메모리 해제를 자동화하지만 파일과 소켓, 잠금, GPU 객체처럼 즉시 반환해야 하는 외부 자원을 자동으로 적절한 시점에 정리한다는 의미는 아니다. 이러한 자원은 별도의 명시적 정리 구문과 범위 기반 관리, 종료 메서드 등을 사용할 수 있다.
예외 처리
예외 처리는 정상적인 반환 흐름으로 처리하기 어려운 오류나 특별한 상황을 상위 호출자에게 전달하는 제어 구조이다.
예외가 발생하면 런타임은 현재 함수에서 적절한 처리기를 찾는다. 처리기가 없으면 호출한 함수의 프레임으로 이동하며, 처리기를 찾을 때까지 호출 스택을 거슬러 올라간다. 이 과정에서 이미 생성된 지역 객체의 소멸자나 정리 구문을 실행할 수 있다. 이를 스택 되감기라고 한다.
예외 처리는 구현에 따라 다음 방식으로 구성될 수 있다.
- 각 함수 호출과 작업에서 예외 발생 여부를 지속적으로 검사한다.
- 정상 실행에는 추가 분기를 넣지 않고 예외가 발생했을 때 테이블을 이용해 처리 위치를 찾는다.
- 예외 객체와 타입 정보를 런타임이 관리한다.
- 운영체제의 구조화된 예외 처리 또는 신호 체계와 결합한다.
C++ ABI는 예외 처리 인터페이스와 스택 되감기, 객체 정리 및 처리기 탐색을 위한 바이너리 규칙을 정의할 수 있다. 서로 다른 컴파일러나 언어 런타임 사이에서 예외를 전달하려면 호환되는 ABI와 예외 모델이 필요하다.
예외는 언어 수준의 오류 표현 방식이며 운영체제의 하드웨어 예외와 항상 동일하지 않다. 0으로 나누기와 잘못된 메모리 접근 같은 하드웨어 또는 운영체제 오류를 언어 예외로 변환하는 구현도 있고, 프로그램을 즉시 종료하는 구현도 있다.
바이트코드와 가상 머신
바이트코드는 특정 물리적 CPU가 아닌 가상 머신을 대상으로 하는 명령 형식이다. 소스 코드를 바이트코드로 변환하면 언어 프런트엔드와 실제 하드웨어 사이에 공통 실행 계층을 둘 수 있다.
가상 머신은 바이트코드 명령과 메모리 모델, 호출 규칙, 타입 검증과 모듈 형식을 정의한다. 바이트코드는 인터프리터가 직접 실행할 수도 있고 JIT 컴파일러가 네이티브 코드로 변환할 수도 있다.
JVM
Java 소스 코드는 일반적으로 JVM 명령을 담는 class 형식으로 컴파일된다. JVM은 Java 언어 자체를 직접 알지 않으며, 유효한 class 형식을 생성할 수 있는 다른 언어도 JVM 위에서 실행될 수 있다. JVM class 파일에는 바이트코드와 심벌 테이블 역할의 상수 풀, 필드와 메서드 및 여러 속성이 포함된다.
JVM은 클래스 파일을 실행하기 전에 구조와 바이트코드의 유효성을 검증할 수 있다. 검증 과정은 명령의 타입 관계와 스택 사용, 분기 대상 등이 JVM 규칙을 만족하는지 확인하여 잘못된 바이너리가 런타임의 내부 규칙을 깨뜨리는 것을 제한한다.
CPython
CPython은 Python 소스 코드를 내부 코드 객체와 바이트코드로 변환한다. 코드 객체는 컴파일된 실행 코드와 관련 메타데이터를 담는 불변 객체이며, 함수 객체는 코드 객체에 전역 이름 공간과 기본 인수 등의 실행 문맥을 결합한다.
모듈을 가져올 때 CPython은 컴파일 결과를 .pyc 캐시 파일로 저장할 수 있다. 이후 실행에서 캐시가 현재 소스와 일치하면 소스를 다시 바이트코드로 변환하는 작업을 줄일 수 있다. Python의 import 시스템은 타임스탬프와 파일 크기 또는 소스 해시를 사용해 캐시의 유효성을 검사할 수 있다.
.NET
.NET 언어 컴파일러는 일반적으로 공통 중간 언어와 메타데이터가 포함된 어셈블리를 생성한다. CLR은 어셈블리를 불러오고 타입과 메서드 정보를 해석하며, 실행할 메서드를 JIT 컴파일하거나 미리 컴파일된 코드를 사용할 수 있다.
공통 타입 체계와 메타데이터는 C#과 F# 등 서로 다른 언어가 같은 런타임과 라이브러리를 사용할 수 있게 한다. 다만 각 언어의 모든 고유 기능이 다른 언어에 동일한 형태로 노출되는 것은 아니며, 상호 운용을 위해 공통 규칙으로 표현 가능한 공개 인터페이스를 사용해야 한다.
WebAssembly
WebAssembly는 이식 가능한 저수준 가상 명령 형식이다. 핵심 사양은 명령어 집합과 타입, 모듈, 바이너리 인코딩, 검증 및 실행 의미론을 정의한다. 특정 운영체제나 브라우저 API와 상호 작용하는 방법은 핵심 실행 의미와 분리되어 호스트 또는 별도의 인터페이스 계층에서 제공된다.
WebAssembly 모듈은 실행 전에 구조와 타입 규칙에 따라 검증된다. 모듈을 인스턴스화하면 함수와 메모리, 테이블, 전역 변수 등의 런타임 인스턴스가 생성되고 데이터 구간이 초기화되며, 지정된 시작 함수가 실행될 수 있다.
인터프리터
인터프리터는 프로그램의 내부 표현을 읽고 해당 의미에 맞는 동작을 직접 수행한다.
가장 단순한 트리 순회 인터프리터는 AST 노드를 재귀적으로 방문한다.
덧셈 노드 실행:
왼쪽 자식 노드를 평가한다.
오른쪽 자식 노드를 평가한다.
두 결과를 더한다.
트리 순회 방식은 구현하기 쉽고 소스 구조와 실행의 대응이 명확하지만, 각 연산마다 노드 종류를 판별하고 포인터를 따라가야 하므로 실행 비용이 커질 수 있다.
바이트코드 인터프리터는 AST를 더 조밀한 명령 배열로 변환한 뒤 가상 프로그램 카운터를 이동하며 명령을 실행한다.
LOAD_CONST 10
LOAD_LOCAL value
ADD
RETURN
바이트코드 인터프리터는 명령 디스패치와 피연산자 스택 또는 가상 레지스터를 사용한다. 가상 머신은 스택 기반이나 레지스터 기반 구조를 사용할 수 있으며, 명령의 크기와 디코딩 비용, 최적화 가능성에서 서로 다른 특성을 가진다.
인터프리터는 컴파일 시간이 짧고 실행 중 프로그램 구조를 유연하게 변경할 수 있다. 반면 각 연산을 해석하는 비용이 반복되므로 장시간 실행되는 계산에서는 네이티브 코드보다 느릴 수 있다. 현대 런타임은 인터프리터로 빠르게 실행을 시작한 뒤 자주 실행되는 부분만 JIT 컴파일하는 방식을 사용할 수 있다.
JIT 컴파일
JIT 컴파일은 프로그램이 실행되는 동안 바이트코드나 중간 표현을 현재 시스템의 기계어로 변환하는 방식이다.
JIT 컴파일러는 실행 중에 다음과 같은 정보를 수집할 수 있다.
- 실제로 자주 호출되는 함수
- 반복문의 실행 횟수
- 조건 분기의 실제 비율
- 특정 호출 위치에서 관찰된 객체 타입
- 가상 메서드가 실제로 호출한 대상
- 배열의 크기와 값의 범위
- 예외가 자주 발생하는 경로
- 함수 인수가 반복해서 가지는 값의 종류
이 정보를 이용하면 사전 컴파일러가 안전하게 가정하기 어려운 조건을 바탕으로 코드를 특수화할 수 있다. 예를 들어 특정 호출 위치에서 하나의 객체 타입만 관찰되었다면 가상 메서드 탐색을 생략하고 해당 함수를 직접 호출하도록 최적화할 수 있다.
그러나 실행 중 새로운 타입이 나타나 기존 가정이 깨질 수 있다. 이 경우 런타임은 최적화된 코드를 버리고 더 일반적인 코드나 인터프리터 상태로 돌아가야 한다. 이를 역최적화라고 한다.
JIT 컴파일에는 다음 비용도 존재한다.
- 실행 중 컴파일에 CPU 시간을 사용한다.
- 생성한 기계어를 저장하기 위한 메모리가 필요하다.
- 프로그램 시작 직후에는 충분한 프로파일 정보가 없다.
- 최적화가 완료되기 전과 후의 성능이 달라질 수 있다.
- 실행 가능한 메모리를 동적으로 생성하므로 보안 정책과 코드 서명에 영향을 받을 수 있다.
LLVM의 ORC JIT API는 LLVM IR을 실행 중 네이티브 코드로 변환하고 심벌을 관리하는 JIT 기반 구조를 만들 수 있도록 설계되어 있다.
계층형 컴파일
계층형 컴파일은 실행 속도와 컴파일 비용이 다른 여러 실행 계층을 함께 사용하는 방식이다.
빠른 인터프리터
↓ 자주 실행됨
저비용 JIT 컴파일
↓ 매우 자주 실행됨
고비용 최적화 JIT 컴파일
프로그램은 먼저 인터프리터나 빠른 컴파일러로 즉시 실행된다. 런타임은 실행 빈도를 관찰하고 자주 실행되는 코드를 더 높은 최적화 수준으로 다시 컴파일한다. 드물게 실행되는 코드는 고비용 최적화를 수행하지 않아 시작 시간과 메모리 사용을 줄일 수 있다.
AOT 컴파일
AOT 컴파일은 프로그램을 실행하기 전에 대상 코드로 변환하는 방식이다. 일반적인 네이티브 컴파일뿐 아니라 가상 머신용 중간 형식을 배포 전에 네이티브 이미지로 변환하는 방식도 AOT 컴파일에 포함될 수 있다.
AOT 컴파일의 주요 특성은 다음과 같다.
- 실행 중 컴파일 비용이 적거나 없다.
- 시작 시간과 초기 성능을 예측하기 쉽다.
- 실행 환경에서 동적 코드 생성을 허용하지 않아도 된다.
- 대상 아키텍처에 맞춘 코드를 미리 생성해야 한다.
- 실행 중에만 알 수 있는 타입과 호출 정보는 제한적으로만 활용할 수 있다.
- 리플렉션과 동적 로딩, 런타임 코드 생성 기능을 보존하기 위한 추가 정보가 필요할 수 있다.
현대 플랫폼은 AOT와 JIT 중 하나만 고정적으로 선택하지 않고, 미리 컴파일된 기본 코드와 실행 중 프로파일 기반 최적화를 결합하기도 한다.
네이티브 상호 운용
외부 함수 인터페이스는 서로 다른 프로그래밍 언어와 런타임 사이에서 함수를 호출하고 데이터를 교환하기 위한 체계이며 FFI라고 줄여 부른다.
FFI는 다음 문제를 해결해야 한다.
- 두 언어의 타입을 서로 대응시키는 방법
- 함수 호출 규약
- 문자열의 인코딩과 메모리 표현
- 구조체와 배열의 배치
- 객체와 참조의 수명
- 메모리의 소유자와 해제 책임
- 콜백 함수의 전달
- 예외와 오류의 변환
- 스레드와 런타임 상태의 연결
- 가비지 컬렉터가 관리하는 객체의 이동과 고정
C ABI는 비교적 단순하고 여러 언어에서 지원되므로 공통 상호 운용 경계로 널리 사용된다. C++의 클래스와 템플릿, 예외, 표준 라이브러리 객체는 ABI와 컴파일러 버전에 더 강하게 의존하므로, 외부 인터페이스에서는 C 호출 규약과 불투명한 핸들, 명시적인 생성·해제 함수를 사용하는 경우가 많다.
관리형 런타임에서 네이티브 함수를 호출할 때는 관리형 객체를 네이티브 메모리 표현으로 변환하는 마샬링 과정이 필요할 수 있다. 반대로 네이티브 코드가 관리형 객체를 참조하면 가비지 컬렉터가 해당 참조를 인식하고 객체의 수명을 유지하도록 별도의 핸들이 필요하다.
크로스 컴파일
크로스 컴파일은 컴파일러가 실행되는 호스트 시스템과 생성된 프로그램이 실행될 대상 시스템이 다른 컴파일 방식이다.
예를 들어 x86-64 Linux 컴퓨터에서 AArch64 Linux나 임베디드 운영체제용 프로그램을 만들 수 있다.
크로스 컴파일 환경에는 일반적으로 다음 요소가 필요하다.
- 대상 아키텍처를 지원하는 코드 생성 백엔드
- 대상 ABI와 호출 규약
- 대상 운영체제의 헤더
- 대상 표준 라이브러리와 시스템 라이브러리
- 대상용 어셈블러와 링커 또는 이를 내장한 도구
- 대상 파일 시스템을 나타내는 sysroot
- 실행 및 시험을 위한 실제 장치나 에뮬레이터
컴파일러가 대상 CPU의 기계어를 생성할 수 있는 것만으로는 충분하지 않다. 프로그램이 사용하는 라이브러리와 시작 코드, 링커 설정도 대상 환경에 맞아야 한다. Clang은 target과 CPU, ABI, sysroot, 헤더 및 라이브러리 경로를 지정하여 다른 아키텍처용 코드를 생성할 수 있다.
이기종 컴파일
현대 프로그램은 하나의 CPU만을 대상으로 하지 않을 수 있다. GPU와 DSP, 신경망 가속기 등 서로 다른 실행 장치를 함께 사용하는 경우, 하나의 소스 또는 모듈에서 호스트 코드와 장치 코드를 별도로 컴파일해야 한다.
하나의 소스
├── CPU 호스트 코드 → x86-64 또는 AArch64
└── GPU 장치 코드 → GPU ISA 또는 SPIR-V
도구 체인은 동일한 번역 단위를 여러 대상용으로 컴파일하고, 생성된 장치 코드를 호스트 실행 파일에 포함하거나 별도 바이너리로 패키징할 수 있다. Clang의 오프로딩 도구 체인도 호스트와 하나 이상의 장치를 위해 별도의 코드 객체를 생성하고 이를 하나의 번들로 결합할 수 있다.
디버깅 정보
최적화된 기계어는 원래 소스 코드의 변수와 문장, 타입 구조를 그대로 유지하지 않는다. 디버깅 정보는 디버거가 기계어 실행 상태를 소스 코드 수준의 개념으로 다시 연결할 수 있도록 하는 메타데이터이다.
디버깅 정보에는 다음 내용이 포함될 수 있다.
- 기계어 주소와 소스 파일·행·열의 대응
- 함수와 인라인된 호출 정보
- 변수와 매개변수의 타입
- 특정 실행 위치에서 값이 저장된 레지스터나 메모리 위치
- 구조체와 클래스의 필드 배치
- 네임스페이스와 모듈
- 호출 프레임 복원 정보
Unix 계열의 ELF 환경에서는 DWARF 형식이 널리 사용되며, Windows의 PE/COFF 환경에서는 CodeView와 PDB 계열 정보가 사용된다. DWARF는 여러 종류의 컴파일 언어에서 소스 수준 디버깅을 지원하기 위한 정보 형식으로 표준화되어 있다.
최적화된 프로그램에서는 소스 변수에 대응하는 값이 제거되거나 여러 위치로 이동하고, 함수가 인라인되며, 문장의 실행 순서가 바뀔 수 있다. 이 때문에 디버거가 보여주는 실행 위치와 변수 값이 소스의 직관적인 순서와 완전히 일치하지 않을 수 있다.
증분 컴파일과 캐시
대규모 프로그램을 매번 처음부터 모두 컴파일하면 개발 시간이 길어진다. 증분 컴파일은 변경된 부분과 그 변경에 영향을 받는 의존 요소만 다시 처리하는 방식이다.
증분 컴파일러는 다음 정보를 저장할 수 있다.
- 각 모듈의 소스 해시
- 공개 인터페이스와 타입 정보
- 모듈 사이의 의존 관계
- 이전 파싱 및 의미 분석 결과
- 생성된 중간 표현
- 최적화 결과와 오브젝트 파일
- 컴파일 옵션과 대상 환경
함수 내부의 구현만 바뀌고 공개 인터페이스가 유지되면 해당 함수를 포함한 모듈만 다시 생성할 수 있다. 반면 공개 타입과 템플릿, 인라인 함수가 바뀌면 이를 참조하는 다른 모듈까지 다시 컴파일해야 할 수 있다.
빌드 캐시는 소스 내용과 컴파일러 버전, 옵션, 헤더 및 의존 파일을 키로 사용해 이전 결과를 재사용한다. 정확한 캐시 키를 구성하지 못하면 오래된 결과를 잘못 사용하거나 필요 이상으로 캐시가 무효화될 수 있다.
부트스트래핑과 자체 호스팅
프로그래밍 언어의 컴파일러가 같은 언어로 작성되어 있으면 자체 호스팅 컴파일러라고 한다. 새로운 언어는 아직 자체 컴파일러가 없으므로 처음에는 다른 언어로 구현하거나 기존 언어의 부분집합을 처리하는 작은 컴파일러에서 시작할 수 있다.
일반적인 부트스트래핑 과정은 다음과 같다.
기존 언어로 초기 컴파일러 작성
↓
초기 컴파일러로 새 언어 컴파일
↓
새 언어로 컴파일러를 다시 작성
↓
이전 컴파일러로 새 컴파일러 빌드
↓
새 컴파일러가 자기 자신의 소스를 컴파일
자체 호스팅은 언어가 실제 대규모 소프트웨어를 작성할 수 있음을 보여주고, 컴파일러 개발자가 자신의 언어와 도구 체계를 직접 사용할 수 있게 한다. 다만 컴파일러의 바이너리와 소스가 신뢰할 수 있는지, 이전 빌드 단계가 결과에 어떤 영향을 주는지 검증해야 하는 부트스트랩 신뢰 문제가 존재한다.
언어 사양과 구현의 일치
언어 구현체는 언어 사양이 정의한 프로그램의 의미를 올바르게 구현해야 한다. 컴파일러가 더 빠른 코드를 생성하더라도 프로그램의 관찰 가능한 동작을 임의로 변경해서는 안 된다.
구현체의 정확성은 다음 방법으로 검사할 수 있다.
- 언어 표준의 각 기능을 확인하는 적합성 시험
- 컴파일러 자체의 단위 시험과 통합 시험
- 여러 구현체에서 같은 프로그램을 실행하는 차등 시험
- 무작위 프로그램을 생성하는 퍼징
- 중간 표현과 최적화 패스의 검증
- 컴파일 전후 프로그램 동작 비교
- 정적 분석과 런타임 검사기 사용
- 형식 검증된 컴파일러 또는 검증된 변환 사용
정의되지 않은 동작이나 구현 정의 동작을 허용하는 언어에서는 모든 구현체가 같은 결과를 낼 필요가 없다. 이 경우 사양은 구현체가 반드시 지켜야 하는 범위와 선택할 수 있는 범위를 구분한다.
가상 머신용 바이너리는 실행 전에 별도의 검증 단계를 거칠 수 있다. WebAssembly는 명령과 모듈이 정적 타입 및 구조 규칙을 만족하는지 검증하며, JVM 역시 클래스 파일과 바이트코드에 구조적 제약을 적용한다.
전체 실행 흐름
프로그래밍 언어의 실행 과정은 언어와 구현체에 따라 달라지지만, 각 단계는 다음과 같은 역할로 정리할 수 있다.
| 단계 | 주요 역할 | 대표적인 결과 |
|---|---|---|
| 전처리 | 포함 파일과 매크로, 조건부 코드 처리 | 전처리된 토큰 |
| 어휘 분석 | 문자를 의미 있는 단위로 분리 | 토큰 |
| 구문 분석 | 토큰을 문법 구조로 구성 | 구문 트리 또는 AST |
| 의미 분석 | 이름과 타입, 범위와 언어 규칙 검사 | 의미 정보가 추가된 AST |
| 낮추기 | 고수준 기능을 기본 연산으로 변환 | 저수준 AST 또는 IR |
| 중간 표현 생성 | 최적화와 대상 변환이 가능한 형식 구성 | IR |
| 최적화 | 의미를 유지하며 코드 개선 | 최적화된 IR |
| 코드 생성 | 대상 명령과 레지스터, 호출 규약 결정 | 어셈블리 또는 기계 코드 |
| 어셈블 | 기계 코드와 메타데이터 구성 | 오브젝트 파일 |
| 링크 | 심벌 해석과 재배치, 바이너리 결합 | 실행 파일 또는 라이브러리 |
| 로딩 | 프로그램과 라이브러리를 메모리에 배치 | 프로세스 실행 상태 |
| 런타임 | 메모리, 타입, 예외, 스레드 등의 언어 기능 지원 | 실행 중인 프로그램 |
바이트코드와 가상 머신을 사용하는 구현에서는 일부 단계가 다음과 같이 달라진다.
소스 코드
↓
프런트엔드 분석
↓
바이트코드 또는 공통 중간 언어
↓
검증과 모듈 로딩
↓
인터프리터 또는 JIT 컴파일러
↓
네이티브 명령 실행
프로그래밍 언어의 구현은 단순히 문법을 기계어로 바꾸는 번역 작업이 아니다. 컴파일러와 인터프리터는 언어의 의미와 타입 체계, 메모리 모델, 예외와 동시성 규칙을 실제 하드웨어와 운영체제 위에서 구현해야 한다. 링커와 로더, ABI, 런타임과 표준 라이브러리 역시 프로그램의 실행 결과를 결정하는 언어 생태계의 일부이다.
현대의 구현체는 AOT 컴파일과 인터프리트, 바이트코드, JIT 컴파일을 필요에 따라 결합하며, 여러 단계의 중간 표현과 프로파일 정보를 이용해 프로그램을 최적화한다. 따라서 프로그래밍 언어의 실제 성능과 메모리 사용, 시작 시간, 이식성 및 디버깅 특성은 언어 문법만으로 결정되지 않고 컴파일러와 런타임, 운영체제, ABI, 라이브러리 및 대상 하드웨어가 결합된 전체 실행 체계에 의해 결정된다.
타입 시스템
타입 시스템은 프로그램에서 사용되는 값과 표현식을 일정한 범주로 분류하고, 각 범주에 허용되는 연산과 값 사이의 호환 관계를 정의하는 규칙 체계이다. 타입은 값이 메모리에 저장되는 형식만을 나타내는 것이 아니라, 해당 값으로 수행할 수 있는 연산과 다른 값에 전달할 수 있는 조건, 함수의 입력과 출력, 프로그램의 추상화 경계를 함께 표현한다.
예를 들어 정수와 문자열은 모두 메모리에 저장되는 값이지만 허용되는 연산과 의미가 다르다. 정수에는 산술 연산을 적용할 수 있고, 문자열에는 연결과 길이 계산 등의 연산을 적용할 수 있다. 함수 타입은 함수가 어떤 값을 입력받고 어떤 값을 반환하는지를 나타내며, 객체 타입은 사용할 수 있는 필드와 메서드 또는 해당 객체가 만족하는 인터페이스를 나타낼 수 있다.
타입 시스템은 프로그램의 모든 오류를 제거하는 장치가 아니다. 파일이 존재하지 않는 상황과 네트워크 연결 실패, 잘못된 사용자 입력, 알고리즘의 논리적 오류처럼 타입만으로 판단하기 어려운 문제는 여전히 실행 중에 발생할 수 있다. 타입 시스템의 역할은 언어가 정한 범위 안에서 값과 연산의 불일치를 발견하고, 프로그램의 구조와 의도를 명시하며, 컴파일러와 개발 도구가 프로그램을 분석할 수 있는 정보를 제공하는 데 있다. Cardelli와 Wegner는 타입을 프로그램 표현식과 값의 분류, 추상화 및 다형성을 체계화하는 중심 개념으로 다루었다.
타입 체계의 주요 분류는 다음과 같이 요약할 수 있다.
| 기준 | 주요 분류 | 구분 대상 |
|---|---|---|
| 검사 시점 | 정적 타입, 동적 타입 | 타입 규칙을 실행 전과 실행 중 언제 검사하는가 |
| 타입 표기 | 명시적 타입, 타입 추론 | 프로그래머와 컴파일러가 타입 정보를 어떻게 결정하는가 |
| 타입 관계 | 명목적 타입, 구조적 타입 | 타입의 호환성을 이름과 구조 중 무엇으로 판단하는가 |
| 다형성 | 매개변수, 서브타입, 임시 다형성 | 하나의 코드가 여러 타입을 다루는 방식 |
| 값의 결합 | 곱 타입, 합 타입, 함수 타입 | 복합적인 값을 어떤 구조로 표현하는가 |
| 자원 사용 | 선형 타입, 아핀 타입, 소유권 타입 | 값을 몇 번 사용할 수 있고 누가 관리하는가 |
| 정밀도 | 일반 타입, 개선 타입, 의존 타입 | 값에 관한 조건을 타입에 얼마나 구체적으로 포함하는가 |
| 효과 | 일반 타입, 효과 타입 | 함수가 외부에 미치는 작용을 타입으로 추적하는가 |
| 도입 방식 | 완전 정적 타입, 점진적 타입 | 정적 타입 정보가 프로그램에 어느 정도 적용되는가 |
이러한 분류는 서로 배타적이지 않다. 하나의 언어가 정적 타입과 타입 추론, 명목적 타입, 제네릭, 널 가능성 분석과 소유권 검사를 함께 제공할 수 있다. 반대로 동적 타입 언어도 선택적인 타입 표기와 정적 분석기를 추가하여 일부 코드에 타입 검사를 적용할 수 있다.
타입과 타입 판단
타입 검사는 프로그램의 표현식에 특정 타입을 부여하고, 해당 타입이 사용되는 문맥과 호환되는지를 확인하는 과정이다. 형식적인 타입 체계에서는 다음과 비슷한 타입 판단을 사용한다.
Γ ⊢ expression : Type
이는 환경 Γ에서 expression이 Type이라는 타입을 가진다는 뜻이다. 환경에는 현재 범위에서 사용할 수 있는 변수와 함수, 타입 매개변수 및 그 타입에 관한 정보가 들어 있다.
예를 들어 다음과 같은 코드가 있다고 가정할 수 있다.
x: Integer
y: Integer
x + y
타입 검사기는 x와 y가 정수이고 정수 덧셈의 결과도 정수라는 규칙을 적용하여 x + y에 정수 타입을 부여한다. 반대로 문자열과 파일 객체처럼 덧셈 규칙이 정의되지 않은 값을 더하려 한다면 타입 오류가 발생한다.
함수 호출도 같은 방식으로 검사한다.
parse: String → Integer
parse가 문자열을 입력받아 정수를 반환하는 함수라면 문자열 표현식은 인수로 전달할 수 있지만, 문자열로 변환할 수 없는 임의의 객체를 전달하는 것은 허용되지 않을 수 있다.
타입 판단은 단순히 타입 이름이 같은지를 비교하는 과정이 아니다. 서브타입 관계와 암시적 변환, 제네릭 제약, 오버로드 해석, 수명, 널 가능성, 타입 매개변수의 추론 결과 등이 함께 고려될 수 있다.
타입 안전성과 건전성
타입 안전성은 프로그램이 타입 규칙이 금지하는 방식으로 값을 사용하는 것을 제한하는 성질이다. 예를 들어 정수 값을 함수 포인터로 호출하거나, 문자열을 임의의 객체 레이아웃으로 해석하거나, 존재하지 않는 메서드를 호출하는 동작을 타입 체계가 차단할 수 있다.
형식적인 언어 이론에서는 타입 건전성을 설명할 때 보존과 진행이라는 두 성질을 자주 사용한다.
- 보존은 올바르게 타입이 부여된 표현식이 한 단계 실행된 뒤에도 타입 규칙을 유지한다는 성질이다.
- 진행은 올바르게 타입이 부여된 표현식이 이미 결과값이거나, 언어가 정의한 다음 실행 단계로 진행할 수 있다는 성질이다.
이 두 성질을 함께 증명하면 타입 검사를 통과한 프로그램이 실행 중 타입 체계가 정의하지 않은 상태에 빠지지 않는다는 근거를 얻을 수 있다. Wright와 Felleisen은 이러한 방식으로 타입 건전성을 증명하는 구문론적 접근을 체계화하였다.
타입 건전성은 메모리 안전성과 동일하지 않다. 언어가 안전하지 않은 포인터 연산과 임의의 타입 변환을 허용하거나, 외부 네이티브 코드를 호출할 수 있다면 정적 타입 검사를 통과한 코드에서도 메모리 오류가 발생할 수 있다. 일부 언어는 이러한 연산을 unsafe 영역처럼 별도의 경계 안에 두어, 안전한 코드에서 유지되는 보장을 명확하게 구분한다. Rust도 안전한 추상화 내부에서 unsafe 코드를 사용할 수 있지만, 해당 코드의 작성자가 포인터와 별칭, 수명 규칙을 직접 보장해야 한다.
타입 시스템이 무엇을 보장하는지는 언어마다 다르다. 배열 범위를 타입으로 검사하는 언어도 있고 실행 중 검사하는 언어도 있으며, 정수 오버플로를 오류로 처리하거나 값의 순환으로 처리하는 언어도 있다. 따라서 “타입 안전한 언어”라는 표현만으로 모든 실행 안전성이 보장된다고 해석해서는 안 된다.
정적 타입과 동적 타입
정적 타입 체계는 프로그램을 실행하기 전에 변수와 표현식, 함수 호출 등의 타입 관계를 분석한다. Java에서는 모든 변수와 표현식의 타입이 컴파일 시점에 결정되며, 타입은 값의 범위와 허용되는 연산을 제한한다.
정적 타입 검사기는 다음과 같은 문제를 실행 전에 발견할 수 있다.
- 존재하지 않는 필드나 메서드의 사용
- 함수 인수와 매개변수 타입의 불일치
- 잘못된 반환 타입
- 허용되지 않는 타입 변환
- 제네릭 타입 제약 위반
- 처리되지 않은 패턴과 타입 분기의 일부
- 널 가능성 규칙 위반
- 잘못된 참조 수명과 소유권 사용
- 특정 종류의 효과와 예외 규칙 위반
정적 타입은 실행 전에 프로그램 전체의 실제 값이 무엇인지 모두 알아내는 방식이 아니다. 변수의 타입이 정수라는 사실은 알 수 있어도 실제 값이 1인지 100인지는 일반적으로 실행 전에는 알 수 없다. 타입은 가능한 값의 집합과 연산의 범위를 추상적으로 표현한다.
동적 타입 체계에서는 값의 타입과 연산의 유효성이 주로 실행 중에 확인된다. 변수 이름 자체에 하나의 고정된 타입이 부여되기보다, 해당 이름이 현재 가리키는 값이 타입 정보를 가진다고 설명하는 편이 정확하다.
value = 10
value = "text"
동적 타입 언어에서는 같은 이름이 서로 다른 시점에 정수와 문자열을 가리킬 수 있다. 그러나 정수와 문자열의 구분 자체가 사라지는 것은 아니다. 실행기는 현재 값의 타입을 확인하여 해당 연산이 허용되는지를 판단한다.
동적 타입은 프로그램을 빠르게 작성하고, 실행 중 생성되는 구조와 다양한 형태의 데이터를 유연하게 다루는 데 적합하다. 반면 특정 타입 오류가 해당 실행 경로를 실제로 통과하기 전까지 발견되지 않을 수 있다. 이를 보완하기 위해 테스트와 런타임 검사, 선택적 타입 표기 및 외부 정적 분석기가 함께 사용된다.
정적 타입 언어에도 실행 중 타입 검사가 존재할 수 있다. 다운캐스트와 동적 모듈 로딩, 리플렉션, 인터페이스 객체, 외부 데이터 역직렬화 등은 실제 실행 값을 확인해야 한다. 반대로 동적 타입 언어도 컴파일러가 명백한 오류를 미리 진단하거나 최적화를 위해 타입을 추론할 수 있다. 따라서 정적 타입과 동적 타입은 모든 검사가 한 시점에만 존재한다는 뜻이 아니라, 언어가 타입 정확성의 주요 책임을 어느 시점에 두는지를 나타낸다.
명시적 타입과 타입 추론
타입 표기는 프로그래머가 변수와 함수, 매개변수, 반환값 등에 타입을 직접 작성하는 것이다.
fn add(a: i32, b: i32) -> i32 {
a + b
}
타입 추론은 소스 코드에 타입이 명시되지 않은 부분을 컴파일러가 주변 표현식과 사용 관계를 분석하여 결정하는 과정이다.
let value = 10;
이 코드에서 컴파일러는 정수 리터럴과 사용 문맥을 바탕으로 value의 타입을 추론할 수 있다. 타입 추론을 사용한다고 해서 언어가 동적 타입으로 바뀌는 것은 아니다. 추론된 타입은 정적 타입 검사에 사용되며, 이후 해당 변수에 호환되지 않는 값을 대입하면 오류가 발생할 수 있다.
타입 추론은 다음과 같은 정보를 사용한다.
- 리터럴의 기본 타입
- 연산자가 요구하는 피연산자 타입
- 함수의 매개변수와 반환 타입
- 변수의 대입 관계
- 제네릭 타입 매개변수의 제약
- 서브타입 및 변환 관계
- 주변 문맥에서 요구되는 목표 타입
- 제어 흐름에 따른 타입 좁히기
Hindley–Milner 계열의 타입 체계는 명시적인 타입 표기가 적은 함수형 프로그램에서도 가장 일반적인 주 타입을 추론할 수 있는 체계를 제공하였다. Damas와 Milner의 연구는 특정 프로그램에 적용할 수 있는 다른 타입들이 주 타입의 인스턴스로 표현되는 주 타입 체계를 정립하였다.
현대 언어의 타입 추론은 Hindley–Milner와 완전히 같지 않다. 서브타이핑과 오버로딩, 객체, 제네릭, 수명, 가변성 등의 기능이 결합되면 더 복잡한 제약식이 필요하다. Java의 타입 추론도 표현식 호환성과 서브타입 관계를 제약식으로 변환한 뒤 타입 변수의 가능한 경계를 계산한다.
타입 추론에는 다음과 같은 범위가 있다.
- 지역 타입 추론은 함수나 표현식 내부의 일부 타입만 추론한다.
- 전역 타입 추론은 함수 경계를 넘어서 프로그램의 넓은 범위를 분석한다.
- 단방향 추론은 표현식 자체에서 타입을 계산한다.
- 문맥 기반 추론은 주변에서 요구하는 타입을 표현식 안으로 전달한다.
- 양방향 타입 검사는 일부 표현식의 타입을 합성하고, 다른 표현식은 주어진 목표 타입과 일치하는지 검사한다.
양방향 타입 검사는 타입 합성과 타입 검사를 구분하여, 높은 표현력을 가진 타입 체계에서도 필요한 타입 표기의 양과 검사 복잡도를 조절하는 방법으로 사용된다.
모든 타입을 자동으로 추론하는 것이 항상 바람직한 것은 아니다. 공개 함수의 타입을 명시하면 API 계약과 오류 메시지가 명확해지고, 구현 변경으로 추론 결과가 의도치 않게 변하는 것을 줄일 수 있다.
기본 타입과 복합 타입
프로그래밍 언어는 값의 종류를 표현하기 위해 다양한 타입을 제공한다. 언어에 따라 분류 방식은 다르지만 일반적으로 다음과 같은 타입을 볼 수 있다.
- 정수와 실수
- 논리값
- 문자와 문자열
- 포인터와 참조
- 배열과 슬라이스
- 튜플과 레코드
- 구조체와 클래스
- 열거 타입
- 함수 타입
- 인터페이스와 trait
- 제네릭 타입
- 합 타입과 교차 타입
- 재귀 타입
- 널 가능 타입
- 타입 매개변수
Java는 타입을 기본 타입과 참조 타입으로 구분하며, 기본 타입에는 논리값과 수치 타입이 있고 참조 타입에는 클래스와 인터페이스, 배열 타입이 포함된다.
이러한 분류는 모든 언어에 동일하게 적용되지 않는다. 어떤 언어에서는 정수도 객체이고, 다른 언어에서는 정수와 객체가 서로 다른 메모리 표현과 호출 규칙을 가진다. 값 타입과 참조 타입의 차이도 단순히 스택과 힙의 차이로 정의되지 않는다. 핵심은 값이 복사되고 비교되며 공유되는 방식과, 언어가 해당 값의 정체성을 어떻게 정의하는지에 있다.
곱 타입
곱 타입은 여러 값을 동시에 포함하는 복합 타입이다. 튜플과 레코드, 구조체가 대표적이다.
Point = {
x: Float,
y: Float
}
Point 값은 x와 y를 모두 가져야 한다. 가능한 x 값과 y 값의 모든 조합이 전체 Point 값의 집합을 구성하기 때문에 곱 타입이라고 부른다.
튜플은 일반적으로 필드의 위치로 구성 요소를 구분하고, 레코드는 이름으로 구분한다.
(Integer, String)
{
id: Integer,
name: String
}
Haskell의 튜플도 하나의 생성자를 가진 대수적 자료형으로 정의된다.
합 타입
합 타입은 여러 가능한 형태 가운데 하나를 가지는 타입이다. variant, tagged union 또는 구별된 합 타입이라고도 한다.
enum Result<T, E> {
Ok(T),
Err(E),
}
Result<T, E> 값은 Ok와 Err를 동시에 가지는 것이 아니라 둘 중 하나의 형태를 가진다. 각 형태는 서로 다른 데이터를 포함할 수 있으며, 태그 또는 생성자를 통해 현재 형태를 구분한다.
합 타입은 다음과 같은 상태를 명시적으로 표현하는 데 유용하다.
- 성공 또는 실패
- 값이 있음 또는 없음
- 여러 종류의 명령
- 서로 다른 구문 트리 노드
- 네트워크 메시지 종류
- 상태 기계의 현재 상태
Rust의 enum은 명목적 열거 타입과 각 variant를 생성하고 패턴 일치에 사용할 수 있도록 정의한다.
대수적 자료형
대수적 자료형은 곱 타입과 합 타입을 조합하여 정의하는 자료형이다. Haskell은 사용자 정의 대수적 자료형과 패턴 일치를 핵심 언어 기능으로 제공한다.
다음은 이진 트리를 표현하는 재귀적인 대수적 자료형이다.
data Tree a
= Empty
| Node (Tree a) a (Tree a)
Tree a는 Empty이거나, 왼쪽 트리와 값, 오른쪽 트리를 포함하는 Node이다. 타입 정의가 자기 자신을 참조하므로 재귀 타입이기도 하다.
패턴 일치는 현재 생성자를 확인하고, 생성자가 포함한 값을 추출한다.
size Empty = 0
size (Node left _ right) =
1 + size left + size right
컴파일러는 닫힌 합 타입의 모든 형태가 처리되었는지를 검사할 수 있다. 이를 통해 새로운 variant가 추가되었을 때 영향을 받는 코드를 찾거나, 처리되지 않은 상태를 진단할 수 있다.
함수 타입
함수 타입은 함수가 받는 입력과 반환하는 출력을 표현한다.
String → Integer
이는 문자열을 받아 정수를 반환하는 함수의 타입이다.
여러 매개변수는 튜플 형태로 볼 수 있다.
(Integer, Integer) → Integer
커링을 사용하는 언어에서는 다음처럼 한 인수를 받고 다시 함수를 반환하는 형태로 표현할 수 있다.
Integer → Integer → Integer
함수 타입은 일급 함수를 지원하는 언어에서 변수와 필드, 함수의 매개변수와 반환 타입으로 사용된다.
함수 타입의 서브타이핑에서는 입력과 출력의 방향이 다르게 작용한다. 반환 타입은 더 구체적인 타입을 제공해도 안전할 수 있지만, 매개변수 타입은 호출자가 전달할 수 있는 값을 충분히 넓게 받아들여야 한다. 이러한 관계가 공변성과 반공변성으로 이어진다.
합집합 타입과 교차 타입
합집합 타입은 값이 여러 타입 가운데 하나일 수 있음을 나타낸다.
string | number
이 타입의 값은 문자열이거나 숫자이다. 어느 타입인지는 실행 경로나 조건 검사를 통해 좁혀야 하며, 타입이 확인되기 전에는 모든 구성 타입에서 안전한 연산만 사용할 수 있다. TypeScript는 제어 흐름과 타입 검사를 사용하여 합집합 타입을 더 구체적인 타입으로 좁힌다.
교차 타입은 여러 타입의 조건을 동시에 만족하는 타입이다.
Serializable & Loggable
이 타입은 Serializable과 Loggable에 요구되는 구성 요소를 모두 제공해야 한다. TypeScript의 교차 타입은 여러 타입의 구성원을 하나의 타입으로 결합한다.
합집합 타입과 교차 타입은 값의 집합 관점에서 이해할 수 있다.
A | B는 A 또는 B에 속하는 값의 집합이다.A & B는 A와 B의 조건을 모두 만족하는 값의 집합이다.
특수 타입
타입 체계에는 일반적인 데이터 타입 외에 계산 구조를 표현하는 특수한 타입이 존재할 수 있다.
최상위 타입
최상위 타입은 타입 체계 안의 모든 값이 포함되는 가장 넓은 타입이다. 언어에 따라 Object, Any, unknown 등의 이름을 사용할 수 있다.
최상위 타입의 값에는 구체 타입에만 존재하는 연산을 바로 적용할 수 없다. 값을 사용하려면 타입 검사와 패턴 일치, 다운캐스트 등을 통해 더 구체적인 타입으로 좁혀야 한다.
일부 언어의 any는 단순한 최상위 타입보다 강한 우회 기능을 가진다. 타입 검사를 비활성화하거나 대부분의 연산을 허용할 수 있으므로, 타입 이론적인 최상위 타입과 같은 의미로 취급해서는 안 된다.
최하위 타입
최하위 타입은 가능한 값이 없는 타입이다. 함수가 정상적으로 반환하지 않거나 항상 예외를 발생시키는 경우를 표현할 수 있다.
Rust의 ! 타입은 값이 없고 계산이 완료되지 않는 결과를 나타내며, 다른 타입으로 강제 변환될 수 있다.
fn terminate() -> ! {
panic!("terminated");
}
최하위 타입은 어떤 값도 실제로 생성하지 않으므로 여러 타입이 요구되는 위치에 논리적으로 들어갈 수 있다.
let value = if condition {
10
} else {
terminate()
};
terminate()는 값을 반환하지 않으므로 다른 분기의 정수 타입과 충돌하지 않는다.
단위 타입
단위 타입은 의미 있는 정보가 없는 하나의 값을 가진 타입이다. 함수가 계산 결과를 반환하지 않고 부작용만 수행한다는 사실을 표현할 수 있다.
Unit
단위 타입은 값이 없다는 뜻의 최하위 타입과 다르다.
- 단위 타입에는 하나의 값이 있다.
- 최하위 타입에는 값이 하나도 없다.
널 타입
일부 언어에서는 null 또는 이에 해당하는 값이 참조 타입에 포함된다. 다른 언어에서는 널 가능 여부를 타입 자체에서 구분한다.
널 가능성을 일반 참조 타입에 암묵적으로 포함하면 모든 참조 사용에서 널 여부를 고려해야 한다. 이를 줄이기 위해 현대 언어는 T와 T?, Option<T>, Maybe T처럼 값의 존재 여부를 타입으로 분리한다.
명목적 타입과 구조적 타입
명목적 타입 체계는 타입의 선언 이름과 명시적인 관계를 기준으로 호환성을 판단한다.
class UserId
class OrderId
두 타입의 내부 표현이 모두 정수 하나로 같더라도 별도로 선언된 타입이라면 서로 다른 타입으로 취급할 수 있다. 한 타입이 다른 타입의 서브타입이 되려면 상속이나 인터페이스 구현처럼 명시적인 관계가 필요하다.
명목적 타입은 도메인 개념을 구분하고 의도하지 않은 타입 혼용을 막는 데 유리하다. 길이와 질량이 같은 실수 표현을 사용하더라도 서로 다른 타입으로 정의하면 잘못된 대입을 차단할 수 있다.
구조적 타입 체계는 타입의 이름보다 실제로 제공하는 필드와 메서드 등의 구조를 기준으로 호환성을 판단한다.
type Named = {
name: string;
};
name: string 필드를 가진 값은 해당 타입을 명시적으로 구현한다고 선언하지 않아도 Named와 호환될 수 있다. TypeScript는 JavaScript에서 익명 객체와 객체 리터럴이 널리 사용되는 특성을 반영해 구조적 타입 체계를 사용한다.
명목적 타입과 구조적 타입은 각각 장단점이 있다.
| 구분 | 명목적 타입 | 구조적 타입 |
|---|---|---|
| 호환 기준 | 선언 이름과 명시적 관계 | 필드와 메서드 구조 |
| 관계 선언 | 일반적으로 필요함 | 구조가 맞으면 생략 가능 |
| 도메인 구분 | 강함 | 같은 구조는 섞일 수 있음 |
| 기존 객체 재사용 | 명시적 구현이 필요할 수 있음 | 별도 수정 없이 호환 가능 |
| API 진화 | 선언 관계가 명확함 | 구조 변경의 영향이 넓을 수 있음 |
일부 언어는 명목적 타입과 구조적 요소를 함께 사용한다. 명목적인 클래스 타입을 제공하면서 구조적인 함수 타입이나 익명 레코드를 지원할 수 있다.
서브타이핑
서브타이핑은 한 타입의 값을 다른 타입이 요구되는 위치에서 안전하게 사용할 수 있는 관계이다.
Dog <: Animal
이는 Dog가 Animal의 서브타입이라는 뜻이다. Animal을 요구하는 코드가 기대하는 모든 조건을 Dog가 만족한다면 Dog 값을 해당 위치에 전달할 수 있다.
서브타이핑은 단순히 필드가 더 많다는 뜻이 아니다. 메서드의 입력과 출력, 가변 필드, 예외, 상태 변경 규칙 등이 함께 고려되어야 한다. Cardelli와 Wegner는 서브타입에 기반한 포함 다형성을 보편적 다형성의 한 형태로 분류하였다.
상속과 서브타이핑은 밀접하지만 동일한 개념은 아니다.
- 상속은 구현과 선언을 재사용하거나 확장하는 언어 구조이다.
- 서브타이핑은 값이 다른 타입을 요구하는 위치에 들어갈 수 있는 호환 관계이다.
언어에 따라 상속 없이 인터페이스 서브타이핑을 제공할 수도 있고, 구현을 재사용하지만 안전한 대입 관계를 만들지 않는 기능도 존재할 수 있다.
Rust는 일반적인 클래스 계층 서브타이핑을 사용하지 않으며, 서브타이핑을 주로 수명 관계와 고차 수명에 제한한다. 수명을 제거하면 대부분의 타입 관계는 타입 동등성으로 처리된다.
공변성, 반공변성과 불변성
분산은 타입 생성자 안의 타입 매개변수가 서브타입 관계를 어떻게 보존하는지를 나타낸다.
Dog <: Animal이라고 가정할 때 Container<Dog>와 Container<Animal>의 관계가 어떻게 되는지가 핵심이다.
공변성
타입 관계가 같은 방향으로 유지되면 공변적이다.
Dog <: Animal
Producer<Dog> <: Producer<Animal>
Dog만 생성하는 생산자는 Animal을 생성하는 생산자로 사용할 수 있다. 호출자는 Animal을 기대하며, 실제로 Dog가 반환되어도 문제가 없다.
반공변성
타입 관계가 반대 방향으로 바뀌면 반공변적이다.
Dog <: Animal
Consumer<Animal> <: Consumer<Dog>
모든 Animal을 처리할 수 있는 소비자는 Dog도 처리할 수 있으므로 Dog 소비자가 필요한 위치에 사용할 수 있다.
불변성
서브타입 관계가 제네릭 타입 사이에 전달되지 않으면 불변적이다.
Box<Dog>와 Box<Animal> 사이에
자동 서브타입 관계가 없음
읽기와 쓰기를 모두 허용하는 가변 컨테이너는 일반적으로 불변이어야 안전하다. Box<Dog>를 Box<Animal>로 취급할 수 있다면 누군가 Cat을 넣을 수 있고, 원래 Box<Dog>를 사용하는 코드의 가정이 깨질 수 있다.
.NET의 제네릭 인터페이스와 대리자에서는 공변성과 반공변성을 명시할 수 있으며, 불변 타입 인수는 정확히 같은 타입을 요구한다. 생산자 역할의 반환 타입은 공변적으로, 소비자 역할의 매개변수 타입은 반공변적으로 사용할 수 있다.
함수 타입에서는 일반적으로 매개변수가 반공변적이고 반환값이 공변적이다.
Animal → Dog
이 함수는 Animal을 받을 수 있고 Dog를 반환한다. 더 좁은 입력만 받는 함수로 대체하면 호출자가 전달하는 일반 Animal을 처리하지 못할 수 있다. 반대로 더 구체적인 Dog를 반환하는 것은 Animal을 기대하는 호출자에게 안전하다.
타입 변환과 캐스트
타입 변환은 한 타입의 값을 다른 타입의 값으로 취급하거나 실제 표현을 변경하는 과정이다.
타입 변환은 다음과 같이 나눌 수 있다.
- 암시적 변환
- 명시적 변환
- 표현을 바꾸는 수치 변환
- 서브타입 업캐스트
- 실행 중 검사가 필요한 다운캐스트
- boxing과 unboxing
- 포인터 재해석
- 사용자 정의 변환
강제 변환 또는 coercion은 언어가 특정 문맥에서 자동으로 수행하는 타입 변환이다. Rust의 강제 변환도 정해진 위치와 제한된 타입 관계에서만 자동으로 일어난다.
업캐스트는 일반적으로 정보 손실 없이 서브타입을 상위 타입으로 취급한다.
Dog → Animal
다운캐스트는 상위 타입의 실제 값이 특정 하위 타입인지 실행 중 확인해야 할 수 있다.
Animal → Dog
Java 사양도 Thread에서 Object로의 변환은 추가 검사 없이 가능하지만, Object에서 Thread로의 변환은 실제 객체가 해당 타입인지 실행 중 검사해야 한다고 설명한다.
수치 변환에서는 값의 범위와 정밀도가 달라질 수 있다. 작은 정수를 큰 정수 타입으로 확장하는 변환은 값을 유지할 수 있지만, 실수를 정수로 바꾸거나 큰 정수를 작은 정수로 바꾸면 정보가 손실될 수 있다.
타입 변환은 값의 의미를 바꾸는 변환과 비트 표현을 그대로 두고 해석만 바꾸는 재해석을 구분해야 한다. 임의의 재해석은 정렬과 유효한 비트 패턴, 별칭 규칙을 위반하여 정의되지 않은 동작을 일으킬 수 있다.
다형성
다형성은 하나의 함수나 타입, 인터페이스가 여러 종류의 값에 적용될 수 있는 성질이다. Cardelli와 Wegner는 다형성을 크게 보편적 다형성과 임시 다형성으로 구분하고, 보편적 다형성을 매개변수 다형성과 포함 다형성으로 나누었다.
매개변수 다형성
매개변수 다형성은 구체적인 타입을 타입 매개변수로 추상화하여 같은 구현을 여러 타입에 적용하는 방식이다.
identity<T>(value: T) -> T
identity는 정수와 문자열, 사용자 정의 객체 등에 동일한 구조로 적용할 수 있다. 구현은 T의 구체적인 정체보다 모든 타입에 공통으로 허용되는 동작만 사용한다.
매개변수 다형성은 타입에 따라 구현을 임의로 바꾸지 않는다는 점에서 코드의 일반성과 재사용성을 높인다.
포함 다형성
포함 다형성은 서브타입 값을 상위 타입 인터페이스를 통해 사용하는 방식이다.
draw(shape: Shape)
Circle과 Rectangle이 Shape의 서브타입이라면 같은 함수에 전달할 수 있다. 실제 메서드 구현은 동적 디스패치를 통해 런타임 타입에 따라 선택될 수 있다.
임시 다형성
임시 다형성은 하나의 이름이나 연산이 타입마다 서로 다른 구현을 가지는 방식이다. 함수 오버로딩과 연산자 오버로딩이 대표적이다.
add(Integer, Integer)
add(Float, Float)
add(Vector, Vector)
동일한 add라는 이름을 사용하지만 실제 구현은 타입에 따라 달라질 수 있다.
타입 클래스
타입 클래스는 타입이 제공해야 하는 연산을 선언하고, 각 타입에 대한 구현을 별도로 정의하여 임시 다형성을 체계화한다.
class Eq a where
(==) :: a -> a -> Bool
Eq의 인스턴스를 제공하는 타입은 동등성 연산을 사용할 수 있다. Wadler와 Blott는 연산자 오버로딩과 같은 임시 다형성을 타입 클래스로 표현하는 방식을 제시하였다.
Rust의 trait도 타입이 구현할 수 있는 추상 인터페이스를 정의하며 함수와 연관 타입, 연관 상수를 포함할 수 있다. 제네릭 함수는 trait bound를 이용해 허용할 타입의 동작을 제한한다.
제네릭
제네릭은 타입이나 함수의 일부를 타입 매개변수로 정의하여 여러 타입에 재사용할 수 있도록 하는 언어 기능이다.
struct Pair<T> {
first: T,
second: T,
}
제네릭 타입 매개변수에는 제약을 지정할 수 있다.
fn maximum<T: Ord>(values: &[T]) -> &T
이 함수는 모든 타입을 무조건 허용하는 것이 아니라 순서 비교를 제공하는 타입만 받는다.
제네릭 제약은 언어에 따라 다음과 같은 형태를 가진다.
- 클래스 또는 인터페이스 상한
- trait bound
- 타입 클래스 제약
- 메서드 집합
- 수명 제약
- 생성자 존재 조건
- 값 타입 또는 참조 타입 조건
- 타입 집합과 연산자 조건
제네릭은 소스 수준에서 비슷하게 보이더라도 실행 코드로 구현하는 방식은 다를 수 있다.
타입 소거
타입 소거는 컴파일 과정에서 제네릭 타입 인수의 일부 또는 전부를 공통 표현으로 변환하는 방식이다. Java의 제네릭은 하위 호환성을 유지하기 위해 소거를 사용하며, 실행 시점에 모든 타입 인수를 직접 조회할 수 있는 것은 아니다. Java 사양은 제네릭 타입과 타입 변수의 소거 규칙을 정의한다.
타입 소거는 여러 타입 인스턴스가 같은 실행 코드를 공유할 수 있게 하지만, 실행 중 타입 매개변수 정보가 필요한 작업에 제한을 만들 수 있다.
단형화
단형화는 제네릭 코드가 사용된 구체 타입마다 특수화된 코드를 생성하는 방식이다. Rust는 컴파일 시점에 제네릭 사용 위치를 조사하여 구체 타입에 맞는 코드를 생성한다.
단형화는 타입별 최적화와 정적 디스패치에 유리하지만, 많은 타입 조합이 사용되면 생성되는 코드 크기와 컴파일 시간이 증가할 수 있다.
실행 시 타입 보존
일부 런타임은 제네릭 타입 인수를 실행 시점에도 보존한다. .NET의 제네릭은 런타임 타입 정보를 유지하며 Java와 같은 타입 소거 방식을 사용하지 않는다.
이 방식은 리플렉션과 런타임 타입 검사에 더 많은 정보를 제공할 수 있지만, 런타임과 메타데이터가 이를 지원해야 한다.
실제 구현은 이 세 방식 중 하나에만 완전히 속하지 않을 수 있다. 참조 타입은 코드를 공유하고 값 타입은 특수화하거나, 공통 코드를 만들면서 일부 연산만 타입별 보조 함수로 전달할 수도 있다.
널 가능성과 선택 타입
null은 값이 존재하지 않거나 참조가 대상을 가리키지 않음을 나타내는 데 사용된다. 모든 참조 타입에 null이 암묵적으로 포함되면 다음 코드의 안전성을 타입만으로 판단하기 어렵다.
user.name
user가 실제 객체인지 null인지 별도의 검사가 필요하다.
널 안전성을 지원하는 타입 체계는 일반적으로 다음과 같이 타입을 구분한다.
User
User?
User는 널이 아닌 값만 허용하고 User?는 User 또는 null을 허용한다. Kotlin은 널 가능성을 타입 시스템에서 구분하고, 널 가능 값에 직접 접근하는 동작을 제한한다.
C#의 nullable reference type은 기존 참조 타입에 대한 컴파일 시점의 주석과 흐름 분석으로 구현된다. string과 string?는 런타임에서 서로 다른 클래스 타입이 아니며, 컴파일러가 잠재적인 널 참조 사용을 경고하는 데 사용된다.
함수형 언어와 Rust는 값의 존재 여부를 합 타입으로 표현하는 방식을 주로 사용한다.
enum Option<T> {
None,
Some(T),
}
이 구조에서는 값이 없는 상태가 명시적인 variant이므로, 패턴 일치나 조합 함수를 통해 두 경우를 처리해야 한다.
널 가능성을 타입으로 표현하더라도 외부 코드와의 상호 운용과 강제 단언, 리플렉션, 초기화 순서 문제로 널 오류가 완전히 사라지는 것은 아니다. 타입 시스템의 보장이 적용되는 경계와 우회 기능을 구분해야 한다.
흐름 민감 타입과 타입 좁히기
흐름 민감 타입 분석은 프로그램의 제어 흐름에 따라 같은 변수의 타입 정보를 더 구체적으로 조정하는 방식이다.
function length(value: string | string[]) {
if (typeof value === "string") {
return value.length;
}
return value.length;
}
조건문 안에서는 typeof value === "string" 검사에 따라 value가 문자열로 좁혀진다. 다른 분기에서는 문자열 배열로 좁혀질 수 있다.
TypeScript는 typeof, instanceof, 속성 검사, 사용자 정의 타입 술어와 제어 흐름을 이용해 합집합 타입을 좁힌다.
이와 유사한 체계를 발생 타입이라고 부르기도 한다. Typed Scheme은 조건문과 타입 검사에 따라 변수의 타입을 세분화하는 발생 타입 체계를 도입하였다.
흐름 민감 분석에서는 변수의 값이 검사 이후 변경될 수 있는지도 고려해야 한다. 공유되는 가변 변수와 비동기 콜백, 다른 스레드에서 변경 가능한 상태는 이전 타입 검사의 유효성을 깨뜨릴 수 있다. 따라서 컴파일러는 불변 지역 변수나 분석 가능한 범위에서만 좁힌 타입을 유지할 수 있다.
점진적 타입
점진적 타입은 하나의 프로그램 안에서 정적으로 타입이 지정된 부분과 동적으로 검사되는 부분을 함께 사용할 수 있도록 하는 접근이다.
기존의 동적 타입 코드베이스를 한 번에 모두 정적 타입으로 변경하기보다, 모듈과 함수 또는 표현식 단위로 타입 정보를 추가할 수 있다. 타입 정보가 구체적인 부분은 정적으로 검사하고, 타입이 알려지지 않은 경계를 통과할 때 실행 중 검사를 삽입할 수 있다.
점진적 타입은 정적 검사와 동적 검사를 단순히 병렬로 제공하는 것보다 엄격한 의미를 가진다. 정밀한 타입과 알 수 없는 타입 사이의 관계, 경계에서의 런타임 검사, 타입 오류의 책임 위치 등을 체계적으로 정의해야 한다. 점진적 타입 연구는 프로그래머가 프로그램의 각 부분에 적용할 정적 검사의 정도를 조절할 수 있도록 하는 방향에서 발전하였다.
TypeScript는 JavaScript 코드에 정적 타입 분석을 추가하는 대표적인 실용 사례이지만, TypeScript의 모든 기능을 형식적인 점진적 타입의 특정 모델과 완전히 동일하다고 볼 수는 없다. any처럼 타입 검사를 광범위하게 우회하는 기능과 구조적 타입, 소거되는 타입 표기 등이 함께 존재하기 때문이다.
점진적 타입의 장점은 기존 코드와 외부 라이브러리를 유지하면서 타입 적용 범위를 확장할 수 있다는 것이다. 반면 정적 영역과 동적 영역의 경계에서 검사 비용과 오류 추적 복잡성이 생길 수 있으며, 타입이 없는 값이 정적 영역의 가정을 깨뜨리지 않도록 경계 규칙이 필요하다.
개선 타입
개선 타입은 기존 타입에 논리적인 조건을 추가해 값의 범위를 더 정밀하게 표현하는 타입이다.
PositiveInteger = {
value: Integer | value > 0
}
일반 정수 타입은 모든 정수를 허용하지만 PositiveInteger는 0보다 큰 정수만 허용한다.
함수 타입에도 조건을 포함할 수 있다.
divide:
x: Integer
y: Integer where y != 0
→ Integer
이 타입은 나누는 값이 0이 아니라는 전제 조건을 표현한다.
개선 타입 검사기는 프로그램에서 생성되는 논리 조건을 SMT solver 등의 자동 증명 도구로 전달할 수 있다. Liquid Type 계열은 타입에 논리 술어를 추가하면서도 검사를 자동화할 수 있는 제한된 개선 체계를 사용한다. 개선 타입은 일반 타입보다 더 정밀한 프로그램 속성을 검증할 수 있다.
개선 타입으로 표현할 수 있는 예시는 다음과 같다.
- 배열 인덱스가 범위 안에 있음
- 정수가 특정 범위에 있음
- 결과 배열의 길이가 입력 길이와 같음
- 분모가 0이 아님
- 정렬된 컬렉션이라는 조건
- 접근 권한과 보안 수준
- 산술 오버플로가 발생하지 않음
논리 조건이 복잡해질수록 타입 검사 비용과 오류 설명의 어려움이 증가할 수 있다. 자동 증명기가 판단할 수 있는 논리 범위를 제한하거나, 필요한 부분에 사용자가 보조 증명을 제공하는 방식이 사용된다.
의존 타입
의존 타입은 타입이 값에 의존할 수 있는 타입 체계이다. 일반적인 제네릭 타입이 다른 타입을 매개변수로 받는다면, 의존 타입은 자연수와 논리값, 문자열 등의 값도 타입을 결정하는 데 사용할 수 있다.
대표적인 예는 길이가 타입에 포함된 벡터이다.
Vector<Element, 3>
Vector<Element, 4>
길이가 3인 벡터와 길이가 4인 벡터는 서로 다른 타입이다. 벡터 연결 함수는 길이 관계를 타입으로 표현할 수 있다.
append:
Vector<T, n>
→ Vector<T, m>
→ Vector<T, n + m>
Agda 문서는 의존 타입을 다른 타입의 객체로 색인된 타입 계열로 설명하며, Vec n을 자연수 n에 의해 색인된 벡터 타입의 예로 든다.
의존 함수 타입에서는 반환 타입이 입력값에 따라 달라질 수 있다.
isSingleton:
Bool → Type
입력값이 true이면 정수 타입을, false이면 정수 목록 타입을 반환하도록 타입 수준에서 계산할 수 있다. Idris는 타입을 일급 대상으로 다루어 함수에 전달하고 반환하거나 계산할 수 있도록 한다.
의존 타입은 다음과 같은 속성을 타입에 포함할 수 있다.
- 배열과 행렬의 크기
- 프로토콜의 현재 상태
- 구문 트리가 타입 규칙을 만족한다는 사실
- 연산 결과의 길이와 구조
- 프로그램이 특정 정리를 만족한다는 증명
- 인덱스 접근이 유효하다는 증거
Curry–Howard 대응에 따라 타입을 명제로, 해당 타입의 값을 명제의 증명으로 해석할 수 있다. 함수의 구현은 타입이 표현하는 명제를 증명하는 항이 된다. 이 구조를 사용하면 프로그램과 증명을 같은 언어에서 작성할 수 있다.
의존 타입은 매우 정밀한 계약을 제공하지만, 타입 검사 과정에서 값 수준의 계산과 정규화가 필요하다. 자동 추론이 어려운 경우 타입과 증명 항을 직접 작성해야 하며, 프로그램의 구현 변화가 증명 수정으로 이어질 수 있다.
선형 타입과 아핀 타입
일반적인 타입 체계에서는 하나의 값을 여러 번 사용하거나 전혀 사용하지 않을 수 있다. 선형 타입은 특정 값을 정확히 한 번 사용하도록 제한한다. 아핀 타입은 값을 최대 한 번만 사용할 수 있도록 하며, 사용하지 않고 버리는 것은 허용한다.
LinearResource
이 자원을 복사하거나 두 번 소비할 수 없다면 파일 핸들과 메모리 버퍼, 통신 채널, GPU 명령 버퍼처럼 소유자가 하나여야 하는 자원을 타입으로 관리할 수 있다.
선형 타입은 값의 사용 횟수를 제한해 자원의 중복 사용과 누락을 제어한다. 아핀 타입은 일부 값이 한 번보다 많이 사용되지 않도록 강제한다.
활용 예시는 다음과 같다.
- 메모리를 정확히 한 번 해제
- 파일을 사용한 뒤 닫기
- 잠금을 해제한 뒤 다시 사용하지 않기
- 메시지를 한 번만 전송
- 복사할 수 없는 하드웨어 자원 관리
- 양자 상태의 복제 제한
- 동시 프로그램의 별칭 제한
Rust는 전통적인 선형 타입 언어와 동일하지는 않지만 소유권과 이동, 대여, 수명을 타입 검사와 결합한다. 소유권 규칙은 컴파일러가 메모리 관리 관계를 검사하게 하며, 가비지 컬렉터 없이 메모리 안전성을 제공하는 기반이 된다.
Rust의 참조 규칙은 한 시점에 하나의 가변 참조 또는 여러 개의 불변 참조를 허용하고, 참조가 유효한 대상을 벗어나 살아남지 못하도록 제한한다.
소유권과 선형성은 타입 시스템이 단순히 값의 모양뿐 아니라 값의 사용 권한과 수명까지 추적할 수 있음을 보여준다.
효과 타입
일반적인 함수 타입은 입력과 출력만 나타낸다.
String → Integer
그러나 같은 입력과 출력 타입을 가진 함수라도 실제 동작은 크게 다를 수 있다.
- 순수하게 문자열을 분석함
- 파일을 읽음
- 네트워크 요청을 보냄
- 전역 상태를 변경함
- 예외를 발생시킴
- 비동기 작업을 시작함
효과 시스템은 함수가 수행할 수 있는 부작용을 타입과 함께 추적한다.
readConfig:
Path → Config
effects { FileRead, IOError }
효과 시스템은 다음을 표현할 수 있다.
- 입출력
- 상태 읽기와 변경
- 예외
- 비동기 실행
- 연속과 제어 효과
- 메모리 영역 접근
- 동시성 작업 생성
- 보안 권한
- 함수의 순수성
Lucassen과 Gifford는 표현식이 수행할 수 있는 효과를 다형적으로 추적하여 병렬 실행의 제약을 분석하는 효과 시스템을 제안하였다.
효과 타입은 프로그램의 실행 결과 타입뿐 아니라 실행 과정의 성질을 인터페이스에 포함한다. 이를 통해 순수 함수가 필요한 위치에 상태를 변경하는 함수를 전달하는 것을 막거나, 특정 예외를 호출자가 처리하도록 요구할 수 있다.
Java의 검사 예외도 제한적인 효과 추적의 한 형태로 볼 수 있지만, 일반적인 효과 시스템은 예외뿐 아니라 여러 종류의 부작용을 조합하고 추론할 수 있다.
세션 타입
세션 타입은 통신 채널을 통해 주고받을 메시지의 순서와 방향, 분기 및 종료 규칙을 타입으로 표현한다.
Client:
Send<Request>
Receive<Response>
Close
이 타입은 클라이언트가 요청을 보내고 응답을 받은 뒤 채널을 닫아야 한다는 프로토콜을 나타낸다.
서버 측 타입은 반대 방향의 통신을 표현한다.
Server:
Receive<Request>
Send<Response>
Close
세션 타입은 다음과 같은 오류를 방지하는 데 사용될 수 있다.
- 잘못된 순서로 메시지 전송
- 예상하지 않은 메시지 타입 수신
- 통신 분기 불일치
- 한쪽만 종료한 채널
- 프로토콜의 일부 단계를 생략
- 통신 상대 사이의 역할 불일치
다자간 세션 타입은 두 참가자를 넘어 여러 역할 사이의 전체 통신 규약을 정의하고, 이를 각 참가자의 지역 타입으로 투영할 수 있다. 세션 타입 연구는 통신 중심 프로그램을 구조화하는 타입 기반으로 발전하였다.
세션 타입은 선형 타입과 결합되는 경우가 많다. 하나의 채널 상태를 여러 곳에서 임의로 사용하면 프로토콜 진행 상태가 갈라질 수 있으므로, 채널 소유권과 사용 횟수를 제한하여 다음 단계가 유일하게 이어지도록 만들 수 있다.
타입의 실행 시점 표현
타입이 소스 코드와 컴파일 시점에만 존재하는지, 실행 중에도 보존되는지는 언어와 구현체에 따라 다르다.
구체화된 타입은 실행 중에도 타입 정보가 유지되어 리플렉션과 타입 검사, 직렬화 등에 사용할 수 있다. 클래스 객체와 런타임 타입 설명자, 가상 메서드 테이블 등이 해당 정보를 포함할 수 있다.
소거된 타입은 타입 검사 뒤 실행 코드에서 일부 타입 정보가 제거된다. 제네릭 타입 변수와 타입 별칭, 정적 전용 nullability 정보 등이 소거될 수 있다.
타입 소거는 타입이 실행 결과에 아무 영향도 주지 않는다는 뜻은 아니다. 타입 정보는 이미 다음을 결정하는 데 사용되었을 수 있다.
- 호출할 함수와 오버로드
- 메모리 배치
- 생성할 기계어
- 삽입할 런타임 검사
- 제네릭 특수화
- 소멸과 복사 방식
- ABI와 호출 규약
- 최적화 가능성
C#의 nullable reference type처럼 런타임 표현은 같지만 컴파일러의 흐름 분석에만 사용되는 타입 정보도 존재한다.
강한 타입과 약한 타입
“강한 타입”과 “약한 타입”은 널리 사용되지만 하나의 합의된 형식적 기준이 없는 표현이다. 문맥에 따라 다음과 같은 서로 다른 특성을 가리킬 수 있다.
- 암시적 타입 변환의 범위
- 서로 다른 타입의 혼용 가능성
- 메모리 재해석 가능성
- 런타임 타입 검사 여부
- 타입 규칙을 우회할 수 있는 기능
- 문자열과 숫자 사이의 자동 변환
- 포인터 연산과 정의되지 않은 동작
- 타입 오류가 발생하는 시점
예를 들어 암시적 수치 변환을 많이 제공하는 정적 타입 언어와, 자동 변환은 적지만 런타임에 타입을 검사하는 동적 언어를 단순한 강약 순서로 비교하기 어렵다.
정확한 설명이 필요할 때는 다음처럼 구체적인 성질을 직접 서술하는 편이 좋다.
- 정적 타입 검사를 사용한다.
- 문자열과 숫자 사이의 암시적 변환을 허용한다.
- 다운캐스트에 런타임 검사가 필요하다.
- 포인터를 임의 타입으로 재해석할 수 있다.
- 제네릭 타입 인수가 실행 중에 보존된다.
- 타입 오류가 발생하면 예외를 발생시킨다.
- 안전한 코드에서 메모리 재해석을 허용하지 않는다.
Java 사양은 Java를 정적이면서 강하게 타입이 지정된 언어라고 표현하지만, 이는 Java가 정의한 타입과 연산 제한을 설명하는 맥락이다. 다른 언어까지 포함하는 보편적인 강약 척도를 정의하는 것은 아니다.
타입 시스템의 우회와 경계
실용적인 언어는 타입 시스템으로 표현하기 어렵거나 외부 시스템과 상호 운용해야 하는 경우를 위해 우회 기능을 제공한다.
대표적인 예는 다음과 같다.
- 명시적 캐스트
- 동적 타입
any와 같은 검사 제외 타입- 원시 포인터
unsafe블록- 리플렉션
- 외부 함수 인터페이스
- 직렬화와 역직렬화
- 네이티브 플러그인
- 타입 단언
- 컴파일러 내장 함수
이러한 기능은 시스템 프로그래밍과 성능 최적화, 하위 수준 API 연결에 필요할 수 있다. 다만 타입 검사기가 증명하던 조건을 프로그래머 또는 외부 코드가 직접 책임지게 된다.
안전한 경계를 만들려면 우회 기능을 작은 내부 구현에 한정하고, 외부에는 타입 규칙을 만족하는 인터페이스를 제공해야 한다. Rust에서 안전 함수 내부에 unsafe 구현을 감싸는 방식도 이러한 구조에 해당한다.
외부 데이터 역시 타입 시스템의 신뢰 경계 밖에 있다. JSON과 네트워크 패킷, 파일, 데이터베이스 행은 프로그램이 기대하는 타입을 자동으로 만족하지 않는다. 역직렬화 단계에서 구조와 값의 범위를 검증한 뒤 내부 타입으로 변환해야 한다.
타입 시스템의 설계 선택
타입 시스템은 표현력과 안전성을 무조건 최대화하는 방식으로만 설계되지 않는다. 언어 설계자는 다음 요소 사이에서 균형을 선택한다.
- 타입 검사의 정확성
- 타입 표기의 양
- 타입 추론 가능성
- 컴파일 시간
- 오류 메시지의 이해 가능성
- 기존 코드와의 호환성
- 동적 기능과 리플렉션
- 실행 성능과 코드 크기
- 별도 컴파일과 모듈 경계
- 외부 언어와의 상호 운용
- 도구와 IDE의 분석 가능성
- 언어 사양과 구현의 복잡도
더 정밀한 타입 시스템은 더 많은 오류를 실행 전에 발견하고 프로그램의 성질을 문서화할 수 있다. 그러나 타입 검사 문제가 복잡해지고 타입 표기와 증명 코드가 늘어날 수 있다. 반대로 단순한 타입 시스템은 언어와 구현을 이해하기 쉽지만, 프로그램의 더 많은 불변 조건을 실행 중 검사나 테스트에 맡겨야 할 수 있다.
현대 언어는 한 가지 방식만을 선택하기보다 여러 수준의 검사를 결합한다. 기본적인 정적 타입과 타입 추론을 제공하고, 필요한 부분에서 합 타입과 패턴 일치, 제네릭 제약, nullability, 소유권, 개선 타입 또는 외부 정적 분석기를 추가할 수 있다.
타입 시스템의 목적은 가능한 모든 프로그램을 받아들이는 것이 아니다. 언어가 안전하다고 판단할 수 없는 프로그램을 거부하는 것도 타입 시스템의 역할이다. 실제로 안전한 프로그램이더라도 타입 검사기가 그 사실을 증명할 정보가 부족하면 거부될 수 있다. 이 경우 프로그램의 구조를 바꾸거나 타입 표기와 증명을 추가하고, 검사를 실행 시점으로 미루거나 제한된 안전하지 않은 경계를 사용할 수 있다.
따라서 타입 시스템은 값에 이름표를 붙이는 기능이 아니라 프로그램의 구조와 계산 가능 범위, 추상화와 자원 소유, 오류와 부작용, 통신 규약까지 표현하는 형식 체계이다. 정적·동적 검사와 타입 추론, 서브타이핑, 다형성, 제네릭, 대수적 자료형을 비롯한 기본 기능에서 출발해 의존 타입과 선형 타입, 효과 타입, 세션 타입까지 확장되며, 프로그래밍 언어가 어떤 프로그램을 표현하고 어떤 오류를 사전에 차단할 수 있는지를 결정한다.
언어 설계와 표준화
프로그래밍 언어의 설계는 새로운 키워드와 문법을 만드는 작업에 그치지 않는다. 언어가 해결하려는 문제와 주요 사용 환경을 정하고, 값과 타입, 상태 변화, 함수 호출, 메모리 관리, 오류 처리, 동시성, 모듈, 실행 방식과 외부 시스템의 상호 운용 규칙을 하나의 일관된 계산 체계로 구성하는 작업이다. 여기에 언어 사양과 구현체, 표준 라이브러리, 개발 도구, 변경 절차와 호환성 정책이 결합되어야 장기간 사용할 수 있는 프로그래밍 언어 생태계가 형성된다.
프로그래밍 언어의 표준화는 언어의 구문과 의미를 공통된 문서로 규정하고, 서로 다른 구현체가 같은 프로그램을 가능한 한 일관되게 처리하도록 기준을 세우는 과정이다. 표준화는 프로그램과 라이브러리의 이식성을 높이고, 특정 구현체에 종속되지 않은 언어 생태계를 형성하는 데 목적이 있다. ISO/IEC JTC 1 산하 SC 22는 프로그래밍 언어와 그 실행 환경, 시스템 소프트웨어 인터페이스를 담당하는 국제 표준화 분과이며, 이식성을 주요 목표로 삼고 있다.
모든 프로그래밍 언어가 국제 표준을 가지는 것은 아니다. C (프로그래밍 언어)와 C++는 ISO/IEC 작업반에서 표준화되지만, JavaScript의 기반 사양인 ECMAScript는 Ecma International의 TC39가 발전시킨다. Python은 PEP, Rust는 RFC, Go (프로그래밍 언어)는 제안 절차, Kotlin은 KEEP, Swift는 Swift Evolution을 통해 변경 사항을 논의한다. 이처럼 프로그래밍 언어의 발전은 국제 표준화 기구와 산업 컨소시엄, 기업, 공개 소프트웨어 프로젝트 및 독립적인 공동체가 서로 다른 방식으로 주도할 수 있다.
설계 목표와 적용 영역
새로운 프로그래밍 언어를 설계할 때는 언어가 해결하려는 문제와 주요 사용자를 먼저 정해야 한다. 모든 분야에서 가장 높은 실행 성능과 가장 단순한 문법, 가장 강한 안전성, 가장 빠른 컴파일, 가장 높은 이식성과 완전한 하위 호환성을 동시에 달성하기는 어렵다. 언어 설계는 서로 충돌할 수 있는 목표 사이에서 우선순위를 선택하는 과정이다.
주요 설계 목표에는 다음과 같은 항목이 포함될 수 있다.
- 하드웨어와 시스템 자원을 세밀하게 제어할 수 있는가
- 메모리 오류와 타입 오류를 어느 수준까지 방지하는가
- 프로그램의 동작과 실행 비용을 예측할 수 있는가
- 코드를 간결하고 명확하게 표현할 수 있는가
- 컴파일과 프로그램 시작이 빠른가
- 장시간 실행되는 프로그램의 성능이 높은가
- 여러 운영체제와 프로세서로 이식하기 쉬운가
- 대규모 코드베이스와 여러 개발자의 협업을 지원하는가
- 기존 언어와 라이브러리, 운영체제 API를 재사용할 수 있는가
- 동시성과 병렬성을 안전하게 표현할 수 있는가
- 컴파일러와 런타임, 개발 도구를 구현하기 쉬운가
- 소스 코드와 라이브러리의 호환성을 장기간 유지할 수 있는가
- 오류 메시지와 디버깅 경험이 이해하기 쉬운가
시스템 프로그래밍 언어는 메모리 배치와 수명, 호출 규약, 외부 함수 인터페이스, 지연 시간과 실행 비용의 예측 가능성을 중요하게 다루는 경우가 많다. 자동화나 데이터 분석에 주로 사용되는 언어는 대화형 실행과 간결한 표현, 동적 자료 구조 및 라이브러리 접근성을 우선할 수 있다. 교육용 언어는 개념의 명료성과 일관된 문법, 이해하기 쉬운 오류 메시지를 중시하며, 도메인 특화 언어는 특정 분야의 개념을 짧고 정확하게 표현하는 데 집중한다.
언어가 목표로 하는 실행 환경도 설계에 직접적인 영향을 준다. 운영체제와 임베디드 시스템을 대상으로 한다면 독립 실행 파일과 작은 런타임, 명확한 ABI, 제한된 자원에서의 동작이 중요해진다. 웹 브라우저에서 실행되는 언어라면 샌드박스와 점진적인 코드 로딩, 호스트 환경과의 연결이 중요하다. 분산 서비스용 언어에서는 경량 작업과 실패 격리, 네트워크 통신, 관찰 가능성 및 배포 도구가 핵심 요소가 될 수 있다.
설계 원칙
언어 설계자는 개별 기능을 독립적으로 추가하기보다 언어 전체에 적용할 기본 원칙을 세워야 한다. 기능마다 서로 다른 규칙을 사용하면 언어를 배우고 구현하기 어려워지며, 여러 기능을 결합할 때 예측하기 어려운 동작이 발생할 수 있다.
대표적인 설계 원칙은 다음과 같다.
- 일관성은 비슷한 개념이 비슷한 문법과 의미를 가지게 한다.
- 직교성은 적은 수의 독립적인 기능을 여러 방식으로 조합할 수 있게 한다.
- 명시성은 중요한 상태 변화와 오류 가능성, 비용을 소스 코드에서 드러낸다.
- 추상화는 구현 세부 사항을 숨기면서 필요한 의미와 성능 특성을 보존한다.
- 지역적 추론 가능성은 코드 일부를 이해하기 위해 프로그램 전체를 조사해야 하는 상황을 줄인다.
- 보수적 확장은 새로운 기능이 기존 프로그램의 의미를 불필요하게 바꾸지 않도록 한다.
- 구현 가능성은 언어가 정의한 기능을 현실적인 컴파일러와 런타임으로 구현할 수 있게 한다.
- 도구 친화성은 파서와 포매터, 디버거, 정적 분석기 및 IDE가 코드를 안정적으로 분석할 수 있게 한다.
- 상호 운용성은 운영체제와 외부 라이브러리, 다른 언어 및 기존 데이터 형식과 연결될 수 있게 한다.
- 단순성은 언어의 개념과 예외 규칙을 가능한 한 적게 유지한다.
직교성이 높다고 해서 모든 기능을 아무런 제한 없이 결합해야 한다는 뜻은 아니다. 개별 기능은 단순하더라도 두 기능의 조합이 모호한 의미나 지나치게 복잡한 구현을 만들 수 있다. 제네릭과 오버로딩, 타입 추론, 암시적 변환, 서브타이핑을 함께 제공하면 각각은 유용하지만 타입 검사와 오류 진단의 복잡도가 크게 증가한다. 따라서 언어 설계에서는 기능의 수뿐 아니라 기능 사이의 상호 작용도 함께 검토해야 한다.
언어의 단순성 역시 단순히 키워드 수가 적다는 뜻은 아니다. 문법이 짧더라도 숨겨진 암시적 동작과 예외 규칙이 많으면 실제 의미 체계는 복잡할 수 있다. 반대로 문법 요소가 다소 많아도 각 요소의 역할과 조합 규칙이 명확하다면 프로그램의 동작을 더 쉽게 예측할 수 있다.
핵심 계산 모델
언어는 프로그램을 어떤 계산 모델로 표현할 것인지 정해야 한다. 명령형 언어는 저장소의 상태 변화와 명령의 실행 순서를 중심으로 하고, 함수형 언어는 함수의 평가와 값의 변환을 중심으로 한다. 객체 지향 언어는 객체와 메시지 또는 메서드 호출을 중심으로 하며, 논리 언어는 사실과 규칙, 질의를 중심으로 한다.
현대 프로그래밍 언어는 여러 패러다임을 지원하는 경우가 많지만, 모든 패러다임이 언어에서 같은 비중을 가지는 것은 아니다. 함수가 일급 값이어도 프로그램의 기본 구조와 표준 라이브러리가 가변 객체와 명령형 제어 흐름을 중심으로 설계되어 있다면, 해당 언어는 주로 명령형·객체 지향 방식으로 사용될 수 있다.
계산 모델을 정할 때는 다음 요소를 함께 설계해야 한다.
- 값과 객체가 무엇인지
- 값에 정체성과 변경 가능성이 존재하는지
- 함수와 메서드가 어떻게 호출되는지
- 표현식을 어떤 순서로 평가하는지
- 프로그램 상태를 어디에 저장하고 어떻게 변경하는지
- 부작용과 입출력을 어떻게 표현하는지
- 오류와 실패를 어떤 방식으로 전달하는지
- 동시 작업이 상태를 공유하거나 교환하는 방법
- 프로그램을 어떤 단위로 분해하고 조합하는지
계산 모델은 문법보다 먼저 의미적으로 정리하는 것이 중요하다. 서로 다른 문법으로 같은 계산 모델을 표현할 수 있지만, 의미가 불명확한 상태에서 문법부터 결정하면 이후 기능을 추가할 때 예외 규칙과 암시적 동작이 늘어날 수 있다.
값과 데이터 모델
언어 설계에서는 프로그램에서 다루는 값이 무엇인지 정의해야 한다. 정수와 실수, 논리값, 문자와 문자열 같은 기본 값뿐 아니라 배열과 튜플, 레코드, 함수, 객체, 포인터, 참조, 합 타입과 모듈도 언어의 데이터 모델에 포함될 수 있다.
데이터 모델은 다음과 같은 질문에 답해야 한다.
- 값은 직접 복사되는가, 참조를 통해 공유되는가
- 값에 고유한 정체성이 존재하는가
- 값을 생성한 뒤 변경할 수 있는가
- 동일성과 동등성을 어떻게 구분하는가
- 값이 메모리에서 어떤 표현을 가지는가
- 값의 수명은 누가 관리하는가
- 순환 참조를 허용하는가
- 함수와 타입도 값으로 다룰 수 있는가
- 런타임에 타입 정보를 보존하는가
값 타입과 참조 타입의 차이는 단순히 스택과 힙의 차이로 정의되지 않는다. 핵심은 대입과 인수 전달이 값을 복사하는지 참조를 공유하는지, 그리고 변경이 다른 참조에서 관찰되는지에 있다. 최적화된 구현에서는 값 타입이 힙에 배치되거나 참조 타입의 객체가 스택에 배치될 수도 있으므로, 언어 의미와 구체적인 메모리 배치를 구분해야 한다.
상태와 변경 가능성
프로그램 상태를 변경할 수 있는지, 어떤 범위에서 변경을 허용하는지도 핵심 설계 문제이다. 변경 가능한 변수와 객체는 현실적인 시스템의 상태를 직접 표현하기 쉽지만, 여러 부분에서 같은 상태를 공유하면 프로그램의 동작을 추론하기 어려워질 수 있다.
언어는 다음과 같은 방식으로 변경 가능성을 제어할 수 있다.
- 모든 변수를 기본적으로 변경 가능하게 한다.
- 불변 변수와 가변 변수를 별도의 선언으로 구분한다.
- 객체 자체의 변경 가능성과 참조의 변경 가능성을 구분한다.
- 함수가 외부 상태를 변경하는지를 타입으로 표현한다.
- 소유자 하나만 값을 변경할 수 있도록 제한한다.
- 공유된 값은 불변으로만 접근하게 한다.
- 상태 변경을 특정 모듈이나 객체의 내부로 제한한다.
불변성은 프로그램의 상태를 완전히 없애는 개념이 아니다. 새로운 값을 생성하거나 상태를 명시적인 인수와 반환값으로 전달하여 변경의 위치와 범위를 제어하는 방식이다. 반대로 가변성을 제공하는 언어에서도 캡슐화와 소유권, 접근 제한을 통해 상태 변경을 지역화할 수 있다.
타입 시스템의 설계
타입 시스템은 언어가 허용할 값과 연산의 관계를 정의한다. 언어 설계자는 정적·동적 타입 검사, 타입 추론, 서브타이핑, 제네릭, 오버로딩, 타입 변환과 런타임 타입 정보 등을 어떤 방식으로 조합할지 결정해야 한다.
타입 시스템의 설계에는 다음과 같은 선택이 포함된다.
- 타입을 실행 전에 검사할 것인가, 실행 중 검사할 것인가
- 프로그래머가 타입을 어느 정도 직접 표기해야 하는가
- 타입 이름과 구조 중 무엇을 호환성 기준으로 사용할 것인가
- 암시적 타입 변환을 어느 범위까지 허용할 것인가
- 제네릭 코드를 어떻게 표현하고 구현할 것인가
- 널 가능성과 오류 상태를 타입에 포함할 것인가
- 값의 소유권과 수명을 타입 검사기가 추적할 것인가
- 함수의 부작용과 예외를 타입으로 나타낼 것인가
- 안전하지 않은 연산을 어떤 경계에서 허용할 것인가
타입 체계의 표현력을 높이면 더 많은 오류와 불변 조건을 컴파일 시점에 검사할 수 있지만, 타입 검사와 오류 메시지가 복잡해질 수 있다. 반대로 단순한 타입 체계는 언어와 구현을 이해하기 쉽지만 더 많은 조건을 실행 중 검사와 테스트에 맡겨야 한다.
타입 추론도 표기를 무조건 생략하는 방향으로만 설계해서는 안 된다. 지역 변수의 명백한 타입은 추론하되 공개 함수와 모듈 경계에서는 타입을 명시하게 하면, 코드의 장황함을 줄이면서 API 계약을 분명하게 유지할 수 있다.
제어 흐름과 평가 규칙
제어 흐름은 프로그램의 어느 부분을 어떤 순서로 실행할지를 결정한다. 언어는 순차 실행과 조건 분기, 반복, 함수 호출, 재귀, 예외 처리, 패턴 일치, 코루틴 및 비동기 실행 등의 규칙을 정의해야 한다.
평가 순서가 명확하지 않으면 부작용이 있는 표현식에서 구현체마다 결과가 달라질 수 있다. 따라서 언어 사양은 다음과 같은 사항을 정해야 한다.
- 함수 인수를 어느 순서로 평가하는가
- 논리 연산자가 단락 평가를 수행하는가
- 대입과 연산의 우선순위 및 결합 방향은 무엇인가
- 임시 값과 지역 변수는 언제 생성되고 파괴되는가
- 예외가 발생하면 어떤 정리 작업을 수행하는가
- 비동기 함수는 어떤 시점에 실행을 중단하고 재개하는가
- 여러 스레드에서 메모리 연산이 어떤 순서로 관찰되는가
언어가 평가 순서를 일부 지정하지 않는 경우 구현체는 최적화를 위해 여러 순서를 선택할 수 있다. 그러나 프로그래머가 지정되지 않은 순서에 의존하면 구현체와 최적화 수준에 따라 결과가 달라질 수 있다. 이러한 자유를 허용할 때는 사양에서 보장되는 부분과 보장되지 않는 부분을 명확하게 구분해야 한다.
함수와 호출 모델
함수는 대부분의 프로그래밍 언어에서 계산을 분리하고 재사용하는 기본 단위이다. 함수 설계에는 단순한 선언 문법을 넘어 매개변수 전달과 반환, 캡처, 오버로딩, 다형성과 호출 규칙이 포함된다.
언어는 다음과 같은 사항을 결정해야 한다.
- 인수를 값으로 전달하는가, 참조로 전달하는가
- 이름에 의한 전달이나 지연 평가를 지원하는가
- 기본 인수와 가변 인수를 허용하는가
- 함수를 일급 값으로 다룰 수 있는가
- 클로저가 외부 변수를 어떻게 캡처하는가
- 함수 오버로딩과 연산자 오버로딩을 허용하는가
- 메서드 호출에 동적 디스패치를 사용하는가
- 꼬리 호출 최적화를 보장하는가
- 함수가 반환할 수 있는 오류와 효과를 어떻게 표현하는가
- 외부 코드와 호출할 때 어떤 ABI를 사용하는가
클로저가 외부 변수를 값으로 복사하는지, 참조로 캡처하는지, 캡처된 변수의 수명을 어떻게 연장하는지는 언어의 메모리 모델과 밀접하게 연결된다. 함수 호출 모델 역시 타입 시스템과 객체 모델, 런타임 구현을 함께 고려해야 한다.
메모리와 자원 관리
언어 설계자는 객체와 메모리, 파일, 네트워크 연결, 잠금, GPU 자원 등의 수명을 어떻게 관리할지 정해야 한다.
대표적인 방식은 다음과 같다.
- 프로그래머가 직접 할당하고 해제한다.
- 객체의 참조 수를 계산하여 자동으로 해제한다.
- 도달 가능한 객체를 추적하는 가비지 컬렉션을 사용한다.
- 객체를 영역 또는 아레나 단위로 관리한다.
- 소유권과 수명을 정적으로 검사한다.
- 범위가 끝날 때 소멸자나 정리 함수를 자동으로 실행한다.
- 여러 방식을 함께 사용한다.
메모리 자동 관리가 외부 자원까지 자동으로 적절한 시점에 정리한다는 뜻은 아니다. 가비지 컬렉터가 있는 언어에서도 파일과 소켓, 잠금처럼 즉시 반환해야 하는 자원은 별도의 정리 구조가 필요하다.
메모리 관리 방식은 언어의 실행 성능과 지연 시간, 안전성, ABI, 외부 언어와의 상호 운용 및 프로그램 구조에 영향을 준다. 수동 메모리 관리는 실행 비용을 세밀하게 제어할 수 있지만 해제 후 사용과 이중 해제, 누수 등의 위험을 프로그래머가 관리해야 한다. 가비지 컬렉션은 객체 수명을 자동화하지만 수집 비용과 중단 시간, 런타임 복잡성이 발생할 수 있다. 소유권 기반 방식은 여러 오류를 정적으로 차단할 수 있지만 참조 관계와 수명에 관한 추가 제약이 필요하다.
오류 처리
언어는 실패를 어떻게 표현하고 전달할지 정의해야 한다. 오류 코드를 반환하거나 예외를 발생시킬 수 있으며, 성공과 실패를 합 타입이나 결과 타입으로 표현할 수도 있다.
오류 처리 설계에서 고려할 사항은 다음과 같다.
- 복구 가능한 오류와 프로그램 결함을 구분하는가
- 오류를 함수 타입에 명시하는가
- 호출자가 오류를 처리하도록 강제하는가
- 예외가 함수 경계를 어떻게 이동하는가
- 스택을 되감을 때 자원을 어떻게 정리하는가
- 여러 오류를 동시에 보존할 수 있는가
- 비동기 작업과 동시 작업의 오류를 어디로 전달하는가
- 외부 언어와의 경계에서 오류를 어떻게 변환하는가
예외는 오류 전파를 간결하게 만들 수 있지만 함수 선언만 보고 어떤 예외가 발생하는지 알기 어려울 수 있다. 결과 타입은 오류 가능성을 명시적으로 만들지만 반복적인 전달 코드가 늘어날 수 있다. 언어는 문법과 타입 시스템, 패턴 일치 및 전파 연산자를 조합하여 이러한 비용을 줄일 수 있다.
동시성과 메모리 모델
현대 언어는 여러 작업이 동시에 실행되는 환경을 고려해야 한다. 단순히 스레드를 생성하는 API를 제공하는 것만으로는 충분하지 않으며, 여러 실행 단위가 메모리 연산을 어떤 순서로 관찰하는지를 정의하는 메모리 모델이 필요하다.
동시성 설계에는 다음과 같은 선택이 포함된다.
- 공유 메모리와 스레드를 사용하는가
- 메시지 전달과 채널을 중심으로 하는가
- 액터와 격리된 상태를 제공하는가
- 코루틴과 비동기 함수를 제공하는가
- 원자적 연산과 메모리 순서를 어떻게 정의하는가
- 데이터 경쟁을 오류 또는 정의되지 않은 동작으로 처리하는가
- 작업의 취소와 실패 전파를 어떻게 구성하는가
- 구조화된 동시성을 언어에 포함하는가
- 병렬 반복과 데이터 병렬성을 어떤 방식으로 표현하는가
컴파일러와 CPU는 성능을 위해 메모리 연산의 실제 순서를 변경할 수 있다. 언어의 메모리 모델은 어떤 재배치가 허용되는지, 동기화 연산이 어떤 순서 관계를 형성하는지 정의해야 한다. 메모리 모델이 불명확하면 같은 프로그램이 프로세서와 컴파일러에 따라 다르게 동작할 수 있다.
모듈과 이름 공간
모듈 시스템은 프로그램을 여러 단위로 분해하고, 각 단위의 공개 인터페이스와 내부 구현을 구분하는 체계이다.
모듈 설계에서는 다음 요소를 정해야 한다.
- 하나의 파일과 하나의 모듈이 같은 단위인가
- 여러 파일이 하나의 모듈을 구성할 수 있는가
- 이름 공간을 중첩할 수 있는가
- 공개 선언과 비공개 선언을 어떻게 구분하는가
- 모듈을 가져올 때 이름 충돌을 어떻게 해결하는가
- 순환 의존성을 허용하는가
- 모듈을 별도로 컴파일할 수 있는가
- 공개 타입과 함수의 정보를 어떤 형식으로 저장하는가
- 패키지와 모듈, 라이브러리를 어떻게 구분하는가
- 버전이 다른 패키지를 동시에 사용할 수 있는가
좋은 모듈 시스템은 구현을 숨기고 의존 관계를 명확하게 만들며 증분 컴파일을 지원한다. 반면 공개 인터페이스에 구현 세부 사항이 지나치게 노출되면 작은 내부 변경도 의존 모듈의 재컴파일이나 호환성 문제로 이어질 수 있다.
상호 운용성과 외부 인터페이스
새로운 프로그래밍 언어가 실제 환경에서 사용되려면 기존 운영체제와 라이브러리, 데이터 형식 및 다른 언어와 연결될 수 있어야 한다. 언어의 상호 운용성은 초기 생태계 형성에 특히 중요하다.
상호 운용 설계에는 다음 요소가 포함된다.
- C ABI와 네이티브 함수를 호출할 수 있는가
- 구조체와 배열, 문자열을 어떤 메모리 형식으로 전달하는가
- 객체의 소유권과 해제 책임은 어느 쪽에 있는가
- 콜백을 다른 언어에 전달할 수 있는가
- 예외를 언어 경계 밖으로 전달할 수 있는가
- 런타임이 관리하는 객체를 이동하지 않도록 고정할 수 있는가
- 다른 언어의 제네릭과 클래스, 인터페이스를 어떻게 표현하는가
- 버전이 다른 런타임과 바이너리를 함께 사용할 수 있는가
외부 인터페이스는 언어의 안전성 보장이 끊어지는 경계가 될 수 있다. 외부 함수가 잘못된 포인터나 길이를 반환하면 안전한 언어 안에서도 오류가 발생할 수 있으므로, 외부 데이터를 검증하고 안전한 내부 타입으로 변환하는 계층이 필요하다.
구문 설계
구문은 언어의 개념을 문자와 토큰으로 표현하는 방법이다. 구문 설계에서는 코드의 길이뿐 아니라 가독성과 모호성, 오류 복구, 도구 처리 가능성을 함께 고려해야 한다.
구문 설계에서 결정해야 할 사항은 다음과 같다.
- 식별자와 키워드의 형태
- 문장과 블록의 구분 방식
- 공백과 들여쓰기의 의미
- 연산자의 우선순위와 결합 방향
- 함수와 타입, 모듈 선언의 형태
- 제네릭과 타입 표기의 문법
- 주석과 문서 주석의 형식
- 문자열과 수치 리터럴의 표기
- 매크로와 메타프로그래밍 문법
- 문법 오류 이후의 복구 가능성
구문은 여러 해석이 가능한 모호한 구조를 피해야 한다. 파서가 해석할 수 있더라도 사람이 쉽게 오해할 수 있는 표현은 유지보수성을 떨어뜨릴 수 있다. 또한 새로운 키워드를 추가하면 기존 코드에서 같은 이름을 식별자로 사용하던 프로그램이 깨질 수 있으므로, 예약어 정책은 장기적인 언어 발전과 연결된다.
구문 설계는 자동 포매터와 IDE, 코드 생성기에도 영향을 준다. 문맥에 따라 토큰의 의미가 과도하게 달라지거나 문법이 다른 파일의 정보에 의존하면 독립적인 구문 분석과 편집기 지원이 어려워질 수 있다.
문법과 의미의 분리
언어 사양은 구문적으로 올바른 프로그램과 의미적으로 올바른 프로그램을 구분한다. 문법은 토큰을 어떤 구조로 조합할 수 있는지를 정의하지만, 이름의 선언 여부와 타입 호환성, 접근 권한, 평가 결과는 별도의 의미 규칙으로 결정된다.
예를 들어 다음과 같은 표현식은 문법적으로 올바를 수 있다.
left + right
그러나 left와 right에 덧셈을 적용할 수 있는지, 어떤 덧셈 구현을 선택하는지, 결과 타입이 무엇인지는 의미 분석과 타입 시스템이 결정한다.
언어 사양은 일반적으로 다음 층을 구분해 설명한다.
- 어휘 문법
- 구문 문법
- 정적 의미 규칙
- 동적 실행 의미
- 라이브러리 인터페이스
- 구현체가 선택할 수 있는 범위
- 프로그램이 의존해서는 안 되는 동작
문법만 상세하고 의미가 불명확하면 여러 구현체가 같은 소스 코드를 다르게 처리할 수 있다. 반대로 의미를 지나치게 구현 절차에 맞추어 정의하면 새로운 최적화나 구현 전략을 사용하기 어려워진다. 사양은 결과와 관찰 가능한 동작을 충분히 명확하게 규정하면서도 구현체의 내부 구조에는 불필요한 제약을 가하지 않아야 한다.
언어 사양
언어 사양은 어떤 소스 프로그램이 올바르며, 올바른 프로그램이 어떤 의미를 가지는지를 정의하는 규범 문서이다. 사양은 특정 컴파일러의 현재 동작을 단순히 설명하는 문서와 다르다. 구현체는 사양을 따라야 하며, 구현체의 동작이 사양과 다르면 구현체 결함이나 사양의 모호성으로 다루어진다.
일반적인 언어 사양에는 다음과 같은 내용이 포함된다.
- 문자 집합과 소스 코드 인코딩
- 토큰과 어휘 문법
- 구문 문법
- 이름과 범위
- 타입과 변환
- 표현식과 문장
- 선언과 모듈
- 실행 순서와 평가 규칙
- 객체와 메모리 모델
- 동시성과 원자적 연산
- 오류와 예외
- 표준 라이브러리
- 구현체의 적합성 조건
- 구현 정의·미지정·정의되지 않은 동작
- 다른 표준과의 관계
C (프로그래밍 언어) 표준은 C로 표현된 프로그램의 형태와 해석을 정하고, 이식 가능한 프로그램과 적합한 구현체를 위한 공통 기반을 제공하는 것을 목적으로 한다. WG14는 C의 국제 표준화를 담당하고 있으며, WG21은 C++의 국제 표준화를 담당한다.
규범적 내용과 설명적 내용
표준 문서에서는 구현체가 반드시 따라야 하는 규범적 내용과 이해를 돕는 설명적 내용을 구분할 수 있다.
규범적 내용은 다음과 같은 표현을 포함할 수 있다.
- 구현체는 특정 동작을 수행해야 한다.
- 프로그램은 특정 조건을 만족해야 한다.
- 조건을 위반하면 진단을 제공해야 한다.
- 결과는 정해진 범위에 속해야 한다.
설명적 내용은 예시와 설계 배경, 권고 사항, 참고 문헌 등을 제공하지만 구현 적합성을 직접 결정하지 않을 수 있다.
이 구분이 모호하면 예제 코드와 설명 문장을 규칙으로 해석해야 하는지 논쟁이 발생할 수 있다. 따라서 공식 사양은 요구 사항과 참고 설명을 명확하게 구분하는 편이 바람직하다.
적합한 프로그램과 적합한 구현체
언어 표준은 프로그램뿐 아니라 구현체의 적합성도 정의할 수 있다. 적합한 구현체는 표준이 요구하는 구문과 의미, 라이브러리 기능을 지원하고 필요한 진단을 제공해야 한다.
구현체가 표준 이외의 확장 기능을 제공하는 것은 가능할 수 있다. 다만 확장 기능을 사용하지 않는 적합한 프로그램의 의미를 바꾸거나 표준 프로그램을 거부해서는 안 된다. 구현체는 확장 모드와 엄격한 표준 모드를 별도로 제공하기도 한다.
적합성은 구현체가 모든 프로그램에서 같은 기계어를 생성해야 한다는 뜻이 아니다. 최적화와 메모리 배치, 내부 자료 구조는 자유롭게 선택할 수 있지만 프로그램에서 관찰 가능한 결과는 사양이 허용한 범위 안에 있어야 한다.
구현 정의·미지정·정의되지 않은 동작
언어 사양은 모든 세부 동작을 하나로 고정하지 않을 수 있다. 하드웨어와 운영체제의 차이를 허용하거나 구현 최적화의 자유를 확보하기 위해 일부 동작을 여러 범주로 나눌 수 있다.
구현 정의 동작
구현 정의 동작은 구현체가 여러 선택지 가운데 하나를 선택할 수 있지만, 어떤 선택을 했는지 문서화해야 하는 동작이다.
예를 들면 다음과 같은 사항이 구현 정의일 수 있다.
- 기본 정수 타입의 크기
- 문자의 부호 여부
- 특정 파일과 문자 인코딩의 처리
- 구현체가 제공하는 확장 타입
- 플랫폼별 호출 규약
프로그램은 구현체의 문서를 확인하여 해당 동작에 의존할 수 있지만 다른 구현체로 이식할 때 결과가 달라질 수 있다.
미지정 동작
미지정 동작은 사양이 여러 결과를 허용하며 구현체가 어떤 결과를 선택했는지 반드시 문서화할 필요가 없는 동작이다. 같은 구현체에서도 최적화나 실행 조건에 따라 선택이 달라질 수 있다.
프로그램은 허용된 결과 가운데 하나가 나온다는 사실만 가정해야 하며, 특정 결과에 의존해서는 안 된다.
정의되지 않은 동작
정의되지 않은 동작은 사양이 결과를 규정하지 않는 동작이다. 해당 동작이 발생한 프로그램에 대해 구현체는 특정 결과를 보장하지 않는다.
정의되지 않은 동작은 하드웨어에 가까운 언어에서 높은 성능과 다양한 구현을 허용하기 위해 사용되기도 한다. 그러나 프로그래머가 이를 정상적인 실행 경로에서 발생시키면 보안 취약점과 예측 불가능한 오류로 이어질 수 있다.
정의되지 않은 동작과 구현체 결함은 구분해야 한다. 프로그램이 사양을 준수했는데 구현체가 잘못된 결과를 내면 구현체 결함이다. 반대로 프로그램이 사양에서 금지한 동작을 수행했다면 구현체가 특정 결과를 내지 않았다는 이유만으로 결함이라고 볼 수 없다.
사양과 구현체의 관계
하나의 프로그래밍 언어에 여러 구현체가 존재할 수 있다. 각 구현체는 서로 다른 컴파일러 구조와 최적화 방식, 가상 머신, 가비지 컬렉터, 표준 라이브러리 구현 및 대상 플랫폼을 사용할 수 있다.
여러 구현체가 존재하면 다음과 같은 장점이 있다.
- 특정 기업이나 프로젝트에 대한 종속을 줄일 수 있다.
- 서로 다른 실행 환경에 맞는 구현 전략을 사용할 수 있다.
- 구현체 사이의 비교를 통해 사양의 모호성을 발견할 수 있다.
- 하나의 구현체에서 발생한 결함을 다른 구현체로 확인할 수 있다.
- 새로운 최적화와 런타임 구조를 실험할 수 있다.
반면 여러 구현체가 실제로 같은 프로그램을 다르게 처리하면 생태계가 분열될 수 있다. 이를 방지하려면 명확한 사양과 적합성 시험, 구현체 사이의 차등 시험이 필요하다.
하나의 구현체만 사실상 사용되는 언어에서는 구현체의 동작이 비공식적인 사양 역할을 할 수 있다. 그러나 구현체 내부의 우연한 동작과 의도된 언어 규칙이 구분되지 않으면 이후 최적화나 결함 수정이 기존 프로그램을 깨뜨릴 수 있다.
참조 구현
참조 구현은 언어 사양을 구현한 대표적인 컴파일러나 인터프리터이다. 참조 구현은 언어 기능을 실제로 시험하고 다른 구현체가 동작을 비교할 수 있는 기준을 제공한다.
다만 참조 구현과 언어 사양은 동일하지 않다. 참조 구현에도 결함이 있을 수 있으며, 사양에서 허용한 여러 구현 방법 가운데 하나만 사용한다. 다른 구현체가 참조 구현과 내부 구조나 성능 특성이 다르더라도 사양을 만족하면 적합한 구현체가 될 수 있다.
참조 구현이 지나치게 강한 기준이 되면 사양에 없는 구현 세부 사항까지 사실상의 표준으로 굳어질 수 있다. 따라서 언어 프로젝트는 의도된 규칙을 사양과 시험으로 명확하게 분리하는 것이 바람직하다.
적합성 시험
적합성 시험은 구현체가 언어 사양과 표준 라이브러리의 요구 사항을 충족하는지 확인하는 시험 모음이다.
적합성 시험에는 다음과 같은 내용이 포함될 수 있다.
- 올바른 프로그램이 정상적으로 처리되는지 확인
- 잘못된 프로그램에 필요한 진단을 제공하는지 확인
- 표현식과 제어 흐름의 실행 결과 확인
- 타입 변환과 오버로드 선택 확인
- 표준 라이브러리의 결과와 오류 조건 확인
- 메모리 모델과 동시성 동작 확인
- 파일 및 바이너리 형식의 구조 확인
- 서로 다른 플랫폼에서의 결과 비교
적합성 시험을 통과했다고 해서 구현체에 결함이 없다는 뜻은 아니다. 가능한 프로그램의 수는 사실상 무한하므로 시험은 사양의 대표적인 규칙과 경계 조건을 확인한다. 무작위 프로그램 생성과 퍼징, 차등 시험, 형식 검증을 함께 사용하면 더 많은 구현 결함을 발견할 수 있다.
Java 사양은 독립 구현체가 정해진 요구를 충족하고 호환성 시험을 통과하는 조건을 명시적으로 다룬다. Java 언어 사양은 소스 언어뿐 아니라 클래스와 인터페이스의 바이너리 호환성 규칙도 별도의 장으로 정의한다.
표준 라이브러리
프로그래밍 언어의 실제 사용성은 핵심 문법뿐 아니라 표준 라이브러리에도 크게 좌우된다. 문자열과 컬렉션, 파일, 시간, 수학, 동시성, 네트워크, 직렬화 등의 기능을 어떤 범위까지 표준으로 제공할지 결정해야 한다.
표준 라이브러리가 작으면 구현과 이식이 쉬워지고, 다양한 외부 라이브러리가 경쟁할 수 있다. 반면 기본적인 기능까지 외부 패키지에 의존하면 프로젝트마다 서로 다른 인터페이스가 사용되고 생태계가 분열될 수 있다.
표준 라이브러리를 설계할 때는 다음 사항을 고려해야 한다.
- 언어의 핵심 기능과 라이브러리 기능의 경계
- 모든 플랫폼에서 제공할 수 있는 기능
- 운영체제별 차이를 추상화하는 방법
- 오류 처리 방식
- 동시성과 비동기 API
- 바이너리 호환성
- API의 이름과 일관성
- 오래된 기능의 폐기 절차
- 외부 패키지 생태계와의 역할 분담
언어 문법보다 표준 라이브러리 API가 더 넓은 사용자 코드에 직접적인 영향을 미칠 수 있다. 한번 공개한 타입과 함수의 이름, 동작을 변경하기 어려우므로 초기부터 호환성 정책과 확장 가능성을 고려해야 한다.
언어 변경 제안
성숙한 언어에서는 기능을 즉시 추가하지 않고 일정한 제안 절차를 거친다. 제안 문서는 문제와 해결 방법뿐 아니라 기존 기능과의 관계, 대안, 호환성, 구현 가능성 및 단점을 함께 설명해야 한다.
일반적인 제안 문서에는 다음과 같은 내용이 들어간다.
- 해결하려는 문제
- 실제 사용 사례
- 제안하는 문법과 의미
- 타입 검사와 실행 규칙
- 기존 코드와의 호환성
- 표준 라이브러리와 도구에 미치는 영향
- 구현 방법과 성능 비용
- 보안 및 안전성 영향
- 고려했지만 채택하지 않은 대안
- 해결되지 않은 문제
- 향후 확장 가능성
- 시험과 문서화 계획
Python의 PEP는 Python 공동체에 정보를 제공하거나 언어와 절차, 환경의 새로운 기능을 설명하는 설계 문서이며, 기술 사양과 제안의 근거를 함께 제공하도록 요구한다.
Rust RFC 절차는 언어와 표준 라이브러리의 주요 변경 사항이 일관되고 통제된 경로를 거치도록 마련되었다. 제안은 공개 토론과 팀의 검토를 거쳐 수락 또는 거절되며, 수락된 RFC가 곧바로 안정 기능이 되는 것은 아니다. 구현과 시험, 안정화 과정이 별도로 필요하다.
Go의 제안 절차에서는 먼저 문제를 간단히 설명하는 이슈를 제출하고, 검토 과정에서 즉시 수락·거절하거나 더 자세한 설계 문서를 요구할 수 있다. 제안 검토는 일반적인 합의 형성을 목표로 하며, 언어의 핵심 설계와 충돌하거나 효율적으로 구현하기 어려운 제안은 거절될 수 있다.
Kotlin의 KEEP 저장소는 언어와 표준 라이브러리에 대한 진행 중인 제안과 이미 구현된 변경 사항의 설계 문서를 함께 관리한다.
Swift Evolution은 Swift 언어와 표준 라이브러리, 핵심 도구의 사용자에게 보이는 설계 변경을 공개 절차로 검토한다. 이 절차는 공동체의 폭넓은 의견을 반영하면서도 언어의 전체적인 방향과 개념적 일관성을 유지하는 것을 목표로 한다.
제안의 단계적 발전
언어 기능은 일반적으로 하나의 제안에서 곧바로 정식 표준으로 들어가지 않는다. 다음과 같은 단계를 거칠 수 있다.
아이디어와 문제 제기
↓
초기 설계와 사용 사례
↓
공개 제안 문서
↓
토론과 수정
↓
프로토타입 구현
↓
시험과 실제 사용 경험 수집
↓
사양 문구 작성
↓
안정화 검토
↓
정식 언어 또는 표준에 포함
초기 단계에서는 해결하려는 문제가 실제로 충분히 중요한지 검토한다. 유용해 보이는 기능이라도 기존 문법과 라이브러리로 충분히 해결할 수 있거나 사용 사례가 제한적이면 언어 자체에 추가하지 않을 수 있다.
프로토타입은 제안이 실제로 구현 가능한지 확인하고, 문법과 타입 검사, 오류 메시지, 성능과 도구 지원에서 발생하는 문제를 발견하는 데 사용된다. 구현 경험 없이 문서만으로 기능을 결정하면 숨겨진 복잡성을 뒤늦게 발견할 수 있다.
ECMAScript의 TC39 절차는 제안이 아이디어 단계에서 시작하여 조사와 설계, 구현 피드백과 사양 완성을 거쳐 정식 사양에 포함되도록 여러 단계를 사용한다. 완성 단계에 도달한 제안은 실제 구현과 시험, 편집 가능한 사양 문구 등의 조건을 충족해야 한다.
실험 기능과 안정화
새로운 기능을 즉시 안정 기능으로 제공하면 이후 설계 결함을 발견해도 수정하기 어렵다. 이를 줄이기 위해 언어와 플랫폼은 실험적 기능과 미리 보기 기능, 인큐베이터 API 등을 사용할 수 있다.
실험 단계에서는 다음 사항이 허용될 수 있다.
- 문법과 의미가 이후 버전에서 변경될 수 있음
- 명시적인 컴파일 옵션이 필요함
- 표준 라이브러리의 별도 이름 공간에서 제공됨
- 운영 환경에서 사용을 권장하지 않음
- 호환성 보장이 제한됨
- 사용 경험과 구현 피드백을 수집함
안정화 전에 검토해야 할 항목은 다음과 같다.
- 실제 사용 사례가 충분한가
- 문법과 의미가 기존 기능과 일관적인가
- 구현체가 효율적으로 지원할 수 있는가
- 오류 메시지와 도구 지원이 충분한가
- 향후 확장할 여지가 있는가
- 안전성과 보안에 문제가 없는가
- 기존 프로그램의 의미를 바꾸지 않는가
- 다른 구현체가 독립적으로 구현할 수 있는가
OpenJDK는 JEP 절차를 통해 JDK와 Java 플랫폼의 주요 기능 및 변경 사항을 추적한다. 인큐베이터 모듈과 미리 보기 기능 역시 JEP 절차의 일부로 관리된다.
실험 기능이 오래 유지되면 안정 기능과 실험 기능의 경계가 흐려질 수 있다. 반대로 실제 사용자 경험을 충분히 모으지 않고 안정화하면 잘못된 API와 문법을 장기간 유지해야 할 수 있다. 따라서 실험 기간의 목적과 종료 조건을 미리 정하는 것이 중요하다.
합의와 의사 결정
언어 변경은 단순한 다수결로 결정하기 어려운 경우가 많다. 기능 하나가 기존 사용자와 구현자, 도구 개발자, 라이브러리 작성자에게 서로 다른 영향을 줄 수 있기 때문이다.
일반적인 합의 절차에서는 다음 요소를 고려한다.
- 기술적인 타당성
- 언어의 장기적인 방향
- 실제 사용자 요구
- 구현체와 도구의 부담
- 기존 코드와의 호환성
- 대안의 존재
- 공동체 의견
- 유지 비용
- 담당 팀의 지속적인 관리 능력
합의는 모든 참여자가 제안을 선호해야 한다는 뜻이 아니다. 중요한 반대 의견이 충분히 검토되고, 제안의 장점과 비용을 책임 있는 의사 결정 기관이 판단할 수 있는 상태에 도달하는 것을 뜻할 수 있다.
공개 토론은 다양한 사용 사례와 문제를 발견하는 데 도움이 되지만, 토론 참여자의 수가 실제 사용자 전체를 대표하지 않을 수 있다. 따라서 언어 설계자는 토론 의견뿐 아니라 코드 조사와 성능 측정, 사용자 설문, 기존 오류 사례 및 프로토타입 경험을 함께 활용해야 한다.
거버넌스
거버넌스는 언어의 기술적 방향과 변경 사항을 누가 어떤 절차로 결정하는지를 정의하는 체계이다.
프로그래밍 언어의 거버넌스는 다음과 같은 형태를 가질 수 있다.
- 한 명 또는 소수의 설계자가 최종 결정을 내린다.
- 기업 내부 팀이 언어와 구현체를 관리한다.
- 공개 프로젝트의 핵심 팀과 하위 팀이 역할을 분담한다.
- 산업 회원사로 구성된 위원회가 합의로 결정한다.
- 국가 대표가 참여하는 국제 표준화 절차를 사용한다.
- 재단이나 비영리 조직이 행정과 자원을 지원한다.
거버넌스 구조는 의사 결정의 속도와 투명성, 일관성 및 참여 가능성에 영향을 준다. 한 명의 설계자가 주도하면 일관된 방향을 유지하고 빠르게 결정할 수 있지만, 해당 인물에 대한 의존도가 높아질 수 있다. 위원회 방식은 다양한 이해관계를 반영할 수 있지만 결정에 오랜 시간이 걸릴 수 있다.
좋은 거버넌스 체계는 다음 사항을 명확하게 공개해야 한다.
- 제안을 제출하는 방법
- 제안을 검토하는 담당자와 조직
- 수락과 거절의 기준
- 의견을 제시할 수 있는 방법
- 이해관계 충돌을 처리하는 방법
- 결정과 회의 기록을 공개하는 범위
- 기존 결정을 다시 검토하는 절차
- 프로젝트 팀과 표준화 위원회의 권한 관계
언어의 기술적 사양이 공개되어 있어도 실제 의사 결정 과정이 불투명하면 외부 구현자와 사용자가 장기적인 방향을 예측하기 어렵다.
국제 표준화
국제 표준화는 국가별 표준 기관과 전문가가 참여하여 공통 규격을 만드는 절차이다. 프로그래밍 언어 분야에서는 ISO와 IEC의 합동 기술위원회인 JTC 1 산하 SC 22가 주요 역할을 담당한다. SC 22의 범위에는 프로그래밍 언어와 실행 환경, 시스템 소프트웨어 인터페이스가 포함된다.
C (프로그래밍 언어)의 국제 표준화는 WG14가 담당한다. WG14는 C 언어 표준과 관련 기술 사양을 관리하는 작업반이다.
C++의 국제 표준화는 WG21이 담당한다. WG21은 언어 핵심 규칙과 표준 라이브러리를 포함한 C++ 표준과 기술 문서를 개발한다. 제안은 위원회 문서로 제출되고, 세부 분야별 연구·진화·문구 검토를 거쳐 표준 초안에 반영될 수 있다.
국제 표준은 특정 구현체의 소스 코드를 표준으로 지정하기보다 프로그램과 구현체가 만족해야 할 요구 사항을 문서로 정의한다. 표준 문서는 정기적으로 개정될 수 있으며, 결함 보고와 해석 문제를 다루는 별도의 절차도 운영될 수 있다.
국제 표준화의 장점은 특정 기업이나 구현체에 독립된 공통 기준을 제공한다는 것이다. 반면 국가별 절차와 합의, 문서 검토가 필요하므로 변경 속도가 공개 소프트웨어 프로젝트보다 느릴 수 있다.
산업 표준과 컨소시엄
일부 언어는 국제 표준보다 산업 단체나 컨소시엄을 중심으로 발전한다.
ECMAScript는 Ecma International의 TC39가 관리하는 언어 사양이다. TC39는 제안 문서와 사양 문구를 공개 저장소에서 관리하고, 정기 회의에서 제안의 진행 여부를 논의한다. 실제 JavaScript 구현체와 웹 생태계의 호환성이 중요하기 때문에 여러 브라우저 및 런타임 구현자의 경험이 제안 과정에 반영된다.
이러한 방식은 국제 표준화보다 빠르게 기능을 발전시킬 수 있으며, 실제 구현체의 피드백을 사양에 직접 반영하기 쉽다. 다만 참여 기업과 주요 구현자의 영향력이 상대적으로 커질 수 있으므로 공개 절차와 회의 기록, 합의 기준이 중요하다.
공개 프로젝트의 발전 절차
공개 소프트웨어 프로젝트가 관리하는 언어는 코드 저장소와 이슈 추적기, 공개 제안 문서를 중심으로 발전할 수 있다.
Python의 PEP 체계에서는 언어 기능뿐 아니라 개발 절차와 거버넌스, 패키징 표준도 제안 문서로 다룰 수 있다. PEP 번호와 상태, 변경 기록이 보존되므로 채택된 기능뿐 아니라 거절되거나 철회된 설계의 배경도 확인할 수 있다.
Rust의 RFC 체계는 주요 언어 변경과 표준 라이브러리 변경이 공개 설계 문서를 거치게 한다. 언어 팀과 라이브러리 팀 등 담당 조직이 제안을 검토하며, 구현과 안정화는 RFC 수락 이후에도 별도의 절차로 진행된다.
Go의 제안 절차는 작은 변경까지 모두 긴 설계 문서를 요구하지 않는다. 초기 이슈 검토에서 제안의 중요성과 복잡성을 판단하고, 필요한 경우에만 상세한 설계 문서를 요청한다. 이는 제안 절차의 비용을 변경 규모에 맞게 조절하는 방식이다.
Kotlin의 KEEP와 Swift Evolution도 제안 문서와 공개 토론, 담당 팀의 결정을 통해 언어와 표준 라이브러리를 발전시킨다.
호환성
프로그래밍 언어가 널리 사용되기 시작하면 새로운 기능의 추가보다 기존 프로그램을 깨뜨리지 않는 일이 더 중요해질 수 있다. 호환성은 하나의 개념이 아니라 여러 층으로 나뉜다.
| 종류 | 의미 |
|---|---|
| 소스 호환성 | 기존 소스 코드가 새 컴파일러에서도 다시 컴파일되는가 |
| 바이너리 호환성 | 다시 컴파일하지 않은 라이브러리와 프로그램이 함께 동작하는가 |
| 동작 호환성 | 프로그램이 이전과 같은 의미와 결과를 유지하는가 |
| API 호환성 | 공개 함수와 타입, 모듈의 사용 방법이 유지되는가 |
| ABI 호환성 | 호출 규약과 심벌, 객체 배치가 유지되는가 |
| 데이터 호환성 | 기존 파일과 네트워크, 직렬화 형식을 계속 읽을 수 있는가 |
| 도구 호환성 | 빌드 도구와 디버거, 포매터, 분석기가 계속 동작하는가 |
| 생태계 호환성 | 패키지와 프레임워크가 새 언어 버전에서 함께 사용되는가 |
소스 호환성이 유지되어도 바이너리 호환성이 깨질 수 있다. 기존 코드를 새 컴파일러로 다시 빌드하면 동작하지만, 이전 컴파일러로 만든 라이브러리와 새 프로그램을 직접 연결할 수 없을 수 있다.
반대로 바이너리 호환성이 유지되어도 동작이 미묘하게 달라질 수 있다. 표준 라이브러리 함수의 오류 처리나 정렬 순서, 부동소수점 결과가 바뀌면 프로그램은 실행되더라도 결과가 달라질 수 있다.
Java 언어 사양은 클래스와 인터페이스가 수정되었을 때 기존 바이너리와의 호환성을 유지하는 변경과 그렇지 않은 변경을 구체적으로 구분한다.
하위 호환성
하위 호환성은 새로운 언어 또는 구현체 버전이 이전 버전을 대상으로 작성된 프로그램을 계속 처리할 수 있는 성질이다.
하위 호환성을 유지하려면 다음 사항을 주의해야 한다.
- 새로운 키워드가 기존 식별자와 충돌하지 않는가
- 오버로드와 타입 추론 결과가 달라지지 않는가
- 기존 문법이 새 문법과 모호해지지 않는가
- 표준 라이브러리에 새 함수가 추가되면서 이름 충돌이 발생하지 않는가
- 오류로 처리되던 코드가 다른 의미의 정상 코드로 해석되지 않는가
- 실행 순서와 수치 계산 결과가 바뀌지 않는가
- 기존 ABI와 직렬화 형식이 유지되는가
호환성을 절대적으로 보장하기는 어렵다. 보안 취약점을 수정하거나 명백한 사양 결함을 바로잡기 위해 기존 동작을 변경해야 할 수 있다. 또한 프로그램이 사양에서 보장하지 않은 동작에 의존한 경우 새 구현체에서 깨질 수 있다.
Go 1 호환성 문서는 기존 Go 1 프로그램이 이후 Go 1 구현에서 계속 컴파일되고 실행될 것이라는 기대를 제시하면서도, 보안 문제와 명시되지 않은 동작 등 예외가 존재할 수 있음을 설명한다.
폐기와 제거
더 나은 기능이 등장했거나 안전하지 않은 기능을 더 이상 권장하지 않더라도, 기존 기능을 즉시 제거하면 많은 프로그램이 깨질 수 있다. 이를 위해 폐기 절차를 사용한다.
일반적인 폐기 과정은 다음과 같다.
대체 기능 제공
↓
기존 기능을 폐기 예정으로 표시
↓
컴파일러와 문서에서 경고
↓
자동 변환 도구 제공
↓
충분한 이전 기간 유지
↓
주요 버전이나 새 언어 모드에서 제거
폐기 기능은 사용을 권장하지 않는다는 뜻이며 곧바로 사용할 수 없다는 뜻은 아니다. 폐기 경고에는 대체 방법과 제거 예정 시점이 포함되는 편이 좋다.
기능을 제거하지 않고 영구적으로 유지하면 언어와 구현체의 복잡성이 계속 증가한다. 반대로 너무 빠르게 제거하면 생태계가 새 버전으로 이동하지 못한다. 따라서 사용량과 보안 위험, 유지 비용, 자동 이전 가능성을 함께 고려해야 한다.
언어 버전과 판
언어의 변경 사항을 어떤 단위로 묶어 배포할지도 중요한 설계 문제이다.
일반적인 방식에는 다음이 있다.
- 일정한 간격으로 새로운 언어 버전을 발표한다.
- 완성된 기능을 지속적으로 사양에 반영한다.
- 장기간 지원되는 주요 버전을 별도로 제공한다.
- 소스 파일이나 프로젝트에서 언어 버전을 선택하게 한다.
- 서로 다른 언어 판을 같은 컴파일러에서 지원한다.
Rust는 하위 호환되지 않는 일부 변경을 판이라는 선택 가능한 경계에 배치한다. 프로젝트가 새 판을 명시적으로 선택하지 않으면 기존 판의 규칙을 유지하며, 서로 다른 판을 사용하는 라이브러리도 같은 의존성 그래프에서 함께 사용할 수 있다. Rust는 자동 이전 도구를 제공하여 새 판으로의 소스 변환을 지원한다.
이 방식은 언어 전체를 한 번에 분리된 새 버전으로 나누지 않으면서 문법적으로 호환되지 않는 변경을 도입할 수 있게 한다. 그러나 컴파일러와 도구는 여러 판의 문법과 의미 차이를 동시에 관리해야 한다.
ECMAScript는 완성된 제안을 살아 있는 최신 사양에 반영하고, 이를 정기적인 연도별 판으로 출판하는 방식을 사용한다.
표준과 구현의 배포 주기
표준 문서의 개정 주기와 컴파일러의 배포 주기는 서로 다를 수 있다. 구현체는 아직 최종 표준에 포함되지 않은 기능을 실험적으로 제공하거나, 새 표준이 발표된 뒤 기능을 단계적으로 구현할 수 있다.
이 경우 다음 상태를 구분할 필요가 있다.
- 제안만 존재하고 구현되지 않은 기능
- 실험 구현만 존재하는 기능
- 사양에는 포함되었지만 일부 구현체가 지원하지 않는 기능
- 주요 구현체에서 지원하지만 공식 표준에는 포함되지 않은 확장
- 표준과 주요 구현체 모두에서 안정적으로 지원되는 기능
사용자는 언어 버전뿐 아니라 구현체 버전과 대상 플랫폼, 활성화된 기능 옵션을 함께 확인해야 한다. 동일한 언어 버전을 표시하더라도 구현체별로 지원 범위와 결함이 다를 수 있다.
표준 확장
실제 구현체는 표준에 없는 확장 기능을 제공할 수 있다. 하드웨어별 벡터 타입과 속성, 인라인 어셈블리, 운영체제 전용 API, 컴파일러 내장 함수 등이 대표적이다.
확장 기능은 표준만으로 표현하기 어려운 하드웨어와 플랫폼 기능을 사용할 수 있게 한다. 그러나 확장 기능에 의존한 프로그램은 다른 컴파일러와 플랫폼으로 이식하기 어려워질 수 있다.
구현체는 확장 기능을 다음과 같이 관리할 수 있다.
- 별도의 이름 공간이나 접두사를 사용한다.
- 확장을 명시적으로 활성화하게 한다.
- 표준 모드에서 확장 사용을 진단한다.
- 기능 탐지 매크로나 속성을 제공한다.
- 널리 채택된 확장을 표준화 위원회에 제안한다.
여러 구현체에서 비슷한 확장을 독립적으로 제공하면 표준화의 후보가 될 수 있다. 반대로 특정 구현체의 내부 구조에 지나치게 종속된 기능은 공통 표준으로 만들기 어려울 수 있다.
기능 탐지
프로그램이 여러 언어 버전과 구현체를 지원하려면 특정 기능을 사용할 수 있는지 확인하는 방법이 필요하다.
단순히 컴파일러 버전 번호를 검사하면 포크된 구현체나 기능의 개별 백포트를 정확하게 처리하지 못할 수 있다. 따라서 언어는 다음과 같은 기능 탐지 방식을 제공할 수 있다.
- 특정 문법 기능의 지원 여부 확인
- 표준 라이브러리 API의 존재 여부 확인
- 속성과 내장 함수 지원 여부 확인
- 대상 운영체제와 CPU 기능 확인
- 언어 판 또는 기능 집합 선택
- 조건부 컴파일
기능 탐지는 프로그램에 조건 분기를 늘려 복잡성을 높일 수 있으므로, 공통적으로 지원되는 최소 버전을 정하고 필요한 차이만 제한적으로 처리하는 것이 바람직하다.
사양 결함과 해석
아무리 상세한 사양도 모호한 표현과 모순, 누락을 포함할 수 있다. 구현체를 개발하거나 새로운 사용 사례가 등장하면서 사양의 문제가 발견될 수 있다.
사양 결함은 다음과 같은 형태로 나타난다.
- 문법이 두 가지 방식으로 해석될 수 있음
- 서로 다른 절의 규칙이 충돌함
- 특정 경계 조건의 결과가 정의되지 않음
- 예제와 규범 문구가 일치하지 않음
- 구현할 수 없거나 지나치게 비효율적인 요구가 있음
- 기존 구현체의 공통 동작과 사양이 다름
표준화 조직과 언어 프로젝트는 결함 보고서를 접수하고, 사양의 의도와 기존 구현 및 호환성을 검토하여 수정한다. 단순한 편집 수정은 빠르게 반영할 수 있지만 프로그램의 의미를 바꾸는 수정은 언어 변경 제안과 같은 검토가 필요할 수 있다.
사양 결함을 수정할 때는 기존 프로그램이 어느 해석에 의존하고 있는지도 고려해야 한다. 이론적으로 올바른 규칙으로 바꾸더라도 널리 사용되는 프로그램을 깨뜨린다면 단계적인 이전 절차가 필요할 수 있다.
구현 경험과 실증적 설계
언어 기능은 이론적인 일관성뿐 아니라 실제 코드에서의 사용성을 검토해야 한다. 프로토타입 컴파일러와 실험 브랜치, 외부 라이브러리는 기능을 정식 언어에 포함하기 전에 경험을 축적하는 방법이다.
실증적 설계에서는 다음과 같은 자료를 사용할 수 있다.
- 실제 코드베이스에서 반복되는 패턴
- 자주 발생하는 컴파일 오류
- 기능을 사용한 코드의 길이와 복잡도
- 컴파일 시간과 실행 성능
- 생성되는 바이너리 크기
- IDE와 정적 분석기의 구현 난도
- 신규 사용자의 학습 결과
- 기존 기능으로 작성한 대안과의 비교
- 오픈소스 프로젝트에 대한 자동 변환 결과
기능이 몇 개의 예제에서는 간결해 보여도 대규모 코드에서 오류 메시지와 추론 결과가 복잡해질 수 있다. 반대로 처음에는 제한적으로 보이는 기능이 여러 분야에서 공통적인 문제를 해결할 수도 있다.
Java 언어 사양의 과거 개정에서도 실제 오픈소스 코드 집합을 이용해 타입 추론 방식의 효과를 측정하는 등, 성숙한 언어의 변경에서 실제 코드 분석이 활용되었다.
보안과 안전성
언어 설계는 프로그램의 표현력과 성능뿐 아니라 보안 취약점을 만들기 쉬운 구조인지도 고려해야 한다.
주요 검토 대상은 다음과 같다.
- 배열 범위를 벗어난 접근
- 해제된 메모리와 잘못된 포인터 사용
- 정수 오버플로와 잘림
- 문자열과 명령의 잘못된 결합
- 초기화하지 않은 값의 사용
- 데이터 경쟁
- 잘못된 역직렬화
- 권한과 신뢰 경계
- 예외와 자원 정리
- 코드 생성과 매크로의 위생
- 공급망과 패키지 실행 코드
모든 안전성을 언어 차원에서 보장할 수는 없다. 그러나 안전한 기본값을 제공하고 위험한 연산을 명시적인 경계로 제한하면 결함이 발생할 수 있는 범위를 줄일 수 있다.
안전 기능은 실행 비용과 구현 복잡성에도 영향을 준다. 범위 검사와 오버플로 검사, 런타임 타입 검사는 추가 비용을 만들 수 있으므로 컴파일러가 불필요한 검사를 제거할 수 있도록 의미 규칙과 중간 표현을 설계해야 한다.
이식성
이식성은 프로그램을 서로 다른 구현체와 운영체제, 프로세서에서 적은 수정으로 사용할 수 있는 성질이다. C 표준화와 SC 22의 활동에서도 이식성은 핵심적인 목적이다.
이식성을 높이려면 다음을 고려해야 한다.
- 기본 타입의 크기와 범위
- 정수와 부동소수점 연산
- 엔디언과 정렬
- 파일 경로와 문자 인코딩
- 스레드와 메모리 모델
- 운영체제 API의 차이
- 시간과 로케일
- 네트워크와 파일 시스템
- 외부 라이브러리와 ABI
- 조건부 컴파일
모든 플랫폼의 차이를 완전히 감추는 것은 어려우며, 지나친 추상화는 플랫폼 고유 기능을 사용하기 어렵게 만들 수 있다. 언어와 표준 라이브러리는 공통 기능을 제공하면서도 필요한 경우 플랫폼별 확장에 접근할 수 있는 구조를 마련해야 한다.
언어의 안정성과 발전
프로그래밍 언어는 변화하지 않으면 새로운 하드웨어와 개발 방식, 안전성 요구에 대응하기 어렵다. 반대로 너무 빠르게 변화하면 사용자가 코드를 지속적으로 수정해야 하며 라이브러리와 도구가 안정되기 어렵다.
언어의 안정적인 발전을 위해서는 다음 요소가 필요하다.
- 명확한 설계 목표
- 문서화된 변경 절차
- 공개적인 제안과 검토
- 실제 구현과 사용 경험
- 적합성 시험
- 하위 호환성 정책
- 폐기와 이전 절차
- 자동화된 코드 변환 도구
- 사양과 구현의 지속적인 동기화
- 책임 있는 거버넌스
언어에 기능을 추가하는 것뿐 아니라 기능을 추가하지 않는 결정도 중요하다. 한 번 표준화된 문법과 의미, API는 장기간 유지해야 하므로 단기적인 편의보다 언어 전체의 일관성과 유지 비용을 고려해야 한다.
표준화 역시 언어를 고정하여 변화를 막는 과정이 아니다. 합의된 공통 기준을 유지하면서 새로운 요구를 검토하고, 기존 프로그램의 안정성과 앞으로의 발전 가능성을 조정하는 과정이다.
따라서 프로그래밍 언어의 설계와 표준화는 문법과 기능을 결정하는 기술 작업인 동시에, 여러 구현체와 사용자, 라이브러리 및 도구가 오랜 기간 함께 발전할 수 있도록 규칙과 의사 결정 구조를 만드는 작업이다. 언어의 성공은 개별 기능의 수보다 설계 원칙의 일관성, 사양의 정확성, 구현 품질, 호환성 정책과 생태계가 서로 얼마나 안정적으로 연결되어 있는지에 의해 결정된다.
활용 분야
프로그래밍 언어는 운영체제와 장치 제어부터 웹 서비스, 모바일 애플리케이션, 게임, 과학 계산, 인공지능과 업무 자동화에 이르기까지 컴퓨터를 사용하는 거의 모든 분야에 활용된다. 그러나 모든 언어가 모든 분야에서 같은 수준으로 적합한 것은 아니다. 언어의 실행 모델과 타입 체계, 메모리 관리 방식, 지원 플랫폼, 표준 라이브러리, 개발 도구와 기존 생태계가 분야별 요구와 얼마나 잘 맞는지에 따라 실제 사용 범위가 달라진다.
프로그래밍 언어의 활용 분야는 언어 자체의 기능만으로 결정되지 않는다. 같은 언어라도 특정 운영체제와 프레임워크, 런타임 또는 산업 생태계와 결합하면서 특정 분야에서 널리 사용될 수 있다. Kotlin은 범용 프로그래밍 언어이지만 Android 개발 환경과 긴밀하게 결합되어 있으며, Swift는 Apple 플랫폼의 애플리케이션 개발에서 중심적으로 사용된다. JavaScript는 웹 브라우저의 표준 실행 언어라는 위치를 바탕으로 웹 프런트엔드에서 중요한 역할을 한다. Google은 Android 개발을 Kotlin 중심으로 운영하고 있으며, Apple은 Swift를 iOS, iPadOS, macOS, tvOS와 watchOS를 포함한 Apple 플랫폼을 위한 언어로 제공한다.
하나의 소프트웨어 시스템도 여러 언어로 구성될 수 있다. 게임은 엔진과 성능 중심 시스템을 C++로 구현하고, 게임 규칙이나 도구를 C#, Lua 또는 시각적 스크립트로 작성할 수 있다. 웹 서비스는 브라우저에서 JavaScript 또는 TypeScript를 사용하고, 서버에서는 Java, C#, Go, Python이나 Rust를 사용하며, 데이터베이스 질의는 SQL로 작성할 수 있다. 머신러닝 시스템은 Python으로 모델과 학습 과정을 구성하면서 계산량이 큰 연산을 C++, CUDA 또는 다른 가속기용 언어로 구현할 수 있다.
따라서 활용 분야를 이해할 때는 특정 언어를 하나의 분야에 고정하기보다, 해당 분야에서 요구하는 조건과 언어가 맡는 역할을 함께 살펴보아야 한다.
시스템 소프트웨어
시스템 소프트웨어는 운영체제와 장치 드라이버, 컴파일러, 런타임, 파일 시스템, 데이터베이스 엔진, 네트워크 스택과 가상 머신처럼 다른 프로그램의 실행 기반을 제공하는 소프트웨어이다.
시스템 소프트웨어에서는 다음과 같은 특성이 중요하다.
- 메모리의 배치와 수명을 세밀하게 제어할 수 있어야 한다.
- 운영체제와 하드웨어 인터페이스에 직접 접근할 수 있어야 한다.
- 실행 시간과 메모리 사용을 예측할 수 있어야 한다.
- 별도의 대규모 런타임 없이 실행할 수 있어야 한다.
- 여러 CPU 아키텍처와 ABI를 지원할 수 있어야 한다.
- 외부 라이브러리와 운영체제 API를 안정적으로 호출할 수 있어야 한다.
- 장기간 유지되는 코드에서 성능과 호환성을 보존할 수 있어야 한다.
C (프로그래밍 언어)는 포인터와 구조체, 명시적인 메모리 관리, 비교적 단순한 실행 모델과 널리 지원되는 ABI를 제공하여 운영체제와 시스템 라이브러리, 임베디드 소프트웨어에서 오랫동안 사용되어 왔다. C 표준은 C 프로그램의 표현과 구문, 제약, 의미 규칙 및 적합한 구현체가 따라야 할 제한을 정의한다.
C++는 C에 가까운 하드웨어 제어와 네이티브 실행 성능을 유지하면서 클래스와 템플릿, 일반화 프로그래밍, 범위 기반 자원 관리 및 표준 라이브러리를 제공한다. 브라우저와 게임 엔진, 데이터베이스, 컴파일러와 대규모 데스크톱 프로그램처럼 성능과 복잡한 추상화가 동시에 필요한 시스템에 사용된다.
Rust는 소유권과 대여, 수명 검사를 이용하여 가비지 컬렉터 없이 메모리 안전성을 확보하려는 시스템 프로그래밍 언어이다. 명령행 프로그램과 네트워크 서비스, WebAssembly, 임베디드 장치 등이 Rust 프로젝트에서 주요 적용 분야로 다루어져 왔다.
시스템 프로그래밍이라고 해서 모든 코드를 저수준 언어로 작성해야 하는 것은 아니다. 운영체제의 핵심 부분과 하드웨어 경계는 네이티브 언어로 구현하고, 관리 도구와 설치 프로그램, 빌드 시스템 및 시험 자동화는 Python이나 셸 언어로 작성할 수 있다. 시스템 전체의 신뢰성과 개발 효율을 위해 서로 다른 수준의 언어를 조합하는 경우가 많다.
임베디드 시스템
임베디드 시스템은 특정 장치나 제품 안에 포함되어 제한된 목적을 수행하는 컴퓨터 시스템이다. 마이크로컨트롤러와 센서, 자동차 제어기, 산업 장비, 가전제품, 의료기기, 드론, 통신 장치와 사물 인터넷 기기 등이 포함된다.
임베디드 환경은 장치에 따라 매우 다르다. 일부 장치는 수 킬로바이트의 메모리만 가진 8비트 마이크로컨트롤러를 사용하지만, 다른 장치는 다중 코어 프로세서와 운영체제, 수 기가바이트의 메모리를 갖출 수 있다. Embedded Rust 문서도 임베디드 프로그래밍의 범위가 작은 8비트 MCU부터 다중 코어 시스템까지 넓으며, 대상에 따라 서로 다른 제약이 적용된다고 설명한다.
임베디드 소프트웨어에서는 다음 요소가 중요하다.
- 제한된 RAM과 저장 공간
- 낮은 전력 소비
- 하드웨어 레지스터와 주변 장치 제어
- 인터럽트 처리
- 실시간 응답
- 운영체제가 없는 bare-metal 실행
- 장기간의 안정성
- 원격 업데이트와 장애 복구
- 안전성과 보안
- 대상 장치에 대한 크로스 컴파일
C와 C++는 임베디드 환경에서 널리 지원되는 컴파일러와 장치용 라이브러리, 하드웨어 제조사의 SDK를 기반으로 사용된다. Rust도 운영체제와 표준 라이브러리가 없는 no_std 환경을 지원하며, 마이크로컨트롤러에서 언어의 소유권 및 타입 안전성 기능을 활용할 수 있다. Embedded Rust Book은 Rust를 bare-metal 마이크로컨트롤러에 사용하는 방법을 공식적으로 설명한다.
임베디드 환경에서는 동시성이 운영체제 스레드로만 나타나지 않는다. 인터럽트 처리기가 예측할 수 없는 시점에 실행되고, 실시간 작업이 번갈아 수행되며, 다중 코어가 독립적으로 코드를 실행할 수 있다. 이러한 실행 구조에서는 공유 상태와 원자적 접근, 인터럽트 우선순위 및 실시간 스케줄링을 함께 고려해야 한다.
실시간 시스템
실시간 시스템은 단순히 빠르게 실행되는 시스템이 아니라, 정해진 시간 안에 결과를 제공해야 올바른 것으로 인정되는 시스템이다. 공장 제어와 로봇, 항공기 제어, 차량 제어, 통신 장치, 의료기기 및 오디오 처리 등이 대표적인 적용 분야이다.
실시간 시스템에서는 평균 처리 속도보다 최악 실행 시간과 지연 시간의 상한, 작업 우선순위, 인터럽트 응답, 자원 경쟁과 스케줄링의 예측 가능성이 중요하다. 긴 시간 동안 임의로 실행을 중단할 수 있는 가비지 컬렉션이나 실행 중 컴파일은 일부 실시간 환경에 부적합할 수 있으므로, 실시간용으로 제한된 런타임이나 예측 가능한 메모리 관리 방식을 사용한다.
Ada는 작업과 실시간 실행, 강한 타입 체계 및 높은 무결성이 필요한 시스템을 지원하도록 발전하였다. Ada 표준의 고무결성 시스템 부속서는 높은 신뢰성과 안전성이 필요한 시스템을 위한 제한과 실행 환경을 다룬다.
실시간 프로그램도 모든 구성 요소를 하나의 언어로 작성할 필요는 없다. 시간 제약이 엄격한 제어 루프는 C, C++, Ada 또는 Rust로 구현하고, 구성 도구와 데이터 분석, 시뮬레이션 및 사용자 인터페이스는 다른 고급 언어로 구현할 수 있다.
데스크톱 애플리케이션
데스크톱 애플리케이션은 Windows와 macOS, Linux 같은 개인용 컴퓨터 운영체제에서 직접 실행되는 프로그램이다. 문서 편집기와 그래픽 도구, 개발 환경, 메신저, 미디어 프로그램, 설계 도구, 파일 관리자와 기업용 클라이언트가 포함된다.
데스크톱 애플리케이션에서는 다음과 같은 요구가 중요하다.
- 운영체제의 창과 입력, 파일 및 알림 기능과의 통합
- 복잡한 그래픽 사용자 인터페이스
- 로컬 파일과 장치 접근
- 오프라인 실행
- 설치와 자동 업데이트
- 여러 운영체제 지원
- 접근성과 국제화
- 장시간 실행에 대한 안정성
- 네이티브 성능과 메모리 사용
Windows의 네이티브 프로그램은 C++와 C#, .NET을 사용할 수 있으며, macOS 프로그램은 Swift와 Objective-C, C++를 사용할 수 있다. 여러 운영체제를 지원하는 프로그램에서는 C++와 Qt, C# 기반 다중 플랫폼 프레임워크, Java와 JVM, JavaScript 기반 데스크톱 런타임, Rust 기반 GUI 도구 등을 사용할 수 있다.
Python도 데스크톱 애플리케이션과 스크립트, 웹 서비스 및 과학 계산을 포함한 여러 개발 영역에서 활용될 수 있으며, GUI 라이브러리와 네이티브 확장 모듈을 조합할 수 있다.
데스크톱 프로그램은 사용자 인터페이스 전체를 한 언어로 작성하더라도 내부 계산 엔진을 다른 언어의 라이브러리로 구성할 수 있다. 영상 편집기와 3D 제작 도구는 UI 및 스크립트 계층과 고성능 미디어·그래픽 처리 계층을 분리하는 경우가 많다.
모바일 애플리케이션
모바일 애플리케이션은 스마트폰과 태블릿, 웨어러블 장치에서 실행되며 터치 입력과 센서, 카메라, 알림, 위치 정보, 제한된 전력 및 백그라운드 실행 정책을 고려해야 한다.
Android에서는 Kotlin과 Java가 주요 언어로 사용된다. Google은 Android 개발에서 Kotlin을 우선적으로 권장하며, Kotlin이 기존 Java 코드와 통합될 수 있도록 지원한다.
Kotlin의 코루틴은 네트워크 요청이나 데이터베이스 접근처럼 UI 스레드를 차단해서는 안 되는 비동기 작업을 표현하는 데 사용된다. Android 공식 문서는 코루틴을 비동기 프로그래밍을 위한 권장 방식으로 설명하며, 구조화된 동시성과 취소 전파를 주요 특성으로 제시한다.
Apple 플랫폼에서는 Swift와 Objective-C가 사용된다. Apple은 Swift를 모든 Apple 플랫폼을 위한 언어로 제공하며, SwiftUI는 Swift를 사용해 여러 Apple 장치의 사용자 인터페이스를 구축하는 프레임워크이다.
다중 플랫폼 모바일 개발에서는 하나의 코드베이스에서 Android와 iOS를 함께 지원하는 프레임워크를 사용할 수 있다. Kotlin Multiplatform은 Android와 iOS 사이에서 업무 로직을 공유할 수 있도록 지원되며, 필요에 따라 플랫폼별 UI 또는 공유 UI와 결합할 수 있다.
모바일 게임과 고성능 미디어 프로그램은 C++로 작성된 공통 엔진을 플랫폼별 Kotlin·Java 또는 Swift·Objective-C 계층과 연결하기도 한다.
웹 프런트엔드
웹 프런트엔드는 웹 브라우저에서 실행되어 문서의 화면 표현과 사용자 입력, 네트워크 통신, 애니메이션 및 멀티미디어를 처리하는 소프트웨어 영역이다.
웹의 기본 기술은 역할에 따라 구분된다.
- HTML은 문서의 구조와 의미를 표현한다.
- CSS는 문서의 모양과 배치를 정의한다.
- JavaScript는 사용자 입력과 동적인 상태 변화, 브라우저 API 사용을 처리한다.
JavaScript는 웹 페이지에 동적인 콘텐츠 갱신과 그래픽, 멀티미디어 제어 및 상호 작용을 추가하는 프로그래밍 언어이다. 브라우저는 문서 객체 모델과 네트워크, 저장소, 그래픽, 오디오 등 다양한 Web API를 JavaScript에 제공한다.
TypeScript는 JavaScript에 정적 타입 표기와 분석 기능을 추가하고 일반 JavaScript로 변환된다. 대규모 프런트엔드 프로그램에서는 컴포넌트의 속성과 네트워크 데이터, 상태 관리 구조의 타입을 명시하여 코드 탐색과 리팩터링, 오류 탐지를 보조할 수 있다.
웹 프런트엔드는 브라우저 환경의 제약을 받는다. 파일과 장치, 네트워크 접근은 브라우저의 보안 모델과 사용자 권한을 따라야 하며, 서로 다른 브라우저의 기능 지원 상태와 성능, 접근성을 고려해야 한다.
WebAssembly는 C, C++, Rust 등의 언어에서 생성된 저수준 코드를 웹 환경에서 실행하는 대상으로 사용할 수 있다. 이미지와 오디오 처리, 게임, 시뮬레이션, 압축과 같은 계산 중심 모듈을 WebAssembly로 구현하고 JavaScript가 브라우저 UI와 API 연결을 담당하는 방식이 가능하다.
웹 백엔드와 서버 애플리케이션
웹 백엔드는 클라이언트의 요청을 받아 인증과 업무 로직, 데이터베이스 접근, 파일 처리 및 외부 서비스 통신을 수행하는 서버 측 소프트웨어이다.
웹 서버와 API에서는 다음 요소가 중요하다.
- 많은 동시 연결 처리
- 네트워크 입출력과 비동기 실행
- 데이터베이스 연결
- 인증과 권한 관리
- 입력 데이터 검증
- 수평 확장
- 관찰 가능성과 로그
- 보안 업데이트
- 안정적인 배포
- 기존 서비스 및 라이브러리와의 통합
Java와 Kotlin은 JVM의 서버 프레임워크와 기업 생태계를 사용할 수 있으며, C#은 .NET의 웹 및 클라우드 프레임워크와 결합된다. Python은 빠른 개발과 광범위한 라이브러리를 바탕으로 웹 API와 관리 서비스, 데이터 중심 백엔드에 사용할 수 있다. JavaScript와 TypeScript는 Node.js와 같은 서버 런타임을 통해 브라우저와 서버에서 언어와 자료 구조를 공유할 수 있다.
Go는 고루틴과 채널, 네트워크 및 HTTP 표준 라이브러리, 네이티브 배포를 바탕으로 네트워크 서비스와 클라우드 도구에 사용된다. Rust는 비동기 런타임과 메모리 안전성, 네이티브 성능이 필요한 서비스에 적용될 수 있다. Rust 프로젝트에서도 네트워크 서비스는 주요 적용 분야로 다루어졌다.
서버의 언어 선택은 요청 한 건의 실행 성능만으로 결정되지 않는다. 서비스의 개발 속도와 장애 대응, 라이브러리 성숙도, 배포 환경, 메모리 사용, 시작 시간, 운영 인력의 경험과 기존 시스템과의 호환성이 함께 영향을 준다.
클라우드 컴퓨팅
클라우드 컴퓨팅 환경에서는 프로그램이 가상 머신과 컨테이너, 서버리스 실행 환경, 관리형 데이터 서비스와 여러 지역의 분산 인프라 위에서 실행된다.
클라우드 소프트웨어는 다음 영역으로 나뉠 수 있다.
- 인프라 제어와 자원 프로비저닝
- 컨테이너 및 클러스터 관리
- 마이크로서비스와 API
- 서버리스 함수
- 로그와 메트릭 수집
- 데이터 처리 파이프라인
- 배포 및 운영 자동화
- 보안 정책과 접근 제어
Go는 단일 실행 파일과 비교적 빠른 시작, 동시성 및 네트워크 프로그래밍 기능을 바탕으로 클라우드 인프라 도구에 사용된다. Java와 C#은 기업 시스템과 관리형 클라우드 서비스에서 널리 활용되며, Python은 클라우드 API를 이용한 자동화와 데이터 처리, 서버리스 함수에 사용할 수 있다. Rust와 C++는 프록시와 저장소 엔진, 네트워크 구성 요소처럼 높은 처리량과 낮은 지연 시간이 필요한 기반 소프트웨어에 적용될 수 있다.
클라우드 프로그램은 특정 언어의 런타임 특성과 운영 비용의 영향을 받는다. 메모리 사용량과 프로그램 시작 시간은 서버리스 환경과 대규모 컨테이너 배포에서 비용에 영향을 줄 수 있으며, 가비지 컬렉션과 JIT 컴파일은 장기 실행 서비스와 짧게 실행되는 함수에서 서로 다른 특성을 보인다.
분산 시스템
분산 시스템은 서로 독립된 메모리와 장애 가능성을 가진 여러 컴퓨터가 네트워크를 통해 하나의 서비스를 구성하는 시스템이다.
분산 시스템에서는 다음 문제가 중요하다.
- 네트워크 지연과 단절
- 메시지 중복과 순서 변경
- 일부 노드만 실패하는 부분 장애
- 데이터 복제와 일관성
- 작업의 재시도
- 시간과 순서의 불확실성
- 장애 감지
- 서비스 탐색
- 부하 분산
- 버전이 다른 서비스 사이의 통신
언어가 메시지 전달과 액터, 비동기 함수, 경량 작업과 구조화된 동시성을 제공하면 이러한 시스템의 일부 구조를 더 직접적으로 표현할 수 있다. 그러나 언어 기능만으로 분산 합의와 데이터 일관성, 네트워크 분할 문제를 자동으로 해결할 수는 없다. 데이터 저장 방식과 통신 프로토콜, 재시도 정책 및 장애 모델을 별도로 설계해야 한다.
Java와 C#, Go, Rust, Erlang, Elixir, Scala, C++와 Python 등 다양한 언어가 분산 서비스에 사용된다. Erlang 계열 언어는 격리된 프로세스와 메시지 전달, 감독 구조를 중심으로 하며, JVM과 .NET 기반 언어는 성숙한 서버 및 관찰 도구 생태계를 활용한다.
명령행 프로그램
명령행 프로그램은 터미널이나 셸에서 실행되며 인수와 표준 입력을 받아 결과를 표준 출력이나 파일로 제공한다. 컴파일러와 버전 관리 도구, 파일 변환기, 패키지 관리자, 빌드 도구, 시스템 관리 프로그램이 포함된다.
명령행 프로그램에서는 다음 특성이 중요하다.
- 빠른 시작 시간
- 명확한 인수와 도움말
- 파이프와 파일을 이용한 조합
- 자동화하기 쉬운 종료 코드
- 여러 운영체제에서의 배포
- 작은 실행 파일과 적은 의존성
- 오류 메시지와 로그
- 셸 자동 완성
셸 언어는 운영체제 명령과 파이프라인을 빠르게 연결하는 데 적합하지만, 프로그램이 커질수록 타입과 자료 구조, 오류 처리의 한계가 나타날 수 있다. Python은 표준 라이브러리의 argparse 등을 이용하여 명령행 인수를 처리할 수 있고, Rust는 네이티브 실행 파일과 패키지 생태계를 이용한 CLI 개발 분야를 공식 적용 영역으로 다룬다.
Go와 C++, C#도 독립 실행형 관리 도구와 개발 도구에 사용된다. 설치 과정과 대상 플랫폼, 시작 시간, 네이티브 라이브러리 사용 여부에 따라 적합한 언어가 달라진다.
스크립트와 업무 자동화
스크립트는 파일 처리와 프로그램 실행, 데이터 변환, 시스템 설정, 반복 작업을 자동화하기 위해 작성되는 비교적 작은 프로그램을 가리킨다.
자동화 분야에서는 다음 기능이 중요하다.
- 문자열과 파일 처리
- 운영체제 명령 실행
- JSON과 XML, CSV 처리
- HTTP 요청과 API 호출
- 빠른 수정과 실행
- 외부 프로그램과의 연결
- 오류와 로그 처리
- 여러 운영체제 지원
셸 언어는 파일과 프로세스, 파이프라인을 직접 제어하는 데 적합하다. Python은 표준 라이브러리와 외부 패키지를 통해 파일, 네트워크, 직렬화, 테스트와 명령행 도구를 구성할 수 있다. PowerShell은 객체 기반 파이프라인과 Windows 관리 환경을 제공하며, JavaScript는 Node.js 기반 도구와 웹 개발 자동화에서 사용된다.
짧은 일회성 작업은 간단한 스크립트로 충분하지만, 자동화 프로그램이 배포와 보안, 중요한 데이터를 담당하게 되면 일반 애플리케이션과 같은 수준의 타입 검사와 시험, 오류 처리, 권한 관리와 유지보수가 필요하다.
DevOps와 빌드 자동화
DevOps와 소프트웨어 전달 환경에서는 소스 코드를 빌드하고 시험하며 패키징하고 배포하는 과정을 자동화한다.
사용되는 프로그램은 다음을 포함한다.
- 빌드 스크립트
- 지속적 통합 파이프라인
- 컨테이너 이미지 구성
- 인프라 정의
- 배포 및 롤백 도구
- 시험 자동화
- 로그와 상태 수집
- 개발 환경 구성
셸과 Python, Go, JavaScript, Ruby와 PowerShell 등이 자동화 도구 작성에 사용된다. 일부 빌드 및 인프라 시스템은 YAML과 같은 선언 형식이나 자체 도메인 특화 언어를 제공한다.
자동화 환경에서는 재현성과 멱등성, 비밀 정보 관리, 실패 시 중단과 복구, 실행 로그가 중요하다. 문법이 짧다는 이유만으로 언어를 선택하기보다 실패를 정확하게 감지하고 환경 차이를 통제할 수 있는지를 고려해야 한다.
데이터베이스와 데이터 관리
데이터베이스 분야에서는 데이터 저장 엔진을 구현하는 언어와 저장된 데이터를 질의하는 언어가 구분된다.
데이터베이스 관리 시스템의 내부 엔진은 다음 기능을 수행한다.
- 디스크와 메모리 페이지 관리
- 인덱스
- 질의 최적화
- 트랜잭션
- 동시성 제어
- 로그와 복구
- 복제
- 네트워크 프로토콜
이러한 구성 요소는 성능과 메모리, 동시성을 세밀하게 다뤄야 하므로 C와 C++, Rust, Java 등이 사용될 수 있다.
사용자는 SQL과 같은 선언형 언어로 원하는 데이터의 조건과 결과 형태를 기술한다. 실제 인덱스 선택과 조인 순서, 병렬 처리 방법은 데이터베이스의 질의 최적화기가 결정한다.
데이터베이스 주변의 애플리케이션은 Java와 C#, Python, JavaScript, Go, PHP 등 다양한 언어를 사용하여 SQL을 실행하고 결과를 도메인 객체로 변환한다. 객체 관계 매핑 도구를 사용할 수도 있지만, 데이터베이스의 트랜잭션과 인덱스, 질의 실행 계획을 완전히 추상화할 수는 없다.
데이터 공학
데이터 공학은 여러 출처에서 데이터를 수집하고 정제하며 변환하여 분석과 서비스에 사용할 수 있는 형태로 만드는 분야이다.
주요 작업은 다음과 같다.
- 데이터 수집
- 배치 처리와 스트림 처리
- 스키마 변환
- 데이터 품질 검사
- 데이터 저장소 간 이동
- 작업 스케줄링
- 분산 처리
- 메타데이터 관리
- 분석용 데이터 모델 생성
Python과 SQL은 데이터 처리 절차와 분석 시스템을 구성하는 데 사용되며, Java와 Scala는 JVM 기반 분산 데이터 처리 시스템에서 사용된다. Azure Databricks 역시 데이터 공학과 데이터 과학 작업을 위해 여러 프로그래밍 언어를 지원하며, 언어별로 사용할 수 있는 위치와 제한이 다르다고 설명한다.
데이터 공학에서는 개별 연산의 언어 성능보다 분산 실행 엔진과 저장 형식, 네트워크 비용, 데이터 배치와 파티셔닝이 전체 성능에 더 큰 영향을 미칠 수 있다. 고수준 언어로 작성된 작업이 내부적으로 JVM이나 C++, 네이티브 벡터 연산으로 실행되기도 한다.
데이터 분석과 통계
데이터 분석은 데이터를 탐색하고 요약하며 통계적 관계를 찾고 시각화하는 분야이다.
Python과 R (프로그래밍 언어)은 대화형 분석과 통계, 데이터 시각화 생태계를 제공한다. SQL은 분석할 데이터를 선택하고 집계하는 데 사용되며, Julia와 MATLAB 같은 언어 및 환경도 수치 계산과 연구 분야에서 활용된다.
분석용 언어에서는 다음 특성이 중요하다.
- 배열과 표 형태 데이터 처리
- 통계 함수
- 시각화
- 대화형 실행 환경
- 노트북
- 과학 라이브러리와의 연결
- C와 C++, Fortran으로 작성된 고성능 라이브러리 호출
- 분석 결과의 재현성
대화형 분석 코드는 빠르게 실험하는 데 적합하지만, 운영 시스템에 배포할 때는 입력 검증과 시험, 버전 고정, 자원 사용 및 장애 처리를 보강해야 한다.
인공지능과 머신러닝
인공지능과 머신러닝 분야에서는 데이터 준비와 모델 정의, 학습, 평가, 추론 및 서비스 배포가 이루어진다.
Python은 간결한 표현과 데이터·수치 계산 생태계를 바탕으로 모델 연구와 실험, 학습 파이프라인의 상위 인터페이스에 사용된다. 그러나 대규모 행렬 연산과 텐서 처리, GPU 커널은 일반적으로 C++, CUDA나 다른 네이티브 구현에서 수행된다. Python 코드는 이러한 고성능 라이브러리와 런타임을 제어하는 역할을 할 수 있다.
CUDA는 GPU를 이용해 계산 집약적인 프로그램을 가속하는 병렬 컴퓨팅 플랫폼이자 프로그래밍 모델이다. NVIDIA의 공식 CUDA 문서는 GPU 계산이 딥러닝과 과학 계산, 고성능 컴퓨팅에 사용된다고 설명하며, C++와 Python을 통한 GPU 프로그래밍을 다룬다.
머신러닝 시스템은 다음과 같이 여러 층으로 구성될 수 있다.
실험과 모델 정의
Python
프레임워크와 실행 엔진
C++ / Rust / Java
GPU 연산
CUDA / 셰이더·커널 언어
데이터 처리
Python / SQL / Scala / Java
서비스 API
Python / Go / Java / C# / Rust
모바일·엣지 추론
C++ / Java / Kotlin / Swift
언어 선택은 모델의 정확도를 직접 결정하지 않는다. 지원되는 프레임워크와 하드웨어 가속기, 데이터 처리 환경, 배포 대상과 운영 도구가 더 직접적인 영향을 준다.
과학 계산
과학 계산은 물리와 화학, 기상, 유체, 천문, 생명과학, 수치 해석과 공학 시뮬레이션에서 수학적 모델을 컴퓨터로 계산하는 분야이다.
과학 계산에서는 다음 요소가 중요하다.
- 부동소수점 계산의 정확성과 재현성
- 배열과 행렬 연산
- 수치 알고리즘
- 병렬 처리
- 대규모 메모리와 저장소
- 기존 연구 코드와의 호환성
- 여러 세대에 걸친 코드 유지
- 다른 언어 및 라이브러리와의 상호 운용
Fortran은 과학과 공학에서 컴퓨터가 일찍 도입된 분야를 중심으로 사용되어 왔다. Fortran-lang은 기상 및 해양 예측과 전산유체역학, 응용수학, 통계와 금융 등을 Fortran의 주요 활용 분야로 설명한다.
C와 C++, Python, Julia와 MATLAB 등도 과학 계산에 사용된다. Python은 연구 절차와 데이터 분석을 구성하는 상위 계층으로 사용되고, 실제 계산은 Fortran과 C, C++로 작성된 라이브러리에 맡길 수 있다.
과학 소프트웨어에서는 최신 문법보다 이미 검증된 알고리즘과 데이터 형식, 장기간 축적된 코드의 가치가 더 클 수 있다. 새로운 언어를 도입할 때는 기존 코드와의 연결 및 결과 검증, 부동소수점 동작의 차이를 고려해야 한다.
고성능 컴퓨팅
고성능 컴퓨팅은 슈퍼컴퓨터와 계산 클러스터, 다중 코어 CPU 및 GPU를 사용하여 대규모 계산을 수행하는 분야이다. 기상 예측과 물리 시뮬레이션, 유전체 분석, 유체역학, 재료 연구, 금융 계산과 머신러닝 학습 등에 사용된다.
고성능 컴퓨팅에서는 다음과 같은 병렬화 수준이 함께 존재할 수 있다.
- CPU 명령 수준의 벡터화
- 하나의 노드 안에서의 다중 스레드
- 여러 노드 사이의 메시지 전달
- GPU와 가속기 오프로딩
- 여러 계산 장치를 결합하는 이기종 실행
Fortran과 C, C++가 전통적으로 사용되며, Python과 Julia는 상위 수준의 작업 구성과 분석에 사용될 수 있다. GPU 계산에서는 CUDA C++와 커널 언어, 가속기 라이브러리가 사용된다. CUDA의 프로그래밍 모델은 GPU에서 실행될 코드를 작성하고 메모리와 병렬 실행 구조를 제어하는 기능을 제공한다.
고성능 컴퓨팅에서 언어의 이론적인 실행 속도만으로 성능이 결정되지는 않는다. 메모리 접근 패턴과 데이터 배치, 통신량, 벡터화, 알고리즘의 병렬 구조 및 대상 장치에 맞는 최적화가 중요하다.
컴퓨터 그래픽스
컴퓨터 그래픽스 분야에서는 2D와 3D 장면의 렌더링, 영상 처리, 모델링, 애니메이션, 시뮬레이션과 사용자 인터페이스를 구현한다.
그래픽스 소프트웨어는 일반적으로 다음 계층으로 구성된다.
- 그래픽 API와 장치 추상화
- 렌더링 엔진
- 셰이더
- 장면과 에셋 처리
- 편집기와 도구
- 애니메이션과 물리 연동
- 콘텐츠 스크립트
렌더링 엔진과 그래픽 드라이버는 하드웨어 접근과 성능 제어를 위해 C와 C++를 사용하는 경우가 많다. Rust도 그래픽 API 래퍼와 렌더링 엔진에 사용할 수 있다.
셰이더는 GPU의 정점과 픽셀, 컴퓨트 단계를 대상으로 하는 도메인 특화 언어로 작성된다. GLSL과 HLSL, Metal Shading Language 등이 대표적이다. 셰이더 코드는 일반 CPU 프로그램과 다른 대규모 병렬 실행 모델과 메모리 구조를 고려해야 한다.
그래픽 제작 도구와 파이프라인에서는 Python이 에셋 변환과 반복 작업 자동화, 플러그인 및 제작 도구에 사용될 수 있다. 따라서 하나의 그래픽 프로그램 안에서도 네이티브 렌더러와 셰이더 언어, 도구용 스크립트가 함께 사용된다.
게임 개발
게임 개발은 실시간 렌더링과 물리, 오디오, 네트워크, 인공지능, 입력, 사용자 인터페이스, 도구와 콘텐츠 제작을 함께 다루는 복합적인 분야이다.
게임 개발에서는 역할에 따라 서로 다른 언어가 사용될 수 있다.
- 게임 엔진과 렌더러
- 물리와 오디오 시스템
- 게임 플레이 코드
- 서버와 네트워크
- 에디터와 제작 도구
- 빌드와 에셋 파이프라인
- 셰이더
- 모드와 사용자 스크립트
C++는 높은 성능과 메모리 제어, 콘솔 및 그래픽 API 지원을 바탕으로 대형 게임 엔진에 사용된다. Unreal Engine은 C++ 개발자를 위한 프레임워크와 API를 제공하며, C++와 Blueprint 시각적 스크립트를 함께 사용하여 게임 시스템과 콘텐츠를 구성할 수 있다.
C#은 관리형 런타임과 생산성을 바탕으로 일부 게임 엔진과 도구에서 사용된다. Lua와 Python, 자체 스크립트 언어는 게임 규칙과 콘텐츠, 모드 또는 에디터 자동화를 위해 네이티브 엔진에 내장될 수 있다.
게임에서 언어 선택은 최대 프레임 성능만의 문제가 아니다. 실시간 코드와 도구 코드의 요구는 서로 다르다. 렌더링과 물리의 핵심 경로에는 낮은 지연과 예측 가능한 메모리 사용이 중요하지만, 에디터와 빌드 파이프라인에는 빠른 개발과 UI, 파일 처리 기능이 더 중요할 수 있다.
오디오와 미디어 처리
오디오와 영상 프로그램은 큰 데이터 스트림을 실시간 또는 비실시간으로 처리한다. 코덱과 믹서, 신호 처리, 영상 변환, 편집, 스트리밍과 재생 시스템이 포함된다.
실시간 오디오에서는 처리 마감 시간을 놓치면 소리가 끊길 수 있으므로 예측 가능한 실행과 메모리 할당 제한이 중요하다. C와 C++가 DSP와 코덱, 오디오 엔진에서 사용되며, Rust도 메모리 안전성이 필요한 네이티브 미디어 처리에 적용할 수 있다.
미디어 제작 프로그램은 네이티브 처리 엔진에 Python이나 JavaScript 기반 자동화 및 플러그인 계층을 결합할 수 있다. GPU를 이용한 영상 처리에는 CUDA와 컴퓨트 셰이더가 사용될 수 있다.
네트워크 프로그래밍
네트워크 프로그래밍은 소켓과 프로토콜, 서버와 클라이언트, 프록시, 네트워크 장비 및 통신 라이브러리를 구현한다.
네트워크 프로그램에서는 다음 요소가 중요하다.
- 많은 동시 연결
- 비동기 입출력
- 타임아웃과 취소
- 메시지 경계와 직렬화
- 보안 프로토콜
- 부분적인 송수신
- 연결 종료와 재연결
- 버퍼와 메모리 관리
- 운영체제별 네트워크 API
C와 C++는 운영체제 네트워크 스택과 고성능 프록시, 프로토콜 구현에 사용된다. Go는 고루틴과 네트워크 표준 라이브러리를 이용한 서비스 개발에 적합하며, Rust는 메모리 안전성과 비동기 라이브러리를 활용할 수 있다. Java와 C#, Python, JavaScript도 서버와 클라이언트 애플리케이션에 사용된다.
네트워크 프로그램에서 언어가 제공하는 비동기 문법은 복잡성을 줄일 수 있지만, 프로토콜의 상태와 시간 제한, 입력 검증과 공격 대응은 별도로 설계해야 한다.
사이버 보안
사이버 보안 분야에서는 보안 제품 개발과 취약점 분석, 네트워크 감시, 암호 구현, 악성 코드 분석, 디지털 포렌식과 자동화가 이루어진다.
활용되는 언어는 작업에 따라 달라진다.
- C와 C++는 운영체제와 바이너리, 네트워크 및 저수준 보안 도구에 사용된다.
- Rust는 메모리 안전성이 중요한 보안 도구와 시스템 구성 요소에 사용될 수 있다.
- Python은 분석과 자동화, 보안 API 연결과 시험 도구에 사용된다.
- 어셈블리어는 실행 파일과 프로세서 명령의 분석에 필요할 수 있다.
- JavaScript는 웹 보안과 브라우저 환경의 분석에 사용된다.
- SQL은 데이터베이스 보안과 질의 분석에 관련된다.
보안 분야에서 안전한 언어를 사용한다고 해서 프로그램 전체가 자동으로 안전해지는 것은 아니다. 인증과 권한, 암호 프로토콜, 입력 검증, 키 관리와 공급망 보안은 언어의 메모리 안전성과 별개의 문제이다.
암호학
암호학 소프트웨어는 암호화와 서명, 해시, 키 교환과 난수 생성 알고리즘을 구현한다.
암호 구현에서는 다음 요소가 중요하다.
- 정확한 정수와 비트 연산
- 일정 시간 실행
- 비밀 정보의 메모리 관리
- 최적화에 따른 코드 변화
- 하드웨어 가속 명령
- 검증된 알고리즘 사용
- 외부 입력에 대한 엄격한 검사
- 구현의 독립적인 검토
고성능 암호 라이브러리는 C와 C++, Rust, 어셈블리어를 함께 사용할 수 있다. Python과 Java, C# 등의 응용 프로그램은 직접 암호 알고리즘을 구현하기보다 검증된 표준 라이브러리와 플랫폼 API를 호출하는 것이 일반적이다.
금융과 업무 시스템
금융과 보험, 정부, 유통, 회계 및 기업 업무 시스템은 거래와 고객, 계약, 결제, 장부와 보고서를 처리한다.
이 분야에서는 다음 요소가 중요하다.
- 데이터의 정확성
- 트랜잭션과 감사 기록
- 장기간의 호환성
- 접근 권한과 규정 준수
- 대규모 데이터 처리
- 기존 시스템과의 통합
- 장애 복구
- 정밀한 금액 계산
COBOL은 은행과 보험, 정부 및 대형 기업의 기존 업무 시스템에서 사용되어 왔다. Java와 C#, SQL은 현대적인 기업 서버와 업무 애플리케이션에서 사용되며, JavaScript와 TypeScript는 사용자 인터페이스에 활용된다.
고빈도 거래와 위험 계산처럼 지연 시간과 처리량이 중요한 분야에서는 C++와 Java, Rust, Fortran 등의 언어가 사용될 수 있다. Fortran은 통계와 금융을 과학·수치 계산의 적용 분야 가운데 하나로 포함한다.
금융 시스템에서 언어를 바꾸는 일은 단순한 코드 변환 문제가 아니다. 기존 거래 규칙과 데이터 형식, 규제 및 수십 년 동안 축적된 동작을 정확히 보존해야 하므로 점진적인 현대화와 상호 운용이 중요하다.
안전 필수 시스템
안전 필수 시스템은 소프트웨어의 실패가 사람의 생명과 건강, 환경 또는 대규모 자산 피해로 이어질 수 있는 시스템이다. 항공과 철도, 자동차, 의료기기, 산업 제어와 원자력 시스템 등이 포함된다.
이 분야에서는 일반적인 애플리케이션보다 엄격한 개발과 검증이 필요하다.
- 요구 사항의 추적
- 코딩 규칙
- 정적 분석
- 시험 범위 측정
- 형식 검증
- 최악 실행 시간 분석
- 제한된 동적 메모리 사용
- 인증 가능한 도구
- 변경 관리와 감사 기록
Ada는 고무결성 애플리케이션과 안전 표준에 대한 인증이 필요한 시스템을 지원하는 언어로 사용될 수 있다. Ada의 강한 타입 체계와 제한된 실행 프로파일은 프로그램의 오류 가능성을 줄이고 검증을 지원하도록 설계되었다.
C와 C++도 자동차와 항공, 산업 환경에서 사용되지만 안전한 부분집합과 코딩 표준, 정적 분석 및 런타임 제한을 함께 적용할 수 있다. 언어 선택보다 개발 프로세스와 검증 도구, 하드웨어 및 전체 시스템의 안전 설계가 더 중요할 수 있다.
로봇과 자율 시스템
로봇과 자율 시스템은 센서 입력과 위치 추정, 계획, 제어, 영상 처리와 하드웨어 통신을 결합한다.
주요 언어 역할은 다음과 같이 나눌 수 있다.
- C와 C++는 실시간 제어와 센서 처리, 로봇 미들웨어에 사용된다.
- Python은 연구용 알고리즘과 실험, 도구 및 머신러닝 모델 연결에 사용된다.
- CUDA와 GPU 언어는 영상 및 신경망 처리를 가속한다.
- Rust는 안전한 장치 통신과 시스템 구성 요소에 적용될 수 있다.
- MATLAB과 Simulink 계열 환경은 제어 모델과 시뮬레이션에 활용될 수 있다.
로봇 프로그램은 물리 세계와 직접 상호 작용하므로 오류가 단순한 프로그램 종료를 넘어 장치의 위험한 동작으로 이어질 수 있다. 실시간 제약과 센서의 불확실성, 비상 정지와 안전 상태를 언어 외부의 시스템 설계와 함께 다뤄야 한다.
사물 인터넷
사물 인터넷은 센서와 장치가 네트워크로 연결되어 데이터를 수집하고 원격 명령을 수행하는 시스템이다.
하나의 IoT 시스템은 여러 층으로 구성된다.
장치 펌웨어
C / C++ / Rust
게이트웨이
C++ / Rust / Go / Python
메시지 처리와 서버
Go / Java / C# / Python / JavaScript
데이터 저장과 분석
SQL / Python / Scala
관리 화면
JavaScript / TypeScript
장치에서는 제한된 메모리와 전력, 네트워크 단절, 원격 업데이트의 신뢰성이 중요하다. 서버에서는 많은 장치의 연결과 메시지 처리, 장치 인증 및 데이터 분석이 중요하다.
IoT 장치는 장기간 현장에 설치되고 직접 유지보수하기 어려울 수 있으므로 안전한 업데이트와 키 관리, 통신 암호화 및 장애 복구가 언어 선택만큼 중요하다.
블록체인과 분산 원장
블록체인과 분산 원장 시스템은 노드 프로그램과 합의 프로토콜, 네트워크 통신, 저장소 및 스마트 계약으로 구성된다.
노드 구현에는 C++와 Go, Rust, Java 등이 사용될 수 있으며, 스마트 계약에는 플랫폼별 도메인 특화 언어 또는 제한된 범용 언어가 사용된다.
스마트 계약은 배포 뒤 수정하기 어렵고 실행 비용과 상태 변화가 경제적 가치에 직접 연결될 수 있으므로 다음 요소가 중요하다.
- 결정적인 실행
- 정수와 금액 처리
- 권한 검사
- 재진입과 호출 관계
- 자원 사용 제한
- 상태 변경의 원자성
- 형식 검증과 정적 분석
- 업그레이드 정책
일반적인 서버 프로그램에서 복구할 수 있는 결함도 스마트 계약에서는 영구적인 자산 손실로 이어질 수 있으므로, 언어의 표현력보다 제한과 검증 가능성을 우선할 수 있다.
교육
프로그래밍 언어는 컴퓨터 과학과 소프트웨어 개발, 논리적 사고와 문제 해결을 교육하는 데 사용된다.
교육용 언어를 선택할 때는 다음 사항이 고려된다.
- 문법의 단순성과 일관성
- 오류 메시지
- 설치와 실행의 편의
- 대화형 실습
- 시각화와 디버깅
- 학습할 개념과의 적합성
- 이후 사용할 언어로의 연결
- 교재와 교육 자료
- 운영체제 및 장치 접근성
Python은 비교적 간결한 문법과 대화형 실행 환경을 바탕으로 입문 교육과 데이터·과학 교육에 사용될 수 있다. Swift Playground는 실제 Swift 코드와 대화형 수업을 이용해 코딩 및 앱 개발을 학습하도록 제공된다.
Scratch와 블록 기반 언어는 구문 오류의 부담을 줄이고 사건과 반복, 조건 및 변수의 개념을 시각적으로 학습하게 한다. 대학의 컴퓨터 과학 교육에서는 Python과 Java, C, C++, Haskell, Scheme 등 교육 목적에 따라 서로 다른 언어를 사용할 수 있다.
입문하기 쉬운 언어가 모든 고급 개념을 가르치는 데 가장 적합한 것은 아니다. 메모리와 운영체제를 학습하려면 C와 어셈블리어가 도움이 될 수 있고, 함수와 타입 이론을 학습하려면 Haskell이나 ML 계열 언어가 적합할 수 있다.
프로그래밍 언어 연구
프로그래밍 언어 자체도 연구 대상이다. 새로운 구문과 타입 시스템, 의미론, 컴파일러 최적화, 프로그램 검증과 개발 도구가 연구된다.
주요 연구 분야는 다음과 같다.
- 타입 이론
- 형식 의미론
- 컴파일러
- 정적 분석
- 프로그램 검증
- 메모리 안전성
- 동시성 모델
- 도메인 특화 언어
- 합성 및 코드 생성
- 언어와 사용자 경험
- 오류 메시지와 개발 도구
- 양자 프로그래밍 언어
- 확률적 프로그래밍 언어
연구 언어는 산업용 범용 언어가 제공하기 어려운 새로운 기능을 실험할 수 있다. 검증된 기능은 이후 기존 언어와 라이브러리, 컴파일러에 흡수될 수 있다.
연구에서 성공적인 기능이 곧바로 산업 언어에 적합한 것은 아니다. 구현 비용과 컴파일 시간, 오류 진단, 기존 코드와의 호환성 및 사용자 학습 비용을 함께 검토해야 한다.
도메인 특화 언어
도메인 특화 언어는 특정 문제 영역의 개념과 규칙을 직접 표현하도록 설계된 언어이다.
대표적인 예는 다음과 같다.
- SQL은 관계형 데이터 질의를 표현한다.
- 정규 표현식은 문자열 패턴을 표현한다.
- 셰이더 언어는 GPU 그래픽 연산을 표현한다.
- 하드웨어 기술 언어는 디지털 회로를 표현한다.
- 빌드 언어는 파일과 작업의 의존성을 표현한다.
- 설정 언어는 프로그램과 시스템의 구성을 표현한다.
- 통계 모델 언어는 확률 분포와 관측 관계를 표현한다.
- 정책 언어는 권한과 접근 규칙을 표현한다.
도메인 특화 언어는 해당 분야에서 범용 언어보다 짧고 명확한 표현을 제공하며, 실행기가 도메인 지식을 이용해 최적화와 검증을 수행할 수 있다. 반면 언어의 적용 범위가 좁고 별도의 도구와 학습이 필요할 수 있다.
도메인 특화 언어는 독립된 파일 형식으로 존재할 수도 있고, 범용 언어의 라이브러리와 연산자, 매크로 안에 내장될 수도 있다.
하드웨어 설계
디지털 회로와 프로세서를 설계할 때는 순차적인 소프트웨어 실행과 다른 계산 모델이 필요하다. 회로의 여러 부분은 동시에 동작하며, 신호와 클록, 지연 및 물리적인 자원 구조를 표현해야 한다.
Verilog와 VHDL 같은 하드웨어 기술 언어는 레지스터와 조합 회로, 상태 기계 및 모듈 연결을 기술한다. 작성된 설계는 시뮬레이션하거나 FPGA 및 ASIC 회로로 합성할 수 있다.
최근에는 Scala와 Python, Haskell 등의 범용 언어 위에서 회로 구조를 생성하는 하드웨어 구성 언어도 사용된다. 이 경우 범용 언어의 추상화와 메타프로그래밍 기능을 활용하면서 최종적으로 하드웨어 기술 중간 표현을 생성한다.
셰이더와 GPU 프로그래밍
GPU는 많은 계산 단위에서 유사한 연산을 병렬로 수행하도록 설계된다. 셰이더와 GPU 커널은 이러한 실행 모델을 대상으로 한다.
그래픽 셰이더는 다음과 같은 작업을 수행한다.
- 정점 좌표 변환
- 표면 재질과 조명 계산
- 텍스처 샘플링
- 픽셀 색상 계산
- 후처리
- 광선 추적
- 범용 병렬 계산
GPU 언어에서는 스레드와 작업 그룹, 메모리 계층, 동기화와 분기 발산을 고려해야 한다. 일반 CPU 언어와 유사한 문법을 사용하더라도 실행 방식은 크게 다르다.
CUDA는 GPU 계산을 C++와 Python에서 사용할 수 있는 모델을 제공하며, 그래픽 API는 HLSL과 GLSL, SPIR-V 등의 셰이더 표현을 사용할 수 있다.
양자 컴퓨팅
양자 컴퓨팅에서는 큐비트와 양자 게이트, 측정 및 고전적 제어를 표현하는 언어와 SDK가 사용된다.
양자 프로그램은 일반적으로 고전 프로그램이 양자 회로를 구성하고 실행 결과를 받아 후처리하는 혼합 구조를 가진다.
고전적 호스트 프로그램
↓
양자 회로 생성
↓
시뮬레이터 또는 양자 장치
↓
측정 결과
↓
고전적 분석
양자 상태는 일반적인 값처럼 자유롭게 복사할 수 없으며 측정은 상태에 영향을 준다. 이에 따라 선형 타입과 효과 시스템, 회로 기반 언어가 연구되고 있다.
현재 양자 프로그래밍은 실제 장치뿐 아니라 시뮬레이터와 교육, 알고리즘 연구에서 사용되며, 특정 언어보다 하드웨어 SDK와 컴파일러, 회로 표현의 지원이 중요하다.
웹 브라우저 밖의 WebAssembly
WebAssembly는 웹 브라우저뿐 아니라 서버와 플러그인, 샌드박스 및 이식 가능한 모듈 실행 환경에도 활용할 수 있다.
활용 사례는 다음과 같다.
- 브라우저에서 네이티브 언어 모듈 실행
- 안전하게 격리된 플러그인
- 서버리스 함수
- 여러 운영체제에서 실행할 수 있는 배포 형식
- 게임과 응용 프로그램의 모드 시스템
- 엣지 컴퓨팅
- 언어 런타임의 공통 백엔드
WebAssembly는 핵심 계산 모델과 호스트의 파일·네트워크 기능을 분리한다. 실제 프로그램이 운영체제 기능에 접근하려면 브라우저 API나 WASI 같은 호스트 인터페이스가 필요하다.
언어 내장과 확장
응용 프로그램은 다른 프로그래밍 언어의 인터프리터나 가상 머신을 내부에 포함할 수 있다. 이를 통해 사용자가 프로그램의 동작을 확장하거나 반복 작업을 자동화할 수 있다.
주요 적용 분야는 다음과 같다.
- 게임 스크립트
- 편집기 자동화
- 모드
- 플러그인
- 설정과 규칙
- 시뮬레이션 시나리오
- 데이터 처리 파이프라인
Lua와 Python, JavaScript와 자체 도메인 특화 언어가 내장 언어로 사용될 수 있다. 호스트 프로그램은 안전한 API만 스크립트에 노출하고 실행 시간과 메모리, 파일 및 네트워크 접근을 제한할 수 있다.
언어 내장은 유연성을 높이지만, 호스트 객체의 수명과 오류 전달, 디버깅, 버전 호환성 및 신뢰할 수 없는 코드의 격리를 해결해야 한다.
언어 선택 기준
특정 분야에서 사용할 언어를 선택할 때는 단순한 인기나 실행 속도 하나만으로 판단하기 어렵다.
주요 선택 기준은 다음과 같다.
- 대상 운영체제와 하드웨어를 지원하는가
- 필요한 라이브러리와 프레임워크가 존재하는가
- 성능과 지연 시간 요구를 충족하는가
- 메모리와 전력 제한에 맞는가
- 안전성과 보안 요구를 충족하는가
- 기존 코드 및 데이터와 연결할 수 있는가
- 개발자와 유지보수 인력을 확보할 수 있는가
- 빌드와 시험, 디버깅 도구가 충분한가
- 장기간 지원과 호환성 정책이 있는가
- 배포와 업데이트가 쉬운가
- 라이선스와 표준, 인증 요구에 적합한가
- 프로젝트 규모와 개발 기간에 맞는가
언어 선택은 프로젝트의 모든 부분에 하나의 언어를 강제하는 결정일 필요가 없다. 서로 다른 요구를 가진 구성 요소를 분리하고 안정적인 인터페이스로 연결하면 각 영역에 더 적합한 언어를 사용할 수 있다.
그러나 언어 수가 늘어나면 다음 비용도 증가한다.
- 빌드 시스템과 도구 체계가 복잡해진다.
- 언어 경계에서 데이터를 변환해야 한다.
- 서로 다른 런타임과 메모리 관리 방식을 조정해야 한다.
- 디버깅과 프로파일링이 어려워질 수 있다.
- 개발자가 여러 생태계를 이해해야 한다.
- ABI와 버전 호환성을 관리해야 한다.
따라서 다중 언어 구조는 구성 요소의 경계가 명확하고, 언어를 추가함으로써 얻는 이점이 상호 운용 비용보다 클 때 사용하는 것이 적절하다.
분야와 언어의 관계
프로그래밍 언어와 활용 분야의 관계는 고정되어 있지 않다. 새로운 실행 환경과 라이브러리, 컴파일러가 등장하면 기존 언어가 새로운 분야로 확장될 수 있다. Kotlin은 JVM 언어에서 Android와 다중 플랫폼 개발로 범위를 넓혔고, Swift는 Apple 애플리케이션을 넘어 서버와 임베디드 영역으로 확장되고 있다. Apple의 Swift 관련 공식 자료도 모바일 애플리케이션뿐 아니라 서버와 임베디드 펌웨어까지 언어의 적용 범위로 제시하고 있다.
반대로 특정 분야에서 널리 사용되는 언어가 기술적으로 항상 가장 우수한 언어이기 때문에 선택된 것은 아니다. 기존 코드와 라이브러리, 교육 자료, 개발 도구, 하드웨어 공급사의 지원과 인력 시장이 강한 영향을 준다. 오랫동안 검증된 코드와 표준, 상호 운용성은 새로운 문법과 기능보다 더 큰 가치가 있을 수 있다.
프로그래밍 언어의 활용은 하나의 언어가 모든 영역을 대체하는 방향보다 서로 다른 추상화 수준의 언어가 협력하는 방향으로 발전해 왔다. 시스템과 성능 중심의 기반 계층, 관리형 애플리케이션 계층, 자동화용 스크립트, 데이터 질의와 GPU를 위한 도메인 특화 언어가 하나의 시스템 안에서 함께 사용된다.
따라서 프로그래밍 언어의 활용 분야를 이해한다는 것은 특정 언어로 무엇을 만들 수 있는지를 나열하는 데 그치지 않는다. 각 분야가 요구하는 계산 모델과 안전성, 성능, 실행 환경과 생태계를 파악하고, 언어와 런타임, 라이브러리 및 도구를 전체 시스템의 목적에 맞게 선택하는 것을 의미한다.
프로그래밍 언어의 비교
프로그래밍 언어를 비교하는 일은 하나의 언어가 다른 언어보다 전반적으로 우수한지를 판단하는 일이 아니다. 각 언어는 서로 다른 문제와 실행 환경, 안전성 요구, 개발 방식과 기존 생태계를 대상으로 설계되었으므로 동일한 기준 하나로 모든 언어의 우열을 정할 수 없다. 운영체제 커널에 적합한 언어가 데이터 분석에도 가장 적합한 것은 아니며, 짧은 자동화 프로그램을 빠르게 작성할 수 있는 언어가 실시간 렌더러의 핵심 코드에도 같은 장점을 제공하는 것은 아니다.
언어의 실제 특성은 문법만으로 결정되지 않는다. 언어 사양과 타입 체계, 컴파일러 또는 인터프리터, 런타임, 가비지 컬렉터, 표준 라이브러리, 패키지 생태계, 개발 도구와 대상 플랫폼이 함께 작용한다. 같은 언어라도 구현체와 컴파일 옵션, 라이브러리, 실행 환경에 따라 성능과 메모리 사용, 시작 시간 및 배포 방식이 달라질 수 있다.
따라서 프로그래밍 언어는 다음과 같은 여러 기준을 분리하여 비교해야 한다.
| 비교 기준 | 주요 질문 |
|---|---|
| 추상화와 제어 | 하드웨어와 메모리를 어느 정도 직접 제어할 수 있는가 |
| 실행 모델 | 네이티브 코드, 가상 머신, 인터프리터 중 무엇을 사용하는가 |
| 타입 체계 | 오류를 언제 검사하고 타입을 어떻게 표현하는가 |
| 메모리 관리 | 객체와 자원의 수명을 누가 관리하는가 |
| 안전성 | 어떤 종류의 오류를 언어와 구현체가 방지하는가 |
| 성능 | 처리량과 지연 시간, 메모리 사용을 어느 정도 제어할 수 있는가 |
| 동시성 | 여러 작업과 공유 상태를 어떻게 표현하는가 |
| 이식성 | 여러 운영체제와 아키텍처에서 실행할 수 있는가 |
| 상호 운용성 | 기존 라이브러리와 다른 언어를 얼마나 쉽게 사용할 수 있는가 |
| 개발 생산성 | 작성과 수정, 시험, 오류 탐지가 얼마나 편리한가 |
| 도구와 생태계 | 컴파일러, 디버거, 패키지와 프레임워크가 충분한가 |
| 안정성과 호환성 | 언어와 플랫폼이 장기간 기존 코드를 유지하는가 |
비교의 한계
프로그래밍 언어의 비교에서는 언어와 구현체를 구분해야 한다. Python은 언어이고 CPython은 대표적인 구현체이다. Java는 언어이고 JVM은 Java를 포함한 여러 언어가 사용할 수 있는 실행 환경이다. C#은 언어이며 일반적으로 .NET과 함께 사용되지만, C# 언어 사양은 적합한 구현체가 반드시 CLI나 .NET 위에 구축되어야 한다고 제한하지 않는다. Python의 공식 언어 참조도 언어의 핵심 구문과 의미를 설명하며, 모든 구현 세부 사항을 CPython에 고정하지 않는다.
“컴파일 언어”와 “인터프리트 언어”라는 구분도 언어 자체의 절대적인 속성으로 사용해서는 안 된다. Java는 일반적으로 소스 코드를 기계 독립적인 바이트코드로 번역한 뒤 JVM에서 실행하고, JVM은 해당 코드를 인터프리트하거나 JIT 컴파일할 수 있다. Python 구현체도 내부 바이트코드와 가상 머신, JIT 컴파일 등 서로 다른 방식을 사용할 수 있다. Java 언어 사양은 일반적인 컴파일 결과로 기계 독립적인 바이트코드를 설명하지만, 이것이 Java 언어를 하나의 구체적인 실행 전략에만 고정한다는 뜻은 아니다.
성능 비교 역시 언어 이름만으로 결론을 내릴 수 없다. 같은 언어에서도 알고리즘과 자료 구조, 컴파일러, 최적화 옵션, 런타임의 준비 상태, 가비지 컬렉터 설정, CPU와 메모리 구조에 따라 결과가 달라진다. 짧은 명령행 프로그램의 시작 시간과 장시간 실행되는 서버의 최대 처리량, 실시간 오디오 시스템의 최악 지연 시간은 서로 다른 성능 기준이다.
따라서 언어 비교는 “무엇이 가장 빠른가”보다 “어떤 비용을 어느 계층에서 부담하며, 그 비용을 얼마나 예측하고 제어할 수 있는가”를 살펴보는 방식으로 이루어져야 한다.
추상화 수준과 하드웨어 제어
프로그래밍 언어는 하드웨어의 구체적인 구조를 어느 정도 노출하는지에 따라 차이를 보인다.
C (프로그래밍 언어)는 포인터와 메모리 주소, 정수 표현, 구조체 배치와 수동 메모리 관리를 직접 다룰 수 있다. C 표준은 프로그램의 표현과 의미를 정의하면서 다양한 컴퓨터 시스템에서의 이식성과 효율적인 실행을 목표로 한다. 다만 자료형의 정확한 크기와 정렬, 일부 연산 결과는 구현체와 대상 환경에 따라 달라질 수 있다.
C++는 C와 유사한 저수준 제어를 제공하면서 클래스와 템플릿, 일반화 프로그래밍, 값 의미론과 범위 기반 자원 관리 같은 고수준 추상화를 추가한다. C++ 설계에서 중요한 원칙 가운데 하나는 사용하지 않는 기능의 비용을 부담하지 않고, 사용한 추상화도 적절히 구현되었을 때 직접 작성한 저수준 코드보다 불필요한 비용을 요구하지 않아야 한다는 무비용 추상화 원칙이다.
Rust도 포인터와 메모리 배치, 네이티브 ABI와 직접적인 자원 관리가 필요한 시스템 프로그램을 작성할 수 있지만, 안전한 코드에서는 소유권과 대여, 수명 규칙을 통해 메모리 사용을 제한한다. Rust의 소유권은 실행 중 가비지 컬렉터를 사용하지 않고 컴파일러가 메모리 관리 규칙을 검사하는 방식이다.
Go (프로그래밍 언어)는 시스템 프로그래밍을 고려한 범용 언어이지만, C와 Rust보다 메모리와 객체 배치의 세부 사항을 런타임에 더 많이 맡긴다. Go 사양은 Go를 정적 타입과 가비지 컬렉션, 명시적인 동시성 지원 및 패키지 기반 프로그램 구성을 제공하는 언어로 설명한다.
Java와 C#, Python, JavaScript 같은 언어는 일반적으로 포인터 연산과 객체 메모리 배치를 직접 조작하지 않고, 가상 머신이나 런타임의 객체 모델을 사용한다. 이러한 추상화는 메모리 안전성과 이식성을 높일 수 있지만, 객체 배치와 수집 시점, 동적 호출 등의 비용을 직접 제어하기는 상대적으로 어렵다.
추상화 수준은 단순히 높고 낮은 것으로만 평가할 수 없다. 하드웨어 제어가 많으면 특수한 최적화와 장치 접근이 가능하지만, 플랫폼별 차이와 메모리 오류를 직접 관리해야 한다. 반대로 런타임이 많은 세부 사항을 관리하면 애플리케이션 로직에 집중하기 쉬워지지만, 실시간 응답과 메모리 배치가 중요한 분야에서는 제한이 될 수 있다.
실행 모델
대표적인 프로그래밍 언어의 일반적인 실행 모델은 다음과 같이 비교할 수 있다.
| 언어 | 일반적인 실행 모델 | 주요 특성 |
|---|---|---|
| C (프로그래밍 언어) | AOT 네이티브 컴파일 | 작은 런타임, 직접적인 ABI와 시스템 접근 |
| C++ | AOT 네이티브 컴파일 | 고수준 추상화와 대상별 최적화 |
| Rust | AOT 네이티브 컴파일 | 소유권 검사 후 네이티브 코드 생성 |
| Go (프로그래밍 언어) | AOT 네이티브 컴파일 | 가비지 컬렉터와 언어 런타임 포함 |
| Java | JVM 바이트코드와 JIT 또는 AOT | 가상 머신과 관리형 실행 환경 |
| C# | 공통 중간 언어와 .NET JIT 또는 AOT | CLR과 관리형 런타임 |
| Python | 구현체에 따라 바이트코드 해석·JIT·네이티브 확장 | 동적 실행과 대화형 개발 |
| JavaScript | 브라우저·서버 엔진의 인터프리터와 JIT | 호스트 환경과 긴밀한 결합 |
| TypeScript | JavaScript로 변환한 뒤 JavaScript 환경에서 실행 | 타입 정보는 일반적으로 실행 전에 제거 |
| Kotlin | JVM, JavaScript, 네이티브, WebAssembly 등 | 대상별 백엔드와 상호 운용 |
| Swift | 네이티브 컴파일 | 값 타입과 자동 메모리 관리 |
| Fortran | AOT 네이티브 컴파일 | 수치·배열 중심 최적화와 병렬 기능 |
이 표는 대표적인 구현 방식을 나타낼 뿐 언어가 가질 수 있는 모든 구현 방식을 제한하지 않는다. C와 C++ 코드를 WebAssembly나 다른 중간 표현으로 컴파일할 수 있고, Java와 .NET 프로그램도 AOT 방식으로 배포할 수 있다. Python 코드의 계산량이 큰 부분을 C와 C++, Fortran 또는 GPU 라이브러리에 맡길 수도 있다.
JavaScript의 표준인 ECMAScript는 언어의 계산과 객체 조작 규칙을 정의하지만 외부 입력과 출력은 브라우저나 서버 런타임 같은 호스트 환경에 맡긴다. 따라서 JavaScript의 실제 기능 범위는 ECMAScript 언어와 브라우저 Web API 또는 Node.js API가 결합된 결과이다.
TypeScript는 JavaScript의 실행 환경을 대체하지 않는다. TypeScript는 JavaScript 값의 형태와 동작을 정적으로 분석하고, 타입 표기와 일부 문법을 제거하거나 변환하여 JavaScript를 생성한다. TypeScript의 타입 시스템은 JavaScript의 객체 사용 방식을 모델링하기 위해 구조적 타입을 중심으로 한다.
정적 타입과 동적 타입
정적 타입 언어는 변수와 표현식, 함수 호출의 타입 관계를 주로 실행 전에 검사한다. C와 C++, Rust, Go, Java, C#, Kotlin, Swift와 Fortran 등이 이에 해당한다. Java 사양은 모든 변수와 표현식의 타입이 컴파일 시점에 알려지는 정적 타입 언어로 Java를 정의한다.
동적 타입 언어에서는 값이 실행 중 타입을 가지며, 연산을 수행할 때 실제 값의 타입을 확인한다. Python과 JavaScript가 대표적이다. Python의 데이터 모델에서는 모든 데이터가 객체 또는 객체 사이의 관계로 표현되고, 각 객체는 정체성과 타입, 값을 가진다.
정적 타입과 동적 타입의 차이는 단순히 변수 선언에 타입 이름을 적는지에 있지 않다. Rust와 Kotlin, Swift, C# 등은 지역 변수의 타입을 추론할 수 있지만 정적 타입 검사를 수행한다. 반대로 Python과 JavaScript에도 타입 검사와 런타임 객체 검사가 존재한다.
정적 타입의 주요 장점은 다음과 같다.
- 일부 오류를 실행 전에 발견할 수 있다.
- 함수와 모듈의 계약을 타입으로 표현할 수 있다.
- IDE가 코드 탐색과 자동 완성, 리팩터링을 지원하기 쉽다.
- 컴파일러가 값과 호출 관계를 최적화에 활용할 수 있다.
- 대규모 코드 변경의 영향을 확인하기 쉽다.
동적 타입의 주요 장점은 다음과 같다.
- 실행 중 형태가 달라지는 데이터와 객체를 자연스럽게 다룰 수 있다.
- 작은 프로그램과 실험 코드를 빠르게 작성할 수 있다.
- 복잡한 제네릭 또는 타입 선언 없이 추상화를 구성할 수 있다.
- 리플렉션과 메타프로그래밍, 동적 로딩을 직접적으로 활용하기 쉽다.
정적 타입 언어라도 타입 시스템이 표현하지 못한 논리 오류는 남아 있으며, 동적 타입 언어도 충분한 시험과 정적 분석을 통해 높은 신뢰성을 확보할 수 있다. 타입 검사 시점은 소프트웨어 품질을 결정하는 여러 요소 가운데 하나이다.
명목적 타입과 구조적 타입
Java와 C#, Kotlin, Swift의 클래스와 인터페이스는 주로 선언된 이름과 명시적인 상속·구현 관계를 사용하는 명목적 타입 체계를 따른다. 서로 같은 필드와 메서드를 가진 두 클래스라도 명시적인 관계가 없다면 일반적으로 같은 타입으로 취급되지 않는다.
TypeScript는 주로 객체가 가진 구성원의 형태를 비교하는 구조적 타입 체계를 사용한다. 별도로 인터페이스 구현을 선언하지 않았더라도 필요한 필드와 메서드를 모두 가진 객체는 해당 타입과 호환될 수 있다.
Go의 인터페이스도 명시적인 implements 선언 없이 메서드 집합을 만족하면 구현한 것으로 취급하는 구조적 성격을 가진다. 다만 Go의 전체 타입 체계와 TypeScript의 구조적 객체 타입은 같은 규칙으로 이루어진 것이 아니므로 단순히 동일한 체계라고 볼 수는 없다.
명목적 타입은 도메인 개념과 API 경계를 명확하게 구분하는 데 유리하다. 구조적 타입은 기존 객체를 수정하지 않고 필요한 인터페이스에 사용할 수 있어 결합도를 줄일 수 있다. 반면 우연히 같은 구조를 가진 값이 의도하지 않은 위치에 호환될 가능성도 고려해야 한다.
제네릭과 일반화 프로그래밍
C++와 Rust의 제네릭은 일반적으로 컴파일 과정에서 구체 타입에 맞는 코드를 생성할 수 있다. 이를 통해 타입별 최적화와 정적 디스패치를 수행할 수 있지만, 많은 타입 조합을 사용하면 컴파일 시간과 코드 크기가 증가할 수 있다.
Java의 제네릭은 주로 타입 소거를 사용하여 여러 타입 인스턴스가 공통 바이트코드를 공유한다. .NET의 제네릭은 런타임에서도 타입 정보를 보존하며, 값 타입과 참조 타입에 따라 코드 공유와 특수화 전략이 달라질 수 있다. C#의 타입 체계는 값 타입과 참조 타입을 통합하여 모든 값을 최종적으로 object로 취급할 수 있는 구조를 제공한다.
Go의 타입 매개변수는 인터페이스 형태의 제약으로 허용할 타입과 연산을 정한다. Rust는 trait bound를 사용하며, C++는 템플릿과 concepts를 사용한다. Haskell 계열 언어는 매개변수 다형성과 타입 클래스를 중심으로 일반화를 표현한다.
제네릭의 문법이 비슷해도 구현 방식과 타입 제약, 실행 시 타입 정보의 존재 여부가 다르므로 라이브러리 설계와 성능 특성도 달라진다.
메모리 관리 방식
메모리 관리 방식은 프로그래밍 언어를 비교할 때 가장 중요한 기준 가운데 하나이다.
수동 메모리 관리
C에서는 프로그래머가 메모리를 할당하고 해제하는 시점을 직접 결정한다. 이를 통해 자원 사용을 세밀하게 제어할 수 있지만 메모리 누수와 해제 후 사용, 이중 해제, 버퍼 범위 초과 등의 오류를 직접 방지해야 한다.
C++는 수동 할당을 사용할 수 있으면서도 객체의 수명을 범위에 연결하는 RAII와 스마트 포인터, 컨테이너를 제공한다. 적절한 C++에서는 직접적인 new와 delete 사용을 줄이고 객체가 범위를 벗어날 때 자원을 자동으로 정리할 수 있다. 다만 원시 포인터와 수동 자원 관리 기능도 남아 있으므로 전체 안전성은 코드의 설계와 규칙에 영향을 받는다.
소유권 기반 관리
Rust는 각 값에 소유자를 두고, 소유자가 범위를 벗어나면 자원을 정리한다. 참조는 값을 소유하지 않고 대여하며, 컴파일러가 가변 참조와 불변 참조의 관계 및 수명을 검사한다. 이 구조는 가비지 컬렉터 없이 메모리 안전성을 제공하는 것을 목표로 한다.
Rust의 장점은 많은 메모리 오류와 데이터 경쟁 가능성을 컴파일 시점에 제한할 수 있다는 것이다. 반면 복잡한 공유 구조와 자기 참조 구조, 비동기 수명을 표현할 때 소유권과 수명을 명시적으로 고려해야 하며, 일부 저수준 작업에는 unsafe 코드가 필요할 수 있다.
가비지 컬렉션
Go와 Java, C#, Python 및 JavaScript의 주요 구현체는 자동 메모리 관리를 제공한다. 프로그램이 더 이상 접근할 수 없는 객체를 런타임이 찾아 회수하므로 일반적인 객체를 직접 해제할 필요가 없다.
.NET의 가비지 컬렉터는 관리형 힙에 객체를 할당하고, 스택과 정적 필드, CPU 레지스터 등의 루트에서 도달할 수 없는 객체를 회수한다. 객체를 압축하여 메모리 단편화를 줄일 수도 있다.
가비지 컬렉션은 객체 수명 관리의 부담을 줄이지만 수집 시점과 중단 시간, 추가 메모리, 런타임 복잡성을 발생시킬 수 있다. 현대 수집기는 세대별 수집과 동시 수집, 증분 처리를 통해 이러한 비용을 줄이지만, 엄격한 최악 지연 시간이 요구되는 시스템에서는 별도의 고려가 필요하다.
참조 계수
Swift는 클래스 인스턴스의 수명을 자동 참조 계수로 관리하는 한편 구조체와 열거형 등 값 타입을 적극적으로 사용한다. Apple의 Swift 문서는 배열과 사전 같은 일반적인 타입이 값 타입이며, Swift가 초기화와 범위 검사, 메모리 안전성을 언어 설계의 주요 부분으로 제공한다고 설명한다.
참조 계수는 객체의 마지막 강한 참조가 사라지는 시점에 정리가 이루어져 수명 시점을 비교적 이해하기 쉽지만, 강한 순환 참조를 별도로 방지해야 하며 참조 증가와 감소 연산의 비용이 발생할 수 있다.
메모리 안전성
메모리 안전성은 프로그램이 유효한 메모리만 올바른 타입과 수명 안에서 접근하도록 보장하는 성질이다.
C와 C++는 메모리의 직접적인 접근과 재해석을 허용하므로 매우 넓은 제어 능력을 제공하지만, 언어의 안전한 부분집합과 코딩 규칙, 정적 분석, 런타임 검사 및 라이브러리 설계가 중요하다. 잘못된 포인터와 범위 오류가 정의되지 않은 동작으로 이어질 수 있다.
Rust는 안전한 코드에서 소유권과 대여 규칙을 검사하지만, 외부 함수와 원시 포인터, 일부 시스템 작업을 위해 unsafe를 제공한다. unsafe는 컴파일러의 모든 검사를 끄는 것이 아니라 원시 포인터 역참조와 같은 일부 추가 동작을 허용하고, 그 안전 조건을 프로그래머에게 맡긴다.
Java와 C#, Go 같은 관리형 언어는 일반 코드에서 임의의 포인터 연산을 제한하고 배열 범위와 객체 타입을 검사한다. C#은 대부분의 코드를 검증 가능한 안전 코드로 작성할 수 있지만, 명시적인 unsafe 영역에서는 포인터와 직접적인 메모리 접근을 허용한다.
Swift도 안전한 기본 동작을 제공하지만 C와의 상호 운용 및 특수한 최적화를 위해 UnsafePointer 계열을 제공한다. 이러한 포인터를 사용하면 자동 메모리 관리와 일부 타입·수명 보장을 개발자가 직접 책임져야 한다.
따라서 “안전한 언어”와 “안전하지 않은 언어”를 절대적으로 나누기보다, 기본 코드에서 어떤 오류가 차단되고 어떤 우회 기능이 존재하며, 안전 경계를 얼마나 명확하게 구성할 수 있는지를 비교해야 한다.
값 의미론과 참조 의미론
언어는 값을 복사하고 공유하는 기본 방식에서도 차이를 보인다.
C와 C++의 구조체 및 일반 객체는 기본적으로 값으로 복사할 수 있다. 포인터나 참조를 사용하면 동일한 객체를 공유할 수 있다. C++에서는 복사와 이동 생성자, 대입 연산자를 통해 값의 복사와 소유권 이동 방식을 정의할 수 있다.
Rust는 이동이 기본인 비복사 타입과 명시적으로 복사 가능한 타입을 구분한다. 힙 자원을 소유하는 값은 대입하거나 함수에 전달할 때 소유권이 이동할 수 있으며, 이전 이름의 사용은 제한된다.
Java와 Python, JavaScript에서는 객체 변수가 일반적으로 객체에 대한 참조를 가진다. 대입은 객체 전체를 복사하기보다 참조를 복사한다. 정수와 문자열 등 일부 값은 불변이거나 별도의 값 규칙을 가지지만, 사용자 정의 객체의 변경은 여러 참조에서 관찰될 수 있다.
C#은 값 타입과 참조 타입을 명시적으로 구분한다. 구조체와 열거형은 값 타입이며 대입과 인수 전달에서 인스턴스의 값이 복사된다. 클래스는 참조 타입이다.
Swift는 구조체와 열거형을 값 타입으로, 클래스 인스턴스를 참조 타입으로 다룬다. 표준 컬렉션도 값 의미론을 제공하면서 내부적으로 복사 시 쓰기 방식의 최적화를 사용할 수 있다.
값 의미론은 객체의 변경이 다른 위치에 예상치 못하게 전파되는 것을 줄이고 병렬 처리에 유리할 수 있다. 참조 의미론은 큰 객체를 복사하지 않고 여러 구성 요소가 동일한 상태를 공유할 수 있지만, 별칭과 동기화 관계를 관리해야 한다.
객체 지향 모델
Java와 C#은 클래스와 인터페이스, 상속, 가상 메서드와 명목적 타입을 중심으로 객체 지향 프로그래밍을 지원한다. C#은 클래스 외에도 값 타입인 구조체와 레코드 구조를 제공하며, 모든 타입을 통합된 타입 체계에서 다룬다.
C++는 클래스와 다중 상속, 가상 함수뿐 아니라 값 의미론과 템플릿 기반 정적 다형성을 함께 제공한다. 객체 지향 프로그래밍은 C++의 여러 패러다임 가운데 하나이며, 모든 추상화가 상속과 가상 호출을 사용해야 하는 것은 아니다.
Rust는 전통적인 클래스 상속을 제공하지 않는다. 구조체와 열거형에 메서드를 정의하고 trait로 공통 동작을 추상화한다. 동적 trait 객체를 사용할 수도 있지만, 일반적으로 제네릭과 정적 디스패치를 함께 활용한다.
Go도 클래스 상속을 제공하지 않고 구조체와 메서드, 인터페이스, 합성을 사용한다. 인터페이스 구현을 명시적으로 선언하지 않아도 필요한 메서드 집합을 제공하면 인터페이스를 만족할 수 있다.
JavaScript는 프로토타입 기반 객체 모델을 사용한다. class 문법을 제공하지만 객체의 상속 관계는 프로토타입 연결을 기반으로 한다. ECMAScript 사양은 객체를 속성의 집합으로 정의하고, 객체와 함수 및 프로토타입 동작을 언어 의미의 중심에 둔다.
어떤 객체 모델이 우수한지는 사용하려는 추상화에 따라 달라진다. 안정적인 명목적 API 계약에는 클래스와 인터페이스가 적합할 수 있고, 데이터 중심 모델에는 값 타입과 합 타입이 적합할 수 있다. 동적 객체 확장과 메타프로그래밍이 중요한 환경에서는 프로토타입과 동적 속성이 유리할 수 있다.
오류 처리
언어는 오류를 전달하는 방식에서도 차이를 보인다.
C는 일반적으로 반환값과 오류 코드, 전역 오류 상태를 사용한다. 오류 처리가 명시적이지만 호출자가 반환값을 검사하지 않으면 오류가 무시될 수 있다.
C++와 Java, C#, Python, JavaScript, Kotlin, Swift는 예외 처리 구조를 제공한다. 예외는 여러 호출 단계를 자동으로 거슬러 올라갈 수 있지만, 함수의 일반적인 반환 타입만으로 어떤 실패가 발생하는지 알기 어려울 수 있다.
Rust는 복구 가능한 오류를 주로 Result<T, E>와 같은 합 타입으로 표현하고, 복구할 수 없는 오류에는 패닉을 사용할 수 있다. Go는 오류를 일반 값으로 반환하는 방식을 중심으로 한다. 두 방식은 오류 가능성을 호출 코드에 명시적으로 드러내지만, 반복적인 전달 코드를 줄이기 위한 언어 문법과 관용적 패턴이 필요하다.
Swift는 throws와 do·try·catch, 선택 값, 결과 타입을 사용할 수 있으며, Kotlin은 Java와 상호 운용하지만 검사 예외를 언어 타입 체계에서 강제하지 않는다.
오류 처리 방식은 단순한 문법 선호가 아니다. 라이브러리 API와 자원 정리, 비동기 실행, 트랜잭션 경계, 프로그램 복구 전략에 영향을 준다.
널 안전성
Java와 초기 C# 같은 언어에서는 대부분의 참조 타입에 null이 포함되며, 값이 실제 객체인지 별도로 검사해야 한다. 현대 C#은 nullable reference type 분석을 통해 string과 string?를 소스 수준에서 구분할 수 있지만, 두 표기는 별도의 런타임 타입을 만드는 것이 아니라 컴파일러의 정적 분석에 사용된다.
Kotlin은 널이 아닌 타입과 널 가능 타입을 구문적으로 구분한다. String에는 원칙적으로 null을 대입할 수 없고 String?에서만 허용한다. 다만 Java와 상호 운용할 때는 Java 선언에 충분한 널 정보가 없을 수 있으므로 플랫폼 타입과 외부 애너테이션을 함께 다뤄야 한다.
Rust는 일반 참조에 널을 포함하지 않고 값의 부재를 Option<T>로 표현한다. Swift도 선택 타입을 사용하여 값이 없을 가능성을 타입으로 구분한다.
Python과 JavaScript에서는 None, null, undefined 같은 값이 동적 실행 중 사용되며, 정적 분석기와 TypeScript를 통해 일부 가능성을 검사할 수 있다.
널 안전 기능은 널 참조 오류의 범위를 크게 줄일 수 있지만, 외부 코드와 초기화 과정, 강제 단언 및 안전하지 않은 인터페이스를 통해 여전히 오류가 발생할 수 있다.
동시성과 병렬성
동시성 지원은 언어의 문법뿐 아니라 런타임과 표준 라이브러리의 영향을 크게 받는다.
C와 C++는 운영체제 스레드와 원자적 연산, 잠금 및 메모리 모델을 사용한다. 낮은 수준의 제어가 가능하지만 공유 상태와 수명, 동기화의 정확성을 직접 관리해야 한다.
Rust도 스레드와 잠금, 원자적 연산 및 비동기 실행을 지원한다. 소유권과 Send, Sync 같은 타입 관계는 특정 값을 스레드 사이에서 이동하거나 공유할 수 있는지를 정적으로 제한할 수 있다. 공유 메모리 동시성은 여전히 잠금과 원자성 설계가 필요하지만 일부 데이터 경쟁을 타입 검사 단계에서 차단할 수 있다.
Go는 고루틴과 채널을 언어 및 런타임의 핵심 동시성 모델로 제공한다. 고루틴은 운영체제 스레드보다 가벼운 작업 단위이며, 채널은 고루틴 사이의 값 전달과 동기화에 사용된다. 다만 Go도 공유 메모리와 잠금을 사용할 수 있으므로 채널만이 유일한 동시성 방식은 아니다.
Java와 C#은 스레드 풀과 future, task, async·await, 동시성 컬렉션과 관리형 런타임을 제공한다. JVM과 .NET의 오랜 서버 생태계는 대규모 동시 서비스를 구성하는 데 필요한 도구와 라이브러리를 제공한다.
Kotlin 코루틴은 언어의 중단 함수 기능과 라이브러리 기반 실행 모델을 결합한다. 실행 스케줄러와 채널, flow 등은 주로 kotlinx.coroutines 라이브러리에서 제공된다. 코루틴의 실행 문맥에는 작업과 디스패처 등의 요소가 포함된다.
JavaScript는 일반적으로 이벤트 루프와 Promise, async·await를 사용하여 비동기 입출력을 처리한다. 브라우저와 서버 런타임은 작업 큐와 Web Worker, 네이티브 입출력 시스템을 통해 실제 병렬 또는 비동기 작업을 제공할 수 있다.
동시성 기능이 간결하다고 해서 경쟁 조건과 교착 상태, 취소, 부분 실패 문제가 자동으로 해결되는 것은 아니다. 공유 상태와 작업 수명, 오류 전파, 배압을 어떻게 설계하는지가 더 중요하다.
성능과 예측 가능성
성능은 처리량과 지연 시간, 시작 시간, 메모리 사용, 바이너리 크기와 전력 소비 등 여러 항목으로 나뉜다.
C와 C++, Rust는 일반적으로 네이티브 기계어와 작은 실행 경계를 사용하여 메모리 배치와 할당, 호출 방식을 세밀하게 제어할 수 있다. 이는 낮은 수준의 최적화와 예측 가능한 지연 시간이 필요한 분야에 유리하다. 다만 높은 성능은 자동으로 보장되지 않으며, 잘못된 자료 구조와 메모리 접근은 어떤 언어에서도 큰 성능 저하를 일으킬 수 있다.
Go는 네이티브 코드를 생성하지만 가비지 컬렉터와 고루틴 스케줄러가 포함된다. 서버와 네트워크 프로그램을 단순하게 구성할 수 있지만, 수집과 스케줄링 비용을 고려해야 한다.
Java와 C#은 JIT 컴파일러가 실제 실행 정보를 바탕으로 코드를 최적화할 수 있다. 장시간 실행되는 프로그램에서는 동적 인라이닝과 타입 특수화 등의 최적화가 가능하지만, 프로그램 시작과 준비 단계, 코드 캐시 및 가비지 컬렉션이 성능에 영향을 준다.
Python과 JavaScript 같은 동적 언어는 각 값의 타입과 연산을 실행 중 처리하는 비용이 발생할 수 있다. JavaScript JIT 엔진은 반복되는 타입과 객체 형태를 특수화할 수 있지만 가정이 깨지면 역최적화가 필요하다. Python은 C와 C++, Fortran으로 작성된 라이브러리에서 대량 연산을 수행하여 언어 인터프리터의 반복 비용을 줄이는 방식이 널리 사용된다.
Fortran은 배열과 수치 연산, 과학 계산을 중심으로 발전했으며 명령형과 절차형뿐 아니라 배열 중심, 객체 지향과 함수형 방식을 지원한다. Fortran의 언어 구조와 컴파일러는 수치 계산 및 병렬 프로그램에서 중요한 선택지가 될 수 있다.
성능을 비교할 때는 언어의 평균적인 이미지보다 실제 구현과 작업 부하를 측정해야 한다. 문자열 처리와 데이터베이스 대기, 수치 배열, 메모리 할당, 네트워크 입출력은 서로 다른 언어와 런타임 특성을 요구한다.
컴파일 시간과 시작 시간
C와 C++는 헤더 포함과 템플릿 인스턴스화, 최적화 수준에 따라 컴파일 시간이 크게 달라질 수 있다. 특히 C++의 복잡한 템플릿과 전역 최적화는 높은 컴파일 비용을 요구할 수 있다.
Rust도 제네릭 단형화와 소유권·타입 검사, LLVM 최적화 때문에 대규모 프로젝트에서 컴파일 시간이 중요한 고려 사항이 될 수 있다.
Go는 빠른 컴파일과 단순한 의존 관계를 주요 설계 방향으로 삼았으며, 패키지 구조를 통해 의존성을 관리한다.
Java와 C#은 소스에서 바이트코드 또는 중간 언어를 비교적 빠르게 생성할 수 있지만, 실행 중 JIT 컴파일이 추가될 수 있다. AOT와 준비된 런타임 이미지를 사용하면 시작 특성을 조정할 수 있다.
Python과 JavaScript는 별도의 전체 네이티브 빌드 없이 프로그램을 즉시 실행할 수 있어 개발과 짧은 스크립트에 유리하다. 그러나 모듈 로딩과 런타임 초기화, JIT 준비가 시작 시간에 포함될 수 있다.
짧게 실행되는 명령행 프로그램과 서버리스 함수에서는 시작 시간이 중요하고, 장시간 실행되는 서버에서는 초기 준비 비용보다 안정 상태의 처리량이 더 중요할 수 있다.
배포 방식
C와 C++, Rust, Go의 프로그램은 일반적으로 대상 운영체제와 CPU에 맞는 네이티브 실행 파일로 배포된다. 정적 링크를 사용하면 외부 의존성을 줄일 수 있지만 파일 크기와 라이선스, 시스템 라이브러리 정책을 고려해야 한다. 동적 링크는 라이브러리를 공유할 수 있지만 대상 시스템의 ABI와 라이브러리 버전에 의존한다.
Java 프로그램은 JVM과 클래스 또는 JAR 파일을 필요로 하며, 런타임을 프로그램과 함께 패키징하거나 대상 환경에 설치된 JVM을 사용할 수 있다. C#과 .NET도 프레임워크 종속 배포와 자체 포함 배포, 네이티브 AOT 등 여러 방식을 사용할 수 있다.
Python 프로그램은 인터프리터와 패키지를 함께 준비해야 한다. 독립 실행 패키지로 묶을 수 있지만 네이티브 확장과 운영체제별 의존성 때문에 배포 크기와 호환성 관리가 필요할 수 있다.
JavaScript 프런트엔드는 일반적으로 브라우저가 런타임 역할을 하며, 번들러가 모듈을 배포용 파일로 구성한다. 서버 JavaScript는 Node.js 등의 런타임과 패키지를 필요로 한다.
Swift와 Kotlin은 대상 플랫폼에 따라 배포 방식이 달라진다. Kotlin/JVM은 JVM을 사용하고 Kotlin/Native는 네이티브 바이너리를 생성할 수 있다. Swift는 Apple 플랫폼과 Linux, Windows 등의 네이티브 실행 파일을 만들 수 있다.
플랫폼 이식성
C와 C++는 거의 모든 주요 운영체제와 프로세서에 구현체가 존재하며, 하드웨어 및 운영체제 API에 접근하기 쉽다. 그러나 플랫폼별 타입 크기와 ABI, 파일 시스템 및 시스템 API 차이를 프로그램이 직접 처리해야 할 수 있다.
Rust와 Go도 주요 데스크톱·서버 및 일부 임베디드 아키텍처를 지원한다. 지원 수준은 언어 사양만 아니라 컴파일러 백엔드와 표준 라이브러리, 플랫폼별 도구 체인의 성숙도에 따라 달라진다.
Java는 JVM이라는 공통 실행 계층을 사용하여 동일한 클래스 형식을 여러 시스템에서 실행하도록 설계되었다. 다만 네이티브 라이브러리와 그래픽, 운영체제 통합을 사용하면 플랫폼 차이가 다시 나타날 수 있다.
C#과 .NET은 여러 운영체제를 지원하지만 특정 UI 프레임워크와 운영체제 API는 플랫폼별일 수 있다.
JavaScript는 브라우저라는 매우 넓은 공통 실행 환경을 가지지만 브라우저별 기능 지원과 호스트 API의 차이를 고려해야 한다.
Kotlin과 Swift 같은 언어는 여러 플랫폼을 지원해도 각 플랫폼에서 사용할 수 있는 프레임워크와 라이브러리가 다를 수 있다. 언어가 다중 플랫폼이라는 사실과 애플리케이션 전체가 수정 없이 모든 플랫폼에서 동작한다는 것은 별개의 문제이다.
외부 언어와의 상호 운용
C ABI는 비교적 단순하고 많은 언어와 운영체제가 지원하므로 언어 간 공통 경계로 널리 사용된다. C++와 Rust, Swift, Python, Java, C# 등은 C 함수를 호출하거나 C에서 호출할 수 있는 인터페이스를 제공할 수 있다.
C++ ABI는 클래스와 이름 장식, 예외, 표준 라이브러리 타입과 컴파일러 버전의 영향을 더 많이 받는다. 외부에 공개할 안정적인 인터페이스에서는 C ABI와 불투명한 핸들을 사용하는 경우가 많다.
Kotlin은 Java와의 상호 운용을 주요 설계 목표로 삼아 대부분의 Java 코드를 Kotlin에서 직접 사용할 수 있고 Kotlin 코드도 Java에 노출할 수 있다.
C#은 .NET의 다른 언어와 공통 타입 체계 및 중간 언어를 공유하며, P/Invoke와 네이티브 상호 운용 기능을 사용할 수 있다.
Python은 C API와 확장 모듈을 통해 네이티브 라이브러리를 연결할 수 있다. 이는 과학 계산과 머신러닝에서 Python을 상위 제어 계층으로 사용하고 계산 집약적인 부분을 네이티브 라이브러리에 맡기는 기반이 된다.
Swift는 Objective-C와 C에 대한 상호 운용을 제공하고 C++와의 연동 범위도 확장되고 있다. 다만 안전하지 않은 외부 인터페이스를 사용할 때는 메모리와 수명 규칙을 직접 확인해야 한다.
표준화와 언어 안정성
C와 C++는 ISO/IEC 표준을 통해 사양이 관리된다. 현재 C 표준화는 WG14가, C++ 표준화는 WG21이 담당한다. 국제 표준은 여러 독립 구현체가 공통 언어 규칙을 따를 수 있는 기반을 제공한다.
Java는 언어 사양과 JVM 사양을 별도로 제공하며, 소스 언어와 가상 머신의 관계를 명확하게 구분한다.
ECMAScript는 TC39의 살아 있는 사양을 중심으로 발전하며, 완성된 제안이 최신 사양에 반영된다.
Python과 Rust, Go, Kotlin, Swift는 각각 공개 제안과 프로젝트 거버넌스를 통해 언어를 발전시킨다. 국제 표준이 없더라도 명확한 참조 문서와 안정성 정책, 주요 구현체의 품질을 통해 실질적인 호환성을 제공할 수 있다.
표준화된 언어라고 해서 모든 구현체가 항상 모든 최신 기능을 같은 시점에 지원하는 것은 아니다. 반대로 단일 프로젝트가 관리하는 언어는 새로운 기능을 빠르게 배포할 수 있지만, 구현체와 거버넌스에 대한 의존도가 높아질 수 있다.
개발 생산성
개발 생산성은 코드 줄 수만으로 측정할 수 없다. 언어가 오류를 얼마나 일찍 발견하는지, 빌드와 시험이 얼마나 빠른지, 라이브러리를 얼마나 쉽게 사용할 수 있는지, 오류 메시지와 도구가 얼마나 이해하기 쉬운지가 함께 영향을 준다.
Python은 동적 객체와 간결한 문법, 대화형 실행을 통해 자동화와 실험적인 개발에 적합하다. 반면 대규모 코드에서는 타입 힌트와 정적 분석, 시험 및 모듈 경계가 중요해질 수 있다.
Java와 C#은 명시적인 타입 및 객체 모델과 넓은 표준 라이브러리, 성숙한 IDE를 통해 대규모 애플리케이션을 관리할 수 있다. 코드가 상대적으로 장황해질 수 있지만 리팩터링과 도구 분석에 많은 정보를 제공한다.
Kotlin은 Java 생태계와의 호환성을 유지하면서 널 안전성과 타입 추론, 확장 함수, 간결한 데이터 표현을 제공한다. Java와 같은 플랫폼을 사용하므로 기존 라이브러리를 활용할 수 있지만, Java와 Kotlin 사이의 널 정보와 API 관용 방식 차이를 고려해야 한다.
TypeScript는 기존 JavaScript 생태계를 유지하면서 정적 타입과 편집기 분석을 추가한다. JavaScript로 표현 가능한 동적 패턴을 모델링하기 위해 타입 체계가 매우 유연하지만, 일부 타입은 실제 런타임 검사 없이 제거되므로 외부 데이터의 유효성을 별도로 검증해야 한다.
Rust는 컴파일러가 소유권과 수명, trait 제약을 검사해 많은 오류를 실행 전에 발견하지만, 초기 설계와 학습 비용이 커질 수 있다. 장기간 유지되는 시스템 코드에서는 이 정적 검사의 비용이 결함 감소와 리팩터링 안정성으로 이어질 수 있다.
C와 C++는 하드웨어 및 기존 라이브러리 접근성이 뛰어나지만 메모리와 ABI, 빌드 설정 및 플랫폼 차이를 직접 관리해야 하는 범위가 넓다. C++는 매우 다양한 추상화와 기존 기능을 제공하기 때문에 팀 차원의 언어 부분집합과 코딩 규칙이 중요할 수 있다.
대표적인 언어군 비교
C, C++와 Rust
세 언어는 네이티브 시스템 프로그래밍과 저수준 제어에 사용할 수 있지만 안전성과 추상화 방식에서 차이가 있다.
| 기준 | C (프로그래밍 언어) | C++ | Rust |
|---|---|---|---|
| 핵심 추상화 | 함수, 구조체, 포인터 | 클래스, 값 타입, 템플릿, RAII | 구조체, 열거형, trait, 소유권 |
| 메모리 관리 | 주로 수동 | RAII와 수동 관리 병행 | 소유권과 대여 |
| 가비지 컬렉션 | 없음 | 없음 | 없음 |
| 제네릭 | 매크로·void* 중심 | 템플릿과 concepts | 제네릭과 trait |
| 상속 | 없음 | 클래스 상속 지원 | 클래스 상속 없음 |
| 안전하지 않은 접근 | 일반 언어 기능 | 일반 언어 기능 | unsafe 경계로 분리 |
| ABI | C ABI가 널리 사용됨 | 구현체·플랫폼 의존성이 큼 | C ABI를 통한 외부 경계가 일반적 |
| 주요 장점 | 단순한 실행 모델과 광범위한 지원 | 높은 표현력과 기존 생태계 | 정적 메모리·동시성 안전성 |
| 주요 부담 | 메모리 안전을 직접 관리 | 기능 복잡성과 안전하지 않은 기존 구조 | 소유권 모델과 컴파일 비용 |
C는 작은 런타임과 단순한 ABI가 중요한 경계 코드에 적합하다. C++는 고성능 응용 프로그램과 대규모 추상화, 방대한 기존 라이브러리를 사용할 때 강점이 있다. Rust는 새로운 시스템 구성 요소에서 메모리 안전성을 강화하려는 경우 유리하지만 기존 C++ 생태계와 복잡한 객체 모델을 그대로 대체하는 작업은 별도의 비용을 요구한다.
Go, Java와 C#
세 언어는 서버와 기업용 애플리케이션, 네트워크 서비스에서 비교되는 경우가 많지만 실행 모델과 언어 철학이 다르다.
| 기준 | Go (프로그래밍 언어) | Java | C# |
|---|---|---|---|
| 실행 | 네이티브 코드와 Go 런타임 | JVM | .NET CLR |
| 메모리 | 가비지 컬렉션 | 가비지 컬렉션 | 가비지 컬렉션 |
| 객체 모델 | 구조체, 메서드, 구조적 인터페이스 | 클래스와 명목적 인터페이스 | 클래스·구조체와 명목적 인터페이스 |
| 동시성 | 고루틴과 채널 | 스레드, future, 가상 스레드 등 | task와 async·await |
| 제네릭 | 타입 매개변수와 제약 | 소거 기반 제네릭 | 런타임 타입 정보를 가진 제네릭 |
| 배포 | 네이티브 실행 파일 | JVM과 바이트코드 | 런타임 종속·자체 포함·AOT |
| 설계 경향 | 작은 언어와 통합 도구 | 안정된 JVM 생태계 | 언어 기능과 .NET 통합 |
Go는 언어 기능을 비교적 제한하고 동시 네트워크 프로그램과 빌드·배포의 단순성을 강조한다. Java는 JVM과 장기간 축적된 서버 및 기업 생태계가 강점이다. C#은 .NET과 긴밀하게 결합하며 값 타입과 LINQ, 비동기 함수, 패턴 일치 등 넓은 언어 기능을 제공한다.
Java와 Kotlin
Kotlin은 Java를 완전히 분리된 생태계로 대체하기보다 JVM과 Java 라이브러리를 활용하면서 언어 표현을 현대화하는 방향으로 설계되었다.
| 기준 | Java | Kotlin |
|---|---|---|
| 주요 실행 환경 | JVM | JVM, JavaScript, Native, WebAssembly |
| 널 처리 | 전통적 참조에 null 가능 | 널 가능 타입을 구분 |
| 코드 표현 | 명시적이고 보수적 | 타입 추론과 간결한 문법 |
| 비동기 | 스레드와 future, 가상 스레드 | 코루틴과 라이브러리 기반 실행 |
| 상호 운용 | JVM 생태계의 기반 | Java와 직접 상호 운용 |
| 발전 방식 | JLS와 JEP | Kotlin 사양·KEEP와 프로젝트 절차 |
기존 Java 조직과 라이브러리, 장기간의 호환성이 중요하면 Java 자체가 단순한 선택일 수 있다. 동일한 JVM 생태계에서 더 강한 널 분석과 간결한 API, 코루틴을 활용하려면 Kotlin이 유리할 수 있다. 두 언어를 함께 사용할 때는 Java의 널 정보와 checked exception, 정적 메서드 및 프로퍼티 표현 차이를 고려해야 한다.
JavaScript와 TypeScript
TypeScript는 JavaScript와 경쟁하는 별도의 런타임 언어라기보다 JavaScript 프로그램을 정적으로 분석하고 JavaScript로 변환하는 언어 계층이다.
| 기준 | JavaScript | TypeScript |
|---|---|---|
| 타입 검사 | 주로 실행 중 | 컴파일 전 정적 분석 추가 |
| 실행 | 브라우저·서버 엔진 | 변환된 JavaScript 실행 |
| 타입 체계 | 동적 객체 타입 | 구조적 정적 타입 |
| 외부 데이터 | 실행 중 직접 처리 | 타입 선언과 별도로 런타임 검증 필요 |
| 기존 코드 | 직접 실행 가능 | JavaScript를 점진적으로 포함 가능 |
| 주요 장점 | 모든 웹 환경의 기본 언어 | 대규모 코드 탐색과 변경 지원 |
작은 스크립트와 동적인 코드에서는 JavaScript만으로 충분할 수 있다. 여러 개발자가 관리하는 대규모 애플리케이션에서는 TypeScript가 API 계약과 리팩터링을 보조할 수 있다. 다만 TypeScript 타입은 일반적으로 실행 시 제거되므로 네트워크에서 받은 JSON이 선언된 타입을 실제로 만족하는지 자동으로 보장하지 않는다.
Python과 정적 컴파일 언어
Python은 네이티브 정적 언어보다 실행 성능이 낮다고 단순하게 분류되지만 실제 시스템에서는 역할 분담이 중요하다.
Python은 프로그램 구성과 자동화, 대화형 분석, 모델 정의에 사용하고, 계산 집약적인 연산은 C와 C++, Fortran, Rust 또는 GPU 라이브러리에 맡길 수 있다. 이러한 구조에서는 Python 코드의 반복 속도보다 네이티브 라이브러리에 큰 단위의 작업을 전달하는 방식이 중요하다.
정적 네이티브 언어는 반복적인 소규모 연산과 메모리 배치, 지연 시간 제어에 유리하다. Python은 표현의 간결함과 동적 객체, 풍부한 라이브러리를 통해 개발과 실험 속도에 유리할 수 있다.
따라서 Python과 C++ 가운데 하나만 선택하기보다 Python이 상위 작업 흐름을 담당하고 C++가 핵심 계산을 담당하는 혼합 구조가 적합할 수 있다.
Swift와 Kotlin
Swift와 Kotlin은 각각 Apple과 Android 생태계에서 자주 비교되지만 언어 자체의 실행 범위는 모바일에 한정되지 않는다.
Swift는 네이티브 컴파일과 값 타입, 자동 참조 계수, 메모리 안전성과 Apple 프레임워크 통합을 중심으로 한다. Kotlin은 JVM 상호 운용과 가비지 컬렉션, 널 안전성, 코루틴 및 다중 플랫폼 백엔드를 제공한다.
Apple 플랫폼의 네이티브 UI와 시스템 API가 중심이면 Swift가 자연스러운 선택이며, Android 및 JVM 생태계에서는 Kotlin이 자연스럽다. 두 플랫폼의 업무 로직을 공유하려는 경우 Kotlin Multiplatform이나 C++·Rust 기반 공통 코어 등 별도의 구조를 검토할 수 있다.
흔한 비교 오류
언어의 속도와 구현체의 속도를 동일하게 보는 오류
언어 사양은 프로그램의 의미를 정의하지만 구체적인 기계어와 최적화 수준을 모두 결정하지 않는다. 같은 언어도 여러 구현체에서 다른 성능을 보일 수 있다.
코드가 짧으면 생산성이 항상 높다고 보는 오류
짧은 코드는 작성 속도를 높일 수 있지만 숨겨진 동작과 동적 오류, 유지보수 비용이 커질 수 있다. 반대로 명시적인 타입과 오류 처리는 코드를 길게 만들지만 대규모 변경에 도움을 줄 수 있다.
정적 타입이면 오류가 없다고 보는 오류
정적 타입은 타입 체계가 표현하는 오류만 차단한다. 잘못된 알고리즘과 보안 정책, 데이터베이스 트랜잭션 오류, 외부 입력 문제는 별도로 처리해야 한다.
가비지 컬렉션은 항상 느리다고 보는 오류
가비지 컬렉터는 할당을 빠르게 처리하고 객체를 압축하여 지역성을 높일 수 있다. 반면 수집 시점과 메모리 여유가 중요한 비용이 된다. 작업 부하와 수집기 설계에 따라 결과가 달라진다.
네이티브 언어는 항상 관리형 언어보다 빠르다고 보는 오류
JIT 컴파일러는 실제 실행 정보를 이용해 동적 최적화를 수행할 수 있다. 반대로 네이티브 컴파일러는 실행 전 전체 프로그램 분석과 대상별 최적화를 수행할 수 있다. 어느 쪽이 유리한지는 프로그램과 실행 시간, 구현체에 따라 달라진다.
최신 언어가 오래된 언어를 항상 대체한다고 보는 오류
기존 코드와 라이브러리, ABI, 인력과 검증 기록은 큰 자산이다. Fortran과 COBOL, C가 계속 사용되는 이유는 문법의 최신성만이 아니라 축적된 시스템과 생태계에 있다.
여러 패러다임을 지원하면 항상 더 좋다고 보는 오류
기능이 많으면 다양한 문제를 표현할 수 있지만 언어와 코드베이스의 복잡성이 증가할 수 있다. 작고 제한적인 언어는 팀 전체가 일관된 방식을 사용하게 하는 장점이 있다.
언어 선택 방법
프로그래밍 언어를 선택할 때는 먼저 프로젝트의 필수 조건과 변경 가능한 조건을 분리해야 한다.
실행 환경
대상 운영체제와 CPU, 브라우저, 모바일 플랫폼, 게임 콘솔과 임베디드 장치를 먼저 확인해야 한다. 특정 플랫폼이 공식적으로 지원하는 언어와 SDK가 사실상 선택 범위를 결정할 수 있다.
성능 요구
평균 처리량뿐 아니라 최악 지연 시간과 시작 시간, 메모리 상한, 바이너리 크기 및 전력 사용을 구체적으로 정해야 한다. 실제와 유사한 작업으로 프로토타입을 측정해야 한다.
안전성과 신뢰성
메모리 안전성과 타입 오류, 데이터 경쟁, 널 가능성, 오류 처리와 인증 요구를 검토해야 한다. 언어 기능뿐 아니라 코딩 표준과 정적 분석, 시험 도구도 함께 평가해야 한다.
기존 생태계
필요한 데이터베이스 드라이버와 그래픽 API, 머신러닝 프레임워크, 장치 SDK와 기업 시스템이 어느 언어를 지원하는지 확인해야 한다. 핵심 라이브러리가 없다면 언어의 문법적 장점보다 직접 구현해야 하는 비용이 커질 수 있다.
개발 조직
개발자의 경험과 채용 가능성, 교육 비용, 코드 리뷰와 운영 체계를 고려해야 한다. 소수 전문가만 이해하는 언어를 핵심 시스템 전체에 사용하면 장기 유지보수 위험이 발생할 수 있다.
배포와 운영
프로그램을 어떤 방식으로 설치하고 업데이트하며 관찰할지 검토해야 한다. 런타임 설치와 컨테이너 크기, 콜드 스타트, 디버깅 및 프로파일링 도구가 운영 비용에 영향을 준다.
장기 유지보수
언어와 구현체의 거버넌스, 호환성 정책, 표준화 상태와 버전 지원 기간을 확인해야 한다. 단기적인 개발 속도보다 수년 동안 코드와 의존성을 갱신할 수 있는지가 더 중요할 수 있다.
비교를 위한 실용적 판단표
| 요구 사항 | 우선 검토할 특성 | 대표적인 언어 선택지 |
|---|---|---|
| 운영체제·드라이버 | 네이티브 실행, ABI, 메모리 제어 | C, C++, Rust |
| 고성능 게임·렌더링 | 예측 가능한 지연, 그래픽 생태계 | C++, Rust, C# |
| 네트워크 서비스 | 동시성, 배포, 서버 생태계 | Go, Java, C#, Rust, Kotlin |
| 기업 업무 시스템 | 호환성, 관리형 런타임, 프레임워크 | Java, C#, Kotlin |
| 웹 프런트엔드 | 브라우저 실행과 웹 API | JavaScript, TypeScript |
| 자동화와 도구 | 빠른 작성, 파일·프로세스 처리 | Python, 셸, PowerShell |
| 데이터 분석 | 대화형 실행, 통계·수치 생태계 | Python, R, Julia |
| 과학·수치 계산 | 배열 연산, 병렬 계산, 기존 코드 | Fortran, C++, Python, Julia |
| 모바일 애플리케이션 | 플랫폼 SDK와 UI 프레임워크 | Kotlin, Swift |
| 임베디드 장치 | 작은 런타임, 하드웨어 접근 | C, C++, Rust, Ada |
| 안전 필수 시스템 | 검증 가능성, 제한된 실행 모델 | Ada, C, C++, Rust |
| 플러그인·모드 | 내장성, 샌드박스, 간단한 API | Lua, JavaScript, WebAssembly |
| 빠른 프로토타입 | 실행과 수정의 즉시성 | Python, JavaScript |
| 대규모 정적 웹 코드 | 구조적 타입과 JS 생태계 | TypeScript |
이 표는 절대적인 권장 목록이 아니다. 같은 분야에서도 기존 기술과 팀의 경험, 플랫폼과 라이선스에 따라 다른 선택이 더 적합할 수 있다.
종합
프로그래밍 언어의 차이는 문법 표현의 차이에 그치지 않는다. 값과 객체를 어떻게 모델링하는지, 타입을 언제 검사하는지, 메모리와 자원을 누가 관리하는지, 프로그램이 어떤 실행 환경에서 동작하는지에 따라 개발 방식과 오류의 종류, 성능 특성이 달라진다.
C와 C++는 하드웨어와 기존 시스템 생태계에 대한 넓은 제어를 제공한다. Rust는 비슷한 영역에서 소유권 기반 안전성을 강화한다. Go는 비교적 작은 언어와 런타임 기반 동시성을 제공한다. Java와 C#은 관리형 플랫폼과 대규모 응용 프로그램 생태계를 중심으로 한다. Python과 JavaScript는 동적 실행과 빠른 개발, 광범위한 활용 환경을 제공하며, TypeScript는 JavaScript 생태계에 정적 분석을 추가한다. Kotlin과 Swift는 현대적인 타입 및 안전 기능과 주요 플랫폼 생태계를 결합한다. Fortran은 배열과 수치 계산을 중심으로 축적된 과학·공학 생태계를 가진다.
어느 언어도 모든 비교 기준에서 동시에 우월하지 않다. 저수준 제어를 강화하면 프로그래머가 책임져야 할 영역이 넓어지고, 자동 관리를 강화하면 런타임과 구현체가 부담하는 영역이 넓어진다. 정적 검사를 강화하면 일부 오류를 사전에 차단할 수 있지만 타입 체계와 컴파일 과정이 복잡해질 수 있고, 동적 실행을 강화하면 유연성이 높아지지만 실행 중 검증과 시험의 책임이 커질 수 있다.
따라서 프로그래밍 언어를 비교하는 목적은 하나의 최종 승자를 정하는 것이 아니다. 각 언어가 어떤 문제를 해결하기 위해 어떤 비용과 제약을 선택했는지를 이해하고, 프로젝트의 실행 환경과 안전성, 성능, 개발 조직 및 기존 생태계에 가장 적합한 선택을 내리는 데 있다.
관련 문서
- 프로그래밍
- 소스 코드
- 기계어
- 어셈블리어
- 고급 프로그래밍 언어
- 저급 프로그래밍 언어
- 범용 프로그래밍 언어
- 도메인 특화 언어
- 스크립트 언어
- 프로그래밍 패러다임
- 명령형 프로그래밍
- 객체 지향 프로그래밍
- 함수형 프로그래밍
- 선언형 프로그래밍
- 논리 프로그래밍
- 타입 시스템
- 정적 타입
- 동적 타입
- 구문
- 프로그래밍 언어 의미론
- 컴파일러
- 인터프리터
- 어휘 분석
- 구문 분석
- 추상 구문 트리
- 중간 표현
- 바이트코드
- 가상 머신
- 런타임 시스템
- 표준 라이브러리
- 언어 사양
- 프로그래밍 언어 표준화
- C (프로그래밍 언어)
- C++
- Fortran
- COBOL
- LISP
- Smalltalk
- Pascal
- Prolog
- Haskell
- Java
- JavaScript
- Python
- Ruby
- PHP
- C#
- Go (프로그래밍 언어)
- Rust
- Kotlin
- Swift
- TypeScript
- SQL
- WebAssembly