고민해보기

서비스는 인터페이스를 꼭 구현해야할까? (w. 단위테스트)

mopil 2023. 12. 16. 02:02
반응형

간혹 서비스 객체를 인터페이스와 구현체로 분리해서 사용하는 레포들을 볼 수 있다.

 

    interface UserService {
        fun updateUserInfo(request: UpdateUserInfoRequest)
        fun getUserInfo(): UserInfo
        fun login()
    }
    
    @Service
    class UserServiceImpl(
        private val userRepository: UserRepository,
        private val loginHelper: LoginHelper,
    ) : UserService {
        override fun updateUserInfo(request: UpdateUserInfoRequest) {
            request.process()
            val userInfo = userRepository.getUserInfo()
            userInfo.birthdate = request.birthdate
            userRepository.save(userInfo)
        }
        
        override fun getUserInfo(): UserInfo {
            return userRepository.getUserInfo()
        }
        
        override fun login() {
            loginHelper.loginBySocial()
        }
    }

 

 

이렇게 나누는 게 어떤 효용성이 있을까? 필자는 여태 개발 생산성이 저하된다는 이유로 (인터페이스를 만들고, 구현체를 만들어야 하기 때문에) 곧바로 구현체를 의존하게끔 만들었었다.

 

하지만, 이는 결코 좋은 방법은 아니었다.

 

결론적으로 서비스 자체를 mocking 하거나 faking 하여 단위 테스트를 할 필요가 있을 땐 인터페이스를 의존하게끔 하는 게 훨씬 구조적으로 유연하다.

 

# 서비스 자체를 구현체로 의존했을 경우 문제점

BoardService가 있고, 현재 로그인 정보를 가져오는 AuthService와 유저정보를 관리하는 UserService가 있다고 해보자.

 

BoardService는 게시물 관련 로직을 관할하며, 게시물 작성 로직은 다음과 같다.

 

1. 로그인된 사용자 정보를 AuthService에서 가져온다.

2. 해당 정보로 UserService에서 유저정보를 가져온다.

3. 게시물을 저장한다.

 

    class BoardService(
        private val userAuthService: UserAuthService, // 직접 구현체
        private val authService: AuthService, // 직접 구현체
        private val boardRepository: BoardRepository
    ) {
        fun postBoard() {
            val email = authService.getCurrentUserEmail()
            val user = userAuthService.getUserInfo(email)
            boardRepository.save(BoardEntity(user))
        }
    }

 

 

이때 BoardService의 게시물 작성 메서드를 단위테스팅 하려고 한다 해보자. 그리고 AuthService의 현재 로그인 된 사용자 조회는 Spring Security의 SecurityContext에서 그 인증 정보를 가져온다고 해보자.

 

    class AuthService {
        fun getCurrentUserEmail(): String {
            return SecurityContextHolder.getContext().authentication.principal.email
        }
    }

 

이제 BoardService의 postBoard 메서드를 단위테스트 한다고 가정해 보자.

 

이러면 mocking이나 faking을 사용한 단위테스트를 작성하기 매우 곤란하다.

 

	@Test
	fun test() {
		val boardService = BoardService(
			authService = AuthService(), // 넣어도 단위테스트는 실패한다. 
            // 왜냐하면 테스트 환경에서 SecurityContextHolder는 쓰레드로컬에 아무 인증 값을 가지고 있지 않기 때문
		)
	}

 

왜냐하면 단위테스트 환경에서는 AuthService의 쓰레드 로컬에 저장되는 인증 정보를 mocking 하거나 faking 하기 어렵기 때문이다.

 

만약 AuthService가 인터페이스로 되어있고, BoardService가 해당 인터페이스를 의존한다면?

 

단위 테스트 시점에 FakeAuthService를 만들어 SecurityContext가 아닌 일반 Map에서 인증 정보를 가져오게끔 faking 할 수 있을 것이다.

 

 

즉, 단위테스트를 하기 위해선, 특히나 faking을 하기 위해선 인터페이스로 분리하는 것이 좋다.

 

# 인터페이스로 분리하는 팁

특정 컴포넌트를 다음과 같이 함수형 인터페이스로 만들 수 있다.

   fun interface LoginBySocial {
        fun invoke()
        
        @Component
        class Default : LoginBySocial {
            override fun invoke() {
                // 복잡한 로직s
            }
        }
    }

 

 

스프링 부트는 컴포넌트 스캔을 할 때 이터 클래스의 컴포넌트도 스캔하여 기본 구현체로 등록을 해줘서 위 코드가 가능하다.

 

    class UserAuthService(
        private val loginBySocial: LoginBySocial,
    ) {
        fun loginBySocial() {
            loginBySocial()
        }
    }

 

사용은 의존성 주입을 해서 사용한다

 

만약 UserAuthService를 의존하는 BoardService에서 loginBySocial 호출이 필요하다면, 직접 구현체를 의존했으면 단위테스트를 할 수 없을 것이다. (단위 테스트 환경에선 직접 소셜 서버에 http call을 할 수 없으니)

 

함수형 인터페이스 구조를 가져갔다면 아주 쉽게 FakeUserAuthService를 제작할 수 있다.

 

    class FakeUserAuthService : UserAuthService {
        val fakeThreadLocal = mutableMapOf<String, Any>
        override fun loginBySocial() {
            // fake 로그인 로직
            // SecurityContextHolder에서 가져오지 않고 fakeThreadLocal에서 가져온다.
        }
    }

 

 

BoardService는 UserAuthService 인터페이스를 의존하기 때문에 수정할 곳이 전혀 없다.

 

	@Test
	fun test() {
		val boardService = BoardService(
			authService = FakeAuthService(), // 쌉 가능
		)
	}

 

 

몇 가지 함수들을 공통 한 컴포넌트에 묶고 싶으면 함수형 인터페이스 말고 그냥 인터페이스를 만들면 된다.

 

interface LoginHelper {
    fun loginBySocial()
    fun loginByOurService()

    @Component
    class Default : LoginHelper {
        override fun loginBySocial() {
            // logics 
        }

        override fun loginByOurService() {
            // logics
        }

    }
}

 

 

핵심은 단위테스트에 필요한 컴포넌트(요소)는 인터페이스를 의존하게끔 하라!이다.

 

이렇게 하면 엄청 유연하고 결합도 낮은 구조를 가져갈 수 있다.

반응형