Unknownpgr

The-Form Refactoring

2023-08-16 14:17:01 | English, Korean

저는 더폼(The-Form)이라는 서비스를 운영 중입니다. 더폼은 2021년 소프트웨어 마에스트로 교육과정을 연수하며 만든 설문조사 플랫폼입니다.

더폼을 개발하기로 결정하고 첫 커밋을 찍은 것이 21년 7월 8일입니다. 그 이후로 2년이 지났고, 더폼에는 많은 변화가 생겼습니다. 그러면서 더폼의 코드도 점점 복잡해지며 유지보수가 어려워졌습니다. 그래서 저는 더폼의 코드를 리팩토링하기로 결정했습니다.

이 글에서는 더폼의 리팩토링 과정을 소개하고, 리팩토링을 하면서 느꼈던 점들을 공유하려고 합니다.

왜 리팩토링을 하게 되었나?

더폼은 (신기하게도) 많은 유저분들이 사용해주시는 플랫폼으로, 다양한 기능 요청들이 항상 들어옵니다. 하루에 수 건씩 새로운 기능에 대한 요청이 들어올 때도 있습니다. 그런데 이런 기능들을 개발하려다 보니 코드가 너무 복잡하고 불안정해서 새 기능을 추가하기가 너무 어려워졌습니다. 물론 어떻게든 개발하려고 하면 못할 것은 없겠지만 그러다 일이 잘못되면 도저히 손댈 수 없게 될 것 같았습니다.

더폼의 문제점들

더폼의 가장 큰 문제는 타입스크립트가 아니라 자바스크립트로 개발됐다는 점입니다. 당시 프로덕트를 빠르게 개발하려는 욕심이 있었고, 저를 포함한 팀원들은 타입스크립트에 익숙하지 않았습니다. 그래서 자바스크립트를 사용하기로 의사결정을 내렸었습니다. 프로덕트 구조가 간단하고 항상 코드를 보고 있을 때는 별 문제가 없었는데, 프로덕트가 복잡해지고 한동안 코드를 보지 않다가 다시 코드를 보니 해석하기가 너무 어려웠습니다.

그리고 컴포넌트들이 별도의 리포지토리로 존재했고 각각 독립적인 배포 프로세스를 가지고 있었습니다. 더폼은 백엔드, 프론트엔드, 인증 서버, 이미지 서버 등 다양한 컴포넌트로 나뉘어져 있습니다. 그런데 이들이 별도의 리포지토리에 존재하다 보니 배포 관련 작업을 바꾸는 것이 너무 큰일이 되어버렸습니다. 특히 GitHub Action에 각종 시크릿을 비롯한 중요한 값들이 각 리포지토리마다 설정되어 있어 하나의 설정값을 바꾸려고 해도 모든 설정값을 전부 건드려야 했습니다. 또 GitHub Action에서는 보안상의 이유로 시크릿을 설정하는 것은 가능하나 시크릿을 다시 확인하는 것은 불가능해서 시크릿을 별도로 유지해야 했습니다. 특히 시크릿은 Git으로 추적할 수도 없어 실수로 유실할 때도 있었습니다.(물론 쿠버네티스 상에서 그 값을 확인 가능하므로 다시 가져오면 되기는 합니다.)

무엇보다 아키텍쳐가 깔끔하지 않았었습니다. 특히 더폼은 중요한 로직들이 프론트엔드에 많이 들어있습니다. 그런데 규모가 큰 프론트엔드 코드를 작성해본 경험이 부족했을 뿐만 아니라 아키텍쳐에 대한 이해도 부족해서, 프론트엔드에서 비즈니스 로직과 UI 코드가 많이 섞여버리고 말았습니다.

리팩토링 전략

리팩토링에 앞서 리팩토링 전략을 철저하게 짜고 점진적 배포를 목표로 리팩토링에 들어가기로 했습니다. 왜냐하면 이전에 리팩토링을 시도했다가 실패한 경험이 몇 번 있었기 때문입니다. 그때는 한 번에 리팩토링을 너무 많이 진행한 나머지 프로덕트가 이전과 너무 많이 달라져 기존 서비스에 저장되어있던 데이터를 이식해오는 것이 사실상 불가능해졌었습니다. 거기다 프론트엔드와 백엔드의 구조를 한번에 너무 많이 바꾸다 보니 데이터 이식이 가능하다 한들 사실상 프로덕트를 새로 만들어 배포하는 상황이었습니다. 완전히 새로운 코드베이스를 배포하는 것은 너무 위험한 결정입니다. 그래서 결국 리팩토링한 버전을 배포하지 못하고 원래대로 돌아왔었습니다.

이를 위해 가장 먼저 더폼의 모든 리포지토리를 전부 하나의 monorepo에 몰아 넣었습니다. 그리고 CI/CD 파이프라인을 다시 구축했습니다. monorepo로 리포지토리를 합치는 데는 여러 장점이 있습니다.

그리고 이 작업을 시작하면서 기존의 모든 리포지토리를 archive했습니다. 기존의 코드베이스를 건드리지 못하게 강제하기 위함입니다. 전에 그렇게 하지 않았더니 사소한 오류 수정이나 디자인 개선을 이유로 기존의 코드베이스를 자꾸 건드리게 되고, 그러다 기존 코드베이스와 새 monorepo의 차이가 너무 커지면서 결국 monorepo로 이전하는 것이 불가능해진 적이 있습니다.

마지막으로 '어쨌든 프로덕션 배포'를 가장 우선의 목표로 삼았습니다. 코드베이스와 프로덕트의 차이를 최소화하기 위해서입니다.

monorepo 통합 및 배포 이후에는 억눌려있었던 다른 개발자, 디자이너, PM 분들의 프로덕트 개선 욕구를 반영하기 위해 리팩토링을 잠시 중지하고 디자인 개선과 각종 데이터 분석 툴 추가, 기능 개선 작업을 진행했습니다. 그러면서 백엔드의 아키텍쳐가 좀 더 견고하게 바뀌었고 메인 페이지의 디자인을 개선했으며 각종 자잘한 오류를 해결했습니다. 이로서 리팩토링을 시작할 최소한의 준비를 마쳤습니다.

리팩토링을 다시 시작하기에 앞서 백엔드와 프론트엔드 중 어떤 컴포넌트를 먼저 작업할지를 결정해야 했습니다. 두 컴포넌트를 동시에 리팩토링하는 것이 불가능하지는 않습니다. 그러나 그 경우 대단히 많은 코드가 수정된 후에야 배포가 가능합니다. 즉, 점진적인 배포가 불가능합니다. 그래서 고민 끝에 프론트엔드를 먼저 리팩토링하고 백엔드를 비롯한 나머지 컴포넌트를 리팩토링하기로 결정했습니다. 더폼은 설문조사라는 도메인 상 비즈니스 로직이 대부분 프론트엔드에 있기 때문입니다.

프론트엔드 리팩토링 [1] - Ts-fy

모든 구조적 리팩토링에 앞서 프론트엔드를 JS에서 TS로 리팩토링했습니다. 불확실한 부분에 전부 any를 넣는 한이 있더라도 일단 모든 파일을 타입스크립트로 변환했습니다. 그러면서 UI컴포넌트를 비롯하여 세부적인 부분들을 전부 안정적인 타입스크립트로 변환할 수 있었습니다.

프론트엔드 리팩토링 [2] - 아키텍쳐 설계

이후에는 클린 아키텍쳐에 기반한 설문 구조를 구현했습니다. 이 과정에서는 비즈니스 로직을 구현하기 위한 프로그래밍 방법론을 결정하는 것이 가장 힘들었습니다. 프로그래밍에는 절차지향형, 함수형, 객체지향형 등 다양한 패러다임이 있습니다. 이중 더폼의 비즈니스 로직을 구현하는 데는 객체지향형(Object Oriented Programming; OOP)와 자료지향형(Data Oriented Programming; DOP) 두 가지 방법론이 적당합니다. 그런데 두 방법론 모두 장단점이 있어서 어느 방법론을 선택할지 결정하기가 쉽지 않았습니다.

OOP를 택하면:

DOP를 택하면:

이 결정이 어려운 이유는 더폼의 비즈니스 로직이 프론트엔드와 백엔드에 걸쳐있기 때문입니다. 만약 더폼이 프론트엔드나 백엔드 둘 중 한쪽이 대부분의 비즈니스 로직을 차지하고 다른 쪽은 상대의 인프라, 혹은 표현 레이어에 불과하다면 이러한 고민을 할 필요가 없었을 것입니다.

사실 프론트엔드와 백엔드라는 관점은 애초에 아키텍쳐 설계 단계에서는 고려해서는 안 됩니다. 먼저 아키텍쳐를 설계하고 난 후, 구체적인 부분을 구현할 때 프론트와 백이라는 경계를 컴포넌트 사이에 그을 수 있을 뿐입니다.

물론 OOP와 DOP는 관점이 다를 뿐 상충하는 방법론이 아닙니다. 필요에 따라 두 방법론을 조화롭게 사용할 수 있습니다. 다만 그 과정에서 각 방법론의 장점이 희석돼서는 안됩니다. 그래서 저는 아래와 같은 방식으로 접근했습니다.

DOP의 관점에서:

OOP의 관점에서:

아래는 이를 구현한 의사 코드입니다.

// 아래 코드는 pseduo code입니다.
import { Survey, SurveySchema } from 'entity';

class SurveyService{
    private surveyObject: Survey;

    constructor(surveyObject: Survey){
        const newSurveyObject = deepCopyObject(surveyObject); // 그러므로 SurveyService 외부에서 데이터는 불변입니다.
        this.surveyObject = SurveySchema.validateOrThrow(newSurveyObject); 
    }

    public getSurveyObject(): Survey{
        return deepCopyObject(this.surveyObject);
    }

    public setSurveyTitle(title:string){
        this.surveyObject.title = title;
    }

    // ... 하략
}

이러한 방법으로부터 얻을 수 있는 장점은 다음과 같습니다.

또 엔티티 스키마를 작성할 때 그것이 백엔드와 프론트엔드에 대한 single source of truth가 되기를 바랐습니다. 그러려면 언어 및 프레임워크에 대한 의존성을 최소화해야 합니다. 그래서 아래와 같은 방법을 택했습니다.

프론트엔드 리팩토링 [3] - 로직 마이그레이션

이후에는 기존의 프론트엔드를 새롭게 만들어진 깔끔한 비즈니스 로직을 사용하도록 마이그레이션했습니다. 이 과정에서는 새롭게 작성한 코드를 손상시키지 않으면서 리액트와 호환되게 만드는 것이 가장 어려웠습니다.

리액트에서는 상태를 state로 관리합니다. statesetState에 의하여 값이 설정되고 그러면 리액트에 의해 자동으로 렌더링이 다시 이루어집니다. 그런데 새로 만든 아키텍쳐에서 상태는 오브젝트 혹은 이를 포함하는 클래스의 인스턴스일 뿐입니다. 그래서 메서드를 사용해서 내부적으로 값을 변경해도 렌더링이 발생하지 않습니다.

이 문제를 해결하기 위해 다양한 고민을 해보고 또 시도해봤습니다.

이러한 시도 끝에 결국은 가장 확실하고 안정적인 방법을 택하기로 했습니다. 데이터를 관리하는 Service object에는 listener를 등록할 수 있고, 데이터의 값을 변경하는 함수가 호출되면 값을 변경한 후 명시적으로 등록된 listener를 호출하는 방식입니다. 이 방식은 특별한 환경을 요구하지 않으므로 가장 안정적이고 확실한 방법이라고 생각했습니다. 이 방법은 대신 실수로 새로운 기능을 구현하고서 listener 함수를 호출하지 않으면 렌더링이 발생하지 않습니다.

// 아래 코드는 pseduo code입니다.
class SurveyService{
    // ... 상략
    private listeners: (()=>void)[] = [];
    public addListener(listener:()=>void){
        this.listeners.push(listener);
    }

    private notifyListeners(){
        this.listeners.forEach(listener=>listener());
    }

    public setTitle(title:string){
        this.surveyObject.title = title;
        this.notifyListeners();
    }
    // ... 하략
}

프론트엔드 리팩토링 [4] - 데이터 마이그레이션

이후에는 데이터를 마이그레이션했습니다. 먼저 백엔드를 호출해야 하는 작업들을 Repository라는 인터페이스에 정의하고 이 인터페이스를 구현하는 클래스를 작성했습니다. 이때 백엔드는 아직 전혀 손대지 않은 상태이므로 기존에 백엔드를 호출하던 함수들을 그대로 가져다 썼습니다. 그리고 백엔드에서 가져온 데이터들을 새롭게 바뀐 엔티티 구조로 마이그레이션하는 코드를 넣었습니다.

이때, 충격적이게도, 백엔드로 값을 집어넣을 때는 마이그레이션이 필요하지 않았습니다. 왜냐하면 백엔드에서 값 검사를 거의 하지 않았기 때문입니다. (그래서 사실 기존 더폼 데이터베이스에는 임의의 JSON 객체를 삽입하는 것이 가능했었습니다.)

마이그레이션 함수를 만들 때는 별로 고민할 것이 없었습니다. 값의 validation이나 parsing을 zod 라이브러리를 통해 쉽게 해결했기 때문입니다. 그러나 기존 설문 데이터 구조를 대충 적어만 놓고 그 스키마를 Typescript interface나 JSON-Schema 등의 구조적인 자료로 만들어두지 않았기 때문에 데이터베이스를 직접 확인하면서 기존 설문 구조를 파악해야만 했습니다. 특히 기존에는 Optinal한 필드가 너무 많았어서 기본값 처리를 하는 것 역시 꽤 난감한 작업이었습니다.

프론트엔드 리팩토링 [5] - 용어 정리

구조를 정리한 후에는 용어를 확실히 정리했습니다. 이전까지는 여러 용어를 사용하다 보니 혼란이 있었기 때문입니다. 예를 들어 설문의 응답을 나타내기 위해 국문으로 응답, 답변, 결과, 영문으로는 answer, response, result 등의 용어가 혼용되고 있었습니다. 특히 response, 혹은 응답은 일반적인 API 응답을 나타내기도 하고 설문 답변을 나타내기도 해서 대단히 혼란스러웠습니다. 그래서 아래와 같이 용어를 정리했습니다.

백엔드 리팩토링 [1] - 아키텍쳐

이후에는 백엔드 리팩토링을 진행했습니다. 이전까지는 express router에 들어 있었던 비즈니스 로직을 단일 클래스로 모았습니다. 그리고 다양한 기능들의 의존성을 분리했습니다.

예를 들어 이메일을 보내는 기능을 리팩토링했습니다. 이전에는 메일을 보내는 과정이 비즈니스 로직에 직접 포함되어 있었습니다. 비즈니스 로직에서 EJS 템플릿 엔진을 실행하고, 직접 AWS SDK를 호출했습니다. 그러므로 이메일을 보내는 route하나가 라우팅, 파라매터 파싱, 템플릿 렌더링, 보내기까지 모든 과정을 수행했습니다. 그러나 리팩토링을 진행하며 이 기능을 EmailSender, TemplatedEmailSender라는 두 가지 인터페이스로 분리했습니다.

이후 이를 상속하는 SesEmailSender, TemplatedEmailSenderImpl 클래스를 실제로 구현했습니다. 이로부터 관심사의 분리가 이루어졌습니다.

물론 백엔드에도 데이터베이스 접근을 위한 Repository 인터페이스를 만들고 실제로 접근하는 RepositoryImpl 클래스를 구현했습니다. 그러면서 프론트에 있었던 옛날 버전의 설문을 새롭게 마이그레이션하는 코드를 백엔드 RepositoryImpl로 옮겨왔습니다. 몇 가지 사소한 오류를 제외하면 (프론트에서와 다르게 백엔드에서는 Date 형식의 값이 문자열이 아니라 오브젝트였기 때문에 발생한) 문제 없이 잘 동작했습니다. 이로부터 백엔드에서도 타입 안전성이 확보되었습니다.

백엔드 리팩토링 [2] - API

비즈니스 로직을 리팩토링한 후에는 API 구현을 신경썼습니다. API는 테스트하기 어려울 뿐만 아니라 컴파일 타임에 오류를 잡기도 힘들기 때문입니다. 그래서 API 코드를 직접 구현하는 대신, tsoa 라이브러리를 사용하여 자동으로 API route와 OpenAPI Schema를 생성하도록 구현했습니다. 이후 프론트엔드에서 openapi-typescript-codegen 라이브러리를 사용하여 API 클라이언트를 자동으로 생성하도록 구현했습니다.

프론트와 백은 OpenAPI의 subset인 JSON Schema로 작성된 엔티티를 공유하기 때문에 타입은 자연스럽게 일치됐습니다. 프론트의 Repository는 API를 호출하고 migration을 수행하는 두꺼운 레이어에서 Request, Response 객체만 다뤄주는 얇은 레이어가 되었습니다.

또한 부가적인 이득으로 입력 데이터의 스키마 검증이 자동으로 이뤄지게 됐습니다. 예전에는 데이터의 값을 제대로 validation하지 않았기 때문에 사실상 임의의 JSON을 보내도 저장이 가능했습니다. 그러나 지금은 tsoa에서 자동으로 타입 검증을 해 주기 때문에 이런 문제가 발생하지 않습니다.

백엔드 리팩토링 [3] - 테스트

이후 드디어 테스트 스크립트를 도입하기로 했습니다. 기존에는 기능이 별로 많지 않아 수동으로 QA를 진행했었지만, 앞으로 기능을 더 추가할 예정인데다 배포도 잦아질 것이므로 수동 QA는 합리적이지 않다고 판단했기 때문입니다.

테스트 프레임워크로는 Puppeteer, Playwright를 고려했습니다. Postman, Jmeter 등 다양한 테스트 툴이 있지만 이들은 API나 백엔드 레이턴시 / 스루풋 측정에는 적합해도 UI를 테스트할 수 없다는 단점이 있었기 때문입니다. 더폼은 설문조사 플랫폼이기 때문에 UI 테스트가 필수적입니다.

이 둘 중 Playwright를 선택했습니다.

시크릿 관리

그리고 sealed-secret 기반으로 시크릿을 관리하도록 업데이트했습니다. 기존에는 GitHub Action을 동작시킬 때 환경 변수를 통해 시크릿을 주입했습니다. 이 방법은 시크릿 관리가 어려울 뿐만 아니라 시크릿이 포함된 Kubernetes resource를 git에 track하는 것이 불가능한 문제가 있었습니다.

Sealed-secret은 이러한 문제를 해결하기 위한 툴입니다. Sealed-secret은 쿠버네티스에 떠 있는 컨트롤러와 CLI로 구성되어 있습니다. CLI는 쿠버네티스의 public key로 시크릿을 암호화하여 YAML 파일로 출력합니다. 이 YAML 파일을 쿠버네티스에 배포하면 컨트롤러가 이를 감지하여 시크릿을 복호화하여 쿠버네티스에 저장합니다.

따라서 sealed-secret 리소스가 노출되더라도 컨트롤러가 가지는 private key가 없으면 그 값을 알 수 없습니다. 반면 원본 시크릿 없이 sealed-secret만을 가지고 있더라도 문제없이 시크릿을 배포할 수 있습니다. 이로부터 시크릿 및 다른 Kubernetes resource를 git에 track할 수 있게 되었습니다.

CI / CD 리팩토링

그와 함께 GitHub Action으로 구현되었던 CI / CD를 로컬에서 동작하는 Node.js 스크립트로 업데이트했습니다.

이 Node.js 스크립트는 각 서비스에 대하여 아래 동작을 실행합니다.

  1. node-modules 등을 제외한 리포지토리의 모든 파일의 목록을 얻습니다.
  2. 이를 정렬합니다.
  3. 순서에 따라 모든 파일을 읽으며 그 해시를 계산합니다.
  4. 파일시스템에 저장되어있는 해시와 비교, 다르다면 빌드를 수행합니다.
  5. 도커 이미지를 레지스트리에 푸시합니다. (더폼은 프라이빗 리포지토리를 사용하고 있습니다.)
  6. kustomize를 실행하여 더폼 전체 서비스를 띄우는 단일 manifest.yaml 파일을 생성합니다.
  7. 이를 kubectl로 배포합니다.

이로부터 해당 manifest.yaml 파일만 가지고 있으면 더폼 전체 서비스를 배포할 수 있게 되었습니다. 이 파일은 git에 track되어 있으므로 만약 오류가 발견되어 서비스를 롤백해야 하는 경우, 가장 최근 배포한 커밋에서 해당 파일을 가져와서 배포하면 됩니다.

마무리하며

이렇게 더폼을 리팩토링해봤습니다. 어려운 부분도 많았지만 그만큼 배울 점도 많았습니다. 더폼은 아직도 많이 부족한 서비스이지만, 이번 리팩토링을 통해 더 나은 서비스를 만들 수 있을 것이라고 믿습니다.


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -