카카오커머스의 FE개발파트는 React나 순수 JS로 개발되어있던 선물하기, 쇼핑하기 등 각 서비스 프레임웍을 모두 Angular로 전환하고 있습니다. 이 글에서는 이 결정을 하게 된 배경과 Angular가 어떤 프레임웍이고. 왜 전환 하는지에 대해 공유하려 합니다.
전환 배경
분사 후 선물하기, 쇼핑하기, 스타일, 장보기 별로 존재 또는 존재하지 않던 UI개발팀이 하나로 묶이면서 서버 개발자의 리소스 부족으로 밀려있던 많은 UI이슈들을 해결하게 되었는데요.
이때 당시 각 서비스들은 모두 별개의 개발 프로세스와 프레임웍을 사용하고 있었습니다. 때문에 담당 개발자 부재 시 서포트에 큰 어려움이 있었습니다.
따라서 프레임웍과 개발 프로세스를 맞출 필요성이 생겼습니다. 당시 쇼핑하기 판매자, 모바일 웹 개발에 사용했던 Angular가 안정적이었고. 여러가지 이점도 있어 결국 Angular로 모두 전환하는 것으로 결정하게 되었습니다.
Angular채택 이유
앞서 설명했던 상황 적 배경 외에 Angular를 채택하게 된 이유를 우선순위에 따라 나열하면 아래와 같습니다.
1. 상품 등록과 같은 복잡한 입력폼 페이지를 만들 때 편하다
2. 개발에 필요한 모든 것이 포함되어 서드파티 패키지가 필요없다
3. 자동 Change Detection, 쉬운 성능 최적화로 인한 개발 편리함
4. 강력한 CLI도구
5. 서비스의 전반적 성능이 향상되는 프레임웍 업데이트
6. rxjs
상품 등록과 같은 복잡한 입력폼 페이지를 만들 때 편하다
커머스 플랫폼의 관리자 서비스는 상품, 배송, 주문, 기획전 등등 수 많은 등록/수정 폼 페이지를 제공합니다.
각 페이지는 입력 필드도 많고 A필드가 입력되어야 B필드를 사용할 수 있는 등의 복잡한 필드 사이의 관계도 많았습니다.
이 이슈를 어떻게 해결했는지 공유하기 전에 Angular가 폼을 어떻게 처리하는지 간단하게 설명을 드리겠습니다.
Angular의 @angular/form 살펴보기
Angular는 폼을 다루기 위한 @angular/form 패키지를 제공합니다. FormGroup, FormArray, FormControl 3가지 클래스를 제공하며 각각 객체, 배열, 값에 대응하고 디렉티브를 사용하면 모델과 뷰 간 단방향 동기화 상태를 쉽게 구현할 수 있습니다.
@Component({
template: `
<form [formGroup]="form">
<input type="text" formControlName="name" />
</form>`})
export class ProductComponent {
form = new FormGroup({
name: new FormControl(''),
});
}
위의 코드에서 FormGroup의 인스턴스 form과 템플릿의 form은 `[formGroup]=”form”`디렉티브를 통해 연결되어 있는 상태입니다. 이 때 DOM의 값 변경(뷰)은 form인스턴스(모델)의 값 변경으로 단방향으로 동기화가 되며. 반대의 경우도 마찬가지입니다.
따라서 form 인스턴스의 프로퍼티를 통해 값과 각 상태를 쉽게 조회할 수 있습니다.
form.value // {name:"아디다스 플리스"}
form.valid // true
form.dirty // false
form.touched // false
또 FormGroup, FormArray, FormControl 로 구성한 계층 중 재사용이 필요한 부분은 별도의 Angular컴포넌트로 분리해 사용할수도 있습니다.
예를 들어 상품 등록/수정 페이지의 할인기간 설정 영역을 별도의 컴포넌트로 분리해 팝업과 같은 다른 부분에서도 사용할 수 있습니다.
// 할인기간 설정 컴포넌트
@Component({
selector: 'app-discount',
template: `
<div [formGroup]="discount">
<!-- ... -->
</div> `})
class DiscountComponent {
discount = new FormGroup({
price: new FormControl(''),
period: new FormControl(''),
});
}
// 상품 등록/수정 컴포넌트
@Component({
template: `
<form [formGroup]="form">
<input type="text" formControlName="name" />
// 할인기간 설정필드를 컴포넌트로 사용
<app-discount formControlName="discount"></app-discount>
</form>`})
class ExampleComponent {
form = new FormGroup({
name: new FormControl(''),
discount: new FormControl(),
});
}
필드의 유효성 검사도 간편하게 구현할 수 있습니다. 각 클래스 생성자 함수에 유효성 검사 함수를 전달하면 값이 변경될 때 함수가 호출되어 인스턴스의 유효성 상태가 업데이트 됩니다.
// 유효성 검사 함수 작성 (!)
function required(ctrl) {
if (!ctrl.value) {
return {required: true};
}
return null;
}
@Component({
template: `
<form [formGroup]="form">
<input type="text" formControlName="name" />
<!-- 유효성 검사 오류 템플릿 작성 (!) -->
<span *ngIf="form.get('name')?.errors?.required">
상품명을 입력해 주세요
</span>
</form>`})
class ExampleComponent {
form = new FormGroup({
name: new FormControl('', required), // 인자로 전달
});
ngOnInit() {
this.form.valid; // false 유효한 상태
this.form.invalid; // true 유효하지 않은 상태
this.form.statusChanges.subscribe(e => {
e; // ‘INVALID’, ‘PENDING’, ‘VALID’ 상태변화 감지
});
}
}
Angular가 제공하는 @angular/form을 사용하면 상품 등록/수정 페이지를 수월히 개발할 수 있지만 몇가지 번거로운 점이 있습니다. 바로 유효성 검사 함수와 오류 출력 템플릿을 일일히 작성해야한다는 것입니다. 위의 코드에서 (!)로 표시한 부분이지요.
유효성검사 템플릿은 아래 코드와 같이 각 필드마다 오류에 대한 템플릿을 하드코딩해야 하는 구조였습니다.
<input id="name" class="form-control" formControlName="name" />
<div *ngIf="name.invalid && (name.dirty || name.touched)">
<div *ngIf="name.errors.required">
상품명을 입력하세요.
</div>
<div *ngIf="name.errors.minlength">
이름은 4글자 이상 입력하세요.
</div>
<div *ngIf="name.errors.forbiddenName">
사용 불가한 이름입니다.
</div>
</div>
그리고 유효성 검사 함수는 필드별로 만들어 각 클래스 생성자에 일일히 전달해야 하는 점도 번거로웠습니다.
@Component({
template: `
<form [formGroup]="form">
<input type="text" formControlName="name" />
</form>`})
class ExampleComponent {
form = new FormGroup({
name: new FormControl(
'',
// 값 존재여부 유효성검사
ctrl => '' === ctrl.value ? {required: true} : null,
),
},
// 필드 두개의 값이 모두 존재하는지 여부 유효성검사
form => isEmpty(form.valueA) && isEmpty(form.valueB)
? {error: true}
: null
);
}
또한 보안 상 서버 유효성검사는 필수인데. 클라이언트에서 작성한 유효성검사와 서버의 유효성 검사가 중복되어 관리에 어려움이 예상되는 상황이었습니다.
이 이슈는 공통 포멧과 모듈을 사용하는 방법으로 해결했습니다.
@angular/form을 편하게 쓰기 위한 공통 모듈 개발
앞서 언급한 번거로운 부분은 크게 두 부분입니다. 첫번째는 유효성검사 오류 템플릿을 일일히 적어주어야 한다는 점이었고 두번째는 유효성검사 함수를 FormGroup, FormArray, FormControl마아 일일히 작성해 넣어주어야 한다는 것이었습니다.
앞서 FormControl의 상태를 감시할 수 있는 Observable객체인 statusChanges를 반환한다는 설명을 드렸습니다. Angular의 ElementInjector의 특성을 응용해 각 FormControl 노드에 providing되는 Directive를 만들고 이 인스턴스가 statusChanges상태를 구독해 에러가 발생하면 동적으로 에러 컴포넌트를 만들어 컨트롤 하위에 엘리먼트를 삽입하도록 리펙토링했습니다.
@Directive({
// 해당 selector에 해당하는 템플릿 노드에 providing함
selector: '[formControlName]'
})
export class ErrorDirective {
constructor(
// 동일 노드에 providing된 FormControl인스턴스 DI받기
@Self()
private formControlName: FormControlName
) {}
ngOnInit() {
// 인스턴스 상태 감시
this.formControlName
.control
.statusChanges
.subscribe(status => {
if ('INVALID' === status) {
// [유효성 검사 실패] 에러 데이터 뽑아서 컴포넌트 동적 삽입
return;
}
// [유효성 검사 성공] 에러가 없으므로 삽입되었던 컴포넌트 제거
});
}
}
위 디렉티브를 사용하면 컨트롤의 오류를 자동으로 DOM에 출력하므로 이후의 개발에서 오류 템플릿을 작성할 필요가 없어졌습니다.
다음 이슈였던 유효성 검사 함수 중복은 클라이언트 측 함수 사용을 최소화하고 최대한 서버의 유효성 검사를 활용하는 방향으로 리펙토링했습니다. 먼저 서버의 유효성검사 API응답을 정해진 형식으로 맞췄습니다.
// POST: /product/8022881030
// 요청
{
name: “아디다스 플리스”,
discountA: {
period: “20190101000000”
}
}
// 응답코드 400
{
error_code: -10003,
validations: {
"name": ["중복 상품명"],
"discountA.period": ["톡딜할인과 기간이 겹쳐 판매가 0원인 기간이 존재함"]
}
}
위의 서버 응답을 보면 validations에 각 필드별로 어떤 오류가 있는지 배열로 응답하고 있습니다. discountA.period는 discountA객체 내 period필드에 오류가 있다는 의미입니다. 응답을 파싱하여 path에 해당하는 컨트롤을 찾아 에러를 넣어주는 모듈을 만들었습니다.
해당 모듈이 에러를 넣어주면 앞서 언급한 디렉티브가 상태를 감시하고 있다가 오류를 동적으로 출력할 수 있게 되었습니다.
<form [formGroup]="form">
<input type="text" formControlName="name" />
<-- 동적으로 삽입 --> <app-error>중복 상품명</app-error>
<input type="text" formControlName="price" />
<div formGroupName="discountA">
<input type="text" formControlName="period" />
<!-- 동적으로 삽입 --> <app-error>톡딜할인과 겹쳐 ...</app-error>
</div>
</form>
다만 이렇게 사용할 경우 API를 호출해야만 모든 필드에 대한 오류 메시지를 볼 수 있다는 UX이슈가 있는데요. 입력과 동시에 피드백이 필요한 부분은 기획 협의 하에 서버와API를 별도로 협의해 사용하고 있습니다. 하지만 그렇게 적용해 쓰는 부분은 의외로 많지 않습니다.
해당 모듈 사용으로 컴포넌트에 FormGroup, FormArray, FormControl만 잘 구성하고 디렉티브 연결만 하면 유효성검사 오류 관련 코드와 템플릿을 작성하지 않아도 됩니다.
import { Component } from "@angular/core";
import { FormGroup, FormControl, Validators } from "@angular/forms";
@Component({
selector: "my-app",
template: `
<form [formGroup]="form">
<input formControlName="name" /> <!-- 오류 템플릿 없음 -->
</form>
`
})
export class AppComponent {
form = new FormGroup({
name: new FormControl("", Validators.required)
});
}
위와 같이 작성하는것 만으로도 아래처럼 오류가 자동으로 출력되도록 할 수 있습니다.
해당 모듈 자체를 개발하고 안정성을 확보할때까지는 시간이 걸렸지만 현재는 안정적으로 동작하고 있고 서비스 내 판매자, 관리자에서 모두 사용하여 생산성 크게 끌어올릴 수 있었습니다.
개발에 필요한 모든 것이 포함되어 서드파티 패키지가 필요없다
FE개발파트 내 모든 개발자가 유연하게 여러 서비스의 이슈를 대응하기 위해서는 코드의 파편화가 없어야 합니다. 프레임웍이 같아야 함은 물론이고 그 안에 사용되는 패키지들도 마찬가지입니다. Angular는 SPA개발에 필요한 모든 모듈을 퍼스트파티로 제공합니다. 따라서 코드의 파편화가 적어 이 부분에서 매우 유리합니다.
- XHR – @angular/common/http
- Forms – @angular/forms
- Routing – @angular/router
- Testing – @angular/testing
- Animation – @angular/animation
- PWA – @angular/service-worker
- 기타 등등 ...
또 Angular자체적으로 프레임웍 마이그레이션을 위한 명령어를 제공합니다. `ng update`한번으로 현재 버전에서 최신 버전으로 모든 패키지들을 쉽게 업데이트할 수 있습니다. 또 update.angular.io 사이트에서 각 버전 업데이트에 어떤 Breaking Changes가 있고 New Feature는 무엇인지 한눈에 볼 수 있어 매우 안정적으로 서비스를 운영할 수 있습니다.
자동 Change Detection, 쉬운 성능 최적화로 인한 개발 편리함
zone.js를 이용한 자동 Change Detection
Angular는 zone.js를 활용한 자동 Change Detection을 채용하고 있습니다. 따라서 zone.js가 감지할 수 있는 선이라면 컴포넌트의 프로퍼티를 그냥 변경해도 알아서 뷰가 업데이트 됩니다.
@Component({
selector: 'app-product-list',
template: `
<div>
click count: {{count}}
<button type="button" (click)="onClick()">click</button>
</div>
`})
export class ProductListComponent {
count = 0;
onClick() {
this.count += 1;
// 컴포넌트의 프로퍼티를 그냥 변경해도 클릭 핸들러 실행 후 뷰가 업데이트 된다
}
}
이것이 가능한 이유는 zone.js가 아래 목록을 포함한 표준 브라우저 API들을 monkey patching하고 있기 때문입니다.
setTimeout/clearTimeout | setImmediate/clearImmediate | setInterval/clearInterval | requestAnimationFrame/cancel |
alert/prompt/confirm | Promise | EventTarget | HTMLElement on properties |
XMLHttpRequest.send/abort | XMLHttpRequest on properties | WebSocket on properties | MutationObserver |
그외 브라우저 표준 API 들 ... |
setTimeout을 예제로 수도 코드를 보면 아래와 같습니다.
const origin = window.setTimeout;
window.setTimeout = function(...args) {
origin(...args); // 실제 setTimeout을 호출하고
ngZone.runChangeDetection(); // Angular에게 모델의 변화가 있을수도 있음을 알림
}
브라우저에서 어떤 비동기 동작이 일어났다면 모델이 변화가 있을것으로 예상하고 CD를 실행하는 것입니다. 실제로는 ngZone이라는 Angular모듈이 이벤트를 받아 정말로 필요한 경우에만 CD가 돌도록 제한하고 있어 대부분의 경우 성능상의 이슈는 없습니다. 그리고 성능 최적화가 필요한 컴포넌트 별로 자동 CD가 일어나는 조건을 제한해 성능을 향상시킬 수 있습니다. 또 필요한 경우 zone.js가 코드를 monkey fetching하지 않는 조건에서 돌릴수도 있습니다.
이 내용을 알게된 대부분의 개발자분들은 성능저하를 우려하는데. 4년간 서비스를 운영하며 zone.js를 이용한 자동 CD때문에 성능이 느려 이슈가 되었던적은 한번도 없습니다. 이는 모바일 웹에서도 마찬가지입니다.
쉬운 성능 최적화
Angular는 공식 가이드대로 개발하고 몇 가지 코드만 수정하면 lazy loading, prefetching과 같은 고급 성능 최적화를 쉽게 적용할 수 있습니다. 아래처럼 라우팅 설정을 하면 자동으로 code splitting이 적용되며. 옵션 추가시 idle상태에 다른 모듈을 prefetching 합니다.
@NgModule({
imports: [
RouterModule.forRoot([
{
path: '/product',
loadChildren: () =>
import('./product/product.module')
.then(mod => mod.ProductModule) // lazyloading 적용
},
{
path: '/delivery',
loadChildren: () =>
import('./delivery/delivery.module')
.then(mod => mod.DeliveryModule)
}
], {preloadingStrategy: PreloadAllModules}) // prefetching 적용
]
})
class EntryModule {}
/product 페이지를 진입해서 ProductModule을 받아 실행한 뒤. idle상태가 되면 DeliveryModule도 자동으로 다운로드합니다. 이 옵션은 퍼스트파티로 제공되는 옵션입니다.
재미있는 것은 서드파티 옵션을 사용하면. /delivery로 향하는 링크 버튼이 문서의 뷰포트에 노출되었을 때에만 즉시 DeliveryModule을 다운받도록 설정할 수도 있다는 점입니다.
강력한 CLI도구
@angular/cli도 마찬가지로 퍼스트 파티 패키지이며 SPA개발에 필요한 모든 도구를 포함하고 있습니다. 컴포넌트, 서비스, 모듈 등 모든 파일들을 명령어로 쉽게 추가할 수 있고 심지어 기존 코드를 읽어 import문도 작성해줍니다.
만약 명령어를 통해 서비스에 PWA기능을 추가한 경우 자동으로 @angular/service-worker 패키지도 추가하여 package.json파일 자체를 수정해줍니다. 기능 추가에 따라 알아서 의존 패키지를 서비스에 추가해주는것이죠.
이 cli의 기능은 사실 @angular/schematics를 활용한 패키지인데요. 따라서 이 패키지를 사용해 우리만의 마이그레이션 스크립트도 작성할 수 있습니다. 예를 들면 반복되는 비즈니스 로직이 이미 포함된 컴포넌트를 만들거나. Angular모듈 트리를 인식해 특정 패턴의 코드를 일괄적으로 치환하는 수준의 스크립팅도 가능합니다.
cli도구가 앱 개발 초기에만 쓰이고 나중에는 사용하지 않기 마련인데. @angular/cli는 앱의 개발 초기부터 후반 운영에까지 모두 골고루 쓰이고 있습니다.
서비스의 전반적 성능을 고려해주는 프레임웍 업데이트
앞서 언급했던 대로 Angular는 SPA개발에 필요한 모든것을 퍼스트파티로 제공하고 있기 때문에 각 패키지의 성능을 모두 고려하고 있어서. 이를 이용하는 서비스는 앱의 전체적인 성능이 증가하는 장점이 있습니다. v6 ~ v8의 업데이트 내역 중 몇가지를 살펴보면 아래와 같은데요.
v6 | v7 | v8 |
- Treeshakable Providers - Remove web-animation-js - Add `ng update`LTS Support |
- Remove reflect-metadata |
- Differential Loading - Ivy Renderer (Incremental DOM) |
v6의 Treeshakable Provider 업데이트를 통해 파일단위의 treeshaking뿐만 아니라 클래스 단위의 treeshaking이 가능하게 되었습니다. 그리고 `ng update` 명령 추가와 LTS 지원으로 안정적인 운영을 할 수 있게 되었습니다.
v7의 Remove reflect-metadata는 원래 reflect-metadata polyfill은 필요가 없게 되었는데 개발자들이 실수로 polyfill로 추가해 쓰는 것을 angular개발팀이 알고 마이그레이션 시에 누락하는 않도록 스크립트를 추가해주었다고 합니다. 개발자의 실수마저도 서포팅해주고 있는 부분입니다.
v8에 추가되는 differential loading은 Angular CLI가 repository에 있는 browserslist파일을 읽어 서비스의 지원 범위를 파악하고 만약 빌드 설정이 범위를 벗어나게 되어있다면 하위 호환을 위한 번들을 자동으로 생성해주는 기능입니다.
예를 들어 browserslist에 IE11을 지원하게 적어 놓고 tsconfig.json의 target 을 es2015로 지정했을 경우 IE11은 스크립트 오류가 발생할텐데요. 이 때 module스펙 지원 번들과 미지원 번들을 별도로 생성하도록 자동으로 설정하는 것이죠. 이것으로 브라우저 지원 여부에 따라 7~20%의 번들 사이즈 감소 효과도 있다고 합니다.
Ivy렌더러는 Angular내부에서 템플릿을 토대로 DOM렌더링을 하는 코어 모듈을 새로 만드는 업데이트 인데요.. 내부적으로 Incremental DOM을 사용해 템플릿 내부 부분 단위의 treeshacking이 가능해지고. vdom에 비해 훨씬 적은 메모리를 사용하는 것을 포함하고 있습니다. 메모리가 제한적인 모바일 웹 서비스에 도움이 될 것이라 생각합니다.
rxjs
rxjs는 tc39의 스펙인 Observable의 JS 구현체로 일련의 비동기 이벤트를 쉽게 다룰 수 있는 인터페이스를 제공합니다. Angular는 내부적으로 rxjs를 채택하고 있어 비동기 이벤트를 다루는 부분에서 상당히 유연하게 처리가 가능합니다. 두 가지의 실무적인 예제를 소개합니다.
Referrer와 CurrentUrl 구하기
router.events
.pipe(
filter(e => e instanceof NavigationEnd),
pairwise()
)
.subscribe(([referrer, currentUrl]) => {
console.log(referrer); // referrer
console.log(currentUrl); // currentUrl
});
router는 Angular내부에서 인앱 라우팅을 다룰 수 있는 인스턴스입니다. 위의 코드에선 라우터에서 발생하는 여러 이벤트 타입 중 `NavigationEnd` 이벤트만 거르고 있습니다. 이로 인해 라우팅이 끝날때마나 콜백이 실행되는 구조가 됩니다.
여기에 rxjs의 `pairwise`연산자를 붙였습니다. 이제 이벤트가 2 개씩 짝지어서 콜백이 실행됩니다. 최초 앱 진입의 `NavigationEnd`는 무시되고. 두 번째 `NavigationEnd`부터 배열로 만들어져 콜백이 실행되므로. 각각 referrer와 currentUrl로 사용할 수 있게 됩니다.
중복 API요청 취소하기
const subscription = activatedRoute.queryParams.pipe(
switchMap(queryParams => httpClient.get('/api/product', queryParams)
).subscribe();
브라우저의 쿼리가 바뀌었을 때 서버로부터 페이징 데이터를 받아오는것은 자주 쓰이는 패턴입니다. 하지만 단순하게 이벤트만 연결한 경우 앞으로가기, 뒤로가기를 빠르게 반복했을 때 서버 응답 순서가 달라 2페이지를 요청한 상태인데 3페이지 데이터가 출력되는 경우가 있습니다.
이 때 rxjs의 switchMap을 사용할 경우. 새로운 요청이 들어왔을 때 아직 진행중인 요청이 있다면 취소합니다 따라서 순서가 꼬여서 원치 않는 화면이 출력되는 것을 막을 수 있습니다.
마무리
지금까지 카카오커머스의 FE개발파트가 Angular를 사용하는 이유에 대해서 공유드렸습니다.
Angular는 러닝 커브가 비교적 가파르게 느껴질 수 있는 프레임웍입니다. 그 이유는 SPA를 개발하기 위한 모든 기능을 제공하고 있기 때문입니다. 프레임웍이든 라이브러리든 실 서비스 운영을 위해서는 XHR, 라우팅 등의 여러 기능들이 필수적인데요.
Angular는 그 모든 기능을 퍼스트파티로 안정적으로 제공하고 있습니다. 이 부분은 UI개발팀을 효율적으로 운영할 수 있는 밑거름이 됩니다. 처음부터 모든것을 알아야 한다는 부담만 덜어내고 폴더 구조와 서비스 및 컴포넌트의 역할까지 친절하게 설명되어 있는 가이드 문서를 참고하며 개발하면 의외로 편리한 부분이 많습니다.
그리고 Angular는 개발자가 고급 최적화 기능을 쉽게 적용 할 수 있는 형태로 개선되고 있습니다. 또 어느 한 부분이 아닌 앱의 전체적인 성능 즉 First Input Delay자체를 개선할 수 있는 형태의 업데이트가 제공되고 있습니다. 현재 몇가지 단점보다 앞으로의 장점들이 더욱 더 기대되는 프레임웍입니다.
끝으로 저희 카카오커머스 FE개발파트에서는 성장을 주도할 새로운 크루를 모집하고 있습니다. Angular를 사용할 줄 모르더라도. JS, React 어느것이든 하나만 잘 할 수 있다면 상관없으니 많은 지원을 부탁드립니다. 고맙습니다.
함께 해요!
'krew story > tech story' 카테고리의 다른 글
2021 카카오커머스 개발자 공개 채용 (0) | 2021.03.22 |
---|---|
카카오커머스 개발자의 하루 (Vlog) (0) | 2020.11.17 |
플랫폼개발랩 1만의 기적!!! (0) | 2020.06.19 |
주문개발파트 <Benji> - "카카오커머스에서 개발하실래요?" (0) | 2020.05.12 |
JDBC 커넥션 풀들의 리소스 관리 방식 이해하기 (0) | 2020.02.17 |