
0편에서 이야기했듯이 디자인 패턴은 문법 암기보다 변화에 덜 흔들리는 구조를 만드는 사고법에 가깝습니다. 그래서 코틀린 싱글톤 패턴을 볼 때도 문법보다 설계 이유를 먼저 봐야 합니다. 그런데 코틀린에서는 object가 워낙 편해서 Singleton도 그냥 문법처럼 받아들이기 쉽습니다. 이번 글은 바로 그 오해를 실무 기준으로 정리하는 데 초점을 둡니다.
결론부터 말하면 코틀린의 object는 Singleton 구현을 쉽게 만들지만, 언제 Singleton을 써야 하는지까지 대신 판단해주지는 않습니다. 그래서 이번 글에서는 Singleton의 원래 의도, object와 companion object 차이, 그리고 DI와 Factory Method를 언제 검토해야 하는지를 함께 보겠습니다.
핵심 결론
Singleton을 검토할 때 가장 먼저 물어야 할 질문은 이 객체는 정말 하나만 존재해야 하는가입니다. 생성해서 넘기기 귀찮다, 여러 곳에서 바로 쓰고 싶다, 일단 빨리 붙이기 쉽다 같은 이유가 먼저 나오면 대부분 Singleton이라기보다 편한 전역 상태에 가깝습니다.
- 위험 신호: 생성해서 넘기기 귀찮다
- 위험 신호: 여러 곳에서 바로 쓰고 싶다
- 위험 신호: 지금은 하나면 충분해 보인다
- 상대적으로 정당한 경우: 읽기 전용 설정 접근기, 빌드 정보 제공기, 아주 작은 레지스트리
Singleton의 기준은 편의가 아니라 단일성의 이유가 분명한가입니다.
Singleton의 의도
- 어떤 클래스의 인스턴스를 하나만 유지하고 싶다
- 그 인스턴스에 접근할 수 있는 공통 진입점이 필요하다
- 핵심은 전역 호출의 편의가 아니라 객체 수 제한의 이유다
예를 들어 빌드 정보 제공기처럼 앱 안에서 값이 크게 달라질 이유가 없는 대상은 Singleton과 잘 맞습니다. 반대로 로그 전송기, 결제 처리기, 저장소 접근기처럼 정책이 바뀌기 쉬운 대상은 단순히 하나만 쓴다는 이유로 Singleton을 택하면 결합도가 커지기 쉽습니다.
object와 Singleton의 연결점
Kotlin 공식 문서의 object declarations는 object가 클래스를 정의하면서 인스턴스도 함께 만드는 방식이며 singleton 구현에 유용하다고 설명합니다. 그래서 자바의 private constructor, static field, getInstance() 패턴을 코틀린에서는 훨씬 짧게 표현할 수 있습니다.
object AppConfig {
private val values = mutableMapOf<String, String>()
fun put(key: String, value: String) {
values[key] = value
}
fun get(key: String): String? = values[key]
}다만 여기서 꼭 끊어서 봐야 합니다. object는 단일 인스턴스를 표현하는 문법이고, Singleton은 정말 단일 인스턴스가 필요한 상황을 고르는 설계 판단입니다. 문법이 쉬워졌다고 설계 판단까지 쉬워지는 것은 아닙니다.
전역 상태로 흐르는 패턴
object EventLogger {
fun log(message: String) {
println("LOG: $message")
}
}
class OrderService {
fun completeOrder() {
EventLogger.log("order completed")
}
}처음에는 이 구조가 정말 편합니다. 하지만 시간이 조금만 지나도 콘솔 대신 외부 수집기로 보내고 싶다, 환경별로 정책을 나누고 싶다, 테스트에서는 가짜 로거를 넣고 싶다 같은 요구가 붙습니다. 그 순간부터 문제는 object 문법이 아니라 OrderService가 EventLogger라는 전역 구체 구현에 직접 묶여 있다는 점입니다.
Singleton이 위험한 순간은 객체가 하나라는 사실보다, 그 하나의 구체 구현에 시스템 전체가 직접 달라붙기 시작할 때입니다.
수정 시나리오
로그 시스템 변경
처음에는 println이면 충분했지만, 운영에 들어가면 Sentry, Firebase Analytics, 사내 로그 서버 연동이 필요할 수 있습니다. 전역 object 하나에 구현을 계속 얹기 시작하면 로깅 방식 변경이 단순한 내부 수정이 아니라 전체 호출부의 기대치까지 굳히는 방향으로 흘러가기 쉽습니다.
환경별 정책 분기
개발 환경에서는 자세한 로그가 필요하고, 운영 환경에서는 민감 정보를 마스킹해야 하며, 테스트 환경에서는 아예 출력이 없어야 할 수 있습니다. 그러면 전역 객체 하나가 환경 정책, 출력 정책, 보안 정책까지 같이 떠안게 됩니다.
테스트 격리
주입받는 구조가 아니면 테스트에서 가짜 로거를 넣기가 어렵습니다. 그 결과 전역 상태 초기화 코드가 늘어나거나, 테스트 순서에 따른 간섭이 생기거나, 검증 자체를 포기하게 되는 경우가 많습니다.
모듈 전반 의존
주문, 결제, 알림, 배치, 관리자 모듈이 모두 같은 전역 로거를 직접 호출하기 시작하면, 나중에 로깅 구조를 바꾸는 일은 더 이상 작은 수정이 아닙니다. 전역 object 하나가 변경 전파의 중심점이 되기 쉽습니다.
DI로 바꿨을 때의 차이
interface Logger {
fun log(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) {
println("LOG: $message")
}
}
class OrderService(
private val logger: Logger
) {
fun completeOrder() {
logger.log("order completed")
}
}이 구조에서는 OrderService가 전역 객체를 직접 모르는 대신 Logger라는 추상화만 압니다. 그래서 파일 로거를 추가하거나, 운영 전용 로거로 바꾸거나, 테스트에서 FakeLogger를 넣는 일이 훨씬 쉬워집니다.
핵심은 이것입니다. 인스턴스를 하나만 쓰는 것과 전역 구체 구현에 직접 의존하는 것은 다른 문제입니다. DI를 써도 런타임에서는 객체를 하나만 둘 수 있고, 동시에 구조는 더 유연하게 유지할 수 있습니다.
개념 구분표
object
- 무엇인가: 단일 인스턴스 자체를 표현하는 코틀린 문법
- 잘 맞는 경우: 정말 하나만 존재해야 하는 작고 명확한 책임
- 대표 예: 읽기 전용 설정 접근기, 빌드 정보 제공기
- 오해: 여러 곳에서 쓰이니 일단 object로 두자
companion object
- 무엇인가: 클래스 수준 함수와 프로퍼티를 두는 자리
- 잘 맞는 경우: 팩토리 함수, 상수, named constructor 스타일 API
- 대표 예: User.create(name)
- 오해: companion object가 있으면 그 클래스도 Singleton이다
DI
- 무엇인가: 의존성을 외부에서 조립하고 교체 가능하게 만드는 방식
- 잘 맞는 경우: 테스트, 구현 교체, 환경별 설정이 중요한 서비스
- 대표 예: Logger, PaymentGateway, Repository
- 오해: 인스턴스를 하나만 쓰고 싶으면 DI를 못 쓴다
Factory Method
- 무엇인가: 객체 생성 규칙과 생성 책임을 캡슐화하는 패턴
- 잘 맞는 경우: 어떤 구현을 만들지 선택해야 하거나 생성 규칙을 감추고 싶을 때
- 대표 예: 입력 조건에 따라 다른 구현체 생성
- 오해: 객체를 하나만 두면 무조건 Factory Method라고 부른다
object는 하나의 객체를 표현하고, companion object는 클래스 수준 진입점을 제공하고, DI는 교체 가능성을 확보하고, Factory Method는 생성 규칙을 숨깁니다.
companion object의 역할
class User private constructor(
val name: String
) {
companion object {
fun create(name: String): User {
require(name.isNotBlank())
return User(name.trim())
}
}
}User.create("Bora")처럼 부르면 static 메서드처럼 보여도, 실제 의미는 User를 만드는 클래스 수준 진입점입니다. 여러 번 호출하면 여러 User 인스턴스가 생기므로, companion object가 있다고 해서 그 클래스가 Singleton이 되는 것은 아닙니다.
Singleton이 맞는 경우
- 시스템 안에서 정말 하나만 존재해야 한다
- 상태가 작고 단순하다
- 생애주기가 앱 또는 프로세스 전역과 거의 같다
- 구현 교체 가능성이 높지 않다
- 테스트 격리 비용이 크지 않다
앱 버전과 빌드 번호를 제공하는 빌드 정보 제공기, 읽기 전용 설정 접근기, 프로세스 범위의 작은 레지스트리처럼 왜 하나만 있어야 하는지 설명이 가능한 대상은 Singleton과 잘 맞습니다.
다른 선택지가 더 나은 경우
- 가변 상태가 계속 커질 때
- 구현 교체 가능성이 높을 때
- 테스트 격리가 중요할 때
- 사실은 생성 규칙을 감추고 싶은 문제일 때
이런 경우에는 Singleton보다 DI, 인터페이스 분리, 혹은 Factory Method가 더 오래 버티는 구조가 될 가능성이 큽니다. 특히 객체를 하나만 두고 싶은 것이 아니라 생성 규칙을 감추고 싶은 경우라면 문제의 본질은 Singleton이 아니라 생성 책임 분리입니다.
판단 체크리스트
- 이 객체는 정말 하나만 존재해야 하는가
- 그 이유가 도메인 제약인가, 아니면 그냥 편의성인가
- 전역 접근이 꼭 필요한가, 아니면 주입으로 충분한가
- 나중에 구현을 바꾸거나 테스트할 가능성이 큰가
- 사실은 객체 수보다 생성 규칙이 더 중요한 문제인가
많은 object는 사실 Singleton이 아니라 전역 유틸, 팩토리, 혹은 DI 컨테이너가 관리해야 할 서비스에 더 가깝습니다. 그래서 중요한 것은 object로 만들 수 있는 것과 object로 만들어야 하는 것을 구분하는 감각입니다.
마무리
이번 글의 결론은 단순합니다. 코틀린의 object는 Singleton 구현을 쉽게 만들어주지만, Singleton 사용 판단까지 대신해주지는 않습니다. 그리고 companion object는 Singleton의 대체어가 아니라 클래스 수준 생성 진입점이나 유틸리티를 두는 자리로 이해하는 편이 정확합니다.
관련해서 먼저 읽어두면 좋은 글은 코틀린 디자인 패턴 시리즈(0) – 왜 아직도 디자인 패턴을 배워야 할까, 객체지향 설계에서 결합도가 중요한 이유, 상속 vs 조합: inheritance와 composition 비교입니다. 외부 참고 자료로는 Kotlin object declarations 공식 문서와 Singleton 패턴 레퍼런스를 함께 보면 좋습니다.