이 글은 Effect-TS를 공부하며 개인적인 목적으로 정리한 내용입니다. 내용에 오류가 있을 수 있으며 잘못된 내용은 댓글 등을 통하여 알려주시면 최대한 반영하도록 하겠습니다. 본문은 평서문으로 작성되었으며 여기에 사용된 소스 코드는 이곳에서 찾아보실 수 있습니다. 각 챕터별로 커밋의 diff를 함께 보시는걸 추천드립니다. 이 포스팅은 ChatGPT와 함께 작성하였습니다.
Effect-TS(이하 Effect)는 TypeScript 라이브러리로 Scala의 ZIO에 큰 영향을 받아 개발되었다. Effect를 사용하면 type system의 도움을 받아 실행에 성공했을 때의 값뿐만 아니라 오류가 발생한 경우에 대해서도 명시적으로 모델링을 할 수 있다.
함수형 프로그래밍에서는 exception 대신 이러한 패턴(Try, Result 등의 타입)을 사용하여 오류 발생에 대한 모호함을 제거하고 성공과 실패를 정확하게 다룰 수 있도록 하는 게 일반적인 사용 패턴이다.
Effect는 type safety와 error handling 이외에도 concurrency, composability, resouce safety, asynchronicity, observability 등의 기능을 탑재하고 있다. (이 목록은 여기에서 자세히 확인 할 수 있다.)
이 포스팅에서는 앞서 언급한 error handling, type safety와 더불어 asynchronicity(비동기), composability(합성) 중심으로 살펴볼 것이며, context(layer, service)를 알아보며 마무리가 될 예정이다.
개별적인 API들을 세부적으로 설명하는 대신 비동기를 사용하는 간단한 프로그램을 만들고 이것을 Promise와 Effect를 사용해서 리팩토링하는 과정을 순차적으로 거치면서 Effect의 사용 방법에 대해서 알아볼 것이다.
commit 보기
마침 진행 중이던 업무에서 이미지를 선택 후 몇 가지 작업을 할 일이 생겼는데, '이 작업에 Effect를 적용해 보면 어떨까?' 하는 생각이 들었다. 우선 해당 기능을 작은 크기로 축소해서 다시 구현을 해보았다. 이 프로그램은 React 기반으로 작성되었으며, 선택한 파일이 이미지일 경우 가로세로 크기를 알려주는 일을 한다.
type ImageSize = { width: number; height: number };
type FileStatus =
| { _tag: "NotSelected" }
| { _tag: "NotImage" }
| { _tag: "FileError" }
| { _tag: "Selected"; size: ImageSize };
function App() {
// ... 다른 코드
const [status, setStatus] = useState<FileStatus>({ _tag: "NotSelected" });
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file === undefined) {
setStatus({ _tag: "NotSelected" });
return;
}
const reader = new FileReader();
reader.onload = () => {
const text = reader.result;
const img = new Image();
img.onload = () => {
setStatus({
_tag: "Selected",
size: { width: img.width, height: img.height },
});
};
img.onerror = () => {
setStatus({ _tag: "NotImage" });
};
img.src = text as string;
};
reader.onerror = () => {
setStatus({ _tag: "FileError" });
};
reader.readAsDataURL(file);
};
return (
// 또 다른 코드
);
}
코드에서 핵심적인 부분만 옮겨왔다. 전체 코드는 여기에서 확인할 수 있다.
파일을 선택하면 FileReader
로 파일을 읽고, Image
를 사용해 이미지를 로드한 후 해당 이미지의 가로세로 크기를 구한다. 이미지에 대한 읽기 상태를 표현하기 위해 FileStatus
타입을 tagged union 형태로 모델링하였다.
기능은 잘 동작하지만, 전형적인 명령적 프로그래밍 방식으로 코드가 작성되었다. FileReader
와 Image
는 event 기반으로 동작하는 특성이 있다. 이 때문에 코드의 흐름이 비선형적으로 중첩되어 callback-hell과 같은 형태를 띠기 때문에 실행 순서를 파악하기가 쉽지 않다.
지금은 기능이 단순하지만, 요구사항이 조금만 늘어나더라도 코드의 복잡도는 기하급수적으로 증가할 것이다.
commit 보기
FileReader
와 Image
를 더 쉽게 사용하기 위해 Promise를 사용해서 리팩토링하였다.
const ReadFile = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = (e) => reject(e);
reader.readAsDataURL(file);
});
const LoadImage = (dataUrl: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = dataUrl;
});
const getImageSize = <T extends ImageSize>({
width,
height,
}: T): ImageSize => ({ width, height });
function App() {
// ... 다른 코드
const [status, setStatus] = useState<FileStatus>({ _tag: "NotSelected" });
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file === undefined) {
setStatus({ _tag: "NotSelected" });
return;
}
try {
const dataUrl = await ReadFile(file);
const img = await LoadImage(dataUrl);
const size = getImageSize(img);
setStatus({ _tag: "Selected", size });
} catch (e) {
// 에러 식별이 어렵다.
setStatus({ _tag: "FileError" });
console.error(e);
}
};
return (
// 또 다른 코드
);
}
파일을 읽는 것과 이미지를 로드하는 것을 각각 ReadFile
과 LoadImage
로 분리하고 Promise
를 적용하였다. 또한 이미지 크기를 얻어오는 부분은 getImageSize
함수로 분리하였다.
비동기 동작을 기능별로 분리하고 Promise
를 적용하였기 때문에 async-await
구문을 사용하면 비동기 코드라 할지라도 코드의 실행 흐름을 순차적으로 파악하기가 쉬워진다. 그러나 에러 처리를 위해 사용한 try-catch
는 에러가 발생했을 경우 실행을 중단시키기는 하지만 정확히 어떤 에러가 발생했는지 파악하기 어렵다는 단점이 있다.
catch
는 발생한 에러의 종류를 정확하게 합성할 수 없기 때문에 e
를 unknown
으로 취급하고 개발자가 처리를 떠넘긴다. 그렇기 때문에 catch
구문에서 모든 에러를 처리하고 있는지 보장할 수 없다.
commit 보기
Effect는 작업의 실행 흐름을 명확하게 표현하는 함수형 프로그래밍 패턴을 사용한다. 이를 통해 개발자는 어떤 에러가 어디서 발생하는지 정확히 파악할 수 있으며, 에러의 유형에 따라 다르게 대응할 수 있다.
에러를 명시적인 값으로 표현하고 합성할 수 있게 하여 여러 작업을 조합하거나 순차적으로 연결하는 과정에서 발생할 수 있는 에러들도 우아하게 처리할 수 있다. 특히 Effect를 사용하면 동기와 비동기를 따로 구분하지 않아도 되므로 코드 작성이 훨씬 더 편리하고 유연해진다.
앞서 작성한 ReadFile
과 LoadImage
에 Effect
를 적용했다. Promise를 Effect를 반환하는 함수로 만들기 위해서 tryPromise
를 사용하고, 각 작업이 발생하는 에러를 각각 ErrorOfReadingFile
과 ErrorOfLoadingImage
로 모델링 하였다. 에러의 구분을 위해서는 _tag
필드를 사용하여 discminitated union으로 표현하였다. Effect에서의 에러 모델링은 여기를 참고하자.
class ErrorOfReadingFile {
readonly _tag = "ErrorOfReadingFile";
}
const ReadFile = (file: File) =>
Effect.tryPromise({
try: () => {
const reader = new FileReader();
return new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = (e) => reject(e);
reader.readAsDataURL(file);
});
},
catch: (_) => new ErrorOfReadingFile(),
});
class ErrorOfLoadingImage {
readonly _tag = "ErrorOfLoadingImage";
}
const LoadImage = (dataUrl: string) =>
Effect.tryPromise({
try: () => {
const img = new Image();
return new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = dataUrl;
});
},
catch: (_) => new ErrorOfLoadingImage(),
});
이제 이 Effect들을 활용하여 파일을 읽고 이미지를 로드한 다음, 이미지의 크기를 가져오는 작업을 할 것이다. pipe
와 Effect.flatMap
을 사용하면 작업을 순차적으로 연결할 수 있으며, 각 단계의 출력이 다음 단계의 입력이 되기 때문에 전체 작업의 흐름이 명확해진다.
특히 동기와 비동기 작업을 동일한 방식으로 처리할 수 있는 특성은 코드가 훨씬 간결하고 일관성 있게 만들며, 각 단계에서의 실패는 중앙 집중화된 방식으로 처리될 수 있으므로 특별한 에러 처리 로직 없이도 안전하게 작업을 연결할 수 있다.
const programOfGetImageSize = (file: File) =>
pipe(
Effect.succeed(file),
Effect.flatMap(ReadFile),
Effect.flatMap(LoadImage),
Effect.map(getImageSize),
);
programOfGetImageSize
를 사용하기 전에 이 함수와 ReadFile
, LoadImage
의 리턴 타입을 잠깐 보고 가자. Effect.Effect
타입의 두 번째 파라미터는 발생 가능한 에러의 타입을, 세 번째 파라미터는 성공 시 반환되는 결과의 타입을 나타낸다. 첫 번째 파라미터는 뒤에서 설명하겠다.
const ReadFile: (file: File) => Effect.Effect<never, ErrorOfReadingFile, string>
const LoadImage: (dataUrl: string) => Effect.Effect<never, ErrorOfLoadingImage, HTMLImageElement>
const programOfGetImageSize: (file: File) => Effect.Effect<never, ErrorOfReadingFile | ErrorOfLoadingImage, ImageSize>
Effect의 눈에 띄는 특징 중 하나는 에러의 자동 합성이다. 예를 들어 ReadFile과 LoadImage라는 두 함수가 있을 때, 각각이 다른 유형의 에러를 반환하는 이 함수들을 조합하여 새로운 작업을 생성하면 Effect는 이 에러들을 자동으로 합성해준다. 이는 에러 처리를 수동으로 관리할 필요 없이 여러 작업을 자연스럽게 조합하면서 전체 작업 흐름에 대한 에러 관리를 유지할 수 있게 해준다.
ReadFile
과 LoadImage
를 합성하여 작성한 programOfGetImageSize
의 에러 타입이 자동으로 ErrorOfReadingFile | ErrorOfLoadingImage
가 되었다는 것에 주목하자.
이러한 자동 에러 합성은 코드의 복잡성을 크게 줄이며, 작업 간에 에러의 일관성을 보장한다. 개발자는 각 작업의 세부 사항에 대해 신경 쓰지 않고 전체 작업 흐름에 집중할 수 있게 된다.
이제 handleFileChange
에 programOfGetImageSize
를 적용해보자.
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file === undefined) {
setStatus({ _tag: "NotSelected" });
return;
}
const program = programOfGetImageSize(file).pipe(
// 이미지 변환 성공시 status로 인코딩
Effect.map((size) => ({ _tag: "Selected", size } as const)),
// 에러를 status로 인코딩
Effect.catchTags({
ErrorOfReadingFile: () =>
Effect.succeed({ _tag: "FileError" } as const),
ErrorOfLoadingImage: () =>
Effect.succeed({ _tag: "NotImage" } as const),
}),
);
const result = await Effect.runPromise(program);
setStatus(result);
};
개선된 코드는 에러 처리의 유연성을 크게 향상시킨다. Effect.catchTags
를 사용하면 각각의 에러 유형을 명시적으로 다룰 수 있게 되어, 에러의 종류에 따라 다르게 대응할 수 있다. 특히 새로운 에러 유형이 추가될 때 기존 코드를 수정할 필요 없이 필요한 에러 처리만 쉽게 추가하면 된다.
commit 보기
Effect의 Context(또는 Service)는 프로그램의 서술(무엇을 할 것인지)과 실행(어떻게 할 것인지)을 분리하며, 이는 ReadFile
과 LoadImage
인터페이스에서 확인할 수 있다. 이런 분리는 프로그램 로직의 이해와 검증을 간소화하며, 코드의 재사용과 설계의 유연성을 높인다.
다음은 Context를 적용하여 다시 선언한 ReadFile
과 LoadImage
의 인터페이스이다.
// src/ReadFileService.ts
export interface ReadFileService {
readonly read: (
file: File,
) => Effect.Effect<never, ErrorOfReadingFile, string>;
}
export const ReadFileService = Context.Tag<ReadFile>();
// src/LoadImageService.ts
export interface LoadImageService {
readonly load: (
dataUrl: string,
) => Effect.Effect<never, ErrorOfLoadingImage, HTMLImageElement>;
}
export const LoadImageService = Context.Tag<LoadImage>();
ReadFileService
와 LoadImageService
는 작업의 정의만 제공하고 실제 실행은 지정하지 않는다. 예를 들어 ReadFileService
는 파일 읽기 작업을 정의하지만, 이를 로컬 파일 시스템에서 읽을 것인지, 원격 서버에서 받아올 것인지, 아니면 테스트 환경에서 가상의 데이터를 사용할 것인지는 실행부에서 결정할 수 있다. 이렇게 하면 실행 환경이나 요구 사항의 변화, 테스트의 필요에 따라 동일한 서술을 다른 방식으로 구현할 수 있다.
결과적으로 Context의 사용은 코드의 견고성을 높이고, 서로 다른 실행 전략을 간편하게 적용할 수 있는 설계를 가능하게 한다. 특히 테스트의 경우 실행 로직을 수정하지 않고도 원하는 테스트 케이스를 손쉽게 구현할 수 있어 효율적인 테스트 작성을 돕는다.
이제 ReadFileService
와 LoadImageService
는 Effect
를 리턴하는 함수가 아닌 Service 인터페이스이기 때문에 인터페이스로부터 구현부를 얻어오는 절차를 추가해야 한다.
const programOfGetImageSize = (file: File) =>
pipe(
Effect.all([ReadFileService, LoadImageService]),
Effect.flatMap(([ReadFile, LoadImage]) =>
pipe(
Effect.succeed(file),
Effect.flatMap(ReadFile),
Effect.flatMap(LoadImage),
Effect.map(getImageSize),
),
),
);
ReadFileService
와 LoadImageService
는 실제 구현을 포함하지 않은 순수한 인터페이스로, 이를 사용함으로서 로직과 구현이 분리되었다. 이로 인해 다양한 실행 환경에서 동일한 로직을 효과적으로 재사용할 수 있게 되었다.
다음으로는 실제 구현을 해볼 차례이다. 지금까지는 인터페이스와 로직의 설계에 집중했으므로, 구현이 분리된 형태로 작성되었다. 이제 이러한 설계를 바탕으로 구체적인 실행 환경에 맞게 실제 구현을 작성해야한다. 이전의 구현을 Layer
로 감싸 Service 인터페이스에 맞는 구현체로 만들었다.
const ReadFileLive = Layer.succeed(
ReadFileService,
ReadFileService.of({
read: (file: File) =>
Effect.tryPromise({
try: () => {
const reader = new FileReader();
return new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = (e) => reject(e);
reader.readAsDataURL(file);
});
},
catch: (_) => new ErrorOfReadingFile(),
}),
}),
);
const LoadImageLive = Layer.succeed(
LoadImageService,
LoadImageService.of({
load: (dataUrl: string) =>
Effect.tryPromise({
try: () => {
const img = new Image();
return new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = dataUrl;
});
},
catch: (_) => new ErrorOfLoadingImage(),
}),
}),
);
다음으로 진행하기에 앞서 programOfGetImageSize
의 타입을 살펴보자.
const programOfGetImageSize: (file: File) => Effect.Effect<ReadFileService | LoadImageService, ErrorOfReadingFile | ErrorOfLoadingImage, ImageSize>
Effect.Effect
의 첫번째 파라미터는 'Requirements'라고 불리며, Effect가 실행될 때 필요한 데이터를 나타낸다. 이 데이터는 프로그램을 실행하는데 필요한 요구사항(구현체)를 명시적으로 표현한다.
이전의 구현에서는 서비스가 없어서 첫 번째 파라미터가 never
였다. 하지만 새로운 구현에서는 ReadFileService
와 LoadImageService
로 구현되었기 때문에 첫 번째 파라미터가 ReadFileService | LoadImageService
가 되었다. 에러와 마찬가지로 이 타입들도 자동으로 추론되고 합성 되었다는 것을 눈여겨 보자.
이전에는 runPromise
를 사용하여 program
을 바로 실행 할 수 있었지만 이제는 Requirements가 필요하기 때문에 그렇게 할 수 없다. provideLayer
를 사용해서 program
이 요구하는 구현체를 주입해야한다.
const runnable: Effect.Effect<never, never, FileStatus> =
Effect.provideLayer(program, Layer.merge(ReadFileLive, LoadImageLive));
이제 이 runnable
을 실행하면 프로그램이 완성된다.
const result = await Effect.runPromise(runnable);
setStatus(result);
지금까지 Effect-TS에서 에러를 다루는 방법과 Layer사용에 대해서 간략하게 알아보았다. Effect-TS에 대해서 더 궁금한 것이 있다면 다음 자료들을 참고해보자.
https://www.effect.website/docs/essentials/effect-type
https://www.youtube.com/@effect-ts
https://hashnode.imch.dev/why-i-love-effect-ts
https://www.youtube.com/watch?v=SloZE4i4Zfk&list=PLstS9OwMLPrU8PWCRv5rW0cNHXu_TzlDi
잘 봤습니다. 좋은 글 감사합니다.