배민마켓 개발하기

안녕하세요, 배민앱개발팀에서 안드로이드 앱 개발을 하고 있는 강경완입니다.

안드로이드 파트로 온 지 얼마 되지 않아 미션이 떨어졌습니다. 기존에 음식 배달만을 하던 배달의민족 서비스에 배민마켓이라는 새로운 형태의 서비스를 빠르게 넣어야 하는 것이었습니다. 빠른 시범 운영을 위해 기존의 도메인을 그대로 사용한 채 만들게 되었습니다.

클.. 클린 아키텍처요..?

배달의민족 안드로이드 앱(이하 배민앱)은 MVP 와 클린 아키텍처를 조금 변형해서 사용하고 있습니다.

소프트웨어 아키텍처는 항상 뜨거운 주제인 것 같습니다. 제 주변에는 ‘MVP 와 MVVM 중에 어떤 것이 더 나은가’라는 주제와 화이트보드를 던져주면 1시간 동안 토론을 할 수 있을 만큼 관심이 많은 친구들이 있는데요, 저 또한 안드로이드 파트에 오기 전에는 MVP 와 MVVM 을 공부를 했었고, iOS 파트에서도 MVVM 패턴으로 구현을 하고 있었는데, (주변에서 클-린 하지 못하다고 얘기해서 딱히 유심히 보지 않았던) 클린 아키텍처를 접하게 되었습니다.

package

배민앱도 흔히 많이 쓰는 3가지 레이어로 분리되어 있습니다.

Presentation 레이어

Presentation 레이어는 플랫폼과 (배민앱에서는 안드로이드) 의존성이 높은 UI 레벨의 레이어입니다. 이곳에서 MVP, MVVM 또는 그 외의 패턴을 사용할 수 있고, 배민앱에서는 MVP 패턴을 사용하고 있습니다.

MVP 패턴을 사용하면 사용자에게 화면을 보여주거나 액션을 받을 수 있는 UI 부분인 View 와 데이터를 가공한 후 View 에게 전달해주는 Presenter 부분으로 나눌 수 있습니다.

배민앱에서는 List 형식의 View 에서 Recycler View 사용하고, Presenter에서 Usecase 로 가져온 데이터를 DisplayModel 에 Mapping 하여 AdapterDelegate를 통해 뿌려주고 있습니다.

public class MarketShopFragment extends BaseFragment implements MarketShopContract.View {

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        GetShopUseCase getShopUseCase = new GetShopUseCase();
        presenter = new MarketShopPresenter(this, getShopUseCase);
    }

    @Override
    public void setUpContent(List<Model> items) {
        adapter.setItems(items);
        adapter.notifyDataSetChanged();
    }

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        presenter.loadShop();
    }
}

MVP 패턴이므로 Contract Interface 에 View 와 Presenter 를 넣어 각각 implements 해주고 있습니다.

public class MarketShopPresenter implements MarketShopContract.Presenter {        
    public void loadShop() {
        getShopUseCase.execute((shopModel)-> {
            bindView(shopModel);
        });
    }

    private void bindView(ShopModel shopModel) {
        view.setUpContent(shopModel.getModels());
    }
}

Domain 레이어

Domain 레이어는 비즈니스 로직을 담고 있습니다. Domain 레이어에는 비즈니스 로직에서 필요한 Model 과 (UseCase 를 통해 넘겨줄 데이터를 담을 모델) Presentation 레이어와 Service 를 거쳐 Data 레이어를 이어줄 UseCase, 그리고 중간에서 비즈니스 로직을 책임질 Service 구현체들, 그리고 Service 가 가져올 데이터 도메인의 Repository의 인터페이스가 자리 잡고 있습니다.

public class GetShopUseCase extends UseCase<ShopModel,Long> {
    @Override
    protected Flowable<ShopModel> buildUseCaseFlowable(long shopNumber) {
        return shopService.getShop(shopNumber);
    }
}

public class ShopServiceImpl implements ShopService {
    @Override
    public Flowable<ShopModel> getShop(long shopNumber) {
        return getShop(shopNumber)
                .flatMap(shop -> just(userService.getUser())
                        .flatMap(user -> preferableShopRepository.isFavorite(shopNumber))
                        .map(updateFavorite(shop))
                        .defaultIfEmpty(shop));
    }
    
    @NonNull
    private Function<Boolean, ShopModel> updateFavorite(ShopModel shop) {
        return isFavorite -> shopModel.toBuilder()
                .favorite(isFavorite)
                .build();
    }
}

public interface ShopRepository {
    Flowable<ShopModel> getShop(long shopNumber);
}

Repository 에서 데이터를 가져와 비즈니스로직에 맞게 데이터를 수정해서 UseCase 를 통해 전달하고 있습니다.

Data 레이어

마지막으로 Data 레이어에는 Repository 와 DataSource, 그리고 Data 그 자체인 Entity 가 있습니다. 추가로 DataSource 가 Data 를 가져오기 위해 연결될 Remote API Client (Retrofit Interface) 와 LocalCache (Sharedpreferences, SQLite, 혹은 메모리에 그냥 들고 있다던지 등) 도 자리 잡고 있습니다.

배민앱의 경우 Data 레이어는 조금 다른 방법을 취하고 있는데요, 먼저 많은 예제 사이에서 귀감이 되는 Google Sample Code를 살펴보겠습니다.

if (mCacheIsDirty) {
    // If the cache is dirty we need to fetch new data from the network.
    getTasksFromRemoteDataSource(callback);
} else {
    // Query the local storage if available. If not, query the network.
    mTasksLocalDataSource.getTasks(new LoadTasksCallback() {
        @Override
        public void onTasksLoaded(List<Task> tasks) {
            refreshCache(tasks);
            callback.onTasksLoaded(new ArrayList<>(mCachedTasks.values()));
        }

        @Override
        public void onDataNotAvailable() {
            getTasksFromRemoteDataSource(callback);
        }
    });
}

출처 : https://github.com/googlesamples/android-architecture/

번외지만 소스가 너무 아름다워요…

Google의 Sample Code 인 MVP-CleanCode 에서는 Repository 에서 LocalDataSource와 RemoteDataSource 중 적절한 DataSource를 선택하여 응답을 내려줍니다. Repository 클래스 안에 두 객체를 가지고 있는 형태이죠.

배민앱에서는 Local과 Remote로 구분된 DataSource가 아닌 하나의 DataSource가 Remote와 Cache 중 적절한 객체를 선택하고 Remote와 Cache에 대한 CRUD를 처리합니다.

flow

public class ShopRepositoryImpl implements ShopRepository {
    
    private final ShopDataSource dataSource;
    private final ShopEntityMapper shopEntityMapper;
    
    Flowable<ShopModel> getShop(long shopNumber) {
        return dataSource.getShop(shopNumber)
            .map(shopEntityMapper::transform);
    }
}

DataSource 에서는 Remote 와 Cache 를 선택하여 데이터를 가져오고, 상황에 따라 다른 객체를 선택하여 처리합니다. 아래 코드에서는 화면에 진입했을 때의 상황이기 때문에 서버에서 최신의 데이터를 가져오기 위해 Remote 에서 shop 을 가져오고, Cache 에 저장하고 있습니다.

public class ShopDataSourceImpl implements ShopDataSource {
    private final ShopCache shopCache;
    private final ShopRemote shopRemote;
    
    public Flowable<ShopEntity> getShop(long shopNumber) {
        return shopRemote.getShop(shopNumber)
            .doOnNext(shopCache::setShop);
    }   
}

또한 Remote라는 객체는 서버에서 API를 호출 할 Retrofit Interface에서 데이터를 가져온 후 네트워크 실패 / 서버에러 등 에러처리를 Remote에서 판단하여 데이터를 내려주고 있습니다.

이런 방식은 데이터 캐싱에 관련된 Remote / Cache 처리가 쉬워집니다.

그래서 배민마켓!

배민마켓의 물건 배송은 배민라이더스 분들이 하시기 때문에 기존의 배민라이더스 인프라를 그대로 이용한다고 하면 가게 정보에 대한 서버 API 는 그대로 이용해도 문제가 없습니다.

빠른 시범 운영이 중요하기 때문에 기존의 서버 API를 최대한 활용하기로 하고 개발이 시작되었습니다.

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GetShopUseCase getShopUseCase =
        new GetShopUseCase(ShopServiceImpl.getInstance());

    GetBaeraOperationUseCase getBaeraOperationUseCase =
        new GetBaeraOperationUseCase(BaeraShopListServiceImpl.getInstance());

    presenter = new MarketShopPresenter(
        this,
        getShopUseCase,
        getBaeraOperationUseCase,
        MarketShopModelMapper.getInstance())
}

Domain 레이어에서 필요한 데이터를 새로 코드를 만들일 없이 그대로 가져온다!

그렇기 때문에 Domain 레이어 이하는 새로 만들거나 변경할 필요가 거의 없었습니다. (일반 가게와 배민마켓을 구분해줄 Flag 추가 정도!) 그렇기 때문에 사전에 잘 분리된 아키텍처 덕분에 기존의 코드를 활용하여 Presentaion 레이어만 디자인 가이드에 맞게 구현해 주었습니다.

market

그리고 2주만에 완성된 배민마켓

처음에는 2주라는 일정이 말이 안된다고 생각했지만 잘 분리되어 있는 아키텍처 덕분에 해낸 것 같습니다!

(물론 힘들었습니다.)

마무리

MVP 가 더 좋은지, MVVM 이 더 좋은지, 또는 클린 아키텍처가 정말 클-린한 지에 대해서는 논란이 많습니다. 그리고 아키텍처를 나누는 것이 어찌 보면 제약으로 다가올 수도 있고, 급한 상황에서는 신경 쓸 겨를이 없을 때가 있습니다. 저희 팀에서 쓰고 있는 아키텍처가 완벽하지도 않습니다.

클린 아키텍처의 장점으로는 잘 나눠진 프로젝트인 덕분에 어떤 역할을 하는 코드가 어디에 있는지 명확하게 파악 가능하다는 부분입니다. 안드로이드 파트로 온 지 얼마 되지 않았지만, 쉽게 파악하고 사용할 수 있었습니다.

단점으로는 클래스 파일 수가 많아지는 점이 있었지만, 그와 반비례 적으로 한 클래스 당 라인 수가 줄어들어 가독성이 향상되었습니다.

또한 아키텍처에 대한 이해도 없이 처음 봤을 상태에서는 Repository -> RepositoryImpl -> DataSource -> DataSourceImpl -> Remote ->…. 까지 따라가며 이해하는 게 힘들었었고, 한 번에 구조를 잘 잡기는 힘들어 보였습니다.

개인적으로는 단점보다 강점이 더 많고, 좋은 아키텍처는 개발자에게 더 나은 안정감과 더 큰 생산성을 준다고 생각합니다. 아키텍처에는 정말 정답이 없는 것 같습니다. 마치 언어학처럼 느껴지기도 합니다. 그래서 다양한 사람들을 만나보고 토론하며 더 나은 방향이 무엇인지 토론하는 게 재미있는 것 같습니다.

평균 30분 안에 배달하는 배민마켓 파이팅!!