Lobsters

You don't need an effect system

이펙트 시스템은 필요하지 않습니다

Haskell 서비스 코드를 새로 작성하며 이펙트 시스템을 걷어낸 경험을 설명합니다. 글쓴이는 함수 인자, 일반 IO, 예외를 조합해 이펙트를 표현하고, 효과 추적이나 모킹 같은 목적도 별도 프레임워크 없이 해결할 수 있다고 주장합니다.

AI 요약

글쓴이는 오래된 Haskell 서비스 코드를 처음부터 다시 작성하면서 이펙트 시스템이 실제로 어떤 문제를 해결하는지 따져봅니다. 인자 전달은 함수 인자로 충분하고, 이펙트를 추적해도 나쁜 IO나 unsafePerformIO 사용을 막지는 못합니다. 순수 함수 안에서 상태를 바꿀 때는 ST 모나드로 처리할 수 있고, 난수 생성처럼 굳이 추적하고 싶지 않은 IO도 있습니다. 사용하던 기능을 하나씩 걷어내자 남은 용도는 오류 처리뿐이었습니다.

조기 반환과 타입 안전 예외

Haskell에서 함수 중간에 빠져나오는 방법으로 Either나 continuation을 쓰면 오류 처리가 코드 뒤쪽으로 밀리기 쉽습니다. 예외를 던지는 방법은 흐름을 감추지만, 이펙트 시스템도 결국 이 두 방식을 감쌉니다. 예를 들어 ExceptT는 각 동작에 Either를 전달하는 방식입니다. 글쓴이는 그 대신 특정 함수 안에서만 던질 수 있는 예외를 만들자고 제안합니다.

예제의 Early e 타입은 조기 반환 토큰 역할을 합니다. leave는 토큰과 반환할 값을 받아 예외를 던지고, runEarly는 해당 예외를 잡아 IO (Either e a)로 바꿉니다. 토큰의 타입 역할을 nominal로 지정해 타입 동등성으로 토큰을 바꾸지 못하게 합니다. 파일 핸들이나 포인터를 bracket으로 관리하듯, 예외의 사용 범위를 함수 타입으로 제한한다는 설명입니다.

IO에서 이펙트 표현하기

글쓴이는 이펙트를 인터페이스와 구현을 잇는 데이터 타입으로 정의합니다. 예를 들어 Database 타입이 함수 인자로 전달되고, createUser 같은 함수가 인터페이스가 되며, runDatabase가 구현을 제공합니다. 생성자는 내보내지 않아 구현을 감춥니다. 이펙트 시스템처럼 타입 제약으로 목록을 추적하는 대신, 필요한 이펙트를 함수 인자로 명시합니다. 그러면 이펙트 순서를 바꾸거나 같은 이름의 이펙트를 구별하는 복잡성이 줄어듭니다.

모듈을 나누면 어떤 코드가 이펙트를 만들고 실행할 수 있는지도 통제할 수 있습니다. 모킹은 함수들로 구성된 레코드를 대체 구현으로 바꾸는 방식으로 처리합니다. 로깅이나 메트릭처럼 실패하지 않는 이펙트도 같은 틀에 넣을 수 있습니다. 설정값은 별도 타입으로 두고 함수 인자로 전달하는 편을 선호합니다. Reader 이펙트를 따로 흉내 내려면 불필요한 래핑이 늘어난다고 설명합니다.

오류와 자원 관리

각 이펙트 구현은 자신이 던진 예외를 책임지고 잡아야 합니다. 구현을 중첩해 실행하면 오류 타입도 중첩된 Either로 쌓입니다. 글에서는 내부 이펙트의 오류와 바깥 이펙트의 오류를 구분하는 타입을 구성해 이 문제를 처리합니다. 데이터베이스 연결 같은 자원은 성공, 오류, 예외 상황에서 같은 정리 함수를 호출해야 합니다. 라이브러리가 여러 예외를 던진다면 인터페이스 함수 안에서 예외를 데이터베이스 오류 타입으로 바꿀 수 있습니다.

여러 이펙트와 실행 구조

엔드포인트에는 설정, 로거, 메트릭, 오류 반환 토큰, 데이터베이스, 캐시, 메시징 등 많은 인자가 들어갈 수 있습니다. 글쓴이는 모든 이펙트를 하나의 레코드에 묶기보다 각 함수가 실제로 쓰는 이펙트만 받게 하는 방식을 택합니다. GHC의 미사용 인자 경고로 함수가 쓰지 않는 이펙트를 찾아낼 수 있다는 설명입니다. 예컨대 사용자 생성은 데이터베이스만 쓰는 함수로 분리하고, 로그 기록과 오류 반환은 해당 이펙트가 필요한 상위 함수에서 처리합니다.

실행 단계에서는 runDatabase, runMessaging 같은 구현을 중첩하고, 바깥에서 오류를 분기합니다. Servant에서도 비슷한 구조를 쓸 수 있지만, 함수 인자로 이펙트를 전달하면 hoisting 기능을 그대로 활용하기 어렵습니다. 따라서 각 엔드포인트 안에서 실행 스택을 직접 호출해야 합니다.

글쓴이는 이펙트 시스템이나 IO를 대체하는 커스텀 모나드의 고유한 기능을 코드 사이에 개입하는 능력이라고 정리합니다. 이 능력이 필요하지 않은 코드라면 plain IO가 Haskell 98 이상 컴파일러에서 작동하고, 구현과 의존성이 단순하며, 타입 오류가 덜 낯설고 컴파일과 실행도 빠르다고 주장합니다. 대가로 일부 함수가 길어질 수 있다고 덧붙입니다.

원문: Burning Witness / 번역·요약: Trawling