꿈돌이랜드

앰비언트 컨텍스트(Ambient Context)는 왜 테스트 코드에서 안티 패턴일까 본문

Programming/Swift

앰비언트 컨텍스트(Ambient Context)는 왜 테스트 코드에서 안티 패턴일까

loinsir 2026. 1. 8. 23:48
반응형

코드를 작성하다 보면 종종 이런 형태를 보게 된다.

Date()
Locale.current
UserSession.shared.currentUser
AppEnvironment.apiClient

함수의 파라미터에는 아무것도 없지만,
코드 어딘가에는 당연히 존재한다고 가정되는 값들이 있다.

이 글에서는 이런 패턴을 앰비언트 컨텍스트(Ambient Context) 라고 부르고,
왜 이것이 테스트 코드를 작성할 때 안티 패턴으로 여겨지는지 정리해본다.


앰비언트 컨텍스트란 무엇인가

앰비언트 컨텍스트는
명시적으로 전달되지 않지만, 전역적으로 접근 가능한 실행 문맥 정보를 의미한다.

주로 다음과 같은 형태로 나타난다.

  • 전역 변수
  • 싱글톤 객체
  • static 프로퍼티
  • Thread-local / Task-local 값
  • 플랫폼이 암묵적으로 제공하는 상태

예시

enum AppEnvironment {
    static var currentUser: User?
    static var apiClient: APIClient = .live
}
func loadProfile() {
    guard let user = AppEnvironment.currentUser else { return }
    AppEnvironment.apiClient.fetchProfile(user.id)
}

loadProfile()는 아무런 인자를 받지 않지만,
실제로는 다음 값들에 의존하고 있다.

  • 현재 로그인된 사용자
  • APIClient의 구현체

이 의존성들은 코드 표면에는 드러나지 않는다.
이 점이 앰비언트 컨텍스트의 가장 큰 특징이다.


왜 이런 패턴이 사용될까

앰비언트 컨텍스트는 분명 장점이 있다.

  • 파라미터 전달이 줄어든다
  • 어디서든 접근 가능해 사용이 편하다
  • 레거시 코드와 잘 맞는다
  • 플랫폼 자체가 이런 방식을 제공하기도 한다

문제는 이 편의성이 테스트 코드에서 그대로 비용으로 돌아온다는 점이다.


테스트 코드에서 문제가 되는 이유

1. 숨겨진 의존성

func testLoadProfile() {
    loadProfile()
}

이 테스트만 보고는 다음을 알 수 없다.

  • 어떤 사용자 상태를 전제로 하는지
  • 실제 API 호출이 일어나는지
  • 어떤 환경에서 동작하는지

결국 테스트는 이렇게 작성된다.

func testLoadProfile() {
    AppEnvironment.currentUser = User(id: 1)
    AppEnvironment.apiClient = .mock

    loadProfile()
}

함수 시그니처만 보고는
어떤 준비가 필요한지 전혀 알 수 없다.


2. 테스트 간 결합

func testA() {
    AppEnvironment.apiClient = .mockA
}

func testB() {
    // 기본 apiClient를 기대
}
  • 테스트 실행 순서에 따라 결과가 달라질 수 있다
  • 하나의 테스트가 전역 상태를 변경하면 다른 테스트에 영향을 준다
  • 병렬 테스트 실행이 어려워진다

테스트는 독립적으로 실행될 수 있어야 하지만,
앰비언트 컨텍스트는 이를 깨뜨린다.


3. 테스트 의도를 읽기 어렵다

loadProfile()

이 한 줄의 테스트 코드로는 다음을 파악할 수 없다.

  • 어떤 APIClient를 사용했는지
  • 어떤 사용자 상태인지
  • 어떤 환경을 가정하는지

테스트 코드는 동작에 대한 사양 역할을 해야 하지만,
앰비언트 컨텍스트는 테스트를 설명하기 어렵게 만든다.


4. Mock과 상태 복구 비용 증가

전역 상태를 사용하는 테스트는 항상 복구 코드가 필요하다.

override func tearDown() {
    AppEnvironment.apiClient = .live
}
  • 복구를 빠뜨리면 테스트가 불안정해진다
  • 테스트 수가 늘어날수록 관리 비용이 커진다

Mocking 자체보다
전역 상태 관리가 더 큰 문제가 된다.


5. 결정론적이지 않은 코드

func isAdult() -> Bool {
    AppEnvironment.currentUser?.age ?? 0 >= 20
}

입력이 없는데 결과가 달라질 수 있다.
이런 함수는 테스트하기 어렵고, 동작을 예측하기도 힘들다.


대안

1. 명시적인 의존성 주입

struct ProfileService {
    let apiClient: APIClient
    let currentUser: User
}
let service = ProfileService(
    apiClient: .mock,
    currentUser: User(id: 1)
)

의존성이 코드에 그대로 드러나고,
테스트 의도도 명확해진다.


2. 파라미터로 컨텍스트 전달

func loadProfile(user: User, apiClient: APIClient)

가장 단순하면서도 테스트 친화적인 방식이다.


3. 스코프가 제한된 컨텍스트

완전한 전역 대신,
명시적으로 범위를 제한한 컨텍스트를 사용할 수도 있다.

withDependencies {
    $0.apiClient = .mock
} operation: {
    // 이 블록 안에서만 유효
}

전역처럼 보이지만,
테스트에서는 비교적 안전하게 사용할 수 있다.


마무리

앰비언트 컨텍스트는 코드를 작성하기는 편하게 만든다.
하지만 그 편의성은 테스트 코드의 복잡도로 이어진다.

테스트를 중요하게 생각한다면,
의존성은 숨기기보다 드러내는 쪽이 장기적으로 훨씬 낫다.

반응형