AWS 비용 최적화 Part 1: 버즈빌은 어떻게 월 1억 이상의 AWS 비용을 절약할 수 있었을까
버즈빌은 2023년 한 해 동안 월간 약 1.2억, 연 기준으로 14억에 달하는 AWS 비용을 절약하였습니다. 그 경험과 팁을 여러 차례에 걸쳐 공유합니다. AWS 비용 최적화 Part 1: 버즈빌은 어떻게 월 1억 이상의 AWS 비용을 절약할 수 있었을까 (준비중) …
Read Article부끄럽지만 저는 버즈빌의 초보 개발자입니다. 버즈빌에서의 다양한 경험들이 저를 많이 변화시키고 있구요. 버즈빌의 뛰어난 개발자들 사이에서 일을 배워나가면서, 앞으로 향후에 저도 뛰어난 개발자가 되기 위해서는 반드시 공부해 두어야겠다고 생각한 몇 개의 영역이 있습니다. 그 중 하나가 ‘다양한 소프트웨어 디자인 패턴을 적절한 곳에 적용시키는 방법’이었습니다. 다행히 최근에는 많은 사람들의 경험을 통해 검증된 디자인 패턴이 많이 개발되고 있어, 다양한 패턴을 꾸준히 탐색해서 활용할 수 있는 안목만 키울 수 있다면 학습에 큰 어려움을 없을 것이라 생각하였습니다. 그동안 주로 기능적으로 시스템을 정상 동작하도록 만드는 것에만 집중했었지만, 여기서 더 나아가 가독성과 확장성이 좋고 협업하기 쉽게 만드는 구조를 짤 줄 알아야 장기적으로 더 좋은 프로덕트를 만들어 낼 수 있을 것이라는 어찌보면 막연한 생각도 배움에 대한 호기심을 자극하는데 한 몫 하였습니다. 그러던 중 실제 업무 진행 중에, 중 몇 가지 주요 패턴을 적용해 리팩토링을 할 기회를 갖게 되었습니다. 실무에서 직접 경험해보니 패턴의 유무가 만들어내는 결과의 차이는 생각보다 큰 역할을 하고 있었습니다. 미숙하지만, 이러한 맥락과 배경에서 저의 첫 버즈빌 기술 블로그를 시작해보고자 합니다. 이번 포스팅의 주요 내용은, 허니스크린 안드로이드 코드에 MVP패턴을 도입해 리팩토링 했던 경험을 바탕으로 안드로이드 어플리케이션에 MVP패턴을 적용하는 방법에 관한 것입니다.
‘MVP 패턴’은 Model, View, Presenter의 앞글자를 따서 이름이 지어졌습니다. 이 패턴의 핵심 아이디어는 사용자 인터페이스(View)와 비즈니스 로직(Model)을 분리하고, 서로간에 상호작용을 다른 객체(Presenter)에 위임해 서로의 영향을 최소화하는 것에 있습니다. 각 파트의 자세한 설명을 살펴보면 아래와 같습니다.
‘MVP패턴’을 이용해서 이와 같이 Model과 View간의 결합도를 낮추면, 새로운 기능을 추가하거나 변경할 필요가 있을 때 관련된 부분만 수정하면 되기 때문에 확장성이 좋아지며, 테스트 코드를 작성하기 편리해지기 때문에 더 안전한 코드 작업이 가능해집니다.
안드로이드에서 UI를 표현하는 컴포넌트들의 특징은 화면을 시각적으로 직접 그리는 역할 및 화면에 있는 UI 요소들에 대한 액션 처리를 항상 함께 담당한다는 것입니다. 이러한 프레임워크의 특징 때문에 기존에 웹 어플리케이션 등에서 많이 쓰이던 MVC(Model, View, Controller)패턴을 적용하기에는 화면을 그리는 View와 액션을 처리하는 Controller를 완전히 분리하기 어렵다는 한계가 있습니다. 이러한 이유때문에, MVP가 안드로이드에 더 적합하다는 논의가 이어져오고 있는거구요. 안드로이드를 개발한 구글 측에서도 Android architecture blueprints 라는 이름으로 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<String, String> 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를 작성하기가 어려울 것입니다.
위의 코드에 구현된 ‘프로필 입력’ 단계의 요구사항은 다음과 같이 정리할 수 있습니다.[info_box title=“Requirements - 1st”]
[/info_box]위 요구사항을 세분화하여 특성에 따라 Data, UI 파트로 나누도록 하겠습니다.[info_box title=“Requirements - 2nd”]
[/info_box]
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);
}
}
이제 위에 정의된 프로토타입 및 인터페이스에 맞춰서 실제 클래스를 구현하도록 하겠습니다. 먼저 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<String, String> 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");
}
}
}
이로써 프로필 입력 단계에 MVP 패턴을 적용하여 리팩토링하는 전체 과정이 완료되었습니다. 실제 코드에 이를 적용한 이후 가장 크게 느꼈던 장점은 새로운 기능을 추가할 때 UI, data 파트를 나누어 적용하게 되어서 해야 할 일이 명확해졌고, 그 결과 쉽고 빠르게 적용이 가능했다는 것입니다. 이번에 다룬 MVP패턴은 기존에 정형화된 개발 패턴이 없었던 안드로이드에서 점차 입지를 넓혀가고 있으며, 이에 따라 다양한 적용 사례들을 쉽게 찾아볼 수 있긴 하지만, 이 블로그를 통하여 저는 제가 직접 사례들을 찾아가며 공부할때 느꼈던 막연함을 보완하고 조금 더 쉬운 방법으로 설명할 수 있게 코드에 적용을 마친 결과만이 아니라 요구사항 분석, 인터페이스 정의, 구현 등 ‘과정’에 대해 자세하게 기술하도록 노력하였습니다. 어떻게 보면 작은 성과이지만, 학습하고자 했던 영역 중 하나인 디자인 패턴 적용에 대해 처음부터 끝까지 스스로 학습하고 적용해, 허니스크린 개발에 조금이나마 효율성을 가져다 주었다는데 의의를 찾고 싶습니다.
버즈빌은 2023년 한 해 동안 월간 약 1.2억, 연 기준으로 14억에 달하는 AWS 비용을 절약하였습니다. 그 경험과 팁을 여러 차례에 걸쳐 공유합니다. AWS 비용 최적화 Part 1: 버즈빌은 어떻게 월 1억 이상의 AWS 비용을 절약할 수 있었을까 (준비중) …
Read Article들어가며 안녕하세요, 버즈빌 데이터 엔지니어 Abel 입니다. 이번 포스팅에서는 데이터 파이프라인 CI 테스트에 소요되는 시간을 어떻게 7분대에서 3분대로 개선하였는지에 대해 소개하려 합니다. 배경 이전에 버즈빌의 데이터 플랫폼 팀에서 ‘셀프 서빙 데이터 …
Read Article