마지막 편에서는 ‘미드레이어의 실수’라는 패턴을 통해 서브시스템을 설계할 때 미드레이어 대신 라이브러리 방식이 왜 유리한지 블록 계층, VFS, MD/RAID를 예로 들어 살펴본다.
LWN.net에 도움이 필요합니다! 구독자가 없다면 LWN은 존재할 수 없습니다. 구독 신청을 고려해 LWN의 발행이 계속될 수 있도록 도와주세요.
이 마지막 글에서는 단 하나의 설계 패턴만 살펴보려 한다. 우리는 시작 글에서 참조 카운팅의 세부를 다뤘고, 두 번째 글에서는 전체 자료구조를 바라보는 관점으로 확대했으며, 이제는 서브시스템을 설계하는 더 큰 관점으로 이동한다. 모든 패턴이 그러하듯, 이 패턴에도 이름이 필요하고, 우리가 붙인 작업 제목은 “미드레이어의 실수(midlayer mistake)”다. 이름만 보면 피해야 할 것을 묘사하는 안티-패턴처럼 들린다. 그것도 맞지만, 동시에 이 패턴은 매우 강한 규범적 지침을 가진 ‘패턴’이기도 하다. “미드레이어”가 보이기 시작하면, 여러분은 이 패턴의 적용 대상 영역에 들어온 것이며, 이 패턴이 적용되는지 확인하고 더 나은 방향으로 안내받을 때가 된 것이다.
리눅스 세계에서 “미드레이어”라는 용어는 (필자의 머릿속에서도, 그리고 구글 캐시에서도) 가장 강하게 SCSI와 연결된다. “SCSI 미드레이어”는 꽤 오래전에 한동안 좋지 않은 시기를 겪었고, 관련 메일링 리스트에서는 왜 그것이 필요한 일을 하지 못했는지를 두고 많은 논쟁이 있었다. 그 논의를 지켜보는 과정에서 이 패턴이 천천히 형태를 갖추게 된 씨앗이 생겼다.
“미드레이어”라는 말은 분명 “상위 레이어(top layer)”와 “하위 레이어(bottom layer)”의 존재를 내포한다. 여기서 “상위” 레이어는 서로 관련된 많은 서브시스템에 적용되는 코드 묶음이다. 예를 들어 모든 시스템 콜을 지원하는 POSIX 시스템 콜 레이어, 모든 블록 장치를 지원하는 블록 레이어, 모든 파일시스템을 지원하는 VFS 등이 있다. “SCSI 미드레이어” 예시에서 블록 레이어가 상위 레이어가 된다. 반대로 “하위” 레이어는 특정 서비스의 구체적 구현이다. 특정 시스템 콜, 특정 하드웨어를 위한 드라이버, 특정 파일시스템 등이 이에 해당한다. SCSI 미드레이어의 하위 레이어를 채우는 것은 각기 다른 SCSI 컨트롤러의 드라이버들이다. 예시 목록을 잠깐만 되새겨보면, 어떤 코드가 어떤 위치에 놓이는지는 관점의 문제라는 것을 알 수 있다. VFS 관점에서 특정 파일시스템은 하위 레이어의 일부다. 하지만 블록 장치 관점에서는 같은 파일시스템이 상위 레이어의 일부가 된다.
미드레이어는 상위와 하위 레이어 사이에 놓인다. 상위 레이어로부터 요청을 받아, 하위 레이어 구현들에 공통인 처리를 수행한 다음, (아마도 더 단순해지고 도메인 특화된) 전처리된 요청을 관련 드라이버로 내려보낸다. 이는 구현의 통일성, 코드 공유를 제공하며, 하위 레이어 드라이버 구현 작업을 크게 단순화한다.
“미드레이어의 실수”의 핵심 주장은 미드레이어는 나쁘며 존재해서는 안 된다는 것이다. 미드레이어에 넣고 싶어지는 그 공통 기능은 대신 라이브러리 루틴으로 제공되어야 하며, 각 하위 레벨 드라이버가 독립적으로 이를 사용하거나, 확장하거나, 무시할 수 있어야 한다. 즉 여러 구현(또는 여러 드라이버)을 지원하는 모든 서브시스템은 하위 레이어 드라이버를 직접 호출하는 매우 얇은 상위 레이어와, 드라이버 구현을 쉽게 해주는 풍부한 지원 코드 라이브러리를 제공해야 한다. 이 라이브러리는 드라이버가 사용할 수 있지만, 강제되어서는 안 된다.
이 패턴을 더 잘 비추기 위해, 세 가지 서로 다른 서브시스템을 살펴보며 이 패턴이 각각에 어떻게 적용되는지 살펴보겠다. 블록 레이어, VFS, 그리고 ‘md’ RAID 레이어(즉 필자가 가장 익숙한 영역)다.
블록 레이어가 하는 일의 대부분은 블록 장치에 대한 ‘read’/‘write’ 요청을 받아 적절한 하위 레벨 장치 드라이버로 보내는 것이다. 충분히 단순해 보인다. 흥미로운 점은, 블록 장치는 회전 매체를 포함하는 경우가 많고, 회전 매체는 주소 공간상에서 연속된 요청들이 서로 가까이 모여 있을 때 이점을 얻는다는 것이다. 이렇게 하면 탐색(seek) 시간이 줄어든다. 비회전 매체도 인접한 주소에 대한 요청이 시간적으로도 인접해 있어 합쳐질 수 있다면, 더 적은 수의 큰 요청으로 결합될 수 있어 이점이 있다. 그래서 많은 블록 장치는 모든 요청이 엘리베이터 알고리즘을 통과하며 주소 기준으로 정렬되어 장치를 더 잘 활용할 수 있을 때 이득을 본다.
이 엘리베이터 알고리즘을 ‘미드레이어’, 즉 상위 레이어 바로 아래의 레이어로 구현하고 싶은 유혹이 크다. 실제로 리눅스는 2.2 커널과 그 이전 시절에 그렇게 했다. 요청은 ll_rw_block()(상위 레이어)로 들어왔고, 여기서 기본적인 타당성 검사와 구조체의 내부용 필드를 초기화한 뒤, 엘리베이터의 심장부인 make_request()로 요청을 넘겼다. 하지만 모든 요청이 make_request()로 간 것은 아니었다. “md” 장치에 대해서는 특별 예외가 있었고, 그 요청은 md_make_request()로 전달되어 RAID 장치에 맞는 완전히 다른 일을 했다.
여기서 미드레이어를 싫어해야 하는 첫 번째 이유가 보인다. 미드레이어는 특수 케이스를 부추긴다. 미드레이어를 작성할 때는 하위 레벨 드라이버가 가질 수 있는 모든 필요를 미리 예측할 수 없으므로, 미드레이어에서 모두를 허용하는 것도 불가능하다. 새로운 요구가 생길 때마다 미드레이어를 재설계할 수도 있겠지만, 그건 시간을 효과적으로 쓰는 방식이기 어렵다. 대신 특수 케이스가 자라나기 쉽다.
오늘날의 블록 레이어는 엘리베이터가 매우 중심적이라는 점에서 당시와 여러모로 비슷하다. 물론 세부는 많이 바뀌었고 IO 요청 스케줄링도 훨씬 정교해졌다. 하지만 여전히 강한 가족 유사성이 있다. (우리 목적상) 중요한 차이 하나는 blk_queue_make_request() 함수의 존재다. 모든 블록 장치 드라이버는 이를 호출해야 하며, 직접 호출하거나 blk_init_queue()를 통해 간접 호출한다. 이 함수는 2.2의 make_request()나 md_make_request()와 비슷한, 각 IO 요청을 처리할 함수 포인터를 등록한다.
이 작은 추가 하나가 엘리베이터를 ‘모든 장치에 강제되는 미드레이어’에서 ‘장치가 호출할 수 있는 라이브러리 함수’로 효과적으로 바꿔놓았다. 이는 올바른 방향으로의 중요한 한 걸음이었다. 이제 드라이버가 엘리베이터를 쓰지 않기로 선택하는 것이 쉬워졌다. 모든 가상 드라이버(md, dm, loop, drbd 등)는 그렇게 하고, 물리 하드웨어 드라이버 중에도 (예: umem) 자체 make_request_fn()을 제공하는 것들이 있다.
엘리베이터는 미드레이어라는 지위에서 확실히 벗어났지만, 여러 면에서 여전히 미드레이어의 외형을 남기고 있다. 한 예가 struct request_queue 구조체(<linux/blkdev.h>에 정의)다. 이 구조체는 사실 블록 레이어의 일부다. 이미 언급한 make_request_fn() 함수 포인터처럼 블록 인터페이스의 근본 요소들을 담고 있다. 하지만 다른 많은 필드는 엘리베이터 코드에 특화되어 있다. 예를 들어 elevator(여러 IO 스케줄러 중 선택)나 last_merge(현재 큐에서의 조회를 빠르게 하기 위한 것)가 그렇다. 엘리베이터는 struct request_queue 안에 필드를 둘 수 있지만, 다른 코드들은 부차적인 데이터 구조를 저장하기 위해 queuedata 포인터를 사용해야 한다.
이 배치는 미드레이어를 알려주는 또 다른 징후다. 어떤 1차 자료구조가 종속된(부차) 자료구조를 가리키는 포인터를 담고 있다면, 아마도 우리는 그 1차 자료구조를 관리하는 미드레이어를 보고 있는 것이다. 더 나은 배치는 이 연재의 이전 글에서 다룬 “내장 앵커(embedded anchor)” 패턴을 사용하는 것이다. 하위 레벨 드라이버는 자체 자료구조를 할당하고, 그 안에 라이브러리들이 사용하는 자료구조(들)를 임베드해야 한다. struct inode가 이 접근의 좋은 예인데, 세부는 조금 다르다. 2.2에서 struct inode는 각 파일시스템별 자료구조를 위한 union과, 다른 파일시스템이 쓰도록 둔 포인터(generic_ip)를 포함했다. 2.6 커널에서는 보통 struct inode가 파일시스템 특화 inode 구조체 안에 임베드된다(다만 i_private 포인터는 여전히 남아 있는데 불필요해 보인다).
미드레이어의 마지막 대표적 징후 하나는, 서로 무관한 코드를 묶어 호출하는 경향이다. 라이브러리 설계는 보통 별개의 기능을 별개의 함수로 제공하고, 하위 레벨 드라이버가 필요한 것을 골라 호출하게 한다. 반면 미드레이어는 ‘필요할지도 모르는 모든 것’을 그냥 호출해버린다.
엘리베이터의 2.6 진입점인 __make_request()를 보면, 초기에 blk_queue_bounce()를 호출한다. 이는 DMA로 시스템 메모리와 장치 사이에 데이터를 옮길 때 주소 공간 전체에 접근할 수 없는 하드웨어를 지원한다. 이런 경우 데이터를 장치로 전송하기 전에 더 접근하기 쉬운 메모리로 복사하거나, 장치에서 전송된 뒤 그 메모리에서 다시 복사해야 한다. 이 기능은 엘리베이터와 꽤 독립적이지만, 엘리베이터 사용자 모두에게 강제되고 있다.
따라서 블록 레이어와 엘리베이터의 관계는, 과거에는 미드레이어로 구현되었으나 엘리베이터를 명확히 선택 사항으로 만들면서 미드레이어에서 벗어나는 긍정적 ნაბიჯ을 밟은 서브시스템의 사례다. 다만 여전히 역사적 흔적이 남아 있으며, 이는 미드레이어의 핵심 식별자들을 소개하는 데 유용했다. 즉 하위 레이어에 강제되는 코드, 그 코드의 특수 케이스, 종속 자료구조에 대한 포인터를 저장하는 자료구조, 그리고 단일 지원 함수가 서로 무관한 코드를 호출하는 경향이다.
이 그림을 염두에 두고 다음으로 넘어가자.
VFS(가상 파일 시스템)는 미드레이어와 그 대안을 배우기 위한 매우 풍부한 영역이다. 파일시스템의 다양성이 크고, 사용할 수 있는 유용한 서비스가 많으며, 이를 효과적이고 효율적으로 함께 동작시키기 위한 작업이 많이 이루어졌기 때문이다. VFS의 상위 레이어는 주로 VFS의 엔트리 포인트를 제공하는 vfs_ 함수 호출들로 구성된다. 이들은 시스템 콜을 구현하는 다양한 sys_ 함수들, 시스템 콜을 사용하지 않고도 많은 파일시스템 접근을 수행하는 nfsd, 그리고 파일을 다뤄야 하는 커널의 다른 일부로부터 호출된다.
vfs_ 함수들은 비교적 빠르게, 여러 _operations 구조체 중 하나(함수 포인터 목록을 담음)를 통해 해당 파일시스템을 직접 호출한다. 조작 대상이 무엇이냐에 따라 inode_operations, file_operations, super_operations 등이 있다. 이것이 바로 “미드레이어의 실수” 패턴이 옹호하는 모델이다. 얇은 상위 레이어가 하위 레이어를 직접 호출하고, 하위 레이어는(곧 보겠지만) 작업을 수행하기 위해 라이브러리 함수들을 적극 활용한다.
여기서는 파일시스템에 제공되는 두 가지 서비스 묶음을 살펴보고 대비해보겠다. 페이지 캐시와 디렉터리 엔트리 캐시다.
파일시스템은 일반적으로 미리 읽기(read-ahead)와 지연 쓰기(write-behind)를 활용하고 싶어한다. 가능하다면 데이터는 필요해지기 전에 저장장치에서 읽어와, 필요할 때 이미 준비되어 있게 해야 하며, 한 번 읽은 데이터는 다시 필요해질 가능성이 꽤 높기 때문에 계속 유지하는 것이 좋다. 마찬가지로 쓰기를 약간 지연하면 장치로의 처리량을 평탄화할 수 있고, 애플리케이션이 쓰기 완료를 기다리지 않아도 되므로 이점이 있다. 이 두 기능은 주로 mm/filemap.c와 mm/page-writeback.c에 구현된 페이지 캐시가 제공한다.
가장 단순한 형태에서 파일시스템은 address_space라는 객체를 페이지 캐시에 제공하고, 그 안의 address_space_operations에 단일 페이지를 읽고 쓰는 루틴을 제공한다. 그러면 페이지 캐시는 VFS 상위 레이어에 제공되어야 하는 ‘파일’ 추상화를 제공하기 위해 file_operations로 사용할 수 있는 연산들을 제공한다. ext3의 일반 파일에 대한 file_operations를 보면 다음과 같다:
cconst struct file_operations ext3_file_operations = { .llseek = generic_file_llseek, .read = do_sync_read, .write = do_sync_write, .aio_read = generic_file_aio_read, .aio_write = ext3_file_write, .unlocked_ioctl = ext3_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = ext3_compat_ioctl, #endif .mmap = generic_file_mmap, .open = generic_file_open, .release = ext3_release_file, .fsync = ext3_sync_file, .splice_read = generic_file_splice_read, .splice_write = generic_file_splice_write, };
13개 연산 중 8개가 페이지 캐시가 제공하는 제네릭 함수다. 나머지 5개 중 ioctl() 2개와 release()는 파일시스템 특화 구현이 필요하고, ext3_file_write()와 ext3_sync_file은 페이지 캐시가 제공하는 제네릭 함수에 대한 중간 규모 래퍼다. 이것은 우리 패턴에 따른 좋은 서브시스템 설계의 정수다. 페이지 캐시는 잘 정의된 라이브러리로서, (ext3 파일 읽기처럼) 거의 그대로 사용할 수 있고, (ext3_file_write()처럼) 다양한 진입점 주변에 파일시스템이 기능을 덧붙일 수 있으며, 관련이 없을 때는 (sysfs나 procfs처럼) 완전히 무시할 수도 있다.
여기에도 약간의 ‘미드레이어가 하위 레이어에 강제하는’ 요소가 있다. 제네릭 struct inode가 struct address_space를 포함하는데, 이것은 페이지 캐시에만 사용되고 페이지 캐시를 쓰지 않는 파일시스템에는 무관하기 때문이다. 하지만 대다수 파일시스템이 페이지 캐시를 사용한다는 점에서, 단순성을 제공하는 이 작은 일탈은 정당화될 수 있다.
페이지 캐시처럼 dcache도 파일시스템에 중요한 서비스를 제공한다. 파일 이름은 파일 내용보다 훨씬 더 자주 접근되는 경우가 많다. 따라서 이를 캐싱하는 것은 필수이고, 잘 설계되고 효율적인 디렉터리 엔트리 캐시는 모든 파일시스템 객체에 대한 효율적 접근의 큰 부분을 차지한다. 그러나 dcache는 페이지 캐시와 한 가지 매우 중요한 차이가 있다. 선택 사항이 아니라는 점이다. dcache는 모든 파일시스템에 강제되며 사실상 “미드레이어”다. 왜 그런지, 그리고 그것이 좋은 선택인지 이해하는 것은 이 설계 패턴의 가치와 적용 가능성을 이해하는 중요한 부분이다.
dcache를 강제하는 편의 논거 중 하나는, 디렉터리 rename과 관련된 흥미로운 경쟁(race)들이 있고, 이것을 올바르게 처리하지 못하기 쉽다는 점이다. 모든 파일시스템이 각자 잠재적으로 이를 틀리게 하기보다, dcache에서 한 번에 해결할 수 있다. 고전적 예는 /a/x가 /b/c/x로 rename되는 동시에 /b/c가 /a/x/c로 rename되는 경우다. 둘 다 성공하면 ‘c’와 ‘x’가 서로를 포함하게 되어 디렉터리 트리의 나머지와 분리되는 상황이 된다. 이는 원치 않는 결과다.
이런 종류의 레이스를 막는 것은 디렉터리별(per-directory) 수준에서만 디렉터리 엔트리를 캐싱해서는 불가능하다. 공통 캐싱 코드는 가능한 루프를 유발하는 레이스를 감지하려면 최소한 파일시스템 전체를 볼 수 있어야 한다. 따라서 파일시스템별(per-filesystem) 디렉터리 캐시를 유지하는 것은 분명 좋은 생각이고, 로컬 파일시스템이 이를 사용하도록 강하게 유도하는 것도 타당하다. 하지만 이를 모든 파일시스템에 강제하는 것이 좋은 선택인지에 대해서는 덜 명확하다.
네트워크 파일시스템은 dcache가 제공하는 루프 감지로 이득을 보지 못한다. 그런 일은 어차피 서버에서 해야 한다. sysfs, procfs, ptyfs 같은 “가상” 파일시스템은 파일 이름들이 영구히 메모리에 있으므로 캐시 자체가 크게 필요하지도 않다. dcache가 이런 파일시스템에 해가 되는지 여부는, dcache에 의존하지 않는 완전하고 최적화된 구현과 비교해볼 수 없기 때문에 쉽게 말하기 어렵다.
앞서 논의한 미드레이어의 핵심 식별자들 중, 비용을 가장 명확히 시사하는 것은 미드레이어가 특수 케이스 코드를 키우는 경향이다. 따라서 dcache가 이것을 겪었는지 살펴보는 것이 유용할 것이다.
dcache에서 처음 발견되는 특수 케이스는 d_flags에 저장된 플래그들이다. 그 중 두 개는 DCACHE_AUTOFS_PENDING과 DCACHE_NFSFS_RENAMED인데, 각각 한 파일시스템에만 특화되어 있다. AUTOFS 플래그는 autofs 내부에서만 쓰이는 것으로 보이므로, dcache의 특수 케이스라고 하긴 어렵다. 하지만 NFS 플래그는 공통 dcache 코드가 몇 군데에서 결정을 내릴 때 사용되므로, 명백히 특수 케이스다(다만 비용이 큰 특수 케이스인지는 별개의 문제다).
특수 케이스 코드를 찾을 다른 지점은, _operations 구조체의 함수 포인터가 NULL이 될 수 있고, 그 NULL이 ‘아무 것도 하지 않음’이 아니라 어떤 특정 동작(‘기본값’)을 의미하도록 해석되는 경우다. 이는 어떤 특수 요구를 지원하기 위해 새 연산을 추가하면서, NULL은 ‘기본’ 케이스를 의미하도록 남겨두었을 때 발생한다. 이것이 항상 나쁜 것은 아니지만 경고 신호가 될 수 있다.
dentry_operations 구조체에는 NULL이 될 수 있는 함수들이 몇 개 있다. d_revalidate()가 그 예인데, 꽤 무해하다. 파일시스템이 엔트리가 여전히 유효한지 확인하고 갱신하거나 무효화할 수 있게 해준다. 이를 필요로 하지 않는 파일시스템은 아무 것도 하지 않으면 된다. 아무 것도 하지 않는 함수를 호출할 필요는 없기 때문이다.
하지만 d_hash()와 d_compare()도 있다. 이들은 예를 들어 대소문자 비구분 파일명을 지원하기 위해, 비표준 해시/비교 함수를 파일시스템이 제공할 수 있게 한다. 이는 NULL일 때 공통 코드가 명시적 기본값을 사용하므로 특수 케이스처럼 보인다. 더 균일한 구현이라면 모든 파일시스템이 non-NULL인 d_hash()와 d_compare()를 제공하고, 많은 파일시스템은 라이브러리에서 대소문자 구분 버전을 선택하는 방식일 것이다.
그렇게 하면(일반적인 파일시스템에 대해 해시/비교마다 추가 함수 호출을 강제하면) 과도한 성능 비용이 된다고 쉽게 반박할 수 있으며, 사실 그렇다. 그렇다면 왜 다른 표준을 따르는 파일시스템에는 그런 성능 비용을 강제하는 것이 적절한가?
더 라이브러리 같은 접근이라면 VFS가 경로를 파일시스템에 전달하고, 파일시스템이 직접 lookup을 하게 할 수 있다. 즉 라이브러리의 캐시 처리기를 호출하거나, 라이브러리 루틴으로 이름 컴포넌트를 추출해 자체 저장된 파일 트리를 대상으로 직접 lookup을 수행하는 식이다.
따라서 dcache는 분명 미드레이어이며, 그 결과로 약간의 결함(울퉁불퉁한 부분)이 있다. 커널의 모든 미드레이어 중에서도, 앞서 말한 “새 요구가 나올 때마다 재설계될 수도 있다”는 관찰에 가장 잘 들어맞는 것이 아마 dcache일 것이다. dcache는 새 파일시스템의 필요를 충족하기 위해 지속적으로 개선된다. 그것이 “시간의 효과적 사용”인지 여부는 다른 곳에서 토론해야 할 주제일 것이다.
미드레이어와 라이브러리를 생각할 때 마지막 예시는 다양한 소프트웨어 RAID 구현과 관련 코드를 지원하는 md 드라이버다. md는 미드레이어 같은 특징과 라이브러리 같은 특징이 섞여 있어, 그 자체로 다소 엉성한 상태라는 점에서 흥미롭다.
“미드레이어의 실수” 패턴에 따르면 md 드라이버의 “이상적” 설계는 독립적인 RAID 레벨 모듈들이 사용할 수 있는 유용한 라이브러리 루틴 묶음을 제공하는 것이다. 예를 들어 RAID1은 독립적인 드라이버로서, 스페어 관리, 리싱크(resync) 수행, 메타데이터 읽기 같은 라이브러리 지원을 사용할 수 있다. RAID0는 별도의 드라이버로, 같은 메타데이터 읽기 코드는 사용하지만 스페어 관리나 리싱크 코드는 필요 없을 것이다.
하지만 현실은 그렇지 않다. 그 이유 중 하나는, 과거 블록 레이어가 메이저/마이너 디바이스 번호를 관리하던 방식과 관련된다. 오늘날은 훨씬 더 유연하지만, 과거에는 서로 다른 메이저 번호가 고유한 장치 드라이버와 마이너 번호의 고유한 파티셔닝 체계를 의미했다. 메이저 번호는 제한된 자원이었고, RAID0/RAID1/RAID5 등에 각각 별도의 메이저를 부여하는 것은 낭비였을 것이다. 그래서 단 하나의 번호(9)만 할당되었고, 하나의 드라이버가 모든 RAID 레벨을 담당해야 했다. 이 필요는 의심할 여지 없이 “모든 RAID 레벨을 처리하는 미드레이어가 옳다”는 사고방식을 만들었고, 그 사고방식은 지속되었다.
좀 더 라이브러리 중심으로 가기 위한 작은 कदम들이 있긴 하지만, 작고 결론적이지 않다. 단순한 예로 md_check_recovery() 함수를 들 수 있다. 이는 특정 RAID 레벨 구현이 명시적으로 호출해야만 사용되는 의미에서 라이브러리 함수다. 하지만 이 함수는 메타데이터 갱신, write-intent-bitmap 플러시, 실패한 장치 제거, 그리고(놀랍게도) 복구 필요 여부 확인 등 서로 무관한 일을 여러 가지 수행한다. 따라서 여러 무관 작업이 한데 묶여 강제된다는 점에서 미드레이어의 일부처럼 보인다.
더 나은 예는 md_register_thread()와 관련 함수들일 것이다. 어떤 md 어레이는 (예: 장애 후 서로 다른 드라이브로의 읽기 요청을 스케줄링하는 등) 지원을 제공하기 위해 커널 스레드를 실행해야 한다. md.c는 personality가 필요에 따라 호출할 수 있는 라이브러리 루틴 md_register_thread()와 md_unregister_thread()를 제공한다. 이 자체는 좋다. 하지만 md는 특정 RAID 레벨 드라이버가 결정하도록 두지 않고, 어떤 시점에 md_unregister_thread()를 호출하기로 스스로 결정한다. 이는 라이브러리 접근을 명백히 위반한다. 지금은 실제 문제를 일으키지 않지만, 나중에 특수 케이스 추가를 요구하게 될 수 있는 전형적인 유형이다.
md와 dm은 어떤 방식으로든 통합되어야 한다는 말이 종종 나온다(하지만 그 ‘실제 의미’의 실무적 문제까지 함께 논의되는 경우는 드물다). md와 dm 모두, 사실상 둘을 분리해버리는 뚜렷한 미드레이어를 가진다는 점에서 문제를 안고 있다. 이 미드레이어가 실수라는 사실을 충분히 이해하고, 이를 효과적인 라이브러리 구조로 대체하는 방향으로 나아가는 것이, 어떤 형태의 통합이든 향한 중요한 첫걸음이 될 가능성이 크다.
이로써 커널에서 미드레이어와 라이브러리를 살펴보는 탐구를 끝낸다. 다만 최근에는 가상 파일시스템을 지원하는 libfs, SATA 드라이브를 지원하는 libata 같은 것들이 추가되었다는 점을 덧붙일 만하다. 이는 미드레이어로부터 멀어지려는 경향이 필자의 바람 목록에만 있는 것이 아니라 실제 코드에 존재함을 보여준다.
이 글이 “미드레이어의 실수” 패턴의 배경 이슈와 라이브러리 접근을 따를 때의 이점을 이해하는 데 도움이 되었기를 바란다.
여기서 리눅스 커널의 설계 패턴에 관한 짧은 연재도 끝난다. 유용하게 추출되고, 이름 붙여지고, 예시로 조명될 수 있는 패턴은 더 많을 것이다. 하지만 그것들은 다음 기회로 미뤄야 한다.
일단 이런 모음이 완성된다면, 커널 코드를 효과적이고 일관되게 만드는 방법에 대한 매우 귀중한 통찰을 제공할 것이다. 이는 현재 코드가 어떻게 동작하는지(또는 왜 동작하지 않는지)를 이해하는 데, 새로운 개발을 진행할 때 선택을 내리는 데, 리뷰 과정에서 설계에 대해 코멘트할 때 유용하며, 전반적으로 커널 구성의 이 설계 수준에서 가시성을 높여줄 것이다. 장기적으로는 전반적인 품질 향상으로 이어질 수도 있다.
지금은 그 과정에 대한 기여로서, 우리가 찾아낸 패턴들을 빠르게 요약해보겠다.
| 이 글의 인덱스 항목 |
|---|
| Kernel |
| GuestArticles |