CS · Compile · Backend

바이트코드 - 한 번 더 접으면 무엇이 쉬워지나

소스와 기계어 사이에 한 겹을 끼워 넣으면 만들어야 할 번역기의 수가 곱셈에서 덧셈으로 바뀐다. '한 번 짜서 어디서나 돌린다'가 실제로 옮긴 것이 무엇인지, 그리고 그 대가로 무엇을 내주는지를 본다.

·돌아가기 전에 벌어지는 일 3편
목차
  1. 언어 셋과 기계 셋이 만나면 아홉 벌이 된다
  2. 가운데에 한 겹을 두면
  3. 중간 요약본은 무엇으로 되어 있나
  4. 그 요약본을 읽는 기계는 소프트웨어다
  5. ”한 번 짜서 어디서나”가 실제로 옮긴 것
  6. 여전히 진짜 기계어가 필요하다
  7. 자바만의 이야기가 아니다
  8. 실무에서: 바이트코드가 보이는 자리
  9. 정리

앞 글에서 실제 언어들은 옮기는 일을 여러 단계로 쪼갠다고 했다. 그 쪼갠 자리에 무엇이 놓이는지 본다.

언어 셋과 기계 셋이 만나면 아홉 벌이 된다

번역 사무실에 원고가 세 나라 말로 들어오고, 읽어야 할 직원도 세 나라 사람이라고 하자. 원고를 직원마다 직접 옮기려면 번역기를 몇 벌 만들어야 할까. 아홉 벌이다.

여기서 나쁜 건 아홉이라는 숫자 자체가 아니라 늘어나는 방식이다. 새 언어가 하나 들어오면 세 벌을 더 만들어야 하고, 새 CPU 계열이 하나 나오면 또 세 벌이 더 필요하다. 곱셈으로 늘어나는 일은 금방 감당이 안 된다.

실제로도 그랬다. 언어는 계속 새로 나오고, CPU 계열도 하나로 수렴하지 않았다.

가운데에 한 겹을 두면

해법은 사무실에서라면 이렇게 생겼다. 모두가 아는 중간 요약본을 하나 정해 두는 것.

원고는 어느 나라 말이든 일단 이 요약본으로 옮긴다. 직원은 자기 말로 읽고 싶으면 요약본만 보면 된다. 그러면 만들 것이 확 줄어든다.

언어마다 기계마다 따로 만들면 - 잇는 길이 아홉 갈래

C
자바
파이썬
x86-64
arm64
RISC-V

언어를 하나 더 얹으면 새로 만들 것이 셋 늘어난다

가운데에 한 겹을 두면 - 잇는 길이 여섯 갈래

C
자바
파이썬
중간 표현
x86-64
arm64
RISC-V

언어를 하나 더 얹어도 새로 만들 것은 하나다

가운데 한 겹이 모든 선이 지나는 자리가 된다. 언어와 기계가 늘수록 아끼는 양이 커진다.

곱셈이 덧셈으로 바뀐 것이다. 새 언어를 얹으려면 요약본까지만 옮기면 되고, 새 CPU 계열이 나오면 요약본을 읽는 쪽만 하나 만들면 된다. 나머지는 그대로 얹혀 간다.

이 중간 요약본이 바이트코드(bytecode) 다.

중간 요약본은 무엇으로 되어 있나

바이트코드는 사람이 읽으라고 만든 게 아니다. 소스처럼 생기지 않았고, 그렇다고 어떤 실제 CPU의 기계어도 아니다.

가장 이해하기 쉬운 설명은 이것이다. 바이트코드는 세상에 없는 가상의 기계를 위한 기계어다. 명령 하나하나가 아주 작고, 이름표가 아니라 수로 적혀 있고, 순서대로 늘어서 있다. 기계어 글에서 본 진짜 기계어와 성격이 똑같다. 다른 점은 이 명령들을 알아듣는 회로가 세상에 없다는 것뿐이다.

그래서 바이트코드는 어느 CPU에도 치우쳐 있지 않다. 레지스터가 몇 개인지, 메모리 접근 규칙이 어떤지 같은 특정 기계의 사정이 안 들어 있다. 그 덕에 어느 기계 쪽으로든 다시 옮기기 쉽다.

그 요약본을 읽는 기계는 소프트웨어다

알아듣는 회로가 없다면 누가 실행하나. 회로 대신 프로그램을 만든다.

바이트코드를 한 명령씩 읽어 그대로 수행해 주는 프로그램이 있고, 이걸 가상 머신이라 부른다. 자바의 JVM이 대표다. 하는 일은 앞 글에서 본 인터프리터와 똑같다. 다만 읽는 대상이 소스가 아니라 이미 한 번 접힌 바이트코드라서 훨씬 빠르게 읽어 낸다.

여기서 자바의 흐름이 정리된다. .java 소스를 javac가 .class 바이트코드로 옮겨 두고, 실행할 때 JVM이 그 바이트코드를 읽는다. 미리 옮기는 단계와 그때그때 읽는 단계가 둘 다 있는 것이다. 앞 글에서 언어들이 눈금 위 어딘가에 서 있다고 한 게 이 얘기다.

”한 번 짜서 어디서나”가 실제로 옮긴 것

자바를 소개할 때 늘 나오는 문구가 있다. 한 번 짜면 어디서나 돈다는 말이다. 이 문장이 정확히 무엇을 뜻하는지 이제 말할 수 있다.

옮겨 다니는 것은 바이트코드이고, 기계마다 따로 만들어 두는 것은 가상 머신이다. 내 .class 파일은 리눅스 x86 서버에서도, 맥의 arm 노트북에서도 똑같은 파일 그대로 돈다. 대신 그 두 곳에는 각자의 CPU와 운영체제에 맞게 만들어진 JVM이 미리 깔려 있어야 한다.

바꿔 말하면 “어디서나 돈다”는 마법이 아니라, 기계마다 다른 부분을 누군가 미리 만들어 두고 그 위에 얹혀 가는 것이다. 실행 파일이 CPU와 운영체제를 정해 놓고 만들어진 물건이었다면, 바이트코드는 그 결정을 실행하는 쪽으로 미뤄 둔 물건이다.

여전히 진짜 기계어가 필요하다

한 가지 오해를 걷어내자. 중간 층을 뒀다고 기계어가 없어진 것이 아니다.

가상 머신 자체가 그 기계의 기계어로 만들어진 실행 파일이다. 리눅스 x86용 JVM은 리눅스 x86용 실행 파일이고, 파이썬 해석기도 마찬가지다. 그것을 실행하면 다른 프로그램과 똑같이 프로세스 하나가 된다. 기계어를 마주하는 자리를 프로그램 하나로 몰아넣었을 뿐, 없앤 게 아니다.

이게 중간 층의 정직한 값이다. 우리가 매번 하던 일을 누군가 한 번만 해 두고, 우리는 그 결과에 올라탄다. 그 결과 우리 앞에는 한 벌의 산출물만 남는다.

자바만의 이야기가 아니다

바이트코드는 특정 언어의 장치가 아니라 널리 쓰이는 수법이다.

  • 파이썬 - 소스를 실행하면 먼저 바이트코드로 옮기고 그걸 해석한다. __pycache__ 안의 .pyc가 그 결과물이다. 같은 파일을 두 번째 실행할 때 조금 빠른 게 이 캐시 덕이다.
  • C# - 소스를 IL이라는 중간 형태로 옮겨 두고, 실행할 때 그 위에서 다시 옮긴다. 뼈대가 자바와 같다.
  • 웹어셈블리 - 브라우저에서 돌리려고 만든 중간 형태다. C나 Rust로 짠 것을 여기로 옮겨 두면 브라우저가 실행한다. 여기서도 옮겨 다니는 건 중간 형태고, 기계마다 다른 부분은 브라우저가 맡는다.

셋의 사정은 다르지만 구조는 하나다. 곱셈을 덧셈으로 바꾸려고 가운데에 한 겹을 뒀다.

실무에서: 바이트코드가 보이는 자리

바이트코드를 손으로 읽을 일은 없다. 그래도 이 층이 얼굴을 내미는 자리가 있다.

  • 버전이 안 맞을 때 - 바이트코드 파일에는 “몇 번째 판 규격으로 적혔는지”가 적혀 있다. 새 규격으로 만든 것을 옛 가상 머신에 주면 실행이 거부된다. 자바에서 자주 보는 클래스 버전 에러가 이것이다. 소스는 멀쩡한데 옮겨 둔 결과물이 안 맞는 것이라, 소스를 아무리 봐도 원인이 안 보인다.
  • 캐시가 남을 때 - 파이썬의 .pyc처럼 중간 결과물이 디스크에 남는다. 소스와 어긋난 캐시가 남아 이상하게 도는 일이 드물게 있고, 그럴 때 캐시를 지우면 풀린다. 뒤에서 볼 증분 빌드와 같은 종류의 함정이다.
  • 코드를 감추고 싶을 때 - 바이트코드는 기계어보다 원래 구조가 많이 남아 있어서, 도구를 쓰면 소스에 가깝게 되돌릴 수 있다. 그래서 배포본에 난독화를 얹는 관행이 생겼다. 중간 층이 옮겨 다니기 쉽다는 성질은, 남이 들여다보기도 쉽다는 뜻이기도 하다.

세 가지 다 “바이트코드를 다룰 줄 알아야” 하는 일은 아니다. 소스와 실행 사이에 파일이 하나 더 끼어 있다는 걸 알면 설명이 되는 일들이다.

정리

  • 언어와 기계를 직접 이으면 만들 것이 곱셈으로 늘어난다. 가운데 한 겹을 두면 덧셈으로 바뀐다.
  • 그 한 겹이 바이트코드다. 세상에 없는 가상 기계를 위한 기계어라, 어느 CPU에도 치우쳐 있지 않다.
  • 알아듣는 회로가 없으니 프로그램이 대신 읽는다. 그것이 가상 머신이다.
  • “한 번 짜서 어디서나”가 옮긴 것은 바이트코드이고, 기계마다 다른 부분은 미리 깔아 둔 가상 머신이 떠안는다.
  • 기계어는 사라지지 않았다. 마주하는 자리를 프로그램 하나로 몰아넣은 것뿐이다.

다음 글은 이 구조에 한 걸음을 더한다. 실행해 보기 전에는 알 수 없던 것을 돌려 보고 나서 알게 됐을 때, 그 앎으로 한 번 더 옮기는 이야기다.