🎟️

Observer Pattern과 Final-Form

Created
2021/04/11 03:44
Tags
Frontend
Subtitle
#Observer Pattern #Final-Form #Redux #Erik Rasmussen
해당 게시글은 javascript form 상태 관리 라이브러리인 Final-Form을 통해 옵져버 패턴을 어떻게 사용하는지 관찰하는 글입니다.

0. Final-Form의 소개

Observer Pattern은 꽤 일반적인 디자인 패턴이다. 그렇기 때문에 많은 분들이 아시겠지만, Final-Form은 그에 비해 모르시는 분들도 계실꺼 같다. 이러한 이유도 Observer Pattern을 설명하기 위해 왜 Final-Form을 가지고 왔는지 잠시 소개드리려 한다.

Erik Rasmussen

Final-Form maintainer인 Erik Rasmussen이다.
Final-Form를 개발하기 이전에는 Redux-Form의 개발 및 유지보수를 담당했다.
Redux-Form와 React관련 오픈소스를 운영하면서 얻은 경험치를 바탕으로 Final-Form이라는 해답을 내놓았다고 한다
나는 개발자를 나눌 때, 보통 노력형과 천재형으로 나누곤 하는데 Erik Rasmussen Final-Form을 읽으면서 천재형 개발자 쪽에 가깝다고 생각했다.
Form이라는 UI자체의 특징인지 모르겠으나 Final-Form은 정말 많은 상태를 가지고 있는데, 이런점 때문에 더 그렇게 느꼈다.
성능은 보장합니다. 코드는 어렵습니다.

Final-Form 의 철학

Final Form은 Zero Dependencies, Framework Agnostic, Minimal Bundle Size, High Performance등의 철학을 가지고 있다. 그 중 High Performance에 대해 이렇게 설명되어 있다.

높은 퍼포먼스

Final Form 특정 부분의 상태에 대한 업데이트를 구독하기 위해 이미 잘 알려진 Observer pattern 을 사용한다.
만약 Redux에 익숙하다면, Redux에서 selectors를 사용해서, 컴포넌트에 대해 당신이 알리고자 하는 상태의 slice 를 정확하게 지정하는 것과 비슷합니다.
결과적으로, 여러분의 Form을 최대 성능으로 부드러운 동작을 하게 합니다. 본문 참고
위의 설명에서 Final-Form은 Observer pattern을 통해서 최고의 성능으로 Form의 상태 관리를 한다고 주장한다. 어떤 구조인지 살펴보도록 하자.

1. Observer Pattern 이란

Observer Pattern에 대한 깊은 고민을 하는 글은 아니라서, 간단하게 개념만 살펴가고자 한다.
옵저버 패턴은 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다. 주로 분산 이벤트 핸들링 시스템을 구현하는 데 사용된다.

구현

이 패턴의 핵심은 옵저버 또는 리스너(listener)라 불리는 하나 이상의 객체를 관찰 대상이 되는 객체에 등록시킨다. 그리고 각각의 옵저버들은 관찰 대상인 객체가 발생시키는 이벤트를 받아 처리한다.
개인적으로 옵저버 패턴을 이전부터 보아왔다. 그런데 이해하기 전까지 이 구조가 그렇게 특별한 일인가 싶었다.
설명에서 말하다시피 그냥 Subject에 Observer들을 저장해두고 실행하는게 전부이다. ( 사실 그게 전부다. )
그런데 옵저버 패턴으로 만들어진 다른 프로젝트를 여럿 접하면서 조금씩 특별함을 이해할 수 있었다.
Observer패턴은 Pub/Sub(구독/발행) 패턴이라고도 불리운다.
많은 예제에서 우리 일상에서 가장 가까운 구독 시스템인 유튜브를 위의 설명에 비유해서 설명한다.
이해하기 좋은 예제 라는 점에 동의하지만, 이 비유 에는 특이점이 있다. 이 특이점 때문에 잘못된 비유라고 생각이 들기도 하지만, 다른 한편으로는 Observer패턴의 특별함을 오히려 잘 설명한다는 생각이 든다.

Subject

주체, 즉 유튜브 시스템 혹은 유튜버이다.
구독자들을 보유하고 있고 영상업로드, 커뮤니티 게시글 업로드등의 행위를 한다.

Observer

알림 시스템이다.
구독자들이 알림 요청을 하면 유튜버의 행위에 대한 여러 알람을 구독자들에게 전달한다.
예를 들어 새로운 영상을 업로드 하면 시스템을 통해서 모든 구독자들에게 그 사실을 알린다.
여기까진... 알겠는데...

ConcreateObserver

ConcreateObserver는 구독자 일것이다.
Subject의 행위에 따라 알림을 받고 그에 대한 행위를 하기 때문이다. 예를 들면 업로드된 영상을 시청하는 일이 되겠다.
근데 다이어그램에서 notifyObservers는 꽤 이질적이다. Observer의 행위를 유튜버가 실행하는 점이다.
마치 유투버가 영상올리고, 내 눈앞에 휴대폰을 들이미는 형식인거 같지 않나?

구현 ver.2

사실 EventEmitter와 같은 특별한 이벤트 처리 API없이는 Subject의 행위에 따라 Observer가 매번 액션을 취하는 것은 불가능하다. 유튜버가 upload라는 함수를 실행할때마다 구독자가 read라는 함수를 실행할수 있겠는가? 때문에 위의 다이얼로그처럼 register에 어떤 액션을 취할지에 대한 정보까지 포함하여 Subject에게 등록되어야 한다.

2. Final-Form Example & Interface

Core API Interface

createForm

(config: Config) => FormApi import { createForm } from 'final-form'
JavaScript

registerField

( name: string, // 구독자명 subscriber: FieldState => void, // notify ( 알람시의 액션 ) subscription: { [string]: boolean }, // 업데이트 목록 config?: FieldConfig ) => Unsubscribe // 구독해제
JavaScript

Usage

import { createForm } from 'final-form' const form = createForm({ initialValues, onSubmit, // required validate }) const unregisterField = form.registerField( 'username', (fieldState: FieldState) => { // 업데이트할 Field UI const { blur, change, focus, ...rest } = fieldState // 구독하는 값 외에도 fieldState에는 상태를 업데이트하는데 필요한 함수도 포함됩니다. }, { // FieldSubscription: 업데이트 할 값 목록 touched: true, valid: true, value: true } ) form.submit()
JavaScript

Example

3. Observer pattern 적용 원리

예제 코드 분석

Subject 등록하기
createForm으로 subject 인스턴스를 생성한다.
이 때, validate onSubmit initialValues 와 같은 Subject의 상태와 Observer가 notify시에 글로벌하게 처리해야 하는 부분들을 미리 등록한다.
const onSubmit = values => { JSON.stringify(values, undefined, 2) ... 이하생략 } const form = createForm({ onSubmit, initialValues: { color: '#0000FF' }, validate: values => { const errors = {} if (!values.firstName) { errors.firstName = 'Required' } if (!values.lastName) { errors.lastName = 'Required' } if (values.color === '#00FF00') { errors.color = 'Gross! Not green! 🤮' } return errors } })
JavaScript
Observer 등록하기
registerField를 통해서 필드 값인 Observer를 등록한다.
이 때, 인자로는 각각 다음을 받는다.
name: string, // 구독 필드 명 subscriber: FieldState => void, // notify ( 알람시의 액션 ) subscription: { [string]: boolean }, // 업데이트 목록
JavaScript
subscriber에서는 다음 액션을 취한다.

FieldState에 적용할 이벤트를 EventListner에 등록

const { blur, change, error, focus, touched, value } = fieldState input.addEventListener('blur', () => blur()) input.addEventListener('input', event => change( input.type === 'checkbox' ? event.target.checked : event.target.value ) ) input.addEventListener('focus', () => focus())
JavaScript

Value 업데이트

if (input.type === 'checkbox') { input.checked = value } else { input.value = value === undefined ? '' : value }
JavaScript

에러의 노출 여부

if (errorElement) { if (touched && error) { errorElement.innerHTML = error errorElement.style.display = 'block' } else { errorElement.innerHTML = '' errorElement.style.display = 'none' } }
JavaScript

FieldState Event( notify )실행시 일어나는 일

여러 Subject의 이벤트가 존재하지만 여기서는 가장 많이 발생할것 같은 change 를 기준으로 설명하고자 한다.
change(event.target.value)
JavaScript
change함수는 다음과 같이 name과 파라미터인 값을 넘겨준다.
change: value => api.change(name, value)
JavaScript
그리고 change는 등록된 구독자들에게 notify 실행한다.
change: (name, value) => { const { fields } = state if (fields[name] && fields[name].value !== value) { fields[name].value = value state.formState.values = setIn(state.formState.values, name, value) || {} notifyFieldListeners() notifyFormListeners() runValidation(() => { notifyFieldListeners() notifyFormListeners() }) } },
JavaScript
state에는 Observer에 등록된 fields들의 정보를 담아두는 객체가 존재한다.
Subject에서 역시 fields의 값의 변화를 알아야 하기 때문에 setIn이라는 함수를 만들어 formState에도 변화한 값을 업데이트 해준다.
이후에 notifyFieldListenersnotifyFormListeners 같은 notify함수들이 실행되고, 등록된 validate가 존재한다면 runValidation 까지 실행하는 패턴이다.
const notifyFieldListeners = (force) => { if (inBatch) { return } const { fields, fieldSubscribers, formState } = state Object.keys(fields).forEach(name => { const field = fields[name] const fieldState = publishFieldState(formState, field) const { lastFieldState } = field if (!shallowEqual(fieldState, lastFieldState)) { field.lastFieldState = fieldState notify( fieldSubscribers[name], fieldState, lastFieldState, filterFieldState ) } }) }
JavaScript
notifyFieldListeners 는 다음과 같이 생겼는데, inBatch와 같은 상태를 만들어서 Batch처리를 하거나 shallowEqual를 통해 불필요한 연산을 줄이는 등의 성능 문제를 돕고 있다.
notify를 살펴보기 전에 잠시 publishFieldState 함수를 살펴보자.
const publishFieldState = ( form, field ) => { const { submitFailed, submitSucceeded } = form const { active, blur, change, error, focus, initial, name, submitError, touched, value, visited } = field const pristine = initial === value const valid = !error && !submitError return { active, blur, change, dirty: !pristine, error, focus, initial, invalid: !valid, name, pristine, submitError, submitFailed, submitSucceeded, touched, valid, value, visited } } // 가독성이 별로라서 formatter로 beautify하셔서 보시길 권장합니다 :)
JavaScript
잠시 살펴보고자 했던 이유는 많은 상태값을 사용하는 점을 공유하고 싶어서이다.
예를들면 dirty 의 반대 값은 pristine 이기 때문에 굳이 pristine 상태가 별도로 필요 없다고 생각하는 경우들을 많이들 본다.
pristine는 깨끗한 이라는 뜻으로, 한 번도 입력되지 않은 상태를 dirty는 이미 수정된 상태를 의미한다.
하지만 기록할 수 있는 모든 값들을 상태로 다 나누었다. 정확한 의도는 모르겠으나, Clean Code적인 관점에서 훨씬 의미 전달이 명확한 느낌이라서 좋은 패턴이라고 생각한다.
다시 notify 로 넘어가자.
function notify( { entries }, // notify 액션 관련 기능을 담아두는 객체 state, // 구독 필드의 상태 lastState, // 구독 필드의 지난 상태 filter // 상태 필터 ) { Object.keys(entries).forEach(key => { const { subscription, subscriber } = entries[Number(key)] notifySubscriber(subscriber, subscription, state, lastState, filter) }) }
JavaScript
function notifySubscriber( subscriber, subscription, state, lastState, filter, force = false ) { const notification = filter(state, lastState, subscription, force) if (notification) { subscriber(notification) } }
JavaScript
특별한 코드는 딱히 없다. 구독자들이 행위를 하기 위한 notification을 받아 행위를 실행시킨다.
여기서 filtersubscriptionstate ,lastState의 비교를 통해서 정말 업데이트 할 필요가 있는지 체크해서 정말 필요한 업데이트만 실행시킨다.

Validate Process

원본 Final-Form의 코드가 너무 복잡해서, 최대한 간략하게 코드를 나누었습니다. 그렇기 때문에 해당 코드를 바로 실행할 수는 없는 점을 알려 드립니다.
runValidation 는 formState와 fieldState의 validate를 실행한다.
const runValidation = (callback) => { const { fields, formState: { values } } = state state.validating++ const fieldKeys = Object.keys(fields) if (validate) { const errors = validate(values, processValidationErrors) if (errors) { if (isPromise(errors)) { errors.then(processValidationErrors, processValidationErrors) } else { processValidationErrors(errors) } } } else { let remaining = fieldKeys.length if (remaining) { fieldKeys.forEach(key => { runFieldLevelValidation(key, values, error => { fields[key].error = error remaining-- finish() }) }) } else { finish() } } }
JavaScript
이 때 비동기 처리 여부등 때문에, validating 와 같은 상태를 가진다. 특이한 점은 숫자를 이용한다는 점.
등록한 validate가 있다면 실행하고 없다면 field의 validate만 실행한다. 재미있는 점은 processValidationErrors 까지 validate 에게 넘겨주는 점이다.
processValidationErrors 은 실제 validate의 결과를 에러를 담는 함수이다. 특별한 점은 없다.
const processValidationErrors = errors => { // assign errors to each field state.error = errors[FORM_ERROR] let remaining = fieldKeys.length if (fieldKeys.length) { fieldKeys.forEach(key => { runFieldLevelValidation(key, values, localError => { const recordError = getIn(errors, key) fields[key].error = localError || recordError // local overrides record remaining-- finish() }) }) } else { finish() } }
JavaScript
finish 함수이다. 역시 특별한 점은 없다.
const finish = () => { if (remaining === 0) { state.validating-- if (callback) { callback() } } }
JavaScript
runFieldLevelValidation 는 Field수준의 Validate체크를 진행한다.
const runFieldLevelValidation = ( name, allValues, callback ) => { const field = state.fields[name] const { validators } = field const validatorKeys = Object.keys(validators) let returned = false if (validatorKeys.length) { let remaining = validatorKeys.length validatorKeys.forEach((index) => { if (!returned) { const validator = validators[Number(index)] const error = validator(field.value, allValues, processError) if (error && isPromise(error)) { error.then(processError, processError) } else if (validator.length < 3) { // validator.length<3은 callback이 undefined임을 의미함. processError(error) } } }) } else { callback() } }
JavaScript
등록한 validator 의 key들을 모두 돌며 error 를 생성해낸다.
processError 는 error 과정을 처리하고 이후에 callback 에게 에러를 전달한다.
const processError = (error) => { remaining-- if (remaining === 0 || (!returned && error)) { callback(error) returned = true } }
JavaScript
notify시에 위와 같은 과정으로 validate를 체크한다. 특히 눈여겨봐야 할 점은 validate 역시 조건 처리등을 통해서 불필요한 연산을 줄인점이나 remaining 와 같은 상태값을 이용한점등이 있다.
다만 아쉬운 점은 callback함수나 상태들이 너무 많은데 비해 그 값들이 각각 어떤 역할을 하는지는 어려운 점이다.

4. 마지막으로

Fina-Form이 Observer Pattern으로 High Performance를 가졌다고 한다. 사실 아직까지는 여러 Field에 대한 Form의 상태구현 구조 자체가 Observer Pattern에 적합한 느낌이고 High Performance에 대해선 와닫지는 않는다.
다만 상태비교를 통해서 업데이트 해야할 값들만 notify 한다거나, 이 글에서는 없지만 subscriber 를memoize와 같은 기법을 통해서 최적화 한 점들은 재미있게 보았다.
그리고 ... Observer Pattern과 Erik의 코드는 정말 쉽지 않다!
이렇게 공부해도 만들어보라고 하면 코딩 허접이라서 엄두조차 나지 않는다!