본문 바로가기

MoneyMong

Markdown 을 파싱해서 compose 로 그린 이야기

이건 아니야...

프로젝트에서 마이페이지에 있는 개인정보처리 방침과 이용약관을 구현하려고 피그마를 킨

난 아래의 사진과 같이 수백 줄이 넘고 단순 Text 가 아닌 Bold, 번호 목록,  등 다양한 요소들이 들어있는 화면을 목격했다...

이걸 모두 Compose 로 일일이 구현하는 건 팀 리소스를 많이 잡아먹고 좋은 방법이 아니라 생각했다.

 

 

그래서 PM 님에게 "노션에 작성하고 웹뷰로 보여주면 어떨까요?"라고 제안을 드렸고

PM 님도 일일이 구현하는 건 좋지 않다고 판단하셔서 흔쾌히 승낙하셨다.
구현 후 appetize 로 완성된 UI 를 보여드렸고 완벽하다고 확인받았다.

 

그러고 추후 정기 회의 도중

 

"희직 님... 저희 이용약관 웹뷰로 구현한 게 너무 신경 쓰여요.. 리젝 당할 수도 있을 것 같은데..."

개인적으론 리젝 안 날 것 같았지만, PM 님이 조금이라도 걱정하고 바꿀 수 있다면 바꾸는 게 맞다 생각했다.


"우리 PM 님이 원하시면 바꿔야죠! 바꾸고 다시 DM 드릴게요."

라고 말씀드리고 고민을 시작했다.

 

고민의 시작

생각은 아래 순서로 진행됐다.

  1. String
  2. Markdown - 라이브러리
  3. Markdown - 직접 구현

 

1. 그냥 Text 하나에 모든 String 을 넣자.

생각하고 바로 Text에 넣어보고 실행시켜 보고 코드를 모두 지워버렸다...🙃

일단 내 성향 자체가 꽤나 완벽주의이고, 개발 과정에서 지고 타협하는 걸 되도록이면 지양한다.

String 으로 그냥 Text 에 넣어버리면 그게 줄글이지... 그건 디자인한 사람에게 예의가 아니다

 

 

2. Markdown 을 토대로 라이브러리를 써서 보여줘 볼까?

노션의 내용은 html 과 markdown 으로 export 할 수 있다.

html 보단 더 단순한 markdown 을 파싱 하면 잘 그릴 수 있겠다 생각했고 바로 검색했다.

"how to show markdown in jetpack compose"

 

 

여러 라이브러리가 나오고 살펴보던 중 
https://github.com/jeziellago/compose-markdown

 

GitHub - jeziellago/compose-markdown: Markdown Text for Android Jetpack Compose 📋.

Markdown Text for Android Jetpack Compose 📋. Contribute to jeziellago/compose-markdown development by creating an account on GitHub.

github.com

 

해당 라이브러리가 괜찮다고 판단했지만 디자인을 만족하지 못하는 게 아쉬웠다.

 

3.  내가 직접 구현해 볼까?

그럼 직접 해보자. 그렇게 어려워 보이지 않잖아?

물론 하다가 시간이 많이 소비된다고 파악되면 바로 엎을 예정이었다.

내 구현 욕심보단 팀이 먼저기에

 

요구사항은 아래와 같다.

  • 노션에 있는 개인정보 처리 방침, 서비스 이용약관과 최대한 동일하게 화면에 표시한다.
  • 표는 디자인과 일치시킨다.

 

먼저 마크다운의 모든 문법을 파싱 할 필요는 없다.

우리 프로젝트에서 사용되는 문법들만 분석하고 파싱 하자.

 

다음과 같다.

  1. 글자 크기를 크게 하는 Heading

        ex). 나는 Heading

   2. 불릿으로 목록을 나타내는 BulletPoint

        ex).

  • 목록 1
  • 목록 2
  • 목록 3

   3. 표를 나타내는 Table

         ex).

title title title
content content content
content content content

 

딱 이렇게 3개다. 할만하잖아?

 

 

구현은 이렇게

파일은 3개로 나뉜다.

  • MyMongMarkdownView: Markdown 이 차지하는 화면을 책임진다. 
  • MyMongMarkdownText: 문법에 맞는 Text 를 그려준다.
  • MyMongMarkdownTable: 표를 그린다.

 

MyMongMarkdownView

@Composable
fun MyMongMarkdownView(
    modifier: Modifier = Modifier,
    markdownText: String,
    textColor: Color = Gray10
) {
    var isTable = false
    val tableLines = LinkedList<String>()

    Column(modifier = modifier) {
        val lines = markdownText.split("\n")

        lines.forEach { line ->
            if (line.startsWith(prefix = tableSymbol)) {
                isTable = true
                tableLines.add(line)
            } else {
                if (isTable) {
                    isTable = false
                    MyMongMarkdownTable(lines = tableLines)
                    tableLines.clear()
                }
                MyMongMarkdownText(
                    line = line,
                    textColor = textColor
                )
            }
        }
        if (isTable) {
            MyMongMarkdownTable(lines = tableLines)
        }
    }
}

 

  1. 텍스트를 "\n" 으로 split() 해서 라인을 분류한다.
  2. 라인들의 첫번째 문자를 확인해서 Table(표) 을 그릴 지 Text 를 그릴 지 판단한다.

노션의 표를 Makrdown 으로  export 하면 아래와 같다.

| 수탁자 | 위탁 업무 내용 | 개인정보의 보유 및 이용기간 |
| --- | --- | --- |
| 네이버 클라우드 | OCR (이미지 텍스트 인식), 회원 정보 보관, 이미지 데이터 보관 | 회원 탈퇴 시 또는 위탁 계약 종료시까지 |

 

| 로 시작하면 table 내용이다.

로 시작하지 않는데 이미 table 상태라면 지금 까지 모은 tableLines 로 table 을 그린다.

혹시라도 마지막이 table 로 끝났을 경우를 대비해서 if 조건문을 추가했다.

 

 

MyMongMarkdownText

private enum class MarkdownLineType(val prefix: String, val style: TextStyle) {
    Heading3(prefix = "# ", style = MDSHeading3),
    Heading2(prefix = "## ", style = MDSHeading2),
    Heading1(prefix = "### ", style = MDSHeading1),
    BulletPoint(prefix = "- ", style = Body3),
    Body(prefix = "", style = Body3),
}

@Composable
internal fun MyMongMarkdownText(
    modifier: Modifier = Modifier,
    line: String,
    textColor: Color
) {
    val lineType = MarkdownLineType.values()
        .filter { type -> type != MarkdownLineType.Body }
        .find { type -> line.startsWith(type.prefix) } ?: MarkdownLineType.Body

    val markdownLine = when (lineType) {
        MarkdownLineType.BulletPoint -> line.replaceFirst("- ", "▪ ")
        else -> line.removePrefix(lineType.prefix)
    }

    Text(
        modifier = modifier,
        text = markdownLine,
        color = textColor,
        style = lineType.style
    )
}

 

 

enum class 로 올 수 있는 Text 타입들을 나열하고 인자로는 타입을 분류할 prefix 와 (text)Style 이 들어가 있다.

 

MyMongMarkdownText Composable 은 line(text 한 줄) 을 기준으로
해당하는 type 을 찾아내고 알맞게 Text 를 표시한다.

 

 

MyMongMarkdownTable

internal const val tableSymbol = "|"

@Composable
internal fun MyMongMarkdownTable(
    modifier: Modifier = Modifier,
    lines: List<String>,
) {
    Column(modifier = modifier.clip(shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))) {
        val titles = lines.first().split(tableSymbol).filter { it.isNotEmpty() }

        Row(
            modifier = Modifier.background(Blue04),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            titles.forEach { title ->
                Text(
                    modifier = Modifier
                        .weight(1f)
                        .padding(10.dp),
                    text = title,
                    textAlign = TextAlign.Center,
                    color = White,
                    style = Body3
                )
            }
        }

        lines.drop(2).forEach { line ->
            val contents = line.split(tableSymbol).filter { it.isNotEmpty() }

            Row(
                modifier = Modifier
                    .background(Gray01)
                    .height(intrinsicSize = IntrinsicSize.Min),
                verticalAlignment = Alignment.CenterVertically
            ) {
                contents.forEachIndexed { index, content ->
                    Text(
                        modifier = Modifier
                            .weight(1f)
                            .padding(10.dp),
                        text = content,
                        textAlign = TextAlign.Center,
                        color = Gray08,
                        style = Body3
                    )
                    if (index != contents.lastIndex) {
                        Divider(
                            modifier = Modifier
                                .fillMaxHeight()
                                .width(1.dp),
                            color = Gray03
                        )
                    }
                }
            }
        }
    }
}

 

목표하는 바는 아래와 같다.

구현 목표

위와 같은 table(표)일 때 실제 넘어오는 lines 먼저 보겠다.

 

| 수탁자 | 위탁 업무 내용 | 개인정보의 보유 및 이용기간 |
| --- | --- | --- |
| 네이버 클라우드 | OCR (이미지 텍스트 인식), 회원 정보 보관, 이미지 데이터 보관 | 회원 탈퇴 시 또는 위탁 계약 종료시까지 |

 

title(파란색) content(그레이) 의 ui 가 구분되어 있다 보니

[수탁자, 위탁 업무 내용, 개인 정보의 보유 및 이용기간]title 로 분류해야한다.

val titles = lines.first().split(tableSymbol).filter { it.isNotEmpty() }

 

그리고 lines 에서 title 과 구분선을 드랍하면
content 이기에 drop 을 2번 하고 해당 라인들의 content 를 하나씩 화면에 나타낸다.

lines.drop(2).forEach { line ->
    val contents = line.split(tableSymbol).filter { it.isNotEmpty() }

 

 

구현 화면

 

 

마지막으로 해당 글의 코드가 적용된 PR 이다

https://github.com/YAPP-Github/23rd-Android-Team-2-Android/pull/57

 

Feature/moneymong 224 개인정보처리방침,서비스 이용약관 수정 by jhg3410 · Pull Request #57 · YAPP-Github/23rd-

요약 개인정보 처리 방침, 서비스 이용약관을 수정했습니다. 🙂 작업내용 기존 WebView 를 제거하고 Compose 로 변경했습니다. 노션에 작성된 Text 를 Markdown 으로 추출해 strings 에 넣었습니다. Markdown

github.com

 

 

수정한 UI 를 PM 님에게 전달드리고 꽤나 강렬한 반응을 받았다.

이런 맛에 UI 개발하지😀

 

 

생각

개발자에겐 구현(개발) 능력도 당연히 매우 매우 중요하지만
- 서비스에 대한 깊은 생각과 이해, 특히 관심도

- 구현 요청에 대한 당연한 오케이보단 본인의 의견과 뒤받침하는 내용을 나열할 수 있는 능력

또한 중요하지 않을까?

 

YAPP 에 지원한 이유는 협업 경험이었다.

몇 달을 혼자 개발하면서, 디자이너의 디자인과 코드 리뷰가 목말랐고, 같은 기능에 대한 다른 사람들의 의견이 배고팠다.(그냥 사람이 고팠나...)

 

이번 프로젝트로 일정관리, 다양한 상황에서의 의사소통 능력 등 꽤나 많이 배우고 얻어간다.

 

프로젝트 자체에 대한 성과도 당연히 중요하지만 (여긴 회사가 아니기 때문에)

본인이 처음  YAPP (대외활동)에 지원한 목적을 달성했다면 그게 더 값진 게 아닐까 생각한다.