C vs Java vs Python

Updated:

컴파일러 언어 vs 인터프리터 언어

컴파일러 언어

컴파일러 언어는 작성한 코드를 기계어로 번역을 해놓기 때문에 실행 속도가 빠르고 보안성이 높습니다. 이러한 프로그램을 목적프로그램이라고도 하고, 컴퓨터 하드웨어(cpu)가 알아 들을 수 있는 기계어로 번역되었다는 의미에서 바이너리 파일이라고도 합니다.

하지만, 코드 수정을 조금이라도 한다면, 모든 부분을 다시 컴파일을 해야 하기 때문에 개발 기간이 오래 걸리지만, 개발 후 실행 속도는 가장 빠릅니다.

대표적인 컴파일러 언어로는 C / Java가 있습니다.

인터프리터 언어

인터프리터 언어는 컴파일 언어처럼 몽땅 기계어로 미리 변환되는 것이 아니고 실행중에 ‘interpreted’ 된다.

따라서, 인터프리터 언어는 컴파일 언어와는 달리 코드를 한 줄씩 번역, 실행하는 방식입니다. 따라서 실행 속도는 컴파일 언어 보다 느리지만, 코드 수정시 전체를 완전히 새로 ‘recompile’ 할 필요가 없이 바꾼 부분만 번역, 실행하여 빠르게 수정이 가능합니다. 심지어 실행 중에도 수정이 가능합니다. 그리고, 컴파일 언어보다 문법이 쉬운 편입니다. 단, 보안성이 떨어지는 편입니다.

대표적인 인터프리터 언어로는 Python이 이습니다.

C / C++

Visual Studio에서 우리는 실행할때 F5(또는 Ctrl+F5)를 눌러서 우리가 만든 소스코드를 실행시켜봤죠? 우리는 너무 쉽게 프로그램을 실행시킨다고 생각할 수 있지만 의외로 몇몇 단계를 거치고 있습니다.

image

program.c

#include <stdio.h>
#define A 10
#define B 20
int main(){
        int a=A;
        int b=B;
        int c=a+b;
        printf("%d + %d = %d\n",a,b,c);
}

전처리기(Preprocessor)

전처리기 구문(#으로 시작하는 구문)을 처리하는 것이 바로 전처리기라고 하는데요. 일반적으로 #으로 시작하는 부분을 거의 항상 사용합니다. 그것이 언제냐면 바로 #include지요. 너무나도 소중한 printf를 사용하기 위해서는 항상 #include 를 항상 명시해주어야 하죠.

#include를 통해서 stdio.h의 내용이 그대로 들어오게 됩니다!

또한 위의 코드에서 우리는 #define A 10 과 같은 줄을 볼 수 있는데요. 여기서 전처리기는 A라는 부분을 단순히 10으로 치환합니다. 그렇다면 전처리 과정을 끝낸 program.i는 어떻게 변할까요?

gcc -E program.c -o program.i

위의 명령어로 program.i의 내용을 살펴봅시다.

program.i

# 1 "program.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "program.c"

....
extern int printf (const char *__restrict __format, ...);
...

int main(){
 int a=10;
 int b=20;
 int c=a+b;
 printf("%d + %d = %d\n",a,b,c);
}

stdio.h의 내용이 main위의 그대로 들어오지요? 또한 #define A 10과 같은 내용은 없어지고 A가 10으로 치환된것을 알 수 있습니다.

중요한것은 전처리기가 컴파일 단계 맨 처음 단계라는 것을 기억하셔야합니다. 그래야지 전처리를 통한 조건부 컴파일을 이해하게 됩니다.

컴파일러(Compiler)

이제 전처리기를 거쳤으니 컴파일러로 컴파일해줍니다. 컴파일러는 고수준언어를 저수준언어로 나타내는 역할을 수행합니다. 저수준언어라는 것은 기계어와 가장 가까운 언어입니다.

이제 program.i로부터 어떻게 program.s가 생겨나는지 보도록 합시다.

gcc -S program.i -o program.s

program.s

.file   "program.c"
        .section        .rodata
.LC0:
        .string "%d + %d = %d\n"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $16, %rsp
        movl    $10, -4(%rbp)
        movl    $20, -8(%rbp)
        movl    -8(%rbp), %eax
        movl    -4(%rbp), %edx
        addl    %edx, %eax
        movl    %eax, -12(%rbp)
        movl    -12(%rbp), %ecx
        movl    -8(%rbp), %edx
        movl    -4(%rbp), %eax
        movl    %eax, %esi
        movl    $.LC0, %edi
        movl    $0, %eax
        call    printf
        leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-16)"
        .section        .note.GNU-stack,"",@progbits

그냥 저수준언어로 변한것 밖에는 모르겠네요.

근데 “%d + %d = %d\n” 는 우리가 printf에 썼던 문자열이라는 것을 알 수 있네요.

이것이 컴파일러가 하는 역할입니다. 이제 파일을 오브젝트파일로 변환하는 어셈블러를 보도록 합니다.

어셈블러(Assembler)

이제 완전히 기계어로 바꾸어 주는 역할을 합니다. 우리가 읽을 수 없거든요. 다음의 명령어를 통해서 기계어 파일을 만들고 확인해보도록 하죠.

gcc -c program.s -o program.o

program.o

^?ELF^B^A^A^@^@^@^@^@^@^@^@^@^A^@>^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@È^B^@^@^@^@^@^@^@^@^@^@@^@^@^@^@^@@^@^M^@

^@UH<89>åH<83>ì^PÇEü

^@^@^@ÇEø^T^@^@^@<8b>Eø<8b>Uü^AÐ<89>Eô<8b>Mô<8b>Uø<8b>Eü<89>Æ¿^@^@^@^@¸^@^@^@^@è^@^@^@^@ÉÃ%d + %d = %d

^@^@GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-16)^@^@^@^@^@^@^@^@^T^@^@^@^@^@^@^@^AzR^@^Ax^P^A^[^L^G^H<90>^A^@^@^\^@^@^@^\^@^@^@^@^@^@^@=^@^@^@^@A^N^P<86>^BC^M^Fx^L^G^H^@^@^@^@.symtab^@.strtab^@.shstrtab^@.rela.text^@.data^@.bss^@.rodata^@.comment^@.note.GNU-stack^@.rela.eh_frame^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^D^@ñÿ^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^C^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^D^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^E^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^G^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^H^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^F^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^K^@^@^@^R^@^A^@^@^@^@^@^@^@^@^@=^@^@^@^@^@^@^@^P^@^@^@^P^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@program.c^@main^@printf^@^@-^@^@^@^@^@^@^@

^@^@^@^E^@^@^@^@^@^@^@^@^@^@^@7^@^@^@^@^@^@^@^B^@^@^@

링커(Linker)

링커는 이름이 말해주듯 연결해주는 역할을 합니다. 여러개의 오브젝트파일을 하나로 합치거나 라이브러리를 합칠때 링커가 필요하다는 거지요.

우리는 일반적으로 개발할때 협업을 합니다. 그래서 위와 같이 오브젝트 파일(.o)라던가 라이브러리 파일이 여럿 존재할 수 있는데 하나의 소프트웨어를 만들기 위해서는 위의 파일들을 합쳐야하는 거죠. 이해되셨나요?

이제 실행파일을 만들어보겠습니다.

gcc program.o -o program.exe

그 후 실행을 시키면

./program.exe
10 + 20 = 30

정상적으로 실행이 되는 것을 확인할 수 있습니다.

Java

Java의 작동방식을 알기 위해선 JVM을 먼저 알아야 합니다.

JVM은 물리적 머신을 소프트웨어 구현한 Java 프로그램을 실행할 수 있게 해주는 추상컴퓨팅 시스템입니다. JVM은 스택기반의 가상머신입니다. Java는 WORA(Write Once Run Anywhere)의 철학을 위해 VM을 개발하였습니다. JVM은 JAVA와 OS사이에서 중개자 역할을 수행하여 OS 상관없이 어느 환경에서도 Java가 실행이 가능토록 만들어 준 것입니다. Java를 컴파일 언어로서 실행하기 위해서는 컴파일을 거쳐야 합니다.

실행과정

  1. 프로그램이 실행되면 JVM은 OS로 부터 이 프로그램이 필요로 하는 메모리를 할당받는다.
  2. 자바 컴파일러(javac)가 자바 소스코드(.java)를 읽어서 자바 바이트코드(.class)로 변환시킨다.
  3. 클래스 로더를 통해 class파일을 JVM으로 로딩한.
  4. 로딩된 class 파일들은 Execution engine을 통해 해석된다.
  5. 해석된 바이트코드는 Runtime Data Area 에 배치되어 실직적인 수행이 이루어진다.

JVM Architecture

Class Loader

JVM내로 클래스(.class파일)를 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈이다. jar파일 내 저장된 클래스들을 JVM위에 탑재하고 사용하지 않는 클래스들은 메모리에서 삭제한다. 자바는 동적코드, 컴파일 타임이 아니라 런타임에 참조한다. 즉, 클래스를 처음으로 참조할 때, 해당 클래스를 로드하고 링크한다는 것이다. 그 역할을 클래스 로더가 수행을 한다.

Execution Engine

클래스를 실행시키는 역할이다. 클래스 로더가 JVM내의 런타임 데이터 영역에 바이트 코드를 배치시키고, 이것은 실행엔진에 의해 실행된다. 자바 바이트코드는 기계가 바로 수행할 수 있는 언어보다는 비교적 인간이 보기 편한 형태로 기술이 되어있는데 이 자바 바이트 코드를 실행 엔진이 실제로 JVM내부에서 기계가 실행할 수 있는 형태로 변경한다.

Interpreter

실행 엔진은 자바 바이트 코드를 명령어 단위로 읽어서 실행한다. 하지만 이 방식은 인터프리터 언어의 단점을 그대로 갖고 있다.

JIT(Just In Time)

인터프리터 방식의 단점을 보완하기 위해 도입된 JIT 컴파일러이다. 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 더 이상 인터프리팅 하지 않고 네이티브 코드로 직접 실행하는 방식이다. 네이티브 코드는 캐시에 보관하기 때문에 한 번 컴파일된 코드는 빠르게 수행하게 된다.

JIT컴파일러가 컴파일하는 과정은 바이트코드를 인터프리팅하는 것보다 훨씬 오래걸리므로 한 번만 실행되는 코드라면 컴파일하지 않고 인터프리팅하는 것이 유리하다. 따라서 JIT 컴파일러를 사용하는 JVM들은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고, 일정 정도를 넘을 때에만 컴파일을 수행한다.

Runtime Data Area

PC Register

Thread 가 시작될 때 생성되며 생성될 때마다 생성되는 공간으로 스레드마다 하나씩 존재한다. Thread가 어떤 부분을 어떤 명령으로 실행해야할 지에 대한 기록을 하는 부분으로 현재 수행 중인 JVM 명령의 주소를 갖는다.

JVM Stack Area

프로그램 실행과정에서 임시로 할당되었다가 메소드를 빠져나가면 바로 소멸되는 특성의 데이터를 저장하기 위한 영역이다. 각종 형태의 변수나 임시 데이터, 스레드나 메소드의 정보를 저장한다. 메소드 호출 시마다 각각의 스택 프레임(그 메서드만을 위한 공간)이 생성된다. 메서드 수행이 끝나면 프레임 별로 삭제를 한다. 메소드 안에서 사용되는 값들(local variable)을 저장한다. 또 호출된 메소드의 매개변수, 지역변수, 리턴 값 및 연산 시 일어나는 값들을 임시로 저장한다.

Native Method Stack

자바 프로그램이 컴파일되어 생성되는 바이트 코드가 아닌 실제 실행할 수 있는 기계어로 작성된 프로그램을 실행시키는 영역이다. JAVA가 아닌 다른 언어로 작성된 코드를 위한 공간이다. JAVA Native Interface를 통해 바이트 코드로 전환하여 저장하게 된다. 일반 프로그램처럼 커널이 스택을 잡아 독자적으로 프로그램을 실행시키는 영역이다. 이 부분을 통해 C code를 실행시켜 Kernel에 접근할 수 있다.

Method Area

클래스 정보를 처음 메모리 공간에 올릴 때 초기화되는 대상을 저장하기 위한 메모리 공간이다. 올라가게 되는 메소드의 바이트 코드는 프로그램의 흐름을 구성하는 바이트 코드이다. 자바 프로그램은 main 메소드의 호출에서부터 계속된 메소드의 호출로 흐름을 이어가기 때문이다. 대부분 인스턴스의 생성도 메소드 내에서 명령하고 호출한다. 사실상 컴파일 된 바이트코드의 대부분이 메소드 바이트코드이기 때문에 거의 모든 바이트코드가 올라간다고 봐도 상관없다. Runtime Constant Pool이라는 별도의 관리 영역도 함께 존재한다. 이는 상수 자료형을 저장하여 참조하고 중복을 막는 역할을 수행한다.

Heap Area

객체를 저장하는 가상 메모리 공간이다. new연산자로 생성된 객체와 배열을 저장한다. 물론 class area영역에 올라온 클래스들만 객체로 생성할 수 있다.

Python

파이썬 코드를 실행하려면 당연하지만, 파이썬 인터프리터가 있어야 합니다.

파이썬 인터프리터는 홈페이지에서 다운로드 받거나 OS 별로 제공되는 패키지 매니저를 통해서 설치할 수 있습니다.

파이썬을 설치하면, 인터프리터와 내장 지원 라이브러리 등이 설치됩니다. 여기서 인터프리터 란, 파이썬 코드를 실행하는 소프트웨어 입니다.

컴파일

python app.py 를 실행하면 아래와 같은 일이 일어납니다.

  • .py 파일을 바이트 코드 상태로 컴파일 합니다.
  • PVM (Python Virtual Machine) 이라 하는 파이썬 실행 환경에 바이트코드를 전달합니다.

바이트 코드로 소스 코드를 컴파일 하는 과정은 그 정확한 동작을 알 수 없습니다.

컴파일한다는 것은, 소스 코드를 해석하여 원하는 상태로 변환 하는 것을 의미하고 파이썬에서 컴파일은 소스 코드를 저수준의, 플랫폼 독립적인(!) 코드로 변환 하는 것을 의미합니다.

파이썬의 컴파일 과정은 아래와 같습니다.

  1. 소스 코드를 Parse Tree 로 변환합니다
  2. Parse Tree 를 AST(Abstract Syntax Tree) 로 다시 한 번 변환 합니다
  3. AST를 제어 흐름 그래프(Control FlowGraph)로 변환 합니다
  4. 제어 흐름 그래프를 Byte code 로 변환 합니다.

이 과정을 거치고 나면, .pyc 파일이 생성 됩니다. .pyc 파일은 파이썬 3.2 버전 이전에는 .py 파일과 같은 경로에 생성되고 3.2 이후 버전에서는 __pycache__ 디렉터리 아래에 생성 됩니다.

.pyc 파일은 다음과 같은 경우에 유용하게 사용할 수 있습니다

  1. AWS Lambda 에 배포할 때 용량 제한을 피할 수 있습니다. (여러 라이브러리는 .pyc 파일로 컴파일된 것만 배포)
  2. 파이썬 프로그램을 작성하여 판매 하는 경우

.pyc 파일을 python app.py 와 같은 명령어로 실행 한다고 반드시 생성 되는 것은 아닙니다. .py 파일이 다른 스크립트에 의해 import 되었을 경우에만 생성 됩니다.

import 문이 호출되었을 때는 아래와 같은 확인을 합니다

  1. 파이썬은 import 되는 스크립트의 컴파일 된 파일이 존재 하는지 확인 합니다
  2. 없다면, .pyc 파일을 생성하고 불러 옵니다.
  3. 있다면, 내부 timestamp 에서 .py 파일 보다 .pyc 파일이 더 오래 되었는지 확인합니다

-> 한 마디로, 소스 코드가 변경 되면 자동으로 새 .pyc 로 갱신합니다 -> 대화형 프롬프트(REPL) 환경에서 입력한 코드에 대해서는 .pyc 파일을 생성하지 않습니다

파이썬 가상 머신 PVM

PVM 은 파이썬의 런타임 엔진 입니다. 파이썬 시스템의 일부이기 때문에 별도의 설치가 필요하지 않고, 항상 존재 합니다.

소스 코드를 .pyc 와 같은 바이트 코드 형태로 변환한 뒤 PVM 으로 전달하면 파이썬 가상 머신은 이것을 실행해 줍니다.

추가로, 프로즌 바이너리는 내가 작성한 파이썬 프로그램을 우리가 아는 실행 파일로(.exe) 변환 하는 것 또한 가능합니다.

이를테면, PyInstaller, py2exe, py2app 등을 통해서 실행 가능한 바이너리로 만들 수 있습니다.

프로즌 바이너리를 만들 때에는 바이트 코드와, 실행 환경(PVM) 그리고 의존성 모듈을 단일 패키지로 만듭니다. 그리고 그 결과물은 실행 가능한 형태 (.exe 등) 가 됩니다

AST

컴퓨터 과학에서 추상 구문 트리(abstract syntax tree, AST), 또는 간단히 구문 트리(syntax tree)는 프로그래밍 언어로 작성된 소스 코드의 추상 구문 구조의 트리이다. 이 트리의 각 노드는 소스 코드에서 발생되는 구조체를 나타낸다. 구문이 추상적이라는 의미는 실제 구문에서 나타나는 모든 세세한 정보를 나타내지는 않는다는 것을 의미한다.

유클리드 호제법을 사용하여 다음의 코드를 나타낸 추상 구문 트리

image

while b ≠ 0
  if a > b
    a := a − b
  else
    b := b − a
return a

Leave a comment