클린 아키텍처

기술/아키텍처 2021. 5. 20. 19:51 Posted by 아는 개발자

 

로버트 마틴의 클린 코드에선 코드를 깔끔하게 잘 짜는 방법을 배웠다면 클린 아키텍처에서는 소프트웨어를 더 잘 만드는 방법을 배운 것 같다. 책의 표현을 빌리자면 클린 코드에서는 좋은 벽돌을 구분하는 방법을 배웠다면 클린 아키텍처에서는 좋은 벽돌로 건물을 짓는 방법을 배운 느낌이랄까. 책에선 저자가 경험한 내용을 바탕으로 전달하려는 교훈이 많다. 저수준, 고수준, 프레임워크는 세부사항일 뿐이다 등등.. 그런데 나의 소프트웨어 깊이가 부족해 공감하기 어려운 부분도 있었고 이해되지 않던 부분도 있어서 모두 소화하진 못했다. 그래도 연차가 늘어나고 더 큰 규모의 소프트웨어를 경험하다보면 이 책에서 내가 캐치하지 못했던 새로운 면이 보일 것 같아 기대된다. 2-3년 후에 다시 이 책을 읽어봐야 겠다.

 

많은 전달 내용 중 내 머릿속을 관통하는 소프트웨어 원칙은 이 그림으로 표현 할 수 있다.

 

 

다이어그램 상에선 컴포넌트A 가 컴포넌트B를 가리키고 있는 그림인데 소프트웨어상에선 컴포넌트A 가 컴포넌트B 에 의존한다는 의미의 그림이다. 이 의존 관계는 소프트웨어 상에서 생길 수 있는 가장 중요한 관계고 이 관계를 어떻게 정의하느냐에 따라서 소프트웨어의 아키텍처가 결정된다. 컴포넌트A는 저수준으로, 컴포넌트B는 고수준으로 둬야한다고 그림상에선 표현 했는데 여기서 말하는 수준은 어떤 컴포넌트가 우월한지를 결정하는 기준이 아니라 얼마만큼 변동성이 크냐를 기준으로 결정한다.

 

위 그림처럼 의존관계가 성립되려면 고수준인 컴포넌트B는 수정할 일이 적어야 한다. 그래야 컴포넌트A에 미치는 영향을 최소화 할 수 있기 때문이다. 위 그림처럼 결정되는 대표적인 예가 애플리케이션에서 String, Math 같은 자바 고유 라이브러리 클래스를 사용하는 경우다. 자바 버전에 따라서 클래스가 변경될 소지가 있지만 그래도 우리가 개발하는 클래스보다 변경될 소지는 적다. 이런 경우 의존 관계는 적절한 것으로 볼 수 있다.

 

그래도 가끔은 이런 의존 관계를 성립하기 힘든 경우도 있다. 둘다 변경의 소지가 크지만 두 클래스를 연결해야할 때가 있다. 이럴때 사용하는 방식이 의존성 역전원칙이다. 좀더 고수준으로 보이는 클래스에 특정 인터페이스를 만들고 이것과 상속 관계로 만든다. 그리고 저수준 클래스를 인터페이스에 의존하는 관계로 만든다. 인터페이스는 변경될 소지가 적기 때문에 이 의존 관계도 적절한 관계로 볼 수 있다.

 

 

물론 매번 이렇게 코딩할 수는 없다. 개발하다보면 새로운 함수도 추가해야돼 인터페이스도 손될 일이 많아지니까. 모든 원칙을 지키다 보면 오버 엔지니어링이돼 개발 프로세스가 느려지는 부작용도 생길 수 있다. 항상 모든 원칙을 지키기는 어려울 것 같다. 하지만 원칙을 알고 생략하는 것과 모르고 넘어가는 것은 차이가 크다. 앞으로 일하면서 어떤 원칙을 넘기면서 개발하고 있는지 되새겨봐야 할 것 같다.

728x90

'기술 > 아키텍처' 카테고리의 다른 글

클린 아키텍처  (0) 2021.05.20
DIP(Dependency Inversion Principle)  (0) 2021.05.09
ISP (Interface Segregation Principle)  (0) 2021.05.09
LSP (Liskov Substitution Principle)  (0) 2021.05.09
OCP (Open Closed Principle)  (0) 2021.05.04
SRP (Single Responsibility Principle)  (0) 2021.05.04

Android 10 스토리지 정책 대처하기

개발/안드로이드 2021. 5. 18. 20:40 Posted by 아는 개발자

targetSdkVersion 을 30으로 올리면 파일 절대 경로를 사용해서 접근 할 수 없기 때문에 개발자들은 지금부터 슬슬 절대 경로를 사용해서 접근하는 코드를 변경해야한다. 이번 포스트에서는 안드로이드 새로운 스토리지 정책을 적용한 과정을 다뤄본다.

 

1. 절대 경로 대신 Uri 를 사용하도록 변경

 

기존에는 ContentResolver 클래스를 이용해 파일을 읽어올 때 DATA 칼럼을 이용해서 파일의 절대 경로를 읽어올 수 있었다. 그런데 DATA 컬럼은 Android 10부터 Deprecated가 됐고, targetSdkVersion 30으로 올리면 DATA 칼럼으로 얻을 수 있는 절대 경로로 파일이 접근이 되지 않는다. 

 

private suspend fun loadVideoContent(): Cursor? = coroutineScope {
    val where = MediaStore.Video.VideoColumns.SIZE + " > " + 0
    val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
    val projections = listOf(
        MediaStore.Video.Media._ID,
        MediaStore.Video.Media.DATA, // Deprecated됨
        MediaStore.Video.Media.DISPLAY_NAME
    ).toTypedArray()

    return@coroutineScope requireActivity().contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projections, where, null, sortOrder)
}

 

 

이제는 우리에게 익숙한 절대 경로 대신 Uri를 이용한 상대 경로를 사용해야한다. Uri는 content:// 로 시작하는 문자열인데, _ID 칼럼에서 얻어온 값과 ContentUri 클래스를 이용해서 얻어올 수 있다. 이 값도 파일을 찾는 경로로 사용되며 현재 Glide, MediaMetadataRetriever, Exoplayer처럼 유명한 안드로이드 라이브러리들은 Uri를 통해서도 파일을 불러올 수 있게끔 업데이트가 된 상태라 호환성은 크게 걱정하지 않아도 된다. 절대경로와 다른점은 실제 파일의 경로를 보여주지 않아 플랫폼 보안적인 요소가 강화된다. 반대로 개발자의 피로도는 악화되고.

 

val uriCol = cursor.getColumnIndex(MediaStore.Video.Media._ID)

do {
    mediaItems.add(
        MediaItem(
            ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                cursor.getLong(uriCol)
            ),

 

2. File 클래스를 선언해야하는 경우 

 

문제는 File 클래스를 선언해야하는 경우다. 오래된 오픈소스거나 Uri를 고려하지 않은 모듈인 경우엔 절대 경로가 필요한 File 클래스를 사용해야하는 경우가 종종 있다. 그런데 앞서 언급했듯이 Uri는 상대 경로다. Uri를 통해 File 클래스로 바꿀수 있긴 한데 이건 file 스키마를 가진 Uri인 경우에만 그렇다. Uri 클래스에 Kotlin에 확장 코드로 toFile() 함수가 있긴 한데 ContentUris로 얻어온 Uri 클래스에 쓰면 요런 에러가 뜬다.

 

 

이럴 때는 절대 경로를 읽을 수 있는 형태로 꼼수가 필요하다. Android 10부터 스토리지를 절대 경로로 접근하는 것은 안되지만 앱 전용 캐시 영역은 여전히 절대 경로로 접근 할 수 있다. 스토리지에 있는 파일을 캐시로 복사하면 복사한 파일의 절대 경로로 파일 클래스를 선언해줄 수 있다. copy 작업이 딜레이도 있고 불필요하게 캐시영역 써야해 완벽한 방법은 아니다. 하지만 라이브러리에서 Uri를 지원하기 전까지는 써먹을 수 있을 것 같다. 더 좋은 방법이 있다면 공유해주시면 좋겠다. 나는 이것 말고는 딱히 방법을 못찾겠다...

 

val dir = File(context.cacheDir.path + File.separator + effectFolderName)
val filePath = context.cacheDir.path + File.separator + effectFolderName + File.separator + filename
val file = File(filePath)
val inputStream = getApplication<App>().contentResolver.openInputStream(uri)

try {
    FileUtils.copyToFile(inputStream, it) // org.apache.commons.io 를 사용
} catch (e: IOException ) {
    e.printStackTrace()
}

 

3. 미디어 파일을 추가하는 경우

 

앱에서 이미지나 동영상을 다운받는 경우 예전에는 Environment.getExternalStorageDirectory().path 코드를 이용해서 직접 원하는 경로에 파일을 생성해서 추가할 수 있었으나 Android 10 부터는 ContentResolver를 이용해 Uri로 파일을 추가해야한다. 아래 코드는 이미지 파일을 저장소에 추가하는 코드다. ContentValues 값을 설정해 임의의 이미지 파일을 만든 후 insert 함수에서 생성된 Uri 변수로 FileOutputStream을 만들고 I/O 라이브러리를 이용해 기존 파일과 복사하는 작업이다. 관계형 데이터베이스에 새로운 행을 추가하고 값을 업데이트한다고 보면 쉬울 것 같다. 실제로 ContentResolver는 관계형 데이터베이스 쿼리랑 상당부분 흡사하다.

 

val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

val contentvalues = ContentValues().also {
    it.put(MediaStore.MediaColumns.RELATIVE_PATH, "Images")
    it.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
    it.put(MediaStore.MediaColumns.IS_PENDING, true)
}

val uri = context.contentResolver.insert(collection, contentvalues)
val fos = context.contentResolver.openOutputStream(uri!!, "w")

try {
    FileUtils.copyFile(sourceFile, fos)
} catch (e: IOException) {
    return false
}
values.put(MediaStore.MediaColumns.IS_PENDING, false)
context.contentResolver.update(uri, values, null, null)

 

728x90

아래 그림처럼 ViewPager 형태인데 다음 페이지가 살짝 보이는 UI를 만드는 경우가 종종 있다. 이번 포스트에서는 ViewPager2를 이용해 이 화면을 만드는 방법을 다뤄보려고 한다.

 

val currentVisibleItemPx = DimensionUtils.dp2px(requireContext(), 40f).toInt()

margin_pager.addItemDecoration(object: RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        outRect.right = currentVisibleItemPx
        outRect.left = currentVisibleItemPx
    }
})

ViewPager에서 inflate 된 페이지는 부모의 width를 따라가게 되므로 우선 각 페이지가 전체 영역을 잡지 않게 여백을 만들어둔다. 현재 보여진 위치로부터 양 옆에 margin을 추가한다. 그러면 아래 그림과 같은 상태가 된다.

 

 

val nextVisibleItemPx = DimensionUtils.dp2px(requireContext(), 20f).toInt()
val pageTranslationX = nextVisibleItemPx + currentVisibleItemPx

margin_pager.offscreenPageLimit = 1

margin_pager.setPageTransformer { page, position ->
    page.translationX = -pageTranslationX * ( position)
}

 

다음은 이전 페이지와 다음 페이지에 이동 효과를 줘야한다. 먼저 첫번째는 offscreenPageLimit 값을 설정는데 이 속성은 ViewPager2가 스크린에 현재 페이지로부터 얼만큼 떨어져 있는 페이지를 미리 생성 할 것인지 설정하는 함수다. offscreenPageLimit 값이 1이고, 5번 페이지가 현재 위치라면 ViewPager2는 4, 6번 페이지도 미리 생성 해둔다.

 

그 다음은 setPageTransformer 함수를 사용하는 것이다. 이 콜백은 현재 포커싱된 page 뷰 객체를 받을 수 있고 각 page 별로 포커싱 된 페이지로부터 얼마만큼 떨어져 있는지 비율 정보를 position으로 받을 수 있다. 아래 그림에선 현재 페이지가 1번에 포커싱돼 있어서 이전 페이지인 0번 페이지는 -1f만큼 떨어져 있게 되고, 다음 페이지인 2번 페이지는 1f만큼 떨어져 있게 된다. 

 

 

이 정보 값을 이용하면 0번과 2번 페이지를 필요한 만큼 이동시킬 수 있다. translationX 값을 변경하면 0번과 2번 페이지가 움직여서 미리보기 형태로 볼 수 있게 된다.

 

728x90

움직이는 TextView

개발/안드로이드 2021. 5. 11. 19:57 Posted by 아는 개발자

종종 화면내에서 움직이는 TextView를 만들어야 할 때가 있다.

 

이렇게 직선형태로 움직이는 애니메이션의 경우 TranslateAnimation 클래스를 이용해서 쉽게 구현이 가능하다. 아래 코드는 새로운 TextView를 만들고 layout에 추가한 다음 애니메이션을 실행한 코드다. 주목할 부분은 TranslateAnimation 코드다.

 

CoroutineScope(Dispatchers.Main).launch {
            val movingText = TextView(requireContext()).apply {
                this.text = "움직이는 텍스트"
                this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {}
                this.visibility = View.INVISIBLE
                this.setTextColor(0xff141414.toInt())
            }

            danmu_layout.addView(movingText)

            movingText.post {
                val animation = TranslateAnimation(requireView().width.toFloat(), -(movingText.width.toFloat()), 0f, 0f)
                animation.duration = 3000
                animation.repeatCount = Animation.INFINITE
                animation.setAnimationListener(object: Animation.AnimationListener {
                    override fun onAnimationStart(animation: Animation?) {
                        movingText.visibility = View.VISIBLE
                    }

                    override fun onAnimationEnd(animation: Animation?) {}
                    override fun onAnimationRepeat(animation: Animation?) {}
                })
                movingText.startAnimation(animation)
            }
        }

 

TranslateAnimation 생성자 인자에서 받는 값은 fromXDelta, toXDelta, fromYDelta, toYDelta다. xml 파일로 애니메이션을 작성할 때는 퍼센테이지 값을 넣을 수 있는데, TranslateAnimation 클래스를 사용하면 픽셀 값으로 입력해야한다. 각각이 의미하는 바를 보자. 

 

public TranslateAnimation(float fromXDelta, float toXDelta, float fromYDelta, float toYDelta) {

 

fromXDelta는 현재 위치로부터 Delta만큼 x축 방향으로 이동한 지점에서 애니메이션을 시작한다. 현재 위치부터 시작하고 싶다면 0을, 다른 위치로 변경하고 싶다면 특정 값을 설정하면 된다. + 값은 오른쪽으로 이동하고 - 값은 왼쪽으로 이동한다. 같은 원리로 toXDelta는 현 위치에서 x축 방향으로 이동한 지점에서 애니메이션을 종료한다. 왼쪽으로 이동한 지점에서 종료하고 싶다면 - 값을, 오른쪽으로 이동한 값에서 종료하고 싶으면 + 값을 넣으면 된다. 앞서 소개한 코드에선 fromXDelta에선 부모 뷰의 width만큼 움직여서 화면 밖에서 시작하고, toXDelta는 현재위치에서 텍스트의 width만큼 왼쪽으로 움직이므로 화면 밖으로 사라지는 애니메이션을 만들 수 있었다. 절대적인 좌표가 아니라 아니라 현 위치로부터 상대적인 거리로 값을 입력해야 한다는 점을 주의하자. y축에서도 동일한 원리를 적용할 수 있다.

 

728x90

DIP(Dependency Inversion Principle)

기술/아키텍처 2021. 5. 9. 08:57 Posted by 아는 개발자

좋은 아키텍처는 변동성이 큰 모듈에 의존하지 않는 것이다. 그런데 개발하다 보면 예상치 못한 버그도 종종 생기기 마련이기 때문에 어떤 클래스는 릴리즈마다 계속 수정을 할 수 밖에 없다. 그런데 이때마다 새로운 함수를 추가하고 새로운 변수가 등장한다면 이 클래스를 의존하는 다른 모듈에도 영향이 미친다. 이런 형태면 하나의 클래스를 수정하는데도 다른 클래스까지 영향을 주게 된다.

 

그림 1

위 그림에선 사용자가 버그가 많은 결제 시스템 클래스를 의존하고 있다. 지금까지 pay 함수에 버그가 많아서 3개의 레거시 함수가 있다. 이런 형태는 새로운 함수가 추가될 때 마다 사용자의 코드에 영향을 주게 되는 사례다.

 

해법은 인터페이스를 이용하는 것이다. 모듈은 안정화된 인터페이스에 의존하고 변동성이 큰 실제 구현체는 인터페이스를 바꾸지 않는 선에서 수정한다. 인터페이스가 바뀌지 않는것이 보장됐기 때문에 원래 실제 구현체에 의존하는 클래스는 수정이 있어도 코드를 수정하지 않아도 된다. 이런 철학으로 만든 원칙이 DIP(Dependency Inversion Principle) 의존성 역전 원칙이다.

 

그림 2

그림 2는 그림 1에서 DIP를 적용한 버전이다. 사용자는 안정화된 결제시스템 Interface를 참조하고 있기 때문에 수정할 일이 없다. 버그가 많은 결제시스템만 수정해도 소프트웨어의 안정성은 보장된다. 요즘에는 프레임워크 차원에서 이렇게 구현할 수 있도록 지원하고 있다. 안드로이드의 Hilt, Dagger가 DIP를 지원하는 대표적인 라이브러리니 아직 사용해보지 않은 분들은 한번 써보는게 좋을 것 같다.

728x90

'기술 > 아키텍처' 카테고리의 다른 글

클린 아키텍처  (0) 2021.05.20
DIP(Dependency Inversion Principle)  (0) 2021.05.09
ISP (Interface Segregation Principle)  (0) 2021.05.09
LSP (Liskov Substitution Principle)  (0) 2021.05.09
OCP (Open Closed Principle)  (0) 2021.05.04
SRP (Single Responsibility Principle)  (0) 2021.05.04