Program 설계시 문제를 객체 간 상호 관계로 해결해 만든 protocol
class 1개에 무조건 1개의 인스턴스만 사용하는 디자인 패턴으로, 여러 모듈이 무조건 한개의 인스턴스를 공유한다. (동일한 힙 메모리 공간을 가리킴)
메모리 절약
인스턴스를 하나만 생성하니까 불필요한 객체를 생성하는 것을 막아준다.
전역 접근 제공
어디서든 동일한 인스턴스를 가져와서 사용할 수 있다.
상태 일관성 유지
한 곳에서만 상태가 바뀌기 때문에 설정/데이터가 일관된다.
리소스 관리 용이
DB 연결, 파일 핸들링 등 공유 자원 하나로 제어가 가능하다.
TDD(Test Driven Development)에서 걸림돌이다. 단위 테스트가 독립적이지 못하고, 어떤 순서로도 실행가능한 상태가 되지 못한다. 이유는 전역 인스턴스를 쓰기 때문에 여러 모듈이 서로 너무 종속되어 있어서 의존성이 높기 때문에 따로 떼서 테스트하기 힘들기 때문이다.
public class UserService {
private Logger logger = Logger.getInstance(); // 직접 Logger 싱글톤을 사용
public void createUser(String name) {
logger.log("User created: " + name);
}
}
java에서는 싱글톤 패턴을 중복 클래스로 구현할 수 있다. 다른 곳에서 마음대로 객체를 생성할 수 없게 private
생성자로 만들어준다.
의존성 주입(DI)을 사용하면 객체 간의 결합도를 낮출 수 있어, 더 유연하고 확장 가능한 구조를 만들 수 있다.
특히, 모듈 간 의존성이 줄어들기 때문에 단위 테스트가 훨씬 쉬워진다.
class Logger {
public void log(String msg) {
System.out.println("[LOG] " + msg);
}
}
class UserService {
private Logger logger = new Logger(); // 직접 생성
public void createUser(String name) {
logger.log("User created: " + name);
}
}
public class Main {
public static void main(String[] args) {
UserService userService = new UserService();
userService.createUser("alice");
// 테스트하고 싶어도 logger에 접근할 방법 없음!
}
}
위의 출력만 보면 문제없어 보이지만, 로직이 커지면 logger 호출이 여러번 일어난다. 다른 모듈에서도 다 logger를 호출하면 내가 확인하고 싶은 UserService
의 로그 결과만을 확인하기 힘들다.
또한, logger가 실패하면 테스트 전체가 실패해 실제 검증하고 싶은 로직과 무관하게 테스트가 깨질 수 있다.
// Logger 클래스
class Logger {
public void log(String msg) {
System.out.println("[LOG] " + msg);
}
}
// 테스트용 가짜 Logger
class FakeLogger extends Logger {
public boolean called = false;
public String lastMessage = "";
@Override
public void log(String msg) {
called = true;
lastMessage = msg;
}
}
// 의존성 주입을 받는 UserService
class UserService {
private Logger logger;
public UserService(Logger logger) {
this.logger = logger;
}
public void createUser(String name) {
logger.log("User created: " + name);
}
}
// 테스트 실행
public class Main {
public static void main(String[] args) {
FakeLogger fakeLogger = new FakeLogger();
UserService userService = new UserService(fakeLogger);
userService.createUser("alice");
// 코드로 테스트 가능!
System.out.println("[TEST] log() called: " + fakeLogger.called);
System.out.println("[TEST] message: " + fakeLogger.lastMessage);
if (fakeLogger.called && fakeLogger.lastMessage.equals("User created: alice")) {
System.out.println("test success");
} else {
System.out.println("test failed");
}
}
}
위 코드처럼 의존성 주입을 적용하면, 로그를 찍는 것 뿐 아니라 logger.log()
가 진짜 호출 됐는지 어떤 메시지가 찍혔는지 코드로 검증할 수 있다.
FakeLogger
를 통해 “로직이 의도대로 작동했는가”를 코드로 자동 검증할 수 있는 테스트가 가능해진 것이다.
// 외부에서 Logger를 주입받는 구조
Logger logger = new FakeLogger(); // 또는 new Logger(); 의존성 주입 part
UserService userService = new UserService(logger);
userService.createUser("alice");
class UserService {
private Logger logger;
public UserService(Logger logger) { // 🔥 생성자를 통해 주입
this.logger = logger;
}
public void createUser(String name) {
logger.log("User created: " + name);
}
}
이처럼 Logger 인스턴스를 직접 만들지 않고 외부에서 주입하면, 테스트 시에는 FakeLogger를, 실제 환경에서는 Logger를 주입해 유연한 테스트와 확장 가능한 설계를 동시에 만족할 수 있다.
class UserService {
private Logger logger = new Logger(); // 직접 생성
// 외부에서 logger를 주입할 수 없음!
}
싱글톤 패턴과 의존성 주입은 각각 객체를 관리하고 결합도를 낮추기 위한 중요한 설계 전략이다.
두 개념을 적절히 조합하면 효율적인 리소스 관리와 유지보수에 강한 코드 구조를 동시에 얻을 수 있다.
결국 좋은 설계란, 테스트 가능하고 확장 가능한 코드를 만드는 것이다.