[java & 디자인 패턴] singleton 패턴 학습 내용 정리
주제
싱글톤 패턴의 개념
싱글톤 패턴의 장점 및 주의사항(메모리 절약, 상태 공유, 멀티 스레드와 경합 현상)
멀티 스레드 앱에서의 싱글톤 패턴 예제
개념
배경
싱글톤 패턴을 사용하지 않는 객체 지향적 언어에서는 많은 객체 인스턴스를 생성하게 된다.
이럴 경우 아래 문제가 발생한다.
- 불필요한 객체 생성으로 메모리 낭비 및 성능 감소
- 공유되어야할 상태 간의 공유 불가
싱글톤 패턴이란
앱 내에서 오로지 하나 또는 최소한의 갯수인 n개의 인스턴스만을 만들어 전역에서 사용하는 방식이다.
또한, 싱글톤 객체의 생명주기 및 생성 제한은 해당 클래스의 내부로 추상화함으로써 외부에서는 신경쓰지 않아야 한다.
이 패턴은 주로 단일한 인스턴스가 존재해도 충분한 로깅 객체 등에 사용된다.
장점
- 제한된 객체 생성으로 성능 향상
- 상태 공유의 편리함
주의 사항
- 멀티 스레드 앱에서 동시에 싱글톤 객체에 접근하여 상태를 CRUD하는 경우, 경합 현상이 발생하거나 생성 시점에서 동시에 생성 요청이 들어와 여러 개가 생성될 수 있다.
- 위 문제를 해결하기 위해 synchronized 키워드를 사용하거나 double-checked locking 등을 사용할 수 있다.
예시
1. 간단한 싱글톤 생성
생성
db 인스턴스를 사용한다 가정하고 다음 같이 싱글톤 객체 디자인을 짰다.
- 싱글톤 디자인에서는 생성자를 외부에서 컨트롤할 필요가 없으므로 private 생성자를 선언
- static 변수에 database를 선언한다.
- getInstance static 메소드로 하나의 인스턴스만 생성 및 전역에서 참조가능하도록 하였다.
package prac;
public class Database {
private String name;
private static Database database;
private Database(String name) {
this.name = name;
}
public static Database getInstance(String name) {
if (database == null) {
System.out.println("db 생성");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
database = new Database(name);
}
return database;
}
public String getName() {
return name;
}
}
참고. private 변수를 의존성 주입 받는 생성자 생성 기능
상단의 메뉴 바 – source – generate constructor using field
검증
싱글톤 디자인이 잘 적용되었는지 확인하기 위해 debug 모드를 사용해보자.
- 아래처럼 여러 개의 변수에서 인스턴스를 호출하고 하단에 break point를 건다. break point는 이클립스 기준으로 해당 줄의 왼쪽 라인 넘버에서 우클릭 - toggle breakpoint를 클릭한다.
- f11로 디버그 모드를 실행한다.
package prac.case1.step1;
public class TestPattern1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
System.out.println("프로그램 시작");
Database d1 = Database.getInstance("1");
Database d2 = Database.getInstance("2");
Database d3 = Database.getInstance("3");
//break point 설정
System.out.println("database name:" + d1.getName() + d2.getName() + d3.getName());
}
}
- 디버그 모드가 실행되면 좌 우측으로 디버그 모드용 view 탭이 생성된다. 좌측은 현재 실행된 프로젝트와 breakpoint가 걸린 라인 넘버, 쓰레드를 알려준다.
- 우측(아래 사진)은 현재 스택에서 변수를 보여주는데, 아래를 보면 Database 객체 인스턴스를 호출한 d1, d2, d3가 모두 동일한 인스턴스를 할당받았음을 id로 알 수 있다.
- 우측 상단에 위치한 체크 표시한 아이콘으로 자바 view와 debug view를 토글할 수 있다.
- f2로 종료가능하다.
2. 문제 및 보완
현재 방식은 getInstance라는 정적 메소드를 실행할 시 분기 로직에 따라 최초에 객체가 존재하지 않을 시 인스턴스 생성 여부를 결정한다. 이 방식에는 다음 문제가 존재한다.
- 멀티 스레드에서 싱글톤 객체에 getInstance를 호출할 시, 동시에 실행된다. 그러면 각 스레드는 최초에 객체가 존재하지 않는 시점에 분기로직을 실행하여 의도치 않게 여러 객체가 생성될 것이다.
- 객체 인스턴스가 생성된 이후에도 분기 로직은 불필요하게 매번 실행된다.
문제가 될만한 예시를 보자.
- task라는 Runnable 람다식 작업을 만들었다. 이 작업은 getInstance 메소드로 db에 접근하니, 이론 상 하나의 Database 객체가 만들어져야 한다. 그리고 만들어진 객체의 이름을 반환한다.
- for 문으로 멀티 스레드 작업을 구현했다. 이론 상 싱글톤 객체가 성립하면 동일한 database 이름이10번 찍힐 것이다.
- 하지만, 실행 결과, 10개의 database 인스턴스가 생성되었으며 서로 다른 이름이 출력되었다.
package prac;
public class TestPatter2 {
private static int num = 0;
public static void main(String[] args) {
Runnable task = () -> {
try {
num++;
Database database = Database.getInstance("Database" + num);
System.out.println(database.getName());
} catch (Exception e) {
}
};
for (int i = 0; i < 10; i++) {
Thread t = new Thread(task);
t.start();
}
}
}
이처럼 싱글톤 디자인 패턴은 멀티 스레드를 고려하여 의도치 않게 여러 인스턴스가 생성되는 경우를 방지해야 한다.
해결 방법은 크게 두 가지가 있다.
- static 변수 선언 단계에서 인스턴스 초기화
- synchronized로 멀티 스레드 요청의 동기적 처리 강제
사례1. static 변수 선언 단계에서 초기화
static 변수는 jre에서 클래스 로더에 의해 클래스를 처음 참조할 때(static 메소드 및 변수, 생성자), 혹은 Class.forName() 메서드로 동적으로 로드할 때 클래스를 로드하며 생성된다.
단, 이 방식은 인스턴스 생성 시점이 static 변수가 생성되는 시점으로 강제된다는 단점이 있다.
package prac.case1.step1;
public class Database {
private String name;
private static Database database = new Database("product");
private Database(String name) {
super();
this.name = name;
}
public static Database getInstance(String name) {
return database;
}
public String getName() {
return name;
}
}
사례2. synchronized로 동기적 처리
synchronized를 이용하면 멀티 스레드 작업 상황에서도 비결정적인 결과를 방지할 수 있고 경합 현상을 막을 수 있다. 단, 여러 요청이 몰릴 경우 병목 현상이 발생한다.
package prac;
public class Database {
private String name;
private static Database database;
private Database(String name) {
super();
this.name = name;
}
public synchronized static Database getInstance(String name) {
if(database == null) {
try {
database = new Database(name);
} catch (Exception e) {
}
}
return database;
}
public String getName() {
return name;
}
}
사례3. loging to file 기능 만들기
로깅은 프로젝트 전반에서 사용되는 기능이다. 스프링 같은 프레임워크 대부분에서는 이를 제공하나 무거우므로, 간단하게 사용할 목적이면 직접 만들어 사용할 수 있다. 이 경우에는 싱글톤 디자인 패턴을 사용하기 적절하며, 멀티 스레드에서 동시에 요청이 들어오는 경우를 처리할 수 있어야 한다.
아래는 log를 txt 파일에 저장하는 클래스다.
이 경우, syncronized 키워드로 병목 현상을 감수하고 멀티 스레드를 순차적으로 처리하도록 하였다.
package prac.case2;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class LogWriter {
private static LogWriter logWriter = new LogWriter();
private static BufferedWriter bufferedWriter;
private LogWriter() {
try {
FileWriter fileWriter = new FileWriter("logTest.txt");
BufferedWriter bw = new BufferedWriter(fileWriter);
bufferedWriter = bw;
} catch (IOException e) {
e.printStackTrace();
}
}
public synchronized static LogWriter getInstance() {
return logWriter;
}
public void log(String str) {
try {
bufferedWriter.write(str);
bufferedWriter.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
protected void finalize() {
try {
bufferedWriter.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
참고. bufferedWritter
입출력 작업은 디스크와 교신하는 과정이므로 리소스를 많이 소모한다. 따라서, 잦은 호출은 성능에 좋지 않다.
bufferedWritter는 write 메소드로 입력받은 문자열을 바로 디스크에 기록하는 대신, 렘의 버퍼에 기록했다가 flush 메소드나 close 메소드를 통해 한꺼번에 기록한다.
이는 성능 향상에 도움을 주며 프로세스 오버헤드를 줄인다.
단, 사용자의 요청이 즉각 반영되어야 하는 곳에서는 사용하면 안되며, 비정상 종료 시 데이터가 유실될 가능성이 있다.
참고. finalize 메소드
객체가 가비지 콜렉터에 의해 수거될 때 실행되는 메소드.
리소스 해제 작업을 할 때 유용하다.