Realm과 함께 하는 안드로이드의 단방향(Uni-directional) 아키텍처 안내서

소개

최근 안드로이드에서 단방향(Uni-directional) 아키텍처가 주목받으면서 관련 포스팅도 많이 올라오고 있고, 안드로이드 커뮤니티의 많은 개발자들이 이 아키텍처를 지지하고 있습니다. 편의를 위해 앞으로는 단방향 아키텍처를 간략히 UDA로 표기하도록 하겠습니다. UDA를 하나의 아키텍처로 이해하는 것은 적절하지 않은 것 같고, 앱을 디자인하는 방법 또는 아키텍처의 특성이라고 생각하시면 될 것 같습니다. 여러분이 디자인한 앱의 데이터 흐름이 한 방향으로 진행되고, 상태는 모델에서만 관리된다면 UDA라고 할 수 있습니다. 이것에 대해서는 잠시 후에 추가로 설명하겠습니다. Smalltalk-80을 위해 고안된 오리지널 MVC 패턴은 가장 단순한 형태의 UDA라고 할 수 있습니다.

Smalltalk-80 MVC

이 글에서 설명하는 MVC는 Apple 및 빅 너드 랜치의 안드로이드 프로그래밍책 제 3판 2장[Android and MVC pg 38]에 소개된 MVC 개념과 다른 것입니다. 그들은 컨트롤러를 중앙에 놓고 모델과 뷰를 중재하는 역할로 소개하고 있습니다. 저는 어떤 설명이 맞고 틀린 지 논쟁하고 싶지 않습니다. MVC의 기원과 역사에 관련해 cocoawithloveaspiring craftsman이 작성한 훌륭한 글들이 있으니 참고하시기 바랍니다. 이 글에서 사용된 MVC는 Smalltalk-80을 위해 고안된 오리지널 패턴을 의미합니다.

모델이 중심에 놓여있는 구조이며, 모델은 애플리케이션의 상태와 비즈니스 로직을 관리합니다. 컨트롤러는 모델을 업데이트하는 방법을 제공합니다. 뷰는 모델을 화면에 나타내고, 항상 모델의 현재 상태를 반영합니다. 뷰와 사용자 사이에서 상호작용이 발생하면 컨트롤러에서 이와 관련된 액션을 수행하고 이 과정이 반복됩니다. 액션과 데이터는 단방향으로 흘러가고 있습니다.

UDA에 관심을 가져야 하는 이유

왜 UDA에 관심을 가져야 할까요? 프리젠터가 뷰로부터 액션을 전달받고, 모델을 업데이트하며 다시 뷰로 돌아가 뷰 자체를 업데이트하고, 어떤 것을 화면에 표시할지, 다음에는 무엇을 할지 뷰에게 알려주는 MVP와 같은 구조에는 어떤 문제가 있을까요?

크게 두 가지 문제가 있을 수 있습니다.

  1. 프리젠터는 수많은 기반 코드와 이와 관련된 코드로 가득 찰 것입니다. 이런 코드들은 뷰가 모델을 직접 다룰 경우 필요 없는 것들이지요.

  2. 상태 정보가 뷰와 모델 두 곳에 존재하기 때문에, 다중 스레드 및 다양한 입력(사용자 상호작용, 백그라운드 업데이트 등)에 의해 모델에 대한 업데이트가 동시에 발생할 수도 있는 멀티스레드나 동적 환경에서는 이들 상태 정보를 관리하기 어렵습니다.

UDA는 뷰와 모델을 직접 연결하여 이러한 문제를 해결했습니다. UDA에서는 모든 상태 정보가 모델에 저장됩니다.

UDA에는 반드시 지켜야 할 규칙이 있습니다. 데이터와 액션은 반드시 한 방향으로 흘러가야 한다는 것입니다. 이 규칙만 지킨다면 구현 방식은 제한이 없습니다.

안드로이드에는 플럭스(Flux), 리덕스(Redux), Model View Intent 등 UDA 구조를 갖고 있는 아키텍처 또는 프레임워크가 있습니다. 물론 다른 아키텍처나 프레임워크도 많이 있습니다. 일반적인 형태의 프레임워크나 아키텍처들을 제외하고는 대부분이 단방향 아키텍처를 구성하기 위해 RxJava를 사용하고 있습니다. 이들 라이브러리에서 사용하는 대부분의 개념들은 자바스크립트 커뮤니티에서 차용한 것들입니다.

좀 전에 언급한 세 가지 프로젝트들을 간략히 살펴본 다음, Realm을 사용해 UDA 구조의 Todo 앱을 만들어보도록 하겠습니다. Todo 앱은 안드로이드에서 활용할 수 있는 더욱 단순한 UDA 접근법을 보여주고 있습니다.

최종적인 Todo 앱의 모습은 다음과 같습니다.

우선, 플럭스, 리덕스, Model View Intent의 특징들을 살펴보겠습니다.

Model View Intent (MVI)

MVI는 자바스크립트 생태계에서 탄생했으며, André Staltz가 만든 CycleJs 프레임워크의 핵심 개념입니다. 안드로이드용 MVI는 Mosby를 만든 Hannes Dorfmann가 구현하였습니다. Hannes가 만든 안드로이드용 MVI에 대한 추가 정보는 이곳을 참조하세요. Model, View, Intent의 관계는 다음 그림과 같습니다.

이런 개발 뉴스를 더 만나보세요

Model View Intent

Hannes의 글에서는 함수 기능이 강조된 View(Model(Intent())) 형태로 표현하고 있습니다.

Cycle.js 문서에 MVI 컴포넌트와 그들의 역할이 잘 설명되어 있으니 참고 바랍니다.

모델 객체를 직접 가져와서 모델의 상태를 시각적으로 보여주며, 사용자 상호작용에 대한 결과로 이벤트를 발생시켜서 인텐트(Intents)에 전달합니다.

인텐트

뷰에서 이벤트를 전달받으면, 의도된 액션(intended action)으로 변환(interpret)한 다음, 모델 상태를 조작하기 위해 해당 액션을 모델에게 전달합니다. 여기서 말하는 인텐트는 안드로이드 인텐트와는 아무런 관련이 없습니다.

모델

모델은 애플리케이션의 상태와 비즈니스 로직을 관리합니다. 즉, 인텐트로부터 액션을 받아서 처리하고 상태 변화를 뷰에게 알려줍니다.

플럭스/리덕스와의 비교

MVI와 플럭스/리덕스는 외형에서 차이가 있어 보이지만, 코드 측면에서는 매우 유사한 개념입니다. 플럭스 및 리덕스에서는 인텐트 대신 디스패처라는 용어가 사용되고, 에서 발생한 액션이라는 개념도 사용합니다. 또한, 플럭스와 리덕스에서는 그들의 아키텍처 다이어그램에서도 사용될 정도로 액션이 좀 더 공식화되었다는 점이 MVI과 차별되는 부분이라고 할 수 있습니다.

플럭스와 리덕스

자바스크립트 세계에서 탄생한 리덕스는 대화 상대에 따라 플럭스 아키텍처를 구현한 것을 의미할 때도 있고, 플럭스 아키텍처에서 영감을 받아 탄생한 프레임워크를 가리키기도 합니다. 관점에 차이는 있겠지만 플럭스와 리덕스는 서로 유사한 구조이며 액션 이 명시적인 개념으로 표현된다는 점만 제외하면 MVI와도 비슷합니다.

Flux Architecture Diagram

뷰는 스토어로부터 읽어 들인 모델 데이터를 시각적으로 표현한 사용자 인터페이스입니다. 뷰는 액션을 만들며, 액션은 사용자 상호작용에 대한 응답을 위해 디스패처로 전달됩니다.

액션

타입 정보가 포함된 간단한 데이터 컴포넌트이며 가끔 타입 정보와 함께 데이터가 포함되기도 합니다. 액션은 큐(queue)에 비동기적으로 쌓여서 순차적으로 처리되는 개별 작업들(discrete units of work)을 캡슐화합니다.

디스패처

디스패처는 큐에서 액션을 받아들이는 eventbus며, 모델을 업데이트하기 위해 스토어에 있는 메서드들을 실행하는 역할을 담당합니다.

스토어

스토어에는 애플리케이션의 상태와 비즈니스 로직이 담겨 있습니다. 그들의 역할은 전통적인 MVC의 모델과 유사한 측면이 있지만 하나의 객체가 아니라 많은 모델 객체들의 상태들을 관리할 수 있다는 점에서 차이가 있습니다. 추가정보는 플럭스 프로젝트를 참고하세요.

분석

액션은 개별 작업들을 캡슐화하는 유용한 방법이며, 액션은 Otto같은 eventbus 또는 RxJava를 활용한 스트림을 통해 비동기적으로 디스패처에게 전달됩니다. 디스패처는 FIFO 방식으로 이들을 하나씩 처리할 것입니다. Realm을 사용하면 이와 동일한 기능을 쉽게 구현할 수 있으며, 플럭스 아키텍처도 단순화시킬 수 있습니다.

Realm의 비동기 트랜잭션 기능을 통해 위에서 언급한 기능들을 구현할 수 있습니다.

realm.executeTransactionAsync(Realm.Transaction txBlock)

txBlock는 비동기로 처리하고 싶은 작업을 캡슐화하는 역할을 담당하는 액션에 해당합니다. txBlocksexecuteTransactionAsync를 호출하는 스레드와는 상관없이 순차적으로 실행됩니다. 이것은 바로 디스패처가 담당했던 역할입니다. Realm이 액션과 디스패처 역할을 대신하기 때문에 아키텍처에는 다음과 같은 다이어그램 구조만 남게 됩니다.

View Store Diagram

다이어그램에서는 우리가 아직 추가하지 않는 모델에 관한 부분이 빠져있습니다. 스토어에서 모델에 대한 부분을 분리해볼까요?

Model Store View Diagram

모델을 별도의 엔티티로 구성했더니 전체 구조상 단방향 흐름으로 보이지 않네요. Realm Object가 Active Models기 때문에 Realm을 사용하면 이 문제를 해결할 수 있습니다. 항상 최신 데이터를 이용할 수 있으며, 데이터 변경사항을 반영하기 위해 리스너를 붙일 수 있습니다. 데이터 변경은 백그라운드 스레드뿐만 아니라 앱의 어느 곳에서든 가능합니다. 이러한 사항들을 고려한다면, 비동기 트랜잭션을 통해 모델의 상태를 업데이트하는 동작을 실행하기 위해서는 스토어가 필요합니다. 이제 스토어는 컨트롤러와 비슷한 모습이 되었고, 전체 구조는 다음과 같을 것입니다.

Realm MVC Annotated

변경 불가능(Immutability): 플럭스, 리덕스, MVI에서는 스토어나 모델에서 각각의 액션이 완료될 때 변경 불가능 모델을 생성합니다. 변경 불가능 모델의 사용은 이들 아키텍처에서 권장하는 방법입니다. 변경 불가능 모델을 사용하면 UDA 실행에 도움이 되고, 멀티스레드에서 모델에 접근하는 문제를 간단히 처리할 수 있습니다. Realm을 사용한다면 변경 불가능 모델 전략이 필요 없는데 그 이유는 Realm에서는 UI 스레드에서 뷰를 위해 제공된 Realm Model 객체를 변경하는 것이 근본적으로 불가능하기 때문입니다. 우리는 뷰에 독립적인 컨트롤러의 백그라운드 스레드에서만 기초 데이터베이스를 조작합니다. 이것이 어떻게 동작하는지는 코드를 통해서 살펴보도록 하겠습니다.

코드

아키텍처에 관한 글에서는 항상 코드가 제공돼야겠죠?

Model

매우 간단한 모델부터 시작해보죠.

public class TodoItem extends RealmObject {

    @PrimaryKey
    @Required
    private String id;

    @Required
    private Date createdDate;

    @Required
    private String text;

    private boolean selected;

    public TodoItem(final String text) {
        this.id = UUID.randomUUID().toString();
        this.createdDate = new Date();
        this.text = text;
    }

    public TodoItem() {}

    public String getId() {
        return id;
    }

    public String getText() {
        return text;
    }

    public boolean isSelected() {
        return selected;
    }

    public void setSelected(boolean selected) {
        this.selected = selected;
    }
}


Controller

모델을 업데이트하는 간단한 컨트롤러입니다. 컨트롤러는 입력된 것을 검사하고, 모델들을 비동기적으로, 그리고 순차적으로 업데이트합니다.

public class TodoController {

   private Realm realm;

   public TodoController() {
       realm = Realm.getDefaultInstance();
   }

   public void close() {
       realm.close();
   }

   public void addItem(final String text) {

       if(TextUtils.isEmpty(text)) { return; }

       realm.executeTransactionAsync(new Realm.Transaction() {
           public void execute(Realm bgRealm) {
               bgRealm.copyToRealmOrUpdate(new TodoItem(text));
           }
       });
   }

   public void deleteAllChecked() {

       realm.executeTransactionAsync(new Realm.Transaction() {
           public void execute(Realm bgRealm) {
               bgRealm.where(TodoItem.class).equalTo("selected", true).findAll().deleteAllFromRealm();
           }
       });

   }

   public void setAllCheckedValue(final boolean isChecked) {

       realm.executeTransactionAsync(new Realm.Transaction() {
           public void execute(Realm bgRealm) {
               RealmResults<TodoItem> items = bgRealm.where(TodoItem.class).findAll();
               for(TodoItem item : items) { item.setSelected(isChecked); }
           }
       });

   }

   public void setSingleCheckedValue(final String itemId, final boolean isChecked) {

       if(TextUtils.isEmpty(itemId)) { return; }

       realm.executeTransactionAsync(new Realm.Transaction() {
           public void execute(Realm bgRealm) {
               TodoItem TodoItem = bgRealm.where(TodoItem.class).equalTo("id", itemId).findFirst();
               TodoItem.setSelected(isChecked);
           }
       });
   }
}


View

뷰는 레이아웃과 액티비티, RecyclerViewAdapter로 구성됩니다.

Layout 레이아웃은 XML로 구성되며 UI 구조를 정의합니다.

LayoutBreakdown

Activity 액티비티(TodoView)는 모델을 컨트롤러 및 뷰와 연결(bind)합니다.

public class TodoView extends AppCompatActivity implements TodoRecyclerViewAdapter.ItemSelectionChangeDelegate {

    private Realm realm;

    @BindView(R.id.add_todo_text) public EditText addTodoText;
    @BindView(R.id.todo_list)     public RecyclerView recyclerView;
    @BindView(R.id.add_item_fab)  public FloatingActionButton addItemFab;
    @BindView(R.id.toolbar)       public Toolbar toolbar;

    private TodoController controller;
    private RealmResults<TodoItem> model;

    /**
     * OnCreate we setup our view and bind the model and controller.
     */
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_todo);
        ButterKnife.bind(this);
        setSupportActionBar(toolbar);
        bindController();
        bindModel();
    }

    private void bindController() {
        controller = new TodoController();
    }

    private void bindModel() {
        realm = Realm.getDefaultInstance();
        model = realm.where(TodoItem.class).findAllSortedAsync("createdDate", Sort.DESCENDING);
        recyclerView.setAdapter(new TodoRecyclerViewAdapter(this, model));
        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));

        model.addChangeListener(new OrderedRealmCollectionChangeListener<RealmResults<TodoItem>>() {
            @Override
            public void onChange(RealmResults<TodoItem> collection, OrderedCollectionChangeSet changeSet) {
                if (changeSet != null && changeSet.getInsertions().length >= 1) {
                    // Every time the model is updated with a new todo list item, react:
                    // clearing the add todo EditText and scroll to the top of the todo list.
                    addTodoText.setText("");
                    recyclerView.getLayoutManager().scrollToPosition(0);
                }
            }
        });
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.menu_todo, menu);
        return true;
    }

    /**
     * OnDestroy we unbind from the model and controller.
     */
    @Override
    protected void onDestroy() {
        super.onDestroy();
        unbindModel();
        unbindController();
    }

    private void unbindController() {
        controller.close();
    }

    private void unbindModel() {
        model.removeAllChangeListeners();
        realm.close();
    }

    /**
     *  OnSelectionChanged, onAddItem(), onOptionsItemSelected()
     *  Bind UI Events to the controller.
     */
    @Override
    public void onSelectionChanged(String itemId, boolean isSelected) {
        controller.setSingleCheckedValue(itemId, isSelected);
    }

    @OnClick(R.id.add_item_fab)
    public void onAddItem() {
        controller.addItem(addTodoText.getText().toString());
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {

        boolean handled = true;

        switch (item.getItemId()) {
            case R.id.action_check_all:
                controller.setAllCheckedValue(true);
                break;
            case R.id.action_uncheck_all:
                controller.setAllCheckedValue(false);
                break;
            case R.id.action_delete_checked:
                controller.deleteAllChecked();
                break;
            default:
                handled = false;
                break;
        }

        return handled;
    }
}

RecyclerViewAdapter 마지막으로 TodoRecyclerViewAdapter는 TodoItem 모델 리스트와 이들을 화면에 보여주는 RecyclerView 위젯을 연결(bind)하는 헬퍼입니다. 모델 변경 사항을 자동으로 반영하도록 RealmRecyclerViewAdapter를 확장했습니다.

class TodoRecyclerViewAdapter extends RealmRecyclerViewAdapter<TodoItem, TodoRecyclerViewAdapter.ViewHolder> {

    // Callback to TodoView (an ItemSelectionChangeDelegate).  Fires when an item is checked.
    interface ItemSelectionChangeDelegate {
        void onSelectionChanged(String itemId, boolean isSelected);
    }

    private ItemSelectionChangeDelegate itemSelectionChangeDelegate;

    TodoRecyclerViewAdapter(@NonNull  ItemSelectionChangeDelegate delegate, @NonNull OrderedRealmCollection<TodoItem> todoList) {
        super(todoList, true);
        itemSelectionChangeDelegate = delegate;
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.todo_item, parent, false);
        return new ViewHolder(view);
    }

    @Override
    public void onBindViewHolder(final ViewHolder holder, final int position) {
        OrderedRealmCollection data = getData();
        if(data != null) {
            holder.checkBox.setOnCheckedChangeListener(null);
            final TodoItem item = getData().get(position);
            holder.itemId = item.getId();

            TextView tv = holder.titleView;
            tv.setText(item.getText());
            if(item.isSelected()) {
                tv.setPaintFlags(tv.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
            } else {
                tv.setPaintFlags(tv.getPaintFlags() & (~ Paint.STRIKE_THRU_TEXT_FLAG));
            }
            holder.checkBox.setChecked(item.isSelected());
            holder.checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(CompoundButton buttonView, boolean newCheckedState) {
                    if(item.isValid() && item.isSelected() != newCheckedState) {
                        itemSelectionChangeDelegate.onSelectionChanged(item.getId(), newCheckedState );
                    }
                }
            });
        }
    }

    class ViewHolder extends RecyclerView.ViewHolder {
        String itemId;
        final TextView titleView;
        final CheckBox checkBox;

        ViewHolder(View view) {
            super(view);
            checkBox = (CheckBox) view.findViewById(R.id.todo_item_checkbox);
            titleView = (TextView) view.findViewById(R.id.todo_item_text);
        }
    }
}

이상입니다. 안드로이드에서 Realm을 이용해 쉽게 UDA앱을 만들었습니다.

글을 마치며

안드로이드 앱을 만들 때 반드시 따라야 하는 패턴이나 아키텍처는 없습니다. 이글에서 강조했듯이 UDA 아키텍처는 확실한 이점이 있지만, 여러분의 앱, 여러분의 팀, 여러분의 스타일에 맞는 것이 무엇인지 찾으려는 노력을 게을리하지 마십시오. 이글에서 사용된 예제의 소스는 이곳에서 다운로드할 수 있습니다.

이제 멋진 소프트웨어를 만들러 떠나볼까요?

컨텐츠에 대하여

이 컨텐츠는 저자의 허가 하에 이곳에서 공유합니다.


Eric Maxwell

Eric은 Realm의 제품 엔지니어입니다. 그는 십 년 이상 의료, 보험, 도서관학, 민간 항공을 비롯한 여러 산업 분야의 다양한 회사를 위해 소프트웨어를 설계하고 개발해 왔습니다. 현재는 교육, 멘토링 및 모바일 개발에 주력하고 있으며, Java, Android, iOS 강의를 개발하고 강의했습니다. 여가시간에는 가족과 함께 시간을 보내고 여행하고 즉흥 코메디 쇼를 즐깁니다.

4 design patterns for a RESTless mobile integration »

close