[JAVA] 싱글톤 패턴

MINJEE·2024년 1월 15일

자바 공부

목록 보기
1/1
post-thumbnail

싱글톤 패턴 (Singleton Pattern)

:  생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고 
최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴하는 디자인 패턴

애플리케이션 전체에서 단 한 개의 객체만 생성해서 사용하고 싶을 때 싱글톤 패턴을 적용할 수 있다.

생성자를 private로 접근 제한해서 외부에서 new 연산자로 생성자를 호출할 수 없도록 막는 것이다.

나는 싱글톤 패턴을 정보처리기사 자격증을 준비하면서 처음 접했고, 자바 공부를 본격적으로 시작하면서 디자인 패턴에 대해 공부해야겠다는 마음을 먹었다.

  • 디자인 패턴의 생성 패턴 중 하나이다.
  • 여러 컴포넌트 간의 자원 공유, 객체 풀 관리 등 다양한 상황에서 활용될 수 있다.
  • 전역적인 접근이 필요한 경우에 유용한 디자인 패턴이다.

가장 일반적인 싱글톤 패턴

public class 클래스 {
  private static 클래스 singleton = null;
  
  private 클래스() { }
  
  public static 클래스 getInstance() {
  	if(singleton == null) {
      singleton = new 클래스();
    }
    return singleton;
  }
}

생성자를 호출 못 하게 하고, getInstance() 메소드로 매번 새로운 객체를 생성하지 않고 이미 만들어진 인스턴스가 없을 경우에만 생성하여 리턴하는 방식이다.

클래스 singleton = 클래스.getInstance(); 를 통해 싱글톤 객체 호출

이 방식의 문제점은
싱글 스레드에서는 문제가 없지만, 멀티 스레드 환경에서는 여러 스레드가 동시에 접근하게 된 경우 동시에 싱글톤 인스턴스가 null로 판단되어 동시에 인스턴스를 생성하게 되는 경우가 발생할 수 있다는 것이다.

멀티 스레드 환경에서 싱글톤 패턴을 어떻게 사용해야 할 지 보기 전에, 싱글톤 패턴을 사용했던 예시를 먼저 보려고 한다.


싱글톤 패턴 사용 예시

: MVC 패턴 CRUD 게시판 구현 시 DB Connection에서 사용한 예시

DBConnection 클래스
DBConnection 클래스가 싱글톤 패턴에 해당한다.
DB 연결을 서비스(로그인, 상세정보 등)을 실행할 때마다 DB 연동을 위해 연결을 하는 것이 아니라, DB 연결을 공유하여 재사용성을 높이기 위한 것이다.

public class DBConnection {
    private static Connection conn;
    private static String url = "jdbc:oracle:thin:@localhost:1521:xe";
    private static String user = "user01";
    private static String password = "1234";
    private static String driver = "oracle.jdbc.driver.OracleDriver";
    public static Connection getInstance() throws Exception {
        if(conn==null || conn.isClosed()){
            Class.forName(driver);
            conn = DriverManager.getConnection(url,user,password);
        }
        return conn;
    }
}
  • 싱글톤 패턴 + connection pool을 많이 한다.
  • db 접속을 1개의 객체가 다 처리하면 다른 유저의 요청이 완료될 때까지 쿼리실행을 기다린다(db의 접속 1개가 동시에 sql실행을 못한다.)
  • db접속 10개 정도 미리 만들어 놓고 그것을 돌려가면서 사용하는 것 = connection pool

Controller

@WebServlet("/user/login.do")
public class UsersLoginController extends HttpServlet {
    //login form - 로그인 폼 페이지에 접속 (login.jsp로 포워딩)
    @Override 
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        req.getRequestDispatcher("/WEB-INF/templates/user/login.jsp").forward(req, resp);
    }

    //login action - 아이디, 비밀번호 입력 후 로그인 버튼 클릭하여 로그인 처리
    @Override 
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // login form에서 입력한 아이디와 비밀번호 가져오기
        String uId = req.getParameter("uId");
        String pw = req.getParameter("password");
        
        // Service의 로그인 처리하는 메소드 호출
        UsersDto loginUser = null;
        UsersService usersService = new UsersServiceImp();
        loginUser = usersService.login(uId,pw);

        if(loginUser==null) resp.sendRedirect("./login.do"); //회원정보가 없으면 다시 로그인 페이지
        else {
            HttpSession session = req.getSession(); //클라이언트 서버에 요청(접속)하면 세션 객체를 만들고 유지(30분)
                            //다시(30분안에) 해당 클라이언트가 서버에 요청하면 동적리소스에 클라이언트와 대응되는 세션 객체를 전달
            session.setAttribute("loginUser", loginUser);
            resp.sendRedirect(req.getContextPath()+"/"); //회원정보가 있으면 index페이지
        }
    }
}

Service

public interface UsersService {
    //로그인, 개인정보 조회, 개인정보 수정, 회원가입, 회원탈퇴, 회원조회리스트(팔로우)
    // service : 유저에게 제공되는 서비스 단위 (==transaction)
    UsersDto login(String uId, String pw); //로그인
    UsersDto detail(String uId); //개인정보 조회
    int modify(UsersDto user); //개인정보 수정
    int signup(UsersDto user); //회원가입
    int remove(String uId, String pw); //회원탈퇴
}

ServiceImpl

public class UsersServiceImp implements UsersService {
    //Connection conn : service 에서 사용하면 commit, rollback 같은 TCL 사용 가능
    // => service 는 DB를 소비하는 마지막 주체
    private UsersDao usersDao = null;
    private Connection conn = null;
    public UsersServiceImp(){
        try {
            conn = DBConnection.getInstance();
            usersDao = new UsersDaoImp(conn);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    
    @Override
    public UsersDto login(String uId, String pw){
        UsersDto login = null;
        try {
            login = usersDao.findByUIdAndPw(uId, pw);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return login;
    }
	...
}

DAO와 DAOImpl, DTO 까지 많은 코드를 가져오지 않고, 싱글톤 패턴을 이해하기 위해 필요한 부분만 가져왔다.

  • Controller 코드를 보면, 로그인 폼에서 로그인 버튼을 누르면 로그인 처리를 위해 Service의 login메소드를 호출한다.
    (loginUser = usersService.login(uId,pw);)
  • Service의 login메소드를 호출하면, Service 인터페이스를 구현하고 있는 ServiceImp 클래스의 login메소드가 호출된다. (=메소드 오버라이딩)
  • ServiceImp 클래스의 메소드가 호출될 때, 생성자(public UsersServiceImp() { })에서 DBConnection.getInstance()로 싱글톤 패턴인 DBConnection 인스턴스를 가져온다.
  • 이후 재정의된 login메소드가 실행되면서 DAO를 통해 DB 작업을 처리하고 DTO 형태로 리턴하여 Controller로 돌아와서, 존재하는 회원이면 session 객체를 만들고 로그인 상태로 처리된다.

멀티 스레드 환경에서의 싱글톤 패턴

synchronized 메소드로 선언하기

public class 클래스 {
  private static 클래스 singleton = null;
  
  private 클래스() { }
  
  public static synchronized 클래스 getInstance() {
    if (singleton == null) {
      singleton = new 클래스();
    }
    return singleton;
  }
}

getInstance() 메소드를 synchronized로 동기화 메소드로 선언하여, 한 스레드가 getInstance() 메소드를 실행할 경우, 다른 스레드가 클래스에 접근하지 못하도록 하여 동시에 생성되는 것을 막을 수 있다.

문제는 getInstance() 메소드를 호출할 때마다 lock이 걸려 이미 인스턴스가 생성된 이후에도 계속 lock이 걸리게 되고 오버헤드가 발생한다는 점입니다.

DCL(Double Checked Locking)기법 사용하기

public class 클래스 {
  private static 클래스 singleton = null;
  
  private 클래스() { }
  
  public static 클래스 getInstance() {
    if (singleton == null) {
      synchronized(클래스.class){
        if (singleton == null) {
          singleton = new 클래스();
        }
      }
    }
    return singleton;
  }
}

DCL기법은 Double Checked 즉, 두 번 체크한다는 의미이다.
getInstance() 메소드를 호출할 때마다 lock을 거는 것이 아닌, getInstance() 메소드를 호출할 때 null이면 lock을 걸어서 한 번 더 null인지 체크하고 인스턴스를 생성한다.

자바에서는 여러 스레드를 사용할 경우, 성능을 위해 각각의 스레드는 변수를 메인 메모리로부터 가져오는 것이 아니라 캐시 메모리에서 가져오게 되는데,
이 과정에서 비동기로 각 스레드마다 할당되어 있는 캐시 메모리의 변수값이 일치하지 않을 수도 있다는 것이다.
따라서 이 방식은 권고되지 않는다.

DCL + volatile 키워드 추가하기

public class 클래스 {
  private volatile static 클래스 singleton = null; //volatile키워드 적용
  
  private 클래스() { }
  
  public static 클래스 getInstance() {
    if (singleton == null) {
      synchronized(클래스.class){
        if (singleton == null) {
          singleton = new 클래스();
        }
      }
    }
    return singleton;
  }
}

volatile 키워드로 선언하여, singleton 변수에 값을 할당하거나 수정할 때 캐시 메모리에 사용하지 않고 메인 메모리에서 사용하게 한다.

문제는 volatile 키워드가 JVM 1.5 이상이어야 하고, 심층적 이해가 필요하여 사용하는 것을 거의 지양한다고 한다.
또한, volatile 키워드는 변수의 가시성과 순서에 관련된 문제를 해결해주지만, 원자성(atomicity)에는 영향을 주지 않는다. 따라서 원자적인 연산이 필요한 경우에는 volatile 키워드만으로는 충분하지 않으며, 별도의 동기화 메커니즘을 사용해야 한다.

volatile 키워드
: 다중 스레드 환경에서 변수의 가시성(visibility)과 순서(ordering)에 관련된 동작을 조정하는 데 사용되는 키워드

  • volatile 키워드가 사용된 변수는 메인 메모리(Main Memory)에 쓰여지고 읽히는 특성을 가진다.
  • 변수의 값을 읽거나 수정할 때 CPU 캐시(CPU Cache)를 거치지 않고 항상 메인 메모리의 값을 직접 사용하게 됨을 의미한다.
  • 이로 인해, 변수의 변경 사항이 다른 스레드에 즉시 반영되고, 스레드 간의 일관성을 유지할 수 있다.

원자성
: 연산이 독립적으로 실행되는 것처럼 보이는 것을 의미

  • 원자적인 연산은 중간에 다른 스레드에 의해 간섭받지 않고 완전히 수행되거나 아예 수행되지 않아야 한다.
  • volatile 키워드는 변수에 대한 연산이 원자적으로 수행되지 않을 수 있다. 예를 들어, volatile 변수에 값을 읽고 수정하는 연산을 두 개의 스레드가 동시에 수행한다면, 서로 겹쳐서 연산이 이루어질 수 있다. 이로 인해 원자성이 깨지게 된다.
  • 원자적인 연산이 필요한 경우에는 volatile 키워드만으로는 충분하지 않으며, 동기화 메커니즘인 synchronized 키워드나 Lock 인터페이스를 사용하여 원자적인 연산을 보장해야 한다.

static 초기화 하기

public class 클래스 {
  private static 클래스 singleton = new 클래스(); //static으로 미리 객체를 생성해서 초기화
  
  private 클래스() { }
  
  public static 클래스 getInstance() {
    return singleton;
  }
}

static 으로 초기화함으로써, 클래스가 로딩될 때 미리 객체를 생성하고 private으로 외부에서 정적 필드값을 변경하지 못하도록 막음으로써, 위의 문제들이 해결된다!

문제는 클래스가 로딩된 후 메모리에 자리를 계속 차지하게 되는데, 이 객체를 사용하지 않더라도 불필요한 자리를 차지하게 된다는 문제가 발생한다.

LazyHolder 방식 사용하기

public class 클래스 {
  private 클래스() { }
  
  public static 클래스 getInstance() {
    return LazyHolder.INSTANCE;
  }
  
  private static class LazyHolder { //내부 클래스 안에서 객체를 생성
    private static final 클래스 INSTANCE = new 클래스();
  }

LazyHolder 방식은 LazyHolder라는 내부 정적 클래스를 선언함으로써, getInstance() 메소드를 호출할 때 내부 클래스의 상수인 INSTANCE를 호출하여 객체를 생성한다.
이 방식은 static으로 초기화하여 클래스가 로딩되어 메모리를 계속 차지하게 되는 방식보다 getInstance() 메소드를 호출 시 LazyHolder.INSTANCE 를 참조하게 되는 순간 내부 클래스가 로딩되며 인스턴스를 생성하게 되어 그 후에 메모리를 차지하게 된다.
즉, LazyHolder 클래스는 초기화되는 시점에는 사용되지 않으며, getInstance() 메소드가 호출될 때 비로소 로드된다.

클래스가 로딩되고 초기화되는 시점은 스레드 safety가 보장되는 시점이다!!
스레드의 동시성 문제가 해결된다는 장점이 있다. 따라서 synchronizedvolatile키워드가 없어도 멀티 스레드 문제에서 안전하다!

가장 안전해 보이지만 단점이 존재한다.
Reflection API에 취약하고, 클라이언트가 임의로 직렬화/역직렬화하는 경우 싱글톤의 유일성이 깨질 수 있다는 단점이 있다.

  • 역직렬화 과정에서 새로운 인스턴스가 생성되는 문제가 발생할 수 있으므로, readResolve() 메서드를 구현하여 이를 방지해야 한다.

출처

profile
개발, 분석 배운 내용 정리하기!

0개의 댓글