Tip
우측 상단의 버튼으로 목차를 확인할 수 있습니다.
| 항목 | 내용 |
|---|---|
| 🕒 기간 | 2024-10 ~ 2024-11 |
| 👥 인원 | 6명 |
| 🛠 사용 기술 | |
| 🎯 담당 역할 | 1) 이모티콘 검색 기능 개발 (안드로이드) 2) 이모티콘 팩 상세 화면 개발 (안드로이드) 3) 이모티콘 구매 및 다운로드 기능 개발 (안드로이드) |
| 📖 개요 | 자유로운 이모티콘 공유 및 사용 플랫폼 (안드로이드 모바일 앱, 웹) |
- 오픈티콘은 사용자가 직접 제작한 이모티콘을 자유롭게 등록하고, 다양한 플랫폼에서 손쉽게 사용할 수 있도록 돕는 서비스입니다.
- 이모티콘 시장의 빠른 성장과 사용자들의 다양한 니즈를 반영하여, 누구나 쉽게 이모티콘 작가로 데뷔하고, 이모티콘을 다양한 용도로 활용할 수 있는 환경을 제공하는것을 목표로 합니다.
| 이모티콘 스토어 메인 | 이모티콘 팩 상세 화면 | 이모티콘 검색 |
|---|---|---|
![]() |
![]() |
![]() |
- 이모티콘 스토어에서 신규, 인기, 태그별 이모티콘을 탐색할 수 있습니다.
- 이모티콘 팩 상세 화면에서는 이모티콘 미리보기를 제공하며 구매 및 다운로드를 할 수 있습니다.
- 이모티콘 검색 화면에서 텍스트로 이모티콘을 검색할 수 있습니다.
| 플로팅 아이콘 클릭시 | 이모티콘 클릭시 | 붙여넣기로 전송 |
|---|---|---|
![]() |
![]() |
![]() |
- 스토어 앱에서 "이모티콘 서랍" 버튼으로 플로팅 아이콘을 켜고 끌 수 있습니다.
- 플로팅 아이콘을 통해 다른 앱 위에서 사용할 수 있습니다.
- 플로팅 아이콘을 클릭하면 내가 다운로드한 이모티콘들이 플로팅 윈도우로 표시됩니다.
- 원하는 이모티콘을 클릭하면 클립보드에 이미지가 복사됩니다.
- 이미지 붙여넣기를 지원하는 어떤 곳이든 (카카오톡, 디스코드, Mattermost 등) 붙여넣기로 편리하게 전송할 수 있습니다.
| 이미지로 이모티콘 검색 | 소셜 로그인 | 구매한 이모티콘 관리 |
|---|---|---|
![]() |
![]() |
![]() |
- 텍스트 뿐만 아니라 이미지로도 유사한 이모티콘을 검색할 수 있습니다.
- 소셜 로그인을 지원합니다.
- 내 이모티콘 관리에서 순서 및 표시 여부를 편집할 수 있습니다.
- 웹 이모티콘 스토어는 주로 이모티콘 작가를 위한 서비스 입니다.
- 작가는 자신이 만든 이모티콘(이미지)을 팩으로 업로드할 수 있습니다.
- AI 기능을 이용해 이모티콘을 제작하는 이모티콘 스튜디오를 제공합니다.
- 웹에서도 이모티콘을 탐색할 수 있습니다.
본 프로젝트에서는 안드로이드 코드 전반에 MVVM 패턴을 적용하였습니다.
- MVVM 패턴은 다음 세 가지 주요 컴포넌트로 구성됩니다.
- Model (모델): 데이터 및 비즈니스 로직을 담당하는 계층
- View (뷰): UI를 담당하며 사용자의 입력을 처리하는 계층
- ViewModel (뷰모델): Model과 View 사이에서 데이터를 관리하고 UI 로직을 처리하는 계층
- MVVM 패턴의 장점은 다음과 같습니다.
- UI와 비즈니스 로직 분리 → ViewModel이 UI 로직을 담당하므로 UI 코드가 깔끔해짐
- 생명 주기 관리 → ViewModel은 Activity/Fragment의 생명 주기와 무관하게 데이터를 유지
- 비동기 처리 최적화 → Coroutine을 활용하여 백그라운드에서 데이터 로딩 가능
이모티콘 검색 화면의 예를 통해 프로젝트에서 MVVM 패턴을 적용 방식을 설명하겠습니다.
SearchScreenViewModel은 검색 화면에서 UI 상태를 관리하며, StateFlow를 이용해 Compose UI와 데이터 바인딩을 수행합니다.
@HiltViewModel
class SearchScreenViewModel @Inject constructor(
private val searchEmoticonPacksUseCase: SearchEmoticonPacksUseCase,
private val searchEmoticonPacksByImageUseCase: SearchEmoticonPacksByImageUseCase
) : ViewModel() {
private val _searchText = MutableStateFlow("")
private val _searchResult = MutableStateFlow(emptyList<SearchEmoticonPacksListItem>())
private val _isLoading = MutableStateFlow(false)
private val _selectedImageUri = MutableStateFlow<Uri?>(null)
val searchText: StateFlow<String> = _searchText
val searchResult: StateFlow<List<SearchEmoticonPacksListItem>> = _searchResult
val isLoading: StateFlow<Boolean> = _isLoading
val selectedImageUri: StateFlow<Uri?> = _selectedImageUri
fun onSearchTextChange(value: String) {
_searchText.value = value
}
fun search() {
viewModelScope.launch {
_isLoading.value = true
val result = searchEmoticonPacksUseCase(_searchText.value)
_searchResult.value = result
_isLoading.value = false
}
}
}MutableStateFlow를 사용하여 UI 상태를 관리함onSearchTextChange()를 통해 사용자의 입력을 반영search()메서드에서 비동기 작업을 수행하여 검색 결과를 가져오고 UI 상태를 업데이트viewModelScope.launch {}블록을 사용하여 코루틴 실행 (백그라운드 작업 수행)
SearchBar Composable 함수에서는 ViewModel을 hiltViewModel()로 주입받아 UI와 상태를 연결합니다.
@Composable
fun SearchBar(
viewModel: SearchScreenViewModel = hiltViewModel()
) {
val searchText by viewModel.searchText.collectAsState()
val context = LocalContext.current
Row(modifier = Modifier.fillMaxWidth()) {
BasicTextField(
value = searchText,
onValueChange = { viewModel.onSearchTextChange(it) },
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = {
viewModel.search()
}
)
)
}
}viewModel.searchText.collectAsState()를 사용하여 UI가 ViewModel의 상태를 관찰- 사용자가 텍스트를 입력하면
viewModel.onSearchTextChange()가 호출되어 상태를 업데이트 imeAction = ImeAction.Search를 설정하여 엔터 키 입력 시viewModel.search()가 호출되도록 구성
ViewModel 내부에서 suspend fun을 사용하여 네트워크 요청을 비동기적으로 처리합니다. 이는 UI 스레드를 차단하지 않고 데이터를 가져올 수 있도록 합니다.
suspend fun loadMoreSearchResult() {
if (_isLoading.value) return
_isLoading.value = true
val (newItems, isLast) = searchEmoticonPacksUseCase(
searchKey = _searchKey.value.key,
searchText = _searchText.value,
page = page,
size = pageSize,
sort = _searchSort.value.key
)
_searchResult.value += newItems
lastPageReached = isLast
page++
_isLoading.value = false
}suspend함수로 작성하여 비동기 실행을 최적화_isLoading상태를 사용하여 중복 요청을 방지searchEmoticonPacksUseCase()를 호출하여 검색 API 요청 수행 후_searchResult상태 업데이트
Jetpack Compose 기반의 MVVM 구조를 적용함으로써 코드의 가독성과 유지보수성 향상 및 비동기 데이터 처리 최적화를 달성할 수 있었습니다.
특히 StateFlow와 Hilt를 활용해 ViewModel을 구성하여 MVVM 패턴을 더욱 효과적으로 적용할 수 있었습니다.
본 프로젝트에서는 안드로이드 코드 전반에 클린 아키텍처를 적용하고자 노력하였습니다.
클린 아키텍처를 적용하면 유지보수성과 확장성을 높일 수 있습니다.
클린 아키텍처는 Data, Domain, UI (Presentation) 레이어를 분리하여 각 계층이 명확한 역할을 가지도록 구성하는 것이 핵심입니다.
이모티콘 다운로드 기능의 예를 통해 프로젝트에서 클린 아키텍처를 적용한 방식을 설명하겠습니다.
클린 아키텍처는 의존성 규칙(Dependency Rule) 을 따릅니다. 즉, 안쪽 레이어는 바깥 레이어에 대해 알지 못하지만, 바깥 레이어는 안쪽 레이어를 의존해야 합니다. 이를 반영하여 프로젝트를 아래와 같이 3개의 계층으로 구성하였습니다.
핵심 비즈니스 로직과 규칙을 담당하는 계층으로, 앱의 비즈니스 로직이 다른 요소들과 독립적으로 동작할 수 있도록 설계되었습니다.
이 계층은 플랫폼(Android), 라이브러리, UI, 데이터 소스(API, DB 등)와 완전히 분리되어 있어야 합니다.
구성 요소
- UseCase: 특정 비즈니스 로직을 캡슐화하여 UI에서 호출할 수 있도록 함.
- Repository Interface: Data Layer와 UI Layer 간의 중재 역할을 하는 인터페이스.
적용 예시
class DownloadEmoticonPackUseCase @Inject constructor(
private val repository: EmoticonPackRepository
) {
suspend operator fun invoke(idx: Int, packId: Int, url: String): Result<Unit> {
try {
repository.downloadAndSaveEmoticonPack(idx, packId, url)
return Result.success(Unit)
} catch (e: Exception) {
e.printStackTrace()
return Result.failure(e)
}
}
}특징
DownloadEmoticonPackUseCase는EmoticonPackRepository인터페이스를 통해 데이터를 가져오므로, Data Layer의 구체적인 구현을 몰라도 됨.- UI에서 비즈니스 로직을 직접 수행하지 않고 UseCase를 호출하도록 설계하여 역할을 명확히 분리함.
데이터를 관리하는 계층으로, API, 데이터베이스, 로컬 저장소와 통신하여 데이터를 가져오고 저장하는 역할을 합니다.
이 계층에서는 Domain Layer에서 정의한 Repository 인터페이스를 구현하여, 데이터를 실제로 가져오는 로직을 처리합니다.
구성 요소
- Repository Implementation: Repository 인터페이스를 구현하여 데이터 처리 로직을 담당.
- Data Source (Local, Remote): API 요청 또는 데이터베이스 접근을 수행.
💡 적용 예시
class EmoticonPackRepositoryImpl @Inject constructor(
private val emoticonPacksApi: EmoticonPacksApi,
private val emoticonDao: EmoticonDao,
@ApplicationContext private val context: Context
) : EmoticonPackRepository {
override suspend fun downloadAndSaveEmoticonPack(idx: Int, packId: Int, url: String) {
val fileName = "emoticon_$idx.${url.substringAfterLast(".")}"
val filePath = downloadAndSaveEmoticonFile(url, packId, fileName)
if (filePath != null) {
val emoticon = Emoticon(
id = "$packId-$idx",
packId = packId,
filePath = filePath
)
emoticonDao.insertEmoticon(emoticon)
} else {
throw Exception("Failed to download emoticon from $url")
}
}
}특징
EmoticonPackRepositoryImpl이EmoticonPackRepository인터페이스를 구현하여 Domain Layer와 Data Layer를 분리.downloadAndSaveEmoticonPack에서 네트워크 요청과 로컬 파일 저장을 처리함.- Room Database(
EmoticonDao)를 사용하여 다운로드된 이모티콘을 로컬에 저장함.
사용자 인터페이스와 관련된 계층으로, ViewModel을 통해 UI 상태를 관리하고, UI 이벤트를 처리합니다.
이 계층에서는 UseCase를 호출하여 데이터를 가져오고 UI에서 사용할 형태로 가공합니다.
구성 요소
- ViewModel: UI 상태 관리 및 UseCase 호출.
- State Management (StateFlow, LiveData): UI 상태를 저장하고 변경 사항을 감지하여 UI를 업데이트.
적용 예시
@HiltViewModel
class EmoticonPackDetailScreenViewModel @Inject constructor(
private val downloadEmoticonPackUseCase: DownloadEmoticonPackUseCase
) : ViewModel() {
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
fun downloadEmoticonPack(packId: Int, uuid: String) {
viewModelScope.launch {
_isDownloading.value = true
for ((index, url) in getDownloadPackInfoUseCase(uuid).emoticonUrls.withIndex()) {
val result = downloadEmoticonPackUseCase(index, packId, url)
result.onFailure {
_isDownloading.value = false
return@launch
}
}
_isDownloading.value = false
}
}
}특징
downloadEmoticonPack을 호출하면DownloadEmoticonPackUseCase를 통해 데이터를 가져와 UI에 반영.StateFlow를 사용하여 UI 상태 변화를 감지.- UI 로직을 분리하여 ViewModel이 비즈니스 로직을 담당하고, UI(View)는 상태 변화만 감지하도록 설계됨.
- 유지보수 용이
- 각 계층이 명확히 분리되어 있어, 특정 로직을 변경해도 다른 레이어에 영향을 주지 않음.
- 테스트 용이
Domain Layer가UseCase를 중심으로 독립적이므로, 단위 테스트가 쉬워짐.- Repository 인터페이스를 사용하여 Mocking을 통해 테스트할 수 있음.
- 확장성 높음
- UI가 변경되더라도 (예: XML → Jetpack Compose) Domain Layer는 그대로 유지 가능.
- API 변경 시에도 Data Layer만 수정하면 되므로, 다른 계층에는 영향을 주지 않음.
이번 프로젝트에서 클린 아키텍처를 적용하면서 유지보수성, 확장성, 테스트 용이성을 높일 수 있었습니다.
각 계층의 역할을 명확하게 나누고, 의존성 규칙(Dependency Rule) 을 지켜 구현한 덕분에 코드의 가독성이 좋아지고 변경에 유연하게 대응할 수 있었습니다.
클린 아키텍처를 처음 도입할 때 가장 어려웠던 점은 의존성 규칙(Dependency Rule)을 올바르게 이해하는 것이었습니다.
처음에는 Domain Layer가 Data Layer에 직접 의존하도록 설계했습니다. 예를 들어, Data Layer에서 사용하는 DTO를 Domain Layer에서 사용하는 모델로 변환하는 메서드를 Domain Layer 안에서 fromXXX 형태로 구현했고, UI에서도 Data Layer의 Repository를 직접 호출하도록 작성했습니다. 이 방식은 코드가 일관성 있고 가독성이 높아 보였지만, 클린 아키텍처의 핵심 원칙인 **“안쪽 계층은 바깥 계층을 몰라야 한다”**는 규칙을 위반하고 있었습니다.
// Domain Layer 내부 (잘못된 예시)
fun fromPackDownloadResponseDto(dto: PackDownloadResponseDto): EmoticonPack {
return EmoticonPack(
thumbnail = dto.thumbnailImg,
listImg = dto.listImg,
emoticonUrls = dto.emoticons,
id = dto.id,
title = dto.title,
isPublic = dto.isPublic,
uuid = dto.uuid
)
}위와 같이 Data Layer의 DTO를 Domain Layer에서 직접 변환하는 방식은 Domain Layer가 Data Layer를 알고 있다는 뜻이므로 잘못된 설계였습니다.
하지만, 코드를 일관성 있게 작성하고 가독성을 유지하는 데 신경을 썼던 덕분에, 중간에 올바른 클린 아키텍처 설계를 깨닫고도 수정을 어렵지 않게 진행할 수 있었습니다.
결국 DTO 변환 로직을 Data Layer 내부로 이동하고, Domain Layer에서는 순수한 비즈니스 로직만 유지하는 방식으로 개선했습니다.
package io.ssafy.openticon.data.model
import io.ssafy.openticon.domain.model.EmoticonPack
data class PackDownloadResponseDto(
val thumbnailImg: String,
val listImg: String,
val emoticons: List<String>,
val id: Int,
val title: String,
val isPublic: Boolean,
val uuid: String
) {
fun toEmoticonPack(): EmoticonPack {
return EmoticonPack(
thumbnail = thumbnailImg,
listImg = listImg,
emoticonUrls = emoticons,
id = id,
title = title,
isPublic = isPublic,
uuid = uuid
)
}
}이제 Domain Layer는 DTO를 알 필요가 없어졌고, Data Layer에서 변환한 Domain 객체만 사용하도록 변경되었습니다.
- 의존성 방향을 항상 고려해야 한다 → 바깥 계층(Data, UI)은 안쪽 계층(Domain)에 의존해야 하지만, 반대로 Domain이 바깥 계층을 알면 안 된다.
- 잘못된 구조라도 코드가 일관성 있고 깔끔하면 수정이 쉽다 → 아키텍처를 잘못 적용했더라도, 명확한 패턴을 유지하면 리팩토링이 용이하다.
- DTO 변환 로직은 Data Layer에서 담당해야 한다 → Domain Layer는 순수한 비즈니스 로직만 포함하도록 설계해야 한다.
이 경험을 통해 클린 아키텍처를 단순히 개념적으로 이해하는 것뿐만 아니라, 실제 코드에 적용하면서 설계 원칙을 지키는 것의 중요성을 배울 수 있었습니다.












