어떤 소프트웨어든 OOP 기반이라면 모델 구조를 설계하는 것부터 시작합니다. 모델이라 함은 소프트웨어를 Object-Oriented로 만들기 위해 현실에 존재하는 개념을 묶은 객체를 의미합니다. 모델은 소프트웨어를 구성하는 작으면서도 근본적인 단위이기 때문에 처음 정의하는 것에 따라 구현방향과 성능에 영향을 미칩니다. 따라서 상황에 적절한 모델을 설계하고 활용하는 것이 중요합니다.
이번에 리멤버에서 메신저 기능을 추가하면서 지금까지와는 다른 속성의 모델들이 추가되었습니다. 자연히 상황에 따라 다르게 활용할 수 있는 유연한 모델 구조가 필요했고, 많은 리팩토링을 하였습니다. 이번 포스팅에서는 서비스를 만들면 누구나 하는, 사소하지만 중요한 모델에 대한 고민을, 안드로이드 클라이언트 개발자의 시선에서 나누려고 합니다.
시작하기
Card 라는 명함 모델이 하나 있다고 합시다. 이제부터 다양한 상황에 따라 발전시키면서 어떻게 모델이 진화하는가 살펴보겠습니다.
public class Card { private long id; private String name; private String mobile; private String address; private String email; private String company; private String position; private Date createdAt; private Date updatedAt; }
1. 로컬에서만 돌아가는 서비스
로컬에서만 비즈니스 로직이 돌아가는 서비스는 구현한 모델에 따로 손을 대지 않고 바로 사용할 수 있습니다.
2. API를 호출하는 서비스
이제 서비스가 로컬에서만 돌아가지 않고 서버를 이용하게 되었습니다. 그러면서 Card 모델을 사용하는 API가 추가되었습니다. 스펙은 Json으로 가정하여 다음과 같습니다.
{"card":{ "id": 1, "name": "이승민", "mobile": "01012341234", "address": "서울시 ...", "email": "[email protected]", "company": "드라마앤컴퍼니", "position": "안드로이드 개발자", "created_at": "2015-04-01T10:00:00.000+09:00", "updated_at": "2015-04-01T10:00:00.000+09:00" } }
Card를 받아오거나 Parameter로 보내는 등의 작업을 하기 위해서는 Card 모델의 변경이 필요합니다. 아직은 복잡한 비즈니스 로직이 없기 때문에 최대한 하나의 모델로 해결하고 싶습니다. Google의 Gson Library를 이용하여 기존 Card 모델로 API까지 사용할 수 있도록 개선합니다.
public class Card { @SerializedName("id") private long id; @SerializedName("name") private String name; @SerializedName("mobile") private String mobile; @SerializedName("address") private String address; @SerializedName("email") private String email; @SerializedName("company") private String company; @SerializedName("position") private String position; @SerializedName("created_at") private Date createdAt; @SerializedName("update_at") private Date updatedAt; }
3. 로컬DB를 사용하는 서비스
인터넷이 연결되어있지 않아도 서비스를 사용할 수 있도록 해달라는 요구사항이 왔습니다. 서버에서 받아온 명함들을 로컬DB에 저장해야 합니다. 역시 아직은 복잡한 로직이 없기 때문에 최대한 하나의 모델로 해결하고 싶습니다. 이제 API, 비즈니스, 로컬DB에서 모두 사용가능한 모델로 개선해야 합니다. 로컬DB로 RDB를 사용하면 ORM을 이용하여 DB 스키마를 클래스로 변환해 모델로 바로 활용할 수 있습니다. 안드로이드 ORM 프레임워크 중 하나인 GreenDAO를 사용하여 개선해보겠습니다.
@Entity public class Card { @Id @SerializedName("id") private long id; @SerializedName("name") private String name; @SerializedName("mobile") private String mobile; @SerializedName("address") private String address; @SerializedName("email") private String email; @SerializedName("company") private String company; @SerializedName("position") private String position; @SerializedName("created_at") private Date createdAt; @SerializedName("update_at") private Date updatedAt; }
4. API와 비즈니스의 분리
다양한 비즈니스 로직이 생기면서 API 스펙이 다음과 같이 복잡해졌습니다.
{"card":{ "id": 1, "data": { "name": "이승민", "mobile": "01012341234", "address": "서울시 ...", "email": "[email protected]", "company": "드라마앤컴퍼니", "position": "안드로이드 개발자" }, "created_at": "2015-04-01T10:00:00.000+09:00", "updated_at": "2015-04-01T10:00:00.000+09:00" } }
이제 Depth가 생겨 하나의 모델 스키마로 자동매핑을 할 수 없게 되었습니다. 하지만 아직 비즈니스 로직은 하나의 모델로 간단하게 유지하고 싶습니다. 그래서 API 응답을 그대로 사용하지 않고 비즈니스 객체로 따로 매핑해주기로 하였습니다. 그러기 위해서는 다음과 같은 Mapper가 필요합니다.
public class CardAPIMapper { public void doMaterialize(JsonElement from, Card to) { JsonObject json = from.getAsJsonObject(); JsonElement idJson = json.get("id"); to.setId(idJson.getAsLong()); Gson gson = new Gson(); to.setCreatedAt(gson.fromJson(json.get("created_at"), Date.class)); to.setUpdatedAt(gson.fromJson(json.get("updated_at"), Date.class)); JsonObject dataJson = json.get("data").getAsJsonObject(); to.setName(dataJson.get("name").getAsString()); to.setMobile(dataJson.get("mobile").getAsString()); to.setAddress(dataJson.get("address").getAsString()); to.setEmail(dataJson.get("email").getAsString()); to.setCompany(dataJson.get("company").getAsString()); to.setPosition(dataJson.get("position").getAsString()); } }
이제 API 스펙이 어떻게 복잡하게 되더라도 Mapper만 수정하면, 비즈니스 로직은 건드리지 않고 수정된 사항을 적용할 수 있게 되었습니다.
5. DB와 비즈니스의 분리
Card에 Primitive 타입이 아닌 변수가 추가되었습니다. Original, Preview 속성을 가진 이미지 모델이 앞면, 뒷면 총 2개가 있어야 합니다. DB 스키마와 같은 모델을 사용하고 있으므로 ORM으로 Relation도 함께 추가합니다.
@Entity public class Card { @Id private long id; private String name; private String mobile; private String address; private String email; private String company; private String position; private Date createdAt; private Date updatedAt; @ToOne private Image front; @ToOne private Image back; } @Entity public class Image { @Id private long id; private String original; private String preview; }
모델을 위와같이 만들고보니 DB에 Image 테이블을 추가하여 Card 테이블에 Relation을 추가해야 합니다. 하지만 Card에만 사용되는 Image라는 작은 요소로 인해 테이블을 추가해야 하는 부담감, 테이블을 구분하였을 때 생기는 Join 연산으로 인한 성능저하 등 우려되는 점이 있습니다. 그래서 DB에는 하나의 테이블에 변수을 몰아넣고, 비즈니스 모델과 분리하기로 하였습니다. 그러면 API와 마찬가지로 Mapper가 필요합니다.
@Entity public class CardDB { @Id private long id; private String name; private String mobile; private String address; private String email; private String company; private String position; private Date createdAt; private Date updatedAt; private String frontOriginal; private String frontPreview; private String backOriginal; private String backPreview; }
public class CardDBMapper { public void materialize(CardDB from, Card to) { to.setId(from.getId()); to.setName(from.getName()); to.setMobile(from.getMobile()); to.setAddress(from.getAddress()); to.setEmail(from.getEmail()); to.setCompany(from.getCompany()); to.setPosition(from.getPosition()); to.setCreatedAt(from.getCreatedAt()); to.setUpdatedAt(from.getUpdatedAt()); Image front = new Image(); front.setOriginal(from.getFrontOriginal()); front.setPreview(from.getFrontPreview()); to.setFront(front); Image back = new Image(); back.setOriginal(from.getBackOriginal()); back.setPreview(from.getBackPreview()); to.setBack(back); } public void dematerialize(Card from, CardDB to) { to.setId(from.getId()); to.setName(from.getName()); to.setMobile(from.getMobile()); to.setAddress(from.getAddress()); to.setEmail(from.getEmail()); to.setCompany(from.getCompany()); to.setPosition(from.getPosition()); to.setCreatedAt(from.getCreatedAt()); to.setUpdatedAt(from.getUpdatedAt()); to.setFrontOriginal(from.getFront().getOriginal()); to.setFrontPreview(from.getFront().getPreview()); to.setBackOriginal(from.getBack().getOriginal()); to.setBackPreview(from.getBack().getPreview()); } }
이제 DB 스키마도 비즈니스 모델과 분리되었습니다. API, 비즈니스, DB가 모두 분리되어 서로의 스키마가 변하더라도 Mapper만 손보면 영향 없이 모든 수정을 아름답게 적용할 수 있습니다. 하지만 서비스가 커질수록 이 구조는 아름답지 못했습니다.
6. Mapping 작업의 성능향상
갑자기 사용자들로부터 CS가 들어오기 시작하였습니다. 앱이 점점 느려진다는 이야기 입니다. 몇 사용자는 아예 사용하지 못할 정도로 심하게 느려졌다고 합니다. 원인을 분석해보니, 사용자들이 점점 많은 갯수의 명함을 저장하면서 DB ORM 객체를 Mapper가 비즈니스 객체로 변환하는 과정에서 엄청난 성능 저하가 있었습니다. 모델을 하나로 사용하였을 때에는 없었던 객체 변환과정이 추가되면서, 예상하지 못했던 문제가 생긴 것입니다.
모델의 형태는 상황에 맞추어 결정한 사항이므로 변경하고 싶지 않습니다. 그래서 성능 저하가 있는 ‘변환’ 부분만 손보기로 하였습니다. 먼저 인스턴스 변수를 모두 삭제합니다. 스키마를 갖춘 Getter, Setter는 그대로 유지합니다. 그리고 ORM 객체를 레퍼런스로 두고 Getter Setter에서 Proxy로 사용합니다. 이러면 객체를 변환하지 않고 Proxy로 사용하는 ORM 객체로부터 직접 값을 가져오므로, 변환으로 인한 성능 저하를 해소할 수 있습니다.
public class Card { private CardDB cardDB; public long getId() {return cardDB.getId();} public void setId(long id) {cardDB.setId(id);} public String getName() {return cardDB.getName();} public void setName(String name) {cardDB.setName(name);} public String getMobile() {return cardDB.getMobile();} public void setMobile(String mobile) {cardDB.setMobile(mobile);} public String getAddress() {return cardDB.getAddress();} public void setAddress(String address) {cardDB.setAddress(address);} public String getEmail() {return cardDB.getEmail();} public void setEmail(String email) {cardDB.setEmail(email);} public String getCompany() {return cardDB.getCompany();} public void setCompany(String company) {cardDB.setCompany(company);} public String getPosition() {return cardDB.getPosition();} public void setPosition(String position) {cardDB.setPosition(position);} public Date getCreatedAt() {return cardDB.getCreatedAt();} public void setCreatedAt(Date createdAt) {cardDB.setCreatedAt(createdAt);} public Date getUpdatedAt() {return cardDB.getUpdatedAt();} public void setUpdatedAt(Date updatedAt) {cardDB.setUpdatedAt(updatedAt);} public Image getFront() { Image image = new Image(); image.setOriginal(cardDB.getFrontOriginal()); image.setPreview(cardDB.getFrontPreview()); return image; } public void setFront(Image front) { cardDB.setFrontOriginal(front.getOriginal()); cardDB.setFrontPreview(front.getPreview()); } public Image getBack() { Image image = new Image(); image.setOriginal(cardDB.getBackOriginal()); image.setPreview(cardDB.getBackPreview()); return image; } public void setBack(Image back) { cardDB.setBackOriginal(back.getOriginal()); cardDB.setBackPreview(back.getPreview()); } }
7. Proxy 고도화
Proxy 방식으로 비즈니스와 DB 스키마를 분리하면서 성능을 유지할 수 있었습니다. 하지만 비즈니스 객체 안에서 여러 연산이 필요해지고 Relation, Depth 및 변수가 추가됨에 따라, 모든 변수를 Proxy로부터 가져오는 것이 개발자의 생산성을 크게 떨어뜨리게 되었습니다. 인스턴스 변수에 바로 접근하지 못하고 프록시를 거치게 되면 개발자가 한번 더 신경써줘야 하기 때문입니다. 그래서 다음과 같은 딜레마에 빠지게 되었습니다.
- 비즈니스와 DB 모델의 스키마를 분리하고싶다. 모델을 구분하고 Mapper를 두어 해결하였다.
- 둘 사이 변환 비용을 최소화 하고싶다. 비즈니스 모델에 인스턴스 변수를 배제하고 DB 객체 Proxy를 두어 해결하였다.
- 개발자 생산성을 위해 비즈니스 모델에서 인스턴스 변수를 유지하고싶다. – (b)와 모순된다!
(b)와 (c)의 해결방법은 정반대 이므로 모든 장점을 취할 수는 없었습니다. 그래서 상황에 따라 부분적으로 선택하기로 하였습니다.
먼저 (c)는 모델을 정의하는 요구사항이므로 양보할 부분이 없습니다. 인스턴스 변수를 추가합니다.
이제 (b)가 무엇이 문제였는지 다시 살펴보면, 사용자가 인지할 정도의 성능저하가 생기는 것이 가장 큰 문제였습니다. 그러면 사용자가 인지할 정도의 성능저하가 나는 곳에서는 Proxy를 사용하고, 그정도의 저하가 일어나지 않는 곳에서는 원하는 객체로 변환하여 사용하면 될 것 같습니다. 그래서 Card를 상속하는 CardProxy 모델을 만들었습니다. 평소에는 Proxy 로부터 값을 직접 받아오지만, 필요할 때에 Card로 스스로 변환되어 활용되는 모델입니다.
이 모델의 핵심은 ‘부분적인 인스턴스 변수에 대해서’ Proxy로부터 값을 직접 받아오는 것입니다. 성능에 가장 큰 영향을 주는 곳에서 사용하는 변수를 Proxy에서 직접 받아옵니다. 변환으로 인해 가장 느린 곳은 많은 Card를 DB로부터 한번에 불러오는 메인 리스트 화면 이였습니다. 메인 리스트에서 활용하는 Card의 인스턴스 변수는 id, name, company, position 이라고 가정합니다. 그러면 해당 변수만 Proxy로 돌리고, 다른 값을 호출하였을 때에는 Mapper를 통해 변환 작업을 하도록 합니다. 이것을 정리한 모델의 형태는 다음과 같습니다.
public class CardProxy extends Card { private CardDB cardDB; private CardDBMapper mapper = new CardDBMapper(); private boolean loaded = false; public CardProxy(CardDB cardDB) { this.cardDB = cardDB; } @Override public long getId() { // 리스트에 사용되는 변수는 CardDB 로부터 직접 값을 가져온다. if (!isLoaded()) return cardDB.getId(); return super.getId(); } @Override public String getName() { if (!isLoaded()) return cardDB.getName(); return super.getName(); } @Override public String getMobile() { // 리스트에 사용되지 않는 변수는 Mapper를 통해 변환 후 값을 가져온다. load(); return super.getMobile(); } @Override public String getAddress() { load(); return super.getAddress(); } @Override public String getEmail() { load(); return super.getEmail(); } @Override public String getCompany() { if (!isLoaded()) return cardDB.getCompany(); return super.getCompany(); } @Override public String getPosition() { if (!isLoaded()) return cardDB.getPosition(); return super.getPosition(); } @Override public Date getCreatedAt() { load(); return super.getCreatedAt(); } @Override public Date getUpdatedAt() { load(); return super.getUpdatedAt(); } ... // Mapper를 통해 변환되었는지 여부 private boolean isLoaded() { return loaded; } // Mapper를 이용하여 Card Instance들을 채워준다. private void load() { if (loaded) return; mapper.materialize(cardDB, this); loaded = true; } }
위 코드에서 getId()와 같이 메인 리스트에서 사용되는 값을 가져올 때에는 Proxy로부터 바로 가져옵니다. 이것으로 성능을 보장합니다. getMobile()과 같이 메인 리스트에서 사용되지 않는 값을 가져올 때에는 Card로 스스로 변환되어 값을 가져옵니다. 이것으로 Card Interface의 사용성이 보장됩니다. 이 형태를 결과적으로 바라보면, ‘원하는 값을 조절할 수 있는 Lazy Loading 모델’ 입니다. 비즈니스 로직에서는 Card를 Interface로 사용하면서 실제 객체는 CardProxy를 로드하면 원하는 Lazy Loading을 얻을 수 있습니다.
이렇게 해서 다양한 형태의 API, 비즈니스, DB 스키마를 소화하는 유연성과, 사용자에게 만족스러운 속도를 보장하는 아름다운 Card 모델이 완성되었습니다.
마무리
사실 모델은 자체 구조뿐 아니라 모델을 활용하는 Controller, Presenter, Repository 등의 구조에도 많은 영향을 받습니다. 때문에 위처럼 구현한다고 당연히 만능이 되지 않습니다. 요구사항에 맞는 적절한 형태를 개발자가 그때그때 생각하고 결정해야 합니다. 제가 고민하고 적용했던 위와 같은 설계 외에도 더 좋은 많은 방식들이 있을 것입니다. 앞으로 어떤 복잡한 요구사항이 생겨 위 구조의 허점을 깨닫고 새롭게 구상해야할 수도 있습니다. 그러니 이것을 정답으로 생각하지 마시고 ‘아 리멤버는 저런 고민을 하였구나. 우리한테는 이런 것이 도움이 되겠다.’ 정도로 느끼시기만 하셔도 저는 기쁠 것 같습니다.
모두 자신의 서비스에 맞는 효율적이면서 성능 좋은 유연한 구조를 잘 찾으시기를 바랍니다.
글 잘 읽었습니다 🙂
essay writing for mba essays about love where can i buy essays