AI Briefing

도메인 에러 처리와 Arrow Either의 도입

·2025.06.24 19:09

Sealed class의 한계를 넘기 위해 Arrow Either로 성공·실패를 분리했다.

결제형 BE팀은 오래된 protocol 기반 결제 로직을 Kotlin + Spring Boot 기반의 신규 HTTP API 시스템으로 전환하면서, 도메인 에러를 값으로 다루는 방식을 고민했다. 기존에는 예외가 발생하는 조건과 처리 위치가 제각각이라 원인 추적이 어려웠고, 예외 클래스도 계속 늘어나 전체 흐름을 파악하기 힘들었다.

먼저 sealed class 로 결과 타입을 모델링해 성공과 실패 케이스를 명시적으로 표현했다. BillingPaymentResult 같은 타입을 두고 when 분기로 처리하면, API 응답의 의미 있는 상태만 선별해 다룰 수 있고, 새로운 오류 타입이 추가돼도 컴파일러가 누락 여부를 검사해 준다.

하지만 사용이 늘수록 한계도 분명했다.

  • 결과 타입마다 PaymentResult, UserInfoResult처럼 sealed class 를 계속 새로 정의해야 했다.
  • 성공 값을 꺼내기 위한 getOrNull(), getOrThrow() 같은 헬퍼도 반복해서 붙게 됐다.
  • 여러 결과 함수를 연속 호출하면 when 이 중첩되고, 성공 흐름이 깊게 들어가면서 코드 중복이 생겼다.

특히 포인트 충전 후 결제 승인을 이어 붙이는 흐름에서는, 충전 성공 분기 안에서 다시 결제 승인을 호출하는 구조가 생겨 가독성이 크게 떨어졌다. 충전이 필요 없는 경우까지 포함하면 동일한 결제 승인 로직이 중복되면서 유지보수성이 더 나빠졌다.

이 문제를 풀기 위해 Railway Oriented Programming(ROP) 을 받아들이고, 이를 코드로 구현할 도구로 Arrow 를 선택했다. Arrow의 Either<L, R> 는 실패를 Left, 성공을 Right 로 분리해 표현하고, map, flatMap, getOrElse 같은 조합 함수를 제공해 성공과 실패 흐름을 자연스럽게 이어 준다.

적용 후 가장 먼저 줄어든 것은 보일러플레이트였다. 각 도메인마다 별도 결과 클래스를 만드는 대신, Either<FailureType, SuccessType> 형태를 재사용하면서 결과 타입 정의가 크게 단순해졌다. 또한 실패 전용 로직은 실패 타입만 받도록 만들 수 있어, 성공 케이스를 억지로 처리하거나 nullable 반환을 둘 필요가 없어졌다.

Either 로 성공과 실패를 명확히 분리하니, 관심사도 더 선명해졌다. 예를 들어 실패 결과를 DomainErrorDetail 로 바꾸는 함수는 실패 타입만 입력으로 받아야 하므로, 성공 분기를 무의미하게 포함할 필요가 없어지고, 호출 측도 null 체크 없이 실패 처리에만 집중할 수 있었다.

다만 @Transactional 과 함께 쓸 때는 주의가 필요했다. catch 블록 안에서 예외를 잡아 Either.Left 로 바꿔버리면, Spring 입장에서는 예외가 발생하지 않은 것으로 보이기 때문에 트랜잭션이 롤백되지 않았다. 결국 데이터 저장 중 실패가 나도 일부 작업이 커밋되는 문제가 생겼다.

해결책은 관심사 분리였다.

  • 내부 서비스는 @Transactional 을 붙이고, 실패 시 예외를 그대로 throw 해서 롤백을 유도한다.
  • 외부 서비스는 트랜잭션 바깥에서 내부 서비스를 호출한 뒤, 예외를 받아 Either.Left 로 변환한다.

이렇게 나누면 트랜잭션은 데이터 일관성을 책임지고, Either는 비즈니스 결과 표현을 책임지는 구조가 된다. Arrow의 Either 는 단순한 결과 래퍼가 아니라, 도메인 에러를 예측 가능하고 조합 가능한 형태로 다루게 해 주는 도구로 자리 잡았다.

이 요약은 원문 이해를 돕기 위한 큐레이션입니다. 저작권은 원저작자에게 있으며, 정확한 내용과 맥락은 원문을 확인하세요.

요약 오류, 출처 표기 문제, 삭제 요청은 문의 · 건의로 알려주세요.