bracket과 resource-pool

Eunmin Kim·2022년 6월 18일
2

하스켈 일기

목록 보기
9/12
post-thumbnail

지난 금요일은 미루고 있던 mongoDB 패키지에서 연결 관리를 어떻게 하는지 살펴보기로 했다. 일단은 잘 동작하고 기능 개발도 바쁘기 때문에 나중에 살펴보려고 했지만 개발 중에도 가끔 데이터베이스에 연결이 끊겨 불편함이 있었기 때문에 살펴봤다.

mongoDB 패키지는 연결을 하면 Pipe라고 하는 값을 준다. 문서에 따르면 여러 스레드에서 동시성 문제없이 응답을 기다리지 않고 연결 하나에 여러 요청을 보낼 수 있다. 그리고 응답은 보낸 순서대로 받을 수 있다고 한다. 내부적으로 퓨처와 프로미스로 구현되어 있다고 한다. 보통 과거에 사용하던 데이터베이스 관련 라이브러리들은 사용하는 라이브러리에 마다 커넥션 풀 기능을 지원하고 있었기 때문에 mongoDB 패키지도 커넥션 풀을 지원할 것이라고 생각했다. 일단 다소 모호한 설명을 보고 Pipe가 동시에 여러 데이터베이스 쿼리를 지원하는지 궁금해서 확인해보기로 했다. 원래 코드는 mongoDB 예제에 있는 기본 형태의 코드이다.

run :: Mongo r m => Action m a -> m a
run action = do
  State Config{configDatabaseName} pipe <- asks getter
  access pipe master configDatabaseName action

run 함수는 mongoDB 보통 쿼리 타입인 Action m a 값을 인자로 받아 Reader 모나드로 가져온 미리 연결을 만들어 둔 pipe 연결을 사용해 쿼리를 실행해 결과를 받아온다. pipe는 애플리케이션 시작할 때 다음 예제와 비슷하게 만들어 둔 유일한 연결이다.

start :: IO
start = do 
	pipe <- connect (host "127.0.0.1")
    ...

물론 서비스 코드는 이렇게 단순하지 않다. mongoDB를 직접 설치해서 운형 할 능력도 시간도 없는 스타트업이기 때문에 mongoDB cloud를 쓰고 있다. 또 연결에 필요한 값들도 환경 변수로 관리하기 때문에 코드에 할 일이 더 있다. 아무튼 중요한 것은 연결을 하나이고 연결을 끊는 것은 애플리케이션에 셧다운 시그널 핸들러에서 처리하고 있다.

코드를 분석해보는 것이 동작을 제대로 알 수 있는 방법이지만 코드는 나중에 보고 먼저 확인해보기로 했다. 1초 정도 걸리는 쿼리가 있는 API 요청 10개를 동시에 보냈을 때 예상되는 시간을 생각해보기로 했다. Pipe라는 것이 (뭔가 이상하지만 내가 모르는 신세계가 있을지도 모르기 때문에) 동시에 10개 요청을 응답 없이 비동기로 보낼 수 있다면 1초 조금 넘게 걸릴 것이다. 하지만 그렇지 않을 것이라고 생각했다. 확인해보자.

보통 간단하게 확인할 때는 ab(아파치 벤치마킹 커맨드 라인 도구)로 동시 요청을 보낼 수 있기 때문에 ab로 빠르게 확인해보기로 했다. 하지만 먼저 1초 정도 걸리는 쿼리를 만들어야 하는데, 다행히 검색해보니 mongoDB에 sleep 함수를 이용하면 되는 것 같았다.

Mongo.findOne [ "$where" =: "function() { return sleep(1000) || true }", "_id" =: userId ]

ab로 다음과 같이 API를 불러 봤다.

ab -c10 -n10 -pslow.json -T'application/json' -H"Authorization: Bearer ******" http://127.0.0.1:3000/graphql

결과는 예상대로? 10초 조금 넘게 걸렸다. 아마 문서에 나온 대로 요청을 비동기로 여러 개 보낼 수 있지만 응답은 순서대로 받기 때문에 이렇게 동작하는 것이 맞다. 혹시 뭔가 Pipe가 내부적으로 연결을 매번 맺을까 해서 mongoDB 서버 쪽에 현재 연결된 연결 수를 확인해 봤지만 연결은 하나만 유지하고 있었다.

> db.serverStatus().connections
{
	"current" : 2, // 하나는 콘솔 연결
    ...

의심이 많기 때문에 삽질을 방지하기 위해서 그럴 일은 거의 없겠지만 웹 서버에 현재 요청 수도 확인해보면 좋겠다고 생각해서 ab를 실행하는 동안 현재 요청 수를 확인해봤다. 응답이 가기 전에 요청 수는 당연히 10개로 특별한 것은 없었다. 웹 서버 모니터링에 대해서는 다음에 다시 다뤄야 하겠지만, 다른 웹 애플리케이션과 비슷하다. 자바의 웹 애플리케이션이 서블릿 프로토콜을 사용하듯이 하스켈 웹 애플리케이션은 대부분 WAI라고 하는 프로토콜을 사용하고 있다. 물론 WAI를 사용하지 않는 웹 프레임워크도 있다. 아무튼 WAI를 사용하고 있다면 wai-middleware-prometheus를 추가해서 애플리케이션에서 prometheus 메트릭스를 제공할 수 있다. 내용이 산으로 갈 뻔했지만 정신 차리고 mongoDB 패키지로 돌아가 보자.

Pipe에 대해 문서를 조금 더 살펴보니 Pipe의 목적은 mongoDB에서 지원하는 Cursor를 잘 지원하려고 만든 것 같다. 또 오래된 하스켈 mongoDB 패키지 버전에서는 자체 Pool을 지원했다가 없앤 흔적도 있었다. 없앤 커밋 로그에는 대신 resource-pool를 사용하는 것을 언급한 내용이 있었다. mongoDB 패키지 코드도 조금 보고 내린 결론은 Pipe는 그냥 단일 연결로 보는 것이 목적에 맞는 것 같았다. 그리고 자주 참고하고 있는 Practical Web Development with Haskell 책에서도 resource-pool 패키지로 커넥션 풀 관리를 하고 있었다.

커넥션 풀을 사용하는 이유는 연결을 맺고 끊는 비용을 아낄 수 있지만 애플리케이션의 연결 수를 제한할 필요가 있기 때문에 매번 연결을 맺고 끊고 하는 방식은 사용하지 않기로 했다. resource-pool을 적용하기 전에 당연한 것을 확인해보기로 했다. 매 쿼리에 연결을 맺고 끊고 하면 ab로 확인했던 동시 요청 지원이 가능한지 빠르게 확인해봤다. 하스켈에서는 리소스 Loan pattern을 사용할 때 bracket 함수를 사용한다. bracket으로 매번 연결을 하는 방식으로 바꾼 코드의 예제는 다음과 같다.

run :: (Mongo r m) => Action m a -> m a
run action = do
  State config _ <- asks getter
  bracket (createPipe config) closePipe (handler config)
  where
    createPipe Config{configHost} = do
      liftIO $ connect (host configHost)

    closePipe pipe = do
      liftIO $ Mongo.close pipe

    handler Config{configDatabaseName} pipe = do
      access pipe master configDatabaseName action

bracket 함수는 인자를 3개 받는데 첫 번째 인자는 리소스 할당, 두 번째 인자는 리소스 해제, 세 번째 인자가 리소스를 사용해 뭔가를 하는 함수이다. 이렇게 매번 연결하도록 만들어서 ab로 확인해보면 동시 10개를 요청했을 때 1초가 조금 넘게 걸렸다. 그냥 심심풀이로 해본 것이고 이제 resource-pool로 커넥션 풀을 만들어보자.

resource-pool도 간단한 함수 몇 개만 알면 쓰기 어렵지 않다. createPool 함수로 리소스 풀을 만들고 withResource로 사용하면 된다. createPool은 다음과 만든다.

pool <- createPool (createPipe config) Mongo.close 1 0.5 10

bracket과 비슷한 인자로 풀을 만들 수 있다. 뒤에 숫자 1, 0.5, 10은 각각 서브 풀의 개수, Idle 리소스의 만료 시간, 풀 크기이다. 자세한 내용은 문서를 참고하자. 최신 resource-pool 패키지(0.3.0.0 이상)는 조금 다른 인터페이스를 갖지만 개념은 비슷하다. 이렇게 만든 pool은 Reader 모나드 같은 곳에 넣어두고 필요할 때 꺼내서 사용할 수 있다. 사용할 때는 withResource 함수로 다음과 같이 사용하면 된다.

run :: (Mongo r m) => Action m a -> m a
run action = do
  State Config{configDatabaseName} pool <- asks getter
  withResource pool (\ pipe -> 
    access pipe master configDatabaseName action
  )

ab로 확인해보면 동시에 여러 요청을 잘 지원하는 것을 알 수 있다. 그리고 ab의 동시 요청 수를 20개로 늘려도 mongoDB 서버 쪽에서 확인할 수 있는 연결은 10개로 유지되는 것을 볼 수 있다.

> db.serverStatus().connections
{
	"current" : 11,

쓰고 보니 특별한 내용은 아니지만 하스켈에서 커넥션 풀 관리를 위한 resource-pool 패키지와 bracket 패턴을 소개한 것에 의미가 있는 것 같다.

profile
Functional Programmer @Constacts, Inc.

0개의 댓글