효율적인 유지보수를 위한 요청서 개선하기

효율적인 유지보수를 위한 요청서 개선하기

Tech

들어가며

안녕하세요, 숨고 Mobile Engineer Sean 입니다.

숨고를 애정하는 분들이라면 잘 아시겠지만 숨고는 고객이 작성하는 요청서와 요청서를 바탕으로 전문가(고수)가 작성하는 견적서를 매칭하는 서비스를 제공하고 있습니다. 이 과정에서 요청서의 역할은 중요합니다. 요청서에 기재된 정보를 기반으로 견적서가 작성되기 때문이죠.

기존 시스템에서 요청서 작성은 특정 UI(View Pager)에 종속되어 있어 다양한 UI 요구사항에 유연하게 대응하기 어려웠습니다. 이러한 문제를 해결하기 위해 모델을 재정의하고 비즈니스 로직과 UI 로직을 분리했으며, 이를 통해 시스템의 유연성을 확보하고 효율적으로 유지보수 할 수 있도록 개선했습니다. 이번 글에서는 리팩터링 과정에서의 경험과 그 과정에서 얻은 인사이트를 공유하고자 합니다.

이 글을 읽는 분들은 아래 3가지를 얻어가실 수 있습니다.

  • 도메인 모델을 정제하는 과정
  • 브랜딩 타입을 응용한 유효 모델 도입 및 비즈니스 룰 코드로의 명세화
  • DDD에서 일컫는 Application Layer Service 도입 과정

요청서 분석

요청서 작성 시나리오

요청서 작성 방식은 다양하지만 시나리오를 요약해서 설명하면 다음과 같습니다.

  1. 서비스 선택: 고객이 원하는 서비스를 선택합니다.

  2. 요청서 양식 작성: 요청서 등록 양식(요청서 폼)에 표시된 질문에 답변을 작성합니다.

  3. 사용자 인증 절차 수행: 고객의 신원을 확인하기 위해 사용자 인증 절차를 거칩니다.

  4. 요청서 등록: 작성된 요청서를 시스템에 등록합니다.

기존 구조

숨고의 요청서 작성 폼은 초기에는 ViewPager 방식만을 지원했습니다. 구조는 다음과 같습니다.

대체 이미지
폼 모델 구조
대체 이미지
UI 컴포넌트 구조

문제점 분석

기존 요청서 폼 구조를 검토한 결과 몇 가지 개선이 필요한 부분을 발견했습니다. 다음은 주요 개선점들에 대한 상세한 설명입니다.

  1. 모델과 UI의 강결합

    • BaseFormItem에서 파생된 QuestionFormItemRegeditFormItem은 업력이 오래된 서비스 특성상 히스토리 파악이 어려웠지만 ViewPager UI를 지원하기 위해 만들어진 구조로 파악됩니다. 요청서 작성 도메인 관점에서 보면 인증 단계는 요청서 작성과 별도로 처리되어야 하지만 ViewPager UI에 맞추기 위해 하나의 모델 구조로 통합되었습니다.
    • 그 결과, 두 모델이 독립적으로 설계되지 못하고 모델의 계층 구조가 복잡해져 이해하기 어려워졌습니다
  2. UI 컴포넌트에서 관리하는 유효성 검증

    • 사용자가 입력한 답변에 대한 유효성 검증 로직이 UI 컴포넌트 내부에 포함되어 있습니다. 유효성 검증은 본래 비즈니스 로직의 일부로 입력 데이터가 도메인 규칙을 준수하는지 확인하는 역할을 합니다.
    • 그러나 유효성 검증이 UI 컴포넌트에서 관리되기 때문에 다른 폼이나 UI 요소에서 재사용되기 어렵습니다. 이는 중복 코드를 작성하게 만들고 코드 유지보수성을 저하합니다. 또한, 유효성 검증 로직이 분산되어 있어 테스트와 관리가 어렵습니다.
  3. 사용자 답변에 대한 암묵적인 가정

    • 요청서를 등록하는 비즈니스 로직은 유효한 답변 모델을 전제로 동작해야 합니다. 그러나 현재 구조에서는 명시적인 유효한 답변 모델이 없고 유효성 검증이 UI 컴포넌트에서 이루어지기 때문에 전달받는 답변이 유효하다는 가정하에 동작합니다.
    • 이로 인해 요청서 등록 비즈니스 로직에 집중한 단위 테스트를 작성하기 어렵습니다.
  4. UI 화면에 구현된 요청서 등록 로직

    • 요청서 등록 로직이 UI 화면에 직접 구현되어 있어 여러 문제가 발생합니다. 비즈니스 로직이 UI와 결합하여 코드의 재사용, 독립 실행 및 단위 테스트가 어렵습니다.

이러한 개선점들은 스마트 UI 안티 패턴(Smart UI Anti-Pattern)의 전형적인 예시로 판단했고, 코드의 유연성과 유지보수성을 높이기 위해 모델을 재정의하고 비즈니스 로직과 UI 로직을 분리하는 작업을 수행했습니다.

요청서 개선의 여정

위 문제점들을 개선하기 위해 ‘도메인 모델 정제 및 추출’, ‘답변 검증 로직을 UI로부터 분리’ 그리고 ‘요청서 등록 로직을 Application Layer Service로 구현’ 작업을 수행했습니다. 자세한 내용은 다음과 같습니다.

Step 1. 도메인 모델 정제 및 추출

도메인 전문가와 개발자 간의 가상의 대화를 통해 요청서 작성 도메인에서 사용하는 도메인 모델들을 추출하는 예시를 보여드리겠습니다.

image

A: 개발자, B: 도메인 전문가

A: 질문 유형들이 다양할까요?

B: 네, 질문의 유형에는 라디오 버튼, 체크박스(복수 선택), 텍스트, 카운터, 주소, 캘린더 등이 있습니다.

A: 사용자가 입력한 답변에 대한 유효성 검증은 어떻게 진행되나요?

B: 질문의 선택 항목 이외의 값은 선택할 수 없으며, 각 질문의 유형에 따라 검증 방식이 다양합니다.

A: 요청서 등록은 어떻게 진행되나요?

B: 사용자 인증 절차를 진행하고 작성한 요청서를 작성합니다.

A: 사용자 인증 절차는 어떻게 진행되나요?

B: 사용자 인증 절차는 다음 두 단계로 진행됩니다:

  • 로그인 여부 확인

  • 전화번호 인증 여부 확인

이러한 대화를 통해 요청서 작성 도메인에서 사용할 수 있는 도메인 모델 후보들을 추출해 보았습니다.

  • 요청서 폼

  • 질문

  • 질문의 유형

  • 질문에 대한 답변 명세

  • 답변

  • 유효한 답변

  • 요청서 초안

  • 사용자 인증 절차

대화를 통해 사용자 인증 절차는 요청서를 등록하는 단계에서만 필요하다는 것을 식별하게 되었습니다.

추출한 도메인 모델 중 ‘유효한 답변’, ‘요청서 초안’을 새롭게 식별하게 되었습니다.

기존의 BaseFormItem, QuestionFormItem, RegeditFormItem 용어는 요청서 도메인의 유비쿼터스 언어로 인식되지 않았고 특히 “RegeditFormItem은 요청서를 작성하는 단계에서 불필요한 절차로 인식되었습니다. 따라서 RegeditFormItem을 제거하고, QuestionFormItem은 질문(Question)으로 대체하였습니다.

'유효한 답변'의 경우, 브랜딩 타입을 활용하여 다음과 같이 타입을 선언했습니다.

export type ValidRequestFormUserAnswer = Readonly<RequestFormUserAnswer> & { _brand: 'ValidRequestFormUserAnswer' };

답변에 유효 여부를 나타내는 속성(isValid)을 추가할 수도 있었지만 “답변”과 '유효한 답변'을 타입으로 구분함으로써 유효 여부 속성 추가 대비 다음과 같은 이점을 얻을 수 있었습니다.

  1. 타입 안전성

    • 브랜딩 타입: 컴파일 타임에 타입 검사가 가능하여 유효한 답변만을 사용하는 함수나 로직에서 컴파일러가 잘못된 타입의 사용을 방지해줍니다.
    • 유효 여부 속성: 런타임에 유효 여부를 확인해야 하므로 실수로 유효하지 않은 답변을 사용하는 경우를 컴파일러가 방지하지 못합니다.
  2. 명확한 코드

    • 브랜딩 타입: 유효한 답변과 일반 답변이 타입 시스템으로 명확히 구분되므로 코드의 의도가 명확해지고 문서화가 잘 되어 있지 않더라도 타입을 통해 코드의 의미를 쉽게 이해할 수 있습니다.
    • 유효 여부 속성: isValid 변수를 통해 유효성을 확인해야 하므로 코드의 의도가 명확하지 않을 수 있으며 실수로 유효 여부를 확인하지 않고 사용하는 경우가 발생할 수 있습니다.
  3. 불변성 유지

    • 브랜딩 타입: 유효한 답변을 생성한 후에는 해당 답변이 변하지 않음을 보장할 수 있습니다. 이는 함수형 프로그래밍의 불변성 원칙을 잘 따릅니다.
    • 유효 여부 속성: 유효 여부를 나타내는 변수는 변경 가능성이 있으며 실수로 유효 여부를 변경할 수 있습니다. 이는 불변성 원칙을 따르기 어렵게 만듭니다.
  4. 함수 시그니처의 명확성

    • 브랜딩 타입: 함수 시그니처에서 유효한 답변을 요구하는 경우, 타입 시스템을 통해 이를 명확히 표현할 수 있습니다.

요청서 초안’ 모델은 사용자가 작성한 요청서를 저장하기 전 상태를 나타냅니다. 이 모델을 사용하면 요청서를 등록할 수 없는 경우(서버 오류, 일일 요청서 작성 횟수 초과 등)에 데이터를 임시로 저장하고 추후 고객이 동일한 서비스에 대해 다시 요청서를 작성할 때 활용할 수 있는 장점이 있습니다.

하지만 해당 모델을 사용하여 개선하는 작업은 리팩터링 작업 범위를 벗어난다고 판단하여 모델만 추출하고 실제 코드에서는 사용하지 않았습니다.

Step 2. 답변 검증 로직을 UI로부터 분리

도메인 레이어에서 사용자 답변에 대한 유효성 검증을 수행하고 그 결과로 유효한 답변 모델을 생성하는 방법을 소개하고자 합니다.

유효성 검증 로직 구현

/* 예시 코드 */ type UserAnswerConstraint = (question: Question, userAnswer: RequestFormUserAnswer) => Result<void>; //사용자 입력에 대한 id 제약 조건에 따라 검증 수행 const userInputIdConstraint: UserAnswerConstraint = (_, userAnswer) => {}; // 질문에 대한 답변 갯수의 제약 조건에 따라 검증 수행 const userInputSizeConstraint: UserAnswerConstraint = (question, userAnswer) => {}; // 질문에 대한 사용자 답변의 길이 제약 조건에 따른 검증 수행 const userInputValueLengthConstraint: UserAnswerConstraint = (question, userAnswer) => {}; // 카운터 필드에 대한 제약 조건에 따른 검증 수행 const counterFieldConstraint: UserAnswerConstraint = (question, userAnswer) => {}; //질문에 따른 사용자 답변에 대한 룰 처리기 class UserAnswerRuleExecutor { constructor(readonly constraints: UserAnswerConstraint[]) {} validate(question: Question, userAnswer: RequestFormUserAnswer): Result<void> { const errors = this.constraints .map((constrain) => constrain(question, userAnswer)) .map((result) => { if (result.isFailed()) { return result.error; } }) .filter(isNotNil); if (isEmpty(errors)) { return new Success(undefined); } else { return new Failure(new Error(errors.join(','))); } } } //모든 질문 타입에 공통으로 들어가는 제약 조건 목록 const defaultConstraints = [userInputIdConstraint, userInputSizeConstraint, userInputValueLengthConstraint]; //질문 타입 별 룰 수행 전략 const ruleExecutorStrategies: Record<RequestFormQuestionType, UserAnswerRuleExecutor> = { [RequestFormQuestionType.CHECKBOX]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.RADIO]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.SELECT_BOX]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.CALENDAR]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.COUNTER]: new UserAnswerRuleExecutor([...defaultConstraints, counterFieldConstraint]), [RequestFormQuestionType.ADDRESS_SI_GU]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.ADDRESS_SI_GU_DONG]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.MULTI_LINE_TEXT_INPUT]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.SINGLE_LINE_TEXT_INPUT]: new UserAnswerRuleExecutor(defaultConstraints), [RequestFormQuestionType.PHOTO_UPLOAD]: new UserAnswerRuleExecutor(defaultConstraints), }; /** * @package */ export const validateUserAnswer = (question: Question, userAnswer: RequestFormUserAnswer): Result<void> => { const ruleExecutor = ruleExecutorStrategies[question.type]; const result = ruleExecutor.validate(question, userAnswer); return result; }; /** * @package */ export const validateUserAnswers = (requestForm: RequestForm, userAnswers: RequestFormUserAnswers): Result<void> => { try { //사용자 답변 목록에 대한 유효성 검증을 수행 return new Success(undefined); } catch (e) { const error = mapToError(e); return new Failure(error); } };

사용자의 답변에는 여러 제약 조건이 있습니다. 이러한 제약 조건을 바탕으로 유효성 검증을 수행하는 것이 기본적인 구조입니다. 질문의 유형에 따라 제약 조건은 다양해질 수 있으며 이를 질문 타입별 룰로 관리할 수 있도록 설정해 두었습니다. 최종적으로 룰 실행기를 통해 질문에 대한 답변이 유효한지 검증하게 됩니다.

추가로, eslint-plugin-import-access를 이용하여 validateUserAnswervalidateUserAnswers의 접근 제한자를 패키지 수준으로 설정하여 같은 디렉터리에 있는 유효한 답변 모델 생성기에서만 접근할 수 있도록 강제했습니다. 이렇게 함으로써 UI에서는 유효한 답변 모델과 답변 목록 모델을 적극적으로 사용할 수 있는 이점을 갖게 되었습니다.

유효한 답변 모델 생성기 구현

//예시 코드 export namespace ValidRequestFormUserAnswerCreator { export const createUserAnswer = ( question: Question, userAnswer: RequestFormUserAnswer, ): ValidRequestFormUserAnswer => { const result = validateUserAnswer(question, userAnswer); if (result.isFailed()) { throw result.error; } return { ...userAnswer, _brand: 'ValidRequestFormUserAnswer' }; }; export const createUserAnswers = ( requestForm: RequestForm, userAnswers: RequestFormUserAnswers, ): ValidRequestFormUserAnswers => { const result = validateUserAnswers(requestForm, userAnswers); if (result.isFailed()) { throw result.error; } return { ...userAnswers, _brand: 'ValidRequestFormUserAnswers' }; }; }

위 구조를 통해 일반적인 답변이 유효성 검증을 통과한 경우에만 유효한 답변이 생성되도록 보장할 수 있게 되었습니다. 유효한 답변은 오직 유효한 답변 모델 생성기를 통해서만 생성되며 다른 방식으로는 생성할 수 없음을 팀 내에 공유하여 답변에 대한 명확성을 확보할 수 있었습니다.

위 작업을 통해 답변 검증 로직을 UI로부터 분리하여 코드의 재사용성과 유지보수성을 개선했습니다. 또한, 유효성 검증 로직을 도메인 레이어에 집중시킴으로써 UI와 비즈니스 로직 간의 결합도를 낮췄습니다.

Step 3. 요청서 등록 로직을 Application Layer Service로 구현

해당 작업을 설명하기 전 Application Layer Service에 대한 설명과 Mobile Chapter에서 정의 내린 역할은 아래와 같이 정의 내릴 수 있습니다.

Application Layer Service란?

Application Layer Service는 애플리케이션의 비즈니스 로직을 캡슐화하는 계층입니다. 이는 사용자 인터페이스와 도메인 모델 사이의 중간 계층으로 주로 사용자의 요청을 처리하고 도메인 객체와 상호 작용을 하여 비즈니스 로직을 수행합니다.

Application Layer Service의 역할

비즈니스 로직 수행

  • 도메인 모델과 리포지토리(Repository)를 이용하여 복잡한 비즈니스 로직을 수행합니다

예외 처리

  • 발생할 수 있는 예외를 처리합니다. 이는 적절한 오류 메시지를 반환하거나 비즈니스 로직을 수행하기 전의 상태로 되돌리는 등의 작업을 포함합니다

에러 리포팅

  • 에러 발생 시 이를 적절하게 리포팅하여 개발자가 문제를 추적하고 해결할 수 있도록 지원합니다. 이는 외부 모니터링 시스템에 에러를 보고하는 등의 방식으로 구현될 수 있습니다
//예시 코드 export class RequestFormService { constructor(props:{ //요청서 등록를 등록하는데 필요한 의존선 주입 //예: repository, 에러 핸들링 관련 인스턴스 }) {} //요청서 등록을 수행하는 함수 public async submitRequestForm(funcPrams:{ //요청서 등록에 필요한 데이터 집합 //예: 요청서 폼, 유효한 사용자 답변 등등.. }): Promise<Result<{ requestId: string }>> { try { //요청서 등록과 괸련된 비즈니스 로직을 수행 } catch (e) { const error = mapToError(e); //에러 리포팅 //실패 사유에 따른 추가적인 로직 수행 return new Failure(error); } } }

위 예시 코드에서 submitRequestForm 함수의 파라미터를 객체로 받지만 다음과 같이 command 패턴을 이용할 수도 있습니다.

//예시 코드 class SubmitRequestFormCommand { constructor(params:{}) { //요청서 등록에 필요한 정보를 주입받고 해당 데이터 중 유효성 검증이 필요한 경우 constructor에서 검증을 수행 //브랜딩 타입을 이용해서 유효한 타입을 구분했다면 해당 constructor에서 유효한 타입을 생성 //유효하지 않은 데이터로 판별될 경우 constructor에서 바로 예외를 던짐 } } export class RequestFormService { //요청서 등록을 수행하는 함수 public async submitRequestForm(command:SubmitRequestFormCommand): Promise<Result<{ requestId: string }>> { } } //사용처 예시 //유효하지 않은 데이터가 들어온 경우에는 submitRequestForm의 비즈니스 로직 자체는 수행되지 않음 new RequestFormService(/* 의존성 주입 */).submitRequestForm(new SubmitRequestFormCommand(/* 요청서 등록에 필요한 정보 */));

SubmitRequestFormCommand에 유효성 검증의 책임을 위임함으로써 서비스는 비즈니스 로직에 집중할 수 있습니다.

요청서 등록 로직을 Application Layer Service로 구현함으로써 비즈니스 로직을 독립적으로 수행하고 단위 테스트를 수행하기에 더 용이해졌습니다.

글을 마치며

이번 리팩터링 작업을 통해 다음과 같은 긍정적인 효과를 가져올 수 있었습니다. 먼저 숨고의 요청서 작성 프로세스는 더욱 유연하고 유지 보수하기 쉬워졌습니다. 그리고 모델과 UI의 강결합을 해소하고 비즈니스 로직과 UI 로직을 분리하여 다양한 UI 요구사항에 효과적으로 대응할 수 있게 되었습니다. 특히, 유효성 검증 로직을 도메인 레이어로 이동시켜 코드 재사용성을 높였고 요청서 등록 로직을 Application Layer Service로 구현하여 비즈니스 로직의 독립성과 테스트 용이성을 확보했습니다.

이 작업은 단순히 코드 구조를 개선하는 것을 넘어 시스템의 유연성과 확장성을 향상하는 중요한 발판이 되었습니다. 앞으로도 이러한 접근 방식을 유지하며 다양한 요구사항에 신속하게 대응할 수 있도록 시스템을 지속적으로 개선해 나가겠습니다. 더불어 이번 작업을 통해 얻은 교훈을 바탕으로 숨고의 서비스가 더 많은 사용자에게 안정적이고 효율적인 경험을 제공할 수 있도록 최선을 다하겠습니다.

읽어 주셔서 감사합니다.

참고자료

  • #application layer service
  • #clean architecture
  • #refactoring
  • #ddd
Sean Jung

Sean Jung

Mobile App Engineer

연결을 통해 가치를 만드는 숨고팀과
함께할 당신을 기다립니다

채용중인 공고 보기