CS · Compile · Backend

링크 - 흩어진 조각을 하나로 묶는 일

프로그램은 한 파일로 만들어지지 않는다. 여러 조각이 따로 옮겨지고, 조각마다 '이 이름은 다른 데 있다'는 빈칸이 남는다. 그 빈칸을 채우는 일이 링크이고, '그 라이브러리를 못 찾겠다'는 에러가 여기서 나온다.

·돌아가기 전에 벌어지는 일 5편
목차
  1. 나눠 번역한 원고를 한 권으로 묶는다
  2. 조각마다 빈칸이 남는다
  3. 링커가 하는 일은 빈칸 채우기다
  4. 안에 넣을 것인가, 밖에서 찾을 것인가
  5. ”그 라이브러리를 못 찾겠다”의 정체
  6. 같은 이름이 둘이면
  7. 자바와 파이썬에도 같은 일이 있다
  8. 실무에서: 어느 쪽을 고르나
  9. 정리

지금까지는 원고 하나를 옮기는 이야기였다. 실제 프로젝트는 원고가 한 권이 아니다.

나눠 번역한 원고를 한 권으로 묶는다

300쪽짜리 원고를 혼자 번역하면 오래 걸린다. 그래서 사무실은 원고를 열 뭉치로 나눠 열 사람에게 맡긴다.

여기서 새로운 일이 하나 생긴다. 다 끝난 뒤에 열 뭉치를 한 권으로 묶어야 한다. 그냥 순서대로 쌓기만 하면 되는 게 아니다. 3번 뭉치에 “자세한 건 계약 조항 편을 보라”고 적혀 있으면, 그 편이 묶인 뒤 몇 쪽이 됐는지 찾아 정확한 쪽수를 적어 넣어야 한다.

이 묶는 일이 링크(link) 이고, 그 일을 하는 도구가 링커(linker) 다.

조각마다 빈칸이 남는다

코드에서 이 “다른 편을 보라”에 해당하는 것이 이름이다. 함수 이름, 전역 변수 이름 같은 것들이고, 이런 이름들을 통틀어 심볼(symbol) 이라 부른다.

한 파일을 옮길 때, 옮기는 쪽은 그 파일만 본다. 그래서 심볼은 두 가지 얼굴로 나온다.

  • 여기 있다 - 이 파일 안에서 정의된 것. 자리를 알고 있다.
  • 어딘가에 있다 - 이 파일에서 부르기만 하는 것. 자리를 모른다.

뒤엣것이 빈칸이다. 옮긴 결과물에는 “여기에 sendMail을 부르는 명령이 있는데, 그게 어느 자리인지는 나중에 채워 넣어라”는 표시가 남는다. 기계어 글에서 본 대로 CPU 명령은 결국 수의 나열이라, 함수를 부르는 명령도 결국 주소 하나를 품어야 한다. 그 주소를 아직 모르는 것이다.

링커가 하는 일은 빈칸 채우기다

링커는 조각들을 모아 놓고 두 가지를 한다. 먼저 어느 조각의 어느 이름이 어디에 놓일지 자리를 정한다. 그다음 조각들에 남은 빈칸을 돌며 그 이름의 실제 자리를 적어 넣는다.

빈칸에 맞는 이름을 어느 조각에서도 못 찾으면 거기서 멈춘다. 이게 흔히 보는 “정의되지 않은 참조”류의 에러다. 문법도 맞고 타입도 맞는데 부르는 대상이 세상에 없는 것이라, 소스만 들여다봐서는 원인이 안 보인다.

참고

여기서 비유가 어긋난다. 책을 묶을 때는 “계약 조항 편”이라고만 적혀 있어도 사람이 알아서 찾는다. 링커는 못 그런다. 이름이 한 글자라도 다르면 다른 것이고, 대소문자도 구분한다. 게다가 언어에 따라 우리가 쓴 이름이 그대로 심볼이 되지도 않는다. 같은 이름의 함수를 인자만 다르게 여럿 만들 수 있는 언어는, 그것들을 구별하려고 인자 정보까지 이름에 섞어 만들어 둔다. 에러 메시지에 나오는 심볼이 우리가 쓴 이름과 안 닮아 보이는 게 그래서다.

안에 넣을 것인가, 밖에서 찾을 것인가

여기까지는 내가 쓴 조각들 얘기였다. 남의 라이브러리도 같은 방식으로 붙는데, 여기서 갈림길이 하나 나온다.

정적 링크 - 라이브러리를 실행 파일 안에 넣어 둔다

실행 파일 하나
내 코드내가 쓴 것
라이브러리사본이 안에 들어와 있다

실행할 때
밖을 안 본다

시스템에 깔린 것
봐도 그만, 안 봐도 그만

동적 링크 - 이름만 적어 두고 실행할 때 밖에서 찾는다

실행 파일 하나
내 코드내가 쓴 것
libssl.so
시스템에 깔린 것
라이브러리파일 하나로 따로 있다

달라지는 건 하나다 - 라이브러리가 안에 있느냐 밖에 있느냐. 밖에 있으면 파일은 작아지고, 못 찾는 사고는 빌드가 아니라 실행할 때 난다.

정적 링크는 라이브러리의 필요한 부분을 실행 파일 안에 복사해 넣는다. 완성된 한 권이라 실행할 때 밖을 안 본다. 대신 파일이 커지고, 같은 라이브러리를 쓰는 프로그램이 열 개면 사본도 열 개다.

동적 링크는 빈칸에 “이 이름의 라이브러리를 찾아라”만 적어 두고 끝낸다. 실행 파일은 작다. 대신 실행하는 순간에 그 라이브러리를 찾아 빈칸을 채우는 일이 벌어진다. 여러 프로그램이 같은 라이브러리 파일 하나를 함께 쓴다.

가장 중요한 차이는 크기가 아니다. 사고가 나는 시점이다. 정적 링크는 못 찾으면 빌드가 실패한다. 동적 링크는 빌드가 멀쩡히 끝난 다음, 실행하는 순간에 실패한다.

”그 라이브러리를 못 찾겠다”의 정체

이제 그 에러가 읽힌다. 내 컴퓨터에서는 잘 만들어진 실행 파일을 서버에 올렸는데 뜨자마자 죽고, 로그에는 어떤 파일을 못 찾겠다고 적혀 있다.

빌드할 때는 이름만 적어 두면 됐으니 아무 문제가 없었다. 실행하는 쪽에서 그 이름에 해당하는 파일을 찾아야 하는데 그 서버에는 없거나, 있는데 판이 다른 것이다. 이 라이브러리 파일은 리눅스에서 .so, 윈도우에서 .dll, macOS에서 .dylib라는 꼬리를 달고 있다.

찾는 자리도 아무 데나가 아니다. 실행 파일 안에 적어 둔 경로, 환경변수로 알려 준 경로, 시스템이 정해 둔 기본 경로를 정해진 순서로 훑는다. 그래서 같은 파일이 서버에 있는데도 못 찾는 일이 생긴다. 경로 밖에 있으면 없는 것과 같다.

같은 뿌리에서 나오는 흔한 사고가 하나 더 있다. 아주 가벼운 리눅스 이미지로 갈아탔더니 멀쩡하던 바이너리가 안 도는 경우다. 그 이미지들은 기본 C 라이브러리부터 다른 것을 쓰는데, 내 바이너리는 원래 쓰던 쪽 이름으로 빈칸을 적어 뒀기 때문이다. 내 컴퓨터에선 되는데의 전형적인 얼굴 중 하나다.

같은 이름이 둘이면

빈칸 채우기가 이름으로 이뤄지므로, 같은 이름이 둘 이상 있으면 또 다른 문제가 생긴다.

내 프로그램이 A 라이브러리와 B 라이브러리를 쓰는데, 둘이 각각 같은 라이브러리의 다른 판을 원한다고 하자. 시스템에는 그 라이브러리 파일이 하나뿐이다. 어느 판을 두든 한쪽은 자기가 기대한 것과 다른 것을 받는다. 이름은 맞는데 내용이 달라서, 부르는 방법이 바뀌었다면 그 자리에서 터진다.

이런 일이 자주 벌어지니 도구들은 저마다 회피책을 만들었다. 판마다 다른 이름을 달아 여러 판을 같이 두게 하거나, 아예 프로젝트마다 자기 사본을 따로 들고 있게 하는 식이다. 의존성 관리 도구들이 하는 일의 상당 부분이 이 이름 충돌을 미리 막는 것이다.

자바와 파이썬에도 같은 일이 있다

이름을 실제 물건에 잇는 일은 기계어를 다루는 언어만의 사정이 아니다. 이름이 있는 곳이면 어디서나 같은 구조가 나온다.

  • 자바 - 클래스 이름을 실제 .class 파일에 잇는 일이 실행 중에 일어난다. 그 파일을 어디서 찾을지 알려 주는 목록이 클래스패스다. 목록에 없으면 클래스를 못 찾겠다는 에러가 실행 중에 난다. 컴파일은 통과했는데 실행에서 터지는 그 구조 그대로다.
  • 파이썬 - import는 이름을 실제 모듈 파일에 잇는 일이고, 찾는 경로 목록이 정해져 있다. 모듈을 못 찾겠다는 에러가 나면 파일이 없는 게 아니라 찾는 자리에 없는 것인 경우가 많다.
  • 자바스크립트 - 번들러가 하는 일 중 하나가 흩어진 모듈을 한 파일로 묶는 것이다. 이름으로 서로를 부르던 조각들을 실제로 잇고 한 권으로 만든다는 점에서 링커와 하는 일이 같다.

셋 다 도구도 다르고 시점도 다르지만 질문은 하나다. 이 이름이 가리키는 실물이 어디에 있나.

실무에서: 어느 쪽을 고르나

정적이냐 동적이냐를 우리가 직접 고르는 일은 언어마다 다르다. 그래도 이 선택이 무엇을 바꾸는지는 알아 둘 값이 있다.

  • 컨테이너를 얇게 만들 때 - 정적으로 묶인 바이너리는 딸려 갈 것이 거의 없어서 이미지를 아주 작게 만들 수 있다. Go로 만든 서비스가 유난히 얇은 이미지로 배포되는 이유다. 반대로 동적으로 묶인 바이너리는 필요한 라이브러리가 이미지 안에 함께 있어야 하고, 그게 빠지면 앞에서 본 에러가 실행 시점에 난다.
  • 보안 패치를 돌릴 때 - 여기서는 방향이 반대다. 동적 링크는 라이브러리 파일 하나만 갈아 끼우면 그걸 쓰는 프로그램 전부가 고쳐진다. 정적으로 묶었다면 그 라이브러리를 품은 모든 프로그램을 다시 빌드해 배포해야 한다. 널리 알려진 취약점이 터졌을 때 대응 속도가 이 지점에서 갈린다.
  • 원인을 좁힐 때 - 판단 기준은 단순하다. 빌드에서 터지면 이름을 못 찾은 것이고, 실행에서 터지면 실물을 못 찾은 것이다. 앞엣것은 코드와 빌드 설정을 보고, 뒤엣것은 실행하는 환경을 본다. 이 둘을 섞으면 엉뚱한 데를 파게 된다.

정리

  • 프로그램은 여러 조각으로 나뉘어 따로 옮겨진다. 조각마다 아직 자리를 모르는 이름이 빈칸으로 남는다.
  • 링커는 조각들의 자리를 정하고 그 빈칸을 실제 주소로 채운다. 맞는 이름을 못 찾으면 거기서 멈춘다.
  • 정적 링크는 라이브러리를 안에 넣는다. 파일은 커지고, 못 찾는 사고는 빌드에서 난다.
  • 동적 링크는 이름만 적어 둔다. 파일은 작고, 못 찾는 사고는 실행하는 순간에 난다.
  • 이름을 실물에 잇는 일은 어디에나 있다. 클래스패스도, import도, 번들러도 같은 일을 한다.

다음 글은 지금까지 본 단계들을 한 줄에 담는다. 우리가 매일 치는 그 빌드 명령이 실제로 무엇을 하는지, 그리고 왜 그렇게 오래 걸리는지가 마지막 주제다.