F# 컴포넌트 디자인 가이드라인

F# 컴포넌트 디자인 가이드라인

요청하신 원문 URL(/dotnet/fsharp/using-fsharp/libraries/application-architecture)은 현재 공식 문서에서 제거되어 404로만 응답합니다(라이브 문서·git 기록·웹 아카이브 모두에서 확인). 그래서 실제로 존재하는 F# 공식 아키텍처 문서인 F# component design guidelines 페이지로 대체해 번역했어요. "어떻게 F# 코드를 설계하고 컴포넌트 경계를 나눌지"를 다룬다는 점에서 애플리케이션 아키텍처를 고민할 때 가장 잘 맞는 정식 자료입니다.

출처: F# component design guidelines - .NET | Microsoft Learn

본문

이 문서는 F# 프로그래밍을 위한 컴포넌트 디자인 가이드라인 모음이에요. Microsoft Research의 F# Component Design Guidelines v14와, 원래 F# Software Foundation이 만들고 관리하던 버전을 바탕으로 하고 있어요.

이 문서를 읽으려면 F# 프로그래밍에 어느 정도 익숙하다고 가정할게요. 여러 버전에 걸쳐 기여와 유익한 피드백을 준 F# 커뮤니티에 감사드립니다.

Overview

이 문서는 F# 컴포넌트 설계와 코딩에서 마주치는 여러 문제를 다뤄요. 여기서 "컴포넌트"는 다음 중 어떤 것이든 될 수 있어요.

  • 프로젝트 안에서 외부 사용자가 있는 F# 프로젝트의 레이어
  • 어셈블리 경계를 넘어 F# 코드가 사용하도록 만들어진 라이브러리
  • 어셈블리 경계를 넘어 어떤 .NET 언어든 사용하도록 만들어진 라이브러리
  • NuGet 같은 패키지 저장소를 통해 배포되도록 만들어진 라이브러리

이 글에서 설명하는 기법들은 Five principles of good F# code를 따르기 때문에, 상황에 맞게 함수형 프로그래밍과 객체 지향 프로그래밍을 모두 활용해요.

어떤 방법론을 쓰든 컴포넌트·라이브러리 설계자는 "개발자가 가장 쓰기 쉬운 API"를 만들려고 할 때 여러 실용적이고 흔한 문제들을 마주쳐요. .NET Library Design Guidelines를 꼼꼼히 적용하면 일관성 있고 쓰기 좋은 API 집합을 만들 수 있어요.

General guidelines

F# 라이브러리에 보편적으로 적용되는 가이드라인들이 몇 가지 있어요. 대상 독자가 누구든 상관없이 적용돼요.

.NET 라이브러리 디자인 가이드라인 익히기

어떤 종류의 F# 코딩을 하든 .NET Library Design Guidelines에 대한 실무 지식을 갖추는 게 좋아요. 대부분의 다른 F#·.NET 개발자들은 이 가이드라인에 익숙하고, .NET 코드가 이 기준을 따르기를 기대해요.

.NET Library Design Guidelines는 네이밍, 클래스·인터페이스 설계, 멤버 설계(프로퍼티·메서드·이벤트 등) 등에 대한 일반적인 지침을 제공해서, 다양한 설계 지침의 첫 출발점으로 유용해요.

코드에 XML 문서 주석 달기

공개 API에 XML 문서를 달아 두면 사용자가 타입·멤버를 쓸 때 훌륭한 Intellisense와 Quickinfo를 얻을 수 있고, 라이브러리용 문서 파일을 빌드할 수도 있어요. xmldoc 주석 안에서 추가 마크업에 쓸 수 있는 다양한 xml 태그는 XML Documentation에서 볼 수 있어요.

/// A class for representing (x,y) coordinates
type Point =

    /// Computes the distance between this point and another
    member DistanceTo: otherPoint:Point -> float

짧은 형식의 XML 주석(/// comment)이나 표준 XML 주석(///<summary>comment</summary>) 둘 다 쓸 수 있어요.

안정적인 라이브러리·컴포넌트 API에는 명시적 시그니처 파일(.fsi) 고려하기

F# 라이브러리에서 명시적 시그니처 파일을 쓰면 공개 API를 간결하게 요약해 줘서, 라이브러리의 전체 공개 면을 정확히 파악할 수 있고 공개 문서와 내부 구현 세부사항이 깔끔하게 분리돼요. 시그니처 파일은 구현 파일과 시그니처 파일 양쪽을 모두 고쳐야 하기 때문에 공개 API를 바꾸는 데 마찰을 더해요. 그래서 시그니처 파일은 보통 API가 굳어져서 더 이상 크게 바뀔 일이 없다고 판단될 때 도입하는 게 맞아요.

.NET에서 문자열을 다루는 모범 사례 따르기

프로젝트 범위가 요구할 때는 Best Practices for Using Strings in .NET 가이드를 따라요. 특히 문자열 변환·비교에서 (가능한 경우) 문화적 의도를 명시적으로 드러내는 게 중요해요.

F#을 대상으로 하는 라이브러리를 위한 가이드라인

이 절은 공개 F# 라이브러리, 즉 F# 개발자가 사용하도록 만들어진 공개 API를 가진 라이브러리를 개발하기 위한 권장사항을 담아요. F#에 특화된 라이브러리 디자인 권장사항은 다양해요. 아래에 나오는 특별한 권장사항이 없으면 .NET Library Design Guidelines가 기본 가이드가 돼요.

네이밍 규칙

.NET 네이밍·대문자 규칙 쓰기

아래 표는 .NET 네이밍·대문자 규칙을 따라요. 여기에 F# 구문을 위한 약간의 추가가 있어요. 이런 권장사항은 특히 F#-to-F# 경계를 넘는 API에 적용되며, .NET BCL과 대부분 라이브러리의 관용구와 맞아떨어져요.

Construct Case Part Examples Notes
Concrete types PascalCase Noun/ adjective List, Double, Complex Concrete types are structs, classes, enumerations, delegates, records, and unions. Though type names are traditionally lowercase in OCaml, F# has adopted the .NET naming scheme for types.
DLLs PascalCase Fabrikam.Core.dll
Union tags PascalCase Noun Some, Add, Success Do not use a prefix in public APIs. Optionally use a prefix when internal, such as "type Teams = TAlpha
Event PascalCase Verb ValueChanged / ValueChanging
Exceptions PascalCase WebException Name should end with "Exception".
Field PascalCase Noun CurrentName
Interface types PascalCase Noun/ adjective IDisposable Name should start with "I".
Method PascalCase Verb ToString
Namespace PascalCase Microsoft.FSharp.Core Generally use <Organization>.<Technology>[.<Subnamespace>], though drop the organization if the technology is independent of organization.
Parameters camelCase Noun typeName, transform, range
let values (internal) camelCase or PascalCase Noun/ verb getValue, myTable
let values (external) camelCase or PascalCase Noun/verb List.map, Dates.Today let-bound values are often public when following traditional functional design patterns. However, generally use PascalCase when the identifier can be used from other .NET languages.
Property PascalCase Noun/ adjective IsEndOfFile, BackColor Boolean properties generally use Is and Can and should be affirmative, as in IsEndOfFile, not IsNotEndOfFile.
약어 피하기

.NET 가이드라인은 약어 사용을 권장하지 않아요(예: "OnBtnClick보다 OnButtonClick을 쓰세요"). Async("Asynchronous")처럼 흔한 약어는 용인돼요. 함수형 프로그래밍에서는 이 지침이 가끔 무시되기도 해요. 예를 들어 List.iter는 "iterate"의 약어를 써요. 이런 이유로 F#-to-F# 프로그래밍에서는 약어 사용이 더 관대하게 용인되지만, 공개 컴포넌트 설계에서는 여전히 일반적으로 피해야 해요.

대소문자만 다른 이름 충돌 피하기

.NET 가이드라인은 일부 클라이언트 언어(예: Visual Basic)가 대소문자를 구분하지 않으므로, 이름 충돌을 구분하는 데 대소문자만 쓸 수 없다고 말해요.

적절한 곳에 약어 사용하기

XML 같은 약어는 약자(abbreviation)가 아니고, .NET 라이브러리에서 소문자 형태(Xml)로 널리 쓰여요. 잘 알려지고 널리 인식되는 약어만 사용해야 해요.

제네릭 파라미터 이름에는 PascalCase 쓰기

공개 API의 제네릭 파라미터 이름에는 PascalCase를 써야 해요. F# 대상 라이브러리도 마찬가지예요. 특히 임의의 제네릭 파라미터에는 T, U, T1, T2 같은 이름을 쓰고, 특정 이름이 의미가 있을 때는 F# 대상 라이브러리에서 Key, Value, Arg 같은 이름을 써요(예를 들어 TKey 같은 건 쓰지 마세요).

F# 모듈의 공개 함수·값에는 PascalCase 또는 camelCase 쓰기

camelCase는 한정자 없이 쓰도록 설계된 공개 함수(예: invalidArg)와 "표준 컬렉션 함수"(예: List.map)에 사용돼요. 두 경우 모두 함수 이름이 언어의 키워드처럼 동작해요.

객체, 타입, 모듈 설계

타입과 모듈을 담을 네임스페이스 또는 모듈 사용하기

컴포넌트의 각 F# 파일은 네임스페이스 선언이나 모듈 선언으로 시작해야 해요.

namespace Fabrikam.BasicOperationsAndTypes

type ObjectType1() =
    ...

type ObjectType2() =
     ...

module CommonOperations =
    ...

또는

module Fabrikam.BasicOperationsAndTypes

type ObjectType1() =
    ...

type ObjectType2() =
    ...

module CommonOperations =
    ...

최상위 코드를 모듈로 정리할지 네임스페이스로 정리할지의 차이는 다음과 같아요.

  • 네임스페이스는 여러 파일에 걸칠 수 있어요
  • 네임스페이스는 안쪽 모듈 안에 있지 않으면 F# 함수를 담을 수 없어요
  • 어떤 모듈의 코드든 반드시 단일 파일 안에 포함돼야 해요
  • 최상위 모듈은 안쪽 모듈 없이도 F# 함수를 담을 수 있어요

최상위 네임스페이스와 모듈 중 무엇을 고르느냐는 코드의 컴파일 형태에 영향을 주고, API가 나중에 F# 코드 밖에서 사용된다면 다른 .NET 언어에서 보이는 모습에도 영향을 줘요.

객체 타입에 본질적인 연산은 메서드·프로퍼티로 구현하기

객체를 다룰 때는 사용 가능한 기능을 그 타입의 메서드와 프로퍼티로 구현하는 게 좋아요.

type HardwareDevice() =

    member this.ID = ...

    member this.SupportedProtocols = ...

type HashTable<'Key,'Value>(comparer: IEqualityComparer<'Key>) =

    member this.Add(key, value) = ...

    member this.ContainsKey(key) = ...

    member this.ContainsValue(value) = ...

특정 멤버가 가진 기능의 대부분을 반드시 그 멤버 안에서 다 구현할 필요는 없어요. 하지만 그 기능 중 "사용 가능한 조각"은 그 멤버 안에 있어야 해요.

가변 상태를 캡슐화하려면 클래스 사용하기

F#에서는 그 상태가 이미 다른 언어 구조(클로저, 시퀀스 식, 비동기 계산 등)로 캡슐화되어 있지 않은 경우에만 이렇게 하면 돼요.

type Counter() =
    // let-bound values are private in classes.
    let mutable count = 0

    member this.Next() =
        count <- count + 1
        count
관련 연산을 묶으려면 인터페이스 사용하기

연산 집합을 나타내는 데는 인터페이스 타입을 사용해요. 함수의 튜플이나 함수의 레코드 같은 다른 방식보다 인터페이스가 권장돼요.

type Serializer =
    abstract Serialize<'T> : preserveRefEq: bool -> value: 'T -> string
    abstract Deserialize<'T> : preserveRefEq: bool -> pickle: string -> 'T

이것보다는:

type Serializer<'T> = {
    Serialize: bool -> 'T -> string
    Deserialize: bool -> string -> 'T
}

인터페이스는 .NET에서 일급 개념이라, 보통 Functor가 해주는 일을 성취하는 데 쓸 수 있어요. 게다가 함수의 레코드로는 할 수 없는 "존재 타입(existential type)"을 프로그램에 인코딩하는 데도 쓸 수 있어요.

컬렉션에 작용하는 함수를 묶으려면 모듈 사용하기

컬렉션 타입을 정의할 때는 새 컬렉션 타입에 CollectionType.map, CollectionType.iter 같은 표준 연산 집합을 제공하는 걸 고려해 봐요.

module CollectionType =
    let map f c =
        ...
    let iter f c =
        ...

그런 모듈을 넣는다면 FSharp.Core에서 찾을 수 있는 표준 함수 네이밍 규칙을 따라요.

흔하고 표준적인 함수, 특히 수학·DSL 라이브러리에서는 모듈로 함수 묶기

예를 들어 Microsoft.FSharp.Core.Operators는 FSharp.Core.dll이 제공하는 (abs, sin 같은) 최상위 함수들을 자동으로 열어 주는 컬렉션이에요.

마찬가지로 통계 라이브러리라면 erf, erfc 함수를 가진 모듈을 포함할 수 있고, 이 모듈은 명시적으로든 자동으로든 열리도록 설계돼요.

RequireQualifiedAccess 사용 고려, AutoOpen 속성은 신중히 적용하기

모듈에 [<RequireQualifiedAccess>] 속성을 추가하면 그 모듈은 열 수 없고, 모듈 요소에 대한 참조는 명시적 한정 접근이 필요해져요. 예를 들어 Microsoft.FSharp.Collections.List 모듈이 이 속성을 가져요.

이는 모듈의 함수·값 이름이 다른 모듈의 이름과 충돌할 가능성이 높을 때 유용해요. 한정 접근을 요구하면 라이브러리의 장기적인 유지보수성과 진화 가능성이 크게 높아져요.

FSharp.Core가 제공하는 (Seq, List, Array 같은) 모듈을 확장하는 커스텀 모듈에는 [<RequireQualifiedAccess>] 속성을 두는 것을 강력히 권장해요. 그 모듈들은 F# 코드에서 널리 쓰이고 [<RequireQualifiedAccess>]가 정의되어 있거든요. 더 일반적으로, 그 속성이 있는 다른 모듈을 가리거나 확장하는 커스텀 모듈이 속성 없이 정의되는 것은 권장하지 않아요.

모듈에 [<AutoOpen>] 속성을 추가하면 포함 네임스페이스가 열릴 때 그 모듈도 열려요. [<AutoOpen>] 속성은 어셈블리에 적용해서, 그 어셈블리가 참조될 때 자동으로 열리는 모듈을 나타낼 수도 있어요.

예를 들어 통계 라이브러리 MathsHeaven.Statisticsmodule MathsHeaven.Statistics.Operators를 포함하고 그 안에 함수 erferfc가 있다고 해 볼게요. 이 모듈을 [<AutoOpen>]으로 표시하는 게 합리적이에요. 그러면 open MathsHeaven.Statistics가 이 모듈도 열어서 erferfc 이름을 스코프에 가져와요. [<AutoOpen>]의 또 다른 좋은 용도는 확장 메서드를 담은 모듈이에요.

[<AutoOpen>]을 남용하면 네임스페이스가 오염되므로 신중히 써야 해요. 특정 도메인의 특정 라이브러리에서는 [<AutoOpen>]을 적절히 사용하면 사용성이 좋아질 수 있어요.

잘 알려진 연산자를 쓸 만한 곳에서는 클래스에 연산자 멤버 정의 고려하기

때로 클래스가 Vector 같은 수학적 구문을 모델링하는 데 쓰여요. 모델링하는 도메인에 잘 알려진 연산자가 있으면, 그 연산자를 클래스에 본질적인 멤버로 정의하는 게 도움이 돼요.

type Vector(x: float) =

    member v.X = x

    static member (*) (vector: Vector, scalar: float) = Vector(vector.X * scalar)

    static member (+) (vector1: Vector, vector2: Vector) = Vector(vector1.X + vector2.X)

let v = Vector(5.0)

let u = v * 10.0

이 지침은 이런 타입에 대한 일반 .NET 지침과 일치해요. 하지만 F# 코딩에서는 특히 중요할 수 있는데, 이 타입들을 List.sumBy 같은 멤버 제약이 있는 F# 함수·메서드와 함께 쓸 수 있기 때문이에요.

다른 .NET 언어 사용자를 위해 .NET 친화적 이름을 제공하려면 CompiledName 사용 고려하기

때로 F# 사용자를 위한 스타일(예: 모듈 바인딩 함수처럼 보이도록 정적 멤버를 소문자로)과, 어셈블리로 컴파일될 때의 이름 스타일을 다르게 하고 싶을 수 있어요. [<CompiledName>] 속성을 쓰면 어셈블리를 사용하는 F# 이외 코드에 다른 스타일을 제공할 수 있어요.

type Vector(x:float, y:float) =

    member v.X = x
    member v.Y = y

    [<CompiledName("Create")>]
    static member create x y = Vector (x, y)

let v = Vector.create 5.0 3.0

[<CompiledName>]을 쓰면 어셈블리의 F# 이외 사용자에게 .NET 네이밍 규칙을 쓸 수 있어요.

더 간단한 API를 만들 수 있다면 멤버 함수에 메서드 오버로딩 사용하기

메서드 오버로딩은 비슷한 기능을 서로 다른 옵션이나 인자로 수행해야 할 수 있는 API를 단순화하는 강력한 도구예요.

type Logger() =

    member this.Log(message) =
        ...
    member this.Log(message, retryPolicy) =
        ...

F#에서는 인자의 타입보다 인자의 개수로 오버로드하는 것이 더 흔해요.

레코드·유니온 타입의 설계가 진화할 가능성이 있다면 표현을 숨기기

객체의 구체적 표현을 드러내는 걸 피해야 해요. 예를 들어 DateTime 값의 구체적 표현은 .NET 라이브러리 설계의 외부 공개 API로 드러나지 않아요. 런타임에 CLR(Common Language Runtime)은 실행 내내 사용될 확정 구현을 알고 있어요. 하지만 컴파일된 코드 자체는 구체적 표현에 대한 의존성을 갖지 않아요.

확장성을 위해 구현 상속 사용 피하기

F#에서는 구현 상속이 거의 쓰이지 않아요. 게다가 상속 계층은 흔히 복잡하고 새 요구사항이 생기면 바꾸기 어려워요. 구현 상속은 여전히 F#에 호환성과 특정 문제에 가장 좋은 해법인 드문 경우를 위해 존재하지만, 다형성을 설계할 때는 인터페이스 구현 같은 대안 기법을 F# 프로그램에서 찾아야 해요.

함수·멤버 시그니처

서로 관련 없는 여러 값을 소수 반환할 때는 반환값에 튜플 사용하기

반환 타입에 튜플을 쓰는 좋은 예를 볼게요.

val divrem: BigInteger -> BigInteger -> BigInteger * BigInteger

많은 구성요소를 반환하거나, 구성요소들이 하나의 식별 가능한 실체와 관련된 경우에는 튜플 대신 이름 있는 타입을 쓰는 걸 고려해 봐요.

F# API 경계의 비동기 프로그래밍에는 Async<T> 사용하기

T를 반환하는 Operation이라는 대응하는 동기 연산이 있다면, 비동기 연산은 Async<T>를 반환할 때 AsyncOperation으로, Task<T>를 반환할 때 OperationAsync로 이름을 지어야 해요. 흔히 쓰이는 Begin/End 메서드를 노출하는 .NET 타입에는 Async.FromBeginEnd를 사용해 확장 메서드를 파사드로 작성해서, F# 비동기 프로그래밍 모델을 그 .NET API에 제공하는 걸 고려해 봐요.

type SomeType =
    member this.Compute(x:int): int =
        ...
    member this.AsyncCompute(x:int): Async<int> =
        ...

type System.ServiceModel.Channels.IInputChannel with
    member this.AsyncReceive() =
        ...

예외

예외, results, options의 적절한 사용은 Error Management에서 배울 수 있어요.

확장 멤버

F#-to-F# 컴포넌트에서는 F# 확장 멤버를 신중히 적용하기

F# 확장 멤버는 일반적으로 그 타입과 결부된 본질적 연산의 폐포 안에 있는 연산, 즉 그 타입의 대부분 사용 방식에서 결부된 연산에만 쓰는 게 좋아요. 흔한 용도 하나는 다양한 .NET 타입에 F#에 더 관용적인 API를 제공하는 거예요.

type System.ServiceModel.Channels.IInputChannel with
    member this.AsyncReceive() =
        Async.FromBeginEnd(this.BeginReceive, this.EndReceive)

type System.Collections.Generic.IDictionary<'Key,'Value> with
    member this.TryGet key =
        let ok, v = this.TryGetValue key
        if ok then Some v else None

유니온 타입

트리 구조 데이터에는 클래스 계층 대신 판별 유니온 사용하기

트리 같은 구조는 재귀적으로 정의돼요. 이는 상속으로는 어색하지만 판별 유니온(Discriminated Unions)으로는 우아해요.

type BST<'T> =
    | Empty
    | Node of 'T * BST<'T> * BST<'T>

트리 같은 데이터를 판별 유니온으로 표현하면 패턴 매칭의 완전성(exhaustiveness)이라는 이점도 얻을 수 있어요.

케이스 이름이 충분히 고유하지 않은 유니온 타입에는 [<RequireQualifiedAccess>] 사용하기

동일한 이름이 서로 다른 것에 가장 좋은 이름인 도메인에 처할 수 있어요(예: 판별 유니온 케이스). [<RequireQualifiedAccess>]을 쓰면 케이스 이름을 구분해서, open 문의 순서에 의존하는 섀도잉 때문에 혼란스러운 오류가 발생하는 걸 피할 수 있어요.

설계가 진화할 가능성이 있는 이진 호환 API에서는 판별 유니온의 표현 숨기기

유니온 타입은 간결한 프로그래밍 모델을 위해 F# 패턴 매칭 형태에 의존해요. 앞서 언급했듯이, 타입 설계가 진화할 가능성이 있다면 구체적 데이터 표현을 드러내는 걸 피해야 해요.

예를 들어 판별 유니온의 표현은 private 또는 internal 선언을 쓰거나 시그니처 파일을 써서 숨길 수 있어요.

type Union =
    private
    | CaseA of int
    | CaseB of string

판별 유니온을 무분별하게 드러내면 사용자 코드를 깨뜨리지 않고 라이브러리를 버전업하기 어려워질 수 있어요. 대신 하나 이상의 액티브 패턴(active pattern)을 공개해서 타입 값에 대한 패턴 매칭을 허용하는 걸 고려해 봐요.

액티브 패턴은 F# 유니온 타입을 직접 노출하지 않으면서 F# 사용자에게 패턴 매칭을 제공하는 대안을 만들어 줘요.

인라인 함수와 멤버 제약

암시적 멤버 제약과 정적으로 해석되는 제네릭 타입을 쓴 인라인 함수로 제네릭 수치 알고리즘 정의하기

산술 멤버 제약과 F# 비교 제약은 F# 프로그래밍의 표준이에요. 예를 들어 다음 코드를 보세요.

let inline highestCommonFactor a b =
    let rec loop a b =
        if a = LanguagePrimitives.GenericZero<_> then b
        elif a < b then loop a (b - a)
        else loop (a - b) b
    loop a b

이 함수의 타입은 다음과 같아요.

val inline highestCommonFactor : ^T -> ^T -> ^T
                when ^T : (static member Zero : ^T)
                and ^T : (static member ( - ) : ^T * ^T -> ^T)
                and ^T : equality
                and ^T : comparison

이런 함수는 수학 라이브러리의 공개 API로 적합해요.

타입 클래스와 덕 타이핑을 흉내내기 위해 멤버 제약 사용 피하기

F# 멤버 제약을 사용해 "덕 타이핑"을 흉내낼 수 있어요. 하지만 이를 사용하는 멤버는 일반적으로 F#-to-F# 라이브러리 설계에 쓰면 안 돼요. 익숙하지 않거나 비표준적인 암시적 제약에 기반한 라이브러리 설계는 사용자 코드를 유연하지 않게 만들고 특정 프레임워크 패턴 하나에 묶여버리기 쉬워지기 때문이에요.

게다가 이런 방식으로 멤버 제약을 많이 사용하면 컴파일 시간이 매우 길어질 가능성도 높아요.

연산자 정의

커스텀 기호 연산자 정의 피하기

커스텀 연산자는 어떤 상황에서는 필수적이고, 방대한 구현 코드 안에서는 매우 유용한 표기 장치예요. 라이브러리를 처음 쓰는 사용자에게는 이름 있는 함수가 흔히 더 쓰기 쉬워요. 게다가 커스텀 기호 연산자는 문서화하기 어렵고, IDE와 검색 엔진의 기존 한계 때문에 사용자가 연산자에 대한 도움말을 찾기 더 어려워요.

그래서 기능은 이름 있는 함수와 멤버로 공개하는 것이 좋고, 그 표기적 이점이 문서화·인지 비용보다 클 때만 이 기능에 대한 연산자도 추가로 노출하는 게 좋아요.

측정 단위(Units of Measure)

F# 코드에서 추가 타입 안전성을 위해 측정 단위를 신중히 사용하기

측정 단위에 대한 추가 타입 정보는 다른 .NET 언어에서 볼 때 지워져요. .NET 컴포넌트·도구·리플렉션은 단위가 제거된 타입을 보게 된다는 점을 알아야 해요. 예를 들어 C# 사용자는 float<kg>가 아니라 float를 볼 거예요.

타입 약어(Type Abbreviations)

F# 코드를 단순화하려면 타입 약어를 신중히 사용하기

.NET 컴포넌트·도구·리플렉션은 타입의 약어 이름을 보지 못해요. 타입 약어를 심하게 사용하면 도메인이 실제보다 더 복잡해 보여서 사용자를 혼란스럽게 할 수도 있어요.

멤버·프로퍼티가 약어 대상 타입의 것과 본질적으로 달라야 하는 공개 타입에는 타입 약어 피하기

이 경우 약어 대상 타입이 정의 중인 실제 타입의 표현에 대해 너무 많이 드러내요. 대신 약어를 클래스 타입이나 단일 케이스 판별 유니온으로 감싸는 걸 고려해 봐요(성능이 중요할 때는 약어를 감싸는 struct 타입을 고려해 봐요).

예를 들어 멀티맵을 F# map의 특수한 경우로 정의하고 싶은 유혹이 들어요.

type MultiMap<'Key,'Value> = Map<'Key,'Value list>

하지만 이 타입의 논리적 dot-notation 연산은 Map의 연산과 같지 않아요. 예를 들어 키가 딕셔너리에 없을 때 조회 연산자 map[key]가 예외를 던지는 대신 빈 리스트를 반환하는 게 합리적이에요.

다른 .NET 언어에서 사용할 라이브러리를 위한 가이드라인

다른 .NET 언어에서 사용할 라이브러리를 설계할 때는 .NET Library Design Guidelines을 지키는 게 중요해요. 이 문서에서는 이런 라이브러리를 vanilla .NET 라이브러리라고 부르는데, F# 구문을 제약 없이 쓰는 F# 대상 라이브러리와 구분하기 위해서예요. vanilla .NET 라이브러리를 설계한다는 것은 공개 API에서 F# 특유의 구문 사용을 최소화해서, .NET Framework의 나머지와 일관된 익숙하고 관용적인 API를 제공한다는 뜻이에요. 규칙은 다음 절들에서 설명해요.

네임스페이스와 타입 설계(다른 .NET 언어에서 사용할 라이브러리용)

컴포넌트의 공개 API에 .NET 네이밍 규칙 적용하기

약어 이름의 사용과 .NET 대문자 규칙에 특히 주의해요.

type pCoord = ...
    member this.theta = ...

type PolarCoordinate = ...
    member this.Theta = ...
컴포넌트의 기본 조직 구조로 네임스페이스·타입·멤버 사용하기

공개 기능을 담은 모든 파일은 namespace 선언으로 시작해야 하고, 네임스페이스 안의 유일한 공개 실체는 타입이어야 해요. F# 모듈은 쓰지 마세요.

구현 코드, 유틸리티 타입, 유틸리티 함수를 담는 데는 비공개 모듈을 사용해요.

정적 타입이 모듈보다 선호돼요. 정적 타입은 API가 나중에 오버로딩과 F# 모듈 안에서는 쓰이지 않을 수 있는 다른 .NET API 설계 개념을 사용하도록 진화할 수 있게 해 주거든요.

예를 들어 다음 공개 API 대신에:

module Fabrikam

module Utilities =
    let Name = "Bob"
    let Add2 x y = x + y
    let Add3 x y z = x + y + z

이걸 고려해 봐요:

namespace Fabrikam

[<AbstractClass; Sealed>]
type Utilities =
    static member Name = "Bob"
    static member Add(x,y) = x + y
    static member Add(x,y,z) = x + y + z
타입 설계가 진화하지 않을 거라면 vanilla .NET API에 F# 레코드 타입 사용하기

F# 레코드 타입은 간단한 .NET 클래스로 컴파일돼요. API의 간단하고 안정적인 타입에 적합해요. 인터페이스 자동 생성은 [<NoEquality>], [<NoComparison>] 속성으로 억제하는 걸 고려해 봐요. 또한 vanilla .NET API에서 가변 레코드 필드는 공개 필드를 노출하므로 피해야 해요. API의 향후 진화를 위해 클래스가 더 유연한 옵션을 제공할지 항상 고려해 봐요.

예를 들어 다음 F# 코드가 C# 사용자에게 공개 API를 노출해요.

F#:

[<NoEquality; NoComparison>]
type MyRecord =
    { FirstThing: int
        SecondThing: string }

C#:

public sealed class MyRecord
{
    public MyRecord(int firstThing, string secondThing);
    public int FirstThing { get; }
    public string SecondThing { get; }
}
vanilla .NET API에서 F# 유니온 타입의 표현 숨기기

F# 유니온 타입은 F#-to-F# 코딩에서도 컴포넌트 경계에서 흔히 사용되지 않아요. 컴포넌트·라이브러리 내부에서 사용할 때는 훌륭한 구현 장치예요.

vanilla .NET API를 설계할 때는 private 선언이나 시그니처 파일을 사용해 유니온 타입의 표현을 숨기는 걸 고려해 봐요.

type PropLogic =
    private
    | And of PropLogic * PropLogic
    | Not of PropLogic
    | True

내부적으로 유니온 표현을 사용하는 타입에, 원하는 .NET 지향 API를 제공하는 멤버를 추가로 붙일 수도 있어요.

type PropLogic =
    private
    | And of PropLogic * PropLogic
    | Not of PropLogic
    | True

    /// A public member for use from C#
    member x.Evaluate =
        match x with
        | And(a,b) -> a.Evaluate && b.Evaluate
        | Not a -> not a.Evaluate
        | True -> true

    /// A public member for use from C#
    static member CreateAnd(a,b) = And(a,b)
프레임워크의 설계 패턴으로 GUI 및 기타 컴포넌트 설계하기

.NET에는 WinForms, WPF, ASP.NET 같은 다양한 프레임워크가 있어요. 이런 프레임워크에서 사용할 컴포넌트를 설계할 때는 각각의 네이밍·설계 규칙을 사용해야 해요. 예를 들어 WPF 프로그래밍에서는 설계 중인 클래스에 WPF 설계 패턴을 적용해요. 사용자 인터페이스 프로그래밍의 모델에서는 이벤트와 System.Collections.ObjectModel 같은 알림 기반 컬렉션 같은 설계 패턴을 사용해요.

객체·멤버 설계(다른 .NET 언어에서 사용할 라이브러리용)

.NET 이벤트를 노출하려면 CLIEvent 속성 사용하기

객체와 EventArgs를 받는 특정 .NET 델리게이트 타입으로 DelegateEvent를 구성해요(기본적으로 FSharpHandler 타입만 쓰는 Event 대신). 그래야 이벤트가 다른 .NET 언어에 익숙한 방식으로 공개돼요.

type MyBadType() =
    let myEv = new Event<int>()

    [<CLIEvent>]
    member this.MyEvent = myEv.Publish

type MyEventArgs(x: int) =
    inherit System.EventArgs()
    member this.X = x

    /// A type in a component designed for use from other .NET languages
type MyGoodType() =
    let myEv = new DelegateEvent<EventHandler<MyEventArgs>>()

    [<CLIEvent>]
    member this.MyEvent = myEv.Publish
비동기 연산을 .NET task를 반환하는 메서드로 노출하기

Task는 .NET에서 활성 비동기 계산을 나타내는 데 사용돼요. Task는 일반적으로 F# Async<T> 객체보다 합성성이 낮아요. Task는 "이미 실행 중인" 계산을 나타내고, 병렬 합성을 수행하거나 취소 신호·기타 컨텍스트 파라미터의 전파를 숨기는 방식으로 합성될 수 없기 때문이에요.

하지만 그럼에도 Task를 반환하는 메서드는 .NET에서 비동기 프로그래밍의 표준 표현이에요.

/// A type in a component designed for use from other .NET languages
type MyType() =

    let compute (x: int): Async<int> = async { ... }

    member this.ComputeAsync(x) = compute x |> Async.StartAsTask

명시적 취소 토큰을 받아들이고 싶을 때도 잦을 거예요.

/// A type in a component designed for use from other .NET languages
type MyType() =
    let compute(x: int): Async<int> = async { ... }
    member this.ComputeAsTask(x, cancellationToken) = Async.StartAsTask(compute x, cancellationToken)
F# 함수 타입 대신 .NET 델리게이트 타입 사용하기

여기서 "F# 함수 타입"은 int -> int 같은 "화살표" 타입을 말해요.

이렇게 하지 말고:

member this.Transform(f: int->int) =
    ...

이렇게 해요:

member this.Transform(f: Func<int,int>) =
    ...

F# 함수 타입은 다른 .NET 언어에 class FSharpFunc<T,U>로 보이고, 델리게이트 타입을 이해하는 언어 기능·도구에는 덜 적합해요. .NET Framework 3.5 이상을 대상으로 하는 고차 메서드를 작성할 때는 System.FuncSystem.Action 델리게이트가 .NET 개발자가 마찰 없이 쓸 수 있도록 공개하기에 맞는 API예요. (.NET Framework 2.0을 대상으로 할 때는 시스템 정의 델리게이트 타입이 더 제한적이에요. System.Converter<T,U> 같은 미리 정의된 델리게이트 타입을 쓰거나 특정 델리게이트 타입을 정의하는 걸 고려해 봐요.)

반대로 .NET 델리게이트는 F# 대상 라이브러리에는 자연스럽지 않아요(다음 F# 대상 라이브러리 절 참조). 그 결과 vanilla .NET 라이브러리용 고차 메서드를 개발할 때 흔한 구현 전략은 구현 전체를 F# 함수 타입으로 작성한 다음, 실제 F# 구현 위에 델리게이트를 얇은 파사드로 써서 공개 API를 만드는 거예요.

F# option 값을 반환하는 대신 TryGetValue 패턴을 쓰고, 인자로 F# option 값을 받는 것보다 메서드 오버로딩 선호하기

F# option 타입을 API에서 쓰는 흔한 패턴들은 vanilla .NET API에서 표준 .NET 설계 기법으로 구현하는 게 더 좋아요. F# option 값을 반환하는 대신 "TryGetValue" 패턴처럼 bool 반환 타입과 out 파라미터를 쓰는 걸 고려해 봐요. 그리고 F# option 값을 파라미터로 받는 대신 메서드 오버로딩이나 선택적 인자를 사용하는 걸 고려해 봐요.

member this.ReturnOption() = Some 3

member this.ReturnBoolAndOut(outVal: byref<int>) =
    outVal <- 3
    true

member this.ParamOption(x: int, y: int option) =
    match y with
    | Some y2 -> x + y2
    | None -> x

member this.ParamOverload(x: int) = x

member this.ParamOverload(x: int, y: int) = x + y
파라미터·반환값에는 .NET 컬렉션 인터페이스 타입 IEnumerable<T>와 IDictionary<Key,Value> 사용하기

.NET 배열 T[], F# 타입 list<T>, Map<Key,Value>, Set<T>, .NET 구체 컬렉션 타입 Dictionary<Key,Value> 같은 구체 컬렉션 타입 사용은 피해요. .NET Library Design Guidelines는 IEnumerable<T> 같은 다양한 컬렉션 타입을 언제 써야 하는지에 대해 좋은 조언을 줘요. 배열(T[])은 성능상 이유로 어떤 상황에서는 약간의 사용이 허용돼요. 특히 seq<T>IEnumerable<T>의 F# 별칭일 뿐이므로, seq는 vanilla .NET API에 흔히 적절한 타입이에요.

F# 리스트 대신:

member this.PrintNames(names: string list) =
    ...

F# 시퀀스를 사용해요:

member this.PrintNames(names: seq<string>) =
    ...
메서드의 유일한 입력 타입으로 unit 타입을 써서 인자 없는 메서드를, 유일한 반환 타입으로 써서 void 반환 메서드를 정의하기

unit 타입의 다른 용도는 피해요. 이건 좋아요:

✔ member this.NoArguments() = 3

✔ member this.ReturnVoid(x: int) = ()

이건 나빠요:

member this.WrongUnit( x: unit, z: int) = ((), ())
vanilla .NET API 경계에서 null 값 확인하기

F# 구현 코드는 불변 설계 패턴과 F# 타입에 대한 null 리터럴 사용 제한 덕분에 null 값이 더 적은 경향이 있어요. 다른 .NET 언어는 null을 값으로 훨씬 더 자주 사용해요. 그래서 vanilla .NET API를 노출하는 F# 코드는 API 경계에서 파라미터의 null을 확인해서, 그 값이 F# 구현 코드 깊숙이 흘러 들어가지 않게 막아야 해요. isNull 함수나 null 패턴에 대한 패턴 매칭을 쓸 수 있어요.

let checkNonNull argName (arg: obj) =
    match arg with
    | null -> nullArg argName
    | _ -> ()

let checkNonNull' argName (arg: obj) =
    if isNull arg then nullArg argName
    else ()

F# 9부터는 새 | null syntax를 활용해서 컴파일러가 가능한 null 값과 그걸 처리해야 할 위치를 알려주게 할 수 있어요.

let checkNonNull argName (arg: obj | null) =
    match arg with
    | null -> nullArg argName
    | _ -> ()

let checkNonNull' argName (arg: obj | null) =
    if isNull arg then nullArg argName 
    else ()

F# 9에서 컴파일러는 가능한 null 값이 처리되지 않은 것을 감지하면 경고를 내보내요.

let printLineLength (s: string) =
    printfn "%i" s.Length

let readLineFromStream (sr: System.IO.StreamReader) =
    // `ReadLine` may return null here - when the stream is finished
    let line = sr.ReadLine()
    // nullness warning: The types 'string' and 'string | null'
    // do not have equivalent nullability
    printLineLength line

이런 경고는 F# null pattern을 매칭에 사용해서 해결해야 해요.

let printLineLength (s: string) =
    printfn "%i" s.Length

let readLineFromStream (sr: System.IO.StreamReader) =
    let line = sr.ReadLine()
    match line with
    | null -> ()
    | s -> printLineLength s
반환값으로 튜플 사용 피하기

대신 집계 데이터를 담은 이름 있는 타입을 반환하거나, out 파라미터를 사용해 여러 값을 반환하는 걸 선호해요. 튜플과 struct 튜플이 .NET에 존재하지만(C#의 struct 튜플 언어 지원 포함), 대부분 .NET 개발자에게 이상적이고 기대되는 API를 제공하지는 않을 거예요.

파라미터 커링(currying) 사용 피하기

대신 .NET 호출 규칙 Method(arg1,arg2,…,argN)을 사용해요.

member this.TupledArguments(str, num) = String.replicate num str

팁: 어떤 .NET 언어에서든 사용할 라이브러리를 설계한다면, 라이브러리가 그 언어들에서 "제대로 느껴지는지" 확인하기 위해 실험적인 C#과 Visual Basic 프로그래밍을 실제로 해보는 것에 대체할 게 없어요. .NET Reflector와 Visual Studio 개체 브라우저(Object Browser) 같은 도구를 써서 라이브러리와 문서가 개발자에게 기대한 대로 보이는지 확인할 수도 있어요.

부록: 다른 .NET 언어에서 사용하도록 F# 코드를 설계하는 종단 간 예제

다음 클래스를 생각해 봐요.

open System

type Point1(angle,radius) =
    new() = Point1(angle=0.0, radius=0.0)
    member x.Angle = angle
    member x.Radius = radius
    member x.Stretch(l) = Point1(angle=x.Angle, radius=x.Radius * l)
    member x.Warp(f) = Point1(angle=f(x.Angle), radius=x.Radius)
    static member Circle(n) =
        [ for i in 1..n -> Point1(angle=2.0*Math.PI/float(n), radius=1.0) ]

이 클래스의 추론된 F# 타입은 다음과 같아요.

type Point1 =
    new : unit -> Point1
    new : angle:double * radius:double -> Point1
    static member Circle : n:int -> Point1 list
    member Stretch : l:double -> Point1
    member Warp : f:(double -> double) -> Point1
    member Angle : double
    member Radius : double

이 F# 타입이 다른 .NET 언어를 쓰는 프로그래머에게 어떻게 보이는지 살펴볼게요. 예를 들어 대략적인 C# "시그니처"는 다음과 같아요.

// C# signature for the unadjusted Point1 class
public class Point1
{
    public Point1();

    public Point1(double angle, double radius);

    public static Microsoft.FSharp.Collections.List<Point1> Circle(int count);

    public Point1 Stretch(double factor);

    public Point1 Warp(Microsoft.FSharp.Core.FastFunc<double,double> transform);

    public double Angle { get; }

    public double Radius { get; }
}

F#이 여기서 구문을 어떻게 표현하는지 주목할 중요한 점들이 몇 가지 있어요. 예를 들어:

  • 인자 이름 같은 메타데이터가 보존돼요.
  • 인자 두 개를 받는 F# 메서드가 인자 두 개를 받는 C# 메서드가 돼요.
  • 함수와 리스트가 F# 라이브러리의 대응하는 타입에 대한 참조가 돼요.

다음 코드는 이런 점들을 고려하도록 코드를 조정하는 방법을 보여줘요.

namespace SuperDuperFSharpLibrary.Types

type RadialPoint(angle:double, radius:double) =

    /// Return a point at the origin
    new() = RadialPoint(angle=0.0, radius=0.0)

    /// The angle to the point, from the x-axis
    member x.Angle = angle

    /// The distance to the point, from the origin
    member x.Radius = radius

    /// Return a new point, with radius multiplied by the given factor
    member x.Stretch(factor) =
        RadialPoint(angle=angle, radius=radius * factor)

    /// Return a new point, with angle transformed by the function
    member x.Warp(transform:Func<_,_>) =
        RadialPoint(angle=transform.Invoke angle, radius=radius)

    /// Return a sequence of points describing an approximate circle using
    /// the given count of points
    static member Circle(count) =
        seq { for i in 1..count ->
                RadialPoint(angle=2.0*Math.PI/float(count), radius=1.0) }

이 코드의 추론된 F# 타입은 다음과 같아요.

type RadialPoint =
    new : unit -> RadialPoint
    new : angle:double * radius:double -> RadialPoint
    static member Circle : count:int -> seq<RadialPoint>
    member Stretch : factor:double -> RadialPoint
    member Warp : transform:System.Func<double,double> -> RadialPoint
    member Angle : double
    member Radius : double

이제 C# 시그니처는 다음과 같아요.

public class RadialPoint
{
    public RadialPoint();

    public RadialPoint(double angle, double radius);

    public static System.Collections.Generic.IEnumerable<RadialPoint> Circle(int count);

    public RadialPoint Stretch(double factor);

    public RadialPoint Warp(System.Func<double,double> transform);

    public double Angle { get; }

    public double Radius { get; }
}

이 타입을 vanilla .NET 라이브러리의 일부로 사용할 수 있게 준비하기 위해 만든 수정은 다음과 같아요.

  • 이름 몇 개를 조정했어요. Point1, n, l, f가 각각 RadialPoint, count, factor, transform이 됐어요.
  • [ ... ]를 쓰는 리스트 구성 대신 IEnumerable<RadialPoint>를 반환하도록 시퀀스 구성으로 바꿔, 반환 타입을 RadialPoint list 대신 seq<RadialPoint>로 사용했어요.
  • F# 함수 타입 대신 .NET 델리게이트 타입 System.Func를 사용했어요.

이렇게 하면 C# 코드에서 훨씬 더 쓰기 좋아져요.

더 알아보기