F# 코딩 규칙

F# 코딩 규칙 (Coding Conventions)

큰 규모의 F# 코드베이스에서 오랫동안 일해 오면서 다듬어진 규칙들을 모아 둔 문서예요. 여기 적힌 권장 사항 하나하나는 좋은 F# 코드의 다섯 가지 원칙에 뿌리를 두고 있어요. 또 F# 컴포넌트 설계 지침과도 이어지는데, 이 규칙은 라이브러리 같은 컴포넌트에만 적용되는 게 아니라 F# 코드라면 어디서든 쓸 수 있어요.

출처: F# coding conventions (공식 문서)

본문

코드 정리하기 (Organizing code)

F#에는 코드를 정리하는 방법이 크게 두 가지가 있어요. 바로 **모듈(module)**과 **네임스페이스(namespace)**인데요. 둘은 비슷해 보이지만 차이가 분명해요.

  • 네임스페이스는 .NET 네임스페이스로 컴파일되고, 모듈은 정적 클래스(static class)로 컴파일돼요.
  • 네임스페이스는 항상 최상위에만 둘 수 있어요. 모듈은 최상위에 둘 수도 있고 다른 모듈 안에 중첩할 수도 있어요.
  • 네임스페이스는 여러 파일에 걸쳐 있을 수 있어요. 모듈은 그럴 수 없어요.
  • 모듈에는 [<RequireQualifiedAccess>][<AutoOpen>] 같은 특성을 붙일 수 있어요.

아래 지침은 이 둘을 잘 활용해 코드를 정리하는 방법을 알려줘요.

최상위에는 네임스페이스를 선호해요

외부에 공개해서 쓸 코드라면 최상위에는 모듈보다 네임스페이스가 낫습니다. 네임스페이스는 .NET 네임스페이스로 컴파일되기 때문에 C#에서 using static 같은 특별한 장치 없이도 그대로 쓸 수 있어요.

// Recommended.
namespace MyCode

type MyClass() =
 ...

최상위 모듈은 F# 코드 안에서만 호출하면 별 차이를 못 느껴요. 그런데 C# 쪽 소비자를 생각하면 얘기가 달라져요. MyCode 모듈 안에 있는 MyClass를 쓰려면 C#의 특정한 using static 구문을 알고 있어야 하고, 그걸 모르는 호출자는 MyClassMyCode 모듈로 한 번 더 감싸야 하는 상황에 당황하게 돼요.

// Will be seen as a static class outside F#
module MyCode

type MyClass() =
 ...

[<AutoOpen>]은 신중하게 적용해요

[<AutoOpen>]는 호출자에게 열려 있는 스코프를 오염시킬 수 있어요. 그러면 어느 값이 어디서 온 건지 그 대답이 사실상 "마법"이 되어 버리죠. 그건 좋은 일이 아니에요. 이 규칙의 예외는 F# 핵심 라이브러리(F# Core Library) 자체인데, 사실 이 예외도 논란이 좀 있는 편이에요.

하지만 공개 API를 위한 헬퍼 기능을 그 API와 분리해서 정리하고 싶을 때는 이게 꽤 편리하긴 해요.

module MyAPI =
 [<AutoOpen>]
 module private Helpers =
 let helper1 x y z =
 ...

 let myFunction1 x =
 let y = ...
 let z = ...

 helper1 x y z

이렇게 하면 함수 호출 때마다 헬퍼를 매번 전체 이름으로 지정할 필요 없이, 구현 세부 사항과 공개 API를 깔끔하게 분리할 수 있어요.

또 확장 메서드나 식 작성기(expression builder)를 네임스페이스 수준에서 노출할 때도 [<AutoOpen>]으로 깔끔하게 표현할 수 있어요.

이름이 충돌할 수 있거나 가독성에 도움이 된다고 느껴질 때는 [<RequireQualifiedAccess>]를 써요

모듈에 [<RequireQualifiedAccess>] 특성을 붙이면 그 모듈은 열 수(open) 없게 되고, 모듈의 요소를 참조할 때는 반드시 명시적으로 한정해 접근해야 해요. 예를 들어 Microsoft.FSharp.Collections.List 모듈이 이 특성을 쓰고 있어요.

모듈 안의 함수·값 이름이 다른 모듈의 이름과 충돌하기 쉬울 때 특히 유용해요. 한정 접근을 강제하면 장기적인 유지보수성과 라이브러리의 진화 능력이 크게 좋아져요.

[<RequireQualifiedAccess>]
module StringTokenization =
 let parse s = ...

...

let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'

open 문은 위상 정렬(topological)로 정리해요

F#에서는 선언의 순서가 결과에 영향을 줘요. open 문(그리고 여기선 그냥 open이라 부를 open type까지)도 예외가 아니에요. 이건 C#과 달라요. C#에서 usingusing static의 효과는 파일 안에서 그 문장이 놓인 순서와 무관하거든요.

F#에서는 스코프 안으로 열린 요소가 이미 존재하는 다른 요소를 가릴(shadow) 수 있어요. 다시 말해 open 문의 순서를 바꾸면 코드의 의미가 달라질 수 있어요. 그래서 모든 open 문을 아무렇게나 정렬(예: 알파벳순)하는 건 권장하지 않아요. 원치 않는 동작이 생길 수 있거든요.

대신 위상 정렬을 권장해요. 즉 open 문을 시스템의 계층이 정의된 순서대로 나열하는 거예요. 서로 다른 위상 계층 안에서는 알파벳순 정렬을 고려할 수도 있어요.

F# 컴파일러 서비스의 공개 API 파일을 예로 들면 위상 정렬은 이렇게 돼요.

namespace Microsoft.FSharp.Compiler.SourceCodeServices

open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text

open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library

open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver

open Internal.Utilities
open Internal.Utilities.Collections

빈 줄이 위상 계층을 구분해 주고, 각 계층 안에서는 알파벳순으로 정렬돼요. 이렇게 정리하면 실수로 값을 가리게 되는 일 없이 코드가 깔끔하게 정돈돼요.

값 초기화에 부수 효과가 있다면 클래스를 이용해요

값을 초기화할 때 부수 효과(side effect)가 생기는 경우가 꽤 많아요. 데이터베이스나 다른 원격 리소스에 대한 컨텍스트를 만들 때가 대표적이죠. 그런 값을 let 바인딩으로 초기화하는 건 몇 가지 이유로 문제가 될 수 있어요.

첫째, 애플리케이션 설정이 dep1dep2처럼 코드베이스 안으로 밀려 들어가요. 코드베이스가 커질수록 이건 유지보수하기 어려워져요.

둘째, 정적으로 초기화되는 데이터에, 컴포넌트 자체가 여러 스레드를 쓴다면 스레드 안전하지 않은 값이 들어가면 안 돼요. dep3이 딱 그걸 어기고 있네요.

마지막으로, 모듈 초기화는 컴파일 단위 전체의 정적 생성자(static constructor)로 컴파일돼요. 그 모듈에서 let 바인딩 값 초기화 중 어떤 오류라도 나면 그건 TypeInitializationException으로 나타나고, 앱이 살아 있는 한 계속 캐시되어 있어요. 이건 진단하기 어려운 문제예요. 보통 안쪽 예외(inner exception)가 있어서 원인을 유추할 수는 있지만, 그게 없으면 근본 원인이 뭔지 알 길이 없어요.

그럴 땐 그냥 의존성을 담는 단순한 클래스를 쓰는 게 낫습니다.

type MyParametricApi(dep1, dep2, dep3) =
 member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
 member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2

이렇게 하면 이런 일들이 가능해져요.

  • 의존 상태를 API 밖으로 밀어낼 수 있어요.
  • 설정을 API 밖에서 할 수 있어요.
  • 의존 값 초기화에서 오류가 나도 TypeInitializationException으로 나타날 가능성이 낮아져요.
  • API를 테스트하기 쉬워져요.

오류 관리 (Error management)

대규모 시스템에서 오류를 관리하는 일은 복잡하고 미묘한 작업이에요. 시스템을 결함에 강하고 잘 동작하게 만드는 은탄환은 없어요. 아래 지침은 이 어려운 영역을 헤쳐 나가는 데 도움을 주려는 거예요.

오류 케이스와 불법 상태를 도메인에 내재된 타입으로 표현해요

구별된 공용체(Discriminated Unions) 덕분에 F#은 프로그램의 잘못된 상태를 타입 시스템 안에서 표현할 수 있어요. 예를 들면 이렇게요.

type MoneyWithdrawalResult =
 | Success of amount:decimal
 | InsufficientFunds of balance:decimal
 | CardExpired of DateTime
 | UndisclosedFailure

여기서는 은행 계좌에서 돈을 인출하다 실패할 수 있는 경우가 세 가지로 정리돼 있어요. 각 오류 케이스가 타입에 표현되어 있으니, 프로그램 곳곳에서 안전하게 그 케이스를 다룰 수 있어요.

let handleWithdrawal amount =
 let w = withdrawMoney amount
 match w with
 | Success am -> printfn $"Successfully withdrew %f{am}"
 | InsufficientFunds balance -> printfn $"Failed: balance is %f{balance}"
 | CardExpired expiredDate -> printfn $"Failed: card expired on {expiredDate}"
 | UndisclosedFailure -> printfn "Failed: unknown"

일반적으로 말하면, 도메인에서 어떤 방식으로든 실패할 수 있는 경우들을 모델링할 수 있다면, 오류 처리 코드는 "평범한 프로그램 흐름과 별개로 따로 처리해야 하는 것"이 아니게 돼요. 그냥 평범한 프로그램 흐름의 일부가 되고, 특별한(exceptional) 일로 취급되지 않죠. 여기에는 두 가지 큰 이점이 있어요.

  • 도메인이 시간에 따라 변해도 유지보수하기 쉬워져요.
  • 오류 케이스를 단위 테스트하기 쉬워져요.

오류를 타입으로 표현할 수 없을 때는 예외를 사용해요

모든 오류가 문제 도메인 안에서 표현될 수 있는 건 아니에요. 이런 종류의 결함은 본질적으로 예외적(exceptional)이라서, F#이 예외를 일으키고 잡을 수 있는 능력을 제공하는 거예요.

먼저 예외 설계 지침(번역: Exception Design Guidelines)을 읽어 보길 권해요. 이 지침은 F#에도 그대로 적용돼요.

F#에서 예외를 일으키는 데 쓸 수 있는 주요 구성 요소는 선호도 순서대로 이렇게 정리할 수 있어요.

함수 구문 목적
nullArg nullArg "argumentName" 지정한 인자 이름으로 System.ArgumentNullException을 일으켜요.
invalidArg invalidArg "argumentName" "message" 지정한 인자 이름과 메시지로 System.ArgumentException을 일으켜요.
invalidOp invalidOp "message" 지정한 메시지로 System.InvalidOperationException을 일으켜요.
raise raise (ExceptionType("message")) 예외를 던지는 범용 메커니즘이에요.
failwith failwith "message" 지정한 메시지로 System.Exception을 일으켜요.
failwithf failwithf "format string" argForFormatString 형식 문자열과 그 입력값으로 결정되는 메시지로 System.Exception을 일으켜요.

적절한 상황에서는 nullArg, invalidArg, invalidOp를 써서 ArgumentNullException, ArgumentException, InvalidOperationException을 던져요.

failwithfailwithf는 일반적으로 피하는 게 좋아요. 둘 다 기본 Exception 타입을 일으키지, 특정 예외를 일으키지 않거든요. 예외 설계 지침에 따라, 가능하면 더 구체적인 예외를 일으키는 게 좋아요.

예외 처리 구문을 사용해요

F#은 try...with 구문으로 예외 패턴을 지원해요.

try
 tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here

예외 상황에서 수행할 기능을 패턴 매칭과 함께 깔끔하게 맞추는 건 꽤 까다로울 수 있어요. 좋은 방법 하나는 활성 패턴(active patterns)을 써서 오류 케이스 주변의 기능을 예외와 함께 묶는 거예요. 예를 들어, 예외를 던질 때 그 예외의 메타데이터 안에 유용한 정보를 넣어 주는 API를 쓰고 있다고 해볼게요. 활성 패턴 안에서 잡힌 예외의 본문에 담긴 유용한 값을 꺼내서 돌려주는 게 상황에 따라 도움이 돼요.

모나딕 오류 처리를 예외 대체용으로 쓰지 마세요

순수 함수형 패러다임에서는 예외가 금기시되는 경우가 많아요. 실제로 예외는 순수성(purity)을 위반하니까, 함수형 순수성에 썩 잘 맞는다고 보기 어려운 건 맞아요. 그런데 그렇게만 보면 코드가 실제로 돌아가야 할 현실을 무시하게 돼요. 런타임 오류는 생기거든요. 일반적으로는 대부분의 것이 순수하지도, 완전(total)하지도 않다는 전제로 코드를 쓰는 게 좋아요. 그러면 C#에서 빈 catch를 쓰는 것, 스택 트레이스를 제대로 관리하지 못해 정보를 버리는 것 같은 불쾌한 놀라움을 최소화할 수 있어요.

.NET 런타임과 여러 언어가 공존하는 생태계 전체의 맥락에서 볼 때, 예외가 지니는 핵심적인 장점과 측면을 꼽아 보면 이래요.

  • 예외는 상세한 진단 정보를 담고 있어서 문제를 디버깅할 때 도움이 돼요.
  • 런타임과 다른 .NET 언어들이 잘 알고 있는 개념이에요.
  • 예외를 피하려고 그 의미의 일부를 임시방편으로 구현하는 코드에 비해, 상당한 보일러플레이트를 줄여줘요.

이 세 번째가 결정적이에요. 단순하지 않은 복잡한 연산에서 예외를 쓰지 않으면 이런 구조를 대면하게 돼요.

Result<Result<MyType, string>, string list>

그러면 이렇게 "문자열 타입(stringly typed)" 오류에 패턴 매칭하는 깨지기 쉬운 코드로 쉽게 이어져요.

let result = doStuff()
match result with
| Ok r -> ...
| Error e ->
 if e.Contains "Error string 1" then ...
 elif e.Contains "Error string 2" then ...
 else ... // Who knows?

게다가, "더 예쁜" 타입을 반환하는 "단순한" 함수를 만들고 싶어서 아무 예외나 삼키고 싶은 유혹이 들기도 해요.

// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
 try System.IO.File.ReadAllText path |> Some
 with _ -> None

안타깝게도 tryReadAllText는 파일 시스템에서 벌어질 수 있는 수많은 상황 때문에 다양한 예외를 던질 수 있어요. 그런데 이 코드는 환경에서 실제로 뭐가 잘못됐는지에 관한 어떤 정보도 다 버려 버려요. 이 코드를 결과 타입으로 바꾸면 다시 "문자열 타입" 오류 메시지 파싱으로 돌아가게 돼요.

// Problematic, callers only have a string to figure the cause of error.
let tryReadAllText (path : string) =
 try System.IO.File.ReadAllText path |> Ok
 with e -> Error e.Message

let r = tryReadAllText "path-to-file"
match r with
| Ok text -> ...
| Error e ->
 if e.Contains "uh oh, here we go again..." then ...
 else ...

그리고 예외 객체 자체를 Error 생성자에 그냥 넣어 버리면, 함수 안이 아니라 호출 지점에서 예외 타입을 제대로 다루도록 강제할 뿐이에요. 이렇게 하면 사실상 체크 예외(checked exception)를 만들어 내는데, API 호출자 입장에서 체크 예외를 다루는 건 악명 높게 불편해요.

위 예들보다 나은 대안은, 특정 예외를 잡아서 그 예외라는 맥락에서 의미 있는 값을 돌려주는 거예요. tryReadAllText 함수를 이렇게 바꾸면 None이 훨씬 더 의미를 갖게 돼요.

let tryReadAllTextIfPresent (path : string) =
 try System.IO.File.ReadAllText path |> Some
 with :? FileNotFoundException -> None

이 함수는 이제 만능 잡이(catch-all)처럼 동작하는 대신, 파일을 찾지 못한 경우를 제대로 처리해서 그 의미를 반환값에 담아요. 이 반환값은 그 오류 케이스에 대응하면서도, 문맥 정보를 버리지도 않고, 호출자에게 그 시점에 무관한 케이스까지 다루라고 강요하지도 않아요.

Result<'Success, 'Error> 같은 타입은 중첩되지 않은 기본 연산에 적합하고, F# 옵션 타입은 뭔가를 돌려주거나 아무것도 돌려주지 않을 수 있는 경우를 나타내는 데 딱 어울려요. 하지만 이것들은 예외를 대체하는 게 아니에요. 예외를 대체하려고 쓰면 안 되고, 예외·오류 관리 정책의 특정 측면을 겨냥해 신중하게 적용해야 해요.

부분 적용과 point-free 프로그래밍

F#은 부분 적용(partial application)을 지원해서 다양한 방식으로 point-free 스타일로 프로그래밍할 수 있어요. 이는 모듈 안에서 코드를 재사용하거나 무언가를 구현할 때 유용할 수 있지만, 밖으로 공개할 만한 것은 아니에요. 일반적으로 point-free 프로그래밍은 그 자체가 미덕이 아니고, 그 스타일에 익숙하지 않은 사람에게는 상당한 인지 장벽을 추가할 수 있어요.

공개 API에서 부분 적용과 커링을 쓰지 마세요

아주 작은 예외를 빼면, 공개 API에서 부분 적용을 쓰는 건 소비자에게 혼란을 줄 수 있어요. 보통 F# 코드의 let 바인딩 값은 값이지, 함수 값이 아니에요. 값과 함수 값을 섞어 쓰면, 함수를 합성하는 >> 같은 연산자와 결합했을 때, 몇 줄의 코드를 줄이는 대신 꽤 많은 인지 부담을 주고 말아요.

point-free 프로그래밍의 도구 지원 영향을 고려해요

커링된 함수는 인자에 이름을 붙이지 않아요. 이건 도구 지원에 영향을 줘요. 다음 두 함수를 볼게요.

let func name age =
 printfn $"My name is {name} and I am %d{age} years old!"

let funcWithApplication =
 printfn "My name is %s and I am %d years old!"

둘 다 유효한 함수인데, funcWithApplication은 커링된 함수예요. 에디터에서 이 둘의 타입 위에 마우스를 올리면 이렇게 보여요.

val func : name:string -> age:int -> unit

val funcWithApplication : (string -> int -> unit)

호출 지점에서 Visual Studio 같은 도구의 툴팁은 타입 시그니처를 보여주지만, 이름이 정의되어 있지 않으니까 이름은 표시하지 않아요. 이름은 좋은 API 설계에 아주 중요해요. 이름이 있어야 호출자가 API 뒤에 있는 의미를 더 잘 이해할 수 있거든요. 공개 API에 point-free 코드를 쓰면 호출자가 이해하기 더 어려워질 수 있어요.

funcWithApplication처럼 공개해서 쓸 수 있는 point-free 코드를 만나게 되면, 도구가 인자에 의미 있는 이름을 잡아낼 수 있도록 완전한 η-확장(η-expansion)을 하는 걸 권장해요.

또 point-free 코드는 디버깅이 매우 어려울 수 있고, 불가능할 수도 있어요. 디버깅 도구는 이름에 바인딩된 값(let 바인딩 같은 것)에 의존해서, 실행 중간에 있는 중간 값을 검사할 수 있게 해요. 코드에 검사할 값이 없으면 디버깅할 게 없어요. 미래에는 디버깅 도구가 이전에 실행된 경로를 바탕으로 이런 값을 합성해 낼 수도 있겠지만, 잠재적인 디버깅 기능에 기대를 걸어 두는 건 좋은 생각이 아니에요.

부분 적용을 내부 보일러플레이트를 줄이는 기법으로 고려해요

아까와는 대조적으로, 부분 적용은 애플리케이션 내부나 API의 더 깊은 내부에서 보일러플레이트를 줄이는 훌륭한 도구예요. 더 복잡한 API의 구현을 단위 테스트할 때 특히 도움이 되는데, 그런 곳에서는 보일러플레이트가 골칫거리인 경우가 많거든요. 예를 들어, 아래 코드는 대부분의 mocking 프레임워크가 해 주는 일을, 외부 의존성을 갖지 않고 그에 딸린 독자적인 API를 배우지 않고도 해내는 방법을 보여줘요.

다음과 같은 솔루션 구조를 생각해 볼게요.

MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj

ImplementationLogic.fsproj는 이런 코드를 노출할 수도 있어요.

module Transactions =
 let doTransaction txnContext txnType balance =
 ...

type Transactor(ctx, currentBalance) =
 member _.ExecuteTransaction(txnType) =
 Transactions.doTransaction ctx txnType currentBalance
 ...

ImplementationLogic.Tests.fsproj에서 Transactions.doTransaction을 단위 테스트하는 건 쉬워요.

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
 let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

doTransaction을 mock 컨텍스트 객체로 부분 적용해 두면, 매번 mock 컨텍스트를 만드는 수고 없이 모든 단위 테스트에서 그 함수를 호출할 수 있어요.

module TransactionTests

open Xunit
open TransactionTypes
open TransactionsTestingUtil
open TransactionsTestingUtil.TransactionsTestable

let testableContext =
 { new ITransactionContext with
 member _.TheFirstMember() = ...
 member _.TheSecondMember() = ... }

let transactionRoutine = getTestableTransactionRoutine testableContext

[<Fact>]
let ``Test withdrawal transaction with 0.0 for balance``() =
 let expected = ...
 let actual = transactionRoutine TransactionType.Withdraw 0.0
 Assert.Equal(expected, actual)

이 기법을 코드베이스 전체에 무작정 적용하지는 마세요. 다만 복잡한 내부 코드와 그 내부를 단위 테스트할 때 보일러플레이트를 줄이는 좋은 방법이라는 점은 기억해 둘 만해요.

접근 제어 (Access control)

F#에는 .NET 런타임에서 물려받은 접근 제어(Access control) 옵션이 여러 개 있어요. 이건 타입에만 쓸 수 있는 게 아니라 함수에도 쓸 수 있어요.

널리 쓰이는 라이브러리 맥락에서 좋은 관행은 이래요.

  • 타입과 멤버를 공개해서 쓸 수 있게 하기 전까지는 public이 아닌 것으로 두는 게 좋아요. 그러면 소비자가 얽매이는 부분도 최소화돼요.
  • 모든 헬퍼 기능은 private로 유지하려고 노력해요.
  • 헬퍼 함수가 많아지면 헬퍼 함수들의 private 모듈에 [<AutoOpen>]을 붙이는 걸 고려해요.

타입 추론과 제네릭 (Type inference and generics)

타입 추론 덕분에 보일러플레이트를 많이 안 써도 돼요. 또 F# 컴파일러의 자동 일반화(automatic generalization)는 거의 아무 노력 없이 더 제네릭한 코드를 쓸 수 있게 해 줘요. 그런데 이 기능들이 무조건 좋은 건 아니에요.

  • 공개 API에서는 인자 이름에 명시적 타입을 붙이는 걸 고려하고, 타입 추론에 기대지 마세요.

그 이유는 API의 형태(shape)를 컴파일러가 아니라 내가 통제해야 하기 때문이에요. 컴파일러가 타입을 잘 추론해 주긴 하지만, API가 의존하는 내부 요소의 타입이 바뀌면 API의 형태가 바뀔 수 있어요. 그게 원하는 결과일 수도 있지만, 거의 확실히 하위 소비자가 감당해야 할 호환성 깨짐(breaking change)을 만들 거예요. 대신 공개 API의 형태를 명시적으로 통제하면 이런 호환성 깨짐도 통제할 수 있어요. 도메인 주도 설계(DDD) 용어로 말하면 이건 오염 방지 계층(Anti-corruption layer)으로 생각할 수 있어요.

  • 제네릭 인자에 의미 있는 이름을 붙이는 걸 고려해요.

특정 도메인에 묶이지 않는 진짜 제네릭 코드를 쓰는 게 아니라면, 의미 있는 이름이 다른 프로그래머가 자기들이 다루는 도메인을 이해하는 데 도움을 줘요. 예를 들어 문서 데이터베이스와 상호작용하는 맥락에서 'Document라는 타입 파라미터를 쓰면, 그 함수나 멤버가 제네릭한 문서 타입을 받아들일 수 있다는 게 더 분명해져요.

  • 제네릭 타입 파라미터는 PascalCase로 이름 짓는 걸 고려해요.

이건 .NET에서 일반적으로 하는 방식이라, snake_case나 camelCase보다는 PascalCase를 쓰길 권장해요.

마지막으로, 자동 일반화가 F#이나 거대한 코드베이스에 막 입문한 사람에게 항상 축복인 것은 아니에요. 제네릭인 컴포넌트를 쓰는 데는 인지 부담이 있어요. 게다가 자동 일반화된 함수를 서로 다른 입력 타입으로 쓰지 않는다면(그럴 의도로 쓰는 것도 아니라면), 제네릭으로서의 실질적인 이점이 없어요. 쓸 코드가 실제로 제네릭 덕을 볼지 항상 생각해 보세요.

성능 (Performance)

할당이 잦은 작은 타입에는 struct를 고려해요

구조체(struct, 값 타입 Value Type이라고도 해요)를 쓰면 객체 할당을 피하는 경우가 많아서 일부 코드에서 성능이 더 좋아지곤 해요. 그런데 struct가 항상 "더 빨라지는 버튼"은 아니에요. struct 안의 데이터가 16바이트를 넘으면 값을 복사하는 데 참조 타입을 쓸 때보다 CPU 시간을 더 쓸 수 있거든요.

struct를 써야 할지 판단할 때는 다음 조건을 고려해요.

  • 데이터 크기가 16바이트 이하인지.
  • 실행 중인 프로그램에서 이런 타입의 인스턴스가 메모리에 여러 개 있을 가능성이 큰지.

첫 번째 조건만 해당하면 보통 struct를 쓰면 돼요. 두 조건이 모두 해당하면 거의 항상 struct를 써야 해요. 위 조건이 해당하지만 struct를 써도 참조 타입과 비교해 낫지도 나쁘지도 않은 경우도 있을 수 있는데, 그런 경우는 드물 거예요. 이렇게 변경할 때는 항상 측정을 해야 해요. 가정이나 직관에 기대면 안 되고요.

작은 값 타입을 묶을 때 struct tuple을 고려해요

다음 두 함수를 볼게요.

let rec runWithTuple t offset times =
 let offsetValues x y z offset =
 (x + offset, y + offset, z + offset)

 if times <= 0 then
 t
 else
 let (x, y, z) = t
 let r = offsetValues x y z offset
 runWithTuple r offset (times - 1)

let rec runWithStructTuple t offset times =
 let offsetValues x y z offset =
 struct(x + offset, y + offset, z + offset)

 if times <= 0 then
 t
 else
 let struct(x, y, z) = t
 let r = offsetValues x y z offset
 runWithStructTuple r offset (times - 1)

BenchmarkDotNet 같은 통계적 벤치마킹 도구로 이 함수들을 측정해 보면, struct tuple을 쓰는 runWithStructTuple 함수가 40% 더 빠르고 메모리를 전혀 할당하지 않는 걸 발견할 수 있어요.

하지만 이런 결과가 내 코드에서도 항상 그런 건 아니에요. 함수를 inline으로 표시하면 참조 tuple을 쓰는 코드가 추가 최적화를 얻을 수도 있고, 할당하던 코드가 그냥 최적화되어 사라질 수도 있어요. 성능이 관여할 때는 항상 결과를 측정하고, 가정이나 직관에 기대면 안 돼요.

타입이 작고 할당이 잦으면 struct record를 고려해요

아까 말한 경험칙은 F# 레코드 타입에도 그대로 적용돼요. 다음 데이터 타입과 그걸 처리하는 함수들을 볼게요.

type Point = { X: float; Y: float; Z: float }

[<Struct>]
type SPoint = { X: float; Y: float; Z: float }

let rec processPoint (p: Point) offset times =
 let inline offsetValues (p: Point) offset =
 { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

 if times <= 0 then
 p
 else
 let r = offsetValues p offset
 processPoint r offset (times - 1)

let rec processStructPoint (p: SPoint) offset times =
 let inline offsetValues (p: SPoint) offset =
 { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

 if times <= 0 then
 p
 else
 let r = offsetValues p offset
 processStructPoint r offset (times - 1)

앞의 tuple 코드와 비슷하지만, 이번 예시는 record와 인라인된 내부 함수를 써요.

이 함수들을 BenchmarkDotNet 같은 도구로 측정해 보면, processStructPoint가 거의 60% 더 빠르고 관리 힙(managed heap)에는 아무것도 할당하지 않는다는 걸 알 수 있어요.

데이터 타입이 작고 할당이 잦으면 struct 구별된 공용체를 고려해요

앞서 struct tuple과 record에서 본 성능 관찰은 F# 구별된 공용체에도 그대로 적용돼요. 다음 코드를 볼게요.

 type Name = Name of string

 [<Struct>]
 type SName = SName of string

 let reverseName (Name s) =
 s.ToCharArray()
 |> Array.rev
 |> System.String
 |> Name

 let structReverseName (SName s) =
 s.ToCharArray()
 |> Array.rev
 |> System.String
 |> SName

이렇게 단일 케이스 구별된 공용체를 도메인 모델링에 쓰는 건 흔한 일이에요. 이 함수들을 BenchmarkDotNet 같은 도구로 측정해 보면, 작은 문자열에 대해 structReverseNamereverseName보다 약 25% 더 빠르게 돌아요. 큰 문자열에서는 둘이 비슷하게 동작해요. 그래서 이 경우에는 항상 struct를 쓰는 게 좋아요. 앞서 말했듯이 항상 측정하고, 가정이나 직관에 기대면 안 돼요.

앞의 예시에서 struct 구별된 공용체가 더 나은 성능을 보여 줬지만, 도메인을 모델링할 때는 더 큰 구별된 공용체를 갖는 게 흔해요. 그런 커다란 데이터 타입은 struct로 만들면 그에 대한 연산에 따라 성능이 떨어질 수 있어요. 복사가 더 많이 일어날 수 있기 때문이에요.

불변성과 변경 (Immutability and mutation)

F# 값은 기본적으로 불변이에요. 그래서 특정 부류의 버그(특히 동시성과 병렬성에 관련된 것)를 피할 수 있어요. 다만 어떤 경우에는 실행 시간이나 메모리 할당의 최적(아니면 그냥 합리적인) 효율을 얻으려면 상태를 제자리에서 변경(in-place mutation)하는 방식이 최선일 때가 있어요. F#에서는 mutable 키워드로 이를 선택적으로(opt-in) 쓸 수 있어요.

F#에서 mutable을 쓰는 건 함수형 순수성과 어긋나는 것처럼 느껴질 수 있어요. 그런 느낌이 들 만해요. 그런데 어디서나 함수형 순수성을 고집하면 성능 목표와 충돌할 수 있어요. 절충안은 변경을 캡슐화해서, 호출자가 함수를 호출할 때 무슨 일이 벌어지는지 신경 쓰지 않아도 되게 하는 거예요. 이렇게 하면 성능이 중요한 코드에서 변경 기반 구현 위에 함수형 인터페이스를 쓸 수 있어요.

또한 F# let 바인딩 구성은 바인딩을 다른 바인딩 안에 중첩할 수 있게 해 줘요. 이걸 활용하면 mutable 변수의 스코프를 이론상 가장 작게, 아니면 아주 가깝게 유지할 수 있어요.

let data =
 [
 let mutable completed = false
 while not completed do
 logic ()
 // ...
 if someCondition then
 completed <- true
 ]

data let 바인딩 값을 초기화하는 데만 쓰인 mutable한 completed에는 어떤 코드도 접근할 수 없어요.

변경 코드를 불변 인터페이스로 감싸요

참조 투명성(referential transparency)을 목표로 삼으려면, 성능이 중요한 함수의 변경(mutable) 속을 밖에 드러내지 않는 코드를 쓰는 게 아주 중요해요. 예를 들어 다음 코드는 F# 핵심 라이브러리의 Array.contains 함수를 구현한 거예요.

[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
 checkNonNull "array" array
 let mutable state = false
 let mutable i = 0
 while not state && i < array.Length do
 state <- value = array[i]
 i <- i + 1
 state

이 함수를 여러 번 호출해도 밑바탕의 배열은 변하지 않고, 소비하는 쪽에서 어떤 mutable 상태도 유지할 필요가 없어요. 함수 내부의 거의 모든 줄이 변경을 쓰는데도, 이 함수는 참조 투명해요.

변경 데이터를 클래스로 캡슐화하는 걸 고려해요

앞 예시는 mutable 데이터를 써서 연산을 캡슐화할 때 함수 하나를 썼어요. 그런데 더 복잡한 데이터 집합에는 함수 하나로 충분하지 않을 때가 있어요. 다음 함수 집합을 볼게요.

open System.Collections.Generic

let addToClosureTable (key, value) (t: Dictionary<_,_>) =
 if t.ContainsKey(key) then
 t[key] <- value
 else
 t.Add(key, value)

let closureTableCount (t: Dictionary<_,_>) = t.Count

let closureTableContains (key, value) (t: Dictionary<_, HashSet<_>>) =
 match t.TryGetValue(key) with
 | (true, v) -> v.Equals(value)
 | (false, _) -> false

이 코드는 성능이 좋지만, 호출자가 직접 유지·보수해야 하는 변경 기반 데이터 구조를 드러내요. 이걸 안쪽에 바뀔 수 있는 멤버가 없는 클래스로 감쌀 수 있어요.

open System.Collections.Generic

/// The results of computing the LALR(1) closure of an LR(0) kernel
type Closure1Table() =
 let t = Dictionary<Item0, HashSet<TerminalIndex>>()

 member _.Add(key, value) =
 if t.ContainsKey(key) then
 t[key] <- value
 else
 t.Add(key, value)

 member _.Count = t.Count

 member _.Contains(key, value) =
 match t.TryGetValue(key) with
 | (true, v) -> v.Equals(value)
 | (false, _) -> false

Closure1Table은 밑바탕의 변경 기반 데이터 구조를 캡슐화해서, 호출자가 그 데이터 구조를 유지·보수하지 않아도 되게 해요. 클래스는 변경 기반 데이터와 루틴을 세부 사항을 밖에 드러내지 않고 캡슐화하는 강력한 방법이에요.

ref보다 let mutable를 선호해요

참조 셀(reference cell)은 값 자체가 아니라 값에 대한 참조를 나타내는 방법이에요. 성능이 중요한 코드에 쓸 수는 있지만 권장되지는 않아요. 다음 예시를 볼게요.

let kernels =
 let acc = ref Set.empty

 processWorkList startKernels (fun kernel ->
 if not ((!acc).Contains(kernel)) then
 acc := (!acc).Add(kernel)
 ...)

 !acc |> Seq.toList

참조 셀을 쓰면 뒤따르는 모든 코드가 밑바탕 데이터를 역참조하고 다시 참조해야 해서 "오염"돼요. 대신 let mutable을 고려해 보세요.

let kernels =
 let mutable acc = Set.empty

 processWorkList startKernels (fun kernel ->
 if not (acc.Contains(kernel)) then
 acc <- acc.Add(kernel)
 ...)

 acc |> Seq.toList

람다 표현식 중간에 있는 변경 지점 하나만 빼면, acc를 다루는 다른 모든 코드는 평범한 let 바인딩 불변 값을 쓸 때와 다를 바 없어요. 이러면 시간이 지나도 바꾸기가 더 쉬워져요.

Null과 기본값 (Nulls and default values)

F#에서는 일반적으로 null을 피해야 해요. 기본적으로 F#으로 선언한 타입은 null 리터럴을 지원하지 않고, 모든 값과 객체는 초기화돼요. 그런데 흔한 .NET API 중에는 null을 반환하거나 받아들이는 것들이 있고, 배열이나 문자열 같은 흔한 .NET 선언 타입은 null을 허용해요. 다만 F# 프로그래밍에서 null 값이 나타나는 일은 매우 드물고, F#을 쓰는 이점 중 하나가 대부분의 경우 null 참조 오류를 피할 수 있다는 거예요.

AllowNullLiteral 특성은 피해요

기본적으로 F#으로 선언한 타입은 null 리터럴을 지원하지 않아요. F# 타입에 AllowNullLiteral을 직접 붙여서 이를 허용할 수는 있지만, 그렇게 하는 것보다는 거의 항상 피하는 게 나아요.

Unchecked.defaultof<_> 특성은 피해요

Unchecked.defaultof<_>를 쓰면 F# 타입에 대해 null 또는 0으로 초기화된 값을 만들 수 있어요. 일부 데이터 구조의 저장 공간을 초기화하거나, 일부 고성능 코딩 패턴, 또는 상호운용성에서 유용할 수 있어요. 하지만 이 구성은 피하는 게 좋아요.

DefaultValue 특성은 피해요

기본적으로 F# record와 객체는 생성 시 제대로 초기화되어야 해요. DefaultValue 특성은 객체의 일부 필드를 null 또는 0으로 초기화된 값으로 채우는 데 쓸 수 있어요. 이 구성은 좀처럼 필요하지 않으니 피하는 게 좋아요.

null 입력을 검사한다면 기회가 되는 대로 즉시 예외를 일으켜요

새 F# 코드를 쓸 때는, 그 코드를 C#이나 다른 .NET 언어에서 쓸 걸 예상하지 않는 한 null 입력을 검사할 필요가 없는 게 실무적으로 맞아요.

그래도 null 입력 검사를 넣기로 마음먹었다면, 기회가 되는 대로 검사를 하고 예외를 일으켜요. 예를 들면 이렇게요.

let inline checkNonNull argName arg =
 if isNull arg then
 nullArg argName

module Array =
 let contains value (array:'T[]) =
 checkNonNull "array" array
 let mutable result = false
 let mutable i = 0
 while not state && i < array.Length do
 result <- value = array[i]
 i <- i + 1
 result

이전 버전과의 호환성 때문에 FSharp.Core의 일부 문자열 함수는 여전히 null을 빈 문자열로 취급하고 null 인자에서 실패하지 않아요. 하지만 이걸 지침으로 받아들이면 안 되고, "null"에 어떤 의미론적 의미를 부여하는 코딩 패턴도 채택하면 안 돼요.

API 경계에서 F# 9 null 구문을 활용해요

F# 9는 값이 null일 수 있음을 명시적으로 나타내는 구문을 추가했어요. API 경계에서 쓰도록 설계된 것으로, 컴파일러가 null 처리가 빠진 곳을 알려 주게 하려는 거예요.

이 구문의 올바른 사용 예시는 이래요.

type CustomType(m1, m2) =
 member _.M1 = m1
 member _.M2 = m2

 override this.Equals(obj: obj | null) =
 match obj with
 | :? CustomType as other -> this.M1 = other.M1 && this.M2 = other.M2
 | _ -> false

 override this.GetHashCode() =
 hash (this.M1, this.M2)

F# 코드 더 아래로 null을 전파하는 건 피해요.

let getLineFromStream (stream: System.IO.StreamReader) : string | null =
 stream.ReadLine()

대신 options 같은 관용적인 F# 방식을 써요.

let getLineFromStream (stream: System.IO.StreamReader) =
 stream.ReadLine() |> Option.ofObj

null 관련 예외를 일으키기 위해 특별한 nullArgChecknonNull 함수를 쓸 수 있어요. 이 함수들은 값이 null이 아닐 때 인자를 그 정제된 값으로 가려서(shadow), 이후의 코드가 가능한 null 포인터에 접근하지 못 하게 한다는 점에서도 편리해요.

let inline processNullableList list =
 let list = nullArgCheck (nameof list) list // throws `ArgumentNullException`
 // 'list' is safe to use from now on
 list |> List.distinct

let inline processNullableList' list =
 let list = nonNull list // throws `NullReferenceException`
 // 'list' is safe to use from now on
 list |> List.distinct

객체 프로그래밍 (Object programming)

F#은 객체와 객체 지향(OO) 개념을 완전히 지원해요. 많은 OO 개념은 강력하고 유용하지만, 그 모두가 쓰기에 이상적인 건 아니에요. 아래 목록은 OO 기능의 부류를 높은 수준에서 안내해 줘요.

많은 상황에서 사용을 고려할 기능들:

  • 점 표기법 (x.Length)
  • 인스턴스 멤버
  • 암시적 생성자
  • 정적 멤버
  • 인덱서 표기법 (arr[x]) — Item 속성을 정의해서 쓰는 방식
  • 슬라이싱 표기법 (arr[x..y], arr[x..], arr[..y]) — GetSlice 멤버를 정의해서 쓰는 방식
  • 명명된 인자와 선택적 인자
  • 인터페이스와 인터페이스 구현

이 기능들을 먼저 꺼내 들진 말되, 문제를 푸는 데 편리할 때 신중하게 적용하는 게 좋은 것들:

  • 메서드 오버로딩
  • 캡슐화된 변경 데이터
  • 타입에 대한 연산자
  • 자동 속성(Auto properties)
  • IDisposableIEnumerable 구현
  • 타입 확장
  • 이벤트
  • 구조체(Struct)
  • 대리자(Delegate)
  • 열거형(Enum)

쓸 수밖에 없는 경우가 아니면 일반적으로 피해야 할 기능들:

  • 상속 기반 타입 계층 구조와 구현 상속
  • Null과 Unchecked.defaultof<_>

상속보다 합성을 선호해요

합성이 상속보다 낫다(Composition over inheritance)는 오래된 관용구로, 좋은 F# 코드가 따를 수 있는 원칙이에요. 핵심 원칙은, 기능을 얻으려고 호출자가 기본 클래스에서 상속하도록 강제할 베이스 클래스를 노출하지 말라는 거예요.

클래스가 필요 없을 때는 객체 식으로 인터페이스를 구현해요

객체 식(Object Expressions)을 쓰면 인터페이스를 그 자리에서 구현하고, 구현한 인터페이스를 클래스 안에서가 아니라 값에 바인딩할 수 있어요. 인터페이스만 구현하면 되고 완전한 클래스가 필요 없는 경우 특히 편리해요.

예를 들어, 다음은 Ionide에서, open 문이 없는 심볼을 추가했을 때 코드 수정(code fix) 동작을 제공하기 위해 실행되는 코드예요.

 let private createProvider () =
 { new CodeActionProvider with
 member this.provideCodeActions(doc, range, context, ct) =
 let diagnostics = context.diagnostics
 let diagnostic = diagnostics |> Seq.tryFind (fun d -> d.message.Contains "Unused open statement")
 let res =
 match diagnostic with
 | None -> [||]
 | Some d ->
 let line = doc.lineAt d.range.start.line
 let cmd = createEmpty<Command>
 cmd.title <- "Remove unused open"
 cmd.command <- "fsharp.unusedOpenFix"
 cmd.arguments <- Some ([| doc |> unbox; line.range |> unbox; |] |> ResizeArray)
 [|cmd |]
 res
 |> ResizeArray
 |> U2.Case1
 }

Visual Studio Code API와 상호작용할 때는 클래스가 필요 없으니, 객체 식이 이 작업에 딱 맞는 도구예요. 단위 테스트에서 인터페이스를 임시로 테스트 루틴으로 대체(stub)하고 싶을 때도 유용해요.

시그니처를 줄이려고 타입 약칭을 고려해요

타입 약칭(Type Abbreviations)은 함수 시그니처나 더 복잡한 타입 같은 다른 타입에 이름표를 붙이는 편리한 방법이에요. 예를 들어, 다음 별칭은 딥러닝 라이브러리인 CNTK로 계산을 정의하는 데 필요한 것에 이름표를 붙여 줘요.

open CNTK

// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function

Computation이라는 이름은 자신이 가리키는 시그니처와 일치하는 어떤 함수든 나타내는 편리한 표기예요. 이렇게 타입 약칭을 쓰면 편리하고 더 간결한 코드를 쓸 수 있어요.

도메인을 표현하는 데 타입 약칭을 쓰는 건 피해요

타입 약칭은 함수 시그니처에 이름을 붙이는 데는 편리하지만, 다른 타입을 약칭할 때는 혼란을 줄 수 있어요. 다음 약칭을 볼게요.

// Does not actually abstract integers.
type BufferSize = int

이건 여러모로 혼란을 줄 수 있어요.

  • BufferSize는 추상화가 아니에요. 그냥 정수에 붙인 다른 이름일 뿐이에요.
  • BufferSize가 공개 API에 노출되면 단지 int 이상의 뭔가를 의미한다고 오해받기 쉬워요. 일반적으로 도메인 타입은 여러 속성을 갖고 있고 int 같은 기본 타입이 아니에요. 이 약칭은 그 가정을 어겨요.
  • BufferSize의 대문자 표기(PascalCase)는 이 타입이 더 많은 데이터를 담을 것처럼 보이게 해요.
  • 이 별칭은 함수에 명명된 인자를 제공하는 것에 비해 더 큰 명확성을 주지 않아요.
  • 이 약칭은 컴파일된 IL에 나타나지 않아요. 그냥 정수이고, 이 별칭은 컴파일 타임 구성일 뿐이에요.
module Networking =
 ...
 let send data (bufferSize: int) = ...

요약하면, 타입 약칭의 함정은 그것이 약칭 대상 타입에 대한 추상화가 아니라는 점이에요. 앞의 예시에서 BufferSize는 실상은 그냥 int이고, 추가 데이터도 없고, int 이미 가진 것 이상의 타입 시스템 혜택도 없어요.

도메인을 표현하는 데 타입 약칭을 쓰는 대안은 단일 케이스 구별된 공용체를 쓰는 거예요. 앞의 예시는 이렇게 모델링할 수 있어요.

type BufferSize = BufferSize of int

BufferSize와 그 밑바탕 값의 관점에서 동작하는 코드를 짠다면, 아무 정수나 건네는 게 아니라 하나를 생성해서 써야 해요.

module Networking =
 ...
 let send data (BufferSize size) =
 ...

이렇게 하면 send 함수에 임의의 정수를 실수로 건네줄 가능성이 줄어들어요. 호출자가 값을 감싸려면 반드시 BufferSize 타입을 만들어야 하거든요.

더 알아보기