F# 10의 새로운 기능
F# 10의 새로운 기능
F# 10에서는 F# 언어, FSharp.Core 라이브러리, 그리고 툴링에 여러 개선이 더해져요. 이번 버전은 '정돈(refinement)'에 초점을 맞춘 릴리스라서, 명확함과 일관성, 성능을 다듬는 데 힘을 썼어요. 하나하나는 작지만, 매일 쓰는 코드가 더 읽기 쉽고 튼튼해지는 변화들이죠. F# 10은 .NET 10과 Visual Studio 2026에 함께 제공돼요.
최신 .NET SDK는 .NET 다운로드 페이지에서 받을 수 있어요.
출처: What's new in F# 10 — Microsoft Learn
본문
시작하기 (Get started)
F# 10은 모든 .NET Core 배포판과 Visual Studio 툴링에서 바로 쓸 수 있어요. 자세한 내용은 F# 시작하기 문서를 봐요.
범위를 지정한 경고 억제 (Scoped warning suppression)
이제 코드의 특정 구간에서만 경고를 끌 수 있는 #warnon 지시문이 새로 생겼어요. 이미 있던 #nowarn 지시문과 짝을 이루는데, 덕분에 어떤 경고를 어디에서 적용할지 훨씬 정밀하게 제어할 수 있죠.
예전에는 #nowarn을 쓰면 그 시점부터 파일 끝까지 해당 경고가 꺼져 버렸어요. 그래서 다른 곳에서 유용하게 쓰여야 할 경고까지 같이 억제되는 문제가 있었죠. 어떤 상황인지 동기 부여 예시를 하나 볼게요:
// We know f is never called with None.
let f (Some a) = // creates warning 25, which we want to suppress
// 2000 loc, where the incomplete match warning is beneficial
함수 정의 위에 #nowarn 25를 붙이면, 파일의 나머지 전체에서 FS0025가 꺼져 버렸어요.
F# 10에서는 이제 경고를 끄고 싶은 정확한 구간을 표시할 수 있어요:
#nowarn 25
let f (Some x) = // FS0025 suppressed
#warnon 25
// FS0025 enabled again
반대로, 경고가 전역적으로(예를 들어 컴파일러 플래그로) 꺼져 있다면 #warnon으로 그 자리에서만 다시 켤 수 있어요. 이 지시문은 짝이 맞는 #nowarn을 만나거나 파일이 끝날 때까지 적용돼요.
주요 호환성 참고사항이 몇 가지 있어요. 이 기능에는 #nowarn/#warnon 지시문의 일관성을 높이기 위한 변화가 여럿 포함되는데, 이것들은 비호환(breaking) 변경이에요:
- 컴파일러가 더 이상 여러 줄 및 빈 warn 지시문을 허용하지 않아요.
- 컴파일러가 더 이상
#과nowarn사이의 공백을 허용하지 않아요. - 경고 번호에 삼중 따옴표 문자열, 보간 문자열, verbatim 문자열을 쓸 수 없어요.
스크립트의 동작도 바뀌었어요. 예전에는 스크립트 어디에 #nowarn을 넣든 컴파일 전체에 적용됐는데, 이제는 .fs 파일과 똑같이 파일 끝이나 짝이 맞는 #warnon까지만 적용돼요.
이 기능은 RFC FS-1146으로 구현됐어요.
자동 속성 접근자의 액세스 한정자 (Access modifiers on auto property accessors)
객체 지향 프로그래밍에서 자주 쓰는 패턴 하나가 '외부에서 읽을 수는 있되, 내부에서만 값이 바뀌는 상태'를 만드는 거예요. F# 10 이전에는 이걸 하려면 속성 구문으로 백킹 필드(실제 속성 값을 들고 있는 숨은 변수)를 일일이 만들어야만 했어요. 반복되는 코드가 늘어났죠:
type Ledger() =
[<DefaultValue>] val mutable private _Balance: decimal
member this.Balance with public get() = this._Balance and private set v = this._Balance <- v
F# 10에서는 이제 속성 접근자 하나하나에 다른 액세스 한정자를 적용할 수 있어요. getter와 setter에 서로 다른 접근 레벨을 지정할 수 있으니, 위 패턴이 훨씬 단순해져요:
type Ledger() =
member val Balance = 0m with public get, private set
액세스 한정자는 속성 이름 앞(두 접근자 모두에 적용)이나 개별 접근자 앞 중 한 곳에만 둘 수 있고, 둘을 동시에 쓰진 못 해요.
이 기능은 시그니처(.fsi) 파일까지 확장되진 않는다는 점에 주의해요. 위 Ledger 예시의 올바른 시그니처는 이렇게 생겼어요:
type Ledger() =
member Balance : decimal
member private Balance : decimal with set
이 기능은 RFC FS-1141으로 구현됐어요.
ValueOption 선택적 매개 변수 (ValueOption optional parameters)
이제 선택적 매개 변수를 struct 기반의 ValueOption<'T> 형태로 표현할 수 있어요. 선택적 매개 변수에 [<Struct>] 특성을 붙이면, 참조형 option 대신 ValueOption<'T>를 쓰도록 컴파일돼요. 이렇게 하면 옵션 래퍼를 위한 힙 할당(관리 힙에 메모리를 잡고, 나중에 가비지 컬렉션이 처리하는 그 할당) 자체를 피할 수 있어서, 성능이 중요한 코드에서 유리해요.
예전에는 매개 변수가 실제로 넘어오지 않았을 때조차 F#이 항상 힙 할당 방식의 option 타입을 썼어요:
// Prior to F# 10: always uses reference option
type X() =
static member M(?x : string) =
match x with
| Some v -> printfn "Some %s" v
| None -> printfn "None"
F# 10에서는 [<Struct>] 특성을 써서 struct 기반의 ValueOption을 활용할 수 있어요:
type X() =
static member M([<Struct>] ?x : string) =
match x with
| ValueSome v -> printfn "ValueSome %s" v
| ValueNone -> printfn "ValueNone"
이렇게 하면 인자가 없을 때 힙 할당이 사라져서, 성능이 중요한 코드에서 몸값을 해요.
struct 기반 옵션은 값이 작거나, 생성이 잦아서 할당 압박이 신경 쓰이는 타입을 다룰 때 골라요. 반대로 기존 패턴 매칭 헬퍼에 기대거나 참조 시맨틱스가 필요하거나, 성능 차이가 무시할 정도라면 기본값인 참조형 옵션을 쓰면 돼요. 이 기능은 이미 ValueOption을 지원하는 다른 F# 언어 요소들과의 일관성도 높여줘요.
계산 식에서의 꼬리 호출 지원 (Tail-call support in computation expressions)
F# 10은 계산 식에 꼬리 호출 최적화를 더했어요. 계산 식의 빌더가 특별한 메서드를 구현하면, 이제 꼬리 호출 최적화를 선택(opt-in)할 수 있어요.
컴파일러는 계산 식을 일반 F# 코드로 번역할 때(이 과정을 desugaring이라고 해요) return!, yield!, do! 같은 식이 꼬리 위치에 있는지 알아채요. 빌더가 아래 메서드들을 제공하면, 컴파일러는 그 호출을 최적화된 진입점으로 라우팅해요:
ReturnFromFinal— 꼬리 위치의return!에서 호출돼요(없으면ReturnFrom으로 폴백).YieldFromFinal— 꼬리 위치의yield!에서 호출돼요(없으면YieldFrom으로 폴백).- 마지막에 오는
do!의 경우 컴파일러는ReturnFromFinal을 우선하고, 그다음YieldFromFinal을 보고, 그래도 없으면 일반Bind경로로 폴백해요.
이 *Final 멤버들은 모두 선택 사항이며, 순전히 최적화를 켜기 위해 존재해요. 이 멤버들이 없는 빌더는 기존 시맨틱스를 그대로 유지해요.
예를 들어:
coroutine {
yield! subRoutine() // tail position -> YieldFromFinal if available
}
하지만 꼬리 위치가 아니라면:
coroutine {
try
yield! subRoutine() // not tail -> normal YieldFrom
finally ()
}
주요 호환성 참고사항: 만약 계산 식 빌더가 이미 같은 이름의 멤버를 정의하고 있다면 이 변경은 비호환일 수 있어요. 대부분의 경우 기존 빌더는 F# 10으로 컴파일해도 수정 없이 잘 동작해요. 다만 이전 컴파일러 버전은 새 *Final 메서드를 그냥 무시하므로, 이전 버전과 호환되어야 하는 빌더라면 컴파일러가 이 메서드를 호출한다고 가정하면 안 돼요.
이 기능은 RFC FS-1330으로 구현됐어요.
계산 식 바인딩에서 괄호 없는 타입 주석 (Typed bindings in computation expressions without parentheses)
F# 10에서는 계산 식 바인딩에 타입 주석을 달 때 괄호가 더 이상 필요 없어졌어요. 이제 let!, use!, and! 바인딩에도 일반 let 바인딩과 똑같은 문법으로 타입 주석을 붙일 수 있어요.
예전에는 타입 주석을 쓰려면 괄호가 필요했죠:
async {
let! (a: int) = fetchA()
and! (b: int) = fetchB()
use! (d: MyDisposable) = acquireAsync()
return a + b
}
F# 10에서는 괄호 없이 이렇게 쓸 수 있어요:
async {
let! a: int = fetchA()
and! b: int = fetchB()
use! d: MyDisposable = acquireAsync()
return a + b
}
use! 바인딩에서 _ 허용 (Allow _ in use! bindings)
이제 계산 식 안의 use! 바인딩에서도 무시 패턴(discard pattern, _)을 쓸 수 있어요. 이건 use!의 동작을 일반 use 바인딩과 일치시키는 변화예요.
예전에는 use!에서 무시 패턴을 쓰면 컴파일러가 거부했기 때문에, 어쩔 수 없이 버리는 용도의 식별자를 만들어야 했어요:
counterDisposable {
use! _ignored = new Disposable()
// logic
}
F# 10에서는 무시 패턴을 바로 쓸 수 있어요:
counterDisposable {
use! _ = new Disposable()
// logic
}
값 자체는 필요 없고 수명 관리만 중요한 비동기 리소스를 바인딩할 때, 의도가 훨씬 분명해져요.
타입 안의 가상 중첩 모듈 거부 (Rejecting pseudo-nested modules in types)
이제 타입 정의 안에서 모듈 선언을 같은 구조적 레벨로 들여쓰면, 컴파일러가 오류를 내요. 타입 안의 어정쩡한 모듈 배치를 구조 검증으로 막는 거예요.
예전에는 타입 정의 안쪽으로 들여쓴 모듈 선언을 컴파일러가 받아줬는데, 실제로는 타입 안에 중첩되는 게 아니라 타입과 **형제(sibling)**로 만들어지는 잘못된 동작이었어요:
type U =
| A
| B
module M = // Silently created a sibling module, not nested
let f () = ()
F# 10에서는 이 패턴이 FS0058 오류를 내므로, 모듈을 제대로 배치해서 의도를 명확히 해야 해요:
type U =
| A
| B
module M =
let f () = ()
seq 생략에 대한 지원 중단 경고 (Deprecation warning for omitted seq)
이제 seq 빌더를 생략한 '맨몸 상태의' 시퀀스 식에 대해 컴파일러가 경고를 내요. { 1..10 }처럼 시퀀스 빌더 없이 범위 중괄호만 쓰면, 명시적인 seq { ... } 형태를 권장하는 폐기(deprecation) 경고를 보게 돼요.
역사적으로 F#은 seq 키워드를 생략할 수 있는 '가벼운 시퀀스 컴프리헨션(sequence comprehension lite)'이라는 특수 문법을 허용했어요:
{ 1..10 } |> List.ofSeq // implicit sequence, warning FS3873 in F# 10
F# 10에서 컴파일러는 이 패턴에 경고를 내고, 명시적인 형태를 권장해요:
seq { 1..10 } |> List.ofSeq
지금은 오류가 아니라 경고라서, 코드베이스를 고칠 시간은 충분해요. 이를테면 프로젝트 파일의 NoWarn 속성이나 로컬의 #nowarn 지시문에 경고 번호 3873을 넘겨 이 경고를 끌 수도 있어요.
명시적인 seq 형태는 다른 계산 식들과의 일관성과 코드 명확성을 높여줘요. 이후 버전의 F#에서는 이걸 오류로 바꿀 수도 있으니, 코드를 업데이트할 때 명시적 문법을 적용해 두길 권해요.
이 기능은 RFC FS-1033으로 구현됐어요.
특성 대상 강제 검사 (Attribute target enforcement)
F# 10은 모든 언어 요소에서 특성 대상(attribute target) 검증을 적용해요. 이제 컴파일러가 AttributeTargets를 확인해서, let 바인딩 값, 함수, 공용체 케이스, 암시적 생성자, struct, 클래스 전반에 걸쳐 특성이 의도한 대상에만 붙도록 검증해요.
예전에는 컴파일러가 특성을 호환되지 않는 대상에 잘못 붙여도 조용히 넘어갔어요. 그 결과 함수임을 나타내는 ()을 깜빡했을 때 테스트 특성이 그냥 무시되는 것 같은 미묘한 버그가 생겼죠:
[<Fact>]
let ``this is not a function`` = // Silently ignored in F# 9, not a test!
Assert.True(false)
F# 10에서는 컴파일러가 특성 대상을 강제하고, 특성이 잘못 붙으면 경고를 내요:
[<Fact>]
//^^^^ - warning FS0842: This attribute cannot be applied to property, field, return value. Valid targets are: method
let ``this is not a function`` =
Assert.True(false)
주요 호환성 참고사항: 이건 비호환 변경이라, 코드베이스에서 예전엔 조용히 지나쳤던 문제를 드러낼 수 있어요. 일찍 오류를 잡아주니 테스트 탐색 문제를 예방하고, 분석기나 데코레이터 같은 특성이 의도대로 동작한다는 걸 보장해요.
task 식에서의 and! 지원 (Support for and! in task expressions)
이제 task 식에서 and!를 써서 여러 task를 동시에 기다릴 수 있어요. 특히 C#과의 상호 운용이 필요할 때 F#에서 비동기 워크플로를 다루는 인기 있는 방법이 task인데, 그동안 계산 식 안에서 여러 task를 동시에 기다리는 간결한 방법이 없었죠.
아마 순차적으로 기다리던 코드에서 시작했을 거예요:
// Awaiting sequentially
task {
let! a = fetchA()
let! b = fetchB()
return combineAB a b
}
이걸 동시에 기다리도록 바꾸고 싶다면, 보통 Task.WhenAll을 썼을 거예요:
// Use explicit Task combinator to await concurrently
task {
let ta = fetchA()
let tb = fetchB()
let! results = Task.WhenAll([| ta; tb |])
return combineAB ta.Result tb.Result
}
F# 10에서는 and!를 쓰는 더 관용적인 방법을 제공해요:
task {
let! a = fetchA()
and! b = fetchB()
return combineAB a b
}
동시 실행 버전의 시맨틱스와 순차 버전의 단순함을 한 번에 얻는 셈이에요.
이 기능은 F# 언어 제안 #1363으로 구현됐고, FSharp.Core 라이브러리에 추가되는 형태로 들어왔어요. 대부분의 프로젝트는 컴파일러에서 최신 FSharp.Core 버전을 자동으로 받지만, 버전을 명시적으로 고정한 프로젝트라면 이 기능을 쓰려면 그 버전을 업데이트해야 해요.
기본적으로 더 나은 트리밍 (Better trimming by default)
F# 10은 F# 어셈블리를 트리밍할 때 오랫동안 걸리던 불편을 하나 없앴어요. 트리밍은 게시된 애플리케이션에서 쓰지 않는 코드를 제거해 크기를 줄이는 과정이에요. 이제 컴파일러가 쓰는 데 비해 런타임에는 필요 없는 큰 F# 메타데이터 리소스 덩어리(시그니처·최적화 데이터)를 떼어 내기 위해 ILLink.Substitutions.xml 파일을 수동으로 관리하지 않아도 돼요.
트리밍을 켜고 게시하면(PublishTrimmed=true), F# 빌드가 이제 이러한 툴링 전용 F# 리소스를 겨냥한 임베디드 대체(substitutions) 파일을 자동으로 생성해요.
예전에는 메타데이터를 떼어 내려면 이 파일을 손으로 관리해야 했어요. 유지 보수 부담이 늘고, 깜빡하기도 쉬웠죠.
그 결과 기본적으로 출력이 더 작아지고, 반복적으로 관리해야 할 코드가 줄어들며, 유지 보수 위험이 하나 줄어요. 완전한 수동 제어가 필요하다면 여전히 자체 대체 파일을 추가할 수 있고, <DisableILLinkSubstitutions>false</DisableILLinkSubstitutions> 속성으로 자동 생성을 끌 수도 있어요.
미리 보기의 병렬 컴파일 (Parallel compilation in preview)
컴파일 시간을 줄이고 싶던 F# 사용자에게 반가운 소식이에요. 병렬 컴파일 기능이 안정화 단계에 들어섰어요. .NET 10부터 그래프 기반 타입 검사(graph-based type checking), 병렬 IL 코드 생성, 병렬 최적화라는 세 기능이 ParallelCompilation 프로젝트 속성 아래로 묶여요.
F# 10은 LangVersion=Preview를 쓰는 프로젝트에서 이 설정을 기본으로 켜요. 향후 .NET 11에서는 모든 프로젝트에서 켤 계획이에요.
한번 시도해 보고 컴파일이 빨라지는지 확인해 보세요. F# 10에서 병렬 컴파일을 켜는 방법은 다음과 같아요:
<PropertyGroup>
<ParallelCompilation>true</ParallelCompilation>
<Deterministic>false</Deterministic> <!-- Note: deterministic builds don't get the benefits of parallel compilation -->
</PropertyGroup>
다른 미리 보기 기능은 누리면서 병렬 컴파일만 끄고 싶다면, ParallelCompilation을 false로 설정하면 돼요:
<PropertyGroup>
<LangVersion>Preview</LangVersion>
<ParallelCompilation>false</ParallelCompilation>
</PropertyGroup>
병렬 컴파일은 파일과 의존성이 여럿 있는 프로젝트에서 컴파일 시간을 크게 줄여줘요.
타입 포함 관계(subsumption) 캐시 (Type subsumption cache)
컴파일러가 이제 타입 관계 검사 결과를 캐시해서, 특히 복잡한 타입 계층을 다룰 때 타입 추론 속도를 높이고 IDE 성능을 개선해요. 이전 subsumption 검사 결과를 저장해 재사용함으로써, 컴파일과 IntelliSense를 느려지게 하던 중복 계산을 피하는 거예요.
캐시 관리하기: 대부분의 경우 타입 포함 관계 캐시는 별도 설정 없이도 성능을 높여줘요. 다만 (캐시 유지 워커 때문인지) 메모리 사용량이나 CPU 사용량이 늘었다면 캐시 동작을 조정할 수 있어요:
- 캐시를 완전히 끄려면 프로젝트 파일에
<LangVersion>9</LangVersion>을 설정해 F# 9 동작으로 되돌리면 돼요. - 비동기 캐시 제거(스레드 압력을 높이는 요인)를 끄고 동기 제거를 쓰려면
FSharp_CacheEvictionImmediate=1환경 변수를 설정하면 돼요.