입출력 완벽 가이드
입출력 완벽 가이드 (Input/Output the definitive guide)
Raku에서 입출력(introduction 수준)을 하다 보면 IO::Path라는 이름을 가장 많이 만나게 돼요. 이 문서는 파일을 읽고 쓰는 Raku 방식의 정석을, 배경 클래스부터 주의할 함정까지 차근차근 설명해줄게요.
본문
기본기 (The basics)
일상적인 IO 작업의 대부분은 IO::Path 타입이 처리해요. 어떤 형태로든 파일을 읽거나 쓰고 싶다면 이 클래스가 바로 그 대상이에요. 파일핸들(또는 "파일 디스크립터")의 세부 사항을 추상화해주기 때문에, 대부분은 그것을 신경 쓸 필요조차 없어요.
내부적으로 IO::Path는 IO::Handle과 함께 동작해요. IO::Path가 주는 것보다 좀 더 세밀한 제어가 필요하다면 이 클래스를 직접 쓸 수 있어요. Proc이나 Proc::Async 타입을 통해 다른 프로세스를 다룰 때는, IO::Handle의 서브클래스인 IO::Pipe를 다루게 되기도 해요.
마지막으로 IO::CatHandle과 IO::Spec 및 그 서브클래스가 있는데, 이것들은 직접 쓸 일이 거의 없어요. 이 클래스들은 여러 파일을 하나의 핸들처럼 다루거나 저수준 경로 조작 같은 고급 기능을 제공해요.
이 모든 클래스와 함께 Raku는 이 클래스들을 간접적으로 다루게 해주는 서브루틴 몇 개를 제공해요. 함수형 프로그래밍 스타일을 좋아하거나 Raku 원라이너(one-liner)를 즐긴다면 이 서브루틴들이 유용해요.
IO::Socket와 그 서브클래스도 입출력과 관련이 있지만, 이 가이드에서는 다루지 않아요.
경로 다루기 (Navigating paths)
잠깐, IO::Path가 뭔데요? (What's an IO::Path anyway?)
경로를 파일 또는 디렉터리로 표현하려면 IO::Path 타입을 사용해요. 이 타입의 객체를 얻는 가장 간단한 방법은 Str에 .IO 메서드를 호출해 강제 변환하는 거예요.
say 'my-file.txt'.IO; # OUTPUT: «"my-file.txt".IO»
뭔가 빠진 것처럼 보일 수 있어요. 볼륨(volume)이나 절대 경로가 없잖아요? 그런데 그 정보는 실제로 객체 안에 들어 있어요. .raku 메서드로 확인할 수 있어요.
say 'my-file.txt'.IO.raku;
# OUTPUT: «IO::Path.new("my-file.txt", :SPEC(IO::Spec::Unix), :CWD("/home/camelia"))»
두 개의 추가 속성, SPEC와 CWD가 중요해요. SPEC는 경로가 어떤 운영체제의 의미 체계를 따라야 하는지 지정하고, CWD는 경로의 "현재 작업 디렉터리"를 지정해요. 즉 경로가 상대 경로라면 그 디렉터리를 기준으로 해석돼요.
이 말은, 어떤 방식으로 만들었든 IO::Path 객체는 기술적으로 항상 절대 경로를 가리킨다는 뜻이에요. 그래서 .absolute와 .relative 메서드가 Str 객체를 반환하고, 이것이 경로를 문자열로 만드는 올바른 방법이에요.
다만 서두르지 마세요. 경로는 IO::Path 객체 그대로 주고받아요. 경로를 다루는 모든 루틴이 객체를 처리할 수 있으니 변환할 필요가 없어요.
경로의 구성 요소 (Path parts)
로컬 파일 이름이 주어졌을 때, 그 구성 요소를 얻는 것은 아주 쉽게요. 예를 들어 /usr/local/data라는 디렉터리에 financial.data라는 파일이 있다고 해볼게요. Raku로 이 경로를 분석해봐요.
my $fname = "financial.data";
# 전체 경로명을 문자열로 만들기
my $f = $fname.IO.absolute;
say $f;
# OUTPUT: «/usr/local/data/financial.data»
# 경로의 부분들을 문자열로 만들기:
say $f.IO.dirname; # OUTPUT: «/usr/local/data»
say $f.IO.basename; # OUTPUT: «financial.data»
# 그리고 basename의 부분들:
# 확장자를 위한 메서드 사용:
say $f.IO.extension; # OUTPUT: «data»
# 재정의해서 확장자 제거:
say ($f.IO.extension("")).IO.basename; # OUTPUT: «financial»
파일 다루기 (Working with files)
파일에 쓰기 (Writing into files)
새 내용 쓰기 (Writing new content)
파일을 만들고 데이터를 쓰고 읽는 일을 해볼게요! spurt와 slurp 루틴은 데이터를 각각 한 덩어리로 쓰고 읽어요. 메모리에 전부 동시에 담기 어려운 아주 큰 파일을 다루는 게 아니라면, 이 두 루틴이면 충분해요.
"my-file.txt".IO.spurt: "I ♥ Raku!";
위 코드는 현재 디렉터리에 my-file.txt라는 파일을 만든 다음, 그 안에 I ♥ Raku! 텍스트를 써요. Raku가 처음 배우는 언어라면, 이 성취를 축하해도 좋아요! 이제 텍스트 편집기로 만든 파일을 열어서 프로그램이 쓴 내용을 확인해보세요. 이미 다른 언어를 알고 있다면, 이 가이드가 인코딩 처리나 오류 조건 같은 걸 빠뜨린 게 아닌지 궁금할 수 있어요.
그런데 이게 필요한 전부예요. 문자열은 기본적으로 utf-8 인코딩으로 처리되고, 오류는 Failure 메커니즘으로 처리돼요. 이는 일반 조건문으로 처리할 수 있는 예외예요. 이 경우 우리는 잠재적인 Failure 전부가 호출 뒤에 sink되도록 놔두고 있어서, 그 안에 포함된 예외가 던져지게 돼요.
내용 덧붙이기 (Appending content)
앞 절에서 만든 파일에 내용을 더 추가하고 싶다면, spurt 문서가 인자 옵션 중 하나로 :append를 언급한다는 점을 활용할 수 있어요. 하지만 더 세밀한 제어를 위해 IO::Handle을 하나 얻어볼게요.
my $fh = 'my-file.txt'.IO.open: :a;
$fh.print: "I count: ";
$fh.print: "$_ " for ^10;
$fh.close;
.open 메서드 호출은 IO::Path를 열고 IO::Handle을 반환해요. 인자로 :a를 넘겼는데, 이는 append 모드(덧붙이기)로 파일을 열겠다는 뜻이에요.
다음 두 줄에서는 그 IO::Handle에 일반적인 .print 메서드를 써서 텍스트 11개 부분('I count: ' 문자열과 숫자 10개)을 출력해요. 여기서도 Failure 메커니즘이 모든 오류 검사를 대신 처리한다는 점을 유의하세요. .open이 실패하면 Failure를 반환하고, 우리가 그 위에서 .print 메서드를 호출하려 할 때 예외가 던져져요.
마지막으로 .close 메서드를 호출해 IO::Handle을 닫아요. 특히 큰 프로그램이나 파일을 많이 다루는 프로그램에서는 이 작업이 정말 중요해요. 많은 시스템이 프로그램이 동시에 열 수 있는 파일 수에 한도를 두기 때문이에요. 핸들을 닫지 않으면 결국 그 한도에 도달해서 .open 호출이 실패하게 돼요. 다른 언어들과 달리 Raku는 참조 카운팅(reference counting)을 사용하지 않아서, 파일핸들이 정의된 스코프를 벗어났다고 해서 닫히지 않아요. 파일핸들은 가비지 컬렉션될 때만 닫혀요. 그래서 핸들을 닫지 않으면, 열린 핸들이 가비지 컬렉션될 기회를 얻기도 전에 파일 한도에 도달할 수 있어요.
파일에서 읽기 (Reading from files)
IO::Path 사용하기 (Using IO::Path)
앞 절에서 파일에 쓰는 일이 Raku에서 한 줄이면 충분하다는 걸 봤어요. 읽는 일도 마찬가지로 쉬워요.
say 'my-file.txt'.IO.slurp; # OUTPUT: «I ♥ Raku!»
say 'my-file.txt'.IO.slurp: :bin; # OUTPUT: «Buf[uint8]:0x<49 20 E2 99 A5 20 52 61 6B 75 21>»
.slurp 메서드는 파일 전체 내용을 읽어서 단일 Str 객체로 반환해요. :bin 명명 인자를 지정해 이진 모드를 요청하면 Buf 객체로 반환하고요.
slurp는 파일 전체를 메모리에 올리기 때문에, 아주 큰 파일을 다룰 때는 이상적이지 않아요.
IO::Path 타입은 또 다른 유용한 메서드 두 개를 제공해요. .words와 .lines인데, 이 둘은 파일을 더 작은 조각으로 지연(lazily) 읽어서, (기본적으로) 이미 소비된 값을 보관하지 않는 Seq 객체를 반환해요.
텍스트 파일에서 Raku를 언급한 줄을 찾아 출력하는 예시를 볼게요. 파일 자체가 사용 가능한 RAM에 다 들어가기 어려울 만큼 커도, 내용이 작은 조각으로 처리되므로 프로그램은 문제없이 실행돼요.
.say for '500-PetaByte-File.txt'.IO.lines.grep: *.contains: 'Raku';
다음 예시는 파일을 전부 메모리에 올리지 않고 앞의 100개 단어를 출력해요.
.say for '500-PetaByte-File.txt'.IO.words: 100
한 가지 유의할 점이 있어요. 위에서 리스트 인덱싱 연산을 쓰는 대신 .words에 limit 인자를 넘겼죠. 그 이유는 내부적으로 여전히 파일핸들이 사용 중이라, 반환된 Seq를 완전히 소비하기 전까지 핸들이 열려 있기 때문이에요. Seq를 참조하는 게 아무것도 없으면 핸들은 가비지 컬렉션 중에 결국 닫히겠지만, 파일을 많이 다루는 큰 프로그램에서는 모든 핸들이 즉시 닫히도록 확실히 하는 게 좋아요. 그래서 IO::Path의 .words와 .lines 메서드가 반환하는 Seq는 항상 완전히 구체화(reify)되도록 해야 해요. limit 인자는 그걸 돕기 위해 있는 거예요.
IO::Handle 사용하기 (Using IO::Handle)
IO::Handle 타입으로도 파일을 읽을 수 있어요. 이 방식은 과정을 더 세밀하게 제어할 수 있게 해줘요.
given 'some-file.txt'.IO.open {
say .readchars: 8; # OUTPUT: «I ♥ Raku»
.seek: 1, SeekFromCurrent;
say .readchars: 15; # OUTPUT: «I ♥ Programming»
.close
}
IO::Handle은 데이터를 읽는 데 .read, .readchars, .get, .getc, .words, .lines, .slurp, .comb, .split, .Supply 메서드를 제공해요. 선택지가 많은 대신, 다 쓰고 나면 핸들을 직접 닫아야 한다는 조건이 있어요.
일부 언어와 달리, 핸들은 정의된 스코프를 벗어나면 자동으로 닫히지 않아요. 대신 가비지 컬렉션될 때까지 열려 있어요. 닫는 일을 더 쉽게 하기 위해, 몇몇 메서드는 :close 인자를 지정하게 해주고, will leave 트레잇이나 Trait::IO 모듈이 제공하는 does auto-close 트레잇을 쓸 수도 있어요.
하면 안 되는 방식 (The wrong way to do things)
이 절에서는 Raku IO를 어떻게 하면 안 되는지 설명해요.
$*SPEC은 그만 건드리기 (Leave $*SPEC alone)
$*SPEC에 대해 들어봤고, 경로 조각을 나누고 합치는 데 그 사용법을 보여주는 코드나 책을 본 적이 있을 거예요. $*SPEC이 제공하는 루틴 이름 중 일부는 다른 언어에서 쓰던 것과 비슷해 보일 수도 있어요.
하지만 직접 IO 프레임워크를 만드는 게 아니라면, $*SPEC을 직접 쓸 일은 거의 없어요. $*SPEC은 저수준 기능을 제공하는데, 그걸 쓰면 코드가 읽기 힘들어질 뿐만 아니라 보안 문제(예: 널 문자)를 일으키기 쉽기 때문이에요!
IO::Path 타입이야말로 Raku 세계의 일꾼이에요. 모든 경로 조작 요구를 충족하면서도 파일핸들을 피할 수 있는 지름길 루틴을 제공해요. $*SPEC 대신 그걸 사용하세요.
팁: 경로 부분을 /로 이어서 IO::Path의 루틴에 넘겨도 돼요. 운영체제가 무엇이든 그 루틴들이 알아서 제대로(The Right Thing™) 처리해줘요.
# 틀렸어요!! 일이 너무 많아요!
my $fh = open $*SPEC.catpath: '', 'foo/bar', $file;
my $data = $fh.slurp;
$fh.close;
# 맞아요! IO::Path가 더러운 일을 다 해줘요
my $data = 'foo/bar'.IO.add($file).slurp;
다만 IO::Path가 제공하지 않는 일에 쓰는 것은 괜찮아요. 예를 들어 .devnull 메서드처럼요.
{
temp $*OUT = open :w, $*SPEC.devnull;
say "In space no one can hear you scream!";
}
say "Hello";
IO::Path를 문자열로 만들기 (Stringifying IO::Path)
표시용으로 어딘가에 보여주려는 게 아니라면, IO::Path 객체를 문자열로 만들기 위해 .Str 메서드를 쓰지 마세요. .Str 메서드는 IO::Path가 인스턴스화될 때 받은 기본 경로 문자열을 그대로 반환해요. $.CWD 속성의 값은 고려하지 않아요. 예를 들어 이 코드는 깨져 있어요.
my $path = 'foo'.IO;
chdir 'bar';
# 틀렸어요!! .Str은 $.CWD를 사용하지 않아요!
run <tar -cvvf archive.tar>, $path.Str;
chdir 호출이 현재 디렉터리의 값을 바꿨지만, 우리가 만든 $path는 그 변경 이전 디렉터리에 상대적이에요.
그런데 IO::Path 객체는 자기 자신이 어떤 디렉터리에 상대적인지 알고 있어요. 객체를 문자열로 만들 때는 .absolute나 .relative를 쓰면 돼요. 두 루틴 모두 Str 객체를 반환하며, 결과가 절대 경로인지 상대 경로인지만 다를 뿐이에요. 그래서 코드를 이렇게 고칠 수 있어요.
my $path = 'foo'.IO;
chdir 'bar';
# 맞아요!! .absolute는 $.CWD의 값을 고려해요!
run <tar -cvvf archive.tar>, $path.absolute;
# 이것도 좋아요:
run <tar -cvvf archive.tar>, $path.relative;
$*CWD에 주의하기 (Be mindful of $*CWD)
보통 시야에서 벗어나 있지만, 모든 IO::Path 객체는 기본적으로 현재 $*CWD의 값을 사용해 $.CWD 속성을 설정해요. 즉 주의할 점이 두 가지가 있어요.
$*CWD를 temp로 다루기
이 코드는 실수예요.
# 틀렸어요!!
my $*CWD = "foo".IO;
my $*CWD가 $*CWD를 정의되지 않은 상태로 만들어요. 그러면 .IO 강제 변환이 진행되면서, 만드는 경로의 $.CWD 속성을 정의되지 않은 $*CWD의 문자열 버전, 즉 빈 문자열로 설정해요.
이 연산을 올바르게 하려면 my 대신 temp를 사용하세요. temp는 my처럼 $*CWD 변경의 효과를 지역화하지만, 정의되지 않게 만들지는 않아요. 그래서 .IO 강제 변환이 여전히 올바른 이전 값을 얻을 수 있어요.
temp $*CWD = "foo".IO;
더 좋은 방법으로, 지역화된 $*CWD 안에서 어떤 코드를 실행하고 싶다면 그 목적에 맞는 indir 루틴을 사용하세요.