안녕하세요! 오늘은 Android의 Clean Architecture에 관한 글을 써보려고 해요 😊
이 글을 쓰는 이유는 역시나 제가 이 개념을 몰라서 인터넷을 뒤지는데 시간을 썼기 때문!
이제 갓 1년이 지나가는 안드 개발자인 저로써는 Clean Architecture가 뭔지도, 그걸 왜 써야 하는지도 몰라서
설명글을 봐도 이해하기 어려웠어요 ㅠㅠ
그래서 1년 이하의 경력을 가지신 분들을 대상으로
Android Clean Architecture의 구조와 사용하는 이유에 대해 간략하게 설명하려고 해요!
우선 Clean Architecture라는게 뭘까요?
기본적으로 Architecture라는 건 건축 용어인데요, 구조라는 뜻이라고 해요.
그러니까 깨끗한 구조, 더 구체적으로는 개발하기 좋은 구조라는 거죠.
근데 "개발하기 좋다"는 말은 조금 애매하죠?
제가 예시를 하나 드릴게요.
예전에 멘토링을 들으면서 알게된 사실인데,
Android에서 제공하는 기본 전화 앱이 자그마치 17만줄의 코드로 이루어져 있다고 해요.
근데 만약에 전화 앱의 모든 내용이 한 파일에 쑤셔 박혀 있다고 생각해봐요.
만약에 작은 버그라도 생겨서 코드 내용을 조금만 수정하려면...?
17만줄을 뒤져가면서 버그가 발생한 부분을 찾고, 관련 있는 코드를 찾아서 모두 수정해줘야 하겠죠...?
생각만 해도 눈알이 빠질 것 같네요...
이런 사태를 방지하려면 관련이 있는 코드끼리 묶고,
역할이 다른 코드끼리는 분리해두는 작업이 필요해요.
즉, 개발하기 좋은 구조란 코드의 역할이 분리되어 있는 구조를 의미해요.
그러면 역할은 어떻게 나눌까요?
Clean Architecture는 아래와 같이 세 영역으로 나뉘어져 있어요.
그림으로 본 것처럼, Clean Architecture는 크게
⓵ UI 담당 View 계층과
⓶ 통신 담당 Data 계층, 그리고
⓷ 잡일 담당 Domain 계층으로 구성돼요.
조금만 더 풀어서 설명하자면,
View 계층은 Activity와 Fragment, 그리고 각종 View와 같이 화면에서 보이는 영역이에요.
Data 계층도 이름 그대로 서버 또는 사용자 기기와 데이터를 주고 받는 역할을 수행해요.
마지막으로 Domain 계층은 비즈니스 로직을 담당하는데, 말이 조금 애매하죠?
쉽게 말하면 View 계층과 Data 계층 사이에서 잡일을 처리한다는 말이에요 😅
이렇게 말하니까 너무 딱딱한 것 같아서 비유적으로 얘기해볼게요.
우선 서버를 밭, 서버에서 보낸 데이터는 쌀이라고 생각해봐요.
그러면 Data 영역은 밭에서 만들어진 쌀을 처음으로 받아오는 마트고,
View 영역은 쌀로 밥을 짓는 부엌이에요.
그럼 그 사이에 쌀을 사고, 집으로 가져오는 등의 과정이 필요하죠?
이 부분이 Domain 영역이라고 생각하면 돼요.
이제 조금 감이 오나요?
그렇다면 조금 더 구체적인 얘기를 해봐도 될 거 같아요.
우선 Clean Architecture를 도식화한 사진을 볼건데,
제가 이해할 수 있도록 천~천~히 설명해드릴테니까 미리 겁먹지 마세요!
뒤로 가기 마렵죠?
클린 아키텍쳐가 아니라 더티 아키텍쳐라는 말이 절로 나오죠?
그치만 사실 하나 하나 뜯어보면 앞에서 했던 얘기를 그림으로 나타낸 것 뿐이에요.
그러니까 걱정하지 마시고, 각 영역 별로 알아봐요!
우선 통신을 담당하는 Data 영역 먼저 볼까요?
끝에서부터 시작해보죠.
우선, Entitiy는 본체라는 뜻인데요, 현실 세계에 있는 사물이랑 똑같은 거라고 생각하면 돼요.
예를 들어 주고 받은 데이터가 쌀이라면 쌀 클래스에 수확 일자, 무게, 생산지 등의 속성이 있겠죠?
😶🌫️ 몰라도 되는 내용
Entity 계층의 각각의 클래스는 DTO라고 불리기도 해요. 예를 들면 쌀 DTO, 벼 DTO 이런 식으로요.
Data Transfer Object라는 뜻인데, 데이터를 주고 받을 때 사용하는 물건이라는 뜻이에요.
그 다음, Entity는 DataStore와 이어져 있죠?
DataStore은 이름 그대로 데이터를 다루는 가게에요.
애플 스토어에 가면 애플의 제품들이 다 있죠?
그런 것처럼 우리도 DataStore에서 Entity에 담긴 데이터를 받아올 수가 있어요.
즉, DataStore에는 데이터를 받아오는 메서드를 가진 클래스가 있는 거죠.
😶🌫️ 몰라도 되는 내용
데이터를 주고 받는 대상은 주로 서버 또는 사용자 기기에요.
Android 측에서는 통신 기능을 쉽게 구현할 수 있도록 라이브러리를 제공하는데요,
서버와의 통신은 Retrofit2를, 사용자 기기와의 통신은 Room을 이용하면 간단하게 구현할 수 있어요.
마지막으로 DataStore은 Repository와 이어져 있어요.
Repository는 이상하게도 Domain 영역과 Model 영역에 반씩 걸쳐 있는데,
지금은 그렇게 중요하지 않으니 나중에 설명할게요.
우선은 Repository가 무엇인지 먼저 얘기해봐요.
Repository는 저장소라는 뜻인데요, 간단히 말하면 데이터 구매 대행을 해주는 곳이에요.
데이터 가게에 해당하는 DataStore가 있는데,
왜 가게에서 물건을 직접 사지 않고 구매 대행을 하는지 모르겠죠?
현실 세계랑 똑같아요.
우리도 물건을 살 때 가격 비교 사이트를 이용하잖아요?
가격을 비교해주는 사이트를 이용하면
여러 쇼핑몰을 들락날락 하면서 하나 하나 가격을 비교할 필요 없이
사이트에서 안내해주는 물건을 사면 되죠.
똑같아요.
코드를 작성할 때도 각각의 DataStore에서 Data를 직접 받아오면
DataStore의 세부 사항이 변경될 때마다 관련 있는 부분의 코드를 찾아서 일일이 변경해줘야 해요.
하지만 Repository라는 중간 다리를 두어서,
Repository(=구매 대행소)가 DataStore(=쇼핑몰)으로부터 Data(=물건)를 받아오고
우리는 Repository로부터 데이터를 받아오면
DataStore의 세부 사항이 변경되어도 전체 코드를 다 뒤적거릴 필요 없이 Repository의 코드만 변경하면 되죠!
굳이 이렇게까지 해야 하냐고요?
이렇게 안 해서 손목이 시큰해지는 노가다 약간의 고생을 해보면 '이래서 구매 대행소를 둔거구나' 하실 거에요 😉
그럼 Data 영역을 알아봤으니 Domain 영역도 알아볼까요?
이제 데이터가 구매 대행소까지 건너 왔네요.
Domain 계층에서는 UseCase를 거치는데요, 그 와중에 Translater와 Model이 끼어드는 구조에요.
UseCase란 무엇인가요? 간단하게 말하면 잡일 사용자의 요구 사항이에요.
우리는 구매 대행소에서 가져온 데이터를 View에 가져다 줘야 하는데,
View에서 데이터를 주문하기가 조금 더 간편하도록 사용자 관점으로 정리한 거라고 생각하면 돼요.
더 구체적으로 말하면, 쌀 판매하기 UseCase, 결제 대금 받기 UseCase, 환불하기 UseCase와 같이
서비스의 요구 사항에 따라 어떤 데이터를 가져오고, 이를 어떻게 가공할지에 관한 내용을 담고 있어요.
비유해서 말하면, 마트에도 상품이랑 계산대만 딸랑 있는 건 아니죠?
할인 코너도 있고, 일정 금액 이상 구매하면 집으로 무료 배송 해주는 서비스도 있죠.
신제품을 홍보하는 코너도 있구요.
사람들이 상품을 더 쉽게 구매할 수 있도록 마트에서 다양한 방법을 적용하는 것처럼,
데이터를 가져오고, 사용하기가 쉽도록 만들어 놓은 것이 UseCase에요.
그래서 앱의 기능이 간단하고 UseCase에서 구현해야 하는 내용이 별로 없으면 Domain 영역을 생략하기도 해요.
즉 Domain 영역은 필요에 따라 생략할 수도 있는 계층이에요.
Translater과 Model은 뭔가요?
우선 Model은 Entity처럼 데이터를 주고 받을 때 사용하는 물건이에요.
그럼 Entity를 그대로 쓰면 되지, 왜 따로 만들어 두었냐고요?
엄밀히 말하면 Entity와 Model은 목적이 조금 달라요.
조금 비유해서 말하자면, Model은 가게에서 판매하는 메뉴이고, Entity는 식자재 공장에서 생산하는 품목이에요.
가게의 메뉴판은 손님의 수요에 따라서 조금씩 바뀔 수 있죠?
메뉴에 들어가는 재료의 비율이 바뀌거나, 다른 재료가 들어간다거나요.
그치만 공장에서는 웬만해서는 생산 물품이 바뀌지 않죠?
그렇게 하려면 생산 라인을 바꿔야 하고, 인력도 새로 뽑아야 하고, 대대적인 리뉴얼을 해야 해요.
이처럼 Model은 View 영역에서 화면에 정보를 표시하기 위해 사용되고,
그렇기 때문에 약간씩 변화가 생길 수 있어요.
하지만 Entity는 서버 또는 사용자 기기와 통신하기 위해서 사용되기 때문에 어지간해서는 변하지 않아요.
(변경하려면 데이터베이스까지 다 변경해야겠죠?)
😶🌫️ 몰라도 되는 내용
아까 Entity 계층에 있는 클래스를 DTO라고 부르기도 한다고 했죠?
비슷하게 Model 계층에 있는 클래스를 VO라고 부르기도 해요. 쌀 VO, 벼 VO 이런 식으로요.
VO라는 건 Value Object의 약자로, 실제 값을 가지고 있는 물건이라는 뜻이에요.
그리고 Translator은 이름 그대로 번역기에요.
서버에서 전달 받은 데이터(Entity)의 형식을 뷰에서 사용하는 데이터(Model)의 형식으로 바꿔주는 거죠.
별로 어려울 거 없죠?
그리고 클린 아키텍쳐 삼형제 중에서 도메인이 했던 말 기억나나요?
Domain 계층은 서버나 Android에 의존하지 않고, 오로지 Java 코드로만 구성되어야 해요.
쉽게 말해 Data 계층이나 Android의 내용을 import하면 안된다는 얘기에요.
(build 파일에서 import하는 것도 마찬가지!)
왜냐하면 Domain 계층은 말 그대로 비즈니스를 담당하기 때문이에요.
책임을 완벽하게 분리하고 싶은 거죠.
책임을 분리한다는 말을 다른 말로 바꾸면,
우리가 작성한 코드가 Android가 아니라 web이나 iOS에서도 돌아가야 하고,
사용하는 서버가 다른 서버로 바뀌어도 작동해야 한다는 얘기에요.
그러기 위해선 도메인 계층에서 데이터 계층의 Class나 Android의 Library를 import하지 않아야 해요
근데 생각해보면 이건 말이 안돼요...
왜 그런지 Clean Architecture 그림을 보고 얘기해볼까요?
클린 아키텍쳐에서 데이터를 호출하는 과정을 다시 한 번 살펴보면,
Data 계층에서 서버나 사용자 기기와 통신해서 데이터를 가져오고,
그 데이터가 Repository를 거쳐 Domain 영역으로 넘어오는 형태였어요.
그럼 UseCase에서는 Repository 객체를 생성해서 그 객체의 메서드를 호출해야 하는데
Repository 클래스가 데이터 계층에 있잖아요.
그러면 당연히 데이터 계층의 클래스를 import 할 수 밖에 없지 않나요?
예를 들면 아래의 코드처럼요.
// domain 영역
package domain;
// data 영역에 의존하는 코드 (여기가 문제!)
import data.GmarketRepository;
public class BuyRiceUseCase {
public void buyRice() {
GmarketRepository gmarket = new GmarketRepository();
gmarkey.buyRice();
}
}
맞는 말이에요!
하지만 반만 맞는 말입니다.
왜냐고요? Clean Architecture 그림을 자세히 보세요.
Repository는 데이터 영역과 도메인 영역에 걸쳐 있잖아요.
말장난 하는 것 같겠지만 저렇게 표현된 이유가 있어요 😅
그 이유를 알려드릴게요.
아까 좋은 코드는 책임이 분리된 코드라고 했죠?
책임을 분리하는 여러 가지 방법이 있는데, 그 중에 유명한 것이 의존성 역전이에요
(Dependency Inversion라고 말하면 조금 더 똑똑해 보여요 😎)
지금부터 의존성이 뭔지, 그걸 왜 역전시키는지 얘기해볼 건데,
조~금 어려울 수 있으니까 천천히 읽어보세요!
우선 A가 B에 의존한다는 건 A를 만들려면 B가 필요하다는 의미에요.
예를 들어서 김치찌개를 끓이려면 김치, 두부, 돼지고기 같은 재료가 필요하죠?
이 경우에는 김치찌개가 김치, 두부, 돼지고기에 의존한다고 표현할 수 있어요
말이 익숙해질 때까지 연습을 조금만 해볼까요?
자동차는 프레임, 타이어, 엔진에 의존해요.
사람은 뼈, 장기, 혈액에 의존해요.
토트넘은 손흥민에 의존해요.
이런 식이에요.
조금 더 구체적으로 얘기해볼까요?
우리가 축구 게임을 개발하는 중에 토트넘이라는 팀을 만든다고 생각해봐요.
토트넘에는 손흥민, 해리 케인 같은 선수들이 있으니까 이 선수들을 속성으로 추가해볼게요.
그러면 아마 이런 식이겠죠?
public class Tottenham {
public Son son;
public Kane kane;
...
}
그치만 이런 식으로 코드를 짜면 토트넘에 있는 선수들이 변경될 수가 없어요.
클래스 내부의 속성 자체를 없애거나 추가할 수는 없으니까요.
그래서 보통은 Son이나 Kane같은 클래스를 묶어서 Player같은 인터페이스를 만들어요.
아래처럼 바뀌는 거죠.
public Tottenham {
public Player son;
public Player kane;
}
이게 바로 의존성 역전이에요.
엥? 뭔소린가 싶죠?
첫 번째 코드에서는 Tottenham 클래스가 Son, Kane과 같은 클래스에 의존했어요.
근데 이렇게 하면 속성 값을 바꿀 수 없으니까 Son, Kane과 같은 구체적인 클래스를 추상적인 클래스로 변경했죠
그래서 두 번째 코드에서는 Tottenham 클래스가 Player 클래스에 의존해요.
이렇게 구체적인 클래스(= 구체 클래스)가 아니라 추상적인 클래스(= 추상 클래스)에 의존하도록 하는 것이 의존성 역전이에요.
생각보다 간단하죠?
// domain 영역
package domain;
// data 영역에 의존하는 코드 (여기가 문제!)
import data.GmarketRepository;
public class BuyRiceUseCase {
public void buyRice() {
GmarketRepository gmarket = new GmarketRepository();
gmarkey.buyRice();
}
}
그러면 다시 Repository로 돌아와서,
아까는 BuyRiceUseCase 클래스가 GmarketRepository에 의존하고 있었어요.
근데 책임을 분리하려면 의존성을 역전시키라고 했죠?
즉, 구체 클래스가 아니라 추상 클래스를 사용하라고 했어요.
그러니까 GmarketRepository를 추상화한 OnlineMarketRepository를 만들고,
GmarketRepository는 이 인터페이스를 implement하게 만드는 거에요.
이때, OnlineMarketRepository(추상 클래스)는 domain 영역에,
GmarketRepository(구체 클래스)는 data 영역에 생성해요.
그러면 결론적으로 BuyRiceUseCase는 OnlineMarkeyRepository에 의존하니까
data 영역에 의존하지 않게 되는 거죠!
말이 조금 복잡한데, 아래의 코드를 보고 확인해볼까요?
// domain 영역
package domain;
// data 영역의 클래스를 import하지 않는다
import domain.OnlineMarketRepository;
public class BuyRiceUseCase {
public void buyRice() {
// GmarketRepository 대신 OnlineRepository를 사용한다
OnlineMarketRepository onlineMarket = new OnlineMarketRepository();
onlineMarket.buyRice();
}
}
우리가 바라던 대로 buyRice() 메서드가
Data 영역의 GmarketRepository가 아니라,
Domain 영역의 OnlineMarketRepository에 의존해요.
그치만 이것만으로는 완벽하지 않아요.
왜냐면 OnlineMarketRepository는 클래스가 아니라 인터페이스이기 때문에 객체를 생성할 수 없어요.
그래서 최종적으로는 아래의 코드처럼 바꿔야 해요.
package domain;
import domain.OnlineMarketRepository;
public class BuyRiceUseCase {
// OnlineMarketRepository 속성이 새로 추가됐고,
// 이 속성은 생성자를 통해 초기화된다
public OnlineMarketRepository onlineMarket;
// 생성자에서는 OnlineMarketRepository와 그 하위 타입을 대입할 수 있다
// 따라서 BuyRiceUseCase를 생성할 때 GmarketRepository를 대입하면 된다
public BuyRiceUsecase(OnlineMarketRepository onlineMarket) {
this.onlineMarket = onlineMarket;
}
public void buyRice() {
onlineMarket.buyRice();
}
}
이렇게 해서 data 영역과 domain 영역을 알아봤고,
domain 영역이 어떻게 해서 data 영역에 의존하지 않을 수 있는 지를 알아봤어요.
이제 슬슬 끝이 보이네요 ☺️
그럼 마지막으로 View 영역도 알아볼까요?
View 영역은 구조가 상대적으로 간단하죠?
저기서 View는 Activity, Fragment, View와 같이 화면을 구성하는 요소를 말해요.
그렇다면 Presenter는 무엇일까요?
Presenter는 View의 상태를 관리하는 요소에요.
말이 조금 어려운데, 쉽게 말하면 상태를 관리한다는 건 정보를 항상 업데이트해준다는 거에요.
예를 들면 새로운 일정이 생길 때마다 캘린더에 일정을 꼬박꼬박 추가하는 것처럼요.
생각보다 별 게 아니죠?
사실 View의 상태 관리라고 해봐야 사용자의 입력을 받거나 서버에서 데이터를 받아오는 것 뿐이라서
View 영역에서도 얼마든지 할 수 있어요.
그럼 굳이 상태를 관리하는 역할을 분리하는 이유는 뭘까요?
그게 편하니까요 😊
'그래봐야 얼마나 편하다고?' 싶을 수도 있겠지만,
Observer 패턴을 곁들이면 꽤 놀라운 일이 일어나요.
Observer 패턴은 이름 그대로 관찰자 패턴이에요.
예를 들어서 캘린더를 쓸때 매번 캘린더에 일정을 추가하는게 귀찮잖아요?
그럴 때 우리의 대화 내용을 듣고, 알아서 캘린더에 일정을 추가해주는 로봇이 있으면 편하겠죠?
그 로봇이 바로 Observer 패턴이에요.
관심 있는 대상(= Subject)을 계속 관찰하고, 바뀐 내용을 업데이트하는 거죠.
😶🌫️ 몰라도 되는 내용
무슨 이야기인지 이해는 되는데, 코드로 어떻게 구현해야 할지는 잘 모르겠죠?
아래처럼 구현되는데, 이해가 안 가면 그냥 넘어가도 괜찮아요.
어차피 나중에 다시 만나게 될 거에요 😙
// 관찰받는 대상 (= Subject)
public class People {
// 사람들이 일정을 기록할 달력
private Calendar calendar;
// 일정을 기록할 달력을 설정하는 메서드
public void observe(Calendar calendar) {
this.calendar = calendar
}
// 새로운 일정이 잡히면 호출되는 메서드
public void onScheduleSet(Schedule schedule) {
// 달력에 일정을 추가한다
calendar.add(schedule);
}
}
// 사람들이 이야기하는 것을 관찰하는 Observer
public class Calendar {
// 일정을 추가하는 메서드
// 사람들이 새로운 일정을 잡으면 호출된다
public add(Schedule schedule) { ... }
}
작동 방식을 간단히 설명하자면,
일정을 기록할 Calendar가 있어야겠죠?
그래서 People 클래스는 Calendar 속성과 그에 대한 setter인 observe() 메서드를 가져요
그리고 일정이 정해지면 사람들은 onScheduleSet() 메서드를 호출하고,
이에 따라 Calendar에 일정이 자동으로 등록돼요.
중요한 건 Calendar의 add() 메서드는
사람들이 직접 호출하는게 아니고,
일정이 잡히면 자동으로 호출된다는 거죠.
심지어 Android에는 Observer 패턴을 쉽게 사용할 수 있도록 Library도 준비해놨어요.
이 Library가 ViewModel과 LiveData에요.
이름이 쓸데없이 어려운 감이 있는데 😅
ViewModel은 View의 상태 관리를 전담하는 클래스에요.
쉽게 말하면 데이터 보관소인데, 데이터를 보관하는 상자가 LiveData에요.
조금 복잡한가요?
Observer 패턴에서 이야기하던 대로 말하면,
데이터를 가지고 있는 관심 대상(= Subject)이 LiveData이고,
그것을 관찰하는 쪽(= Observer)이 View에요.
결과적으로 LiveData의 값이 변하면 Observer 패턴이라는 로봇이 View를 자동으로 업데이트해주는 거죠.
드디어 Clean Architecture에 대한 설명이 끝났어요!
마지막으로 전체 그림을 보면서 정리해볼까요?
각 계층이 어떤 일을 하는지 가볍게 정리해봐요.
Entity는 서버에서 전송된 데이터입니다. 밭에서 생산된 쌀 같은 거라고 했죠?
DataStore은 이름 그대로 가게에요. 여러 가지 Entity를 가져오는 메서드를 포함해요.
Repository는 구매 대행소였죠? 구체적인 가게 이름을 몰라도 물건을 살 수 있었어요.
UseCase는 손님들이 물건을 찾기 쉽도록 도와주는 곳이었어요.
서비스의 요구 사항에 따라 데이터를 가져오고 가공해요.
마지막으로 View는 우리가 알듯이 화면을 구성하고,
Presenter는 Observer 패턴이라는 로봇을 써서 UseCase에서 넘어온 데이터가 알아서 View에 기록되도록 해주죠.
지금까지 Clean Architecture에 대해 알아봤어요.
제가 어렵다고 느꼈던 내용을 최대한 이해하기 쉽게 써보려고 했는데
돌이켜 보면 내용 자체가 어렵다기 보다는 낯설어서 어렵게 느껴졌던 것 같아요.
지금은 이야기가 조금 복잡하게 느껴지겠지만,
참을성을 가지고 몇 번만 읽어 보면 충분히 이해하실 수 있을 거에요 😉
그러니까 이해 안 간다고 좌절하지 마시고 천천히, 여러 번 읽어보시길 바래요!
마지막으로 제가 CleanArchitecture을 이해하는데 많은 도움을 준 글이에요.
특히 이 글은 코드 위주로 설명하고 있어서 저의 글로 흐름을 파악하고 나서 읽어보시면 도움이 많이 될 거에요!
[Android] Clean Architecture in Android
Clean Architecture란? 고객들에게 제공하는 애플리케이션 같은 경우에는 수많은 기능들이 있기에 복잡도가 굉장히 높습니다. 복잡도가 높은 애플리케이션을 개발할 때 어떻게 하면 유지 보수하기
leveloper.tistory.com
읽어주셔서 감사합니다 😄
'Android' 카테고리의 다른 글
Coroutine ② (0) | 2023.12.24 |
---|---|
Fragment (0) | 2023.12.09 |
Activity (1) | 2023.12.09 |
Retrofit (1) | 2023.12.05 |