F#으로 웹서버 API 만들기

틈틈이 살펴 볼 예정입니다.

8 Likes

1. 프로젝트 생성하기

 dotnet new web -lang f#  --no-https

프로젝트를 생성하면 매우 단순한 샘플 코드가 작성되어 있습니다.

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapGet("/", Func<string>(fun () -> "Hello World!")) |> ignore

    app.Run()

    0 // Exit code

F# 은 Delegate를 암시적으로 생성할 수 없습니다. 좀 더 코드를 줄여 본다면 다음과 같이 작성 할 수 있습니다.

#nowarn "0020"
...
app.MapGet("/", Func<_>(fun () -> "Hello World!")) 

하지만 동일한 C# 코드에 비해 장황해 지기 때문에 F# 커뮤니티는 오랜 기간 독립적인 웹 프레임워크를 구축했습니다.

asp.net core의 강력한 미들웨어 기능은 Giraffe 와 같은 거대한 커뮤니티 주도 프레임워크를 작성 할 수 있게 해 주었습니다. 다만 별개의 미들웨어로 개발된 Giraffe는 Asp.Net Core 버전 업데이트의 혜택을 누리기 어렵습니다.

Oxpecker 저자는 Giraffe의 유지 관리 상태에 대해 비판하였는데 오픈소스의 마지막을 지켜보는 거 같아 매우 안타깝네요,

2 Likes

2. Computation Expressions 기반 웹서버 구성하기

dotnet add package FSharp.AspNetCore.WebAppBuilder

f# 개발자는 CE(계산표현식) 작성을 즐깁니다. 계산표현식은 C#의 linq 쿼리식가 유사하지만 장점이 더 많습니다. 개발자는 CE 내부에서 사용할 키워드를 만들 수 있습니다. 이는 키워드에 매우 제약을 두는 로슬린 컴파일러를 우회하여 DSL을 작성하는 방법을 열어줍니다.

기존 샘플 코드는 다음과 같이 리펙토링 할 수 있습니다.

open FSharp.AspNetCore.Builder

[<EntryPoint>]
let main _ =
    let app = webApp {
        get "/" (fun () -> "Hello World!")
    }

    app.Run()
    0 // Exit code

Asp.Net Core 의 Endpoint는 비동기로 작성 되어야 합니다.

일반적인 비동기CE와 Results 팩토리를 사용해서 코드를 수정해 봅니다.

open FSharp.AspNetCore.Builder
open Microsoft.AspNetCore.Http

[<EntryPoint>]
let main _ =
    let app = webApp {
        get "/" (fun () -> task {
            return Results.Ok("Hello World")
        })
    }

    app.Run()
    0 // Exit code

task CE는 F# 6에 추가되었으며 덕분에 C# API와 상호 호환성이 매우 좋아졌습니다. F# 6 이전 방식으로 작성한다면 다음과 같이 작성할 수 있습니다.

open FSharp.AspNetCore.Builder  
open Microsoft.AspNetCore.Http  

[<EntryPoint>]  
let main _ =  
   let app = webApp {  
       get "/" (fun () -> Async.StartAsTask <| async {  
           return Results.Ok("Hello World")  
       })  
   }  

   app.Run()  
   0 // Exit code

async CE를 사용하는 경우 퍼포먼스 차이가 발생합니다. F# 6 이후 task를 쓰는것이 올바른 작성 방법입니다.

3 Likes

3. 외부 REST API 서비스 호출하기

외부 서비스를 호출하는 코드를 작성해 보겠습니다. 마이크로 서비스를 구성하면 API Gateway를 작성하게 되고 대부분 로직을 외부 서비스에 위임하여 처리하게 됩니다.

외부 서비스를 호출할 때 사용할 명명된 클라이언트를 DI로 준비합니다.

open Microsoft.Extensions.DependencyInjection
...

[<EntryPoint>]
let main _ =
    let app = webApp {
        services (fun services _ ->
            services.AddHttpClient("reqres", fun (http: HttpClient) ->
                http.BaseAddress <- Uri("https://reqres.in")
            ).AddAsKeyed()
        )
...

AddAsKeyed()는 .NET 9에 들어왔는데요. 사용하는게 유지 보수에 유리할 수 있습니다.

POST 호출을 다른 마이크로 서비스에 위임하는 코드를 추가해 보겠습니다.

open System.Net.Http.Json
...

[<EntryPoint>]
let main _ =
    let app = webApp {
        ...
        
        post "/api/v2/users" (fun (httpContext: HttpContext) -> backgroundTask {
            use httpClient = httpContext.RequestServices.GetRequiredKeyedService<HttpClient>("reqres")
            let! response = httpClient.PostAsJsonAsync("/api/users", {|
                Hello = "World"
            |})
            let! stream = response.Content.ReadAsStreamAsync()
            return Results.Stream(stream, contentType= $"{response.Content.Headers.ContentType}")
        })
    }

F#의 backgroundTaskBuilder는 호출하는 비동기 메소드를 ConfigureAwait(false)로 처리합니다. 따라서 UI Thread와 무관한 작업이라면 backgroundTask {}로 작성하는게 좋습니다.

api.http 파일을 만들어 쉽게 테스트해 볼 수 있습니다.

POST http://localhost:5013/api/v2/users
Content-Type: application/json

{}
{
  "hello": "World",
  "id": "359",
  "createdAt": "2025-04-02T11:13:30.828Z"
}
2 Likes

3. 외부 REST API 서비스 호출하기 (이어서…)

POST 끝점에 DTO를 전달 할 수 있습니다.
전달 받을 record type을 선언합니다.

type UserRequestDto = {
    Name : string
    PhoneNumber : string
}

F#이 버전 업데이트 하면서 [<CLIMutable>] 어트리뷰트 없이도 역직렬화가 가능해졌습니다. 언제부터 가능했는지는 찾기 어렵군요. 정말 마음에 듭니다.

코드를 좀 더 고쳐 보겠습니다.

[<EntryPoint>]
let main _ =
    let app = webApp {
        ...
        
        post "/api/v2/users" (fun (httpContext: HttpContext) (req: UsersRequestDto) -> backgroundTask {
            use httpClient = httpContext.RequestServices.GetRequiredKeyedService<HttpClient>("reqres")

            let! response = httpClient.PostAsJsonAsync("/api/users", {|
                Name = req.Name
                PhoneNumber = req.PhoneNumber
            |})

            let! stream = response.Content.ReadAsStreamAsync()
            return Results.Stream(stream, contentType= $"{response.Content.Headers.ContentType}")
        })
    }

다음 요청에 대해 정상적으로 응답을 받을 수 있습니다.

POST http://localhost:5013/api/v2/users
Content-Type: application/json

{
    "name": "John Doe",
    "phoneNumber": "821000000000"
}

---

application/json; charset=utf-8, 98 bytes
{
  "name": "John Doe",
  "phoneNumber": "821000000000",
  "id": "334",
  "createdAt": "2025-04-04T06:29:45.398Z"
}

도메인 기반 타입 선언을 DTO에 반영해 보겠습니다. UserRequestDto를 수정해 봅니다.

type Name = Name of string
type InternationalPhoneNumber = InternationalPhoneNumber of string

type UsersRequestDto = {
    Name: Name
    PhoneNumber : InternationalPhoneNumber 
}

type Name = Name of stringtype Name = | Name of string을 줄여 쓴 표현이고 type Name = string과는 다릅니다.

수정한 코드는 F# 컴파일에 성공하지만 런타임에서 역직렬화에 실패합니다.
DU 에 대한 역질렬화는 F# CLR에 동작이 정의되어 있지 않습니다.

System.NotSupportedException: F# discriminated union serialization is not supported. Consider authoring a custom converter for the type. 

DU에 대한 직렬화를 위해 별개의 라이브러리를 추가합니다.

dotnet add package FSharp.SystemTextJson

(작성중…)

2 Likes