애플리케이션을 만들다보면 어떤 인스턴스가 애플리케이션에 하나만 존재해야 하는데 이럴때 사용하는것이 싱글턴이며, 싱글턴이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
그런데 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워 질 수 있다.
왜냐하면 타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜(mock) 구현으로 대체할 수 없기 때문이다.
위 정보를 토대로 싱글턴을 만드는 일반적인 방식과 문제점을 살펴보자.
1. private 생성자 + public static 멤버 변수
class Elvis{
public static final Elvis INSTANCE = new Elvis();
private Elvis(){}
public void leaveTheBuilding(){}
}
// client
public static void main(String[] args) {
Elvis elvis1 = Elvis.INSTANCE;
Elvis elvis2 = Elvis.INSTANCE;
System.out.println(elvis1.equals(elvis2)); //true
}
간략하게 private 생성자로 외부 생성을 막고, static final변수에 인스턴스를 할당한다.
일반적으로 클래스안에 자기 자신을 생성하는 변수를 두는 것은 재귀적 참조를 만들수 있어 이상적이지 않지만
싱글턴 패턴에선 하나의 인스턴스만 가지게 하도록 하기 때문에 위처럼 설계가 가능하다.
2. private 생성자 + 정적 팩토리 메서드로 멤버 변수 접근
class Elvis{
private static final Elvis INSTANCE = new Elvis();
private Elvis(){}
public static Elvis getInstance(){
return INSTANCE;
}
public void leaveTheBuilding(){}
}
//client
public static void main(String[] args) {
Elvis elvis1 = Elvis.getInstance();
Elvis elvis2 = Elvis.getInstance();
System.out.println(elvis1.equals(elvis2)); // true
}
인스턴스 접근 변수를 private으로 선언하고, 정적 팩토리 메서드로 가져오게 하는 방식이다.
위 방식은 1번보다 장점이 몇 가지 있다.
장점1. API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다.
예를들어 클라이언트 코드에서 아래와 같이 사용중이라고 가정하자.
public static void main(String[] args) {
Elvis elvis1 = Elvis.getInstance();
elvis1.leaveTheBuilding();
}
위 상태에서 싱글턴이 아니게 변경하려면 클라이언트 코드가 아닌 정적 팩토리 메서드만 변경시켜주면 된다.
class Elvis{
private static final Elvis INSTANCE = new Elvis();
private Elvis(){}
public static Elvis getInstance(){
//return INSTANCE; // 변경 전
return new Elvis(); // 변경 후
}
public void leaveTheBuilding(){}
}
변경 후에 팩토리 메서드에서 직접 생성하도록 수정하도록 하였다.
장점2. 정적 팩토리를 제네릭 싱글턴 팩토리로 만들 수 있다.
💡제네릭 싱글톤 팩토리
제네릭+싱글톤 팩토리로 제네릭을 사용하여 하나의 싱글톤 인스턴스를 다양한 타입으로 제공하는 정적 팩토리 메서드를 의미한다.
사용 이유
보통 싱글톤 클래스는 하나의 인스턴스만 제공하기 때문에 특정 타입에 고정되기 쉽다. 따라서 싱글턴 팩토리를 사용하면 제네릭 타입을 활용하여 다양한 타입에 대해 단일 인스턴스를 제공할 수 있다.
아래 코드는 타 강의에서 가져온 예시이다.
public class MetaElvis<T> {
private static final MetaElvis<Object> INSTANCE = new MetaElvis<>();
private MetaElvis() { }
@SuppressWarnings("unchecked") // 제네릭 싱글턴 팩토리
public static <E> MetaElvis<E> getInstance() {
return (MetaElvis<E>) INSTANCE;
}
public void say(T t) {
System.out.println(t);
}
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
}
싱글턴은 동일하게 유지한채로 달라진것은 여러타입을 가질 수 있는 제네릭이 추가된것 말고는 없다.
제네릭 문법이 생소한데 팩토리 메서드 구조만 조금만 짚고 가보자
@SuppressWarnings("unchecked") // 제네릭 싱글턴 팩토리
public static <E> MetaElvis<E> getInstance() {
return (MetaElvis<E>) INSTANCE;
}
<E>는 해당 메서드에서만 사용되는 로컬 타입 파라미터이다.
말그대로 파라미터로, <A>던지 <B> 뭐든 가능하다. 다만 <T> 또는 <E>로 많이 나타내는 것은
개발자들 간의 관습적인 표현으로 T는 Type을 의미하고 E는 Element를 의미한다.
public class main {
public static void main(String[] args) {
MetaElvis<String> elvis1 = MetaElvis.getInstance();
MetaElvis<Integer> elvis2 = MetaElvis.getInstance();
System.out.println(elvis1.equals(elvis2)); // true (제네릭 타입이 다르기 때문에 equals로 비교)
}
}
인스턴스는 동일하지만 각각의 타입은 다른것을 확인할 수 있다.
장점3. 정적 팩토리의 메서드 참조를 공급자(Supplier)로 사용할 수 있다.
이는 달리말해 정적 팩토리 메서드를 Supplier과 같은 함수형 인터페이스에 전달할 수 있다라는 의미이다.
@FunctionalInterface
public interface Supplier<T> {
T get();
}
위 Supplier<T>는 자바에서 타입 T의 객체를 반환하는 함수형 인터페이스이다.
public interface Person {
void run();
}
run() 메서드를 가지는 Person 인터페이스를 만든다.
class Elvis implements Person{
private static final Elvis INSTANCE = new Elvis();
private Elvis(){}
public static Elvis getInstance(){
return new Elvis();
}
public void leaveTheBuilding(){}
@Override
public void run() {
System.out.println("run start");
}
}
Elvis는 Person인터페이스를 구현한다.
public class Gym {
void exercise(Supplier<Elvis> elvisSupplier){
Elvis elvis = elvisSupplier.get();
elvis.run();
}
}
Gym 클래스는 exercise() 메서드를 가진다. 이때 인수로 함수형 인터페이스 Supplier를 받도록 한다.
public static void main(String[] args) {
Gym gym = new Gym();
gym.exercise(Elvis::getInstance);
}
외부에서 Elvis::getInstance와 같이 메서드 참조로 사용할 수 있다.
이제 가장 위에서 테스트가 어렵다고 언급하였는데 이와 관련한 단점들을 살펴보자.
* 1,2번 방식의 단점
단점1. 싱글톤을 사용하는 클라이언트 코드를 테스트하기 어려워진다.
예를 들어, 데이터베이스 연결을 관리하는 DatabaseConnectionManager 싱글톤 클래스가 있다고 가정하자.
public class DatabaseConnectionManager {
private static final DatabaseConnectionManager INSTANCE = new DatabaseConnectionManager();
private DatabaseConnectionManager() {
// 실제 DB 연결 설정
}
public static DatabaseConnectionManager getInstance() {
return INSTANCE;
}
public void connect() {
System.out.println("Connecting to the real database...");
}
}
이제 위 클래스를 통해 테스트 해보고 싶은 어떤 비즈니스 로직이 있다고 가정해보자.
public class SomeService {
public void performDatabaseOperation() {
DatabaseConnectionManager connection = DatabaseConnectionManager.getInstance(); // 변경 불가
connection.connect();
... // 여러 비즈니스 로직 수행
}
}
위 구조에선 항상 DatabaseConnectionManager의 인스턴스를 사용하기 때문에, 테스트 환경에서 DatebaseConnectionManager를 모의 객체로 바꿀 수 없다.
실제 테스트 코드는 아래와 같이 작성될 것이다.
public class SomeServiceTest {
public static void main(String[] args) {
SomeService service = new SomeService();
// 테스트 환경에서 performDatabaseOperation 호출 시 실제 DB 연결이 시도됨
service.performDatabaseOperation(); // "Connecting to the real database..." 출력
}
}
이제 이를 해결하기 위해 인터페이스를 생성하자.
public interface DatabaseConnection {
void connect();
}
public class DatabaseConnectionManager implements DatabaseConnection {
private static final DatabaseConnectionManager INSTANCE = new DatabaseConnectionManager();
private DatabaseConnectionManager() {}
public static DatabaseConnectionManager getInstance() {
return INSTANCE;
}
@Override
public void connect() {
System.out.println("Connecting to the real database...");
}
}
DatabaseConnectionManager는 인터페이스를 구현한다.
public class SomeService {
public void performDatabaseOperation(DatabaseConnection databaseConnection) {
connection.connect();
... // 여러 비즈니스 로직 수행
}
}
테스트할 서비스 메서드는 파라미터로 인터페이스를 받게한다.
public class SomeServiceTest {
public static void main(String[] args) {
// 모의 객체 생성
DatabaseConnection mockConnection = new MockDatabaseConnection();
SomeService service = new SomeService();
// performDatabaseOperation 호출 시 모의 객체 전달
service.performDatabaseOperation(mockConnection); // "Pretending to connect to a database for testing..." 출력
}
}
서비스 메서드에 인터페이스를 넘김으로써 실제 DB에 연결하지 않고도 로직을 검증할 수 있게된다.
단점2.리플렉션으로 private 생성자를 호출할 수 있다.
public static void main(String[] args) {
try {
Constructor<Elvis> defaultConstructor = Elvis.class.getDeclaredConstructor();
defaultConstructor.setAccessible(true); // private 생성자 호출 가능하게 설정
Elvis elvis1 = defaultConstructor.newInstance();
Elvis elvis2 = defaultConstructor.newInstance();
System.out.println(elvis1 == elvis2); // false
} catch (NoSuchMethodException e) {
...
} ...
}
리플렉션을 사용하면 private 생성자에 접근할 수 있기에 싱글톤으로 설계해도 위 처럼 여러개의 인스턴스가 만들어질 수 있다.
따라서 해결 방안으로 생성자에서 이를 방어할 수 있다.
class Elvis implements Person{
private static final Elvis INSTANCE = new Elvis();
private static boolean isCreated = false;
private Elvis(){
if(isCreated){
throw new UnsupportedOperationException("...");
}
isCreated = true;
}
public static Elvis getInstance(){
return INSTANCE;
}
public void leaveTheBuilding(){}
@Override
public void run() {
System.out.println("run start");
}
}
isCreated라는 플래그를 두어 생성한 이력이 있다면 에러를 내게하였다.
위 처럼하게 되면 리플렉션을 통해 싱글톤을 깨뜨릴 수 없게된다. 다만 코드가 간결해지는 장점은 사라진다.
단점3. 역직렬화 할 때 새로운 인스턴스가 생길 수 있다.
💡직렬화
직렬화는 객체를 바이트 스트림으로 상호 변환하는 기술이다.
변환한 바이트 스트림은 어떤 파일로 저장하거나 네트워크를 통해 다른 시스템으로 전송할 수 있다.
간단하게 이사를 가는 것으로 비유할 수 있다.
짐을 포장하는 과정을 직렬화 , 이삿짐을 푸는 과정을 역 직렬화 라고 생각하면 된다.
보통 10여년 전에는 자바 객체를 직렬화하거나 역직렬화 하는 일이 많았지만 오늘날에는 JSON을 많이 사용한다.
받아서 처리할 곳이 JVM 이라면 직렬화가 유용하지만 다른 시스템이라면 굳이 사용하지 않는다.
public static void main(String[] args) {
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("elvis.obj"))) {
out.writeObject(Elvis.getInstance());
} catch (IOException e) {
e.printStackTrace();
}
try (ObjectInput in = new ObjectInputStream(new FileInputStream("elvis.obj"))) {
Elvis elvis3 = (Elvis) in.readObject();
System.out.println(elvis3 == Elvis.getInstance()); // false , 역직렬화시 새로운 인스턴스가 생긴다.
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
직렬화를 통해 객체 정보를 저장할 수 있고, 역직렬화를 통해 저장되어있는 객체의 정보를 읽어올 수 있다.
그런데 역직렬화시 새로운 인스턴스가 생기게 된다.
따라서 아래와 같이 해결할 수 있다.
먼저 , 직렬화를 하려면 Serializable 인터페이스를 구현해야 한다.
class Elvis implements Person, Serializable {
private static final Elvis INSTANCE = new Elvis();
private static boolean isCreated = false;
private Elvis(){
if(isCreated){
throw new UnsupportedOperationException("...");
}
isCreated = true;
}
public static Elvis getInstance(){
return INSTANCE;
}
public void leaveTheBuilding(){}
@Override
public void run() {
System.out.println("run start");
}
private Object readResolve() {
return INSTANCE;
}
}
역직렬화시 호출되는 메서드인 readResolve()내부에서 기존에 사용하던 인스턴스를 반환하게 하면 된다.
위 방법을 따르면 역직렬화시 싱글턴이 깨지는것을 방지할 수 있지만 단점2번과 마찬가지로 간결한 코드의 장점이 사라지게 된다.
3. 열거 타입으로 싱글톤 생성
마지막 세번째 방법은 열거 타입으로 싱글톤을 생성하는 방법이다.
열거 타입으로 싱글톤을 생성하면 리플렉션 및 직렬화 & 역직렬화에 대해 안전하다.
Enum 타입은 리플렉션을 내부코드로 막아 놓았기 때문에 생성자를 불러오려고 하면 에러가 발생한다.
public enum Elvis implements Person{
INSTANCE;
public void leaveTheBuilding(){
}
@Override
public void run() {
}
}
enum은 인터페이스 또한 구현할 수 있기때문에 테스트 코드 작성시의 문제점까지 해결할 수 있다.
'Java' 카테고리의 다른 글
Java) 인스턴스화를 막으려거든 private 생성자를 사용하라 (1) | 2024.11.07 |
---|---|
Java) 생성자에 매개변수가 많다면 빌더를 고려하라 (4) | 2024.11.04 |
Java) 생성자 대신 정적 팩토리 메서드 (1) | 2024.10.30 |