최대 N개로 객체 생성을 제한하는 패턴
객체가 너무 많아지면 컴퓨터 자원을 과도하게 사용하게 되고, 이는 프로그램 전체의 속도를 느리게 할 수 있다.
→ 개발자는 객체의 최대 개수를 제한할 필요가 생긴다.
여기서 중요한 것은 생성되는 객체의 최대 개수를 제한하는 데 있어 객체의 생성을 요청하는 쪽에서 일일이 신경쓰지 않아도 되도록 만들어주는 것이다.
싱글톤 패턴은 주로 하나의 객체로 데이터를 일관성 있게 유지하고 싶을 때 사용한다.
프로그램 내에서 아래와 같은 Database라는 객체를 이용한다고 가정해보자. (이 클래스를 하나씩 개선해나가보자)
public class Database {
private String name;
public Database(String name) {
super();
this.name = name;
}
public String getName() {
return this.name;
}
}
위의 클래스는 다음과 같이 이용될 수 있다.
public static void main(String[] args) {
Database database1 = new Database("첫번째 db");
Database database2 = new Database("두번째 db");
Database database3 = new Database("세번째 db");
System.out.println(database1);
System.out.println(database2);
System.out.println(database3);
}
// 실행 결과
Database@5e481248
Database@66d3c617
Database@63947c6b
이 경우 데이터를 생성할 때마다 새로운 객체가 생성됨을 확인할 수 있다.
이제 Database 객체의 생성을 하나로 제한해보도록 하자.
public class Database {
private static Database singleton;
private String name;
public Database(String name) {
super();
this.name = name;
}
public static Database getInstance(String name) {
if (singleton == null) { // singleton이 없으면 새로운 객체를 생성하게 됨.
singleton = new Database(name);
}
return singleton; // singleton이 존재하면 새로운 객체를 생성하지 않고 기존의 객체를 사용하게 됨.
}
public String getName() {
return this.name;
}
}
Database 내부 멤버 변수로 private static의 Database를 추가해주었다. 또한 생성자가 아닌 getInstance()라는 메서드를 통해 Database 객체를 반환하도록 하였다.
getInstance() 내부에는 sigleton을 확인하여 null인 경우에 새로 객체를 생성하고, null이 아닌 경우에는 기존 변수를 리턴한다.
public static void main(String[] args) {
Database database1 = Database.getInstance("첫번째 db");
Database database2 = Database.getInstance("두번째 db");
Database database3 = Database.getInstance("세번째 db");
System.out.println(database1 + " " + database1.getName());
System.out.println(database2 + " " + database2.getName());
System.out.println(database3 + " " + database3.getName());
Database database4 = new Database("네번째 db");
System.out.println();
System.out.println(database4 + " " + database4.getName());
}
// 실행결과
Database@5e481248 첫번째 db
Database@5e481248 첫번째 db
Database@5e481248 첫번째 db
Database@66d3c617 네번째 db
Database.getInstance()를 통해 생성한 database1, database2, database3을 확인해보면 모두 동일한 인스턴스를 가리킴을 알 수 있다. name 또한 최초에 입력받은 값을 갖는다. Database라는 객체의 생성을 하나로 제한시킨 것이다.
하지만 완전하지 않음을 알 수 있다. database4는 getInstacne()가 아닌 new를 이용해 객체를 생성하도록 했고, 결과를 찍어보면 기존의 인스턴스가 아닌 새로운 주소가 찍힌다.
**private** Database(String name) {
super();
this.name = name;
}
이러한 부분은 생성자를 private으로 변경해줌으로써 new를 이용한 객체 생성은 클래스 내부에서만 가능하도록 하여 해결 가능하다.
개선 1. 멀티 스레드 환경 고려하기
지금까지 작성한 코드는 멀티 스레딩 환경에서도 하나의 인스턴스만 유지할 수 있을까? 실제와 유사한 환경을 위해 Database의 생성자 코드를 다음과 같이 수정했다.
private Database(String name) {
try {
Thread.sleep(100); // 실제와 유사한 환경을 만들기 위함.
this.name = name;
} catch (Exception e) {
}
}
Database 객체를 최초로 생성할 때 0.1초가 걸리도록 설정했고, 아래 코드와 같이 10개의 스레드를 생성해서 돌려보았다.
public class Main {
private static int num = 0;
public static void main(String[] args) {
Runnable task = () -> {
try {
num++;
Database database = Database.getInstance(num + "번째 db");
System.out.println(database + " " + database.getName());
} catch (Exception e) {
}
};
for (int i = 0; i < 10; i++) {
Thread t = new Thread(task);
t.start();
}
}
}
// 실행 결과
Database@5f93f536 9번째 db
Database@1e542a06 6번째 db
Database@55f9807a 7번째 db
Database@8664408 8번째 db
Database@528ff726 1번째 db
Database@2244ae3b 5번째 db
Database@67c716ea 10번째 db
Database@5102a646 2번째 db
Database@4082029 4번째 db
Database@371adbca 3번째 db
실행 결과를 통해 알 수 있듯이 의도한 것과는 다르게 여러 개의 객체가 생성되고 있다. 한 스레드에서 최초로 인스턴스를 생성하기도 전에 다른 스레드의 코드가 실행이 되기 때문에 인스턴스가 만들어지지 않았다고 판단하여 각 스레드가 각각 객체를 생성하게 된 것이다. 멀티 쓰레드 환경에서 분명 문제가 있는 코드다.
이를 해결할 수 있는 방법으로 synchronized 를 이용하여 해당 영역을 하나의 스레드만 접근하도록 동시성을 제어하는 방법이 있다.
public synchronized static Database getInstance(String name) {
if (singleton == null) { // singleton이 없으면 새로운 객체를 생성하게 됨.
singleton = new Database(name);
}
return singleton; // singleton이 존재하면 새로운 객체를 생성하지 않고 기존의 객체를 사용하게 됨.
}
getInstance()에 synchronized를 붙이고 동일한 코드를 실행하면 다음과 같은 결과가 나온다.
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
Database@5f93f536 6번째 db
컴퓨터 환경에 따라 처음 시작되는 스레드는 매번 다르다. 하지만 10개의 스레드 모두 동일한 인스턴스를 참조하고 있음을 알 수 있다.
개선2. 좀 더 효율적인 코드 만들기
사실, synchronized는 비싼 비용이 들어가는 작업이다. 동시에 여러 개의 쓰레드가 접근하더라도 차례대로 줄을 서서 앞의 스레드가 끝나길 기다리게 하기 때문에 현재 위의 코드는 병목 현상이 일어난다. 또한 싱글턴이 null인지 비교하는 것은 최초에만 유효하기 때문에 비효율적이기까지 하다.
이러한 문제는 static이라는 특성을 이용해서 해결 가능하다. Static 변수는 프로그램이 실행되면 가장 먼저 실행된다. static으로 객체를 미리 생성해두면 null인지 비교하는 로직이 필요 없어진다.
public class Database {
private static Database singleton = new Database("미리생성된 db");
private String name;
...
public static Database getInstance(String name) {
return singleton;
}
}
위에서 돌렸던 main함수를 그대로 돌려보면 다음과 같이 하나의 객체만 생성되는 것을 확인할 수 있다.
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
Database@3d8fd87b 미리생성된 db
개선3. Singleton의 Lazy 초기화
아직 더 개선할 점이 남아있다. 이번에는 싱글톤 패턴을 적용한 객체의 생성 시점에 대해 생각해보자. static으로 객체를 생성하는 방법은 JVM의 클래스 로더에 의해 클래스가 로딩 될 때 싱글톤 객체가 생성된다.
이는 사용하기 전에 미리 생성해 놓는 방식으로 이른 초기화 방식이라고 한다. 이른 초기화 방식의 단점은 싱글톤으로 생성해둔 객체의 사용유무와 관계없이 클래스가 로딩되는 시점에 항상 객체가 생성된다는 점이다. 사용하지도 않는데 메모리를 잡고 있기 때문에 비효율적일 수 있다.
이를 개선하기 위해 늦은 초기화 방식(Lazy initialization)으로 코드를 아래와 같이 수정했다.
public class Database {
private String name;
...
public synchronized static Database getInstance(String name) {
return LazyHolder.INSTANCE;
}
public String getName() {
return this.name;
}
private static class LazyHolder {
private static final Database INSTANCE = new Database("늦은 초기화된 db");
}
}
이 방법은 priavate static class의 특성을 이용한 것이다. private static class는 static class이지만 메모리에 바로 올라가지 않는다. 누군가 getInstance()를 호출하는 그 시점에 메모리에 올라가게 된다.