Private Method 테스트 코드를 짜야하나?

devty·2025년 6월 16일

SpringBoot

목록 보기
9/11

왜 private 메서드를 고민하게 되었나

  • 서비스를 만들다 보면 내부적으로만 쓰는 로직을 private 함수로 빼는 건 흔한 패턴임
  • 예를 들어 아래 코드처럼
    class PostService(
        private val userService: UserService,
        private val postRepository: PostRepository
    ) {
    
        fun getRecommendedPosts(userId: String): List<PostDto> {
            val user = userService.getUserById(userId)
            val posts = postRepository.findAll()
    
            val filtered = filterPostsForUser(posts, user)
            return filtered.map { PostDto.from(it) }
        }
    
        private fun filterPostsForUser(posts: List<Post>, user: User): List<Post> {
            return posts.filter {
                it.isActive &&
                it.region == user.region &&
                it.visibility == "public"
            }
        }
    }
    • 여기까진 깔끔해 보이고 재사용성이 없기에 private로 빼는건 거의 국룰임
    • 하지만 이렇게 되면 문제가 생김

여기서 드러나는 문제점

  • filterPostsForUser()는 private이라 단독 테스트가 불가능함
  • 그런데 이 함수 내부에는 비즈니스 조건이 담겨 있음 → isActive, region, visibility 등 도메인에 가까운 로직
  • 필터 조건이 많아질수록 PostService의 책임이 무겁고 테스트 어려운 구조로 바뀜

결국 고민하게 된다

  • 이대로 private 함수에 로직을 묻어두는 게 맞을까?
  • 아니면 책임을 밖으로 빼서 구조를 바꿔야 하는 걸까?

해결 방향 1 : PostFilter 클래스로 책임 분리

  • 필터링 책임을 PostService에서 분리하여 단일 책임 원칙(SRP)을 지키자
  • 도메인 지식을 명확히 분리해 응집도 높임
    class PostFilter {
        fun filter(posts: List<Post>, user: User): List<Post> {
            return posts.filter {
                it.isActive &&
                it.region == user.region &&
                it.visibility == "public"
            }
        }
    }
  • PostService 리팩토링
    class PostService(
        private val userService: UserService,
        private val postRepository: PostRepository,
        private val postFilter: PostFilter
    ) {
        fun getRecommendedPosts(userId: String): List<PostDto> {
            val user = userService.getUserById(userId)
            val posts = postRepository.findAll()
    
            return postFilter.filter(posts, user).map { PostDto.from(it) }
        }
    }

해결 방향 2 : Post 도메인 객체에 책임 위임

  • 필터 기준이 도메인 관점에서 정의 가능하다면 Post 객체 내부에서 isRecommendedFor(user) 같은 메서드로 책임을 위임할 수도 있음
  • 다만 이 방식은 도메인에 로직이 종속되며 로직 변경이 빈번한 경우엔 PostFilter 방식이 더 유연함
    class Post(
        val id: String,
        val region: String,
        val isActive: Boolean,
        val visibility: String
    ) {
        fun isRecommendedFor(user: User): Boolean {
            return isActive && visibility == "public" && region == user.region
        }
    }
  • PostService 리팩토링
    class PostService(
        private val userService: UserService,
        private val postRepository: PostRepository
    ) {
    
        fun getRecommendedPosts(userId: String): List<PostDto> {
            val user = userService.getUserById(userId)
            val posts = postRepository.findAll()
    
            val filtered = posts.filter { it.isRecommendedFor(user) }
            return filtered.map { PostDto.from(it) }
        }
    }

테스트 전략

  • PostFilter 클래스로 책임 분리
    @Test
    fun `유저 정보와 전체 포스트를 기반으로 필터링된 추천 포스트를 반환한다`() {
        // given
        val userId = "user123"
        val user = User(region = "SEOUL")
        val posts = listOf(
            Post(id = "p1", region = "SEOUL", isActive = true, visibility = "public"),
            Post(id = "p2", region = "BUSAN", isActive = true, visibility = "public")
        )
        val filteredPosts = listOf(posts.first())
    
        whenever(userService.getUserById(userId)).thenReturn(user)
        whenever(postRepository.findAll()).thenReturn(posts)
        whenever(postFilter.filter(posts, user)).thenReturn(filteredPosts)
    
        // when
        val result = postService.getRecommendedPosts(userId)
    
        // then
        assertThat(result).hasSize(1)
        assertThat(result.first().id).isEqualTo("p1")
    }
    @Test
    fun `user의 지역과 다른 글은 제외된다`() {
        // given
        val posts = listOf(
            Post(region = "SEOUL", isActive = true, visibility = "public"),
            Post(region = "BUSAN", isActive = true, visibility = "public")
        )
        val user = User(region = "SEOUL")
    
        // when
        val filtered = postFilter.filter(posts, user)
    
        // then
        assertThat(filtered).hasSize(1)
        assertThat(filtered.first().region).isEqualTo("SEOUL")
    }
  • Post 도메인 객체에 책임 위임
    @Test
    fun `추천 조건을 만족하는 포스트만 반환한다`() {
        // given
        val userId = "user123"
        val user = User(region = "SEOUL")
        val posts = listOf(
            Post(id = "p1", region = "SEOUL", isActive = true, visibility = "public"),
            Post(id = "p2", region = "BUSAN", isActive = true, visibility = "public"),
            Post(id = "p3", region = "SEOUL", isActive = false, visibility = "public"),
            Post(id = "p4", region = "SEOUL", isActive = true, visibility = "private")
        )
    
        whenever(userService.getUserById(userId)).thenReturn(user)
        whenever(postRepository.findAll()).thenReturn(posts)
    
        // when
        val result = postService.getRecommendedPosts(userId)
    
        // then
        assertThat(result).hasSize(1)
        assertThat(result.first().id).isEqualTo("p1")
    }
    @Test
    fun `isRecommendedFor - 동일 지역, 공개 상태, 활성화된 포스트는 true`() {
        val post = Post(id = "p1", region = "SEOUL", isActive = true, visibility = "public")
        val user = User(region = "SEOUL")
    
        val result = post.isRecommendedFor(user)
    
        assertThat(result).isTrue()
    }

그럼 private에는 어떤 게 들어가야 할까?

  • 단순 유틸리티 수준의 분기 처리
    • 값 없을 때 기본값을 주는 함수
    • 단순 정렬 함수 등
  • 하위 작업을 분리한 함수이되 도메인 지식과 무관한 것
    • 로직을 읽기 쉽게 하는 목적임
    • 비즈니스 판단이 들어가선 안 됨

결론

  • private 메서드에 복잡한 로직이 있다면 단위 테스트를 하기 위해 리플렉션을 쓸 것이 아니라 구조 자체를 리팩토링해야함
  • 테스트가 어려운 구조는 대체로 책임이 뒤섞인 구조임 이는 리팩토링의 시그널임
  • 재사용성뿐 아니라 가독성과 테스트 용이성도 private 분리의 기준이 되어야함
profile
지나가는 개발자

0개의 댓글