저는 최근에 다른분이 하시던 Android 프로젝트를 이어서 하고있습니다.
코드를 깔끔하게 작성해주셔서 다양한 기능을 수월하게 추가하고 시도해보고 있는데요, 이번 글에서는 그 중 기존 단일 리스트에 파일 탐색, 즉 폴더 구조를 추가하게 된 과정을 이야기해보려합니다.

먼저 기본 구조는 왼쪽 같이 상단 앱바, 설명 텍스트, 리스트로 이루어진 일반적인 구조였습니다.
여기서 이제 PM님은 오른쪽 와이어프레임을 주시면서 아래와 같은 기능 변경을 이야기해주셨습니다.
1. 리스트에 폴더가 일반 파일과
동등하게 표시된다.
2. 폴더 선택시 해당 폴더 하위에 있는 파일이 보여진다.
3. 폴더 선택시 해당 폴더의 이름이 탑 앱바에 보여진다.
4. 폴더 선택시 설명 텍스트가 사라진다.
5. 파일(폴더, 일반파일)은 꾹 눌러 드래그하여 순서 조정이 가능하다.
6. 파일은 꾹 눌러 폴더안에 집어 넣을 수 있다.
7. 파일은 꾹 눌러 탑앱바에 닿게 하여 폴더 밖으로 빼낼 수 있다.
(이번 글에서 다룰 내용은 1~4입니다.)
PM님은 한 층으로 된 폴더 구조만 이야기해주셨지만, (폴더 안에는 일반 파일만 존재) 제 개인적으로는 일반적인 파일 탐색기와 같이 폴더 안에 폴더가 가능하다면 더 편리할 것 같다는 생각이 들고, 구현과정이 재미있을것 같아 추후 확장을 고려하여 폴더 안의 폴더가 가능하도록 구현하기로 하였습니다.
(폴더 안의 폴더를 이하 파일 탐색기라 부르겠습니다.)
일단 가장 먼저 서버에서 넘어오는 응답 형식을 BE와 함께 정하기로 하였습니다.
제가 파일 탐색기 형식을 생각하긴 하였지만, 이는 BE와 논의된 사항이 아니였기에 일단 조심스럽게 의견을 이야기하였습니다.
BE에서는 가능 할 것 같지만, 좀 더 조사가 필요하기도 하고 지금은 수정사항이 많아, 다음에 하는 게 맞겠다고 이야기해주셨습니다.
대신 추후 파일 탐색기 형식으로 확장이 가능하게 아래와 같은 형식으로 JSON 응답을 작성해주셨습니다.
{
"files": [
{
"fileId": 1,
"title": "asdf",
"order": 1,
"folderId": 1
},
{
"fileId": 2,
"title": "example.txt",
"order": 2,
"folderId": 1
}
],
"folders": [
{
"folderId": 1,
"order": 2,
"title": "Project Files"
}
]
}
위와 같이 트리 구조의 JSON 응답이 만들어졌습니다.

만약 파일 탐색기 형식이 가능해진다면 폴더 정보에 parentFolderId를 같이 보내 어떤 폴더에 속하는지 알려주면 구현이 가능해집니다.
일단 가장 먼저 DTO를 변경된 JSON에 맞춰 수정하고, 실제로 사용할 Entity를 만들어주었습니다.
// domain/entity/FileEntity.kt
class FileEntity {
@Serializable
open class Document(
@SerialName("documentId")
val documentId: Int,
@SerialName("title")
val title: String,
@SerialName("order")
val order: Int,
val parentId: Int
): java.io.Serializable {
//override 코드
}
@Serializable
data class Folder(
val folderTitle: String,
val folderId: Int,
val folderOrder: Int,
val documents: List<Document>,
val parentFolderId: Int
) : Document(folderId, folderTitle, folderOrder, parentFolderId), java.io.Serializable
}
구조가 조금 복잡한데 그 이유는 코틀린의 List는 한가지의 타입만 들어갈 수 있기 때문입니다.
그렇기에 지금과 같이 폴더, 일반 파일 타입을 동시에 표현해야하는 경우 두 가지의 타입이 다른 하나의 인터페이스를 상속받게하고 그 인터페이스 타입으로 List를 만들어서 다형성으로 데이터를 집어넣는 형식을 택합니다.
하지만 이렇게 하게 되면 파일은 파일이고, 폴더는 폴더이게 됩니다. 즉, 파일에 적용되는 작업은 폴더에 처리할 수 없게 됩니다.
저희가 원하는 것은 폴더 또한 기존의 파일과 동일하게 처리하는 것이기에 컴포지트 패턴(복합체 패턴)과 비슷하게 구현하였습니다.
그림으로 보면 아래와 같은 거죠!

이렇게 구현함으로서 폴더와 파일을 동등하게 처리할 수 있게되었습니다.
다음으로는 DTO를 Entity로 바꾸는 Mapper을 작성하였습니다.
이 작업은 꽤나 어려운 과정에 속합니다. 왜냐면 JSON이 구조화했을때는 트리구조이지만 실제로는 파일과 폴더가 별도로 주어지기에 이를 트리구조로 바꿔야하기 때문입니다.
또한 각 파일의 order가 존재하기에 이 order또한 신경쓰며 작업해야합니다.
저는 아래와 같이 작업하였습니다.
보면 파일은 반드시 특정 폴더 아래에 존재하게 되고, 이는 폴더가 어떤 구조를 이루던 변하지 않는 사항입니다.
그렇기에 가장 먼저 각 폴더에 파일을 저장하는 작업을 하였습니다.
// mapper/FileEntityMapper.kt val fileMap = mutableMapOf<Int, MutableMap<Int,FileEntity.Document>>() for(item in dto.files){ if(fileMap[item.folderId] == null){ fileMap[item.folderId] = mutableMapOf() } fileMap[item.folderId]? .set( item.order, (FileEntity.Document( item.fileId, item.title, item.order, item.folderId )) ) }
폴더 id를 key로 맵을 만들고 각 파일을 저장해주었습니다.
여기까지 하면 모든 파일이 폴더에 order순으로 저장됩니다.
다음으로는 폴더간 관계 그래프를 만들어주었습니다.
폴더또한 일반 파일과 같이 취급되고 정렬순서가 있기에 만약 하위 폴더의 아이템이 다 지정되기 이전에 해당폴더를 다른 폴더에 집어넣게 되면 순서가 맞지 않는 문제가 생깁니다.

그냥 집어넣고 정렬을 하려해도 기본적으로 파일과 폴더들은 불변 객체이기에 아예 객체를 새로 만들어야하고, 새로 만들어진 객체를 상위 폴더에 집어넣어야하며, 거기서 또 정렬이 깨져 다시 정렬해야하는 식으로 변경이 전파됩니다.
이를 방지하기 위해 가장 깊은(하위 폴더가 없는)폴더 부터 상위폴더로 점점 합쳐나가야했는데, 저는 이를 위해 위상정렬을 수행하기로 하였습니다.
그 과정의 첫번째로 폴더간 차수계산과 그래프 생성을 해주었습니다.
val folderCount = mutableMapOf<Int, Int>() val folderMap= mutableMapOf<Int,GetFileListResponseDto.Folder>() for(item in dto.folders){ if(folderCount[item.parentFolderId] == null){ folderCount[item.parentFolderId] = 0 } if(folderCount[item.folderId] == null){ folderCount[item.folderId] = 0 } folderCount[item.parentFolderId] = folderCount[item.parentFolderId]!!.plus(1) folderMap[item.folderId] = item }
여기까지하면 각 폴더의 하위 폴더 갯수 카운트와 폴더 id로 객체를 찾을 수 있도록 매칭이 됩니다.
이후 만들어진 그래프를 바탕으로 위상정렬을 수행하였습니다.
val queue: Queue<Int> = LinkedList() for(item in dto.folders){ if (folderCount[item.folderId]!! == 0) queue.add(item.folderId) } while(queue.isNotEmpty()){ val frontItem = queue.poll()!! if(frontItem == 0) break val item = folderMap[frontItem]!! fileMap[item.parentFolderId]?.set(item.order, FileEntity.Folder( folderTitle = item.title, folderId = item.folderId, folderOrder = item.order, documents = fileMap[item.folderId]?.toList()?.sortedBy { it.first }?.map { it.second }?: listOf(), parentFolderId = item.parentFolderId )) folderCount[item.parentFolderId] = folderCount[item.parentFolderId]!!.minus(1) if (folderCount[item.parentFolderId]!! == 0) queue.add(item.parentFolderId) }
정렬을 수행하여 큐에서 나오게된 폴더는 하위 폴더들의 정렬및 파일로서의 삽입이 완료된 상태기에 map을 리스트로 바꿔 폴더를 생성한 뒤 상위 폴더에 파일로서 집어넣어줬습니다.
이제 최종적으로 Root_num 즉 0번 폴더에 모든 파일 및 폴더가 저장되게 됩니다.
return fileMap[ROOT_NUM]?.toList()?.sortedBy { it.first }?.map { it.second }?: listOf()
이제 Entity를 화면에 표시할 일만 남았습니다.
이 구현은 첫번째로 네비게이션을 활용하여 실제로 화면이 쌓이도록 만드는 방식을 고민해보았습니다.
하지만 네비게이션의 경우 직접적인 호출이 아닌 최초 네비게이션 생성시에 만들어둔 형태로 매개변수 그리고 navArgs로만 정보를 보낼 수 있기에 함수를 보내야하는 지금 상태에선 활용하기가 어려웠습니다.
그렇기에 두번째 방식으로 리스트 컴포넌트 안에서 폴더가 선택되었다면 폴더 안의 파일들로 새로운 리스트 컴포넌트를 호출하는 재귀적 호출 방법을 떠올렸습니다.
먼저 List 컴포넌트에 폴더가 선택되었는지 여부를 나타내는 focusedItem 상태를 추가했습니다.
var focusedItem by rememberSaveable { mutableStateOf<FileEntity.Folder?>(null) }
다음으로는 클릭이벤트를 처리했습니다. 원래 기존 구조에서 item의 onClick은 List컴포넌트의 매개변수로 외부에서 넘겨주는 함수(onFileClick: (Int) -> Unit)를 그대로 호출하였습니다.
이를 랩핑하여 만약 클릭한 아이템이 폴더라면 기존 onFileClick를 호출하는대신 focusedItem에 등록하도록 하여 수정을 최소화 하였습니다.
onFileClick = { if (file is FileEntity.Folder) { focusedItem = file } else { onFileClick(file.documentId) } },
그리고 focusItem이 설정되면 다시 재귀적으로 List컴포넌트가 보여지도록 하였습니다.
if(focusedItem!=null) { FileList( fileList = focusedItem!!.documents, onFileClick = onFileClick, loadMoreFile = loadMoreFile, ) }
이렇게 구현함으로서 폴더 이동로 들어가는 것이 완료 되었습니다.
뒤로가기의 경우 만약 focusItem이 있다면 뒤로가기를 가로채 focusItem을 제거하도록 하여 구현하였습니다.
BackHandler(focusedItem!=null) { focusedItem = null }
폴더 구조의 구현 및 이동이 완성되었습니다.

(dex를 사용해 촬영하여 조금 깨져보입니다)
자... List컴포넌트는 해결되었는데.. 이제 폴더에 진입하였을때 탑앱바를 변경해주어야합니다.
단순하게 새로운 폴더에 들어갔을때 이름을 바꾸고 설명텍스트를 제거하는 것이라면 onFileClick과 같이 변경하는 함수를 넘기면 됩니다.
하지만 뒤로 이동하여 상위 폴더로 이동하여도 이름을 바꿔야합니다.
이를 위해 저는 재귀적으로 List컴포넌트를 호출할때 기존 타이틀 변경을 함수를 감싸 현재 깊이의 폴더명으로 바꿀 수 있도록 구현하였습니다.
이렇게 말로만 적으면 어려운데 코드를 봐보겠습니다.
가장 처음의 List컴포넌트를 호출하는 깊이 0의 타이틀 변경함수는 아래와 같습니다.
onChangeSelectFolder = { folderTitle = it },
말 그대로 앱바의 타이틀을 변경합니다.
그리고 재귀적으로 호출하는 List컴포넌트의 타이틀 변경 함수는 아래와 같이 구현했습니다.
onChangeSelectFolder = { if (it == null) { onChangeSelectFolder(focusedItem?.title) } else { onChangeSelectFolder(it) } },
기존 타이틀 변경 함수를 감싸게 됩니다.
이렇게 구현하게 되면 가장 깊은 List컴포넌트에서 타이틀 변경 함수를 호출했을때 감싼 함수를 벗겨내며 올라가게 되고, 새로운 폴더로 진입한다면 onFileClick과 같이 호출을,
상위 폴더로 나가게 된다면 최초로 focustItem이 있는(다음으로 깊은) List컴포넌트의 폴더명을 찾아 그 타이틀로onFileClick과 같이 호출을 하게 됩니다.
좀 더 자세히 예와 함께 보자면 아래와 같습니다.
- 깊이 2인 리스트에서 새로운 폴더를 눌렀다.
onClick에서 변경 함수에새로운 타이틀을 넘긴다.- 이를 받는 것은 깊이 1에서 만든 변경 함수이다.
새로운 타이틀은 null이 아니기에 그대로 변경 함수에 넘긴다.- 이를 받는 것은 깊이 0에서 만든 변경 함수이다. 깊이 0에서 만든 최초의 변경 함수는 앱바의 타이틀을 변경하는 것이기에 앱바의 타이틀을
새로운 타이틀으로 변경한다.
-> 깊이 3인 리스트가 보여지고 그 타이틀로 앱바 이름이 바뀐다.
- 깊이 3인 리스트에서 뒤로가기를 눌렀다.
- 이 뒤로가기 이벤트는
focusItem이 있으면서 가장 앞에 표시되는(화면상) 깊이 2인 리스트가 받는다.(BackHandler)- 뒤로가기를 눌렀기에 변경함수에
null을 넘기고focusItem을 제거한다.- 이를 받는 것은 깊이 1에서 만든 변경 함수이다.
null이 넘어왔기에 자신이 가진focusItem의 타이틀, 즉 깊이 2 리스트의 타이틀을 변경 함수에 넘긴다.- 이를 받는 것은 깊이 0에서 만든 변경 함수이다. 깊이 0에서 만든 최초의 변경 함수는 앱바의 타이틀을 변경하는 것이기에 앱바의 타이틀을
깊이 2 리스트의 타이틀으로 변경한다.
-> 깊이 2인 리스트가 보여지고 그 타이틀로 앱바 이름이 바뀐다.
구상이 조금 복잡하였지만, 아래와 같은 이쁜 결과물을 얻을 수 있었습니다.

재귀적인 컴포넌트 호출이 처음이다보니 함수를 깊은 곳까지 전달하고 이벤트를 위로 끌어오는게 복잡했던 것 같습니다.
하지만 재귀 호출을 이용한 함수형 프로그래밍에 대해 조금 더 알게 된 계기인 것 같아 뿌듯하네요!