Android MVP Pattern - What, Why and How?
[Buzzvil News] Lockscreen Giants Combine, Buzzvil Acquires Slidejoy
1월 23, 2017
[Buzzvil People] Rob Seo, CEO of Buzzvil US
2월 2, 2017

[Tech Blog] Android MVP Pattern – What, Why and How?

1.  글을 시작하기 전에

부끄럽지만 저는 버즈빌의 초보 개발자입니다. 버즈빌에서의 다양한 경험들이 저를 많이 변화시키고 있구요. 버즈빌의 뛰어난 개발자들 사이에서 일을 배워나가면서, 앞으로 향후에 저도 뛰어난 개발자가 되기 위해서는 반드시 공부해 두어야겠다고 생각한 몇 개의 영역이 있습니다.

그 중 하나가 ‘다양한 소프트웨어 디자인 패턴을 적절한 곳에 적용시키는 방법’이었습니다. 다행히 최근에는 많은 사람들의 경험을 통해 검증된 디자인 패턴이 많이 개발되고 있어, 다양한 패턴을 꾸준히 탐색해서 활용할 수 있는 안목만 키울 수 있다면 학습에 큰 어려움을 없을 것이라 생각하였습니다.

그동안 주로 기능적으로 시스템을 정상 동작하도록 만드는 것에만 집중했었지만,  여기서 더 나아가 가독성과 확장성이 좋고 협업하기 쉽게 만드는 구조를 짤 줄 알아야 장기적으로 더 좋은 프로덕트를 만들어 낼 수 있을 것이라는 어찌보면 막연한 생각도 배움에 대한 호기심을 자극하는데 한 몫 하였습니다.

그러던 중 실제 업무 진행 중에, 중 몇 가지 주요 패턴을 적용해 리팩토링을 할 기회를 갖게 되었습니다. 실무에서 직접 경험해보니 패턴의 유무가 만들어내는 결과의 차이는 생각보다 큰 역할을 하고 있었습니다.

미숙하지만, 이러한 맥락과 배경에서 저의 첫 버즈빌 기술 블로그를 시작해보고자 합니다. 이번 포스팅의 주요 내용은, 허니스크린 안드로이드 코드에 MVP패턴을 도입해 리팩토링 했던 경험을 바탕으로 안드로이드 어플리케이션에 MVP패턴을 적용하는 방법에 관한 것입니다.

2. MVP 패턴에 대해서

‘MVP 패턴’은 Model, View, Presenter의 앞글자를 따서 이름이 지어졌습니다. 이 패턴의 핵심 아이디어는 사용자 인터페이스(View)와 비즈니스 로직(Model)을 분리하고, 서로간에 상호작용을 다른 객체(Presenter)에 위임해 서로의 영향을 최소화하는 것에 있습니다. 각 파트의 자세한 설명을 살펴보면 아래와 같습니다.

  • Model : 내부적으로 쓰이는 데이터를 저장하고, 처리하는 역할을 합니다. 흔히 ‘비즈니스 로직’ 이라고 부르는 부분입니다. View, Presenter등 다른 어떤 요소에도 의존적이지 않은 독립적인 영역이죠. 
  • View : 사용자 인터페이스(User Interface-UI)라 불리는 영역입니다. 안드로이드에서는 Activity, Fragment가 대표적인 예입니다. Model에서 처리된 데이터를 Presenter를 통해 받아서 사용자에게 보여주며, 사용자 액션 및 Activity lifecycle 변화를 감지해서 Presenter에 보내는 역할을 합니다. Presenter를 이용해 데이터를 주고받기 때문에 Presenter에 의존적입니다.
  • Presenter : Model과 View사이의 매개체입니다. View에서 캐치한 유저 액션을 전달 받아서 Model의 로직을 호출하거나, Model의 로직으로부터 나온 결과를 전달 받아서 View에 보내 UI변경을 야기하는 등 둘 사이의 소통의 역할을 합니다. Model, View 모두에 의존적입니다.

‘MVP패턴’을 이용해서 이와 같이 Model과 View간의 결합도를 낮추면, 새로운 기능을 추가하거나 변경할 필요가 있을 때 관련된 부분만 수정하면 되기 때문에 확장성이 좋아지며, 테스트 코드를 작성하기 편리해지기 때문에 더 안전한 코드 작업이 가능해집니다.

MVP pattern (https://code.tutsplus.com/tutorials/how-to-adopt-model-view-presenter-on-android--cms-26206)

3. 안드로이드에 MVP가 적합한 이유

안드로이드에서 UI를 표현하는 컴포넌트들의 특징은 화면을 시각적으로 직접 그리는 역할 및 화면에 있는 UI 요소들에 대한 액션 처리를 항상 함께 담당한다는 것입니다. 이러한 프레임워크의 특징 때문에 기존에 웹 어플리케이션 등에서 많이 쓰이던 MVC(Model, View, Controller)패턴을 적용하기에는 화면을 그리는 View와 액션을 처리하는 Controller를 완전히 분리하기 어렵다는 한계가 있습니다. 이러한 이유때문에, MVP가 안드로이드에 더 적합하다는 논의가 이어져오고 있는거구요. 

안드로이드를 개발한 구글 측에서도 Android architecture blueprints 라는 이름으로 MVP 패턴을 적용한 샘플 프로젝트를 공식적으로 운영하는 것으로 보아, 이 패턴은 안드로이드에 더 적절한 것으로 어느정도 검증되었다고 볼 수 있을 것 같습니다. 

4. MVP패턴 적용 전

제가 실무를 통해 MVP패턴을 적용하여 리팩토링 한 부분은 허니스크린 유저 회원가입의 마지막 단계였습니다. 다시 말하자면, 유저에게 닉네임, 나이, 성별, 추천인 등을 입력받아 서버에 회원 가입 요청을 보내는, ‘프로필 입력’ 단계라고 볼 수 있을 것 같습니다. 실제 코드를 바탕으로 다루기에는 지면이 충분하지 않기에 간략화된 버전을 이용해 MVP패턴을 적용하는 과정을 중심으로 기술하겠습니다.

기존에 ‘프로필 입력’ 단계를 구현한 Profile Activity의 코드를 간추리면 다음과 같습니다.

public class ProfileActivity extends Activity {

    EditText etNickname;
    Button btSignUp;
	
    String email;
	
    @Override
    public void onCreate(Bundle savedInstanceState) {
	super.onCreate(savedInstanceState);
	setContentView(R.layout.activity_profile);
	email = getIntent().getStringExtra("email");
	initView();
    }

    private void initView() {
	etNickname = (EditText) findViewById(R.id.nickname);
	etNickname.setText(makeNickname());
	btSignUp = (Button) findViewById(R.id.button_signup);

	btSignUp.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
            	if (checkNicknameLength()) {
            	    callSignUp();
            	} else {
            	    Toast.makeText(ProfileActivity.this, "Nickname is too short", Toast.LENGTH_SHORT).show();
            	}
            }
        });
    }

    private String makeNickname() {
	int pos = email.indexOf("@");
	return email.substring(0, pos);
    }

    private boolean checkNicknameLength() {
        if (etNickname.getText().toString().length() < 4) {
	    return false;
	} else {
	    return true;
	}
    }

    private void callSignUp() {
	Map params = new HashMap<>();
	params.put("nickname", etNickname.getText().toString());
	params.put("email", email);
	// Add additional parameters
	RequestInterface.requestPOST("/SIGNUP/", params, new ResponseListener() {
            @Override
            public void onSuccess(JSONObject json) {
            	Intent intent = new Intent(ProfileActivity.this, MainActivity.class);
            	Bundle params = new Bundle();
                params.putString("user_info", json.getString("user_info"));
        	intent.putExtras(params);
        	startActivity(intent);
            }

            @Override
            public void onError(int code, String message) {
            	Toast.makeText(ProfileActivity.this, message, Toast.LENGTH_SHORT).show();
            }
        });
    }
}
보시다시피 하나의 Profile Activity 클래스에서 모든 역할을 다 수행하고 있는데요. 이건 간략화한 예시이기 때문에 내용 파악이 쉬운 편이지만 여기에 다양한 뷰 요소가 추가되거나, DB접근, AsyncTask등 복잡한 백그라운드 로직이 추가된다면, 쉽게 내용을 파악하기 힘들 뿐만 아니라 모든 로직이 뒤섞여 있어서 기능을 추가하거나 변경하기 어렵고, Android framework에 종속성을 가지기 때문에 Unit test를 작성하기가 어려울 것입니다.

5. 요구 사항 분석

위의 코드에 구현된 ‘프로필 입력’ 단계의 요구사항은 다음과 같이 정리할 수 있습니다.

Requirements - 1st

  • 이전 액티비티에서 유저가 입력한 이메일을 전달받아 저장한다.
  • 닉네임 입력 칸에 이메일을 기반으로 만든 추천 닉네임(이메일의 @앞부분)을 보여준다.
  • 회원가입 버튼을 누르면 닉네임의 길이가 최소 길이 이상인지 확인하고 아닐 경우 에러 메세지를 보여주고 맞을 경우 다음 단계로 진행한다.
  • 이메일, 닉네임을 파라미터로 담아 회원가입 API를 호출한다.
  • 회원가입 API에서 실패 응답을 받으면 서버에서 받아온 에러 메세지를 유저에게 보여주고, 성공 응답을 받으면 회원 가입이 완료되면서 응답으로 받아온 완성된 유저 정보를 담아 허니스크린의 MainActivity로 이동한다.

위 요구사항을 세분화하여 특성에 따라 Data, UI 파트로 나누도록 하겠습니다.

Requirements - 2nd

  • Data: 이메일을 받아서 저장한다.
    UI: 액티비티 생성 시 이전 액티비티로부터 전달받은 Intent를 파싱해서 전달한다.
  • Data : 이메일을 기반으로 추천 닉네임을 만든다.
    UI: 닉네임 입력 칸에 추천 닉네임을 보여준다.
  • Data : 닉네임의 길이를 확인한다.
    UI: 회원가입 버튼이 클릭되는 이벤트를 감지하고 닉네임을 전달한다, 에러 메세지를 보여준다.
  • Data : 회원가입 API를 호출한다.
    UI: (없음)
  • Data : 회원가입 API의 응답을 받는다.
    UI: 에러 메세지를 보여준다, ProfileActivity에서 MainActivity로 화면을 전환시킨다.

6. 뼈대 구조 완성

Data파트의 요구사항을 구현하는 것이 바로 Model이기에 위의 Data파트의 요구사항 리스트를 통해 Model이 구현해야 할 메소드들의 프로토타입을 정의하도록 하겠습니다. 앞의 코드와 메소드 이름 등 겹치는 부분이 많으나 주목할만한 차이점은 Intent, EditText와 같은 View요소를 접근하는 부분을 제거하고 파라미터로 내용만 전달받도록 바꾼 것, 그리고, API는 비동기적인 응답을 받기 때문에 Listener를 파라미터로 전달해 Model을 이용하는 쪽에(즉, Presenter에) 응답을 전달할 수 있는 구조로 변경한 것입니다.

void setUserData(String email);
String makeNickname();
boolean checkNicknameLength(String nickname);
void callSignup(ApiListener listener);

public interface ApiListener {
    void onSuccess(JSONObject json);
    void onFail(String message);
}

UI파트의 요구사항을 ‘UI 변경’이라는 액티브 타입과 ‘이벤트 인식’이라는 패시브 타입으로 나누면 전자는 Presenter가 View를 호출하는 경우, 후자는 View가 Presenter를 호출하는 경우로 나눌 수 있습니다.

즉 전자에 해당되는 ‘추천 닉네임 보여주기’, ‘에러 메세지 보여주기’, ‘화면 전환하기’는 View에 구현되어야 하며, 후자에 해당되는 ‘액티비티 생성 이벤트 발생 시 인텐트 파싱’, ‘버튼 클릭 이벤트 감지’는 Presenter에 구현되어야 한다는 걸로 정리할 수 있습니다.

이렇게 정리한 것을 기반으로 서로 의존성을 갖는 View, Presenter간의 규약을 ‘Profile’이라는 Java interface에 정의하고, 최종 View, Presenter에서는 각각 Profile.View, Profile.Presenter interface를 구현해 보도록 하겠습니다.

public interface Profile {

    interface View {
	void showNickname(String nickname);
	void showErrorMessage(String message);
	void startMainActivity(JSONObject json);
    }

    interface Presenter {
        void initUserData(String email);
        void callSignup(String nickname);
    }

}

7. 구현

이제 위에 정의된 프로토타입 및 인터페이스에 맞춰서 실제 클래스를 구현하도록 하겠습니다. 먼저 Model의 경우 Presenter, View에 의존성이 없기 때문에 위의 프로토타입 그대로를 구현하면 다음과 같이 완성됩니다.

public class ProfileModel {

    String email;

    public void setUserData(String email) {
	this.email = email;
    }

    public String makeNickname() {
	int pos = email.indexOf("@");
	return email.substring(0, pos);
    }

    public boolean checkNicknameLength(String nickname) {
	if (nickname.length() < 4) {
	    return false;
	} else {
	    return true;
	}
    }

    public void callSignup(ApiListener listener) {
	Map params = new HashMap<>();
	params.put("nickname", etNickname.getText().toString());
	params.put("email", email);
	// Add additional parameters
	RequestInterface.requestPOST("/SIGNUP/", params, new ResponseListener() {
            @Override
	    public void onSuccess(JSONObject json) {
	        listener.onSuccess(json);
	    }

	    @Override
	    public void onError(int code, String message) {
	        listener.onFail(message);
	    }
	});
    }

    public interface ApiListener {
	void onSuccess(JSONObject json);
	void onFail(String message);
    }
}

View, Presenter의 경우는 약간의 추가 작업이 필요합니다. View는 Presenter에 의존하기 때문에 Presenter 객체를 멤버 변수로 가지고 있으며 정해진 ‘이벤트 발생’ 시점에 해야 할 역할을 Presenter에게 위임하도록 구현해야 합니다.

또한, Presenter는 View와 Model에 종속적이기 때문에 둘 다를 멤버 변수로 가지고 있으며, 요구사항에 알맞게 View, Model의 메소드들을 호출해 UI파트와 data파트의 상호작용을 만들 수 있도록 구현해야 합니다.

이어서, ProfileActivity에서 Profile.View를 구현하면 다음과 같습니다. onCreate() 시점에 Presenter의 initUserData()를 호출하고, 버튼의 OnClickListener 내에서 callSignup()을 호출함으로써 이벤트 발생을 Presenter에게 알리고 있습니다.

public class ProfileActivity extends Activity implements Profile.View {
	
    EditText etNickname;
    Button btSignUp;

    Profile.Presenter presenter;

    @Override
    public void onCreate(Bundle savedInstanceState) {
	super.onCreate(savedInstanceState);
	setContentView(R.layout.activity_profile);
	presenter = new ProfilePresenter(this);
	presenter.initUserData(getIntent().getStringExtra("email"));
	initView();
    }

    private void initView() {
	etNickname = (EditText) findViewById(R.id.nickname);
	btSignUp = (Button) findViewById(R.id.button_signup);

	btSignUp.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
            	presenter.callSignup(etNickname.getText().toString());
            }
        });
    }

    @Override
    public void showNickname(String nickname) {
    	etNickname.setText(nickname);
    }

    @Override
    public void showErrorMessage(String message) {
    	Toast.makeText(ProfileActivity.this, message, Toast.LENGTH_SHORT).show();
    }

    @Override
    public void startMainActivity(JSONObject json) {
    	Intent intent = new Intent(ProfileActivity.this, MainActivity.class);
    	Bundle params = new Bundle();
        params.putString("user_info", json.getString("user_info"));
        intent.putExtras(params);
        startActivity(intent);
    }
}

마지막으로, Profile.Presenter를 구현해서 ProfilePresenter 클래스를 완성시킵니다. 생성자 호출 방법은 여러 가지가 있으나 여기서는 고유한 lifecycle을 갖는 View가 생성되는 시점(onCreate())에 View를 파라미터로 전달하여 Presenter의 생성자를 호출하고 그 내부에서 ProfileModel 생성자를 호출하도록 구현하겠습니다. 나머지 메소드들은 요구사항에 정의된 대로 Model에서 데이터를 처리해서 View에 전달하는 역할을 합니다.

public class ProfilePresenter implements Profile.Presenter {
	
    Profile.View profileView;
    ProfileModel profileModel;

    public ProfilePresenter(Profile.View profileView) {
        this.profileView = profileView;
        this.profileModel = new ProfileModel();
    }

    @Override
    public void initUserData(String email) {
    	profileModel.setUserData(email);
    	profileView.showNickname(profileModel.makeNickname());
    }

    @Override
    public void callSignup(String nickname) {
    	if (profileModel.checkNicknameLength(nickname)) {
    	    profileModel.callSignup(new ProfileModel.ApiListener() {
		@Override
		public void onSuccess(JSONObject json) {
		    profileView.startMainActivity(json);
		}

	    	@Override
	    	public void onFail(String message) {
		    profileView.showErrorMessage(message);
	    	}
    	    });
    	} else {
    	    profileView.showErrorMessage("Nickname is too short");
    	}
    }
}

8. 글을 마치며

이로써 프로필 입력 단계에 MVP 패턴을 적용하여 리팩토링하는 전체 과정이 완료되었습니다. 실제 코드에 이를 적용한 이후 가장 크게 느꼈던 장점은 새로운 기능을 추가할 때 UI, data 파트를 나누어 적용하게 되어서 해야 할 일이 명확해졌고, 그 결과 쉽고 빠르게 적용이 가능했다는 것입니다.

이번에 다룬 MVP패턴은 기존에 정형화된 개발 패턴이 없었던 안드로이드에서 점차 입지를 넓혀가고 있으며, 이에 따라 다양한 적용 사례들을 쉽게 찾아볼 수 있긴 하지만, 이 블로그를 통하여 저는 제가 직접 사례들을 찾아가며 공부할때 느꼈던 막연함을 보완하고 조금 더 쉬운 방법으로 설명할 수 있게 코드에 적용을 마친 결과만이 아니라 요구사항 분석, 인터페이스 정의, 구현 등 ‘과정’에 대해 자세하게 기술하도록 노력하였습니다.

어떻게 보면 작은 성과이지만, 학습하고자 했던 영역 중 하나인 디자인 패턴 적용에 대해 처음부터 끝까지 스스로 학습하고 적용해, 허니스크린 개발에 조금이나마 효율성을 가져다 주었다는데 의의를 찾고 싶습니다.