이식성
고친 사람 github-actions[bot]
이식성은 프로그램을 다른 컴퓨터나 운영체제로 옮길 때 고칠 곳을 줄여 줍니다. 맥 노트북에서 짠 서버 코드를 고치지 않고 리눅스 서버에서 돌리는 것이 그 모습입니다. 개인 데이터를 다른 서비스로 가져가는 일도 같은 낱말로 부릅니다. 이 항목은 프로그램을 옮기는 쪽을 다룹니다.
쉽고 빠른 이해
이식성은 프로그램을 다른 환경으로 옮겨도 적게 고치고 돌게 해 주는 성질입니다. 맥 노트북에서 짠 서버 코드가 리눅스 서버에서도 도는 것이 그렇습니다.
이 성질이 없으면 옮길 때마다 코드를 새로 손봐야 합니다. 운영체제나 칩 종류가 하나 늘 때마다 고칠 코드도 한 벌씩 늡니다.
- 여러 환경이 함께 지키는 약속에 맞춰 코드를 짭니다
- 환경마다 다른 부분은 한곳에 모아 가려 둡니다
- 옮길 때는 그 한곳만 고치거나 새 환경에서 다시 빌드합니다
대가는 환경마다 가진 고유한 기능과 속도를 일부 내준다는 것입니다. 모든 환경이 가진 것만 쓰게 되기 때문입니다.
그래서 여러 환경에 나눠 주는 라이브러리나 도구는 이 대가를 치르고 이식성을 챙깁니다. 한 종류 서버에만 올리는 서비스는 덜 챙깁니다.
상세
요리법을 친구에게 건넨다고 해 봅시다. 「소금 한 작은술, 중불에서 5분」이라고 적으면 친구는 자기 부엌에서 그 글만 보고 따라 합니다. 「우리 집 가스레인지 3단에서 파란 냄비로」라고 적으면 친구는 그 가스레인지도 파란 냄비도 없어서 거기서 멈춥니다.
프로그램도 자기가 도는 환경에 기댑니다. 이식성은 프로그램이 특정 환경에 덜 기대게 짜여 있어서, 다른 환경으로 옮길 때 고칠 곳이 적은 성질입니다.
이식성은 있다 없다로 갈리지 않고 정도로 잽니다. 옮기는 수고가 처음부터 새로 짜는 수고보다 훨씬 적으면 이식성이 높다고 말합니다. 프로그램을 다른 환경으로 옮기는 작업 자체는 포팅이라고 부릅니다.
플랫폼과 그 차이
먼저 프로그램이 기대는 환경이 무엇으로 이루어지는지 보고, 환경이 바뀌면 무엇이 달라지는지를 표로 모읍니다.
프로그램이 올라타서 도는 바탕을 플랫폼이라고 부릅니다. 보통 칩 종류와 운영체제의 짝을 말합니다. 윈도우 노트북과 리눅스 서버는 플랫폼이 다릅니다.
칩 쪽부터 봅니다. CPU(Central Processing Unit, 중앙 처리 장치)는 종류마다 알아듣는 명령이 다릅니다. 한 종류의 CPU 가 알아듣는 명령의 목록을 명령어 집합이라고 부릅니다.
컴파일러가 소스 코드를 번역해 내놓는 결과물이 기계어입니다. 기계어는 한 명령어 집합의 명령으로 적혀 있습니다. 그래서 명령어 집합이 다른 CPU 에서는 돌지 않습니다.
운영체제 쪽도 다릅니다. 운영체제는 파일을 열거나 네트워크로 데이터를 보내는 일을 프로그램 대신 해 줍니다. 프로그램이 운영체제에 이런 일을 맡기는 통로가 시스템 콜입니다. 이 통로의 이름과 모양이 운영체제마다 다릅니다.
그 밖에도 플랫폼마다 갈리는 것이 여럿입니다. 아래 표는 옮길 때 자주 걸리는 차이를 모은 것입니다.
| 달라지는 것 | 무엇이 갈리나 |
|---|---|
| 명령어 집합 | 한 CPU 용으로 만든 실행 파일이 다른 CPU 에서 안 돈다 |
| 시스템 콜 | 파일과 프로세스를 다루는 호출의 이름과 인자가 다르다 |
| 정수 크기 | 같은 이름의 정수 타입이 4바이트이기도 8바이트이기도 하다 |
| 바이트 순서 | 여러 바이트짜리 수를 메모리에 어느 끝부터 놓는지가 다르다 |
| 파일 경로 | 경로 구분자가 / 이기도 \ 이기도 하다 |
| 줄바꿈 | 줄 끝을 한 글자로 적기도 두 글자로 적기도 한다 |
표의 위 둘은 옮기자마자 드러납니다. 빌드가 안 되거나 실행 파일이 안 뜨기 때문입니다. 아래 넷은 빌드도 되고 실행도 됩니다. 특정 값이나 특정 파일을 만났을 때 뒤늦게 틀린 결과로 드러납니다.
소스 이식성과 바이너리 이식성
옮긴다고 할 때 무엇을 들고 가느냐에 따라 길이 갈립니다. 이 소절은 소스 코드를 들고 가는 길, 실행 파일을 들고 가는 길, 그 둘 사이에 중간 언어를 두는 길을 차례로 봅니다.
소스 이식성은 소스 코드를 새 플랫폼으로 가져가 그곳의 컴파일러로 다시 빌드하면 도는 성질입니다. 기계어는 플랫폼마다 새로 나옵니다. 고치지 않고 한 벌로 남는 것은 소스 코드입니다.
인터프리터 언어도 소스 코드를 들고 갑니다. 이쪽은 다시 빌드하지 않습니다. 플랫폼마다 깔린 인터프리터가 그 소스 코드를 읽어 바로 돌립니다. Python 이 이 길입니다.
바이너리 이식성은 이미 빌드한 실행 파일을 다시 빌드하지 않고 다른 컴퓨터에서 돌리는 성질입니다. 이게 되려면 두 컴퓨터의 명령어 집합이 같아야 합니다. 실행 파일이 운영체제·라이브러리와 맞물리는 약속도 같아야 합니다.
그 약속을 ABI(Application Binary Interface, 애플리케이션 바이너리 인터페이스)라고 부릅니다. 실행 파일이 라이브러리 함수나 운영체제를 부를 때 인자를 어떻게 넘기는지, 자료가 메모리에 어떤 모양으로 놓이는지를 정합니다. 소스 코드가 같아도 운영체제·라이브러리와 다른 ABI 로 빌드된 실행 파일은 그 위에서 맞물리지 않습니다.
세 번째 길은 중간 언어를 두는 것입니다. 소스 코드를 특정 CPU 의 기계어가 아니라 바이트코드로 번역합니다. 바이트코드는 실제로 없는 가상의 컴퓨터가 알아듣도록 정한 명령입니다.
플랫폼마다 그 가상의 컴퓨터를 흉내 내는 프로그램이 따로 깔립니다. 이 프로그램을 가상 머신이라고 부릅니다. 바이트코드 파일 한 벌을 들고 가면 가상 머신이 깔린 어느 플랫폼에서든 돕니다.
Java 가 이 길을 택한 대표입니다. 자바 컴파일러가 내놓는 클래스 파일은 JVM(Java Virtual Machine, 자바 가상 머신)이 깔린 곳이면 다시 빌드하지 않고 돕니다. 흔히 「한 번 쓰고 어디서나 돌린다」라고 부르는 성질입니다.
아래 그림은 소스 코드를 들고 가는 길과 바이트코드를 들고 가는 길을 나란히 놓은 것입니다. 무엇을 한 벌만 만들고 무엇을 플랫폼마다 따로 두는지가 다릅니다.
flowchart TD
subgraph "소스 코드를 들고 간다"
S["소스 코드 한 벌"] --> C1["리눅스에서 빌드"] --> M1["리눅스용 기계어"]
S --> C2["윈도우에서 빌드"] --> M2["윈도우용 기계어"]
end
subgraph "바이트코드를 들고 간다"
B["바이트코드 한 벌"] --> V1["리눅스의 가상 머신"]
B --> V2["윈도우의 가상 머신"]
end
위쪽은 플랫폼마다 빌드를 한 번씩 합니다. 결과물도 한 벌씩 생깁니다. 아래쪽은 결과물이 한 벌뿐입니다. 대신 플랫폼마다 가상 머신이 먼저 깔려 있어야 합니다.
차이를 한곳에 모으는 방법
이식성을 얻는 방법은 모양이 여럿이지만 생각은 하나입니다. 플랫폼마다 다른 부분을 한 계층에 모아 가리고, 나머지 코드는 그 계층만 보게 합니다. 그런 계층을 만드는 방법 넷을 차례로 보고 끝에 표로 모읍니다.
차이를 가리고 공통된 모양만 내보이는 계층을 추상화 계층이라고 부릅니다. 이 계층 위의 코드는 계층 아래에서 무엇이 바뀌든 모릅니다. 옮길 때는 계층 아래만 새로 맞춥니다.
첫째 방법은 여러 플랫폼이 함께 지키는 표준에 맞춰 짜는 것입니다. C 언어는 언어 규칙과 표준 라이브러리를 문서로 정해 두었습니다. 그 문서가 정한 것만 쓴 코드는 C 컴파일러가 있는 플랫폼이면 다시 빌드해 돕니다.
POSIX(Portable Operating System Interface, 이식 가능 운영체제 인터페이스)는 같은 일을 운영체제 쪽에서 합니다. 파일, 프로세스, 스레드를 다루는 호출의 이름과 동작을 정해 둡니다. 이 표준에 맞춘 프로그램은 유닉스 계열 운영체제 사이를 소스 코드 수준에서 옮겨 다닙니다.
C 로 짜서 옮기는 이 방법의 대표 사례는 운영체제 자신입니다. 운영체제는 원래 기계마다 어셈블리어로 새로 짜던 물건이었습니다. 어셈블리어는 기계어 명령을 사람이 읽는 낱말로 옮겨 적은 언어라서 명령어 집합에 묶입니다.
유닉스는 운영체제 대부분을 C 로 다시 썼습니다. 새 기계로 옮길 때는 그 기계용 C 컴파일러와 기계에 직접 닿는 적은 코드만 새로 만들면 됐습니다.
둘째 방법은 플랫폼마다 달라야 하는 코드를 한 모듈에 모으는 것입니다. 파일 경로를 다루는 코드를 한 모듈에 모아 두면 옮길 때 그 모듈만 새로 짭니다. 나머지 코드는 그 모듈이 내놓는 함수만 부릅니다.
언어의 표준 라이브러리가 이 모듈을 대신 만들어 주기도 합니다. 아래 줄은 경로 두 조각을 플랫폼에 맞는 구분자로 잇습니다.
os.path.join("logs", "a.log") # logs/a.log
리눅스에서는 위처럼 / 로 잇습니다. 윈도우에서 같은 줄은 logs\a.log 를 냅니다. 코드를 쓰는
쪽은 구분자를 몰라도 됩니다.
C 에서는 빌드할 때 플랫폼에 따라 코드 일부를 넣고 빼는 조건부 컴파일로 이 모듈을 만들기도 합니다. 플랫폼마다 다른 코드가 한 파일 안에서 갈래로 나뉩니다.
셋째 방법은 앞 소절의 가상 머신입니다. 플랫폼 차이를 가상 머신이 전부 떠안습니다. 그 위의 바이트코드는 플랫폼을 모릅니다.
넷째 방법은 프로그램이 기대는 라이브러리와 설정까지 함께 싸서 옮기는 것입니다. 컨테이너 이미지가 이 방식입니다. 이미지 안에 필요한 파일이 다 들어 있어서 옮겨 간 곳에 무엇이 깔려 있는지에 덜 기댑니다.
이 방식이 싸 가지 않는 것이 둘 있습니다. 하나는 운영체제의 핵심부인 커널입니다. 컨테이너는 옮겨 간 곳의 커널을 함께 씁니다. 그래서 리눅스용 이미지는 리눅스 커널 위에서만 돕니다.
다른 하나는 CPU 종류입니다. 이미지 안의 기계어도 한 명령어 집합용입니다. CPU 종류가 다르면 그 종류용 이미지를 따로 만들어야 합니다.
네 방법을 모으면 이렇습니다. 방법마다 플랫폼 차이를 떠안는 쪽이 다릅니다.
| 방법 | 차이를 떠안는 쪽 | 옮길 때 할 일 |
|---|---|---|
| 표준에 맞춰 짜기 | 그 표준을 구현한 컴파일러와 운영체제 | 새 플랫폼에서 다시 빌드 |
| 차이를 한 모듈에 모으기 | 그 모듈 | 그 모듈만 새로 짜기 |
| 가상 머신 위에서 돌리기 | 가상 머신 | 가상 머신만 깔기 |
| 함께 싸서 옮기기 | 이미지 | 커널 종류와 CPU 종류가 이미지와 맞는 곳에 올리기 |
이식성을 깨는 코드
차이를 가리는 계층이 있어도 코드가 그 계층을 건너뛰면 이식성이 깨집니다. 흔한 원인 넷을 차례로 보고, 그중 하나는 C 코드 한 줄로 확인합니다.
첫째는 구현 정의 동작입니다. 언어 규칙이 값을 정하지 않고 컴파일러에게 고르게 맡긴 동작을 말합니다. 컴파일러가 바뀌면 같은 코드가 다른 값을 냅니다.
C 의 long 타입이 대표입니다. long 은 최소 32비트라는 것만 정해져 있습니다. 몇 바이트로 할지는
플랫폼마다 고릅니다.
printf("%zu\n", sizeof(long)); // 4 또는 8
이 줄은 어떤 플랫폼에서는 4를, 어떤 플랫폼에서는 8을 찍습니다. long 을 8바이트로 가정하고 파일에
수를 써 두면 4바이트인 플랫폼에서 그 파일을 잘못 읽습니다.
Java 는 반대쪽을 골랐습니다. int 는 어디서나 32비트, long 은 어디서나 64비트로 언어가 못
박습니다. 크기가 플랫폼을 따라가지 않으니 이런 어긋남이 없습니다.
둘째는 정의되지 않은 동작입니다. 언어 규칙이 결과를 아예 정하지 않은 동작입니다. 배열 끝을 넘어 읽는 것이 그 예입니다. 한 플랫폼에서 우연히 맞게 돌던 코드가 다른 컴파일러에서는 다르게 돕니다.
셋째는 바이트 순서입니다. 여러 바이트짜리 수를 메모리에 어느 끝부터 놓느냐를 엔디언이라고
부릅니다. 수 0x01020304 를 어떤 CPU 는 01 02 03 04 순서로 놓고, 어떤 CPU 는 04 03 02 01
순서로 놓습니다.
메모리의 바이트를 손대지 않고 파일이나 네트워크로 내보내면 문제가 생깁니다. 받는 쪽 CPU 의 순서가 다르면 다른 수로 읽습니다. 그래서 밖으로 내보내는 수는 순서를 한쪽으로 정해 두고 씁니다.
넷째는 한 플랫폼에만 있는 호출을 직접 부르는 것입니다. 리눅스에만 있는 epoll 은 네트워크 연결
여럿을 한 번에 지켜보는 시스템 콜입니다. 이것을 부른 코드는 맥OS 에서 빌드부터 안 됩니다.
이식성의 대가와 쓰는 때
이식성을 높이는 일에는 값이 붙습니다. 이 소절은 무엇을 내주는지 보고, 그 값을 언제 치르고 언제 안 치르는지를 봅니다.
첫째로 한 플랫폼만의 기능을 못 씁니다. 모든 플랫폼이 가진 기능만 쓰면 리눅스의 epoll, 맥OS 의
kqueue 처럼 운영체제마다 따로 만든 입출력 방법을 놓칩니다. 모두가 가진 만큼에 맞춘다고 해서 이런 상태를 최소 공통 분모라고
부릅니다.
둘째로 차이를 가리는 계층을 한 번 더 거칩니다. 가상 머신이 바이트코드를 읽어 돌리는 데는 시간이 듭니다. 계층이 많을수록 코드가 기계에 닿기까지 거치는 단계가 늡니다.
셋째로 확인할 조합이 늡니다. 플랫폼 셋을 지원하면 같은 시험을 세 플랫폼에서 돌려야 합니다. 한 플랫폼에서만 나는 버그도 생깁니다.
그래서 이식성은 필요한 만큼만 들입니다. 리눅스 서버 한 종류에만 올리는 사내 서비스는 다른 운영체제로 옮길 일이 적습니다. 그만큼 이식성에 들일 수고도 적습니다.
반대로 여러 운영체제에 나눠 주는 라이브러리나 개발 도구는 이식성이 곧 쓸 수 있는 사람의 범위입니다. 하드웨어가 여러 번 바뀌는 동안 계속 써야 하는 프로그램도 그렇습니다. 옮길 때마다 새로 짤 수는 없기 때문입니다.
백엔드에서는 개발자의 맥 노트북과 운영 서버의 리눅스가 서로 다른 플랫폼입니다. 로컬에서 되던 것이 서버에서 안 되는 일은 이 차이에서 오기도 합니다.
칩도 다를 수 있습니다. Arm 과 x86 은 명령어 집합이 서로 다른 CPU 계열입니다. Arm 칩을 단 맥에서 빌드한 컨테이너 이미지가 x86 칩을 단 리눅스 서버에서 안 뜨는 일이 그 예입니다. 앞에서 본 「CPU 종류가 다르면 이미지를 따로 만든다」가 이것입니다.
헷갈리는 이웃 낱말
이식성과 자주 같이 나오는 낱말이 둘 있습니다. 셋은 무엇이 움직이느냐로 갈립니다.
| 낱말 | 묻는 것 |
|---|---|
| 이식성 | 한 프로그램을 다른 플랫폼으로 옮겨도 도나 |
| 상호운용성 | 따로 만든 프로그램끼리 함께 일하나 |
| 호환성 | 밑에 깔린 쪽을 새것으로 바꿔 끼워도 그 위의 것이 도나 |
이식성에서는 프로그램이 움직입니다. 상호운용성에서는 둘 이상의 프로그램이 서로 마주 봅니다. 호환성에서는 프로그램은 가만히 있고 그 밑이 바뀝니다.
데이터 이식성과 클라우드 이식성
같은 낱말이 다른 분야에서도 쓰입니다. 뜻의 줄기는 같고 움직이는 것이 다릅니다.
개인정보 쪽에서는 사용자가 한 서비스에 쌓은 자기 데이터를 받아서 다른 서비스로 가져가는 일을 데이터 이식성이라고 부릅니다. 여기서 움직이는 것은 프로그램이 아니라 데이터입니다. 옮기는 사람도 개발자가 아니라 사용자입니다.
클라우드 쪽에서는 한 클라우드 업체에서 돌던 서비스를 다른 업체로 옮기는 일을 이식성이라고 부릅니다. 한 업체만의 기능에 깊이 기대 옮기기 어려워진 상태가 그 반대편입니다. 이 상태를 벤더 종속이라고 부릅니다.
관련 항목
이식성이 가려야 하는 플랫폼 차이
플랫폼 · CPU · ARM · x86 · 명령어 집합 · 기계어 · 운영체제 · 커널 · 시스템 콜 · ABI · 엔디언 · 워드 크기 · 줄바꿈 문자 · 파일 경로
이식성을 얻는 수단
추상화 · 표준 · 표준 라이브러리 · 조건부 컴파일 · 가상 머신 · 바이트코드 · 인터프리터 · 컨테이너 이미지 · 하드웨어 추상화 계층 · 크로스 플랫폼
이식성을 설계 목표로 삼은 언어와 시스템
C · C++ · POSIX · 유닉스 · Java · JVM · Python · WebAssembly
이식성을 깨는 동작과 코드
구현 정의 동작 · 정의되지 않은 동작 · 미지정 동작 · 플랫폼 전용 API · 인라인 어셈블리 · 어셈블리어
플랫폼을 옮길 때 거치는 작업
포팅 · 컴파일러 · 크로스 컴파일 · 빌드 · 링커 · 정적 링크 · 동적 링크
이식성을 높이며 치르는 대가
최소 공통 분모 · 성능 · 오버헤드 · 플랫폼 종속 · 벤더 종속
이식성과 헷갈리는 이웃 성질
다른 이름: portability · 소프트웨어 이식성 · 이식 가능성 · 포터빌리티