마스터Q&A 안드로이드는 안드로이드 개발자들의 질문과 답변을 위한 지식 커뮤니티 사이트입니다. 안드로이드펍에서 운영하고 있습니다. [사용법, 운영진]

Fragment에서만 사용하는 객체의 의존성 주입 질문 드립니다..!

0 추천

안녕하세요, 저는 개인 프로젝트를 하고 있는 취준생입니다.

현재 의존성 주입을 학습하고 프로젝트에 적용하고 있습니다.

특정 Fragment에서만 사용하는 View 조작을 하는 클래스가 있습니다. 그 외에 RecyclerView Adapter도 그렇고, Fragment에서 생성해서 Fragment에서만 사용하는 객체들이 몇 개 있어요. 이러한 객체들도 DI(Hilt)로 외부에서 주입하는 것이 좋나요? 생성자에 Binding 객체와 콜백 함수를 받는 클래스가 있는데, 주입하려니 꽤 불편해 보이더라구요. 그러다가 이렇게 주입해서 얻는 이점이 무엇인가.. 그냥 내부에서 생성하는 게 낫나 의문이 들었어요.

DI가 의존성 역전 원칙과 나아가서 개방-폐쇄 원칙을 지키기 위한 것으로 알고 있습니다(+ 테스트 편의성). Adapter와 ViewBinding처럼 View와 1:1로 강하게 연결되는 객체에도 이것이 해당되는 건가요? 이러한 경우는 DI의 장점인 클래스 재사용성 증가 + 유지보수성 증가(클라이언트 객체를 변경으로부터 보호)가 해당되지 않는 것 같아서요. 왜냐면.. 사실상 Fragment와 한몸에 가까운 1:1 관계라 중간에 추상화를 두지 않을 것 같다고 생각했어요.

그래서 결론은.. 만약 View 관련 클래스가 충분히 비대하다면, SOLID의 목적이 아닌 테스트 편의성 목적으로 추상화를 한 후 구현 객체를 주입하는 방식으로 사용하기도 하나요? 그리고 그런 경우가 아니면 Adapter와 같은 것들은 Fragment/Activity 내부에서 직접 생성해도 괜찮나요?

길고 장황한 질문 읽어주셔서 감사합니다ㅠㅠ

 

아래 코드들은 View 관련 클래스입니다.

class PosterDragHandlerImpl(
    private val binding: FragmentGameBinding,
    private val removeCaughtContent: () -> Unit
) : PosterDragHandler {

    override fun onStartDrag() {
        binding.darkBackgroundCoverForPoster.visibility = View.VISIBLE
        binding.ivContentRemovableArea.isVisible = true
    }

    override fun onDraggingPoster(y: Float) {
        when (isContentInRemoveRange(y)) {
            true -> binding.ivContentRemovableArea.setBackgroundResource(
                R.drawable.game_white_filled_circle_button
            )
            false -> binding.ivContentRemovableArea.setBackgroundResource(
                R.drawable.game_outlined_circle_button
            )
        }
    }

    override fun isPosterRemovable(y: Float): Boolean =
        isContentInRemoveRange(y)

    override fun onFinishDrag(lastY: Float) {
        binding.darkBackgroundCoverForPoster.visibility = View.INVISIBLE
        binding.ivContentRemovableArea.isVisible = false
        if (isContentInRemoveRange(lastY)) {
            removeCaughtContent()
        }
    }

    private fun isContentInRemoveRange(y: Float): Boolean =
        binding.ivContentRemovableArea.y + binding.ivContentRemovableArea.height * 0.65 >=
            binding.viewPagerPoster.y + y
}

 

class HintButtonOpenHandler(
    hintEntranceButton: FloatingActionButton,
    hintButtons: List<Button>,
    wasHintOpened: Boolean,
    private val darkBackgroundView: View
) {

    private val innerButtons = mutableListOf<HintButton>()
    private var animationDistance = 0f
    var isHintOpen = wasHintOpened
        private set

    init {
        hintEntranceButton.setOnClickListener {
            when (isHintOpen) {
                true -> closeHintAndDarkBackground()
                false -> openHintAndDarkBackground()
            }
        }

        hintEntranceButton.doOnLayout {
            animationDistance = it.height.toFloat() + hintEntranceButton.getHintButtonMargin()
            hintButtons.forEach(this::addInnerButton)
            rollbackToPrevState()
        }
    }

    private fun addInnerButton(button: Button) {
        button.doOnLayout {
            val openAnimator = ObjectAnimator
                .ofFloat(button, "TranslationY", -animationDistance)
                .setDuration(ANIMATION_DURATION)
            innerButtons += HintButton(button, openAnimator)
            animationDistance += (button.height + button.getHintButtonMargin())
        }
    }

    private fun openHintAndDarkBackground() {
        openHint()
        isHintOpen = true
        darkBackgroundView.visibility = View.VISIBLE
    }

    fun closeHintAndDarkBackground() {
        closeHint()
        isHintOpen = false
        darkBackgroundView.visibility = View.GONE
    }

    private fun openHint() {
        innerButtons.forEach {
            it.open()
        }
    }

    private fun closeHint() {
        innerButtons.forEach {
            it.close()
        }
    }

    private fun rollbackToPrevState() {
        if (isHintOpen) {
            openHintAndDarkBackground()
        }
    }

    private class HintButton(
        private val button: Button,
        private val openAnimator: ObjectAnimator
    ) {

        init {
            button.elevation = button.resources.getDimension(R.dimen.game_hintbutton_elevation)
            button.isVisible = false
        }

        fun open() {
            openAnimator.start()
            button.isVisible = true
        }

        fun close() {
            button.translationY = 0f
            button.isVisible = false
        }
    }

    companion object {
        private const val ANIMATION_DURATION = 400L
    }
}

private fun <T : View> T.getHintButtonMargin() = resources.getDimension(R.dimen.game_hintbutton_margin)

 

이륙사 (370 포인트) 님이 2023년 3월 20일 질문
이륙사님이 2023년 3월 20일 수정

1개의 답변

+1 추천
 
채택된 답변
지적하신 부분은 공감하는 내용입니다. 안드로이드에서 DI, 특히 뷰쪽에서는 일반적인 생성자 주입을 직접할 수 없는 관계로 변형된 형태로 처리되기 때문에, 의문이 들만하다고 생각합니다.
테스트를 작성할 경우라면 DI가 이점이 되는 측면이 있다고 봅니다. 다만, 현실적으로 팀의 사이즈가 큰 곳이 아니면 유닛테스트 외에 뷰테스트까지 작성할 수 있을 지는 의문이 들긴 합니다.

저의 경우는 도메인, 데이터 레이어에 DI를 사용하고, 어느정도의 유닛테스트를 작성하기 때문에 도움이 되긴 합니다. 그리고 뷰쪽은 객체 생성하는 코드를 뷰쪽에 직접 작성하는 것보다 DI 라이브러리에 위임하는 정도로 사용하고 있습니다. 코드를 읽을 때 객체 생성하는 부분까지 읽을 필요가 없어서 낫긴합니다만, 꼭 이것 때문에 DI를 뷰쪽에 DI 를 사용해야 하는지는 좀 더 생각해보아야 할 사항인 것 같습니다.

사실 복잡하지 않은 프로젝트라면 DI는 Hilt나 Dagger같은 라이브러리의 도움없이 간단한게 작성할 수 있습니다. 개인 프로젝트에서는 그렇게 하고 있구요. 님이 제기하신 문제는 충분히 고려할 가치가 있고, 프로젝트, 팀사이즈, 스킬 정도 등등 여러부분을 고려해 걸정하는 것이 좋지 않을까 생각합니다.
spark (227,470 포인트) 님이 2023년 3월 21일 답변
이륙사님이 2023년 3월 22일 채택됨
답변 정말 감사합니다.
무조건 사용하는 것이 아니라 상황에 따라 도움이 될 것 같을 때만 적용하는 것이 좋겠군요. 프래그먼트에 DI를 적용하려다가 문득 그게 코드를 더 복잡하게 만들 것 같아서 질문 드렸어요. 이제 막 공부하는 단계라 적용했을 때 이점을 판단하기 어렵더라구요..

spark님이 경험하시기에 DI의 장점은 무엇인가요?
이론적으로 가운데 추상화를 둠으로써 "클래스 재사용성이 좋아지고, 생성자 변경과 같은 구현체 변경으로부터 클라이언트 객체를 보호한다, 테스트 편의성이 증가한다." 라고 하는데..
저는 싱글톤 클래스 관련된 보일러 플레이트 코드 안써도 되는 건 편하다고 생각 했는데, 그 외에는 직접 경험해보지 않으니 공감이 잘 안되네요ㅠㅠ
유닛테스트 등을 작성할 때 구현코드를 Mock이나 Stub으로 바꿀 수 있어서 유연하게 됩니다. 그리고 인터페이스 타입으로 inject을 하게 되면 구현 클래스를 바꾸어야 할 때 안전하게 기존 코드를 건드리지 않고도 할 수 있어서 좋습니다. 추가적으로는 객체를 생성하는 코드를 지저분하게 위임하다 보니 코드베이스가 좀 더 깔끔해 질 수도 있고, Scope 등을 잘 활용한다면, 객체의 생명주기도 컨트롤이 가능하기 때문에 객체를 낭비하지 않고 좀 더 효율적으로 사용할 수 있습니다. Hilt같은 경우는 어노테이션을 통해 기본적인  Scope을 지원하고 있긴하죠.
iOS경우는 딱히 DI를 즐겨 사용하지는 않는 것 같이 보이는데, 언어와 SDK의 특성에 따른 것이기도 합니다. 어느 것이 정답이다라고 결론지을 수는 없고 언어와 플랫폼, 프로젝트의 특성에 맞는 최상의 선택을 하는 것이 정답일 겁니다.
지원서 쓴다고 정신 없다가 이제야 댓글 답니다ㅠㅠ 답변 정말 감사합니다 DI 개념 잡는데 도움이 많이 되고 있어요.

그런데 답변을 읽다가 또 궁금한 게 생겼습니다.
spark님은 개발하실 때 SOLID 원칙을 신경 쓰시나요? 아니면 그 내용을 자연스럽게 코드에 녹이시나요?

인터페이스 분리 원칙과 단일 책임 원칙에 대해서도 제가 이해한 것이 맞는지 궁금합니다. 예를 들어 "A->C,  B->C의 의존 관계가 있는데 A, B가 사용하는 기능이 서로 관련이 없다면, C를 CA, CB로 분리해서 A->CA, B->CB로 만들어라" 라고 이해했는데 맞을까요?
맞다면 왜 그렇게 하는 게 좋은가요? C가 서로 다른 요구 사항을 갖고 있으면, A의 요구사항 변경으로 C를 수정한 것이 B가 호출하고 있는 메서드에도 영향을 줄 수 있기 때문인 건가요??
이것도 맞다면, 영향을 주는 원인은 private method나 상태를 공유하기 때문인 걸까요?
그래서 메서드는 하나의 기능만 담당해야 한다는 말이 나오는 것 같은데.. 이게 맞는지 궁금합니다.

객체 지향은 원칙 이름은 제 각각이면서 왜 다 연결되어 있는 걸까요? 너무한 것 같아요ㅠㅠ
가능한 신경은 쓰지만 자연스럽게 코드에 녹아드는 건 다른 문제입니다. 아무리 뛰어난 개발자라도 늘 고민해야될 문제이구요. 그래서 마틴파울러같은 최상급의 엔지니어도가 리팩토링이랑 책을 쓴거죠. Junit을 개발한 켄벡이 일런 말을 했습니다. 나는 훌륭한 개발자는 아니다. 다만 좋은 습관을 가진 개발자다.
의존성에 관해서는 처음부터 아직 발생하지 않은 모든 걸 고려해서 코드를 만들 필요는 없습니다. 어떤 경우는 힌클래스가 한가지 이상의 일을 하는게 더 관리하깃 쉬울 때도 있어요. 예를 들면 코드가 몇 라인 밖에 안되거나 하는 경우에 말이죠.
그래서 단위 테스트를 잘 짤 수록 좋습니다. 그래야 필요한 경우 리팩토링이 쉬워집니다. 모든 코드는 복잡성을 줄이고 변경이 쉬운 쪽으로 우선 순위를 두면 좋습니다. 이 관점에서 SOLID가 도움이 되는 기법이 될 수 있구요.
핵심은 원칙 자체가 아니라 "복잡성을 줄이고 변경하기 쉽게 만들어라" 라는 거군요, SOLID는 그러한 기법 중 하나이구요.

매번 답변 감사합니다 선배님. 앞으로도 귀찮게 하러 올게요... 하핫
...