F#의 `let` vs `use`: 리소스 정리를 제대로 하는 법
F#의 let vs use: 리소스 정리를 제대로 하는 법
리소스를 다루는 코드를 짜다 보면, 파일을 열고 나서 "이건 언제 닫아 주지?" 하는 고민이 생기기 마련이에요. F#에는 이 고민을 아주 깔끔하게 풀어 주는 키워드가 있는데, 바로 use예요. let과 생김새는 거의 같은데, 한 가지가 달라요. 값이 스코프(범위)를 벗어날 때 Dispose를 자동으로 호출해 준다는 점이요. 이번 글에서는 use 키워드와 using 함수가 리소스의 생성과 해제를 어떻게 제어하는지, 그리고 let과 어떻게 다른지 함께 살펴볼게요.
출처
- 원문: Resource Management: The use Keyword — Microsoft Learn 공식 F# 문서 (ms.date: 10/29/2021)
- 참고 원문: let Bindings
본문
리소스(Resources)란
리소스라는 말은 사실 여러 가지로 쓰여요. 애플리케이션이 쓰는 데이터(문자열, 그래픽 같은 것들)도 리소스라고 부르기도 하지만, 여기서 말하는 리소스는 조금 다릅니다. 운영체제나 소프트웨어가 관리하는 자원, 예를 들어 그래픽 디바이스 컨텍스트, 파일 핸들, 네트워크·데이터베이스 연결, 대기 핸들 같은 동시성 객체 같은 것들이죠. 애플리케이션이 이런 리소스를 쓸 때는 운영체제나 다른 리소스 공급자에게 리소스를 받아 온 뒤(획득), 나중에 다시 풀(pool)에 돌려줘야(해제) 다른 애플리케이션이 쓸 수 있어요. 문제는 애플리케이션이 리소스를 공용 풀에 제대로 돌려주지 않을 때 생깁니다.
리소스 관리하기
애플리케이션에서 리소스를 효율적이고 책임감 있게 관리하려면, 리소스를 제때 그리고 예측 가능한 방식으로 해제해야 해요. 이때 .NET Framework가 도움을 주는 장치가 있는데, 바로 System.IDisposable 인터페이스예요. System.IDisposable를 구현하는 타입에는 System.IDisposable.Dispose 메서드가 있어서 리소스를 올바르게 해제해 주죠. 잘 짜여진 애플리케이션은 제한된 리소스를 쥐고 있는 객체가 더 이상 필요 없어지면 System.IDisposable.Dispose를 제때 호출하도록 보장합니다.
다행히 대부분의 .NET 언어가 이 작업을 더 쉽게 해 주는 지원을 제공하고, F#도 예외는 아니에요. dispose 패턴을 지원하는 유용한 언어 구조가 두 가지 있는데, 바로 use 바인딩과 using 함수예요.
use 바인딩
use 키워드는 let 바인딩과 아주 비슷한 형태를 갖습니다.
use value = expression
let 바인딩이 하는 일은 그대로 하면서, 한 가지가 더해져요. 값이 스코프를 벗어날 때 그 값에 Dispose를 호출해 주는 거예요. 주의할 점이 있는데, 컴파일러가 값에 대해 null 검사를 넣어 준다는 거예요. 그래서 값이 null이면 Dispose 호출 자체를 시도하지 않습니다.
use 키워드로 파일을 자동으로 닫는 예시를 볼게요.
open System.IO
let writetofile filename obj =
use file1 = File.CreateText(filename)
file1.WriteLine("{0}", obj.ToString() )
// file1.Dispose() is called implicitly here.
writetofile "abc.txt" "Humpty Dumpty sat on a wall."
file1이 스코프를 벗어나는 순간 Dispose가 자동으로 호출되니까, 따로 닫는 코드를 쓰지 않아도 파일이 정리돼요. 스코프 안에서 file1.WriteLine을 쓴 뒤 더 이상 필요 없어지면 컴파일러가 알아서 닫아 주는 거죠.
use가 여러 번 등장하면, 선언된 순서의 역순으로 dispose돼요. 즉, 가장 먼저 선언된 use가 마지막에 해제됩니다.
참고:
use는 계산 식(computation expression) 안에서도 쓸 수 있어요. 그 경우에는use식의 커스터마이즈된 버전이 사용됩니다. 자세한 내용은 Sequences, Async expressions, Task expressions, Computation Expressions를 참고하세요.
using 함수
using 함수는 다음과 같은 형태를 갖습니다.
using (expression1) function-or-lambda
using 식에서 expression1은 반드시 dispose되어야 할 객체를 만들어 냅니다. expression1의 결과(즉, dispose되어야 할 객체)는 function-or-lambda의 인자 value가 되고, 여기에는 두 가지가 올 수 있어요. expression1이 만들어 낸 값의 타입과 일치하는 남은 인자를 하나 받는 함수이거나, 그 타입의 인자를 받는 람다 식이죠. 함수가 실행을 마치는 지점에서 런타임이 Dispose를 호출해 리소스를 해제합니다(값이 null이면 예외로, Dispose 호출은 시도되지 않아요).
람다 식과 함께 쓰는 using 예제를 볼게요.
open System.IO
let writetofile2 filename obj =
using (System.IO.File.CreateText(filename)) ( fun file1 ->
file1.WriteLine("{0}", obj.ToString() )
)
writetofile2 "abc2.txt" "The quick sly fox jumps over the lazy brown dog."
함수와 함께 쓰는 using 표현식도 있는데, 이렇게 써요.
let printToFile (file1 : System.IO.StreamWriter) =
file1.WriteLine("Test output");
using (System.IO.File.CreateText("test.txt")) printToFile
여기서 그 함수는 이미 인자를 일부 적용한 함수일 수도 있어요. 아래 코드가 그 예인데, 문자열 XYZ가 담긴 파일을 만들어 줍니다.
let printToFile2 obj (file1 : System.IO.StreamWriter) =
file1.WriteLine(obj.ToString())
using (System.IO.File.CreateText("test.txt")) (printToFile2 "XYZ")
using 함수와 use 바인딩은 사실상 같은 일을 하는 거의 동등한 두 방법이에요. 다만 using 키워드는 Dispose가 호출되는 시점을 더 세밀하게 제어할 수 있다는 차이가 있죠. using을 쓰면 함수나 람다 식이 끝나는 지점에서 Dispose가 호출되고, use 키워드를 쓰면 이를 감싸고 있는 코드 블록이 끝나는 지점에서 Dispose가 호출됩니다. 일반적으로는 using 함수보다 use를 쓰는 쪽이 더 권장돼요.