Typescript에서 사용되는 Effect-TS 라이브러리를 사용해보고 작성하는 글입니다.
제가 이해한대로 작성되어 올바르지 않은 단어나 정보가 있을 수 있습니다
자바 언어에는 메소드에서 어떤 예외를 던질 수 있는지 명시적으로 작성합니다
public void myMethod throws SomeException {
// ..
}
타입스크립트는 위와 같은 기능이 없기 때문에 어떤 함수를 실행할 때 발생할 수 있는 예외에 대한 정보를 얻을 수 없습니다.
interface UserService {
signIn(email: string, password: string): Promise<AuthToken>
}
위 서비스를 실행했을 때, 패스워드가 틀린경우 or 일치하는 이메일이 없는 경우
어떤 오류가 발생하는지 알 수 없습니다.
위와 같은 문제를 해결하기 위한 방법을 찾던 중 Effect-TS를 알게 되어 사용해보게 되었습니다.
Effect-ts는 아주 많은 기능을 제공하지만 가장 기본이 되는 Effect 타입부터 알아보겠습니다.
Effect 타입은 아래와 같이 생겼습니다
export interface Effect<out A, out E = never, out R = never> ...
Effect<A, E, R>은 다음과 같습니다.
DI 관련 기능인 R 타입은 NestJS 를 사용하므로 일단 보류하고
A와 E만 사용하는 예제로 시작합니다.
// 1. 기존 방식
const divide =
(dividend: number, divisor: number): number => {
if (divisor === 0) {
throw Error();
}
return dividend / divisor;
};
// 2. Effect
const divideEffect =
(dividend: number, divisor: number): Effect.Effect<number, DivideByZero> =>
divisor === 0
? Effect.fail({ _tag: 'DivideByZero' })
: Effect.sync(() => dividend / divisor);
나누기를 하는 함수를 일반적인 방법과 Effect를 사용한 방법으로 작성하였습니다.
Effect.fail()은 실패를 나타내고, Effect.sync()는 동기 함수로 실행하는 성공케이스를 나타냅니다.
Effect를 사용한 경우 반환 타입만 봐도 어떤 오류가 발생할 수 있는지 알 수 있습니다.
기존 함수의 경우 간단한 예시라 구현을 봐도 바로 알 수 있지만
일반적으로 함수 안에서 여러 함수를 호출하고 그 안에서도 다른 함수를 호출하기 때문에
실제로 어떤 오류가 발생할지 하나하나 확인하기는 아주 어렵습니다
Effect 의 경우 함수 내에서 실행한 다른 함수의 오류를 그대로 누적하여 반환 타입에 넘겨줍니다
declare const methodA: (value: number) => Effect.Effect<number, A>;
declare const methodB: (value: number) => Effect.Effect<number, B>;
declare const methodC: (value: number) => Effect.Effect<number, C>;
const myMethod =
(value: number) =>
pipe(
methodA(value),
Effect.andThen((value) => methodB(value)),
Effect.andThen((value) => methodC(value)),
);
const myEffect: Effect.Effect<number, A | B | C> = myMethod(100)
각각 A B C 오류가 발생할 수 있는 함수 methodA, methodB, methodC 가 있을 때,
methodA, methodB, methodC 를 순차적으로 실행하면
각 단계를 거치면서 발생할 수 있는 오류 A,B,C가 누적되어 myMethod()의 결과인 myEffect 에 나타납니다.
myEffect를 실행하는 경우 성공하여 number 가 나오거나, A,B,C 중의 한 오류가 발생할 수 있습니다.
Effect 를 실행하기 전 오류를 처리하는 것이 가능합니다.
declare const methodA: (value: number) => Effect.Effect<number, A>;
declare const methodB: (value: number) => Effect.Effect<number, B>;
declare const methodC: (value: number) => Effect.Effect<number, C>;
const myMethod =
(value: number) =>
pipe(
methodA(value),
Effect.andThen((value) => methodB(value)),
Effect.andThen((value) => methodC(value)),
// A 오류만 처리
Effect.catchTag('A', () => Effect.succeed(1000)),
// // B, C 오류 처리
// Effect.catchTags({
// 'B': () => Effect.succeed(1000),
// 'C': () => Effect.succeed(1000)
// }),
//
// // 모든 오류 처리
// Effect.catchAll(() => Effect.succeed(1000))
);
const myEffect: Effect.Effect<number, B | C> = myMethod(500)
처리한 오류는 타입에서 제외되어 B 와 C만 남은 모습입니다.
NestJS에서 Effect 를 사용하여 간단한 로그인 기능을 구현해봅니다.
// auth.service
@Injectable()
class AuthService {
// ...
signIn(
email: string,
password: string
): Effect.Effect<AuthToken, UserNotFound> {
return pipe(
// 1. 입력한 email로 유저를 가져옵니다.
// -> Effect<User, UserNotFound>
this.repository.findByEmail(email),
// 2. 패스워드를 검증합니다.
// -> Effect<User, UserNotFound | InvalidPassword>
Effect.tap((user) =>
this.passwordEncryptor.compare(password, user.password),
),
// 4. 유저 정보로 인증 토큰을 생성합니다.
// -> Effect<AuthToken, UserNotFound | InvalidPassword>
Effect.andThen((user) =>
this.authTokenFactory.create(user),
),
// 4. 보안상 InvalidPassword 에러를 UserNotFound 오류로 변경합니다.
// -> Effect<AuthToken, UserNotFound>
Effect.catchTag('InvalidPassword', () => new UserNotFound())
);
}
}
user.repository
@Injectable()
class UserRepository {
findByEmail(email: string) {
return pipe(
// DB 에서 유저 조회
// -> Effect<UserEntity | null>
Effect.promise(() =>
this.repository.findOne({
where: { email },
}),
),
// null인 경우 실패 처리
// -> Effect<UserEntity, UserNotFound>
Effect.filterOrFail(
(userEntityOrNull) => userEntityOrNull !== null,
() => new UserNotFound(),
),
// 도메인 모델로 변환
// -> Effect<User, UserNotFound>
Effect.andThen((userEntity) =>
UserMapper.toDomain(userEntity),
),
);
}
}
password.encryptor
// 의존성 주입을 위해 interface 대신 abstract class 사용
abstract class PasswordEncryptor {
abstract compare(
input: string,
hashed: string
): Effect.Effect<void, InvalidPassword>
}
마지막으로 컨트롤러를 구현해봅니다
@Controller(...)
class UserController {
@Post('sign-in')
signIn(
@Body() dto: SignInDto,
) {
return pipe(
this.authService.signIn(dto.email, dto.password),
// 오류 처리
// Effect.catchTags(...)
// 실행
Effect.runPromise,
)
}
}
컨트롤러에서 runPromise로 Effect를 실행시켜주어야 실제 로직이 실행됩니다.
컨트롤러에서 매번 runPromise로 실행시키는 것 보다
interceptor로 분리하여 전역으로 설정해줍니다.
필요한 경우 로깅이나 다른 기능도 추가할 수 있습니다.
// 컨트롤러에서 Effect를 반환하는 경우 실행합니다.
@Injectable()
export class EffectInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<any> | Promise<Observable<any>> {
const ctx = context.switchToHttp();
const response = ctx.getResponse<Response>();
return next
.handle()
.pipe(
map(async (value) => {
// 반환 타입이 Effect가 아닌 경우 그대로 리턴
if (!Effect.isEffect(value)) {
return value;
}
// Effect 실행
const result = await pipe(
value as TEffect<any>,
Effect.runPromiseExit,
);
// 성공한 경우 성공객체 리턴
if (Exit.isSuccess(result)) {
return result.value;
}
const cause = result.cause;
// Die인 경우 처리
if (Cause.isDieType(cause)) {
response.status(500);
return cause.defect;
}
// 일반 실패인 경우 처리
if (Cause.isFailType(cause)) {
response.status(400);
return cause.error;
}
// 예외
// ... logging
return cause;
}),
);
}
}
컨트롤러에서 이제 Effect 타입을 그대로 반환할 수 있습니다.
class UserController {
@Post('sign-in')
signIn(
@Body() dto: SignInDto,
) {
return pipe(
this.authService.signIn(dto.email, dto.password),
// 필요한 오류 처리
// Effect.catchTags(...)
)
}
}
Effect 타입은 실행 전까지 실제 로직을 실행하지 않습니다.
const random = () => Effect.sync(() => Math.random())
const myEffect = random()
console.log(Effect.runSync(myEffect))
console.log(Effect.runSync(myEffect))
console.log(Effect.runSync(myEffect))
// 0.9610924668137069
// 0.8621419846703764
// 0.46989516306422563
random() 함수를 실행해서 myEffect를 얻는 시점에 Math.random() 함수가 아직 실행되지 않습니다.
myEffect를 실제로 run할때 내부 함수인 Math.random() 함수가 실행되기 때문에
실행할때마다 각각 다른 랜덤 값을 출력하는 것을 볼 수 있습니다.
일반적인 경우 컨트롤러/인터셉터와 같은 가장 끝단에서 Effect를 실행시키는 방식이 별 문제가 되지 않습니다.
하지만 트랜잭션 기능을 데코레이터로 사용한다면 문제가 됩니다.
@Transactional()
myFunction(): Effect.Effect<...> {
pipe(
this.repository.usingTransactionMethod()
)
}
실제론 조금 더 복잡하지만 간단하게 표현하면 이런식으로 되어있습니다.
const Transactional = (
target: any,
methodName: string | symbol,
descriptor: TypedPropertyDescriptor<unknown>,
) => {
const originalMethod = descriptor.value as () => unknown;
descriptor.value = function(this: unknown, ...args: unknown[]) {
try {
await transactionBegin()
const result = await originalMethod.apply(this, args)
await transactionCommit()
return result
} catch {
await transactionRollback()
}
}
};
즉 데코레이터가 붙은 기존 함수(originalMethod)의 실행 전후로 트랜잭션 기능을 실행하도록 되어있습니다
트랜잭션을 사용하는 함수가 Promise를 반환하기 때문인데
Effect를 사용하는 경우 originalMethod는 실제 실행 시점이 아닌 effect를 반환하는 시점이기 때문에 올바르지 않습니다.
effect 반환 함수에서 Transactional 사용하는 경우
try {
await transactionBegin()
// 트랜잭션 필요 없는 부분
const effect = await originalMethod.apply(this, args)
await transactionCommit()
return effect
} catch {
await transactionRollback()
}
// ------------------------------------------
// 트랜잭션이 필요한 부분
await Effect.runPromise(effect)
데코레이터를 쓰지 않고 트랜잭션을 실행해서 객체를 넘기는 방법도 있지만
Effect 연습겸 라이브러리로 제작하였습니다.
NPM: https://www.npmjs.com/package/@jys9962/effect-ts-typeorm
https://github.com/jys9962/effect-ts-typeorm
Effect 라이브러리를 간단하게 사용해보았습니다.
위 내용 뿐 아니라 Effect 에서는 Array, Stream, Date, Either, Struct, Schema, Logging, DI 등의 많은 기능이 있고 웹프레임워크도 개발되어 있습니다.
아직까지는 DI나 웹프레임워크까지 사용해서 NestJS를 대체하기엔 이르다는 느낌이 듭니다.
실제 사용 예시를 찾기가 어렵고 공식 문서에도 빠진 부분이 많습니다.
pipe에서 사용이 편하도록 만들어진 Effect의 Array, Stream, String, Number 등의 간단한 기능과
Either<R, L>, Effect<A,E> 정도는 실무에서도 적용할만 한 것 같습니다.