[ReactiveX] RxJS 사용 예시
RxJS를 사용하여 자동 완성 검색창, 애니메이션 그림판, 아이폰 인증번호 UI를 구현하는 다양한 예제를 살펴보세요.
Contents
AutoComplete 검색창 만들기
자동검색 기능은 서비스들에서 흔하게 사용되는 기능인데, 이를 RxJS 를 통해서 깔끔하게 구현할 수 있다. 흔히 사용되는 기능이므로, 이 기능을 구현하는 방법은 다양하다. ReactiveX 를 사용하면, 지역 변수를 하나도 선언하지 않고, 함수와 Stream 의 선언들로 해당 기능을 깔끔하게 구성할 수 있다. 지역 변수를 사용하지 않으므로, 예외와 버그도 당연히 줄어들게 된다.
HTML, CSS 부분은 다음과 같이 설정한다.
<html>
    <head>
        <script src="https://unpkg.com/@reactivex/rxjs/dist/global/rxjs.umd.js"></script>
        <style>
            body { 
              padding: 12px; 
              font-family: sans-serif 
            }
            #keyword {
              width: 200px; height: 24px; line-height: 24px;
              margin-bottom: 8px; padding: 2px 8px;
              border: 2px solid #ccc;
              border-radius: 4px;
            }
            #result {
              width: 200px;
            }
            #result article {
              width: 192px; height: 30px; line-height: 30px;
              padding: 0 12px;
              border: 1px solid #ddd;
              background-color: #f5f5f5;
              cursor: pointer;
            }
            #result article:not(:last-child) { border-bottom: 0; }
            #result article:first-child { border-radius: 4px 4px 0 0; }
            #result article:last-child { border-radius: 0 0 4px 4px; }
            #result article:hover {
              background-color: white;
              color: dodgerblue;
            }
            #result .searching {
              width: 192px; height: 30px; line-height: 30px;
              padding: 0 12px;
              background-color: dodgerblue; color: white;
              border-radius: 4px;
            }
        </style>
    </head>
    <body>
        <input id='keyword' type='text' />
        <br />
        <div id='result'></div>    
    </body>
</html>
- 간단히 자동검색 기능 구현하기
 
const {fromEvent, from} = rxjs;
const {ajax} = rxjs.ajax;
const {tap, reduce, mergeMap, switchMap, pluck, retry, 
map, scan, filter, debounceTime, distinctUntilChanged} = rxjs.operators;
const url = "http://127.0.0.1:3000/people/quarter-error";
const keyword = document.querySelector("#keyword");
const result = document.querySelector("#result");
fromEvent(keyword, "keyup").pipe(
    // Backspace 키의 경우는 무시한다.
    filter(e => event.code != "Backspace"),
    // 0.5초 이내에 값이 발행되었으면, 무시한다.
    debounceTime(500),
    // 입력 값을 받아온다.
    pluck("target", "value"),
    // 입력 길이가 1 이상인 것들만 남긴다
    filter(typed => typed.length > 1),
    // 최근에 발행된 값이 있으면, 이전에 발행된 값은 무시한다.
    switchMap(keyword => ajax(`${url}?name=${keyword}`).pipe(
            retry(3)
        )
    ),
    // Ajax 요청으로부터, response 값만 모아온다.
    pluck("response"),
).subscribe(showResults);
function showResults(results) {
    from(results).pipe(
        // 이름 정보를 가공한다.
        map(person => `${person.first_name} ${person.last_name}`),
        // HTML 로 만들어 줌
        map(name => `<article>${name}</article>`),
        // reduce 와 비슷하게 하나의 HTML 로 누적
        scan((ac, cv) => ac + cv, ""),
        tap(console.log)
    ).subscribe(people => result.innerHTML = people);
}
위의 예시도 잘 동작하지만, 위의 Observable 을 잘 쪼개면, 더 깔끔하게 코드를 관리할 수 있다. 만약 유저의 input 을 받아서 하나의 로직을 수행하게 할 때에는 위와 같이 코드를 짜도 무방하지만, 만약 2개 이상의 이벤트를 발생시켜야 한다면, 하나의 Observable 을 쪼개서 공통 로직들로 분리할 수 있다.
아래의 예시는, 검색했을 때 "입력중…" 부분을 UI 에 표시해주는 기능을 추가했는데, 이를 위해서 Observable 을 두 가지로 쪼갰다.
- UX 를 위한 기능 추가
 
const { fromEvent, from, merge } = rxjs
const { ajax } = rxjs.ajax
const { mergeMap, switchMap, pluck, retry, map, filter, 
debounceTime, distinctUntilChanged, mapTo, scan, tap } = rxjs.operators
const url = 'http://127.0.0.1:3000/people/quarter-error'
const keyword = document.querySelector('#keyword')
const result = document.querySelector('#result')
// searchLoading$, searchResult$ 의 공통 로직 
const searchInit$ = fromEvent(keyword, "keyup").pipe(
    filter(event => event.code != "Backspace"),
    pluck("target", "value"),
    filter(typed => typed.length > 1),
    debounceTime(500),
    distinctUntilChanged(),
);
// Ajax 요청이 수행되는 동안, "검색중..." 을 UI 에 표시해 준다.
const searchLoading$ = searchInit$.pipe(
    mapTo('<div class="searching">Searching...</div>')
);
// 검색한 결과를 HTML 형태로 만들어 준다.
const makeAutocompleteHTML = (results) => from(results).pipe(
    // 이름 정보를 가공한다.
    map(person => `${person.first_name} ${person.last_name}`),
    // HTML 로 만들어 줌
    map(name => `<article>${name}</article>`),
    // reduce 와 비슷하게 하나의 HTML 로 누적
    scan((ac, cv) => ac + cv, ""),
    tap(console.log)
)
// 서버에 Ajax 요청을 날린 후, 검색 결과를 화면에 표시해 준다.
const searchResult$ = searchInit$.pipe(
    switchMap(keyword => ajax(`${url}?name=${keyword}`).pipe(retry(3))),
    pluck("response"),
    mergeMap(makeAutocompleteHTML),
);
merge(searchLoading$, searchResult$).subscribe(html => result.innerHTML = html);
애니메이션 그림판 만들기
HTML 기본 설정은 다음과 같이 해준다.
<html>
    <head>
        <script src="https://unpkg.com/@reactivex/rxjs/dist/global/rxjs.umd.js"></script>
        <style>
            body { margin: 0; padding: 0; }
            div { padding: 16px; }
        </style>
    </head>
    <body>
        <canvas id='canvas' width=600 height=360 style='background: #EEE'></canvas>    
    </body>
</html>   
아래의 함수는 클릭할 때마다, 이전에 클릭되었던 점과 지금의 점을 선으로 이어준다. mergeMap 으로 실행하면 이전에 실행되었던 연산이 끝나기 전에, 병렬적으로 현재 클릭된 점을 이어준다. concatMap 으로 실행하면, 이전에 실행되었던 연산이 모두 끝날 때까지 기다렸다가, 새로 클릭된 점을 잇는다. switchMap 으로 실행하면, 이전에 실행되었던 연산을 강제로 종료시키고, 새로운 선을 그린다.
const { fromEvent, interval, iif, empty, merge, BehaviorSubject } = rxjs
const { map, tap, startWith, scan, takeUntil, take, pluck, switchMap, throttleTime, mergeMap } = rxjs.operators
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
ctx.lineWidth = 3
ctx.strokeStyle = 'dodgerblue'
ctx.font = '16px sans-serif';
const whichMap = rxjs.operators.mergeMap;
function drawLine (xy) {
    ctx.beginPath()
    ctx.moveTo(xy.x1, xy.y1)
    ctx.lineTo(xy.x2, xy.y2)
    ctx.closePath()
    ctx.stroke()
}
fromEvent(canvas, 'click').pipe(
    map(e => { return { x: e.x, y: e.y }}),
    startWith({ x1: null, y1: null, x2: null, y2: null }),
    scan((acc, cur) => {
        return { x1: acc.x2, y1: acc.y2, x2: cur.x, y2: cur.y }
    }),
    whichMap(xy => iif(
        _ => xy.x1 === null,
        empty(),
        interval(10).pipe(
            startWith({ x1: xy.x1, y1: xy.y1, x2: xy.x1, y2: xy.y1  }),
            scan((acc, cur) => {
                return {
                    x1: acc.x1, y1: acc.y1,
                    x2: acc.x2 + (xy.x2 - xy.x1) / 100,
                    y2: acc.y2 + (xy.y2 - xy.y1) / 100,
                }
            }),
            take(100)
        )
    )),
).subscribe(drawLine)
아이폰 인증번호 구현하기
iCloud 로그인 할 때, 아이폰 인증번호 6자리를 입력하는 기능이 있다. 이 기능을 연습문제 삼아 구현해 보았다.

<html>
    <head>
        <script src="https://unpkg.com/@reactivex/rxjs/dist/global/rxjs.umd.js"></script>
    </head>
    <body>        
        <div>
          <span>인증번호</span>
          <span id="answer"></span>
        </div>
        <div style="width: 100%; text-align: center; margin-top: 100px;">
          <input class="one-digit" id="input1" type="text" />
          <input class="one-digit" id="input2" type="text" />
          <input class="one-digit" id="input3" type="text" />
          <input class="one-digit" id="input4" type="text" />
          <input class="one-digit" id="input5" type="text" />
          <input class="one-digit" id="input6" type="text" />
        </div>
        <style>
            input.one-digit {
              width: 30px;
              height: 40px;
              font-size: 40px;
            }
        </style>
    </body>
</html>
const {fromEvent, iif} = rxjs;
const {startWith, identity, scan, pluck, map, last, filter, tap, debounceTime, mergeMap, reduce} = rxjs.operators;
// 인증 코드를 생성해 준다.
const generateRandomNumber = (digit) => (Math.random()+1).toString(36).substr(2, PASSWORD_LENGTH).toUpperCase();
const PASSWORD_LENGTH = 6;
let ANSWER = "";
// 인증 코드를 생성해준다.
const initializeAnswer = () => {
    clearInput();
    ANSWER = generateRandomNumber(PASSWORD_LENGTH).toString();
    document.querySelector("#answer").innerHTML = ANSWER;
}
// HTML 의 Input 값을 비워준다.
const clearInput = () => {
  for(let i = 1 ; i <= PASSWORD_LENGTH ; i++ ) {
    document.querySelector(`#input${i}`).value = "";      
  }
}
// 빈 String 인지 체크
const isEmpty = (input) => input == "" || !input;
// 최대 6자리만 출력되게 검사한다.
const trimInput = (input) => {
    if(input.length < PASSWORD_LENGTH) {
        return input;
    }
    return input.substr(input.length - PASSWORD_LENGTH, input.length);
}
// Input 에 값을 그려준다.
const draw = (input) => {
    clearInput();
    if(isEmpty(input)) {
        return;
    }
    input.split("").forEach((char, idx) => {
        document.querySelector(`#input${idx+1}`).value = char;
    })
    return input;
}
const initializeInput$ = fromEvent(document, "keyup").pipe(
startWith(""),
debounceTime(100),
    filter((input) => !isEmpty(input)),
    pluck("key"),
    map((val) => val.toString()),
    map((val) => val.toUpperCase()),
    scan((ac, cv) => {
        if(cv.toUpperCase() == "BACKSPACE") {
            return ac.substr(0, ac.length-1);
        }
        if(cv.length !== 1) {
            return ac;
        }
        return ac+cv;
    }, ""),
    map(trimInput),
);
const checkAnswer$ = initializeInput$.pipe(
  tap(input => console.log("Check Answer ", input, ANSWER, input=== ANSWER)),
    filter((input) => ANSWER === input),
);
const drawInput$ = initializeInput$.pipe(
    tap(draw),
);
initializeAnswer();
drawInput$.subscribe();
checkAnswer$.subscribe(_ => {
    alert("SUCCESS");
  initializeAnswer();
});
            
            이것도 읽어보세요