모델과 뷰를 분리하여 칵테일 검색 페이지 만들어보기

hksdpr·2022년 10월 30일
0

백엔드 개발자가 될 수 있지 않을까 하고 입사를 했는데 생각보다 프론트엔드 일을 많이 하게 되었다. 프론트엔드 개발을 하는 팀은 별도로 존재한다. 하지만 API로 서버에서 데이터를 호출하고 화면 구조에 맞춰 보여주는 부분은 아직 우리팀에서 담당하고 있다.

프로젝트는 모두 React.js나 Vue.js같은 고상한 프레임워크는 하나도 없이 script 태그로 Jquery만 추가해서 개발을 진행하고 있다. 필요한 파일들이 로딩되면 이벤트가 발생할 만한 부분을 모두 찾아서 핸들러 함수를 연결해주는 방식으로 개발을 하고 있는 중이다.

이런 식으로 개발을 진행하다 보니 생각보다 유지보수하기가 힘들어서 mvc 패턴 같은 것을 적용해보면 어떨까 싶었다. 그러나 프론트엔드 경험은 많이 없다보니 어떤 식으로 적용해야 할지 감이 잘 오지 않았다. 그래도 model가 view 분분이라도 나눠보면 어떨까 싶어 적용해보았다. 예시 페이지를 기존 방식으로 만들고 내가 생각한 새로운 방식으로 바꾸는 과정을 적어보려고 한다.

칵테일 검색 페이지

나는 칵테일을 좋아하니까 예시로 칵테일 검색 페이지를 만들어보려고 한다. 지구촌 어딘가의 누군가가 칵테일 정보를 조회할 수 있는 api를 만들어두어 사용했다.
사용한 칵테일 조회 api

위 api를 활용하여 아주 간단한 페이지를 만들었다.
칵테일 조회 페이지
전체 코드

<body>
    <header class="mdc-top-app-bar mdc-top-app-bar--short-fixed">
        <div class="mdc-top-app-bar__row">
            <section class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start">
                <span class="mdc-top-app-bar__title">Cocktails</span>
            </section>
            <section class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end" role="toolbar">
                <label class="mdc-text-field mdc-text-field--filled">
                    <span class="mdc-text-field__ripple"></span>
                    <span class="mdc-floating-label" id="my-label-id">검색어를 입력하세요.</span>
                    <input id="search-keyword-input" class="mdc-text-field__input" type="text"
                        aria-labelledby="my-label-id">
                    <span class="mdc-line-ripple"></span>
                </label>
                <button id="search-button" class="material-icons mdc-top-app-bar__action-item mdc-icon-button" aria-label="Search">
                    search
                </button>
            </section>
        </div>
    </header>
    <main class="mdc-top-app-bar--short-fixed-adjust ">
        <div class="mdc-layout-grid"  id="cocktail-list">
            
        </div>
    </main>
</body>

아주 간단하게 검색어를 입력할 수 있는 부분, 검색 버튼, 칵테일 정보 목록이 보여지는 구조를 가지고 있다.

기존 개발 방식

기존에 사용하던 방식은 selector를 통해 이벤트가 발생하는 곳을 찾고 이벤트가 발생했을 때의 동작을 함수로 등록해주는 방식이다. jquery를 이용해서 addSearchButtonClickEvent 함수를 호출하여 검색 버튼에 핸들러를 등록한다.

$(function(){
    addSearchButtonClickEvent();
});

문제는 addSearchButtonClickEvent(); 함수이다.

function addSearchButtonClickEvent(){
    $('#search-button').on('click', function(){
        var keyword = $('#search-keyword-input').val();
        $.ajax({
            type: "GET",
            url: "https://www.thecocktaildb.com/api/json/v1/1/search.php?s="+keyword,
            data: 'json'
        }).done(function(res){
            var cocktailList = $('#cocktail-list');
            cocktailList.empty();
            for(var i=0;i<res.drinks.length;i++){
                var cocktailNode = `<div class="mdc-card">
                <div class="mdc-card__primary-action" tabindex="0">
                    <div class="mdc-card__media mdc-card__media--16-9"
                        style="background-image: url(&quot;`+res.drinks[i].strDrinkThumb+`&quot;);">
                    </div>
                    <div class="mdc-card__content">
                        <h2 class="mdc-typography mdc-typography--headline6">`+res.drinks[i].strDrink+`</h2>
                        <h3 class="mdc-typography mdc-typography--subtitle2">`+res.drinks[i].strInstructions+`</h3>
                    </div>
                    <div class="mdc-card__content mdc-typography mdc-typography--body2">
                        <ul class="mdc-list mdc-list--two-line" name="ingredients-list-`+i+`">`
                for(var j=1;j<=15;j++){
                    if(res.drinks[i]['strIngredient'+j]===null) break;
                    cocktailNode += `<li role="separator" class="mdc-list-divider"></li>
                    <li class="mdc-list-item" tabindex="0">
                        <span class="mdc-list-item__text">`+
                            `<span class="mdc-list-item__primary-text">`+(res.drinks[i]['strIngredient'+j]).trim()+`</span>`+
                            `<span class="mdc-list-item__secondary-text">`+(res.drinks[i]['strMeasure'+j] ?? '').trim()+`</span>`+
                        `</span>
                    </li>
                    `;
                }
                cocktailNode+=`</ul>
                        </div>
                    </div>
                </div>`
                cocktailList.append($(cocktailNode));
                const list = new MDCList(document.querySelector('ul[name=ingredients-list-'+i));
            }
        }).fail(function(err){
            alert('오류가 발생하였습니다.');
        })
    });
}

이 함수가 하는 일은 다음과 같다.

  • jquery로 버튼 찾기
  • jquery로 검색창에서 검색어 찾기
  • jquery로 ajax 통신을 통해 api 조회
  • 조회한 데이터를 스트링 형태의 html에 적당히 더해서 화면에 표시할 부분 만들기
  • jquery를 이용해서 화면에 html 추가하기

아주 많을 일을 하는 함수가 하나의 함수에 들어있다. 현재는 이벤트가 한 두개밖에 없는데 페이지에 이벤트가 10개 정도만 되어도 코드가 매우 복잡해진다. 이러한 방식으로 작성된 페이지에 변경이 필요한 상황이 발생한다면 코드를 읽는 시간이 많이 걸릴 것이다.

새로운 개발 방식

mvc, mvp, mvvm 다양한 방식을 검색해 보았는데 정확히 어떤 방식으로 적용해야 할지 몰랐다. 하지만 공통적으로 model가 view를 나누고 있었다. 그래서 일단 model과 view를 나누어보려고 한다.

models

내가 생각한 모델로 나누어야 하는 부분은 다음과 같았다.

  • 칵테일
  • 칵테일 목록
  • 재료
  • 검색어

그리고 각각을 다음과 같이 클래스로 만들었다.

Cocktail.js

칵테일은 아이디, 이름, 제조법, 재료라는 속성을 갖는다.

import Ingredients from "./Ingredients.js";

export default class Cocktail{
    constructor(data){
        this.id = data.idDrink;
        this.name = data.strDrink;
        this.instructions = data.strInstructions;
        this.thumbnail = data.strDrinkThumb;

        this.ingredients = [];
        for(let i=1;i<=15;i++){
            if(data['strIngredient'+i]===null) continue;
            this.ingredients.push(new Ingredients(
                data['strIngredient'+i],
                data['strMeasure'+i]??''
            ));
        }
    }
}
CocktailList.js

칵테일의 전체 목록을 관리하기 위한 칵테일 목록 모델을 별도로 만들었다. 그리고 실제 api를 통해 칵테일 정보를 조회하는 부분을 이 모델에 두었다.

import Cocktail from './Cocktail.js'
export default class CocktailList{
    constructor(ajaxUtil){
        this.ajaxUtil = ajaxUtil;
        this.cocktails = [];
    }
    
    fetchAll(keyword, done, fail, always){
        this.cocktails = [];
        this.ajaxUtil.get('/api/json/v1/1/search.php', {s: keyword})
        .done((res)=>{
            for (const drink of res.drinks) {
                this.cocktails.push(new Cocktail(drink));    
            }
            if(done){
                done(res);
            }
        })
        .fail((err)=>{
            if(fail){
                fail(res);
            }
        })
        .always(()=>{
            if(always){
                always();
            }
        });
    }
}
Ingredients.js

재료는 이름과 양이라는 속성을 갖는다.

export default class Ingredients{
    constructor(name, measure){
        this.name = name,
        this.measure = measure
    }
}
Keyword.js

검색어도 별도의 모델로 만들어두었다.

export default class Keyword{
    constructor(keyword){
        this.keyword = keyword;
    }
    set(keyword){
        this.keyword = keyword;
    }
    get(){
        return this.keyword;
    }
}

Views

모델의 정보를 받아서 화면에 보여주는 부분은 뷰로 분리하였다.

  • CocktailView.js
  • CocktailListView.js
  • IngredientsView.js
  • KeywordView.js
  • SearchButton.js

내가 생각한 뷰의 역할은 다음과 같다.

  • 사용자로부터 받은 입력 정보 제공
  • 발생할 수 있는 이벤트에 대한 등록점 제공
  • 정보를 화면에 표시
CocktailView.js

html 코드를 템플릿으로 가지고 있고 render 함수를 호출하게 되면 전달받은 target 부분에 표시되게 하였다.

export default class Cocktail{
    constructor(data){
        this.data = data;
        this.element = $(`
            <div class="mdc-card">
                <div class="mdc-card__primary-action" tabindex="0">
                    <div class="mdc-card__media mdc-card__media--16-9"
                        style="background-image: url(&quot;${this.data.thumbnail}&quot;);">
                    </div>
                    <div class="mdc-card__content">
                        <h2 class="mdc-typography mdc-typography--headline6">${this.data.name}</h2>
                        <h3 class="mdc-typography mdc-typography--subtitle2">${(this.data.instructions??'')}</h3>
                    </div>
                    <div class="mdc-card__content mdc-typography mdc-typography--body2">
                        <ul class="mdc-list mdc-list--two-line" name="ingredients-list">
                        </ul>
                    </div>
                </div>
            </div>
        `);
        this.ingredients = this.element.find('ul[name=ingredients-list]');
    }

    render(target){
        target.append(this.element);
    }

    addIngredient(ingredient){
        ingredient.render(this.ingredients);
    }
}
CocktailListView.js
export default class CocktailList{
    constructor(element){
        this.element = element;
    }
    empty(){
        this.element.empty();
    }
    addCocktail(cocktail){
        cocktail.render(this.element);
    }
}
IngredientsView.js

CocktailView.js 와 마찬가지로 render함수를 가지고 있다.

export default class Ingredients{
    constructor(data){
        this.data = data;
        this.element = $(`
        <li role="separator" class="mdc-list-divider"></li>
        <li class="mdc-list-item" tabindex="0">
            <span class="mdc-list-item__text">
                <span class="mdc-list-item__primary-text">${this.data.name}</span>
                <span class="mdc-list-item__secondary-text">${this.data.measure}</span>
            </span>
        </li>
        `)
    }
    render(target){
        target.append(this.element);
    }
}
KeywordView.js

검색어가 변경되었을 때의 이벤트에 대한 핸들러를 등록할 수 있는 addKeyupHandler 함수를 가지고 있다.

export default class Keyword{
    constructor(element){
        this.element = element;
    }
    getKeyword(){
        return this.element.val();
    }
    addKeyupHandler(handler){
        this.element.on('keyup', handler);
    }
    render(target){
        target.append(this.element);
    }
}
SearchButton.js

위 파일과 마찬가지로 버튼이 클릭되었을 때의 핸들러를 등록할 수 있는 함수를 가지고 있다.

export default class SearchButton{
    constructor(element){
        this.element = element;
    }
    addClickHandler(handler){
        this.element.off('click').on('click', handler);
    }
}

app.js

model과 view를 초기화하고 view 연결해야 할 handler를 연결하는 부분을 app.js라는 파일에 작성하였다.

필요한 요소들을 초기화하고 초기화된 요소들을 App에 등록해주었다.

$(function(){
    const ajaxUtil = new AjaxUtil('https://www.thecocktaildb.com');

    //views
    const keywordview = new KeywordView($('#search-keyword-input'));
    const searchButtonView = new SearchButtonView($('#search-button'));
    const cocktailListView = new CocktailListView($('#cocktail-list'));

    //models
    const keyword = new Keyword();
    const cocktailList = new CocktailList(ajaxUtil);

    const app = new App(
        keywordview, searchButtonView, cocktailListView, keyword, cocktailList
    );
    app.init();
});

search(), setkeyword()와 같은 함수를 작성해서 views에 등록하였다.

class App{
    constructor(
        keywordView, searchButtonView, cocktailListView,keyword,cocktailList
    ){
        this.views = {
            keyword: keywordView,
            searchButton: searchButtonView,
            cocktailList: cocktailListView
        };
        this.models = {
            keyword: keyword,
            cocktailList: cocktailList
        };
    }
    init(){
        this.views.keyword.addKeyupHandler(()=>{
            this.setKeyword();
        });
        this.views.searchButton.addClickHandler(()=>{
            this.search();
        });
    }
    setKeyword(){
        this.models.keyword.set(this.views.keyword.getKeyword());
    }
    search(){
        const keyword = this.models.keyword.get();
        this.views.cocktailList.empty();
        this.models.cocktailList.fetchAll(keyword, (res)=>{
            for (const cocktail of this.models.cocktailList.cocktails) {
                const cocktailView = new CocktailView(cocktail);
                this.views.cocktailList.addCocktail(cocktailView);
                for(const ingredient of cocktail.ingredients){
                    cocktailView.addIngredient(new IngredientsView(ingredient));
                }
            }
        }, (err)=>{
            alert('오류가 발생했습니다.');
        });
    }
}

마치며

기존의 방식을 새로운 방식으로 만들어보았다. 사실 기존의 방식이 전체적인 코드의 양이나 파일의 갯수는 새로운 방식보다 훨씬 적었다. 그리고 기능을 동작하도록 만드는데에 걸린 시간도 사실 더 적었다. 하지만 기능이 지속적으로 추가되고 변경이 계속 발생하는 환경이라면 새로운 방식이 더 도움이 되지 않을까라는 생각을 하면서 개선을 진행하였다. model과 view 사이에 dto 같은 객체를 만드는 과정을 두어서 더 명확히 분리해야 할 것 같다는 아쉬움도 들었다.

0개의 댓글